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
+218 -9
View File
@@ -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;