@guinetik/gcanvas 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/dist/CNAME +1 -0
  2. package/dist/animations.html +31 -0
  3. package/dist/basic.html +38 -0
  4. package/dist/baskara.html +31 -0
  5. package/dist/bezier.html +35 -0
  6. package/dist/beziersignature.html +29 -0
  7. package/dist/blackhole.html +28 -0
  8. package/dist/blob.html +35 -0
  9. package/dist/coordinates.html +698 -0
  10. package/dist/cube3d.html +23 -0
  11. package/dist/demos.css +303 -0
  12. package/dist/dino.html +42 -0
  13. package/dist/easing.html +28 -0
  14. package/dist/events.html +195 -0
  15. package/dist/fluent.html +647 -0
  16. package/dist/fluid-simple.html +22 -0
  17. package/dist/fluid.html +37 -0
  18. package/dist/fractals.html +36 -0
  19. package/dist/gameobjects.html +626 -0
  20. package/dist/gcanvas.es.js +517 -0
  21. package/dist/gcanvas.es.min.js +1 -1
  22. package/dist/gcanvas.umd.js +1 -1
  23. package/dist/gcanvas.umd.min.js +1 -1
  24. package/dist/genart.html +26 -0
  25. package/dist/gendream.html +26 -0
  26. package/dist/group.html +36 -0
  27. package/dist/home.html +587 -0
  28. package/dist/hyperbolic001.html +23 -0
  29. package/dist/hyperbolic002.html +23 -0
  30. package/dist/hyperbolic003.html +23 -0
  31. package/dist/hyperbolic004.html +23 -0
  32. package/dist/hyperbolic005.html +22 -0
  33. package/dist/index.html +398 -0
  34. package/dist/isometric.html +34 -0
  35. package/dist/js/animations.js +452 -0
  36. package/dist/js/basic.js +204 -0
  37. package/dist/js/baskara.js +751 -0
  38. package/dist/js/bezier.js +692 -0
  39. package/dist/js/beziersignature.js +241 -0
  40. package/dist/js/blackhole/accretiondisk.obj.js +379 -0
  41. package/dist/js/blackhole/blackhole.obj.js +318 -0
  42. package/dist/js/blackhole/index.js +409 -0
  43. package/dist/js/blackhole/particle.js +56 -0
  44. package/dist/js/blackhole/starfield.obj.js +218 -0
  45. package/dist/js/blob.js +2276 -0
  46. package/dist/js/coordinates.js +840 -0
  47. package/dist/js/cube3d.js +789 -0
  48. package/dist/js/dino.js +1420 -0
  49. package/dist/js/easing.js +477 -0
  50. package/dist/js/fluent.js +183 -0
  51. package/dist/js/fluid-simple.js +253 -0
  52. package/dist/js/fluid.js +527 -0
  53. package/dist/js/fractals.js +932 -0
  54. package/dist/js/fractalworker.js +93 -0
  55. package/dist/js/gameobjects.js +176 -0
  56. package/dist/js/genart.js +268 -0
  57. package/dist/js/gendream.js +209 -0
  58. package/dist/js/group.js +140 -0
  59. package/dist/js/hyperbolic001.js +310 -0
  60. package/dist/js/hyperbolic002.js +388 -0
  61. package/dist/js/hyperbolic003.js +319 -0
  62. package/dist/js/hyperbolic004.js +345 -0
  63. package/dist/js/hyperbolic005.js +340 -0
  64. package/dist/js/info-toggle.js +25 -0
  65. package/dist/js/isometric.js +863 -0
  66. package/dist/js/kerr.js +1547 -0
  67. package/dist/js/lavalamp.js +590 -0
  68. package/dist/js/layout.js +354 -0
  69. package/dist/js/mondrian.js +285 -0
  70. package/dist/js/opacity.js +275 -0
  71. package/dist/js/painter.js +484 -0
  72. package/dist/js/particles-showcase.js +514 -0
  73. package/dist/js/particles.js +299 -0
  74. package/dist/js/patterns.js +397 -0
  75. package/dist/js/penrose/artifact.js +69 -0
  76. package/dist/js/penrose/blackhole.js +121 -0
  77. package/dist/js/penrose/constants.js +73 -0
  78. package/dist/js/penrose/game.js +943 -0
  79. package/dist/js/penrose/lore.js +278 -0
  80. package/dist/js/penrose/penrosescene.js +892 -0
  81. package/dist/js/penrose/ship.js +216 -0
  82. package/dist/js/penrose/sounds.js +211 -0
  83. package/dist/js/penrose/voidparticle.js +55 -0
  84. package/dist/js/penrose/voidscene.js +258 -0
  85. package/dist/js/penrose/voidship.js +144 -0
  86. package/dist/js/penrose/wormhole.js +46 -0
  87. package/dist/js/pipeline.js +555 -0
  88. package/dist/js/plane3d.js +256 -0
  89. package/dist/js/platformer.js +1579 -0
  90. package/dist/js/scene.js +304 -0
  91. package/dist/js/scenes.js +320 -0
  92. package/dist/js/schrodinger.js +410 -0
  93. package/dist/js/schwarzschild.js +1015 -0
  94. package/dist/js/shapes.js +628 -0
  95. package/dist/js/space/alien.js +171 -0
  96. package/dist/js/space/boom.js +98 -0
  97. package/dist/js/space/boss.js +353 -0
  98. package/dist/js/space/buff.js +73 -0
  99. package/dist/js/space/bullet.js +102 -0
  100. package/dist/js/space/constants.js +85 -0
  101. package/dist/js/space/game.js +1884 -0
  102. package/dist/js/space/hud.js +112 -0
  103. package/dist/js/space/laserbeam.js +179 -0
  104. package/dist/js/space/lightning.js +277 -0
  105. package/dist/js/space/minion.js +192 -0
  106. package/dist/js/space/missile.js +212 -0
  107. package/dist/js/space/player.js +430 -0
  108. package/dist/js/space/powerup.js +90 -0
  109. package/dist/js/space/starfield.js +58 -0
  110. package/dist/js/space/starpower.js +90 -0
  111. package/dist/js/spacetime.js +559 -0
  112. package/dist/js/sphere3d.js +229 -0
  113. package/dist/js/sprite.js +473 -0
  114. package/dist/js/starfaux/config.js +118 -0
  115. package/dist/js/starfaux/enemy.js +353 -0
  116. package/dist/js/starfaux/hud.js +78 -0
  117. package/dist/js/starfaux/index.js +482 -0
  118. package/dist/js/starfaux/laser.js +182 -0
  119. package/dist/js/starfaux/player.js +468 -0
  120. package/dist/js/starfaux/terrain.js +560 -0
  121. package/dist/js/study001.js +275 -0
  122. package/dist/js/study002.js +366 -0
  123. package/dist/js/study003.js +331 -0
  124. package/dist/js/study004.js +389 -0
  125. package/dist/js/study005.js +209 -0
  126. package/dist/js/study006.js +194 -0
  127. package/dist/js/study007.js +192 -0
  128. package/dist/js/study008.js +413 -0
  129. package/dist/js/svgtween.js +204 -0
  130. package/dist/js/tde/accretiondisk.js +471 -0
  131. package/dist/js/tde/blackhole.js +219 -0
  132. package/dist/js/tde/blackholescene.js +209 -0
  133. package/dist/js/tde/config.js +59 -0
  134. package/dist/js/tde/index.js +820 -0
  135. package/dist/js/tde/jets.js +290 -0
  136. package/dist/js/tde/lensedstarfield.js +154 -0
  137. package/dist/js/tde/tdestar.js +297 -0
  138. package/dist/js/tde/tidalstream.js +372 -0
  139. package/dist/js/tde_old/blackhole.obj.js +354 -0
  140. package/dist/js/tde_old/debris.obj.js +791 -0
  141. package/dist/js/tde_old/flare.obj.js +239 -0
  142. package/dist/js/tde_old/index.js +448 -0
  143. package/dist/js/tde_old/star.obj.js +812 -0
  144. package/dist/js/tetris/config.js +157 -0
  145. package/dist/js/tetris/grid.js +286 -0
  146. package/dist/js/tetris/index.js +1195 -0
  147. package/dist/js/tetris/renderer.js +634 -0
  148. package/dist/js/tetris/tetrominos.js +280 -0
  149. package/dist/js/tiles.js +312 -0
  150. package/dist/js/tweendemo.js +79 -0
  151. package/dist/js/visibility.js +102 -0
  152. package/dist/kerr.html +28 -0
  153. package/dist/lavalamp.html +27 -0
  154. package/dist/layouts.html +37 -0
  155. package/dist/logo.svg +4 -0
  156. package/dist/loop.html +84 -0
  157. package/dist/mondrian.html +32 -0
  158. package/dist/og_image.png +0 -0
  159. package/dist/opacity.html +36 -0
  160. package/dist/painter.html +39 -0
  161. package/dist/particles-showcase.html +28 -0
  162. package/dist/particles.html +24 -0
  163. package/dist/patterns.html +33 -0
  164. package/dist/penrose-game.html +31 -0
  165. package/dist/pipeline.html +737 -0
  166. package/dist/plane3d.html +24 -0
  167. package/dist/platformer.html +43 -0
  168. package/dist/scene.html +33 -0
  169. package/dist/scenes.html +96 -0
  170. package/dist/schrodinger.html +27 -0
  171. package/dist/schwarzschild.html +27 -0
  172. package/dist/shapes.html +16 -0
  173. package/dist/space.html +85 -0
  174. package/dist/spacetime.html +27 -0
  175. package/dist/sphere3d.html +24 -0
  176. package/dist/sprite.html +18 -0
  177. package/dist/starfaux.html +22 -0
  178. package/dist/study001.html +23 -0
  179. package/dist/study002.html +23 -0
  180. package/dist/study003.html +23 -0
  181. package/dist/study004.html +23 -0
  182. package/dist/study005.html +22 -0
  183. package/dist/study006.html +24 -0
  184. package/dist/study007.html +24 -0
  185. package/dist/study008.html +22 -0
  186. package/dist/svgtween.html +29 -0
  187. package/dist/tde.html +28 -0
  188. package/dist/tetris3d.html +25 -0
  189. package/dist/tiles.html +28 -0
  190. package/dist/transforms.html +400 -0
  191. package/dist/tween.html +45 -0
  192. package/dist/visibility.html +33 -0
  193. package/package.json +1 -1
