From b0e7d04f6e69e1bc53ab0f21a660b5ea844ec648 Mon Sep 17 00:00:00 2001 From: FloPinguin <25036848+FloPinguin@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:00:24 +0200 Subject: [PATCH] =?UTF-8?q?Add=20help=20notification=20system=20to=20contr?= =?UTF-8?q?ol=20panel=20=E2=84=B9=EF=B8=8F=20(#4212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) 582494157-cf19b13e-a0a9-44e4-8de8-86c007fe9c79 **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. image New "Help Messages" toggle in settings (default: on) image 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 --- resources/lang/en.json | 9 ++ src/client/hud/layers/ControlPanel.ts | 166 ++++++++++++++++++++++++- src/client/hud/layers/SettingsModal.ts | 46 +++++++ src/core/configuration/Config.ts | 3 + src/core/game/UserSettings.ts | 8 ++ 5 files changed, 227 insertions(+), 5 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index 6ff046312..5c0be6cf4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", diff --git a/src/client/hud/layers/ControlPanel.ts b/src/client/hud/layers/ControlPanel.ts index 234a67f6f..f8416017f 100644 --- a/src/client/hud/layers/ControlPanel.ts +++ b/src/client/hud/layers/ControlPanel.ts @@ -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 = new Set(); + private _borderRefreshCounter: number = 0; + private _borderTilesPromise: Promise | null = null; + // Track last attack tick per target player (for 15-second threshold) + private _lastAttackTickByTarget: Map = 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(); + 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` +
+ ${isWarning ? "⚠" : "ℹ"} + ${translateText(this._notification.message)} +
+ `; + } + private renderDesktop() { return html` + ${this.renderNotification()}
@@ -396,6 +551,7 @@ export class ControlPanel extends LitElement implements Controller { private renderMobile() { return html` + ${this.renderNotification()}
+ +