diff --git a/resources/atlases/status-atlas-meta.json b/resources/atlases/status-atlas-meta.json index 32cebd1cc..9f93da03c 100644 --- a/resources/atlases/status-atlas-meta.json +++ b/resources/atlases/status-atlas-meta.json @@ -14,6 +14,7 @@ "embargo": 6, "nukeRed": 7, "nukeWhite": 8, - "allianceFaded": 9 + "allianceFaded": 9, + "doomsdayClock": 10 } } diff --git a/resources/atlases/status-atlas.png b/resources/atlases/status-atlas.png index 944dbf204..67bc65db9 100644 Binary files a/resources/atlases/status-atlas.png and b/resources/atlases/status-atlas.png differ diff --git a/resources/images/DoomsdayClockSkull.svg b/resources/images/DoomsdayClockSkull.svg new file mode 100644 index 000000000..e644cae15 --- /dev/null +++ b/resources/images/DoomsdayClockSkull.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + diff --git a/resources/lang/en.json b/resources/lang/en.json index bb908775f..3c5b5a299 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -400,6 +400,25 @@ "discord_user_header": { "avatar_alt": "Avatar" }, + "doomsday_clock": { + "collapsing": "Collapsing -{rate}/s", + "decay_in": "Decay in {secs}s", + "final": "Final zone, hold {pct}%", + "growing": "Rising to {pct}%", + "hold": "Hold ≥ {pct}%", + "next_wave": "Next {pct}% in {time}", + "stable": "Stable", + "title": "Doomsday Clock", + "unstable": "Unstable", + "you": "You {pct}%", + "your_team": "{team}: {pct}%" + }, + "doomsday_clock_speed": { + "fast": "Fast", + "normal": "Normal", + "slow": "Slow", + "veryfast": "Very Fast" + }, "effects": { "button_title": "Pick an effect!", "nukeType": { @@ -740,6 +759,7 @@ "disable_alliances": "Disable alliances", "donate_gold": "Donate gold", "donate_troops": "Donate troops", + "doomsday_clock": "Doomsday Clock", "empty_team": "Empty", "empty_teams": "Empty Teams", "enables_title": "Disable Units", @@ -1244,6 +1264,7 @@ "bots_disabled": "Disabled", "compact_map": "Compact Map", "disable_alliances": "Disable alliances", + "doomsday_clock": "Doomsday Clock", "enables_title": "Disable Units", "gold_multiplier": "Gold multiplier", "gold_multiplier_placeholder": "2.0x", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index d7f9257c9..969cf8620 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -8,6 +8,7 @@ import { translateText, } from "../client/Utils"; import { EventBus } from "../core/EventBus"; +import { DoomsdayClockSpeed } from "../core/game/DoomsdayClock"; import { Difficulty, GameMapSize, @@ -79,6 +80,8 @@ export class HostLobbyModal extends BaseModal { @state() private startingGold: boolean = false; @state() private startingGoldValue: number | undefined = undefined; @state() private disableAlliances: boolean = false; + @state() private doomsdayClock: boolean = false; + @state() private doomsdayClockSpeed: DoomsdayClockSpeed = "normal"; @state() private anonymizeNames: boolean = false; @state() private nameReveals: string[] = []; @state() private whitelistEnabled: boolean = false; @@ -423,6 +426,11 @@ export class HostLobbyModal extends BaseModal { labelKey: "host_modal.water_nukes", checked: this.waterNukes, }, + { + labelKey: "host_modal.doomsday_clock", + checked: this.doomsdayClock, + doomsdayClockSpeed: this.doomsdayClockSpeed, + }, { labelKey: "host_modal.host_cheats", checked: this.hostCheatsEnabled, @@ -453,6 +461,8 @@ export class HostLobbyModal extends BaseModal { @map-selected=${this.handleConfigMapSelected} @random-map-selected=${this.handleConfigRandomMapSelected} @difficulty-selected=${this.handleConfigDifficultySelected} + @doomsday-clock-speed-selected=${this + .handleConfigDoomsdayClockSpeedSelected} @game-mode-selected=${this.handleConfigGameModeSelected} @team-count-selected=${this.handleConfigTeamCountSelected} @bots-changed=${this.handleBotsChange} @@ -603,6 +613,8 @@ export class HostLobbyModal extends BaseModal { this.startingGold = false; this.startingGoldValue = undefined; this.disableAlliances = false; + this.doomsdayClock = false; + this.doomsdayClockSpeed = "normal"; this.anonymizeNames = false; this.nameReveals = []; this.whitelistEnabled = false; @@ -652,6 +664,12 @@ export class HostLobbyModal extends BaseModal { void this.handleDifficultySelection(customEvent.detail.difficulty); }; + private handleConfigDoomsdayClockSpeedSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ speed: DoomsdayClockSpeed }>; + this.doomsdayClockSpeed = customEvent.detail.speed; + this.putGameConfig(); + }; + private handleConfigGameModeSelected = (e: Event) => { const customEvent = e as CustomEvent<{ mode: GameMode }>; void this.handleGameModeSelection(customEvent.detail.mode); @@ -703,6 +721,10 @@ export class HostLobbyModal extends BaseModal { this.waterNukes = checked; this.putGameConfig(); break; + case "host_modal.doomsday_clock": + this.doomsdayClock = checked; + this.putGameConfig(); + break; case "host_modal.host_cheats": this.hostCheatsEnabled = checked; this.putGameConfig(); @@ -1086,6 +1108,12 @@ export class HostLobbyModal extends BaseModal { ? Math.round(this.startingGoldValue * 1_000_000) : null, disableAlliances: this.disableAlliances || null, + // Send {enabled:false} (not undefined) when off: undefined is dropped + // by JSON.stringify, so the server's "!== undefined" merge would keep a + // previously-enabled config and the toggle could never turn off. + doomsdayClock: this.doomsdayClock + ? { enabled: true, speed: this.doomsdayClockSpeed } + : { enabled: false }, anonymizeNames: this.anonymizeNames, nameReveals: this.nameReveals, allowedPublicIds: this.whitelistEnabled diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index f79ef39b9..1457ef688 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; import { UserMeResponse } from "../core/ApiSchemas"; import { assetUrl } from "../core/AssetUrls"; +import { DoomsdayClockSpeed } from "../core/game/DoomsdayClock"; import { Difficulty, GameMapSize, @@ -61,6 +62,8 @@ const DEFAULT_OPTIONS = { disabledUnits: [] as UnitType[], disableAlliances: false, waterNukes: false, + doomsdayClock: false, + doomsdayClockSpeed: "normal" as DoomsdayClockSpeed, } as const; // A map earns achievements only if it has nations to conquer — the same rule @@ -144,6 +147,9 @@ export class SinglePlayerModal extends BaseModal { ]; @state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances; @state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes; + @state() private doomsdayClock: boolean = DEFAULT_OPTIONS.doomsdayClock; + @state() private doomsdayClockSpeed: DoomsdayClockSpeed = + DEFAULT_OPTIONS.doomsdayClockSpeed; private mapLoader = terrainMapFileLoader; @@ -442,6 +448,11 @@ export class SinglePlayerModal extends BaseModal { labelKey: "single_modal.water_nukes", checked: this.waterNukes, }, + { + labelKey: "single_modal.doomsday_clock", + checked: this.doomsdayClock, + doomsdayClockSpeed: this.doomsdayClockSpeed, + }, ], inputCards, }, @@ -453,6 +464,8 @@ export class SinglePlayerModal extends BaseModal { @map-selected=${this.handleConfigMapSelected} @random-map-selected=${this.handleConfigRandomMapSelected} @difficulty-selected=${this.handleConfigDifficultySelected} + @doomsday-clock-speed-selected=${this + .handleConfigDoomsdayClockSpeedSelected} @game-mode-selected=${this.handleConfigGameModeSelected} @team-count-selected=${this.handleConfigTeamCountSelected} @bots-changed=${this.handleBotsChange} @@ -499,6 +512,10 @@ export class SinglePlayerModal extends BaseModal { this.startingGold !== DEFAULT_OPTIONS.startingGold || this.disableAlliances !== DEFAULT_OPTIONS.disableAlliances || this.waterNukes !== DEFAULT_OPTIONS.waterNukes || + this.doomsdayClock !== DEFAULT_OPTIONS.doomsdayClock || + // Pace only matters when the mode is on (startGame drops it when off). + (this.doomsdayClock && + this.doomsdayClockSpeed !== DEFAULT_OPTIONS.doomsdayClockSpeed) || this.disabledUnits.length > 0 ); } @@ -527,6 +544,8 @@ export class SinglePlayerModal extends BaseModal { this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue; this.disableAlliances = DEFAULT_OPTIONS.disableAlliances; this.waterNukes = DEFAULT_OPTIONS.waterNukes; + this.doomsdayClock = DEFAULT_OPTIONS.doomsdayClock; + this.doomsdayClockSpeed = DEFAULT_OPTIONS.doomsdayClockSpeed; } protected onOpen(): void { @@ -563,6 +582,11 @@ export class SinglePlayerModal extends BaseModal { this.handleDifficultySelection(customEvent.detail.difficulty); }; + private handleConfigDoomsdayClockSpeedSelected = (e: Event) => { + const customEvent = e as CustomEvent<{ speed: DoomsdayClockSpeed }>; + this.doomsdayClockSpeed = customEvent.detail.speed; + }; + private handleConfigGameModeSelected = (e: Event) => { const customEvent = e as CustomEvent<{ mode: GameMode }>; this.handleGameModeSelection(customEvent.detail.mode); @@ -612,6 +636,9 @@ export class SinglePlayerModal extends BaseModal { case "single_modal.water_nukes": this.waterNukes = checked; break; + case "single_modal.doomsday_clock": + this.doomsdayClock = checked; + break; default: break; } @@ -820,6 +847,14 @@ export class SinglePlayerModal extends BaseModal { : {}), ...(this.disableAlliances ? { disableAlliances: true } : {}), ...(this.waterNukes ? { waterNukes: true } : {}), + ...(this.doomsdayClock + ? { + doomsdayClock: { + enabled: true, + speed: this.doomsdayClockSpeed, + }, + } + : {}), }, lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP }, diff --git a/src/client/components/DoomsdayClockPanel.ts b/src/client/components/DoomsdayClockPanel.ts new file mode 100644 index 000000000..6c483da26 --- /dev/null +++ b/src/client/components/DoomsdayClockPanel.ts @@ -0,0 +1,245 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { assetUrl } from "../../core/AssetUrls"; +import { + doomsdayClockDrain, + doomsdayClockSideRequiredTiles, + doomsdayClockWaveState, +} from "../../core/game/DoomsdayClock"; +import { GameMode, PlayerType, Team } from "../../core/game/Game"; +import { themeProvider } from "../theme/ThemeProvider"; +import { renderTroops, translateText } from "../Utils"; +import { GameView } from "../view"; + +const doomsdayClockIcon = assetUrl("images/DoomsdayClockSkull.svg"); + +/** + * The Doomsday Clock readout: a self-contained panel showing the rising bar, the + * side's share vs the threshold, the stage (Stable/Unstable/Collapsing) and the + * wave countdown. Embedded by game-right-sidebar so it stacks (centered) under + * the game timer; it hides itself when the mode is off, after a winner, or for a + * spectator/eliminated player. + */ +@customElement("doomsday-clock-panel") +export class DoomsdayClockPanel extends LitElement { + @property({ attribute: false }) game!: GameView; + @property({ attribute: false }) hasWinner = false; + // Bumped by the parent each tick so the countdown + bar advance every second. + @property({ attribute: false }) refreshKey = 0; + + // Light DOM so Tailwind classes apply and it stacks in the parent's flex. + createRenderRoot() { + return this; + } + + private secondsToHms(d: number): string { + const pad = (n: number) => (n < 10 ? `0${n}` : n); + const h = Math.floor(d / 3600); + const m = Math.floor((d % 3600) / 60); + const s = Math.floor((d % 3600) % 60); + return h !== 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`; + } + + // The player's "side" (matching the sim): themselves in FFA, their whole team + // otherwise. Returns the combined tiles and the headcount (the sim scales the + // threshold by headcount, so the HUD needs it too). + private sideStats(me: ReturnType): { + tiles: number; + size: number; + } { + if (!me) return { tiles: 0, size: 1 }; + const ffa = this.game.config().gameConfig().gameMode === GameMode.FFA; + const myTeam = me.team(); + if (ffa || myTeam === null) return { tiles: me.numTilesOwned(), size: 1 }; + const mates = this.game + .playerViews() + .filter( + (p) => + p.team() === myTeam && p.isAlive() && p.type() !== PlayerType.Bot, + ); + return { + tiles: mates.reduce((sum, p) => sum + p.numTilesOwned(), 0), + size: mates.length, + }; + } + + // Localized team name (e.g. "Red"), matching TeamStats; falls back to the raw + // team id for numbered teams. + private teamDisplayName(team: Team): string { + const key = `team_colors.${team.toLowerCase()}`; + const translated = translateText(key); + return translated !== key ? translated : team; + } + + // The team's on-map color as a hex string, for the readout label. + private teamColor(team: Team): string { + return themeProvider.current().teamColor(team).toHex(); + } + + render() { + const sd = this.game?.config().doomsdayClockConfig(); + const me = this.game?.myPlayer(); + // Personal readout: no meaning when off, after a winner, or for a spectator + // / eliminated player (a 0-tile "me" would also pulse red-alert forever). + const visible = + !!sd?.enabled && !this.hasWinner && (me?.isAlive() ?? false); + this.style.display = visible ? "block" : "none"; + if (!visible || !me || !sd) return html``; + + const elapsed = Math.floor(this.game.elapsedGameSeconds()); + const land = this.game.numLandTiles() - this.game.numTilesWithFallout(); + const myTeam = me.team() ?? null; + const { tiles: yourTiles, size: mySize } = this.sideStats(me); + // Threshold is scaled by the side's headcount (same as the sim). + const requiredTiles = doomsdayClockSideRequiredTiles( + sd.speed, + land, + elapsed, + mySize, + ); + const wave = doomsdayClockWaveState(sd.speed, elapsed); + // Wave readout percentages scale by headcount too (capped at the whole map). + const scalePct = (p: number) => Math.min(100, p * mySize); + // Match the sim: no land -> no bar, no percentages (avoid div-by-zero / >100%). + const requiredPct = land > 0 ? (requiredTiles / land) * 100 : 0; + const yourPct = land > 0 ? (yourTiles / land) * 100 : 0; + const flagged = me?.inDoomsdayClock() ?? false; + const secondsUnder = Math.floor((me?.doomsdayClockTicks() ?? 0) / 10); + const draining = flagged && secondsUnder >= sd.warnSeconds; + // Safe but within 10% (relative) of the bar: e.g. at 9% when the bar is 10%, + // or 0.9% when it's 1%. About to be caught, so it blinks red too. + const nearDanger = + !flagged && requiredTiles > 0 && yourPct <= requiredPct * 1.1; + // In danger (caught/draining) or about to be: everything red. + const redAlert = flagged || nearDanger; + + // The zone's own progress, independent of your status. Shown while stable + // AND while collapsing, so you can still see the bar rising as you bleed. + const zoneDetail = wave.done + ? translateText("doomsday_clock.final", { + pct: scalePct(wave.currentPercent), + }) + : wave.growing + ? translateText("doomsday_clock.growing", { + pct: scalePct(wave.targetPercent), + }) + : translateText("doomsday_clock.next_wave", { + pct: scalePct(wave.targetPercent), + time: this.secondsToHms(wave.secondsToNextGrowth), + }); + + // Status word + detail line. + let status: string; + let statusClass: string; + let detail: string; + if (draining && me) { + // Drain is a % of max-troop capacity, capped at current troops; show the + // actual per-second loss (renderTroops handles the /10 display unit). + const chunk = doomsdayClockDrain( + this.game.config().maxTroops(me), + secondsUnder - sd.warnSeconds, + sd, + ); + status = translateText("doomsday_clock.collapsing", { + rate: renderTroops(Math.min(me.troops(), chunk)), + }); + statusClass = "text-red-400 font-bold"; + detail = zoneDetail; // keep the zone readout visible while collapsing + } else if (flagged) { + // Caught below a wave: count down the cooldown before decay begins. + status = translateText("doomsday_clock.unstable"); + statusClass = "text-red-400 font-bold"; + detail = translateText("doomsday_clock.decay_in", { + secs: Math.max(0, sd.warnSeconds - secondsUnder), + }); + } else { + status = translateText("doomsday_clock.stable"); + statusClass = nearDanger ? "text-orange-300 font-bold" : "text-green-400"; + detail = zoneDetail; + } + + // Panel edge cue: red pulse when in/near danger, orange pulse in the 10s + // window around a wave firing. + const edge = redAlert + ? "sd-pulse-red" + : wave.waveFlash + ? "sd-pulse-orange" + : ""; + const panel = + "w-fit flex flex-col gap-1.5 py-2 px-4 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-bl-lg text-white text-sm"; + + return html` + +
+
+ + + ${translateText("doomsday_clock.title")} + + ${status} +
+
+ +
+
+
+
+ + ${translateText("doomsday_clock.hold", { + pct: requiredPct.toFixed(1), + })} + + ${myTeam !== null + ? html` + ${translateText("doomsday_clock.your_team", { + team: this.teamDisplayName(myTeam), + pct: yourPct.toFixed(1), + })} + ` + : html` + ${translateText("doomsday_clock.you", { + pct: yourPct.toFixed(1), + })} + `} +
+ ${detail + ? html`
${detail}
` + : ""} +
+ `; + } +} diff --git a/src/client/components/GameConfigSettings.ts b/src/client/components/GameConfigSettings.ts index 414807297..fccde571b 100644 --- a/src/client/components/GameConfigSettings.ts +++ b/src/client/components/GameConfigSettings.ts @@ -7,6 +7,10 @@ import { svg, } from "lit"; import { customElement, property, state } from "lit/decorators.js"; +import { + DOOMSDAY_CLOCK_SPEEDS, + DoomsdayClockSpeed, +} from "../../core/game/DoomsdayClock"; import { Difficulty, Duos, @@ -65,7 +69,13 @@ function renderTextCardButton( cardExtraClass: string, ): TemplateResult { return html` -