totalannihilation: add building upgrades and spinning toppers
- New `upgradesFrom` rule: a building def (Nuclear Power Plant on the Energy Generator, Advanced Metal Generator on the Mass Generator) may be placed exactly on a friendly, finished building of the named type, consuming it for a 50% refund of its build cost instead of needing clear ground (TALogic findUpgradeTarget / consumeUpgrade / buildCommand) - New `topperFrame`: a second sprite stacked on the finished structure that spins continuously (the Advanced Metal Generator's centrifuge, frame 26), hidden while under construction; TAArt sizes the sheet for it and paints a generic spinner-glyph fallback - New rules: Nuclear Power Plant (frames 22-23) and Advanced Metal Generator (frames 24-26) in totalannihilation-rules.json - TARules: validate topperFrame and upgradesFrom (real building with a cost); TAWorldView renders/spins the topper; verifyTotalAnnihilation checks topper frames fit and were painted - Test section covering upgrade placement (ownership, type match, exact footprint, refund amount, army-less fallback) and sprites.md updated for frames 22-26
This commit is contained in:
parent
c690d80e68
commit
3c7184564a
|
|
@ -38,7 +38,7 @@
|
|||
"arm-structures": {
|
||||
"key": "ta-arm-structures", "path": "assets/images/ta/arm-structures.png", "kind": "structure",
|
||||
"frameWidth": 192, "frameHeight": 192, "cols": 6,
|
||||
"rows": 4
|
||||
"rows": 6
|
||||
},
|
||||
"core-units": {
|
||||
"key": "ta-core-units", "path": "assets/images/ta/core-units.png", "kind": "unit",
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
"core-structures": {
|
||||
"key": "ta-core-structures", "path": "assets/images/ta/core-structures.png", "kind": "structure",
|
||||
"frameWidth": 192, "frameHeight": 192, "cols": 6,
|
||||
"rows": 4
|
||||
"rows": 6
|
||||
},
|
||||
"terrain-grasslands": {
|
||||
"key": "ta-terrain-grasslands", "path": "assets/images/ta/terrain-grasslands.png", "kind": "terrain",
|
||||
|
|
|
|||
|
|
@ -1024,6 +1024,39 @@
|
|||
"spritePx": 50,
|
||||
"moveSound": "sfx-engine-heavy"
|
||||
},
|
||||
{
|
||||
"id": "advancedconstructor",
|
||||
"name": "Advanced Construction Vehicle",
|
||||
"role": "builder",
|
||||
"size": "medium",
|
||||
"radius": 26,
|
||||
"hp": 950,
|
||||
"speed": 80,
|
||||
"turnRate": 2.2,
|
||||
"moveClass": "tread",
|
||||
"armorClass": "light",
|
||||
"sight": 360,
|
||||
"cost": {
|
||||
"energy": 900,
|
||||
"mass": 220
|
||||
},
|
||||
"buildTime": 22,
|
||||
"builtBy": [
|
||||
"advancedvehicleplant"
|
||||
],
|
||||
"buildPower": 135,
|
||||
"buildRange": 200,
|
||||
"builds": [
|
||||
"advancedmassgen",
|
||||
"nuclearplant"
|
||||
],
|
||||
"sheetSlot": "unitSheet",
|
||||
"frame": 17,
|
||||
"procShape": "constructor",
|
||||
"icon": 33,
|
||||
"spritePx": 54,
|
||||
"moveSound": "sfx-engine-heavy"
|
||||
},
|
||||
{
|
||||
"id": "fighter",
|
||||
"name": "Fighter",
|
||||
|
|
@ -1182,6 +1215,38 @@
|
|||
"icon": 10,
|
||||
"idleAnimation": true
|
||||
},
|
||||
{
|
||||
"id": "nuclearplant",
|
||||
"name": "Nuclear Power Plant",
|
||||
"footprint": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"hp": 4200,
|
||||
"armorClass": "structure",
|
||||
"sight": 160,
|
||||
"cost": {
|
||||
"energy": 2200,
|
||||
"mass": 260
|
||||
},
|
||||
"buildTime": 42,
|
||||
"produce": {
|
||||
"energy": 80
|
||||
},
|
||||
"storage": {
|
||||
"energy": 900
|
||||
},
|
||||
"deathExplosion": {
|
||||
"radius": 220,
|
||||
"damage": 650
|
||||
},
|
||||
"upgradesFrom": "energygen",
|
||||
"sheetSlot": "structureSheet",
|
||||
"frame": 22,
|
||||
"buildFrame": 23,
|
||||
"procShape": "energyGen",
|
||||
"icon": 35
|
||||
},
|
||||
{
|
||||
"id": "massgen",
|
||||
"name": "Mass Generator",
|
||||
|
|
@ -1214,6 +1279,39 @@
|
|||
"icon": 11,
|
||||
"idleAnimation": true
|
||||
},
|
||||
{
|
||||
"id": "advancedmassgen",
|
||||
"name": "Advanced Metal Generator",
|
||||
"footprint": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"hp": 3200,
|
||||
"armorClass": "structure",
|
||||
"sight": 160,
|
||||
"cost": {
|
||||
"energy": 1400,
|
||||
"mass": 150
|
||||
},
|
||||
"buildTime": 30,
|
||||
"produce": {
|
||||
"mass": 8.0
|
||||
},
|
||||
"upkeep": {
|
||||
"energy": 60
|
||||
},
|
||||
"storage": {
|
||||
"mass": 400
|
||||
},
|
||||
"terrainMultiplier": "massMultiplier",
|
||||
"upgradesFrom": "massgen",
|
||||
"sheetSlot": "structureSheet",
|
||||
"frame": 24,
|
||||
"buildFrame": 25,
|
||||
"topperFrame": 26,
|
||||
"procShape": "massGen",
|
||||
"icon": 34
|
||||
},
|
||||
{
|
||||
"id": "barracks",
|
||||
"name": "Barracks",
|
||||
|
|
@ -1356,10 +1454,8 @@
|
|||
"buildTime": 55,
|
||||
"buildPower": 140,
|
||||
"builds": [
|
||||
"jeep",
|
||||
"tank",
|
||||
"rockettank",
|
||||
"megatank"
|
||||
"megatank",
|
||||
"advancedconstructor"
|
||||
],
|
||||
"spawnOffset": {
|
||||
"x": 0,
|
||||
|
|
|
|||
|
|
@ -671,7 +671,9 @@ const PROC_PAINTERS = {
|
|||
structure(scene, key, spec, rules) {
|
||||
const defs = rules.buildings.filter((b) => b.sheetSlot === 'structureSheet');
|
||||
let count = 0;
|
||||
for (const d of defs) count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1);
|
||||
for (const d of defs) {
|
||||
count = Math.max(count, d.frame + 1, (d.buildFrame ?? -1) + 1, (d.topperFrame ?? -1) + 1);
|
||||
}
|
||||
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
|
||||
for (const d of defs) {
|
||||
const draw = STRUCT_SHAPES[d.procShape];
|
||||
|
|
@ -684,6 +686,22 @@ const PROC_PAINTERS = {
|
|||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
// A generic spinner glyph — real toppers (the Advanced Metal Generator's centrifuge) get
|
||||
// real art; this is just enough for the frame to register and not render blank.
|
||||
if (d.topperFrame != null) {
|
||||
sh.at(d.topperFrame, (ctx, S) => {
|
||||
const c = S / 2, r = S * 0.3;
|
||||
ctx.strokeStyle = '#cfe3ff'; ctx.lineWidth = S * 0.035;
|
||||
ctx.beginPath(); ctx.arc(c, c, r, 0, Math.PI * 2); ctx.stroke();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const ang = (Math.PI / 2) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(c, c);
|
||||
ctx.lineTo(c + Math.cos(ang) * r, c + Math.sin(ang) * r);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return sh.finish();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -370,15 +370,58 @@ function buildCommand(state, rules, army, units, order, queue) {
|
|||
if (!(bdef.builds ?? []).includes(order.defId)) {
|
||||
return { ok: false, error: `${bdef.name} cannot build ${def.name}` };
|
||||
}
|
||||
const legal = canPlaceAt(state, rules, order.tx, order.ty, def);
|
||||
const legal = canPlaceAt(state, rules, order.tx, order.ty, def, army);
|
||||
if (!legal.ok) return legal;
|
||||
|
||||
// An upgrade building (Nuclear Power Plant over an Energy Generator, Advanced Metal Generator
|
||||
// over a Mass Generator) may land exactly on the friendly building it names in `upgradesFrom`
|
||||
// instead of needing clear ground — canPlaceAt already let this through, so re-find the same
|
||||
// target here and consume it.
|
||||
const upgradeTarget = findUpgradeTarget(state, army, order.tx, order.ty, def);
|
||||
if (upgradeTarget) reclaimUpgradeTarget(state, rules, army, upgradeTarget);
|
||||
|
||||
const site = placeBuilding(state, rules, army, order.defId, order.tx, order.ty);
|
||||
if (!site) return { ok: false, error: 'placement failed' };
|
||||
pushOrder(builder, { type: 'build', targetId: site.id }, queue);
|
||||
return { ok: true, siteId: site.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* A friendly, finished building of `def.upgradesFrom`'s type sitting in EXACTLY this footprint
|
||||
* — the only shape of "occupied" an upgrade building is allowed to land on. Deliberately not a
|
||||
* looser "overlaps anywhere" match: a 2x2 upgrade has to replace a 2x2 original one-for-one, not
|
||||
* clip a neighbour.
|
||||
*/
|
||||
function findUpgradeTarget(state, army, tx, ty, def) {
|
||||
if (!def.upgradesFrom) return null;
|
||||
for (const e of state.entities) {
|
||||
if (e.dead || e.site || !e.isBuilding || e.army !== army) continue;
|
||||
if (e.defId !== def.upgradesFrom) continue;
|
||||
if (e.tx === tx && e.ty === ty && e.fw === def.footprint.w && e.fh === def.footprint.h) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume a building being upgraded in place: half its own build cost comes back (a genuine
|
||||
* refund, not a discount on the new building's price), and it disappears quietly — no death
|
||||
* explosion, no kill/loss stat, no 'unitDestroyed' blast FX. This is recycling your own
|
||||
* structure to make room for its replacement, not losing it to the enemy.
|
||||
*/
|
||||
function reclaimUpgradeTarget(state, rules, army, target) {
|
||||
const tdef = rules.buildingById[target.defId];
|
||||
const a = state.armies[army];
|
||||
a.energy = Math.min(a.energyCap, a.energy + (tdef.cost?.energy ?? 0) * 0.5);
|
||||
a.mass = Math.min(a.massCap, a.mass + (tdef.cost?.mass ?? 0) * 0.5);
|
||||
target.dead = true;
|
||||
stampFootprint(state.nav, rules, target.tx, target.ty, target.fw, target.fh, false);
|
||||
recomputeEconomyCaps(state, rules);
|
||||
for (const o of state.entities) {
|
||||
if (o.buildTargetId === target.id) o.buildTargetId = 0;
|
||||
if (o.targetId === target.id) o.targetId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one order from a unit's queue by index — the missing counterpart to `stop`, which
|
||||
* only ever clears the whole queue. Cancelling the active order (index 0) gets the same state
|
||||
|
|
@ -418,12 +461,20 @@ export function cancelOrder(state, rules, army, unitId, index) {
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Can this footprint go here? Checks bounds, terrain buildability and occupancy. */
|
||||
export function canPlaceAt(state, rules, tx, ty, def) {
|
||||
/**
|
||||
* Can this footprint go here? Checks bounds, terrain buildability and occupancy.
|
||||
*
|
||||
* `army` is optional and only needed to recognise an upgrade placement (see `upgradesFrom`) —
|
||||
* every caller that doesn't pass one just gets the plain "is this ground clear" answer, which
|
||||
* is what siting logic that has no notion of ownership (the AI's placement search, verify
|
||||
* fixtures) actually wants anyway.
|
||||
*/
|
||||
export function canPlaceAt(state, rules, tx, ty, def, army = null) {
|
||||
const fw = def.footprint.w, fh = def.footprint.h;
|
||||
if (tx < 0 || ty < 0 || tx + fw > state.w || ty + fh > state.h) {
|
||||
return { ok: false, error: 'off the map' };
|
||||
}
|
||||
if (army != null && findUpgradeTarget(state, army, tx, ty, def)) return { ok: true };
|
||||
for (let y = ty; y < ty + fh; y++) {
|
||||
for (let x = tx; x < tx + fw; x++) {
|
||||
const i = y * state.w + x;
|
||||
|
|
|
|||
|
|
@ -303,6 +303,9 @@ export function compileRules(json) {
|
|||
fail(`building "${b.id}" needs a footprint of at least 1x1 tiles`);
|
||||
}
|
||||
if (!Number.isInteger(b.frame) || b.frame < 0) fail(`building "${b.id}" needs a non-negative integer frame`);
|
||||
if (b.topperFrame != null && (!Number.isInteger(b.topperFrame) || b.topperFrame < 0)) {
|
||||
fail(`building "${b.id}" topperFrame must be a non-negative integer`);
|
||||
}
|
||||
if (b.terrainMultiplier && !terrain.some((t) => b.terrainMultiplier in t)) {
|
||||
fail(`building "${b.id}" terrainMultiplier "${b.terrainMultiplier}" is on no terrain type`);
|
||||
}
|
||||
|
|
@ -355,6 +358,15 @@ export function compileRules(json) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// `upgradesFrom` names the building a def may be placed directly on top of, reclaiming half
|
||||
// its build cost (see TALogic.buildCommand/findUpgradeTarget) — must be a real building, and
|
||||
// one with a real cost to actually reclaim from.
|
||||
for (const b of buildings) {
|
||||
if (b.upgradesFrom == null) continue;
|
||||
const from = buildingById[b.upgradesFrom];
|
||||
if (!from) fail(`building "${b.id}" upgradesFrom references unknown building "${b.upgradesFrom}"`);
|
||||
else if (!from.cost) fail(`building "${b.id}" upgradesFrom "${b.upgradesFrom}" has no cost to reclaim`);
|
||||
}
|
||||
|
||||
// ---- victory modes -----------------------------------------------------
|
||||
// The engine understands two rules: "commander" (an army dies with its Commander) and
|
||||
|
|
|
|||
|
|
@ -384,6 +384,17 @@ export default class TAWorldView {
|
|||
this._addWorld(turret);
|
||||
}
|
||||
|
||||
// A building's decorative topper (e.g. the Advanced Metal Generator's centrifuge): a second
|
||||
// sprite stacked on the roof that spins continuously and fast, independent of the slow
|
||||
// idleAnimation breathing/turn every other resource building gets — this is meant to read as
|
||||
// active machinery, not ambient scenery. Hidden while under construction (see render()).
|
||||
let topper = null;
|
||||
if (def.isBuilding && def.topperFrame != null) {
|
||||
topper = this.scene.add.image(e.x, e.y, key, def.topperFrame);
|
||||
topper.setDisplaySize(def.footprint.w * this.ts, def.footprint.h * this.ts);
|
||||
this._addWorld(topper);
|
||||
}
|
||||
|
||||
// Anything that leaves the ground gets a flattened black copy of its own frame beneath it.
|
||||
let shadow = null;
|
||||
if (def.flight) {
|
||||
|
|
@ -394,7 +405,7 @@ export default class TAWorldView {
|
|||
// `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,
|
||||
img, turret, final, shadow, topper, defId: e.defId,
|
||||
alt: 0, baseScale: img.scaleX,
|
||||
batchDone: 0, batchPrev: 0, // production-dial accounting, see _drawProductionPies
|
||||
};
|
||||
|
|
@ -409,6 +420,7 @@ export default class TAWorldView {
|
|||
s.turret?.destroy();
|
||||
s.final?.destroy();
|
||||
s.shadow?.destroy();
|
||||
s.topper?.destroy();
|
||||
this.sprites.delete(id);
|
||||
}
|
||||
|
||||
|
|
@ -449,6 +461,7 @@ export default class TAWorldView {
|
|||
if (s.turret) s.turret.setVisible(shown);
|
||||
if (s.final) s.final.setVisible(shown);
|
||||
if (s.shadow) s.shadow.setVisible(shown);
|
||||
if (s.topper) s.topper.setVisible(shown);
|
||||
if (!shown) continue;
|
||||
|
||||
const x = e.px + (e.x - e.px) * alpha;
|
||||
|
|
@ -520,6 +533,21 @@ export default class TAWorldView {
|
|||
s.img.setRotation((s.idleRot.turns + turnT) * (Math.PI / 2));
|
||||
}
|
||||
|
||||
// The topper (e.g. the Advanced Metal Generator's centrifuge) spins fast and continuously
|
||||
// off real time rather than sim ticks, so it stays smooth regardless of simSpeed and never
|
||||
// needs its own per-entity timer state. Hidden while the site is still a wireframe skeleton
|
||||
// — a spinning centrifuge on unfinished girders would read as broken, not "under construction".
|
||||
if (s.topper) {
|
||||
s.topper.setPosition(x, y);
|
||||
s.topper.setDepth(band + (y / state.worldH) * 10 + 0.06);
|
||||
if (e.site) {
|
||||
s.topper.setAlpha(0);
|
||||
} else {
|
||||
const spinMs = 1800; // one full rotation every 1.8s — fast enough to read as active
|
||||
s.topper.setAlpha(1).setRotation(((this.scene.time.now % spinMs) / spinMs) * Math.PI * 2);
|
||||
}
|
||||
}
|
||||
|
||||
if (s.turret) {
|
||||
const tr = lerpAngle(e.pturretRot, e.turretRot, alpha);
|
||||
s.turret.setPosition(x, y - lift).setRotation(tr);
|
||||
|
|
|
|||
|
|
@ -884,7 +884,7 @@ export default class TotalAnnihilationGame extends Phaser.Scene {
|
|||
this.placement.tx = tx; this.placement.ty = ty;
|
||||
this.view.placement = {
|
||||
def, tx, ty,
|
||||
legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def),
|
||||
legal: Logic.canPlaceAt(this.match, this.rules, tx, ty, def, this.playerArmy),
|
||||
builderX: builder?.x, builderY: builder?.y,
|
||||
buildRange: this.rules.defById[builder?.defId ?? '']?.buildRange ?? 0,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ Frames are numbered **left to right, then top to bottom**, starting at 0.
|
|||
|
||||
---
|
||||
|
||||
## Unit sheets (64×64 cells, 8 per row)
|
||||
## Unit sheets (128×128 cells, 8 per row)
|
||||
|
||||
Both army sheets use the identical frame layout — that is what lets one unit definition serve
|
||||
every army. `spritePx` is the on-screen size the frame is scaled to; draw to fill the cell and
|
||||
|
|
@ -68,6 +68,7 @@ let the scale do the work.
|
|||
| 14 | Hover Constructor | skirted hull over a plenum, nanolathe crane, no tracks | 52 |
|
||||
| 15 | Megatank | hull + tracks, wider than the Tank | 66 |
|
||||
| 16 | Megatank turret | twin cannon barrels + side rocket pods | 66 |
|
||||
| 17 | Advanced Construction Vehicle | tracked hull, twin nanolathe cranes, no gun | 54 |
|
||||
|
||||
### Units that leave the ground
|
||||
|
||||
|
|
@ -120,13 +121,14 @@ independently, so the unit aims while it drives. Rules:
|
|||
sits at the cell centre (64, 64 for the current 128×128 unit cells), with the barrel
|
||||
extending to the **right**.
|
||||
- Leave the rest of the turret cell transparent. The hull shows through it.
|
||||
- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, and all three
|
||||
Airfield units) simply has no turret frame; its whole sprite rotates to face its target. The
|
||||
Construction Vehicle and Hover Constructor are unarmed, so they never turn to aim regardless —
|
||||
they just rotate to face wherever they're moving. Aircraft have fixed forward guns, so they
|
||||
aim by pointing the whole airframe, which is why they have no turret either.
|
||||
- A unit with no turret (infantry, sniper, rocket trooper, construction vehicle, advanced
|
||||
construction vehicle, and all three Airfield units) simply has no turret frame; its whole
|
||||
sprite rotates to face its target. The two Construction Vehicles and the Hover Constructor are
|
||||
unarmed, so they never turn to aim regardless — they just rotate to face wherever they're
|
||||
moving. Aircraft have fixed forward guns, so they aim by pointing the whole airframe, which is
|
||||
why they have no turret either.
|
||||
|
||||
Frames 17–63 are free. Add a unit by appending a definition to `units[]` in
|
||||
Frames 18–63 are free. Add a unit by appending a definition to `units[]` in
|
||||
`data/totalannihilation-rules.json` with its `frame` (and optional `turretFrame`) — no code.
|
||||
|
||||
A weapon may declare `barrels` (default 1) and, when `barrels > 1`, a `barrelSpacing` in world
|
||||
|
|
@ -173,6 +175,11 @@ build completes; the finished frame crossfades in underneath starting at 20% pro
|
|||
| 19 | Advanced Radar — under construction | 2×2 | |
|
||||
| 20 | Radar Jammer | 2×2 | squat emitter, no dish, broken rings — **placeholder** |
|
||||
| 21 | Radar Jammer — under construction | 2×2 | |
|
||||
| 22 | Nuclear Power Plant | 2×2 | cooling towers + reactor dome |
|
||||
| 23 | Nuclear Power Plant — under construction | 2×2 | |
|
||||
| 24 | Advanced Metal Generator | 2×2 | circular receptacle in the centre — the topper (frame 26) sits into it |
|
||||
| 25 | Advanced Metal Generator — under construction | 2×2 | |
|
||||
| 26 | Advanced Metal Generator topper — spinning centrifuge | — | **not a building**, see below |
|
||||
|
||||
The three sensor structures share a visual language deliberately: a low pad with corner
|
||||
footings and **no armoured plinth**, so a glance separates "sensor" from "gun". They never
|
||||
|
|
@ -188,7 +195,36 @@ Defensive structures never rotate, and their mount traverses freely in the simul
|
|||
shoot in every direction regardless of how the art is drawn. Draw them facing **up** and do not
|
||||
imply a firing arc.
|
||||
|
||||
Frames 22–23 are free in the current 6×4 sheet; growing it another row adds six more.
|
||||
**Frame 26 is a `topperFrame`, not a second building.** A building def may name one
|
||||
(`"topperFrame": 26` on `advancedmassgen`) to get a second sprite stacked on top of the finished
|
||||
structure, at the same display size as the building itself, that spins **continuously and
|
||||
fast** — 360° every 1.8s, real time, independent of `idleAnimation`'s slow whole-building
|
||||
breathing/turn (`TAWorldView`'s `render()`, the block right after the `idleAnimation` one).
|
||||
Unlike a unit's `turretFrame`, a topper never aims at anything; it just spins, and it fades in
|
||||
only once the building completes (hidden throughout the wireframe/build phase — a spinning
|
||||
centrifuge on unfinished girders would read as broken machinery, not construction). Draw a
|
||||
topper's frame centred exactly like a turret's, with matching transparent padding to whatever
|
||||
extent it should read as smaller than the building itself — the renderer scales the whole cell
|
||||
uniformly, so the padding you draw is the only thing controlling how big it looks once placed.
|
||||
`sh.at(frame, ...)` isn't required for the sheet to hold it — see `mkCanvasSheet.finish()` — but
|
||||
`TAArt.js`'s `structure` painter does draw a generic spinning-glyph fallback for any topper.
|
||||
|
||||
**`upgradesFrom` lets a building be placed directly on top of a friendly building it names,
|
||||
consuming it for a 50% refund of ITS OWN build cost** (not a discount on the new building's
|
||||
price — a genuine resource credit) instead of requiring clear ground. Both `nuclearplant`
|
||||
(`upgradesFrom: "energygen"`) and `advancedmassgen` (`upgradesFrom: "massgen"`) use it. The
|
||||
match has to be exact — same tile position AND same footprint size as the building being
|
||||
replaced, checked by `TALogic.findUpgradeTarget` — so this is "swap this generator for its
|
||||
advanced version," not "clear whatever's nearby." The old building disappears quietly: no death
|
||||
explosion, no kill/loss stat, no 'unitDestroyed' blast FX, because reclaiming your own structure
|
||||
to upgrade it isn't the same event as losing it. `TALogic.canPlaceAt` takes an optional 5th
|
||||
`army` argument to recognise this — omit it (as the AI's placement search and most of the verify
|
||||
fixtures do) and you get the plain "is this ground clear" answer with no upgrade awareness at
|
||||
all, which is the deliberately conservative default for any caller that doesn't actually know
|
||||
whose building is sitting there.
|
||||
|
||||
**No frames remain free in the current 6×6 sheet** (grown from 4 rows to 6 for this session,
|
||||
1152×1152) — growing it another row is the next move for anything new.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -260,10 +296,12 @@ preview size.
|
|||
| 20–29 | Commands: move, attack, attack-move, stop, hold, patrol, guard, assist, repair, rally |
|
||||
| 30–31 | Advanced radar, radar jammer |
|
||||
| 32 | Megatank (unit) |
|
||||
| 33 | Advanced Construction Vehicle (unit) |
|
||||
| 34–35 | Advanced Metal Generator, Nuclear Power Plant |
|
||||
|
||||
Row 0 filled up before the Airfield units were added, which is why the hover constructor sits
|
||||
next to the buildings at 17 rather than with the other units. Nothing reads a row as a
|
||||
category — only the `icon` numbers matter. Frames 33–39 are free.
|
||||
category — only the `icon` numbers matter. Frames 36–39 are free.
|
||||
|
||||
### Painting priority: buildings the Commander and Construction Vehicle put up
|
||||
|
||||
|
|
|
|||
|
|
@ -139,6 +139,8 @@ section('2. Artwork manifest');
|
|||
const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8);
|
||||
check(`${d.id} frame ${d.frame} fits ${sheet.key}`, d.frame < cap);
|
||||
if (d.turretFrame != null) check(`${d.id} turret frame fits`, d.turretFrame < cap);
|
||||
if (d.buildFrame != null) check(`${d.id} build frame fits`, d.buildFrame < cap);
|
||||
if (d.topperFrame != null) check(`${d.id} topper frame fits`, d.topperFrame < cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -216,6 +218,9 @@ section('2b. Procedural art actually paints');
|
|||
if (d.buildFrame != null) {
|
||||
check(`${d.id} build frame was painted`, tex?.frames.includes(d.buildFrame));
|
||||
}
|
||||
if (d.topperFrame != null) {
|
||||
check(`${d.id} topper frame was painted`, tex?.frames.includes(d.topperFrame));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [name, sheet] of Object.entries(artJson.sheets ?? {})) {
|
||||
|
|
@ -694,6 +699,81 @@ section('3. Economy fixtures');
|
|||
check('some terrain carries that multiplier', !!multTerrain);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('3b. Building upgrades in place');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// The Advanced Metal Generator / Nuclear Power Plant can be placed directly on top of a
|
||||
// friendly Mass/Energy Generator instead of needing clear ground, reclaiming half the older
|
||||
// building's cost. This exercises the whole path end to end: canPlaceAt(..., army) recognising
|
||||
// the spot, buildCommand consuming the old building and crediting the refund, and a real site
|
||||
// for the new building landing exactly where the old one stood.
|
||||
const map = generateMap(rules, { seed: 40, size: 'small', symmetry: 'mirror-x' });
|
||||
const cmdStart = map.starts.find((s) => s.army === 0) ?? map.starts[0];
|
||||
const tx = cmdStart.x + 3, ty = cmdStart.y;
|
||||
const st = L.createMatch(rules, {
|
||||
seed: 40, map: { ...map, buildings: [{ army: 0, type: 'massgen', tx, ty }] },
|
||||
armies: [{ armyId: 'arm' }, { armyId: 'core' }],
|
||||
});
|
||||
const a = st.armies[0];
|
||||
// Headroom below the cap, or the refund's Math.min(cap, ...) clamp would silently eat it and
|
||||
// this fixture would "pass" while testing nothing.
|
||||
a.massCap = 5000; a.energyCap = 5000; a.mass = 2500; a.energy = 2500;
|
||||
|
||||
const oldGen = st.entities.find((e) => e.defId === 'massgen' && !e.dead);
|
||||
check('fixture mass generator placed', !!oldGen);
|
||||
|
||||
const advDef = rules.buildingById.advancedmassgen;
|
||||
check('advanced metal generator declares upgradesFrom massgen', advDef.upgradesFrom === 'massgen');
|
||||
const builder = L.spawnUnit(st, rules, 0, 'advancedconstructor', oldGen.x + 300, oldGen.y);
|
||||
const massBefore = a.mass;
|
||||
const expectedRefund = rules.buildingById.massgen.cost.mass * 0.5;
|
||||
|
||||
const r = L.issueOrder(st, rules, {
|
||||
army: 0, unitIds: [builder.id],
|
||||
order: { type: 'build', defId: 'advancedmassgen', tx, ty },
|
||||
});
|
||||
check('advanced metal generator order accepted directly on the mass generator', r.ok, r.error);
|
||||
check('the old mass generator is gone', !!oldGen.dead);
|
||||
check('half the mass generator\'s build cost was refunded',
|
||||
Math.abs((a.mass - massBefore) - expectedRefund) < 1e-6,
|
||||
`+${(a.mass - massBefore).toFixed(1)} vs expected +${expectedRefund}`);
|
||||
const site = st.entities.find((e) => e.id === r.siteId);
|
||||
check('a new advanced metal generator site stands in its exact footprint',
|
||||
!!site && site.defId === 'advancedmassgen' && site.tx === tx && site.ty === ty && site.site === true);
|
||||
|
||||
// Fresh ground still works — upgrading in place is an alternative, not a replacement.
|
||||
let fx = tx + 6, fy = ty;
|
||||
while (!L.canPlaceAt(st, rules, fx, fy, advDef, 0).ok && fx < st.w - 4) fx++;
|
||||
const r2 = L.issueOrder(st, rules, {
|
||||
army: 0, unitIds: [builder.id],
|
||||
order: { type: 'build', defId: 'advancedmassgen', tx: fx, ty: fy },
|
||||
});
|
||||
check('advanced metal generator also builds on fresh ground', r2.ok, r2.error);
|
||||
|
||||
// Ownership: an enemy's finished mass generator must not be upgradeable.
|
||||
const enemyGen = L.placeBuilding(st, rules, 1, 'massgen', tx + 20, ty);
|
||||
enemyGen.site = false; enemyGen.progress = 1; enemyGen.hp = rules.buildingById.massgen.hp;
|
||||
check('cannot upgrade an enemy\'s mass generator', !L.canPlaceAt(st, rules, tx + 20, ty, advDef, 0).ok);
|
||||
|
||||
// Type mismatch: an Advanced Metal Generator's upgradesFrom is massgen, not energygen.
|
||||
const wrongType = L.placeBuilding(st, rules, 0, 'energygen', tx + 30, ty);
|
||||
wrongType.site = false; wrongType.progress = 1; wrongType.hp = rules.buildingById.energygen.hp;
|
||||
check('cannot upgrade a different building type', !L.canPlaceAt(st, rules, tx + 30, ty, advDef, 0).ok);
|
||||
|
||||
// The reverse pairing: a Nuclear Power Plant upgrades that same Energy Generator.
|
||||
const nukeDef = rules.buildingById.nuclearplant;
|
||||
check('nuclear power plant declares upgradesFrom energygen', nukeDef.upgradesFrom === 'energygen');
|
||||
check('nuclear power plant can place directly on that energy generator',
|
||||
L.canPlaceAt(st, rules, tx + 30, ty, nukeDef, 0).ok);
|
||||
|
||||
// Without an army argument, canPlaceAt must fall back to the plain occupancy check — callers
|
||||
// that don't know ownership (the AI's placement search, most of this very test file) should
|
||||
// never silently start recognising upgrade spots.
|
||||
check('canPlaceAt without an army never grants an upgrade placement',
|
||||
!L.canPlaceAt(st, rules, tx + 30, ty, nukeDef).ok);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('4. Pathfinding');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue