Updated with new unit and better AI

This commit is contained in:
Brian Fertig 2026-08-17 20:27:48 -06:00
parent 347957bd84
commit 73de46e0f1
12 changed files with 296 additions and 18 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

View File

@ -44,6 +44,8 @@
"retargetPeriodTicks": 10, "retargetPeriodTicks": 10,
"engageHoldFraction": 0.45, "engageHoldFraction": 0.45,
"siegeHoldFraction": 0.95, "siegeHoldFraction": 0.95,
"standoffMarginIn": 60,
"standoffMarginOut": 160,
"separationStiffness": 0.55, "separationStiffness": 0.55,
"shoveAfterSec": 0.6, "shoveAfterSec": 0.6,
"stuckGiveUpSec": 3.0, "stuckGiveUpSec": 3.0,
@ -707,6 +709,37 @@
}, },
"sound": "sfx-scifi-launch", "sound": "sfx-scifi-launch",
"impactSound": "sfx-ta-nuclear" "impactSound": "sfx-ta-nuclear"
},
{
"id": "siegerocket",
"name": "Siege Rocket",
"kind": "ballistic",
"damage": 280,
"reload": 9.0,
"range": 760,
"speed": 170,
"aoe": 100,
"aoeFalloff": 0.3,
"targets": ["ground"],
"armorMul": {
"infantry": 0.4,
"light": 1.2,
"medium": 1.3,
"heavy": 1.3,
"structure": 1.7,
"fortification": 2.1,
"air": 0
},
"fx": {
"style": "sprite",
"frame": 19,
"arc": 140,
"color": "#ff9c4a",
"width": 3,
"trail": true
},
"sound": "sfx-ta-rocket-1",
"impactSound": "sfx-ta-rocket-2"
} }
], ],
"units": [ "units": [
@ -1180,6 +1213,35 @@
"icon": 17, "icon": 17,
"spritePx": 52, "spritePx": 52,
"moveSound": "sfx-engine-medium" "moveSound": "sfx-engine-medium"
},
{
"id": "rocketartillery",
"name": "Rocket Artillery",
"role": "artillery",
"size": "medium",
"radius": 30,
"hp": 550,
"speed": 55,
"turnRate": 1.5,
"moveClass": "tread",
"armorClass": "medium",
"sight": 300,
"cost": {
"energy": 2600,
"mass": 850
},
"buildTime": 65,
"builtBy": [
"advancedvehicleplant"
],
"weapons": ["siegerocket"],
"sheetSlot": "unitSheet",
"frame": 18,
"procShape": "rocketArtillery",
"icon": 36,
"spritePx": 58,
"moveSound": "sfx-engine-heavy",
"standoffRange": true
} }
], ],
"buildings": [ "buildings": [
@ -1455,7 +1517,8 @@
"buildPower": 140, "buildPower": 140,
"builds": [ "builds": [
"megatank", "megatank",
"advancedconstructor" "advancedconstructor",
"rocketartillery"
], ],
"spawnOffset": { "spawnOffset": {
"x": 0, "x": 0,

View File

@ -39,6 +39,7 @@ function memFor(state, armyIdx) {
scoutId: 0, airPreyId: 0, lastThreatTick: -99999, scoutId: 0, airPreyId: 0, lastThreatTick: -99999,
knownEnemies: [], // [{x,y,defId,isBuilding,tick}] knownEnemies: [], // [{x,y,defId,isBuilding,tick}]
lastAttackTick: -99999, lastAttackTick: -99999,
waveFailStreak: 0, attackTargetId: 0,
buildSpiral: 0, buildSpiral: 0,
baseX: 0, baseY: 0, baseSet: false, baseX: 0, baseY: 0, baseSet: false,
}; };
@ -587,7 +588,7 @@ function enemyBaseGuess(ctx) {
const { state, armyIdx, mem } = ctx; const { state, armyIdx, mem } = ctx;
// Prefer something we've actually seen; fall back to the enemy's start position. // Prefer something we've actually seen; fall back to the enemy's start position.
const building = mem.knownEnemies.find((k) => k.isBuilding); const building = mem.knownEnemies.find((k) => k.isBuilding);
if (building) return { x: building.x, y: building.y }; if (building) return { x: building.x, y: building.y, id: building.id, isBuilding: true };
if (mem.knownEnemies.length) return { x: mem.knownEnemies[0].x, y: mem.knownEnemies[0].y }; if (mem.knownEnemies.length) return { x: mem.knownEnemies[0].x, y: mem.knownEnemies[0].y };
// Nothing in sight. If we've already been to the enemy start, sweep the map instead of // Nothing in sight. If we've already been to the enemy start, sweep the map instead of
@ -717,7 +718,11 @@ function manageMilitary(ctx, mine) {
const target = enemyBaseGuess(ctx); const target = enemyBaseGuess(ctx);
if (!target) return; if (!target) return;
const wantSize = Math.max(2, Math.round(tuning.squadSize * (0.6 + aggression * 0.8))); // A previous push that got gutted makes the AI hold back for a bigger next wave; a
// confirmed kill eases that back off. Capped so a permanently stronger enemy doesn't push
// the AI toward hoarding its whole army forever.
const escalation = 1 + Math.min(mem.waveFailStreak, 3) * 0.3;
const wantSize = Math.max(2, Math.round(tuning.squadSize * (0.6 + aggression * 0.8) * escalation));
if (mem.phase !== 'attack') { if (mem.phase !== 'attack') {
if (mem.squad.length < wantSize) { if (mem.squad.length < wantSize) {
// Gather near the base while we build up. // Gather near the base while we build up.
@ -733,12 +738,27 @@ function manageMilitary(ctx, mine) {
mem.phase = 'attack'; mem.phase = 'attack';
mem.squadPeak = mem.squad.length; mem.squadPeak = mem.squad.length;
mem.lastAttackTick = state.tick; mem.lastAttackTick = state.tick;
// Only a building target gives a trustworthy "it's dead" signal (knownEnemies only ever
// drops a building entry when it dies, never on a sight-line timeout) — that's what lets
// us tell a won push apart from one that just lost track of a fleeing unit.
mem.attackTargetId = target.isBuilding ? target.id : 0;
} }
if (mem.phase === 'attack') { if (mem.phase === 'attack') {
// Break off if the push has been gutted — skill 2+ knows when it's lost a fight. // The building we pushed on is confirmed dead — ease off the escalation and go pick a
if (skill >= 2 && mem.squad.length < mem.squadPeak * 0.4) { // new target instead of grinding the same attackMove into empty ground.
if (mem.attackTargetId && !mem.knownEnemies.some((k) => k.id === mem.attackTargetId)) {
mem.waveFailStreak = Math.max(0, mem.waveFailStreak - 1);
mem.attackTargetId = 0;
mem.phase = 'build'; mem.phase = 'build';
return;
}
// Break off if the push has been gutted — skill 2+ knows when it's lost a fight, and
// brings a bigger wave next time rather than trickling the same size back in.
if (skill >= 2 && mem.squad.length < mem.squadPeak * 0.4) {
mem.waveFailStreak = Math.min(mem.waveFailStreak + 1, 3);
mem.phase = 'build';
mem.attackTargetId = 0;
order(ctx, { order(ctx, {
army: armyIdx, unitIds: mem.squad.slice(), army: armyIdx, unitIds: mem.squad.slice(),
order: { type: 'move', x: mem.baseX, y: mem.baseY }, order: { type: 'move', x: mem.baseX, y: mem.baseY },

View File

@ -230,6 +230,24 @@ const UNIT_SHAPES = {
ctx.restore(); ctx.restore();
}, },
// No turret — whole body rotates. Same tread-vehicle base as the combat tanks, topped with
// a fixed, forward-angled launch rack (longer and heavier than rocketTrooper's shoulder
// tube, since this is a vehicle-scale siege weapon) so the silhouette reads as artillery
// rather than another tank. Placeholder only — real painted art already exists at frame 18
// on both army sheets.
rocketArtillery(ctx, S) {
tankHull(ctx, S, 40, 26);
const c = S / 2;
ctx.save(); ctx.translate(c, c);
ctx.strokeStyle = OUTLINE; ctx.lineWidth = 5;
ctx.beginPath(); ctx.moveTo(2, -2); ctx.lineTo(22, -10); ctx.stroke();
ctx.strokeStyle = ACCENT; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.moveTo(2, -2); ctx.lineTo(22, -10); ctx.stroke();
ctx.fillStyle = HOT;
ctx.beginPath(); ctx.arc(22, -10, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.restore();
},
// Aircraft are drawn as a plan-view airframe: swept wings well behind the nose, a tail // Aircraft are drawn as a plan-view airframe: swept wings well behind the nose, a tail
// plane, and no tracks or wheels anywhere. That silhouette is the only thing telling the // plane, and no tracks or wheels anywhere. That silhouette is the only thing telling the
// player at a glance that a unit is in a layer their tanks cannot shoot at. // player at a glance that a unit is in a layer their tanks cannot shoot at.
@ -655,6 +673,12 @@ const PROC_PAINTERS = {
const defs = rules.units.filter((u) => u.sheetSlot === 'unitSheet'); const defs = rules.units.filter((u) => u.sheetSlot === 'unitSheet');
let count = 0; let count = 0;
for (const d of defs) count = Math.max(count, d.frame + 1, (d.turretFrame ?? -1) + 1); for (const d of defs) count = Math.max(count, d.frame + 1, (d.turretFrame ?? -1) + 1);
// A sprite-style weapon projectile (e.g. the Rocket Artillery's shell) also lives on the
// unit sheet, at a frame no unit def otherwise claims — size the fallback canvas to cover
// it too, or a missing-art run would generate a sheet one frame too short to hold it.
for (const w of rules.weapons) {
if (w.fx?.style === 'sprite') count = Math.max(count, w.fx.frame + 1);
}
const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count); const sh = mkCanvasSheet(scene, key, spec.frameWidth, spec.frameHeight, spec.cols, count);
for (const d of defs) { for (const d of defs) {
const hull = UNIT_SHAPES[d.procShape]; const hull = UNIT_SHAPES[d.procShape];

View File

@ -130,6 +130,9 @@ export default class TAFx {
// ---- live projectiles (over actors) ---- // ---- live projectiles (over actors) ----
for (const p of projectiles) { for (const p of projectiles) {
// Sprite-style shots (the Rocket Artillery's shell) are drawn as a real Image+shadow
// pair by TAWorldView's projectile pool instead — this loop only owns the vector body.
if (p.style === 'sprite') continue;
const ang = Math.atan2(p.vy, p.vx); const ang = Math.atan2(p.vy, p.vx);
if (p.style === 'rocket') { if (p.style === 'rocket') {
const bx = p.x - Math.cos(ang) * 9, by = p.y - Math.sin(ang) * 9; const bx = p.x - Math.cos(ang) * 9, by = p.y - Math.sin(ang) * 9;

View File

@ -918,10 +918,15 @@ const MOVE_LIKE = new Set(['move', 'attackMove', 'patrol']);
* measured at 64 games, closing all the way in ran the skill ladder at 90% and holding at * measured at 64 games, closing all the way in ran the skill ladder at 90% and holding at
* range dropped it to 68%, so this stays narrowly scoped to targets that can hurt you. * range dropped it to 68%, so this stays narrowly scoped to targets that can hurt you.
*/ */
function holdFractionFor(rules, target) { function holdFractionFor(rules, attackerDef, target) {
const c = rules.constants; const c = rules.constants;
// A unit that carries `standoffRange` stops at the same near-maximum fraction against
// ANYTHING it's closing on, mobile or not — the whole point of the flag is that it never
// willingly gives up its range advantage, unlike every other unit's low default below
// against a mobile target.
if (attackerDef?.standoffRange) return c.siegeHoldFraction ?? 0.95;
const def = rules.defById[target.defId]; const def = rules.defById[target.defId];
return def?.weaponDefs?.length ? (c.siegeHoldFraction ?? 0.95) : (c.engageHoldFraction ?? 0.85); return def?.weaponDefs?.length ? (c.siegeHoldFraction ?? 0.95) : (c.engageHoldFraction ?? 0.45);
} }
/** A point `dist` ahead of where `e` is pointing. */ /** A point `dist` ahead of where `e` is pointing. */
@ -940,6 +945,49 @@ function hostileBuildingNear(state, rules, army, x, y, radius) {
return best; return best;
} }
/**
* Nearest hostile (unit or building) whose OWN weapon range could reach `e` from where it
* currently stands, plus a safety margin. Used only by `standoffRange` units, so it can never
* affect the movement of anything else in the roster.
*
* Hysteresis: `e._fleeingId` remembers what we're currently backing away from. While it's
* still that same threat, the wider `standoffMarginOut` boundary has to be cleared before we
* consider ourselves safe again; a newly-noticed threat only trips the tighter
* `standoffMarginIn` boundary. Without this a unit sitting near the trigger line would flicker
* between "retreating" and "closing" every tick.
*/
function findNearestThreat(state, rules, e, near) {
const c = rules.constants;
const marginIn = c.standoffMarginIn ?? 60;
const marginOut = c.standoffMarginOut ?? 160;
let best = null, bestGap = Infinity;
const consider = (t) => {
if (t.dead || t.army === e.army || !state.armies[t.army]) return;
const tdef = defOf(rules, t);
if (!tdef.weaponDefs?.length || !canEngage(tdef, e)) return;
const margin = e._fleeingId === t.id ? marginOut : marginIn;
const gap = surfaceDist(rules, e, t) - (tdef.maxRange + margin);
if (gap > 0 || gap >= bestGap) return;
best = t; bestGap = gap;
};
near.length = 0;
state.hash.query(e.x, e.y, state.tileSize * 16, near);
for (const t of near) consider(t);
for (const t of state.entities) if (t.isBuilding && !t.dead) consider(t);
return best;
}
/** One tactical step directly away from `threat`, clamped inside the map bounds. */
function stepAwayFrom(state, e, threat) {
const dx = e.x - threat.x, dy = e.y - threat.y;
const dist = Math.hypot(dx, dy) || 1;
const hop = Math.max(80, e.radius * 2);
return {
x: Math.max(e.radius, Math.min(state.worldW - e.radius, e.x + (dx / dist) * hop)),
y: Math.max(e.radius, Math.min(state.worldH - e.radius, e.y + (dy / dist) * hop)),
};
}
/** /**
* Where a strafing aircraft should point right now, given the thing it is attacking. * Where a strafing aircraft should point right now, given the thing it is attacking.
* *
@ -1090,6 +1138,7 @@ function stepFlight(state, rules) {
function stepOrders(state, rules) { function stepOrders(state, rules) {
const ts = state.tileSize; const ts = state.tileSize;
const near = [];
for (const e of state.entities) { for (const e of state.entities) {
if (e.dead || e.site) continue; if (e.dead || e.site) continue;
if (e.isBuilding) continue; if (e.isBuilding) continue;
@ -1168,11 +1217,23 @@ function stepOrders(state, rules) {
// //
// Deliberately buildings only. Applying it to mobile targets as well made every // Deliberately buildings only. Applying it to mobile targets as well made every
// squad halt at maximum range the instant it saw anything, which took the sting out // squad halt at maximum range the instant it saw anything, which took the sting out
// of attacking altogether and flattened the AI skill ladder from 90% to 53%. // of attacking altogether and flattened the AI skill ladder from 90% to 53%. A
// `standoffRange` unit is the deliberate, single-unit-scoped exception below.
if (order.type === 'attackMove' && def.standoffRange) {
const threat = findNearestThreat(state, rules, e, near);
if (threat) {
e._fleeingId = threat.id;
e.path = null; e.wantPath = false; e.noPath = true;
e.movingTo = stepAwayFrom(state, e, threat);
break;
}
e._fleeingId = 0;
}
if (order.type === 'attackMove' && def.maxRange > 0) { if (order.type === 'attackMove' && def.maxRange > 0) {
const foe = entityById(state, e.targetId); const foe = entityById(state, e.targetId);
if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army] const eligible = foe && !foe.dead && foe.army !== e.army && state.armies[foe.army]
&& surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, foe)) { && (foe.isBuilding || def.standoffRange);
if (eligible && surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, def, foe)) {
e.path = null; e.path = null;
e.movingTo = null; e.movingTo = null;
e.stuckTicks = 0; e.stuckTicks = 0;
@ -1206,10 +1267,22 @@ function stepOrders(state, rules) {
// weapon chasing a Fighter would follow it off the map without ever firing a shot. // weapon chasing a Fighter would follow it off the map without ever firing a shot.
if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; } if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; }
e.targetId = target.id; e.targetId = target.id;
// A standoff unit never willingly closes into someone else's range, even under a
// direct attack order — it backs off and keeps firing from its own envelope instead
// (stepCombat fires purely off range/reload state, independent of this movement
// decision, so it keeps shooting while it retreats).
if (def.standoffRange) {
const threat = findNearestThreat(state, rules, e, near);
if (threat) {
e._fleeingId = threat.id;
e.path = null; e.wantPath = false; e.noPath = true;
e.movingTo = stepAwayFrom(state, e, threat);
break;
}
e._fleeingId = 0;
}
const d = surfaceDist(rules, e, target); const d = surfaceDist(rules, e, target);
const wantRange = def.maxRange * (target.isBuilding const wantRange = def.maxRange * holdFractionFor(rules, def, target);
? holdFractionFor(rules, target)
: (rules.constants.engageHoldFraction ?? 0.85));
if (d > wantRange) { if (d > wantRange) {
if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y); if (!e.path && !e.wantPath) seekTo(state, e, target.x, target.y);
e.movingTo = { x: target.x, y: target.y }; e.movingTo = { x: target.x, y: target.y };
@ -1841,6 +1914,13 @@ function fireWeapon(state, rules, e, w, target, facing, aimPt) {
if (w.spread > 0) ang += rngRange(state, -w.spread, w.spread); if (w.spread > 0) ang += rngRange(state, -w.spread, w.spread);
if (state.projectiles.length >= rules.constants.projectileCap) break; if (state.projectiles.length >= rules.constants.projectileCap) break;
// `ageTicks`/`flightTicks` are timing data only, not a height field — the sim stays flat
// 2D. They let the renderer derive a parabolic screen-space lob (see TAWorldView's
// sprite-projectile pool) for weapons that declare `fx.arc`, the same way an aircraft's
// `liftFrac` is sim-owned but its pixel lift is computed only in the renderer. Basing
// progress on elapsed/expected ticks rather than remaining distance keeps it monotonic
// even through guided homing wobble.
const flightDist = Math.hypot(aimX - ox, aimY - oy);
state.projectiles.push({ state.projectiles.push({
id: state.nextProjectileId++, id: state.nextProjectileId++,
weapon: w.id, army: e.army, ownerId: e.id, weapon: w.id, army: e.army, ownerId: e.id,
@ -1850,6 +1930,11 @@ function fireWeapon(state, rules, e, w, target, facing, aimPt) {
aimX, aimY, aimX, aimY,
ttl: Math.ceil(((w.range * 1.4) / w.speed) * rules.constants.tickHz), ttl: Math.ceil(((w.range * 1.4) / w.speed) * rules.constants.tickHz),
trail: [], trail: [],
ageTicks: 0,
flightTicks: Math.max(1, Math.round((flightDist / w.speed) * rules.constants.tickHz)),
// So a sprite-style shot (TAWorldView's projectile pool) renders at the SAME scale as
// the vehicle that fired it, rather than some arbitrary fixed projectile size.
spritePx: rules.defById[e.defId].spritePx,
}); });
} }
} }
@ -1860,6 +1945,7 @@ function stepProjectiles(state, rules) {
for (const p of state.projectiles) { for (const p of state.projectiles) {
const w = rules.weaponById[p.weapon]; const w = rules.weaponById[p.weapon];
p.px = p.x; p.py = p.y; p.px = p.x; p.py = p.y;
p.ageTicks++;
if (w.kind === 'guided') { if (w.kind === 'guided') {
const target = entityById(state, p.targetId); const target = entityById(state, p.targetId);

View File

@ -8,7 +8,7 @@
// runtime three minutes into a match. // runtime three minutes into a match.
export const WEAPON_KINDS = new Set(['hitscan', 'ballistic', 'guided', 'beam']); export const WEAPON_KINDS = new Set(['hitscan', 'ballistic', 'guided', 'beam']);
export const FX_STYLES = new Set(['tracer', 'beam', 'shell', 'rocket', 'dgun']); export const FX_STYLES = new Set(['tracer', 'beam', 'shell', 'rocket', 'dgun', 'sprite']);
export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']); export const UNIT_ROLES = new Set(['builder', 'combat', 'scout', 'artillery']);
export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']); export const SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']);
export const TARGET_DOMAINS = new Set(['ground', 'air']); export const TARGET_DOMAINS = new Set(['ground', 'air']);
@ -24,7 +24,7 @@ const SIGHT_RANGE_MARGIN_TILES = 2;
// this set is duplicated there and the verify script asserts the two agree. // this set is duplicated there and the verify script asserts the two agree.
export const PROC_SHAPES = new Set([ export const PROC_SHAPES = new Set([
'commander', 'infantry', 'sniper', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor', 'commander', 'infantry', 'sniper', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor',
'fighter', 'bomber', 'hoverConstructor', 'fighter', 'bomber', 'hoverConstructor', 'rocketArtillery',
'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher', 'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher',
'advancedVehiclePlant', 'airfield', 'radar', 'advancedRadar', 'radarJammer', 'advancedVehiclePlant', 'airfield', 'radar', 'advancedRadar', 'radarJammer',
]); ]);
@ -181,6 +181,9 @@ export function compileRules(json) {
if (!w.fx || !FX_STYLES.has(w.fx.style)) { if (!w.fx || !FX_STYLES.has(w.fx.style)) {
fail(`weapon "${w.id}" needs fx.style from ${[...FX_STYLES].join('|')}`); fail(`weapon "${w.id}" needs fx.style from ${[...FX_STYLES].join('|')}`);
} }
if (w.fx.style === 'sprite' && !(Number.isInteger(w.fx.frame) && w.fx.frame >= 0)) {
fail(`weapon "${w.id}" has fx.style "sprite" but no non-negative integer fx.frame`);
}
if (w.aoe != null && !(w.aoe > 0)) fail(`weapon "${w.id}" aoe must be positive when present`); if (w.aoe != null && !(w.aoe > 0)) fail(`weapon "${w.id}" aoe must be positive when present`);
// Reload/burst in ticks, precomputed so the sim never divides in its hot loop. // Reload/burst in ticks, precomputed so the sim never divides in its hot loop.
w.reloadTicks = Math.max(1, Math.round(w.reload * c.tickHz)); w.reloadTicks = Math.max(1, Math.round(w.reload * c.tickHz));
@ -283,6 +286,9 @@ export function compileRules(json) {
// 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.
u.isCommander = u.isCommander === true; u.isCommander = u.isCommander === true;
// A unit that stands off rather than closing to point-blank — see `holdFractionFor` and
// the threat-avoidance branch in TALogic.js's `attack`/`attackMove` handling.
u.standoffRange = u.standoffRange === true;
buildable[u.id] = u; buildable[u.id] = u;
} }
const unitById = indexById(units); const unitById = indexById(units);

View File

@ -105,6 +105,7 @@ export default class TAWorldView {
this._addWorld(this.gGhost); this._addWorld(this.gGhost);
this.sprites = new Map(); // entity id -> { img, turret } this.sprites = new Map(); // entity id -> { img, turret }
this.projSprites = new Map(); // projectile id -> { img, shadow, baseScale }
// Ids the Commander-death cascade has already blown up. The entities are still in the // Ids the Commander-death cascade has already blown up. The entities are still in the
// simulation — the match ended the moment the Commander died and nothing steps after that // simulation — the match ended the moment the Commander died and nothing steps after that
// — so this is the view's own record of what it has stopped drawing. // — so this is the view's own record of what it has stopped drawing.
@ -424,6 +425,37 @@ export default class TAWorldView {
this.sprites.delete(id); this.sprites.delete(id);
} }
// A projectile whose weapon has `fx.style === 'sprite'` (the Rocket Artillery's shell) gets
// an actual Image pair instead of the vector-drawn shapes TAFx.js uses for every other
// weapon — same body+shadow technique as an aircraft's altitude cue (see the `lift`/shadow
// block in render() below), just applied to a projectile instead of a unit.
_ensureProjSprite(p, w) {
let s = this.projSprites.get(p.id);
if (s) return s;
const sheets = this.armySheets[p.army];
const key = sheets.unitSheet;
const frameSize = sheets.unitFrame;
const img = this.scene.add.image(p.x, p.y, key, w.fx.frame).setDepth(DEPTHS.projectile);
// Same on-screen size as the vehicle that fired it, not an arbitrary fixed projectile
// size — `p.spritePx` is the firer's own spritePx, stamped on at spawn in fireWeapon.
img.setScale((p.spritePx ?? frameSize.w) / frameSize.w);
this._addWorld(img);
const shadow = this.scene.add.image(p.x, p.y, key, w.fx.frame);
shadow.setTint(0x000000).setAlpha(0).setDepth(DEPTHS.shadow);
this._addWorld(shadow);
s = { img, shadow, baseScale: img.scaleX };
this.projSprites.set(p.id, s);
return s;
}
_releaseProjSprite(id) {
const s = this.projSprites.get(id);
if (!s) return;
s.img.destroy();
s.shadow.destroy();
this.projSprites.delete(id);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Frame // Frame
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -604,6 +636,7 @@ export default class TAWorldView {
// Projectiles, interpolated and pre-coloured for the FX layer. // Projectiles, interpolated and pre-coloured for the FX layer.
const projectiles = []; const projectiles = [];
const liveProj = new Set();
for (const p of state.projectiles) { for (const p of state.projectiles) {
const w = rules.weaponById[p.weapon]; const w = rules.weaponById[p.weapon];
const px = p.px + (p.x - p.px) * alpha; const px = p.px + (p.x - p.px) * alpha;
@ -615,12 +648,39 @@ export default class TAWorldView {
if (gx < 0 || gy < 0 || gx >= state.visW || gy >= state.visH) continue; if (gx < 0 || gy < 0 || gx >= state.visW || gy >= state.visH) continue;
if (!army.visible[gy * state.visW + gx]) continue; if (!army.visible[gy * state.visW + gx]) continue;
} }
// A sprite-style shot (the Rocket Artillery's shell) gets a real Image+shadow pair
// instead of the vector body TAFx draws for everything else — see `_ensureProjSprite`.
// Progress is elapsed/expected ticks, not remaining distance, so it stays monotonic
// through guided homing wobble; `z` is the same quadratic lob curve requested for the
// arc, zero for any weapon that doesn't declare `fx.arc`.
if (w.fx.style === 'sprite') {
liveProj.add(p.id);
const s = this._ensureProjSprite(p, w);
const ageT = Math.max(0, (p.ageTicks - 1) + alpha);
const frac = w.fx.arc ? Math.min(1, ageT / p.flightTicks) : 0;
const z = (w.fx.arc ?? 0) * 4 * frac * (1 - frac);
// Pitch the nose to follow the arc — up on the way up, level at the apex, down on the
// way down — derived from the parabola's own slope (dz/dfrac, converted to the same
// px/sec units as vx/vy) so a steeper `fx.arc` naturally reads as a steeper climb/dive.
// Composed as an extra term on the velocity vector rather than a separate angle offset,
// so it falls out of the same atan2(vy, vx) heading idiom used everywhere else in this
// renderer and stays correct for a shot fired in any direction, not just rightward.
const zSlope = (w.fx.arc ?? 0) * 4 * (1 - 2 * frac);
const zRate = w.fx.arc ? zSlope * (rules.constants.tickHz / p.flightTicks) : 0;
s.img.setPosition(px, py - z).setRotation(Math.atan2(p.vy - zRate, p.vx));
s.shadow.setPosition(px, py);
s.shadow.setAlpha(SHADOW_ALPHA * smoothstep(frac));
s.shadow.setScale(s.baseScale * (1 - SHADOW_SHRINK * frac));
}
projectiles.push({ projectiles.push({
x: px, y: py, vx: p.vx, vy: p.vy, x: px, y: py, vx: p.vx, vy: p.vy,
color: colorInt(w.fx.color), width: w.fx.width ?? 2, color: colorInt(w.fx.color), width: w.fx.width ?? 2,
style: w.fx.style, trail: w.fx.trail ? p.trail : null, style: w.fx.style, trail: w.fx.trail ? p.trail : null,
}); });
} }
for (const id of [...this.projSprites.keys()]) if (!liveProj.has(id)) this._releaseProjSprite(id);
return { nanoLinks, projectiles }; return { nanoLinks, projectiles };
} }
@ -826,6 +886,7 @@ export default class TAWorldView {
destroy() { destroy() {
for (const id of [...this.sprites.keys()]) this._releaseSprite(id); for (const id of [...this.sprites.keys()]) this._releaseSprite(id);
for (const id of [...this.projSprites.keys()]) this._releaseProjSprite(id);
for (const [, chunk] of this.chunks) chunk.rt.destroy(); for (const [, chunk] of this.chunks) chunk.rt.destroy();
this.chunks.clear(); this.chunks.clear();
this.massSpotG?.destroy(); this.massSpotG?.destroy();

View File

@ -143,6 +143,17 @@ section('2. Artwork manifest');
if (d.topperFrame != null) check(`${d.id} topper frame fits`, d.topperFrame < cap); if (d.topperFrame != null) check(`${d.id} topper frame fits`, d.topperFrame < cap);
} }
} }
// Sprite-style weapon projectiles (e.g. the Rocket Artillery's shell) point at a frame on
// each army's unit sheet too, same capacity contract as a unit's own frame above.
for (const w of rules.weapons) {
if (w.fx.style !== 'sprite') continue;
for (const a of rules.armies) {
const sheet = sheets[a.unitSheet];
const cap = (sheet.cols ?? 8) * (sheet.rows ?? 8);
check(`weapon ${w.id} fx.frame ${w.fx.frame} fits ${sheet.key}`, w.fx.frame < cap);
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1868,9 +1879,13 @@ section('6e. Defensive structures');
// Neither builds anything, so a base of nothing but towers is still an army that can never // Neither builds anything, so a base of nothing but towers is still an army that can never
// produce again — the elimination rule must not treat a turret as a factory. // produce again — the elimination rule must not treat a turret as a factory.
check('defences are not factories', !(tower.builds ?? []).length && !(launcher.builds ?? []).length); check('defences are not factories', !(tower.builds ?? []).length && !(launcher.builds ?? []).length);
check('the Missile Launcher outranges every mobile unit', // `standoffRange` is the deliberate exception: it's the whole design of that unit class to
dr.units.every((u) => u.maxRange < launcher.maxRange), // out-range and dismantle a defensive structure, mirroring why the Rocket Tank (440) already
`${launcher.maxRange} vs best unit ${Math.max(...dr.units.map((u) => u.maxRange))}`); // out-ranges the Laser Tower (340) — see the `holdFractionFor` comment in TALogic.js.
check('the Missile Launcher outranges every mobile unit except purpose-built standoff artillery',
dr.units.every((u) => u.maxRange < launcher.maxRange || u.standoffRange),
`${launcher.maxRange} vs best non-artillery unit `
+ `${Math.max(...dr.units.filter((u) => !u.standoffRange).map((u) => u.maxRange))}`);
check('the Missile Launcher has a close-in dead zone', check('the Missile Launcher has a close-in dead zone',
launcher.weaponDefs[0].minRange > 0 && launcher.weaponDefs[0].minRange < launcher.maxRange); launcher.weaponDefs[0].minRange > 0 && launcher.weaponDefs[0].minRange < launcher.maxRange);
check('the Laser Tower is the cheaper of the two', check('the Laser Tower is the cheaper of the two',