Files
OpenFrontIO/src/core/game/UserSettings.ts
T
FloPinguin b0e7d04f6e 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
2026-06-10 17:00:24 -07:00

438 lines
13 KiB
TypeScript

import {
GraphicsOverrides,
GraphicsOverridesSchema,
} from "../../client/render/gl/GraphicsOverrides";
import { Cosmetics } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
return {
toggleView: "Space",
coordinateGrid: "KeyM",
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
retaliateAttack: "Shift+KeyR",
requestAlliance: "KeyK",
breakAlliance: "KeyL",
swapDirection: "KeyU",
zoomOut: "KeyQ",
zoomIn: "KeyE",
centerCamera: "KeyC",
moveUp: "KeyW",
moveLeft: "KeyA",
moveDown: "KeyS",
moveRight: "KeyD",
buildMenuModifier: isMac ? "MetaLeft" : "ControlLeft",
emojiMenuModifier: "AltLeft",
shiftKey: "ShiftLeft",
resetGfx: "KeyR",
selectAllWarships: "KeyF",
pauseGame: "KeyP",
gameSpeedUp: "Period",
gameSpeedDown: "Comma",
};
}
export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed";
/**
* Storage key for the player's selected territory cosmetic. Stores either
* `"pattern:<name>[:<palette>]"` or `"skin:<name>"` — patterns and skins are
* mutually exclusive, so they share one slot.
*/
export const PATTERN_KEY = "territoryPattern";
export const FLAG_KEY = "flag";
export const COLOR_KEY = "settings.territoryColor";
export const DARK_MODE_KEY = "settings.darkMode";
export const PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
export const KEYBINDS_KEY = "settings.keybinds";
export const GRAPHICS_KEY = "settings.graphics";
export class UserSettings {
private static cache = new Map<string, string | null>();
private emitChange(key: string, value: any): void {
try {
const maybeDispatch = (globalThis as any)?.dispatchEvent;
if (typeof maybeDispatch !== "function") return;
(globalThis as any).dispatchEvent(
new CustomEvent(`${USER_SETTINGS_CHANGED_EVENT}:${key}`, {
detail: value,
}),
);
} catch {
// Ignore - settings should still be applied even if event dispatch fails.
}
}
private getCached(key: string): string | null {
if (!UserSettings.cache.has(key)) {
UserSettings.cache.set(key, localStorage.getItem(key));
}
return UserSettings.cache.get(key) ?? null;
}
private setCached(key: string, value: string, emitChange: boolean = true) {
localStorage.setItem(key, value);
UserSettings.cache.set(key, value);
if (emitChange) {
this.emitChange(key, value);
}
}
public removeCached(key: string, emitChange: boolean = true) {
localStorage.removeItem(key);
UserSettings.cache.set(key, null);
if (emitChange) {
this.emitChange(key, null);
}
}
private getBool(key: string, defaultValue: boolean): boolean {
const value = this.getCached(key);
if (!value) return defaultValue;
if (value === "true") return true;
if (value === "false") return false;
return defaultValue;
}
private setBool(key: string, value: boolean) {
this.setCached(key, value ? "true" : "false");
}
private getString(key: string, defaultValue: string = ""): string {
const value = this.getCached(key);
if (value === null) return defaultValue;
return value;
}
private setString(key: string, value: string) {
this.setCached(key, value);
}
private getFloat(key: string, defaultValue: number): number {
const value = this.getCached(key);
if (!value) return defaultValue;
const floatValue = parseFloat(value);
if (isNaN(floatValue)) return defaultValue;
return floatValue;
}
private setFloat(key: string, value: number) {
this.setCached(key, value.toString());
}
emojis() {
return this.getBool("settings.emojis", true);
}
performanceOverlay() {
return this.getBool(PERFORMANCE_OVERLAY_KEY, false);
}
alertFrame() {
return this.getBool("settings.alertFrame", true);
}
anonymousNames() {
return this.getBool("settings.anonymousNames", false);
}
lobbyIdVisibility() {
return this.getBool("settings.lobbyIdVisibility", true);
}
darkMode() {
return this.getBool(DARK_MODE_KEY, false);
}
leftClickOpensMenu() {
return this.getBool("settings.leftClickOpensMenu", false);
}
territoryPatterns() {
return this.getBool("settings.territoryPatterns", true);
}
goToPlayer() {
return this.getBool("settings.goToPlayer", true);
}
attackingTroopsOverlay() {
return this.getBool("settings.attackingTroopsOverlay", true);
}
toggleAttackingTroopsOverlay() {
this.setBool(
"settings.attackingTroopsOverlay",
!this.attackingTroopsOverlay(),
);
}
cursorCostLabel() {
const legacy = this.getBool("settings.ghostPricePill", true);
return this.getBool("settings.cursorCostLabel", legacy);
}
toggleLeftClickOpenMenu() {
this.setBool("settings.leftClickOpensMenu", !this.leftClickOpensMenu());
}
toggleEmojis() {
this.setBool("settings.emojis", !this.emojis());
}
// Performance overlay specifically needs a direct setter for Shift-D
setPerformanceOverlay(value: boolean) {
this.setBool(PERFORMANCE_OVERLAY_KEY, value);
}
togglePerformanceOverlay() {
this.setBool(PERFORMANCE_OVERLAY_KEY, !this.performanceOverlay());
}
toggleAlertFrame() {
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());
}
toggleLobbyIdVisibility() {
this.setBool("settings.lobbyIdVisibility", !this.lobbyIdVisibility());
}
toggleCursorCostLabel() {
this.setBool("settings.cursorCostLabel", !this.cursorCostLabel());
}
toggleTerritoryPatterns() {
this.setBool("settings.territoryPatterns", !this.territoryPatterns());
}
toggleGoToPlayer() {
this.setBool("settings.goToPlayer", !this.goToPlayer());
}
toggleDarkMode() {
this.setBool(DARK_MODE_KEY, !this.darkMode());
}
// For development only. Used for testing patterns, set in the console manually.
getDevOnlyPattern(): PlayerPattern | undefined {
const data = localStorage.getItem("dev-pattern") ?? undefined;
if (data === undefined) return undefined;
return {
name: "dev-pattern",
patternData: data,
colorPalette: {
name: "dev-color-palette",
primaryColor: localStorage.getItem("dev-primary") ?? "#ffffff",
secondaryColor: localStorage.getItem("dev-secondary") ?? "#000000",
},
} satisfies PlayerPattern;
}
getSelectedPatternName(cosmetics: Cosmetics | null): PlayerPattern | null {
if (cosmetics === null) return null;
let data = this.getCached(PATTERN_KEY);
if (data === null) return null;
// Skin selections share this key — defer to getSelectedSkinName.
if (data.startsWith("skin:")) return null;
const patternPrefix = "pattern:";
// Accept both `pattern:<name>[:<palette>]` (current) and bare `<name>[:<palette>]`
// (older builds wrote unprefixed) so existing localStorage values still resolve.
if (data.startsWith(patternPrefix)) {
data = data.slice(patternPrefix.length);
}
const [patternName, colorPalette] = data.split(":");
const pattern = cosmetics.patterns[patternName];
if (pattern === undefined) return null;
return {
name: patternName,
patternData: pattern.pattern,
colorPalette: cosmetics.colorPalettes?.[colorPalette],
} satisfies PlayerPattern;
}
/**
* Accepts a fully-prefixed cosmetic value: `"pattern:<name>[:<palette>]"`
* or `"skin:<name>"`. Patterns and skins share storage because they're
* mutually exclusive — writing one automatically clears the other.
*/
setSelectedPatternName(value: string | undefined): void {
if (value === undefined) {
this.removeCached(PATTERN_KEY);
} else {
this.setCached(PATTERN_KEY, value);
}
}
/** Returns the bare skin name (no `skin:` prefix), or null if a pattern (or nothing) is selected. */
getSelectedSkinName(): string | null {
const data = this.getCached(PATTERN_KEY);
if (data === null) return null;
const skinPrefix = "skin:";
return data.startsWith(skinPrefix) ? data.slice(skinPrefix.length) : null;
}
getFlag(): string | null {
let flag = this.getCached(FLAG_KEY);
if (!flag) return null;
// Migrate bare country codes to country: prefix
if (!flag.startsWith("flag:") && !flag.startsWith("country:")) {
flag = `country:${flag}`;
// Silent migration: don't emit change event for FlagInput
this.setCached(FLAG_KEY, flag, false);
}
return flag;
}
setFlag(flag: string): void {
if (flag === "country:xx") {
this.clearFlag(true);
} else {
this.setCached(FLAG_KEY, flag);
}
}
clearFlag(emitChange: boolean = false): void {
this.removeCached(FLAG_KEY, emitChange);
}
backgroundMusicVolume(): number {
return this.getFloat("settings.backgroundMusicVolume", 0);
}
setBackgroundMusicVolume(volume: number): void {
this.setFloat("settings.backgroundMusicVolume", volume);
}
// What % attack ratio increments per click/scroll
attackRatioIncrement(): number {
const increment = Math.round(
this.getFloat("settings.attackRatioIncrement", 10),
);
if (!Number.isFinite(increment) || increment <= 0) return 10;
return increment;
}
setAttackRatioIncrement(value: number): void {
this.setFloat("settings.attackRatioIncrement", value);
}
// What % attack ratio is set to
attackRatio(): number {
return this.getFloat("settings.attackRatio", 0.2);
}
setAttackRatio(value: number): void {
this.setFloat("settings.attackRatio", value);
}
// Returns {} if missing, unparseable, or fails schema validation.
graphicsOverrides(): GraphicsOverrides {
const raw = this.getString(GRAPHICS_KEY, "");
if (!raw) return {};
try {
const parsed = GraphicsOverridesSchema.safeParse(JSON.parse(raw));
if (parsed.success) return parsed.data;
} catch {
// fall through
}
return {};
}
setGraphicsOverrides(value: GraphicsOverrides): void {
this.setString(GRAPHICS_KEY, JSON.stringify(value));
}
// In case localStorage was manually edited to be invalid, return an empty object
parsedUserKeybinds(): Record<string, any> {
const raw = this.getString(KEYBINDS_KEY, "{}");
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed;
}
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
return {};
}
// Returns a flat keybind map { action: "keyCode" }, handling nested objects and legacy strings
private normalizedUserKeybinds(): Record<string, string> {
const parsed = this.parsedUserKeybinds();
return Object.fromEntries(
Object.entries(parsed)
// Extract value from nested object or plain string, filter out non-string values
.map(([k, v]) => {
let val = v;
if (v && typeof v === "object" && !Array.isArray(v) && "value" in v) {
val = v.value;
}
if (Array.isArray(val) && typeof val[0] === "string") {
val = val[0];
}
return [k, val];
})
.filter(([, v]) => typeof v === "string"),
) as Record<string, string>;
}
keybinds(isMac: boolean): Record<string, string> {
const merged = {
...getDefaultKeybinds(isMac),
...this.normalizedUserKeybinds(),
};
// Actually unbind key: if Unbind is clicked in UserSettingsModal, eg. for Attack Ratio Up,
// keybind is "Null". Even if it is in default kindbinds (Y), it should not work anymore.
// The key (Y) can now be bound to another action like Boat Attack, and no two actions listen to the same key.
for (const k in merged) {
if (merged[k] === "Null") {
delete merged[k];
}
}
return merged;
}
setKeybinds(value: string | Record<string, any>): void {
if (typeof value === "string") {
this.setString(KEYBINDS_KEY, value);
} else {
this.setString(KEYBINDS_KEY, JSON.stringify(value));
}
}
soundEffectsVolume(): number {
return this.getFloat("settings.soundEffectsVolume", 0);
}
setSoundEffectsVolume(volume: number): void {
this.setFloat("settings.soundEffectsVolume", volume);
}
}