migrate away from canvas

This commit is contained in:
evanpelle
2026-05-16 08:55:02 -07:00
parent 9c4ba757c2
commit 53cf2d43f8
21 changed files with 364 additions and 4951 deletions
+7
View File
@@ -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",
+1
View File
@@ -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",
+75
View File
@@ -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
+247
View File
@@ -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<UnitType> = new Set<UnitType>([
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<number>();
private readonly railroadCache: RailroadCache;
private readonly trailManager: TrailManager;
private readonly unitMap = new Map<number, UnitState>();
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;
}
}
+4 -29
View File
@@ -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<Layer, { lastTickAtMs: number }>();
private renderFramesSinceLastTick: number = 0;
private renderLayerDurationsSinceLastTick: Record<string, number> = {};
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,
+2 -2
View File
@@ -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;
@@ -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();
}
}
-379
View File
@@ -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);
}
}
}
}
@@ -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();
}
}
-501
View File
@@ -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<TileRef, RailRef>();
private railroads = new Map<number, RailroadView>();
// Railroads under construction
private pendingRailroads = new Set<number>();
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
private railTileIndex = new Map<TileRef, number>();
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();
}
}
@@ -1,161 +0,0 @@
import { RailType } from "./RailroadView";
const railTypeToFunctionMap: Record<RailType, () => 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<RailType, () => 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;
}
-176
View File
@@ -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;
}
}
@@ -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<number, SamInfo> = 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();
}
}
+22 -435
View File
@@ -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<number, StructureRenderInfo> =
new Map();
private readonly seenUnitIds: Set<number> = new Set();
private readonly connectedAllySmallIds: Set<number> = 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;
}
}
}
@@ -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<string, HTMLImageElement> = new Map();
private theme: Theme;
private tempCanvas: HTMLCanvasElement;
private tempContext: CanvasRenderingContext2D;
// Configuration for supported unit types only
private readonly unitConfigs: Partial<Record<UnitType, UnitRenderConfig>> = {
[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);
}
}
-108
View File
@@ -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(),
);
}
}
@@ -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();
}
}
+3 -189
View File
@@ -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<number, ProgressBar> = 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);
-768
View File
@@ -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<UnitView, TileRef[]>();
private pendingTrailClears: UnitView[] = [];
private theme: Theme;
private alternateView = false;
private oldShellTile = new Map<UnitView, TileRef>();
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<number>(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<TileRef>();
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();
}
}
}
}
+3
View File
@@ -215,6 +215,9 @@ export class UnitView {
isLoaded(): boolean | undefined {
return this.data.loaded;
}
missileTimerQueue(): number[] {
return this.data.missileTimerQueue;
}
}
export class PlayerView {
-105
View File
@@ -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);
});
});