Drew one bombardment arc per stack above the unit icons
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user