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,15 @@
1
+ {
2
+ "name": "offroad-nitro-racer",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc && vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "typescript": "latest",
13
+ "vite": "latest"
14
+ }
15
+ }
@@ -0,0 +1,28 @@
1
+ import { Car, DriverInput } from './Car';
2
+ import { Track } from './Track';
3
+ import { angleTo, clamp, normalizeAngle } from './math';
4
+
5
+ export class AIController {
6
+ private aggression: number;
7
+ private lookahead: number;
8
+
9
+ constructor(index: number) {
10
+ this.aggression = 0.84 + index * 0.06;
11
+ this.lookahead = 1 + (index % 2);
12
+ }
13
+
14
+ update(car: Car, track: Track, playerRank: number, difficulty: number): DriverInput {
15
+ const targetIndex = (car.nextCheckpoint + this.lookahead) % track.checkpoints.length;
16
+ const target = track.nextCheckpoint(targetIndex);
17
+ const desired = angleTo(car, target);
18
+ const delta = normalizeAngle(desired - car.angle);
19
+ const steer = clamp(delta * 2.2, -1, 1);
20
+ const caution = Math.abs(delta) > 0.8 ? 0.45 : Math.abs(delta) > 0.45 ? 0.7 : 1;
21
+ const catchup = playerRank < car.rank ? 1.08 : 0.95;
22
+ const throttle = clamp(this.aggression * caution * catchup + difficulty * 0.04, 0.35, 1);
23
+ const brake = Math.abs(delta) > 1.15 && car.speed > 115 ? 0.65 : 0;
24
+ const nitro = car.nitroCharges > 0 && Math.abs(delta) < 0.22 && car.speed > 135 && Math.random() < 0.015 + difficulty * 0.004;
25
+
26
+ return { throttle, brake, steer, nitro };
27
+ }
28
+ }
@@ -0,0 +1,63 @@
1
+ import { clamp } from './math';
2
+
3
+ export class AudioSystem {
4
+ private ctx: AudioContext | null = null;
5
+ private master: GainNode | null = null;
6
+ private engineOsc: OscillatorNode | null = null;
7
+ private engineGain: GainNode | null = null;
8
+
9
+ ensure(): void {
10
+ if (this.ctx) return;
11
+
12
+ const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
13
+ this.ctx = new Ctx();
14
+ this.master = this.ctx.createGain();
15
+ this.master.gain.value = 0.18;
16
+ this.master.connect(this.ctx.destination);
17
+
18
+ this.engineOsc = this.ctx.createOscillator();
19
+ this.engineGain = this.ctx.createGain();
20
+ this.engineOsc.type = 'sawtooth';
21
+ this.engineOsc.frequency.value = 70;
22
+ this.engineGain.gain.value = 0;
23
+ this.engineOsc.connect(this.engineGain);
24
+ this.engineGain.connect(this.master);
25
+ this.engineOsc.start();
26
+ }
27
+
28
+ updateEngine(speed: number, throttle: number, boosting: boolean): void {
29
+ if (!this.ctx || !this.engineOsc || !this.engineGain) return;
30
+ const now = this.ctx.currentTime;
31
+ this.engineOsc.frequency.setTargetAtTime(65 + clamp(speed, 0, 240) * 1.2 + (boosting ? 70 : 0), now, 0.04);
32
+ this.engineGain.gain.setTargetAtTime(0.025 + Math.abs(throttle) * 0.05 + (boosting ? 0.035 : 0), now, 0.05);
33
+ }
34
+
35
+ beep(kind: 'count' | 'go' | 'pickup' | 'boost' | 'hit' | 'finish'): void {
36
+ this.ensure();
37
+ if (!this.ctx || !this.master) return;
38
+
39
+ const now = this.ctx.currentTime;
40
+ const osc = this.ctx.createOscillator();
41
+ const gain = this.ctx.createGain();
42
+ const settings = {
43
+ count: [440, 0.12, 'square'],
44
+ go: [780, 0.35, 'triangle'],
45
+ pickup: [920, 0.18, 'sine'],
46
+ boost: [120, 0.42, 'sawtooth'],
47
+ hit: [80, 0.18, 'square'],
48
+ finish: [660, 0.7, 'triangle'],
49
+ } as const;
50
+ const [frequency, duration, type] = settings[kind];
51
+
52
+ osc.type = type;
53
+ osc.frequency.setValueAtTime(frequency, now);
54
+ if (kind === 'boost') osc.frequency.exponentialRampToValueAtTime(240, now + duration);
55
+ if (kind === 'hit') osc.frequency.exponentialRampToValueAtTime(45, now + duration);
56
+ gain.gain.setValueAtTime(kind === 'hit' ? 0.13 : 0.18, now);
57
+ gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
58
+ osc.connect(gain);
59
+ gain.connect(this.master);
60
+ osc.start(now);
61
+ osc.stop(now + duration + 0.02);
62
+ }
63
+ }
@@ -0,0 +1,247 @@
1
+ import { Effects } from './Effects';
2
+ import { Track } from './Track';
3
+ import { Vec2, clamp, dot, length, normalizeAngle } from './math';
4
+
5
+ export type DriverInput = {
6
+ throttle: number;
7
+ brake: number;
8
+ steer: number;
9
+ nitro: boolean;
10
+ };
11
+
12
+ export type CarConfig = {
13
+ name: string;
14
+ color: string;
15
+ accent: string;
16
+ isPlayer?: boolean;
17
+ };
18
+
19
+ export class Car {
20
+ name: string;
21
+ color: string;
22
+ accent: string;
23
+ isPlayer: boolean;
24
+ x: number;
25
+ y: number;
26
+ vx = 0;
27
+ vy = 0;
28
+ angle: number;
29
+ radius = 24;
30
+ width = 34;
31
+ length = 54;
32
+ nitroCharges = 2;
33
+ maxNitro = 3;
34
+ nitroTimer = 0;
35
+ nextCheckpoint = 0;
36
+ lap = 1;
37
+ finished = false;
38
+ finishTime = 0;
39
+ airborne = 0;
40
+ verticalVelocity = 0;
41
+ driftAmount = 0;
42
+ collectedPickup = false;
43
+ lastProgress = 0;
44
+ totalProgress = 0;
45
+ rank = 1;
46
+ wrongWayTimer = 0;
47
+
48
+ constructor(config: CarConfig, start: Vec2 & { angle: number }) {
49
+ this.name = config.name;
50
+ this.color = config.color;
51
+ this.accent = config.accent;
52
+ this.isPlayer = Boolean(config.isPlayer);
53
+ this.x = start.x;
54
+ this.y = start.y;
55
+ this.angle = start.angle;
56
+ }
57
+
58
+ get speed(): number {
59
+ return Math.hypot(this.vx, this.vy);
60
+ }
61
+
62
+ reset(start: Vec2 & { angle: number }): void {
63
+ this.x = start.x;
64
+ this.y = start.y;
65
+ this.vx = 0;
66
+ this.vy = 0;
67
+ this.angle = start.angle;
68
+ this.nitroCharges = this.isPlayer ? 2 : 1;
69
+ this.nitroTimer = 0;
70
+ this.nextCheckpoint = 0;
71
+ this.lap = 1;
72
+ this.finished = false;
73
+ this.finishTime = 0;
74
+ this.airborne = 0;
75
+ this.verticalVelocity = 0;
76
+ this.totalProgress = 0;
77
+ this.lastProgress = 0;
78
+ this.rank = 1;
79
+ }
80
+
81
+ update(delta: number, input: DriverInput, track: Track, effects: Effects, raceTime: number, difficulty = 1): void {
82
+ if (this.finished) {
83
+ this.vx *= 1 - delta * 2;
84
+ this.vy *= 1 - delta * 2;
85
+ return;
86
+ }
87
+
88
+ this.collectedPickup = false;
89
+
90
+ const terrain = track.sample(this.x, this.y);
91
+ const forwardX = Math.cos(this.angle);
92
+ const forwardY = Math.sin(this.angle);
93
+ const rightX = -forwardY;
94
+ const rightY = forwardX;
95
+ const forwardSpeed = dot(this.vx, this.vy, forwardX, forwardY);
96
+ const sideSpeed = dot(this.vx, this.vy, rightX, rightY);
97
+ const movingFactor = clamp(Math.abs(forwardSpeed) / 90, 0.25, 1);
98
+ const steerPower = (2.15 + difficulty * 0.08) * terrain.grip * movingFactor;
99
+
100
+ this.angle = normalizeAngle(this.angle + input.steer * steerPower * delta * (forwardSpeed >= -15 ? 1 : -1));
101
+
102
+ let acceleration = input.throttle * (230 + difficulty * 12) - input.brake * 320;
103
+ const boosting = input.nitro && this.nitroCharges > 0 && this.nitroTimer <= 0 && input.throttle > 0;
104
+ if (boosting) {
105
+ this.nitroCharges -= 1;
106
+ this.nitroTimer = 1.15;
107
+ effects.add('boost', this.x, this.y, this.angle, 12);
108
+ effects.impact(this.isPlayer ? 5 : 2);
109
+ }
110
+
111
+ if (this.nitroTimer > 0) {
112
+ this.nitroTimer -= delta;
113
+ acceleration += 360;
114
+ if (Math.random() < 0.8) effects.add('boost', this.x - forwardX * 26, this.y - forwardY * 26, this.angle, 1);
115
+ }
116
+
117
+ const maxSpeed = (this.nitroTimer > 0 ? 330 : 235 + difficulty * 8) * terrain.speedMultiplier;
118
+ this.vx += forwardX * acceleration * delta;
119
+ this.vy += forwardY * acceleration * delta;
120
+
121
+ const newForwardSpeed = dot(this.vx, this.vy, forwardX, forwardY);
122
+ const limitedForward = clamp(newForwardSpeed, -95, maxSpeed);
123
+ const lateralGrip = this.airborne > 0 ? 0.02 : clamp(terrain.grip * (input.brake > 0 ? 0.72 : 1), 0.08, 1);
124
+ const limitedSide = sideSpeed * (1 - clamp(delta * (6.5 * lateralGrip), 0, 0.92));
125
+
126
+ this.vx = forwardX * limitedForward + rightX * limitedSide;
127
+ this.vy = forwardY * limitedForward + rightY * limitedSide;
128
+
129
+ const drag = 1 - delta * (terrain.terrain === 'rough' ? 1.4 : 0.65);
130
+ this.vx *= drag;
131
+ this.vy *= drag;
132
+
133
+ this.x += this.vx * delta;
134
+ this.y += this.vy * delta;
135
+
136
+ this.driftAmount = Math.abs(limitedSide) / 90;
137
+ if (this.speed > 45 && terrain.terrain !== 'track' && Math.random() < 0.7) effects.add('dust', this.x - forwardX * 24, this.y - forwardY * 24, this.angle, 1);
138
+ if (this.driftAmount > 0.42 && this.airborne <= 0 && Math.random() < 0.6) effects.add('skid', this.x - forwardX * 18, this.y - forwardY * 18, this.angle, 1);
139
+
140
+ this.updateAir(delta, track, effects);
141
+ this.updateCheckpoints(track, raceTime);
142
+ }
143
+
144
+ collideWith(other: Car, effects: Effects): void {
145
+ const dx = this.x - other.x;
146
+ const dy = this.y - other.y;
147
+ const dist = length(dx, dy);
148
+ const minDist = this.radius + other.radius;
149
+ if (dist <= 0 || dist >= minDist) return;
150
+
151
+ const nx = dx / dist;
152
+ const ny = dy / dist;
153
+ const overlap = minDist - dist;
154
+ this.x += nx * overlap * 0.5;
155
+ this.y += ny * overlap * 0.5;
156
+ other.x -= nx * overlap * 0.5;
157
+ other.y -= ny * overlap * 0.5;
158
+
159
+ const impulse = dot(this.vx - other.vx, this.vy - other.vy, nx, ny) * 0.55;
160
+ this.vx -= nx * impulse;
161
+ this.vy -= ny * impulse;
162
+ other.vx += nx * impulse;
163
+ other.vy += ny * impulse;
164
+ effects.add('spark', (this.x + other.x) / 2, (this.y + other.y) / 2, this.angle, 5);
165
+ effects.impact(this.isPlayer || other.isPlayer ? 6 : 2);
166
+ }
167
+
168
+ collideObstacles(track: Track, effects: Effects): 'hit' | 'bump' | null {
169
+ for (const obstacle of track.obstacles) {
170
+ const dx = this.x - obstacle.x;
171
+ const dy = this.y - obstacle.y;
172
+ const dist = length(dx, dy);
173
+ if (dist >= this.radius + obstacle.radius) continue;
174
+
175
+ if (obstacle.kind === 'bump') {
176
+ if (this.airborne <= 0 && this.speed > 65) {
177
+ this.verticalVelocity = 180 + this.speed * 0.45;
178
+ effects.add('dust', this.x, this.y, this.angle, 10);
179
+ effects.impact(this.isPlayer ? 5 : 1);
180
+ return 'bump';
181
+ }
182
+ continue;
183
+ }
184
+
185
+ const nx = dx / Math.max(dist, 0.01);
186
+ const ny = dy / Math.max(dist, 0.01);
187
+ const overlap = this.radius + obstacle.radius - dist;
188
+ this.x += nx * overlap;
189
+ this.y += ny * overlap;
190
+ const impactSpeed = dot(this.vx, this.vy, nx, ny);
191
+ this.vx -= nx * impactSpeed * 1.55;
192
+ this.vy -= ny * impactSpeed * 1.55;
193
+ this.vx *= 0.72;
194
+ this.vy *= 0.72;
195
+ effects.add('spark', this.x - nx * this.radius, this.y - ny * this.radius, this.angle, 8);
196
+ effects.impact(this.isPlayer ? 9 : 2);
197
+ return 'hit';
198
+ }
199
+ return null;
200
+ }
201
+
202
+ private updateAir(delta: number, track: Track, effects: Effects): void {
203
+ if (this.airborne > 0 || this.verticalVelocity > 0) {
204
+ this.airborne += this.verticalVelocity * delta;
205
+ this.verticalVelocity -= 520 * delta;
206
+ if (this.airborne <= 0) {
207
+ this.airborne = 0;
208
+ this.verticalVelocity = 0;
209
+ if (this.speed > 75) {
210
+ effects.add('dust', this.x, this.y, this.angle, 14);
211
+ effects.impact(this.isPlayer ? 7 : 2);
212
+ }
213
+ }
214
+ }
215
+
216
+ if (track.consumePickup({ x: this.x, y: this.y })) {
217
+ this.nitroCharges = Math.min(this.maxNitro, this.nitroCharges + 1);
218
+ this.collectedPickup = true;
219
+ effects.add('boost', this.x, this.y, this.angle, 10);
220
+ }
221
+ }
222
+
223
+ private updateCheckpoints(track: Track, raceTime: number): void {
224
+ const sample = track.sample(this.x, this.y);
225
+ const progress = sample.progress;
226
+ const checkpoint = track.nextCheckpoint(this.nextCheckpoint);
227
+ const closeEnough = Math.hypot(this.x - checkpoint.x, this.y - checkpoint.y) < 155;
228
+
229
+ if (closeEnough) {
230
+ this.nextCheckpoint += 1;
231
+ if (this.nextCheckpoint >= track.checkpoints.length) {
232
+ this.nextCheckpoint = 0;
233
+ this.lap += 1;
234
+ if (this.lap > track.lapsToWin) {
235
+ this.finished = true;
236
+ this.finishTime = raceTime;
237
+ this.lap = track.lapsToWin;
238
+ }
239
+ }
240
+ }
241
+
242
+ const wrappedProgress = progress < 0.15 && this.lastProgress > 0.85 ? progress + 1 : progress;
243
+ const lapBase = Math.max(0, this.lap - 1);
244
+ this.totalProgress = lapBase + wrappedProgress;
245
+ this.lastProgress = progress;
246
+ }
247
+ }
@@ -0,0 +1,62 @@
1
+ import { Vec2, rand } from './math';
2
+
3
+ export type ParticleKind = 'dust' | 'skid' | 'spark' | 'boost';
4
+
5
+ type Particle = Vec2 & {
6
+ vx: number;
7
+ vy: number;
8
+ life: number;
9
+ maxLife: number;
10
+ size: number;
11
+ kind: ParticleKind;
12
+ };
13
+
14
+ export class Effects {
15
+ particles: Particle[] = [];
16
+ shake = 0;
17
+
18
+ add(kind: ParticleKind, x: number, y: number, angle = 0, amount = 1): void {
19
+ for (let i = 0; i < amount; i++) {
20
+ const speed = kind === 'boost' ? rand(80, 180) : rand(10, 95);
21
+ const spread = angle + Math.PI + rand(-0.75, 0.75);
22
+ this.particles.push({
23
+ x: x + rand(-8, 8),
24
+ y: y + rand(-8, 8),
25
+ vx: Math.cos(spread) * speed + rand(-20, 20),
26
+ vy: Math.sin(spread) * speed + rand(-20, 20),
27
+ life: kind === 'skid' ? 1.4 : rand(0.25, 0.75),
28
+ maxLife: kind === 'skid' ? 1.4 : 0.75,
29
+ size: kind === 'spark' ? rand(2, 5) : rand(6, 18),
30
+ kind,
31
+ });
32
+ }
33
+ }
34
+
35
+ impact(amount: number): void {
36
+ this.shake = Math.max(this.shake, amount);
37
+ }
38
+
39
+ update(delta: number): void {
40
+ this.shake = Math.max(0, this.shake - delta * 18);
41
+ for (const p of this.particles) {
42
+ p.x += p.vx * delta;
43
+ p.y += p.vy * delta;
44
+ p.vx *= 1 - delta * 2.2;
45
+ p.vy *= 1 - delta * 2.2;
46
+ p.life -= delta;
47
+ }
48
+ this.particles = this.particles.filter((p) => p.life > 0);
49
+ }
50
+
51
+ render(ctx: CanvasRenderingContext2D): void {
52
+ for (const p of this.particles) {
53
+ const alpha = Math.max(0, p.life / p.maxLife);
54
+ ctx.globalAlpha = alpha * (p.kind === 'skid' ? 0.55 : 0.8);
55
+ ctx.fillStyle = p.kind === 'spark' ? '#ffd166' : p.kind === 'boost' ? '#6ff7ff' : p.kind === 'skid' ? '#282018' : '#c89b66';
56
+ ctx.beginPath();
57
+ ctx.ellipse(p.x, p.y, p.size * (1.2 - alpha * 0.3), p.size * 0.45, 0, 0, Math.PI * 2);
58
+ ctx.fill();
59
+ }
60
+ ctx.globalAlpha = 1;
61
+ }
62
+ }
@@ -0,0 +1,229 @@
1
+ import { AIController } from './AI';
2
+ import { AudioSystem } from './Audio';
3
+ import { Car, DriverInput } from './Car';
4
+ import { Effects } from './Effects';
5
+ import { Input } from './Input';
6
+ import { Camera, Renderer } from './Renderer';
7
+ import { Track } from './Track';
8
+ import { UI, UiState } from './UI';
9
+ import { clamp, lerp, rand } from './math';
10
+
11
+ export class Game {
12
+ private renderer: Renderer;
13
+ private input = new Input();
14
+ private audio = new AudioSystem();
15
+ private ui: UI;
16
+ private track = new Track();
17
+ private effects = new Effects();
18
+ private cars: Car[] = [];
19
+ private ai: AIController[] = [];
20
+ private player!: Car;
21
+ private camera: Camera = { x: 0, y: 0, zoom: 0.72, shakeX: 0, shakeY: 0 };
22
+ private state: UiState = 'menu';
23
+ private lastTime = 0;
24
+ private raceTime = 0;
25
+ private countdown = 3.4;
26
+ private message = '';
27
+ private messageTimer = 0;
28
+ private winner = '';
29
+ private countdownBeep = 4;
30
+ private pausedFrom: UiState = 'racing';
31
+
32
+ constructor(canvas: HTMLCanvasElement, overlay: HTMLDivElement) {
33
+ this.renderer = new Renderer(canvas);
34
+ this.ui = new UI(overlay);
35
+ this.resetRace();
36
+ }
37
+
38
+ start(): void {
39
+ this.ui.render(this.uiData());
40
+ requestAnimationFrame((time) => this.loop(time));
41
+ }
42
+
43
+ private loop(time: number): void {
44
+ const delta = Math.min((time - this.lastTime) / 1000 || 0, 0.033);
45
+ this.lastTime = time;
46
+
47
+ this.handleGlobalInput();
48
+ this.update(delta);
49
+ this.render();
50
+ this.input.endFrame();
51
+
52
+ requestAnimationFrame((nextTime) => this.loop(nextTime));
53
+ }
54
+
55
+ private handleGlobalInput(): void {
56
+ if (this.input.consume('enter') && this.state === 'menu') {
57
+ this.audio.ensure();
58
+ this.beginCountdown();
59
+ }
60
+
61
+ if (this.input.consume('escape')) {
62
+ if (this.state === 'racing' || this.state === 'countdown') {
63
+ this.pausedFrom = this.state;
64
+ this.state = 'paused';
65
+ } else if (this.state === 'paused') this.state = this.pausedFrom;
66
+ }
67
+
68
+ if (this.input.consume('r')) {
69
+ this.resetRace();
70
+ this.beginCountdown();
71
+ }
72
+ }
73
+
74
+ private beginCountdown(): void {
75
+ this.state = 'countdown';
76
+ this.countdown = 3.4;
77
+ this.countdownBeep = 4;
78
+ this.raceTime = 0;
79
+ this.audio.beep('count');
80
+ }
81
+
82
+ private update(delta: number): void {
83
+ if (this.state === 'menu' || this.state === 'paused' || this.state === 'finished') {
84
+ this.updateCamera(delta);
85
+ return;
86
+ }
87
+
88
+ this.track.update(delta);
89
+ this.effects.update(delta);
90
+
91
+ if (this.state === 'countdown') {
92
+ this.countdown -= delta;
93
+ const nextBeep = Math.ceil(this.countdown);
94
+ if (nextBeep > 0 && nextBeep < this.countdownBeep) {
95
+ this.countdownBeep = nextBeep;
96
+ this.audio.beep('count');
97
+ }
98
+ if (this.countdown <= 0) {
99
+ this.state = 'racing';
100
+ this.audio.beep('go');
101
+ }
102
+ this.updateCamera(delta);
103
+ return;
104
+ }
105
+
106
+ this.raceTime += delta;
107
+ const difficulty = 1 + (this.player.lap - 1) * 0.12 + this.raceTime / 190;
108
+ const playerInput = this.readPlayerInput();
109
+ const wasBoosting = this.player.nitroTimer > 0;
110
+ this.player.update(delta, playerInput, this.track, this.effects, this.raceTime, difficulty);
111
+ if (!wasBoosting && this.player.nitroTimer > 0) this.audio.beep('boost');
112
+
113
+ for (let i = 1; i < this.cars.length; i++) {
114
+ const car = this.cars[i];
115
+ car.update(delta, this.ai[i - 1].update(car, this.track, this.player.rank, difficulty), this.track, this.effects, this.raceTime, difficulty);
116
+ }
117
+
118
+ this.resolveCollisions();
119
+ this.updateRanks();
120
+ this.updateMessages(delta);
121
+ this.updateFinishState();
122
+ this.updateCamera(delta);
123
+ this.audio.updateEngine(this.player.speed, playerInput.throttle - playerInput.brake, this.player.nitroTimer > 0);
124
+ }
125
+
126
+ private readPlayerInput(): DriverInput {
127
+ return {
128
+ throttle: this.input.isDown('arrowup', 'w') ? 1 : 0,
129
+ brake: this.input.isDown('arrowdown', 's') ? 1 : 0,
130
+ steer: (this.input.isDown('arrowright', 'd') ? 1 : 0) - (this.input.isDown('arrowleft', 'a') ? 1 : 0),
131
+ nitro: this.input.isDown('space'),
132
+ };
133
+ }
134
+
135
+ private resolveCollisions(): void {
136
+ for (const car of this.cars) {
137
+ const result = car.collideObstacles(this.track, this.effects);
138
+ if (car === this.player && result === 'hit') {
139
+ this.audio.beep('hit');
140
+ this.flashMessage('Ouch! Watch the rocks.');
141
+ }
142
+ if (car === this.player && result === 'bump') this.flashMessage('Big air!');
143
+
144
+ if (car === this.player && car.collectedPickup) {
145
+ this.audio.beep('pickup');
146
+ this.flashMessage('Nitro recharged!');
147
+ }
148
+ }
149
+
150
+ for (let i = 0; i < this.cars.length; i++) {
151
+ for (let j = i + 1; j < this.cars.length; j++) this.cars[i].collideWith(this.cars[j], this.effects);
152
+ }
153
+ }
154
+
155
+ private updateRanks(): void {
156
+ const sorted = [...this.cars].sort((a, b) => b.totalProgress - a.totalProgress || b.speed - a.speed);
157
+ sorted.forEach((car, index) => {
158
+ car.rank = index + 1;
159
+ });
160
+ }
161
+
162
+ private updateMessages(delta: number): void {
163
+ if (this.messageTimer > 0) {
164
+ this.messageTimer -= delta;
165
+ if (this.messageTimer <= 0) this.message = '';
166
+ }
167
+ }
168
+
169
+ private updateFinishState(): void {
170
+ const finished = this.cars.filter((car) => car.finished).sort((a, b) => a.finishTime - b.finishTime);
171
+ if (!finished.length) return;
172
+ this.winner = finished[0].name;
173
+ if (this.player.finished || finished.length >= 2) {
174
+ this.state = 'finished';
175
+ this.audio.beep('finish');
176
+ }
177
+ }
178
+
179
+ private updateCamera(delta: number): void {
180
+ const lookX = this.player.x + this.player.vx * 0.55;
181
+ const lookY = this.player.y + this.player.vy * 0.55;
182
+ this.camera.x = lerp(this.camera.x, lookX, 1 - Math.pow(0.001, delta));
183
+ this.camera.y = lerp(this.camera.y, lookY, 1 - Math.pow(0.001, delta));
184
+ this.camera.zoom = lerp(this.camera.zoom, clamp(0.78 - this.player.speed / 950, 0.56, 0.78), 0.04);
185
+ this.camera.shakeX = this.effects.shake ? rand(-this.effects.shake, this.effects.shake) : 0;
186
+ this.camera.shakeY = this.effects.shake ? rand(-this.effects.shake, this.effects.shake) : 0;
187
+ }
188
+
189
+ private render(): void {
190
+ this.renderer.render(this.track, this.cars, this.player, this.effects, this.camera);
191
+ this.ui.render(this.uiData());
192
+ }
193
+
194
+ private uiData() {
195
+ return {
196
+ state: this.state,
197
+ countdown: this.countdown,
198
+ player: this.player,
199
+ track: this.track,
200
+ elapsed: this.raceTime,
201
+ message: this.message,
202
+ winner: this.winner,
203
+ };
204
+ }
205
+
206
+ private resetRace(): void {
207
+ this.track = new Track();
208
+ this.effects = new Effects();
209
+ this.raceTime = 0;
210
+ this.winner = '';
211
+ this.message = '';
212
+ this.cars = [
213
+ new Car({ name: 'You', color: '#f43f5e', accent: '#fecdd3', isPlayer: true }, this.track.startPosition(0)),
214
+ new Car({ name: 'Baja Bob', color: '#f59e0b', accent: '#fde68a' }, this.track.startPosition(1)),
215
+ new Car({ name: 'Dusty Diaz', color: '#22c55e', accent: '#bbf7d0' }, this.track.startPosition(2)),
216
+ new Car({ name: 'Nitro Nell', color: '#3b82f6', accent: '#bfdbfe' }, this.track.startPosition(3)),
217
+ ];
218
+ this.player = this.cars[0];
219
+ this.ai = [new AIController(0), new AIController(1), new AIController(2)];
220
+ this.camera.x = this.player.x;
221
+ this.camera.y = this.player.y;
222
+ this.state = this.state === 'menu' ? 'menu' : 'countdown';
223
+ }
224
+
225
+ private flashMessage(message: string): void {
226
+ this.message = message;
227
+ this.messageTimer = 1.6;
228
+ }
229
+ }
@@ -0,0 +1,45 @@
1
+ export class Input {
2
+ private keys = new Set<string>();
3
+ private pressed = new Set<string>();
4
+
5
+ constructor() {
6
+ window.addEventListener('keydown', (event) => {
7
+ const key = this.normalize(event.key);
8
+ if (!this.keys.has(key)) this.pressed.add(key);
9
+ this.keys.add(key);
10
+
11
+ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' ', 'Space'].includes(event.key)) {
12
+ event.preventDefault();
13
+ }
14
+ });
15
+
16
+ window.addEventListener('keyup', (event) => {
17
+ this.keys.delete(this.normalize(event.key));
18
+ });
19
+
20
+ window.addEventListener('blur', () => {
21
+ this.keys.clear();
22
+ this.pressed.clear();
23
+ });
24
+ }
25
+
26
+ isDown(...keys: string[]): boolean {
27
+ return keys.some((key) => this.keys.has(this.normalize(key)));
28
+ }
29
+
30
+ consume(key: string): boolean {
31
+ const normalized = this.normalize(key);
32
+ if (!this.pressed.has(normalized)) return false;
33
+ this.pressed.delete(normalized);
34
+ return true;
35
+ }
36
+
37
+ endFrame(): void {
38
+ this.pressed.clear();
39
+ }
40
+
41
+ private normalize(key: string): string {
42
+ if (key === ' ') return 'space';
43
+ return key.toLowerCase();
44
+ }
45
+ }