@guinetik/gcanvas 1.0.1 → 1.0.2

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 (42) hide show
  1. package/demos/coordinates.html +698 -0
  2. package/demos/cube3d.html +23 -0
  3. package/demos/demos.css +17 -3
  4. package/demos/dino.html +42 -0
  5. package/demos/gameobjects.html +626 -0
  6. package/demos/index.html +17 -7
  7. package/demos/js/coordinates.js +840 -0
  8. package/demos/js/cube3d.js +789 -0
  9. package/demos/js/dino.js +1420 -0
  10. package/demos/js/gameobjects.js +176 -0
  11. package/demos/js/plane3d.js +256 -0
  12. package/demos/js/platformer.js +1579 -0
  13. package/demos/js/sphere3d.js +229 -0
  14. package/demos/js/sprite.js +473 -0
  15. package/demos/js/tde/accretiondisk.js +3 -3
  16. package/demos/js/tde/tidalstream.js +2 -2
  17. package/demos/plane3d.html +24 -0
  18. package/demos/platformer.html +43 -0
  19. package/demos/sphere3d.html +24 -0
  20. package/demos/sprite.html +18 -0
  21. package/docs/concepts/coordinate-system.md +384 -0
  22. package/docs/concepts/shapes-vs-gameobjects.md +187 -0
  23. package/docs/fluid-dynamics.md +99 -97
  24. package/package.json +1 -1
  25. package/src/game/game.js +11 -5
  26. package/src/game/objects/index.js +3 -0
  27. package/src/game/objects/platformer-scene.js +411 -0
  28. package/src/game/objects/scene.js +14 -0
  29. package/src/game/objects/sprite.js +529 -0
  30. package/src/game/pipeline.js +20 -16
  31. package/src/game/ui/theme.js +123 -121
  32. package/src/io/input.js +75 -45
  33. package/src/io/mouse.js +44 -19
  34. package/src/io/touch.js +35 -12
  35. package/src/shapes/cube3d.js +599 -0
  36. package/src/shapes/index.js +2 -0
  37. package/src/shapes/plane3d.js +687 -0
  38. package/src/shapes/sphere3d.js +75 -6
  39. package/src/util/camera2d.js +315 -0
  40. package/src/util/index.js +1 -0
  41. package/src/webgl/shaders/plane-shaders.js +332 -0
  42. package/src/webgl/shaders/sphere-shaders.js +4 -2
