feat: add Star Control space combat game
- Register Star Control in games registry as 2-player arcade game - Import and register StarControlGame scene - Add starcontrol slug mapping in GameRoomScene dispatch - Load star-control-ships.json asset data in PreloadScene - Add dynamic ship sprite sheet loading in asset manifest - Update README to reflect new game in Arcade/Console/PC category - Update game icons to include Star Control icon (frame 82)
|
|
@ -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: 1.7 KiB |
|
After Width: | Height: | Size: 826 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.5 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,964 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import { api } from '../../services/api.js';
|
||||
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
|
||||
import {
|
||||
ARENA_W, ARENA_H, ZOOM, TUNE, STEP_MS,
|
||||
mulberry32, wrap, tdelta, tdist, tmid, targetZoom, smoothTowards,
|
||||
compileShips, createBattle, setInput, stepMatch, quantizeFacing, facingToRad,
|
||||
} from './StarControlLogic.js';
|
||||
import { createAI, updateAI } from './StarControlAI.js';
|
||||
import {
|
||||
FLEET_MAX, PRESET_FLEETS, materializeFleet, fleetPoints, randomFleet, aiCounterPick,
|
||||
} from './StarControlFleets.js';
|
||||
|
||||
const D = { world: 0, fx: 5, hud: 30, overlay: 60 };
|
||||
const BEST_KEY = 'starcontrol-best';
|
||||
const FONT = 'm6x11, "Julius Sans One"';
|
||||
|
||||
// Side palettes: P1 cool green (Alliance flavor), P2 hot red (Hierarchy).
|
||||
const SIDE_COLORS = [
|
||||
{ hull: 0x74f5a1, glow: 0x2e8f57, hex: '#74f5a1' },
|
||||
{ hull: 0xf4726c, glow: 0x93312d, hex: '#f4726c' },
|
||||
];
|
||||
|
||||
// sfxClass strings in the ships JSON -> loaded SFX keys.
|
||||
const SFX_CLASS = {
|
||||
pellet: SFX.SCIFI_PLINK,
|
||||
flame: SFX.WOOSH,
|
||||
missile: SFX.SCIFI_LAUNCH,
|
||||
heavy: SFX.SCIFI_PLONK,
|
||||
zap: SFX.LASER_ZAP,
|
||||
launch: SFX.SCIFI_REVEAL,
|
||||
warp: SFX.SCIFI_RISER,
|
||||
explode: SFX.SCIFI_EXPLODE,
|
||||
};
|
||||
|
||||
const MODES = [
|
||||
{ id: 'ai', label: '1P VS COMPUTER' },
|
||||
{ id: 'hotseat', label: '2P HOTSEAT' },
|
||||
{ id: 'watch', label: 'WATCH AI VS AI' },
|
||||
];
|
||||
|
||||
export default class StarControlGame extends Phaser.Scene {
|
||||
constructor() { super('StarControlGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'starcontrol', name: 'Star Control' };
|
||||
this.view = null;
|
||||
this.match = null;
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
create() {
|
||||
const json = this.cache.json.get('star-control-ships');
|
||||
this.SHIPS = compileShips(json ?? { ships: {} });
|
||||
this.shipKeys = Object.keys(this.SHIPS);
|
||||
this.credits = json?.credits ?? '';
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x03060e).setDepth(-10);
|
||||
|
||||
this.gWorld = this.add.graphics().setDepth(D.world);
|
||||
this.gFx = this.add.graphics().setDepth(D.fx);
|
||||
this.hudRoot = this.add.container(0, 0).setDepth(D.hud);
|
||||
this.viewRoot = null;
|
||||
|
||||
// Fixed seeded starfield in world space, two parallax layers.
|
||||
const rng = mulberry32(20260714);
|
||||
this.stars = [[], []];
|
||||
for (let i = 0; i < 240; i += 1) {
|
||||
this.stars[i % 2].push({
|
||||
x: rng() * ARENA_W, y: rng() * ARENA_H,
|
||||
size: 1 + rng() * 2.2, tone: 0.35 + rng() * 0.65,
|
||||
});
|
||||
}
|
||||
|
||||
this.cam = { x: ARENA_W / 2, y: ARENA_H / 2, zoom: ZOOM.FAR };
|
||||
this.fxList = [];
|
||||
this.lastSfxAt = {};
|
||||
this.deathHoldMs = 0;
|
||||
this.deathFocus = null;
|
||||
|
||||
this.keys = this.input.keyboard.addKeys(
|
||||
'W,A,S,D,Z,X,UP,DOWN,LEFT,RIGHT,COMMA,PERIOD',
|
||||
);
|
||||
this.escHandler = () => this.onEscape();
|
||||
this.input.keyboard.on('keydown-ESC', this.escHandler);
|
||||
|
||||
this.crt = applyArcadeCRTOverlay(this, {
|
||||
accentTint: 0x74f5a1, scanlineTint: 0x2e8f57,
|
||||
});
|
||||
try {
|
||||
const music = this.cache.json.get('music');
|
||||
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
|
||||
} catch (_) { /* optional */ }
|
||||
|
||||
this.events.once('shutdown', () => {
|
||||
this.crt.destroy();
|
||||
this.input.keyboard.off('keydown-ESC', this.escHandler);
|
||||
});
|
||||
|
||||
// Match configuration defaults (setup view edits these).
|
||||
this.mode = 'ai';
|
||||
this.skills = [5, 5];
|
||||
this.fleetChoice = [0, 1]; // index into fleet options per side
|
||||
this.customFleets = [null, null]; // custom builder results (task: setup UI)
|
||||
|
||||
this.showSetup();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// View plumbing
|
||||
|
||||
clearView() {
|
||||
this.viewRoot?.destroy(true);
|
||||
this.viewRoot = this.add.container(0, 0).setDepth(D.overlay - 10);
|
||||
this.builderRoot?.destroy(true);
|
||||
this.builderRoot = null;
|
||||
this.hudRoot.removeAll(true);
|
||||
this.gWorld.clear();
|
||||
this.gFx.clear();
|
||||
this.shipSprites?.forEach((s) => s?.destroy());
|
||||
this.shipSprites = null;
|
||||
}
|
||||
|
||||
mkText(x, y, str, size, color = '#e8f4e8', origin = 0.5) {
|
||||
return this.add.text(x, y, str, {
|
||||
fontFamily: FONT, fontSize: `${size}px`, color,
|
||||
}).setOrigin(origin);
|
||||
}
|
||||
|
||||
fleetOptions(side) {
|
||||
const opts = PRESET_FLEETS.map((p) => ({
|
||||
id: p.id, name: p.name, blurb: p.blurb,
|
||||
resolve: () => materializeFleet(this.SHIPS, p.ships).entries,
|
||||
}));
|
||||
opts.push({
|
||||
id: 'grab', name: 'Grab Bag', blurb: 'A random armada within a 200-point budget.',
|
||||
resolve: () => randomFleet(this.SHIPS, mulberry32((Date.now() % 100000) + 7), 200),
|
||||
});
|
||||
opts.push({
|
||||
id: 'custom', name: 'Custom Fleet', blurb: 'Hand-pick up to 14 ships (duplicates allowed).',
|
||||
resolve: () => (this.customFleets[side]?.length
|
||||
? this.customFleets[side].slice(0, FLEET_MAX)
|
||||
: materializeFleet(this.SHIPS, PRESET_FLEETS[side === 0 ? 0 : 1].ships).entries),
|
||||
});
|
||||
return opts;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SETUP view
|
||||
|
||||
showSetup() {
|
||||
this.view = 'setup';
|
||||
this.match = null;
|
||||
this.clearView();
|
||||
const root = this.viewRoot;
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
root.add(this.mkText(cx, 90, 'STAR CONTROL', 84, '#74f5a1'));
|
||||
root.add(this.mkText(cx, 152, 'SUPER MELEE', 40, '#9adbb2'));
|
||||
|
||||
// Mode row.
|
||||
this.modeButtons = [];
|
||||
MODES.forEach((m, i) => {
|
||||
const b = this.textButton(cx + (i - 1) * 380, 240, m.label, () => {
|
||||
this.mode = m.id;
|
||||
this.refreshSetup();
|
||||
});
|
||||
this.modeButtons.push({ b, id: m.id });
|
||||
root.add(b);
|
||||
});
|
||||
|
||||
// Fleet panels per side.
|
||||
this.fleetPanels = [];
|
||||
[0, 1].forEach((side) => {
|
||||
const px = side === 0 ? 480 : GAME_WIDTH - 480;
|
||||
const panel = this.add.container(px, 560);
|
||||
const label = this.mkText(0, -250, side === 0 ? 'PLAYER 1' : 'PLAYER 2', 34, SIDE_COLORS[side].hex);
|
||||
const nameT = this.mkText(0, -190, '', 38);
|
||||
const blurbT = this.mkText(0, -150, '', 20, '#89a6a0');
|
||||
const listT = this.mkText(0, 40, '', 22, '#c7e6cf');
|
||||
listT.setLineSpacing(6).setAlign('center');
|
||||
const ptsT = this.mkText(0, 236, '', 26, '#e7d27b');
|
||||
const prev = this.textButton(-260, -190, '<', () => this.cycleFleet(side, -1));
|
||||
const next = this.textButton(260, -190, '>', () => this.cycleFleet(side, 1));
|
||||
const skillLabel = this.mkText(0, 290, '', 24, '#9adbb2');
|
||||
const minus = this.textButton(-90, 290, '-', () => this.bumpSkill(side, -1));
|
||||
const plus = this.textButton(90, 290, '+', () => this.bumpSkill(side, 1));
|
||||
const edit = this.textButton(0, 236, 'EDIT FLEET', () => this.showFleetBuilder(side), 22);
|
||||
panel.add([label, nameT, blurbT, listT, ptsT, prev, next, skillLabel, minus, plus, edit]);
|
||||
root.add(panel);
|
||||
this.fleetPanels.push({ panel, nameT, blurbT, listT, ptsT, skillLabel, minus, plus, edit });
|
||||
});
|
||||
|
||||
const engage = this.textButton(cx, 900, 'ENGAGE', () => this.beginMatch(), 46);
|
||||
root.add(engage);
|
||||
|
||||
const menu = this.textButton(120, 60, '< MENU', () => this.scene.start('GameMenu'), 22);
|
||||
root.add(menu);
|
||||
|
||||
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
|
||||
if (best > 0) root.add(this.mkText(cx, 960, `BEST FLEET SCORE ${best}`, 20, '#e7d27b'));
|
||||
|
||||
root.add(this.mkText(cx, GAME_HEIGHT - 26,
|
||||
'Ship data & art: The Ur-Quan Masters (Star Control II) © Toys for Bob / UQM project — CC BY-NC-SA 2.5',
|
||||
16, '#5d7a72'));
|
||||
|
||||
this.refreshSetup();
|
||||
}
|
||||
|
||||
textButton(x, y, label, cb, size = 26) {
|
||||
const t = this.add.text(x, y, label, {
|
||||
fontFamily: FONT, fontSize: `${size}px`, color: '#d9f7e2',
|
||||
backgroundColor: '#12241c', padding: { x: 18, y: 10 },
|
||||
}).setOrigin(0.5);
|
||||
t.setInteractive({ useHandCursor: true });
|
||||
t.on('pointerover', () => t.setColor('#74f5a1'));
|
||||
t.on('pointerout', () => t.setStyle({ color: t.getData('sel') ? '#0a130d' : '#d9f7e2' }));
|
||||
t.on('pointerdown', () => { playSound(this, SFX.EIGHTBIT_ACTION); cb(); });
|
||||
return t;
|
||||
}
|
||||
|
||||
cycleFleet(side, dir) {
|
||||
const n = this.fleetOptions(side).length;
|
||||
this.fleetChoice[side] = ((this.fleetChoice[side] + dir) % n + n) % n;
|
||||
this.refreshSetup();
|
||||
}
|
||||
|
||||
bumpSkill(side, dir) {
|
||||
this.skills[side] = Math.max(1, Math.min(10, this.skills[side] + dir));
|
||||
this.refreshSetup();
|
||||
}
|
||||
|
||||
refreshSetup() {
|
||||
if (this.view !== 'setup') return;
|
||||
for (const { b, id } of this.modeButtons) {
|
||||
const sel = id === this.mode;
|
||||
b.setData('sel', sel);
|
||||
b.setStyle({ color: sel ? '#0a130d' : '#d9f7e2', backgroundColor: sel ? '#74f5a1' : '#12241c' });
|
||||
}
|
||||
[0, 1].forEach((side) => {
|
||||
const opts = this.fleetOptions(side);
|
||||
const opt = opts[this.fleetChoice[side]];
|
||||
const keys = opt.resolve();
|
||||
const p = this.fleetPanels[side];
|
||||
p.nameT.setText(opt.name);
|
||||
p.blurbT.setText(opt.blurb);
|
||||
const counts = {};
|
||||
for (const k of keys) counts[k] = (counts[k] ?? 0) + 1;
|
||||
p.listT.setText(Object.entries(counts)
|
||||
.map(([k, n]) => `${this.SHIPS[k].name}${n > 1 ? ` x${n}` : ''}`)
|
||||
.join('\n'));
|
||||
p.ptsT.setText(`${keys.length} SHIPS — ${fleetPoints(this.SHIPS, keys)} PTS`);
|
||||
p.ptsT.setY(opt.id === 'custom' ? 200 : 236);
|
||||
p.edit.setVisible(opt.id === 'custom');
|
||||
const isAI = this.sideIsAI(side);
|
||||
p.skillLabel.setText(isAI ? `AI SKILL ${this.skills[side]}` : (side === 0 ? 'W A D + Z X' : 'ARROWS + , .'));
|
||||
p.minus.setVisible(isAI);
|
||||
p.plus.setVisible(isAI);
|
||||
});
|
||||
}
|
||||
|
||||
sideIsAI(side) {
|
||||
if (this.mode === 'watch') return true;
|
||||
if (this.mode === 'hotseat') return false;
|
||||
return side === 1;
|
||||
}
|
||||
|
||||
// ---- Custom fleet builder overlay -------------------------------------
|
||||
|
||||
showFleetBuilder(side) {
|
||||
this.builderRoot?.destroy(true);
|
||||
this.builderList = (this.customFleets[side] ?? []).slice();
|
||||
const root = this.add.container(0, 0).setDepth(D.overlay);
|
||||
this.builderRoot = root;
|
||||
const cx = GAME_WIDTH / 2;
|
||||
root.add(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x02070c, 0.94)
|
||||
.setInteractive()); // swallow clicks behind the overlay
|
||||
root.add(this.mkText(cx, 64, `PLAYER ${side + 1} — CUSTOM FLEET`, 44, SIDE_COLORS[side].hex));
|
||||
root.add(this.mkText(cx, 112, `CLICK SHIPS TO ADD (MAX ${FLEET_MAX}, DUPLICATES ALLOWED) — CLICK A SLOT TO REMOVE`, 20, '#89a6a0'));
|
||||
|
||||
// Roster grid.
|
||||
const keys = this.shipKeys.slice().sort((a, b) => this.SHIPS[a].name.localeCompare(this.SHIPS[b].name));
|
||||
const cols = 5;
|
||||
keys.forEach((key, i) => {
|
||||
const def = this.SHIPS[key];
|
||||
const x = cx + ((i % cols) - (cols - 1) / 2) * 350;
|
||||
const y = 190 + Math.floor(i / cols) * 78;
|
||||
const b = this.textButton(x, y, `${def.name} · ${def.pointValue}`, () => {
|
||||
if (this.builderList.length >= FLEET_MAX) return;
|
||||
this.builderList.push(key);
|
||||
this.refreshBuilder(side);
|
||||
}, 20);
|
||||
root.add(b);
|
||||
});
|
||||
|
||||
this.builderSlotsRoot = this.add.container(0, 0);
|
||||
root.add(this.builderSlotsRoot);
|
||||
this.builderTotalT = this.mkText(cx, 900, '', 26, '#e7d27b');
|
||||
root.add(this.builderTotalT);
|
||||
|
||||
root.add(this.textButton(cx - 220, 990, 'CLEAR', () => {
|
||||
this.builderList = [];
|
||||
this.refreshBuilder(side);
|
||||
}, 26));
|
||||
root.add(this.textButton(cx, 990, 'DONE', () => {
|
||||
if (this.builderList.length === 0) return;
|
||||
this.customFleets[side] = this.builderList.slice();
|
||||
root.destroy(true);
|
||||
this.builderRoot = null;
|
||||
this.refreshSetup();
|
||||
}, 26));
|
||||
root.add(this.textButton(cx + 220, 990, 'CANCEL', () => {
|
||||
root.destroy(true);
|
||||
this.builderRoot = null;
|
||||
}, 26));
|
||||
this.refreshBuilder(side);
|
||||
}
|
||||
|
||||
refreshBuilder(side) {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
this.builderSlotsRoot.removeAll(true);
|
||||
this.builderList.forEach((key, i) => {
|
||||
const x = cx + ((i % 7) - 3) * 250;
|
||||
const y = 800 + Math.floor(i / 7) * 52;
|
||||
const chip = this.textButton(x, y, `${this.SHIPS[key].name.split(' ').pop()} ${this.SHIPS[key].pointValue}`, () => {
|
||||
this.builderList.splice(i, 1);
|
||||
this.refreshBuilder(side);
|
||||
}, 18);
|
||||
chip.setStyle({ backgroundColor: '#1c3327', color: SIDE_COLORS[side].hex });
|
||||
this.builderSlotsRoot.add(chip);
|
||||
});
|
||||
this.builderTotalT.setText(
|
||||
`${this.builderList.length}/${FLEET_MAX} SHIPS — ${fleetPoints(this.SHIPS, this.builderList)} PTS TOTAL`,
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Match / fleet flow
|
||||
|
||||
beginMatch() {
|
||||
this.fleets = [0, 1].map((side) => {
|
||||
const keys = this.fleetOptions(side)[this.fleetChoice[side]].resolve();
|
||||
return keys.map((key) => ({ key, crew: null, energy: null, dead: false }));
|
||||
});
|
||||
if (this.fleets.some((f) => f.length === 0)) return;
|
||||
this.ais = [0, 1].map((side) => (this.sideIsAI(side)
|
||||
? createAI({ skill: this.skills[side], side, seed: (Date.now() % 65521) + side })
|
||||
: null));
|
||||
this.current = [null, null];
|
||||
this.needPick = [true, true];
|
||||
this.destroyedPoints = [0, 0];
|
||||
this.carry = null;
|
||||
this.matchSeed = (Date.now() % 2147483647) | 1;
|
||||
this.showShipPick();
|
||||
}
|
||||
|
||||
aliveFleet(side) {
|
||||
return this.fleets[side].map((e, i) => ({ ...e, i })).filter((e) => !e.dead);
|
||||
}
|
||||
|
||||
showShipPick() {
|
||||
this.view = 'shipPick';
|
||||
this.clearView();
|
||||
|
||||
// AI sides pick instantly.
|
||||
[0, 1].forEach((side) => {
|
||||
if (!this.needPick[side] || !this.sideIsAI(side)) return;
|
||||
const remaining = this.aliveFleet(side);
|
||||
const enemyIdx = this.current[1 - side];
|
||||
const enemyKey = this.needPick[1 - side] ? null : this.fleets[1 - side][enemyIdx]?.key;
|
||||
const pick = aiCounterPick(this.SHIPS, remaining.map((e) => e.key), enemyKey, Math.random);
|
||||
const entry = remaining.find((e) => e.key === pick);
|
||||
this.current[side] = entry.i;
|
||||
this.needPick[side] = false;
|
||||
});
|
||||
|
||||
const humanSide = [0, 1].find((s) => this.needPick[s]);
|
||||
if (humanSide === undefined) { this.startBattle(); return; }
|
||||
|
||||
const root = this.viewRoot;
|
||||
const cx = GAME_WIDTH / 2;
|
||||
root.add(this.mkText(cx, 90,
|
||||
`${this.mode === 'hotseat' ? `PLAYER ${humanSide + 1}` : 'COMMANDER'} — CHOOSE YOUR SHIP`,
|
||||
44, SIDE_COLORS[humanSide].hex));
|
||||
|
||||
const enemyIdx = this.current[1 - humanSide];
|
||||
if (enemyIdx !== null && !this.needPick[1 - humanSide]) {
|
||||
const ek = this.fleets[1 - humanSide][enemyIdx].key;
|
||||
root.add(this.mkText(cx, 148, `ENEMY FIELDS: ${this.SHIPS[ek].name.toUpperCase()}`, 26, SIDE_COLORS[1 - humanSide].hex));
|
||||
}
|
||||
|
||||
const remaining = this.aliveFleet(humanSide);
|
||||
const perRow = Math.min(remaining.length, 7);
|
||||
const cardW = 240;
|
||||
const cardH = 290;
|
||||
remaining.forEach((entry, n) => {
|
||||
const row = Math.floor(n / perRow);
|
||||
const inRow = Math.min(remaining.length - row * perRow, perRow);
|
||||
const x = cx + (n % perRow - (inRow - 1) / 2) * (cardW + 20);
|
||||
const y = 420 + row * (cardH + 24);
|
||||
root.add(this.shipCard(x, y, cardW, cardH, entry, humanSide, () => {
|
||||
this.current[humanSide] = entry.i;
|
||||
this.needPick[humanSide] = false;
|
||||
playSound(this, SFX.EIGHTBIT_CARD);
|
||||
this.showShipPick(); // next picker (hotseat) or battle
|
||||
}));
|
||||
});
|
||||
|
||||
root.add(this.mkText(cx, GAME_HEIGHT - 40,
|
||||
`FLEET VALUE REMAINING — YOU ${fleetPoints(this.SHIPS, remaining.map((e) => e.key))} PTS / ` +
|
||||
`ENEMY ${fleetPoints(this.SHIPS, this.aliveFleet(1 - humanSide).map((e) => e.key))} PTS`,
|
||||
22, '#89a6a0'));
|
||||
}
|
||||
|
||||
shipCard(x, y, w, h, entry, side, onPick) {
|
||||
const def = this.SHIPS[entry.key];
|
||||
const c = this.add.container(x, y);
|
||||
const g = this.add.graphics();
|
||||
g.fillStyle(0x0c1a14, 0.94).fillRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
g.lineStyle(2, SIDE_COLORS[side].hull, 0.7).strokeRoundedRect(-w / 2, -h / 2, w, h, 10);
|
||||
c.add(g);
|
||||
const icon = this.add.graphics();
|
||||
this.drawChevron(icon, 0, -62, 34, -Math.PI / 2, SIDE_COLORS[side].hull, false);
|
||||
c.add(icon);
|
||||
c.add(this.mkText(0, 8, def.name.replace(' ', '\n'), 24).setAlign('center'));
|
||||
const crew = entry.crew ?? def.stats.maxCrew;
|
||||
c.add(this.mkText(0, 74, `CREW ${crew}/${def.stats.maxCrew}`, 18, crew < def.stats.maxCrew ? '#f4a26c' : '#9adbb2'));
|
||||
c.add(this.mkText(0, 102, `${def.pointValue} PTS`, 18, '#e7d27b'));
|
||||
c.setSize(w, h);
|
||||
c.setInteractive({ useHandCursor: true });
|
||||
c.on('pointerover', () => g.lineStyle(3, 0xffffff, 1).strokeRoundedRect(-w / 2, -h / 2, w, h, 10));
|
||||
c.on('pointerdown', onPick);
|
||||
return c;
|
||||
}
|
||||
|
||||
startBattle() {
|
||||
this.view = 'battle';
|
||||
this.clearView();
|
||||
this.paused = false;
|
||||
this.deathHoldMs = 0;
|
||||
this.deathFocus = null;
|
||||
this.fxList = [];
|
||||
|
||||
const mk = (side) => {
|
||||
const entry = this.fleets[side][this.current[side]];
|
||||
return {
|
||||
def: this.SHIPS[entry.key],
|
||||
crew: entry.crew ?? undefined,
|
||||
energy: entry.energy ?? undefined,
|
||||
};
|
||||
};
|
||||
this.matchSeed = (this.matchSeed * 16807) % 2147483647;
|
||||
this.match = createBattle({
|
||||
a: mk(0), b: mk(1), seed: this.matchSeed,
|
||||
keep: this.carry,
|
||||
});
|
||||
// Real UQM rotation sheets when present; vector chevrons otherwise.
|
||||
this.shipSprites = this.match.ships.map((ship) => {
|
||||
const sheets = [ship.def.sprite, ...(ship.def.forms ?? []).map((f) => f.sprite)]
|
||||
.filter((sp) => sp?.sheet && this.textures.exists(sp.sheet));
|
||||
for (const sp of sheets) {
|
||||
this.textures.get(sp.sheet).setFilter(Phaser.Textures.FilterMode.NEAREST);
|
||||
}
|
||||
if (!sheets.length) return null;
|
||||
return this.add.sprite(0, 0, sheets[0].sheet, 0).setDepth(D.world + 1).setVisible(false);
|
||||
});
|
||||
|
||||
// Camera opens wide on the survivor/new pair and glides in.
|
||||
const mid = tmid(this.match.ships[0].x, this.match.ships[0].y, this.match.ships[1].x, this.match.ships[1].y);
|
||||
this.cam = { x: mid.x, y: mid.y, zoom: ZOOM.FAR };
|
||||
this.buildHud();
|
||||
playSound(this, SFX.SCIFI_RISER);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Battle loop
|
||||
|
||||
update(time, delta) {
|
||||
if (this.view !== 'battle' || !this.match) return;
|
||||
if (this.paused) return;
|
||||
|
||||
if (!this.match.over) {
|
||||
for (const side of [0, 1]) {
|
||||
if (this.ais[side]) {
|
||||
setInput(this.match, side, updateAI(this.ais[side], this.match, delta));
|
||||
} else {
|
||||
setInput(this.match, side, this.readKeys(side));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const events = stepMatch(this.match, delta);
|
||||
for (const e of events) this.handleEvent(e);
|
||||
|
||||
if (this.match.over && this.deathHoldMs > 0) {
|
||||
this.deathHoldMs -= delta;
|
||||
if (this.deathHoldMs <= 0) this.resolveBattleEnd();
|
||||
}
|
||||
|
||||
this.updateCamera(delta);
|
||||
this.renderWorld(delta);
|
||||
this.updateHud();
|
||||
}
|
||||
|
||||
readKeys(side) {
|
||||
const k = this.keys;
|
||||
// In single-player, both key clusters drive the human ship.
|
||||
const p1 = { left: k.A.isDown, right: k.D.isDown, thrust: k.W.isDown, fire: k.Z.isDown, special: k.X.isDown };
|
||||
const p2 = {
|
||||
left: k.LEFT.isDown, right: k.RIGHT.isDown, thrust: k.UP.isDown,
|
||||
fire: k.COMMA.isDown, special: k.PERIOD.isDown,
|
||||
};
|
||||
if (this.mode === 'hotseat') return side === 0 ? p1 : p2;
|
||||
return {
|
||||
left: p1.left || p2.left, right: p1.right || p2.right,
|
||||
thrust: p1.thrust || p2.thrust, fire: p1.fire || p2.fire, special: p1.special || p2.special,
|
||||
};
|
||||
}
|
||||
|
||||
handleEvent(e) {
|
||||
switch (e.t) {
|
||||
case 'fire':
|
||||
this.throttledSfx(SFX_CLASS[e.sfx] ?? SFX.SCIFI_PLINK, 90);
|
||||
break;
|
||||
case 'special':
|
||||
this.throttledSfx(SFX_CLASS[e.sfx] ?? SFX.SCIFI_REVEAL, 120);
|
||||
break;
|
||||
case 'beam':
|
||||
case 'pdZap':
|
||||
this.fxList.push({ type: 'beam', x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2, side: e.side, ttl: 110 });
|
||||
this.throttledSfx(SFX.LASER_ZAP, 140);
|
||||
break;
|
||||
case 'shipHit':
|
||||
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 40 + e.dmg * 14, ttl: 300, age: 0 });
|
||||
this.throttledSfx(SFX.EIGHTBIT_EXPLODE, 120);
|
||||
this.crt.pulse?.(0.4, 120);
|
||||
break;
|
||||
case 'shotHit':
|
||||
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 26, ttl: 200, age: 0 });
|
||||
break;
|
||||
case 'shipsCollide':
|
||||
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 60, ttl: 260, age: 0 });
|
||||
this.throttledSfx(SFX.EIGHTBIT_EXPLODE_2, 200);
|
||||
break;
|
||||
case 'planetBounce':
|
||||
this.throttledSfx(SFX.EIGHTBIT_ACTION, 200);
|
||||
break;
|
||||
case 'asteroidBreak':
|
||||
this.fxList.push({ type: 'burst', x: e.x, y: e.y, r: 34, ttl: 220, age: 0 });
|
||||
this.throttledSfx(SFX.SCIFI_PLINK, 150);
|
||||
break;
|
||||
case 'glory':
|
||||
this.fxList.push({ type: 'ring', x: e.x, y: e.y, r: e.range, ttl: 600, age: 0 });
|
||||
playSound(this, SFX.SCIFI_EXPLODE);
|
||||
this.crt.pulse?.(1, 400);
|
||||
break;
|
||||
case 'cloak':
|
||||
this.throttledSfx(SFX.SCIFI_RISER, 300);
|
||||
break;
|
||||
case 'fighterDock':
|
||||
this.throttledSfx(SFX.EIGHTBIT_CARD, 200);
|
||||
break;
|
||||
case 'resurrect':
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
break;
|
||||
case 'death':
|
||||
this.fxList.push({ type: 'boom', x: e.x, y: e.y, r: 260, ttl: 1100, age: 0 });
|
||||
playSound(this, SFX.SCIFI_EXPLODE);
|
||||
this.crt.pulse?.(1, 500);
|
||||
this.deathFocus = { x: e.x, y: e.y };
|
||||
break;
|
||||
case 'battleOver':
|
||||
this.deathHoldMs = 1300;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throttledSfx(key, gapMs) {
|
||||
if (!key) return;
|
||||
const now = this.time.now;
|
||||
if ((this.lastSfxAt[key] ?? -1e9) + gapMs > now) return;
|
||||
this.lastSfxAt[key] = now;
|
||||
playSound(this, key);
|
||||
}
|
||||
|
||||
resolveBattleEnd() {
|
||||
const w = this.match.winner;
|
||||
[0, 1].forEach((side) => {
|
||||
const entry = this.fleets[side][this.current[side]];
|
||||
const ship = this.match.ships[side];
|
||||
if (w === side) {
|
||||
// Winner keeps its ship, with damage/charge carried over.
|
||||
entry.crew = ship.crew;
|
||||
entry.energy = ship.energy;
|
||||
this.destroyedPoints[side] += this.SHIPS[this.fleets[1 - side][this.current[1 - side]].key].pointValue;
|
||||
this.carry = {
|
||||
side, x: ship.x, y: ship.y, vx: ship.vx, vy: ship.vy, facing: ship.facing,
|
||||
};
|
||||
this.needPick[side] = false;
|
||||
} else {
|
||||
entry.dead = true;
|
||||
this.needPick[side] = true;
|
||||
}
|
||||
});
|
||||
if (w === 'draw') {
|
||||
this.carry = null;
|
||||
this.destroyedPoints[0] += this.SHIPS[this.fleets[1][this.current[1]].key].pointValue;
|
||||
this.destroyedPoints[1] += this.SHIPS[this.fleets[0][this.current[0]].key].pointValue;
|
||||
}
|
||||
const out = [0, 1].filter((s) => this.aliveFleet(s).length === 0);
|
||||
if (out.length > 0) this.showRecap(out);
|
||||
else this.showShipPick();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Camera + rendering
|
||||
|
||||
renderPos(e) {
|
||||
const a = this.match.alpha ?? 1;
|
||||
return {
|
||||
x: wrap(e.px + tdelta(e.px, e.x, ARENA_W) * a, ARENA_W),
|
||||
y: wrap(e.py + tdelta(e.py, e.y, ARENA_H) * a, ARENA_H),
|
||||
};
|
||||
}
|
||||
|
||||
w2s(x, y) {
|
||||
return {
|
||||
x: GAME_WIDTH / 2 + tdelta(this.cam.x, x, ARENA_W) * this.cam.zoom,
|
||||
y: GAME_HEIGHT / 2 + tdelta(this.cam.y, y, ARENA_H) * this.cam.zoom,
|
||||
};
|
||||
}
|
||||
|
||||
onScreen(s, margin) {
|
||||
return s.x > -margin && s.x < GAME_WIDTH + margin && s.y > -margin && s.y < GAME_HEIGHT + margin;
|
||||
}
|
||||
|
||||
updateCamera(delta) {
|
||||
const [A, B] = this.match.ships;
|
||||
let mid;
|
||||
let tz;
|
||||
if (this.match.over && this.deathFocus) {
|
||||
mid = this.deathFocus;
|
||||
tz = ZOOM.MED;
|
||||
} else {
|
||||
const ra = this.renderPos(A);
|
||||
const rb = this.renderPos(B);
|
||||
mid = tmid(ra.x, ra.y, rb.x, rb.y);
|
||||
tz = targetZoom(tdist(ra.x, ra.y, rb.x, rb.y));
|
||||
}
|
||||
const k = 1 - Math.exp(-delta / ZOOM.SMOOTH_MS);
|
||||
this.cam.zoom = smoothTowards(this.cam.zoom, tz, delta);
|
||||
this.cam.x = wrap(this.cam.x + tdelta(this.cam.x, mid.x, ARENA_W) * k, ARENA_W);
|
||||
this.cam.y = wrap(this.cam.y + tdelta(this.cam.y, mid.y, ARENA_H) * k, ARENA_H);
|
||||
}
|
||||
|
||||
drawChevron(g, x, y, size, facing, color, thrusting) {
|
||||
const c = Math.cos(facing);
|
||||
const s = Math.sin(facing);
|
||||
const pt = (fx, fy) => ({ x: x + (fx * c - fy * s), y: y + (fx * s + fy * c) });
|
||||
const nose = pt(size, 0);
|
||||
const lw = pt(-size * 0.7, size * 0.66);
|
||||
const rw = pt(-size * 0.7, -size * 0.66);
|
||||
const tail = pt(-size * 0.32, 0);
|
||||
g.fillStyle(color, 0.22);
|
||||
g.fillTriangle(nose.x, nose.y, lw.x, lw.y, tail.x, tail.y);
|
||||
g.fillTriangle(nose.x, nose.y, rw.x, rw.y, tail.x, tail.y);
|
||||
g.lineStyle(Math.max(1.5, size * 0.09), color, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(lw.x, lw.y); g.lineTo(nose.x, nose.y); g.lineTo(rw.x, rw.y); g.lineTo(tail.x, tail.y);
|
||||
g.closePath();
|
||||
g.strokePath();
|
||||
if (thrusting) {
|
||||
const f1 = pt(-size * (0.9 + Math.random() * 0.5), 0);
|
||||
const fl = pt(-size * 0.55, size * 0.22);
|
||||
const fr = pt(-size * 0.55, -size * 0.22);
|
||||
g.fillStyle(0xffc06a, 0.85);
|
||||
g.fillTriangle(f1.x, f1.y, fl.x, fl.y, fr.x, fr.y);
|
||||
}
|
||||
}
|
||||
|
||||
renderWorld(delta) {
|
||||
const g = this.gWorld;
|
||||
const z = this.cam.zoom;
|
||||
g.clear();
|
||||
|
||||
// Starfield (two parallax layers).
|
||||
for (let layer = 0; layer < 2; layer += 1) {
|
||||
const par = layer === 0 ? 0.45 : 1;
|
||||
const camX = wrap(this.cam.x * par, ARENA_W);
|
||||
const camY = wrap(this.cam.y * par, ARENA_H);
|
||||
for (const st of this.stars[layer]) {
|
||||
const sx = GAME_WIDTH / 2 + tdelta(camX, st.x, ARENA_W) * z;
|
||||
const sy = GAME_HEIGHT / 2 + tdelta(camY, st.y, ARENA_H) * z;
|
||||
if (sx < -4 || sx > GAME_WIDTH + 4 || sy < -4 || sy > GAME_HEIGHT + 4) continue;
|
||||
g.fillStyle(0xbfd9ff, st.tone * (layer === 0 ? 0.4 : 0.8));
|
||||
g.fillRect(sx, sy, Math.max(1, st.size * (layer === 0 ? 0.7 : 1)), Math.max(1, st.size));
|
||||
}
|
||||
}
|
||||
|
||||
// Planet.
|
||||
const p = this.match.planet;
|
||||
const ps = this.w2s(p.x, p.y);
|
||||
if (this.onScreen(ps, p.radius * z + 40)) {
|
||||
g.fillStyle(0x6a5540, 1).fillCircle(ps.x, ps.y, p.radius * z);
|
||||
g.fillStyle(0x86704f, 1).fillCircle(ps.x - p.radius * z * 0.25, ps.y - p.radius * z * 0.2, p.radius * z * 0.62);
|
||||
g.fillStyle(0x4c3c2e, 0.9);
|
||||
g.fillCircle(ps.x + p.radius * z * 0.35, ps.y + p.radius * z * 0.3, p.radius * z * 0.2);
|
||||
g.fillCircle(ps.x - p.radius * z * 0.1, ps.y + p.radius * z * 0.45, p.radius * z * 0.12);
|
||||
g.lineStyle(2, 0xc79a62, 0.5).strokeCircle(ps.x, ps.y, p.radius * z);
|
||||
}
|
||||
|
||||
// Asteroids.
|
||||
for (const ast of this.match.asteroids) {
|
||||
if (!ast.alive) continue;
|
||||
const rp = this.renderPos(ast);
|
||||
const s = this.w2s(rp.x, rp.y);
|
||||
if (!this.onScreen(s, 60)) continue;
|
||||
const r = ast.radius * z;
|
||||
g.lineStyle(2, 0x9a9a8c, 1);
|
||||
g.fillStyle(0x55554c, 1);
|
||||
g.beginPath();
|
||||
for (let i = 0; i < 7; i += 1) {
|
||||
const a = ast.rot + (i / 7) * Math.PI * 2;
|
||||
const rr = r * (0.75 + 0.25 * Math.sin(i * 2.7 + ast.id));
|
||||
const px2 = s.x + Math.cos(a) * rr;
|
||||
const py2 = s.y + Math.sin(a) * rr;
|
||||
if (i === 0) g.moveTo(px2, py2); else g.lineTo(px2, py2);
|
||||
}
|
||||
g.closePath();
|
||||
g.fillPath();
|
||||
g.strokePath();
|
||||
}
|
||||
|
||||
// Projectiles + units.
|
||||
for (const pr of this.match.projectiles) {
|
||||
if (!pr.alive) continue;
|
||||
const rp = this.renderPos(pr);
|
||||
const s = this.w2s(rp.x, rp.y);
|
||||
if (!this.onScreen(s, 80)) continue;
|
||||
const col = SIDE_COLORS[pr.owner].hull;
|
||||
if (pr.type === 'flame') {
|
||||
const lifeFrac = Math.min(1, pr.life / 8);
|
||||
g.fillStyle(0xff9040, 0.25 + 0.55 * lifeFrac);
|
||||
g.fillCircle(s.x, s.y, (10 + (1 - lifeFrac) * 26) * z * 4 * 0.25);
|
||||
} else if (pr.kind === 'unit') {
|
||||
this.drawChevron(g, s.x, s.y, Math.max(6, pr.radius * z), pr.facing, col, false);
|
||||
} else if (pr.type === 'missile' || pr.type === 'butt') {
|
||||
const c = Math.cos(pr.facing);
|
||||
const sn = Math.sin(pr.facing);
|
||||
const len = Math.max(6, pr.radius * z * 1.8);
|
||||
g.lineStyle(Math.max(2, 3 * z * 2), col, 1);
|
||||
g.lineBetween(s.x - c * len, s.y - sn * len, s.x + c * len, s.y + sn * len);
|
||||
g.fillStyle(0xffe9a0, 1).fillCircle(s.x - c * len, s.y - sn * len, Math.max(1.5, 2.4 * z * 2));
|
||||
} else {
|
||||
g.fillStyle(col, 1).fillCircle(s.x, s.y, Math.max(2, pr.radius * z * 0.8));
|
||||
g.lineStyle(1, 0xffffff, 0.5).strokeCircle(s.x, s.y, Math.max(2, pr.radius * z * 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
// Ships. UQM sheets: frame 0 faces up, rotating counterclockwise, so the
|
||||
// frame for quantized facing q (0 = east, clockwise) is (12 - q) & 15.
|
||||
for (const ship of this.match.ships) {
|
||||
const spr = this.shipSprites?.[ship.side];
|
||||
if (!ship.alive || ship.fx.cloaked) { spr?.setVisible(false); continue; }
|
||||
const rp = this.renderPos(ship);
|
||||
const s = this.w2s(rp.x, rp.y);
|
||||
if (!this.onScreen(s, 260)) { spr?.setVisible(false); continue; }
|
||||
const thrusting = ship.input.thrust && !this.match.over;
|
||||
const spDef = (ship.fx.formIndex ? ship.def.forms?.[ship.fx.formIndex - 1]?.sprite : null)
|
||||
?? ship.def.sprite;
|
||||
if (spr && spDef?.sheet && this.textures.exists(spDef.sheet)) {
|
||||
if (spr.texture.key !== spDef.sheet) spr.setTexture(spDef.sheet, 0);
|
||||
const q = quantizeFacing(ship.facing);
|
||||
spr.setVisible(true)
|
||||
.setPosition(s.x, s.y)
|
||||
.setScale(z * 4)
|
||||
.setFrame((12 - q + 16) & 15);
|
||||
spr.setTint(ship.fx.blazer ? 0xff8866 : 0xffffff);
|
||||
if (thrusting) {
|
||||
const f = ship.facing;
|
||||
const back = ship.def.radius * 1.05 * z;
|
||||
g.fillStyle(0xffc06a, 0.5 + Math.random() * 0.3);
|
||||
g.fillCircle(s.x - Math.cos(f) * back, s.y - Math.sin(f) * back, Math.max(2, 22 * z));
|
||||
}
|
||||
} else {
|
||||
spr?.setVisible(false);
|
||||
const size = Math.max(9, ship.def.radius * z);
|
||||
this.drawChevron(g, s.x, s.y, size, ship.facing, SIDE_COLORS[ship.side].hull, thrusting);
|
||||
}
|
||||
// Shield/absorb auras read through both render paths.
|
||||
if (ship.fx.shieldFrames > 0 || ship.fx.absorbFrames > 0) {
|
||||
g.lineStyle(3, ship.fx.absorbFrames > 0 ? 0xe7d27b : 0x8ad4ff, 0.8);
|
||||
g.strokeCircle(s.x, s.y, (ship.def.radius + 40) * z);
|
||||
}
|
||||
}
|
||||
|
||||
// Transient FX.
|
||||
const fg = this.gFx;
|
||||
fg.clear();
|
||||
this.fxList = this.fxList.filter((fx) => {
|
||||
fx.age = (fx.age ?? 0) + delta;
|
||||
if (fx.age >= fx.ttl) return false;
|
||||
const t = fx.age / fx.ttl;
|
||||
if (fx.type === 'beam') {
|
||||
const a = this.w2s(fx.x1, fx.y1);
|
||||
const b = this.w2s(fx.x2, fx.y2);
|
||||
fg.lineStyle(Math.max(1.5, 3 * z * 2) * (1 - t) + 1, SIDE_COLORS[fx.side ?? 0].hull, 1 - t * 0.7);
|
||||
fg.lineBetween(a.x, a.y, b.x, b.y);
|
||||
} else if (fx.type === 'burst') {
|
||||
const s = this.w2s(fx.x, fx.y);
|
||||
fg.fillStyle(0xffd27a, (1 - t) * 0.8);
|
||||
fg.fillCircle(s.x, s.y, fx.r * z * (0.4 + t));
|
||||
} else if (fx.type === 'ring') {
|
||||
const s = this.w2s(fx.x, fx.y);
|
||||
fg.lineStyle(6 * (1 - t) + 1, 0xffe9a0, 1 - t);
|
||||
fg.strokeCircle(s.x, s.y, fx.r * z * t);
|
||||
fg.fillStyle(0xff9040, (1 - t) * 0.5);
|
||||
fg.fillCircle(s.x, s.y, fx.r * z * t * 0.5);
|
||||
} else if (fx.type === 'boom') {
|
||||
const s = this.w2s(fx.x, fx.y);
|
||||
for (let i = 0; i < 9; i += 1) {
|
||||
const a = (i / 9) * Math.PI * 2 + fx.x;
|
||||
fg.fillStyle(i % 2 ? 0xffd27a : 0xff7040, (1 - t) * 0.9);
|
||||
fg.fillCircle(
|
||||
s.x + Math.cos(a) * fx.r * z * t,
|
||||
s.y + Math.sin(a) * fx.r * z * t,
|
||||
Math.max(2, fx.r * z * 0.16 * (1 - t)),
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HUD
|
||||
|
||||
buildHud() {
|
||||
this.hudRoot.removeAll(true);
|
||||
this.hudPanels = [];
|
||||
this.lastHud = [null, null];
|
||||
[0, 1].forEach((side) => {
|
||||
const x = side === 0 ? 30 : GAME_WIDTH - 330;
|
||||
const c = this.add.container(x, 24);
|
||||
const entry = this.fleets[side][this.current[side]];
|
||||
const def = this.SHIPS[entry.key];
|
||||
const bg = this.add.graphics();
|
||||
bg.fillStyle(0x081209, 0.72).fillRoundedRect(0, 0, 300, 118, 8);
|
||||
bg.lineStyle(2, SIDE_COLORS[side].hull, 0.6).strokeRoundedRect(0, 0, 300, 118, 8);
|
||||
c.add(bg);
|
||||
const name = this.add.text(12, 8, def.name.toUpperCase(), {
|
||||
fontFamily: FONT, fontSize: '20px', color: SIDE_COLORS[side].hex,
|
||||
});
|
||||
c.add(name);
|
||||
const bars = this.add.graphics();
|
||||
c.add(bars);
|
||||
const crewT = this.add.text(288, 34, '', { fontFamily: FONT, fontSize: '16px', color: '#9adbb2' }).setOrigin(1, 0);
|
||||
const enT = this.add.text(288, 64, '', { fontFamily: FONT, fontSize: '16px', color: '#f0908a' }).setOrigin(1, 0);
|
||||
c.add(crewT);
|
||||
c.add(enT);
|
||||
const fleetT = this.add.text(12, 92, '', { fontFamily: FONT, fontSize: '15px', color: '#89a6a0' });
|
||||
c.add(fleetT);
|
||||
this.hudRoot.add(c);
|
||||
this.hudPanels.push({ c, bars, crewT, enT, fleetT, def });
|
||||
});
|
||||
}
|
||||
|
||||
updateHud() {
|
||||
[0, 1].forEach((side) => {
|
||||
const ship = this.match.ships[side];
|
||||
const p = this.hudPanels[side];
|
||||
const remaining = this.aliveFleet(side).length;
|
||||
const sig = `${ship.crew}|${ship.energy}|${remaining}`;
|
||||
if (this.lastHud[side] === sig) return;
|
||||
this.lastHud[side] = sig;
|
||||
const s = ship.def.stats;
|
||||
p.bars.clear();
|
||||
const bar = (y, frac, color) => {
|
||||
p.bars.fillStyle(0x111d16, 1).fillRect(12, y, 210, 16);
|
||||
if (frac > 0) p.bars.fillStyle(color, 1).fillRect(13, y + 1, 208 * frac, 14);
|
||||
p.bars.lineStyle(1, 0x3a5a48, 1).strokeRect(12, y, 210, 16);
|
||||
const ticks = Math.floor(210 / 14);
|
||||
p.bars.lineStyle(1, 0x081209, 0.9);
|
||||
for (let i = 1; i < ticks; i += 1) p.bars.lineBetween(12 + i * 14, y, 12 + i * 14, y + 16);
|
||||
};
|
||||
bar(34, Math.max(0, ship.crew) / s.maxCrew, 0x63d97c);
|
||||
bar(64, Math.max(0, ship.energy) / s.maxEnergy, 0xe2574e);
|
||||
p.crewT.setText(`${Math.max(0, ship.crew)}`);
|
||||
p.enT.setText(`${Math.max(0, ship.energy)}`);
|
||||
p.fleetT.setText(`FLEET ${remaining} — DESTROYED ${this.destroyedPoints[side]} PTS`);
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Pause / escape
|
||||
|
||||
onEscape() {
|
||||
if (this.view !== 'battle') return;
|
||||
if (this.paused) { this.closePause(); return; }
|
||||
this.paused = true;
|
||||
const cx = GAME_WIDTH / 2;
|
||||
this.pauseRoot = this.add.container(0, 0).setDepth(D.overlay);
|
||||
const dim = this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62);
|
||||
this.pauseRoot.add(dim);
|
||||
this.pauseRoot.add(this.mkText(cx, 340, 'PAUSED', 60, '#74f5a1'));
|
||||
this.pauseRoot.add(this.textButton(cx, 480, 'RESUME', () => this.closePause(), 32));
|
||||
this.pauseRoot.add(this.textButton(cx, 570, 'ABANDON MATCH', () => { this.closePause(); this.showSetup(); }, 32));
|
||||
this.pauseRoot.add(this.textButton(cx, 660, 'EXIT TO MENU', () => this.scene.start('GameMenu'), 32));
|
||||
}
|
||||
|
||||
closePause() {
|
||||
this.paused = false;
|
||||
this.pauseRoot?.destroy(true);
|
||||
this.pauseRoot = null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// RECAP view
|
||||
|
||||
showRecap(losers) {
|
||||
this.view = 'recap';
|
||||
this.match = null;
|
||||
this.clearView();
|
||||
const root = this.viewRoot;
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const bothOut = losers.length === 2;
|
||||
const winner = bothOut ? null : 1 - losers[0];
|
||||
const title = bothOut ? 'MUTUAL ANNIHILATION'
|
||||
: `${this.mode === 'hotseat' ? `PLAYER ${winner + 1}` : (winner === 0 ? 'YOU' : 'THE COMPUTER')} WIN${winner === 0 && this.mode !== 'hotseat' ? '' : 'S'} THE MELEE`;
|
||||
root.add(this.mkText(cx, 200, title, 56, winner === null ? '#e7d27b' : SIDE_COLORS[winner].hex));
|
||||
|
||||
[0, 1].forEach((side) => {
|
||||
const x = side === 0 ? 620 : GAME_WIDTH - 620;
|
||||
root.add(this.mkText(x, 330, this.mode === 'hotseat' || side === 0 ? `PLAYER ${side + 1}` : 'COMPUTER', 30, SIDE_COLORS[side].hex));
|
||||
root.add(this.mkText(x, 390, `DESTROYED ${this.destroyedPoints[side]} PTS OF SHIPS`, 26));
|
||||
root.add(this.mkText(x, 440, `SHIPS LEFT ${this.aliveFleet(side).length}`, 22, '#89a6a0'));
|
||||
});
|
||||
|
||||
// Score/history: single-player only, from the human's perspective.
|
||||
if (this.mode === 'ai') {
|
||||
const score = this.destroyedPoints[0];
|
||||
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
|
||||
if (score > best) localStorage.setItem(BEST_KEY, String(score));
|
||||
api.post('/history/single-player', {
|
||||
slug: 'starcontrol',
|
||||
score,
|
||||
opponentScores: [this.destroyedPoints[1]],
|
||||
result: winner === 0 ? 'win' : 'loss',
|
||||
}).catch(() => {});
|
||||
playSound(this, winner === 0 ? SFX.VICTORY_SHORT : SFX.SCIFI_EXPLODE);
|
||||
}
|
||||
|
||||
root.add(this.textButton(cx - 260, 760, 'REMATCH', () => { this.beginMatch(); }, 32));
|
||||
root.add(this.textButton(cx, 760, 'NEW FLEETS', () => this.showSetup(), 32));
|
||||
root.add(this.textButton(cx + 260, 760, 'EXIT', () => this.scene.start('GameMenu'), 32));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,180 @@
|
|||
// One-time asset/codegen fetcher for Star Control Super Melee.
|
||||
//
|
||||
// node tools/fetchStarControlAssets.js --ships # download + build ship rotation sheets
|
||||
// node tools/fetchStarControlAssets.js --stats # print UQM stat blocks for manual review
|
||||
//
|
||||
// Downloads ship art and stats from the Ur-Quan Masters GitHub mirror
|
||||
// (juj/sc2-uqm). UQM content is CC BY-NC-SA 2.5 — an attribution line ships in
|
||||
// data/star-control-ships.json and on the game's setup screen. This script is
|
||||
// never run by the site; it needs network access plus ImageMagick (`magick`).
|
||||
//
|
||||
// --ships: for each ship, the GitHub contents API lists
|
||||
// sc2/content/base/ships/<dir>/, the 16 `<base>-big-000..015.png` rotation
|
||||
// frames are downloaded, montaged into a 16x1 spritesheet at
|
||||
// assets/images/starcontrol/sc-ship-<slug>.png, and the measured frame
|
||||
// dimensions are written into that ship's `sprite` block in
|
||||
// data/star-control-ships.json (only the sprite block is touched).
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const REPO = 'juj/sc2-uqm';
|
||||
const CONTENT = 'sc2/content/base/ships';
|
||||
const SRC = 'sc2/src/uqm/ships';
|
||||
const OUT_DIR = join(root, 'assets/images/starcontrol');
|
||||
const JSON_PATH = join(root, 'data/star-control-ships.json');
|
||||
|
||||
// slug (our JSON key) -> UQM content directory. Frame prefixes are discovered,
|
||||
// not guessed (e.g. human/cruiser-big-000.png).
|
||||
const CONTENT_DIRS = {
|
||||
androsynth: 'androsynth', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr',
|
||||
druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'kohrah',
|
||||
melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz',
|
||||
pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandro', spathi: 'spathi',
|
||||
supox: 'supox', syreen: 'syreen', thraddash: 'thraddash', umgah: 'umgah',
|
||||
urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfotpik',
|
||||
};
|
||||
|
||||
// slug -> the rotation-frame prefix that is the SHIP (several directories
|
||||
// also hold 16-frame weapon animations like spathi's "butt-big-000..015").
|
||||
const SHIP_BASE = {
|
||||
androsynth: 'guardian', arilou: 'skiff', chenjesu: 'broodhome', chmmr: 'avatar',
|
||||
druuge: 'mauler', earthling: 'cruiser', ilwrath: 'avenger', kohrah: 'marauder',
|
||||
melnorme: 'trader', mmrnmhrm: 'xform', mycon: 'podship', orz: 'nemesis',
|
||||
pkunk: 'fury', shofixti: 'scout', slylandro: 'probe', spathi: 'eluder',
|
||||
supox: 'blade', syreen: 'penetrator', thraddash: 'torch', umgah: 'drone',
|
||||
urquan: 'dreadnought', utwig: 'jugger', vux: 'intruder', yehat: 'terminator',
|
||||
zoqfot: 'stinger',
|
||||
};
|
||||
|
||||
// slug -> UQM source directory for --stats.
|
||||
const SRC_DIRS = {
|
||||
androsynth: 'androsyn', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr',
|
||||
druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'blackurq',
|
||||
melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz',
|
||||
pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandr', spathi: 'spathi',
|
||||
supox: 'supox', syreen: 'syreen', thraddash: 'thradd', umgah: 'umgah',
|
||||
urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfot',
|
||||
};
|
||||
|
||||
async function ghJson(path) {
|
||||
const res = await fetch(`https://api.github.com/repos/${REPO}/contents/${path}`, {
|
||||
headers: { 'User-Agent': 'fertig-classic-games-asset-fetch' },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status} for ${path}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function download(url, dest) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`download ${res.status} for ${url}`);
|
||||
writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
|
||||
}
|
||||
|
||||
// Finds the rotation-frame prefix: a group of exactly 16 files named
|
||||
// `<base>-big-000.png` .. `<base>-big-015.png` (weapon/captain art doesn't
|
||||
// match this shape; e.g. human's saturn-big has 23 frames and is skipped).
|
||||
function findRotationGroup(listing, preferredBase) {
|
||||
const groups = {};
|
||||
for (const f of listing) {
|
||||
const m = /^(.+)-big-(\d{3})\.png$/.exec(f.name);
|
||||
if (!m) continue;
|
||||
(groups[m[1]] ??= []).push({ n: Number(m[2]), url: f.download_url });
|
||||
}
|
||||
const candidates = Object.entries(groups)
|
||||
.filter(([, frames]) => frames.length === 16
|
||||
&& Math.min(...frames.map((fr) => fr.n)) === 0
|
||||
&& Math.max(...frames.map((fr) => fr.n)) === 15);
|
||||
if (candidates.length === 0) return null;
|
||||
let picked = candidates.find(([base]) => base === preferredBase);
|
||||
if (!picked) {
|
||||
console.warn(` preferred base "${preferredBase}" not found; candidates: ${candidates.map(([k]) => k).join(', ')}`);
|
||||
[picked] = candidates;
|
||||
}
|
||||
const [base, frames] = picked;
|
||||
frames.sort((a, b) => a.n - b.n);
|
||||
return { base, frames };
|
||||
}
|
||||
|
||||
async function buildSheet(slug, group, suffix = '') {
|
||||
const tmp = join(tmpdir(), `sc-fetch-${slug}${suffix}`);
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
const files = [];
|
||||
for (const fr of group.frames) {
|
||||
const dest = join(tmp, `f-${String(fr.n).padStart(3, '0')}.png`);
|
||||
await download(fr.url, dest);
|
||||
files.push(dest);
|
||||
}
|
||||
const sheetName = `sc-ship-${slug}${suffix}`;
|
||||
const out = join(OUT_DIR, `${sheetName}.png`);
|
||||
execFileSync('magick', ['montage', ...files, '-tile', '16x1', '-geometry', '+0+0', '-background', 'none', out]);
|
||||
const dims = execFileSync('magick', ['identify', '-format', '%w %h', files[0]]).toString().trim().split(' ');
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
return { sheet: sheetName, frames: 16, frameWidth: Number(dims[0]), frameHeight: Number(dims[1]) };
|
||||
}
|
||||
|
||||
async function fetchShips() {
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
const json = JSON.parse(readFileSync(JSON_PATH, 'utf8'));
|
||||
const results = [];
|
||||
for (const [slug, dir] of Object.entries(CONTENT_DIRS)) {
|
||||
if (!json.ships[slug]) { console.warn(`skip ${slug}: not in ships JSON`); continue; }
|
||||
process.stdout.write(`${slug} (${dir}) ... `);
|
||||
try {
|
||||
const listing = await ghJson(`${CONTENT}/${dir}`);
|
||||
const group = findRotationGroup(listing, SHIP_BASE[slug]);
|
||||
if (!group) { console.log('NO 16-frame rotation group found'); continue; }
|
||||
json.ships[slug].sprite = await buildSheet(slug, group);
|
||||
console.log(`ok (${group.base}, ${json.ships[slug].sprite.frameWidth}x${json.ships[slug].sprite.frameHeight})`);
|
||||
// Mmrnmhrm's alternate Y-Wing form has its own rotation frames.
|
||||
if (slug === 'mmrnmhrm' && json.ships[slug].forms?.[0]) {
|
||||
const yGroup = findRotationGroup(listing, 'ywing');
|
||||
if (yGroup?.base === 'ywing') {
|
||||
json.ships[slug].forms[0].sprite = await buildSheet(slug, yGroup, '-y');
|
||||
console.log(` + y-form sheet (${json.ships[slug].forms[0].sprite.frameWidth}x${json.ships[slug].forms[0].sprite.frameHeight})`);
|
||||
}
|
||||
}
|
||||
results.push(slug);
|
||||
} catch (e) {
|
||||
console.log(`FAILED: ${e.message}`);
|
||||
}
|
||||
}
|
||||
writeFileSync(JSON_PATH, `${JSON.stringify(json, null, 2)}\n`);
|
||||
console.log(`\n${results.length}/${Object.keys(CONTENT_DIRS).length} sheets built; sprite blocks written to data/star-control-ships.json`);
|
||||
}
|
||||
|
||||
const STAT_DEFINES = [
|
||||
'MAX_CREW', 'MAX_ENERGY', 'ENERGY_REGENERATION', 'ENERGY_WAIT', 'MAX_THRUST',
|
||||
'THRUST_INCREMENT', 'THRUST_WAIT', 'TURN_WAIT', 'SHIP_MASS',
|
||||
'WEAPON_ENERGY_COST', 'WEAPON_WAIT', 'SPECIAL_ENERGY_COST', 'SPECIAL_WAIT',
|
||||
'MISSILE_SPEED', 'MISSILE_LIFE', 'MISSILE_HITS', 'MISSILE_DAMAGE',
|
||||
];
|
||||
|
||||
// Prints raw #define blocks for manual review/merge — NOT written to the JSON
|
||||
// (several ships encode stats in code: Chmmr zapsats, Slylandro drive,
|
||||
// Mmrnmhrm forms).
|
||||
async function fetchStats() {
|
||||
for (const [slug, dir] of Object.entries(SRC_DIRS)) {
|
||||
const url = `https://raw.githubusercontent.com/${REPO}/master/${SRC}/${dir}/${dir}.c`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) { console.log(`${slug}: fetch failed ${res.status}`); continue; }
|
||||
const text = await res.text();
|
||||
const out = {};
|
||||
for (const name of STAT_DEFINES) {
|
||||
const m = new RegExp(`^#define\\s+${name}\\s+(.+)$`, 'm').exec(text);
|
||||
if (m) out[name] = m[1].replace(/\/\*.*?\*\//g, '').trim();
|
||||
}
|
||||
console.log(`\n=== ${slug} (${dir}.c)`);
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes('--stats')) await fetchStats();
|
||||
else if (args.includes('--ships')) await fetchShips();
|
||||
else console.log('usage: node tools/fetchStarControlAssets.js --ships | --stats');
|
||||
|
|
@ -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);
|
||||