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.
- package/TRAINING_DATA_v3.md +11 -0
- package/codex-test/offroad-nitro-racer/dist/assets/index-CuvdKK6U.js +44 -0
- package/codex-test/offroad-nitro-racer/dist/assets/index-DvHn2Thu.css +1 -0
- package/codex-test/offroad-nitro-racer/dist/index.html +23 -0
- package/codex-test/offroad-nitro-racer/index.html +21 -0
- package/codex-test/offroad-nitro-racer/package-lock.json +841 -0
- package/codex-test/offroad-nitro-racer/package.json +15 -0
- package/codex-test/offroad-nitro-racer/src/game/AI.ts +28 -0
- package/codex-test/offroad-nitro-racer/src/game/Audio.ts +63 -0
- package/codex-test/offroad-nitro-racer/src/game/Car.ts +247 -0
- package/codex-test/offroad-nitro-racer/src/game/Effects.ts +62 -0
- package/codex-test/offroad-nitro-racer/src/game/Game.ts +229 -0
- package/codex-test/offroad-nitro-racer/src/game/Input.ts +45 -0
- package/codex-test/offroad-nitro-racer/src/game/Renderer.ts +224 -0
- package/codex-test/offroad-nitro-racer/src/game/Track.ts +158 -0
- package/codex-test/offroad-nitro-racer/src/game/UI.ts +96 -0
- package/codex-test/offroad-nitro-racer/src/game/math.ts +42 -0
- package/codex-test/offroad-nitro-racer/src/main.ts +24 -0
- package/codex-test/offroad-nitro-racer/src/style.css +291 -0
- package/codex-test/offroad-nitro-racer/src/vite-env.d.ts +1 -0
- package/codex-test/offroad-nitro-racer/tsconfig.json +18 -0
- package/codex-test/offroad-nitro-racer/vite.config.ts +7 -0
- package/moe-training/client/parsers/codex.js +3 -3
- package/moe-training/client/parsers/gemini.js +2 -2
- package/moe-training/client/step-classifier.js +2 -2
- package/moe-training/test/client/step-classifier.test.js +63 -7
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +51 -15
- package/node_modules/@groove-dev/daemon/src/index.js +22 -8
- package/node_modules/@groove-dev/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
- package/node_modules/@groove-dev/gui/dist/index.html +1 -1
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/preview/preview-workspace.jsx +3 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +15 -0
- package/node_modules/moe-training/client/parsers/codex.js +3 -3
- package/node_modules/moe-training/client/parsers/gemini.js +2 -2
- package/node_modules/moe-training/client/step-classifier.js +2 -2
- package/node_modules/moe-training/test/client/step-classifier.test.js +63 -7
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +51 -15
- package/packages/daemon/src/index.js +22 -8
- package/packages/gui/dist/assets/{index-CHu5w3i3.js → index-BYh6iHqL.js} +3 -3
- package/packages/gui/dist/index.html +1 -1
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/preview/preview-workspace.jsx +3 -1
- package/packages/gui/src/stores/groove.js +15 -0
- 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
|
+
}
|