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:
Ivan Batsulin
2026-04-22 00:06:07 +03:00
committed by GitHub
parent 78d4b301a6
commit 29a1e8dfda
14 changed files with 971 additions and 94 deletions
+187
View File
@@ -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("");
});
});