Update Agent Arcade canvas (#4482)
Co-authored-by: Aaron Powell <me@aaron-powell.com>
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 538 KiB |
|
Before Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 937 KiB After Width: | Height: | Size: 1.8 MiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 686 KiB After Width: | Height: | Size: 980 KiB |
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||