groove-dev 0.27.111 → 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 (54) 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/consent.js +47 -55
  24. package/moe-training/client/parsers/codex.js +3 -3
  25. package/moe-training/client/parsers/gemini.js +2 -2
  26. package/moe-training/client/step-classifier.js +2 -2
  27. package/moe-training/test/client/consent.test.js +23 -20
  28. package/moe-training/test/client/step-classifier.test.js +63 -7
  29. package/node_modules/@groove-dev/cli/package.json +1 -1
  30. package/node_modules/@groove-dev/daemon/package.json +1 -1
  31. package/node_modules/@groove-dev/daemon/src/api.js +74 -69
  32. package/node_modules/@groove-dev/daemon/src/index.js +30 -18
  33. package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
  34. package/node_modules/@groove-dev/gui/dist/index.html +1 -1
  35. package/node_modules/@groove-dev/gui/package.json +1 -1
  36. package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
  37. package/node_modules/@groove-dev/gui/src/stores/groove.js +15 -0
  38. package/node_modules/moe-training/client/consent.js +47 -55
  39. package/node_modules/moe-training/client/parsers/codex.js +3 -3
  40. package/node_modules/moe-training/client/parsers/gemini.js +2 -2
  41. package/node_modules/moe-training/client/step-classifier.js +2 -2
  42. package/node_modules/moe-training/test/client/consent.test.js +23 -20
  43. package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
  44. package/package.json +1 -1
  45. package/packages/cli/package.json +1 -1
  46. package/packages/daemon/package.json +1 -1
  47. package/packages/daemon/src/api.js +74 -69
  48. package/packages/daemon/src/index.js +30 -18
  49. package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
  50. package/packages/gui/dist/index.html +1 -1
  51. package/packages/gui/package.json +1 -1
  52. package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
  53. package/packages/gui/src/stores/groove.js +15 -0
  54. 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
+ }