@@ -0,0 +1,1579 @@
1
+ import {
2
+ Game,
3
+ GameObject,
4
+ Text,
5
+ Rectangle,
6
+ Circle,
7
+ Keys,
8
+ FPSCounter,
9
+ PlatformerScene,
10
+ Collision,
11
+ Painter,
12
+ Position,
13
+ Group,
14
+ Synth,
15
+ Sprite,
16
+ Camera2D,
17
+ } from "/gcanvas.es.min.js";
18
+
19
+ // ==================== Configuration ====================
20
+ const CONFIG = {
21
+ // Theme - Terminal/Tron aesthetic
22
+ theme: {
23
+ background: "#000000",
24
+ primary: "#00ff00", // Terminal green
25
+ secondary: "#0a0a0a",
26
+ accent: "#00cc00",
27
+ text: "#ffffff",
28
+ textDim: "#666666",
29
+ platform: "#1a1a1a",
30
+ platformLine: "#00ff00",
31
+ player: "#00ff00",
32
+ enemy: "#ff3333", // Red glitches
33
+ orb: "#00ffff", // Cyan data orbs
34
+ goal: "#ffff00", // Yellow end zone
35
+ },
36
+
37
+ // Physics - mathematically balanced for level design
38
+ // Standing jump: ~57px high, ~97px far
39
+ // Running jump: ~75px high, ~112px far
40
+ gravity: 1400,
41
+ jumpVelocity: -400, // base jump
42
+ jumpSpeedBonus: 0.35, // extra jump power from horizontal speed
43
+ maxSpeed: 170, // max horizontal speed
44
+ acceleration: 1000, // how fast you speed up
45
+ friction: 600, // how fast you slow down on ground
46
+ airFriction: 60, // low friction in air - maintain momentum
47
+ airControl: 1.0, // full control in air
48
+ jumpCooldown: 0.1, // seconds between jumps
49
+ pogoBoost: -500, // bounce velocity when stomping enemies (~89px high)
50
+
51
+ // Player
52
+ playerScale: 1.0,
53
+
54
+ // Camera
55
+ cameraLerp: 0.08,
56
+ cameraDeadzoneWidth: 120,
57
+ cameraDeadzoneHeight: 60,
58
+
59
+ // Sound
60
+ soundEnabled: true,
61
+ masterVolume: 0.3,
62
+ };
63
+
64
+ // ==================== Level Data ====================
65
+ // Level uses relative Y positions (0 = ground level, negative = above ground)
66
+ // These are converted to screen coordinates based on game height
67
+ // Note: All positions are CENTER-based. For platforms, we place the center,
68
+ // and for entities, we need to offset by half their height to stand ON platforms.
69
+ const LEVEL_DATA = {
70
+ bounds: { minX: 0, maxX: 3000 },
71
+ spawn: { x: 80, yOffset: 0 },
72
+
73
+ // Platform design based on jump physics:
74
+ // Standing jump: 57px high, 97px far | Running jump: 75px high, 112px far
75
+ // Easy gap: <80px | Medium gap: 80-100px | Hard gap: 100-110px
76
+ // Easy height: <45px | Medium height: 45-55px | Hard height: 55-70px
77
+ platforms: [
78
+ // === ZONE 1: Tutorial Area ===
79
+ { type: 'ground', x: 200, yOffset: 0, width: 400, height: 32 },
80
+ // Easy first jump (gap ~50px, same height)
81
+ { type: 'floating', x: 480, yOffset: 0, width: 80, height: 16 },
82
+
83
+ // === ZONE 2: First Challenge ===
84
+ { type: 'ground', x: 650, yOffset: 0, width: 200, height: 32 },
85
+ // Medium gap (~70px) with enemy
86
+ { type: 'ground', x: 920, yOffset: 0, width: 200, height: 32 },
87
+
88
+ // === ZONE 3: Vertical Climb ===
89
+ // Stair stepping stones (gap ~60px, height +40px each)
90
+ { type: 'floating', x: 1100, yOffset: -40, width: 80, height: 16 },
91
+ { type: 'floating', x: 1220, yOffset: -40, width: 80, height: 16 },
92
+ // Upper bonus path (harder - need running jump, +65px height)
93
+ { type: 'floating', x: 1160, yOffset: -105, width: 70, height: 16 },
94
+
95
+ // === ZONE 4: Platforming Gauntlet ===
96
+ { type: 'ground', x: 1420, yOffset: 0, width: 180, height: 32 },
97
+ // Chain jumps (gap ~70px each, slight height variation)
98
+ { type: 'floating', x: 1600, yOffset: -30, width: 70, height: 16 },
99
+ { type: 'floating', x: 1730, yOffset: -30, width: 70, height: 16 },
100
+
101
+ // === ZONE 5: Enemy Gauntlet ===
102
+ { type: 'ground', x: 1950, yOffset: 0, width: 300, height: 32 },
103
+
104
+ // === ZONE 6: Final Stretch ===
105
+ // Stepping stones (gap ~70px each)
106
+ { type: 'floating', x: 2200, yOffset: -25, width: 80, height: 16 },
107
+ { type: 'floating', x: 2330, yOffset: -25, width: 80, height: 16 },
108
+ { type: 'floating', x: 2460, yOffset: -25, width: 80, height: 16 },
109
+
110
+ // === ZONE 7: Victory Road ===
111
+ { type: 'ground', x: 2680, yOffset: 0, width: 350, height: 32 },
112
+ ],
113
+
114
+ enemies: [
115
+ // Zone 2 - first enemy encounter
116
+ { x: 920, yOffset: 0, patrolStart: 840, patrolEnd: 1000 },
117
+ // Zone 4 - ground patrol
118
+ { x: 1420, yOffset: 0, patrolStart: 1350, patrolEnd: 1490 },
119
+ // Zone 5 - enemy gauntlet (2 enemies!)
120
+ { x: 1900, yOffset: 0, patrolStart: 1820, patrolEnd: 1980 },
121
+ { x: 2020, yOffset: 0, patrolStart: 1940, patrolEnd: 2080 },
122
+ // Zone 7 - final guard
123
+ { x: 2680, yOffset: 0, patrolStart: 2530, patrolEnd: 2830 },
124
+ ],
125
+
126
+ orbs: [
127
+ // Zone 1 - Easy pickups (above ground)
128
+ { x: 100, yOffset: -40, value: 10 },
129
+ { x: 200, yOffset: -40, value: 10 },
130
+ { x: 300, yOffset: -40, value: 10 },
131
+ { x: 480, yOffset: -35, value: 15 }, // On first platform
132
+
133
+ // Zone 2
134
+ { x: 650, yOffset: -40, value: 10 },
135
+ { x: 780, yOffset: -50, value: 15 }, // In the gap (risky!)
136
+ { x: 920, yOffset: -40, value: 10 },
137
+
138
+ // Zone 3 - Stair rewards
139
+ { x: 1100, yOffset: -75, value: 15 },
140
+ { x: 1220, yOffset: -75, value: 15 },
141
+ // Bonus path reward (on high platform)
142
+ { x: 1160, yOffset: -140, value: 100 },
143
+
144
+ // Zone 4 - Gauntlet rewards
145
+ { x: 1420, yOffset: -40, value: 10 },
146
+ { x: 1600, yOffset: -65, value: 20 },
147
+ { x: 1730, yOffset: -65, value: 20 },
148
+
149
+ // Zone 5 - Near enemies
150
+ { x: 1850, yOffset: -40, value: 10 },
151
+ { x: 1950, yOffset: -40, value: 10 },
152
+ { x: 2050, yOffset: -40, value: 10 },
153
+
154
+ // Zone 6 - Stepping stones
155
+ { x: 2200, yOffset: -60, value: 15 },
156
+ { x: 2330, yOffset: -60, value: 20 },
157
+ { x: 2460, yOffset: -60, value: 25 },
158
+
159
+ // Zone 7 - Victory orbs
160
+ { x: 2600, yOffset: -40, value: 10 },
161
+ { x: 2700, yOffset: -40, value: 10 },
162
+ { x: 2800, yOffset: -40, value: 10 },
163
+ ],
164
+
165
+ endZone: { x: 2900, yOffset: -80, width: 80, height: 160 },
166
+ };
167
+
168
+ // ==================== Sound Effects ====================
169
+ class SFX {
170
+ static initialized = false;
171
+
172
+ static init() {
173
+ if (this.initialized || !CONFIG.soundEnabled) return;
174
+ Synth.init({ masterVolume: CONFIG.masterVolume });
175
+ this.initialized = true;
176
+ }
177
+
178
+ static async resume() {
179
+ if (!this.initialized) return;
180
+ await Synth.resume();
181
+ }
182
+
183
+ static jump() {
184
+ if (!this.initialized) return;
185
+ Synth.osc.sweep(200, 500, 0.12, {
186
+ type: "square",
187
+ volume: 0.2,
188
+ });
189
+ }
190
+
191
+ static collect() {
192
+ if (!this.initialized) return;
193
+ const now = Synth.now;
194
+ Synth.osc.tone(880, 0.08, {
195
+ type: "sine",
196
+ volume: 0.15,
197
+ attack: 0.01,
198
+ release: 0.05,
199
+ startTime: now,
200
+ });
201
+ Synth.osc.tone(1320, 0.1, {
202
+ type: "sine",
203
+ volume: 0.15,
204
+ attack: 0.01,
205
+ release: 0.08,
206
+ startTime: now + 0.06,
207
+ });
208
+ }
209
+
210
+ static stomp() {
211
+ if (!this.initialized) return;
212
+ Synth.osc.sweep(300, 100, 0.15, {
213
+ type: "square",
214
+ volume: 0.25,
215
+ });
216
+ }
217
+
218
+ static death() {
219
+ if (!this.initialized) return;
220
+ const now = Synth.now;
221
+ const notes = [400, 300, 200, 100];
222
+ notes.forEach((freq, i) => {
223
+ Synth.osc.tone(freq, 0.2, {
224
+ type: "sawtooth",
225
+ volume: 0.2,
226
+ attack: 0.01,
227
+ release: 0.15,
228
+ startTime: now + i * 0.1,
229
+ });
230
+ });
231
+ }
232
+
233
+ static complete() {
234
+ if (!this.initialized) return;
235
+ const now = Synth.now;
236
+ const notes = [523, 659, 784, 1047];
237
+ notes.forEach((freq, i) => {
238
+ Synth.osc.tone(freq, 0.2, {
239
+ type: "square",
240
+ volume: 0.2,
241
+ attack: 0.01,
242
+ sustain: 0.7,
243
+ release: 0.1,
244
+ startTime: now + i * 0.12,
245
+ });
246
+ });
247
+ }
248
+
249
+ static start() {
250
+ if (!this.initialized) return;
251
+ Synth.osc.sweep(150, 500, 0.2, {
252
+ type: "square",
253
+ volume: 0.15,
254
+ });
255
+ }
256
+ }
257
+
258
+ // ==================== HackerShapeFactory ====================
259
+ class HackerShapeFactory {
260
+ static createFrame(options = {}) {
261
+ const color = options.color || CONFIG.theme.player;
262
+ const scale = options.scale || 1;
263
+ const pose = options.pose || 'stand';
264
+ const px = 2 * scale;
265
+
266
+ const group = new Group({});
267
+
268
+ // Head (hood/helmet) - 8px wide
269
+ const headPixels = [
270
+ { x: 1, y: 0, w: 6, h: 1 }, // top of hood
271
+ { x: 0, y: 1, w: 8, h: 1 }, // hood wider
272
+ { x: 0, y: 2, w: 8, h: 1 }, // hood
273
+ // Visor/face gap at y:3
274
+ { x: 0, y: 3, w: 2, h: 1 }, // left hood
275
+ { x: 6, y: 3, w: 2, h: 1 }, // right hood
276
+ { x: 0, y: 4, w: 8, h: 1 }, // chin
277
+ ];
278
+
279
+ // Visor (eyes) - glowing accent
280
+ const visorPixels = [
281
+ { x: 2, y: 3, w: 4, h: 1, isVisor: true },
282
+ ];
283
+
284
+ // Body (coat/jacket)
285
+ const bodyPixels = [
286
+ { x: 1, y: 5, w: 6, h: 1 }, // shoulders
287
+ { x: 0, y: 6, w: 8, h: 1 }, // torso
288
+ { x: 0, y: 7, w: 8, h: 1 }, // torso
289
+ { x: 0, y: 8, w: 8, h: 1 }, // waist
290
+ { x: 0, y: 9, w: 8, h: 1 }, // coat bottom
291
+ ];
292
+
293
+ // Legs based on pose
294
+ let legPixels = [];
295
+ switch (pose) {
296
+ case 'walk1':
297
+ // Left leg forward, right back
298
+ legPixels = [
299
+ { x: 0, y: 10, w: 3, h: 1 },
300
+ { x: 5, y: 10, w: 3, h: 1 },
301
+ { x: -1, y: 11, w: 3, h: 1 },
302
+ { x: 6, y: 11, w: 2, h: 1 },
303
+ { x: -2, y: 12, w: 3, h: 1 },
304
+ { x: 6, y: 12, w: 2, h: 1 },
305
+ ];
306
+ break;
307
+ case 'walk2':
308
+ // Right leg forward, left back
309
+ legPixels = [
310
+ { x: 0, y: 10, w: 3, h: 1 },
311
+ { x: 5, y: 10, w: 3, h: 1 },
312
+ { x: 0, y: 11, w: 2, h: 1 },
313
+ { x: 6, y: 11, w: 3, h: 1 },
314
+ { x: 0, y: 12, w: 2, h: 1 },
315
+ { x: 7, y: 12, w: 3, h: 1 },
316
+ ];
317
+ break;
318
+ case 'jump':
319
+ // Legs tucked
320
+ legPixels = [
321
+ { x: 1, y: 10, w: 6, h: 1 },
322
+ { x: 2, y: 11, w: 4, h: 1 },
323
+ { x: 2, y: 12, w: 4, h: 1 },
324
+ ];
325
+ break;
326
+ case 'stand':
327
+ default:
328
+ // Standing straight
329
+ legPixels = [
330
+ { x: 1, y: 10, w: 2, h: 1 },
331
+ { x: 5, y: 10, w: 2, h: 1 },
332
+ { x: 1, y: 11, w: 2, h: 1 },
333
+ { x: 5, y: 11, w: 2, h: 1 },
334
+ { x: 0, y: 12, w: 3, h: 1 },
335
+ { x: 5, y: 12, w: 3, h: 1 },
336
+ ];
337
+ break;
338
+ }
339
+
340
+ // Centering offset
341
+ const offsetX = 4 * px;
342
+ const offsetY = 6 * px;
343
+
344
+ // Draw all body parts
345
+ const allPixels = [...headPixels, ...bodyPixels, ...legPixels];
346
+ allPixels.forEach(p => {
347
+ group.add(new Rectangle({
348
+ x: p.x * px - offsetX + (p.w * px) / 2,
349
+ y: p.y * px - offsetY + (p.h * px) / 2,
350
+ width: p.w * px,
351
+ height: p.h * px,
352
+ color: color,
353
+ }));
354
+ });
355
+
356
+ // Draw visor (accent color)
357
+ visorPixels.forEach(p => {
358
+ group.add(new Rectangle({
359
+ x: p.x * px - offsetX + (p.w * px) / 2,
360
+ y: p.y * px - offsetY + (p.h * px) / 2,
361
+ width: p.w * px,
362
+ height: p.h * px,
363
+ color: CONFIG.theme.orb, // Cyan visor
364
+ }));
365
+ });
366
+
367
+ return group;
368
+ }
369
+
370
+ static createWalkFrames(options = {}) {
371
+ return [
372
+ this.createFrame({ ...options, pose: 'walk1' }),
373
+ this.createFrame({ ...options, pose: 'stand' }),
374
+ this.createFrame({ ...options, pose: 'walk2' }),
375
+ this.createFrame({ ...options, pose: 'stand' }),
376
+ ];
377
+ }
378
+
379
+ static createIdleFrame(options = {}) {
380
+ return this.createFrame({ ...options, pose: 'stand' });
381
+ }
382
+
383
+ static createJumpFrame(options = {}) {
384
+ return this.createFrame({ ...options, pose: 'jump' });
385
+ }
386
+
387
+ static createDeathFrame(options = {}) {
388
+ const color = options.color || CONFIG.theme.player;
389
+ const scale = options.scale || 1;
390
+ const px = 2 * scale;
391
+
392
+ const group = new Group({});
393
+
394
+ // Fallen/collapsed pose - lying on side
395
+ const pixels = [
396
+ // Head tilted
397
+ { x: 0, y: 2, w: 1, h: 6 },
398
+ { x: 1, y: 1, w: 1, h: 8 },
399
+ { x: 2, y: 1, w: 1, h: 8 },
400
+ { x: 3, y: 2, w: 1, h: 6 },
401
+ // Body sprawled
402
+ { x: 4, y: 3, w: 1, h: 4 },
403
+ { x: 5, y: 3, w: 1, h: 4 },
404
+ { x: 6, y: 3, w: 1, h: 4 },
405
+ { x: 7, y: 4, w: 1, h: 2 },
406
+ // Legs
407
+ { x: 8, y: 4, w: 2, h: 2 },
408
+ { x: 10, y: 5, w: 2, h: 1 },
409
+ ];
410
+
411
+ // Visor (dim, dying)
412
+ const visorPixels = [
413
+ { x: 1, y: 4, w: 2, h: 1 },
414
+ ];
415
+
416
+ const offsetX = 6 * px;
417
+ const offsetY = 3 * px;
418
+
419
+ pixels.forEach(p => {
420
+ group.add(new Rectangle({
421
+ x: p.x * px - offsetX + (p.w * px) / 2,
422
+ y: p.y * px - offsetY + (p.h * px) / 2,
423
+ width: p.w * px,
424
+ height: p.h * px,
425
+ color: color,
426
+ }));
427
+ });
428
+
429
+ // Dim visor
430
+ visorPixels.forEach(p => {
431
+ group.add(new Rectangle({
432
+ x: p.x * px - offsetX + (p.w * px) / 2,
433
+ y: p.y * px - offsetY + (p.h * px) / 2,
434
+ width: p.w * px,
435
+ height: p.h * px,
436
+ color: CONFIG.theme.enemy, // Red when dying
437
+ }));
438
+ });
439
+
440
+ return group;
441
+ }
442
+ }
443
+
444
+ // ==================== Player ====================
445
+ class Player extends Sprite {
446
+ constructor(game, options = {}) {
447
+ super(game, {
448
+ ...options,
449
+ frameRate: 10,
450
+ loop: true,
451
+ });
452
+
453
+ // Actual pixel art dimensions: 8 cols x 13 rows at 2px each = 16x26
454
+ this.width = 16;
455
+ this.height = 26;
456
+ this.vx = 0;
457
+ this.vy = 0;
458
+ this._grounded = false;
459
+ this.facingRight = true;
460
+ this._isMoving = false;
461
+ this.isDead = false;
462
+ this.jumpCooldownTimer = 0;
463
+
464
+ this.buildAnimations();
465
+ this.stopAnimation('idle');
466
+ }
467
+
468
+ canJump() {
469
+ return this._grounded && this.jumpCooldownTimer <= 0 && !this.isDead;
470
+ }
471
+
472
+ jump() {
473
+ if (!this.canJump()) return false;
474
+ // Base jump + bonus from horizontal speed (running jumps go higher!)
475
+ const speedBonus = Math.abs(this.vx) * CONFIG.jumpSpeedBonus;
476
+ this.vy = CONFIG.jumpVelocity - speedBonus;
477
+ this._grounded = false;
478
+ this.jumpCooldownTimer = CONFIG.jumpCooldown;
479
+ return true;
480
+ }
481
+
482
+ // Pogo bounce when stomping enemies
483
+ pogo() {
484
+ this.vy = CONFIG.pogoBoost;
485
+ this._grounded = false;
486
+ }
487
+
488
+ buildAnimations() {
489
+ this._animations.clear();
490
+
491
+ const frameOptions = {
492
+ color: CONFIG.theme.player,
493
+ scale: CONFIG.playerScale,
494
+ };
495
+
496
+ this.addAnimation('walk', HackerShapeFactory.createWalkFrames(frameOptions), {
497
+ frameRate: 10,
498
+ });
499
+ this.addAnimation('idle', [HackerShapeFactory.createIdleFrame(frameOptions)], {
500
+ loop: false,
501
+ });
502
+ this.addAnimation('jump', [HackerShapeFactory.createJumpFrame(frameOptions)], {
503
+ loop: false,
504
+ });
505
+ this.addAnimation('death', [HackerShapeFactory.createDeathFrame(frameOptions)], {
506
+ loop: false,
507
+ });
508
+ }
509
+
510
+ die() {
511
+ if (this.isDead) return;
512
+ this.isDead = true;
513
+ this.vx = 0;
514
+ this.vy = 0;
515
+ this.playAnimation('death');
516
+ }
517
+
518
+ update(dt) {
519
+ super.update(dt);
520
+
521
+ // Tick jump cooldown
522
+ if (this.jumpCooldownTimer > 0) {
523
+ this.jumpCooldownTimer -= dt;
524
+ }
525
+
526
+ // Don't update animation state machine if dead
527
+ if (this.isDead) return;
528
+
529
+ // Flip sprite based on direction
530
+ this.scaleX = this.facingRight ? 1 : -1;
531
+
532
+ // Animation state machine
533
+ if (!this._grounded) {
534
+ if (this.currentAnimationName !== 'jump') {
535
+ this.playAnimation('jump');
536
+ }
537
+ } else if (this._isMoving) {
538
+ if (this.currentAnimationName !== 'walk') {
539
+ this.playAnimation('walk');
540
+ }
541
+ } else {
542
+ if (this.currentAnimationName !== 'idle') {
543
+ this.stopAnimation('idle');
544
+ }
545
+ }
546
+ }
547
+
548
+ getBounds() {
549
+ const shrink = 2;
550
+ return {
551
+ x: this.x - this.width / 2 + shrink,
552
+ y: this.y - this.height / 2 + shrink,
553
+ width: this.width - shrink * 2,
554
+ height: this.height - shrink * 2,
555
+ };
556
+ }
557
+ }
558
+
559
+ // ==================== Platform ====================
560
+ class Platform extends GameObject {
561
+ constructor(game, options = {}) {
562
+ super(game, options);
563
+ this.width = options.width || 100;
564
+ this.height = options.height || 16;
565
+ this.type = options.type || 'floating';
566
+ }
567
+
568
+ draw() {
569
+ super.draw();
570
+
571
+ Painter.useCtx((ctx) => {
572
+ const w = this.width;
573
+ const h = this.height;
574
+
575
+ // Main platform body
576
+ ctx.fillStyle = CONFIG.theme.platform;
577
+ ctx.fillRect(-w / 2, -h / 2, w, h);
578
+
579
+ // Top glow line
580
+ ctx.fillStyle = CONFIG.theme.platformLine;
581
+ ctx.fillRect(-w / 2, -h / 2, w, 2);
582
+
583
+ // Grid lines for Tron effect
584
+ ctx.strokeStyle = CONFIG.theme.platformLine;
585
+ ctx.globalAlpha = 0.2;
586
+ ctx.lineWidth = 1;
587
+
588
+ // Vertical grid
589
+ const gridSpacing = 20;
590
+ for (let x = -w / 2 + gridSpacing; x < w / 2; x += gridSpacing) {
591
+ ctx.beginPath();
592
+ ctx.moveTo(x, -h / 2 + 2);
593
+ ctx.lineTo(x, h / 2);
594
+ ctx.stroke();
595
+ }
596
+
597
+ ctx.globalAlpha = 1;
598
+ });
599
+ }
600
+
601
+ getBounds() {
602
+ return {
603
+ x: this.x - this.width / 2,
604
+ y: this.y - this.height / 2,
605
+ width: this.width,
606
+ height: this.height,
607
+ };
608
+ }
609
+ }
610
+
611
+ // ==================== GlitchShapeFactory ====================
612
+ class GlitchShapeFactory {
613
+ static createFrame(options = {}) {
614
+ const color = options.color || CONFIG.theme.enemy;
615
+ const scale = options.scale || 1;
616
+ const pose = options.pose || 'walk1';
617
+ const px = 2 * scale;
618
+
619
+ const group = new Group({});
620
+
621
+ // Glitchy corrupted creature body
622
+ const bodyPixels = [
623
+ // Head (glitchy/corrupted)
624
+ { x: 2, y: 0, w: 4, h: 1 },
625
+ { x: 1, y: 1, w: 6, h: 1 },
626
+ { x: 0, y: 2, w: 8, h: 1 },
627
+ { x: 0, y: 3, w: 8, h: 1 },
628
+ // Body
629
+ { x: 1, y: 4, w: 6, h: 1 },
630
+ { x: 1, y: 5, w: 6, h: 1 },
631
+ { x: 2, y: 6, w: 4, h: 1 },
632
+ ];
633
+
634
+ // Evil eyes
635
+ const eyePixels = [
636
+ { x: 2, y: 2, w: 1, h: 1, isEye: true },
637
+ { x: 5, y: 2, w: 1, h: 1, isEye: true },
638
+ ];
639
+
640
+ // Legs based on pose
641
+ let legPixels = [];
642
+ if (pose === 'walk1') {
643
+ legPixels = [
644
+ { x: 1, y: 7, w: 2, h: 1 },
645
+ { x: 5, y: 7, w: 2, h: 1 },
646
+ { x: 0, y: 8, w: 2, h: 1 },
647
+ { x: 6, y: 8, w: 2, h: 1 },
648
+ ];
649
+ } else {
650
+ legPixels = [
651
+ { x: 2, y: 7, w: 2, h: 1 },
652
+ { x: 4, y: 7, w: 2, h: 1 },
653
+ { x: 2, y: 8, w: 2, h: 1 },
654
+ { x: 4, y: 8, w: 2, h: 1 },
655
+ ];
656
+ }
657
+
658
+ const offsetX = 4 * px;
659
+ const offsetY = 4 * px;
660
+
661
+ [...bodyPixels, ...legPixels].forEach(p => {
662
+ group.add(new Rectangle({
663
+ x: p.x * px - offsetX + (p.w * px) / 2,
664
+ y: p.y * px - offsetY + (p.h * px) / 2,
665
+ width: p.w * px,
666
+ height: p.h * px,
667
+ color: color,
668
+ }));
669
+ });
670
+
671
+ // Eyes (white/glowing)
672
+ eyePixels.forEach(p => {
673
+ group.add(new Rectangle({
674
+ x: p.x * px - offsetX + (p.w * px) / 2,
675
+ y: p.y * px - offsetY + (p.h * px) / 2,
676
+ width: p.w * px,
677
+ height: p.h * px,
678
+ color: "#ffffff",
679
+ }));
680
+ });
681
+
682
+ return group;
683
+ }
684
+
685
+ static createWalkFrames(options = {}) {
686
+ return [
687
+ this.createFrame({ ...options, pose: 'walk1' }),
688
+ this.createFrame({ ...options, pose: 'walk2' }),
689
+ ];
690
+ }
691
+ }
692
+
693
+ // ==================== GlitchEnemy ====================
694
+ class GlitchEnemy extends Sprite {
695
+ constructor(game, options = {}) {
696
+ super(game, {
697
+ ...options,
698
+ frameRate: 6,
699
+ loop: true,
700
+ });
701
+
702
+ // Actual pixel art dimensions: 8 cols x 9 rows at 2px each = 16x18
703
+ this.width = 16;
704
+ this.height = 18;
705
+ this.patrolStart = options.patrolStart || this.x - 50;
706
+ this.patrolEnd = options.patrolEnd || this.x + 50;
707
+ this.patrolSpeed = options.patrolSpeed || 60;
708
+ this.direction = 1;
709
+ this.isDead = false;
710
+
711
+ this.buildAnimations();
712
+ this.playAnimation('walk');
713
+ }
714
+
715
+ buildAnimations() {
716
+ this._animations.clear();
717
+
718
+ const frameOptions = {
719
+ color: CONFIG.theme.enemy,
720
+ scale: 1,
721
+ };
722
+
723
+ this.addAnimation('walk', GlitchShapeFactory.createWalkFrames(frameOptions), {
724
+ frameRate: 6,
725
+ });
726
+ }
727
+
728
+ update(dt) {
729
+ if (this.isDead) return;
730
+
731
+ super.update(dt);
732
+
733
+ // Patrol movement
734
+ this.x += this.direction * this.patrolSpeed * dt;
735
+
736
+ // Reverse at patrol bounds
737
+ if (this.x >= this.patrolEnd) {
738
+ this.x = this.patrolEnd;
739
+ this.direction = -1;
740
+ this.scaleX = -1;
741
+ } else if (this.x <= this.patrolStart) {
742
+ this.x = this.patrolStart;
743
+ this.direction = 1;
744
+ this.scaleX = 1;
745
+ }
746
+ }
747
+
748
+ die() {
749
+ this.isDead = true;
750
+ this.visible = false;
751
+ }
752
+
753
+ getBounds() {
754
+ const shrink = 2;
755
+ return {
756
+ x: this.x - this.width / 2 + shrink,
757
+ y: this.y - this.height / 2 + shrink,
758
+ width: this.width - shrink * 2,
759
+ height: this.height - shrink * 2,
760
+ };
761
+ }
762
+ }
763
+
764
+ // ==================== DataOrb ====================
765
+ class DataOrb extends GameObject {
766
+ constructor(game, options = {}) {
767
+ super(game, options);
768
+ this.radius = 10;
769
+ this.value = options.value || 10;
770
+ this.collected = false;
771
+ this.pulsePhase = Math.random() * Math.PI * 2;
772
+ }
773
+
774
+ update(dt) {
775
+ super.update(dt);
776
+ this.pulsePhase += dt * 4;
777
+ }
778
+
779
+ draw() {
780
+ if (this.collected) return;
781
+ super.draw();
782
+
783
+ const pulse = 1 + Math.sin(this.pulsePhase) * 0.2;
784
+ const r = this.radius * pulse;
785
+
786
+ Painter.useCtx((ctx) => {
787
+ // Glow effect
788
+ const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, r * 2);
789
+ gradient.addColorStop(0, CONFIG.theme.orb);
790
+ gradient.addColorStop(0.5, CONFIG.theme.orb + "44");
791
+ gradient.addColorStop(1, "transparent");
792
+
793
+ ctx.fillStyle = gradient;
794
+ ctx.beginPath();
795
+ ctx.arc(0, 0, r * 2, 0, Math.PI * 2);
796
+ ctx.fill();
797
+
798
+ // Core
799
+ ctx.fillStyle = CONFIG.theme.orb;
800
+ ctx.beginPath();
801
+ ctx.arc(0, 0, r * 0.6, 0, Math.PI * 2);
802
+ ctx.fill();
803
+
804
+ // Inner highlight
805
+ ctx.fillStyle = "#ffffff";
806
+ ctx.globalAlpha = 0.7;
807
+ ctx.beginPath();
808
+ ctx.arc(-r * 0.2, -r * 0.2, r * 0.2, 0, Math.PI * 2);
809
+ ctx.fill();
810
+ ctx.globalAlpha = 1;
811
+ });
812
+ }
813
+
814
+ collect() {
815
+ if (this.collected) return false;
816
+ this.collected = true;
817
+ this.visible = false;
818
+ return true;
819
+ }
820
+
821
+ getBounds() {
822
+ return {
823
+ x: this.x - this.radius,
824
+ y: this.y - this.radius,
825
+ width: this.radius * 2,
826
+ height: this.radius * 2,
827
+ };
828
+ }
829
+ }
830
+
831
+ // ==================== EndZone ====================
832
+ class EndZone extends GameObject {
833
+ constructor(game, options = {}) {
834
+ super(game, options);
835
+ this.width = options.width || 80;
836
+ this.height = options.height || 160;
837
+ this.animPhase = 0;
838
+ }
839
+
840
+ update(dt) {
841
+ super.update(dt);
842
+ this.animPhase += dt * 2;
843
+ }
844
+
845
+ draw() {
846
+ super.draw();
847
+
848
+ const w = this.width;
849
+ const h = this.height;
850
+
851
+ Painter.useCtx((ctx) => {
852
+ // Portal glow
853
+ const gradient = ctx.createLinearGradient(0, -h / 2, 0, h / 2);
854
+ gradient.addColorStop(0, CONFIG.theme.goal + "00");
855
+ gradient.addColorStop(0.3, CONFIG.theme.goal + "44");
856
+ gradient.addColorStop(0.5, CONFIG.theme.goal + "88");
857
+ gradient.addColorStop(0.7, CONFIG.theme.goal + "44");
858
+ gradient.addColorStop(1, CONFIG.theme.goal + "00");
859
+
860
+ ctx.fillStyle = gradient;
861
+ ctx.fillRect(-w / 2, -h / 2, w, h);
862
+
863
+ // Animated scan lines
864
+ ctx.strokeStyle = CONFIG.theme.goal;
865
+ ctx.lineWidth = 2;
866
+ const numLines = 8;
867
+ for (let i = 0; i < numLines; i++) {
868
+ const lineY = ((i / numLines + this.animPhase * 0.1) % 1) * h - h / 2;
869
+ ctx.globalAlpha = 0.3 + Math.sin(i + this.animPhase) * 0.2;
870
+ ctx.beginPath();
871
+ ctx.moveTo(-w / 2, lineY);
872
+ ctx.lineTo(w / 2, lineY);
873
+ ctx.stroke();
874
+ }
875
+
876
+ // Border
877
+ ctx.globalAlpha = 0.8;
878
+ ctx.strokeStyle = CONFIG.theme.goal;
879
+ ctx.lineWidth = 3;
880
+ ctx.strokeRect(-w / 2, -h / 2, w, h);
881
+
882
+ // Arrow pointing up
883
+ ctx.globalAlpha = 0.6 + Math.sin(this.animPhase * 3) * 0.4;
884
+ ctx.fillStyle = CONFIG.theme.goal;
885
+ ctx.beginPath();
886
+ ctx.moveTo(0, -30);
887
+ ctx.lineTo(15, 0);
888
+ ctx.lineTo(5, 0);
889
+ ctx.lineTo(5, 20);
890
+ ctx.lineTo(-5, 20);
891
+ ctx.lineTo(-5, 0);
892
+ ctx.lineTo(-15, 0);
893
+ ctx.closePath();
894
+ ctx.fill();
895
+
896
+ ctx.globalAlpha = 1;
897
+ });
898
+ }
899
+
900
+ getBounds() {
901
+ return {
902
+ x: this.x - this.width / 2,
903
+ y: this.y - this.height / 2,
904
+ width: this.width,
905
+ height: this.height,
906
+ };
907
+ }
908
+
909
+ isPlayerInside(playerBounds) {
910
+ const zoneBounds = this.getBounds();
911
+ const centerX = playerBounds.x + playerBounds.width / 2;
912
+ return centerX > zoneBounds.x && centerX < zoneBounds.x + zoneBounds.width;
913
+ }
914
+ }
915
+
916
+ // ==================== SkyLayer ====================
917
+ class SkyLayer extends GameObject {
918
+ constructor(game, options = {}) {
919
+ super(game, options);
920
+ this.gridWidth = 4000;
921
+ this.scrollOffset = 0;
922
+
923
+ // Generate grid lines
924
+ this.verticalLines = [];
925
+ this.horizontalLines = [];
926
+
927
+ const spacing = 60;
928
+ for (let x = 0; x < this.gridWidth; x += spacing) {
929
+ this.verticalLines.push({
930
+ x,
931
+ height: 150 + Math.random() * 100,
932
+ opacity: 0.1 + Math.random() * 0.2,
933
+ });
934
+ }
935
+
936
+ for (let y = 0; y < 300; y += spacing / 2) {
937
+ this.horizontalLines.push({
938
+ y: -250 + y,
939
+ opacity: 0.05 + Math.random() * 0.1,
940
+ });
941
+ }
942
+ }
943
+
944
+ setScrollOffset(offset) {
945
+ this.scrollOffset = offset % this.gridWidth;
946
+ }
947
+
948
+ draw() {
949
+ super.draw();
950
+
951
+ Painter.useCtx((ctx) => {
952
+ ctx.strokeStyle = CONFIG.theme.primary;
953
+ ctx.lineWidth = 1;
954
+
955
+ // Draw horizontal lines
956
+ this.horizontalLines.forEach(line => {
957
+ ctx.globalAlpha = line.opacity;
958
+ ctx.beginPath();
959
+ ctx.moveTo(-this.gridWidth, line.y);
960
+ ctx.lineTo(this.gridWidth, line.y);
961
+ ctx.stroke();
962
+ });
963
+
964
+ // Draw vertical lines
965
+ this.verticalLines.forEach(line => {
966
+ let x = line.x - this.scrollOffset;
967
+ while (x < -this.gridWidth / 2) x += this.gridWidth;
968
+ while (x > this.gridWidth / 2) x -= this.gridWidth;
969
+
970
+ ctx.globalAlpha = line.opacity;
971
+ ctx.beginPath();
972
+ ctx.moveTo(x, -300);
973
+ ctx.lineTo(x, -300 + line.height);
974
+ ctx.stroke();
975
+ });
976
+
977
+ ctx.globalAlpha = 1;
978
+ });
979
+ }
980
+ }
981
+
982
+ // ==================== Level ====================
983
+ class Level extends PlatformerScene {
984
+ constructor(game, options = {}) {
985
+ super(game, {
986
+ ...options,
987
+ player: options.player,
988
+ gravity: CONFIG.gravity,
989
+ jumpVelocity: CONFIG.jumpVelocity,
990
+ moveSpeed: CONFIG.moveSpeed,
991
+ autoInput: false, // We'll handle input manually to respect game state
992
+ autoGravity: false, // We'll handle gravity manually to respect game state
993
+ groundY: null, // No default ground - use platforms
994
+ });
995
+
996
+ this.platforms = [];
997
+ this.enemies = [];
998
+ this.orbs = [];
999
+ this.endZone = null;
1000
+ this.score = 0;
1001
+ this.totalOrbs = 0;
1002
+ this.collectedOrbs = 0;
1003
+ }
1004
+
1005
+ buildLevel(levelData, groundY) {
1006
+ this.groundY = groundY;
1007
+
1008
+ // Create platforms
1009
+ // yOffset: 0 means platform TOP is at groundY
1010
+ // Platform center = groundY + yOffset + height/2
1011
+ levelData.platforms.forEach(p => {
1012
+ const platform = new Platform(this.game, {
1013
+ x: p.x,
1014
+ y: groundY + p.yOffset + p.height / 2,
1015
+ width: p.width,
1016
+ height: p.height,
1017
+ type: p.type,
1018
+ });
1019
+ this.platforms.push(platform);
1020
+ this.add(platform);
1021
+ });
1022
+
1023
+ // Create enemies
1024
+ // Enemy sprite feet are 10 pixels below center (based on pixel art)
1025
+ const enemyFeetOffset = 10;
1026
+ levelData.enemies.forEach(e => {
1027
+ const enemy = new GlitchEnemy(this.game, {
1028
+ x: e.x,
1029
+ y: groundY + e.yOffset - enemyFeetOffset,
1030
+ patrolStart: e.patrolStart,
1031
+ patrolEnd: e.patrolEnd,
1032
+ });
1033
+ this.enemies.push(enemy);
1034
+ this.add(enemy);
1035
+ });
1036
+
1037
+ // Create orbs - these float, so yOffset is from ground level
1038
+ levelData.orbs.forEach(o => {
1039
+ const orb = new DataOrb(this.game, {
1040
+ x: o.x,
1041
+ y: groundY + o.yOffset,
1042
+ value: o.value,
1043
+ });
1044
+ this.orbs.push(orb);
1045
+ this.add(orb);
1046
+ });
1047
+ this.totalOrbs = this.orbs.length;
1048
+
1049
+ // Create end zone - center-based
1050
+ const ez = levelData.endZone;
1051
+ this.endZone = new EndZone(this.game, {
1052
+ x: ez.x,
1053
+ y: groundY + ez.yOffset,
1054
+ width: ez.width,
1055
+ height: ez.height,
1056
+ });
1057
+ this.add(this.endZone);
1058
+ }
1059
+
1060
+ // Override applyInput to handle horizontal movement with acceleration
1061
+ applyInput(player, dt) {
1062
+ const accel = CONFIG.acceleration;
1063
+ const maxSpeed = CONFIG.maxSpeed;
1064
+ const grounded = player._grounded;
1065
+ // Use different friction for ground vs air
1066
+ const friction = grounded ? CONFIG.friction : CONFIG.airFriction;
1067
+ const controlMult = grounded ? 1.0 : CONFIG.airControl;
1068
+
1069
+ let inputDir = 0;
1070
+ if (Keys.isDown(Keys.LEFT) || Keys.isDown(Keys.A)) {
1071
+ inputDir = -1;
1072
+ }
1073
+ if (Keys.isDown(Keys.RIGHT) || Keys.isDown(Keys.D)) {
1074
+ inputDir = 1;
1075
+ }
1076
+
1077
+ if (inputDir !== 0) {
1078
+ // Accelerate in input direction
1079
+ player.vx += inputDir * accel * controlMult * dt;
1080
+ // Clamp to max speed
1081
+ if (player.vx > maxSpeed) player.vx = maxSpeed;
1082
+ if (player.vx < -maxSpeed) player.vx = -maxSpeed;
1083
+ } else {
1084
+ // Apply friction when no input (much less in air)
1085
+ if (player.vx > 0) {
1086
+ player.vx -= friction * dt;
1087
+ if (player.vx < 0) player.vx = 0;
1088
+ } else if (player.vx < 0) {
1089
+ player.vx += friction * dt;
1090
+ if (player.vx > 0) player.vx = 0;
1091
+ }
1092
+ }
1093
+ // Jump is handled by player.jump() via handleAction - NOT here
1094
+ }
1095
+
1096
+ isPlayerGrounded() {
1097
+ if (!this.player) return false;
1098
+
1099
+ const pb = this.player.getBounds();
1100
+ const feetY = pb.y + pb.height;
1101
+ const tolerance = 12; // Increased tolerance for better landing detection
1102
+
1103
+ for (const platform of this.platforms) {
1104
+ const platB = platform.getBounds();
1105
+
1106
+ // Check if player feet are at or slightly below platform top level
1107
+ if (feetY >= platB.y - tolerance && feetY <= platB.y + tolerance) {
1108
+ // Check horizontal overlap
1109
+ if (pb.x + pb.width > platB.x && pb.x < platB.x + platB.width) {
1110
+ return true;
1111
+ }
1112
+ }
1113
+ }
1114
+ return false;
1115
+ }
1116
+
1117
+ handleGroundCollision(player) {
1118
+ let grounded = false;
1119
+ const pb = player.getBounds();
1120
+
1121
+ for (const platform of this.platforms) {
1122
+ const platB = platform.getBounds();
1123
+
1124
+ if (Collision.rectRect(pb, platB)) {
1125
+ const mtv = Collision.getMTV(pb, platB);
1126
+
1127
+ if (mtv) {
1128
+ // Determine collision direction based on overlap
1129
+ const overlapX = Math.abs(mtv.x);
1130
+ const overlapY = Math.abs(mtv.y);
1131
+
1132
+ if (overlapY <= overlapX || player.vy > 0) {
1133
+ // Vertical collision takes priority when falling
1134
+ if (mtv.y < 0 && player.vy >= 0) {
1135
+ // Landing from above
1136
+ player.y += mtv.y;
1137
+ player.vy = 0;
1138
+ grounded = true;
1139
+ } else if (mtv.y > 0 && player.vy < 0) {
1140
+ // Hit ceiling from below
1141
+ player.y += mtv.y;
1142
+ player.vy = 0;
1143
+ }
1144
+ } else {
1145
+ // Horizontal collision
1146
+ player.x += mtv.x;
1147
+ player.vx = 0;
1148
+ }
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ // Also check if standing on platform surface (for walking off edges)
1154
+ if (!grounded && player.vy >= 0) {
1155
+ const feetY = player.y + player.height / 2;
1156
+ for (const platform of this.platforms) {
1157
+ const platB = platform.getBounds();
1158
+ // Check if feet are just above or at platform and horizontally aligned
1159
+ if (feetY >= platB.y - 4 && feetY <= platB.y + 8) {
1160
+ if (pb.x + pb.width > platB.x && pb.x < platB.x + platB.width) {
1161
+ // Snap feet to platform surface
1162
+ player.y = platB.y - player.height / 2;
1163
+ player.vy = 0;
1164
+ grounded = true;
1165
+ break;
1166
+ }
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ player._grounded = grounded;
1172
+ }
1173
+
1174
+ checkEnemyCollisions() {
1175
+ if (!this.player || this.game.gameState !== 'playing') return;
1176
+
1177
+ const pb = this.player.getBounds();
1178
+
1179
+ for (const enemy of this.enemies) {
1180
+ if (enemy.isDead) continue;
1181
+
1182
+ const eb = enemy.getBounds();
1183
+
1184
+ if (Collision.rectRect(pb, eb)) {
1185
+ const playerBottom = pb.y + pb.height;
1186
+ const enemyTop = eb.y;
1187
+
1188
+ // Check if stomping (player falling and above enemy)
1189
+ if (this.player.vy > 0 && playerBottom < enemyTop + 20) {
1190
+ // Stomp! Pogo bounce off enemy
1191
+ enemy.die();
1192
+ this.player.pogo(); // Big bounce!
1193
+ this.score += 100;
1194
+ SFX.stomp();
1195
+ this.game.shakeCamera(5, 0.15);
1196
+ } else {
1197
+ // Death
1198
+ this.game.handleDeath();
1199
+ }
1200
+ return;
1201
+ }
1202
+ }
1203
+ }
1204
+
1205
+ checkOrbCollisions() {
1206
+ if (!this.player || this.game.gameState !== 'playing') return;
1207
+
1208
+ const pb = this.player.getBounds();
1209
+
1210
+ for (const orb of this.orbs) {
1211
+ if (orb.collected) continue;
1212
+
1213
+ const ob = orb.getBounds();
1214
+
1215
+ if (Collision.rectRect(pb, ob)) {
1216
+ if (orb.collect()) {
1217
+ this.score += orb.value;
1218
+ this.collectedOrbs++;
1219
+ SFX.collect();
1220
+ }
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ checkEndZone() {
1226
+ if (!this.player || !this.endZone || this.game.gameState !== 'playing') return;
1227
+
1228
+ if (this.endZone.isPlayerInside(this.player.getBounds())) {
1229
+ this.game.handleComplete();
1230
+ }
1231
+ }
1232
+
1233
+ checkFallDeath() {
1234
+ if (!this.player || this.game.gameState !== 'playing') return;
1235
+
1236
+ if (this.player.y > this.game.height + 100) {
1237
+ this.game.handleDeath();
1238
+ }
1239
+ }
1240
+
1241
+ update(dt) {
1242
+ // Only apply physics when playing
1243
+ if (this.game.gameState === 'playing' && this.player) {
1244
+ // Apply gravity
1245
+ this.applyGravity(this.player, dt);
1246
+
1247
+ // Apply input
1248
+ this.applyInput(this.player, dt);
1249
+
1250
+ // Apply velocity
1251
+ this.applyVelocity(this.player, dt);
1252
+
1253
+ // Handle platform collisions
1254
+ this.handleGroundCollision(this.player);
1255
+
1256
+ // Update player movement state for animation
1257
+ this.player._isMoving = Math.abs(this.player.vx) > 10;
1258
+ if (this.player.vx > 0) this.player.facingRight = true;
1259
+ else if (this.player.vx < 0) this.player.facingRight = false;
1260
+ }
1261
+
1262
+ // Update camera
1263
+ this.updateCamera(dt);
1264
+
1265
+ // Update all children (orbs, enemies, etc.)
1266
+ for (let i = 0; i < this.children.length; i++) {
1267
+ const child = this.children[i];
1268
+ if (child.active !== false && child.update) {
1269
+ child.update(dt);
1270
+ }
1271
+ }
1272
+
1273
+ // Re-resolve ground collision after all updates
1274
+ // This ensures player can't fall through platforms even if something moved them
1275
+ if (this.game.gameState === 'playing' && this.player) {
1276
+ this.handleGroundCollision(this.player);
1277
+ }
1278
+
1279
+ // Collision checks (only when playing)
1280
+ if (this.game.gameState === 'playing') {
1281
+ this.checkEnemyCollisions();
1282
+ this.checkOrbCollisions();
1283
+ this.checkEndZone();
1284
+ this.checkFallDeath();
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ // ==================== PlatformerGame ====================
1290
+ class PlatformerGame extends Game {
1291
+ constructor(canvas) {
1292
+ super(canvas);
1293
+ this.backgroundColor = CONFIG.theme.background;
1294
+ this.enableFluidSize();
1295
+ }
1296
+
1297
+ init() {
1298
+ super.init();
1299
+ SFX.init();
1300
+ this.setupGame();
1301
+ this.setupInput();
1302
+ }
1303
+
1304
+ setupGame() {
1305
+ this.score = 0;
1306
+ this.gameState = 'start'; // 'start', 'playing', 'dead', 'complete'
1307
+
1308
+ // Calculate ground Y - vertically centered, slightly below center
1309
+ this.groundY = this.height * 0.7;
1310
+
1311
+ // Create player - spawn position relative to ground
1312
+ // Player sprite feet are 13 pixels below center (based on pixel art)
1313
+ const playerFeetOffset = 13;
1314
+ this.player = new Player(this, {
1315
+ x: LEVEL_DATA.spawn.x,
1316
+ y: this.groundY + LEVEL_DATA.spawn.yOffset - playerFeetOffset,
1317
+ });
1318
+
1319
+ // Create camera with bounds relative to ground
1320
+ this.camera = new Camera2D({
1321
+ target: this.player,
1322
+ viewportWidth: this.width,
1323
+ viewportHeight: this.height,
1324
+ lerp: CONFIG.cameraLerp,
1325
+ deadzone: {
1326
+ width: CONFIG.cameraDeadzoneWidth,
1327
+ height: CONFIG.cameraDeadzoneHeight,
1328
+ },
1329
+ bounds: {
1330
+ minX: LEVEL_DATA.bounds.minX,
1331
+ maxX: LEVEL_DATA.bounds.maxX,
1332
+ minY: 0,
1333
+ maxY: this.height,
1334
+ },
1335
+ });
1336
+
1337
+ // Create level
1338
+ this.level = new Level(this, {
1339
+ player: this.player,
1340
+ camera: this.camera,
1341
+ viewportWidth: this.width,
1342
+ viewportHeight: this.height,
1343
+ x: 0,
1344
+ y: 0,
1345
+ });
1346
+
1347
+ // Add sky layer (slow parallax) - centered vertically
1348
+ this.sky = new SkyLayer(this, {
1349
+ x: this.width / 2,
1350
+ y: this.height / 2,
1351
+ });
1352
+ this.level.addLayer(this.sky, { speed: 0.2 });
1353
+
1354
+ // Build level content with groundY
1355
+ this.level.buildLevel(LEVEL_DATA, this.groundY);
1356
+
1357
+ // Add player to level (moves with camera)
1358
+ this.level.add(this.player);
1359
+
1360
+ this.pipeline.add(this.level);
1361
+
1362
+ // Create UI
1363
+ this.createUI();
1364
+
1365
+ // FPS counter
1366
+ this.pipeline.add(
1367
+ new FPSCounter(this, {
1368
+ color: CONFIG.theme.textDim,
1369
+ anchor: Position.BOTTOM_RIGHT,
1370
+ })
1371
+ );
1372
+ }
1373
+
1374
+ createUI() {
1375
+ // Score display
1376
+ this.scoreText = new Text(this, "SCORE: 0", {
1377
+ font: "bold 20px 'Courier New', monospace",
1378
+ color: CONFIG.theme.primary,
1379
+ anchor: Position.TOP_LEFT,
1380
+ anchorOffsetX: 20,
1381
+ anchorOffsetY: 20,
1382
+ });
1383
+ this.pipeline.add(this.scoreText);
1384
+
1385
+ // Orb counter
1386
+ this.orbText = new Text(this, "DATA: 0/0", {
1387
+ font: "16px 'Courier New', monospace",
1388
+ color: CONFIG.theme.orb,
1389
+ anchor: Position.TOP_LEFT,
1390
+ anchorOffsetX: 20,
1391
+ anchorOffsetY: 50,
1392
+ });
1393
+ this.pipeline.add(this.orbText);
1394
+
1395
+ // Start message
1396
+ this.startText = new Text(this, "[ PRESS SPACE TO START ]", {
1397
+ font: "18px 'Courier New', monospace",
1398
+ color: CONFIG.theme.primary,
1399
+ anchor: Position.CENTER,
1400
+ });
1401
+ this.pipeline.add(this.startText);
1402
+
1403
+ this.subtitleText = new Text(this, "collect data orbs and reach the portal", {
1404
+ font: "14px 'Courier New', monospace",
1405
+ color: CONFIG.theme.textDim,
1406
+ anchor: Position.CENTER,
1407
+ anchorOffsetY: 30,
1408
+ });
1409
+ this.pipeline.add(this.subtitleText);
1410
+
1411
+ // Game over text
1412
+ this.gameOverText = new Text(this, "SYSTEM CRASH", {
1413
+ font: "bold 36px 'Courier New', monospace",
1414
+ color: CONFIG.theme.enemy,
1415
+ anchor: Position.CENTER,
1416
+ anchorOffsetY: -30,
1417
+ visible: false,
1418
+ });
1419
+ this.pipeline.add(this.gameOverText);
1420
+
1421
+ this.restartText = new Text(this, "[ PRESS SPACE TO RESTART ]", {
1422
+ font: "16px 'Courier New', monospace",
1423
+ color: CONFIG.theme.textDim,
1424
+ anchor: Position.CENTER,
1425
+ anchorOffsetY: 60,
1426
+ visible: false,
1427
+ });
1428
+ this.pipeline.add(this.restartText);
1429
+
1430
+ // Complete text
1431
+ this.completeText = new Text(this, "UPLOAD COMPLETE", {
1432
+ font: "bold 36px 'Courier New', monospace",
1433
+ color: CONFIG.theme.goal,
1434
+ anchor: Position.CENTER,
1435
+ anchorOffsetY: -20,
1436
+ visible: false,
1437
+ });
1438
+ this.pipeline.add(this.completeText);
1439
+
1440
+ this.finalScoreText = new Text(this, "", {
1441
+ font: "20px 'Courier New', monospace",
1442
+ color: CONFIG.theme.text,
1443
+ anchor: Position.CENTER,
1444
+ anchorOffsetY: 20,
1445
+ visible: false,
1446
+ });
1447
+ this.pipeline.add(this.finalScoreText);
1448
+ }
1449
+
1450
+ setupInput() {
1451
+ this.events.on(Keys.SPACE, () => this.handleAction());
1452
+ this.events.on(Keys.UP, () => this.handleAction());
1453
+ this.events.on(Keys.W, () => this.handleAction());
1454
+ }
1455
+
1456
+ async handleAction() {
1457
+ await SFX.resume();
1458
+
1459
+ if (this.gameState === 'start') {
1460
+ this.startGame();
1461
+ } else if (this.gameState === 'dead' || this.gameState === 'complete') {
1462
+ this.restartGame();
1463
+ } else if (this.gameState === 'playing') {
1464
+ // Use player's jump method with cooldown
1465
+ if (this.player && this.player.jump()) {
1466
+ SFX.jump();
1467
+ }
1468
+ }
1469
+ }
1470
+
1471
+ startGame() {
1472
+ this.gameState = 'playing';
1473
+ this.startText.visible = false;
1474
+ this.subtitleText.visible = false;
1475
+ SFX.start();
1476
+ }
1477
+
1478
+ handleDeath() {
1479
+ if (this.gameState !== 'playing') return;
1480
+
1481
+ this.gameState = 'dead';
1482
+
1483
+ // Trigger player death animation
1484
+ if (this.player) {
1485
+ this.player.die();
1486
+ }
1487
+
1488
+ SFX.death();
1489
+ this.shakeCamera(10, 0.3);
1490
+
1491
+ // Show game over UI after a short delay for death animation
1492
+ setTimeout(() => {
1493
+ // Only show if still in dead state (user might have restarted)
1494
+ if (this.gameState === 'dead') {
1495
+ this.gameOverText.visible = true;
1496
+ this.restartText.visible = true;
1497
+ }
1498
+ }, 500);
1499
+ }
1500
+
1501
+ handleComplete() {
1502
+ if (this.gameState !== 'playing') return;
1503
+
1504
+ this.gameState = 'complete';
1505
+ this.completeText.visible = true;
1506
+ this.finalScoreText.visible = true;
1507
+ this.finalScoreText.text = `FINAL SCORE: ${this.level.score}`;
1508
+ this.restartText.visible = true;
1509
+ SFX.complete();
1510
+ }
1511
+
1512
+ restartGame() {
1513
+ this.pipeline.clear();
1514
+ this.setupGame();
1515
+
1516
+ // Auto-start
1517
+ this.gameState = 'playing';
1518
+ this.startText.visible = false;
1519
+ this.subtitleText.visible = false;
1520
+ }
1521
+
1522
+ shakeCamera(intensity, duration) {
1523
+ if (this.level && this.level.camera) {
1524
+ this.level.shakeCamera(intensity, duration);
1525
+ }
1526
+ }
1527
+
1528
+ update(dt) {
1529
+ // Blinking start text
1530
+ if (this.gameState === 'start' && this.startText) {
1531
+ const blink = Math.sin(Date.now() / 500) > 0;
1532
+ this.startText.opacity = blink ? 1 : 0.5;
1533
+ }
1534
+
1535
+ // Update UI
1536
+ if (this.level) {
1537
+ this.scoreText.text = `SCORE: ${this.level.score}`;
1538
+ this.orbText.text = `DATA: ${this.level.collectedOrbs}/${this.level.totalOrbs}`;
1539
+
1540
+ // Update sky parallax
1541
+ if (this.sky && this.camera) {
1542
+ const offset = this.camera.getOffset();
1543
+ this.sky.setScrollOffset(offset.x);
1544
+ }
1545
+ }
1546
+
1547
+ // Pause updates when not playing
1548
+ if (this.gameState !== 'playing') {
1549
+ // Still update camera and rendering but not game logic
1550
+ if (this.camera) {
1551
+ this.camera.update(dt);
1552
+ }
1553
+ }
1554
+
1555
+ super.update(dt);
1556
+ }
1557
+
1558
+ onResize() {
1559
+ if (this.camera) {
1560
+ this.camera.viewportWidth = this.width;
1561
+ this.camera.viewportHeight = this.height;
1562
+ this.camera.bounds.maxY = this.height;
1563
+ }
1564
+ if (this.level) {
1565
+ this.level.setViewport(this.width, this.height);
1566
+ }
1567
+ if (this.sky) {
1568
+ this.sky.x = this.width / 2;
1569
+ this.sky.y = this.height / 2;
1570
+ }
1571
+ }
1572
+ }
1573
+
1574
+ // ==================== Initialize ====================
1575
+ window.addEventListener("load", () => {
1576
+ const canvas = document.getElementById("game");
1577
+ const game = new PlatformerGame(canvas);
1578
+ game.start();
1579
+ });