synthos 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/README.md +5 -5
  2. package/default-pages/elevenlabs_effects_studio/chat-history.json +1 -0
  3. package/default-pages/elevenlabs_effects_studio/page.html +1345 -1363
  4. package/default-pages/elevenlabs_effects_studio/page.json +13 -11
  5. package/default-pages/elevenlabs_voice_studio/chat-history.json +1 -0
  6. package/default-pages/elevenlabs_voice_studio/page.html +782 -801
  7. package/default-pages/elevenlabs_voice_studio/page.json +13 -11
  8. package/default-pages/json_tools/chat-history.json +1 -0
  9. package/default-pages/json_tools/page.html +70 -90
  10. package/default-pages/json_tools/page.json +12 -10
  11. package/default-pages/my_notes/chat-history.json +1 -0
  12. package/default-pages/my_notes/page.html +115 -131
  13. package/default-pages/my_notes/page.json +14 -12
  14. package/default-pages/neon_asteroids/chat-history.json +1 -0
  15. package/default-pages/neon_asteroids/page.html +1777 -1803
  16. package/default-pages/neon_asteroids/page.json +14 -12
  17. package/default-pages/oregon_trail/chat-history.json +1 -0
  18. package/default-pages/oregon_trail/page.html +290 -307
  19. package/default-pages/oregon_trail/page.json +14 -12
  20. package/default-pages/solar_explorer/chat-history.json +1 -0
  21. package/default-pages/solar_explorer/page.html +1929 -1951
  22. package/default-pages/solar_explorer/page.json +14 -12
  23. package/default-pages/solar_tutorial/chat-history.json +1 -0
  24. package/default-pages/solar_tutorial/page.html +464 -478
  25. package/default-pages/solar_tutorial/page.json +12 -10
  26. package/default-pages/us_map/chat-history.json +1 -0
  27. package/default-pages/us_map/page.html +170 -193
  28. package/default-pages/us_map/page.json +14 -12
  29. package/default-pages/us_map/page.light.png +0 -0
  30. package/default-pages/us_map_1850/chat-history.json +1 -0
  31. package/default-pages/us_map_1850/page.html +302 -326
  32. package/default-pages/us_map_1850/page.json +14 -12
  33. package/default-pages/western_cities_1850/chat-history.json +1 -0
  34. package/default-pages/western_cities_1850/page.html +503 -527
  35. package/default-pages/western_cities_1850/page.json +14 -12
  36. package/default-themes/aurora-dawn.v3.css +15 -14
  37. package/default-themes/aurora-dusk.v3.css +26 -26
  38. package/default-themes/cosmos-dawn.v3.css +15 -14
  39. package/default-themes/cosmos-dusk.v3.css +26 -26
  40. package/default-themes/elemental-dawn.v3.css +200 -0
  41. package/default-themes/nebula-dawn.v3.css +15 -14
  42. package/default-themes/nebula-dusk.v3.css +24 -24
  43. package/default-themes/solar-flare-dawn.v3.css +15 -14
  44. package/default-themes/solar-flare-dusk.v3.css +26 -26
  45. package/dist/builders/anthropic.d.ts +26 -2
  46. package/dist/builders/anthropic.d.ts.map +1 -1
  47. package/dist/builders/anthropic.js +132 -31
  48. package/dist/builders/anthropic.js.map +1 -1
  49. package/dist/builders/claudecode.d.ts +13 -0
  50. package/dist/builders/claudecode.d.ts.map +1 -0
  51. package/dist/builders/claudecode.js +253 -0
  52. package/dist/builders/claudecode.js.map +1 -0
  53. package/dist/builders/index.d.ts +2 -1
  54. package/dist/builders/index.d.ts.map +1 -1
  55. package/dist/builders/index.js +8 -1
  56. package/dist/builders/index.js.map +1 -1
  57. package/dist/builders/openai.js +2 -1
  58. package/dist/builders/openai.js.map +1 -1
  59. package/dist/builders/types.d.ts +31 -7
  60. package/dist/builders/types.d.ts.map +1 -1
  61. package/dist/builders/types.js +60 -28
  62. package/dist/builders/types.js.map +1 -1
  63. package/dist/connectors/types.d.ts +8 -0
  64. package/dist/connectors/types.d.ts.map +1 -1
  65. package/dist/init.d.ts.map +1 -1
  66. package/dist/init.js +13 -6
  67. package/dist/init.js.map +1 -1
  68. package/dist/migrations.d.ts.map +1 -1
  69. package/dist/migrations.js +161 -14
  70. package/dist/migrations.js.map +1 -1
  71. package/dist/models/anthropic.d.ts +1 -0
  72. package/dist/models/anthropic.d.ts.map +1 -1
  73. package/dist/models/anthropic.js +129 -29
  74. package/dist/models/anthropic.js.map +1 -1
  75. package/dist/models/chainOfThought.d.ts.map +1 -1
  76. package/dist/models/chainOfThought.js +32 -19
  77. package/dist/models/chainOfThought.js.map +1 -1
  78. package/dist/models/index.d.ts +2 -2
  79. package/dist/models/index.d.ts.map +1 -1
  80. package/dist/models/index.js +2 -1
  81. package/dist/models/index.js.map +1 -1
  82. package/dist/models/providers.d.ts +1 -0
  83. package/dist/models/providers.d.ts.map +1 -1
  84. package/dist/models/providers.js +12 -4
  85. package/dist/models/providers.js.map +1 -1
  86. package/dist/models/types.d.ts +15 -1
  87. package/dist/models/types.d.ts.map +1 -1
  88. package/dist/models/types.js.map +1 -1
  89. package/dist/pages.d.ts +57 -8
  90. package/dist/pages.d.ts.map +1 -1
  91. package/dist/pages.js +258 -45
  92. package/dist/pages.js.map +1 -1
  93. package/dist/service/createCompletePrompt.d.ts.map +1 -1
  94. package/dist/service/createCompletePrompt.js +5 -0
  95. package/dist/service/createCompletePrompt.js.map +1 -1
  96. package/dist/service/mediaCache.d.ts +36 -0
  97. package/dist/service/mediaCache.d.ts.map +1 -0
  98. package/dist/service/mediaCache.js +182 -0
  99. package/dist/service/mediaCache.js.map +1 -0
  100. package/dist/service/pageValidator.d.ts +25 -0
  101. package/dist/service/pageValidator.d.ts.map +1 -0
  102. package/dist/service/pageValidator.js +315 -0
  103. package/dist/service/pageValidator.js.map +1 -0
  104. package/dist/service/server.d.ts.map +1 -1
  105. package/dist/service/server.js +4 -0
  106. package/dist/service/server.js.map +1 -1
  107. package/dist/service/sharedTableSchema.d.ts +73 -0
  108. package/dist/service/sharedTableSchema.d.ts.map +1 -0
  109. package/dist/service/sharedTableSchema.js +206 -0
  110. package/dist/service/sharedTableSchema.js.map +1 -0
  111. package/dist/service/transformPage.d.ts +49 -11
  112. package/dist/service/transformPage.d.ts.map +1 -1
  113. package/dist/service/transformPage.js +354 -241
  114. package/dist/service/transformPage.js.map +1 -1
  115. package/dist/service/useApiRoutes.d.ts.map +1 -1
  116. package/dist/service/useApiRoutes.js +288 -34
  117. package/dist/service/useApiRoutes.js.map +1 -1
  118. package/dist/service/useConnectorRoutes.d.ts.map +1 -1
  119. package/dist/service/useConnectorRoutes.js +170 -32
  120. package/dist/service/useConnectorRoutes.js.map +1 -1
  121. package/dist/service/useDataRoutes.d.ts.map +1 -1
  122. package/dist/service/useDataRoutes.js +59 -2
  123. package/dist/service/useDataRoutes.js.map +1 -1
  124. package/dist/service/useExtractRoutes.d.ts +4 -0
  125. package/dist/service/useExtractRoutes.d.ts.map +1 -0
  126. package/dist/service/useExtractRoutes.js +304 -0
  127. package/dist/service/useExtractRoutes.js.map +1 -0
  128. package/dist/service/usePageRoutes.d.ts +17 -0
  129. package/dist/service/usePageRoutes.d.ts.map +1 -1
  130. package/dist/service/usePageRoutes.js +1385 -483
  131. package/dist/service/usePageRoutes.js.map +1 -1
  132. package/dist/service/useSharedDataRoutes.d.ts.map +1 -1
  133. package/dist/service/useSharedDataRoutes.js +54 -2
  134. package/dist/service/useSharedDataRoutes.js.map +1 -1
  135. package/dist/settings.d.ts +27 -0
  136. package/dist/settings.d.ts.map +1 -1
  137. package/dist/settings.js +40 -1
  138. package/dist/settings.js.map +1 -1
  139. package/dist/themes.d.ts +0 -5
  140. package/dist/themes.d.ts.map +1 -1
  141. package/dist/themes.js +3 -95
  142. package/dist/themes.js.map +1 -1
  143. package/migration-rules/v2-to-v3.md +277 -119
  144. package/package.json +5 -1
  145. package/{default-pages/application → required-pages/_shell}/page.html +56 -42
  146. package/required-pages/_shell/page.json +14 -0
  147. package/required-pages/_starters/page.html +534 -0
  148. package/required-pages/_starters/page.json +12 -0
  149. package/required-pages/builder/page.html +353 -43
  150. package/required-pages/builder/page.json +12 -10
  151. package/required-pages/pages/page.html +697 -924
  152. package/required-pages/pages/page.json +12 -10
  153. package/required-pages/settings/page.html +1879 -1753
  154. package/required-pages/settings/page.json +12 -10
  155. package/required-pages/synthos_apis/page.html +834 -845
  156. package/required-pages/synthos_apis/page.json +12 -10
  157. package/required-pages/synthos_scripts/page.html +74 -88
  158. package/required-pages/synthos_scripts/page.json +12 -10
  159. package/scripts/append-instructions.py +90 -0
  160. package/scripts/audit-instructions.py +76 -0
  161. package/scripts/cleanup-shell-markup.mjs +112 -0
  162. package/service-connectors/buffer/connector.json +46 -0
  163. package/service-connectors/canva/connector.json +67 -0
  164. package/service-connectors/elevenlabs/connector.json +1 -1
  165. package/src/builders/anthropic.ts +155 -30
  166. package/src/builders/claudecode.ts +310 -0
  167. package/src/builders/index.ts +7 -1
  168. package/src/builders/openai.ts +2 -1
  169. package/src/builders/types.ts +93 -32
  170. package/src/connectors/types.ts +8 -0
  171. package/src/init.ts +13 -7
  172. package/src/migrations.ts +187 -16
  173. package/src/models/anthropic.ts +140 -30
  174. package/src/models/chainOfThought.ts +33 -18
  175. package/src/models/index.ts +2 -2
  176. package/src/models/providers.ts +12 -3
  177. package/src/models/types.ts +21 -1
  178. package/src/pages.ts +271 -35
  179. package/src/service/createCompletePrompt.ts +6 -0
  180. package/src/service/mediaCache.ts +206 -0
  181. package/src/service/pageValidator.ts +337 -0
  182. package/src/service/server.ts +4 -0
  183. package/src/service/sharedTableSchema.ts +236 -0
  184. package/src/service/transformPage.ts +370 -260
  185. package/src/service/useApiRoutes.ts +282 -32
  186. package/src/service/useConnectorRoutes.ts +189 -34
  187. package/src/service/useDataRoutes.ts +198 -116
  188. package/src/service/useExtractRoutes.ts +331 -0
  189. package/src/service/usePageRoutes.ts +1411 -394
  190. package/src/service/useSharedDataRoutes.ts +184 -109
  191. package/src/settings.ts +65 -0
  192. package/src/themes.ts +78 -180
  193. package/starters/blank_starter/chat-history.json +1 -0
  194. package/starters/blank_starter/page.dark.png +0 -0
  195. package/starters/blank_starter/page.html +47 -0
  196. package/starters/blank_starter/page.json +13 -0
  197. package/starters/blank_starter/page.light.png +0 -0
  198. package/starters/calculator_starter/chat-history.json +1 -0
  199. package/starters/calculator_starter/page.dark.png +0 -0
  200. package/starters/calculator_starter/page.html +232 -0
  201. package/starters/calculator_starter/page.json +13 -0
  202. package/starters/calculator_starter/page.light.png +0 -0
  203. package/starters/calendar_starter/chat-history.json +1 -0
  204. package/starters/calendar_starter/page.dark.png +0 -0
  205. package/starters/calendar_starter/page.html +495 -0
  206. package/starters/calendar_starter/page.json +13 -0
  207. package/starters/calendar_starter/page.light.png +0 -0
  208. package/starters/chat_starter/chat-history.json +1 -0
  209. package/starters/chat_starter/page.dark.png +0 -0
  210. package/starters/chat_starter/page.html +351 -0
  211. package/starters/chat_starter/page.json +13 -0
  212. package/starters/chat_starter/page.light.png +0 -0
  213. package/starters/checklist_starter/chat-history.json +1 -0
  214. package/starters/checklist_starter/page.dark.png +0 -0
  215. package/starters/checklist_starter/page.html +437 -0
  216. package/starters/checklist_starter/page.json +13 -0
  217. package/starters/checklist_starter/page.light.png +0 -0
  218. package/starters/dashboard_starter/chat-history.json +1 -0
  219. package/starters/dashboard_starter/page.dark.png +0 -0
  220. package/starters/dashboard_starter/page.html +195 -0
  221. package/starters/dashboard_starter/page.json +13 -0
  222. package/starters/dashboard_starter/page.light.png +0 -0
  223. package/starters/form_starter/chat-history.json +1 -0
  224. package/starters/form_starter/page.dark.png +0 -0
  225. package/starters/form_starter/page.html +313 -0
  226. package/starters/form_starter/page.json +13 -0
  227. package/starters/form_starter/page.light.png +0 -0
  228. package/starters/gallery_starter/chat-history.json +1 -0
  229. package/starters/gallery_starter/page.dark.png +0 -0
  230. package/starters/gallery_starter/page.html +418 -0
  231. package/starters/gallery_starter/page.json +13 -0
  232. package/starters/gallery_starter/page.light.png +0 -0
  233. package/starters/generator_starter/chat-history.json +1 -0
  234. package/starters/generator_starter/page.dark.png +0 -0
  235. package/starters/generator_starter/page.html +261 -0
  236. package/starters/generator_starter/page.json +13 -0
  237. package/starters/generator_starter/page.light.png +0 -0
  238. package/starters/index.html +538 -0
  239. package/starters/kanban_starter/chat-history.json +1 -0
  240. package/starters/kanban_starter/page.dark.png +0 -0
  241. package/starters/kanban_starter/page.html +432 -0
  242. package/starters/kanban_starter/page.json +13 -0
  243. package/starters/kanban_starter/page.light.png +0 -0
  244. package/starters/presentation_builder/chat-history.json +1 -0
  245. package/starters/presentation_builder/page.dark.png +0 -0
  246. package/starters/presentation_builder/page.html +970 -0
  247. package/starters/presentation_builder/page.json +15 -0
  248. package/starters/presentation_builder/page.light.png +0 -0
  249. package/starters/presentation_builder/presentation_voice/voice_config.json +9 -0
  250. package/starters/pulse_starter/chat-history.json +1 -0
  251. package/starters/pulse_starter/page.dark.png +0 -0
  252. package/starters/pulse_starter/page.html +698 -0
  253. package/starters/pulse_starter/page.json +13 -0
  254. package/starters/pulse_starter/page.light.png +0 -0
  255. package/starters/quiz_starter/chat-history.json +1 -0
  256. package/starters/quiz_starter/page.dark.png +0 -0
  257. package/starters/quiz_starter/page.html +292 -0
  258. package/starters/quiz_starter/page.json +13 -0
  259. package/starters/quiz_starter/page.light.png +0 -0
  260. package/starters/reference_starter/chat-history.json +1 -0
  261. package/starters/reference_starter/page.dark.png +0 -0
  262. package/starters/reference_starter/page.html +250 -0
  263. package/starters/reference_starter/page.json +13 -0
  264. package/starters/reference_starter/page.light.png +0 -0
  265. package/starters/retro_game_starter/chat-history.json +1 -0
  266. package/starters/retro_game_starter/page.dark.png +0 -0
  267. package/{default-pages → starters}/retro_game_starter/page.html +1281 -1308
  268. package/starters/retro_game_starter/page.json +15 -0
  269. package/starters/retro_game_starter/page.light.png +0 -0
  270. package/starters/roster_starter/chat-history.json +1 -0
  271. package/starters/roster_starter/page.dark.png +0 -0
  272. package/starters/roster_starter/page.html +600 -0
  273. package/starters/roster_starter/page.json +13 -0
  274. package/starters/roster_starter/page.light.png +0 -0
  275. package/starters/server.js +182 -0
  276. package/starters/start.cmd +1 -0
  277. package/starters/timeline_starter/chat-history.json +1 -0
  278. package/starters/timeline_starter/page.dark.png +0 -0
  279. package/starters/timeline_starter/page.html +446 -0
  280. package/starters/timeline_starter/page.json +13 -0
  281. package/starters/timeline_starter/page.light.png +0 -0
  282. package/starters/tutorial_starter/chat-history.json +1 -0
  283. package/starters/tutorial_starter/page.dark.png +0 -0
  284. package/starters/tutorial_starter/page.html +283 -0
  285. package/starters/tutorial_starter/page.json +13 -0
  286. package/starters/tutorial_starter/page.light.png +0 -0
  287. package/static-files/agent.v3.js +122 -0
  288. package/static-files/connector.v3.js +48 -0
  289. package/static-files/extract.v3.js +188 -0
  290. package/static-files/helpers.v3.js +50 -6
  291. package/static-files/page-bridge.js +114 -0
  292. package/static-files/page.v3.js +1292 -1290
  293. package/static-files/script.v3.js +32 -0
  294. package/static-files/server.v3.js +89 -0
  295. package/static-files/shell-bridge.v3.js +174 -0
  296. package/static-files/shell-modals.v3.js +521 -0
  297. package/static-files/{shell.css → shell.v3.css} +271 -22
  298. package/static-files/shell.v3.js +1865 -0
  299. package/static-files/storage.v3.js +176 -0
  300. package/tests/anthropic.spec.ts +42 -7
  301. package/tests/builders.spec.ts +72 -4
  302. package/tests/pageValidator.spec.ts +548 -0
  303. package/tests/profiles.spec.ts +122 -0
  304. package/tests/providers.spec.ts +1 -1
  305. package/tests/sharedTableSchema.spec.ts +242 -0
  306. package/tests/transformPage.spec.ts +62 -81
  307. package/default-pages/application/page.json +0 -10
  308. package/default-pages/retro_game_starter/page.json +0 -12
  309. package/default-pages/sidebar_page/page.html +0 -51
  310. package/default-pages/sidebar_page/page.json +0 -10
  311. package/default-pages/two-panel_page/page.html +0 -68
  312. package/default-pages/two-panel_page/page.json +0 -10
@@ -1,1803 +1,1777 @@
1
- <!DOCTYPE html><html lang="en"><head>
2
- <meta charset="UTF-8">
3
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
4
- <title>SynthOS</title>
5
- <script id="theme-info" src="/api/theme-info.js" data-locked="true"></script>
6
- <link id="theme-css" rel="stylesheet" href="/api/theme.css" data-locked="true">
7
- <style>
8
- #game-wrapper{width:100%;height:100%;display:flex;align-items:flex-start;justify-content:center;background:#000;overflow:hidden}
9
- #game-container{position:relative;border:1px solid rgba(0,255,255,.25);box-shadow:0 0 24px rgba(0,255,255,.1),inset 0 0 24px rgba(0,255,255,.05);overflow:hidden;flex-shrink:0}
10
- #gameCanvas{display:block;width:100%;height:100%}
11
- .game-ui{position:absolute;top:20px;left:20px;right:20px;display:flex;justify-content:space-between;pointer-events:none;z-index:10}
12
- .level-display,.lives-display,.score-display{font-family:Orbitron,'Segoe UI',sans-serif;font-size:18px;color:#0ff;text-shadow:0 0 10px #0ff,0 0 20px #0ff;letter-spacing:2px}
13
- .mute-btn{pointer-events:auto;background:none;border:1px solid rgba(0,255,255,.4);border-radius:4px;color:#0ff;cursor:pointer;padding:2px 6px;font-size:16px;line-height:1;opacity:.7;transition:opacity .2s}.mute-btn:hover{opacity:1}
14
- .player-indicator{font-family:Orbitron,'Segoe UI',sans-serif;font-size:18px;text-shadow:0 0 10px currentColor,0 0 20px currentColor;letter-spacing:2px;margin-right:8px}
15
- .game-over-screen,.start-screen{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;z-index:20}
16
- .game-over-screen h1,.start-screen h1{font-family:Orbitron,'Segoe UI',sans-serif;font-size:48px;line-height:1.2;color:#f0f;text-shadow:0 0 20px #f0f,0 0 40px #f0f,0 0 60px #f0f;margin-bottom:20px;letter-spacing:5px}
17
- .game-over-screen p,.start-screen p{font-size:18px;color:#0ff;text-shadow:0 0 10px #0ff;margin-bottom:30px}
18
- .restart-btn,.start-btn,.mode-btn{padding:15px 40px;font-size:18px;font-family:Orbitron,'Segoe UI',sans-serif;background:0 0;border:2px solid #0ff;color:#0ff;cursor:pointer;text-transform:uppercase;letter-spacing:3px;transition:.3s;box-shadow:0 0 20px rgba(0,255,255,.3),inset 0 0 20px rgba(0,255,255,.1)}
19
- .restart-btn:hover,.start-btn:hover,.mode-btn:hover{background:rgba(0,255,255,.2);box-shadow:0 0 30px rgba(0,255,255,.5),inset 0 0 30px rgba(0,255,255,.2);transform:scale(1.05)}
20
- .controls-info{position:absolute;bottom:16px;right:20px;font-size:13px;color:rgba(0,255,255,.5);text-align:right;letter-spacing:1px;display:flex;align-items:center;gap:16px;font-family:'Segoe UI',sans-serif}
21
- .controls-info .action-item{display:inline-flex;align-items:center;gap:4px}
22
-
23
- /* Mode selection */
24
- .mode-select{display:flex;flex-direction:column;gap:12px;align-items:center;margin-bottom:20px}
25
- .mode-btn{padding:12px 36px;font-size:16px;min-width:240px}
26
- .mode-btn.selected{background:rgba(0,255,255,.25);border-color:#fff;color:#fff;box-shadow:0 0 30px rgba(0,255,255,.6),inset 0 0 20px rgba(0,255,255,.3)}
27
-
28
- /* Player count selector */
29
- .player-count-select{display:none;flex-direction:column;align-items:center;gap:12px;margin-top:16px}
30
- .player-count-select.visible{display:flex}
31
- .player-count-row{display:flex;align-items:center;gap:16px}
32
- .player-count-arrow{font-size:28px;color:#0ff;cursor:pointer;user-select:none;text-shadow:0 0 10px #0ff;padding:4px 12px;transition:.2s}
33
- .player-count-arrow:hover{color:#fff;text-shadow:0 0 15px #fff}
34
- .player-count-num{font-family:Orbitron,'Segoe UI',sans-serif;font-size:36px;color:#f0f;text-shadow:0 0 15px #f0f,0 0 30px #f0f;min-width:50px;text-align:center}
35
- .player-count-label{font-size:14px;color:rgba(0,255,255,.6);letter-spacing:2px}
36
- .player-count-confirm{margin-top:8px}
37
-
38
- /* Sub-mode selection */
39
- .submode-select{display:none;flex-direction:column;align-items:center;gap:12px;margin-top:16px}
40
- .submode-select.visible{display:flex}
41
- .submode-title{font-family:Orbitron,'Segoe UI',sans-serif;font-size:14px;color:rgba(0,255,255,.7);letter-spacing:2px;margin-bottom:4px}
42
- .submode-btn{padding:10px 30px;font-size:14px;min-width:220px;font-family:Orbitron,'Segoe UI',sans-serif;background:0 0;border:2px solid #0ff;color:#0ff;cursor:pointer;text-transform:uppercase;letter-spacing:2px;transition:.3s;box-shadow:0 0 15px rgba(0,255,255,.2),inset 0 0 15px rgba(0,255,255,.08)}
43
- .submode-btn:hover{background:rgba(0,255,255,.2);box-shadow:0 0 25px rgba(0,255,255,.5);transform:scale(1.03)}
44
- .submode-desc{font-size:11px;color:rgba(0,255,255,.45);font-family:'Segoe UI',sans-serif;max-width:280px;text-align:center;margin-top:-4px}
45
-
46
- /* Side panels */
47
- .side-panel{width:200px;flex-shrink:0;padding:20px 16px;display:flex;flex-direction:column;font-family:Orbitron,'Segoe UI',sans-serif;box-sizing:border-box;overflow:hidden}
48
- .side-panel.hidden{display:none}
49
- .side-panel h2{font-size:16px;letter-spacing:3px;margin:0 0 16px 0;text-align:center;font-weight:700}
50
- #side-leaderboard{color:#f0f;text-shadow:0 0 10px #f0f,0 0 20px rgba(255,0,255,.4)}
51
- #side-leaderboard h2{color:#f0f;text-shadow:0 0 10px #f0f,0 0 20px #f0f}
52
- #side-controls{color:#0ff;text-shadow:0 0 10px #0ff,0 0 20px rgba(0,255,255,.4)}
53
- #side-controls h2{color:#0ff;text-shadow:0 0 10px #0ff,0 0 20px #0ff}
54
- .leaderboard-table{width:100%;border-collapse:collapse;font-family:'Courier New',monospace;font-size:13px}
55
- .leaderboard-table td{padding:4px 0;white-space:nowrap}
56
- .leaderboard-table .rank{width:24px;color:rgba(255,0,255,.6);text-align:right;padding-right:6px}
57
- .leaderboard-table .initials{color:#f0f;letter-spacing:2px}
58
- .leaderboard-table .hs-score{text-align:right;color:rgba(255,0,255,.7);position:relative;cursor:default}
59
- .leaderboard-table .hs-score.overflow{animation:scoreFlash 0.8s ease-in-out infinite}
60
- .leaderboard-table .hs-score[title]:hover::after{content:attr(title);position:absolute;right:0;top:-22px;background:#1a0028;color:#f0f;font-size:11px;padding:2px 6px;border:1px solid rgba(255,0,255,.4);border-radius:3px;white-space:nowrap;z-index:5;pointer-events:none}
61
- @keyframes scoreFlash{0%,100%{opacity:1}50%{opacity:.4}}
62
- .score-display .score-overflow{animation:hudScoreFlash 0.8s ease-in-out infinite}
63
- @keyframes hudScoreFlash{0%,100%{color:#0ff;text-shadow:0 0 10px #0ff,0 0 20px #0ff}50%{color:#f0f;text-shadow:0 0 10px #f0f,0 0 20px #f0f}}
64
- .controls-list{list-style:none;padding:0;margin:0;font-size:12px}
65
- .controls-list li{padding:6px 0;display:flex;gap:8px;align-items:center;color:#0ff}
66
- .controls-list .key-icon{display:inline-block;min-width:50px;padding:2px 6px;border:1px solid rgba(0,255,255,.4);border-radius:3px;text-align:center;font-size:11px;color:#0ff;background:rgba(0,255,255,.05);flex-shrink:0}
67
- .controls-list .key-desc{color:rgba(0,255,255,.7);font-family:'Segoe UI',sans-serif}
68
- .bt-section{margin-top:auto;padding-top:16px;border-top:1px solid rgba(0,255,255,.15)}
69
- .bt-section h3{font-size:11px;letter-spacing:2px;color:rgba(0,255,255,.5);margin:0 0 8px 0;text-align:center;font-weight:400}
70
- .bt-section p{font-size:11px;color:rgba(0,255,255,.4);font-family:'Segoe UI',sans-serif;margin:0 0 4px 0;line-height:1.4}
71
- .side-panel{overflow-y:auto}
72
- #side-leaderboard{padding-top:20px}
73
- #side-controls{padding-top:20px}
74
-
75
- /* Gamepad status indicator */
76
- #gamepadStatus{text-align:center;padding:8px 0;margin-bottom:12px;font-size:11px;letter-spacing:2px;border:1px solid rgba(0,255,255,.15);border-radius:4px;transition:all .3s;color:rgba(0,255,255,.3);text-shadow:none}
77
- #gamepadStatus.connected{color:#0f0;text-shadow:0 0 8px #0f0,0 0 16px rgba(0,255,0,.4);border-color:rgba(0,255,0,.3);background:rgba(0,255,0,.05);animation:gpPulse 2s ease-in-out infinite}
78
- @keyframes gpPulse{0%,100%{box-shadow:0 0 4px rgba(0,255,0,.1)}50%{box-shadow:0 0 12px rgba(0,255,0,.25)}}
79
-
80
- /* Pause overlay */
81
- #pauseOverlay{position:absolute;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;z-index:25;background:rgba(0,0,0,.5);flex-direction:column;gap:8px}
82
- #pauseOverlay.visible{display:flex}
83
- #pauseOverlay .pause-title{font-family:Orbitron,'Segoe UI',sans-serif;font-size:48px;color:#0ff;text-shadow:0 0 20px #0ff,0 0 40px #0ff,0 0 60px #0ff;letter-spacing:8px}
84
- #pauseOverlay .pause-hint{font-family:'Segoe UI',sans-serif;font-size:14px;color:rgba(0,255,255,.5);letter-spacing:1px}
85
-
86
- /* High score entry */
87
- #highScoreEntry{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;z-index:30;display:none}
88
- #highScoreEntry h2{font-family:Orbitron,'Segoe UI',sans-serif;font-size:24px;color:#f0f;text-shadow:0 0 15px #f0f,0 0 30px #f0f;margin-bottom:10px;letter-spacing:3px}
89
- #highScoreEntry .hs-final-score{font-family:Orbitron,'Segoe UI',sans-serif;font-size:18px;color:#0ff;text-shadow:0 0 10px #0ff;margin-bottom:20px}
90
- .initial-slots{display:flex;justify-content:center;gap:12px;margin-bottom:20px}
91
- .initial-slot{width:40px;height:50px;border:2px solid #f0f;display:flex;align-items:center;justify-content:center;font-family:Orbitron,'Segoe UI',sans-serif;font-size:28px;color:#f0f;text-shadow:0 0 10px #f0f;box-shadow:0 0 10px rgba(255,0,255,.3)}
92
- .initial-slot.active{border-color:#0ff;color:#0ff;text-shadow:0 0 10px #0ff;box-shadow:0 0 15px rgba(0,255,255,.5)}
93
- .hs-instructions{font-size:12px;color:rgba(0,255,255,.6);font-family:'Segoe UI',sans-serif;margin-bottom:16px}
94
-
95
- /* Keyboard key hint (for bottom bar) */
96
- .kb-key{display:inline-block;padding:1px 6px;border:1px solid rgba(0,255,255,.35);border-radius:3px;font-size:11px;color:rgba(0,255,255,.7);background:rgba(0,255,255,.05);vertical-align:middle;margin:0 2px;font-family:'Segoe UI',sans-serif}
97
- /* Focused menu item */
98
- .mode-btn.focused,.submode-btn.focused{background:rgba(0,255,255,.2);border-color:#fff;color:#fff;box-shadow:0 0 25px rgba(0,255,255,.5),inset 0 0 20px rgba(0,255,255,.2)}
99
-
100
- /* Turn ready overlay */
101
- #turnReadyOverlay{position:absolute;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:12px;z-index:26;background:rgba(0,0,0,.7)}
102
- #turnReadyOverlay.visible{display:flex}
103
- #turnReadyOverlay .turn-player{font-family:Orbitron,'Segoe UI',sans-serif;font-size:42px;text-shadow:0 0 20px currentColor,0 0 40px currentColor;letter-spacing:6px}
104
- #turnReadyOverlay .turn-subtitle{font-family:Orbitron,'Segoe UI',sans-serif;font-size:22px;color:#fff;text-shadow:0 0 12px rgba(255,255,255,.6);letter-spacing:4px;margin-bottom:8px}
105
- #turnReadyOverlay .turn-prompt{font-family:'Segoe UI',sans-serif;font-size:16px;color:rgba(0,255,255,.6);letter-spacing:1px}
106
-
107
- /* Final scoreboard overlay */
108
- #finalScoreboard{position:absolute;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:16px;z-index:27;background:rgba(0,0,0,.8)}
109
- #finalScoreboard.visible{display:flex}
110
- #finalScoreboard .sb-title{font-family:Orbitron,'Segoe UI',sans-serif;font-size:32px;color:#f0f;text-shadow:0 0 20px #f0f,0 0 40px #f0f;letter-spacing:5px;margin-bottom:8px}
111
- #finalScoreboard .sb-subtitle{font-family:Orbitron,'Segoe UI',sans-serif;font-size:14px;color:rgba(255,255,255,.5);letter-spacing:3px;margin-bottom:12px}
112
- .sb-table{border-collapse:collapse;font-family:Orbitron,'Segoe UI',sans-serif;font-size:18px}
113
- .sb-table td{padding:8px 16px}
114
- .sb-table .sb-rank{color:rgba(255,255,255,.5);text-align:right;font-size:14px}
115
- .sb-table .sb-player{letter-spacing:3px}
116
- .sb-table .sb-initials{font-size:14px;color:rgba(255,255,255,.5);letter-spacing:2px}
117
- .sb-table .sb-score{text-align:right;color:#fff;letter-spacing:2px}
118
- .sb-play-again{margin-top:16px}
119
- </style>
120
- <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&amp;display=swap" rel="stylesheet">
121
- <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
122
- </head>
123
-
124
- <body>
125
- <div class="shell-toolbar" data-locked="true">
126
- <button class="shell-toolbar-btn" id="builderToggle" aria-label="Page Builder" data-locked="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M7 18.5H6.2c-1.77 0-3.2-1.43-3.2-3.2V7.7C3 5.93 4.43 4.5 6.2 4.5h11.6c1.77 0 3.2 1.43 3.2 3.2v7.6c0 1.77-1.43 3.2-3.2 3.2H12l-4.2 3.2c-.5.38-1.2.02-1.2-.6V18.5Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"></path><circle cx="8.5" cy="11.5" r="1" fill="currentColor"></circle><circle cx="12" cy="11.5" r="1" fill="currentColor"></circle><circle cx="15.5" cy="11.5" r="1" fill="currentColor"></circle></svg></button>
127
- <button class="shell-toolbar-btn" id="pagesBtn" aria-label="View All Pages" data-locked="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"><rect x="3" y="3" width="11" height="12" rx="1.5" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"></rect><path d="M6 7.5h5M6 10h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path><rect x="18" y="3" width="11" height="12" rx="1.5" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"></rect><path d="M21 7.5h5M21 10h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path><rect x="3" y="18" width="11" height="12" rx="1.5" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"></rect><path d="M6 22.5h5M6 25h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path><rect x="18" y="18" width="11" height="12" rx="1.5" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"></rect><path d="M21 22.5h5M21 25h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path></svg></button>
128
- <button class="shell-toolbar-btn" id="saveBtn" aria-label="Save Page" data-locked="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"></path><path d="M17 21v-8H7v8" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"></path><path d="M7 3v5h8" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"></path></svg></button>
129
- <div class="shell-toolbar-spacer" data-locked="true"></div>
130
- <button class="shell-toolbar-btn" id="settingsBtn" aria-label="Settings" data-locked="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" stroke="currentColor" stroke-width="1.8"></path><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></svg></button>
131
- </div>
132
- <div class="chat-panel" data-locked="true">
133
- <div class="chat-header" data-locked="true"><span>Page Builder</span><button class="chat-header-close" id="builderClose" aria-label="Close builder" data-locked="true">×</button></div>
134
- <div class="chat-messages" id="chatMessages" data-locked="true">
135
- <div class="chat-message">
136
- <p><strong>SynthOS:</strong> <b>NEON ASTEROIDS</b> &mdash; Destroy asteroids, dodge saucers, collect power-ups. Controls: <b>WASD/Arrows</b> to move, <b>Space</b> to fire, <b>H</b> for hyperspace, <b>P</b> to pause. Gamepad supported. Up to 4 players in Pass &amp; Play mode.</p>
137
- <p>Want to build your own retro game? Start from the <a href="/retro_game_starter">Retro Game Starter</a> — it gives you a ready-made canvas, game loop, and input handling so you can jump straight into designing your game.</p>
138
- </div>
139
- </div>
140
-
141
- <form action="/" method="POST" id="chatForm" data-locked="true">
142
- <textarea class="chat-input" id="chatInput" name="message" rows="2" placeholder="Type a message..." data-locked="true"></textarea>
143
- </form>
144
- </div>
145
- <div class="viewer-panel full-viewer" id="viewerPanel" style="background:#000;">
146
- <div id="game-wrapper">
147
- <div id="side-leaderboard" class="side-panel hidden">
148
- <h2>HIGH SCORES</h2>
149
- <table class="leaderboard-table">
150
- <tbody id="leaderboardBody"></tbody>
151
- </table>
152
- </div>
153
- <div id="game-container">
154
- <canvas id="gameCanvas"></canvas>
155
- <div class="game-ui">
156
- <div class="score-display"><span id="playerIndicator" class="player-indicator" style="display:none"></span>SCORE: <span id="score">00000</span></div>
157
- <div class="level-display">LEVEL: <span id="level">1</span></div>
158
- <div class="lives-display">LIVES: <span id="lives">3</span></div>
159
- <button class="mute-btn" id="muteBtn" title="Toggle sound">&#128266;</button>
160
- </div>
161
- <div id="pauseOverlay"><span class="pause-title">PAUSED</span><span class="pause-hint" id="pauseHint"></span></div>
162
- <div id="turnReadyOverlay">
163
- <div class="turn-player" id="turnPlayerName">PLAYER 1</div>
164
- <div class="turn-subtitle">YOUR TURN</div>
165
- <div class="turn-prompt" id="turnPrompt">Press SPACE to start</div>
166
- </div>
167
- <div id="finalScoreboard">
168
- <div class="sb-title">GAME OVER</div>
169
- <div class="sb-subtitle" id="sbSubtitle">FINAL STANDINGS</div>
170
- <table class="sb-table"><tbody id="sbBody"></tbody></table>
171
- <button class="restart-btn sb-play-again" id="sbPlayAgain">PLAY AGAIN</button>
172
- </div>
173
- <div class="start-screen" id="startScreen">
174
- <h1>NEON<br>ASTEROIDS</h1>
175
- <p>Navigate the cosmic void. Destroy all asteroids. Beware the saucers!</p>
176
- <div class="mode-select" id="modeSelect">
177
- <button class="mode-btn" id="btn1P">START MISSION</button>
178
- <button class="mode-btn" id="btnPassPlay">PASS &amp; PLAY</button>
179
- </div>
180
- <div class="submode-select" id="submodeSelect">
181
- <div class="submode-title">CHOOSE MODE</div>
182
- <button class="submode-btn" id="btnSoloTurns">SOLO TURNS</button>
183
- <div class="submode-desc">Each player gets a fresh game. Highest total score wins.</div>
184
- <button class="submode-btn" id="btnHotSeat">HOT SEAT</button>
185
- <div class="submode-desc">Shared asteroid field. Rotate on each death. Last one standing!</div>
186
- </div>
187
- <div class="player-count-select" id="playerCountSelect">
188
- <div class="player-count-label">PLAYERS</div>
189
- <div class="player-count-row">
190
- <span class="player-count-arrow" id="pcLeft">←</span>
191
- <span class="player-count-num" id="pcNum">2</span>
192
- <span class="player-count-arrow" id="pcRight">→</span>
193
- </div>
194
- <button class="mode-btn player-count-confirm" id="pcConfirm">START</button>
195
- </div>
196
- </div>
197
- <div class="game-over-screen" id="gameOverScreen" style="display: none;">
198
- <h1>GAME OVER</h1>
199
- <p>Final Score: <span id="finalScore">0</span></p>
200
- <button class="restart-btn" id="restartBtn">TRY AGAIN</button>
201
- </div>
202
- <div id="highScoreEntry">
203
- <h2>NEW HIGH SCORE!</h2>
204
- <div class="hs-final-score">SCORE: <span id="hsScore">0</span></div>
205
- <div class="initial-slots">
206
- <div class="initial-slot active" id="slot0">A</div>
207
- <div class="initial-slot" id="slot1">A</div>
208
- <div class="initial-slot" id="slot2">A</div>
209
- </div>
210
- <div class="hs-instructions" id="hsInstructions">LEFT/RIGHT select slot • UP/DOWN change letter • ENTER confirm</div>
211
- </div>
212
- <div class="controls-info" id="controlsInfo">WASD / ARROWS to move • SPACE to fire • H hyperspace • P pause</div>
213
- </div>
214
- <div id="side-controls" class="side-panel hidden">
215
- <div id="gamepadStatus">NO GAMEPAD</div>
216
- <h2>CONTROLS</h2>
217
- <ul class="controls-list" id="controlsList">
218
- <li><span class="key-icon">W/↑</span><span class="key-desc">Thrust</span></li>
219
- <li><span class="key-icon">A/←</span><span class="key-desc">Rotate Left</span></li>
220
- <li><span class="key-icon">D/→</span><span class="key-desc">Rotate Right</span></li>
221
- <li><span class="key-icon">SPACE</span><span class="key-desc">Fire</span></li>
222
- <li><span class="key-icon">H</span><span class="key-desc">Hyperspace</span></li>
223
- <li><span class="key-icon">P</span><span class="key-desc">Pause</span></li>
224
- </ul>
225
- <h2 style="margin-top:24px">POWER-UPS</h2>
226
- <ul class="controls-list">
227
- <li><span class="key-icon" style="color:#0ff;border-color:rgba(0,255,255,.4)">S</span><span class="key-desc">Shield</span></li>
228
- <li><span class="key-icon" style="color:#f00;border-color:rgba(255,0,0,.4)">R</span><span class="key-desc">Rapid Fire</span></li>
229
- <li><span class="key-icon" style="color:#ff0;border-color:rgba(255,255,0,.4)">M</span><span class="key-desc">Multi-Shot</span></li>
230
- <li><span class="key-icon" style="color:#0f0;border-color:rgba(0,255,0,.4)">+</span><span class="key-desc">Extra Life</span></li>
231
- </ul>
232
- <div class="bt-section" id="btSection"></div>
233
- </div>
234
- </div>
235
- <div id="loadingOverlay" class="loading-overlay">
236
- <div class="spinner"></div>
237
- </div>
238
- </div>
239
- <div id="instructions" style="display: none;" data-locked="true"></div>
240
- <div id="thoughts" style="display: none;" data-locked="true"></div>
241
- <script src="/static/retro-game.js"></script>
242
- <script id="asteroids-game">
243
- RetroGame.injectCSS();
244
-
245
- // --- Sound Effects ---
246
- var sfx = RetroGame.createSoundEffects();
247
- var sfxReady = false;
248
- sfx.load().then(function() {
249
- console.log('✓ Sound effects loaded successfully');
250
- sfxReady = true;
251
- }).catch(function(e) {
252
- console.error('✗ Sound effects failed to load:', e);
253
- console.error('Make sure effects.json exists in page file storage');
254
- console.error('Upload it via: synthos.files.upload("effects.json", blob)');
255
- });
256
-
257
- // Mute toggle
258
- var sfxMuted = false;
259
- document.getElementById("muteBtn").addEventListener("click", function() {
260
- sfxMuted = !sfxMuted;
261
- sfx.volume = sfxMuted ? 0 : 1;
262
- this.innerHTML = sfxMuted ? "\u{1F507}" : "\u{1F50A}";
263
- });
264
-
265
- var ambientPlaying = false;
266
- var ambientTimeout = null;
267
- var AMBIENT_TRACKS = ['Ambient_Space', 'Ambient_Space2', 'Ambient_Space3'];
268
- var currentAmbientIndex = -1;
269
-
270
- function startAmbient() {
271
- if (ambientPlaying) return;
272
- ambientPlaying = true;
273
- playNextAmbient();
274
- }
275
-
276
- function playNextAmbient() {
277
- if (!ambientPlaying || !sfxReady) return;
278
- // Pick a random track different from the last one
279
- let idx;
280
- do { idx = Math.floor(Math.random() * AMBIENT_TRACKS.length); } while (idx === currentAmbientIndex && AMBIENT_TRACKS.length > 1);
281
- currentAmbientIndex = idx;
282
- sfx.play(AMBIENT_TRACKS[idx]);
283
- }
284
-
285
- function stopAmbient() {
286
- ambientPlaying = false;
287
- if (ambientTimeout) { clearTimeout(ambientTimeout); ambientTimeout = null; }
288
- if (sfxReady) AMBIENT_TRACKS.forEach(function(t) { sfx.stop(t); });
289
- currentAmbientIndex = -1;
290
- }
291
-
292
- var canvas = document.getElementById("gameCanvas");
293
- var ctx = canvas.getContext("2d");
294
- var viewerPanel = document.getElementById("viewerPanel");
295
- var gameContainer = document.getElementById("game-container");
296
- var sideLeaderboard = document.getElementById("side-leaderboard");
297
- var sideControls = document.getElementById("side-controls");
298
- var pauseOverlay = document.getElementById("pauseOverlay");
299
- var ASPECT = 4 / 3;
300
- var MAX_SCORE = 99999;
301
-
302
-
303
- // Player colors: P1=cyan, P2=magenta, P3=yellow, P4=green
304
- var PLAYER_COLORS = ["#00ffff", "#ff00ff", "#ffff00", "#00ff00", "#ff6600", "#ff3388", "#33ccff", "#ccff33"];
305
-
306
- var resizeTimer = 0;
307
-
308
- function resizeCanvas() {
309
- const vw = viewerPanel.clientWidth;
310
- const vh = viewerPanel.clientHeight;
311
- if (vw < 1 || vh < 1) return;
312
- const panelRatio = vw / vh;
313
- let w, h;
314
- if (panelRatio > ASPECT) {
315
- h = vh;
316
- w = Math.floor(h * ASPECT);
317
- } else {
318
- w = vw;
319
- h = Math.floor(w / ASPECT);
320
- }
321
- gameContainer.style.width = w + "px";
322
- gameContainer.style.height = h + "px";
323
- canvas.width = w;
324
- canvas.height = h;
325
- const extraSpace = vw - w;
326
- if (extraSpace > 400) {
327
- sideLeaderboard.classList.remove("hidden");
328
- sideControls.classList.remove("hidden");
329
- } else {
330
- sideLeaderboard.classList.add("hidden");
331
- sideControls.classList.add("hidden");
332
- }
333
- }
334
-
335
- function debouncedResize() {
336
- clearTimeout(resizeTimer);
337
- resizeCanvas();
338
- resizeTimer = setTimeout(resizeCanvas, 300);
339
- }
340
-
341
- resizeCanvas();
342
- window.addEventListener("resize", resizeCanvas);
343
- new MutationObserver(debouncedResize).observe(document.body, { attributes: true, attributeFilter: ["class"] });
344
-
345
- // --- Gamepad input (library) ---
346
- var gamepad = RetroGame.createGamepad({ deadzone: 0.3 });
347
- var B = RetroGame.BUTTON;
348
- var gamepadUI = RetroGame.createGamepadUI(gamepad, { statusElement: document.getElementById("gamepadStatus") });
349
-
350
- // Game-specific gamepad action mappings
351
- function gpFire() { return gamepad.isDown(B.A) || gamepad.isDown(B.X) || gamepad.isDown(B.RB) || gamepad.isDown(B.RT); }
352
- function gpHyper() { return gamepad.wasPressed(B.B) || gamepad.wasPressed(B.LB); }
353
- function gpPause() { return gamepad.wasPressed(B.START); }
354
- function gpConfirm() { return gamepad.wasPressed(B.A); }
355
- function gpBack() { return gamepad.wasPressed(B.B); }
356
-
357
- // Edge-triggered directional helpers (stick + dpad)
358
- // Track previous directional state for stick-based edge detection
359
- var _prevDir = { up: false, down: false, left: false, right: false };
360
- function gpUp() { var r = gamepad.up && !_prevDir.up; return r; }
361
- function gpDown() { var r = gamepad.down && !_prevDir.down; return r; }
362
- function gpLeft() { var r = gamepad.left && !_prevDir.left; return r; }
363
- function gpRight() { var r = gamepad.right && !_prevDir.right; return r; }
364
- function saveDirState() { _prevDir.up = gamepad.up; _prevDir.down = gamepad.down; _prevDir.left = gamepad.left; _prevDir.right = gamepad.right; }
365
-
366
- // Delegate to library UI
367
- function gpBtn(name) { return gamepadUI.buttonGlyph(name); }
368
- function gpBtnName(name) { return gamepadUI.buttonName(name); }
369
- function gpStartName() { return gamepadUI.startName(); }
370
-
371
- // Returns styled HTML for a non-face button label (game-specific, not in library)
372
- function gpLabel(text) {
373
- return '<span class="gp-label">' + text + '</span>';
374
- }
375
-
376
- // --- Gamepad-aware text helper ---
377
- function btnText(kbLabel, gpLabelText) {
378
- return gamepad.connected ? gpLabelText : kbLabel;
379
- }
380
-
381
- // Keyboard key styled span
382
- function kbKey(text) {
383
- return '<span class="kb-key">' + text + '</span>';
384
- }
385
-
386
- // Build an action item for the bottom bar
387
- function actionItem(hint, label) {
388
- return '<span class="action-item">' + hint + ' ' + label + '</span>';
389
- }
390
-
391
- // --- Unified bottom bar: menu nav OR game controls ---
392
- function updateBottomBar() {
393
- var bar = document.getElementById("controlsInfo");
394
- var startVisible = document.getElementById("startScreen").style.display !== "none";
395
- var gameOverVisible = document.getElementById("gameOverScreen").style.display === "block";
396
- var scoreboardVisible = document.getElementById("finalScoreboard").classList.contains("visible");
397
-
398
- // Menu screens show navigation hints
399
- if (startVisible) {
400
- if (gamepad.connected) {
401
- if (startScreenPhase === "mode") {
402
- bar.innerHTML =
403
- actionItem(gpLabel("\u2191\u2193"), "Navigate") +
404
- actionItem(gpBtn("a"), "Select");
405
- } else if (startScreenPhase === "submode") {
406
- bar.innerHTML =
407
- actionItem(gpLabel("\u2191\u2193"), "Navigate") +
408
- actionItem(gpBtn("a"), "Select") +
409
- actionItem(gpBtn("b"), "Back");
410
- } else if (startScreenPhase === "count") {
411
- bar.innerHTML =
412
- actionItem(gpLabel("\u2190\u2192"), "Players") +
413
- actionItem(gpBtn("a"), "Start") +
414
- actionItem(gpBtn("b"), "Back");
415
- }
416
- } else {
417
- if (startScreenPhase === "mode") {
418
- bar.innerHTML =
419
- actionItem(kbKey("\u2191\u2193"), "Navigate") +
420
- actionItem(kbKey("ENTER"), "Select");
421
- } else if (startScreenPhase === "submode") {
422
- bar.innerHTML =
423
- actionItem(kbKey("\u2191\u2193"), "Navigate") +
424
- actionItem(kbKey("ENTER"), "Select") +
425
- actionItem(kbKey("ESC"), "Back");
426
- } else if (startScreenPhase === "count") {
427
- bar.innerHTML =
428
- actionItem(kbKey("\u2190\u2192"), "Players") +
429
- actionItem(kbKey("ENTER"), "Start") +
430
- actionItem(kbKey("ESC"), "Back");
431
- }
432
- }
433
- return;
434
- }
435
-
436
- // High score entry
437
- if (hsEntryActive) {
438
- if (gamepad.connected) {
439
- bar.innerHTML =
440
- actionItem(gpLabel("\u2191\u2193"), "Letter") +
441
- actionItem(gpBtn("a"), "Next") +
442
- actionItem(gpBtn("b"), "Back");
443
- } else {
444
- bar.innerHTML =
445
- actionItem(kbKey("\u2191\u2193"), "Letter") +
446
- actionItem(kbKey("\u2190\u2192"), "Slot") +
447
- actionItem(kbKey("ENTER"), "Confirm");
448
- }
449
- return;
450
- }
451
-
452
- // Game over / scoreboard
453
- if (gameOverVisible || scoreboardVisible) {
454
- if (gamepad.connected) {
455
- bar.innerHTML = actionItem(gpBtn("a"), "Continue");
456
- } else {
457
- bar.innerHTML = actionItem(kbKey("ENTER"), "Continue");
458
- }
459
- return;
460
- }
461
-
462
- // In-game controls
463
- if (gamepad.connected) {
464
- bar.innerHTML =
465
- actionItem(gpLabel("L-Stick"), "Move") +
466
- actionItem(gpBtn("a"), "Fire") +
467
- actionItem(gpBtn("b"), "Hyperspace") +
468
- actionItem(gpLabel(gpStartName()), "Pause");
469
- } else {
470
- bar.innerHTML =
471
- actionItem(kbKey("WASD"), "Move") +
472
- actionItem(kbKey("SPACE"), "Fire") +
473
- actionItem(kbKey("H"), "Hyperspace") +
474
- actionItem(kbKey("P"), "Pause");
475
- }
476
- }
477
-
478
- function updateAllPrompts() {
479
- // Start screen buttons
480
- document.getElementById("btn1P").textContent = gamepad.connected ? "1 PLAYER" : "START MISSION";
481
- document.getElementById("btnPassPlay").textContent = "PASS & PLAY";
482
- document.getElementById("btnSoloTurns").textContent = "SOLO TURNS";
483
- document.getElementById("btnHotSeat").textContent = "HOT SEAT";
484
- // Focus highlight
485
- updateMenuFocus();
486
- // Restart button
487
- var restartBtn = document.getElementById("restartBtn");
488
- if (gamepad.connected) {
489
- restartBtn.innerHTML = gpBtn("a") + ' RETRY';
490
- } else {
491
- restartBtn.textContent = "TRY AGAIN";
492
- }
493
- // HS instructions
494
- document.getElementById("hsInstructions").innerHTML = btnText(
495
- "LEFT/RIGHT select slot &bull; UP/DOWN change letter &bull; ENTER confirm",
496
- gpLabel("\u2191\u2193") + " change letter &bull; " + gpBtn("a") + " next &bull; " + gpBtn("b") + " back"
497
- );
498
- // Pause hint
499
- document.getElementById("pauseHint").innerHTML = btnText(
500
- "P to resume",
501
- gpLabel(gpStartName()) + " to resume"
502
- );
503
- // Turn ready prompt
504
- document.getElementById("turnPrompt").innerHTML = btnText(
505
- "Press SPACE to start",
506
- "Press " + gpBtn("a") + " to start"
507
- );
508
- // Player count confirm
509
- var pcConfirm = document.getElementById("pcConfirm");
510
- if (gamepad.connected) {
511
- pcConfirm.innerHTML = gpBtn("a") + ' START';
512
- } else {
513
- pcConfirm.textContent = "START";
514
- }
515
- // Scoreboard play again
516
- var sbBtn = document.getElementById("sbPlayAgain");
517
- if (gamepad.connected) {
518
- sbBtn.innerHTML = gpBtn("a") + ' PLAY AGAIN';
519
- } else {
520
- sbBtn.textContent = "PLAY AGAIN";
521
- }
522
- // Bottom bar
523
- updateBottomBar();
524
- }
525
-
526
- // --- High Score System (synthos.data, top 20) ---
527
- var MAX_LEADERBOARD = 20;
528
- var _highScores = [{ initials: "ACE", score: 10000 }];
529
-
530
- // Load scores from app table storage on startup
531
- (async function() {
532
- try {
533
- var row = await synthos.data.get('high_scores', 'scores');
534
- if (row && Array.isArray(row.entries) && row.entries.length > 0) {
535
- _highScores = row.entries;
536
- }
537
- } catch(e) {}
538
- renderLeaderboard();
539
- })();
540
-
541
- function loadHighScores() {
542
- return _highScores;
543
- }
544
-
545
- function saveHighScores(scores) {
546
- _highScores = scores;
547
- synthos.data.save('high_scores', { id: 'scores', entries: scores }).catch(function() {});
548
- }
549
-
550
- function qualifiesForHighScore(s) {
551
- var scores = loadHighScores();
552
- return scores.length < MAX_LEADERBOARD || s > scores[scores.length - 1].score;
553
- }
554
-
555
- function insertHighScore(initials, s) {
556
- var scores = loadHighScores().slice();
557
- scores.push({ initials: initials, score: s });
558
- scores.sort(function(a, b) { return b.score - a.score; });
559
- if (scores.length > MAX_LEADERBOARD) scores.length = MAX_LEADERBOARD;
560
- saveHighScores(scores);
561
- renderLeaderboard();
562
- }
563
-
564
- function formatLeaderboardScore(s) {
565
- if (s > MAX_SCORE) {
566
- return { text: "99,999+", overflow: true };
567
- }
568
- return { text: s.toLocaleString(), overflow: false };
569
- }
570
-
571
- function renderLeaderboard() {
572
- var scores = loadHighScores();
573
- var tbody = document.getElementById("leaderboardBody");
574
- tbody.innerHTML = "";
575
- scores.forEach(function(entry, i) {
576
- var tr = document.createElement("tr");
577
- var fmt = formatLeaderboardScore(entry.score);
578
- var overflowClass = fmt.overflow ? " overflow" : "";
579
- var titleAttr = fmt.overflow ? ' title="' + entry.score.toLocaleString() + '"' : "";
580
- tr.innerHTML =
581
- '<td class="rank">' + (i + 1) + '.</td>' +
582
- '<td class="initials">' + entry.initials + '</td>' +
583
- '<td class="hs-score' + overflowClass + '"' + titleAttr + '>' + fmt.text + '</td>';
584
- tbody.appendChild(tr);
585
- });
586
- }
587
-
588
- renderLeaderboard();
589
-
590
- // --- BT Controller Instructions (platform-aware) ---
591
- (function() {
592
- const el = document.getElementById("btSection");
593
- const isMac = /Mac|iPhone|iPad/.test(navigator.platform) || /Macintosh/.test(navigator.userAgent);
594
- if (isMac) {
595
- el.innerHTML =
596
- '<h3>GAMEPAD</h3>' +
597
- '<p>1. Put controller in pairing mode</p>' +
598
- '<p>2. Open <b>System Settings &gt; Bluetooth</b></p>' +
599
- '<p>3. Find controller in list, click <b>Connect</b></p>' +
600
- '<p>4. Reload page once paired</p>';
601
- } else {
602
- el.innerHTML =
603
- '<h3>GAMEPAD</h3>' +
604
- '<p>1. Put controller in pairing mode</p>' +
605
- '<p>2. Open <b>Settings &gt; Bluetooth &amp; devices</b></p>' +
606
- '<p>3. Click <b>Add device &gt; Bluetooth</b></p>' +
607
- '<p>4. Select controller, then reload page</p>';
608
- }
609
- })();
610
-
611
- // --- Gamepad Detection & Controls Swap ---
612
- var KEYBOARD_CONTROLS = '<li><span class="key-icon">W/\u2191</span><span class="key-desc">Thrust</span></li>' +
613
- '<li><span class="key-icon">A/\u2190</span><span class="key-desc">Rotate Left</span></li>' +
614
- '<li><span class="key-icon">D/\u2192</span><span class="key-desc">Rotate Right</span></li>' +
615
- '<li><span class="key-icon">SPACE</span><span class="key-desc">Fire</span></li>' +
616
- '<li><span class="key-icon">H</span><span class="key-desc">Hyperspace</span></li>' +
617
- '<li><span class="key-icon">P</span><span class="key-desc">Pause</span></li>';
618
-
619
- function buildGamepadControls() {
620
- return '<li><span class="key-icon">L-Stick</span><span class="key-desc">Thrust / Rotate</span></li>' +
621
- '<li><span class="key-icon">D-Pad</span><span class="key-desc">Thrust / Rotate</span></li>' +
622
- '<li><span class="key-icon">' + gpBtnName("a") + ' / RB</span><span class="key-desc">Fire</span></li>' +
623
- '<li><span class="key-icon">' + gpBtnName("b") + ' / LB</span><span class="key-desc">Hyperspace</span></li>' +
624
- '<li><span class="key-icon">' + gpStartName() + '</span><span class="key-desc">Pause</span></li>';
625
- }
626
-
627
- function refreshGamepadUI() {
628
- gamepadUI.update();
629
- var listEl = document.getElementById("controlsList");
630
- var btEl = document.getElementById("btSection");
631
- if (gamepad.connected) {
632
- listEl.innerHTML = buildGamepadControls();
633
- btEl.style.display = "none";
634
- } else {
635
- listEl.innerHTML = KEYBOARD_CONTROLS;
636
- btEl.style.display = "";
637
- }
638
- updateAllPrompts();
639
- }
640
-
641
- gamepad.onConnect = function() { refreshGamepadUI(); };
642
- gamepad.onDisconnect = function() { refreshGamepadUI(); };
643
-
644
- // --- Game Mode State ---
645
- var gameMode = "single"; // "single", "solo_turns", "hot_seat"
646
- var playerCount = 2;
647
- var currentPlayer = 0;
648
- var players = []; // {score, lives, level, alive, initials}
649
- var turnReady = false;
650
- var startScreenPhase = "mode"; // "mode", "submode", "count"
651
-
652
- // --- Game State ---
653
- var gameRunning = false, gamePaused = false, score = 0, lives = 3, level = 1;
654
- var shake = RetroGame.createShake();
655
- var hyperspaceCooldown = 0;
656
- var hyperspaceInvincible = 0;
657
-
658
- var ship = {
659
- x: 0, y: 0, vx: 0, vy: 0, angle: -Math.PI / 2,
660
- radius: 15, thrust: 0, rotationSpeed: 0,
661
- invincible: 0, shield: 0, rapidFire: 0, multiShot: 0
662
- };
663
-
664
- var bullets = [], asteroids = [], powerUps = [], stars = [];
665
- var saucers = [], saucerBullets = [], saucerSpawnTimer = 0;
666
- var keyboard = RetroGame.createKeyboard();
667
- var particles = RetroGame.createParticles({ maxParticles: 500 });
668
-
669
- // --- High Score Entry State ---
670
- var hsEntryActive = false;
671
- var hsSlots = [0, 0, 0];
672
- var hsActiveSlot = 0;
673
- var hsFinalScore = 0;
674
- var hsEntryPlayerIndex = -1; // track which player is entering HS in multiplayer
675
-
676
- function getActivePlayerColor() {
677
- if (gameMode === "single") return PLAYER_COLORS[0];
678
- return PLAYER_COLORS[currentPlayer] || PLAYER_COLORS[0];
679
- }
680
-
681
- function initStars() {
682
- stars = [];
683
- for (let i = 0; i < 150; i++)
684
- stars.push({
685
- x: Math.random() * canvas.width,
686
- y: Math.random() * canvas.height,
687
- size: 2 * Math.random() + 0.5,
688
- speed: 0.5 * Math.random() + 0.1,
689
- brightness: Math.random()
690
- });
691
- }
692
-
693
- function initGame() {
694
- ship.x = canvas.width / 2;
695
- ship.y = canvas.height / 2;
696
- ship.vx = 0; ship.vy = 0;
697
- ship.angle = -Math.PI / 2;
698
- ship.invincible = 180;
699
- ship.shield = 0; ship.rapidFire = 0; ship.multiShot = 0;
700
- bullets = []; asteroids = []; powerUps = [];
701
- particles.clear();
702
- saucers = []; saucerBullets = []; saucerSpawnTimer = 0;
703
- hyperspaceCooldown = 0; hyperspaceInvincible = 0;
704
- score = 0; lives = 3; level = 1;
705
- initStars();
706
- spawnAsteroids(4);
707
- updateUI();
708
- }
709
-
710
- // Init game for hot seat (preserves asteroids/level across player swaps)
711
- function initHotSeatRespawn() {
712
- ship.x = canvas.width / 2;
713
- ship.y = canvas.height / 2;
714
- ship.vx = 0; ship.vy = 0;
715
- ship.angle = -Math.PI / 2;
716
- ship.invincible = 180;
717
- ship.shield = 0; ship.rapidFire = 0; ship.multiShot = 0;
718
- bullets = [];
719
- saucerBullets = [];
720
- hyperspaceCooldown = 0; hyperspaceInvincible = 0;
721
- shootCooldown = 0;
722
- }
723
-
724
- function initMultiplayerPlayers() {
725
- players = [];
726
- for (let i = 0; i < playerCount; i++) {
727
- players.push({ score: 0, lives: 3, level: 1, alive: true, initials: "" });
728
- }
729
- currentPlayer = 0;
730
- }
731
-
732
- function formatScore(s) {
733
- return String(s).padStart(5, "0");
734
- }
735
-
736
- function addScore(points) {
737
- score += points;
738
- }
739
-
740
- function getLevelSpeedMultiplier() {
741
- return 1 + 0.15 * (level - 1);
742
- }
743
-
744
- function spawnAsteroids(count) {
745
- for (let i = 0; i < count; i++) {
746
- let x, y;
747
- do {
748
- x = Math.random() * canvas.width;
749
- y = Math.random() * canvas.height;
750
- } while (distance(x, y, ship.x, ship.y) < 150);
751
- asteroids.push(createAsteroid(x, y, 3));
752
- }
753
- }
754
-
755
- function createAsteroid(x, y, size) {
756
- const speed = (0.5 * (4 - size) + 1.5 * Math.random()) * getLevelSpeedMultiplier();
757
- const angle = Math.random() * Math.PI * 2;
758
- const radii = [];
759
- const points = 8 + Math.floor(5 * Math.random());
760
- const baseRadius = 15 * size + 10;
761
- for (let i = 0; i < points; i++)
762
- radii.push(baseRadius * (0.7 + 0.6 * Math.random()));
763
- return {
764
- x, y,
765
- vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
766
- size, radii, points, rotation: 0,
767
- rotationSpeed: 0.03 * (Math.random() - 0.5),
768
- color: size === 3 ? "#ff00ff" : size === 2 ? "#ffff00" : "#00ffff"
769
- };
770
- }
771
-
772
- function createSaucer() {
773
- const isSmall = Math.random() > 0.5;
774
- const fromLeft = Math.random() > 0.5;
775
- const y = Math.random() * (canvas.height - 100) + 50;
776
- return {
777
- x: fromLeft ? -30 : canvas.width + 30, y,
778
- vx: (fromLeft ? 1 : -1) * (isSmall ? 3 : 2) * getLevelSpeedMultiplier(),
779
- vy: 0, isSmall,
780
- radius: isSmall ? 15 : 25,
781
- shootTimer: 60 + 60 * Math.random(),
782
- directionChangeTimer: 60 + 120 * Math.random(),
783
- points: isSmall ? 1000 : 500
784
- };
785
- }
786
-
787
- function distance(x1, y1, x2, y2) {
788
- return RetroGame.distance({x: x1, y: y1}, {x: x2, y: y2});
789
- }
790
-
791
- function wrap(obj) {
792
- RetroGame.wrap(obj, canvas.width, canvas.height, 50);
793
- }
794
-
795
- function createExplosion(x, y, color, count) {
796
- particles.emit(x, y, count || 20, {
797
- color: color, speed: 5, size: 3,
798
- lifetime: 1.2, decay: 0.02, spread: Math.PI * 2
799
- });
800
- shake.trigger(10);
801
- }
802
-
803
- // --- Hyperspace ---
804
- function hyperspace() {
805
- if (hyperspaceCooldown > 0) return;
806
- if (sfxReady) sfx.play('Hyperspace_Jump');
807
- createExplosion(ship.x, ship.y, "#ffffff", 12);
808
- ship.x = Math.random() * (canvas.width - 100) + 50;
809
- ship.y = Math.random() * (canvas.height - 100) + 50;
810
- ship.vx = 0; ship.vy = 0;
811
- createExplosion(ship.x, ship.y, "#00ffff", 12);
812
- hyperspaceCooldown = 180;
813
- hyperspaceInvincible = 60;
814
- }
815
-
816
- var shootCooldown = 0;
817
-
818
- function shoot() {
819
- if (shootCooldown > 0) return;
820
- const cooldown = ship.rapidFire > 0 ? 5 : 15;
821
- shootCooldown = cooldown;
822
- if (sfxReady) sfx.play('Laser_Fire');
823
- const offsets = ship.multiShot > 0 ? [-0.2, 0, 0.2] : [0];
824
- offsets.forEach(function(offset) {
825
- const angle = ship.angle + offset;
826
- bullets.push({
827
- x: ship.x + 20 * Math.cos(angle),
828
- y: ship.y + 20 * Math.sin(angle),
829
- vx: 10 * Math.cos(angle) + 0.5 * ship.vx,
830
- vy: 10 * Math.sin(angle) + 0.5 * ship.vy,
831
- life: 60
832
- });
833
- });
834
- }
835
-
836
- function saucerShoot(saucer) {
837
- let angle = Math.atan2(ship.y - saucer.y, ship.x - saucer.x);
838
- angle += (saucer.isSmall ? 0.2 : 0.5) * (Math.random() - 0.5);
839
- saucerBullets.push({
840
- x: saucer.x, y: saucer.y,
841
- vx: 6 * Math.cos(angle), vy: 6 * Math.sin(angle),
842
- life: 90
843
- });
844
- }
845
-
846
- function spawnPowerUp(x, y) {
847
- if (Math.random() > 0.15) return;
848
- let type;
849
- const rand = Math.random();
850
- type = rand < 0.05 ? "life" : rand < 0.38 ? "shield" : rand < 0.71 ? "rapid" : "multi";
851
- powerUps.push({ x, y, type, life: 600, pulse: 0 });
852
- }
853
-
854
- function updateUI() {
855
- const scoreEl = document.getElementById("score");
856
- if (score > MAX_SCORE) {
857
- scoreEl.textContent = "99,999+";
858
- scoreEl.className = "score-overflow";
859
- scoreEl.title = score.toLocaleString();
860
- } else {
861
- scoreEl.textContent = formatScore(score);
862
- scoreEl.className = "";
863
- scoreEl.title = "";
864
- }
865
- document.getElementById("level").textContent = level;
866
- document.getElementById("lives").textContent = lives;
867
-
868
- // Player indicator for multiplayer
869
- const piEl = document.getElementById("playerIndicator");
870
- if (gameMode !== "single") {
871
- piEl.style.display = "inline";
872
- piEl.textContent = "P" + (currentPlayer + 1) + " ";
873
- piEl.style.color = getActivePlayerColor();
874
- } else {
875
- piEl.style.display = "none";
876
- }
877
- }
878
-
879
- function isShipInvincible() {
880
- return ship.invincible > 0 || ship.shield > 0 || hyperspaceInvincible > 0;
881
- }
882
-
883
- // Gamepad polling is now handled by gamepad.poll() in the game loop
884
- // Game-specific action functions (gpFire, gpHyper, etc.) are defined above
885
-
886
- // --- Multiplayer Turn Management ---
887
- var TURN_COOLDOWN_MS = 3000; // 3 second cooldown between players
888
- var turnReadyAt = 0;
889
-
890
- function showTurnReady() {
891
- turnReady = true;
892
- turnReadyAt = Date.now();
893
- const overlay = document.getElementById("turnReadyOverlay");
894
- const nameEl = document.getElementById("turnPlayerName");
895
- const color = getActivePlayerColor();
896
- nameEl.textContent = "PLAYER " + (currentPlayer + 1);
897
- nameEl.style.color = color;
898
- updateAllPrompts();
899
- overlay.classList.add("visible");
900
- }
901
-
902
- function getTurnCooldownRemaining() {
903
- return Math.max(0, TURN_COOLDOWN_MS - (Date.now() - turnReadyAt));
904
- }
905
-
906
- function updateTurnPrompt() {
907
- var remaining = getTurnCooldownRemaining();
908
- var promptEl = document.getElementById("turnPrompt");
909
- if (remaining > 0) {
910
- var secs = Math.ceil(remaining / 1000);
911
- promptEl.textContent = "Get ready... " + secs;
912
- } else {
913
- promptEl.innerHTML = btnText(
914
- "Press SPACE to start",
915
- "Press " + gpBtn("a") + " to start"
916
- );
917
- }
918
- }
919
-
920
- function hideTurnReady() {
921
- turnReady = false;
922
- document.getElementById("turnReadyOverlay").classList.remove("visible");
923
- }
924
-
925
- function startPlayerTurn() {
926
- hideTurnReady();
927
- if (gameMode === "solo_turns") {
928
- // Fresh game for each player
929
- initGame();
930
- score = 0;
931
- lives = 3;
932
- level = 1;
933
- } else if (gameMode === "hot_seat") {
934
- // Load this player's state into shared field
935
- const p = players[currentPlayer];
936
- score = p.score;
937
- lives = p.lives;
938
- initHotSeatRespawn();
939
- }
940
- gameRunning = true;
941
- startAmbient();
942
- updateUI();
943
- updateBottomBar();
944
- }
945
-
946
- function saveCurrentPlayerState() {
947
- if (gameMode === "single") return;
948
- players[currentPlayer].score = score;
949
- players[currentPlayer].lives = lives;
950
- if (gameMode === "solo_turns") {
951
- players[currentPlayer].level = level;
952
- }
953
- }
954
-
955
- function advanceToNextPlayer() {
956
- saveCurrentPlayerState();
957
-
958
- // Find next alive player
959
- let next = -1;
960
- for (let i = 1; i <= playerCount; i++) {
961
- const idx = (currentPlayer + i) % playerCount;
962
- if (players[idx].alive) { next = idx; break; }
963
- }
964
-
965
- if (next === -1) {
966
- // All players eliminated
967
- showFinalScoreboard();
968
- return;
969
- }
970
-
971
- currentPlayer = next;
972
- gameRunning = false;
973
- showTurnReady();
974
- }
975
-
976
- function handlePlayerDeath() {
977
- if (gameMode === "single") {
978
- gameOver();
979
- return;
980
- }
981
- sfx.stop('Ship_Thrust');
982
-
983
- if (gameMode === "solo_turns") {
984
- // Player lost all lives — they're done
985
- lives--;
986
- updateUI();
987
- if (lives <= 0) {
988
- saveCurrentPlayerState();
989
- players[currentPlayer].alive = false;
990
- gameRunning = false;
991
- // Check HS for this player
992
- if (qualifiesForHighScore(score)) {
993
- hsEntryPlayerIndex = currentPlayer;
994
- showHighScoreEntry();
995
- } else {
996
- advanceToNextPlayer();
997
- }
998
- } else {
999
- // Respawn
1000
- ship.x = canvas.width / 2; ship.y = canvas.height / 2;
1001
- ship.vx = 0; ship.vy = 0; ship.invincible = 180;
1002
- }
1003
- } else if (gameMode === "hot_seat") {
1004
- // Lost one life rotate to next player
1005
- lives--;
1006
- updateUI();
1007
- saveCurrentPlayerState();
1008
- if (lives <= 0) {
1009
- players[currentPlayer].alive = false;
1010
- // Check HS for this player
1011
- if (qualifiesForHighScore(score)) {
1012
- hsEntryPlayerIndex = currentPlayer;
1013
- gameRunning = false;
1014
- showHighScoreEntry();
1015
- return;
1016
- }
1017
- }
1018
- gameRunning = false;
1019
- advanceToNextPlayer();
1020
- }
1021
- }
1022
-
1023
- function showFinalScoreboard() {
1024
- gameRunning = false;
1025
- stopAmbient();
1026
- sfx.stop('Ship_Thrust');
1027
- // Sort players by score descending
1028
- const ranked = players.map(function(p, i) { return { index: i, score: p.score, initials: p.initials }; });
1029
- ranked.sort(function(a, b) { return b.score - a.score; });
1030
-
1031
- const modeLabel = gameMode === "solo_turns" ? "SOLO TURNS" : "HOT SEAT";
1032
- document.getElementById("sbSubtitle").textContent = modeLabel + " \u2014 FINAL STANDINGS";
1033
-
1034
- const tbody = document.getElementById("sbBody");
1035
- tbody.innerHTML = "";
1036
- ranked.forEach(function(r, rank) {
1037
- const tr = document.createElement("tr");
1038
- const color = PLAYER_COLORS[r.index];
1039
- const initStr = r.initials || "";
1040
- tr.innerHTML =
1041
- '<td class="sb-rank">' + (rank + 1) + '.</td>' +
1042
- '<td class="sb-player" style="color:' + color + ';text-shadow:0 0 10px ' + color + '">P' + (r.index + 1) + '</td>' +
1043
- '<td class="sb-initials">' + initStr + '</td>' +
1044
- '<td class="sb-score">' + r.score.toLocaleString() + '</td>';
1045
- tbody.appendChild(tr);
1046
- });
1047
-
1048
- document.getElementById("finalScoreboard").classList.add("visible");
1049
- updateBottomBar();
1050
- }
1051
-
1052
- function hideFinalScoreboard() {
1053
- document.getElementById("finalScoreboard").classList.remove("visible");
1054
- }
1055
-
1056
- // --- Start Screen UI Logic ---
1057
- var menuCursor = 0; // index of focused item in current phase
1058
-
1059
- // Items per phase: mode=[1P, PassPlay], submode=[Solo, HotSeat], count=N/A (uses arrows)
1060
- function getMenuItems() {
1061
- if (startScreenPhase === "mode") return [document.getElementById("btn1P"), document.getElementById("btnPassPlay")];
1062
- if (startScreenPhase === "submode") return [document.getElementById("btnSoloTurns"), document.getElementById("btnHotSeat")];
1063
- return [];
1064
- }
1065
-
1066
- function updateMenuFocus() {
1067
- // Clear all focused classes
1068
- var all = document.querySelectorAll(".mode-btn.focused, .submode-btn.focused");
1069
- for (var i = 0; i < all.length; i++) all[i].classList.remove("focused");
1070
- // Apply to current
1071
- var items = getMenuItems();
1072
- if (items.length > 0 && menuCursor >= 0 && menuCursor < items.length) {
1073
- items[menuCursor].classList.add("focused");
1074
- }
1075
- }
1076
-
1077
- function showStartPhase(phase) {
1078
- startScreenPhase = phase;
1079
- menuCursor = 0;
1080
- var modeSelect = document.getElementById("modeSelect");
1081
- var submodeSelect = document.getElementById("submodeSelect");
1082
- var pcSelect = document.getElementById("playerCountSelect");
1083
- modeSelect.style.display = phase === "mode" ? "flex" : "none";
1084
- submodeSelect.style.display = phase === "submode" ? "flex" : "none";
1085
- pcSelect.style.display = "none";
1086
- pcSelect.classList.remove("visible");
1087
- if (phase === "count") {
1088
- pcSelect.style.display = "flex";
1089
- pcSelect.classList.add("visible");
1090
- }
1091
- updateMenuFocus();
1092
- updateBottomBar();
1093
- }
1094
-
1095
- function resetStartScreen() {
1096
- var startScreen = document.getElementById("startScreen");
1097
- startScreen.style.display = "";
1098
- showStartPhase("mode");
1099
- playerCount = 2;
1100
- document.getElementById("pcNum").textContent = "2";
1101
- document.getElementById("gameOverScreen").style.display = "none";
1102
- hideFinalScoreboard();
1103
- hideTurnReady();
1104
- stopAmbient();
1105
- sfx.stop('Ship_Thrust');
1106
- }
1107
-
1108
- function menuConfirm() {
1109
- if (startScreenPhase === "mode") {
1110
- if (menuCursor === 0) startSinglePlayer();
1111
- else if (menuCursor === 1) showStartPhase("submode");
1112
- } else if (startScreenPhase === "submode") {
1113
- if (menuCursor === 0) selectSubmode("solo_turns");
1114
- else if (menuCursor === 1) selectSubmode("hot_seat");
1115
- } else if (startScreenPhase === "count") {
1116
- confirmPlayerCount();
1117
- }
1118
- }
1119
-
1120
- function menuBack() {
1121
- if (startScreenPhase === "submode") {
1122
- showStartPhase("mode");
1123
- menuCursor = 1; // return focus to "Pass & Play"
1124
- updateMenuFocus();
1125
- } else if (startScreenPhase === "count") {
1126
- showStartPhase("submode");
1127
- }
1128
- }
1129
-
1130
- function menuMove(delta) {
1131
- var items = getMenuItems();
1132
- if (items.length === 0) return;
1133
- var prev = menuCursor;
1134
- menuCursor = Math.max(0, Math.min(items.length - 1, menuCursor + delta));
1135
- if (menuCursor !== prev && sfxReady) sfx.play('Menu_Navigate');
1136
- updateMenuFocus();
1137
- }
1138
-
1139
- function startSinglePlayer() {
1140
- document.getElementById("startScreen").style.display = "none";
1141
- gameMode = "single";
1142
- initGame();
1143
- gameRunning = true;
1144
- startAmbient();
1145
- updateBottomBar();
1146
- }
1147
-
1148
- function selectSubmode(mode) {
1149
- gameMode = mode;
1150
- showStartPhase("count");
1151
- }
1152
-
1153
- function adjustPlayerCount(delta) {
1154
- playerCount = Math.max(2, Math.min(8, playerCount + delta));
1155
- document.getElementById("pcNum").textContent = playerCount;
1156
- }
1157
-
1158
- function confirmPlayerCount() {
1159
- document.getElementById("startScreen").style.display = "none";
1160
- initMultiplayerPlayers();
1161
- currentPlayer = 0;
1162
-
1163
- if (gameMode === "hot_seat") {
1164
- initGame();
1165
- gameRunning = false;
1166
- }
1167
-
1168
- showTurnReady();
1169
- }
1170
-
1171
- // Start screen button events
1172
- document.getElementById("btn1P").addEventListener("click", startSinglePlayer);
1173
- document.getElementById("btnPassPlay").addEventListener("click", function() { showStartPhase("submode"); });
1174
- document.getElementById("btnSoloTurns").addEventListener("click", function() { selectSubmode("solo_turns"); });
1175
- document.getElementById("btnHotSeat").addEventListener("click", function() { selectSubmode("hot_seat"); });
1176
- // Mouse hover syncs focus cursor
1177
- document.getElementById("btn1P").addEventListener("mouseenter", function() { if (menuCursor !== 0 && sfxReady) sfx.play('Menu_Navigate'); menuCursor = 0; updateMenuFocus(); });
1178
- document.getElementById("btnPassPlay").addEventListener("mouseenter", function() { if (menuCursor !== 1 && sfxReady) sfx.play('Menu_Navigate'); menuCursor = 1; updateMenuFocus(); });
1179
- document.getElementById("btnSoloTurns").addEventListener("mouseenter", function() { if (menuCursor !== 0 && sfxReady) sfx.play('Menu_Navigate'); menuCursor = 0; updateMenuFocus(); });
1180
- document.getElementById("btnHotSeat").addEventListener("mouseenter", function() { if (menuCursor !== 1 && sfxReady) sfx.play('Menu_Navigate'); menuCursor = 1; updateMenuFocus(); });
1181
- document.getElementById("pcLeft").addEventListener("click", function() { adjustPlayerCount(-1); });
1182
- document.getElementById("pcRight").addEventListener("click", function() { adjustPlayerCount(1); });
1183
- document.getElementById("pcConfirm").addEventListener("click", confirmPlayerCount);
1184
- document.getElementById("sbPlayAgain").addEventListener("click", function() { resetStartScreen(); });
1185
-
1186
- function update() {
1187
- // keyboard.poll() and gamepad.poll() are called in gameLoop() before this
1188
-
1189
- // Gamepad pause toggle (edge-triggered, works even when paused)
1190
- if (gpPause() && gameRunning) {
1191
- gamePaused = !gamePaused;
1192
- if (gamePaused) pauseOverlay.classList.add("visible");
1193
- else pauseOverlay.classList.remove("visible");
1194
- }
1195
-
1196
- if (!gameRunning || gamePaused) return;
1197
-
1198
- // Gamepad hyperspace (edge-triggered)
1199
- if (gpHyper()) hyperspace();
1200
-
1201
- // Ship controls (keyboard OR gamepad)
1202
- if (keyboard.isDown("ArrowLeft") || keyboard.isDown("KeyA") || gamepad.left) ship.angle -= 0.08;
1203
- if (keyboard.isDown("ArrowRight") || keyboard.isDown("KeyD") || gamepad.right) ship.angle += 0.08;
1204
- if (keyboard.isDown("ArrowUp") || keyboard.isDown("KeyW") || gamepad.up) {
1205
- ship.vx += 0.15 * Math.cos(ship.angle);
1206
- ship.vy += 0.15 * Math.sin(ship.angle);
1207
- ship.thrust = 1;
1208
- if (sfxReady) sfx.play('Ship_Thrust');
1209
- } else {
1210
- if (ship.thrust > 0.1 && sfxReady) sfx.stop('Ship_Thrust');
1211
- ship.thrust *= 0.95;
1212
- }
1213
- if (keyboard.isDown("Space") || gpFire()) shoot();
1214
-
1215
- ship.vx *= 0.99;
1216
- ship.vy *= 0.99;
1217
- const speed = Math.sqrt(ship.vx ** 2 + ship.vy ** 2);
1218
- if (speed > 8) {
1219
- ship.vx = ship.vx / speed * 8;
1220
- ship.vy = ship.vy / speed * 8;
1221
- }
1222
- ship.x += ship.vx;
1223
- ship.y += ship.vy;
1224
- wrap(ship);
1225
-
1226
- if (ship.invincible > 0) ship.invincible--;
1227
- if (ship.shield > 0) ship.shield--;
1228
- if (ship.rapidFire > 0) ship.rapidFire--;
1229
- if (ship.multiShot > 0) ship.multiShot--;
1230
- if (shootCooldown > 0) shootCooldown--;
1231
- if (hyperspaceCooldown > 0) hyperspaceCooldown--;
1232
- if (hyperspaceInvincible > 0) hyperspaceInvincible--;
1233
-
1234
- // Saucer spawning
1235
- if (level >= 2) {
1236
- saucerSpawnTimer++;
1237
- const spawnInterval = Math.max(300, 600 - 30 * level);
1238
- if (saucerSpawnTimer >= spawnInterval && saucers.length < 2) {
1239
- saucers.push(createSaucer());
1240
- if (sfxReady) sfx.play('Saucer_Alert');
1241
- saucerSpawnTimer = 0;
1242
- }
1243
- }
1244
-
1245
- // Update saucers
1246
- saucers = saucers.filter(function(s) {
1247
- s.x += s.vx; s.y += s.vy;
1248
- s.directionChangeTimer--;
1249
- if (s.directionChangeTimer <= 0) {
1250
- s.vy = 2 * (Math.random() - 0.5);
1251
- s.directionChangeTimer = 60 + 120 * Math.random();
1252
- }
1253
- if (s.y < 50) s.vy = Math.abs(s.vy);
1254
- if (s.y > canvas.height - 50) s.vy = -Math.abs(s.vy);
1255
- s.shootTimer--;
1256
- if (s.shootTimer <= 0) {
1257
- saucerShoot(s);
1258
- s.shootTimer = s.isSmall ? 40 + 40 * Math.random() : 60 + 60 * Math.random();
1259
- }
1260
- return s.x > -50 && s.x < canvas.width + 50;
1261
- });
1262
-
1263
- // Update saucer bullets
1264
- saucerBullets = saucerBullets.filter(function(b) {
1265
- b.x += b.vx; b.y += b.vy; b.life--;
1266
- return b.life > 0 && b.x > -10 && b.x < canvas.width + 10 && b.y > -10 && b.y < canvas.height + 10;
1267
- });
1268
-
1269
- // Update bullets
1270
- bullets = bullets.filter(function(b) {
1271
- b.x += b.vx; b.y += b.vy; b.life--;
1272
- wrap(b);
1273
- return b.life > 0;
1274
- });
1275
-
1276
- // Update asteroids
1277
- asteroids.forEach(function(a) {
1278
- a.x += a.vx; a.y += a.vy;
1279
- a.rotation += a.rotationSpeed;
1280
- wrap(a);
1281
- });
1282
-
1283
- // Update particles
1284
- particles.update(1/60);
1285
-
1286
- // Update power-ups
1287
- powerUps = powerUps.filter(function(p) {
1288
- p.life--; p.pulse += 0.1;
1289
- return p.life > 0;
1290
- });
1291
-
1292
- // Update stars
1293
- stars.forEach(function(s) {
1294
- s.y += s.speed;
1295
- if (s.y > canvas.height) { s.y = 0; s.x = Math.random() * canvas.width; }
1296
- s.brightness = 0.3 + 0.3 * Math.sin(0.003 * Date.now() + s.x);
1297
- });
1298
-
1299
- // Bullet-asteroid collisions
1300
- bullets.forEach(function(b, bi) {
1301
- asteroids.forEach(function(a, ai) {
1302
- const avgRadius = a.radii.reduce(function(sum, r) { return sum + r; }, 0) / a.radii.length;
1303
- if (distance(b.x, b.y, a.x, a.y) < avgRadius) {
1304
- bullets.splice(bi, 1);
1305
- if (sfxReady) sfx.play('Asteroid_Explosion');
1306
- createExplosion(a.x, a.y, a.color, 15);
1307
- if (a.size > 1) {
1308
- for (let i = 0; i < 2; i++) asteroids.push(createAsteroid(a.x, a.y, a.size - 1));
1309
- }
1310
- spawnPowerUp(a.x, a.y);
1311
- asteroids.splice(ai, 1);
1312
- addScore(100 * (4 - a.size));
1313
- updateUI();
1314
- }
1315
- });
1316
- });
1317
-
1318
- // Bullet-saucer collisions
1319
- bullets.forEach(function(b, bi) {
1320
- saucers.forEach(function(s, si) {
1321
- if (distance(b.x, b.y, s.x, s.y) < s.radius) {
1322
- bullets.splice(bi, 1);
1323
- createExplosion(s.x, s.y, "#ff6600", 25);
1324
- addScore(s.points);
1325
- saucers.splice(si, 1);
1326
- updateUI();
1327
- }
1328
- });
1329
- });
1330
-
1331
- // Ship-asteroid collisions
1332
- if (!isShipInvincible()) {
1333
- asteroids.forEach(function(a, ai) {
1334
- const avgRadius = a.radii.reduce(function(sum, r) { return sum + r; }, 0) / a.radii.length;
1335
- if (distance(ship.x, ship.y, a.x, a.y) < avgRadius + ship.radius) {
1336
- createExplosion(ship.x, ship.y, getActivePlayerColor(), 30);
1337
- if (gameMode !== "single") {
1338
- handlePlayerDeath();
1339
- } else {
1340
- lives--;
1341
- updateUI();
1342
- if (lives <= 0) { gameOver(); return; }
1343
- ship.x = canvas.width / 2; ship.y = canvas.height / 2;
1344
- ship.vx = 0; ship.vy = 0; ship.invincible = 180;
1345
- }
1346
- }
1347
- });
1348
- }
1349
-
1350
- // Ship-saucer collisions
1351
- if (!isShipInvincible()) {
1352
- saucers.forEach(function(s, si) {
1353
- if (distance(ship.x, ship.y, s.x, s.y) < s.radius + ship.radius) {
1354
- createExplosion(ship.x, ship.y, getActivePlayerColor(), 30);
1355
- createExplosion(s.x, s.y, "#ff6600", 25);
1356
- saucers.splice(si, 1);
1357
- if (gameMode !== "single") {
1358
- handlePlayerDeath();
1359
- } else {
1360
- lives--;
1361
- updateUI();
1362
- if (lives <= 0) { gameOver(); return; }
1363
- ship.x = canvas.width / 2; ship.y = canvas.height / 2;
1364
- ship.vx = 0; ship.vy = 0; ship.invincible = 180;
1365
- }
1366
- }
1367
- });
1368
- }
1369
-
1370
- // Ship-saucerBullet collisions
1371
- if (!isShipInvincible()) {
1372
- saucerBullets.forEach(function(b, bi) {
1373
- if (distance(ship.x, ship.y, b.x, b.y) < ship.radius + 5) {
1374
- createExplosion(ship.x, ship.y, getActivePlayerColor(), 30);
1375
- saucerBullets.splice(bi, 1);
1376
- if (gameMode !== "single") {
1377
- handlePlayerDeath();
1378
- } else {
1379
- lives--;
1380
- updateUI();
1381
- if (lives <= 0) { gameOver(); return; }
1382
- ship.x = canvas.width / 2; ship.y = canvas.height / 2;
1383
- ship.vx = 0; ship.vy = 0; ship.invincible = 180;
1384
- }
1385
- }
1386
- });
1387
- }
1388
-
1389
- // Power-up pickups
1390
- powerUps.forEach(function(p, pi) {
1391
- if (distance(ship.x, ship.y, p.x, p.y) < 30) {
1392
- powerUps.splice(pi, 1);
1393
- if (sfxReady) sfx.play('Power_Up_Collect');
1394
- createExplosion(p.x, p.y, "#00ff00", 10);
1395
- switch (p.type) {
1396
- case "shield": ship.shield = 600; break;
1397
- case "rapid": ship.rapidFire = 600; break;
1398
- case "multi": ship.multiShot = 600; break;
1399
- case "life": lives = Math.min(lives + 1, 5); updateUI(); break;
1400
- }
1401
- }
1402
- });
1403
-
1404
- // Next level
1405
- if (asteroids.length === 0) {
1406
- level++;
1407
- updateUI();
1408
- spawnAsteroids(3 + level);
1409
- saucerSpawnTimer = 0;
1410
- }
1411
-
1412
- shake.update(1/60);
1413
- }
1414
-
1415
- function draw() {
1416
- ctx.save();
1417
- if (shake.isActive) ctx.translate(shake.offsetX, shake.offsetY);
1418
- ctx.fillStyle = "#000";
1419
- ctx.fillRect(0, 0, canvas.width, canvas.height);
1420
-
1421
- // Stars
1422
- stars.forEach(function(s) {
1423
- ctx.fillStyle = "rgba(255, 255, 255, " + s.brightness + ")";
1424
- ctx.beginPath();
1425
- ctx.arc(s.x, s.y, s.size, 0, 2 * Math.PI);
1426
- ctx.fill();
1427
- });
1428
-
1429
- // Particles
1430
- particles.draw(ctx);
1431
-
1432
- // Power-ups
1433
- powerUps.forEach(function(p) {
1434
- const pulse = 5 * Math.sin(p.pulse);
1435
- ctx.strokeStyle = p.type === "shield" ? "#00ffff" : p.type === "rapid" ? "#ff0000" : p.type === "multi" ? "#ffff00" : "#00ff00";
1436
- ctx.lineWidth = 2;
1437
- ctx.shadowColor = ctx.strokeStyle;
1438
- ctx.shadowBlur = 15;
1439
- ctx.beginPath();
1440
- ctx.arc(p.x, p.y, 15 + pulse, 0, 2 * Math.PI);
1441
- ctx.stroke();
1442
- ctx.fillStyle = ctx.strokeStyle;
1443
- ctx.font = "12px Arial";
1444
- ctx.textAlign = "center";
1445
- ctx.textBaseline = "middle";
1446
- const icon = p.type === "shield" ? "S" : p.type === "rapid" ? "R" : p.type === "multi" ? "M" : "+";
1447
- ctx.fillText(icon, p.x, p.y);
1448
- ctx.shadowBlur = 0;
1449
- });
1450
-
1451
- // Asteroids
1452
- asteroids.forEach(function(a) {
1453
- ctx.save();
1454
- ctx.translate(a.x, a.y);
1455
- ctx.rotate(a.rotation);
1456
- ctx.strokeStyle = a.color;
1457
- ctx.lineWidth = 2;
1458
- ctx.shadowColor = a.color;
1459
- ctx.shadowBlur = 20;
1460
- ctx.beginPath();
1461
- for (let i = 0; i < a.points; i++) {
1462
- const angle = i / a.points * Math.PI * 2;
1463
- const r = a.radii[i];
1464
- const x = Math.cos(angle) * r;
1465
- const y = Math.sin(angle) * r;
1466
- if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
1467
- }
1468
- ctx.closePath();
1469
- ctx.stroke();
1470
- ctx.restore();
1471
- });
1472
-
1473
- // Saucers
1474
- saucers.forEach(function(s) {
1475
- ctx.save();
1476
- ctx.translate(s.x, s.y);
1477
- ctx.strokeStyle = "#ff6600";
1478
- ctx.lineWidth = 2;
1479
- ctx.shadowColor = "#ff6600";
1480
- ctx.shadowBlur = 20;
1481
- const r = s.radius;
1482
- ctx.beginPath(); ctx.ellipse(0, -0.2 * r, 0.4 * r, 0.3 * r, 0, Math.PI, 0); ctx.stroke();
1483
- ctx.beginPath(); ctx.ellipse(0, 0, r, 0.35 * r, 0, 0, 2 * Math.PI); ctx.stroke();
1484
- ctx.beginPath(); ctx.ellipse(0, 0.15 * r, 0.5 * r, 0.2 * r, 0, 0, Math.PI); ctx.stroke();
1485
- ctx.fillStyle = "#ff6600";
1486
- for (let i = 0; i < 3; i++) {
1487
- const lx = (i - 1) * r * 0.5;
1488
- ctx.beginPath(); ctx.arc(lx, 0, 3, 0, 2 * Math.PI); ctx.fill();
1489
- }
1490
- ctx.restore();
1491
- });
1492
-
1493
- // Saucer bullets
1494
- saucerBullets.forEach(function(b) {
1495
- ctx.fillStyle = "#ff6600";
1496
- ctx.shadowColor = "#ff6600";
1497
- ctx.shadowBlur = 15;
1498
- ctx.beginPath();
1499
- ctx.arc(b.x, b.y, 4, 0, 2 * Math.PI);
1500
- ctx.fill();
1501
- });
1502
-
1503
- // Player bullets use active player color
1504
- const bulletColor = getActivePlayerColor();
1505
- bullets.forEach(function(b) {
1506
- ctx.fillStyle = bulletColor;
1507
- ctx.shadowColor = bulletColor;
1508
- ctx.shadowBlur = 15;
1509
- ctx.beginPath();
1510
- ctx.arc(b.x, b.y, 3, 0, 2 * Math.PI);
1511
- ctx.fill();
1512
- ctx.strokeStyle = bulletColor.replace(")", ", 0.5)").replace("rgb", "rgba").replace("#", "");
1513
- // Use hex alpha approach for trail
1514
- ctx.globalAlpha = 0.5;
1515
- ctx.strokeStyle = bulletColor;
1516
- ctx.lineWidth = 2;
1517
- ctx.beginPath();
1518
- ctx.moveTo(b.x, b.y);
1519
- ctx.lineTo(b.x - 2 * b.vx, b.y - 2 * b.vy);
1520
- ctx.stroke();
1521
- ctx.globalAlpha = 1;
1522
- });
1523
-
1524
- // Ship — use active player color
1525
- const shipColor = getActivePlayerColor();
1526
- if (gameRunning && (ship.invincible <= 0 || Math.floor(ship.invincible / 5) % 2 === 0)) {
1527
- ctx.save();
1528
- ctx.translate(ship.x, ship.y);
1529
- ctx.rotate(ship.angle);
1530
- if (ship.shield > 0) {
1531
- ctx.strokeStyle = shipColor.replace("ff", "80"); // semi-transparent
1532
- ctx.globalAlpha = 0.5;
1533
- ctx.strokeStyle = shipColor;
1534
- ctx.lineWidth = 2;
1535
- ctx.shadowColor = shipColor;
1536
- ctx.shadowBlur = 20;
1537
- ctx.beginPath();
1538
- ctx.arc(0, 0, 25, 0, 2 * Math.PI);
1539
- ctx.stroke();
1540
- ctx.globalAlpha = 1;
1541
- }
1542
- if (hyperspaceInvincible > 0) {
1543
- ctx.strokeStyle = "rgba(255, 255, 255, " + (0.3 + 0.2 * Math.sin(hyperspaceInvincible * 0.3)) + ")";
1544
- ctx.lineWidth = 1;
1545
- ctx.shadowColor = "#ffffff";
1546
- ctx.shadowBlur = 25;
1547
- ctx.beginPath();
1548
- ctx.arc(0, 0, 22, 0, 2 * Math.PI);
1549
- ctx.stroke();
1550
- }
1551
- ctx.strokeStyle = shipColor;
1552
- ctx.lineWidth = 2;
1553
- ctx.shadowColor = shipColor;
1554
- ctx.shadowBlur = 15;
1555
- ctx.beginPath();
1556
- ctx.moveTo(20, 0);
1557
- ctx.lineTo(-15, -12);
1558
- ctx.lineTo(-8, 0);
1559
- ctx.lineTo(-15, 12);
1560
- ctx.closePath();
1561
- ctx.stroke();
1562
- if (ship.thrust > 0.1) {
1563
- ctx.strokeStyle = "#ff6600";
1564
- ctx.shadowColor = "#ff6600";
1565
- ctx.beginPath();
1566
- ctx.moveTo(-8, -5);
1567
- ctx.lineTo(-20 - 10 * Math.random() * ship.thrust, 0);
1568
- ctx.lineTo(-8, 5);
1569
- ctx.stroke();
1570
- }
1571
- ctx.restore();
1572
- }
1573
-
1574
- ctx.shadowBlur = 0;
1575
- ctx.restore();
1576
- }
1577
-
1578
- // --- Game Over & High Score Entry ---
1579
- function gameOver() {
1580
- gameRunning = false;
1581
- stopAmbient();
1582
- sfx.stop('Ship_Thrust');
1583
- if (qualifiesForHighScore(score)) {
1584
- hsEntryPlayerIndex = -1;
1585
- showHighScoreEntry();
1586
- } else {
1587
- showGameOverScreen();
1588
- }
1589
- }
1590
-
1591
- function showGameOverScreen() {
1592
- document.getElementById("finalScore").textContent = score.toLocaleString();
1593
- document.getElementById("gameOverScreen").style.display = "block";
1594
- updateAllPrompts();
1595
- }
1596
-
1597
- function showHighScoreEntry() {
1598
- hsFinalScore = score;
1599
- hsSlots = [0, 0, 0];
1600
- hsActiveSlot = 0;
1601
- hsEntryActive = true;
1602
- document.getElementById("hsScore").textContent = score.toLocaleString();
1603
- updateSlotDisplay();
1604
- document.getElementById("highScoreEntry").style.display = "block";
1605
- updateAllPrompts();
1606
- }
1607
-
1608
- function updateSlotDisplay() {
1609
- for (let i = 0; i < 3; i++) {
1610
- const el = document.getElementById("slot" + i);
1611
- el.textContent = String.fromCharCode(65 + hsSlots[i]);
1612
- el.className = "initial-slot" + (i === hsActiveSlot ? " active" : "");
1613
- }
1614
- }
1615
-
1616
- function confirmHighScore() {
1617
- const initials = String.fromCharCode(65 + hsSlots[0]) +
1618
- String.fromCharCode(65 + hsSlots[1]) +
1619
- String.fromCharCode(65 + hsSlots[2]);
1620
- insertHighScore(initials, hsFinalScore);
1621
- hsEntryActive = false;
1622
- document.getElementById("highScoreEntry").style.display = "none";
1623
-
1624
- // In multiplayer, store initials and advance
1625
- if (hsEntryPlayerIndex >= 0 && gameMode !== "single") {
1626
- players[hsEntryPlayerIndex].initials = initials;
1627
- advanceToNextPlayer();
1628
- } else {
1629
- showGameOverScreen();
1630
- }
1631
- }
1632
-
1633
- var _turnStartPressed = false;
1634
-
1635
- function gameLoop() {
1636
- keyboard.poll();
1637
- gamepad.poll();
1638
-
1639
- // --- Turn ready overlay: cooldown then wait for player input ---
1640
- if (turnReady) {
1641
- updateTurnPrompt();
1642
- if (getTurnCooldownRemaining() <= 0) {
1643
- if (gpConfirm() || _turnStartPressed) {
1644
- _turnStartPressed = false;
1645
- startPlayerTurn();
1646
- }
1647
- }
1648
- // Still draw background
1649
- draw();
1650
- saveDirState();
1651
- requestAnimationFrame(gameLoop);
1652
- return;
1653
- }
1654
-
1655
- // Gamepad menu/HS entry controls (when game not running)
1656
- if (hsEntryActive) {
1657
- if (gpUp()) { hsSlots[hsActiveSlot] = (hsSlots[hsActiveSlot] + 1) % 26; updateSlotDisplay(); }
1658
- if (gpDown()) { hsSlots[hsActiveSlot] = (hsSlots[hsActiveSlot] + 25) % 26; updateSlotDisplay(); }
1659
- if (gpLeft()) { hsActiveSlot = Math.max(0, hsActiveSlot - 1); updateSlotDisplay(); }
1660
- if (gpRight()) { hsActiveSlot = Math.min(2, hsActiveSlot + 1); updateSlotDisplay(); }
1661
- // A = accept letter & advance, confirm on last slot
1662
- if (gpConfirm()) {
1663
- if (hsActiveSlot < 2) { hsActiveSlot++; updateSlotDisplay(); }
1664
- else confirmHighScore();
1665
- }
1666
- // B = go back one slot
1667
- if (gpBack()) {
1668
- if (hsActiveSlot > 0) { hsActiveSlot--; updateSlotDisplay(); }
1669
- }
1670
- } else if (!gameRunning) {
1671
- // Start screen navigation with gamepad (d-pad + A/B)
1672
- if (document.getElementById("startScreen").style.display !== "none") {
1673
- if (startScreenPhase === "mode" || startScreenPhase === "submode") {
1674
- if (gpUp()) menuMove(-1);
1675
- if (gpDown()) menuMove(1);
1676
- if (gpConfirm()) menuConfirm();
1677
- if (gpBack()) menuBack(); // B = back
1678
- } else if (startScreenPhase === "count") {
1679
- if (gpLeft()) adjustPlayerCount(-1);
1680
- if (gpRight()) adjustPlayerCount(1);
1681
- if (gpConfirm()) confirmPlayerCount();
1682
- if (gpBack()) menuBack(); // B = back
1683
- }
1684
- }
1685
- // Game over screen restart
1686
- const gameOverScreen = document.getElementById("gameOverScreen");
1687
- if (gameOverScreen.style.display === "block") {
1688
- if (gpConfirm() || gpPause()) {
1689
- gameOverScreen.style.display = "none";
1690
- resetStartScreen();
1691
- }
1692
- }
1693
- // Final scoreboard — play again
1694
- if (document.getElementById("finalScoreboard").classList.contains("visible")) {
1695
- if (gpConfirm() || gpPause()) {
1696
- resetStartScreen();
1697
- }
1698
- }
1699
- }
1700
-
1701
- update();
1702
- draw();
1703
- saveDirState();
1704
- requestAnimationFrame(gameLoop);
1705
- }
1706
-
1707
- // --- Input Handling (one-shot keyboard actions) ---
1708
- // Continuous key state is handled by keyboard.isDown() via RetroGame.createKeyboard()
1709
- document.addEventListener("keydown", function(e) {
1710
- // Turn ready: SPACE to start
1711
- if (turnReady) {
1712
- if (e.code === "Space") {
1713
- _turnStartPressed = true;
1714
- e.preventDefault();
1715
- }
1716
- return;
1717
- }
1718
-
1719
- if (hsEntryActive) {
1720
- if (e.code === "ArrowLeft") {
1721
- hsActiveSlot = Math.max(0, hsActiveSlot - 1);
1722
- updateSlotDisplay();
1723
- } else if (e.code === "ArrowRight") {
1724
- hsActiveSlot = Math.min(2, hsActiveSlot + 1);
1725
- updateSlotDisplay();
1726
- } else if (e.code === "ArrowUp") {
1727
- hsSlots[hsActiveSlot] = (hsSlots[hsActiveSlot] + 1) % 26;
1728
- updateSlotDisplay();
1729
- } else if (e.code === "ArrowDown") {
1730
- hsSlots[hsActiveSlot] = (hsSlots[hsActiveSlot] + 25) % 26;
1731
- updateSlotDisplay();
1732
- } else if (e.code === "Enter") {
1733
- confirmHighScore();
1734
- }
1735
- e.preventDefault();
1736
- return;
1737
- }
1738
-
1739
- // Start screen keyboard navigation
1740
- if (!gameRunning && document.getElementById("startScreen").style.display !== "none") {
1741
- if (startScreenPhase === "mode" || startScreenPhase === "submode") {
1742
- if (e.code === "ArrowUp") { menuMove(-1); e.preventDefault(); }
1743
- if (e.code === "ArrowDown") { menuMove(1); e.preventDefault(); }
1744
- if (e.code === "Space" || e.code === "Enter") { menuConfirm(); e.preventDefault(); }
1745
- if (e.code === "Escape" || e.code === "Backspace") { menuBack(); e.preventDefault(); }
1746
- } else if (startScreenPhase === "count") {
1747
- if (e.code === "ArrowLeft") { adjustPlayerCount(-1); e.preventDefault(); }
1748
- if (e.code === "ArrowRight") { adjustPlayerCount(1); e.preventDefault(); }
1749
- if (e.code === "Space" || e.code === "Enter") { confirmPlayerCount(); e.preventDefault(); }
1750
- if (e.code === "Escape" || e.code === "Backspace") { menuBack(); e.preventDefault(); }
1751
- }
1752
- return;
1753
- }
1754
-
1755
- if (e.code === "KeyP" && gameRunning) {
1756
- gamePaused = !gamePaused;
1757
- if (gamePaused) {
1758
- pauseOverlay.classList.add("visible");
1759
- } else {
1760
- pauseOverlay.classList.remove("visible");
1761
- }
1762
- }
1763
-
1764
- if (e.code === "KeyH" && gameRunning && !gamePaused) {
1765
- hyperspace();
1766
- }
1767
-
1768
- // Game over: space or enter to go back to start
1769
- const gameOverScreen = document.getElementById("gameOverScreen");
1770
- if (!gameRunning && gameOverScreen.style.display === "block" && (e.code === "Space" || e.code === "Enter")) {
1771
- gameOverScreen.style.display = "none";
1772
- resetStartScreen();
1773
- e.preventDefault();
1774
- return;
1775
- }
1776
-
1777
- // Final scoreboard: space or enter to go back to start
1778
- if (!gameRunning && document.getElementById("finalScoreboard").classList.contains("visible") && (e.code === "Space" || e.code === "Enter")) {
1779
- resetStartScreen();
1780
- e.preventDefault();
1781
- return;
1782
- }
1783
-
1784
- if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) {
1785
- e.preventDefault();
1786
- }
1787
- });
1788
-
1789
- document.getElementById("restartBtn").addEventListener("click", function() {
1790
- document.getElementById("gameOverScreen").style.display = "none";
1791
- resetStartScreen();
1792
- });
1793
-
1794
- // Initialize
1795
- updateAllPrompts();
1796
- initStars();
1797
- gameLoop();
1798
- </script>
1799
-
1800
-
1801
-
1802
-
1803
- <script id="page-helpers" src="/api/page-helpers.js?v=3" data-locked="true"></script><script id="page-script" src="/api/page-script.js?v=3" data-locked="true"></script></body></html>
1
+ <!DOCTYPE html><html lang="en"><head>
2
+ <meta charset="UTF-8">
3
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
4
+ <title>SynthOS</title>
5
+ <script id="theme-info" src="/api/theme-info.js" data-locked="true"></script>
6
+ <link id="theme-css" rel="stylesheet" href="/api/theme.css" data-locked="true">
7
+ <style>
8
+ #game-wrapper{width:100%;height:100%;display:flex;align-items:flex-start;justify-content:center;background:#000;overflow:hidden}
9
+ #game-container{position:relative;border:1px solid rgba(0,255,255,.25);box-shadow:0 0 24px rgba(0,255,255,.1),inset 0 0 24px rgba(0,255,255,.05);overflow:hidden;flex-shrink:0}
10
+ #gameCanvas{display:block;width:100%;height:100%}
11
+ .game-ui{position:absolute;top:20px;left:20px;right:20px;display:flex;justify-content:space-between;pointer-events:none;z-index:10}
12
+ .level-display,.lives-display,.score-display{font-family:Orbitron,'Segoe UI',sans-serif;font-size:18px;color:#0ff;text-shadow:0 0 10px #0ff,0 0 20px #0ff;letter-spacing:2px}
13
+ .mute-btn{pointer-events:auto;background:none;border:1px solid rgba(0,255,255,.4);border-radius:4px;color:#0ff;cursor:pointer;padding:2px 6px;font-size:16px;line-height:1;opacity:.7;transition:opacity .2s}.mute-btn:hover{opacity:1}
14
+ .player-indicator{font-family:Orbitron,'Segoe UI',sans-serif;font-size:18px;text-shadow:0 0 10px currentColor,0 0 20px currentColor;letter-spacing:2px;margin-right:8px}
15
+ .game-over-screen,.start-screen{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;z-index:20}
16
+ .game-over-screen h1,.start-screen h1{font-family:Orbitron,'Segoe UI',sans-serif;font-size:48px;line-height:1.2;color:#f0f;text-shadow:0 0 20px #f0f,0 0 40px #f0f,0 0 60px #f0f;margin-bottom:20px;letter-spacing:5px}
17
+ .game-over-screen p,.start-screen p{font-size:18px;color:#0ff;text-shadow:0 0 10px #0ff;margin-bottom:30px}
18
+ .restart-btn,.start-btn,.mode-btn{padding:15px 40px;font-size:18px;font-family:Orbitron,'Segoe UI',sans-serif;background:0 0;border:2px solid #0ff;color:#0ff;cursor:pointer;text-transform:uppercase;letter-spacing:3px;transition:.3s;box-shadow:0 0 20px rgba(0,255,255,.3),inset 0 0 20px rgba(0,255,255,.1)}
19
+ .restart-btn:hover,.start-btn:hover,.mode-btn:hover{background:rgba(0,255,255,.2);box-shadow:0 0 30px rgba(0,255,255,.5),inset 0 0 30px rgba(0,255,255,.2);transform:scale(1.05)}
20
+ .controls-info{position:absolute;bottom:16px;right:20px;font-size:13px;color:rgba(0,255,255,.5);text-align:right;letter-spacing:1px;display:flex;align-items:center;gap:16px;font-family:'Segoe UI',sans-serif}
21
+ .controls-info .action-item{display:inline-flex;align-items:center;gap:4px}
22
+
23
+ /* Mode selection */
24
+ .mode-select{display:flex;flex-direction:column;gap:12px;align-items:center;margin-bottom:20px}
25
+ .mode-btn{padding:12px 36px;font-size:16px;min-width:240px}
26
+ .mode-btn.selected{background:rgba(0,255,255,.25);border-color:#fff;color:#fff;box-shadow:0 0 30px rgba(0,255,255,.6),inset 0 0 20px rgba(0,255,255,.3)}
27
+
28
+ /* Player count selector */
29
+ .player-count-select{display:none;flex-direction:column;align-items:center;gap:12px;margin-top:16px}
30
+ .player-count-select.visible{display:flex}
31
+ .player-count-row{display:flex;align-items:center;gap:16px}
32
+ .player-count-arrow{font-size:28px;color:#0ff;cursor:pointer;user-select:none;text-shadow:0 0 10px #0ff;padding:4px 12px;transition:.2s}
33
+ .player-count-arrow:hover{color:#fff;text-shadow:0 0 15px #fff}
34
+ .player-count-num{font-family:Orbitron,'Segoe UI',sans-serif;font-size:36px;color:#f0f;text-shadow:0 0 15px #f0f,0 0 30px #f0f;min-width:50px;text-align:center}
35
+ .player-count-label{font-size:14px;color:rgba(0,255,255,.6);letter-spacing:2px}
36
+ .player-count-confirm{margin-top:8px}
37
+
38
+ /* Sub-mode selection */
39
+ .submode-select{display:none;flex-direction:column;align-items:center;gap:12px;margin-top:16px}
40
+ .submode-select.visible{display:flex}
41
+ .submode-title{font-family:Orbitron,'Segoe UI',sans-serif;font-size:14px;color:rgba(0,255,255,.7);letter-spacing:2px;margin-bottom:4px}
42
+ .submode-btn{padding:10px 30px;font-size:14px;min-width:220px;font-family:Orbitron,'Segoe UI',sans-serif;background:0 0;border:2px solid #0ff;color:#0ff;cursor:pointer;text-transform:uppercase;letter-spacing:2px;transition:.3s;box-shadow:0 0 15px rgba(0,255,255,.2),inset 0 0 15px rgba(0,255,255,.08)}
43
+ .submode-btn:hover{background:rgba(0,255,255,.2);box-shadow:0 0 25px rgba(0,255,255,.5);transform:scale(1.03)}
44
+ .submode-desc{font-size:11px;color:rgba(0,255,255,.45);font-family:'Segoe UI',sans-serif;max-width:280px;text-align:center;margin-top:-4px}
45
+
46
+ /* Side panels */
47
+ .side-panel{width:200px;flex-shrink:0;padding:20px 16px;display:flex;flex-direction:column;font-family:Orbitron,'Segoe UI',sans-serif;box-sizing:border-box;overflow:hidden}
48
+ .side-panel.hidden{display:none}
49
+ .side-panel h2{font-size:16px;letter-spacing:3px;margin:0 0 16px 0;text-align:center;font-weight:700}
50
+ #side-leaderboard{color:#f0f;text-shadow:0 0 10px #f0f,0 0 20px rgba(255,0,255,.4)}
51
+ #side-leaderboard h2{color:#f0f;text-shadow:0 0 10px #f0f,0 0 20px #f0f}
52
+ #side-controls{color:#0ff;text-shadow:0 0 10px #0ff,0 0 20px rgba(0,255,255,.4)}
53
+ #side-controls h2{color:#0ff;text-shadow:0 0 10px #0ff,0 0 20px #0ff}
54
+ .leaderboard-table{width:100%;border-collapse:collapse;font-family:'Courier New',monospace;font-size:13px}
55
+ .leaderboard-table td{padding:4px 0;white-space:nowrap}
56
+ .leaderboard-table .rank{width:24px;color:rgba(255,0,255,.6);text-align:right;padding-right:6px}
57
+ .leaderboard-table .initials{color:#f0f;letter-spacing:2px}
58
+ .leaderboard-table .hs-score{text-align:right;color:rgba(255,0,255,.7);position:relative;cursor:default}
59
+ .leaderboard-table .hs-score.overflow{animation:scoreFlash 0.8s ease-in-out infinite}
60
+ .leaderboard-table .hs-score[title]:hover::after{content:attr(title);position:absolute;right:0;top:-22px;background:#1a0028;color:#f0f;font-size:11px;padding:2px 6px;border:1px solid rgba(255,0,255,.4);border-radius:3px;white-space:nowrap;z-index:5;pointer-events:none}
61
+ @keyframes scoreFlash{0%,100%{opacity:1}50%{opacity:.4}}
62
+ .score-display .score-overflow{animation:hudScoreFlash 0.8s ease-in-out infinite}
63
+ @keyframes hudScoreFlash{0%,100%{color:#0ff;text-shadow:0 0 10px #0ff,0 0 20px #0ff}50%{color:#f0f;text-shadow:0 0 10px #f0f,0 0 20px #f0f}}
64
+ .controls-list{list-style:none;padding:0;margin:0;font-size:12px}
65
+ .controls-list li{padding:6px 0;display:flex;gap:8px;align-items:center;color:#0ff}
66
+ .controls-list .key-icon{display:inline-block;min-width:50px;padding:2px 6px;border:1px solid rgba(0,255,255,.4);border-radius:3px;text-align:center;font-size:11px;color:#0ff;background:rgba(0,255,255,.05);flex-shrink:0}
67
+ .controls-list .key-desc{color:rgba(0,255,255,.7);font-family:'Segoe UI',sans-serif}
68
+ .bt-section{margin-top:auto;padding-top:16px;border-top:1px solid rgba(0,255,255,.15)}
69
+ .bt-section h3{font-size:11px;letter-spacing:2px;color:rgba(0,255,255,.5);margin:0 0 8px 0;text-align:center;font-weight:400}
70
+ .bt-section p{font-size:11px;color:rgba(0,255,255,.4);font-family:'Segoe UI',sans-serif;margin:0 0 4px 0;line-height:1.4}
71
+ .side-panel{overflow-y:auto}
72
+ #side-leaderboard{padding-top:20px}
73
+ #side-controls{padding-top:20px}
74
+
75
+ /* Gamepad status indicator */
76
+ #gamepadStatus{text-align:center;padding:8px 0;margin-bottom:12px;font-size:11px;letter-spacing:2px;border:1px solid rgba(0,255,255,.15);border-radius:4px;transition:all .3s;color:rgba(0,255,255,.3);text-shadow:none}
77
+ #gamepadStatus.connected{color:#0f0;text-shadow:0 0 8px #0f0,0 0 16px rgba(0,255,0,.4);border-color:rgba(0,255,0,.3);background:rgba(0,255,0,.05);animation:gpPulse 2s ease-in-out infinite}
78
+ @keyframes gpPulse{0%,100%{box-shadow:0 0 4px rgba(0,255,0,.1)}50%{box-shadow:0 0 12px rgba(0,255,0,.25)}}
79
+
80
+ /* Pause overlay */
81
+ #pauseOverlay{position:absolute;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;z-index:25;background:rgba(0,0,0,.5);flex-direction:column;gap:8px}
82
+ #pauseOverlay.visible{display:flex}
83
+ #pauseOverlay .pause-title{font-family:Orbitron,'Segoe UI',sans-serif;font-size:48px;color:#0ff;text-shadow:0 0 20px #0ff,0 0 40px #0ff,0 0 60px #0ff;letter-spacing:8px}
84
+ #pauseOverlay .pause-hint{font-family:'Segoe UI',sans-serif;font-size:14px;color:rgba(0,255,255,.5);letter-spacing:1px}
85
+
86
+ /* High score entry */
87
+ #highScoreEntry{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;z-index:30;display:none}
88
+ #highScoreEntry h2{font-family:Orbitron,'Segoe UI',sans-serif;font-size:24px;color:#f0f;text-shadow:0 0 15px #f0f,0 0 30px #f0f;margin-bottom:10px;letter-spacing:3px}
89
+ #highScoreEntry .hs-final-score{font-family:Orbitron,'Segoe UI',sans-serif;font-size:18px;color:#0ff;text-shadow:0 0 10px #0ff;margin-bottom:20px}
90
+ .initial-slots{display:flex;justify-content:center;gap:12px;margin-bottom:20px}
91
+ .initial-slot{width:40px;height:50px;border:2px solid #f0f;display:flex;align-items:center;justify-content:center;font-family:Orbitron,'Segoe UI',sans-serif;font-size:28px;color:#f0f;text-shadow:0 0 10px #f0f;box-shadow:0 0 10px rgba(255,0,255,.3)}
92
+ .initial-slot.active{border-color:#0ff;color:#0ff;text-shadow:0 0 10px #0ff;box-shadow:0 0 15px rgba(0,255,255,.5)}
93
+ .hs-instructions{font-size:12px;color:rgba(0,255,255,.6);font-family:'Segoe UI',sans-serif;margin-bottom:16px}
94
+
95
+ /* Keyboard key hint (for bottom bar) */
96
+ .kb-key{display:inline-block;padding:1px 6px;border:1px solid rgba(0,255,255,.35);border-radius:3px;font-size:11px;color:rgba(0,255,255,.7);background:rgba(0,255,255,.05);vertical-align:middle;margin:0 2px;font-family:'Segoe UI',sans-serif}
97
+ /* Focused menu item */
98
+ .mode-btn.focused,.submode-btn.focused{background:rgba(0,255,255,.2);border-color:#fff;color:#fff;box-shadow:0 0 25px rgba(0,255,255,.5),inset 0 0 20px rgba(0,255,255,.2)}
99
+
100
+ /* Turn ready overlay */
101
+ #turnReadyOverlay{position:absolute;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:12px;z-index:26;background:rgba(0,0,0,.7)}
102
+ #turnReadyOverlay.visible{display:flex}
103
+ #turnReadyOverlay .turn-player{font-family:Orbitron,'Segoe UI',sans-serif;font-size:42px;text-shadow:0 0 20px currentColor,0 0 40px currentColor;letter-spacing:6px}
104
+ #turnReadyOverlay .turn-subtitle{font-family:Orbitron,'Segoe UI',sans-serif;font-size:22px;color:#fff;text-shadow:0 0 12px rgba(255,255,255,.6);letter-spacing:4px;margin-bottom:8px}
105
+ #turnReadyOverlay .turn-prompt{font-family:'Segoe UI',sans-serif;font-size:16px;color:rgba(0,255,255,.6);letter-spacing:1px}
106
+
107
+ /* Final scoreboard overlay */
108
+ #finalScoreboard{position:absolute;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;flex-direction:column;gap:16px;z-index:27;background:rgba(0,0,0,.8)}
109
+ #finalScoreboard.visible{display:flex}
110
+ #finalScoreboard .sb-title{font-family:Orbitron,'Segoe UI',sans-serif;font-size:32px;color:#f0f;text-shadow:0 0 20px #f0f,0 0 40px #f0f;letter-spacing:5px;margin-bottom:8px}
111
+ #finalScoreboard .sb-subtitle{font-family:Orbitron,'Segoe UI',sans-serif;font-size:14px;color:rgba(255,255,255,.5);letter-spacing:3px;margin-bottom:12px}
112
+ .sb-table{border-collapse:collapse;font-family:Orbitron,'Segoe UI',sans-serif;font-size:18px}
113
+ .sb-table td{padding:8px 16px}
114
+ .sb-table .sb-rank{color:rgba(255,255,255,.5);text-align:right;font-size:14px}
115
+ .sb-table .sb-player{letter-spacing:3px}
116
+ .sb-table .sb-initials{font-size:14px;color:rgba(255,255,255,.5);letter-spacing:2px}
117
+ .sb-table .sb-score{text-align:right;color:#fff;letter-spacing:2px}
118
+ .sb-play-again{margin-top:16px}
119
+ </style>
120
+ <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&amp;display=swap" rel="stylesheet">
121
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
122
+ </head>
123
+
124
+ <body>
125
+ <div class="viewer-panel full-viewer" id="viewerPanel" style="background:#000;">
126
+ <div id="game-wrapper">
127
+ <div id="side-leaderboard" class="side-panel hidden">
128
+ <h2>HIGH SCORES</h2>
129
+ <table class="leaderboard-table">
130
+ <tbody id="leaderboardBody"></tbody>
131
+ </table>
132
+ </div>
133
+ <div id="game-container">
134
+ <canvas id="gameCanvas"></canvas>
135
+ <div class="game-ui">
136
+ <div class="score-display"><span id="playerIndicator" class="player-indicator" style="display:none"></span>SCORE: <span id="score">00000</span></div>
137
+ <div class="level-display">LEVEL: <span id="level">1</span></div>
138
+ <div class="lives-display">LIVES: <span id="lives">3</span></div>
139
+ <button class="mute-btn" id="muteBtn" title="Toggle sound">🔊</button>
140
+ </div>
141
+ <div id="pauseOverlay"><span class="pause-title">PAUSED</span><span class="pause-hint" id="pauseHint"></span></div>
142
+ <div id="turnReadyOverlay">
143
+ <div class="turn-player" id="turnPlayerName">PLAYER 1</div>
144
+ <div class="turn-subtitle">YOUR TURN</div>
145
+ <div class="turn-prompt" id="turnPrompt">Press SPACE to start</div>
146
+ </div>
147
+ <div id="finalScoreboard">
148
+ <div class="sb-title">GAME OVER</div>
149
+ <div class="sb-subtitle" id="sbSubtitle">FINAL STANDINGS</div>
150
+ <table class="sb-table"><tbody id="sbBody"></tbody></table>
151
+ <button class="restart-btn sb-play-again" id="sbPlayAgain">PLAY AGAIN</button>
152
+ </div>
153
+ <div class="start-screen" id="startScreen">
154
+ <h1>NEON<br>ASTEROIDS</h1>
155
+ <p>Navigate the cosmic void. Destroy all asteroids. Beware the saucers!</p>
156
+ <div class="mode-select" id="modeSelect">
157
+ <button class="mode-btn" id="btn1P">START MISSION</button>
158
+ <button class="mode-btn" id="btnPassPlay">PASS &amp; PLAY</button>
159
+ </div>
160
+ <div class="submode-select" id="submodeSelect">
161
+ <div class="submode-title">CHOOSE MODE</div>
162
+ <button class="submode-btn" id="btnSoloTurns">SOLO TURNS</button>
163
+ <div class="submode-desc">Each player gets a fresh game. Highest total score wins.</div>
164
+ <button class="submode-btn" id="btnHotSeat">HOT SEAT</button>
165
+ <div class="submode-desc">Shared asteroid field. Rotate on each death. Last one standing!</div>
166
+ </div>
167
+ <div class="player-count-select" id="playerCountSelect">
168
+ <div class="player-count-label">PLAYERS</div>
169
+ <div class="player-count-row">
170
+ <span class="player-count-arrow" id="pcLeft">←</span>
171
+ <span class="player-count-num" id="pcNum">2</span>
172
+ <span class="player-count-arrow" id="pcRight">→</span>
173
+ </div>
174
+ <button class="mode-btn player-count-confirm" id="pcConfirm">START</button>
175
+ </div>
176
+ </div>
177
+ <div class="game-over-screen" id="gameOverScreen" style="display: none;">
178
+ <h1>GAME OVER</h1>
179
+ <p>Final Score: <span id="finalScore">0</span></p>
180
+ <button class="restart-btn" id="restartBtn">TRY AGAIN</button>
181
+ </div>
182
+ <div id="highScoreEntry">
183
+ <h2>NEW HIGH SCORE!</h2>
184
+ <div class="hs-final-score">SCORE: <span id="hsScore">0</span></div>
185
+ <div class="initial-slots">
186
+ <div class="initial-slot active" id="slot0">A</div>
187
+ <div class="initial-slot" id="slot1">A</div>
188
+ <div class="initial-slot" id="slot2">A</div>
189
+ </div>
190
+ <div class="hs-instructions" id="hsInstructions">LEFT/RIGHT select slot • UP/DOWN change letter • ENTER confirm</div>
191
+ </div>
192
+ <div class="controls-info" id="controlsInfo">WASD / ARROWS to move • SPACE to fire • H hyperspace • P pause</div>
193
+ </div>
194
+ <div id="side-controls" class="side-panel hidden">
195
+ <div id="gamepadStatus">NO GAMEPAD</div>
196
+ <h2>CONTROLS</h2>
197
+ <ul class="controls-list" id="controlsList">
198
+ <li><span class="key-icon">W/↑</span><span class="key-desc">Thrust</span></li>
199
+ <li><span class="key-icon">A/←</span><span class="key-desc">Rotate Left</span></li>
200
+ <li><span class="key-icon">D/→</span><span class="key-desc">Rotate Right</span></li>
201
+ <li><span class="key-icon">SPACE</span><span class="key-desc">Fire</span></li>
202
+ <li><span class="key-icon">H</span><span class="key-desc">Hyperspace</span></li>
203
+ <li><span class="key-icon">P</span><span class="key-desc">Pause</span></li>
204
+ </ul>
205
+ <h2 style="margin-top:24px">POWER-UPS</h2>
206
+ <ul class="controls-list">
207
+ <li><span class="key-icon" style="color:#0ff;border-color:rgba(0,255,255,.4)">S</span><span class="key-desc">Shield</span></li>
208
+ <li><span class="key-icon" style="color:#f00;border-color:rgba(255,0,0,.4)">R</span><span class="key-desc">Rapid Fire</span></li>
209
+ <li><span class="key-icon" style="color:#ff0;border-color:rgba(255,255,0,.4)">M</span><span class="key-desc">Multi-Shot</span></li>
210
+ <li><span class="key-icon" style="color:#0f0;border-color:rgba(0,255,0,.4)">+</span><span class="key-desc">Extra Life</span></li>
211
+ </ul>
212
+ <div class="bt-section" id="btSection"></div>
213
+ </div>
214
+ </div>
215
+
216
+ </div>
217
+ <div id="instructions" style="display: none;" data-locked="true"></div>
218
+ <div id="thoughts" style="display: none;" data-locked="true"></div>
219
+ <script src="/static/retro-game.js"></script>
220
+ <script id="asteroids-game">
221
+ RetroGame.injectCSS();
222
+
223
+ // --- Sound Effects ---
224
+ var sfx = RetroGame.createSoundEffects();
225
+ var sfxReady = false;
226
+ sfx.load().then(function() {
227
+ console.log('✓ Sound effects loaded successfully');
228
+ sfxReady = true;
229
+ }).catch(function(e) {
230
+ console.error('✗ Sound effects failed to load:', e);
231
+ console.error('Make sure effects.json exists in page file storage');
232
+ console.error('Upload it via: synthos.files.upload("effects.json", blob)');
233
+ });
234
+
235
+ // Mute toggle
236
+ var sfxMuted = false;
237
+ document.getElementById("muteBtn").addEventListener("click", function() {
238
+ sfxMuted = !sfxMuted;
239
+ sfx.volume = sfxMuted ? 0 : 1;
240
+ this.innerHTML = sfxMuted ? "\u{1F507}" : "\u{1F50A}";
241
+ });
242
+
243
+ var ambientPlaying = false;
244
+ var ambientTimeout = null;
245
+ var AMBIENT_TRACKS = ['Ambient_Space', 'Ambient_Space2', 'Ambient_Space3'];
246
+ var currentAmbientIndex = -1;
247
+
248
+ function startAmbient() {
249
+ if (ambientPlaying) return;
250
+ ambientPlaying = true;
251
+ playNextAmbient();
252
+ }
253
+
254
+ function playNextAmbient() {
255
+ if (!ambientPlaying || !sfxReady) return;
256
+ // Pick a random track different from the last one
257
+ let idx;
258
+ do { idx = Math.floor(Math.random() * AMBIENT_TRACKS.length); } while (idx === currentAmbientIndex && AMBIENT_TRACKS.length > 1);
259
+ currentAmbientIndex = idx;
260
+ sfx.play(AMBIENT_TRACKS[idx]);
261
+ }
262
+
263
+ function stopAmbient() {
264
+ ambientPlaying = false;
265
+ if (ambientTimeout) { clearTimeout(ambientTimeout); ambientTimeout = null; }
266
+ if (sfxReady) AMBIENT_TRACKS.forEach(function(t) { sfx.stop(t); });
267
+ currentAmbientIndex = -1;
268
+ }
269
+
270
+ var canvas = document.getElementById("gameCanvas");
271
+ var ctx = canvas.getContext("2d");
272
+ var viewerPanel = document.getElementById("viewerPanel");
273
+ var gameContainer = document.getElementById("game-container");
274
+ var sideLeaderboard = document.getElementById("side-leaderboard");
275
+ var sideControls = document.getElementById("side-controls");
276
+ var pauseOverlay = document.getElementById("pauseOverlay");
277
+ var ASPECT = 4 / 3;
278
+ var MAX_SCORE = 99999;
279
+
280
+ // Player colors: P1=cyan, P2=magenta, P3=yellow, P4=green
281
+ var PLAYER_COLORS = ["#00ffff", "#ff00ff", "#ffff00", "#00ff00", "#ff6600", "#ff3388", "#33ccff", "#ccff33"];
282
+
283
+ var resizeTimer = 0;
284
+
285
+ function resizeCanvas() {
286
+ const vw = viewerPanel.clientWidth;
287
+ const vh = viewerPanel.clientHeight;
288
+ if (vw < 1 || vh < 1) return;
289
+ const panelRatio = vw / vh;
290
+ let w, h;
291
+ if (panelRatio > ASPECT) {
292
+ h = vh;
293
+ w = Math.floor(h * ASPECT);
294
+ } else {
295
+ w = vw;
296
+ h = Math.floor(w / ASPECT);
297
+ }
298
+ gameContainer.style.width = w + "px";
299
+ gameContainer.style.height = h + "px";
300
+ canvas.width = w;
301
+ canvas.height = h;
302
+ const extraSpace = vw - w;
303
+ if (extraSpace > 400) {
304
+ sideLeaderboard.classList.remove("hidden");
305
+ sideControls.classList.remove("hidden");
306
+ } else {
307
+ sideLeaderboard.classList.add("hidden");
308
+ sideControls.classList.add("hidden");
309
+ }
310
+ }
311
+
312
+ function debouncedResize() {
313
+ clearTimeout(resizeTimer);
314
+ resizeCanvas();
315
+ resizeTimer = setTimeout(resizeCanvas, 300);
316
+ }
317
+
318
+ resizeCanvas();
319
+ window.addEventListener("resize", resizeCanvas);
320
+ new MutationObserver(debouncedResize).observe(document.body, { attributes: true, attributeFilter: ["class"] });
321
+
322
+ // --- Gamepad input (library) ---
323
+ var gamepad = RetroGame.createGamepad({ deadzone: 0.3 });
324
+ var B = RetroGame.BUTTON;
325
+ var gamepadUI = RetroGame.createGamepadUI(gamepad, { statusElement: document.getElementById("gamepadStatus") });
326
+
327
+ // Game-specific gamepad action mappings
328
+ function gpFire() { return gamepad.isDown(B.A) || gamepad.isDown(B.X) || gamepad.isDown(B.RB) || gamepad.isDown(B.RT); }
329
+ function gpHyper() { return gamepad.wasPressed(B.B) || gamepad.wasPressed(B.LB); }
330
+ function gpPause() { return gamepad.wasPressed(B.START); }
331
+ function gpConfirm() { return gamepad.wasPressed(B.A); }
332
+ function gpBack() { return gamepad.wasPressed(B.B); }
333
+
334
+ // Edge-triggered directional helpers (stick + dpad)
335
+ // Track previous directional state for stick-based edge detection
336
+ var _prevDir = { up: false, down: false, left: false, right: false };
337
+ function gpUp() { var r = gamepad.up && !_prevDir.up; return r; }
338
+ function gpDown() { var r = gamepad.down && !_prevDir.down; return r; }
339
+ function gpLeft() { var r = gamepad.left && !_prevDir.left; return r; }
340
+ function gpRight() { var r = gamepad.right && !_prevDir.right; return r; }
341
+ function saveDirState() { _prevDir.up = gamepad.up; _prevDir.down = gamepad.down; _prevDir.left = gamepad.left; _prevDir.right = gamepad.right; }
342
+
343
+ // Delegate to library UI
344
+ function gpBtn(name) { return gamepadUI.buttonGlyph(name); }
345
+ function gpBtnName(name) { return gamepadUI.buttonName(name); }
346
+ function gpStartName() { return gamepadUI.startName(); }
347
+
348
+ // Returns styled HTML for a non-face button label (game-specific, not in library)
349
+ function gpLabel(text) {
350
+ return '<span class="gp-label">' + text + '</span>';
351
+ }
352
+
353
+ // --- Gamepad-aware text helper ---
354
+ function btnText(kbLabel, gpLabelText) {
355
+ return gamepad.connected ? gpLabelText : kbLabel;
356
+ }
357
+
358
+ // Keyboard key styled span
359
+ function kbKey(text) {
360
+ return '<span class="kb-key">' + text + '</span>';
361
+ }
362
+
363
+ // Build an action item for the bottom bar
364
+ function actionItem(hint, label) {
365
+ return '<span class="action-item">' + hint + ' ' + label + '</span>';
366
+ }
367
+
368
+ // --- Unified bottom bar: menu nav OR game controls ---
369
+ function updateBottomBar() {
370
+ var bar = document.getElementById("controlsInfo");
371
+ var startVisible = document.getElementById("startScreen").style.display !== "none";
372
+ var gameOverVisible = document.getElementById("gameOverScreen").style.display === "block";
373
+ var scoreboardVisible = document.getElementById("finalScoreboard").classList.contains("visible");
374
+
375
+ // Menu screens — show navigation hints
376
+ if (startVisible) {
377
+ if (gamepad.connected) {
378
+ if (startScreenPhase === "mode") {
379
+ bar.innerHTML =
380
+ actionItem(gpLabel("\u2191\u2193"), "Navigate") +
381
+ actionItem(gpBtn("a"), "Select");
382
+ } else if (startScreenPhase === "submode") {
383
+ bar.innerHTML =
384
+ actionItem(gpLabel("\u2191\u2193"), "Navigate") +
385
+ actionItem(gpBtn("a"), "Select") +
386
+ actionItem(gpBtn("b"), "Back");
387
+ } else if (startScreenPhase === "count") {
388
+ bar.innerHTML =
389
+ actionItem(gpLabel("\u2190\u2192"), "Players") +
390
+ actionItem(gpBtn("a"), "Start") +
391
+ actionItem(gpBtn("b"), "Back");
392
+ }
393
+ } else {
394
+ if (startScreenPhase === "mode") {
395
+ bar.innerHTML =
396
+ actionItem(kbKey("\u2191\u2193"), "Navigate") +
397
+ actionItem(kbKey("ENTER"), "Select");
398
+ } else if (startScreenPhase === "submode") {
399
+ bar.innerHTML =
400
+ actionItem(kbKey("\u2191\u2193"), "Navigate") +
401
+ actionItem(kbKey("ENTER"), "Select") +
402
+ actionItem(kbKey("ESC"), "Back");
403
+ } else if (startScreenPhase === "count") {
404
+ bar.innerHTML =
405
+ actionItem(kbKey("\u2190\u2192"), "Players") +
406
+ actionItem(kbKey("ENTER"), "Start") +
407
+ actionItem(kbKey("ESC"), "Back");
408
+ }
409
+ }
410
+ return;
411
+ }
412
+
413
+ // High score entry
414
+ if (hsEntryActive) {
415
+ if (gamepad.connected) {
416
+ bar.innerHTML =
417
+ actionItem(gpLabel("\u2191\u2193"), "Letter") +
418
+ actionItem(gpBtn("a"), "Next") +
419
+ actionItem(gpBtn("b"), "Back");
420
+ } else {
421
+ bar.innerHTML =
422
+ actionItem(kbKey("\u2191\u2193"), "Letter") +
423
+ actionItem(kbKey("\u2190\u2192"), "Slot") +
424
+ actionItem(kbKey("ENTER"), "Confirm");
425
+ }
426
+ return;
427
+ }
428
+
429
+ // Game over / scoreboard
430
+ if (gameOverVisible || scoreboardVisible) {
431
+ if (gamepad.connected) {
432
+ bar.innerHTML = actionItem(gpBtn("a"), "Continue");
433
+ } else {
434
+ bar.innerHTML = actionItem(kbKey("ENTER"), "Continue");
435
+ }
436
+ return;
437
+ }
438
+
439
+ // In-game controls
440
+ if (gamepad.connected) {
441
+ bar.innerHTML =
442
+ actionItem(gpLabel("L-Stick"), "Move") +
443
+ actionItem(gpBtn("a"), "Fire") +
444
+ actionItem(gpBtn("b"), "Hyperspace") +
445
+ actionItem(gpLabel(gpStartName()), "Pause");
446
+ } else {
447
+ bar.innerHTML =
448
+ actionItem(kbKey("WASD"), "Move") +
449
+ actionItem(kbKey("SPACE"), "Fire") +
450
+ actionItem(kbKey("H"), "Hyperspace") +
451
+ actionItem(kbKey("P"), "Pause");
452
+ }
453
+ }
454
+
455
+ function updateAllPrompts() {
456
+ // Start screen buttons
457
+ document.getElementById("btn1P").textContent = gamepad.connected ? "1 PLAYER" : "START MISSION";
458
+ document.getElementById("btnPassPlay").textContent = "PASS & PLAY";
459
+ document.getElementById("btnSoloTurns").textContent = "SOLO TURNS";
460
+ document.getElementById("btnHotSeat").textContent = "HOT SEAT";
461
+ // Focus highlight
462
+ updateMenuFocus();
463
+ // Restart button
464
+ var restartBtn = document.getElementById("restartBtn");
465
+ if (gamepad.connected) {
466
+ restartBtn.innerHTML = gpBtn("a") + ' RETRY';
467
+ } else {
468
+ restartBtn.textContent = "TRY AGAIN";
469
+ }
470
+ // HS instructions
471
+ document.getElementById("hsInstructions").innerHTML = btnText(
472
+ "LEFT/RIGHT select slot &bull; UP/DOWN change letter &bull; ENTER confirm",
473
+ gpLabel("\u2191\u2193") + " change letter &bull; " + gpBtn("a") + " next &bull; " + gpBtn("b") + " back"
474
+ );
475
+ // Pause hint
476
+ document.getElementById("pauseHint").innerHTML = btnText(
477
+ "P to resume",
478
+ gpLabel(gpStartName()) + " to resume"
479
+ );
480
+ // Turn ready prompt
481
+ document.getElementById("turnPrompt").innerHTML = btnText(
482
+ "Press SPACE to start",
483
+ "Press " + gpBtn("a") + " to start"
484
+ );
485
+ // Player count confirm
486
+ var pcConfirm = document.getElementById("pcConfirm");
487
+ if (gamepad.connected) {
488
+ pcConfirm.innerHTML = gpBtn("a") + ' START';
489
+ } else {
490
+ pcConfirm.textContent = "START";
491
+ }
492
+ // Scoreboard play again
493
+ var sbBtn = document.getElementById("sbPlayAgain");
494
+ if (gamepad.connected) {
495
+ sbBtn.innerHTML = gpBtn("a") + ' PLAY AGAIN';
496
+ } else {
497
+ sbBtn.textContent = "PLAY AGAIN";
498
+ }
499
+ // Bottom bar
500
+ updateBottomBar();
501
+ }
502
+
503
+ // --- High Score System (synthos.data, top 20) ---
504
+ var MAX_LEADERBOARD = 20;
505
+ var _highScores = [{ initials: "ACE", score: 10000 }];
506
+
507
+ // Load scores from app table storage on startup
508
+ (async function() {
509
+ try {
510
+ var row = await synthos.data.get('high_scores', 'scores');
511
+ if (row && Array.isArray(row.entries) && row.entries.length > 0) {
512
+ _highScores = row.entries;
513
+ }
514
+ } catch(e) {}
515
+ renderLeaderboard();
516
+ })();
517
+
518
+ function loadHighScores() {
519
+ return _highScores;
520
+ }
521
+
522
+ function saveHighScores(scores) {
523
+ _highScores = scores;
524
+ synthos.data.save('high_scores', { id: 'scores', entries: scores }).catch(function() {});
525
+ }
526
+
527
+ function qualifiesForHighScore(s) {
528
+ var scores = loadHighScores();
529
+ return scores.length < MAX_LEADERBOARD || s > scores[scores.length - 1].score;
530
+ }
531
+
532
+ function insertHighScore(initials, s) {
533
+ var scores = loadHighScores().slice();
534
+ scores.push({ initials: initials, score: s });
535
+ scores.sort(function(a, b) { return b.score - a.score; });
536
+ if (scores.length > MAX_LEADERBOARD) scores.length = MAX_LEADERBOARD;
537
+ saveHighScores(scores);
538
+ renderLeaderboard();
539
+ }
540
+
541
+ function formatLeaderboardScore(s) {
542
+ if (s > MAX_SCORE) {
543
+ return { text: "99,999+", overflow: true };
544
+ }
545
+ return { text: s.toLocaleString(), overflow: false };
546
+ }
547
+
548
+ function renderLeaderboard() {
549
+ var scores = loadHighScores();
550
+ var tbody = document.getElementById("leaderboardBody");
551
+ tbody.innerHTML = "";
552
+ scores.forEach(function(entry, i) {
553
+ var tr = document.createElement("tr");
554
+ var fmt = formatLeaderboardScore(entry.score);
555
+ var overflowClass = fmt.overflow ? " overflow" : "";
556
+ var titleAttr = fmt.overflow ? ' title="' + entry.score.toLocaleString() + '"' : "";
557
+ tr.innerHTML =
558
+ '<td class="rank">' + (i + 1) + '.</td>' +
559
+ '<td class="initials">' + entry.initials + '</td>' +
560
+ '<td class="hs-score' + overflowClass + '"' + titleAttr + '>' + fmt.text + '</td>';
561
+ tbody.appendChild(tr);
562
+ });
563
+ }
564
+
565
+ renderLeaderboard();
566
+
567
+ // --- BT Controller Instructions (platform-aware) ---
568
+ (function() {
569
+ const el = document.getElementById("btSection");
570
+ const isMac = /Mac|iPhone|iPad/.test(navigator.platform) || /Macintosh/.test(navigator.userAgent);
571
+ if (isMac) {
572
+ el.innerHTML =
573
+ '<h3>GAMEPAD</h3>' +
574
+ '<p>1. Put controller in pairing mode</p>' +
575
+ '<p>2. Open <b>System Settings &gt; Bluetooth</b></p>' +
576
+ '<p>3. Find controller in list, click <b>Connect</b></p>' +
577
+ '<p>4. Reload page once paired</p>';
578
+ } else {
579
+ el.innerHTML =
580
+ '<h3>GAMEPAD</h3>' +
581
+ '<p>1. Put controller in pairing mode</p>' +
582
+ '<p>2. Open <b>Settings &gt; Bluetooth &amp; devices</b></p>' +
583
+ '<p>3. Click <b>Add device &gt; Bluetooth</b></p>' +
584
+ '<p>4. Select controller, then reload page</p>';
585
+ }
586
+ })();
587
+
588
+ // --- Gamepad Detection & Controls Swap ---
589
+ var KEYBOARD_CONTROLS = '<li><span class="key-icon">W/\u2191</span><span class="key-desc">Thrust</span></li>' +
590
+ '<li><span class="key-icon">A/\u2190</span><span class="key-desc">Rotate Left</span></li>' +
591
+ '<li><span class="key-icon">D/\u2192</span><span class="key-desc">Rotate Right</span></li>' +
592
+ '<li><span class="key-icon">SPACE</span><span class="key-desc">Fire</span></li>' +
593
+ '<li><span class="key-icon">H</span><span class="key-desc">Hyperspace</span></li>' +
594
+ '<li><span class="key-icon">P</span><span class="key-desc">Pause</span></li>';
595
+
596
+ function buildGamepadControls() {
597
+ return '<li><span class="key-icon">L-Stick</span><span class="key-desc">Thrust / Rotate</span></li>' +
598
+ '<li><span class="key-icon">D-Pad</span><span class="key-desc">Thrust / Rotate</span></li>' +
599
+ '<li><span class="key-icon">' + gpBtnName("a") + ' / RB</span><span class="key-desc">Fire</span></li>' +
600
+ '<li><span class="key-icon">' + gpBtnName("b") + ' / LB</span><span class="key-desc">Hyperspace</span></li>' +
601
+ '<li><span class="key-icon">' + gpStartName() + '</span><span class="key-desc">Pause</span></li>';
602
+ }
603
+
604
+ function refreshGamepadUI() {
605
+ gamepadUI.update();
606
+ var listEl = document.getElementById("controlsList");
607
+ var btEl = document.getElementById("btSection");
608
+ if (gamepad.connected) {
609
+ listEl.innerHTML = buildGamepadControls();
610
+ btEl.style.display = "none";
611
+ } else {
612
+ listEl.innerHTML = KEYBOARD_CONTROLS;
613
+ btEl.style.display = "";
614
+ }
615
+ updateAllPrompts();
616
+ }
617
+
618
+ gamepad.onConnect = function() { refreshGamepadUI(); };
619
+ gamepad.onDisconnect = function() { refreshGamepadUI(); };
620
+
621
+ // --- Game Mode State ---
622
+ var gameMode = "single"; // "single", "solo_turns", "hot_seat"
623
+ var playerCount = 2;
624
+ var currentPlayer = 0;
625
+ var players = []; // {score, lives, level, alive, initials}
626
+ var turnReady = false;
627
+ var startScreenPhase = "mode"; // "mode", "submode", "count"
628
+
629
+ // --- Game State ---
630
+ var gameRunning = false, gamePaused = false, score = 0, lives = 3, level = 1;
631
+ var shake = RetroGame.createShake();
632
+ var hyperspaceCooldown = 0;
633
+ var hyperspaceInvincible = 0;
634
+
635
+ var ship = {
636
+ x: 0, y: 0, vx: 0, vy: 0, angle: -Math.PI / 2,
637
+ radius: 15, thrust: 0, rotationSpeed: 0,
638
+ invincible: 0, shield: 0, rapidFire: 0, multiShot: 0
639
+ };
640
+
641
+ var bullets = [], asteroids = [], powerUps = [], stars = [];
642
+ var saucers = [], saucerBullets = [], saucerSpawnTimer = 0;
643
+ var keyboard = RetroGame.createKeyboard();
644
+ var particles = RetroGame.createParticles({ maxParticles: 500 });
645
+
646
+ // --- High Score Entry State ---
647
+ var hsEntryActive = false;
648
+ var hsSlots = [0, 0, 0];
649
+ var hsActiveSlot = 0;
650
+ var hsFinalScore = 0;
651
+ var hsEntryPlayerIndex = -1; // track which player is entering HS in multiplayer
652
+
653
+ function getActivePlayerColor() {
654
+ if (gameMode === "single") return PLAYER_COLORS[0];
655
+ return PLAYER_COLORS[currentPlayer] || PLAYER_COLORS[0];
656
+ }
657
+
658
+ function initStars() {
659
+ stars = [];
660
+ for (let i = 0; i < 150; i++)
661
+ stars.push({
662
+ x: Math.random() * canvas.width,
663
+ y: Math.random() * canvas.height,
664
+ size: 2 * Math.random() + 0.5,
665
+ speed: 0.5 * Math.random() + 0.1,
666
+ brightness: Math.random()
667
+ });
668
+ }
669
+
670
+ function initGame() {
671
+ ship.x = canvas.width / 2;
672
+ ship.y = canvas.height / 2;
673
+ ship.vx = 0; ship.vy = 0;
674
+ ship.angle = -Math.PI / 2;
675
+ ship.invincible = 180;
676
+ ship.shield = 0; ship.rapidFire = 0; ship.multiShot = 0;
677
+ bullets = []; asteroids = []; powerUps = [];
678
+ particles.clear();
679
+ saucers = []; saucerBullets = []; saucerSpawnTimer = 0;
680
+ hyperspaceCooldown = 0; hyperspaceInvincible = 0;
681
+ score = 0; lives = 3; level = 1;
682
+ initStars();
683
+ spawnAsteroids(4);
684
+ updateUI();
685
+ }
686
+
687
+ // Init game for hot seat (preserves asteroids/level across player swaps)
688
+ function initHotSeatRespawn() {
689
+ ship.x = canvas.width / 2;
690
+ ship.y = canvas.height / 2;
691
+ ship.vx = 0; ship.vy = 0;
692
+ ship.angle = -Math.PI / 2;
693
+ ship.invincible = 180;
694
+ ship.shield = 0; ship.rapidFire = 0; ship.multiShot = 0;
695
+ bullets = [];
696
+ saucerBullets = [];
697
+ hyperspaceCooldown = 0; hyperspaceInvincible = 0;
698
+ shootCooldown = 0;
699
+ }
700
+
701
+ function initMultiplayerPlayers() {
702
+ players = [];
703
+ for (let i = 0; i < playerCount; i++) {
704
+ players.push({ score: 0, lives: 3, level: 1, alive: true, initials: "" });
705
+ }
706
+ currentPlayer = 0;
707
+ }
708
+
709
+ function formatScore(s) {
710
+ return String(s).padStart(5, "0");
711
+ }
712
+
713
+ function addScore(points) {
714
+ score += points;
715
+ }
716
+
717
+ function getLevelSpeedMultiplier() {
718
+ return 1 + 0.15 * (level - 1);
719
+ }
720
+
721
+ function spawnAsteroids(count) {
722
+ for (let i = 0; i < count; i++) {
723
+ let x, y;
724
+ do {
725
+ x = Math.random() * canvas.width;
726
+ y = Math.random() * canvas.height;
727
+ } while (distance(x, y, ship.x, ship.y) < 150);
728
+ asteroids.push(createAsteroid(x, y, 3));
729
+ }
730
+ }
731
+
732
+ function createAsteroid(x, y, size) {
733
+ const speed = (0.5 * (4 - size) + 1.5 * Math.random()) * getLevelSpeedMultiplier();
734
+ const angle = Math.random() * Math.PI * 2;
735
+ const radii = [];
736
+ const points = 8 + Math.floor(5 * Math.random());
737
+ const baseRadius = 15 * size + 10;
738
+ for (let i = 0; i < points; i++)
739
+ radii.push(baseRadius * (0.7 + 0.6 * Math.random()));
740
+ return {
741
+ x, y,
742
+ vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
743
+ size, radii, points, rotation: 0,
744
+ rotationSpeed: 0.03 * (Math.random() - 0.5),
745
+ color: size === 3 ? "#ff00ff" : size === 2 ? "#ffff00" : "#00ffff"
746
+ };
747
+ }
748
+
749
+ function createSaucer() {
750
+ const isSmall = Math.random() > 0.5;
751
+ const fromLeft = Math.random() > 0.5;
752
+ const y = Math.random() * (canvas.height - 100) + 50;
753
+ return {
754
+ x: fromLeft ? -30 : canvas.width + 30, y,
755
+ vx: (fromLeft ? 1 : -1) * (isSmall ? 3 : 2) * getLevelSpeedMultiplier(),
756
+ vy: 0, isSmall,
757
+ radius: isSmall ? 15 : 25,
758
+ shootTimer: 60 + 60 * Math.random(),
759
+ directionChangeTimer: 60 + 120 * Math.random(),
760
+ points: isSmall ? 1000 : 500
761
+ };
762
+ }
763
+
764
+ function distance(x1, y1, x2, y2) {
765
+ return RetroGame.distance({x: x1, y: y1}, {x: x2, y: y2});
766
+ }
767
+
768
+ function wrap(obj) {
769
+ RetroGame.wrap(obj, canvas.width, canvas.height, 50);
770
+ }
771
+
772
+ function createExplosion(x, y, color, count) {
773
+ particles.emit(x, y, count || 20, {
774
+ color: color, speed: 5, size: 3,
775
+ lifetime: 1.2, decay: 0.02, spread: Math.PI * 2
776
+ });
777
+ shake.trigger(10);
778
+ }
779
+
780
+ // --- Hyperspace ---
781
+ function hyperspace() {
782
+ if (hyperspaceCooldown > 0) return;
783
+ if (sfxReady) sfx.play('Hyperspace_Jump');
784
+ createExplosion(ship.x, ship.y, "#ffffff", 12);
785
+ ship.x = Math.random() * (canvas.width - 100) + 50;
786
+ ship.y = Math.random() * (canvas.height - 100) + 50;
787
+ ship.vx = 0; ship.vy = 0;
788
+ createExplosion(ship.x, ship.y, "#00ffff", 12);
789
+ hyperspaceCooldown = 180;
790
+ hyperspaceInvincible = 60;
791
+ }
792
+
793
+ var shootCooldown = 0;
794
+
795
+ function shoot() {
796
+ if (shootCooldown > 0) return;
797
+ const cooldown = ship.rapidFire > 0 ? 5 : 15;
798
+ shootCooldown = cooldown;
799
+ if (sfxReady) sfx.play('Laser_Fire');
800
+ const offsets = ship.multiShot > 0 ? [-0.2, 0, 0.2] : [0];
801
+ offsets.forEach(function(offset) {
802
+ const angle = ship.angle + offset;
803
+ bullets.push({
804
+ x: ship.x + 20 * Math.cos(angle),
805
+ y: ship.y + 20 * Math.sin(angle),
806
+ vx: 10 * Math.cos(angle) + 0.5 * ship.vx,
807
+ vy: 10 * Math.sin(angle) + 0.5 * ship.vy,
808
+ life: 60
809
+ });
810
+ });
811
+ }
812
+
813
+ function saucerShoot(saucer) {
814
+ let angle = Math.atan2(ship.y - saucer.y, ship.x - saucer.x);
815
+ angle += (saucer.isSmall ? 0.2 : 0.5) * (Math.random() - 0.5);
816
+ saucerBullets.push({
817
+ x: saucer.x, y: saucer.y,
818
+ vx: 6 * Math.cos(angle), vy: 6 * Math.sin(angle),
819
+ life: 90
820
+ });
821
+ }
822
+
823
+ function spawnPowerUp(x, y) {
824
+ if (Math.random() > 0.15) return;
825
+ let type;
826
+ const rand = Math.random();
827
+ type = rand < 0.05 ? "life" : rand < 0.38 ? "shield" : rand < 0.71 ? "rapid" : "multi";
828
+ powerUps.push({ x, y, type, life: 600, pulse: 0 });
829
+ }
830
+
831
+ function updateUI() {
832
+ const scoreEl = document.getElementById("score");
833
+ if (score > MAX_SCORE) {
834
+ scoreEl.textContent = "99,999+";
835
+ scoreEl.className = "score-overflow";
836
+ scoreEl.title = score.toLocaleString();
837
+ } else {
838
+ scoreEl.textContent = formatScore(score);
839
+ scoreEl.className = "";
840
+ scoreEl.title = "";
841
+ }
842
+ document.getElementById("level").textContent = level;
843
+ document.getElementById("lives").textContent = lives;
844
+
845
+ // Player indicator for multiplayer
846
+ const piEl = document.getElementById("playerIndicator");
847
+ if (gameMode !== "single") {
848
+ piEl.style.display = "inline";
849
+ piEl.textContent = "P" + (currentPlayer + 1) + " ";
850
+ piEl.style.color = getActivePlayerColor();
851
+ } else {
852
+ piEl.style.display = "none";
853
+ }
854
+ }
855
+
856
+ function isShipInvincible() {
857
+ return ship.invincible > 0 || ship.shield > 0 || hyperspaceInvincible > 0;
858
+ }
859
+
860
+ // Gamepad polling is now handled by gamepad.poll() in the game loop
861
+ // Game-specific action functions (gpFire, gpHyper, etc.) are defined above
862
+
863
+ // --- Multiplayer Turn Management ---
864
+ var TURN_COOLDOWN_MS = 3000; // 3 second cooldown between players
865
+ var turnReadyAt = 0;
866
+
867
+ function showTurnReady() {
868
+ turnReady = true;
869
+ turnReadyAt = Date.now();
870
+ const overlay = document.getElementById("turnReadyOverlay");
871
+ const nameEl = document.getElementById("turnPlayerName");
872
+ const color = getActivePlayerColor();
873
+ nameEl.textContent = "PLAYER " + (currentPlayer + 1);
874
+ nameEl.style.color = color;
875
+ updateAllPrompts();
876
+ overlay.classList.add("visible");
877
+ }
878
+
879
+ function getTurnCooldownRemaining() {
880
+ return Math.max(0, TURN_COOLDOWN_MS - (Date.now() - turnReadyAt));
881
+ }
882
+
883
+ function updateTurnPrompt() {
884
+ var remaining = getTurnCooldownRemaining();
885
+ var promptEl = document.getElementById("turnPrompt");
886
+ if (remaining > 0) {
887
+ var secs = Math.ceil(remaining / 1000);
888
+ promptEl.textContent = "Get ready... " + secs;
889
+ } else {
890
+ promptEl.innerHTML = btnText(
891
+ "Press SPACE to start",
892
+ "Press " + gpBtn("a") + " to start"
893
+ );
894
+ }
895
+ }
896
+
897
+ function hideTurnReady() {
898
+ turnReady = false;
899
+ document.getElementById("turnReadyOverlay").classList.remove("visible");
900
+ }
901
+
902
+ function startPlayerTurn() {
903
+ hideTurnReady();
904
+ if (gameMode === "solo_turns") {
905
+ // Fresh game for each player
906
+ initGame();
907
+ score = 0;
908
+ lives = 3;
909
+ level = 1;
910
+ } else if (gameMode === "hot_seat") {
911
+ // Load this player's state into shared field
912
+ const p = players[currentPlayer];
913
+ score = p.score;
914
+ lives = p.lives;
915
+ initHotSeatRespawn();
916
+ }
917
+ gameRunning = true;
918
+ startAmbient();
919
+ updateUI();
920
+ updateBottomBar();
921
+ }
922
+
923
+ function saveCurrentPlayerState() {
924
+ if (gameMode === "single") return;
925
+ players[currentPlayer].score = score;
926
+ players[currentPlayer].lives = lives;
927
+ if (gameMode === "solo_turns") {
928
+ players[currentPlayer].level = level;
929
+ }
930
+ }
931
+
932
+ function advanceToNextPlayer() {
933
+ saveCurrentPlayerState();
934
+
935
+ // Find next alive player
936
+ let next = -1;
937
+ for (let i = 1; i <= playerCount; i++) {
938
+ const idx = (currentPlayer + i) % playerCount;
939
+ if (players[idx].alive) { next = idx; break; }
940
+ }
941
+
942
+ if (next === -1) {
943
+ // All players eliminated
944
+ showFinalScoreboard();
945
+ return;
946
+ }
947
+
948
+ currentPlayer = next;
949
+ gameRunning = false;
950
+ showTurnReady();
951
+ }
952
+
953
+ function handlePlayerDeath() {
954
+ if (gameMode === "single") {
955
+ gameOver();
956
+ return;
957
+ }
958
+ sfx.stop('Ship_Thrust');
959
+
960
+ if (gameMode === "solo_turns") {
961
+ // Player lost all lives they're done
962
+ lives--;
963
+ updateUI();
964
+ if (lives <= 0) {
965
+ saveCurrentPlayerState();
966
+ players[currentPlayer].alive = false;
967
+ gameRunning = false;
968
+ // Check HS for this player
969
+ if (qualifiesForHighScore(score)) {
970
+ hsEntryPlayerIndex = currentPlayer;
971
+ showHighScoreEntry();
972
+ } else {
973
+ advanceToNextPlayer();
974
+ }
975
+ } else {
976
+ // Respawn
977
+ ship.x = canvas.width / 2; ship.y = canvas.height / 2;
978
+ ship.vx = 0; ship.vy = 0; ship.invincible = 180;
979
+ }
980
+ } else if (gameMode === "hot_seat") {
981
+ // Lost one life — rotate to next player
982
+ lives--;
983
+ updateUI();
984
+ saveCurrentPlayerState();
985
+ if (lives <= 0) {
986
+ players[currentPlayer].alive = false;
987
+ // Check HS for this player
988
+ if (qualifiesForHighScore(score)) {
989
+ hsEntryPlayerIndex = currentPlayer;
990
+ gameRunning = false;
991
+ showHighScoreEntry();
992
+ return;
993
+ }
994
+ }
995
+ gameRunning = false;
996
+ advanceToNextPlayer();
997
+ }
998
+ }
999
+
1000
+ function showFinalScoreboard() {
1001
+ gameRunning = false;
1002
+ stopAmbient();
1003
+ sfx.stop('Ship_Thrust');
1004
+ // Sort players by score descending
1005
+ const ranked = players.map(function(p, i) { return { index: i, score: p.score, initials: p.initials }; });
1006
+ ranked.sort(function(a, b) { return b.score - a.score; });
1007
+
1008
+ const modeLabel = gameMode === "solo_turns" ? "SOLO TURNS" : "HOT SEAT";
1009
+ document.getElementById("sbSubtitle").textContent = modeLabel + " \u2014 FINAL STANDINGS";
1010
+
1011
+ const tbody = document.getElementById("sbBody");
1012
+ tbody.innerHTML = "";
1013
+ ranked.forEach(function(r, rank) {
1014
+ const tr = document.createElement("tr");
1015
+ const color = PLAYER_COLORS[r.index];
1016
+ const initStr = r.initials || "";
1017
+ tr.innerHTML =
1018
+ '<td class="sb-rank">' + (rank + 1) + '.</td>' +
1019
+ '<td class="sb-player" style="color:' + color + ';text-shadow:0 0 10px ' + color + '">P' + (r.index + 1) + '</td>' +
1020
+ '<td class="sb-initials">' + initStr + '</td>' +
1021
+ '<td class="sb-score">' + r.score.toLocaleString() + '</td>';
1022
+ tbody.appendChild(tr);
1023
+ });
1024
+
1025
+ document.getElementById("finalScoreboard").classList.add("visible");
1026
+ updateBottomBar();
1027
+ }
1028
+
1029
+ function hideFinalScoreboard() {
1030
+ document.getElementById("finalScoreboard").classList.remove("visible");
1031
+ }
1032
+
1033
+ // --- Start Screen UI Logic ---
1034
+ var menuCursor = 0; // index of focused item in current phase
1035
+
1036
+ // Items per phase: mode=[1P, PassPlay], submode=[Solo, HotSeat], count=N/A (uses arrows)
1037
+ function getMenuItems() {
1038
+ if (startScreenPhase === "mode") return [document.getElementById("btn1P"), document.getElementById("btnPassPlay")];
1039
+ if (startScreenPhase === "submode") return [document.getElementById("btnSoloTurns"), document.getElementById("btnHotSeat")];
1040
+ return [];
1041
+ }
1042
+
1043
+ function updateMenuFocus() {
1044
+ // Clear all focused classes
1045
+ var all = document.querySelectorAll(".mode-btn.focused, .submode-btn.focused");
1046
+ for (var i = 0; i < all.length; i++) all[i].classList.remove("focused");
1047
+ // Apply to current
1048
+ var items = getMenuItems();
1049
+ if (items.length > 0 && menuCursor >= 0 && menuCursor < items.length) {
1050
+ items[menuCursor].classList.add("focused");
1051
+ }
1052
+ }
1053
+
1054
+ function showStartPhase(phase) {
1055
+ startScreenPhase = phase;
1056
+ menuCursor = 0;
1057
+ var modeSelect = document.getElementById("modeSelect");
1058
+ var submodeSelect = document.getElementById("submodeSelect");
1059
+ var pcSelect = document.getElementById("playerCountSelect");
1060
+ modeSelect.style.display = phase === "mode" ? "flex" : "none";
1061
+ submodeSelect.style.display = phase === "submode" ? "flex" : "none";
1062
+ pcSelect.style.display = "none";
1063
+ pcSelect.classList.remove("visible");
1064
+ if (phase === "count") {
1065
+ pcSelect.style.display = "flex";
1066
+ pcSelect.classList.add("visible");
1067
+ }
1068
+ updateMenuFocus();
1069
+ updateBottomBar();
1070
+ }
1071
+
1072
+ function resetStartScreen() {
1073
+ var startScreen = document.getElementById("startScreen");
1074
+ startScreen.style.display = "";
1075
+ showStartPhase("mode");
1076
+ playerCount = 2;
1077
+ document.getElementById("pcNum").textContent = "2";
1078
+ document.getElementById("gameOverScreen").style.display = "none";
1079
+ hideFinalScoreboard();
1080
+ hideTurnReady();
1081
+ stopAmbient();
1082
+ sfx.stop('Ship_Thrust');
1083
+ }
1084
+
1085
+ function menuConfirm() {
1086
+ if (startScreenPhase === "mode") {
1087
+ if (menuCursor === 0) startSinglePlayer();
1088
+ else if (menuCursor === 1) showStartPhase("submode");
1089
+ } else if (startScreenPhase === "submode") {
1090
+ if (menuCursor === 0) selectSubmode("solo_turns");
1091
+ else if (menuCursor === 1) selectSubmode("hot_seat");
1092
+ } else if (startScreenPhase === "count") {
1093
+ confirmPlayerCount();
1094
+ }
1095
+ }
1096
+
1097
+ function menuBack() {
1098
+ if (startScreenPhase === "submode") {
1099
+ showStartPhase("mode");
1100
+ menuCursor = 1; // return focus to "Pass & Play"
1101
+ updateMenuFocus();
1102
+ } else if (startScreenPhase === "count") {
1103
+ showStartPhase("submode");
1104
+ }
1105
+ }
1106
+
1107
+ function menuMove(delta) {
1108
+ var items = getMenuItems();
1109
+ if (items.length === 0) return;
1110
+ var prev = menuCursor;
1111
+ menuCursor = Math.max(0, Math.min(items.length - 1, menuCursor + delta));
1112
+ if (menuCursor !== prev && sfxReady) sfx.play('Menu_Navigate');
1113
+ updateMenuFocus();
1114
+ }
1115
+
1116
+ function startSinglePlayer() {
1117
+ document.getElementById("startScreen").style.display = "none";
1118
+ gameMode = "single";
1119
+ initGame();
1120
+ gameRunning = true;
1121
+ startAmbient();
1122
+ updateBottomBar();
1123
+ }
1124
+
1125
+ function selectSubmode(mode) {
1126
+ gameMode = mode;
1127
+ showStartPhase("count");
1128
+ }
1129
+
1130
+ function adjustPlayerCount(delta) {
1131
+ playerCount = Math.max(2, Math.min(8, playerCount + delta));
1132
+ document.getElementById("pcNum").textContent = playerCount;
1133
+ }
1134
+
1135
+ function confirmPlayerCount() {
1136
+ document.getElementById("startScreen").style.display = "none";
1137
+ initMultiplayerPlayers();
1138
+ currentPlayer = 0;
1139
+
1140
+ if (gameMode === "hot_seat") {
1141
+ initGame();
1142
+ gameRunning = false;
1143
+ }
1144
+
1145
+ showTurnReady();
1146
+ }
1147
+
1148
+ // Start screen button events
1149
+ document.getElementById("btn1P").addEventListener("click", startSinglePlayer);
1150
+ document.getElementById("btnPassPlay").addEventListener("click", function() { showStartPhase("submode"); });
1151
+ document.getElementById("btnSoloTurns").addEventListener("click", function() { selectSubmode("solo_turns"); });
1152
+ document.getElementById("btnHotSeat").addEventListener("click", function() { selectSubmode("hot_seat"); });
1153
+ // Mouse hover syncs focus cursor
1154
+ document.getElementById("btn1P").addEventListener("mouseenter", function() { if (menuCursor !== 0 && sfxReady) sfx.play('Menu_Navigate'); menuCursor = 0; updateMenuFocus(); });
1155
+ document.getElementById("btnPassPlay").addEventListener("mouseenter", function() { if (menuCursor !== 1 && sfxReady) sfx.play('Menu_Navigate'); menuCursor = 1; updateMenuFocus(); });
1156
+ document.getElementById("btnSoloTurns").addEventListener("mouseenter", function() { if (menuCursor !== 0 && sfxReady) sfx.play('Menu_Navigate'); menuCursor = 0; updateMenuFocus(); });
1157
+ document.getElementById("btnHotSeat").addEventListener("mouseenter", function() { if (menuCursor !== 1 && sfxReady) sfx.play('Menu_Navigate'); menuCursor = 1; updateMenuFocus(); });
1158
+ document.getElementById("pcLeft").addEventListener("click", function() { adjustPlayerCount(-1); });
1159
+ document.getElementById("pcRight").addEventListener("click", function() { adjustPlayerCount(1); });
1160
+ document.getElementById("pcConfirm").addEventListener("click", confirmPlayerCount);
1161
+ document.getElementById("sbPlayAgain").addEventListener("click", function() { resetStartScreen(); });
1162
+
1163
+ function update() {
1164
+ // keyboard.poll() and gamepad.poll() are called in gameLoop() before this
1165
+
1166
+ // Gamepad pause toggle (edge-triggered, works even when paused)
1167
+ if (gpPause() && gameRunning) {
1168
+ gamePaused = !gamePaused;
1169
+ if (gamePaused) pauseOverlay.classList.add("visible");
1170
+ else pauseOverlay.classList.remove("visible");
1171
+ }
1172
+
1173
+ if (!gameRunning || gamePaused) return;
1174
+
1175
+ // Gamepad hyperspace (edge-triggered)
1176
+ if (gpHyper()) hyperspace();
1177
+
1178
+ // Ship controls (keyboard OR gamepad)
1179
+ if (keyboard.isDown("ArrowLeft") || keyboard.isDown("KeyA") || gamepad.left) ship.angle -= 0.08;
1180
+ if (keyboard.isDown("ArrowRight") || keyboard.isDown("KeyD") || gamepad.right) ship.angle += 0.08;
1181
+ if (keyboard.isDown("ArrowUp") || keyboard.isDown("KeyW") || gamepad.up) {
1182
+ ship.vx += 0.15 * Math.cos(ship.angle);
1183
+ ship.vy += 0.15 * Math.sin(ship.angle);
1184
+ ship.thrust = 1;
1185
+ if (sfxReady) sfx.play('Ship_Thrust');
1186
+ } else {
1187
+ if (ship.thrust > 0.1 && sfxReady) sfx.stop('Ship_Thrust');
1188
+ ship.thrust *= 0.95;
1189
+ }
1190
+ if (keyboard.isDown("Space") || gpFire()) shoot();
1191
+
1192
+ ship.vx *= 0.99;
1193
+ ship.vy *= 0.99;
1194
+ const speed = Math.sqrt(ship.vx ** 2 + ship.vy ** 2);
1195
+ if (speed > 8) {
1196
+ ship.vx = ship.vx / speed * 8;
1197
+ ship.vy = ship.vy / speed * 8;
1198
+ }
1199
+ ship.x += ship.vx;
1200
+ ship.y += ship.vy;
1201
+ wrap(ship);
1202
+
1203
+ if (ship.invincible > 0) ship.invincible--;
1204
+ if (ship.shield > 0) ship.shield--;
1205
+ if (ship.rapidFire > 0) ship.rapidFire--;
1206
+ if (ship.multiShot > 0) ship.multiShot--;
1207
+ if (shootCooldown > 0) shootCooldown--;
1208
+ if (hyperspaceCooldown > 0) hyperspaceCooldown--;
1209
+ if (hyperspaceInvincible > 0) hyperspaceInvincible--;
1210
+
1211
+ // Saucer spawning
1212
+ if (level >= 2) {
1213
+ saucerSpawnTimer++;
1214
+ const spawnInterval = Math.max(300, 600 - 30 * level);
1215
+ if (saucerSpawnTimer >= spawnInterval && saucers.length < 2) {
1216
+ saucers.push(createSaucer());
1217
+ if (sfxReady) sfx.play('Saucer_Alert');
1218
+ saucerSpawnTimer = 0;
1219
+ }
1220
+ }
1221
+
1222
+ // Update saucers
1223
+ saucers = saucers.filter(function(s) {
1224
+ s.x += s.vx; s.y += s.vy;
1225
+ s.directionChangeTimer--;
1226
+ if (s.directionChangeTimer <= 0) {
1227
+ s.vy = 2 * (Math.random() - 0.5);
1228
+ s.directionChangeTimer = 60 + 120 * Math.random();
1229
+ }
1230
+ if (s.y < 50) s.vy = Math.abs(s.vy);
1231
+ if (s.y > canvas.height - 50) s.vy = -Math.abs(s.vy);
1232
+ s.shootTimer--;
1233
+ if (s.shootTimer <= 0) {
1234
+ saucerShoot(s);
1235
+ s.shootTimer = s.isSmall ? 40 + 40 * Math.random() : 60 + 60 * Math.random();
1236
+ }
1237
+ return s.x > -50 && s.x < canvas.width + 50;
1238
+ });
1239
+
1240
+ // Update saucer bullets
1241
+ saucerBullets = saucerBullets.filter(function(b) {
1242
+ b.x += b.vx; b.y += b.vy; b.life--;
1243
+ return b.life > 0 && b.x > -10 && b.x < canvas.width + 10 && b.y > -10 && b.y < canvas.height + 10;
1244
+ });
1245
+
1246
+ // Update bullets
1247
+ bullets = bullets.filter(function(b) {
1248
+ b.x += b.vx; b.y += b.vy; b.life--;
1249
+ wrap(b);
1250
+ return b.life > 0;
1251
+ });
1252
+
1253
+ // Update asteroids
1254
+ asteroids.forEach(function(a) {
1255
+ a.x += a.vx; a.y += a.vy;
1256
+ a.rotation += a.rotationSpeed;
1257
+ wrap(a);
1258
+ });
1259
+
1260
+ // Update particles
1261
+ particles.update(1/60);
1262
+
1263
+ // Update power-ups
1264
+ powerUps = powerUps.filter(function(p) {
1265
+ p.life--; p.pulse += 0.1;
1266
+ return p.life > 0;
1267
+ });
1268
+
1269
+ // Update stars
1270
+ stars.forEach(function(s) {
1271
+ s.y += s.speed;
1272
+ if (s.y > canvas.height) { s.y = 0; s.x = Math.random() * canvas.width; }
1273
+ s.brightness = 0.3 + 0.3 * Math.sin(0.003 * Date.now() + s.x);
1274
+ });
1275
+
1276
+ // Bullet-asteroid collisions
1277
+ bullets.forEach(function(b, bi) {
1278
+ asteroids.forEach(function(a, ai) {
1279
+ const avgRadius = a.radii.reduce(function(sum, r) { return sum + r; }, 0) / a.radii.length;
1280
+ if (distance(b.x, b.y, a.x, a.y) < avgRadius) {
1281
+ bullets.splice(bi, 1);
1282
+ if (sfxReady) sfx.play('Asteroid_Explosion');
1283
+ createExplosion(a.x, a.y, a.color, 15);
1284
+ if (a.size > 1) {
1285
+ for (let i = 0; i < 2; i++) asteroids.push(createAsteroid(a.x, a.y, a.size - 1));
1286
+ }
1287
+ spawnPowerUp(a.x, a.y);
1288
+ asteroids.splice(ai, 1);
1289
+ addScore(100 * (4 - a.size));
1290
+ updateUI();
1291
+ }
1292
+ });
1293
+ });
1294
+
1295
+ // Bullet-saucer collisions
1296
+ bullets.forEach(function(b, bi) {
1297
+ saucers.forEach(function(s, si) {
1298
+ if (distance(b.x, b.y, s.x, s.y) < s.radius) {
1299
+ bullets.splice(bi, 1);
1300
+ createExplosion(s.x, s.y, "#ff6600", 25);
1301
+ addScore(s.points);
1302
+ saucers.splice(si, 1);
1303
+ updateUI();
1304
+ }
1305
+ });
1306
+ });
1307
+
1308
+ // Ship-asteroid collisions
1309
+ if (!isShipInvincible()) {
1310
+ asteroids.forEach(function(a, ai) {
1311
+ const avgRadius = a.radii.reduce(function(sum, r) { return sum + r; }, 0) / a.radii.length;
1312
+ if (distance(ship.x, ship.y, a.x, a.y) < avgRadius + ship.radius) {
1313
+ createExplosion(ship.x, ship.y, getActivePlayerColor(), 30);
1314
+ if (gameMode !== "single") {
1315
+ handlePlayerDeath();
1316
+ } else {
1317
+ lives--;
1318
+ updateUI();
1319
+ if (lives <= 0) { gameOver(); return; }
1320
+ ship.x = canvas.width / 2; ship.y = canvas.height / 2;
1321
+ ship.vx = 0; ship.vy = 0; ship.invincible = 180;
1322
+ }
1323
+ }
1324
+ });
1325
+ }
1326
+
1327
+ // Ship-saucer collisions
1328
+ if (!isShipInvincible()) {
1329
+ saucers.forEach(function(s, si) {
1330
+ if (distance(ship.x, ship.y, s.x, s.y) < s.radius + ship.radius) {
1331
+ createExplosion(ship.x, ship.y, getActivePlayerColor(), 30);
1332
+ createExplosion(s.x, s.y, "#ff6600", 25);
1333
+ saucers.splice(si, 1);
1334
+ if (gameMode !== "single") {
1335
+ handlePlayerDeath();
1336
+ } else {
1337
+ lives--;
1338
+ updateUI();
1339
+ if (lives <= 0) { gameOver(); return; }
1340
+ ship.x = canvas.width / 2; ship.y = canvas.height / 2;
1341
+ ship.vx = 0; ship.vy = 0; ship.invincible = 180;
1342
+ }
1343
+ }
1344
+ });
1345
+ }
1346
+
1347
+ // Ship-saucerBullet collisions
1348
+ if (!isShipInvincible()) {
1349
+ saucerBullets.forEach(function(b, bi) {
1350
+ if (distance(ship.x, ship.y, b.x, b.y) < ship.radius + 5) {
1351
+ createExplosion(ship.x, ship.y, getActivePlayerColor(), 30);
1352
+ saucerBullets.splice(bi, 1);
1353
+ if (gameMode !== "single") {
1354
+ handlePlayerDeath();
1355
+ } else {
1356
+ lives--;
1357
+ updateUI();
1358
+ if (lives <= 0) { gameOver(); return; }
1359
+ ship.x = canvas.width / 2; ship.y = canvas.height / 2;
1360
+ ship.vx = 0; ship.vy = 0; ship.invincible = 180;
1361
+ }
1362
+ }
1363
+ });
1364
+ }
1365
+
1366
+ // Power-up pickups
1367
+ powerUps.forEach(function(p, pi) {
1368
+ if (distance(ship.x, ship.y, p.x, p.y) < 30) {
1369
+ powerUps.splice(pi, 1);
1370
+ if (sfxReady) sfx.play('Power_Up_Collect');
1371
+ createExplosion(p.x, p.y, "#00ff00", 10);
1372
+ switch (p.type) {
1373
+ case "shield": ship.shield = 600; break;
1374
+ case "rapid": ship.rapidFire = 600; break;
1375
+ case "multi": ship.multiShot = 600; break;
1376
+ case "life": lives = Math.min(lives + 1, 5); updateUI(); break;
1377
+ }
1378
+ }
1379
+ });
1380
+
1381
+ // Next level
1382
+ if (asteroids.length === 0) {
1383
+ level++;
1384
+ updateUI();
1385
+ spawnAsteroids(3 + level);
1386
+ saucerSpawnTimer = 0;
1387
+ }
1388
+
1389
+ shake.update(1/60);
1390
+ }
1391
+
1392
+ function draw() {
1393
+ ctx.save();
1394
+ if (shake.isActive) ctx.translate(shake.offsetX, shake.offsetY);
1395
+ ctx.fillStyle = "#000";
1396
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
1397
+
1398
+ // Stars
1399
+ stars.forEach(function(s) {
1400
+ ctx.fillStyle = "rgba(255, 255, 255, " + s.brightness + ")";
1401
+ ctx.beginPath();
1402
+ ctx.arc(s.x, s.y, s.size, 0, 2 * Math.PI);
1403
+ ctx.fill();
1404
+ });
1405
+
1406
+ // Particles
1407
+ particles.draw(ctx);
1408
+
1409
+ // Power-ups
1410
+ powerUps.forEach(function(p) {
1411
+ const pulse = 5 * Math.sin(p.pulse);
1412
+ ctx.strokeStyle = p.type === "shield" ? "#00ffff" : p.type === "rapid" ? "#ff0000" : p.type === "multi" ? "#ffff00" : "#00ff00";
1413
+ ctx.lineWidth = 2;
1414
+ ctx.shadowColor = ctx.strokeStyle;
1415
+ ctx.shadowBlur = 15;
1416
+ ctx.beginPath();
1417
+ ctx.arc(p.x, p.y, 15 + pulse, 0, 2 * Math.PI);
1418
+ ctx.stroke();
1419
+ ctx.fillStyle = ctx.strokeStyle;
1420
+ ctx.font = "12px Arial";
1421
+ ctx.textAlign = "center";
1422
+ ctx.textBaseline = "middle";
1423
+ const icon = p.type === "shield" ? "S" : p.type === "rapid" ? "R" : p.type === "multi" ? "M" : "+";
1424
+ ctx.fillText(icon, p.x, p.y);
1425
+ ctx.shadowBlur = 0;
1426
+ });
1427
+
1428
+ // Asteroids
1429
+ asteroids.forEach(function(a) {
1430
+ ctx.save();
1431
+ ctx.translate(a.x, a.y);
1432
+ ctx.rotate(a.rotation);
1433
+ ctx.strokeStyle = a.color;
1434
+ ctx.lineWidth = 2;
1435
+ ctx.shadowColor = a.color;
1436
+ ctx.shadowBlur = 20;
1437
+ ctx.beginPath();
1438
+ for (let i = 0; i < a.points; i++) {
1439
+ const angle = i / a.points * Math.PI * 2;
1440
+ const r = a.radii[i];
1441
+ const x = Math.cos(angle) * r;
1442
+ const y = Math.sin(angle) * r;
1443
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
1444
+ }
1445
+ ctx.closePath();
1446
+ ctx.stroke();
1447
+ ctx.restore();
1448
+ });
1449
+
1450
+ // Saucers
1451
+ saucers.forEach(function(s) {
1452
+ ctx.save();
1453
+ ctx.translate(s.x, s.y);
1454
+ ctx.strokeStyle = "#ff6600";
1455
+ ctx.lineWidth = 2;
1456
+ ctx.shadowColor = "#ff6600";
1457
+ ctx.shadowBlur = 20;
1458
+ const r = s.radius;
1459
+ ctx.beginPath(); ctx.ellipse(0, -0.2 * r, 0.4 * r, 0.3 * r, 0, Math.PI, 0); ctx.stroke();
1460
+ ctx.beginPath(); ctx.ellipse(0, 0, r, 0.35 * r, 0, 0, 2 * Math.PI); ctx.stroke();
1461
+ ctx.beginPath(); ctx.ellipse(0, 0.15 * r, 0.5 * r, 0.2 * r, 0, 0, Math.PI); ctx.stroke();
1462
+ ctx.fillStyle = "#ff6600";
1463
+ for (let i = 0; i < 3; i++) {
1464
+ const lx = (i - 1) * r * 0.5;
1465
+ ctx.beginPath(); ctx.arc(lx, 0, 3, 0, 2 * Math.PI); ctx.fill();
1466
+ }
1467
+ ctx.restore();
1468
+ });
1469
+
1470
+ // Saucer bullets
1471
+ saucerBullets.forEach(function(b) {
1472
+ ctx.fillStyle = "#ff6600";
1473
+ ctx.shadowColor = "#ff6600";
1474
+ ctx.shadowBlur = 15;
1475
+ ctx.beginPath();
1476
+ ctx.arc(b.x, b.y, 4, 0, 2 * Math.PI);
1477
+ ctx.fill();
1478
+ });
1479
+
1480
+ // Player bullets — use active player color
1481
+ const bulletColor = getActivePlayerColor();
1482
+ bullets.forEach(function(b) {
1483
+ ctx.fillStyle = bulletColor;
1484
+ ctx.shadowColor = bulletColor;
1485
+ ctx.shadowBlur = 15;
1486
+ ctx.beginPath();
1487
+ ctx.arc(b.x, b.y, 3, 0, 2 * Math.PI);
1488
+ ctx.fill();
1489
+ ctx.strokeStyle = bulletColor.replace(")", ", 0.5)").replace("rgb", "rgba").replace("#", "");
1490
+ // Use hex alpha approach for trail
1491
+ ctx.globalAlpha = 0.5;
1492
+ ctx.strokeStyle = bulletColor;
1493
+ ctx.lineWidth = 2;
1494
+ ctx.beginPath();
1495
+ ctx.moveTo(b.x, b.y);
1496
+ ctx.lineTo(b.x - 2 * b.vx, b.y - 2 * b.vy);
1497
+ ctx.stroke();
1498
+ ctx.globalAlpha = 1;
1499
+ });
1500
+
1501
+ // Ship — use active player color
1502
+ const shipColor = getActivePlayerColor();
1503
+ if (gameRunning && (ship.invincible <= 0 || Math.floor(ship.invincible / 5) % 2 === 0)) {
1504
+ ctx.save();
1505
+ ctx.translate(ship.x, ship.y);
1506
+ ctx.rotate(ship.angle);
1507
+ if (ship.shield > 0) {
1508
+ ctx.strokeStyle = shipColor.replace("ff", "80"); // semi-transparent
1509
+ ctx.globalAlpha = 0.5;
1510
+ ctx.strokeStyle = shipColor;
1511
+ ctx.lineWidth = 2;
1512
+ ctx.shadowColor = shipColor;
1513
+ ctx.shadowBlur = 20;
1514
+ ctx.beginPath();
1515
+ ctx.arc(0, 0, 25, 0, 2 * Math.PI);
1516
+ ctx.stroke();
1517
+ ctx.globalAlpha = 1;
1518
+ }
1519
+ if (hyperspaceInvincible > 0) {
1520
+ ctx.strokeStyle = "rgba(255, 255, 255, " + (0.3 + 0.2 * Math.sin(hyperspaceInvincible * 0.3)) + ")";
1521
+ ctx.lineWidth = 1;
1522
+ ctx.shadowColor = "#ffffff";
1523
+ ctx.shadowBlur = 25;
1524
+ ctx.beginPath();
1525
+ ctx.arc(0, 0, 22, 0, 2 * Math.PI);
1526
+ ctx.stroke();
1527
+ }
1528
+ ctx.strokeStyle = shipColor;
1529
+ ctx.lineWidth = 2;
1530
+ ctx.shadowColor = shipColor;
1531
+ ctx.shadowBlur = 15;
1532
+ ctx.beginPath();
1533
+ ctx.moveTo(20, 0);
1534
+ ctx.lineTo(-15, -12);
1535
+ ctx.lineTo(-8, 0);
1536
+ ctx.lineTo(-15, 12);
1537
+ ctx.closePath();
1538
+ ctx.stroke();
1539
+ if (ship.thrust > 0.1) {
1540
+ ctx.strokeStyle = "#ff6600";
1541
+ ctx.shadowColor = "#ff6600";
1542
+ ctx.beginPath();
1543
+ ctx.moveTo(-8, -5);
1544
+ ctx.lineTo(-20 - 10 * Math.random() * ship.thrust, 0);
1545
+ ctx.lineTo(-8, 5);
1546
+ ctx.stroke();
1547
+ }
1548
+ ctx.restore();
1549
+ }
1550
+
1551
+ ctx.shadowBlur = 0;
1552
+ ctx.restore();
1553
+ }
1554
+
1555
+ // --- Game Over & High Score Entry ---
1556
+ function gameOver() {
1557
+ gameRunning = false;
1558
+ stopAmbient();
1559
+ sfx.stop('Ship_Thrust');
1560
+ if (qualifiesForHighScore(score)) {
1561
+ hsEntryPlayerIndex = -1;
1562
+ showHighScoreEntry();
1563
+ } else {
1564
+ showGameOverScreen();
1565
+ }
1566
+ }
1567
+
1568
+ function showGameOverScreen() {
1569
+ document.getElementById("finalScore").textContent = score.toLocaleString();
1570
+ document.getElementById("gameOverScreen").style.display = "block";
1571
+ updateAllPrompts();
1572
+ }
1573
+
1574
+ function showHighScoreEntry() {
1575
+ hsFinalScore = score;
1576
+ hsSlots = [0, 0, 0];
1577
+ hsActiveSlot = 0;
1578
+ hsEntryActive = true;
1579
+ document.getElementById("hsScore").textContent = score.toLocaleString();
1580
+ updateSlotDisplay();
1581
+ document.getElementById("highScoreEntry").style.display = "block";
1582
+ updateAllPrompts();
1583
+ }
1584
+
1585
+ function updateSlotDisplay() {
1586
+ for (let i = 0; i < 3; i++) {
1587
+ const el = document.getElementById("slot" + i);
1588
+ el.textContent = String.fromCharCode(65 + hsSlots[i]);
1589
+ el.className = "initial-slot" + (i === hsActiveSlot ? " active" : "");
1590
+ }
1591
+ }
1592
+
1593
+ function confirmHighScore() {
1594
+ const initials = String.fromCharCode(65 + hsSlots[0]) +
1595
+ String.fromCharCode(65 + hsSlots[1]) +
1596
+ String.fromCharCode(65 + hsSlots[2]);
1597
+ insertHighScore(initials, hsFinalScore);
1598
+ hsEntryActive = false;
1599
+ document.getElementById("highScoreEntry").style.display = "none";
1600
+
1601
+ // In multiplayer, store initials and advance
1602
+ if (hsEntryPlayerIndex >= 0 && gameMode !== "single") {
1603
+ players[hsEntryPlayerIndex].initials = initials;
1604
+ advanceToNextPlayer();
1605
+ } else {
1606
+ showGameOverScreen();
1607
+ }
1608
+ }
1609
+
1610
+ var _turnStartPressed = false;
1611
+
1612
+ function gameLoop() {
1613
+ keyboard.poll();
1614
+ gamepad.poll();
1615
+
1616
+ // --- Turn ready overlay: cooldown then wait for player input ---
1617
+ if (turnReady) {
1618
+ updateTurnPrompt();
1619
+ if (getTurnCooldownRemaining() <= 0) {
1620
+ if (gpConfirm() || _turnStartPressed) {
1621
+ _turnStartPressed = false;
1622
+ startPlayerTurn();
1623
+ }
1624
+ }
1625
+ // Still draw background
1626
+ draw();
1627
+ saveDirState();
1628
+ requestAnimationFrame(gameLoop);
1629
+ return;
1630
+ }
1631
+
1632
+ // Gamepad menu/HS entry controls (when game not running)
1633
+ if (hsEntryActive) {
1634
+ if (gpUp()) { hsSlots[hsActiveSlot] = (hsSlots[hsActiveSlot] + 1) % 26; updateSlotDisplay(); }
1635
+ if (gpDown()) { hsSlots[hsActiveSlot] = (hsSlots[hsActiveSlot] + 25) % 26; updateSlotDisplay(); }
1636
+ if (gpLeft()) { hsActiveSlot = Math.max(0, hsActiveSlot - 1); updateSlotDisplay(); }
1637
+ if (gpRight()) { hsActiveSlot = Math.min(2, hsActiveSlot + 1); updateSlotDisplay(); }
1638
+ // A = accept letter & advance, confirm on last slot
1639
+ if (gpConfirm()) {
1640
+ if (hsActiveSlot < 2) { hsActiveSlot++; updateSlotDisplay(); }
1641
+ else confirmHighScore();
1642
+ }
1643
+ // B = go back one slot
1644
+ if (gpBack()) {
1645
+ if (hsActiveSlot > 0) { hsActiveSlot--; updateSlotDisplay(); }
1646
+ }
1647
+ } else if (!gameRunning) {
1648
+ // Start screen navigation with gamepad (d-pad + A/B)
1649
+ if (document.getElementById("startScreen").style.display !== "none") {
1650
+ if (startScreenPhase === "mode" || startScreenPhase === "submode") {
1651
+ if (gpUp()) menuMove(-1);
1652
+ if (gpDown()) menuMove(1);
1653
+ if (gpConfirm()) menuConfirm();
1654
+ if (gpBack()) menuBack(); // B = back
1655
+ } else if (startScreenPhase === "count") {
1656
+ if (gpLeft()) adjustPlayerCount(-1);
1657
+ if (gpRight()) adjustPlayerCount(1);
1658
+ if (gpConfirm()) confirmPlayerCount();
1659
+ if (gpBack()) menuBack(); // B = back
1660
+ }
1661
+ }
1662
+ // Game over screen — restart
1663
+ const gameOverScreen = document.getElementById("gameOverScreen");
1664
+ if (gameOverScreen.style.display === "block") {
1665
+ if (gpConfirm() || gpPause()) {
1666
+ gameOverScreen.style.display = "none";
1667
+ resetStartScreen();
1668
+ }
1669
+ }
1670
+ // Final scoreboard play again
1671
+ if (document.getElementById("finalScoreboard").classList.contains("visible")) {
1672
+ if (gpConfirm() || gpPause()) {
1673
+ resetStartScreen();
1674
+ }
1675
+ }
1676
+ }
1677
+
1678
+ update();
1679
+ draw();
1680
+ saveDirState();
1681
+ requestAnimationFrame(gameLoop);
1682
+ }
1683
+
1684
+ // --- Input Handling (one-shot keyboard actions) ---
1685
+ // Continuous key state is handled by keyboard.isDown() via RetroGame.createKeyboard()
1686
+ document.addEventListener("keydown", function(e) {
1687
+ // Turn ready: SPACE to start
1688
+ if (turnReady) {
1689
+ if (e.code === "Space") {
1690
+ _turnStartPressed = true;
1691
+ e.preventDefault();
1692
+ }
1693
+ return;
1694
+ }
1695
+
1696
+ if (hsEntryActive) {
1697
+ if (e.code === "ArrowLeft") {
1698
+ hsActiveSlot = Math.max(0, hsActiveSlot - 1);
1699
+ updateSlotDisplay();
1700
+ } else if (e.code === "ArrowRight") {
1701
+ hsActiveSlot = Math.min(2, hsActiveSlot + 1);
1702
+ updateSlotDisplay();
1703
+ } else if (e.code === "ArrowUp") {
1704
+ hsSlots[hsActiveSlot] = (hsSlots[hsActiveSlot] + 1) % 26;
1705
+ updateSlotDisplay();
1706
+ } else if (e.code === "ArrowDown") {
1707
+ hsSlots[hsActiveSlot] = (hsSlots[hsActiveSlot] + 25) % 26;
1708
+ updateSlotDisplay();
1709
+ } else if (e.code === "Enter") {
1710
+ confirmHighScore();
1711
+ }
1712
+ e.preventDefault();
1713
+ return;
1714
+ }
1715
+
1716
+ // Start screen keyboard navigation
1717
+ if (!gameRunning && document.getElementById("startScreen").style.display !== "none") {
1718
+ if (startScreenPhase === "mode" || startScreenPhase === "submode") {
1719
+ if (e.code === "ArrowUp") { menuMove(-1); e.preventDefault(); }
1720
+ if (e.code === "ArrowDown") { menuMove(1); e.preventDefault(); }
1721
+ if (e.code === "Space" || e.code === "Enter") { menuConfirm(); e.preventDefault(); }
1722
+ if (e.code === "Escape" || e.code === "Backspace") { menuBack(); e.preventDefault(); }
1723
+ } else if (startScreenPhase === "count") {
1724
+ if (e.code === "ArrowLeft") { adjustPlayerCount(-1); e.preventDefault(); }
1725
+ if (e.code === "ArrowRight") { adjustPlayerCount(1); e.preventDefault(); }
1726
+ if (e.code === "Space" || e.code === "Enter") { confirmPlayerCount(); e.preventDefault(); }
1727
+ if (e.code === "Escape" || e.code === "Backspace") { menuBack(); e.preventDefault(); }
1728
+ }
1729
+ return;
1730
+ }
1731
+
1732
+ if (e.code === "KeyP" && gameRunning) {
1733
+ gamePaused = !gamePaused;
1734
+ if (gamePaused) {
1735
+ pauseOverlay.classList.add("visible");
1736
+ } else {
1737
+ pauseOverlay.classList.remove("visible");
1738
+ }
1739
+ }
1740
+
1741
+ if (e.code === "KeyH" && gameRunning && !gamePaused) {
1742
+ hyperspace();
1743
+ }
1744
+
1745
+ // Game over: space or enter to go back to start
1746
+ const gameOverScreen = document.getElementById("gameOverScreen");
1747
+ if (!gameRunning && gameOverScreen.style.display === "block" && (e.code === "Space" || e.code === "Enter")) {
1748
+ gameOverScreen.style.display = "none";
1749
+ resetStartScreen();
1750
+ e.preventDefault();
1751
+ return;
1752
+ }
1753
+
1754
+ // Final scoreboard: space or enter to go back to start
1755
+ if (!gameRunning && document.getElementById("finalScoreboard").classList.contains("visible") && (e.code === "Space" || e.code === "Enter")) {
1756
+ resetStartScreen();
1757
+ e.preventDefault();
1758
+ return;
1759
+ }
1760
+
1761
+ if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) {
1762
+ e.preventDefault();
1763
+ }
1764
+ });
1765
+
1766
+ document.getElementById("restartBtn").addEventListener("click", function() {
1767
+ document.getElementById("gameOverScreen").style.display = "none";
1768
+ resetStartScreen();
1769
+ });
1770
+
1771
+ // Initialize
1772
+ updateAllPrompts();
1773
+ initStars();
1774
+ gameLoop();
1775
+ </script>
1776
+
1777
+ </body></html>