mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 19:25:16 +00:00
feat(doomsday-clock): battle-royale style zone gamemode (#4469)
Resolves Issue #4463 ## Description: An optional game mode that (almost) guarantees a finish instead of letting late-game stalemates drag on. Originally called sudden death, renamed to Doomsday clock Once enabled, every side (each player in FFA, each whole team in team modes) must hold a rising share of the map. A side below the bar is skulled; after a short warn its troops bleed to zero, forcing consolidation to a winner. ### How it works - **Rising zone:** a grace period, then the required share ramps up linearly to each level with 30s pauses between (a battle-royale "zone"). Levels track the ofstats FFA territory median (3/5/10/20/30%). - **Four speed presets** (slow / normal / fast / very fast) change only the pace: normal ends ~30 min, very fast ~15. - **Troop decay:** a linear ramp as a % of max capacity, ~50s from caught to zero (10s warn + ~50s ≈ 1 min total). - **UI:** a HUD panel (live share vs target, wave/decay countdowns, red/orange cues) and an on-map skull above flagged players (blinks in danger, steady while draining). ### Notes for review - Off by default; no effect on existing games. However, as discussed we can add it to the modifier pool for public games to see how popular the gamemode is vs normal play. - Sim is deterministic (integer-only, in `src/core`), covered by unit + integration tests. - One-line addition to `GameServer.updateGameConfig` so the setting survives the host → server → client round-trip. - Status is packed into the existing name-pass data slot (`pd4.w`: 0/1/2 = none/danger/draining); the skull is composited into the icon atlas at load. ### Testing `npm test`, `npm run lint`, `npx prettier --check .`, `npm run build-prod` all pass. ### UI: <img width="243" height="100" alt="Image" src="https://github.com/user-attachments/assets/c4c9eeb0-4feb-437d-9aac-b2786a841b74" /> Dropdown between slow, normal, fast, very fast Before zone: <img width="302" height="175" alt="Image" src="https://github.com/user-attachments/assets/7359a1ea-4951-446d-a23c-0711fe06cc5d" /> Zone started, player not affected the pannel also blinks orange for 10s: <img width="297" height="175" alt="Image" src="https://github.com/user-attachments/assets/fcc565a5-d5d0-47a7-97ea-d0ba9d9ad899" /> Player affected, grace period (Danger): <img width="314" height="170" alt="Image" src="https://github.com/user-attachments/assets/ff96d21e-96f3-4ef9-8190-48eecc7aac0f" /> Skull icon blinking over player (everyone sees it) - older screenshot, the clipping has been fixed <img width="462" height="145" alt="Image" src="https://github.com/user-attachments/assets/53899211-33b1-40e1-83f2-77f2096f0cad" /> Player affected, grace period ended (Draining): <img width="360" height="159" alt="Image" src="https://github.com/user-attachments/assets/4b226d57-da4d-4866-ab5f-db48e4ed1ea2" /> Skull icon no longer blinking, everyone can see you are in a state of decay, and troops are draining: <img width="732" height="146" alt="image" src="https://github.com/user-attachments/assets/cd10fedb-6e87-4dfc-9fbf-55d3945a7901" /> Skull is visible like alliances icon also on player tab <img width="558" height="81" alt="Image" src="https://github.com/user-attachments/assets/6acdbe91-bdd0-40c7-942b-3990d4dae87f" /> (just UI example, best way to see it is to hop on a solo game and play against AI) ## 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._
This commit is contained in:
@@ -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<GameView["myPlayer"]>): {
|
||||
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`
|
||||
<style>
|
||||
@keyframes sd-red {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(248, 113, 113, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.95);
|
||||
}
|
||||
}
|
||||
@keyframes sd-orange {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(251, 146, 60, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.9);
|
||||
}
|
||||
}
|
||||
.sd-pulse-red {
|
||||
animation: sd-red 1s ease-in-out infinite;
|
||||
}
|
||||
.sd-pulse-orange {
|
||||
animation: sd-orange 1.8s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
<div class="${panel} ${edge}">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="flex items-center gap-1.5 font-bold tracking-wide text-red-400"
|
||||
>
|
||||
<img src=${doomsdayClockIcon} alt="" width="20" height="20" />
|
||||
${translateText("doomsday_clock.title")}
|
||||
</span>
|
||||
<span class=${statusClass}>${status}</span>
|
||||
</div>
|
||||
<div class="relative h-2.5 w-52 overflow-hidden rounded bg-gray-600/60">
|
||||
<!-- your held share (green) vs the target threshold (red bar): the gap
|
||||
between them shows how far you are from safe. -->
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-green-400"
|
||||
style="width:${Math.min(100, yourPct)}%"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-y-0 w-0.5 bg-red-500"
|
||||
style="left:${Math.min(100, requiredPct)}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 text-gray-300">
|
||||
<span>
|
||||
${translateText("doomsday_clock.hold", {
|
||||
pct: requiredPct.toFixed(1),
|
||||
})}
|
||||
</span>
|
||||
${myTeam !== null
|
||||
? html`<span style=${`color:${this.teamColor(myTeam)}`}>
|
||||
${translateText("doomsday_clock.your_team", {
|
||||
team: this.teamDisplayName(myTeam),
|
||||
pct: yourPct.toFixed(1),
|
||||
})}
|
||||
</span>`
|
||||
: html`<span class=${redAlert ? "text-red-300" : "text-green-300"}>
|
||||
${translateText("doomsday_clock.you", {
|
||||
pct: yourPct.toFixed(1),
|
||||
})}
|
||||
</span>`}
|
||||
</div>
|
||||
${detail
|
||||
? html`<div class="text-xs text-gray-400">${detail}</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user