Compare commits
No commits in common. "main" and "Game-Polish" have entirely different histories.
main
...
Game-Polis
Binary file not shown.
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 341 KiB |
Binary file not shown.
|
|
@ -483,17 +483,6 @@ export const MANIFEST = {
|
||||||
// so its audio only downloads once Tempest is actually entered.
|
// so its audio only downloads once Tempest is actually entered.
|
||||||
(scene) => musicFrom(scene, 'arcadedark-music'),
|
(scene) => musicFrom(scene, 'arcadedark-music'),
|
||||||
],
|
],
|
||||||
defender: [
|
|
||||||
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
|
|
||||||
// so its audio only downloads once Defender is actually entered.
|
|
||||||
(scene) => musicFrom(scene, 'arcadedark-music'),
|
|
||||||
],
|
|
||||||
geometrywars: [
|
|
||||||
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
|
|
||||||
// so its audio only downloads once Geometry Wars is actually entered.
|
|
||||||
// Everything else in the game is drawn procedurally; no image assets.
|
|
||||||
(scene) => musicFrom(scene, 'arcadedark-music'),
|
|
||||||
],
|
|
||||||
mastermind: [
|
mastermind: [
|
||||||
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
|
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
|
||||||
// its audio only downloads once Mastermind is actually entered.
|
// its audio only downloads once Mastermind is actually entered.
|
||||||
|
|
|
||||||
|
|
@ -124,5 +124,3 @@ registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-co
|
||||||
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
||||||
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
|
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
|
||||||
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });
|
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });
|
||||||
registerGame({ slug: 'defender', name: 'Defender', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 97 });
|
|
||||||
registerGame({ slug: 'geometrywars', name: 'Geometry Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 98 });
|
|
||||||
|
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
// Defender — procedural wireframe combat effects.
|
|
||||||
//
|
|
||||||
// Every bullet and explosion is a Graphics stroke, not a sprite: a capped TTL
|
|
||||||
// list is fed from sim events and redrawn each frame using the repo's
|
|
||||||
// glow-stroke pair (one wide low-alpha pass, then a thin opaque one on top) —
|
|
||||||
// the same idiom as Total Annihilation's TAFx.js and Star Control's fxList.
|
|
||||||
// Explosions render as radiating line-segment "shatter" bursts rather than
|
|
||||||
// filled particles, to match the wireframe aesthetic.
|
|
||||||
|
|
||||||
const MAX_FX = 420;
|
|
||||||
|
|
||||||
// Every explosion — enemy kills, the boss dying, the player dying — uses this
|
|
||||||
// one bright, fixed orange rather than the target's own color, so a kill
|
|
||||||
// always reads as a kill: a consistent, high-contrast burst against every
|
|
||||||
// level's cyan/magenta/green/yellow/red wireframe palette.
|
|
||||||
export const EXPLOSION_COLOR = 0xff7a1a;
|
|
||||||
|
|
||||||
export default class DefenderFx {
|
|
||||||
constructor(scene, depths) {
|
|
||||||
this.scene = scene;
|
|
||||||
this.gUnder = scene.add.graphics().setDepth(depths.fxUnder);
|
|
||||||
this.gOver = scene.add.graphics().setDepth(depths.fxOver);
|
|
||||||
this.list = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.gUnder.destroy();
|
|
||||||
this.gOver.destroy();
|
|
||||||
this.list.length = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
push(fx) {
|
|
||||||
if (this.list.length >= MAX_FX) this.list.shift();
|
|
||||||
fx.age = 0;
|
|
||||||
this.list.push(fx);
|
|
||||||
}
|
|
||||||
|
|
||||||
shatter(x, y, color, count = 8, speed = 220) {
|
|
||||||
for (let i = 0; i < count; i += 1) {
|
|
||||||
const a = (i / count) * Math.PI * 2 + Math.random() * 0.4;
|
|
||||||
const len = 10 + Math.random() * 14;
|
|
||||||
const spd = speed * (0.6 + Math.random() * 0.6);
|
|
||||||
this.push({
|
|
||||||
kind: 'shard', x, y, ang: a, len, color,
|
|
||||||
dx: Math.cos(a) * spd, dy: Math.sin(a) * spd, spin: (Math.random() - 0.5) * 6,
|
|
||||||
ttl: 360 + Math.random() * 220,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ring(x, y, color, r = 40, ttl = 320) {
|
|
||||||
this.push({ kind: 'ring', x, y, r, color, ttl });
|
|
||||||
}
|
|
||||||
|
|
||||||
tracer(x1, y1, x2, y2, color, width = 2, ttl = 90) {
|
|
||||||
this.push({ kind: 'tracer', x1, y1, x2, y2, color, width, ttl });
|
|
||||||
}
|
|
||||||
|
|
||||||
spark(x, y, color, ttl = 220) {
|
|
||||||
this.push({ kind: 'spark', x, y, color, ttl });
|
|
||||||
}
|
|
||||||
|
|
||||||
onEvent(ev) {
|
|
||||||
switch (ev.type) {
|
|
||||||
case 'enemyKilled':
|
|
||||||
this.shatter(ev.x, ev.y, EXPLOSION_COLOR, ev.enemyType === 'walker' ? 12 : 8,
|
|
||||||
ev.enemyType === 'walker' ? 260 : 200);
|
|
||||||
this.ring(ev.x, ev.y, EXPLOSION_COLOR);
|
|
||||||
break;
|
|
||||||
case 'humanoidLost':
|
|
||||||
this.spark(ev.x ?? 0, ev.y ?? 0, 0xff5566, 300);
|
|
||||||
break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
draw(delta) {
|
|
||||||
const gu = this.gUnder; const go = this.gOver;
|
|
||||||
gu.clear(); go.clear();
|
|
||||||
|
|
||||||
const keep = [];
|
|
||||||
for (const fx of this.list) {
|
|
||||||
fx.age += delta;
|
|
||||||
if (fx.age >= fx.ttl) continue;
|
|
||||||
keep.push(fx);
|
|
||||||
const t = fx.age / fx.ttl;
|
|
||||||
const a = 1 - t;
|
|
||||||
|
|
||||||
switch (fx.kind) {
|
|
||||||
case 'shard': {
|
|
||||||
const cx = fx.x + fx.dx * (fx.age / 1000);
|
|
||||||
const cy = fx.y + fx.dy * (fx.age / 1000);
|
|
||||||
const ang = fx.ang + fx.spin * (fx.age / 1000);
|
|
||||||
const hx = Math.cos(ang) * fx.len * 0.5;
|
|
||||||
const hy = Math.sin(ang) * fx.len * 0.5;
|
|
||||||
go.lineStyle(4, fx.color, 0.18 * a);
|
|
||||||
go.lineBetween(cx - hx, cy - hy, cx + hx, cy + hy);
|
|
||||||
go.lineStyle(1.6, fx.color, a);
|
|
||||||
go.lineBetween(cx - hx, cy - hy, cx + hx, cy + hy);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'ring': {
|
|
||||||
const r = fx.r * (0.3 + t * 1.1);
|
|
||||||
go.lineStyle(5 * a + 1, fx.color, 0.6 * a);
|
|
||||||
go.strokeCircle(fx.x, fx.y, r);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'tracer': {
|
|
||||||
go.lineStyle(fx.width * 3, fx.color, 0.16 * a);
|
|
||||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
|
||||||
go.lineStyle(fx.width, fx.color, a);
|
|
||||||
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'spark': {
|
|
||||||
go.fillStyle(fx.color, a);
|
|
||||||
go.fillCircle(fx.x, fx.y, 3 * a + 1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.list = keep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,782 +0,0 @@
|
||||||
import * as Phaser from 'phaser';
|
|
||||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
|
||||||
import { Button } from '../../ui/Button.js';
|
|
||||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
|
||||||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
|
||||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
|
||||||
import { api } from '../../services/api.js';
|
|
||||||
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
|
|
||||||
import {
|
|
||||||
WORLD_W, Y_MIN, Y_GROUND, Y_MAX, TUNE,
|
|
||||||
wrap, tdelta, createGame, setInput, step,
|
|
||||||
} from './DefenderLogic.js';
|
|
||||||
import DefenderFx, { EXPLOSION_COLOR } from './DefenderFx.js';
|
|
||||||
import { drawVectorText } from './DefenderVectorFont.js';
|
|
||||||
|
|
||||||
// Depth layers — one Graphics object per layer, cleared and redrawn every
|
|
||||||
// frame from current sim state (never persistent GameObjects), same idiom as
|
|
||||||
// Tempest/Total Annihilation.
|
|
||||||
const D = {
|
|
||||||
bgFar: -6, bgNear: -5, groundLine: -4, extraction: -3,
|
|
||||||
fxUnder: -2, humanoids: -1, tractorBeams: -0.5, enemies: 0,
|
|
||||||
player: 1, shots: 1.5, fxOver: 2, banner: 5, ui: 30, overlay: 61,
|
|
||||||
};
|
|
||||||
|
|
||||||
const BEST_KEY = 'defender-best';
|
|
||||||
|
|
||||||
// One accent palette per level (1-indexed) — cycles if a level ever exceeds
|
|
||||||
// the authored count. Chosen to read clearly as wireframe strokes on black.
|
|
||||||
const PALETTES = [
|
|
||||||
{ accent: 0x33e6ff, swarmer: 0x33e6ff, walker: 0xff8a3c, abductor: 0xff4fd8, humanoid: 0xffffff, boss: 0xff4fd8, ground: 0x2a3a4a },
|
|
||||||
{ accent: 0xff4fd8, swarmer: 0xff4fd8, walker: 0xffd23c, abductor: 0x33e6ff, humanoid: 0xffffff, boss: 0x33e6ff, ground: 0x3a2a4a },
|
|
||||||
{ accent: 0x7cff5a, swarmer: 0x7cff5a, walker: 0xff8a3c, abductor: 0xffd23c, humanoid: 0xffffff, boss: 0xffd23c, ground: 0x1a3a2a },
|
|
||||||
{ accent: 0xffd23c, swarmer: 0xffd23c, walker: 0xff4fd8, abductor: 0x7cff5a, humanoid: 0xffffff, boss: 0x7cff5a, ground: 0x3a3a1a },
|
|
||||||
{ accent: 0xff5a5a, swarmer: 0xff5a5a, walker: 0x33e6ff, abductor: 0xffd23c, humanoid: 0xffffff, boss: 0xffffff, ground: 0x3a1a1a },
|
|
||||||
];
|
|
||||||
function paletteFor(level) { return PALETTES[(level - 1) % PALETTES.length]; }
|
|
||||||
|
|
||||||
// Vivid, deliberately off-palette pink — every level accent above is a cyan/
|
|
||||||
// magenta/green/yellow/red, so this reads as "alert" rather than blending in
|
|
||||||
// as just another enemy color.
|
|
||||||
const ALERT_PINK = 0xff17c4;
|
|
||||||
|
|
||||||
// The player's own bolts, distinct from every level's accent color and from
|
|
||||||
// the red enemy shots, so incoming vs. outgoing fire is unmistakable at a glance.
|
|
||||||
const PLAYER_SHOT_COLOR = 0xffe135;
|
|
||||||
|
|
||||||
// Fixed neon engine-glow accent for the player ship — independent of the level
|
|
||||||
// palette, like PLAYER_SHOT_COLOR, so the thruster always reads as "engine".
|
|
||||||
const ENGINE_COLOR = 0x00e5ff;
|
|
||||||
|
|
||||||
// Player ship silhouette, in unit space (nose at +x; mirrored by `facing`,
|
|
||||||
// scaled by radius) — an asymmetric interceptor: a long swept dorsal fin
|
|
||||||
// against a short ventral one, with concave "blade" notches at both wing
|
|
||||||
// roots and a spiked tail, for a sharper silhouette than a plain dart.
|
|
||||||
const SHIP_HULL = [
|
|
||||||
[1.35, 0], [0.75, -0.16], [0.30, -0.40], [-0.10, -0.30], [-1.15, -0.92],
|
|
||||||
[-0.50, -0.26], [-0.80, -0.14], [-1.05, 0], [-0.80, 0.14], [-0.50, 0.26],
|
|
||||||
[-0.95, 0.68], [-0.10, 0.30], [0.30, 0.40], [0.75, 0.16],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Mini-map panel — a squashed side-view of the whole wrapped world, tucked
|
|
||||||
// under MusicPlayer's buttons + track-name readout (src/ui/MusicPlayer.js:
|
|
||||||
// PAD=12, BTN=32 buttons at y=12-44, track info text at y=58, so the music
|
|
||||||
// block's footprint ends around y=74-80).
|
|
||||||
const MINI_W = 220;
|
|
||||||
const MINI_H = 64;
|
|
||||||
const MINI_X = GAME_WIDTH - 20 - MINI_W;
|
|
||||||
const MINI_Y = 92;
|
|
||||||
|
|
||||||
export default class DefenderGame extends Phaser.Scene {
|
|
||||||
constructor() { super('DefenderGame'); }
|
|
||||||
|
|
||||||
init(data) {
|
|
||||||
this.gameDef = data.game ?? { slug: 'defender', name: 'Defender' };
|
|
||||||
this.mode = 'title'; // 'title' | 'playing' | 'gameover' | 'victory'
|
|
||||||
this.sim = null;
|
|
||||||
this.camX = WORLD_W / 2;
|
|
||||||
this.spin = 0;
|
|
||||||
this.banner = null;
|
|
||||||
this.multPulseMs = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
create() {
|
|
||||||
try {
|
|
||||||
const { tracks, volume } = getGameSoundtrack(this);
|
|
||||||
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
|
|
||||||
} catch (_) { /* optional */ }
|
|
||||||
|
|
||||||
this.bgRect = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x05060c)
|
|
||||||
.setDepth(D.bgFar - 1);
|
|
||||||
this.bgFarG = this.add.graphics().setDepth(D.bgFar);
|
|
||||||
this.bgNearG = this.add.graphics().setDepth(D.bgNear);
|
|
||||||
this.groundG = this.add.graphics().setDepth(D.groundLine);
|
|
||||||
this.extractG = this.add.graphics().setDepth(D.extraction);
|
|
||||||
this.humanoidG = this.add.graphics().setDepth(D.humanoids);
|
|
||||||
this.beamG = this.add.graphics().setDepth(D.tractorBeams);
|
|
||||||
this.enemyG = this.add.graphics().setDepth(D.enemies);
|
|
||||||
this.playerG = this.add.graphics().setDepth(D.player);
|
|
||||||
this.shotG = this.add.graphics().setDepth(D.shots);
|
|
||||||
this.bannerG = this.add.graphics().setDepth(D.banner);
|
|
||||||
this.hudG = this.add.graphics().setDepth(D.ui);
|
|
||||||
this.minimapG = this.add.graphics().setDepth(D.ui);
|
|
||||||
this.minimapLabel = this.add.text(MINI_X + MINI_W / 2, MINI_Y - 16, 'MAP', {
|
|
||||||
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
|
|
||||||
}).setOrigin(0.5, 1).setDepth(D.ui);
|
|
||||||
|
|
||||||
this.fx = new DefenderFx(this, { fxUnder: D.fxUnder, fxOver: D.fxOver });
|
|
||||||
|
|
||||||
this.crt = applyArcadeCRTOverlay(this, {
|
|
||||||
accentTint: paletteFor(1).accent, scanlineTint: paletteFor(1).accent,
|
|
||||||
});
|
|
||||||
this.events.once('shutdown', () => { this.crt.destroy(); this.fx.destroy(); });
|
|
||||||
|
|
||||||
this.buildHud();
|
|
||||||
this.bindInput();
|
|
||||||
this.showTitle();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Input ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
bindInput() {
|
|
||||||
this.cursors = this.input.keyboard.createCursorKeys();
|
|
||||||
this.wasd = this.input.keyboard.addKeys('W,A,S,D,SPACE,SHIFT');
|
|
||||||
}
|
|
||||||
|
|
||||||
readInput() {
|
|
||||||
return {
|
|
||||||
left: this.cursors.left.isDown || this.wasd.A.isDown,
|
|
||||||
right: this.cursors.right.isDown || this.wasd.D.isDown,
|
|
||||||
up: this.cursors.up.isDown || this.wasd.W.isDown,
|
|
||||||
down: this.cursors.down.isDown || this.wasd.S.isDown,
|
|
||||||
fire: this.cursors.space?.isDown || this.wasd.SPACE.isDown,
|
|
||||||
overdrive: this.wasd.SHIFT.isDown,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Title screen ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
showTitle() {
|
|
||||||
this.mode = 'title';
|
|
||||||
this.titleUi = this.add.container(0, 0).setDepth(D.ui + 1);
|
|
||||||
this.titleG = this.add.graphics().setDepth(D.ui);
|
|
||||||
|
|
||||||
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
|
|
||||||
const sub = this.add.text(GAME_WIDTH / 2, 470,
|
|
||||||
'ARROWS/WASD: FLY • SPACE: FIRE • SHIFT: OVERDRIVE (when charged)', {
|
|
||||||
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
|
|
||||||
}).setOrigin(0.5);
|
|
||||||
const sub2 = this.add.text(GAME_WIDTH / 2, 508,
|
|
||||||
'Free the humanoids from abductors, catch them before they fall, and fly them to a beacon.', {
|
|
||||||
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
|
||||||
}).setOrigin(0.5);
|
|
||||||
this.titleUi.add([sub, sub2]);
|
|
||||||
if (best > 0) {
|
|
||||||
this.titleUi.add(this.add.text(GAME_WIDTH / 2, 546, `BEST ${best}`, {
|
|
||||||
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
|
|
||||||
}).setOrigin(0.5));
|
|
||||||
}
|
|
||||||
const play = new Button(this, GAME_WIDTH / 2, 640, 'Play', () => this.startRun(),
|
|
||||||
{ width: 280, height: 66, fontSize: 28 });
|
|
||||||
this.titleUi.add(play);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawTitle(delta) {
|
|
||||||
const g = this.titleG;
|
|
||||||
g.clear();
|
|
||||||
this.spin += delta * 0.0002;
|
|
||||||
const pal = paletteFor(1);
|
|
||||||
for (let i = 0; i < 40; i += 1) {
|
|
||||||
const a = this.spin + (i / 40) * Math.PI * 2;
|
|
||||||
const r = 260 + Math.sin(a * 3) * 20;
|
|
||||||
const x = GAME_WIDTH / 2 + Math.cos(a) * r;
|
|
||||||
const y = 300 + Math.sin(a) * r * 0.32;
|
|
||||||
g.lineStyle(1.4, pal.accent, 0.12);
|
|
||||||
g.lineBetween(GAME_WIDTH / 2, 300, x, y);
|
|
||||||
}
|
|
||||||
drawVectorText(g, 'DEFENDER', GAME_WIDTH / 2, 300, 24, pal.accent,
|
|
||||||
{ lineWidth: 5, glowWidth: 18, glowAlpha: 0.22 });
|
|
||||||
}
|
|
||||||
|
|
||||||
startRun() {
|
|
||||||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
|
||||||
this.titleUi.destroy(true);
|
|
||||||
this.titleG.destroy();
|
|
||||||
this.titleUi = null;
|
|
||||||
this.titleG = null;
|
|
||||||
this.sim = createGame({ seed: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 });
|
|
||||||
this.camX = this.sim.player.x;
|
|
||||||
this.mode = 'playing';
|
|
||||||
this.applyPalette();
|
|
||||||
this.showBanner('LEVEL 1', paletteFor(1).accent, 1600);
|
|
||||||
playSound(this, SFX.COUNTDOWN_GO);
|
|
||||||
}
|
|
||||||
|
|
||||||
applyPalette() {
|
|
||||||
const pal = paletteFor(this.sim.level);
|
|
||||||
this.crt.setIntensity({ accentTint: pal.accent, scanlineTint: pal.accent });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── HUD ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
buildHud() {
|
|
||||||
const font = { fontFamily: 'm6x11, "Julius Sans One"' };
|
|
||||||
this.scoreLabel = this.add.text(40, 26, 'SCORE', { ...font, fontSize: '18px', color: COLORS.mutedHex }).setDepth(D.ui);
|
|
||||||
this.scoreText = this.add.text(40, 46, '0', { ...font, fontSize: '40px', color: COLORS.textHex }).setDepth(D.ui);
|
|
||||||
this.levelText = this.add.text(GAME_WIDTH / 2, 30, '', { ...font, fontSize: '28px', color: COLORS.goldHex })
|
|
||||||
.setOrigin(0.5, 0).setDepth(D.ui);
|
|
||||||
this.bestText = this.add.text(GAME_WIDTH - 40, 30, `BEST ${Number(localStorage.getItem(BEST_KEY) ?? 0)}`,
|
|
||||||
{ ...font, fontSize: '22px', color: COLORS.mutedHex }).setOrigin(1, 0).setDepth(D.ui);
|
|
||||||
this.multText = this.add.text(GAME_WIDTH - 40, 66, '', { ...font, fontSize: '26px', color: COLORS.goldHex })
|
|
||||||
.setOrigin(1, 0).setDepth(D.ui);
|
|
||||||
this.overdriveLabel = this.add.text(40, 150, 'OVERDRIVE', { ...font, fontSize: '16px', color: COLORS.mutedHex }).setDepth(D.ui);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateHud() {
|
|
||||||
const sim = this.sim;
|
|
||||||
this.scoreText.setText(String(sim.score));
|
|
||||||
this.levelText.setText(`LEVEL ${sim.level} • WAVE ${Math.min(sim.wave, TUNE.WAVES_PER_LEVEL)}/${TUNE.WAVES_PER_LEVEL}`);
|
|
||||||
if (sim.multiplier > 1) {
|
|
||||||
this.multText.setText(`×${sim.multiplier}`);
|
|
||||||
this.multText.setVisible(true);
|
|
||||||
} else {
|
|
||||||
this.multText.setVisible(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const g = this.hudG;
|
|
||||||
g.clear();
|
|
||||||
const pal = paletteFor(sim.level);
|
|
||||||
|
|
||||||
// Lives as tiny ship glyphs under the score, matching the real thing.
|
|
||||||
for (let i = 0; i < Math.min(sim.lives, 8); i += 1) {
|
|
||||||
const x = 50 + i * 38; const y = 108;
|
|
||||||
this.drawShip(g, x, y, 12, 1, pal.accent, ENGINE_COLOR, 0, [[5, 0.18], [2, 1]]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overdrive meter bar.
|
|
||||||
const bx = 40; const by = 172; const bw = 260; const bh = 18;
|
|
||||||
g.lineStyle(2, COLORS.muted, 0.6);
|
|
||||||
g.strokeRect(bx, by, bw, bh);
|
|
||||||
const fillColor = sim.overdriveActive ? 0xffffff : pal.accent;
|
|
||||||
g.fillStyle(fillColor, sim.overdriveMeter >= 1 && !sim.overdriveActive ? 0.5 + 0.5 * Math.sin(this.time.now * 0.01) : 0.8);
|
|
||||||
g.fillRect(bx + 2, by + 2, Math.max(0, (bw - 4) * sim.overdriveMeter), bh - 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Frame loop ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
update(time, delta) {
|
|
||||||
if (this.mode === 'title') { this.drawTitle(delta); return; }
|
|
||||||
if (!this.sim) return;
|
|
||||||
|
|
||||||
if (this.mode === 'playing') {
|
|
||||||
setInput(this.sim, this.readInput());
|
|
||||||
const events = step(this.sim, delta);
|
|
||||||
for (const e of events) this.handleEvent(e);
|
|
||||||
}
|
|
||||||
this.syncGraphics(delta);
|
|
||||||
if (this.mode === 'playing') this.updateHud();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEvent(e) {
|
|
||||||
const pal = paletteFor(this.sim.level);
|
|
||||||
switch (e.type) {
|
|
||||||
case 'shotFired':
|
|
||||||
if (!e.enemy) playSound(this, SFX.LASER_ZAP);
|
|
||||||
break;
|
|
||||||
case 'enemyKilled':
|
|
||||||
playSound(this, e.enemyType === 'walker' ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE);
|
|
||||||
// Sim events carry world-space x/y; DefenderFx draws in screen space, so the x has
|
|
||||||
// to go through screenX() here or the burst lands wherever the world coordinate
|
|
||||||
// happens to fall on screen instead of where the kill actually happened.
|
|
||||||
this.fx.onEvent({ ...e, x: this.screenX(e.x) });
|
|
||||||
break;
|
|
||||||
case 'humanoidGrabbed':
|
|
||||||
playSound(this, SFX.CASINO_LOSE);
|
|
||||||
this.showBanner('HUMAN CAPTURED!', ALERT_PINK, 1500);
|
|
||||||
this.crt.pulse(0.5, 220);
|
|
||||||
break;
|
|
||||||
case 'humanoidPickedUp':
|
|
||||||
playSound(this, SFX.UI_CHIME);
|
|
||||||
break;
|
|
||||||
case 'humanoidRescued':
|
|
||||||
playSound(this, SFX.VICTORY_SHORT);
|
|
||||||
break;
|
|
||||||
case 'humanoidLost':
|
|
||||||
playSound(this, SFX.CASINO_LOSE);
|
|
||||||
this.fx.onEvent({ ...e, x: this.screenX(e.x) });
|
|
||||||
break;
|
|
||||||
case 'overdriveReady':
|
|
||||||
playSound(this, SFX.EIGHTBIT_COUNT);
|
|
||||||
break;
|
|
||||||
case 'overdriveStart':
|
|
||||||
playSound(this, SFX.ENERGY_HUM);
|
|
||||||
this.crt.pulse(0.6, 260);
|
|
||||||
break;
|
|
||||||
case 'overdriveEnd':
|
|
||||||
playSound(this, SFX.SCIFI_WOOSH);
|
|
||||||
break;
|
|
||||||
case 'waveStart':
|
|
||||||
this.showBanner(`WAVE ${e.wave}`, pal.accent, 1200);
|
|
||||||
break;
|
|
||||||
case 'waveClear':
|
|
||||||
playSound(this, SFX.UI_ACTIVATE);
|
|
||||||
break;
|
|
||||||
case 'bossSpawn':
|
|
||||||
playSound(this, SFX.SCIFI_REVEAL);
|
|
||||||
this.showBanner(`WARNING: ${e.kind}`, 0xff4040, 2000);
|
|
||||||
break;
|
|
||||||
case 'bossPhaseChange':
|
|
||||||
playSound(this, SFX.EIGHTBIT_EXPLODE_2);
|
|
||||||
this.crt.pulse(0.7, 300);
|
|
||||||
break;
|
|
||||||
case 'bossDefeated':
|
|
||||||
playSound(this, SFX.SCIFI_EXPLODE);
|
|
||||||
this.fx.shatter(this.screenX(this.sim.boss?.x ?? this.camX), (Y_MIN + Y_GROUND) / 2, EXPLOSION_COLOR, 24, 320);
|
|
||||||
this.crt.pulse(1, 400);
|
|
||||||
break;
|
|
||||||
case 'levelComplete':
|
|
||||||
this.showBanner(e.fullRescue ? 'LEVEL CLEAR — ALL RESCUED!' : 'LEVEL CLEAR', COLORS.gold, 2000);
|
|
||||||
playSound(this, SFX.EIGHTBIT_WIN);
|
|
||||||
break;
|
|
||||||
case 'playerDied':
|
|
||||||
playSound(this, SFX.EIGHTBIT_EXPLODE_2);
|
|
||||||
// Player position is world-space; convert through the camera or the burst
|
|
||||||
// renders at whatever raw world-x maps to in screen pixels, not the ship.
|
|
||||||
this.fx.shatter(this.screenX(this.sim.player.x), this.sim.player.y, EXPLOSION_COLOR, 14, 260);
|
|
||||||
this.crt.pulse(0.8, 320);
|
|
||||||
break;
|
|
||||||
case 'playerRespawned':
|
|
||||||
playSound(this, SFX.COUNTDOWN_GO);
|
|
||||||
break;
|
|
||||||
case 'victory':
|
|
||||||
this.onVictory(e);
|
|
||||||
break;
|
|
||||||
case 'gameOver':
|
|
||||||
this.onGameOver(e);
|
|
||||||
break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
if (this.sim.level && (e.type === 'levelComplete')) {
|
|
||||||
this.time.delayedCall(50, () => this.applyPalette());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── World → screen mapping & camera ─────────────────────────────────────
|
|
||||||
|
|
||||||
screenX(worldX) {
|
|
||||||
return GAME_WIDTH / 2 + tdelta(this.camX, worldX);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateCamera() {
|
|
||||||
const target = this.sim.player.x;
|
|
||||||
const d = tdelta(this.camX, target);
|
|
||||||
this.camX = wrap(this.camX + d * 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A sleek, asymmetric interceptor silhouette in place of the old plain dart:
|
|
||||||
// a long swept dorsal fin against a shorter ventral one, concave "blade" cuts
|
|
||||||
// at each wing root, a canted canopy, and a speed-reactive engine flare —
|
|
||||||
// aiming for sharp/edgy cyberpunk rather than a generic arcade ship.
|
|
||||||
drawShip(g, x, y, r, facing, color, engineColor, thrust, passes) {
|
|
||||||
const P = (ux, uy) => ({ x: x + facing * ux * r, y: y + uy * r });
|
|
||||||
const hull = SHIP_HULL.map(([ux, uy]) => P(ux, uy));
|
|
||||||
|
|
||||||
for (const [lw, a] of passes) {
|
|
||||||
g.lineStyle(lw, color, a);
|
|
||||||
g.beginPath();
|
|
||||||
hull.forEach((p, i) => { if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y); });
|
|
||||||
g.closePath();
|
|
||||||
g.strokePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fuselage spine.
|
|
||||||
const spineA = P(0.9, 0); const spineB = P(-0.8, 0);
|
|
||||||
g.lineStyle(passes[passes.length - 1][0] * 0.7, color, 0.3);
|
|
||||||
g.lineBetween(spineA.x, spineA.y, spineB.x, spineB.y);
|
|
||||||
|
|
||||||
// Canted canopy — a filled, glassy diamond just aft of the nose.
|
|
||||||
const canopy = [[0.63, 0], [0.45, -0.11], [0.27, 0], [0.45, 0.11]].map(([ux, uy]) => P(ux, uy));
|
|
||||||
g.beginPath();
|
|
||||||
canopy.forEach((p, i) => { if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y); });
|
|
||||||
g.closePath();
|
|
||||||
g.fillStyle(color, 0.3);
|
|
||||||
g.fillPath();
|
|
||||||
g.lineStyle(1.6, 0xffffff, 0.85);
|
|
||||||
g.strokePath();
|
|
||||||
|
|
||||||
// Two greeble ticks along the long dorsal fin for mechanical detail.
|
|
||||||
const wingA = P(-0.1, -0.3); const wingB = P(-1.15, -0.92);
|
|
||||||
const wdx = wingB.x - wingA.x; const wdy = wingB.y - wingA.y;
|
|
||||||
const wlen = Math.hypot(wdx, wdy) || 1;
|
|
||||||
const nx = -wdy / wlen; const ny = wdx / wlen;
|
|
||||||
for (const t of [0.35, 0.7]) {
|
|
||||||
const mx = wingA.x + wdx * t; const my = wingA.y + wdy * t;
|
|
||||||
g.lineStyle(1.4, color, 0.6);
|
|
||||||
g.lineBetween(mx - nx * r * 0.12, my - ny * r * 0.12, mx + nx * r * 0.12, my + ny * r * 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Engine flare — a fixed neon accent (not the level palette) so the
|
|
||||||
// thruster always reads as "engine", and it stretches with current speed.
|
|
||||||
const tail = P(-1.05, 0);
|
|
||||||
const flareLen = r * (0.5 + thrust * 1.5);
|
|
||||||
g.lineStyle(7, engineColor, 0.16);
|
|
||||||
g.lineBetween(tail.x, tail.y, tail.x - facing * flareLen, tail.y);
|
|
||||||
g.lineStyle(2.4, engineColor, 0.9);
|
|
||||||
g.lineBetween(tail.x, tail.y, tail.x - facing * flareLen * 0.65, tail.y);
|
|
||||||
g.fillStyle(engineColor, 0.95);
|
|
||||||
g.fillCircle(tail.x, tail.y, r * 0.14);
|
|
||||||
}
|
|
||||||
|
|
||||||
strokeNgon(g, x, y, r, sides, rot = 0) {
|
|
||||||
g.beginPath();
|
|
||||||
for (let i = 0; i <= sides; i += 1) {
|
|
||||||
const a = rot + (i / sides) * Math.PI * 2;
|
|
||||||
const px = x + Math.cos(a) * r; const py = y + Math.sin(a) * r;
|
|
||||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
|
|
||||||
}
|
|
||||||
g.strokePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
strokeStick(g, x, y, r, color) {
|
|
||||||
for (const [lw, a] of [[4, 0.16], [1.6, 0.9]]) {
|
|
||||||
g.lineStyle(lw, color, a);
|
|
||||||
g.strokeCircle(x, y - r * 1.3, r * 0.45);
|
|
||||||
g.beginPath();
|
|
||||||
g.moveTo(x, y - r * 0.85); g.lineTo(x, y + r * 0.3);
|
|
||||||
g.moveTo(x - r * 0.6, y - r * 0.4); g.lineTo(x + r * 0.6, y - r * 0.4);
|
|
||||||
g.moveTo(x, y + r * 0.3); g.lineTo(x - r * 0.4, y + r);
|
|
||||||
g.moveTo(x, y + r * 0.3); g.lineTo(x + r * 0.4, y + r);
|
|
||||||
g.strokePath();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Draw ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
drawBackground(delta) {
|
|
||||||
this.spin += delta * 0.00006;
|
|
||||||
const pal = paletteFor(this.sim.level);
|
|
||||||
this.drawSkyline(this.bgFarG, this.camX * 0.35, 30, 0.0014, pal.ground, 0.5);
|
|
||||||
this.drawSkyline(this.bgNearG, this.camX * 0.7, 60, 0.0026, pal.ground, 0.85);
|
|
||||||
|
|
||||||
const gg = this.groundG;
|
|
||||||
gg.clear();
|
|
||||||
gg.lineStyle(3, paletteFor(this.sim.level).ground, 0.9);
|
|
||||||
gg.lineBetween(0, Y_GROUND, GAME_WIDTH, Y_GROUND);
|
|
||||||
gg.lineStyle(1, paletteFor(this.sim.level).ground, 0.35);
|
|
||||||
gg.lineBetween(0, Y_MAX, GAME_WIDTH, Y_MAX);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawSkyline(g, camOffset, amp, freq, color, alpha) {
|
|
||||||
g.clear();
|
|
||||||
g.lineStyle(2, color, alpha);
|
|
||||||
g.beginPath();
|
|
||||||
const baseY = Y_GROUND - 40;
|
|
||||||
for (let sx = 0; sx <= GAME_WIDTH; sx += 24) {
|
|
||||||
const wx = camOffset + sx;
|
|
||||||
const h = amp * (Math.sin(wx * freq) + 0.5 * Math.sin(wx * freq * 2.7 + 1.3));
|
|
||||||
const y = baseY - Math.abs(h) - amp * 0.4;
|
|
||||||
if (sx === 0) g.moveTo(sx, y); else g.lineTo(sx, y);
|
|
||||||
}
|
|
||||||
g.strokePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
drawExtractionZones() {
|
|
||||||
const g = this.extractG;
|
|
||||||
g.clear();
|
|
||||||
const pal = paletteFor(this.sim.level);
|
|
||||||
const pulse = 0.5 + 0.5 * Math.sin(this.time.now * 0.006);
|
|
||||||
for (const z of this.sim.extractionZones) {
|
|
||||||
const sx = this.screenX(z.x);
|
|
||||||
if (sx < -80 || sx > GAME_WIDTH + 80) continue;
|
|
||||||
g.lineStyle(2, pal.accent, 0.3 + 0.3 * pulse);
|
|
||||||
g.lineBetween(sx, Y_MIN - 30, sx, Y_GROUND);
|
|
||||||
g.lineStyle(3, pal.accent, 0.7 + 0.3 * pulse);
|
|
||||||
this.strokeNgon(g, sx, Y_MIN - 30, 14, 4, Math.PI / 4);
|
|
||||||
drawVectorText(g, 'BEACON', sx, Y_MIN - 60, 1.6, pal.accent, { lineWidth: 1.4, glowWidth: 4, alpha: 0.85 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawHumanoids() {
|
|
||||||
const g = this.humanoidG; const bg = this.beamG;
|
|
||||||
g.clear(); bg.clear();
|
|
||||||
const pal = paletteFor(this.sim.level);
|
|
||||||
for (const h of this.sim.humanoids) {
|
|
||||||
if (h.status === 'rescued' || h.status === 'lost') continue;
|
|
||||||
const sx = this.screenX(h.x);
|
|
||||||
if (sx < -60 || sx > GAME_WIDTH + 60) continue;
|
|
||||||
this.strokeStick(g, sx, h.y, TUNE.HUMANOID_RADIUS, h.status === 'idle' ? COLORS.muted : pal.humanoid);
|
|
||||||
if (h.status === 'grabbed') {
|
|
||||||
const e = this.sim.enemies.find((ee) => ee.id === h.grabberId);
|
|
||||||
if (e) {
|
|
||||||
const ex = this.screenX(e.x);
|
|
||||||
bg.lineStyle(5, pal.abductor, 0.18);
|
|
||||||
bg.lineBetween(ex, e.y, sx, h.y);
|
|
||||||
bg.lineStyle(2, pal.abductor, 0.8);
|
|
||||||
bg.lineBetween(ex, e.y, sx, h.y);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawEnemies() {
|
|
||||||
const g = this.enemyG;
|
|
||||||
g.clear();
|
|
||||||
const pal = paletteFor(this.sim.level);
|
|
||||||
const t = this.time.now;
|
|
||||||
for (const e of this.sim.enemies) {
|
|
||||||
const sx = this.screenX(e.x);
|
|
||||||
if (sx < -80 || sx > GAME_WIDTH + 80) continue;
|
|
||||||
if (e.type === 'swarmer') {
|
|
||||||
const rot = Math.atan2(e.vy, e.vx || 0.001);
|
|
||||||
g.lineStyle(4, pal.swarmer, 0.16);
|
|
||||||
this.strokeNgon(g, sx, e.y, TUNE.SWARMER_RADIUS, 3, rot);
|
|
||||||
g.lineStyle(1.6, pal.swarmer, 1);
|
|
||||||
this.strokeNgon(g, sx, e.y, TUNE.SWARMER_RADIUS, 3, rot);
|
|
||||||
} else if (e.type === 'walker') {
|
|
||||||
const r = TUNE.WALKER_RADIUS;
|
|
||||||
for (const [lw, a] of [[5, 0.16], [1.8, 0.95]]) {
|
|
||||||
g.lineStyle(lw, pal.walker, a);
|
|
||||||
g.beginPath();
|
|
||||||
g.moveTo(sx - r, e.y + r * 0.6); g.lineTo(sx - r * 0.6, e.y - r * 0.6);
|
|
||||||
g.lineTo(sx + r * 0.6, e.y - r * 0.6); g.lineTo(sx + r, e.y + r * 0.6);
|
|
||||||
g.closePath();
|
|
||||||
g.strokePath();
|
|
||||||
const aim = Math.atan2(0, tdelta(e.x, this.sim.player.x) || 1) + (tdelta(e.x, this.sim.player.x) < 0 ? Math.PI : 0);
|
|
||||||
g.beginPath();
|
|
||||||
g.moveTo(sx, e.y - r * 0.3);
|
|
||||||
g.lineTo(sx + Math.cos(aim) * r * 1.2, e.y - r * 0.3 + Math.sin(aim) * r * 0.3);
|
|
||||||
g.strokePath();
|
|
||||||
}
|
|
||||||
} else if (e.type === 'abductor') {
|
|
||||||
const spin = t * 0.003;
|
|
||||||
g.lineStyle(4, pal.abductor, 0.18);
|
|
||||||
this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS, 8, spin);
|
|
||||||
g.lineStyle(1.8, pal.abductor, 1);
|
|
||||||
this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS, 8, spin);
|
|
||||||
this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS * 0.5, 8, -spin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.sim.boss) {
|
|
||||||
const b = this.sim.boss;
|
|
||||||
const sx = this.screenX(b.x);
|
|
||||||
const spin = t * 0.0015;
|
|
||||||
const hpFrac = Math.max(0, b.hp / b.maxHp);
|
|
||||||
for (const [lw, a] of [[6, 0.18], [2.2, 1]]) {
|
|
||||||
g.lineStyle(lw, pal.boss, a);
|
|
||||||
this.strokeNgon(g, sx, b.y, TUNE.BOSS_RADIUS, 5, spin);
|
|
||||||
this.strokeNgon(g, sx, b.y, TUNE.BOSS_RADIUS * 0.6, 5, -spin * 1.6);
|
|
||||||
}
|
|
||||||
// boss health bar
|
|
||||||
const bw = 300; const bx = sx - bw / 2;
|
|
||||||
g.lineStyle(2, COLORS.muted, 0.7);
|
|
||||||
g.strokeRect(bx, b.y - TUNE.BOSS_RADIUS - 40, bw, 14);
|
|
||||||
g.fillStyle(pal.boss, 0.85);
|
|
||||||
g.fillRect(bx + 2, b.y - TUNE.BOSS_RADIUS - 38, Math.max(0, (bw - 4) * hpFrac), 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPlayerAndShots() {
|
|
||||||
const g = this.playerG; const sg = this.shotG;
|
|
||||||
g.clear(); sg.clear();
|
|
||||||
const pal = paletteFor(this.sim.level);
|
|
||||||
const p = this.sim.player;
|
|
||||||
if (p.alive && (Math.floor(p.invulnMs / 90) % 2 === 0 || p.invulnMs <= 0)) {
|
|
||||||
const sx = this.screenX(p.x);
|
|
||||||
const color = this.sim.overdriveActive ? 0xffffff : pal.accent;
|
|
||||||
const engineColor = this.sim.overdriveActive ? 0xffffff : ENGINE_COLOR;
|
|
||||||
const thrust = Math.min(1, Math.hypot(p.vx, p.vy) / TUNE.PLAYER_MAX_SPEED_X);
|
|
||||||
this.drawShip(g, sx, p.y, TUNE.PLAYER_RADIUS, p.facing, color, engineColor, thrust, [[6, 0.2], [2.2, 1]]);
|
|
||||||
if (p.carrying != null) {
|
|
||||||
// (drawn in drawHumanoids since the humanoid entity itself already
|
|
||||||
// tracks the carry offset each tick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const s of this.sim.shots) {
|
|
||||||
const sx = this.screenX(s.x);
|
|
||||||
sg.lineStyle(4, PLAYER_SHOT_COLOR, 0.22);
|
|
||||||
sg.lineBetween(sx - 10 * Math.sign(s.vx || 1), s.y, sx + 10 * Math.sign(s.vx || 1), s.y);
|
|
||||||
sg.lineStyle(1.8, PLAYER_SHOT_COLOR, 1);
|
|
||||||
sg.lineBetween(sx - 10 * Math.sign(s.vx || 1), s.y, sx + 10 * Math.sign(s.vx || 1), s.y);
|
|
||||||
}
|
|
||||||
for (const s of this.sim.enemyShots) {
|
|
||||||
const sx = this.screenX(s.x);
|
|
||||||
sg.lineStyle(4, 0xff5050, 0.18);
|
|
||||||
sg.fillStyle(0xff5050, 1);
|
|
||||||
sg.fillCircle(sx, s.y, 4);
|
|
||||||
sg.lineStyle(1.6, 0xffb0b0, 1);
|
|
||||||
sg.strokeCircle(sx, s.y, 4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A squashed side-view of the entire wrapped world: the player sits fixed
|
|
||||||
// at the horizontal center, and every other entity is placed left/right of
|
|
||||||
// it by tdelta() scaled against half the world's width — so an entity's
|
|
||||||
// offset from center IS its shortest-path direction and distance, with no
|
|
||||||
// separate case needed for "which way around the ring is closer". Vertical
|
|
||||||
// position mirrors world Y, so a climbing abductor visibly rises toward the
|
|
||||||
// top of the panel — a glance tells you both which way to fly and how much
|
|
||||||
// time is left before an escape.
|
|
||||||
minimapX(worldX) {
|
|
||||||
const half = MINI_W / 2 - 12;
|
|
||||||
return MINI_X + MINI_W / 2 + (tdelta(this.sim.player.x, worldX) / (WORLD_W / 2)) * half;
|
|
||||||
}
|
|
||||||
|
|
||||||
minimapY(worldY) {
|
|
||||||
const top = MINI_Y + 8; const h = MINI_H - 16;
|
|
||||||
const f = Math.max(0, Math.min(1, (worldY - Y_MIN) / (Y_GROUND - Y_MIN)));
|
|
||||||
return top + f * h;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawMinimap() {
|
|
||||||
const g = this.minimapG;
|
|
||||||
g.clear();
|
|
||||||
if (!this.sim) return;
|
|
||||||
const pal = paletteFor(this.sim.level);
|
|
||||||
|
|
||||||
const dangerHumanoids = this.sim.humanoids.filter((h) => h.status === 'grabbed' || h.status === 'falling');
|
|
||||||
const alerting = dangerHumanoids.length > 0;
|
|
||||||
const pulse = 0.5 + 0.5 * Math.sin(this.time.now * 0.014);
|
|
||||||
const frameColor = alerting ? ALERT_PINK : pal.accent;
|
|
||||||
|
|
||||||
g.fillStyle(0x000000, 0.5);
|
|
||||||
g.fillRoundedRect(MINI_X, MINI_Y, MINI_W, MINI_H, 8);
|
|
||||||
g.lineStyle(alerting ? 3 : 2, frameColor, alerting ? 0.6 + 0.4 * pulse : 0.7);
|
|
||||||
g.strokeRoundedRect(MINI_X, MINI_Y, MINI_W, MINI_H, 8);
|
|
||||||
|
|
||||||
// Extraction beacons — always visible, so "where do I take them" never gets lost.
|
|
||||||
for (const z of this.sim.extractionZones) {
|
|
||||||
const x = this.minimapX(z.x);
|
|
||||||
if (x < MINI_X || x > MINI_X + MINI_W) continue;
|
|
||||||
g.lineStyle(2, pal.accent, 0.9);
|
|
||||||
this.strokeNgon(g, x, MINI_Y + MINI_H - 8, 4, 4, Math.PI / 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enemies — small dots tinted by type, same palette colors as the world view.
|
|
||||||
for (const en of this.sim.enemies) {
|
|
||||||
const x = this.minimapX(en.x);
|
|
||||||
if (x < MINI_X - 4 || x > MINI_X + MINI_W + 4) continue;
|
|
||||||
const y = this.minimapY(en.y);
|
|
||||||
const color = pal[en.type] ?? pal.accent;
|
|
||||||
g.fillStyle(color, 0.8);
|
|
||||||
g.fillCircle(x, y, en.type === 'walker' ? 3 : 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boss, if active.
|
|
||||||
if (this.sim.boss) {
|
|
||||||
const x = this.minimapX(this.sim.boss.x);
|
|
||||||
if (x >= MINI_X && x <= MINI_X + MINI_W) {
|
|
||||||
g.fillStyle(pal.boss, 0.6 + 0.4 * pulse);
|
|
||||||
g.fillCircle(x, this.minimapY(this.sim.boss.y), 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Humanoids — idle dim, carried safe-white, grabbed/falling alert-pink and pulsing.
|
|
||||||
for (const h of this.sim.humanoids) {
|
|
||||||
if (h.status === 'rescued' || h.status === 'lost') continue;
|
|
||||||
const x = this.minimapX(h.x);
|
|
||||||
if (x < MINI_X - 6 || x > MINI_X + MINI_W + 6) continue;
|
|
||||||
const y = this.minimapY(h.y);
|
|
||||||
if (h.status === 'grabbed' || h.status === 'falling') {
|
|
||||||
g.fillStyle(ALERT_PINK, 0.3 + 0.35 * pulse);
|
|
||||||
g.fillCircle(x, y, 7 + 3 * pulse);
|
|
||||||
g.fillStyle(0xffffff, 1);
|
|
||||||
g.fillCircle(x, y, 2.6);
|
|
||||||
} else {
|
|
||||||
g.fillStyle(h.status === 'carried' ? 0xffffff : COLORS.muted, h.status === 'carried' ? 1 : 0.75);
|
|
||||||
g.fillCircle(x, y, h.status === 'carried' ? 3.4 : 2.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Player — fixed at horizontal center; vertical position still tracks real altitude.
|
|
||||||
const px = MINI_X + MINI_W / 2; const py = this.minimapY(this.sim.player.y);
|
|
||||||
g.fillStyle(this.sim.overdriveActive ? 0xffffff : pal.accent, 1);
|
|
||||||
g.beginPath();
|
|
||||||
g.moveTo(px, py - 5); g.lineTo(px - 4, py + 4); g.lineTo(px + 4, py + 4);
|
|
||||||
g.closePath();
|
|
||||||
g.fillPath();
|
|
||||||
|
|
||||||
// Directional alert arrow, just outside the panel, toward the nearest capture.
|
|
||||||
if (alerting) {
|
|
||||||
dangerHumanoids.sort((a, b) => Math.abs(tdelta(this.sim.player.x, a.x)) - Math.abs(tdelta(this.sim.player.x, b.x)));
|
|
||||||
const nearest = dangerHumanoids[0];
|
|
||||||
const dir = tdelta(this.sim.player.x, nearest.x) >= 0 ? 1 : -1;
|
|
||||||
const ax = dir > 0 ? MINI_X + MINI_W + 16 : MINI_X - 16;
|
|
||||||
const ay = MINI_Y + MINI_H / 2;
|
|
||||||
g.fillStyle(ALERT_PINK, 0.6 + 0.4 * pulse);
|
|
||||||
g.beginPath();
|
|
||||||
g.moveTo(ax + dir * 8, ay); g.lineTo(ax - dir * 6, ay - 8); g.lineTo(ax - dir * 6, ay + 8);
|
|
||||||
g.closePath();
|
|
||||||
g.fillPath();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
syncGraphics(delta) {
|
|
||||||
this.updateCamera();
|
|
||||||
this.drawBackground(delta);
|
|
||||||
this.drawExtractionZones();
|
|
||||||
this.drawHumanoids();
|
|
||||||
this.drawEnemies();
|
|
||||||
this.drawPlayerAndShots();
|
|
||||||
this.fx.draw(delta);
|
|
||||||
this.drawMinimap();
|
|
||||||
|
|
||||||
const bg = this.bannerG;
|
|
||||||
bg.clear();
|
|
||||||
if (this.banner) {
|
|
||||||
this.banner.ageMs += delta;
|
|
||||||
if (this.banner.ageMs >= this.banner.lifeMs) {
|
|
||||||
this.banner = null;
|
|
||||||
} else {
|
|
||||||
const f = this.banner.ageMs / this.banner.lifeMs;
|
|
||||||
const alpha = f < 0.15 ? f / 0.15 : (f > 0.75 ? (1 - f) / 0.25 : 1);
|
|
||||||
drawVectorText(bg, this.banner.text, GAME_WIDTH / 2, 200, 6, this.banner.color,
|
|
||||||
{ lineWidth: 3, glowWidth: 12, glowAlpha: 0.22, alpha });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showBanner(text, color, lifeMs) {
|
|
||||||
this.banner = { text, color, lifeMs, ageMs: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Game over / victory ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
recordScore(score, result) {
|
|
||||||
const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0);
|
|
||||||
const newBest = score > prevBest;
|
|
||||||
if (newBest) localStorage.setItem(BEST_KEY, String(score));
|
|
||||||
api.post('/history/single-player', {
|
|
||||||
slug: 'defender', score, opponentScores: [], result,
|
|
||||||
}).catch(() => { /* best effort */ });
|
|
||||||
return { prevBest, newBest };
|
|
||||||
}
|
|
||||||
|
|
||||||
onGameOver(e) {
|
|
||||||
this.mode = 'gameover';
|
|
||||||
this.crt.pulse(1, 400);
|
|
||||||
const { prevBest, newBest } = this.recordScore(e.score, 'loss');
|
|
||||||
this.time.delayedCall(600, () => this.showEndPanel('GAME OVER', COLORS.dangerHex, e, prevBest, newBest, `You reached level ${e.level}.`));
|
|
||||||
}
|
|
||||||
|
|
||||||
onVictory(e) {
|
|
||||||
this.mode = 'victory';
|
|
||||||
this.crt.pulse(1, 500);
|
|
||||||
const { prevBest, newBest } = this.recordScore(e.score, 'win');
|
|
||||||
playSound(this, SFX.EIGHTBIT_WIN);
|
|
||||||
this.time.delayedCall(600, () => this.showEndPanel('VICTORY!', COLORS.goldHex, e, prevBest, newBest, 'All 5 levels defended.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
showEndPanel(title, titleColor, e, prevBest, newBest, subtitle) {
|
|
||||||
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
|
|
||||||
const root = this.add.container(0, 0).setDepth(D.overlay);
|
|
||||||
root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65).setInteractive());
|
|
||||||
|
|
||||||
const panel = this.add.graphics();
|
|
||||||
panel.fillStyle(COLORS.panel, 0.98);
|
|
||||||
panel.fillRoundedRect(cx - 380, cy - 260, 760, 520, 22);
|
|
||||||
panel.lineStyle(3, paletteFor(this.sim.level).accent, 1);
|
|
||||||
panel.strokeRoundedRect(cx - 380, cy - 260, 760, 520, 22);
|
|
||||||
root.add(panel);
|
|
||||||
|
|
||||||
root.add(this.add.text(cx, cy - 192, title, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '52px', color: titleColor }).setOrigin(0.5));
|
|
||||||
root.add(this.add.text(cx, cy - 130, subtitle, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5));
|
|
||||||
|
|
||||||
const scoreText = this.add.text(cx, cy - 30, '0', { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '88px', color: COLORS.goldHex }).setOrigin(0.5);
|
|
||||||
root.add(scoreText);
|
|
||||||
const counter = { v: 0 };
|
|
||||||
this.tweens.add({
|
|
||||||
targets: counter, v: e.score, duration: 900, ease: 'Cubic.easeOut',
|
|
||||||
onUpdate: () => scoreText.setText(String(Math.round(counter.v))),
|
|
||||||
});
|
|
||||||
root.add(this.add.text(cx, cy + 30, 'SCORE', { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex }).setOrigin(0.5));
|
|
||||||
root.add(this.add.text(cx, cy + 74, newBest ? '★ NEW BEST ★' : (prevBest > 0 ? `Best: ${prevBest}` : ''),
|
|
||||||
{ fontFamily: 'm6x11, "Julius Sans One"', fontSize: '24px', color: newBest ? COLORS.goldHex : COLORS.mutedHex }).setOrigin(0.5));
|
|
||||||
|
|
||||||
const again = new Button(this, cx - 170, cy + 190, 'Play Again', () => this.scene.restart({ game: this.gameDef }),
|
|
||||||
{ width: 280, height: 62, fontSize: 26 });
|
|
||||||
const menu = new Button(this, cx + 170, cy + 190, 'Menu', () => this.scene.start('GameMenu'),
|
|
||||||
{ width: 280, height: 62, fontSize: 26, variant: 'ghost' });
|
|
||||||
root.add([again, menu]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,882 +0,0 @@
|
||||||
// Pure simulation for Defender (Resogun-style wireframe swarm shooter). No
|
|
||||||
// Phaser dependency — fully unit-testable headlessly via tools/verifyDefender.js.
|
|
||||||
//
|
|
||||||
// World model: a single 2D plane that WRAPS horizontally (a "ring", like a
|
|
||||||
// side view of a cylinder) and is bounded vertically. Every position/velocity
|
|
||||||
// update and every AI distance/heading calc that touches X must go through
|
|
||||||
// wrap()/tdelta() below — a naive `dx = a.x - b.x` will make swarms visibly
|
|
||||||
// split at the wrap seam. Same toroidal-math idiom as Star Control
|
|
||||||
// (src/games/starcontrol/StarControlLogic.js), reduced to one wrapped axis.
|
|
||||||
//
|
|
||||||
// Fixed-tick loop: same accumulator/spiral-of-death-guard pattern as Total
|
|
||||||
// Annihilation (src/games/totalannihilation/TALogic.js) rather than a raw
|
|
||||||
// per-frame delta — swarm flocking and rescue timers behave identically
|
|
||||||
// regardless of render framerate. state.alpha is the leftover fraction the
|
|
||||||
// view uses to interpolate between the last two ticks.
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Seeded RNG (same generator every other game in this repo uses).
|
|
||||||
export function mulberry32(seed) {
|
|
||||||
let a = seed >>> 0;
|
|
||||||
return () => {
|
|
||||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
|
||||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
||||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
||||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// World / wrap math
|
|
||||||
export const WORLD_W = 6000;
|
|
||||||
export const Y_SKY = 60; // an abductor carrying a humanoid past this height = escaped
|
|
||||||
export const Y_MIN = 90; // top of the flight band
|
|
||||||
export const Y_GROUND = 900; // ground band: walkers, idle humanoids, falling humanoids land here
|
|
||||||
export const Y_MAX = 940; // bottom clamp for the player
|
|
||||||
|
|
||||||
export const STEP_MS = 1000 / 60;
|
|
||||||
export const MAX_STEPS = 4;
|
|
||||||
|
|
||||||
export function wrap(v, size = WORLD_W) {
|
|
||||||
return ((v % size) + size) % size;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shortest signed delta from a to b on the wrapped X axis.
|
|
||||||
export function tdelta(a, b, size = WORLD_W) {
|
|
||||||
let d = (b - a) % size;
|
|
||||||
if (d > size / 2) d -= size;
|
|
||||||
else if (d < -size / 2) d += size;
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function tdist(ax, ay, bx, by) {
|
|
||||||
const dx = tdelta(ax, bx);
|
|
||||||
const dy = ay - by;
|
|
||||||
return Math.hypot(dx, dy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tuning — every gameplay constant lives here, named, so feel-tuning never
|
|
||||||
// touches game logic (same convention as Tempest's TUNE table).
|
|
||||||
export const TUNE = {
|
|
||||||
// Horizontal flight is deliberately much quicker than vertical — this is a
|
|
||||||
// side-view wraparound shooter, so covering ground left/right is the ship's
|
|
||||||
// primary job and should feel snappy; vertical is fine-grained dodging.
|
|
||||||
PLAYER_ACCEL_X: 3600,
|
|
||||||
PLAYER_MAX_SPEED_X: 1150,
|
|
||||||
PLAYER_ACCEL_Y: 1400,
|
|
||||||
PLAYER_MAX_SPEED_Y: 460,
|
|
||||||
PLAYER_DRAG: 5.2,
|
|
||||||
PLAYER_RADIUS: 18,
|
|
||||||
PLAYER_FIRE_COOLDOWN_MS: 130,
|
|
||||||
// Fast + long-lived enough to cross from the player (screen-centered by the
|
|
||||||
// camera) all the way past either screen edge before expiring. TTL carries
|
|
||||||
// extra margin over the bare half-screen distance because at top horizontal
|
|
||||||
// speed the camera's smoothed follow lags the ship by ~100+px.
|
|
||||||
PLAYER_SHOT_SPEED: 1100,
|
|
||||||
PLAYER_SHOT_TTL_MS: 1100,
|
|
||||||
PLAYER_SHOT_RADIUS: 6,
|
|
||||||
RESPAWN_DELAY_MS: 1500,
|
|
||||||
RESPAWN_INVULN_MS: 1200,
|
|
||||||
PLAYER_LIVES_START: 3,
|
|
||||||
|
|
||||||
SWARMER_RADIUS: 14,
|
|
||||||
SWARMER_SPEED: 220,
|
|
||||||
SWARMER_HP: 1,
|
|
||||||
SEPARATION_RADIUS: 30,
|
|
||||||
COHESION_RADIUS: 130,
|
|
||||||
ALIGNMENT_RADIUS: 100,
|
|
||||||
SEPARATION_W: 1.5,
|
|
||||||
COHESION_W: 0.45,
|
|
||||||
ALIGNMENT_W: 0.6,
|
|
||||||
SEEK_PLAYER_W: 0.55,
|
|
||||||
SEEK_RADIUS: 520,
|
|
||||||
SWARM_PACK_SIZE_MIN: 10,
|
|
||||||
SWARM_PACK_SIZE_MAX: 16,
|
|
||||||
SWARM_MAX_CONCURRENT: 48,
|
|
||||||
|
|
||||||
WALKER_RADIUS: 24,
|
|
||||||
WALKER_HP: 3,
|
|
||||||
WALKER_SPEED: 70,
|
|
||||||
WALKER_PATROL_RANGE: 260,
|
|
||||||
WALKER_FIRE_COOLDOWN_MS: 1500,
|
|
||||||
WALKER_SHOT_SPEED: 360,
|
|
||||||
WALKER_SHOT_RADIUS: 7,
|
|
||||||
WALKER_AIM_RANGE: 640,
|
|
||||||
|
|
||||||
ABDUCTOR_RADIUS: 26,
|
|
||||||
ABDUCTOR_HP: 2,
|
|
||||||
ABDUCTOR_SPEED: 160,
|
|
||||||
ABDUCTOR_RISE_SPEED: 65,
|
|
||||||
ABDUCTOR_GRAB_RADIUS: 46,
|
|
||||||
|
|
||||||
HUMANOID_RADIUS: 14,
|
|
||||||
PICKUP_RADIUS: 46,
|
|
||||||
// A freed humanoid falls from roughly Y_SKY..Y_MIN down to Y_GROUND (~790-840px)
|
|
||||||
// at this constant speed, so a typical fall takes ~5.5-6s; GRAB_WINDOW_MS sits
|
|
||||||
// comfortably above that so hitting the ground — not the window — is normally
|
|
||||||
// what ends an uncaught fall, with the window only as a backstop.
|
|
||||||
FALL_SPEED: 140,
|
|
||||||
GRAB_WINDOW_MS: 8000,
|
|
||||||
CARRY_TIMEOUT_MS: 12000,
|
|
||||||
EXTRACT_RADIUS: 80,
|
|
||||||
EXTRACTION_ZONES_PER_LEVEL: 2,
|
|
||||||
HUMANOIDS_PER_LEVEL: 6,
|
|
||||||
|
|
||||||
COMBO_WINDOW_MS: 1800,
|
|
||||||
MULT_MAX: 8,
|
|
||||||
OVERDRIVE_FILL_PER_KILL: 0.04,
|
|
||||||
OVERDRIVE_DURATION_MS: 8000,
|
|
||||||
OVERDRIVE_TIMESCALE: 0.35,
|
|
||||||
OVERDRIVE_SCORE_MULT: 5,
|
|
||||||
|
|
||||||
LEVEL_COUNT: 5,
|
|
||||||
WAVES_PER_LEVEL: 4,
|
|
||||||
WAVE_BREATHER_MS: 2200,
|
|
||||||
BOSS_INTRO_MS: 2600,
|
|
||||||
BOSS_OUTRO_MS: 1800,
|
|
||||||
BOSS_HP_BASE: 60,
|
|
||||||
BOSS_HP_STEP: 30,
|
|
||||||
BOSS_RADIUS: 70,
|
|
||||||
BOSS_SPEED: 90,
|
|
||||||
BOSS_SHOT_SPEED: 300,
|
|
||||||
BOSS_ATTACK_COOLDOWN_MS: 1600,
|
|
||||||
BOSS_SPOKE_COUNT: 10,
|
|
||||||
|
|
||||||
FULL_RESCUE_BONUS: 5000,
|
|
||||||
ENEMY_KILL_SCORE: { swarmer: 50, walker: 150, abductor: 120 },
|
|
||||||
BOSS_KILL_SCORE: 3000,
|
|
||||||
HUMANOID_RESCUE_SCORE: 400,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Entity ids are assigned from a per-state counter (not a module-level one) so
|
|
||||||
// that replaying the same seed from a fresh createGame() is fully
|
|
||||||
// reproducible regardless of how many other games ran earlier in the process.
|
|
||||||
function nid(state) { return state.nextId += 1; }
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Wave / boss authoring — formula-based per (level, wave), same idiom as
|
|
||||||
// Tempest's per-level tuning functions (flipperSpeed(level), spawnInterval(level), …).
|
|
||||||
export function waveSpec(level, wave) {
|
|
||||||
const p = (level - 1) * TUNE.WAVES_PER_LEVEL + (wave - 1); // 0..19 overall progress index
|
|
||||||
return {
|
|
||||||
swarmerCount: 8 + p * 3,
|
|
||||||
packSize: Math.min(TUNE.SWARM_PACK_SIZE_MAX, TUNE.SWARM_PACK_SIZE_MIN + Math.floor(p / 2)),
|
|
||||||
walkerCount: Math.max(0, Math.floor((p - 1) / 3)),
|
|
||||||
abductorCount: Math.max(1, Math.floor(p / 3) + 1),
|
|
||||||
spawnIntervalMs: Math.max(260, 900 - p * 26),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// One distinct, escalating boss encounter per level — not just more HP, but a
|
|
||||||
// genuinely different attack repertoire each time. `moves` is round-robined
|
|
||||||
// every attack (so a fight never just repeats one pattern), `phase2Moves`
|
|
||||||
// (if present) takes over once the boss drops below half health, and
|
|
||||||
// `speedMult`/`cooldownMs` layer movement and attack-rate pressure on top so
|
|
||||||
// later fights are harder along every axis at once, not just bullet variety.
|
|
||||||
export const BOSS_PROFILES = [
|
|
||||||
{ // Level 1 — Sentinel: a single steady ring burst. The introduction.
|
|
||||||
name: 'SENTINEL', moves: ['ring'], speedMult: 1, cooldownMs: 1700,
|
|
||||||
},
|
|
||||||
{ // Level 2 — Ravager: rams hard and sprays a forward shotgun spread.
|
|
||||||
name: 'RAVAGER', moves: ['spread'], speedMult: 1.8, cooldownMs: 1450,
|
|
||||||
},
|
|
||||||
{ // Level 3 — Swarmlord: a rotating bullet spiral plus reinforcement waves —
|
|
||||||
// now you're managing adds and dodging a moving pattern at the same time.
|
|
||||||
name: 'SWARMLORD', moves: ['spiral', 'reinforce'], speedMult: 1.2, cooldownMs: 1300,
|
|
||||||
},
|
|
||||||
{ // Level 4 — Warden: alternates area-denial and player-tracking fire; past
|
|
||||||
// half health it adds the spiral and reinforcements too. First two-phase fight.
|
|
||||||
name: 'WARDEN', moves: ['ring', 'aimed'], phase2Moves: ['spiral', 'aimed', 'reinforce'],
|
|
||||||
speedMult: 1.4, cooldownMs: 1150,
|
|
||||||
},
|
|
||||||
{ // Level 5 — Overlord: the full arsenal from every earlier fight, fastest
|
|
||||||
// base cooldown, and a dense bullet-wall move once it's wounded.
|
|
||||||
name: 'OVERLORD', moves: ['ring', 'spread', 'aimed'], phase2Moves: ['spiral', 'wall', 'aimed', 'reinforce'],
|
|
||||||
speedMult: 1.6, cooldownMs: 950,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function bossProfileFor(level) { return BOSS_PROFILES[(level - 1) % BOSS_PROFILES.length]; }
|
|
||||||
|
|
||||||
export function bossSpec(level) {
|
|
||||||
return {
|
|
||||||
name: bossProfileFor(level).name,
|
|
||||||
hp: TUNE.BOSS_HP_BASE + (level - 1) * TUNE.BOSS_HP_STEP,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Entity factories
|
|
||||||
function makePlayer() {
|
|
||||||
return {
|
|
||||||
x: WORLD_W / 2, y: (Y_MIN + Y_GROUND) / 2, vx: 0, vy: 0, facing: 1,
|
|
||||||
alive: true, invulnMs: 0, respawnMs: 0,
|
|
||||||
fireCooldownMs: 0, carrying: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeExtractionZones(rng) {
|
|
||||||
const zones = [];
|
|
||||||
const spacing = WORLD_W / TUNE.EXTRACTION_ZONES_PER_LEVEL;
|
|
||||||
for (let i = 0; i < TUNE.EXTRACTION_ZONES_PER_LEVEL; i += 1) {
|
|
||||||
zones.push({ x: wrap(spacing * i + spacing * 0.5 + (rng() - 0.5) * spacing * 0.3) });
|
|
||||||
}
|
|
||||||
return zones;
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnSwarmerPack(state, count) {
|
|
||||||
const cx = wrap(state.rng() * WORLD_W);
|
|
||||||
const cy = Y_MIN + state.rng() * (Y_GROUND - Y_MIN - 200);
|
|
||||||
for (let i = 0; i < count && state.enemies.filter((e) => e.type === 'swarmer').length < TUNE.SWARM_MAX_CONCURRENT; i += 1) {
|
|
||||||
state.enemies.push({
|
|
||||||
id: nid(state), type: 'swarmer', hp: TUNE.SWARMER_HP, radius: TUNE.SWARMER_RADIUS,
|
|
||||||
x: wrap(cx + (state.rng() - 0.5) * 80),
|
|
||||||
y: cy + (state.rng() - 0.5) * 80,
|
|
||||||
vx: (state.rng() - 0.5) * 40, vy: (state.rng() - 0.5) * 40,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnWalker(state) {
|
|
||||||
const x = wrap(state.rng() * WORLD_W);
|
|
||||||
state.enemies.push({
|
|
||||||
id: nid(state), type: 'walker', hp: TUNE.WALKER_HP, radius: TUNE.WALKER_RADIUS,
|
|
||||||
x, y: Y_GROUND, vx: 0, vy: 0,
|
|
||||||
homeX: x, dir: state.rng() < 0.5 ? -1 : 1, fireCooldownMs: TUNE.WALKER_FIRE_COOLDOWN_MS * state.rng(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnAbductor(state) {
|
|
||||||
state.enemies.push({
|
|
||||||
id: nid(state), type: 'abductor', hp: TUNE.ABDUCTOR_HP, radius: TUNE.ABDUCTOR_RADIUS,
|
|
||||||
x: wrap(state.rng() * WORLD_W), y: Y_MIN + 20, vx: 0, vy: 0,
|
|
||||||
targetHumanoidId: null, carryingId: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnHumanoids(state, count) {
|
|
||||||
const used = new Set();
|
|
||||||
for (let i = 0; i < count; i += 1) {
|
|
||||||
let x;
|
|
||||||
do { x = wrap(state.rng() * WORLD_W); } while (used.has(Math.floor(x / 120)));
|
|
||||||
used.add(Math.floor(x / 120));
|
|
||||||
state.humanoids.push({
|
|
||||||
id: nid(state), status: 'idle', x, y: Y_GROUND, vx: 0, vy: 0,
|
|
||||||
grabberId: null, carrierIsPlayer: false, timerMs: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startWave(state, wave) {
|
|
||||||
state.wave = wave;
|
|
||||||
state.phase = 'waveIntro';
|
|
||||||
state.phaseMs = 0;
|
|
||||||
const spec = waveSpec(state.level, wave);
|
|
||||||
const queue = [];
|
|
||||||
for (let i = 0; i < Math.ceil(spec.swarmerCount / spec.packSize); i += 1) {
|
|
||||||
queue.push({ kind: 'swarmerPack', count: Math.min(spec.packSize, spec.swarmerCount - i * spec.packSize) });
|
|
||||||
}
|
|
||||||
for (let i = 0; i < spec.walkerCount; i += 1) queue.push({ kind: 'walker' });
|
|
||||||
for (let i = 0; i < spec.abductorCount; i += 1) queue.push({ kind: 'abductor' });
|
|
||||||
state.spawnQueue = queue;
|
|
||||||
state.spawnTimerMs = 0;
|
|
||||||
state.spawnIntervalMs = spec.spawnIntervalMs;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGame(opts = {}) {
|
|
||||||
const seed = opts.seed ?? 1;
|
|
||||||
const rng = mulberry32(seed);
|
|
||||||
const state = {
|
|
||||||
seed, rng, nextId: 1,
|
|
||||||
level: opts.startLevel ?? 1,
|
|
||||||
wave: 1, phase: 'waveIntro', phaseMs: 0,
|
|
||||||
accumulatorMs: 0, alpha: 0, timeMs: 0,
|
|
||||||
player: makePlayer(),
|
|
||||||
enemies: [], humanoids: [], shots: [], enemyShots: [], boss: null,
|
|
||||||
score: 0, lives: TUNE.PLAYER_LIVES_START,
|
|
||||||
multiplier: 1, lastKillMs: -Infinity,
|
|
||||||
overdriveMeter: 0, overdriveActive: false, overdriveMsLeft: 0,
|
|
||||||
rescuedThisLevel: 0, lostThisLevel: 0,
|
|
||||||
extractionZones: makeExtractionZones(rng),
|
|
||||||
spawnQueue: [], spawnTimerMs: 0, spawnIntervalMs: 800,
|
|
||||||
input: { up: false, down: false, left: false, right: false, fire: false, overdrive: false },
|
|
||||||
over: false, victory: false,
|
|
||||||
};
|
|
||||||
spawnHumanoids(state, TUNE.HUMANOIDS_PER_LEVEL);
|
|
||||||
startWave(state, 1);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setInput(state, patch) {
|
|
||||||
Object.assign(state.input, patch);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-tick subsystems
|
|
||||||
|
|
||||||
function timescale(state) {
|
|
||||||
return state.overdriveActive ? TUNE.OVERDRIVE_TIMESCALE : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePlayer(state, dt, events) {
|
|
||||||
const p = state.player;
|
|
||||||
if (!p.alive) {
|
|
||||||
p.respawnMs -= dt;
|
|
||||||
if (p.respawnMs <= 0) {
|
|
||||||
p.alive = true;
|
|
||||||
p.x = wrap(p.x);
|
|
||||||
p.y = (Y_MIN + Y_GROUND) / 2;
|
|
||||||
p.vx = 0; p.vy = 0;
|
|
||||||
p.invulnMs = TUNE.RESPAWN_INVULN_MS;
|
|
||||||
p.carrying = null;
|
|
||||||
events.push({ type: 'playerRespawned' });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (p.invulnMs > 0) p.invulnMs -= dt;
|
|
||||||
|
|
||||||
const { input } = state;
|
|
||||||
const ax = (input.right ? 1 : 0) - (input.left ? 1 : 0);
|
|
||||||
const ay = (input.down ? 1 : 0) - (input.up ? 1 : 0);
|
|
||||||
const dtS = dt / 1000;
|
|
||||||
p.vx += ax * TUNE.PLAYER_ACCEL_X * dtS;
|
|
||||||
p.vy += ay * TUNE.PLAYER_ACCEL_Y * dtS;
|
|
||||||
const drag = 1 / (1 + TUNE.PLAYER_DRAG * dtS);
|
|
||||||
p.vx *= drag; p.vy *= drag;
|
|
||||||
// Independent per-axis clamps (not a combined-magnitude clamp) so the much
|
|
||||||
// higher horizontal cap isn't diluted whenever the player is also holding
|
|
||||||
// a vertical direction.
|
|
||||||
p.vx = Math.max(-TUNE.PLAYER_MAX_SPEED_X, Math.min(TUNE.PLAYER_MAX_SPEED_X, p.vx));
|
|
||||||
p.vy = Math.max(-TUNE.PLAYER_MAX_SPEED_Y, Math.min(TUNE.PLAYER_MAX_SPEED_Y, p.vy));
|
|
||||||
p.x = wrap(p.x + p.vx * dtS);
|
|
||||||
p.y = Math.min(Y_MAX, Math.max(Y_MIN, p.y + p.vy * dtS));
|
|
||||||
if (ax > 0) p.facing = 1; else if (ax < 0) p.facing = -1;
|
|
||||||
|
|
||||||
p.fireCooldownMs -= dt;
|
|
||||||
if (input.fire && p.fireCooldownMs <= 0) {
|
|
||||||
p.fireCooldownMs = TUNE.PLAYER_FIRE_COOLDOWN_MS;
|
|
||||||
state.shots.push({
|
|
||||||
x: p.x, y: p.y, vx: TUNE.PLAYER_SHOT_SPEED * p.facing, vy: 0, ttlMs: TUNE.PLAYER_SHOT_TTL_MS,
|
|
||||||
});
|
|
||||||
events.push({ type: 'shotFired' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overdrive trigger
|
|
||||||
if (input.overdrive && !state.overdriveActive && state.overdriveMeter >= 1) {
|
|
||||||
state.overdriveActive = true;
|
|
||||||
state.overdriveMsLeft = TUNE.OVERDRIVE_DURATION_MS;
|
|
||||||
events.push({ type: 'overdriveStart' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carried humanoid follows the player, and can be dropped at an extraction zone.
|
|
||||||
if (p.carrying != null) {
|
|
||||||
const h = state.humanoids.find((hh) => hh.id === p.carrying);
|
|
||||||
if (h) {
|
|
||||||
h.x = wrap(p.x - p.facing * 24);
|
|
||||||
h.y = p.y + 24;
|
|
||||||
h.timerMs += dt;
|
|
||||||
const nearZone = state.extractionZones.some((z) => Math.abs(tdelta(p.x, z.x)) < TUNE.EXTRACT_RADIUS);
|
|
||||||
if (nearZone) {
|
|
||||||
h.status = 'rescued';
|
|
||||||
p.carrying = null;
|
|
||||||
state.rescuedThisLevel += 1;
|
|
||||||
state.score += TUNE.HUMANOID_RESCUE_SCORE;
|
|
||||||
events.push({ type: 'humanoidRescued', id: h.id });
|
|
||||||
} else if (h.timerMs >= TUNE.CARRY_TIMEOUT_MS) {
|
|
||||||
h.status = 'lost';
|
|
||||||
p.carrying = null;
|
|
||||||
state.lostThisLevel += 1;
|
|
||||||
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'carryTimeout' });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.carrying = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Auto-pickup: a falling humanoid within pickup radius, if not already carried.
|
|
||||||
for (const h of state.humanoids) {
|
|
||||||
if (h.status !== 'falling') continue;
|
|
||||||
if (tdist(p.x, p.y, h.x, h.y) <= TUNE.PICKUP_RADIUS) {
|
|
||||||
h.status = 'carried';
|
|
||||||
h.timerMs = 0;
|
|
||||||
p.carrying = h.id;
|
|
||||||
events.push({ type: 'humanoidPickedUp', id: h.id });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function killPlayer(state, events) {
|
|
||||||
const p = state.player;
|
|
||||||
if (!p.alive || p.invulnMs > 0) return;
|
|
||||||
p.alive = false;
|
|
||||||
p.respawnMs = TUNE.RESPAWN_DELAY_MS;
|
|
||||||
if (p.carrying != null) {
|
|
||||||
const h = state.humanoids.find((hh) => hh.id === p.carrying);
|
|
||||||
if (h) {
|
|
||||||
h.status = 'lost';
|
|
||||||
state.lostThisLevel += 1;
|
|
||||||
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'playerDied' });
|
|
||||||
}
|
|
||||||
p.carrying = null;
|
|
||||||
}
|
|
||||||
state.lives -= 1;
|
|
||||||
events.push({ type: 'playerDied' });
|
|
||||||
if (state.lives < 0) {
|
|
||||||
state.over = true;
|
|
||||||
state.phase = 'gameOver';
|
|
||||||
events.push({ type: 'gameOver', score: state.score, level: state.level });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function neighborForces(e, list, dt) {
|
|
||||||
let sepX = 0; let sepY = 0; let cohX = 0; let cohY = 0; let aliX = 0; let aliY = 0; let n = 0;
|
|
||||||
for (const o of list) {
|
|
||||||
if (o === e) continue;
|
|
||||||
const dx = tdelta(e.x, o.x);
|
|
||||||
const dy = o.y - e.y;
|
|
||||||
const d = Math.hypot(dx, dy) || 0.001;
|
|
||||||
if (d < TUNE.SEPARATION_RADIUS) { sepX -= dx / d; sepY -= dy / d; }
|
|
||||||
if (d < TUNE.COHESION_RADIUS) { cohX += dx; cohY += dy; n += 1; }
|
|
||||||
if (d < TUNE.ALIGNMENT_RADIUS) { aliX += o.vx; aliY += o.vy; }
|
|
||||||
}
|
|
||||||
if (n > 0) { cohX /= n; cohY /= n; aliX /= n; aliY /= n; }
|
|
||||||
return {
|
|
||||||
fx: sepX * TUNE.SEPARATION_W + cohX * TUNE.COHESION_W * 0.02 + aliX * TUNE.ALIGNMENT_W * 0.02,
|
|
||||||
fy: sepY * TUNE.SEPARATION_W + cohY * TUNE.COHESION_W * 0.02 + aliY * TUNE.ALIGNMENT_W * 0.02,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSwarmers(state, dt) {
|
|
||||||
const dtS = dt / 1000;
|
|
||||||
const swarmers = state.enemies.filter((e) => e.type === 'swarmer');
|
|
||||||
const p = state.player;
|
|
||||||
for (const e of swarmers) {
|
|
||||||
const { fx, fy } = neighborForces(e, swarmers, dt);
|
|
||||||
e.vx += fx * dt; e.vy += fy * dt;
|
|
||||||
const dToPlayer = tdist(e.x, e.y, p.x, p.y);
|
|
||||||
if (dToPlayer < TUNE.SEEK_RADIUS && p.alive) {
|
|
||||||
const dx = tdelta(e.x, p.x); const dy = p.y - e.y;
|
|
||||||
const d = Math.hypot(dx, dy) || 1;
|
|
||||||
e.vx += (dx / d) * TUNE.SEEK_PLAYER_W * dt;
|
|
||||||
e.vy += (dy / d) * TUNE.SEEK_PLAYER_W * dt;
|
|
||||||
}
|
|
||||||
const sp = Math.hypot(e.vx, e.vy);
|
|
||||||
if (sp > TUNE.SWARMER_SPEED) { const k = TUNE.SWARMER_SPEED / sp; e.vx *= k; e.vy *= k; }
|
|
||||||
e.x = wrap(e.x + e.vx * dtS);
|
|
||||||
e.y = Math.min(Y_GROUND - 40, Math.max(Y_MIN, e.y + e.vy * dtS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateWalkers(state, dt, events) {
|
|
||||||
const dtS = dt / 1000;
|
|
||||||
for (const e of state.enemies) {
|
|
||||||
if (e.type !== 'walker') continue;
|
|
||||||
const dHome = tdelta(e.homeX, e.x);
|
|
||||||
if (Math.abs(dHome) > TUNE.WALKER_PATROL_RANGE) e.dir = dHome > 0 ? -1 : 1;
|
|
||||||
e.x = wrap(e.x + e.dir * TUNE.WALKER_SPEED * dtS);
|
|
||||||
e.fireCooldownMs -= dt;
|
|
||||||
const dToPlayer = tdist(e.x, e.y, state.player.x, state.player.y);
|
|
||||||
if (state.player.alive && dToPlayer < TUNE.WALKER_AIM_RANGE && e.fireCooldownMs <= 0) {
|
|
||||||
e.fireCooldownMs = TUNE.WALKER_FIRE_COOLDOWN_MS;
|
|
||||||
const dx = tdelta(e.x, state.player.x); const dy = state.player.y - e.y;
|
|
||||||
const d = Math.hypot(dx, dy) || 1;
|
|
||||||
state.enemyShots.push({
|
|
||||||
x: e.x, y: e.y, vx: (dx / d) * TUNE.WALKER_SHOT_SPEED, vy: (dy / d) * TUNE.WALKER_SHOT_SPEED,
|
|
||||||
radius: TUNE.WALKER_SHOT_RADIUS, ttlMs: 2200,
|
|
||||||
});
|
|
||||||
events.push({ type: 'shotFired', enemy: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAbductors(state, dt, events) {
|
|
||||||
const dtS = dt / 1000;
|
|
||||||
for (const e of state.enemies) {
|
|
||||||
if (e.type !== 'abductor') continue;
|
|
||||||
if (e.carryingId == null) {
|
|
||||||
// seek an idle humanoid
|
|
||||||
if (e.targetHumanoidId == null) {
|
|
||||||
const idle = state.humanoids.filter((h) => h.status === 'idle');
|
|
||||||
if (idle.length) {
|
|
||||||
idle.sort((a, b) => Math.abs(tdelta(e.x, a.x)) - Math.abs(tdelta(e.x, b.x)));
|
|
||||||
e.targetHumanoidId = idle[0].id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const target = state.humanoids.find((h) => h.id === e.targetHumanoidId && h.status === 'idle');
|
|
||||||
if (target) {
|
|
||||||
const dx = tdelta(e.x, target.x); const dy = target.y - e.y;
|
|
||||||
const d = Math.hypot(dx, dy) || 1;
|
|
||||||
e.x = wrap(e.x + (dx / d) * TUNE.ABDUCTOR_SPEED * dtS);
|
|
||||||
e.y += (dy / d) * TUNE.ABDUCTOR_SPEED * dtS;
|
|
||||||
if (d < TUNE.ABDUCTOR_GRAB_RADIUS) {
|
|
||||||
target.status = 'grabbed';
|
|
||||||
target.grabberId = e.id;
|
|
||||||
e.carryingId = target.id;
|
|
||||||
events.push({ type: 'humanoidGrabbed', id: target.id });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
e.targetHumanoidId = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const h = state.humanoids.find((hh) => hh.id === e.carryingId);
|
|
||||||
if (!h || h.status !== 'grabbed') { e.carryingId = null; continue; }
|
|
||||||
e.y -= TUNE.ABDUCTOR_RISE_SPEED * dtS;
|
|
||||||
h.x = e.x; h.y = e.y + 20;
|
|
||||||
if (e.y <= Y_SKY) {
|
|
||||||
h.status = 'lost';
|
|
||||||
state.lostThisLevel += 1;
|
|
||||||
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'escaped' });
|
|
||||||
e.carryingId = null;
|
|
||||||
e.targetHumanoidId = null;
|
|
||||||
// The abductor escapes with its prize — remove it from play.
|
|
||||||
e.dead = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHumanoids(state, dt, events) {
|
|
||||||
const dtS = dt / 1000;
|
|
||||||
for (const h of state.humanoids) {
|
|
||||||
if (h.status === 'falling') {
|
|
||||||
h.timerMs += dt;
|
|
||||||
// Constant, gentle descent — no acceleration, so a catch attempt is just as
|
|
||||||
// makeable in the last moment as it was at the start of the fall.
|
|
||||||
h.y += TUNE.FALL_SPEED * dtS;
|
|
||||||
if (h.y >= Y_GROUND || h.timerMs >= TUNE.GRAB_WINDOW_MS) {
|
|
||||||
h.status = 'lost';
|
|
||||||
state.lostThisLevel += 1;
|
|
||||||
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: h.y >= Y_GROUND ? 'hitGround' : 'grabWindow' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A grabbed humanoid's carrying abductor may die mid-carry — releases it to fall.
|
|
||||||
function releaseGrabbedHumanoids(state, deadAbductorIds, events) {
|
|
||||||
if (!deadAbductorIds.size) return;
|
|
||||||
for (const h of state.humanoids) {
|
|
||||||
if (h.status === 'grabbed' && deadAbductorIds.has(h.grabberId)) {
|
|
||||||
h.status = 'falling';
|
|
||||||
h.vy = TUNE.FALL_SPEED;
|
|
||||||
h.timerMs = 0;
|
|
||||||
events.push({ type: 'humanoidFreed', id: h.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateShots(state, dt) {
|
|
||||||
const dtS = dt / 1000;
|
|
||||||
for (const s of state.shots) { s.x = wrap(s.x + s.vx * dtS); s.y += s.vy * dtS; s.ttlMs -= dt; }
|
|
||||||
state.shots = state.shots.filter((s) => s.ttlMs > 0);
|
|
||||||
for (const s of state.enemyShots) { s.x = wrap(s.x + s.vx * dtS); s.y += s.vy * dtS; s.ttlMs -= dt; }
|
|
||||||
state.enemyShots = state.enemyShots.filter((s) => s.ttlMs > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function circleHit(ax, ay, ar, bx, by, br) {
|
|
||||||
const dx = tdelta(ax, bx); const dy = ay - by;
|
|
||||||
const r = ar + br;
|
|
||||||
return dx * dx + dy * dy <= r * r;
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerKill(state, enemy, events) {
|
|
||||||
const now = state.timeMs;
|
|
||||||
if (now - state.lastKillMs <= TUNE.COMBO_WINDOW_MS) {
|
|
||||||
state.multiplier = Math.min(TUNE.MULT_MAX, state.multiplier + 1);
|
|
||||||
} else {
|
|
||||||
state.multiplier = 1;
|
|
||||||
}
|
|
||||||
state.lastKillMs = now;
|
|
||||||
state.overdriveMeter = Math.min(1, state.overdriveMeter + TUNE.OVERDRIVE_FILL_PER_KILL);
|
|
||||||
if (state.overdriveMeter >= 1) events.push({ type: 'overdriveReady' });
|
|
||||||
const base = TUNE.ENEMY_KILL_SCORE[enemy.type] ?? 50;
|
|
||||||
const mult = state.overdriveActive ? TUNE.OVERDRIVE_SCORE_MULT : state.multiplier;
|
|
||||||
state.score += base * mult;
|
|
||||||
events.push({ type: 'enemyKilled', enemyType: enemy.type, x: enemy.x, y: enemy.y, multiplier: state.multiplier });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCollisions(state, events) {
|
|
||||||
const p = state.player;
|
|
||||||
const deadAbductorIds = new Set();
|
|
||||||
|
|
||||||
// player shots vs enemies
|
|
||||||
for (const s of state.shots) {
|
|
||||||
for (const e of state.enemies) {
|
|
||||||
if (e.dead || s.dead) continue;
|
|
||||||
if (circleHit(s.x, s.y, TUNE.PLAYER_SHOT_RADIUS, e.x, e.y, e.radius)) {
|
|
||||||
s.dead = true;
|
|
||||||
e.hp -= 1;
|
|
||||||
events.push({ type: 'enemyHit', id: e.id });
|
|
||||||
if (e.hp <= 0) {
|
|
||||||
e.dead = true;
|
|
||||||
if (e.type === 'abductor') deadAbductorIds.add(e.id);
|
|
||||||
registerKill(state, e, events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// player shots vs boss
|
|
||||||
if (state.boss && !s.dead && circleHit(s.x, s.y, TUNE.PLAYER_SHOT_RADIUS, state.boss.x, state.boss.y, TUNE.BOSS_RADIUS)) {
|
|
||||||
s.dead = true;
|
|
||||||
state.boss.hp -= 1;
|
|
||||||
events.push({ type: 'enemyHit', boss: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.shots = state.shots.filter((s) => !s.dead);
|
|
||||||
releaseGrabbedHumanoids(state, deadAbductorIds, events);
|
|
||||||
state.enemies = state.enemies.filter((e) => !e.dead);
|
|
||||||
|
|
||||||
// enemy bodies / enemy shots vs player
|
|
||||||
if (p.alive && p.invulnMs <= 0 && !state.overdriveActive) {
|
|
||||||
for (const e of state.enemies) {
|
|
||||||
if (circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, e.x, e.y, e.radius)) { killPlayer(state, events); break; }
|
|
||||||
}
|
|
||||||
if (p.alive) {
|
|
||||||
for (const s of state.enemyShots) {
|
|
||||||
if (circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, s.x, s.y, s.radius)) { s.dead = true; killPlayer(state, events); break; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (p.alive && state.boss && circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, state.boss.x, state.boss.y, TUNE.BOSS_RADIUS)) {
|
|
||||||
killPlayer(state, events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.enemyShots = state.enemyShots.filter((s) => !s.dead);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Boss
|
|
||||||
|
|
||||||
function spawnBoss(state, events) {
|
|
||||||
const spec = bossSpec(state.level);
|
|
||||||
state.boss = {
|
|
||||||
name: spec.name, hp: spec.hp, maxHp: spec.hp,
|
|
||||||
x: wrap(state.player.x + WORLD_W / 2), y: (Y_MIN + Y_GROUND) / 2,
|
|
||||||
dir: 1, attackCooldownMs: bossProfileFor(state.level).cooldownMs, phase: 1,
|
|
||||||
moveIdx: 0, spiralAngle: 0,
|
|
||||||
};
|
|
||||||
events.push({ type: 'bossSpawn', kind: spec.name });
|
|
||||||
}
|
|
||||||
|
|
||||||
function fireBossShot(state, boss, angle, speed) {
|
|
||||||
state.enemyShots.push({
|
|
||||||
x: boss.x, y: boss.y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
|
|
||||||
radius: 8, ttlMs: 2600,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every attack pattern a boss can draw from. Each is a genuinely different
|
|
||||||
// shape/behavior (not a recolor of another), so "harder boss" means "new
|
|
||||||
// things to read and dodge", not just "more of the same bullets".
|
|
||||||
function performBossMove(state, boss, move, events) {
|
|
||||||
const p = state.player;
|
|
||||||
switch (move) {
|
|
||||||
case 'ring': // static radial burst — the baseline area-denial pattern
|
|
||||||
for (let i = 0; i < TUNE.BOSS_SPOKE_COUNT; i += 1) {
|
|
||||||
fireBossShot(state, boss, (i / TUNE.BOSS_SPOKE_COUNT) * Math.PI * 2, TUNE.BOSS_SHOT_SPEED);
|
|
||||||
}
|
|
||||||
events.push({ type: 'shotFired', enemy: true, boss: true });
|
|
||||||
break;
|
|
||||||
case 'spread': { // a forward shotgun cone toward the player's general side
|
|
||||||
const base = tdelta(boss.x, p.x) >= 0 ? 0 : Math.PI;
|
|
||||||
for (const off of [-0.5, -0.25, 0, 0.25, 0.5]) fireBossShot(state, boss, base + off, TUNE.BOSS_SHOT_SPEED * 1.1);
|
|
||||||
events.push({ type: 'shotFired', enemy: true, boss: true });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'spiral': // three arms that rotate a bit further each time this move fires
|
|
||||||
for (const off of [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]) {
|
|
||||||
fireBossShot(state, boss, boss.spiralAngle + off, TUNE.BOSS_SHOT_SPEED * 0.85);
|
|
||||||
}
|
|
||||||
boss.spiralAngle += 0.5;
|
|
||||||
events.push({ type: 'shotFired', enemy: true, boss: true });
|
|
||||||
break;
|
|
||||||
case 'aimed': { // tracks the player directly — punishes standing still
|
|
||||||
const base = Math.atan2(p.y - boss.y, tdelta(boss.x, p.x));
|
|
||||||
for (const off of [-0.12, 0, 0.12]) fireBossShot(state, boss, base + off, TUNE.BOSS_SHOT_SPEED * 1.3);
|
|
||||||
events.push({ type: 'shotFired', enemy: true, boss: true });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'wall': { // a dense ring, twice the density of 'ring' — find the gap
|
|
||||||
const count = TUNE.BOSS_SPOKE_COUNT * 2;
|
|
||||||
for (let i = 0; i < count; i += 1) fireBossShot(state, boss, (i / count) * Math.PI * 2, TUNE.BOSS_SHOT_SPEED * 0.9);
|
|
||||||
events.push({ type: 'shotFired', enemy: true, boss: true });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'reinforce': // calls in a swarmer pack — now you're managing adds too
|
|
||||||
spawnSwarmerPack(state, 6);
|
|
||||||
break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateBoss(state, dt, events) {
|
|
||||||
const boss = state.boss;
|
|
||||||
if (!boss) return;
|
|
||||||
const dtS = dt / 1000;
|
|
||||||
const profile = bossProfileFor(state.level);
|
|
||||||
const wasPhase = boss.phase;
|
|
||||||
boss.phase = boss.hp > boss.maxHp / 2 ? 1 : 2;
|
|
||||||
if (boss.phase !== wasPhase) events.push({ type: 'bossPhaseChange', phase: boss.phase, kind: boss.name });
|
|
||||||
|
|
||||||
const moves = (boss.phase === 2 && profile.phase2Moves) ? profile.phase2Moves : profile.moves;
|
|
||||||
|
|
||||||
boss.x = wrap(boss.x + boss.dir * TUNE.BOSS_SPEED * profile.speedMult * dtS);
|
|
||||||
const dHome = tdelta(state.player.x - WORLD_W / 2, boss.x); // roam the far side of the ring
|
|
||||||
if (Math.abs(dHome) > WORLD_W * 0.3) boss.dir *= -1;
|
|
||||||
|
|
||||||
boss.attackCooldownMs -= dt;
|
|
||||||
if (boss.attackCooldownMs <= 0 && state.player.alive) {
|
|
||||||
// Phase 2 also attacks a little faster, on top of whatever new moves it unlocked.
|
|
||||||
boss.attackCooldownMs = profile.cooldownMs * (boss.phase === 2 ? 0.8 : 1);
|
|
||||||
const move = moves[boss.moveIdx % moves.length];
|
|
||||||
boss.moveIdx += 1;
|
|
||||||
events.push({ type: 'bossMove', move, phase: boss.phase });
|
|
||||||
performBossMove(state, boss, move, events);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (boss.hp <= 0) {
|
|
||||||
events.push({ type: 'bossDefeated', kind: boss.name });
|
|
||||||
state.score += TUNE.BOSS_KILL_SCORE;
|
|
||||||
state.boss = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Wave / level progression
|
|
||||||
|
|
||||||
function trySpawnFromQueue(state, dt) {
|
|
||||||
if (!state.spawnQueue.length) return;
|
|
||||||
state.spawnTimerMs -= dt;
|
|
||||||
if (state.spawnTimerMs > 0) return;
|
|
||||||
state.spawnTimerMs = state.spawnIntervalMs;
|
|
||||||
const next = state.spawnQueue.shift();
|
|
||||||
if (next.kind === 'swarmerPack') spawnSwarmerPack(state, next.count);
|
|
||||||
else if (next.kind === 'walker') spawnWalker(state);
|
|
||||||
else if (next.kind === 'abductor') spawnAbductor(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePhase(state, dt, events) {
|
|
||||||
state.phaseMs += dt;
|
|
||||||
switch (state.phase) {
|
|
||||||
case 'waveIntro':
|
|
||||||
if (state.phaseMs >= TUNE.WAVE_BREATHER_MS * 0.4) {
|
|
||||||
state.phase = 'wave';
|
|
||||||
state.phaseMs = 0;
|
|
||||||
events.push({ type: 'waveStart', level: state.level, wave: state.wave });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'wave':
|
|
||||||
trySpawnFromQueue(state, dt);
|
|
||||||
if (!state.spawnQueue.length && state.enemies.length === 0) {
|
|
||||||
state.phase = 'waveClear';
|
|
||||||
state.phaseMs = 0;
|
|
||||||
events.push({ type: 'waveClear', level: state.level, wave: state.wave });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'waveClear':
|
|
||||||
if (state.phaseMs >= TUNE.WAVE_BREATHER_MS) {
|
|
||||||
if (state.wave < TUNE.WAVES_PER_LEVEL) {
|
|
||||||
startWave(state, state.wave + 1);
|
|
||||||
} else {
|
|
||||||
state.phase = 'bossIntro';
|
|
||||||
state.phaseMs = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'bossIntro':
|
|
||||||
if (state.phaseMs >= TUNE.BOSS_INTRO_MS) {
|
|
||||||
spawnBoss(state, events);
|
|
||||||
state.phase = 'boss';
|
|
||||||
state.phaseMs = 0;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'boss':
|
|
||||||
updateBoss(state, dt, events);
|
|
||||||
if (!state.boss) {
|
|
||||||
state.phase = 'levelComplete';
|
|
||||||
state.phaseMs = 0;
|
|
||||||
const fullRescue = state.lostThisLevel === 0 && state.rescuedThisLevel === TUNE.HUMANOIDS_PER_LEVEL;
|
|
||||||
if (fullRescue) {
|
|
||||||
state.score += TUNE.FULL_RESCUE_BONUS;
|
|
||||||
state.lives += 1;
|
|
||||||
}
|
|
||||||
events.push({
|
|
||||||
type: 'levelComplete', level: state.level,
|
|
||||||
rescued: state.rescuedThisLevel, lost: state.lostThisLevel, fullRescue,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'levelComplete':
|
|
||||||
if (state.phaseMs >= TUNE.BOSS_OUTRO_MS) {
|
|
||||||
if (state.level >= TUNE.LEVEL_COUNT) {
|
|
||||||
state.victory = true;
|
|
||||||
state.over = true;
|
|
||||||
state.phase = 'victory';
|
|
||||||
events.push({ type: 'victory', score: state.score });
|
|
||||||
} else {
|
|
||||||
state.level += 1;
|
|
||||||
state.rescuedThisLevel = 0;
|
|
||||||
state.lostThisLevel = 0;
|
|
||||||
state.humanoids = state.humanoids.filter((h) => h.status !== 'rescued' && h.status !== 'lost');
|
|
||||||
spawnHumanoids(state, TUNE.HUMANOIDS_PER_LEVEL);
|
|
||||||
state.extractionZones = makeExtractionZones(state.rng);
|
|
||||||
startWave(state, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateOverdrive(state, dt, events) {
|
|
||||||
if (!state.overdriveActive) return;
|
|
||||||
state.overdriveMsLeft -= dt;
|
|
||||||
state.overdriveMeter = Math.max(0, state.overdriveMeter - dt / TUNE.OVERDRIVE_DURATION_MS);
|
|
||||||
if (state.overdriveMsLeft <= 0 || state.overdriveMeter <= 0) {
|
|
||||||
state.overdriveActive = false;
|
|
||||||
state.overdriveMeter = 0;
|
|
||||||
events.push({ type: 'overdriveEnd' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCombo(state) {
|
|
||||||
if (state.timeMs - state.lastKillMs > TUNE.COMBO_WINDOW_MS) state.multiplier = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Master tick — one fixed STEP_MS of simulation.
|
|
||||||
function tick(state) {
|
|
||||||
const events = [];
|
|
||||||
if (state.over) return events;
|
|
||||||
const dt = STEP_MS * timescale(state);
|
|
||||||
state.timeMs += dt;
|
|
||||||
|
|
||||||
updatePlayer(state, state.player.alive ? dt : STEP_MS, events);
|
|
||||||
updateSwarmers(state, dt);
|
|
||||||
updateWalkers(state, dt, events);
|
|
||||||
updateAbductors(state, dt, events);
|
|
||||||
updateHumanoids(state, dt, events);
|
|
||||||
updateShots(state, dt);
|
|
||||||
handleCollisions(state, events);
|
|
||||||
updatePhase(state, dt, events);
|
|
||||||
updateOverdrive(state, dt, events);
|
|
||||||
updateCombo(state);
|
|
||||||
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function step(state, deltaMs) {
|
|
||||||
const out = [];
|
|
||||||
state.accumulatorMs += deltaMs;
|
|
||||||
let n = 0;
|
|
||||||
while (state.accumulatorMs >= STEP_MS && n < MAX_STEPS) {
|
|
||||||
state.accumulatorMs -= STEP_MS;
|
|
||||||
const ev = tick(state);
|
|
||||||
for (let i = 0; i < ev.length; i += 1) out.push(ev[i]);
|
|
||||||
n += 1;
|
|
||||||
if (state.over) break;
|
|
||||||
}
|
|
||||||
if (n === MAX_STEPS && state.accumulatorMs >= STEP_MS) state.accumulatorMs = 0; // spiral-of-death guard
|
|
||||||
state.alpha = Math.min(1, state.accumulatorMs / STEP_MS);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
// A stroked vector font for Defender — letters and digits as line segments in
|
|
||||||
// a 4x6 unit cell, drawn as a glow-stroke pair to match the game's wireframe
|
|
||||||
// look (same convention as Tempest's TempestVectorFont.js, copied verbatim
|
|
||||||
// since the glyph set itself is generic).
|
|
||||||
|
|
||||||
const GLYPH_W = 4;
|
|
||||||
const GLYPH_H = 6;
|
|
||||||
const GLYPH_GAP = 1.2;
|
|
||||||
const SPACE_W = 2.6;
|
|
||||||
|
|
||||||
const GLYPHS = {
|
|
||||||
A: [[[0, 6], [2, 0], [4, 6]], [[1, 3.4], [3, 3.4]]],
|
|
||||||
B: [[[0, 0], [0, 6]], [[0, 0], [3, 0], [4, 1], [4, 2], [3, 2.8], [0, 2.8]], [[3, 2.8], [4, 3.6], [4, 5], [3, 6], [0, 6]]],
|
|
||||||
C: [[[3.6, 1], [1.2, 0], [0, 1.6], [0, 4.4], [1.2, 6], [3.6, 5]]],
|
|
||||||
D: [[[0, 0], [2.2, 0], [4, 1.6], [4, 4.4], [2.2, 6], [0, 6], [0, 0]]],
|
|
||||||
E: [[[3.4, 0], [0, 0], [0, 6], [3.4, 6]], [[0, 3], [2.4, 3]]],
|
|
||||||
F: [[[3.4, 0], [0, 0], [0, 6]], [[0, 3], [2.4, 3]]],
|
|
||||||
G: [[[3.6, 1], [1.2, 0], [0, 1.6], [0, 4.4], [1.2, 6], [3, 6], [4, 4.6], [4, 3.2], [2.2, 3.2]]],
|
|
||||||
H: [[[0, 0], [0, 6]], [[4, 0], [4, 6]], [[0, 3], [4, 3]]],
|
|
||||||
I: [[[1, 0], [3, 0]], [[2, 0], [2, 6]], [[1, 6], [3, 6]]],
|
|
||||||
J: [[[4, 0], [4, 5], [3, 6], [1, 6], [0, 5]]],
|
|
||||||
K: [[[0, 0], [0, 6]], [[4, 0], [0, 3.2]], [[1.4, 2.2], [4, 6]]],
|
|
||||||
L: [[[0, 0], [0, 6], [3.4, 6]]],
|
|
||||||
M: [[[0, 6], [0, 0], [2, 2.6], [4, 0], [4, 6]]],
|
|
||||||
N: [[[0, 6], [0, 0], [4, 6], [4, 0]]],
|
|
||||||
O: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]]],
|
|
||||||
P: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.6], [0, 2.6]]],
|
|
||||||
Q: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]], [[2.5, 4.4], [4, 6]]],
|
|
||||||
R: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.4], [0, 2.4]], [[1.6, 2.4], [4, 6]]],
|
|
||||||
S: [[[4, 1], [3, 0], [1, 0], [0, 1], [0, 2], [1, 2.8], [3, 3.2], [4, 4], [4, 5], [3, 6], [1, 6], [0, 5]]],
|
|
||||||
T: [[[0, 0], [4, 0]], [[2, 0], [2, 6]]],
|
|
||||||
U: [[[0, 0], [0, 5], [1, 6], [3, 6], [4, 5], [4, 0]]],
|
|
||||||
V: [[[0, 0], [2, 6], [4, 0]]],
|
|
||||||
W: [[[0, 0], [0.8, 6], [2, 3.2], [3.2, 6], [4, 0]]],
|
|
||||||
X: [[[0, 0], [4, 6]], [[4, 0], [0, 6]]],
|
|
||||||
Y: [[[0, 0], [2, 2.8], [4, 0]], [[2, 2.8], [2, 6]]],
|
|
||||||
Z: [[[0, 0], [4, 0], [0, 6], [4, 6]]],
|
|
||||||
0: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]]],
|
|
||||||
1: [[[1, 1], [2, 0], [2, 6]], [[1, 6], [3, 6]]],
|
|
||||||
2: [[[0, 1], [1, 0], [3, 0], [4, 1], [4, 2.4], [0, 6], [4, 6]]],
|
|
||||||
3: [[[0, 0], [4, 0], [2.4, 2.4], [4, 3.4], [4, 5], [3, 6], [1, 6], [0, 5]]],
|
|
||||||
4: [[[3, 6], [3, 0], [0, 4], [4, 4]]],
|
|
||||||
5: [[[4, 0], [0, 0], [0, 2.6], [3, 2.6], [4, 3.6], [4, 5], [3, 6], [1, 6], [0, 5]]],
|
|
||||||
6: [[[3.5, 0], [1, 0], [0, 1.5], [0, 5], [1, 6], [3, 6], [4, 5], [4, 3.6], [3, 2.6], [0, 2.6]]],
|
|
||||||
7: [[[0, 0], [4, 0], [1.5, 6]]],
|
|
||||||
8: [[[1, 0], [3, 0], [4, 1], [4, 2], [3, 2.8], [1, 2.8], [0, 2], [0, 1], [1, 0]], [[3, 2.8], [4, 3.6], [4, 5], [3, 6], [1, 6], [0, 5], [0, 3.6], [1, 2.8]]],
|
|
||||||
9: [[[0.5, 6], [3, 6], [4, 4.5], [4, 1], [3, 0], [1, 0], [0, 1], [0, 2.4], [1, 3.4], [4, 3.4]]],
|
|
||||||
'-': [[[0.8, 3], [3.2, 3]]],
|
|
||||||
'.': [[[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
|
|
||||||
',': [[[2.2, 5.2], [2.2, 6], [1.6, 7]]],
|
|
||||||
':': [[[1.8, 1.4], [2.2, 1.4], [2.2, 2], [1.8, 2], [1.8, 1.4]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
|
|
||||||
'!': [[[2, 0], [2, 4]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
|
|
||||||
'×': [[[0, 0], [4, 6]], [[4, 0], [0, 6]]],
|
|
||||||
};
|
|
||||||
|
|
||||||
function strokePoly(g, pts) {
|
|
||||||
g.beginPath();
|
|
||||||
g.moveTo(pts[0][0], pts[0][1]);
|
|
||||||
for (let i = 1; i < pts.length; i += 1) g.lineTo(pts[i][0], pts[i][1]);
|
|
||||||
g.strokePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function measureVectorText(text, scale) {
|
|
||||||
let w = 0;
|
|
||||||
for (const ch of text.toUpperCase()) {
|
|
||||||
w += (ch === ' ' ? SPACE_W : GLYPH_W + GLYPH_GAP) * scale;
|
|
||||||
}
|
|
||||||
return w - GLYPH_GAP * scale;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function drawVectorText(g, text, cx, cy, scale, color, options = {}) {
|
|
||||||
const { lineWidth = 3, glowWidth = 9, glowAlpha = 0.18, alpha = 1 } = options;
|
|
||||||
const totalW = measureVectorText(text, scale);
|
|
||||||
let x = cx - totalW / 2;
|
|
||||||
const y = cy - (GLYPH_H * scale) / 2;
|
|
||||||
for (const ch of text.toUpperCase()) {
|
|
||||||
if (ch === ' ') { x += SPACE_W * scale; continue; }
|
|
||||||
const strokes = GLYPHS[ch];
|
|
||||||
if (strokes) {
|
|
||||||
for (const poly of strokes) {
|
|
||||||
const pts = poly.map(([ux, uy]) => [x + ux * scale, y + uy * scale]);
|
|
||||||
g.lineStyle(glowWidth, color, glowAlpha * alpha);
|
|
||||||
strokePoly(g, pts);
|
|
||||||
g.lineStyle(lineWidth, color, alpha);
|
|
||||||
strokePoly(g, pts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
x += (GLYPH_W + GLYPH_GAP) * scale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
// Additive neon bloom for Geometry Wars' world camera.
|
|
||||||
//
|
|
||||||
// Phaser's built-in Bloom FX finishes with a 50/50 linear mix of the sharp
|
|
||||||
// frame and the blurred one, which halves the brightness of every thin line
|
|
||||||
// — exactly the wrong trade for a game made of 2px neon strokes. This is the
|
|
||||||
// same ping-pong Gaussian blur (same onDraw shape as Phaser's
|
|
||||||
// BloomFXPipeline) but composited with blendFramesAdditive, so cores stay at
|
|
||||||
// full brightness and the glow is added on top.
|
|
||||||
//
|
|
||||||
// Canvas renderer gets a no-op stub. PostFXPipeline convention follows
|
|
||||||
// src/ui/ArcadeCurvePipeline.js.
|
|
||||||
|
|
||||||
import * as Phaser from 'phaser';
|
|
||||||
|
|
||||||
const FRAG = `
|
|
||||||
precision mediump float;
|
|
||||||
uniform sampler2D uMainSampler;
|
|
||||||
uniform vec2 offset;
|
|
||||||
varying vec2 outTexCoord;
|
|
||||||
void main(void) {
|
|
||||||
vec4 sum = texture2D(uMainSampler, outTexCoord) * 0.2270270270;
|
|
||||||
sum += texture2D(uMainSampler, outTexCoord + offset * 1.3846153846) * 0.3162162162;
|
|
||||||
sum += texture2D(uMainSampler, outTexCoord - offset * 1.3846153846) * 0.3162162162;
|
|
||||||
sum += texture2D(uMainSampler, outTexCoord + offset * 3.2307692308) * 0.0702702703;
|
|
||||||
sum += texture2D(uMainSampler, outTexCoord - offset * 3.2307692308) * 0.0702702703;
|
|
||||||
gl_FragColor = sum;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Tuning knobs — radius is in ~2px units per blur tap, strength is how much
|
|
||||||
// of the blurred frame gets added back on top.
|
|
||||||
export const GLOW_LEVELS = {
|
|
||||||
off: null,
|
|
||||||
low: { radius: 1.2, strength: 0.75, steps: 3 },
|
|
||||||
high: { radius: 1.8, strength: 1.2, steps: 4 },
|
|
||||||
};
|
|
||||||
|
|
||||||
export class NeonBloomPipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline {
|
|
||||||
constructor(game) {
|
|
||||||
super({ game, name: 'GWNeonBloom', fragShader: FRAG });
|
|
||||||
this.radius = GLOW_LEVELS.high.radius;
|
|
||||||
this.strength = GLOW_LEVELS.high.strength;
|
|
||||||
this.steps = GLOW_LEVELS.high.steps;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDraw(target1) {
|
|
||||||
const target2 = this.fullFrame1;
|
|
||||||
const target3 = this.fullFrame2;
|
|
||||||
this.copyFrame(target1, target3);
|
|
||||||
const x = (2 / target1.width) * this.radius;
|
|
||||||
const y = (2 / target1.height) * this.radius;
|
|
||||||
// set2f only rebinds this pipeline's program when the value changes;
|
|
||||||
// alternating (x,0)/(0,y) guarantees it does after copyFrame bound the
|
|
||||||
// utility shader (same reliance as Phaser's own BloomFXPipeline).
|
|
||||||
for (let i = 0; i < this.steps; i += 1) {
|
|
||||||
this.set2f('offset', x, 0);
|
|
||||||
this.copySprite(target1, target2);
|
|
||||||
this.set2f('offset', 0, y);
|
|
||||||
this.copySprite(target2, target1);
|
|
||||||
}
|
|
||||||
this.blendFramesAdditive(target3, target1, target2, this.strength);
|
|
||||||
this.copyToGame(target2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachNeonBloom(scene, camera, level = 'high') {
|
|
||||||
if (!scene.renderer || scene.renderer.type !== Phaser.WEBGL) {
|
|
||||||
return { setLevel() {}, destroy() {} };
|
|
||||||
}
|
|
||||||
scene.renderer.pipelines.addPostPipeline('GWNeonBloom', NeonBloomPipeline);
|
|
||||||
let attached = false;
|
|
||||||
const setLevel = (lvl) => {
|
|
||||||
const cfg = GLOW_LEVELS[lvl] ?? null;
|
|
||||||
if (!cfg) {
|
|
||||||
if (attached) { camera.removePostPipeline(NeonBloomPipeline); attached = false; }
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!attached) { camera.setPostPipeline(NeonBloomPipeline); attached = true; }
|
|
||||||
const got = camera.getPostPipeline(NeonBloomPipeline);
|
|
||||||
const inst = Array.isArray(got) ? got[0] : got;
|
|
||||||
if (inst) Object.assign(inst, cfg);
|
|
||||||
};
|
|
||||||
setLevel(level);
|
|
||||||
return {
|
|
||||||
setLevel,
|
|
||||||
destroy() {
|
|
||||||
if (attached) { try { camera.removePostPipeline(NeonBloomPipeline); } catch (_) { /* camera gone */ } }
|
|
||||||
attached = false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
// Particle field + shockwave rings for Geometry Wars.
|
|
||||||
//
|
|
||||||
// Particles are a fixed-size struct-of-arrays pool (oldest slot reused when
|
|
||||||
// full) drawn as velocity-aligned streaks — the look of a Geometry Wars
|
|
||||||
// explosion is thousands of short neon dashes slowing down, bouncing off the
|
|
||||||
// arena walls and getting sucked into black holes. Drawing is batched by
|
|
||||||
// (colour, alpha band, width) so a full screen costs a few dozen
|
|
||||||
// lineStyle switches rather than one per particle.
|
|
||||||
//
|
|
||||||
// Phaser-free: draw() only calls Graphics methods on the object it is given.
|
|
||||||
|
|
||||||
export const FX_MAX = 2800;
|
|
||||||
const ALPHA_BANDS = 4;
|
|
||||||
const WIDTHS = [2, 3.4];
|
|
||||||
|
|
||||||
export default class ParticleField {
|
|
||||||
constructor(bounds) {
|
|
||||||
this.bounds = bounds; // { w, h }
|
|
||||||
const n = FX_MAX;
|
|
||||||
this.x = new Float32Array(n); this.y = new Float32Array(n);
|
|
||||||
this.vx = new Float32Array(n); this.vy = new Float32Array(n);
|
|
||||||
this.life = new Float32Array(n); this.maxLife = new Float32Array(n);
|
|
||||||
this.len = new Float32Array(n); this.damp = new Float32Array(n);
|
|
||||||
this.col = new Uint8Array(n); this.wid = new Uint8Array(n);
|
|
||||||
this.cursor = 0;
|
|
||||||
this.palette = [];
|
|
||||||
this.paletteIndex = new Map();
|
|
||||||
this.rings = [];
|
|
||||||
this.buckets = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
colorId(color) {
|
|
||||||
let id = this.paletteIndex.get(color);
|
|
||||||
if (id === undefined) {
|
|
||||||
id = this.palette.length;
|
|
||||||
if (id >= 255) return 0;
|
|
||||||
this.palette.push(color);
|
|
||||||
this.paletteIndex.set(color, id);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
liveCount() {
|
|
||||||
let n = 0;
|
|
||||||
for (let i = 0; i < FX_MAX; i += 1) if (this.life[i] > 0) n += 1;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.life.fill(0);
|
|
||||||
this.rings.length = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(x, y, vx, vy, lifeMs, color, opts = {}) {
|
|
||||||
const i = this.cursor;
|
|
||||||
this.cursor = (i + 1) % FX_MAX;
|
|
||||||
this.x[i] = x; this.y[i] = y;
|
|
||||||
this.vx[i] = vx; this.vy[i] = vy;
|
|
||||||
this.life[i] = lifeMs; this.maxLife[i] = lifeMs;
|
|
||||||
this.len[i] = opts.len ?? 1;
|
|
||||||
this.damp[i] = opts.damp ?? 0.955;
|
|
||||||
this.col[i] = this.colorId(color);
|
|
||||||
this.wid[i] = opts.wide ? 1 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classic radial burst. Speeds follow 1 - 1/rand so most sparks are fast
|
|
||||||
// and a few linger near the centre, which reads as a "hot core".
|
|
||||||
explosion(x, y, color, opts = {}) {
|
|
||||||
const count = opts.count ?? 60;
|
|
||||||
const speed = opts.speed ?? 1000;
|
|
||||||
const life = opts.life ?? 1100;
|
|
||||||
const color2 = opts.color2 ?? color;
|
|
||||||
for (let k = 0; k < count; k += 1) {
|
|
||||||
const a = Math.random() * Math.PI * 2;
|
|
||||||
const sp = speed * (1 - 1 / (1 + Math.random() * 9));
|
|
||||||
const c = Math.random() < 0.5 ? color : color2;
|
|
||||||
this.emit(x, y, Math.cos(a) * sp, Math.sin(a) * sp, life * (0.55 + Math.random() * 0.6), c,
|
|
||||||
{ len: opts.len ?? 1, wide: opts.wide && Math.random() < 0.5 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sparks thrown back off a wall hit, reflected about the wall normal.
|
|
||||||
wallSparks(x, y, angle, color, count = 18) {
|
|
||||||
const { w, h } = this.bounds;
|
|
||||||
let nx = 0; let ny = 0;
|
|
||||||
if (x <= 1) nx = 1; else if (x >= w - 1) nx = -1;
|
|
||||||
if (y <= 1) ny = 1; else if (y >= h - 1) ny = -1;
|
|
||||||
let dx = Math.cos(angle); let dy = Math.sin(angle);
|
|
||||||
if (nx) dx = -dx;
|
|
||||||
if (ny) dy = -dy;
|
|
||||||
const base = Math.atan2(dy, dx);
|
|
||||||
for (let k = 0; k < count; k += 1) {
|
|
||||||
const a = base + (Math.random() - 0.5) * 1.6;
|
|
||||||
const sp = 200 + Math.random() * 700;
|
|
||||||
this.emit(x + nx * 2, y + ny * 2, Math.cos(a) * sp, Math.sin(a) * sp, 380 + Math.random() * 380, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ring(x, y, maxR, lifeMs, color, width = 3, startR = 0) {
|
|
||||||
if (this.rings.length > 60) this.rings.shift();
|
|
||||||
this.rings.push({ x, y, r: startR, startR, maxR, life: lifeMs, maxLife: lifeMs, color, width });
|
|
||||||
}
|
|
||||||
|
|
||||||
// holes: [{x, y, r}] — active black holes pull sparks in and spin them.
|
|
||||||
update(dtMs, holes = []) {
|
|
||||||
const dt = dtMs / 1000;
|
|
||||||
if (dt <= 0) return;
|
|
||||||
const { w, h } = this.bounds;
|
|
||||||
const f60 = dt * 60;
|
|
||||||
for (let i = 0; i < FX_MAX; i += 1) {
|
|
||||||
if (this.life[i] <= 0) continue;
|
|
||||||
this.life[i] -= dtMs;
|
|
||||||
if (this.life[i] <= 0) continue;
|
|
||||||
let vx = this.vx[i]; let vy = this.vy[i];
|
|
||||||
for (let k = 0; k < holes.length; k += 1) {
|
|
||||||
const hole = holes[k];
|
|
||||||
const dx = hole.x - this.x[i]; const dy = hole.y - this.y[i];
|
|
||||||
const d2 = dx * dx + dy * dy;
|
|
||||||
if (d2 > 160000) continue;
|
|
||||||
const d = Math.sqrt(d2) || 1;
|
|
||||||
if (d < hole.r * 0.6) { this.life[i] = 0; break; }
|
|
||||||
const pull = 2600 * (1 - d / 400) * dt;
|
|
||||||
vx += (dx / d) * pull - (dy / d) * pull * 0.9;
|
|
||||||
vy += (dy / d) * pull + (dx / d) * pull * 0.9;
|
|
||||||
}
|
|
||||||
const damp = Math.pow(this.damp[i], f60);
|
|
||||||
vx *= damp; vy *= damp;
|
|
||||||
let x = this.x[i] + vx * dt; let y = this.y[i] + vy * dt;
|
|
||||||
if (x < 0) { x = -x; vx = -vx * 0.6; } else if (x > w) { x = 2 * w - x; vx = -vx * 0.6; }
|
|
||||||
if (y < 0) { y = -y; vy = -vy * 0.6; } else if (y > h) { y = 2 * h - y; vy = -vy * 0.6; }
|
|
||||||
this.x[i] = x; this.y[i] = y; this.vx[i] = vx; this.vy[i] = vy;
|
|
||||||
}
|
|
||||||
for (const r of this.rings) {
|
|
||||||
r.life -= dtMs;
|
|
||||||
const t = 1 - Math.max(0, r.life) / r.maxLife;
|
|
||||||
r.r = r.startR + (r.maxR - r.startR) * (1 - (1 - t) * (1 - t));
|
|
||||||
}
|
|
||||||
if (this.rings.length && this.rings[0].life <= 0) this.rings = this.rings.filter((r) => r.life > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
draw(g, view) {
|
|
||||||
const x0 = view.x - 40; const x1 = view.x + view.w + 40;
|
|
||||||
const y0 = view.y - 40; const y1 = view.y + view.h + 40;
|
|
||||||
for (const list of this.buckets.values()) list.length = 0;
|
|
||||||
for (let i = 0; i < FX_MAX; i += 1) {
|
|
||||||
if (this.life[i] <= 0) continue;
|
|
||||||
const x = this.x[i]; const y = this.y[i];
|
|
||||||
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
|
|
||||||
const f = this.life[i] / this.maxLife[i];
|
|
||||||
const band = Math.min(ALPHA_BANDS - 1, Math.floor(Math.min(1, f * 1.6) * ALPHA_BANDS));
|
|
||||||
const key = (this.col[i] * ALPHA_BANDS + band) * 2 + this.wid[i];
|
|
||||||
let list = this.buckets.get(key);
|
|
||||||
if (!list) { list = []; this.buckets.set(key, list); }
|
|
||||||
list.push(i);
|
|
||||||
}
|
|
||||||
for (const [key, list] of this.buckets) {
|
|
||||||
if (list.length === 0) continue;
|
|
||||||
const wid = key % 2;
|
|
||||||
const band = Math.floor(key / 2) % ALPHA_BANDS;
|
|
||||||
const col = Math.floor(key / 2 / ALPHA_BANDS);
|
|
||||||
g.lineStyle(WIDTHS[wid], this.palette[col], (band + 1) / ALPHA_BANDS);
|
|
||||||
g.beginPath();
|
|
||||||
for (const i of list) {
|
|
||||||
const vx = this.vx[i]; const vy = this.vy[i];
|
|
||||||
const sp = Math.sqrt(vx * vx + vy * vy);
|
|
||||||
const L = Math.min(28, Math.max(2.5, sp * 0.02)) * this.len[i];
|
|
||||||
const ux = sp > 1 ? vx / sp : 1; const uy = sp > 1 ? vy / sp : 0;
|
|
||||||
g.moveTo(this.x[i] - ux * L, this.y[i] - uy * L);
|
|
||||||
g.lineTo(this.x[i], this.y[i]);
|
|
||||||
}
|
|
||||||
g.strokePath();
|
|
||||||
}
|
|
||||||
for (const r of this.rings) {
|
|
||||||
if (r.life <= 0) continue;
|
|
||||||
const f = Math.max(0, r.life) / r.maxLife;
|
|
||||||
g.lineStyle(r.width * 3, r.color, 0.18 * f);
|
|
||||||
g.strokeCircle(r.x, r.y, r.r);
|
|
||||||
g.lineStyle(r.width, r.color, 0.85 * f);
|
|
||||||
g.strokeCircle(r.x, r.y, r.r);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,249 +0,0 @@
|
||||||
// The signature warping background grid: a lattice of point masses joined by
|
|
||||||
// springs, pushed around by bullets, explosions and black holes, and drawn
|
|
||||||
// with a slight perspective projection so pushes along z read as bulges.
|
|
||||||
//
|
|
||||||
// Phaser-free (it only calls Graphics methods on the object passed to
|
|
||||||
// draw()), so tools/verifyGeometryWars.js can exercise the physics in Node.
|
|
||||||
//
|
|
||||||
// Model after Michael Hoffman's well-known Geometry Wars grid write-up:
|
|
||||||
// springs only pull (target length is 95% of the rest length, which keeps
|
|
||||||
// the lattice taut), each mass's damping resets to 0.98 every update, and
|
|
||||||
// force calls temporarily raise damping so a blast punches in and then
|
|
||||||
// settles instead of ringing forever. Border masses are pinned outright
|
|
||||||
// (Hoffman anchors them with springs) so the lattice starts at equilibrium.
|
|
||||||
// Forces are in px/step units at the fixed 60Hz update rate.
|
|
||||||
|
|
||||||
const SPRING_STIFF = 0.28;
|
|
||||||
const SPRING_DAMP = 0.06;
|
|
||||||
const ANCHOR_STIFF = 0.002;
|
|
||||||
const ANCHOR_DAMP = 0.02;
|
|
||||||
const ANCHOR_EVERY = 3;
|
|
||||||
const BASE_DAMPING = 0.98;
|
|
||||||
const PERSPECTIVE = 2000;
|
|
||||||
|
|
||||||
export default class WarpGrid {
|
|
||||||
constructor(width, height, spacing = 40) {
|
|
||||||
this.width = width;
|
|
||||||
this.height = height;
|
|
||||||
this.spacing = spacing;
|
|
||||||
this.cols = Math.floor(width / spacing) + 1;
|
|
||||||
this.rows = Math.floor(height / spacing) + 1;
|
|
||||||
const n = this.cols * this.rows;
|
|
||||||
this.n = n;
|
|
||||||
|
|
||||||
this.px = new Float32Array(n); this.py = new Float32Array(n); this.pz = new Float32Array(n);
|
|
||||||
this.vx = new Float32Array(n); this.vy = new Float32Array(n); this.vz = new Float32Array(n);
|
|
||||||
this.ax = new Float32Array(n); this.ay = new Float32Array(n); this.az = new Float32Array(n);
|
|
||||||
this.ox = new Float32Array(n); this.oy = new Float32Array(n);
|
|
||||||
this.inv = new Float32Array(n);
|
|
||||||
this.damp = new Float32Array(n).fill(BASE_DAMPING);
|
|
||||||
// Projected screen positions, refreshed by draw().
|
|
||||||
this.sx = new Float32Array(n); this.sy = new Float32Array(n);
|
|
||||||
|
|
||||||
for (let r = 0; r < this.rows; r += 1) {
|
|
||||||
for (let c = 0; c < this.cols; c += 1) {
|
|
||||||
const i = r * this.cols + c;
|
|
||||||
const x = c * spacing; const y = r * spacing;
|
|
||||||
this.px[i] = x; this.py[i] = y;
|
|
||||||
this.ox[i] = x; this.oy[i] = y;
|
|
||||||
const border = r === 0 || c === 0 || r === this.rows - 1 || c === this.cols - 1;
|
|
||||||
this.inv[i] = border ? 0 : 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Springs: neighbour links (b >= 0) and interior anchors (b === -1,
|
|
||||||
// pulling toward the mass's own rest position).
|
|
||||||
const sa = []; const sb = []; const rest = []; const k = []; const damp = [];
|
|
||||||
for (let r = 0; r < this.rows; r += 1) {
|
|
||||||
for (let c = 0; c < this.cols; c += 1) {
|
|
||||||
const i = r * this.cols + c;
|
|
||||||
if (c < this.cols - 1) { sa.push(i); sb.push(i + 1); rest.push(spacing * 0.95); k.push(SPRING_STIFF); damp.push(SPRING_DAMP); }
|
|
||||||
if (r < this.rows - 1) { sa.push(i); sb.push(i + this.cols); rest.push(spacing * 0.95); k.push(SPRING_STIFF); damp.push(SPRING_DAMP); }
|
|
||||||
if (this.inv[i] > 0 && r % ANCHOR_EVERY === 0 && c % ANCHOR_EVERY === 0) {
|
|
||||||
sa.push(i); sb.push(-1); rest.push(0); k.push(ANCHOR_STIFF); damp.push(ANCHOR_DAMP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.sa = Int32Array.from(sa);
|
|
||||||
this.sb = Int32Array.from(sb);
|
|
||||||
this.rest = Float32Array.from(rest);
|
|
||||||
this.k = Float32Array.from(k);
|
|
||||||
this.sdamp = Float32Array.from(damp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Index range of lattice cells that could lie within `radius` of (x, y).
|
|
||||||
// Masses drift from their rest spots, so pad by a couple of cells.
|
|
||||||
range(x, y, radius) {
|
|
||||||
const pad = this.spacing * 2;
|
|
||||||
const s = this.spacing;
|
|
||||||
return [
|
|
||||||
Math.max(1, Math.floor((x - radius - pad) / s)),
|
|
||||||
Math.min(this.cols - 2, Math.ceil((x + radius + pad) / s)),
|
|
||||||
Math.max(1, Math.floor((y - radius - pad) / s)),
|
|
||||||
Math.min(this.rows - 2, Math.ceil((y + radius + pad) / s)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
applyDirectedForce(fx, fy, fz, x, y, radius) {
|
|
||||||
const [c0, c1, r0, r1] = this.range(x, y, radius);
|
|
||||||
const r2 = radius * radius;
|
|
||||||
for (let r = r0; r <= r1; r += 1) {
|
|
||||||
for (let c = c0; c <= c1; c += 1) {
|
|
||||||
const i = r * this.cols + c;
|
|
||||||
const dx = x - this.px[i]; const dy = y - this.py[i]; const dz = this.pz[i];
|
|
||||||
const d2 = dx * dx + dy * dy + dz * dz;
|
|
||||||
if (d2 >= r2) continue;
|
|
||||||
const f = (10 / (10 + Math.sqrt(d2))) * this.inv[i];
|
|
||||||
this.ax[i] += fx * f; this.ay[i] += fy * f; this.az[i] += fz * f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyImplosiveForce(force, x, y, radius) {
|
|
||||||
const [c0, c1, r0, r1] = this.range(x, y, radius);
|
|
||||||
const r2 = radius * radius;
|
|
||||||
for (let r = r0; r <= r1; r += 1) {
|
|
||||||
for (let c = c0; c <= c1; c += 1) {
|
|
||||||
const i = r * this.cols + c;
|
|
||||||
const dx = x - this.px[i]; const dy = y - this.py[i]; const dz = -this.pz[i];
|
|
||||||
const d2 = dx * dx + dy * dy + dz * dz;
|
|
||||||
if (d2 >= r2) continue;
|
|
||||||
const f = ((10 * force) / (100 + d2)) * this.inv[i];
|
|
||||||
this.ax[i] += dx * f; this.ay[i] += dy * f; this.az[i] += dz * f;
|
|
||||||
this.damp[i] *= 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyExplosiveForce(force, x, y, radius) {
|
|
||||||
const [c0, c1, r0, r1] = this.range(x, y, radius);
|
|
||||||
const r2 = radius * radius;
|
|
||||||
for (let r = r0; r <= r1; r += 1) {
|
|
||||||
for (let c = c0; c <= c1; c += 1) {
|
|
||||||
const i = r * this.cols + c;
|
|
||||||
const dx = this.px[i] - x; const dy = this.py[i] - y; const dz = this.pz[i];
|
|
||||||
const d2 = dx * dx + dy * dy + dz * dz;
|
|
||||||
if (d2 >= r2) continue;
|
|
||||||
const f = ((100 * force) / (10000 + d2)) * this.inv[i];
|
|
||||||
this.ax[i] += dx * f; this.ay[i] += dy * f; this.az[i] += dz * f;
|
|
||||||
this.damp[i] *= 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One fixed 60Hz physics step.
|
|
||||||
update() {
|
|
||||||
const { px, py, pz, vx, vy, vz, ax, ay, az, inv, sa, sb, rest, k, sdamp } = this;
|
|
||||||
const m = sa.length;
|
|
||||||
for (let s = 0; s < m; s += 1) {
|
|
||||||
const a = sa[s]; const b = sb[s];
|
|
||||||
let bxp; let byp; let bzp; let bvx; let bvy; let bvz;
|
|
||||||
if (b >= 0) { bxp = px[b]; byp = py[b]; bzp = pz[b]; bvx = vx[b]; bvy = vy[b]; bvz = vz[b]; }
|
|
||||||
else { bxp = this.ox[a]; byp = this.oy[a]; bzp = 0; bvx = 0; bvy = 0; bvz = 0; }
|
|
||||||
let dx = px[a] - bxp; let dy = py[a] - byp; let dz = pz[a] - bzp;
|
|
||||||
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
||||||
if (len <= rest[s]) continue;
|
|
||||||
const stretch = (len - rest[s]) / len;
|
|
||||||
dx *= stretch; dy *= stretch; dz *= stretch;
|
|
||||||
const kk = k[s]; const cc = sdamp[s];
|
|
||||||
const fx = kk * dx - (bvx - vx[a]) * cc;
|
|
||||||
const fy = kk * dy - (bvy - vy[a]) * cc;
|
|
||||||
const fz = kk * dz - (bvz - vz[a]) * cc;
|
|
||||||
ax[a] -= fx * inv[a]; ay[a] -= fy * inv[a]; az[a] -= fz * inv[a];
|
|
||||||
if (b >= 0) { ax[b] += fx * inv[b]; ay[b] += fy * inv[b]; az[b] += fz * inv[b]; }
|
|
||||||
}
|
|
||||||
const damp = this.damp;
|
|
||||||
for (let i = 0; i < this.n; i += 1) {
|
|
||||||
if (inv[i] === 0) { ax[i] = 0; ay[i] = 0; az[i] = 0; continue; }
|
|
||||||
vx[i] += ax[i]; vy[i] += ay[i]; vz[i] += az[i];
|
|
||||||
px[i] += vx[i]; py[i] += vy[i]; pz[i] += vz[i];
|
|
||||||
ax[i] = 0; ay[i] = 0; az[i] = 0;
|
|
||||||
if (vx[i] * vx[i] + vy[i] * vy[i] + vz[i] * vz[i] < 1e-6) { vx[i] = 0; vy[i] = 0; vz[i] = 0; }
|
|
||||||
const d = damp[i];
|
|
||||||
vx[i] *= d; vy[i] *= d; vz[i] *= d;
|
|
||||||
damp[i] = BASE_DAMPING;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Largest displacement from rest anywhere on the lattice (verification aid).
|
|
||||||
maxDisplacement() {
|
|
||||||
let m = 0;
|
|
||||||
for (let i = 0; i < this.n; i += 1) {
|
|
||||||
const dx = this.px[i] - this.ox[i]; const dy = this.py[i] - this.oy[i]; const dz = this.pz[i];
|
|
||||||
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
||||||
if (d > m) m = d;
|
|
||||||
}
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw into a Graphics-like object. `view` = {x, y, w, h} world rect.
|
|
||||||
// `style` = { minor, minorAlpha, minorWidth, major, majorAlpha, majorWidth,
|
|
||||||
// hot, hotAlpha, hotter, hotterAlpha, majorEvery }.
|
|
||||||
draw(g, view, style) {
|
|
||||||
const { cols, rows, spacing, px, py, pz, sx, sy } = this;
|
|
||||||
const cx = view.x + view.w / 2; const cy = view.y + view.h / 2;
|
|
||||||
const c0 = Math.max(0, Math.floor(view.x / spacing) - 1);
|
|
||||||
const c1 = Math.min(cols - 1, Math.ceil((view.x + view.w) / spacing) + 1);
|
|
||||||
const r0 = Math.max(0, Math.floor(view.y / spacing) - 1);
|
|
||||||
const r1 = Math.min(rows - 1, Math.ceil((view.y + view.h) / spacing) + 1);
|
|
||||||
|
|
||||||
for (let r = r0; r <= r1; r += 1) {
|
|
||||||
for (let c = c0; c <= c1; c += 1) {
|
|
||||||
const i = r * cols + c;
|
|
||||||
const f = (pz[i] + PERSPECTIVE) / PERSPECTIVE;
|
|
||||||
sx[i] = (px[i] - cx) * f + cx;
|
|
||||||
sy[i] = (py[i] - cy) * f + cy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const every = style.majorEvery ?? 4;
|
|
||||||
const pass = (major) => {
|
|
||||||
g.beginPath();
|
|
||||||
for (let r = r0; r <= r1; r += 1) {
|
|
||||||
if ((r % every === 0) !== major) continue;
|
|
||||||
let i = r * cols + c0;
|
|
||||||
g.moveTo(sx[i], sy[i]);
|
|
||||||
for (let c = c0 + 1; c <= c1; c += 1) { i += 1; g.lineTo(sx[i], sy[i]); }
|
|
||||||
}
|
|
||||||
for (let c = c0; c <= c1; c += 1) {
|
|
||||||
if ((c % every === 0) !== major) continue;
|
|
||||||
let i = r0 * cols + c;
|
|
||||||
g.moveTo(sx[i], sy[i]);
|
|
||||||
for (let r = r0 + 1; r <= r1; r += 1) { i += cols; g.lineTo(sx[i], sy[i]); }
|
|
||||||
}
|
|
||||||
g.strokePath();
|
|
||||||
};
|
|
||||||
g.lineStyle(style.minorWidth, style.minor, style.minorAlpha);
|
|
||||||
pass(false);
|
|
||||||
g.lineStyle(style.majorWidth, style.major, style.majorAlpha);
|
|
||||||
pass(true);
|
|
||||||
|
|
||||||
// Hot overlay: segments around strongly displaced masses glow brighter,
|
|
||||||
// so ripples and black-hole funnels light up instead of just bending.
|
|
||||||
const hot = []; const hotter = [];
|
|
||||||
for (let r = r0; r <= r1; r += 1) {
|
|
||||||
for (let c = c0; c <= c1; c += 1) {
|
|
||||||
const i = r * cols + c;
|
|
||||||
const dx = px[i] - this.ox[i]; const dy = py[i] - this.oy[i];
|
|
||||||
const d2 = dx * dx + dy * dy + pz[i] * pz[i] * 0.0025;
|
|
||||||
if (d2 > 144) hotter.push(i);
|
|
||||||
else if (d2 > 25) hot.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Only link to neighbours inside the projected window — anything outside
|
|
||||||
// it holds a stale (or never-written) screen position.
|
|
||||||
const segs = (list) => {
|
|
||||||
g.beginPath();
|
|
||||||
for (const i of list) {
|
|
||||||
const c = i % cols; const r = (i - c) / cols;
|
|
||||||
if (c < c1) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i + 1], sy[i + 1]); }
|
|
||||||
if (r < r1) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i + cols], sy[i + cols]); }
|
|
||||||
if (c > c0) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i - 1], sy[i - 1]); }
|
|
||||||
if (r > r0) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i - cols], sy[i - cols]); }
|
|
||||||
}
|
|
||||||
g.strokePath();
|
|
||||||
};
|
|
||||||
if (hot.length) { g.lineStyle(style.majorWidth, style.hot, style.hotAlpha); segs(hot); }
|
|
||||||
if (hotter.length) { g.lineStyle(style.majorWidth + 0.5, style.hotter, style.hotterAlpha); segs(hotter); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,117 +0,0 @@
|
||||||
// Stroked vector font for Geometry Wars — the glyph table is the generic
|
|
||||||
// 4x6-cell set from TempestVectorFont.js / DefenderVectorFont.js (copied, per
|
|
||||||
// this repo's convention), extended with a few symbols, plus italic skew,
|
|
||||||
// left/right alignment, letter spacing and a separate glow colour so titles
|
|
||||||
// and HUD numbers can read as neon tubes.
|
|
||||||
|
|
||||||
const GLYPH_W = 4;
|
|
||||||
const GLYPH_H = 6;
|
|
||||||
const GLYPH_GAP = 1.2;
|
|
||||||
const SPACE_W = 2.6;
|
|
||||||
|
|
||||||
const GLYPHS = {
|
|
||||||
A: [[[0, 6], [2, 0], [4, 6]], [[1, 3.4], [3, 3.4]]],
|
|
||||||
B: [[[0, 0], [0, 6]], [[0, 0], [3, 0], [4, 1], [4, 2], [3, 2.8], [0, 2.8]], [[3, 2.8], [4, 3.6], [4, 5], [3, 6], [0, 6]]],
|
|
||||||
C: [[[3.6, 1], [1.2, 0], [0, 1.6], [0, 4.4], [1.2, 6], [3.6, 5]]],
|
|
||||||
D: [[[0, 0], [2.2, 0], [4, 1.6], [4, 4.4], [2.2, 6], [0, 6], [0, 0]]],
|
|
||||||
E: [[[3.4, 0], [0, 0], [0, 6], [3.4, 6]], [[0, 3], [2.4, 3]]],
|
|
||||||
F: [[[3.4, 0], [0, 0], [0, 6]], [[0, 3], [2.4, 3]]],
|
|
||||||
G: [[[3.6, 1], [1.2, 0], [0, 1.6], [0, 4.4], [1.2, 6], [3, 6], [4, 4.6], [4, 3.2], [2.2, 3.2]]],
|
|
||||||
H: [[[0, 0], [0, 6]], [[4, 0], [4, 6]], [[0, 3], [4, 3]]],
|
|
||||||
I: [[[1, 0], [3, 0]], [[2, 0], [2, 6]], [[1, 6], [3, 6]]],
|
|
||||||
J: [[[4, 0], [4, 5], [3, 6], [1, 6], [0, 5]]],
|
|
||||||
K: [[[0, 0], [0, 6]], [[4, 0], [0, 3.2]], [[1.4, 2.2], [4, 6]]],
|
|
||||||
L: [[[0, 0], [0, 6], [3.4, 6]]],
|
|
||||||
M: [[[0, 6], [0, 0], [2, 2.6], [4, 0], [4, 6]]],
|
|
||||||
N: [[[0, 6], [0, 0], [4, 6], [4, 0]]],
|
|
||||||
O: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]]],
|
|
||||||
P: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.6], [0, 2.6]]],
|
|
||||||
Q: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]], [[2.5, 4.4], [4, 6]]],
|
|
||||||
R: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.4], [0, 2.4]], [[1.6, 2.4], [4, 6]]],
|
|
||||||
S: [[[4, 1], [3, 0], [1, 0], [0, 1], [0, 2], [1, 2.8], [3, 3.2], [4, 4], [4, 5], [3, 6], [1, 6], [0, 5]]],
|
|
||||||
T: [[[0, 0], [4, 0]], [[2, 0], [2, 6]]],
|
|
||||||
U: [[[0, 0], [0, 5], [1, 6], [3, 6], [4, 5], [4, 0]]],
|
|
||||||
V: [[[0, 0], [2, 6], [4, 0]]],
|
|
||||||
W: [[[0, 0], [0.8, 6], [2, 3.2], [3.2, 6], [4, 0]]],
|
|
||||||
X: [[[0, 0], [4, 6]], [[4, 0], [0, 6]]],
|
|
||||||
Y: [[[0, 0], [2, 2.8], [4, 0]], [[2, 2.8], [2, 6]]],
|
|
||||||
Z: [[[0, 0], [4, 0], [0, 6], [4, 6]]],
|
|
||||||
0: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]]],
|
|
||||||
1: [[[1, 1], [2, 0], [2, 6]], [[1, 6], [3, 6]]],
|
|
||||||
2: [[[0, 1], [1, 0], [3, 0], [4, 1], [4, 2.4], [0, 6], [4, 6]]],
|
|
||||||
3: [[[0, 0], [4, 0], [2.4, 2.4], [4, 3.4], [4, 5], [3, 6], [1, 6], [0, 5]]],
|
|
||||||
4: [[[3, 6], [3, 0], [0, 4], [4, 4]]],
|
|
||||||
5: [[[4, 0], [0, 0], [0, 2.6], [3, 2.6], [4, 3.6], [4, 5], [3, 6], [1, 6], [0, 5]]],
|
|
||||||
6: [[[3.5, 0], [1, 0], [0, 1.5], [0, 5], [1, 6], [3, 6], [4, 5], [4, 3.6], [3, 2.6], [0, 2.6]]],
|
|
||||||
7: [[[0, 0], [4, 0], [1.5, 6]]],
|
|
||||||
8: [[[1, 0], [3, 0], [4, 1], [4, 2], [3, 2.8], [1, 2.8], [0, 2], [0, 1], [1, 0]], [[3, 2.8], [4, 3.6], [4, 5], [3, 6], [1, 6], [0, 5], [0, 3.6], [1, 2.8]]],
|
|
||||||
9: [[[0.5, 6], [3, 6], [4, 4.5], [4, 1], [3, 0], [1, 0], [0, 1], [0, 2.4], [1, 3.4], [4, 3.4]]],
|
|
||||||
'-': [[[0.8, 3], [3.2, 3]]],
|
|
||||||
'+': [[[0.6, 3], [3.4, 3]], [[2, 1.6], [2, 4.4]]],
|
|
||||||
'.': [[[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
|
|
||||||
',': [[[2.2, 5.2], [2.2, 6], [1.6, 7]]],
|
|
||||||
':': [[[1.8, 1.4], [2.2, 1.4], [2.2, 2], [1.8, 2], [1.8, 1.4]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
|
|
||||||
'!': [[[2, 0], [2, 4]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
|
|
||||||
'?': [[[0, 1], [1, 0], [3, 0], [4, 1], [4, 2], [2, 3.4], [2, 4.2]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
|
|
||||||
'/': [[[3.6, 0], [0.4, 6]]],
|
|
||||||
"'": [[[2, 0], [2, 1.6]]],
|
|
||||||
'%': [[[3.6, 0], [0.4, 6]], [[0.4, 0.4], [1.4, 0.4], [1.4, 1.6], [0.4, 1.6], [0.4, 0.4]], [[2.6, 4.4], [3.6, 4.4], [3.6, 5.6], [2.6, 5.6], [2.6, 4.4]]],
|
|
||||||
'×': [[[0.6, 1.6], [3.4, 4.6]], [[3.4, 1.6], [0.6, 4.6]]],
|
|
||||||
};
|
|
||||||
|
|
||||||
function advance(ch, spacing) {
|
|
||||||
return ch === ' ' ? SPACE_W + spacing : GLYPH_W + GLYPH_GAP + spacing;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function measureVectorText(text, scale, spacing = 0) {
|
|
||||||
let w = 0;
|
|
||||||
for (const ch of String(text).toUpperCase()) w += advance(ch, spacing) * scale;
|
|
||||||
return Math.max(0, w - (GLYPH_GAP + spacing) * scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function vectorTextHeight(scale) { return GLYPH_H * scale; }
|
|
||||||
|
|
||||||
function strokeGlyphs(g, text, x0, y0, scale, skew, spacing) {
|
|
||||||
let x = x0;
|
|
||||||
for (const ch of text) {
|
|
||||||
const strokes = GLYPHS[ch];
|
|
||||||
if (strokes) {
|
|
||||||
for (const poly of strokes) {
|
|
||||||
g.beginPath();
|
|
||||||
for (let i = 0; i < poly.length; i += 1) {
|
|
||||||
const [ux, uy] = poly[i];
|
|
||||||
const px = x + (ux + (GLYPH_H - uy) * skew) * scale;
|
|
||||||
const py = y0 + uy * scale;
|
|
||||||
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
|
|
||||||
}
|
|
||||||
g.strokePath();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
x += advance(ch, spacing) * scale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// (x, y) is the anchor: centre/left/right edge per `align`, vertical centre.
|
|
||||||
export function drawVectorText(g, text, x, y, scale, color, options = {}) {
|
|
||||||
const {
|
|
||||||
lineWidth = 3, glowWidth = 9, glowAlpha = 0.2, alpha = 1,
|
|
||||||
align = 'center', skew = 0, spacing = 0, glowColor = color, glow = true,
|
|
||||||
} = options;
|
|
||||||
const str = String(text).toUpperCase();
|
|
||||||
const w = measureVectorText(str, scale, spacing);
|
|
||||||
let x0 = x - w / 2;
|
|
||||||
if (align === 'left') x0 = x;
|
|
||||||
else if (align === 'right') x0 = x - w;
|
|
||||||
const y0 = y - (GLYPH_H * scale) / 2;
|
|
||||||
if (glow && glowWidth > 0 && glowAlpha > 0) {
|
|
||||||
g.lineStyle(glowWidth, glowColor, glowAlpha * alpha);
|
|
||||||
strokeGlyphs(g, str, x0, y0, scale, skew, spacing);
|
|
||||||
}
|
|
||||||
g.lineStyle(lineWidth, color, alpha);
|
|
||||||
strokeGlyphs(g, str, x0, y0, scale, skew, spacing);
|
|
||||||
return w;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatScore(n) {
|
|
||||||
return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
||||||
}
|
|
||||||
|
|
@ -13,16 +13,6 @@ import {
|
||||||
|
|
||||||
const FONT_D = 'Righteous';
|
const FONT_D = 'Righteous';
|
||||||
const FONT_B = '"Julius Sans One"';
|
const FONT_B = '"Julius Sans One"';
|
||||||
const RULES_W = 420;
|
|
||||||
|
|
||||||
// The same four rules the engine enforces (TentsLogic.js rule comment) —
|
|
||||||
// kept here as plain strings so the panel can never drift from the model.
|
|
||||||
const RULES_TEXT = [
|
|
||||||
'Every tent must sit next to a tree — up, down, left, or right (no diagonals).',
|
|
||||||
"It's a one-to-one pairing: each tree gets exactly one tent, and each tent belongs to exactly one tree.",
|
|
||||||
'Tents can never touch another tent, not even corner to corner.',
|
|
||||||
'The numbers along the top and side are the exact tent count for that column and row — no more, no less.',
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Color helpers (day → night lerps drive the whole scene) ─────────────────
|
// ── Color helpers (day → night lerps drive the whole scene) ─────────────────
|
||||||
|
|
||||||
|
|
@ -85,7 +75,6 @@ export default class TentsGame extends Phaser.Scene {
|
||||||
this.sky = { t: 0 }; // 0 = midday, 1 = starry night
|
this.sky = { t: 0 }; // 0 = midday, 1 = starry night
|
||||||
this.won = false;
|
this.won = false;
|
||||||
this.winPanelShown = false;
|
this.winPanelShown = false;
|
||||||
this.rulesOpen = false;
|
|
||||||
this.difficulty = 'porch';
|
this.difficulty = 'porch';
|
||||||
this.hover = null; // {c, r}
|
this.hover = null; // {c, r}
|
||||||
this.embers = [];
|
this.embers = [];
|
||||||
|
|
@ -118,7 +107,6 @@ export default class TentsGame extends Phaser.Scene {
|
||||||
|
|
||||||
this.buildHud();
|
this.buildHud();
|
||||||
this.hudLayer.setVisible(false);
|
this.hudLayer.setVisible(false);
|
||||||
this.buildRulesPanel();
|
|
||||||
this.buildSelectScreen();
|
this.buildSelectScreen();
|
||||||
|
|
||||||
this.events.on('shutdown', () => {
|
this.events.on('shutdown', () => {
|
||||||
|
|
@ -221,14 +209,7 @@ export default class TentsGame extends Phaser.Scene {
|
||||||
this.btnReset = mk('Reset Tents', 700, () => this.resetTents(), 'ghost');
|
this.btnReset = mk('Reset Tents', 700, () => this.resetTents(), 'ghost');
|
||||||
this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost');
|
this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost');
|
||||||
|
|
||||||
// Parked at the same screen position the rules panel's Close button sits
|
this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu]);
|
||||||
// at once open (GAME_WIDTH - RULES_W/2, GAME_HEIGHT - 80), so opening the
|
|
||||||
// panel reads as this button "becoming" Close rather than two separate
|
|
||||||
// controls.
|
|
||||||
this.btnRules = new Button(this, GAME_WIDTH - RULES_W / 2, GAME_HEIGHT - 80, 'Rules',
|
|
||||||
() => this.toggleRules(), { width: 200, height: 56, variant: 'ghost' }).setDepth(31);
|
|
||||||
|
|
||||||
this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu, this.btnRules]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
makeText(str, x, y, font, size, color) {
|
makeText(str, x, y, font, size, color) {
|
||||||
|
|
@ -238,68 +219,6 @@ export default class TentsGame extends Phaser.Scene {
|
||||||
}).setOrigin(0, 0).setDepth(30);
|
}).setOrigin(0, 0).setDepth(30);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Rules panel (toggled in-play; parked off-screen until opened) ──────────
|
|
||||||
|
|
||||||
buildRulesPanel() {
|
|
||||||
const panel = this.add.container(GAME_WIDTH, 0).setDepth(35);
|
|
||||||
|
|
||||||
const bg = this.add.graphics();
|
|
||||||
bg.fillStyle(0x101024, 0.95);
|
|
||||||
bg.fillRect(0, 0, RULES_W, GAME_HEIGHT);
|
|
||||||
bg.lineStyle(3, 0xffc46a, 0.9);
|
|
||||||
bg.lineBetween(0, 0, 0, GAME_HEIGHT);
|
|
||||||
panel.add(bg);
|
|
||||||
|
|
||||||
// Pushed down ~100px so the title doesn't sit under the soundtrack
|
|
||||||
// controls (MusicPlayer's skip/mute buttons + track name) in the
|
|
||||||
// top-right corner, which render above this panel's depth.
|
|
||||||
panel.add(this.add.text(RULES_W / 2, 150, 'How to Play', {
|
|
||||||
fontFamily: FONT_D, fontSize: '36px', color: '#ffd76a',
|
|
||||||
stroke: '#3a2a14', strokeThickness: 6,
|
|
||||||
}).setOrigin(0.5));
|
|
||||||
|
|
||||||
let y = 218;
|
|
||||||
RULES_TEXT.forEach((line, i) => {
|
|
||||||
panel.add(this.add.text(24, y, `${i + 1}.`, {
|
|
||||||
fontFamily: FONT_D, fontSize: '22px', color: '#ffd76a',
|
|
||||||
}).setOrigin(0, 0));
|
|
||||||
const body = this.add.text(58, y, line, {
|
|
||||||
fontFamily: FONT_B, fontSize: '20px', color: '#fdf6e3',
|
|
||||||
wordWrap: { width: RULES_W - 82 }, lineSpacing: 4,
|
|
||||||
}).setOrigin(0, 0);
|
|
||||||
panel.add(body);
|
|
||||||
y += body.height + 34;
|
|
||||||
});
|
|
||||||
|
|
||||||
const closeBtn = new Button(this, RULES_W / 2, GAME_HEIGHT - 80, 'Close',
|
|
||||||
() => this.toggleRules(), { width: 200, height: 56, variant: 'ghost' });
|
|
||||||
panel.add(closeBtn);
|
|
||||||
|
|
||||||
this.rulesPanel = panel;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleRules() {
|
|
||||||
this.rulesOpen = !this.rulesOpen;
|
|
||||||
this.tweens.add({
|
|
||||||
targets: this.rulesPanel,
|
|
||||||
x: this.rulesOpen ? GAME_WIDTH - RULES_W : GAME_WIDTH,
|
|
||||||
duration: 220,
|
|
||||||
ease: 'Quad.Out',
|
|
||||||
});
|
|
||||||
// The panel's own Close button sits in this same screen spot once open,
|
|
||||||
// so hide (and un-click) the Rules button rather than stacking both.
|
|
||||||
this.btnRules.setVisible(!this.rulesOpen);
|
|
||||||
if (this.rulesOpen) {
|
|
||||||
this.btnRules.disableInteractive();
|
|
||||||
} else {
|
|
||||||
this.btnRules.setInteractive({
|
|
||||||
useHandCursor: true,
|
|
||||||
hitArea: new Phaser.Geom.Rectangle(0, 0, 200, 56),
|
|
||||||
hitAreaCallback: Phaser.Geom.Rectangle.Contains,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Select screen ──────────────────────────────────────────────────────────
|
// ── Select screen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
buildSelectScreen() {
|
buildSelectScreen() {
|
||||||
|
|
|
||||||
|
|
@ -110,8 +110,6 @@ import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
|
||||||
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
|
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
|
||||||
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
|
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
|
||||||
import TentsGame from './games/tents/TentsGame.js';
|
import TentsGame from './games/tents/TentsGame.js';
|
||||||
import DefenderGame from './games/defender/DefenderGame.js';
|
|
||||||
import GeometryWarsGame from './games/geometrywars/GeometryWarsGame.js';
|
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -235,8 +233,6 @@ const config = {
|
||||||
WolfensteinEditor,
|
WolfensteinEditor,
|
||||||
PipePuzzleGame,
|
PipePuzzleGame,
|
||||||
TentsGame,
|
TentsGame,
|
||||||
DefenderGame,
|
|
||||||
GeometryWarsGame,
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game', defender: 'DefenderGame', geometrywars: 'GeometryWarsGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
const sceneKey = slugDispatch[this.game.slug];
|
const sceneKey = slugDispatch[this.game.slug];
|
||||||
const startData = {
|
const startData = {
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,6 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
|
||||||
totalannihilation: 'hacker',
|
totalannihilation: 'hacker',
|
||||||
coloradodefense: 'arcadedark',
|
coloradodefense: 'arcadedark',
|
||||||
tempest: 'arcadedark',
|
tempest: 'arcadedark',
|
||||||
defender: 'arcadedark',
|
|
||||||
geometrywars: 'arcadedark',
|
|
||||||
mastermind: 'hacker',
|
mastermind: 'hacker',
|
||||||
balatro: 'hacker',
|
balatro: 'hacker',
|
||||||
hexsweeper: 'hacker',
|
hexsweeper: 'hacker',
|
||||||
|
|
|
||||||
|
|
@ -1,437 +0,0 @@
|
||||||
// Headless verification for Defender.
|
|
||||||
// node tools/verifyDefender.js
|
|
||||||
// Exits non-zero on any failure.
|
|
||||||
//
|
|
||||||
// 1. Wraparound math (wrap/tdelta) round-trip and shortest-path correctness.
|
|
||||||
// 2. Boids seam correctness (neighbors across the wrap seam attract/repel
|
|
||||||
// as if adjacent, not as if worlds apart).
|
|
||||||
// 3. Rescue state machine — every transition in the humanoid lifecycle.
|
|
||||||
// 4. Overdrive meter thresholds and combo multiplier behavior.
|
|
||||||
// 5. No entity leaks across a long soak.
|
|
||||||
// 6. Boss defeat always precedes exactly one levelComplete, tally correct.
|
|
||||||
// 7. Spiral-of-death guard on a huge injected deltaMs.
|
|
||||||
// 8. Determinism — same seed + same input sequence replayed twice.
|
|
||||||
// 9. Monte-carlo bot soak across many seeds through all 5 levels.
|
|
||||||
// 10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety.
|
|
||||||
|
|
||||||
import {
|
|
||||||
WORLD_W, Y_SKY, Y_GROUND, STEP_MS, MAX_STEPS, TUNE,
|
|
||||||
wrap, tdelta, tdist, createGame, setInput, step,
|
|
||||||
BOSS_PROFILES, bossSpec,
|
|
||||||
} from '../src/games/defender/DefenderLogic.js';
|
|
||||||
|
|
||||||
let failures = 0;
|
|
||||||
function check(name, cond, detail = '') {
|
|
||||||
if (cond) { console.log(` ok ${name}`); return; }
|
|
||||||
failures += 1;
|
|
||||||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runTicks(state, count, input = {}) {
|
|
||||||
setInput(state, input);
|
|
||||||
const events = [];
|
|
||||||
for (let i = 0; i < count; i += 1) events.push(...step(state, STEP_MS));
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('1. Wraparound math');
|
|
||||||
{
|
|
||||||
check('wrap() folds negative into range', wrap(-10) === WORLD_W - 10);
|
|
||||||
check('wrap() folds overflow into range', wrap(WORLD_W + 25) === 25);
|
|
||||||
check('wrap() is identity inside range', wrap(1234) === 1234);
|
|
||||||
check('tdelta shortest path across seam is small', Math.abs(tdelta(5, WORLD_W - 5)) === 10,
|
|
||||||
`got ${tdelta(5, WORLD_W - 5)}`);
|
|
||||||
check('tdelta sign points the short way', tdelta(5, WORLD_W - 5) < 0);
|
|
||||||
check('tdelta of equal points is 0', tdelta(500, 500) === 0);
|
|
||||||
check('tdelta magnitude never exceeds half the world', Math.abs(tdelta(0, WORLD_W / 2)) <= WORLD_W / 2 + 1e-9);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('2. Boids seam correctness');
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 1 });
|
|
||||||
state.phase = 'wave';
|
|
||||||
state.spawnQueue = [];
|
|
||||||
state.enemies = [
|
|
||||||
{ id: 901, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: 5, y: 400, vx: 0, vy: 0 },
|
|
||||||
{ id: 902, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: WORLD_W - 5, y: 400, vx: 0, vy: 0 },
|
|
||||||
];
|
|
||||||
state.player.x = 3000; // far from both, out of seek range
|
|
||||||
const before = tdist(state.enemies[0].x, state.enemies[0].y, state.enemies[1].x, state.enemies[1].y);
|
|
||||||
runTicks(state, 30);
|
|
||||||
const [a, b] = state.enemies;
|
|
||||||
const after = tdist(a.x, a.y, b.x, b.y);
|
|
||||||
check('seam-adjacent swarmers perceive each other as close', before < 20, `raw seam gap ${before}`);
|
|
||||||
check('seam-adjacent swarmers stay bounded, not flung apart', after < 400,
|
|
||||||
`wrapped distance grew to ${after}`);
|
|
||||||
check('no NaN positions after seam interaction', Number.isFinite(a.x) && Number.isFinite(b.x));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('3. Rescue state machine');
|
|
||||||
{
|
|
||||||
// grabbed -> lost (escaped past Y_SKY)
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 2 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const h = state.humanoids[0];
|
|
||||||
h.status = 'grabbed'; h.grabberId = 777;
|
|
||||||
state.enemies = [{ id: 777, type: 'abductor', hp: 2, radius: TUNE.ABDUCTOR_RADIUS, x: h.x, y: Y_SKY + 2, vx: 0, vy: 0, carryingId: h.id, targetHumanoidId: null }];
|
|
||||||
const ev = runTicks(state, 5);
|
|
||||||
check('grabbed humanoid lost on reaching Y_SKY', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'escaped'));
|
|
||||||
}
|
|
||||||
// grabbed -> falling (carrying abductor dies)
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 3 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const h = state.humanoids[0];
|
|
||||||
h.status = 'grabbed'; h.grabberId = 778; h.x = 3000; h.y = 400;
|
|
||||||
state.enemies = [{ id: 778, type: 'abductor', hp: 1, radius: TUNE.ABDUCTOR_RADIUS, x: 3000, y: 400, vx: 0, vy: 0, carryingId: h.id, targetHumanoidId: null }];
|
|
||||||
state.shots = [{ x: 3000, y: 400, vx: 0, vy: 0, ttlMs: 500 }];
|
|
||||||
const ev = runTicks(state, 1);
|
|
||||||
check('humanoid freed when its abductor dies', h.status === 'falling' && ev.some((e) => e.type === 'humanoidFreed'));
|
|
||||||
}
|
|
||||||
// falling -> lost (hits ground)
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 4 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const h = state.humanoids[0];
|
|
||||||
h.status = 'falling'; h.y = Y_GROUND - 2; h.vy = TUNE.FALL_SPEED; h.timerMs = 0;
|
|
||||||
state.player.x = wrap(h.x + 3000); // keep the player far away so it can't intercept
|
|
||||||
const ev = runTicks(state, 3);
|
|
||||||
check('falling humanoid lost on hitting ground', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'hitGround'));
|
|
||||||
}
|
|
||||||
// falling -> lost (grab window elapses, never reaches ground)
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 5 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const h = state.humanoids[0];
|
|
||||||
// Fall distance over a full GRAB_WINDOW_MS at the constant FALL_SPEED is
|
|
||||||
// ~1120px; start well above that so "hits ground" can't pre-empt this
|
|
||||||
// test of the "grab window elapses" path.
|
|
||||||
h.status = 'falling'; h.y = Y_GROUND - 1400; h.vy = TUNE.FALL_SPEED; h.timerMs = 0;
|
|
||||||
state.player.x = wrap(h.x + 3000);
|
|
||||||
const justBefore = Math.floor((TUNE.GRAB_WINDOW_MS - 3 * STEP_MS) / STEP_MS);
|
|
||||||
runTicks(state, justBefore);
|
|
||||||
const stillFalling = h.status === 'falling';
|
|
||||||
const ev = runTicks(state, 10);
|
|
||||||
check('grab window not triggered early', stillFalling);
|
|
||||||
check('falling humanoid lost when grab window elapses', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'grabWindow'));
|
|
||||||
}
|
|
||||||
// falling -> carried (player proximity)
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 6 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const h = state.humanoids[0];
|
|
||||||
h.status = 'falling'; h.y = 400; h.vy = 0; h.timerMs = 0;
|
|
||||||
state.player.x = h.x; state.player.y = h.y; state.player.vx = 0; state.player.vy = 0;
|
|
||||||
const ev = runTicks(state, 1);
|
|
||||||
check('nearby falling humanoid is auto-picked-up', h.status === 'carried' && state.player.carrying === h.id
|
|
||||||
&& ev.some((e) => e.type === 'humanoidPickedUp'));
|
|
||||||
}
|
|
||||||
// carried -> rescued (extraction zone)
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 7 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const h = state.humanoids[0];
|
|
||||||
const zoneX = state.extractionZones[0].x;
|
|
||||||
h.status = 'carried'; h.timerMs = 0;
|
|
||||||
state.player.carrying = h.id; state.player.x = zoneX; state.player.y = 400; state.player.vx = 0; state.player.vy = 0;
|
|
||||||
const ev = runTicks(state, 1);
|
|
||||||
check('carried humanoid rescued at extraction zone', h.status === 'rescued' && state.player.carrying === null
|
|
||||||
&& state.rescuedThisLevel === 1 && ev.some((e) => e.type === 'humanoidRescued'));
|
|
||||||
}
|
|
||||||
// carried -> lost (timeout)
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 8 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const h = state.humanoids[0];
|
|
||||||
h.status = 'carried'; h.timerMs = 0;
|
|
||||||
state.player.carrying = h.id;
|
|
||||||
state.player.x = wrap(state.extractionZones[0].x + WORLD_W / 2); // far from every zone
|
|
||||||
state.player.y = 400; state.player.vx = 0; state.player.vy = 0;
|
|
||||||
const ticks = Math.ceil(TUNE.CARRY_TIMEOUT_MS / STEP_MS) + 2;
|
|
||||||
const ev = runTicks(state, ticks);
|
|
||||||
check('carried humanoid lost after carry timeout', h.status === 'lost' && state.player.carrying === null
|
|
||||||
&& ev.some((e) => e.type === 'humanoidLost' && e.reason === 'carryTimeout'));
|
|
||||||
}
|
|
||||||
// carried -> lost (player dies)
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 9 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const h = state.humanoids[0];
|
|
||||||
h.status = 'carried'; h.timerMs = 0;
|
|
||||||
state.player.carrying = h.id; state.player.invulnMs = 0;
|
|
||||||
state.player.x = 3000; state.player.y = 400;
|
|
||||||
state.enemies = [{ id: 555, type: 'walker', hp: 3, radius: TUNE.WALKER_RADIUS, x: 3000, y: 400, vx: 0, vy: 0, homeX: 3000, dir: 1, fireCooldownMs: 9999 }];
|
|
||||||
const ev = runTicks(state, 1);
|
|
||||||
check('carried humanoid lost when player dies', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'playerDied'));
|
|
||||||
check('player death event also fired', ev.some((e) => e.type === 'playerDied'));
|
|
||||||
}
|
|
||||||
// illegal transition: can't pick up a second humanoid while already carrying one
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 10 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
const [h1, h2] = state.humanoids;
|
|
||||||
h1.status = 'carried'; h1.timerMs = 0;
|
|
||||||
h2.status = 'falling'; h2.y = 400; h2.vy = 0; h2.timerMs = 0; h2.x = wrap(h1.x);
|
|
||||||
state.player.carrying = h1.id;
|
|
||||||
state.player.x = h2.x; state.player.y = 400; state.player.vx = 0; state.player.vy = 0;
|
|
||||||
runTicks(state, 1);
|
|
||||||
check('already-carrying player cannot pick up a second humanoid', state.player.carrying === h1.id && h2.status === 'falling');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('4. Overdrive meter & combo multiplier');
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 11 });
|
|
||||||
state.phase = 'wave'; state.spawnQueue = [];
|
|
||||||
let readyFired = 0; let startFired = 0; let endFired = 0;
|
|
||||||
let prevMeter = 0; let monotonicOnFill = true;
|
|
||||||
let killsToReady = 0;
|
|
||||||
while (state.overdriveMeter < 1 && killsToReady < 60) {
|
|
||||||
killsToReady += 1;
|
|
||||||
state.enemies = [{ id: 1000 + killsToReady, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: state.player.x, y: state.player.y, vx: 0, vy: 0 }];
|
|
||||||
state.shots = [{ x: state.player.x, y: state.player.y, vx: 0, vy: 0, ttlMs: 500 }];
|
|
||||||
prevMeter = state.overdriveMeter;
|
|
||||||
const ev = runTicks(state, 1);
|
|
||||||
if (state.overdriveMeter < prevMeter) monotonicOnFill = false;
|
|
||||||
readyFired += ev.filter((e) => e.type === 'overdriveReady').length;
|
|
||||||
}
|
|
||||||
check('overdrive meter fills monotonically with kills', monotonicOnFill);
|
|
||||||
check('expected number of kills to fill the meter', killsToReady === Math.ceil(1 / TUNE.OVERDRIVE_FILL_PER_KILL),
|
|
||||||
`took ${killsToReady} kills`);
|
|
||||||
check('overdriveReady fires exactly once at the 1.0 crossing', readyFired === 1, `fired ${readyFired} times`);
|
|
||||||
check('multiplier escalated across the rapid kill chain', state.multiplier > 1, `multiplier=${state.multiplier}`);
|
|
||||||
|
|
||||||
const startEv = runTicks(state, 1, { overdrive: true });
|
|
||||||
startFired = startEv.filter((e) => e.type === 'overdriveStart').length;
|
|
||||||
check('overdriveStart fires exactly once on trigger', startFired === 1 && state.overdriveActive === true);
|
|
||||||
|
|
||||||
const ticksForFullDrain = Math.ceil(TUNE.OVERDRIVE_DURATION_MS / (STEP_MS * TUNE.OVERDRIVE_TIMESCALE)) + 5;
|
|
||||||
const endEv = runTicks(state, ticksForFullDrain, { overdrive: false });
|
|
||||||
endFired = endEv.filter((e) => e.type === 'overdriveEnd').length;
|
|
||||||
check('overdriveEnd fires exactly once after duration elapses', endFired === 1 && state.overdriveActive === false,
|
|
||||||
`fired ${endFired} times, active=${state.overdriveActive}`);
|
|
||||||
|
|
||||||
// combo reset after a gap
|
|
||||||
const state2 = createGame({ seed: 12 });
|
|
||||||
state2.phase = 'wave'; state2.spawnQueue = [];
|
|
||||||
state2.enemies = [{ id: 2001, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: state2.player.x, y: state2.player.y, vx: 0, vy: 0 }];
|
|
||||||
state2.shots = [{ x: state2.player.x, y: state2.player.y, vx: 0, vy: 0, ttlMs: 500 }];
|
|
||||||
runTicks(state2, 1);
|
|
||||||
const multAfterOneKill = state2.multiplier;
|
|
||||||
runTicks(state2, Math.ceil((TUNE.COMBO_WINDOW_MS + 200) / STEP_MS));
|
|
||||||
check('combo multiplier resets after the combo window elapses', multAfterOneKill >= 1 && state2.multiplier === 1,
|
|
||||||
`after=${state2.multiplier}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('5. No entity leaks / bounded arrays across a soak');
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 13 });
|
|
||||||
let maxEnemies = 0; let maxShots = 0; let maxEnemyShots = 0; let maxHumanoids = 0;
|
|
||||||
for (let i = 0; i < 6000; i += 1) {
|
|
||||||
setInput(state, { right: i % 120 < 60, fire: true, overdrive: state.overdriveMeter >= 1 });
|
|
||||||
step(state, STEP_MS);
|
|
||||||
maxEnemies = Math.max(maxEnemies, state.enemies.length);
|
|
||||||
maxShots = Math.max(maxShots, state.shots.length);
|
|
||||||
maxEnemyShots = Math.max(maxEnemyShots, state.enemyShots.length);
|
|
||||||
maxHumanoids = Math.max(maxHumanoids, state.humanoids.length);
|
|
||||||
if (state.over) break;
|
|
||||||
}
|
|
||||||
check('enemy count stays bounded', maxEnemies < 200, `max ${maxEnemies}`);
|
|
||||||
check('player shot count stays bounded', maxShots < 500, `max ${maxShots}`);
|
|
||||||
check('enemy shot count stays bounded', maxEnemyShots < 500, `max ${maxEnemyShots}`);
|
|
||||||
check('humanoid count stays bounded near per-level count', maxHumanoids <= TUNE.HUMANOIDS_PER_LEVEL + 1, `max ${maxHumanoids}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('6. Boss defeat -> exactly one levelComplete, tally correct');
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 14 });
|
|
||||||
state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = [];
|
|
||||||
let ev = runTicks(state, 1); // spawns the boss
|
|
||||||
check('boss spawns from bossIntro', state.boss != null && ev.some((e) => e.type === 'bossSpawn'));
|
|
||||||
state.boss.hp = 1;
|
|
||||||
state.shots = [{ x: state.boss.x, y: state.boss.y, vx: 0, vy: 0, ttlMs: 500 }];
|
|
||||||
ev = runTicks(state, 1);
|
|
||||||
const defeatIdx = ev.findIndex((e) => e.type === 'bossDefeated');
|
|
||||||
const completeCount = ev.filter((e) => e.type === 'levelComplete').length;
|
|
||||||
check('bossDefeated fires', defeatIdx >= 0);
|
|
||||||
check('exactly one levelComplete follows boss defeat', completeCount === 1, `got ${completeCount}`);
|
|
||||||
const complete = ev.find((e) => e.type === 'levelComplete');
|
|
||||||
check('levelComplete tally matches rescued/lost counts', complete
|
|
||||||
&& complete.rescued === state.rescuedThisLevel && complete.lost === state.lostThisLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('7. Spiral-of-death guard');
|
|
||||||
{
|
|
||||||
const state = createGame({ seed: 15 });
|
|
||||||
const before = state.timeMs;
|
|
||||||
step(state, 5000); // a huge delta, e.g. a backgrounded tab waking up
|
|
||||||
const advanced = state.timeMs - before;
|
|
||||||
check('a huge delta only advances MAX_STEPS worth of sim time',
|
|
||||||
advanced <= MAX_STEPS * STEP_MS + 1e-6, `advanced ${advanced}ms`);
|
|
||||||
check('leftover accumulator is discarded rather than replayed', state.accumulatorMs === 0,
|
|
||||||
`accumulatorMs=${state.accumulatorMs}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('8. Determinism');
|
|
||||||
{
|
|
||||||
function scriptedInputAt(i) {
|
|
||||||
const p = i % 240;
|
|
||||||
return {
|
|
||||||
left: p < 40, right: p >= 40 && p < 90, up: p >= 90 && p < 110, down: p >= 150 && p < 170,
|
|
||||||
fire: true, overdrive: p === 200,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function replay(seed, ticks) {
|
|
||||||
const s = createGame({ seed });
|
|
||||||
const log = [];
|
|
||||||
for (let i = 0; i < ticks; i += 1) {
|
|
||||||
setInput(s, scriptedInputAt(i));
|
|
||||||
log.push(...step(s, STEP_MS));
|
|
||||||
}
|
|
||||||
return { s, log };
|
|
||||||
}
|
|
||||||
const a = replay(42, 3000);
|
|
||||||
const b = replay(42, 3000);
|
|
||||||
const same = JSON.stringify(a.log) === JSON.stringify(b.log);
|
|
||||||
check('same seed + same inputs produce identical event streams', same);
|
|
||||||
check('same seed + same inputs produce identical final score', a.s.score === b.s.score, `${a.s.score} vs ${b.s.score}`);
|
|
||||||
check('same seed + same inputs produce identical final state shape',
|
|
||||||
JSON.stringify(a.s) === JSON.stringify(b.s));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('9. Monte-carlo bot soak (all 5 levels)');
|
|
||||||
{
|
|
||||||
function nearestEnemyX(state) {
|
|
||||||
let best = null; let bestD = Infinity;
|
|
||||||
for (const e of state.enemies) {
|
|
||||||
const d = Math.abs(tdelta(state.player.x, e.x));
|
|
||||||
if (d < bestD) { bestD = d; best = e; }
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
function botInput(state) {
|
|
||||||
const p = state.player;
|
|
||||||
let targetX = p.x; let targetY = (Y_GROUND + 400) / 2;
|
|
||||||
if (p.carrying != null) {
|
|
||||||
const zone = state.extractionZones[0];
|
|
||||||
targetX = zone.x; targetY = 400;
|
|
||||||
} else {
|
|
||||||
const falling = state.humanoids.find((h) => h.status === 'falling');
|
|
||||||
if (falling) { targetX = falling.x; targetY = falling.y; }
|
|
||||||
else {
|
|
||||||
const e = nearestEnemyX(state);
|
|
||||||
if (e) { targetX = e.x; targetY = e.y; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dx = tdelta(p.x, targetX);
|
|
||||||
const dy = targetY - p.y;
|
|
||||||
return {
|
|
||||||
left: dx < -8, right: dx > 8, up: dy < -8, down: dy > 8,
|
|
||||||
fire: true, overdrive: state.overdriveMeter >= 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let seedsRun = 0; let victories = 0; let gameOvers = 0;
|
|
||||||
const SEEDS = 8;
|
|
||||||
const MAX_TICKS = 200000; // generous safety valve; a healthy sim finishes well inside this
|
|
||||||
for (let seed = 1; seed <= SEEDS; seed += 1) {
|
|
||||||
const state = createGame({ seed: seed * 1000 + 7 });
|
|
||||||
let ticks = 0;
|
|
||||||
let invariantsOk = true;
|
|
||||||
while (!state.over && ticks < MAX_TICKS) {
|
|
||||||
setInput(state, botInput(state));
|
|
||||||
step(state, STEP_MS);
|
|
||||||
ticks += 1;
|
|
||||||
if (!Number.isFinite(state.player.x) || !Number.isFinite(state.player.y)) invariantsOk = false;
|
|
||||||
if (state.level < 1 || state.level > TUNE.LEVEL_COUNT) invariantsOk = false;
|
|
||||||
if (state.multiplier < 1 || state.multiplier > TUNE.MULT_MAX) invariantsOk = false;
|
|
||||||
if (state.overdriveMeter < 0 || state.overdriveMeter > 1 + 1e-9) invariantsOk = false;
|
|
||||||
if (!invariantsOk) break;
|
|
||||||
}
|
|
||||||
seedsRun += 1;
|
|
||||||
check(`seed ${seed}: invariants held every tick`, invariantsOk);
|
|
||||||
check(`seed ${seed}: run terminated (won or lost) within budget`, state.over, `stopped at ${ticks} ticks, phase=${state.phase}`);
|
|
||||||
if (state.victory) victories += 1;
|
|
||||||
if (state.over && !state.victory) gameOvers += 1;
|
|
||||||
}
|
|
||||||
check('every seed in the soak terminated', seedsRun === SEEDS);
|
|
||||||
console.log(` info: ${victories}/${SEEDS} bot runs reached victory, ${gameOvers}/${SEEDS} ended in game over`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety');
|
|
||||||
{
|
|
||||||
const VALID_MOVES = new Set(['ring', 'spread', 'spiral', 'aimed', 'wall', 'reinforce']);
|
|
||||||
check('one boss profile authored per level', BOSS_PROFILES.length === TUNE.LEVEL_COUNT,
|
|
||||||
`${BOSS_PROFILES.length} profiles vs ${TUNE.LEVEL_COUNT} levels`);
|
|
||||||
|
|
||||||
let prevHp = -Infinity; let prevCooldown = Infinity;
|
|
||||||
let hpMonotonic = true; let cooldownMonotonic = true; let allMovesValid = true; let anyDualPhase = false;
|
|
||||||
for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) {
|
|
||||||
const spec = bossSpec(level);
|
|
||||||
const profile = BOSS_PROFILES[level - 1];
|
|
||||||
if (spec.hp <= prevHp) hpMonotonic = false;
|
|
||||||
prevHp = spec.hp;
|
|
||||||
if (profile.cooldownMs >= prevCooldown) cooldownMonotonic = false;
|
|
||||||
prevCooldown = profile.cooldownMs;
|
|
||||||
for (const m of profile.moves) if (!VALID_MOVES.has(m)) allMovesValid = false;
|
|
||||||
if (profile.phase2Moves) {
|
|
||||||
anyDualPhase = true;
|
|
||||||
for (const m of profile.phase2Moves) if (!VALID_MOVES.has(m)) allMovesValid = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check('boss HP strictly increases level over level', hpMonotonic);
|
|
||||||
check('boss attack cooldown strictly shortens level over level (attacks come faster)', cooldownMonotonic);
|
|
||||||
check('every authored move name is a recognized attack pattern', allMovesValid);
|
|
||||||
check('later levels introduce a second, harder attack phase', anyDualPhase);
|
|
||||||
|
|
||||||
// Live-sim: spawn each level's boss directly and confirm its phase-1 moves cycle
|
|
||||||
// in the declared round-robin order, and phase-2 moves take over once wounded.
|
|
||||||
for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) {
|
|
||||||
const profile = BOSS_PROFILES[level - 1];
|
|
||||||
const state = createGame({ seed: 900 + level, startLevel: level });
|
|
||||||
state.player.invulnMs = 999999; // isolate attack-pattern dispatch from collision outcomes
|
|
||||||
state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = [];
|
|
||||||
runTicks(state, 1); // spawns the boss
|
|
||||||
|
|
||||||
const ticksPerCycle = Math.ceil(profile.cooldownMs / STEP_MS) + 2;
|
|
||||||
const observed = [];
|
|
||||||
for (let i = 0; i < profile.moves.length; i += 1) {
|
|
||||||
const ev = runTicks(state, ticksPerCycle);
|
|
||||||
const mv = ev.find((e) => e.type === 'bossMove');
|
|
||||||
if (mv) observed.push(mv.move);
|
|
||||||
}
|
|
||||||
const matches = observed.length === profile.moves.length && observed.every((m, i) => m === profile.moves[i]);
|
|
||||||
check(`level ${level} (${profile.name}) phase-1 moves cycle in declared order`,
|
|
||||||
matches, `expected ${JSON.stringify(profile.moves)}, observed ${JSON.stringify(observed)}`);
|
|
||||||
|
|
||||||
if (profile.phase2Moves) {
|
|
||||||
state.boss.hp = 1; // force the phase-2 threshold on the next tick
|
|
||||||
const ev2 = runTicks(state, ticksPerCycle);
|
|
||||||
const phaseEv = ev2.find((e) => e.type === 'bossPhaseChange');
|
|
||||||
const mv2 = ev2.find((e) => e.type === 'bossMove');
|
|
||||||
check(`level ${level} (${profile.name}) drops into phase 2 when wounded`, !!phaseEv && phaseEv.phase === 2);
|
|
||||||
check(`level ${level} (${profile.name}) phase-2 move comes from its harder move set`,
|
|
||||||
!!mv2 && profile.phase2Moves.includes(mv2.move), `got ${mv2 && mv2.move}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
if (failures > 0) {
|
|
||||||
console.error(`\n${failures} check(s) FAILED`);
|
|
||||||
process.exit(1);
|
|
||||||
} else {
|
|
||||||
console.log('\nAll checks passed.');
|
|
||||||
}
|
|
||||||
|
|
@ -1,650 +0,0 @@
|
||||||
// Headless verification for Geometry Wars.
|
|
||||||
// node tools/verifyGeometryWars.js
|
|
||||||
// Exits non-zero on any failure.
|
|
||||||
//
|
|
||||||
// 1. Setup per mode (lives, bombs, gun).
|
|
||||||
// 2. Movement: speed cap, diagonal normalisation, arena clamp.
|
|
||||||
// 3. Firing: volley size/rate per weapon tier; pacifism can't shoot.
|
|
||||||
// 4. Kill → score × multiplier, geoms drop, pickup raises multiplier + gun.
|
|
||||||
// 5. Splitter splits into three minis; bombs/deaths never split.
|
|
||||||
// 6. Snake: tail eats bullets, head dies, tail kills the player.
|
|
||||||
// 7. Black hole: dormant until shot, pulls + absorbs, overloads into protons.
|
|
||||||
// 8. Bomb: kills everything for no points, edge-triggered, consumes a bomb.
|
|
||||||
// 9. Death: board wipe, multiplier/gun reset, respawn invulnerability,
|
|
||||||
// game over when the last ship goes.
|
|
||||||
// 10. Extra life / bomb thresholds.
|
|
||||||
// 11. Pacifism gates: crossing detonates, posts kill, blast radius.
|
|
||||||
// 12. Spawning: nothing within the safe radius; warming-up enemies are inert.
|
|
||||||
// 13. Spiral-of-death guard.
|
|
||||||
// 14. Determinism.
|
|
||||||
// 15. Warp grid: settles back to rest, never NaNs.
|
|
||||||
// 16. Particle field: pool cap, NaN-free, hole capture.
|
|
||||||
// 17. Bot soak: many seeds, both modes, invariants every tick.
|
|
||||||
|
|
||||||
import {
|
|
||||||
STEP_MS, MAX_STEPS, ARENA_W, ARENA_H, TUNE, ENEMY_TYPES, WEAPONS,
|
|
||||||
createGame, setInput, step, spawnEnemy, snakeSegments, gateEndpoints,
|
|
||||||
weaponTierFor, runPattern, _internal,
|
|
||||||
} from '../src/games/geometrywars/GeometryWarsLogic.js';
|
|
||||||
import WarpGrid from '../src/games/geometrywars/GeometryWarsGrid.js';
|
|
||||||
import ParticleField, { FX_MAX } from '../src/games/geometrywars/GeometryWarsFx.js';
|
|
||||||
|
|
||||||
let failures = 0;
|
|
||||||
let passes = 0;
|
|
||||||
function check(name, cond, detail = '') {
|
|
||||||
if (cond) { passes += 1; console.log(` ok ${name}`); return; }
|
|
||||||
failures += 1;
|
|
||||||
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runTicks(s, count, input = null) {
|
|
||||||
if (input) setInput(s, input);
|
|
||||||
const events = [];
|
|
||||||
for (let i = 0; i < count; i += 1) events.push(...step(s, STEP_MS));
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A quiet arena: director held off so tests control every spawn.
|
|
||||||
function quiet(mode = 'evolved', seed = 7) {
|
|
||||||
const s = createGame({ seed, mode });
|
|
||||||
s.director.spawnCd = 1e12;
|
|
||||||
s.director.gateCd = 1e12;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ofType = (events, type) => events.filter((e) => e.type === type);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('1. Setup per mode');
|
|
||||||
{
|
|
||||||
const ev = createGame({ seed: 1, mode: 'evolved' });
|
|
||||||
check('evolved starts with 3 ships', ev.lives === 3);
|
|
||||||
check('evolved starts with 3 bombs', ev.bombs === 3);
|
|
||||||
check('evolved starts on the twin stream', ev.weaponTier === 0 && WEAPONS[0].shots.length === 2);
|
|
||||||
check('multiplier starts at 1', ev.multiplier === 1);
|
|
||||||
const pc = createGame({ seed: 1, mode: 'pacifism' });
|
|
||||||
check('pacifism is one ship', pc.lives === 1);
|
|
||||||
check('pacifism has no bombs', pc.bombs === 0);
|
|
||||||
check('unknown mode falls back to evolved', createGame({ mode: 'bogus' }).mode === 'evolved');
|
|
||||||
check('player starts mid-arena', ev.player.x === ARENA_W / 2 && ev.player.y === ARENA_H / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('2. Movement');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
runTicks(s, 90, { moveX: 1, moveY: 0 });
|
|
||||||
const sp = Math.hypot(s.player.vx, s.player.vy);
|
|
||||||
check('reaches but never exceeds top speed', sp <= TUNE.PLAYER_SPEED + 1e-6 && sp > TUNE.PLAYER_SPEED * 0.95, `speed ${sp.toFixed(1)}`);
|
|
||||||
const d = quiet();
|
|
||||||
runTicks(d, 90, { moveX: 1, moveY: 1 });
|
|
||||||
const dsp = Math.hypot(d.player.vx, d.player.vy);
|
|
||||||
check('diagonal is normalised (no √2 boost)', dsp <= TUNE.PLAYER_SPEED + 1e-6, `speed ${dsp.toFixed(1)}`);
|
|
||||||
const c = quiet();
|
|
||||||
runTicks(c, 400, { moveX: -1, moveY: -1 });
|
|
||||||
check('clamped inside arena (top-left)', c.player.x >= TUNE.PLAYER_RADIUS - 1e-9 && c.player.y >= TUNE.PLAYER_RADIUS - 1e-9);
|
|
||||||
runTicks(c, 600, { moveX: 1, moveY: 1 });
|
|
||||||
check('clamped inside arena (bottom-right)', c.player.x <= ARENA_W - TUNE.PLAYER_RADIUS + 1e-9 && c.player.y <= ARENA_H - TUNE.PLAYER_RADIUS + 1e-9);
|
|
||||||
const f = quiet();
|
|
||||||
runTicks(f, 60, { moveX: 0, moveY: 1 });
|
|
||||||
check('ship turns to face its travel direction', Math.abs(f.player.angle - Math.PI / 2) < 0.05, `angle ${f.player.angle.toFixed(3)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('3. Firing');
|
|
||||||
{
|
|
||||||
for (let tier = 0; tier < WEAPONS.length; tier += 1) {
|
|
||||||
const s = quiet();
|
|
||||||
s.weaponTier = tier;
|
|
||||||
const ev = runTicks(s, 1, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
|
|
||||||
check(`tier ${tier} (${WEAPONS[tier].name}) fires ${WEAPONS[tier].shots.length} bullets`, s.bullets.length === WEAPONS[tier].shots.length && ofType(ev, 'shotFired').length === 1,
|
|
||||||
`got ${s.bullets.length}`);
|
|
||||||
}
|
|
||||||
const s = quiet();
|
|
||||||
const ev = runTicks(s, 60, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
|
|
||||||
const volleys = ofType(ev, 'shotFired').length;
|
|
||||||
const expected = Math.floor(1000 / WEAPONS[0].intervalMs) + 1;
|
|
||||||
check('fire rate matches the weapon interval', Math.abs(volleys - expected) <= 1, `${volleys} volleys/s, expected ~${expected}`);
|
|
||||||
const forward = s.bullets.every((b) => b.vx > 0 && Math.abs(b.vy) < Math.abs(b.vx) * 0.1);
|
|
||||||
check('bullets travel toward the aim point', forward);
|
|
||||||
const pc = quiet('pacifism');
|
|
||||||
runTicks(pc, 60, { fire: true, aimX: 0, aimY: 0 });
|
|
||||||
check('pacifism cannot shoot', pc.bullets.length === 0);
|
|
||||||
const wall = quiet();
|
|
||||||
const wev = runTicks(wall, 120, { fire: true, aimX: wall.player.x + 5000, aimY: wall.player.y });
|
|
||||||
check('bullets that reach a wall emit bulletWall', ofType(wev, 'bulletWall').length > 0);
|
|
||||||
check('no bullet survives outside the arena', wall.bullets.every((b) => b.x >= 0 && b.x <= ARENA_W && b.y >= 0 && b.y <= ARENA_H));
|
|
||||||
check('weaponTierFor thresholds', weaponTierFor(1) === 0 && weaponTierFor(10) === 1 && weaponTierFor(40) === 2 && weaponTierFor(100) === 3 && weaponTierFor(99999) === 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('4. Kills, geoms, multiplier');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
s.multiplier = 3;
|
|
||||||
const g = spawnEnemy(s, 'grunt', s.player.x + 300, s.player.y, { spawnMs: 0 });
|
|
||||||
g.vx = 0;
|
|
||||||
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 300, aimY: s.player.y });
|
|
||||||
const kill = ofType(ev, 'enemyKilled')[0];
|
|
||||||
check('grunt killed by bullet', kill && kill.enemyType === 'grunt' && kill.cause === 'bullet');
|
|
||||||
check('points = base × multiplier', kill && kill.points === ENEMY_TYPES.grunt.score * 3, `points ${kill?.points}`);
|
|
||||||
check('score credited', s.score === ENEMY_TYPES.grunt.score * 3);
|
|
||||||
check('kill dropped geoms', s.geoms.length + s.stats.geoms >= ENEMY_TYPES.grunt.geoms);
|
|
||||||
|
|
||||||
const p = quiet();
|
|
||||||
for (let i = 0; i < 9; i += 1) p.geoms.push({ id: 1000 + i, x: p.player.x + 5, y: p.player.y, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
|
|
||||||
const pev = runTicks(p, 2);
|
|
||||||
check('touching geoms collects them', p.multiplier === 10 && p.stats.geoms === 9, `mult ${p.multiplier}`);
|
|
||||||
check('crossing ×10 upgrades the gun', p.weaponTier === 1 && ofType(pev, 'weaponUpgrade').length === 1);
|
|
||||||
check('milestone event at ×10', ofType(pev, 'multiplierMilestone').some((e) => e.multiplier === 10));
|
|
||||||
|
|
||||||
const m = quiet();
|
|
||||||
m.geoms.push({ id: 5, x: m.player.x + 120, y: m.player.y, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
|
|
||||||
runTicks(m, 40);
|
|
||||||
check('geoms within magnet range fly to the ship', m.stats.geoms === 1);
|
|
||||||
|
|
||||||
const e = quiet();
|
|
||||||
e.geoms.push({ id: 6, x: 100, y: 100, vx: 0, vy: 0, lifeMs: 500, spin: 0, dead: false });
|
|
||||||
runTicks(e, 40);
|
|
||||||
check('uncollected geoms expire', e.geoms.length === 0 && e.stats.geoms === 0);
|
|
||||||
|
|
||||||
const cap = quiet();
|
|
||||||
for (let i = 0; i < 200; i += 1) _internal.collectGeom(cap, { dead: false, x: 0, y: 0 });
|
|
||||||
check('peak multiplier tracked', cap.stats.peakMult === 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('5. Splitter');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
const sp = spawnEnemy(s, 'splitter', s.player.x + 400, s.player.y, { spawnMs: 0 });
|
|
||||||
sp.vx = 0;
|
|
||||||
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
|
|
||||||
check('splitter dies to a bullet', ofType(ev, 'enemyKilled').some((k) => k.enemyType === 'splitter'));
|
|
||||||
const minis = ofType(ev, 'enemySpawned').filter((k) => k.enemyType === 'mini');
|
|
||||||
check('splits into exactly three minis', minis.length === 3, `got ${minis.length}`);
|
|
||||||
const b = quiet();
|
|
||||||
spawnEnemy(b, 'splitter', 300, 300, { spawnMs: 0 });
|
|
||||||
_internal.detonateBomb(b);
|
|
||||||
runTicks(b, 1);
|
|
||||||
check('bombed splitter does not split', b.enemies.length === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('6. Snake');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
const sn = spawnEnemy(s, 'snake', s.player.x + 500, s.player.y, { spawnMs: 0, heading: 0 });
|
|
||||||
// Lay its tail out toward the player so shots hit tail before head.
|
|
||||||
sn.trail = sn.trail.map((_, i) => ({ x: sn.x - i * 6, y: sn.y }));
|
|
||||||
sn.heading = 0;
|
|
||||||
const segs = snakeSegments(sn);
|
|
||||||
check('snake exposes its segment count', segs.length === TUNE.SNAKE_SEGMENTS);
|
|
||||||
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 500, aimY: s.player.y });
|
|
||||||
check('tail absorbs bullets', ofType(ev, 'bulletDeflect').length > 0);
|
|
||||||
check('tail hits do not kill the snake', !ofType(ev, 'enemyKilled').some((k) => k.enemyType === 'snake'));
|
|
||||||
|
|
||||||
const h = quiet();
|
|
||||||
// Coming straight at the player, so the head leads and the tail trails away.
|
|
||||||
const head = spawnEnemy(h, 'snake', h.player.x + 300, h.player.y, { spawnMs: 0, heading: Math.PI });
|
|
||||||
head.trail = head.trail.map((_, i) => ({ x: head.x + i * 6, y: head.y }));
|
|
||||||
const hev = runTicks(h, 30, { fire: true, aimX: h.player.x + 300, aimY: h.player.y });
|
|
||||||
check('head shot kills the snake', ofType(hev, 'enemyKilled').some((k) => k.enemyType === 'snake'));
|
|
||||||
|
|
||||||
const t = quiet();
|
|
||||||
const tk = spawnEnemy(t, 'snake', t.player.x + 600, t.player.y + 600, { spawnMs: 0 });
|
|
||||||
tk.trail = tk.trail.map(() => ({ x: t.player.x, y: t.player.y }));
|
|
||||||
tk.trail[0] = { x: tk.x, y: tk.y };
|
|
||||||
const save = TUNE.SNAKE_SEGMENTS;
|
|
||||||
_internal.tick(t);
|
|
||||||
check('touching the tail kills the player', !t.player.alive && t.stats.deaths === 1);
|
|
||||||
check('(segment count unchanged)', TUNE.SNAKE_SEGMENTS === save);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('7. Black hole');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
const hole = spawnEnemy(s, 'blackhole', s.player.x + 500, s.player.y, { spawnMs: 0 });
|
|
||||||
const g = spawnEnemy(s, 'wanderer', hole.x, hole.y - 200, { spawnMs: 0 });
|
|
||||||
const gy0 = g.y;
|
|
||||||
runTicks(s, 20, { fire: false });
|
|
||||||
check('dormant hole does not pull', Math.abs(g.y - gy0) > 0 && !hole.active); // wanderer moves on its own
|
|
||||||
const ev = runTicks(s, 30, { fire: true, aimX: hole.x, aimY: hole.y });
|
|
||||||
check('first hit activates the hole', hole.active && ofType(ev, 'blackHoleActivated').length === 1);
|
|
||||||
check('hits drain hp', hole.hp < TUNE.BLACKHOLE_HP);
|
|
||||||
|
|
||||||
const p = quiet();
|
|
||||||
const h2 = spawnEnemy(p, 'blackhole', 1000, 600, { spawnMs: 0 });
|
|
||||||
h2.active = true;
|
|
||||||
const gr = spawnEnemy(p, 'grunt', 1000, 420, { spawnMs: 0 });
|
|
||||||
p.player.x = 200; p.player.y = 1200; // far away, so the grunt chases elsewhere
|
|
||||||
const d0 = Math.hypot(gr.x - h2.x, gr.y - h2.y);
|
|
||||||
runTicks(p, 10);
|
|
||||||
const d1 = Math.hypot(gr.x - h2.x, gr.y - h2.y);
|
|
||||||
check('active hole pulls enemies in', d1 < d0 || gr.dead, `${d0.toFixed(1)} → ${d1.toFixed(1)}`);
|
|
||||||
runTicks(p, 240);
|
|
||||||
check('hole absorbs what it pulls in (no points)', h2.absorbed >= 1 && p.score === 0);
|
|
||||||
|
|
||||||
const pp = quiet();
|
|
||||||
const h3 = spawnEnemy(pp, 'blackhole', pp.player.x + 250, pp.player.y, { spawnMs: 0 });
|
|
||||||
h3.active = true;
|
|
||||||
const px0 = pp.player.x;
|
|
||||||
runTicks(pp, 10, { moveX: 0, moveY: 0 });
|
|
||||||
check('active hole drags the player', pp.player.x > px0);
|
|
||||||
|
|
||||||
const o = quiet();
|
|
||||||
const h4 = spawnEnemy(o, 'blackhole', 1000, 600, { spawnMs: 0 });
|
|
||||||
h4.active = true;
|
|
||||||
o.player.x = 200; o.player.y = 200;
|
|
||||||
for (let i = 0; i < TUNE.BLACKHOLE_OVERLOAD; i += 1) spawnEnemy(o, 'wanderer', 1000 + 5, 600, { spawnMs: 0 });
|
|
||||||
const oev = runTicks(o, 3);
|
|
||||||
check('overfed hole overloads', ofType(oev, 'blackHoleOverload').length === 1 && !o.enemies.some((e) => e.type === 'blackhole'));
|
|
||||||
check('overload releases protons', o.enemies.filter((e) => e.type === 'proton').length >= 6);
|
|
||||||
|
|
||||||
const k = quiet();
|
|
||||||
const h5 = spawnEnemy(k, 'blackhole', k.player.x + 300, k.player.y, { spawnMs: 0 });
|
|
||||||
h5.active = true; h5.hp = 1; h5.absorbed = 4;
|
|
||||||
k.multiplier = 2;
|
|
||||||
const kev = runTicks(k, 30, { fire: true, aimX: h5.x, aimY: h5.y });
|
|
||||||
const kk = ofType(kev, 'enemyKilled').find((e) => e.enemyType === 'blackhole');
|
|
||||||
check('shot-down hole scores base + 50/absorbed × mult', kk && kk.points === (ENEMY_TYPES.blackhole.score + 200) * 2, `points ${kk?.points}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('8. Bomb');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
for (let i = 0; i < 20; i += 1) spawnEnemy(s, 'grunt', 200 + i * 40, 200, { spawnMs: i % 2 ? 0 : 500 });
|
|
||||||
const ev = runTicks(s, 1, { bomb: true });
|
|
||||||
check('bomb clears every enemy (incl. warming up)', s.enemies.length === 0);
|
|
||||||
check('bomb kills score nothing', s.score === 0 && ofType(ev, 'enemyKilled').every((e) => e.points === 0));
|
|
||||||
check('bomb consumes one bomb', s.bombs === 2 && s.stats.bombsUsed === 1);
|
|
||||||
runTicks(s, 30, { bomb: true });
|
|
||||||
check('holding the button does not chain bombs', s.bombs === 2);
|
|
||||||
runTicks(s, 1, { bomb: false });
|
|
||||||
runTicks(s, 1, { bomb: true });
|
|
||||||
check('release + press fires the next bomb', s.bombs === 1);
|
|
||||||
check('bomb grants a spawn lull', s.director.spawnCd > 0);
|
|
||||||
const z = quiet();
|
|
||||||
z.bombs = 0;
|
|
||||||
spawnEnemy(z, 'grunt', 200, 200, { spawnMs: 0 });
|
|
||||||
runTicks(z, 1, { bomb: true });
|
|
||||||
check('no bombs → nothing happens', z.enemies.length === 1);
|
|
||||||
const pc = quiet('pacifism');
|
|
||||||
pc.bombs = 3;
|
|
||||||
spawnEnemy(pc, 'grunt', 200, 200, { spawnMs: 0 });
|
|
||||||
runTicks(pc, 1, { bomb: true });
|
|
||||||
check('pacifism never bombs', pc.enemies.length === 1 && pc.bombs === 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('9. Death and respawn');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
s.multiplier = 55; s.weaponTier = 2; s.director.clockMs = 100000;
|
|
||||||
for (let i = 0; i < 10; i += 1) spawnEnemy(s, 'wanderer', 300 + i * 50, 300, { spawnMs: 0 });
|
|
||||||
s.geoms.push({ id: 9, x: 50, y: 50, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
|
|
||||||
spawnEnemy(s, 'grunt', s.player.x, s.player.y, { spawnMs: 0 });
|
|
||||||
const ev = runTicks(s, 1);
|
|
||||||
check('touching an enemy kills the player', ofType(ev, 'playerDied').length === 1 && !s.player.alive);
|
|
||||||
check('a ship is lost', s.lives === 2);
|
|
||||||
check('board wiped for no points', s.enemies.length === 0 && s.score === 0);
|
|
||||||
check('geoms cleared', s.geoms.length === 0);
|
|
||||||
check('multiplier and gun reset', s.multiplier === 1 && s.weaponTier === 0);
|
|
||||||
check('difficulty rewinds', s.director.clockMs < 100000);
|
|
||||||
const rev = runTicks(s, Math.ceil(TUNE.RESPAWN_MS / STEP_MS) + 1);
|
|
||||||
check('respawns after the delay', s.player.alive && ofType(rev, 'playerRespawned').length === 1);
|
|
||||||
check('respawn is invulnerable', s.player.invulnMs > 0);
|
|
||||||
spawnEnemy(s, 'grunt', s.player.x, s.player.y, { spawnMs: 0 });
|
|
||||||
runTicks(s, 2);
|
|
||||||
check('invulnerable ship survives contact', s.player.alive);
|
|
||||||
|
|
||||||
const warm = quiet();
|
|
||||||
spawnEnemy(warm, 'grunt', warm.player.x, warm.player.y, { spawnMs: 400 });
|
|
||||||
runTicks(warm, 3);
|
|
||||||
check('warming-up enemies are harmless', warm.player.alive);
|
|
||||||
|
|
||||||
const last = quiet();
|
|
||||||
last.lives = 1;
|
|
||||||
spawnEnemy(last, 'grunt', last.player.x, last.player.y, { spawnMs: 0 });
|
|
||||||
runTicks(last, 1);
|
|
||||||
check('last ship lost → no respawn', !last.player.alive && last.lives === 0);
|
|
||||||
const gev = runTicks(last, Math.ceil(TUNE.RESPAWN_MS / STEP_MS) + 2);
|
|
||||||
const go = ofType(gev, 'gameOver');
|
|
||||||
check('game over emitted exactly once', go.length === 1 && last.phase === 'over');
|
|
||||||
check('game over carries score + stats', go[0] && go[0].score === last.score && go[0].stats.deaths === 1);
|
|
||||||
check('a finished game stays finished', runTicks(last, 60).length === 0 && last.phase === 'over');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('10. Extra lives and bombs');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
s.score = TUNE.FIRST_LIFE_AT;
|
|
||||||
const ev = runTicks(s, 1);
|
|
||||||
check('first life threshold awards a ship', s.lives === 4 && ofType(ev, 'extraLife').length === 1);
|
|
||||||
check('next life threshold grows', s.nextLifeAt === Math.round(TUNE.FIRST_LIFE_AT * TUNE.THRESHOLD_GROWTH));
|
|
||||||
s.score = TUNE.FIRST_BOMB_AT;
|
|
||||||
const bev = runTicks(s, 1);
|
|
||||||
check('bomb threshold awards a bomb', s.bombs === 4 && ofType(bev, 'extraBomb').length === 1);
|
|
||||||
const big = quiet();
|
|
||||||
big.score = 1e12;
|
|
||||||
runTicks(big, 1);
|
|
||||||
check('lives capped', big.lives === TUNE.MAX_LIVES);
|
|
||||||
check('bombs capped', big.bombs === TUNE.MAX_BOMBS);
|
|
||||||
check('thresholds advanced past the score', big.nextLifeAt > big.score && big.nextBombAt > big.score);
|
|
||||||
const pc = quiet('pacifism');
|
|
||||||
pc.score = 1e9;
|
|
||||||
runTicks(pc, 1);
|
|
||||||
check('pacifism awards no extra ships', pc.lives === 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('11. Pacifism gates');
|
|
||||||
{
|
|
||||||
const s = quiet('pacifism');
|
|
||||||
const g = _internal.spawnGate(s);
|
|
||||||
check('gate spawns', !!g && s.gates.length === 1);
|
|
||||||
g.spawnMs = 0; g.vx = 0; g.vy = 0; g.spinRate = 0; g.angle = Math.PI / 2; // vertical
|
|
||||||
g.x = 1000; g.y = 600;
|
|
||||||
s.player.x = 900; s.player.y = 600;
|
|
||||||
s.multiplier = 2;
|
|
||||||
const near = spawnEnemy(s, 'grunt', 1000, 600 + TUNE.GATE_BLAST_R - 40, { spawnMs: 0 });
|
|
||||||
const far = spawnEnemy(s, 'grunt', 1000, 600 + TUNE.GATE_BLAST_R + 200, { spawnMs: 0 });
|
|
||||||
near.vx = 0; far.vx = 0;
|
|
||||||
const ev = runTicks(s, 30, { moveX: 1, moveY: 0 });
|
|
||||||
check('flying through the middle detonates the gate', ofType(ev, 'gateTriggered').length === 1 && s.gates.length === 0);
|
|
||||||
check('blast kills enemies inside its radius', near.dead && ofType(ev, 'enemyKilled').some((k) => k.id === near.id && k.cause === 'gate'));
|
|
||||||
check('blast spares enemies outside it', !far.dead);
|
|
||||||
check('gate kills score × multiplier', s.score === ENEMY_TYPES.grunt.score * 2, `score ${s.score}`);
|
|
||||||
check('player survives a clean pass', s.player.alive);
|
|
||||||
check('gates counted', s.stats.gates === 1);
|
|
||||||
|
|
||||||
const p = quiet('pacifism');
|
|
||||||
const g2 = _internal.spawnGate(p);
|
|
||||||
g2.spawnMs = 0; g2.vx = 0; g2.vy = 0; g2.spinRate = 0; g2.angle = 0;
|
|
||||||
g2.x = 1000; g2.y = 600;
|
|
||||||
const [a] = gateEndpoints(g2);
|
|
||||||
p.player.x = a.x; p.player.y = a.y - 4;
|
|
||||||
runTicks(p, 1);
|
|
||||||
check('touching a gate post kills', !p.player.alive);
|
|
||||||
|
|
||||||
const d = createGame({ seed: 3, mode: 'pacifism' });
|
|
||||||
d.player.invulnMs = 1e12;
|
|
||||||
runTicks(d, 60 * 20);
|
|
||||||
check('director keeps gates on the field', d.stats.gates > 0 || d.gates.length >= 3, `gates ${d.gates.length}`);
|
|
||||||
check('pacifism director spawns enemies', d.enemies.length > 0 || d.stats.deaths > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('12. Spawning');
|
|
||||||
{
|
|
||||||
let tooClose = 0; let total = 0;
|
|
||||||
for (const seed of [1, 2, 3, 4, 5, 6]) {
|
|
||||||
const s = createGame({ seed });
|
|
||||||
s.player.invulnMs = 1e12;
|
|
||||||
s.director.clockMs = 200000; // everything unlocked
|
|
||||||
for (let i = 0; i < 1500; i += 1) {
|
|
||||||
s.player.x = 300 + ((i * 37) % (ARENA_W - 600));
|
|
||||||
s.player.y = 300 + ((i * 53) % (ARENA_H - 600));
|
|
||||||
const ev = step(s, STEP_MS);
|
|
||||||
for (const e of ev) {
|
|
||||||
if (e.type !== 'enemySpawned' || e.enemyType === 'mini' || e.enemyType === 'proton') continue;
|
|
||||||
total += 1;
|
|
||||||
if (Math.hypot(e.x - s.player.x, e.y - s.player.y) < TUNE.SPAWN_SAFE_DIST - 1) tooClose += 1;
|
|
||||||
}
|
|
||||||
s.enemies.length = Math.min(s.enemies.length, 120);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check('director spawns plenty', total > 500, `spawned ${total}`);
|
|
||||||
check('nothing spawns inside the safe radius', tooClose === 0, `${tooClose}/${total} too close`);
|
|
||||||
for (const pattern of ['scatter', 'corners', 'swarm', 'ring', 'rocketwall', 'blackhole', 'snakes']) {
|
|
||||||
const s = quiet('evolved', 11);
|
|
||||||
s.director.clockMs = 200000;
|
|
||||||
runPattern(s, pattern);
|
|
||||||
const inside = s.enemies.every((e) => e.x >= e.radius - 1e-6 && e.x <= ARENA_W - e.radius + 1e-6 && e.y >= e.radius - 1e-6 && e.y <= ARENA_H - e.radius + 1e-6);
|
|
||||||
check(`pattern '${pattern}' spawns inside the arena`, s.enemies.length > 0 && inside, `count ${s.enemies.length}`);
|
|
||||||
}
|
|
||||||
const cap = quiet();
|
|
||||||
for (let i = 0; i < 400; i += 1) spawnEnemy(cap, 'grunt', 100, 100);
|
|
||||||
check('enemy cap respected', cap.enemies.length === TUNE.MAX_ENEMIES);
|
|
||||||
const bh = quiet();
|
|
||||||
bh.director.clockMs = 200000;
|
|
||||||
for (let i = 0; i < 10; i += 1) runPattern(bh, 'blackhole');
|
|
||||||
check('black hole count capped', bh.enemies.filter((e) => e.type === 'blackhole').length <= TUNE.BLACKHOLE_MAX);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('13. Spiral-of-death guard');
|
|
||||||
{
|
|
||||||
const s = quiet();
|
|
||||||
const t0 = s.tick;
|
|
||||||
step(s, 10_000);
|
|
||||||
check('huge delta runs at most MAX_STEPS ticks', s.tick - t0 === MAX_STEPS, `ran ${s.tick - t0}`);
|
|
||||||
const u = quiet();
|
|
||||||
step(u, -50); step(u, NaN);
|
|
||||||
check('negative/NaN deltas are ignored', u.tick === 0 && u.acc === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('14. Determinism');
|
|
||||||
{
|
|
||||||
const run = () => {
|
|
||||||
const s = createGame({ seed: 4242 });
|
|
||||||
let sig = 0;
|
|
||||||
for (let i = 0; i < 60 * 90; i += 1) {
|
|
||||||
const a = i * 0.013;
|
|
||||||
setInput(s, {
|
|
||||||
moveX: Math.cos(a), moveY: Math.sin(a * 1.3),
|
|
||||||
aimX: s.player.x + Math.cos(a * 3) * 300, aimY: s.player.y + Math.sin(a * 3) * 300,
|
|
||||||
fire: true, bomb: i % 900 === 0,
|
|
||||||
});
|
|
||||||
const ev = step(s, STEP_MS * (1 + (i % 3) * 0.37));
|
|
||||||
sig = (sig * 31 + ev.length + s.enemies.length * 7 + s.score) % 1e15;
|
|
||||||
}
|
|
||||||
return { sig, score: s.score, id: s.nextId, x: s.player.x, mult: s.multiplier, deaths: s.stats.deaths };
|
|
||||||
};
|
|
||||||
const a = run(); const b = run();
|
|
||||||
check('same seed + inputs → identical run', JSON.stringify(a) === JSON.stringify(b), `${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
|
|
||||||
const c = createGame({ seed: 1 }); const d = createGame({ seed: 2 });
|
|
||||||
for (let i = 0; i < 600; i += 1) { step(c, STEP_MS); step(d, STEP_MS); }
|
|
||||||
check('different seeds diverge', JSON.stringify(c.enemies.map((e) => [e.x, e.y])) !== JSON.stringify(d.enemies.map((e) => [e.x, e.y])));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('15. Warp grid');
|
|
||||||
{
|
|
||||||
const g = new WarpGrid(ARENA_W, ARENA_H, 40);
|
|
||||||
for (let i = 0; i < 30; i += 1) g.update();
|
|
||||||
check('grid starts at rest', g.maxDisplacement() < 1e-6);
|
|
||||||
g.applyExplosiveForce(40, 1000, 600, 900);
|
|
||||||
g.applyDirectedForce(0, 0, 150, 1000, 600, 300);
|
|
||||||
for (let i = 0; i < 300; i += 1) g.applyImplosiveForce(4, 1500, 800, 240), g.update();
|
|
||||||
const disp = g.maxDisplacement();
|
|
||||||
check('forces displace the grid', disp > 5, `disp ${disp.toFixed(2)}`);
|
|
||||||
let finite = true;
|
|
||||||
for (let i = 0; i < g.n; i += 1) if (!Number.isFinite(g.px[i]) || !Number.isFinite(g.py[i]) || !Number.isFinite(g.pz[i])) finite = false;
|
|
||||||
check('grid stays finite under load', finite);
|
|
||||||
check('grid stays bounded under a sustained hole', disp < 400, `disp ${disp.toFixed(1)}`);
|
|
||||||
for (let i = 0; i < 60 * 30; i += 1) g.update();
|
|
||||||
check('grid settles back to rest', g.maxDisplacement() < 1.5, `disp ${g.maxDisplacement().toFixed(3)}`);
|
|
||||||
let pinned = true;
|
|
||||||
for (let r = 0; r < g.rows; r += 1) for (let c = 0; c < g.cols; c += 1) {
|
|
||||||
if (r !== 0 && c !== 0 && r !== g.rows - 1 && c !== g.cols - 1) continue;
|
|
||||||
const i = r * g.cols + c;
|
|
||||||
if (g.px[i] !== g.ox[i] || g.py[i] !== g.oy[i]) pinned = false;
|
|
||||||
}
|
|
||||||
check('border masses stay pinned', pinned);
|
|
||||||
// Draw against a recording stub: every coordinate must be finite.
|
|
||||||
const calls = { n: 0, bad: 0 };
|
|
||||||
const stub = {
|
|
||||||
lineStyle() {}, beginPath() {}, strokePath() {},
|
|
||||||
moveTo(x, y) { calls.n += 1; if (!Number.isFinite(x) || !Number.isFinite(y)) calls.bad += 1; },
|
|
||||||
lineTo(x, y) { calls.n += 1; if (!Number.isFinite(x) || !Number.isFinite(y)) calls.bad += 1; },
|
|
||||||
};
|
|
||||||
g.applyExplosiveForce(30, 600, 400, 300);
|
|
||||||
g.update();
|
|
||||||
g.draw(stub, { x: 160, y: 100, w: 1920, h: 1080 }, { minor: 1, minorAlpha: 1, minorWidth: 1, major: 1, majorAlpha: 1, majorWidth: 2, hot: 1, hotAlpha: 1, hotter: 1, hotterAlpha: 1 });
|
|
||||||
check('grid draw emits finite coordinates', calls.n > 1000 && calls.bad === 0, `${calls.n} calls, ${calls.bad} bad`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('16. Particle field');
|
|
||||||
{
|
|
||||||
const fx = new ParticleField({ w: ARENA_W, h: ARENA_H });
|
|
||||||
for (let i = 0; i < 20; i += 1) fx.explosion(1000, 600, 0xff00ff, { count: 300 });
|
|
||||||
check('pool never exceeds FX_MAX', fx.liveCount() <= FX_MAX);
|
|
||||||
for (let i = 0; i < 30; i += 1) fx.update(16.7, [{ x: 1100, y: 600, r: 26 }]);
|
|
||||||
let ok = true;
|
|
||||||
for (let i = 0; i < FX_MAX; i += 1) {
|
|
||||||
if (fx.life[i] <= 0) continue;
|
|
||||||
if (!Number.isFinite(fx.x[i]) || !Number.isFinite(fx.y[i])) ok = false;
|
|
||||||
if (fx.x[i] < -1 || fx.x[i] > ARENA_W + 1 || fx.y[i] < -1 || fx.y[i] > ARENA_H + 1) ok = false;
|
|
||||||
}
|
|
||||||
check('particles stay finite and inside the arena', ok);
|
|
||||||
const before = fx.liveCount();
|
|
||||||
for (let i = 0; i < 200; i += 1) fx.update(16.7, [{ x: 1000, y: 600, r: 60 }]);
|
|
||||||
check('particles expire', fx.liveCount() < before);
|
|
||||||
fx.ring(0, 0, 100, 300, 0xffffff);
|
|
||||||
fx.update(400);
|
|
||||||
check('rings expire', fx.rings.length === 0);
|
|
||||||
fx.wallSparks(0, 500, Math.PI, 0xffff00, 10);
|
|
||||||
fx.update(16.7);
|
|
||||||
let rightward = 0;
|
|
||||||
for (let i = 0; i < FX_MAX; i += 1) if (fx.life[i] > 0 && fx.vx[i] > 0) rightward += 1;
|
|
||||||
check('wall sparks bounce back into the arena', rightward >= 8);
|
|
||||||
const calls = { bad: 0 };
|
|
||||||
const stub = {
|
|
||||||
lineStyle() {}, beginPath() {}, strokePath() {}, strokeCircle() {},
|
|
||||||
moveTo(x, y) { if (!Number.isFinite(x + y)) calls.bad += 1; },
|
|
||||||
lineTo(x, y) { if (!Number.isFinite(x + y)) calls.bad += 1; },
|
|
||||||
};
|
|
||||||
fx.explosion(500, 500, 0x00ffff, { count: 100 });
|
|
||||||
fx.draw(stub, { x: 0, y: 0, w: 1920, h: 1080 });
|
|
||||||
check('particle draw emits finite coordinates', calls.bad === 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
console.log('17. Bot soak');
|
|
||||||
{
|
|
||||||
// A dodging bot with human-ish limits: it only re-decides every `latency`
|
|
||||||
// ticks, aims with deterministic jitter, and only reacts to enemies inside
|
|
||||||
// `react` px. It aims at the nearest enemy, strafes away from anything
|
|
||||||
// close and bombs when crowded. Two profiles bracket the difficulty curve;
|
|
||||||
// invariants are checked every tick of every run.
|
|
||||||
const PROFILES = {
|
|
||||||
sharp: { latency: 10, noise: 0.15, react: 260 },
|
|
||||||
sloppy: { latency: 18, noise: 0.2, react: 220 },
|
|
||||||
};
|
|
||||||
function makeBot(profile, seed) {
|
|
||||||
let r = seed * 7919 + 13;
|
|
||||||
const jitter = () => { r = (r * 1103515245 + 12345) % 2147483648; return r / 2147483648 - 0.5; };
|
|
||||||
let held = null; let tick = 0;
|
|
||||||
return (s) => {
|
|
||||||
tick += 1;
|
|
||||||
if (held && tick % profile.latency !== 0) return held;
|
|
||||||
const p = s.player;
|
|
||||||
let nearest = null; let nd = Infinity;
|
|
||||||
let ax = 0; let ay = 0;
|
|
||||||
for (const e of s.enemies) {
|
|
||||||
const dx = e.x - p.x; const dy = e.y - p.y;
|
|
||||||
const d = Math.hypot(dx, dy) || 1;
|
|
||||||
if (d < nd && e.spawnMs <= 0) { nd = d; nearest = e; }
|
|
||||||
if (d < profile.react) { ax -= dx / d * (profile.react - d); ay -= dy / d * (profile.react - d); }
|
|
||||||
}
|
|
||||||
for (const g of s.gates) {
|
|
||||||
const dx = g.x - p.x; const dy = g.y - p.y;
|
|
||||||
const d = Math.hypot(dx, dy) || 1;
|
|
||||||
ax += dx / d * 120; ay += dy / d * 120;
|
|
||||||
for (const end of gateEndpoints(g)) {
|
|
||||||
const ex = end.x - p.x; const ey = end.y - p.y;
|
|
||||||
const ed = Math.hypot(ex, ey) || 1;
|
|
||||||
if (ed < 90) { ax -= ex / ed * (90 - ed) * 12; ay -= ey / ed * (90 - ed) * 12; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ax += (ARENA_W / 2 - p.x) * 0.08; ay += (ARENA_H / 2 - p.y) * 0.08;
|
|
||||||
const close = s.enemies.filter((e) => Math.hypot(e.x - p.x, e.y - p.y) < 140).length;
|
|
||||||
const aim = (nearest ? Math.atan2(nearest.y - p.y, nearest.x - p.x) : 0) + jitter() * 2 * profile.noise;
|
|
||||||
held = {
|
|
||||||
moveX: Math.sign(ax) * Math.min(1, Math.abs(ax) / 50),
|
|
||||||
moveY: Math.sign(ay) * Math.min(1, Math.abs(ay) / 50),
|
|
||||||
aimX: p.x + Math.cos(aim) * 300, aimY: p.y + Math.sin(aim) * 300,
|
|
||||||
fire: true,
|
|
||||||
bomb: close >= 4,
|
|
||||||
};
|
|
||||||
return held;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const runs = [
|
|
||||||
...[11, 22, 33, 44].map((seed) => ({ mode: 'evolved', profile: 'sharp', seed })),
|
|
||||||
...[11, 22, 33, 44].map((seed) => ({ mode: 'evolved', profile: 'sloppy', seed })),
|
|
||||||
...[11, 22, 33, 44].map((seed) => ({ mode: 'pacifism', profile: 'sharp', seed })),
|
|
||||||
];
|
|
||||||
const results = [];
|
|
||||||
let violations = [];
|
|
||||||
const seenTypes = new Set();
|
|
||||||
for (const { mode, profile, seed } of runs) {
|
|
||||||
const s = createGame({ seed, mode });
|
|
||||||
const bot = makeBot(PROFILES[profile], seed);
|
|
||||||
let lastScore = 0;
|
|
||||||
let firstDeathMs = null;
|
|
||||||
const limit = 60 * 60 * 8; // 8 simulated minutes
|
|
||||||
let ticks = 0;
|
|
||||||
const tag = `${mode}/${profile}/${seed}`;
|
|
||||||
while (s.phase !== 'over' && ticks < limit) {
|
|
||||||
setInput(s, bot(s));
|
|
||||||
const ev = step(s, STEP_MS);
|
|
||||||
ticks += 1;
|
|
||||||
if (firstDeathMs == null && ev.some((e) => e.type === 'playerDied')) firstDeathMs = s.timeMs;
|
|
||||||
if (s.score < lastScore) violations.push(`${tag}: score went down`);
|
|
||||||
lastScore = s.score;
|
|
||||||
if (s.enemies.length > TUNE.MAX_ENEMIES + 40) violations.push(`${tag}: enemy overflow ${s.enemies.length}`);
|
|
||||||
if (s.bullets.length > TUNE.MAX_BULLETS) violations.push(`${tag}: bullet overflow`);
|
|
||||||
if (s.geoms.length > TUNE.MAX_GEOMS) violations.push(`${tag}: geom overflow`);
|
|
||||||
if (s.multiplier < 1 || s.lives < 0 || s.bombs < 0) violations.push(`${tag}: negative counters`);
|
|
||||||
if (mode === 'pacifism' && s.bullets.length) violations.push(`${tag}: pacifism bullet`);
|
|
||||||
if (ticks % 30 === 0) {
|
|
||||||
for (const e of s.enemies) {
|
|
||||||
seenTypes.add(e.type);
|
|
||||||
if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) violations.push(`${tag}: NaN ${e.type}`);
|
|
||||||
if (e.x < -1 || e.x > ARENA_W + 1 || e.y < -1 || e.y > ARENA_H + 1) violations.push(`${tag}: ${e.type} escaped arena`);
|
|
||||||
}
|
|
||||||
const p = s.player;
|
|
||||||
if (!Number.isFinite(p.x) || p.x < 0 || p.x > ARENA_W || p.y < 0 || p.y > ARENA_H) violations.push(`${tag}: player escaped`);
|
|
||||||
if (s.enemies.some((e) => e.dead) || s.bullets.some((b) => b.dead)) violations.push(`${tag}: dead entity kept`);
|
|
||||||
}
|
|
||||||
if (violations.length > 20) break;
|
|
||||||
}
|
|
||||||
results.push({
|
|
||||||
mode, profile, seed, over: s.phase === 'over', mins: s.timeMs / 60000,
|
|
||||||
firstDeath: firstDeathMs == null ? null : firstDeathMs / 60000,
|
|
||||||
score: s.score, kills: s.stats.kills, peak: s.stats.peakMult, deaths: s.stats.deaths, gates: s.stats.gates,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
violations = [...new Set(violations)];
|
|
||||||
for (const r of results) {
|
|
||||||
console.log(` ${r.mode.padEnd(8)} ${r.profile.padEnd(6)} seed ${String(r.seed).padStart(2)}: ${r.over ? 'over ' : 'alive'} at ${r.mins.toFixed(1)}m`
|
|
||||||
+ `, first death ${r.firstDeath == null ? '—' : `${r.firstDeath.toFixed(1)}m`}, deaths ${r.deaths}, score ${formatInt(r.score)}, peak ×${r.peak}`
|
|
||||||
+ `${r.mode === 'pacifism' ? `, gates ${r.gates}` : ''}`);
|
|
||||||
}
|
|
||||||
const median = (xs) => { const m = [...xs].sort((a, b) => a - b); return m[Math.floor(m.length / 2)]; };
|
|
||||||
const sharp = results.filter((r) => r.mode === 'evolved' && r.profile === 'sharp');
|
|
||||||
const sloppy = results.filter((r) => r.mode === 'evolved' && r.profile === 'sloppy');
|
|
||||||
check('no invariant violations across the soak', violations.length === 0, violations.slice(0, 6).join('; '));
|
|
||||||
check('every run terminates or hits the time limit', results.length === runs.length);
|
|
||||||
check('evolved bots score', [...sharp, ...sloppy].every((r) => r.score > 0));
|
|
||||||
check('pacifism bot detonates gates', results.filter((r) => r.mode === 'pacifism').some((r) => r.gates > 0));
|
|
||||||
const allTypes = ['wanderer', 'grunt', 'weaver', 'splitter', 'snake', 'blackhole', 'rocket'];
|
|
||||||
check('every evolved enemy type appears in play', allTypes.every((t) => seenTypes.has(t)), `missing: ${allTypes.filter((t) => !seenTypes.has(t)).join(',')}`);
|
|
||||||
check('not too hard: sharp bot median run ≥ 3 min', median(sharp.map((r) => r.mins)) >= 3, `median ${median(sharp.map((r) => r.mins)).toFixed(2)}m`);
|
|
||||||
check('not too easy: sloppy bot loses a ship in most runs', sloppy.filter((r) => r.deaths > 0).length >= 3);
|
|
||||||
check('not too easy: sloppy bot first death before 4 min (median)',
|
|
||||||
median(sloppy.map((r) => r.firstDeath ?? 99)) < 4, `median ${median(sloppy.map((r) => r.firstDeath ?? 99)).toFixed(2)}m`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatInt(n) { return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
|
|
||||||
|
|
||||||
console.log(`\n${passes} passed, ${failures} failed`);
|
|
||||||
process.exit(failures ? 1 : 0);
|
|
||||||
Loading…
Reference in New Issue