@guinetik/gcanvas 1.0.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 (349) hide show
  1. package/.github/workflows/release.yaml +70 -0
  2. package/.jshintrc +4 -0
  3. package/.vscode/settings.json +22 -0
  4. package/CLAUDE.md +310 -0
  5. package/blackhole.jpg +0 -0
  6. package/demo.png +0 -0
  7. package/demos/CNAME +1 -0
  8. package/demos/animations.html +31 -0
  9. package/demos/basic.html +38 -0
  10. package/demos/baskara.html +31 -0
  11. package/demos/bezier.html +35 -0
  12. package/demos/beziersignature.html +29 -0
  13. package/demos/blackhole.html +28 -0
  14. package/demos/blob.html +35 -0
  15. package/demos/demos.css +289 -0
  16. package/demos/easing.html +28 -0
  17. package/demos/events.html +195 -0
  18. package/demos/fluent.html +647 -0
  19. package/demos/fractals.html +36 -0
  20. package/demos/genart.html +26 -0
  21. package/demos/gendream.html +26 -0
  22. package/demos/group.html +36 -0
  23. package/demos/home.html +587 -0
  24. package/demos/index.html +364 -0
  25. package/demos/isometric.html +34 -0
  26. package/demos/js/animations.js +452 -0
  27. package/demos/js/basic.js +204 -0
  28. package/demos/js/baskara.js +751 -0
  29. package/demos/js/bezier.js +692 -0
  30. package/demos/js/beziersignature.js +241 -0
  31. package/demos/js/blackhole/accretiondisk.obj.js +379 -0
  32. package/demos/js/blackhole/blackhole.obj.js +318 -0
  33. package/demos/js/blackhole/index.js +409 -0
  34. package/demos/js/blackhole/particle.js +56 -0
  35. package/demos/js/blackhole/starfield.obj.js +218 -0
  36. package/demos/js/blob.js +2263 -0
  37. package/demos/js/easing.js +477 -0
  38. package/demos/js/fluent.js +183 -0
  39. package/demos/js/fractals.js +931 -0
  40. package/demos/js/fractalworker.js +93 -0
  41. package/demos/js/genart.js +268 -0
  42. package/demos/js/gendream.js +209 -0
  43. package/demos/js/group.js +140 -0
  44. package/demos/js/info-toggle.js +25 -0
  45. package/demos/js/isometric.js +863 -0
  46. package/demos/js/kerr.js +1556 -0
  47. package/demos/js/lavalamp.js +590 -0
  48. package/demos/js/layout.js +354 -0
  49. package/demos/js/mondrian.js +285 -0
  50. package/demos/js/opacity.js +275 -0
  51. package/demos/js/painter.js +484 -0
  52. package/demos/js/particles-showcase.js +514 -0
  53. package/demos/js/particles.js +299 -0
  54. package/demos/js/patterns.js +397 -0
  55. package/demos/js/penrose/artifact.js +69 -0
  56. package/demos/js/penrose/blackhole.js +121 -0
  57. package/demos/js/penrose/constants.js +73 -0
  58. package/demos/js/penrose/game.js +943 -0
  59. package/demos/js/penrose/lore.js +278 -0
  60. package/demos/js/penrose/penrosescene.js +892 -0
  61. package/demos/js/penrose/ship.js +216 -0
  62. package/demos/js/penrose/sounds.js +211 -0
  63. package/demos/js/penrose/voidparticle.js +55 -0
  64. package/demos/js/penrose/voidscene.js +258 -0
  65. package/demos/js/penrose/voidship.js +144 -0
  66. package/demos/js/penrose/wormhole.js +46 -0
  67. package/demos/js/pipeline.js +555 -0
  68. package/demos/js/scene.js +304 -0
  69. package/demos/js/scenes.js +320 -0
  70. package/demos/js/schrodinger.js +410 -0
  71. package/demos/js/schwarzschild.js +1023 -0
  72. package/demos/js/shapes.js +628 -0
  73. package/demos/js/space/alien.js +171 -0
  74. package/demos/js/space/boom.js +98 -0
  75. package/demos/js/space/boss.js +353 -0
  76. package/demos/js/space/buff.js +73 -0
  77. package/demos/js/space/bullet.js +102 -0
  78. package/demos/js/space/constants.js +85 -0
  79. package/demos/js/space/game.js +1884 -0
  80. package/demos/js/space/hud.js +112 -0
  81. package/demos/js/space/laserbeam.js +179 -0
  82. package/demos/js/space/lightning.js +277 -0
  83. package/demos/js/space/minion.js +192 -0
  84. package/demos/js/space/missile.js +212 -0
  85. package/demos/js/space/player.js +430 -0
  86. package/demos/js/space/powerup.js +90 -0
  87. package/demos/js/space/starfield.js +58 -0
  88. package/demos/js/space/starpower.js +90 -0
  89. package/demos/js/spacetime.js +559 -0
  90. package/demos/js/svgtween.js +204 -0
  91. package/demos/js/tde/accretiondisk.js +418 -0
  92. package/demos/js/tde/blackhole.js +219 -0
  93. package/demos/js/tde/blackholescene.js +209 -0
  94. package/demos/js/tde/config.js +59 -0
  95. package/demos/js/tde/index.js +695 -0
  96. package/demos/js/tde/jets.js +290 -0
  97. package/demos/js/tde/lensedstarfield.js +147 -0
  98. package/demos/js/tde/tdestar.js +317 -0
  99. package/demos/js/tde/tidalstream.js +356 -0
  100. package/demos/js/tde_old/blackhole.obj.js +354 -0
  101. package/demos/js/tde_old/debris.obj.js +791 -0
  102. package/demos/js/tde_old/flare.obj.js +239 -0
  103. package/demos/js/tde_old/index.js +448 -0
  104. package/demos/js/tde_old/star.obj.js +812 -0
  105. package/demos/js/tiles.js +312 -0
  106. package/demos/js/tweendemo.js +79 -0
  107. package/demos/js/visibility.js +102 -0
  108. package/demos/kerr.html +28 -0
  109. package/demos/lavalamp.html +27 -0
  110. package/demos/layouts.html +37 -0
  111. package/demos/logo.svg +4 -0
  112. package/demos/loop.html +84 -0
  113. package/demos/mondrian.html +32 -0
  114. package/demos/og_image.png +0 -0
  115. package/demos/opacity.html +36 -0
  116. package/demos/painter.html +39 -0
  117. package/demos/particles-showcase.html +28 -0
  118. package/demos/particles.html +24 -0
  119. package/demos/patterns.html +33 -0
  120. package/demos/penrose-game.html +31 -0
  121. package/demos/pipeline.html +737 -0
  122. package/demos/scene.html +33 -0
  123. package/demos/scenes.html +96 -0
  124. package/demos/schrodinger.html +27 -0
  125. package/demos/schwarzschild.html +27 -0
  126. package/demos/shapes.html +16 -0
  127. package/demos/space.html +85 -0
  128. package/demos/spacetime.html +27 -0
  129. package/demos/svgtween.html +29 -0
  130. package/demos/tde.html +28 -0
  131. package/demos/tiles.html +28 -0
  132. package/demos/transforms.html +400 -0
  133. package/demos/tween.html +45 -0
  134. package/demos/visibility.html +33 -0
  135. package/disk_example.png +0 -0
  136. package/docs/README.md +222 -0
  137. package/docs/concepts/architecture-overview.md +204 -0
  138. package/docs/concepts/lifecycle.md +255 -0
  139. package/docs/concepts/rendering-pipeline.md +279 -0
  140. package/docs/concepts/tde-zorder.md +106 -0
  141. package/docs/concepts/two-layer-architecture.md +229 -0
  142. package/docs/getting-started/first-game.md +354 -0
  143. package/docs/getting-started/hello-world.md +269 -0
  144. package/docs/getting-started/installation.md +157 -0
  145. package/docs/modules/collision/README.md +453 -0
  146. package/docs/modules/fluent/README.md +1075 -0
  147. package/docs/modules/game/README.md +303 -0
  148. package/docs/modules/isometric-camera.md +210 -0
  149. package/docs/modules/isometric.md +275 -0
  150. package/docs/modules/painter/README.md +328 -0
  151. package/docs/modules/particle/README.md +559 -0
  152. package/docs/modules/shapes/README.md +221 -0
  153. package/docs/modules/shapes/base/euclidian.md +123 -0
  154. package/docs/modules/shapes/base/geometry2d.md +204 -0
  155. package/docs/modules/shapes/base/renderable.md +215 -0
  156. package/docs/modules/shapes/base/shape.md +262 -0
  157. package/docs/modules/shapes/base/transformable.md +243 -0
  158. package/docs/modules/shapes/hierarchy.md +218 -0
  159. package/docs/modules/state/README.md +577 -0
  160. package/docs/modules/util/README.md +99 -0
  161. package/docs/modules/util/camera3d.md +412 -0
  162. package/docs/modules/util/scene3d.md +395 -0
  163. package/index.html +17 -0
  164. package/jsdoc.json +50 -0
  165. package/package.json +55 -0
  166. package/readme.md +599 -0
  167. package/scripts/build-demo.js +69 -0
  168. package/scripts/bundle4llm.js +276 -0
  169. package/scripts/clearconsole.js +48 -0
  170. package/src/collision/collision-system.js +332 -0
  171. package/src/collision/collision.js +303 -0
  172. package/src/collision/index.js +10 -0
  173. package/src/fluent/fluent-game.js +430 -0
  174. package/src/fluent/fluent-go.js +1060 -0
  175. package/src/fluent/fluent-layer.js +152 -0
  176. package/src/fluent/fluent-scene.js +291 -0
  177. package/src/fluent/index.js +98 -0
  178. package/src/fluent/sketch.js +380 -0
  179. package/src/game/game.js +467 -0
  180. package/src/game/index.js +49 -0
  181. package/src/game/objects/go.js +220 -0
  182. package/src/game/objects/imagego.js +30 -0
  183. package/src/game/objects/index.js +54 -0
  184. package/src/game/objects/isometric-scene.js +260 -0
  185. package/src/game/objects/layoutscene.js +549 -0
  186. package/src/game/objects/scene.js +175 -0
  187. package/src/game/objects/scene3d.js +118 -0
  188. package/src/game/objects/text.js +221 -0
  189. package/src/game/objects/wrapper.js +232 -0
  190. package/src/game/pipeline.js +243 -0
  191. package/src/game/ui/button.js +396 -0
  192. package/src/game/ui/cursor.js +93 -0
  193. package/src/game/ui/fps.js +91 -0
  194. package/src/game/ui/index.js +5 -0
  195. package/src/game/ui/togglebutton.js +93 -0
  196. package/src/game/ui/tooltip.js +249 -0
  197. package/src/index.js +25 -0
  198. package/src/io/events.js +20 -0
  199. package/src/io/index.js +86 -0
  200. package/src/io/input.js +70 -0
  201. package/src/io/keys.js +152 -0
  202. package/src/io/mouse.js +61 -0
  203. package/src/io/touch.js +39 -0
  204. package/src/logger/debugtab.js +138 -0
  205. package/src/logger/index.js +3 -0
  206. package/src/logger/loggable.js +47 -0
  207. package/src/logger/logger.js +113 -0
  208. package/src/math/complex.js +37 -0
  209. package/src/math/constants.js +1 -0
  210. package/src/math/fractal.js +1271 -0
  211. package/src/math/gr.js +201 -0
  212. package/src/math/heat.js +202 -0
  213. package/src/math/index.js +12 -0
  214. package/src/math/noise.js +433 -0
  215. package/src/math/orbital.js +191 -0
  216. package/src/math/patterns.js +1339 -0
  217. package/src/math/penrose.js +259 -0
  218. package/src/math/quantum.js +115 -0
  219. package/src/math/random.js +195 -0
  220. package/src/math/tensor.js +1009 -0
  221. package/src/mixins/anchor.js +131 -0
  222. package/src/mixins/draggable.js +72 -0
  223. package/src/mixins/index.js +2 -0
  224. package/src/motion/bezier.js +132 -0
  225. package/src/motion/bounce.js +58 -0
  226. package/src/motion/easing.js +349 -0
  227. package/src/motion/float.js +130 -0
  228. package/src/motion/follow.js +125 -0
  229. package/src/motion/hop.js +52 -0
  230. package/src/motion/index.js +82 -0
  231. package/src/motion/motion.js +1124 -0
  232. package/src/motion/orbit.js +49 -0
  233. package/src/motion/oscillate.js +39 -0
  234. package/src/motion/parabolic.js +141 -0
  235. package/src/motion/patrol.js +147 -0
  236. package/src/motion/pendulum.js +48 -0
  237. package/src/motion/pulse.js +88 -0
  238. package/src/motion/shake.js +83 -0
  239. package/src/motion/spiral.js +144 -0
  240. package/src/motion/spring.js +150 -0
  241. package/src/motion/swing.js +47 -0
  242. package/src/motion/tween.js +92 -0
  243. package/src/motion/tweenetik.js +139 -0
  244. package/src/motion/waypoint.js +210 -0
  245. package/src/painter/index.js +8 -0
  246. package/src/painter/painter.colors.js +331 -0
  247. package/src/painter/painter.effects.js +230 -0
  248. package/src/painter/painter.img.js +229 -0
  249. package/src/painter/painter.js +295 -0
  250. package/src/painter/painter.lines.js +189 -0
  251. package/src/painter/painter.opacity.js +41 -0
  252. package/src/painter/painter.shapes.js +277 -0
  253. package/src/painter/painter.text.js +273 -0
  254. package/src/particle/emitter.js +124 -0
  255. package/src/particle/index.js +11 -0
  256. package/src/particle/particle-system.js +322 -0
  257. package/src/particle/particle.js +71 -0
  258. package/src/particle/updaters.js +170 -0
  259. package/src/shapes/arc.js +43 -0
  260. package/src/shapes/arrow.js +33 -0
  261. package/src/shapes/bezier.js +42 -0
  262. package/src/shapes/circle.js +62 -0
  263. package/src/shapes/clouds.js +56 -0
  264. package/src/shapes/cone.js +219 -0
  265. package/src/shapes/cross.js +70 -0
  266. package/src/shapes/cube.js +244 -0
  267. package/src/shapes/cylinder.js +254 -0
  268. package/src/shapes/diamond.js +48 -0
  269. package/src/shapes/euclidian.js +111 -0
  270. package/src/shapes/figure.js +115 -0
  271. package/src/shapes/geometry.js +220 -0
  272. package/src/shapes/group.js +375 -0
  273. package/src/shapes/heart.js +42 -0
  274. package/src/shapes/hexagon.js +26 -0
  275. package/src/shapes/image.js +192 -0
  276. package/src/shapes/index.js +111 -0
  277. package/src/shapes/line.js +29 -0
  278. package/src/shapes/pattern.js +90 -0
  279. package/src/shapes/pin.js +44 -0
  280. package/src/shapes/poly.js +31 -0
  281. package/src/shapes/prism.js +226 -0
  282. package/src/shapes/rect.js +35 -0
  283. package/src/shapes/renderable.js +333 -0
  284. package/src/shapes/ring.js +26 -0
  285. package/src/shapes/roundrect.js +95 -0
  286. package/src/shapes/shape.js +117 -0
  287. package/src/shapes/slice.js +26 -0
  288. package/src/shapes/sphere.js +314 -0
  289. package/src/shapes/sphere3d.js +537 -0
  290. package/src/shapes/square.js +15 -0
  291. package/src/shapes/star.js +99 -0
  292. package/src/shapes/svg.js +408 -0
  293. package/src/shapes/text.js +553 -0
  294. package/src/shapes/traceable.js +83 -0
  295. package/src/shapes/transform.js +357 -0
  296. package/src/shapes/transformable.js +172 -0
  297. package/src/shapes/triangle.js +26 -0
  298. package/src/sound/index.js +17 -0
  299. package/src/sound/sound.js +473 -0
  300. package/src/sound/synth.analyzer.js +149 -0
  301. package/src/sound/synth.effects.js +207 -0
  302. package/src/sound/synth.envelope.js +59 -0
  303. package/src/sound/synth.js +229 -0
  304. package/src/sound/synth.musical.js +160 -0
  305. package/src/sound/synth.noise.js +85 -0
  306. package/src/sound/synth.oscillators.js +293 -0
  307. package/src/state/index.js +10 -0
  308. package/src/state/state-machine.js +371 -0
  309. package/src/util/camera3d.js +438 -0
  310. package/src/util/index.js +6 -0
  311. package/src/util/isometric-camera.js +235 -0
  312. package/src/util/layout.js +317 -0
  313. package/src/util/position.js +147 -0
  314. package/src/util/tasks.js +47 -0
  315. package/src/util/zindex.js +287 -0
  316. package/src/webgl/index.js +9 -0
  317. package/src/webgl/shaders/sphere-shaders.js +994 -0
  318. package/src/webgl/webgl-renderer.js +388 -0
  319. package/tde.png +0 -0
  320. package/test/math/orbital.test.js +61 -0
  321. package/test/math/tensor.test.js +114 -0
  322. package/test/particle/emitter.test.js +204 -0
  323. package/test/particle/particle-system.test.js +310 -0
  324. package/test/particle/particle.test.js +116 -0
  325. package/test/particle/updaters.test.js +386 -0
  326. package/test/setup.js +120 -0
  327. package/test/shapes/euclidian.test.js +44 -0
  328. package/test/shapes/geometry.test.js +86 -0
  329. package/test/shapes/group.test.js +86 -0
  330. package/test/shapes/rectangle.test.js +64 -0
  331. package/test/shapes/transform.test.js +379 -0
  332. package/test/util/camera3d.test.js +428 -0
  333. package/test/util/scene3d.test.js +352 -0
  334. package/types/collision.d.ts +249 -0
  335. package/types/common.d.ts +155 -0
  336. package/types/game.d.ts +497 -0
  337. package/types/index.d.ts +309 -0
  338. package/types/io.d.ts +188 -0
  339. package/types/logger.d.ts +127 -0
  340. package/types/math.d.ts +268 -0
  341. package/types/mixins.d.ts +92 -0
  342. package/types/motion.d.ts +678 -0
  343. package/types/painter.d.ts +378 -0
  344. package/types/shapes.d.ts +864 -0
  345. package/types/sound.d.ts +672 -0
  346. package/types/state.d.ts +251 -0
  347. package/types/util.d.ts +253 -0
  348. package/vite.config.js +50 -0
  349. package/vitest.config.js +13 -0
