@guinetik/gcanvas 1.0.0

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