groove-dev 0.27.112 → 0.27.113

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 (50) hide show
  1. package/TRAINING_DATA_v3.md +11 -0
  2. package/codex-test/offroad-nitro-racer/dist/assets/index-CuvdKK6U.js +44 -0
  3. package/codex-test/offroad-nitro-racer/dist/assets/index-DvHn2Thu.css +1 -0
  4. package/codex-test/offroad-nitro-racer/dist/index.html +23 -0
  5. package/codex-test/offroad-nitro-racer/index.html +21 -0
  6. package/codex-test/offroad-nitro-racer/package-lock.json +841 -0
  7. package/codex-test/offroad-nitro-racer/package.json +15 -0
  8. package/codex-test/offroad-nitro-racer/src/game/AI.ts +28 -0
  9. package/codex-test/offroad-nitro-racer/src/game/Audio.ts +63 -0
  10. package/codex-test/offroad-nitro-racer/src/game/Car.ts +247 -0
  11. package/codex-test/offroad-nitro-racer/src/game/Effects.ts +62 -0
  12. package/codex-test/offroad-nitro-racer/src/game/Game.ts +229 -0
  13. package/codex-test/offroad-nitro-racer/src/game/Input.ts +45 -0
  14. package/codex-test/offroad-nitro-racer/src/game/Renderer.ts +224 -0
  15. package/codex-test/offroad-nitro-racer/src/game/Track.ts +158 -0
  16. package/codex-test/offroad-nitro-racer/src/game/UI.ts +96 -0
  17. package/codex-test/offroad-nitro-racer/src/game/math.ts +42 -0
  18. package/codex-test/offroad-nitro-racer/src/main.ts +24 -0
  19. package/codex-test/offroad-nitro-racer/src/style.css +291 -0
  20. package/codex-test/offroad-nitro-racer/src/vite-env.d.ts +1 -0
  21. package/codex-test/offroad-nitro-racer/tsconfig.json +18 -0
  22. package/codex-test/offroad-nitro-racer/vite.config.ts +7 -0
  23. package/moe-training/client/parsers/codex.js +3 -3
  24. package/moe-training/client/parsers/gemini.js +2 -2
  25. package/moe-training/client/step-classifier.js +2 -2
  26. package/moe-training/test/client/step-classifier.test.js +63 -7
  27. package/node_modules/@groove-dev/cli/package.json +1 -1
  28. package/node_modules/@groove-dev/daemon/package.json +1 -1
  29. package/node_modules/@groove-dev/daemon/src/api.js +51 -15
  30. package/node_modules/@groove-dev/daemon/src/index.js +22 -8
  31. package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
  32. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  33. package/node_modules/@groove-dev/gui/package.json +1 -1
  34. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
  35. package/node_modules/@groove-dev/gui/src/stores/groove.js +15 -0
  36. package/node_modules/moe-training/client/parsers/codex.js +3 -3
  37. package/node_modules/moe-training/client/parsers/gemini.js +2 -2
  38. package/node_modules/moe-training/client/step-classifier.js +2 -2
  39. package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
  40. package/package.json +1 -1
  41. package/packages/cli/package.json +1 -1
  42. package/packages/daemon/package.json +1 -1
  43. package/packages/daemon/src/api.js +51 -15
  44. package/packages/daemon/src/index.js +22 -8
  45. package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
  46. package/packages/gui/dist/index.html +1 -1
  47. package/packages/gui/package.json +1 -1
  48. package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
  49. package/packages/gui/src/stores/groove.js +15 -0
  50. package/TRAINING_DATA_v2.md +0 -9
