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 { 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(); 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> { 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 { 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 { 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): 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); } }