mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-03 18:30:36 +00:00
950 lines
30 KiB
TypeScript
950 lines
30 KiB
TypeScript
import { extend } from "colord";
|
|
import a11yPlugin from "colord/plugins/a11y";
|
|
import { OutlineFilter } from "pixi-filters";
|
|
import * as PIXI from "pixi.js";
|
|
import { assetUrl } from "../../../core/AssetUrls";
|
|
import { Theme } from "../../../core/configuration/Config";
|
|
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 {
|
|
ConfirmGhostStructureEvent,
|
|
GhostStructureChangedEvent,
|
|
MouseMoveEvent,
|
|
MouseUpEvent,
|
|
ToggleStructureEvent as ToggleStructuresEvent,
|
|
} from "../../InputHandler";
|
|
import {
|
|
BuildUnitIntentEvent,
|
|
SendUpgradeStructureIntentEvent,
|
|
} from "../../Transport";
|
|
import { renderNumber } from "../../Utils";
|
|
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");
|
|
|
|
/** True for nuke types (AtomBomb, HydrogenBomb): ghost is preserved after placement so user can place multiple or keep selection (Enter/key confirm). */
|
|
export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean {
|
|
return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb;
|
|
}
|
|
|
|
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;
|
|
priceText: PIXI.BitmapText;
|
|
priceBg: PIXI.Graphics;
|
|
priceGroup: PIXI.Container;
|
|
priceBox: { height: number; y: number; paddingX: number; minWidth: number };
|
|
range: PIXI.Container | null;
|
|
rangeLevel?: number;
|
|
targetingAlly?: boolean;
|
|
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 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,
|
|
private eventBus: EventBus,
|
|
public uiState: UIState,
|
|
private transformHandler: TransformHandler,
|
|
) {
|
|
this.theme = game.config().theme();
|
|
this.factory = new SpriteFactory(
|
|
this.theme,
|
|
game,
|
|
transformHandler,
|
|
this.renderSprites,
|
|
);
|
|
}
|
|
|
|
async setupRenderer() {
|
|
if (this.renderer) {
|
|
this.renderer.destroy(true);
|
|
this.rootStage.removeChildren();
|
|
}
|
|
|
|
try {
|
|
await PIXI.Assets.load(bitmapFont);
|
|
} catch (error) {
|
|
console.error("Failed to load bitmap font:", error);
|
|
}
|
|
|
|
this.pixicanvas = document.createElement("canvas");
|
|
const resolution = window.devicePixelRatio || 1;
|
|
this.resizePixiCanvasElement(resolution);
|
|
|
|
// 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,
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
antialias: false,
|
|
clearBeforeRender: true,
|
|
backgroundAlpha: 0,
|
|
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);
|
|
|
|
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.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;
|
|
|
|
if (this.renderer.name === "webgpu") {
|
|
// Listen to device loss as PixiJS doesn't handle WebGPU context loss itself
|
|
const gpuRenderer = this.renderer as PIXI.WebGPURenderer;
|
|
gpuRenderer.gpu.device.lost.then(() => {
|
|
this.redraw();
|
|
});
|
|
}
|
|
|
|
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();
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
this.rebuildPending = true;
|
|
|
|
try {
|
|
if (this.renderer?.name === "webgpu") {
|
|
this.rendererInitialized = false;
|
|
await this.setupRenderer();
|
|
}
|
|
this.resizeCanvas();
|
|
this.rebuildAllIcons();
|
|
} 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(
|
|
new MouseUpEvent(this.mousePos.x, this.mousePos.y),
|
|
),
|
|
);
|
|
|
|
window.addEventListener("resize", () => this.resizeCanvas());
|
|
await this.setupRenderer();
|
|
this.resizeCanvas();
|
|
}
|
|
|
|
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;
|
|
}
|
|
const resolution = window.devicePixelRatio || 1;
|
|
this.resizePixiCanvasElement(resolution);
|
|
this.renderer?.resize(window.innerWidth, window.innerHeight, resolution);
|
|
}
|
|
|
|
private resizePixiCanvasElement(resolution: number) {
|
|
this.pixicanvas.width = Math.ceil(window.innerWidth * resolution);
|
|
this.pixicanvas.height = Math.ceil(window.innerHeight * resolution);
|
|
this.pixicanvas.style.width = `${window.innerWidth}px`;
|
|
this.pixicanvas.style.height = `${window.innerHeight}px`;
|
|
}
|
|
|
|
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.ghostUnit) {
|
|
if (this.uiState.ghostStructure === null) {
|
|
this.removeGhostStructure();
|
|
} else if (
|
|
this.uiState.ghostStructure !== this.ghostUnit.buildableUnit.type
|
|
) {
|
|
this.clearGhostStructure();
|
|
}
|
|
} else if (this.uiState.ghostStructure !== null) {
|
|
this.createGhostStructure(this.uiState.ghostStructure);
|
|
}
|
|
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,
|
|
this.renderer.canvas.width,
|
|
this.renderer.canvas.height,
|
|
0,
|
|
0,
|
|
mainContext.canvas.width,
|
|
mainContext.canvas.height,
|
|
);
|
|
}
|
|
}
|
|
|
|
renderGhost() {
|
|
if (!this.ghostUnit) return;
|
|
|
|
const now = performance.now();
|
|
if (now - this.lastGhostQueryAt < 50) {
|
|
return;
|
|
}
|
|
this.lastGhostQueryAt = now;
|
|
let tileRef: TileRef | undefined;
|
|
const tile = this.transformHandler.screenToWorldCoordinates(
|
|
this.mousePos.x,
|
|
this.mousePos.y,
|
|
);
|
|
if (this.game.isValidCoord(tile.x, tile.y)) {
|
|
tileRef = this.game.ref(tile.x, tile.y);
|
|
}
|
|
|
|
// 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;
|
|
if (
|
|
tileRef &&
|
|
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++) {
|
|
const ally = allies[i];
|
|
if (!ally.isDisconnected()) {
|
|
this.connectedAllySmallIds.add(ally.smallID());
|
|
}
|
|
}
|
|
|
|
if (this.connectedAllySmallIds.size > 0) {
|
|
targetingAlly = wouldNukeBreakAlliance({
|
|
game: this.game,
|
|
targetTile: tileRef,
|
|
magnitude: this.game.config().nukeMagnitudes(nukeType),
|
|
allySmallIds: this.connectedAllySmallIds,
|
|
threshold: this.game.config().nukeAllianceBreakThreshold(),
|
|
});
|
|
}
|
|
}
|
|
|
|
this.game
|
|
?.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 = [];
|
|
}
|
|
|
|
if (!this.ghostUnit) {
|
|
this.pendingConfirm = null;
|
|
return;
|
|
}
|
|
|
|
const unit = buildables.find(
|
|
(u) => u.type === this.ghostUnit!.buildableUnit.type,
|
|
);
|
|
const showPrice = this.game.config().userSettings().cursorCostLabel();
|
|
if (!unit) {
|
|
Object.assign(this.ghostUnit.buildableUnit, {
|
|
canBuild: false,
|
|
canUpgrade: false,
|
|
});
|
|
this.updateGhostPrice(0, showPrice);
|
|
this.ghostUnit.container.filters = this.filterRedArray;
|
|
this.pendingConfirm = null;
|
|
return;
|
|
}
|
|
|
|
this.ghostUnit.buildableUnit = unit;
|
|
this.updateGhostPrice(unit.cost ?? 0, showPrice);
|
|
|
|
const targetLevel = this.resolveGhostRangeLevel(unit);
|
|
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 = [];
|
|
} else if (unit.canBuild === false) {
|
|
this.ghostUnit.container.filters = this.filterRedArray;
|
|
this.uiState.overlappingRailroads = [];
|
|
this.uiState.ghostRailPaths = [];
|
|
} else {
|
|
this.uiState.overlappingRailroads = unit.overlappingRailroads;
|
|
this.uiState.ghostRailPaths = unit.ghostRailPaths;
|
|
}
|
|
|
|
const scale = this.transformHandler.scale;
|
|
const s =
|
|
scale >= ZOOM_THRESHOLD
|
|
? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN)
|
|
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
|
|
this.ghostUnit.container.scale.set(s);
|
|
this.ghostUnit.range?.scale.set(this.transformHandler.scale);
|
|
|
|
if (this.pendingConfirm !== null) {
|
|
const ev = this.pendingConfirm;
|
|
this.pendingConfirm = null;
|
|
if (this.isGhostReadyForConfirm()) {
|
|
this.createStructure(ev);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateGhostPrice(cost: bigint | number, showPrice: boolean) {
|
|
if (!this.ghostUnit) return;
|
|
const { priceText, priceBg, priceBox, priceGroup } = this.ghostUnit;
|
|
priceGroup.visible = showPrice;
|
|
if (!showPrice) return;
|
|
|
|
priceText.text = renderNumber(cost);
|
|
priceText.position.set(0, priceBox.y);
|
|
|
|
const textWidth = priceText.width;
|
|
const boxWidth = Math.max(
|
|
priceBox.minWidth,
|
|
textWidth + priceBox.paddingX * 2,
|
|
);
|
|
|
|
priceBg.clear();
|
|
priceBg
|
|
.roundRect(
|
|
-boxWidth / 2,
|
|
priceBox.y - priceBox.height / 2,
|
|
boxWidth,
|
|
priceBox.height,
|
|
4,
|
|
)
|
|
.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()) {
|
|
this.createStructure(e);
|
|
} else {
|
|
this.pendingConfirm = e;
|
|
}
|
|
}
|
|
|
|
private createStructure(e: MouseUpEvent) {
|
|
if (!this.ghostUnit) return;
|
|
if (
|
|
this.ghostUnit.buildableUnit.canBuild === false &&
|
|
this.ghostUnit.buildableUnit.canUpgrade === false
|
|
) {
|
|
this.removeGhostStructure();
|
|
return;
|
|
}
|
|
const tile = this.transformHandler.screenToWorldCoordinates(e.x, e.y);
|
|
if (this.ghostUnit.buildableUnit.canUpgrade !== false) {
|
|
this.eventBus.emit(
|
|
new SendUpgradeStructureIntentEvent(
|
|
this.ghostUnit.buildableUnit.canUpgrade,
|
|
this.ghostUnit.buildableUnit.type,
|
|
),
|
|
);
|
|
this.removeGhostStructure();
|
|
} else if (this.ghostUnit.buildableUnit.canBuild) {
|
|
const unitType = this.ghostUnit.buildableUnit.type;
|
|
const rocketDirectionUp =
|
|
unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb
|
|
? this.uiState.rocketDirectionUp
|
|
: undefined;
|
|
this.eventBus.emit(
|
|
new BuildUnitIntentEvent(
|
|
unitType,
|
|
this.game.ref(tile.x, tile.y),
|
|
rocketDirectionUp,
|
|
),
|
|
);
|
|
if (!shouldPreserveGhostAfterBuild(unitType)) {
|
|
this.removeGhostStructure();
|
|
}
|
|
} else {
|
|
this.removeGhostStructure();
|
|
}
|
|
}
|
|
|
|
private moveGhost(e: MouseMoveEvent) {
|
|
this.mousePos.x = e.x;
|
|
this.mousePos.y = e.y;
|
|
|
|
if (!this.ghostUnit) return;
|
|
const local = this.transformHandler.screenToCanvasCoordinates(e.x, e.y);
|
|
this.ghostUnit.container.position.set(local.x, local.y);
|
|
this.ghostUnit.range?.position.set(local.x, local.y);
|
|
}
|
|
|
|
private createGhostStructure(type: PlayerBuildableUnitType | null) {
|
|
const player = this.game.myPlayer();
|
|
if (!player) return;
|
|
if (type === null) {
|
|
return;
|
|
}
|
|
const local = this.transformHandler.screenToCanvasCoordinates(
|
|
this.mousePos.x,
|
|
this.mousePos.y,
|
|
);
|
|
const ghost = this.factory.createGhostContainer(
|
|
player,
|
|
this.ghostStage,
|
|
{ x: local.x, y: local.y },
|
|
type,
|
|
);
|
|
this.ghostUnit = {
|
|
container: ghost.container,
|
|
priceText: ghost.priceText,
|
|
priceBg: ghost.priceBg,
|
|
priceGroup: ghost.priceGroup,
|
|
priceBox: ghost.priceBox,
|
|
range: null,
|
|
buildableUnit: {
|
|
type,
|
|
canBuild: false,
|
|
canUpgrade: false,
|
|
cost: 0n,
|
|
overlappingRailroads: [],
|
|
ghostRailPaths: [],
|
|
},
|
|
};
|
|
const showPrice = this.game.config().userSettings().cursorCostLabel();
|
|
this.updateGhostPrice(0, showPrice);
|
|
const baseLevel = this.resolveGhostRangeLevel(this.ghostUnit.buildableUnit);
|
|
this.updateGhostRange(baseLevel);
|
|
}
|
|
|
|
private clearGhostStructure() {
|
|
this.pendingConfirm = null;
|
|
if (this.ghostUnit) {
|
|
this.ghostUnit.container.destroy({ children: true });
|
|
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 = [];
|
|
}
|
|
|
|
private removeGhostStructure() {
|
|
this.clearGhostStructure();
|
|
this.uiState.ghostStructure = null;
|
|
this.eventBus.emit(new GhostStructureChangedEvent(null));
|
|
}
|
|
|
|
private resolveGhostRangeLevel(
|
|
buildableUnit: BuildableUnit,
|
|
): number | undefined {
|
|
if (buildableUnit.type !== UnitType.SAMLauncher) {
|
|
return undefined;
|
|
}
|
|
if (buildableUnit.canUpgrade !== false) {
|
|
const existing = this.game.unit(buildableUnit.canUpgrade);
|
|
if (existing) {
|
|
return existing.level() + 1;
|
|
} else {
|
|
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.range &&
|
|
this.ghostUnit.rangeLevel === level &&
|
|
this.ghostUnit.targetingAlly === targetingAlly
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.ghostUnit.range?.destroy({ children: true });
|
|
this.ghostUnit.range = null;
|
|
this.ghostUnit.rangeLevel = level;
|
|
this.ghostUnit.targetingAlly = targetingAlly;
|
|
|
|
const position = this.ghostUnit.container.position;
|
|
const range = this.factory.createRange(
|
|
this.ghostUnit.buildableUnit.type,
|
|
this.ghostStage,
|
|
{ x: position.x, y: position.y },
|
|
level,
|
|
targetingAlly,
|
|
);
|
|
if (range) {
|
|
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;
|
|
}
|
|
}
|
|
}
|