@series-inc/rundot-kinetix 0.0.0-bootstrap.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/README.md +77 -0
- package/authoring.d.ts +30 -0
- package/index.d.ts +221 -0
- package/native/component_runtime.cpp +341 -0
- package/native/component_runtime.hpp +112 -0
- package/native/deterministic_math.cpp +594 -0
- package/native/deterministic_math.hpp +21 -0
- package/native/kinetix-f64-native-profile.cpp +406 -0
- package/native/kinetix-f64-native-profile.hpp +5 -0
- package/native/kinetix-fixed1000-native-profile.cpp +777 -0
- package/native/kinetix-fixed1000-native-profile.hpp +58 -0
- package/native/kinetix-fixed1000-physics.cpp +887 -0
- package/native/kinetix-fixed1000-physics.hpp +28 -0
- package/native/kinetix-installed-f64-renderer.cpp +344 -0
- package/native/kinetix-installed-f64-renderer.hpp +35 -0
- package/native/kinetix-installed-f64-runtime.cpp +1085 -0
- package/native/kinetix-installed-f64-runtime.hpp +141 -0
- package/native/kinetix-installed-fixed1000-data.hpp +77 -0
- package/native/kinetix-native-main.cpp +37 -0
- package/native/kinetix-native-runtime.cpp +20 -0
- package/native/kinetix-native-runtime.hpp +25 -0
- package/native/kinetix-render-projection.cpp +20 -0
- package/native/kinetix-render-projection.hpp +14 -0
- package/package.json +65 -0
- package/runtime.d.ts +76 -0
- package/scripts/build-native.mjs +67 -0
- package/scripts/emit-product-digests.mjs +33 -0
- package/scripts/preflight.mjs +76 -0
- package/src/index.mjs +57 -0
- package/src/kinetix-authoring.mjs +69 -0
- package/src/kinetix-baker.mjs +587 -0
- package/src/kinetix-deterministic-math.mjs +1044 -0
- package/src/kinetix-fixed1000-runtime-adapter.mjs +33 -0
- package/src/kinetix-fixed1000-runtime.mjs +954 -0
- package/src/kinetix-installed-f64-reference.mjs +157 -0
- package/src/kinetix-installed-f64-render-frames.mjs +53 -0
- package/src/kinetix-installed-f64-renderer.mjs +240 -0
- package/src/kinetix-installed-f64-runtime-adapter.mjs +68 -0
- package/src/kinetix-installed-f64-runtime.mjs +607 -0
- package/src/kinetix-installed-mechanics.mjs +377 -0
- package/src/kinetix-installed-systems.mjs +181 -0
- package/src/kinetix-native-product.mjs +72 -0
- package/src/kinetix-product-contract.mjs +121 -0
- package/src/kinetix-project-compiler.mjs +1017 -0
- package/src/kinetix-render-projection.mjs +28 -0
- package/src/kinetix-runtime-adapter-utils.mjs +168 -0
- package/src/kinetix-runtime-contract.mjs +54 -0
- package/src/kinetix-session-config.mjs +170 -0
- package/src/kinetix-source-snapshot.mjs +24 -0
- package/src/kinetix-survival-runtime-adapter.mjs +305 -0
- package/src/kinetix-survival-runtime.generated.mjs +1 -0
- package/src/kinetix-world-kernel.mjs +580 -0
- package/src/kinetix-world-runtime.mjs +171 -0
- package/src/runtime.mjs +14 -0
- package/src/shared/f64-bits.mjs +14 -0
- package/src/shared/kinetix-envelope-v1.mjs +589 -0
- package/src/shared/render-records-v1.mjs +168 -0
- package/src/shared/sha256.mjs +73 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
import { sha256Hex } from './shared/sha256.mjs';
|
|
2
|
+
|
|
3
|
+
export { sha256Hex } from './shared/sha256.mjs';
|
|
4
|
+
|
|
5
|
+
// Browser-safe sim core for the deterministic fixed-point component runtime
|
|
6
|
+
// (pack profile "fixed-1000"). Generic, parameterized component families —
|
|
7
|
+
// every constant comes from the data pack, no game-specific literals live
|
|
8
|
+
// here. The families:
|
|
9
|
+
//
|
|
10
|
+
// FixedTurnsMath — scale-N integer math + turns-based polynomial trig
|
|
11
|
+
// GridCellCodec — 3-byte base64url grid-cell decode
|
|
12
|
+
// GridTrackCollider — grid cells → static wall boxes (straight sides,
|
|
13
|
+
// corner arc segments, pass-through cells skipped)
|
|
14
|
+
// ArenaBounds — bounded square free-roam arena (4 static walls)
|
|
15
|
+
// ArcadeDriftVehicle — heading/speed/velocity traction model (velocity
|
|
16
|
+
// lags heading above the slip speed = drift)
|
|
17
|
+
// WaypointRingDriver — pure-pursuit bot over a CCW-sorted cell-centre ring
|
|
18
|
+
// LapProgress — checkpoint bitmask + forward line-crossing detector
|
|
19
|
+
// with anti-farming reset and finish/DNF frames
|
|
20
|
+
// GridSpawn — 2-wide staggered start grid behind the finish line
|
|
21
|
+
// ScatterSpawn — spread grid inside the arena
|
|
22
|
+
// MatchPhaseMachine — frame-counted lobby/countdown/active/finished loop
|
|
23
|
+
// BotSlotResolution — null input packet → bot, present packet → human
|
|
24
|
+
// PhysicsWriteback — transient physics world per tick (statics + dynamic
|
|
25
|
+
// spheres), one cold solve, positions-only rounded
|
|
26
|
+
// readback
|
|
27
|
+
//
|
|
28
|
+
// Numeric contract: plain JS numbers holding integers only; Math.round /
|
|
29
|
+
// Math.trunc / Math.floor on integer-valued expressions; zero RNG, zero
|
|
30
|
+
// clock, zero transcendentals (trig is an integer polynomial in turns).
|
|
31
|
+
//
|
|
32
|
+
// The physics solver is injected (options.createPhysicsWorld /
|
|
33
|
+
// options.stepPhysicsWorld) so this module stays dependency-free; the caller
|
|
34
|
+
// wires in the deterministic 3D physics slice.
|
|
35
|
+
//
|
|
36
|
+
// Canonical hash layout (canonicalFixedTickHash / canonicalFixedStateBytes):
|
|
37
|
+
// u64le frame
|
|
38
|
+
// str track (u32le byte length + utf8 bytes)
|
|
39
|
+
// i64le phase, phaseStartFrame, lobbyDeadlineFrame
|
|
40
|
+
// field-major per-slot arrays, in state order:
|
|
41
|
+
// carX, carZ, carVX, carVZ, speed, yawTurns (i64le each element)
|
|
42
|
+
// drifting (u8 each element)
|
|
43
|
+
// lap, cellsVisited, lapProj, finishFrame (i64le each element)
|
|
44
|
+
// isBot (u8 each element)
|
|
45
|
+
// tickHash = sha256(buffer) hex.
|
|
46
|
+
|
|
47
|
+
// ─── FixedTurnsMath ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export function createFixedTurnsMath(scale) {
|
|
50
|
+
const ONE = scale;
|
|
51
|
+
const mul = (a, b) => Math.trunc((a * b) / ONE);
|
|
52
|
+
const div = (a, b) => (b === 0 ? 0 : Math.trunc((a * ONE) / b));
|
|
53
|
+
const clamp = (v, min, max) => (v < min ? min : v > max ? max : v);
|
|
54
|
+
const fabs = (v) => (v < 0 ? -v : v);
|
|
55
|
+
const fsign = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0);
|
|
56
|
+
const lerp = (a, b, t) => a + mul(b - a, t);
|
|
57
|
+
|
|
58
|
+
// 5th-order odd minimax fit of sin over the first quarter turn, evaluated in
|
|
59
|
+
// fixed point. Angles are in TURNS (one revolution == ONE).
|
|
60
|
+
function sinQuarter(x) {
|
|
61
|
+
const u = div(x, ONE / 4);
|
|
62
|
+
const u2 = mul(u, u);
|
|
63
|
+
const u3 = mul(u2, u);
|
|
64
|
+
const u5 = mul(u3, u2);
|
|
65
|
+
const a1 = 1571;
|
|
66
|
+
const a3 = -646;
|
|
67
|
+
const a5 = 80;
|
|
68
|
+
return mul(a1, u) + mul(a3, u3) + mul(a5, u5);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fsin(turns) {
|
|
72
|
+
const t = ((turns % ONE) + ONE) % ONE;
|
|
73
|
+
if (t < ONE / 4) return clamp(sinQuarter(t), -ONE, ONE);
|
|
74
|
+
if (t < ONE / 2) return clamp(sinQuarter(ONE / 2 - t), -ONE, ONE);
|
|
75
|
+
if (t < (3 * ONE) / 4) return clamp(-sinQuarter(t - ONE / 2), -ONE, ONE);
|
|
76
|
+
return clamp(-sinQuarter(ONE - t), -ONE, ONE);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fcos = (turns) => fsin(turns + ONE / 4);
|
|
80
|
+
const F = (value) => Math.round(value * ONE);
|
|
81
|
+
|
|
82
|
+
return { ONE, F, mul, div, clamp, fabs, fsign, lerp, fsin, fcos };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── GridCellCodec (3-byte base64url) ────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const B64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
88
|
+
const B64URL_LOOKUP = (() => {
|
|
89
|
+
const table = new Int16Array(128).fill(-1);
|
|
90
|
+
for (let i = 0; i < B64URL_ALPHABET.length; i += 1) table[B64URL_ALPHABET.charCodeAt(i)] = i;
|
|
91
|
+
return table;
|
|
92
|
+
})();
|
|
93
|
+
|
|
94
|
+
function base64urlToBytes(str) {
|
|
95
|
+
const clean = str.replace(/=+$/, '');
|
|
96
|
+
const byteLength = Math.floor((clean.length * 6) / 8);
|
|
97
|
+
const bytes = new Uint8Array(byteLength);
|
|
98
|
+
let bits = 0;
|
|
99
|
+
let bitCount = 0;
|
|
100
|
+
let out = 0;
|
|
101
|
+
for (let i = 0; i < clean.length; i += 1) {
|
|
102
|
+
const value = B64URL_LOOKUP[clean.charCodeAt(i)];
|
|
103
|
+
if (value === undefined || value < 0) {
|
|
104
|
+
throw new Error(`base64urlToBytes: invalid character '${clean[i]}' at index ${i}`);
|
|
105
|
+
}
|
|
106
|
+
bits = (bits << 6) | value;
|
|
107
|
+
bitCount += 6;
|
|
108
|
+
if (bitCount >= 8) {
|
|
109
|
+
bitCount -= 8;
|
|
110
|
+
bytes[out++] = (bits >> bitCount) & 0xff;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return bytes;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function decodeGridCells(encoded) {
|
|
117
|
+
if (encoded === '') return [];
|
|
118
|
+
const bytes = base64urlToBytes(encoded);
|
|
119
|
+
const cells = [];
|
|
120
|
+
for (let i = 0; i + 2 < bytes.length; i += 3) {
|
|
121
|
+
const gridX = bytes[i] - 128;
|
|
122
|
+
const gridZ = bytes[i + 1] - 128;
|
|
123
|
+
const packed = bytes[i + 2];
|
|
124
|
+
const typeId = (packed >> 2) & 0x3f;
|
|
125
|
+
const orientation = packed & 0x03;
|
|
126
|
+
cells.push({ gridX, gridZ, typeId, orientation });
|
|
127
|
+
}
|
|
128
|
+
return cells;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Shared grid helpers ─────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function orientTurnsTable(ONE) {
|
|
134
|
+
return [0, ONE / 4, ONE / 2, (3 * ONE) / 4];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── GridTrackCollider ───────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
export function cookGridTrackColliders(cells, grid, params, math) {
|
|
140
|
+
const { ONE, mul, fsin, fcos } = math;
|
|
141
|
+
const S = grid.gridScale;
|
|
142
|
+
const CELL_HALF = grid.cellRaw / 2;
|
|
143
|
+
const H_THICK = mul(params.wallHalfThick, S);
|
|
144
|
+
const H_HEIGHT = mul(params.wallHalfHeightRaw, S);
|
|
145
|
+
const H_LEN = mul(CELL_HALF, S);
|
|
146
|
+
const ARC_SPAN_TURNS = -(ONE / 4);
|
|
147
|
+
const ARC_CENTER_X = -CELL_HALF;
|
|
148
|
+
const ARC_CENTER_Z = CELL_HALF;
|
|
149
|
+
const OUTER_R = 2 * CELL_HALF - params.wallHalfThick;
|
|
150
|
+
const OUTER_SEG = params.outerArcSegments;
|
|
151
|
+
const OUTER_SEG_HALF_LEN = mul(Math.trunc(mul(OUTER_R, params.halfPiFixed) / (OUTER_SEG * 2)), S);
|
|
152
|
+
const INNER_R = params.wallHalfThick;
|
|
153
|
+
const INNER_SEG = params.innerArcSegments;
|
|
154
|
+
const INNER_SEG_HALF_LEN = mul(Math.trunc(mul(INNER_R, params.halfPiFixed) / (INNER_SEG * 2)), S);
|
|
155
|
+
const ORIENT_TURNS = orientTurnsTable(ONE);
|
|
156
|
+
const cornerTypeIds = new Set(grid.cornerTypeIds);
|
|
157
|
+
const passThroughTypeIds = new Set(grid.passThroughTypeIds);
|
|
158
|
+
|
|
159
|
+
const turnsToRadians = (turns) => mul(turns, params.twoPiFixed);
|
|
160
|
+
const box = (id, x, z, ryTurns, half) => ({
|
|
161
|
+
id,
|
|
162
|
+
kind: 'static',
|
|
163
|
+
x,
|
|
164
|
+
y: params.wallY,
|
|
165
|
+
z,
|
|
166
|
+
vx: 0,
|
|
167
|
+
vy: 0,
|
|
168
|
+
vz: 0,
|
|
169
|
+
ry: turnsToRadians(ryTurns),
|
|
170
|
+
shape: { type: 'box', halfX: half[0], halfY: half[1], halfZ: half[2] },
|
|
171
|
+
layer: params.layer,
|
|
172
|
+
mask: params.mask,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const bodies = [];
|
|
176
|
+
let n = 0;
|
|
177
|
+
const addArcWall = (wcx, wcz, arcStartTurns, radius, numSeg, segHalfLen) => {
|
|
178
|
+
for (let i = 0; i < numSeg; i += 1) {
|
|
179
|
+
const frac = Math.trunc(((2 * i + 1) * ONE) / (2 * numSeg));
|
|
180
|
+
const aMid = arcStartTurns + mul(frac, ARC_SPAN_TURNS);
|
|
181
|
+
const wx = wcx + mul(mul(radius, fcos(aMid)), S);
|
|
182
|
+
const wz = wcz + mul(mul(radius, fsin(aMid)), S);
|
|
183
|
+
bodies.push(box(`${params.bodyIdPrefix}${n++}`, wx, wz, -aMid, [H_THICK, H_HEIGHT, segHalfLen]));
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
for (const cell of cells) {
|
|
188
|
+
const { gridX, gridZ, typeId, orientation } = cell;
|
|
189
|
+
if (passThroughTypeIds.has(typeId)) continue;
|
|
190
|
+
|
|
191
|
+
const cx = mul((2 * gridX + 1) * (grid.cellRaw / 2), S);
|
|
192
|
+
const cz = mul((2 * gridZ + 1) * (grid.cellRaw / 2), S);
|
|
193
|
+
const turns = ORIENT_TURNS[orientation] ?? 0;
|
|
194
|
+
const cr = fcos(turns);
|
|
195
|
+
const sr = fsin(turns);
|
|
196
|
+
|
|
197
|
+
if (cornerTypeIds.has(typeId)) {
|
|
198
|
+
const wcx = cx + mul(mul(ARC_CENTER_X, cr) + mul(ARC_CENTER_Z, sr), S);
|
|
199
|
+
const wcz = cz + mul(mul(-ARC_CENTER_X, sr) + mul(ARC_CENTER_Z, cr), S);
|
|
200
|
+
const arcStartTurns = -turns;
|
|
201
|
+
addArcWall(wcx, wcz, arcStartTurns, OUTER_R, OUTER_SEG, OUTER_SEG_HALF_LEN);
|
|
202
|
+
addArcWall(wcx, wcz, arcStartTurns, INNER_R, INNER_SEG, INNER_SEG_HALF_LEN);
|
|
203
|
+
} else {
|
|
204
|
+
for (const side of [-1, 1]) {
|
|
205
|
+
const lx = side * params.wallLateral;
|
|
206
|
+
const wx = cx + mul(mul(lx, cr), S);
|
|
207
|
+
const wz = cz + mul(mul(-lx, sr), S);
|
|
208
|
+
bodies.push(box(`${params.bodyIdPrefix}${n++}`, wx, wz, turns, [H_THICK, H_HEIGHT, H_LEN]));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return bodies;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── ArenaBounds ─────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
export function cookArenaBounds(params, math) {
|
|
218
|
+
const { ONE } = math;
|
|
219
|
+
const halfF = params.halfUnits * ONE;
|
|
220
|
+
const wall = (id, x, z, hx, hz) => ({
|
|
221
|
+
id,
|
|
222
|
+
kind: 'static',
|
|
223
|
+
x,
|
|
224
|
+
y: params.wallY,
|
|
225
|
+
z,
|
|
226
|
+
vx: 0,
|
|
227
|
+
vy: 0,
|
|
228
|
+
vz: 0,
|
|
229
|
+
shape: { type: 'box', halfX: hx, halfY: params.wallHalfHeight, halfZ: hz },
|
|
230
|
+
layer: params.layer,
|
|
231
|
+
mask: params.mask,
|
|
232
|
+
});
|
|
233
|
+
const span = halfF + params.wallThick;
|
|
234
|
+
const [north, south, west, east] = params.bodyIds;
|
|
235
|
+
return [
|
|
236
|
+
wall(north, params.centerX, params.centerZ - halfF, span, params.wallThick),
|
|
237
|
+
wall(south, params.centerX, params.centerZ + halfF, span, params.wallThick),
|
|
238
|
+
wall(west, params.centerX - halfF, params.centerZ, params.wallThick, span),
|
|
239
|
+
wall(east, params.centerX + halfF, params.centerZ, params.wallThick, span),
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ─── ScatterSpawn (arena) ────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export function deriveScatterSpawns(slotCount, arenaParams, params) {
|
|
246
|
+
const cols = params.cols;
|
|
247
|
+
const spacing = params.spacing;
|
|
248
|
+
const poses = [];
|
|
249
|
+
for (let i = 0; i < slotCount; i += 1) {
|
|
250
|
+
const col = i % cols;
|
|
251
|
+
const row = Math.floor(i / cols);
|
|
252
|
+
poses.push({
|
|
253
|
+
x: arenaParams.centerX + (col - (cols - 1) / 2) * spacing,
|
|
254
|
+
z: arenaParams.centerZ + (row - 1) * spacing,
|
|
255
|
+
yawTurns: 0,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return poses;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ─── GridSpawn (race grid behind the finish line) ────────────────────────────
|
|
262
|
+
|
|
263
|
+
export function findFinishCell(cells, finishTypeId) {
|
|
264
|
+
return cells.find((c) => c.typeId === finishTypeId) ?? null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function deriveGridSpawns(cells, slotCount, grid, params, math) {
|
|
268
|
+
const { ONE, mul, fsin, fcos } = math;
|
|
269
|
+
if (slotCount < params.minSlots || slotCount > params.maxSlots) {
|
|
270
|
+
throw new Error(`deriveGridSpawns: slotCount ${slotCount} out of range [${params.minSlots}, ${params.maxSlots}]`);
|
|
271
|
+
}
|
|
272
|
+
const finish = findFinishCell(cells, grid.finishTypeId);
|
|
273
|
+
if (!finish) {
|
|
274
|
+
throw new Error(`deriveGridSpawns: grid has no finish cell (typeId ${grid.finishTypeId}) — cannot seat the field`);
|
|
275
|
+
}
|
|
276
|
+
const rowsNeeded = Math.ceil(slotCount / 2);
|
|
277
|
+
if (cells.length < rowsNeeded) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`deriveGridSpawns: grid too small to seat ${slotCount} slots ` +
|
|
280
|
+
`(needs ${rowsNeeded} rows, grid has ${cells.length} cells)`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const S = grid.gridScale;
|
|
285
|
+
const baseX = mul((2 * finish.gridX + 1) * (grid.cellRaw / 2), S);
|
|
286
|
+
const baseZ = mul((2 * finish.gridZ + 1) * (grid.cellRaw / 2), S);
|
|
287
|
+
const ORIENT_TURNS = orientTurnsTable(ONE);
|
|
288
|
+
const turns = ORIENT_TURNS[finish.orientation] ?? 0;
|
|
289
|
+
const cos = fcos(turns);
|
|
290
|
+
const sin = fsin(turns);
|
|
291
|
+
|
|
292
|
+
const poses = [];
|
|
293
|
+
for (let i = 0; i < slotCount; i += 1) {
|
|
294
|
+
const col = i % 2;
|
|
295
|
+
const row = Math.floor(i / 2);
|
|
296
|
+
const lateral = (col === 0 ? -1 : 1) * params.lateral;
|
|
297
|
+
const back = -row * params.rowBack;
|
|
298
|
+
poses.push({
|
|
299
|
+
x: baseX + mul(lateral, cos) - mul(back, sin),
|
|
300
|
+
z: baseZ + mul(lateral, sin) + mul(back, cos),
|
|
301
|
+
yawTurns: turns,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
return poses;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── WaypointRingDriver ──────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
function upperHalf(x, z) {
|
|
310
|
+
return z < 0 || (z === 0 && x < 0) ? 1 : 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function buildWaypointRing(cells, grid, math) {
|
|
314
|
+
const { mul } = math;
|
|
315
|
+
const centre = (g) => mul((2 * g + 1) * (grid.cellRaw / 2), grid.gridScale);
|
|
316
|
+
const pts = cells.map((c) => ({ x: centre(c.gridX), z: centre(c.gridZ) }));
|
|
317
|
+
if (pts.length === 0) return { wx: [], wz: [] };
|
|
318
|
+
let sx = 0;
|
|
319
|
+
let sz = 0;
|
|
320
|
+
for (const p of pts) {
|
|
321
|
+
sx += p.x;
|
|
322
|
+
sz += p.z;
|
|
323
|
+
}
|
|
324
|
+
const cxc = Math.trunc(sx / pts.length);
|
|
325
|
+
const czc = Math.trunc(sz / pts.length);
|
|
326
|
+
const rel = pts.map((p) => ({ x: p.x - cxc, z: p.z - czc }));
|
|
327
|
+
rel.sort((a, b) => {
|
|
328
|
+
const ha = upperHalf(a.x, a.z);
|
|
329
|
+
const hb = upperHalf(b.x, b.z);
|
|
330
|
+
if (ha !== hb) return ha - hb;
|
|
331
|
+
const cross = a.x * b.z - a.z * b.x;
|
|
332
|
+
if (cross > 0) return -1;
|
|
333
|
+
if (cross < 0) return 1;
|
|
334
|
+
return a.x * a.x + a.z * a.z - (b.x * b.x + b.z * b.z);
|
|
335
|
+
});
|
|
336
|
+
return { wx: rel.map((r) => r.x + cxc), wz: rel.map((r) => r.z + czc) };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function approxMag(x, z, math) {
|
|
340
|
+
const ax = math.fabs(x);
|
|
341
|
+
const az = math.fabs(z);
|
|
342
|
+
const hi = ax > az ? ax : az;
|
|
343
|
+
const lo = ax > az ? az : ax;
|
|
344
|
+
return hi + Math.trunc(lo / 2);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function waypointRingDrive(ring, view, params, math) {
|
|
348
|
+
const { ONE, mul, div, clamp, fsin, fcos } = math;
|
|
349
|
+
const n = ring.wx.length;
|
|
350
|
+
if (n === 0) return { steerX: 0, throttle: 0, handbrake: false, bot: true };
|
|
351
|
+
|
|
352
|
+
let nearest = 0;
|
|
353
|
+
let best = Infinity;
|
|
354
|
+
for (let i = 0; i < n; i += 1) {
|
|
355
|
+
const dx = ring.wx[i] - view.x;
|
|
356
|
+
const dz = ring.wz[i] - view.z;
|
|
357
|
+
const d = dx * dx + dz * dz;
|
|
358
|
+
if (d < best) {
|
|
359
|
+
best = d;
|
|
360
|
+
nearest = i;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const t = (nearest + params.lookahead) % n;
|
|
364
|
+
const dirX = ring.wx[t] - view.x;
|
|
365
|
+
const dirZ = ring.wz[t] - view.z;
|
|
366
|
+
|
|
367
|
+
const fx = fsin(view.yawTurns);
|
|
368
|
+
const fz = fcos(view.yawTurns);
|
|
369
|
+
const mag = approxMag(dirX, dirZ, math);
|
|
370
|
+
if (mag === 0) return { steerX: 0, throttle: ONE, handbrake: false, bot: true };
|
|
371
|
+
|
|
372
|
+
const lateral = div(mul(fz, dirX) - mul(fx, dirZ), mag);
|
|
373
|
+
const forward = div(mul(fx, dirX) + mul(fz, dirZ), mag);
|
|
374
|
+
const steerX = clamp(mul(params.steerGain, lateral), -ONE, ONE);
|
|
375
|
+
const throttle = forward > 0 ? ONE : params.sharpTurnThrottle;
|
|
376
|
+
return { steerX, throttle, handbrake: false, bot: true };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─── LapProgress ─────────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
const cellKey = (gx, gz) => `${gx},${gz}`;
|
|
382
|
+
|
|
383
|
+
export function buildLapProgressInfo(cells, grid, params, math) {
|
|
384
|
+
const { ONE, mul, fsin, fcos } = math;
|
|
385
|
+
const finish = findFinishCell(cells, grid.finishTypeId);
|
|
386
|
+
if (!finish) {
|
|
387
|
+
return { enabled: false, cx: 0, cz: 0, fwdX: 0, fwdZ: 0, rightX: 0, rightZ: 0, requiredBit: new Map(), requiredMask: 0 };
|
|
388
|
+
}
|
|
389
|
+
const cx = mul((2 * finish.gridX + 1) * (grid.cellRaw / 2), grid.gridScale);
|
|
390
|
+
const cz = mul((2 * finish.gridZ + 1) * (grid.cellRaw / 2), grid.gridScale);
|
|
391
|
+
const ORIENT_TURNS = orientTurnsTable(ONE);
|
|
392
|
+
const turns = ORIENT_TURNS[finish.orientation] ?? 0;
|
|
393
|
+
const fwdX = fsin(turns);
|
|
394
|
+
const fwdZ = fcos(turns);
|
|
395
|
+
|
|
396
|
+
const required = cells
|
|
397
|
+
.filter((c) => c.typeId !== grid.finishTypeId)
|
|
398
|
+
.slice()
|
|
399
|
+
.sort((a, b) => (a.gridX - b.gridX) || (a.gridZ - b.gridZ));
|
|
400
|
+
const requiredBit = new Map();
|
|
401
|
+
let bit = 0;
|
|
402
|
+
for (const c of required) {
|
|
403
|
+
if (bit >= params.maxRequiredBits) break;
|
|
404
|
+
requiredBit.set(cellKey(c.gridX, c.gridZ), bit);
|
|
405
|
+
bit += 1;
|
|
406
|
+
}
|
|
407
|
+
const requiredMask = bit === 0 ? 0 : (bit >= 31 ? 0x7fffffff : (1 << bit) - 1);
|
|
408
|
+
|
|
409
|
+
return { enabled: true, cx, cz, fwdX, fwdZ, rightX: fwdZ, rightZ: -fwdX, requiredBit, requiredMask };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function stepLapProgress(info, car, frame, grid, params, math) {
|
|
413
|
+
const { mul, fabs } = math;
|
|
414
|
+
if (!info.enabled || car.finishFrame >= 0) {
|
|
415
|
+
return { ...car, justFinished: false };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const cellWorld = mul(grid.cellRaw, grid.gridScale);
|
|
419
|
+
const lateralOnLine = Math.trunc(cellWorld / 2);
|
|
420
|
+
|
|
421
|
+
let cellsVisited = car.cellsVisited;
|
|
422
|
+
const gx = Math.floor(car.x / cellWorld);
|
|
423
|
+
const gz = Math.floor(car.z / cellWorld);
|
|
424
|
+
const bit = info.requiredBit.get(cellKey(gx, gz));
|
|
425
|
+
if (bit !== undefined) cellsVisited |= 1 << bit;
|
|
426
|
+
|
|
427
|
+
const relX = car.x - info.cx;
|
|
428
|
+
const relZ = car.z - info.cz;
|
|
429
|
+
const forwardProj = mul(relX, info.fwdX) + mul(relZ, info.fwdZ);
|
|
430
|
+
const lateralProj = fabs(mul(relX, info.rightX) + mul(relZ, info.rightZ));
|
|
431
|
+
|
|
432
|
+
let lap = car.lap;
|
|
433
|
+
let finishFrame = car.finishFrame;
|
|
434
|
+
let justFinished = false;
|
|
435
|
+
|
|
436
|
+
const onLine = lateralProj <= lateralOnLine;
|
|
437
|
+
const noTeleport = fabs(forwardProj - car.lapProj) < params.noTeleport;
|
|
438
|
+
const crossedForward = car.lapProj < 0 && forwardProj >= 0;
|
|
439
|
+
if (onLine && noTeleport && crossedForward) {
|
|
440
|
+
if (cellsVisited === info.requiredMask && info.requiredMask > 0) {
|
|
441
|
+
lap += 1;
|
|
442
|
+
cellsVisited = 0;
|
|
443
|
+
if (lap >= params.totalLaps) {
|
|
444
|
+
finishFrame = frame;
|
|
445
|
+
justFinished = true;
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
cellsVisited = 0;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { ...car, cellsVisited, lap, finishFrame, lapProj: forwardProj, justFinished };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── ArcadeDriftVehicle ──────────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
export function stepArcadeDriftVehicle(car, steerX, throttle, params, math) {
|
|
458
|
+
const { ONE, mul, div, clamp, lerp, fabs, fsign, fsin, fcos } = math;
|
|
459
|
+
const steer = clamp(steerX, -ONE, ONE);
|
|
460
|
+
const thr = clamp(throttle, -ONE, ONE);
|
|
461
|
+
|
|
462
|
+
const target = thr >= 0 ? mul(thr, params.maxSpeed) : mul(thr, params.maxReverse);
|
|
463
|
+
const braking = thr < 0 && car.speed > 0;
|
|
464
|
+
const rate = braking ? params.brakeRate : params.accelRate;
|
|
465
|
+
let speed = lerp(car.speed, target, rate);
|
|
466
|
+
if (fabs(speed) < params.stopDeadband) speed = 0;
|
|
467
|
+
|
|
468
|
+
const grip = clamp(div(fabs(speed), params.maxSpeed), params.gripMin, ONE);
|
|
469
|
+
const dir = speed === 0 ? 0 : fsign(speed);
|
|
470
|
+
const yawDelta = mul(mul(steer, params.turnPerTick), grip) * dir;
|
|
471
|
+
let yawTurns = (car.yawTurns + yawDelta) % ONE;
|
|
472
|
+
if (yawTurns < 0) yawTurns += ONE;
|
|
473
|
+
|
|
474
|
+
const fx = fsin(yawTurns);
|
|
475
|
+
const fz = fcos(yawTurns);
|
|
476
|
+
const desiredVX = mul(fx, speed);
|
|
477
|
+
const desiredVZ = mul(fz, speed);
|
|
478
|
+
const drifting = fabs(speed) > params.slipSpeed && fabs(steer) > params.driftSteer;
|
|
479
|
+
const traction = fabs(speed) > params.slipSpeed ? params.tractionFast : params.tractionSlow;
|
|
480
|
+
const vx = lerp(car.vx, desiredVX, traction);
|
|
481
|
+
const vz = lerp(car.vz, desiredVZ, traction);
|
|
482
|
+
|
|
483
|
+
return { yawTurns, speed, vx, vz, drifting };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── MatchPhaseMachine constants ─────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
export const PHASE_LOBBY = 0;
|
|
489
|
+
export const PHASE_COUNTDOWN = 1;
|
|
490
|
+
export const PHASE_ACTIVE = 2;
|
|
491
|
+
export const PHASE_FINISHED = 3;
|
|
492
|
+
|
|
493
|
+
const PHASE_BY_NAME = { lobby: PHASE_LOBBY, countdown: PHASE_COUNTDOWN, active: PHASE_ACTIVE, finished: PHASE_FINISHED };
|
|
494
|
+
const PHASE_NAMES = ['lobby', 'countdown', 'active', 'finished'];
|
|
495
|
+
|
|
496
|
+
// ─── Runtime ─────────────────────────────────────────────────────────────────
|
|
497
|
+
|
|
498
|
+
function requireOption(options, name) {
|
|
499
|
+
const value = options?.[name];
|
|
500
|
+
if (typeof value !== 'function') {
|
|
501
|
+
throw new Error(`createFixedRuntime: options.${name} must be a function (inject the deterministic physics slice)`);
|
|
502
|
+
}
|
|
503
|
+
return value;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function toPhysicsBody(body, scale) {
|
|
507
|
+
const shape = body.shape.type === 'box'
|
|
508
|
+
? {
|
|
509
|
+
type: 'box',
|
|
510
|
+
halfX: body.shape.halfX / scale,
|
|
511
|
+
halfY: body.shape.halfY / scale,
|
|
512
|
+
halfZ: body.shape.halfZ / scale,
|
|
513
|
+
}
|
|
514
|
+
: { type: 'sphere', radius: body.shape.radius / scale };
|
|
515
|
+
return {
|
|
516
|
+
...body,
|
|
517
|
+
x: body.x / scale,
|
|
518
|
+
y: body.y / scale,
|
|
519
|
+
z: body.z / scale,
|
|
520
|
+
vx: body.vx / scale,
|
|
521
|
+
vy: body.vy / scale,
|
|
522
|
+
vz: body.vz / scale,
|
|
523
|
+
rx: body.rx === undefined ? undefined : body.rx / scale,
|
|
524
|
+
ry: body.ry === undefined ? undefined : body.ry / scale,
|
|
525
|
+
rz: body.rz === undefined ? undefined : body.rz / scale,
|
|
526
|
+
avx: body.avx === undefined ? undefined : body.avx / scale,
|
|
527
|
+
avy: body.avy === undefined ? undefined : body.avy / scale,
|
|
528
|
+
avz: body.avz === undefined ? undefined : body.avz / scale,
|
|
529
|
+
shape,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function createFixedRuntime(pack, options = {}) {
|
|
534
|
+
if (pack.numericProfile !== 'fixed-1000') {
|
|
535
|
+
throw new Error(`createFixedRuntime: unsupported numericProfile '${pack.numericProfile}' (expected 'fixed-1000')`);
|
|
536
|
+
}
|
|
537
|
+
const createPhysicsWorld = requireOption(options, 'createPhysicsWorld');
|
|
538
|
+
const createTrustedPhysicsWorld = options.createTrustedPhysicsWorld ?? createPhysicsWorld;
|
|
539
|
+
if (typeof createTrustedPhysicsWorld !== 'function') {
|
|
540
|
+
throw new Error('createFixedRuntime: options.createTrustedPhysicsWorld must be a function');
|
|
541
|
+
}
|
|
542
|
+
const stepPhysicsWorld = requireOption(options, 'stepPhysicsWorld');
|
|
543
|
+
|
|
544
|
+
const math = createFixedTurnsMath(pack.scale);
|
|
545
|
+
const grid = pack.grid;
|
|
546
|
+
const c = pack.components;
|
|
547
|
+
const cells = decodeGridCells(grid.encoded);
|
|
548
|
+
|
|
549
|
+
const initialPhase = PHASE_BY_NAME[pack.initialPhase];
|
|
550
|
+
if (initialPhase === undefined) {
|
|
551
|
+
throw new Error(`createFixedRuntime: unknown initialPhase '${pack.initialPhase}'`);
|
|
552
|
+
}
|
|
553
|
+
const slotCount = pack.slotCount;
|
|
554
|
+
|
|
555
|
+
const cooked = {
|
|
556
|
+
cells,
|
|
557
|
+
walls: cookGridTrackColliders(cells, grid, c.gridTrackCollider, math),
|
|
558
|
+
arenaWalls: cookArenaBounds(c.arenaBounds, math),
|
|
559
|
+
lapInfo: buildLapProgressInfo(cells, grid, c.lapProgress, math),
|
|
560
|
+
botRing: buildWaypointRing(cells, grid, math),
|
|
561
|
+
};
|
|
562
|
+
createPhysicsWorld(cooked.walls.map((body) => toPhysicsBody(body, pack.scale)));
|
|
563
|
+
createPhysicsWorld(cooked.arenaWalls.map((body) => toPhysicsBody(body, pack.scale)));
|
|
564
|
+
|
|
565
|
+
const spawns = initialPhase === PHASE_LOBBY
|
|
566
|
+
? deriveScatterSpawns(slotCount, c.arenaBounds, c.scatterSpawn)
|
|
567
|
+
: deriveGridSpawns(cells, slotCount, grid, c.gridSpawn, math);
|
|
568
|
+
const fill = (v) => new Array(slotCount).fill(v);
|
|
569
|
+
|
|
570
|
+
const state = {
|
|
571
|
+
frame: 0,
|
|
572
|
+
track: grid.encoded,
|
|
573
|
+
phase: initialPhase,
|
|
574
|
+
phaseStartFrame: 0,
|
|
575
|
+
lobbyDeadlineFrame: initialPhase === PHASE_LOBBY ? c.matchPhaseMachine.lobbyInitialFrames : 0,
|
|
576
|
+
carX: spawns.map((s) => s.x),
|
|
577
|
+
carZ: spawns.map((s) => s.z),
|
|
578
|
+
carVX: fill(0),
|
|
579
|
+
carVZ: fill(0),
|
|
580
|
+
speed: fill(0),
|
|
581
|
+
yawTurns: spawns.map((s) => s.yawTurns),
|
|
582
|
+
drifting: fill(false),
|
|
583
|
+
lap: fill(0),
|
|
584
|
+
cellsVisited: fill(0),
|
|
585
|
+
lapProj: fill(0),
|
|
586
|
+
finishFrame: fill(-1),
|
|
587
|
+
isBot: Array.from({ length: slotCount }, (_, i) => i >= pack.humanCount),
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
return { pack, math, cooked, state, physics: { createPhysicsWorld: createTrustedPhysicsWorld, stepPhysicsWorld } };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// BotSlotResolution: null packet → bot marker, present packet → human input.
|
|
594
|
+
function decodeSlotInput(raw) {
|
|
595
|
+
if (raw === null || raw === undefined) {
|
|
596
|
+
return { steerX: 0, throttle: 0, handbrake: false, bot: true };
|
|
597
|
+
}
|
|
598
|
+
if (typeof raw !== 'object') throw new Error('KINETIX_FIXED_INPUT_INVALID');
|
|
599
|
+
const keys = Reflect.ownKeys(raw);
|
|
600
|
+
if (keys.length !== 3
|
|
601
|
+
|| !keys.includes('steerX') || !keys.includes('throttle') || !keys.includes('handbrake')
|
|
602
|
+
|| !Number.isSafeInteger(raw.steerX) || raw.steerX < -1000 || raw.steerX > 1000
|
|
603
|
+
|| !Number.isSafeInteger(raw.throttle) || raw.throttle < -1000 || raw.throttle > 1000
|
|
604
|
+
|| typeof raw.handbrake !== 'boolean') {
|
|
605
|
+
throw new Error('KINETIX_FIXED_INPUT_INVALID');
|
|
606
|
+
}
|
|
607
|
+
return { steerX: raw.steerX, throttle: raw.throttle, handbrake: raw.handbrake === true, bot: false };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function freezeSlot(i, speed, vx, vz, drifting) {
|
|
611
|
+
speed[i] = 0;
|
|
612
|
+
vx[i] = 0;
|
|
613
|
+
vz[i] = 0;
|
|
614
|
+
drifting[i] = false;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function tickFixedRuntime(runtime, inputs) {
|
|
618
|
+
const { pack, math, cooked, state, physics } = runtime;
|
|
619
|
+
const c = pack.components;
|
|
620
|
+
const phaseParams = c.matchPhaseMachine;
|
|
621
|
+
const writeback = c.physicsWriteback;
|
|
622
|
+
const frame = state.frame;
|
|
623
|
+
const n = pack.slotCount;
|
|
624
|
+
if (!Array.isArray(inputs) || inputs.length !== n) {
|
|
625
|
+
throw new Error(`tickFixedRuntime: inputs must be an array of length ${n} (got ${inputs?.length})`);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const carX = state.carX.slice();
|
|
629
|
+
const carZ = state.carZ.slice();
|
|
630
|
+
const carVX = state.carVX.slice();
|
|
631
|
+
const carVZ = state.carVZ.slice();
|
|
632
|
+
const speed = state.speed.slice();
|
|
633
|
+
const yawTurns = state.yawTurns.slice();
|
|
634
|
+
const drifting = state.drifting.slice();
|
|
635
|
+
const lap = state.lap.slice();
|
|
636
|
+
const cellsVisited = state.cellsVisited.slice();
|
|
637
|
+
const lapProj = state.lapProj.slice();
|
|
638
|
+
const finishFrame = state.finishFrame.slice();
|
|
639
|
+
const isBot = state.isBot.slice();
|
|
640
|
+
|
|
641
|
+
let phase = state.phase;
|
|
642
|
+
let phaseStartFrame = state.phaseStartFrame;
|
|
643
|
+
let lobbyDeadlineFrame = state.lobbyDeadlineFrame;
|
|
644
|
+
|
|
645
|
+
// ── 1. Resolve input (bot vs human), detect joins, count present humans. ──
|
|
646
|
+
const cmds = [];
|
|
647
|
+
let humans = 0;
|
|
648
|
+
let joined = false;
|
|
649
|
+
for (let slot = 0; slot < n; slot += 1) {
|
|
650
|
+
const raw = decodeSlotInput(inputs[slot]);
|
|
651
|
+
const effectiveBot = raw.bot !== undefined ? raw.bot : isBot[slot];
|
|
652
|
+
if (state.isBot[slot] === true && effectiveBot === false) joined = true;
|
|
653
|
+
isBot[slot] = effectiveBot;
|
|
654
|
+
if (!effectiveBot) humans += 1;
|
|
655
|
+
cmds.push(raw);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const active = phase === PHASE_ACTIVE;
|
|
659
|
+
const inArena = phase === PHASE_LOBBY;
|
|
660
|
+
|
|
661
|
+
// ── 2. Drive each slot per phase and build its physics body. ──
|
|
662
|
+
const dynamicBodies = [];
|
|
663
|
+
for (let slot = 0; slot < n; slot += 1) {
|
|
664
|
+
const raw = cmds[slot];
|
|
665
|
+
const bot = isBot[slot];
|
|
666
|
+
const finished = finishFrame[slot] >= 0;
|
|
667
|
+
|
|
668
|
+
let steer = 0;
|
|
669
|
+
let throttle = 0;
|
|
670
|
+
let drive = false;
|
|
671
|
+
if (phase === PHASE_LOBBY) {
|
|
672
|
+
if (!bot) {
|
|
673
|
+
steer = raw.steerX;
|
|
674
|
+
throttle = raw.throttle;
|
|
675
|
+
drive = true;
|
|
676
|
+
}
|
|
677
|
+
} else if (phase === PHASE_ACTIVE && !finished) {
|
|
678
|
+
drive = true;
|
|
679
|
+
if (bot) {
|
|
680
|
+
const b = waypointRingDrive(
|
|
681
|
+
cooked.botRing,
|
|
682
|
+
{ x: carX[slot], z: carZ[slot], yawTurns: yawTurns[slot] },
|
|
683
|
+
c.waypointRingDriver,
|
|
684
|
+
math,
|
|
685
|
+
);
|
|
686
|
+
steer = b.steerX;
|
|
687
|
+
throttle = b.throttle;
|
|
688
|
+
} else {
|
|
689
|
+
steer = raw.steerX;
|
|
690
|
+
throttle = raw.throttle;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (drive) {
|
|
695
|
+
const kin = stepArcadeDriftVehicle(
|
|
696
|
+
{ yawTurns: yawTurns[slot], speed: speed[slot], vx: carVX[slot], vz: carVZ[slot], drifting: drifting[slot] },
|
|
697
|
+
steer,
|
|
698
|
+
throttle,
|
|
699
|
+
c.arcadeDriftVehicle,
|
|
700
|
+
math,
|
|
701
|
+
);
|
|
702
|
+
yawTurns[slot] = kin.yawTurns;
|
|
703
|
+
speed[slot] = kin.speed;
|
|
704
|
+
drifting[slot] = kin.drifting;
|
|
705
|
+
carVX[slot] = kin.vx;
|
|
706
|
+
carVZ[slot] = kin.vz;
|
|
707
|
+
} else {
|
|
708
|
+
freezeSlot(slot, speed, carVX, carVZ, drifting);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
dynamicBodies.push({
|
|
712
|
+
id: `${writeback.bodyIdPrefix}${slot}`,
|
|
713
|
+
kind: 'dynamic',
|
|
714
|
+
x: carX[slot],
|
|
715
|
+
y: writeback.bodyY,
|
|
716
|
+
z: carZ[slot],
|
|
717
|
+
vx: carVX[slot],
|
|
718
|
+
vy: 0,
|
|
719
|
+
vz: carVZ[slot],
|
|
720
|
+
shape: { type: 'sphere', radius: writeback.bodyRadius },
|
|
721
|
+
layer: writeback.layer,
|
|
722
|
+
mask: writeback.mask,
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// ── 3. PhysicsWriteback: one cold solve in world units, rounded fixed readback. ──
|
|
727
|
+
const statics = inArena ? cooked.arenaWalls : cooked.walls;
|
|
728
|
+
const world = physics.createPhysicsWorld(
|
|
729
|
+
[...statics, ...dynamicBodies].map((body) => toPhysicsBody(body, pack.scale)),
|
|
730
|
+
);
|
|
731
|
+
const result = physics.stepPhysicsWorld(world, { gravityY: writeback.gravityY });
|
|
732
|
+
for (let slot = 0; slot < n; slot += 1) {
|
|
733
|
+
const body = result.world.bodies.find((b) => b.id === `${writeback.bodyIdPrefix}${slot}`);
|
|
734
|
+
if (body) {
|
|
735
|
+
carX[slot] = Math.round(body.x * pack.scale);
|
|
736
|
+
carZ[slot] = Math.round(body.z * pack.scale);
|
|
737
|
+
carVX[slot] = Math.round(body.vx * pack.scale);
|
|
738
|
+
carVZ[slot] = Math.round(body.vz * pack.scale);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ── 4. LapProgress only while active. ──
|
|
743
|
+
if (active) {
|
|
744
|
+
for (let slot = 0; slot < n; slot += 1) {
|
|
745
|
+
const r = stepLapProgress(
|
|
746
|
+
cooked.lapInfo,
|
|
747
|
+
{
|
|
748
|
+
x: carX[slot],
|
|
749
|
+
z: carZ[slot],
|
|
750
|
+
lap: lap[slot],
|
|
751
|
+
cellsVisited: cellsVisited[slot],
|
|
752
|
+
finishFrame: finishFrame[slot],
|
|
753
|
+
lapProj: lapProj[slot],
|
|
754
|
+
},
|
|
755
|
+
frame,
|
|
756
|
+
pack.grid,
|
|
757
|
+
c.lapProgress,
|
|
758
|
+
math,
|
|
759
|
+
);
|
|
760
|
+
lap[slot] = r.lap;
|
|
761
|
+
cellsVisited[slot] = r.cellsVisited;
|
|
762
|
+
finishFrame[slot] = r.finishFrame;
|
|
763
|
+
lapProj[slot] = r.lapProj;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// ── 5. MatchPhaseMachine transitions (all frame-counted). ──
|
|
768
|
+
if (phase === PHASE_LOBBY) {
|
|
769
|
+
if (joined) {
|
|
770
|
+
lobbyDeadlineFrame = Math.min(
|
|
771
|
+
lobbyDeadlineFrame + phaseParams.lobbyExtendFrames,
|
|
772
|
+
phaseStartFrame + phaseParams.lobbyMaxFrames,
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
if (humans >= phaseParams.maxSlotsAutoStart || frame >= lobbyDeadlineFrame) {
|
|
776
|
+
const gridPoses = deriveGridSpawns(cooked.cells, n, pack.grid, c.gridSpawn, math);
|
|
777
|
+
for (let slot = 0; slot < n; slot += 1) {
|
|
778
|
+
carX[slot] = gridPoses[slot].x;
|
|
779
|
+
carZ[slot] = gridPoses[slot].z;
|
|
780
|
+
yawTurns[slot] = gridPoses[slot].yawTurns;
|
|
781
|
+
freezeSlot(slot, speed, carVX, carVZ, drifting);
|
|
782
|
+
lap[slot] = 0;
|
|
783
|
+
cellsVisited[slot] = 0;
|
|
784
|
+
lapProj[slot] = 0;
|
|
785
|
+
finishFrame[slot] = -1;
|
|
786
|
+
}
|
|
787
|
+
phase = PHASE_COUNTDOWN;
|
|
788
|
+
phaseStartFrame = frame;
|
|
789
|
+
}
|
|
790
|
+
} else if (phase === PHASE_COUNTDOWN) {
|
|
791
|
+
if (frame - phaseStartFrame >= phaseParams.countdownFrames) {
|
|
792
|
+
phase = PHASE_ACTIVE;
|
|
793
|
+
phaseStartFrame = frame;
|
|
794
|
+
}
|
|
795
|
+
} else if (phase === PHASE_ACTIVE) {
|
|
796
|
+
const everyoneDone = finishFrame.every((f) => f >= 0);
|
|
797
|
+
const capped = frame - phaseStartFrame >= c.lapProgress.maxRaceFrames;
|
|
798
|
+
if (everyoneDone || capped) {
|
|
799
|
+
phase = PHASE_FINISHED;
|
|
800
|
+
phaseStartFrame = frame;
|
|
801
|
+
}
|
|
802
|
+
} else if (phase === PHASE_FINISHED) {
|
|
803
|
+
if (frame - phaseStartFrame >= phaseParams.resultsHoldFrames) {
|
|
804
|
+
const lobby = deriveScatterSpawns(n, c.arenaBounds, c.scatterSpawn);
|
|
805
|
+
for (let slot = 0; slot < n; slot += 1) {
|
|
806
|
+
carX[slot] = lobby[slot].x;
|
|
807
|
+
carZ[slot] = lobby[slot].z;
|
|
808
|
+
yawTurns[slot] = lobby[slot].yawTurns;
|
|
809
|
+
freezeSlot(slot, speed, carVX, carVZ, drifting);
|
|
810
|
+
lap[slot] = 0;
|
|
811
|
+
cellsVisited[slot] = 0;
|
|
812
|
+
lapProj[slot] = 0;
|
|
813
|
+
finishFrame[slot] = -1;
|
|
814
|
+
}
|
|
815
|
+
phase = PHASE_LOBBY;
|
|
816
|
+
phaseStartFrame = frame;
|
|
817
|
+
lobbyDeadlineFrame = frame + phaseParams.lobbyInitialFrames;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
runtime.state = {
|
|
822
|
+
...state,
|
|
823
|
+
frame: frame + 1,
|
|
824
|
+
phase,
|
|
825
|
+
phaseStartFrame,
|
|
826
|
+
lobbyDeadlineFrame,
|
|
827
|
+
carX,
|
|
828
|
+
carZ,
|
|
829
|
+
carVX,
|
|
830
|
+
carVZ,
|
|
831
|
+
speed,
|
|
832
|
+
yawTurns,
|
|
833
|
+
drifting,
|
|
834
|
+
lap,
|
|
835
|
+
cellsVisited,
|
|
836
|
+
lapProj,
|
|
837
|
+
finishFrame,
|
|
838
|
+
isBot,
|
|
839
|
+
};
|
|
840
|
+
return runtime.state;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const fixedSystemOrder = Object.freeze([
|
|
844
|
+
'slot-input-resolution',
|
|
845
|
+
'vehicle-drive-body-build',
|
|
846
|
+
'vehicle-physics-step',
|
|
847
|
+
'vehicle-lap-finish',
|
|
848
|
+
'match-phase-transition',
|
|
849
|
+
]);
|
|
850
|
+
|
|
851
|
+
export function executeFixedRuntimeSystem(runtime, systemId, inputs) {
|
|
852
|
+
const pending = runtime.pendingInstalledTick ?? { index: 0, inputs };
|
|
853
|
+
if (fixedSystemOrder[pending.index] !== systemId) {
|
|
854
|
+
throw new Error(`KINETIX_FIXED_SYSTEM_ORDER_INVALID expected=${fixedSystemOrder[pending.index]} actual=${systemId}`);
|
|
855
|
+
}
|
|
856
|
+
if (pending.index === 0) runtime.pendingInstalledTick = pending;
|
|
857
|
+
pending.index += 1;
|
|
858
|
+
if (pending.index === fixedSystemOrder.length) {
|
|
859
|
+
delete runtime.pendingInstalledTick;
|
|
860
|
+
tickFixedRuntime(runtime, pending.inputs);
|
|
861
|
+
}
|
|
862
|
+
return runtime.state;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ─── Canonical state serialization + hashing (browser-safe) ─────────────────
|
|
866
|
+
|
|
867
|
+
const utf8Encoder = new TextEncoder();
|
|
868
|
+
|
|
869
|
+
function makeWriter() {
|
|
870
|
+
const chunks = [];
|
|
871
|
+
return {
|
|
872
|
+
chunks,
|
|
873
|
+
u64(value) {
|
|
874
|
+
const bytes = new Uint8Array(8);
|
|
875
|
+
new DataView(bytes.buffer).setBigUint64(0, BigInt(value), true);
|
|
876
|
+
chunks.push(bytes);
|
|
877
|
+
},
|
|
878
|
+
i64(value) {
|
|
879
|
+
const bytes = new Uint8Array(8);
|
|
880
|
+
new DataView(bytes.buffer).setBigInt64(0, BigInt(value), true);
|
|
881
|
+
chunks.push(bytes);
|
|
882
|
+
},
|
|
883
|
+
u8(value) {
|
|
884
|
+
chunks.push(Uint8Array.of(value ? 1 : 0));
|
|
885
|
+
},
|
|
886
|
+
str(value) {
|
|
887
|
+
const bytes = utf8Encoder.encode(value);
|
|
888
|
+
const length = new Uint8Array(4);
|
|
889
|
+
new DataView(length.buffer).setUint32(0, bytes.length, true);
|
|
890
|
+
chunks.push(length, bytes);
|
|
891
|
+
},
|
|
892
|
+
concat() {
|
|
893
|
+
let total = 0;
|
|
894
|
+
for (const chunk of chunks) total += chunk.length;
|
|
895
|
+
const out = new Uint8Array(total);
|
|
896
|
+
let offset = 0;
|
|
897
|
+
for (const chunk of chunks) {
|
|
898
|
+
out.set(chunk, offset);
|
|
899
|
+
offset += chunk.length;
|
|
900
|
+
}
|
|
901
|
+
return out;
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
export function canonicalFixedStateBytes(runtime) {
|
|
907
|
+
const s = runtime.state;
|
|
908
|
+
const w = makeWriter();
|
|
909
|
+
w.u64(s.frame);
|
|
910
|
+
w.str(s.track);
|
|
911
|
+
w.i64(s.phase);
|
|
912
|
+
w.i64(s.phaseStartFrame);
|
|
913
|
+
w.i64(s.lobbyDeadlineFrame);
|
|
914
|
+
for (const field of ['carX', 'carZ', 'carVX', 'carVZ', 'speed', 'yawTurns']) {
|
|
915
|
+
for (const value of s[field]) w.i64(value);
|
|
916
|
+
}
|
|
917
|
+
for (const value of s.drifting) w.u8(value);
|
|
918
|
+
for (const field of ['lap', 'cellsVisited', 'lapProj', 'finishFrame']) {
|
|
919
|
+
for (const value of s[field]) w.i64(value);
|
|
920
|
+
}
|
|
921
|
+
for (const value of s.isBot) w.u8(value);
|
|
922
|
+
return w.concat();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export function canonicalFixedTickHash(runtime) {
|
|
926
|
+
return sha256Hex(canonicalFixedStateBytes(runtime));
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ─── View accessor for the render core / scripted camera ────────────────────
|
|
930
|
+
|
|
931
|
+
export function carViews(runtime) {
|
|
932
|
+
const s = runtime.state;
|
|
933
|
+
const scale = runtime.math.ONE;
|
|
934
|
+
const cars = [];
|
|
935
|
+
for (let slot = 0; slot < runtime.pack.slotCount; slot += 1) {
|
|
936
|
+
cars.push({
|
|
937
|
+
slot,
|
|
938
|
+
x: s.carX[slot] / scale,
|
|
939
|
+
z: s.carZ[slot] / scale,
|
|
940
|
+
yawTurns: s.yawTurns[slot] / scale,
|
|
941
|
+
isBot: s.isBot[slot],
|
|
942
|
+
lap: s.lap[slot],
|
|
943
|
+
finished: s.finishFrame[slot] >= 0,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
frame: s.frame,
|
|
948
|
+
phase: s.phase,
|
|
949
|
+
phaseName: PHASE_NAMES[s.phase],
|
|
950
|
+
phaseStartFrame: s.phaseStartFrame,
|
|
951
|
+
lobbyDeadlineFrame: s.lobbyDeadlineFrame,
|
|
952
|
+
cars,
|
|
953
|
+
};
|
|
954
|
+
}
|