diff --git a/README.md b/README.md index 41dfd47..679def1 100644 --- a/README.md +++ b/README.md @@ -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//`. Games are single-player against 0–7 diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 3aae5dd..262d8bc 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index c3d6d7b..a60e230 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/assets/images/starcontrol/sc-ship-androsynth.png b/assets/images/starcontrol/sc-ship-androsynth.png new file mode 100644 index 0000000..8688716 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-androsynth.png differ diff --git a/assets/images/starcontrol/sc-ship-arilou.png b/assets/images/starcontrol/sc-ship-arilou.png new file mode 100644 index 0000000..1792924 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-arilou.png differ diff --git a/assets/images/starcontrol/sc-ship-chenjesu.png b/assets/images/starcontrol/sc-ship-chenjesu.png new file mode 100644 index 0000000..4a143d0 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-chenjesu.png differ diff --git a/assets/images/starcontrol/sc-ship-chmmr.png b/assets/images/starcontrol/sc-ship-chmmr.png new file mode 100644 index 0000000..09b9e95 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-chmmr.png differ diff --git a/assets/images/starcontrol/sc-ship-druuge.png b/assets/images/starcontrol/sc-ship-druuge.png new file mode 100644 index 0000000..4b8a429 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-druuge.png differ diff --git a/assets/images/starcontrol/sc-ship-earthling.png b/assets/images/starcontrol/sc-ship-earthling.png new file mode 100644 index 0000000..89ae26b Binary files /dev/null and b/assets/images/starcontrol/sc-ship-earthling.png differ diff --git a/assets/images/starcontrol/sc-ship-ilwrath.png b/assets/images/starcontrol/sc-ship-ilwrath.png new file mode 100644 index 0000000..236fa02 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-ilwrath.png differ diff --git a/assets/images/starcontrol/sc-ship-kohrah.png b/assets/images/starcontrol/sc-ship-kohrah.png new file mode 100644 index 0000000..e5f2efb Binary files /dev/null and b/assets/images/starcontrol/sc-ship-kohrah.png differ diff --git a/assets/images/starcontrol/sc-ship-melnorme.png b/assets/images/starcontrol/sc-ship-melnorme.png new file mode 100644 index 0000000..6fad9e4 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-melnorme.png differ diff --git a/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png b/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png new file mode 100644 index 0000000..75eb325 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-mmrnmhrm-y.png differ diff --git a/assets/images/starcontrol/sc-ship-mmrnmhrm.png b/assets/images/starcontrol/sc-ship-mmrnmhrm.png new file mode 100644 index 0000000..5968e79 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-mmrnmhrm.png differ diff --git a/assets/images/starcontrol/sc-ship-mycon.png b/assets/images/starcontrol/sc-ship-mycon.png new file mode 100644 index 0000000..6f8e827 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-mycon.png differ diff --git a/assets/images/starcontrol/sc-ship-orz.png b/assets/images/starcontrol/sc-ship-orz.png new file mode 100644 index 0000000..b0ed1b2 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-orz.png differ diff --git a/assets/images/starcontrol/sc-ship-pkunk.png b/assets/images/starcontrol/sc-ship-pkunk.png new file mode 100644 index 0000000..6c51e47 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-pkunk.png differ diff --git a/assets/images/starcontrol/sc-ship-shofixti.png b/assets/images/starcontrol/sc-ship-shofixti.png new file mode 100644 index 0000000..609727a Binary files /dev/null and b/assets/images/starcontrol/sc-ship-shofixti.png differ diff --git a/assets/images/starcontrol/sc-ship-slylandro.png b/assets/images/starcontrol/sc-ship-slylandro.png new file mode 100644 index 0000000..320e59c Binary files /dev/null and b/assets/images/starcontrol/sc-ship-slylandro.png differ diff --git a/assets/images/starcontrol/sc-ship-spathi.png b/assets/images/starcontrol/sc-ship-spathi.png new file mode 100644 index 0000000..bd13f00 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-spathi.png differ diff --git a/assets/images/starcontrol/sc-ship-supox.png b/assets/images/starcontrol/sc-ship-supox.png new file mode 100644 index 0000000..0da9ad1 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-supox.png differ diff --git a/assets/images/starcontrol/sc-ship-syreen.png b/assets/images/starcontrol/sc-ship-syreen.png new file mode 100644 index 0000000..1c743af Binary files /dev/null and b/assets/images/starcontrol/sc-ship-syreen.png differ diff --git a/assets/images/starcontrol/sc-ship-thraddash.png b/assets/images/starcontrol/sc-ship-thraddash.png new file mode 100644 index 0000000..28b5870 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-thraddash.png differ diff --git a/assets/images/starcontrol/sc-ship-umgah.png b/assets/images/starcontrol/sc-ship-umgah.png new file mode 100644 index 0000000..13b7967 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-umgah.png differ diff --git a/assets/images/starcontrol/sc-ship-urquan.png b/assets/images/starcontrol/sc-ship-urquan.png new file mode 100644 index 0000000..2c6acc0 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-urquan.png differ diff --git a/assets/images/starcontrol/sc-ship-utwig.png b/assets/images/starcontrol/sc-ship-utwig.png new file mode 100644 index 0000000..d852d30 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-utwig.png differ diff --git a/assets/images/starcontrol/sc-ship-vux.png b/assets/images/starcontrol/sc-ship-vux.png new file mode 100644 index 0000000..723a677 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-vux.png differ diff --git a/assets/images/starcontrol/sc-ship-yehat.png b/assets/images/starcontrol/sc-ship-yehat.png new file mode 100644 index 0000000..ae6a578 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-yehat.png differ diff --git a/assets/images/starcontrol/sc-ship-zoqfot.png b/assets/images/starcontrol/sc-ship-zoqfot.png new file mode 100644 index 0000000..d949ff9 Binary files /dev/null and b/assets/images/starcontrol/sc-ship-zoqfot.png differ diff --git a/data/star-control-ships.json b/data/star-control-ships.json new file mode 100644 index 0000000..82d6ce4 --- /dev/null +++ b/data/star-control-ships.json @@ -0,0 +1,1478 @@ +{ + "credits": "Ship data and artwork derived from The Ur-Quan Masters (Star Control II), (c) Toys for Bob / the UQM project. Content licensed CC BY-NC-SA 2.5.", + "notes": "Stats are raw UQM numbers (sc2/src/uqm/ships//.c). Waits are 24fps frame counters with UQM semantics: WAIT=n recurs every n+1 frames. Distances marked 'display px' are multiplied by 4 into world units by the engine (DISPLAY_TO_WORLD); speeds are world units per frame. To add a ship: add an entry here; a 16-frame rotation sheet in assets/images/starcontrol/ is optional (a vector chevron is drawn when absent). New primary/special 'type' strings require a function in src/games/starcontrol/StarControlShips.js.", + "ships": { + "androsynth": { + "name": "Androsynth Guardian", + "race": "Androsynth", + "team": "hierarchy", + "pointValue": 15, + "stats": { + "maxCrew": 20, + "maxEnergy": 24, + "energyRegen": 1, + "energyWait": 8, + "maxThrust": 24, + "thrustIncrement": 3, + "thrustWait": 0, + "turnWait": 4, + "mass": 6 + }, + "primary": { + "type": "homing", + "name": "Acid Bubbles", + "energyCost": 3, + "refireWait": 0, + "damage": 2, + "speed": 32, + "lifeFrames": 200, + "hp": 3, + "trackWait": 2, + "muzzleDist": 12, + "radius": 26, + "projectileType": "bubble", + "sfxClass": "pellet" + }, + "special": { + "type": "blazer", + "name": "Blazer Form", + "energyCost": 2, + "refireWait": 0, + "params": { + "thrust": 60, + "ramDamage": 3 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 800, + "engageStyle": "chase", + "specialUse": "blazer", + "fleeCrewFrac": 0.15, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-androsynth", + "frames": 16, + "frameWidth": 16, + "frameHeight": 21 + } + }, + "arilou": { + "name": "Arilou Skiff", + "race": "Arilou Lalee'lay", + "team": "alliance", + "pointValue": 16, + "stats": { + "maxCrew": 6, + "maxEnergy": 20, + "energyRegen": 1, + "energyWait": 6, + "maxThrust": 40, + "thrustIncrement": 40, + "thrustWait": 0, + "turnWait": 0, + "mass": 1 + }, + "primary": { + "type": "beam", + "name": "Auto-Tracking Laser", + "energyCost": 2, + "refireWait": 1, + "damage": 1, + "range": 120, + "autoAim": true, + "sfxClass": "zap" + }, + "special": { + "type": "teleport", + "name": "Hyperspace Shunt", + "energyCost": 3, + "refireWait": 2, + "sfxClass": "warp" + }, + "passives": [ + "inertialess" + ], + "ai": { + "preferredRange": 420, + "engageStyle": "chase", + "specialUse": "teleportEscape", + "fleeCrewFrac": 0.35, + "gravityCaution": 0.2 + }, + "sprite": { + "sheet": "sc-ship-arilou", + "frames": 16, + "frameWidth": 17, + "frameHeight": 14 + } + }, + "chenjesu": { + "name": "Chenjesu Broodhome", + "race": "Chenjesu", + "team": "alliance", + "pointValue": 28, + "stats": { + "maxCrew": 36, + "maxEnergy": 30, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 27, + "thrustIncrement": 3, + "thrustWait": 4, + "turnWait": 6, + "mass": 10 + }, + "primary": { + "type": "crystalShard", + "name": "Photon Crystal", + "energyCost": 5, + "refireWait": 0, + "damage": 6, + "speed": 64, + "lifeFrames": 90, + "hp": 10, + "muzzleDist": 12, + "radius": 30, + "fragment": { + "count": 8, + "speed": 64, + "lifeFrames": 10, + "damage": 2 + }, + "sfxClass": "heavy" + }, + "special": { + "type": "dogi", + "name": "D.O.G.I.", + "energyCost": 30, + "refireWait": 0, + "params": { + "speed": 32, + "hp": 3, + "drain": 10, + "maxUnits": 4, + "radius": 48 + }, + "sfxClass": "launch" + }, + "passives": [], + "ai": { + "preferredRange": 2300, + "engageStyle": "snipe", + "specialUse": "onCooldown", + "fleeCrewFrac": 0.1, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-chenjesu", + "frames": 16, + "frameWidth": 34, + "frameHeight": 34 + } + }, + "chmmr": { + "name": "Chmmr Avatar", + "race": "Chmmr", + "team": "alliance", + "pointValue": 30, + "stats": { + "maxCrew": 42, + "maxEnergy": 42, + "energyRegen": 1, + "energyWait": 1, + "maxThrust": 35, + "thrustIncrement": 7, + "thrustWait": 5, + "turnWait": 3, + "mass": 10 + }, + "primary": { + "type": "beam", + "name": "Heavy Laser", + "energyCost": 2, + "refireWait": 0, + "damage": 2, + "range": 150, + "sfxClass": "zap" + }, + "special": { + "type": "tractor", + "name": "Tractor Beam", + "energyCost": 1, + "refireWait": 0, + "params": { + "pull": 6, + "range": 1000 + }, + "sfxClass": "warp" + }, + "passives": [ + "zapsats" + ], + "zapsats": { + "count": 3, + "hp": 10, + "zapRange": 64, + "zapWait": 2, + "orbitR": 300 + }, + "ai": { + "preferredRange": 520, + "engageStyle": "chase", + "specialUse": "tractor", + "fleeCrewFrac": 0.1, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-chmmr", + "frames": 16, + "frameWidth": 37, + "frameHeight": 38 + } + }, + "druuge": { + "name": "Druuge Mauler", + "race": "Druuge", + "team": "neutral", + "pointValue": 17, + "stats": { + "maxCrew": 14, + "maxEnergy": 32, + "energyRegen": 1, + "energyWait": 50, + "maxThrust": 20, + "thrustIncrement": 2, + "thrustWait": 1, + "turnWait": 4, + "mass": 5 + }, + "primary": { + "type": "projectile", + "name": "Mass Driver", + "energyCost": 4, + "refireWait": 10, + "damage": 6, + "speed": 120, + "lifeFrames": 20, + "hp": 4, + "muzzleDist": 24, + "radius": 28, + "recoil": 24, + "sfxClass": "heavy" + }, + "special": { + "type": "furnace", + "name": "Crew Furnace", + "energyCost": 0, + "refireWait": 30, + "sfxClass": "explode" + }, + "passives": [], + "ai": { + "preferredRange": 2200, + "engageStyle": "snipe", + "specialUse": "furnace", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-druuge", + "frames": 16, + "frameWidth": 15, + "frameHeight": 34 + } + }, + "earthling": { + "name": "Earthling Cruiser", + "race": "Earthling", + "team": "alliance", + "pointValue": 11, + "stats": { + "maxCrew": 18, + "maxEnergy": 18, + "energyRegen": 1, + "energyWait": 8, + "maxThrust": 24, + "thrustIncrement": 3, + "thrustWait": 4, + "turnWait": 1, + "mass": 6 + }, + "primary": { + "type": "homing", + "name": "Nuclear Missile", + "energyCost": 9, + "refireWait": 10, + "damage": 4, + "speed": 40, + "lifeFrames": 60, + "hp": 1, + "trackWait": 3, + "muzzleDist": 42, + "sfxClass": "missile" + }, + "special": { + "type": "pointDefense", + "name": "Point-Defense Laser", + "energyCost": 4, + "refireWait": 9, + "params": { + "range": 100, + "damage": 1, + "maxTargets": 4 + }, + "sfxClass": "zap" + }, + "passives": [], + "ai": { + "preferredRange": 2400, + "engageStyle": "kite", + "specialUse": "pointDefenseAuto", + "fleeCrewFrac": 0.25, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-earthling", + "frames": 16, + "frameWidth": 13, + "frameHeight": 35 + } + }, + "ilwrath": { + "name": "Ilwrath Avenger", + "race": "Ilwrath", + "team": "hierarchy", + "pointValue": 10, + "stats": { + "maxCrew": 22, + "maxEnergy": 16, + "energyRegen": 4, + "energyWait": 4, + "maxThrust": 25, + "thrustIncrement": 5, + "thrustWait": 0, + "turnWait": 2, + "mass": 7 + }, + "primary": { + "type": "projectile", + "name": "Hellfire Spout", + "energyCost": 1, + "refireWait": 0, + "damage": 1, + "speed": 25, + "lifeFrames": 8, + "hp": 1, + "jitterDeg": 7, + "addShipVelocity": true, + "muzzleDist": 29, + "projectileType": "flame", + "sfxClass": "flame" + }, + "special": { + "type": "cloak", + "name": "Cloaking Device", + "energyCost": 3, + "refireWait": 13, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 500, + "engageStyle": "ambush", + "specialUse": "cloakApproach", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.8 + }, + "sprite": { + "sheet": "sc-ship-ilwrath", + "frames": 16, + "frameWidth": 38, + "frameHeight": 31 + } + }, + "kohrah": { + "name": "Kohr-Ah Marauder", + "race": "Ur-Quan Kohr-Ah", + "team": "hierarchy", + "pointValue": 30, + "stats": { + "maxCrew": 42, + "maxEnergy": 42, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 30, + "thrustIncrement": 6, + "thrustWait": 6, + "turnWait": 4, + "mass": 10 + }, + "primary": { + "type": "guidedBlades", + "name": "Spinning Blades", + "energyCost": 6, + "refireWait": 6, + "damage": 4, + "speed": 64, + "lifeFrames": 64, + "hp": 10, + "muzzleDist": 9, + "radius": 30, + "sfxClass": "heavy" + }, + "special": { + "type": "fireRing", + "name": "F.R.I.E.D.", + "energyCost": 21, + "refireWait": 9, + "params": { + "range": 520, + "frames": 32, + "damage": 1, + "damageEvery": 8 + }, + "sfxClass": "explode" + }, + "passives": [], + "ai": { + "preferredRange": 1500, + "engageStyle": "chase", + "specialUse": "fireRingReactive", + "fleeCrewFrac": 0.1, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-kohrah", + "frames": 16, + "frameWidth": 22, + "frameHeight": 38 + } + }, + "melnorme": { + "name": "Melnorme Trader", + "race": "Melnorme", + "team": "neutral", + "pointValue": 18, + "stats": { + "maxCrew": 20, + "maxEnergy": 42, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 36, + "thrustIncrement": 6, + "thrustWait": 4, + "turnWait": 4, + "mass": 7 + }, + "primary": { + "type": "chargeShot", + "name": "Pump-Up Blaster", + "energyCost": 5, + "refireWait": 1, + "speed": 180, + "lifeFrames": 10, + "damagePerLevel": 2, + "maxLevel": 4, + "levelFrames": 24, + "muzzleDist": 24, + "sfxClass": "heavy" + }, + "special": { + "type": "confuse", + "name": "Confusion Pulse", + "energyCost": 20, + "refireWait": 20, + "params": { + "speed": 120, + "lifeFrames": 20, + "confuseFrames": 96, + "hp": 200, + "muzzleDist": 16 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 2400, + "engageStyle": "snipe", + "specialUse": "onCooldown", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-melnorme", + "frames": 16, + "frameWidth": 31, + "frameHeight": 31 + } + }, + "mmrnmhrm": { + "name": "Mmrnmhrm X-Form", + "race": "Mmrnmhrm", + "team": "alliance", + "pointValue": 19, + "stats": { + "maxCrew": 20, + "maxEnergy": 10, + "energyRegen": 2, + "energyWait": 6, + "maxThrust": 20, + "thrustIncrement": 5, + "thrustWait": 1, + "turnWait": 2, + "mass": 3 + }, + "primary": { + "type": "beam", + "name": "Twin Auto-Lasers", + "energyCost": 1, + "refireWait": 0, + "damage": 2, + "range": 141, + "autoAim": true, + "sfxClass": "zap" + }, + "special": { + "type": "transform", + "name": "Transform", + "energyCost": 10, + "refireWait": 0, + "sfxClass": "warp" + }, + "forms": [ + { + "name": "Y-Wing", + "stats": { + "maxThrust": 50, + "thrustIncrement": 10, + "thrustWait": 0, + "turnWait": 14, + "energyRegen": 1, + "energyWait": 6 + }, + "primary": { + "type": "homing", + "name": "Twin Seekers", + "energyCost": 1, + "refireWait": 20, + "damage": 1, + "speed": 80, + "lifeFrames": 40, + "hp": 1, + "trackWait": 5, + "offsets": [ + [ + 4, + -10 + ], + [ + 4, + 10 + ] + ], + "sfxClass": "missile" + }, + "sprite": { + "sheet": "sc-ship-mmrnmhrm-y", + "frames": 16, + "frameWidth": 17, + "frameHeight": 24 + } + } + ], + "passives": [], + "ai": { + "preferredRange": 1100, + "engageStyle": "kite", + "specialUse": "transformRange", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-mmrnmhrm", + "frames": 16, + "frameWidth": 26, + "frameHeight": 18 + } + }, + "mycon": { + "name": "Mycon Podship", + "race": "Mycon", + "team": "hierarchy", + "pointValue": 21, + "stats": { + "maxCrew": 20, + "maxEnergy": 40, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 27, + "thrustIncrement": 9, + "thrustWait": 6, + "turnWait": 6, + "mass": 7 + }, + "primary": { + "type": "homing", + "name": "Plasmoid", + "energyCost": 20, + "refireWait": 5, + "damage": 10, + "speed": 32, + "lifeFrames": 143, + "hp": 1, + "trackWait": 1, + "muzzleDist": 12, + "radius": 60, + "projectileType": "plasmoid", + "sfxClass": "heavy" + }, + "special": { + "type": "regenerate", + "name": "Regenerate", + "energyCost": 40, + "refireWait": 0, + "params": { + "crew": 4 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 1600, + "engageStyle": "kite", + "specialUse": "regen", + "fleeCrewFrac": 0.3, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-mycon", + "frames": 16, + "frameWidth": 26, + "frameHeight": 25 + } + }, + "orz": { + "name": "Orz Nemesis", + "race": "Orz", + "team": "alliance", + "pointValue": 23, + "stats": { + "maxCrew": 16, + "maxEnergy": 20, + "energyRegen": 1, + "energyWait": 6, + "maxThrust": 35, + "thrustIncrement": 5, + "thrustWait": 0, + "turnWait": 1, + "mass": 4 + }, + "primary": { + "type": "projectile", + "name": "Howitzer", + "energyCost": 6, + "refireWait": 4, + "damage": 3, + "speed": 120, + "lifeFrames": 12, + "hp": 2, + "muzzleDist": 4, + "turretArcDeg": 60, + "sfxClass": "heavy" + }, + "special": { + "type": "marines", + "name": "Space Marines", + "energyCost": 0, + "refireWait": 12, + "params": { + "speed": 24, + "hp": 3, + "maxUnits": 8, + "killEvery": 36, + "maxKills": 4 + }, + "sfxClass": "launch" + }, + "passives": [], + "ai": { + "preferredRange": 1300, + "engageStyle": "chase", + "specialUse": "marines", + "fleeCrewFrac": 0.25, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-orz", + "frames": 16, + "frameWidth": 29, + "frameHeight": 23 + } + }, + "pkunk": { + "name": "Pkunk Fury", + "race": "Pkunk", + "team": "alliance", + "pointValue": 20, + "stats": { + "maxCrew": 8, + "maxEnergy": 12, + "energyRegen": 0, + "energyWait": 0, + "maxThrust": 64, + "thrustIncrement": 16, + "thrustWait": 0, + "turnWait": 0, + "mass": 1 + }, + "primary": { + "type": "projectile", + "name": "Triple Miniguns", + "energyCost": 1, + "refireWait": 0, + "damage": 1, + "speed": 96, + "lifeFrames": 5, + "hp": 1, + "anglesDeg": [ + 0, + 90, + -90 + ], + "muzzleDist": 4, + "sfxClass": "pellet" + }, + "special": { + "type": "taunt", + "name": "Cathartic Insults", + "energyCost": 0, + "refireWait": 16, + "params": { + "energy": 2 + }, + "sfxClass": "launch" + }, + "passives": [ + "resurrect" + ], + "ai": { + "preferredRange": 600, + "engageStyle": "chase", + "specialUse": "taunt", + "fleeCrewFrac": 0, + "gravityCaution": 0.5 + }, + "sprite": { + "sheet": "sc-ship-pkunk", + "frames": 16, + "frameWidth": 27, + "frameHeight": 20 + } + }, + "shofixti": { + "name": "Shofixti Scout", + "race": "Shofixti", + "team": "alliance", + "pointValue": 5, + "stats": { + "maxCrew": 6, + "maxEnergy": 4, + "energyRegen": 1, + "energyWait": 9, + "maxThrust": 35, + "thrustIncrement": 5, + "thrustWait": 0, + "turnWait": 1, + "mass": 1 + }, + "primary": { + "type": "projectile", + "name": "Dart Gun", + "energyCost": 1, + "refireWait": 3, + "damage": 1, + "speed": 96, + "lifeFrames": 10, + "hp": 1, + "muzzleDist": 15, + "sfxClass": "pellet" + }, + "special": { + "type": "glory", + "name": "Glory Device", + "energyCost": 0, + "refireWait": 0, + "params": { + "range": 180, + "maxDamage": 18 + }, + "sfxClass": "explode" + }, + "passives": [], + "ai": { + "preferredRange": 900, + "engageStyle": "chase", + "specialUse": "gloryFinale", + "fleeCrewFrac": 0, + "gravityCaution": 0.7 + }, + "sprite": { + "sheet": "sc-ship-shofixti", + "frames": 16, + "frameWidth": 9, + "frameHeight": 15 + } + }, + "slylandro": { + "name": "Slylandro Probe", + "race": "Slylandro", + "team": "neutral", + "pointValue": 17, + "stats": { + "maxCrew": 12, + "maxEnergy": 20, + "energyRegen": 0, + "energyWait": 10, + "maxThrust": 60, + "thrustIncrement": 60, + "thrustWait": 0, + "turnWait": 0, + "mass": 1 + }, + "primary": { + "type": "lightning", + "name": "Lightning Discharge", + "energyCost": 2, + "refireWait": 17, + "damage": 2, + "minRange": 48, + "maxRange": 200, + "sfxClass": "zap" + }, + "special": { + "type": "harvest", + "name": "Asteroid Harvest", + "energyCost": 0, + "refireWait": 20, + "params": { + "range": 120, + "energy": 8 + }, + "sfxClass": "launch" + }, + "passives": [ + "probeDrive" + ], + "ai": { + "preferredRange": 500, + "engageStyle": "chase", + "specialUse": "harvest", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.1 + }, + "sprite": { + "sheet": "sc-ship-slylandro", + "frames": 16, + "frameWidth": 28, + "frameHeight": 45 + } + }, + "spathi": { + "name": "Spathi Eluder", + "race": "Spathi", + "team": "hierarchy", + "pointValue": 18, + "stats": { + "maxCrew": 30, + "maxEnergy": 10, + "energyRegen": 1, + "energyWait": 10, + "maxThrust": 48, + "thrustIncrement": 12, + "thrustWait": 1, + "turnWait": 1, + "mass": 5 + }, + "primary": { + "type": "projectile", + "name": "Forward Gun", + "energyCost": 2, + "refireWait": 0, + "damage": 1, + "speed": 120, + "lifeFrames": 10, + "hp": 1, + "muzzleDist": 16, + "sfxClass": "pellet" + }, + "special": { + "type": "rearHoming", + "name": "B.U.T.T. Missile", + "energyCost": 3, + "refireWait": 7, + "params": { + "speed": 32, + "damage": 2, + "lifeFrames": 30, + "hp": 1, + "trackWait": 1, + "muzzleDist": 20 + }, + "sfxClass": "missile" + }, + "passives": [], + "ai": { + "preferredRange": 2000, + "engageStyle": "kite", + "specialUse": "kiteRear", + "fleeCrewFrac": 0.5, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-spathi", + "frames": 16, + "frameWidth": 28, + "frameHeight": 22 + } + }, + "supox": { + "name": "Supox Blade", + "race": "Supox", + "team": "alliance", + "pointValue": 16, + "stats": { + "maxCrew": 12, + "maxEnergy": 16, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 40, + "thrustIncrement": 8, + "thrustWait": 0, + "turnWait": 1, + "mass": 4 + }, + "primary": { + "type": "projectile", + "name": "Gob Gun", + "energyCost": 1, + "refireWait": 2, + "damage": 1, + "speed": 120, + "lifeFrames": 10, + "hp": 1, + "muzzleDist": 8, + "sfxClass": "pellet" + }, + "special": { + "type": "omniThrust", + "name": "Omni-Thrust", + "energyCost": 1, + "refireWait": 0, + "sfxClass": "launch" + }, + "passives": [], + "ai": { + "preferredRange": 1000, + "engageStyle": "chase", + "specialUse": "never", + "fleeCrewFrac": 0.25, + "gravityCaution": 0.8 + }, + "sprite": { + "sheet": "sc-ship-supox", + "frames": 16, + "frameWidth": 13, + "frameHeight": 30 + } + }, + "syreen": { + "name": "Syreen Penetrator", + "race": "Syreen", + "team": "alliance", + "pointValue": 13, + "stats": { + "maxCrew": 12, + "maxEnergy": 16, + "energyRegen": 1, + "energyWait": 6, + "maxThrust": 36, + "thrustIncrement": 9, + "thrustWait": 1, + "turnWait": 1, + "mass": 2 + }, + "primary": { + "type": "projectile", + "name": "Particle Stiletto", + "energyCost": 1, + "refireWait": 8, + "damage": 2, + "speed": 120, + "lifeFrames": 10, + "hp": 1, + "muzzleDist": 12, + "sfxClass": "pellet" + }, + "special": { + "type": "song", + "name": "Syreen Song", + "energyCost": 5, + "refireWait": 20, + "params": { + "range": 208, + "maxCrew": 8, + "fraction": 0.25 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 800, + "engageStyle": "chase", + "specialUse": "song", + "fleeCrewFrac": 0.3, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-syreen", + "frames": 16, + "frameWidth": 17, + "frameHeight": 30 + } + }, + "thraddash": { + "name": "Thraddash Torch", + "race": "Thraddash", + "team": "hierarchy", + "pointValue": 10, + "stats": { + "maxCrew": 8, + "maxEnergy": 24, + "energyRegen": 1, + "energyWait": 6, + "maxThrust": 28, + "thrustIncrement": 7, + "thrustWait": 0, + "turnWait": 1, + "mass": 7 + }, + "primary": { + "type": "projectile", + "name": "Ion Blaster", + "energyCost": 2, + "refireWait": 12, + "damage": 1, + "speed": 120, + "lifeFrames": 15, + "hp": 2, + "muzzleDist": 12, + "sfxClass": "pellet" + }, + "special": { + "type": "afterburner", + "name": "Afterburner", + "energyCost": 1, + "refireWait": 0, + "params": { + "thrust": 64, + "thrustIncrement": 12, + "napalm": { + "lifeFrames": 48, + "damage": 2, + "radius": 60 + } + }, + "sfxClass": "flame" + }, + "passives": [], + "ai": { + "preferredRange": 700, + "engageStyle": "chase", + "specialUse": "burner", + "fleeCrewFrac": 0.25, + "gravityCaution": 0.8 + }, + "sprite": { + "sheet": "sc-ship-thraddash", + "frames": 16, + "frameWidth": 22, + "frameHeight": 22 + } + }, + "umgah": { + "name": "Umgah Drone", + "race": "Umgah", + "team": "hierarchy", + "pointValue": 7, + "stats": { + "maxCrew": 10, + "maxEnergy": 30, + "energyRegen": 30, + "energyWait": 150, + "maxThrust": 18, + "thrustIncrement": 6, + "thrustWait": 3, + "turnWait": 4, + "mass": 1 + }, + "primary": { + "type": "cone", + "name": "Antimatter Cone", + "energyCost": 0, + "refireWait": 2, + "damage": 1, + "hp": 100, + "lifeFrames": 3, + "radius": 130, + "muzzleDist": 32, + "sfxClass": "flame" + }, + "special": { + "type": "retroHop", + "name": "Retro-Propulsion", + "energyCost": 1, + "refireWait": 2, + "params": { + "dist": 160 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 380, + "engageStyle": "chase", + "specialUse": "retro", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.7 + }, + "sprite": { + "sheet": "sc-ship-umgah", + "frames": 16, + "frameWidth": 12, + "frameHeight": 14 + } + }, + "urquan": { + "name": "Ur-Quan Dreadnought", + "race": "Ur-Quan Kzer-Za", + "team": "hierarchy", + "pointValue": 30, + "stats": { + "maxCrew": 42, + "maxEnergy": 42, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 30, + "thrustIncrement": 6, + "thrustWait": 6, + "turnWait": 4, + "mass": 10 + }, + "primary": { + "type": "projectile", + "name": "Fusion Blast", + "energyCost": 6, + "refireWait": 6, + "damage": 6, + "speed": 80, + "lifeFrames": 20, + "hp": 10, + "radius": 36, + "muzzleDist": 32, + "sfxClass": "heavy" + }, + "special": { + "type": "fighters", + "name": "Fighter Launch", + "energyCost": 8, + "refireWait": 9, + "params": { + "speed": 32, + "outFrames": 125, + "lifeFrames": 400, + "laserRange": 72, + "laserDamage": 1, + "weaponWait": 8, + "hp": 1 + }, + "sfxClass": "launch" + }, + "passives": [], + "ai": { + "preferredRange": 1400, + "engageStyle": "chase", + "specialUse": "swarm", + "fleeCrewFrac": 0.15, + "gravityCaution": 1 + }, + "sprite": { + "sheet": "sc-ship-urquan", + "frames": 16, + "frameWidth": 29, + "frameHeight": 33 + } + }, + "utwig": { + "name": "Utwig Jugger", + "race": "Utwig", + "team": "alliance", + "pointValue": 22, + "stats": { + "maxCrew": 20, + "maxEnergy": 20, + "energyRegen": 0, + "energyWait": 255, + "maxThrust": 36, + "thrustIncrement": 6, + "thrustWait": 6, + "turnWait": 1, + "mass": 8 + }, + "primary": { + "type": "projectile", + "name": "Six-Barreled Spray", + "energyCost": 0, + "refireWait": 7, + "damage": 1, + "speed": 120, + "lifeFrames": 10, + "hp": 1, + "count": 6, + "offsets": [ + [ + 5, + -18 + ], + [ + 13, + -9 + ], + [ + 17, + -4 + ], + [ + 17, + 4 + ], + [ + 13, + 9 + ], + [ + 5, + 18 + ] + ], + "anglesDeg": [ + -10, + -6, + -2, + 2, + 6, + 10 + ], + "sfxClass": "pellet" + }, + "special": { + "type": "absorbShield", + "name": "Absorption Shield", + "energyCost": 1, + "refireWait": 12, + "params": { + "frames": 14 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 1100, + "engageStyle": "chase", + "specialUse": "shieldReactive", + "fleeCrewFrac": 0.15, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-utwig", + "frames": 16, + "frameWidth": 33, + "frameHeight": 31 + } + }, + "vux": { + "name": "VUX Intruder", + "race": "VUX", + "team": "hierarchy", + "pointValue": 12, + "stats": { + "maxCrew": 20, + "maxEnergy": 40, + "energyRegen": 1, + "energyWait": 8, + "maxThrust": 21, + "thrustIncrement": 7, + "thrustWait": 4, + "turnWait": 6, + "mass": 6 + }, + "primary": { + "type": "beam", + "name": "Gigawatt Laser", + "energyCost": 1, + "refireWait": 0, + "damage": 1, + "range": 179, + "sfxClass": "zap" + }, + "special": { + "type": "limpets", + "name": "Limpet Launcher", + "energyCost": 2, + "refireWait": 7, + "params": { + "speed": 25, + "lifeFrames": 80, + "trackWait": 0 + }, + "sfxClass": "launch" + }, + "passives": [ + "warpIn" + ], + "ai": { + "preferredRange": 650, + "engageStyle": "snipe", + "specialUse": "onCooldown", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.8 + }, + "sprite": { + "sheet": "sc-ship-vux", + "frames": 16, + "frameWidth": 15, + "frameHeight": 18 + } + }, + "yehat": { + "name": "Yehat Terminator", + "race": "Yehat", + "team": "alliance", + "pointValue": 23, + "stats": { + "maxCrew": 20, + "maxEnergy": 10, + "energyRegen": 2, + "energyWait": 6, + "maxThrust": 30, + "thrustIncrement": 6, + "thrustWait": 2, + "turnWait": 2, + "mass": 3 + }, + "primary": { + "type": "projectile", + "name": "Twin Pulse Cannons", + "energyCost": 1, + "refireWait": 0, + "damage": 1, + "speed": 80, + "lifeFrames": 10, + "hp": 1, + "count": 2, + "offsets": [ + [ + 14, + -10 + ], + [ + 14, + 10 + ] + ], + "sfxClass": "pellet" + }, + "special": { + "type": "forceShield", + "name": "Force Shield", + "energyCost": 3, + "refireWait": 2, + "params": { + "frames": 10 + }, + "sfxClass": "warp" + }, + "passives": [], + "ai": { + "preferredRange": 900, + "engageStyle": "chase", + "specialUse": "shieldReactive", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.9 + }, + "sprite": { + "sheet": "sc-ship-yehat", + "frames": 16, + "frameWidth": 19, + "frameHeight": 13 + } + }, + "zoqfot": { + "name": "Zoq-Fot-Pik Stinger", + "race": "Zoq-Fot-Pik", + "team": "alliance", + "pointValue": 6, + "stats": { + "maxCrew": 10, + "maxEnergy": 10, + "energyRegen": 1, + "energyWait": 4, + "maxThrust": 40, + "thrustIncrement": 10, + "thrustWait": 0, + "turnWait": 1, + "mass": 5 + }, + "primary": { + "type": "projectile", + "name": "Spray Gun", + "energyCost": 1, + "refireWait": 2, + "damage": 1, + "speed": 40, + "lifeFrames": 10, + "hp": 1, + "jitterDeg": 4, + "muzzleDist": 8, + "sfxClass": "pellet" + }, + "special": { + "type": "tongue", + "name": "The Tongue", + "energyCost": 7, + "refireWait": 6, + "params": { + "range": 35, + "damage": 12 + }, + "sfxClass": "flame" + }, + "passives": [], + "ai": { + "preferredRange": 550, + "engageStyle": "chase", + "specialUse": "tongueRange", + "fleeCrewFrac": 0.2, + "gravityCaution": 0.7 + }, + "sprite": { + "sheet": "sc-ship-zoqfot", + "frames": 16, + "frameWidth": 23, + "frameHeight": 24 + } + } + } +} diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 252030a..c7ee57e 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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 diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 12a3392..599e25b 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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 }); diff --git a/src/games/starcontrol/StarControlAI.js b/src/games/starcontrol/StarControlAI.js new file mode 100644 index 0000000..d11d403 --- /dev/null +++ b/src/games/starcontrol/StarControlAI.js @@ -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; +} diff --git a/src/games/starcontrol/StarControlFleets.js b/src/games/starcontrol/StarControlFleets.js new file mode 100644 index 0000000..a3c5cff --- /dev/null +++ b/src/games/starcontrol/StarControlFleets.js @@ -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; +} diff --git a/src/games/starcontrol/StarControlGame.js b/src/games/starcontrol/StarControlGame.js new file mode 100644 index 0000000..412f625 --- /dev/null +++ b/src/games/starcontrol/StarControlGame.js @@ -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)); + } +} diff --git a/src/games/starcontrol/StarControlLogic.js b/src/games/starcontrol/StarControlLogic.js new file mode 100644 index 0000000..b746400 --- /dev/null +++ b/src/games/starcontrol/StarControlLogic.js @@ -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)); +} diff --git a/src/games/starcontrol/StarControlShips.js b/src/games/starcontrol/StarControlShips.js new file mode 100644 index 0000000..da6e088 --- /dev/null +++ b/src/games/starcontrol/StarControlShips.js @@ -0,0 +1,1071 @@ +// Weapon, special-ability and passive behavior registries for Star Control +// Super Melee. No Phaser dependency. Ship JSON entries reference these by +// string type; all numbers come from the JSON so the functions here are pure +// mechanics. Adding a new ship usually means JSON only — a new function lands +// here only for a genuinely new mechanic. +// +// Contracts: +// PRIMARIES[type] = { fire(state, ship, def), canFire?, init?, tick? } +// SPECIALS[type] = { activate(state, ship, def) -> false aborts (no cost), +// canFire?, init?, tick?, preprocess?, +// absorbDamage?(state, ship, dmg, kind) -> remaining dmg, +// ramDamage?(state, attacker) -> crew dmg dealt by contact } +// PASSIVES[name] = { init?, preprocess?, onDeath? -> true cancels death, +// onAsteroidKill? } +// Distances/speeds are UQM world units (1 display px = 4 world units); waits +// and lifetimes are 24 fps frame counts. + +import { + wrap, tdelta, tdist, tdist2, angleDelta, interceptAngle, + quantizeFacing, facingToRad, spawnProjectile, muzzle, damageShip, gainCrew, + gainEnergy, killShip, enemyOf, emit, destroyAsteroid, shipStats, + ARENA_W, ARENA_H, FACING_STEP, +} from './StarControlLogic.js'; + +// --------------------------------------------------------------------------- +// Shared helpers. + +// Steers a projectile toward a target angle at a limited turn rate and keeps +// its velocity aligned with its facing. +function steerProjectile(p, targetAngle, turnPerFrame, speed) { + const d = angleDelta(p.facing, targetAngle); + const step = Math.max(-turnPerFrame, Math.min(turnPerFrame, d)); + p.facing += step; + p.vx = Math.cos(p.facing) * speed; + p.vy = Math.sin(p.facing) * speed; +} + +function bearingTo(from, to) { + return Math.atan2(tdelta(from.y, to.y, ARENA_H), tdelta(from.x, to.x, ARENA_W)); +} + +// Ray-vs-circle: distance along the ray (from x,y toward angle) where it first +// hits the circle, or Infinity. Toroidal-aware over the short delta. +function rayCircle(x, y, angle, cx, cy, radius) { + const dx = tdelta(x, cx, ARENA_W); + const dy = tdelta(y, cy, ARENA_H); + const ux = Math.cos(angle); + const uy = Math.sin(angle); + const proj = dx * ux + dy * uy; + if (proj < 0) return Infinity; + const perp2 = dx * dx + dy * dy - proj * proj; + const r2 = radius * radius; + if (perp2 > r2) return Infinity; + return proj - Math.sqrt(r2 - perp2); +} + +// Everything a beam/point-defense zap can hit that belongs to the enemy. +function beamTargets(state, ship) { + const targets = []; + const enemy = enemyOf(state, ship); + if (enemy.alive && !enemy.fx.phasing) targets.push({ obj: enemy, ship: true }); + for (const p of state.projectiles) { + if (p.alive && p.owner !== ship.side && p.collidesShots) targets.push({ obj: p }); + } + for (const ast of state.asteroids) { + if (ast.alive) targets.push({ obj: ast, asteroid: true }); + } + return targets; +} + +function applyBeamHit(state, ship, hit, damage) { + if (hit.ship) { + damageShip(state, hit.obj, damage, 'beam'); + } else if (hit.asteroid) { + destroyAsteroid(state, hit.obj, ship.side); + } else { + hit.obj.hp -= damage; + if (hit.obj.hp <= 0) hit.obj.alive = false; + emit(state, { t: 'shotHit', x: hit.obj.x, y: hit.obj.y, kind: 'zapped' }); + } +} + +// Instant hit-scan along `angle` up to `range`; damages the first thing hit. +// Returns the hit (or null) and emits the beam event. +function hitscan(state, ship, angle, range, damage, color) { + const targets = beamTargets(state, ship); + let best = null; + let bestDist = range; + for (const t of targets) { + const r = t.obj.radius ?? t.obj.def?.radius ?? 24; + const d = rayCircle(ship.x, ship.y, angle, t.obj.x, t.obj.y, r); + if (d < bestDist) { bestDist = d; best = { ...t, dist: d }; } + } + emit(state, { + t: 'beam', side: ship.side, + x1: ship.x, y1: ship.y, + x2: wrap(ship.x + Math.cos(angle) * bestDist, ARENA_W), + y2: wrap(ship.y + Math.sin(angle) * bestDist, ARENA_H), + color: color ?? 'laser', hit: !!best, + }); + if (best) applyBeamHit(state, ship, best, damage); + return best; +} + +// Spawned combat unit (fighter / marine / DOGI / zapsat family): a projectile +// with hp and a per-frame brain. +function spawnUnit(state, ship, opts) { + return spawnProjectile(state, ship, { + kind: 'unit', + collidesShips: false, + collidesShots: true, + gravity: false, + ...opts, + }); +} + +function unitsOf(state, side, type) { + return state.projectiles.filter((p) => p.alive && p.owner === side && p.type === type); +} + +function launchHomingMissile(state, ship, def, x, y, angle) { + // UQM tracking: every (trackWait+1) frames the missile turns one full 1/16 + // facing toward the intercept course. + const trackWait = def.trackWait ?? 3; + return spawnProjectile(state, ship, { + type: def.projectileType ?? 'missile', + x, y, angle, + speed: def.speed, + damage: def.damage, hp: def.hp ?? 1, life: def.lifeFrames, + radius: def.radius ?? 28, + data: def.data ?? {}, + behavior(st, p) { + if (st.frame % (trackWait + 1) !== 0) return; + const enemy = st.ships[1 - p.owner]; + if (!enemy.alive || enemy.fx.cloaked || enemy.fx.phasing) return; + steerProjectile(p, interceptAngle(p.x, p.y, enemy, def.speed), FACING_STEP, def.speed); + }, + }); +} + +// --------------------------------------------------------------------------- +// PRIMARY weapon types. + +export const PRIMARIES = { + + // Straight-line shot(s). Params: damage, speed, lifeFrames, hp, radius?, + // count?, spreadDeg? (fan around facing), anglesDeg? (absolute per-shot + // offsets, e.g. Pkunk [0, 120, -120]), jitterDeg? (random per shot), + // offsets? ([[fwd, side], ...] display px), addShipVelocity?, recoil? + // (units/frame kick opposite the shot), muzzleDist? (display px), + // turretArcDeg? (auto-aim at the enemy within this cone — Orz turret). + projectile: { + fire(state, ship, def) { + const count = def.count ?? (def.anglesDeg ? def.anglesDeg.length : 1); + const offsets = def.offsets ?? [[def.muzzleDist ?? 12, 0]]; + const spread = ((def.spreadDeg ?? 0) * Math.PI) / 180; + for (let i = 0; i < count; i += 1) { + const [fwd, side] = offsets[i % offsets.length]; + const m = muzzle(ship, fwd * 4, side * 4); + let ang = m.angle; + if (def.anglesDeg) ang += (def.anglesDeg[i] * Math.PI) / 180; + else if (count > 1 && spread > 0) ang += spread * (i / (count - 1) - 0.5); + if (def.jitterDeg) ang += ((state.rng() - 0.5) * 2 * def.jitterDeg * Math.PI) / 180; + if (def.turretArcDeg) { + const enemy = enemyOf(state, ship); + if (enemy.alive && !enemy.fx.cloaked) { + const want = interceptAngle(m.x, m.y, enemy, def.speed); + const off = angleDelta(m.angle, want); + const arc = (def.turretArcDeg * Math.PI) / 180; + if (Math.abs(off) <= arc) ang = want; + } + } + spawnProjectile(state, ship, { + type: def.projectileType ?? 'shot', + x: m.x, y: m.y, angle: ang, + vx: Math.cos(ang) * def.speed + (def.addShipVelocity ? ship.vx : 0), + vy: Math.sin(ang) * def.speed + (def.addShipVelocity ? ship.vy : 0), + damage: def.damage, hp: def.hp ?? 1, life: def.lifeFrames, + radius: def.radius ?? 24, + }); + } + if (def.recoil) { + const f = facingToRad(quantizeFacing(ship.facing)); + ship.vx -= Math.cos(f) * def.recoil; + ship.vy -= Math.sin(f) * def.recoil; + } + }, + }, + + // Seeking shot. Extra params: trackWait (frames between course corrections). + homing: { + fire(state, ship, def) { + const offsets = def.offsets ?? [[def.muzzleDist ?? 12, 0]]; + for (const [fwd, side] of offsets) { + const m = muzzle(ship, fwd * 4, side * 4); + launchHomingMissile(state, ship, def, m.x, m.y, m.angle); + } + }, + }, + + // Instant hit-scan. Params: range (display px), damage, autoAim? (nearest + // target in range regardless of facing), color?. + beam: { + fire(state, ship, def) { + const range = def.range * 4; + if (def.autoAim) { + const enemy = enemyOf(state, ship); + let best = null; + let bestD2 = range * range; + const candidates = []; + if (enemy.alive && !enemy.fx.phasing) candidates.push(enemy); + for (const p of state.projectiles) { + if (p.alive && p.owner !== ship.side && p.collidesShots) candidates.push(p); + } + for (const obj of candidates) { + const d2 = tdist2(ship.x, ship.y, obj.x, obj.y); + if (d2 < bestD2) { bestD2 = d2; best = obj; } + } + if (!best) { + const f = facingToRad(quantizeFacing(ship.facing)); + hitscan(state, ship, f, range, def.damage, def.color); + return; + } + hitscan(state, ship, bearingTo(ship, best), range, def.damage, def.color); + return; + } + hitscan(state, ship, facingToRad(quantizeFacing(ship.facing)), range, def.damage, def.color); + }, + }, + + // Umgah antimatter cone: a stationary damage zone held in front of the ship, + // respawned every frame while firing (CONE_LIFE 1 in UQM). Destroys shots. + // Params: damage, radius, muzzleDist (display px). + cone: { + fire(state, ship, def) { + const m = muzzle(ship, (def.muzzleDist ?? 32) * 4, 0); + spawnProjectile(state, ship, { + type: 'cone', + x: m.x, y: m.y, angle: m.angle, + vx: ship.vx, vy: ship.vy, + damage: def.damage ?? 1, hp: def.hp ?? 100, life: def.lifeFrames ?? 2, + radius: def.radius ?? 120, + }); + }, + }, + + // Melnorme pump-up blaster: first press starts a charge; holding pumps it a + // level every levelFrames (spending energyCost again); releasing fires. + // Params: speed, lifeFrames, damagePerLevel, maxLevel, levelFrames. + chargeShot: { + canFire(state, ship) { return !ship.fx.pump; }, + fire(state, ship, def) { + ship.fx.pump = { level: 0, frames: 0 }; + }, + tick(state, ship, def) { + const pump = ship.fx.pump; + if (!pump) return; + if (ship.input.fire) { + pump.frames += 1; + if (pump.frames >= (def.levelFrames ?? 72) && pump.level < (def.maxLevel ?? 4) + && ship.energy >= def.energyCost) { + ship.energy -= def.energyCost; + pump.level += 1; + pump.frames = 0; + emit(state, { t: 'pump', side: ship.side, level: pump.level }); + } + return; + } + // Released: let it fly. + const m = muzzle(ship, (def.muzzleDist ?? 24) * 4, 0); + spawnProjectile(state, ship, { + type: 'pumpshot', + x: m.x, y: m.y, angle: m.angle, + speed: def.speed, + damage: (def.damagePerLevel ?? 2) * (pump.level + 1), + hp: pump.level + 1, + life: def.lifeFrames ?? 10, + radius: 20 + pump.level * 14, + }); + emit(state, { t: 'fire', side: ship.side, weapon: 'chargeShot', sfx: def.sfxClass }); + ship.fx.pump = null; + }, + }, + + // Chenjesu photon crystal: flies while the button is held; releasing the + // button shatters every live shard into a ring of fragments. + // Params: speed, lifeFrames, damage, hp, fragment{count,speed,lifeFrames,damage}. + crystalShard: { + fire(state, ship, def) { + const m = muzzle(ship, (def.muzzleDist ?? 12) * 4, 0); + spawnProjectile(state, ship, { + type: 'shard', + x: m.x, y: m.y, angle: m.angle, + speed: def.speed, + damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames, + radius: def.radius ?? 30, + }); + }, + tick(state, ship, def) { + if (ship.input.fire) return; + const frag = def.fragment ?? {}; + for (const p of state.projectiles) { + if (!p.alive || p.owner !== ship.side || p.type !== 'shard') continue; + p.alive = false; + const n = frag.count ?? 8; + for (let i = 0; i < n; i += 1) { + const a = (i / n) * Math.PI * 2; + spawnProjectile(state, ship, { + type: 'fragment', + x: p.x, y: p.y, angle: a, + speed: frag.speed ?? 64, + damage: frag.damage ?? 2, hp: 1, life: frag.lifeFrames ?? 10, + radius: 16, + }); + } + emit(state, { t: 'shotHit', x: p.x, y: p.y, kind: 'shatter' }); + } + }, + }, + + // Kohr-Ah spinning blades: launched forward, then steered toward wherever + // the Marauder points while the button is held. + guidedBlades: { + fire(state, ship, def) { + const m = muzzle(ship, (def.muzzleDist ?? 9) * 4, 0); + spawnProjectile(state, ship, { + type: 'blade', + x: m.x, y: m.y, angle: m.angle, + speed: def.speed, + damage: def.damage, hp: def.hp ?? 10, life: def.lifeFrames, + radius: def.radius ?? 30, + behavior(st, p) { + const owner = st.ships[p.owner]; + if (!owner.alive || !owner.input.fire) return; + steerProjectile(p, facingToRad(quantizeFacing(owner.facing)), FACING_STEP / 2, def.speed); + }, + }); + }, + }, + + // Slylandro lightning: a hit-scan bolt of random length. + // Params: minRange/maxRange (display px), damage. + lightning: { + fire(state, ship, def) { + const range = (def.minRange ?? 32) + state.rng() * ((def.maxRange ?? 160) - (def.minRange ?? 32)); + hitscan(state, ship, facingToRad(quantizeFacing(ship.facing)), range * 4, def.damage, 'lightning'); + }, + }, +}; + +// --------------------------------------------------------------------------- +// SPECIAL ability types. + +export const SPECIALS = { + + // Earthling Cruiser: point-defense laser. Zaps up to params.maxTargets + // enemy projectiles in range; hits the enemy ship for the same damage if it + // is the only thing in reach. Params: range (display px), damage, maxTargets. + pointDefense: { + activate(state, ship, def) { + const range = (def.params?.range ?? 100) * 4; + const damage = def.params?.damage ?? 1; + const maxTargets = def.params?.maxTargets ?? 4; + const inRange = []; + for (const p of state.projectiles) { + if (!p.alive || p.owner === ship.side || !p.collidesShots) continue; + const d = tdist(ship.x, ship.y, p.x, p.y); + if (d <= range) inRange.push({ obj: p, dist: d }); + } + inRange.sort((a, b) => a.dist - b.dist); + let zapped = 0; + for (const t of inRange.slice(0, maxTargets)) { + applyBeamHit(state, ship, t, damage); + emit(state, { t: 'pdZap', x1: ship.x, y1: ship.y, x2: t.obj.x, y2: t.obj.y, side: ship.side }); + zapped += 1; + } + if (zapped === 0) { + const enemy = enemyOf(state, ship); + if (enemy.alive && !enemy.fx.phasing + && tdist(ship.x, ship.y, enemy.x, enemy.y) <= range + enemy.def.radius) { + damageShip(state, enemy, damage, 'beam'); + emit(state, { t: 'pdZap', x1: ship.x, y1: ship.y, x2: enemy.x, y2: enemy.y, side: ship.side }); + zapped += 1; + } + } + return zapped > 0 ? true : false; // no cost when nothing in reach + }, + }, + + // Shofixti Scout: Glory Device. Self-destruct with radius falloff damage + // (UQM: MAX_DESTRUCTION * (range - d) / range + 1 within range). + // Params: range (display px), maxDamage. + glory: { + activate(state, ship, def) { + const range = (def.params?.range ?? 180) * 4; + const maxDamage = def.params?.maxDamage ?? 18; + emit(state, { t: 'glory', x: ship.x, y: ship.y, range }); + const enemy = enemyOf(state, ship); + if (enemy.alive) { + const d = tdist(ship.x, ship.y, enemy.x, enemy.y); + if (d < range) { + const dmg = Math.floor((maxDamage * (range - d)) / range) + 1; + damageShip(state, enemy, dmg, 'glory'); + } + } + for (const p of state.projectiles) { + if (p.alive && tdist(ship.x, ship.y, p.x, p.y) < range) p.alive = false; + } + for (const ast of state.asteroids) { + if (ast.alive && tdist(ship.x, ship.y, ast.x, ast.y) < range) { + destroyAsteroid(state, ast, ship.side); + } + } + killShip(state, ship, 'glory'); + return true; + }, + }, + + // Ur-Quan Dreadnought: autonomous fighters. Each launch costs 1 crew (plus + // energy); fighters fly out, harass the enemy with a short-range laser, then + // return to dock (restoring the crew member). Params: speed, lifeFrames, + // outFrames, laserRange (display px), laserDamage, weaponWait, hp. + fighters: { + canFire(state, ship) { + return ship.crew > 1; // never launch the last crew member + }, + activate(state, ship, def) { + const p = def.params ?? {}; + ship.crew -= 1; + const m = muzzle(ship, -ship.def.radius, 0); // launch from the stern + const speed = p.speed ?? 32; + spawnUnit(state, ship, { + type: 'fighter', + x: m.x, y: m.y, + angle: m.angle + Math.PI, + speed, + damage: 0, hp: p.hp ?? 1, + life: p.lifeFrames ?? 400, + radius: p.radius ?? 36, + data: { + speed, + outFrames: p.outFrames ?? 125, + laserRange: (p.laserRange ?? 72) * 4, + laserDamage: p.laserDamage ?? 1, + weaponWait: p.weaponWait ?? 8, + cooldown: 0, + age: 0, + }, + behavior: fighterBrain, + }); + return true; + }, + }, + + // Ilwrath Avenger: cloaking device. Toggles on; firing the primary (or + // re-triggering the special) drops it. + cloak: { + activate(state, ship) { + ship.fx.cloaked = !ship.fx.cloaked; + emit(state, { t: 'cloak', side: ship.side, on: ship.fx.cloaked }); + return true; + }, + tick(state, ship) { + if (ship.fx.cloaked && ship.input.fire) { + ship.fx.cloaked = false; + emit(state, { t: 'cloak', side: ship.side, on: false }); + } + }, + }, + + // Spathi Eluder: B.U.T.T. — a homing missile launched backwards. Params + // mirror the homing primary (speed, damage, lifeFrames, trackWait, hp). + rearHoming: { + activate(state, ship, def) { + const p = def.params ?? {}; + const f = facingToRad(quantizeFacing(ship.facing)) + Math.PI; + const dist = (p.muzzleDist ?? 20) * 4; + launchHomingMissile( + state, ship, + { + speed: p.speed ?? 32, damage: p.damage ?? 2, lifeFrames: p.lifeFrames ?? 30, + trackWait: p.trackWait ?? 1, hp: p.hp ?? 1, projectileType: 'butt', + }, + wrap(ship.x + Math.cos(f) * dist, ARENA_W), + wrap(ship.y + Math.sin(f) * dist, ARENA_H), + f, + ); + return true; + }, + }, + + // Androsynth Guardian: Blazer form. Toggles into a ramming comet — fast, + // constant speed, can't shoot, drains energy (blazer degeneration); reverts + // when energy empties. Params: thrust, ramDamage. + blazer: { + activate(state, ship) { + ship.fx.blazer = !ship.fx.blazer; + ship.fx.energyRegenOverride = ship.fx.blazer ? -1 : undefined; + emit(state, { t: 'blazer', side: ship.side, on: ship.fx.blazer }); + return true; + }, + preprocess(state, ship) { + if (!ship.fx.blazer) return; + if (ship.energy <= 0) { + ship.fx.blazer = false; + ship.fx.energyRegenOverride = undefined; + emit(state, { t: 'blazer', side: ship.side, on: false }); + return; + } + const p = ship.def.special.params ?? {}; + ship.moveOverride = { + ...(ship.moveOverride ?? {}), + autoThrust: true, setVelocity: true, noFire: true, + maxThrust: p.thrust ?? 60, thrustWait: 0, + }; + }, + ramDamage(state, attacker) { + return attacker.fx.blazer ? (attacker.def.special.params?.ramDamage ?? 3) : 1; + }, + }, + + // Arilou Skiff: hyperspace shunt — random relocation, keeps velocity. + teleport: { + activate(state, ship) { + for (let tries = 0; tries < 12; tries += 1) { + const x = state.rng() * ARENA_W; + const y = state.rng() * ARENA_H; + if (tdist(x, y, state.planet.x, state.planet.y) < state.planet.radius + 500) continue; + emit(state, { t: 'teleport', side: ship.side, fromX: ship.x, fromY: ship.y, toX: x, toY: y }); + ship.x = x; ship.y = y; ship.px = x; ship.py = y; + return true; + } + return false; + }, + }, + + // Chenjesu D.O.G.I.: a spawned drone that chases the enemy and bites off + // energy on contact. Params: speed, hp, drain, maxUnits, radius. + dogi: { + canFire(state, ship, def) { + return unitsOf(state, ship.side, 'dogi').length < (ship.def.special.params?.maxUnits ?? 4); + }, + activate(state, ship, def) { + const p = def.params ?? {}; + const m = muzzle(ship, -ship.def.radius, 0); + const speed = p.speed ?? 32; + spawnUnit(state, ship, { + type: 'dogi', + x: m.x, y: m.y, angle: m.angle + Math.PI, + speed, + damage: 0, hp: p.hp ?? 3, life: 100000, + radius: p.radius ?? 48, + data: { speed, drain: p.drain ?? 10, cooldown: 0 }, + behavior(st, u) { + const enemy = st.ships[1 - u.owner]; + if (!enemy.alive) return; + if (u.data.cooldown > 0) u.data.cooldown -= 1; + steerProjectile(u, bearingTo(u, enemy), FACING_STEP / 2, u.data.speed); + if (u.data.cooldown === 0 && !enemy.fx.phasing + && tdist(u.x, u.y, enemy.x, enemy.y) < enemy.def.radius + u.radius) { + enemy.energy = Math.max(0, enemy.energy - u.data.drain); + u.data.cooldown = 24; + // Bounce off after the bite. + u.facing += Math.PI; + u.vx = Math.cos(u.facing) * u.data.speed; + u.vy = Math.sin(u.facing) * u.data.speed; + emit(state, { t: 'dogiDrain', x: u.x, y: u.y, side: u.owner }); + } + }, + }); + return true; + }, + }, + + // Chmmr Avatar: tractor beam — hold to reel the enemy in. + // Params: pull (units/frame^2), range (display px). + tractor: { + activate(state, ship, def) { + const enemy = enemyOf(state, ship); + if (!enemy.alive) return false; + const p = def.params ?? {}; + const range = (p.range ?? 1000) * 4; + if (tdist(ship.x, ship.y, enemy.x, enemy.y) > range) return false; + const b = bearingTo(enemy, ship); + const pull = p.pull ?? 6; + enemy.vx += Math.cos(b) * pull; + enemy.vy += Math.sin(b) * pull; + emit(state, { + t: 'pdZap', side: ship.side, x1: ship.x, y1: ship.y, x2: enemy.x, y2: enemy.y, + }); + return true; + }, + }, + + // Druuge Mauler: crew furnace — shove a crewman in the engine, refill the + // battery completely. + furnace: { + activate(state, ship) { + if (ship.crew <= 1) return false; + if (ship.energy >= ship.def.stats.maxEnergy) return false; + ship.crew -= 1; + ship.energy = ship.def.stats.maxEnergy; + emit(state, { t: 'furnace', side: ship.side, x: ship.x, y: ship.y }); + return true; + }, + }, + + // Kohr-Ah F.R.I.E.D.: a ring of fire that burns off incoming ordnance and + // scorches a too-close enemy. Params: range (world), frames, damage, + // damageEvery. + fireRing: { + activate(state, ship, def) { + ship.fx.friedFrames = def.params?.frames ?? 32; + emit(state, { t: 'fried', side: ship.side, x: ship.x, y: ship.y, range: def.params?.range ?? 520 }); + return true; + }, + tick(state, ship, def) { + if (!(ship.fx.friedFrames > 0)) return; + ship.fx.friedFrames -= 1; + const p = def.params ?? {}; + const range = p.range ?? 520; + for (const pr of state.projectiles) { + if (pr.alive && pr.owner !== ship.side && pr.collidesShots + && tdist(ship.x, ship.y, pr.x, pr.y) < range) { + pr.alive = false; + emit(state, { t: 'shotHit', x: pr.x, y: pr.y, kind: 'fried' }); + } + } + const enemy = enemyOf(state, ship); + if (enemy.alive && ship.fx.friedFrames % (p.damageEvery ?? 8) === 0 + && tdist(ship.x, ship.y, enemy.x, enemy.y) < range + enemy.def.radius) { + damageShip(state, enemy, p.damage ?? 1, 'fried'); + } + }, + }, + + // Melnorme confusion pulse: a fat, harmless missile that scrambles the + // enemy's steering on impact. Params: speed, lifeFrames, confuseFrames. + confuse: { + activate(state, ship, def) { + const p = def.params ?? {}; + const m = muzzle(ship, (p.muzzleDist ?? 16) * 4, 0); + spawnProjectile(state, ship, { + type: 'confuse', + x: m.x, y: m.y, angle: m.angle, + speed: p.speed ?? 120, + damage: 0, hp: p.hp ?? 200, life: p.lifeFrames ?? 20, + radius: 30, + data: { + onHitShip(st, pr, target) { + target.fx.confusedFrames = p.confuseFrames ?? 96; + emit(st, { t: 'confused', side: target.side }); + }, + }, + }); + return true; + }, + }, + + // Mmrnmhrm Transform: swap between the ship's JSON `forms` (stat + primary + // overrides). Costs the battery, as in UQM. + transform: { + activate(state, ship) { + const forms = ship.def.forms ?? []; + if (forms.length === 0) return false; + const next = ship.fx.formIndex ? 0 : 1; + ship.fx.formIndex = next; + const form = next === 0 ? null : forms[next - 1]; + ship.fx.formStats = form?.stats ?? null; + ship.fx.formPrimary = form?.primary ?? null; + ship.fx.pump = null; + emit(state, { t: 'transform', side: ship.side, form: next }); + return true; + }, + }, + + // Mycon Podship: regenerate crew from the whole battery. + // Params: crew (regeneration amount). + regenerate: { + canFire(state, ship) { return ship.crew < ship.def.stats.maxCrew; }, + activate(state, ship, def) { + gainCrew(state, ship, def.params?.crew ?? 4); + emit(state, { t: 'regenerate', side: ship.side, x: ship.x, y: ship.y }); + return true; + }, + }, + + // Orz space marines: crew sent to chew through the enemy hull. + // Params: speed, hp, maxUnits, killEvery (frames), maxKills. + marines: { + canFire(state, ship, def) { + return ship.crew > 1 + && unitsOf(state, ship.side, 'marine').length < (ship.def.special.params?.maxUnits ?? 8); + }, + activate(state, ship, def) { + const p = def.params ?? {}; + ship.crew -= 1; + const m = muzzle(ship, -ship.def.radius, 0); + const speed = p.speed ?? 24; + spawnUnit(state, ship, { + type: 'marine', + x: m.x, y: m.y, angle: m.angle + Math.PI, + speed, + damage: 0, hp: p.hp ?? 3, life: 100000, + radius: p.radius ?? 30, + data: { speed, attached: false, timer: 0, kills: 0 }, + behavior(st, u) { + const enemy = st.ships[1 - u.owner]; + if (!enemy.alive) { u.alive = false; return; } + if (u.data.attached) { + u.x = enemy.x; u.y = enemy.y; u.px = enemy.px; u.py = enemy.py; + u.vx = enemy.vx; u.vy = enemy.vy; + u.collidesShots = false; // inside the hull now + u.data.timer += 1; + if (u.data.timer >= (ship.def.special.params?.killEvery ?? 36)) { + u.data.timer = 0; + damageShip(st, enemy, 1, 'marine'); + u.data.kills += 1; + if (u.data.kills >= (ship.def.special.params?.maxKills ?? 4)) u.alive = false; + } + return; + } + steerProjectile(u, bearingTo(u, enemy), FACING_STEP / 2, u.data.speed); + if (!enemy.fx.phasing && tdist(u.x, u.y, enemy.x, enemy.y) < enemy.def.radius + u.radius) { + u.data.attached = true; + emit(st, { t: 'marineBoard', side: u.owner, x: u.x, y: u.y }); + } + }, + }); + return true; + }, + }, + + // Pkunk Fury: cathartic insults restore a little energy. + // Params: energy. + taunt: { + canFire(state, ship) { return ship.energy < ship.def.stats.maxEnergy; }, + activate(state, ship, def) { + gainEnergy(state, ship, def.params?.energy ?? 2); + emit(state, { t: 'taunt', side: ship.side }); + return true; + }, + }, + + // Slylandro Probe: consume a nearby asteroid for energy. + // Params: range (display px), energy. + harvest: { + activate(state, ship, def) { + const p = def.params ?? {}; + const range = (p.range ?? 78) * 4; + for (const ast of state.asteroids) { + if (ast.alive && tdist(ship.x, ship.y, ast.x, ast.y) < range + ast.radius) { + // bySide null: the passive contact-harvest bonus must not also fire. + destroyAsteroid(state, ast, null); + gainEnergy(state, ship, p.energy ?? 8); + emit(state, { t: 'harvest', side: ship.side, x: ast.x, y: ast.y }); + return true; + } + } + return false; + }, + }, + + // Supox Blade: omni-thrust — while held, the engine burns backwards. + omniThrust: { + activate(state, ship) { + if (!ship.input.thrust) return false; // only costs energy while burning + ship.moveOverride = { ...(ship.moveOverride ?? {}), reverseThrust: true }; + return true; + }, + }, + + // Syreen Song: lure a slice of the enemy crew overboard as floating pods + // either ship can scoop up. Params: range (display px), maxCrew, fraction. + song: { + activate(state, ship, def) { + const enemy = enemyOf(state, ship); + const p = def.params ?? {}; + if (!enemy.alive || enemy.crew <= 1) return false; + if (tdist(ship.x, ship.y, enemy.x, enemy.y) > (p.range ?? 208) * 4) return false; + const n = Math.min( + p.maxCrew ?? 8, + Math.max(1, Math.floor(enemy.crew * (p.fraction ?? 0.25))), + enemy.crew - 1, + ); + enemy.crew -= n; + emit(state, { t: 'song', side: ship.side, count: n, x: enemy.x, y: enemy.y }); + for (let i = 0; i < n; i += 1) { + const a = state.rng() * Math.PI * 2; + spawnUnit(state, ship, { + type: 'crew', + x: wrap(enemy.x + Math.cos(a) * (enemy.def.radius + 140), ARENA_W), + y: wrap(enemy.y + Math.sin(a) * (enemy.def.radius + 140), ARENA_H), + angle: a, + speed: 6 + state.rng() * 8, + damage: 0, hp: 1, life: 24 * 30, + radius: 22, + data: { age: 0 }, + behavior(st, u) { + // A short grace period so the victim can't instantly re-scoop them. + u.data.age += 1; + if (u.data.age < 24) return; + for (const s of st.ships) { + if (!s.alive || s.fx.phasing) continue; + if (tdist(u.x, u.y, s.x, s.y) < s.def.radius + u.radius + 20) { + gainCrew(st, s, 1); + u.alive = false; + emit(st, { t: 'crewRescued', side: s.side, x: u.x, y: u.y }); + return; + } + } + }, + }); + } + return true; + }, + }, + + // Thraddash Torch: afterburner — rockets forward leaving burning napalm. + // Params: thrust, thrustIncrement, napalm{lifeFrames,damage,radius}. + afterburner: { + activate(state, ship, def) { + const p = def.params ?? {}; + ship.moveOverride = { + ...(ship.moveOverride ?? {}), + autoThrust: true, thrustWait: 0, + maxThrust: p.thrust ?? 64, thrustIncrement: p.thrustIncrement ?? 12, + }; + ship.fx.burnerTick = (ship.fx.burnerTick ?? 0) + 1; + if (ship.fx.burnerTick % 2 === 0) { + const n = p.napalm ?? {}; + const m = muzzle(ship, -ship.def.radius - 20, 0); + spawnProjectile(state, ship, { + type: 'napalm', + x: m.x, y: m.y, angle: m.angle, + vx: 0, vy: 0, + damage: n.damage ?? 2, hp: 1, life: n.lifeFrames ?? 48, + radius: n.radius ?? 60, + collidesShots: false, + }); + } + return true; + }, + }, + + // Umgah Drone: retro-propulsion — instant backward hop. + // Params: dist (world units per hop). + retroHop: { + activate(state, ship, def) { + const f = facingToRad(quantizeFacing(ship.facing)); + const d = def.params?.dist ?? 160; + ship.x = wrap(ship.x - Math.cos(f) * d, ARENA_W); + ship.y = wrap(ship.y - Math.sin(f) * d, ARENA_H); + emit(state, { t: 'retro', side: ship.side, x: ship.x, y: ship.y }); + return true; + }, + }, + + // Utwig Jugger: absorption shield — while raised, damage feeds the battery. + // Params: frames (shield life per press). + absorbShield: { + activate(state, ship, def) { + ship.fx.absorbFrames = def.params?.frames ?? 12; + emit(state, { t: 'shield', side: ship.side, on: true }); + return true; + }, + tick(state, ship) { + if (ship.fx.absorbFrames > 0) ship.fx.absorbFrames -= 1; + }, + absorbDamage(state, ship, dmg) { + if (!(ship.fx.absorbFrames > 0)) return dmg; + gainEnergy(state, ship, dmg); + emit(state, { t: 'absorbed', side: ship.side, amount: dmg }); + return 0; + }, + }, + + // VUX limpets: slow homing suckers that gum up the enemy's helm (each one + // permanently worsens turning/thrust). Params: speed, lifeFrames, trackWait. + limpets: { + activate(state, ship, def) { + const p = def.params ?? {}; + const f = facingToRad(quantizeFacing(ship.facing)) + Math.PI; + launchHomingMissile( + state, ship, + { + speed: p.speed ?? 25, damage: 0, lifeFrames: p.lifeFrames ?? 80, + trackWait: p.trackWait ?? 0, hp: 1, projectileType: 'limpet', + data: { + onHitShip(st, pr, target) { + target.fx.limpets = (target.fx.limpets ?? 0) + 1; + emit(st, { t: 'limpet', side: target.side, count: target.fx.limpets }); + }, + }, + }, + wrap(ship.x + Math.cos(f) * 40, ARENA_W), + wrap(ship.y + Math.sin(f) * 40, ARENA_H), + f, + ); + return true; + }, + }, + + // Yehat Terminator: force shield — total immunity while charged. + // Params: frames (shield life per press). + forceShield: { + activate(state, ship, def) { + ship.fx.shieldFrames = def.params?.frames ?? 10; + emit(state, { t: 'shield', side: ship.side, on: true }); + return true; + }, + tick(state, ship) { + if (ship.fx.shieldFrames > 0) ship.fx.shieldFrames -= 1; + }, + absorbDamage(state, ship, dmg) { + if (!(ship.fx.shieldFrames > 0)) return dmg; + emit(state, { t: 'absorbed', side: ship.side, amount: dmg }); + return 0; + }, + }, + + // Zoq-Fot-Pik tongue: a very short-range, very heavy lick. + // Params: range (display px), damage. + tongue: { + activate(state, ship, def) { + const p = def.params ?? {}; + const hit = hitscan( + state, ship, + facingToRad(quantizeFacing(ship.facing)), + (p.range ?? 35) * 4 + ship.def.radius, + p.damage ?? 12, + 'tongue', + ); + return hit ? true : false; // free lick when it misses + }, + }, +}; + +function fighterBrain(state, p) { + const d = p.data; + d.age += 1; + if (d.cooldown > 0) d.cooldown -= 1; + const mother = state.ships[p.owner]; + const enemy = state.ships[1 - p.owner]; + const returning = d.age > d.outFrames || !enemy.alive || p.life < d.outFrames; + const target = returning ? mother : enemy; + if (!target || !target.alive) return; + steerProjectile(p, bearingTo(p, target), FACING_STEP / 2, d.speed); + if (returning) { + if (mother.alive && tdist(p.x, p.y, mother.x, mother.y) < mother.def.radius + 80) { + p.alive = false; + gainCrew(state, mother, 1); + emit(state, { t: 'fighterDock', side: p.owner, x: p.x, y: p.y }); + } + return; + } + if (!enemy.fx.cloaked && !enemy.fx.phasing && d.cooldown === 0 + && tdist(p.x, p.y, enemy.x, enemy.y) < d.laserRange + enemy.def.radius) { + d.cooldown = d.weaponWait + 1; + damageShip(state, enemy, d.laserDamage, 'beam'); + emit(state, { t: 'pdZap', x1: p.x, y1: p.y, x2: enemy.x, y2: enemy.y, side: p.owner }); + } +} + +// --------------------------------------------------------------------------- +// PASSIVE abilities (always-on ship traits). + +export const PASSIVES = { + + // Pkunk Fury: fifty-fifty phoenix resurrection with full crew and battery. + resurrect: { + onDeath(state, ship) { + if (state.rng() >= 0.5) return false; + ship.crew = ship.def.stats.maxCrew; + ship.energy = ship.def.stats.maxEnergy; + return true; + }, + }, + + // Arilou Skiff: inertialess drive — stops dead without thrust, ignores + // gravity. + inertialess: { + init(state, ship) { ship.fx.noGravity = true; }, + preprocess(state, ship) { + if (!ship.input.thrust) { ship.vx = 0; ship.vy = 0; } + }, + }, + + // Slylandro Probe: gravity-immune constant-velocity drive — always moving + // at full speed along its facing. + probeDrive: { + init(state, ship) { ship.fx.noGravity = true; }, + preprocess(state, ship) { + const f = facingToRad(quantizeFacing(ship.facing)); + const v = shipStats(ship).maxThrust; + ship.vx = Math.cos(f) * v; + ship.vy = Math.sin(f) * v; + ship.moveOverride = { ...(ship.moveOverride ?? {}), noThrust: true }; + }, + onAsteroidKill(state, ship) { + gainEnergy(state, ship, ship.def.special?.params?.energy ?? 8); + }, + }, + + // Chmmr Avatar ZapSats: three orbiting satellites that zap whatever strays + // close (enemy shots first, then the enemy ship). + zapsats: { + init(state, ship) { + const cfg = ship.def.zapsats ?? {}; + const n = cfg.count ?? 3; + for (let i = 0; i < n; i += 1) { + spawnUnit(state, ship, { + type: 'zapsat', + x: ship.x, y: ship.y, angle: 0, speed: 0, + damage: 0, hp: cfg.hp ?? 10, life: 100000, + radius: 26, + data: { + orbit: (i / n) * Math.PI * 2, + orbitR: cfg.orbitR ?? ship.def.radius + 190, + zapRange: (cfg.zapRange ?? 64) * 4, + zapWait: cfg.zapWait ?? 2, + cooldown: 0, + }, + behavior(st, u) { + const mother = st.ships[u.owner]; + if (!mother.alive) { u.alive = false; return; } + u.data.orbit += 0.045; + const nx = wrap(mother.x + Math.cos(u.data.orbit) * u.data.orbitR, ARENA_W); + const ny = wrap(mother.y + Math.sin(u.data.orbit) * u.data.orbitR, ARENA_H); + u.px = u.x; u.py = u.y; + u.x = nx; u.y = ny; u.vx = 0; u.vy = 0; + if (u.data.cooldown > 0) { u.data.cooldown -= 1; return; } + for (const pr of st.projectiles) { + if (pr.alive && pr.owner !== u.owner && pr.collidesShots && pr.kind !== 'unit' + && tdist(u.x, u.y, pr.x, pr.y) < u.data.zapRange) { + pr.hp -= 1; + if (pr.hp <= 0) pr.alive = false; + u.data.cooldown = u.data.zapWait + 1; + emit(st, { t: 'pdZap', x1: u.x, y1: u.y, x2: pr.x, y2: pr.y, side: u.owner }); + return; + } + } + const enemy = st.ships[1 - u.owner]; + if (enemy.alive && !enemy.fx.phasing + && tdist(u.x, u.y, enemy.x, enemy.y) < u.data.zapRange + enemy.def.radius) { + damageShip(st, enemy, 1, 'beam'); + u.data.cooldown = u.data.zapWait + 1; + emit(st, { t: 'pdZap', x1: u.x, y1: u.y, x2: enemy.x, y2: enemy.y, side: u.owner }); + } + }, + }); + } + }, + }, + + // VUX Intruder: warps in right on top of its prey at the start of a battle. + warpIn: { + preprocess(state, ship) { + if (ship.fx.warped || state.frame > 2) { ship.fx.warped = true; return; } + ship.fx.warped = true; + const enemy = enemyOf(state, ship); + const a = state.rng() * Math.PI * 2; + const d = 900; + ship.x = wrap(enemy.x + Math.cos(a) * d, ARENA_W); + ship.y = wrap(enemy.y + Math.sin(a) * d, ARENA_H); + ship.px = ship.x; ship.py = ship.y; + ship.facing = bearingTo(ship, enemy); + emit(state, { t: 'teleport', side: ship.side, toX: ship.x, toY: ship.y }); + }, + }, +}; diff --git a/src/main.js b/src/main.js index cee50df..cfa7cec 100644 --- a/src/main.js +++ b/src/main.js @@ -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, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 39fda0e..2a49292 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -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 = { diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index 7546030..43341de 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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'); diff --git a/tools/fetchStarControlAssets.js b/tools/fetchStarControlAssets.js new file mode 100644 index 0000000..41f8b8c --- /dev/null +++ b/tools/fetchStarControlAssets.js @@ -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//, the 16 `-big-000..015.png` rotation +// frames are downloaded, montaged into a 16x1 spritesheet at +// assets/images/starcontrol/sc-ship-.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 +// `-big-000.png` .. `-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'); diff --git a/tools/verifyStarControl.js b/tools/verifyStarControl.js new file mode 100644 index 0000000..839b00f --- /dev/null +++ b/tools/verifyStarControl.js @@ -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);