diff --git a/client/css/style.css b/client/css/style.css index 0de05c7..819e10c 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -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; diff --git a/client/index.html b/client/index.html index 76b8b1c..8a7c5d4 100644 --- a/client/index.html +++ b/client/index.html @@ -103,6 +103,9 @@
+ + diff --git a/client/js/devlog_data.js b/client/js/devlog_data.js index af45862..7720f0d 100644 --- a/client/js/devlog_data.js +++ b/client/js/devlog_data.js @@ -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": "" } ]; diff --git a/client/js/map_view.js b/client/js/map_view.js index 2f0ecec..db82e52 100644 --- a/client/js/map_view.js +++ b/client/js/map_view.js @@ -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 = $(''); + 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"); } diff --git a/client/js/map_view/motion.js b/client/js/map_view/motion.js index 8a899f9..c8d6341 100644 --- a/client/js/map_view/motion.js +++ b/client/js/map_view/motion.js @@ -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; }, diff --git a/client/js/map_view/terrain.js b/client/js/map_view/terrain.js index 2de6b29..b713686 100644 --- a/client/js/map_view/terrain.js +++ b/client/js/map_view/terrain.js @@ -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 }, diff --git a/tests/map_view_test.js b/tests/map_view_test.js index fe7a069..e9ccc14 100644 --- a/tests/map_view_test.js +++ b/tests/map_view_test.js @@ -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" );