From 53cf2d43f81028203b56aaab5f209702292cce45 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 16 May 2026 08:55:02 -0700 Subject: [PATCH] migrate away from canvas --- package-lock.json | 7 + package.json | 1 + src/client/ClientGameRunner.ts | 75 ++ src/client/WebGLFrameBuilder.ts | 247 ++++++ src/client/graphics/GameRenderer.ts | 33 +- src/client/graphics/TransformHandler.ts | 4 +- .../graphics/layers/CoordinateGridLayer.ts | 324 -------- src/client/graphics/layers/FxLayer.ts | 379 --------- .../layers/NukeTrajectoryPreviewLayer.ts | 428 ---------- src/client/graphics/layers/RailroadLayer.ts | 501 ------------ src/client/graphics/layers/RailroadSprites.ts | 161 ---- src/client/graphics/layers/RailroadView.ts | 176 ---- src/client/graphics/layers/SAMRadiusLayer.ts | 334 -------- .../graphics/layers/StructureIconsLayer.ts | 457 +---------- src/client/graphics/layers/StructureLayer.ts | 303 ------- src/client/graphics/layers/TerrainLayer.ts | 108 --- src/client/graphics/layers/TerritoryLayer.ts | 709 ---------------- src/client/graphics/layers/UILayer.ts | 192 +---- src/client/graphics/layers/UnitLayer.ts | 768 ------------------ src/core/game/GameView.ts | 3 + tests/client/graphics/UILayer.test.ts | 105 --- 21 files changed, 364 insertions(+), 4951 deletions(-) create mode 100644 src/client/WebGLFrameBuilder.ts delete mode 100644 src/client/graphics/layers/CoordinateGridLayer.ts delete mode 100644 src/client/graphics/layers/FxLayer.ts delete mode 100644 src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts delete mode 100644 src/client/graphics/layers/RailroadLayer.ts delete mode 100644 src/client/graphics/layers/RailroadSprites.ts delete mode 100644 src/client/graphics/layers/RailroadView.ts delete mode 100644 src/client/graphics/layers/SAMRadiusLayer.ts delete mode 100644 src/client/graphics/layers/StructureLayer.ts delete mode 100644 src/client/graphics/layers/TerrainLayer.ts delete mode 100644 src/client/graphics/layers/TerritoryLayer.ts delete mode 100644 src/client/graphics/layers/UnitLayer.ts diff --git a/package-lock.json b/package-lock.json index fd3d31773..7113dfa9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "ip-anonymize": "^0.1.0", "jose": "^6.2.3", "js-yaml": "^4.1.1", + "lil-gui": "^0.21.0", "limiter": "^3.0.0", "nanoid": "^5.1.11", "node-html-parser": "^7.1.0", @@ -6689,6 +6690,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lil-gui": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.21.0.tgz", + "integrity": "sha512-tpvxN7v1GvE/Tv+GRopfOp0W7fVEjF4PltkuX8vOCIfim22rD1ztvfkoEMcv9lzQeuNUSeIrUmUjBwmlW/oUew==", + "license": "MIT" + }, "node_modules/limiter": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/limiter/-/limiter-3.0.0.tgz", diff --git a/package.json b/package.json index 3ed080276..7b5325228 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "ip-anonymize": "^0.1.0", "jose": "^6.2.3", "js-yaml": "^4.1.1", + "lil-gui": "^0.21.0", "limiter": "^3.0.0", "nanoid": "^5.1.11", "node-html-parser": "^7.1.0", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 5cb2ad853..22bec1aff 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -58,8 +58,11 @@ import { Transport, } from "./Transport"; import { createCanvas } from "./Utils"; +import { WebGLFrameBuilder } from "./WebGLFrameBuilder"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; import { GoToPlayerEvent } from "./graphics/TransformHandler"; +import { GameView as WebGLGameView } from "./render/gl"; +import { ALL_UNIT_TYPES } from "./render/types"; import { SoundManager } from "./sound/SoundManager"; export interface LobbyConfig { @@ -225,6 +228,68 @@ export function joinLobby( }; } +function mountWebGLDebugRenderer( + terrainMap: TerrainMapData, + gameView: GameView, + transformHandler: import("./graphics/TransformHandler").TransformHandler, +): { builder: WebGLFrameBuilder; syncCamera: () => void } { + const gameMap = terrainMap.gameMap; + const mapWidth = gameMap.width(); + const mapHeight = gameMap.height(); + + const terrainBytes = new Uint8Array(mapWidth * mapHeight); + for (let y = 0; y < mapHeight; y++) { + for (let x = 0; x < mapWidth; x++) { + terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y)); + } + } + + const glCanvas = createCanvas(); + glCanvas.id = "webgl-debug-canvas"; + glCanvas.style.pointerEvents = "none"; + document.body.insertBefore(glCanvas, document.body.firstChild); + + const palette = new Float32Array(4096 * 2 * 4); + const view = new WebGLGameView( + glCanvas, + { + mapWidth, + mapHeight, + unitTypes: [...ALL_UNIT_TYPES], + players: [], + }, + terrainBytes, + palette, + ); + + window.addEventListener("keydown", (e) => { + if (e.key === "\\") { + glCanvas.style.display = + glCanvas.style.display === "none" ? "block" : "none"; + } + }); + + const syncCamera = (): void => { + const scale = transformHandler.scale; + const dpr = window.devicePixelRatio || 1; + const canvasW = glCanvas.clientWidth; + const canvasH = glCanvas.clientHeight; + const centerX = + transformHandler.offsetX + + mapWidth / 2 + + (canvasW - mapWidth) / (2 * scale); + const centerY = + transformHandler.offsetY + + mapHeight / 2 + + (canvasH - mapHeight) / (2 * scale); + view.setCameraState(centerX, centerY, scale * dpr); + }; + + (window as unknown as { __webglView?: unknown }).__webglView = view; + + return { builder: new WebGLFrameBuilder(view, gameView), syncCamera }; +} + async function createClientGame( lobbyConfig: LobbyConfig, clientID: ClientID | undefined, @@ -276,6 +341,13 @@ async function createClientGame( lobbyConfig.playerRole, ); + const { builder: webglBuilder, syncCamera } = mountWebGLDebugRenderer( + gameMap, + gameView, + gameRenderer.transformHandler, + ); + gameRenderer.onPreRender = syncCamera; + console.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, ); @@ -291,6 +363,7 @@ async function createClientGame( gameView, soundManager, userSettings, + webglBuilder, ); } catch (err) { soundManager.dispose(); @@ -323,6 +396,7 @@ export class ClientGameRunner { private gameView: GameView, private soundManager: SoundManager, private userSettings: UserSettings, + private webglBuilder: WebGLFrameBuilder | null = null, ) { this.lastMessageTime = Date.now(); } @@ -433,6 +507,7 @@ export class ClientGameRunner { this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); }); this.gameView.update(gu); + this.webglBuilder?.update(this.gameView, gu); this.renderer.tick(); // Emit tick metrics event for performance overlay diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts new file mode 100644 index 000000000..be5a76819 --- /dev/null +++ b/src/client/WebGLFrameBuilder.ts @@ -0,0 +1,247 @@ +import { Colord } from "colord"; +import { PlayerType, TrainType, UnitType } from "../core/game/Game"; +import { GameUpdateType, GameUpdateViewData } from "../core/game/GameUpdates"; +import { GameView } from "../core/game/GameView"; +import { RailroadCache } from "./render/frame/railroad-cache"; +import { TrailManager } from "./render/frame/trail-manager"; +import { + PlayerStatic, + UnitState, + GameView as WebGLGameView, +} from "./render/gl"; +import { + BonusEvent, + ConquestFx, + DeadUnitFx, + PlayerTypeEnum, + TrainType as RendererTrainType, +} from "./render/types"; + +const TRAIL_TYPES: ReadonlySet = new Set([ + UnitType.TransportShip, + UnitType.AtomBomb, + UnitType.HydrogenBomb, + UnitType.MIRV, + UnitType.MIRVWarhead, +]); + +const PALETTE_SIZE = 4096; + +export class WebGLFrameBuilder { + private readonly mapW: number; + private readonly mapH: number; + private readonly tileState: Uint16Array; + private readonly palette: Float32Array; + private readonly knownSmallIDs = new Set(); + private readonly railroadCache: RailroadCache; + private readonly trailManager: TrailManager; + private readonly unitMap = new Map(); + private readonly trailIds: number[] = []; + + constructor( + private readonly view: WebGLGameView, + gameView: GameView, + ) { + this.mapW = gameView.width(); + this.mapH = gameView.height(); + this.tileState = new Uint16Array(this.mapW * this.mapH); + this.palette = new Float32Array(PALETTE_SIZE * 2 * 4); + this.railroadCache = new RailroadCache(this.mapW, this.mapH); + this.trailManager = new TrailManager(this.mapW, this.mapH); + } + + update(gameView: GameView, gu: GameUpdateViewData): void { + this.syncPlayers(gameView); + this.fillTileState(gameView); + this.fillUnitMap(gameView); + this.trailManager.update(this.unitMap, this.trailIds); + this.view.uploadTileAndTrailState( + this.tileState, + this.trailManager.getTrailState(), + ); + this.trailManager.clearDirtyRows(); + this.applyRailroads(gu); + this.view.updateStructures(this.unitMap); + this.view.updateUnits(this.unitMap, gameView.ticks()); + this.applyFxEvents(gameView, gu); + } + + private applyFxEvents(gameView: GameView, gu: GameUpdateViewData): void { + const deadUnits: DeadUnitFx[] = []; + for (const u of gu.updates[GameUpdateType.Unit] ?? []) { + if (u.isActive) continue; + deadUnits.push({ + unitType: u.unitType, + pos: u.pos, + reachedTarget: u.reachedTarget, + }); + } + if (deadUnits.length > 0) { + this.view.applyDeadUnits(deadUnits); + } + + const conquests: ConquestFx[] = []; + for (const c of gu.updates[GameUpdateType.ConquestEvent] ?? []) { + const conquered = gameView.player(c.conqueredId); + const loc = conquered.nameLocation(); + conquests.push({ + x: loc.x, + y: loc.y, + gold: Number(c.gold), + }); + } + if (conquests.length > 0) { + this.view.applyConquestEvents(conquests); + } + + const bonuses: BonusEvent[] = []; + for (const b of gu.updates[GameUpdateType.BonusEvent] ?? []) { + const player = gameView.player(b.player); + bonuses.push({ + playerID: b.player, + smallID: player.smallID(), + tile: b.tile, + gold: Number(b.gold), + troops: b.troops, + }); + } + if (bonuses.length > 0) { + this.view.applyBonusEvents(bonuses); + } + } + + private fillUnitMap(gameView: GameView): void { + this.unitMap.clear(); + this.trailIds.length = 0; + for (const u of gameView.units()) { + this.unitMap.set(u.id(), toUnitState(u)); + if (TRAIL_TYPES.has(u.type())) { + this.trailIds.push(u.id()); + } + } + } + + private applyRailroads(gu: GameUpdateViewData): void { + this.railroadCache.apply(gu); + if (this.railroadCache.railroadDirty) { + this.view.uploadRailroadState(this.railroadCache.railroadState); + this.railroadCache.clearDirty(); + } + if (this.railroadCache.revealedRailTiles.length > 0) { + this.view.applyRailroadDust(this.railroadCache.revealedRailTiles); + } + } + + private syncPlayers(gameView: GameView): void { + const newPlayers: PlayerStatic[] = []; + for (const p of gameView.players()) { + const smallID = p.smallID(); + if (this.knownSmallIDs.has(smallID)) continue; + this.knownSmallIDs.add(smallID); + + this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor()); + + newPlayers.push({ + smallID, + id: p.id(), + name: p.name(), + displayName: p.displayName(), + clientID: p.clientID(), + playerType: gamePlayerTypeToEnum(p.type()), + team: p.team() ?? null, + isLobbyCreator: p.isLobbyCreator(), + color: p.territoryColor().toHex(), + }); + } + if (newPlayers.length > 0) { + this.view.addPlayers(newPlayers, this.palette); + } + } + + private writePaletteEntry( + smallID: number, + fill: Colord, + border: Colord, + ): void { + const fillRgba = fill.toRgb(); + const fillOff = smallID * 4; + this.palette[fillOff] = fillRgba.r / 255; + this.palette[fillOff + 1] = fillRgba.g / 255; + this.palette[fillOff + 2] = fillRgba.b / 255; + this.palette[fillOff + 3] = 150 / 255; + + const borderRgba = border.toRgb(); + const borderOff = PALETTE_SIZE * 4 + smallID * 4; + this.palette[borderOff] = borderRgba.r / 255; + this.palette[borderOff + 1] = borderRgba.g / 255; + this.palette[borderOff + 2] = borderRgba.b / 255; + this.palette[borderOff + 3] = 1.0; + } + + private fillTileState(gameView: GameView): void { + const w = this.mapW; + const h = this.mapH; + const buf = this.tileState; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const ref = gameView.ref(x, y); + let v = gameView.ownerID(ref) & 0x0fff; + if (gameView.hasFallout(ref)) v |= 1 << 13; + buf[y * w + x] = v; + } + } + } +} + +function toUnitState(u: import("../core/game/GameView").UnitView): UnitState { + return { + id: u.id(), + unitType: u.type(), + ownerID: u.owner().smallID(), + lastOwnerID: null, + pos: u.tile(), + lastPos: u.lastTile(), + isActive: u.isActive(), + reachedTarget: u.reachedTarget(), + retreating: false, + targetable: u.targetable(), + markedForDeletion: u.markedForDeletion(), + health: u.hasHealth() ? u.health() : null, + underConstruction: u.isUnderConstruction(), + targetUnitId: u.targetUnitId() ?? null, + targetTile: u.targetTile() ?? null, + troops: u.troops(), + missileTimerQueue: u.missileTimerQueue(), + level: u.level(), + hasTrainStation: u.hasTrainStation(), + trainType: trainTypeToNum(u.trainType()), + loaded: u.isLoaded() ?? null, + constructionStartTick: u.isUnderConstruction() ? u.createdAt() : null, + }; +} + +function trainTypeToNum(t: TrainType | undefined): number | null { + switch (t) { + case TrainType.Engine: + return RendererTrainType.Engine; + case TrainType.TailEngine: + return RendererTrainType.TailEngine; + case TrainType.Carriage: + return RendererTrainType.Carriage; + default: + return null; + } +} + +function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum { + switch (t) { + case PlayerType.Human: + return PlayerTypeEnum.Human; + case PlayerType.Bot: + return PlayerTypeEnum.Bot; + case PlayerType.Nation: + return PlayerTypeEnum.Nation; + default: + return PlayerTypeEnum.Bot; + } +} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 4df65facc..a25bff278 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -13,11 +13,9 @@ import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; import { ControlPanel } from "./layers/ControlPanel"; -import { CoordinateGridLayer } from "./layers/CoordinateGridLayer"; import { DynamicUILayer } from "./layers/DynamicUILayer"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; -import { FxLayer } from "./layers/FxLayer"; import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; @@ -28,23 +26,16 @@ import { Leaderboard } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; -import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer"; import { PerformanceOverlay } from "./layers/PerformanceOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; -import { RailroadLayer } from "./layers/RailroadLayer"; import { ReplayPanel } from "./layers/ReplayPanel"; -import { SAMRadiusLayer } from "./layers/SAMRadiusLayer"; import { SettingsModal } from "./layers/SettingsModal"; import { SpawnTimer } from "./layers/SpawnTimer"; import { StructureIconsLayer } from "./layers/StructureIconsLayer"; -import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; -import { TerrainLayer } from "./layers/TerrainLayer"; -import { TerritoryLayer } from "./layers/TerritoryLayer"; import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; -import { UnitLayer } from "./layers/UnitLayer"; import { WinModal } from "./layers/WinModal"; export function createRenderer( @@ -230,9 +221,6 @@ export function createRenderer( } headsUpMessage.game = game; - const structureLayer = new StructureLayer(game, eventBus, transformHandler); - const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState); - const performanceOverlay = document.querySelector( "performance-overlay", ) as PerformanceOverlay; @@ -275,16 +263,7 @@ export function createRenderer( // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). const layers: Layer[] = [ - new TerrainLayer(game, transformHandler), - new TerritoryLayer(game, eventBus, transformHandler), - new RailroadLayer(game, eventBus, transformHandler, uiState), - new CoordinateGridLayer(game, eventBus, transformHandler), - structureLayer, - samRadiusLayer, - new UnitLayer(game, eventBus, transformHandler), - new FxLayer(game, eventBus, transformHandler), new UILayer(game, eventBus, transformHandler), - new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), new StructureIconsLayer(game, eventBus, uiState, transformHandler), new DynamicUILayer(game, transformHandler, eventBus), new NameLayer(game, transformHandler, eventBus), @@ -338,6 +317,7 @@ export class GameRenderer { private layerTickState = new Map(); private renderFramesSinceLastTick: number = 0; private renderLayerDurationsSinceLastTick: Record = {}; + public onPreRender: (() => void) | null = null; constructor( private game: GameView, @@ -348,7 +328,7 @@ export class GameRenderer { private layers: Layer[], private performanceOverlay: PerformanceOverlay, ) { - const context = canvas.getContext("2d", { alpha: false }); + const context = canvas.getContext("2d", { alpha: true }); if (context === null) throw new Error("2d context not supported"); this.context = context; } @@ -399,13 +379,8 @@ export class GameRenderer { FrameProfiler.clear(); } const start = performance.now(); - // Set background - this.context.fillStyle = this.game - .config() - .theme() - .backgroundColor() - .toHex(); - this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.onPreRender?.(); + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); const handleTransformState = ( needsTransform: boolean, diff --git a/src/client/graphics/TransformHandler.ts b/src/client/graphics/TransformHandler.ts index 90966525c..94e9535c9 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/graphics/TransformHandler.ts @@ -28,8 +28,8 @@ export const CAMERA_SMOOTHING = 0.03; export class TransformHandler { public scale: number = 1.8; private _boundingRect: DOMRect; - private offsetX: number = -350; - private offsetY: number = -200; + public offsetX: number = -350; + public offsetY: number = -200; private lastGoToCallTime: number | null = null; private target: Cell | null; diff --git a/src/client/graphics/layers/CoordinateGridLayer.ts b/src/client/graphics/layers/CoordinateGridLayer.ts deleted file mode 100644 index 885f76f82..000000000 --- a/src/client/graphics/layers/CoordinateGridLayer.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { EventBus } from "../../../core/EventBus"; -import { Cell } from "../../../core/game/Game"; -import { GameView } from "../../../core/game/GameView"; -import { - AlternateViewEvent, - ToggleCoordinateGridEvent, -} from "../../InputHandler"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -const BASE_CELL_COUNT = 10; -const MAX_COLUMNS = 50; -const MIN_ROWS = 2; -const LABEL_PADDING = 8; - -const toAlphaLabel = (index: number): string => { - let value = index; - let label = ""; - do { - label = String.fromCharCode(65 + (value % 26)) + label; - value = Math.floor(value / 26) - 1; - } while (value >= 0); - return label; -}; - -const computeGrid = (width: number, height: number) => { - // Initial square-ish estimate - let cellSize = Math.min(width, height) / BASE_CELL_COUNT; - let rows = Math.max(1, Math.round(height / cellSize)); - let cols = Math.max(1, Math.round(width / cellSize)); - - // Cap columns and adjust rows accordingly - if (cols > MAX_COLUMNS) { - const maxRowsForCols = Math.floor((MAX_COLUMNS * height) / width); - rows = Math.max(MIN_ROWS, Math.min(rows, maxRowsForCols)); - cols = MAX_COLUMNS; - } - - cellSize = Math.min(width / cols, height / rows); - const fullCols = Math.max(1, Math.floor(width / cellSize)); - const fullRows = Math.max(1, Math.floor(height / cellSize)); - - const remainderX = Math.max(0, width - fullCols * cellSize); - const remainderY = Math.max(0, height - fullRows * cellSize); - - const hasExtraCol = remainderX > 0.001; - const hasExtraRow = remainderY > 0.001; - - const totalCols = fullCols + (hasExtraCol ? 1 : 0); - const totalRows = fullRows + (hasExtraRow ? 1 : 0); - - const lastColWidth = hasExtraCol ? remainderX : cellSize; - const lastRowHeight = hasExtraRow ? remainderY : cellSize; - - return { - cellSize, - rows: totalRows, - cols: totalCols, - fullCols, - fullRows, - lastColWidth, - lastRowHeight, - hasExtraCol, - hasExtraRow, - gridWidth: width, - gridHeight: height, - }; -}; - -export class CoordinateGridLayer implements Layer { - private isVisible = false; - private alternateView = false; - private cachedGridCanvas: HTMLCanvasElement | null = null; - private cachedGridContext: CanvasRenderingContext2D | null = null; - private cachedGridKey = ""; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) {} - - init() { - this.eventBus.on(ToggleCoordinateGridEvent, (event) => { - this.isVisible = event.enabled; - }); - this.eventBus.on(AlternateViewEvent, (event) => { - this.alternateView = event.alternateView; - }); - } - - shouldTransform(): boolean { - return false; - } - - renderLayer(context: CanvasRenderingContext2D) { - if (!this.isVisible && !this.alternateView) return; - - const width = this.game.width(); - const height = this.game.height(); - if (width <= 0 || height <= 0) return; - const canvasWidth = context.canvas.width; - const canvasHeight = context.canvas.height; - - const cacheKey = this.buildCacheKey( - width, - height, - canvasWidth, - canvasHeight, - ); - const cacheContext = this.ensureCacheContext(canvasWidth, canvasHeight); - if (cacheContext === null || this.cachedGridCanvas === null) return; - - if (this.cachedGridKey !== cacheKey) { - cacheContext.clearRect(0, 0, canvasWidth, canvasHeight); - this.drawGrid(cacheContext, width, height); - this.cachedGridKey = cacheKey; - } - - context.drawImage(this.cachedGridCanvas, 0, 0); - } - - private ensureCacheContext( - canvasWidth: number, - canvasHeight: number, - ): CanvasRenderingContext2D | null { - this.cachedGridCanvas ??= document.createElement("canvas"); - - if ( - this.cachedGridCanvas.width !== canvasWidth || - this.cachedGridCanvas.height !== canvasHeight - ) { - this.cachedGridCanvas.width = canvasWidth; - this.cachedGridCanvas.height = canvasHeight; - this.cachedGridContext = null; - this.cachedGridKey = ""; - } - - this.cachedGridContext ??= this.cachedGridCanvas.getContext("2d"); - - return this.cachedGridContext; - } - - private buildCacheKey( - width: number, - height: number, - canvasWidth: number, - canvasHeight: number, - ): string { - const topLeft = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, 0), - ); - const bottomRight = this.transformHandler.worldToCanvasCoordinates( - new Cell(width, height), - ); - const darkMode = this.game.config().userSettings()?.darkMode() ?? false; - return [ - width, - height, - canvasWidth, - canvasHeight, - this.transformHandler.scale.toFixed(4), - topLeft.x.toFixed(2), - topLeft.y.toFixed(2), - bottomRight.x.toFixed(2), - bottomRight.y.toFixed(2), - darkMode ? "1" : "0", - ].join("|"); - } - - private drawGrid( - context: CanvasRenderingContext2D, - width: number, - height: number, - ) { - const { - cellSize, - rows, - cols, - fullCols, - fullRows, - lastColWidth, - lastRowHeight, - hasExtraCol, - hasExtraRow, - gridWidth, - gridHeight, - } = computeGrid(width, height); - const cellWidth = cellSize; - const cellHeight = cellSize; - const canvasWidth = context.canvas.width; - const canvasHeight = context.canvas.height; - - const mapTopScreenRaw = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, 0), - ).y; - const mapBottomScreenRaw = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, height), - ).y; - const mapLeftScreenRaw = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, 0), - ).x; - const mapRightScreenRaw = this.transformHandler.worldToCanvasCoordinates( - new Cell(width, 0), - ).x; - - const mapTopScreen = Math.min(mapTopScreenRaw, mapBottomScreenRaw); - const mapLeftScreen = Math.min(mapLeftScreenRaw, mapRightScreenRaw); - const mapTopWorld = 0; - const mapLeftWorld = 0; - - context.save(); - context.strokeStyle = "rgba(255, 255, 255, 0.35)"; - context.lineWidth = 1.25; - context.beginPath(); - - for (let col = 0; col <= fullCols; col++) { - const worldX = col * cellWidth + mapLeftWorld; - const screenX = this.transformHandler.worldToCanvasCoordinates( - new Cell(worldX, mapTopWorld), - ).x; - if (screenX < -1 || screenX > canvasWidth + 1) continue; - const screenBottom = this.transformHandler.worldToCanvasCoordinates( - new Cell(worldX, gridHeight), - ).y; - context.moveTo(screenX, mapTopScreen); - context.lineTo(screenX, screenBottom); - } - // Final vertical line at map right edge only if grid fits perfectly - if (!hasExtraCol) { - const mapRightLine = this.transformHandler.worldToCanvasCoordinates( - new Cell(gridWidth, mapTopWorld), - ).x; - context.moveTo(mapRightLine, mapTopScreen); - context.lineTo( - mapRightLine, - this.transformHandler.worldToCanvasCoordinates( - new Cell(gridWidth, gridHeight), - ).y, - ); - } - - for (let row = 0; row <= fullRows; row++) { - const worldY = row * cellHeight + mapTopWorld; - const screenY = this.transformHandler.worldToCanvasCoordinates( - new Cell(mapLeftWorld, worldY), - ).y; - if (screenY < -1 || screenY > canvasHeight + 1) continue; - const screenRight = this.transformHandler.worldToCanvasCoordinates( - new Cell(gridWidth, worldY), - ).x; - context.moveTo(mapLeftScreen, screenY); - context.lineTo(screenRight, screenY); - } - // Final horizontal line at map bottom edge only if grid fits perfectly - if (!hasExtraRow) { - const mapBottomLine = this.transformHandler.worldToCanvasCoordinates( - new Cell(mapLeftWorld, gridHeight), - ).y; - context.moveTo(mapLeftScreen, mapBottomLine); - context.lineTo( - this.transformHandler.worldToCanvasCoordinates( - new Cell(gridWidth, gridHeight), - ).x, - mapBottomLine, - ); - } - - context.stroke(); - - context.font = "12px monospace"; - - const isDarkMode = this.game.config().userSettings()?.darkMode() ?? false; - const drawLabel = (text: string, x: number, y: number) => { - context.textAlign = "left"; - context.textBaseline = "top"; - context.fillStyle = isDarkMode - ? "rgba(255, 255, 255, 0.9)" - : "rgba(20, 20, 20, 0.9)"; - context.fillText(text, x, y); - }; - - // Render per-cell labels (like A1, B1, etc.) at cell top-left - const fontSize = Math.min( - 16, - Math.max(9, 10 + (this.transformHandler.scale - 1) * 1.2), - ); - context.font = `${fontSize}px monospace`; - for (let row = 0; row < rows; row++) { - const rowLabel = toAlphaLabel(row); - const startY = row * cellHeight; - const rowHeight = row < fullRows ? cellHeight : lastRowHeight; - const centerY = startY + rowHeight / 2; - const screenY = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, centerY), - ).y; - if (screenY < -LABEL_PADDING || screenY > canvasHeight + LABEL_PADDING) - continue; - - for (let col = 0; col < cols; col++) { - const startX = col * cellWidth; - const colWidth = col < fullCols ? cellWidth : lastColWidth; - const centerX = startX + colWidth / 2; - const screenX = this.transformHandler.worldToCanvasCoordinates( - new Cell(centerX, centerY), - ).x; - if (screenX < -LABEL_PADDING || screenX > canvasWidth + LABEL_PADDING) - continue; - - // Position at cell top-left in screen space - const cellTopLeft = this.transformHandler.worldToCanvasCoordinates( - new Cell(startX, startY), - ); - drawLabel( - `${rowLabel}${col + 1}`, - cellTopLeft.x + LABEL_PADDING, - cellTopLeft.y + LABEL_PADDING, - ); - } - } - - context.restore(); - } -} diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts deleted file mode 100644 index 22ce78ff5..000000000 --- a/src/client/graphics/layers/FxLayer.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { Theme } from "src/core/configuration/Theme"; -import { EventBus } from "../../../core/EventBus"; -import { UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, UnitView } from "../../../core/game/GameView"; -import { PlaySoundEffectEvent } from "../../sound/Sounds"; -import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; -import { conquestFxFactory } from "../fx/ConquestFx"; -import { Fx, FxType } from "../fx/Fx"; -import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; -import { SpriteFx } from "../fx/SpriteFx"; -import { UnitExplosionFx } from "../fx/UnitExplosionFx"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; -import { RailTileChangedEvent } from "./RailroadLayer"; -export class FxLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - - private lastRefreshMs: number = 0; - private refreshRate: number = 10; - private theme: Theme; - private animatedSpriteLoader: AnimatedSpriteLoader = - new AnimatedSpriteLoader(); - - private allFx: Fx[] = []; - private hasBufferedFrame = false; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) { - this.theme = this.game.config().theme(); - } - - shouldTransform(): boolean { - return true; - } - - private fxEnabled(): boolean { - return this.game.config().userSettings()?.fxLayer() ?? true; - } - - tick() { - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) - ?.forEach((unitView) => { - if (unitView === undefined) return; - this.onUnitEvent(unitView); - }); - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.ConquestEvent]?.forEach((update) => { - if (update === undefined) return; - this.onConquestEvent(update); - }); - } - - onUnitEvent(unit: UnitView) { - // Detect unit creation (launches, warship built) - if (unit.isActive() && unit.createdAt() === this.game.ticks()) { - this.onUnitCreated(unit); - } - - switch (unit.type()) { - case UnitType.AtomBomb: { - this.onNukeEvent(unit, 70); - break; - } - case UnitType.MIRVWarhead: - this.onNukeEvent(unit, 70); - break; - case UnitType.HydrogenBomb: { - this.onNukeEvent(unit, 160); - break; - } - case UnitType.Warship: - this.onWarshipEvent(unit); - break; - case UnitType.Shell: - this.onShellEvent(unit); - break; - case UnitType.Train: - this.onTrainEvent(unit); - break; - case UnitType.DefensePost: - case UnitType.City: - case UnitType.Port: - case UnitType.MissileSilo: - case UnitType.SAMLauncher: - case UnitType.Factory: - this.onStructureEvent(unit); - break; - } - } - - onUnitCreated(unit: UnitView) { - switch (unit.type()) { - case UnitType.AtomBomb: - this.eventBus.emit(new PlaySoundEffectEvent("atom-launch")); - break; - case UnitType.HydrogenBomb: - this.eventBus.emit(new PlaySoundEffectEvent("hydrogen-launch")); - break; - case UnitType.MIRV: - this.eventBus.emit(new PlaySoundEffectEvent("mirv-launch")); - break; - case UnitType.Warship: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("build-warship")); - } - break; - case UnitType.City: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("build-city")); - } - break; - case UnitType.Port: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("build-port")); - } - break; - case UnitType.DefensePost: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("build-defense-post")); - } - break; - case UnitType.SAMLauncher: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("sam-built")); - } - break; - } - } - - onShellEvent(unit: UnitView) { - if (!unit.isActive()) { - if (unit.reachedTarget() && this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const explosion = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.MiniExplosion, - ); - this.allFx.push(explosion); - } - } - } - - onTrainEvent(unit: UnitView) { - if (!unit.isActive()) { - if (!unit.reachedTarget() && this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const explosion = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.MiniExplosion, - ); - this.allFx.push(explosion); - } - } - } - - onRailroadEvent(tile: TileRef) { - if (!this.fxEnabled()) return; - // No need for pseudorandom, this is fx - const chanceFx = Math.floor(Math.random() * 3); - if (chanceFx === 0) { - const x = this.game.x(tile); - const y = this.game.y(tile); - const animation = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.Dust, - ); - this.allFx.push(animation); - } - } - - onConquestEvent(conquest: ConquestUpdate) { - // Only display fx for the current player - const conqueror = this.game.player(conquest.conquerorId); - if (conqueror !== this.game.myPlayer()) { - return; - } - - this.eventBus.emit(new PlaySoundEffectEvent("ka-ching")); - - if (this.fxEnabled()) { - this.allFx.push( - conquestFxFactory(this.animatedSpriteLoader, conquest, this.game), - ); - } - } - - onWarshipEvent(unit: UnitView) { - if (!unit.isActive() && this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const shipExplosion = new UnitExplosionFx( - this.animatedSpriteLoader, - x, - y, - this.game, - ); - this.allFx.push(shipExplosion); - const sinkingShip = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.SinkingShip, - undefined, - unit.owner(), - this.theme, - ); - this.allFx.push(sinkingShip); - } - } - - onStructureEvent(unit: UnitView) { - if (!unit.isActive() && this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const explosion = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.BuildingExplosion, - ); - this.allFx.push(explosion); - } - } - - onNukeEvent(unit: UnitView, radius: number) { - if (!unit.isActive()) { - if (!unit.reachedTarget()) { - this.handleSAMInterception(unit); - } else { - // Kaboom - this.handleNukeExplosion(unit, radius); - } - } - } - - handleNukeExplosion(unit: UnitView, radius: number) { - if (this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const nukeFx = nukeFxFactory( - this.animatedSpriteLoader, - x, - y, - radius, - this.game, - ); - this.allFx = this.allFx.concat(nukeFx); - } - const sound = - unit.type() === UnitType.HydrogenBomb ? "hydrogen-hit" : "atom-hit"; - this.eventBus.emit(new PlaySoundEffectEvent(sound)); - } - - handleSAMInterception(unit: UnitView) { - if (this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const explosion = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.SAMExplosion, - ); - this.allFx.push(explosion); - const shockwave = new ShockwaveFx(x, y, 800, 40); - this.allFx.push(shockwave); - } - } - - async init() { - this.redraw(); - - this.eventBus.on(RailTileChangedEvent, (e) => { - this.onRailroadEvent(e.tile); - }); - try { - this.animatedSpriteLoader.loadAllAnimatedSpriteImages(); - console.log("FX sprites loaded successfully"); - } catch (err) { - console.error("Failed to load FX sprites:", err); - } - } - - redraw(): void { - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d"); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - this.context.imageSmoothingEnabled = false; - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - } - - renderLayer(context: CanvasRenderingContext2D) { - const nowMs = performance.now(); - - const hasFx = this.allFx.length > 0; - if (!this.game.config().userSettings()?.fxLayer() || !hasFx) { - if (this.hasBufferedFrame) { - // Clear stale pixels once when fx ends/disabled so re-enabling doesn't - // flash old frames. - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.hasBufferedFrame = false; - } - this.lastRefreshMs = nowMs; - return; - } - - const needsRefresh = - !this.hasBufferedFrame || nowMs > this.lastRefreshMs + this.refreshRate; - if (needsRefresh) { - const delta = this.hasBufferedFrame ? nowMs - this.lastRefreshMs : 0; - this.renderAllFx(delta); - this.lastRefreshMs = nowMs; - this.hasBufferedFrame = true; - } - - this.drawVisibleFx(context); - } - - private drawVisibleFx(context: CanvasRenderingContext2D) { - const mapW = this.game.width(); - const mapH = this.game.height(); - - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const pad = 2; - - const left = Math.max(0, Math.floor(topLeft.x - pad)); - const top = Math.max(0, Math.floor(topLeft.y - pad)); - const right = Math.min(mapW, Math.ceil(bottomRight.x + pad)); - const bottom = Math.min(mapH, Math.ceil(bottomRight.y + pad)); - - const width = Math.max(0, right - left); - const height = Math.max(0, bottom - top); - if (width === 0 || height === 0) return; - - context.drawImage( - this.canvas, - left, - top, - width, - height, - -mapW / 2 + left, - -mapH / 2 + top, - width, - height, - ); - } - - private renderAllFx(delta: number) { - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.renderContextFx(delta); - } - - renderContextFx(duration: number) { - for (let i = this.allFx.length - 1; i >= 0; i--) { - if (!this.allFx[i].renderTick(duration, this.context)) { - this.allFx.splice(i, 1); - } - } - } -} diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts deleted file mode 100644 index 447f92d87..000000000 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { EventBus } from "../../../core/EventBus"; -import { listNukeBreakAlliance } from "../../../core/execution/Util"; -import { UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameView } from "../../../core/game/GameView"; -import { UniversalPathFinding } from "../../../core/pathfinding/PathFinder"; -import { - GhostStructureChangedEvent, - MouseMoveEvent, - SwapRocketDirectionEvent, -} from "../../InputHandler"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; - -/** - * Layer responsible for rendering the nuke trajectory preview line - * when a nuke type (AtomBomb or HydrogenBomb) is selected and the user hovers over potential targets. - */ -export class NukeTrajectoryPreviewLayer implements Layer { - // Trajectory preview state - private mousePos = { x: 0, y: 0 }; - private trajectoryPoints: TileRef[] = []; - private untargetableSegmentBounds: [number, number] = [-1, -1]; - private targetedIndex = -1; - private lastTrajectoryUpdate: number = 0; - private lastTargetTile: TileRef | null = null; - private currentGhostStructure: UnitType | null = null; - // Cache spawn tile to avoid expensive player.buildables() calls - private cachedSpawnTile: TileRef | null = null; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - private uiState: UIState, - ) {} - - shouldTransform(): boolean { - return true; - } - - init() { - this.eventBus.on(MouseMoveEvent, (e) => { - this.mousePos.x = e.x; - this.mousePos.y = e.y; - }); - this.eventBus.on(GhostStructureChangedEvent, (e) => { - this.currentGhostStructure = e.ghostStructure; - // Clear trajectory if ghost structure changed - if ( - e.ghostStructure !== UnitType.AtomBomb && - e.ghostStructure !== UnitType.HydrogenBomb - ) { - this.trajectoryPoints = []; - this.lastTargetTile = null; - this.cachedSpawnTile = null; - } - }); - this.eventBus.on(SwapRocketDirectionEvent, (event) => { - this.uiState.rocketDirectionUp = event.rocketDirectionUp; - // Force trajectory recalculation - this.lastTargetTile = null; - }); - } - - tick() { - this.updateTrajectoryPreview(); - } - - renderLayer(context: CanvasRenderingContext2D) { - // Update trajectory path each frame for smooth responsiveness - this.updateTrajectoryPath(); - this.drawTrajectoryPreview(context); - } - - /** - * Update trajectory preview - called from tick() to cache spawn tile via expensive player.buildables() call - * This only runs when target tile changes, minimizing worker thread communication - */ - private updateTrajectoryPreview() { - const ghostStructure = this.currentGhostStructure; - const isNukeType = - ghostStructure === UnitType.AtomBomb || - ghostStructure === UnitType.HydrogenBomb; - - // Clear trajectory if not a nuke type - if (!isNukeType) { - this.cachedSpawnTile = null; - return; - } - - // Throttle updates (similar to StructureIconsLayer.renderGhost) - const now = performance.now(); - if (now - this.lastTrajectoryUpdate < 50) { - return; - } - this.lastTrajectoryUpdate = now; - - const player = this.game.myPlayer(); - if (!player) { - this.trajectoryPoints = []; - this.lastTargetTile = null; - this.cachedSpawnTile = null; - return; - } - - // Convert mouse position to world coordinates - const worldCoords = this.transformHandler.screenToWorldCoordinates( - this.mousePos.x, - this.mousePos.y, - ); - - if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { - this.trajectoryPoints = []; - this.lastTargetTile = null; - this.cachedSpawnTile = null; - return; - } - - const targetTile = this.game.ref(worldCoords.x, worldCoords.y); - - // Only recalculate if target tile changed - if (this.lastTargetTile === targetTile) { - return; - } - - this.lastTargetTile = targetTile; - - // Get buildable units to find spawn tile (expensive call - only on tick when tile changes) - player - .buildables(targetTile, [ghostStructure]) - .then((buildables) => { - // Ignore stale results if target changed - if (this.lastTargetTile !== targetTile) { - return; - } - - const buildableUnit = buildables.find( - (bu) => bu.type === ghostStructure, - ); - - if (!buildableUnit || buildableUnit.canBuild === false) { - this.cachedSpawnTile = null; - return; - } - - const spawnTile = buildableUnit.canBuild; - if (!spawnTile) { - this.cachedSpawnTile = null; - return; - } - - // Cache the spawn tile for use in updateTrajectoryPath() - this.cachedSpawnTile = spawnTile; - }) - .catch(() => { - // Handle errors silently - this.cachedSpawnTile = null; - }); - } - - /** - * Update trajectory path - called from renderLayer() each frame for smooth visual feedback - * Uses cached spawn tile to avoid expensive player.buildables() calls - */ - private updateTrajectoryPath() { - const ghostStructure = this.currentGhostStructure; - const isNukeType = - ghostStructure === UnitType.AtomBomb || - ghostStructure === UnitType.HydrogenBomb; - - // Clear trajectory if not a nuke type or no cached spawn tile - if (!isNukeType || !this.cachedSpawnTile) { - this.trajectoryPoints = []; - return; - } - - const player = this.game.myPlayer(); - if (!player) { - this.trajectoryPoints = []; - return; - } - - // Convert mouse position to world coordinates - const worldCoords = this.transformHandler.screenToWorldCoordinates( - this.mousePos.x, - this.mousePos.y, - ); - - if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { - this.trajectoryPoints = []; - return; - } - - const targetTile = this.game.ref(worldCoords.x, worldCoords.y); - - // Calculate trajectory using ParabolaUniversalPathFinder with cached spawn tile - const speed = this.game.config().defaultNukeSpeed(); - const pathFinder = UniversalPathFinding.Parabola(this.game, { - increment: speed, - distanceBasedHeight: true, // AtomBomb/HydrogenBomb use distance-based height - directionUp: this.uiState.rocketDirectionUp, - }); - - this.trajectoryPoints = - pathFinder.findPath(this.cachedSpawnTile, targetTile) ?? []; - - // NOTE: This is a lot to do in the rendering method, naive - // But trajectory is already calculated here and needed for prediction. - // From testing, does not seem to have much effect, so I keep it this way. - - // Calculate points when bomb targetability switches - const targetRangeSquared = - this.game.config().defaultNukeTargetableRange() ** 2; - - // Find two switch points where bomb transitions: - // [0]: leaves spawn range, enters untargetable zone - // [1]: enters target range, becomes targetable again - this.untargetableSegmentBounds = [-1, -1]; - for (let i = 0; i < this.trajectoryPoints.length; i++) { - const tile = this.trajectoryPoints[i]; - if (this.untargetableSegmentBounds[0] === -1) { - if ( - this.game.euclideanDistSquared(tile, this.cachedSpawnTile) > - targetRangeSquared - ) { - if ( - this.game.euclideanDistSquared(tile, targetTile) < - targetRangeSquared - ) { - // overlapping spawn & target range - break; - } else { - this.untargetableSegmentBounds[0] = i; - } - } - } else if ( - this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared - ) { - this.untargetableSegmentBounds[1] = i; - break; - } - } - const playersToBreakAllianceWith = listNukeBreakAlliance({ - game: this.game, - targetTile, - magnitude: this.game.config().nukeMagnitudes(ghostStructure), - threshold: this.game.config().nukeAllianceBreakThreshold(), - }); - // Find the point where SAM can intercept - this.targetedIndex = this.trajectoryPoints.length; - // Check trajectory - for (let i = 0; i < this.trajectoryPoints.length; i++) { - const tile = this.trajectoryPoints[i]; - for (const sam of this.game.nearbyUnits( - tile, - this.game.config().maxSamRange(), - UnitType.SAMLauncher, - )) { - if ( - sam.unit.owner().isMe() || - (this.game.myPlayer()?.isFriendly(sam.unit.owner()) && - !playersToBreakAllianceWith.has(sam.unit.owner().smallID())) - ) { - continue; - } - if ( - sam.distSquared <= - this.game.config().samRange(sam.unit.level()) ** 2 - ) { - this.targetedIndex = i; - break; - } - } - if (this.targetedIndex !== this.trajectoryPoints.length) break; - // Jump over untargetable segment - if (i === this.untargetableSegmentBounds[0]) - i = this.untargetableSegmentBounds[1] - 1; - } - } - - /** - * Draw trajectory preview line on the canvas - */ - private drawTrajectoryPreview(context: CanvasRenderingContext2D) { - const ghostStructure = this.currentGhostStructure; - const isNukeType = - ghostStructure === UnitType.AtomBomb || - ghostStructure === UnitType.HydrogenBomb; - - if (!isNukeType || this.trajectoryPoints.length === 0) { - return; - } - - const player = this.game.myPlayer(); - if (!player) { - return; - } - - // Set of line colors, targeted is after SAM intercept is detected. - const untargetedOutlineColor = "rgba(140, 140, 140, 1)"; - const targetedOutlineColor = "rgba(150, 90, 90, 1)"; - const symbolOutlineColor = "rgba(0, 0, 0, 1)"; - const targetedLocationColor = "rgba(255, 0, 0, 1)"; - const untargetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)"; - const targetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)"; - const untargetableAndTargetedLineColor = "rgba(255, 80, 80, 1)"; - const targetableAndTargetedLineColor = "rgba(255, 80, 80, 1)"; - - // Set of line widths - const outlineExtraWidth = 1.5; // adds onto below - const lineWidth = 1.25; - const XLineWidth = 2; - const XSize = 6; - - // Set of line dashes - // Outline dashes calculated automatically - const untargetableAndUntargetedLineDash = [2, 6]; - const targetableAndUntargetedLineDash = [8, 4]; - const untargetableAndTargetedLineDash = [2, 6]; - const targetableAndTargetedLineDash = [8, 4]; - - const outlineDash = (dash: number[], extra: number) => { - return [dash[0] + extra, Math.max(dash[1] - extra, 0)]; - }; - - // Tracks the change of color and dash length throughout - let currentOutlineColor = untargetedOutlineColor; - let currentLineColor = targetableAndUntargetedLineColor; - let currentLineDash = targetableAndUntargetedLineDash; - let currentLineWidth = lineWidth; - - // Take in set of "current" parameters and draw both outline and line. - const outlineAndStroke = () => { - context.lineWidth = currentLineWidth + outlineExtraWidth; - context.setLineDash(outlineDash(currentLineDash, outlineExtraWidth)); - context.lineDashOffset = outlineExtraWidth / 2; - context.strokeStyle = currentOutlineColor; - context.stroke(); - context.lineWidth = currentLineWidth; - context.setLineDash(currentLineDash); - context.lineDashOffset = 0; - context.strokeStyle = currentLineColor; - context.stroke(); - }; - const drawUntargetableCircle = (x: number, y: number) => { - context.beginPath(); - context.arc(x, y, 4, 0, 2 * Math.PI, false); - currentOutlineColor = untargetedOutlineColor; - currentLineColor = targetableAndUntargetedLineColor; - currentLineDash = [1, 0]; - outlineAndStroke(); - }; - const drawTargetedX = (x: number, y: number) => { - context.beginPath(); - context.moveTo(x - XSize, y - XSize); - context.lineTo(x + XSize, y + XSize); - context.moveTo(x - XSize, y + XSize); - context.lineTo(x + XSize, y - XSize); - currentOutlineColor = symbolOutlineColor; - currentLineColor = targetedLocationColor; - currentLineDash = [1, 0]; - currentLineWidth = XLineWidth; - outlineAndStroke(); - }; - - // Calculate offset to center coordinates (same as canvas drawing) - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - - context.save(); - context.beginPath(); - - // Draw line connecting trajectory points - for (let i = 0; i < this.trajectoryPoints.length; i++) { - const tile = this.trajectoryPoints[i]; - const x = this.game.x(tile) + offsetX; - const y = this.game.y(tile) + offsetY; - - if (i === 0) { - context.moveTo(x, y); - } else { - context.lineTo(x, y); - } - if (i === this.untargetableSegmentBounds[0]) { - outlineAndStroke(); - drawUntargetableCircle(x, y); - context.beginPath(); - if (i >= this.targetedIndex) { - currentOutlineColor = targetedOutlineColor; - currentLineColor = untargetableAndTargetedLineColor; - currentLineDash = untargetableAndTargetedLineDash; - } else { - currentOutlineColor = untargetedOutlineColor; - currentLineColor = untargetableAndUntargetedLineColor; - currentLineDash = untargetableAndUntargetedLineDash; - } - } else if (i === this.untargetableSegmentBounds[1]) { - outlineAndStroke(); - drawUntargetableCircle(x, y); - context.beginPath(); - if (i >= this.targetedIndex) { - currentOutlineColor = targetedOutlineColor; - currentLineColor = targetableAndTargetedLineColor; - currentLineDash = targetableAndTargetedLineDash; - } else { - currentOutlineColor = untargetedOutlineColor; - currentLineColor = targetableAndUntargetedLineColor; - currentLineDash = targetableAndUntargetedLineDash; - } - } - if (i === this.targetedIndex) { - outlineAndStroke(); - drawTargetedX(x, y); - context.beginPath(); - // Always in the targetable zone by definition. - currentOutlineColor = targetedOutlineColor; - currentLineColor = targetableAndTargetedLineColor; - currentLineDash = targetableAndTargetedLineDash; - currentLineWidth = lineWidth; - } - } - - outlineAndStroke(); - context.restore(); - } -} diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts deleted file mode 100644 index 367c77a27..000000000 --- a/src/client/graphics/layers/RailroadLayer.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { colord } from "colord"; -import { EventBus, GameEvent } from "../../../core/EventBus"; -import { PlayerID, UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { - GameUpdateType, - RailroadConstructionUpdate, - RailroadDestructionUpdate, - RailroadSnapUpdate, -} from "../../../core/game/GameUpdates"; -import { GameView } from "../../../core/game/GameView"; -import { AlternateViewEvent } from "../../InputHandler"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; -import { getBridgeRects, getRailroadRects } from "./RailroadSprites"; -import { - computeRailTiles, - RailroadView, - RailTile, - RailType, -} from "./RailroadView"; - -type RailRef = { - tile: RailTile; - numOccurence: number; - lastOwnerId: PlayerID | null; -}; -const SNAPPABLE_STRUCTURES: UnitType[] = [ - UnitType.Port, - UnitType.City, - UnitType.Factory, -]; -export class RailTileChangedEvent implements GameEvent { - constructor(public tile: TileRef) {} -} - -export class RailroadLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private alternativeView = false; - // Save the number of railroads per tiles. Delete when it reaches 0 - private existingRailroads = new Map(); - private railroads = new Map(); - // Railroads under construction - private pendingRailroads = new Set(); - private nextRailIndexToCheck = 0; - private railTileList: TileRef[] = []; - private railTileIndex = new Map(); - private lastRailColorUpdate = 0; - private readonly railColorIntervalMs = 50; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - private uiState: UIState, - ) {} - - shouldTransform(): boolean { - return true; - } - - tick() { - this.updatePendingRailroads(); - const updates = this.game.updatesSinceLastTick(); - if (!updates) return; - // The event has to be handled in this specific order: construction / snap / destruction - // Otherwise some ID may not be available yet/anymore - updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadConstruction(update); - }); - updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadSnapEvent(update); - }); - updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadDestruction(update); - }); - } - - updatePendingRailroads() { - for (const id of this.pendingRailroads) { - const pending = this.railroads.get(id); - if (pending === undefined) { - // Rail deleted or snapped before the end of the animation - this.pendingRailroads.delete(id); - continue; - } - const newTiles = pending.tick(); - if (newTiles.length === 0) { - // Animation complete - this.pendingRailroads.delete(id); - continue; - } - - for (const railTile of newTiles) { - this.paintRailTile(railTile); - this.eventBus.emit(new RailTileChangedEvent(railTile.tile)); - } - } - } - - updateRailColors() { - if (this.railTileList.length === 0) { - return; - } - // Throttle color checks so we do not re-evaluate on every frame - const now = performance.now(); - if (now - this.lastRailColorUpdate < this.railColorIntervalMs) { - return; - } - this.lastRailColorUpdate = now; - - // Spread work over multiple frames to avoid large bursts when many rails exist - const maxTilesPerFrame = Math.max( - 1, - Math.ceil(this.railTileList.length / 120), - ); - let checked = 0; - - while (checked < maxTilesPerFrame && this.railTileList.length > 0) { - const tile = this.railTileList[this.nextRailIndexToCheck]; - const railRef = this.existingRailroads.get(tile); - if (railRef) { - const currentOwner = this.game.owner(tile)?.id() ?? null; - if (railRef.lastOwnerId !== currentOwner) { - // Repaint only when the owner changed to keep colors in sync - railRef.lastOwnerId = currentOwner; - this.paintRail(railRef.tile); - } - } - - this.nextRailIndexToCheck = - (this.nextRailIndexToCheck + 1) % this.railTileList.length; - checked++; - } - } - - init() { - this.eventBus.on(AlternateViewEvent, (e) => { - this.alternativeView = e.alternateView; - for (const { tile } of this.existingRailroads.values()) { - this.paintRail(tile); - } - }); - this.redraw(); - } - - redraw() { - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d", { alpha: true }); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - - // Firefox's GPU limit is 8192, only known browser issue - const maxTextureSize = 8192; - const scaleX = maxTextureSize / this.game.width(); - const scaleY = maxTextureSize / this.game.height(); - const targetScale = Math.min(2, scaleX, scaleY); - - this.canvas.width = Math.max( - 1, - Math.floor(this.game.width() * targetScale), - ); - this.canvas.height = Math.max( - 1, - Math.floor(this.game.height() * targetScale), - ); - - // Enable smooth scaling - this.context.imageSmoothingEnabled = true; - this.context.imageSmoothingQuality = "high"; - - // Scale context so existing *2 rendering math continues to work automatically - this.context.scale( - this.canvas.width / (this.game.width() * 2), - this.canvas.height / (this.game.height() * 2), - ); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, rail] of this.existingRailroads) { - this.paintRail(rail.tile); - } - } - - private highlightOverlappingRailroads(context: CanvasRenderingContext2D) { - if ( - this.uiState.ghostStructure === null || - !SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure) - ) - return; - if ( - this.uiState.overlappingRailroads === undefined || - this.uiState.overlappingRailroads.length === 0 - ) - return; - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - context.fillStyle = "rgba(0, 255, 0, 0.4)"; - for (const id of this.uiState.overlappingRailroads) { - const rail = this.railroads.get(id); - if (rail) { - for (const railTile of rail.drawnTiles()) { - const x = this.game.x(railTile.tile); - const y = this.game.y(railTile.tile); - context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5); - } - } - } - } - - renderLayer(context: CanvasRenderingContext2D) { - const scale = this.transformHandler.scale; - if (scale <= 1) { - return; - } - this.updateRailColors(); - const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1 - const alpha = Math.max(0, Math.min(1, rawAlpha)); - - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const padding = 2; // small margin so edges do not pop - const visLeft = Math.max(0, topLeft.x - padding); - const visTop = Math.max(0, topLeft.y - padding); - const visRight = Math.min(this.game.width(), bottomRight.x + padding); - const visBottom = Math.min(this.game.height(), bottomRight.y + padding); - const visWidth = Math.max(0, visRight - visLeft); - const visHeight = Math.max(0, visBottom - visTop); - if (visWidth === 0 || visHeight === 0) { - return; - } - - const actualScaleX = this.canvas.width / this.game.width(); - const actualScaleY = this.canvas.height / this.game.height(); - - const srcX = visLeft * actualScaleX; - const srcY = visTop * actualScaleY; - const srcW = visWidth * actualScaleX; - const srcH = visHeight * actualScaleY; - - const dstX = -this.game.width() / 2 + visLeft; - const dstY = -this.game.height() / 2 + visTop; - - context.save(); - context.globalAlpha = alpha; - - this.renderGhostRailroads(context); - - if (this.existingRailroads.size > 0) { - this.highlightOverlappingRailroads(context); - - context.drawImage( - this.canvas, - srcX, - srcY, - srcW, - srcH, - dstX, - dstY, - visWidth, - visHeight, - ); - } - - context.restore(); - } - - private renderGhostRailroads(context: CanvasRenderingContext2D) { - if ( - this.uiState.ghostStructure !== UnitType.City && - this.uiState.ghostStructure !== UnitType.Port - ) - return; - if (this.uiState.ghostRailPaths.length === 0) return; - - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - context.fillStyle = "rgba(0, 0, 0, 0.4)"; - - for (const path of this.uiState.ghostRailPaths) { - const railTiles = computeRailTiles(this.game, path); - for (const railTile of railTiles) { - const x = this.game.x(railTile.tile); - const y = this.game.y(railTile.tile); - - if (this.game.isWater(railTile.tile)) { - context.save(); - context.fillStyle = "rgba(197, 69, 72, 0.4)"; - const bridgeRects = getBridgeRects(railTile.type); - for (const [dx, dy, w, h] of bridgeRects) { - context.fillRect( - x + offsetX + dx / 2, - y + offsetY + dy / 2, - w / 2, - h / 2, - ); - } - context.restore(); - } - - const railRects = getRailroadRects(railTile.type); - for (const [dx, dy, w, h] of railRects) { - context.fillRect( - x + offsetX + dx / 2, - y + offsetY + dy / 2, - w / 2, - h / 2, - ); - } - } - } - } - - private onRailroadSnapEvent(update: RailroadSnapUpdate) { - const original = this.railroads.get(update.originalId); - if (!original) { - console.warn("Could not snap railroad: ", update.originalId); - return; - } - if (!original.isComplete()) { - // The animation is not complete but we don't want to compute where the animation should resume - // Just draw every remaining rails at once - this.drawRemainingTiles(original); - } - - // No need to compute the directions here, the rails are already painted - const directions1: RailTile[] = update.tiles1.map((tile) => ({ - tile, - type: RailType.HORIZONTAL, - })); - const directions2: RailTile[] = update.tiles2.map((tile) => ({ - tile, - type: RailType.HORIZONTAL, - })); - // The rails are already painted, consider them complete - this.railroads.set( - update.newId1, - new RailroadView(update.newId1, directions1, true), - ); - this.railroads.set( - update.newId2, - new RailroadView(update.newId2, directions2, true), - ); - - this.railroads.delete(update.originalId); - } - - private drawRemainingTiles(railroad: RailroadView) { - for (const tile of railroad.remainingTiles()) { - this.paintRail(tile); - } - this.pendingRailroads.delete(railroad.id); - } - - private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) { - const railTiles = computeRailTiles(this.game, railUpdate.tiles); - const rail = new RailroadView(railUpdate.id, railTiles); - this.addRailroad(rail); - } - - private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) { - const railroad = this.railroads.get(railUpdate.id); - if (!railroad) { - console.warn("Can't remove unexisting railroad: ", railUpdate.id); - return; - } - this.removeRailroad(railroad); - } - - private addRailroad(railroad: RailroadView) { - this.railroads.set(railroad.id, railroad); - this.pendingRailroads.add(railroad.id); - } - - private removeRailroad(railroad: RailroadView) { - this.pendingRailroads.delete(railroad.id); - for (const railTile of railroad.drawnTiles()) { - this.clearRailroad(railTile.tile); - this.eventBus.emit(new RailTileChangedEvent(railTile.tile)); - } - this.railroads.delete(railroad.id); - } - - private paintRailTile(railTile: RailTile) { - const currentOwner = this.game.owner(railTile.tile)?.id() ?? null; - const railRef = this.existingRailroads.get(railTile.tile); - - if (railRef) { - railRef.numOccurence++; - railRef.tile = railTile; - railRef.lastOwnerId = currentOwner; - } else { - this.existingRailroads.set(railTile.tile, { - tile: railTile, - numOccurence: 1, - lastOwnerId: currentOwner, - }); - this.railTileIndex.set(railTile.tile, this.railTileList.length); - this.railTileList.push(railTile.tile); - this.paintRail(railTile); - } - } - - private clearRailroad(railroad: TileRef) { - const ref = this.existingRailroads.get(railroad); - if (ref) ref.numOccurence--; - - if (!ref || ref.numOccurence <= 0) { - this.existingRailroads.delete(railroad); - this.removeRailTile(railroad); - if (this.context === undefined) throw new Error("Not initialized"); - if (this.game.isWater(railroad)) { - this.context.clearRect( - this.game.x(railroad) * 2 - 2, - this.game.y(railroad) * 2 - 2, - 5, - 6, - ); - } else { - this.context.clearRect( - this.game.x(railroad) * 2 - 1, - this.game.y(railroad) * 2 - 1, - 3, - 3, - ); - } - } - } - - private removeRailTile(tile: TileRef) { - const idx = this.railTileIndex.get(tile); - if (idx === undefined) return; - - const lastIndex = this.railTileList.length - 1; - const lastTile = this.railTileList[lastIndex]; - - this.railTileList[idx] = lastTile; - this.railTileIndex.set(lastTile, idx); - - this.railTileList.pop(); - this.railTileIndex.delete(tile); - - if (this.nextRailIndexToCheck >= this.railTileList.length) { - this.nextRailIndexToCheck = 0; - } - } - - paintRail(railTile: RailTile) { - if (this.context === undefined) throw new Error("Not initialized"); - const { tile } = railTile; - const { type } = railTile; - const x = this.game.x(tile); - const y = this.game.y(tile); - // If rail tile is over water, paint a bridge underlay first - if (this.game.isWater(tile)) { - this.paintBridge(this.context, x, y, type); - } - const owner = this.game.owner(tile); - const recipient = owner.isPlayer() ? owner : null; - let color = recipient - ? recipient.borderColor() - : colord("rgba(255,255,255,1)"); - - if (this.alternativeView && recipient?.isMe()) { - color = colord("#00ff00"); - } - - this.context.fillStyle = color.toRgbString(); - this.paintRailRects(this.context, x, y, type); - } - - private paintRailRects( - context: CanvasRenderingContext2D, - x: number, - y: number, - direction: RailType, - ) { - const railRects = getRailroadRects(direction); - for (const [dx, dy, w, h] of railRects) { - context.fillRect(x * 2 + dx, y * 2 + dy, w, h); - } - } - - private paintBridge( - context: CanvasRenderingContext2D, - x: number, - y: number, - direction: RailType, - ) { - context.save(); - context.fillStyle = "rgb(197,69,72)"; - const bridgeRects = getBridgeRects(direction); - for (const [dx, dy, w, h] of bridgeRects) { - context.fillRect(x * 2 + dx, y * 2 + dy, w, h); - } - context.restore(); - } -} diff --git a/src/client/graphics/layers/RailroadSprites.ts b/src/client/graphics/layers/RailroadSprites.ts deleted file mode 100644 index 8572ae68f..000000000 --- a/src/client/graphics/layers/RailroadSprites.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { RailType } from "./RailroadView"; - -const railTypeToFunctionMap: Record number[][]> = { - [RailType.TOP_RIGHT]: topRightRailroadCornerRects, - [RailType.BOTTOM_LEFT]: bottomLeftRailroadCornerRects, - [RailType.TOP_LEFT]: topLeftRailroadCornerRects, - [RailType.BOTTOM_RIGHT]: bottomRightRailroadCornerRects, - [RailType.HORIZONTAL]: horizontalRailroadRects, - [RailType.VERTICAL]: verticalRailroadRects, -}; - -const railTypeToBridgeFunctionMap: Record number[][]> = { - [RailType.TOP_RIGHT]: topRightBridgeCornerRects, - [RailType.BOTTOM_LEFT]: bottomLeftBridgeCornerRects, - [RailType.TOP_LEFT]: topLeftBridgeCornerRects, - [RailType.BOTTOM_RIGHT]: bottomRightBridgeCornerRects, - [RailType.HORIZONTAL]: horizontalBridge, - [RailType.VERTICAL]: verticalBridge, -}; - -export function getRailroadRects(type: RailType): number[][] { - const railRects = railTypeToFunctionMap[type]; - if (!railRects) { - // Should never happen - throw new Error(`Unsupported RailType: ${type}`); - } - return railRects(); -} - -function horizontalRailroadRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 2, 1], - [-1, 1, 2, 1], - [-1, 0, 1, 1], - ]; - return rects; -} - -function verticalRailroadRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 1, 2], - [1, -1, 1, 2], - [0, 0, 1, 1], - ]; - return rects; -} - -function topRightRailroadCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 1, 1], - [0, -1, 1, 2], - [1, -1, 1, 3], - ]; - return rects; -} - -function topLeftRailroadCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 1, 3], - [0, -1, 1, 2], - [1, -1, 1, 1], - ]; - return rects; -} - -function bottomRightRailroadCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, 1, 1, 1], - [0, 0, 1, 2], - [1, -1, 1, 3], - ]; - return rects; -} - -function bottomLeftRailroadCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 1, 3], - [0, 0, 1, 2], - [1, 1, 1, 1], - ]; - return rects; -} - -export function getBridgeRects(type: RailType): number[][] { - const bridgeRects = railTypeToBridgeFunctionMap[type]; - if (!bridgeRects) { - // Should never happen - throw new Error(`Unsupported RailType: ${type}`); - } - return bridgeRects(); -} - -function horizontalBridge(): number[][] { - // x/y/w/h - return [ - [-1, -2, 3, 1], - [-1, 2, 3, 1], - [-1, 3, 1, 1], - [1, 3, 1, 1], - ]; -} - -function verticalBridge(): number[][] { - // x/y/w/h - return [ - [-2, -1, 1, 3], - [2, -1, 1, 3], - ]; -} -// ⌞ -function topRightBridgeCornerRects(): number[][] { - return [ - [-2, -2, 1, 2], - [-1, 0, 1, 1], - [0, 1, 1, 1], - [1, 2, 2, 1], - [2, -2, 1, 1], - ]; -} -// ⌝ -function bottomLeftBridgeCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-2, -2, 2, 1], - [0, -1, 1, 1], - [1, 0, 1, 1], - [2, 1, 1, 2], - [-2, 2, 1, 1], - ]; - return rects; -} -// ⌟ -function topLeftBridgeCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-2, -2, 1, 1], - [-2, 2, 2, 1], - [0, 1, 1, 1], - [1, 0, 1, 1], - [2, -2, 1, 2], - ]; - return rects; -} -// ⌜ -function bottomRightBridgeCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-2, 1, 1, 2], - [-1, 0, 1, 1], - [0, -1, 1, 1], - [1, -2, 2, 1], - [2, 2, 1, 1], - ]; - return rects; -} diff --git a/src/client/graphics/layers/RailroadView.ts b/src/client/graphics/layers/RailroadView.ts deleted file mode 100644 index 2f690965d..000000000 --- a/src/client/graphics/layers/RailroadView.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { TileRef } from "../../../core/game/GameMap"; -import { GameView } from "../../../core/game/GameView"; - -export enum RailType { - VERTICAL, - HORIZONTAL, - TOP_LEFT, - TOP_RIGHT, - BOTTOM_LEFT, - BOTTOM_RIGHT, -} - -export type RailTile = { - tile: TileRef; - type: RailType; -}; - -export function computeRailTiles(game: GameView, tiles: TileRef[]): RailTile[] { - if (tiles.length === 0) return []; - if (tiles.length === 1) { - return [{ tile: tiles[0], type: RailType.VERTICAL }]; - } - const railTypes: RailTile[] = []; - // Inverse direction computation for the first tile - railTypes.push({ - tile: tiles[0], - type: computeExtremityDirection(game, tiles[0], tiles[1]), - }); - for (let i = 1; i < tiles.length - 1; i++) { - const direction = computeDirection( - game, - tiles[i - 1], - tiles[i], - tiles[i + 1], - ); - railTypes.push({ tile: tiles[i], type: direction }); - } - railTypes.push({ - tile: tiles[tiles.length - 1], - type: computeExtremityDirection( - game, - tiles[tiles.length - 1], - tiles[tiles.length - 2], - ), - }); - return railTypes; -} - -function computeExtremityDirection( - game: GameView, - tile: TileRef, - next: TileRef, -): RailType { - const x = game.x(tile); - const y = game.y(tile); - const nextX = game.x(next); - const nextY = game.y(next); - - const dx = nextX - x; - const dy = nextY - y; - - if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement - - if (dx === 0) { - return RailType.VERTICAL; - } else if (dy === 0) { - return RailType.HORIZONTAL; - } - return RailType.VERTICAL; -} - -export function computeDirection( - game: GameView, - prev: TileRef, - current: TileRef, - next: TileRef, -): RailType { - const x1 = game.x(prev); - const y1 = game.y(prev); - const x2 = game.x(current); - const y2 = game.y(current); - const x3 = game.x(next); - const y3 = game.y(next); - - const dx1 = x2 - x1; - const dy1 = y2 - y1; - const dx2 = x3 - x2; - const dy2 = y3 - y2; - - // Straight line - if (dx1 === dx2 && dy1 === dy2) { - if (dx1 !== 0) return RailType.HORIZONTAL; - if (dy1 !== 0) return RailType.VERTICAL; - } - - // Turn (corner) cases - if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) { - // Now figure out which type of corner - if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT; - if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT; - if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT; - if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT; - - if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT; - if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT; - if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT; - if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT; - } - return RailType.VERTICAL; -} - -/** - * A list of tile that can be incrementally painted each tick - */ -export class RailroadView { - private headIndex: number = 0; - private tailIndex: number; - private increment: number = 3; - constructor( - public id: number, - private railTiles: RailTile[], - complete: boolean = false, - ) { - // If the railroad is considered complete, no drawing or animation is required - this.tailIndex = complete ? 0 : railTiles.length; - } - - isComplete(): boolean { - return this.headIndex >= this.tailIndex; - } - - tiles(): RailTile[] { - return this.railTiles; - } - - remainingTiles(): RailTile[] { - if (this.isComplete()) { - // Animation complete, no tiles need to be painted - return []; - } - return this.railTiles.slice(this.headIndex, this.tailIndex); - } - - drawnTiles(): RailTile[] { - if (this.isComplete()) { - // Animation complete, every tiles have been painted - return this.tiles(); - } - let drawnTiles = this.railTiles.slice(0, this.headIndex); - drawnTiles = drawnTiles.concat(this.railTiles.slice(this.tailIndex)); - return drawnTiles; - } - - tick(): RailTile[] { - if (this.isComplete()) return []; - let updatedRailTiles: RailTile[]; - // Check if remaining tiles can be done all at once - if (this.tailIndex - this.headIndex <= 2 * this.increment) { - updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex); - } else { - updatedRailTiles = [ - ...this.railTiles.slice( - this.headIndex, - this.headIndex + this.increment, - ), - ...this.railTiles.slice( - this.tailIndex - this.increment, - this.tailIndex, - ), - ]; - } - this.headIndex = Math.min(this.headIndex + this.increment, this.tailIndex); - this.tailIndex = Math.max(this.tailIndex - this.increment, this.headIndex); - return updatedRailTiles; - } -} diff --git a/src/client/graphics/layers/SAMRadiusLayer.ts b/src/client/graphics/layers/SAMRadiusLayer.ts deleted file mode 100644 index a7f0da170..000000000 --- a/src/client/graphics/layers/SAMRadiusLayer.ts +++ /dev/null @@ -1,334 +0,0 @@ -import type { EventBus } from "../../../core/EventBus"; -import { UnitType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import type { - GameView, - PlayerView, - UnitView, -} from "../../../core/game/GameView"; -import { ToggleStructureEvent } from "../../InputHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; - -type Interval = [number, number]; -interface SAMRadius { - x: number; - y: number; - r: number; - owner: PlayerView; - arcs: Interval[]; -} - -interface SamInfo { - ownerId: number; - level: number; -} -/** - * Layer responsible for rendering SAM launcher defense radii - */ -export class SAMRadiusLayer implements Layer { - private readonly samLaunchers: Map = new Map(); // Track SAM launcher IDs -> SAM info - // track whether the stroke should be shown due to hover or due to an active build ghost - private hoveredShow: boolean = false; - private ghostShow: boolean = false; - private visible: boolean = false; - private samRanges: SAMRadius[] = []; - private dashOffset = 0; - private rotationSpeed = 14; // px per second - private lastRefresh = Date.now(); - private needsRedraw = false; - - private handleToggleStructure(e: ToggleStructureEvent) { - const types = e.structureTypes; - this.hoveredShow = - !!types && - (types.indexOf(UnitType.SAMLauncher) !== -1 || - types.indexOf(UnitType.City) !== -1); - this.updateVisibility(); - } - - constructor( - private readonly game: GameView, - private readonly eventBus: EventBus, - private readonly uiState: UIState, - ) {} - - init() { - // Listen for game updates to detect SAM launcher changes - // Also listen for UI toggle structure events so we can show borders when - // the user is hovering the Atom/Hydrogen option (UnitDisplay emits - // ToggleStructureEvent with SAMLauncher included in the list). - this.eventBus.on(ToggleStructureEvent, (e) => - this.handleToggleStructure(e), - ); - } - - shouldTransform(): boolean { - return true; - } - - tick() { - // Check for updates to SAM launchers - const unitUpdates = this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]; - if (unitUpdates) { - for (const update of unitUpdates) { - const unit = this.game.unit(update.id); - if (unit && unit.type() === UnitType.SAMLauncher) { - if (this.hasChanged(unit)) { - this.needsRedraw = true; // A SAM changed: radiuses shall be recomputed when necessary - break; - } - } - } - } - - // show when in ghost mode for silo/sam/atom/hydrogen - this.ghostShow = - this.uiState.ghostStructure === UnitType.MissileSilo || - this.uiState.ghostStructure === UnitType.SAMLauncher || - this.uiState.ghostStructure === UnitType.City || - this.uiState.ghostStructure === UnitType.AtomBomb || - this.uiState.ghostStructure === UnitType.HydrogenBomb; - this.updateVisibility(); - } - - renderLayer(context: CanvasRenderingContext2D) { - if (this.visible) { - if (this.needsRedraw) { - // SAM changed: the radiuses needs to be updated - this.computeCircleUnions(); - this.needsRedraw = false; - } - this.updateDashAnimation(); - this.drawCirclesUnion(context); - } - } - - private updateDashAnimation() { - const now = Date.now(); - const dt = now - this.lastRefresh; - this.lastRefresh = now; - this.dashOffset += (this.rotationSpeed * dt) / 1000; - if (this.dashOffset > 1e6) this.dashOffset = this.dashOffset % 1000000; - } - - private updateVisibility() { - const next = this.hoveredShow || this.ghostShow; - if (next !== this.visible) { - this.visible = next; - } - } - - private hasChanged(unit: UnitView): boolean { - const samInfos = this.samLaunchers.get(unit.id()); - const isNew = samInfos === undefined; - const active = unit.isActive(); - const ownerId = unit.owner().smallID(); - let hasChanges = isNew || !active; // was built or destroyed - hasChanges ||= !isNew && samInfos.ownerId !== ownerId; // Sam owner changed - hasChanges ||= !isNew && samInfos.level !== unit.level(); // Sam leveled up - return hasChanges; - } - - private getAllSamRanges(): SAMRadius[] { - // Get all active SAM launchers - const samLaunchers = this.game - .units(UnitType.SAMLauncher) - .filter((unit) => unit.isActive()); - - // Update our tracking set - this.samLaunchers.clear(); - samLaunchers.forEach((sam) => - this.samLaunchers.set(sam.id(), { - ownerId: sam.owner().smallID(), - level: sam.level(), - }), - ); - - // Collect radius data - const radiuses = samLaunchers.map((sam) => { - const tile = sam.tile(); - return { - x: this.game.x(tile), - y: this.game.y(tile), - r: this.game.config().samRange(sam.level()), - owner: sam.owner(), - arcs: [], - }; - }); - return radiuses; - } - - private computeUncoveredArcIntervals(a: SAMRadius, circles: SAMRadius[]) { - a.arcs = []; - const TWO_PI = Math.PI * 2; - const EPS = 1e-9; - // helper functions - const normalize = (a: number) => { - while (a < 0) a += TWO_PI; - while (a >= TWO_PI) a -= TWO_PI; - return a; - }; - // merge a list of intervals [s,e] (both between 0..2pi), taking wraparound into account - const mergeIntervals = ( - intervals: Array<[number, number]>, - ): Array<[number, number]> => { - if (intervals.length === 0) return []; - // normalize to non-wrap intervals - const flat: Array<[number, number]> = []; - for (const [s, e] of intervals) { - const ns = normalize(s); - const ne = normalize(e); - if (ne < ns) { - // wraps, split - flat.push([ns, TWO_PI]); - flat.push([0, ne]); - } else { - flat.push([ns, ne]); - } - } - flat.sort((a, b) => a[0] - b[0]); - const merged: Array<[number, number]> = []; - let cur = flat[0].slice() as [number, number]; - for (let i = 1; i < flat.length; i++) { - const it = flat[i]; - if (it[0] <= cur[1] + EPS) { - cur[1] = Math.max(cur[1], it[1]); - } else { - merged.push([cur[0], cur[1]]); - cur = it.slice() as [number, number]; - } - } - merged.push([cur[0], cur[1]]); - return merged; - }; - const covered: Interval[] = []; - let fullyCovered = false; - - for (const b of circles) { - if (a === b) continue; - - // Only same-owner coverage - if (a.owner.smallID() !== b.owner.smallID()) continue; - - const dx = b.x - a.x; - const dy = b.y - a.y; - const d = Math.hypot(dx, dy); - - // a fully inside b - if (d + a.r <= b.r + EPS) { - fullyCovered = true; - break; - } - - // no overlap - if (d >= a.r + b.r - EPS) continue; - - // coincident centers - if (d <= EPS) { - if (b.r >= a.r) { - fullyCovered = true; - break; - } - continue; - } - - // angular span on a covered by b - const theta = Math.atan2(dy, dx); - const cosPhi = (a.r * a.r + d * d - b.r * b.r) / (2 * a.r * d); - const phi = Math.acos(Math.max(-1, Math.min(1, cosPhi))); - - covered.push([theta - phi, theta + phi]); - } - - if (fullyCovered) return; - - const merged = mergeIntervals(covered); - - // subtract from [0, 2π) - const uncovered: Interval[] = []; - if (merged.length === 0) { - uncovered.push([0, TWO_PI]); - } else { - let cursor = 0; - for (const [s, e] of merged) { - if (s > cursor + EPS) { - uncovered.push([cursor, s]); - } - cursor = Math.max(cursor, e); - } - if (cursor < TWO_PI - EPS) { - uncovered.push([cursor, TWO_PI]); - } - } - a.arcs = uncovered; - } - - private drawArcSegments(ctx: CanvasRenderingContext2D, a: SAMRadius) { - const outlineColor = "rgba(0, 0, 0, 1)"; - const lineColorSelf = "rgba(0, 255, 0, 1)"; - const lineColorEnemy = "rgba(255, 0, 0, 1)"; - const lineColorFriend = "rgba(255, 255, 0, 1)"; - const extraOutlineWidth = 1; // adds onto below - const lineWidth = 3; - const lineDash = [12, 6]; - - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - for (const [s, e] of a.arcs) { - // skip tiny arcs - if (e - s < 1e-3) continue; - ctx.beginPath(); - ctx.arc(a.x + offsetX, a.y + offsetY, a.r, s, e); - - // Outline - ctx.strokeStyle = outlineColor; - ctx.lineWidth = lineWidth + extraOutlineWidth; - ctx.setLineDash([ - lineDash[0] + extraOutlineWidth, - Math.max(lineDash[1] - extraOutlineWidth, 0), - ]); - ctx.lineDashOffset = this.dashOffset + extraOutlineWidth / 2; - ctx.stroke(); - - // Inline - if (a.owner.isMe()) { - ctx.strokeStyle = lineColorSelf; - } else if (this.game.myPlayer()?.isFriendly(a.owner)) { - ctx.strokeStyle = lineColorFriend; - } else { - ctx.strokeStyle = lineColorEnemy; - } - - ctx.lineWidth = lineWidth; - ctx.setLineDash(lineDash); - ctx.lineDashOffset = this.dashOffset; - ctx.stroke(); - } - } - - /** - * Compute for each circle which angular segments are NOT covered by any other circle - */ - private computeCircleUnions() { - this.samRanges = this.getAllSamRanges(); - for (let i = 0; i < this.samRanges.length; i++) { - const a = this.samRanges[i]; - this.computeUncoveredArcIntervals(a, this.samRanges); - } - } - - /** - * Draw union of multiple circles: stroke only the outer arcs so overlapping circles appear as one combined shape. - */ - private drawCirclesUnion(context: CanvasRenderingContext2D) { - const circles = this.samRanges; - if (circles.length === 0 || !this.visible) return; - // Only draw the stroke when UI toggle indicates SAM launchers are focused (e.g. hovering Atom/Hydrogen option). - context.save(); - for (let i = 0; i < circles.length; i++) { - this.drawArcSegments(context, circles[i]); - } - context.restore(); - } -} diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts index 760b17d2f..a6759c9cb 100644 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ b/src/client/graphics/layers/StructureIconsLayer.ts @@ -1,3 +1,11 @@ +/** + * StructureIconsLayer — now just the build ghost + click-to-build flow. + * + * Structure icons themselves are rendered by the WebGL StructurePass; this + * layer keeps the Pixi-based ghost preview (translucent outline at the cursor, + * range circle, price tag) and the build/upgrade event flow. + */ + import { extend } from "colord"; import a11yPlugin from "colord/plugins/a11y"; import { OutlineFilter } from "pixi-filters"; @@ -8,21 +16,16 @@ import { EventBus } from "../../../core/EventBus"; import { wouldNukeBreakAlliance } from "../../../core/execution/Util"; import { BuildableUnit, - Cell, PlayerBuildableUnitType, - PlayerID, - Structures, UnitType, } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, UnitView } from "../../../core/game/GameView"; +import { GameView } from "../../../core/game/GameView"; import { ConfirmGhostStructureEvent, GhostStructureChangedEvent, MouseMoveEvent, MouseUpEvent, - ToggleStructureEvent as ToggleStructuresEvent, } from "../../InputHandler"; import { BuildUnitIntentEvent, @@ -33,14 +36,9 @@ import { TransformHandler } from "../TransformHandler"; import { UIState } from "../UIState"; import { Layer } from "./Layer"; import { - DOTS_ZOOM_THRESHOLD, ICON_SCALE_FACTOR_ZOOMED_IN, ICON_SCALE_FACTOR_ZOOMED_OUT, - ICON_SIZE, - LEVEL_SCALE_FACTOR, - OFFSET_ZOOM_Y, SpriteFactory, - STRUCTURE_SHAPES, ZOOM_THRESHOLD, } from "./StructureDrawingUtils"; const bitmapFont = assetUrl("fonts/round_6x6_modified.xml"); @@ -52,19 +50,6 @@ export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { extend([a11yPlugin]); -class StructureRenderInfo { - public isOnScreen: boolean = false; - constructor( - public unit: UnitView, - public owner: PlayerID, - public iconContainer: PIXI.Container, - public levelContainer: PIXI.Container, - public dotContainer: PIXI.Container, - public level: number = 0, - public underConstruction: boolean = true, - ) {} -} - export class StructureIconsLayer implements Layer { private ghostUnit: { container: PIXI.Container; @@ -78,34 +63,18 @@ export class StructureIconsLayer implements Layer { buildableUnit: BuildableUnit; } | null = null; private pixicanvas: HTMLCanvasElement; - private iconsStage: PIXI.Container; private ghostStage: PIXI.Container; - private levelsStage: PIXI.Container; private rootStage: PIXI.Container = new PIXI.Container(); - private dotsStage: PIXI.Container; private readonly theme: Theme; private renderer: PIXI.Renderer | null = null; private rendererInitialized: boolean = false; - private readonly rendersByUnitId: Map = - new Map(); - private readonly seenUnitIds: Set = new Set(); private readonly connectedAllySmallIds: Set = new Set(); private readonly mousePos = { x: 0, y: 0 }; - private renderSprites = true; private factory: SpriteFactory; - private readonly structures: Map< - PlayerBuildableUnitType, - { visible: boolean } - > = new Map(Structures.types.map((type) => [type, { visible: true }])); - private lastGhostQueryAt: number; - private visibilityStateDirty = true; + private lastGhostQueryAt: number = 0; private pendingConfirm: MouseUpEvent | null = null; - private hasHiddenStructure = false; private rebuildPending = false; - potentialUpgrade: StructureRenderInfo | undefined; private filterRedArray: OutlineFilter[] = []; - private filterGreenArray: OutlineFilter[] = []; - private filterWhiteArray: OutlineFilter[] = []; constructor( private game: GameView, @@ -114,12 +83,7 @@ export class StructureIconsLayer implements Layer { private transformHandler: TransformHandler, ) { this.theme = game.config().theme(); - this.factory = new SpriteFactory( - this.theme, - game, - transformHandler, - this.renderSprites, - ); + this.factory = new SpriteFactory(this.theme, game, transformHandler, true); } async setupRenderer() { @@ -138,9 +102,6 @@ export class StructureIconsLayer implements Layer { this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; - // This will prefer WebGL, eventually WebGPU, and fallback to Canvas - // Restrict using 'preferences: ["WebGPU", "WebGL"]' or - // 'preferences: "WebGPU"' later if needed const renderer = await PIXI.autoDetectRenderer({ canvas: this.pixicanvas, resolution: 1, @@ -152,42 +113,19 @@ export class StructureIconsLayer implements Layer { backgroundColor: 0x00000000, }); - console.info(`Using ${renderer.name} for structure icons layer`); - - this.iconsStage = new PIXI.Container(); - this.iconsStage.position.set(0, 0); - this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); + console.info(`Using ${renderer.name} for build ghost layer`); this.ghostStage = new PIXI.Container(); this.ghostStage.position.set(0, 0); this.ghostStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - this.levelsStage = new PIXI.Container(); - this.levelsStage.position.set(0, 0); - this.levelsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - - this.dotsStage = new PIXI.Container(); - this.dotsStage.position.set(0, 0); - this.dotsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - - this.rootStage.addChild( - this.dotsStage, - this.iconsStage, - this.levelsStage, - this.ghostStage, - ); + this.rootStage.addChild(this.ghostStage); this.rootStage.position.set(0, 0); this.rootStage.setSize(this.pixicanvas.width, this.pixicanvas.height); this.filterRedArray = [ new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), ]; - this.filterGreenArray = [ - new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), - ]; - this.filterWhiteArray = [ - new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }), - ]; this.renderer = renderer; @@ -201,8 +139,6 @@ export class StructureIconsLayer implements Layer { if (this.renderer.name === "webgl") { this.renderer.runners.contextChange.add({ - // Listen to contextChange as PixiJS handles WebGL context loss and restores itself. - // Don't listen to "webglcontextrestored" event directly as it can fire before PixiJS is ready. contextChange: () => { requestAnimationFrame(() => { this.redraw(); @@ -214,58 +150,28 @@ export class StructureIconsLayer implements Layer { this.rendererInitialized = true; } - private rebuildAllIcons() { - this.clearGhostStructure(); - this.factory.clearCache(); - const allUnitIds = Array.from(this.seenUnitIds); - this.seenUnitIds.clear(); - for (const unitId of allUnitIds) { - const render = this.rendersByUnitId.get(unitId); - if (render) { - render.iconContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.levelContainer?.destroy({ children: true }); - } - const unitView = this.game.unit(unitId); - if (unitView && unitView.isActive()) { - this.handleActiveUnit(unitView); - } else { - this.rendersByUnitId.delete(unitId); - } - } - } - shouldTransform(): boolean { return false; } async redraw() { - if (this.rebuildPending) { - return; - } - if (this.rendererOrGLContextLost()) { - return; - } + if (this.rebuildPending) return; + if (this.rendererOrGLContextLost()) return; this.rebuildPending = true; - try { if (this.renderer?.name === "webgpu") { this.rendererInitialized = false; await this.setupRenderer(); } this.resizeCanvas(); - this.rebuildAllIcons(); + this.clearGhostStructure(); } finally { this.rebuildPending = false; } } async init() { - this.eventBus.on(ToggleStructuresEvent, (e) => - this.toggleStructures(e.structureTypes), - ); this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); - this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e)); this.eventBus.on(ConfirmGhostStructureEvent, () => this.requestConfirmStructure( @@ -281,48 +187,20 @@ export class StructureIconsLayer implements Layer { private rendererOrGLContextLost(): boolean { if (!this.renderer || !this.rendererInitialized) return true; if (this.renderer.name === "webgl") { - // For WebGL, check isLost to prevent ungraceful handling by PixiJS: - // its GL > logPrettyShaderError throws, when getShaderSource returns null - // Needs to be fixed in PixiJS, in meantime prevent it from here return (this.renderer as PIXI.WebGLRenderer).context?.isLost === true; } return false; } resizeCanvas() { - if (this.rendererOrGLContextLost()) { - return; - } + if (this.rendererOrGLContextLost()) return; this.pixicanvas.width = window.innerWidth; this.pixicanvas.height = window.innerHeight; this.renderer?.resize(innerWidth, innerHeight, 1); } - tick() { - const unitUpdates = this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]; - if (unitUpdates) { - for (let i = 0, len = unitUpdates.length; i < len; i++) { - const unitView = this.game.unit(unitUpdates[i].id); - if (unitView === undefined) { - continue; - } - - const unitId = unitView.id(); - if (unitView.isActive()) { - this.handleActiveUnit(unitView); - } else if (this.seenUnitIds.has(unitId)) { - this.handleInactiveUnit(unitView); - } - } - } - this.renderSprites = - this.game.config().userSettings()?.structureSprites() ?? true; - } - renderLayer(mainContext: CanvasRenderingContext2D) { - if (this.rendererOrGLContextLost()) { - return; - } + if (this.rendererOrGLContextLost()) return; if (this.ghostUnit) { if (this.uiState.ghostStructure === null) { @@ -337,18 +215,6 @@ export class StructureIconsLayer implements Layer { } this.renderGhost(); - if (this.transformHandler.hasChanged()) { - for (const render of this.rendersByUnitId.values()) { - this.computeNewLocation(render); - } - } - const scale = this.transformHandler.scale; - - this.dotsStage!.visible = scale <= DOTS_ZOOM_THRESHOLD; - this.iconsStage!.visible = - scale > DOTS_ZOOM_THRESHOLD && - (scale <= ZOOM_THRESHOLD || !this.renderSprites); - this.levelsStage!.visible = scale > ZOOM_THRESHOLD && this.renderSprites; if (this.renderer) { this.renderer.render(this.rootStage); mainContext.drawImage(this.renderer.canvas, 0, 0); @@ -359,9 +225,7 @@ export class StructureIconsLayer implements Layer { if (!this.ghostUnit) return; const now = performance.now(); - if (now - this.lastGhostQueryAt < 50) { - return; - } + if (now - this.lastGhostQueryAt < 50) return; this.lastGhostQueryAt = now; let tileRef: TileRef | undefined; const tile = this.transformHandler.screenToWorldCoordinates( @@ -373,7 +237,6 @@ export class StructureIconsLayer implements Layer { } // Check if targeting an ally (for nuke warning visual) - // Uses shared logic with NukeExecution.maybeBreakAlliances() let targetingAlly = false; const myPlayer = this.game.myPlayer(); const nukeType = this.ghostUnit.buildableUnit.type; @@ -382,7 +245,6 @@ export class StructureIconsLayer implements Layer { myPlayer && (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) ) { - // Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff this.connectedAllySmallIds.clear(); const allies = myPlayer.allies(); for (let i = 0; i < allies.length; i++) { @@ -407,10 +269,6 @@ export class StructureIconsLayer implements Layer { ?.myPlayer() ?.buildables(tileRef, [this.ghostUnit?.buildableUnit.type]) .then((buildables) => { - if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = []; - this.potentialUpgrade.dotContainer.filters = []; - } if (this.ghostUnit?.container) { this.ghostUnit.container.filters = []; } @@ -442,18 +300,6 @@ export class StructureIconsLayer implements Layer { this.updateGhostRange(targetLevel, targetingAlly); if (unit.canUpgrade) { - this.potentialUpgrade = this.rendersByUnitId.get(unit.canUpgrade); - if ( - this.potentialUpgrade && - this.potentialUpgrade.unit.owner().id() !== - this.game.myPlayer()?.id() - ) { - this.potentialUpgrade = undefined; - } - if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = this.filterGreenArray; - this.potentialUpgrade.dotContainer.filters = this.filterGreenArray; - } // No overlapping when a structure is upgradable this.uiState.overlappingRailroads = []; this.uiState.ghostRailPaths = []; @@ -511,21 +357,12 @@ export class StructureIconsLayer implements Layer { .fill({ color: 0x000000, alpha: 0.65 }); } - /** - * True when the ghost exists and buildableUnit has been refreshed (canBuild or canUpgrade set). - * Used to avoid running createStructure before renderGhost's async buildables() has updated the ghost. - */ private isGhostReadyForConfirm(): boolean { if (!this.ghostUnit) return false; const bu = this.ghostUnit.buildableUnit; return bu.canBuild !== false || bu.canUpgrade !== false; } - /** - * Request confirm (place/upgrade): run createStructure now if ghost is ready, otherwise defer until - * renderGhost's buildables() callback has updated the ghost. Shared by Enter (ConfirmGhostStructureEvent) - * and mouse click (MouseUpEvent) so numpad-select-then-confirm works. - */ private requestConfirmStructure(e: MouseUpEvent): void { if (!this.ghostUnit && !this.uiState.ghostStructure) return; if (this.isGhostReadyForConfirm()) { @@ -587,9 +424,7 @@ export class StructureIconsLayer implements Layer { private createGhostStructure(type: PlayerBuildableUnitType | null) { const player = this.game.myPlayer(); if (!player) return; - if (type === null) { - return; - } + if (type === null) return; const local = this.transformHandler.screenToCanvasCoordinates( this.mousePos.x, this.mousePos.y, @@ -629,11 +464,6 @@ export class StructureIconsLayer implements Layer { this.ghostUnit.range?.destroy({ children: true }); this.ghostUnit = null; } - if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = []; - this.potentialUpgrade.dotContainer.filters = []; - this.potentialUpgrade = undefined; - } this.uiState.ghostRailPaths = []; } @@ -646,9 +476,7 @@ export class StructureIconsLayer implements Layer { private resolveGhostRangeLevel( buildableUnit: BuildableUnit, ): number | undefined { - if (buildableUnit.type !== UnitType.SAMLauncher) { - return undefined; - } + if (buildableUnit.type !== UnitType.SAMLauncher) return undefined; if (buildableUnit.canUpgrade !== false) { const existing = this.game.unit(buildableUnit.canUpgrade); if (existing) { @@ -657,14 +485,11 @@ export class StructureIconsLayer implements Layer { console.error("Failed to find existing SAMLauncher for upgrade"); } } - return 1; } private updateGhostRange(level?: number, targetingAlly: boolean = false) { - if (!this.ghostUnit) { - return; - } + if (!this.ghostUnit) return; if ( this.ghostUnit.range && @@ -691,242 +516,4 @@ export class StructureIconsLayer implements Layer { this.ghostUnit.range = range; } } - - private toggleStructures( - toggleStructureType: PlayerBuildableUnitType[] | null, - ): void { - for (const [structureType, infos] of this.structures) { - infos.visible = - toggleStructureType?.indexOf(structureType) !== -1 || - toggleStructureType === null; - } - this.visibilityStateDirty = true; - for (const render of this.rendersByUnitId.values()) { - this.modifyVisibility(render); - } - } - - private refreshVisibilityStateCache() { - if (!this.visibilityStateDirty) { - return; - } - - this.hasHiddenStructure = false; - for (const infos of this.structures.values()) { - if (infos.visible === false) { - this.hasHiddenStructure = true; - break; - } - } - - this.visibilityStateDirty = false; - } - - private findRenderByUnit( - unitView: UnitView, - ): StructureRenderInfo | undefined { - return this.rendersByUnitId.get(unitView.id()); - } - - private handleActiveUnit(unitView: UnitView) { - if (this.seenUnitIds.has(unitView.id())) { - const render = this.findRenderByUnit(unitView); - if (render) { - this.checkForConstructionState(render, unitView); - this.checkForDeletionState(render, unitView); - this.checkForOwnershipChange(render, unitView); - this.checkForLevelChange(render, unitView); - } - } else if ( - this.structures.has(unitView.type() as PlayerBuildableUnitType) - ) { - this.addNewStructure(unitView); - } - } - - private handleInactiveUnit(unitView: UnitView) { - if (!this.seenUnitIds.has(unitView.id())) { - return; - } - - const render = this.findRenderByUnit(unitView); - if (render) { - this.deleteStructure(render); - } - } - - private modifyVisibility(render: StructureRenderInfo) { - this.refreshVisibilityStateCache(); - - const structureType = render.unit.type() as PlayerBuildableUnitType; - const structureInfos = this.structures.get(structureType); - - if (structureInfos) { - render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3; - render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3; - if (structureInfos.visible && this.hasHiddenStructure) { - render.iconContainer.filters = this.filterWhiteArray; - render.dotContainer.filters = this.filterWhiteArray; - } else { - render.iconContainer.filters = []; - render.dotContainer.filters = []; - } - } - } - - private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) { - if (unit.markedForDeletion() !== false) { - render.iconContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.iconContainer = this.createIconSprite(unit); - render.dotContainer = this.createDotSprite(unit); - this.modifyVisibility(render); - } - } - - private checkForConstructionState( - render: StructureRenderInfo, - unit: UnitView, - ) { - if (render.underConstruction && !unit.isUnderConstruction()) { - render.underConstruction = false; - render.iconContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.iconContainer = this.createIconSprite(unit); - render.dotContainer = this.createDotSprite(unit); - this.modifyVisibility(render); - } - } - - private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) { - if (render.owner !== unit.owner().id()) { - render.owner = unit.owner().id(); - render.iconContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.iconContainer = this.createIconSprite(unit); - render.dotContainer = this.createDotSprite(unit); - this.modifyVisibility(render); - } - } - - private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) { - if (render.level !== unit.level()) { - render.level = unit.level(); - render.iconContainer?.destroy({ children: true }); - render.levelContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.iconContainer = this.createIconSprite(unit); - render.levelContainer = this.createLevelSprite(unit); - render.dotContainer = this.createDotSprite(unit); - this.modifyVisibility(render); - } - } - - private computeNewLocation(render: StructureRenderInfo) { - const tile = render.unit.tile(); - const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); - const screenPos = this.transformHandler.worldToCanvasCoordinates(worldPos); - screenPos.x = Math.round(screenPos.x); - - const scale = this.transformHandler.scale; - screenPos.y = Math.round( - scale >= ZOOM_THRESHOLD && - this.game.config().userSettings()?.structureSprites() - ? screenPos.y - scale * OFFSET_ZOOM_Y - : screenPos.y, - ); - - const type = render.unit.type(); - const margin = - type !== undefined && STRUCTURE_SHAPES[type] !== undefined - ? ICON_SIZE[STRUCTURE_SHAPES[type]] - : 28; - - const onScreen = - screenPos.x + margin > 0 && - screenPos.x - margin < this.pixicanvas.width && - screenPos.y + margin > 0 && - screenPos.y - margin < this.pixicanvas.height; - - if (onScreen) { - if (scale > ZOOM_THRESHOLD) { - const target = this.game.config().userSettings()?.structureSprites() - ? render.levelContainer - : render.iconContainer; - target.position.set(screenPos.x, screenPos.y); - target.scale.set( - Math.max( - 1, - scale / - (target === render.levelContainer - ? LEVEL_SCALE_FACTOR - : ICON_SCALE_FACTOR_ZOOMED_IN), - ), - ); - } else if (scale > DOTS_ZOOM_THRESHOLD) { - render.iconContainer.position.set(screenPos.x, screenPos.y); - render.iconContainer.scale.set( - Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT), - ); - } else { - render.dotContainer.position.set(screenPos.x, screenPos.y); - } - } - - if (render.isOnScreen !== onScreen) { - render.isOnScreen = onScreen; - render.iconContainer.visible = onScreen; - render.dotContainer.visible = onScreen; - render.levelContainer.visible = onScreen; - } - } - - private addNewStructure(unitView: UnitView) { - this.seenUnitIds.add(unitView.id()); - const render = new StructureRenderInfo( - unitView, - unitView.owner().id(), - this.createIconSprite(unitView), - this.createLevelSprite(unitView), - this.createDotSprite(unitView), - unitView.level(), - unitView.isUnderConstruction(), - ); - this.rendersByUnitId.set(unitView.id(), render); - this.computeNewLocation(render); - this.modifyVisibility(render); - } - - private createLevelSprite(unit: UnitView): PIXI.Container { - return this.factory.createUnitContainer(unit, { - type: "level", - stage: this.levelsStage, - }); - } - - private createDotSprite(unit: UnitView): PIXI.Container { - return this.factory.createUnitContainer(unit, { - type: "dot", - stage: this.dotsStage, - }); - } - - private createIconSprite(unit: UnitView): PIXI.Container { - return this.factory.createUnitContainer(unit, { - type: "icon", - stage: this.iconsStage, - }); - } - - private deleteStructure(render: StructureRenderInfo) { - render.iconContainer?.destroy({ children: true }); - render.levelContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - const unitId = render.unit.id(); - this.rendersByUnitId.delete(unitId); - this.seenUnitIds.delete(unitId); - if (this.potentialUpgrade?.unit.id() === unitId) { - this.potentialUpgrade = undefined; - } - } } diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts deleted file mode 100644 index 9ba8e3b2a..000000000 --- a/src/client/graphics/layers/StructureLayer.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { colord, Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; -import { assetUrl } from "../../../core/AssetUrls"; -import { EventBus } from "../../../core/EventBus"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -import { Cell, UnitType } from "../../../core/game/Game"; -import { euclDistFN, isometricDistFN } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, UnitView } from "../../../core/game/GameView"; -const cityIcon = assetUrl("images/buildings/cityAlt1.png"); -const factoryIcon = assetUrl("images/buildings/factoryAlt1.png"); -const shieldIcon = assetUrl("images/buildings/fortAlt3.png"); -const anchorIcon = assetUrl("images/buildings/port1.png"); -const missileSiloIcon = assetUrl("images/buildings/silo1.png"); -const SAMMissileIcon = assetUrl("images/buildings/silo4.png"); - -const underConstructionColor = colord("rgb(150,150,150)"); - -// Base radius values and scaling factor for unit borders and territories -const BASE_BORDER_RADIUS = 16.5; -const BASE_TERRITORY_RADIUS = 13.5; -const RADIUS_SCALE_FACTOR = 0.5; -const ZOOM_THRESHOLD = 4.3; // below this zoom level, structures are not rendered - -interface UnitRenderConfig { - icon: string; - borderRadius: number; - territoryRadius: number; -} - -export class StructureLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private unitIcons: Map = new Map(); - private theme: Theme; - private tempCanvas: HTMLCanvasElement; - private tempContext: CanvasRenderingContext2D; - - // Configuration for supported unit types only - private readonly unitConfigs: Partial> = { - [UnitType.Port]: { - icon: anchorIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.City]: { - icon: cityIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.Factory]: { - icon: factoryIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.MissileSilo]: { - icon: missileSiloIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.DefensePost]: { - icon: shieldIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.SAMLauncher]: { - icon: SAMMissileIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - }; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - this.tempCanvas = document.createElement("canvas"); - const tempContext = this.tempCanvas.getContext("2d"); - if (tempContext === null) throw new Error("2d context not supported"); - this.tempContext = tempContext; - this.loadIconData(); - } - - private loadIcon(unitType: string, config: UnitRenderConfig) { - const image = new Image(); - // crossOrigin must be set before src so the fetch is CORS-checked. - // Without this, an icon served from CDN_BASE taints any canvas/texture - // it's drawn into, and WebGL refuses to upload it via texImage2D. - image.crossOrigin = "anonymous"; - image.src = config.icon; - image.onload = () => { - this.unitIcons.set(unitType, image); - console.log( - `icon loaded: ${unitType}, size: ${image.width}x${image.height}`, - ); - }; - image.onerror = () => { - console.error(`Failed to load icon for ${unitType}: ${config.icon}`); - }; - } - - private loadIconData() { - Object.entries(this.unitConfigs).forEach(([unitType, config]) => { - this.loadIcon(unitType, config); - }); - } - - shouldTransform(): boolean { - return true; - } - - tick() { - const updates = this.game.updatesSinceLastTick(); - const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; - for (const u of unitUpdates) { - const unit = this.game.unit(u.id); - if (unit === undefined) continue; - this.handleUnitRendering(unit); - } - } - - init() { - this.redraw(); - } - - redraw() { - console.log("structure layer redrawing"); - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d", { alpha: true }); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - - // Firefox's GPU limit is 8192, only known browser issue - const maxTextureSize = 8192; - const scaleX = maxTextureSize / this.game.width(); - const scaleY = maxTextureSize / this.game.height(); - const targetScale = Math.min(2, scaleX, scaleY); - this.canvas.width = Math.max( - 1, - Math.floor(this.game.width() * targetScale), - ); - this.canvas.height = Math.max( - 1, - Math.floor(this.game.height() * targetScale), - ); - - // Enable smooth scaling - this.context.imageSmoothingEnabled = true; - this.context.imageSmoothingQuality = "high"; - this.context.scale( - this.canvas.width / (this.game.width() * 2), - this.canvas.height / (this.game.height() * 2), - ); - - Promise.all( - Array.from(this.unitIcons.values()).map((img) => - img.decode?.().catch((err) => { - console.warn("Failed to decode unit icon image:", err); - }), - ), - ).finally(() => { - this.game.units().forEach((u) => this.handleUnitRendering(u)); - }); - } - - renderLayer(context: CanvasRenderingContext2D) { - if ( - this.transformHandler.scale <= ZOOM_THRESHOLD || - !this.game.config().userSettings()?.structureSprites() - ) { - return; - } - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } - - private isUnitTypeSupported(unitType: UnitType): boolean { - return unitType in this.unitConfigs; - } - - private drawBorder( - unit: UnitView, - borderColor: Colord, - config: UnitRenderConfig, - ) { - // Draw border and territory - for (const tile of this.game.bfs( - unit.tile(), - isometricDistFN(unit.tile(), config.borderRadius, true), - )) { - this.paintCell( - new Cell(this.game.x(tile), this.game.y(tile)), - borderColor, - 255, - ); - } - - for (const tile of this.game.bfs( - unit.tile(), - isometricDistFN(unit.tile(), config.territoryRadius, true), - )) { - this.paintCell( - new Cell(this.game.x(tile), this.game.y(tile)), - unit.isUnderConstruction() - ? underConstructionColor - : unit.owner().territoryColor(), - 130, - ); - } - } - - private handleUnitRendering(unit: UnitView) { - const unitType = unit.type(); - const iconType = unitType; - if (!this.isUnitTypeSupported(unitType)) return; - - const config = this.unitConfigs[unitType]; - let icon: HTMLImageElement | undefined; - let borderColor = unit.owner().borderColor(); - - // Handle cooldown states and special icons - if (unit.isUnderConstruction()) { - icon = this.unitIcons.get(iconType); - borderColor = underConstructionColor; - } else { - icon = this.unitIcons.get(iconType); - } - - if (!config || !icon) return; - - // Clear previous rendering - for (const tile of this.game.bfs( - unit.tile(), - euclDistFN(unit.tile(), config.borderRadius + 1, true), - )) { - this.clearCell(new Cell(this.game.x(tile), this.game.y(tile))); - } - - if (!unit.isActive()) return; - - this.drawBorder(unit, borderColor, config); - - // Render icon at 1/2 scale for better quality - const scaledWidth = icon.width >> 1; - const scaledHeight = icon.height >> 1; - const startX = this.game.x(unit.tile()) - (scaledWidth >> 1); - const startY = this.game.y(unit.tile()) - (scaledHeight >> 1); - - this.renderIcon(icon, startX, startY - 4, scaledWidth, scaledHeight, unit); - } - - private renderIcon( - image: HTMLImageElement, - startX: number, - startY: number, - width: number, - height: number, - unit: UnitView, - ) { - let color = unit.owner().borderColor(); - if (unit.isUnderConstruction()) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - color = underConstructionColor; - } - - // Make temp canvas at the final render size (2x scale) - this.tempCanvas.width = width * 2; - this.tempCanvas.height = height * 2; - - // Enable smooth scaling - this.tempContext.imageSmoothingEnabled = true; - this.tempContext.imageSmoothingQuality = "high"; - - // Draw the image at final size with high quality scaling - this.tempContext.drawImage(image, 0, 0, width * 2, height * 2); - - // Restore the alpha channel - this.tempContext.globalCompositeOperation = "destination-in"; - this.tempContext.drawImage(image, 0, 0, width * 2, height * 2); - - // Draw the final result to the main canvas - this.context.drawImage(this.tempCanvas, startX * 2, startY * 2); - } - - paintCell(cell: Cell, color: Colord, alpha: number) { - this.clearCell(cell); - this.context.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.context.fillRect(cell.x * 2, cell.y * 2, 2, 2); - } - - clearCell(cell: Cell) { - this.context.clearRect(cell.x * 2, cell.y * 2, 2, 2); - } -} diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts deleted file mode 100644 index 492c97028..000000000 --- a/src/client/graphics/layers/TerrainLayer.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Theme } from "src/core/configuration/Theme"; -import { Config } from "../../../core/configuration/Config"; -import { GameView } from "../../../core/game/GameView"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -export class TerrainLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private theme: Theme; - private config: Config; - - constructor( - private game: GameView, - private transformHandler: TransformHandler, - ) { - this.config = this.game.config(); - } - shouldTransform(): boolean { - return true; - } - tick() { - if (this.config.theme() !== this.theme) { - this.redraw(); - return; - } - // Repaint terrain for tiles whose terrain changed (e.g. nuke - // turning land to water). - const updatedTiles = this.game.recentlyUpdatedTerrainTiles(); - if (updatedTiles.length > 0) { - let dirty = false; - for (const tile of updatedTiles) { - const terrainColor = this.theme.terrainColor(this.game, tile); - const offset = tile * 4; - const r = terrainColor.rgba.r; - const g = terrainColor.rgba.g; - const b = terrainColor.rgba.b; - if ( - this.imageData.data[offset] !== r || - this.imageData.data[offset + 1] !== g || - this.imageData.data[offset + 2] !== b - ) { - this.imageData.data[offset] = r; - this.imageData.data[offset + 1] = g; - this.imageData.data[offset + 2] = b; - dirty = true; - } - } - if (dirty) { - this.context.putImageData(this.imageData, 0, 0); - } - } - } - - init() { - console.log("redrew terrain layer"); - this.redraw(); - } - - redraw(): void { - this.canvas = document.createElement("canvas"); - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - - const context = this.canvas.getContext("2d", { alpha: false }); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - - this.imageData = this.context.createImageData( - this.canvas.width, - this.canvas.height, - ); - - this.initImageData(); - this.context.putImageData(this.imageData, 0, 0); - } - - initImageData() { - this.theme = this.config.theme(); - this.game.forEachTile((tile) => { - const terrainColor = this.theme.terrainColor(this.game, tile); - // TODO: isn't tileref and index the same? - const index = this.game.y(tile) * this.game.width() + this.game.x(tile); - const offset = index * 4; - this.imageData.data[offset] = terrainColor.rgba.r; - this.imageData.data[offset + 1] = terrainColor.rgba.g; - this.imageData.data[offset + 2] = terrainColor.rgba.b; - this.imageData.data[offset + 3] = 255; - }); - } - - renderLayer(context: CanvasRenderingContext2D) { - if (this.transformHandler.scale < 1) { - context.imageSmoothingEnabled = true; - context.imageSmoothingQuality = "low"; - } else { - context.imageSmoothingEnabled = false; - } - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } -} diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts deleted file mode 100644 index 3cc3d34e6..000000000 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ /dev/null @@ -1,709 +0,0 @@ -import { PriorityQueue } from "@datastructures-js/priority-queue"; -import { Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; -import { EventBus } from "../../../core/EventBus"; -import { - Cell, - ColoredTeams, - PlayerType, - Team, - UnitType, -} from "../../../core/game/Game"; -import { euclDistFN, TileRef } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, PlayerView } from "../../../core/game/GameView"; -import { PseudoRandom } from "../../../core/PseudoRandom"; -import { - AlternateViewEvent, - DragEvent, - MouseOverEvent, -} from "../../InputHandler"; -import { FrameProfiler } from "../FrameProfiler"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -export class TerritoryLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private alternativeImageData: ImageData; - private borderAnimTime = 0; - - private cachedTerritoryPatternsEnabled: boolean | undefined; - - private tileToRenderQueue: PriorityQueue<{ - tile: TileRef; - lastUpdate: number; - }> = new PriorityQueue((a, b) => { - return a.lastUpdate - b.lastUpdate; - }); - private random = new PseudoRandom(123); - private theme: Theme; - - // Used for spawn highlighting - private highlightCanvas: HTMLCanvasElement; - private highlightContext: CanvasRenderingContext2D; - - private highlightedTerritory: PlayerView | null = null; - - private alternativeView = false; - private lastDragTime = 0; - private nodrawDragDuration = 200; - private lastMousePosition: { x: number; y: number } | null = null; - - private refreshRate = 10; //refresh every 10ms - private lastRefresh = 0; - - private lastFocusedPlayer: PlayerView | null = null; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - this.cachedTerritoryPatternsEnabled = undefined; - } - - shouldTransform(): boolean { - return true; - } - - async paintPlayerBorder(player: PlayerView) { - const tiles = await player.borderTiles(); - tiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing - }); - } - - tick() { - if (this.game.inSpawnPhase()) { - this.spawnHighlight(); - } - - this.game.recentlyUpdatedTiles().forEach((t) => { - this.enqueueTile(t); - // Immediately clear territory overlay for water tiles so old - // borders/territory don't persist visually (e.g. after nuke turns land to water) - if (this.game.isWater(t)) { - this.clearTile(t); - } - }); - const updates = this.game.updatesSinceLastTick(); - const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; - unitUpdates.forEach((update) => { - if (update.unitType === UnitType.DefensePost) { - // Only update borders if the defense post is not under construction - if (update.underConstruction) { - return; // Skip barrier creation while under construction - } - - const tile = update.pos; - this.game - .bfs(tile, euclDistFN(tile, this.game.config().defensePostRange())) - .forEach((t) => { - if ( - this.game.isBorder(t) && - (this.game.ownerID(t) === update.ownerID || - this.game.ownerID(t) === update.lastOwnerID) - ) { - this.enqueueTile(t); - } - }); - } - }); - - // Detect alliance mutations - const myPlayer = this.game.myPlayer(); - if (myPlayer) { - updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => { - const territory = this.game.playerBySmallID(update.betrayedID); - if (territory && territory instanceof PlayerView) { - this.redrawBorder(territory); - } - }); - - updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => { - if ( - update.accepted && - (update.request.requestorID === myPlayer.smallID() || - update.request.recipientID === myPlayer.smallID()) - ) { - const territoryId = - update.request.requestorID === myPlayer.smallID() - ? update.request.recipientID - : update.request.requestorID; - const territory = this.game.playerBySmallID(territoryId); - if (territory && territory instanceof PlayerView) { - this.redrawBorder(territory); - } - } - }); - updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => { - const player = this.game.playerBySmallID(update.playerID) as PlayerView; - const embargoed = this.game.playerBySmallID( - update.embargoedID, - ) as PlayerView; - - if ( - player.id() === myPlayer?.id() || - embargoed.id() === myPlayer?.id() - ) { - this.redrawBorder(player, embargoed); - } - }); - } - - const focusedPlayer = this.game.focusedPlayer(); - if (focusedPlayer !== this.lastFocusedPlayer) { - if (this.lastFocusedPlayer) { - this.paintPlayerBorder(this.lastFocusedPlayer); - } - if (focusedPlayer) { - this.paintPlayerBorder(focusedPlayer); - } - this.lastFocusedPlayer = focusedPlayer; - } - } - - private spawnHighlight() { - this.highlightContext.clearRect( - 0, - 0, - this.game.width(), - this.game.height(), - ); - - this.drawFocusedPlayerHighlight(); - - const humans = this.game - .playerViews() - .filter((p) => p.type() === PlayerType.Human); - - const focusedPlayer = this.game.focusedPlayer(); - const teamColors = Object.values(ColoredTeams); - for (const human of humans) { - if (human === focusedPlayer) { - continue; - } - const center = human.nameLocation(); - if (!center) { - continue; - } - const centerTile = this.game.ref(center.x, center.y); - if (!centerTile) { - continue; - } - let color = this.theme.spawnHighlightColor(); - const myPlayer = this.game.myPlayer(); - if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) { - // In FFA games (when team === null), use default yellow spawn highlight color - color = this.theme.spawnHighlightColor(); - } else if (myPlayer !== null && myPlayer !== human) { - // In Team games, the spawn highlight color becomes that player's team color - // Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively - const team = human.team(); - if (team !== null && teamColors.includes(team)) { - color = this.theme.teamColor(team); - } else { - if (myPlayer.isFriendly(human)) { - color = this.theme.spawnHighlightTeamColor(); - } else { - color = this.theme.spawnHighlightColor(); - } - } - } - - for (const tile of this.game.bfs( - centerTile, - euclDistFN(centerTile, 9, true), - )) { - if (!this.game.hasOwner(tile)) { - this.paintHighlightTile(tile, color, 255); - } - } - } - } - - private drawFocusedPlayerHighlight() { - const focusedPlayer = this.game.focusedPlayer(); - - if (!focusedPlayer) { - return; - } - const center = focusedPlayer.nameLocation(); - if (!center) { - return; - } - // Breathing border animation - this.borderAnimTime += 0.5; - const minRad = 8; - const maxRad = 24; - // Range: [minPadding..maxPadding] - const radius = - minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime)); - - const baseColor = this.theme.spawnHighlightSelfColor(); //white - let teamColor: Colord; - - const team: Team | null = focusedPlayer.team(); - if (team !== null && Object.values(ColoredTeams).includes(team)) { - teamColor = this.theme.teamColor(team).alpha(0.5); - } else { - teamColor = baseColor; - } - - this.drawBreathingRing( - center.x, - center.y, - minRad, - maxRad, - radius, - baseColor, // Always draw white static semi-transparent ring - teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games. - ); - - // Draw breathing rings for teammates in team games (helps colorblind players identify teammates) - this.drawTeammateHighlights(minRad, maxRad, radius); - } - - private drawTeammateHighlights( - minRad: number, - maxRad: number, - radius: number, - ) { - const myPlayer = this.game.myPlayer(); - if (myPlayer === null || myPlayer.team() === null) { - return; - } - - const teammates = this.game - .playerViews() - .filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p)); - - // Smaller radius for teammates (more subtle than self highlight) - const teammateMinRad = 5; - const teammateMaxRad = 14; - const teammateRadius = - teammateMinRad + - (teammateMaxRad - teammateMinRad) * - ((radius - minRad) / (maxRad - minRad)); - - const teamColors = Object.values(ColoredTeams); - for (const teammate of teammates) { - const center = teammate.nameLocation(); - if (!center) { - continue; - } - - const team = teammate.team(); - let baseColor: Colord; - let breathingColor: Colord; - - if (team !== null && teamColors.includes(team)) { - baseColor = this.theme.teamColor(team).alpha(0.5); - breathingColor = this.theme.teamColor(team).alpha(0.5); - } else { - baseColor = this.theme.spawnHighlightTeamColor(); - breathingColor = this.theme.spawnHighlightTeamColor(); - } - - this.drawBreathingRing( - center.x, - center.y, - teammateMinRad, - teammateMaxRad, - teammateRadius, - baseColor, - breathingColor, - ); - } - } - - init() { - this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); - this.eventBus.on(AlternateViewEvent, (e) => { - this.alternativeView = e.alternateView; - }); - this.eventBus.on(DragEvent, (e) => { - // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. - // this.lastDragTime = Date.now(); - }); - this.redraw(); - } - - onMouseOver(event: MouseOverEvent) { - this.lastMousePosition = { x: event.x, y: event.y }; - this.updateHighlightedTerritory(); - } - - private updateHighlightedTerritory() { - if (!this.alternativeView) { - return; - } - - if (!this.lastMousePosition) { - return; - } - - const cell = this.transformHandler.screenToWorldCoordinates( - this.lastMousePosition.x, - this.lastMousePosition.y, - ); - if (!this.game.isValidCoord(cell.x, cell.y)) { - return; - } - - const previousTerritory = this.highlightedTerritory; - const territory = this.getTerritoryAtCell(cell); - - if (territory) { - this.highlightedTerritory = territory; - } else { - this.highlightedTerritory = null; - } - - if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - const territories: PlayerView[] = []; - if (previousTerritory) { - territories.push(previousTerritory); - } - if (this.highlightedTerritory) { - territories.push(this.highlightedTerritory); - } - this.redrawBorder(...territories); - } - } - - private getTerritoryAtCell(cell: { x: number; y: number }) { - const tile = this.game.ref(cell.x, cell.y); - if (!tile) { - return null; - } - // If the tile has no owner, it is either a fallout tile or a terra nullius tile. - if (!this.game.hasOwner(tile)) { - return null; - } - const owner = this.game.owner(tile); - return owner instanceof PlayerView ? owner : null; - } - - redraw() { - console.log("redrew territory layer"); - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d"); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - - this.imageData = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - this.alternativeImageData = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - this.initImageData(); - - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - ); - - // Add a second canvas for highlights - this.highlightCanvas = document.createElement("canvas"); - const highlightContext = this.highlightCanvas.getContext("2d", { - alpha: true, - }); - if (highlightContext === null) throw new Error("2d context not supported"); - this.highlightContext = highlightContext; - this.highlightCanvas.width = this.game.width(); - this.highlightCanvas.height = this.game.height(); - - this.game.forEachTile((t) => { - this.paintTerritory(t); - }); - } - - redrawBorder(...players: PlayerView[]) { - return Promise.all( - players.map(async (player) => { - const tiles = await player.borderTiles(); - tiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); - }); - }), - ); - } - - initImageData() { - this.game.forEachTile((tile) => { - const cell = new Cell(this.game.x(tile), this.game.y(tile)); - const index = cell.y * this.game.width() + cell.x; - const offset = index * 4; - this.imageData.data[offset + 3] = 0; - this.alternativeImageData.data[offset + 3] = 0; - }); - } - - renderLayer(context: CanvasRenderingContext2D) { - const now = Date.now(); - if ( - now > this.lastDragTime + this.nodrawDragDuration && - now > this.lastRefresh + this.refreshRate - ) { - this.lastRefresh = now; - const renderTerritoryStart = FrameProfiler.start(); - this.renderTerritory(); - FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); - - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const vx0 = Math.max(0, topLeft.x); - const vy0 = Math.max(0, topLeft.y); - const vx1 = Math.min(this.game.width() - 1, bottomRight.x); - const vy1 = Math.min(this.game.height() - 1, bottomRight.y); - - const w = vx1 - vx0 + 1; - const h = vy1 - vy0 + 1; - - if (w > 0 && h > 0) { - const putImageStart = FrameProfiler.start(); - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - vx0, - vy0, - w, - h, - ); - FrameProfiler.end("TerritoryLayer:putImageData", putImageStart); - } - } - - const drawCanvasStart = FrameProfiler.start(); - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart); - if (this.game.inSpawnPhase()) { - const highlightDrawStart = FrameProfiler.start(); - context.drawImage( - this.highlightCanvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - FrameProfiler.end( - "TerritoryLayer:drawHighlightCanvas", - highlightDrawStart, - ); - } - } - - renderTerritory() { - let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); - if (numToRender === 0 || this.game.inSpawnPhase()) { - numToRender = this.tileToRenderQueue.size(); - } - - while (numToRender > 0) { - numToRender--; - - const entry = this.tileToRenderQueue.pop(); - if (!entry) { - break; - } - - const tile = entry.tile; - this.paintTerritory(tile); - for (const neighbor of this.game.neighbors(tile)) { - this.paintTerritory(neighbor, true); - } - } - } - - paintTerritory(tile: TileRef, isBorder: boolean = false) { - if (isBorder && !this.game.hasOwner(tile)) { - return; - } - - if (!this.game.hasOwner(tile)) { - if (this.game.hasFallout(tile)) { - this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); - this.paintTile( - this.alternativeImageData, - tile, - this.theme.falloutColor(), - 150, - ); - return; - } - this.clearTile(tile); - return; - } - const owner = this.game.owner(tile) as PlayerView; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const isHighlighted = - this.highlightedTerritory && - this.highlightedTerritory.id() === owner.id(); - const myPlayer = this.game.myPlayer(); - - if (this.game.isBorder(tile)) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const playerIsFocused = owner && this.game.focusedPlayer() === owner; - if (myPlayer) { - const alternativeColor = this.alternateViewColor(owner); - this.paintTile(this.alternativeImageData, tile, alternativeColor, 255); - } - const isDefended = this.game.hasUnitNearby( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - owner.id(), - ); - - this.paintTile( - this.imageData, - tile, - owner.borderColor(tile, isDefended), - 255, - ); - } else { - // Alternative view only shows borders. - this.clearAlternativeTile(tile); - - this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150); - } - } - - alternateViewColor(other: PlayerView): Colord { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return this.theme.neutralColor(); - } - if (other.smallID() === myPlayer.smallID()) { - return this.theme.selfColor(); - } - if (other.isFriendly(myPlayer)) { - return this.theme.allyColor(); - } - if (!other.hasEmbargo(myPlayer)) { - return this.theme.neutralColor(); - } - return this.theme.enemyColor(); - } - - paintAlternateViewTile(tile: TileRef, other: PlayerView) { - const color = this.alternateViewColor(other); - this.paintTile(this.alternativeImageData, tile, color, 255); - } - - paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) { - const offset = tile * 4; - imageData.data[offset] = color.rgba.r; - imageData.data[offset + 1] = color.rgba.g; - imageData.data[offset + 2] = color.rgba.b; - imageData.data[offset + 3] = alpha; - } - - clearTile(tile: TileRef) { - const offset = tile * 4; - this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - } - - clearAlternativeTile(tile: TileRef) { - const offset = tile * 4; - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - } - - enqueueTile(tile: TileRef) { - this.tileToRenderQueue.push({ - tile: tile, - lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5), - }); - } - - async enqueuePlayerBorder(player: PlayerView) { - const playerBorderTiles = await player.borderTiles(); - playerBorderTiles.borderTiles.forEach((tile: TileRef) => { - this.enqueueTile(tile); - }); - } - - paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { - this.clearTile(tile); - const x = this.game.x(tile); - const y = this.game.y(tile); - this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.highlightContext.fillRect(x, y, 1, 1); - } - - clearHighlightTile(tile: TileRef) { - const x = this.game.x(tile); - const y = this.game.y(tile); - this.highlightContext.clearRect(x, y, 1, 1); - } - - private drawBreathingRing( - cx: number, - cy: number, - minRad: number, - maxRad: number, - radius: number, - transparentColor: Colord, - breathingColor: Colord, - ) { - const ctx = this.highlightContext; - if (!ctx) return; - - // Draw a semi-transparent ring around the starting location - ctx.beginPath(); - // Transparency matches the highlight color provided - const transparent = transparentColor.alpha(0); - const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad); - - // Pixels with radius < minRad are transparent - radGrad.addColorStop(0, transparent.toRgbString()); - // The ring then starts with solid highlight color - radGrad.addColorStop(0.01, transparentColor.toRgbString()); - radGrad.addColorStop(0.1, transparentColor.toRgbString()); - // The outer edge of the ring is transparent - radGrad.addColorStop(1, transparent.toRgbString()); - - // Draw an arc at the max radius and fill with the created radial gradient - ctx.arc(cx, cy, maxRad, 0, Math.PI * 2); - ctx.fillStyle = radGrad; - ctx.closePath(); - ctx.fill(); - - const breatheInner = breathingColor.alpha(0); - // Draw a solid ring around the starting location with outer radius = the breathing radius - ctx.beginPath(); - const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius); - // Pixels with radius < minRad are transparent - radGrad2.addColorStop(0, breatheInner.toRgbString()); - // The ring then starts with solid highlight color - radGrad2.addColorStop(0.01, breathingColor.toRgbString()); - // The ring is solid throughout - radGrad2.addColorStop(1, breathingColor.toRgbString()); - - // Draw an arc at the current breathing radius and fill with the created "gradient" - ctx.arc(cx, cy, radius, 0, Math.PI * 2); - ctx.fillStyle = radGrad2; - ctx.fill(); - } -} diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts index 4c4ff15bb..1338baad4 100644 --- a/src/client/graphics/layers/UILayer.ts +++ b/src/client/graphics/layers/UILayer.ts @@ -2,7 +2,6 @@ import { Colord } from "colord"; import { Theme } from "src/core/configuration/Theme"; import { EventBus } from "../../../core/EventBus"; import { UnitType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView, UnitView } from "../../../core/game/GameView"; import { CloseViewEvent, @@ -11,34 +10,19 @@ import { WarshipSelectionBoxCompleteEvent, WarshipSelectionBoxUpdateEvent, } from "../../InputHandler"; -import { ProgressBar } from "../ProgressBar"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; -const COLOR_PROGRESSION = [ - "rgb(232, 25, 25)", - "rgb(240, 122, 25)", - "rgb(202, 231, 15)", - "rgb(44, 239, 18)", -]; -const HEALTHBAR_WIDTH = 11; // Width of the health bar -const LOADINGBAR_WIDTH = 14; // Width of the loading bar -const PROGRESSBAR_HEIGHT = 3; // Height of a bar - /** - * Layer responsible for drawing UI elements that overlay the game - * such as selection boxes, health bars, etc. + * Layer responsible for drawing UI elements that overlay the game. + * Currently: warship selection boxes + drag-rectangle selection. + * Health/progress bars are now drawn by the WebGL BarPass. */ export class UILayer implements Layer { private canvas: HTMLCanvasElement; private context: CanvasRenderingContext2D | null; private theme: Theme | null = null; private selectionAnimTime = 0; - private allProgressBars: Map< - number, - { unit: UnitView; progressBar: ProgressBar } - > = new Map(); - private allHealthBars: Map = new Map(); // Keep track of currently selected unit private selectedUnit: UnitView | null = null; @@ -109,15 +93,6 @@ export class UILayer implements Layer { this.multiSelectedWarships = this.multiSelectedWarships.filter((u) => u.isActive(), ); - - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) - ?.forEach((unitView) => { - if (unitView === undefined) return; - this.onUnitEvent(unitView); - }); - this.updateProgressBars(); } init() { @@ -249,56 +224,6 @@ export class UILayer implements Layer { this.selectionBoxCtx = this.selectionBoxCanvas.getContext("2d"); } - onUnitEvent(unit: UnitView) { - const underConst = unit.isUnderConstruction(); - if (underConst) { - this.createLoadingBar(unit); - return; - } - switch (unit.type()) { - case UnitType.Warship: { - this.drawHealthBar(unit); - break; - } - case UnitType.City: - case UnitType.Factory: - case UnitType.DefensePost: - case UnitType.Port: - case UnitType.MissileSilo: - case UnitType.SAMLauncher: - if ( - unit.markedForDeletion() !== false || - unit.missileReadinesss() < 1 - ) { - this.createLoadingBar(unit); - } - break; - default: - return; - } - } - - private clearIcon(icon: HTMLImageElement, startX: number, startY: number) { - if (this.context !== null) { - this.context.clearRect(startX, startY, icon.width, icon.height); - } - } - - private drawIcon( - icon: HTMLImageElement, - unit: UnitView, - startX: number, - startY: number, - ) { - if (this.context === null || this.theme === null) { - return; - } - const color = unit.owner().borderColor(); - this.context.fillStyle = color.toRgbString(); - this.context.fillRect(startX, startY, icon.width, icon.height); - this.context.drawImage(icon, startX, startY); - } - /** * Handle the unit selection event (single or multi). * When event.units.length > 0 it's a multi-selection from box/select-all. @@ -458,117 +383,6 @@ export class UILayer implements Layer { }; } - /** - * Draw health bar for a unit - */ - public drawHealthBar(unit: UnitView) { - const maxHealth = this.game.unitInfo(unit.type()).maxHealth; - if (maxHealth === undefined || this.context === null) { - return; - } - if ( - this.allHealthBars.has(unit.id()) && - (unit.health() >= maxHealth || unit.health() <= 0 || !unit.isActive()) - ) { - // full hp/dead warships dont need a hp bar - this.allHealthBars.get(unit.id())?.clear(); - this.allHealthBars.delete(unit.id()); - } else if ( - unit.isActive() && - unit.health() < maxHealth && - unit.health() > 0 - ) { - this.allHealthBars.get(unit.id())?.clear(); - const healthBar = new ProgressBar( - COLOR_PROGRESSION, - this.context, - this.game.x(unit.tile()) - 4, - this.game.y(unit.tile()) - 6, - HEALTHBAR_WIDTH, - PROGRESSBAR_HEIGHT, - unit.health() / maxHealth, - ); - // keep track of units that have health bars for clearing purposes - this.allHealthBars.set(unit.id(), healthBar); - } - } - - private updateProgressBars() { - this.allProgressBars.forEach((progressBarInfo, unitId) => { - const progress = this.getProgress(progressBarInfo.unit); - if (progress >= 1) { - this.allProgressBars.get(unitId)?.progressBar.clear(); - this.allProgressBars.delete(unitId); - return; - } else { - progressBarInfo.progressBar.setProgress(progress); - } - }); - } - - private getProgress(unit: UnitView): number { - if (!unit.isActive()) { - return 1; - } - const underConst = unit.isUnderConstruction(); - if (underConst) { - const constDuration = this.game.unitInfo( - unit.type(), - ).constructionDuration; - if (constDuration === undefined) { - throw new Error("unit does not have constructionTime"); - } - return ( - (this.game.ticks() - unit.createdAt()) / - (constDuration === 0 ? 1 : constDuration) - ); - } - switch (unit.type()) { - case UnitType.MissileSilo: - case UnitType.SAMLauncher: - return !unit.markedForDeletion() - ? unit.missileReadinesss() - : this.deletionProgress(this.game, unit); - case UnitType.City: - case UnitType.Factory: - case UnitType.Port: - case UnitType.DefensePost: - return this.deletionProgress(this.game, unit); - default: - return 1; - } - } - - private deletionProgress(game: GameView, unit: UnitView): number { - const deleteAt = unit.markedForDeletion(); - if (deleteAt === false) return 1; - return Math.max( - 0, - (deleteAt - game.ticks()) / game.config().deletionMarkDuration(), - ); - } - - public createLoadingBar(unit: UnitView) { - if (!this.context) { - return; - } - if (!this.allProgressBars.has(unit.id())) { - const progressBar = new ProgressBar( - COLOR_PROGRESSION, - this.context, - this.game.x(unit.tile()) - 6, - this.game.y(unit.tile()) + 6, - LOADINGBAR_WIDTH, - PROGRESSBAR_HEIGHT, - 0, - ); - this.allProgressBars.set(unit.id(), { - unit, - progressBar, - }); - } - } - paintCell(x: number, y: number, color: Colord, alpha: number) { if (this.context === null) throw new Error("null context"); this.clearCell(x, y); diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts deleted file mode 100644 index e40fb4d0e..000000000 --- a/src/client/graphics/layers/UnitLayer.ts +++ /dev/null @@ -1,768 +0,0 @@ -import { colord, Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; -import { EventBus } from "../../../core/EventBus"; -import { Cell, UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameView, UnitView } from "../../../core/game/GameView"; -import { BezenhamLine } from "../../../core/utilities/Line"; -import { - AlternateViewEvent, - CloseViewEvent, - ContextMenuEvent, - MouseUpEvent, - SelectAllWarshipsEvent, - TouchEvent, - UnitSelectionEvent, - WarshipSelectionBoxCancelEvent, - WarshipSelectionBoxCompleteEvent, -} from "../../InputHandler"; -import { MoveWarshipIntentEvent } from "../../Transport"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { - getColoredSprite, - isSpriteReady, - loadAllSprites, -} from "../SpriteLoader"; - -enum Relationship { - Self, - Ally, - Enemy, -} - -export class UnitLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private transportShipTrailCanvas: HTMLCanvasElement; - private unitTrailContext: CanvasRenderingContext2D; - - private unitToTrail = new Map(); - private pendingTrailClears: UnitView[] = []; - - private theme: Theme; - - private alternateView = false; - - private oldShellTile = new Map(); - - private transformHandler: TransformHandler; - - // Selected unit property as suggested in the review comment - private selectedUnit: UnitView | null = null; - - // Multi-selected warships (from selection box) - private selectedWarships: UnitView[] = []; - - // Configuration for unit selection - private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone - - constructor( - private game: GameView, - private eventBus: EventBus, - transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - this.transformHandler = transformHandler; - } - - shouldTransform(): boolean { - return true; - } - - tick() { - const updatedUnitIds = - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? []; - - const motionPlanUnitIds = this.game.motionPlannedUnitIds(); - - if (updatedUnitIds.length === 0) { - this.updateUnitsSprites(motionPlanUnitIds); - return; - } - if (motionPlanUnitIds.length === 0) { - this.updateUnitsSprites(updatedUnitIds); - return; - } - - const unitIds = new Set(updatedUnitIds); - for (const id of motionPlanUnitIds) { - unitIds.add(id); - } - this.updateUnitsSprites(Array.from(unitIds)); - } - - init() { - this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e)); - this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e)); - this.eventBus.on(TouchEvent, (e) => this.onTouch(e)); - this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e)); - this.eventBus.on(WarshipSelectionBoxCompleteEvent, (e) => - this.onSelectionBoxComplete(e), - ); - this.eventBus.on(WarshipSelectionBoxCancelEvent, () => - this.onSelectionBoxCancel(), - ); - this.eventBus.on(CloseViewEvent, () => this.onSelectionBoxCancel()); - this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships()); - this.redraw(); - - loadAllSprites(); - } - - /** - * Find player-owned warships near the given cell within a configurable radius - * @param clickRef The tile to check - * @returns Array of player's warships in range, sorted by distance (closest first) - */ - private findWarshipsNearCell(clickRef: TileRef): UnitView[] { - // Only select warships owned by the player - return this.game - .units(UnitType.Warship) - .filter( - (unit) => - unit.isActive() && - unit.owner() === this.game.myPlayer() && // Only allow selecting own warships - this.game.manhattanDist(unit.tile(), clickRef) <= - this.WARSHIP_SELECTION_RADIUS, - ) - .sort((a, b) => { - // Sort by distance (closest first) - const distA = this.game.manhattanDist(a.tile(), clickRef); - const distB = this.game.manhattanDist(b.tile(), clickRef); - return distA - distB; - }); - } - - private onMouseUp( - event: MouseUpEvent, - clickRef?: TileRef, - nearbyWarships?: UnitView[], - ) { - if (clickRef === undefined) { - // Convert screen coordinates to world coordinates - const cell = this.transformHandler.screenToWorldCoordinates( - event.x, - event.y, - ); - if (!this.game.isValidCoord(cell.x, cell.y)) return; - - clickRef = this.game.ref(cell.x, cell.y); - } - if (!this.game.isWater(clickRef)) return; - - // If we have multi-selected warships, send them all to this tile - if (this.selectedWarships.length > 0) { - const myPlayer = this.game.myPlayer(); - const activeIds = this.selectedWarships - .filter((u) => u.isActive() && u.owner() === myPlayer) - .map((u) => u.id()); - - if (activeIds.length > 0) { - this.eventBus.emit(new MoveWarshipIntentEvent(activeIds, clickRef)); - } - this.selectedWarships = []; - this.eventBus.emit(new UnitSelectionEvent(null, false)); - return; - } - - if (this.selectedUnit) { - this.eventBus.emit( - new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef), - ); - // Deselect - this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); - return; - } - - // Find warships near this tile, sorted by distance - nearbyWarships ??= this.findWarshipsNearCell(clickRef); - if (nearbyWarships.length > 0) { - // Toggle selection of the closest warship - this.eventBus.emit(new UnitSelectionEvent(nearbyWarships[0], true)); - } - } - - private onTouch(event: TouchEvent) { - const cell = this.transformHandler.screenToWorldCoordinates( - event.x, - event.y, - ); - - if (!this.game.isValidCoord(cell.x, cell.y)) { - return; - } - - const clickRef = this.game.ref(cell.x, cell.y); - if (this.game.inSpawnPhase()) { - // No Radial Menu during spawn phase, only spawn point selection - if (!this.game.isWater(clickRef)) { - this.eventBus.emit(new MouseUpEvent(event.x, event.y)); - } - return; - } - - if (!this.game.isWater(clickRef)) { - // No warship to find because no Ocean tile, open Radial Menu - this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); - return; - } - - if (this.selectedUnit) { - // Reuse the mouse logic, send clickRef to avoid fetching it again - this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef); - return; - } - - // Also delegate if we have multi-selected warships - if (this.selectedWarships.length > 0) { - this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef); - return; - } - - const nearbyWarships = this.findWarshipsNearCell(clickRef); - - if (nearbyWarships.length > 0) { - this.onMouseUp( - new MouseUpEvent(event.x, event.y), - clickRef, - nearbyWarships, - ); - } else { - // No warships selected or nearby, open Radial Menu - this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); - } - } - - /** - * Handle unit selection changes - */ - private onUnitSelectionChange(event: UnitSelectionEvent) { - if (event.isSelected) { - this.selectedUnit = event.unit; - } else if (this.selectedUnit === event.unit) { - this.selectedUnit = null; - } - } - - /** - * Handle completion of shift+drag selection box. - * Finds all player-owned warships within the screen rectangle. - */ - private onSelectionBoxComplete(event: WarshipSelectionBoxCompleteEvent) { - const x1 = Math.min(event.startX, event.endX); - const y1 = Math.min(event.startY, event.endY); - const x2 = Math.max(event.startX, event.endX); - const y2 = Math.max(event.startY, event.endY); - - const myPlayer = this.game.myPlayer(); - if (!myPlayer) return; - - this.selectedWarships = this.game.units(UnitType.Warship).filter((unit) => { - if (!unit.isActive() || unit.owner() !== myPlayer) return false; - const screen = this.transformHandler.worldToScreenCoordinates( - new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())), - ); - return ( - screen.x >= x1 && screen.x <= x2 && screen.y >= y1 && screen.y <= y2 - ); - }); - - // Clear single selection if we got a box selection - if (this.selectedWarships.length > 0 && this.selectedUnit) { - this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); - } - - // Notify UILayer to draw selection boxes for all selected warships - this.eventBus.emit( - new UnitSelectionEvent(null, true, this.selectedWarships), - ); - } - - private onSelectionBoxCancel() { - this.selectedWarships = []; - this.eventBus.emit(new UnitSelectionEvent(null, false)); - } - - private onSelectAllWarships() { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) return; - - const allWarships = this.game - .units(UnitType.Warship) - .filter((u) => u.isActive() && u.owner() === myPlayer); - - if (allWarships.length === 0) return; - - // Clear single selection if active - if (this.selectedUnit) { - this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); - } - - this.selectedWarships = allWarships; - this.eventBus.emit( - new UnitSelectionEvent(null, true, this.selectedWarships), - ); - } - - /** - * Handle unit deactivation or destruction - * If the selected unit is removed from the game, deselect it - */ - private handleUnitDeactivation(unit: UnitView) { - if (this.selectedUnit === unit && !unit.isActive()) { - this.eventBus.emit(new UnitSelectionEvent(unit, false)); - } - } - - renderLayer(context: CanvasRenderingContext2D) { - context.drawImage( - this.transportShipTrailCanvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } - - onAlternativeViewEvent(event: AlternateViewEvent) { - this.alternateView = event.alternateView; - this.redraw(); - } - - redraw() { - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d"); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - this.transportShipTrailCanvas = document.createElement("canvas"); - const trailContext = this.transportShipTrailCanvas.getContext("2d"); - if (trailContext === null) throw new Error("2d context not supported"); - this.unitTrailContext = trailContext; - - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - this.transportShipTrailCanvas.width = this.game.width(); - this.transportShipTrailCanvas.height = this.game.height(); - - this.updateUnitsSprites(this.game.units().map((unit) => unit.id())); - - this.unitToTrail.forEach((trail, unit) => { - for (const t of trail) { - this.paintCell( - this.game.x(t), - this.game.y(t), - this.relationship(unit), - unit.owner().territoryColor(), - 150, - this.unitTrailContext, - ); - } - }); - } - - private updateUnitsSprites(unitIds: number[]) { - const unitsToUpdate = unitIds - ?.map((id) => this.game.unit(id)) - .filter((unit) => unit !== undefined); - - if (unitsToUpdate) { - // 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); - this.drawUnitsCells(unitsToUpdate); - this.flushTrailClears(); - } - } - - private clearUnitsCells(unitViews: UnitView[]) { - unitViews - .filter((unitView) => isSpriteReady(unitView)) - .forEach((unitView) => { - const sprite = getColoredSprite(unitView, this.theme); - const clearsize = sprite.width + 1; - const lastX = this.game.x(unitView.lastTile()); - const lastY = this.game.y(unitView.lastTile()); - this.context.clearRect( - lastX - clearsize / 2, - lastY - clearsize / 2, - clearsize, - clearsize, - ); - }); - } - - private drawUnitsCells(unitViews: UnitView[]) { - unitViews.forEach((unitView) => this.onUnitEvent(unitView)); - } - - private relationship(unit: UnitView): Relationship { - const myPlayer = this.game.myPlayer(); - if (myPlayer === null) { - return Relationship.Enemy; - } - if (myPlayer === unit.owner()) { - return Relationship.Self; - } - if (myPlayer.isFriendly(unit.owner())) { - return Relationship.Ally; - } - return Relationship.Enemy; - } - - onUnitEvent(unit: UnitView) { - // Check if unit was deactivated - if (!unit.isActive()) { - this.handleUnitDeactivation(unit); - } - - switch (unit.type()) { - case UnitType.TransportShip: - this.handleBoatEvent(unit); - break; - case UnitType.Warship: - this.handleWarShipEvent(unit); - break; - case UnitType.Shell: - this.handleShellEvent(unit); - break; - case UnitType.SAMMissile: - this.handleMissileEvent(unit); - break; - case UnitType.TradeShip: - this.handleTradeShipEvent(unit); - break; - case UnitType.Train: - this.handleTrainEvent(unit); - break; - case UnitType.MIRVWarhead: - this.handleMIRVWarhead(unit); - break; - case UnitType.AtomBomb: - case UnitType.HydrogenBomb: - case UnitType.MIRV: - this.handleNuke(unit); - break; - } - } - - private handleWarShipEvent(unit: UnitView) { - if (unit.warshipState().state !== "patrolling" && unit.isActive()) { - if (unit.warshipState().isInCombat) { - this.drawSprite(unit, colord("rgb(200,0,0)")); - } else { - this.drawSprite(unit); - } - this.drawRetreatCross(unit); - return; - } - - if (unit.warshipState().isInCombat) { - this.drawSprite(unit, colord("rgb(200,0,0)")); - return; - } - - this.drawSprite(unit); - } - - private drawRetreatCross(unit: UnitView) { - // Blink: 500ms on, 500ms off - if (Math.floor(Date.now() / 500) % 2 === 0) return; - const x = this.game.x(unit.tile()); - const y = this.game.y(unit.tile()); - const ctx = this.context; - ctx.save(); - const cx = x + 0.5; - const cy = y + 0.5; - ctx.lineCap = "square"; - ctx.strokeStyle = "rgb(36,36,36)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(cx, cy - 1.5); - ctx.lineTo(cx, cy + 1.5); - ctx.moveTo(cx - 1.5, cy); - ctx.lineTo(cx + 1.5, cy); - ctx.stroke(); - ctx.restore(); - } - - private handleShellEvent(unit: UnitView) { - const rel = this.relationship(unit); - - // Clear current and previous positions - this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile())); - const oldTile = this.oldShellTile.get(unit); - if (oldTile !== undefined) { - this.clearCell(this.game.x(oldTile), this.game.y(oldTile)); - } - - this.oldShellTile.set(unit, unit.lastTile()); - if (!unit.isActive()) { - return; - } - - // Paint current and previous positions - this.paintCell( - this.game.x(unit.tile()), - this.game.y(unit.tile()), - rel, - unit.owner().borderColor(), - 255, - ); - this.paintCell( - this.game.x(unit.lastTile()), - this.game.y(unit.lastTile()), - rel, - unit.owner().borderColor(), - 255, - ); - } - - // interception missile from SAM - private handleMissileEvent(unit: UnitView) { - this.drawSprite(unit); - } - - private drawTrail(trail: number[], color: Colord, rel: Relationship) { - // Paint new trail - for (const t of trail) { - this.paintCell( - this.game.x(t), - this.game.y(t), - rel, - color, - 150, - this.unitTrailContext, - ); - } - } - - private flushTrailClears() { - if (this.pendingTrailClears.length === 0) return; - - const clearedTiles = new Set(); - for (const unit of this.pendingTrailClears) { - const trail = this.unitToTrail.get(unit); - if (trail) { - for (const t of trail) { - if (!clearedTiles.has(t)) { - this.clearCell( - this.game.x(t), - this.game.y(t), - this.unitTrailContext, - ); - clearedTiles.add(t); - } - } - this.unitToTrail.delete(unit); - } - } - this.pendingTrailClears = []; - - // Single repaint pass for all remaining units - for (const [other, trail] of this.unitToTrail) { - const rel = this.relationship(other); - for (const t of trail) { - if (clearedTiles.has(t)) { - this.paintCell( - this.game.x(t), - this.game.y(t), - rel, - other.owner().territoryColor(), - 150, - this.unitTrailContext, - ); - } - } - } - } - - private handleNuke(unit: UnitView) { - const rel = this.relationship(unit); - - if (!this.unitToTrail.has(unit)) { - this.unitToTrail.set(unit, []); - } - - let newTrailSize = 1; - const trail = this.unitToTrail.get(unit) ?? []; - // It can move faster than 1 pixel, draw a line for the trail or else it will be dotted - if (trail.length >= 1) { - const cur = { - x: this.game.x(unit.lastTile()), - y: this.game.y(unit.lastTile()), - }; - const prev = { - x: this.game.x(trail[trail.length - 1]), - y: this.game.y(trail[trail.length - 1]), - }; - const line = new BezenhamLine(prev, cur); - let point = line.increment(); - while (point !== true) { - trail.push(this.game.ref(point.x, point.y)); - point = line.increment(); - } - newTrailSize = line.size(); - } else { - trail.push(unit.lastTile()); - } - - this.drawTrail( - trail.slice(-newTrailSize), - unit.owner().territoryColor(), - rel, - ); - this.drawSprite(unit); - if (!unit.isActive()) { - this.pendingTrailClears.push(unit); - } - } - - private handleMIRVWarhead(unit: UnitView) { - const rel = this.relationship(unit); - - this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile())); - - if (unit.isActive()) { - // Paint area - this.paintCell( - this.game.x(unit.tile()), - this.game.y(unit.tile()), - rel, - unit.owner().borderColor(), - 255, - ); - } - } - - private handleTradeShipEvent(unit: UnitView) { - this.drawSprite(unit); - } - - private handleTrainEvent(unit: UnitView) { - this.drawSprite(unit); - } - - private handleBoatEvent(unit: UnitView) { - const rel = this.relationship(unit); - - if (!this.unitToTrail.has(unit)) { - this.unitToTrail.set(unit, []); - } - const trail = this.unitToTrail.get(unit) ?? []; - trail.push(unit.lastTile()); - - // Paint trail - this.drawTrail(trail.slice(-1), unit.owner().territoryColor(), rel); - this.drawSprite(unit); - - if (!unit.isActive()) { - this.pendingTrailClears.push(unit); - } - } - - paintCell( - x: number, - y: number, - relationship: Relationship, - color: Colord, - alpha: number, - context: CanvasRenderingContext2D = this.context, - ) { - this.clearCell(x, y, context); - if (this.alternateView) { - switch (relationship) { - case Relationship.Self: - context.fillStyle = this.theme.selfColor().toRgbString(); - break; - case Relationship.Ally: - context.fillStyle = this.theme.allyColor().toRgbString(); - break; - case Relationship.Enemy: - context.fillStyle = this.theme.enemyColor().toRgbString(); - break; - } - } else { - context.fillStyle = color.alpha(alpha / 255).toRgbString(); - } - context.fillRect(x, y, 1, 1); - } - - clearCell( - x: number, - y: number, - context: CanvasRenderingContext2D = this.context, - ) { - context.clearRect(x, y, 1, 1); - } - - drawSprite(unit: UnitView, customTerritoryColor?: Colord) { - const x = this.game.x(unit.tile()); - const y = this.game.y(unit.tile()); - - let alternateViewColor: Colord | null = null; - - if (this.alternateView) { - let rel = this.relationship(unit); - const dstPortId = unit.targetUnitId(); - if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) { - const target = this.game.unit(dstPortId)?.owner(); - const myPlayer = this.game.myPlayer(); - if (myPlayer !== null && target !== undefined) { - if (myPlayer === target) { - rel = Relationship.Self; - } else if (myPlayer.isFriendly(target)) { - rel = Relationship.Ally; - } - } - } - switch (rel) { - case Relationship.Self: - alternateViewColor = this.theme.selfColor(); - break; - case Relationship.Ally: - alternateViewColor = this.theme.allyColor(); - break; - case Relationship.Enemy: - alternateViewColor = this.theme.enemyColor(); - break; - } - } - - const sprite = getColoredSprite( - unit, - this.theme, - alternateViewColor ?? customTerritoryColor, - alternateViewColor ?? undefined, - ); - - if (unit.isActive()) { - const targetable = unit.targetable(); - if (!targetable) { - this.context.save(); - this.context.globalAlpha = 0.5; - } - this.context.drawImage( - sprite, - Math.round(x - sprite.width / 2), - Math.round(y - sprite.height / 2), - sprite.width, - sprite.width, - ); - if (!targetable) { - this.context.restore(); - } - } - } -} diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 44bc83a85..a84f17b6a 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -215,6 +215,9 @@ export class UnitView { isLoaded(): boolean | undefined { return this.data.loaded; } + missileTimerQueue(): number[] { + return this.data.missileTimerQueue; + } } export class PlayerView { diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts index ab1300ab1..7ada98699 100644 --- a/tests/client/graphics/UILayer.test.ts +++ b/tests/client/graphics/UILayer.test.ts @@ -1,6 +1,5 @@ import { UILayer } from "../../../src/client/graphics/layers/UILayer"; import { UnitSelectionEvent } from "../../../src/client/InputHandler"; -import { UnitView } from "../../../src/core/game/GameView"; describe("UILayer", () => { let game: any; @@ -51,108 +50,4 @@ describe("UILayer", () => { ui["onUnitSelection"](event as UnitSelectionEvent); expect(ui.drawSelectionBox).toHaveBeenCalledWith(unit); }); - - it("should add and clear health bars", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 1, - type: () => "Warship", - health: () => 5, - tile: () => ({}), - owner: () => ({}), - isActive: () => true, - createdAt: () => 1, - } as unknown as UnitView; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(true); - - // a full hp unit doesn't have a health bar - unit.health = () => 10; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(false); - - // a dead unit doesn't have a health bar - unit.health = () => 5; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(true); - unit.health = () => 0; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(false); - }); - - it("should remove health bars for inactive units", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 1, - type: () => "Warship", - health: () => 5, - tile: () => ({}), - owner: () => ({}), - isActive: () => true, - } as unknown as UnitView; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(true); - - // an inactive unit doesn't have a health bar - unit.isActive = () => false; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(false); - }); - - it("should add loading bar for unit", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 2, - tile: () => ({}), - isActive: () => true, - } as unknown as UnitView; - ui.createLoadingBar(unit); - expect(ui["allProgressBars"].has(2)).toBe(true); - }); - - it("should remove loading bar for inactive unit", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 2, - type: () => "City", - isUnderConstruction: () => true, - owner: () => ({ id: () => 1 }), - tile: () => ({}), - isActive: () => true, - } as unknown as UnitView; - ui.onUnitEvent(unit); - expect(ui["allProgressBars"].has(2)).toBe(true); - - // an inactive unit should not have a loading bar - unit.isActive = () => false; - ui.tick(); - expect(ui["allProgressBars"].has(2)).toBe(false); - }); - - it("should remove loading bar for a finished progress bar", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 2, - type: () => "City", - isUnderConstruction: () => true, - owner: () => ({ id: () => 1 }), - tile: () => ({}), - isActive: () => true, - createdAt: () => 1, - markedForDeletion: () => false, - } as unknown as UnitView; - ui.onUnitEvent(unit); - expect(ui["allProgressBars"].has(2)).toBe(true); - - game.ticks = () => 6; // simulate enough ticks for completion - // simulate construction finished - (unit as any).isUnderConstruction = () => false; - ui.tick(); - expect(ui["allProgressBars"].has(2)).toBe(false); - }); });