feat: add Star Control space combat game

- Register Star Control in games registry as 2-player arcade game
- Import and register StarControlGame scene
- Add starcontrol slug mapping in GameRoomScene dispatch
- Load star-control-ships.json asset data in PreloadScene
- Add dynamic ship sprite sheet loading in asset manifest
- Update README to reflect new game in Arcade/Console/PC category
- Update game icons to include Star Control icon (frame 82)
This commit is contained in:
Brian Fertig 2026-07-14 22:45:56 -06:00
parent 8399cc69db
commit 3f1d534ec2
42 changed files with 5740 additions and 2 deletions

View File

@ -90,7 +90,7 @@ menu categories:
| **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines |
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble |
| **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter |
| **Arcade, Console & PC** | 1 | Colorado Defense |
| **Arcade, Console & PC** | 2 | Colorado Defense, Star Control |
Each game is a self-contained `Phaser.Scene` (plus its own logic/AI/data helper
modules) under `src/games/<slug>/`. Games are single-player against 07

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

1478
data/star-control-ships.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -130,6 +130,17 @@ export const MANIFEST = {
// Optional drop-in art — spec in assets/gamedata/peggle/sprites.md. Missing
// files 404 harmlessly and the game renders procedurally: circles for
// pegs/ball, gradient starfield for the board background.
// Ship rotation sheets are described by each ship's `sprite` blocks in
// data/star-control-ships.json (written by tools/fetchStarControlAssets.js).
// Ships without a sheet render as procedural vector chevrons, so a missing
// file is harmless and new JSON ships need no manifest edit.
starcontrol: (scene) => {
const cfg = scene.cache.json.get('star-control-ships');
return Object.values(cfg?.ships ?? {})
.flatMap((s) => [s.sprite, s.projectileSprite, ...(s.forms ?? []).map((f) => f.sprite)])
.filter((sp) => sp?.sheet && sp.frameWidth > 0 && sp.frameHeight > 0)
.map((sp) => sheet(sp.sheet, `assets/images/starcontrol/${sp.sheet}.png`, sp.frameWidth, sp.frameHeight));
},
peggle: [
sheet('peggle-sprites', 'assets/images/peggle-sprites.png', 64, 64),
// One board background per master, named by opponent id

View File

@ -109,3 +109,4 @@ registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: tr
registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 79 });
registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 });
registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 });
registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, iconFrame: 82 });

View File

@ -0,0 +1,371 @@
// Real-time computer pilot for Star Control Super Melee. No Phaser
// dependency. Modeled on BlockFighterAI: skill 1-10 interpolates between
// anchor knob sets, and the pilot re-decides its inputs only every reactMs,
// so low skill reacts like a slow human rather than a lobotomized one.
//
// Layered decision, first match wins the steering vote:
// 1. survival — don't fall into the planet, dodge incoming tracked fire
// 2. range — close/hold/open distance per the ship's engageStyle
// 3. aim+fire — intercept lead with skill-scaled aim error
// 4. special — per-ship tactic script keyed by ai.specialUse in the JSON
import {
ARENA_W, ARENA_H, TUNE,
mulberry32, tdelta, tdist, angleDelta, interceptAngle, quantizeFacing,
facingToRad, enemyOf,
} from './StarControlLogic.js';
export const SKILL_ANCHORS = [
{ skill: 1, reactMs: 550, aimErrDeg: 22, blunder: 0.35, lead: 0.0, gravityIQ: 0.2, dodge: 0.0 },
{ skill: 3, reactMs: 400, aimErrDeg: 14, blunder: 0.20, lead: 0.4, gravityIQ: 0.5, dodge: 0.25 },
{ skill: 5, reactMs: 280, aimErrDeg: 8, blunder: 0.10, lead: 1.0, gravityIQ: 0.8, dodge: 0.55 },
{ skill: 7, reactMs: 190, aimErrDeg: 4, blunder: 0.04, lead: 1.0, gravityIQ: 1.0, dodge: 0.8 },
{ skill: 10, reactMs: 110, aimErrDeg: 1, blunder: 0.0, lead: 1.0, gravityIQ: 1.0, dodge: 1.0 },
];
export function knobsFor(skill) {
const s = Math.max(1, Math.min(10, skill));
let lo = SKILL_ANCHORS[0];
let hi = SKILL_ANCHORS[SKILL_ANCHORS.length - 1];
for (const a of SKILL_ANCHORS) {
if (a.skill <= s && a.skill >= lo.skill) lo = a;
if (a.skill >= s && a.skill < hi.skill) hi = a;
}
if (lo.skill === hi.skill) return { ...lo };
const t = (s - lo.skill) / (hi.skill - lo.skill);
const mix = {};
for (const k of Object.keys(lo)) mix[k] = lo[k] + (hi[k] - lo[k]) * t;
mix.skill = s;
return mix;
}
export function createAI({ skill = 5, side = 1, seed = 1 } = {}) {
return {
side,
skill,
knobs: knobsFor(skill),
rng: mulberry32((seed * 2654435761) >>> 0),
clockMs: 0,
nextDecisionMs: 0,
input: { left: false, right: false, thrust: false, fire: false, special: false },
lastKnown: null, // last visible enemy position (cloak memory)
aimBias: 0, // current aim-error sample, re-rolled per decision
};
}
// --- steering helper: sets left/right toward a desired absolute heading.
function steerTo(ai, ship, heading, deadzone) {
const d = angleDelta(ship.facing, heading);
ai.input.left = d < -deadzone;
ai.input.right = d > deadzone;
return Math.abs(d);
}
function primaryReach(def) {
const p = def.primary;
if (p.type === 'beam') return p.range * 4;
if (p.maxRange) return p.maxRange * 4 * 0.8; // hitscan bolts (lightning)
if (p.type === 'cone') return (p.muzzleDist ?? 32) * 4 + (p.radius ?? 120);
return (p.muzzleDist ?? 12) * 4 + (p.speed ?? 0) * (p.lifeFrames ?? 0);
}
// Nearest live enemy projectile that is closing on the ship.
function incomingThreat(state, ship) {
let best = null;
let bestD = Infinity;
for (const p of state.projectiles) {
if (!p.alive || p.owner === ship.side || p.kind === 'unit') continue;
const d = tdist(ship.x, ship.y, p.x, p.y);
if (d > 1600 || d >= bestD) continue;
const closing = tdelta(p.x, ship.x, ARENA_W) * p.vx + tdelta(p.y, ship.y, ARENA_H) * p.vy;
if (closing > 0) { best = p; bestD = d; }
}
return best;
}
// Predicts whether coasting for `frames` brings the ship dangerously close to
// the planet.
function planetDanger(state, ship, frames) {
let { x, y } = ship;
const { vx, vy } = ship;
const danger = state.planet.radius + ship.def.radius + 260;
for (let f = 0; f < frames; f += 8) {
x += vx * 8; y += vy * 8;
if (tdist(x, y, state.planet.x, state.planet.y) < danger) return true;
}
return false;
}
// --- special-ability tactics, keyed by the ship JSON's ai.specialUse.
const SPECIAL_TACTICS = {
pointDefenseAuto(ai, state, ship) {
const range = (ship.def.special.params?.range ?? 100) * 4 * 1.15;
for (const p of state.projectiles) {
if (p.alive && p.owner !== ship.side && tdist(ship.x, ship.y, p.x, p.y) < range) return true;
}
return false;
},
gloryFinale(ai, state, ship) {
const enemy = enemyOf(state, ship);
const range = (ship.def.special.params?.range ?? 180) * 4;
const dist = tdist(ship.x, ship.y, enemy.x, enemy.y);
// Boom when point-blank, or when nearly dead and still in blast range.
return dist < range * 0.45 || (ship.crew <= 2 && dist < range * 0.85);
},
swarm(ai, state, ship) {
const enemy = enemyOf(state, ship);
const out = state.projectiles.filter((p) => p.alive && p.type === 'fighter' && p.owner === ship.side).length;
return ship.crew > 8 && out < 4
&& ship.energy > ship.def.special.energyCost + 10
&& tdist(ship.x, ship.y, enemy.x, enemy.y) < 4200;
},
cloakApproach(ai, state, ship) {
const enemy = enemyOf(state, ship);
const dist = tdist(ship.x, ship.y, enemy.x, enemy.y);
if (ship.fx.cloaked) return false; // stay dark until the flame flies
return dist > 1400;
},
kiteRear(ai, state, ship) {
const enemy = enemyOf(state, ship);
const dist = tdist(ship.x, ship.y, enemy.x, enemy.y);
if (dist > 1600) return false;
const bearing = Math.atan2(tdelta(ship.y, enemy.y, ARENA_H), tdelta(ship.x, enemy.x, ARENA_W));
return Math.abs(angleDelta(ship.facing, bearing)) > 1.9; // enemy behind us
},
onCooldown(ai, state, ship) {
return ship.energy >= ship.def.stats.maxEnergy * 0.85;
},
never() { return false; },
blazer(ai, state, ship) {
const enemy = enemyOf(state, ship);
const dist = tdist(ship.x, ship.y, enemy.x, enemy.y);
if (ship.fx.blazer) return ship.energy < 3 || dist > 3200; // toggle back off
return dist < 2600 && ship.energy > ship.def.stats.maxEnergy * 0.5;
},
teleportEscape(ai, state, ship) {
if (incomingThreat(state, ship) && ai.rng() < 0.5) return true;
const enemy = enemyOf(state, ship);
return ship.crew <= 2 && tdist(ship.x, ship.y, enemy.x, enemy.y) < 900;
},
tractor(ai, state, ship) {
const enemy = enemyOf(state, ship);
const d = tdist(ship.x, ship.y, enemy.x, enemy.y);
return d < 3600 && d > 500 && ship.energy > 10;
},
furnace(ai, state, ship) {
return ship.crew > 3 && ship.energy < (ship.def.primary.energyCost ?? 4) + 2;
},
fireRingReactive(ai, state, ship) {
if (ship.fx.friedFrames > 0) return false;
const range = (ship.def.special.params?.range ?? 520) * 0.9;
for (const p of state.projectiles) {
if (p.alive && p.owner !== ship.side && p.kind !== 'unit'
&& tdist(ship.x, ship.y, p.x, p.y) < range) return true;
}
const enemy = enemyOf(state, ship);
return tdist(ship.x, ship.y, enemy.x, enemy.y) < range;
},
transformRange(ai, state, ship) {
const enemy = enemyOf(state, ship);
const dist = tdist(ship.x, ship.y, enemy.x, enemy.y);
const inYForm = ship.fx.formIndex === 1;
if (!inYForm && dist > 3000) return true; // Y-wing to close the gap
if (inYForm && dist < 1300) return true; // X-wing for the knife fight
return false;
},
regen(ai, state, ship) {
return ship.crew <= ship.def.stats.maxCrew - (ship.def.special.params?.crew ?? 4)
&& ship.energy >= ship.def.stats.maxEnergy;
},
marines(ai, state, ship) {
const enemy = enemyOf(state, ship);
return ship.crew > 4 && tdist(ship.x, ship.y, enemy.x, enemy.y) < 1500;
},
taunt(ai, state, ship) {
return ship.energy <= ship.def.stats.maxEnergy - (ship.def.special.params?.energy ?? 2);
},
harvest(ai, state, ship) {
if (ship.energy > ship.def.stats.maxEnergy - 8) return false;
const range = (ship.def.special.params?.range ?? 78) * 4 * 1.2;
return state.asteroids.some((a) => a.alive && tdist(ship.x, ship.y, a.x, a.y) < range);
},
song(ai, state, ship) {
const enemy = enemyOf(state, ship);
return enemy.crew > 2
&& tdist(ship.x, ship.y, enemy.x, enemy.y) < (ship.def.special.params?.range ?? 208) * 4 * 0.9;
},
burner(ai, state, ship) {
// Drive-by: light the afterburner when charging roughly at the enemy so
// the napalm trail crosses their path.
const enemy = enemyOf(state, ship);
if (ship.energy < 6) return false;
const dist = tdist(ship.x, ship.y, enemy.x, enemy.y);
const bearing = Math.atan2(
tdelta(ship.y, enemy.y, ARENA_H),
tdelta(ship.x, enemy.x, ARENA_W),
);
return dist > 400 && dist < 2400 && Math.abs(angleDelta(ship.facing, bearing)) < 0.5;
},
retro(ai, state, ship) {
// Hop backward only when the enemy is on the Drone's tail — it can't
// out-turn anyone, but it can out-reverse everyone.
const enemy = enemyOf(state, ship);
if (ship.energy < 2) return false;
const dist = tdist(ship.x, ship.y, enemy.x, enemy.y);
const bearing = Math.atan2(
tdelta(ship.y, enemy.y, ARENA_H),
tdelta(ship.x, enemy.x, ARENA_W),
);
return dist < 700 && Math.abs(angleDelta(ship.facing, bearing)) > 1.9;
},
shieldReactive(ai, state, ship) {
if (ship.fx.shieldFrames > 0 || ship.fx.absorbFrames > 0) return false;
const enemy = enemyOf(state, ship);
if (tdist(ship.x, ship.y, enemy.x, enemy.y) < 600) return true;
const threat = incomingThreat(state, ship);
return !!threat && tdist(ship.x, ship.y, threat.x, threat.y) < 700;
},
tongueRange(ai, state, ship) {
const enemy = enemyOf(state, ship);
const reach = (ship.def.special.params?.range ?? 35) * 4 + ship.def.radius + enemy.def.radius;
return tdist(ship.x, ship.y, enemy.x, enemy.y) < reach * 1.1;
},
};
// Advances the pilot by dtMs and returns the input object to feed setInput.
export function updateAI(ai, state, dtMs) {
ai.clockMs += dtMs;
if (ai.clockMs < ai.nextDecisionMs) return ai.input;
ai.nextDecisionMs = ai.clockMs + ai.knobs.reactMs;
const ship = state.ships[ai.side];
const enemy = enemyOf(state, ship);
const input = ai.input;
input.left = input.right = input.thrust = input.fire = input.special = false;
if (!ship?.alive || state.over) return input;
const rng = ai.rng;
const K = ai.knobs;
ai.aimBias = ((rng() - 0.5) * 2 * K.aimErrDeg * Math.PI) / 180;
// Cloak memory: track the last place we actually saw the enemy.
if (!enemy.fx.cloaked) ai.lastKnown = { x: enemy.x, y: enemy.y, vx: enemy.vx, vy: enemy.vy };
const target = enemy.fx.cloaked && ai.lastKnown ? ai.lastKnown : enemy;
const dist = tdist(ship.x, ship.y, target.x, target.y);
const bearing = Math.atan2(
tdelta(ship.y, target.y, ARENA_H),
tdelta(ship.x, target.x, ARENA_W),
);
const hints = ship.def.ai ?? {};
const style = hints.engageStyle ?? 'chase';
const preferred = hints.preferredRange ?? 1200;
// ---- 1. survival
if (K.gravityIQ > 0.05 && planetDanger(state, ship, 40 + 60 * K.gravityIQ)
&& (hints.gravityCaution ?? 1) > rng() * 0.4) {
// Thrust perpendicular to the planet bearing to swing wide.
const pb = Math.atan2(
tdelta(ship.y, state.planet.y, ARENA_H),
tdelta(ship.x, state.planet.x, ARENA_W),
);
const escape = pb + (angleDelta(pb, Math.atan2(ship.vy, ship.vx)) > 0 ? Math.PI / 2 : -Math.PI / 2);
steerTo(ai, ship, escape, ship.def.turnPerFrame * 1.5);
input.thrust = true;
maybeSpecial(ai, state, ship, hints);
return blunder(ai, input);
}
// Harvest ships (Slylandro Probe): when the battery runs low, graze the
// asteroid field instead of pressing a fight it can't shoot.
if (hints.specialUse === 'harvest' && ship.energy < ship.def.stats.maxEnergy * 0.3) {
let rock = null;
let rockD = Infinity;
for (const a of state.asteroids) {
if (!a.alive) continue;
const d = tdist(ship.x, ship.y, a.x, a.y);
if (d < rockD) { rockD = d; rock = a; }
}
if (rock) {
steerTo(ai, ship, Math.atan2(
tdelta(ship.y, rock.y, ARENA_H),
tdelta(ship.x, rock.x, ARENA_W),
), ship.def.turnPerFrame);
input.thrust = true;
maybeSpecial(ai, state, ship, hints);
return blunder(ai, input);
}
}
const threat = K.dodge > 0 ? incomingThreat(state, ship) : null;
if (threat && rng() < K.dodge) {
const tb = Math.atan2(threat.vy, threat.vx);
steerTo(ai, ship, tb + Math.PI / 2, ship.def.turnPerFrame * 1.5);
input.thrust = true;
maybeSpecial(ai, state, ship, hints);
return blunder(ai, input);
}
// ---- 2 + 3. range control and aim/fire
const reach = primaryReach(ship.def);
const shotSpeed = ship.def.primary.speed ?? 999;
const lead = K.lead >= 1 ? target
: { x: target.x, y: target.y, vx: (target.vx ?? 0) * K.lead, vy: (target.vy ?? 0) * K.lead };
const aimAngle = interceptAngle(ship.x, ship.y, lead, shotSpeed) + ai.aimBias;
let wantThrust = false;
let heading = aimAngle;
if (style === 'kite' && dist < preferred * 0.62) {
heading = bearing + Math.PI; // run away
wantThrust = true;
} else if (style === 'ambush' && ship.fx.cloaked) {
heading = bearing; // sneak straight in, guns cold
wantThrust = true;
} else if (dist > preferred) {
heading = aimAngle; // close in along the firing solution
wantThrust = dist > preferred * 0.8;
} else if (style === 'chase') {
heading = aimAngle;
wantThrust = dist > preferred * 0.45;
}
const offAim = steerTo(ai, ship, heading, ship.def.turnPerFrame * 0.6);
input.thrust = wantThrust;
// Fire when the (quantized) nose is on the solution and the shot can reach.
const noseErr = Math.abs(angleDelta(facingToRad(quantizeFacing(ship.facing)), aimAngle));
const reserve = hints.specialUse === 'pointDefenseAuto' ? ship.def.special.energyCost : 0;
const canAfford = ship.energy >= ship.def.primary.energyCost + reserve;
const inRange = dist < reach + enemy.def.radius;
const holdForCloak = ship.fx.cloaked && dist > 700; // don't blow cover early
if (canAfford && inRange && !holdForCloak && noseErr < 0.38 && offAim < 1.2
&& !(enemy.fx.cloaked && dist > 900)) {
input.fire = true;
}
// Charge weapons (Melnorme pump-up): keep pumping while far or unaimed,
// release once the shot is charged and the nose is on target.
if (ship.fx.pump) {
const maxLevel = (ship.fx.formPrimary ?? ship.def.primary).maxLevel ?? 4;
const charged = ship.fx.pump.level >= Math.min(2, maxLevel);
if (!charged || !inRange || noseErr > 0.3) input.fire = true;
if (charged && inRange && noseErr <= 0.3) input.fire = false;
}
// ---- 4. special
maybeSpecial(ai, state, ship, hints);
return blunder(ai, input);
}
function maybeSpecial(ai, state, ship, hints) {
const tactic = SPECIAL_TACTICS[hints.specialUse] ?? SPECIAL_TACTICS.onCooldown;
if (ship.energy < ship.def.special.energyCost) return;
if (tactic(ai, state, ship)) ai.input.special = true;
}
function blunder(ai, input) {
if (ai.rng() < ai.knobs.blunder) {
const roll = ai.rng();
if (roll < 0.34) { input.left = !input.left; input.right = false; } else if (roll < 0.67) {
input.thrust = !input.thrust;
} else { input.fire = false; }
}
return input;
}

