refactor(coloradodefense): replace MIRV splitAt/splitDone with split-zone geometry
- Replace time-based splitAt/splitDone with screen-space Y zones (SPLIT_ZONE_1 at top third, SPLIT_ZONE_2 at halfway) for more predictable MIRV behavior - Add MIRV_RESPLIT_WAVE (wave 8): from wave 8 onward, MIRV buses split twice instead of once, creating more late-game pressure - Lower early-game difficulty: MIRV starts at wave 3 (was 6), reduced base chance (0.05 vs 0.15), increased growth rate, lowered max (0.4 vs 0.5) - MIRV trunk continues flying toward its target after each split - Updated verification tests for split-zone geometry and new missile fields - Cleaned up obsolete splitAt/splitDone fields across test fixtures
This commit is contained in:
parent
db1e36a42c
commit
6cef254dde
|
|
@ -55,9 +55,15 @@ export const TUNE = {
|
||||||
SPAWN_MS_BASE: 1800, SPAWN_MS_DECAY: 0.92, SPAWN_MS_MIN: 350,
|
SPAWN_MS_BASE: 1800, SPAWN_MS_DECAY: 0.92, SPAWN_MS_MIN: 350,
|
||||||
FALL_SPEED_BASE: 90, FALL_SPEED_GROWTH: 1.06, FALL_SPEED_MAX: 340,
|
FALL_SPEED_BASE: 90, FALL_SPEED_GROWTH: 1.06, FALL_SPEED_MAX: 340,
|
||||||
WAVE_QUOTA_BASE: 8, WAVE_QUOTA_GROWTH: 1.15, WAVE_QUOTA_MAX: 40,
|
WAVE_QUOTA_BASE: 8, WAVE_QUOTA_GROWTH: 1.15, WAVE_QUOTA_MAX: 40,
|
||||||
MIRV_WAVE: 6,
|
MIRV_WAVE: 3,
|
||||||
MIRV_CHANCE_BASE: 0.15, MIRV_CHANCE_GROWTH: 0.02, MIRV_CHANCE_MAX: 0.5,
|
MIRV_CHANCE_BASE: 0.05, MIRV_CHANCE_GROWTH: 0.025, MIRV_CHANCE_MAX: 0.4,
|
||||||
MIRV_CHILDREN: [2, 3],
|
MIRV_CHILDREN: [2, 3],
|
||||||
|
// Screen-space bands (fraction of GAME_HEIGHT) a MIRV missile splits
|
||||||
|
// within. Through wave 7 it splits once, in the top third; from wave 8 on
|
||||||
|
// the same missile splits again lower down, around the halfway mark.
|
||||||
|
MIRV_RESPLIT_WAVE: 8,
|
||||||
|
SPLIT_ZONE_1: [0.20, 0.333],
|
||||||
|
SPLIT_ZONE_2: [0.45, 0.55],
|
||||||
BASE_AMMO: 10,
|
BASE_AMMO: 10,
|
||||||
BLAST_R_BASE: 90, BLAST_GROW_MS: 350, BLAST_HOLD_MS: 120, BLAST_FADE_MS: 200,
|
BLAST_R_BASE: 90, BLAST_GROW_MS: 350, BLAST_HOLD_MS: 120, BLAST_FADE_MS: 200,
|
||||||
INTERCEPTOR_SPEED: 900,
|
INTERCEPTOR_SPEED: 900,
|
||||||
|
|
@ -100,6 +106,12 @@ export function paletteIndexForWave(wave) {
|
||||||
|
|
||||||
function lerp(a, b, t) { return a + (b - a) * t; }
|
function lerp(a, b, t) { return a + (b - a) * t; }
|
||||||
|
|
||||||
|
// A random screen-space Y within a [loFrac, hiFrac] band of GAME_HEIGHT.
|
||||||
|
function pickSplitY(rng, zone) {
|
||||||
|
const [lo, hi] = zone;
|
||||||
|
return HEIGHT * (lo + rng() * (hi - lo));
|
||||||
|
}
|
||||||
|
|
||||||
// Current x/y for any in-flight entity with fromX/fromY/toX/toY/t fields.
|
// Current x/y for any in-flight entity with fromX/fromY/toX/toY/t fields.
|
||||||
export function lerpPos(entity) {
|
export function lerpPos(entity) {
|
||||||
const t = Math.min(1, entity.t);
|
const t = Math.min(1, entity.t);
|
||||||
|
|
@ -168,11 +180,16 @@ export class Sim {
|
||||||
const dist = Math.hypot(target.x - fromX, target.y - fromY);
|
const dist = Math.hypot(target.x - fromX, target.y - fromY);
|
||||||
const speed = fallSpeed(this.wave);
|
const speed = fallSpeed(this.wave);
|
||||||
const dur = Math.max(200, (dist / speed) * 1000);
|
const dur = Math.max(200, (dist / speed) * 1000);
|
||||||
const kind = fromOverride ? 'single' : (this.rng() < mirvChance(this.wave) ? 'mirv' : 'single');
|
// Only a freshly-spawned missile (not a MIRV child) can itself be a MIRV
|
||||||
|
// "bus" — it keeps flying toward its own target after each split.
|
||||||
|
const isMirv = !fromOverride && this.rng() < mirvChance(this.wave);
|
||||||
|
const kind = isMirv ? 'mirv' : 'single';
|
||||||
|
const splitsLeft = isMirv ? (this.wave >= TUNE.MIRV_RESPLIT_WAVE ? 2 : 1) : 0;
|
||||||
const missile = {
|
const missile = {
|
||||||
id: this.nextId++, fromX, fromY, toX: target.x, toY: target.y,
|
id: this.nextId++, fromX, fromY, toX: target.x, toY: target.y,
|
||||||
targetSlot: target.slot, targetKind: target.kind,
|
targetSlot: target.slot, targetKind: target.kind,
|
||||||
t: 0, dur, kind, splitAt: 0.4 + this.rng() * 0.25, splitDone: false,
|
t: 0, dur, kind, splitsLeft,
|
||||||
|
splitY: splitsLeft > 0 ? pickSplitY(this.rng, TUNE.SPLIT_ZONE_1) : null,
|
||||||
};
|
};
|
||||||
this.enemyMissiles.push(missile);
|
this.enemyMissiles.push(missile);
|
||||||
return missile;
|
return missile;
|
||||||
|
|
@ -258,12 +275,18 @@ export class Sim {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance enemy missiles; collect MIRV splits without mutating mid-loop.
|
// Advance enemy missiles; collect MIRV splits without mutating mid-loop.
|
||||||
|
// A MIRV keeps flying toward its own target after each split; once
|
||||||
|
// MIRV_RESPLIT_WAVE is reached it gets a second split lower down.
|
||||||
const newChildren = [];
|
const newChildren = [];
|
||||||
for (const m of this.enemyMissiles) {
|
for (const m of this.enemyMissiles) {
|
||||||
m.t += dtMs / m.dur;
|
m.t += dtMs / m.dur;
|
||||||
if (m.kind === 'mirv' && !m.splitDone && m.t >= m.splitAt && m.t < 1) {
|
if (m.kind === 'mirv' && m.splitsLeft > 0 && m.t < 1) {
|
||||||
m.splitDone = true;
|
const pos = lerpPos(m);
|
||||||
|
if (pos.y >= m.splitY) {
|
||||||
|
m.splitsLeft -= 1;
|
||||||
newChildren.push(...this.makeMirvChildren(m));
|
newChildren.push(...this.makeMirvChildren(m));
|
||||||
|
m.splitY = m.splitsLeft > 0 ? pickSplitY(this.rng, TUNE.SPLIT_ZONE_2) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (newChildren.length) this.enemyMissiles.push(...newChildren);
|
if (newChildren.length) this.enemyMissiles.push(...newChildren);
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@
|
||||||
// 2. City-pool draw correctness + malformed-pool fallback.
|
// 2. City-pool draw correctness + malformed-pool fallback.
|
||||||
// 3. Difficulty escalation monotonicity across waves 1-40 (bounded).
|
// 3. Difficulty escalation monotonicity across waves 1-40 (bounded).
|
||||||
// 4. MIRV step-unlock threshold.
|
// 4. MIRV step-unlock threshold.
|
||||||
|
// 4b. MIRV split-zone geometry (top-third once, top-third + halfway from wave 8).
|
||||||
// 5. Scripted explosion/collision fixture.
|
// 5. Scripted explosion/collision fixture.
|
||||||
// 6. Palette-progression monotonicity.
|
// 6. Palette-progression monotonicity.
|
||||||
// 7. Wave-complete bonus breakdown (recap data) matches the score delta.
|
// 7. Wave-complete bonus breakdown (recap data) matches the score delta.
|
||||||
// 8. Monte-carlo "always fire at nearest incoming missile" bot run.
|
// 8. Monte-carlo "always fire at nearest incoming missile" bot run.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, slotX, mulberry32, pickCities,
|
BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, HEIGHT, slotX, mulberry32, pickCities,
|
||||||
DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance,
|
DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance,
|
||||||
PALETTES, paletteIndexForWave, createGame, step, pickFiringBase,
|
PALETTES, paletteIndexForWave, createGame, step, pickFiringBase,
|
||||||
fireInterceptor, lerpPos, explosionRadius,
|
fireInterceptor, lerpPos, explosionRadius,
|
||||||
|
|
@ -98,6 +99,88 @@ console.log('MIRV step-unlock');
|
||||||
check('mirvChance bounded at max', mirvChance(999) <= TUNE.MIRV_CHANCE_MAX);
|
check('mirvChance bounded at max', mirvChance(999) <= TUNE.MIRV_CHANCE_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 4b. MIRV split-zone geometry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('MIRV split-zone geometry');
|
||||||
|
{
|
||||||
|
// spawnEnemyMissile() itself: force isMirv=true (rng()=0 < any nonzero
|
||||||
|
// mirvChance) and check the resulting splitsLeft/splitY at both tiers.
|
||||||
|
const spawnLow = createGame(DEFAULT_CITIES, 3);
|
||||||
|
spawnLow.wave = TUNE.MIRV_RESPLIT_WAVE - 1;
|
||||||
|
spawnLow.rng = () => 0;
|
||||||
|
const missileLow = spawnLow.spawnEnemyMissile();
|
||||||
|
check('low-wave spawn becomes a MIRV with splitsLeft=1',
|
||||||
|
missileLow.kind === 'mirv' && missileLow.splitsLeft === 1);
|
||||||
|
check('low-wave MIRV splitY lands in SPLIT_ZONE_1',
|
||||||
|
missileLow.splitY >= HEIGHT * TUNE.SPLIT_ZONE_1[0] && missileLow.splitY <= HEIGHT * TUNE.SPLIT_ZONE_1[1],
|
||||||
|
`splitY=${missileLow.splitY}`);
|
||||||
|
|
||||||
|
const spawnHigh = createGame(DEFAULT_CITIES, 3);
|
||||||
|
spawnHigh.wave = TUNE.MIRV_RESPLIT_WAVE;
|
||||||
|
spawnHigh.rng = () => 0;
|
||||||
|
const missileHigh = spawnHigh.spawnEnemyMissile();
|
||||||
|
check('wave>=MIRV_RESPLIT_WAVE spawn becomes a MIRV with splitsLeft=2',
|
||||||
|
missileHigh.kind === 'mirv' && missileHigh.splitsLeft === 2);
|
||||||
|
check('its first splitY still lands in SPLIT_ZONE_1',
|
||||||
|
missileHigh.splitY >= HEIGHT * TUNE.SPLIT_ZONE_1[0] && missileHigh.splitY <= HEIGHT * TUNE.SPLIT_ZONE_1[1],
|
||||||
|
`splitY=${missileHigh.splitY}`);
|
||||||
|
|
||||||
|
// Full flight, low wave: exactly one split, and the children never re-split.
|
||||||
|
// (waveSpawned pinned to waveQuota so the sim's own spawn timer can't add
|
||||||
|
// unrelated missiles that would pollute the length-based split count.)
|
||||||
|
const simLow = createGame(DEFAULT_CITIES, 5);
|
||||||
|
simLow.wave = TUNE.MIRV_RESPLIT_WAVE - 1;
|
||||||
|
simLow.waveSpawned = simLow.waveQuota;
|
||||||
|
const t1 = simLow.cities[0];
|
||||||
|
simLow.enemyMissiles.push({
|
||||||
|
id: simLow.nextId++, fromX: t1.x, fromY: 0, toX: t1.x, toY: t1.y,
|
||||||
|
targetSlot: t1.slot, targetKind: 'city', t: 0, dur: 5000, kind: 'mirv',
|
||||||
|
splitsLeft: 1, splitY: HEIGHT * 0.25,
|
||||||
|
});
|
||||||
|
let splitCountLow = 0;
|
||||||
|
let childKinds = [];
|
||||||
|
for (let i = 0; i < 250; i += 1) {
|
||||||
|
const before = simLow.enemyMissiles.length;
|
||||||
|
step(simLow, 16);
|
||||||
|
if (simLow.enemyMissiles.length > before) {
|
||||||
|
splitCountLow += 1;
|
||||||
|
childKinds = simLow.enemyMissiles.filter((m) => m.kind !== 'mirv').map((m) => m.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('low-wave MIRV splits exactly once over its full flight', splitCountLow === 1, `splitCount=${splitCountLow}`);
|
||||||
|
check('its children never re-split', childKinds.length > 0 && childKinds.every((k) => k === 'single'));
|
||||||
|
|
||||||
|
// Full flight, wave >= MIRV_RESPLIT_WAVE: splits twice, second time in zone 2.
|
||||||
|
const simHigh = createGame(DEFAULT_CITIES, 6);
|
||||||
|
simHigh.wave = TUNE.MIRV_RESPLIT_WAVE;
|
||||||
|
simHigh.waveSpawned = simHigh.waveQuota;
|
||||||
|
const t2 = simHigh.cities[0];
|
||||||
|
simHigh.enemyMissiles.push({
|
||||||
|
id: simHigh.nextId++, fromX: t2.x, fromY: 0, toX: t2.x, toY: t2.y,
|
||||||
|
targetSlot: t2.slot, targetKind: 'city', t: 0, dur: 5000, kind: 'mirv',
|
||||||
|
splitsLeft: 2, splitY: HEIGHT * 0.25,
|
||||||
|
});
|
||||||
|
let splitEvents = 0;
|
||||||
|
let sawZone2SplitY = false;
|
||||||
|
// Stops well before the trunk's own ~5000ms impact — once it lands and
|
||||||
|
// completeWave() fires, waveSpawned resets and the spawn timer could add
|
||||||
|
// unrelated missiles again, polluting the length-based split count.
|
||||||
|
for (let i = 0; i < 280; i += 1) {
|
||||||
|
const before = simHigh.enemyMissiles.length;
|
||||||
|
step(simHigh, 16);
|
||||||
|
if (simHigh.enemyMissiles.length > before) {
|
||||||
|
splitEvents += 1;
|
||||||
|
const trunk = simHigh.enemyMissiles.find((m) => m.kind === 'mirv');
|
||||||
|
if (trunk && trunk.splitY !== null) {
|
||||||
|
const frac = trunk.splitY / HEIGHT;
|
||||||
|
if (frac >= TUNE.SPLIT_ZONE_2[0] && frac <= TUNE.SPLIT_ZONE_2[1]) sawZone2SplitY = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('wave>=MIRV_RESPLIT_WAVE MIRV splits exactly twice', splitEvents === 2, `splitEvents=${splitEvents}`);
|
||||||
|
check('its second split is scheduled in SPLIT_ZONE_2', sawZone2SplitY);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 5. Explosion/collision fixture ────────────────────────────────────────────
|
// ── 5. Explosion/collision fixture ────────────────────────────────────────────
|
||||||
|
|
||||||
console.log('Explosion/collision fixture');
|
console.log('Explosion/collision fixture');
|
||||||
|
|
@ -108,7 +191,7 @@ console.log('Explosion/collision fixture');
|
||||||
// interceptor's flight time, so a shot at its current position still lands.
|
// interceptor's flight time, so a shot at its current position still lands.
|
||||||
sim.enemyMissiles.push({
|
sim.enemyMissiles.push({
|
||||||
id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y,
|
id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y,
|
||||||
targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true,
|
targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitsLeft: 0,
|
||||||
});
|
});
|
||||||
const missilePos = lerpPos(sim.enemyMissiles[0]);
|
const missilePos = lerpPos(sim.enemyMissiles[0]);
|
||||||
const base = pickFiringBase(sim, missilePos.x, missilePos.y);
|
const base = pickFiringBase(sim, missilePos.x, missilePos.y);
|
||||||
|
|
@ -127,7 +210,7 @@ console.log('Explosion/collision fixture');
|
||||||
const target2 = sim2.cities[0];
|
const target2 = sim2.cities[0];
|
||||||
sim2.enemyMissiles.push({
|
sim2.enemyMissiles.push({
|
||||||
id: sim2.nextId++, fromX: target2.x, fromY: 0, toX: target2.x, toY: target2.y,
|
id: sim2.nextId++, fromX: target2.x, fromY: 0, toX: target2.x, toY: target2.y,
|
||||||
targetSlot: target2.slot, targetKind: 'city', t: 0.5, dur: 4000, kind: 'single', splitAt: 2, splitDone: true,
|
targetSlot: target2.slot, targetKind: 'city', t: 0.5, dur: 4000, kind: 'single', splitsLeft: 0,
|
||||||
});
|
});
|
||||||
const far = lerpPos(sim2.enemyMissiles[0]);
|
const far = lerpPos(sim2.enemyMissiles[0]);
|
||||||
fireInterceptor(sim2, sim2.bases[1], far.x + 5000, far.y);
|
fireInterceptor(sim2, sim2.bases[1], far.x + 5000, far.y);
|
||||||
|
|
@ -159,7 +242,7 @@ console.log('Chain-reaction fixture');
|
||||||
const target = sim.cities[0];
|
const target = sim.cities[0];
|
||||||
sim.enemyMissiles.push({
|
sim.enemyMissiles.push({
|
||||||
id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y,
|
id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y,
|
||||||
targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true,
|
targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitsLeft: 0,
|
||||||
});
|
});
|
||||||
const primaryPos = lerpPos(sim.enemyMissiles[0]);
|
const primaryPos = lerpPos(sim.enemyMissiles[0]);
|
||||||
const base = pickFiringBase(sim, primaryPos.x, primaryPos.y);
|
const base = pickFiringBase(sim, primaryPos.x, primaryPos.y);
|
||||||
|
|
@ -192,7 +275,7 @@ console.log('Chain-reaction fixture');
|
||||||
if (!secondaryInjected && primaryKillElapsed !== null && elapsed >= primaryKillElapsed + 1000) {
|
if (!secondaryInjected && primaryKillElapsed !== null && elapsed >= primaryKillElapsed + 1000) {
|
||||||
sim.enemyMissiles.push({
|
sim.enemyMissiles.push({
|
||||||
id: sim.nextId++, fromX: primaryPos.x - 50, fromY: primaryPos.y, toX: primaryPos.x - 50, toY: primaryPos.y,
|
id: sim.nextId++, fromX: primaryPos.x - 50, fromY: primaryPos.y, toX: primaryPos.x - 50, toY: primaryPos.y,
|
||||||
targetSlot: sim.cities[1].slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true,
|
targetSlot: sim.cities[1].slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitsLeft: 0,
|
||||||
});
|
});
|
||||||
secondaryInjected = true;
|
secondaryInjected = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue