mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 19:20:18 +00:00
## Description: Adds a **"Highlight small players"** client setting (in the in-game Settings menu). When it's on, human players holding **0.2% or less of the map** get a soft red glow that breathes (a pulsing aura) around their territory, starting **one minute into the game**. This makes near-eliminated players easy to spot on a busy map, even when their territory is scattered in fragments or sitting under structures. How it works: - **Purely client-side**, no simulation or determinism impact. `SmallPlayerGlowPass` renders a tile-space bloom: extract a sub-tile mask of the small players' tiles, run a separable Gaussian blur, then composite the soft aura over the map additively. It's camera-independent (no shimmer when panning/zooming), so scattered tiles blur into one clean halo. The aura breathes: its intensity fades fully to 0 at the trough for clear contrast. - The set is recomputed each tick in `WebGLFrameBuilder`: alive human players with `tilesOwned / (landTiles - fallout) <= 0.002` (0.2%), suppressed during the spawn phase and the first minute. - Backed by a persisted `UserSettings` flag, toggleable live from the in-game Settings modal (with a new, distinct icon). - Drawn after the structure passes so buildings can't hide it. - Mirrors the existing FalloutBloom pipeline and reuses the shared blur shader and render-target helpers rather than duplicating them. Tunable via `render-settings.json` (`smallPlayerGlow`: color / alpha / pulseSpeed). UI (glow fades in and out): <img width="474" height="334" alt="image" src="https://github.com/user-attachments/assets/2b94f292-2cbb-43d3-82fb-f274d1afdedf" /> <img width="976" height="400" alt="image" src="https://github.com/user-attachments/assets/f1b972d7-e046-4f7a-ab43-da1eb758c7b5" /> ## 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: zixer._
472 lines
14 KiB
TypeScript
472 lines
14 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",
|
|
altKey: "AltLeft",
|
|
};
|
|
}
|
|
|
|
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 PERFORMANCE_OVERLAY_KEY = "settings.performanceOverlay";
|
|
export const KEYBINDS_KEY = "settings.keybinds";
|
|
export const GRAPHICS_KEY = "settings.graphics";
|
|
export const EFFECTS_KEY = "settings.effects";
|
|
|
|
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);
|
|
}
|
|
|
|
highlightSmallPlayers() {
|
|
return this.getBool("settings.highlightSmallPlayers", false);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
toggleHighlightSmallPlayers() {
|
|
this.setBool(
|
|
"settings.highlightSmallPlayers",
|
|
!this.highlightSmallPlayers(),
|
|
);
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* Selected effect cosmetics, keyed by selection slot (at most one per slot).
|
|
* A slot is the effectType for trails and the nukeType for nuke explosions —
|
|
* see effectTypeForSlot. Persisted as a single JSON blob under EFFECTS_KEY.
|
|
*/
|
|
getSelectedEffects(): Record<string, string> {
|
|
const raw = this.getString(EFFECTS_KEY, "");
|
|
if (!raw) return {};
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
? parsed
|
|
: {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
getSelectedEffectName(slot: string): string | null {
|
|
return this.getSelectedEffects()[slot] ?? null;
|
|
}
|
|
|
|
setSelectedEffectName(slot: string, name: string | undefined): void {
|
|
const map = this.getSelectedEffects();
|
|
if (name === undefined) delete map[slot];
|
|
else map[slot] = name;
|
|
if (Object.keys(map).length === 0) this.removeCached(EFFECTS_KEY);
|
|
else this.setString(EFFECTS_KEY, JSON.stringify(map));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|