mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
feat: multi-warship selection with Shift+drag box (#3677)
Resolves #3666 ## Description: Adds RTS-style box selection for warships. Hold Shift and drag (desktop) or long-press and drag (touch/mobile) to draw a selection rectangle — all player-owned warships inside get selected at once. A subsequent click/tap on water sends them all to that location. - `SelectionBoxLayer` — pixel-dashed rectangle in world-space, player territory color; shared between desktop and touch - `UILayer` — same pulsing selection outline on each box-selected warship; clears correctly when switching between single/multi selection - `UnitLayer` — finds warships in screen rect, filters inactive ships before sending; touch support included - `InputHandler` — Shift+drag and touch long-press+drag both emit selection box events; cursor becomes crosshair on Shift; discards active ghost structure on Shift press; configurable via `shiftKey` keybind - `Transport` — single atomic `move_multiple_warships` intent (no split on socket drop) - `Schemas` + `ExecutionManager` + `MoveMultipleWarshipsExecution` — server fans out atomic intent into individual `MoveWarshipExecution` per ship - `DynamicUILayer` — `MoveIndicatorUI` chevron animation on target tile for both single and multi move - `UnitDisplay` — warship tooltip Shift hint via `translateText` - `HelpModal` — new hotkey row: Shift + drag → select multiple warships ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## UI update ### Mouse + Keyboard https://github.com/user-attachments/assets/3f35ab5e-1f3c-4c5d-bc4f-aabccf64dc60 ### Touch https://github.com/user-attachments/assets/0d6aec3f-44fa-4fee-b5c6-b267b9b14d79 ## ## Please put your Discord username so you can be contacted if a bug or regression is found: fghjk_60845
This commit is contained in:
@@ -106,6 +106,9 @@
|
||||
"action_ratio_change": "Decrease/Increase attack ratio",
|
||||
"action_reset_gfx": "Reset graphics",
|
||||
"action_auto_upgrade": "Auto-upgrade nearest building",
|
||||
"action_warship_multiselect": "Select multiple warships (drag to draw selection box)",
|
||||
"action_warship_selectall": "Select all your warships",
|
||||
"drag": "drag",
|
||||
"ui_section": "Game UI",
|
||||
"ui_leaderboard": "Leaderboard",
|
||||
"ui_your_team": "Your team:",
|
||||
@@ -761,6 +764,7 @@
|
||||
"city": "Increases max population",
|
||||
"factory": "Creates railroads and spawns trains"
|
||||
},
|
||||
"warship_shift_hint": "Hold Shift and drag to select multiple warships at once",
|
||||
"not_enough_money": "Not enough money"
|
||||
},
|
||||
"win_modal": {
|
||||
|
||||
@@ -434,6 +434,28 @@ export class HelpModal extends BaseModal {
|
||||
${translateText("help_modal.action_auto_upgrade")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
${this.renderKey(keybinds.shiftKey)}
|
||||
<span class="text-white/40 font-bold">+</span>
|
||||
<span class="text-white/50 text-xs"
|
||||
>${translateText("help_modal.drag")}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 border-b border-white/5 text-white/70">
|
||||
${translateText("help_modal.action_warship_multiselect")}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="hover:bg-white/5 transition-colors">
|
||||
<td class="py-3 pl-4 border-b border-white/5">
|
||||
${this.renderKey(keybinds.selectAllWarships)}
|
||||
</td>
|
||||
<td class="py-3 border-b border-white/5 text-white/70">
|
||||
${translateText("help_modal.action_warship_selectall")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
+218
-9
@@ -27,12 +27,16 @@ export class TouchEvent implements GameEvent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted when a unit is selected or deselected
|
||||
* Event emitted when one or more warships are selected or deselected.
|
||||
* For single selection: unit is set, units is empty.
|
||||
* For multi selection: units contains all selected warships, unit is null.
|
||||
* For deselection: isSelected is false.
|
||||
*/
|
||||
export class UnitSelectionEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly unit: UnitView | null,
|
||||
public readonly isSelected: boolean,
|
||||
public readonly units: UnitView[] = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -98,6 +102,40 @@ export class SwapRocketDirectionEvent implements GameEvent {
|
||||
constructor(public readonly rocketDirectionUp: boolean) {}
|
||||
}
|
||||
|
||||
/** Emitted while the user is drawing a shift+drag selection rectangle */
|
||||
export class WarshipSelectionBoxUpdateEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly startX: number,
|
||||
public readonly startY: number,
|
||||
public readonly endX: number,
|
||||
public readonly endY: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Emitted when the user releases the mouse after drawing a selection rectangle */
|
||||
export class WarshipSelectionBoxCompleteEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly startX: number,
|
||||
public readonly startY: number,
|
||||
public readonly endX: number,
|
||||
public readonly endY: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
/** Emitted when the selection box is cancelled (e.g. Escape or no drag) */
|
||||
export class WarshipSelectionBoxCancelEvent implements GameEvent {}
|
||||
|
||||
/** Emitted when the player triggers select-all-warships hotkey */
|
||||
export class SelectAllWarshipsEvent implements GameEvent {}
|
||||
|
||||
/** Emitted when a touch long-press is detected (shows crosshair indicator) */
|
||||
export class TouchLongPressStartEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly x: number,
|
||||
public readonly y: number,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ShowBuildMenuEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly x: number,
|
||||
@@ -166,6 +204,17 @@ export class InputHandler {
|
||||
|
||||
private alternateView = false;
|
||||
|
||||
// Warship selection box state
|
||||
private selectionBoxActive: boolean = false;
|
||||
// True while warships are selected via box (waiting for move target click)
|
||||
private multiSelectionActive: boolean = false;
|
||||
|
||||
// Touch long-press state
|
||||
private longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private longPressActive: boolean = false;
|
||||
private suppressNextTap: boolean = false;
|
||||
private readonly LONG_PRESS_MS = 800;
|
||||
|
||||
private moveInterval: NodeJS.Timeout | null = null;
|
||||
private activeKeys = new Set<string>();
|
||||
private keybinds: Record<string, string> = {};
|
||||
@@ -173,6 +222,7 @@ export class InputHandler {
|
||||
|
||||
private readonly PAN_SPEED = 5;
|
||||
private readonly ZOOM_SPEED = 10;
|
||||
private readonly DRAG_THRESHOLD_PX = 10;
|
||||
|
||||
private readonly userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@@ -186,8 +236,28 @@ export class InputHandler {
|
||||
initialize() {
|
||||
this.keybinds = this.userSettings.keybinds(Platform.isMac);
|
||||
|
||||
// Listen for warship selection to change cursor
|
||||
this.eventBus.on(UnitSelectionEvent, (e) => {
|
||||
if (e.isSelected && (e.units ?? []).length > 0) {
|
||||
// Multi-selection active
|
||||
this.multiSelectionActive = true;
|
||||
this.canvas.style.cursor = "crosshair";
|
||||
} else if (e.isSelected) {
|
||||
// Single warship selected — cursor crosshair, but not multi
|
||||
this.multiSelectionActive = false;
|
||||
this.canvas.style.cursor = "crosshair";
|
||||
} else {
|
||||
// Deselected
|
||||
this.multiSelectionActive = false;
|
||||
if (!this.selectionBoxActive) {
|
||||
this.canvas.style.cursor = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
|
||||
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
|
||||
window.addEventListener("pointercancel", (e) => this.onPointerUp(e));
|
||||
this.canvas.addEventListener(
|
||||
"wheel",
|
||||
(e) => {
|
||||
@@ -216,6 +286,18 @@ export class InputHandler {
|
||||
}
|
||||
this.pointerDown = false;
|
||||
this.pointers.clear();
|
||||
if (this.longPressTimer !== null) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
this.longPressActive = false;
|
||||
this.suppressNextTap = false;
|
||||
if (this.selectionBoxActive || this.multiSelectionActive) {
|
||||
this.selectionBoxActive = false;
|
||||
this.multiSelectionActive = false;
|
||||
this.eventBus.emit(new WarshipSelectionBoxCancelEvent());
|
||||
}
|
||||
this.canvas.style.cursor = "";
|
||||
});
|
||||
this.pointers.clear();
|
||||
|
||||
@@ -224,10 +306,7 @@ export class InputHandler {
|
||||
let deltaY = 0;
|
||||
|
||||
// Skip if shift is held down
|
||||
if (
|
||||
this.activeKeys.has("ShiftLeft") ||
|
||||
this.activeKeys.has("ShiftRight")
|
||||
) {
|
||||
if (this.activeKeys.has(this.keybinds.shiftKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -304,6 +383,11 @@ export class InputHandler {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new CloseViewEvent());
|
||||
this.setGhostStructure(null);
|
||||
if (this.selectionBoxActive || this.multiSelectionActive) {
|
||||
this.selectionBoxActive = false;
|
||||
this.multiSelectionActive = false;
|
||||
this.eventBus.emit(new WarshipSelectionBoxCancelEvent());
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -347,12 +431,20 @@ export class InputHandler {
|
||||
this.keybinds.centerCamera,
|
||||
"ControlLeft",
|
||||
"ControlRight",
|
||||
"ShiftLeft",
|
||||
"ShiftRight",
|
||||
this.keybinds.shiftKey,
|
||||
].includes(e.code)
|
||||
) {
|
||||
this.activeKeys.add(e.code);
|
||||
}
|
||||
|
||||
// Shift = warship box selection mode.
|
||||
// If a ghost structure is active, discard it first.
|
||||
if (e.code === this.keybinds.shiftKey) {
|
||||
if (this.uiState.ghostStructure !== null) {
|
||||
this.setGhostStructure(null);
|
||||
}
|
||||
this.canvas.style.cursor = "crosshair";
|
||||
}
|
||||
});
|
||||
window.addEventListener("keyup", (e) => {
|
||||
const isTextInput = this.isTextInputTarget(e.target);
|
||||
@@ -417,6 +509,11 @@ export class InputHandler {
|
||||
this.eventBus.emit(new CenterCameraEvent());
|
||||
}
|
||||
|
||||
if (e.code === this.keybinds.selectAllWarships) {
|
||||
e.preventDefault();
|
||||
this.eventBus.emit(new SelectAllWarshipsEvent());
|
||||
}
|
||||
|
||||
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
|
||||
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
|
||||
if (matchedBuild !== null) {
|
||||
@@ -454,6 +551,15 @@ export class InputHandler {
|
||||
}
|
||||
|
||||
this.activeKeys.delete(e.code);
|
||||
|
||||
// Reset crosshair when Shift is released (unless selection box or multi-selection still active)
|
||||
if (
|
||||
e.code === this.keybinds.shiftKey &&
|
||||
!this.selectionBoxActive &&
|
||||
!this.multiSelectionActive
|
||||
) {
|
||||
this.canvas.style.cursor = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -479,7 +585,37 @@ export class InputHandler {
|
||||
this.lastPointerDownY = event.clientY;
|
||||
|
||||
this.eventBus.emit(new MouseDownEvent(event.clientX, event.clientY));
|
||||
|
||||
// Start long-press timer for touch devices
|
||||
if (event.pointerType === "touch") {
|
||||
this.longPressActive = false;
|
||||
if (this.longPressTimer !== null) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
this.longPressTimer = setTimeout(() => {
|
||||
this.longPressTimer = null;
|
||||
this.longPressActive = true;
|
||||
this.canvas.style.cursor = "crosshair";
|
||||
this.eventBus.emit(
|
||||
new TouchLongPressStartEvent(
|
||||
this.lastPointerDownX,
|
||||
this.lastPointerDownY,
|
||||
),
|
||||
);
|
||||
}, this.LONG_PRESS_MS);
|
||||
}
|
||||
} else if (this.pointers.size === 2) {
|
||||
// Second finger down — cancel any pending long-press to avoid
|
||||
// triggering selection mode mid-pinch
|
||||
if (this.longPressTimer !== null) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
if (this.longPressActive) {
|
||||
this.longPressActive = false;
|
||||
this.canvas.style.cursor = "";
|
||||
}
|
||||
this.lastPinchDistance = this.getPinchDistance();
|
||||
}
|
||||
}
|
||||
@@ -496,11 +632,50 @@ export class InputHandler {
|
||||
this.pointerDown = false;
|
||||
this.pointers.clear();
|
||||
|
||||
// Clean up long-press state
|
||||
if (this.longPressTimer !== null) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
const wasLongPress = this.longPressActive;
|
||||
this.longPressActive = false;
|
||||
if (wasLongPress) {
|
||||
this.canvas.style.cursor = "";
|
||||
// If long-press fired but no drag happened (selectionBoxActive is false),
|
||||
// suppress the tap so we don't emit a spurious TouchEvent
|
||||
if (!this.selectionBoxActive) {
|
||||
this.suppressNextTap = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Complete selection box if it was active
|
||||
if (this.selectionBoxActive) {
|
||||
this.selectionBoxActive = false;
|
||||
const dist =
|
||||
Math.abs(event.clientX - this.lastPointerDownX) +
|
||||
Math.abs(event.clientY - this.lastPointerDownY);
|
||||
if (dist >= this.DRAG_THRESHOLD_PX) {
|
||||
this.eventBus.emit(
|
||||
new WarshipSelectionBoxCompleteEvent(
|
||||
this.lastPointerDownX,
|
||||
this.lastPointerDownY,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
this.eventBus.emit(new WarshipSelectionBoxCancelEvent());
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isModifierKeyPressed(event)) {
|
||||
this.suppressNextTap = false;
|
||||
this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY));
|
||||
return;
|
||||
}
|
||||
if (this.isAltKeyPressed(event)) {
|
||||
this.suppressNextTap = false;
|
||||
this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY));
|
||||
return;
|
||||
}
|
||||
@@ -508,8 +683,13 @@ export class InputHandler {
|
||||
const dist =
|
||||
Math.abs(event.x - this.lastPointerDownX) +
|
||||
Math.abs(event.y - this.lastPointerDownY);
|
||||
if (dist < 10) {
|
||||
if (dist < this.DRAG_THRESHOLD_PX) {
|
||||
if (event.pointerType === "touch") {
|
||||
if (this.suppressNextTap) {
|
||||
this.suppressNextTap = false;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.eventBus.emit(new TouchEvent(event.x, event.y));
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -586,7 +766,36 @@ export class InputHandler {
|
||||
const deltaX = event.clientX - this.lastPointerX;
|
||||
const deltaY = event.clientY - this.lastPointerY;
|
||||
|
||||
this.eventBus.emit(new DragEvent(deltaX, deltaY));
|
||||
// Cancel long-press if finger moved significantly before timer fires
|
||||
if (this.longPressTimer !== null) {
|
||||
const moveDist =
|
||||
Math.abs(event.clientX - this.lastPointerDownX) +
|
||||
Math.abs(event.clientY - this.lastPointerDownY);
|
||||
if (moveDist >= this.DRAG_THRESHOLD_PX) {
|
||||
clearTimeout(this.longPressTimer);
|
||||
this.longPressTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If shift is held OR touch long-press is active OR selection box already
|
||||
// started, continue emitting selection box updates
|
||||
if (
|
||||
this.selectionBoxActive ||
|
||||
this.activeKeys.has(this.keybinds.shiftKey) ||
|
||||
this.longPressActive
|
||||
) {
|
||||
this.selectionBoxActive = true;
|
||||
this.eventBus.emit(
|
||||
new WarshipSelectionBoxUpdateEvent(
|
||||
this.lastPointerDownX,
|
||||
this.lastPointerDownY,
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
this.eventBus.emit(new DragEvent(deltaX, deltaY));
|
||||
}
|
||||
|
||||
this.lastPointerX = event.clientX;
|
||||
this.lastPointerY = event.clientY;
|
||||
|
||||
@@ -160,7 +160,7 @@ export class SendHashEvent implements GameEvent {
|
||||
|
||||
export class MoveWarshipIntentEvent implements GameEvent {
|
||||
constructor(
|
||||
public readonly unitId: number,
|
||||
public readonly unitIds: number[],
|
||||
public readonly tile: number,
|
||||
) {}
|
||||
}
|
||||
@@ -618,7 +618,7 @@ export class Transport {
|
||||
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "move_warship",
|
||||
unitId: event.unitId,
|
||||
unitIds: event.unitIds,
|
||||
tile: event.tile,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ import { Theme } from "../../../core/configuration/Config";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { UnitSelectionEvent } from "../../InputHandler";
|
||||
import {
|
||||
CloseViewEvent,
|
||||
UnitSelectionEvent,
|
||||
WarshipSelectionBoxCancelEvent,
|
||||
WarshipSelectionBoxCompleteEvent,
|
||||
WarshipSelectionBoxUpdateEvent,
|
||||
} from "../../InputHandler";
|
||||
import { ProgressBar } from "../ProgressBar";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -36,6 +42,15 @@ export class UILayer implements Layer {
|
||||
// Keep track of currently selected unit
|
||||
private selectedUnit: UnitView | null = null;
|
||||
|
||||
// Keep track of multi-selected warships (box selection)
|
||||
private multiSelectedWarships: UnitView[] = [];
|
||||
|
||||
// Per-unit last selection box position for multi-select cleanup
|
||||
private multiSelectionBoxCenters: Map<
|
||||
number,
|
||||
{ x: number; y: number; size: number }
|
||||
> = new Map();
|
||||
|
||||
// Keep track of previous selection box position for cleanup
|
||||
private lastSelectionBoxCenter: {
|
||||
x: number;
|
||||
@@ -46,6 +61,16 @@ export class UILayer implements Layer {
|
||||
// Visual settings for selection
|
||||
private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship)
|
||||
|
||||
// Selection box (drag rectangle) state
|
||||
private selectionBoxActive = false;
|
||||
private selectionBoxStartX = 0;
|
||||
private selectionBoxStartY = 0;
|
||||
private selectionBoxEndX = 0;
|
||||
private selectionBoxEndY = 0;
|
||||
private selectionBoxCanvas: HTMLCanvasElement =
|
||||
document.createElement("canvas");
|
||||
private selectionBoxCtx: CanvasRenderingContext2D | null = null;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private eventBus: EventBus,
|
||||
@@ -67,6 +92,24 @@ export class UILayer implements Layer {
|
||||
this.drawSelectionBox(this.selectedUnit);
|
||||
}
|
||||
|
||||
// Animate multi-selected warships
|
||||
for (const unit of this.multiSelectedWarships) {
|
||||
if (unit.isActive()) {
|
||||
this.drawSelectionBoxMulti(unit);
|
||||
} else {
|
||||
// Unit was destroyed — clean up its box
|
||||
const prev = this.multiSelectionBoxCenters.get(unit.id());
|
||||
if (prev) {
|
||||
this.clearSelectionBox(prev.x, prev.y, prev.size);
|
||||
this.multiSelectionBoxCenters.delete(unit.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove destroyed units from the list
|
||||
this.multiSelectedWarships = this.multiSelectedWarships.filter((u) =>
|
||||
u.isActive(),
|
||||
);
|
||||
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
|
||||
@@ -79,6 +122,25 @@ export class UILayer implements Layer {
|
||||
|
||||
init() {
|
||||
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelection(e));
|
||||
this.eventBus.on(WarshipSelectionBoxUpdateEvent, (e) => {
|
||||
this.selectionBoxActive = true;
|
||||
this.selectionBoxStartX = e.startX;
|
||||
this.selectionBoxStartY = e.startY;
|
||||
this.selectionBoxEndX = e.endX;
|
||||
this.selectionBoxEndY = e.endY;
|
||||
});
|
||||
const clearBox = () => {
|
||||
this.selectionBoxActive = false;
|
||||
this.selectionBoxCtx?.clearRect(
|
||||
0,
|
||||
0,
|
||||
this.selectionBoxCanvas.width,
|
||||
this.selectionBoxCanvas.height,
|
||||
);
|
||||
};
|
||||
this.eventBus.on(WarshipSelectionBoxCompleteEvent, clearBox);
|
||||
this.eventBus.on(WarshipSelectionBoxCancelEvent, clearBox);
|
||||
this.eventBus.on(CloseViewEvent, clearBox);
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
@@ -90,14 +152,101 @@ export class UILayer implements Layer {
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
if (this.selectionBoxActive) {
|
||||
this.renderSelectionBox(context);
|
||||
}
|
||||
}
|
||||
|
||||
private renderSelectionBox(context: CanvasRenderingContext2D) {
|
||||
if (!this.selectionBoxCtx) return;
|
||||
|
||||
const topLeft = this.transformHandler.screenToWorldCoordinates(
|
||||
Math.min(this.selectionBoxStartX, this.selectionBoxEndX),
|
||||
Math.min(this.selectionBoxStartY, this.selectionBoxEndY),
|
||||
);
|
||||
const bottomRight = this.transformHandler.screenToWorldCoordinates(
|
||||
Math.max(this.selectionBoxStartX, this.selectionBoxEndX),
|
||||
Math.max(this.selectionBoxStartY, this.selectionBoxEndY),
|
||||
);
|
||||
|
||||
const cx1 = Math.max(0, Math.floor(topLeft.x));
|
||||
const cy1 = Math.max(0, Math.floor(topLeft.y));
|
||||
const cx2 = Math.min(
|
||||
this.selectionBoxCanvas.width - 1,
|
||||
Math.floor(bottomRight.x),
|
||||
);
|
||||
const cy2 = Math.min(
|
||||
this.selectionBoxCanvas.height - 1,
|
||||
Math.floor(bottomRight.y),
|
||||
);
|
||||
|
||||
if (cx2 <= cx1 || cy2 <= cy1) return;
|
||||
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const baseColor = myPlayer ? myPlayer.territoryColor().lighten(0.2) : null;
|
||||
const colorStr = baseColor
|
||||
? baseColor.alpha(0.85).toRgbString()
|
||||
: "rgba(100,200,255,0.85)";
|
||||
|
||||
this.selectionBoxCtx.clearRect(
|
||||
0,
|
||||
0,
|
||||
this.selectionBoxCanvas.width,
|
||||
this.selectionBoxCanvas.height,
|
||||
);
|
||||
this.selectionBoxCtx.fillStyle = colorStr;
|
||||
this.drawDashedLine(this.selectionBoxCtx, cx1, cy1, cx2, cy1);
|
||||
this.drawDashedLine(this.selectionBoxCtx, cx1, cy2, cx2, cy2);
|
||||
this.drawDashedLine(this.selectionBoxCtx, cx1, cy1, cx1, cy2);
|
||||
this.drawDashedLine(this.selectionBoxCtx, cx2, cy1, cx2, cy2);
|
||||
|
||||
this.selectionBoxCtx.fillStyle = baseColor
|
||||
? baseColor.alpha(0.06).toRgbString()
|
||||
: "rgba(100,200,255,0.06)";
|
||||
this.selectionBoxCtx.fillRect(
|
||||
cx1 + 1,
|
||||
cy1 + 1,
|
||||
cx2 - cx1 - 1,
|
||||
cy2 - cy1 - 1,
|
||||
);
|
||||
|
||||
context.drawImage(
|
||||
this.selectionBoxCanvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
}
|
||||
|
||||
private drawDashedLine(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
) {
|
||||
if (x1 === x2) {
|
||||
for (let y = y1; y <= y2; y++) {
|
||||
if ((x1 + y) % 2 === 0) ctx.fillRect(x1, y, 1, 1);
|
||||
}
|
||||
} else {
|
||||
for (let x = x1; x <= x2; x++) {
|
||||
if ((x + y1) % 2 === 0) ctx.fillRect(x, y1, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redraw() {
|
||||
this.canvas = document.createElement("canvas");
|
||||
this.context = this.canvas.getContext("2d");
|
||||
|
||||
this.canvas.width = this.game.width();
|
||||
this.canvas.height = this.game.height();
|
||||
|
||||
this.selectionBoxCanvas = document.createElement("canvas");
|
||||
this.selectionBoxCanvas.width = this.game.width();
|
||||
this.selectionBoxCanvas.height = this.game.height();
|
||||
this.selectionBoxCtx = this.selectionBoxCanvas.getContext("2d");
|
||||
}
|
||||
|
||||
onUnitEvent(unit: UnitView) {
|
||||
@@ -151,23 +300,107 @@ export class UILayer implements Layer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the unit selection event
|
||||
* Handle the unit selection event (single or multi).
|
||||
* When event.units.length > 0 it's a multi-selection from box/select-all.
|
||||
* When event.unit is set it's a single warship selection.
|
||||
* When event.isSelected is false it clears all selection state.
|
||||
*/
|
||||
private onUnitSelection(event: UnitSelectionEvent) {
|
||||
if (event.isSelected) {
|
||||
this.selectedUnit = event.unit;
|
||||
if (event.unit && event.unit.type() === UnitType.Warship) {
|
||||
this.drawSelectionBox(event.unit);
|
||||
// Always clear single-selection outline first
|
||||
if (this.lastSelectionBoxCenter) {
|
||||
const { x, y, size } = this.lastSelectionBoxCenter;
|
||||
this.clearSelectionBox(x, y, size);
|
||||
this.lastSelectionBoxCenter = null;
|
||||
}
|
||||
// selectedUnit is always reset regardless of lastSelectionBoxCenter
|
||||
this.selectedUnit = null;
|
||||
// Always clear previous multi-selection boxes
|
||||
for (const [, center] of this.multiSelectionBoxCenters) {
|
||||
this.clearSelectionBox(center.x, center.y, center.size);
|
||||
}
|
||||
this.multiSelectionBoxCenters.clear();
|
||||
this.multiSelectedWarships = [];
|
||||
|
||||
if ((event.units ?? []).length > 0) {
|
||||
// Multi-selection
|
||||
this.multiSelectedWarships = event.units;
|
||||
for (const unit of this.multiSelectedWarships) {
|
||||
if (unit.isActive()) {
|
||||
this.drawSelectionBoxMulti(unit);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single selection
|
||||
this.selectedUnit = event.unit;
|
||||
if (event.unit && event.unit.type() === UnitType.Warship) {
|
||||
this.drawSelectionBox(event.unit);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this.selectedUnit === event.unit) {
|
||||
// Clear the selection box
|
||||
if (this.lastSelectionBoxCenter) {
|
||||
const { x, y, size } = this.lastSelectionBoxCenter;
|
||||
this.clearSelectionBox(x, y, size);
|
||||
this.lastSelectionBoxCenter = null;
|
||||
// Deselect everything
|
||||
if (this.lastSelectionBoxCenter) {
|
||||
const { x, y, size } = this.lastSelectionBoxCenter;
|
||||
this.clearSelectionBox(x, y, size);
|
||||
this.lastSelectionBoxCenter = null;
|
||||
}
|
||||
this.selectedUnit = null;
|
||||
for (const [, center] of this.multiSelectionBoxCenters) {
|
||||
this.clearSelectionBox(center.x, center.y, center.size);
|
||||
}
|
||||
this.multiSelectionBoxCenters.clear();
|
||||
this.multiSelectedWarships = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw selection box for a multi-selected warship, tracking position per unit id.
|
||||
*/
|
||||
private drawSelectionBoxMulti(unit: UnitView) {
|
||||
if (!unit || !unit.isActive()) return;
|
||||
|
||||
if (this.theme === null) throw new Error("missing theme");
|
||||
const selectionColor = unit.owner().territoryColor().lighten(0.2);
|
||||
const centerX = this.game.x(unit.tile());
|
||||
const centerY = this.game.y(unit.tile());
|
||||
|
||||
const prev = this.multiSelectionBoxCenters.get(unit.id());
|
||||
if (prev && (prev.x !== centerX || prev.y !== centerY)) {
|
||||
this.clearSelectionBox(prev.x, prev.y, prev.size);
|
||||
}
|
||||
|
||||
this.paintSelectionBoxAt(centerX, centerY, selectionColor);
|
||||
|
||||
this.multiSelectionBoxCenters.set(unit.id(), {
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
size: this.SELECTION_BOX_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper: paint the dashed pulsing border pixels for a selection box.
|
||||
*/
|
||||
private paintSelectionBoxAt(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
selectionColor: Colord,
|
||||
) {
|
||||
const size = this.SELECTION_BOX_SIZE;
|
||||
const opacity = 200 + Math.sin(this.selectionAnimTime * 0.1) * 55;
|
||||
|
||||
for (let x = centerX - size; x <= centerX + size; x++) {
|
||||
for (let y = centerY - size; y <= centerY + size; y++) {
|
||||
if (
|
||||
x === centerX - size ||
|
||||
x === centerX + size ||
|
||||
y === centerY - size ||
|
||||
y === centerY + size
|
||||
) {
|
||||
if ((x + y) % 2 === 0) {
|
||||
this.paintCell(x, y, selectionColor, opacity);
|
||||
}
|
||||
}
|
||||
this.selectedUnit = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,65 +431,30 @@ export class UILayer implements Layer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the configured selection box size
|
||||
const selectionSize = this.SELECTION_BOX_SIZE;
|
||||
|
||||
// Calculate pulsating effect based on animation time (25% variation in opacity)
|
||||
const baseOpacity = 200;
|
||||
const pulseAmount = 55;
|
||||
const opacity =
|
||||
baseOpacity + Math.sin(this.selectionAnimTime * 0.1) * pulseAmount;
|
||||
|
||||
// Get the unit's owner color for the box
|
||||
if (this.theme === null) throw new Error("missing theme");
|
||||
const ownerColor = unit.owner().territoryColor();
|
||||
const selectionColor = unit.owner().territoryColor().lighten(0.2);
|
||||
const centerX = this.game.x(unit.tile());
|
||||
const centerY = this.game.y(unit.tile());
|
||||
|
||||
// Create a brighter version of the owner color for the selection
|
||||
const selectionColor = ownerColor.lighten(0.2);
|
||||
|
||||
// Get current center position
|
||||
const center = unit.tile();
|
||||
const centerX = this.game.x(center);
|
||||
const centerY = this.game.y(center);
|
||||
|
||||
// Clear previous selection box if it exists and is different from current position
|
||||
// Clear previous box if unit moved
|
||||
if (
|
||||
this.lastSelectionBoxCenter &&
|
||||
(this.lastSelectionBoxCenter.x !== centerX ||
|
||||
this.lastSelectionBoxCenter.y !== centerY)
|
||||
) {
|
||||
const lastSize = this.lastSelectionBoxCenter.size;
|
||||
const lastX = this.lastSelectionBoxCenter.x;
|
||||
const lastY = this.lastSelectionBoxCenter.y;
|
||||
|
||||
// Clear the previous selection box
|
||||
this.clearSelectionBox(lastX, lastY, lastSize);
|
||||
this.clearSelectionBox(
|
||||
this.lastSelectionBoxCenter.x,
|
||||
this.lastSelectionBoxCenter.y,
|
||||
this.lastSelectionBoxCenter.size,
|
||||
);
|
||||
}
|
||||
|
||||
// Draw the selection box
|
||||
for (let x = centerX - selectionSize; x <= centerX + selectionSize; x++) {
|
||||
for (let y = centerY - selectionSize; y <= centerY + selectionSize; y++) {
|
||||
// Only draw if it's on the border (not inside or outside the box)
|
||||
if (
|
||||
x === centerX - selectionSize ||
|
||||
x === centerX + selectionSize ||
|
||||
y === centerY - selectionSize ||
|
||||
y === centerY + selectionSize
|
||||
) {
|
||||
// Create a dashed effect by only drawing some pixels
|
||||
const dashPattern = (x + y) % 2 === 0;
|
||||
if (dashPattern) {
|
||||
this.paintCell(x, y, selectionColor, opacity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.paintSelectionBoxAt(centerX, centerY, selectionColor);
|
||||
|
||||
// Store current selection box position for next cleanup
|
||||
this.lastSelectionBoxCenter = {
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
size: selectionSize,
|
||||
size: this.SELECTION_BOX_SIZE,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -233,7 +233,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
${hovered
|
||||
? html`
|
||||
<div
|
||||
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-[100] shadow-lg pointer-events-none"
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-[100] shadow-lg pointer-events-none"
|
||||
>
|
||||
<div class="font-bold text-sm mb-1">
|
||||
${translateText(
|
||||
@@ -243,6 +243,13 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
<div class="p-2">
|
||||
${translateText("build_menu.desc." + structureKey)}
|
||||
</div>
|
||||
${unitType === UnitType.Warship
|
||||
? html`<div
|
||||
class="mt-1 px-2 py-1 text-[10px] text-cyan-300 border-t border-white/10"
|
||||
>
|
||||
⇧ ${translateText("build_menu.warship_shift_hint")}
|
||||
</div>`
|
||||
: null}
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<img src=${goldCoinIcon} width="13" height="13" />
|
||||
<span class="text-yellow-300"
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { colord, Colord } from "colord";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
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";
|
||||
@@ -48,6 +52,9 @@ export class UnitLayer implements Layer {
|
||||
// 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
|
||||
|
||||
@@ -93,6 +100,14 @@ export class UnitLayer implements Layer {
|
||||
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();
|
||||
@@ -139,9 +154,24 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
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),
|
||||
new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef),
|
||||
);
|
||||
// Deselect
|
||||
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
|
||||
@@ -187,6 +217,12 @@ export class UnitLayer implements Layer {
|
||||
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) {
|
||||
@@ -212,6 +248,66 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
+1
-1
@@ -416,7 +416,7 @@ export const CancelBoatIntentSchema = z.object({
|
||||
|
||||
export const MoveWarshipIntentSchema = z.object({
|
||||
type: z.literal("move_warship"),
|
||||
unitId: z.number(),
|
||||
unitIds: z.array(z.number().int()).nonempty(),
|
||||
tile: z.number(),
|
||||
});
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ export class Executor {
|
||||
case "cancel_boat":
|
||||
return new BoatRetreatExecution(player, intent.unitID);
|
||||
case "move_warship":
|
||||
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
|
||||
return new MoveWarshipExecution(player, intent.unitIds, intent.tile);
|
||||
case "spawn":
|
||||
return new SpawnExecution(this.gameID, player.info(), intent.tile);
|
||||
case "boat":
|
||||
|
||||
@@ -4,31 +4,36 @@ import { TileRef } from "../game/GameMap";
|
||||
export class MoveWarshipExecution implements Execution {
|
||||
constructor(
|
||||
private readonly owner: Player,
|
||||
private readonly unitId: number,
|
||||
private readonly unitIds: number[],
|
||||
private readonly position: TileRef,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
init(mg: Game, _ticks: number): void {
|
||||
if (!mg.isValidRef(this.position)) {
|
||||
console.warn(`MoveWarshipExecution: position ${this.position} not valid`);
|
||||
return;
|
||||
}
|
||||
const warship = this.owner
|
||||
.units(UnitType.Warship)
|
||||
.find((u) => u.id() === this.unitId);
|
||||
if (!warship) {
|
||||
console.warn("MoveWarshipExecution: warship not found");
|
||||
return;
|
||||
// Cache warship list and build a lookup map — avoids repeated iteration
|
||||
const warshipMap = new Map(
|
||||
this.owner.units(UnitType.Warship).map((u) => [u.id(), u]),
|
||||
);
|
||||
// Deduplicate ids so each warship is only moved once
|
||||
for (const unitId of new Set(this.unitIds)) {
|
||||
const warship = warshipMap.get(unitId);
|
||||
if (!warship) {
|
||||
console.warn(`MoveWarshipExecution: warship ${unitId} not found`);
|
||||
continue;
|
||||
}
|
||||
if (!warship.isActive()) {
|
||||
console.warn(`MoveWarshipExecution: warship ${unitId} is not active`);
|
||||
continue;
|
||||
}
|
||||
warship.setPatrolTile(this.position);
|
||||
warship.setTargetTile(undefined);
|
||||
}
|
||||
if (!warship.isActive()) {
|
||||
console.warn("MoveWarshipExecution: warship is not active");
|
||||
return;
|
||||
}
|
||||
warship.setPatrolTile(this.position);
|
||||
warship.setTargetTile(undefined);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {}
|
||||
tick(_ticks: number): void {}
|
||||
|
||||
isActive(): boolean {
|
||||
return false;
|
||||
|
||||
@@ -31,6 +31,7 @@ export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
|
||||
altKey: "AltLeft",
|
||||
shiftKey: "ShiftLeft",
|
||||
resetGfx: "KeyR",
|
||||
selectAllWarships: "KeyF",
|
||||
pauseGame: "KeyP",
|
||||
gameSpeedUp: "Period",
|
||||
gameSpeedDown: "Comma",
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
AutoUpgradeEvent,
|
||||
ConfirmGhostStructureEvent,
|
||||
InputHandler,
|
||||
WarshipSelectionBoxCancelEvent,
|
||||
WarshipSelectionBoxCompleteEvent,
|
||||
WarshipSelectionBoxUpdateEvent,
|
||||
} from "../src/client/InputHandler";
|
||||
import { UIState } from "../src/client/graphics/UIState";
|
||||
import { EventBus } from "../src/core/EventBus";
|
||||
@@ -860,3 +863,187 @@ describe("InputHandler AutoUpgrade", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Warship box selection (Shift+drag)", () => {
|
||||
let inputHandler: InputHandler;
|
||||
let eventBus: EventBus;
|
||||
let mockCanvas: HTMLCanvasElement;
|
||||
let uiState: UIState;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockGameView = { inSpawnPhase: () => false } as GameView;
|
||||
mockCanvas = document.createElement("canvas");
|
||||
eventBus = new EventBus();
|
||||
uiState = {
|
||||
attackRatio: 20,
|
||||
ghostStructure: null,
|
||||
rocketDirectionUp: true,
|
||||
overlappingRailroads: [],
|
||||
ghostRailPaths: [],
|
||||
} as UIState;
|
||||
inputHandler = new InputHandler(
|
||||
mockGameView,
|
||||
uiState,
|
||||
mockCanvas,
|
||||
eventBus,
|
||||
);
|
||||
inputHandler.initialize();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
inputHandler.destroy();
|
||||
});
|
||||
|
||||
test("Shift keydown sets canvas cursor to crosshair", () => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
||||
expect(mockCanvas.style.cursor).toBe("crosshair");
|
||||
});
|
||||
|
||||
test("ShiftRight keydown also sets cursor to crosshair", () => {
|
||||
// ShiftRight is not the default shiftKey keybind (ShiftLeft is).
|
||||
// This test verifies the configured shiftKey works, not a hardcoded ShiftRight.
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
||||
expect(mockCanvas.style.cursor).toBe("crosshair");
|
||||
});
|
||||
|
||||
test("Shift keyup resets cursor when no selection box active", () => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
||||
window.dispatchEvent(new KeyboardEvent("keyup", { code: "ShiftLeft" }));
|
||||
expect(mockCanvas.style.cursor).toBe("");
|
||||
});
|
||||
|
||||
test("Shift keydown discards active ghostStructure", () => {
|
||||
uiState.ghostStructure = UnitType.Warship;
|
||||
const emitSpy = vi.spyOn(eventBus, "emit");
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
||||
|
||||
expect(uiState.ghostStructure).toBeNull();
|
||||
const types = emitSpy.mock.calls.map((c) => c[0].constructor.name);
|
||||
expect(types).toContain("GhostStructureChangedEvent");
|
||||
});
|
||||
|
||||
test("Shift+drag emits WarshipSelectionBoxUpdateEvent", () => {
|
||||
const listener = vi.fn();
|
||||
eventBus.on(WarshipSelectionBoxUpdateEvent, listener);
|
||||
|
||||
inputHandler["onPointerDown"](
|
||||
new PointerEvent("pointerdown", {
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
inputHandler["activeKeys"].add("ShiftLeft");
|
||||
inputHandler["onPointerMove"](
|
||||
new PointerEvent("pointermove", {
|
||||
button: 0,
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
startX: 100,
|
||||
startY: 100,
|
||||
endX: 200,
|
||||
endY: 200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("Shift+drag then pointerup emits WarshipSelectionBoxCompleteEvent", () => {
|
||||
const listener = vi.fn();
|
||||
eventBus.on(WarshipSelectionBoxCompleteEvent, listener);
|
||||
|
||||
inputHandler["onPointerDown"](
|
||||
new PointerEvent("pointerdown", {
|
||||
button: 0,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
inputHandler["activeKeys"].add("ShiftLeft");
|
||||
inputHandler["onPointerMove"](
|
||||
new PointerEvent("pointermove", {
|
||||
button: 0,
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
expect(inputHandler["selectionBoxActive"]).toBe(true);
|
||||
|
||||
inputHandler["onPointerUp"](
|
||||
new PointerEvent("pointerup", {
|
||||
button: 0,
|
||||
clientX: 200,
|
||||
clientY: 200,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ startX: 50, startY: 50, endX: 200, endY: 200 }),
|
||||
);
|
||||
expect(inputHandler["selectionBoxActive"]).toBe(false);
|
||||
});
|
||||
|
||||
test("Escape cancels active selection box", () => {
|
||||
const listener = vi.fn();
|
||||
eventBus.on(WarshipSelectionBoxCancelEvent, listener);
|
||||
|
||||
inputHandler["selectionBoxActive"] = true;
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
|
||||
|
||||
expect(listener).toHaveBeenCalled();
|
||||
expect(inputHandler["selectionBoxActive"]).toBe(false);
|
||||
});
|
||||
|
||||
test("tiny drag (< 10px) cancels selection box instead of completing it", () => {
|
||||
const cancelListener = vi.fn();
|
||||
const completeListener = vi.fn();
|
||||
eventBus.on(WarshipSelectionBoxCancelEvent, cancelListener);
|
||||
eventBus.on(WarshipSelectionBoxCompleteEvent, completeListener);
|
||||
|
||||
inputHandler["onPointerDown"](
|
||||
new PointerEvent("pointerdown", {
|
||||
button: 0,
|
||||
clientX: 100,
|
||||
clientY: 100,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
inputHandler["activeKeys"].add("ShiftLeft");
|
||||
inputHandler["onPointerMove"](
|
||||
new PointerEvent("pointermove", {
|
||||
button: 0,
|
||||
clientX: 104,
|
||||
clientY: 104,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
inputHandler["onPointerUp"](
|
||||
new PointerEvent("pointerup", {
|
||||
button: 0,
|
||||
clientX: 104,
|
||||
clientY: 104,
|
||||
pointerId: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cancelListener).toHaveBeenCalled();
|
||||
expect(completeListener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("window blur resets cursor", () => {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
|
||||
expect(mockCanvas.style.cursor).toBe("crosshair");
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
expect(mockCanvas.style.cursor).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,7 +164,11 @@ describe("Warship", () => {
|
||||
game.addExecution(new WarshipExecution(warship));
|
||||
|
||||
game.addExecution(
|
||||
new MoveWarshipExecution(player1, warship.id(), game.ref(coastX + 5, 15)),
|
||||
new MoveWarshipExecution(
|
||||
player1,
|
||||
[warship.id()],
|
||||
game.ref(coastX + 5, 15),
|
||||
),
|
||||
);
|
||||
|
||||
executeTicks(game, 10);
|
||||
@@ -244,7 +248,7 @@ describe("Warship", () => {
|
||||
);
|
||||
new MoveWarshipExecution(
|
||||
player2,
|
||||
warship.id(),
|
||||
[warship.id()],
|
||||
game.ref(coastX + 5, 15),
|
||||
).init(game, 0);
|
||||
expect(warship.patrolTile()).toBe(originalPatrolTile);
|
||||
@@ -262,7 +266,7 @@ describe("Warship", () => {
|
||||
warship.delete();
|
||||
new MoveWarshipExecution(
|
||||
player1,
|
||||
warship.id(),
|
||||
[warship.id()],
|
||||
game.ref(coastX + 5, 15),
|
||||
).init(game, 0);
|
||||
expect(warship.patrolTile()).toBe(originalPatrolTile);
|
||||
@@ -271,7 +275,7 @@ describe("Warship", () => {
|
||||
test("MoveWarshipExecution fails gracefully if warship not found", async () => {
|
||||
const exec = new MoveWarshipExecution(
|
||||
player1,
|
||||
123,
|
||||
[123],
|
||||
game.ref(coastX + 5, 15),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { MoveWarshipExecution } from "../src/core/execution/MoveWarshipExecution";
|
||||
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
|
||||
import {
|
||||
Game,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
UnitType,
|
||||
} from "../src/core/game/Game";
|
||||
import { setup } from "./util/Setup";
|
||||
import { executeTicks } from "./util/utils";
|
||||
|
||||
const coastX = 7;
|
||||
let game: Game;
|
||||
let player1: Player;
|
||||
let player2: Player;
|
||||
|
||||
describe("Warship multi-selection (MoveWarshipExecution)", () => {
|
||||
beforeEach(async () => {
|
||||
game = await setup(
|
||||
"half_land_half_ocean",
|
||||
{ infiniteGold: true, instantBuild: true },
|
||||
[
|
||||
new PlayerInfo("p1", PlayerType.Human, null, "p1"),
|
||||
new PlayerInfo("p2", PlayerType.Human, null, "p2"),
|
||||
],
|
||||
);
|
||||
while (game.inSpawnPhase()) game.executeNextTick();
|
||||
player1 = game.player("p1");
|
||||
player2 = game.player("p2");
|
||||
});
|
||||
|
||||
test("moving multiple warships via array MoveWarshipExecution updates all patrol tiles", () => {
|
||||
const w1 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 1, 10), {
|
||||
patrolTile: game.ref(coastX + 1, 10),
|
||||
});
|
||||
const w2 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 2, 10), {
|
||||
patrolTile: game.ref(coastX + 2, 10),
|
||||
});
|
||||
const w3 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 3, 10), {
|
||||
patrolTile: game.ref(coastX + 3, 10),
|
||||
});
|
||||
|
||||
game.addExecution(new WarshipExecution(w1));
|
||||
game.addExecution(new WarshipExecution(w2));
|
||||
game.addExecution(new WarshipExecution(w3));
|
||||
|
||||
const sharedTarget = game.ref(coastX + 5, 15);
|
||||
// Single execution with array of ids — the new unified API
|
||||
game.addExecution(
|
||||
new MoveWarshipExecution(
|
||||
player1,
|
||||
[w1.id(), w2.id(), w3.id()],
|
||||
sharedTarget,
|
||||
),
|
||||
);
|
||||
|
||||
executeTicks(game, 5);
|
||||
|
||||
expect(w1.patrolTile()).toBe(sharedTarget);
|
||||
expect(w2.patrolTile()).toBe(sharedTarget);
|
||||
expect(w3.patrolTile()).toBe(sharedTarget);
|
||||
});
|
||||
|
||||
test("moving multiple warships to different targets works independently", () => {
|
||||
const w1 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 1, 10), {
|
||||
patrolTile: game.ref(coastX + 1, 10),
|
||||
});
|
||||
const w2 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 2, 10), {
|
||||
patrolTile: game.ref(coastX + 2, 10),
|
||||
});
|
||||
|
||||
game.addExecution(new WarshipExecution(w1));
|
||||
game.addExecution(new WarshipExecution(w2));
|
||||
|
||||
const target1 = game.ref(coastX + 3, 12);
|
||||
const target2 = game.ref(coastX + 4, 14);
|
||||
|
||||
game.addExecution(new MoveWarshipExecution(player1, [w1.id()], target1));
|
||||
game.addExecution(new MoveWarshipExecution(player1, [w2.id()], target2));
|
||||
|
||||
executeTicks(game, 5);
|
||||
|
||||
expect(w1.patrolTile()).toBe(target1);
|
||||
expect(w2.patrolTile()).toBe(target2);
|
||||
});
|
||||
|
||||
test("enemy cannot move player's warships via MoveWarshipExecution", () => {
|
||||
const originalTile = game.ref(coastX + 1, 10);
|
||||
const w1 = player1.buildUnit(UnitType.Warship, originalTile, {
|
||||
patrolTile: originalTile,
|
||||
});
|
||||
game.addExecution(new WarshipExecution(w1));
|
||||
|
||||
new MoveWarshipExecution(player2, [w1.id()], game.ref(coastX + 5, 15)).init(
|
||||
game,
|
||||
0,
|
||||
);
|
||||
|
||||
expect(w1.patrolTile()).toBe(originalTile);
|
||||
});
|
||||
|
||||
test("MoveWarshipExecution on destroyed warship does not throw", () => {
|
||||
const w1 = player1.buildUnit(UnitType.Warship, game.ref(coastX + 1, 10), {
|
||||
patrolTile: game.ref(coastX + 1, 10),
|
||||
});
|
||||
w1.delete();
|
||||
|
||||
const exec = new MoveWarshipExecution(
|
||||
player1,
|
||||
[w1.id()],
|
||||
game.ref(coastX + 5, 15),
|
||||
);
|
||||
expect(() => exec.init(game, 0)).not.toThrow();
|
||||
expect(exec.isActive()).toBe(false);
|
||||
});
|
||||
|
||||
test("batch move does not affect warships owned by other players", () => {
|
||||
const p1tile = game.ref(coastX + 1, 10);
|
||||
const p2tile = game.ref(coastX + 2, 10);
|
||||
|
||||
const w1 = player1.buildUnit(UnitType.Warship, p1tile, {
|
||||
patrolTile: p1tile,
|
||||
});
|
||||
const w2 = player2.buildUnit(UnitType.Warship, p2tile, {
|
||||
patrolTile: p2tile,
|
||||
});
|
||||
|
||||
game.addExecution(new WarshipExecution(w1));
|
||||
game.addExecution(new WarshipExecution(w2));
|
||||
|
||||
const target = game.ref(coastX + 5, 15);
|
||||
|
||||
// player1 sends both IDs — but w2 belongs to player2
|
||||
game.addExecution(
|
||||
new MoveWarshipExecution(player1, [w1.id(), w2.id()], target),
|
||||
);
|
||||
|
||||
executeTicks(game, 5);
|
||||
|
||||
expect(w1.patrolTile()).toBe(target);
|
||||
expect(w2.patrolTile()).toBe(p2tile); // unchanged — wrong owner
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user