groove-dev 0.27.110 → 0.27.111

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 (37) hide show
  1. package/EMBEDDING_SERVICE_BUILD_PLAN.md +200 -0
  2. package/TRAINING_DATA_v2.md +9 -0
  3. package/moe-training/client/domain-tagger.js +3 -1
  4. package/moe-training/client/trajectory-capture.js +3 -2
  5. package/moe-training/shared/constants.js +1 -0
  6. package/moe-training/test/client/domain-tagger.test.js +6 -4
  7. package/node_modules/@groove-dev/cli/package.json +1 -1
  8. package/node_modules/@groove-dev/daemon/package.json +1 -1
  9. package/node_modules/@groove-dev/daemon/src/api.js +16 -3
  10. package/node_modules/@groove-dev/gui/dist/assets/{index-B8JomvGM.js → index-CHu5w3i3.js} +1 -1
  11. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  12. package/node_modules/@groove-dev/gui/package.json +1 -1
  13. package/node_modules/@groove-dev/gui/src/stores/groove.js +1 -1
  14. package/node_modules/moe-training/client/domain-tagger.js +3 -1
  15. package/node_modules/moe-training/client/trajectory-capture.js +3 -2
  16. package/node_modules/moe-training/shared/constants.js +1 -0
  17. package/node_modules/moe-training/test/client/domain-tagger.test.js +6 -4
  18. package/package.json +1 -1
  19. package/packages/cli/package.json +1 -1
  20. package/packages/daemon/package.json +1 -1
  21. package/packages/daemon/src/api.js +16 -3
  22. package/packages/gui/dist/assets/{index-B8JomvGM.js → index-CHu5w3i3.js} +1 -1
  23. package/packages/gui/dist/index.html +1 -1
  24. package/packages/gui/package.json +1 -1
  25. package/packages/gui/src/stores/groove.js +1 -1
  26. package/TRAINING_DATA.md +0 -12
  27. package/codex/browser-racing-game/README.md +0 -45
  28. package/codex/browser-racing-game/dist/assets/index-D-sGTraQ.js +0 -47
  29. package/codex/browser-racing-game/dist/assets/index-S75nJv69.css +0 -1
  30. package/codex/browser-racing-game/dist/index.html +0 -14
  31. package/codex/browser-racing-game/index.html +0 -13
  32. package/codex/browser-racing-game/package-lock.json +0 -841
  33. package/codex/browser-racing-game/package.json +0 -15
  34. package/codex/browser-racing-game/src/app.css +0 -359
  35. package/codex/browser-racing-game/src/main.ts +0 -913
  36. package/codex/browser-racing-game/tsconfig.json +0 -20
  37. package/codex/browser-racing-game/vite.config.ts +0 -12
