@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.
- package/.github/workflows/release.yaml +70 -0
- package/.jshintrc +4 -0
- package/.vscode/settings.json +22 -0
- package/CLAUDE.md +310 -0
- package/blackhole.jpg +0 -0
- package/demo.png +0 -0
- package/demos/CNAME +1 -0
- package/demos/animations.html +31 -0
- package/demos/basic.html +38 -0
- package/demos/baskara.html +31 -0
- package/demos/bezier.html +35 -0
- package/demos/beziersignature.html +29 -0
- package/demos/blackhole.html +28 -0
- package/demos/blob.html +35 -0
- package/demos/demos.css +289 -0
- package/demos/easing.html +28 -0
- package/demos/events.html +195 -0
- package/demos/fluent.html +647 -0
- package/demos/fractals.html +36 -0
- package/demos/genart.html +26 -0
- package/demos/gendream.html +26 -0
- package/demos/group.html +36 -0
- package/demos/home.html +587 -0
- package/demos/index.html +364 -0
- package/demos/isometric.html +34 -0
- package/demos/js/animations.js +452 -0
- package/demos/js/basic.js +204 -0
- package/demos/js/baskara.js +751 -0
- package/demos/js/bezier.js +692 -0
- package/demos/js/beziersignature.js +241 -0
- package/demos/js/blackhole/accretiondisk.obj.js +379 -0
- package/demos/js/blackhole/blackhole.obj.js +318 -0
- package/demos/js/blackhole/index.js +409 -0
- package/demos/js/blackhole/particle.js +56 -0
- package/demos/js/blackhole/starfield.obj.js +218 -0
- package/demos/js/blob.js +2263 -0
- package/demos/js/easing.js +477 -0
- package/demos/js/fluent.js +183 -0
- package/demos/js/fractals.js +931 -0
- package/demos/js/fractalworker.js +93 -0
- package/demos/js/genart.js +268 -0
- package/demos/js/gendream.js +209 -0
- package/demos/js/group.js +140 -0
- package/demos/js/info-toggle.js +25 -0
- package/demos/js/isometric.js +863 -0
- package/demos/js/kerr.js +1556 -0
- package/demos/js/lavalamp.js +590 -0
- package/demos/js/layout.js +354 -0
- package/demos/js/mondrian.js +285 -0
- package/demos/js/opacity.js +275 -0
- package/demos/js/painter.js +484 -0
- package/demos/js/particles-showcase.js +514 -0
- package/demos/js/particles.js +299 -0
- package/demos/js/patterns.js +397 -0
- package/demos/js/penrose/artifact.js +69 -0
- package/demos/js/penrose/blackhole.js +121 -0
- package/demos/js/penrose/constants.js +73 -0
- package/demos/js/penrose/game.js +943 -0
- package/demos/js/penrose/lore.js +278 -0
- package/demos/js/penrose/penrosescene.js +892 -0
- package/demos/js/penrose/ship.js +216 -0
- package/demos/js/penrose/sounds.js +211 -0
- package/demos/js/penrose/voidparticle.js +55 -0
- package/demos/js/penrose/voidscene.js +258 -0
- package/demos/js/penrose/voidship.js +144 -0
- package/demos/js/penrose/wormhole.js +46 -0
- package/demos/js/pipeline.js +555 -0
- package/demos/js/scene.js +304 -0
- package/demos/js/scenes.js +320 -0
- package/demos/js/schrodinger.js +410 -0
- package/demos/js/schwarzschild.js +1023 -0
- package/demos/js/shapes.js +628 -0
- package/demos/js/space/alien.js +171 -0
- package/demos/js/space/boom.js +98 -0
- package/demos/js/space/boss.js +353 -0
- package/demos/js/space/buff.js +73 -0
- package/demos/js/space/bullet.js +102 -0
- package/demos/js/space/constants.js +85 -0
- package/demos/js/space/game.js +1884 -0
- package/demos/js/space/hud.js +112 -0
- package/demos/js/space/laserbeam.js +179 -0
- package/demos/js/space/lightning.js +277 -0
- package/demos/js/space/minion.js +192 -0
- package/demos/js/space/missile.js +212 -0
- package/demos/js/space/player.js +430 -0
- package/demos/js/space/powerup.js +90 -0
- package/demos/js/space/starfield.js +58 -0
- package/demos/js/space/starpower.js +90 -0
- package/demos/js/spacetime.js +559 -0
- package/demos/js/svgtween.js +204 -0
- package/demos/js/tde/accretiondisk.js +418 -0
- package/demos/js/tde/blackhole.js +219 -0
- package/demos/js/tde/blackholescene.js +209 -0
- package/demos/js/tde/config.js +59 -0
- package/demos/js/tde/index.js +695 -0
- package/demos/js/tde/jets.js +290 -0
- package/demos/js/tde/lensedstarfield.js +147 -0
- package/demos/js/tde/tdestar.js +317 -0
- package/demos/js/tde/tidalstream.js +356 -0
- package/demos/js/tde_old/blackhole.obj.js +354 -0
- package/demos/js/tde_old/debris.obj.js +791 -0
- package/demos/js/tde_old/flare.obj.js +239 -0
- package/demos/js/tde_old/index.js +448 -0
- package/demos/js/tde_old/star.obj.js +812 -0
- package/demos/js/tiles.js +312 -0
- package/demos/js/tweendemo.js +79 -0
- package/demos/js/visibility.js +102 -0
- package/demos/kerr.html +28 -0
- package/demos/lavalamp.html +27 -0
- package/demos/layouts.html +37 -0
- package/demos/logo.svg +4 -0
- package/demos/loop.html +84 -0
- package/demos/mondrian.html +32 -0
- package/demos/og_image.png +0 -0
- package/demos/opacity.html +36 -0
- package/demos/painter.html +39 -0
- package/demos/particles-showcase.html +28 -0
- package/demos/particles.html +24 -0
- package/demos/patterns.html +33 -0
- package/demos/penrose-game.html +31 -0
- package/demos/pipeline.html +737 -0
- package/demos/scene.html +33 -0
- package/demos/scenes.html +96 -0
- package/demos/schrodinger.html +27 -0
- package/demos/schwarzschild.html +27 -0
- package/demos/shapes.html +16 -0
- package/demos/space.html +85 -0
- package/demos/spacetime.html +27 -0
- package/demos/svgtween.html +29 -0
- package/demos/tde.html +28 -0
- package/demos/tiles.html +28 -0
- package/demos/transforms.html +400 -0
- package/demos/tween.html +45 -0
- package/demos/visibility.html +33 -0
- package/disk_example.png +0 -0
- package/docs/README.md +222 -0
- package/docs/concepts/architecture-overview.md +204 -0
- package/docs/concepts/lifecycle.md +255 -0
- package/docs/concepts/rendering-pipeline.md +279 -0
- package/docs/concepts/tde-zorder.md +106 -0
- package/docs/concepts/two-layer-architecture.md +229 -0
- package/docs/getting-started/first-game.md +354 -0
- package/docs/getting-started/hello-world.md +269 -0
- package/docs/getting-started/installation.md +157 -0
- package/docs/modules/collision/README.md +453 -0
- package/docs/modules/fluent/README.md +1075 -0
- package/docs/modules/game/README.md +303 -0
- package/docs/modules/isometric-camera.md +210 -0
- package/docs/modules/isometric.md +275 -0
- package/docs/modules/painter/README.md +328 -0
- package/docs/modules/particle/README.md +559 -0
- package/docs/modules/shapes/README.md +221 -0
- package/docs/modules/shapes/base/euclidian.md +123 -0
- package/docs/modules/shapes/base/geometry2d.md +204 -0
- package/docs/modules/shapes/base/renderable.md +215 -0
- package/docs/modules/shapes/base/shape.md +262 -0
- package/docs/modules/shapes/base/transformable.md +243 -0
- package/docs/modules/shapes/hierarchy.md +218 -0
- package/docs/modules/state/README.md +577 -0
- package/docs/modules/util/README.md +99 -0
- package/docs/modules/util/camera3d.md +412 -0
- package/docs/modules/util/scene3d.md +395 -0
- package/index.html +17 -0
- package/jsdoc.json +50 -0
- package/package.json +55 -0
- package/readme.md +599 -0
- package/scripts/build-demo.js +69 -0
- package/scripts/bundle4llm.js +276 -0
- package/scripts/clearconsole.js +48 -0
- package/src/collision/collision-system.js +332 -0
- package/src/collision/collision.js +303 -0
- package/src/collision/index.js +10 -0
- package/src/fluent/fluent-game.js +430 -0
- package/src/fluent/fluent-go.js +1060 -0
- package/src/fluent/fluent-layer.js +152 -0
- package/src/fluent/fluent-scene.js +291 -0
- package/src/fluent/index.js +98 -0
- package/src/fluent/sketch.js +380 -0
- package/src/game/game.js +467 -0
- package/src/game/index.js +49 -0
- package/src/game/objects/go.js +220 -0
- package/src/game/objects/imagego.js +30 -0
- package/src/game/objects/index.js +54 -0
- package/src/game/objects/isometric-scene.js +260 -0
- package/src/game/objects/layoutscene.js +549 -0
- package/src/game/objects/scene.js +175 -0
- package/src/game/objects/scene3d.js +118 -0
- package/src/game/objects/text.js +221 -0
- package/src/game/objects/wrapper.js +232 -0
- package/src/game/pipeline.js +243 -0
- package/src/game/ui/button.js +396 -0
- package/src/game/ui/cursor.js +93 -0
- package/src/game/ui/fps.js +91 -0
- package/src/game/ui/index.js +5 -0
- package/src/game/ui/togglebutton.js +93 -0
- package/src/game/ui/tooltip.js +249 -0
- package/src/index.js +25 -0
- package/src/io/events.js +20 -0
- package/src/io/index.js +86 -0
- package/src/io/input.js +70 -0
- package/src/io/keys.js +152 -0
- package/src/io/mouse.js +61 -0
- package/src/io/touch.js +39 -0
- package/src/logger/debugtab.js +138 -0
- package/src/logger/index.js +3 -0
- package/src/logger/loggable.js +47 -0
- package/src/logger/logger.js +113 -0
- package/src/math/complex.js +37 -0
- package/src/math/constants.js +1 -0
- package/src/math/fractal.js +1271 -0
- package/src/math/gr.js +201 -0
- package/src/math/heat.js +202 -0
- package/src/math/index.js +12 -0
- package/src/math/noise.js +433 -0
- package/src/math/orbital.js +191 -0
- package/src/math/patterns.js +1339 -0
- package/src/math/penrose.js +259 -0
- package/src/math/quantum.js +115 -0
- package/src/math/random.js +195 -0
- package/src/math/tensor.js +1009 -0
- package/src/mixins/anchor.js +131 -0
- package/src/mixins/draggable.js +72 -0
- package/src/mixins/index.js +2 -0
- package/src/motion/bezier.js +132 -0
- package/src/motion/bounce.js +58 -0
- package/src/motion/easing.js +349 -0
- package/src/motion/float.js +130 -0
- package/src/motion/follow.js +125 -0
- package/src/motion/hop.js +52 -0
- package/src/motion/index.js +82 -0
- package/src/motion/motion.js +1124 -0
- package/src/motion/orbit.js +49 -0
- package/src/motion/oscillate.js +39 -0
- package/src/motion/parabolic.js +141 -0
- package/src/motion/patrol.js +147 -0
- package/src/motion/pendulum.js +48 -0
- package/src/motion/pulse.js +88 -0
- package/src/motion/shake.js +83 -0
- package/src/motion/spiral.js +144 -0
- package/src/motion/spring.js +150 -0
- package/src/motion/swing.js +47 -0
- package/src/motion/tween.js +92 -0
- package/src/motion/tweenetik.js +139 -0
- package/src/motion/waypoint.js +210 -0
- package/src/painter/index.js +8 -0
- package/src/painter/painter.colors.js +331 -0
- package/src/painter/painter.effects.js +230 -0
- package/src/painter/painter.img.js +229 -0
- package/src/painter/painter.js +295 -0
- package/src/painter/painter.lines.js +189 -0
- package/src/painter/painter.opacity.js +41 -0
- package/src/painter/painter.shapes.js +277 -0
- package/src/painter/painter.text.js +273 -0
- package/src/particle/emitter.js +124 -0
- package/src/particle/index.js +11 -0
- package/src/particle/particle-system.js +322 -0
- package/src/particle/particle.js +71 -0
- package/src/particle/updaters.js +170 -0
- package/src/shapes/arc.js +43 -0
- package/src/shapes/arrow.js +33 -0
- package/src/shapes/bezier.js +42 -0
- package/src/shapes/circle.js +62 -0
- package/src/shapes/clouds.js +56 -0
- package/src/shapes/cone.js +219 -0
- package/src/shapes/cross.js +70 -0
- package/src/shapes/cube.js +244 -0
- package/src/shapes/cylinder.js +254 -0
- package/src/shapes/diamond.js +48 -0
- package/src/shapes/euclidian.js +111 -0
- package/src/shapes/figure.js +115 -0
- package/src/shapes/geometry.js +220 -0
- package/src/shapes/group.js +375 -0
- package/src/shapes/heart.js +42 -0
- package/src/shapes/hexagon.js +26 -0
- package/src/shapes/image.js +192 -0
- package/src/shapes/index.js +111 -0
- package/src/shapes/line.js +29 -0
- package/src/shapes/pattern.js +90 -0
- package/src/shapes/pin.js +44 -0
- package/src/shapes/poly.js +31 -0
- package/src/shapes/prism.js +226 -0
- package/src/shapes/rect.js +35 -0
- package/src/shapes/renderable.js +333 -0
- package/src/shapes/ring.js +26 -0
- package/src/shapes/roundrect.js +95 -0
- package/src/shapes/shape.js +117 -0
- package/src/shapes/slice.js +26 -0
- package/src/shapes/sphere.js +314 -0
- package/src/shapes/sphere3d.js +537 -0
- package/src/shapes/square.js +15 -0
- package/src/shapes/star.js +99 -0
- package/src/shapes/svg.js +408 -0
- package/src/shapes/text.js +553 -0
- package/src/shapes/traceable.js +83 -0
- package/src/shapes/transform.js +357 -0
- package/src/shapes/transformable.js +172 -0
- package/src/shapes/triangle.js +26 -0
- package/src/sound/index.js +17 -0
- package/src/sound/sound.js +473 -0
- package/src/sound/synth.analyzer.js +149 -0
- package/src/sound/synth.effects.js +207 -0
- package/src/sound/synth.envelope.js +59 -0
- package/src/sound/synth.js +229 -0
- package/src/sound/synth.musical.js +160 -0
- package/src/sound/synth.noise.js +85 -0
- package/src/sound/synth.oscillators.js +293 -0
- package/src/state/index.js +10 -0
- package/src/state/state-machine.js +371 -0
- package/src/util/camera3d.js +438 -0
- package/src/util/index.js +6 -0
- package/src/util/isometric-camera.js +235 -0
- package/src/util/layout.js +317 -0
- package/src/util/position.js +147 -0
- package/src/util/tasks.js +47 -0
- package/src/util/zindex.js +287 -0
- package/src/webgl/index.js +9 -0
- package/src/webgl/shaders/sphere-shaders.js +994 -0
- package/src/webgl/webgl-renderer.js +388 -0
- package/tde.png +0 -0
- package/test/math/orbital.test.js +61 -0
- package/test/math/tensor.test.js +114 -0
- package/test/particle/emitter.test.js +204 -0
- package/test/particle/particle-system.test.js +310 -0
- package/test/particle/particle.test.js +116 -0
- package/test/particle/updaters.test.js +386 -0
- package/test/setup.js +120 -0
- package/test/shapes/euclidian.test.js +44 -0
- package/test/shapes/geometry.test.js +86 -0
- package/test/shapes/group.test.js +86 -0
- package/test/shapes/rectangle.test.js +64 -0
- package/test/shapes/transform.test.js +379 -0
- package/test/util/camera3d.test.js +428 -0
- package/test/util/scene3d.test.js +352 -0
- package/types/collision.d.ts +249 -0
- package/types/common.d.ts +155 -0
- package/types/game.d.ts +497 -0
- package/types/index.d.ts +309 -0
- package/types/io.d.ts +188 -0
- package/types/logger.d.ts +127 -0
- package/types/math.d.ts +268 -0
- package/types/mixins.d.ts +92 -0
- package/types/motion.d.ts +678 -0
- package/types/painter.d.ts +378 -0
- package/types/shapes.d.ts +864 -0
- package/types/sound.d.ts +672 -0
- package/types/state.d.ts +251 -0
- package/types/util.d.ts +253 -0
- package/vite.config.js +50 -0
- package/vitest.config.js +13 -0
package/demos/js/blob.js
ADDED
|
@@ -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 };
|