Files
OpenFrontIO/src/core/game/UserSettings.ts
T
2026-04-22 01:17:49 +02:00

445 lines
14 KiB
TypeScript

import { Cosmetics } from "../CosmeticSchemas";
import { PlayerPattern } from "../Schemas";
// ADD Reserved keys here or in Utils.ts:
// (maybe also comment about Shift+left quick being reserved for future devs, see HelpModal, code in onPointerUp in InputHandler: when leftClickOpensMenu is false, Shift+left click is hardcoded to be attack. So it should not be used elsewhere where modifier+click is expected)
// Shift+D for performance overlay, Alt+R for reset gfx (NO not reserved anymore, can be changed with fallback removed), Escape for menu close and cancel ghost build, Enter for confirm Ghost build
// But also browser combos: Ctrl+Shift+I for dev tools, etc. Shift+Tab for backwards tabbing through fields. Alt+N for new browser screen, etc. If we won't just suppress Alt/Ctrl etc alltogether or if used in combination (but then confusing things could still happen)
// These keys won't be available for binding in UserSettingsModal.
export const KeyUnbound = "Null";
export enum KeybindAction {
toggleView = "toggleView",
coordinateGrid = "coordinateGrid",
buildCity = "buildCity",
buildFactory = "buildFactory",
buildPort = "buildPort",
buildDefensePost = "buildDefensePost",
buildMissileSilo = "buildMissileSilo",
buildSamLauncher = "buildSamLauncher",
buildWarship = "buildWarship",
buildAtomBomb = "buildAtomBomb",
buildHydrogenBomb = "buildHydrogenBomb",
buildMIRV = "buildMIRV",
attackRatioDown = "attackRatioDown",
attackRatioUp = "attackRatioUp",
boatAttack = "boatAttack",
groundAttack = "groundAttack",
breakAlliance = "breakAlliance",
requestAlliance = "requestAlliance",
swapDirection = "swapDirection",
zoomOut = "zoomOut",
zoomIn = "zoomIn",
centerCamera = "centerCamera",
moveUp = "moveUp",
moveLeft = "moveLeft",
moveDown = "moveDown",
moveRight = "moveRight",
buildMenuModifier = "buildMenuModifier",
emojiMenuModifier = "emojiMenuModifier",
shiftKey = "shiftKey",
resetGfx = "resetGfx",
selectAllWarships = "selectAllWarships",
pauseGame = "pauseGame",
gameSpeedUp = "gameSpeedUp",
gameSpeedDown = "gameSpeedDown",
}
export function getDefaultKeybinds(
isMac: boolean,
): Record<KeybindAction, string> {
return {
[KeybindAction.toggleView]: "Space",
[KeybindAction.coordinateGrid]: "KeyM",
[KeybindAction.buildCity]: "Digit1",
[KeybindAction.buildFactory]: "Digit2",
[KeybindAction.buildPort]: "Digit3",
[KeybindAction.buildDefensePost]: "Digit4",
[KeybindAction.buildMissileSilo]: "Digit5",
[KeybindAction.buildSamLauncher]: "Digit6",
[KeybindAction.buildWarship]: "Digit7",
[KeybindAction.buildAtomBomb]: "Digit8",
[KeybindAction.buildHydrogenBomb]: "Digit9",
[KeybindAction.buildMIRV]: "Digit0",
[KeybindAction.attackRatioDown]: "KeyT",
[KeybindAction.attackRatioUp]: "KeyY",
[KeybindAction.boatAttack]: "KeyB",
[KeybindAction.groundAttack]: "KeyG",
[KeybindAction.requestAlliance]: "KeyK",
[KeybindAction.breakAlliance]: "KeyL",
[KeybindAction.swapDirection]: "KeyU",
[KeybindAction.zoomOut]: "KeyQ",
[KeybindAction.zoomIn]: "KeyE",
[KeybindAction.centerCamera]: "KeyC",
[KeybindAction.moveUp]: "KeyW",
[KeybindAction.moveLeft]: "KeyA",
[KeybindAction.moveDown]: "KeyS",
[KeybindAction.moveRight]: "KeyD",
[KeybindAction.buildMenuModifier]: isMac ? "MetaLeft" : "ControlLeft",
[KeybindAction.emojiMenuModifier]: "AltLeft",
[KeybindAction.shiftKey]: "ShiftLeft",
[KeybindAction.resetGfx]: "KeyR",
[KeybindAction.selectAllWarships]: "KeyF",
[KeybindAction.pauseGame]: "KeyP",
[KeybindAction.gameSpeedUp]: "Period",
[KeybindAction.gameSpeedDown]: "Comma",
};
}
export const USER_SETTINGS_CHANGED_EVENT = "event:user-settings-changed";
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 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);
}
}
private 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);
}
fxLayer() {
return this.getBool("settings.specialEffects", true);
}
structureSprites() {
return this.getBool("settings.structureSprites", true);
}
darkMode() {
return this.getBool(DARK_MODE_KEY, false);
}
leftClickOpensMenu() {
return this.getBool("settings.leftClickOpensMenu", false);
}
territoryPatterns() {
return this.getBool("settings.territoryPatterns", 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());
}
toggleRandomName() {
this.setBool("settings.anonymousNames", !this.anonymousNames());
}
toggleLobbyIdVisibility() {
this.setBool("settings.lobbyIdVisibility", !this.lobbyIdVisibility());
}
toggleFxLayer() {
this.setBool("settings.specialEffects", !this.fxLayer());
}
toggleStructureSprites() {
this.setBool("settings.structureSprites", !this.structureSprites());
}
toggleCursorCostLabel() {
this.setBool("settings.cursorCostLabel", !this.cursorCostLabel());
}
toggleTerritoryPatterns() {
this.setBool("settings.territoryPatterns", !this.territoryPatterns());
}
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;
const patternPrefix = "pattern:";
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;
}
setSelectedPatternName(patternName: string | undefined): void {
if (patternName === undefined) {
this.removeCached(PATTERN_KEY);
} else {
this.setCached(PATTERN_KEY, patternName);
}
}
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);
}
// In case localStorage was manually edited to be invalid, return an empty object
parsedUserKeybinds(): Partial<Record<KeybindAction, 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: "code" }, handling nested objects and legacy strings
private normalizedUserKeybinds(): Record<KeybindAction, 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(([action, codeAndKey]) => {
let code = codeAndKey;
if (
codeAndKey &&
typeof codeAndKey === "object" &&
!Array.isArray(codeAndKey) &&
"value" in codeAndKey
) {
code = codeAndKey.value;
}
if (Array.isArray(code) && typeof code[0] === "string") {
code = code[0];
}
return [action, code];
})
.filter(([, code]) => typeof code === "string"),
);
}
keybinds(isMac: boolean): Record<KeybindAction, string> {
const mergedKeybinds = {
...getDefaultKeybinds(isMac),
...this.normalizedUserKeybinds(),
};
// Actually unbind key: if Unbind is clicked in UserSettingsModal, eg. for Attack Ratio Up,
// keybind is KeyUnbound. 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 action in mergedKeybinds) {
if (mergedKeybinds[action] === KeyUnbound) {
delete mergedKeybinds[action];
}
}
return mergedKeybinds;
}
setUserKeybinds(value: Record<string, any>): void {
this.setString(KEYBINDS_KEY, JSON.stringify(value));
}
removeUserKeybinds(emitChange: boolean = true): void {
this.removeCached(KEYBINDS_KEY, emitChange);
}
soundEffectsVolume(): number {
return this.getFloat("settings.soundEffectsVolume", 1);
}
setSoundEffectsVolume(volume: number): void {
this.setFloat("settings.soundEffectsVolume", volume);
}
}