@guinetik/gcanvas 1.0.4 → 1.0.5

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 (193) hide show
  1. package/dist/CNAME +1 -0
  2. package/dist/animations.html +31 -0
  3. package/dist/basic.html +38 -0
  4. package/dist/baskara.html +31 -0
  5. package/dist/bezier.html +35 -0
  6. package/dist/beziersignature.html +29 -0
  7. package/dist/blackhole.html +28 -0
  8. package/dist/blob.html +35 -0
  9. package/dist/coordinates.html +698 -0
  10. package/dist/cube3d.html +23 -0
  11. package/dist/demos.css +303 -0
  12. package/dist/dino.html +42 -0
  13. package/dist/easing.html +28 -0
  14. package/dist/events.html +195 -0
  15. package/dist/fluent.html +647 -0
  16. package/dist/fluid-simple.html +22 -0
  17. package/dist/fluid.html +37 -0
  18. package/dist/fractals.html +36 -0
  19. package/dist/gameobjects.html +626 -0
  20. package/dist/gcanvas.es.js +517 -0
  21. package/dist/gcanvas.es.min.js +1 -1
  22. package/dist/gcanvas.umd.js +1 -1
  23. package/dist/gcanvas.umd.min.js +1 -1
  24. package/dist/genart.html +26 -0
  25. package/dist/gendream.html +26 -0
  26. package/dist/group.html +36 -0
  27. package/dist/home.html +587 -0
  28. package/dist/hyperbolic001.html +23 -0
  29. package/dist/hyperbolic002.html +23 -0
  30. package/dist/hyperbolic003.html +23 -0
  31. package/dist/hyperbolic004.html +23 -0
  32. package/dist/hyperbolic005.html +22 -0
  33. package/dist/index.html +398 -0
  34. package/dist/isometric.html +34 -0
  35. package/dist/js/animations.js +452 -0
  36. package/dist/js/basic.js +204 -0
  37. package/dist/js/baskara.js +751 -0
  38. package/dist/js/bezier.js +692 -0
  39. package/dist/js/beziersignature.js +241 -0
  40. package/dist/js/blackhole/accretiondisk.obj.js +379 -0
  41. package/dist/js/blackhole/blackhole.obj.js +318 -0
  42. package/dist/js/blackhole/index.js +409 -0
  43. package/dist/js/blackhole/particle.js +56 -0
  44. package/dist/js/blackhole/starfield.obj.js +218 -0
  45. package/dist/js/blob.js +2276 -0
  46. package/dist/js/coordinates.js +840 -0
  47. package/dist/js/cube3d.js +789 -0
  48. package/dist/js/dino.js +1420 -0
  49. package/dist/js/easing.js +477 -0
  50. package/dist/js/fluent.js +183 -0
  51. package/dist/js/fluid-simple.js +253 -0
  52. package/dist/js/fluid.js +527 -0
  53. package/dist/js/fractals.js +932 -0
  54. package/dist/js/fractalworker.js +93 -0
  55. package/dist/js/gameobjects.js +176 -0
  56. package/dist/js/genart.js +268 -0
  57. package/dist/js/gendream.js +209 -0
  58. package/dist/js/group.js +140 -0
  59. package/dist/js/hyperbolic001.js +310 -0
  60. package/dist/js/hyperbolic002.js +388 -0
  61. package/dist/js/hyperbolic003.js +319 -0
  62. package/dist/js/hyperbolic004.js +345 -0
  63. package/dist/js/hyperbolic005.js +340 -0
  64. package/dist/js/info-toggle.js +25 -0
  65. package/dist/js/isometric.js +863 -0
  66. package/dist/js/kerr.js +1547 -0
  67. package/dist/js/lavalamp.js +590 -0
  68. package/dist/js/layout.js +354 -0
  69. package/dist/js/mondrian.js +285 -0
  70. package/dist/js/opacity.js +275 -0
  71. package/dist/js/painter.js +484 -0
  72. package/dist/js/particles-showcase.js +514 -0
  73. package/dist/js/particles.js +299 -0
  74. package/dist/js/patterns.js +397 -0
  75. package/dist/js/penrose/artifact.js +69 -0
  76. package/dist/js/penrose/blackhole.js +121 -0
  77. package/dist/js/penrose/constants.js +73 -0
  78. package/dist/js/penrose/game.js +943 -0
  79. package/dist/js/penrose/lore.js +278 -0
  80. package/dist/js/penrose/penrosescene.js +892 -0
  81. package/dist/js/penrose/ship.js +216 -0
  82. package/dist/js/penrose/sounds.js +211 -0
  83. package/dist/js/penrose/voidparticle.js +55 -0
  84. package/dist/js/penrose/voidscene.js +258 -0
  85. package/dist/js/penrose/voidship.js +144 -0
  86. package/dist/js/penrose/wormhole.js +46 -0
  87. package/dist/js/pipeline.js +555 -0
  88. package/dist/js/plane3d.js +256 -0
  89. package/dist/js/platformer.js +1579 -0
  90. package/dist/js/scene.js +304 -0
  91. package/dist/js/scenes.js +320 -0
  92. package/dist/js/schrodinger.js +410 -0
  93. package/dist/js/schwarzschild.js +1015 -0
  94. package/dist/js/shapes.js +628 -0
  95. package/dist/js/space/alien.js +171 -0
  96. package/dist/js/space/boom.js +98 -0
  97. package/dist/js/space/boss.js +353 -0
  98. package/dist/js/space/buff.js +73 -0
  99. package/dist/js/space/bullet.js +102 -0
  100. package/dist/js/space/constants.js +85 -0
  101. package/dist/js/space/game.js +1884 -0
  102. package/dist/js/space/hud.js +112 -0
  103. package/dist/js/space/laserbeam.js +179 -0
  104. package/dist/js/space/lightning.js +277 -0
  105. package/dist/js/space/minion.js +192 -0
  106. package/dist/js/space/missile.js +212 -0
  107. package/dist/js/space/player.js +430 -0
  108. package/dist/js/space/powerup.js +90 -0
  109. package/dist/js/space/starfield.js +58 -0
  110. package/dist/js/space/starpower.js +90 -0
  111. package/dist/js/spacetime.js +559 -0
  112. package/dist/js/sphere3d.js +229 -0
  113. package/dist/js/sprite.js +473 -0
  114. package/dist/js/starfaux/config.js +118 -0
  115. package/dist/js/starfaux/enemy.js +353 -0
  116. package/dist/js/starfaux/hud.js +78 -0
  117. package/dist/js/starfaux/index.js +482 -0
  118. package/dist/js/starfaux/laser.js +182 -0
  119. package/dist/js/starfaux/player.js +468 -0
  120. package/dist/js/starfaux/terrain.js +560 -0
  121. package/dist/js/study001.js +275 -0
  122. package/dist/js/study002.js +366 -0
  123. package/dist/js/study003.js +331 -0
  124. package/dist/js/study004.js +389 -0
  125. package/dist/js/study005.js +209 -0
  126. package/dist/js/study006.js +194 -0
  127. package/dist/js/study007.js +192 -0
  128. package/dist/js/study008.js +413 -0
  129. package/dist/js/svgtween.js +204 -0
  130. package/dist/js/tde/accretiondisk.js +471 -0
  131. package/dist/js/tde/blackhole.js +219 -0
  132. package/dist/js/tde/blackholescene.js +209 -0
  133. package/dist/js/tde/config.js +59 -0
  134. package/dist/js/tde/index.js +820 -0
  135. package/dist/js/tde/jets.js +290 -0
  136. package/dist/js/tde/lensedstarfield.js +154 -0
  137. package/dist/js/tde/tdestar.js +297 -0
  138. package/dist/js/tde/tidalstream.js +372 -0
  139. package/dist/js/tde_old/blackhole.obj.js +354 -0
  140. package/dist/js/tde_old/debris.obj.js +791 -0
  141. package/dist/js/tde_old/flare.obj.js +239 -0
  142. package/dist/js/tde_old/index.js +448 -0
  143. package/dist/js/tde_old/star.obj.js +812 -0
  144. package/dist/js/tetris/config.js +157 -0
  145. package/dist/js/tetris/grid.js +286 -0
  146. package/dist/js/tetris/index.js +1195 -0
  147. package/dist/js/tetris/renderer.js +634 -0
  148. package/dist/js/tetris/tetrominos.js +280 -0
  149. package/dist/js/tiles.js +312 -0
  150. package/dist/js/tweendemo.js +79 -0
  151. package/dist/js/visibility.js +102 -0
  152. package/dist/kerr.html +28 -0
  153. package/dist/lavalamp.html +27 -0
  154. package/dist/layouts.html +37 -0
  155. package/dist/logo.svg +4 -0
  156. package/dist/loop.html +84 -0
  157. package/dist/mondrian.html +32 -0
  158. package/dist/og_image.png +0 -0
  159. package/dist/opacity.html +36 -0
  160. package/dist/painter.html +39 -0
  161. package/dist/particles-showcase.html +28 -0
  162. package/dist/particles.html +24 -0
  163. package/dist/patterns.html +33 -0
  164. package/dist/penrose-game.html +31 -0
  165. package/dist/pipeline.html +737 -0
  166. package/dist/plane3d.html +24 -0
  167. package/dist/platformer.html +43 -0
  168. package/dist/scene.html +33 -0
  169. package/dist/scenes.html +96 -0
  170. package/dist/schrodinger.html +27 -0
  171. package/dist/schwarzschild.html +27 -0
  172. package/dist/shapes.html +16 -0
  173. package/dist/space.html +85 -0
  174. package/dist/spacetime.html +27 -0
  175. package/dist/sphere3d.html +24 -0
  176. package/dist/sprite.html +18 -0
  177. package/dist/starfaux.html +22 -0
  178. package/dist/study001.html +23 -0
  179. package/dist/study002.html +23 -0
  180. package/dist/study003.html +23 -0
  181. package/dist/study004.html +23 -0
  182. package/dist/study005.html +22 -0
  183. package/dist/study006.html +24 -0
  184. package/dist/study007.html +24 -0
  185. package/dist/study008.html +22 -0
  186. package/dist/svgtween.html +29 -0
  187. package/dist/tde.html +28 -0
  188. package/dist/tetris3d.html +25 -0
  189. package/dist/tiles.html +28 -0
  190. package/dist/transforms.html +400 -0
  191. package/dist/tween.html +45 -0
  192. package/dist/visibility.html +33 -0
  193. package/package.json +1 -1
