diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index 910609d..8be5afa 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -903,6 +903,11 @@ "weapons": [ "aacannon" ], + "flight": { + "height": 26, + "takeoffSec": 0.45, + "landSec": 0.7 + }, "sheetSlot": "unitSheet", "frame": 12, "procShape": "fighter", @@ -934,6 +939,11 @@ "weapons": [ "bombrack" ], + "flight": { + "height": 30, + "takeoffSec": 0.7, + "landSec": 1.0 + }, "sheetSlot": "unitSheet", "frame": 13, "procShape": "bomber", @@ -973,6 +983,11 @@ "advancedvehicleplant", "airfield" ], + "flight": { + "height": 9, + "takeoffSec": 0.35, + "landSec": 0.5 + }, "sheetSlot": "unitSheet", "frame": 14, "procShape": "hoverConstructor", diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index ecd0263..d0c6de3 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -246,6 +246,22 @@ export function compileRules(json) { u.selfHeal.hpPerTick = (u.hp * frac) / 60 / c.tickHz; u.selfHeal.pauseTicks = Math.round(pause * c.tickHz); } + // Purely cosmetic: how far the renderer lifts this unit off the ground and how long it + // takes. The simulation has NO notion of height — a `flight` unit is still exactly where + // its x/y say it is for movement, collision, targeting and clicking. It is declared + // separately from `domain` on purpose: the Hover Constructor floats visibly but is a + // ground unit that rifles can hit and that has to path around cliffs. + if (u.flight) { + for (const k of ['height', 'takeoffSec']) { + if (!(u.flight[k] > 0)) fail(`unit "${u.id}" flight.${k} must be positive`); + } + if (u.flight.landSec != null && !(u.flight.landSec > 0)) { + fail(`unit "${u.id}" flight.landSec must be positive when present`); + } + u.flight.landSec = u.flight.landSec ?? u.flight.takeoffSec; + } else if (u.isAir) { + fail(`air unit "${u.id}" needs a flight block, or it will render sitting on the ground`); + } u.spritePx = u.spritePx ?? sc.radius * 2; // Losing one of these ends the match under the default victory rule, so it is def data // rather than a hardcoded "commander" id — a second Commander-class unit is a JSON change. diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index 4754e60..9095628 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -31,10 +31,36 @@ export const DEPTHS = { selection: 15, actor: 20, air: 40, bars: 55, projectile: 60, fxOver: 65, fog: 80, }; -// How far down-right an aircraft's shadow falls. The aircraft itself is NOT offset: click -// selection and every order test use the entity's own position, so moving the sprite off it -// would make aircraft feel un-clickable. Moving only the shadow sells the altitude for free. -const SHADOW_OFFSET = { x: 18, y: 22 }; +// Altitude, for units whose def carries a `flight` block (everything the Airfield builds). +// +// Entirely view-side. The simulation has no height axis: the entity stays exactly where its +// x/y say it is for movement, collision, targeting and clicking, and only the SPRITE is +// lifted. That is what keeps this free — no extra sim work, no state to serialize, and a unit +// you can still click where you see its shadow. +// +// The shadow stays pinned to the true ground position while the body rises off it, so the gap +// between the two IS the altitude cue. At rest they are coincident and the shadow is fully +// transparent, which reads as the unit sitting on the ground. +const SHADOW_ALPHA = 0.5; // opacity at full altitude +const SHADOW_SHRINK = 0.14; // how much the shadow tightens as the unit climbs + +// Production dials on a factory's top corners: LEFT is the whole queue, RIGHT is the unit +// currently on the bench. Sized to read at a glance from a normal camera height without +// covering the building art — a 30px disc on a 192px structure. +const PIE_R = 15; +const PIE_INSET = 6; // gap from the footprint corner +const PIE_QUEUE_COLOR = 0xffd27a; // amber — the batch +const PIE_JOB_COLOR = 0x8ad4ff; // build blue, same language as sites and nanolathe +const TAU = Math.PI * 2; + +/** Move `cur` toward `want` by at most `step`. */ +function approach(cur, want, step) { + if (cur < want) return Math.min(want, cur + step); + return Math.max(want, cur - step); +} + +/** Smoothstep, so a takeoff eases out of the ground and into the hover instead of snapping. */ +function smoothstep(t) { return t * t * (3 - 2 * t); } export default class TAWorldView { constructor(scene, rules, art, state, playerArmy) { @@ -73,10 +99,12 @@ export default class TAWorldView { this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection); this.gOrders = scene.add.graphics().setDepth(DEPTHS.selection); this.gBars = scene.add.graphics().setDepth(DEPTHS.bars); + this.gPies = scene.add.graphics().setDepth(DEPTHS.bars); this.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost); this._addWorld(this.gSelection); this._addWorld(this.gOrders); this._addWorld(this.gBars); + this._addWorld(this.gPies); this._addWorld(this.gGhost); this.sprites = new Map(); // entity id -> { img, turret } @@ -355,15 +383,20 @@ export default class TAWorldView { this._addWorld(turret); } - // Aircraft get a flattened black copy of their own frame on the ground beneath them. + // Anything that leaves the ground gets a flattened black copy of its own frame beneath it. let shadow = null; - if (def.isAir) { + if (def.flight) { shadow = this.scene.add.image(e.x, e.y, key, def.frame); - shadow.setScale((def.spritePx ?? def.radius * 2) / frameSize.w); - shadow.setTint(0x000000).setAlpha(0.28).setDepth(DEPTHS.shadow); + shadow.setTint(0x000000).setAlpha(0).setDepth(DEPTHS.shadow); this._addWorld(shadow); } - s = { img, turret, final, shadow, defId: e.defId }; + // `alt` is the current altitude as a 0..1 fraction of def.flight.height, eased toward its + // target every frame in render(). Newly built units start on the ground. + s = { + img, turret, final, shadow, defId: e.defId, + alt: 0, baseScale: img.scaleX, + batchDone: 0, batchPrev: 0, // production-dial accounting, see _drawProductionPies + }; this.sprites.set(e.id, s); return s; } @@ -384,17 +417,21 @@ export default class TAWorldView { /** * @param {number} alpha interpolation factor between the last two sim ticks + * @param {number} [deltaMs] real frame time, for framerate-independent view animation * @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space */ - render(alpha) { + render(alpha, deltaMs = 16.7) { const { state, rules } = this; + // Clamped: a tab that was backgrounded for ten seconds must not teleport every aircraft + // to full altitude in one frame. + const dtSec = Math.min(0.1, Math.max(0, deltaMs) / 1000); this._updateChunks(); if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; } const live = new Set(); - const gSel = this.gSelection, gBar = this.gBars; - gSel.clear(); gBar.clear(); + const gSel = this.gSelection, gBar = this.gBars, gPie = this.gPies; + gSel.clear(); gBar.clear(); gPie.clear(); for (const e of state.entities) { if (e.dead) continue; @@ -412,14 +449,32 @@ export default class TAWorldView { const y = e.py + (e.y - e.py) * alpha; const heading = lerpAngle(e.pheading, e.heading, alpha); - s.img.setPosition(x, y); + // Altitude. The trigger is deliberately "has been given something to do" rather than + // "is physically moving": e.orders fills the instant the order is issued, a tick before + // stepMovement has translated the unit anywhere, so the climb visibly leads the move + // instead of trailing it. A unit standing on a target it is shooting stays up too. + let lift = 0; + if (def.flight) { + const airborne = !!(e.movingTo || e.orders.length || e.targetId); + const secs = airborne ? def.flight.takeoffSec : def.flight.landSec; + s.alt = approach(s.alt, airborne ? 1 : 0, dtSec / secs); + lift = def.flight.height * smoothstep(s.alt); + } + + s.img.setPosition(x, y - lift); if (!def.isBuilding) s.img.setRotation(heading); // Y-sorted actor band; buildings sit just under units sharing a row. Aircraft ride in // their own band above ALL of it — a Fighter must never disappear behind a factory. + // Depth comes from the GROUND y, not the lifted one, so climbing never re-sorts a unit + // against its neighbours. const band = def.isAir ? DEPTHS.air : DEPTHS.actor; s.img.setDepth(band + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05)); if (s.shadow) { - s.shadow.setPosition(x + SHADOW_OFFSET.x, y + SHADOW_OFFSET.y).setRotation(heading); + // Pinned to the true position and fading in as the body leaves it — the separation + // between the two sprites is the whole effect. + s.shadow.setPosition(x, y).setRotation(heading); + s.shadow.setAlpha(SHADOW_ALPHA * s.alt); + s.shadow.setScale(s.baseScale * (1 - SHADOW_SHRINK * s.alt)); } // Build sites show the wireframe frame. Queued (progress still 0) sits at 50% @@ -464,11 +519,13 @@ export default class TAWorldView { if (s.turret) { const tr = lerpAngle(e.pturretRot, e.turretRot, alpha); - s.turret.setPosition(x, y).setRotation(tr); - s.turret.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + 0.08); + s.turret.setPosition(x, y - lift).setRotation(tr); + s.turret.setDepth((def.isAir ? DEPTHS.air : DEPTHS.actor) + (y / state.worldH) * 10 + 0.08); } - // Selection ring + // Selection ring — drawn on the GROUND, under a hovering unit rather than around it. + // The ring is what tells the player where a unit actually is for ordering purposes, and + // for anything airborne that is its shadow's position, not its sprite's. if (this.selection.has(e.id)) { const r = def.isBuilding ? Math.max(def.footprint.w, def.footprint.h) * this.ts * 0.55 : e.radius + 4; gSel.lineStyle(2, 0x7dff9b, 0.95); @@ -476,14 +533,19 @@ export default class TAWorldView { } // Health / build bars — only when they say something, so we're not stroking 300 of them. + // These ride up with the body: a bar left on the ground reads as belonging to whatever + // the aircraft is flying over. + const barY = y - lift - e.radius - 10; const damaged = e.hp < e.maxHp - 0.5; if (e.site) { - drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, e.progress, 0x8ad4ff); + drawBar(gBar, x, barY, e.radius * 1.8, e.progress, 0x8ad4ff); } else if (damaged || this.selection.has(e.id) || this.showAllBars) { const frac = Math.max(0, e.hp / e.maxHp); const col = frac > 0.6 ? 0x6fe27a : frac > 0.3 ? 0xe2d16f : 0xe2705f; - drawBar(gBar, x, y - e.radius - 10, e.radius * 1.8, frac, col); + drawBar(gBar, x, barY, e.radius * 1.8, frac, col); } + + this._drawProductionPies(gPie, e, def, x, y); } for (const id of [...this.sprites.keys()]) if (!live.has(id)) this._releaseSprite(id); @@ -532,6 +594,53 @@ export default class TAWorldView { return { nanoLinks, projectiles }; } + /** + * Production dials on a working factory: the whole queue on the top-LEFT corner, the unit + * currently on the bench on the top-RIGHT. + * + * Only the viewing player's own factories get these. They are a production-management + * affordance, and putting them on enemy structures would hand out exact intel on what the + * opponent is building and how close it is — information no other part of the HUD gives. + * Drop the `e.army` test if you'd rather see them on everything. + */ + _drawProductionPies(g, e, def, x, y) { + if (!e.isBuilding || e.army !== this.playerArmy) return; + const s = this.sprites.get(e.id); + // An idle factory shows nothing, and forgets the batch it just finished so the next one + // starts its dial from zero. + if (e.site || !e.queue?.length) { if (s) { s.batchDone = 0; s.batchPrev = 0; } return; } + if (!s) return; + + // Queue progress is measured in BUILD SECONDS, not items: three Jeeps and three Bombers + // are not the same amount of work, and a dial that treated them as equal would crawl and + // then leap. + const workOf = (id) => (this.rules.unitById[id]?.buildTime ?? 0); + let remaining = 0; + for (const item of e.queue) remaining += workOf(item.defId) * item.count; + remaining -= (e.jobProgress ?? 0) * workOf(e.queue[0].defId); + remaining = Math.max(0, remaining); + + // Accumulate work COMPLETED, view-side, rather than deriving progress from the queue as + // it stands. The queue array SHRINKS as units pop out of it, so "done / still queued" + // would snap the dial back to zero on every completion; and remembering the batch's + // original size instead would throw that progress away the moment the player queued more. + // Tracking the drop in remaining work handles both: finished work is never lost, and new + // work honestly enlarges the denominator. + // + // A batch already in flight when a save is loaded re-baselines to zero here, since the + // sim doesn't record how much of it was done. It corrects itself as the batch finishes. + const prev = s.batchPrev ?? 0; + s.batchDone = (s.batchDone ?? 0) + Math.max(0, prev - remaining); + s.batchPrev = remaining; + const total = s.batchDone + remaining; + if (!(total > 0)) return; + + const hw = def.halfW ?? e.radius, hh = def.halfH ?? e.radius; + const cy = y - hh + PIE_INSET + PIE_R; + drawPie(g, x - hw + PIE_INSET + PIE_R, cy, PIE_R, s.batchDone / total, PIE_QUEUE_COLOR); + drawPie(g, x + hw - PIE_INSET - PIE_R, cy, PIE_R, e.jobProgress ?? 0, PIE_JOB_COLOR); + } + /** * Draw the pending order queue for every selected unit: a waypoint chain from the unit * through each queued order, coloured by order type. @@ -684,6 +793,26 @@ export default class TAWorldView { } } +/** + * A progress dial: dark disc, filled wedge sweeping clockwise from 12 o'clock, thin rim. + * + * The backing disc is what makes this legible over any building art — without it the wedge + * competes with whatever is painted underneath and reads as noise rather than as a gauge. + */ +function drawPie(g, cx, cy, r, frac, color) { + const t = Math.max(0, Math.min(1, frac)); + g.fillStyle(0x0b0e12, 0.62); + g.fillCircle(cx, cy, r); + if (t > 0.001) { + const start = -Math.PI / 2; + g.fillStyle(color, 0.92); + g.slice(cx, cy, r - 2.5, start, start + t * TAU, false); + g.fillPath(); + } + g.lineStyle(1.5, color, 0.75); + g.strokeCircle(cx, cy, r); +} + function drawBar(g, cx, y, w, frac, color) { const h = 4; const x = cx - w / 2; diff --git a/src/games/totalannihilation/TotalAnnihilationGame.js b/src/games/totalannihilation/TotalAnnihilationGame.js index 82ac67b..b08ae02 100644 --- a/src/games/totalannihilation/TotalAnnihilationGame.js +++ b/src/games/totalannihilation/TotalAnnihilationGame.js @@ -241,7 +241,9 @@ export default class TotalAnnihilationGame extends Phaser.Scene { for (const ev of events) this._onSimEvent(ev); - const { nanoLinks, projectiles } = this.view.render(st.alpha); + // Real frame delta, NOT delta * simSpeed — takeoff is a view animation and should look the + // same whether the match is paused, at 1x or at 4x. + const { nanoLinks, projectiles } = this.view.render(st.alpha, delta); this.fx.draw(delta, nanoLinks, projectiles, time); this._syncSelection(); this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null); diff --git a/src/games/totalannihilation/sprites.md b/src/games/totalannihilation/sprites.md index 8cc33c5..b37f777 100644 --- a/src/games/totalannihilation/sprites.md +++ b/src/games/totalannihilation/sprites.md @@ -64,15 +64,39 @@ let the scale do the work. | 13 | Bomber | plan-view airframe, long straight wing, wing-mounted engines | 60 | | 14 | Hover Constructor | skirted hull over a plenum, nanolathe crane, no tracks | 52 | -**Aircraft** (frames 12-13) are drawn on the same sheet and rotated exactly like a ground -unit, but the renderer puts them in their own depth band above every ground actor and lays a -black, 28%-opacity copy of the frame on the ground 18px right and 22px down as a shadow. Two -consequences for the art: +### Units that leave the ground + +All three Airfield units carry a `flight` block in the rules JSON. It is **purely cosmetic** — +the simulation has no height axis, and the unit stays exactly where its x/y say it is for +movement, collision, targeting and clicking: + +```json +"flight": { "height": 26, "takeoffSec": 0.45, "landSec": 0.7 } +``` + +| Unit | height | takeoff | land | +|---|---|---|---| +| Fighter | 26px | 0.45s | 0.7s | +| Bomber | 30px | 0.7s | 1.0s | +| Hover Constructor | 9px | 0.35s | 0.5s | + +At rest the unit sits flat on the ground with no visible shadow. The moment it is given +something to do, the renderer eases the **sprite** upward by `height` while leaving a black, +50%-opacity copy of its own frame pinned to the true ground position — the growing gap between +the two is the altitude. It settles back down when idle. `height` is the one number to tune if +a unit reads as too glued down or too detached. + +Three consequences for the art: - The silhouette must read as flying — wings well clear of the fuselage, nothing that looks like a track or a wheel. It is the only cue the player gets that ground units cannot shoot it. -- The frame is reused as its own shadow, so keep the shape solid. A hollow or heavily +- **The frame is reused as its own shadow**, so keep the shape solid. A hollow or heavily outlined airframe casts a shadow that reads as a smudge. +- Draw the unit as if seen from directly above, not at a three-quarter angle. The body and its + shadow are the same image separated vertically, so any built-in perspective fights the effect. + +The Hover Constructor uses the same machinery at a much lower height — it should read as +*floating*, not flying, which is why its shadow never gets far from it. **Turret frames** are drawn as a separate image stacked on the hull and rotated independently, so the unit aims while it drives. Rules: diff --git a/tools/lib/taStubScene.js b/tools/lib/taStubScene.js index d4e75d9..21cbab3 100644 --- a/tools/lib/taStubScene.js +++ b/tools/lib/taStubScene.js @@ -35,7 +35,7 @@ export function makeStubScene(w = 1920, h = 1080) { }; const gfx = () => base('graphics', { ops: 0, - clear() { this.ops = 0; return this; }, + clear() { this.ops = 0; this.slices.length = 0; return this; }, lineStyle() { return this; }, fillStyle() { return this; }, beginPath() { return this; }, closePath() { return this; }, moveTo() { return this; }, lineTo() { return this; }, @@ -45,6 +45,15 @@ export function makeStubScene(w = 1920, h = 1080) { fillEllipse() { this.ops++; return this; }, strokeEllipse() { this.ops++; return this; }, lineBetween() { this.ops++; return this; }, fillTriangle() { this.ops++; return this; }, fillRoundedRect() { this.ops++; return this; }, strokeRoundedRect() { this.ops++; return this; }, + // Pie slices record their geometry: the production overlays are only meaningful if the + // ARC they sweep matches the progress fraction, which a bare call counter cannot show. + slices: [], + slice(x, y, radius, startAngle, endAngle) { + this.ops++; + this.slices.push({ x, y, radius, startAngle, endAngle }); + return this; + }, + arc() { this.ops++; return this; }, }); const container = () => base('container', { list: [], diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index 41f64d5..9ec5980 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -312,6 +312,231 @@ section('2c. View layering and first frame'); } } +// --------------------------------------------------------------------------- +section('2e. Takeoff, hover and shadow'); +// --------------------------------------------------------------------------- +{ + // Altitude is a pure view animation, so it is tested the way §2c tests layering: by driving + // the REAL TAWorldView against the stub scene and reading the sprites back. The sim is + // never ticked here — orders are set and frames are rendered — which is itself the point: + // if any of this leaked into TALogic these fixtures could not work at all. + check('the Hover Constructor hovers without being an air unit', + !!rules.unitById.hoverconstructor.flight && rules.unitById.hoverconstructor.isAir === false, + 'the visual and the simulation domain must stay separate'); + for (const u of rules.units) { + if (!u.isAir) continue; + check(`${u.id} declares a flight block`, !!u.flight); + } + + const map = generateMap(rules, { seed: 4242, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 }); + const FRAME = 1000 / 60; + + /** Fresh view + a single grounded fighter, with fog off so visibility never masks a bug. */ + const rig = () => { + const st = L.createMatch(rules, { seed: 4242, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] }); + const scene = makeStubScene(); + const view = new TAWorldView(scene, rules, artJson, st, 0); + view.setFogEnabled(false); + const start = st.starts.find((x) => x.army === 0); + const e = L.spawnUnit(st, rules, 0, 'fighter', start.x * st.tileSize, start.y * st.tileSize); + const run = (sec, frameMs = FRAME) => { + for (let t = 0; t < sec * 1000; t += frameMs) view.render(0, frameMs); + }; + return { st, view, e, run, spr: () => view.sprites.get(e.id) }; + }; + + let r = rig(); + try { + const def = rules.unitById.fighter; + r.run(1.0); + let s = r.spr(); + check('an idle aircraft renders on the ground', Math.abs(s.img.y - r.e.y) < 0.01, + `body ${(s.img.y - r.e.y).toFixed(2)}px off the ground`); + check('a grounded aircraft casts no visible shadow', s.shadow.alpha < 0.001, + `alpha ${s.shadow.alpha.toFixed(3)}`); + + // Giving it somewhere to be is what starts the climb — orders fill a tick before the sim + // has moved it anywhere, so the takeoff leads the movement rather than trailing it. + L.issueOrder(r.st, rules, { + army: 0, unitIds: [r.e.id], order: { type: 'move', x: r.e.x + 600, y: r.e.y }, + }); + r.run(0.1); + s = r.spr(); + check('the climb starts before the unit has travelled', s.img.y < r.e.y - 0.5, + `only ${(r.e.y - s.img.y).toFixed(2)}px up after 0.1s`); + + r.run(2.0); + s = r.spr(); + const lift = r.e.y - s.img.y; + check('a moving aircraft reaches its full hover height', + Math.abs(lift - def.flight.height) < 0.5, `lifted ${lift.toFixed(1)} of ${def.flight.height}px`); + check('the shadow stays pinned to the true ground position', + Math.abs(s.shadow.x - r.e.x) < 0.01 && Math.abs(s.shadow.y - r.e.y) < 0.01); + check('the shadow separates from the body by the hover height', + Math.abs((s.shadow.y - s.img.y) - def.flight.height) < 0.5); + check('the airborne shadow is 50% transparent', Math.abs(s.shadow.alpha - 0.5) < 0.001, + `alpha ${s.shadow.alpha.toFixed(3)}`); + check('the shadow tightens as the unit climbs', s.shadow.scaleX < s.img.scaleX); + // Lifting the sprite must not re-sort it against its neighbours, or an aircraft would + // pop in front of things as it took off. + const depthUp = s.img.depth; + check('depth is taken from the ground position, not the lifted sprite', + Math.abs(depthUp - (DEPTHS.air + (r.e.y / r.st.worldH) * 10 + 0.05)) < 1e-6); + + // Landing: strip the orders the way arriving would, without running the sim. + r.e.orders.length = 0; r.e.movingTo = null; r.e.targetId = 0; + r.run(2.0); + s = r.spr(); + check('an aircraft settles back onto the ground when idle', + Math.abs(s.img.y - r.e.y) < 0.01 && s.shadow.alpha < 0.001, + `body ${(s.img.y - r.e.y).toFixed(2)}px up, shadow alpha ${s.shadow.alpha.toFixed(3)}`); + } catch (err) { + check('the takeoff animation runs', false, err.message); + } + r.view.destroy(); + + // Framerate independence. The animation advances on real elapsed time, so half a second of + // 60fps and half a second of 120fps must arrive at the same altitude — otherwise takeoff + // speed would silently depend on the player's hardware. + { + const a = rig(), b = rig(); + try { + for (const g of [a, b]) { + L.issueOrder(g.st, rules, { + army: 0, unitIds: [g.e.id], order: { type: 'move', x: g.e.x + 600, y: g.e.y }, + }); + } + a.run(0.3, 1000 / 60); + b.run(0.3, 1000 / 120); + const la = a.e.y - a.spr().img.y, lb = b.e.y - b.spr().img.y; + check('takeoff is framerate independent', Math.abs(la - lb) < 1.0, + `60fps lifted ${la.toFixed(2)}px, 120fps lifted ${lb.toFixed(2)}px`); + } catch (err) { + check('the framerate-independence rig runs', false, err.message); + } + a.view.destroy(); b.view.destroy(); + } +} + +// --------------------------------------------------------------------------- +section('2f. Factory production dials'); +// --------------------------------------------------------------------------- +{ + // Two pies on a working factory: top-LEFT is the whole queue, top-RIGHT is the unit on the + // bench. Read back off the stub's recorded slice geometry, because "a graphics call + // happened" proves nothing — the arc has to match the progress it claims to show. + const map = generateMap(rules, { seed: 5150, size: 'small', theme: 'grasslands', symmetry: 'mirror-x', armies: 2 }); + const st = L.createMatch(rules, { seed: 5150, map, armies: [{ armyId: 'arm', isHuman: true }, { armyId: 'core' }] }); + const scene = makeStubScene(); + const TAU = Math.PI * 2; + + try { + const view = new TAWorldView(scene, rules, artJson, st, 0); + view.setFogEnabled(false); + const plant = rules.buildingById.vehicleplant; + + /** Drop a finished factory for `army` somewhere legal near its start. */ + const factory = (army) => { + const s = st.starts.find((x) => x.army === army) ?? st.starts[0]; + for (let r = 2; r < 16; r++) { + for (let a = 0; a < 24; a++) { + const ang = (a / 24) * TAU; + const tx = Math.round(s.x + Math.cos(ang) * r), ty = Math.round(s.y + Math.sin(ang) * r); + if (!L.canPlaceAt(st, rules, tx, ty, plant).ok) continue; + const b = L.placeBuilding(st, rules, army, 'vehicleplant', tx, ty); + b.site = false; b.progress = 1; b.hp = plant.hp; + return b; + } + } + return null; + }; + const f = factory(0); + check('the dial fixture placed a factory', !!f); + + const slices = () => view.gPies.slices; + + view.render(0, 16.7); + check('an idle factory draws no dials', slices().length === 0, `${slices().length} slice(s)`); + + // Queue 4 tanks (80s of work). A batch always starts before any work is done on it, so + // the dials are sampled in that order here too. + L.issueOrder(st, rules, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 4 } }); + view.render(0, 16.7); + check('a fresh queue starts both dials empty', slices().length === 0, `${slices().length} slice(s)`); + + f.jobProgress = 0.5; + view.render(0, 16.7); + let sl = slices(); + check('a working factory draws two dials', sl.length === 2, `${sl.length} slice(s)`); + + if (sl.length === 2) { + const left = sl.find((p) => p.x < f.x), right = sl.find((p) => p.x > f.x); + check('the dials sit on the top-left and top-right corners', !!left && !!right); + // Both must land inside the footprint, or they'd float over neighbouring buildings. + const inside = [left, right].every((p) => p + && Math.abs(p.x - f.x) < plant.halfW && Math.abs(p.y - f.y) < plant.halfH + && p.y < f.y); + check('both dials sit inside the top half of the footprint', inside); + + const sweep = (p) => (p.endAngle - p.startAngle) / TAU; + check('the right dial tracks the unit on the bench', + Math.abs(sweep(right) - 0.5) < 0.01, `${(sweep(right) * 100).toFixed(0)}% vs 50%`); + // 4 tanks queued, half of the first one done => 0.5/4 of the batch. + check('the left dial tracks the whole queue', + Math.abs(sweep(left) - 0.125) < 0.01, `${(sweep(left) * 100).toFixed(1)}% vs 12.5%`); + } + + // The important one. The queue array SHRINKS as units pop out of it, so a dial computed + // from "done / still queued" would snap back to zero on every completion. After one tank + // of four finishes, the batch dial must read a quarter, not nothing. + f.queue = [{ defId: 'tank', count: 3 }]; + f.jobProgress = 0; + view.render(0, 16.7); + sl = slices(); + const left2 = sl.find((p) => p.x < f.x); + check('the queue dial does not reset when a unit completes', + !!left2 && Math.abs((left2.endAngle - left2.startAngle) / TAU - 0.25) < 0.01, + left2 ? `${(((left2.endAngle - left2.startAngle) / TAU) * 100).toFixed(1)}% vs 25%` : 'no wedge drawn'); + + // Queueing 4 more mid-run must ENLARGE the batch without discarding the tank already + // built: 20s done against a 160s total is 12.5%, not 0% and not still 25%. + L.issueOrder(st, rules, { army: 0, order: { type: 'factoryEnqueue', factoryId: f.id, defId: 'tank', count: 4 } }); + view.render(0, 16.7); + const left3 = slices().find((p) => p.x < f.x); + const frac3 = left3 ? (left3.endAngle - left3.startAngle) / TAU : 0; + check('adding to the queue keeps the work already done', Math.abs(frac3 - 0.125) < 0.01, + `${(frac3 * 100).toFixed(1)}% vs 12.5%`); + + // Emptying the queue forgets the batch, so the next run starts from zero. + f.queue = []; + view.render(0, 16.7); + check('an emptied queue clears the dials', slices().length === 0); + f.queue = [{ defId: 'tank', count: 2 }]; + view.render(0, 16.7); + check('a fresh batch restarts the queue dial from empty', slices().length === 0, + 'the previous batch should be forgotten'); + f.jobProgress = 0.5; + view.render(0, 16.7); + const left4 = slices().find((p) => p.x < f.x); + check('the fresh batch then measures against its own total', + Math.abs(((left4?.endAngle ?? 0) - (left4?.startAngle ?? 0)) / TAU - 0.25) < 0.01, + left4 ? `${((((left4.endAngle - left4.startAngle) / TAU)) * 100).toFixed(1)}% vs 25%` : 'no wedge'); + + // Enemy production is not the player's business — no other part of the HUD leaks it. + const foe = factory(1); + if (foe) { + L.issueOrder(st, rules, { army: 1, order: { type: 'factoryEnqueue', factoryId: foe.id, defId: 'tank', count: 4 } }); + foe.jobProgress = 0.5; + f.queue = []; + view.render(0, 16.7); + check('enemy factories show no dials', slices().length === 0, `${slices().length} slice(s)`); + } + view.destroy(); + } catch (err) { + check('the production dials render', false, err.message); + } +} + // --------------------------------------------------------------------------- section('2d. Container hitbox lint'); // ---------------------------------------------------------------------------