Grouped the unit stack by type and let ranged units pick a target

This commit is contained in:
2026-09-24 16:20:09 +02:00
parent 1c6f0ae1f1
commit 54efd66a63
11 changed files with 763 additions and 98 deletions
+70 -5
View File
@@ -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 {
+2
View File
@@ -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>
+6 -6
View File
@@ -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": ""
}
];
+15 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+158 -2
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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 {
+30 -6
View File
@@ -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);
}