feat(totalannihilation): add flight animation and factory production dials

- Add `flight` blocks to Fighter, Bomber, and Hover Constructor in rules JSON
  defining height, takeoff time, and landing time
- Implement purely cosmetic altitude system: units ease upward when given
  orders and settle back down when idle, using smoothstep easing
- Shadow stays pinned to ground position, fades in and shrinks as altitude
  increases — the gap between body and shadow sells the height
- Health/build bars move with the lifted body; selection ring stays on ground
- Add production dials (progress pies) on the player's own factories:
  top-left shows whole queue progress, top-right shows unit on the bench
- Pass real delta time to render() for framerate-independent takeoff animation
- Update sprites.md with flight system docs and art implications
- Add stub scene support and comprehensive verification tests
This commit is contained in:
Brian Fertig 2026-08-01 09:41:03 -06:00
parent cff1f02d9b
commit 2b6132031a
7 changed files with 446 additions and 26 deletions

View File

@ -903,6 +903,11 @@
"weapons": [ "weapons": [
"aacannon" "aacannon"
], ],
"flight": {
"height": 26,
"takeoffSec": 0.45,
"landSec": 0.7
},
"sheetSlot": "unitSheet", "sheetSlot": "unitSheet",
"frame": 12, "frame": 12,
"procShape": "fighter", "procShape": "fighter",
@ -934,6 +939,11 @@
"weapons": [ "weapons": [
"bombrack" "bombrack"
], ],
"flight": {
"height": 30,
"takeoffSec": 0.7,
"landSec": 1.0
},
"sheetSlot": "unitSheet", "sheetSlot": "unitSheet",
"frame": 13, "frame": 13,
"procShape": "bomber", "procShape": "bomber",
@ -973,6 +983,11 @@
"advancedvehicleplant", "advancedvehicleplant",
"airfield" "airfield"
], ],
"flight": {
"height": 9,
"takeoffSec": 0.35,
"landSec": 0.5
},
"sheetSlot": "unitSheet", "sheetSlot": "unitSheet",
"frame": 14, "frame": 14,
"procShape": "hoverConstructor", "procShape": "hoverConstructor",

View File

