Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
166 changes: 161 additions & 5 deletions src/client/hud/layers/ControlPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should also do a check like: getGamesPlayed() < 10. Since we don't want to start showing this to veteran players.

@FloPinguin FloPinguin Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I didn't even knew that we are tracking that in localstorage
It says 977 in my prod localstorage, crazy

I added it

But I went with 20, 10 games are very quickly over if you are a noob


// 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) {
Expand Down Expand Up @@ -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" };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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) {
Expand Down Expand Up @@ -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 -->
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/client/hud/layers/SettingsModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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}"
Expand Down
3 changes: 3 additions & 0 deletions src/core/configuration/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,9 @@ export class Config {
}
return 80;
}
armyLimitWarningThreshold(): number {
return 0.8;
}
Comment thread
FloPinguin marked this conversation as resolved.
boatMaxNumber(): number {
if (this.isUnitDisabled(UnitType.TransportShip)) {
return 0;
Expand Down
8 changes: 8 additions & 0 deletions src/core/game/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Comment thread
FloPinguin marked this conversation as resolved.

toggleRandomName() {
this.setBool("settings.anonymousNames", !this.anonymousNames());
}
Expand Down
Loading