mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 09:14:09 +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:
@@ -106,6 +106,9 @@
|
|||||||
"action_ratio_change": "Decrease/Increase attack ratio",
|
"action_ratio_change": "Decrease/Increase attack ratio",
|
||||||
"action_reset_gfx": "Reset graphics",
|
"action_reset_gfx": "Reset graphics",
|
||||||
"action_auto_upgrade": "Auto-upgrade nearest building",
|
"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_section": "Game UI",
|
||||||
"ui_leaderboard": "Leaderboard",
|
"ui_leaderboard": "Leaderboard",
|
||||||
"ui_your_team": "Your team:",
|
"ui_your_team": "Your team:",
|
||||||
@@ -761,6 +764,7 @@
|
|||||||
"city": "Increases max population",
|
"city": "Increases max population",
|
||||||
"factory": "Creates railroads and spawns trains"
|
"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"
|
"not_enough_money": "Not enough money"
|
||||||
},
|
},
|
||||||
"win_modal": {
|
"win_modal": {
|
||||||
|
|||||||
@@ -434,6 +434,28 @@ export class HelpModal extends BaseModal {
|
|||||||
${translateText("help_modal.action_auto_upgrade")}
|
${translateText("help_modal.action_auto_upgrade")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+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 {
|
export class UnitSelectionEvent implements GameEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly unit: UnitView | null,
|
public readonly unit: UnitView | null,
|
||||||
public readonly isSelected: boolean,
|
public readonly isSelected: boolean,
|
||||||
|
public readonly units: UnitView[] = [],
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +102,40 @@ export class SwapRocketDirectionEvent implements GameEvent {
|
|||||||
constructor(public readonly rocketDirectionUp: boolean) {}
|
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 {
|
export class ShowBuildMenuEvent implements GameEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly x: number,
|
public readonly x: number,
|
||||||
@@ -166,6 +204,17 @@ export class InputHandler {
|
|||||||
|
|
||||||
private alternateView = false;
|
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 moveInterval: NodeJS.Timeout | null = null;
|
||||||
private activeKeys = new Set<string>();
|
private activeKeys = new Set<string>();
|
||||||
private keybinds: Record<string, string> = {};
|
private keybinds: Record<string, string> = {};
|
||||||
@@ -173,6 +222,7 @@ export class InputHandler {
|
|||||||
|
|
||||||
private readonly PAN_SPEED = 5;
|
private readonly PAN_SPEED = 5;
|
||||||
private readonly ZOOM_SPEED = 10;
|
private readonly ZOOM_SPEED = 10;
|
||||||
|
private readonly DRAG_THRESHOLD_PX = 10;
|
||||||
|
|
||||||
private readonly userSettings: UserSettings = new UserSettings();
|
private readonly userSettings: UserSettings = new UserSettings();
|
||||||
|
|
||||||
@@ -186,8 +236,28 @@ export class InputHandler {
|
|||||||
initialize() {
|
initialize() {
|
||||||
this.keybinds = this.userSettings.keybinds(Platform.isMac);
|
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));
|
this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e));
|
||||||
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
|
window.addEventListener("pointerup", (e) => this.onPointerUp(e));
|
||||||
|
window.addEventListener("pointercancel", (e) => this.onPointerUp(e));
|
||||||
this.canvas.addEventListener(
|
this.canvas.addEventListener(
|
||||||
"wheel",
|
"wheel",
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -216,6 +286,18 @@ export class InputHandler {
|
|||||||
}
|
}
|
||||||
this.pointerDown = false;
|
this.pointerDown = false;
|
||||||
this.pointers.clear();
|
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();
|
this.pointers.clear();
|
||||||
|
|
||||||
@@ -224,10 +306,7 @@ export class InputHandler {
|
|||||||
let deltaY = 0;
|
let deltaY = 0;
|
||||||
|
|
||||||
// Skip if shift is held down
|
// Skip if shift is held down
|
||||||
if (
|
if (this.activeKeys.has(this.keybinds.shiftKey)) {
|
||||||
this.activeKeys.has("ShiftLeft") ||
|
|
||||||
this.activeKeys.has("ShiftRight")
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,6 +383,11 @@ export class InputHandler {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.eventBus.emit(new CloseViewEvent());
|
this.eventBus.emit(new CloseViewEvent());
|
||||||
this.setGhostStructure(null);
|
this.setGhostStructure(null);
|
||||||
|
if (this.selectionBoxActive || this.multiSelectionActive) {
|
||||||
|
this.selectionBoxActive = false;
|
||||||
|
this.multiSelectionActive = false;
|
||||||
|
this.eventBus.emit(new WarshipSelectionBoxCancelEvent());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -347,12 +431,20 @@ export class InputHandler {
|
|||||||
this.keybinds.centerCamera,
|
this.keybinds.centerCamera,
|
||||||
"ControlLeft",
|
"ControlLeft",
|
||||||
"ControlRight",
|
"ControlRight",
|
||||||
"ShiftLeft",
|
this.keybinds.shiftKey,
|
||||||
"ShiftRight",
|
|
||||||
].includes(e.code)
|
].includes(e.code)
|
||||||
) {
|
) {
|
||||||
this.activeKeys.add(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) => {
|
window.addEventListener("keyup", (e) => {
|
||||||
const isTextInput = this.isTextInputTarget(e.target);
|
const isTextInput = this.isTextInputTarget(e.target);
|
||||||
@@ -417,6 +509,11 @@ export class InputHandler {
|
|||||||
this.eventBus.emit(new CenterCameraEvent());
|
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.
|
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
|
||||||
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
|
const matchedBuild = this.resolveBuildKeybind(e.code, e.shiftKey);
|
||||||
if (matchedBuild !== null) {
|
if (matchedBuild !== null) {
|
||||||
@@ -454,6 +551,15 @@ export class InputHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.activeKeys.delete(e.code);
|
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.lastPointerDownY = event.clientY;
|
||||||
|
|
||||||
this.eventBus.emit(new MouseDownEvent(event.clientX, 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) {
|
} 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();
|
this.lastPinchDistance = this.getPinchDistance();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,11 +632,50 @@ export class InputHandler {
|
|||||||
this.pointerDown = false;
|
this.pointerDown = false;
|
||||||
this.pointers.clear();
|
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)) {
|
if (this.isModifierKeyPressed(event)) {
|
||||||
|
this.suppressNextTap = false;
|
||||||
this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY));
|
this.eventBus.emit(new ShowBuildMenuEvent(event.clientX, event.clientY));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.isAltKeyPressed(event)) {
|
if (this.isAltKeyPressed(event)) {
|
||||||
|
this.suppressNextTap = false;
|
||||||
this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY));
|
this.eventBus.emit(new ShowEmojiMenuEvent(event.clientX, event.clientY));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -508,8 +683,13 @@ export class InputHandler {
|
|||||||
const dist =
|
const dist =
|
||||||
Math.abs(event.x - this.lastPointerDownX) +
|
Math.abs(event.x - this.lastPointerDownX) +
|
||||||
Math.abs(event.y - this.lastPointerDownY);
|
Math.abs(event.y - this.lastPointerDownY);
|
||||||
if (dist < 10) {
|
if (dist < this.DRAG_THRESHOLD_PX) {
|
||||||
if (event.pointerType === "touch") {
|
if (event.pointerType === "touch") {
|
||||||
|
if (this.suppressNextTap) {
|
||||||
|
this.suppressNextTap = false;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.eventBus.emit(new TouchEvent(event.x, event.y));
|
this.eventBus.emit(new TouchEvent(event.x, event.y));
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -586,7 +766,36 @@ export class InputHandler {
|
|||||||
const deltaX = event.clientX - this.lastPointerX;
|
const deltaX = event.clientX - this.lastPointerX;
|
||||||
const deltaY = event.clientY - this.lastPointerY;
|
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.lastPointerX = event.clientX;
|
||||||
this.lastPointerY = event.clientY;
|
this.lastPointerY = event.clientY;
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export class SendHashEvent implements GameEvent {
|
|||||||
|
|
||||||
export class MoveWarshipIntentEvent implements GameEvent {
|
export class MoveWarshipIntentEvent implements GameEvent {
|
||||||
constructor(
|
constructor(
|
||||||
public readonly unitId: number,
|
public readonly unitIds: number[],
|
||||||
public readonly tile: number,
|
public readonly tile: number,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -618,7 +618,7 @@ export class Transport {
|
|||||||
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
|
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
|
||||||
this.sendIntent({
|
this.sendIntent({
|
||||||
type: "move_warship",
|
type: "move_warship",
|
||||||
unitId: event.unitId,
|
unitIds: event.unitIds,
|
||||||
tile: event.tile,
|
tile: event.tile,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import { Theme } from "../../../core/configuration/Config";
|
|||||||
import { UnitType } from "../../../core/game/Game";
|
import { UnitType } from "../../../core/game/Game";
|
||||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||||
import { UnitSelectionEvent } from "../../InputHandler";
|
import {
|
||||||
|
CloseViewEvent,
|
||||||
|
UnitSelectionEvent,
|
||||||
|
WarshipSelectionBoxCancelEvent,
|
||||||
|
WarshipSelectionBoxCompleteEvent,
|
||||||
|
WarshipSelectionBoxUpdateEvent,
|
||||||
|
} from "../../InputHandler";
|
||||||
import { ProgressBar } from "../ProgressBar";
|
import { ProgressBar } from "../ProgressBar";
|
||||||
import { TransformHandler } from "../TransformHandler";
|
import { TransformHandler } from "../TransformHandler";
|
||||||
import { Layer } from "./Layer";
|
import { Layer } from "./Layer";
|
||||||
@@ -36,6 +42,15 @@ export class UILayer implements Layer {
|
|||||||
// Keep track of currently selected unit
|
// Keep track of currently selected unit
|
||||||
private selectedUnit: UnitView | null = null;
|
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
|
// Keep track of previous selection box position for cleanup
|
||||||
private lastSelectionBoxCenter: {
|
private lastSelectionBoxCenter: {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -46,6 +61,16 @@ export class UILayer implements Layer {
|
|||||||
// Visual settings for selection
|
// Visual settings for selection
|
||||||
private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship)
|
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(
|
constructor(
|
||||||
private game: GameView,
|
private game: GameView,
|
||||||
private eventBus: EventBus,
|
private eventBus: EventBus,
|
||||||
@@ -67,6 +92,24 @@ export class UILayer implements Layer {
|
|||||||
this.drawSelectionBox(this.selectedUnit);
|
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
|
this.game
|
||||||
.updatesSinceLastTick()
|
.updatesSinceLastTick()
|
||||||
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
|
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
|
||||||
@@ -79,6 +122,25 @@ export class UILayer implements Layer {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelection(e));
|
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();
|
this.redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,14 +152,101 @@ export class UILayer implements Layer {
|
|||||||
this.game.width(),
|
this.game.width(),
|
||||||
this.game.height(),
|
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() {
|
redraw() {
|
||||||
this.canvas = document.createElement("canvas");
|
this.canvas = document.createElement("canvas");
|
||||||
this.context = this.canvas.getContext("2d");
|
this.context = this.canvas.getContext("2d");
|
||||||
|
|
||||||
this.canvas.width = this.game.width();
|
this.canvas.width = this.game.width();
|
||||||
this.canvas.height = this.game.height();
|
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) {
|
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) {
|
private onUnitSelection(event: UnitSelectionEvent) {
|
||||||
if (event.isSelected) {
|
if (event.isSelected) {
|
||||||
this.selectedUnit = event.unit;
|
// Always clear single-selection outline first
|
||||||
if (event.unit && event.unit.type() === UnitType.Warship) {
|
if (this.lastSelectionBoxCenter) {
|
||||||
this.drawSelectionBox(event.unit);
|
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 {
|
} else {
|
||||||
if (this.selectedUnit === event.unit) {
|
// Deselect everything
|
||||||
// Clear the selection box
|
if (this.lastSelectionBoxCenter) {
|
||||||
if (this.lastSelectionBoxCenter) {
|
const { x, y, size } = this.lastSelectionBoxCenter;
|
||||||
const { x, y, size } = this.lastSelectionBoxCenter;
|
this.clearSelectionBox(x, y, size);
|
||||||
this.clearSelectionBox(x, y, size);
|
this.lastSelectionBoxCenter = null;
|
||||||
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;
|
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");
|
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
|
// Clear previous box if unit moved
|
||||||
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
|
|
||||||
if (
|
if (
|
||||||
this.lastSelectionBoxCenter &&
|
this.lastSelectionBoxCenter &&
|
||||||
(this.lastSelectionBoxCenter.x !== centerX ||
|
(this.lastSelectionBoxCenter.x !== centerX ||
|
||||||
this.lastSelectionBoxCenter.y !== centerY)
|
this.lastSelectionBoxCenter.y !== centerY)
|
||||||
) {
|
) {
|
||||||
const lastSize = this.lastSelectionBoxCenter.size;
|
this.clearSelectionBox(
|
||||||
const lastX = this.lastSelectionBoxCenter.x;
|
this.lastSelectionBoxCenter.x,
|
||||||
const lastY = this.lastSelectionBoxCenter.y;
|
this.lastSelectionBoxCenter.y,
|
||||||
|
this.lastSelectionBoxCenter.size,
|
||||||
// Clear the previous selection box
|
);
|
||||||
this.clearSelectionBox(lastX, lastY, lastSize);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the selection box
|
this.paintSelectionBoxAt(centerX, centerY, selectionColor);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store current selection box position for next cleanup
|
|
||||||
this.lastSelectionBoxCenter = {
|
this.lastSelectionBoxCenter = {
|
||||||
x: centerX,
|
x: centerX,
|
||||||
y: centerY,
|
y: centerY,
|
||||||
size: selectionSize,
|
size: this.SELECTION_BOX_SIZE,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export class UnitDisplay extends LitElement implements Layer {
|
|||||||
${hovered
|
${hovered
|
||||||
? html`
|
? html`
|
||||||
<div
|
<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">
|
<div class="font-bold text-sm mb-1">
|
||||||
${translateText(
|
${translateText(
|
||||||
@@ -243,6 +243,13 @@ export class UnitDisplay extends LitElement implements Layer {
|
|||||||
<div class="p-2">
|
<div class="p-2">
|
||||||
${translateText("build_menu.desc." + structureKey)}
|
${translateText("build_menu.desc." + structureKey)}
|
||||||
</div>
|
</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">
|
<div class="flex items-center justify-center gap-1">
|
||||||
<img src=${goldCoinIcon} width="13" height="13" />
|
<img src=${goldCoinIcon} width="13" height="13" />
|
||||||
<span class="text-yellow-300"
|
<span class="text-yellow-300"
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { colord, Colord } from "colord";
|
import { colord, Colord } from "colord";
|
||||||
import { EventBus } from "../../../core/EventBus";
|
import { EventBus } from "../../../core/EventBus";
|
||||||
import { Theme } from "../../../core/configuration/Config";
|
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 { TileRef } from "../../../core/game/GameMap";
|
||||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||||
import { BezenhamLine } from "../../../core/utilities/Line";
|
import { BezenhamLine } from "../../../core/utilities/Line";
|
||||||
import {
|
import {
|
||||||
AlternateViewEvent,
|
AlternateViewEvent,
|
||||||
|
CloseViewEvent,
|
||||||
ContextMenuEvent,
|
ContextMenuEvent,
|
||||||
MouseUpEvent,
|
MouseUpEvent,
|
||||||
|
SelectAllWarshipsEvent,
|
||||||
TouchEvent,
|
TouchEvent,
|
||||||
UnitSelectionEvent,
|
UnitSelectionEvent,
|
||||||
|
WarshipSelectionBoxCancelEvent,
|
||||||
|
WarshipSelectionBoxCompleteEvent,
|
||||||
} from "../../InputHandler";
|
} from "../../InputHandler";
|
||||||
import { MoveWarshipIntentEvent } from "../../Transport";
|
import { MoveWarshipIntentEvent } from "../../Transport";
|
||||||
import { TransformHandler } from "../TransformHandler";
|
import { TransformHandler } from "../TransformHandler";
|
||||||
@@ -48,6 +52,9 @@ export class UnitLayer implements Layer {
|
|||||||
// Selected unit property as suggested in the review comment
|
// Selected unit property as suggested in the review comment
|
||||||
private selectedUnit: UnitView | null = null;
|
private selectedUnit: UnitView | null = null;
|
||||||
|
|
||||||
|
// Multi-selected warships (from selection box)
|
||||||
|
private selectedWarships: UnitView[] = [];
|
||||||
|
|
||||||
// Configuration for unit selection
|
// Configuration for unit selection
|
||||||
private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone
|
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(MouseUpEvent, (e) => this.onMouseUp(e));
|
||||||
this.eventBus.on(TouchEvent, (e) => this.onTouch(e));
|
this.eventBus.on(TouchEvent, (e) => this.onTouch(e));
|
||||||
this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(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();
|
this.redraw();
|
||||||
|
|
||||||
loadAllSprites();
|
loadAllSprites();
|
||||||
@@ -139,9 +154,24 @@ export class UnitLayer implements Layer {
|
|||||||
}
|
}
|
||||||
if (!this.game.isWater(clickRef)) return;
|
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) {
|
if (this.selectedUnit) {
|
||||||
this.eventBus.emit(
|
this.eventBus.emit(
|
||||||
new MoveWarshipIntentEvent(this.selectedUnit.id(), clickRef),
|
new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef),
|
||||||
);
|
);
|
||||||
// Deselect
|
// Deselect
|
||||||
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
|
this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false));
|
||||||
@@ -187,6 +217,12 @@ export class UnitLayer implements Layer {
|
|||||||
return;
|
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);
|
const nearbyWarships = this.findWarshipsNearCell(clickRef);
|
||||||
|
|
||||||
if (nearbyWarships.length > 0) {
|
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
|
* Handle unit deactivation or destruction
|
||||||
* If the selected unit is removed from the game, deselect it
|
* If the selected unit is removed from the game, deselect it
|
||||||
|
|||||||
+1
-1
@@ -416,7 +416,7 @@ export const CancelBoatIntentSchema = z.object({
|
|||||||
|
|
||||||
export const MoveWarshipIntentSchema = z.object({
|
export const MoveWarshipIntentSchema = z.object({
|
||||||
type: z.literal("move_warship"),
|
type: z.literal("move_warship"),
|
||||||
unitId: z.number(),
|
unitIds: z.array(z.number().int()).nonempty(),
|
||||||
tile: z.number(),
|
tile: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export class Executor {
|
|||||||
case "cancel_boat":
|
case "cancel_boat":
|
||||||
return new BoatRetreatExecution(player, intent.unitID);
|
return new BoatRetreatExecution(player, intent.unitID);
|
||||||
case "move_warship":
|
case "move_warship":
|
||||||
return new MoveWarshipExecution(player, intent.unitId, intent.tile);
|
return new MoveWarshipExecution(player, intent.unitIds, intent.tile);
|
||||||
case "spawn":
|
case "spawn":
|
||||||
return new SpawnExecution(this.gameID, player.info(), intent.tile);
|
return new SpawnExecution(this.gameID, player.info(), intent.tile);
|
||||||
case "boat":
|
case "boat":
|
||||||
|
|||||||
@@ -4,31 +4,36 @@ import { TileRef } from "../game/GameMap";
|
|||||||
export class MoveWarshipExecution implements Execution {
|
export class MoveWarshipExecution implements Execution {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly owner: Player,
|
private readonly owner: Player,
|
||||||
private readonly unitId: number,
|
private readonly unitIds: number[],
|
||||||
private readonly position: TileRef,
|
private readonly position: TileRef,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
init(mg: Game, ticks: number): void {
|
init(mg: Game, _ticks: number): void {
|
||||||
if (!mg.isValidRef(this.position)) {
|
if (!mg.isValidRef(this.position)) {
|
||||||
console.warn(`MoveWarshipExecution: position ${this.position} not valid`);
|
console.warn(`MoveWarshipExecution: position ${this.position} not valid`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const warship = this.owner
|
// Cache warship list and build a lookup map — avoids repeated iteration
|
||||||
.units(UnitType.Warship)
|
const warshipMap = new Map(
|
||||||
.find((u) => u.id() === this.unitId);
|
this.owner.units(UnitType.Warship).map((u) => [u.id(), u]),
|
||||||
if (!warship) {
|
);
|
||||||
console.warn("MoveWarshipExecution: warship not found");
|
// Deduplicate ids so each warship is only moved once
|
||||||
return;
|
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 {
|
isActive(): boolean {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export function getDefaultKeybinds(isMac: boolean): Record<string, string> {
|
|||||||
altKey: "AltLeft",
|
altKey: "AltLeft",
|
||||||
shiftKey: "ShiftLeft",
|
shiftKey: "ShiftLeft",
|
||||||
resetGfx: "KeyR",
|
resetGfx: "KeyR",
|
||||||
|
selectAllWarships: "KeyF",
|
||||||
pauseGame: "KeyP",
|
pauseGame: "KeyP",
|
||||||
gameSpeedUp: "Period",
|
gameSpeedUp: "Period",
|
||||||
gameSpeedDown: "Comma",
|
gameSpeedDown: "Comma",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import {
|
|||||||
AutoUpgradeEvent,
|
AutoUpgradeEvent,
|
||||||
ConfirmGhostStructureEvent,
|
ConfirmGhostStructureEvent,
|
||||||
InputHandler,
|
InputHandler,
|
||||||
|
WarshipSelectionBoxCancelEvent,
|
||||||
|
WarshipSelectionBoxCompleteEvent,
|
||||||
|
WarshipSelectionBoxUpdateEvent,
|
||||||
} from "../src/client/InputHandler";
|
} from "../src/client/InputHandler";
|
||||||
import { UIState } from "../src/client/graphics/UIState";
|
import { UIState } from "../src/client/graphics/UIState";
|
||||||
import { EventBus } from "../src/core/EventBus";
|
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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -164,7 +164,11 @@ describe("Warship", () => {
|
|||||||
game.addExecution(new WarshipExecution(warship));
|
game.addExecution(new WarshipExecution(warship));
|
||||||
|
|
||||||
game.addExecution(
|
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);
|
executeTicks(game, 10);
|
||||||
@@ -244,7 +248,7 @@ describe("Warship", () => {
|
|||||||
);
|
);
|
||||||
new MoveWarshipExecution(
|
new MoveWarshipExecution(
|
||||||
player2,
|
player2,
|
||||||
warship.id(),
|
[warship.id()],
|
||||||
game.ref(coastX + 5, 15),
|
game.ref(coastX + 5, 15),
|
||||||
).init(game, 0);
|
).init(game, 0);
|
||||||
expect(warship.patrolTile()).toBe(originalPatrolTile);
|
expect(warship.patrolTile()).toBe(originalPatrolTile);
|
||||||
@@ -262,7 +266,7 @@ describe("Warship", () => {
|
|||||||
warship.delete();
|
warship.delete();
|
||||||
new MoveWarshipExecution(
|
new MoveWarshipExecution(
|
||||||
player1,
|
player1,
|
||||||
warship.id(),
|
[warship.id()],
|
||||||
game.ref(coastX + 5, 15),
|
game.ref(coastX + 5, 15),
|
||||||
).init(game, 0);
|
).init(game, 0);
|
||||||
expect(warship.patrolTile()).toBe(originalPatrolTile);
|
expect(warship.patrolTile()).toBe(originalPatrolTile);
|
||||||
@@ -271,7 +275,7 @@ describe("Warship", () => {
|
|||||||
test("MoveWarshipExecution fails gracefully if warship not found", async () => {
|
test("MoveWarshipExecution fails gracefully if warship not found", async () => {
|
||||||
const exec = new MoveWarshipExecution(
|
const exec = new MoveWarshipExecution(
|
||||||
player1,
|
player1,
|
||||||
123,
|
[123],
|
||||||
game.ref(coastX + 5, 15),
|
game.ref(coastX + 5, 15),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user