import * as PIXI from "pixi.js"; import { assetUrl } from "../../../core/AssetUrls"; import { Theme } from "../../../core/configuration/Config"; 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 = 0; 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; } }