@@ -0,0 +1,943 @@
1
+ /**
2
+ * Penrose Diagram Game
3
+ *
4
+ * A spacetime survival game played on a Penrose (conformal) diagram.
5
+ * Navigate through spacetime, dodge black holes, and watch your light cone!
6
+ *
7
+ * Physics:
8
+ * - Light always travels at 45deg in a Penrose diagram
9
+ * - Your worldline must be timelike (steeper than 45deg)
10
+ * - Cross an event horizon = all futures lead to singularity = death
11
+ */
12
+
13
+ import { Collision, Game, Keys, Painter, StateMachine } from "../../../src/index.js";
14
+ import { PenroseArtifact } from "./artifact.js";
15
+ import { PenroseBlackHole } from "./blackhole.js";
16
+ import { CONFIG } from "./constants.js";
17
+ import { PenroseScene } from "./penrosescene.js";
18
+ import { PenroseShip } from "./ship.js";
19
+ import { PenroseSounds } from "./sounds.js";
20
+ import { VoidScene } from "./voidscene.js";
21
+ import { PenroseWormhole } from "./wormhole.js";
22
+ import { LoreDisplay, LorePrism, LORE_CONFIG } from "./lore.js";
23
+
24
+ // ============================================================================
25
+ // PENROSE GAME
26
+ // ============================================================================
27
+
28
+ class PenroseGame extends Game {
29
+ constructor(canvas) {
30
+ super(canvas);
31
+ this.enableFluidSize();
32
+ this.backgroundColor = "#000008";
33
+
34
+ // State machine
35
+ this.fsm = null;
36
+
37
+ // Camera
38
+ this.viewCenter = { u: 0, v: 0 };
39
+ this.viewScale = 2.5;
40
+ this.targetViewScale = CONFIG.baseViewScale;
41
+ this.cameraRotation = 0;
42
+ this.targetCameraRotation = 0;
43
+
44
+ // Ship (Penrose coordinates)
45
+ this.ship = new PenroseShip(this);
46
+
47
+ // Black holes
48
+ this.blackHoles = [];
49
+ this.spawnTimer = 0;
50
+ this.currentSpawnRate = CONFIG.blackHoleSpawnRate;
51
+
52
+ // Score
53
+ this.timeSurvived = 0;
54
+ this.blackHolesDodged = 0;
55
+ this.kerrHarvests = 0;
56
+ this.score = 0;
57
+ this.scoreMultiplier = 1;
58
+ this.harvestingBlackHole = null;
59
+
60
+ // Kerr energy
61
+ this.kerrEnergy = 100;
62
+ this.isBoosting = false;
63
+
64
+ // Wormholes
65
+ this.wormholes = [];
66
+ this.wormholeSpawnTimer = 0;
67
+ this.wormholeUsedTimer = 0;
68
+ this.wormholesUsed = 0;
69
+
70
+ // Artifacts
71
+ this.artifacts = [];
72
+ this.artifactSpawnTimer = 0;
73
+ this.hasArtifact = false;
74
+ this.artifactCollectedTimer = 0;
75
+
76
+ // Lore prisms
77
+ this.lorePrisms = [];
78
+ this.loreSpawnTimer = 0;
79
+
80
+ // Intro
81
+ this.introProgress = 0;
82
+ this.introPhase = 0;
83
+
84
+ // Input tracking
85
+ this.spaceWasPressed = false;
86
+ this.spaceJustPressed = false;
87
+
88
+ // Timers
89
+ this.kerrCollectedTimer = 0;
90
+
91
+ // Lore system
92
+ this.lore = new LoreDisplay(this);
93
+
94
+ // Scenes
95
+ this.penroseScene = new PenroseScene(this);
96
+ this.voidScene = new VoidScene(this);
97
+
98
+ // Wire up scene callbacks
99
+ this.voidScene.onEscape = () => this.escapeVoid();
100
+ this.voidScene.onTimeout = () => {
101
+ this.hasArtifact = false;
102
+ this.fsm.setState("gameover");
103
+ };
104
+
105
+ // Initialize state machine
106
+ this.fsm = new StateMachine({
107
+ initial: "intro",
108
+ context: this,
109
+ states: {
110
+ intro: {
111
+ enter: this.onEnterIntro,
112
+ update: this.updateIntro,
113
+ },
114
+ playing: {
115
+ enter: this.onEnterPlaying,
116
+ update: this.updatePlaying,
117
+ },
118
+ dying: {
119
+ update: this.updateDying,
120
+ },
121
+ insideBlackHole: {
122
+ enter: this.onEnterVoid,
123
+ update: this.updateInsideBlackHole,
124
+ },
125
+ gameover: {},
126
+ },
127
+ });
128
+ }
129
+
130
+ get state() {
131
+ return this.fsm?.state || "intro";
132
+ }
133
+
134
+ // ============================================================================
135
+ // STATE CALLBACKS
136
+ // ============================================================================
137
+
138
+ onEnterIntro() {
139
+ this.introProgress = 0;
140
+ this.introPhase = 0;
141
+ }
142
+
143
+ onEnterPlaying() {
144
+ this.penroseScene.regenerateStars();
145
+ }
146
+
147
+ onEnterVoid() {
148
+ PenroseSounds.voidEnter();
149
+ this.voidScene.reset();
150
+ }
151
+
152
+ // ============================================================================
153
+ // GAME LIFECYCLE
154
+ // ============================================================================
155
+
156
+ reset() {
157
+ this.ship.reset();
158
+ this.blackHoles = [];
159
+ this.spawnTimer = 0;
160
+ this.currentSpawnRate = CONFIG.blackHoleSpawnRate;
161
+ this.timeSurvived = 0;
162
+ this.blackHolesDodged = 0;
163
+ this.kerrHarvests = 0;
164
+ this.score = 0;
165
+ this.scoreMultiplier = 1;
166
+ this.harvestingBlackHole = null;
167
+ this.kerrCollectedTimer = 0;
168
+ this.kerrEnergy = 100;
169
+ this.isBoosting = false;
170
+ this.wormholes = [];
171
+ this.wormholeSpawnTimer = 0;
172
+ this.wormholeUsedTimer = 0;
173
+ this.artifacts = [];
174
+ this.artifactSpawnTimer = 0;
175
+ this.hasArtifact = false;
176
+ this.artifactCollectedTimer = 0;
177
+ this.lorePrisms = [];
178
+ this.loreSpawnTimer = 0;
179
+ this.viewCenter = { u: 0, v: CONFIG.shipStartV + CONFIG.cameraLookAhead };
180
+ this.viewScale = CONFIG.baseViewScale;
181
+ this.cameraRotation = 0;
182
+ this.targetCameraRotation = 0;
183
+ this.fsm.setState("playing");
184
+
185
+ PenroseSounds.startEngine();
186
+ }
187
+
188
+ update(dt) {
189
+ super.update(dt);
190
+
191
+ // Space key detection
192
+ const spaceIsPressed = Keys.isDown(Keys.SPACE);
193
+ this.spaceJustPressed = spaceIsPressed && !this.spaceWasPressed;
194
+ this.spaceWasPressed = spaceIsPressed;
195
+
196
+ // State transitions via space
197
+ if (this.spaceJustPressed) {
198
+ PenroseSounds.init();
199
+
200
+ if (this.fsm.is("intro") && this.introPhase === 0) {
201
+ this.introPhase = 1;
202
+ this.introProgress = 0;
203
+ } else if (this.fsm.is("gameover")) {
204
+ this.reset();
205
+ }
206
+ }
207
+
208
+ // Update state machine
209
+ this.fsm.update(dt);
210
+
211
+ // Sync scene state
212
+ this.syncSceneState();
213
+ }
214
+
215
+ /**
216
+ * Sync game state to scene for rendering
217
+ */
218
+ syncSceneState() {
219
+ const ps = this.penroseScene;
220
+ ps.viewCenter = this.viewCenter;
221
+ ps.viewScale = this.viewScale;
222
+ ps.cameraRotation = this.cameraRotation;
223
+ ps.ship = this.ship;
224
+ ps.blackHoles = this.blackHoles;
225
+ ps.wormholes = this.wormholes;
226
+ ps.artifacts = this.artifacts;
227
+ ps.lorePrisms = this.lorePrisms;
228
+ ps.harvestingBlackHole = this.harvestingBlackHole;
229
+ ps.kerrCollectedTimer = this.kerrCollectedTimer;
230
+ ps.scoreMultiplier = this.scoreMultiplier;
231
+ ps.timeSurvived = this.timeSurvived;
232
+ ps.isIntro = this.fsm.is("intro");
233
+ ps.introPhase = this.introPhase;
234
+ ps.isBoosting = this.isBoosting;
235
+ }
236
+
237
+ // ============================================================================
238
+ // STATE UPDATES
239
+ // ============================================================================
240
+
241
+ updateIntro(dt) {
242
+ this.introProgress += dt;
243
+
244
+ if (this.introPhase === 0) {
245
+ this.viewScale = 2.5;
246
+ this.viewCenter.u = 0;
247
+ this.viewCenter.v = 0 + Math.sin(this.introProgress * 0.5) * 0.03;
248
+ } else {
249
+ const t = Math.min(this.introProgress / 2, 1);
250
+ const eased = 1 - Math.pow(1 - t, 3);
251
+
252
+ this.viewScale = 2.5 - (2.5 - CONFIG.baseViewScale) * eased;
253
+ const targetV = CONFIG.shipStartV + CONFIG.cameraLookAhead;
254
+ this.viewCenter.v = 0 + (targetV - 0) * eased;
255
+
256
+ if (t >= 1) {
257
+ this.fsm.setState("playing");
258
+ }
259
+ }
260
+ }
261
+
262
+ updatePlaying(dt) {
263
+ // Update ship
264
+ this.ship.update(dt);
265
+
266
+ // Boost check
267
+ const boostPressed = Keys.isDown(Keys.SHIFT) || Keys.isDown("w") || Keys.isDown("W");
268
+ this.isBoosting = boostPressed && this.kerrEnergy > 0;
269
+
270
+ if (this.isBoosting) {
271
+ if (!this._lastBoosting) PenroseSounds.boost();
272
+ this.kerrEnergy = Math.max(0, this.kerrEnergy - CONFIG.boostDrainRate * dt);
273
+ this.ship.boostMultiplier = CONFIG.boostSpeedMultiplier;
274
+
275
+ const leftPressed = Keys.isDown("a") || Keys.isDown("A") || Keys.isDown(Keys.LEFT);
276
+ const rightPressed = Keys.isDown("d") || Keys.isDown("D") || Keys.isDown(Keys.RIGHT);
277
+
278
+ if (leftPressed) {
279
+ this.ship.velocity -= CONFIG.shipSteering * CONFIG.boostSteeringMultiplier * dt;
280
+ }
281
+ if (rightPressed) {
282
+ this.ship.velocity += CONFIG.shipSteering * CONFIG.boostSteeringMultiplier * dt;
283
+ }
284
+ } else {
285
+ this.ship.boostMultiplier = 1;
286
+ }
287
+ this._lastBoosting = this.isBoosting;
288
+
289
+ // Update engine sound
290
+ PenroseSounds.updateEngine(this.ship.timeSpeed, this.isBoosting);
291
+
292
+ // Frame dragging
293
+ let totalFrameDrag = 0;
294
+ for (const bh of this.blackHoles) {
295
+ totalFrameDrag += bh.getFrameDragForce(this.ship.u, this.ship.v);
296
+ }
297
+
298
+ if (this.isBoosting) {
299
+ totalFrameDrag *= 0.3;
300
+ }
301
+
302
+ if (totalFrameDrag !== 0) {
303
+ this.ship.velocity += totalFrameDrag * dt * 60;
304
+ this.ship.inErgosphere = true;
305
+ } else {
306
+ this.ship.inErgosphere = false;
307
+ }
308
+
309
+ // Camera follow
310
+ this.viewCenter.u += (this.ship.u - this.viewCenter.u) * CONFIG.cameraLag;
311
+ this.viewCenter.v += (this.ship.v + CONFIG.cameraLookAhead - this.viewCenter.v) * CONFIG.cameraLag;
312
+ this.viewScale = CONFIG.baseViewScale + this.ship.timeSpeed * 0.5;
313
+
314
+ // No camera rotation - world stays stable, ship moves in heading direction
315
+ this.cameraRotation = 0;
316
+
317
+ // Timers
318
+ this.timeSurvived += dt;
319
+ this.spawnTimer += dt;
320
+
321
+ // Difficulty ramp
322
+ const difficultyT = Math.min(this.timeSurvived / CONFIG.difficultyRampTime, 1);
323
+ this.currentSpawnRate = CONFIG.blackHoleSpawnRate - (CONFIG.blackHoleSpawnRate - CONFIG.blackHoleMinSpawnRate) * difficultyT;
324
+
325
+ // Spawn black holes
326
+ if (this.spawnTimer >= this.currentSpawnRate) {
327
+ this.spawnBlackHole();
328
+ this.spawnTimer = 0;
329
+ }
330
+
331
+ // Score
332
+ this.score += dt * 100 * this.scoreMultiplier;
333
+
334
+ // Kerr timer
335
+ if (this.kerrCollectedTimer > 0) {
336
+ this.kerrCollectedTimer -= dt;
337
+ }
338
+
339
+ // Track harvesting
340
+ this.harvestingBlackHole = null;
341
+
342
+ // Update black holes and check collisions
343
+ const shipCircle = this.ship.getCircle();
344
+
345
+ for (const bh of this.blackHoles) {
346
+ bh.update(dt);
347
+
348
+ // Collision with event horizon
349
+ if (Collision.circleCircle(shipCircle, bh.getCircle())) {
350
+ this.ship.die(bh);
351
+ PenroseSounds.death();
352
+ this.fsm.setState("dying");
353
+ return;
354
+ }
355
+
356
+ // Kerr harvesting
357
+ if (!bh.harvested && bh.checkLightConeContact(this.ship.u, this.ship.v, CONFIG.coneLength)) {
358
+ bh.isBeingHarvested = true;
359
+ bh.harvestProgress += dt;
360
+ this.harvestingBlackHole = bh;
361
+
362
+ if (bh.harvestProgress >= CONFIG.kerrHarvestTime) {
363
+ bh.harvested = true;
364
+ this.kerrHarvests++;
365
+ this.scoreMultiplier *= CONFIG.kerrScoreMultiplier;
366
+ this.kerrCollectedTimer = 2.0;
367
+ this.kerrEnergy = Math.min(100, this.kerrEnergy + CONFIG.kerrEnergyPerHarvest);
368
+ PenroseSounds.kerrCollect();
369
+ }
370
+ }
371
+
372
+ // Dodged tracking
373
+ if (!bh.passed && this.ship.v > bh.v + bh.horizonRadius) {
374
+ bh.passed = true;
375
+ this.blackHolesDodged++;
376
+ }
377
+ }
378
+
379
+ // Cleanup black holes
380
+ this.blackHoles = this.blackHoles.filter((bh) => bh.v > this.ship.v - 1);
381
+
382
+ // Wormhole spawning
383
+ this.wormholeSpawnTimer += dt;
384
+ if (this.timeSurvived > CONFIG.wormholeMinTime && this.wormholeSpawnTimer >= CONFIG.wormholeSpawnInterval) {
385
+ this.wormholeSpawnTimer = 0;
386
+ if (Math.random() < CONFIG.wormholeSpawnChance) {
387
+ this.spawnWormhole();
388
+ }
389
+ }
390
+
391
+ // Update wormholes
392
+ for (const wh of this.wormholes) {
393
+ wh.update(dt);
394
+
395
+ if (wh.active && Collision.circleCircle(shipCircle, wh.getCircle())) {
396
+ wh.used = true;
397
+ PenroseSounds.wormholeEnter();
398
+ this.wormholeUsedTimer = 2.5;
399
+ this.wormholesUsed++;
400
+
401
+ this.ship.u = 0;
402
+ this.ship.v = CONFIG.shipStartV;
403
+ this.ship.velocity = 0;
404
+ this.ship.timeSpeed = CONFIG.shipTimeSpeed;
405
+ this.ship.worldline = [];
406
+
407
+ this.blackHoles = [];
408
+ this.spawnTimer = 0;
409
+
410
+ this.viewCenter = { u: 0, v: CONFIG.shipStartV + CONFIG.cameraLookAhead };
411
+ this.cameraRotation = 0;
412
+ this.targetCameraRotation = 0;
413
+
414
+ this.penroseScene.regenerateStars();
415
+ }
416
+ }
417
+
418
+ this.wormholes = this.wormholes.filter((wh) => !wh.used && wh.v > this.ship.v - 0.5);
419
+
420
+ if (this.wormholeUsedTimer > 0) {
421
+ this.wormholeUsedTimer -= dt;
422
+ }
423
+
424
+ // Artifact spawning
425
+ if (this.wormholesUsed > 0 && !this.hasArtifact) {
426
+ this.artifactSpawnTimer += dt;
427
+ if (this.artifactSpawnTimer >= CONFIG.artifactSpawnInterval) {
428
+ this.artifactSpawnTimer = 0;
429
+ if (Math.random() < CONFIG.artifactSpawnChance) {
430
+ this.spawnArtifact();
431
+ }
432
+ }
433
+ }
434
+
435
+ // Update artifacts
436
+ for (const art of this.artifacts) {
437
+ art.update(dt);
438
+
439
+ if (art.active && Collision.circleCircle(shipCircle, art.getCircle())) {
440
+ art.collected = true;
441
+ this.hasArtifact = true;
442
+ this.artifactCollectedTimer = 3.0;
443
+ PenroseSounds.artifactCollect();
444
+ }
445
+ }
446
+
447
+ this.artifacts = this.artifacts.filter((art) => !art.collected && art.v > this.ship.v - 0.5);
448
+
449
+ if (this.artifactCollectedTimer > 0) {
450
+ this.artifactCollectedTimer -= dt;
451
+ }
452
+
453
+ // Lore prism spawning
454
+ this.loreSpawnTimer += dt;
455
+ if (this.loreSpawnTimer >= LORE_CONFIG.spawnInterval) {
456
+ this.loreSpawnTimer = 0;
457
+ if (Math.random() < LORE_CONFIG.spawnChance) {
458
+ this.spawnLorePrism();
459
+ }
460
+ }
461
+
462
+ // Update lore prisms and check collision
463
+ for (const prism of this.lorePrisms) {
464
+ prism.update(dt);
465
+
466
+ if (prism.active && Collision.circleCircle(shipCircle, prism.getCircle())) {
467
+ prism.collected = true;
468
+ // Rewards
469
+ this.kerrEnergy = Math.min(100, this.kerrEnergy + LORE_CONFIG.kerrReward);
470
+ this.score += LORE_CONFIG.scoreReward;
471
+ // Show lore message
472
+ this.lore.show(prism.lore);
473
+ PenroseSounds.kerrCollect(); // Reuse the kerr sound
474
+ }
475
+ }
476
+
477
+ this.lorePrisms = this.lorePrisms.filter((p) => !p.collected && p.v > this.ship.v - 0.5);
478
+
479
+ // Win condition
480
+ if (this.ship.v >= 0.95) {
481
+ PenroseSounds.stopEngine();
482
+ this.fsm.setState("gameover");
483
+ }
484
+
485
+ // Update lore display
486
+ this.lore.update(dt);
487
+ }
488
+
489
+ updateDying(dt) {
490
+ this.ship.update(dt);
491
+
492
+ if (this.ship.deathFade >= 1) {
493
+ if (this.hasArtifact) {
494
+ this.fsm.setState("insideBlackHole");
495
+ } else {
496
+ this.fsm.setState("gameover");
497
+ }
498
+ }
499
+ }
500
+
501
+ updateInsideBlackHole(dt) {
502
+ this.voidScene.update(dt);
503
+ }
504
+
505
+ escapeVoid() {
506
+ PenroseSounds.voidEscape();
507
+ this.hasArtifact = false;
508
+ this.ship.alive = true;
509
+ this.ship.u = 0;
510
+ this.ship.v = CONFIG.shipStartV;
511
+ this.ship.velocity = 0;
512
+ this.ship.timeSpeed = CONFIG.shipTimeSpeed;
513
+ this.ship.worldline = [];
514
+ this.ship.deathFade = 0;
515
+ this.ship.deathProgress = 0;
516
+
517
+ this.blackHoles = [];
518
+ this.spawnTimer = 0;
519
+
520
+ this.viewCenter = { u: 0, v: CONFIG.shipStartV + CONFIG.cameraLookAhead };
521
+ this.cameraRotation = 0;
522
+ this.targetCameraRotation = 0;
523
+
524
+ this.penroseScene.regenerateStars();
525
+ this.fsm.setState("playing");
526
+ }
527
+
528
+ // ============================================================================
529
+ // SPAWNING
530
+ // ============================================================================
531
+
532
+ spawnBlackHole() {
533
+ const aheadV = this.ship.v + CONFIG.blackHoleSpawnAhead;
534
+ const maxU = Math.max(0, 1 - Math.abs(aheadV)) - 0.1;
535
+ if (maxU <= 0.1) return;
536
+
537
+ const nearbyHoles = this.blackHoles.filter(
538
+ (bh) => Math.abs(bh.v - aheadV) < CONFIG.blackHoleSpawnAhead * 1.5
539
+ );
540
+
541
+ const sortedU = nearbyHoles
542
+ .map((bh) => ({ u: bh.u, r: bh.horizonRadius }))
543
+ .sort((a, b) => a.u - b.u);
544
+
545
+ let spawnU = null;
546
+ const minGap = CONFIG.blackHoleMinGap;
547
+ const spawnSpread = 0.25;
548
+ const shipBias = this.ship.u + this.ship.velocity * 0.3;
549
+
550
+ if (sortedU.length === 0) {
551
+ const offset = (Math.random() - 0.5) * spawnSpread * 2;
552
+ spawnU = shipBias + offset;
553
+ spawnU = Math.max(-maxU, Math.min(maxU, spawnU));
554
+ } else {
555
+ const gaps = [];
556
+
557
+ const firstHole = sortedU[0];
558
+ if (firstHole.u - firstHole.r - -maxU > minGap) {
559
+ gaps.push({ start: -maxU, end: firstHole.u - firstHole.r - minGap / 2 });
560
+ }
561
+
562
+ for (let i = 0; i < sortedU.length - 1; i++) {
563
+ const left = sortedU[i];
564
+ const right = sortedU[i + 1];
565
+ const gapStart = left.u + left.r + minGap / 2;
566
+ const gapEnd = right.u - right.r - minGap / 2;
567
+ if (gapEnd - gapStart > minGap) {
568
+ gaps.push({ start: gapStart, end: gapEnd });
569
+ }
570
+ }
571
+
572
+ const lastHole = sortedU[sortedU.length - 1];
573
+ if (maxU - (lastHole.u + lastHole.r) > minGap) {
574
+ gaps.push({ start: lastHole.u + lastHole.r + minGap / 2, end: maxU });
575
+ }
576
+
577
+ if (gaps.length > 0) {
578
+ const scoredGaps = gaps
579
+ .map((gap) => {
580
+ const gapCenter = (gap.start + gap.end) / 2;
581
+ const distFromShip = Math.abs(gapCenter - shipBias);
582
+ return { gap, gapCenter, score: 1 / (distFromShip + 0.1) };
583
+ })
584
+ .sort((a, b) => b.score - a.score);
585
+
586
+ const pickIndex = Math.random() < 0.7 ? 0 : Math.floor(Math.random() * scoredGaps.length);
587
+ const chosen = scoredGaps[pickIndex];
588
+ const gapHalfWidth = (chosen.gap.end - chosen.gap.start) / 2;
589
+
590
+ if (gapHalfWidth > minGap) {
591
+ spawnU = chosen.gapCenter + (Math.random() - 0.5) * gapHalfWidth * 0.5;
592
+ }
593
+ }
594
+ }
595
+
596
+ if (spawnU === null) return;
597
+
598
+ const difficultyT = Math.min(this.timeSurvived / CONFIG.difficultyRampTime, 1);
599
+ const mass = 0.3 + Math.random() * 0.4 * difficultyT;
600
+
601
+ this.blackHoles.push(new PenroseBlackHole(spawnU, aheadV, mass));
602
+ }
603
+
604
+ spawnWormhole() {
605
+ const aheadV = this.ship.v + CONFIG.blackHoleSpawnAhead * 3;
606
+ const maxU = Math.max(0, 1 - Math.abs(aheadV)) - 0.1;
607
+ if (maxU <= 0.1) return;
608
+
609
+ const predictedU = this.ship.u + this.ship.velocity * 0.15;
610
+ const spawnU = Math.max(-maxU, Math.min(maxU, predictedU));
611
+
612
+ this.wormholes.push(new PenroseWormhole(spawnU, aheadV));
613
+ PenroseSounds.wormholeSpawn();
614
+ }
615
+
616
+ spawnArtifact() {
617
+ const aheadV = this.ship.v + CONFIG.blackHoleSpawnAhead * 2.5;
618
+ const maxU = Math.max(0, 1 - Math.abs(aheadV)) - 0.1;
619
+ if (maxU <= 0.1) return;
620
+
621
+ const predictedU = this.ship.u + this.ship.velocity * 0.1;
622
+ const spawnU = Math.max(-maxU, Math.min(maxU, predictedU));
623
+
624
+ this.artifacts.push(new PenroseArtifact(spawnU, aheadV));
625
+ PenroseSounds.artifactSpawn();
626
+ }
627
+
628
+ spawnLorePrism() {
629
+ const aheadV = this.ship.v + CONFIG.blackHoleSpawnAhead * 1.5;
630
+ const maxU = Math.max(0, 1 - Math.abs(aheadV)) - 0.1;
631
+ if (maxU <= 0.1) return;
632
+
633
+ // Spawn with some randomness
634
+ const spawnU = (Math.random() - 0.5) * maxU * 1.5;
635
+
636
+ // Select lore pool based on player progress
637
+ let pool = "early";
638
+ if (this.wormholesUsed > 0) {
639
+ pool = Math.random() < 0.5 ? "post_wormhole" : "early";
640
+ } else {
641
+ // Weight toward wormhole hints
642
+ pool = Math.random() < 0.6 ? "wormhole" : "early";
643
+ }
644
+ if (this.kerrHarvests > 0 && Math.random() < 0.3) {
645
+ pool = "kerr";
646
+ }
647
+
648
+ this.lorePrisms.push(new LorePrism(spawnU, aheadV, pool));
649
+ }
650
+
651
+ // ============================================================================
652
+ // RENDERING
653
+ // ============================================================================
654
+
655
+ render() {
656
+ super.render();
657
+ const ctx = Painter.ctx;
658
+
659
+ if (this.fsm.is("insideBlackHole")) {
660
+ this.voidScene.render();
661
+ return;
662
+ }
663
+
664
+ // Render Penrose scene
665
+ this.penroseScene.render();
666
+
667
+ // UI overlay (not rotated)
668
+ this.drawUI(ctx);
669
+ }
670
+
671
+ drawUI(ctx) {
672
+ ctx.font = "16px monospace";
673
+ ctx.textAlign = "left";
674
+
675
+ if (this.fsm.is("intro")) {
676
+ this.drawIntroUI(ctx);
677
+ } else if (this.fsm.is("playing")) {
678
+ this.drawPlayingUI(ctx);
679
+ } else if (this.fsm.is("dying")) {
680
+ this.drawDyingUI(ctx);
681
+ } else if (this.fsm.is("gameover")) {
682
+ this.drawGameOverUI(ctx);
683
+ }
684
+ }
685
+
686
+ drawIntroUI(ctx) {
687
+ ctx.fillStyle = "#fff";
688
+ ctx.textAlign = "center";
689
+ ctx.font = "24px monospace";
690
+ ctx.fillText("PENROSE DIAGRAM", this.width / 2, 50);
691
+
692
+ ctx.font = "14px monospace";
693
+ ctx.fillStyle = "#aaa";
694
+
695
+ if (this.introPhase === 0) {
696
+ const lines = [
697
+ "Navigate through spacetime",
698
+ "",
699
+ "Your LIGHT CONE shows possible futures (45 degrees)",
700
+ "Cross an EVENT HORIZON = all paths lead to SINGULARITY",
701
+ "",
702
+ "Controls: A/D or Arrow Keys to steer",
703
+ "",
704
+ "Press SPACE to begin",
705
+ ];
706
+
707
+ lines.forEach((line, i) => {
708
+ ctx.fillText(line, this.width / 2, this.height / 2 + i * 24 - 80);
709
+ });
710
+ }
711
+ }
712
+
713
+ drawPlayingUI(ctx) {
714
+ // Score
715
+ ctx.textAlign = "left";
716
+ ctx.font = "bold 28px monospace";
717
+ ctx.fillStyle = "#0f0";
718
+ ctx.fillText(`${Math.floor(this.score)}`, 20, this.height - 80);
719
+
720
+ ctx.font = "12px monospace";
721
+ ctx.fillStyle = "#8f8";
722
+ ctx.fillText(`x${this.scoreMultiplier} multiplier`, 20, this.height - 55);
723
+
724
+ if (this.kerrHarvests > 0) {
725
+ ctx.fillStyle = "#5ff";
726
+ ctx.fillText(`KERR x${this.kerrHarvests}`, 20, this.height - 38);
727
+ }
728
+
729
+ // Artifact indicator
730
+ if (this.hasArtifact) {
731
+ const pulse = 0.7 + Math.sin(Date.now() / 200) * 0.3;
732
+ ctx.fillStyle = `rgba(200, 100, 255, ${pulse})`;
733
+ ctx.font = "bold 14px monospace";
734
+ ctx.fillText("ARTIFACT", 20, this.height - 100);
735
+ ctx.font = "10px monospace";
736
+ ctx.fillStyle = "#a0a";
737
+ ctx.fillText("Survive singularity!", 20, this.height - 85);
738
+ }
739
+
740
+ // Artifact collected message
741
+ if (this.artifactCollectedTimer > 0) {
742
+ const alpha = Math.min(1, this.artifactCollectedTimer / 0.5);
743
+ const scale = 1 + (3 - this.artifactCollectedTimer) * 0.03;
744
+ ctx.save();
745
+ ctx.translate(this.width / 2, this.height / 2 - 60);
746
+ ctx.scale(scale, scale);
747
+
748
+ ctx.fillStyle = `rgba(100, 50, 150, ${alpha * 0.4})`;
749
+ ctx.fillRect(-160, -30, 320, 80);
750
+
751
+ this.drawOutlinedText(ctx, "ARTIFACT ACQUIRED!", 0, 0, `rgba(200, 150, 255, ${alpha})`, `rgba(20, 0, 40, ${alpha})`, "bold 22px monospace");
752
+ this.drawOutlinedText(ctx, "You can now survive the singularity!", 0, 28, `rgba(150, 100, 200, ${alpha * 0.9})`, `rgba(20, 0, 40, ${alpha})`, "12px monospace");
753
+ ctx.restore();
754
+ }
755
+
756
+ // Stats
757
+ ctx.fillStyle = "#888";
758
+ ctx.font = "12px monospace";
759
+ ctx.fillText(`Time: ${this.timeSurvived.toFixed(1)}s | Dodged: ${this.blackHolesDodged}`, 20, this.height - 15);
760
+
761
+ // Kerr energy bar
762
+ const barWidth = 120;
763
+ const barHeight = 16;
764
+ const barX = this.width - barWidth - 20;
765
+ const barY = this.height - 35;
766
+
767
+ ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
768
+ ctx.fillRect(barX - 2, barY - 2, barWidth + 4, barHeight + 4);
769
+
770
+ const energyWidth = (this.kerrEnergy / 100) * barWidth;
771
+ ctx.fillStyle = this.isBoosting ? "#ff0" : "#0ff";
772
+ ctx.fillRect(barX, barY, energyWidth, barHeight);
773
+
774
+ ctx.strokeStyle = this.isBoosting ? "#ff0" : "#088";
775
+ ctx.lineWidth = 2;
776
+ ctx.strokeRect(barX, barY, barWidth, barHeight);
777
+
778
+ ctx.fillStyle = this.kerrEnergy > 0 ? "#fff" : "#666";
779
+ ctx.font = "10px monospace";
780
+ ctx.textAlign = "center";
781
+ ctx.fillText("KERR ENERGY [W]", barX + barWidth / 2, barY - 6);
782
+
783
+ if (this.isBoosting) {
784
+ const boostPulse = 0.7 + Math.sin(Date.now() / 50) * 0.3;
785
+ ctx.fillStyle = `rgba(255, 255, 0, ${boostPulse})`;
786
+ ctx.font = "bold 12px monospace";
787
+ ctx.fillText("BOOSTING!", barX + barWidth / 2, barY + barHeight + 14);
788
+ }
789
+
790
+ // Wormhole message
791
+ if (this.wormholeUsedTimer > 0) {
792
+ const alpha = Math.min(1, this.wormholeUsedTimer / 0.5);
793
+ const scale = 1 + (2.5 - this.wormholeUsedTimer) * 0.05;
794
+ ctx.save();
795
+ ctx.translate(this.width / 2, this.height / 2 - 80);
796
+ ctx.scale(scale, scale);
797
+
798
+ ctx.fillStyle = `rgba(100, 50, 200, ${alpha * 0.3})`;
799
+ ctx.fillRect(-180, -25, 360, 70);
800
+
801
+ this.drawOutlinedText(ctx, "WORMHOLE ACTIVATED!", 0, 0, `rgba(150, 200, 255, ${alpha})`, `rgba(0, 0, 50, ${alpha})`, "bold 24px monospace");
802
+ this.drawOutlinedText(ctx, "Teleported to past infinity!", 0, 28, `rgba(200, 150, 255, ${alpha * 0.8})`, `rgba(0, 0, 50, ${alpha})`, "14px monospace");
803
+ ctx.restore();
804
+ }
805
+
806
+ // Lore display - position at light cone tip
807
+ if (this.ship && this.ship.alive) {
808
+ const shipPos = this.penroseScene.penroseToScreen(this.ship.u, this.ship.v);
809
+ const scale = Math.min(this.width, this.height) / this.viewScale;
810
+ const coneLength = CONFIG.coneLength * scale;
811
+
812
+ // Position at tip of light cone (follows heading direction)
813
+ const tipX = shipPos.x + Math.sin(this.ship.heading) * (coneLength + 60);
814
+ const tipY = shipPos.y - Math.cos(this.ship.heading) * (coneLength + 60);
815
+
816
+ this.lore.render(ctx, tipX, tipY);
817
+ } else {
818
+ this.lore.render(ctx);
819
+ }
820
+ }
821
+
822
+ drawDyingUI(ctx) {
823
+ const textAlpha = Math.min(1, this.ship.deathProgress * 0.5);
824
+ ctx.textAlign = "center";
825
+
826
+ ctx.fillStyle = `rgba(255, 50, 50, ${textAlpha})`;
827
+ ctx.font = "32px monospace";
828
+ ctx.fillText("SINGULARITY", this.width / 2, this.height / 2 - 40);
829
+
830
+ ctx.font = "16px monospace";
831
+ ctx.fillStyle = `rgba(255, 136, 136, ${textAlpha})`;
832
+ ctx.fillText("All futures lead here...", this.width / 2, this.height / 2);
833
+
834
+ if (this.ship.deathFade > 0.3) {
835
+ const scoreAlpha = (this.ship.deathFade - 0.3) / 0.7;
836
+ ctx.font = "18px monospace";
837
+ ctx.fillStyle = `rgba(100, 255, 100, ${scoreAlpha})`;
838
+ ctx.fillText(`Time: ${this.timeSurvived.toFixed(1)}s`, this.width / 2, this.height / 2 + 50);
839
+ ctx.fillText(`Escaped: ${this.blackHolesDodged}`, this.width / 2, this.height / 2 + 75);
840
+ }
841
+ }
842
+
843
+ drawGameOverUI(ctx) {
844
+ ctx.fillStyle = "#fff";
845
+ ctx.textAlign = "center";
846
+ ctx.font = "32px monospace";
847
+ ctx.fillText("GAME OVER", this.width / 2, this.height / 2 - 100);
848
+
849
+ ctx.font = "bold 36px monospace";
850
+ ctx.fillStyle = "#0f0";
851
+ ctx.fillText(`${Math.floor(this.score)}`, this.width / 2, this.height / 2 - 40);
852
+
853
+ ctx.font = "14px monospace";
854
+ ctx.fillStyle = "#8f8";
855
+ ctx.fillText("SCORE", this.width / 2, this.height / 2 - 60);
856
+
857
+ ctx.font = "16px monospace";
858
+ ctx.fillStyle = "#aaa";
859
+ ctx.fillText(`Time: ${this.timeSurvived.toFixed(1)}s`, this.width / 2, this.height / 2 + 10);
860
+ ctx.fillText(`Black Holes Dodged: ${this.blackHolesDodged}`, this.width / 2, this.height / 2 + 35);
861
+
862
+ if (this.kerrHarvests > 0) {
863
+ ctx.fillStyle = "#5ff";
864
+ ctx.fillText(`Kerr Energy Harvested: ${this.kerrHarvests}`, this.width / 2, this.height / 2 + 60);
865
+ ctx.fillStyle = "#fa0";
866
+ ctx.fillText(`Score Multiplier: x${this.scoreMultiplier}`, this.width / 2, this.height / 2 + 85);
867
+ }
868
+
869
+ ctx.font = "14px monospace";
870
+ ctx.fillStyle = "#888";
871
+ ctx.fillText("Press SPACE to try again", this.width / 2, this.height / 2 + 130);
872
+ }
873
+
874
+ drawOutlinedText(ctx, text, x, y, fillColor, strokeColor, font) {
875
+ ctx.save();
876
+ ctx.font = font;
877
+ ctx.textAlign = "center";
878
+ ctx.textBaseline = "middle";
879
+ ctx.lineWidth = 4;
880
+ ctx.strokeStyle = strokeColor;
881
+ ctx.fillStyle = fillColor;
882
+ ctx.strokeText(text, x, y);
883
+ ctx.fillText(text, x, y);
884
+ ctx.restore();
885
+ }
886
+ }
887
+
888
+ // ============================================================================
889
+ // INITIALIZE
890
+ // ============================================================================
891
+
892
+ const canvas = document.getElementById("game");
893
+ const game = new PenroseGame(canvas);
894
+ game.start();
895
+
896
+ // ============================================================================
897
+ // DEBUG HELPERS
898
+ // ============================================================================
899
+
900
+ window.game = game;
901
+
902
+ window.artifact = () => {
903
+ if (!game.fsm.is("playing")) {
904
+ console.log("Must be in playing state to spawn artifact");
905
+ return;
906
+ }
907
+ game.spawnArtifact();
908
+ };
909
+
910
+ window.wormhole = () => {
911
+ if (!game.fsm.is("playing")) {
912
+ console.log("Must be in playing state to spawn wormhole");
913
+ return;
914
+ }
915
+ game.spawnWormhole();
916
+ };
917
+
918
+ window.giveArtifact = () => {
919
+ game.hasArtifact = true;
920
+ game.wormholesUsed = 1;
921
+ console.log("Artifact granted! You can now survive a singularity.");
922
+ };
923
+
924
+ window.die = () => {
925
+ if (!game.fsm.is("playing")) {
926
+ console.log("Must be in playing state");
927
+ return;
928
+ }
929
+ if (game.blackHoles.length > 0) {
930
+ game.ship.die(game.blackHoles[0]);
931
+ game.fsm.setState("dying");
932
+ } else {
933
+ console.log("No black holes to die to");
934
+ }
935
+ };
936
+
937
+ window.lore = () => {
938
+ if (!game.fsm.is("playing")) {
939
+ console.log("Must be in playing state to spawn lore prism");
940
+ return;
941
+ }
942
+ game.spawnLorePrism();
943
+ };