mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-08 11:10:37 +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:
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user