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:
parent
cff1f02d9b
commit
2b6132031a
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue