Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 79 additions & 47 deletions src/client/InputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export class GhostStructureChangedEvent implements GameEvent {
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
}

export class ConfirmGhostStructureEvent implements GameEvent {}

export class SwapRocketDirectionEvent implements GameEvent {
constructor(public readonly rocketDirectionUp: boolean) {}
}
Expand Down Expand Up @@ -339,6 +341,14 @@ export class InputHandler {
this.setGhostStructure(null);
}

if (
(e.code === "Enter" || e.code === "NumpadEnter") &&
this.uiState.ghostStructure !== null
) {
e.preventDefault();
this.eventBus.emit(new ConfirmGhostStructureEvent());
}
Comment thread
VariableVince marked this conversation as resolved.

if (
[
this.keybinds.moveUp,
Expand Down Expand Up @@ -410,54 +420,11 @@ export class InputHandler {
this.eventBus.emit(new CenterCameraEvent());
}

if (e.code === this.keybinds.buildCity) {
e.preventDefault();
this.setGhostStructure(UnitType.City);
}

if (e.code === this.keybinds.buildFactory) {
e.preventDefault();
this.setGhostStructure(UnitType.Factory);
}

if (e.code === this.keybinds.buildPort) {
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
const matchedBuild = this.resolveBuildKeybind(e.code);
if (matchedBuild !== null) {
e.preventDefault();
this.setGhostStructure(UnitType.Port);
}

if (e.code === this.keybinds.buildDefensePost) {
e.preventDefault();
this.setGhostStructure(UnitType.DefensePost);
}

if (e.code === this.keybinds.buildMissileSilo) {
e.preventDefault();
this.setGhostStructure(UnitType.MissileSilo);
}

if (e.code === this.keybinds.buildSamLauncher) {
e.preventDefault();
this.setGhostStructure(UnitType.SAMLauncher);
}

if (e.code === this.keybinds.buildAtomBomb) {
e.preventDefault();
this.setGhostStructure(UnitType.AtomBomb);
}

if (e.code === this.keybinds.buildHydrogenBomb) {
e.preventDefault();
this.setGhostStructure(UnitType.HydrogenBomb);
}

if (e.code === this.keybinds.buildWarship) {
e.preventDefault();
this.setGhostStructure(UnitType.Warship);
}

if (e.code === this.keybinds.buildMIRV) {
e.preventDefault();
this.setGhostStructure(UnitType.MIRV);
this.setGhostStructure(matchedBuild);
}

if (e.code === this.keybinds.swapDirection) {
Expand Down Expand Up @@ -616,6 +583,71 @@ export class InputHandler {
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
}

/**
* Extracts the digit character from KeyboardEvent.code.
* Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and
* "Numpad0".."Numpad9" (7 chars, digit at index 6). Returns null if not a digit key.
*/
private digitFromKeyCode(code: string): string | null {
Comment thread
evanpelle marked this conversation as resolved.
if (
code?.length === 6 &&
code.startsWith("Digit") &&
/^[0-9]$/.test(code[5])
)
return code[5];
if (
code?.length === 7 &&
code.startsWith("Numpad") &&
/^[0-9]$/.test(code[6])
)
return code[6];
return null;
}

/** Strict equality only: used for first-pass exact KeyboardEvent.code match. */
private buildKeybindMatches(code: string, keybindValue: string): boolean {
return code === keybindValue;
}

/** Digit/Numpad alias match: used only when no exact match was found. */
private buildKeybindMatchesDigit(
code: string,
keybindValue: string,
): boolean {
const digit = this.digitFromKeyCode(code);
const bindDigit = this.digitFromKeyCode(keybindValue);
return digit !== null && bindDigit !== null && digit === bindDigit;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias.
* Returns the UnitType to set as ghost, or null if no build keybind matched.
*/
private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null {
const buildKeybinds: ReadonlyArray<{
key: string;
type: PlayerBuildableUnitType;
}> = [
{ key: "buildCity", type: UnitType.City },
{ key: "buildFactory", type: UnitType.Factory },
{ key: "buildPort", type: UnitType.Port },
{ key: "buildDefensePost", type: UnitType.DefensePost },
{ key: "buildMissileSilo", type: UnitType.MissileSilo },
{ key: "buildSamLauncher", type: UnitType.SAMLauncher },
{ key: "buildAtomBomb", type: UnitType.AtomBomb },
{ key: "buildHydrogenBomb", type: UnitType.HydrogenBomb },
{ key: "buildWarship", type: UnitType.Warship },
{ key: "buildMIRV", type: UnitType.MIRV },
];
for (const { key, type } of buildKeybinds) {
if (this.buildKeybindMatches(code, this.keybinds[key])) return type;
}
for (const { key, type } of buildKeybinds) {
if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type;
}
return null;
}

private getPinchDistance(): number {
const pointerEvents = Array.from(this.pointers.values());
const dx = pointerEvents[0].clientX - pointerEvents[1].clientX;
Expand Down
60 changes: 57 additions & 3 deletions src/client/graphics/layers/StructureIconsLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import {
ConfirmGhostStructureEvent,
GhostStructureChangedEvent,
MouseMoveEvent,
MouseUpEvent,
Expand All @@ -43,6 +44,11 @@ import {
} from "./StructureDrawingUtils";
import bitmapFont from "/fonts/round_6x6_modified.xml?url";

/** 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 {
Expand Down Expand Up @@ -92,6 +98,7 @@ export class StructureIconsLayer implements Layer {
> = new Map(Structures.types.map((type) => [type, { visible: true }]));
private lastGhostQueryAt: number;
private visibilityStateDirty = true;
private pendingConfirm: MouseUpEvent | null = null;
private hasHiddenStructure = false;
potentialUpgrade: StructureRenderInfo | undefined;

Expand Down Expand Up @@ -171,7 +178,12 @@ export class StructureIconsLayer implements Layer {
);
this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e));

this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e));
this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e));
this.eventBus.on(ConfirmGhostStructureEvent, () =>
this.requestConfirmStructure(
new MouseUpEvent(this.mousePos.x, this.mousePos.y),
),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

window.addEventListener("resize", () => this.resizeCanvas());
await this.setupRenderer();
Expand Down Expand Up @@ -307,7 +319,10 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.container.filters = [];
}

if (!this.ghostUnit) return;
if (!this.ghostUnit) {
this.pendingConfirm = null;
return;
}

const unit = buildables.find(
(u) => u.type === this.ghostUnit!.buildableUnit.type,
Expand All @@ -322,6 +337,7 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.pendingConfirm = null;
return;
}

Expand Down Expand Up @@ -369,6 +385,14 @@ export class StructureIconsLayer implements Layer {
: 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);
}
}
});
}

Expand Down Expand Up @@ -399,6 +423,30 @@ 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()) {
this.createStructure(e);
} else {
this.pendingConfirm = e;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private createStructure(e: MouseUpEvent) {
if (!this.ghostUnit) return;
if (
Expand All @@ -420,6 +468,7 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.buildableUnit.type,
),
);
this.removeGhostStructure();
} else if (this.ghostUnit.buildableUnit.canBuild) {
const unitType = this.ghostUnit.buildableUnit.type;
const rocketDirectionUp =
Expand All @@ -433,8 +482,12 @@ export class StructureIconsLayer implements Layer {
rocketDirectionUp,
),
);
if (!shouldPreserveGhostAfterBuild(unitType)) {
this.removeGhostStructure();
}
} else {
this.removeGhostStructure();
}
this.removeGhostStructure();
}

private moveGhost(e: MouseMoveEvent) {
Expand Down Expand Up @@ -489,6 +542,7 @@ export class StructureIconsLayer implements Layer {
}

private clearGhostStructure() {
this.pendingConfirm = null;
if (this.ghostUnit) {
this.ghostUnit.container.destroy();
this.ghostUnit.range?.destroy();
Expand Down
Loading
Loading