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
+4
View File
@@ -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": {
+22
View File
@@ -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
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;
+2 -2
View File
@@ -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,
});
}
+257 -59
View File
@@ -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,
};
}
+8 -1
View File
@@ -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"
+98 -2
View File
@@ -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
View File
@@ -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(),
});
+1 -1
View File
@@ -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":
+20 -15
View File
@@ -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;
+1
View File
@@ -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",
+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("");
});
});
+8 -4
View File
@@ -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),
);
+144
View File
@@ -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
});
});