retune tempest difficulty and fix death persistence
- Replace hard-capped difficulty curves with exponential ramp function that continues scaling deep into endless play without plateauing - Reduce base speeds and shot counts for more deliberate gameplay - Increase flipper, fuseball, and pulsar speeds for more pressure - Flipper AI now chains hops toward the claw instead of resting, making it a true pursuer rather than a shuffler - Change zapper to refill +1 per level instead of full reset, making charge management meaningful - Death no longer resets the board: only enemies within threshold t die with the player, others remain mid-climb - Improve enemy shot hit detection with lane distance check instead of exact lane match - Add advanceShots() helper for cleaner shot advancement logic - Update verification tests for new mechanics and difficulty curves - Add michael background sprites and update tetrisattack characters
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 463 KiB After Width: | Height: | Size: 669 KiB |
|
|
@ -383,7 +383,11 @@ export default class TempestGame extends Phaser.Scene {
|
|||
case 'warpStart': {
|
||||
playSound(this, SFX.SCIFI_RISER);
|
||||
const spiked = this.sim.spikes.some((tip) => tip < 1);
|
||||
this.showBanner(spiked ? 'AVOID SPIKES' : 'SUPERZAPPER RECHARGE', this.band.lane, 1500);
|
||||
// The zapper only regains one charge per level, so only promise it
|
||||
// when there is actually room for it.
|
||||
const zapBack = this.sim.zapper.uses < TUNE.ZAPPER_USES ? 'SUPERZAPPER +1' : null;
|
||||
const banner = spiked ? 'AVOID SPIKES' : zapBack;
|
||||
if (banner) this.showBanner(banner, this.band.lane, 1500);
|
||||
break;
|
||||
}
|
||||
case 'warpDone': {
|
||||
|
|
|
|||
|
|
@ -238,81 +238,107 @@ export const TUNE = {
|
|||
LIVES: 3,
|
||||
EXTRA_LIFE_EVERY: 20000,
|
||||
|
||||
MOVE_LANES_PER_SEC: 14,
|
||||
FIRE_COOLDOWN_MS: 110,
|
||||
MAX_SHOTS: 8, // arcade cap on live player shots
|
||||
MOVE_LANES_PER_SEC: 10,
|
||||
FIRE_COOLDOWN_MS: 150,
|
||||
MAX_SHOTS: 4, // live player shots — few enough that aim matters
|
||||
SHOT_SPEED: 2.3, // t units per second, rim -> pit
|
||||
|
||||
// Enemy climb speeds in t/sec (positive = toward the rim) and growth.
|
||||
FLIPPER_SPEED: 0.16, FLIPPER_SPEED_GROWTH: 0.006, FLIPPER_SPEED_MAX: 0.42,
|
||||
// The flipper's ramp is a soft cap (see `ramp`) plus a slow tail, so deep
|
||||
// levels keep getting faster instead of flat-lining; HARD_MAX keeps it well
|
||||
// under SHOT_SPEED so a climbing flipper always stays shootable.
|
||||
FLIPPER_SPEED: 0.16, FLIPPER_SPEED_RATE: 0.05, FLIPPER_SPEED_CAP: 0.52,
|
||||
FLIPPER_SPEED_TAIL: 0.0015, FLIPPER_SPEED_HARD_MAX: 1.0,
|
||||
TANKER_SPEED: 0.10,
|
||||
SPIKER_SPEED: 0.22,
|
||||
FUSEBALL_SPEED: 0.30,
|
||||
PULSAR_SPEED: 0.14,
|
||||
FUSEBALL_SPEED: 0.50,
|
||||
PULSAR_SPEED: 0.22,
|
||||
|
||||
// Rim flippers hunt the player: rest between hops, then a hop that can be
|
||||
// shot mid-flight. Flippers also flip lane-to-lane while still climbing
|
||||
// (their arcade signature) — chance ramps up and cooldown shortens with
|
||||
// level so early levels stay gentle and later ones get aggressive.
|
||||
FLIP_DUR_MS: 260,
|
||||
FLIP_REST_MS: 520, FLIP_REST_DECAY: 8, FLIP_REST_MIN_MS: 220,
|
||||
FLIP_CLIMB_CHANCE_BASE: 0.3, FLIP_CLIMB_CHANCE_GROWTH: 0.01, FLIP_CLIMB_CHANCE_MAX: 0.55,
|
||||
FLIP_CLIMB_COOLDOWN_MS: 1100, FLIP_CLIMB_COOLDOWN_DECAY: 20, FLIP_CLIMB_COOLDOWN_MIN_MS: 600,
|
||||
// Rim flippers hunt the player: they chain hops toward the claw without
|
||||
// resting while it is more than a lane away, and a hop that lands on the
|
||||
// player's lane is a grab. Flippers also flip lane-to-lane while still
|
||||
// climbing (their arcade signature) — chance ramps up and cooldown shortens
|
||||
// with level.
|
||||
FLIP_DUR_MS: 150,
|
||||
FLIP_REST_MS: 260, FLIP_REST_DECAY: 10, FLIP_REST_MIN_MS: 90,
|
||||
FLIP_CLIMB_CHANCE_BASE: 0.55, FLIP_CLIMB_CHANCE_GROWTH: 0.01, FLIP_CLIMB_CHANCE_MAX: 0.92,
|
||||
FLIP_CLIMB_COOLDOWN_MS: 650, FLIP_CLIMB_COOLDOWN_DECAY: 25, FLIP_CLIMB_COOLDOWN_MIN_MS: 260,
|
||||
|
||||
SPIKE_MIN_T: 0.22, // how close to the rim a spike can grow
|
||||
SPIKE_TRIM: 0.06, // how much one shot shaves off a spike tip
|
||||
SPIKE_POINTS: 1,
|
||||
|
||||
FUSEBALL_MOVE_MS: [500, 1100],
|
||||
FUSEBALL_PAUSE_MS: [350, 900],
|
||||
FUSEBALL_EDGE_HOP_CHANCE: 0.35,
|
||||
FUSEBALL_PAUSE_MS: [200, 500],
|
||||
FUSEBALL_EDGE_HOP_CHANCE: 0.5,
|
||||
FUSEBALL_CLIMB_BIAS: 0.85, // chance a move phase heads for the rim
|
||||
FUSEBALL_CONTACT_T: 0.05,
|
||||
|
||||
PULSAR_PERIOD_MS: 3200, PULSAR_ACTIVE_MS: 700,
|
||||
PULSAR_LETHAL_T: 0.18, // a pulse only kills when the pulsar is near the rim
|
||||
PULSAR_PERIOD_MS: 2400, PULSAR_ACTIVE_MS: 1000,
|
||||
PULSAR_LETHAL_T: 0.30, // a pulse only kills when the pulsar is near the rim
|
||||
PULSAR_TURN_T: 0.10, // patrol: climb to here, retreat, climb again
|
||||
PULSAR_RETREAT_T: 0.72,
|
||||
|
||||
ENEMY_SHOT_SPEED: 0.34, ENEMY_SHOT_SPEED_GROWTH: 0.012, ENEMY_SHOT_SPEED_MAX: 0.62,
|
||||
ENEMY_FIRE_PER_SEC: 0.16, ENEMY_FIRE_GROWTH: 0.012, ENEMY_FIRE_MAX: 0.55,
|
||||
MAX_ENEMY_SHOTS: 8,
|
||||
ENEMY_SHOT_SPEED: 0.70, ENEMY_SHOT_SPEED_RATE: 0.05, ENEMY_SHOT_SPEED_CAP: 1.25,
|
||||
ENEMY_SHOT_SPEED_TAIL: 0.002, ENEMY_SHOT_SPEED_HARD_MAX: 1.9,
|
||||
ENEMY_FIRE_PER_SEC: 0.50, ENEMY_FIRE_RATE: 0.05, ENEMY_FIRE_CAP: 1.6,
|
||||
ENEMY_FIRE_TAIL: 0.004, ENEMY_FIRE_HARD_MAX: 4.0,
|
||||
ENEMY_SHOT_HIT_LANES: 0.6, // lane distance at which a rim shot connects
|
||||
MAX_ENEMY_SHOTS: 14,
|
||||
|
||||
// Spawn scheduling: per-level budget, concurrency cap, and cadence.
|
||||
BUDGET_BASE: 10, BUDGET_GROWTH: 2.0, BUDGET_MAX: 40,
|
||||
CONCURRENT_BASE: 4, CONCURRENT_MAX: 10,
|
||||
SPAWN_MS_BASE: 1500, SPAWN_MS_DECAY: 0.97, SPAWN_MS_MIN: 480,
|
||||
BUDGET_BASE: 14, BUDGET_RATE: 0.05, BUDGET_CAP: 46, BUDGET_TAIL: 0.35, BUDGET_HARD_MAX: 140,
|
||||
CONCURRENT_BASE: 6, CONCURRENT_PER_LEVEL: 1 / 1.5, CONCURRENT_MAX: 16,
|
||||
SPAWN_MS_BASE: 1100, SPAWN_MS_DECAY: 0.955, SPAWN_MS_MIN: 220,
|
||||
|
||||
// Type unlock levels and relative spawn weights once unlocked.
|
||||
UNLOCK: { flipper: 1, tanker: 3, spiker: 4, fuseball: 11, pulsar: 17 },
|
||||
WEIGHT: { flipper: 1.0, tanker: 0.4, spiker: 0.35, fuseball: 0.3, pulsar: 0.35 },
|
||||
UNLOCK: { flipper: 1, tanker: 2, spiker: 3, fuseball: 6, pulsar: 9 },
|
||||
WEIGHT: { flipper: 1.0, tanker: 0.5, spiker: 0.35, fuseball: 0.45, pulsar: 0.5 },
|
||||
|
||||
POINTS: { flipper: 150, tanker: 100, spiker: 50, pulsar: 200 },
|
||||
FUSEBALL_POINTS: [250, 500, 750], // by proximity to the rim when killed
|
||||
|
||||
ZAPPER_USES: 2,
|
||||
ZAPPER_USES: 2, // cap; a cleared level grants ZAPPER_REFILL back
|
||||
ZAPPER_REFILL: 1,
|
||||
ZAP_CASCADE_MS: 40,
|
||||
|
||||
DEATH_MS: 1300,
|
||||
RESPITE_MS: 900,
|
||||
RESPITE_MS: 600,
|
||||
DEATH_CLEAR_T: 0.25, // only enemies this close to the rim die with you
|
||||
WARP_MS: 2600,
|
||||
|
||||
START_BONUS_SCALE: 700, // start bonus ~ (level-1)^2 * this, rounded
|
||||
};
|
||||
|
||||
// Approach `cap` quickly, then keep creeping by `tail` per level so endless
|
||||
// play never flat-lines the way a hard Math.min clamp does. `hardMax` is a
|
||||
// sanity ceiling, reached only hundreds of levels deep.
|
||||
export function ramp(base, rate, cap, tail, hardMax, level) {
|
||||
const l = Math.max(0, level - 1);
|
||||
const soft = cap - (cap - base) * Math.exp(-rate * l);
|
||||
return Math.min(hardMax, soft + tail * l);
|
||||
}
|
||||
|
||||
export function flipperSpeed(level) {
|
||||
return Math.min(TUNE.FLIPPER_SPEED_MAX, TUNE.FLIPPER_SPEED + TUNE.FLIPPER_SPEED_GROWTH * (level - 1));
|
||||
return ramp(TUNE.FLIPPER_SPEED, TUNE.FLIPPER_SPEED_RATE, TUNE.FLIPPER_SPEED_CAP,
|
||||
TUNE.FLIPPER_SPEED_TAIL, TUNE.FLIPPER_SPEED_HARD_MAX, level);
|
||||
}
|
||||
export function enemyShotSpeed(level) {
|
||||
return Math.min(TUNE.ENEMY_SHOT_SPEED_MAX, TUNE.ENEMY_SHOT_SPEED + TUNE.ENEMY_SHOT_SPEED_GROWTH * (level - 1));
|
||||
return ramp(TUNE.ENEMY_SHOT_SPEED, TUNE.ENEMY_SHOT_SPEED_RATE, TUNE.ENEMY_SHOT_SPEED_CAP,
|
||||
TUNE.ENEMY_SHOT_SPEED_TAIL, TUNE.ENEMY_SHOT_SPEED_HARD_MAX, level);
|
||||
}
|
||||
export function enemyFireRate(level) {
|
||||
return Math.min(TUNE.ENEMY_FIRE_MAX, TUNE.ENEMY_FIRE_PER_SEC + TUNE.ENEMY_FIRE_GROWTH * (level - 1));
|
||||
return ramp(TUNE.ENEMY_FIRE_PER_SEC, TUNE.ENEMY_FIRE_RATE, TUNE.ENEMY_FIRE_CAP,
|
||||
TUNE.ENEMY_FIRE_TAIL, TUNE.ENEMY_FIRE_HARD_MAX, level);
|
||||
}
|
||||
export function levelBudget(level) {
|
||||
return Math.min(TUNE.BUDGET_MAX, Math.round(TUNE.BUDGET_BASE + TUNE.BUDGET_GROWTH * (level - 1)));
|
||||
return Math.round(ramp(TUNE.BUDGET_BASE, TUNE.BUDGET_RATE, TUNE.BUDGET_CAP,
|
||||
TUNE.BUDGET_TAIL, TUNE.BUDGET_HARD_MAX, level));
|
||||
}
|
||||
// Capped for real: the scene redraws every live enemy as vector art each frame.
|
||||
export function maxConcurrent(level) {
|
||||
return Math.min(TUNE.CONCURRENT_MAX, TUNE.CONCURRENT_BASE + Math.floor(level / 2));
|
||||
return Math.min(TUNE.CONCURRENT_MAX,
|
||||
TUNE.CONCURRENT_BASE + Math.floor(level * TUNE.CONCURRENT_PER_LEVEL));
|
||||
}
|
||||
export function spawnInterval(level) {
|
||||
return Math.max(TUNE.SPAWN_MS_MIN, TUNE.SPAWN_MS_BASE * TUNE.SPAWN_MS_DECAY ** (level - 1));
|
||||
|
|
@ -412,7 +438,14 @@ export class Sim {
|
|||
this.enemyShots = [];
|
||||
this.enemies = [];
|
||||
this.spikes = new Array(n).fill(1); // spike tip t per lane; 1 = no spike
|
||||
this.zapper = { uses: TUNE.ZAPPER_USES, cascade: [], cascadeMs: 0 };
|
||||
// The zapper is NOT refilled per level — surviving charge carries over and
|
||||
// a cleared level hands back one, so burning both is a real cost.
|
||||
if (!this.zapper) this.zapper = { uses: TUNE.ZAPPER_USES, cascade: [], cascadeMs: 0 };
|
||||
else {
|
||||
this.zapper.uses = Math.min(TUNE.ZAPPER_USES, this.zapper.uses + TUNE.ZAPPER_REFILL);
|
||||
this.zapper.cascade = [];
|
||||
this.zapper.cascadeMs = 0;
|
||||
}
|
||||
this.spawn = { remaining: this.buildBudget(level), timerMs: 600 };
|
||||
this.warpT = 0;
|
||||
}
|
||||
|
|
@ -605,6 +638,18 @@ export class Sim {
|
|||
return e.lane;
|
||||
}
|
||||
|
||||
// Unsigned lane gap between a lane index and the claw's fractional position,
|
||||
// taking the short way around a closed web.
|
||||
laneDistanceToPlayer(lane) {
|
||||
const n = laneCount(this.web);
|
||||
let d = lane - this.player.pos;
|
||||
if (this.web.closed) {
|
||||
d = ((d % n) + n) % n;
|
||||
if (d > n / 2) d -= n;
|
||||
}
|
||||
return Math.abs(d);
|
||||
}
|
||||
|
||||
adjacentLaneToward(fromLane, targetLane) {
|
||||
const n = laneCount(this.web);
|
||||
if (this.web.closed) {
|
||||
|
|
@ -650,11 +695,23 @@ export class Sim {
|
|||
if (e.flipMs >= TUNE.FLIP_DUR_MS) {
|
||||
e.lane = e.flipTo;
|
||||
e.state = e.flipReturn;
|
||||
if (e.state === 'rest') e.restMs = flipRest(this.level);
|
||||
// A rim flipper chains hops without resting while the claw is still
|
||||
// more than a lane away — this is what makes it a pursuer rather than
|
||||
// a shuffler the player can simply outrun.
|
||||
if (e.state === 'rest') {
|
||||
const to = this.adjacentLaneToward(e.lane, this.playerLane());
|
||||
if (to !== e.lane) {
|
||||
e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0;
|
||||
} else {
|
||||
e.restMs = flipRest(this.level);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A rim flipper sharing the player's lane is a grab — instant death.
|
||||
if (e.t <= 0.02 && e.state !== 'climb' && this.visualLane(e) === this.playerLane() && e.state !== 'flip') {
|
||||
// A rim flipper sharing the player's lane is a grab — instant death. A hop
|
||||
// that lands on the claw counts: past the halfway point of a flip
|
||||
// `visualLane` already reports the destination lane.
|
||||
if (e.t <= 0.02 && e.state !== 'climb' && this.visualLane(e) === this.playerLane()) {
|
||||
this.killPlayer('grabbed');
|
||||
}
|
||||
}
|
||||
|
|
@ -695,7 +752,7 @@ export class Sim {
|
|||
e.state = 'move';
|
||||
e.phaseMs = this.randRange(TUNE.FUSEBALL_MOVE_MS);
|
||||
// Mostly climbs, sometimes dives back down, sometimes hops an edge.
|
||||
e.dir = this.rng() < 0.72 ? -1 : 1;
|
||||
e.dir = this.rng() < TUNE.FUSEBALL_CLIMB_BIAS ? -1 : 1;
|
||||
if (this.rng() < TUNE.FUSEBALL_EDGE_HOP_CHANCE) {
|
||||
const edges = this.edgeCount();
|
||||
let edge = e.edge + (this.rng() < 0.5 ? -1 : 1);
|
||||
|
|
@ -727,8 +784,8 @@ export class Sim {
|
|||
}
|
||||
|
||||
enemyFireTick(e, dt) {
|
||||
if (e.type !== 'flipper' && e.type !== 'tanker') return;
|
||||
if (e.t < 0.12 || e.t > 0.92) return; // only while riding the well
|
||||
if (e.type === 'fuseball') return; // fuseballs ram, they don't shoot
|
||||
if (e.t < 0.03 || e.t > 0.98) return; // only while riding the well
|
||||
if (this.enemyShots.length >= TUNE.MAX_ENEMY_SHOTS) return;
|
||||
if (this.rng() < enemyFireRate(this.level) * (dt / 1000)) {
|
||||
this.enemyShots.push({ lane: this.visualLane(e), t: e.t, prevT: e.t });
|
||||
|
|
@ -736,6 +793,20 @@ export class Sim {
|
|||
}
|
||||
}
|
||||
|
||||
// Advance both sides' shots. A player shot that reaches the pit is pinned
|
||||
// there and flagged spent: it still gets its final collision pass in
|
||||
// resolveShots, which then drops it. Pinning rather than letting it overshoot
|
||||
// matters because a death cuts the tick short before resolveShots runs, which
|
||||
// would otherwise strand shots far past t=1.
|
||||
advanceShots(dt) {
|
||||
for (const shot of this.shots) {
|
||||
shot.prevT = shot.t;
|
||||
shot.t += TUNE.SHOT_SPEED * (dt / 1000);
|
||||
if (shot.t >= 1) { shot.t = 1; shot.spent = true; }
|
||||
}
|
||||
for (const es of this.enemyShots) { es.prevT = es.t; es.t -= enemyShotSpeed(this.level) * (dt / 1000); }
|
||||
}
|
||||
|
||||
// -- collisions -----------------------------------------------------------
|
||||
|
||||
crossed(a, b) {
|
||||
|
|
@ -787,7 +858,7 @@ export class Sim {
|
|||
this.emit('spikeTrimmed', { lane: shot.lane, t: tip });
|
||||
}
|
||||
}
|
||||
this.shots = this.shots.filter((s) => !s.dead && s.t < 1);
|
||||
this.shots = this.shots.filter((s) => !s.dead && !s.spent);
|
||||
this.enemyShots = this.enemyShots.filter((s) => !s.dead);
|
||||
}
|
||||
|
||||
|
|
@ -823,13 +894,11 @@ export class Sim {
|
|||
this.emit('warpStart', { level: this.level, refly: true });
|
||||
return;
|
||||
}
|
||||
// Survivors sink back into the pit and rejoin the spawn queue; the
|
||||
// fight resumes where it left off (zapper charges are NOT refilled).
|
||||
for (const e of this.enemies) {
|
||||
const key = e.type;
|
||||
this.spawn.remaining[key] += 1;
|
||||
}
|
||||
this.enemies = [];
|
||||
// Dying is not a reset. Only the enemies that actually cornered the claw
|
||||
// go up with it — the rest of the wave stays on the web mid-climb, and
|
||||
// none of them are refunded into the spawn budget. Zapper charges are NOT
|
||||
// refilled either.
|
||||
this.enemies = this.enemies.filter((e) => e.t >= TUNE.DEATH_CLEAR_T);
|
||||
this.enemyShots = [];
|
||||
this.shots = [];
|
||||
this.phase = 'respite';
|
||||
|
|
@ -854,7 +923,7 @@ export class Sim {
|
|||
this.warpT = Math.min(1, this.warpT + dt / TUNE.WARP_MS);
|
||||
this.movePlayer(dt);
|
||||
this.fireTick(dt);
|
||||
for (const shot of this.shots) { shot.prevT = shot.t; shot.t += TUNE.SHOT_SPEED * (dt / 1000); }
|
||||
this.advanceShots(dt);
|
||||
// Shots ahead of the diving player shave spikes down.
|
||||
for (const shot of this.shots) {
|
||||
const tip = this.spikes[shot.lane];
|
||||
|
|
@ -865,7 +934,7 @@ export class Sim {
|
|||
this.emit('spikeTrimmed', { lane: shot.lane, t: tip });
|
||||
}
|
||||
}
|
||||
this.shots = this.shots.filter((s) => !s.dead && s.t < 1);
|
||||
this.shots = this.shots.filter((s) => !s.dead && !s.spent);
|
||||
// Flying into a spike costs a life and the warp restarts.
|
||||
const tip = this.spikes[this.playerLane()];
|
||||
if (tip < 1 && prev < tip && this.warpT >= tip) {
|
||||
|
|
@ -931,8 +1000,7 @@ export class Sim {
|
|||
}
|
||||
}
|
||||
|
||||
for (const shot of this.shots) { shot.prevT = shot.t; shot.t += TUNE.SHOT_SPEED * (dt / 1000); }
|
||||
for (const es of this.enemyShots) { es.prevT = es.t; es.t -= enemyShotSpeed(this.level) * (dt / 1000); }
|
||||
this.advanceShots(dt);
|
||||
|
||||
for (const e of this.enemies.slice()) {
|
||||
if (e.dead) continue;
|
||||
|
|
@ -949,10 +1017,11 @@ export class Sim {
|
|||
this.resolveShots();
|
||||
if (this.phase !== 'playing') return this.events;
|
||||
|
||||
// Enemy shots that reach the rim in the player's lane connect.
|
||||
// Enemy shots that reach the rim near the claw connect — a shot grazing
|
||||
// the lane boundary counts, so half-a-lane dodges are not free.
|
||||
for (const es of this.enemyShots) {
|
||||
if (es.t <= 0) {
|
||||
if (es.lane === this.playerLane()) {
|
||||
if (this.laneDistanceToPlayer(es.lane) < TUNE.ENEMY_SHOT_HIT_LANES) {
|
||||
this.killPlayer('shot');
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
laneCount, webForLevel, rimPoint, depthScale, makeProjector, aimLaneForAngle,
|
||||
bandIndexForLevel, startBonus, startLevelOptions,
|
||||
flipperSpeed, enemyShotSpeed, enemyFireRate, levelBudget, maxConcurrent,
|
||||
spawnInterval, flipRest, createGame, step, setAim, setFiring, superzap,
|
||||
spawnInterval, flipRest, flipClimbCooldown, createGame, step, setAim, setFiring, superzap,
|
||||
} from '../src/games/tempest/TempestLogic.js';
|
||||
|
||||
let failures = 0;
|
||||
|
|
@ -333,13 +333,17 @@ console.log('Enemy fixtures');
|
|||
sim9.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 };
|
||||
const deep = sim9.makeEnemy('pulsar', sim9.playerLane(), 0.6);
|
||||
sim9.enemies.push(deep);
|
||||
let deepDeath = false;
|
||||
let deepPulseDeath = false; let deepFired = false;
|
||||
for (let i = 0; i < 60 * 8; i += 1) {
|
||||
deep.t = 0.6; deep.dir = 1; // pin it deep in the well
|
||||
for (const e of step(sim9, DT)) if (e.type === 'playerHit') deepDeath = true;
|
||||
for (const e of step(sim9, DT)) {
|
||||
if (e.type === 'playerHit' && e.cause === 'pulsar') deepPulseDeath = true;
|
||||
if (e.type === 'enemyShotFired') deepFired = true;
|
||||
}
|
||||
if (sim9.phase !== 'playing') break;
|
||||
}
|
||||
check('pulsar deep in the well is harmless', !deepDeath);
|
||||
check('a pulsar\'s pulse is harmless deep in the well', !deepPulseDeath);
|
||||
check('but a deep pulsar still shoots', deepFired);
|
||||
}
|
||||
|
||||
// ── 7. Superzapper ───────────────────────────────────────────────────────────
|
||||
|
|
@ -374,8 +378,17 @@ console.log('Superzapper');
|
|||
step(sim, DT);
|
||||
check('empty zapper does nothing', sim.enemies.length === 1);
|
||||
|
||||
// A cleared level hands back one charge, not a full refill — burning both
|
||||
// has to cost something.
|
||||
sim.enterLevel(sim.level + 1);
|
||||
check('zapper recharges on a new level', sim.zapper.uses === TUNE.ZAPPER_USES);
|
||||
check('a new level grants exactly one zapper charge',
|
||||
sim.zapper.uses === TUNE.ZAPPER_REFILL, `uses=${sim.zapper.uses}`);
|
||||
sim.enterLevel(sim.level + 1);
|
||||
check('zapper charge caps at ZAPPER_USES',
|
||||
sim.zapper.uses === TUNE.ZAPPER_USES, `uses=${sim.zapper.uses}`);
|
||||
sim.enterLevel(sim.level + 1);
|
||||
check('zapper does not exceed its cap',
|
||||
sim.zapper.uses === TUNE.ZAPPER_USES, `uses=${sim.zapper.uses}`);
|
||||
}
|
||||
|
||||
// ── 8. Scoring, extra lives, skill step ──────────────────────────────────────
|
||||
|
|
@ -455,11 +468,13 @@ console.log('Level flow');
|
|||
check('flying into a spike costs a life', spiked && sim2.lives < livesBefore);
|
||||
check('spike death re-flies the warp', reflew);
|
||||
|
||||
// Death and respite: survivors rejoin the spawn budget.
|
||||
// Death is not a reset: only the enemies that cornered the claw go up with
|
||||
// it, the rest of the wave stays on the web, and nothing is refunded.
|
||||
const sim3 = createGame({ seed: 53 });
|
||||
sim3.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 };
|
||||
sim3.enemies.push(sim3.makeEnemy('flipper', (sim3.playerLane() + 5) % LANES, 0.5));
|
||||
sim3.enemies.push(sim3.makeEnemy('spiker', (sim3.playerLane() + 8) % LANES, 0.9));
|
||||
sim3.enemies.push(sim3.makeEnemy('flipper', (sim3.playerLane() + 2) % LANES, 0.1));
|
||||
sim3.killPlayer('shot');
|
||||
check('death costs a life', sim3.lives === TUNE.LIVES - 1);
|
||||
let respited = false;
|
||||
|
|
@ -468,8 +483,13 @@ console.log('Level flow');
|
|||
if (sim3.phase === 'playing') break;
|
||||
}
|
||||
check('respite follows a death with lives left', respited && sim3.phase === 'playing');
|
||||
check('survivors rejoined the spawn budget',
|
||||
sim3.spawn.remaining.flipper === 1 && sim3.spawn.remaining.spiker === 1 && sim3.enemies.length === 0);
|
||||
check('deep enemies survive the player\'s death',
|
||||
sim3.enemies.length === 2 && sim3.enemies.every((e) => e.t >= TUNE.DEATH_CLEAR_T),
|
||||
`left=${sim3.enemies.length}`);
|
||||
check('the enemies that cornered the claw are cleared',
|
||||
!sim3.enemies.some((e) => e.t < TUNE.DEATH_CLEAR_T));
|
||||
check('death does not refund the spawn budget', sim3.budgetTotal() === 0,
|
||||
`budget=${sim3.budgetTotal()}`);
|
||||
|
||||
// Game over at zero lives.
|
||||
const sim4 = createGame({ seed: 54 });
|
||||
|
|
@ -497,11 +517,30 @@ console.log('Difficulty escalation (levels 1-60)');
|
|||
if (flipRest(l) > flipRest(l - 1)) ok = false;
|
||||
}
|
||||
check('all difficulty curves monotonic', ok);
|
||||
check('curves stay bounded',
|
||||
flipperSpeed(999) <= TUNE.FLIPPER_SPEED_MAX
|
||||
&& levelBudget(999) <= TUNE.BUDGET_MAX
|
||||
&& spawnInterval(999) >= TUNE.SPAWN_MS_MIN
|
||||
&& flipRest(999) >= TUNE.FLIP_REST_MIN_MS);
|
||||
|
||||
// Timings genuinely cannot fall to zero, so they keep hard floors.
|
||||
check('timing curves respect their floors',
|
||||
spawnInterval(999) >= TUNE.SPAWN_MS_MIN
|
||||
&& flipRest(999) >= TUNE.FLIP_REST_MIN_MS
|
||||
&& flipClimbCooldown(999) >= TUNE.FLIP_CLIMB_COOLDOWN_MIN_MS);
|
||||
|
||||
// The growth curves must NOT plateau — endless play has to keep ramping
|
||||
// instead of replaying level 44 forever.
|
||||
check('growth curves still rise deep into an endless run',
|
||||
flipperSpeed(200) > flipperSpeed(100)
|
||||
&& enemyShotSpeed(200) > enemyShotSpeed(100)
|
||||
&& enemyFireRate(200) > enemyFireRate(100)
|
||||
&& levelBudget(200) > levelBudget(100));
|
||||
|
||||
// ...but stay sane: a flipper must always be slower than a player shot, or
|
||||
// it could never be shot down on approach.
|
||||
check('growth curves stay under their sanity ceilings',
|
||||
flipperSpeed(9999) <= TUNE.FLIPPER_SPEED_HARD_MAX
|
||||
&& TUNE.FLIPPER_SPEED_HARD_MAX < TUNE.SHOT_SPEED
|
||||
&& enemyShotSpeed(9999) <= TUNE.ENEMY_SHOT_SPEED_HARD_MAX
|
||||
&& enemyFireRate(9999) <= TUNE.ENEMY_FIRE_HARD_MAX
|
||||
&& levelBudget(9999) <= TUNE.BUDGET_HARD_MAX
|
||||
&& maxConcurrent(9999) <= TUNE.CONCURRENT_MAX);
|
||||
check('unlock order matches the arcade schedule',
|
||||
TUNE.UNLOCK.flipper < TUNE.UNLOCK.tanker
|
||||
&& TUNE.UNLOCK.tanker < TUNE.UNLOCK.spiker
|
||||
|
|
@ -522,9 +561,11 @@ console.log('Difficulty escalation (levels 1-60)');
|
|||
console.log('Monte-carlo bot soak (40 games)');
|
||||
{
|
||||
let cleared = 0; let deaths = 0; let maxLevel = 0; let bad = 0; let overs = 0;
|
||||
let gained = 0; // total levels *gained*, since games start at 1/5/9/13
|
||||
const started = Date.now();
|
||||
for (let g = 0; g < 40; g += 1) {
|
||||
const sim = createGame({ seed: 9000 + g, startLevel: 1 + (g % 4) * 4 });
|
||||
const startLevel = 1 + (g % 4) * 4;
|
||||
const sim = createGame({ seed: 9000 + g, startLevel });
|
||||
setFiring(sim, true);
|
||||
let steps = 0;
|
||||
const stepCap = 60 * 60 * 6; // six simulated minutes per game
|
||||
|
|
@ -564,11 +605,26 @@ console.log('Monte-carlo bot soak (40 games)');
|
|||
if (sim.shots.length > TUNE.MAX_SHOTS) bad += 1;
|
||||
}
|
||||
maxLevel = Math.max(maxLevel, sim.level);
|
||||
gained += sim.level - startLevel;
|
||||
}
|
||||
const avgRun = gained / 40;
|
||||
check('no invariant violations across the soak', bad === 0, `violations=${bad}`);
|
||||
check('bot makes real progress', cleared >= 40 && maxLevel >= 5, `cleared=${cleared} maxLevel=${maxLevel}`);
|
||||
check('deaths occur (the game bites back)', deaths > 0, `deaths=${deaths}`);
|
||||
console.log(` info soak: cleared=${cleared} deaths=${deaths} gameovers=${overs} maxLevel=${maxLevel} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
||||
|
||||
// Difficulty regression guard. This bot is deliberately naive (aim at the
|
||||
// nearest enemy, hold fire, panic-zap) — it stands in for a careless player.
|
||||
// Before the retune it posted deaths=2, gameovers=0, maxLevel=22 across
|
||||
// these same 40 games, i.e. it could not lose. It now has to lose.
|
||||
check('a careless player actually loses', overs >= 30, `gameovers=${overs}/40`);
|
||||
check('deaths are common, not incidental', deaths / 40 >= 3,
|
||||
`deaths=${deaths} (${(deaths / 40).toFixed(1)}/game)`);
|
||||
check('but level 1 is still winnable', cleared >= 40, `cleared=${cleared}`);
|
||||
// Mean levels gained per run — far stabler across seeds than a max, and the
|
||||
// number to watch if this is ever retuned again. Too low means level 1 got
|
||||
// unfair; too high means the retune has eroded.
|
||||
check('the naive bot does not cruise', avgRun <= 6, `avgRun=${avgRun.toFixed(1)}`);
|
||||
check('runs are not instantly fatal', avgRun >= 1, `avgRun=${avgRun.toFixed(1)}`);
|
||||
console.log(` info soak: cleared=${cleared} deaths=${deaths} gameovers=${overs}`
|
||||
+ ` maxLevel=${maxLevel} avgRun=+${avgRun.toFixed(1)} in ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
||||
}
|
||||
|
||||
// ── Result ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||