diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 17f70b31f..dbcd718b2 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -241,7 +241,10 @@ export function joinLobby( // Build the WebGL view + its glCanvas. Must run before createRenderer so the // controllers can be wired directly to the view. -function createWebGLView(terrainMap: TerrainMapData): { +function createWebGLView( + terrainMap: TerrainMapData, + config: Config, +): { view: WebGLGameView; glCanvas: HTMLCanvasElement; cachedWebGLFrameCallback: { current: FrameRequestCallback | null }; @@ -295,6 +298,7 @@ function createWebGLView(terrainMap: TerrainMapData): { }, terrainBytes, palette, + config, captureRaf, captureCaf, ); @@ -463,8 +467,10 @@ async function createClientGame( const soundManager = new SoundManager(eventBus, userSettings); try { - const { view, glCanvas, cachedWebGLFrameCallback } = - createWebGLView(gameMap); + const { view, glCanvas, cachedWebGLFrameCallback } = createWebGLView( + gameMap, + config, + ); // Bind the WebGL renderer's day/night mode to the existing darkMode // UserSetting so the in-game map matches the rest of the UI. Initial diff --git a/src/client/render/CLAUDE.md b/src/client/render/CLAUDE.md index b5441d1d2..68520110b 100644 --- a/src/client/render/CLAUDE.md +++ b/src/client/render/CLAUDE.md @@ -37,7 +37,6 @@ each frame (and animate from local time, e.g. the spawn-overlay breath). | Path | Purpose | | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GameConstants.ts` | Top-level constants shared across passes (`MS_PER_TICK`, nuke radii, etc.) | | `types/` | Shared TS interfaces: `FrameData`, `UnitState`, `PlayerState`, `RendererConfig`, pass-input shapes (`GhostPreviewData`, `NukeTrajectoryData`, `SpawnCenter`, …) | | `frame/` | Frame-data accumulators + per-tick derivations (CPU-side, no GL) | | `frame/derive/` | Pure derivations that turn raw simulation state into renderer-ready shapes (attack rings, alliance clusters, relation matrix, player status, nuke telegraphs) | diff --git a/src/client/render/GameConstants.ts b/src/client/render/GameConstants.ts deleted file mode 100644 index 84dec0fc4..000000000 --- a/src/client/render/GameConstants.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * game-constants.ts — Upstream game facts replicated in the renderer/shim. - * - * All values here are sourced from upstream game code. When upstream changes, - * audit this file first. - * - * Primary sources: - * - vendor/openfront/src/core/configuration/DefaultConfig.ts (DefaultConfig, DefaultServerConfig) - * - vendor/openfront/src/client/hud/layers/FxLayer.ts (visual-only constants) - */ - -import { - UT_ATOM_BOMB, - UT_CITY, - UT_DEFENSE_POST, - UT_FACTORY, - UT_HYDROGEN_BOMB, - UT_MIRV_WARHEAD, - UT_MISSILE_SILO, - UT_PORT, - UT_SAM_LAUNCHER, -} from "./types"; - -// --------------------------------------------------------------------------- -// Tick timing -// --------------------------------------------------------------------------- - -/** - * Milliseconds per game tick. - * Source: DefaultServerConfig.turnIntervalMs() → return 100 - */ -export const MS_PER_TICK = 100; - -// --------------------------------------------------------------------------- -// Unit health -// --------------------------------------------------------------------------- - -/** - * Maximum health for a Warship unit. - * Source: DefaultConfig.unitInfo(UnitType.Warship) → { maxHealth: 1000 } - */ -export const WARSHIP_MAX_HEALTH = 1000; - -// --------------------------------------------------------------------------- -// Construction durations (ticks) -// --------------------------------------------------------------------------- - -/** - * How many ticks each structure type takes to finish construction. - * Source: DefaultConfig.unitInfo(type).constructionDuration (non-instantBuild path): - * case UnitType.City: constructionDuration: 2 * 10 - * case UnitType.Port: constructionDuration: 2 * 10 - * case UnitType.Factory: constructionDuration: 2 * 10 - * case UnitType.DefensePost: constructionDuration: 5 * 10 - * case UnitType.MissileSilo: constructionDuration: 10 * 10 - * case UnitType.SAMLauncher: constructionDuration: 30 * 10 - */ -export const CONSTRUCTION_DURATIONS: Readonly> = { - [UT_CITY]: 2 * 10, - [UT_PORT]: 2 * 10, - [UT_FACTORY]: 2 * 10, - [UT_DEFENSE_POST]: 5 * 10, - [UT_MISSILE_SILO]: 10 * 10, - [UT_SAM_LAUNCHER]: 30 * 10, -}; - -// --------------------------------------------------------------------------- -// Missile cooldowns (ticks) -// --------------------------------------------------------------------------- - -/** - * Ticks for a SAM Launcher to reload one missile. - * Source: DefaultConfig.SAMCooldown() → return 120 - * NOTE: different from SiloCooldown — do not conflate. - */ -export const SAM_COOLDOWN_TICKS = 120; - -/** - * Ticks for a Missile Silo to reload one missile. - * Source: DefaultConfig.SiloCooldown() → return 75 - */ -export const SILO_COOLDOWN_TICKS = 75; - -// --------------------------------------------------------------------------- -// Deletion mark duration (ticks) -// --------------------------------------------------------------------------- - -/** - * How many ticks a structure remains in the "marked for deletion" state. - * Source: DefaultConfig.deletionMarkDuration() → return 30 * 10 - */ -export const DELETION_MARK_DURATION = 30 * 10; - -// --------------------------------------------------------------------------- -// Nuke explosion visual radii (tiles) -// --------------------------------------------------------------------------- - -/** - * Visual explosion radius (tiles) for each nuke type, used for shockwave and - * debris scatter sizing. - * - * Source: FxLayer.ts, inside the unit-death event handler: - * case UnitType.AtomBomb: this.onNukeEvent(unit, 70) - * case UnitType.MIRVWarhead: this.onNukeEvent(unit, 70) - * case UnitType.HydrogenBomb: this.onNukeEvent(unit, 160) - * - * Note: these are visual-only radii. The gameplay damage radii are separate - * and come from DefaultConfig.nukeMagnitudes() → { inner, outer }. - */ -export const NUKE_EXPLOSION_RADII: Readonly> = { - [UT_ATOM_BOMB]: 70, - [UT_HYDROGEN_BOMB]: 160, - [UT_MIRV_WARHEAD]: 70, -}; - -// --------------------------------------------------------------------------- -// SAM range formula -// --------------------------------------------------------------------------- - -/** - * SAM Launcher coverage radius in tiles at a given upgrade level. - * Source: DefaultConfig.samRange(level): - * return this.maxSamRange() - 480 / (level + 5) - * where maxSamRange() → return 150 - */ -export function samRange(level: number): number { - return 150 - 480 / (level + 5); -} - -// --------------------------------------------------------------------------- -// Missile readiness formula -// --------------------------------------------------------------------------- - -/** - * Fractional missile readiness [0, 1] for a Silo or SAM Launcher. - * Returns 1.0 when fully loaded, 0.0 when completely empty with no partial reload. - * - * Source: adapted from upstream readiness display logic (UILayer / FxLayer). - * Uses per-type cooldown: SAMCooldown() = 120, SiloCooldown() = 75. - */ -export function missileReadiness( - unitType: string, - level: number, - missileTimerQueue: number[], - gameTick: number, -): number { - const cooldown = - unitType === UT_SAM_LAUNCHER ? SAM_COOLDOWN_TICKS : SILO_COOLDOWN_TICKS; - const maxMissiles = level; - const reloading = missileTimerQueue.length; - if (reloading === 0) return 1; - - const ready = maxMissiles - reloading; - if (ready === 0 && maxMissiles > 1) return 0; - - let readiness = ready / maxMissiles; - for (const timer of missileTimerQueue) { - const progress = gameTick - timer; - const ratio = progress / cooldown; - readiness += ratio / maxMissiles; - } - return Math.max(0, Math.min(1, readiness)); -} diff --git a/src/client/render/gl/GameView.ts b/src/client/render/gl/GameView.ts index 963f55bdf..9b6938480 100644 --- a/src/client/render/gl/GameView.ts +++ b/src/client/render/gl/GameView.ts @@ -8,6 +8,7 @@ * Consumers only touch GameView — they never import GPURenderer or Camera. */ +import type { Config } from "../../../core/configuration/Config"; import type { AttackRingInput, BonusEvent, @@ -51,6 +52,7 @@ export class GameView { private header: RendererConfig, private terrainBytes: Uint8Array, private paletteData: Float32Array, + private config: Config, private raf?: typeof requestAnimationFrame, private caf?: typeof cancelAnimationFrame, ) { @@ -78,6 +80,7 @@ export class GameView { this.header, this.terrainBytes, this.paletteData, + this.config, this.raf, this.caf, ); diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index 269527acf..e442be642 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -9,6 +9,7 @@ * structure levels → bars → bloom → trails → missiles → fx → conquest → names */ +import type { Config } from "../../../core/configuration/Config"; import type { AttackRingInput, BonusEvent, @@ -185,6 +186,7 @@ export class GPURenderer { header: RendererConfig, terrainBytes: Uint8Array, paletteData: Float32Array, + config: Config, raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window), caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window), ) { @@ -435,9 +437,9 @@ export class GPURenderer { this.structureLevelPass = new StructureLevelPass(gl, header, this.settings); this.unitPass = new UnitPass(gl, header, this.paletteTex, this.settings); this.namePass = new NamePass(gl, header, paletteData, this.settings); - this.fxPass = new FxPass(gl, header, this.settings); - this.barPass = new BarPass(gl, header, this.settings); - this.worldTextPass = new WorldTextPass(gl, this.settings); + this.fxPass = new FxPass(gl, header, this.settings, config); + this.barPass = new BarPass(gl, header, this.settings, config); + this.worldTextPass = new WorldTextPass(gl, this.settings, config); this.worldTextPass.setMapWidth(this.mapW); this.radialMenuPass = new RadialMenuPass(gl); this.selectionBoxPass = new SelectionBoxPass(gl); diff --git a/src/client/render/gl/passes/BarPass.ts b/src/client/render/gl/passes/BarPass.ts index 159e6b911..c7d950711 100644 --- a/src/client/render/gl/passes/BarPass.ts +++ b/src/client/render/gl/passes/BarPass.ts @@ -10,12 +10,8 @@ * → instance VBO (x, y, progress) → GPU colored rectangle */ -import { - CONSTRUCTION_DURATIONS, - DELETION_MARK_DURATION, - missileReadiness, - WARSHIP_MAX_HEALTH, -} from "../../GameConstants"; +import type { Config } from "../../../../core/configuration/Config"; +import { UnitType } from "../../../../core/game/Game"; import type { RendererConfig, UnitState } from "../../types"; import { UT_MISSILE_SILO, UT_SAM_LAUNCHER } from "../../types"; import type { RenderSettings } from "../RenderSettings"; @@ -60,15 +56,18 @@ export class BarPass { private progressCount = 0; private mapW: number; + private warshipMaxHealth: number; constructor( gl: WebGL2RenderingContext, header: RendererConfig, settings: RenderSettings, + private config: Config, ) { this.gl = gl; this.settings = settings; this.mapW = header.mapWidth; + this.warshipMaxHealth = config.unitInfo(UnitType.Warship).maxHealth ?? 0; // --- Shader program --- this.program = createProgram(gl, barVertSrc, barFragSrc); @@ -130,10 +129,10 @@ export class BarPass { if ( unit.health === null || unit.health <= 0 || - unit.health >= WARSHIP_MAX_HEALTH + unit.health >= this.warshipMaxHealth ) continue; - this.pushHealth(unit, unit.health / WARSHIP_MAX_HEALTH); + this.pushHealth(unit, unit.health / this.warshipMaxHealth); } // --- Progress bars (structures) --- @@ -234,12 +233,17 @@ export class BarPass { // Deletion progress (reverse countdown — takes priority over other bars) if (unit.markedForDeletion !== false) { const remaining = unit.markedForDeletion - gameTick; - return Math.max(0, Math.min(1, remaining / DELETION_MARK_DURATION)); + return Math.max( + 0, + Math.min(1, remaining / this.config.deletionMarkDuration()), + ); } // Construction progress if (unit.underConstruction && unit.constructionStartTick !== null) { - const duration = CONSTRUCTION_DURATIONS[unit.unitType] ?? 50; + const duration = + this.config.unitInfo(unit.unitType as UnitType).constructionDuration ?? + 50; const elapsed = gameTick - unit.constructionStartTick; return Math.min(1, Math.max(0, elapsed / duration)); } @@ -249,15 +253,31 @@ export class BarPass { unit.unitType === UT_MISSILE_SILO || unit.unitType === UT_SAM_LAUNCHER ) { - const readiness = missileReadiness( - unit.unitType, - unit.level, - unit.missileTimerQueue, - gameTick, - ); + const readiness = this.missileReadiness(unit, gameTick); if (readiness < 1) return readiness; } return null; } + + private missileReadiness(unit: UnitState, gameTick: number): number { + const maxMissiles = unit.level; + const reloading = unit.missileTimerQueue.length; + if (reloading === 0) return 1; + + const ready = maxMissiles - reloading; + if (ready === 0 && maxMissiles > 1) return 0; + + const cooldown = + unit.unitType === UT_SAM_LAUNCHER + ? this.config.SAMCooldown() + : this.config.SiloCooldown(); + + let readiness = ready / maxMissiles; + for (const timer of unit.missileTimerQueue) { + const progress = gameTick - timer; + readiness += progress / cooldown / maxMissiles; + } + return Math.max(0, Math.min(1, readiness)); + } } diff --git a/src/client/render/gl/passes/WorldTextPass.ts b/src/client/render/gl/passes/WorldTextPass.ts index 4ffd13d19..4a37dcbd2 100644 --- a/src/client/render/gl/passes/WorldTextPass.ts +++ b/src/client/render/gl/passes/WorldTextPass.ts @@ -7,6 +7,7 @@ * - Ghost cost label: persistent build-cost number under the ghost cursor */ +import type { Config } from "../../../../core/configuration/Config"; import type { BonusEvent, ConquestFx } from "../../types"; import type { RenderSettings } from "../RenderSettings"; import { createProgram } from "../utils/GlUtils"; @@ -31,8 +32,6 @@ const atlasUrl = assetUrl("atlases/msdf-atlas.png"); const FLOATS_PER_INSTANCE = 10; const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4; const CONQUEST_LIFETIME_MS = 2500; -/** Nominal game tick rate — 100ms per tick. */ -const MS_PER_TICK = 100; /** Tiles below conquered name location (matches upstream DynamicUILayer). */ const CONQUEST_Y_OFFSET = 8; /** World-space font size for conquest popups. */ @@ -152,7 +151,11 @@ export class WorldTextPass { return this.timeFn(); } - constructor(gl: WebGL2RenderingContext, settings: RenderSettings) { + constructor( + gl: WebGL2RenderingContext, + settings: RenderSettings, + private config: Config, + ) { this.gl = gl; this.settings = settings; @@ -273,7 +276,7 @@ export class WorldTextPass { applyConquestEvents(events: ConquestFx[]): void { const now = this.now(); for (const evt of events) { - const startMs = now - (evt.tickAge ?? 0) * MS_PER_TICK; + const startMs = now - (evt.tickAge ?? 0) * this.config.msPerTick(); if (now - startMs >= CONQUEST_LIFETIME_MS) continue; this.active.push({ x: evt.x, diff --git a/src/client/render/gl/passes/fx-pass/FxSpritePass.ts b/src/client/render/gl/passes/fx-pass/FxSpritePass.ts index 5937583d6..43ed3ac5a 100644 --- a/src/client/render/gl/passes/fx-pass/FxSpritePass.ts +++ b/src/client/render/gl/passes/fx-pass/FxSpritePass.ts @@ -6,10 +6,13 @@ * Pre-built by generate-sprite-atlases.mjs. */ -import { MS_PER_TICK, NUKE_EXPLOSION_RADII } from "../../../GameConstants"; +import type { Config } from "../../../../../core/configuration/Config"; import type { ConquestFx, DeadUnitFx, RendererConfig } from "../../../types"; import { STRUCTURE_TYPES, + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV_WARHEAD, UT_SHELL, UT_TRAIN, UT_WARSHIP, @@ -130,6 +133,17 @@ const FX_CONFIG: FxTypeConfig[] = [ }, ]; +// --------------------------------------------------------------------------- +// Nuke explosion radii — visual-only (FxLayer source, not Config). These are +// the shockwave/debris scatter sizes, not the gameplay damage radii. +// --------------------------------------------------------------------------- + +export const NUKE_EXPLOSION_RADII: Readonly> = { + [UT_ATOM_BOMB]: 70, + [UT_HYDROGEN_BOMB]: 160, + [UT_MIRV_WARHEAD]: 70, +}; + // --------------------------------------------------------------------------- // Nuke debris plan // --------------------------------------------------------------------------- @@ -196,6 +210,7 @@ export class FxSpritePass { gl: WebGL2RenderingContext, header: RendererConfig, settings: RenderSettings, + private config: Config, ) { this.gl = gl; this.mapW = header.mapWidth; @@ -319,7 +334,7 @@ export class FxSpritePass { const now = this.timeFn(); const fx = this.settings.fx; for (const evt of events) { - const startMs = now - (evt.tickAge ?? 0) * MS_PER_TICK; + const startMs = now - (evt.tickAge ?? 0) * this.config.msPerTick(); if (now - startMs >= fx.conquestLifetimeMs) continue; this.activeFx.push({ x: evt.x, diff --git a/src/client/render/gl/passes/fx-pass/index.ts b/src/client/render/gl/passes/fx-pass/index.ts index 12ec3c0b7..21eb52c10 100644 --- a/src/client/render/gl/passes/fx-pass/index.ts +++ b/src/client/render/gl/passes/fx-pass/index.ts @@ -8,7 +8,7 @@ * interceptions) are coordinated here so each sub-pass stays self-contained. */ -import { MS_PER_TICK, NUKE_EXPLOSION_RADII } from "../../../GameConstants"; +import type { Config } from "../../../../../core/configuration/Config"; import type { AttackRingInput, ConquestFx, @@ -18,7 +18,7 @@ import type { import type { RenderSettings } from "../../RenderSettings"; import { FxAttackRingPass } from "./FxAttackRingPass"; import { FxShockwavePass } from "./FxShockwavePass"; -import { FxSpritePass } from "./FxSpritePass"; +import { FxSpritePass, NUKE_EXPLOSION_RADII } from "./FxSpritePass"; export type { AttackRingInput } from "../../../types"; @@ -33,9 +33,10 @@ export class FxPass { gl: WebGL2RenderingContext, header: RendererConfig, settings: RenderSettings, + private config: Config, ) { this.mapW = header.mapWidth; - this.spritePass = new FxSpritePass(gl, header, settings); + this.spritePass = new FxSpritePass(gl, header, settings, config); this.shockwavePass = new FxShockwavePass(gl, settings); this.attackRingPass = new FxAttackRingPass(gl, settings); } @@ -47,7 +48,7 @@ export class FxPass { applyDeadUnits(deadUnits: DeadUnitFx[]): void { const now = this.timeFn(); for (const unit of deadUnits) { - const startMs = now - (unit.tickAge ?? 0) * MS_PER_TICK; + const startMs = now - (unit.tickAge ?? 0) * this.config.msPerTick(); this.spawnUnit(unit, startMs); } } diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 9ea5dd3cb..994e06dc9 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -138,6 +138,9 @@ export class Config { // So defense modifier is between [5, 2.5] return 5 - falloutRatio * 2; } + msPerTick(): number { + return 100; + } SAMCooldown(): number { return 90; }