mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
migrate away from canvas
This commit is contained in:
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,9 @@ export class UnitView {
|
||||
isLoaded(): boolean | undefined {
|
||||
return this.data.loaded;
|
||||
}
|
||||
missileTimerQueue(): number[] {
|
||||
return this.data.missileTimerQueue;
|
||||
}
|
||||
}
|
||||
|
||||
export class PlayerView {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user