@@ -0,0 +1,1420 @@
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
+ } from "../../src/index.js";
17
+
18
+ // ==================== Configuration ====================
19
+ const CONFIG = {
20
+ // Theme - Vercel meets Terminal (default)
21
+ theme: {
22
+ background: "#000000",
23
+ primary: "#00ff00", // Terminal green
24
+ secondary: "#0a0a0a",
25
+ accent: "#00cc00",
26
+ text: "#ffffff",
27
+ textDim: "#666666",
28
+ ground: "#1a1a1a",
29
+ groundLine: "#00ff00",
30
+ obstacle: "#00ff00",
31
+ dino: "#00ff00",
32
+ },
33
+
34
+ // Theme - 80s Outrun (unlocks at 1000 points)
35
+ outrunTheme: {
36
+ background: "#1a1a2e", // Deep purple-blue
37
+ primary: "#ff6b9d", // Hot pink
38
+ secondary: "#c44569",
39
+ accent: "#f8b500", // Orange/gold
40
+ text: "#ffffff",
41
+ textDim: "#9d65c9", // Purple
42
+ ground: "#16213e",
43
+ groundLine: "#ff6b9d",
44
+ obstacle: "#f8b500", // Orange palm trees
45
+ dino: "#00d9ff", // Cyan dino
46
+ sun: "#ff6b9d", // Sun gradient colors
47
+ sunGlow: "#f8b500",
48
+ },
49
+
50
+ // Level transition thresholds
51
+ outrunStartScore: 2000, // Enter outrun at 2k
52
+ outrunDuration: 1000, // Stay in outrun for 1k points
53
+ levelCycle: 3000, // Full cycle length (2k + 1k)
54
+
55
+ // Game settings
56
+ gravity: 2800,
57
+ jumpVelocity: -750,
58
+
59
+ // Player (Dino)
60
+ dinoScale: 1.0,
61
+
62
+ // Ground - centered layout
63
+ groundHeight: 2,
64
+
65
+ // Obstacles (Cacti / Palm Trees)
66
+ cactusWidth: 15,
67
+ cactusMinHeight: 30,
68
+ cactusMaxHeight: 50,
69
+ cactusSpawnMinInterval: 1.0,
70
+ cactusSpawnMaxInterval: 2.2,
71
+
72
+ // Scrolling
73
+ scrollSpeed: 350,
74
+ scrollAcceleration: 8,
75
+ maxScrollSpeed: 800,
76
+
77
+ // Sound
78
+ soundEnabled: true,
79
+ masterVolume: 0.3,
80
+ };
81
+
82
+ // ==================== Sound Effects ====================
83
+ /**
84
+ * SFX - Retro 8-bit style sound effects for the dino game
85
+ */
86
+ class SFX {
87
+ static initialized = false;
88
+
89
+ static init() {
90
+ if (this.initialized || !CONFIG.soundEnabled) return;
91
+
92
+ Synth.init({ masterVolume: CONFIG.masterVolume });
93
+ this.initialized = true;
94
+ }
95
+
96
+ static async resume() {
97
+ if (!this.initialized) return;
98
+ await Synth.resume();
99
+ }
100
+
101
+ /**
102
+ * Jump sound - Quick upward sweep
103
+ */
104
+ static jump() {
105
+ if (!this.initialized) return;
106
+
107
+ // Retro jump: quick upward frequency sweep
108
+ Synth.osc.sweep(150, 400, 0.15, {
109
+ type: "square",
110
+ volume: 0.25,
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Score milestone sound - Quick double beep
116
+ */
117
+ static milestone() {
118
+ if (!this.initialized) return;
119
+
120
+ const now = Synth.now;
121
+
122
+ // Two quick beeps
123
+ Synth.osc.tone(880, 0.08, {
124
+ type: "square",
125
+ volume: 0.2,
126
+ attack: 0.01,
127
+ decay: 0.02,
128
+ sustain: 0.5,
129
+ release: 0.05,
130
+ startTime: now,
131
+ });
132
+
133
+ Synth.osc.tone(1100, 0.1, {
134
+ type: "square",
135
+ volume: 0.2,
136
+ attack: 0.01,
137
+ decay: 0.02,
138
+ sustain: 0.5,
139
+ release: 0.05,
140
+ startTime: now + 0.1,
141
+ });
142
+ }
143
+
144
+ /**
145
+ * Game over sound - Sad descending tone
146
+ */
147
+ static gameOver() {
148
+ if (!this.initialized) return;
149
+
150
+ const now = Synth.now;
151
+
152
+ // Descending arpeggio
153
+ const notes = [440, 349, 293, 220];
154
+ notes.forEach((freq, i) => {
155
+ Synth.osc.tone(freq, 0.2, {
156
+ type: "square",
157
+ volume: 0.25,
158
+ attack: 0.01,
159
+ decay: 0.05,
160
+ sustain: 0.6,
161
+ release: 0.15,
162
+ startTime: now + i * 0.12,
163
+ });
164
+ });
165
+
166
+ // Final low buzz
167
+ Synth.osc.tone(110, 0.4, {
168
+ type: "sawtooth",
169
+ volume: 0.15,
170
+ attack: 0.05,
171
+ decay: 0.1,
172
+ sustain: 0.3,
173
+ release: 0.25,
174
+ startTime: now + 0.5,
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Start game sound - Ascending beep
180
+ */
181
+ static start() {
182
+ if (!this.initialized) return;
183
+
184
+ Synth.osc.sweep(200, 600, 0.2, {
185
+ type: "square",
186
+ volume: 0.2,
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Theme transition sound - Synth flourish
192
+ */
193
+ static themeTransition(toOutrun = true) {
194
+ if (!this.initialized) return;
195
+
196
+ const now = Synth.now;
197
+
198
+ if (toOutrun) {
199
+ // Ascending arpeggio for entering outrun mode
200
+ const notes = [330, 440, 550, 660, 880];
201
+ notes.forEach((freq, i) => {
202
+ Synth.osc.tone(freq, 0.15, {
203
+ type: "sawtooth",
204
+ volume: 0.2,
205
+ attack: 0.01,
206
+ decay: 0.03,
207
+ sustain: 0.7,
208
+ release: 0.1,
209
+ startTime: now + i * 0.08,
210
+ });
211
+ });
212
+ } else {
213
+ // Descending for returning to normal
214
+ const notes = [660, 550, 440, 330];
215
+ notes.forEach((freq, i) => {
216
+ Synth.osc.tone(freq, 0.12, {
217
+ type: "square",
218
+ volume: 0.15,
219
+ attack: 0.01,
220
+ decay: 0.02,
221
+ sustain: 0.6,
222
+ release: 0.08,
223
+ startTime: now + i * 0.06,
224
+ });
225
+ });
226
+ }
227
+ }
228
+ }
229
+
230
+ // ==================== Dino Shape Factory ====================
231
+ /**
232
+ * Creates pixelated T-Rex dinosaur frames using rectangles
233
+ * Supports different leg positions for walking animation
234
+ */
235
+ class DinoShapeFactory {
236
+ /**
237
+ * Creates a dino frame with specified leg position
238
+ * @param {Object} options
239
+ * @param {string} [options.color] - Dino color
240
+ * @param {number} [options.scale] - Scale factor
241
+ * @param {string} [options.legPose] - 'stand' | 'left' | 'right'
242
+ * @returns {Group} A Group containing the dino shape
243
+ */
244
+ static createFrame(options = {}) {
245
+ const color = options.color || CONFIG.theme.dino;
246
+ const scale = options.scale || 1;
247
+ const legPose = options.legPose || 'stand';
248
+ const px = 3 * scale;
249
+
250
+ const group = new Group({});
251
+
252
+ // Body pixels (shared across all frames) - tail handled separately
253
+ const bodyPixels = [
254
+ // Head (top)
255
+ { x: 6, y: 0, w: 7, h: 1 },
256
+ { x: 5, y: 1, w: 9, h: 1 },
257
+ { x: 5, y: 2, w: 9, h: 1 },
258
+ // Eye (gap)
259
+ { x: 5, y: 3, w: 4, h: 1 },
260
+ { x: 11, y: 3, w: 3, h: 1 },
261
+ // Mouth area
262
+ { x: 5, y: 4, w: 9, h: 1 },
263
+ { x: 3, y: 5, w: 11, h: 1 },
264
+ { x: 2, y: 6, w: 8, h: 1 },
265
+ // Neck
266
+ { x: 2, y: 7, w: 5, h: 1 },
267
+ { x: 1, y: 8, w: 5, h: 1 },
268
+ // Body
269
+ { x: 0, y: 9, w: 6, h: 1 },
270
+ { x: 0, y: 10, w: 6, h: 1 },
271
+ { x: 0, y: 11, w: 6, h: 1 },
272
+ // Tiny T-Rex arms (high on chest, classic style)
273
+ { x: 5, y: 7, w: 2, h: 1 }, // Upper arm
274
+ { x: 6, y: 8, w: 2, h: 1 }, // Forearm
275
+ // Lower body (connects to legs)
276
+ { x: 0, y: 12, w: 6, h: 1 },
277
+ { x: 0, y: 13, w: 6, h: 1 },
278
+ ];
279
+
280
+ // Tail pixels based on pose
281
+ let tailPixels = [];
282
+ if (legPose === 'jump') {
283
+ // Tail up during jump
284
+ tailPixels = [
285
+ { x: -3, y: 9, w: 3, h: 1 },
286
+ { x: -4, y: 8, w: 4, h: 1 },
287
+ { x: -5, y: 7, w: 4, h: 1 },
288
+ { x: -6, y: 6, w: 3, h: 1 },
289
+ ];
290
+ } else {
291
+ // Normal tail position
292
+ tailPixels = [
293
+ { x: -3, y: 10, w: 3, h: 1 },
294
+ { x: -4, y: 11, w: 4, h: 1 },
295
+ { x: -5, y: 12, w: 5, h: 1 },
296
+ { x: -5, y: 13, w: 6, h: 1 },
297
+ ];
298
+ }
299
+
300
+ // Leg pixels based on pose
301
+ let legPixels = [];
302
+
303
+ switch (legPose) {
304
+ case 'left':
305
+ // Left leg forward, right leg back
306
+ legPixels = [
307
+ // Left leg (forward, extended)
308
+ { x: 0, y: 14, w: 2, h: 1 },
309
+ { x: -1, y: 15, w: 2, h: 1 },
310
+ { x: -2, y: 16, w: 3, h: 1 },
311
+ // Right leg (back, lifted)
312
+ { x: 4, y: 14, w: 2, h: 1 },
313
+ { x: 5, y: 15, w: 2, h: 1 },
314
+ ];
315
+ break;
316
+
317
+ case 'right':
318
+ // Right leg forward, left leg back
319
+ legPixels = [
320
+ // Left leg (back, lifted)
321
+ { x: 0, y: 14, w: 2, h: 1 },
322
+ { x: -1, y: 15, w: 2, h: 1 },
323
+ // Right leg (forward, extended)
324
+ { x: 4, y: 14, w: 2, h: 1 },
325
+ { x: 5, y: 15, w: 2, h: 1 },
326
+ { x: 6, y: 16, w: 3, h: 1 },
327
+ ];
328
+ break;
329
+
330
+ case 'jump':
331
+ // Legs tucked together during jump
332
+ legPixels = [
333
+ { x: 1, y: 14, w: 4, h: 1 },
334
+ { x: 2, y: 15, w: 3, h: 1 },
335
+ { x: 3, y: 16, w: 2, h: 1 },
336
+ ];
337
+ break;
338
+
339
+ case 'stand':
340
+ default:
341
+ // Both legs down (standing/idle)
342
+ legPixels = [
343
+ { x: 0, y: 14, w: 2, h: 1 },
344
+ { x: 4, y: 14, w: 2, h: 1 },
345
+ { x: 0, y: 15, w: 2, h: 1 },
346
+ { x: 4, y: 15, w: 2, h: 1 },
347
+ { x: -1, y: 16, w: 3, h: 1 },
348
+ { x: 3, y: 16, w: 3, h: 1 },
349
+ ];
350
+ break;
351
+ }
352
+
353
+ const allPixels = [...bodyPixels, ...tailPixels, ...legPixels];
354
+
355
+ // Center offset
356
+ const offsetX = -3 * px;
357
+ const offsetY = -8 * px;
358
+
359
+ allPixels.forEach(p => {
360
+ const rect = new Rectangle({
361
+ x: p.x * px + offsetX + (p.w * px) / 2,
362
+ y: p.y * px + offsetY + (p.h * px) / 2,
363
+ width: p.w * px,
364
+ height: p.h * px,
365
+ color: color,
366
+ });
367
+ group.add(rect);
368
+ });
369
+
370
+ return group;
371
+ }
372
+
373
+ /**
374
+ * Creates all walking animation frames
375
+ * @param {Object} options
376
+ * @returns {Group[]} Array of frame groups
377
+ */
378
+ static createWalkFrames(options = {}) {
379
+ return [
380
+ this.createFrame({ ...options, legPose: 'left' }),
381
+ this.createFrame({ ...options, legPose: 'stand' }),
382
+ this.createFrame({ ...options, legPose: 'right' }),
383
+ this.createFrame({ ...options, legPose: 'stand' }),
384
+ ];
385
+ }
386
+
387
+ /**
388
+ * Creates the idle frame
389
+ * @param {Object} options
390
+ * @returns {Group}
391
+ */
392
+ static createIdleFrame(options = {}) {
393
+ return this.createFrame({ ...options, legPose: 'stand' });
394
+ }
395
+
396
+ /**
397
+ * Creates the jump frame with tucked legs and raised tail
398
+ * @param {Object} options
399
+ * @returns {Group}
400
+ */
401
+ static createJumpFrame(options = {}) {
402
+ return this.createFrame({ ...options, legPose: 'jump' });
403
+ }
404
+ }
405
+
406
+ // ==================== Dino (Player) ====================
407
+ /**
408
+ * Dino - The player character as an animated Sprite
409
+ */
410
+ class Dino extends Sprite {
411
+ constructor(game, options = {}) {
412
+ super(game, {
413
+ ...options,
414
+ frameRate: 10,
415
+ loop: true,
416
+ });
417
+
418
+ this.width = 50;
419
+ this.height = 55;
420
+ this.vx = 0;
421
+ this.vy = 0;
422
+ this._grounded = true;
423
+ this._isRunning = false; // Track if game has started
424
+ this._currentTheme = CONFIG.theme;
425
+ this._targetRotation = 0;
426
+ this._rotationSpeed = 8; // How fast to lerp rotation
427
+
428
+ this.buildAnimations(CONFIG.theme);
429
+
430
+ // Start with idle animation (legs don't move before game starts)
431
+ this.stopAnimation('idle');
432
+ }
433
+
434
+ buildAnimations(theme) {
435
+ // Clear existing animations
436
+ this._animations.clear();
437
+
438
+ const frameOptions = {
439
+ color: theme.dino,
440
+ scale: CONFIG.dinoScale,
441
+ };
442
+
443
+ // Add animations
444
+ this.addAnimation('walk', DinoShapeFactory.createWalkFrames(frameOptions), {
445
+ frameRate: 12,
446
+ });
447
+ this.addAnimation('idle', [DinoShapeFactory.createIdleFrame(frameOptions)], {
448
+ loop: false,
449
+ });
450
+ this.addAnimation('jump', [DinoShapeFactory.createJumpFrame(frameOptions)], {
451
+ loop: false,
452
+ });
453
+ }
454
+
455
+ setTheme(theme) {
456
+ if (this._currentTheme === theme) return;
457
+ this._currentTheme = theme;
458
+ const currentAnim = this.currentAnimationName;
459
+ this.buildAnimations(theme);
460
+ if (currentAnim) {
461
+ this.playAnimation(currentAnim);
462
+ }
463
+ }
464
+
465
+ startRunning() {
466
+ this._isRunning = true;
467
+ this.playAnimation('walk');
468
+ }
469
+
470
+ stopRunning() {
471
+ this._isRunning = false;
472
+ this.stopAnimation('idle');
473
+ }
474
+
475
+ update(dt) {
476
+ super.update(dt);
477
+
478
+ // Don't animate if game hasn't started
479
+ if (!this._isRunning) return;
480
+
481
+ // Switch animations based on state
482
+ if (!this._grounded) {
483
+ if (this.currentAnimationName !== 'jump') {
484
+ this.playAnimation('jump');
485
+ }
486
+ // Target tilt when jumping (upward)
487
+ this._targetRotation = -0.3;
488
+ } else {
489
+ if (this.currentAnimationName !== 'walk') {
490
+ this.playAnimation('walk');
491
+ }
492
+ // Reset rotation when grounded
493
+ this._targetRotation = 0;
494
+ }
495
+
496
+ // Smoothly animate rotation towards target
497
+ const rotationDiff = this._targetRotation - this.rotation;
498
+ this.rotation += rotationDiff * this._rotationSpeed * dt;
499
+ }
500
+
501
+ getBounds() {
502
+ // Shrink hitbox for fairness
503
+ const shrink = 8;
504
+ return {
505
+ x: this.x - this.width / 2 + shrink,
506
+ y: this.y - this.height / 2 + shrink,
507
+ width: this.width - shrink * 2,
508
+ height: this.height - shrink * 2,
509
+ };
510
+ }
511
+ }
512
+
513
+ // ==================== Obstacle (Cactus / Palm Tree) ====================
514
+ /**
515
+ * Obstacle - A pixel-art style obstacle (cactus or palm tree)
516
+ */
517
+ class Obstacle extends GameObject {
518
+ constructor(game, options = {}) {
519
+ super(game, options);
520
+ this.width = CONFIG.cactusWidth;
521
+ this.height = options.height || CONFIG.cactusMinHeight +
522
+ Math.random() * (CONFIG.cactusMaxHeight - CONFIG.cactusMinHeight);
523
+ this.isPalmTree = options.isPalmTree || false;
524
+ this.theme = options.theme || CONFIG.theme;
525
+
526
+ this.group = new Group({});
527
+ this.build();
528
+ }
529
+
530
+ build() {
531
+ this.group.clear();
532
+
533
+ if (this.isPalmTree) {
534
+ this.buildPalmTree();
535
+ } else {
536
+ this.buildCactus();
537
+ }
538
+ }
539
+
540
+ buildCactus() {
541
+ const c = this.theme.obstacle;
542
+ const w = this.width;
543
+ const h = this.height;
544
+
545
+ // Main stem
546
+ this.group.add(new Rectangle({
547
+ x: 0,
548
+ y: 0,
549
+ width: w * 0.6,
550
+ height: h,
551
+ color: c,
552
+ }));
553
+
554
+ // Left arm (if tall enough)
555
+ if (h > 35) {
556
+ this.group.add(new Rectangle({
557
+ x: -w * 0.5,
558
+ y: -h * 0.2,
559
+ width: w * 0.4,
560
+ height: h * 0.3,
561
+ color: c,
562
+ }));
563
+ this.group.add(new Rectangle({
564
+ x: -w * 0.5,
565
+ y: -h * 0.35,
566
+ width: w * 0.4,
567
+ height: w * 0.4,
568
+ color: c,
569
+ }));
570
+ }
571
+
572
+ // Right arm (if even taller)
573
+ if (h > 45) {
574
+ this.group.add(new Rectangle({
575
+ x: w * 0.5,
576
+ y: -h * 0.1,
577
+ width: w * 0.4,
578
+ height: h * 0.25,
579
+ color: c,
580
+ }));
581
+ this.group.add(new Rectangle({
582
+ x: w * 0.5,
583
+ y: -h * 0.27,
584
+ width: w * 0.4,
585
+ height: w * 0.4,
586
+ color: c,
587
+ }));
588
+ }
589
+ }
590
+
591
+ buildPalmTree() {
592
+ const trunkColor = "#8B4513"; // Brown trunk
593
+ const leafColor = this.theme.obstacle; // Orange/gold leaves
594
+ const w = this.width;
595
+ const h = this.height * 1.5; // Palm trees are taller
596
+
597
+ // Trunk (slightly curved look with segments)
598
+ const trunkWidth = w * 0.4;
599
+ const segments = 4;
600
+ for (let i = 0; i < segments; i++) {
601
+ const segY = (i / segments) * h - h * 0.3;
602
+ const segH = h / segments + 2;
603
+ this.group.add(new Rectangle({
604
+ x: (i % 2 === 0 ? 1 : -1) * 1, // Slight wobble
605
+ y: segY,
606
+ width: trunkWidth,
607
+ height: segH,
608
+ color: trunkColor,
609
+ }));
610
+ }
611
+
612
+ // Palm fronds (leaves) - radiating from top
613
+ const leafLength = w * 2.5;
614
+ const leafWidth = w * 0.3;
615
+ const topY = -h * 0.5 - 5;
616
+
617
+ // Left fronds
618
+ this.group.add(new Rectangle({
619
+ x: -leafLength * 0.4,
620
+ y: topY - 5,
621
+ width: leafLength,
622
+ height: leafWidth,
623
+ color: leafColor,
624
+ }));
625
+ this.group.add(new Rectangle({
626
+ x: -leafLength * 0.3,
627
+ y: topY - 12,
628
+ width: leafLength * 0.8,
629
+ height: leafWidth,
630
+ color: leafColor,
631
+ }));
632
+
633
+ // Right fronds
634
+ this.group.add(new Rectangle({
635
+ x: leafLength * 0.4,
636
+ y: topY - 5,
637
+ width: leafLength,
638
+ height: leafWidth,
639
+ color: leafColor,
640
+ }));
641
+ this.group.add(new Rectangle({
642
+ x: leafLength * 0.3,
643
+ y: topY - 12,
644
+ width: leafLength * 0.8,
645
+ height: leafWidth,
646
+ color: leafColor,
647
+ }));
648
+
649
+ // Center/top fronds
650
+ this.group.add(new Rectangle({
651
+ x: 0,
652
+ y: topY - 18,
653
+ width: leafLength * 0.6,
654
+ height: leafWidth,
655
+ color: leafColor,
656
+ }));
657
+
658
+ // Coconuts (small circles near top)
659
+ const coconutColor = "#654321";
660
+ this.group.add(new Circle({
661
+ x: -3,
662
+ y: topY + 3,
663
+ radius: 4,
664
+ color: coconutColor,
665
+ }));
666
+ this.group.add(new Circle({
667
+ x: 4,
668
+ y: topY + 5,
669
+ radius: 4,
670
+ color: coconutColor,
671
+ }));
672
+ }
673
+
674
+ draw() {
675
+ super.draw();
676
+ this.group.render();
677
+ }
678
+
679
+ getBounds() {
680
+ // Use consistent hitbox regardless of visual style
681
+ return {
682
+ x: this.x - this.width / 2,
683
+ y: this.y - this.height / 2,
684
+ width: this.width,
685
+ height: this.height,
686
+ };
687
+ }
688
+ }
689
+
690
+ // Alias for backwards compatibility
691
+ const Cactus = Obstacle;
692
+
693
+ // ==================== Ground ====================
694
+ /**
695
+ * Ground - Terminal-style ground with scrolling texture
696
+ */
697
+ class Ground extends GameObject {
698
+ constructor(game, options = {}) {
699
+ super(game, options);
700
+ this.groundWidth = options.width || game.width * 3;
701
+ this.scrollOffset = 0;
702
+ this._theme = CONFIG.theme;
703
+
704
+ // Main ground line
705
+ this.line = new Rectangle({
706
+ width: this.groundWidth,
707
+ height: CONFIG.groundHeight,
708
+ color: this._theme.groundLine,
709
+ });
710
+
711
+ // Generate ground texture marks (will scroll)
712
+ this.marks = [];
713
+ const markSpacing = 15;
714
+ const numMarks = Math.ceil(this.groundWidth / markSpacing) + 10;
715
+ for (let i = 0; i < numMarks; i++) {
716
+ // Different mark types for variety
717
+ const type = Math.random();
718
+ if (type < 0.3) {
719
+ // Small dash
720
+ this.marks.push({
721
+ baseX: i * markSpacing,
722
+ y: 4 + Math.random() * 3,
723
+ width: 2 + Math.random() * 4,
724
+ height: 1,
725
+ });
726
+ } else if (type < 0.5) {
727
+ // Tall tick
728
+ this.marks.push({
729
+ baseX: i * markSpacing,
730
+ y: 3,
731
+ width: 1,
732
+ height: 3 + Math.random() * 4,
733
+ });
734
+ } else if (type < 0.6) {
735
+ // Double dash
736
+ this.marks.push({
737
+ baseX: i * markSpacing,
738
+ y: 4,
739
+ width: 6 + Math.random() * 8,
740
+ height: 1,
741
+ });
742
+ this.marks.push({
743
+ baseX: i * markSpacing + 2,
744
+ y: 7,
745
+ width: 4 + Math.random() * 4,
746
+ height: 1,
747
+ });
748
+ }
749
+ }
750
+ this.markCycleWidth = numMarks * markSpacing;
751
+ }
752
+
753
+ setTheme(theme) {
754
+ this._theme = theme;
755
+ this.line.color = theme.groundLine;
756
+ }
757
+
758
+ setScrollOffset(offset) {
759
+ this.scrollOffset = offset % this.markCycleWidth;
760
+ }
761
+
762
+ draw() {
763
+ super.draw();
764
+ this.line.render();
765
+
766
+ // Draw scrolling ground texture
767
+ Painter.useCtx((ctx) => {
768
+ ctx.fillStyle = this._theme.textDim;
769
+ const startX = -this.groundWidth / 2;
770
+ this.marks.forEach(m => {
771
+ // Calculate scrolled position
772
+ let x = startX + m.baseX - this.scrollOffset;
773
+ // Wrap around
774
+ while (x < startX - 20) x += this.markCycleWidth;
775
+ while (x > startX + this.groundWidth) x -= this.markCycleWidth;
776
+ ctx.fillRect(x, m.y, m.width, m.height);
777
+ });
778
+ });
779
+ }
780
+ }
781
+
782
+ // ==================== Sky Layer ====================
783
+ /**
784
+ * SkyLayer - Parallax sky with Tron clouds or Outrun sun
785
+ */
786
+ class SkyLayer extends GameObject {
787
+ constructor(game, options = {}) {
788
+ super(game, options);
789
+ this.cloudWidth = game.width * 2;
790
+ this.scrollOffset = 0;
791
+ this._theme = CONFIG.theme;
792
+ this._isOutrunMode = false;
793
+
794
+ // Generate cloud grid lines
795
+ this.gridLines = [];
796
+ this.glowLines = [];
797
+
798
+ // Horizontal scan lines (main cloud layer)
799
+ const numLines = 8;
800
+ for (let i = 0; i < numLines; i++) {
801
+ const y = -150 + i * 25 + (Math.random() - 0.5) * 10;
802
+ const segments = [];
803
+
804
+ // Create broken line segments for cloud effect
805
+ let x = 0;
806
+ while (x < this.cloudWidth) {
807
+ if (Math.random() > 0.4) {
808
+ const segWidth = 50 + Math.random() * 150;
809
+ const opacity = 0.1 + Math.random() * 0.3;
810
+ segments.push({
811
+ x: x - this.cloudWidth / 2,
812
+ width: segWidth,
813
+ opacity,
814
+ glow: Math.random() > 0.7,
815
+ });
816
+ }
817
+ x += 30 + Math.random() * 80;
818
+ }
819
+
820
+ this.gridLines.push({ y, segments });
821
+ }
822
+
823
+ // Vertical grid lines (sparse, for depth)
824
+ const numVerticals = 12;
825
+ for (let i = 0; i < numVerticals; i++) {
826
+ const x = (i / numVerticals) * this.cloudWidth - this.cloudWidth / 2;
827
+ if (Math.random() > 0.5) {
828
+ this.glowLines.push({
829
+ x,
830
+ y1: -180 + Math.random() * 30,
831
+ y2: -80 + Math.random() * 40,
832
+ opacity: 0.05 + Math.random() * 0.15,
833
+ });
834
+ }
835
+ }
836
+ }
837
+
838
+ setTheme(theme, isOutrun = false) {
839
+ this._theme = theme;
840
+ this._isOutrunMode = isOutrun;
841
+ }
842
+
843
+ setScrollOffset(offset) {
844
+ this.scrollOffset = offset % this.cloudWidth;
845
+ }
846
+
847
+ drawOutrunSun(ctx) {
848
+ const sunX = 0;
849
+ const sunY = -120;
850
+ const sunRadius = 80;
851
+
852
+ // Sun glow
853
+ const gradient = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, sunRadius * 1.5);
854
+ gradient.addColorStop(0, this._theme.sunGlow || '#f8b500');
855
+ gradient.addColorStop(0.5, this._theme.sun || '#ff6b9d');
856
+ gradient.addColorStop(1, 'transparent');
857
+
858
+ ctx.globalAlpha = 0.6;
859
+ ctx.fillStyle = gradient;
860
+ ctx.beginPath();
861
+ ctx.arc(sunX, sunY, sunRadius * 1.5, 0, Math.PI * 2);
862
+ ctx.fill();
863
+
864
+ // Sun body with horizontal stripes (retrowave style)
865
+ ctx.globalAlpha = 1;
866
+ const stripeCount = 8;
867
+ for (let i = 0; i < stripeCount; i++) {
868
+ const stripeY = sunY - sunRadius + (i * 2 + 1) * (sunRadius / stripeCount);
869
+ const stripeHeight = sunRadius / stripeCount - 2;
870
+
871
+ // Calculate stripe width at this y position (circle intersection)
872
+ const dy = Math.abs(stripeY - sunY);
873
+ if (dy < sunRadius) {
874
+ const stripeHalfWidth = Math.sqrt(sunRadius * sunRadius - dy * dy);
875
+
876
+ // Gradient from orange to pink
877
+ const t = i / stripeCount;
878
+ const r = Math.floor(255 - t * 50);
879
+ const g = Math.floor(107 + t * 50);
880
+ const b = Math.floor(0 + t * 157);
881
+
882
+ ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
883
+ ctx.fillRect(sunX - stripeHalfWidth, stripeY, stripeHalfWidth * 2, stripeHeight);
884
+ }
885
+ }
886
+
887
+ // Horizontal lines cutting through sun (retrowave effect)
888
+ ctx.strokeStyle = this._theme.background || '#1a1a2e';
889
+ ctx.lineWidth = 3;
890
+ for (let i = 1; i < 6; i++) {
891
+ const lineY = sunY + sunRadius * 0.2 + i * 12;
892
+ if (lineY < sunY + sunRadius) {
893
+ const dy = Math.abs(lineY - sunY);
894
+ const halfWidth = Math.sqrt(sunRadius * sunRadius - dy * dy);
895
+ ctx.beginPath();
896
+ ctx.moveTo(sunX - halfWidth, lineY);
897
+ ctx.lineTo(sunX + halfWidth, lineY);
898
+ ctx.stroke();
899
+ }
900
+ }
901
+ }
902
+
903
+ draw() {
904
+ super.draw();
905
+
906
+ Painter.useCtx((ctx) => {
907
+ // Draw outrun sun if in outrun mode
908
+ if (this._isOutrunMode) {
909
+ this.drawOutrunSun(ctx);
910
+ }
911
+
912
+ const baseX = -this.scrollOffset;
913
+
914
+ // Draw vertical grid lines
915
+ ctx.strokeStyle = this._theme.primary;
916
+ this.glowLines.forEach(line => {
917
+ let x = line.x + baseX;
918
+ // Wrap
919
+ while (x < -this.cloudWidth / 2) x += this.cloudWidth;
920
+ while (x > this.cloudWidth / 2) x -= this.cloudWidth;
921
+
922
+ ctx.globalAlpha = line.opacity;
923
+ ctx.beginPath();
924
+ ctx.moveTo(x, line.y1);
925
+ ctx.lineTo(x, line.y2);
926
+ ctx.lineWidth = 1;
927
+ ctx.stroke();
928
+ });
929
+
930
+ // Draw horizontal cloud lines
931
+ this.gridLines.forEach(line => {
932
+ line.segments.forEach(seg => {
933
+ let x = seg.x + baseX;
934
+ // Wrap
935
+ while (x < -this.cloudWidth / 2 - 200) x += this.cloudWidth;
936
+ while (x > this.cloudWidth / 2 + 200) x -= this.cloudWidth;
937
+
938
+ // Glow effect
939
+ if (seg.glow) {
940
+ ctx.globalAlpha = seg.opacity * 0.3;
941
+ ctx.fillStyle = this._theme.primary;
942
+ ctx.fillRect(x - 2, line.y - 2, seg.width + 4, 5);
943
+ }
944
+
945
+ // Main line
946
+ ctx.globalAlpha = seg.opacity;
947
+ ctx.fillStyle = this._theme.primary;
948
+ ctx.fillRect(x, line.y, seg.width, 1);
949
+ });
950
+ });
951
+
952
+ // Add some "data" dots traveling along lines
953
+ const time = Date.now() / 1000;
954
+ ctx.fillStyle = this._theme.primary;
955
+ for (let i = 0; i < 5; i++) {
956
+ const lineIdx = i % this.gridLines.length;
957
+ const line = this.gridLines[lineIdx];
958
+ if (line.segments.length > 0) {
959
+ const seg = line.segments[i % line.segments.length];
960
+ const dotX = seg.x + baseX + ((time * 50 * (i + 1)) % seg.width);
961
+ let x = dotX;
962
+ while (x < -this.cloudWidth / 2 - 200) x += this.cloudWidth;
963
+ while (x > this.cloudWidth / 2 + 200) x -= this.cloudWidth;
964
+
965
+ ctx.globalAlpha = 0.8;
966
+ ctx.fillRect(x, line.y - 1, 3, 3);
967
+ }
968
+ }
969
+
970
+ ctx.globalAlpha = 1;
971
+ });
972
+ }
973
+ }
974
+
975
+ // Alias for backwards compatibility
976
+ const TronClouds = SkyLayer;
977
+
978
+ // ==================== AutoScrollScene ====================
979
+ /**
980
+ * AutoScrollScene - A PlatformerScene for endless runner
981
+ * Full screen centered layout
982
+ */
983
+ class AutoScrollScene extends PlatformerScene {
984
+ constructor(game, options = {}) {
985
+ super(game, {
986
+ ...options,
987
+ autoInput: false,
988
+ });
989
+ this.scrollSpeed = options.scrollSpeed || CONFIG.scrollSpeed;
990
+ }
991
+
992
+ updateCamera(dt) {
993
+ // No camera following in endless runner
994
+ }
995
+
996
+ getCameraOffset() {
997
+ return { x: 0, y: 0 };
998
+ }
999
+ }
1000
+
1001
+ // ==================== DinoGame ====================
1002
+ /**
1003
+ * DinoGame - Vercel/Terminal aesthetic endless runner
1004
+ */
1005
+ class DinoGame extends Game {
1006
+ constructor(canvas) {
1007
+ super(canvas);
1008
+ this.backgroundColor = CONFIG.theme.background;
1009
+ this.enableFluidSize();
1010
+ }
1011
+
1012
+ init() {
1013
+ super.init();
1014
+ SFX.init();
1015
+ this.setupGame();
1016
+ this.setupInput();
1017
+ }
1018
+
1019
+ setupGame() {
1020
+ // Game state
1021
+ this.score = 0;
1022
+ this.highScore = parseInt(localStorage.getItem('dinoHighScore') || '0');
1023
+ this.scrollSpeed = CONFIG.scrollSpeed;
1024
+ this.gameOver = false;
1025
+ this.gameStarted = false;
1026
+ this.distance = 0;
1027
+ this.nextCactusSpawn = this.getRandomSpawnTime();
1028
+ this.cacti = [];
1029
+
1030
+ // Theme state
1031
+ this.isOutrunMode = false;
1032
+ this.currentTheme = CONFIG.theme;
1033
+ this._lastThemeScore = 0; // Track last theme transition
1034
+
1035
+ // Calculate ground Y - centered vertically, slightly below center
1036
+ this.groundY = this.height * 0.65;
1037
+
1038
+ // Create player at left side of screen
1039
+ this.dino = new Dino(this, {
1040
+ x: this.width * 0.15,
1041
+ y: this.groundY - 27,
1042
+ });
1043
+
1044
+ // Create platformer scene - full screen at origin
1045
+ this.level = new AutoScrollScene(this, {
1046
+ player: this.dino,
1047
+ gravity: CONFIG.gravity,
1048
+ jumpVelocity: CONFIG.jumpVelocity,
1049
+ groundY: this.groundY - 27,
1050
+ scrollSpeed: this.scrollSpeed,
1051
+ x: 0,
1052
+ y: 0,
1053
+ });
1054
+
1055
+ // Add Tron-style clouds (slow parallax)
1056
+ this.clouds = new TronClouds(this, {
1057
+ x: this.width / 2,
1058
+ y: this.groundY - 100,
1059
+ });
1060
+ this.level.addLayer(this.clouds, { speed: 0.3 });
1061
+
1062
+ // Add ground - spans full width
1063
+ this.ground = new Ground(this, {
1064
+ x: this.width / 2,
1065
+ y: this.groundY,
1066
+ width: this.width * 3,
1067
+ });
1068
+ this.level.addLayer(this.ground, { speed: 0 });
1069
+
1070
+ // Add player to scene
1071
+ this.level.add(this.dino);
1072
+
1073
+ this.pipeline.add(this.level);
1074
+
1075
+ // UI Elements
1076
+ this.createUI();
1077
+
1078
+ // FPS counter
1079
+ this.pipeline.add(
1080
+ new FPSCounter(this, {
1081
+ color: CONFIG.theme.textDim,
1082
+ anchor: Position.BOTTOM_RIGHT
1083
+ })
1084
+ );
1085
+ }
1086
+
1087
+ createUI() {
1088
+ // Score display (top right)
1089
+ this.scoreText = new Text(this, "00000", {
1090
+ font: "bold 24px 'Courier New', monospace",
1091
+ color: CONFIG.theme.primary,
1092
+ align: "right",
1093
+ anchor: Position.BOTTOM_LEFT,
1094
+ anchorOffsetX: -30,
1095
+ });
1096
+ this.pipeline.add(this.scoreText);
1097
+
1098
+ // High score (next to score)
1099
+ this.highScoreText = new Text(this, this.highScore > 0 ? `HI ${String(this.highScore).padStart(5, "0")}` : "", {
1100
+ font: "16px 'Courier New', monospace",
1101
+ color: CONFIG.theme.textDim,
1102
+ align: "right",
1103
+ anchor: Position.TOP_RIGHT,
1104
+ anchorOffsetX: -30,
1105
+ anchorOffsetY: 55,
1106
+ });
1107
+ this.pipeline.add(this.highScoreText);
1108
+
1109
+ // Start message - centered
1110
+ this.startText = new Text(this, "[ PRESS SPACE TO START ]", {
1111
+ font: "18px 'Courier New', monospace",
1112
+ color: CONFIG.theme.primary,
1113
+ align: "center",
1114
+ anchor: Position.CENTER,
1115
+ });
1116
+ this.pipeline.add(this.startText);
1117
+
1118
+ // Subtitle
1119
+ this.subtitleText = new Text(this, "avoid the obstacles", {
1120
+ font: "14px 'Courier New', monospace",
1121
+ color: CONFIG.theme.textDim,
1122
+ align: "center",
1123
+ anchor: Position.CENTER,
1124
+ anchorOffsetY: 30,
1125
+ });
1126
+ this.pipeline.add(this.subtitleText);
1127
+
1128
+ // Game over text (hidden)
1129
+ this.gameOverText = new Text(this, "GAME OVER", {
1130
+ font: "bold 36px 'Courier New', monospace",
1131
+ color: CONFIG.theme.primary,
1132
+ align: "center",
1133
+ anchor: Position.CENTER,
1134
+ anchorOffsetY: -30,
1135
+ visible: false,
1136
+ });
1137
+ this.pipeline.add(this.gameOverText);
1138
+
1139
+ // Restart instruction (hidden)
1140
+ this.restartText = new Text(this, "[ PRESS SPACE TO RESTART ]", {
1141
+ font: "16px 'Courier New', monospace",
1142
+ color: CONFIG.theme.textDim,
1143
+ align: "center",
1144
+ anchor: Position.CENTER,
1145
+ anchorOffsetY: 20,
1146
+ visible: false,
1147
+ });
1148
+ this.pipeline.add(this.restartText);
1149
+
1150
+ // Final score text (hidden)
1151
+ this.finalScoreText = new Text(this, "", {
1152
+ font: "20px 'Courier New', monospace",
1153
+ color: CONFIG.theme.text,
1154
+ align: "center",
1155
+ anchor: Position.CENTER,
1156
+ anchorOffsetY: 60,
1157
+ visible: false,
1158
+ });
1159
+ this.pipeline.add(this.finalScoreText);
1160
+ }
1161
+
1162
+ setupInput() {
1163
+ this.events.on(Keys.SPACE, () => this.handleJump());
1164
+ this.events.on(Keys.UP, () => this.handleJump());
1165
+ this.events.on(Keys.W, () => this.handleJump());
1166
+ }
1167
+
1168
+ async handleJump() {
1169
+ // Resume audio on first interaction
1170
+ await SFX.resume();
1171
+
1172
+ if (this.gameOver) {
1173
+ this.restartGame();
1174
+ return;
1175
+ }
1176
+
1177
+ if (!this.gameStarted) {
1178
+ this.startGame();
1179
+ return;
1180
+ }
1181
+
1182
+ if (this.level.isPlayerGrounded()) {
1183
+ this.dino.vy = CONFIG.jumpVelocity;
1184
+ this.dino._grounded = false;
1185
+ SFX.jump();
1186
+ }
1187
+ }
1188
+
1189
+ startGame() {
1190
+ this.gameStarted = true;
1191
+ this.startText.visible = false;
1192
+ this.subtitleText.visible = false;
1193
+ this.dino.startRunning(); // Start walking animation
1194
+ SFX.start();
1195
+ }
1196
+
1197
+ restartGame() {
1198
+ // Save high score to localStorage
1199
+ if (this.score > this.highScore) {
1200
+ this.highScore = this.score;
1201
+ localStorage.setItem('dinoHighScore', String(this.highScore));
1202
+ }
1203
+
1204
+ // Clear and restart
1205
+ this.pipeline.clear();
1206
+ this.setupGame();
1207
+
1208
+ // Auto-start after game over
1209
+ this.gameStarted = true;
1210
+ this.startText.visible = false;
1211
+ this.subtitleText.visible = false;
1212
+ this.dino.startRunning(); // Start walking animation
1213
+ }
1214
+
1215
+ getRandomSpawnTime() {
1216
+ return CONFIG.cactusSpawnMinInterval +
1217
+ Math.random() * (CONFIG.cactusSpawnMaxInterval - CONFIG.cactusSpawnMinInterval);
1218
+ }
1219
+
1220
+ /**
1221
+ * Transition to a new theme (normal or outrun)
1222
+ * @param {boolean} toOutrun - True to switch to outrun mode
1223
+ */
1224
+ setTheme(toOutrun) {
1225
+ if (this.isOutrunMode === toOutrun) return;
1226
+
1227
+ this.isOutrunMode = toOutrun;
1228
+ this.currentTheme = toOutrun ? CONFIG.outrunTheme : CONFIG.theme;
1229
+
1230
+ // Update background
1231
+ this.backgroundColor = this.currentTheme.background;
1232
+
1233
+ // Update dino
1234
+ if (this.dino) {
1235
+ this.dino.setTheme(this.currentTheme);
1236
+ }
1237
+
1238
+ // Update ground
1239
+ if (this.ground) {
1240
+ this.ground.setTheme(this.currentTheme);
1241
+ }
1242
+
1243
+ // Update clouds/sky
1244
+ if (this.clouds) {
1245
+ this.clouds.setTheme(this.currentTheme, toOutrun);
1246
+ }
1247
+
1248
+ // Update UI colors
1249
+ if (this.scoreText) {
1250
+ this.scoreText.color = this.currentTheme.primary;
1251
+ }
1252
+ if (this.highScoreText) {
1253
+ this.highScoreText.color = this.currentTheme.textDim;
1254
+ }
1255
+ if (this.startText) {
1256
+ this.startText.color = this.currentTheme.primary;
1257
+ }
1258
+ if (this.gameOverText) {
1259
+ this.gameOverText.color = this.currentTheme.primary;
1260
+ }
1261
+ if (this.restartText) {
1262
+ this.restartText.color = this.currentTheme.textDim;
1263
+ }
1264
+ if (this.finalScoreText) {
1265
+ this.finalScoreText.color = this.currentTheme.text;
1266
+ }
1267
+
1268
+ // Play transition sound
1269
+ SFX.themeTransition(toOutrun);
1270
+ }
1271
+
1272
+ /**
1273
+ * Check and handle theme transitions based on score
1274
+ */
1275
+ checkThemeTransition() {
1276
+ // Calculate position in the level cycle
1277
+ const cyclePosition = this.score % CONFIG.levelCycle;
1278
+
1279
+ // Within first 1000 points: normal theme
1280
+ // From 1000 to 6000: outrun theme
1281
+ const shouldBeOutrun = cyclePosition >= CONFIG.outrunStartScore;
1282
+
1283
+ // Only transition if state changed
1284
+ if (shouldBeOutrun !== this.isOutrunMode) {
1285
+ this.setTheme(shouldBeOutrun);
1286
+ }
1287
+ }
1288
+
1289
+ update(dt) {
1290
+ if (!this.gameStarted || this.gameOver) {
1291
+ // Blinking effect for start text
1292
+ if (!this.gameStarted && this.startText) {
1293
+ const blink = Math.sin(Date.now() / 500) > 0;
1294
+ this.startText.opacity = blink ? 1 : 0.5;
1295
+ }
1296
+ super.update(dt);
1297
+ return;
1298
+ }
1299
+
1300
+ // Update distance/score
1301
+ this.distance += this.scrollSpeed * dt;
1302
+ this.score = Math.floor(this.distance / 10);
1303
+ this.scoreText.text = String(this.score).padStart(5, "0");
1304
+
1305
+ // Check for theme transitions (normal <-> outrun)
1306
+ this.checkThemeTransition();
1307
+
1308
+ // Update scroll offsets for ground and clouds
1309
+ if (this.ground) {
1310
+ this.ground.setScrollOffset(this.distance);
1311
+ }
1312
+ if (this.clouds) {
1313
+ this.clouds.setScrollOffset(this.distance * 0.3); // Slower parallax
1314
+ }
1315
+
1316
+ // Milestone flash effect (every 100 points)
1317
+ if (this.score > 0 && this.score % 100 === 0 && !this._lastMilestone) {
1318
+ this._lastMilestone = this.score;
1319
+ this.scoreText.color = this.currentTheme.text;
1320
+ SFX.milestone();
1321
+ setTimeout(() => {
1322
+ if (this.scoreText) this.scoreText.color = this.currentTheme.primary;
1323
+ }, 100);
1324
+ } else if (this.score % 100 !== 0) {
1325
+ this._lastMilestone = null;
1326
+ }
1327
+
1328
+ // Increase speed over time
1329
+ this.scrollSpeed = Math.min(
1330
+ CONFIG.maxScrollSpeed,
1331
+ this.scrollSpeed + CONFIG.scrollAcceleration * dt
1332
+ );
1333
+
1334
+ // Spawn cacti
1335
+ this.nextCactusSpawn -= dt;
1336
+ if (this.nextCactusSpawn <= 0) {
1337
+ this.spawnCactus();
1338
+ this.nextCactusSpawn = this.getRandomSpawnTime();
1339
+ }
1340
+
1341
+ // Update cacti and check collisions
1342
+ for (let i = this.cacti.length - 1; i >= 0; i--) {
1343
+ const cactus = this.cacti[i];
1344
+ cactus.x -= this.scrollSpeed * dt;
1345
+
1346
+ // Remove off-screen
1347
+ if (cactus.x < -100) {
1348
+ this.level.remove(cactus);
1349
+ this.cacti.splice(i, 1);
1350
+ continue;
1351
+ }
1352
+
1353
+ // Collision check
1354
+ if (Collision.rectRect(this.dino.getBounds(), cactus.getBounds())) {
1355
+ this.triggerGameOver();
1356
+ break;
1357
+ }
1358
+ }
1359
+
1360
+ super.update(dt);
1361
+ }
1362
+
1363
+ spawnCactus() {
1364
+ const cactusHeight = CONFIG.cactusMinHeight +
1365
+ Math.random() * (CONFIG.cactusMaxHeight - CONFIG.cactusMinHeight);
1366
+
1367
+ // In outrun mode, spawn palm trees instead of cacti
1368
+ const obstacle = new Obstacle(this, {
1369
+ x: this.width + 50,
1370
+ y: this.groundY - cactusHeight / 2,
1371
+ height: cactusHeight,
1372
+ isPalmTree: this.isOutrunMode,
1373
+ theme: this.currentTheme,
1374
+ });
1375
+
1376
+ this.cacti.push(obstacle);
1377
+ this.level.add(obstacle);
1378
+ }
1379
+
1380
+ triggerGameOver() {
1381
+ this.gameOver = true;
1382
+ this.dino.stopRunning();
1383
+ this.gameOverText.visible = true;
1384
+ this.restartText.visible = true;
1385
+ this.finalScoreText.visible = true;
1386
+ this.finalScoreText.text = `SCORE: ${this.score}`;
1387
+ SFX.gameOver();
1388
+
1389
+ // Update high score
1390
+ if (this.score > this.highScore) {
1391
+ this.highScore = this.score;
1392
+ this.highScoreText.text = `HI ${String(this.highScore).padStart(5, "0")}`;
1393
+ localStorage.setItem('dinoHighScore', String(this.highScore));
1394
+ }
1395
+ }
1396
+
1397
+ onResize() {
1398
+ // Recalculate positions on resize
1399
+ if (this.dino) {
1400
+ this.groundY = this.height * 0.65;
1401
+ this.dino.x = this.width * 0.15;
1402
+ this.level.groundY = this.groundY - 27;
1403
+ if (this.ground) {
1404
+ this.ground.x = this.width / 2;
1405
+ this.ground.y = this.groundY;
1406
+ }
1407
+ if (this.clouds) {
1408
+ this.clouds.x = this.width / 2;
1409
+ this.clouds.y = this.groundY - 100;
1410
+ }
1411
+ }
1412
+ }
1413
+ }
1414
+
1415
+ // ==================== Initialize ====================
1416
+ window.addEventListener("load", () => {
1417
+ const canvas = document.getElementById("game");
1418
+ const game = new DinoGame(canvas);
1419
+ game.start();
1420
+ });