@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.
- package/demos/coordinates.html +698 -0
- package/demos/cube3d.html +23 -0
- package/demos/demos.css +17 -3
- package/demos/dino.html +42 -0
- package/demos/gameobjects.html +626 -0
- package/demos/index.html +17 -7
- package/demos/js/coordinates.js +840 -0
- package/demos/js/cube3d.js +789 -0
- package/demos/js/dino.js +1420 -0
- package/demos/js/gameobjects.js +176 -0
- package/demos/js/plane3d.js +256 -0
- package/demos/js/platformer.js +1579 -0
- package/demos/js/sphere3d.js +229 -0
- package/demos/js/sprite.js +473 -0
- package/demos/js/tde/accretiondisk.js +3 -3
- package/demos/js/tde/tidalstream.js +2 -2
- package/demos/plane3d.html +24 -0
- package/demos/platformer.html +43 -0
- package/demos/sphere3d.html +24 -0
- package/demos/sprite.html +18 -0
- package/docs/concepts/coordinate-system.md +384 -0
- package/docs/concepts/shapes-vs-gameobjects.md +187 -0
- package/docs/fluid-dynamics.md +99 -97
- package/package.json +1 -1
- package/src/game/game.js +11 -5
- package/src/game/objects/index.js +3 -0
- package/src/game/objects/platformer-scene.js +411 -0
- package/src/game/objects/scene.js +14 -0
- package/src/game/objects/sprite.js +529 -0
- package/src/game/pipeline.js +20 -16
- package/src/game/ui/theme.js +123 -121
- package/src/io/input.js +75 -45
- package/src/io/mouse.js +44 -19
- package/src/io/touch.js +35 -12
- package/src/shapes/cube3d.js +599 -0
- package/src/shapes/index.js +2 -0
- package/src/shapes/plane3d.js +687 -0
- package/src/shapes/sphere3d.js +75 -6
- package/src/util/camera2d.js +315 -0
- package/src/util/index.js +1 -0
- package/src/webgl/shaders/plane-shaders.js +332 -0
- package/src/webgl/shaders/sphere-shaders.js +4 -2
package/demos/js/dino.js
ADDED
|
@@ -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
|
+
});
|