@netforge/template-racer 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/NOTICE +33 -0
- package/README.md +54 -2
- package/client/audio.ts +26 -0
- package/client/decor.ts +27 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +257 -0
- package/client/index.html +189 -0
- package/client/input.ts +105 -0
- package/client/main.ts +464 -0
- package/client/names.ts +42 -0
- package/client/scene.ts +646 -0
- package/client/stake.ts +143 -0
- package/llms.txt +125 -0
- package/package.json +53 -3
- package/src/game.ts +96 -0
- package/src/manifest.ts +44 -0
- package/src/store.ts +109 -0
package/client/scene.ts
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TORQUEBAY scene: a chase camera over a harbour circuit in three.js
|
|
3
|
+
* (WebGPU with WebGL2 fallback, the engine render stack in every art style).
|
|
4
|
+
*
|
|
5
|
+
* The track streams: it is cut into chunks of about
|
|
6
|
+
* 40 m of road, and only the chunks near the camera are built (road ribbon,
|
|
7
|
+
* grass verges, kerbs, barriers and the scenery past them); chunks behind
|
|
8
|
+
* the camera are dropped. At most two chunks build a frame, nearest first,
|
|
9
|
+
* so a long track never stalls a frame and memory holds only the road you
|
|
10
|
+
* can see. Karts are library models (sprites in the pixel style) that lean
|
|
11
|
+
* into drifts, spit sparks in the drift tier's colour and flame on boosts.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
AmbientLight,
|
|
15
|
+
BoxGeometry,
|
|
16
|
+
BufferAttribute,
|
|
17
|
+
BufferGeometry,
|
|
18
|
+
CanvasTexture,
|
|
19
|
+
Color,
|
|
20
|
+
CylinderGeometry,
|
|
21
|
+
DirectionalLight,
|
|
22
|
+
Group,
|
|
23
|
+
HemisphereLight,
|
|
24
|
+
InstancedMesh,
|
|
25
|
+
Mesh,
|
|
26
|
+
MeshBasicMaterial,
|
|
27
|
+
MeshStandardMaterial,
|
|
28
|
+
PlaneGeometry,
|
|
29
|
+
RepeatWrapping,
|
|
30
|
+
Scene,
|
|
31
|
+
SRGBColorSpace,
|
|
32
|
+
Vector3,
|
|
33
|
+
} from "three/webgpu";
|
|
34
|
+
import type { Material, Object3D } from "three/webgpu";
|
|
35
|
+
import { CameraRig, createRenderStack, styleKit } from "@netforge/engine/runtime";
|
|
36
|
+
import type { FeelKit, RenderStack, RendererHandle, StyleKit } from "@netforge/engine/runtime";
|
|
37
|
+
import { KF } from "@netforge/engine/game";
|
|
38
|
+
import type { CompiledRacer } from "@netforge/engine/game";
|
|
39
|
+
import { BOT_PLAYER_BASE, buildKartTrack, KartFlag, kartCoinTaken } from "@netforge/engine-sim";
|
|
40
|
+
import type { KartTrack } from "@netforge/engine-sim";
|
|
41
|
+
import type { CharacterAnimator, LibraryClient } from "@netforge/asset-library/three";
|
|
42
|
+
import { placeStatic } from "@netforge/asset-library/three";
|
|
43
|
+
import { BOT_MODELS, COIN_MODEL, KART_HEIGHT, MODEL_YAW, PIXEL_BOTS, SCENERY } from "./decor.js";
|
|
44
|
+
import { KEEPER_COLORS } from "./names.js";
|
|
45
|
+
|
|
46
|
+
export interface SceneEntity {
|
|
47
|
+
readonly id: number;
|
|
48
|
+
readonly kind: number;
|
|
49
|
+
readonly owner: number;
|
|
50
|
+
readonly values: readonly number[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface FrameEvents {
|
|
54
|
+
coins: number;
|
|
55
|
+
turbo: number;
|
|
56
|
+
boostStart: boolean;
|
|
57
|
+
wall: boolean;
|
|
58
|
+
lap: number;
|
|
59
|
+
finished: boolean;
|
|
60
|
+
driftStart: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const Q = 1024;
|
|
64
|
+
const TAU = Math.PI * 2;
|
|
65
|
+
/** Samples per streamed chunk (~2 m a sample). */
|
|
66
|
+
const CHUNK = 20;
|
|
67
|
+
/** Chunks within this distance of the camera's focus are built, m. */
|
|
68
|
+
const STREAM_RADIUS = 170;
|
|
69
|
+
const MAX_BUILDS_PER_FRAME = 2;
|
|
70
|
+
|
|
71
|
+
export function kartColor(owner: number): number {
|
|
72
|
+
const bots = [0xd94141, 0x3f7fe0, 0x40b860, 0xe0a030, 0x9050d0, 0x30b0b0, 0xe06090];
|
|
73
|
+
if (owner >= BOT_PLAYER_BASE) return bots[(owner - BOT_PLAYER_BASE) % bots.length] ?? 0xd94141;
|
|
74
|
+
return KEEPER_COLORS[(owner - 1) % KEEPER_COLORS.length] ?? 0xffffff;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function paintOf(cosmetic: number, base: number): { body: number; trim: number } {
|
|
78
|
+
if (cosmetic === 1) return { body: 0xff5a1f, trim: 0xffd23f };
|
|
79
|
+
if (cosmetic === 2) return { body: 0xd8dee8, trim: 0x8fd3ff };
|
|
80
|
+
return { body: base, trim: base };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function tex(kind: "asphalt" | "grass" | "kerb" | "water" | "check" | "arrow"): CanvasTexture {
|
|
84
|
+
const c = document.createElement("canvas");
|
|
85
|
+
c.width = c.height = kind === "kerb" || kind === "check" ? 64 : 128;
|
|
86
|
+
const g = c.getContext("2d");
|
|
87
|
+
if (!g) throw new Error("no 2d canvas");
|
|
88
|
+
let s = 17;
|
|
89
|
+
const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
|
|
90
|
+
const W = c.width;
|
|
91
|
+
if (kind === "asphalt") {
|
|
92
|
+
g.fillStyle = "#3b3f47";
|
|
93
|
+
g.fillRect(0, 0, W, W);
|
|
94
|
+
for (let i = 0; i < 1400; i++) {
|
|
95
|
+
const v = 50 + Math.floor(rnd() * 26);
|
|
96
|
+
g.fillStyle = `rgb(${v},${v + 2},${v + 6})`;
|
|
97
|
+
g.fillRect(rnd() * W, rnd() * W, 2, 2);
|
|
98
|
+
}
|
|
99
|
+
// Centre dashes run along v.
|
|
100
|
+
g.fillStyle = "rgba(240,240,230,0.75)";
|
|
101
|
+
g.fillRect(W / 2 - 2, 0, 4, W * 0.45);
|
|
102
|
+
} else if (kind === "grass") {
|
|
103
|
+
g.fillStyle = "#4f9a43";
|
|
104
|
+
g.fillRect(0, 0, W, W);
|
|
105
|
+
for (let i = 0; i < 1600; i++) {
|
|
106
|
+
g.fillStyle = `rgba(${40 + rnd() * 40},${120 + rnd() * 50},${40 + rnd() * 30},0.8)`;
|
|
107
|
+
g.fillRect(rnd() * W, rnd() * W, 2, 3);
|
|
108
|
+
}
|
|
109
|
+
} else if (kind === "water") {
|
|
110
|
+
g.fillStyle = "#2c6f9e";
|
|
111
|
+
g.fillRect(0, 0, W, W);
|
|
112
|
+
for (let i = 0; i < 90; i++) {
|
|
113
|
+
g.strokeStyle = `rgba(200,235,255,${0.15 + rnd() * 0.2})`;
|
|
114
|
+
g.beginPath();
|
|
115
|
+
const x = rnd() * W;
|
|
116
|
+
const y = rnd() * W;
|
|
117
|
+
g.moveTo(x, y);
|
|
118
|
+
g.lineTo(x + 10 + rnd() * 14, y);
|
|
119
|
+
g.stroke();
|
|
120
|
+
}
|
|
121
|
+
} else if (kind === "kerb") {
|
|
122
|
+
g.fillStyle = "#e8e8e8";
|
|
123
|
+
g.fillRect(0, 0, W, W);
|
|
124
|
+
g.fillStyle = "#d63b3b";
|
|
125
|
+
g.fillRect(0, 0, W, W / 2);
|
|
126
|
+
} else if (kind === "check") {
|
|
127
|
+
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
|
|
128
|
+
g.fillStyle = (x + y) % 2 ? "#111" : "#f4f4f4";
|
|
129
|
+
g.fillRect(x * 8, y * 8, 8, 8);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
g.fillStyle = "#1f6fff";
|
|
133
|
+
g.fillRect(0, 0, W, W);
|
|
134
|
+
g.fillStyle = "#9fe8ff";
|
|
135
|
+
for (const y0 of [8, 56]) {
|
|
136
|
+
g.beginPath();
|
|
137
|
+
g.moveTo(W / 2, y0);
|
|
138
|
+
g.lineTo(W - 16, y0 + 40);
|
|
139
|
+
g.lineTo(16, y0 + 40);
|
|
140
|
+
g.fill();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const t = new CanvasTexture(c);
|
|
144
|
+
t.wrapS = t.wrapT = RepeatWrapping;
|
|
145
|
+
t.colorSpace = SRGBColorSpace;
|
|
146
|
+
return t;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A ribbon between two lateral offsets of the centreline over samples [a, b] (wrapping), y lifted by `lift`. */
|
|
150
|
+
function ribbon(t: KartTrack, a: number, b: number, from: number, to: number, lift: number, vScale: number): BufferGeometry {
|
|
151
|
+
const n = b - a + 1;
|
|
152
|
+
const pos = new Float32Array(n * 2 * 3);
|
|
153
|
+
const uv = new Float32Array(n * 2 * 2);
|
|
154
|
+
const nor = new Float32Array(n * 2 * 3);
|
|
155
|
+
for (let k = 0; k < n; k++) {
|
|
156
|
+
const i = (a + k) % t.count;
|
|
157
|
+
const nx = -(t.tz[i] ?? 0);
|
|
158
|
+
const nz = t.tx[i] ?? 0;
|
|
159
|
+
const cx = t.x[i] ?? 0;
|
|
160
|
+
const cz = t.z[i] ?? 0;
|
|
161
|
+
const d = (t.dist[i] ?? 0) + (a + k >= t.count ? t.length : 0);
|
|
162
|
+
pos.set([cx + nx * from, lift, cz + nz * from, cx + nx * to, lift, cz + nz * to], k * 6);
|
|
163
|
+
uv.set([0, d * vScale, 1, d * vScale], k * 4);
|
|
164
|
+
nor.set([0, 1, 0, 0, 1, 0], k * 6);
|
|
165
|
+
}
|
|
166
|
+
const idx: number[] = [];
|
|
167
|
+
for (let k = 0; k < n - 1; k++) {
|
|
168
|
+
const o = k * 2;
|
|
169
|
+
// Wound to face up whichever side of the centreline the strip lies (from < to or not).
|
|
170
|
+
if (to < from) idx.push(o, o + 2, o + 1, o + 1, o + 2, o + 3);
|
|
171
|
+
else idx.push(o, o + 1, o + 2, o + 1, o + 3, o + 2);
|
|
172
|
+
}
|
|
173
|
+
const g = new BufferGeometry();
|
|
174
|
+
g.setAttribute("position", new BufferAttribute(pos, 3));
|
|
175
|
+
g.setAttribute("normal", new BufferAttribute(nor, 3));
|
|
176
|
+
g.setAttribute("uv", new BufferAttribute(uv, 2));
|
|
177
|
+
g.setIndex(idx);
|
|
178
|
+
g.computeBoundingSphere();
|
|
179
|
+
return g;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface Chunk {
|
|
183
|
+
readonly group: Group;
|
|
184
|
+
readonly cx: number;
|
|
185
|
+
readonly cz: number;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface KartView {
|
|
189
|
+
root: Group;
|
|
190
|
+
body: Group;
|
|
191
|
+
model: Object3D | null;
|
|
192
|
+
animator: CharacterAnimator | null;
|
|
193
|
+
mats: MeshStandardMaterial[];
|
|
194
|
+
tag: Mesh;
|
|
195
|
+
flame: Mesh;
|
|
196
|
+
color: number;
|
|
197
|
+
paint: number;
|
|
198
|
+
lastFlags: number;
|
|
199
|
+
lastLap: number;
|
|
200
|
+
lastCoins: number;
|
|
201
|
+
lastBoost: number;
|
|
202
|
+
lean: number;
|
|
203
|
+
loading: boolean;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export class KartScene {
|
|
207
|
+
readonly scene = new Scene();
|
|
208
|
+
readonly rig: CameraRig;
|
|
209
|
+
readonly stack: RenderStack;
|
|
210
|
+
readonly style: string;
|
|
211
|
+
private readonly kit: StyleKit | null;
|
|
212
|
+
private readonly tracks: KartTrack[];
|
|
213
|
+
private course = -1;
|
|
214
|
+
private track: KartTrack | null = null;
|
|
215
|
+
private trackGroup = new Group();
|
|
216
|
+
private chunks = new Map<number, Chunk>();
|
|
217
|
+
private readonly mats: Record<string, Material>;
|
|
218
|
+
private coins: InstancedMesh | null = null;
|
|
219
|
+
private coinModel: { geometry: BufferGeometry; material: Material } | null = null;
|
|
220
|
+
private readonly karts = new Map<number, KartView>();
|
|
221
|
+
private readonly focus = new Vector3();
|
|
222
|
+
private camYaw = 0;
|
|
223
|
+
private time = 0;
|
|
224
|
+
/** Chunks built and dropped so far (the streaming numbers measure reads). */
|
|
225
|
+
readonly streaming = { built: 0, dropped: 0, live: 0 };
|
|
226
|
+
readonly counts = { coins: 0, turbos: 0, laps: 0, walls: 0 };
|
|
227
|
+
|
|
228
|
+
constructor(
|
|
229
|
+
handle: RendererHandle,
|
|
230
|
+
private readonly compiled: CompiledRacer,
|
|
231
|
+
private readonly library: LibraryClient | null,
|
|
232
|
+
private readonly feel: FeelKit | null,
|
|
233
|
+
private readonly playerModel: string,
|
|
234
|
+
opts: { style: string; quality?: "low" | "medium" | "high" },
|
|
235
|
+
) {
|
|
236
|
+
this.style = opts.style;
|
|
237
|
+
this.kit = styleKit(this.style);
|
|
238
|
+
this.tracks = compiled.tracks.map(buildKartTrack);
|
|
239
|
+
this.rig = new CameraRig(1, { distance: 9.5, height: 1.4, follow: 10, lead: 0.05, leadMax: 2, fov: 62, minPitch: 0.05, maxPitch: 0.9, shake: { maxOffset: 0.25 } });
|
|
240
|
+
this.rig.pitch = 0.3;
|
|
241
|
+
const s = this.scene;
|
|
242
|
+
this.stack = createRenderStack(handle, s, this.rig.camera, {
|
|
243
|
+
preset: "day",
|
|
244
|
+
style: this.style,
|
|
245
|
+
quality: opts.quality ?? "auto",
|
|
246
|
+
shadowExtent: 30,
|
|
247
|
+
atmosphere: { heightFog: 0, near: 90, far: 260 },
|
|
248
|
+
resolve: (id) => {
|
|
249
|
+
try {
|
|
250
|
+
return library?.url(id) ?? null;
|
|
251
|
+
} catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
if (this.kit?.stack?.("day").shadows !== true) this.stack.lighting.setShadowMapSize(0);
|
|
257
|
+
if (feel) feel.particles = this.stack.particles;
|
|
258
|
+
if (!this.kit) {
|
|
259
|
+
s.add(new HemisphereLight(0xd8ecff, 0x40502c, 0.7));
|
|
260
|
+
s.add(new AmbientLight(0xffffff, 0.15));
|
|
261
|
+
const sun = new DirectionalLight(0xfff2dc, 0.6);
|
|
262
|
+
sun.position.set(-30, 60, 20);
|
|
263
|
+
s.add(sun);
|
|
264
|
+
}
|
|
265
|
+
const session = this.stack.session;
|
|
266
|
+
const asphalt = session?.surface?.("floor", { tile: 4, mood: "asphalt road" }) ?? tex("asphalt");
|
|
267
|
+
this.mats = {
|
|
268
|
+
asphalt: new MeshStandardMaterial({ map: asphalt, roughness: 0.9 }),
|
|
269
|
+
grass: new MeshStandardMaterial({ map: session?.surface?.("ground", { tile: 6, mood: "harbour grass" }) ?? tex("grass"), roughness: 1 }),
|
|
270
|
+
kerb: new MeshStandardMaterial({ map: tex("kerb"), roughness: 0.6 }),
|
|
271
|
+
barrier: new MeshStandardMaterial({ color: 0xe6e9ef, roughness: 0.5, metalness: 0.2 }),
|
|
272
|
+
barrierTop: new MeshStandardMaterial({ color: 0x2f62d6, roughness: 0.5 }),
|
|
273
|
+
check: new MeshStandardMaterial({ map: tex("check"), roughness: 0.7 }),
|
|
274
|
+
pad: new MeshBasicMaterial({ map: tex("arrow") }),
|
|
275
|
+
};
|
|
276
|
+
// The bay: water to the horizon, under everything.
|
|
277
|
+
const waterMap = tex("water");
|
|
278
|
+
waterMap.repeat.set(80, 80);
|
|
279
|
+
const water = new Mesh(new PlaneGeometry(2000, 2000), new MeshStandardMaterial({ map: waterMap, roughness: 0.3, metalness: 0.1 }));
|
|
280
|
+
water.rotation.x = -Math.PI / 2;
|
|
281
|
+
water.position.y = -0.6;
|
|
282
|
+
water.userData.surface = "water";
|
|
283
|
+
s.add(water, this.trackGroup);
|
|
284
|
+
if (library) {
|
|
285
|
+
void library
|
|
286
|
+
.crowd(COIN_MODEL, undefined, { height: 1 })
|
|
287
|
+
.then((m) => {
|
|
288
|
+
const p = m.parts[0];
|
|
289
|
+
if (p) this.coinModel = { geometry: p.geometry, material: p.material };
|
|
290
|
+
if (this.track) this.buildCoins(this.track);
|
|
291
|
+
})
|
|
292
|
+
.catch(() => undefined);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
setCourse(index: number): void {
|
|
297
|
+
if (index === this.course) return;
|
|
298
|
+
const t = this.tracks[index];
|
|
299
|
+
if (!t) return;
|
|
300
|
+
this.course = index;
|
|
301
|
+
this.track = t;
|
|
302
|
+
this.scene.remove(this.trackGroup);
|
|
303
|
+
this.trackGroup = new Group();
|
|
304
|
+
this.scene.add(this.trackGroup);
|
|
305
|
+
this.chunks.clear();
|
|
306
|
+
this.streaming.live = 0;
|
|
307
|
+
// An island under the circuit: grass far enough round the track (the chunks lay their own verges).
|
|
308
|
+
let minX = Infinity;
|
|
309
|
+
let maxX = -Infinity;
|
|
310
|
+
let minZ = Infinity;
|
|
311
|
+
let maxZ = -Infinity;
|
|
312
|
+
for (let i = 0; i < t.count; i++) {
|
|
313
|
+
minX = Math.min(minX, t.x[i] ?? 0);
|
|
314
|
+
maxX = Math.max(maxX, t.x[i] ?? 0);
|
|
315
|
+
minZ = Math.min(minZ, t.z[i] ?? 0);
|
|
316
|
+
maxZ = Math.max(maxZ, t.z[i] ?? 0);
|
|
317
|
+
}
|
|
318
|
+
const pad = t.wall + 30;
|
|
319
|
+
const isl = new Mesh(new BoxGeometry(maxX - minX + pad * 2, 1, maxZ - minZ + pad * 2), this.mats.grass as Material);
|
|
320
|
+
const gm = (this.mats.grass as MeshStandardMaterial).map;
|
|
321
|
+
if (gm) gm.repeat.set((maxX - minX + pad * 2) / 6, (maxZ - minZ + pad * 2) / 6);
|
|
322
|
+
isl.position.set((minX + maxX) / 2, -0.55, (minZ + maxZ) / 2);
|
|
323
|
+
isl.userData.surface = "grass";
|
|
324
|
+
this.trackGroup.add(isl);
|
|
325
|
+
// Start line and boost pads.
|
|
326
|
+
const line = new Mesh(ribbon(t, 0, 1, -t.half, t.half, 0.03, 1), this.mats.check as Material);
|
|
327
|
+
this.trackGroup.add(line);
|
|
328
|
+
for (const p of t.pads) {
|
|
329
|
+
const m = new Mesh(new PlaneGeometry(2.6, 3.6), this.mats.pad as Material);
|
|
330
|
+
m.rotation.x = -Math.PI / 2;
|
|
331
|
+
m.rotation.z = -Math.atan2(p.tz, p.tx) + Math.PI / 2;
|
|
332
|
+
m.position.set(p.x, 0.04, p.z);
|
|
333
|
+
this.trackGroup.add(m);
|
|
334
|
+
}
|
|
335
|
+
this.buildCoins(t);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private buildCoins(t: KartTrack): void {
|
|
339
|
+
if (this.coins) this.trackGroup.remove(this.coins);
|
|
340
|
+
const geo = this.coinModel?.geometry ?? new CylinderGeometry(0.45, 0.45, 0.1, 16).rotateX(Math.PI / 2);
|
|
341
|
+
const mat = this.coinModel?.material ?? new MeshStandardMaterial({ color: 0xffd23f, emissive: new Color(0x6a4a00), metalness: 0.7, roughness: 0.3 });
|
|
342
|
+
const m = new InstancedMesh(geo, mat, Math.max(1, t.coinAt.length));
|
|
343
|
+
m.count = t.coinAt.length;
|
|
344
|
+
m.frustumCulled = false;
|
|
345
|
+
this.coins = m;
|
|
346
|
+
this.trackGroup.add(m);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Build a chunk: road, verges, kerbs, barriers and the scenery past them. */
|
|
350
|
+
private buildChunk(t: KartTrack, c: number): Chunk {
|
|
351
|
+
const a = c * CHUNK;
|
|
352
|
+
const b = Math.min(a + CHUNK, t.count);
|
|
353
|
+
const g = new Group();
|
|
354
|
+
const road = new Mesh(ribbon(t, a, b, -t.half, t.half, 0.02, 0.25), this.mats.asphalt as Material);
|
|
355
|
+
road.userData.surface = "floor";
|
|
356
|
+
road.receiveShadow = true;
|
|
357
|
+
g.add(road);
|
|
358
|
+
for (const side of [-1, 1]) {
|
|
359
|
+
const kerb = new Mesh(ribbon(t, a, b, side * t.half, side * (t.half + 0.9), 0.03, 0.5), this.mats.kerb as Material);
|
|
360
|
+
g.add(kerb);
|
|
361
|
+
}
|
|
362
|
+
// Barriers: low rails along the wall line, one instanced draw a chunk.
|
|
363
|
+
const n = Math.ceil((b - a) / 2);
|
|
364
|
+
const rails = new InstancedMesh(new BoxGeometry(4.2, 0.9, 0.5), this.mats.barrier as Material, n * 2);
|
|
365
|
+
let k = 0;
|
|
366
|
+
const m = rails.instanceMatrix.array as Float32Array;
|
|
367
|
+
for (let i = a; i < b; i += 2) {
|
|
368
|
+
for (const side of [-1, 1]) {
|
|
369
|
+
const nx = -(t.tz[i] ?? 0) * side;
|
|
370
|
+
const nz = (t.tx[i] ?? 0) * side;
|
|
371
|
+
const x = (t.x[i] ?? 0) + nx * (t.wall + 0.3);
|
|
372
|
+
const z = (t.z[i] ?? 0) + nz * (t.wall + 0.3);
|
|
373
|
+
const yaw = Math.atan2(t.tz[i] ?? 0, t.tx[i] ?? 1);
|
|
374
|
+
const cs = Math.cos(-yaw);
|
|
375
|
+
const sn = Math.sin(-yaw);
|
|
376
|
+
m.set([cs, 0, -sn, 0, 0, 1, 0, 0, sn, 0, cs, 0, x, 0.45, z, 1], k * 16);
|
|
377
|
+
k++;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
rails.count = k;
|
|
381
|
+
rails.instanceMatrix.needsUpdate = true;
|
|
382
|
+
g.add(rails);
|
|
383
|
+
// Scenery past the barriers, deterministic per chunk.
|
|
384
|
+
if (this.library) {
|
|
385
|
+
let s = 1000 + c * 97 + this.course * 7919;
|
|
386
|
+
const rnd = () => ((s = (s * 16807) % 2147483647) / 2147483647);
|
|
387
|
+
for (const [id, height, every] of SCENERY) {
|
|
388
|
+
const places: { x: number; y: number; z: number; yaw: number }[] = [];
|
|
389
|
+
for (let i = a; i < b; i += every) {
|
|
390
|
+
if (rnd() > 0.55) continue;
|
|
391
|
+
const side = rnd() < 0.5 ? -1 : 1;
|
|
392
|
+
const out = t.wall + 4 + rnd() * 16;
|
|
393
|
+
const nx = -(t.tz[i] ?? 0) * side;
|
|
394
|
+
const nz = (t.tx[i] ?? 0) * side;
|
|
395
|
+
const x = (t.x[i] ?? 0) + nx * out;
|
|
396
|
+
const z = (t.z[i] ?? 0) + nz * out;
|
|
397
|
+
if (this.nearRoad(t, x, z)) continue;
|
|
398
|
+
places.push({ x, y: 0, z, yaw: rnd() * TAU });
|
|
399
|
+
}
|
|
400
|
+
if (places.length === 0) continue;
|
|
401
|
+
void this.library
|
|
402
|
+
.crowd(id, undefined, { height })
|
|
403
|
+
.then((model) => {
|
|
404
|
+
for (const o of placeStatic(model, places)) g.add(o);
|
|
405
|
+
})
|
|
406
|
+
.catch(() => undefined);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const mid = Math.min(t.count - 1, a + CHUNK / 2);
|
|
410
|
+
this.trackGroup.add(g);
|
|
411
|
+
this.streaming.built++;
|
|
412
|
+
return { group: g, cx: t.x[mid] ?? 0, cz: t.z[mid] ?? 0 };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** True when (x, z) is within the wall of any part of the track (scenery stays off the road). */
|
|
416
|
+
private nearRoad(t: KartTrack, x: number, z: number): boolean {
|
|
417
|
+
for (let i = 0; i < t.count; i += 3) {
|
|
418
|
+
const dx = (t.x[i] ?? 0) - x;
|
|
419
|
+
const dz = (t.z[i] ?? 0) - z;
|
|
420
|
+
if (dx * dx + dz * dz < (t.wall + 3) ** 2) return true;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Stream: build the nearest missing chunks (two a frame) and drop far ones. */
|
|
426
|
+
private stream(): void {
|
|
427
|
+
const t = this.track;
|
|
428
|
+
if (!t) return;
|
|
429
|
+
const total = Math.ceil(t.count / CHUNK);
|
|
430
|
+
const want: { c: number; d: number }[] = [];
|
|
431
|
+
for (let c = 0; c < total; c++) {
|
|
432
|
+
const i = Math.min(t.count - 1, c * CHUNK + CHUNK / 2);
|
|
433
|
+
const d = Math.hypot((t.x[i] ?? 0) - this.focus.x, (t.z[i] ?? 0) - this.focus.z);
|
|
434
|
+
if (d < STREAM_RADIUS) want.push({ c, d });
|
|
435
|
+
else {
|
|
436
|
+
const have = this.chunks.get(c);
|
|
437
|
+
if (have && d > STREAM_RADIUS + 30) {
|
|
438
|
+
this.trackGroup.remove(have.group);
|
|
439
|
+
have.group.traverse((o) => (o as Mesh).geometry?.dispose?.());
|
|
440
|
+
this.chunks.delete(c);
|
|
441
|
+
this.streaming.dropped++;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
want.sort((p, q) => p.d - q.d);
|
|
446
|
+
let built = 0;
|
|
447
|
+
for (const { c } of want) {
|
|
448
|
+
if (this.chunks.has(c)) continue;
|
|
449
|
+
if (built++ >= MAX_BUILDS_PER_FRAME) break;
|
|
450
|
+
this.chunks.set(c, this.buildChunk(t, c));
|
|
451
|
+
}
|
|
452
|
+
this.streaming.live = this.chunks.size;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
private kart(owner: number): KartView {
|
|
456
|
+
let v = this.karts.get(owner);
|
|
457
|
+
if (v) return v;
|
|
458
|
+
const root = new Group();
|
|
459
|
+
const body = new Group();
|
|
460
|
+
root.add(body);
|
|
461
|
+
const color = kartColor(owner);
|
|
462
|
+
const mat = new MeshStandardMaterial({ color, roughness: 0.5, metalness: 0.2 });
|
|
463
|
+
const chassis = new Mesh(new BoxGeometry(1.8, 0.45, 1.1), mat);
|
|
464
|
+
chassis.position.y = 0.4;
|
|
465
|
+
body.add(chassis);
|
|
466
|
+
const tag = new Mesh(new CylinderGeometry(0, 0.2, 0.35, 4), new MeshBasicMaterial({ color }));
|
|
467
|
+
tag.rotation.x = Math.PI;
|
|
468
|
+
tag.position.y = KART_HEIGHT + 1.1;
|
|
469
|
+
const flame = new Mesh(new CylinderGeometry(0.05, 0.28, 1.2, 8), new MeshBasicMaterial({ color: 0x7fd0ff, transparent: true, opacity: 0.85 }));
|
|
470
|
+
flame.rotation.z = Math.PI / 2;
|
|
471
|
+
flame.position.set(-1.3, 0.45, 0);
|
|
472
|
+
flame.visible = false;
|
|
473
|
+
body.add(flame);
|
|
474
|
+
root.add(tag);
|
|
475
|
+
this.scene.add(root);
|
|
476
|
+
v = { root, body, model: null, animator: null, mats: [mat], tag, flame, color, paint: -1, lastFlags: 0, lastLap: -1, lastCoins: -1, lastBoost: 0, lean: 0, loading: false };
|
|
477
|
+
this.karts.set(owner, v);
|
|
478
|
+
if (this.library) {
|
|
479
|
+
v.loading = true;
|
|
480
|
+
const bots = this.style === "pixel" ? PIXEL_BOTS : BOT_MODELS;
|
|
481
|
+
const id = owner >= BOT_PLAYER_BASE ? (bots[(owner - BOT_PLAYER_BASE) % bots.length] ?? this.playerModel) : this.playerModel;
|
|
482
|
+
const kv = v;
|
|
483
|
+
void this.library
|
|
484
|
+
.character(id, undefined, { height: KART_HEIGHT })
|
|
485
|
+
.then((ch) => {
|
|
486
|
+
if (!this.karts.has(owner)) return;
|
|
487
|
+
for (const c of [...kv.body.children]) if (c !== kv.flame) kv.body.remove(c);
|
|
488
|
+
if (ch.height > KART_HEIGHT * 1.4) ch.root.scale.setScalar((KART_HEIGHT * 1.2) / ch.height);
|
|
489
|
+
ch.root.rotation.y = MODEL_YAW;
|
|
490
|
+
kv.body.add(ch.root);
|
|
491
|
+
kv.model = ch.root;
|
|
492
|
+
kv.animator = ch.animator;
|
|
493
|
+
kv.mats = [];
|
|
494
|
+
ch.root.traverse((o) => {
|
|
495
|
+
const m = (o as Mesh).material as MeshStandardMaterial | undefined;
|
|
496
|
+
if (m && (m as { isMeshStandardMaterial?: boolean }).isMeshStandardMaterial) kv.mats.push(m);
|
|
497
|
+
(o as Mesh).castShadow = true;
|
|
498
|
+
});
|
|
499
|
+
kv.paint = -1;
|
|
500
|
+
})
|
|
501
|
+
.catch(() => undefined);
|
|
502
|
+
}
|
|
503
|
+
return v;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
frame(o: { dt: number; realDt: number; self: SceneEntity | undefined; others: readonly SceneEntity[]; course: number }): FrameEvents {
|
|
507
|
+
const ev: FrameEvents = { coins: 0, turbo: 0, boostStart: false, wall: false, lap: 0, finished: false, driftStart: false };
|
|
508
|
+
this.setCourse(o.course);
|
|
509
|
+
const t = this.track;
|
|
510
|
+
this.time += o.dt;
|
|
511
|
+
const seen = new Set<number>();
|
|
512
|
+
const all = [...(o.self ? [o.self] : []), ...o.others];
|
|
513
|
+
for (const e of all) {
|
|
514
|
+
seen.add(e.owner);
|
|
515
|
+
const v = this.kart(e.owner);
|
|
516
|
+
const vals = e.values;
|
|
517
|
+
const x = (vals[KF.x] ?? 0) / Q;
|
|
518
|
+
const z = (vals[KF.z] ?? 0) / Q;
|
|
519
|
+
const yaw = ((vals[KF.yaw] ?? 0) / 65536) * TAU;
|
|
520
|
+
const flags = vals[KF.flags] ?? 0;
|
|
521
|
+
const mine = e === o.self;
|
|
522
|
+
v.root.position.set(x, 0, z);
|
|
523
|
+
v.root.rotation.y = -yaw;
|
|
524
|
+
// Lean into a drift.
|
|
525
|
+
const drifting = (flags & KartFlag.Drifting) !== 0;
|
|
526
|
+
const dir = (flags & KartFlag.DriftRight) !== 0 ? 1 : -1;
|
|
527
|
+
const leanTo = drifting ? dir * 0.12 : 0;
|
|
528
|
+
v.lean += (leanTo - v.lean) * Math.min(1, o.realDt * 10);
|
|
529
|
+
v.body.rotation.x = v.lean;
|
|
530
|
+
v.body.rotation.y = drifting ? -dir * 0.25 : 0;
|
|
531
|
+
const boosting = (flags & KartFlag.Boosting) !== 0;
|
|
532
|
+
v.flame.visible = boosting;
|
|
533
|
+
if (boosting) v.flame.scale.set(1, 0.8 + Math.sin(this.time * 40) * 0.25, 1);
|
|
534
|
+
const timers = vals[KF.timers] ?? 0;
|
|
535
|
+
const charge = timers & 0xfff;
|
|
536
|
+
if (drifting) {
|
|
537
|
+
const tier = charge >= 120 ? 3 : charge >= 72 ? 2 : charge >= 33 ? 1 : 0;
|
|
538
|
+
const color = tier === 3 ? 0xd16bff : tier === 2 ? 0xffa53a : tier === 1 ? 0x5fc8ff : 0xdddddd;
|
|
539
|
+
if (Math.floor(this.time * 30) % 2 === 0) {
|
|
540
|
+
const bx = x - Math.cos(yaw) * 0.9;
|
|
541
|
+
const bz = z - Math.sin(yaw) * 0.9;
|
|
542
|
+
this.stack.particles.burst("sparks", bx, 0.15, bz, { count: tier > 0 ? 3 : 1, color });
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (mine) {
|
|
546
|
+
if (drifting && (v.lastFlags & KartFlag.Drifting) === 0) ev.driftStart = true;
|
|
547
|
+
const boost = (timers >>> 12) & 0xfff;
|
|
548
|
+
if (boost > v.lastBoost + 5) {
|
|
549
|
+
ev.boostStart = true;
|
|
550
|
+
this.stack.particles.burst("burst", x, 0.5, z, { count: 14, color: 0x7fd0ff });
|
|
551
|
+
}
|
|
552
|
+
v.lastBoost = boost;
|
|
553
|
+
if ((flags & KartFlag.Bumped) !== 0 && (v.lastFlags & KartFlag.Bumped) === 0) {
|
|
554
|
+
ev.wall = true;
|
|
555
|
+
this.counts.walls++;
|
|
556
|
+
}
|
|
557
|
+
const lap = ((vals[KF.track] ?? 0) >>> 16) & 0xff;
|
|
558
|
+
if (v.lastLap >= 0 && lap > v.lastLap) {
|
|
559
|
+
ev.lap = lap;
|
|
560
|
+
this.counts.laps++;
|
|
561
|
+
}
|
|
562
|
+
v.lastLap = lap;
|
|
563
|
+
if ((flags & KartFlag.Finished) !== 0 && (v.lastFlags & KartFlag.Finished) === 0) {
|
|
564
|
+
ev.finished = true;
|
|
565
|
+
this.stack.particles.burst("magic", x, 1.5, z, { count: 40, color: 0xffd23f });
|
|
566
|
+
}
|
|
567
|
+
const n = countBits(vals[KF.coinsLo] ?? 0) + countBits(vals[KF.coinsHi] ?? 0);
|
|
568
|
+
if (v.lastCoins >= 0 && n > v.lastCoins) {
|
|
569
|
+
ev.coins = n - v.lastCoins;
|
|
570
|
+
this.counts.coins += ev.coins;
|
|
571
|
+
this.stack.particles.burst("sparks", x, 1, z, { count: 8, color: 0xffd23f });
|
|
572
|
+
}
|
|
573
|
+
v.lastCoins = n;
|
|
574
|
+
if (t) this.updateCoins(t, vals[KF.coinsLo] ?? 0, vals[KF.coinsHi] ?? 0);
|
|
575
|
+
// The chase camera: behind the kart, swinging round smoothly.
|
|
576
|
+
const want = Math.atan2(-Math.cos(yaw), -Math.sin(yaw));
|
|
577
|
+
let d = want - this.camYaw;
|
|
578
|
+
while (d > Math.PI) d -= TAU;
|
|
579
|
+
while (d < -Math.PI) d += TAU;
|
|
580
|
+
this.camYaw += d * Math.min(1, o.realDt * 5);
|
|
581
|
+
this.rig.yaw = this.camYaw;
|
|
582
|
+
this.focus.set(x, 0.8, z);
|
|
583
|
+
}
|
|
584
|
+
v.lastFlags = flags;
|
|
585
|
+
// Paint: the cosmetic tints the kart.
|
|
586
|
+
const paint = (vals[KF.cosmetic] ?? 0) & 0xff;
|
|
587
|
+
if (paint !== v.paint) {
|
|
588
|
+
v.paint = paint;
|
|
589
|
+
const look = paintOf(paint, v.color);
|
|
590
|
+
for (const m of v.mats) m.emissive?.setHex(paint === 0 ? v.color : look.body).multiplyScalar(paint === 0 ? (v.model ? 0.12 : 0.25) : 0.4);
|
|
591
|
+
}
|
|
592
|
+
v.tag.visible = !mine && e.owner < BOT_PLAYER_BASE;
|
|
593
|
+
v.animator?.update(o.dt);
|
|
594
|
+
}
|
|
595
|
+
for (const [owner, v] of this.karts) {
|
|
596
|
+
if (seen.has(owner)) continue;
|
|
597
|
+
this.scene.remove(v.root);
|
|
598
|
+
this.karts.delete(owner);
|
|
599
|
+
}
|
|
600
|
+
if (!o.self && o.others[0]) {
|
|
601
|
+
const lead = o.others[0];
|
|
602
|
+
this.focus.set((lead.values[KF.x] ?? 0) / Q, 0.8, (lead.values[KF.z] ?? 0) / Q);
|
|
603
|
+
}
|
|
604
|
+
this.stream();
|
|
605
|
+
this.rig.update(this.focus, o.dt, o.realDt);
|
|
606
|
+
this.stack.frame(o.dt, this.focus);
|
|
607
|
+
return ev;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private updateCoins(t: KartTrack, lo: number, hi: number): void {
|
|
611
|
+
const mesh = this.coins;
|
|
612
|
+
if (!mesh) return;
|
|
613
|
+
const arr = mesh.instanceMatrix.array as Float32Array;
|
|
614
|
+
for (let i = 0; i < t.coinAt.length; i++) {
|
|
615
|
+
const c = t.coinAt[i] as readonly [number, number];
|
|
616
|
+
const sc = kartCoinTaken({ coinsLo: lo, coinsHi: hi }, i) ? 0 : 1;
|
|
617
|
+
const a = this.time * 3 + i * 0.5;
|
|
618
|
+
const cs = Math.cos(a) * sc;
|
|
619
|
+
const sn = Math.sin(a) * sc;
|
|
620
|
+
arr.set([cs, 0, -sn, 0, 0, sc, 0, 0, sn, 0, cs, 0, c[0], 0.9 + Math.sin(this.time * 2 + i) * 0.1 - (this.coinModel ? 0.5 : 0), c[1], 1], i * 16);
|
|
621
|
+
}
|
|
622
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
project(x: number, y: number, z: number, w: number, h: number): { x: number; y: number; visible: boolean } {
|
|
626
|
+
const v = new Vector3(x, y, z).project(this.rig.camera);
|
|
627
|
+
return { x: ((v.x + 1) / 2) * w, y: ((1 - v.y) / 2) * h, visible: v.z < 1 };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
resize(w: number, h: number): void {
|
|
631
|
+
this.rig.resize(w / h);
|
|
632
|
+
this.rig.setDistance(w / h < 1 ? 12 : 9.5);
|
|
633
|
+
this.stack.resize(w, h);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The track as drawn (the minimap reads it). */
|
|
637
|
+
get trackNow(): KartTrack | null {
|
|
638
|
+
return this.track;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function countBits(v: number): number {
|
|
643
|
+
let n = 0;
|
|
644
|
+
for (let x = v >>> 0; x; x &= x - 1) n++;
|
|
645
|
+
return n;
|
|
646
|
+
}
|