import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; import { assetUrl } from "../../core/AssetUrls"; import { doomsdayClockDrain, doomsdayClockRequiredTiles, 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 only when the mode is off or after a winner. A * spectator, replay viewer, or eliminated / not-spawned player still sees the * zone-only readout (rising bar + wave countdown, no personal status line). */ @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. The sim judges a side on its combined territory against the same // bar as a solo player, so the HUD only needs the tile sum. private sideTiles(me: ReturnType): number { if (!me) return 0; const ffa = this.game.config().gameConfig().gameMode === GameMode.FFA; const myTeam = me.team(); if (ffa || myTeam === null) return me.numTilesOwned(); return this.game .playerViews() .filter( (p) => p.team() === myTeam && p.isAlive() && p.type() !== PlayerType.Bot, ) .reduce((sum, p) => sum + p.numTilesOwned(), 0); } // 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(); // Show whenever the mode is on and there's no winner yet. A spectator, a // replay viewer, or a not-spawned / eliminated player has no live side, so // `live` is false and they get the zone-only readout (rising bar + countdown). const visible = !!sd?.enabled && !this.hasWinner; this.style.display = visible ? "block" : "none"; if (!visible || !sd) return html``; const live = !!me && me.isAlive(); const elapsed = Math.floor(this.game.elapsedGameSeconds()); const land = this.game.numLandTiles() - this.game.numTilesWithFallout(); const myTeam = me?.team() ?? null; const yourTiles = this.sideTiles(me); // Same bar for every side, solo or team (same as the sim) — so the zone // readout is one universal, monotonic number for every player. const requiredTiles = doomsdayClockRequiredTiles(sd.speed, land, elapsed); const wave = doomsdayClockWaveState(sd.speed, elapsed); // 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 = live && !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: wave.currentPercent, }) : wave.growing ? translateText("doomsday_clock.growing", { pct: wave.targetPercent, time: this.secondsToHms(wave.secondsToTarget), }) : translateText("doomsday_clock.next_wave", { pct: wave.targetPercent, time: this.secondsToHms(wave.secondsToNextGrowth), }); // Status word + detail line. Spectators, replay viewers, and eliminated / // not-spawned players have no live side, so they see just the zone readout // (no personal status word). Detail defaults to the zone readout for everyone. let status = ""; let statusClass = ""; let detail = zoneDetail; if (live && draining && me) { // Drain is a % of max-troop capacity that stops at the floor // (drainFloorPercent of max); show the actual per-second loss, i.e. only // what sits above the floor (renderTroops handles the /10 display unit). const maxTroops = this.game.config().maxTroops(me); const floor = Math.floor((maxTroops * sd.drainFloorPercent) / 100); const chunk = doomsdayClockDrain( maxTroops, secondsUnder - sd.warnSeconds, sd, ); status = translateText("doomsday_clock.collapsing", { rate: renderTroops(Math.max(0, Math.min(me.troops() - floor, chunk))), }); statusClass = "text-red-400 font-bold"; } else if (live && 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 if (live) { status = translateText("doomsday_clock.stable"); statusClass = nearDanger ? "text-orange-300 font-bold" : "text-green-400"; } // 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), })} ${live ? 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}
` : ""}
`; } }