From 55bc2a1ae34442d9e4da90343f8f4af80977d341 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Tue, 18 Nov 2025 23:06:00 +0100 Subject: [PATCH 1/8] Add per-level health and damage configuration for Fighter Jets --- src/client/graphics/layers/UILayer.ts | 4 +++ src/core/configuration/Config.ts | 4 +++ src/core/configuration/DefaultConfig.ts | 34 +++++++++++++++++++++++ src/core/execution/FighterJetExecution.ts | 7 +++++ src/core/execution/ShellExecution.ts | 13 +++++++-- 5 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 0f1eaa861..376bde3e9 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -306,6 +306,10 @@ export class UILayer implements Layer { if (typeof maxHealth === "number") { maxHealth = maxHealth + (lvl - 1) * 1000; } + } else if (unit.type() === UnitType.FighterJet) { + // Fighter Jet: per-level max health from config + const lvl = unit.level ? unit.level() : 1; + maxHealth = this.game.config().fighterJetLevelMaxHealth(lvl); } if (maxHealth === undefined || this.context === null) { return; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 3aa59d325..7dd117dd9 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -225,6 +225,10 @@ export interface Config { fighterJetTargetReachedDistance(): number; fighterJetDogfightDistance(): number; fighterJetMinDogfightDistance(): number; + // Fighter Jet: per-level max health + fighterJetLevelMaxHealth(level: number): number; + // Fighter Jet: per-level damage range (inclusive) + fighterJetDamageRange(level: number): { min: number; max: number }; warshipAARange(): number; warshipAACooldown(): number; warshipAAScanInterval(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index a26371f23..af1bee15f 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -481,6 +481,40 @@ export class DefaultConfig implements Config { return 10; } + // Fighter Jet per-level stats + fighterJetLevelMaxHealth(level: number): number { + const lvl = Math.max(1, Math.min(4, Math.floor(level))); + switch (lvl) { + case 1: + return 750; // default + case 2: + return 1000; + case 3: + return 1250; + case 4: + return 1500; + default: + return 750; + } + } + + fighterJetDamageRange(level: number): { min: number; max: number } { + const lvl = Math.max(1, Math.min(4, Math.floor(level))); + switch (lvl) { + case 1: + // Level 1 fighter damage + return { min: 200, max: 325 }; + case 2: + return { min: 300, max: 425 }; + case 3: + return { min: 400, max: 525 }; + case 4: + return { min: 500, max: 625 }; + default: + return { min: 200, max: 325 }; + } + } + // Paratroopers/Air attack paratrooperMaxNumber(): number { return 3; diff --git a/src/core/execution/FighterJetExecution.ts b/src/core/execution/FighterJetExecution.ts index 07124f871..27d7ecd29 100644 --- a/src/core/execution/FighterJetExecution.ts +++ b/src/core/execution/FighterJetExecution.ts @@ -46,6 +46,13 @@ export class FighterJetExecution implements Execution { const lvl = Math.max(1, this.desiredLevel | 0); if (lvl > 1) { (this.fighterJet as any)._level = lvl; + // Apply per-level max health using config + const base = + this.mg.config().unitInfo(UnitType.FighterJet).maxHealth ?? 750; + const desired = this.mg.config().fighterJetLevelMaxHealth(lvl); + const bonus = Math.max(0, desired - base); + (this.fighterJet as any)._bonusMaxHealth = bonus; + (this.fighterJet as any)._health = BigInt(desired); this.mg.addUpdate(this.fighterJet.toUpdate()); } } diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index e23e8e8f1..36d97b842 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -70,12 +70,21 @@ export class ShellExecution implements Execution { } private effectOnTarget(): number { + // Fighter Jets use per-level configurable damage ranges + if (this.ownerUnit.type() === UnitType.FighterJet) { + const level = this.ownerUnit.level ? this.ownerUnit.level() : 1; + const range = this.mg.config().fighterJetDamageRange(level); + // Use 6-step discrete spread between min and max (inclusive) + const roll = this.random.nextInt(0, 5); // 0..5 + const step = (range.max - range.min) / 5; + return Math.round(range.min + roll * step); + } + + // Default: shell damage based on base value and 5-step multiplier const { damage } = this.mg.config().unitInfo(UnitType.Shell); const baseDamage = damage ?? 250; - const roll = this.random.nextInt(1, 6); const damageMultiplier = (roll - 1) * 25 + 200; - return Math.round((baseDamage / 250) * damageMultiplier); } From fa0a9b3c18b48ffdd4e950e4e5dca9467e3c4389 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Tue, 18 Nov 2025 23:11:02 +0100 Subject: [PATCH 2/8] Add per-level health and damage configurations for Warships and Submarines --- src/client/graphics/layers/UILayer.ts | 6 ++++ src/core/configuration/Config.ts | 8 +++++ src/core/configuration/DefaultConfig.ts | 40 ++++++++++++++++++++++++ src/core/execution/ShellExecution.ts | 12 +++++++ src/core/execution/SubmarineExecution.ts | 7 +++++ src/core/execution/WarshipExecution.ts | 7 +++++ 6 files changed, 80 insertions(+) diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 376bde3e9..9ac46051d 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -310,6 +310,12 @@ export class UILayer implements Layer { // Fighter Jet: per-level max health from config const lvl = unit.level ? unit.level() : 1; maxHealth = this.game.config().fighterJetLevelMaxHealth(lvl); + } else if (unit.type() === UnitType.Warship) { + const lvl = unit.level ? unit.level() : 1; + maxHealth = this.game.config().warshipLevelMaxHealth(lvl); + } else if (unit.type() === UnitType.Submarine) { + const lvl = unit.level ? unit.level() : 1; + maxHealth = this.game.config().submarineLevelMaxHealth(lvl); } if (maxHealth === undefined || this.context === null) { return; diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 7dd117dd9..12047c32e 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -229,6 +229,14 @@ export interface Config { fighterJetLevelMaxHealth(level: number): number; // Fighter Jet: per-level damage range (inclusive) fighterJetDamageRange(level: number): { min: number; max: number }; + // Warship: per-level max health + warshipLevelMaxHealth(level: number): number; + // Warship: per-level damage range + warshipDamageRange(level: number): { min: number; max: number }; + // Submarine: per-level max health + submarineLevelMaxHealth(level: number): number; + // Submarine: per-level damage range + submarineDamageRange(level: number): { min: number; max: number }; warshipAARange(): number; warshipAACooldown(): number; warshipAAScanInterval(): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index af1bee15f..b49763b07 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -515,6 +515,46 @@ export class DefaultConfig implements Config { } } + // Warship per-level stats + warshipLevelMaxHealth(level: number): number { + const lvl = Math.max(1, Math.min(3, Math.floor(level))); + switch (lvl) { + case 1: + return 1000; + case 2: + return 1250; + case 3: + return 1500; + default: + return 1000; + } + } + warshipDamageRange(level: number): { min: number; max: number } { + const lvl = Math.max(1, Math.min(3, Math.floor(level))); + const bonus = 70 * (lvl - 1); + return { min: 200 + bonus, max: 325 + bonus }; + } + + // Submarine per-level stats + submarineLevelMaxHealth(level: number): number { + const lvl = Math.max(1, Math.min(3, Math.floor(level))); + switch (lvl) { + case 1: + return 1000; + case 2: + return 1250; + case 3: + return 1500; + default: + return 1000; + } + } + submarineDamageRange(level: number): { min: number; max: number } { + const lvl = Math.max(1, Math.min(3, Math.floor(level))); + const bonus = 70 * (lvl - 1); + return { min: 200 + bonus, max: 325 + bonus }; + } + // Paratroopers/Air attack paratrooperMaxNumber(): number { return 3; diff --git a/src/core/execution/ShellExecution.ts b/src/core/execution/ShellExecution.ts index 36d97b842..3de99bd79 100644 --- a/src/core/execution/ShellExecution.ts +++ b/src/core/execution/ShellExecution.ts @@ -78,6 +78,18 @@ export class ShellExecution implements Execution { const roll = this.random.nextInt(0, 5); // 0..5 const step = (range.max - range.min) / 5; return Math.round(range.min + roll * step); + } else if (this.ownerUnit.type() === UnitType.Warship) { + const level = this.ownerUnit.level ? this.ownerUnit.level() : 1; + const range = this.mg.config().warshipDamageRange(level); + const roll = this.random.nextInt(0, 5); + const step = (range.max - range.min) / 5; + return Math.round(range.min + roll * step); + } else if (this.ownerUnit.type() === UnitType.Submarine) { + const level = this.ownerUnit.level ? this.ownerUnit.level() : 1; + const range = this.mg.config().submarineDamageRange(level); + const roll = this.random.nextInt(0, 5); + const step = (range.max - range.min) / 5; + return Math.round(range.min + roll * step); } // Default: shell damage based on base value and 5-step multiplier diff --git a/src/core/execution/SubmarineExecution.ts b/src/core/execution/SubmarineExecution.ts index f95e767e3..80475266d 100644 --- a/src/core/execution/SubmarineExecution.ts +++ b/src/core/execution/SubmarineExecution.ts @@ -50,6 +50,13 @@ export class SubmarineExecution implements Execution { const lvl = Math.max(1, this.desiredLevel | 0); if (lvl > 1) { (this.submarine as any)._level = lvl; + // Apply per-level max health boost + const base = + this.mg.config().unitInfo(UnitType.Submarine).maxHealth ?? 1000; + const desired = this.mg.config().submarineLevelMaxHealth(lvl); + const bonus = Math.max(0, desired - base); + (this.submarine as any)._bonusMaxHealth = bonus; + (this.submarine as any)._health = BigInt(desired); this.mg.addUpdate(this.submarine.toUpdate()); } } diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index ad3b56d8f..7e40ff7be 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -56,6 +56,13 @@ export class WarshipExecution implements Execution { const lvl = Math.max(1, this.desiredLevel | 0); if (lvl > 1) { (this.warship as any)._level = lvl; + // Apply per-level max health boost + const base = + this.mg.config().unitInfo(UnitType.Warship).maxHealth ?? 1000; + const desired = this.mg.config().warshipLevelMaxHealth(lvl); + const bonus = Math.max(0, desired - base); + (this.warship as any)._bonusMaxHealth = bonus; + (this.warship as any)._health = BigInt(desired); this.mg.addUpdate(this.warship.toUpdate()); } } From 1761c1c23979287c596c79b97716171352251182 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Tue, 18 Nov 2025 23:25:49 +0100 Subject: [PATCH 3/8] Add 'Research all techs' option to game configuration --- resources/lang/en.json | 2 ++ src/client/HostLobbyModal.ts | 26 ++++++++++++++++++++++++++ src/client/SinglePlayerModal.ts | 21 +++++++++++++++++++++ src/core/GameRunner.ts | 11 +++++++++++ src/core/Schemas.ts | 2 ++ src/server/GameManager.ts | 1 + src/server/GameServer.ts | 3 +++ 7 files changed, 66 insertions(+) diff --git a/resources/lang/en.json b/resources/lang/en.json index 7c12086a9..37934c1a0 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -127,6 +127,7 @@ "disable_nations": "Disable Nations", "instant_build": "Instant Build", "instant_research": "Instant Research", + "research_all_techs": "Research All Techs", "infinite_gold": "Infinite Gold", "infinite_troops": "Infinite Troops", "disable_nukes": "Disable Nukes", @@ -209,6 +210,7 @@ "disable_nations": "Disable Nations", "instant_build": "Instant Build", "instant_research": "Instant Research", + "research_all_techs": "Research All Techs", "infinite_gold": "Infinite Gold", "infinite_troops": "Infinite Troops", "peace_timer": "Protected Start", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index f450f89a1..26cac9adf 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -54,6 +54,7 @@ export class HostLobbyModal extends LitElement { @state() private infiniteTroops: boolean = false; @state() private instantBuild: boolean = false; @state() private instantResearchHumanOnly: boolean = false; + @state() private researchAllTechs: boolean = false; @state() private lobbyId = ""; @state() private copySuccess = false; @state() private clients: ClientInfo[] = []; @@ -377,6 +378,27 @@ export class HostLobbyModal extends LitElement { + + + { + this.researchAllTechs = Boolean( + (e.target as HTMLInputElement).checked, + ); + this.putGameConfig(); + }} + .checked=${this.researchAllTechs} + /> + + ${translateText("host_modal.research_all_techs")} + + + + + + + (this.researchAllTechs = Boolean( + (e.target as HTMLInputElement).checked, + ))} + .checked=${this.researchAllTechs} + /> + + ${translateText("single_modal.research_all_techs")} + + + Object.values(UnitType).find((ut) => ut === u)) .filter((ut): ut is UnitType => ut !== undefined), diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index ae6688e55..0362bf4a8 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -34,6 +34,7 @@ import { import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader"; import { PseudoRandom } from "./PseudoRandom"; import { ClientID, GameStartInfo, Turn } from "./Schemas"; +import { getTechNodes } from "./tech/ResearchTree"; import { sanitize, simpleHash } from "./Util"; import { fixProfaneUsername } from "./validations/username"; @@ -233,6 +234,16 @@ export class GameRunner { } init() { + // Optionally grant all techs to all players at game start + if (this.game.config().gameConfig().researchAllTechs) { + const nodes = getTechNodes(); + const techIds = nodes.map((n) => n.id); + this.game + .players() + .forEach((p) => + techIds.forEach((id) => (p as any).addResearchedTech?.(id)), + ); + } if (this.game.config().bots() > 0) { this.game.addExecution( ...this.execManager.spawnBots(this.game.config().numBots()), diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index d47b830f3..e7d5ba09b 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -222,6 +222,8 @@ export const GameConfigSchema = z.object({ instantBuild: z.boolean(), // If true, human player's tech selection instantly researches that tech instantResearchHumanOnly: z.boolean().optional(), + // If true, all players start with all techs researched + researchAllTechs: z.boolean().optional(), maxPlayers: z.number().optional(), disabledUnits: z.enum(UnitType).array().optional(), playerTeams: TeamCountConfigSchema.optional(), diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index ab4fa8ea0..5dd4ca9af 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -46,6 +46,7 @@ export class GameManager { infiniteGold: false, infiniteTroops: false, instantBuild: false, + researchAllTechs: false, gameMode: GameMode.FFA, bots: 400, disabledUnits: [], diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 6d813464a..d226c9a8c 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -103,6 +103,9 @@ export class GameServer { this.gameConfig.instantResearchHumanOnly = gameConfig.instantResearchHumanOnly; } + if (gameConfig.researchAllTechs !== undefined) { + this.gameConfig.researchAllTechs = gameConfig.researchAllTechs; + } if (gameConfig.gameMode !== undefined) { this.gameConfig.gameMode = gameConfig.gameMode; } From 76aaa7c986499c67551c56e1f8a4b75ece36f418 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Tue, 18 Nov 2025 23:41:16 +0100 Subject: [PATCH 4/8] Use allPlayers() to include unspawned players in tech research at game start --- src/core/GameRunner.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 0362bf4a8..5bc45a1d8 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -238,8 +238,9 @@ export class GameRunner { if (this.game.config().gameConfig().researchAllTechs) { const nodes = getTechNodes(); const techIds = nodes.map((n) => n.id); + // Use allPlayers() so we include unspawned players at game start this.game - .players() + .allPlayers() .forEach((p) => techIds.forEach((id) => (p as any).addResearchedTech?.(id)), ); From c58543690c9544d13537771d979dc894de348527 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 19 Nov 2025 00:31:22 +0100 Subject: [PATCH 5/8] Enhance submarine ghost rendering and management - Update submarine ghost structure to include ownerID. - Implement ghost rendering and clearing logic in UnitLayer. - Clear persisted settings in GameRenderer for new game resets. --- src/client/graphics/GameRenderer.ts | 7 + src/client/graphics/layers/UnitLayer.ts | 165 ++++++++++++++++++------ src/core/game/GameView.ts | 28 +++- 3 files changed, 158 insertions(+), 42 deletions(-) diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index a21e89695..40df04715 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -47,6 +47,13 @@ export function createRenderer( game: GameView, eventBus: EventBus, ): GameRenderer { + // Remove persisted settings modals from previous games so they reset to default levels + document.querySelector("build-settings-modal")?.remove(); + document.querySelector("unit-upgrade-settings-modal")?.remove(); + // Clear persisted levels so they reset to 1 for the new game + localStorage.removeItem("buildSettings.levels"); + localStorage.removeItem("unitUpgradeSettings.levels"); + const transformHandler = new TransformHandler(game, eventBus, canvas); // Prevent main menu/page scrolling during gameplay diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 787aa55aa..b039c52dd 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -91,6 +91,9 @@ export class UnitLayer implements Layer { // Cache sprite sizes per UnitType to avoid repeated lookups when clearing private spriteSizeCache = new Map(); + private renderedGhosts = new Map(); + private renderedUnits = new Map(); + constructor( private game: GameView, private eventBus: EventBus, @@ -126,6 +129,7 @@ export class UnitLayer implements Layer { ?.[GameUpdateType.Unit]?.map((unit) => unit.id); this.updateUnitsSprites(unitIds ?? []); + this.updateGhosts(); } init() { @@ -344,31 +348,22 @@ export class UnitLayer implements Layer { this.interpolationCanvas.width = this.game.width(); this.interpolationCanvas.height = this.game.height(); - this.updateUnitsSprites(this.game.units().map((unit) => unit.id())); + this.renderedUnits.clear(); + const units = this.game.units(); + units.forEach((u) => this.renderedUnits.set(u.id(), u)); + this.updateUnitsSprites(units.map((unit) => unit.id())); // After redrawing units, render submarine ghosts (last known positions) + this.renderedGhosts.clear(); const ghosts = (this.game as any).submarineGhosts?.call(this.game) ?? []; for (const ghost of ghosts as Array<{ id: number; pos: number; expiresAt: number; + ownerID: number; }>) { - // Draw a faint submarine sprite at ghost.pos - const x = this.game.x(ghost.pos); - const y = this.game.y(ghost.pos); - // Simple faint marker: paint a small translucent cell using enemy color as default - this.context.save(); - this.context.globalAlpha = 0.3; - const dummyUnit = { - tile: () => ghost.pos, - type: () => UnitType.Submarine, - owner: () => this.game.myPlayer() ?? (this.game.players()[0] as any), - targetable: () => true, - isActive: () => false, - lastTile: () => ghost.pos, - } as unknown as UnitView; - this.drawSprite(dummyUnit as UnitView); - this.context.restore(); + this.drawGhost(ghost); + this.renderedGhosts.set(ghost.id, ghost.pos); } this.unitToTrail.forEach((trail, unit) => { @@ -386,13 +381,30 @@ export class UnitLayer implements Layer { } private updateUnitsSprites(unitIds: number[]) { - const unitsToUpdate = unitIds - ?.map((id) => this.game.unit(id)) - .filter((unit) => unit !== undefined) as UnitView[] | undefined; + const unitsToUpdate: UnitView[] = []; + const unitsToRemove: UnitView[] = []; + + if (unitIds) { + for (const id of unitIds) { + const unit = this.game.unit(id); + if (unit) { + unitsToUpdate.push(unit); + this.renderedUnits.set(id, unit); + } else { + const removed = this.renderedUnits.get(id); + if (removed) { + unitsToRemove.push(removed); + this.renderedUnits.delete(id); + } + } + } + } - if (unitsToUpdate && unitsToUpdate.length > 0) { + const allUnitsToClear = [...unitsToUpdate, ...unitsToRemove]; + + if (allUnitsToClear.length > 0) { const oldAngleByUnit = new Map(); - for (const u of unitsToUpdate) { + for (const u of allUnitsToClear) { oldAngleByUnit.set(u, this.unitToLastAngle.get(u) ?? null); } @@ -405,8 +417,13 @@ export class UnitLayer implements Layer { // the clearing and drawing of unit sprites need to be done in 2 passes // otherwise the sprite of a unit can be drawn on top of another unit - this.clearUnitsCells(unitsToUpdate, oldAngleByUnit); + this.clearUnitsCells(allUnitsToClear, oldAngleByUnit); this.drawUnitsCells(unitsToUpdate, angleByUnit); + + // Handle deactivation for removed units + for (const u of unitsToRemove) { + this.handleUnitDeactivation(u); + } } } @@ -503,13 +520,7 @@ export class UnitLayer implements Layer { unit.type() === UnitType.Submarine && unit.owner() !== this.game.myPlayer() ) { - const isAttacking = unit.isAttacking(); - const isDetected = unit.isDetectedByNavalUnit(); - const isOnCooldown = unit.isCooldown(); - const shouldShow = isAttacking || isDetected || isOnCooldown; - if (!shouldShow) { - continue; - } + // Server handles visibility filtering. } const position = this.interpolatePosition(unit, alpha); @@ -564,13 +575,9 @@ export class UnitLayer implements Layer { unit.type() === UnitType.Submarine && unit.owner() !== this.game.myPlayer() ) { - const isAttacking = unit.isAttacking(); - const isDetected = unit.isDetectedByNavalUnit(); - const isOnCooldown = unit.isCooldown(); - const shouldShow = isAttacking || isDetected || isOnCooldown; - if (!shouldShow) { - return; // Hidden submarine (no ghost rendering here; ghosts handled separately) - } + // Server handles visibility filtering (including linger time). + // If we receive an update for an enemy sub, it should be visible. + // We trust the server's judgment here to avoid client-side lag when linger is active. } // START: Custom rendering for owner's submarine visibility @@ -1182,4 +1189,88 @@ export class UnitLayer implements Layer { } return Date.now(); } + + private updateGhosts() { + const ghosts = (this.game as any).submarineGhosts?.call(this.game) ?? []; + const currentGhostIds = new Set(); + + for (const ghost of ghosts as Array<{ + id: number; + pos: number; + expiresAt: number; + ownerID: number; + }>) { + currentGhostIds.add(ghost.id); + if (!this.renderedGhosts.has(ghost.id)) { + this.drawGhost(ghost); + this.renderedGhosts.set(ghost.id, ghost.pos); + } + } + + for (const [id, pos] of this.renderedGhosts) { + if (!currentGhostIds.has(id)) { + this.clearGhost({ pos, ownerID: 0 }); // ownerID not needed for clearing + this.renderedGhosts.delete(id); + // If a unit is currently at this position, redraw it so it doesn't disappear + const unitAtPos = this.game.units().find((u) => u.tile() === pos); + if (unitAtPos) { + this.drawSprite(unitAtPos); + } + } + } + } + + private clearGhost(ghost: { pos: number; ownerID: number }) { + // Create a dummy unit to get the sprite size + // We need a valid owner for getColoredSprite, but for size it doesn't matter much + // as long as it returns a sprite. + const dummyUnit = { + tile: () => ghost.pos, + type: () => UnitType.Submarine, + owner: () => + this.game.playerBySmallID(ghost.ownerID) || this.game.players()[0], + targetable: () => true, + isActive: () => true, + lastTile: () => ghost.pos, + } as unknown as UnitView; + + const spriteSize = this.getSpriteSize(dummyUnit); + const newWidth = spriteSize; // Ghosts are drawn at 1.0 scale + const newHeight = spriteSize; + + // Badge overlay parameters: badge sits 1px outside top-right + // Ghosts default to level 1 (bronze) badge + const badgeSize = Math.max(2, Math.min(3, Math.round(newWidth * 0.18))); + const offset = 1; + const overlayTop = badgeSize + offset; // extend upwards to cover outside badge + const extraRight = badgeSize + offset; // full right-side extension beyond sprite + + const padding = 2; // small safety margin around computed bounds + const maxHalfWidth = newWidth / 2 + extraRight; + + const cx = Math.round(this.game.x(ghost.pos)); + const cy = Math.round(this.game.y(ghost.pos)); + + const left = cx - maxHalfWidth - padding; + const top = cy - newHeight / 2 - overlayTop - padding; + const width = maxHalfWidth * 2 + padding * 2; + const height = newHeight + overlayTop + padding * 2; + + this.context.clearRect(left, top, width, height); + } + + private drawGhost(ghost: { id: number; pos: number; ownerID: number }) { + this.context.save(); + this.context.globalAlpha = 0.3; + const dummyUnit = { + tile: () => ghost.pos, + type: () => UnitType.Submarine, + owner: () => this.game.playerBySmallID(ghost.ownerID), + targetable: () => true, + isActive: () => true, + lastTile: () => ghost.pos, + } as unknown as UnitView; + this.drawSprite(dummyUnit as UnitView); + this.context.restore(); + } } diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 4b9689365..531d555b1 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -491,8 +491,10 @@ export class GameView implements GameMap { private _focusedPlayer: PlayerView | null = null; private _alliances: AllianceViewData[] = []; // Submarine periodic pings removed; ghosts are used instead - private _submarineGhosts: Map = - new Map(); + private _submarineGhosts: Map< + number, + { pos: TileRef; expiresAt: Tick; ownerID: number } + > = new Map(); private _cooldownActive = new Set(); private unitGrid: UnitGrid; @@ -580,6 +582,7 @@ export class GameView implements GameMap { this._submarineGhosts.set(update.id, { pos: update.pos, expiresAt: expiresAt ?? this.ticks() + 300, + ownerID: update.ownerID, }); } else if (update.unitType === UnitType.Submarine) { // Receiving a real sub update clears any ghost @@ -647,12 +650,27 @@ export class GameView implements GameMap { this._emitEndedUnitCooldowns(); } - submarineGhosts(): Array<{ id: number; pos: TileRef; expiresAt: Tick }> { + submarineGhosts(): Array<{ + id: number; + pos: TileRef; + expiresAt: Tick; + ownerID: number; + }> { const now = this.ticks(); - const result: Array<{ id: number; pos: TileRef; expiresAt: Tick }> = []; + const result: Array<{ + id: number; + pos: TileRef; + expiresAt: Tick; + ownerID: number; + }> = []; for (const [id, ghost] of this._submarineGhosts) { if (ghost.expiresAt > now) { - result.push({ id, pos: ghost.pos, expiresAt: ghost.expiresAt }); + result.push({ + id, + pos: ghost.pos, + expiresAt: ghost.expiresAt, + ownerID: ghost.ownerID, + }); } else { this._submarineGhosts.delete(id); } From e1c05c97ae4a8c3fcbf6fb179a4ad31859f187cf Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 19 Nov 2025 00:32:43 +0100 Subject: [PATCH 6/8] Add tier badge rendering for Warships, Fighter Jets, and Submarines --- src/client/graphics/layers/UnitLayer.ts | 44 +++++++++++++++---------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index b039c52dd..81085dcd9 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -976,24 +976,32 @@ export class UnitLayer implements Layer { ); // Draw a tiny top-right corner badge offset 1px outside the sprite - const level = unit.level ? unit.level() : 1; - // Tier color mapping: 1→bronze, 2→silver, 3→gold, 4+→platinum - const tierColor = - level >= 4 - ? "#E5E4E2" /* platinum */ - : level === 3 - ? "#FFD700" /* gold */ - : level === 2 - ? "#C0C0C0" /* silver */ - : "#CD7F32"; /* bronze */ - // Badge size: crisp 2–3 px depending on sprite size - const badgeSize = Math.max(2, Math.min(3, Math.round(newWidth * 0.18))); - // Offset 1px to the right and 1px above the sprite's top-right corner - const offset = 1; - const badgeLeft = Math.round(cx + newWidth / 2 + offset); - const badgeTop = Math.round(cy - newHeight / 2 - badgeSize - offset); - this.context.fillStyle = tierColor; - this.context.fillRect(badgeLeft, badgeTop, badgeSize, badgeSize); + // Only for Warships, FighterJets, and Submarines + const type = unit.type(); + if ( + type === UnitType.Warship || + type === UnitType.FighterJet || + type === UnitType.Submarine + ) { + const level = unit.level ? unit.level() : 1; + // Tier color mapping: 1→bronze, 2→silver, 3→gold, 4+→platinum + const tierColor = + level >= 4 + ? "#E5E4E2" /* platinum */ + : level === 3 + ? "#FFD700" /* gold */ + : level === 2 + ? "#C0C0C0" /* silver */ + : "#CD7F32"; /* bronze */ + // Badge size: crisp 2–3 px depending on sprite size + const badgeSize = Math.max(2, Math.min(3, Math.round(newWidth * 0.18))); + // Offset 1px to the right and 1px above the sprite's top-right corner + const offset = 1; + const badgeLeft = Math.round(cx + newWidth / 2 + offset); + const badgeTop = Math.round(cy - newHeight / 2 - badgeSize - offset); + this.context.fillStyle = tierColor; + this.context.fillRect(badgeLeft, badgeTop, badgeSize, badgeSize); + } if (angle !== null) { this.context.restore(); From 2e3546b369f9f3e3b2a3044ae26714addc84e0c0 Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 19 Nov 2025 00:52:04 +0100 Subject: [PATCH 7/8] Suppress double-draw of interpolated unit sprites during base-canvas draw pass --- src/client/graphics/layers/UnitLayer.ts | 73 ++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index 81085dcd9..acb939079 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -65,6 +65,9 @@ export class UnitLayer implements Layer { private readonly SUBMARINE_SELECTION_RADIUS = 10; private readonly FIGHTER_JET_SELECTION_RADIUS = 10; + // Indicates we're in the base-canvas draw pass (used to suppress double-draw) + private drawingBasePass = false; + // Unit types that should be interpolated between ticks private readonly interpolatedUnitTypes: UnitType[] = [ UnitType.SAMMissile, @@ -476,8 +479,13 @@ export class UnitLayer implements Layer { unitViews: UnitView[], angleByUnit: Map, ) { - // Pass-through for now; angleByUnit helps avoid recomputation in drawSprite via an overload - unitViews.forEach((unitView) => this.onUnitEvent(unitView, angleByUnit)); + // Suppress base-canvas sprites for units that are also drawn via interpolation overlay + this.drawingBasePass = true; + try { + unitViews.forEach((unitView) => this.onUnitEvent(unitView, angleByUnit)); + } finally { + this.drawingBasePass = false; + } } private interpolatePosition(unit: UnitView, alpha: number) { @@ -904,6 +912,15 @@ export class UnitLayer implements Layer { angleByUnit = angleByUnitOrSizeMultiplier; } + // If we're in the base pass and this unit type is interpolated, skip drawing the sprite + // to avoid double images (the interpolation overlay will render it smoothly). + if ( + this.drawingBasePass && + this.interpolatedUnitTypes.includes(unit.type()) + ) { + return; + } + const x = this.game.x(unit.tile()); const y = this.game.y(unit.tile()); @@ -1074,8 +1091,60 @@ export class UnitLayer implements Layer { ? Math.round(position.y - sprite.width / 2) : position.y - sprite.width / 2; + // Apply rotation on interpolation overlay for aircraft + const isAircraft = + unit.type() === UnitType.Bomber || + unit.type() === UnitType.FighterJet || + unit.type() === UnitType.CargoPlane; + let rotated = false; + if (isAircraft) { + const angle = this.getUnitAngle(unit); + if (angle !== null) { + const cx = offsetX + sprite.width / 2; + const cy = offsetY + sprite.width / 2; + context.save(); + context.translate(cx, cy); + context.rotate(angle); + context.translate(-cx, -cy); + rotated = true; + } + } + context.drawImage(sprite, offsetX, offsetY, sprite.width, sprite.width); + // Draw the same tiny badge on interpolation overlay for select unit types + const type = unit.type(); + if ( + type === UnitType.Warship || + type === UnitType.FighterJet || + type === UnitType.Submarine + ) { + const level = (unit as any).level ? (unit as any).level() : 1; + const tierColor = + level >= 4 + ? "#E5E4E2" /* platinum */ + : level === 3 + ? "#FFD700" /* gold */ + : level === 2 + ? "#C0C0C0" /* silver */ + : "#CD7F32"; /* bronze */ + const badgeSize = Math.max( + 2, + Math.min(3, Math.round(sprite.width * 0.18)), + ); + const offset = 1; + const cx = offsetX + sprite.width / 2; + const cy = offsetY + sprite.width / 2; + const badgeLeft = Math.round(cx + sprite.width / 2 + offset); + const badgeTop = Math.round(cy - sprite.width / 2 - badgeSize - offset); + context.fillStyle = tierColor; + context.fillRect(badgeLeft, badgeTop, badgeSize, badgeSize); + } + + if (rotated) { + context.restore(); + } + if (!targetable) { context.restore(); } From c16588eaf27267001f42f9e376e471b64fda674a Mon Sep 17 00:00:00 2001 From: 1brucben <1benjbruce@gmail.com> Date: Wed, 19 Nov 2025 00:55:12 +0100 Subject: [PATCH 8/8] Enhance unit rendering by adding specific handling for Shell and MIRV Warhead types --- src/client/graphics/layers/UnitLayer.ts | 117 ++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts index acb939079..22bae93bb 100644 --- a/src/client/graphics/layers/UnitLayer.ts +++ b/src/client/graphics/layers/UnitLayer.ts @@ -533,17 +533,25 @@ export class UnitLayer implements Layer { const position = this.interpolatePosition(unit, alpha); - if (!isSpriteReady(unit.type())) { - continue; + switch (unit.type()) { + case UnitType.Shell: + this.renderShell(unit, position); + continue; + case UnitType.MIRVWarhead: + this.renderWarhead(unit, position); + continue; + default: + if (!isSpriteReady(unit.type())) { + continue; + } + this.drawSpriteAtPosition( + unit, + position, + this.getInterpolatedSpriteColor(unit), + this.interpolationContext, + false, + ); } - - this.drawSpriteAtPosition( - unit, - position, - this.getInterpolatedSpriteColor(unit), - this.interpolationContext, - false, - ); } } @@ -559,6 +567,95 @@ export class UnitLayer implements Layer { return undefined; } + private renderShell(unit: UnitView, position: { x: number; y: number }) { + const rel = this.relationship(unit); + const color = this.theme.borderColor(unit.owner()); + this.drawInterpolatedSquare(position, rel, color, 1, 1); + this.drawInterpolatedSquare(position, rel, color, 2, 0.4); + + const last = { + x: this.game.x(unit.lastTile()), + y: this.game.y(unit.lastTile()), + }; + if (last.x !== position.x || last.y !== position.y) { + this.drawInterpolatedSegment(last, position, rel, color, 0.7); + } + } + + private renderWarhead(unit: UnitView, position: { x: number; y: number }) { + const rel = this.relationship(unit); + const color = this.theme.borderColor(unit.owner()); + this.drawInterpolatedSquare(position, rel, color, 1, 1); + this.drawInterpolatedSquare(position, rel, color, 2, 0.35); + + const last = { + x: this.game.x(unit.lastTile()), + y: this.game.y(unit.lastTile()), + }; + if (last.x !== position.x || last.y !== position.y) { + this.drawInterpolatedSegment(last, position, rel, color, 0.5); + } + } + + private drawInterpolatedSquare( + position: { x: number; y: number }, + relationship: Relationship, + color: Colord, + size: number, + alpha: number, + ) { + if (!this.interpolationContext) { + return; + } + const ctx = this.interpolationContext; + ctx.fillStyle = this.resolveInterpolatedColor(relationship, color, alpha); + ctx.fillRect(position.x - size / 2, position.y - size / 2, size, size); + } + + private drawInterpolatedSegment( + start: { x: number; y: number }, + end: { x: number; y: number }, + relationship: Relationship, + color: Colord, + alpha: number, + ) { + if (!this.interpolationContext) { + return; + } + const ctx = this.interpolationContext; + ctx.strokeStyle = this.resolveInterpolatedColor(relationship, color, alpha); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + } + + private resolveInterpolatedColor( + relationship: Relationship, + color: Colord, + alpha: number, + ): string { + if (this.alternateView) { + return this.getAlternateViewColor(relationship) + .alpha(alpha) + .toRgbString(); + } + return color.alpha(alpha).toRgbString(); + } + + private getAlternateViewColor(relationship: Relationship): Colord { + switch (relationship) { + case Relationship.Self: + return this.theme.selfColor(); + case Relationship.Ally: + return this.theme.allyColor(); + case Relationship.Enemy: + default: + return this.theme.enemyColor(); + } + } + private relationship(unit: UnitView): Relationship { const myPlayer = this.game.myPlayer(); if (myPlayer === null) {