@@ -0,0 +1,2276 @@
1
+ import {
2
+ BezierShape,
3
+ Button,
4
+ Circle,
5
+ Collision,
6
+ Diamond,
7
+ Easing,
8
+ FPSCounter,
9
+ Game,
10
+ Heart,
11
+ Hexagon,
12
+ HorizontalLayout,
13
+ Motion,
14
+ Painter,
15
+ Position,
16
+ Rectangle,
17
+ Scene,
18
+ ShapeGOFactory,
19
+ Star,
20
+ StateMachine,
21
+ Synth,
22
+ TextShape,
23
+ Tween,
24
+ Tweenetik,
25
+ VerticalLayout,
26
+ } from "/gcanvas.es.min.js";
27
+
28
+ // Game configuration
29
+ const CONFIG = {
30
+ // Blob starting size
31
+ startRadius: 40,
32
+ maxRadius: 120,
33
+ minRadius: 20, // minimum size before death
34
+ growthPerCollect: 3,
35
+
36
+ // Hunger/starvation system
37
+ hungerTime: 3.0, // seconds without eating before hunger starts
38
+ hungerTimeMin: 1.0, // minimum hunger time at max difficulty
39
+ shrinkRate: 5, // pixels per second of shrinking when hungry
40
+ shrinkRateMax: 15, // max shrink rate at max difficulty
41
+ shrinkScorePenalty: 2, // score lost per pixel shrunk
42
+
43
+ // Collectibles
44
+ spawnInterval: 1.5, // seconds between spawns
45
+ minSpawnInterval: 0.4, // minimum spawn interval at max difficulty
46
+ collectibleLifespan: 4.0, // seconds before collectible disappears
47
+ minLifespan: 1.5, // minimum lifespan at max difficulty
48
+ maxCollectibles: 8, // max on screen at once
49
+
50
+ // Scoring
51
+ basePoints: 10,
52
+ multiplierDecay: 0.5, // seconds before multiplier resets
53
+ maxMultiplier: 8,
54
+
55
+ // Difficulty scaling
56
+ difficultyRampTime: 60, // seconds to reach max difficulty
57
+ };
58
+
59
+ /**
60
+ * BezierBlob Game - A playful blob that follows the mouse with Tween animations
61
+ */
62
+ class BezierBlobGame extends Game {
63
+ constructor(canvas) {
64
+ super(canvas);
65
+ this.enableFluidSize();
66
+ this.backgroundColor = "#111122";
67
+ this.debug = false;
68
+ this.hovering = false;
69
+ }
70
+
71
+ /**
72
+ * Check if screen is narrow (mobile width)
73
+ */
74
+ isMobile() {
75
+ return this.width < 600;
76
+ }
77
+
78
+ /**
79
+ * Get responsive configuration based on screen size
80
+ */
81
+ getResponsiveConfig() {
82
+ const isMobile = this.isMobile();
83
+ return {
84
+ buttonWidth: isMobile ? 80 : 100,
85
+ buttonHeight: 32,
86
+ spacing: isMobile ? 5 : 8,
87
+ // Always horizontal at bottom left
88
+ layoutType: "horizontal",
89
+ anchor: Position.BOTTOM_LEFT,
90
+ anchorOffsetX: 10,
91
+ anchorOffsetY: -10,
92
+ };
93
+ }
94
+
95
+ init() {
96
+ super.init();
97
+
98
+ // Initialize audio system
99
+ Synth.init({ masterVolume: 0.3 });
100
+
101
+ this.blobScene = new BlobScene(this);
102
+ this.uiScene = new BlobUIScene(this, this.blobScene, {
103
+ debug: this.debug,
104
+ debugColor: "pink",
105
+ });
106
+ this.pipeline.add(this.blobScene);
107
+ this.pipeline.add(this.uiScene);
108
+ }
109
+
110
+ onResize() {
111
+ if (this.uiScene) {
112
+ this.uiScene.onResize();
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Main scene containing the blob and handling interactions
119
+ */
120
+ class BlobScene extends Scene {
121
+ constructor(game) {
122
+ super(game);
123
+
124
+ // Create a background that will receive mouse events
125
+ this.bg = ShapeGOFactory.create(
126
+ game,
127
+ new Rectangle({
128
+ width: game.width,
129
+ height: game.height,
130
+ debug: this.debug,
131
+ color: "rgba(0, 0, 0, 0)",
132
+ })
133
+ );
134
+ this.add(this.bg);
135
+
136
+ // Mouse position tracking
137
+ this.mouseX = game.width / 2;
138
+ this.mouseY = game.height / 2;
139
+ this.interactive = true;
140
+ // Forward mouse events
141
+ this.game.events.on("inputmove", (e) => {
142
+ this.mouseX = e.x;
143
+ this.mouseY = e.y;
144
+ });
145
+
146
+ // Create the blob
147
+ this.createBlob();
148
+
149
+ // Setup physics properties
150
+ this.blobPhysics = {
151
+ // Target position (will follow mouse with delay)
152
+ targetX: this.mouseX,
153
+ targetY: this.mouseY,
154
+ // Current position of blob center
155
+ currentX: game.width / 2,
156
+ currentY: game.height / 2,
157
+ // Velocity
158
+ vx: 0,
159
+ vy: 0,
160
+ // Physics constants
161
+ springFactor: 0.08, // How strongly it's pulled toward target
162
+ drag: 0.5, // Air resistance/friction
163
+ wobbleAmount: 0.8, // How much the blob wobbles (0-1)
164
+ wobbleSpeed: 8, // Speed of wobble oscillation
165
+ // Animation state
166
+ excitementLevel: 0, // Gets excited with fast mouse movements
167
+ mood: 0, // 0 = normal, 1 = happy, -1 = scared, -2 = very sad
168
+ // Color state
169
+ baseColor: [64, 180, 255], // RGB base color (the "full" color when happy)
170
+ currentColor: [64, 180, 255], // Current RGB color
171
+ // Blob size
172
+ baseRadius: 80, // Normal size
173
+ currentRadius: 80, // Current size
174
+ radiusScale: 0, // Scale
175
+ // Tamagotchi life/energy system
176
+ energy: 1.0, // 0 = dead/black, 1 = fully alive/vibrant
177
+ energyDecayRate: 0.15, // How fast energy drains per second when idle (~7 sec to die)
178
+ energyGainRate: 0.8, // How fast energy increases from movement
179
+ };
180
+
181
+ // State machine for blob lifecycle
182
+ this.stateMachine = new StateMachine({
183
+ initial: "ready",
184
+ context: this,
185
+ states: {
186
+ ready: {
187
+ enter: () => this.enterReadyState(),
188
+ update: (dt) => this.updateReadyState(dt),
189
+ },
190
+ alive: {
191
+ enter: () => this.enterAliveState(),
192
+ update: (dt) => this.updateAliveState(dt),
193
+ },
194
+ falling: {
195
+ enter: () => {
196
+ this.fallVelocity = 0;
197
+ this.fallSquish = 0;
198
+ this.playDeathSound();
199
+ this.stopWobbleSound();
200
+ },
201
+ update: (dt) => this.updateFallingState(dt),
202
+ },
203
+ dead: {
204
+ enter: () => {
205
+ this.setDeadFace();
206
+ },
207
+ update: (dt) => this.updateDeadState(dt),
208
+ },
209
+ },
210
+ });
211
+
212
+ this.bounceHeight = 0; // Will be set on each click
213
+ this.originalRadius = this.blobPhysics.baseRadius;
214
+
215
+ // Fall/death state
216
+ this.fallVelocity = 0;
217
+ this.fallSquish = 0;
218
+
219
+ // === GAME STATE ===
220
+ this.gameState = {
221
+ score: 0,
222
+ multiplier: 1,
223
+ multiplierTimer: 0,
224
+ gameTime: 0,
225
+ spawnTimer: 0,
226
+ collectiblesEaten: 0,
227
+ currentLevel: 1,
228
+ lastEatTime: 0, // Time since last collectible eaten
229
+ isHungry: false, // Whether blob is currently hungry/starving
230
+ };
231
+
232
+ // Collectibles array
233
+ this.collectibles = [];
234
+
235
+ // Shape types for collectibles
236
+ this.shapeTypes = [
237
+ { shape: Star, size: 20, points: 10 },
238
+ { shape: Heart, size: 18, points: 15 },
239
+ { shape: Diamond, size: 16, points: 20 },
240
+ { shape: Hexagon, size: 14, points: 25 },
241
+ ];
242
+
243
+ // Set initial blob size (smaller)
244
+ this.blobPhysics.baseRadius = CONFIG.startRadius;
245
+ this.blobPhysics.currentRadius = CONFIG.startRadius;
246
+ this.blobPhysics.healthyRadius = CONFIG.startRadius; // Track healthy size before hunger effects
247
+
248
+ // Control points around the blob (in polar coordinates for easy animation)
249
+ this.blobPoints = [];
250
+ // Increased to 16 points for more segments and wobbliness
251
+ const numPoints = 16;
252
+
253
+ for (let i = 0; i < numPoints; i++) {
254
+ const angle = (i / numPoints) * Math.PI * 2;
255
+ this.blobPoints.push({
256
+ angle: angle,
257
+ radius: this.blobPhysics.baseRadius, // Base radius
258
+ radiusOffset: 0, // Will be animated
259
+ phaseOffset: i * 0.7, // Different starting phase for each point
260
+ wobbleFrequency: 1 + Math.random() * 0.5, // Slightly different frequencies for each point
261
+ });
262
+ }
263
+
264
+ // Animation timing
265
+ this.time = 0;
266
+
267
+ // Tween animations
268
+ this.animations = {
269
+ gradientShift: {
270
+ name: "gradientShift",
271
+ active: false,
272
+ startColor: 0,
273
+ targetColor: 0,
274
+ duration: 2.5,
275
+ elapsed: 0,
276
+ },
277
+ pulseAnimation: {
278
+ active: false,
279
+ startTime: 0,
280
+ duration: 0.5,
281
+ startRadius: this.blobPhysics.baseRadius,
282
+ targetRadius: this.blobPhysics.baseRadius * 1.2,
283
+ },
284
+ colorAnimation: {
285
+ active: false,
286
+ startTime: 0,
287
+ duration: 1.0,
288
+ },
289
+ bounceAnimation: {
290
+ active: false,
291
+ startTime: 0,
292
+ duration: 0.8,
293
+ },
294
+ };
295
+
296
+ // Blob emotions/states
297
+ this.blobState = {
298
+ excited: false,
299
+ scared: false,
300
+ happy: false,
301
+ };
302
+ this.bg.interactive = true;
303
+ // Background receives input for mouse tracking but no growth on click
304
+ // Growth only happens from collecting items
305
+
306
+ // Add FPS counter
307
+ this.add(
308
+ new FPSCounter(game, {
309
+ anchor: "bottom-right",
310
+ })
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Create the blob using BezierShape
316
+ */
317
+ createBlob() {
318
+ this.blobBounceDeform = 0;
319
+ // Initial simple circle path
320
+ const path = [
321
+ ["M", 50, 0],
322
+ ["C", 50, 27.6, 27.6, 50, 0, 50],
323
+ ["C", -27.6, 50, -50, 27.6, -50, 0],
324
+ ["C", -50, -27.6, -27.6, -50, 0, -50],
325
+ ["C", 27.6, -50, 50, -27.6, 50, 0],
326
+ ["Z"],
327
+ ];
328
+
329
+ // Create BezierShape for the blob
330
+ const blobShape = new BezierShape(path, {
331
+ color: "rgba(80, 200, 255, 0.8)",
332
+ stroke: "rgba(255, 255, 255, 0.8)",
333
+ debug: this.debug,
334
+ width: 100,
335
+ height: 100,
336
+ debugColor: "rgba(255, 0, 0, 0.8)",
337
+ lineWidth: 2,
338
+ });
339
+
340
+ // Create GameObject using the factory
341
+ this.blob = ShapeGOFactory.create(this.game, blobShape);
342
+
343
+ // Add the blob to the scene
344
+ this.add(this.blob);
345
+
346
+ // Create eyes for the blob
347
+ const leftEye = ShapeGOFactory.create(
348
+ this.game,
349
+ new Circle(10, {
350
+ x: -20,
351
+ y: -15,
352
+ color: "white",
353
+ stroke: "rgba(0, 0, 0, 0.5)",
354
+ lineWidth: 1,
355
+ }),
356
+ {
357
+ debug: this.debug,
358
+ debugColor: "white",
359
+ }
360
+ );
361
+
362
+ const rightEye = ShapeGOFactory.create(
363
+ this.game,
364
+ new Circle(10, {
365
+ x: 20,
366
+ y: -15,
367
+ color: "white",
368
+ stroke: "rgba(0, 0, 0, 0.5)",
369
+ lineWidth: 1,
370
+ }),
371
+ {
372
+ debug: this.debug,
373
+ debugColor: "white",
374
+ }
375
+ );
376
+
377
+ // Create pupils
378
+ const leftPupil = ShapeGOFactory.create(
379
+ this.game,
380
+ new Circle(4, {
381
+ x: -20,
382
+ y: -15,
383
+ color: "black",
384
+ }),
385
+ {
386
+ debug: this.debug,
387
+ debugColor: "blue",
388
+ }
389
+ );
390
+
391
+ const rightPupil = ShapeGOFactory.create(
392
+ this.game,
393
+ new Circle(4, {
394
+ x: 20,
395
+ y: -15,
396
+ color: "black",
397
+ }),
398
+ {
399
+ debug: this.debug,
400
+ debugColor: "blue",
401
+ }
402
+ );
403
+
404
+ // Create mouth (initially a small line)
405
+ const mouthShape = new BezierShape(
406
+ [
407
+ ["M", -15, 0],
408
+ ["Q", 0, 5, 15, 0],
409
+ ],
410
+ {
411
+ x: 0,
412
+ y: 10,
413
+ width: 30,
414
+ height: 10,
415
+ stroke: "rgba(0, 0, 0, 0.7)",
416
+ lineWidth: 3,
417
+ color: null,
418
+ }
419
+ );
420
+
421
+ const mouth = ShapeGOFactory.create(this.game, mouthShape, {
422
+ debug: this.debug,
423
+ debugColor: "red",
424
+ });
425
+
426
+ // Add facial features to the scene
427
+ this.add(leftEye);
428
+ this.add(rightEye);
429
+ this.add(leftPupil);
430
+ this.add(rightPupil);
431
+ this.add(mouth);
432
+
433
+ // Store reference to facial features for animation
434
+ this.leftEye = leftEye;
435
+ this.rightEye = rightEye;
436
+ this.leftPupil = leftPupil;
437
+ this.rightPupil = rightPupil;
438
+ this.mouth = mouth;
439
+ }
440
+
441
+ /**
442
+ * Trigger a specific animation
443
+ */
444
+ triggerAnimation(animType) {
445
+ const anim = this.animations[animType + "Animation"];
446
+ if (!anim) return;
447
+
448
+ anim.active = true;
449
+ anim.startTime = this.time;
450
+
451
+ // Handle specific animation setup
452
+ if (animType === "color") {
453
+ // Choose a random hue
454
+ const hue = Math.floor(Math.random() * 360);
455
+ this.targetHue = hue;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Set the blob's mood and update facial features
461
+ * 2 = ecstatic, 1 = happy, 0 = neutral, -1 = sad, -2 = very sad/dying
462
+ */
463
+ setMood(mood) {
464
+ if (this.blobPhysics.mood === mood) return; // No change needed
465
+ this.blobPhysics.mood = mood;
466
+
467
+ // Update mouth shape based on mood
468
+ if (mood >= 2) {
469
+ // Ecstatic - huge open smile
470
+ this.mouth.shape.path = [
471
+ ["M", -30, -5],
472
+ ["Q", 0, 25, 30, -5],
473
+ ];
474
+ this.mouth.shape.stroke = "rgba(0, 0, 0, 0.8)";
475
+ this.mouth.shape.lineWidth = 4;
476
+ } else if (mood === 1) {
477
+ // Happy - big smile
478
+ this.mouth.shape.path = [
479
+ ["M", -25, 0],
480
+ ["Q", 0, 15, 25, 0],
481
+ ];
482
+ this.mouth.shape.stroke = "rgba(0, 0, 0, 0.7)";
483
+ this.mouth.shape.lineWidth = 3;
484
+ } else if (mood === 0) {
485
+ // Neutral - slight curve
486
+ this.mouth.shape.path = [
487
+ ["M", -15, 0],
488
+ ["Q", 0, 5, 15, 0],
489
+ ];
490
+ this.mouth.shape.stroke = "rgba(0, 0, 0, 0.6)";
491
+ this.mouth.shape.lineWidth = 3;
492
+ } else if (mood === -1) {
493
+ // Sad - slight frown
494
+ this.mouth.shape.path = [
495
+ ["M", -15, 5],
496
+ ["Q", 0, -3, 15, 5],
497
+ ];
498
+ this.mouth.shape.stroke = "rgba(0, 0, 0, 0.5)";
499
+ this.mouth.shape.lineWidth = 2;
500
+ } else {
501
+ // Very sad/dying - big frown, droopy
502
+ this.mouth.shape.path = [
503
+ ["M", -20, 8],
504
+ ["Q", 0, -8, 20, 8],
505
+ ];
506
+ this.mouth.shape.stroke = "rgba(0, 0, 0, 0.4)";
507
+ this.mouth.shape.lineWidth = 2;
508
+ }
509
+
510
+ // Update eye size based on mood (happy = bigger eyes, sad = smaller)
511
+ const eyeScale = mood >= 1 ? 1.2 : mood === 0 ? 1.0 : mood === -1 ? 0.9 : 0.7;
512
+ this.leftEye.scaleX = this.leftEye.scaleY = eyeScale;
513
+ this.rightEye.scaleX = this.rightEye.scaleY = eyeScale;
514
+
515
+ // Pupils also scale
516
+ const pupilScale = mood >= 1 ? 1.1 : mood <= -1 ? 0.8 : 1.0;
517
+ this.leftPupil.scaleX = this.leftPupil.scaleY = pupilScale;
518
+ this.rightPupil.scaleX = this.rightPupil.scaleY = pupilScale;
519
+ }
520
+
521
+ /**
522
+ * Update mood based on energy level and hunger
523
+ */
524
+ updateMoodFromEnergy() {
525
+ const energy = this.blobPhysics.energy;
526
+ const excitement = this.blobPhysics.excitementLevel;
527
+ const isHungry = this.gameState.isHungry;
528
+
529
+ let newMood;
530
+
531
+ // Dying always takes priority
532
+ if (energy <= 0.15) {
533
+ newMood = -2; // Very sad/dying when almost no energy
534
+ } else if (isHungry) {
535
+ // Hunger makes blob sad - sadder the longer it's hungry
536
+ const diff = this.getDifficulty();
537
+ const hungerThreshold = CONFIG.hungerTime -
538
+ (CONFIG.hungerTime - CONFIG.hungerTimeMin) * diff;
539
+ const hungerDuration = this.gameState.lastEatTime - hungerThreshold;
540
+ newMood = hungerDuration > 1.5 ? -2 : -1; // Very sad if hungry for long
541
+ } else if (excitement > 0.7 && energy > 0.5) {
542
+ newMood = 2; // Ecstatic when very excited and has energy
543
+ } else if (energy > 0.7) {
544
+ newMood = 1; // Happy when energy is high
545
+ } else if (energy > 0.4) {
546
+ newMood = 0; // Neutral
547
+ } else {
548
+ newMood = -1; // Sad when energy is low
549
+ }
550
+
551
+ this.setMood(newMood);
552
+ }
553
+
554
+ /**
555
+ * Update the scene
556
+ */
557
+ update(dt) {
558
+ // Update background size
559
+ this.bg.width = this.game.width;
560
+ this.bg.height = this.game.height;
561
+ this.bg.x = this.game.width / 2;
562
+ this.bg.y = this.game.height / 2;
563
+ // Update time
564
+ this.time += dt;
565
+ // Process animations
566
+ this.updateAnimations(dt);
567
+ // Update Tweenetik animations (for flash effects, etc.)
568
+ Tweenetik.updateAll(dt);
569
+ // Update state machine
570
+ this.stateMachine.update(dt);
571
+ super.update(dt);
572
+ }
573
+
574
+ /**
575
+ * Update when blob is alive - follows mouse, has energy system
576
+ */
577
+ updateAliveState(dt) {
578
+ const physics = this.blobPhysics;
579
+
580
+ // Update game time
581
+ this.gameState.gameTime += dt;
582
+
583
+ // Check for level up - Level N requires N scales (8 notes each)
584
+ // Level 1: 8 notes, Level 2: 16 more, Level 3: 24 more, etc.
585
+ // Total notes to complete level N = 8 * (1+2+...+N) = 4*N*(N+1)
586
+ const popCount = this._popNoteIndex || 0;
587
+ const newLevel = this.getLevelFromPops(popCount);
588
+ if (newLevel > this.gameState.currentLevel) {
589
+ this.gameState.currentLevel = newLevel;
590
+ this.playStartSound(); // Play level-up melody
591
+ this.showFloatingText(`LEVEL ${newLevel}!`, this.game.width / 2, this.game.height / 2 - 50);
592
+ }
593
+
594
+ // Calculate spring force toward target (mouse position)
595
+ const dx = this.mouseX - physics.currentX;
596
+ const dy = this.mouseY - physics.currentY;
597
+ // Apply spring force to velocity
598
+ physics.vx += dx * physics.springFactor;
599
+ physics.vy += dy * physics.springFactor;
600
+ // Apply drag
601
+ physics.vx *= physics.drag;
602
+ physics.vy *= physics.drag;
603
+ // Update position
604
+ if (!this.hovering) {
605
+ physics.currentX += physics.vx;
606
+ physics.currentY += physics.vy;
607
+ } else {
608
+ this.mouseX = this.game.width / 2;
609
+ this.mouseY = this.game.height / 2;
610
+ physics.currentX = this.game.width / 2;
611
+ physics.currentY = this.game.height / 2;
612
+ }
613
+
614
+ // Calculate speed for excitement level
615
+ const speed = Math.sqrt(physics.vx * physics.vx + physics.vy * physics.vy);
616
+ const direction = Math.atan2(physics.vy, physics.vx);
617
+ this.speed = speed;
618
+
619
+ // Update excitement level based on speed
620
+ const targetExcitement = Math.min(speed / 2, 1);
621
+ physics.excitementLevel = Tween.lerp(
622
+ physics.excitementLevel,
623
+ targetExcitement,
624
+ dt * 2
625
+ );
626
+
627
+ // === TAMAGOTCHI ENERGY SYSTEM ===
628
+ // Movement adds energy, idleness drains it
629
+ if (physics.excitementLevel > 0.2) {
630
+ const gainAmount = physics.excitementLevel * physics.energyGainRate * dt;
631
+ physics.energy = Math.min(1.0, physics.energy + gainAmount);
632
+ } else {
633
+ physics.energy = Math.max(0, physics.energy - physics.energyDecayRate * dt);
634
+ }
635
+
636
+ // === COLLECTIBLE SYSTEM ===
637
+ this.updateCollectibles(dt);
638
+ this.checkCollisions();
639
+ this.updateCollectionParticles(dt);
640
+ this.updateFloatingTexts(dt);
641
+
642
+ // === HUNGER/STARVATION SYSTEM ===
643
+ this.updateHunger(dt);
644
+
645
+ // Check for death - transition to falling state
646
+ // Die from energy depletion OR shrinking too small
647
+ if (physics.energy <= 0 || physics.baseRadius <= CONFIG.minRadius) {
648
+ this.stateMachine.setState("falling");
649
+ return;
650
+ }
651
+
652
+ // Low energy warning
653
+ if (physics.energy < 0.2 && physics.energy > 0) {
654
+ this.playLowEnergyWarning();
655
+ }
656
+
657
+ // Normal alive updates
658
+ this.updateMoodFromEnergy();
659
+ this.updateEnergyColor();
660
+ this.updateBlobShape(speed, direction);
661
+ this.positionBlobFeatures(dt);
662
+
663
+ // Update wobble sound based on movement
664
+ this.updateWobbleSound();
665
+ }
666
+
667
+ /**
668
+ * Update when blob is falling to the ground
669
+ */
670
+ updateFallingState(dt) {
671
+ const physics = this.blobPhysics;
672
+ const groundY = this.game.height - 60;
673
+ const gravity = 800;
674
+
675
+ // Apply gravity
676
+ this.fallVelocity += gravity * dt;
677
+ physics.currentY += this.fallVelocity * dt;
678
+
679
+ // Hit the ground
680
+ if (physics.currentY >= groundY) {
681
+ physics.currentY = groundY;
682
+ // Small squish on impact
683
+ this.fallSquish = 0.3;
684
+ this.stateMachine.setState("dead");
685
+ }
686
+
687
+ // Update blob position
688
+ this.blob.x = physics.currentX;
689
+ this.blob.y = physics.currentY;
690
+
691
+ // Position face during fall
692
+ this.leftEye.x = physics.currentX - 20;
693
+ this.leftEye.y = physics.currentY - 15;
694
+ this.rightEye.x = physics.currentX + 20;
695
+ this.rightEye.y = physics.currentY - 15;
696
+ this.leftPupil.x = this.leftEye.x;
697
+ this.leftPupil.y = this.leftEye.y;
698
+ this.rightPupil.x = this.rightEye.x;
699
+ this.rightPupil.y = this.rightEye.y;
700
+ this.mouth.x = physics.currentX;
701
+ this.mouth.y = physics.currentY + 10;
702
+
703
+ // Darken during fall
704
+ this.blob.shape.color = "rgba(30, 30, 30, 0.9)";
705
+ }
706
+
707
+ /**
708
+ * Update when blob is dead on the ground
709
+ */
710
+ updateDeadState(dt) {
711
+ const physics = this.blobPhysics;
712
+
713
+ // Slowly settle squish and deflate
714
+ this.fallSquish = Math.min(0.5, this.fallSquish + dt * 0.3);
715
+
716
+ // Apply squish - flatten vertically, stretch horizontally
717
+ const squishY = 1 - this.fallSquish;
718
+ const squishX = 1 + this.fallSquish * 0.6;
719
+ this.blob.scaleX = squishX;
720
+ this.blob.scaleY = squishY;
721
+
722
+ // Position blob
723
+ this.blob.x = physics.currentX;
724
+ this.blob.y = physics.currentY;
725
+
726
+ // Position face on squished blob
727
+ const faceY = physics.currentY - 15 * squishY;
728
+ this.leftEye.x = physics.currentX - 20 * squishX;
729
+ this.leftEye.y = faceY;
730
+ this.leftEye.scaleX = squishX * 0.5;
731
+ this.leftEye.scaleY = squishY * 0.5;
732
+
733
+ this.rightEye.x = physics.currentX + 20 * squishX;
734
+ this.rightEye.y = faceY;
735
+ this.rightEye.scaleX = squishX * 0.5;
736
+ this.rightEye.scaleY = squishY * 0.5;
737
+
738
+ this.leftPupil.x = this.leftEye.x;
739
+ this.leftPupil.y = this.leftEye.y;
740
+ this.rightPupil.x = this.rightEye.x;
741
+ this.rightPupil.y = this.rightEye.y;
742
+
743
+ this.mouth.x = physics.currentX;
744
+ this.mouth.y = physics.currentY + 5 * squishY;
745
+ this.mouth.scaleX = squishX;
746
+ this.mouth.scaleY = squishY * 0.5;
747
+
748
+ // Dead color
749
+ this.blob.shape.color = "rgba(20, 20, 20, 0.9)";
750
+ this.leftEye.shape.color = "rgba(80, 80, 80, 0.5)";
751
+ this.rightEye.shape.color = "rgba(80, 80, 80, 0.5)";
752
+ }
753
+
754
+ /**
755
+ * Check if the blob is dead (falling or dead state)
756
+ */
757
+ isDead() {
758
+ return this.stateMachine.isAny("falling", "dead");
759
+ }
760
+
761
+ /**
762
+ * Check if in ready state (before game starts)
763
+ */
764
+ isReady() {
765
+ return this.stateMachine.is("ready");
766
+ }
767
+
768
+ /**
769
+ * Enter ready state - hide blob, show play button
770
+ */
771
+ enterReadyState() {
772
+ // Hide the blob and facial features
773
+ this.blob.visible = false;
774
+ this.leftEye.visible = false;
775
+ this.rightEye.visible = false;
776
+ this.leftPupil.visible = false;
777
+ this.rightPupil.visible = false;
778
+ this.mouth.visible = false;
779
+
780
+ // Create play button if not exists
781
+ if (!this.playButton) {
782
+ this.playButton = new Button(this.game, {
783
+ text: "▶ PLAY",
784
+ width: 140,
785
+ height: 60,
786
+ onClick: () => this.startGame(),
787
+ });
788
+ this.add(this.playButton);
789
+ }
790
+ this.playButton.visible = true;
791
+ this.playButton.x = this.game.width / 2;
792
+ this.playButton.y = this.game.height / 2;
793
+ }
794
+
795
+ /**
796
+ * Update ready state - just position the button
797
+ */
798
+ updateReadyState(dt) {
799
+ if (this.playButton) {
800
+ this.playButton.x = this.game.width / 2;
801
+ this.playButton.y = this.game.height / 2;
802
+ }
803
+ }
804
+
805
+ /**
806
+ * Start the game - transition from ready to alive
807
+ */
808
+ startGame() {
809
+ // Hide play button
810
+ if (this.playButton) {
811
+ this.playButton.visible = false;
812
+ }
813
+
814
+ // Reset game state
815
+ this.resetGameState();
816
+
817
+ // Play start sound
818
+ this.playStartSound();
819
+
820
+ // Transition to alive
821
+ this.stateMachine.setState("alive");
822
+ }
823
+
824
+ /**
825
+ * Enter alive state - show blob and face
826
+ */
827
+ enterAliveState() {
828
+ // Show the blob and facial features
829
+ this.blob.visible = true;
830
+ this.leftEye.visible = true;
831
+ this.rightEye.visible = true;
832
+ this.leftPupil.visible = true;
833
+ this.rightPupil.visible = true;
834
+ this.mouth.visible = true;
835
+
836
+ // Reset blob to center
837
+ const physics = this.blobPhysics;
838
+ physics.currentX = this.game.width / 2;
839
+ physics.currentY = this.game.height / 2;
840
+ physics.vx = 0;
841
+ physics.vy = 0;
842
+ physics.energy = 1.0;
843
+
844
+ // Reset mood
845
+ this.setMood(1);
846
+
847
+ // Initialize wobble sound
848
+ this.initWobbleSound();
849
+ }
850
+
851
+ // === COLLECTIBLE SYSTEM ===
852
+
853
+ /**
854
+ * Get current difficulty factor (0-1) based on game time
855
+ */
856
+ getDifficulty() {
857
+ return Math.min(1, this.gameState.gameTime / CONFIG.difficultyRampTime);
858
+ }
859
+
860
+ /**
861
+ * Get current spawn interval based on difficulty
862
+ */
863
+ getSpawnInterval() {
864
+ const diff = this.getDifficulty();
865
+ return CONFIG.spawnInterval - (CONFIG.spawnInterval - CONFIG.minSpawnInterval) * diff;
866
+ }
867
+
868
+ /**
869
+ * Get current collectible lifespan based on difficulty
870
+ */
871
+ getCollectibleLifespan() {
872
+ const diff = this.getDifficulty();
873
+ return CONFIG.collectibleLifespan - (CONFIG.collectibleLifespan - CONFIG.minLifespan) * diff;
874
+ }
875
+
876
+ /**
877
+ * Spawn a new collectible at a random position
878
+ */
879
+ spawnCollectible() {
880
+ if (this.collectibles.length >= CONFIG.maxCollectibles) return;
881
+
882
+ // Random position with margin from edges
883
+ const margin = 80;
884
+ const x = margin + Math.random() * (this.game.width - margin * 2);
885
+ const y = margin + Math.random() * (this.game.height - margin * 2);
886
+
887
+ // Pick random shape type
888
+ const typeIndex = Math.floor(Math.random() * this.shapeTypes.length);
889
+ const type = this.shapeTypes[typeIndex];
890
+
891
+ // Create shape with random color
892
+ const hue = Math.random() * 360;
893
+ const color = `hsl(${hue}, 80%, 60%)`;
894
+ const glowColor = `hsla(${hue}, 100%, 70%, 0.5)`;
895
+
896
+ let shape;
897
+ if (type.shape === Star) {
898
+ // Star(radius, spikes, inset, options) - use size/2 for radius to match other shapes
899
+ shape = new Star(type.size / 2, 5, 0.5, { color, stroke: "white", lineWidth: 1 });
900
+ } else if (type.shape === Heart) {
901
+ shape = new Heart({ width: type.size, height: type.size, color, stroke: "white", lineWidth: 1 });
902
+ } else if (type.shape === Diamond) {
903
+ shape = new Diamond({ width: type.size, height: type.size * 1.3, color, stroke: "white", lineWidth: 1 });
904
+ } else {
905
+ shape = new Hexagon(type.size, { color, stroke: "white", lineWidth: 1 });
906
+ }
907
+
908
+ const collectible = {
909
+ x,
910
+ y,
911
+ shape,
912
+ type,
913
+ lifespan: this.getCollectibleLifespan(),
914
+ age: 0,
915
+ scale: 0, // Start at 0, animate in
916
+ glowColor,
917
+ pulsePhase: Math.random() * Math.PI * 2,
918
+ rotation: 0, // For spin effect
919
+ };
920
+
921
+ this.collectibles.push(collectible);
922
+
923
+ // Use Tweenetik for bouncy pop-in effect
924
+ Tweenetik.to(collectible, { scale: 1 }, 0.5, Easing.easeOutElastic);
925
+ // Add a little spin as it pops in
926
+ Tweenetik.to(collectible, { rotation: Math.PI * 2 }, 0.4, Easing.easeOutQuad);
927
+
928
+ // Play pop sound
929
+ this.playPopSound();
930
+ }
931
+
932
+ /**
933
+ * Calculate level from total pop count
934
+ * Level N requires N scales (8*N notes) to complete
935
+ * Total notes to finish level N = 8*(1+2+...+N) = 4*N*(N+1)
936
+ */
937
+ getLevelFromPops(pops) {
938
+ // Solve 4*N*(N+1) <= pops for N using quadratic formula
939
+ // N^2 + N - pops/4 = 0 => N = (-1 + sqrt(1 + pops)) / 2
940
+ const level = Math.floor((-1 + Math.sqrt(1 + pops)) / 2) + 1;
941
+ return Math.max(1, level);
942
+ }
943
+
944
+ /**
945
+ * Get how many notes into the current level we are (for scale position)
946
+ */
947
+ getNotesInCurrentLevel(pops) {
948
+ const level = this.getLevelFromPops(pops);
949
+ // Notes to start this level = 4*(level-1)*level
950
+ const notesToStartLevel = 4 * (level - 1) * level;
951
+ return pops - notesToStartLevel;
952
+ }
953
+
954
+ /**
955
+ * Play pop sound when collectible spawns - ascending scales within each level
956
+ */
957
+ playPopSound() {
958
+ if (!Synth.isInitialized) return;
959
+ Synth.resume();
960
+
961
+ // Musical scale frequencies (C major octave: do re mi fa sol la ti do)
962
+ const scale = [262, 294, 330, 349, 392, 440, 494, 523];
963
+
964
+ // Initialize or get current note index
965
+ if (this._popNoteIndex === undefined) {
966
+ this._popNoteIndex = 0;
967
+ }
968
+
969
+ // Get position within current level's scales
970
+ const notesInLevel = this.getNotesInCurrentLevel(this._popNoteIndex);
971
+ const noteInScale = notesInLevel % 8;
972
+ const freq = scale[noteInScale];
973
+
974
+ // Ascending pop with current scale note
975
+ Synth.osc.tone(freq, 0.1, {
976
+ type: "sine",
977
+ volume: 0.1,
978
+ attack: 0.01,
979
+ decay: 0.03,
980
+ sustain: 0.4,
981
+ release: 0.06,
982
+ });
983
+
984
+ // Move to next note
985
+ this._popNoteIndex++;
986
+ }
987
+
988
+ /**
989
+ * Update all collectibles - age them, despawn expired ones
990
+ */
991
+ updateCollectibles(dt) {
992
+ // Spawn timer
993
+ this.gameState.spawnTimer += dt;
994
+ if (this.gameState.spawnTimer >= this.getSpawnInterval()) {
995
+ this.gameState.spawnTimer = 0;
996
+ this.spawnCollectible();
997
+ }
998
+
999
+ // Update multiplier decay
1000
+ if (this.gameState.multiplier > 1) {
1001
+ this.gameState.multiplierTimer += dt;
1002
+ if (this.gameState.multiplierTimer >= CONFIG.multiplierDecay) {
1003
+ this.gameState.multiplier = 1;
1004
+ }
1005
+ }
1006
+
1007
+ // Update each collectible
1008
+ for (let i = this.collectibles.length - 1; i >= 0; i--) {
1009
+ const c = this.collectibles[i];
1010
+ c.age += dt;
1011
+
1012
+ // Pulse effect (scale handled by Tweenetik on spawn)
1013
+ c.pulsePhase += dt * 5;
1014
+
1015
+ // Remove expired collectibles
1016
+ if (c.age >= c.lifespan) {
1017
+ this.collectibles.splice(i, 1);
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ /**
1023
+ * Check collisions between blob and collectibles
1024
+ */
1025
+ checkCollisions() {
1026
+ const physics = this.blobPhysics;
1027
+ const blobCircle = {
1028
+ x: physics.currentX,
1029
+ y: physics.currentY,
1030
+ radius: physics.currentRadius * 0.8, // Slightly smaller hitbox
1031
+ };
1032
+
1033
+ for (let i = this.collectibles.length - 1; i >= 0; i--) {
1034
+ const c = this.collectibles[i];
1035
+ const collectibleCircle = {
1036
+ x: c.x,
1037
+ y: c.y,
1038
+ radius: c.type.size * 0.6,
1039
+ };
1040
+
1041
+ if (Collision.circleCircle(blobCircle, collectibleCircle)) {
1042
+ this.collectItem(c, i);
1043
+ }
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * Handle collecting an item - scoring, growth, effects
1049
+ */
1050
+ collectItem(collectible, index) {
1051
+ // Remove from array
1052
+ this.collectibles.splice(index, 1);
1053
+
1054
+ // Calculate speed bonus - faster pickup = more points
1055
+ // Max bonus at 0 age (just spawned), no bonus after 1 second
1056
+ const speedWindow = 1.0; // seconds to get bonus
1057
+ const maxSpeedBonus = 2.0; // up to 2x bonus for instant pickup
1058
+ const ageRatio = Math.min(collectible.age / speedWindow, 1);
1059
+ const speedBonus = 1 + (maxSpeedBonus - 1) * (1 - ageRatio);
1060
+
1061
+ // Calculate score with multiplier and speed bonus
1062
+ const basePoints = collectible.type.points;
1063
+ const points = Math.round(basePoints * this.gameState.multiplier * speedBonus);
1064
+ this.gameState.score += points;
1065
+ this.gameState.collectiblesEaten++;
1066
+
1067
+ // Play collect sound and visual effect
1068
+ this.playCollectSound(basePoints);
1069
+ this.playEatEffect();
1070
+
1071
+ // Show speed bonus indicator if fast pickup
1072
+ if (speedBonus > 1.3) {
1073
+ this.showFloatingText(
1074
+ speedBonus > 1.8 ? "QUICK! x2" : "FAST!",
1075
+ collectible.x,
1076
+ collectible.y
1077
+ );
1078
+ }
1079
+
1080
+ // Check if currently bouncing for multiplier chain
1081
+ const isBouncing = this.animations.bounceAnimation.active;
1082
+ if (isBouncing) {
1083
+ // Increase multiplier!
1084
+ this.gameState.multiplier = Math.min(CONFIG.maxMultiplier, this.gameState.multiplier + 1);
1085
+ this.gameState.multiplierTimer = 0;
1086
+ // Play combo sound
1087
+ this.playComboSound(this.gameState.multiplier);
1088
+ } else {
1089
+ // Start bounce animation
1090
+ this.triggerAnimation("bounce");
1091
+ this.gameState.multiplier = 1;
1092
+ this.gameState.multiplierTimer = 0;
1093
+ }
1094
+
1095
+ // Reset hunger - we just ate!
1096
+ this.gameState.lastEatTime = 0;
1097
+ this.gameState.isHungry = false;
1098
+
1099
+ // Grow the blob - restore to healthy radius plus growth
1100
+ const physics = this.blobPhysics;
1101
+ // Ensure healthyRadius is initialized
1102
+ if (physics.healthyRadius === undefined) {
1103
+ physics.healthyRadius = physics.baseRadius;
1104
+ }
1105
+ const newRadius = Math.min(CONFIG.maxRadius, physics.healthyRadius + CONFIG.growthPerCollect);
1106
+ physics.baseRadius = newRadius;
1107
+ physics.currentRadius = newRadius;
1108
+ physics.healthyRadius = newRadius; // Update healthy radius to new size
1109
+
1110
+ // Also give energy boost
1111
+ physics.energy = Math.min(1, physics.energy + 0.15);
1112
+
1113
+ // Spawn particles at collection point
1114
+ this.spawnCollectionParticles(collectible);
1115
+ }
1116
+
1117
+ /**
1118
+ * Spawn particles when collecting an item
1119
+ */
1120
+ spawnCollectionParticles(collectible) {
1121
+ // Store particles to render
1122
+ if (!this.collectionParticles) this.collectionParticles = [];
1123
+
1124
+ const particleCount = 5 + this.gameState.multiplier;
1125
+ for (let i = 0; i < particleCount; i++) {
1126
+ const angle = (i / particleCount) * Math.PI * 2 + Math.random() * 0.5;
1127
+ const speed = 100 + Math.random() * 150;
1128
+ this.collectionParticles.push({
1129
+ x: collectible.x,
1130
+ y: collectible.y,
1131
+ vx: Math.cos(angle) * speed,
1132
+ vy: Math.sin(angle) * speed,
1133
+ life: 0.5 + Math.random() * 0.3,
1134
+ age: 0,
1135
+ size: 3 + Math.random() * 4,
1136
+ color: collectible.glowColor,
1137
+ });
1138
+ }
1139
+ }
1140
+
1141
+ /**
1142
+ * Update hunger system - blob shrinks and darkens if not fed
1143
+ */
1144
+ updateHunger(dt) {
1145
+ const physics = this.blobPhysics;
1146
+ const diff = this.getDifficulty();
1147
+
1148
+ // Update time since last eating
1149
+ this.gameState.lastEatTime += dt;
1150
+
1151
+ // Calculate hunger threshold based on difficulty (gets harder at higher levels)
1152
+ const hungerThreshold = CONFIG.hungerTime -
1153
+ (CONFIG.hungerTime - CONFIG.hungerTimeMin) * diff;
1154
+
1155
+ // Check if we're hungry
1156
+ const wasHungry = this.gameState.isHungry;
1157
+ this.gameState.isHungry = this.gameState.lastEatTime > hungerThreshold;
1158
+
1159
+ // If just became hungry, show warning and capture healthy radius
1160
+ if (this.gameState.isHungry && !wasHungry) {
1161
+ this.showFloatingText("HUNGRY!", this.game.width / 2, this.game.height / 2);
1162
+ this.playHungryWarning();
1163
+ // Capture the current radius as the healthy radius when hunger starts
1164
+ if (physics.healthyRadius === undefined || physics.healthyRadius < physics.baseRadius) {
1165
+ physics.healthyRadius = physics.baseRadius;
1166
+ }
1167
+ }
1168
+
1169
+ // If hungry, shrink and lose points
1170
+ if (this.gameState.isHungry) {
1171
+ // Calculate shrink rate based on difficulty
1172
+ const shrinkRate = CONFIG.shrinkRate +
1173
+ (CONFIG.shrinkRateMax - CONFIG.shrinkRate) * diff;
1174
+
1175
+ // How long we've been hungry
1176
+ const hungerDuration = this.gameState.lastEatTime - hungerThreshold;
1177
+
1178
+ // Shrink faster the longer we're hungry (up to 2x after 2 seconds)
1179
+ const hungerMultiplier = 1 + Math.min(hungerDuration / 2, 1);
1180
+ const shrinkAmount = shrinkRate * hungerMultiplier * dt;
1181
+
1182
+ // Apply shrinking from healthy radius (but don't modify healthyRadius)
1183
+ const newRadius = Math.max(CONFIG.minRadius, physics.baseRadius - shrinkAmount);
1184
+ if (newRadius < physics.baseRadius) {
1185
+ // Calculate score penalty
1186
+ const radiusLost = physics.baseRadius - newRadius;
1187
+ const scorePenalty = Math.ceil(radiusLost * CONFIG.shrinkScorePenalty);
1188
+ this.gameState.score = Math.max(0, this.gameState.score - scorePenalty);
1189
+
1190
+ // Apply size reduction (healthyRadius stays unchanged)
1191
+ physics.baseRadius = newRadius;
1192
+ physics.currentRadius = newRadius;
1193
+ }
1194
+
1195
+ // Darken the blob color based on hunger duration
1196
+ // Interpolate from normal color toward dark/gray
1197
+ const darkenFactor = Math.min(hungerDuration / 3, 0.7); // Max 70% darkening
1198
+ const baseColor = [64, 180, 255]; // Normal blue
1199
+ const darkColor = [40, 40, 60]; // Dark gray-blue
1200
+
1201
+ physics.currentColor = [
1202
+ Math.round(baseColor[0] + (darkColor[0] - baseColor[0]) * darkenFactor),
1203
+ Math.round(baseColor[1] + (darkColor[1] - baseColor[1]) * darkenFactor),
1204
+ Math.round(baseColor[2] + (darkColor[2] - baseColor[2]) * darkenFactor),
1205
+ ];
1206
+ } else {
1207
+ // Not hungry - restore normal color gradually
1208
+ const baseColor = physics.baseColor;
1209
+ physics.currentColor = [
1210
+ Math.round(Tween.lerp(physics.currentColor[0], baseColor[0], dt * 3)),
1211
+ Math.round(Tween.lerp(physics.currentColor[1], baseColor[1], dt * 3)),
1212
+ Math.round(Tween.lerp(physics.currentColor[2], baseColor[2], dt * 3)),
1213
+ ];
1214
+ }
1215
+ }
1216
+
1217
+ /**
1218
+ * Update and render collection particles
1219
+ */
1220
+ updateCollectionParticles(dt) {
1221
+ if (!this.collectionParticles) return;
1222
+
1223
+ for (let i = this.collectionParticles.length - 1; i >= 0; i--) {
1224
+ const p = this.collectionParticles[i];
1225
+ p.age += dt;
1226
+ p.x += p.vx * dt;
1227
+ p.y += p.vy * dt;
1228
+ p.vx *= 0.95;
1229
+ p.vy *= 0.95;
1230
+
1231
+ if (p.age >= p.life) {
1232
+ this.collectionParticles.splice(i, 1);
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ /**
1238
+ * Render collectibles
1239
+ */
1240
+ renderCollectibles() {
1241
+ for (const c of this.collectibles) {
1242
+ // Calculate fade based on remaining life
1243
+ const fadeStart = 0.7; // Start fading at 70% of lifespan
1244
+ const lifeRatio = c.age / c.lifespan;
1245
+ const alpha = lifeRatio > fadeStart
1246
+ ? 1 - (lifeRatio - fadeStart) / (1 - fadeStart)
1247
+ : 1;
1248
+
1249
+ // Pulse scale
1250
+ const pulse = 1 + Math.sin(c.pulsePhase) * 0.1;
1251
+ const finalScale = c.scale * pulse;
1252
+
1253
+ Painter.save();
1254
+ Painter.ctx.translate(c.x, c.y);
1255
+ Painter.ctx.rotate(c.rotation || 0); // Apply spin rotation
1256
+ Painter.ctx.scale(finalScale, finalScale);
1257
+ Painter.ctx.globalAlpha = alpha;
1258
+
1259
+ // Draw subtle glow
1260
+ Painter.ctx.shadowColor = c.glowColor;
1261
+ Painter.ctx.shadowBlur = 6;
1262
+
1263
+ c.shape.render();
1264
+
1265
+ Painter.restore();
1266
+ }
1267
+ }
1268
+
1269
+ /**
1270
+ * Render collection particles
1271
+ */
1272
+ renderCollectionParticles() {
1273
+ if (!this.collectionParticles) return;
1274
+
1275
+ for (const p of this.collectionParticles) {
1276
+ const alpha = 1 - p.age / p.life;
1277
+ const size = p.size * (1 - p.age / p.life * 0.5);
1278
+ Painter.shapes.fillCircle(p.x, p.y, size, p.color.replace('0.5)', `${alpha * 0.8})`));
1279
+ }
1280
+ }
1281
+
1282
+ /**
1283
+ * Reset game state
1284
+ */
1285
+ resetGameState() {
1286
+ this.gameState = {
1287
+ score: 0,
1288
+ multiplier: 1,
1289
+ multiplierTimer: 0,
1290
+ gameTime: 0,
1291
+ spawnTimer: 0,
1292
+ collectiblesEaten: 0,
1293
+ currentLevel: 1,
1294
+ lastEatTime: 0,
1295
+ isHungry: false,
1296
+ };
1297
+ this.collectibles = [];
1298
+ this.collectionParticles = [];
1299
+ this.blobPhysics.baseRadius = CONFIG.startRadius;
1300
+ this.blobPhysics.currentRadius = CONFIG.startRadius;
1301
+ this.blobPhysics.healthyRadius = CONFIG.startRadius;
1302
+ // Reset color to normal
1303
+ this.blobPhysics.currentColor = [...this.blobPhysics.baseColor];
1304
+ // Reset pop sound scale
1305
+ this._popNoteIndex = 0;
1306
+ }
1307
+
1308
+ /**
1309
+ * Set the dead face - X eyes
1310
+ */
1311
+ setDeadFace() {
1312
+ // X eyes would need custom shapes, for now just make them very small/closed
1313
+ this.leftEye.scaleX = this.leftEye.scaleY = 0.3;
1314
+ this.rightEye.scaleX = this.rightEye.scaleY = 0.3;
1315
+ this.leftPupil.visible = false;
1316
+ this.rightPupil.visible = false;
1317
+
1318
+ // Flat line mouth
1319
+ this.mouth.shape.path = [
1320
+ ["M", -15, 0],
1321
+ ["L", 15, 0],
1322
+ ];
1323
+ }
1324
+
1325
+ /**
1326
+ * Give the blob a new random color and revive it!
1327
+ * This is the "replay" button - brings the blob back to life
1328
+ */
1329
+ triggerBlobGradientShift() {
1330
+ const physics = this.blobPhysics;
1331
+
1332
+ // REVIVE from death!
1333
+ if (this.isDead()) {
1334
+ this.reviveBlob();
1335
+ }
1336
+
1337
+ // Reset energy to full
1338
+ physics.energy = 1.0;
1339
+
1340
+ const current = this.getSafeColor(physics.baseColor);
1341
+
1342
+ // Generate a random vibrant color
1343
+ // hslToRgb expects: h=0-360, s=0-100, l=0-100
1344
+ const randomHue = Math.random() * 360;
1345
+ const randomSat = 70 + Math.random() * 25; // 70-95%
1346
+ const randomLight = 50 + Math.random() * 15; // 50-65%
1347
+
1348
+ // Convert current RGB to HSL for smooth interpolation
1349
+ // rgbToHsl returns h=0-360, s=0-1, l=0-1, so convert s,l to 0-100
1350
+ const rawHsl = Painter.colors.rgbToHsl(...current);
1351
+ const startHsl = [rawHsl[0], rawHsl[1] * 100, rawHsl[2] * 100];
1352
+ const targetHsl = [randomHue, randomSat, randomLight];
1353
+
1354
+ this.animations.gradientShift.startColor = startHsl;
1355
+ this.animations.gradientShift.targetColor = targetHsl;
1356
+ this.animations.gradientShift.startTime = this.time;
1357
+ this.animations.colorAnimation.active = false;
1358
+ this.animations.gradientShift.active = true;
1359
+ this.animations.gradientShift.elapsed = 0;
1360
+ }
1361
+
1362
+ /**
1363
+ * Revive the blob from death - goes back to ready state
1364
+ */
1365
+ reviveBlob() {
1366
+ const physics = this.blobPhysics;
1367
+
1368
+ // Reset physics
1369
+ physics.baseRadius = CONFIG.startRadius;
1370
+ physics.currentRadius = CONFIG.startRadius;
1371
+ physics.healthyRadius = CONFIG.startRadius;
1372
+ physics.currentX = this.game.width / 2;
1373
+ physics.currentY = this.game.height / 2;
1374
+ physics.vx = 0;
1375
+ physics.vy = 0;
1376
+ physics.energy = 1.0;
1377
+
1378
+ // Reset scale
1379
+ this.blob.scaleX = 1;
1380
+ this.blob.scaleY = 1;
1381
+ this.fallSquish = 0;
1382
+
1383
+ // Reset facial features
1384
+ this.leftEye.scaleX = this.leftEye.scaleY = 1;
1385
+ this.rightEye.scaleX = this.rightEye.scaleY = 1;
1386
+ this.leftPupil.scaleX = this.leftPupil.scaleY = 1;
1387
+ this.rightPupil.scaleX = this.rightPupil.scaleY = 1;
1388
+ this.leftPupil.visible = true;
1389
+ this.rightPupil.visible = true;
1390
+ this.mouth.scaleX = this.mouth.scaleY = 1;
1391
+
1392
+ // Reset game state
1393
+ this.resetGameState();
1394
+
1395
+ // Reset mood
1396
+ this.setMood(1);
1397
+
1398
+ // Go back to ready state (shows play button)
1399
+ this.stateMachine.setState("ready");
1400
+ }
1401
+
1402
+ /**
1403
+ * Validate that an RGB color array is valid
1404
+ */
1405
+ isValidRgb(rgb) {
1406
+ if (!Array.isArray(rgb) || rgb.length < 3) return false;
1407
+ // Allow floats, just check they're valid numbers in reasonable range
1408
+ return rgb.slice(0, 3).every(
1409
+ (v) => typeof v === "number" && !isNaN(v) && isFinite(v) && v >= -1 && v <= 256
1410
+ );
1411
+ }
1412
+
1413
+ /**
1414
+ * Get a safe color, falling back to default if invalid
1415
+ * Also clamps values to valid 0-255 range
1416
+ */
1417
+ getSafeColor(color, fallback = [64, 180, 255]) {
1418
+ if (!this.isValidRgb(color)) return fallback;
1419
+ // Clamp and round values
1420
+ return [
1421
+ Math.round(Math.max(0, Math.min(255, color[0]))),
1422
+ Math.round(Math.max(0, Math.min(255, color[1]))),
1423
+ Math.round(Math.max(0, Math.min(255, color[2]))),
1424
+ ];
1425
+ }
1426
+
1427
+ /**
1428
+ * Update active animations
1429
+ */
1430
+ updateAnimations(dt) {
1431
+ // Process all animations
1432
+ for (const [animName, anim] of Object.entries(this.animations)) {
1433
+ if (!anim.active) continue;
1434
+ // Calculate normalized time (0-1)
1435
+ const elapsed = this.time - anim.startTime;
1436
+ const t = Math.min(elapsed / anim.duration, 1);
1437
+ // Process specific animations
1438
+ if (animName === "pulseAnimation") {
1439
+ const easedT = Easing.easeOutElastic(t);
1440
+ const start = anim.startRadius;
1441
+ const end = anim.targetRadius;
1442
+ this.blobPhysics.currentRadius = Tween.lerp(start, end, easedT);
1443
+ if (t >= 1) {
1444
+ anim.active = false;
1445
+ this.blobPhysics.baseRadius = this.blobPhysics.currentRadius;
1446
+ anim.targetRadius = this.blobPhysics.baseRadius * 1.1;
1447
+ }
1448
+ } else if (animName === "gradientShift") {
1449
+ // Change "if" to "else if" to fix the logic issue
1450
+ this.updateColorIdle(t, anim);
1451
+ } else if (animName === "bounceAnimation") {
1452
+ const eased = Easing.easeOutBounce(t);
1453
+ // max deformation (inward squish) - subtle bounce
1454
+ const bounceAmount = 15 + this.blobPhysics.currentRadius * 0.15;
1455
+ // Store this deform amount globally
1456
+ this.blobBounceDeform = bounceAmount * (1 - eased); // starts squished, eases to 0
1457
+ if (t >= 1) {
1458
+ this.blobBounceDeform = 0;
1459
+ anim.active = false;
1460
+ }
1461
+ }
1462
+ }
1463
+ }
1464
+
1465
+ /**
1466
+ * Position the blob and all its features
1467
+ */
1468
+ positionBlobFeatures(dt) {
1469
+ const physics = this.blobPhysics;
1470
+ // Position the blob
1471
+ this.blob.x = physics.currentX;
1472
+ this.blob.y = physics.currentY;
1473
+
1474
+ // Calculate scale factor based on blob size
1475
+ // Note: blob body scales via updateBlobShape(), not scaleX/Y
1476
+ // Scale features proportionally (100 = design baseline)
1477
+ const sizeScale = physics.currentRadius / 100;
1478
+
1479
+ // Update eye positions and shapes - scale offsets by blob size
1480
+ const baseEyeOffsetY = -15;
1481
+ const baseEyeOffsetX = 20;
1482
+ const eyeOffsetY = baseEyeOffsetY * sizeScale;
1483
+ const eyeOffsetX = baseEyeOffsetX * sizeScale;
1484
+ const eyeYAdjust = Math.min(physics.excitementLevel * 5, 3) * sizeScale; // Eyes move up when excited
1485
+
1486
+ // Scale the eyes and pupils
1487
+ this.leftEye.scaleX = this.leftEye.scaleY = sizeScale;
1488
+ this.rightEye.scaleX = this.rightEye.scaleY = sizeScale;
1489
+ this.leftPupil.scaleX = this.leftPupil.scaleY = sizeScale;
1490
+ this.rightPupil.scaleX = this.rightPupil.scaleY = sizeScale;
1491
+ this.mouth.scaleX = this.mouth.scaleY = sizeScale;
1492
+
1493
+ // Position eyes based on blob position
1494
+ this.leftEye.x = physics.currentX - eyeOffsetX;
1495
+ this.leftEye.y = physics.currentY + eyeOffsetY - eyeYAdjust;
1496
+ // Position pupils based on eye position
1497
+ this.rightEye.x = physics.currentX + eyeOffsetX;
1498
+ this.rightEye.y = physics.currentY + eyeOffsetY - eyeYAdjust;
1499
+ //
1500
+ // Eye tracking
1501
+ // First, calculate vectors from eye centers to mouse
1502
+ const leftEyeToDx = this.mouseX - this.leftEye.x;
1503
+ const leftEyeToDy = this.mouseY - this.leftEye.y;
1504
+ // Right eye vector
1505
+ const rightEyeToDx = this.mouseX - this.rightEye.x;
1506
+ const rightEyeToDy = this.mouseY - this.rightEye.y;
1507
+ // Eye dimensions (scaled)
1508
+ const eyeRadius = 10 * sizeScale; // The full white part of the eye
1509
+ const pupilRadius = 4 * sizeScale; // The black part of the eye
1510
+ // Maximum distance the pupil center can move from eye center
1511
+ // This ensures the pupil always stays within the white part
1512
+ const maxPupilOffset = eyeRadius - pupilRadius - 1; // -1 for a small margin
1513
+ // Calculate pupil positions for each eye
1514
+ // -- Left Eye --
1515
+ // First, normalize direction vector
1516
+ const leftEyeDist = Math.sqrt(
1517
+ leftEyeToDx * leftEyeToDx + leftEyeToDy * leftEyeToDy
1518
+ );
1519
+ let leftPupilX = 0,
1520
+ leftPupilY = 0;
1521
+ if (leftEyeDist > 0) {
1522
+ // Normalize and scale by max offset
1523
+ const normalizedX = leftEyeToDx / leftEyeDist;
1524
+ const normalizedY = leftEyeToDy / leftEyeDist;
1525
+ // Scale the movement - eyes follow more strongly when looking directly at the cursor
1526
+ // and less when looking at extreme angles
1527
+ // Calculate a scaled magnitude (distance from eye center to pupil center)
1528
+ // Formula creates a sigmoid-like response curve
1529
+ const scaledMagnitude = maxPupilOffset * Math.tanh(leftEyeDist / 200);
1530
+ leftPupilX = normalizedX * scaledMagnitude;
1531
+ leftPupilY = normalizedY * scaledMagnitude;
1532
+ }
1533
+ //
1534
+ // -- Right Eye --
1535
+ // First, normalize direction vector
1536
+ const rightEyeDist = Math.sqrt(
1537
+ rightEyeToDx * rightEyeToDx + rightEyeToDy * rightEyeToDy
1538
+ );
1539
+ let rightPupilX = 0,
1540
+ rightPupilY = 0;
1541
+ if (rightEyeDist > 0) {
1542
+ // Normalize and scale by max offset
1543
+ const normalizedX = rightEyeToDx / rightEyeDist;
1544
+ const normalizedY = rightEyeToDy / rightEyeDist;
1545
+ // Calculate scaled magnitude with the same formula
1546
+ const scaledMagnitude = maxPupilOffset * Math.tanh(rightEyeDist / 200);
1547
+ rightPupilX = normalizedX * scaledMagnitude;
1548
+ rightPupilY = normalizedY * scaledMagnitude;
1549
+ }
1550
+ //
1551
+ // Apply smoothing with Tween - this creates a more natural lag in eye movement
1552
+ const eyeResponseSpeed = 80; // Higher = faster response
1553
+ // Tween the pupil positions to follow the calculated offsets
1554
+ this.leftPupil.x = Tween.lerp(
1555
+ this.leftPupil.x,
1556
+ this.leftEye.x + leftPupilX,
1557
+ dt * eyeResponseSpeed
1558
+ );
1559
+ this.leftPupil.y = Tween.lerp(
1560
+ this.leftPupil.y,
1561
+ this.leftEye.y + leftPupilY,
1562
+ dt * eyeResponseSpeed
1563
+ );
1564
+ this.rightPupil.x = Tween.lerp(
1565
+ this.rightPupil.x,
1566
+ this.rightEye.x + rightPupilX,
1567
+ dt * eyeResponseSpeed
1568
+ );
1569
+ this.rightPupil.y = Tween.lerp(
1570
+ this.rightPupil.y,
1571
+ this.rightEye.y + rightPupilY,
1572
+ dt * eyeResponseSpeed
1573
+ );
1574
+ // Position mouth (scale the offset)
1575
+ this.mouth.x = physics.currentX;
1576
+ this.mouth.y = physics.currentY + 10 * sizeScale;
1577
+ }
1578
+
1579
+ /**
1580
+ * Update the blob's shape based on movement and time
1581
+ */
1582
+ updateBlobShape(speed, direction) {
1583
+ const physics = this.blobPhysics;
1584
+ const baseRadius = physics.currentRadius;
1585
+ // Calculate the new control points based on speed, direction and wobble
1586
+ let controlPoints = [];
1587
+ // Update radius offsets for wobble effect
1588
+ for (let i = 0; i < this.blobPoints.length; i++) {
1589
+ const point = this.blobPoints[i];
1590
+ // Use Tween functions for wobble animation
1591
+ // Mix sine and elastic easings for more organic movement
1592
+ const wobbleT =
1593
+ (this.time * physics.wobbleSpeed * point.wobbleFrequency +
1594
+ point.phaseOffset) %
1595
+ 2;
1596
+ const wobbleEasing =
1597
+ wobbleT < 1
1598
+ ? Easing.easeInOutSine(wobbleT)
1599
+ : Easing.easeInOutSine(2 - wobbleT);
1600
+ // Apply excitement factor to wobble - more excited = more wobble
1601
+ const excitementFactor = 1 + physics.excitementLevel * speed * 0.2;
1602
+ const osc = Motion.oscillate(
1603
+ -3, // min
1604
+ 3, // max
1605
+ this.time * 10 + i * 0.5, // elapsed time with index offset
1606
+ 1, // duration of full cycle (seconds)
1607
+ true, // loop
1608
+ Easing.easeInOutSine // optional easing
1609
+ );
1610
+
1611
+ // Apply everything to radiusOffset
1612
+ point.radiusOffset =
1613
+ wobbleEasing *
1614
+ physics.wobbleAmount *
1615
+ 20 *
1616
+ (1 + physics.excitementLevel * speed * 0.2) +
1617
+ osc.value * physics.excitementLevel;
1618
+
1619
+ // Squash in the direction of movement if moving fast
1620
+ const squash = Math.min(speed * 0.1, 0.5);
1621
+ const angleDiff = Math.abs(normalizeAngle(point.angle - direction));
1622
+ // Points in the direction of movement get compressed, perpendicular points expand
1623
+ // This creates a more natural squash-and-stretch effect
1624
+ const movementEffect = Math.cos(angleDiff) * squash * 30;
1625
+ const stretchEffect = Math.sin(angleDiff) * squash * 15;
1626
+ // Calculate final radius including all effects
1627
+ const finalRadius =
1628
+ baseRadius +
1629
+ point.radiusOffset +
1630
+ this.blobBounceDeform - // 👈 deformation affects all points equally
1631
+ movementEffect +
1632
+ stretchEffect;
1633
+
1634
+ // Convert polar to cartesian coordinates
1635
+ const x = Math.cos(point.angle) * finalRadius;
1636
+ const y = Math.sin(point.angle) * finalRadius;
1637
+ controlPoints.push({
1638
+ x,
1639
+ y,
1640
+ });
1641
+ }
1642
+ // Generate the path commands for the BezierShape
1643
+ const path = this.generateBlobPath(controlPoints);
1644
+ this.blob.shape.path = path;
1645
+ }
1646
+
1647
+ /**
1648
+ * Update color based on energy and excitement levels
1649
+ * - Energy (from movement) controls base brightness (0 = black, 1 = full color)
1650
+ * - Excitement boosts brightness toward white (but capped so not full white)
1651
+ * - Idle = energy drains = fades to black
1652
+ * - Flash effect blends toward white when eating
1653
+ */
1654
+ updateEnergyColor() {
1655
+ const energy = this.blobPhysics.energy;
1656
+ const excitement = this.blobPhysics.excitementLevel;
1657
+ const flashAmount = this._flashAmount || 0;
1658
+
1659
+ // Get the base color (either from animation or physics)
1660
+ const baseColor = this.getSafeColor(
1661
+ this.blobVisualBaseColor ?? this.blobPhysics.baseColor
1662
+ );
1663
+
1664
+ // Energy controls the base brightness (0 = black, 1 = full base color)
1665
+ // Excitement adds a boost toward white (max 40% boost to avoid full white)
1666
+ const maxExcitementBoost = 0.4;
1667
+ const excitementBoost = excitement * maxExcitementBoost;
1668
+
1669
+ // Calculate final color:
1670
+ // 1. Scale base color by energy (fades to black when energy is low)
1671
+ // 2. Add excitement boost toward white (255)
1672
+ // 3. Apply flash effect (blend toward white)
1673
+ const finalColor = baseColor.map((channel) => {
1674
+ // Base brightness from energy
1675
+ const energyScaled = channel * energy;
1676
+ // Excitement pushes toward white (255)
1677
+ const toWhite = (255 - energyScaled) * excitementBoost;
1678
+ const baseResult = Math.min(255, energyScaled + toWhite);
1679
+ // Flash effect pushes toward white
1680
+ const flashed = baseResult + (255 - baseResult) * flashAmount;
1681
+ return Math.round(Math.min(255, flashed));
1682
+ });
1683
+
1684
+ this.blobPhysics.currentColor = finalColor;
1685
+
1686
+ const [r, g, b] = finalColor;
1687
+ this.blob.shape.color = `rgba(${r}, ${g}, ${b}, 0.8)`;
1688
+
1689
+ // Also dim the eyes when energy is low
1690
+ const eyeAlpha = 0.3 + energy * 0.7;
1691
+ this.leftEye.shape.color = `rgba(255, 255, 255, ${eyeAlpha})`;
1692
+ this.rightEye.shape.color = `rgba(255, 255, 255, ${eyeAlpha})`;
1693
+ }
1694
+
1695
+ updateColor() {
1696
+ // This is now handled by updateEnergyColor()
1697
+ // Keep for compatibility but delegate
1698
+ this.updateEnergyColor();
1699
+ }
1700
+
1701
+ updateColorIdle(t, anim) {
1702
+ const easedT = Easing.easeInOutSine(t);
1703
+
1704
+ // Interpolate in HSL
1705
+ const hsl = Tween.tweenGradient(anim.startColor, anim.targetColor, easedT);
1706
+
1707
+ // Convert back to RGB and validate
1708
+ const rgb = Painter.colors.hslToRgb(...hsl);
1709
+
1710
+ // Only update if we got a valid color - use getSafeColor to clamp values
1711
+ if (this.isValidRgb(rgb)) {
1712
+ this.blobVisualBaseColor = this.getSafeColor(rgb);
1713
+ }
1714
+
1715
+ if (t >= 1) {
1716
+ anim.active = false;
1717
+ // Only update base color if we have a valid visual base color
1718
+ if (this.isValidRgb(this.blobVisualBaseColor)) {
1719
+ this.blobPhysics.baseColor = this.getSafeColor(this.blobVisualBaseColor);
1720
+ }
1721
+ this.blobVisualBaseColor = null;
1722
+ }
1723
+ }
1724
+
1725
+ /**
1726
+ * Render additional effects
1727
+ */
1728
+ render() {
1729
+ // In ready state, just render the play button
1730
+ if (this.isReady()) {
1731
+ super.render();
1732
+ return;
1733
+ }
1734
+
1735
+ // Render collectibles BEFORE the blob (so they appear behind)
1736
+ this.renderCollectibles();
1737
+ this.renderCollectionParticles();
1738
+
1739
+ super.render();
1740
+
1741
+ // Excitement particles when very excited
1742
+ if (this.blobPhysics.excitementLevel > 0.7) {
1743
+ this.renderExcitementParticles();
1744
+ }
1745
+
1746
+ // Render score and multiplier HUD
1747
+ this.renderHUD();
1748
+
1749
+ // Render floating bonus text indicators
1750
+ this.renderFloatingTexts();
1751
+ }
1752
+
1753
+ /**
1754
+ * Render the score and multiplier display
1755
+ */
1756
+ renderHUD() {
1757
+ // Don't render HUD in ready state
1758
+ if (this.isReady()) return;
1759
+
1760
+ const { score, multiplier } = this.gameState;
1761
+ const physics = this.blobPhysics;
1762
+
1763
+ // Score display (top center)
1764
+ Painter.useCtx((ctx) => {
1765
+ ctx.font = "bold 24px monospace";
1766
+ ctx.textAlign = "center";
1767
+ ctx.textBaseline = "top";
1768
+
1769
+ // Score with subtle glow
1770
+ ctx.shadowColor = "rgba(255, 255, 255, 0.3)";
1771
+ ctx.shadowBlur = 4;
1772
+ ctx.fillStyle = "white";
1773
+ ctx.fillText(`SCORE: ${score}`, this.game.width / 2, 20);
1774
+
1775
+ // Multiplier (if > 1)
1776
+ if (multiplier > 1) {
1777
+ ctx.font = "bold 18px monospace";
1778
+ ctx.fillStyle = `hsl(${60 + multiplier * 30}, 80%, 55%)`;
1779
+ ctx.shadowBlur = 0; // No glow on multiplier
1780
+ ctx.fillText(`x${multiplier} COMBO!`, this.game.width / 2, 50);
1781
+ }
1782
+
1783
+ // Difficulty indicator (small, bottom)
1784
+ const diff = this.getDifficulty();
1785
+ ctx.font = "12px monospace";
1786
+ ctx.fillStyle = `rgba(255, 255, 255, 0.5)`;
1787
+ ctx.shadowBlur = 0;
1788
+ ctx.fillText(`Level: ${Math.floor(diff * 10) + 1}`, this.game.width / 2, this.game.height - 130);
1789
+ });
1790
+ }
1791
+
1792
+ // === SOUND EFFECTS ===
1793
+
1794
+ /**
1795
+ * Play collect sound - ascending chirp
1796
+ */
1797
+ playCollectSound(points) {
1798
+ if (!Synth.isInitialized) return;
1799
+ Synth.resume();
1800
+
1801
+ // Base frequency scales with points value
1802
+ const baseFreq = 400 + points * 10;
1803
+ Synth.osc.sweep(baseFreq, baseFreq * 1.5, 0.1, {
1804
+ type: "sine",
1805
+ volume: 0.3,
1806
+ });
1807
+ }
1808
+
1809
+ /**
1810
+ * Play combo sound - exciting ascending arpeggio
1811
+ */
1812
+ playComboSound(multiplier) {
1813
+ if (!Synth.isInitialized) return;
1814
+ Synth.resume();
1815
+
1816
+ const baseFreq = 300 + multiplier * 50;
1817
+ // Quick ascending notes
1818
+ for (let i = 0; i < Math.min(multiplier, 4); i++) {
1819
+ Synth.osc.tone(baseFreq * (1 + i * 0.25), 0.08, {
1820
+ type: "square",
1821
+ volume: 0.15,
1822
+ attack: 0.01,
1823
+ decay: 0.02,
1824
+ sustain: 0.5,
1825
+ release: 0.05,
1826
+ startTime: Synth.now + i * 0.05,
1827
+ });
1828
+ }
1829
+ }
1830
+
1831
+ /**
1832
+ * Play death sound - sad descending tone
1833
+ */
1834
+ playDeathSound() {
1835
+ if (!Synth.isInitialized) return;
1836
+ Synth.resume();
1837
+
1838
+ // Descending sweep
1839
+ Synth.osc.sweep(400, 80, 0.8, {
1840
+ type: "sawtooth",
1841
+ volume: 0.2,
1842
+ exponential: true,
1843
+ });
1844
+ }
1845
+
1846
+ /**
1847
+ * Play hungry warning - stomach growl sound
1848
+ */
1849
+ playHungryWarning() {
1850
+ if (!Synth.isInitialized) return;
1851
+ Synth.resume();
1852
+
1853
+ // Low rumbling growl
1854
+ Synth.osc.sweep(80, 50, 0.3, {
1855
+ type: "sawtooth",
1856
+ volume: 0.15,
1857
+ });
1858
+ // Second growl
1859
+ Synth.osc.sweep(70, 40, 0.25, {
1860
+ type: "sawtooth",
1861
+ volume: 0.12,
1862
+ startTime: Synth.now + 0.35,
1863
+ });
1864
+ }
1865
+
1866
+ /**
1867
+ * Play start sound - cheerful intro
1868
+ */
1869
+ playStartSound() {
1870
+ if (!Synth.isInitialized) return;
1871
+ Synth.resume();
1872
+
1873
+ // Quick ascending chord
1874
+ const notes = [262, 330, 392, 523]; // C major chord + octave
1875
+ notes.forEach((freq, i) => {
1876
+ Synth.osc.tone(freq, 0.2, {
1877
+ type: "sine",
1878
+ volume: 0.2,
1879
+ attack: 0.01,
1880
+ decay: 0.05,
1881
+ sustain: 0.6,
1882
+ release: 0.15,
1883
+ startTime: Synth.now + i * 0.08,
1884
+ });
1885
+ });
1886
+ }
1887
+
1888
+ /**
1889
+ * Play low energy warning beep
1890
+ */
1891
+ playLowEnergyWarning() {
1892
+ if (!Synth.isInitialized) return;
1893
+ if (this._lastWarningTime && Synth.now - this._lastWarningTime < 2) return;
1894
+ this._lastWarningTime = Synth.now;
1895
+
1896
+ Synth.resume();
1897
+ // Two short warning beeps
1898
+ Synth.osc.tone(200, 0.1, { type: "square", volume: 0.1 });
1899
+ Synth.osc.tone(200, 0.1, { type: "square", volume: 0.1, startTime: Synth.now + 0.15 });
1900
+ }
1901
+
1902
+ /**
1903
+ * Initialize the wobble sound - continuous oscillator that responds to movement
1904
+ */
1905
+ initWobbleSound() {
1906
+ if (!Synth.isInitialized || this._wobbleOsc) return;
1907
+
1908
+ Synth.resume();
1909
+
1910
+ // Create a continuous oscillator for the wobble
1911
+ this._wobbleOsc = Synth.osc.continuous({
1912
+ type: "sine",
1913
+ frequency: 80,
1914
+ volume: 0,
1915
+ });
1916
+
1917
+ // Create a second oscillator for FM modulation effect
1918
+ this._wobbleLfo = Synth.osc.continuous({
1919
+ type: "sine",
1920
+ frequency: 4,
1921
+ volume: 0,
1922
+ });
1923
+ }
1924
+
1925
+ /**
1926
+ * Update wobble sound based on blob movement
1927
+ */
1928
+ updateWobbleSound() {
1929
+ if (!this._wobbleOsc) return;
1930
+
1931
+ const physics = this.blobPhysics;
1932
+ const speed = Math.sqrt(physics.vx * physics.vx + physics.vy * physics.vy);
1933
+ const excitement = physics.excitementLevel;
1934
+
1935
+ // Map speed to volume (0 when still, up to 0.08 when very fast)
1936
+ // Scaled down: need to move faster for audible sound
1937
+ const targetVolume = Math.min(0.08, speed * 0.008) * (physics.energy > 0 ? 1 : 0);
1938
+
1939
+ // Map speed to frequency (low rumble when slow, higher when fast)
1940
+ // Scaled down: need to move faster for high notes
1941
+ const targetFreq = 50 + speed * 6 + excitement * 20;
1942
+
1943
+ // Map excitement to LFO rate (faster wobble when more excited)
1944
+ const lfoRate = 2 + excitement * 5;
1945
+
1946
+ // Smooth transitions
1947
+ this._wobbleOsc.setFrequency(targetFreq, 0.1);
1948
+ this._wobbleOsc.setVolume(targetVolume, 0.1);
1949
+ this._wobbleLfo.setFrequency(lfoRate, 0.1);
1950
+ }
1951
+
1952
+ /**
1953
+ * Stop wobble sound
1954
+ */
1955
+ stopWobbleSound() {
1956
+ if (this._wobbleOsc) {
1957
+ this._wobbleOsc.setVolume(0, 0.2);
1958
+ }
1959
+ }
1960
+
1961
+ /**
1962
+ * Show floating text that rises and fades (for bonuses, etc.)
1963
+ */
1964
+ showFloatingText(text, x, y) {
1965
+ if (!this._floatingTexts) {
1966
+ this._floatingTexts = [];
1967
+ }
1968
+
1969
+ this._floatingTexts.push({
1970
+ text,
1971
+ x,
1972
+ y,
1973
+ startY: y,
1974
+ life: 1.0, // seconds
1975
+ age: 0,
1976
+ });
1977
+ }
1978
+
1979
+ /**
1980
+ * Update floating texts
1981
+ */
1982
+ updateFloatingTexts(dt) {
1983
+ if (!this._floatingTexts) return;
1984
+
1985
+ for (let i = this._floatingTexts.length - 1; i >= 0; i--) {
1986
+ const ft = this._floatingTexts[i];
1987
+ ft.age += dt;
1988
+ ft.y = ft.startY - ft.age * 60; // Rise up
1989
+
1990
+ if (ft.age >= ft.life) {
1991
+ this._floatingTexts.splice(i, 1);
1992
+ }
1993
+ }
1994
+ }
1995
+
1996
+ /**
1997
+ * Render floating texts
1998
+ */
1999
+ renderFloatingTexts() {
2000
+ if (!this._floatingTexts || this._floatingTexts.length === 0) return;
2001
+
2002
+ Painter.useCtx((ctx) => {
2003
+ ctx.font = "bold 16px monospace";
2004
+ ctx.textAlign = "center";
2005
+ ctx.textBaseline = "middle";
2006
+
2007
+ for (const ft of this._floatingTexts) {
2008
+ const alpha = 1 - ft.age / ft.life;
2009
+ const scale = 1 + ft.age * 0.5; // Grow slightly
2010
+
2011
+ ctx.save();
2012
+ ctx.translate(ft.x, ft.y);
2013
+ ctx.scale(scale, scale);
2014
+ ctx.globalAlpha = alpha;
2015
+
2016
+ // Outline
2017
+ ctx.strokeStyle = "black";
2018
+ ctx.lineWidth = 3;
2019
+ ctx.strokeText(ft.text, 0, 0);
2020
+
2021
+ // Fill with yellow/gold color
2022
+ ctx.fillStyle = "#FFD700";
2023
+ ctx.fillText(ft.text, 0, 0);
2024
+
2025
+ ctx.restore();
2026
+ }
2027
+ });
2028
+ }
2029
+
2030
+ /**
2031
+ * Play eating visual effect - mouth opens wide and blob flashes white
2032
+ */
2033
+ playEatEffect() {
2034
+ // Initialize flash amount if needed
2035
+ if (this._flashAmount === undefined) {
2036
+ this._flashAmount = 0;
2037
+ }
2038
+
2039
+ // Flash white effect using Tweenetik
2040
+ this._flashAmount = 1; // Start at full white
2041
+ Tweenetik.to(this, { _flashAmount: 0 }, 0.3, Easing.easeOutQuad);
2042
+
2043
+ // Mouth chomping animation - open wide then close
2044
+ // Save current mouth path
2045
+ const originalPath = this.mouth.shape.path;
2046
+
2047
+ // Open mouth wide (big O shape)
2048
+ this.mouth.shape.path = [
2049
+ ["M", -20, -8],
2050
+ ["Q", -25, 8, 0, 12],
2051
+ ["Q", 25, 8, 20, -8],
2052
+ ["Q", 10, -12, 0, -10],
2053
+ ["Q", -10, -12, -20, -8],
2054
+ ];
2055
+ this.mouth.shape.color = "rgba(50, 20, 20, 0.8)";
2056
+
2057
+ // Close mouth after short delay
2058
+ setTimeout(() => {
2059
+ if (!this.isDead()) {
2060
+ this.mouth.shape.color = null;
2061
+ // Restore based on current mood
2062
+ this.updateMoodFromEnergy();
2063
+ }
2064
+ }, 150);
2065
+ }
2066
+
2067
+ /**
2068
+ * Render particles around the blob when excited
2069
+ */
2070
+ renderExcitementParticles() {
2071
+ const { currentX, currentY } = this.blobPhysics;
2072
+
2073
+ // Number of particles based on excitement
2074
+ const particleCount = Math.floor(
2075
+ this.blobPhysics.excitementLevel * 2 * this.speed
2076
+ );
2077
+ for (let i = 0; i < particleCount; i++) {
2078
+ // Random position around the blob
2079
+ const angle = Math.random() * Math.PI * 2;
2080
+ const dist =
2081
+ this.blobPhysics.currentRadius *
2082
+ ((1 * this.speed) / 20 + Math.random() * 0.5);
2083
+ const x = currentX + Math.cos(angle) * dist;
2084
+ const y = currentY + Math.sin(angle) * dist;
2085
+ // Size based on excitement
2086
+ const size = 2 + Math.random() * 5 * this.blobPhysics.excitementLevel;
2087
+ // Use the blob's current color
2088
+ const { currentColor } = this.blobPhysics;
2089
+ const alpha = 0.4 + Math.random() * 0.6;
2090
+ // Draw the particle
2091
+ Painter.shapes.fillCircle(
2092
+ x,
2093
+ y,
2094
+ size,
2095
+ `rgba(${currentColor[0]}, ${currentColor[1]}, ${currentColor[2]}, ${alpha})`
2096
+ );
2097
+ }
2098
+ }
2099
+
2100
+ /**
2101
+ * Generate a smooth closed path through the control points using Bezier curves
2102
+ */
2103
+ generateBlobPath(points) {
2104
+ if (points.length < 3) return [];
2105
+ const path = [];
2106
+ const n = points.length;
2107
+ // Start at the first point
2108
+ path.push(["M", points[0].x, points[0].y]);
2109
+ // For each point, create a bezier curve to the next point
2110
+ for (let i = 0; i < n; i++) {
2111
+ const curr = points[i];
2112
+ const next = points[(i + 1) % n];
2113
+ const nextNext = points[(i + 2) % n];
2114
+ // Calculate control points for a smooth curve
2115
+ // Use the midpoint between current and next as the end point of the curve
2116
+ const midX = (next.x + curr.x) / 2;
2117
+ const midY = (next.y + curr.y) / 2;
2118
+ // Control point 1 - between current and next, biased toward current
2119
+ const cp1x = curr.x + (next.x - curr.x) * 0.5;
2120
+ const cp1y = curr.y + (next.y - curr.y) * 0.5;
2121
+ // Control point 2 - between next and next-next, biased toward next
2122
+ const cp2x = next.x + (midX - next.x) * 0.5;
2123
+ const cp2y = next.y + (midY - next.y) * 0.5;
2124
+ // Add the cubic Bezier curve command
2125
+ path.push(["C", cp1x, cp1y, cp2x, cp2y, midX, midY]);
2126
+ }
2127
+ // Close the path
2128
+ path.push(["Z"]);
2129
+ return path;
2130
+ }
2131
+
2132
+ triggerAnimation(animType) {
2133
+ const anim = this.animations[animType + "Animation"];
2134
+ if (!anim) return;
2135
+
2136
+ anim.active = true;
2137
+ anim.elapsed = 0;
2138
+ anim.startTime = this.time;
2139
+ }
2140
+
2141
+ }
2142
+
2143
+ /**
2144
+ * UI Scene for the blob demo
2145
+ */
2146
+ class BlobUIScene extends Scene {
2147
+ constructor(game, blobScene, options = {}) {
2148
+ super(game, options);
2149
+ this.blobScene = blobScene;
2150
+ this._lastMobileState = null;
2151
+ this.createLayout();
2152
+ }
2153
+
2154
+ /**
2155
+ * Create the UI layout based on current screen size
2156
+ */
2157
+ createLayout() {
2158
+ // Remove existing layout if any
2159
+ if (this.layout) {
2160
+ this.remove(this.layout);
2161
+ this.layout = null;
2162
+ }
2163
+
2164
+ const config = this.game.getResponsiveConfig();
2165
+
2166
+ // Always use horizontal layout at bottom left
2167
+ this.layout = new HorizontalLayout(this.game, {
2168
+ spacing: config.spacing,
2169
+ padding: 0,
2170
+ debug: this.game.debug,
2171
+ debugColor: "purple",
2172
+ anchor: config.anchor,
2173
+ width:200,
2174
+ height:30,
2175
+ anchorOffsetX: config.anchorOffsetX,
2176
+ anchorOffsetY: config.anchorOffsetY,
2177
+ });
2178
+
2179
+ // Add buttons
2180
+ this.resetBtn = new Button(this.game, {
2181
+ text: "Reset",
2182
+ width: config.buttonWidth,
2183
+ height: config.buttonHeight,
2184
+ onClick: () => this.resetBlob(),
2185
+ });
2186
+ this.layout.add(this.resetBtn);
2187
+
2188
+ this.colorBtn = new Button(this.game, {
2189
+ text: "🎨 Recolor",
2190
+ width: config.buttonWidth,
2191
+ height: config.buttonHeight,
2192
+ onClick: () => this.blobScene.triggerBlobGradientShift(),
2193
+ });
2194
+ this.layout.add(this.colorBtn);
2195
+
2196
+ this.add(this.layout);
2197
+ }
2198
+
2199
+ resetBlob() {
2200
+ const physics = this.blobScene.blobPhysics;
2201
+
2202
+ // Reset physics - use CONFIG for starting size
2203
+ physics.baseRadius = CONFIG.startRadius;
2204
+ physics.currentRadius = CONFIG.startRadius;
2205
+ physics.healthyRadius = CONFIG.startRadius;
2206
+ physics.energy = 1.0;
2207
+ physics.baseColor = [64, 180, 255];
2208
+ physics.vx = 0;
2209
+ physics.vy = 0;
2210
+
2211
+ // Reset visual state
2212
+ this.blobScene.blobVisualBaseColor = null;
2213
+ this.blobScene.blob.scaleX = 1;
2214
+ this.blobScene.blob.scaleY = 1;
2215
+ this.blobScene.fallSquish = 0;
2216
+
2217
+ // Reset position
2218
+ physics.currentX = this.game.width / 2;
2219
+ physics.currentY = this.game.height / 2;
2220
+ this.blobScene.mouseX = this.game.width / 2;
2221
+ this.blobScene.mouseY = this.game.height / 2;
2222
+ this.blobScene.blob.x = physics.currentX;
2223
+ this.blobScene.blob.y = physics.currentY;
2224
+
2225
+ // Reset facial features (will be scaled by positionBlobFeatures)
2226
+ this.blobScene.leftPupil.visible = true;
2227
+ this.blobScene.rightPupil.visible = true;
2228
+
2229
+ // Reset mood to happy
2230
+ this.blobScene.setMood(1);
2231
+
2232
+ // Go back to ready state (shows play button)
2233
+ this.blobScene.stateMachine.setState("ready");
2234
+ }
2235
+
2236
+ onResize() {
2237
+ const isMobile = this.game.isMobile();
2238
+
2239
+ // Only recreate layout if mobile state changed
2240
+ if (this._lastMobileState !== isMobile) {
2241
+ this._lastMobileState = isMobile;
2242
+ this.createLayout();
2243
+ }
2244
+ }
2245
+
2246
+ update(dt) {
2247
+ super.update(dt);
2248
+
2249
+ // Hide UI buttons in ready state
2250
+ const isReady = this.blobScene.isReady();
2251
+ if (this.layout) {
2252
+ this.layout.visible = !isReady;
2253
+ }
2254
+
2255
+ // Update button text based on blob state
2256
+ if (this.colorBtn && !isReady) {
2257
+ const isDead = this.blobScene.isDead();
2258
+ const newText = isDead ? "▶ Play Again" : "🎨 Recolor";
2259
+ if (this.colorBtn.text !== newText) {
2260
+ this.colorBtn.text = newText;
2261
+ }
2262
+ }
2263
+ }
2264
+ }
2265
+
2266
+ /**
2267
+ * Normalize an angle to be between -PI and PI
2268
+ */
2269
+ function normalizeAngle(angle) {
2270
+ while (angle > Math.PI) angle -= Math.PI * 2;
2271
+ while (angle < -Math.PI) angle += Math.PI * 2;
2272
+ return angle;
2273
+ }
2274
+
2275
+ // Export the game
2276
+ export { BezierBlobGame };