View File

@ -0,0 +1,123 @@
// Fleet definitions, point math and AI fleet decisions for Star Control
// Super Melee. No Phaser dependency.
//
// A fleet is an ordered array of ship keys (duplicates allowed), max
// FLEET_MAX ships, compared by summed Super Melee point values. Preset lists
// may reference ships that haven't been added to the JSON yet;
// materializeFleet() drops those and reports them, so the game stays playable
// while the roster grows and verify can insist on zero missing once complete.
export const FLEET_MAX = 14;
export const PRESET_FLEETS = [
{
id: 'alliance',
name: 'The Alliance of Free Stars',
blurb: 'Chmmr-led coalition of the New Alliance races.',
ships: [
'chmmr', 'chenjesu', 'mmrnmhrm', 'earthling', 'yehat', 'shofixti', 'pkunk',
'arilou', 'syreen', 'utwig', 'supox', 'orz', 'zoqfot', 'thraddash',
],
},
{
id: 'hierarchy',
name: 'The Ur-Quan Hierarchy',
blurb: 'Battle thralls of the Path of Now and Forever.',
ships: [
'urquan', 'urquan', 'kohrah', 'spathi', 'ilwrath', 'ilwrath', 'vux',
'mycon', 'umgah', 'umgah', 'androsynth', 'druuge', 'melnorme', 'slylandro',
],
},
{
id: 'heavy',
name: 'Heavy Metal',
blurb: 'Maximum tonnage. Slow, huge, devastating.',
ships: ['chmmr', 'urquan', 'kohrah', 'chenjesu', 'utwig', 'mycon', 'orz', 'yehat'],
},
{
id: 'wolfpack',
name: 'Wolf Pack',
blurb: 'Fourteen cheap knives. Death by a thousand cuts.',
ships: [
'shofixti', 'shofixti', 'umgah', 'umgah', 'zoqfot', 'zoqfot', 'ilwrath',
'ilwrath', 'thraddash', 'thraddash', 'vux', 'syreen', 'supox', 'earthling',
],
},
{
id: 'speed',
name: 'Speed Demons',
blurb: 'Nothing in the fleet flies below a scream.',
ships: [
'pkunk', 'pkunk', 'arilou', 'arilou', 'spathi', 'spathi', 'slylandro',
'slylandro', 'supox', 'supox', 'shofixti', 'shofixti', 'zoqfot', 'syreen',
],
},
];
export function fleetPoints(SHIPS, keys) {
return keys.reduce((sum, k) => sum + (SHIPS[k]?.pointValue ?? 0), 0);
}
// Filters a wishlist against the compiled roster.
export function materializeFleet(SHIPS, keys) {
const entries = [];
const missing = [];
for (const k of keys) (SHIPS[k] ? entries : missing).push(k);
return { entries: entries.slice(0, FLEET_MAX), missing, points: fleetPoints(SHIPS, entries) };
}
export function validateFleet(SHIPS, keys) {
if (!Array.isArray(keys) || keys.length === 0) return { ok: false, reason: 'empty fleet' };
if (keys.length > FLEET_MAX) return { ok: false, reason: `more than ${FLEET_MAX} ships` };
for (const k of keys) if (!SHIPS[k]) return { ok: false, reason: `unknown ship "${k}"` };
return { ok: true };
}
// Seeded random fleet within a point budget ("Grab Bag").
export function randomFleet(SHIPS, rng, pointCap = 200) {
const pool = Object.keys(SHIPS);
const keys = [];
let points = 0;
for (let guard = 0; guard < 200 && keys.length < FLEET_MAX; guard += 1) {
const k = pool[Math.floor(rng() * pool.length)];
if (points + SHIPS[k].pointValue > pointCap) continue;
keys.push(k);
points += SHIPS[k].pointValue;
if (points >= pointCap - 4) break;
}
if (keys.length === 0) keys.push(pool[0]);
return keys;
}
// Style-vs-style edge table for the AI's counter-pick. Rows: my style,
// columns: enemy style — positive means favored.
const STYLE_EDGE = {
chase: { chase: 0, kite: -1, snipe: 1, ambush: 1, ram: 0 },
kite: { chase: 1, kite: 0, snipe: -1, ambush: 1, ram: 1 },
snipe: { chase: -1, kite: 1, snipe: 0, ambush: -1, ram: 1 },
ambush: { chase: -1, kite: -1, snipe: 1, ambush: 0, ram: 0 },
ram: { chase: 0, kite: -1, snipe: -1, ambush: 0, ram: 0 },
};
// Picks the AI's next ship from its remaining fleet, given the enemy's
// current ship (or null on the opening pick). Simple heuristic: prefer a
// style edge, spend big hulls early, save a finisher.
export function aiCounterPick(SHIPS, remainingKeys, enemyKey, rng) {
if (remainingKeys.length === 0) return null;
const enemy = enemyKey ? SHIPS[enemyKey] : null;
let best = remainingKeys[0];
let bestScore = -Infinity;
for (const k of remainingKeys) {
const def = SHIPS[k];
let score = def.pointValue * 0.1 + rng() * 2;
if (enemy) {
const mine = def.ai?.engageStyle ?? 'chase';
const theirs = enemy.ai?.engageStyle ?? 'chase';
score += (STYLE_EDGE[mine]?.[theirs] ?? 0) * 3;
// Big crew answers big crew; don't waste a scout on a dreadnought.
if (enemy.stats.maxCrew >= 30 && def.stats.maxCrew <= 8) score -= 4;
}
if (score > bestScore) { bestScore = score; best = k; }
}
return best;
}

View File