@ -246,6 +246,22 @@ export function compileRules(json) {
u.selfHeal.hpPerTick = (u.hp * frac) / 60 / c.tickHz; u.selfHeal.hpPerTick = (u.hp * frac) / 60 / c.tickHz;
u.selfHeal.pauseTicks = Math.round(pause * 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; u.spritePx = u.spritePx ?? sc.radius * 2;
// Losing one of these ends the match under the default victory rule, so it is def data // 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. // rather than a hardcoded "commander" id — a second Commander-class unit is a JSON change.

View File

@ -31,10 +31,36 @@ export const DEPTHS = {
selection: 15, actor: 20, air: 40, bars: 55, projectile: 60, fxOver: 65, fog: 80, 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 // Altitude, for units whose def carries a `flight` block (everything the Airfield builds).
// 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. // Entirely view-side. The simulation has no height axis: the entity stays exactly where its
const SHADOW_OFFSET = { x: 18, y: 22 }; // 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 { export default class TAWorldView {
constructor(scene, rules, art, state, playerArmy) { constructor(scene, rules, art, state, playerArmy) {
@ -73,10 +99,12 @@ export default class TAWorldView {
this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection); this.gSelection = scene.add.graphics().setDepth(DEPTHS.selection);
this.gOrders = scene.add.graphics().setDepth(DEPTHS.selection); this.gOrders = scene.add.graphics().setDepth(DEPTHS.selection);
this.gBars = scene.add.graphics().setDepth(DEPTHS.bars); 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.gGhost = scene.add.graphics().setDepth(DEPTHS.ghost);
this._addWorld(this.gSelection); this._addWorld(this.gSelection);
this._addWorld(this.gOrders); this._addWorld(this.gOrders);
this._addWorld(this.gBars); this._addWorld(this.gBars);
this._addWorld(this.gPies);
this._addWorld(this.gGhost); this._addWorld(this.gGhost);
this.sprites = new Map(); // entity id -> { img, turret } this.sprites = new Map(); // entity id -> { img, turret }
@ -355,15 +383,20 @@ export default class TAWorldView {
this._addWorld(turret); 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; let shadow = null;
if (def.isAir) { if (def.flight) {
shadow = this.scene.add.image(e.x, e.y, key, def.frame); 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).setDepth(DEPTHS.shadow);
shadow.setTint(0x000000).setAlpha(0.28).setDepth(DEPTHS.shadow);
this._addWorld(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); this.sprites.set(e.id, s);
return s; return s;
} }
@ -384,17 +417,21 @@ export default class TAWorldView {
/** /**
* @param {number} alpha interpolation factor between the last two sim ticks * @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 * @returns {{nanoLinks:Array, projectiles:Array}} data the FX layer needs, in view space
*/ */
render(alpha) { render(alpha, deltaMs = 16.7) {
const { state, rules } = this; 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(); this._updateChunks();
if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; } if (this.fogEnabled && this.fogDirty) { this._redrawFog(); this.fogDirty = false; }
const live = new Set(); const live = new Set();
const gSel = this.gSelection, gBar = this.gBars; const gSel = this.gSelection, gBar = this.gBars, gPie = this.gPies;
gSel.clear(); gBar.clear(); gSel.clear(); gBar.clear(); gPie.clear();
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead) continue; if (e.dead) continue;
@ -412,14 +449,32 @@ export default class TAWorldView {
const y = e.py + (e.y - e.py) * alpha; const y = e.py + (e.y - e.py) * alpha;
const heading = lerpAngle(e.pheading, e.heading, 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); if (!def.isBuilding) s.img.setRotation(heading);
// Y-sorted actor band; buildings sit just under units sharing a row. Aircraft ride in // 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. // 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; const band = def.isAir ? DEPTHS.air : DEPTHS.actor;
s.img.setDepth(band + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05)); s.img.setDepth(band + (y / state.worldH) * 10 + (def.isBuilding ? 0 : 0.05));
if (s.shadow) { 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% // Build sites show the wireframe frame. Queued (progress still 0) sits at 50%
@ -464,11 +519,13 @@ export default class TAWorldView {
if (s.turret) { if (s.turret) {
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha); const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
s.turret.setPosition(x, y).setRotation(tr); s.turret.setPosition(x, y - lift).setRotation(tr);
s.turret.setDepth(DEPTHS.actor + (y / state.worldH) * 10 + 0.08); 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)) { 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; 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); 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. // 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; const damaged = e.hp < e.maxHp - 0.5;
if (e.site) { 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) { } else if (damaged || this.selection.has(e.id) || this.showAllBars) {
const frac = Math.max(0, e.hp / e.maxHp); const frac = Math.max(0, e.hp / e.maxHp);
const col = frac > 0.6 ? 0x6fe27a : frac > 0.3 ? 0xe2d16f : 0xe2705f; 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); 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 }; 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 * Draw the pending order queue for every selected unit: a waypoint chain from the unit
* through each queued order, coloured by order type. * 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) { function drawBar(g, cx, y, w, frac, color) {
const h = 4; const h = 4;
const x = cx - w / 2; const x = cx - w / 2;

View File

@ -241,7 +241,9 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
for (const ev of events) this._onSimEvent(ev); 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.fx.draw(delta, nanoLinks, projectiles, time);
this._syncSelection(); this._syncSelection();
this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null); this.hud.update(time, this._selectedEntities(), this.placement?.def ?? null);

View File

@ -64,15 +64,39 @@ let the scale do the work.
| 13 | Bomber | plan-view airframe, long straight wing, wing-mounted engines | 60 | | 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 | | 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 ### Units that leave the 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 All three Airfield units carry a `flight` block in the rules JSON. It is **purely cosmetic**
consequences for the art: 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 - 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. 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. 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 **Turret frames** are drawn as a separate image stacked on the hull and rotated
independently, so the unit aims while it drives. Rules: independently, so the unit aims while it drives. Rules:

View File

@ -35,7 +35,7 @@ export function makeStubScene(w = 1920, h = 1080) {
}; };
const gfx = () => base('graphics', { const gfx = () => base('graphics', {
ops: 0, ops: 0,
clear() { this.ops = 0; return this; }, clear() { this.ops = 0; this.slices.length = 0; return this; },
lineStyle() { return this; }, fillStyle() { return this; }, lineStyle() { return this; }, fillStyle() { return this; },
beginPath() { return this; }, closePath() { return this; }, beginPath() { return this; }, closePath() { return this; },
moveTo() { return this; }, lineTo() { 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; }, fillEllipse() { this.ops++; return this; }, strokeEllipse() { this.ops++; return this; },
lineBetween() { this.ops++; return this; }, fillTriangle() { this.ops++; return this; }, lineBetween() { this.ops++; return this; }, fillTriangle() { this.ops++; return this; },
fillRoundedRect() { this.ops++; return this; }, strokeRoundedRect() { 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', { const container = () => base('container', {
list: [], list: [],

View File

@ -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'); section('2d. Container hitbox lint');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------