Add help notification system to control panel ℹ️ (#4212)

Resolves https://github.com/openfrontio/OpenFrontIO/issues/3445

## Description:

I copied the PR #3743 from @luctrate (Add army limit warning indicator
for team games) to this PR because he didn't respond to requested
changes but I thought it's important.

I expanded on it, now its a full help message system:

**Warnings (orange):**
- Army limit: shown in team games with donations when troops exceed 80%
of max
- Low troops: shown when troops drop below 1k (=> new noob player who
clicks too much)

<img width="764" height="251"
alt="582494157-cf19b13e-a0a9-44e4-8de8-86c007fe9c79"
src="https://github.com/user-attachments/assets/6b4996d9-1993-4d2c-98ba-afba17a5ca4d"
/>

**Info messages (blue):**
- Borders a traitor ally: "You can betray traitors without becoming a
traitor yourself" (Because its not obvious for new players)
- Borders an allied AFK player: "You can attack disconnected players
even if you are allied with them" (Because its not obvious for new
players)
- Borders an AFK teammate: "You can attack disconnected teammates"
(Because its not obvious for new players)

Info messages only appear when the player has not attacked the relevant
neighbor for at least 15 seconds, so they do not show up without reason.

<img width="524" height="141" alt="image"
src="https://github.com/user-attachments/assets/88d74661-d47e-45a7-9f91-d4f5361114b7"
/>

New "Help Messages" toggle in settings (default: on)

<img width="409" height="105" alt="image"
src="https://github.com/user-attachments/assets/24bc8bed-777b-4f72-9451-02116ac39db0"
/>

Implementation details:
- Border detection uses async borderTiles() refreshed every 1s, cached
in a Set of nearby player smallIDs
- Outgoing attacks are tracked per-target to compute the 15-second idle
threshold
- New armyLimitWarningThreshold() on Config (returns 0.8)
- All user-facing strings go through translateText() with en.json
entries

AI Model used: MiMo 2.5 Pro

## Please complete the following:

- [X] I have added screenshots for all UI updates
- [X] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [X] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

