Compare commits
3 Commits
8399cc69db
...
bd693e0191
| Author | SHA1 | Date |
|---|---|---|
|
|
bd693e0191 | |
|
|
e9ddebc891 | |
|
|
3f1d534ec2 |
|
|
@ -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 0–7
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 993 B |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,248 @@
|
|||
// 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 and composed into a 16x1 spritesheet at
|
||||
// assets/images/starcontrol/sc-ship-<slug>.png. UQM frames are tightly
|
||||
// cropped per frame (different sizes within one rotation) and aligned by
|
||||
// per-frame hotspots in `<base>-big.ani`, so each frame is composited onto a
|
||||
// uniform transparent cell with its hotspot at the cell centre; the cell
|
||||
// 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 };
|
||||
}
|
||||
|
||||
// Parses a UQM .ani file: `<frame>.png <transparent> <colormap> <hotX> <hotY>`
|
||||
// per line. Returns { frameNumber: {x, y} } for the rotation frames.
|
||||
function parseAni(text) {
|
||||
const hot = {};
|
||||
for (const line of text.split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 5) continue;
|
||||
const m = /-(\d{3})\.png$/i.exec(parts[0]);
|
||||
if (!m) continue;
|
||||
const x = Number(parts[parts.length - 2]);
|
||||
const y = Number(parts[parts.length - 1]);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) hot[Number(m[1])] = { x, y };
|
||||
}
|
||||
return hot;
|
||||
}
|
||||
|
||||
async function buildSheet(slug, group, aniUrl, 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);
|
||||
}
|
||||
|
||||
// Per-frame dims (UQM crops each rotation frame individually).
|
||||
const dimLines = execFileSync('magick', ['identify', '-format', '%w %h\n', ...files])
|
||||
.toString().trim().split('\n');
|
||||
const dims = dimLines.map((l) => l.split(' ').map(Number));
|
||||
|
||||
// Per-frame hotspots from the .ani; fall back to frame centres if absent.
|
||||
let hotspots = null;
|
||||
if (aniUrl) {
|
||||
try {
|
||||
const res = await fetch(aniUrl);
|
||||
if (res.ok) {
|
||||
const parsed = parseAni(await res.text());
|
||||
if (group.frames.every((fr) => parsed[fr.n])) hotspots = parsed;
|
||||
}
|
||||
} catch { /* fall through to centre alignment */ }
|
||||
}
|
||||
if (!hotspots) {
|
||||
console.warn(` no usable .ani for ${slug}${suffix}; centring frames`);
|
||||
hotspots = {};
|
||||
group.frames.forEach((fr, i) => {
|
||||
hotspots[fr.n] = { x: Math.floor(dims[i][0] / 2), y: Math.floor(dims[i][1] / 2) };
|
||||
});
|
||||
}
|
||||
|
||||
// Uniform cell sized so every frame fits with its hotspot at the centre.
|
||||
let halfW = 0;
|
||||
let halfH = 0;
|
||||
group.frames.forEach((fr, i) => {
|
||||
const [w, h] = dims[i];
|
||||
const { x, y } = hotspots[fr.n];
|
||||
halfW = Math.max(halfW, x, w - x);
|
||||
halfH = Math.max(halfH, y, h - y);
|
||||
});
|
||||
const cellW = halfW * 2;
|
||||
const cellH = halfH * 2;
|
||||
|
||||
const cells = [];
|
||||
group.frames.forEach((fr, i) => {
|
||||
const { x, y } = hotspots[fr.n];
|
||||
const cell = join(tmp, `c-${String(fr.n).padStart(3, '0')}.png`);
|
||||
execFileSync('magick', [
|
||||
'-size', `${cellW}x${cellH}`, 'xc:none',
|
||||
files[i], '-geometry', `+${halfW - x}+${halfH - y}`, '-composite', cell,
|
||||
]);
|
||||
cells.push(cell);
|
||||
});
|
||||
|
||||
const sheetName = `sc-ship-${slug}${suffix}`;
|
||||
const out = join(OUT_DIR, `${sheetName}.png`);
|
||||
execFileSync('magick', [...cells, '+append', '-background', 'none', out]);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
return { sheet: sheetName, frames: 16, frameWidth: cellW, frameHeight: cellH };
|
||||
}
|
||||
|
||||
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; }
|
||||
const aniUrl = listing.find((f) => f.name === `${group.base}-big.ani`)?.download_url;
|
||||
json.ships[slug].sprite = await buildSheet(slug, group, aniUrl);
|
||||
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') {
|
||||
const yAni = listing.find((f) => f.name === 'ywing-big.ani')?.download_url;
|
||||
json.ships[slug].forms[0].sprite = await buildSheet(slug, yGroup, yAni, '-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');
|
||||
|
|
@ -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);
|
||||