@ -0,0 +1,964 @@
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 { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import {
ARENA_W, ARENA_H, ZOOM, TUNE, STEP_MS,
mulberry32, wrap, tdelta, tdist, tmid, targetZoom, smoothTowards,
compileShips, createBattle, setInput, stepMatch, quantizeFacing, facingToRad,
} from './StarControlLogic.js';
import { createAI, updateAI } from './StarControlAI.js';
import {
FLEET_MAX, PRESET_FLEETS, materializeFleet, fleetPoints, randomFleet, aiCounterPick,
} from './StarControlFleets.js';
const D = { world: 0, fx: 5, hud: 30, overlay: 60 };
const BEST_KEY = 'starcontrol-best';
const FONT = 'm6x11, "Julius Sans One"';
// Side palettes: P1 cool green (Alliance flavor), P2 hot red (Hierarchy).
const SIDE_COLORS = [
{ hull: 0x74f5a1, glow: 0x2e8f57, hex: '#74f5a1' },
{ hull: 0xf4726c, glow: 0x93312d, hex: '#f4726c' },
];
// sfxClass strings in the ships JSON -> loaded SFX keys.
const SFX_CLASS = {
pellet: SFX.SCIFI_PLINK,
flame: SFX.WOOSH,
missile: SFX.SCIFI_LAUNCH,
heavy: SFX.SCIFI_PLONK,
zap: SFX.LASER_ZAP,
launch: SFX.SCIFI_REVEAL,
warp: SFX.SCIFI_RISER,
explode: SFX.SCIFI_EXPLODE,
};
const MODES = [
{ id: 'ai', label: '1P VS COMPUTER' },
{ id: 'hotseat', label: '2P HOTSEAT' },
{ id: 'watch', label: 'WATCH AI VS AI' },
];
export default class StarControlGame extends Phaser.Scene {
constructor() { super('StarControlGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'starcontrol', name: 'Star Control' };
this.view = null;
this.match = null;
this.paused = false;
}
create() {
const json = this.cache.json.get('star-control-ships');
this.SHIPS = compileShips(json ?? { ships: {} });
this.shipKeys = Object.keys(this.SHIPS);
this.credits = json?.credits ?? '';
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x03060e).setDepth(-10);
this.gWorld = this.add.graphics().setDepth(D.world);
this.gFx = this.add.graphics().setDepth(D.fx);
this.hudRoot = this.add.container(0, 0).setDepth(D.hud);
this.viewRoot = null;
// Fixed seeded starfield in world space, two parallax layers.
const rng = mulberry32(20260714);
this.stars = [[], []];
for (let i = 0; i < 240; i += 1) {
this.stars[i % 2].push({
x: rng() * ARENA_W, y: rng() * ARENA_H,
size: 1 + rng() * 2.2, tone: 0.35 + rng() * 0.65,
});
}
this.cam = { x: ARENA_W / 2, y: ARENA_H / 2, zoom: ZOOM.FAR };
this.fxList = [];
this.lastSfxAt = {};
this.deathHoldMs = 0;
this.deathFocus = null;
this.keys = this.input.keyboard.addKeys(
'W,A,S,D,Z,X,UP,DOWN,LEFT,RIGHT,COMMA,PERIOD',
);
this.escHandler = () => this.onEscape();
this.input.keyboard.on('keydown-ESC', this.escHandler);
this.crt = applyArcadeCRTOverlay(this, {
accentTint: 0x74f5a1, scanlineTint: 0x2e8f57,
});
try {
const music = this.cache.json.get('music');
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.events.once('shutdown', () => {
this.crt.destroy();
this.input.keyboard.off('keydown-ESC', this.escHandler);
});
// Match configuration defaults (setup view edits these).
this.mode = 'ai';
this.skills = [5, 5];
this.fleetChoice = [0, 1]; // index into fleet options per side
this.customFleets = [null, null]; // custom builder results (task: setup UI)
this.showSetup();
}
// =========================================================================
// View plumbing
clearView() {
this.viewRoot?.destroy(true);
this.viewRoot = this.add.container(0, 0).setDepth(D.overlay - 10);
this.builderRoot?.destroy(true);
this.builderRoot = null;
this.hudRoot.removeAll(true);
this.gWorld.clear();
this.gFx.clear();
this.shipSprites?.forEach((s) => s?.destroy());
this.shipSprites = null;
}
mkText(x, y, str, size, color = '#e8f4e8', origin = 0.5) {
return this.add.text(x, y, str, {
fontFamily: FONT, fontSize: `${size}px`, color,
}).setOrigin(origin);
}
fleetOptions(side) {
const opts = PRESET_FLEETS.map((p) => ({
id: p.id, name: p.name, blurb: p.blurb,
resolve: () => materializeFleet(this.SHIPS, p.ships).entries,
}));
opts.push({
id: 'grab', name: 'Grab Bag', blurb: 'A random armada within a 200-point budget.',
resolve: () => randomFleet(this.SHIPS, mulberry32((Date.now() % 100000) + 7), 200),
});
opts.push({
id: 'custom', name: 'Custom Fleet', blurb: 'Hand-pick up to 14 ships (duplicates allowed).',
resolve: () => (this.customFleets[side]?.length
? this.customFleets[side].slice(0, FLEET_MAX)
: materializeFleet(this.SHIPS, PRESET_FLEETS[side === 0 ? 0 : 1].ships).entries),
});
return opts;
}
// =========================================================================
// SETUP view
showSetup() {
this.view = 'setup';
this.match = null;
this.clearView();
const root = this.viewRoot;
const cx = GAME_WIDTH / 2;
root.add(this.mkText(cx, 90, 'STAR CONTROL', 84, '#74f5a1'));
root.add(this.mkText(cx, 152, 'SUPER MELEE', 40, '#9adbb2'));
// Mode row.
this.modeButtons = [];
MODES.forEach((m, i) => {
const b = this.textButton(cx + (i - 1) * 380, 240, m.label, () => {
this.mode = m.id;
this.refreshSetup();
});
this.modeButtons.push({ b, id: m.id });
root.add(b);
});
// Fleet panels per side.
this.fleetPanels = [];
[0, 1].forEach((side) => {
const px = side === 0 ? 480 : GAME_WIDTH - 480;
const panel = this.add.container(px, 560);
const label = this.mkText(0, -250, side === 0 ? 'PLAYER 1' : 'PLAYER 2', 34, SIDE_COLORS[side].hex);
const nameT = this.mkText(0, -190, '', 38);
const blurbT = this.mkText(0, -150, '', 20, '#89a6a0');
const listT = this.mkText(0, 40, '', 22, '#c7e6cf');
listT.setLineSpacing(6).setAlign('center');
const ptsT = this.mkText(0, 236, '', 26, '#e7d27b');
const prev = this.textButton(-260, -190, '<', () => this.cycleFleet(side, -1));
const next = this.textButton(260, -190, '>', () => this.cycleFleet(side, 1));
const skillLabel = this.mkText(0, 290, '', 24, '#9adbb2');
const minus = this.textButton(-90, 290, '-', () => this.bumpSkill(side, -1));
const plus = this.textButton(90, 290, '+', () => this.bumpSkill(side, 1));
const edit = this.textButton(0, 236, 'EDIT FLEET', () => this.showFleetBuilder(side), 22);
panel.add([label, nameT, blurbT, listT, ptsT, prev, next, skillLabel, minus, plus, edit]);
root.add(panel);
this.fleetPanels.push({ panel, nameT, blurbT, listT, ptsT, skillLabel, minus, plus, edit });
});
const engage = this.textButton(cx, 900, 'ENGAGE', () => this.beginMatch(), 46);
root.add(engage);
const menu = this.textButton(120, 60, '< MENU', () => this.scene.start('GameMenu'), 22);
root.add(menu);
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
if (best > 0) root.add(this.mkText(cx, 960, `BEST FLEET SCORE ${best}`, 20, '#e7d27b'));
root.add(this.mkText(cx, GAME_HEIGHT - 26,
'Ship data & art: The Ur-Quan Masters (Star Control II) © Toys for Bob / UQM project — CC BY-NC-SA 2.5',
16, '#5d7a72'));
this.refreshSetup();
}
textButton(x, y, label, cb, size = 26) {
const t = this.add.text(x, y, label, {
fontFamily: FONT, fontSize: `${size}px`, color: '#d9f7e2',
backgroundColor: '#12241c', padding: { x: 18, y: 10 },
}).setOrigin(0.5);
t.setInteractive({ useHandCursor: true });
t.on('pointerover', () => t.setColor('#74f5a1'));
t.on('pointerout', () => t.setStyle({ color: t.getData('sel') ? '#0a130d' : '#d9f7e2' }));
t.on('pointerdown', () => { playSound(this, SFX.EIGHTBIT_ACTION); cb(); });
return t;
}
cycleFleet(side, dir) {
const n = this.fleetOptions(side).length;
this.fleetChoice[side] = ((this.fleetChoice[side] + dir) % n + n) % n;
this.refreshSetup();
}
bumpSkill(side, dir) {
this.skills[side] = Math.max(1, Math.min(10, this.skills[side] + dir));
this.refreshSetup();
}
refreshSetup() {
if (this.view !== 'setup') return;
for (const { b, id } of this.modeButtons) {
const sel = id === this.mode;
b.setData('sel', sel);
b.setStyle({ color: sel ? '#0a130d' : '#d9f7e2', backgroundColor: sel ? '#74f5a1' : '#12241c' });
}
[0, 1].forEach((side) => {
const opts = this.fleetOptions(side);
const opt = opts[this.fleetChoice[side]];
const keys = opt.resolve();
const p = this.fleetPanels[side];
p.nameT.setText(opt.name);
p.blurbT.setText(opt.blurb);
const counts = {};
for (const k of keys) counts[k] = (counts[k] ?? 0) + 1;
p.listT.setText(Object.entries(counts)
.map(([k, n]) => `${this.SHIPS[k].name}${n > 1 ? ` x${n}` : ''}`)
.join('\n'));
p.ptsT.setText(`${keys.length} SHIPS — ${fleetPoints(this.SHIPS, keys)} PTS`);
p.ptsT.setY(opt.id === 'custom' ? 200 : 236);
p.edit.setVisible(opt.id === 'custom');
const isAI = this.sideIsAI(side);
p.skillLabel.setText(isAI ? `AI SKILL ${this.skills[side]}` : (side === 0 ? 'W A D + Z X' : 'ARROWS + , .'));
p.minus.setVisible(isAI);
p.plus.setVisible(isAI);
});
}
sideIsAI(side) {
if (this.mode === 'watch') return true;
if (this.mode === 'hotseat') return false;
return side === 1;
}
// ---- Custom fleet builder overlay -------------------------------------
showFleetBuilder(side) {
this.builderRoot?.destroy(true);
this.builderList = (this.customFleets[side] ?? []).slice();
const root = this.add.container(0, 0).setDepth(D.overlay);
this.builderRoot = root;
const cx = GAME_WIDTH / 2;
root.add(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x02070c, 0.94)
.setInteractive()); // swallow clicks behind the overlay
root.add(this.mkText(cx, 64, `PLAYER ${side + 1} — CUSTOM FLEET`, 44, SIDE_COLORS[side].hex));
root.add(this.mkText(cx, 112, `CLICK SHIPS TO ADD (MAX ${FLEET_MAX}, DUPLICATES ALLOWED) — CLICK A SLOT TO REMOVE`, 20, '#89a6a0'));
// Roster grid.
const keys = this.shipKeys.slice().sort((a, b) => this.SHIPS[a].name.localeCompare(this.SHIPS[b].name));
const cols = 5;
keys.forEach((key, i) => {
const def = this.SHIPS[key];
const x = cx + ((i % cols) - (cols - 1) / 2) * 350;
const y = 190 + Math.floor(i / cols) * 78;
const b = this.textButton(x, y, `${def.name} · ${def.pointValue}`, () => {
if (this.builderList.length >= FLEET_MAX) return;
this.builderList.push(key);
this.refreshBuilder(side);
}, 20);
root.add(b);
});
this.builderSlotsRoot = this.add.container(0, 0);
root.add(this.builderSlotsRoot);
this.builderTotalT = this.mkText(cx, 900, '', 26, '#e7d27b');
root.add(this.builderTotalT);
root.add(this.textButton(cx - 220, 990, 'CLEAR', () => {
this.builderList = [];
this.refreshBuilder(side);
}, 26));
root.add(this.textButton(cx, 990, 'DONE', () => {
if (this.builderList.length === 0) return;
this.customFleets[side] = this.builderList.slice();
root.destroy(true);
this.builderRoot = null;
this.refreshSetup();
}, 26));
root.add(this.textButton(cx + 220, 990, 'CANCEL', () => {
root.destroy(true);
this.builderRoot = null;
}, 26));
this.refreshBuilder(side);
}
refreshBuilder(side) {
const cx = GAME_WIDTH / 2;
this.builderSlotsRoot.removeAll(true);
this.builderList.forEach((key, i) => {
const x = cx + ((i % 7) - 3) * 250;
const y = 800 + Math.floor(i / 7) * 52;
const chip = this.textButton(x, y, `${this.SHIPS[key].name.split(' ').pop()} ${this.SHIPS[key].pointValue}`, () => {
this.builderList.splice(i, 1);
this.refreshBuilder(side);
}, 18);
chip.setStyle({ backgroundColor: '#1c3327', color: SIDE_COLORS[side].hex });
this.builderSlotsRoot.add(chip);
});
this.builderTotalT.setText(
`${this.builderList.length}/${FLEET_MAX} SHIPS — ${fleetPoints(this.SHIPS, this.builderList)} PTS TOTAL`,
);
}
// =========================================================================
// Match / fleet flow
beginMatch() {
this.fleets = [0, 1].map((side) => {
const keys = this.fleetOptions(side)[this.fleetChoice[side]].resolve();
return keys.map((key) => ({ key, crew: null, energy: null, dead: false }));
});
if (this.fleets.some((f) => f.length === 0)) return;
this.ais = [0, 1].map((side) => (this.sideIsAI(side)
? createAI({ skill: this.skills[side], side, seed: (Date.now() % 65521) + side })
: null));
this.current = [null, null];
this.needPick = [true, true];
this.destroyedPoints = [0, 0];
this.carry = null;
this.matchSeed = (Date.now() % 2147483647) | 1;
this.showShipPick();
}
aliveFleet(side) {
return this.fleets[side].map((e, i) => ({ ...e, i })).filter((e) => !e.dead);
}
showShipPick() {
this.view = 'shipPick';
this.clearView();
// AI sides pick instantly.
[0, 1].forEach((side) => {
if (!this.needPick[side] || !this.sideIsAI(side)) return;
const remaining = this.aliveFleet(side);
const enemyIdx = this.current[1 - side];
const enemyKey = this.needPick[1 - side] ? null : this.fleets[1 - side][enemyIdx]?.key;
const pick = aiCounterPick(this.SHIPS, remaining.map((e) => e.key), enemyKey, Math.random);
const entry = remaining.find((e) => e.key === pick);
this.current[side] = entry.i;
this.needPick[side] = false;
});
const humanSide = [0, 1].find((s) => this.needPick[s]);
if (humanSide === undefined) { this.startBattle(); return; }
const root = this.viewRoot;
const cx = GAME_WIDTH / 2;
root.add(this.mkText(cx, 90,
`${this.mode === 'hotseat' ? `PLAYER ${humanSide + 1}` : 'COMMANDER'} — CHOOSE YOUR SHIP`,
44, SIDE_COLORS[humanSide].hex));
const enemyIdx = this.current[1 - humanSide];
if (enemyIdx !== null && !this.needPick[1 - humanSide]) {
const ek = this.fleets[1 - humanSide][enemyIdx].key;
root.add(this.mkText(cx, 148, `ENEMY FIELDS: ${this.SHIPS[ek].name.toUpperCase()}`, 26, SIDE_COLORS[1 - humanSide].hex));
}
const remaining = this.aliveFleet(humanSide);
const perRow = Math.min(remaining.length, 7);
const cardW = 240;
const cardH = 290;
remaining.forEach((entry, n) => {
const row = Math.floor(n / perRow);
const inRow = Math.min(remaining.length - row * perRow, perRow);
const x = cx + (n % perRow - (inRow - 1) / 2) * (cardW + 20);
const y = 420 + row * (cardH + 24);
root.add(this.shipCard(x, y, cardW, cardH, entry, humanSide, () => {
this.current[humanSide] = entry.i;
this.needPick[humanSide] = false;
playSound(this, SFX.EIGHTBIT_CARD);
this.showShipPick(); // next picker (hotseat) or battle
}));
});
root.add(this.mkText(cx, GAME_HEIGHT - 40,
`FLEET VALUE REMAINING — YOU ${fleetPoints(this.SHIPS, remaining.map((e) => e.key))} PTS / ` +
`ENEMY ${fleetPoints(this.SHIPS, this.aliveFleet(1 - humanSide).map((e) => e.key))} PTS`,
22, '#89a6a0'));
}
shipCard(x, y, w, h, entry, side, onPick) {
const def = this.SHIPS[entry.key];
const c = this.add.container(x, y);
const g = this.add.graphics();
g.fillStyle(0x0c1a14, 0.94).fillRoundedRect(-w / 2, -h / 2, w, h, 10);
g.lineStyle(2, SIDE_COLORS[side].hull, 0.7).strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
c.add(g);
const icon = this.add.graphics();
this.drawChevron(icon, 0, -62, 34, -Math.PI / 2, SIDE_COLORS[side].hull, false);
c.add(icon);
c.add(this.mkText(0, 8, def.name.replace(' ', '\n'), 24).setAlign('center'));
const crew = entry.crew ?? def.stats.maxCrew;
c.add(this.mkText(0, 74, `CREW ${crew}/${def.stats.maxCrew}`, 18, crew < def.stats.maxCrew ? '#f4a26c' : '#9adbb2'));
c.add(this.mkText(0, 102, `${def.pointValue} PTS`, 18, '#e7d27b'));
c.setSize(w, h);
c.setInteractive({ useHandCursor: true });
c.on('pointerover', () => g.lineStyle(3, 0xffffff, 1).strokeRoundedRect(-w / 2, -h / 2, w, h, 10));
c.on('pointerdown', onPick);
return c;
}
startBattle() {
this.view = 'battle';
this.clearView();
this.paused = false;
this.deathHoldMs = 0;
this.deathFocus = null;
this.fxList = [];
const mk = (side) => {
const entry = this.fleets[side][this.current[side]];
return {
def: this.SHIPS[entry.key],
crew: entry.crew ?? undefined,
energy: entry.energy ?? undefined,
};
};
this.matchSeed = (this.matchSeed * 16807) % 2147483647;
this.match = createBattle({
a: mk(0), b: mk(1), seed: this.matchSeed,
keep: this.carry,
});
// Real UQM rotation sheets when present; vector chevrons otherwise.
this.shipSprites = this.match.ships.map((ship) => {
const sheets = [ship.def.sprite, ...(ship.def.forms ?? []).map((f) => f.sprite)]
.filter((sp) => sp?.sheet && this.textures.exists(sp.sheet));
for (const sp of sheets) {
this.textures.get(sp.sheet).setFilter(Phaser.Textures.FilterMode.NEAREST);
}
if (!sheets.length) return null;
return this.add.sprite(0, 0, sheets[0].sheet, 0).setDepth(D.world + 1).setVisible(false);
});
// Camera opens wide on the survivor/new pair and glides in.
const mid = tmid(this.match.ships[0].x, this.match.ships[0].y, this.match.ships[1].x, this.match.ships[1].y);
this.cam = { x: mid.x, y: mid.y, zoom: ZOOM.FAR };
this.buildHud();
playSound(this, SFX.SCIFI_RISER);
}
// =========================================================================
// Battle loop
update(time, delta) {
if (this.view !== 'battle' || !this.match) return;
if (this.paused) return;
if (!this.match.over) {
for (const side of [0, 1]) {
if (this.ais[side]) {
setInput(this.match, side, updateAI(this.ais[side], this.match, delta));
} else {
setInput(this.match, side, this.readKeys(side));
}
}
}
const events = stepMatch(this.match, delta);
for (const e of events) this.handleEvent(e);
if (this.match.over && this.deathHoldMs > 0) {
this.deathHoldMs -= delta;
if (this.deathHoldMs <= 0) this.resolveBattleEnd();
}
this.updateCamera(delta);
this.renderWorld(delta);
this.updateHud();
}
readKeys(side) {
const k = this.keys;
// In single-player, both key clusters drive the human ship.
const p1 = { left: k.A.isDown, right: k.D.isDown, thrust: k.W.isDown, fire: k.Z.isDown, special: k.X.isDown };
const p2 = {
left: k.LEFT.isDown, right: k.RIGHT.isDown, thrust: k.UP.isDown,
fire: k.COMMA.isDown, special: k.PERIOD.isDown,
};
if (this.mode === 'hotseat') return side === 0 ? p1 : p2;
return {
left: p1.left || p2.left, right: p1.right || p2.right,
thrust: p1.thrust || p2.thrust, fire: p1.fire || p2.fire, special: p1.special || p2.special,
};
}
handleEvent(e) {
switch (e.t) {
case 'fire':
this.throttledSfx(SFX_CLASS[e.sfx] ?? SFX.SCIFI_PLINK, 90);
break;
case 'special':
this.throttledSfx(SFX_CLASS[e.sfx] ?? SFX.SCIFI_REVEAL, 120);
break;
case 'beam':
case 'pdZap':
this.fxList.push({ type: 'beam', x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, side: e.side, ttl: 110 });
this.throttledSfx(SFX.LASER_ZAP, 140);
break;
case 'shipHit':
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 40 + e.dmg * 14, ttl: 300, age: 0 });
this.throttledSfx(SFX.EIGHTBIT_EXPLODE, 120);
this.crt.pulse?.(0.4, 120);
break;
case 'shotHit':
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 26, ttl: 200, age: 0 });
break;
case 'shipsCollide':
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 60, ttl: 260, age: 0 });
this.throttledSfx(SFX.EIGHTBIT_EXPLODE_2, 200);
break;
case 'planetBounce':
this.throttledSfx(SFX.EIGHTBIT_ACTION, 200);
break;
case 'asteroidBreak':
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 34, ttl: 220, age: 0 });
this.throttledSfx(SFX.SCIFI_PLINK, 150);
break;
case 'glory':
this.fxList.push({ type: 'ring', x: e.x, y: e.y, r: e.range, ttl: 600, age: 0 });
playSound(this, SFX.SCIFI_EXPLODE);
this.crt.pulse?.(1, 400);
break;
case 'cloak':
this.throttledSfx(SFX.SCIFI_RISER, 300);
break;
case 'fighterDock':
this.throttledSfx(SFX.EIGHTBIT_CARD, 200);
break;
case 'resurrect':
playSound(this, SFX.VICTORY_SHORT);
break;
case 'death':
this.fxList.push({ type: 'boom', x: e.x, y: e.y, r: 260, ttl: 1100, age: 0 });
playSound(this, SFX.SCIFI_EXPLODE);
this.crt.pulse?.(1, 500);
this.deathFocus = { x: e.x, y: e.y };
break;
case 'battleOver':
this.deathHoldMs = 1300;
break;
default:
break;
}
}
throttledSfx(key, gapMs) {
if (!key) return;
const now = this.time.now;
if ((this.lastSfxAt[key] ?? -1e9) + gapMs > now) return;
this.lastSfxAt[key] = now;
playSound(this, key);
}
resolveBattleEnd() {
const w = this.match.winner;
[0, 1].forEach((side) => {
const entry = this.fleets[side][this.current[side]];
const ship = this.match.ships[side];
if (w === side) {
// Winner keeps its ship, with damage/charge carried over.
entry.crew = ship.crew;
entry.energy = ship.energy;
this.destroyedPoints[side] += this.SHIPS[this.fleets[1 - side][this.current[1 - side]].key].pointValue;
this.carry = {
side, x: ship.x, y: ship.y, vx: ship.vx, vy: ship.vy, facing: ship.facing,
};
this.needPick[side] = false;
} else {
entry.dead = true;
this.needPick[side] = true;
}
});
if (w === 'draw') {
this.carry = null;
this.destroyedPoints[0] += this.SHIPS[this.fleets[1][this.current[1]].key].pointValue;
this.destroyedPoints[1] += this.SHIPS[this.fleets[0][this.current[0]].key].pointValue;
}
const out = [0, 1].filter((s) => this.aliveFleet(s).length === 0);
if (out.length > 0) this.showRecap(out);
else this.showShipPick();
}
// =========================================================================
// Camera + rendering
renderPos(e) {
const a = this.match.alpha ?? 1;
return {
x: wrap(e.px + tdelta(e.px, e.x, ARENA_W) * a, ARENA_W),
y: wrap(e.py + tdelta(e.py, e.y, ARENA_H) * a, ARENA_H),
};
}
w2s(x, y) {
return {
x: GAME_WIDTH / 2 + tdelta(this.cam.x, x, ARENA_W) * this.cam.zoom,
y: GAME_HEIGHT / 2 + tdelta(this.cam.y, y, ARENA_H) * this.cam.zoom,
};
}
onScreen(s, margin) {
return s.x > -margin && s.x < GAME_WIDTH + margin && s.y > -margin && s.y < GAME_HEIGHT + margin;
}
updateCamera(delta) {
const [A, B] = this.match.ships;
let mid;
let tz;
if (this.match.over && this.deathFocus) {
mid = this.deathFocus;
tz = ZOOM.MED;
} else {
const ra = this.renderPos(A);
const rb = this.renderPos(B);
mid = tmid(ra.x, ra.y, rb.x, rb.y);
tz = targetZoom(tdist(ra.x, ra.y, rb.x, rb.y));
}
const k = 1 - Math.exp(-delta / ZOOM.SMOOTH_MS);
this.cam.zoom = smoothTowards(this.cam.zoom, tz, delta);
this.cam.x = wrap(this.cam.x + tdelta(this.cam.x, mid.x, ARENA_W) * k, ARENA_W);
this.cam.y = wrap(this.cam.y + tdelta(this.cam.y, mid.y, ARENA_H) * k, ARENA_H);
}
drawChevron(g, x, y, size, facing, color, thrusting) {
const c = Math.cos(facing);
const s = Math.sin(facing);
const pt = (fx, fy) => ({ x: x + (fx * c - fy * s), y: y + (fx * s + fy * c) });
const nose = pt(size, 0);
const lw = pt(-size * 0.7, size * 0.66);
const rw = pt(-size * 0.7, -size * 0.66);
const tail = pt(-size * 0.32, 0);
g.fillStyle(color, 0.22);
g.fillTriangle(nose.x, nose.y, lw.x, lw.y, tail.x, tail.y);
g.fillTriangle(nose.x, nose.y, rw.x, rw.y, tail.x, tail.y);
g.lineStyle(Math.max(1.5, size * 0.09), color, 1);
g.beginPath();
g.moveTo(lw.x, lw.y); g.lineTo(nose.x, nose.y); g.lineTo(rw.x, rw.y); g.lineTo(tail.x, tail.y);
g.closePath();
g.strokePath();
if (thrusting) {
const f1 = pt(-size * (0.9 + Math.random() * 0.5), 0);
const fl = pt(-size * 0.55, size * 0.22);
const fr = pt(-size * 0.55, -size * 0.22);
g.fillStyle(0xffc06a, 0.85);
g.fillTriangle(f1.x, f1.y, fl.x, fl.y, fr.x, fr.y);
}
}
renderWorld(delta) {
const g = this.gWorld;
const z = this.cam.zoom;
g.clear();
// Starfield (two parallax layers).
for (let layer = 0; layer < 2; layer += 1) {
const par = layer === 0 ? 0.45 : 1;
const camX = wrap(this.cam.x * par, ARENA_W);
const camY = wrap(this.cam.y * par, ARENA_H);
for (const st of this.stars[layer]) {
const sx = GAME_WIDTH / 2 + tdelta(camX, st.x, ARENA_W) * z;
const sy = GAME_HEIGHT / 2 + tdelta(camY, st.y, ARENA_H) * z;
if (sx < -4 || sx > GAME_WIDTH + 4 || sy < -4 || sy > GAME_HEIGHT + 4) continue;
g.fillStyle(0xbfd9ff, st.tone * (layer === 0 ? 0.4 : 0.8));
g.fillRect(sx, sy, Math.max(1, st.size * (layer === 0 ? 0.7 : 1)), Math.max(1, st.size));
}
}
// Planet.
const p = this.match.planet;
const ps = this.w2s(p.x, p.y);
if (this.onScreen(ps, p.radius * z + 40)) {
g.fillStyle(0x6a5540, 1).fillCircle(ps.x, ps.y, p.radius * z);
g.fillStyle(0x86704f, 1).fillCircle(ps.x - p.radius * z * 0.25, ps.y - p.radius * z * 0.2, p.radius * z * 0.62);
g.fillStyle(0x4c3c2e, 0.9);
g.fillCircle(ps.x + p.radius * z * 0.35, ps.y + p.radius * z * 0.3, p.radius * z * 0.2);
g.fillCircle(ps.x - p.radius * z * 0.1, ps.y + p.radius * z * 0.45, p.radius * z * 0.12);
g.lineStyle(2, 0xc79a62, 0.5).strokeCircle(ps.x, ps.y, p.radius * z);
}
// Asteroids.
for (const ast of this.match.asteroids) {
if (!ast.alive) continue;
const rp = this.renderPos(ast);
const s = this.w2s(rp.x, rp.y);
if (!this.onScreen(s, 60)) continue;
const r = ast.radius * z;
g.lineStyle(2, 0x9a9a8c, 1);
g.fillStyle(0x55554c, 1);
g.beginPath();
for (let i = 0; i < 7; i += 1) {
const a = ast.rot + (i / 7) * Math.PI * 2;
const rr = r * (0.75 + 0.25 * Math.sin(i * 2.7 + ast.id));
const px2 = s.x + Math.cos(a) * rr;
const py2 = s.y + Math.sin(a) * rr;
if (i === 0) g.moveTo(px2, py2); else g.lineTo(px2, py2);
}
g.closePath();
g.fillPath();
g.strokePath();
}
// Projectiles + units.
for (const pr of this.match.projectiles) {
if (!pr.alive) continue;
const rp = this.renderPos(pr);
const s = this.w2s(rp.x, rp.y);
if (!this.onScreen(s, 80)) continue;
const col = SIDE_COLORS[pr.owner].hull;
if (pr.type === 'flame') {
const lifeFrac = Math.min(1, pr.life / 8);
g.fillStyle(0xff9040, 0.25 + 0.55 * lifeFrac);
g.fillCircle(s.x, s.y, (10 + (1 - lifeFrac) * 26) * z * 4 * 0.25);
} else if (pr.kind === 'unit') {
this.drawChevron(g, s.x, s.y, Math.max(6, pr.radius * z), pr.facing, col, false);
} else if (pr.type === 'missile' || pr.type === 'butt') {
const c = Math.cos(pr.facing);
const sn = Math.sin(pr.facing);
const len = Math.max(6, pr.radius * z * 1.8);
g.lineStyle(Math.max(2, 3 * z * 2), col, 1);
g.lineBetween(s.x - c * len, s.y - sn * len, s.x + c * len, s.y + sn * len);
g.fillStyle(0xffe9a0, 1).fillCircle(s.x - c * len, s.y - sn * len, Math.max(1.5, 2.4 * z * 2));
} else {
g.fillStyle(col, 1).fillCircle(s.x, s.y, Math.max(2, pr.radius * z * 0.8));
g.lineStyle(1, 0xffffff, 0.5).strokeCircle(s.x, s.y, Math.max(2, pr.radius * z * 0.8));
}
}
// Ships. UQM sheets: frame 0 faces up, rotating counterclockwise, so the
// frame for quantized facing q (0 = east, clockwise) is (12 - q) & 15.
for (const ship of this.match.ships) {
const spr = this.shipSprites?.[ship.side];
if (!ship.alive || ship.fx.cloaked) { spr?.setVisible(false); continue; }
const rp = this.renderPos(ship);
const s = this.w2s(rp.x, rp.y);
if (!this.onScreen(s, 260)) { spr?.setVisible(false); continue; }
const thrusting = ship.input.thrust && !this.match.over;
const spDef = (ship.fx.formIndex ? ship.def.forms?.[ship.fx.formIndex - 1]?.sprite : null)
?? ship.def.sprite;
if (spr && spDef?.sheet && this.textures.exists(spDef.sheet)) {
if (spr.texture.key !== spDef.sheet) spr.setTexture(spDef.sheet, 0);
const q = quantizeFacing(ship.facing);
spr.setVisible(true)
.setPosition(s.x, s.y)
.setScale(z * 4)
.setFrame((12 - q + 16) & 15);
spr.setTint(ship.fx.blazer ? 0xff8866 : 0xffffff);
if (thrusting) {
const f = ship.facing;
const back = ship.def.radius * 1.05 * z;
g.fillStyle(0xffc06a, 0.5 + Math.random() * 0.3);
g.fillCircle(s.x - Math.cos(f) * back, s.y - Math.sin(f) * back, Math.max(2, 22 * z));
}
} else {
spr?.setVisible(false);
const size = Math.max(9, ship.def.radius * z);
this.drawChevron(g, s.x, s.y, size, ship.facing, SIDE_COLORS[ship.side].hull, thrusting);
}
// Shield/absorb auras read through both render paths.
if (ship.fx.shieldFrames > 0 || ship.fx.absorbFrames > 0) {
g.lineStyle(3, ship.fx.absorbFrames > 0 ? 0xe7d27b : 0x8ad4ff, 0.8);
g.strokeCircle(s.x, s.y, (ship.def.radius + 40) * z);
}
}
// Transient FX.
const fg = this.gFx;
fg.clear();
this.fxList = this.fxList.filter((fx) => {
fx.age = (fx.age ?? 0) + delta;
if (fx.age >= fx.ttl) return false;
const t = fx.age / fx.ttl;
if (fx.type === 'beam') {
const a = this.w2s(fx.x1, fx.y1);
const b = this.w2s(fx.x2, fx.y2);
fg.lineStyle(Math.max(1.5, 3 * z * 2) * (1 - t) + 1, SIDE_COLORS[fx.side ?? 0].hull, 1 - t * 0.7);
fg.lineBetween(a.x, a.y, b.x, b.y);
} else if (fx.type === 'burst') {
const s = this.w2s(fx.x, fx.y);
fg.fillStyle(0xffd27a, (1 - t) * 0.8);
fg.fillCircle(s.x, s.y, fx.r * z * (0.4 + t));
} else if (fx.type === 'ring') {
const s = this.w2s(fx.x, fx.y);
fg.lineStyle(6 * (1 - t) + 1, 0xffe9a0, 1 - t);
fg.strokeCircle(s.x, s.y, fx.r * z * t);
fg.fillStyle(0xff9040, (1 - t) * 0.5);
fg.fillCircle(s.x, s.y, fx.r * z * t * 0.5);
} else if (fx.type === 'boom') {
const s = this.w2s(fx.x, fx.y);
for (let i = 0; i < 9; i += 1) {
const a = (i / 9) * Math.PI * 2 + fx.x;
fg.fillStyle(i % 2 ? 0xffd27a : 0xff7040, (1 - t) * 0.9);
fg.fillCircle(
s.x + Math.cos(a) * fx.r * z * t,
s.y + Math.sin(a) * fx.r * z * t,
Math.max(2, fx.r * z * 0.16 * (1 - t)),
);
}
}
return true;
});
}
// =========================================================================
// HUD
buildHud() {
this.hudRoot.removeAll(true);
this.hudPanels = [];
this.lastHud = [null, null];
[0, 1].forEach((side) => {
const x = side === 0 ? 30 : GAME_WIDTH - 330;
const c = this.add.container(x, 24);
const entry = this.fleets[side][this.current[side]];
const def = this.SHIPS[entry.key];
const bg = this.add.graphics();
bg.fillStyle(0x081209, 0.72).fillRoundedRect(0, 0, 300, 118, 8);
bg.lineStyle(2, SIDE_COLORS[side].hull, 0.6).strokeRoundedRect(0, 0, 300, 118, 8);
c.add(bg);
const name = this.add.text(12, 8, def.name.toUpperCase(), {
fontFamily: FONT, fontSize: '20px', color: SIDE_COLORS[side].hex,
});
c.add(name);
const bars = this.add.graphics();
c.add(bars);
const crewT = this.add.text(288, 34, '', { fontFamily: FONT, fontSize: '16px', color: '#9adbb2' }).setOrigin(1, 0);
const enT = this.add.text(288, 64, '', { fontFamily: FONT, fontSize: '16px', color: '#f0908a' }).setOrigin(1, 0);
c.add(crewT);
c.add(enT);
const fleetT = this.add.text(12, 92, '', { fontFamily: FONT, fontSize: '15px', color: '#89a6a0' });
c.add(fleetT);
this.hudRoot.add(c);
this.hudPanels.push({ c, bars, crewT, enT, fleetT, def });
});
}
updateHud() {
[0, 1].forEach((side) => {
const ship = this.match.ships[side];
const p = this.hudPanels[side];
const remaining = this.aliveFleet(side).length;
const sig = `${ship.crew}|${ship.energy}|${remaining}`;
if (this.lastHud[side] === sig) return;
this.lastHud[side] = sig;
const s = ship.def.stats;
p.bars.clear();
const bar = (y, frac, color) => {
p.bars.fillStyle(0x111d16, 1).fillRect(12, y, 210, 16);
if (frac > 0) p.bars.fillStyle(color, 1).fillRect(13, y + 1, 208 * frac, 14);
p.bars.lineStyle(1, 0x3a5a48, 1).strokeRect(12, y, 210, 16);
const ticks = Math.floor(210 / 14);
p.bars.lineStyle(1, 0x081209, 0.9);
for (let i = 1; i < ticks; i += 1) p.bars.lineBetween(12 + i * 14, y, 12 + i * 14, y + 16);
};
bar(34, Math.max(0, ship.crew) / s.maxCrew, 0x63d97c);
bar(64, Math.max(0, ship.energy) / s.maxEnergy, 0xe2574e);
p.crewT.setText(`${Math.max(0, ship.crew)}`);
p.enT.setText(`${Math.max(0, ship.energy)}`);
p.fleetT.setText(`FLEET ${remaining} — DESTROYED ${this.destroyedPoints[side]} PTS`);
});
}
// =========================================================================
// Pause / escape
onEscape() {
if (this.view !== 'battle') return;
if (this.paused) { this.closePause(); return; }
this.paused = true;
const cx = GAME_WIDTH / 2;
this.pauseRoot = this.add.container(0, 0).setDepth(D.overlay);
const dim = this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62);
this.pauseRoot.add(dim);
this.pauseRoot.add(this.mkText(cx, 340, 'PAUSED', 60, '#74f5a1'));
this.pauseRoot.add(this.textButton(cx, 480, 'RESUME', () => this.closePause(), 32));
this.pauseRoot.add(this.textButton(cx, 570, 'ABANDON MATCH', () => { this.closePause(); this.showSetup(); }, 32));
this.pauseRoot.add(this.textButton(cx, 660, 'EXIT TO MENU', () => this.scene.start('GameMenu'), 32));
}
closePause() {
this.paused = false;
this.pauseRoot?.destroy(true);
this.pauseRoot = null;
}
// =========================================================================
// RECAP view
showRecap(losers) {
this.view = 'recap';
this.match = null;
this.clearView();
const root = this.viewRoot;
const cx = GAME_WIDTH / 2;
const bothOut = losers.length === 2;
const winner = bothOut ? null : 1 - losers[0];
const title = bothOut ? 'MUTUAL ANNIHILATION'
: `${this.mode === 'hotseat' ? `PLAYER ${winner + 1}` : (winner === 0 ? 'YOU' : 'THE COMPUTER')} WIN${winner === 0 && this.mode !== 'hotseat' ? '' : 'S'} THE MELEE`;
root.add(this.mkText(cx, 200, title, 56, winner === null ? '#e7d27b' : SIDE_COLORS[winner].hex));
[0, 1].forEach((side) => {
const x = side === 0 ? 620 : GAME_WIDTH - 620;
root.add(this.mkText(x, 330, this.mode === 'hotseat' || side === 0 ? `PLAYER ${side + 1}` : 'COMPUTER', 30, SIDE_COLORS[side].hex));
root.add(this.mkText(x, 390, `DESTROYED ${this.destroyedPoints[side]} PTS OF SHIPS`, 26));
root.add(this.mkText(x, 440, `SHIPS LEFT ${this.aliveFleet(side).length}`, 22, '#89a6a0'));
});
// Score/history: single-player only, from the human's perspective.
if (this.mode === 'ai') {
const score = this.destroyedPoints[0];
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
if (score > best) localStorage.setItem(BEST_KEY, String(score));
api.post('/history/single-player', {
slug: 'starcontrol',
score,
opponentScores: [this.destroyedPoints[1]],
result: winner === 0 ? 'win' : 'loss',
}).catch(() => {});
playSound(this, winner === 0 ? SFX.VICTORY_SHORT : SFX.SCIFI_EXPLODE);
}
root.add(this.textButton(cx - 260, 760, 'REMATCH', () => { this.beginMatch(); }, 32));
root.add(this.textButton(cx, 760, 'NEW FLEETS', () => this.showSetup(), 32));
root.add(this.textButton(cx + 260, 760, 'EXIT', () => this.scene.start('GameMenu'), 32));
}
}

