mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-01 13:13:44 +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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user