Grouped the unit stack by type and let ranged units pick a target
This commit is contained in:
+70
-5
@@ -157,6 +157,12 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.aero:disabled { opacity: 0.4; cursor: default; filter: none; }
|
||||
.aero.primary { background: linear-gradient(180deg, rgba(110, 175, 255, 0.7), rgba(40, 95, 175, 0.9)); }
|
||||
.aero.big { display: block; width: 100%; margin: 10px 0; padding: 14px; font-size: 17px; }
|
||||
.aero.attack { background: linear-gradient(180deg, rgba(255, 160, 90, 0.85), rgba(175, 80, 25, 0.95)); border-color: rgba(255, 180, 120, 0.8); }
|
||||
.aero.attack.active {
|
||||
background: linear-gradient(180deg, rgba(255, 120, 90, 0.95), rgba(150, 30, 25, 0.98));
|
||||
border-color: rgba(255, 150, 120, 0.9);
|
||||
box-shadow: 0 0 0 1px rgba(255, 90, 70, 0.6), 0 0 14px rgba(255, 70, 50, 0.6);
|
||||
}
|
||||
|
||||
.status { min-height: 20px; margin: 14px 0 0; color: #ffcf7a; text-align: center; }
|
||||
|
||||
@@ -315,7 +321,7 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
/* A ranged unit's bombardment: a red dashed parabolic arc from the battery to
|
||||
the target and a red ring around the target. Drawn on the layer below the
|
||||
entity icons so the units themselves stay on top. */
|
||||
.target-arcs {
|
||||
.target-arcs, .targeting-arcs {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
@@ -357,6 +363,22 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
/* A barrage the player is looking at stands out from the rest. */
|
||||
.target-line.target-selected, .target-ring.target-selected { opacity: 1; }
|
||||
|
||||
/* The reach shown while picking a ranged target: a solid red line along the
|
||||
boundary of the tiles the selected stack can hit. */
|
||||
.range-boundary {
|
||||
fill: none;
|
||||
stroke: #ff2b2b;
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7));
|
||||
}
|
||||
/* Picking a target takes over the cursor. */
|
||||
#map-viewport.targeting { cursor: crosshair; }
|
||||
#map-viewport.targeting.dragging { cursor: grabbing; }
|
||||
|
||||
/* The delivery graph, shown only in the economic map modes: a white line per
|
||||
delivery leg, an animated merchandise icon riding it and a dot on every
|
||||
storage node. */
|
||||
@@ -1109,10 +1131,53 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
#unit-health-label { margin-top: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.unit-cargo { margin-top: 6px; font-size: 12px; color: var(--muted); }
|
||||
.unit-stack-list { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; max-height: 140px; overflow-y: auto; }
|
||||
.stack-unit { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; }
|
||||
.stack-unit-name { flex: 1; }
|
||||
.stack-unit-hp { color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
/* The selected stack, grouped one unit type per line. A type line shows only
|
||||
the type icon and its count (the unit name is the icon's tooltip); beneath it
|
||||
sit that type's unit cards, each an opaque icon toggle carrying its own
|
||||
healthbar. The cards overlap just enough to fit the line and not at all when
|
||||
they already fit, and the group spreads out when it is clicked. */
|
||||
.unit-stack-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; max-height: 200px; overflow-y: auto; }
|
||||
.stack-type-row { display: flex; align-items: center; gap: 8px; padding: 2px; border-radius: 6px; cursor: pointer; }
|
||||
.stack-type-row:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
.stack-type-label { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
|
||||
.stack-type-icon { width: 22px; height: 22px; object-fit: contain; }
|
||||
.stack-type-count { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.stack-type-units { display: flex; flex-wrap: nowrap; flex: 1 1 auto; min-width: 0; }
|
||||
/* The tri-state group tick: selects or excludes the whole type in one click and
|
||||
doubles as the group's readout (all / some / none). */
|
||||
.stack-group-check {
|
||||
width: 16px; height: 16px; flex: 0 0 auto; padding: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1px solid #46566e; border-radius: 4px; background: #101720;
|
||||
color: #eaf2fc; font-size: 11px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.stack-group-check:hover { border-color: #6eafff; }
|
||||
.stack-group-check.all { background: #3f6fa8; border-color: #6eafff; }
|
||||
.stack-group-check.some { background: #24384f; border-color: rgba(110, 175, 255, 0.7); }
|
||||
.stack-group-check.none { color: transparent; }
|
||||
/* The disclosure caret at the far right; the line's blank space toggles too. */
|
||||
.stack-type-caret { flex: 0 0 auto; width: 14px; text-align: center; color: var(--muted); font-size: 11px; user-select: none; }
|
||||
.stack-unit-tile {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||
width: 42px; padding: 4px 3px; border: 1px solid #0d1218; border-radius: 6px;
|
||||
background: #1c1c1c; color: inherit; cursor: pointer; flex: 0 0 auto;
|
||||
position: relative;
|
||||
/* Left cards sit on top, so the deck casts its shadow to the right. */
|
||||
box-shadow: 3px 0 6px rgba(0, 0, 0, 0.55);
|
||||
transition: filter 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.stack-unit-tile:hover { background: #2a2a2a; z-index: 999 !important; }
|
||||
.stack-unit-tile:not(.active) { background: #151515; filter: grayscale(0.85) brightness(0.7); }
|
||||
.stack-unit-tile.active { background: #24384f; border-color: rgba(110, 175, 255, 0.9); }
|
||||
.stack-unit-tile.active:hover { background: #2c4665; }
|
||||
.stack-unit-icon { width: 26px; height: 26px; object-fit: contain; }
|
||||
.stack-unit-bar { width: 100%; height: 4px; border-radius: 2px; background: rgba(60, 0, 0, 0.8); overflow: hidden; }
|
||||
.stack-unit-fill { display: block; height: 100%; background: linear-gradient(180deg, #6fe08a, #2fa04d); }
|
||||
.stack-type-row.expanded { align-items: flex-start; }
|
||||
.stack-type-row.expanded .stack-type-label { padding-top: 6px; }
|
||||
.stack-type-row.expanded .stack-type-units { flex-wrap: wrap; gap: 4px; padding-top: 2px; }
|
||||
.stack-type-row.expanded .stack-unit-tile { margin-left: 0 !important; box-shadow: none; }
|
||||
.stack-type-row.expanded .stack-type-caret { transform: rotate(90deg); }
|
||||
|
||||
.unit-stats { display: flex; gap: 16px; margin-top: 6px; }
|
||||
.unit-stat {
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
<div class="layer" id="layer-highlight"></div>
|
||||
<div class="layer" id="layer-paths"></div>
|
||||
<div class="layer" id="layer-targets"></div>
|
||||
<div class="layer" id="layer-targeting"></div>
|
||||
<div class="layer" id="layer-resources"></div>
|
||||
<div class="layer" id="layer-entities"></div>
|
||||
<div class="layer city-label-layer" id="layer-labels"></div>
|
||||
@@ -215,6 +216,7 @@
|
||||
<div class="row">
|
||||
<button id="unit-pillage" class="aero hidden">Pillage</button>
|
||||
<button id="unit-trench" class="aero hidden">Dig trench</button>
|
||||
<button id="unit-attack-order" class="aero attack hidden">Attack</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
// Generated by scripts/generate-devlog.js from `git log`; do not edit.
|
||||
// The pre-commit hook refreshes it so the main menu shows the latest commits.
|
||||
export const DEVLOG = [
|
||||
{
|
||||
"hash": "1c6f0ae",
|
||||
"date": "2026-09-24",
|
||||
"subject": "Cut the per-broadcast and per-frame cost on a full-sized map",
|
||||
"body": "The server sent the whole army on every snapshot and the client re-cloned\nthe whole icon layer whenever a unit moved, so a settled world with a\nstanding army drained the broadcast loop and the frame loop alike.\n\n- Ship only the units and city list that changed, keyed by a per-peer\n fingerprint and a serialised hash, and merge those deltas on the client.\n- Memoise the battlefield scan on a battle version bumped when a unit\n joins, leaves or changes side.\n- Skip the DOM writes for units whose snapshot object is unchanged, and\n cache the unit-view inner nodes.\n- Fold units, cities and tile markers onto the nearest world copy instead\n of mirroring the icon layers, which re-cloned thousands of views every\n frame; the small decoration layers still mirror, and only on a change.\n\nFrame work at full zoom-out with units moving fell from ~190ms to ~6.5ms."
|
||||
},
|
||||
{
|
||||
"hash": "822c26f",
|
||||
"date": "2026-09-23",
|
||||
@@ -54,11 +60,5 @@ export const DEVLOG = [
|
||||
"date": "2026-09-23",
|
||||
"subject": "Gave figures a unified signed display and templated the resource table",
|
||||
"body": ""
|
||||
},
|
||||
{
|
||||
"hash": "35c46ab",
|
||||
"date": "2026-09-23",
|
||||
"subject": "Halved the test suite's runtime and pruned trivial tests",
|
||||
"body": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -77,6 +77,12 @@ export class GameScreen {
|
||||
// Previous snapshot's strike state per unit, to spot a plane's bomb run
|
||||
// ending (see `_detectAirStrikes`).
|
||||
this._airStrikeState = null;
|
||||
// While set, the map is picking a ranged-strike target: { ids } of the
|
||||
// units that will fire. See the Attack action in the unit panel.
|
||||
this._targeting = null;
|
||||
// The whole stack the unit panel is showing, so cards deselected in the
|
||||
// panel stay visible (and can be re-ticked) instead of vanishing.
|
||||
this._stackMembers = [];
|
||||
// The capability report is shown at most once per join, when the GPU map
|
||||
// renderer was not used (see `_reportCapabilities`).
|
||||
this._capabilitiesReported = false;
|
||||
@@ -340,6 +346,8 @@ export class GameScreen {
|
||||
};
|
||||
this.map.onTileRequested = (coords) => this._showTile(coords);
|
||||
this.map.onStackMenu = (coords, ids, x, y) => this._openStackMenu(coords, ids, x, y);
|
||||
this.map.onTargetChosen = (coords) => this._chooseTarget(coords);
|
||||
this.map.onTargetCancelled = () => this._endTargeting();
|
||||
this.map.onMoveOrdered = (unitIds, coords) => {
|
||||
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
|
||||
if (units.length === 0) return;
|
||||
@@ -348,11 +356,6 @@ export class GameScreen {
|
||||
if (this._tryTransportOrder(units, coords)) return null;
|
||||
return this._orderMove(units, coords);
|
||||
};
|
||||
this.map.onAttackOrdered = (unitIds, coords) => {
|
||||
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
|
||||
if (units.length === 0) return;
|
||||
return this._orderMove(units, coords, "attack");
|
||||
};
|
||||
this.map.onScheduleOrdered = (unitIds, coords) => {
|
||||
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
|
||||
if (units.length === 0) return;
|
||||
@@ -531,8 +534,13 @@ export class GameScreen {
|
||||
this.map._applyCamera();
|
||||
break;
|
||||
case "Escape": {
|
||||
// ESC closes an open modal first; over the map it clears the selection
|
||||
// and the inspected tile, dismissing both bottom panels.
|
||||
// ESC leaves target-picking first; then it closes an open modal, and
|
||||
// over the map it clears the selection and the inspected tile.
|
||||
if (this._targeting) {
|
||||
this._endTargeting();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const modalOpen = $(".modal").not(".hidden").length > 0;
|
||||
this.cityModal.close();
|
||||
this.nationModal.close();
|
||||
|
||||
+267
-44
@@ -110,6 +110,7 @@ export const panelMethods = {
|
||||
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
|
||||
.on("mouseleave", () => this._hideStatTooltip());
|
||||
$("#unit-trench").on("click", () => this._digTrenchSelected());
|
||||
$("#unit-attack-order").on("click", () => this._toggleTargeting());
|
||||
|
||||
// Every summary stat explains itself on hover through the shared bubble.
|
||||
$("#top-panel .stat")
|
||||
@@ -157,7 +158,13 @@ export const panelMethods = {
|
||||
const unit = this._unit(id);
|
||||
if (unit && unit.civ === this.localCiv) units.push(unit);
|
||||
}
|
||||
// A new selection is not the stack that was picking a target, so leave
|
||||
// target mode rather than firing at the wrong tile.
|
||||
if (this._targeting) this._endTargeting();
|
||||
this.selectedUnitIds = units.map((unit) => unit.id);
|
||||
// The panel keeps showing the whole stack the selection sits on, so a unit
|
||||
// the player unticks remains as a card to tick back on.
|
||||
this._stackMembers = this._stackForSelection(units);
|
||||
this.selectedCityId = 0;
|
||||
if (units.length === 0) {
|
||||
// Clearing the selection also drops the inspected tile, so the tile panel
|
||||
@@ -172,6 +179,27 @@ export const panelMethods = {
|
||||
this._updateTileHighlight();
|
||||
},
|
||||
|
||||
// The ids of the whole friendly stack `units` stands on, so the panel can keep
|
||||
// the deselected cards around. Units spread across tiles (or a lone unit) are
|
||||
// just themselves.
|
||||
_stackForSelection(units) {
|
||||
if (units.length === 0) return [];
|
||||
const coords = units[0].coords;
|
||||
const sameTile = units.every(
|
||||
(unit) => unit.coords[0] === coords[0] && unit.coords[1] === coords[1]
|
||||
);
|
||||
if (!sameTile) return units.map((unit) => unit.id);
|
||||
const ids = this.map.stackIds(units[0].id).filter((id) => {
|
||||
const data = this._unit(id);
|
||||
return data && data.civ === this.localCiv;
|
||||
});
|
||||
return ids.length > 1 ? ids : units.map((unit) => unit.id);
|
||||
},
|
||||
|
||||
_stackMemberUnits() {
|
||||
return (this._stackMembers || []).map((id) => this._unit(id)).filter(Boolean);
|
||||
},
|
||||
|
||||
_selectedUnits() {
|
||||
return this.selectedUnitIds.map((id) => this._unit(id)).filter(Boolean);
|
||||
},
|
||||
@@ -343,6 +371,62 @@ export const panelMethods = {
|
||||
this.sendOrder({ type: "dig_trench", units: units.map((unit) => unit.id) });
|
||||
},
|
||||
|
||||
// The Attack action, offered only when every selected unit is the same type
|
||||
// and that type strikes at range. It enters target-picking mode rather than
|
||||
// firing straight away.
|
||||
_updateAttackButton(units) {
|
||||
const $button = $("#unit-attack-order");
|
||||
if (!$button.length) return;
|
||||
const singleType = units.length > 0 &&
|
||||
units.every((unit) => unit.proto === units[0].proto) &&
|
||||
this._isRangedUnit(units[0]);
|
||||
const active = singleType && !!this._targeting;
|
||||
$button.toggleClass("hidden", !singleType);
|
||||
$button.toggleClass("active", active);
|
||||
$button.text(active ? "Attack — pick a target" : "Attack");
|
||||
},
|
||||
|
||||
// Starts or cancels target-picking for the current ranged stack.
|
||||
_toggleTargeting() {
|
||||
if (this._targeting) {
|
||||
this._endTargeting();
|
||||
return;
|
||||
}
|
||||
const units = this._selectedUnits();
|
||||
if (units.length === 0) return;
|
||||
if (!units.every((unit) => unit.proto === units[0].proto)) return;
|
||||
if (!this._isRangedUnit(units[0])) return;
|
||||
const proto = this.protoUnits[units[0].proto];
|
||||
const ids = units.map((unit) => unit.id);
|
||||
this._targeting = { ids };
|
||||
this.map.beginTargeting({
|
||||
unitIds: ids,
|
||||
origin: { x: units[0].coords[0], y: units[0].coords[1] },
|
||||
range: proto.missionRange || proto.range || 1,
|
||||
globalRange: !!proto.globalRange,
|
||||
});
|
||||
this._updateAttackButton(units);
|
||||
},
|
||||
|
||||
_endTargeting() {
|
||||
if (!this._targeting) return;
|
||||
this._targeting = null;
|
||||
this.map.clearTargeting();
|
||||
this._updateAttackButton(this._selectedUnits());
|
||||
},
|
||||
|
||||
// A left-click in target-picking mode: fire when the tile holds a hostile
|
||||
// military unit, otherwise just leave the mode.
|
||||
_chooseTarget(coords) {
|
||||
const targeting = this._targeting;
|
||||
if (!targeting) return;
|
||||
const units = targeting.ids.map((id) => this._unit(id)).filter(Boolean);
|
||||
this._endTargeting();
|
||||
if (units.length === 0) return;
|
||||
if (!this._isEnemyMilitaryAt(coords)) return;
|
||||
this._orderMove(units, coords, "attack");
|
||||
},
|
||||
|
||||
_isMilitaryUnit(unit) {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
return !!(proto && proto.military);
|
||||
@@ -647,7 +731,7 @@ export const panelMethods = {
|
||||
},
|
||||
|
||||
_updateBottomPanel() {
|
||||
if (this.selectedUnitIds.length > 0) {
|
||||
if (this.selectedUnitIds.length > 0 || this._stackMemberUnits().length > 1) {
|
||||
this._showUnits(this._selectedUnits());
|
||||
} else if (this.selectedCityId) {
|
||||
const city = this._city(this.selectedCityId);
|
||||
@@ -659,9 +743,40 @@ export const panelMethods = {
|
||||
}
|
||||
},
|
||||
|
||||
// Shows the bottom panel for one or many selected units. A stack shows its
|
||||
// size and combined health, plus the pillage action when it can use it.
|
||||
// Shows the bottom panel for the selected units. When they share a stack the
|
||||
// panel lists every unit of it (so deselecting one keeps its card around) and
|
||||
// reports the combined health of the ticked ones.
|
||||
_showUnits(units) {
|
||||
const members = this._stackMemberUnits();
|
||||
if (members.length > 1) {
|
||||
const counted = units.length > 0 ? units : members;
|
||||
const hp = counted.reduce((sum, unit) => sum + unit.hp, 0);
|
||||
const maxHp = counted.reduce((sum, unit) => sum + unit.maxHp, 0);
|
||||
if (units.length === 1) {
|
||||
$("#unit-name").text(this._unitLabel(units[0], this.protoUnits[units[0].proto]));
|
||||
} else if (units.length === 0) {
|
||||
$("#unit-name").text("No units selected");
|
||||
} else {
|
||||
$("#unit-name").text(`${units.length} units selected`);
|
||||
}
|
||||
const single = this._singleProto(units);
|
||||
if (single) {
|
||||
$("#unit-attack, #unit-defense").removeClass("hidden");
|
||||
this._showUnitStats(units[0], single);
|
||||
} else {
|
||||
$("#unit-attack, #unit-defense").addClass("hidden");
|
||||
}
|
||||
$("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`);
|
||||
$("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`);
|
||||
this._renderStackList(members);
|
||||
this._updatePillageButton(units);
|
||||
this._updateTrenchButton(units);
|
||||
this._updateAttackButton(units);
|
||||
this._updateCargo(units);
|
||||
$("#unit-info").removeClass("hidden");
|
||||
this._refreshPanelVisibility();
|
||||
return;
|
||||
}
|
||||
if (units.length === 0) {
|
||||
$("#unit-info").addClass("hidden");
|
||||
this._clearStackList();
|
||||
@@ -670,64 +785,171 @@ export const panelMethods = {
|
||||
this._refreshPanelVisibility();
|
||||
return;
|
||||
}
|
||||
if (units.length === 1) {
|
||||
this._showUnit(units[0]);
|
||||
return;
|
||||
}
|
||||
const hp = units.reduce((sum, unit) => sum + unit.hp, 0);
|
||||
const maxHp = units.reduce((sum, unit) => sum + unit.maxHp, 0);
|
||||
$("#unit-name").text(`${units.length} units selected`);
|
||||
$("#unit-attack, #unit-defense").addClass("hidden");
|
||||
$("#unit-health-fill").css("width", `${maxHp > 0 ? Math.min(100, (hp / maxHp) * 100) : 0}%`);
|
||||
$("#unit-health-label").text(`${Math.round(hp)} / ${Math.round(maxHp)} HP`);
|
||||
this._renderStackList(units);
|
||||
this._updatePillageButton(units);
|
||||
this._updateTrenchButton(units);
|
||||
this._updateCargo(units);
|
||||
$("#unit-info").removeClass("hidden");
|
||||
this._refreshPanelVisibility();
|
||||
this._showUnit(units[0]);
|
||||
},
|
||||
|
||||
// The stack panel's checklist: every member of the selected stack, ticked,
|
||||
// so the player can narrow the selection to the units they want to order.
|
||||
// The one proto every unit shares, or null when the selection is mixed.
|
||||
_singleProto(units) {
|
||||
if (units.length === 0) return null;
|
||||
if (!units.every((unit) => unit.proto === units[0].proto)) return null;
|
||||
return this.protoUnits[units[0].proto] || null;
|
||||
},
|
||||
|
||||
// The stack panel, grouped one unit type per line. A type line opens with a
|
||||
// tri-state tick (all / some / none) that selects or excludes the whole type,
|
||||
// then the type icon and count; clicking the line elsewhere spreads its cards
|
||||
// out. Each card is an opaque icon toggle with its own healthbar, and the
|
||||
// ticked ones sort to the front of their type's list.
|
||||
_renderStackList(units) {
|
||||
const $list = $("#unit-stack-list");
|
||||
if (!$list.length) return;
|
||||
$list.removeClass("hidden");
|
||||
const signature = units.map((unit) => unit.id).join(",");
|
||||
const active = new Set(this.selectedUnitIds);
|
||||
const signature = units.map((unit) => unit.id).join(",") +
|
||||
"|" + this.selectedUnitIds.join(",");
|
||||
if (signature !== this._stackListSignature) {
|
||||
this._stackListSignature = signature;
|
||||
$list.empty();
|
||||
const groups = new Map();
|
||||
for (const unit of units) {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
const $row = $("<label class='stack-unit'></label>");
|
||||
const $check = $("<input type='checkbox'>")
|
||||
.attr("data-id", unit.id)
|
||||
.prop("checked", true);
|
||||
$check.on("change", () => {
|
||||
const ids = [];
|
||||
$list.find("input:checked").each((_index, element) => {
|
||||
ids.push(Number($(element).attr("data-id")));
|
||||
if (!groups.has(unit.proto)) groups.set(unit.proto, []);
|
||||
groups.get(unit.proto).push(unit);
|
||||
}
|
||||
for (const [protoIndex, members] of groups) {
|
||||
const proto = this.protoUnits[protoIndex];
|
||||
const name = proto ? proto.name : "Unit";
|
||||
const $row = $("<div class='stack-type-row'></div>");
|
||||
|
||||
const activeCount = members.filter((unit) => active.has(unit.id)).length;
|
||||
const state = activeCount === 0 ? "none" : activeCount === members.length ? "all" : "some";
|
||||
const $check = $("<button type='button'></button>")
|
||||
.addClass(`stack-group-check ${state}`)
|
||||
.text(state === "all" ? "✓" : state === "some" ? "–" : "")
|
||||
.attr("title", state === "all" ? `Exclude all ${name}` : `Select all ${name}`);
|
||||
$check.on("click", (event) => {
|
||||
event.stopPropagation();
|
||||
$row.find(".stack-unit-tile").toggleClass("active", state !== "all");
|
||||
this._stackSelectionChanged();
|
||||
});
|
||||
$row.append($check);
|
||||
|
||||
const $label = $("<div class='stack-type-label'></div>");
|
||||
if (proto) {
|
||||
$label.append(
|
||||
$("<img class='stack-type-icon'>")
|
||||
.attr("src", `assets/${proto.icon}`)
|
||||
.attr("alt", name)
|
||||
.attr("title", name)
|
||||
);
|
||||
}
|
||||
$label.append($("<span class='stack-type-count'></span>").text(`×${members.length}`));
|
||||
$row.append($label);
|
||||
|
||||
const $cards = $("<div class='stack-type-units'></div>");
|
||||
// Ticked units move to the front of their type's list.
|
||||
const ordered = members
|
||||
.slice()
|
||||
.sort((a, b) => (active.has(b.id) ? 1 : 0) - (active.has(a.id) ? 1 : 0));
|
||||
for (const unit of ordered) {
|
||||
const $card = $("<button type='button'></button>")
|
||||
.addClass(active.has(unit.id) ? "stack-unit-tile active" : "stack-unit-tile")
|
||||
.attr("data-id", unit.id)
|
||||
.attr("title", `${name} — ${Math.round(unit.hp)}/${unit.maxHp}`);
|
||||
if (proto) {
|
||||
$card.append(
|
||||
$("<img class='stack-unit-icon'>").attr("src", `assets/${proto.icon}`).attr("alt", name)
|
||||
);
|
||||
}
|
||||
$card.append(
|
||||
$("<span class='stack-unit-bar'></span>").append(
|
||||
$("<span class='stack-unit-fill'></span>").css("width", `${this._healthPercent(unit)}%`)
|
||||
)
|
||||
);
|
||||
$card.on("click", () => {
|
||||
$card.toggleClass("active");
|
||||
this._stackSelectionChanged();
|
||||
});
|
||||
this._selectUnits(ids);
|
||||
$cards.append($card);
|
||||
}
|
||||
$row.append($cards);
|
||||
|
||||
$row.append($("<span class='stack-type-caret'></span>").text("▸"));
|
||||
$row.on("click", (event) => {
|
||||
if ($(event.target).closest(".stack-unit-tile").length) return;
|
||||
if ($(event.target).closest(".stack-group-check").length) return;
|
||||
$row.toggleClass("expanded");
|
||||
this._layoutStackDecks();
|
||||
});
|
||||
$row.append(
|
||||
$check,
|
||||
$("<span class='stack-unit-name'></span>").text(proto ? proto.name : "Unit"),
|
||||
$("<span class='stack-unit-hp'></span>").text(
|
||||
`${Math.round(unit.hp)}/${unit.maxHp}`
|
||||
)
|
||||
);
|
||||
$list.append($row);
|
||||
}
|
||||
this._layoutStackDecks();
|
||||
}
|
||||
// Keep the hit-point readouts current even when the set did not change.
|
||||
units.forEach((unit, index) => {
|
||||
// Keep the healthbars and tooltips current even when the set did not change.
|
||||
for (const unit of units) {
|
||||
const proto = this.protoUnits[unit.proto];
|
||||
const name = proto ? proto.name : "Unit";
|
||||
$list
|
||||
.find(".stack-unit-hp")
|
||||
.eq(index)
|
||||
.text(`${Math.round(unit.hp)}/${unit.maxHp}`);
|
||||
.find(`.stack-unit-tile[data-id='${unit.id}']`)
|
||||
.attr("title", `${name} — ${Math.round(unit.hp)}/${unit.maxHp}`)
|
||||
.find(".stack-unit-fill")
|
||||
.css("width", `${this._healthPercent(unit)}%`);
|
||||
}
|
||||
},
|
||||
|
||||
_healthPercent(unit) {
|
||||
if (!unit || !unit.maxHp) return 0;
|
||||
return Math.max(0, Math.min(100, (unit.hp / unit.maxHp) * 100));
|
||||
},
|
||||
|
||||
// Overlaps a type's cards by just enough to keep them inside the row; cards
|
||||
// that already fit are left side by side. An expanded group is left alone
|
||||
// (its cards wrap instead).
|
||||
_layoutStackDecks() {
|
||||
if (typeof document === "undefined") return;
|
||||
const rows = document.querySelectorAll("#unit-stack-list .stack-type-row");
|
||||
for (const row of rows) this._layoutStackDeck(row);
|
||||
},
|
||||
|
||||
_layoutStackDeck(row) {
|
||||
if (row.classList.contains("expanded")) {
|
||||
for (const card of row.querySelectorAll(".stack-unit-tile")) card.style.zIndex = "";
|
||||
return;
|
||||
}
|
||||
const units = row.querySelector(".stack-type-units");
|
||||
const cards = units ? Array.from(units.children) : [];
|
||||
if (cards.length < 2) return;
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
cards[i].style.marginLeft = "";
|
||||
// Cards to the left sit on top, so the deck reads left-to-right.
|
||||
cards[i].style.zIndex = String(cards.length - i);
|
||||
}
|
||||
const cardW = cards[0].getBoundingClientRect().width;
|
||||
const avail = units.getBoundingClientRect().width;
|
||||
const needed = cards.length * cardW;
|
||||
if (cardW <= 0 || avail <= 0 || needed <= avail) return;
|
||||
const overlap = (needed - avail) / (cards.length - 1);
|
||||
for (let i = 1; i < cards.length; i++) cards[i].style.marginLeft = `${-overlap}px`;
|
||||
},
|
||||
|
||||
// Reads the ticked cards back into the selection without dropping the rest of
|
||||
// the stack, so unticking a unit leaves its card in place (and moves it to the
|
||||
// back of its type's list).
|
||||
_stackSelectionChanged() {
|
||||
const $list = $("#unit-stack-list");
|
||||
const members = [];
|
||||
const active = [];
|
||||
$list.find(".stack-unit-tile").each((_index, element) => {
|
||||
const id = Number($(element).attr("data-id"));
|
||||
members.push(id);
|
||||
if ($(element).hasClass("active")) active.push(id);
|
||||
});
|
||||
this._stackMembers = members;
|
||||
this.selectedUnitIds = active;
|
||||
this.map.setSelectedUnits(active);
|
||||
this._hideStackMenu();
|
||||
this._updateTileHighlight();
|
||||
this._stackListSignature = null;
|
||||
this._showUnits(this._selectedUnits());
|
||||
},
|
||||
|
||||
_clearStackList() {
|
||||
@@ -754,6 +976,7 @@ export const panelMethods = {
|
||||
$("#unit-health-label").text(`${Math.round(unit.hp)} / ${Math.round(unit.maxHp)} HP`);
|
||||
this._updatePillageButton([unit]);
|
||||
this._updateTrenchButton([unit]);
|
||||
this._updateAttackButton([unit]);
|
||||
this._updateCargo([unit]);
|
||||
$("#unit-info").removeClass("hidden");
|
||||
this._refreshPanelVisibility();
|
||||
|
||||
+23
-2
@@ -47,6 +47,17 @@ export class MapView {
|
||||
this.$targets = layers.targets && layers.targets.length
|
||||
? layers.targets
|
||||
: $("#layer-targets");
|
||||
// The red reach boundary, target circle and barrage arc drawn while the
|
||||
// player is picking a ranged target. Above the standing barrages, below the
|
||||
// unit and city icons.
|
||||
this.$targeting = layers.targeting && layers.targeting.length
|
||||
? layers.targeting
|
||||
: $("#layer-targeting");
|
||||
if (!this.$targeting.length) {
|
||||
this.$targeting = $('<div id="layer-targeting" class="layer"></div>');
|
||||
if (this.$targets && this.$targets.length) this.$targets.after(this.$targeting);
|
||||
else this.$world.append(this.$targeting);
|
||||
}
|
||||
// Resource storage nodes and delivery legs, drawn in the economic map
|
||||
// modes so the delivery graph is never visible on the political map.
|
||||
this.$resources = layers.resources && layers.resources.length
|
||||
@@ -97,7 +108,7 @@ export class MapView {
|
||||
this.$viewport[0].style.setProperty("--camera-tilt", String(CAMERA_TILT));
|
||||
for (const $layer of [
|
||||
this.$terrain, this.$roads, this.$borders, this.$fog,
|
||||
this.$highlight, this.$paths, this.$targets, this.$resources,
|
||||
this.$highlight, this.$paths, this.$targets, this.$targeting, this.$resources,
|
||||
]) {
|
||||
if ($layer && $layer.length) $layer.addClass("tilted");
|
||||
}
|
||||
@@ -186,6 +197,15 @@ export class MapView {
|
||||
this._targetSvg = null;
|
||||
this._strikeTick = null;
|
||||
this._strikeSignature = "";
|
||||
// Target-picking overlay: the active request, the tile the pointer is over,
|
||||
// the reachable-tile set and the reused boundary/arc/ring nodes.
|
||||
this._targeting = null;
|
||||
this._targetHover = null;
|
||||
this._rangeTiles = null;
|
||||
this._targetingSvg = null;
|
||||
this._rangeBoundary = null;
|
||||
this._rangeArc = null;
|
||||
this._rangeRing = null;
|
||||
// In-flight pathfinding job, cancelled when a newer request supersedes it.
|
||||
this._pathToken = null;
|
||||
this._maxStepLength = 1.0;
|
||||
@@ -228,9 +248,10 @@ export class MapView {
|
||||
this.onCityOpened = () => {};
|
||||
this.onTileRequested = () => {};
|
||||
this.onMoveOrdered = () => {};
|
||||
this.onAttackOrdered = () => {};
|
||||
this.onScheduleOrdered = () => {};
|
||||
this.onStackMenu = () => {};
|
||||
this.onTargetChosen = () => {};
|
||||
this.onTargetCancelled = () => {};
|
||||
|
||||
// The map (terrain, roads, borders, fog) is drawn on a WebGL canvas when
|
||||
// the browser provides a context; the icon layers stay DOM.
|
||||
|
||||
+19
-22
@@ -92,7 +92,10 @@ export const inputMethods = {
|
||||
});
|
||||
|
||||
$(window).on("mousemove", (event) => {
|
||||
if (!dragging) return;
|
||||
if (!dragging) {
|
||||
if (this._targeting) this._onTargetingHover(event);
|
||||
return;
|
||||
}
|
||||
if (Math.abs(event.clientX - pressX) > DRAG_THRESHOLD ||
|
||||
Math.abs(event.clientY - pressY) > DRAG_THRESHOLD) {
|
||||
dragged = true;
|
||||
@@ -135,6 +138,12 @@ export const inputMethods = {
|
||||
// ------------------------------------------------------------ clicks --
|
||||
|
||||
_onLeftClick(coords) {
|
||||
// While picking a ranged target a left-click chooses it; the controller
|
||||
// decides whether the tile is a valid target.
|
||||
if (this._targeting) {
|
||||
this.onTargetChosen(coords);
|
||||
return;
|
||||
}
|
||||
const unitId = this.unitAt(coords);
|
||||
if (unitId && !this._selectedUnitIds.has(unitId)) {
|
||||
this.onUnitSelected(unitId);
|
||||
@@ -150,19 +159,21 @@ export const inputMethods = {
|
||||
},
|
||||
|
||||
// With a selection, right-clicking orders it where it clicked: Ctrl queues a
|
||||
// scheduled destination, a hostile stack or city is attacked, and anything
|
||||
// else -- including a friendly stack, which we merge into -- is a move. With
|
||||
// no selection, right-clicking a friendly stack opens its menu and any other
|
||||
// tile is inspected.
|
||||
// scheduled destination and anything else -- including a friendly stack, which
|
||||
// we merge into -- is a move. A hostile stack is attacked by moving onto it
|
||||
// (the server turns the march into a fight); ranged fire is issued through
|
||||
// the unit panel's Attack action instead. With no selection, right-clicking a
|
||||
// friendly stack opens its menu and any other tile is inspected.
|
||||
_onRightClick(coords, event) {
|
||||
if (this._targeting) {
|
||||
this.onTargetCancelled();
|
||||
return;
|
||||
}
|
||||
const selected = Array.from(this._selectedUnitIds);
|
||||
if (selected.length > 0) {
|
||||
if (event && (event.ctrlKey || event.metaKey)) {
|
||||
return this.onScheduleOrdered(selected, coords);
|
||||
}
|
||||
if (this._isEnemyTarget(coords)) {
|
||||
return this.onAttackOrdered(selected, coords);
|
||||
}
|
||||
return this.onMoveOrdered(selected, coords);
|
||||
}
|
||||
const friendly = this.unitIdsAt(coords).filter(
|
||||
@@ -189,20 +200,6 @@ export const inputMethods = {
|
||||
);
|
||||
},
|
||||
|
||||
// Whether the tile holds a hostile unit stack or an enemy city, so a right
|
||||
// click with our stack selected becomes an attack rather than a march.
|
||||
_isEnemyTarget(coords) {
|
||||
const enemy = this.unitIdsAt(coords).some((id) => {
|
||||
const data = this._unitData.get(id);
|
||||
return data && data.civ !== this.localCiv;
|
||||
});
|
||||
if (enemy) return true;
|
||||
const k = key(coords.x, coords.y);
|
||||
if (!this.visible.has(k)) return false;
|
||||
const cityCiv = this._cityCiv.get(k);
|
||||
return cityCiv !== undefined && cityCiv !== this.localCiv;
|
||||
},
|
||||
|
||||
// --------------------------------------------------------- selection --
|
||||
|
||||
setSelectedUnits(ids) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Dead-reckoning unit motion between snapshots, path drawing and the local
|
||||
// move prediction that mirrors GameState.find_path.
|
||||
|
||||
import { mapToLocal, key } from "../../../shared/hex.js";
|
||||
import { mapToLocal, key, parseKey } from "../../../shared/hex.js";
|
||||
import { HexPathfinder } from "../../../shared/hex_pathfinder.js";
|
||||
import { TRANSPORT_BY_ID } from "../../../shared/data/improvements.js";
|
||||
import { CONTROLLED_SPEED_MULTIPLIER } from "../../../shared/game_state/constants.js";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
PREDICTION_GRACE_HOURS,
|
||||
TARGET_REFRESH_MS,
|
||||
} from "./constants.js";
|
||||
import { CORNERS } from "./distortion.js";
|
||||
import { hoursAlong, samePath, pathProgress, makeSegment } from "./utils.js";
|
||||
|
||||
// A pathfinding slice runs for this long before handing the thread back, so
|
||||
@@ -360,7 +361,162 @@ export const motionMethods = {
|
||||
this._targetSvg.remove();
|
||||
this._targetSvg = null;
|
||||
}
|
||||
if (this.$targets[0].firstChild) this.$targets.empty();
|
||||
},
|
||||
|
||||
// ------------------------------------------------------- targeting --
|
||||
|
||||
// Enters target-picking mode for a ranged strike. It draws the red reach
|
||||
// boundary around `origin`; as the pointer moves it puts the target circle on
|
||||
// the hovered tile and the barrage arc from the attacker to it.
|
||||
beginTargeting({ unitIds, origin, range, globalRange }) {
|
||||
this._targeting = {
|
||||
unitIds: unitIds ? unitIds.slice() : [],
|
||||
origin: { x: origin.x, y: origin.y },
|
||||
range: Math.max(1, range | 0),
|
||||
globalRange: !!globalRange,
|
||||
};
|
||||
this._targetHover = null;
|
||||
if (this.$viewport && this.$viewport.length) this.$viewport.addClass("targeting");
|
||||
this._buildRangeBoundary();
|
||||
this._drawTargeting();
|
||||
},
|
||||
|
||||
clearTargeting() {
|
||||
this._targeting = null;
|
||||
this._targetHover = null;
|
||||
this._rangeTiles = null;
|
||||
if (this.$viewport && this.$viewport.length) this.$viewport.removeClass("targeting");
|
||||
if (this._rangeBoundary) { this._rangeBoundary.remove(); this._rangeBoundary = null; }
|
||||
if (this._rangeArc) { this._rangeArc.remove(); this._rangeArc = null; }
|
||||
if (this._rangeRing) { this._rangeRing.remove(); this._rangeRing = null; }
|
||||
if (this._targetingSvg && !this._targetingSvg.childNodes.length) {
|
||||
this._targetingSvg.remove();
|
||||
this._targetingSvg = null;
|
||||
}
|
||||
},
|
||||
|
||||
// The reachable tiles, flood-filled out from the origin through the topology
|
||||
// so the wrap seam is handled. A global-range weapon reaches everywhere, so
|
||||
// no boundary is drawn.
|
||||
_buildRangeBoundary() {
|
||||
this._rangeTiles = null;
|
||||
if (!this._targeting || this._targeting.globalRange || !this.topology) return;
|
||||
const { origin, range } = this._targeting;
|
||||
const reachable = new Set([key(origin.x, origin.y)]);
|
||||
const queue = [{ x: origin.x, y: origin.y, d: 0 }];
|
||||
while (queue.length) {
|
||||
const node = queue.shift();
|
||||
if (node.d >= range) continue;
|
||||
for (const nb of this.topology.neighbours(node.x, node.y)) {
|
||||
const k = key(nb.x, nb.y);
|
||||
if (reachable.has(k)) continue;
|
||||
reachable.add(k);
|
||||
queue.push({ x: nb.x, y: nb.y, d: node.d + 1 });
|
||||
}
|
||||
}
|
||||
this._rangeTiles = reachable;
|
||||
},
|
||||
|
||||
_ensureTargetingSvg() {
|
||||
if (this._targetingSvg && this._targetingSvg.parentNode) return this._targetingSvg;
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", "targeting-arcs");
|
||||
this.$targeting[0].appendChild(svg);
|
||||
this._targetingSvg = svg;
|
||||
return svg;
|
||||
},
|
||||
|
||||
// Draws the reach boundary (static until the origin changes) and refreshes
|
||||
// the aim overlay.
|
||||
_drawTargeting() {
|
||||
if (!this.$targeting || !this.$targeting.length || !this._targeting) return;
|
||||
this._drawRangeBoundary();
|
||||
this._drawTargetAim();
|
||||
},
|
||||
|
||||
_drawRangeBoundary() {
|
||||
if (this._targeting.globalRange || !this._rangeTiles || !this.topology) {
|
||||
if (this._rangeBoundary) { this._rangeBoundary.remove(); this._rangeBoundary = null; }
|
||||
return;
|
||||
}
|
||||
const svg = this._ensureTargetingSvg();
|
||||
if (!this._rangeBoundary) {
|
||||
this._rangeBoundary = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
this._rangeBoundary.setAttribute("class", "range-boundary");
|
||||
svg.appendChild(this._rangeBoundary);
|
||||
}
|
||||
let d = "";
|
||||
for (const k of this._rangeTiles) {
|
||||
const tile = parseKey(k);
|
||||
const a = mapToLocal(tile.x, tile.y);
|
||||
const ca = CORNERS.map((corner) => ({ x: a.x + corner.x, y: a.y + corner.y }));
|
||||
for (const nb of this.topology.neighbours(tile.x, tile.y)) {
|
||||
if (this._rangeTiles.has(key(nb.x, nb.y))) continue;
|
||||
const b = mapToLocal(nb.x, nb.y);
|
||||
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
||||
// The shared edge is the adjacent corner pair whose midpoint sits on the
|
||||
// line between the two centres.
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const p = ca[i];
|
||||
const q = ca[(i + 1) % 6];
|
||||
const m = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
|
||||
const dd = (m.x - mid.x) ** 2 + (m.y - mid.y) ** 2;
|
||||
if (dd < bestD) { bestD = dd; best = [p, q]; }
|
||||
}
|
||||
d += `M${best[0].x.toFixed(2)} ${best[0].y.toFixed(2)}L${best[1].x.toFixed(2)} ${best[1].y.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
this._rangeBoundary.setAttribute("d", d);
|
||||
},
|
||||
|
||||
// Updates the hover target circle and the barrage arc. Called as the pointer
|
||||
// moves and after a camera change so a stale aim is never left behind.
|
||||
_drawTargetAim() {
|
||||
if (!this._targeting || !this.$targeting || !this.$targeting.length) return;
|
||||
if (!this._targetHover) {
|
||||
if (this._rangeArc) this._rangeArc.setAttribute("d", "");
|
||||
if (this._rangeRing) this._rangeRing.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const svg = this._ensureTargetingSvg();
|
||||
const from = mapToLocal(this._targeting.origin.x, this._targeting.origin.y);
|
||||
const to = this._nearestCopy(
|
||||
mapToLocal(this._targetHover.x, this._targetHover.y),
|
||||
from
|
||||
);
|
||||
if (!this._rangeArc) {
|
||||
this._rangeArc = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
this._rangeArc.setAttribute("class", "target-line target-selected");
|
||||
svg.appendChild(this._rangeArc);
|
||||
}
|
||||
// The same parabolic lob the standing barrages use.
|
||||
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
||||
const lift = Math.max(18, distance * 0.4) / CAMERA_TILT;
|
||||
const cx = (from.x + to.x) / 2;
|
||||
const cy = (from.y + to.y) / 2 - lift;
|
||||
this._rangeArc.setAttribute("d", `M ${from.x} ${from.y} Q ${cx} ${cy} ${to.x} ${to.y}`);
|
||||
if (!this._rangeRing) {
|
||||
this._rangeRing = document.createElement("div");
|
||||
this._rangeRing.className = "target-ring target-selected";
|
||||
this.$targeting[0].appendChild(this._rangeRing);
|
||||
}
|
||||
this._rangeRing.style.display = "";
|
||||
this._rangeRing.style.left = `${to.x}px`;
|
||||
this._rangeRing.style.top = `${to.y}px`;
|
||||
},
|
||||
|
||||
// The pointer moved while picking a target: snap to the tile under it.
|
||||
_onTargetingHover(event) {
|
||||
if (!this._targeting || !this.$viewport || !this.$viewport.length) return;
|
||||
const coords = this.tileAtScreen(event.clientX, event.clientY);
|
||||
if (!coords) return;
|
||||
if (this._targetHover && this._targetHover.x === coords.x && this._targetHover.y === coords.y) {
|
||||
return;
|
||||
}
|
||||
this._targetHover = { x: coords.x, y: coords.y };
|
||||
this._drawTargetAim();
|
||||
},
|
||||
|
||||
// The first visible unit standing on each tile, built in one pass. Mirrors
|
||||
|
||||
@@ -722,6 +722,7 @@ export const terrainMethods = {
|
||||
{ source: this.$highlight, interactive: false },
|
||||
{ source: this.$paths, interactive: false },
|
||||
{ source: this.$targets, interactive: false },
|
||||
{ source: this.$targeting, interactive: false },
|
||||
{ source: this.$resources, interactive: false },
|
||||
{ source: this.$politicalLabels, interactive: false },
|
||||
];
|
||||
|
||||
+172
-4
@@ -1937,7 +1937,7 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_stack_panel_checklist_narrows_the_selection() {
|
||||
async test_stack_panel_card_toggle_narrows_the_selection() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
@@ -1947,9 +1947,10 @@ export class GameScreenTest extends TestCase {
|
||||
const second = state._spawnUnit(unit.coords, 0, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._selectUnits([unit.id, second.id]);
|
||||
this.assertSize(env.$("#unit-stack-list input"), 2, "each stacked unit is listed");
|
||||
env.$("#unit-stack-list input").eq(0).prop("checked", false).trigger("change");
|
||||
this.assertEqual(screen.selectedUnitIds.length, 1, "unticking narrows the selection");
|
||||
const $cards = env.$("#unit-stack-list .stack-unit-tile");
|
||||
this.assertSize($cards, 2, "each stacked unit is a card");
|
||||
$cards.eq(0).trigger("click");
|
||||
this.assertEqual(screen.selectedUnitIds.length, 1, "toggling a card off narrows the selection");
|
||||
this.assertEqual(screen.selectedUnitIds[0], second.id);
|
||||
screen.leave();
|
||||
} finally {
|
||||
@@ -1957,6 +1958,173 @@ export class GameScreenTest extends TestCase {
|
||||
}
|
||||
}
|
||||
|
||||
async test_stack_panel_groups_units_by_type() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const infantry = state.units.find((u) => u.civ === 0);
|
||||
const secondInfantry = state._spawnUnit(infantry.coords, 0, state.protoUnits[0]);
|
||||
const artilleryProto = state.protoUnits.find((proto) => proto.id === "artillery");
|
||||
const artillery = state._spawnUnit(infantry.coords, 0, artilleryProto);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._selectUnits([infantry.id, secondInfantry.id, artillery.id]);
|
||||
const $rows = env.$("#unit-stack-list .stack-type-row");
|
||||
this.assertSize($rows, 2, "one line per unit type");
|
||||
this.assertSize(env.$("#unit-stack-list .stack-unit-tile"), 3, "each unit is a card");
|
||||
this.assertEqual($rows.eq(0).find(".stack-type-count").text(), "×2", "the infantry line counts two");
|
||||
this.assertSize($rows.eq(0).find(".stack-unit-tile"), 2, "both infantry cards sit on that line");
|
||||
this.assertSize(
|
||||
env.$("#unit-stack-list .stack-type-name"),
|
||||
0,
|
||||
"the type name lives in the icon tooltip, not the line"
|
||||
);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_group_checkbox_selects_and_excludes_a_type() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const infantry = state.units.find((u) => u.civ === 0);
|
||||
const secondInfantry = state._spawnUnit(infantry.coords, 0, state.protoUnits[0]);
|
||||
const artillery = state._spawnUnit(
|
||||
infantry.coords,
|
||||
0,
|
||||
state.protoUnits.find((proto) => proto.id === "artillery")
|
||||
);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._selectUnits([infantry.id, secondInfantry.id, artillery.id]);
|
||||
|
||||
const row = (index) => env.$("#unit-stack-list .stack-type-row").eq(index);
|
||||
this.assertEqual(row(0).find(".stack-group-check").text(), "✓", "the infantry group reads all-ticked");
|
||||
row(0).find(".stack-group-check").trigger("click");
|
||||
this.assertEqual(screen.selectedUnitIds.length, 1, "excluding a type leaves the other selected");
|
||||
this.assertEqual(screen.selectedUnitIds[0], artillery.id);
|
||||
this.assertEqual(row(0).find(".stack-group-check").text(), "", "the excluded group is unticked");
|
||||
this.assertSize(env.$("#unit-stack-list .stack-unit-tile"), 3, "its cards stay in the list");
|
||||
|
||||
row(0).find(".stack-group-check").trigger("click");
|
||||
this.assertEqual(screen.selectedUnitIds.length, 3, "re-ticking the group selects every unit");
|
||||
this.assertEqual(row(0).find(".stack-group-check").text(), "✓");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_an_unticked_card_stays_and_sorts_to_the_back() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const first = state.units.find((u) => u.civ === 0);
|
||||
const second = state._spawnUnit(first.coords, 0, state.protoUnits[0]);
|
||||
const third = state._spawnUnit(first.coords, 0, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._selectUnits([first.id, second.id, third.id]);
|
||||
|
||||
let $cards = env.$("#unit-stack-list .stack-unit-tile");
|
||||
this.assertSize($cards, 3, "all three infantry are cards");
|
||||
const untickedId = Number($cards.eq(0).attr("data-id"));
|
||||
$cards.eq(0).trigger("click");
|
||||
|
||||
$cards = env.$("#unit-stack-list .stack-unit-tile");
|
||||
this.assertSize($cards, 3, "the unticked card is not dropped");
|
||||
this.assertEqual(
|
||||
Number($cards.eq(2).attr("data-id")),
|
||||
untickedId,
|
||||
"the unticked card moves to the back of its list"
|
||||
);
|
||||
this.assertTrue(
|
||||
screen.selectedUnitIds.indexOf(untickedId) === -1,
|
||||
"the unticked unit leaves the selection"
|
||||
);
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_choosing_a_target_fires_and_an_empty_tile_cancels() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const home = state.units.find((u) => u.civ === 0);
|
||||
const artillery = state._spawnUnit(
|
||||
home.coords,
|
||||
0,
|
||||
state.protoUnits.find((unit) => unit.id === "artillery")
|
||||
);
|
||||
const goal = adjacentLand(state, artillery);
|
||||
state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
let fired = null;
|
||||
screen._orderMove = (units, coords, type) => { fired = { units, coords, type }; };
|
||||
screen._selectUnits([artillery.id]);
|
||||
|
||||
screen._toggleTargeting();
|
||||
screen._chooseTarget({ x: goal.x, y: goal.y });
|
||||
this.assertNull(screen._targeting, "picking a target leaves target mode");
|
||||
this.assertNotNull(fired, "the strike is issued");
|
||||
this.assertEqual(fired.type, "attack");
|
||||
|
||||
fired = null;
|
||||
screen._toggleTargeting();
|
||||
screen._chooseTarget({ x: home.coords.x, y: home.coords.y });
|
||||
this.assertNull(screen._targeting, "a friendly tile still leaves target mode");
|
||||
this.assertNull(fired, "but no order is sent");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_attack_action_enters_targeting_for_a_ranged_stack() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
const screen = new GameScreen(stubNetwork(), makeConfig());
|
||||
screen.enter();
|
||||
const state = smallState();
|
||||
const home = state.units.find((u) => u.civ === 0);
|
||||
const artillery = state._spawnUnit(
|
||||
home.coords,
|
||||
0,
|
||||
state.protoUnits.find((unit) => unit.id === "artillery")
|
||||
);
|
||||
const goal = adjacentLand(state, artillery);
|
||||
state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||||
screen.onState(state.snapshot(0));
|
||||
screen._selectUnits([artillery.id]);
|
||||
this.assertFalse(
|
||||
env.$("#unit-attack-order").hasClass("hidden"),
|
||||
"a single ranged type offers the Attack action"
|
||||
);
|
||||
env.$("#unit-attack-order").trigger("click");
|
||||
this.assertTrue(env.$("#unit-attack-order").hasClass("active"), "picking a target lights the button");
|
||||
this.assertNotNull(screen.map._targeting, "the map entered target mode");
|
||||
this.assertEqual(screen.map._targeting.origin.x, artillery.coords.x, "the reach starts at the battery");
|
||||
this.assertEqual(screen.map._targeting.origin.y, artillery.coords.y);
|
||||
const svg = screen.map.$targeting.find("svg.targeting-arcs");
|
||||
this.assertEqual(svg.length, 1, "the reach overlay is the styled targeting-arcs svg");
|
||||
const boundary = svg.find("path.range-boundary");
|
||||
this.assertEqual(boundary.length, 1, "the reach boundary is drawn");
|
||||
this.assertTrue((boundary.attr("d") || "").length > 0, "the boundary is a solid red path, not empty");
|
||||
screen.leave();
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_city_tile_panel_shows_current_production() {
|
||||
const env = await setupDom();
|
||||
try {
|
||||
|
||||
@@ -257,22 +257,46 @@ export class MapViewInputTest extends MapViewFixture {
|
||||
}
|
||||
}
|
||||
|
||||
async test_right_click_on_an_enemy_stack_attacks_it() {
|
||||
async test_right_click_on_an_enemy_stack_is_a_plain_move() {
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const goal = adjacentLand(state, unit);
|
||||
state._spawnUnit(goal, 1, state.protoUnits[0]);
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
let attack = null;
|
||||
let move = null;
|
||||
map.onAttackOrdered = (ids, coords) => { attack = { ids, coords }; };
|
||||
map.onMoveOrdered = (ids, coords) => { move = { ids, coords }; };
|
||||
map.setSelectedUnit(unit.id);
|
||||
map._onRightClick(goal, {});
|
||||
this.assertNotNull(attack, "the enemy stack is attacked");
|
||||
this.assertNull(move, "the enemy stack is not treated as a plain move");
|
||||
this.assertEqual(attack.coords, goal);
|
||||
this.assertNotNull(move, "a right-click is a move, never a ranged attack");
|
||||
this.assertEqual(move.coords, goal);
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
}
|
||||
|
||||
async test_left_click_in_target_mode_chooses_the_target() {
|
||||
const state = smallState();
|
||||
const unit = state.units.find((u) => u.civ === 0);
|
||||
const goal = adjacentLand(state, unit);
|
||||
const { env, map } = await this.build(state);
|
||||
try {
|
||||
let chosen = null;
|
||||
let selected = null;
|
||||
map.onTargetChosen = (coords) => { chosen = coords; };
|
||||
map.onTargetCancelled = () => map.clearTargeting();
|
||||
map.onUnitSelected = () => { selected = true; };
|
||||
map.beginTargeting({
|
||||
unitIds: [unit.id],
|
||||
origin: { x: unit.coords.x, y: unit.coords.y },
|
||||
range: 3,
|
||||
});
|
||||
map._onLeftClick(goal);
|
||||
this.assertEqual(chosen, goal, "the left-click picks the target tile");
|
||||
this.assertNull(selected, "the click does not also select a unit");
|
||||
this.assertNotNull(map._targeting, "target mode stays on until the controller clears it");
|
||||
map._onRightClick(goal, {});
|
||||
this.assertNull(map._targeting, "a right-click cancels target mode");
|
||||
} finally {
|
||||
teardownDom(env);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user