mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 11:50:42 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -539,6 +539,9 @@ export class Config {
|
||||
}
|
||||
return 80;
|
||||
}
|
||||
armyLimitWarningThreshold(): number {
|
||||
return 0.8;
|
||||
}
|
||||
boatMaxNumber(): number {
|
||||
if (this.isUnitDisabled(UnitType.TransportShip)) {
|
||||
return 0;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user