View File

@ -0,0 +1,806 @@
// Pure Super Melee simulation for Star Control. No Phaser dependency — fully
// unit-testable headlessly via tools/verifyStarControl.js.
//
// Fidelity anchors (from The Ur-Quan Masters sources, sc2/src/uqm):
// - Battle advances in fixed 24 fps frames; every ship stat expressed as a
// "wait" is a frame counter with UQM semantics: WAIT = n means the action
// recurs every n+1 frames.
// - World units are UQM world units (1 display pixel = 4 world units,
// units.h DISPLAY_TO_WORLD). Arena is UQM's LOG_SPACE: 8192 x 7680.
// - Ships have 16 discrete facings for thrust/fire/sprites; the sim keeps a
// float facing for smooth turning at the UQM rate ((PI/8)/(turnWait+1) per
// frame) and quantizes where the original quantizes.
import { PRIMARIES, SPECIALS, PASSIVES } from './StarControlShips.js';
export const STEP_MS = 1000 / 24;
export const ARENA_W = 8192;
export const ARENA_H = 7680;
export const FACINGS = 16;
export const FACING_STEP = (Math.PI * 2) / FACINGS;
export const TUNE = {
MAX_FRAMES_PER_STEP: 6, // spiral-of-death cap after tab suspend
PLANET_RADIUS: 220, // world units (UQM planet ~29 display px)
PLANET_GRAVITY: 1.6e6, // accel = G / r^2 (world units / frame^2)
PLANET_GRAVITY_MIN_R: 420, // clamp radius so slingshots stay sane
PLANET_CREW_DAMAGE: 3, // crew lost on planet impact
PLANET_DAMAGE_COOLDOWN: 24, // frames between crew-damaging planet hits
PLANET_BOUNCE: 0.65, // velocity reflection factor off the planet
PLANET_EJECT: 36, // extra outward shove on impact (units/frame)
ASTEROID_COUNT: 5,
ASTEROID_RADIUS: 60,
ASTEROID_SPEED_MIN: 8, // units/frame
ASTEROID_SPEED_MAX: 24,
ASTEROID_RESPAWN_FRAMES: 240, // ~10 s
SHIP_COLLISION_DAMAGE_SPEED: 40, // relative speed (units/frame) for 1-crew hit
SHIP_COLLISION_RESTITUTION: 0.9,
SPAWN_SEPARATION: 3200, // new ship spawns this far from survivor
DEFAULT_SHIP_RADIUS: 100,
MAX_BATTLE_FRAMES: 24 * 60 * 5, // 5 min safety valve -> draw
};
// ---------------------------------------------------------------------------
// Seeded RNG (same generator the other games use).
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;
};
}
// ---------------------------------------------------------------------------
// Toroidal arena math. Exported for the AI, the scene renderer and verify.
export function wrap(v, size) {
return ((v % size) + size) % size;
}
// Shortest signed delta from a to b on a wrapped axis.
export function tdelta(a, b, size) {
let d = (b - a) % size;
if (d > size / 2) d -= size;
else if (d < -size / 2) d += size;
return d;
}
export function tdist2(ax, ay, bx, by) {
const dx = tdelta(ax, bx, ARENA_W);
const dy = tdelta(ay, by, ARENA_H);
return dx * dx + dy * dy;
}
export function tdist(ax, ay, bx, by) {
return Math.sqrt(tdist2(ax, ay, bx, by));
}
// Toroidal midpoint (halfway along the short path from a to b).
export function tmid(ax, ay, bx, by) {
return {
x: wrap(ax + tdelta(ax, bx, ARENA_W) / 2, ARENA_W),
y: wrap(ay + tdelta(ay, by, ARENA_H) / 2, ARENA_H),
};
}
// True if the toroidal segment (x1,y1)->(x2,y2) passes within `r` of (cx,cy).
// Used for swept collision so fast shots can't tunnel through small ships.
export function segHitsCircle(x1, y1, x2, y2, cx, cy, r) {
const dx = tdelta(x1, x2, ARENA_W);
const dy = tdelta(y1, y2, ARENA_H);
const fx = tdelta(x1, cx, ARENA_W);
const fy = tdelta(y1, cy, ARENA_H);
const len2 = dx * dx + dy * dy;
let t = len2 > 0 ? (fx * dx + fy * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const ex = fx - dx * t;
const ey = fy - dy * t;
return ex * ex + ey * ey < r * r;
}
export function quantizeFacing(facing) {
return ((Math.round(facing / FACING_STEP) % FACINGS) + FACINGS) % FACINGS;
}
export function facingToRad(facing16) {
return facing16 * FACING_STEP;
}
// Angle needed to hit a moving target with a projectile of speed `shotSpeed`
// (world units/frame), from (sx, sy), toroidal-aware. Falls back to a direct
// bearing when no intercept exists. Used by homing shots and the AI.
export function interceptAngle(sx, sy, target, shotSpeed) {
const dx = tdelta(sx, target.x, ARENA_W);
const dy = tdelta(sy, target.y, ARENA_H);
const tvx = target.vx ?? 0;
const tvy = target.vy ?? 0;
const a = tvx * tvx + tvy * tvy - shotSpeed * shotSpeed;
const b = 2 * (dx * tvx + dy * tvy);
const c = dx * dx + dy * dy;
let t = -1;
if (Math.abs(a) < 1e-6) {
if (Math.abs(b) > 1e-6) t = -c / b;
} else {
const disc = b * b - 4 * a * c;
if (disc >= 0) {
const rt = Math.sqrt(disc);
const t1 = (-b - rt) / (2 * a);
const t2 = (-b + rt) / (2 * a);
t = t1 > 0 ? t1 : t2;
}
}
if (t <= 0 || !Number.isFinite(t)) return Math.atan2(dy, dx);
return Math.atan2(dy + tvy * t, dx + tvx * t);
}
// Signed smallest angular difference a -> b in (-PI, PI].
export function angleDelta(a, b) {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
else if (d <= -Math.PI) d += Math.PI * 2;
return d;
}
// ---------------------------------------------------------------------------
// Ship definition compilation / validation.
const REQUIRED_STATS = [
'maxCrew', 'maxEnergy', 'energyRegen', 'energyWait', 'maxThrust',
'thrustIncrement', 'thrustWait', 'turnWait', 'mass',
];
export function validateShipDef(key, def) {
const fail = (why) => { throw new Error(`ship "${key}": ${why}`); };
if (!def || typeof def !== 'object') fail('not an object');
if (!def.name) fail('missing name');
if (!['alliance', 'hierarchy', 'neutral'].includes(def.team)) fail(`bad team "${def.team}"`);
if (!(def.pointValue >= 0)) fail('bad pointValue');
if (!def.stats) fail('missing stats');
for (const s of REQUIRED_STATS) {
if (typeof def.stats[s] !== 'number' || def.stats[s] < 0) fail(`stats.${s} missing or negative`);
}
if (!def.primary || !PRIMARIES[def.primary.type]) fail(`unknown primary type "${def.primary?.type}"`);
if (!def.special || !SPECIALS[def.special.type]) fail(`unknown special type "${def.special?.type}"`);
for (const p of def.passives ?? []) {
if (!PASSIVES[p]) fail(`unknown passive "${p}"`);
}
for (const form of def.forms ?? []) {
if (form.primary && !PRIMARIES[form.primary.type]) fail(`unknown form primary "${form.primary.type}"`);
}
return true;
}
// Alternate-form support (Mmrnmhrm X-Form): a transform special stashes the
// active form's stat/weapon overrides in ship.fx; everything else reads
// through these accessors.
export function shipStats(ship) {
return ship.fx.formStats ? { ...ship.def.stats, ...ship.fx.formStats } : ship.def.stats;
}
export function shipPrimary(ship) {
return ship.fx.formPrimary ?? ship.def.primary;
}
export function compileShip(key, def) {
validateShipDef(key, def);
const s = def.stats;
return {
key,
...def,
passives: def.passives ?? [],
radius: def.radius
?? (def.sprite?.frameWidth
? Math.max(24, (Math.max(def.sprite.frameWidth, def.sprite.frameHeight) / 2) * 4 * 0.8)
: TUNE.DEFAULT_SHIP_RADIUS),
turnPerFrame: FACING_STEP / (s.turnWait + 1), // rad/frame; one 1/16 facing per (turnWait+1) frames
};
}
// json -> { key -> compiled def }. Throws with a named reason on bad entries.
export function compileShips(json) {
const out = {};
const ships = json?.ships ?? {};
for (const key of Object.keys(ships)) out[key] = compileShip(key, ships[key]);
return out;
}
// ---------------------------------------------------------------------------
// Battle state.
let nextEntityId = 1;
function makeShip(def, side, opts = {}) {
return {
id: nextEntityId++,
kind: 'ship',
side, // 0 or 1
def,
x: opts.x ?? 0, y: opts.y ?? 0,
px: opts.x ?? 0, py: opts.y ?? 0, // previous-frame position for render lerp
vx: opts.vx ?? 0, vy: opts.vy ?? 0,
facing: opts.facing ?? 0, // float radians
crew: opts.crew ?? def.stats.maxCrew,
energy: opts.energy ?? def.stats.maxEnergy,
alive: true,
waits: { turn: 0, thrust: 0, weapon: 0, special: 0, energy: 0 },
input: { left: false, right: false, thrust: false, fire: false, special: false },
// Registry-managed status effects / per-ship scratch state.
fx: {},
// Movement overrides registries may set for one frame (blazer, omni-thrust,
// probe drive, afterburner). Cleared by the sim each frame.
moveOverride: null,
};
}
function spawnAsteroid(state, edge) {
const rng = state.rng;
let x; let y;
if (edge) {
if (rng() < 0.5) { x = 0; y = rng() * ARENA_H; } else { x = rng() * ARENA_W; y = 0; }
} else {
// Initial placement: anywhere not too close to the planet or the ships.
do {
x = rng() * ARENA_W; y = rng() * ARENA_H;
} while (
tdist2(x, y, ARENA_W / 2, ARENA_H / 2) < 1400 * 1400
|| state.ships.some((sh) => tdist2(x, y, sh.x, sh.y) < 900 * 900)
);
}
const speed = TUNE.ASTEROID_SPEED_MIN + rng() * (TUNE.ASTEROID_SPEED_MAX - TUNE.ASTEROID_SPEED_MIN);
const ang = rng() * Math.PI * 2;
state.asteroids.push({
id: nextEntityId++, kind: 'asteroid',
x, y, px: x, py: y,
vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed,
spin: (rng() - 0.5) * 0.3, rot: rng() * Math.PI * 2,
radius: TUNE.ASTEROID_RADIUS, alive: true,
});
}
// Creates one 1v1 battle. `a`/`b`: { def, crew?, energy? } — crew/energy carry
// over for a surviving ship between battles. The survivor keeps its position
// and velocity via opts.keep = { side, x, y, vx, vy, facing }.
export function createBattle({ a, b, seed = 1, keep = null }) {
const rng = mulberry32(seed);
const state = {
seed,
rng,
frame: 0,
over: false,
winner: null, // null | 0 | 1 | 'draw'
ships: [],
projectiles: [],
asteroids: [],
events: [],
planet: { x: ARENA_W / 2, y: ARENA_H / 2, radius: TUNE.PLANET_RADIUS },
accumulatorMs: 0,
};
const mk = (spec, side, place) => {
const ship = makeShip(spec.def, side, {
...place, crew: spec.crew, energy: spec.energy,
});
for (const p of ship.def.passives) PASSIVES[p].init?.(state, ship);
SPECIALS[ship.def.special.type].init?.(state, ship);
PRIMARIES[ship.def.primary.type].init?.(state, ship);
return ship;
};
// Default entry: top and bottom of the arena, facing each other (UQM style).
const placeA = { x: ARENA_W / 2, y: ARENA_H * 0.18, facing: Math.PI / 2 };
const placeB = { x: ARENA_W / 2, y: ARENA_H * 0.82, facing: -Math.PI / 2 };
if (keep && keep.side === 0) {
Object.assign(placeA, { x: keep.x, y: keep.y, vx: keep.vx, vy: keep.vy, facing: keep.facing });
Object.assign(placeB, {
x: wrap(keep.x + TUNE.SPAWN_SEPARATION, ARENA_W),
y: wrap(keep.y + ARENA_H / 2, ARENA_H),
});
} else if (keep && keep.side === 1) {
Object.assign(placeB, { x: keep.x, y: keep.y, vx: keep.vx, vy: keep.vy, facing: keep.facing });
Object.assign(placeA, {
x: wrap(keep.x + TUNE.SPAWN_SEPARATION, ARENA_W),
y: wrap(keep.y + ARENA_H / 2, ARENA_H),
});
}
state.ships.push(mk(a, 0, placeA));
state.ships.push(mk(b, 1, placeB));
for (let i = 0; i < TUNE.ASTEROID_COUNT; i += 1) spawnAsteroid(state, false);
return state;
}
export function setInput(state, side, input) {
const ship = state.ships[side];
if (!ship || !ship.alive) return;
Object.assign(ship.input, input);
}
// ---------------------------------------------------------------------------
// Registry-facing sim services (the API weapon/special functions use).
export function emit(state, event) {
state.events.push(event);
}
export function spawnProjectile(state, owner, opts) {
const speed = opts.speed ?? 0;
const ang = opts.angle ?? owner.facing;
const p = {
id: nextEntityId++,
kind: opts.kind ?? 'shot',
type: opts.type ?? 'shot',
owner: owner.side,
x: wrap(opts.x ?? owner.x, ARENA_W),
y: wrap(opts.y ?? owner.y, ARENA_H),
vx: opts.vx ?? Math.cos(ang) * speed,
vy: opts.vy ?? Math.sin(ang) * speed,
px: 0, py: 0,
facing: ang,
damage: opts.damage ?? 1,
hp: opts.hp ?? 1,
life: opts.life ?? 24,
radius: opts.radius ?? 24,
gravity: opts.gravity ?? false,
collidesShips: opts.collidesShips ?? true,
collidesShots: opts.collidesShots ?? true,
behavior: opts.behavior ?? null,
data: opts.data ?? {},
alive: true,
};
p.px = p.x; p.py = p.y;
state.projectiles.push(p);
return p;
}
// Launch offset in ship-local space: dist world units along facing +
// perpendicular offset. Returns spawn {x, y}.
export function muzzle(ship, dist, side = 0) {
const f = facingToRad(quantizeFacing(ship.facing));
return {
x: wrap(ship.x + Math.cos(f) * dist + Math.cos(f + Math.PI / 2) * side, ARENA_W),
y: wrap(ship.y + Math.sin(f) * dist + Math.sin(f + Math.PI / 2) * side, ARENA_H),
angle: f,
};
}
export function spendEnergy(state, ship, amount) {
if (ship.energy < amount) return false;
ship.energy -= amount;
return true;
}
export function gainEnergy(state, ship, amount) {
ship.energy = Math.min(ship.def.stats.maxEnergy, ship.energy + amount);
}
// All crew loss funnels through here so shields/absorption/resurrection hook in.
export function damageShip(state, ship, dmg, kind = 'hit', source = null) {
if (!ship.alive || dmg <= 0) return 0;
const special = SPECIALS[ship.def.special.type];
if (special.absorbDamage) {
dmg = special.absorbDamage(state, ship, dmg, kind);
if (dmg <= 0) return 0;
}
const dealt = Math.min(ship.crew, dmg);
ship.crew -= dealt;
emit(state, { t: 'shipHit', side: ship.side, x: ship.x, y: ship.y, dmg: dealt, kind });
if (ship.crew <= 0) killShip(state, ship, kind);
return dealt;
}
export function gainCrew(state, ship, amount) {
ship.crew = Math.min(ship.def.stats.maxCrew, ship.crew + amount);
}
export function killShip(state, ship, kind = 'destroyed') {
if (!ship.alive) return;
ship.crew = 0;
// Passives get a chance to cancel death (Pkunk resurrection).
for (const p of ship.def.passives) {
if (PASSIVES[p].onDeath && PASSIVES[p].onDeath(state, ship)) {
emit(state, { t: 'resurrect', side: ship.side, x: ship.x, y: ship.y });
return;
}
}
ship.alive = false;
emit(state, { t: 'death', side: ship.side, x: ship.x, y: ship.y, kind });
}
export function enemyOf(state, ship) {
return state.ships[1 - ship.side];
}
// ---------------------------------------------------------------------------
// Frame update.
function applyThrustCapped(ship, ax, ay, maxThrust) {
// UQM rule: thrust may not push the speed past maxThrust, but external
// forces may; thrusting while faster than max keeps the higher of
// (previous speed, maxThrust).
const prevSpeed = Math.hypot(ship.vx, ship.vy);
ship.vx += ax; ship.vy += ay;
const speed = Math.hypot(ship.vx, ship.vy);
const cap = Math.max(prevSpeed, maxThrust);
if (speed > cap) {
const k = cap / speed;
ship.vx *= k; ship.vy *= k;
}
}
function shipFrame(state, ship) {
const w = ship.waits;
for (const k of Object.keys(w)) { if (w[k] > 0) w[k] -= 1; }
const special = SPECIALS[ship.def.special.type];
// Specials/passives that alter movement/state run before movement.
special.preprocess?.(state, ship);
for (const p of ship.def.passives) PASSIVES[p].preprocess?.(state, ship);
const s = shipStats(ship);
const pdef = shipPrimary(ship);
const primary = PRIMARIES[pdef.type];
const ov = ship.moveOverride;
const confused = (ship.fx.confusedFrames ?? 0) > 0;
if (ship.fx.confusedFrames > 0) ship.fx.confusedFrames -= 1;
// Turning (limpets slow it: each limpet adds +1 to the effective turnWait).
if (!(ov?.noTurn)) {
let dir = 0;
if (ship.input.left) dir -= 1;
if (ship.input.right) dir += 1;
if (confused && dir !== 0 && state.rng() < 0.5) dir = -dir;
if (confused && dir === 0 && state.rng() < 0.5) dir = state.rng() < 0.5 ? -1 : 1;
if (dir !== 0) {
const limpets = ship.fx.limpets ?? 0;
const turnPerFrame = FACING_STEP / (s.turnWait + 1 + limpets);
ship.facing += dir * turnPerFrame;
}
}
// Thrust.
const thrusting = ship.input.thrust || ov?.autoThrust;
if (!(ov?.noThrust) && thrusting && w.thrust === 0) {
w.thrust = (ov?.thrustWait ?? s.thrustWait) + 1;
const limpets = ship.fx.limpets ?? 0;
const thrustScale = 1 / (1 + limpets * 0.35);
let f = facingToRad(quantizeFacing(ship.facing));
if (ov?.reverseThrust) f += Math.PI;
const inc = (ov?.thrustIncrement ?? s.thrustIncrement) * thrustScale;
const max = (ov?.maxThrust ?? s.maxThrust) * thrustScale;
if (ov?.setVelocity) {
ship.vx = Math.cos(f) * max; ship.vy = Math.sin(f) * max;
} else {
applyThrustCapped(ship, Math.cos(f) * inc, Math.sin(f) * inc, max);
}
emit(state, { t: 'thrust', side: ship.side, x: ship.x, y: ship.y, facing: ship.facing });
}
// Primary weapon.
if (ship.input.fire && w.weapon === 0 && !(ov?.noFire)) {
const cost = pdef.energyCost;
if (primary.canFire?.(state, ship) !== false && ship.energy >= cost) {
spendEnergy(state, ship, cost);
w.weapon = pdef.refireWait + 1;
primary.fire(state, ship, pdef);
emit(state, { t: 'fire', side: ship.side, weapon: pdef.type, sfx: pdef.sfxClass });
}
}
primary.tick?.(state, ship, pdef);
// Special.
if (ship.input.special && w.special === 0) {
const cost = ship.def.special.energyCost;
if (special.canFire?.(state, ship) !== false && ship.energy >= cost) {
if (special.activate(state, ship, ship.def.special) !== false) {
spendEnergy(state, ship, cost);
w.special = ship.def.special.refireWait + 1;
emit(state, { t: 'special', side: ship.side, special: ship.def.special.type, sfx: ship.def.special.sfxClass });
}
}
}
special.tick?.(state, ship, ship.def.special);
// Energy regeneration (can be negative: Androsynth blazer degeneration).
if (w.energy === 0) {
w.energy = (ship.fx.energyWaitOverride ?? s.energyWait) + 1;
const regen = ship.fx.energyRegenOverride ?? s.energyRegen;
if (regen > 0) gainEnergy(state, ship, regen);
else if (regen < 0) ship.energy = Math.max(0, ship.energy + regen);
}
ship.moveOverride = null;
}
function gravityFrame(state, body) {
const dx = tdelta(body.x, state.planet.x, ARENA_W);
const dy = tdelta(body.y, state.planet.y, ARENA_H);
const r2 = Math.max(dx * dx + dy * dy, TUNE.PLANET_GRAVITY_MIN_R * TUNE.PLANET_GRAVITY_MIN_R);
const a = TUNE.PLANET_GRAVITY / r2;
const r = Math.sqrt(r2);
body.vx += (dx / r) * a;
body.vy += (dy / r) * a;
}
function integrate(body) {
body.px = body.x; body.py = body.y;
body.x = wrap(body.x + body.vx, ARENA_W);
body.y = wrap(body.y + body.vy, ARENA_H);
}
function planetCollision(state, ship) {
const d = tdist(ship.x, ship.y, state.planet.x, state.planet.y);
const minD = state.planet.radius + ship.def.radius * 0.6;
if (d >= minD) return;
const nx = tdelta(state.planet.x, ship.x, ARENA_W) / (d || 1);
const ny = tdelta(state.planet.y, ship.y, ARENA_H) / (d || 1);
const vDotN = ship.vx * nx + ship.vy * ny;
if (vDotN < 0) {
ship.vx -= (1 + TUNE.PLANET_BOUNCE) * vDotN * nx;
ship.vy -= (1 + TUNE.PLANET_BOUNCE) * vDotN * ny;
}
ship.vx += nx * TUNE.PLANET_EJECT;
ship.vy += ny * TUNE.PLANET_EJECT;
ship.x = wrap(state.planet.x + nx * minD, ARENA_W);
ship.y = wrap(state.planet.y + ny * minD, ARENA_H);
// Repeated scrapes within the cooldown window bounce but don't re-damage,
// so a gravity-pinned ship can't be ground to death in a second.
if (!(ship.fx.planetHitFrame > state.frame - TUNE.PLANET_DAMAGE_COOLDOWN)) {
ship.fx.planetHitFrame = state.frame;
damageShip(state, ship, TUNE.PLANET_CREW_DAMAGE, 'planet');
}
emit(state, { t: 'planetBounce', side: ship.side, x: ship.x, y: ship.y });
}
function shipShipCollision(state) {
const [A, B] = state.ships;
if (!A.alive || !B.alive) return;
if (A.fx.phasing || B.fx.phasing) return;
const minD = A.def.radius + B.def.radius;
// Swept: track A relative to B across the frame so fast closing speeds
// can't step through each other.
const relX1 = tdelta(B.px, A.px, ARENA_W);
const relY1 = tdelta(B.py, A.py, ARENA_H);
const relX2 = tdelta(B.x, A.x, ARENA_W);
const relY2 = tdelta(B.y, A.y, ARENA_H);
const swept = (() => {
const dx = relX2 - relX1;
const dy = relY2 - relY1;
const len2 = dx * dx + dy * dy;
let t = len2 > 0 ? -(relX1 * dx + relY1 * dy) / len2 : 0;
t = Math.max(0, Math.min(1, t));
const ex = relX1 + dx * t;
const ey = relY1 + dy * t;
return ex * ex + ey * ey < minD * minD;
})();
if (!swept) return;
const d2 = tdist2(A.x, A.y, B.x, B.y);
const d = Math.sqrt(d2) || 1;
const nx = tdelta(A.x, B.x, ARENA_W) / d;
const ny = tdelta(A.y, B.y, ARENA_H) / d;
const relV = (B.vx - A.vx) * nx + (B.vy - A.vy) * ny;
const mA = A.def.stats.mass || 1;
const mB = B.def.stats.mass || 1;
if (relV < 0) {
const j = (-(1 + TUNE.SHIP_COLLISION_RESTITUTION) * relV) / (1 / mA + 1 / mB);
A.vx -= (j / mA) * nx; A.vy -= (j / mA) * ny;
B.vx += (j / mB) * nx; B.vy += (j / mB) * ny;
}
// Positional separation, split by inverse mass (only when still overlapping).
const overlap = minD - d;
if (overlap > 0) {
const total = 1 / mA + 1 / mB;
A.x = wrap(A.x - nx * overlap * ((1 / mA) / total), ARENA_W);
A.y = wrap(A.y - ny * overlap * ((1 / mA) / total), ARENA_H);
B.x = wrap(B.x + nx * overlap * ((1 / mB) / total), ARENA_W);
B.y = wrap(B.y + ny * overlap * ((1 / mB) / total), ARENA_H);
}
if (Math.abs(relV) > TUNE.SHIP_COLLISION_DAMAGE_SPEED) {
// Contact-damage specials (Androsynth blazer, ZFP tongue handled as beam)
// hook in via onRam; default is 1 crew each.
const dmgA = SPECIALS[B.def.special.type].ramDamage?.(state, B) ?? 1;
const dmgB = SPECIALS[A.def.special.type].ramDamage?.(state, A) ?? 1;
damageShip(state, A, dmgA, 'collision');
damageShip(state, B, dmgB, 'collision');
}
emit(state, { t: 'shipsCollide', x: wrap(A.x + nx * A.def.radius, ARENA_W), y: wrap(A.y + ny * A.def.radius, ARENA_H) });
}
function projectileFrame(state, p) {
if (p.life > 0) p.life -= 1;
if (p.life === 0) { p.alive = false; return; }
p.behavior?.(state, p);
if (!p.alive) return;
if (p.gravity) gravityFrame(state, p);
integrate(p);
// Planet swallows shots.
if (tdist2(p.x, p.y, state.planet.x, state.planet.y) < state.planet.radius * state.planet.radius) {
p.alive = false;
emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: 'planet' });
return;
}
// Ships (swept over this frame's travel so fast shots can't tunnel).
if (p.collidesShips) {
for (const ship of state.ships) {
if (!ship.alive || ship.side === p.owner) continue;
if (ship.fx.phasing) continue;
const rr = ship.def.radius + p.radius;
if (segHitsCircle(p.px, p.py, p.x, p.y, ship.x, ship.y, rr)) {
if (p.data.onHitShip) { p.data.onHitShip(state, p, ship); } else {
damageShip(state, ship, p.damage, p.type);
}
// hp is durability against other shots; hitting a ship detonates the
// projectile outright unless it is explicitly piercing.
if (!p.data.piercing) p.alive = false;
emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: p.type, dmg: p.damage });
if (!p.alive) return;
}
}
}
// Asteroids block shots.
for (const ast of state.asteroids) {
if (!ast.alive) continue;
const rr = ast.radius + p.radius;
if (segHitsCircle(p.px, p.py, p.x, p.y, ast.x, ast.y, rr)) {
destroyAsteroid(state, ast, p.owner);
p.hp -= 1;
if (p.hp <= 0) { p.alive = false; return; }
}
}
}
export function destroyAsteroid(state, ast, bySide = null) {
if (!ast.alive) return;
ast.alive = false;
ast.respawnIn = TUNE.ASTEROID_RESPAWN_FRAMES;
emit(state, { t: 'asteroidBreak', x: ast.x, y: ast.y });
// Slylandro harvest passive.
for (const ship of state.ships) {
if (ship.alive && ship.side === bySide) {
for (const pv of ship.def.passives) PASSIVES[pv].onAsteroidKill?.(state, ship, ast);
}
}
}
function projectileProjectileCollisions(state) {
const ps = state.projectiles;
for (let i = 0; i < ps.length; i += 1) {
const a = ps[i];
if (!a.alive || !a.collidesShots) continue;
for (let j = i + 1; j < ps.length; j += 1) {
const b = ps[j];
if (!b.alive || !b.collidesShots || a.owner === b.owner) continue;
const rr = a.radius + b.radius;
if (tdist2(a.x, a.y, b.x, b.y) < rr * rr) {
a.hp -= b.damage > 0 ? Math.max(1, b.damage) : 1;
b.hp -= a.damage > 0 ? Math.max(1, a.damage) : 1;
if (a.hp <= 0) a.alive = false;
if (b.hp <= 0) b.alive = false;
emit(state, { t: 'shotHit', x: a.x, y: a.y, kind: 'clash' });
if (!a.alive) break;
}
}
}
}
function asteroidFrame(state, ast) {
if (!ast.alive) {
if (ast.respawnIn > 0) {
ast.respawnIn -= 1;
if (ast.respawnIn === 0) {
const idx = state.asteroids.indexOf(ast);
state.asteroids.splice(idx, 1);
spawnAsteroid(state, true);
}
}
return;
}
gravityFrame(state, ast);
ast.rot += ast.spin;
integrate(ast);
if (tdist2(ast.x, ast.y, state.planet.x, state.planet.y)
< (state.planet.radius + ast.radius) ** 2) {
destroyAsteroid(state, ast, null);
return;
}
for (const ship of state.ships) {
if (!ship.alive || ship.fx.phasing) continue;
const rr = ship.def.radius + ast.radius;
if (tdist2(ast.x, ast.y, ship.x, ship.y) < rr * rr) {
// Harmless bump: the rock shatters and nudges the ship.
ship.vx += ast.vx * 0.15; ship.vy += ast.vy * 0.15;
destroyAsteroid(state, ast, ship.side);
return;
}
}
}
function frame(state) {
state.frame += 1;
for (const ship of state.ships) {
if (ship.alive) shipFrame(state, ship);
}
for (const ship of state.ships) {
if (!ship.alive) continue;
if (!ship.fx.noGravity) gravityFrame(state, ship);
integrate(ship);
}
for (const ship of state.ships) {
if (ship.alive) planetCollision(state, ship);
}
shipShipCollision(state);
for (const p of state.projectiles) { if (p.alive) projectileFrame(state, p); }
projectileProjectileCollisions(state);
for (const ast of state.asteroids.slice()) asteroidFrame(state, ast);
state.projectiles = state.projectiles.filter((p) => p.alive);
if (!state.over) {
const deadA = !state.ships[0].alive;
const deadB = !state.ships[1].alive;
if (deadA || deadB) {
state.over = true;
state.winner = deadA && deadB ? 'draw' : (deadA ? 1 : 0);
emit(state, { t: 'battleOver', winner: state.winner });
} else if (state.frame >= TUNE.MAX_BATTLE_FRAMES) {
state.over = true;
state.winner = 'draw';
emit(state, { t: 'battleOver', winner: 'draw' });
}
}
}
// Advances the battle by dtMs of real time in fixed 24 fps frames and returns
// the events that occurred. `state.alpha` afterwards is the interpolation
// fraction [0..1) between the previous and current frame for rendering.
export function stepMatch(state, dtMs) {
if (!state.over) {
state.accumulatorMs += dtMs;
let frames = 0;
while (state.accumulatorMs >= STEP_MS && frames < TUNE.MAX_FRAMES_PER_STEP) {
state.accumulatorMs -= STEP_MS;
frame(state);
frames += 1;
if (state.over) break;
}
if (state.accumulatorMs >= STEP_MS) state.accumulatorMs %= STEP_MS;
}
state.alpha = Math.min(state.accumulatorMs / STEP_MS, 1);
const events = state.events;
state.events = [];
return events;
}
// ---------------------------------------------------------------------------
// Camera helpers (pure math so verify can cover them; the scene applies them).
export const ZOOM = {
CLOSE: 1920 / 2048, // world->screen px; ~UQM view 1
MED: 1920 / 4096, // ~UQM view 2
FAR: 1920 / 7680, // whole-arena width view
// Separation bands (world units) across which the zoom glides.
BAND_1: [900, 1900],
BAND_2: [2300, 4200],
SMOOTH_MS: 180,
};
export function smoothstep(a, b, x) {
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
return t * t * (3 - 2 * t);
}
// Ship separation -> target zoom (screen px per world unit).
export function targetZoom(dist) {
const t1 = smoothstep(ZOOM.BAND_1[0], ZOOM.BAND_1[1], dist);
const t2 = smoothstep(ZOOM.BAND_2[0], ZOOM.BAND_2[1], dist);
const z = ZOOM.CLOSE + (ZOOM.MED - ZOOM.CLOSE) * t1;
return z + (ZOOM.FAR - z) * t2;
}
// Frame-rate independent exponential approach.
export function smoothTowards(current, target, dtMs, tauMs = ZOOM.SMOOTH_MS) {
return current + (target - current) * (1 - Math.exp(-dtMs / tauMs));
}