@@ -1,913 +0,0 @@
1
- import './app.css';
2
-
3
- type Phase = 'menu' | 'countdown' | 'racing' | 'finished';
4
-
5
- type Vec = { x: number; y: number };
6
-
7
- type TrackSample = {
8
- position: Vec;
9
- tangent: Vec;
10
- normal: Vec;
11
- angle: number;
12
- };
13
-
14
- type NearestTrackPoint = TrackSample & {
15
- distance: number;
16
- along: number;
17
- signedOffset: number;
18
- };
19
-
20
- type Racer = {
21
- id: string;
22
- name: string;
23
- color: string;
24
- isPlayer: boolean;
25
- position: Vec;
26
- velocity: Vec;
27
- angle: number;
28
- speed: number;
29
- boost: number;
30
- lap: number;
31
- progress: number;
32
- totalProgress: number;
33
- lastProgress: number;
34
- lane: number;
35
- targetSpeed: number;
36
- finished: boolean;
37
- finishTime: number | null;
38
- bumpFlash: number;
39
- offTrack: boolean;
40
- };
41
-
42
- type Particle = {
43
- position: Vec;
44
- velocity: Vec;
45
- color: string;
46
- life: number;
47
- maxLife: number;
48
- size: number;
49
- };
50
-
51
- type Segment = {
52
- a: Vec;
53
- b: Vec;
54
- length: number;
55
- start: number;
56
- };
57
-
58
- const WORLD = { width: 2400, height: 1500 };
59
- const TRACK_HALF_WIDTH = 145;
60
- const TRACK_EDGE_WIDTH = 22;
61
- const TOTAL_LAPS = 3;
62
- const PLAYER_MAX_SPEED = 760;
63
- const BOOST_MAX_SPEED = 1040;
64
- const AI_COUNT = 7;
65
-
66
- const WAYPOINTS: Vec[] = [
67
- { x: 520, y: 1070 },
68
- { x: 350, y: 760 },
69
- { x: 470, y: 360 },
70
- { x: 880, y: 230 },
71
- { x: 1320, y: 335 },
72
- { x: 1585, y: 245 },
73
- { x: 2040, y: 390 },
74
- { x: 2180, y: 740 },
75
- { x: 2020, y: 1075 },
76
- { x: 1600, y: 1235 },
77
- { x: 1260, y: 1045 },
78
- { x: 940, y: 1250 }
79
- ];
80
-
81
- const AI_NAMES = ['Vega', 'Blitz', 'Nova', 'Apex', 'Turbo', 'Rook', 'Echo'];
82
- const AI_COLORS = ['#ff4d6d', '#f7b731', '#45aaf2', '#a55eea', '#26de81', '#fd9644', '#d1d8e0'];
83
- const FINISH_DISTANCE = trackFinishBuffer();
84
-
85
- const app = document.querySelector<HTMLDivElement>('#app');
86
- if (!app) throw new Error('Missing #app root');
87
-
88
- app.innerHTML = `
89
- <main class="shell">
90
- <section class="hero-panel" aria-label="Game introduction">
91
- <div>
92
- <p class="eyebrow">Apex Rush GP</p>
93
- <h1>Canvas racing with boost, bruises, and bot rivals.</h1>
94
- <p class="lede">Thread the racing line, manage turbo, and out-drag seven AI competitors across ${TOTAL_LAPS} laps.</p>
95
- </div>
96
- <div class="controls-card">
97
- <div><kbd>W</kbd><kbd>↑</kbd><span>Throttle</span></div>
98
- <div><kbd>S</kbd><kbd>↓</kbd><span>Brake / reverse</span></div>
99
- <div><kbd>A</kbd><kbd>D</kbd><kbd>←</kbd><kbd>→</kbd><span>Steer</span></div>
100
- <div><kbd>Space</kbd><span>Boost</span></div>
101
- </div>
102
- </section>
103
-
104
- <section class="game-card" aria-label="Apex Rush GP racing game">
105
- <canvas id="raceCanvas" aria-label="Top-down racing canvas"></canvas>
106
- <div class="hud top-left">
107
- <span class="label">Speed</span>
108
- <strong id="speedText">0</strong>
109
- <span class="unit">km/h</span>
110
- </div>
111
- <div class="hud top-center">
112
- <span class="label">Lap</span>
113
- <strong id="lapText">1/${TOTAL_LAPS}</strong>
114
- </div>
115
- <div class="hud top-right">
116
- <span class="label">Place</span>
117
- <strong id="placeText">P1</strong>
118
- </div>
119
- <div class="boost-meter" aria-label="Boost meter"><span id="boostFill"></span></div>
120
- <canvas id="miniMap" class="mini-map" aria-label="Track minimap"></canvas>
121
- <div id="banner" class="banner">
122
- <p class="eyebrow">Ready?</p>
123
- <h2>Apex Rush GP</h2>
124
- <button id="startButton" class="primary-button">Start Race</button>
125
- </div>
126
- <div id="results" class="results hidden" aria-live="polite"></div>
127
- </section>
128
- </main>
129
- `;
130
-
131
- const canvas = requireElement<HTMLCanvasElement>('#raceCanvas');
132
- const miniMap = requireElement<HTMLCanvasElement>('#miniMap');
133
- const speedText = requireElement<HTMLSpanElement>('#speedText');
134
- const lapText = requireElement<HTMLSpanElement>('#lapText');
135
- const placeText = requireElement<HTMLSpanElement>('#placeText');
136
- const boostFill = requireElement<HTMLSpanElement>('#boostFill');
137
- const banner = requireElement<HTMLDivElement>('#banner');
138
- const results = requireElement<HTMLDivElement>('#results');
139
- const startButton = requireElement<HTMLButtonElement>('#startButton');
140
- const context = requireCanvasContext(canvas);
141
- const miniContext = requireCanvasContext(miniMap);
142
-
143
- const segments = buildSegments(WAYPOINTS);
144
- const trackLength = segments.reduce((sum, segment) => sum + segment.length, 0);
145
- const keys = new Set<string>();
146
- let racers: Racer[] = [];
147
- let particles: Particle[] = [];
148
- let phase: Phase = 'menu';
149
- let countdownStartedAt = 0;
150
- let raceStartedAt = 0;
151
- let lastFrame = performance.now();
152
- let camera = { x: WORLD.width / 2, y: WORLD.height / 2, shake: 0 };
153
- let audio: AudioController | null = null;
154
- let hasFinishedCelebration = false;
155
-
156
- function buildSegments(points: Vec[]): Segment[] {
157
- let start = 0;
158
- return points.map((point, index) => {
159
- const next = points[(index + 1) % points.length];
160
- const length = distance(point, next);
161
- const segment = { a: point, b: next, length, start };
162
- start += length;
163
- return segment;
164
- });
165
- }
166
-
167
- class AudioController {
168
- private context: AudioContext;
169
- private master: GainNode;
170
- private engineOscillator: OscillatorNode;
171
- private engineGain: GainNode;
172
-
173
- constructor() {
174
- this.context = new AudioContext();
175
- this.master = this.context.createGain();
176
- this.master.gain.value = 0.16;
177
- this.master.connect(this.context.destination);
178
-
179
- this.engineOscillator = this.context.createOscillator();
180
- this.engineGain = this.context.createGain();
181
- this.engineOscillator.type = 'sawtooth';
182
- this.engineOscillator.frequency.value = 70;
183
- this.engineGain.gain.value = 0;
184
- this.engineOscillator.connect(this.engineGain);
185
- this.engineGain.connect(this.master);
186
- this.engineOscillator.start();
187
- }
188
-
189
- async resume() {
190
- if (this.context.state !== 'running') await this.context.resume();
191
- }
192
-
193
- updateEngine(speedRatio: number, boosting: boolean) {
194
- const now = this.context.currentTime;
195
- this.engineOscillator.frequency.setTargetAtTime(65 + speedRatio * 170 + (boosting ? 75 : 0), now, 0.04);
196
- this.engineGain.gain.setTargetAtTime(0.015 + speedRatio * 0.07 + (boosting ? 0.035 : 0), now, 0.05);
197
- }
198
-
199
- tone(frequency: number, duration = 0.12, type: OscillatorType = 'triangle', gain = 0.08) {
200
- const oscillator = this.context.createOscillator();
201
- const toneGain = this.context.createGain();
202
- oscillator.type = type;
203
- oscillator.frequency.value = frequency;
204
- toneGain.gain.value = 0;
205
- oscillator.connect(toneGain);
206
- toneGain.connect(this.master);
207
- oscillator.start();
208
- const now = this.context.currentTime;
209
- toneGain.gain.linearRampToValueAtTime(gain, now + 0.01);
210
- toneGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
211
- oscillator.stop(now + duration + 0.02);
212
- }
213
-
214
- crash() {
215
- this.tone(72, 0.18, 'square', 0.1);
216
- window.setTimeout(() => this.tone(48, 0.12, 'sawtooth', 0.05), 35);
217
- }
218
-
219
- lap() {
220
- this.tone(540, 0.1, 'triangle', 0.08);
221
- window.setTimeout(() => this.tone(760, 0.14, 'triangle', 0.08), 85);
222
- }
223
-
224
- countdown(number: number) {
225
- this.tone(number === 0 ? 880 : 420, number === 0 ? 0.28 : 0.12, 'sine', 0.1);
226
- }
227
- }
228
-
229
- function createRacers(): Racer[] {
230
- const gridDistance = trackLength - 170;
231
- const playerStart = pointAtDistance(gridDistance, 0);
232
- const allRacers: Racer[] = [
233
- {
234
- id: 'player',
235
- name: 'You',
236
- color: '#33afbc',
237
- isPlayer: true,
238
- position: playerStart.position,
239
- velocity: { x: 0, y: 0 },
240
- angle: playerStart.angle,
241
- speed: 0,
242
- boost: 100,
243
- lap: 1,
244
- progress: gridDistance,
245
- totalProgress: 0,
246
- lastProgress: gridDistance,
247
- lane: 0,
248
- targetSpeed: PLAYER_MAX_SPEED,
249
- finished: false,
250
- finishTime: null,
251
- bumpFlash: 0,
252
- offTrack: false
253
- }
254
- ];
255
-
256
- for (let index = 0; index < AI_COUNT; index += 1) {
257
- const row = Math.floor((index + 1) / 2);
258
- const side = index % 2 === 0 ? -1 : 1;
259
- const lane = side * (42 + row * 7);
260
- const sample = pointAtDistance(gridDistance - 72 * (index + 1), lane);
261
- allRacers.push({
262
- id: `ai-${index}`,
263
- name: AI_NAMES[index],
264
- color: AI_COLORS[index],
265
- isPlayer: false,
266
- position: sample.position,
267
- velocity: { x: 0, y: 0 },
268
- angle: sample.angle,
269
- speed: 0,
270
- boost: 100,
271
- lap: 1,
272
- progress: wrapDistance(gridDistance - 72 * (index + 1)),
273
- totalProgress: -72 * (index + 1),
274
- lastProgress: wrapDistance(gridDistance - 72 * (index + 1)),
275
- lane,
276
- targetSpeed: 650 + index * 12 + Math.random() * 52,
277
- finished: false,
278
- finishTime: null,
279
- bumpFlash: 0,
280
- offTrack: false
281
- });
282
- }
283
-
284
- return allRacers;
285
- }
286
-
287
- function startCountdown() {
288
- audio ??= new AudioController();
289
- void audio.resume();
290
- phase = 'countdown';
291
- racers = createRacers();
292
- particles = [];
293
- countdownStartedAt = performance.now();
294
- raceStartedAt = 0;
295
- hasFinishedCelebration = false;
296
- results.classList.add('hidden');
297
- banner.classList.remove('hidden');
298
- banner.innerHTML = `<p class="eyebrow">Engines hot</p><h2 id="countdownText">3</h2>`;
299
- audio.countdown(3);
300
- }
301
-
302
- function beginRace() {
303
- phase = 'racing';
304
- raceStartedAt = performance.now();
305
- banner.classList.add('hidden');
306
- }
307
-
308
- function finishRace() {
309
- phase = 'finished';
310
- banner.classList.add('hidden');
311
- const standings = rankedRacers();
312
- const playerPlace = standings.findIndex((racer) => racer.isPlayer) + 1;
313
- results.classList.remove('hidden');
314
- results.innerHTML = `
315
- <p class="eyebrow">Race Complete</p>
316
- <h2>${ordinal(playerPlace)} place!</h2>
317
- <ol>${standings
318
- .map((racer, index) => `<li><span>${index + 1}. ${racer.name}</span><strong>${formatRaceTime(racer.finishTime ?? performance.now())}</strong></li>`)
319
- .join('')}</ol>
320
- <button id="restartButton" class="primary-button">Race Again</button>
321
- `;
322
- results.querySelector<HTMLButtonElement>('#restartButton')?.addEventListener('click', startCountdown);
323
- }
324
-
325
- function update(delta: number, now: number) {
326
- if (phase === 'countdown') {
327
- const elapsed = (now - countdownStartedAt) / 1000;
328
- const countdownText = document.querySelector<HTMLHeadingElement>('#countdownText');
329
- const number = Math.max(0, 3 - Math.floor(elapsed));
330
- if (countdownText) countdownText.textContent = number === 0 ? 'GO!' : String(number);
331
- if (Math.abs(elapsed - 1) < delta) audio?.countdown(2);
332
- if (Math.abs(elapsed - 2) < delta) audio?.countdown(1);
333
- if (Math.abs(elapsed - 3) < delta) audio?.countdown(0);
334
- if (elapsed >= 3.65) beginRace();
335
- }
336
-
337
- if (phase === 'racing' || phase === 'finished') {
338
- for (const racer of racers) {
339
- if (racer.finished) continue;
340
- if (racer.isPlayer) updatePlayer(racer, delta);
341
- else updateAi(racer, delta, now);
342
- updateRacerProgress(racer, now);
343
- racer.bumpFlash = Math.max(0, racer.bumpFlash - delta * 4);
344
- }
345
-
346
- handleCollisions(delta);
347
- updateParticles(delta);
348
- updateCamera(delta);
349
- updateHud();
350
-
351
- const player = racers[0];
352
- audio?.updateEngine(Math.min(1, Math.abs(player.speed) / PLAYER_MAX_SPEED), keys.has(' ') && player.boost > 0 && phase === 'racing');
353
-
354
- if (phase === 'racing' && player.finished && !hasFinishedCelebration) {
355
- hasFinishedCelebration = true;
356
- audio?.lap();
357
- finishRace();
358
- }
359
- } else {
360
- updateCamera(delta);
361
- updateHud();
362
- }
363
- }
364
-
365
- function updatePlayer(player: Racer, delta: number) {
366
- const throttle = pressed('w', 'arrowup');
367
- const brake = pressed('s', 'arrowdown');
368
- const steerLeft = pressed('a', 'arrowleft');
369
- const steerRight = pressed('d', 'arrowright');
370
- const boosting = keys.has(' ') && player.boost > 0 && player.speed > 120;
371
-
372
- if (throttle) player.speed += 620 * delta;
373
- if (brake) player.speed -= player.speed > 60 ? 840 * delta : 430 * delta;
374
- if (!throttle && !brake) player.speed *= 1 - Math.min(0.045, delta * 1.7);
375
-
376
- if (boosting) {
377
- player.speed += 930 * delta;
378
- player.boost = Math.max(0, player.boost - 35 * delta);
379
- emitExhaust(player, '#59f3ff', 2);
380
- } else {
381
- player.boost = Math.min(100, player.boost + 10 * delta);
382
- }
383
-
384
- const nearest = nearestTrackPoint(player.position);
385
- player.offTrack = nearest.distance > TRACK_HALF_WIDTH - 8;
386
- const maxSpeed = boosting ? BOOST_MAX_SPEED : PLAYER_MAX_SPEED;
387
- const surfacePenalty = player.offTrack ? 0.66 : 1;
388
- player.speed = clamp(player.speed, -220, maxSpeed * surfacePenalty);
389
-
390
- if (player.offTrack) {
391
- player.speed *= 1 - Math.min(0.04, delta * 1.8);
392
- if (Math.random() < 0.7) emitExhaust(player, '#b28758', 1);
393
- }
394
-
395
- const steerInput = Number(steerRight) - Number(steerLeft);
396
- const speedGrip = clamp(Math.abs(player.speed) / PLAYER_MAX_SPEED, 0, 1);
397
- player.angle += steerInput * delta * (1.45 + speedGrip * 2.1) * Math.sign(player.speed || 1);
398
-
399
- player.velocity.x = Math.cos(player.angle) * player.speed;
400
- player.velocity.y = Math.sin(player.angle) * player.speed;
401
- player.position.x += player.velocity.x * delta;
402
- player.position.y += player.velocity.y * delta;
403
- constrainToTrackBounds(player);
404
-
405
- if (Math.abs(player.speed) > 180 && Math.random() < 0.25) emitExhaust(player, player.offTrack ? '#b28758' : '#c9d8e8', 1);
406
- }
407
-
408
- function updateAi(racer: Racer, delta: number, now: number) {
409
- const lookAhead = 130 + Math.abs(racer.speed) * 0.34;
410
- const wobble = Math.sin(now / 820 + Number(racer.id.slice(-1)) * 1.7) * 14;
411
- const target = pointAtDistance(racer.progress + lookAhead, racer.lane + wobble);
412
- const desiredAngle = Math.atan2(target.position.y - racer.position.y, target.position.x - racer.position.x);
413
- const turn = normalizeAngle(desiredAngle - racer.angle);
414
- racer.angle += clamp(turn, -2.45 * delta, 2.45 * delta);
415
-
416
- const nearest = nearestTrackPoint(racer.position);
417
- racer.offTrack = nearest.distance > TRACK_HALF_WIDTH - 18;
418
- const cornerIntensity = Math.abs(normalizeAngle(target.angle - pointAtDistance(racer.progress + 20).angle));
419
- const desiredSpeed = racer.targetSpeed * (1 - clamp(cornerIntensity * 0.7, 0, 0.34)) * (racer.offTrack ? 0.72 : 1);
420
- racer.speed += clamp(desiredSpeed - racer.speed, -520 * delta, 360 * delta);
421
-
422
- if (racer.boost > 18 && Math.abs(turn) < 0.16 && racer.speed > 280 && Math.random() < 0.008) {
423
- racer.speed += 160 * delta;
424
- racer.boost -= 16 * delta;
425
- emitExhaust(racer, '#8cf7ff', 1);
426
- } else {
427
- racer.boost = Math.min(100, racer.boost + 5 * delta);
428
- }
429
-
430
- racer.speed = clamp(racer.speed, 0, 850);
431
- racer.velocity.x = Math.cos(racer.angle) * racer.speed;
432
- racer.velocity.y = Math.sin(racer.angle) * racer.speed;
433
- racer.position.x += racer.velocity.x * delta;
434
- racer.position.y += racer.velocity.y * delta;
435
- constrainToTrackBounds(racer);
436
- }
437
-
438
- function updateRacerProgress(racer: Racer, now: number) {
439
- const nearest = nearestTrackPoint(racer.position);
440
- const previousLap = racer.lap;
441
- racer.lastProgress = racer.progress;
442
- racer.progress = nearest.along;
443
- let deltaProgress = racer.progress - racer.lastProgress;
444
- if (deltaProgress < -trackLength * 0.5) deltaProgress += trackLength;
445
- if (deltaProgress > trackLength * 0.5) deltaProgress -= trackLength;
446
- if (deltaProgress > -90) {
447
- racer.totalProgress += Math.max(0, deltaProgress);
448
- racer.lap = clamp(Math.floor(racer.totalProgress / trackLength) + 1, 1, TOTAL_LAPS);
449
- if (racer.isPlayer && racer.lap > previousLap && racer.lap <= TOTAL_LAPS) audio?.lap();
450
- }
451
-
452
- if (!racer.finished && racer.totalProgress >= trackLength * TOTAL_LAPS - FINISH_DISTANCE) {
453
- racer.finished = true;
454
- racer.finishTime = now;
455
- racer.speed *= 0.72;
456
- }
457
- }
458
-
459
- function constrainToTrackBounds(racer: Racer) {
460
- const nearest = nearestTrackPoint(racer.position);
461
- if (nearest.distance > TRACK_HALF_WIDTH + 92) {
462
- racer.position.x = nearest.position.x + nearest.normal.x * Math.sign(nearest.signedOffset) * (TRACK_HALF_WIDTH + 92);
463
- racer.position.y = nearest.position.y + nearest.normal.y * Math.sign(nearest.signedOffset) * (TRACK_HALF_WIDTH + 92);
464
- racer.speed *= 0.62;
465
- }
466
- }
467
-
468
- function handleCollisions(delta: number) {
469
- for (let i = 0; i < racers.length; i += 1) {
470
- for (let j = i + 1; j < racers.length; j += 1) {
471
- const a = racers[i];
472
- const b = racers[j];
473
- if (a.finished || b.finished) continue;
474
- const dx = b.position.x - a.position.x;
475
- const dy = b.position.y - a.position.y;
476
- const gap = Math.hypot(dx, dy);
477
- const minimum = 42;
478
- if (gap > 0 && gap < minimum) {
479
- const normal = { x: dx / gap, y: dy / gap };
480
- const push = (minimum - gap) * 0.55;
481
- a.position.x -= normal.x * push;
482
- a.position.y -= normal.y * push;
483
- b.position.x += normal.x * push;
484
- b.position.y += normal.y * push;
485
- const impact = Math.abs(a.speed - b.speed) * 0.18 + 80;
486
- a.speed = Math.max(-120, a.speed - impact * delta * 5);
487
- b.speed = Math.max(-120, b.speed - impact * delta * 5);
488
- a.bumpFlash = 1;
489
- b.bumpFlash = 1;
490
- camera.shake = Math.max(camera.shake, Math.min(18, impact * 0.04));
491
- if (a.isPlayer || b.isPlayer) audio?.crash();
492
- for (let spark = 0; spark < 9; spark += 1) {
493
- particles.push({
494
- position: { x: (a.position.x + b.position.x) / 2, y: (a.position.y + b.position.y) / 2 },
495
- velocity: rotate({ x: 70 + Math.random() * 260, y: 0 }, Math.random() * Math.PI * 2),
496
- color: Math.random() > 0.45 ? '#ffd166' : '#ffffff',
497
- life: 0.32,
498
- maxLife: 0.32,
499
- size: 2 + Math.random() * 3
500
- });
501
- }
502
- }
503
- }
504
- }
505
- }
506
-
507
- function updateParticles(delta: number) {
508
- particles = particles.filter((particle) => {
509
- particle.life -= delta;
510
- particle.position.x += particle.velocity.x * delta;
511
- particle.position.y += particle.velocity.y * delta;
512
- particle.velocity.x *= 1 - Math.min(0.08, delta * 4);
513
- particle.velocity.y *= 1 - Math.min(0.08, delta * 4);
514
- return particle.life > 0;
515
- });
516
- }
517
-
518
- function updateCamera(delta: number) {
519
- const player = racers[0];
520
- const targetX = player?.position.x ?? WORLD.width / 2;
521
- const targetY = player?.position.y ?? WORLD.height / 2;
522
- camera.x += (targetX - camera.x) * Math.min(1, delta * 5.5);
523
- camera.y += (targetY - camera.y) * Math.min(1, delta * 5.5);
524
- camera.shake = Math.max(0, camera.shake - delta * 28);
525
- }
526
-
527
- function render() {
528
- const width = canvas.width / devicePixelRatio;
529
- const height = canvas.height / devicePixelRatio;
530
- context.save();
531
- context.scale(devicePixelRatio, devicePixelRatio);
532
- context.clearRect(0, 0, width, height);
533
-
534
- const shakeX = (Math.random() - 0.5) * camera.shake;
535
- const shakeY = (Math.random() - 0.5) * camera.shake;
536
- context.translate(width / 2 - camera.x + shakeX, height / 2 - camera.y + shakeY);
537
-
538
- drawWorld(context);
539
- drawTrack(context);
540
- for (const particle of particles) drawParticle(context, particle);
541
- for (const racer of [...racers].sort((a, b) => a.position.y - b.position.y)) drawRacer(context, racer);
542
- context.restore();
543
-
544
- drawVignette(context, width, height);
545
- renderMinimap();
546
- }
547
-
548
- function drawWorld(ctx: CanvasRenderingContext2D) {
549
- const gradient = ctx.createLinearGradient(0, 0, WORLD.width, WORLD.height);
550
- gradient.addColorStop(0, '#102315');
551
- gradient.addColorStop(0.5, '#18351f');
552
- gradient.addColorStop(1, '#0b1c13');
553
- ctx.fillStyle = gradient;
554
- ctx.fillRect(0, 0, WORLD.width, WORLD.height);
555
-
556
- ctx.globalAlpha = 0.18;
557
- ctx.strokeStyle = '#d8f5a2';
558
- ctx.lineWidth = 2;
559
- for (let x = -100; x < WORLD.width + 100; x += 92) {
560
- ctx.beginPath();
561
- ctx.moveTo(x, 0);
562
- ctx.lineTo(x + 360, WORLD.height);
563
- ctx.stroke();
564
- }
565
- ctx.globalAlpha = 1;
566
-
567
- drawGrandstand(ctx, 1430, 92, 455, 95, '#172336');
568
- drawGrandstand(ctx, 1670, 1325, 510, 88, '#2a1731');
569
- drawPaddock(ctx);
570
- }
571
-
572
- function drawGrandstand(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, color: string) {
573
- ctx.fillStyle = 'rgba(0,0,0,0.24)';
574
- roundRect(ctx, x + 10, y + 12, width, height, 16);
575
- ctx.fill();
576
- ctx.fillStyle = color;
577
- roundRect(ctx, x, y, width, height, 16);
578
- ctx.fill();
579
- for (let row = 0; row < 4; row += 1) {
580
- for (let seat = 0; seat < 22; seat += 1) {
581
- ctx.fillStyle = ['#33afbc', '#f7b731', '#ff4d6d', '#f8fbff'][seat % 4];
582
- ctx.globalAlpha = 0.72;
583
- ctx.fillRect(x + 18 + seat * 19, y + 16 + row * 17, 10, 7);
584
- }
585
- }
586
- ctx.globalAlpha = 1;
587
- }
588
-
589
- function drawPaddock(ctx: CanvasRenderingContext2D) {
590
- ctx.fillStyle = '#202936';
591
- roundRect(ctx, 1040, 620, 430, 245, 24);
592
- ctx.fill();
593
- ctx.fillStyle = '#101722';
594
- roundRect(ctx, 1080, 655, 350, 52, 12);
595
- ctx.fill();
596
- ctx.fillStyle = '#33afbc';
597
- ctx.font = '700 32px Inter, sans-serif';
598
- ctx.fillText('APEX RUSH', 1110, 693);
599
- }
600
-
601
- function drawTrack(ctx: CanvasRenderingContext2D) {
602
- drawTrackStroke(ctx, TRACK_HALF_WIDTH * 2 + TRACK_EDGE_WIDTH * 2, '#f7f9fb');
603
- drawTrackStroke(ctx, TRACK_HALF_WIDTH * 2 + TRACK_EDGE_WIDTH, '#d92238', [36, 32]);
604
- drawTrackStroke(ctx, TRACK_HALF_WIDTH * 2, '#29313c');
605
- drawTrackStroke(ctx, 7, 'rgba(255,255,255,0.28)', [30, 26]);
606
- drawTrackStroke(ctx, 3, 'rgba(255,255,255,0.55)');
607
-
608
- const start = pointAtDistance(trackLength - FINISH_DISTANCE);
609
- ctx.save();
610
- ctx.translate(start.position.x, start.position.y);
611
- ctx.rotate(start.angle + Math.PI / 2);
612
- for (let row = -4; row < 4; row += 1) {
613
- for (let column = -3; column < 3; column += 1) {
614
- ctx.fillStyle = (row + column) % 2 === 0 ? '#ffffff' : '#111827';
615
- ctx.fillRect(column * 20, row * 18, 20, 18);
616
- }
617
- }
618
- ctx.restore();
619
- }
620
-
621
- function drawTrackStroke(ctx: CanvasRenderingContext2D, width: number, color: string, dash: number[] = []) {
622
- ctx.save();
623
- ctx.lineJoin = 'round';
624
- ctx.lineCap = 'round';
625
- ctx.lineWidth = width;
626
- ctx.strokeStyle = color;
627
- ctx.setLineDash(dash);
628
- ctx.beginPath();
629
- WAYPOINTS.forEach((point, index) => {
630
- if (index === 0) ctx.moveTo(point.x, point.y);
631
- else ctx.lineTo(point.x, point.y);
632
- });
633
- ctx.closePath();
634
- ctx.stroke();
635
- ctx.restore();
636
- }
637
-
638
- function drawRacer(ctx: CanvasRenderingContext2D, racer: Racer) {
639
- ctx.save();
640
- ctx.translate(racer.position.x, racer.position.y);
641
- ctx.rotate(racer.angle);
642
-
643
- ctx.fillStyle = 'rgba(0,0,0,0.32)';
644
- roundRect(ctx, -23, -15 + 7, 46, 30, 8);
645
- ctx.fill();
646
-
647
- ctx.fillStyle = racer.bumpFlash > 0 ? '#ffffff' : racer.color;
648
- roundRect(ctx, -25, -14, 50, 28, 8);
649
- ctx.fill();
650
-
651
- ctx.fillStyle = '#07111e';
652
- roundRect(ctx, -5, -10, 18, 20, 5);
653
- ctx.fill();
654
-
655
- ctx.fillStyle = '#f8fbff';
656
- ctx.fillRect(11, -9, 8, 5);
657
- ctx.fillRect(11, 4, 8, 5);
658
- ctx.fillStyle = '#111827';
659
- ctx.fillRect(-18, -18, 12, 7);
660
- ctx.fillRect(-18, 11, 12, 7);
661
- ctx.fillRect(9, -18, 12, 7);
662
- ctx.fillRect(9, 11, 12, 7);
663
-
664
- if (racer.isPlayer) {
665
- ctx.strokeStyle = '#8df8ff';
666
- ctx.lineWidth = 3;
667
- ctx.globalAlpha = 0.75;
668
- roundRect(ctx, -31, -20, 62, 40, 12);
669
- ctx.stroke();
670
- }
671
-
672
- ctx.restore();
673
-
674
- ctx.fillStyle = 'rgba(2,6,23,0.72)';
675
- ctx.font = '700 20px Inter, sans-serif';
676
- ctx.textAlign = 'center';
677
- ctx.fillText(racer.name, racer.position.x, racer.position.y - 32);
678
- }
679
-
680
- function drawParticle(ctx: CanvasRenderingContext2D, particle: Particle) {
681
- ctx.save();
682
- ctx.globalAlpha = clamp(particle.life / particle.maxLife, 0, 1);
683
- ctx.fillStyle = particle.color;
684
- ctx.beginPath();
685
- ctx.arc(particle.position.x, particle.position.y, particle.size, 0, Math.PI * 2);
686
- ctx.fill();
687
- ctx.restore();
688
- }
689
-
690
- function drawVignette(ctx: CanvasRenderingContext2D, width: number, height: number) {
691
- const gradient = ctx.createRadialGradient(width / 2, height / 2, height * 0.2, width / 2, height / 2, width * 0.72);
692
- gradient.addColorStop(0, 'rgba(0,0,0,0)');
693
- gradient.addColorStop(1, 'rgba(0,0,0,0.45)');
694
- ctx.fillStyle = gradient;
695
- ctx.fillRect(0, 0, width, height);
696
- }
697
-
698
- function renderMinimap() {
699
- const width = miniMap.width / devicePixelRatio;
700
- const height = miniMap.height / devicePixelRatio;
701
- miniContext.save();
702
- miniContext.scale(devicePixelRatio, devicePixelRatio);
703
- miniContext.clearRect(0, 0, width, height);
704
- miniContext.fillStyle = 'rgba(7, 17, 30, 0.82)';
705
- roundRect(miniContext, 0, 0, width, height, 18);
706
- miniContext.fill();
707
- miniContext.translate(13, 10);
708
- const scale = Math.min((width - 26) / WORLD.width, (height - 22) / WORLD.height);
709
- miniContext.scale(scale, scale);
710
- miniContext.lineWidth = 46;
711
- miniContext.strokeStyle = '#344154';
712
- miniContext.lineCap = 'round';
713
- miniContext.lineJoin = 'round';
714
- miniContext.beginPath();
715
- WAYPOINTS.forEach((point, index) => {
716
- if (index === 0) miniContext.moveTo(point.x, point.y);
717
- else miniContext.lineTo(point.x, point.y);
718
- });
719
- miniContext.closePath();
720
- miniContext.stroke();
721
- for (const racer of racers) {
722
- miniContext.fillStyle = racer.isPlayer ? '#8df8ff' : racer.color;
723
- miniContext.beginPath();
724
- miniContext.arc(racer.position.x, racer.position.y, racer.isPlayer ? 34 : 24, 0, Math.PI * 2);
725
- miniContext.fill();
726
- }
727
- miniContext.restore();
728
- }
729
-
730
- function updateHud() {
731
- const player = racers[0];
732
- if (!player) return;
733
- const place = rankedRacers().findIndex((racer) => racer.isPlayer) + 1;
734
- speedText.textContent = String(Math.max(0, Math.round(Math.abs(player.speed) * 0.42)));
735
- lapText.textContent = `${Math.min(TOTAL_LAPS, player.lap)}/${TOTAL_LAPS}`;
736
- placeText.textContent = `P${place}`;
737
- boostFill.style.width = `${player.boost}%`;
738
- }
739
-
740
- function rankedRacers() {
741
- return [...racers].sort((a, b) => {
742
- if (a.finished && b.finished) return (a.finishTime ?? 0) - (b.finishTime ?? 0);
743
- if (a.finished) return -1;
744
- if (b.finished) return 1;
745
- return b.totalProgress - a.totalProgress;
746
- });
747
- }
748
-
749
- function pointAtDistance(distanceAlong: number, lane = 0): TrackSample {
750
- const wrapped = wrapDistance(distanceAlong);
751
- const segment = segments.find((item) => wrapped >= item.start && wrapped <= item.start + item.length) ?? segments[segments.length - 1];
752
- const amount = clamp((wrapped - segment.start) / segment.length, 0, 1);
753
- const x = lerp(segment.a.x, segment.b.x, amount);
754
- const y = lerp(segment.a.y, segment.b.y, amount);
755
- const tangent = normalize({ x: segment.b.x - segment.a.x, y: segment.b.y - segment.a.y });
756
- const normal = { x: -tangent.y, y: tangent.x };
757
- return {
758
- position: { x: x + normal.x * lane, y: y + normal.y * lane },
759
- tangent,
760
- normal,
761
- angle: Math.atan2(tangent.y, tangent.x)
762
- };
763
- }
764
-
765
- function nearestTrackPoint(position: Vec): NearestTrackPoint {
766
- let best: NearestTrackPoint | null = null;
767
- for (const segment of segments) {
768
- const ab = { x: segment.b.x - segment.a.x, y: segment.b.y - segment.a.y };
769
- const ap = { x: position.x - segment.a.x, y: position.y - segment.a.y };
770
- const amount = clamp(dot(ap, ab) / dot(ab, ab), 0, 1);
771
- const projection = { x: segment.a.x + ab.x * amount, y: segment.a.y + ab.y * amount };
772
- const tangent = normalize(ab);
773
- const normal = { x: -tangent.y, y: tangent.x };
774
- const offset = { x: position.x - projection.x, y: position.y - projection.y };
775
- const signedOffset = dot(offset, normal);
776
- const candidate: NearestTrackPoint = {
777
- position: projection,
778
- tangent,
779
- normal,
780
- angle: Math.atan2(tangent.y, tangent.x),
781
- distance: Math.hypot(offset.x, offset.y),
782
- along: segment.start + segment.length * amount,
783
- signedOffset
784
- };
785
- if (!best || candidate.distance < best.distance) best = candidate;
786
- }
787
- if (!best) throw new Error('Track has no segments');
788
- return best;
789
- }
790
-
791
- function emitExhaust(racer: Racer, color: string, count: number) {
792
- for (let index = 0; index < count; index += 1) {
793
- const rear = rotate({ x: -28, y: (Math.random() - 0.5) * 17 }, racer.angle);
794
- particles.push({
795
- position: { x: racer.position.x + rear.x, y: racer.position.y + rear.y },
796
- velocity: rotate({ x: -80 - Math.random() * 120, y: (Math.random() - 0.5) * 90 }, racer.angle),
797
- color,
798
- life: 0.28 + Math.random() * 0.28,
799
- maxLife: 0.56,
800
- size: 3 + Math.random() * 5
801
- });
802
- }
803
- }
804
-
805
- function resize() {
806
- const rect = canvas.getBoundingClientRect();
807
- canvas.width = Math.floor(rect.width * devicePixelRatio);
808
- canvas.height = Math.floor(rect.height * devicePixelRatio);
809
- miniMap.width = Math.floor(miniMap.clientWidth * devicePixelRatio);
810
- miniMap.height = Math.floor(miniMap.clientHeight * devicePixelRatio);
811
- }
812
-
813
- function loop(now: number) {
814
- const delta = Math.min(0.033, (now - lastFrame) / 1000);
815
- lastFrame = now;
816
- update(delta, now);
817
- render();
818
- requestAnimationFrame(loop);
819
- }
820
-
821
- function pressed(...candidates: string[]) {
822
- return candidates.some((candidate) => keys.has(candidate));
823
- }
824
-
825
- function distance(a: Vec, b: Vec) {
826
- return Math.hypot(a.x - b.x, a.y - b.y);
827
- }
828
-
829
- function dot(a: Vec, b: Vec) {
830
- return a.x * b.x + a.y * b.y;
831
- }
832
-
833
- function normalize(vector: Vec): Vec {
834
- const length = Math.hypot(vector.x, vector.y) || 1;
835
- return { x: vector.x / length, y: vector.y / length };
836
- }
837
-
838
- function rotate(vector: Vec, angle: number): Vec {
839
- const cosine = Math.cos(angle);
840
- const sine = Math.sin(angle);
841
- return { x: vector.x * cosine - vector.y * sine, y: vector.x * sine + vector.y * cosine };
842
- }
843
-
844
- function normalizeAngle(angle: number) {
845
- return Math.atan2(Math.sin(angle), Math.cos(angle));
846
- }
847
-
848
- function wrapDistance(value: number) {
849
- return ((value % trackLength) + trackLength) % trackLength;
850
- }
851
-
852
- function clamp(value: number, min: number, max: number) {
853
- return Math.min(max, Math.max(min, value));
854
- }
855
-
856
- function lerp(start: number, end: number, amount: number) {
857
- return start + (end - start) * amount;
858
- }
859
-
860
- function ordinal(value: number) {
861
- const suffix = value === 1 ? 'st' : value === 2 ? 'nd' : value === 3 ? 'rd' : 'th';
862
- return `${value}${suffix}`;
863
- }
864
-
865
- function formatRaceTime(finishTime: number) {
866
- if (!raceStartedAt) return '--:--';
867
- const elapsed = Math.max(0, finishTime - raceStartedAt);
868
- const seconds = Math.floor(elapsed / 1000);
869
- const millis = Math.floor((elapsed % 1000) / 10).toString().padStart(2, '0');
870
- return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}.${millis}`;
871
- }
872
-
873
- function trackFinishBuffer() {
874
- return 12;
875
- }
876
-
877
- function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
878
- const limitedRadius = Math.min(radius, width / 2, height / 2);
879
- ctx.beginPath();
880
- ctx.moveTo(x + limitedRadius, y);
881
- ctx.arcTo(x + width, y, x + width, y + height, limitedRadius);
882
- ctx.arcTo(x + width, y + height, x, y + height, limitedRadius);
883
- ctx.arcTo(x, y + height, x, y, limitedRadius);
884
- ctx.arcTo(x, y, x + width, y, limitedRadius);
885
- ctx.closePath();
886
- }
887
-
888
- function requireElement<T extends HTMLElement>(selector: string): T {
889
- const element = document.querySelector<T>(selector);
890
- if (!element) throw new Error(`Missing required element: ${selector}`);
891
- return element;
892
- }
893
-
894
- function requireCanvasContext(targetCanvas: HTMLCanvasElement): CanvasRenderingContext2D {
895
- const canvasContext = targetCanvas.getContext('2d');
896
- if (!canvasContext) throw new Error('Canvas 2D context is unavailable');
897
- return canvasContext;
898
- }
899
-
900
- window.addEventListener('keydown', (event) => {
901
- if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].includes(event.key)) event.preventDefault();
902
- keys.add(event.key.toLowerCase());
903
- });
904
-
905
- window.addEventListener('keyup', (event) => {
906
- keys.delete(event.key.toLowerCase());
907
- });
908
-
909
- window.addEventListener('resize', resize);
910
- startButton.addEventListener('click', startCountdown);
911
- racers = createRacers();
912
- resize();
913
- requestAnimationFrame(loop);