diff --git a/assets/images/ta/arm-units.png b/assets/images/ta/arm-units.png index d6a5284..33bf1c4 100644 Binary files a/assets/images/ta/arm-units.png and b/assets/images/ta/arm-units.png differ diff --git a/assets/images/ta/arm-units.psd b/assets/images/ta/arm-units.psd index da32a48..e26bbed 100644 Binary files a/assets/images/ta/arm-units.psd and b/assets/images/ta/arm-units.psd differ diff --git a/assets/images/ta/core-units.png b/assets/images/ta/core-units.png index 354d903..1537068 100644 Binary files a/assets/images/ta/core-units.png and b/assets/images/ta/core-units.png differ diff --git a/assets/images/ta/core-units.psd b/assets/images/ta/core-units.psd index c06f4d5..aa2f932 100644 Binary files a/assets/images/ta/core-units.psd and b/assets/images/ta/core-units.psd differ diff --git a/data/totalannihilation-rules.json b/data/totalannihilation-rules.json index 743d99d..a63c835 100644 --- a/data/totalannihilation-rules.json +++ b/data/totalannihilation-rules.json @@ -44,6 +44,8 @@ "retargetPeriodTicks": 10, "engageHoldFraction": 0.45, "siegeHoldFraction": 0.95, + "standoffMarginIn": 60, + "standoffMarginOut": 160, "separationStiffness": 0.55, "shoveAfterSec": 0.6, "stuckGiveUpSec": 3.0, @@ -707,6 +709,37 @@ }, "sound": "sfx-scifi-launch", "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": [ @@ -1180,6 +1213,35 @@ "icon": 17, "spritePx": 52, "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": [ @@ -1455,7 +1517,8 @@ "buildPower": 140, "builds": [ "megatank", - "advancedconstructor" + "advancedconstructor", + "rocketartillery" ], "spawnOffset": { "x": 0, diff --git a/src/games/totalannihilation/TAAI.js b/src/games/totalannihilation/TAAI.js index 7fa33e9..3efa278 100644 --- a/src/games/totalannihilation/TAAI.js +++ b/src/games/totalannihilation/TAAI.js @@ -39,6 +39,7 @@ function memFor(state, armyIdx) { scoutId: 0, airPreyId: 0, lastThreatTick: -99999, knownEnemies: [], // [{x,y,defId,isBuilding,tick}] lastAttackTick: -99999, + waveFailStreak: 0, attackTargetId: 0, buildSpiral: 0, baseX: 0, baseY: 0, baseSet: false, }; @@ -587,7 +588,7 @@ function enemyBaseGuess(ctx) { const { state, armyIdx, mem } = ctx; // Prefer something we've actually seen; fall back to the enemy's start position. 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 }; // 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); 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.squad.length < wantSize) { // Gather near the base while we build up. @@ -733,12 +738,27 @@ function manageMilitary(ctx, mine) { mem.phase = 'attack'; mem.squadPeak = mem.squad.length; 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') { - // Break off if the push has been gutted — skill 2+ knows when it's lost a fight. - if (skill >= 2 && mem.squad.length < mem.squadPeak * 0.4) { + // The building we pushed on is confirmed dead — ease off the escalation and go pick a + // 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'; + 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, { army: armyIdx, unitIds: mem.squad.slice(), order: { type: 'move', x: mem.baseX, y: mem.baseY }, diff --git a/src/games/totalannihilation/TAArt.js b/src/games/totalannihilation/TAArt.js index 1f22b8a..8cbcd48 100644 --- a/src/games/totalannihilation/TAArt.js +++ b/src/games/totalannihilation/TAArt.js @@ -230,6 +230,24 @@ const UNIT_SHAPES = { 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 // 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. @@ -655,6 +673,12 @@ const PROC_PAINTERS = { const defs = rules.units.filter((u) => u.sheetSlot === 'unitSheet'); let count = 0; 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); for (const d of defs) { const hull = UNIT_SHAPES[d.procShape]; diff --git a/src/games/totalannihilation/TAFx.js b/src/games/totalannihilation/TAFx.js index 91598df..1b1df66 100644 --- a/src/games/totalannihilation/TAFx.js +++ b/src/games/totalannihilation/TAFx.js @@ -130,6 +130,9 @@ export default class TAFx { // ---- live projectiles (over actors) ---- 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); if (p.style === 'rocket') { const bx = p.x - Math.cos(ang) * 9, by = p.y - Math.sin(ang) * 9; diff --git a/src/games/totalannihilation/TALogic.js b/src/games/totalannihilation/TALogic.js index 6a6ea1d..fd84332 100644 --- a/src/games/totalannihilation/TALogic.js +++ b/src/games/totalannihilation/TALogic.js @@ -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 * 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; + // 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]; - 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. */ @@ -940,6 +945,49 @@ function hostileBuildingNear(state, rules, army, x, y, radius) { 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. * @@ -1090,6 +1138,7 @@ function stepFlight(state, rules) { function stepOrders(state, rules) { const ts = state.tileSize; + const near = []; for (const e of state.entities) { if (e.dead || e.site) 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 // 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) { const foe = entityById(state, e.targetId); - if (foe && foe.isBuilding && !foe.dead && foe.army !== e.army && state.armies[foe.army] - && surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, foe)) { + const eligible = foe && !foe.dead && foe.army !== e.army && state.armies[foe.army] + && (foe.isBuilding || def.standoffRange); + if (eligible && surfaceDist(rules, e, foe) <= def.maxRange * holdFractionFor(rules, def, foe)) { e.path = null; e.movingTo = null; 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. if (!canEngage(def, target)) { e.orders.shift(); e.targetId = 0; e.path = null; break; } 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 wantRange = def.maxRange * (target.isBuilding - ? holdFractionFor(rules, target) - : (rules.constants.engageHoldFraction ?? 0.85)); + const wantRange = def.maxRange * holdFractionFor(rules, def, target); if (d > wantRange) { if (!e.path && !e.wantPath) seekTo(state, e, target.x, 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 (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({ id: state.nextProjectileId++, weapon: w.id, army: e.army, ownerId: e.id, @@ -1850,6 +1930,11 @@ function fireWeapon(state, rules, e, w, target, facing, aimPt) { aimX, aimY, ttl: Math.ceil(((w.range * 1.4) / w.speed) * rules.constants.tickHz), 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) { const w = rules.weaponById[p.weapon]; p.px = p.x; p.py = p.y; + p.ageTicks++; if (w.kind === 'guided') { const target = entityById(state, p.targetId); diff --git a/src/games/totalannihilation/TARules.js b/src/games/totalannihilation/TARules.js index 6fbdaaa..21107b1 100644 --- a/src/games/totalannihilation/TARules.js +++ b/src/games/totalannihilation/TARules.js @@ -8,7 +8,7 @@ // runtime three minutes into a match. 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 SHEET_SLOTS = new Set(['unitSheet', 'structureSheet']); 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. export const PROC_SHAPES = new Set([ 'commander', 'infantry', 'sniper', 'rocketTrooper', 'jeep', 'tank', 'rockettank', 'constructor', - 'fighter', 'bomber', 'hoverConstructor', + 'fighter', 'bomber', 'hoverConstructor', 'rocketArtillery', 'energyGen', 'massGen', 'barracks', 'vehiclePlant', 'laserTower', 'missileLauncher', 'advancedVehiclePlant', 'airfield', 'radar', 'advancedRadar', 'radarJammer', ]); @@ -181,6 +181,9 @@ export function compileRules(json) { if (!w.fx || !FX_STYLES.has(w.fx.style)) { 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`); // 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)); @@ -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 // rather than a hardcoded "commander" id — a second Commander-class unit is a JSON change. 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; } const unitById = indexById(units); diff --git a/src/games/totalannihilation/TAWorldView.js b/src/games/totalannihilation/TAWorldView.js index 4597497..9e8c855 100644 --- a/src/games/totalannihilation/TAWorldView.js +++ b/src/games/totalannihilation/TAWorldView.js @@ -105,6 +105,7 @@ export default class TAWorldView { this._addWorld(this.gGhost); 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 // 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. @@ -424,6 +425,37 @@ export default class TAWorldView { 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 // ------------------------------------------------------------------------- @@ -604,6 +636,7 @@ export default class TAWorldView { // Projectiles, interpolated and pre-coloured for the FX layer. const projectiles = []; + const liveProj = new Set(); for (const p of state.projectiles) { const w = rules.weaponById[p.weapon]; 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 (!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({ x: px, y: py, vx: p.vx, vy: p.vy, color: colorInt(w.fx.color), width: w.fx.width ?? 2, 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 }; } @@ -826,6 +886,7 @@ export default class TAWorldView { destroy() { 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(); this.chunks.clear(); this.massSpotG?.destroy(); diff --git a/tools/verifyTotalAnnihilation.js b/tools/verifyTotalAnnihilation.js index a1b399f..8634a46 100644 --- a/tools/verifyTotalAnnihilation.js +++ b/tools/verifyTotalAnnihilation.js @@ -143,6 +143,17 @@ section('2. Artwork manifest'); 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 // 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('the Missile Launcher outranges every mobile unit', - dr.units.every((u) => u.maxRange < launcher.maxRange), - `${launcher.maxRange} vs best unit ${Math.max(...dr.units.map((u) => u.maxRange))}`); + // `standoffRange` is the deliberate exception: it's the whole design of that unit class to + // out-range and dismantle a defensive structure, mirroring why the Rocket Tank (440) already + // 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', launcher.weaponDefs[0].minRange > 0 && launcher.weaponDefs[0].minRange < launcher.maxRange); check('the Laser Tower is the cheaper of the two',