@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,892 @@
1
+ /**
2
+ * PenroseScene - The Penrose Diagram Map
3
+ *
4
+ * Renders the conformal spacetime diagram where the game takes place.
5
+ * Handles coordinate transformation, diagram structure, and all
6
+ * spacetime objects (black holes, wormholes, artifacts).
7
+ *
8
+ * @extends Scene
9
+ */
10
+
11
+ import { Painter, Scene } from "../../../src/index.js";
12
+ import { CONFIG } from "./constants.js";
13
+
14
+ export class PenroseScene extends Scene {
15
+ constructor(game) {
16
+ super(game);
17
+
18
+ // Camera state (controlled by game)
19
+ this.viewCenter = { u: 0, v: 0 };
20
+ this.viewScale = CONFIG.baseViewScale;
21
+ this.cameraRotation = 0;
22
+
23
+ // Background stars
24
+ this.stars = [];
25
+ this.initStars();
26
+
27
+ // References set by game
28
+ this.ship = null;
29
+ this.blackHoles = [];
30
+ this.wormholes = [];
31
+ this.artifacts = [];
32
+ this.lorePrisms = [];
33
+
34
+ // Rendering state from game
35
+ this.harvestingBlackHole = null;
36
+ this.kerrCollectedTimer = 0;
37
+ this.scoreMultiplier = 1;
38
+ this.timeSurvived = 0;
39
+ this.isIntro = false;
40
+ this.introPhase = 0;
41
+ this.isBoosting = false;
42
+ }
43
+
44
+ initStars() {
45
+ for (let i = 0; i < CONFIG.starCount; i++) {
46
+ this.stars.push({
47
+ u: (Math.random() - 0.5) * 4,
48
+ v: (Math.random() - 0.5) * 4,
49
+ size: 0.5 + Math.random() * 2,
50
+ alpha: 0.2 + Math.random() * 0.5,
51
+ });
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Regenerate stars around current ship position
57
+ */
58
+ regenerateStars() {
59
+ if (!this.ship) return;
60
+
61
+ this.stars = [];
62
+ for (let i = 0; i < CONFIG.starCount; i++) {
63
+ this.stars.push({
64
+ u: this.ship.u + (Math.random() - 0.5) * 2,
65
+ v: this.ship.v + (Math.random() - 0.3) * 2,
66
+ size: 0.5 + Math.random() * 2,
67
+ alpha: 0.3 + Math.random() * 0.5,
68
+ });
69
+ }
70
+ }
71
+
72
+ // ============================================================================
73
+ // COORDINATE CONVERSION
74
+ // ============================================================================
75
+
76
+ /**
77
+ * Convert Penrose coordinates to screen coordinates
78
+ */
79
+ penroseToScreen(u, v) {
80
+ const relU = u - this.viewCenter.u;
81
+ const relV = v - this.viewCenter.v;
82
+ const scale = Math.min(this.game.width, this.game.height) / this.viewScale;
83
+
84
+ return {
85
+ x: this.game.width / 2 + relU * scale,
86
+ y: this.game.height / 2 - relV * scale,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Convert screen coordinates to Penrose coordinates
92
+ */
93
+ screenToPenrose(x, y) {
94
+ const scale = Math.min(this.game.width, this.game.height) / this.viewScale;
95
+ return {
96
+ u: this.viewCenter.u + (x - this.game.width / 2) / scale,
97
+ v: this.viewCenter.v - (y - this.game.height / 2) / scale,
98
+ };
99
+ }
100
+
101
+ // ============================================================================
102
+ // RENDERING
103
+ // ============================================================================
104
+
105
+ render() {
106
+ const ctx = Painter.ctx;
107
+
108
+ // Apply camera rotation during gameplay
109
+ const shouldRotate = !this.isIntro && Math.abs(this.cameraRotation) > 0.001;
110
+ if (shouldRotate) {
111
+ ctx.save();
112
+ ctx.translate(this.game.width / 2, this.game.height / 2);
113
+ ctx.rotate(this.cameraRotation);
114
+ ctx.translate(-this.game.width / 2, -this.game.height / 2);
115
+ }
116
+
117
+ // Draw layers
118
+ this.drawStars(ctx);
119
+ this.drawDiagram(ctx);
120
+ this.drawNullGrid(ctx);
121
+
122
+ for (const bh of this.blackHoles) {
123
+ this.drawBlackHole(ctx, bh);
124
+ }
125
+
126
+ for (const wh of this.wormholes) {
127
+ this.drawWormhole(ctx, wh);
128
+ }
129
+
130
+ for (const art of this.artifacts) {
131
+ this.drawArtifact(ctx, art);
132
+ }
133
+
134
+ for (const prism of this.lorePrisms) {
135
+ this.drawLorePrism(ctx, prism);
136
+ }
137
+
138
+ if (!this.isIntro || this.introPhase === 1) {
139
+ this.drawLightCone(ctx);
140
+ }
141
+
142
+ this.drawWorldline(ctx);
143
+ this.drawShip(ctx);
144
+
145
+ if (shouldRotate) {
146
+ ctx.restore();
147
+ }
148
+
149
+ // Children (if any)
150
+ super.render();
151
+ }
152
+
153
+ // ============================================================================
154
+ // DRAW METHODS
155
+ // ============================================================================
156
+
157
+ drawStars(ctx) {
158
+ ctx.fillStyle = "#fff";
159
+ for (const star of this.stars) {
160
+ const pos = this.penroseToScreen(star.u, star.v);
161
+ ctx.globalAlpha = star.alpha * 0.5;
162
+ ctx.beginPath();
163
+ ctx.arc(pos.x, pos.y, star.size, 0, Math.PI * 2);
164
+ ctx.fill();
165
+ }
166
+ ctx.globalAlpha = 1;
167
+ }
168
+
169
+ drawDiagram(ctx) {
170
+ // Diamond corners
171
+ const corners = [
172
+ { u: 0, v: 1 }, // i+ (top)
173
+ { u: 1, v: 0 }, // i0 right
174
+ { u: 0, v: -1 }, // i- (bottom)
175
+ { u: -1, v: 0 }, // i0 left
176
+ ];
177
+
178
+ // Draw diamond outline
179
+ ctx.strokeStyle = "rgba(100, 100, 150, 0.5)";
180
+ ctx.lineWidth = 2;
181
+ ctx.beginPath();
182
+
183
+ for (let i = 0; i < corners.length; i++) {
184
+ const pos = this.penroseToScreen(corners[i].u, corners[i].v);
185
+ if (i === 0) {
186
+ ctx.moveTo(pos.x, pos.y);
187
+ } else {
188
+ ctx.lineTo(pos.x, pos.y);
189
+ }
190
+ }
191
+ ctx.closePath();
192
+ ctx.stroke();
193
+
194
+ // Draw labels in intro
195
+ if (this.isIntro) {
196
+ this.drawDiagramLabels(ctx);
197
+ }
198
+ }
199
+
200
+ drawDiagramLabels(ctx) {
201
+ ctx.textAlign = "center";
202
+
203
+ // i+ (Future Infinity)
204
+ const topPos = this.penroseToScreen(0, 0.85);
205
+ ctx.fillStyle = "#8ff";
206
+ ctx.font = "bold 14px monospace";
207
+ ctx.fillText("i+", topPos.x, topPos.y);
208
+ ctx.font = "11px monospace";
209
+ ctx.fillStyle = "#8aa";
210
+ ctx.fillText("Future Infinity", topPos.x, topPos.y + 14);
211
+ ctx.fillText("(where time ends)", topPos.x, topPos.y + 26);
212
+
213
+ // i- (Past Infinity)
214
+ const bottomPos = this.penroseToScreen(0, -0.85);
215
+ ctx.fillStyle = "#f8f";
216
+ ctx.font = "bold 14px monospace";
217
+ ctx.fillText("i-", bottomPos.x, bottomPos.y);
218
+ ctx.font = "11px monospace";
219
+ ctx.fillStyle = "#a8a";
220
+ ctx.fillText("Past Infinity", bottomPos.x, bottomPos.y + 14);
221
+ ctx.fillText("(where time began)", bottomPos.x, bottomPos.y + 26);
222
+
223
+ // i0 right (Spatial Infinity)
224
+ const rightPos = this.penroseToScreen(0.85, 0);
225
+ ctx.fillStyle = "#8f8";
226
+ ctx.font = "bold 14px monospace";
227
+ ctx.fillText("i0", rightPos.x + 15, rightPos.y);
228
+ ctx.font = "10px monospace";
229
+ ctx.fillStyle = "#8a8";
230
+ ctx.textAlign = "left";
231
+ ctx.fillText("Spatial", rightPos.x + 5, rightPos.y + 14);
232
+ ctx.fillText("Infinity", rightPos.x + 5, rightPos.y + 26);
233
+
234
+ // i0 left
235
+ const leftPos = this.penroseToScreen(-0.85, 0);
236
+ ctx.fillStyle = "#8f8";
237
+ ctx.font = "bold 14px monospace";
238
+ ctx.textAlign = "right";
239
+ ctx.fillText("i0", leftPos.x - 15, leftPos.y);
240
+ ctx.font = "10px monospace";
241
+ ctx.fillStyle = "#8a8";
242
+ ctx.fillText("Spatial", leftPos.x - 5, leftPos.y + 14);
243
+ ctx.fillText("Infinity", leftPos.x - 5, leftPos.y + 26);
244
+
245
+ // Null infinity labels
246
+ ctx.textAlign = "center";
247
+ ctx.font = "bold 12px monospace";
248
+
249
+ ctx.fillStyle = "#ff8";
250
+ const jPlusRight = this.penroseToScreen(0.55, 0.45);
251
+ ctx.fillText("J+", jPlusRight.x, jPlusRight.y);
252
+ ctx.font = "9px monospace";
253
+ ctx.fillStyle = "#aa8";
254
+ ctx.fillText("light escapes", jPlusRight.x, jPlusRight.y + 11);
255
+
256
+ ctx.font = "bold 12px monospace";
257
+ ctx.fillStyle = "#ff8";
258
+ const jPlusLeft = this.penroseToScreen(-0.55, 0.45);
259
+ ctx.fillText("J+", jPlusLeft.x, jPlusLeft.y);
260
+
261
+ ctx.fillStyle = "#fa8";
262
+ const jMinusRight = this.penroseToScreen(0.55, -0.45);
263
+ ctx.fillText("J-", jMinusRight.x, jMinusRight.y);
264
+ ctx.font = "9px monospace";
265
+ ctx.fillStyle = "#a88";
266
+ ctx.fillText("light arrives", jMinusRight.x, jMinusRight.y + 11);
267
+
268
+ ctx.font = "bold 12px monospace";
269
+ ctx.fillStyle = "#fa8";
270
+ const jMinusLeft = this.penroseToScreen(-0.55, -0.45);
271
+ ctx.fillText("J-", jMinusLeft.x, jMinusLeft.y);
272
+ }
273
+
274
+ drawNullGrid(ctx) {
275
+ const spacing = CONFIG.gridSpacing;
276
+ ctx.strokeStyle = "rgba(100, 120, 160, 0.3)";
277
+ ctx.lineWidth = 1;
278
+
279
+ // Lines where u + v = c
280
+ for (let c = -1; c <= 1; c += spacing) {
281
+ const pts = this.getLineInDiamond(c, true);
282
+ if (pts) {
283
+ const p1 = this.penroseToScreen(pts.u1, pts.v1);
284
+ const p2 = this.penroseToScreen(pts.u2, pts.v2);
285
+ ctx.beginPath();
286
+ ctx.moveTo(p1.x, p1.y);
287
+ ctx.lineTo(p2.x, p2.y);
288
+ ctx.stroke();
289
+ }
290
+ }
291
+
292
+ // Lines where u - v = c
293
+ for (let c = -1; c <= 1; c += spacing) {
294
+ const pts = this.getLineInDiamond(c, false);
295
+ if (pts) {
296
+ const p1 = this.penroseToScreen(pts.u1, pts.v1);
297
+ const p2 = this.penroseToScreen(pts.u2, pts.v2);
298
+ ctx.beginPath();
299
+ ctx.moveTo(p1.x, p1.y);
300
+ ctx.lineTo(p2.x, p2.y);
301
+ ctx.stroke();
302
+ }
303
+ }
304
+ }
305
+
306
+ /**
307
+ * Get endpoints of a 45deg line inside the diamond |u|+|v|<=1
308
+ */
309
+ getLineInDiamond(c, isPlusLine) {
310
+ if (Math.abs(c) > 1) return null;
311
+
312
+ let u1, v1, u2, v2;
313
+
314
+ if (isPlusLine) {
315
+ // u + v = c
316
+ u1 = (1 + c) / 2;
317
+ v1 = (c - 1) / 2;
318
+ u2 = (c - 1) / 2;
319
+ v2 = (1 + c) / 2;
320
+ } else {
321
+ // u - v = c
322
+ u1 = (c - 1) / 2;
323
+ v1 = -1 - u1;
324
+ u2 = (1 + c) / 2;
325
+ v2 = (1 - c) / 2;
326
+ }
327
+
328
+ return { u1, v1, u2, v2 };
329
+ }
330
+
331
+ drawBlackHole(ctx, bh) {
332
+ const pos = this.penroseToScreen(bh.u, bh.v);
333
+ const scale = Math.min(this.game.width, this.game.height) / this.viewScale;
334
+ const screenRadius = bh.horizonRadius * scale;
335
+ const ergoRadius = bh.ergosphereRadius * scale;
336
+
337
+ const pulse = 0.8 + Math.sin(bh.pulsePhase) * 0.2;
338
+ const rotationAngle = this.timeSurvived * 2 + bh.pulsePhase;
339
+
340
+ // Ergosphere (harvesting zone)
341
+ if (!bh.harvested) {
342
+ const ergoGradient = ctx.createRadialGradient(
343
+ pos.x, pos.y, screenRadius,
344
+ pos.x, pos.y, ergoRadius
345
+ );
346
+ const ergoAlpha = bh.isBeingHarvested ? 0.4 : 0.15;
347
+ ergoGradient.addColorStop(0, `rgba(0, 255, 200, ${ergoAlpha})`);
348
+ ergoGradient.addColorStop(1, "rgba(0, 255, 200, 0)");
349
+
350
+ ctx.fillStyle = ergoGradient;
351
+ ctx.beginPath();
352
+ ctx.arc(pos.x, pos.y, ergoRadius, 0, Math.PI * 2);
353
+ ctx.fill();
354
+
355
+ // Rotating ergosphere boundary
356
+ ctx.save();
357
+ ctx.translate(pos.x, pos.y);
358
+ ctx.rotate(rotationAngle);
359
+ ctx.strokeStyle = bh.isBeingHarvested
360
+ ? `rgba(50, 255, 150, ${0.8 * pulse})`
361
+ : "rgba(0, 200, 150, 0.3)";
362
+ ctx.lineWidth = bh.isBeingHarvested ? 2 : 1;
363
+ ctx.setLineDash([8, 8]);
364
+ ctx.beginPath();
365
+ ctx.arc(0, 0, ergoRadius, 0, Math.PI * 2);
366
+ ctx.stroke();
367
+ ctx.setLineDash([]);
368
+ ctx.restore();
369
+ }
370
+
371
+ // Rotating accretion swirl
372
+ ctx.save();
373
+ ctx.translate(pos.x, pos.y);
374
+ ctx.rotate(rotationAngle);
375
+
376
+ for (let i = 0; i < 3; i++) {
377
+ const armAngle = (i * Math.PI * 2) / 3;
378
+ ctx.strokeStyle = `rgba(255, 100, 50, ${0.3 * pulse})`;
379
+ ctx.lineWidth = 2;
380
+ ctx.beginPath();
381
+ for (let r = screenRadius * 0.6; r < screenRadius * 1.1; r += 2) {
382
+ const angle = armAngle + (r - screenRadius * 0.6) * 0.15;
383
+ const x = Math.cos(angle) * r;
384
+ const y = Math.sin(angle) * r;
385
+ if (r === screenRadius * 0.6) {
386
+ ctx.moveTo(x, y);
387
+ } else {
388
+ ctx.lineTo(x, y);
389
+ }
390
+ }
391
+ ctx.stroke();
392
+ }
393
+ ctx.restore();
394
+
395
+ // Outer glow
396
+ const gradient = ctx.createRadialGradient(
397
+ pos.x, pos.y, screenRadius * 0.3,
398
+ pos.x, pos.y, screenRadius * 1.2
399
+ );
400
+ gradient.addColorStop(0, "rgba(255, 50, 0, 0.8)");
401
+ gradient.addColorStop(0.5, `rgba(255, 100, 50, ${0.4 * pulse})`);
402
+ gradient.addColorStop(1, "rgba(255, 50, 0, 0)");
403
+
404
+ ctx.fillStyle = gradient;
405
+ ctx.beginPath();
406
+ ctx.arc(pos.x, pos.y, screenRadius * 1.2, 0, Math.PI * 2);
407
+ ctx.fill();
408
+
409
+ // Event horizon boundary
410
+ ctx.strokeStyle = `rgba(255, 150, 100, ${0.8 * pulse})`;
411
+ ctx.lineWidth = 2;
412
+ ctx.beginPath();
413
+ ctx.arc(pos.x, pos.y, screenRadius, 0, Math.PI * 2);
414
+ ctx.stroke();
415
+
416
+ // Singularity center
417
+ ctx.save();
418
+ ctx.translate(pos.x, pos.y);
419
+ ctx.rotate(-rotationAngle * 1.5);
420
+
421
+ const singularityGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, screenRadius * 0.5);
422
+ singularityGradient.addColorStop(0, "#000");
423
+ singularityGradient.addColorStop(0.7, "#000");
424
+ singularityGradient.addColorStop(1, "rgba(128, 0, 128, 0.5)");
425
+
426
+ ctx.fillStyle = singularityGradient;
427
+ ctx.beginPath();
428
+ ctx.arc(0, 0, screenRadius * 0.5, 0, Math.PI * 2);
429
+ ctx.fill();
430
+
431
+ ctx.strokeStyle = `rgba(200, 50, 200, ${0.5 * pulse})`;
432
+ ctx.lineWidth = 1;
433
+ ctx.beginPath();
434
+ ctx.arc(0, 0, screenRadius * 0.35, 0, Math.PI * 1.5);
435
+ ctx.stroke();
436
+
437
+ ctx.restore();
438
+ }
439
+
440
+ drawWormhole(ctx, wh) {
441
+ const pos = this.penroseToScreen(wh.u, wh.v);
442
+ const scale = Math.min(this.game.width, this.game.height) / this.viewScale;
443
+ const screenRadius = wh.radius * scale;
444
+
445
+ const fadeIn = Math.min(1, wh.spawnTime / 0.5);
446
+ const pulse = 0.7 + Math.sin(wh.pulsePhase) * 0.3;
447
+
448
+ ctx.save();
449
+ ctx.globalAlpha = fadeIn;
450
+ ctx.translate(pos.x, pos.y);
451
+
452
+ // Outer swirling rings
453
+ for (let ring = 3; ring >= 0; ring--) {
454
+ const ringRadius = screenRadius * (1 + ring * 0.3);
455
+ const ringRotation = wh.rotationPhase * (ring % 2 === 0 ? 1 : -1) * (1 + ring * 0.2);
456
+
457
+ ctx.save();
458
+ ctx.rotate(ringRotation);
459
+
460
+ const gradient = ctx.createRadialGradient(0, 0, ringRadius * 0.3, 0, 0, ringRadius);
461
+
462
+ if (ring === 0) {
463
+ gradient.addColorStop(0, `rgba(100, 255, 255, ${0.9 * pulse})`);
464
+ gradient.addColorStop(0.5, `rgba(50, 200, 255, ${0.6 * pulse})`);
465
+ gradient.addColorStop(1, "rgba(100, 50, 255, 0)");
466
+ } else {
467
+ const alpha = (0.3 / ring) * pulse;
468
+ gradient.addColorStop(0, `rgba(150, 100, 255, ${alpha})`);
469
+ gradient.addColorStop(0.5, `rgba(100, 150, 255, ${alpha * 0.5})`);
470
+ gradient.addColorStop(1, "rgba(50, 50, 200, 0)");
471
+ }
472
+
473
+ ctx.fillStyle = gradient;
474
+ ctx.beginPath();
475
+ ctx.arc(0, 0, ringRadius, 0, Math.PI * 2);
476
+ ctx.fill();
477
+
478
+ // Spiral arms
479
+ if (ring > 0) {
480
+ ctx.strokeStyle = `rgba(150, 100, 255, ${0.4 * pulse / ring})`;
481
+ ctx.lineWidth = 2;
482
+ for (let arm = 0; arm < 4; arm++) {
483
+ const armAngle = (arm * Math.PI * 2) / 4;
484
+ ctx.beginPath();
485
+ for (let r = ringRadius * 0.4; r < ringRadius; r += 3) {
486
+ const angle = armAngle + (r - ringRadius * 0.4) * 0.08;
487
+ const x = Math.cos(angle) * r;
488
+ const y = Math.sin(angle) * r;
489
+ if (r === ringRadius * 0.4) {
490
+ ctx.moveTo(x, y);
491
+ } else {
492
+ ctx.lineTo(x, y);
493
+ }
494
+ }
495
+ ctx.stroke();
496
+ }
497
+ }
498
+
499
+ ctx.restore();
500
+ }
501
+
502
+ // Center portal
503
+ const centerGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, screenRadius * 0.5);
504
+ centerGradient.addColorStop(0, "#000");
505
+ centerGradient.addColorStop(0.6, "#001");
506
+ centerGradient.addColorStop(0.9, `rgba(100, 200, 255, ${0.8 * pulse})`);
507
+ centerGradient.addColorStop(1, "rgba(150, 100, 255, 0)");
508
+
509
+ ctx.fillStyle = centerGradient;
510
+ ctx.beginPath();
511
+ ctx.arc(0, 0, screenRadius * 0.5, 0, Math.PI * 2);
512
+ ctx.fill();
513
+
514
+ ctx.strokeStyle = `rgba(150, 200, 255, ${0.9 * pulse})`;
515
+ ctx.lineWidth = 3;
516
+ ctx.beginPath();
517
+ ctx.arc(0, 0, screenRadius * 0.45, 0, Math.PI * 2);
518
+ ctx.stroke();
519
+
520
+ // Inner glow particles
521
+ for (let i = 0; i < 6; i++) {
522
+ const particleAngle = wh.rotationPhase * 2 + (i * Math.PI * 2) / 6;
523
+ const particleR = screenRadius * 0.3 * (0.5 + Math.sin(wh.pulsePhase + i) * 0.5);
524
+ const px = Math.cos(particleAngle) * particleR;
525
+ const py = Math.sin(particleAngle) * particleR;
526
+
527
+ ctx.fillStyle = `rgba(200, 255, 255, ${0.8 * pulse})`;
528
+ ctx.beginPath();
529
+ ctx.arc(px, py, 3, 0, Math.PI * 2);
530
+ ctx.fill();
531
+ }
532
+
533
+ ctx.restore();
534
+
535
+ // Label
536
+ if (fadeIn >= 1) {
537
+ this.drawOutlinedText(
538
+ ctx,
539
+ "WORMHOLE",
540
+ pos.x,
541
+ pos.y - screenRadius - 15,
542
+ `rgba(150, 200, 255, ${0.8 * pulse})`,
543
+ "#000",
544
+ "bold 12px monospace"
545
+ );
546
+ }
547
+ }
548
+
549
+ drawArtifact(ctx, art) {
550
+ const pos = this.penroseToScreen(art.u, art.v);
551
+ const scale = Math.min(this.game.width, this.game.height) / this.viewScale;
552
+ const screenRadius = art.radius * scale;
553
+
554
+ const fadeIn = Math.min(1, art.spawnTime / 0.5);
555
+ const pulse = 0.7 + Math.sin(art.pulsePhase) * 0.3;
556
+
557
+ ctx.save();
558
+ ctx.globalAlpha = fadeIn;
559
+
560
+ // Outer glow
561
+ const gradient = ctx.createRadialGradient(
562
+ pos.x, pos.y, screenRadius * 0.3,
563
+ pos.x, pos.y, screenRadius * 2
564
+ );
565
+ gradient.addColorStop(0, `rgba(200, 100, 255, ${0.6 * pulse})`);
566
+ gradient.addColorStop(0.5, `rgba(150, 50, 200, ${0.3 * pulse})`);
567
+ gradient.addColorStop(1, "rgba(100, 0, 150, 0)");
568
+
569
+ ctx.fillStyle = gradient;
570
+ ctx.beginPath();
571
+ ctx.arc(pos.x, pos.y, screenRadius * 2, 0, Math.PI * 2);
572
+ ctx.fill();
573
+
574
+ // Draw the cube
575
+ ctx.save();
576
+ ctx.translate(pos.x, pos.y + art.hoverOffset);
577
+ art.cube.x = 0;
578
+ art.cube.y = 0;
579
+ art.cube.draw();
580
+ ctx.restore();
581
+
582
+ // Sparkles
583
+ for (let i = 0; i < 6; i++) {
584
+ const angle = art.rotationPhase + (i * Math.PI * 2) / 6;
585
+ const sparkleR = screenRadius * 1.2;
586
+ const sx = pos.x + Math.cos(angle) * sparkleR;
587
+ const sy = pos.y + Math.sin(angle) * sparkleR + art.hoverOffset;
588
+
589
+ ctx.fillStyle = `rgba(255, 200, 255, ${0.8 * pulse})`;
590
+ ctx.beginPath();
591
+ ctx.arc(sx, sy, 3, 0, Math.PI * 2);
592
+ ctx.fill();
593
+ }
594
+
595
+ ctx.restore();
596
+ }
597
+
598
+ drawLorePrism(ctx, prism) {
599
+ const pos = this.penroseToScreen(prism.u, prism.v);
600
+ const scale = Math.min(this.game.width, this.game.height) / this.viewScale;
601
+ const screenRadius = prism.radius * scale;
602
+
603
+ const fadeIn = Math.min(1, prism.spawnTime / 0.5);
604
+ const pulse = 0.7 + Math.sin(prism.pulsePhase) * 0.3;
605
+
606
+ ctx.save();
607
+ ctx.globalAlpha = fadeIn;
608
+
609
+ // Outer glow - cyan/blue to match prism colors
610
+ const gradient = ctx.createRadialGradient(
611
+ pos.x, pos.y, screenRadius * 0.2,
612
+ pos.x, pos.y, screenRadius * 2.5
613
+ );
614
+ gradient.addColorStop(0, `rgba(100, 220, 255, ${0.5 * pulse})`);
615
+ gradient.addColorStop(0.4, `rgba(50, 180, 220, ${0.3 * pulse})`);
616
+ gradient.addColorStop(1, "rgba(30, 100, 150, 0)");
617
+
618
+ ctx.fillStyle = gradient;
619
+ ctx.beginPath();
620
+ ctx.arc(pos.x, pos.y, screenRadius * 2.5, 0, Math.PI * 2);
621
+ ctx.fill();
622
+
623
+ // Draw the prism shape
624
+ ctx.save();
625
+ ctx.translate(pos.x, pos.y + prism.hoverOffset);
626
+ prism.prism.x = 0;
627
+ prism.prism.y = 0;
628
+ prism.prism.draw();
629
+ ctx.restore();
630
+
631
+ // Sparkle particles orbiting
632
+ for (let i = 0; i < 4; i++) {
633
+ const angle = prism.rotationPhase * 1.5 + (i * Math.PI * 2) / 4;
634
+ const sparkleR = screenRadius * 1.4;
635
+ const sx = pos.x + Math.cos(angle) * sparkleR;
636
+ const sy = pos.y + Math.sin(angle) * sparkleR + prism.hoverOffset;
637
+
638
+ ctx.fillStyle = `rgba(150, 230, 255, ${0.8 * pulse})`;
639
+ ctx.beginPath();
640
+ ctx.arc(sx, sy, 2.5, 0, Math.PI * 2);
641
+ ctx.fill();
642
+ }
643
+
644
+ ctx.restore();
645
+ }
646
+
647
+ drawLightCone(ctx) {
648
+ if (!this.ship) return;
649
+ if (!this.ship.alive && this.ship.deathFade > 0.5) return;
650
+
651
+ const shipPos = this.penroseToScreen(this.ship.u, this.ship.v);
652
+ const scale = Math.min(this.game.width, this.game.height) / this.viewScale;
653
+ const coneLength = CONFIG.coneLength * scale;
654
+
655
+ const isHarvesting = this.harvestingBlackHole !== null;
656
+ const harvestProgress = isHarvesting
657
+ ? this.harvestingBlackHole.harvestProgress / CONFIG.kerrHarvestTime
658
+ : 0;
659
+
660
+ // Cone colors
661
+ let fillColor, strokeColor;
662
+ if (isHarvesting) {
663
+ strokeColor = "rgba(50, 255, 100, 0.9)";
664
+ fillColor = "rgba(50, 255, 100, 0.1)";
665
+ } else {
666
+ strokeColor = "rgba(255, 220, 100, 0.5)";
667
+ fillColor = "rgba(255, 220, 100, 0.15)";
668
+ }
669
+
670
+ // Light cone points in ship's heading direction
671
+ const coneRotation = this.ship.heading;
672
+
673
+ ctx.save();
674
+ ctx.translate(shipPos.x, shipPos.y);
675
+ ctx.rotate(coneRotation);
676
+
677
+ ctx.strokeStyle = strokeColor;
678
+ ctx.lineWidth = isHarvesting ? 3 : 2;
679
+ ctx.beginPath();
680
+ ctx.moveTo(0, 0);
681
+ ctx.lineTo(coneLength, -coneLength);
682
+ ctx.lineTo(-coneLength, -coneLength);
683
+ ctx.closePath();
684
+ ctx.stroke();
685
+
686
+ ctx.fillStyle = fillColor;
687
+ ctx.fill();
688
+
689
+ ctx.restore();
690
+
691
+ // Harvest progress fill
692
+ if (isHarvesting && harvestProgress > 0) {
693
+ const fillHeight = coneLength * harvestProgress;
694
+ const fillWidth = fillHeight;
695
+
696
+ ctx.save();
697
+ ctx.translate(shipPos.x, shipPos.y);
698
+ ctx.rotate(coneRotation);
699
+
700
+ ctx.fillStyle = "rgba(50, 255, 100, 0.4)";
701
+ ctx.beginPath();
702
+ ctx.moveTo(0, 0);
703
+ ctx.lineTo(fillWidth, -fillHeight);
704
+ ctx.lineTo(-fillWidth, -fillHeight);
705
+ ctx.closePath();
706
+ ctx.fill();
707
+
708
+ const pulse = 0.5 + Math.sin(Date.now() / 100) * 0.3;
709
+ ctx.strokeStyle = `rgba(100, 255, 150, ${pulse})`;
710
+ ctx.lineWidth = 4;
711
+ ctx.stroke();
712
+
713
+ ctx.restore();
714
+
715
+ // Progress text
716
+ const tipX = shipPos.x + Math.sin(coneRotation) * (coneLength + 15);
717
+ const tipY = shipPos.y - Math.cos(coneRotation) * (coneLength + 15);
718
+ this.drawOutlinedText(
719
+ ctx,
720
+ `${(harvestProgress * 100).toFixed(0)}%`,
721
+ tipX,
722
+ tipY,
723
+ "#5f5",
724
+ "#000",
725
+ "bold 16px monospace"
726
+ );
727
+
728
+ // Collecting message
729
+ const textPulse = 0.7 + Math.sin(Date.now() / 200) * 0.3;
730
+ this.drawOutlinedText(
731
+ ctx,
732
+ "COLLECTING KERR ENERGY",
733
+ shipPos.x,
734
+ shipPos.y + 50,
735
+ `rgba(80, 255, 120, ${textPulse})`,
736
+ "#000",
737
+ "bold 14px monospace"
738
+ );
739
+ }
740
+
741
+ // Kerr collected message
742
+ if (this.kerrCollectedTimer > 0 && this.ship.alive) {
743
+ const alpha = Math.min(1, this.kerrCollectedTimer);
744
+ const textScale = 1 + (2 - this.kerrCollectedTimer) * 0.1;
745
+ ctx.save();
746
+ ctx.translate(shipPos.x, shipPos.y + 50);
747
+ ctx.scale(textScale, textScale);
748
+ this.drawOutlinedText(
749
+ ctx,
750
+ "KERR ENERGY COLLECTED!",
751
+ 0,
752
+ 0,
753
+ `rgba(100, 255, 255, ${alpha})`,
754
+ `rgba(0, 0, 0, ${alpha})`,
755
+ "bold 16px monospace"
756
+ );
757
+ this.drawOutlinedText(
758
+ ctx,
759
+ `x${this.scoreMultiplier} MULTIPLIER!`,
760
+ 0,
761
+ 20,
762
+ `rgba(255, 200, 50, ${alpha})`,
763
+ `rgba(0, 0, 0, ${alpha})`,
764
+ "bold 14px monospace"
765
+ );
766
+ ctx.restore();
767
+ }
768
+
769
+ // Frame drag indicator
770
+ if (this.ship.inErgosphere && !isHarvesting && this.ship.alive) {
771
+ const tipX = shipPos.x + Math.sin(coneRotation) * (coneLength + 15);
772
+ const tipY = shipPos.y - Math.cos(coneRotation) * (coneLength + 15);
773
+ this.drawOutlinedText(ctx, "FRAME DRAG", tipX, tipY, "#5ff", "#000", "bold 14px monospace");
774
+ }
775
+
776
+ // Spatial infinity indicator
777
+ if (this.ship.hitSpatialInfinity && this.ship.alive) {
778
+ this.drawOutlinedText(
779
+ ctx,
780
+ "SPATIAL INFINITY",
781
+ shipPos.x,
782
+ shipPos.y + 70,
783
+ "#f88",
784
+ "#000",
785
+ "bold 12px monospace"
786
+ );
787
+ }
788
+
789
+ // Dying cone
790
+ if (!this.ship.alive && this.ship.deathBlackHole) {
791
+ const bh = this.ship.deathBlackHole;
792
+ const bhPos = this.penroseToScreen(bh.u, bh.v);
793
+ const t = Math.min(this.ship.deathProgress, 1);
794
+
795
+ ctx.fillStyle = `rgba(255, 50, 50, ${0.3 * t})`;
796
+ ctx.strokeStyle = `rgba(255, 50, 50, ${0.8 * t})`;
797
+
798
+ ctx.beginPath();
799
+ ctx.moveTo(shipPos.x, shipPos.y);
800
+ ctx.lineTo(bhPos.x, bhPos.y);
801
+ ctx.lineTo(shipPos.x + (bhPos.x - shipPos.x) * 0.3, shipPos.y - coneLength * 0.5);
802
+ ctx.closePath();
803
+ ctx.fill();
804
+ ctx.stroke();
805
+ }
806
+ }
807
+
808
+ drawWorldline(ctx) {
809
+ if (!this.ship || this.ship.worldline.length < 2) return;
810
+
811
+ ctx.strokeStyle = "#0f0";
812
+ ctx.lineWidth = 2;
813
+ ctx.beginPath();
814
+
815
+ for (let i = 0; i < this.ship.worldline.length; i++) {
816
+ const pt = this.ship.worldline[i];
817
+ const pos = this.penroseToScreen(pt.u, pt.v);
818
+ const alpha = i / this.ship.worldline.length;
819
+ ctx.strokeStyle = `rgba(0, 255, 0, ${alpha * 0.8})`;
820
+
821
+ if (i === 0) {
822
+ ctx.moveTo(pos.x, pos.y);
823
+ } else {
824
+ ctx.lineTo(pos.x, pos.y);
825
+ }
826
+ }
827
+
828
+ ctx.stroke();
829
+ }
830
+
831
+ drawShip(ctx) {
832
+ if (!this.ship) return;
833
+ if (this.isIntro && this.introPhase === 0) return;
834
+ if (!this.ship.alive && this.ship.deathFade >= 1) return;
835
+
836
+ const pos = this.penroseToScreen(this.ship.u, this.ship.v);
837
+
838
+ this.ship.shipGroup.x = pos.x;
839
+ this.ship.shipGroup.y = pos.y;
840
+
841
+ // Ship tilts to show heading direction
842
+ const tiltDegrees = this.ship.heading * (180 / Math.PI) * 0.5; // Subtle tilt
843
+ this.ship.shipGroup.rotation = tiltDegrees;
844
+
845
+ if (!this.ship.alive) {
846
+ ctx.save();
847
+ const redshiftAmount = Math.min(this.ship.deathProgress * 2, 1);
848
+ const hueShift = -120 * redshiftAmount;
849
+ const saturate = 1 + redshiftAmount * 0.5;
850
+ const brightness = 1 - redshiftAmount * 0.3;
851
+
852
+ ctx.filter = `hue-rotate(${hueShift}deg) saturate(${saturate}) brightness(${brightness})`;
853
+ this.ship.shipGroup.opacity = 1 - this.ship.deathFade;
854
+ this.ship.shipGroup.render();
855
+ ctx.restore();
856
+ } else if (this.isBoosting) {
857
+ ctx.save();
858
+ const pulse = 0.8 + Math.sin(Date.now() / 50) * 0.2;
859
+ ctx.filter = `hue-rotate(60deg) saturate(1.8) brightness(${1.4 + pulse * 0.3})`;
860
+ this.ship.shipGroup.opacity = 1;
861
+ this.ship.shipGroup.render();
862
+ ctx.restore();
863
+ } else if (this.ship.inErgosphere) {
864
+ ctx.save();
865
+ const pulse = 0.8 + Math.sin(Date.now() / 100) * 0.2;
866
+ ctx.filter = `hue-rotate(160deg) saturate(${1.2 * pulse}) brightness(1.1)`;
867
+ this.ship.shipGroup.opacity = 1;
868
+ this.ship.shipGroup.render();
869
+ ctx.restore();
870
+ } else {
871
+ this.ship.shipGroup.opacity = 1;
872
+ this.ship.shipGroup.render();
873
+ }
874
+ }
875
+
876
+ // ============================================================================
877
+ // HELPERS
878
+ // ============================================================================
879
+
880
+ drawOutlinedText(ctx, text, x, y, fillColor, strokeColor, font) {
881
+ ctx.save();
882
+ ctx.font = font;
883
+ ctx.textAlign = "center";
884
+ ctx.textBaseline = "middle";
885
+ ctx.lineWidth = 4;
886
+ ctx.strokeStyle = strokeColor;
887
+ ctx.fillStyle = fillColor;
888
+ ctx.strokeText(text, x, y);
889
+ ctx.fillText(text, x, y);
890
+ ctx.restore();
891
+ }
892
+ }