Update Agent Arcade canvas (#4482)

Co-authored-by: Aaron Powell <me@aaron-powell.com>
This commit is contained in:
Dan Wahlin
2026-10-07 17:24:23 +11:00
committed by GitHub
co-authored by Aaron Powell
parent 581d8099e4
commit 3a685010a7
65 changed files with 1615 additions and 1354 deletions
+16 -17
View File
@@ -1,6 +1,6 @@
# Agent Arcade Canvas
A GitHub Copilot canvas that opens a retro arcade in the side panel. It serves the built Agent Arcade Phaser frontend and lets either the user or the agent switch between five mini-games.
A GitHub Copilot canvas that opens a retro arcade in the side panel. It serves the built Agent Arcade Phaser frontend and lets either the user or the agent switch between six mini-games.
## Games
@@ -9,36 +9,24 @@ A GitHub Copilot canvas that opens a retro arcade in the side panel. It serves t
- **Galaxy Blaster** — Galaga-style space shooter with formation enemies, attack patterns, and dual-shot power-up.
- **Ninja Runner** — Classic platformer with double jumps, power-ups, warp pipes, and enemies.
- **Planet Guardian** — Defender-style side-scrolling shooter with humanoid rescues and six enemy types.
- **Surface Defense** — Missile-defense strategy with six cities, three batteries, chain reactions, smart warheads, bombers, and hostile satellites.
## Files
- `extension.mjs` — canvas declaration, loopback game server, static asset handling, and agent actions.
- `game/` — compiled Phaser game frontend served inside the canvas.
- `assets/` — game sprites, sounds, app icon, and `preview.png` for the extensions gallery.
- `package.json` — declares the Copilot SDK dependency and ESM entry point.
- `copilot-extension.json` — Copilot extension name/version metadata.
- `canvas.json` — Awesome Copilot gallery metadata.
## Prerequisites
- **Node.js 20.19 or newer** because the Copilot SDK requires `node ^20.19.0 || >=22.12.0`.
- The GitHub Copilot app canvas / UI-extensions experiment enabled.
## Install
Drop this folder at `~/.copilot/extensions/arcade-canvas/` for user scope, or in a repository at `.github/extensions/arcade-canvas/` for project scope. Then install dependencies from inside the copied folder:
Drop this folder at `~/.copilot/extensions/arcade-canvas/` for user scope, or in a repository at `.github/extensions/arcade-canvas/` for project scope. Do not install `@github/copilot-sdk` locally. Copilot provides it through the automatic extension module resolver.
```sh
# User scope
cd ~/.copilot/extensions/arcade-canvas
# Or project scope, from the repository root
cd .github/extensions/arcade-canvas
npm install
```
Reload extensions in the GitHub Copilot app, then open the `arcade-canvas` canvas. The canvas accepts an optional `defaultGame` input with one of these keys: `cosmic-rocks`, `alien-onslaught`, `galaxy-blaster`, `ninja-runner`, or `defender`.
Reload extensions in the GitHub Copilot app, then open the `arcade-canvas` canvas. The canvas accepts an optional `defaultGame` input with one of these keys: `cosmic-rocks`, `alien-onslaught`, `galaxy-blaster`, `ninja-runner`, `defender`, or `surface-defense`.
## Agent actions
@@ -54,7 +42,16 @@ In the Agent Arcade repository, rebuild the committed canvas bundle after fronte
npm run build:canvas
```
That command builds the frontend, copies `dist/game` into `game/`, copies `dist/assets` into `assets/`, writes `assets/preview.png` for the Awesome Copilot gallery, and bundles `assets/canvas-background.webp` for the canvas-only space backdrop.
That command builds the frontend, copies `dist/game` into `game/`, copies the required assets into `assets/`, writes `assets/preview.png` for the Awesome Copilot gallery, and bundles `assets/canvas-background.webp` for the canvas-only space backdrop. The canvas package uses compressed M4A copies of WAV effects and removes unused desktop assets so it stays within the 8 MiB project-extension trust limit.
## Awesome Copilot sync
The published copy lives in `github/awesome-copilot` under `extensions/arcade-canvas/`. After rebuilding this canonical copy:
1. Replace the Awesome Copilot `extensions/arcade-canvas/` directory with this directory.
2. Update `plugins/arcade-canvas/plugin.json`, including its version and six-game description.
3. Update `plugins/arcade-canvas/README.md`.
4. Run `npm run plugin:validate`, `npm run build`, and `npm run website:data` in the Awesome Copilot repository.
## Credits
@@ -63,6 +60,8 @@ That command builds the frontend, copies `dist/game` into `game/`, copies `dist/
- Galaga-style game mechanics: [WesleyEdwards/galaga](https://github.com/WesleyEdwards/galaga) by Wesley Edwards.
- Asteroids-style game mechanics: [phaser3-typescript](https://github.com/digitsensitive/phaser3-typescript) by digitsensitive.
- Defender-style game mechanics and sound effects: [OpenDefender](https://github.com/mkinney/Opendefender) by mkinney.
- Missile-defense gameplay reference: [Basic Missile Command HTML and JavaScript Game](https://gist.github.com/straker/afc4e2a30b6df772a5f9f6ef01751d41) by straker (CC0 1.0).
- Surface Defense uses original synthesized WAV effects generated by `scripts/generate-surface-defense-audio.mjs`; no commercial-game audio is redistributed.
- Retro game sound effects: ["Retro game sound effects"](https://opengameart.org/content/retro-game-sound-effects) by Vircon32 (Carra), published at OpenGameArt under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/).
- Thanks to [John Papa](https://github.com/johnpapa) for his Alien Onslaught game PR.
- Thanks to [Shayne Boyer](https://github.com/spboyer) for the initial PR to get Agent Arcade running in the GitHub App canvas.
+2 -1
View File
@@ -3,4 +3,5 @@ https://opengameart.org/content/2d-nature-platformer-tileset-16x16
https://opengameart.org/content/retro-game-sound-effects
https://github.com/digitsensitive/phaser3-typescript
https://github.com/WesleyEdwards/galaga
https://juhosprite.itch.io/simple-platformer-16
https://juhosprite.itch.io/simple-platformer-16
https://gist.github.com/straker/afc4e2a30b6df772a5f9f6ef01751d41
Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 937 KiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

@@ -1,296 +0,0 @@
<TextureAtlas imagePath="sheet.png">
<SubTexture name="beam0.png" x="143" y="377" width="43" height="31"/>
<SubTexture name="beam1.png" x="327" y="644" width="40" height="20"/>
<SubTexture name="beam2.png" x="262" y="907" width="38" height="31"/>
<SubTexture name="beam3.png" x="396" y="384" width="29" height="29"/>
<SubTexture name="beam4.png" x="177" y="496" width="41" height="17"/>
<SubTexture name="beam5.png" x="186" y="377" width="40" height="25"/>
<SubTexture name="beam6.png" x="120" y="688" width="43" height="23"/>
<SubTexture name="beamLong1.png" x="828" y="943" width="15" height="67"/>
<SubTexture name="beamLong2.png" x="307" y="309" width="25" height="64"/>
<SubTexture name="bold_silver.png" x="810" y="837" width="19" height="30"/>
<SubTexture name="bolt_bronze.png" x="810" y="467" width="19" height="30"/>
<SubTexture name="bolt_gold.png" x="809" y="437" width="19" height="30"/>
<SubTexture name="buttonBlue.png" x="0" y="78" width="222" height="39"/>
<SubTexture name="buttonGreen.png" x="0" y="117" width="222" height="39"/>
<SubTexture name="buttonRed.png" x="0" y="0" width="222" height="39"/>
<SubTexture name="buttonYellow.png" x="0" y="39" width="222" height="39"/>
<SubTexture name="cockpitBlue_0.png" x="586" y="0" width="51" height="75"/>
<SubTexture name="cockpitBlue_1.png" x="736" y="862" width="40" height="40"/>
<SubTexture name="cockpitBlue_2.png" x="684" y="67" width="42" height="56"/>
<SubTexture name="cockpitBlue_3.png" x="336" y="384" width="60" height="61"/>
<SubTexture name="cockpitBlue_4.png" x="637" y="0" width="47" height="67"/>
<SubTexture name="cockpitBlue_5.png" x="627" y="144" width="48" height="75"/>
<SubTexture name="cockpitBlue_6.png" x="684" y="0" width="42" height="67"/>
<SubTexture name="cockpitBlue_7.png" x="737" y="542" width="41" height="71"/>
<SubTexture name="cockpitGreen_0.png" x="576" y="225" width="51" height="75"/>
<SubTexture name="cockpitGreen_1.png" x="734" y="977" width="40" height="40"/>
<SubTexture name="cockpitGreen_2.png" x="696" y="659" width="42" height="56"/>
<SubTexture name="cockpitGreen_3.png" x="346" y="234" width="60" height="61"/>
<SubTexture name="cockpitGreen_4.png" x="627" y="219" width="47" height="67"/>
<SubTexture name="cockpitGreen_5.png" x="694" y="364" width="42" height="67"/>
<SubTexture name="cockpitGreen_6.png" x="737" y="471" width="41" height="71"/>
<SubTexture name="cockpitGreen_7.png" x="602" y="525" width="48" height="75"/>
<SubTexture name="cockpitRed_0.png" x="535" y="75" width="51" height="75"/>
<SubTexture name="cockpitRed_1.png" x="351" y="982" width="40" height="40"/>
<SubTexture name="cockpitRed_2.png" x="718" y="197" width="42" height="56"/>
<SubTexture name="cockpitRed_3.png" x="520" y="661" width="60" height="61"/>
<SubTexture name="cockpitRed_4.png" x="647" y="857" width="47" height="67"/>
<SubTexture name="cockpitRed_5.png" x="605" y="707" width="48" height="75"/>
<SubTexture name="cockpitRed_6.png" x="736" y="795" width="42" height="67"/>
<SubTexture name="cockpitRed_7.png" x="736" y="329" width="41" height="71"/>
<SubTexture name="cockpitYellow_0.png" x="726" y="80" width="40" height="40"/>
<SubTexture name="cockpitYellow_1.png" x="247" y="309" width="60" height="61"/>
<SubTexture name="cockpitYellow_2.png" x="637" y="67" width="47" height="67"/>
<SubTexture name="cockpitYellow_3.png" x="607" y="782" width="48" height="75"/>
<SubTexture name="cockpitYellow_4.png" x="696" y="262" width="42" height="67"/>
<SubTexture name="cockpitYellow_5.png" x="736" y="400" width="41" height="71"/>
<SubTexture name="cockpitYellow_6.png" x="734" y="921" width="42" height="56"/>
<SubTexture name="cockpitYellow_7.png" x="600" y="375" width="51" height="75"/>
<SubTexture name="cursor.png" x="797" y="173" width="30" height="33"/>
<SubTexture name="enemyBlack1.png" x="423" y="728" width="93" height="84"/>
<SubTexture name="enemyBlack2.png" x="120" y="604" width="104" height="84"/>
<SubTexture name="enemyBlack3.png" x="144" y="156" width="103" height="84"/>
<SubTexture name="enemyBlack4.png" x="518" y="325" width="82" height="84"/>
<SubTexture name="enemyBlack5.png" x="346" y="150" width="97" height="84"/>
<SubTexture name="enemyBlue1.png" x="425" y="468" width="93" height="84"/>
<SubTexture name="enemyBlue2.png" x="143" y="293" width="104" height="84"/>
<SubTexture name="enemyBlue3.png" x="222" y="0" width="103" height="84"/>
<SubTexture name="enemyBlue4.png" x="518" y="409" width="82" height="84"/>
<SubTexture name="enemyBlue5.png" x="421" y="814" width="97" height="84"/>
<SubTexture name="enemyGreen1.png" x="425" y="552" width="93" height="84"/>
<SubTexture name="enemyGreen2.png" x="133" y="412" width="104" height="84"/>
<SubTexture name="enemyGreen3.png" x="224" y="496" width="103" height="84"/>
<SubTexture name="enemyGreen4.png" x="518" y="493" width="82" height="84"/>
<SubTexture name="enemyGreen5.png" x="408" y="907" width="97" height="84"/>
<SubTexture name="enemyRed1.png" x="425" y="384" width="93" height="84"/>
<SubTexture name="enemyRed2.png" x="120" y="520" width="104" height="84"/>
<SubTexture name="enemyRed3.png" x="224" y="580" width="103" height="84"/>
<SubTexture name="enemyRed4.png" x="520" y="577" width="82" height="84"/>
<SubTexture name="enemyRed5.png" x="423" y="644" width="97" height="84"/>
<SubTexture name="engine1.png" x="224" y="907" width="38" height="23"/>
<SubTexture name="engine2.png" x="163" y="688" width="42" height="28"/>
<SubTexture name="engine3.png" x="644" y="1002" width="27" height="22"/>
<SubTexture name="engine4.png" x="144" y="240" width="49" height="45"/>
<SubTexture name="engine5.png" x="133" y="496" width="44" height="24"/>
<SubTexture name="fire00.png" x="827" y="125" width="16" height="40"/>
<SubTexture name="fire01.png" x="828" y="206" width="14" height="31"/>
<SubTexture name="fire02.png" x="827" y="663" width="14" height="32"/>
<SubTexture name="fire03.png" x="829" y="437" width="14" height="34"/>
<SubTexture name="fire04.png" x="831" y="0" width="14" height="31"/>
<SubTexture name="fire05.png" x="834" y="299" width="14" height="31"/>
<SubTexture name="fire06.png" x="835" y="502" width="14" height="31"/>
<SubTexture name="fire07.png" x="835" y="330" width="14" height="31"/>
<SubTexture name="fire08.png" x="827" y="867" width="16" height="40"/>
<SubTexture name="fire09.png" x="811" y="663" width="16" height="40"/>
<SubTexture name="fire10.png" x="812" y="206" width="16" height="40"/>
<SubTexture name="fire11.png" x="835" y="395" width="14" height="31"/>
<SubTexture name="fire12.png" x="835" y="533" width="14" height="32"/>
<SubTexture name="fire13.png" x="835" y="361" width="14" height="34"/>
<SubTexture name="fire14.png" x="831" y="31" width="14" height="31"/>
<SubTexture name="fire15.png" x="829" y="471" width="14" height="31"/>
<SubTexture name="fire16.png" x="828" y="268" width="14" height="31"/>
<SubTexture name="fire17.png" x="828" y="237" width="14" height="31"/>
<SubTexture name="fire18.png" x="827" y="165" width="16" height="41"/>
<SubTexture name="fire19.png" x="812" y="246" width="16" height="41"/>
<SubTexture name="gun00.png" x="827" y="907" width="16" height="36"/>
<SubTexture name="gun01.png" x="810" y="867" width="17" height="33"/>
<SubTexture name="gun02.png" x="829" y="611" width="14" height="36"/>
<SubTexture name="gun03.png" x="809" y="796" width="20" height="41"/>
<SubTexture name="gun04.png" x="827" y="84" width="16" height="41"/>
<SubTexture name="gun05.png" x="423" y="0" width="21" height="41"/>
<SubTexture name="gun06.png" x="810" y="900" width="17" height="38"/>
<SubTexture name="gun07.png" x="829" y="796" width="14" height="41"/>
<SubTexture name="gun08.png" x="848" y="263" width="10" height="47"/>
<SubTexture name="gun09.png" x="809" y="611" width="20" height="52"/>
<SubTexture name="gun10.png" x="808" y="961" width="20" height="52"/>
<SubTexture name="laserBlue01.png" x="856" y="421" width="9" height="54"/>
<SubTexture name="laserBlue02.png" x="841" y="647" width="13" height="37"/>
<SubTexture name="laserBlue03.png" x="856" y="57" width="9" height="37"/>
<SubTexture name="laserBlue04.png" x="835" y="565" width="13" height="37"/>
<SubTexture name="laserBlue05.png" x="858" y="475" width="9" height="37"/>
<SubTexture name="laserBlue06.png" x="835" y="752" width="13" height="37"/>
<SubTexture name="laserBlue07.png" x="856" y="775" width="9" height="37"/>
<SubTexture name="laserBlue08.png" x="596" y="961" width="48" height="46"/>
<SubTexture name="laserBlue09.png" x="434" y="325" width="48" height="46"/>
<SubTexture name="laserBlue10.png" x="740" y="724" width="37" height="37"/>
<SubTexture name="laserBlue11.png" x="698" y="795" width="38" height="37"/>
<SubTexture name="laserBlue12.png" x="835" y="695" width="13" height="57"/>
<SubTexture name="laserBlue13.png" x="856" y="869" width="9" height="57"/>
<SubTexture name="laserBlue14.png" x="842" y="206" width="13" height="57"/>
<SubTexture name="laserBlue15.png" x="849" y="480" width="9" height="57"/>
<SubTexture name="laserBlue16.png" x="843" y="62" width="13" height="54"/>
<SubTexture name="laserGreen01.png" x="740" y="686" width="37" height="38"/>
<SubTexture name="laserGreen02.png" x="843" y="116" width="13" height="57"/>
<SubTexture name="laserGreen03.png" x="855" y="173" width="9" height="57"/>
<SubTexture name="laserGreen04.png" x="848" y="565" width="13" height="37"/>
<SubTexture name="laserGreen05.png" x="854" y="639" width="9" height="37"/>
<SubTexture name="laserGreen06.png" x="845" y="0" width="13" height="57"/>
<SubTexture name="laserGreen07.png" x="849" y="364" width="9" height="57"/>
<SubTexture name="laserGreen08.png" x="848" y="738" width="13" height="37"/>
<SubTexture name="laserGreen09.png" x="856" y="94" width="9" height="37"/>
<SubTexture name="laserGreen10.png" x="843" y="426" width="13" height="54"/>
<SubTexture name="laserGreen11.png" x="849" y="310" width="9" height="54"/>
<SubTexture name="laserGreen12.png" x="843" y="602" width="13" height="37"/>
<SubTexture name="laserGreen13.png" x="858" y="0" width="9" height="37"/>
<SubTexture name="laserGreen14.png" x="193" y="240" width="48" height="46"/>
<SubTexture name="laserGreen15.png" x="443" y="182" width="48" height="46"/>
<SubTexture name="laserGreen16.png" x="760" y="192" width="37" height="37"/>
<SubTexture name="laserRed01.png" x="858" y="230" width="9" height="54"/>
<SubTexture name="laserRed02.png" x="843" y="977" width="13" height="37"/>
<SubTexture name="laserRed03.png" x="856" y="602" width="9" height="37"/>
<SubTexture name="laserRed04.png" x="843" y="940" width="13" height="37"/>
<SubTexture name="laserRed05.png" x="856" y="983" width="9" height="37"/>
<SubTexture name="laserRed06.png" x="843" y="903" width="13" height="37"/>
<SubTexture name="laserRed07.png" x="856" y="131" width="9" height="37"/>
<SubTexture name="laserRed08.png" x="580" y="661" width="48" height="46"/>
<SubTexture name="laserRed09.png" x="602" y="600" width="48" height="46"/>
<SubTexture name="laserRed10.png" x="738" y="650" width="37" height="36"/>
<SubTexture name="laserRed11.png" x="737" y="613" width="37" height="37"/>
<SubTexture name="laserRed12.png" x="843" y="846" width="13" height="57"/>
<SubTexture name="laserRed13.png" x="856" y="812" width="9" height="57"/>
<SubTexture name="laserRed14.png" x="843" y="789" width="13" height="57"/>
<SubTexture name="laserRed15.png" x="856" y="926" width="9" height="57"/>
<SubTexture name="laserRed16.png" x="848" y="684" width="13" height="54"/>
<SubTexture name="meteorBrown_big1.png" x="224" y="664" width="101" height="84"/>
<SubTexture name="meteorBrown_big2.png" x="0" y="520" width="120" height="98"/>
<SubTexture name="meteorBrown_big3.png" x="518" y="810" width="89" height="82"/>
<SubTexture name="meteorBrown_big4.png" x="327" y="452" width="98" height="96"/>
<SubTexture name="meteorBrown_med1.png" x="651" y="447" width="43" height="43"/>
<SubTexture name="meteorBrown_med3.png" x="237" y="452" width="45" height="40"/>
<SubTexture name="meteorBrown_small1.png" x="406" y="234" width="28" height="28"/>
<SubTexture name="meteorBrown_small2.png" x="778" y="587" width="29" height="26"/>
<SubTexture name="meteorBrown_tiny1.png" x="346" y="814" width="18" height="18"/>
<SubTexture name="meteorBrown_tiny2.png" x="399" y="814" width="16" height="15"/>
<SubTexture name="meteorGrey_big1.png" x="224" y="748" width="101" height="84"/>
<SubTexture name="meteorGrey_big2.png" x="0" y="618" width="120" height="98"/>
<SubTexture name="meteorGrey_big3.png" x="516" y="728" width="89" height="82"/>
<SubTexture name="meteorGrey_big4.png" x="327" y="548" width="98" height="96"/>
<SubTexture name="meteorGrey_med1.png" x="674" y="219" width="43" height="43"/>
<SubTexture name="meteorGrey_med2.png" x="282" y="452" width="45" height="40"/>
<SubTexture name="meteorGrey_small1.png" x="406" y="262" width="28" height="28"/>
<SubTexture name="meteorGrey_small2.png" x="396" y="413" width="29" height="26"/>
<SubTexture name="meteorGrey_tiny1.png" x="364" y="814" width="18" height="18"/>
<SubTexture name="meteorGrey_tiny2.png" x="602" y="646" width="16" height="15"/>
<SubTexture name="numeral0.png" x="367" y="644" width="19" height="19"/>
<SubTexture name="numeral1.png" x="205" y="688" width="19" height="19"/>
<SubTexture name="numeral2.png" x="406" y="290" width="19" height="19"/>
<SubTexture name="numeral3.png" x="580" y="707" width="19" height="19"/>
<SubTexture name="numeral4.png" x="386" y="644" width="19" height="19"/>
<SubTexture name="numeral5.png" x="628" y="646" width="19" height="19"/>
<SubTexture name="numeral6.png" x="671" y="1002" width="19" height="19"/>
<SubTexture name="numeral7.png" x="690" y="1004" width="19" height="19"/>
<SubTexture name="numeral8.png" x="709" y="1004" width="19" height="19"/>
<SubTexture name="numeral9.png" x="491" y="215" width="19" height="19"/>
<SubTexture name="numeralX.png" x="382" y="814" width="17" height="17"/>
<SubTexture name="pill_blue.png" x="674" y="262" width="22" height="21"/>
<SubTexture name="pill_green.png" x="573" y="989" width="22" height="21"/>
<SubTexture name="pill_red.png" x="222" y="108" width="22" height="21"/>
<SubTexture name="pill_yellow.png" x="222" y="129" width="22" height="21"/>
<SubTexture name="playerLife1_blue.png" x="482" y="358" width="33" height="26"/>
<SubTexture name="playerLife1_green.png" x="535" y="150" width="33" height="26"/>
<SubTexture name="playerLife1_orange.png" x="777" y="327" width="33" height="26"/>
<SubTexture name="playerLife1_red.png" x="775" y="301" width="33" height="26"/>
<SubTexture name="playerLife2_blue.png" x="465" y="991" width="37" height="26"/>
<SubTexture name="playerLife2_green.png" x="391" y="991" width="37" height="26"/>
<SubTexture name="playerLife2_orange.png" x="428" y="991" width="37" height="26"/>
<SubTexture name="playerLife2_red.png" x="502" y="991" width="37" height="26"/>
<SubTexture name="playerLife3_blue.png" x="777" y="385" width="32" height="26"/>
<SubTexture name="playerLife3_green.png" x="778" y="469" width="32" height="26"/>
<SubTexture name="playerLife3_orange.png" x="777" y="712" width="32" height="26"/>
<SubTexture name="playerLife3_red.png" x="777" y="443" width="32" height="26"/>
<SubTexture name="playerShip1_blue.png" x="211" y="941" width="99" height="75"/>
<SubTexture name="playerShip1_damage1.png" x="112" y="941" width="99" height="75"/>
<SubTexture name="playerShip1_damage2.png" x="247" y="234" width="99" height="75"/>
<SubTexture name="playerShip1_damage3.png" x="247" y="159" width="99" height="75"/>
<SubTexture name="playerShip1_green.png" x="237" y="377" width="99" height="75"/>
<SubTexture name="playerShip1_orange.png" x="247" y="84" width="99" height="75"/>
<SubTexture name="playerShip1_red.png" x="224" y="832" width="99" height="75"/>
<SubTexture name="playerShip2_blue.png" x="112" y="791" width="112" height="75"/>
<SubTexture name="playerShip2_damage1.png" x="0" y="866" width="112" height="75"/>
<SubTexture name="playerShip2_damage2.png" x="0" y="791" width="112" height="75"/>
<SubTexture name="playerShip2_damage3.png" x="0" y="716" width="112" height="75"/>
<SubTexture name="playerShip2_green.png" x="112" y="866" width="112" height="75"/>
<SubTexture name="playerShip2_orange.png" x="112" y="716" width="112" height="75"/>
<SubTexture name="playerShip2_red.png" x="0" y="941" width="112" height="75"/>
<SubTexture name="playerShip3_blue.png" x="325" y="739" width="98" height="75"/>
<SubTexture name="playerShip3_damage1.png" x="323" y="832" width="98" height="75"/>
<SubTexture name="playerShip3_damage2.png" x="310" y="907" width="98" height="75"/>
<SubTexture name="playerShip3_damage3.png" x="325" y="664" width="98" height="75"/>
<SubTexture name="playerShip3_green.png" x="346" y="75" width="98" height="75"/>
<SubTexture name="playerShip3_orange.png" x="336" y="309" width="98" height="75"/>
<SubTexture name="playerShip3_red.png" x="325" y="0" width="98" height="75"/>
<SubTexture name="powerupBlue.png" x="696" y="329" width="34" height="33"/>
<SubTexture name="powerupBlue_bolt.png" x="539" y="989" width="34" height="33"/>
<SubTexture name="powerupBlue_shield.png" x="777" y="679" width="34" height="33"/>
<SubTexture name="powerupBlue_star.png" x="776" y="895" width="34" height="33"/>
<SubTexture name="powerupGreen.png" x="774" y="613" width="34" height="33"/>
<SubTexture name="powerupGreen_bolt.png" x="766" y="80" width="34" height="33"/>
<SubTexture name="powerupGreen_shield.png" x="776" y="862" width="34" height="33"/>
<SubTexture name="powerupGreen_star.png" x="651" y="490" width="34" height="33"/>
<SubTexture name="powerupRed.png" x="491" y="182" width="34" height="33"/>
<SubTexture name="powerupRed_bolt.png" x="775" y="646" width="34" height="33"/>
<SubTexture name="powerupRed_shield.png" x="776" y="928" width="34" height="33"/>
<SubTexture name="powerupRed_star.png" x="774" y="977" width="34" height="33"/>
<SubTexture name="powerupYellow.png" x="774" y="761" width="34" height="33"/>
<SubTexture name="powerupYellow_bolt.png" x="740" y="761" width="34" height="33"/>
<SubTexture name="powerupYellow_shield.png" x="482" y="325" width="34" height="33"/>
<SubTexture name="powerupYellow_star.png" x="607" y="857" width="34" height="33"/>
<SubTexture name="scratch1.png" x="325" y="814" width="21" height="16"/>
<SubTexture name="scratch2.png" x="423" y="41" width="21" height="16"/>
<SubTexture name="scratch3.png" x="346" y="295" width="16" height="12"/>
<SubTexture name="shield1.png" x="0" y="412" width="133" height="108"/>
<SubTexture name="shield2.png" x="0" y="293" width="143" height="119"/>
<SubTexture name="shield3.png" x="0" y="156" width="144" height="137"/>
<SubTexture name="shield_bronze.png" x="797" y="143" width="30" height="30"/>
<SubTexture name="shield_gold.png" x="797" y="113" width="30" height="30"/>
<SubTexture name="shield_silver.png" x="778" y="824" width="30" height="30"/>
<SubTexture name="speed.png" x="858" y="284" width="7" height="108"/>
<SubTexture name="star1.png" x="628" y="681" width="25" height="24"/>
<SubTexture name="star2.png" x="222" y="84" width="25" height="24"/>
<SubTexture name="star3.png" x="576" y="300" width="24" height="24"/>
<SubTexture name="star_bronze.png" x="778" y="794" width="31" height="30"/>
<SubTexture name="star_gold.png" x="778" y="557" width="31" height="30"/>
<SubTexture name="star_silver.png" x="778" y="527" width="31" height="30"/>
<SubTexture name="things_bronze.png" x="778" y="495" width="32" height="32"/>
<SubTexture name="things_gold.png" x="777" y="411" width="32" height="32"/>
<SubTexture name="things_silver.png" x="777" y="353" width="32" height="32"/>
<SubTexture name="turretBase_big.png" x="310" y="982" width="41" height="41"/>
<SubTexture name="turretBase_small.png" x="808" y="301" width="26" height="26"/>
<SubTexture name="ufoBlue.png" x="444" y="91" width="91" height="91"/>
<SubTexture name="ufoGreen.png" x="434" y="234" width="91" height="91"/>
<SubTexture name="ufoRed.png" x="444" y="0" width="91" height="91"/>
<SubTexture name="ufoYellow.png" x="505" y="898" width="91" height="91"/>
<SubTexture name="wingBlue_0.png" x="647" y="924" width="45" height="78"/>
<SubTexture name="wingBlue_1.png" x="738" y="253" width="37" height="72"/>
<SubTexture name="wingBlue_2.png" x="805" y="0" width="26" height="84"/>
<SubTexture name="wingBlue_3.png" x="600" y="450" width="51" height="75"/>
<SubTexture name="wingBlue_4.png" x="692" y="924" width="42" height="80"/>
<SubTexture name="wingBlue_5.png" x="596" y="892" width="51" height="69"/>
<SubTexture name="wingBlue_6.png" x="694" y="847" width="42" height="74"/>
<SubTexture name="wingBlue_7.png" x="675" y="134" width="43" height="83"/>
<SubTexture name="wingGreen_0.png" x="650" y="525" width="45" height="78"/>
<SubTexture name="wingGreen_1.png" x="775" y="229" width="37" height="72"/>
<SubTexture name="wingGreen_2.png" x="809" y="527" width="26" height="84"/>
<SubTexture name="wingGreen_3.png" x="535" y="0" width="51" height="75"/>
<SubTexture name="wingGreen_4.png" x="694" y="431" width="42" height="80"/>
<SubTexture name="wingGreen_5.png" x="525" y="251" width="51" height="69"/>
<SubTexture name="wingGreen_6.png" x="695" y="511" width="42" height="74"/>
<SubTexture name="wingGreen_7.png" x="655" y="764" width="43" height="83"/>
<SubTexture name="wingRed_0.png" x="809" y="712" width="26" height="84"/>
<SubTexture name="wingRed_1.png" x="768" y="0" width="37" height="72"/>
<SubTexture name="wingRed_2.png" x="600" y="300" width="51" height="75"/>
<SubTexture name="wingRed_3.png" x="698" y="715" width="42" height="80"/>
<SubTexture name="wingRed_4.png" x="586" y="75" width="51" height="69"/>
<SubTexture name="wingRed_5.png" x="718" y="123" width="42" height="74"/>
<SubTexture name="wingRed_6.png" x="653" y="681" width="43" height="83"/>
<SubTexture name="wingRed_7.png" x="651" y="286" width="45" height="78"/>
<SubTexture name="wingYellow_0.png" x="650" y="603" width="45" height="78"/>
<SubTexture name="wingYellow_1.png" x="760" y="120" width="37" height="72"/>
<SubTexture name="wingYellow_2.png" x="809" y="353" width="26" height="84"/>
<SubTexture name="wingYellow_3.png" x="576" y="150" width="51" height="75"/>
<SubTexture name="wingYellow_4.png" x="726" y="0" width="42" height="80"/>
<SubTexture name="wingYellow_5.png" x="525" y="182" width="51" height="69"/>
<SubTexture name="wingYellow_6.png" x="695" y="585" width="42" height="74"/>
<SubTexture name="wingYellow_7.png" x="651" y="364" width="43" height="83"/>
</TextureAtlas>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 KiB

After

Width:  |  Height:  |  Size: 980 KiB

+31 -75
View File
@@ -10,9 +10,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const gameRoot = path.join(__dirname, "game");
const assetsRoot = path.join(__dirname, "assets");
const indexPath = path.join(gameRoot, "index.html");
const gameJsPath = path.join(gameRoot, "game.js");
const alienOnslaughtJsPath = path.join(gameRoot, "scenes", "AlienOnslaught.js");
const galaxyBlasterJsPath = path.join(gameRoot, "scenes", "GalaxyBlaster.js");
const games = [
{ key: "cosmic-rocks", label: "Cosmic Rocks", icon: "☄️" },
@@ -20,11 +17,12 @@ const games = [
{ key: "galaxy-blaster", label: "Galaxy Blaster", icon: "🚀" },
{ key: "ninja-runner", label: "Ninja Runner", icon: "🥷" },
{ key: "defender", label: "Planet Guardian", icon: "🛡️" },
{ key: "surface-defense", label: "Surface Defense", icon: "🎯" },
];
const gameKeys = new Set(games.map((game) => game.key));
const defaultGame = "ninja-runner";
const canvasBackgroundGames = ["cosmic-rocks", "alien-onslaught", "galaxy-blaster", "defender"];
const canvasBackgroundGames = ["cosmic-rocks", "alien-onslaught", "galaxy-blaster", "defender", "surface-defense"];
const servers = new Map();
function normalizeGameKey(value) {
@@ -96,6 +94,7 @@ async function renderIndex(entry) {
const html = await readFile(indexPath, "utf8");
const bootstrap = `<script>
(() => {
window.__agentArcadeLayoutProfile = "canvas";
const selectedGame = ${JSON.stringify(entry.selectedGame)};
const games = ${JSON.stringify(games)};
const canvasBackgroundGames = ${JSON.stringify(canvasBackgroundGames)};
@@ -220,8 +219,15 @@ async function renderIndex(entry) {
resumeCanvas();
}, true);
document.getElementById("game-select")?.addEventListener("change", () => {
document.getElementById("game-select")?.addEventListener("change", (event) => {
markSwitchingGame();
fetch("/select-game", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ gameKey: event.target.value }),
}).then((response) => {
if (!response.ok) throw new Error("Could not save the selected game");
}).catch((error) => console.error("Game selection failed:", error));
}, true);
storeGame(selectedGame);
@@ -272,33 +278,6 @@ async function renderIndex(entry) {
return html.replace('<script src="./hud.js"></script>', `${bootstrap}\n <script src="./hud.js"></script>`);
}
async function renderGameJs() {
const js = await readFile(gameJsPath, "utf8");
return js
.replaceAll("newW > 800 && newH > 400", "newW > 320 && newH > 220")
.replaceAll("game && newH > 400", "game && newH > 220")
.replaceAll("window.innerWidth > 800 && window.innerHeight > 400", "window.innerWidth > 320 && window.innerHeight > 220");
}
async function renderAlienOnslaughtJs() {
const js = await readFile(alienOnslaughtJsPath, "utf8");
const layoutH = "Math.min(H, W * 3 / 4)";
const layoutY = `((H - ${layoutH}) / 2)`;
return js
.replace("this.playerY = H * 0.92;", `this.playerY = ${layoutY} + ${layoutH} * 0.95;`)
.replace("this.alienGridY = Math.max(H * 0.20, 120);", `this.alienGridY = Math.max(${layoutY} + ${layoutH} * 0.10, 80);`)
.replace("const targetShieldH = H * 0.055;", `const targetShieldH = ${layoutH} * 0.065;`)
.replace("SCALE = Math.min(W / 1920, H / 1080);", "SCALE = Math.max(1.25, Math.min(W / 1920, H / 1080));")
.replace("this.alienCellW = Math.round(W * 0.055);", "this.alienCellW = Math.round(W * 0.068);");
}
async function renderGalaxyBlasterJs() {
const js = await readFile(galaxyBlasterJsPath, "utf8");
return js
.replaceAll("SCALE = Math.min(CONV_X, CONV_Y);", "SCALE = Math.max(1.7, Math.min(CONV_X, CONV_Y));")
.replaceAll("OPPONENT_SIZE = Math.min(32 * SCALE, W / 35);", "OPPONENT_SIZE = Math.max(54, Math.min(32 * SCALE, W / 24));");
}
async function streamFile(res, filePath) {
const fileStat = await stat(filePath).catch(() => undefined);
if (!fileStat?.isFile()) {
@@ -324,22 +303,25 @@ async function streamFile(res, filePath) {
async function handleSelectGame(entry, req, res) {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
for await (const chunk of req) {
body += chunk;
});
req.on("end", () => {
let input;
try {
input = JSON.parse(body || "{}");
} catch {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
res.end("Invalid JSON request body");
return;
}
entry.selectedGame = normalizeGameKey(input.gameKey);
broadcast(entry, "selectGame", { gameKey: entry.selectedGame });
sendJson(res, { selectedGame: entry.selectedGame });
});
}
let input;
try {
input = JSON.parse(body);
} catch {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
res.end("Invalid JSON request body");
return;
}
if (!input || typeof input !== "object" || Array.isArray(input) || !gameKeys.has(input.gameKey)) {
res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
res.end("Expected a valid gameKey");
return;
}
entry.selectedGame = input.gameKey;
broadcast(entry, "selectGame", { gameKey: entry.selectedGame });
sendJson(res, { selectedGame: entry.selectedGame });
}
async function handleRequest(entry, req, res) {
@@ -382,33 +364,6 @@ async function handleRequest(entry, req, res) {
return;
}
if (url.pathname === "/game.js" || url.pathname === "/game/game.js") {
res.writeHead(200, {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "no-cache",
});
res.end(await renderGameJs());
return;
}
if (url.pathname === "/scenes/AlienOnslaught.js" || url.pathname === "/game/scenes/AlienOnslaught.js") {
res.writeHead(200, {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "no-cache",
});
res.end(await renderAlienOnslaughtJs());
return;
}
if (url.pathname === "/scenes/GalaxyBlaster.js" || url.pathname === "/game/scenes/GalaxyBlaster.js") {
res.writeHead(200, {
"content-type": "text/javascript; charset=utf-8",
"cache-control": "no-cache",
});
res.end(await renderGalaxyBlasterJs());
return;
}
const staticPath = url.pathname.startsWith("/assets/")
? resolveUnder(assetsRoot, url.pathname.slice("/assets".length))
: resolveUnder(gameRoot, url.pathname.startsWith("/game/") ? url.pathname.slice("/game".length) : url.pathname);
@@ -459,7 +414,7 @@ await joinSession({
createCanvas({
id: "arcade-canvas",
displayName: "Agent Arcade",
description: "A retro arcade canvas with five mini-games for waiting while agents work.",
description: "A retro arcade canvas with six mini-games for waiting while agents work.",
inputSchema: {
type: "object",
properties: {
@@ -524,6 +479,7 @@ await joinSession({
entry = await startServer(ctx.instanceId, normalizeGameKey(ctx.input?.defaultGame));
} else if (ctx.input?.defaultGame) {
entry.selectedGame = normalizeGameKey(ctx.input.defaultGame);
broadcast(entry, "selectGame", { gameKey: entry.selectedGame });
}
return {
title: "Agent Arcade",
+43 -14
View File
@@ -6,14 +6,20 @@ import { GalaxyBlasterScene } from './scenes/GalaxyBlaster.js';
import { CosmicRocksScene } from './scenes/CosmicRocks.js';
import { AlienOnslaughtScene } from './scenes/AlienOnslaught.js';
import { PlanetGuardianScene } from './scenes/PlanetGuardian.js';
import { SurfaceDefenseScene } from './scenes/SurfaceDefense.js';
import { runtimeLayout } from './layout.js';
// Registry of available games
// pointerGame: the game aims with the mouse, so click-through must stay off
// while it is active (see __agentArcadeSetPointerGameActive in hud.js).
const GAMES = [
{ key: 'cosmic-rocks', scene: CosmicRocksScene, label: '☄️ Cosmic Rocks' },
{ key: 'alien-onslaught', scene: AlienOnslaughtScene, label: '👾 Alien Onslaught' },
{ key: 'galaxy-blaster', scene: GalaxyBlasterScene, label: '🚀 Galaxy Blaster' },
{ key: 'ninja-runner', scene: NinjaRunnerScene, label: '🥷 Ninja Runner' },
{ key: 'defender', scene: PlanetGuardianScene, label: '🛡️ Planet Guardian' },
{ key: 'surface-defense', scene: SurfaceDefenseScene, label: '🎯 Surface Defense', pointerGame: true },
];
const isPointerGame = (key) => !!GAMES.find(g => g.key === key)?.pointerGame;
let currentGameKey;
try {
// Migrate localStorage from old "galaxy-shooter" name
@@ -39,6 +45,8 @@ if (!GAMES.find(g => g.key === currentGameKey))
let game = null;
function initGame() {
refreshDimensions();
const selectedGame = GAMES.find(g => g.key === currentGameKey);
const sceneOrder = [selectedGame, ...GAMES.filter(g => g !== selectedGame)];
game = new Phaser.Game({
type: Phaser.AUTO,
parent: 'game',
@@ -46,7 +54,7 @@ function initGame() {
height: H,
transparent: true,
backgroundColor: 'rgba(0,0,0,0)',
scene: GAMES.map(g => g.scene),
scene: sceneOrder.map(g => g.scene),
physics: {
default: 'arcade',
arcade: { gravity: { y: 1800 }, debug: false },
@@ -56,14 +64,28 @@ function initGame() {
});
// Expose game instance for Playwright testing (no production impact)
window.__phaserGame = game;
// Start the saved game (stop the default first scene if it's different)
if (currentGameKey !== GAMES[0].key) {
game.events.once('ready', () => {
game.scene.stop(GAMES[0].key);
game.scene.start(currentGameKey);
});
}
guardMissingAudio(game);
setupGameSwitcher();
if (isPointerGame(currentGameKey)) {
window.__agentArcadeSetPointerGameActive?.(true);
}
}
/**
* Audio that fails to decode (missing codecs — e.g. WebKitGTK without the
* GStreamer plugin packages) never reaches the cache, and Phaser then throws
* "Audio key not found in cache" from sound.play()/add(). An exception inside
* the frame callback stops requestAnimationFrame for good and freezes the
* game, so hand back a silent sound for missing keys instead.
*/
function guardMissingAudio(game) {
const sm = game.sound;
const cache = game.cache?.audio;
if (!sm || !cache)
return;
const origAdd = sm.add.bind(sm);
sm.add = (key, config) => cache.exists(key) ? origAdd(key, config) : new Phaser.Sound.NoAudioSound(sm, key, config);
const origPlay = sm.play.bind(sm);
sm.play = (key, extra) => (cache.exists(key) ? origPlay(key, extra) : false);
}
function setupGameSwitcher() {
// Expose game switcher for the HUD dropdown
@@ -106,9 +128,12 @@ function setupGameSwitcher() {
// click-through kills mousemove events, leaving no way to detect HUD exit).
if (ab && ab.setClickThrough)
ab.setClickThrough(false);
window.__agentArcadeSetPointerGameActive?.(isPointerGame(key));
const sel = document.getElementById('game-select');
if (sel)
if (sel) {
sel.value = key;
sel.blur();
}
game.canvas.focus();
};
}
@@ -125,20 +150,24 @@ window.addEventListener('resize', () => {
resizeDebounce = window.setTimeout(() => {
const newW = window.innerWidth;
const newH = window.innerHeight;
if (!game && newW > 800 && newH > 400) {
if (!game && newW > runtimeLayout.minimumWidth && newH > runtimeLayout.minimumHeight) {
// First time: window is now full-screen — create the game
refreshDimensions();
initGame();
}
else if (game && newH > 400) {
else if (game && newH > runtimeLayout.minimumHeight) {
// Full-screen resize (could be unpause expand or genuine resize).
// Update dimensions and resize the canvas, but never restart the
// scene — the resume system handles unpause, and a simple resize
// is enough for monitor/display changes.
const previousW = W;
const previousH = H;
refreshDimensions();
game.scale.resize(W, H);
if (W !== previousW || H !== previousH) {
game.scale.resize(W, H);
}
}
// If newH <= 400 (pause shrink to HUD), skip entirely —
// Below the profile's minimum height (pause shrink to HUD), skip entirely —
// keep W/H at full-screen values so the paused game state stays valid.
}, 150);
});
@@ -170,7 +199,7 @@ else {
// If the window is already full-screen (e.g. Playwright tests or fast Tauri
// init), create the game immediately since no resize event will fire.
setTimeout(() => {
if (!game && window.innerWidth > 800 && window.innerHeight > 400) {
if (!game && window.innerWidth > runtimeLayout.minimumWidth && window.innerHeight > runtimeLayout.minimumHeight) {
refreshDimensions();
initGame();
}
+172 -116
View File
@@ -15,7 +15,16 @@ if ('mediaSession' in navigator) {
});
}
// ── Tauri compatibility bridge ──
// ── Platform flag ──
// Linux/Wayland needs a few HUD differences (in-page Escape, pause button,
// self-drawn game <select>) because global shortcuts never fire there and
// WebKitGTK paints native form controls light.
var detectedPlatform = /Linux/i.test(navigator.userAgent) && !/Android/i.test(navigator.userAgent)
? 'linux'
: 'other';
var IS_LINUX = (window.__agentArcadePlatformOverride || detectedPlatform) === 'linux';
if (IS_LINUX) document.documentElement.classList.add('linux');
// ── Tauri compatibility bridge ──
// Creates window.agentArcade matching the Electron preload API so game
// scenes work identically on both runtimes.
@@ -31,6 +40,9 @@ if ('mediaSession' in navigator) {
setPaused: function(paused) {
ti.invoke('set_paused', { paused: !!paused });
},
resetCursorTracker: function() {
resetCursorTracker();
},
onResumeRequest: function(cb) {
resumeCallbacks = [cb];
},
@@ -59,12 +71,8 @@ if ('mediaSession' in navigator) {
if (wb) wb.style.display = '';
var c = document.querySelector('canvas');
if (c) c.focus();
// Determine whether any interactive overlay needs click-through OFF.
var hasOverlay = !!go ||
!!(settingsOv && settingsOv.classList.contains('show')) ||
!!(helpOv && helpOv.classList.contains('show')) ||
!!(updateBanner && updateBanner.classList.contains('show'));
ti.invoke('set_click_through', { enabled: !hasOverlay });
resetCursorTracker();
ti.invoke('set_click_through', { enabled: !hasInteractiveSurface() });
}
// Shared logic for both resume paths: notify game, clear paused CSS, then restore
@@ -106,10 +114,8 @@ if ('mediaSession' in navigator) {
if (ro) ro.remove();
};
// Hybrid cursor tracking: event-based when over HUD, IPC polling otherwise.
// When click-through is OFF (cursor over HUD), mousemove events fire normally
// so we detect exit via events. When click-through is ON, events don't fire
// so we poll at 250ms to detect HUD entry.
// Linux remains interactive and uses mouse events only. Other platforms use
// events over the HUD and poll while click-through prevents pointer events.
var isOverHud = false;
var pollTimer = null;
var hudEl = document.getElementById('hud');
@@ -117,12 +123,35 @@ if ('mediaSession' in navigator) {
var settingsOv = document.getElementById('settings-overlay');
var updateBanner = document.getElementById('update-banner');
function hasFullScreenInteractiveOverlay() {
var gameOver = document.getElementById('gameover-overlay');
return !!(gameOver && gameOver.style.display !== 'none') ||
!!(helpOv && helpOv.classList.contains('show')) ||
!!(settingsOv && settingsOv.classList.contains('show'));
}
function hasInteractiveSurface() {
return !!window.__agentArcadePointerGameActive ||
hasFullScreenInteractiveOverlay() ||
!!(updateBanner && updateBanner.classList.contains('show'));
}
function resetCursorTracker() {
isOverHud = false;
document.removeEventListener('mousemove', onDocMouseMove);
if (IS_LINUX) {
document.addEventListener('mousemove', onDocMouseMove);
return;
}
schedulePoll();
}
function isOverHudArea(x, y) {
if (window.__agentArcadePointerGameActive) return true;
if (hasFullScreenInteractiveOverlay()) return true;
if (!hudEl) return false;
var rect = hudEl.getBoundingClientRect();
var over = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (helpOv && helpOv.classList.contains('show')) over = true;
if (settingsOv && settingsOv.classList.contains('show')) over = true;
if (updateBanner && updateBanner.classList.contains('show')) {
var br = updateBanner.getBoundingClientRect();
if (x >= br.left && x <= br.right && y >= br.top && y <= br.bottom) over = true;
@@ -142,18 +171,37 @@ if ('mediaSession' in navigator) {
if (!isOverHud) return;
isOverHud = false;
// When paused, Rust manages click-through (always OFF so HUD bar is clickable).
// Only enable click-through in running state to avoid race conditions.
if (!document.body.classList.contains('paused')) {
// Interactive overlays also require click-through to remain OFF.
if (!document.body.classList.contains('paused') && !hasFullScreenInteractiveOverlay()) {
ti.invoke('set_click_through', { enabled: true });
}
document.removeEventListener('mousemove', onDocMouseMove);
schedulePoll();
if (!IS_LINUX) {
document.removeEventListener('mousemove', onDocMouseMove);
schedulePoll();
}
}
function onDocMouseMove(e) {
if (!isOverHudArea(e.clientX, e.clientY)) onCursorLeftHud();
if (isOverHudArea(e.clientX, e.clientY)) {
onCursorOverHud();
} else {
onCursorLeftHud();
}
}
window.__agentArcadeSetPointerGameActive = function(active) {
window.__agentArcadePointerGameActive = !!active;
if (active) {
onCursorOverHud();
return;
}
// Game switches originate from the HUD, so keep interaction enabled until
// the pointer leaves the HUD and the normal tracker restores click-through.
isOverHud = false;
onCursorOverHud();
};
function pollCursorPosition() {
var so = settingsOv && settingsOv.classList.contains('show');
if (document.hidden || (document.body.classList.contains('paused') && !so)) {
@@ -170,11 +218,16 @@ if ('mediaSession' in navigator) {
}
function schedulePoll() {
if (IS_LINUX) return;
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(pollCursorPosition, 250);
}
schedulePoll();
if (IS_LINUX) {
document.addEventListener('mousemove', onDocMouseMove);
} else {
schedulePoll();
}
// Reclaim OS keyboard focus the instant the window loses it during active
// gameplay. Click-through lets clicks pass to apps below, which causes macOS
@@ -205,37 +258,43 @@ if ('mediaSession' in navigator) {
}
}
function pauseForOverlay() {
var game = window.__phaserGame;
var scenes = !isPaused() && game && game.scene ? game.scene.getScenes(true) : [];
scenes.forEach(function(scene) { scene.scene.pause(); });
if (scenes.length && game.sound) game.sound.pauseAll();
return scenes;
}
function resumeAfterOverlay(scenes) {
if (isPaused()) return;
var resumed = false;
scenes.forEach(function(scene) {
if (scene.scene.isPaused()) {
scene.scene.resume();
resumed = true;
}
});
var game = window.__phaserGame;
if (resumed && game && game.sound) game.sound.resumeAll();
}
// Help button — show controls overlay
var helpBtn = document.getElementById('help-btn');
var helpOverlay = document.getElementById('help-overlay');
var helpClose = document.getElementById('help-close');
var wasGamePausedBeforeHelp = false;
var helpPausedScenes = [];
function showHelp() {
wasGamePausedBeforeHelp = isPaused();
if (!wasGamePausedBeforeHelp) {
var game = window.__phaserGame;
if (game && game.scene) {
game.scene.getScenes(true).forEach(function(s) {
if (s.scene && s.scene.pause) s.scene.pause();
if (s.sound && s.sound.pauseAll) s.sound.pauseAll();
});
}
}
if (helpOverlay.classList.contains('show')) return;
helpPausedScenes = pauseForOverlay();
helpOverlay.classList.add('show');
}
function hideHelp() {
helpOverlay.classList.remove('show');
if (!wasGamePausedBeforeHelp) {
var game = window.__phaserGame;
if (game && game.scene) {
game.scene.getScenes(false).forEach(function(s) {
if (s.scene && s.scene.resume) s.scene.resume();
if (s.sound && s.sound.resumeAll) s.sound.resumeAll();
});
}
}
resumeAfterOverlay(helpPausedScenes);
helpPausedScenes = [];
}
helpBtn.addEventListener('click', showHelp);
helpClose.addEventListener('click', hideHelp);
@@ -259,6 +318,12 @@ if ('mediaSession' in navigator) {
}
});
// Pause button (Linux only) — Wayland has no global Escape shortcut
var pauseBtn = document.getElementById('pause-btn');
pauseBtn.addEventListener('click', function () {
if (!isPaused()) setPaused(true);
});
// Resume button — unpause the game (shown only when paused)
var resumeBtn = document.getElementById('resume-btn');
resumeBtn.addEventListener('click', function () {
@@ -342,7 +407,10 @@ if ('mediaSession' in navigator) {
}
var savedBg = localStorage.getItem('agentArcade_bgTransparency');
if (savedBg !== null) {
currentBgTransparency = parseInt(savedBg, 10) || DEFAULT_BG_TRANSPARENCY;
var parsedBg = parseInt(savedBg, 10);
if (Number.isFinite(parsedBg)) {
currentBgTransparency = Math.max(0, Math.min(100, parsedBg));
}
} else {
// New user — persist the default so it's always in localStorage
localStorage.setItem('agentArcade_bgTransparency', String(DEFAULT_BG_TRANSPARENCY));
@@ -521,7 +589,7 @@ if ('mediaSession' in navigator) {
}
// Save a hotkey via Tauri invoke and update UI
function saveHotkey(type, combo, displayEl, statusEl, storageKey, tauriCmd, currentRef) {
function saveHotkey(type, combo, displayEl, statusEl, storageKey, tauriCmd) {
displayEl.value = combo;
displayEl.style.borderColor = '#555';
var ui = getRecordingUI(type);
@@ -530,35 +598,31 @@ if ('mediaSession' in navigator) {
statusEl.style.color = '#888';
recordingTarget = 'none';
function saved() {
if (type === 'toggle') currentHotkey = combo;
else if (type === 'pause') currentPauseKey = combo;
else currentUnpauseKey = combo;
displayEl.value = combo;
statusEl.textContent = '✓ Saved';
statusEl.style.color = '#4f4';
try { localStorage.setItem(storageKey, combo); } catch(ex) {}
syncHelpHotkeys();
setTimeout(function() { statusEl.textContent = ''; }, 2000);
}
var ti = window.__TAURI_INTERNALS__;
if (ti) {
ti.invoke(tauriCmd, { combo: combo }).then(function() {
currentRef.value = combo;
displayEl.value = combo;
statusEl.textContent = '✓ Saved';
statusEl.style.color = '#4f4';
try { localStorage.setItem(storageKey, combo); } catch(ex) {}
setTimeout(function() { statusEl.textContent = ''; }, 2000);
}).catch(function() {
displayEl.value = currentRef.value;
ti.invoke(tauriCmd, { combo: combo }).then(saved).catch(function() {
displayEl.value = getCurrentValue(type);
statusEl.textContent = 'Taken!';
statusEl.style.color = '#f44';
setTimeout(function() { statusEl.textContent = ''; }, 3000);
});
} else {
currentRef.value = combo;
try { localStorage.setItem(storageKey, combo); } catch(ex) {}
statusEl.textContent = '✓ Saved';
statusEl.style.color = '#4f4';
setTimeout(function() { statusEl.textContent = ''; }, 2000);
saved();
}
}
// Mutable refs so saveHotkey can update the outer variables
var toggleRef = { value: currentHotkey };
var pauseRef = { value: currentPauseKey };
var unpauseRef = { value: currentUnpauseKey };
// Keep help dialog hotkeys in sync with current settings
function syncHelpHotkeys() {
var toggleEl = document.getElementById('help-toggle-keys');
@@ -597,60 +661,52 @@ if ('mediaSession' in navigator) {
}
if (recordingTarget === 'toggle') {
saveHotkey('toggle', combo, hotkeyDisplay, hotkeyStatus, 'agentArcade_hotkey', 'set_toggle_shortcut', toggleRef);
currentHotkey = combo;
saveHotkey('toggle', combo, hotkeyDisplay, hotkeyStatus, 'agentArcade_hotkey', 'set_toggle_shortcut');
} else if (recordingTarget === 'pause') {
saveHotkey('pause', combo, pauseDisplay, pauseStatus, 'agentArcade_pauseKey', 'set_pause_shortcut', pauseRef);
currentPauseKey = combo;
saveHotkey('pause', combo, pauseDisplay, pauseStatus, 'agentArcade_pauseKey', 'set_pause_shortcut');
} else if (recordingTarget === 'unpause') {
saveHotkey('unpause', combo, unpauseDisplay, unpauseStatus, 'agentArcade_unpauseKey', 'set_unpause_shortcut', unpauseRef);
currentUnpauseKey = combo;
saveHotkey('unpause', combo, unpauseDisplay, unpauseStatus, 'agentArcade_unpauseKey', 'set_unpause_shortcut');
}
syncHelpHotkeys();
}, true);
// Reset buttons
hotkeyResetBtn.addEventListener('click', function() {
if (recordingTarget === 'toggle') stopRecording('toggle');
saveHotkey('toggle', DEFAULT_HOTKEY, hotkeyDisplay, hotkeyStatus, 'agentArcade_hotkey', 'set_toggle_shortcut', toggleRef);
currentHotkey = DEFAULT_HOTKEY;
syncHelpHotkeys();
saveHotkey('toggle', DEFAULT_HOTKEY, hotkeyDisplay, hotkeyStatus, 'agentArcade_hotkey', 'set_toggle_shortcut');
});
pauseResetBtn.addEventListener('click', function() {
if (recordingTarget === 'pause') stopRecording('pause');
saveHotkey('pause', DEFAULT_PAUSE_KEY, pauseDisplay, pauseStatus, 'agentArcade_pauseKey', 'set_pause_shortcut', pauseRef);
currentPauseKey = DEFAULT_PAUSE_KEY;
syncHelpHotkeys();
saveHotkey('pause', DEFAULT_PAUSE_KEY, pauseDisplay, pauseStatus, 'agentArcade_pauseKey', 'set_pause_shortcut');
});
unpauseResetBtn.addEventListener('click', function() {
if (recordingTarget === 'unpause') stopRecording('unpause');
saveHotkey('unpause', DEFAULT_UNPAUSE_KEY, unpauseDisplay, unpauseStatus, 'agentArcade_unpauseKey', 'set_unpause_shortcut', unpauseRef);
currentUnpauseKey = DEFAULT_UNPAUSE_KEY;
syncHelpHotkeys();
saveHotkey('unpause', DEFAULT_UNPAUSE_KEY, unpauseDisplay, unpauseStatus, 'agentArcade_unpauseKey', 'set_unpause_shortcut');
});
// On startup, apply saved hotkeys to Rust
setTimeout(function() {
var ti = window.__TAURI_INTERNALS__;
if (ti) {
if (currentHotkey !== DEFAULT_HOTKEY) {
ti.invoke('set_toggle_shortcut', { combo: currentHotkey }).catch(function() {
hotkeyStatus.textContent = 'Hotkey unavailable';
hotkeyStatus.style.color = '#f44';
if (ti && (currentHotkey !== DEFAULT_HOTKEY ||
currentPauseKey !== DEFAULT_PAUSE_KEY || currentUnpauseKey !== DEFAULT_UNPAUSE_KEY)) {
ti.invoke('restore_shortcuts', {
shortcuts: { toggle: currentHotkey, pause: currentPauseKey, unpause: currentUnpauseKey }
}).catch(function(error) {
[hotkeyStatus, pauseStatus, unpauseStatus].forEach(function(status) {
status.textContent = 'Saved shortcuts unavailable';
status.style.color = '#f44';
status.title = String(error);
});
}
if (currentPauseKey !== DEFAULT_PAUSE_KEY) {
ti.invoke('set_pause_shortcut', { combo: currentPauseKey }).catch(function() {
pauseStatus.textContent = 'Pause key unavailable';
pauseStatus.style.color = '#f44';
return Promise.all([
ti.invoke('get_toggle_shortcut'), ti.invoke('get_pause_shortcut'), ti.invoke('get_unpause_shortcut')
]).then(function(active) {
currentHotkey = hotkeyDisplay.value = active[0];
currentPauseKey = pauseDisplay.value = active[1];
currentUnpauseKey = unpauseDisplay.value = active[2];
syncHelpHotkeys();
}).catch(function() {
hotkeyStatus.textContent = 'Could not read active shortcuts';
});
}
if (currentUnpauseKey !== DEFAULT_UNPAUSE_KEY) {
ti.invoke('set_unpause_shortcut', { combo: currentUnpauseKey }).catch(function() {
unpauseStatus.textContent = 'Resume key unavailable';
unpauseStatus.style.color = '#f44';
});
}
});
}
}, 800);
@@ -709,32 +765,30 @@ if ('mediaSession' in navigator) {
}
var wasGamePausedBeforeSettings = false;
var settingsPausedScenes = [];
var settingsOpen = false;
function showSettings() {
if (settingsOpen) return;
settingsOpen = true;
syncAudioToggle();
bgSlider.value = currentBgTransparency;
bgValue.textContent = currentBgTransparency + '%';
// Pause the Phaser scene if running (but don't trigger full Tauri pause)
wasGamePausedBeforeSettings = isPaused();
if (!wasGamePausedBeforeSettings) {
var game = window.__phaserGame;
if (game && game.scene) {
game.scene.getScenes(true).forEach(function(s) {
if (s.scene && s.scene.pause) s.scene.pause();
if (s.sound && s.sound.pauseAll) s.sound.pauseAll();
});
}
}
settingsPausedScenes = pauseForOverlay();
if (wasGamePausedBeforeSettings) {
// Window is shrunk — expand it first, then show overlay
expandWindowForOverlay(function() {
settingsOverlay.classList.add('show');
if (settingsOpen) settingsOverlay.classList.add('show');
});
} else {
settingsOverlay.classList.add('show');
}
}
function hideSettings() {
settingsOpen = false;
if (recordingTarget !== 'none') stopRecording(recordingTarget);
settingsOverlay.classList.remove('show');
// Re-apply transparency to ensure it sticks after resume
applyBgTransparency(currentBgTransparency);
@@ -742,15 +796,9 @@ if ('mediaSession' in navigator) {
// Shrink the window back to the paused HUD bar
shrinkWindowForPause();
} else {
// Resume the Phaser scene if it wasn't paused before we opened settings
var game = window.__phaserGame;
if (game && game.scene) {
game.scene.getScenes(false).forEach(function(s) {
if (s.scene && s.scene.resume) s.scene.resume();
if (s.sound && s.sound.resumeAll) s.sound.resumeAll();
});
}
resumeAfterOverlay(settingsPausedScenes);
}
settingsPausedScenes = [];
}
settingsBtn.addEventListener('click', showSettings);
settingsClose.addEventListener('click', hideSettings);
@@ -760,7 +808,7 @@ if ('mediaSession' in navigator) {
// Escape closes settings
document.addEventListener('keydown', function(e) {
if (e.code === 'Escape' && settingsOverlay.classList.contains('show')) {
e.stopPropagation();
e.stopImmediatePropagation();
hideSettings();
}
});
@@ -776,8 +824,18 @@ if ('mediaSession' in navigator) {
}
});
// Escape is handled by Rust's global shortcut so it works
// even when another app has focus. No in-page handler needed.
// Escape is handled by Rust's global shortcut on macOS/Windows so it works
// even when another app has focus. Wayland compositors never deliver
// global shortcuts, so Linux pauses from an in-page handler instead.
if (IS_LINUX) {
document.addEventListener('keydown', function (e) {
if (e.code !== 'Escape' || isPaused()) return;
if (settingsOverlay.classList.contains('show')) return;
if (helpOverlay.classList.contains('show')) return;
e.preventDefault();
setPaused(true);
});
}
// Auto-refocus the Phaser canvas when the window regains focus
// or the user clicks anywhere outside an interactive HUD element.
@@ -799,8 +857,6 @@ window.__agentArcadeUpdateAvailable = function(version) {
var banner = document.getElementById('update-banner');
var versionEl = document.getElementById('update-version');
var dismissBtn = document.getElementById('update-dismiss');
var linkEl = banner ? banner.querySelector('.update-link') : null;
var iconEl = banner ? banner.querySelector('.update-icon') : null;
if (!banner || !versionEl) return;
versionEl.textContent = 'v' + version;
+21 -68
View File
@@ -19,7 +19,6 @@
}
body.paused #game,
body.paused #game *,
body.paused #pause-overlay,
body.paused #gameover-overlay,
body.paused #wave-banner,
body.paused #help-overlay {
@@ -80,12 +79,6 @@
flex: 1;
}
.ctrl {
display: inline-flex;
align-items: center;
gap: 5px;
}
kbd {
display: inline-block;
min-width: 18px;
@@ -102,12 +95,6 @@
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.5);
}
.ctrl-label {
font-weight: 600;
letter-spacing: 0.3px;
color: #b8c2e8;
}
.title {
font-weight: 700;
letter-spacing: 0.5px;
@@ -143,10 +130,6 @@
text-align: center;
transition: transform 120ms ease-out;
}
.score-value.bump {
transform: scale(1.25);
filter: brightness(1.4);
}
@keyframes scorePop {
0% { transform: translateY(0) scale(1); color: #ffeb3b; }
30% { transform: translateY(-3px) scale(1.28); color: #fff59d; }
@@ -169,19 +152,6 @@
text-align: center;
}
.level-value {
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
font-size: 20px;
font-weight: 900;
color: #4fc3f7;
text-shadow:
0 0 8px rgba(79, 195, 247, 0.9),
0 0 14px rgba(33, 150, 243, 0.5),
0 2px 0 rgba(0, 0, 0, 0.6);
min-width: 20px;
text-align: center;
}
.hi-value {
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
font-size: 22px;
@@ -508,39 +478,7 @@
box-shadow: none;
animation: none;
}
@keyframes hudPulse {
0%, 100% { box-shadow: 0 0 0 1px rgba(255,255,255,0.08) inset, 0 6px 24px rgba(0,0,0,0.7), 0 0 18px rgba(255, 213, 74, 0.45); }
50% { box-shadow: 0 0 0 1px rgba(255,255,255,0.08) inset, 0 6px 24px rgba(0,0,0,0.7), 0 0 32px rgba(255, 213, 74, 0.85); }
}
.pause-badge {
display: none;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: linear-gradient(180deg, #4d3a06 0%, #2a1f00 100%);
border: 1px solid #ffd54a;
border-radius: 6px;
color: #ffd54a;
font-weight: 800;
font-size: 12px;
letter-spacing: 1.2px;
text-shadow: 0 0 6px rgba(255, 213, 74, 0.6);
}
#hud.paused .pause-badge { display: inline-flex; }
#hud.paused .when-running { display: none; }
.pause-badge .dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffd54a;
box-shadow: 0 0 8px #ffd54a;
animation: dotBlink 1s ease-in-out infinite;
}
@keyframes dotBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
/* Resume button — shown only when paused */
.resume-btn {
@@ -665,6 +603,23 @@
background: #1a1a2e;
color: #ffd700;
}
/* WebKitGTK paints a light native control behind the gold text, so Linux
draws the closed select itself. macOS/Windows keep the native look. */
html.linux .game-select {
appearance: none;
-webkit-appearance: none;
color-scheme: dark;
background-color: #0d1020;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path fill='%23ffd700' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 10px 6px;
padding: 4px 22px 4px 8px;
}
/* Pause button: Linux only (no global Escape on Wayland) */
html:not(.linux) .pause-btn { display: none; }
html.linux #settings-overlay,
html.linux #help-overlay { backdrop-filter: none; }
/* Update notification banner */
#update-banner {
@@ -745,6 +700,7 @@
<button id="help-btn" class="help-btn when-running" type="button">
<span class="badge">?</span>
</button>
<button id="pause-btn" class="mute-btn pause-btn when-running" type="button" title="Pause (Esc)">⏸</button>
<button id="mute-btn" class="mute-btn when-running" type="button" title="Toggle sound (M)">🔊</button>
<button id="resume-btn" class="resume-btn" type="button" title="Resume game">▶ RESUME</button>
<button id="settings-btn" class="settings-btn" type="button" title="Settings">⚙️</button>
@@ -862,6 +818,8 @@
<option value="cosmic-rocks">☄️ Cosmic Rocks</option>
<option value="galaxy-blaster">🚀 Galaxy Blaster</option>
<option value="ninja-runner">🥷 Ninja Runner</option>
<option value="defender">🛡️ Planet Guardian</option>
<option value="surface-defense">🎯 Surface Defense</option>
</select>
<button id="clear-scores-btn" type="button"
style="padding:6px 14px;border:2px solid #c44;border-radius:6px;
@@ -924,14 +882,9 @@
<span class="update-dismiss" id="update-dismiss" title="Dismiss">✕</span>
</div>
<div id="pause-overlay" style="display:none"></div>
<div id="game"></div>
<script src="./hud.js"></script>
<script src="./phaser.min.js"></script>
<script type="module">
await window.__agentArcadePhaserReady;
await import("./game.js");
</script>
<script type="module" src="./game.js"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
const profile = window.__agentArcadeLayoutProfile === 'canvas' ? 'canvas' : 'desktop';
export const runtimeLayout = {
profile,
minimumWidth: profile === 'canvas' ? 320 : 800,
minimumHeight: profile === 'canvas' ? 220 : 400,
};
export function getWavAudioPath(relativePathWithoutExtension) {
const extension = profile === 'canvas' ? 'm4a' : 'wav';
return `../assets/${relativePathWithoutExtension}.${extension}`;
}
export function getAlienLayout(width, height) {
const canvas = profile === 'canvas';
const playfieldHeight = canvas ? Math.min(height, width * 3 / 4) : height;
const playfieldTop = (height - playfieldHeight) / 2;
return {
scale: Math.max(canvas ? 1.25 : 0, Math.min(width / 1920, height / 1080)),
cellWidth: Math.round(width * (canvas ? 0.068 : 0.055)),
playerY: playfieldTop + playfieldHeight * (canvas ? 0.95 : 0.92),
gridY: canvas ? Math.max(playfieldTop + playfieldHeight * 0.10, 80) : Math.max(height * 0.20, 120),
targetShieldHeight: playfieldHeight * (canvas ? 0.065 : 0.055),
};
}
export function getGalaxyLayout(width, height) {
const canvas = profile === 'canvas';
const scale = Math.max(canvas ? 1.7 : 0, Math.min(width / 500, height / 500));
return {
scale,
opponentSize: canvas
? Math.max(54, Math.min(32 * scale, width / 24))
: Math.min(32 * scale, width / 35),
};
}
//# sourceMappingURL=layout.js.map
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -3,10 +3,11 @@
// edges. The player defends from the bottom with destructible shields.
// All graphics are procedural (Phaser Graphics) — no external sprite sheets.
import { BaseScene, W, H } from './BaseScene.js';
import { getAlienLayout } from '../layout.js';
/* ------------------------------------------------------------------ */
/* Constants — recalculated in create() for responsive sizing */
/* ------------------------------------------------------------------ */
let SCALE = Math.min(W / 1920, H / 1080);
let SCALE = getAlienLayout(W, H).scale;
// Grid layout
const ALIEN_COLS = 11;
const ALIEN_ROWS = 5;
@@ -19,7 +20,6 @@ const ALIEN_TYPES = [
// Timing / speeds
const BASE_MARCH_INTERVAL = 700; // ms between march steps at full grid
const MIN_MARCH_INTERVAL = 60; // fastest march with few aliens left
const MARCH_DROP = 0; // calculated in create()
const PLAYER_SPEED = 350; // px/s
const PLAYER_BULLET_SPEED = 500; // px/s
const ALIEN_BULLET_SPEED = 250; // px/s
@@ -61,6 +61,8 @@ export class AlienOnslaughtScene extends BaseScene {
mysteryTimer = 0;
/* shields */
shields = []; // [shieldIdx][blockIdx]
shieldTop = 0;
shieldBottom = 0;
/* starfield */
stars = [];
/* game state */
@@ -100,16 +102,16 @@ export class AlienOnslaughtScene extends BaseScene {
this.load.audio('ao_explosion', '../assets/galaxy-blaster/sounds/sfx_explosion.ogg');
this.load.audio('ao_lose', '../assets/cosmic-rocks/sounds/sfx_lose.ogg');
this.load.audio('ao_twoTone', '../assets/cosmic-rocks/sounds/sfx_twoTone.ogg');
this.load.audio('ao_shieldHit', '../assets/galaxy-blaster/sounds/sfx_zap.ogg');
this.load.audio('ao_mystery', '../assets/galaxy-blaster/sounds/sfx_twoTone.ogg');
}
create() {
this.initBase();
// Responsive sizing — scale the grid to fill ~70% of screen width
SCALE = Math.min(W / 1920, H / 1080);
const layout = getAlienLayout(W, H);
SCALE = layout.scale;
const s = Math.max(SCALE, 0.5);
// Size the grid relative to screen, not a fixed pixel size
this.alienCellW = Math.round(W * 0.055); // ~85% of original — tighter grid
this.alienCellW = layout.cellWidth;
this.alienCellH = Math.round(this.alienCellW * 0.8);
this.alienW = Math.round(this.alienCellW * 0.6);
this.alienH = Math.round(this.alienCellH * 0.55);
@@ -147,7 +149,7 @@ export class AlienOnslaughtScene extends BaseScene {
]);
// Player position — bottom of screen with padding
this.playerX = W / 2;
this.playerY = H * 0.92;
this.playerY = layout.playerY;
this.playerGfx = this.add.graphics().setDepth(10);
this.drawPlayer();
// Input
@@ -212,7 +214,7 @@ export class AlienOnslaughtScene extends BaseScene {
if (this.waveDelay <= 0)
this.startWave();
}
else if (this.aliens.filter(a => a.alive).length === 0 && this.waveDelay <= 0) {
else if (!this.aliens.some(a => a.alive)) {
this.waveDelay = 1500;
}
}
@@ -253,7 +255,7 @@ export class AlienOnslaughtScene extends BaseScene {
// Clamp to screen
const hw = this.playerW / 2;
this.playerX = Math.max(hw, Math.min(W - hw, this.playerX));
this.drawPlayer();
this.playerGfx.setPosition(this.playerX, this.playerY);
// Fire
const spaceDown = this.spaceKey.isDown;
if (spaceDown && !this.spaceWasDown && this.playerBullets.length < 2) {
@@ -320,7 +322,7 @@ export class AlienOnslaughtScene extends BaseScene {
// Calculate grid start position (centered)
const gridW = ALIEN_COLS * this.alienCellW;
this.alienGridX = (W - gridW) / 2;
this.alienGridY = Math.max(H * 0.20, 120);
this.alienGridY = getAlienLayout(W, H).gridY;
// Create aliens
for (const a of this.aliens)
a.gfx.destroy();
@@ -347,7 +349,6 @@ export class AlienOnslaughtScene extends BaseScene {
g.clear();
g.setPosition(alien.x, alien.y);
const type = ALIEN_TYPES[alien.type];
const hw = this.alienW / 2;
const hh = this.alienH / 2;
const px = Math.max(2, Math.round(this.alienW / 10)); // pixel unit size
// Draw pixel-art alien based on type
@@ -460,10 +461,12 @@ export class AlienOnslaughtScene extends BaseScene {
this.sound.play('ao_twoTone', { volume: 0.15 });
}
updateMarchInterval() {
const aliveCount = this.aliens.filter(a => a.alive).length;
let aliveCount = 0;
for (const alien of this.aliens) {
if (alien.alive)
aliveCount++;
}
const total = ALIEN_COLS * ALIEN_ROWS;
if (total === 0)
return;
// Exponential speed-up as aliens are destroyed
const ratio = aliveCount / total;
this.marchInterval = MIN_MARCH_INTERVAL + (BASE_MARCH_INTERVAL - MIN_MARCH_INTERVAL) * ratio;
@@ -548,7 +551,7 @@ export class AlienOnslaughtScene extends BaseScene {
updateMystery(dt, dtSec) {
if (this.mystery && this.mystery.active) {
this.mystery.x += MYSTERY_SPEED * this.mystery.direction * dtSec;
this.drawMystery();
this.mystery.gfx.setPosition(this.mystery.x, this.mystery.y);
// Off screen?
if ((this.mystery.direction === 1 && this.mystery.x > W + 60) ||
(this.mystery.direction === -1 && this.mystery.x < -60)) {
@@ -576,9 +579,8 @@ export class AlienOnslaughtScene extends BaseScene {
}
}
this.shields = [];
const s = Math.max(SCALE, 0.5);
// Original shields were ~6% of screen height tall; derive block size from that
const targetShieldH = H * 0.055;
const targetShieldH = getAlienLayout(W, H).targetShieldHeight;
const blockH = Math.max(2, Math.round(targetShieldH / SHIELD_BLOCK_ROWS));
const blockW = blockH;
const shieldW = SHIELD_BLOCK_COLS * blockW;
@@ -586,6 +588,8 @@ export class AlienOnslaughtScene extends BaseScene {
const totalShieldsW = SHIELD_COUNT * shieldW;
const gap = (W - totalShieldsW) / (SHIELD_COUNT + 1);
const shieldY = this.playerY - this.playerH - shieldH - 20;
this.shieldTop = shieldY;
this.shieldBottom = shieldY + shieldH;
// Classic shield shape mask (inverted U)
const shieldMask = this.generateShieldMask();
for (let si = 0; si < SHIELD_COUNT; si++) {
@@ -732,6 +736,10 @@ export class AlienOnslaughtScene extends BaseScene {
for (const a of this.aliens) {
if (!a.alive)
continue;
// All shields occupy this fixed vertical band; skip block checks until aliens reach it.
if (a.y + this.alienH / 2 <= this.shieldTop ||
a.y - this.alienH / 2 >= this.shieldBottom)
continue;
for (const shield of this.shields) {
for (const block of shield) {
if (!block.alive)
@@ -13,7 +13,9 @@ export class BaseScene extends Phaser.Scene {
highScore = 0;
lives = 3;
level = 0;
scoreAnimTimer;
scoreAnimFrame;
waveBannerTimer;
waveBanner = null;
gameOverKeyListener;
/** Full-screen dark backdrop controlled by the transparency slider. */
_backdrop = null;
@@ -115,8 +117,11 @@ export class BaseScene extends Phaser.Scene {
let alpha = 1;
try {
const saved = localStorage.getItem('agentArcade_bgTransparency');
if (saved !== null)
alpha = Math.max(0.01, Math.min(1, parseInt(saved, 10) / 100));
if (saved !== null) {
const percent = parseInt(saved, 10);
if (Number.isFinite(percent))
alpha = Math.max(0.01, Math.min(1, percent / 100));
}
}
catch { /* ignore */ }
g.setAlpha(alpha);
@@ -138,32 +143,31 @@ export class BaseScene extends Phaser.Scene {
}
/** Push current score into the HTML HUD element. */
syncScoreToHUD() {
const el = document.getElementById('score-value');
if (el)
el.textContent = String(this.score);
this.setHUDValue('score-value', this.score);
}
/** Push high score into the HTML HUD element. */
syncHighScoreToHUD() {
const el = document.getElementById('hi-value');
if (el)
el.textContent = String(this.highScore);
this.setHUDValue('hi-value', this.highScore);
}
/** Push lives count into the HTML HUD element. */
syncLivesToHUD() {
const el = document.getElementById('lives-value');
if (el)
el.textContent = String(this.lives);
this.setHUDValue('lives-value', this.lives);
}
/** Push level/wave number into the HTML HUD element. */
syncLevelToHUD(value) {
const el = document.getElementById('level-value');
if (el)
el.textContent = String(value ?? this.level);
this.setHUDValue('level-value', value ?? this.level);
}
setHUDValue(id, value) {
const el = document.getElementById(id);
const text = String(value);
if (el && el.textContent !== text)
el.textContent = text;
}
/** Animated score bump (count-up + pop class). */
addScore(points, worldX, worldY) {
const prev = this.score;
this.score += points;
this.checkHighScore();
// Floating "+N" text at world position
if (worldX !== undefined && worldY !== undefined) {
const txt = this.add.text(worldX, worldY, `+${points}`, {
@@ -186,25 +190,31 @@ export class BaseScene extends Phaser.Scene {
const el = document.getElementById('score-value');
if (!el)
return;
if (this.scoreAnimTimer)
clearInterval(this.scoreAnimTimer);
if (this.scoreAnimFrame)
cancelAnimationFrame(this.scoreAnimFrame);
const start = prev;
const end = this.score;
const duration = 450;
const startTime = performance.now();
this.scoreAnimTimer = window.setInterval(() => {
const animate = () => {
const t = Math.min(1, (performance.now() - startTime) / duration);
const ease = 1 - Math.pow(1 - t, 3);
el.textContent = String(Math.round(start + (end - start) * ease));
const text = String(Math.round(start + (end - start) * ease));
if (el.textContent !== text)
el.textContent = text;
if (t >= 1) {
clearInterval(this.scoreAnimTimer);
this.scoreAnimTimer = undefined;
this.scoreAnimFrame = undefined;
el.classList.remove('pop');
void el.offsetWidth;
el.classList.add('pop');
this.scoreAnimFrame = requestAnimationFrame(() => {
el.classList.add('pop');
this.scoreAnimFrame = undefined;
});
}
}, 16);
this.checkHighScore();
else {
this.scoreAnimFrame = requestAnimationFrame(animate);
}
};
this.scoreAnimFrame = requestAnimationFrame(animate);
}
/** Get top 10 scores for this game from localStorage. */
getLeaderboard() {
@@ -397,12 +407,19 @@ export class BaseScene extends Phaser.Scene {
this.gameOverShown = false;
document.removeEventListener('keydown', onKey);
overlay.remove();
// Re-enable click-through
if (ti)
const bridge = window.agentArcade;
if (bridge?.resetCursorTracker)
bridge.resetCursorTracker();
// Re-enable click-through — except in pointer-driven games, which need
// it to stay off so aiming clicks reach the canvas.
if (ti && !window.__agentArcadePointerGameActive) {
ti.invoke('set_click_through', { enabled: true });
}
restartFn();
};
const onKey = (ev) => {
if (this.isHUDOverlayOpen() || document.body.classList.contains('paused'))
return;
if (ev.code === 'Space' || ev.code === 'Enter') {
ev.preventDefault();
dismiss();
@@ -580,6 +597,8 @@ export class BaseScene extends Phaser.Scene {
document.body.appendChild(overlay);
this._readyOverlay = overlay;
const onKey = (e) => {
if (this.isHUDOverlayOpen() || document.body.classList.contains('paused'))
return;
if (['Meta', 'Alt', 'Control', 'Shift'].includes(e.key))
return;
document.removeEventListener('keydown', onKey);
@@ -607,6 +626,9 @@ export class BaseScene extends Phaser.Scene {
this._readyKeyListener = undefined;
}
}
isHUDOverlayOpen() {
return !!document.querySelector('#settings-overlay.show, #help-overlay.show');
}
_fireReadyOnStart() {
if (this._readyOnStart) {
const fn = this._readyOnStart;
@@ -616,6 +638,10 @@ export class BaseScene extends Phaser.Scene {
}
/** Called by the pause system. Override if the scene needs custom cleanup. */
pauseGame() {
if (this._readyOverlay) {
this._wasOnReadyScreen = true;
this._cleanupReadyScreen();
}
this.scene.pause();
this.sound.pauseAll();
}
@@ -673,6 +699,7 @@ export class BaseScene extends Phaser.Scene {
* Auto-animates in/out and removes itself after ~2.2 seconds.
*/
showWaveBanner(waveNum) {
this.cleanupWaveBanner();
const existing = document.getElementById('wave-banner');
if (existing)
existing.remove();
@@ -697,6 +724,7 @@ export class BaseScene extends Phaser.Scene {
`;
banner.textContent = `WAVE ${waveNum}`;
document.body.appendChild(banner);
this.waveBanner = banner;
if (!document.getElementById('wave-banner-style')) {
const style = document.createElement('style');
style.id = 'wave-banner-style';
@@ -706,11 +734,19 @@ export class BaseScene extends Phaser.Scene {
`;
document.head.appendChild(style);
}
setTimeout(() => {
this.waveBannerTimer = window.setTimeout(() => {
banner.style.animation = 'waveBannerOut 0.6s ease-in forwards';
setTimeout(() => banner.remove(), 700);
this.waveBannerTimer = window.setTimeout(() => this.cleanupWaveBanner(), 700);
}, 1500);
}
cleanupWaveBanner() {
if (this.waveBannerTimer !== undefined) {
clearTimeout(this.waveBannerTimer);
this.waveBannerTimer = undefined;
}
this.waveBanner?.remove();
this.waveBanner = null;
}
/** Create the shared 'spark' texture used for particle effects. */
ensureSparkTexture() {
if (this.textures.exists('spark'))
@@ -728,13 +764,12 @@ export class BaseScene extends Phaser.Scene {
createStarfield(layers) {
const stars = [];
for (const l of layers) {
const gfx = this.add.graphics().setDepth(-9);
gfx.fillStyle(0xffffff, l.alpha);
for (let i = 0; i < l.count; i++) {
const gfx = this.add.graphics();
const x = Math.random() * W;
const y = Math.random() * H;
gfx.fillStyle(0xffffff, l.alpha);
gfx.fillCircle(0, 0, l.size);
gfx.setPosition(x, y).setDepth(-9);
gfx.fillCircle(x, y, l.size);
stars.push({ x, y, speed: l.speed, size: l.size, alpha: l.alpha, gfx });
}
}
@@ -742,18 +777,30 @@ export class BaseScene extends Phaser.Scene {
}
/** Update parallax starfield positions (call from update). */
updateStarfield(stars, dt) {
if (dt === 0)
return;
const seconds = dt / 1000;
let currentGraphics = null;
// createStarfield stores each layer contiguously, so no regrouping is needed.
for (const s of stars) {
s.y += s.speed * (dt / 1000);
if (s.speed === 0)
continue;
s.y += s.speed * seconds;
if (s.y > H)
s.y -= H;
s.gfx.setPosition(s.x, s.y);
if (s.gfx !== currentGraphics) {
currentGraphics = s.gfx;
currentGraphics.clear();
currentGraphics.fillStyle(0xffffff, s.alpha);
}
currentGraphics.fillCircle(s.x, s.y, s.size);
}
}
/** Clean up timers and listeners on scene shutdown. */
shutdown() {
if (this.scoreAnimTimer) {
clearInterval(this.scoreAnimTimer);
this.scoreAnimTimer = undefined;
if (this.scoreAnimFrame) {
cancelAnimationFrame(this.scoreAnimFrame);
this.scoreAnimFrame = undefined;
}
if (this.gameOverKeyListener) {
document.removeEventListener('keydown', this.gameOverKeyListener);
@@ -764,6 +811,7 @@ export class BaseScene extends Phaser.Scene {
this._gameOverDelayTimer = null;
}
this._cleanupReadyScreen();
this.cleanupWaveBanner();
this._readyOnStart = undefined;
this._wasOnReadyScreen = false;
this.time.removeAllEvents();
@@ -128,7 +128,9 @@ export class CosmicRocksScene extends BaseScene {
if (this.shipAlive) {
this.updateShipInput(dtSec);
this.updateShipPhysics(dtSec);
this.drawShip();
this.shipGfx.setPosition(this.shipX, this.shipY);
this.shipGfx.setRotation(this.shipAngle);
this.drawThrust();
}
this.updateBullets(dtSec);
this.updateAsteroids(dtSec);
@@ -157,6 +159,7 @@ export class CosmicRocksScene extends BaseScene {
this.shipGfx = this.add.graphics().setDepth(10);
this.thrustGfx = this.add.graphics().setDepth(9);
this.drawShip();
this.shipGfx.setRotation(this.shipAngle);
}
updateShipInput(dtSec) {
if (!this.cursors)
@@ -197,13 +200,11 @@ export class CosmicRocksScene extends BaseScene {
const g = this.shipGfx;
g.clear();
g.setPosition(this.shipX, this.shipY);
const cos = Math.cos(this.shipAngle);
const sin = Math.sin(this.shipAngle);
const s = SHIP_SIZE;
// Triangle ship
const nose = { x: cos * s, y: sin * s };
const leftWing = { x: Math.cos(this.shipAngle + 2.4) * s * 0.85, y: Math.sin(this.shipAngle + 2.4) * s * 0.85 };
const rightWing = { x: Math.cos(this.shipAngle - 2.4) * s * 0.85, y: Math.sin(this.shipAngle - 2.4) * s * 0.85 };
// Draw once in local coordinates; movement and rotation use the Graphics transform.
const nose = { x: s, y: 0 };
const leftWing = { x: Math.cos(2.4) * s * 0.85, y: Math.sin(2.4) * s * 0.85 };
const rightWing = { x: leftWing.x, y: -leftWing.y };
// Dark shadow backdrop for visibility on light backgrounds
g.lineStyle(6, 0x000000, 0.5);
g.beginPath();
@@ -228,10 +229,14 @@ export class CosmicRocksScene extends BaseScene {
g.lineTo(rightWing.x, rightWing.y);
g.closePath();
g.strokePath();
// Thrust flame
}
drawThrust() {
const tg = this.thrustGfx;
tg.clear();
if (this.cursors && this.cursors.up.isDown) {
const s = SHIP_SIZE;
const cos = Math.cos(this.shipAngle);
const sin = Math.sin(this.shipAngle);
tg.setPosition(this.shipX, this.shipY);
const tailLen = s * (0.6 + Math.random() * 0.4);
const tailX = -cos * tailLen;
@@ -298,7 +303,6 @@ export class CosmicRocksScene extends BaseScene {
vx: Math.cos(this.shipAngle) * BULLET_SPEED,
vy: Math.sin(this.shipAngle) * BULLET_SPEED,
life: BULLET_LIFE,
color,
});
}
updateBullets(dtSec) {
@@ -393,7 +397,6 @@ export class CosmicRocksScene extends BaseScene {
sizeIdx,
rotation: 0,
rotSpeed: (Math.random() - 0.5) * 2,
vertices,
});
}
drawAsteroid(gfx, vertices) {
@@ -543,7 +546,7 @@ export class CosmicRocksScene extends BaseScene {
const gfx = this.add.graphics().setDepth(12);
this.drawUfo(gfx);
gfx.setPosition(x, y);
this.ufo = { gfx, x, y, vx, shootTimer: 1500 + Math.random() * 1000, active: true };
this.ufo = { gfx, x, y, vx, shootTimer: 1500 + Math.random() * 1000 };
}
drawUfo(gfx) {
gfx.clear();
@@ -580,6 +583,7 @@ export class CosmicRocksScene extends BaseScene {
if ((u.vx > 0 && u.x > W + 60) || (u.vx < 0 && u.x < -60)) {
u.gfx.destroy();
this.ufo = null;
this.updateUfoBullets(dtSec);
return;
}
// Shoot at player
@@ -619,11 +623,9 @@ export class CosmicRocksScene extends BaseScene {
}
}
checkUfoCollisions() {
if (!this.ufo)
return;
const u = this.ufo;
let u = this.ufo;
// Player bullets vs UFO
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
for (let bi = this.bullets.length - 1; u && bi >= 0; bi--) {
const b = this.bullets[bi];
const dx = b.x - u.x;
const dy = b.y - u.y;
@@ -635,7 +637,7 @@ export class CosmicRocksScene extends BaseScene {
this.sound.play('sfx_zap', { volume: 0.4 });
u.gfx.destroy();
this.ufo = null;
return;
break;
}
}
// UFO bullets vs player
@@ -653,7 +655,8 @@ export class CosmicRocksScene extends BaseScene {
}
}
// UFO body vs player
if (this.shipAlive && this.invincibleTimer <= 0) {
u = this.ufo;
if (u && this.shipAlive && this.invincibleTimer <= 0) {
const dx = this.shipX - u.x;
const dy = this.shipY - u.y;
if (dx * dx + dy * dy < (SHIP_SIZE * 1.8) ** 2) {
@@ -3,6 +3,7 @@
// De Casteljau bezier smoothing, hop+figure-eight attack patterns.
// Phaser sprites used ONLY for rendering (setPosition, setRotation, destroy).
import { BaseScene, W, H } from './BaseScene.js';
import { getGalaxyLayout } from '../layout.js';
function overlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x &&
a.y < b.y + b.h && a.y + a.h > b.y;
@@ -13,23 +14,23 @@ function computeDistance(a, b) {
/* ------------------------------------------------------------------ */
/* De Casteljau bezier — exact port from PathFollower.ts */
/* ------------------------------------------------------------------ */
function getBezierPoint(t, points) {
if (points.length === 1)
return { x: points[0].x, y: points[0].y };
const next = [];
for (let i = 0; i < points.length - 1; i++) {
next.push({
x: (1 - t) * points[i].x + t * points[i + 1].x,
y: (1 - t) * points[i].y + t * points[i + 1].y,
});
}
return getBezierPoint(t, next);
}
function generatePointsOnBezierCurve(points, numOfPoints) {
const bezierPoints = [];
const scratch = points.map(point => ({ x: point.x, y: point.y }));
for (let i = 0; i <= numOfPoints; i++) {
const t = i / numOfPoints;
bezierPoints.push(getBezierPoint(t, points));
for (let j = 0; j < points.length; j++) {
scratch[j].x = points[j].x;
scratch[j].y = points[j].y;
}
// Keep De Casteljau's interpolation order without allocating every recursive level.
for (let remaining = points.length - 1; remaining > 0; remaining--) {
for (let j = 0; j < remaining; j++) {
scratch[j].x = (1 - t) * scratch[j].x + t * scratch[j + 1].x;
scratch[j].y = (1 - t) * scratch[j].y + t * scratch[j + 1].y;
}
}
bezierPoints.push({ x: scratch[0].x, y: scratch[0].y });
}
return bezierPoints;
}
@@ -63,8 +64,7 @@ function waveDef(n) {
/* ------------------------------------------------------------------ */
let CONV_X = W / 500;
let CONV_Y = H / 500;
let SCALE = Math.min(CONV_X, CONV_Y);
let OPPONENT_SIZE = Math.min(32 * SCALE, W / 35);
let { scale: SCALE, opponentSize: OPPONENT_SIZE } = getGalaxyLayout(W, H);
let ENTRY_SPEED = 0.4 * SCALE; // px/ms
let ATTACK_SPEED = 0.3 * SCALE; // px/ms
const ENTRANCE_INTERVAL = 100; // ms between spawns in a trail
@@ -328,7 +328,7 @@ export class GalaxyBlasterScene extends BaseScene {
ship;
shipX = W / 2;
shipVx = 0;
shipY = H - OPPONENT_SIZE * 3;
shipY = 0;
bullets = [];
invincible = 0;
/* shield */
@@ -359,7 +359,6 @@ export class GalaxyBlasterScene extends BaseScene {
waveDelay = 0;
spawnQueue = [];
spawnTimer = 0;
waveTextSprite = null;
/* starfield */
stars = [];
/* input */
@@ -400,8 +399,7 @@ export class GalaxyBlasterScene extends BaseScene {
// Recalculate screen-dependent constants now that W/H are correct
CONV_X = W / 500;
CONV_Y = H / 500;
SCALE = Math.min(CONV_X, CONV_Y);
OPPONENT_SIZE = Math.min(32 * SCALE, W / 35);
({ scale: SCALE, opponentSize: OPPONENT_SIZE } = getGalaxyLayout(W, H));
ENTRY_SPEED = 0.4 * SCALE;
ATTACK_SPEED = 0.3 * SCALE;
SHIP_SPEED = 0.25 * 1000 * SCALE;
@@ -428,6 +426,7 @@ export class GalaxyBlasterScene extends BaseScene {
this.invincible = 0;
this.gameOver = false;
this.shipX = W / 2;
this.shipY = H - OPPONENT_SIZE * 3;
this.shipVx = 0;
this.shieldActive = false;
if (this.shieldSprite && this.shieldSprite.active) {
@@ -581,12 +580,6 @@ export class GalaxyBlasterScene extends BaseScene {
this.driftTimer = 0;
this.allStationary = false;
this.offsetLerping = false;
// Clean up old wave text sprite if any
if (this.waveTextSprite) {
this.tweens.killTweensOf(this.waveTextSprite);
this.waveTextSprite.destroy();
this.waveTextSprite = null;
}
this.showWaveBanner(this.wave);
}
updateWave(dt) {
@@ -635,10 +628,8 @@ export class GalaxyBlasterScene extends BaseScene {
activePath: entryPath,
pathIndex: 0,
speed: ENTRY_SPEED,
breathTimer: 0,
breathingOffsetX: 0,
breathingOffsetY: 0,
attackPath: [],
shotsFired: 0,
shotTimer: 0,
};
@@ -776,7 +767,6 @@ export class GalaxyBlasterScene extends BaseScene {
e.breathingOffsetX += dir * ((e.restingPosX - W / 2) / (W / 2)) * 0.3 * dtScale;
e.breathingOffsetY += dir * (e.restingPosY / (H / 2)) * 0.4 * dtScale;
}
// activePath is set to attackPath when dive starts
this.followPath(e, dt, () => {
// Attack complete — return to formation / breathing state
e.pos.x = e.restingPosX + e.breathingOffsetX;
@@ -818,8 +808,6 @@ export class GalaxyBlasterScene extends BaseScene {
if (candidates.length === 0)
return;
const e = candidates[Math.floor(Math.random() * candidates.length)];
if (e.state === 'entrance')
return; // guard against race condition
// Remember breathing state as secondary so it continues independently
if (e.state === 'breathe-in' || e.state === 'breathe-out') {
e.secondaryState = e.state;
@@ -829,7 +817,6 @@ export class GalaxyBlasterScene extends BaseScene {
}
e.state = 'attack';
const atkPath = getAttackPath({ x: e.pos.x, y: e.pos.y });
e.attackPath = atkPath;
e.activePath = atkPath;
e.pathIndex = 0;
e.speed = ATTACK_SPEED;
@@ -1137,6 +1124,7 @@ export class GalaxyBlasterScene extends BaseScene {
const pu = this.dualShotPickups[i];
pu.sprite.y += pu.vy * (dt / 1000);
if (pu.sprite.y > H) {
this.tweens.killTweensOf(pu.sprite);
pu.sprite.destroy();
this.dualShotPickups.splice(i, 1);
continue;
@@ -1144,6 +1132,7 @@ export class GalaxyBlasterScene extends BaseScene {
const dx = Math.abs(pu.sprite.x - this.shipX);
const dy = Math.abs(pu.sprite.y - this.shipY);
if (dx < OPPONENT_SIZE * 0.8 && dy < OPPONENT_SIZE * 0.8) {
this.tweens.killTweensOf(pu.sprite);
pu.sprite.destroy();
this.dualShotPickups.splice(i, 1);
this.activateDualShot();
@@ -1287,11 +1276,13 @@ export class GalaxyBlasterScene extends BaseScene {
this.destroyObj(this.shieldSprite);
this.shieldSprite = undefined;
for (const p of this.shieldPickups)
this.destroyObj(p);
this.destroyObj(p.sprite);
this.shieldPickups = [];
// Destroy dual-shot pickups and glow
for (const p of this.dualShotPickups)
for (const p of this.dualShotPickups) {
this.tweens.killTweensOf(p.sprite);
this.destroyObj(p.sprite);
}
this.dualShotPickups = [];
this.destroyObj(this.dualShotGlow);
this.dualShotGlow = undefined;
@@ -1299,9 +1290,6 @@ export class GalaxyBlasterScene extends BaseScene {
for (const m of this.meteors)
this.destroyObj(m.sprite);
this.meteors = [];
// Destroy wave text
this.destroyObj(this.waveTextSprite);
this.waveTextSprite = null;
}
}
//# sourceMappingURL=GalaxyBlaster.js.map
@@ -6,8 +6,9 @@ const BLOCK = 48; // logical world tile size
const PLAYER_W = 48; // player draw size
const PLAYER_H = 48; // player draw size
const SPAWN_X = 600;
// Computed dynamically so it uses the correct H after refreshDimensions()
function getGroundY() { return H - BLOCK; }
const WORLD_WIDTH = 1_000_000;
const TRAILING_SCREENS = 2;
const OWNED_EFFECT_KEYS = ['manualGlow', 'sparks', 'embers', 'warnEmbers', 'warnGlow'];
let GROUND_Y = H - BLOCK; // will be updated in create()
export class NinjaRunnerScene extends BaseScene {
// Input
@@ -31,11 +32,12 @@ export class NinjaRunnerScene extends BaseScene {
jumpBuffer = 0; // ms left where a queued jump press will fire on landing
canDoubleJump = false;
hasDoubleJumped = false;
// Animation: cycle the run frame based on distance traveled, not wall time,
// so step rhythm matches actual movement speed.
runDistance = 0;
// Generation
genX = 0;
furthestCameraX = 0;
terrainStartX = 0;
terrainDecorations;
worldGroups = [];
// Groups
groundGroup;
brickGroup;
@@ -63,7 +65,6 @@ export class NinjaRunnerScene extends BaseScene {
parachuteFlyingEnemies = [];
parachuteTimer = 0;
windSound;
glowSprite;
constructor() { super('ninja-runner'); }
get displayName() { return 'Ninja Runner'; }
getDescription() {
@@ -89,8 +90,6 @@ export class NinjaRunnerScene extends BaseScene {
this.load.spritesheet('player', '../assets/ninja-runner/player_strip.png', { frameWidth: 16, frameHeight: 16 });
// Enemy spritesheet: 5 frames of 16×16
this.load.spritesheet('enemy', '../assets/ninja-runner/enemy_strip.png', { frameWidth: 16, frameHeight: 16 });
// Coin animation: 4 frames of 16×16
this.load.spritesheet('coin_anim', '../assets/ninja-runner/coin_sheet.png', { frameWidth: 16, frameHeight: 16 });
// Heart pickup
this.load.spritesheet('heart_anim', '../assets/ninja-runner/heart_sheet.png', { frameWidth: 16, frameHeight: 16 });
// Tile textures
@@ -98,12 +97,9 @@ export class NinjaRunnerScene extends BaseScene {
this.load.image('dirt_block', '../assets/ninja-runner/dirt_block.png');
this.load.image('brown_block', '../assets/ninja-runner/brown_block.png');
this.load.image('qblock_img', '../assets/ninja-runner/qblock_new.png');
this.load.image('platform_tile', '../assets/ninja-runner/platform.png');
this.load.image('spikes_tile', '../assets/ninja-runner/spikes.png');
this.load.image('flag_tile', '../assets/ninja-runner/flag.png');
this.load.image('bridge_tile', '../assets/ninja-runner/bridge.png');
this.load.image('impact', '../assets/ninja-runner/impact_sheet.png');
this.load.image('clouds', '../assets/ninja-runner/clouds.png');
this.load.image('hill_0', '../assets/ninja-runner/hill_0.png');
this.load.image('hill_1', '../assets/ninja-runner/hill_1.png');
this.load.image('big_bush', '../assets/ninja-runner/big_bush.png');
@@ -122,8 +118,6 @@ export class NinjaRunnerScene extends BaseScene {
this.load.audio('nr_bounce', '../assets/ninja-runner/sounds/SoundBounce.m4a');
this.load.audio('nr_startlevel', '../assets/ninja-runner/sounds/SoundStartLevel.m4a');
this.load.audio('nr_gameover', '../assets/ninja-runner/sounds/SoundGameOver.m4a');
this.load.audio('nr_land', '../assets/ninja-runner/sounds/SoundLand1.m4a');
this.load.audio('nr_flap', '../assets/ninja-runner/sounds/SoundFlapLight.m4a');
this.load.audio('nr_warp', '../assets/ninja-runner/sounds/SoundOpenDoor.m4a');
this.load.audio('nr_fireball', '../assets/ninja-runner/sounds/SoundShootRegular.m4a');
this.load.audio('nr_explosion', '../assets/ninja-runner/sounds/SoundExplosionSmall.m4a');
@@ -132,11 +126,37 @@ export class NinjaRunnerScene extends BaseScene {
}
create() {
this.initBase();
this.score = 0;
this.lives = 3;
this.isBig = false;
this.facingRight = true;
this.invincible = 1500;
this.shrinkTimer = 0;
this.stompGrace = 0;
this.dead = false;
this.deadTimer = 0;
this.lastSafeX = SPAWN_X;
this.fireCooldown = 0;
this.jumpKeyWasDown = false;
this.coyoteTime = 0;
this.jumpBuffer = 0;
this.canDoubleJump = false;
this.hasDoubleJumped = false;
this.genX = 0;
this.furthestCameraX = 0;
this.terrainStartX = 0;
this.gaps = [];
this.currentLevel = 1;
this.currentBiome = 0;
this.distanceSinceFlag = 0;
this.warping = false;
this.parachuteMode = false;
this.parachuteTimer = 0;
// Recompute GROUND_Y from the actual game height (H may have been
// refreshed after module load by refreshDimensions in game.ts)
GROUND_Y = H - BLOCK;
this.makeBlockTextures();
this.physics.world.setBounds(0, 0, 1_000_000, H);
this.physics.world.setBounds(0, 0, WORLD_WIDTH, H);
this.groundGroup = this.physics.add.staticGroup();
this.brickGroup = this.physics.add.staticGroup();
this.qblockGroup = this.physics.add.staticGroup();
@@ -153,6 +173,14 @@ export class NinjaRunnerScene extends BaseScene {
this.fireGroup = this.physics.add.group({ allowGravity: false });
this.crocGroup = this.physics.add.group({ allowGravity: false });
this.fishGroup = this.physics.add.group({ allowGravity: false });
this.terrainDecorations = this.add.group();
this.worldGroups = [
this.groundGroup, this.brickGroup, this.qblockGroup, this.pipeGroup,
this.coinGroup, this.mushroomGroup, this.heartGroup, this.fireballGroup,
this.enemyGroup, this.bridgeGroup, this.bounceGroup, this.flagGroup,
this.piranhaGroup, this.fireGroup, this.crocGroup, this.fishGroup,
this.terrainDecorations,
];
// Initial ground
this.extendGround(0, W * 2);
// Player — spritesheet frame 0 = idle
@@ -172,18 +200,6 @@ export class NinjaRunnerScene extends BaseScene {
frameRate: 10,
repeat: -1,
});
this.anims.create({
key: 'player_idle',
frames: [{ key: 'player', frame: 0 }],
frameRate: 1,
});
// Coin spin animation
this.anims.create({
key: 'coin_spin',
frames: this.anims.generateFrameNumbers('coin_anim', { start: 0, end: 3 }),
frameRate: 8,
repeat: -1,
});
// Heart pulse animation
this.anims.create({
key: 'heart_pulse',
@@ -211,7 +227,7 @@ export class NinjaRunnerScene extends BaseScene {
repeat: -1,
});
// Camera
this.cameras.main.setBounds(0, 0, 1_000_000, H);
this.cameras.main.setBounds(0, 0, WORLD_WIDTH, H);
this.cameras.main.startFollow(this.player, true, 0.15, 0.05, -W * 0.2, 0);
this.cameras.main.setBackgroundColor('rgba(0,0,0,0)');
// Colliders
@@ -241,7 +257,7 @@ export class NinjaRunnerScene extends BaseScene {
this.physics.add.overlap(this.player, this.fireGroup, this.onPlayerFire, undefined, this);
this.physics.add.overlap(this.player, this.crocGroup, this.onPlayerCroc, undefined, this);
this.physics.add.overlap(this.player, this.fishGroup, this.onPlayerFish, undefined, this);
this.physics.add.collider(this.player, this.bridgeGroup, this.onPlayerBridge, undefined, this);
this.physics.add.collider(this.player, this.bridgeGroup);
this.physics.add.collider(this.enemyGroup, this.bridgeGroup);
this.physics.add.overlap(this.player, this.flagGroup, this.onPlayerFlag, undefined, this);
this.physics.add.collider(this.player, this.bounceGroup, this.onPlayerBounce, undefined, this);
@@ -258,9 +274,8 @@ export class NinjaRunnerScene extends BaseScene {
this.addDecorations();
this.generateLevel(SPAWN_X + 400, W + 600);
this.syncLivesToHUD();
this.syncScoreToHUD();
this.loadHighScore();
this.distanceSinceFlag = 0;
this.currentLevel = 1;
this.syncLevelToHUD(this.currentLevel);
this.sfx('nr_startlevel', 0.25);
this.startWithReadyScreen();
@@ -352,39 +367,6 @@ export class NinjaRunnerScene extends BaseScene {
g.fillStyle(0x000000);
g.fillRect(8, 12, 16, 3);
g.generateTexture('piranha_1', 32, 32);
// Power-up glow effect
g.clear();
g.fillStyle(0xffdd00, 0.3);
g.fillCircle(20, 20, 20);
g.fillStyle(0xffff88, 0.2);
g.fillCircle(20, 20, 14);
g.generateTexture('glow', 40, 40);
// Individual cloud puffs (3 sizes for variety)
g.clear();
g.fillStyle(0xffffff);
g.fillCircle(10, 10, 8);
g.fillCircle(20, 8, 10);
g.fillCircle(32, 10, 9);
g.fillCircle(16, 14, 7);
g.fillCircle(26, 14, 8);
g.generateTexture('cloud_sm', 42, 22);
g.clear();
g.fillStyle(0xffffff);
g.fillCircle(14, 14, 12);
g.fillCircle(30, 10, 14);
g.fillCircle(48, 14, 11);
g.fillCircle(22, 18, 10);
g.fillCircle(38, 18, 12);
g.generateTexture('cloud_md', 60, 28);
g.clear();
g.fillStyle(0xffffff);
g.fillCircle(16, 16, 14);
g.fillCircle(36, 12, 16);
g.fillCircle(58, 14, 13);
g.fillCircle(24, 22, 12);
g.fillCircle(46, 20, 14);
g.fillCircle(70, 16, 10);
g.generateTexture('cloud_lg', 82, 32);
// Green bat enemy — flies in a wave pattern
g.clear();
g.fillStyle(0x22aa44);
@@ -434,14 +416,6 @@ export class NinjaRunnerScene extends BaseScene {
g.clear();
const cw = 64, ch = 80;
const domeBottom = 36; // y where the canopy ends
// Draw dome as upper half only — fill a tall ellipse then cover the bottom half
g.fillStyle(0xff2020);
g.fillEllipse(cw / 2, domeBottom, cw - 4, 56); // tall ellipse centered at rim
// Cover lower half so only the dome (upper half) remains
g.fillStyle(0x000000, 0.0);
// We can't erase, so draw the dome differently:
// Use a filled arc approach — draw overlapping circles for dome shape
g.clear();
// Red canopy dome — build with filled upper-half ellipse
// Panel 1 (red) — left
g.fillStyle(0xff2020);
@@ -662,7 +636,7 @@ export class NinjaRunnerScene extends BaseScene {
const tex = isSmall ? 'hill_0' : 'hill_1';
const hh = isSmall ? 64 : 96;
this.add.image(hx, GROUND_Y - hh / 2 + 10, tex)
.setDisplaySize(isSmall ? 64 : 64, hh)
.setDisplaySize(64, hh)
.setAlpha(0.12)
.setScrollFactor(0.3)
.setDepth(-2);
@@ -680,12 +654,19 @@ export class NinjaRunnerScene extends BaseScene {
}
}
extendGround(fromX, toX) {
for (let x = Math.floor(fromX / BLOCK) * BLOCK; x < toX; x += BLOCK) {
// Include off-grid platforms and retain the original one-pixel tolerance.
const existingXs = this.groundGroup.getChildren()
.map((ground) => ground.x)
.sort((a, b) => a - b);
let existingIndex = 0;
for (let x = Math.floor(Math.max(fromX, this.terrainStartX) / BLOCK) * BLOCK; x < toX; x += BLOCK) {
if (this.isInGap(x + BLOCK / 2))
continue;
// skip if already there
const exists = this.groundGroup.getChildren().some((g) => Math.abs(g.x - (x + BLOCK / 2)) < 1);
if (exists)
const centerX = x + BLOCK / 2;
while (existingIndex < existingXs.length && existingXs[existingIndex] <= centerX - 1) {
existingIndex++;
}
if (existingIndex < existingXs.length && Math.abs(existingXs[existingIndex] - centerX) < 1)
continue;
const g = this.groundGroup.create(x + BLOCK / 2, GROUND_Y + BLOCK / 2, 'grass_block');
g.setDisplaySize(BLOCK, BLOCK);
@@ -729,10 +710,12 @@ export class NinjaRunnerScene extends BaseScene {
const w = this.add.image(cx, startY + row * BLOCK + BLOCK / 2, 'water');
w.setDisplaySize(BLOCK, BLOCK);
w.setDepth(-1);
this.terrainDecorations.add(w);
}
}
}
generateLevel(lo, hi) {
lo = Math.max(lo, this.terrainStartX);
let x = Math.max(lo, this.genX);
let lastPattern = -1;
while (x < hi) {
@@ -1081,6 +1064,7 @@ export class NinjaRunnerScene extends BaseScene {
const spike = this.add.image(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.3, 'spikes_tile');
spike.setDisplaySize(BLOCK, BLOCK * 0.6);
spike.setDepth(2);
this.terrainDecorations.add(spike);
const hitZone = this.fireGroup.create(sx + BLOCK / 2, GROUND_Y - BLOCK * 0.2, 'spikes_tile');
hitZone.setDisplaySize(BLOCK * 0.9, BLOCK * 0.4);
hitZone.setAlpha(0);
@@ -1241,6 +1225,7 @@ export class NinjaRunnerScene extends BaseScene {
bush.setOrigin(0.5, 1);
bush.setDepth(1);
bush.setAlpha(0.8);
this.terrainDecorations.add(bush);
}
// Scatter ground-level coin trails between obstacles (skip coins near pipes)
for (let cx = lo; cx < hi; cx += BLOCK * 8 + Math.floor(Math.random() * BLOCK * 6)) {
@@ -1292,6 +1277,7 @@ export class NinjaRunnerScene extends BaseScene {
const pole = this.add.image(flagX, GROUND_Y - i * BLOCK - BLOCK / 2, 'brown_block');
pole.setDisplaySize(BLOCK * 0.3, BLOCK);
pole.setDepth(1);
this.terrainDecorations.add(pole);
}
const flag = this.flagGroup.create(flagX, GROUND_Y - BLOCK * 3 + BLOCK / 2, 'flag_tile');
flag.setDisplaySize(BLOCK, BLOCK * 1.5);
@@ -1384,6 +1370,7 @@ export class NinjaRunnerScene extends BaseScene {
this.stompGrace -= dtMs;
if (this.fireCooldown > 0)
this.fireCooldown -= dtMs;
this.trimWorld();
this.updatePlayerMovement(dtMs);
if (this.player.y > H + 50) {
this.die();
@@ -1395,7 +1382,7 @@ export class NinjaRunnerScene extends BaseScene {
this.updatePlayerAnimation();
const camLeft = this.cameras.main.scrollX;
this.updateCoins(camLeft);
this.updateBridges(camLeft);
this.updateBridges();
this.updateFireEruptions(camLeft);
this.updatePiranhas(dtMs, camLeft);
this.enemyGroup.getChildren().forEach(e => this.updateEnemy(e, camLeft));
@@ -1580,9 +1567,9 @@ export class NinjaRunnerScene extends BaseScene {
this.throwFireball();
this.fireCooldown = 200;
}
const camLeft = this.cameras.main.scrollX;
if (this.player.x < camLeft) {
this.player.x = camLeft;
const leftLimit = Math.max(this.cameras.main.scrollX, this.terrainStartX);
if (this.player.x < leftLimit) {
this.player.x = leftLimit;
this.player.setVelocityX(0);
}
if (onGround && !this.isInGap(this.player.x)) {
@@ -1625,13 +1612,6 @@ export class NinjaRunnerScene extends BaseScene {
const walkAnim = 'player_walk';
// Scale — always same size, glow indicates power-up
this.player.setDisplaySize(PLAYER_W, PLAYER_H);
// Pulse the built-in glow FX when powered up
if (this.player.getData('hasGlow')) {
const glowFx = this.player.getData('glowFx');
if (glowFx) {
glowFx.outerStrength = this.isBig ? 2 + Math.sin(this.time.now / 200) * 1.5 : 0;
}
}
// Ensure correct texture
if (this.player.texture.key !== sheetKey) {
this.player.setTexture(sheetKey, 0);
@@ -1681,14 +1661,15 @@ export class NinjaRunnerScene extends BaseScene {
}
}
updateCoins(camLeft) {
this.coinGroup.getChildren().forEach(c => {
const i = Math.floor(this.time.now / 120) % 2;
c.setTexture(i === 0 ? 'coin0' : 'coin1');
const texture = Math.floor(this.time.now / 120) % 2 === 0 ? 'coin0' : 'coin1';
for (const c of this.coinGroup.getChildren()) {
if (c.texture.key !== texture)
c.setTexture(texture);
if (c.x < camLeft - 100)
c.destroy();
});
this.destroyWorldObject(c);
}
}
updateBridges(camLeft) {
updateBridges() {
// Bridge collapse — unstable tiles start falling when player approaches
this.bridgeGroup.getChildren().forEach((bt) => {
if (!bt.active || !bt.getData('unstable') || bt.getData('collapsing'))
@@ -1905,24 +1886,7 @@ export class NinjaRunnerScene extends BaseScene {
}
}
if (f.x < camLeft - 200) {
const gl = f.getData('manualGlow');
if (gl)
gl.destroy();
const sp = f.getData('sparks');
if (sp)
sp.destroy();
const emb = f.getData('embers');
if (emb)
emb.destroy();
const we = f.getData('warnEmbers');
if (we)
we.destroy();
const wg = f.getData('warnGlow');
if (wg) {
this.tweens.killTweensOf(wg);
wg.destroy();
}
f.destroy();
this.destroyWorldObject(f);
}
});
}
@@ -2003,19 +1967,46 @@ export class NinjaRunnerScene extends BaseScene {
}
});
}
destroyWorldObject(object) {
this.tweens.killTweensOf(object);
for (const key of OWNED_EFFECT_KEYS) {
const effect = object.getData(key);
if (effect) {
this.tweens.killTweensOf(effect);
effect.destroy();
}
}
object.destroy();
}
trimWorld() {
this.furthestCameraX = Math.max(this.furthestCameraX, this.cameras.main.scrollX);
const cutoff = Math.floor(Math.max(0, this.furthestCameraX - W * TRAILING_SCREENS) / BLOCK) * BLOCK;
if (cutoff <= this.terrainStartX)
return;
// Advance only at tile boundaries. Returning left must not recreate deleted terrain.
this.terrainStartX = cutoff;
this.cameras.main.setBounds(cutoff, 0, WORLD_WIDTH - cutoff, H);
for (const group of this.worldGroups) {
for (const object of group.getChildren()) {
if (object.x + object.displayWidth <= cutoff)
this.destroyWorldObject(object);
}
}
this.gaps = this.gaps.filter(gap => gap.end > cutoff);
}
cleanupOffscreen(camLeft) {
this.mushroomGroup.getChildren().forEach(m => {
if (m.x < camLeft - 100 || m.y > H + 100)
m.destroy();
this.destroyWorldObject(m);
});
this.fireballGroup.getChildren().forEach(fb => {
if (fb.x < camLeft - 100 || fb.x > camLeft + W + 200 || fb.y > H + 50)
fb.destroy();
this.destroyWorldObject(fb);
});
// Fish cleanup — destroy when scrolled offscreen
this.fishGroup.getChildren().forEach((fish) => {
if (fish.x < camLeft - 200)
fish.destroy();
this.destroyWorldObject(fish);
});
}
updateEnemy(e, camLeft) {
@@ -2221,7 +2212,7 @@ export class NinjaRunnerScene extends BaseScene {
}
}
onPlayerHeart(_player, h) {
h.destroy();
this.destroyWorldObject(h);
this.lives++;
this.syncLivesToHUD();
this.addScore(2000, h.x, h.y - 10);
@@ -2395,6 +2386,8 @@ export class NinjaRunnerScene extends BaseScene {
const deathX = Math.max(this.lastSafeX, this.cameras.main.scrollX + 200);
// Find a safe spot — search BACKWARD first to respawn before the hazard
const isSafe = (wx) => {
if (wx < this.terrainStartX)
return false;
if (this.isInGap(wx))
return false;
if (this.isNearObstacle(wx))
@@ -2459,9 +2452,6 @@ export class NinjaRunnerScene extends BaseScene {
}
this.doRespawn();
}
onPlayerBridge(_player, _tile) {
// Collision still needed for standing — collapse is handled by proximity in update
}
onPlayerBounce(_player, pad) {
if (!this.player.body.touching.down)
return;
@@ -2753,7 +2743,7 @@ export class NinjaRunnerScene extends BaseScene {
// Stop wind sound
if (this.windSound) {
try {
this.windSound.stop();
this.windSound.destroy();
}
catch { }
this.windSound = undefined;
@@ -2775,29 +2765,22 @@ export class NinjaRunnerScene extends BaseScene {
shutdown() {
super.shutdown();
// Destroy all physics groups and their children
const groups = [
this.groundGroup, this.brickGroup, this.qblockGroup, this.pipeGroup,
this.coinGroup, this.mushroomGroup, this.heartGroup, this.fireballGroup,
this.enemyGroup, this.bridgeGroup, this.bounceGroup, this.flagGroup,
this.piranhaGroup, this.fireGroup, this.crocGroup, this.fishGroup,
];
for (const g of groups) {
if (g && g.clear)
try {
g.clear(true, true);
}
catch { }
for (const group of this.worldGroups) {
// Phaser can dispose a group before the scene's shutdown listener runs.
if (!group.scene)
continue;
for (const object of group.getChildren())
this.destroyWorldObject(object);
}
this.worldGroups = [];
// Destroy player and extra sprites
this.destroyObj(this.player);
this.destroyObj(this.parachuteSprite);
this.parachuteSprite = undefined;
this.destroyObj(this.glowSprite);
this.glowSprite = undefined;
// Stop wind sound
if (this.windSound) {
try {
this.windSound.stop();
this.windSound.destroy();
}
catch { }
this.windSound = undefined;
@@ -1,6 +1,7 @@
// Defender — Classic 1981 Williams side-scrolling shooter.
// Protect humanoids from alien landers across a scrolling terrain world.
import { BaseScene, W, H } from './BaseScene.js';
import { getWavAudioPath } from '../layout.js';
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
@@ -27,170 +28,6 @@ const RESPAWN_SAFE_RADIUS = 300;
const RESPAWN_SAFE_RADIUS_BAITER = 600;
const RESPAWN_PUSH_OFFSET = 150;
/* ------------------------------------------------------------------ */
/* Pixel Art Data — dimensions matched to original ROM sprite list */
/* Reference: https://www.seanriddle.com/defendersprites.txt */
/* ------------------------------------------------------------------ */
// Ship: ROM = 16×6 px (8 bytes × 6 rows)
// From MAME screenshots: sleek profile facing right
// - Tapers at top and bottom (rows 0,5 are narrow)
// - Widest at center rows (1-4)
// - Magenta engine block at rear left
// - White body, cyan nose tip at right
// - Green exhaust pixels at bottom-left
const SHIP_PIXELS = [
// Row 0 — top taper (narrow, no engine visible)
[6, 0, 0xffffff], [7, 0, 0xffffff], [8, 0, 0xffffff], [9, 0, 0xffffff],
[10, 0, 0xffffff], [11, 0, 0xffffff], [12, 0, 0xffffff], [13, 0, 0xffffff],
// Row 1 — wider, engine appears
[2, 1, 0xff00ff], [3, 1, 0xff44ff],
[4, 1, 0xffffff], [5, 1, 0xffffff], [6, 1, 0xffffff], [7, 1, 0xffffff],
[8, 1, 0xffffff], [9, 1, 0xffffff], [10, 1, 0xffffff], [11, 1, 0xffffff],
[12, 1, 0xffffff], [13, 1, 0xffffff], [14, 1, 0xffffff],
// Row 2 — full width (widest), engine + body + nose tip
[0, 2, 0xff00ff], [1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xff44ff],
[4, 2, 0xffffff], [5, 2, 0xffffff], [6, 2, 0xffffff], [7, 2, 0xffffff],
[8, 2, 0xffffff], [9, 2, 0xffffff], [10, 2, 0xffffff], [11, 2, 0xffffff],
[12, 2, 0xffffff], [13, 2, 0xffffff], [14, 2, 0xffffff], [15, 2, 0x00ccff],
// Row 3 — full width (widest), engine + body + nose tip
[0, 3, 0xff00ff], [1, 3, 0xff00ff], [2, 3, 0xff00ff], [3, 3, 0xff44ff],
[4, 3, 0xffffff], [5, 3, 0xffffff], [6, 3, 0xffffff], [7, 3, 0xffffff],
[8, 3, 0xffffff], [9, 3, 0xffffff], [10, 3, 0xffffff], [11, 3, 0xffffff],
[12, 3, 0xffffff], [13, 3, 0xffffff], [14, 3, 0xffffff], [15, 3, 0x00ccff],
// Row 4 — wider, engine appears
[2, 4, 0xff00ff], [3, 4, 0xff44ff],
[4, 4, 0xffffff], [5, 4, 0xffffff], [6, 4, 0xffffff], [7, 4, 0xffffff],
[8, 4, 0xffffff], [9, 4, 0xffffff], [10, 4, 0xffffff], [11, 4, 0xffffff],
[12, 4, 0xffffff], [13, 4, 0xffffff], [14, 4, 0xffffff],
// Row 5 — bottom taper + green exhaust trail
[4, 5, 0xffffff], [5, 5, 0xffffff], [6, 5, 0xffffff], [7, 5, 0xffffff],
[8, 5, 0xffffff], [9, 5, 0xffffff], [10, 5, 0xffffff], [11, 5, 0xffffff],
[0, 5, 0x00ff00], [1, 5, 0x00ff00],
];
// Lander: ROM = 10×8 px (5 bytes × 8 rows)
// H-shaped: diamond body with grabber legs below
const LANDER_PIXELS = [
// Row 0 — top center
[4, 0, 0x00ff00], [5, 0, 0x00ff00],
// Row 1 — upper diamond
[3, 1, 0x00ff00], [4, 1, 0xffff00], [5, 1, 0xffff00], [6, 1, 0x00ff00],
// Row 2 — widest body
[2, 2, 0x00ff00], [3, 2, 0x00ff00], [4, 2, 0x00ff00], [5, 2, 0x00ff00], [6, 2, 0x00ff00], [7, 2, 0x00ff00],
// Row 3 — full width with side detail
[1, 3, 0x00ff00], [2, 3, 0x00ff00], [3, 3, 0xffff00], [4, 3, 0x00ff00], [5, 3, 0x00ff00], [6, 3, 0xffff00], [7, 3, 0x00ff00], [8, 3, 0x00ff00],
// Row 4 — lower body
[2, 4, 0x00ff00], [3, 4, 0x00ff00], [4, 4, 0x00ff00], [5, 4, 0x00ff00], [6, 4, 0x00ff00], [7, 4, 0x00ff00],
// Row 5 — narrowing
[3, 5, 0x00ff00], [4, 5, 0x00ff00], [5, 5, 0x00ff00], [6, 5, 0x00ff00],
// Row 6 — legs
[1, 6, 0xffff00], [2, 6, 0xffff00], [7, 6, 0xffff00], [8, 6, 0xffff00],
// Row 7 — leg tips
[0, 7, 0xffff00], [1, 7, 0xffff00], [8, 7, 0xffff00], [9, 7, 0xffff00],
];
// Mutant: ROM = 10×8 px (5 bytes × 8 rows)
// Composite of lander + humanoid overlay, blobby organic look
const MUTANT_PIXELS = [
// Row 0
[3, 0, 0xff00ff], [4, 0, 0xff00ff], [5, 0, 0xff00ff], [6, 0, 0xff00ff],
// Row 1
[2, 1, 0xff00ff], [3, 1, 0xcc00cc], [4, 1, 0xcc00cc], [5, 1, 0xcc00cc], [6, 1, 0xcc00cc], [7, 1, 0xff00ff],
// Row 2 — yellow-green eyes
[1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xaaff00], [4, 2, 0xff00ff], [5, 2, 0xff00ff], [6, 2, 0xaaff00], [7, 2, 0xff00ff], [8, 2, 0xff00ff],
// Row 3 — widest
[0, 3, 0xff00ff], [1, 3, 0xff00ff], [2, 3, 0xff00ff], [3, 3, 0xff00ff], [4, 3, 0xff00ff], [5, 3, 0xff00ff], [6, 3, 0xff00ff], [7, 3, 0xff00ff], [8, 3, 0xff00ff], [9, 3, 0xff00ff],
// Row 4 — widest
[0, 4, 0xff00ff], [1, 4, 0xff00ff], [2, 4, 0xff00ff], [3, 4, 0xff00ff], [4, 4, 0xff00ff], [5, 4, 0xff00ff], [6, 4, 0xff00ff], [7, 4, 0xff00ff], [8, 4, 0xff00ff], [9, 4, 0xff00ff],
// Row 5
[1, 5, 0xcc00cc], [2, 5, 0xff00ff], [3, 5, 0xff00ff], [4, 5, 0xff00ff], [5, 5, 0xff00ff], [6, 5, 0xff00ff], [7, 5, 0xff00ff], [8, 5, 0xcc00cc],
// Row 6
[2, 6, 0xcc00cc], [3, 6, 0xff00ff], [4, 6, 0xff00ff], [5, 6, 0xff00ff], [6, 6, 0xff00ff], [7, 6, 0xcc00cc],
// Row 7
[3, 7, 0xcc00cc], [4, 7, 0xcc00cc], [5, 7, 0xcc00cc], [6, 7, 0xcc00cc],
];
// Humanoid: ROM = 4×8 px (2 bytes × 8 rows)
// Multi-colored: green upper body, magenta/pink lower half
const HUMANOID_PIXELS = [
// Row 0 — head (green)
[1, 0, 0x00ff00], [2, 0, 0x00ff00],
// Row 1 — neck (green)
[1, 1, 0x00ff00], [2, 1, 0x00ff00],
// Row 2 — arms + torso (green)
[0, 2, 0x00ff00], [1, 2, 0x00ff00], [2, 2, 0x00ff00], [3, 2, 0x00ff00],
// Row 3 — torso (green)
[1, 3, 0x00ff00], [2, 3, 0x00ff00],
// Row 4 — waist (magenta transition)
[1, 4, 0xff00ff], [2, 4, 0xff00ff],
// Row 5 — hips (magenta)
[1, 5, 0xff00ff], [2, 5, 0xff00ff],
// Row 6 — legs (magenta)
[0, 6, 0xff00ff], [3, 6, 0xff00ff],
// Row 7 — feet (magenta)
[0, 7, 0xff00ff], [3, 7, 0xff00ff],
];
// Bomber: ROM = 8×8 px (4 bytes × 8 rows)
// Compact square block with segmented look, NOT a wide rectangle
const BOMBER_PIXELS = [
// Row 0 — top edge
[1, 0, 0xffff00], [2, 0, 0xffff00], [3, 0, 0xffff00], [4, 0, 0xffff00], [5, 0, 0xffff00], [6, 0, 0xffff00],
// Row 1 — top stripe with detail
[0, 1, 0xffff00], [1, 1, 0xff4400], [2, 1, 0xffff00], [3, 1, 0xff4400], [4, 1, 0xffff00], [5, 1, 0xff4400], [6, 1, 0xffff00], [7, 1, 0xffff00],
// Row 2 — solid
[0, 2, 0xffff00], [1, 2, 0xffff00], [2, 2, 0xffff00], [3, 2, 0xffff00], [4, 2, 0xffff00], [5, 2, 0xffff00], [6, 2, 0xffff00], [7, 2, 0xffff00],
// Row 3 — center detail
[0, 3, 0xffff00], [1, 3, 0xffff00], [2, 3, 0xff4400], [3, 3, 0xffff00], [4, 3, 0xffff00], [5, 3, 0xff4400], [6, 3, 0xffff00], [7, 3, 0xffff00],
// Row 4 — center detail
[0, 4, 0xffff00], [1, 4, 0xffff00], [2, 4, 0xff4400], [3, 4, 0xffff00], [4, 4, 0xffff00], [5, 4, 0xff4400], [6, 4, 0xffff00], [7, 4, 0xffff00],
// Row 5 — solid
[0, 5, 0xffff00], [1, 5, 0xffff00], [2, 5, 0xffff00], [3, 5, 0xffff00], [4, 5, 0xffff00], [5, 5, 0xffff00], [6, 5, 0xffff00], [7, 5, 0xffff00],
// Row 6 — bottom stripe
[0, 6, 0xffff00], [1, 6, 0xff4400], [2, 6, 0xffff00], [3, 6, 0xff4400], [4, 6, 0xffff00], [5, 6, 0xff4400], [6, 6, 0xffff00], [7, 6, 0xffff00],
// Row 7 — bottom edge
[1, 7, 0xffff00], [2, 7, 0xffff00], [3, 7, 0xffff00], [4, 7, 0xffff00], [5, 7, 0xffff00], [6, 7, 0xffff00],
];
// Baiter: ROM = 12×4 px (6 bytes × 4 rows)
// Thin horseshoe/C shape — narrow and aggressive
const BAITER_PIXELS = [
// Row 0 — top bar
[0, 0, 0x00ff44], [1, 0, 0x00ff44], [2, 0, 0x00ff44], [3, 0, 0x00ff44], [4, 0, 0x00ff44], [5, 0, 0x00ff44], [6, 0, 0x00ff44], [7, 0, 0x00ff44], [8, 0, 0x00ff44], [9, 0, 0x00ff44], [10, 0, 0x00ff44], [11, 0, 0x00ff44],
// Row 1 — gap in middle
[0, 1, 0x00ff44], [1, 1, 0x00ff44], [10, 1, 0x00ff44], [11, 1, 0x00ff44],
// Row 2 — gap in middle
[0, 2, 0x00ff44], [1, 2, 0x00ff44], [10, 2, 0x00ff44], [11, 2, 0x00ff44],
// Row 3 — bottom bar
[0, 3, 0x00ff44], [1, 3, 0x00ff44], [2, 3, 0x00ff44], [3, 3, 0x00ff44], [4, 3, 0x00ff44], [5, 3, 0x00ff44], [6, 3, 0x00ff44], [7, 3, 0x00ff44], [8, 3, 0x00ff44], [9, 3, 0x00ff44], [10, 3, 0x00ff44], [11, 3, 0x00ff44],
];
// Pod: ROM = 8×8 px (4 bytes × 8 rows)
// Compact oval/circle shape, not a large egg
const POD_PIXELS = [
// Row 0
[2, 0, 0xcc00cc], [3, 0, 0xcc00cc], [4, 0, 0xcc00cc], [5, 0, 0xcc00cc],
// Row 1
[1, 1, 0xcc00cc], [2, 1, 0xff00ff], [3, 1, 0xff00ff], [4, 1, 0xff00ff], [5, 1, 0xff00ff], [6, 1, 0xcc00cc],
// Row 2
[0, 2, 0xcc00cc], [1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xff44ff], [4, 2, 0xff44ff], [5, 2, 0xff00ff], [6, 2, 0xff00ff], [7, 2, 0xcc00cc],
// Row 3
[0, 3, 0xcc00cc], [1, 3, 0xff00ff], [2, 3, 0xff44ff], [3, 3, 0xff00ff], [4, 3, 0xff00ff], [5, 3, 0xff44ff], [6, 3, 0xff00ff], [7, 3, 0xcc00cc],
// Row 4
[0, 4, 0xcc00cc], [1, 4, 0xff00ff], [2, 4, 0xff44ff], [3, 4, 0xff00ff], [4, 4, 0xff00ff], [5, 4, 0xff44ff], [6, 4, 0xff00ff], [7, 4, 0xcc00cc],
// Row 5
[0, 5, 0xcc00cc], [1, 5, 0xff00ff], [2, 5, 0xff00ff], [3, 5, 0xff44ff], [4, 5, 0xff44ff], [5, 5, 0xff00ff], [6, 5, 0xff00ff], [7, 5, 0xcc00cc],
// Row 6
[1, 6, 0xcc00cc], [2, 6, 0xff00ff], [3, 6, 0xff00ff], [4, 6, 0xff00ff], [5, 6, 0xff00ff], [6, 6, 0xcc00cc],
// Row 7
[2, 7, 0xcc00cc], [3, 7, 0xcc00cc], [4, 7, 0xcc00cc], [5, 7, 0xcc00cc],
];
// Swarmer: ROM = 6×4 px (3 bytes × 4 rows)
// Wider than tall cross/star shape
const SWARMER_PIXELS = [
// Row 0
[2, 0, 0xffff00], [3, 0, 0xffff00],
// Row 1 — full width
[0, 1, 0xffff00], [1, 1, 0xffff00], [2, 1, 0xffff00], [3, 1, 0xffff00], [4, 1, 0xffff00], [5, 1, 0xffff00],
// Row 2 — full width
[0, 2, 0xffff00], [1, 2, 0xffff00], [2, 2, 0xffff00], [3, 2, 0xffff00], [4, 2, 0xffff00], [5, 2, 0xffff00],
// Row 3
[2, 3, 0xffff00], [3, 3, 0xffff00],
];
/* ------------------------------------------------------------------ */
/* Scene */
/* ------------------------------------------------------------------ */
export class PlanetGuardianScene extends BaseScene {
@@ -211,7 +48,6 @@ export class PlanetGuardianScene extends BaseScene {
humanoids = [];
bullets = [];
mines = [];
stars = [];
/* Terrain */
terrainHeights = [];
planetDestroyed = false;
@@ -229,12 +65,12 @@ export class PlanetGuardianScene extends BaseScene {
radarGfx; // radar minimap
terrainGfx; // terrain graphics
hudExtraGfx; // smart bomb display
displayedSmartBombs = -1;
shipSprite; // player ship sprite
/* Input */
cursors;
fireKey;
bombKey;
fireWasDown = false;
bombWasDown = false;
fireCooldown = 0; // rapid-fire rate limiter
thrustSoundPlaying = false;
@@ -266,19 +102,19 @@ export class PlanetGuardianScene extends BaseScene {
this.load.image('def-swarmer', '../assets/defender/swarmer.png');
this.load.image('def-baiter', '../assets/defender/baiter.png');
// Sounds from OpenDefender
this.load.audio('snd_laser', '../assets/defender/sounds/sound_laser.ogg');
this.load.audio('snd_enemydead', '../assets/defender/sounds/sound_enemydead.ogg');
this.load.audio('snd_explode', '../assets/defender/sounds/sound_explode.ogg');
this.load.audio('snd_playerdead', '../assets/defender/sounds/sound_playerdead.ogg');
this.load.audio('snd_bonus', '../assets/defender/sounds/sound_bonus.ogg');
this.load.audio('snd_humanoiddead', '../assets/defender/sounds/sound_humanoiddead.ogg');
this.load.audio('snd_start', '../assets/defender/sounds/sound_start.ogg');
this.load.audio('snd_thrust', '../assets/defender/sounds/sound_thurst.ogg');
this.load.audio('snd_warning', '../assets/defender/sounds/sound_warning.ogg');
this.load.audio('snd_baiterwarning', '../assets/defender/sounds/sound_baiterwarning.ogg');
this.load.audio('snd_player1up', '../assets/defender/sounds/sound_player1up.ogg');
this.load.audio('snd_enemyshoot', '../assets/defender/sounds/sound_enemyshoot.ogg');
this.load.audio('snd_enemyshoot2', '../assets/defender/sounds/sound_enemyshoot2.ogg');
this.load.audio('snd_laser', getWavAudioPath('defender/sounds/sound_laser'));
this.load.audio('snd_enemydead', getWavAudioPath('defender/sounds/sound_enemydead'));
this.load.audio('snd_explode', getWavAudioPath('defender/sounds/sound_explode'));
this.load.audio('snd_playerdead', getWavAudioPath('defender/sounds/sound_playerdead'));
this.load.audio('snd_bonus', getWavAudioPath('defender/sounds/sound_bonus'));
this.load.audio('snd_humanoiddead', getWavAudioPath('defender/sounds/sound_humanoiddead'));
this.load.audio('snd_start', getWavAudioPath('defender/sounds/sound_start'));
this.load.audio('snd_thrust', getWavAudioPath('defender/sounds/sound_thurst'));
this.load.audio('snd_warning', getWavAudioPath('defender/sounds/sound_warning'));
this.load.audio('snd_baiterwarning', getWavAudioPath('defender/sounds/sound_baiterwarning'));
this.load.audio('snd_player1up', getWavAudioPath('defender/sounds/sound_player1up'));
this.load.audio('snd_enemyshoot', getWavAudioPath('defender/sounds/sound_enemyshoot'));
this.load.audio('snd_enemyshoot2', getWavAudioPath('defender/sounds/sound_enemyshoot2'));
}
create() {
this.initBase();
@@ -302,6 +138,7 @@ export class PlanetGuardianScene extends BaseScene {
this.gameOver = false;
this.planetDestroyed = false;
this.smartBombs = 3;
this.displayedSmartBombs = -1;
this.carriedHumanoid = -1;
this.nextExtraLife = EXTRA_LIFE_SCORE;
this.playerX = WORLD_W / 2;
@@ -316,14 +153,13 @@ export class PlanetGuardianScene extends BaseScene {
this.humanoids = [];
this.bullets = [];
this.mines = [];
this.stars = [];
this.activeEmitters = [];
this.waveTimer = 0;
this.waveDelay = 0;
this.baiterSpawned = false;
this.ensureSparkTexture();
// Starfield
this.stars = this.createStarfield([
this.createStarfield([
{ count: 50, speed: 0, size: 1, alpha: 0.25 },
{ count: 30, speed: 0, size: 1.5, alpha: 0.35 },
{ count: 15, speed: 0, size: 2, alpha: 0.45 },
@@ -346,7 +182,6 @@ export class PlanetGuardianScene extends BaseScene {
this.cursors = this.input.keyboard.createCursorKeys();
this.fireKey = this.input.keyboard.addKey('SPACE');
this.bombKey = this.input.keyboard.addKey('Z');
this.fireWasDown = false;
this.bombWasDown = false;
this.fireCooldown = 0;
this.thrustSoundPlaying = false;
@@ -397,7 +232,7 @@ export class PlanetGuardianScene extends BaseScene {
if (this.waveDelay <= 0)
this.startWave();
}
else if (this.enemies.filter(e => e.alive).length === 0 && this.mines.length === 0 && this.waveDelay <= 0 && this.wave > 0) {
else if (!this.enemies.some(e => e.alive) && this.mines.length === 0 && this.wave > 0) {
// Wave complete
this.onWaveComplete();
}
@@ -871,8 +706,10 @@ export class PlanetGuardianScene extends BaseScene {
// World wrap
e.x = this.wrapWorldX(e.x);
// Clamp Y — keep enemies in playable area (not below terrain line)
if (e.y < RADAR_Y + RADAR_H + 10)
// Carrying landers must reach escape altitude before they can mutate.
if (!(e.type === 'lander' && e.hasHumanoid) && e.y < RADAR_Y + RADAR_H + 10) {
e.y = RADAR_Y + RADAR_H + 10;
}
const maxEnemyY = this.planetDestroyed ? H - 40 : H * 0.75;
if (e.y > maxEnemyY)
e.y = maxEnemyY;
@@ -1254,8 +1091,7 @@ export class PlanetGuardianScene extends BaseScene {
checkPlanetDestroyed() {
if (this.planetDestroyed)
return;
const alive = this.humanoids.filter(h => h.state !== 'dead').length;
if (alive === 0) {
if (!this.humanoids.some(h => h.state !== 'dead')) {
this.planetDestroyed = true;
// All remaining landers become mutants
for (const e of this.enemies) {
@@ -1507,9 +1343,9 @@ export class PlanetGuardianScene extends BaseScene {
// Draw terrain
this.renderTerrain();
// Draw humanoids
this.renderHumanoids(g);
this.renderHumanoids();
// Draw enemies
this.renderEnemies(g);
this.renderEnemies();
// Draw mines
this.renderMines(g);
// Draw bullets
@@ -1554,21 +1390,8 @@ export class PlanetGuardianScene extends BaseScene {
}
tg.strokePath();
// Subtle fill below terrain — dark brown
// strokePath preserves the path, so reuse its samples for the fill.
tg.fillStyle(0x331800, 0.3);
tg.beginPath();
firstPoint = true;
for (let wx = startWorldX; wx <= endWorldX; wx += TERRAIN_SAMPLE / 2) {
const wrappedX = this.wrapWorldX(wx);
const sy = this.getTerrainY(wrappedX);
const sx = wx - this.cameraX;
if (firstPoint) {
tg.moveTo(sx, sy);
firstPoint = false;
}
else {
tg.lineTo(sx, sy);
}
}
// Close polygon at bottom
tg.lineTo(endWorldX - this.cameraX, H);
tg.lineTo(startWorldX - this.cameraX, H);
@@ -1578,12 +1401,13 @@ export class PlanetGuardianScene extends BaseScene {
renderPlayer(g) {
const sx = this.worldToScreenX(this.playerX);
this.shipSprite.setPosition(sx, this.playerY);
this.shipSprite.setTexture(this.facingRight ? 'def-ship-r' : 'def-ship-l');
const texture = this.facingRight ? 'def-ship-r' : 'def-ship-l';
if (this.shipSprite.texture.key !== texture)
this.shipSprite.setTexture(texture);
this.shipSprite.setVisible(true);
// Engine exhaust — fires from the REAR of the ship (opposite of facing direction)
if (this.cursors.left.isDown || this.cursors.right.isDown) {
const shipHalfW = 118 * this.spriteScale / 2;
const shipHalfH = 53 * this.spriteScale / 2;
// Exhaust shoots out behind the ship
const exhaustDir = this.facingRight ? -1 : 1;
const exhaustX = sx + exhaustDir * shipHalfW;
@@ -1613,7 +1437,7 @@ export class PlanetGuardianScene extends BaseScene {
}
}
}
renderEnemies(g) {
renderEnemies() {
for (const e of this.enemies) {
if (!e.alive) {
if (e.sprite)
@@ -1636,7 +1460,7 @@ export class PlanetGuardianScene extends BaseScene {
}
}
}
renderHumanoids(g) {
renderHumanoids() {
for (const h of this.humanoids) {
if (h.state === 'dead') {
if (h.sprite)
@@ -1776,6 +1600,9 @@ export class PlanetGuardianScene extends BaseScene {
rg.fillRect(px - 4, py - 1, 9, 3); // horizontal bar
}
renderSmartBombHUD() {
if (this.displayedSmartBombs === this.smartBombs)
return;
this.displayedSmartBombs = this.smartBombs;
const hg = this.hudExtraGfx;
hg.clear();
// Draw smart bomb count below radar
@@ -0,0 +1,984 @@
// Surface Defense — a procedural missile-defense arcade game.
// Defend six cities with three interceptor batteries through escalating waves.
import { BaseScene, W, H } from './BaseScene.js';
import { getWavAudioPath } from '../layout.js';
const PALETTES = [
{ sky: 0x02010d, terrain: 0x6051a6, ridge: 0x2f2861, city: 0x52f7ff, enemy: 0xff4b8b },
{ sky: 0x06121d, terrain: 0x1f8a70, ridge: 0x12504d, city: 0xffd166, enemy: 0xff6b35 },
{ sky: 0x12081c, terrain: 0xa44a88, ridge: 0x5e2758, city: 0x7df9ff, enemy: 0xffd166 },
{ sky: 0x07180d, terrain: 0x6b8e23, ridge: 0x304d16, city: 0xff9f1c, enemy: 0xff4d6d },
];
const BATTERY_AMMO = 10;
const BONUS_CITY_SCORE = 10000;
const MAX_ACTIVE_INTERCEPTORS = 3;
// Killer satellite pixel art traced from the original Missile Command (1980)
// arcade flier sprite: rounded red body, twin blue panels, four antenna legs
// with orange tips. '.' pixels are transparent.
const SATELLITE_SPRITE = [
'..O...........O..',
'...R.........R...',
'....R..rrR..R....',
'.....rrrrrrR.....',
'.....rrrrrrR.....',
'....RDBrRDBrR....',
'....RDBrRDBrR....',
'....RDBrRDBrR....',
'.....rrrrrrR.....',
'.....rrrrrrR.....',
'....R..rrR..R....',
'...R.........R...',
'..O...........O..',
];
// Bomber jet traced from the original arcade flier sprite: swept tail fin,
// long fuselage with the nose pointing right, delta wing sweeping down-left.
const BOMBER_SPRITE = [
'......rR...........',
'...R...rR..........',
'...rR..rrR.........',
'...rrrrrrrrrrR.....',
'..rrrrrrrrrrrrrR...',
'.....rrrrrrrrrrrrR.',
'......rrrrR........',
'......rrrR.........',
'.....rrrR..........',
'.....rrR...........',
'....rrR............',
];
const SPRITE_COLORS = {
R: 0xff2020, // bright red
r: 0xb00000, // dark red
D: 0x0000c8, // dark blue
B: 0x2020ff, // bright blue
O: 0xffa500, // orange antenna tips
};
export class SurfaceDefenseScene extends BaseScene {
stars = [];
terrainGfx;
defenseGfx;
missileGfx;
effectsGfx;
cursorGfx;
statusText;
defensesDirty = true;
displayedAmmo = -1;
displayedMultiplier = -1;
cities = [];
batteries = [];
enemyMissiles = [];
interceptors = [];
explosions = [];
aircraft = [];
cursors;
fireKeys = [];
fireWasDown = [false, false, false, false];
cursorX = 0;
cursorY = 0;
pointerActive = false;
groundY = 0;
scale = 1;
wave = 0;
enemyReserve = 0;
smartReserve = 0;
splitReserve = 0;
spawnTimer = 0;
aircraftSpawnTimer = 0;
aircraftSpawnedThisWave = false;
waveEndTimer = 0;
fireCooldown = 0;
nextBonusCityScore = BONUS_CITY_SCORE;
bonusCityReserve = 0;
gameOverFlag = false;
waveActive = false;
palette = PALETTES[0];
terrainPoints = [];
pointerMoveHandler;
pointerDownHandler;
contextMenuHandler;
canvasBounds;
canvasResizeHandler;
constructor() {
super('surface-defense');
}
get displayName() {
return 'Surface Defense';
}
getDescription() {
return 'Protect six cities with chain-reaction interceptor bursts!';
}
getControls() {
return [
{ key: 'MOUSE / ARROWS', action: 'Move Targeting Cursor' },
{ key: 'CLICK / SPACE', action: 'Fire Nearest Battery' },
{ key: 'A / S / D', action: 'Fire Left / Center / Right' },
];
}
preload() {
// All effects are original WAVs synthesized by
// scripts/generate-surface-defense-audio.mjs — no third-party audio.
this.load.audio('sd_launch', getWavAudioPath('surface-defense/sounds/launch'));
this.load.audio('sd_airburst', getWavAudioPath('surface-defense/sounds/airburst'));
this.load.audio('sd_wave', getWavAudioPath('surface-defense/sounds/wave-start'));
this.load.audio('sd_noAmmo', getWavAudioPath('surface-defense/sounds/no-ammo'));
this.load.audio('sd_gameOver', getWavAudioPath('surface-defense/sounds/game-over'));
this.load.audio('sd_impact', getWavAudioPath('surface-defense/sounds/ground-impact'));
this.load.audio('sd_bonus', getWavAudioPath('surface-defense/sounds/bonus-city'));
this.load.audio('sd_aircraft', getWavAudioPath('surface-defense/sounds/aircraft-alert'));
}
create() {
this.initBase();
this.scale = Math.max(0.58, Math.min(W / 1920, H / 1080));
// The arcade original keeps its flat ground line at ~95% of screen height
// with low battery hills; 0.93 matches that while leaving room for the
// status text inside the terrain band.
this.groundY = H * 0.93;
this.score = 0;
this.lives = 6;
this.level = 0;
this.wave = 0;
this.enemyReserve = 0;
this.smartReserve = 0;
this.splitReserve = 0;
this.spawnTimer = 0;
this.waveEndTimer = 0;
this.fireCooldown = 0;
this.nextBonusCityScore = BONUS_CITY_SCORE;
this.bonusCityReserve = 0;
this.gameOverFlag = false;
this.waveActive = false;
this.enemyMissiles = [];
this.interceptors = [];
this.explosions = [];
this.aircraft = [];
this.aircraftSpawnTimer = 0;
this.aircraftSpawnedThisWave = false;
this.stars = [];
this.defensesDirty = true;
this.displayedAmmo = -1;
this.displayedMultiplier = -1;
this.terrainGfx = this.add.graphics().setDepth(-5);
this.defenseGfx = this.add.graphics().setDepth(5);
this.missileGfx = this.add.graphics().setDepth(10);
this.effectsGfx = this.add.graphics().setDepth(20);
this.cursorGfx = this.add.graphics().setDepth(30);
this.statusText = this.add.text(W / 2, H * 0.945, '', {
fontFamily: '"Press Start 2P", monospace',
fontSize: `${Math.max(11, Math.round(15 * this.scale))}px`,
color: '#ffffff',
stroke: '#000000',
strokeThickness: 4,
align: 'center',
}).setOrigin(0.5, 0);
this.createTerrain();
this.drawTerrain();
this.createDefenses();
this.stars = this.createStarfield([
{ count: 45, speed: 2, size: Math.max(1, this.scale), alpha: 0.22 },
{ count: 25, speed: 5, size: Math.max(1.5, 1.5 * this.scale), alpha: 0.32 },
]);
this.cursorX = W / 2;
this.cursorY = H * 0.4;
this.cursors = this.input.keyboard.createCursorKeys();
this.fireKeys = [
this.input.keyboard.addKey('A'),
this.input.keyboard.addKey('S'),
this.input.keyboard.addKey('D'),
this.input.keyboard.addKey('SPACE'),
];
this.setupPointerInput();
this.syncScoreToHUD();
this.syncLivesToHUD();
this.syncLevelToHUD();
this.loadHighScore();
this.drawScene();
this.startWithReadyScreen(() => this.startWave());
}
update(_time, deltaMs) {
if (this.gameOverFlag)
return;
const dt = Math.min(deltaMs, 33);
const dtSec = dt / 1000;
this.updateStarfield(this.stars, dt);
this.updateInput(dtSec);
if (this.fireCooldown > 0)
this.fireCooldown -= dt;
if (!this.waveActive) {
this.drawScene();
return;
}
this.updateSpawning(dt);
this.updateEnemyMissiles(dtSec);
this.updateInterceptors(dtSec);
this.updateExplosions(dt);
this.updateAircraft(dt, dtSec);
this.checkBlastCollisions();
this.checkWaveComplete(dt);
this.drawScene();
}
createTerrain() {
// Hill heights scaled to the arcade original, where the battery mounds
// rise only ~6% of screen height above the flat ground line.
this.terrainPoints = [
{ x: 0, y: this.groundY - H * 0.008 },
{ x: W * 0.06, y: this.groundY - H * 0.03 },
{ x: W * 0.13, y: this.groundY - H * 0.013 },
{ x: W * 0.22, y: this.groundY - H * 0.038 },
{ x: W * 0.31, y: this.groundY - H * 0.018 },
{ x: W * 0.41, y: this.groundY - H * 0.043 },
{ x: W * 0.5, y: this.groundY - H * 0.02 },
{ x: W * 0.59, y: this.groundY - H * 0.04 },
{ x: W * 0.69, y: this.groundY - H * 0.015 },
{ x: W * 0.78, y: this.groundY - H * 0.035 },
{ x: W * 0.88, y: this.groundY - H * 0.013 },
{ x: W, y: this.groundY - H * 0.028 },
];
}
createDefenses() {
const cityXs = [0.24, 0.32, 0.4, 0.6, 0.68, 0.76];
this.cities = cityXs.map(x => ({ x: W * x, alive: true }));
const batteryY = this.groundY - H * 0.012;
this.batteries = [
{ x: W * 0.105, y: batteryY, ammo: BATTERY_AMMO, alive: true, color: 0x4deeea },
{ x: W * 0.5, y: batteryY, ammo: BATTERY_AMMO, alive: true, color: 0xffd166 },
{ x: W * 0.895, y: batteryY, ammo: BATTERY_AMMO, alive: true, color: 0xff6b9d },
];
}
setupPointerInput() {
this.canvasResizeHandler = () => {
this.canvasBounds = undefined;
};
window.addEventListener('resize', this.canvasResizeHandler);
this.game.scale.on('resize', this.canvasResizeHandler);
this.pointerMoveHandler = (event) => {
const cachedBounds = this.canvasBounds;
const bounds = cachedBounds ?? this.game.canvas.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0)
return;
if (!cachedBounds)
this.canvasBounds = bounds;
const canvasX = (event.clientX - bounds.left) * (W / bounds.width);
const canvasY = (event.clientY - bounds.top) * (H / bounds.height);
this.pointerActive = true;
this.cursorX = Phaser.Math.Clamp(canvasX, W * 0.02, W * 0.98);
this.cursorY = Phaser.Math.Clamp(canvasY, H * 0.08, this.groundY - H * 0.045);
};
this.pointerDownHandler = (pointer) => {
if (!this.waveActive || this.gameOverFlag)
return;
this.cursorX = Phaser.Math.Clamp(pointer.worldX, W * 0.02, W * 0.98);
this.cursorY = Phaser.Math.Clamp(pointer.worldY, H * 0.08, this.groundY - H * 0.045);
const requested = pointer.button === 1 ? 1 : pointer.button === 2 ? 2 : this.findNearestBattery();
this.fireInterceptor(requested);
};
document.addEventListener('mousemove', this.pointerMoveHandler);
this.input.on('pointerdown', this.pointerDownHandler);
this.contextMenuHandler = (event) => event.preventDefault();
this.game.canvas.addEventListener('contextmenu', this.contextMenuHandler);
}
updateInput(dtSec) {
const speed = Math.max(440, W * 0.32);
let moved = false;
if (this.cursors.left.isDown) {
this.cursorX -= speed * dtSec;
moved = true;
}
if (this.cursors.right.isDown) {
this.cursorX += speed * dtSec;
moved = true;
}
if (this.cursors.up.isDown) {
this.cursorY -= speed * dtSec;
moved = true;
}
if (this.cursors.down.isDown) {
this.cursorY += speed * dtSec;
moved = true;
}
if (moved)
this.pointerActive = false;
this.cursorX = Phaser.Math.Clamp(this.cursorX, W * 0.02, W * 0.98);
this.cursorY = Phaser.Math.Clamp(this.cursorY, H * 0.08, this.groundY - H * 0.045);
for (let i = 0; i < this.fireKeys.length; i++) {
const down = this.fireKeys[i].isDown;
if (down && !this.fireWasDown[i] && this.waveActive) {
this.fireInterceptor(i === 3 ? this.findNearestBattery() : i);
}
this.fireWasDown[i] = down;
}
}
findNearestBattery() {
let best = -1;
let bestDistance = Infinity;
for (let i = 0; i < this.batteries.length; i++) {
const battery = this.batteries[i];
if (!battery.alive || battery.ammo <= 0)
continue;
const distance = Math.abs(battery.x - this.cursorX);
if (distance < bestDistance) {
best = i;
bestDistance = distance;
}
}
return best;
}
fireInterceptor(batteryIndex) {
if (this.fireCooldown > 0 || this.interceptors.length >= MAX_ACTIVE_INTERCEPTORS)
return;
const battery = this.batteries[batteryIndex];
if (!battery || !battery.alive || battery.ammo <= 0) {
this.playSound('sd_noAmmo', 0.22);
return;
}
battery.ammo--;
this.defensesDirty = true;
this.fireCooldown = 120;
const startX = battery.x;
const startY = battery.y - H * 0.018;
this.interceptors.push({
x: startX,
y: startY,
startX,
startY,
targetX: this.cursorX,
targetY: this.cursorY,
speed: Math.max(650, H * 0.92),
color: battery.color,
trail: [{ x: startX, y: startY }],
});
this.playSound('sd_launch', 0.25);
}
startWave() {
if (this.gameOverFlag)
return;
this.wave++;
this.level = this.wave;
this.palette = PALETTES[(this.wave - 1) % PALETTES.length];
this.enemyReserve = Math.min(30, 7 + this.wave * 2);
this.smartReserve = this.wave >= 5 ? Math.min(5, Math.floor(this.wave / 3)) : 0;
this.splitReserve = this.wave >= 3 ? Math.min(6, Math.floor(this.wave / 2)) : 0;
this.spawnTimer = 700;
this.aircraftSpawnTimer = this.wave >= 2 ? 1800 + Math.random() * 1400 : 0;
this.aircraftSpawnedThisWave = false;
this.waveEndTimer = 0;
this.waveActive = true;
this.enemyMissiles = [];
this.interceptors = [];
this.explosions = [];
this.aircraft = [];
for (const battery of this.batteries) {
battery.alive = true;
battery.ammo = BATTERY_AMMO;
}
this.defensesDirty = true;
this.syncLevelToHUD();
this.showWaveBanner(this.wave);
this.playSound('sd_wave', 0.32);
this.drawTerrain();
}
updateSpawning(dt) {
if (this.wave >= 2 && !this.aircraftSpawnedThisWave) {
this.aircraftSpawnTimer -= dt;
if (this.aircraftSpawnTimer <= 0) {
const kind = this.wave >= 3 && this.wave % 2 === 1 ? 'satellite' : 'bomber';
this.spawnAircraft(kind);
this.aircraftSpawnedThisWave = true;
}
}
if (this.enemyReserve <= 0)
return;
this.spawnTimer -= dt;
const maxActive = Math.min(10, 4 + Math.floor(this.wave / 2));
if (this.spawnTimer > 0 || this.enemyMissiles.length >= maxActive)
return;
const smart = this.smartReserve > 0 && Math.random() < 0.28;
const split = !smart && this.splitReserve > 0 && Math.random() < 0.3;
if (smart)
this.smartReserve--;
if (split)
this.splitReserve--;
this.spawnEnemyMissile(undefined, undefined, smart, split);
this.enemyReserve--;
this.spawnTimer = Math.max(230, 980 - this.wave * 45) * (0.65 + Math.random() * 0.7);
}
spawnEnemyMissile(startX = W * (0.04 + Math.random() * 0.92), startY = H * 0.055, smart = false, split = false, forcedTarget) {
const target = forcedTarget ?? this.pickTarget();
const targetX = target.kind === 'city'
? this.cities[target.index].x
: this.batteries[target.index].x;
const targetY = target.kind === 'city'
? this.groundY - H * 0.018
: this.batteries[target.index].y;
this.enemyMissiles.push({
x: startX,
y: startY,
startX,
startY,
targetX,
targetY,
speed: Math.min(H * 0.35, H * (0.105 + this.wave * 0.009)) * (smart ? 1.25 : 1),
targetKind: target.kind,
targetIndex: target.index,
smart,
split,
splitDone: false,
color: smart ? 0xffffff : this.palette.enemy,
trail: [{ x: startX, y: startY }],
});
}
pickTarget() {
const targets = [];
this.cities.forEach((city, index) => {
if (city.alive)
targets.push({ kind: 'city', index });
});
this.batteries.forEach((battery, index) => {
if (battery.alive)
targets.push({ kind: 'battery', index });
});
if (targets.length === 0)
return { kind: 'city', index: 0 };
return targets[Math.floor(Math.random() * targets.length)];
}
updateEnemyMissiles(dtSec) {
for (let i = this.enemyMissiles.length - 1; i >= 0; i--) {
const missile = this.enemyMissiles[i];
let dx = missile.targetX - missile.x;
let dy = missile.targetY - missile.y;
const distance = Math.hypot(dx, dy);
if (distance <= missile.speed * dtSec + 3) {
this.enemyMissiles.splice(i, 1);
this.handleImpact(missile);
continue;
}
dx /= distance;
dy /= distance;
if (missile.smart) {
for (const explosion of this.explosions) {
if (!explosion.friendly)
continue;
const ex = missile.x - explosion.x;
const ey = missile.y - explosion.y;
const ed = Math.hypot(ex, ey);
if (ed < explosion.radius + H * 0.055 && ed > 0) {
dx += (ex / ed) * 1.35;
dy += (ey / ed) * 0.45;
}
}
const adjustedLength = Math.hypot(dx, dy);
dx /= adjustedLength;
dy /= adjustedLength;
}
missile.x += dx * missile.speed * dtSec;
missile.y += dy * missile.speed * dtSec;
this.appendTrailPoint(missile.trail ??= [{ x: missile.startX, y: missile.startY }], missile.x, missile.y);
if (missile.split && !missile.splitDone && missile.y > H * 0.33 && Math.random() < 0.025) {
missile.splitDone = true;
const extraTarget = this.pickTarget();
this.spawnEnemyMissile(missile.x, missile.y, false, false, extraTarget);
}
}
}
updateInterceptors(dtSec) {
for (let i = this.interceptors.length - 1; i >= 0; i--) {
const interceptor = this.interceptors[i];
const dx = interceptor.targetX - interceptor.x;
const dy = interceptor.targetY - interceptor.y;
const distance = Math.hypot(dx, dy);
if (distance <= interceptor.speed * dtSec + 3) {
this.interceptors.splice(i, 1);
this.createExplosion(interceptor.targetX, interceptor.targetY, true, interceptor.color);
continue;
}
interceptor.x += (dx / distance) * interceptor.speed * dtSec;
interceptor.y += (dy / distance) * interceptor.speed * dtSec;
this.appendTrailPoint(interceptor.trail ??= [{ x: interceptor.startX, y: interceptor.startY }], interceptor.x, interceptor.y);
}
}
appendTrailPoint(trail, x, y) {
const last = trail[trail.length - 1];
const spacing = Math.max(3, 5 * this.scale);
if (!last || Math.hypot(x - last.x, y - last.y) >= spacing) {
trail.push({ x, y });
}
}
createExplosion(x, y, friendly, color) {
this.explosions.push({
x,
y,
radius: 2,
maxRadius: friendly ? H * 0.072 : H * 0.045,
age: 0,
duration: friendly ? 1550 : 900,
friendly,
color,
});
this.playSound(friendly ? 'sd_airburst' : 'sd_impact', friendly ? 0.28 : 0.4);
}
updateExplosions(dt) {
for (let i = this.explosions.length - 1; i >= 0; i--) {
const explosion = this.explosions[i];
explosion.age += dt;
if (explosion.age >= explosion.duration) {
this.explosions.splice(i, 1);
continue;
}
const progress = explosion.age / explosion.duration;
const pulse = progress < 0.48
? progress / 0.48
: 1 - ((progress - 0.48) / 0.52);
explosion.radius = explosion.maxRadius * Math.sin(pulse * Math.PI / 2);
}
}
checkBlastCollisions() {
for (const explosion of this.explosions) {
if (!explosion.friendly || explosion.radius < 4)
continue;
for (let i = this.enemyMissiles.length - 1; i >= 0; i--) {
const missile = this.enemyMissiles[i];
if (Math.hypot(missile.x - explosion.x, missile.y - explosion.y) > explosion.radius)
continue;
this.enemyMissiles.splice(i, 1);
const points = (missile.smart ? 125 : 25) * this.getScoreMultiplier();
this.addScore(points, missile.x, missile.y);
this.checkBonusCity();
this.createExplosion(missile.x, missile.y, true, missile.color);
}
for (const craft of this.aircraft) {
if (!craft.active ||
Math.hypot(craft.x - explosion.x, craft.y - explosion.y) > explosion.radius + W * 0.018) {
continue;
}
craft.active = false;
// Both fliers scored 100 in the arcade original.
const points = 100;
this.addScore(points * this.getScoreMultiplier(), craft.x, craft.y);
this.createExplosion(craft.x, craft.y, true, craft.kind === 'satellite' ? 0xff2020 : 0xffd166);
this.checkBonusCity();
}
}
}
handleImpact(missile) {
this.defensesDirty = true;
if (missile.targetKind === 'city') {
const city = this.cities[missile.targetIndex];
if (city.alive)
city.alive = false;
}
else {
const battery = this.batteries[missile.targetIndex];
if (battery.alive) {
battery.alive = false;
battery.ammo = 0;
}
}
this.createExplosion(missile.targetX, missile.targetY, false, 0xff5533);
// A banked bonus city keeps the game alive, as in the arcade original.
this.syncLives();
if (this.lives <= 0)
this.endGame();
}
spawnAircraft(kind) {
const direction = Math.random() < 0.5 ? 1 : -1;
this.aircraft.push({
kind,
x: direction > 0 ? -W * 0.05 : W * 1.05,
y: H * (kind === 'satellite' ? 0.12 + Math.random() * 0.08 : 0.2 + Math.random() * 0.09),
vx: direction * Math.max(kind === 'satellite' ? 75 : 105, W * (kind === 'satellite' ? 0.055 : 0.078)),
dropTimer: kind === 'satellite' ? 700 + Math.random() * 600 : 950 + Math.random() * 850,
active: true,
wobble: Math.random() * Math.PI * 2,
});
this.playSound('sd_aircraft', 0.25);
}
updateAircraft(dt, dtSec) {
for (const craft of this.aircraft) {
if (!craft.active)
continue;
craft.x += craft.vx * dtSec;
craft.wobble += dtSec * (craft.kind === 'satellite' ? 3.2 : 1.8);
craft.dropTimer -= dt;
if (craft.dropTimer <= 0) {
const smart = craft.kind === 'satellite' && this.wave >= 4;
this.spawnEnemyMissile(craft.x, craft.y + H * 0.018, smart, false);
craft.dropTimer = craft.kind === 'satellite'
? 900 + Math.random() * 800
: 1150 + Math.random() * 1250;
}
if (craft.x < -W * 0.1 || craft.x > W * 1.1) {
craft.active = false;
}
}
}
checkWaveComplete(dt) {
const aircraftActive = this.aircraft.some(craft => craft.active);
if (this.enemyReserve > 0 || this.enemyMissiles.length > 0 || aircraftActive) {
this.waveEndTimer = 0;
return;
}
this.waveEndTimer += dt;
if (this.waveEndTimer < 900)
return;
this.waveActive = false;
this.awardWaveBonus();
}
awardWaveBonus() {
const ammo = this.batteries.reduce((sum, battery) => sum + battery.ammo, 0);
const cities = this.cities.filter(city => city.alive).length;
const bonus = ammo * 5 + cities * 100;
if (bonus > 0)
this.addScore(bonus, W / 2, H * 0.55);
this.checkBonusCity();
this.restoreCitiesFromReserve();
this.time.delayedCall(1800, () => {
if (!this.gameOverFlag)
this.startWave();
});
}
// The arcade banks a bonus city for every 10,000 points (default DIP
// setting) rather than granting it immediately; destroyed cities are
// rebuilt from the bank between waves, and any surplus stays banked.
checkBonusCity() {
while (this.score >= this.nextBonusCityScore) {
this.bonusCityReserve++;
this.nextBonusCityScore += BONUS_CITY_SCORE;
this.playSound('sd_bonus', 0.42);
this.showBonusCityText();
this.syncLives();
}
}
restoreCitiesFromReserve() {
for (const city of this.cities) {
if (this.bonusCityReserve <= 0)
break;
if (!city.alive) {
city.alive = true;
this.defensesDirty = true;
this.bonusCityReserve--;
}
}
this.syncLives();
}
// Lives = standing cities plus banked bonus cities, matching how the
// arcade tracks the player's total city count in one counter.
syncLives() {
this.lives = this.cities.filter(city => city.alive).length + this.bonusCityReserve;
this.syncLivesToHUD();
}
showBonusCityText() {
const txt = this.add.text(W / 2, H * 0.32, 'BONUS CITY', {
fontFamily: '"Press Start 2P", monospace',
fontSize: `${Math.max(14, Math.round(20 * this.scale))}px`,
color: '#ffd54a',
stroke: '#000000',
strokeThickness: 4,
}).setOrigin(0.5, 0.5).setDepth(900);
this.tweens.add({
targets: txt,
y: H * 0.27,
alpha: 0,
duration: 1600,
onComplete: () => txt.destroy(),
});
}
getScoreMultiplier() {
return Math.min(6, Math.floor((Math.max(1, this.wave) - 1) / 2) + 1);
}
endGame() {
if (this.gameOverFlag)
return;
this.gameOverFlag = true;
this.waveActive = false;
this.checkHighScore();
this.playSound('sd_gameOver', 0.4);
this.time.delayedCall(800, () => {
this.showGameOver(this.score, () => this.scene.restart());
});
}
// Terrain is static within a wave; it's drawn at create() and startWave()
// rather than every frame.
drawScene() {
if (this.defensesDirty) {
this.drawDefenses();
this.defensesDirty = false;
}
this.drawMissiles();
this.drawEffects();
this.drawCursor();
const totalAmmo = this.batteries.reduce((sum, battery) => sum + battery.ammo, 0);
const multiplier = this.getScoreMultiplier();
if (totalAmmo !== this.displayedAmmo || multiplier !== this.displayedMultiplier) {
this.statusText.setText(`INTERCEPTORS ${String(totalAmmo).padStart(2, '0')} ×${multiplier}`);
this.displayedAmmo = totalAmmo;
this.displayedMultiplier = multiplier;
}
}
drawTerrain() {
const g = this.terrainGfx;
g.clear();
g.fillStyle(this.palette.ridge, 0.65);
g.beginPath();
g.moveTo(0, H);
for (const point of this.terrainPoints)
g.lineTo(point.x, point.y);
g.lineTo(W, H);
g.closePath();
g.fillPath();
g.fillStyle(this.palette.terrain, 0.9);
g.beginPath();
g.moveTo(0, H);
for (const point of this.terrainPoints) {
g.lineTo(point.x, point.y + H * 0.018);
}
g.lineTo(W, H);
g.closePath();
g.fillPath();
g.lineStyle(Math.max(2, 3 * this.scale), this.palette.city, 0.45);
g.beginPath();
for (let i = 0; i < this.terrainPoints.length; i++) {
const point = this.terrainPoints[i];
if (i === 0)
g.moveTo(point.x, point.y + H * 0.018);
else
g.lineTo(point.x, point.y + H * 0.018);
}
g.strokePath();
}
drawDefenses() {
const g = this.defenseGfx;
g.clear();
this.cities.forEach((city, index) => {
if (!city.alive) {
this.drawCityRuins(g, city.x, this.groundY - H * 0.01, index);
return;
}
this.drawCity(g, city.x, this.groundY - H * 0.014, index);
});
this.batteries.forEach((battery, index) => {
this.drawBattery(g, battery, index);
});
}
drawCity(g, x, y, variant) {
const cityWidth = W * 0.052;
const baseY = y + H * 0.004;
const buildingCount = 5;
const gap = Math.max(2, 3 * this.scale);
const buildingWidth = (cityWidth - gap * (buildingCount - 1)) / buildingCount;
const heightPatterns = [
[0.52, 0.9, 0.68, 1, 0.58],
[0.72, 0.5, 1, 0.62, 0.84],
[0.58, 0.82, 0.55, 0.92, 0.7],
];
const heights = heightPatterns[variant % heightPatterns.length];
const maxHeight = H * 0.052;
const left = x - cityWidth / 2;
for (let i = 0; i < buildingCount; i++) {
const bx = left + i * (buildingWidth + gap);
const height = maxHeight * heights[i];
const top = baseY - height;
g.fillStyle(0x10152d, 0.96);
g.fillRect(bx, top, buildingWidth, height);
g.lineStyle(Math.max(1, 1.8 * this.scale), this.palette.city, 0.95);
g.strokeRect(bx, top, buildingWidth, height);
if ((i + variant) % 3 === 0) {
g.fillStyle(this.palette.city, 0.9);
g.fillTriangle(bx, top, bx + buildingWidth / 2, top - H * 0.009, bx + buildingWidth, top);
}
else if ((i + variant) % 3 === 1) {
g.lineStyle(Math.max(1, 1.4 * this.scale), this.palette.city, 0.8);
g.lineBetween(bx + buildingWidth / 2, top, bx + buildingWidth / 2, top - H * 0.009);
}
const windowSize = Math.max(2, 3 * this.scale);
const windowGapX = Math.max(windowSize + 2, buildingWidth * 0.32);
const windowGapY = Math.max(windowSize + 3, H * 0.011);
for (let wx = bx + windowGapX * 0.55; wx < bx + buildingWidth - windowSize; wx += windowGapX) {
for (let wy = top + windowGapY * 0.65; wy < baseY - windowSize; wy += windowGapY) {
const lit = (Math.floor(wx + wy) + variant + i) % 3 !== 0;
g.fillStyle(lit ? 0xffe66d : 0x283552, lit ? 0.95 : 0.75);
g.fillRect(wx, wy, windowSize, windowSize);
}
}
}
g.fillStyle(this.palette.city, 0.95);
g.fillRect(left - gap, baseY, cityWidth + gap * 2, Math.max(3, H * 0.005));
}
drawCityRuins(g, x, y, variant) {
const width = W * 0.05;
const left = x - width / 2;
const rubble = [0.18, 0.34, 0.24, 0.42, 0.2, 0.3, 0.16];
const step = width / rubble.length;
g.fillStyle(0x251f2d, 0.95);
g.beginPath();
g.moveTo(left, y);
for (let i = 0; i < rubble.length; i++) {
const height = H * 0.035 * rubble[(i + variant) % rubble.length];
g.lineTo(left + i * step, y - height);
g.lineTo(left + (i + 0.55) * step, y - height * 0.35);
}
g.lineTo(left + width, y);
g.closePath();
g.fillPath();
g.fillStyle(0xff6b35, 0.7);
g.fillCircle(x - width * 0.16, y - H * 0.008, Math.max(2, 3 * this.scale));
g.fillCircle(x + width * 0.12, y - H * 0.006, Math.max(2, 2.5 * this.scale));
}
drawBattery(g, battery, index) {
const width = W * 0.055;
const height = H * 0.052;
const color = battery.alive ? battery.color : 0x3b3542;
g.fillStyle(color, battery.alive ? 0.88 : 0.65);
g.beginPath();
g.moveTo(battery.x - width / 2, battery.y);
g.lineTo(battery.x - width * 0.22, battery.y - height * 0.78);
g.lineTo(battery.x - width * 0.08, battery.y - height);
g.lineTo(battery.x + width * 0.08, battery.y - height);
g.lineTo(battery.x + width * 0.22, battery.y - height * 0.78);
g.lineTo(battery.x + width / 2, battery.y);
g.closePath();
g.fillPath();
if (battery.alive) {
g.lineStyle(Math.max(2, 3 * this.scale), 0xffffff, 0.8);
g.lineBetween(battery.x, battery.y - height * 0.55, battery.x, battery.y - height * 1.2);
g.strokeCircle(battery.x, battery.y - height * 1.24, Math.max(3, 4 * this.scale));
}
const pipSize = Math.max(3, W * 0.0027);
for (let i = 0; i < BATTERY_AMMO; i++) {
g.fillStyle(i < battery.ammo ? color : 0x282330, i < battery.ammo ? 1 : 0.7);
const col = i % 5;
const row = Math.floor(i / 5);
g.fillTriangle(battery.x - pipSize * 6 + col * pipSize * 3, battery.y + H * 0.012 + row * pipSize * 2.2, battery.x - pipSize * 5 + col * pipSize * 3, battery.y + H * 0.012 + row * pipSize * 2.2 - pipSize * 1.8, battery.x - pipSize * 4 + col * pipSize * 3, battery.y + H * 0.012 + row * pipSize * 2.2);
}
g.fillStyle(0xffffff, 0.65);
g.fillCircle(battery.x + (index - 1) * 0.01, battery.y, 1);
}
drawMissiles() {
const g = this.missileGfx;
g.clear();
for (const missile of this.enemyMissiles) {
this.drawRetroTrail(g, missile.trail ?? [{ x: missile.startX, y: missile.startY }], missile.x, missile.y, missile.color, missile.smart ? 0.95 : 0.78);
g.fillStyle(missile.color, 1);
if (missile.smart) {
const r = Math.max(4, 6 * this.scale);
g.fillTriangle(missile.x, missile.y - r, missile.x + r, missile.y, missile.x, missile.y + r);
g.fillTriangle(missile.x, missile.y - r, missile.x - r, missile.y, missile.x, missile.y + r);
}
else {
g.fillCircle(missile.x, missile.y, Math.max(2.5, 3.5 * this.scale));
}
}
for (const interceptor of this.interceptors) {
this.drawRetroTrail(g, interceptor.trail ?? [{ x: interceptor.startX, y: interceptor.startY }], interceptor.x, interceptor.y, interceptor.color, 0.9);
g.fillStyle(0xffffff, 1);
g.fillCircle(interceptor.x, interceptor.y, Math.max(2, 3 * this.scale));
}
for (const craft of this.aircraft) {
if (craft.active)
this.drawAircraft(g, craft);
}
}
drawRetroTrail(g, trail, endX, endY, color, alpha) {
const points = trail.length > 0 ? trail : [{ x: endX, y: endY }];
const grid = Math.max(2, Math.round(3 * this.scale));
const pixel = grid + 1;
g.fillStyle(color, alpha);
for (let i = 1; i <= points.length; i++) {
const from = points[i - 1];
const to = i < points.length ? points[i] : { x: endX, y: endY };
const dx = to.x - from.x;
const dy = to.y - from.y;
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / grid));
for (let step = 0; step <= steps; step++) {
const px = Math.round((from.x + dx * (step / steps)) / grid) * grid;
const py = Math.round((from.y + dy * (step / steps)) / grid) * grid;
g.fillRect(px - pixel / 2, py - pixel / 2, pixel, pixel);
}
}
}
drawAircraft(g, craft) {
const x = craft.x;
const y = craft.y + Math.sin(craft.wobble) * H * 0.004;
const dir = Math.sign(craft.vx);
const sprite = craft.kind === 'bomber' ? BOMBER_SPRITE : SATELLITE_SPRITE;
const px = Math.max(2, Math.round(H * 0.0045));
const cols = sprite[0].length;
const rows = sprite.length;
const originX = x - (cols * px) / 2;
const originY = y - (rows * px) / 2;
// The arcade sprites shimmer as they redraw; swap the two reds a few times a second.
const swap = Math.floor(craft.wobble) % 2 === 0;
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const ch = sprite[row][col];
if (ch === '.')
continue;
let color = SPRITE_COLORS[ch];
if (swap && ch === 'R')
color = SPRITE_COLORS.r;
else if (swap && ch === 'r')
color = SPRITE_COLORS.R;
// The bomber sprite points right; mirror it when flying left.
const drawCol = dir < 0 ? cols - 1 - col : col;
g.fillStyle(color, 0.95);
g.fillRect(originX + drawCol * px, originY + row * px, px, px);
}
}
}
drawEffects() {
const g = this.effectsGfx;
g.clear();
for (const explosion of this.explosions) {
const progress = explosion.age / explosion.duration;
const alpha = Math.max(0.18, 1 - progress * 0.75);
g.fillStyle(explosion.color, alpha * 0.28);
g.fillCircle(explosion.x, explosion.y, explosion.radius);
g.lineStyle(Math.max(2, 4 * this.scale), 0xffffff, alpha);
g.strokeCircle(explosion.x, explosion.y, explosion.radius);
if (explosion.radius > 8) {
g.lineStyle(Math.max(1, 2 * this.scale), explosion.color, alpha);
g.strokeCircle(explosion.x, explosion.y, explosion.radius * 0.78);
}
}
}
drawCursor() {
const g = this.cursorGfx;
g.clear();
const radius = Math.max(12, 18 * this.scale);
const color = this.pointerActive ? 0xffffff : 0x7df9ff;
g.lineStyle(Math.max(1.5, 2.4 * this.scale), color, 0.95);
g.strokeCircle(this.cursorX, this.cursorY, radius);
g.lineBetween(this.cursorX - radius * 1.45, this.cursorY, this.cursorX - radius * 0.5, this.cursorY);
g.lineBetween(this.cursorX + radius * 0.5, this.cursorY, this.cursorX + radius * 1.45, this.cursorY);
g.lineBetween(this.cursorX, this.cursorY - radius * 1.45, this.cursorX, this.cursorY - radius * 0.5);
g.lineBetween(this.cursorX, this.cursorY + radius * 0.5, this.cursorX, this.cursorY + radius * 1.45);
const nearest = this.findNearestBattery();
if (nearest >= 0) {
g.lineStyle(Math.max(1, 1.4 * this.scale), this.batteries[nearest].color, 0.22);
g.lineBetween(this.batteries[nearest].x, this.batteries[nearest].y - H * 0.05, this.cursorX, this.cursorY);
}
}
playSound(key, volume) {
try {
this.sound.play(key, { volume });
}
catch {
// Audio can be unavailable in headless environments.
}
}
shutdown() {
if (this.pointerMoveHandler)
document.removeEventListener('mousemove', this.pointerMoveHandler);
if (this.pointerDownHandler)
this.input.off('pointerdown', this.pointerDownHandler);
if (this.contextMenuHandler)
this.game.canvas.removeEventListener('contextmenu', this.contextMenuHandler);
if (this.canvasResizeHandler) {
window.removeEventListener('resize', this.canvasResizeHandler);
this.game.scale.off('resize', this.canvasResizeHandler);
}
this.pointerMoveHandler = undefined;
this.pointerDownHandler = undefined;
this.contextMenuHandler = undefined;
this.canvasResizeHandler = undefined;
this.canvasBounds = undefined;
super.shutdown();
}
}
//# sourceMappingURL=SurfaceDefense.js.map
-275
View File
@@ -1,275 +0,0 @@
{
"name": "arcade-canvas",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "arcade-canvas",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@github/copilot-sdk": "latest"
}
},
"node_modules/@github/copilot": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.63.tgz",
"integrity": "sha512-e8DRYiWJQc4kepVXsXjC8vpDU2FXS/TfR+Z6p/KAojfcwIUZzKMAfCV5D1lD25hV4CryVH1Z9t7mHqChickj0Q==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"detect-libc": "^2.1.2",
"os-theme": "^0.0.8"
},
"bin": {
"copilot": "npm-loader.js"
},
"optionalDependencies": {
"@github/copilot-darwin-arm64": "1.0.63",
"@github/copilot-darwin-x64": "1.0.63",
"@github/copilot-linux-arm64": "1.0.63",
"@github/copilot-linux-x64": "1.0.63",
"@github/copilot-linuxmusl-arm64": "1.0.63",
"@github/copilot-linuxmusl-x64": "1.0.63",
"@github/copilot-win32-arm64": "1.0.63",
"@github/copilot-win32-x64": "1.0.63"
}
},
"node_modules/@github/copilot-darwin-arm64": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz",
"integrity": "sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-arm64": "copilot"
}
},
"node_modules/@github/copilot-darwin-x64": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz",
"integrity": "sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"darwin"
],
"bin": {
"copilot-darwin-x64": "copilot"
}
},
"node_modules/@github/copilot-linux-arm64": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz",
"integrity": "sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-arm64": "copilot"
}
},
"node_modules/@github/copilot-linux-x64": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz",
"integrity": "sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linux-x64": "copilot"
}
},
"node_modules/@github/copilot-linuxmusl-arm64": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz",
"integrity": "sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linuxmusl-arm64": "copilot"
}
},
"node_modules/@github/copilot-linuxmusl-x64": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz",
"integrity": "sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"linux"
],
"bin": {
"copilot-linuxmusl-x64": "copilot"
}
},
"node_modules/@github/copilot-sdk": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.1.tgz",
"integrity": "sha512-w6AaS0WqqTE/3iyUrZznvgCLQhsUF7ZmEVCneacuHCfOzlH0r6ww9WUmyA0zgqmXO75V0IYrkIcnFke/qJkkDg==",
"license": "MIT",
"dependencies": {
"@github/copilot": "^1.0.61",
"vscode-jsonrpc": "^8.2.1",
"zod": "^4.3.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@github/copilot-win32-arm64": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz",
"integrity": "sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==",
"cpu": [
"arm64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-arm64": "copilot.exe"
}
},
"node_modules/@github/copilot-win32-x64": {
"version": "1.0.63",
"resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz",
"integrity": "sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==",
"cpu": [
"x64"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
"win32"
],
"bin": {
"copilot-win32-x64": "copilot.exe"
}
},
"node_modules/@os-theme/darwin-arm64": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@os-theme/darwin-arm64/-/darwin-arm64-0.0.8.tgz",
"integrity": "sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@os-theme/linux-x64": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@os-theme/linux-x64/-/linux-x64-0.0.8.tgz",
"integrity": "sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@os-theme/win32-x64": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@os-theme/win32-x64/-/win32-x64-0.0.8.tgz",
"integrity": "sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/os-theme": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/os-theme/-/os-theme-0.0.8.tgz",
"integrity": "sha512-u1q3bLSv5uMHNIiPItkfDrHXu6ZFs2juwqxWREFM/uVBa+7Kkhy2v49LmJev2JcinGwqiEccElB/XsH9gwasuA==",
"license": "MIT",
"optionalDependencies": {
"@os-theme/darwin-arm64": "0.0.8",
"@os-theme/linux-x64": "0.0.8",
"@os-theme/win32-x64": "0.0.8"
},
"peerDependencies": {
"typescript": "^5"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz",
"integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"name": "arcade-canvas",
"version": "1.0.0",
"main": "extension.mjs",
"author": "Dan Wahlin",
"license": "MIT",
"type": "module",
"dependencies": {
"@github/copilot-sdk": "latest"
},
"description": "Play five retro Phaser mini-games in a Copilot canvas while agents work.",
"keywords": [
"arcade-games",
"copilot-canvas",
"interactive-canvas",
"phaser",
"retro-games",
"session-breaks"
]
}