@@ -0,0 +1,224 @@
1
+ import { Car } from './Car';
2
+ import { Effects } from './Effects';
3
+ import { Track } from './Track';
4
+ import { roundedRect } from './math';
5
+
6
+ export type Camera = { x: number; y: number; zoom: number; shakeX: number; shakeY: number };
7
+
8
+ export class Renderer {
9
+ private canvas: HTMLCanvasElement;
10
+ private ctx: CanvasRenderingContext2D;
11
+ private pixelRatio = 1;
12
+
13
+ constructor(canvas: HTMLCanvasElement) {
14
+ this.canvas = canvas;
15
+ const ctx = canvas.getContext('2d');
16
+ if (!ctx) throw new Error('Canvas 2D context unavailable');
17
+ this.ctx = ctx;
18
+ this.resize();
19
+ window.addEventListener('resize', () => this.resize());
20
+ }
21
+
22
+ get width(): number {
23
+ return this.canvas.clientWidth;
24
+ }
25
+
26
+ get height(): number {
27
+ return this.canvas.clientHeight;
28
+ }
29
+
30
+ resize(): void {
31
+ this.pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
32
+ const width = window.innerWidth;
33
+ const height = window.innerHeight;
34
+ this.canvas.width = Math.floor(width * this.pixelRatio);
35
+ this.canvas.height = Math.floor(height * this.pixelRatio);
36
+ this.canvas.style.width = `${width}px`;
37
+ this.canvas.style.height = `${height}px`;
38
+ this.ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
39
+ }
40
+
41
+ render(track: Track, cars: Car[], player: Car, effects: Effects, camera: Camera): void {
42
+ const ctx = this.ctx;
43
+ ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0);
44
+ ctx.clearRect(0, 0, this.width, this.height);
45
+
46
+ const gradient = ctx.createLinearGradient(0, 0, this.width, this.height);
47
+ gradient.addColorStop(0, '#70451f');
48
+ gradient.addColorStop(0.45, '#b67635');
49
+ gradient.addColorStop(1, '#6c3b1f');
50
+ ctx.fillStyle = gradient;
51
+ ctx.fillRect(0, 0, this.width, this.height);
52
+
53
+ ctx.save();
54
+ ctx.translate(this.width / 2 + camera.shakeX, this.height / 2 + camera.shakeY);
55
+ ctx.scale(camera.zoom, camera.zoom * 0.86);
56
+ ctx.translate(-camera.x, -camera.y);
57
+
58
+ this.drawGroundNoise(ctx, camera);
59
+ this.drawTrack(ctx, track);
60
+ this.drawPickups(ctx, track);
61
+ this.drawObstacles(ctx, track);
62
+ effects.render(ctx);
63
+
64
+ const sortedCars = [...cars].sort((a, b) => a.y + a.airborne - (b.y + b.airborne));
65
+ for (const car of sortedCars) this.drawCar(ctx, car, car === player);
66
+
67
+ ctx.restore();
68
+ }
69
+
70
+ private drawGroundNoise(ctx: CanvasRenderingContext2D, camera: Camera): void {
71
+ ctx.save();
72
+ ctx.globalAlpha = 0.22;
73
+ for (let i = 0; i < 130; i++) {
74
+ const x = Math.floor((camera.x - 900) / 90) * 90 + ((i * 277) % 1900);
75
+ const y = Math.floor((camera.y - 650) / 70) * 70 + ((i * 163) % 1400);
76
+ ctx.fillStyle = i % 6 === 0 ? '#5c341f' : '#d29554';
77
+ ctx.beginPath();
78
+ ctx.ellipse(x, y, 18 + (i % 4) * 8, 5, (i % 9) * 0.5, 0, Math.PI * 2);
79
+ ctx.fill();
80
+ }
81
+ ctx.restore();
82
+ }
83
+
84
+ private drawTrack(ctx: CanvasRenderingContext2D, track: Track): void {
85
+ ctx.lineJoin = 'round';
86
+ ctx.lineCap = 'round';
87
+
88
+ ctx.strokeStyle = '#5b3d2c';
89
+ ctx.lineWidth = track.roughWidth;
90
+ this.strokeLoop(ctx, track.points);
91
+
92
+ ctx.strokeStyle = '#9a6131';
93
+ ctx.lineWidth = track.width;
94
+ this.strokeLoop(ctx, track.points);
95
+
96
+ ctx.setLineDash([34, 34]);
97
+ ctx.strokeStyle = 'rgba(255, 220, 137, 0.34)';
98
+ ctx.lineWidth = 5;
99
+ this.strokeLoop(ctx, track.points);
100
+ ctx.setLineDash([]);
101
+
102
+ for (let i = 0; i < track.checkpoints.length; i++) {
103
+ const p = track.checkpoints[i];
104
+ ctx.save();
105
+ ctx.translate(p.x, p.y);
106
+ ctx.fillStyle = i === 0 ? 'rgba(80,255,190,0.35)' : 'rgba(255,255,255,0.14)';
107
+ ctx.fillRect(-95, -5, 190, 10);
108
+ ctx.restore();
109
+ }
110
+ }
111
+
112
+ private drawPickups(ctx: CanvasRenderingContext2D, track: Track): void {
113
+ for (const pickup of track.pickups) {
114
+ if (!pickup.active) continue;
115
+ ctx.save();
116
+ ctx.translate(pickup.x, pickup.y);
117
+ ctx.rotate(performance.now() * 0.004);
118
+ ctx.fillStyle = '#32f5ff';
119
+ ctx.shadowColor = '#32f5ff';
120
+ ctx.shadowBlur = 18;
121
+ ctx.beginPath();
122
+ ctx.moveTo(0, -26);
123
+ ctx.lineTo(20, 0);
124
+ ctx.lineTo(0, 26);
125
+ ctx.lineTo(-20, 0);
126
+ ctx.closePath();
127
+ ctx.fill();
128
+ ctx.shadowBlur = 0;
129
+ ctx.fillStyle = '#06343b';
130
+ ctx.font = 'bold 20px sans-serif';
131
+ ctx.textAlign = 'center';
132
+ ctx.textBaseline = 'middle';
133
+ ctx.fillText('N', 0, 1);
134
+ ctx.restore();
135
+ }
136
+ }
137
+
138
+ private drawObstacles(ctx: CanvasRenderingContext2D, track: Track): void {
139
+ for (const obstacle of track.obstacles) {
140
+ ctx.save();
141
+ ctx.translate(obstacle.x, obstacle.y);
142
+ if (obstacle.kind === 'bump') {
143
+ ctx.fillStyle = '#c88a4c';
144
+ ctx.strokeStyle = '#5f3925';
145
+ ctx.lineWidth = 5;
146
+ ctx.beginPath();
147
+ ctx.ellipse(0, 0, obstacle.radius, obstacle.radius * 0.36, 0, 0, Math.PI * 2);
148
+ ctx.fill();
149
+ ctx.stroke();
150
+ } else if (obstacle.kind === 'cactus') {
151
+ ctx.fillStyle = '#216941';
152
+ ctx.fillRect(-8, -34, 16, 68);
153
+ ctx.fillRect(-25, -12, 50, 13);
154
+ } else if (obstacle.kind === 'barrel') {
155
+ ctx.fillStyle = '#c14c32';
156
+ ctx.strokeStyle = '#621f1c';
157
+ ctx.lineWidth = 5;
158
+ ctx.beginPath();
159
+ ctx.arc(0, 0, obstacle.radius, 0, Math.PI * 2);
160
+ ctx.fill();
161
+ ctx.stroke();
162
+ } else {
163
+ ctx.fillStyle = '#4b4038';
164
+ ctx.beginPath();
165
+ ctx.ellipse(0, 0, obstacle.radius, obstacle.radius * 0.8, 0.4, 0, Math.PI * 2);
166
+ ctx.fill();
167
+ }
168
+ ctx.restore();
169
+ }
170
+ }
171
+
172
+ private drawCar(ctx: CanvasRenderingContext2D, car: Car, isPlayer: boolean): void {
173
+ ctx.save();
174
+ ctx.translate(car.x, car.y);
175
+ ctx.globalAlpha = 0.35;
176
+ ctx.fillStyle = '#000';
177
+ ctx.beginPath();
178
+ ctx.ellipse(0, 18 + car.airborne * 0.18, car.width * 0.76, car.length * 0.42, car.angle, 0, Math.PI * 2);
179
+ ctx.fill();
180
+ ctx.globalAlpha = 1;
181
+ ctx.translate(0, -car.airborne * 0.2);
182
+ ctx.rotate(car.angle);
183
+
184
+ ctx.fillStyle = '#171717';
185
+ roundedRect(ctx, -car.length * 0.46, -car.width * 0.72, car.length * 0.92, car.width * 1.44, 8);
186
+ ctx.fill();
187
+
188
+ ctx.fillStyle = car.color;
189
+ roundedRect(ctx, -car.length * 0.5, -car.width * 0.5, car.length, car.width, 10);
190
+ ctx.fill();
191
+
192
+ ctx.fillStyle = car.accent;
193
+ roundedRect(ctx, -2, -car.width * 0.42, car.length * 0.26, car.width * 0.84, 6);
194
+ ctx.fill();
195
+
196
+ ctx.fillStyle = '#dbeafe';
197
+ roundedRect(ctx, -car.length * 0.2, -car.width * 0.32, car.length * 0.22, car.width * 0.64, 5);
198
+ ctx.fill();
199
+
200
+ ctx.fillStyle = '#24211f';
201
+ for (const y of [-car.width * 0.62, car.width * 0.62]) {
202
+ roundedRect(ctx, -car.length * 0.38, y - 5, 22, 10, 3);
203
+ ctx.fill();
204
+ roundedRect(ctx, car.length * 0.14, y - 5, 22, 10, 3);
205
+ ctx.fill();
206
+ }
207
+
208
+ if (isPlayer) {
209
+ ctx.strokeStyle = '#ffffff';
210
+ ctx.lineWidth = 4;
211
+ ctx.strokeRect(-car.length * 0.58, -car.width * 0.58, car.length * 1.16, car.width * 1.16);
212
+ }
213
+
214
+ ctx.restore();
215
+ }
216
+
217
+ private strokeLoop(ctx: CanvasRenderingContext2D, points: { x: number; y: number }[]): void {
218
+ ctx.beginPath();
219
+ ctx.moveTo(points[0].x, points[0].y);
220
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
221
+ ctx.closePath();
222
+ ctx.stroke();
223
+ }
224
+ }
@@ -0,0 +1,158 @@
1
+ import { TAU, Vec2, clamp, distance } from './math';
2
+
3
+ export type Terrain = 'track' | 'sand' | 'rough' | 'mud';
4
+
5
+ export type TrackSample = {
6
+ terrain: Terrain;
7
+ grip: number;
8
+ speedMultiplier: number;
9
+ nearest: Vec2;
10
+ distanceFromCenter: number;
11
+ progress: number;
12
+ };
13
+
14
+ export type Obstacle = Vec2 & { radius: number; kind: 'rock' | 'cactus' | 'barrel' | 'bump' };
15
+ export type Pickup = Vec2 & { radius: number; active: boolean; respawn: number };
16
+
17
+ const CHECKPOINTS = 12;
18
+
19
+ export class Track {
20
+ readonly width = 230;
21
+ readonly roughWidth = 390;
22
+ readonly lapsToWin = 3;
23
+ readonly center: Vec2 = { x: 0, y: 0 };
24
+ readonly points: Vec2[] = [];
25
+ readonly checkpoints: Vec2[] = [];
26
+ readonly obstacles: Obstacle[] = [];
27
+ readonly pickups: Pickup[] = [];
28
+
29
+ constructor() {
30
+ for (let i = 0; i < 220; i++) {
31
+ const t = i / 220;
32
+ const a = t * TAU;
33
+ const wobble = Math.sin(a * 3) * 55 + Math.cos(a * 5) * 24;
34
+ this.points.push({
35
+ x: Math.cos(a) * (890 + wobble) + Math.cos(a * 2.2) * 75,
36
+ y: Math.sin(a) * (550 + Math.sin(a * 4) * 50) + Math.sin(a * 1.5) * 60,
37
+ });
38
+ }
39
+
40
+ for (let i = 0; i < CHECKPOINTS; i++) {
41
+ this.checkpoints.push(this.pointAt(i / CHECKPOINTS));
42
+ }
43
+
44
+ this.obstacles.push(
45
+ { ...this.offsetPoint(0.11, -170), radius: 32, kind: 'rock' },
46
+ { ...this.offsetPoint(0.19, 145), radius: 30, kind: 'cactus' },
47
+ { ...this.offsetPoint(0.27, -60), radius: 58, kind: 'bump' },
48
+ { ...this.offsetPoint(0.36, 150), radius: 34, kind: 'barrel' },
49
+ { ...this.offsetPoint(0.48, -160), radius: 36, kind: 'rock' },
50
+ { ...this.offsetPoint(0.57, 60), radius: 60, kind: 'bump' },
51
+ { ...this.offsetPoint(0.68, 165), radius: 34, kind: 'cactus' },
52
+ { ...this.offsetPoint(0.78, -145), radius: 36, kind: 'barrel' },
53
+ { ...this.offsetPoint(0.88, 75), radius: 54, kind: 'bump' },
54
+ );
55
+
56
+ for (const t of [0.14, 0.31, 0.45, 0.62, 0.82]) {
57
+ this.pickups.push({ ...this.offsetPoint(t, 0), radius: 28, active: true, respawn: 0 });
58
+ }
59
+ }
60
+
61
+ update(delta: number): void {
62
+ for (const pickup of this.pickups) {
63
+ if (!pickup.active) {
64
+ pickup.respawn -= delta;
65
+ if (pickup.respawn <= 0) pickup.active = true;
66
+ }
67
+ }
68
+ }
69
+
70
+ startPosition(index: number): Vec2 & { angle: number } {
71
+ const base = this.pointAt(0.985);
72
+ const next = this.pointAt(0.995);
73
+ const angle = Math.atan2(next.y - base.y, next.x - base.x);
74
+ const side = index % 2 === 0 ? -1 : 1;
75
+ return {
76
+ x: base.x - Math.sin(angle) * side * 45 - Math.cos(angle) * index * 42,
77
+ y: base.y + Math.cos(angle) * side * 45 - Math.sin(angle) * index * 42,
78
+ angle,
79
+ };
80
+ }
81
+
82
+ sample(x: number, y: number): TrackSample {
83
+ let nearest = this.points[0];
84
+ let nearestIndex = 0;
85
+ let best = Infinity;
86
+
87
+ for (let i = 0; i < this.points.length; i++) {
88
+ const d = Math.hypot(x - this.points[i].x, y - this.points[i].y);
89
+ if (d < best) {
90
+ best = d;
91
+ nearest = this.points[i];
92
+ nearestIndex = i;
93
+ }
94
+ }
95
+
96
+ const mud = Math.sin((x + 260) * 0.006) * Math.cos((y - 180) * 0.008) > 0.72;
97
+ const terrain: Terrain = best < this.width / 2 ? (mud && best > 46 ? 'mud' : 'track') : best < this.roughWidth / 2 ? 'sand' : 'rough';
98
+
99
+ const traits = {
100
+ track: { grip: 1, speedMultiplier: 1 },
101
+ mud: { grip: 0.62, speedMultiplier: 0.72 },
102
+ sand: { grip: 0.72, speedMultiplier: 0.82 },
103
+ rough: { grip: 0.46, speedMultiplier: 0.56 },
104
+ }[terrain];
105
+
106
+ return {
107
+ terrain,
108
+ grip: traits.grip,
109
+ speedMultiplier: traits.speedMultiplier,
110
+ nearest,
111
+ distanceFromCenter: best,
112
+ progress: nearestIndex / this.points.length,
113
+ };
114
+ }
115
+
116
+ nextCheckpoint(current: number): Vec2 {
117
+ return this.checkpoints[current % this.checkpoints.length];
118
+ }
119
+
120
+ consumePickup(pos: Vec2): boolean {
121
+ for (const pickup of this.pickups) {
122
+ if (pickup.active && distance(pos, pickup) < pickup.radius + 26) {
123
+ pickup.active = false;
124
+ pickup.respawn = 9;
125
+ return true;
126
+ }
127
+ }
128
+ return false;
129
+ }
130
+
131
+ pointAt(progress: number): Vec2 {
132
+ const wrapped = ((progress % 1) + 1) % 1;
133
+ const index = wrapped * this.points.length;
134
+ const a = Math.floor(index) % this.points.length;
135
+ const b = (a + 1) % this.points.length;
136
+ const t = index - Math.floor(index);
137
+ return {
138
+ x: this.points[a].x + (this.points[b].x - this.points[a].x) * t,
139
+ y: this.points[a].y + (this.points[b].y - this.points[a].y) * t,
140
+ };
141
+ }
142
+
143
+ private offsetPoint(progress: number, offset: number): Vec2 {
144
+ const p = this.pointAt(progress);
145
+ const n = this.pointAt(progress + 0.006);
146
+ const angle = Math.atan2(n.y - p.y, n.x - p.x);
147
+ return {
148
+ x: p.x - Math.sin(angle) * offset,
149
+ y: p.y + Math.cos(angle) * offset,
150
+ };
151
+ }
152
+ }
153
+
154
+ export function checkpointDistance(progress: number, checkpointIndex: number): number {
155
+ const target = checkpointIndex / CHECKPOINTS;
156
+ const ahead = target >= progress ? target - progress : target + 1 - progress;
157
+ return clamp(ahead, 0, 1);
158
+ }
@@ -0,0 +1,96 @@
1
+ import { Car } from './Car';
2
+ import { Track } from './Track';
3
+
4
+ export type UiState = 'menu' | 'countdown' | 'racing' | 'paused' | 'finished';
5
+
6
+ export type UiData = {
7
+ state: UiState;
8
+ countdown: number;
9
+ player: Car;
10
+ track: Track;
11
+ elapsed: number;
12
+ message: string;
13
+ winner: string;
14
+ };
15
+
16
+ export class UI {
17
+ private root: HTMLDivElement;
18
+
19
+ constructor(root: HTMLDivElement) {
20
+ this.root = root;
21
+ }
22
+
23
+ render(data: UiData): void {
24
+ if (data.state === 'menu') {
25
+ this.root.className = 'overlay overlay--active';
26
+ this.root.innerHTML = `
27
+ <section class="panel hero">
28
+ <p class="eyebrow">Desert cup</p>
29
+ <h1>Offroad Nitro Racer</h1>
30
+ <p class="lede">Chunky arcade dirt racing with drifting, boosts, bumps, and ruthless AI trucks.</p>
31
+ <div class="controls-grid">
32
+ <span>Drive</span><strong>Arrows / WASD</strong>
33
+ <span>Nitro</span><strong>Space</strong>
34
+ <span>Pause</span><strong>Esc</strong>
35
+ <span>Restart</span><strong>R</strong>
36
+ </div>
37
+ <button class="primary">Press Enter to Race</button>
38
+ </section>
39
+ `;
40
+ return;
41
+ }
42
+
43
+ if (data.state === 'paused') {
44
+ this.root.className = 'overlay overlay--active';
45
+ this.root.innerHTML = `
46
+ <section class="panel compact">
47
+ <p class="eyebrow">Pit stop</p>
48
+ <h2>Paused</h2>
49
+ <p>Press Esc to resume or R to restart.</p>
50
+ </section>
51
+ `;
52
+ return;
53
+ }
54
+
55
+ if (data.state === 'finished') {
56
+ const won = data.winner === data.player.name;
57
+ this.root.className = 'overlay overlay--active';
58
+ this.root.innerHTML = `
59
+ <section class="panel hero">
60
+ <p class="eyebrow">Race complete</p>
61
+ <h1>${won ? 'You Win!' : 'You Lose'}</h1>
62
+ <p class="lede">${won ? 'Nitro glory across the desert.' : `${data.winner} reached the flag first.`}</p>
63
+ <div class="result-row"><span>Final position</span><strong>${ordinal(data.player.rank)}</strong></div>
64
+ <div class="result-row"><span>Time</span><strong>${formatTime(data.elapsed)}</strong></div>
65
+ <button class="primary">Press R to Restart</button>
66
+ </section>
67
+ `;
68
+ return;
69
+ }
70
+
71
+ this.root.className = 'overlay';
72
+ this.root.innerHTML = `
73
+ <div class="hud hud--top">
74
+ <div class="hud-card"><span>Lap</span><strong>${Math.min(data.player.lap, data.track.lapsToWin)} / ${data.track.lapsToWin}</strong></div>
75
+ <div class="hud-card"><span>Position</span><strong>${ordinal(data.player.rank)}</strong></div>
76
+ <div class="hud-card"><span>Speed</span><strong>${Math.round(data.player.speed * 0.42)} mph</strong></div>
77
+ <div class="hud-card"><span>Timer</span><strong>${formatTime(data.elapsed)}</strong></div>
78
+ </div>
79
+ <div class="nitro-stack">${Array.from({ length: data.player.maxNitro }, (_, i) => `<i class="${i < data.player.nitroCharges ? 'full' : ''}"></i>`).join('')}</div>
80
+ ${data.state === 'countdown' ? `<div class="countdown">${data.countdown > 0.25 ? Math.ceil(data.countdown) : 'GO!'}</div>` : ''}
81
+ ${data.message ? `<div class="toast">${data.message}</div>` : ''}
82
+ `;
83
+ }
84
+ }
85
+
86
+ export function formatTime(seconds: number): string {
87
+ const minutes = Math.floor(seconds / 60);
88
+ const secs = Math.floor(seconds % 60).toString().padStart(2, '0');
89
+ const tenths = Math.floor((seconds % 1) * 10);
90
+ return `${minutes}:${secs}.${tenths}`;
91
+ }
92
+
93
+ function ordinal(value: number): string {
94
+ const suffix = value === 1 ? 'st' : value === 2 ? 'nd' : value === 3 ? 'rd' : 'th';
95
+ return `${value}${suffix}`;
96
+ }
@@ -0,0 +1,42 @@
1
+ export type Vec2 = { x: number; y: number };
2
+
3
+ export const TAU = Math.PI * 2;
4
+
5
+ export function clamp(value: number, min: number, max: number): number {
6
+ return Math.max(min, Math.min(max, value));
7
+ }
8
+
9
+ export function lerp(a: number, b: number, t: number): number {
10
+ return a + (b - a) * t;
11
+ }
12
+
13
+ export function length(x: number, y: number): number {
14
+ return Math.hypot(x, y);
15
+ }
16
+
17
+ export function distance(a: Vec2, b: Vec2): number {
18
+ return Math.hypot(a.x - b.x, a.y - b.y);
19
+ }
20
+
21
+ export function normalizeAngle(angle: number): number {
22
+ while (angle > Math.PI) angle -= TAU;
23
+ while (angle < -Math.PI) angle += TAU;
24
+ return angle;
25
+ }
26
+
27
+ export function angleTo(from: Vec2, to: Vec2): number {
28
+ return Math.atan2(to.y - from.y, to.x - from.x);
29
+ }
30
+
31
+ export function dot(ax: number, ay: number, bx: number, by: number): number {
32
+ return ax * bx + ay * by;
33
+ }
34
+
35
+ export function rand(min: number, max: number): number {
36
+ return min + Math.random() * (max - min);
37
+ }
38
+
39
+ export function roundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
40
+ ctx.beginPath();
41
+ ctx.roundRect(x, y, w, h, r);
42
+ }
@@ -0,0 +1,24 @@
1
+ import './style.css';
2
+ import { Game } from './game/Game';
3
+
4
+ const app = document.querySelector<HTMLDivElement>('#app');
5
+
6
+ if (!app) {
7
+ throw new Error('Missing #app root');
8
+ }
9
+
10
+ app.innerHTML = `
11
+ <div class="shell">
12
+ <canvas id="game" aria-label="Offroad Nitro Racer game canvas"></canvas>
13
+ <div id="overlay" class="overlay"></div>
14
+ </div>
15
+ `;
16
+
17
+ const canvas = document.querySelector<HTMLCanvasElement>('#game');
18
+ const overlay = document.querySelector<HTMLDivElement>('#overlay');
19
+
20
+ if (!canvas || !overlay) {
21
+ throw new Error('Missing game canvas or overlay');
22
+ }
23
+
24
+ new Game(canvas, overlay).start();