Updated with new unit and better AI
This commit is contained in:
parent
347957bd84
commit
73de46e0f1
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.
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue