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-21 14:06:07 -07:00
committed by GitHub
parent 78d4b301a6
commit 29a1e8dfda
14 changed files with 971 additions and 94 deletions
+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