@guinetik/gcanvas 1.0.4 → 2.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 (261) hide show
  1. package/dist/CNAME +1 -0
  2. package/dist/aizawa.html +27 -0
  3. package/dist/animations.html +31 -0
  4. package/dist/basic.html +38 -0
  5. package/dist/baskara.html +31 -0
  6. package/dist/bezier.html +35 -0
  7. package/dist/beziersignature.html +29 -0
  8. package/dist/blackhole.html +28 -0
  9. package/dist/blob.html +35 -0
  10. package/dist/clifford.html +25 -0
  11. package/dist/cmb.html +24 -0
  12. package/dist/coordinates.html +698 -0
  13. package/dist/cube3d.html +23 -0
  14. package/dist/dadras.html +26 -0
  15. package/dist/dejong.html +25 -0
  16. package/dist/demos.css +303 -0
  17. package/dist/dino.html +42 -0
  18. package/dist/easing.html +28 -0
  19. package/dist/events.html +195 -0
  20. package/dist/fluent.html +647 -0
  21. package/dist/fluid-simple.html +22 -0
  22. package/dist/fluid.html +37 -0
  23. package/dist/fractals.html +36 -0
  24. package/dist/gameobjects.html +626 -0
  25. package/dist/gcanvas.es.js +14368 -9093
  26. package/dist/gcanvas.es.min.js +1 -1
  27. package/dist/gcanvas.umd.js +1 -1
  28. package/dist/gcanvas.umd.min.js +1 -1
  29. package/dist/genart.html +26 -0
  30. package/dist/gendream.html +26 -0
  31. package/dist/group.html +36 -0
  32. package/dist/halvorsen.html +27 -0
  33. package/dist/home.html +587 -0
  34. package/dist/hyperbolic001.html +23 -0
  35. package/dist/hyperbolic002.html +23 -0
  36. package/dist/hyperbolic003.html +23 -0
  37. package/dist/hyperbolic004.html +23 -0
  38. package/dist/hyperbolic005.html +22 -0
  39. package/dist/index.html +446 -0
  40. package/dist/isometric.html +34 -0
  41. package/dist/js/aizawa.js +425 -0
  42. package/dist/js/animations.js +452 -0
  43. package/dist/js/basic.js +204 -0
  44. package/dist/js/baskara.js +751 -0
  45. package/dist/js/bezier.js +692 -0
  46. package/dist/js/beziersignature.js +241 -0
  47. package/dist/js/blackhole/accretiondisk.obj.js +379 -0
  48. package/dist/js/blackhole/blackhole.obj.js +318 -0
  49. package/dist/js/blackhole/index.js +409 -0
  50. package/dist/js/blackhole/particle.js +56 -0
  51. package/dist/js/blackhole/starfield.obj.js +218 -0
  52. package/dist/js/blob.js +2276 -0
  53. package/dist/js/clifford.js +236 -0
  54. package/dist/js/cmb.js +594 -0
  55. package/dist/js/coordinates.js +840 -0
  56. package/dist/js/cube3d.js +789 -0
  57. package/dist/js/dadras.js +405 -0
  58. package/dist/js/dejong.js +257 -0
  59. package/dist/js/dino.js +1420 -0
  60. package/dist/js/easing.js +477 -0
  61. package/dist/js/fluent.js +183 -0
  62. package/dist/js/fluid-simple.js +253 -0
  63. package/dist/js/fluid.js +527 -0
  64. package/dist/js/fractals.js +932 -0
  65. package/dist/js/fractalworker.js +93 -0
  66. package/dist/js/gameobjects.js +176 -0
  67. package/dist/js/genart.js +268 -0
  68. package/dist/js/gendream.js +209 -0
  69. package/dist/js/group.js +140 -0
  70. package/dist/js/halvorsen.js +405 -0
  71. package/dist/js/hyperbolic001.js +310 -0
  72. package/dist/js/hyperbolic002.js +388 -0
  73. package/dist/js/hyperbolic003.js +319 -0
  74. package/dist/js/hyperbolic004.js +345 -0
  75. package/dist/js/hyperbolic005.js +340 -0
  76. package/dist/js/info-toggle.js +25 -0
  77. package/dist/js/isometric.js +851 -0
  78. package/dist/js/kerr.js +1547 -0
  79. package/dist/js/lavalamp.js +590 -0
  80. package/dist/js/layout.js +354 -0
  81. package/dist/js/lorenz.js +425 -0
  82. package/dist/js/mondrian.js +285 -0
  83. package/dist/js/opacity.js +275 -0
  84. package/dist/js/painter.js +484 -0
  85. package/dist/js/particles-showcase.js +514 -0
  86. package/dist/js/particles.js +299 -0
  87. package/dist/js/patterns.js +397 -0
  88. package/dist/js/penrose/artifact.js +69 -0
  89. package/dist/js/penrose/blackhole.js +121 -0
  90. package/dist/js/penrose/constants.js +73 -0
  91. package/dist/js/penrose/game.js +943 -0
  92. package/dist/js/penrose/lore.js +278 -0
  93. package/dist/js/penrose/penrosescene.js +892 -0
  94. package/dist/js/penrose/ship.js +216 -0
  95. package/dist/js/penrose/sounds.js +211 -0
  96. package/dist/js/penrose/voidparticle.js +55 -0
  97. package/dist/js/penrose/voidscene.js +258 -0
  98. package/dist/js/penrose/voidship.js +144 -0
  99. package/dist/js/penrose/wormhole.js +46 -0
  100. package/dist/js/pipeline.js +555 -0
  101. package/dist/js/plane3d.js +256 -0
  102. package/dist/js/platformer.js +1579 -0
  103. package/dist/js/rossler.js +480 -0
  104. package/dist/js/scene.js +304 -0
  105. package/dist/js/scenes.js +320 -0
  106. package/dist/js/schrodinger.js +706 -0
  107. package/dist/js/schwarzschild.js +1015 -0
  108. package/dist/js/shapes.js +628 -0
  109. package/dist/js/space/alien.js +171 -0
  110. package/dist/js/space/boom.js +98 -0
  111. package/dist/js/space/boss.js +353 -0
  112. package/dist/js/space/buff.js +73 -0
  113. package/dist/js/space/bullet.js +102 -0
  114. package/dist/js/space/constants.js +85 -0
  115. package/dist/js/space/game.js +1884 -0
  116. package/dist/js/space/hud.js +112 -0
  117. package/dist/js/space/laserbeam.js +179 -0
  118. package/dist/js/space/lightning.js +277 -0
  119. package/dist/js/space/minion.js +192 -0
  120. package/dist/js/space/missile.js +212 -0
  121. package/dist/js/space/player.js +430 -0
  122. package/dist/js/space/powerup.js +90 -0
  123. package/dist/js/space/starfield.js +58 -0
  124. package/dist/js/space/starpower.js +90 -0
  125. package/dist/js/spacetime.js +559 -0
  126. package/dist/js/sphere3d.js +229 -0
  127. package/dist/js/sprite.js +473 -0
  128. package/dist/js/starfaux/config.js +118 -0
  129. package/dist/js/starfaux/enemy.js +353 -0
  130. package/dist/js/starfaux/hud.js +78 -0
  131. package/dist/js/starfaux/index.js +482 -0
  132. package/dist/js/starfaux/laser.js +182 -0
  133. package/dist/js/starfaux/player.js +468 -0
  134. package/dist/js/starfaux/terrain.js +560 -0
  135. package/dist/js/study001.js +275 -0
  136. package/dist/js/study002.js +366 -0
  137. package/dist/js/study003.js +331 -0
  138. package/dist/js/study004.js +389 -0
  139. package/dist/js/study005.js +209 -0
  140. package/dist/js/study006.js +194 -0
  141. package/dist/js/study007.js +192 -0
  142. package/dist/js/study008.js +413 -0
  143. package/dist/js/svgtween.js +204 -0
  144. package/dist/js/tde/accretiondisk.js +471 -0
  145. package/dist/js/tde/blackhole.js +219 -0
  146. package/dist/js/tde/blackholescene.js +209 -0
  147. package/dist/js/tde/config.js +59 -0
  148. package/dist/js/tde/index.js +820 -0
  149. package/dist/js/tde/jets.js +290 -0
  150. package/dist/js/tde/lensedstarfield.js +154 -0
  151. package/dist/js/tde/tdestar.js +297 -0
  152. package/dist/js/tde/tidalstream.js +372 -0
  153. package/dist/js/tde_old/blackhole.obj.js +354 -0
  154. package/dist/js/tde_old/debris.obj.js +791 -0
  155. package/dist/js/tde_old/flare.obj.js +239 -0
  156. package/dist/js/tde_old/index.js +448 -0
  157. package/dist/js/tde_old/star.obj.js +812 -0
  158. package/dist/js/tetris/config.js +157 -0
  159. package/dist/js/tetris/grid.js +286 -0
  160. package/dist/js/tetris/index.js +1195 -0
  161. package/dist/js/tetris/renderer.js +634 -0
  162. package/dist/js/tetris/tetrominos.js +280 -0
  163. package/dist/js/thomas.js +394 -0
  164. package/dist/js/tiles.js +312 -0
  165. package/dist/js/tweendemo.js +79 -0
  166. package/dist/js/visibility.js +102 -0
  167. package/dist/kerr.html +28 -0
  168. package/dist/lavalamp.html +27 -0
  169. package/dist/layouts.html +37 -0
  170. package/dist/logo.svg +4 -0
  171. package/dist/loop.html +84 -0
  172. package/dist/lorenz.html +27 -0
  173. package/dist/mondrian.html +32 -0
  174. package/dist/og_image.png +0 -0
  175. package/dist/opacity.html +36 -0
  176. package/dist/painter.html +39 -0
  177. package/dist/particles-showcase.html +28 -0
  178. package/dist/particles.html +24 -0
  179. package/dist/patterns.html +33 -0
  180. package/dist/penrose-game.html +31 -0
  181. package/dist/pipeline.html +737 -0
  182. package/dist/plane3d.html +24 -0
  183. package/dist/platformer.html +43 -0
  184. package/dist/rossler.html +27 -0
  185. package/dist/scene-interactivity-test.html +220 -0
  186. package/dist/scene.html +33 -0
  187. package/dist/scenes.html +96 -0
  188. package/dist/schrodinger.html +27 -0
  189. package/dist/schwarzschild.html +27 -0
  190. package/dist/shapes.html +16 -0
  191. package/dist/space.html +85 -0
  192. package/dist/spacetime.html +27 -0
  193. package/dist/sphere3d.html +24 -0
  194. package/dist/sprite.html +18 -0
  195. package/dist/starfaux.html +22 -0
  196. package/dist/study001.html +23 -0
  197. package/dist/study002.html +23 -0
  198. package/dist/study003.html +23 -0
  199. package/dist/study004.html +23 -0
  200. package/dist/study005.html +22 -0
  201. package/dist/study006.html +24 -0
  202. package/dist/study007.html +24 -0
  203. package/dist/study008.html +22 -0
  204. package/dist/svgtween.html +29 -0
  205. package/dist/tde.html +28 -0
  206. package/dist/tetris3d.html +25 -0
  207. package/dist/thomas.html +27 -0
  208. package/dist/tiles.html +28 -0
  209. package/dist/transforms.html +400 -0
  210. package/dist/tween.html +45 -0
  211. package/dist/visibility.html +33 -0
  212. package/package.json +1 -1
  213. package/readme.md +30 -22
  214. package/src/game/objects/go.js +7 -0
  215. package/src/game/objects/index.js +2 -0
  216. package/src/game/objects/isometric-scene.js +53 -3
  217. package/src/game/objects/layoutscene.js +57 -0
  218. package/src/game/objects/mask.js +241 -0
  219. package/src/game/objects/scene.js +19 -0
  220. package/src/game/objects/wrapper.js +14 -2
  221. package/src/game/pipeline.js +17 -0
  222. package/src/game/ui/button.js +101 -16
  223. package/src/game/ui/theme.js +0 -6
  224. package/src/game/ui/togglebutton.js +25 -14
  225. package/src/game/ui/tooltip.js +12 -4
  226. package/src/index.js +3 -0
  227. package/src/io/gesture.js +409 -0
  228. package/src/io/index.js +4 -1
  229. package/src/io/keys.js +9 -1
  230. package/src/io/screen.js +476 -0
  231. package/src/math/attractors.js +664 -0
  232. package/src/math/heat.js +106 -0
  233. package/src/math/index.js +1 -0
  234. package/src/mixins/draggable.js +15 -19
  235. package/src/painter/painter.shapes.js +11 -5
  236. package/src/particle/particle-system.js +165 -1
  237. package/src/physics/index.js +26 -0
  238. package/src/physics/physics-updaters.js +333 -0
  239. package/src/physics/physics.js +375 -0
  240. package/src/shapes/image.js +5 -5
  241. package/src/shapes/index.js +2 -0
  242. package/src/shapes/parallelogram.js +147 -0
  243. package/src/shapes/righttriangle.js +115 -0
  244. package/src/shapes/svg.js +281 -100
  245. package/src/shapes/text.js +22 -6
  246. package/src/shapes/transformable.js +5 -0
  247. package/src/sound/effects.js +807 -0
  248. package/src/sound/index.js +13 -0
  249. package/src/webgl/index.js +7 -0
  250. package/src/webgl/shaders/clifford-point-shaders.js +131 -0
  251. package/src/webgl/shaders/dejong-point-shaders.js +131 -0
  252. package/src/webgl/shaders/point-sprite-shaders.js +152 -0
  253. package/src/webgl/webgl-clifford-renderer.js +477 -0
  254. package/src/webgl/webgl-dejong-renderer.js +472 -0
  255. package/src/webgl/webgl-line-renderer.js +391 -0
  256. package/src/webgl/webgl-particle-renderer.js +410 -0
  257. package/types/index.d.ts +30 -2
  258. package/types/io.d.ts +217 -0
  259. package/types/physics.d.ts +299 -0
  260. package/types/shapes.d.ts +8 -0
  261. package/types/webgl.d.ts +188 -109
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Tetromino piece definitions and TetrisPiece class
3
+ */
4
+
5
+ import { CONFIG, SHAPES, PIECE_TYPES } from "./config.js";
6
+
7
+ /**
8
+ * TetrisPiece - Represents a falling tetromino in 3D space
9
+ *
10
+ * Uses a voxel-based representation for true 3D rotation.
11
+ * Pieces can rotate around X, Y, and Z axes.
12
+ */
13
+ export class TetrisPiece {
14
+ /**
15
+ * Create a new tetromino piece
16
+ * @param {string} type - Piece type (I, O, T, S, Z, L, J)
17
+ */
18
+ constructor(type) {
19
+ this.type = type;
20
+ this.color = SHAPES[type].color;
21
+
22
+ // Convert 2D matrix to 3D voxels (relative positions)
23
+ this.voxels = this._matrixToVoxels(SHAPES[type].matrix);
24
+
25
+ // Position in grid coordinates
26
+ // Y = 0 is top of well, Y increases downward
27
+ this.x = 0;
28
+ this.y = 0;
29
+ this.z = 0;
30
+
31
+ // Center the piece horizontally in the well
32
+ this._centerPiece();
33
+ }
34
+
35
+ /**
36
+ * Convert 2D matrix to 3D voxel array
37
+ * @param {number[][]} matrix - 2D shape matrix
38
+ * @returns {Array<{x: number, y: number, z: number}>}
39
+ * @private
40
+ */
41
+ _matrixToVoxels(matrix) {
42
+ const voxels = [];
43
+ for (let z = 0; z < matrix.length; z++) {
44
+ for (let x = 0; x < matrix[z].length; x++) {
45
+ if (matrix[z][x]) {
46
+ voxels.push({ x, y: 0, z }); // y=0 since pieces start flat
47
+ }
48
+ }
49
+ }
50
+ return voxels;
51
+ }
52
+
53
+ /**
54
+ * Center the piece at the top of the well
55
+ * @private
56
+ */
57
+ _centerPiece() {
58
+ const { width, depth } = CONFIG.grid;
59
+ const bounds = this.getBounds();
60
+
61
+ this.x = Math.floor((width - bounds.width) / 2);
62
+ this.z = Math.floor((depth - bounds.depth) / 2);
63
+ this.y = 0; // Start at top
64
+ }
65
+
66
+ /**
67
+ * Get bounding box dimensions of the piece
68
+ * @returns {{width: number, height: number, depth: number}}
69
+ */
70
+ getBounds() {
71
+ if (this.voxels.length === 0) {
72
+ return { width: 0, height: 0, depth: 0 };
73
+ }
74
+ return {
75
+ width: Math.max(...this.voxels.map((v) => v.x)) + 1,
76
+ height: Math.max(...this.voxels.map((v) => v.y)) + 1,
77
+ depth: Math.max(...this.voxels.map((v) => v.z)) + 1,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Get all world positions occupied by this piece
83
+ * @returns {Array<{x: number, y: number, z: number}>}
84
+ */
85
+ getWorldPositions() {
86
+ return this.voxels.map((v) => ({
87
+ x: this.x + v.x,
88
+ y: this.y + v.y,
89
+ z: this.z + v.z,
90
+ }));
91
+ }
92
+
93
+ /**
94
+ * Move the piece by the given offset
95
+ * @param {number} dx - X offset
96
+ * @param {number} dy - Y offset (positive = down)
97
+ * @param {number} dz - Z offset
98
+ */
99
+ move(dx, dy, dz) {
100
+ this.x += dx;
101
+ this.y += dy;
102
+ this.z += dz;
103
+ }
104
+
105
+ /**
106
+ * Rotate the piece around the specified axis
107
+ * @param {string} axis - 'x', 'y', or 'z'
108
+ * @param {number} direction - 1 for clockwise, -1 for counter-clockwise
109
+ */
110
+ rotate(axis = "y", direction = 1) {
111
+ // O piece doesn't rotate
112
+ if (this.type === "O") return;
113
+
114
+ this.voxels = this.voxels.map((v) => {
115
+ switch (axis) {
116
+ case "y": // Horizontal rotation (around Y axis)
117
+ return direction === 1
118
+ ? { x: -v.z, y: v.y, z: v.x }
119
+ : { x: v.z, y: v.y, z: -v.x };
120
+ case "x": // Pitch rotation (around X axis)
121
+ return direction === 1
122
+ ? { x: v.x, y: -v.z, z: v.y }
123
+ : { x: v.x, y: v.z, z: -v.y };
124
+ case "z": // Roll rotation (around Z axis)
125
+ return direction === 1
126
+ ? { x: -v.y, y: v.x, z: v.z }
127
+ : { x: v.y, y: -v.x, z: v.z };
128
+ default:
129
+ return v;
130
+ }
131
+ });
132
+
133
+ // Normalize to keep piece grounded (min coords = 0)
134
+ this._normalizeVoxels();
135
+ }
136
+
137
+ /**
138
+ * Normalize voxels so minimum x/y/z is 0
139
+ * This keeps the piece properly bounded after rotation
140
+ * @private
141
+ */
142
+ _normalizeVoxels() {
143
+ if (this.voxels.length === 0) return;
144
+
145
+ const minX = Math.min(...this.voxels.map((v) => v.x));
146
+ const minY = Math.min(...this.voxels.map((v) => v.y));
147
+ const minZ = Math.min(...this.voxels.map((v) => v.z));
148
+
149
+ this.voxels = this.voxels.map((v) => ({
150
+ x: v.x - minX,
151
+ y: v.y - minY,
152
+ z: v.z - minZ,
153
+ }));
154
+ }
155
+
156
+ /**
157
+ * Undo a rotation (for wall kick failure)
158
+ * @param {string} axis - 'x', 'y', or 'z'
159
+ * @param {number} direction - Original rotation direction to undo
160
+ */
161
+ undoRotate(axis = "y", direction = 1) {
162
+ this.rotate(axis, -direction);
163
+ }
164
+
165
+ /**
166
+ * Get the width of the piece (X dimension)
167
+ * @returns {number}
168
+ */
169
+ getWidth() {
170
+ return this.getBounds().width;
171
+ }
172
+
173
+ /**
174
+ * Get the depth of the piece (Z dimension)
175
+ * @returns {number}
176
+ */
177
+ getDepth() {
178
+ return this.getBounds().depth;
179
+ }
180
+
181
+ /**
182
+ * Clone this piece (for ghost piece calculation)
183
+ * @returns {TetrisPiece}
184
+ */
185
+ clone() {
186
+ const cloned = new TetrisPiece(this.type);
187
+ cloned.voxels = this.voxels.map((v) => ({ ...v }));
188
+ cloned.x = this.x;
189
+ cloned.y = this.y;
190
+ cloned.z = this.z;
191
+ return cloned;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Bag randomizer for fair piece distribution
197
+ * Uses the "7-bag" system where each bag contains all 7 pieces
198
+ */
199
+ class PieceBag {
200
+ constructor() {
201
+ this.bag = [];
202
+ this._refillBag();
203
+ }
204
+
205
+ /**
206
+ * Refill the bag with all piece types, shuffled
207
+ * @private
208
+ */
209
+ _refillBag() {
210
+ this.bag = [...PIECE_TYPES];
211
+ // Fisher-Yates shuffle
212
+ for (let i = this.bag.length - 1; i > 0; i--) {
213
+ const j = Math.floor(Math.random() * (i + 1));
214
+ [this.bag[i], this.bag[j]] = [this.bag[j], this.bag[i]];
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Get the next piece type from the bag
220
+ * @returns {string}
221
+ */
222
+ next() {
223
+ if (this.bag.length === 0) {
224
+ this._refillBag();
225
+ }
226
+ return this.bag.pop();
227
+ }
228
+
229
+ /**
230
+ * Peek at the next piece without removing it
231
+ * @returns {string}
232
+ */
233
+ peek() {
234
+ if (this.bag.length === 0) {
235
+ this._refillBag();
236
+ }
237
+ return this.bag[this.bag.length - 1];
238
+ }
239
+ }
240
+
241
+ // Global piece bag instance
242
+ let pieceBag = null;
243
+
244
+ /**
245
+ * Get a random piece using the bag randomizer
246
+ * @returns {TetrisPiece}
247
+ */
248
+ export function getRandomPiece() {
249
+ if (!pieceBag) {
250
+ pieceBag = new PieceBag();
251
+ }
252
+ return new TetrisPiece(pieceBag.next());
253
+ }
254
+
255
+ /**
256
+ * Peek at the next piece type without consuming it
257
+ * @returns {string}
258
+ */
259
+ export function peekNextPieceType() {
260
+ if (!pieceBag) {
261
+ pieceBag = new PieceBag();
262
+ }
263
+ return pieceBag.peek();
264
+ }
265
+
266
+ /**
267
+ * Reset the piece bag (for game restart)
268
+ */
269
+ export function resetPieceBag() {
270
+ pieceBag = new PieceBag();
271
+ }
272
+
273
+ /**
274
+ * Create a specific piece type (for preview/testing)
275
+ * @param {string} type - Piece type
276
+ * @returns {TetrisPiece}
277
+ */
278
+ export function createPiece(type) {
279
+ return new TetrisPiece(type);
280
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Thomas Attractor 3D Visualization
3
+ *
4
+ * Thomas' Cyclically Symmetric Attractor (1999) discovered by René Thomas.
5
+ * Features elegant symmetry and smooth cyclical motion with a simple
6
+ * sinusoidal structure.
7
+ *
8
+ * Uses the Attractors module for pure math functions and WebGL for
9
+ * high-performance line rendering.
10
+ */
11
+
12
+ import { Game, Gesture, Screen, Attractors } from "/gcanvas.es.min.js";
13
+ import { Camera3D } from "/gcanvas.es.min.js";
14
+ import { WebGLLineRenderer } from "/gcanvas.es.min.js";
15
+
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // CONFIGURATION
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+
20
+ const CONFIG = {
21
+ // Attractor settings (uses Attractors.thomas for equations)
22
+ attractor: {
23
+ dt: 0.08, // Thomas needs larger dt
24
+ scale: 60, // Scale factor for display
25
+ },
26
+
27
+ // Particle settings
28
+ particles: {
29
+ count: 300,
30
+ trailLength: 300,
31
+ spawnRange: 2, // Initial position range
32
+ },
33
+
34
+ // Center offset - adjust to match attractor's visual barycenter
35
+ center: {
36
+ x: -0.2,
37
+ y: -0.2,
38
+ z: 0,
39
+ },
40
+
41
+ // Camera settings
42
+ camera: {
43
+ perspective: 800,
44
+ rotationX: 0.3,
45
+ rotationY: 0.2,
46
+ inertia: true,
47
+ friction: 0.95,
48
+ clampX: false,
49
+ },
50
+
51
+ // Visual settings - green/teal palette for Thomas
52
+ visual: {
53
+ minHue: 120, // Green (fast)
54
+ maxHue: 200, // Cyan-blue (slow)
55
+ maxSpeed: 2.5, // Thomas is slow-moving
56
+ saturation: 85,
57
+ lightness: 50,
58
+ maxAlpha: 0.8,
59
+ hueShiftSpeed: 8,
60
+ },
61
+
62
+ // Glitch/blink effect
63
+ blink: {
64
+ chance: 0.012,
65
+ minDuration: 0.06,
66
+ maxDuration: 0.25,
67
+ intensityBoost: 1.4,
68
+ saturationBoost: 1.15,
69
+ alphaBoost: 1.2,
70
+ },
71
+
72
+ // Zoom settings
73
+ zoom: {
74
+ min: 0.3,
75
+ max: 3.0,
76
+ speed: 0.5,
77
+ easing: 0.12,
78
+ baseScreenSize: 600,
79
+ },
80
+ };
81
+
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // HELPER FUNCTIONS
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+
86
+ function hslToRgb(h, s, l) {
87
+ s /= 100;
88
+ l /= 100;
89
+ const k = (n) => (n + h / 30) % 12;
90
+ const a = s * Math.min(l, 1 - l);
91
+ const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
92
+ return {
93
+ r: Math.round(255 * f(0)),
94
+ g: Math.round(255 * f(8)),
95
+ b: Math.round(255 * f(4)),
96
+ };
97
+ }
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // ATTRACTOR PARTICLE
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ class AttractorParticle {
104
+ constructor(stepFn, spawnRange) {
105
+ this.stepFn = stepFn;
106
+ this.position = {
107
+ x: (Math.random() - 0.5) * spawnRange,
108
+ y: (Math.random() - 0.5) * spawnRange,
109
+ z: (Math.random() - 0.5) * spawnRange,
110
+ };
111
+ this.trail = [];
112
+ this.speed = 0;
113
+ this.blinkTime = 0;
114
+ this.blinkIntensity = 0;
115
+ }
116
+
117
+ updateBlink(dt) {
118
+ const { chance, minDuration, maxDuration } = CONFIG.blink;
119
+
120
+ if (this.blinkTime > 0) {
121
+ this.blinkTime -= dt;
122
+ this.blinkIntensity = Math.max(
123
+ 0,
124
+ this.blinkTime > 0
125
+ ? Math.sin((this.blinkTime / ((minDuration + maxDuration) * 0.5)) * Math.PI)
126
+ : 0
127
+ );
128
+ } else {
129
+ if (Math.random() < chance) {
130
+ this.blinkTime = minDuration + Math.random() * (maxDuration - minDuration);
131
+ this.blinkIntensity = 1;
132
+ } else {
133
+ this.blinkIntensity = 0;
134
+ }
135
+ }
136
+ }
137
+
138
+ update(dt, scale) {
139
+ const result = this.stepFn(this.position, dt);
140
+ this.position = result.position;
141
+ this.speed = result.speed;
142
+
143
+ // Add to trail (centered and scaled for display)
144
+ this.trail.unshift({
145
+ x: (this.position.x - CONFIG.center.x) * scale,
146
+ y: (this.position.y - CONFIG.center.y) * scale,
147
+ z: (this.position.z - CONFIG.center.z) * scale,
148
+ speed: this.speed,
149
+ });
150
+
151
+ if (this.trail.length > CONFIG.particles.trailLength) {
152
+ this.trail.pop();
153
+ }
154
+ }
155
+ }
156
+
157
+ // ─────────────────────────────────────────────────────────────────────────────
158
+ // DEMO CLASS
159
+ // ─────────────────────────────────────────────────────────────────────────────
160
+
161
+ class ThomasDemo extends Game {
162
+ constructor(canvas) {
163
+ super(canvas);
164
+ this.backgroundColor = "#000";
165
+ this.enableFluidSize();
166
+ }
167
+
168
+ init() {
169
+ super.init();
170
+
171
+ this.attractor = Attractors.thomas;
172
+ console.log(`Attractor: ${this.attractor.name}`);
173
+ console.log(`Equations:`, this.attractor.equations);
174
+
175
+ this.stepFn = this.attractor.createStepper();
176
+
177
+ const { min, max, baseScreenSize } = CONFIG.zoom;
178
+ const initialZoom = Math.min(max, Math.max(min, Screen.minDimension() / baseScreenSize));
179
+ this.zoom = initialZoom;
180
+ this.targetZoom = initialZoom;
181
+ this.defaultZoom = initialZoom;
182
+
183
+ this.camera = new Camera3D({
184
+ perspective: CONFIG.camera.perspective,
185
+ rotationX: CONFIG.camera.rotationX,
186
+ rotationY: CONFIG.camera.rotationY,
187
+ inertia: CONFIG.camera.inertia,
188
+ friction: CONFIG.camera.friction,
189
+ clampX: CONFIG.camera.clampX,
190
+ });
191
+ this.camera.enableMouseControl(this.canvas);
192
+
193
+ this.gesture = new Gesture(this.canvas, {
194
+ onZoom: (delta) => {
195
+ this.targetZoom *= 1 + delta * CONFIG.zoom.speed;
196
+ },
197
+ onPan: null,
198
+ });
199
+
200
+ this.canvas.addEventListener("dblclick", () => {
201
+ this.targetZoom = this.defaultZoom;
202
+ });
203
+
204
+ // Log camera params and barycenter on mouse release
205
+ this.canvas.addEventListener("mouseup", () => {
206
+ console.log(`Camera: rotationX: ${this.camera.rotationX.toFixed(3)}, rotationY: ${this.camera.rotationY.toFixed(3)}`);
207
+ let sumX = 0, sumY = 0, sumZ = 0, count = 0;
208
+ for (const p of this.particles) {
209
+ sumX += p.position.x;
210
+ sumY += p.position.y;
211
+ sumZ += p.position.z;
212
+ count++;
213
+ }
214
+ console.log(`Barycenter: x: ${(sumX/count).toFixed(3)}, y: ${(sumY/count).toFixed(3)}, z: ${(sumZ/count).toFixed(3)}`);
215
+ });
216
+
217
+ this.particles = [];
218
+ for (let i = 0; i < CONFIG.particles.count; i++) {
219
+ this.particles.push(
220
+ new AttractorParticle(this.stepFn, CONFIG.particles.spawnRange)
221
+ );
222
+ }
223
+
224
+ const maxSegments = CONFIG.particles.count * CONFIG.particles.trailLength;
225
+ this.lineRenderer = new WebGLLineRenderer(maxSegments, {
226
+ width: this.width,
227
+ height: this.height,
228
+ blendMode: "additive",
229
+ });
230
+
231
+ this.segments = [];
232
+
233
+ if (!this.lineRenderer.isAvailable()) {
234
+ console.warn("WebGL not available, falling back to Canvas 2D");
235
+ this.useWebGL = false;
236
+ } else {
237
+ this.useWebGL = true;
238
+ console.log(`WebGL enabled, ${maxSegments} max segments`);
239
+ }
240
+
241
+ this.time = 0;
242
+ }
243
+
244
+ onResize() {
245
+ if (this.lineRenderer?.isAvailable()) {
246
+ this.lineRenderer.resize(this.width, this.height);
247
+ }
248
+ const { min, max, baseScreenSize } = CONFIG.zoom;
249
+ this.defaultZoom = Math.min(max, Math.max(min, Screen.minDimension() / baseScreenSize));
250
+ }
251
+
252
+ update(dt) {
253
+ super.update(dt);
254
+ this.camera.update(dt);
255
+ this.zoom += (this.targetZoom - this.zoom) * CONFIG.zoom.easing;
256
+ this.time += dt;
257
+
258
+ for (const particle of this.particles) {
259
+ particle.update(CONFIG.attractor.dt, CONFIG.attractor.scale);
260
+ particle.updateBlink(dt);
261
+ }
262
+ }
263
+
264
+ collectSegments(cx, cy) {
265
+ const { minHue, maxHue, maxSpeed, saturation, lightness, maxAlpha, hueShiftSpeed } =
266
+ CONFIG.visual;
267
+ const { intensityBoost, saturationBoost, alphaBoost } = CONFIG.blink;
268
+ const hueOffset = (this.time * hueShiftSpeed) % 360;
269
+
270
+ this.segments.length = 0;
271
+
272
+ for (const particle of this.particles) {
273
+ if (particle.trail.length < 2) continue;
274
+
275
+ const blink = particle.blinkIntensity;
276
+
277
+ for (let i = 1; i < particle.trail.length; i++) {
278
+ const curr = particle.trail[i];
279
+ const prev = particle.trail[i - 1];
280
+
281
+ const p1 = this.camera.project(prev.x, prev.y, prev.z);
282
+ const p2 = this.camera.project(curr.x, curr.y, curr.z);
283
+
284
+ if (p1.scale <= 0 || p2.scale <= 0) continue;
285
+
286
+ const age = i / particle.trail.length;
287
+ const speedNorm = Math.min(curr.speed / maxSpeed, 1);
288
+ const baseHue = maxHue - speedNorm * (maxHue - minHue);
289
+ const hue = (baseHue + hueOffset) % 360;
290
+
291
+ const sat = Math.min(100, saturation * (1 + blink * (saturationBoost - 1)));
292
+ const lit = Math.min(100, lightness * (1 + blink * (intensityBoost - 1)));
293
+ const rgb = hslToRgb(hue, sat, lit);
294
+ const alpha = Math.min(1, (1 - age) * maxAlpha * (1 + blink * (alphaBoost - 1)));
295
+
296
+ this.segments.push({
297
+ x1: cx + p1.x * this.zoom,
298
+ y1: cy + p1.y * this.zoom,
299
+ x2: cx + p2.x * this.zoom,
300
+ y2: cy + p2.y * this.zoom,
301
+ r: rgb.r,
302
+ g: rgb.g,
303
+ b: rgb.b,
304
+ a: alpha,
305
+ });
306
+ }
307
+ }
308
+
309
+ return this.segments.length;
310
+ }
311
+
312
+ renderCanvas2D(cx, cy) {
313
+ const { minHue, maxHue, maxSpeed, saturation, lightness, maxAlpha, hueShiftSpeed } =
314
+ CONFIG.visual;
315
+ const { intensityBoost, saturationBoost, alphaBoost } = CONFIG.blink;
316
+ const hueOffset = (this.time * hueShiftSpeed) % 360;
317
+
318
+ const ctx = this.ctx;
319
+ ctx.save();
320
+ ctx.globalCompositeOperation = "lighter";
321
+ ctx.lineCap = "round";
322
+
323
+ for (const particle of this.particles) {
324
+ if (particle.trail.length < 2) continue;
325
+
326
+ const blink = particle.blinkIntensity;
327
+
328
+ for (let i = 1; i < particle.trail.length; i++) {
329
+ const curr = particle.trail[i];
330
+ const prev = particle.trail[i - 1];
331
+
332
+ const p1 = this.camera.project(prev.x, prev.y, prev.z);
333
+ const p2 = this.camera.project(curr.x, curr.y, curr.z);
334
+
335
+ if (p1.scale <= 0 || p2.scale <= 0) continue;
336
+
337
+ const age = i / particle.trail.length;
338
+ const speedNorm = Math.min(curr.speed / maxSpeed, 1);
339
+ const baseHue = maxHue - speedNorm * (maxHue - minHue);
340
+ const hue = (baseHue + hueOffset) % 360;
341
+
342
+ const sat = Math.min(100, saturation * (1 + blink * (saturationBoost - 1)));
343
+ const lit = Math.min(100, lightness * (1 + blink * (intensityBoost - 1)));
344
+ const alpha = Math.min(1, (1 - age) * maxAlpha * (1 + blink * (alphaBoost - 1)));
345
+
346
+ ctx.strokeStyle = `hsla(${hue}, ${sat}%, ${lit}%, ${alpha})`;
347
+ ctx.lineWidth = 1;
348
+
349
+ ctx.beginPath();
350
+ ctx.moveTo(cx + p1.x * this.zoom, cy + p1.y * this.zoom);
351
+ ctx.lineTo(cx + p2.x * this.zoom, cy + p2.y * this.zoom);
352
+ ctx.stroke();
353
+ }
354
+ }
355
+
356
+ ctx.restore();
357
+ }
358
+
359
+ render() {
360
+ super.render();
361
+ if (!this.particles) return;
362
+
363
+ const cx = this.width / 2;
364
+ const cy = this.height / 2;
365
+
366
+ if (this.useWebGL && this.lineRenderer.isAvailable()) {
367
+ const segmentCount = this.collectSegments(cx, cy);
368
+ if (segmentCount > 0) {
369
+ this.lineRenderer.clear();
370
+ this.lineRenderer.updateLines(this.segments);
371
+ this.lineRenderer.render(segmentCount);
372
+ this.lineRenderer.compositeOnto(this.ctx, 0, 0);
373
+ }
374
+ } else {
375
+ this.renderCanvas2D(cx, cy);
376
+ }
377
+ }
378
+
379
+ destroy() {
380
+ this.gesture?.destroy();
381
+ this.lineRenderer?.destroy();
382
+ super.destroy?.();
383
+ }
384
+ }
385
+
386
+ // ─────────────────────────────────────────────────────────────────────────────
387
+ // INITIALIZATION
388
+ // ─────────────────────────────────────────────────────────────────────────────
389
+
390
+ window.addEventListener("load", () => {
391
+ const canvas = document.getElementById("game");
392
+ const demo = new ThomasDemo(canvas);
393
+ demo.start();
394
+ });