Drew one bombardment arc per stack above the unit icons

This commit is contained in:
2026-09-27 10:46:50 +02:00
parent 4d3591c48f
commit c85f79fee2
7 changed files with 109 additions and 31 deletions
+3 -2
View File
@@ -328,8 +328,9 @@ 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. */
the target and a red ring around the target. The arc and its shell live on
`layer-strike-arcs`, above the entity icons, so a shell is never hidden behind
the battery that fired it; the ring stays on `layer-targets`, below the icons. */
.target-arcs, .targeting-arcs {
position: absolute;
left: 0;
+3
View File
@@ -103,6 +103,9 @@
<div class="layer" id="layer-targeting"></div>
<div class="layer" id="layer-resources"></div>
<div class="layer" id="layer-entities"></div>
<!-- The ballistic arcs and shells of standing bombardments ride above
the unit icons; the target rings stay in layer-targets below them. -->
<div class="layer" id="layer-strike-arcs"></div>
<div class="layer city-label-layer" id="layer-labels"></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": "4d3591c",
"date": "2026-09-27",
"subject": "Corrected the catalogue descriptions and quoted every rate per day",
"body": ""
},
{
"hash": "26e5141",
"date": "2026-09-25",
@@ -54,11 +60,5 @@ export const DEVLOG = [
"date": "2026-09-24",
"subject": "Let stacks split by shift-click and cross allied or at-war land",
"body": ""
},
{
"hash": "cc5c57c",
"date": "2026-09-24",
"subject": "Gave the world spies, treaties and nuclear weapons",
"body": ""
}
];
+13
View File
@@ -78,6 +78,18 @@ export class MapView {
// graph while the population map is active.
this.migrationGraph = { links: [] };
this.$entities = layers.entities;
// The ballistic arc and its shell of every standing bombardment, drawn on
// their own layer above the entity icons so a shell is never hidden behind
// the battery that fired it. The target rings stay in `$targets`, below the
// icons.
this.$strikeArcs = layers.strikeArcs && layers.strikeArcs.length
? layers.strikeArcs
: $("#layer-strike-arcs");
if (!this.$strikeArcs.length) {
this.$strikeArcs = $('<div id="layer-strike-arcs" class="layer"></div>');
if (this.$entities && this.$entities.length) this.$entities.after(this.$strikeArcs);
else this.$world.append(this.$strikeArcs);
}
// City names live on their own layer above the entities so they are never
// hidden behind a unit or city marker.
this.$labels = layers.labels && layers.labels.length
@@ -109,6 +121,7 @@ export class MapView {
for (const $layer of [
this.$terrain, this.$roads, this.$borders, this.$fog,
this.$highlight, this.$paths, this.$targets, this.$targeting, this.$resources,
this.$strikeArcs,
]) {
if ($layer && $layer.length) $layer.addClass("tilted");
}
+25 -12
View File
@@ -265,8 +265,9 @@ export const motionMethods = {
// a parabola rather than along the ground; an aircraft's strike is no ballistic
// launch, so its line stays straight. The dashes march and a glowing shell
// travels the line so the direction is unmistakable. They stay visible so an
// ongoing barrage cannot be forgotten, and sit on a layer above the terrain but
// below the unit and city icons.
// ongoing barrage cannot be forgotten. The arc and shell ride a layer above the
// unit and city icons so a shell is never hidden behind the battery that fired
// it; the ring stays on the layer below them.
//
// The overlay is rebuilt at most every TARGET_REFRESH_MS and immediately when
// its contents change; the per-strike target lookup goes through one index of
@@ -274,18 +275,28 @@ export const motionMethods = {
// units). The nodes are reused, so an unchanged number of barrages allocates
// nothing.
_drawStrikeTargets(force = false) {
if (!this.$targets || !this.$targets.length) return;
if (!this.$targets || !this.$targets.length ||
!this.$strikeArcs || !this.$strikeArcs.length) return;
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
const tick = Math.floor(now / TARGET_REFRESH_MS);
// Collect the standing barrages and a signature that changes the moment a
// barrage appears, moves or is selected, so those edits never wait for the
// next tick.
const strikes = [];
// next tick. A stack fires as one, so batteries sharing an origin tile and a
// target are grouped and drawn as a single arc and ring.
const groups = new Map();
let signature = "";
for (const [id, data] of this._unitData) {
if (!data.strikeTarget) continue;
strikes.push(id);
signature += `${id}:${data.strikeTarget[0]},${data.strikeTarget[1]};`;
const motion = this._unitMotion.get(id);
const origin = motion ? this._motionTile(motion) : { x: data.coords[0], y: data.coords[1] };
const pair = `${key(origin.x, origin.y)}>${key(data.strikeTarget[0], data.strikeTarget[1])}`;
let group = groups.get(pair);
if (!group) {
group = { ids: [], target: data.strikeTarget };
groups.set(pair, group);
}
group.ids.push(id);
}
signature += `|${[...this._selectedUnitIds].join(",")}`;
if (!force && tick === this._strikeTick && signature === this._strikeSignature) return;
@@ -293,7 +304,7 @@ export const motionMethods = {
this._strikeSignature = signature;
const views = this._targetViews;
if (strikes.length === 0) {
if (groups.size === 0) {
this._clearStrikeTargets();
return;
}
@@ -303,14 +314,16 @@ export const motionMethods = {
const targetUnits = this._targetUnitIndex();
const ns = "http://www.w3.org/2000/svg";
const seen = new Set();
for (const id of strikes) {
for (const group of groups.values()) {
const id = group.ids[0];
const data = this._unitData.get(id);
if (!data) continue;
const target = { x: data.strikeTarget[0], y: data.strikeTarget[1] };
const target = { x: group.target[0], y: group.target[1] };
const targetId = targetUnits.get(key(target.x, target.y));
// The selected attacker or target stands out from the standing barrages
// the player is not looking at.
const selected = this._selectedUnitIds.has(id) ||
// the player is not looking at; a stack stands out when any of its
// batteries is selected.
const selected = group.ids.some((unitId) => this._selectedUnitIds.has(unitId)) ||
(targetId !== undefined && this._selectedUnitIds.has(targetId));
const fromPoint = this._unitRenderPosition(id);
if (!fromPoint) continue;
@@ -377,7 +390,7 @@ export const motionMethods = {
if (this._targetSvg && this._targetSvg.parentNode) return this._targetSvg;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "target-arcs");
this.$targets[0].appendChild(svg);
this.$strikeArcs[0].appendChild(svg);
this._targetSvg = svg;
return svg;
},
+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.$strikeArcs, interactive: false },
{ source: this.$targeting, interactive: false },
{ source: this.$resources, interactive: false },
{ source: this.$politicalLabels, interactive: false },
+58 -11
View File
@@ -289,10 +289,10 @@ export class MapViewRenderTest extends MapViewFixture {
try {
map.setSelectedUnit(jet.id);
map.animate(0);
const d = env.$("#layer-targets .target-line").get(0).getAttribute("d");
const d = env.$("#layer-strike-arcs .target-line").get(0).getAttribute("d");
this.assertTrue(d.includes(" L "), "the air strike is a straight segment");
this.assertTrue(!d.includes("Q"), "no ballistic Bezier for an aircraft");
this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell still travels the line");
this.assertSize(env.$("#layer-strike-arcs .target-shell"), 1, "a shell still travels the line");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
} finally {
teardownDom(env);
@@ -316,28 +316,33 @@ export class MapViewRenderTest extends MapViewFixture {
try {
map.setSelectedUnit(artillery.id);
map.animate(0);
const path = env.$("#layer-targets .target-line").get(0);
const path = env.$("#layer-strike-arcs .target-line").get(0);
const d = path.getAttribute("d");
this.assertTrue(d.includes("Q"), "the barrage is drawn as a curve, not a straight line");
// The control point is lifted above both ends, so the shell arcs upwards.
const startY = Number(d.split(" ")[2]);
const controlY = Number(d.split(" ")[5]);
this.assertLess(controlY, startY, "the arc rises above the battery");
this.assertSize(env.$("#layer-targets .target-shell"), 1, "a shell travels the arc");
this.assertSize(env.$("#layer-strike-arcs .target-shell"), 1, "a shell travels the arc");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the target is ringed");
this.assertSize(
env.$("#layer-targets .target-selected"),
2,
env.$("#layer-strike-arcs .target-selected"),
1,
"a selected barrage stands out"
);
this.assertSize(
env.$("#layer-targets .target-selected"),
1,
"the selected target ring stands out"
);
// A standing barrage stays visible even when nothing is selected.
map.clearSelection();
map.animate(0);
this.assertSize(env.$("#layer-targets .target-line"), 1, "the line stays drawn");
this.assertSize(env.$("#layer-strike-arcs .target-line"), 1, "the line stays drawn");
this.assertSize(env.$("#layer-targets .target-ring"), 1, "the ring stays drawn");
this.assertSize(
env.$("#layer-targets .target-selected"),
env.$("#layer-strike-arcs .target-selected"),
0,
"nothing is highlighted without a selection"
);
@@ -346,6 +351,48 @@ export class MapViewRenderTest extends MapViewFixture {
}
}
async test_a_stacked_bombardment_draws_one_arc_and_ring() {
const state = smallState();
const spot = state.landCells.find((c) => !state.unitAt(c) && !state.cityAt(c));
const artilleryProto = state.protoUnits.find((p) => p.id === "artillery");
const first = state._spawnUnit(spot, 0, artilleryProto);
const second = state._spawnUnit(spot, 0, artilleryProto);
const goal = state.topology
.neighbours(spot.x, spot.y)
.find((n) => state._isLand(n) && !state.unitAt(n));
state._spawnUnit(goal, 1, state.protoUnits[0]);
this.assertTrue(
state.requestBombard([first.id, second.id], goal),
"the whole stack opens fire"
);
const { env, map } = await this.build(state);
try {
map.setSelectedUnit(first.id);
map.animate(0);
this.assertSize(
env.$("#layer-strike-arcs .target-line"),
1,
"a stacked barrage draws a single arc"
);
this.assertSize(
env.$("#layer-targets .target-ring"),
1,
"a stacked barrage rings the target once"
);
// Selecting the other battery keeps the one shared arc highlighted.
map.setSelectedUnit(second.id);
map.animate(0);
this.assertSize(
env.$("#layer-strike-arcs .target-selected"),
1,
"the shared arc stays highlighted"
);
} finally {
teardownDom(env);
}
}
async test_city_name_carries_a_production_progress_bar() {
const state = smallState();
state.budgets.set(0, 1.0e15);
@@ -456,15 +503,15 @@ export class MapViewRenderTest extends MapViewFixture {
return [];
};
map._drawStrikeTargets(true);
this.assertGreater(env.$("#layer-targets .target-line").length, 1, "the barrages are drawn");
this.assertGreater(env.$("#layer-strike-arcs .target-line").length, 1, "the barrages are drawn");
this.assertEqual(idScans, 0, "no per-strike army scan");
this.assertEqual(occupantScans, 0, "no per-strike occupant scan");
// A redraw reuses the existing nodes instead of rebuilding them.
const firstPath = env.$("#layer-targets .target-line").get(0);
const firstPath = env.$("#layer-strike-arcs .target-line").get(0);
map._drawStrikeTargets(true);
this.assertEqual(
env.$("#layer-targets .target-line").get(0),
env.$("#layer-strike-arcs .target-line").get(0),
firstPath,
"the arc nodes are reused across frames"
);