initial test

This commit is contained in:
Ryan Barlow
2026-01-03 19:27:21 +00:00
parent ab5b044362
commit 41e5adf1fe
5 changed files with 308 additions and 75 deletions
+1
View File
@@ -54,6 +54,7 @@ export function createRenderer(
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
lockedGhostTile: null,
} as UIState;
//hide when the game renders
+2
View File
@@ -1,7 +1,9 @@
import { UnitType } from "../../core/game/Game";
import { TileRef } from "../../core/game/GameMap";
export interface UIState {
attackRatio: number;
ghostStructure: UnitType | null;
rocketDirectionUp: boolean;
lockedGhostTile: TileRef | null;
}
@@ -104,30 +104,44 @@ export class NukeTrajectoryPreviewLayer implements Layer {
return;
}
// Convert mouse position to world coordinates
const rect = this.transformHandler.boundingRect();
if (!rect) {
this.trajectoryPoints = [];
this.cachedSpawnTile = null;
return;
let targetTile: TileRef | null = null;
// If ghost is locked, use the locked tile; otherwise use mouse position
if (this.uiState.lockedGhostTile) {
targetTile = this.uiState.lockedGhostTile;
} else {
// Convert mouse position to world coordinates
const rect = this.transformHandler.boundingRect();
if (!rect) {
this.trajectoryPoints = [];
this.cachedSpawnTile = null;
return;
}
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
const worldCoords = this.transformHandler.screenToWorldCoordinates(
localX,
localY,
);
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
this.trajectoryPoints = [];
this.lastTargetTile = null;
this.cachedSpawnTile = null;
return;
}
targetTile = this.game.ref(worldCoords.x, worldCoords.y);
}
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
const worldCoords = this.transformHandler.screenToWorldCoordinates(
localX,
localY,
);
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
if (!targetTile) {
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;
@@ -190,27 +204,39 @@ export class NukeTrajectoryPreviewLayer implements Layer {
return;
}
// Convert mouse position to world coordinates
const rect = this.transformHandler.boundingRect();
if (!rect) {
let targetTile: TileRef | null = null;
// If ghost is locked, use the locked tile; otherwise use mouse position
if (this.uiState.lockedGhostTile) {
targetTile = this.uiState.lockedGhostTile;
} else {
// Convert mouse position to world coordinates
const rect = this.transformHandler.boundingRect();
if (!rect) {
this.trajectoryPoints = [];
return;
}
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
const worldCoords = this.transformHandler.screenToWorldCoordinates(
localX,
localY,
);
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
this.trajectoryPoints = [];
return;
}
targetTile = this.game.ref(worldCoords.x, worldCoords.y);
}
if (!targetTile) {
this.trajectoryPoints = [];
return;
}
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
const worldCoords = this.transformHandler.screenToWorldCoordinates(
localX,
localY,
);
if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) {
this.trajectoryPoints = [];
return;
}
const targetTile = this.game.ref(worldCoords.x, worldCoords.y);
// Calculate trajectory using ParabolaPathFinder with cached spawn tile
const pathFinder = new ParabolaPathFinder(this.game);
const speed = this.game.config().defaultNukeSpeed();
@@ -3,6 +3,7 @@ import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
import { GhostStructureChangedEvent } from "../../InputHandler";
import { renderNumber, translateText } from "../../Utils";
import { UIState } from "../UIState";
import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
@@ -412,7 +413,23 @@ function createMenuElements(
if (buildableUnit === undefined) {
return;
}
if (params.buildMenu.canBuildOrUpgrade(item)) {
const isNukeSelection =
filterType === "attack" &&
(item.unitType === UnitType.AtomBomb ||
item.unitType === UnitType.HydrogenBomb);
const canBuild = params.buildMenu.canBuildOrUpgrade(item);
if (isNukeSelection && canBuild && params.uiState) {
// Reuse ghost placement flow so players see the full nuke trajectory/interception preview
params.uiState.ghostStructure = item.unitType;
params.uiState.lockedGhostTile = params.tile;
params.eventBus.emit(new GhostStructureChangedEvent(item.unitType));
params.closeMenu();
return;
}
if (canBuild) {
params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
}
params.closeMenu();
+228 -41
View File
@@ -19,6 +19,7 @@ import {
GhostStructureChangedEvent,
MouseMoveEvent,
MouseUpEvent,
SwapRocketDirectionEvent,
ToggleStructureEvent as ToggleStructuresEvent,
} from "../../InputHandler";
import {
@@ -84,6 +85,12 @@ export class StructureIconsLayer implements Layer {
private readonly mousePos = { x: 0, y: 0 };
private renderSprites = true;
private factory: SpriteFactory;
private ghostControls: {
container: HTMLDivElement;
confirm: HTMLButtonElement;
cancel: HTMLButtonElement;
flip: HTMLButtonElement;
} | null = null;
private readonly structures: Map<UnitType, { visible: boolean }> = new Map([
[UnitType.City, { visible: true }],
[UnitType.Factory, { visible: true }],
@@ -244,21 +251,44 @@ export class StructureIconsLayer implements Layer {
renderGhost() {
if (!this.ghostUnit) return;
const rect = this.transformHandler.boundingRect();
if (!rect) return;
let localX = this.mousePos.x - rect.left;
let localY = this.mousePos.y - rect.top;
let tileRef: TileRef | undefined;
// Always reposition locked ghost every frame (smooth when panning)
if (
this.uiState.lockedGhostTile &&
this.isLockableGhost(this.ghostUnit.buildableUnit.type)
) {
tileRef = this.uiState.lockedGhostTile;
const screen = this.transformHandler.worldToScreenCoordinates(
new Cell(this.game.x(tileRef), this.game.y(tileRef)),
);
localX = screen.x - rect.left;
localY = screen.y - rect.top;
this.ghostUnit.container.position.set(localX, localY);
this.ghostUnit.range?.position.set(localX, localY);
this.updateGhostControls(localX, localY, rect);
} else {
this.hideGhostControls();
const tile = this.transformHandler.screenToWorldCoordinates(
localX,
localY,
);
if (this.game.isValidCoord(tile.x, tile.y)) {
tileRef = this.game.ref(tile.x, tile.y);
}
}
// Throttle expensive tile action queries
const now = performance.now();
if (now - this.lastGhostQueryAt < 50) {
return;
}
const rect = this.transformHandler.boundingRect();
if (!rect) return;
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
this.lastGhostQueryAt = now;
let tileRef: TileRef | undefined;
const tile = this.transformHandler.screenToWorldCoordinates(localX, localY);
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()
@@ -377,44 +407,42 @@ export class StructureIconsLayer implements Layer {
}
private createStructure(e: MouseUpEvent) {
if (!this.ghostUnit) return;
// When locked ghost is active, clicking changes the target location
if (
this.ghostUnit.buildableUnit.canBuild === false &&
this.ghostUnit.buildableUnit.canUpgrade === false
this.uiState.lockedGhostTile &&
this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null)
) {
const newTile = this.getTileFromMouseEvent(e);
if (newTile) {
this.uiState.lockedGhostTile = newTile;
// Force trajectory recalculation by clearing cached tile
this.eventBus.emit(
new GhostStructureChangedEvent(this.uiState.ghostStructure),
);
}
return;
}
if (!this.ghostUnit) return;
if (this.isGhostBuildBlocked()) {
this.removeGhostStructure();
return;
}
const rect = this.transformHandler.boundingRect();
if (!rect) return;
const x = e.x - rect.left;
const y = e.y - rect.top;
const tile = this.transformHandler.screenToWorldCoordinates(x, y);
if (this.ghostUnit.buildableUnit.canUpgrade !== false) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
this.ghostUnit.buildableUnit.canUpgrade,
this.ghostUnit.buildableUnit.type,
),
);
} 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,
),
);
const tileRef = this.resolveTargetTileFromEvent(e);
if (!tileRef) {
this.removeGhostStructure();
return;
}
this.removeGhostStructure();
this.commitStructure(tileRef);
}
private moveGhost(e: MouseMoveEvent) {
if (
this.uiState.lockedGhostTile &&
this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null)
) {
return;
}
this.mousePos.x = e.x;
this.mousePos.y = e.y;
@@ -434,9 +462,22 @@ export class StructureIconsLayer implements Layer {
if (type === null) {
return;
}
if (!this.isLockableGhost(type)) {
this.uiState.lockedGhostTile = null;
}
const rect = this.transformHandler.boundingRect();
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
let localX = this.mousePos.x - rect.left;
let localY = this.mousePos.y - rect.top;
if (this.uiState.lockedGhostTile && this.isLockableGhost(type)) {
const screen = this.transformHandler.worldToScreenCoordinates(
new Cell(
this.game.x(this.uiState.lockedGhostTile),
this.game.y(this.uiState.lockedGhostTile),
),
);
localX = screen.x - rect.left;
localY = screen.y - rect.top;
}
const ghost = this.factory.createGhostContainer(
player,
this.ghostStage,
@@ -464,6 +505,7 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.range?.destroy();
this.ghostUnit = null;
}
this.destroyGhostControls();
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [];
this.potentialUpgrade.dotContainer.filters = [];
@@ -474,9 +516,154 @@ export class StructureIconsLayer implements Layer {
private removeGhostStructure() {
this.clearGhostStructure();
this.uiState.ghostStructure = null;
this.uiState.lockedGhostTile = null;
this.eventBus.emit(new GhostStructureChangedEvent(null));
}
private emitBuildIntent(tileRef: TileRef) {
if (!this.ghostUnit) return;
if (this.ghostUnit.buildableUnit.canUpgrade !== false) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
this.ghostUnit.buildableUnit.canUpgrade,
this.ghostUnit.buildableUnit.type,
),
);
} 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, tileRef, rocketDirectionUp),
);
}
}
private commitStructure(tileRef: TileRef) {
this.emitBuildIntent(tileRef);
this.removeGhostStructure();
}
private getTileFromMouseEvent(e: MouseUpEvent): TileRef | null {
const rect = this.transformHandler.boundingRect();
if (!rect) return null;
const x = e.x - rect.left;
const y = e.y - rect.top;
const tile = this.transformHandler.screenToWorldCoordinates(x, y);
if (!this.game.isValidCoord(tile.x, tile.y)) return null;
return this.game.ref(tile.x, tile.y);
}
private resolveTargetTileFromEvent(e: MouseUpEvent): TileRef | null {
if (
this.uiState.lockedGhostTile &&
this.isLockableGhost(this.ghostUnit?.buildableUnit.type ?? null)
) {
return this.uiState.lockedGhostTile;
}
return this.getTileFromMouseEvent(e);
}
private isGhostBuildBlocked(): boolean {
return (
!this.ghostUnit ||
(this.ghostUnit.buildableUnit.canBuild === false &&
this.ghostUnit.buildableUnit.canUpgrade === false)
);
}
private isLockableGhost(type: UnitType | null): boolean {
return type === UnitType.AtomBomb || type === UnitType.HydrogenBomb;
}
private ensureGhostControls() {
if (this.ghostControls) return;
const container = document.createElement("div");
container.style.position = "absolute";
container.style.display = "flex";
container.style.gap = "8px";
container.style.transform = "translate(-50%, 0)";
container.style.pointerEvents = "auto";
container.style.zIndex = "5";
const makeButton = (
label: string,
background: string,
onClick: () => void,
): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.textContent = label;
button.style.minHeight = "48px";
button.style.minWidth = "48px";
button.style.padding = "8px 16px";
button.style.borderRadius = "6px";
button.style.border = "none";
button.style.fontWeight = "700";
button.style.fontSize = "13px";
button.style.color = "#ffffff";
button.style.background = background;
button.style.cursor = "pointer";
button.style.boxShadow = "0 2px 6px rgba(0,0,0,0.25)";
button.style.whiteSpace = "nowrap";
button.addEventListener("click", onClick);
return button;
};
const confirm = makeButton("OK", "#2e7d32", () => {
if (this.uiState.lockedGhostTile) {
this.emitBuildIntent(this.uiState.lockedGhostTile);
}
});
const flip = makeButton("Flip", "#1565c0", () => {
const next = !this.uiState.rocketDirectionUp;
this.eventBus.emit(new SwapRocketDirectionEvent(next));
});
const cancel = makeButton("X", "#b71c1c", () =>
this.removeGhostStructure(),
);
container.append(confirm, flip, cancel);
document.body.appendChild(container);
this.ghostControls = { container, confirm, cancel, flip };
}
private destroyGhostControls() {
if (!this.ghostControls) return;
this.ghostControls.container.remove();
this.ghostControls = null;
}
private hideGhostControls() {
this.destroyGhostControls();
}
private updateGhostControls(localX: number, localY: number, rect: DOMRect) {
if (
!this.ghostUnit ||
!this.uiState.lockedGhostTile ||
!this.isLockableGhost(this.ghostUnit.buildableUnit.type)
) {
this.destroyGhostControls();
return;
}
this.ensureGhostControls();
const offsetY = 40;
const scale = Math.max(
0.75,
Math.min(1.4, this.transformHandler.scale / 2),
);
this.ghostControls!.container.style.left = `${rect.left + localX}px`;
this.ghostControls!.container.style.top = `${rect.top + localY + offsetY}px`;
this.ghostControls!.container.style.transform = `translate(-50%, 0) scale(${scale})`;
}
private resolveGhostRangeLevel(
buildableUnit: BuildableUnit,
): number | undefined {