FloPinguin
This commit is contained in:
FloPinguin
2026-06-11 02:00:24 +02:00
committed by GitHub
parent 000f1442c4
commit b0e7d04f6e
5 changed files with 227 additions and 5 deletions
+9
View File
@@ -813,6 +813,8 @@
"emojis_desc": "Toggle whether emojis are shown in game",
"alert_frame_label": "Alert Frame",
"alert_frame_desc": "Toggle the alert frame. When enabled, the frame will be displayed when you are betrayed or attacked over land.",
"help_messages_label": "Help Messages",
"help_messages_desc": "Show contextual tips and warnings during gameplay, such as army limit warnings and general gameplay advice.",
"special_effects_label": "Special effects",
"special_effects_desc": "Toggle special effects. Deactivate to improve performances",
"cursor_cost_label_label": "Cursor Build Cost",
@@ -1391,6 +1393,13 @@
"go_to_item": "Go to item {num}",
"firefox_warning": "OpenFront.io doesn't perform well with [Firefox-based browsers](https://simple.wikipedia.org/wiki/Web_browsers_based_on_Firefox). We recommend you to use a [Chromium-based browser](https://en.wikipedia.org/wiki/Chromium_(web_browser)#Browsers_based_on_Chromium) for best performance."
},
"control_panel": {
"army_limit_warning": "You're near your army limit! Consider sending troops to teammates.",
"traitor_neighbor_info": "You can betray traitors without becoming a traitor yourself.",
"allied_afk_neighbor_info": "You can attack disconnected players even if you are allied with them.",
"teammate_afk_neighbor_info": "You can attack disconnected teammates.",
"low_troops_warning": "You are very low on troops - You should always keep some troops for defense."
},
"ios_banner": {
"text": "For fullscreen, add OpenFront to your Home Screen",
"how": "How",
+161 -5
View File
@@ -3,15 +3,23 @@ import { customElement, state } from "lit/decorators.js";
import { keyed } from "lit/directives/keyed.js";
import { assetUrl } from "../../../core/AssetUrls";
import { EventBus } from "../../../core/EventBus";
import { Gold } from "../../../core/game/Game";
import { ClientID } from "../../../core/Schemas";
import { Config } from "../../../core/configuration/Config";
import { GameMode, GameType, Gold } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { ClientID } from "../../../core/Schemas";
import { Controller } from "../../Controller";
import { AttackRatioEvent } from "../../InputHandler";
import { UIState } from "../../UIState";
import { renderNumber, renderTroops } from "../../Utils";
import {
getGamesPlayed,
renderNumber,
renderTroops,
translateText,
} from "../../Utils";
import { PlayerView } from "../../view/PlayerView";
const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg");
const soldierIcon = assetUrl("images/SoldierIcon.svg");
const swordIcon = assetUrl("images/SwordIcon.svg");
@@ -38,6 +46,10 @@ export class ControlPanel extends LitElement implements Controller {
@state()
private _isVisible = false;
@state()
private _notification: { type: "warning" | "info"; message: string } | null =
null;
@state()
private _gold: Gold;
@@ -54,6 +66,15 @@ export class ControlPanel extends LitElement implements Controller {
private _lastTroopIncreaseRate: number;
// Border detection cache
private _nearbyPlayerIDs: Set<number> = new Set();
private _borderRefreshCounter: number = 0;
private _borderTilesPromise: Promise<void> | null = null;
// Track last attack tick per target player (for 15-second threshold)
private _lastAttackTickByTarget: Map<number, number> = new Map();
private static readonly BORDER_REFRESH_INTERVAL = 10; // recompute every 1s
private static readonly ATTACK_THRESHOLD_TICKS = 15 * 10; // 15 seconds
init() {
this.attackRatio = new UserSettings().attackRatio();
this.uiState.attackRatio = this.attackRatio;
@@ -91,14 +112,29 @@ export class ControlPanel extends LitElement implements Controller {
this.updateTroopIncrease();
this._maxTroops = this.game.config().maxTroops(player);
const config = this.game.config();
this._maxTroops = config.maxTroops(player);
this._gold = player.gold();
this._troops = player.troops();
this._attackingTroops = player
.outgoingAttacks()
.map((a) => a.troops)
.reduce((a, b) => a + b, 0);
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;
this.troopRate = config.troopIncreaseRate(player) * 10;
const helpEnabled = new UserSettings().helpMessages();
// Don't target veteran players
if (helpEnabled && getGamesPlayed() < 20) {
// Track outgoing attacks for 15-second threshold
this.trackOutgoingAttacks(player);
// Refresh border detection cache periodically
this.refreshNearbyPlayers(player);
// Compute notification
this._notification = this.computeNotification(player, config);
}
const updates = this.game.updatesSinceLastTick();
if (updates) {
@@ -151,6 +187,109 @@ export class ControlPanel extends LitElement implements Controller {
}, 2000);
}
private trackOutgoingAttacks(player: PlayerView) {
const currentTick = this.game.ticks();
for (const attack of player.outgoingAttacks()) {
if (attack.targetID !== 0 && !attack.retreating) {
this._lastAttackTickByTarget.set(attack.targetID, currentTick);
}
}
// Clean up old entries
for (const [playerID, tick] of this._lastAttackTickByTarget.entries()) {
if (currentTick - tick > ControlPanel.ATTACK_THRESHOLD_TICKS * 2) {
this._lastAttackTickByTarget.delete(playerID);
}
}
}
private refreshNearbyPlayers(player: PlayerView) {
this._borderRefreshCounter++;
if (
this._borderRefreshCounter < ControlPanel.BORDER_REFRESH_INTERVAL ||
this._borderTilesPromise !== null
) {
return;
}
this._borderRefreshCounter = 0;
this._borderTilesPromise = player.borderTiles().then((bt) => {
this._borderTilesPromise = null;
const myID = player.smallID();
const nearby = new Set<number>();
for (const tile of bt.borderTiles) {
for (const neighbor of this.game.neighbors(tile as TileRef)) {
const ownerID = this.game.ownerID(neighbor);
if (ownerID !== 0 && ownerID !== myID) {
nearby.add(ownerID);
}
}
}
this._nearbyPlayerIDs = nearby;
});
}
private computeNotification(
player: PlayerView,
config: Config,
): { type: "warning" | "info"; message: string } | null {
const currentTick = this.game.ticks();
// Army limit warning
const { gameMode, gameType } = config.gameConfig();
const isPublicTeamGame =
gameMode === GameMode.Team && gameType === GameType.Public;
const canDonateTroops = config.donateTroops();
if (isPublicTeamGame && canDonateTroops) {
const ratio = this._troops / Math.max(this._maxTroops, 1);
if (ratio >= config.armyLimitWarningThreshold()) {
return {
type: "warning",
message: "control_panel.army_limit_warning",
};
}
}
// Low troops (Less than 1k) warning
if (this._troops < 10000 && this._troops > 0) {
return { type: "warning", message: "control_panel.low_troops_warning" };
}
// Info messages: check nearby players for traitors, AFK allies, AFK teammates
for (const nearbyID of this._nearbyPlayerIDs) {
let other;
try {
other = this.game.playerBySmallID(nearbyID);
} catch {
continue;
}
if (!other.isPlayer() || !other.isAlive()) continue;
const lastAttackTick = this._lastAttackTickByTarget.get(nearbyID) ?? -1;
const secondsSinceAttack = (currentTick - lastAttackTick) / 10;
const hasNotAttackedRecently =
lastAttackTick < 0 || secondsSinceAttack > 15;
if (!hasNotAttackedRecently) continue;
if (other.isTraitor() && player.isAlliedWith(other)) {
return { type: "info", message: "control_panel.traitor_neighbor_info" };
}
if (other.isDisconnected() && player.isAlliedWith(other)) {
return {
type: "info",
message: "control_panel.allied_afk_neighbor_info",
};
}
if (other.isDisconnected() && player.isOnSameTeam(other)) {
return {
type: "info",
message: "control_panel.teammate_afk_neighbor_info",
};
}
}
return null;
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._goldGainTimeoutId !== null) {
@@ -311,8 +450,24 @@ export class ControlPanel extends LitElement implements Controller {
`;
}
private renderNotification() {
if (!this._notification) return html``;
const isWarning = this._notification.type === "warning";
return html`
<div
class="flex items-center gap-1.5 px-1.5 py-1 rounded-md border text-xs font-medium mb-1 ${isWarning
? "border-orange-400/60 bg-orange-400/10 text-orange-300"
: "border-blue-400/60 bg-blue-400/10 text-blue-300"}"
>
<span class="shrink-0">${isWarning ? "⚠" : ""}</span>
<span>${translateText(this._notification.message)}</span>
</div>
`;
}
private renderDesktop() {
return html`
${this.renderNotification()}
<!-- Row 1: troop rate | troop bar | gold -->
<div class="flex gap-1.5 items-center mb-1">
<!-- Troop rate -->
@@ -396,6 +551,7 @@ export class ControlPanel extends LitElement implements Controller {
private renderMobile() {
return html`
${this.renderNotification()}
<div class="flex gap-2 items-center">
<!-- Gold -->
<div
+46
View File
@@ -136,6 +136,11 @@ export class SettingsModal extends LitElement implements Controller {
this.requestUpdate();
}
private onToggleHelpMessagesButtonClick() {
this.userSettings.toggleHelpMessages();
this.requestUpdate();
}
private onToggleDarkModeButtonClick() {
this.userSettings.toggleDarkMode();
this.eventBus.emit(new RefreshGraphicsEvent());
@@ -393,6 +398,47 @@ export class SettingsModal extends LitElement implements Controller {
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleHelpMessagesButtonClick}"
>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="10"
cy="10"
r="9"
stroke="white"
stroke-width="1.5"
/>
<path
d="M10 9V14"
stroke="white"
stroke-width="1.5"
stroke-linecap="round"
/>
<circle cx="10" cy="6.5" r="1" fill="white" />
</svg>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.help_messages_label")}
</div>
<div class="text-sm text-slate-400">
${translateText("user_setting.help_messages_desc")}
</div>
</div>
<div class="text-sm text-slate-400">
${this.userSettings.helpMessages()
? translateText("user_setting.on")
: translateText("user_setting.off")}
</div>
</button>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded-sm text-white transition-colors"
@click="${this.onToggleAttackingTroopsOverlayButtonClick}"
+3
View File
@@ -539,6 +539,9 @@ export class Config {
}
return 80;
}
armyLimitWarningThreshold(): number {
return 0.8;
}
boatMaxNumber(): number {
if (this.isUnitDisabled(UnitType.TransportShip)) {
return 0;
+8
View File
@@ -207,6 +207,14 @@ export class UserSettings {
this.setBool("settings.alertFrame", !this.alertFrame());
}
helpMessages() {
return this.getBool("settings.helpMessages", true);
}
toggleHelpMessages() {
this.setBool("settings.helpMessages", !this.helpMessages());
}
toggleRandomName() {
this.setBool("settings.anonymousNames", !this.anonymousNames());
}