diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 505abf861..26b61103e 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -294,7 +294,7 @@ function createWebGLView(terrainMap: TerrainMapData): { return { view, glCanvas, cachedWebGLFrameCallback }; } -function mountWebGLDebugRenderer( +function mountWebGLFrameLoop( terrainMap: TerrainMapData, view: WebGLGameView, glCanvas: HTMLCanvasElement, @@ -307,13 +307,6 @@ function mountWebGLDebugRenderer( const mapWidth = gameMap.width(); const mapHeight = gameMap.height(); - window.addEventListener("keydown", (e) => { - if (e.key === "\\") { - glCanvas.style.display = - glCanvas.style.display === "none" ? "block" : "none"; - } - }); - // Cache canvas dimensions to avoid forced reflows every frame. Reading // clientWidth/clientHeight flushes pending layout — at 60fps that's a // measurable cost. Only update on resize events from the observer. @@ -420,7 +413,7 @@ async function createClientGame( // Transparent fullscreen overlay used purely as the pointer-event / // bounding-rect target for InputHandler + TransformHandler. The actual - // map drawing happens on the WebGL canvas created in mountWebGLDebugRenderer. + // map drawing happens on the WebGL canvas created in createWebGLView. const inputOverlay = document.createElement("div"); inputOverlay.id = "game-input-overlay"; inputOverlay.style.position = "fixed"; @@ -444,7 +437,7 @@ async function createClientGame( view, ); - const { builder: webglBuilder } = mountWebGLDebugRenderer( + const { builder: webglBuilder } = mountWebGLFrameLoop( gameMap, view, glCanvas, diff --git a/src/client/graphics/ProgressBar.ts b/src/client/graphics/ProgressBar.ts deleted file mode 100644 index 52500bb87..000000000 --- a/src/client/graphics/ProgressBar.ts +++ /dev/null @@ -1,61 +0,0 @@ -export class ProgressBar { - private static readonly CLEAR_PADDING = 2; - constructor( - private colors: string[] = [], - private ctx: CanvasRenderingContext2D, - private x: number, - private y: number, - private w: number, - private h: number, - private progress: number = 0, // Progress from 0 to 1 - ) { - this.setProgress(progress); - } - - setProgress(progress: number): void { - progress = Math.max(0, Math.min(1, progress)); - this.clear(); - // Draw the loading bar background - this.ctx.fillStyle = "rgba(0, 0, 0, 1)"; - this.ctx.fillRect(this.x - 1, this.y - 1, this.w, this.h); - - // Draw the loading progress - if (this.colors.length === 0) { - this.ctx.fillStyle = "#808080"; // default gray - } else { - const idx = Math.min( - this.colors.length - 1, - Math.floor(progress * this.colors.length), - ); - this.ctx.fillStyle = this.colors[idx]; - } - this.ctx.fillRect( - this.x, - this.y, - Math.max(1, Math.floor(progress * (this.w - 2))), - this.h - 2, - ); - this.progress = progress; - } - - clear() { - this.ctx.clearRect( - this.x - ProgressBar.CLEAR_PADDING, - this.y - ProgressBar.CLEAR_PADDING, - this.w + ProgressBar.CLEAR_PADDING, - this.h + ProgressBar.CLEAR_PADDING, - ); - } - - getX(): number { - return this.x; - } - - getY(): number { - return this.y; - } - - getProgress(): number { - return this.progress; - } -} diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts deleted file mode 100644 index 6ea5ad33a..000000000 --- a/src/client/graphics/layers/StructureDrawingUtils.ts +++ /dev/null @@ -1,549 +0,0 @@ -import * as PIXI from "pixi.js"; -import { Theme } from "src/core/configuration/Theme"; -import { assetUrl } from "../../../core/AssetUrls"; -import { - Cell, - PlayerBuildableUnitType, - UnitType, -} from "../../../core/game/Game"; -import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; -import { TransformHandler } from "../../TransformHandler"; -const anchorIcon = assetUrl("images/AnchorIcon.v1.png"); -const cityIcon = assetUrl("images/CityIcon.v1.png"); -const factoryIcon = assetUrl("images/FactoryUnit.v1.png"); -const missileSiloIcon = assetUrl("images/MissileSiloUnit.v1.png"); -const SAMMissileIcon = assetUrl("images/SamLauncherUnit.v1.png"); -const shieldIcon = assetUrl("images/ShieldIcon.v1.png"); - -export const STRUCTURE_SHAPES: Partial> = { - [UnitType.City]: "circle", - [UnitType.Port]: "pentagon", - [UnitType.Factory]: "circle", - [UnitType.DefensePost]: "octagon", - [UnitType.SAMLauncher]: "square", - [UnitType.MissileSilo]: "triangle", - [UnitType.Warship]: "cross", - [UnitType.AtomBomb]: "cross", - [UnitType.HydrogenBomb]: "cross", - [UnitType.MIRV]: "cross", -}; -export const LEVEL_SCALE_FACTOR = 3; -export const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5; -export const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4; -export const DOTS_ZOOM_THRESHOLD = 0.5; -export const ZOOM_THRESHOLD = 4.3; -export const ICON_SIZE = { - circle: 28, - octagon: 28, - pentagon: 30, - square: 28, - triangle: 28, - cross: 20, -}; -export const OFFSET_ZOOM_Y = 4; - -export type ShapeType = - | "triangle" - | "square" - | "pentagon" - | "octagon" - | "circle" - | "cross"; - -export class SpriteFactory { - private theme: Theme; - private game: GameView; - private transformHandler: TransformHandler; - private renderSprites: boolean; - private readonly textureCache: Map = new Map(); - private colorCanvas: HTMLCanvasElement | null = null; - private colorCtx: CanvasRenderingContext2D | null = null; - - private readonly structuresInfos: Map< - UnitType, - { iconPath: string; image: HTMLImageElement | null } - > = new Map([ - [UnitType.City, { iconPath: cityIcon, image: null }], - [UnitType.Factory, { iconPath: factoryIcon, image: null }], - [UnitType.DefensePost, { iconPath: shieldIcon, image: null }], - [UnitType.Port, { iconPath: anchorIcon, image: null }], - [UnitType.MissileSilo, { iconPath: missileSiloIcon, image: null }], - [UnitType.SAMLauncher, { iconPath: SAMMissileIcon, image: null }], - ]); - constructor( - theme: Theme, - game: GameView, - transformHandler: TransformHandler, - renderSprites: boolean, - ) { - this.theme = theme; - this.game = game; - this.transformHandler = transformHandler; - this.renderSprites = renderSprites; - this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType)); - } - - public clearCache() { - for (const texture of this.textureCache.values()) { - if (texture && texture !== PIXI.Texture.EMPTY) { - try { - texture.destroy(true); - } catch (e) { - console.error("Error clearing texture cache:", e); - } - } - } - this.textureCache.clear(); - this.colorCanvas = null; - this.colorCtx = null; - } - - private loadIcon( - unitInfo: { - iconPath: string; - image: HTMLImageElement | null; - }, - unitType: UnitType, - ) { - 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 structureCanvas - // and PIXI.Texture.from rejects the upload to WebGL. - image.crossOrigin = "anonymous"; - image.src = unitInfo.iconPath; - image.onload = () => { - unitInfo.image = image; - this.invalidateTextureCache(unitType); - }; - image.onerror = () => { - console.error( - `Failed to load icon for ${unitType}: ${unitInfo.iconPath}`, - ); - }; - } - - private invalidateTextureCache(unitType: UnitType) { - for (const key of Array.from(this.textureCache.keys())) { - if (key.includes(`-${unitType}`)) { - const tex = this.textureCache.get(key); - if (tex && tex !== PIXI.Texture.EMPTY) { - tex.destroy(true); - } - this.textureCache.delete(key); - } - } - } - - createGhostContainer( - player: PlayerView, - ghostStage: PIXI.Container, - pos: { x: number; y: number }, - structureType: PlayerBuildableUnitType, - ): { - container: PIXI.Container; - priceText: PIXI.BitmapText; - priceBg: PIXI.Graphics; - priceGroup: PIXI.Container; - priceBox: { height: number; y: number; paddingX: number; minWidth: number }; - } { - const parentContainer = new PIXI.Container(); - const texture = this.createTexture( - structureType, - player, - false, - false, - true, - ); - const sprite = new PIXI.Sprite(texture); - sprite.anchor.set(0.5); - sprite.alpha = 0.5; - parentContainer.addChild(sprite); - - const priceText = new PIXI.BitmapText({ - text: "125K", - style: { fontFamily: "round_6x6_modified", fontSize: 12 }, - }); - priceText.anchor.set(0.5); - const priceGroup = new PIXI.Container(); - const boxHeight = 18; - const boxY = - (sprite.height > 0 ? sprite.height / 2 : 16) + boxHeight / 2 + 4; - - // a way to resize the pill horizontally based on the text width - const paddingX = 8; - const minWidth = 32; - const textWidth = priceText.width; - const boxWidth = Math.max(minWidth, textWidth + paddingX * 2); - - const priceBg = new PIXI.Graphics(); - priceBg - .roundRect(-boxWidth / 2, boxY - boxHeight / 2, boxWidth, boxHeight, 4) - .fill({ color: 0x000000, alpha: 0.65 }); - - priceText.position.set(0, boxY); - - priceGroup.addChild(priceBg); - priceGroup.addChild(priceText); - parentContainer.addChild(priceGroup); - - parentContainer.position.set(pos.x, pos.y); - parentContainer.scale.set( - Math.min(1, this.transformHandler.scale / ICON_SCALE_FACTOR_ZOOMED_OUT), - ); - ghostStage.addChild(parentContainer); - return { - container: parentContainer, - priceText, - priceBg, - priceGroup, - priceBox: { height: boxHeight, y: boxY, paddingX, minWidth }, - }; - } - - // --- internal helpers --- - - public createUnitContainer( - unit: UnitView, - options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container }, - ): PIXI.Container { - const parentContainer = new PIXI.Container(); - const tile = unit.tile(); - const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); - const screenPos = this.transformHandler.worldToCanvasCoordinates(worldPos); - - const isMarkedForDeletion = unit.markedForDeletion() !== false; - const isConstruction = unit.isUnderConstruction(); - const structureType = unit.type(); - const { type, stage } = options; - const { scale } = this.transformHandler; - - this.renderSprites = - this.game.config().userSettings()?.structureSprites() ?? true; - - if (type === "icon" || type === "dot") { - const texture = this.createTexture( - structureType, - unit.owner(), - isConstruction, - isMarkedForDeletion, - type === "icon", - ); - const sprite = new PIXI.Sprite(texture); - sprite.anchor.set(0.5); - parentContainer.addChild(sprite); - } - - if ((type === "icon" || type === "level") && unit.level() > 1) { - const text = new PIXI.BitmapText({ - text: unit.level().toString(), - style: { fontFamily: "round_6x6_modified", fontSize: 14 }, - }); - text.anchor.set(0.5); - - const shape = STRUCTURE_SHAPES[structureType]; - if (shape !== undefined) { - text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2); - } - parentContainer.addChild(text); - } - - const posX = Math.round(screenPos.x); - let posY = Math.round(screenPos.y); - if (type === "level" && scale >= ZOOM_THRESHOLD && this.renderSprites) { - posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y); - } - parentContainer.position.set(posX, posY); - - if (type === "icon") { - const s = - scale >= ZOOM_THRESHOLD && !this.renderSprites - ? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN) - : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); - parentContainer.scale.set(s); - } else if (type === "level") { - parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR)); - } - - stage.addChild(parentContainer); - return parentContainer; - } - - private createTexture( - type: UnitType, - owner: PlayerView, - isConstruction: boolean, - isMarkedForDeletion: boolean, - renderIcon: boolean, - ): PIXI.Texture { - const cacheKeyBase = isConstruction - ? `construction-${type}` - : `${this.theme.territoryColor(owner).toRgbString()}-${type}`; - const cacheKey = - cacheKeyBase + - (renderIcon ? "-icon" : "") + - (isMarkedForDeletion ? "-deleted" : ""); - - if (this.textureCache.has(cacheKey)) { - return this.textureCache.get(cacheKey)!; - } - const shape = STRUCTURE_SHAPES[type]; - const texture = shape - ? this.createIcon( - owner, - type, - isConstruction, - isMarkedForDeletion, - shape, - renderIcon, - ) - : PIXI.Texture.EMPTY; - this.textureCache.set(cacheKey, texture); - return texture; - } - - private createIcon( - owner: PlayerView, - structureType: UnitType, - isConstruction: boolean, - isMarkedForDeletion: boolean, - shape: keyof typeof ICON_SIZE, - renderIcon: boolean, - ): PIXI.Texture { - const structureCanvas = document.createElement("canvas"); - let iconSize = ICON_SIZE[shape]; - if (!renderIcon) { - iconSize /= 2.5; - } - structureCanvas.width = Math.ceil(iconSize); - structureCanvas.height = Math.ceil(iconSize); - const context = structureCanvas.getContext("2d")!; - - // Use structureColors defined from the PlayerView. - context.fillStyle = isConstruction - ? "rgb(198,198,198)" - : owner.structureColors().light.toRgbString(); - context.strokeStyle = isConstruction - ? "rgb(127,127, 127)" - : owner.structureColors().dark.toRgbString(); - context.lineWidth = 1; - const halfIconSize = iconSize / 2; - - switch (shape) { - case "triangle": - context.beginPath(); - context.moveTo(halfIconSize, 1); // Top - context.lineTo(iconSize - 1, iconSize - 1); // Bottom right - context.lineTo(0, iconSize - 1); // Bottom left - context.closePath(); - context.fill(); - context.stroke(); - break; - - case "square": - context.fillRect(1, 1, iconSize - 2, iconSize - 2); - context.strokeRect(1, 1, iconSize - 3, iconSize - 3); - break; - - case "octagon": - { - const cx = halfIconSize; - const cy = halfIconSize; - const r = halfIconSize - 1; - const step = (Math.PI * 2) / 8; - - context.beginPath(); - for (let i = 0; i < 8; i++) { - const angle = step * i - Math.PI / 8; // slight rotation for flat top - const x = cx + r * Math.cos(angle); - const y = cy + r * Math.sin(angle); - if (i === 0) { - context.moveTo(x, y); - } else { - context.lineTo(x, y); - } - } - context.closePath(); - context.fill(); - context.stroke(); - } - break; - case "pentagon": - { - const cx = halfIconSize; - const cy = halfIconSize; - const r = halfIconSize - 1; - const step = (Math.PI * 2) / 5; - - context.beginPath(); - for (let i = 0; i < 5; i++) { - const angle = step * i - Math.PI / 2; // rotate to have flat base or point up - const x = cx + r * Math.cos(angle); - const y = cy + r * Math.sin(angle); - if (i === 0) { - context.moveTo(x, y); - } else { - context.lineTo(x, y); - } - } - context.closePath(); - context.fill(); - context.stroke(); - } - break; - case "cross": { - context.strokeStyle = "rgba(0, 0, 0, 1)"; - context.fillStyle = "rgba(0, 0, 0, 1)"; - - const gap = iconSize * 0.18; // gap at center - const lineLen = iconSize / 2; - context.save(); - context.translate(halfIconSize, halfIconSize); - // Up - context.beginPath(); - context.moveTo(0, -gap); - context.lineTo(0, -lineLen); - context.stroke(); - // Down - context.beginPath(); - context.moveTo(0, gap); - context.lineTo(0, lineLen); - context.stroke(); - // Left - context.beginPath(); - context.moveTo(-gap, 0); - context.lineTo(-lineLen, 0); - context.stroke(); - // Right - context.beginPath(); - context.moveTo(gap, 0); - context.lineTo(lineLen, 0); - context.stroke(); - context.restore(); - break; - } - - case "circle": - context.beginPath(); - context.arc( - halfIconSize, - halfIconSize, - halfIconSize - 1, - 0, - Math.PI * 2, - ); - context.fill(); - context.stroke(); - break; - - default: - throw new Error(`Unknown shape: ${shape}`); - } - - const structureInfo = this.structuresInfos.get(structureType); - - if (structureInfo?.image && renderIcon) { - const SHAPE_OFFSETS = { - triangle: [6, 11], - square: [5, 5], - octagon: [6, 6], - pentagon: [7, 7], - circle: [6, 6], - cross: [0, 0], - }; - const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0]; - context.drawImage( - this.getImageColored( - structureInfo.image, - owner.structureColors().dark.toRgbString(), - ), - offsetX, - offsetY, - ); - } - - if (isMarkedForDeletion) { - context.save(); - context.strokeStyle = "rgba(255, 64, 64, 0.95)"; - context.lineWidth = Math.max(2, Math.round(iconSize * 0.12)); - context.lineCap = "round"; - const padding = Math.max(2, iconSize * 0.12); - context.beginPath(); - context.moveTo(padding, padding); - context.lineTo(iconSize - padding, iconSize - padding); - context.moveTo(iconSize - padding, padding); - context.lineTo(padding, iconSize - padding); - context.stroke(); - context.restore(); - } - - return PIXI.Texture.from(structureCanvas, true); - } - - public createRange( - type: UnitType, - stage: PIXI.Container, - pos: { x: number; y: number }, - level?: number, - targetingAlly: boolean = false, - ): PIXI.Container | null { - if (stage === undefined) throw new Error("Not initialized"); - const parentContainer = new PIXI.Container(); - const circle = new PIXI.Graphics(); - let radius: number; - switch (type) { - case UnitType.SAMLauncher: - radius = this.game.config().samRange(level ?? 1); - break; - case UnitType.Factory: - radius = this.game.config().trainStationMaxRange(); - break; - case UnitType.DefensePost: - radius = this.game.config().defensePostRange(); - break; - case UnitType.AtomBomb: - radius = this.game.config().nukeMagnitudes(UnitType.AtomBomb).outer; - break; - case UnitType.HydrogenBomb: - radius = this.game.config().nukeMagnitudes(UnitType.HydrogenBomb).outer; - break; - default: - return null; - } - // Add warning colors (red/orange) when targeting an ally to indicate alliance will break - const isNuke = type === UnitType.AtomBomb || type === UnitType.HydrogenBomb; - const fillColor = targetingAlly && isNuke ? 0xff6b35 : 0xffffff; - const fillAlpha = targetingAlly && isNuke ? 0.35 : 0.2; - const strokeColor = targetingAlly && isNuke ? 0xff4444 : 0xffffff; - const strokeAlpha = targetingAlly && isNuke ? 0.8 : 0.5; - const strokeWidth = targetingAlly && isNuke ? 2 : 1; - - circle - .circle(0, 0, radius) - .fill({ color: fillColor, alpha: fillAlpha }) - .stroke({ width: strokeWidth, color: strokeColor, alpha: strokeAlpha }); - parentContainer.addChild(circle); - parentContainer.position.set(pos.x, pos.y); - parentContainer.scale.set(this.transformHandler.scale); - stage.addChild(parentContainer); - return parentContainer; - } - - private getImageColored( - image: HTMLImageElement, - color: string, - ): HTMLCanvasElement { - if (!this.colorCanvas || !this.colorCtx) { - this.colorCanvas = document.createElement("canvas"); - this.colorCtx = this.colorCanvas.getContext("2d")!; - } - const { colorCanvas, colorCtx: ctx } = this; - if (colorCanvas.width !== image.width) colorCanvas.width = image.width; - if (colorCanvas.height !== image.height) colorCanvas.height = image.height; - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = color; - ctx.fillRect(0, 0, colorCanvas.width, colorCanvas.height); - ctx.globalCompositeOperation = "destination-in"; - ctx.drawImage(image, 0, 0); - return colorCanvas; - } -} diff --git a/tests/client/graphics/ProgressBar.test.ts b/tests/client/graphics/ProgressBar.test.ts deleted file mode 100644 index 5fc845fbf..000000000 --- a/tests/client/graphics/ProgressBar.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ProgressBar } from "../../../src/client/graphics/ProgressBar"; - -describe("ProgressBar", () => { - let ctx: CanvasRenderingContext2D; - let canvas: HTMLCanvasElement; - - beforeEach(() => { - canvas = document.createElement("canvas"); - canvas.width = 100; - canvas.height = 20; - ctx = canvas.getContext("2d")!; - }); - - it("should initialize and draw the background", () => { - const spyClearRect = vi.spyOn(ctx, "clearRect"); - const spyFillRect = vi.spyOn(ctx, "fillRect"); - const spyFillStyle = vi.spyOn(ctx, "fillStyle", "set"); - const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 0.5); - expect(spyClearRect).toHaveBeenCalledWith(0, 0, 82, 12); - expect(spyFillRect).toHaveBeenCalledWith(1, 1, 80, 10); - expect(spyFillStyle).toHaveBeenCalledWith("#00ff00"); - expect(bar.getX()).toBe(2); - expect(bar.getY()).toBe(2); - }); - - it("should set progress and draw the progress bar", () => { - const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10); - const spyFillRect = vi.spyOn(ctx, "fillRect"); - bar.setProgress(0.5); - expect(bar.getProgress()).toBe(0.5); - expect(spyFillRect).toHaveBeenCalledWith( - 2, - 2, - Math.floor(0.5 * (80 - 2)), - 8, - ); - expect(ctx.fillStyle).toBe("#00ff00"); - - bar.setProgress(0.1); - expect(ctx.fillStyle).toBe("#ff0000"); - }); - - it("should clamp progress between 0 and 1 on init", () => { - const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, -1); - expect(bar.getProgress()).toBe(0); - const bar2 = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 2); - expect(bar2.getProgress()).toBe(1); - }); - - it("should handle empty colors array gracefully", () => { - const bar = new ProgressBar([], ctx, 2, 2, 80, 10, 0.5); - expect(() => bar.setProgress(0.5)).not.toThrow(); - expect(ctx.fillStyle).toBe("#808080"); - }); -});