diff --git a/resources/lang/en.json b/resources/lang/en.json index 47eb4f2d77..42d34ab196 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -808,6 +808,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", @@ -1388,6 +1390,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 234a67f6f1..f8416017fc 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()}
+ +