File diff suppressed because it is too large Load Diff

View File

@ -90,6 +90,7 @@ import BalatroGame from './games/balatro/BalatroGame.js';
import PeggleGame from './games/peggle/PeggleGame.js';
import PeggleEditor from './games/peggle/PeggleEditor.js';
import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js';
import StarControlGame from './games/starcontrol/StarControlGame.js';
const config = {
type: Phaser.AUTO,
@ -193,6 +194,7 @@ const config = {
PeggleGame,
PeggleEditor,
ColoradoDefenseGame,
StarControlGame,
],
};

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
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' };
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' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

@ -61,6 +61,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('balatro-artwork', 'data/balatro-artwork.json');
this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json');
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
this.load.json('star-control-ships', 'data/star-control-ships.json');
this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3');

View File

@ -0,0 +1,180 @@
// One-time asset/codegen fetcher for Star Control Super Melee.
//
// node tools/fetchStarControlAssets.js --ships # download + build ship rotation sheets
// node tools/fetchStarControlAssets.js --stats # print UQM stat blocks for manual review
//
// Downloads ship art and stats from the Ur-Quan Masters GitHub mirror
// (juj/sc2-uqm). UQM content is CC BY-NC-SA 2.5 — an attribution line ships in
// data/star-control-ships.json and on the game's setup screen. This script is
// never run by the site; it needs network access plus ImageMagick (`magick`).
//
// --ships: for each ship, the GitHub contents API lists
// sc2/content/base/ships/<dir>/, the 16 `<base>-big-000..015.png` rotation
// frames are downloaded, montaged into a 16x1 spritesheet at
// assets/images/starcontrol/sc-ship-<slug>.png, and the measured frame
// dimensions are written into that ship's `sprite` block in
// data/star-control-ships.json (only the sprite block is touched).
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const REPO = 'juj/sc2-uqm';
const CONTENT = 'sc2/content/base/ships';
const SRC = 'sc2/src/uqm/ships';
const OUT_DIR = join(root, 'assets/images/starcontrol');
const JSON_PATH = join(root, 'data/star-control-ships.json');
// slug (our JSON key) -> UQM content directory. Frame prefixes are discovered,
// not guessed (e.g. human/cruiser-big-000.png).
const CONTENT_DIRS = {
androsynth: 'androsynth', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr',
druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'kohrah',
melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz',
pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandro', spathi: 'spathi',
supox: 'supox', syreen: 'syreen', thraddash: 'thraddash', umgah: 'umgah',
urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfotpik',
};
// slug -> the rotation-frame prefix that is the SHIP (several directories
// also hold 16-frame weapon animations like spathi's "butt-big-000..015").
const SHIP_BASE = {
androsynth: 'guardian', arilou: 'skiff', chenjesu: 'broodhome', chmmr: 'avatar',
druuge: 'mauler', earthling: 'cruiser', ilwrath: 'avenger', kohrah: 'marauder',
melnorme: 'trader', mmrnmhrm: 'xform', mycon: 'podship', orz: 'nemesis',
pkunk: 'fury', shofixti: 'scout', slylandro: 'probe', spathi: 'eluder',
supox: 'blade', syreen: 'penetrator', thraddash: 'torch', umgah: 'drone',
urquan: 'dreadnought', utwig: 'jugger', vux: 'intruder', yehat: 'terminator',
zoqfot: 'stinger',
};
// slug -> UQM source directory for --stats.
const SRC_DIRS = {
androsynth: 'androsyn', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr',
druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'blackurq',
melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz',
pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandr', spathi: 'spathi',
supox: 'supox', syreen: 'syreen', thraddash: 'thradd', umgah: 'umgah',
urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfot',
};
async function ghJson(path) {
const res = await fetch(`https://api.github.com/repos/${REPO}/contents/${path}`, {
headers: { 'User-Agent': 'fertig-classic-games-asset-fetch' },
});
if (!res.ok) throw new Error(`GitHub API ${res.status} for ${path}`);
return res.json();
}
async function download(url, dest) {
const res = await fetch(url);
if (!res.ok) throw new Error(`download ${res.status} for ${url}`);
writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
}
// Finds the rotation-frame prefix: a group of exactly 16 files named
// `<base>-big-000.png` .. `<base>-big-015.png` (weapon/captain art doesn't
// match this shape; e.g. human's saturn-big has 23 frames and is skipped).
function findRotationGroup(listing, preferredBase) {
const groups = {};
for (const f of listing) {
const m = /^(.+)-big-(\d{3})\.png$/.exec(f.name);
if (!m) continue;
(groups[m[1]] ??= []).push({ n: Number(m[2]), url: f.download_url });
}
const candidates = Object.entries(groups)
.filter(([, frames]) => frames.length === 16
&& Math.min(...frames.map((fr) => fr.n)) === 0
&& Math.max(...frames.map((fr) => fr.n)) === 15);
if (candidates.length === 0) return null;
let picked = candidates.find(([base]) => base === preferredBase);
if (!picked) {
console.warn(` preferred base "${preferredBase}" not found; candidates: ${candidates.map(([k]) => k).join(', ')}`);
[picked] = candidates;
}
const [base, frames] = picked;
frames.sort((a, b) => a.n - b.n);
return { base, frames };
}
async function buildSheet(slug, group, suffix = '') {
const tmp = join(tmpdir(), `sc-fetch-${slug}${suffix}`);
rmSync(tmp, { recursive: true, force: true });
mkdirSync(tmp, { recursive: true });
const files = [];
for (const fr of group.frames) {
const dest = join(tmp, `f-${String(fr.n).padStart(3, '0')}.png`);
await download(fr.url, dest);
files.push(dest);
}
const sheetName = `sc-ship-${slug}${suffix}`;
const out = join(OUT_DIR, `${sheetName}.png`);
execFileSync('magick', ['montage', ...files, '-tile', '16x1', '-geometry', '+0+0', '-background', 'none', out]);
const dims = execFileSync('magick', ['identify', '-format', '%w %h', files[0]]).toString().trim().split(' ');
rmSync(tmp, { recursive: true, force: true });
return { sheet: sheetName, frames: 16, frameWidth: Number(dims[0]), frameHeight: Number(dims[1]) };
}
async function fetchShips() {
mkdirSync(OUT_DIR, { recursive: true });
const json = JSON.parse(readFileSync(JSON_PATH, 'utf8'));
const results = [];
for (const [slug, dir] of Object.entries(CONTENT_DIRS)) {
if (!json.ships[slug]) { console.warn(`skip ${slug}: not in ships JSON`); continue; }
process.stdout.write(`${slug} (${dir}) ... `);
try {
const listing = await ghJson(`${CONTENT}/${dir}`);
const group = findRotationGroup(listing, SHIP_BASE[slug]);
if (!group) { console.log('NO 16-frame rotation group found'); continue; }
json.ships[slug].sprite = await buildSheet(slug, group);
console.log(`ok (${group.base}, ${json.ships[slug].sprite.frameWidth}x${json.ships[slug].sprite.frameHeight})`);
// Mmrnmhrm's alternate Y-Wing form has its own rotation frames.
if (slug === 'mmrnmhrm' && json.ships[slug].forms?.[0]) {
const yGroup = findRotationGroup(listing, 'ywing');
if (yGroup?.base === 'ywing') {
json.ships[slug].forms[0].sprite = await buildSheet(slug, yGroup, '-y');
console.log(` + y-form sheet (${json.ships[slug].forms[0].sprite.frameWidth}x${json.ships[slug].forms[0].sprite.frameHeight})`);
}
}
results.push(slug);
} catch (e) {
console.log(`FAILED: ${e.message}`);
}
}
writeFileSync(JSON_PATH, `${JSON.stringify(json, null, 2)}\n`);
console.log(`\n${results.length}/${Object.keys(CONTENT_DIRS).length} sheets built; sprite blocks written to data/star-control-ships.json`);
}
const STAT_DEFINES = [
'MAX_CREW', 'MAX_ENERGY', 'ENERGY_REGENERATION', 'ENERGY_WAIT', 'MAX_THRUST',
'THRUST_INCREMENT', 'THRUST_WAIT', 'TURN_WAIT', 'SHIP_MASS',
'WEAPON_ENERGY_COST', 'WEAPON_WAIT', 'SPECIAL_ENERGY_COST', 'SPECIAL_WAIT',
'MISSILE_SPEED', 'MISSILE_LIFE', 'MISSILE_HITS', 'MISSILE_DAMAGE',
];
// Prints raw #define blocks for manual review/merge — NOT written to the JSON
// (several ships encode stats in code: Chmmr zapsats, Slylandro drive,
// Mmrnmhrm forms).
async function fetchStats() {
for (const [slug, dir] of Object.entries(SRC_DIRS)) {
const url = `https://raw.githubusercontent.com/${REPO}/master/${SRC}/${dir}/${dir}.c`;
const res = await fetch(url);
if (!res.ok) { console.log(`${slug}: fetch failed ${res.status}`); continue; }
const text = await res.text();
const out = {};
for (const name of STAT_DEFINES) {
const m = new RegExp(`^#define\\s+${name}\\s+(.+)$`, 'm').exec(text);
if (m) out[name] = m[1].replace(/\/\*.*?\*\//g, '').trim();
}
console.log(`\n=== ${slug} (${dir}.c)`);
console.log(JSON.stringify(out, null, 2));
}
}
const args = process.argv.slice(2);
if (args.includes('--stats')) await fetchStats();
else if (args.includes('--ships')) await fetchShips();
else console.log('usage: node tools/fetchStarControlAssets.js --ships | --stats');

730
tools/verifyStarControl.js Normal file
View File

@ -0,0 +1,730 @@
// Headless verification for Star Control Super Melee.
// node tools/verifyStarControl.js
// Exits non-zero on any failure.
//
// 1. Ship JSON schema: every entry compiles, every type resolves in the
// behavior registries, stats in sane bounds.
// 2. Toroidal math (wrap/tdelta/tmid) spot checks.
// 3. Determinism: same seed + same input script => identical state hash.
// 4. Fuzz invariants: energy/crew always in [0, max] under random inputs.
// 5. Physics fixtures: thrust cap, gravity pull, planet bounce + damage.
// 6. Weapon fixtures per pilot ship (hit registration + exact damage),
// special fixtures (point defense, glory falloff, fighters launch/dock,
// cloak vs homing, rear B.U.T.T.).
// 7. Smooth-zoom mapping: monotonic, anchored at the three UQM-style levels.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
STEP_MS, ARENA_W, ARENA_H, TUNE, ZOOM,
mulberry32, wrap, tdelta, tdist, tmid, quantizeFacing, angleDelta,
compileShips, createBattle, setInput, stepMatch,
targetZoom, smoothTowards, interceptAngle,
} from '../src/games/starcontrol/StarControlLogic.js';
import { PRIMARIES, SPECIALS, PASSIVES } from '../src/games/starcontrol/StarControlShips.js';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const shipsJson = JSON.parse(readFileSync(join(root, 'data/star-control-ships.json'), 'utf8'));
let failures = 0;
let passes = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; return; }
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
function section(name) { console.log(`\n== ${name}`); }
// Steps whole 24fps frames deterministically (no accumulator drift in tests).
function stepFrames(state, n, collect = false) {
const events = [];
for (let i = 0; i < n; i += 1) {
const evs = stepMatch(state, STEP_MS);
if (collect) events.push(...evs);
if (state.over) break;
}
return events;
}
const SHIPS = compileShips(shipsJson);
const keys = Object.keys(SHIPS);
function battle(aKey, bKey, seed = 7) {
const st = createBattle({ a: { def: SHIPS[aKey] }, b: { def: SHIPS[bKey] }, seed });
st.asteroids = []; // fixtures don't want random rocks in the way
return st;
}
function place(ship, x, y, facing = 0, vx = 0, vy = 0) {
ship.x = wrap(x, ARENA_W); ship.y = wrap(y, ARENA_H);
ship.px = ship.x; ship.py = ship.y;
ship.facing = facing; ship.vx = vx; ship.vy = vy;
}
// ---------------------------------------------------------------------------
section('1. ship JSON schema');
check('ships JSON has entries', keys.length > 0);
check('credits line present (CC BY-NC-SA attribution)', /CC BY-NC-SA/.test(shipsJson.credits ?? ''));
for (const key of keys) {
const def = SHIPS[key];
check(`${key}: primary type registered`, !!PRIMARIES[def.primary.type], def.primary.type);
check(`${key}: special type registered`, !!SPECIALS[def.special.type], def.special.type);
for (const p of def.passives) check(`${key}: passive registered`, !!PASSIVES[p], p);
check(`${key}: crew/energy sane`, def.stats.maxCrew >= 1 && def.stats.maxCrew <= 42
&& def.stats.maxEnergy >= 1 && def.stats.maxEnergy <= 42);
check(`${key}: point value sane`, def.pointValue >= 1 && def.pointValue <= 30);
check(`${key}: has ai hints`, !!def.ai && typeof def.ai.preferredRange === 'number');
check(`${key}: radius derived`, def.radius > 0);
check(`${key}: turn rate derived`, def.turnPerFrame > 0);
}
check('bad ship def throws (named reason)', (() => {
try { compileShips({ ships: { junk: { name: 'X', team: 'alliance', pointValue: 1, stats: {}, primary: { type: 'nope' }, special: { type: 'nope' } } } }); return false; }
catch (e) { return /junk/.test(e.message); }
})());
// ---------------------------------------------------------------------------
section('2. toroidal math');
check('wrap into range', wrap(-10, ARENA_W) === ARENA_W - 10 && wrap(ARENA_W + 5, ARENA_W) === 5);
check('tdelta short way', tdelta(100, ARENA_W - 100, ARENA_W) === -200);
check('tdelta antisymmetry', tdelta(500, 4000, ARENA_W) === -tdelta(4000, 500, ARENA_W));
{
const m = tmid(ARENA_W - 100, 100, 100, 100);
check('tmid crosses seam', m.x === 0 || m.x === ARENA_W, `got ${m.x}`);
}
check('angleDelta wraps', Math.abs(angleDelta(0.1, Math.PI * 2 - 0.1) + 0.2) < 1e-9);
check('quantizeFacing wraps negatives', quantizeFacing(-0.01) === 0 && quantizeFacing(Math.PI) === 8);
// ---------------------------------------------------------------------------
section('3. determinism');
{
const hashOf = (st) => st.ships.map((s) => [
s.x.toFixed(4), s.y.toFixed(4), s.vx.toFixed(4), s.vy.toFixed(4),
s.facing.toFixed(4), s.crew, s.energy,
].join(',')).join('|') + `#${st.projectiles.length}`;
const run = () => {
const st = createBattle({ a: { def: SHIPS.earthling }, b: { def: SHIPS.spathi }, seed: 42 });
const rng = mulberry32(99);
let acc = '';
for (let f = 0; f < 2000 && !st.over; f += 1) {
for (const side of [0, 1]) {
setInput(st, side, {
left: rng() < 0.2, right: rng() < 0.2, thrust: rng() < 0.5,
fire: rng() < 0.3, special: rng() < 0.1,
});
}
stepMatch(st, STEP_MS);
if (f % 100 === 0) acc += hashOf(st);
}
return acc;
};
check('same seed + inputs => identical trace', run() === run());
}
// ---------------------------------------------------------------------------
section('4. fuzz invariants (energy/crew bounds, positions in-arena)');
for (const key of keys) {
const st = createBattle({ a: { def: SHIPS[key] }, b: { def: SHIPS.urquan }, seed: 11 });
const rng = mulberry32(1234);
let ok = true;
let detail = '';
for (let f = 0; f < 5000 && !st.over; f += 1) {
for (const side of [0, 1]) {
setInput(st, side, {
left: rng() < 0.25, right: rng() < 0.25, thrust: rng() < 0.5,
fire: rng() < 0.35, special: rng() < 0.15,
});
}
stepMatch(st, STEP_MS);
for (const s of st.ships) {
if (s.energy < 0 || s.energy > s.def.stats.maxEnergy) { ok = false; detail = `${s.def.key} energy ${s.energy}`; }
if (s.crew < 0 || s.crew > s.def.stats.maxCrew) { ok = false; detail = `${s.def.key} crew ${s.crew}`; }
if (s.x < 0 || s.x >= ARENA_W || s.y < 0 || s.y >= ARENA_H) { ok = false; detail = `${s.def.key} pos ${s.x},${s.y}`; }
if (!Number.isFinite(s.x + s.y + s.vx + s.vy + s.facing)) { ok = false; detail = `${s.def.key} NaN`; }
}
if (!ok) break;
}
check(`fuzz ${key} vs urquan`, ok, detail);
}
// ---------------------------------------------------------------------------
section('5. physics fixtures');
{
// Thrust alone never exceeds maxThrust.
const st = battle('shofixti', 'urquan');
const savedG = TUNE.PLANET_GRAVITY;
TUNE.PLANET_GRAVITY = 0;
const A = st.ships[0];
place(A, 1000, 1000, 0);
setInput(st, 0, { thrust: true });
stepFrames(st, 200);
const speed = Math.hypot(A.vx, A.vy);
check('thrust reaches maxThrust', Math.abs(speed - A.def.stats.maxThrust) < 0.6, `speed ${speed}`);
stepFrames(st, 100);
check('thrust never exceeds maxThrust', Math.hypot(A.vx, A.vy) <= A.def.stats.maxThrust + 1e-6);
TUNE.PLANET_GRAVITY = savedG;
}
{
// Gravity pulls a coasting ship toward the planet.
const st = battle('earthling', 'urquan');
const A = st.ships[0];
place(A, ARENA_W / 2 - 1500, ARENA_H / 2, 0);
const d0 = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2);
stepFrames(st, 30);
const d1 = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2);
check('gravity pulls ship planet-ward', d1 < d0, `${d0} -> ${d1}`);
}
{
// Planet impact costs crew (once per cooldown window) and ejects the ship.
const st = battle('urquan', 'earthling');
const A = st.ships[0];
place(A, ARENA_W / 2 - 600, ARENA_H / 2, 0, 60, 0);
const crew0 = A.crew;
let bounced = false;
for (let f = 0; f < 30 && !bounced; f += 1) {
bounced = stepFrames(st, 1, true).some((e) => e.t === 'planetBounce');
}
stepFrames(st, 3);
check('planetBounce event emitted', bounced);
check('planet bounce damages crew once', A.crew === crew0 - TUNE.PLANET_CREW_DAMAGE, `crew ${A.crew}`);
const d = tdist(A.x, A.y, ARENA_W / 2, ARENA_H / 2);
check('ship ejected from planet', d > st.planet.radius, `d ${d}`);
}
{
// Ship-ship collision: both take a knock at speed, momentum pushes apart.
const st = battle('urquan', 'earthling');
const [A, B] = st.ships;
const savedG = TUNE.PLANET_GRAVITY;
TUNE.PLANET_GRAVITY = 0;
place(A, 3000, 1000, 0, 80, 0);
place(B, 3000 + A.def.radius + B.def.radius + 90, 1000, Math.PI, -80, 0);
const crews = [A.crew, B.crew];
stepFrames(st, 20);
check('collision damages both ships', A.crew < crews[0] && B.crew < crews[1], `${A.crew},${B.crew}`);
check('collision separates ships', tdist(A.x, A.y, B.x, B.y) >= A.def.radius + B.def.radius - 1);
TUNE.PLANET_GRAVITY = savedG;
}
// ---------------------------------------------------------------------------
section('6. weapon + special fixtures');
const savedG2 = TUNE.PLANET_GRAVITY;
TUNE.PLANET_GRAVITY = 0; // keep fixtures geometric
{
// Every pilot primary lands on a stationary hulk dead ahead, placed inside
// that weapon's actual reach (flamethrowers are short, nukes are long).
for (const key of keys) {
const st = battle(key, key === 'urquan' ? 'earthling' : 'urquan');
const [A, B] = st.ships;
const pd = A.def.primary;
const reach = pd.type === 'beam'
? pd.range * 4
: (pd.muzzleDist ?? 12) * 4 + (pd.speed ?? 0) * (pd.lifeFrames ?? 0);
const dist = Math.min(600, Math.max(220, reach * 0.6));
place(A, 2000, 3000, 0);
place(B, 2000 + dist, 3000, Math.PI);
A.fx.warped = true; B.fx.warped = true; // suppress VUX-style warp-in repositioning
const crew0 = B.crew;
setInput(st, 0, { fire: true });
stepFrames(st, 90);
setInput(st, 0, { fire: false }); // charge-shot weapons launch on release
stepFrames(st, 40);
check(`${key} primary hits`, B.crew < crew0, `crew still ${B.crew} at dist ${dist}`);
}
}
{
// Exact damage: one Ur-Quan fusion bolt = 6 crew.
const st = battle('urquan', 'earthling');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2900, 3000, Math.PI);
setInput(st, 0, { fire: true });
stepFrames(st, 1);
setInput(st, 0, { fire: false });
stepFrames(st, 40);
check('fusion bolt exact damage', B.crew === B.def.stats.maxCrew - 6, `crew ${B.crew}`);
}
{
// Homing: nuke curves onto a target off boresight; cloak defeats tracking.
const run = (cloaked) => {
const st = battle('earthling', 'ilwrath');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000 + 1200, 3000 - 900, 0);
if (cloaked) B.fx.cloaked = true;
setInput(st, 0, { fire: true });
stepFrames(st, 2);
setInput(st, 0, { fire: false });
stepFrames(st, 70);
return B.crew < B.def.stats.maxCrew;
};
check('nuke tracks an off-axis target', run(false));
check('cloak defeats nuke tracking', !run(true));
}
{
// Point defense: zaps an incoming missile before impact, costs energy.
const st = battle('earthling', 'spathi');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000, 3000 + 260, -Math.PI / 2);
// Spathi B.U.T.T. fired away from us won't reach; instead have Spathi shoot at us.
place(B, 2600, 3000, Math.PI);
setInput(st, 1, { fire: true });
stepFrames(st, 3);
setInput(st, 1, { fire: false });
const e0 = A.energy;
let zapped = false;
for (let f = 0; f < 30 && !zapped; f += 1) {
setInput(st, 0, { special: true });
zapped = stepMatch(st, STEP_MS).some((e) => e.t === 'pdZap');
}
check('point defense zaps incoming fire', zapped);
check('point defense costs energy', A.energy < e0, `${e0} -> ${A.energy}`);
check('shot never landed', A.crew === A.def.stats.maxCrew);
}
{
// Glory device: falloff damage, kills the scout, ends the battle.
const dmgAt = (dist) => {
const st = battle('shofixti', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000 + dist, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 3);
return { dmg: B.def.stats.maxCrew - B.crew, st, A };
};
const near = dmgAt(200);
const far = dmgAt(650);
const out = dmgAt(1000);
check('glory kills the scout', !near.A.alive);
check('glory near > far damage', near.dmg > far.dmg, `${near.dmg} vs ${far.dmg}`);
check('glory out of range = 0', out.dmg === 0, `${out.dmg}`);
const expect = Math.floor((18 * (720 - 200)) / 720) + 1;
check('glory matches UQM falloff formula', near.dmg === expect, `${near.dmg} vs ${expect}`);
}
{
// Fighters: launch costs a crew member, they harass, then dock and return it.
const st = battle('urquan', 'earthling');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000 + 1500, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
check('fighter launched', st.projectiles.some((p) => p.type === 'fighter'));
check('launch costs 1 crew', A.crew === A.def.stats.maxCrew - 1);
const crewB0 = B.crew;
const evs = stepFrames(st, 500, true);
check('fighter shot at the enemy', B.crew < crewB0 || evs.some((e) => e.t === 'pdZap'));
check('fighter docked home (crew restored)', A.crew === A.def.stats.maxCrew
|| evs.some((e) => e.t === 'fighterDock'), `crew ${A.crew}`);
}
{
// B.U.T.T.: rear-launched missile homes onto a pursuer behind the Eluder.
const st = battle('spathi', 'urquan');
const [A, B] = st.ships;
place(A, 3000, 3000, 0, 20, 0);
place(B, 3000 - 700, 3000, 0, 20, 0); // chasing from behind
const crew0 = B.crew;
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
stepFrames(st, 40);
check('B.U.T.T. hits the pursuer', B.crew === crew0 - 2, `crew ${B.crew}`);
}
{
// Asteroids block shots and shatter.
const st = battle('spathi', 'urquan');
const [A] = st.ships;
place(A, 2000, 3000, 0);
place(st.ships[1], 6000, 6000, 0);
st.asteroids.push({
id: 9999, kind: 'asteroid', x: 2400, y: 3000, px: 2400, py: 3000,
vx: 0, vy: 0, spin: 0, rot: 0, radius: TUNE.ASTEROID_RADIUS, alive: true,
});
setInput(st, 0, { fire: true });
const evs = stepFrames(st, 10, true);
check('asteroid shattered by shot', evs.some((e) => e.t === 'asteroidBreak'));
}
{
// Battle-over bookkeeping: winner declared once, loser reported.
const st = battle('shofixti', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2150, 3000, Math.PI);
setInput(st, 0, { special: true }); // glory at point blank vs 42-crew hulk: scout dies
const evs = stepFrames(st, 5, true);
check('battleOver emitted', evs.some((e) => e.t === 'battleOver'));
check('winner is the dreadnought', st.winner === 1, `winner ${st.winner}`);
check('death event for scout', evs.some((e) => e.t === 'death' && e.side === 0));
}
// ---------------------------------------------------------------------------
section('6b. special-ability fixtures (full roster)');
{
// Teleport relocates the Skiff.
const st = battle('arilou', 'urquan');
const A = st.ships[0];
place(A, 2000, 3000, 0);
const x0 = A.x;
setInput(st, 0, { special: true });
stepFrames(st, 2);
check('teleport relocates', tdist(A.x, A.y, x0, 3000) > 400 || A.x !== x0);
}
{
// Yehat force shield blocks a fusion bolt outright.
const st = battle('yehat', 'urquan');
const [A, B] = st.ships;
place(A, 2900, 3000, 0);
place(B, 2000, 3000, 0);
A.fx.shieldFrames = 200;
setInput(st, 1, { fire: true });
stepFrames(st, 1);
setInput(st, 1, { fire: false });
stepFrames(st, 40);
check('force shield blocks damage', A.crew === A.def.stats.maxCrew, `crew ${A.crew}`);
}
{
// Utwig absorption shield converts damage into battery charge.
const st = battle('utwig', 'urquan');
const [A, B] = st.ships;
place(A, 2900, 3000, 0);
place(B, 2000, 3000, 0);
A.energy = 2;
A.fx.absorbFrames = 200;
setInput(st, 1, { fire: true });
stepFrames(st, 1);
setInput(st, 1, { fire: false });
stepFrames(st, 40);
check('absorption shield converts damage', A.crew === A.def.stats.maxCrew && A.energy === 8,
`crew ${A.crew} energy ${A.energy}`);
}
{
// Druuge furnace trades a crewman for a full battery.
const st = battle('druuge', 'urquan');
const A = st.ships[0];
A.energy = 3;
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('furnace refills battery for 1 crew',
A.crew === A.def.stats.maxCrew - 1 && A.energy === A.def.stats.maxEnergy,
`crew ${A.crew} energy ${A.energy}`);
}
{
// Mycon regeneration: full battery -> +4 crew.
const st = battle('mycon', 'urquan');
const A = st.ships[0];
A.crew = 10;
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('mycon regenerates 4 crew', A.crew === 14 && A.energy <= 1, `crew ${A.crew} energy ${A.energy}`);
}
{
// Pkunk taunt restores energy; resurrection lands in a plausible band.
const st = battle('pkunk', 'urquan');
const A = st.ships[0];
A.energy = 0;
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('pkunk taunt restores energy', A.energy === 2, `energy ${A.energy}`);
let rebirths = 0;
let deaths = 0;
const N = 400;
for (let i = 0; i < N; i += 1) {
const st2 = battle('pkunk', 'urquan', 500 + i);
const P = st2.ships[0];
place(P, 2600, 3000, 0);
place(st2.ships[1], 2000, 3000, 0);
setInput(st2, 1, { fire: true });
for (let f = 0; f < 80; f += 1) {
const evs = stepFrames(st2, 1, true);
if (evs.some((e) => e.t === 'death' && e.side === 0)) { deaths += 1; break; }
if (evs.some((e) => e.t === 'resurrect' && e.side === 0)) { deaths += 1; rebirths += 1; break; }
}
}
check('pkunk resurrection ~50% (0.40-0.60 band)',
deaths >= 100 && rebirths / deaths > 0.4 && rebirths / deaths < 0.6,
`${rebirths}/${deaths}`);
}
{
// VUX limpets degrade the enemy's turn rate.
const st = battle('yehat', 'vux');
const A = st.ships[0];
A.fx.limpets = 4;
place(A, 2000, 3000, 0);
setInput(st, 0, { right: true });
stepFrames(st, 24);
const slowed = A.facing;
const st2 = battle('yehat', 'vux');
const A2 = st2.ships[0];
place(A2, 2000, 3000, 0);
setInput(st2, 0, { right: true });
stepFrames(st2, 24);
check('limpets slow turning', slowed < A2.facing * 0.6, `${slowed} vs ${A2.facing}`);
}
{
// Orz marines board and chew crew.
const st = battle('orz', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2600, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
check('marine launched costs 1 crew', A.crew === A.def.stats.maxCrew - 1);
stepFrames(st, 300);
check('marines chew enemy crew', B.crew < B.def.stats.maxCrew, `crew ${B.crew}`);
}
{
// Chenjesu DOGI drains enemy energy; shard fragments on release.
const st = battle('chenjesu', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2900, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
check('DOGI spawned', st.projectiles.some((p) => p.type === 'dogi'));
stepFrames(st, 200);
check('DOGI drained enemy energy', B.energy < B.def.stats.maxEnergy, `energy ${B.energy}`);
const st2 = battle('chenjesu', 'urquan');
place(st2.ships[0], 2000, 3000, 0);
place(st2.ships[1], 6000, 5000, Math.PI);
setInput(st2, 0, { fire: true });
stepFrames(st2, 1);
setInput(st2, 0, { fire: false });
stepFrames(st2, 2);
check('shard shatters into fragments on release',
st2.projectiles.filter((p) => p.type === 'fragment').length === 8
&& !st2.projectiles.some((p) => p.type === 'shard'));
}
{
// Chmmr zapsats orbit and shoot down incoming missiles.
const st = battle('chmmr', 'earthling');
const [A, B] = st.ships;
check('zapsats spawned at battle start', st.projectiles.filter((p) => p.type === 'zapsat').length === 3);
place(A, 2000, 3000, 0);
place(B, 3200, 3000, Math.PI);
setInput(st, 1, { fire: true });
stepFrames(st, 2);
setInput(st, 1, { fire: false });
const evs = stepFrames(st, 60, true);
check('zapsats defend the Avatar', A.crew === A.def.stats.maxCrew || evs.some((e) => e.t === 'pdZap'));
}
{
// Androsynth blazer: constant-speed comet, contact does 3 crew.
const st = battle('androsynth', 'earthling');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2000 + 700, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
check('blazer engaged', A.fx.blazer === true);
const crew0 = B.crew;
stepFrames(st, 40);
check('blazer contact damage', B.crew <= crew0 - 3, `crew ${B.crew}`);
}
{
// Kohr-Ah F.R.I.E.D. clears incoming ordnance.
const st = battle('kohrah', 'earthling');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 3000, 3000, Math.PI);
setInput(st, 1, { fire: true });
stepFrames(st, 2);
setInput(st, 1, { fire: false });
stepFrames(st, 8);
setInput(st, 0, { special: true });
stepFrames(st, 2);
check('FRIED burns off incoming missiles',
!st.projectiles.some((p) => p.alive && p.owner === 1 && p.type === 'missile'));
}
{
// Syreen song pulls crew out as floaters; the Penetrator can rescue them.
const st = battle('syreen', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2600, 3000, Math.PI);
const crewB0 = B.crew;
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
const floaters = st.projectiles.filter((p) => p.type === 'crew').length;
check('song pulls crew overboard', floaters > 0 && B.crew < crewB0, `${floaters} floaters`);
}
{
// Mmrnmhrm transform swaps stats and weapon.
const st = battle('mmrnmhrm', 'urquan');
const A = st.ships[0];
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('transform costs the battery', A.energy <= 2, `energy ${A.energy}`);
check('Y-form stats active', A.fx.formIndex === 1 && A.fx.formStats.maxThrust === 50);
check('Y-form weapon active', A.fx.formPrimary.type === 'homing');
}
{
// Umgah cone destroys incoming shots; retro hop jumps backward.
const st = battle('umgah', 'earthling');
const [A, B] = st.ships;
const savedG3 = TUNE.PLANET_GRAVITY;
TUNE.PLANET_GRAVITY = 0;
place(A, 2000, 3000, 0);
place(B, 2900, 3000, Math.PI);
setInput(st, 1, { fire: true });
stepFrames(st, 2);
setInput(st, 1, { fire: false });
setInput(st, 0, { fire: true });
stepFrames(st, 24);
check('cone destroys incoming missile',
!st.projectiles.some((p) => p.alive && p.owner === 1 && p.type === 'missile'));
setInput(st, 0, { fire: false });
const x0 = A.x;
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('retro hop jumps backward', tdelta(x0, A.x, ARENA_W) < -100, `dx ${tdelta(x0, A.x, ARENA_W)}`);
TUNE.PLANET_GRAVITY = savedG3;
}
{
// Slylandro probe drive: always at full speed; harvest refuels from rocks.
const st = battle('slylandro', 'urquan');
const A = st.ships[0];
stepFrames(st, 5);
check('probe always moves at max thrust',
Math.abs(Math.hypot(A.vx, A.vy) - A.def.stats.maxThrust) < 0.001);
A.energy = 0;
st.asteroids = [{
id: 777, kind: 'asteroid', x: wrap(A.x + 200, ARENA_W), y: A.y, px: A.x, py: A.y,
vx: 0, vy: 0, spin: 0, rot: 0, radius: 60, alive: true,
}];
setInput(st, 0, { special: true });
stepFrames(st, 1);
check('harvest converts asteroid to energy', A.energy === 8, `energy ${A.energy}`);
}
{
// Melnorme confusion scrambles steering flag.
const st = battle('melnorme', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
place(B, 2500, 3000, Math.PI);
setInput(st, 0, { special: true });
stepFrames(st, 1);
setInput(st, 0, { special: false });
stepFrames(st, 20);
check('confusion pulse scrambles the enemy', (B.fx.confusedFrames ?? 0) > 0);
}
{
// Thraddash afterburner lays damaging napalm.
const st = battle('thraddash', 'urquan');
const [A, B] = st.ships;
place(A, 2000, 3000, 0);
setInput(st, 0, { special: true });
stepFrames(st, 10);
const trail = st.projectiles.filter((p) => p.type === 'napalm');
check('afterburner lays napalm trail', trail.length > 0);
if (trail.length > 0) {
place(B, trail[0].x, trail[0].y, 0);
const crew0 = B.crew;
stepFrames(st, 3);
check('napalm burns the enemy', B.crew < crew0, `crew ${B.crew}`);
}
}
TUNE.PLANET_GRAVITY = savedG2;
// ---------------------------------------------------------------------------
section('6c. fleets');
{
const { PRESET_FLEETS, materializeFleet, fleetPoints, validateFleet, randomFleet, aiCounterPick, FLEET_MAX } =
await import('../src/games/starcontrol/StarControlFleets.js');
for (const preset of PRESET_FLEETS) {
const m = materializeFleet(SHIPS, preset.ships);
check(`fleet "${preset.id}" fully materializes`, m.missing.length === 0, `missing ${m.missing.join(',')}`);
check(`fleet "${preset.id}" within ${FLEET_MAX}`, m.entries.length <= FLEET_MAX);
check(`fleet "${preset.id}" validates`, validateFleet(SHIPS, m.entries).ok);
}
const alliance = materializeFleet(SHIPS, PRESET_FLEETS[0].ships);
const hierarchy = materializeFleet(SHIPS, PRESET_FLEETS[1].ships);
check('default matchup point-balanced (within 10)',
Math.abs(alliance.points - hierarchy.points) <= 10,
`${alliance.points} vs ${hierarchy.points}`);
const rf = randomFleet(SHIPS, mulberry32(5), 200);
check('grab bag within budget', fleetPoints(SHIPS, rf) <= 200 && rf.length >= 1);
const pick = aiCounterPick(SHIPS, ['shofixti', 'chmmr'], 'urquan', mulberry32(9));
check('counter-pick avoids scout vs dreadnought', pick === 'chmmr', pick);
}
// ---------------------------------------------------------------------------
section('7. smooth zoom mapping');
{
check('close anchor at short range', Math.abs(targetZoom(0) - ZOOM.CLOSE) < 1e-9);
check('med anchor between bands', Math.abs(targetZoom((ZOOM.BAND_1[1] + ZOOM.BAND_2[0]) / 2) - ZOOM.MED) < 1e-9);
check('far anchor at long range', Math.abs(targetZoom(99999) - ZOOM.FAR) < 1e-9);
let mono = true;
let prev = Infinity;
for (let d = 0; d <= 6000; d += 25) {
const z = targetZoom(d);
if (z > prev + 1e-12) mono = false;
prev = z;
}
check('zoom monotonically non-increasing with distance', mono);
check('far viewport fits arena', 1920 / ZOOM.FAR <= ARENA_W && 1080 / ZOOM.FAR <= ARENA_H);
const z1 = smoothTowards(1, 0.25, 180);
const z2 = smoothTowards(z1, 0.25, 180);
check('smoothing approaches without overshoot', z1 > 0.25 && z2 > 0.25 && z2 < z1);
}
// ---------------------------------------------------------------------------
section('8. AI pilots (soak + skill gradient)');
{
const { createAI, updateAI, knobsFor } = await import('../src/games/starcontrol/StarControlAI.js');
check('knobs interpolate between anchors', (() => {
const k = knobsFor(6);
return k.reactMs < knobsFor(5).reactMs && k.reactMs > knobsFor(7).reactMs;
})());
const playBattle = (aKey, bKey, skillA, skillB, seed) => {
const st = createBattle({ a: { def: SHIPS[aKey] }, b: { def: SHIPS[bKey] }, seed });
const aiA = createAI({ skill: skillA, side: 0, seed: seed + 1 });
const aiB = createAI({ skill: skillB, side: 1, seed: seed + 2 });
let hits = 0;
while (!st.over && st.frame < 2160) { // 90 sim-seconds cap
setInput(st, 0, updateAI(aiA, st, STEP_MS));
setInput(st, 1, updateAI(aiB, st, STEP_MS));
for (const e of stepMatch(st, STEP_MS)) if (e.t === 'shipHit') hits += 1;
}
return { winner: st.over ? st.winner : 'timeout', hits, frames: st.frame };
};
let decisive = 0;
let total = 0;
let battlesWithHits = 0;
let threw = null;
try {
for (const aKey of keys) {
for (const bKey of keys) {
const r = playBattle(aKey, bKey, 5, 5, 101 + total);
total += 1;
if (r.winner === 0 || r.winner === 1) decisive += 1;
if (r.hits > 0) battlesWithHits += 1;
}
}
} catch (e) { threw = e; }
check('AI soak throws no exceptions', !threw, threw?.stack?.split('\n')[0]);
check('nearly every AI battle produced hits (>=97%)', battlesWithHits / total >= 0.97, `${battlesWithHits}/${total}`);
check('most AI battles decisive', decisive / total >= 0.6, `${decisive}/${total}`);
let hi = 0;
let games = 0;
for (let g = 0; g < 20; g += 1) {
const r = playBattle('earthling', 'earthling', 8, 2, 900 + g);
if (r.winner === 0) hi += 1;
if (r.winner === 0 || r.winner === 1) games += 1;
}
check('skill 8 beats skill 2 (mirror match)', games >= 10 && hi / games >= 0.65, `${hi}/${games}`);
}
// ---------------------------------------------------------------------------
console.log(`\n${passes} checks passed, ${failures} failed`);
if (failures > 0) process.exit(1);