@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.
Files changed (58) hide show
  1. package/README.md +77 -0
  2. package/authoring.d.ts +30 -0
  3. package/index.d.ts +221 -0
  4. package/native/component_runtime.cpp +341 -0
  5. package/native/component_runtime.hpp +112 -0
  6. package/native/deterministic_math.cpp +594 -0
  7. package/native/deterministic_math.hpp +21 -0
  8. package/native/kinetix-f64-native-profile.cpp +406 -0
  9. package/native/kinetix-f64-native-profile.hpp +5 -0
  10. package/native/kinetix-fixed1000-native-profile.cpp +777 -0
  11. package/native/kinetix-fixed1000-native-profile.hpp +58 -0
  12. package/native/kinetix-fixed1000-physics.cpp +887 -0
  13. package/native/kinetix-fixed1000-physics.hpp +28 -0
  14. package/native/kinetix-installed-f64-renderer.cpp +344 -0
  15. package/native/kinetix-installed-f64-renderer.hpp +35 -0
  16. package/native/kinetix-installed-f64-runtime.cpp +1085 -0
  17. package/native/kinetix-installed-f64-runtime.hpp +141 -0
  18. package/native/kinetix-installed-fixed1000-data.hpp +77 -0
  19. package/native/kinetix-native-main.cpp +37 -0
  20. package/native/kinetix-native-runtime.cpp +20 -0
  21. package/native/kinetix-native-runtime.hpp +25 -0
  22. package/native/kinetix-render-projection.cpp +20 -0
  23. package/native/kinetix-render-projection.hpp +14 -0
  24. package/package.json +65 -0
  25. package/runtime.d.ts +76 -0
  26. package/scripts/build-native.mjs +67 -0
  27. package/scripts/emit-product-digests.mjs +33 -0
  28. package/scripts/preflight.mjs +76 -0
  29. package/src/index.mjs +57 -0
  30. package/src/kinetix-authoring.mjs +69 -0
  31. package/src/kinetix-baker.mjs +587 -0
  32. package/src/kinetix-deterministic-math.mjs +1044 -0
  33. package/src/kinetix-fixed1000-runtime-adapter.mjs +33 -0
  34. package/src/kinetix-fixed1000-runtime.mjs +954 -0
  35. package/src/kinetix-installed-f64-reference.mjs +157 -0
  36. package/src/kinetix-installed-f64-render-frames.mjs +53 -0
  37. package/src/kinetix-installed-f64-renderer.mjs +240 -0
  38. package/src/kinetix-installed-f64-runtime-adapter.mjs +68 -0
  39. package/src/kinetix-installed-f64-runtime.mjs +607 -0
  40. package/src/kinetix-installed-mechanics.mjs +377 -0
  41. package/src/kinetix-installed-systems.mjs +181 -0
  42. package/src/kinetix-native-product.mjs +72 -0
  43. package/src/kinetix-product-contract.mjs +121 -0
  44. package/src/kinetix-project-compiler.mjs +1017 -0
  45. package/src/kinetix-render-projection.mjs +28 -0
  46. package/src/kinetix-runtime-adapter-utils.mjs +168 -0
  47. package/src/kinetix-runtime-contract.mjs +54 -0
  48. package/src/kinetix-session-config.mjs +170 -0
  49. package/src/kinetix-source-snapshot.mjs +24 -0
  50. package/src/kinetix-survival-runtime-adapter.mjs +305 -0
  51. package/src/kinetix-survival-runtime.generated.mjs +1 -0
  52. package/src/kinetix-world-kernel.mjs +580 -0
  53. package/src/kinetix-world-runtime.mjs +171 -0
  54. package/src/runtime.mjs +14 -0
  55. package/src/shared/f64-bits.mjs +14 -0
  56. package/src/shared/kinetix-envelope-v1.mjs +589 -0
  57. package/src/shared/render-records-v1.mjs +168 -0
  58. package/src/shared/sha256.mjs +73 -0
@@ -0,0 +1,607 @@
1
+ // Browser-safe sim core for the deterministic-f64 component runtime
2
+ // (pack v2). This is the semantic oracle for the installed profile: the portable
3
+ // C++ runtime must produce identical canonical per-tick hashes.
4
+ //
5
+ // Numeric contract: IEEE-754 doubles; +,-,*,/ and sqrt only from hardware;
6
+ // every transcendental (sin, cos, atan, atan2, exp, log, hypot) from the
7
+ // deterministic math namespace (battery-proven bit-identical to the C++
8
+ // port). RNG = mulberry32 streams, floats via u32 / 2^32.
9
+ //
10
+ // Tick pipeline (lowered from the reviewed arena fixed-tick order, restricted
11
+ // to the slice mechanics):
12
+ // 1 input components write fields (AxisInputState, FireInputState)
13
+ // 2 shield + telegraph countdowns
14
+ // 3 driven movement + clamp + heading (AxisDriveMover, ArenaBounds, HeadingFromVectors)
15
+ // 4 auto-fire weapons spawn bullets (AutoFireWeapon)
16
+ // 5 linear movers integrate + bounds (LinearMover + ArenaBounds; bullets cull, shards reflect)
17
+ // 6 pre-AI bullet->enemy collisions
18
+ // 7 enemy AI integrate + bounds (WanderMover, SeekMover)
19
+ // 8 bullet->enemy, ship->enemy collisions
20
+ // 9 lifetimes, on-death emitters, despawn, scoring, respawn
21
+ // 10 canonical hash
22
+ //
23
+ // Canonical hash layout (must match the portable installed runtime exactly):
24
+ // u64le tick
25
+ // for each rng stream in pack order: str(id) u32le state
26
+ // for each live entity (static entities in pack order, then pooled entities
27
+ // in ascending spawnId):
28
+ // str(entityRef) u64le spawnId (entityRef = entity id or template id)
29
+ // for each component in pack order: str(type) + type-specific state:
30
+ // Transform2D: 5 x f64le bits (pos.x pos.y vel.x vel.y heading)
31
+ // TwinStickInput: 4 x f64le (move.x move.y aim.x aim.y), u8 fire
32
+ // SpawnShield: f64le remaining
33
+ // Telegraph: f64le remaining
34
+ // WanderMover: f64le rerollTimer
35
+ // SeekMover: f64le aliveSeconds
36
+ // AutoFireWeapon: f64le cooldown, u64le nextBulletId
37
+ // Lifetime: f64le age
38
+ // Health: i64le hp
39
+ // ResourceCounter: i64le score, i64le deaths
40
+ // (AxisDriveMover, HeadingFromVectors, ArenaBounds, LinearMover,
41
+ // Emitter, CollisionCircle, ScoreValue, DecayingMultiplier: no state)
42
+ // tickHash = sha256(buffer) hex; stream digest = sha256 over ascii hex hashes.
43
+
44
+ import { DETERMINISTIC_MATH_SOURCE } from './kinetix-deterministic-math.mjs';
45
+ import { f64FromHex } from './shared/f64-bits.mjs';
46
+
47
+ function createDeterministicMathNamespace() {
48
+ const shadowMath = {};
49
+ for (const key of Object.getOwnPropertyNames(Math)) shadowMath[key] = Math[key];
50
+ const shadowGlobal = {};
51
+ // The shim is an IIFE that patches the Math object it can see; shadowing
52
+ // Math and globalThis confines the patch to this namespace.
53
+ new Function('Math', 'globalThis', DETERMINISTIC_MATH_SOURCE)(shadowMath, shadowGlobal);
54
+ return shadowMath;
55
+ }
56
+
57
+ export const detMath = createDeterministicMathNamespace();
58
+
59
+ const TWO_PI = 6.283185307179586;
60
+
61
+ export function mulberry32Next(state) {
62
+ const nextState = (state + 0x6d2b79f5) >>> 0;
63
+ let t = nextState;
64
+ t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
65
+ t = (t ^ (t + Math.imul(t ^ (t >>> 7), t | 61))) >>> 0;
66
+ return { state: nextState, value: (t ^ (t >>> 14)) >>> 0 };
67
+ }
68
+
69
+ function rngFloat(runtime, streamId) {
70
+ const stream = runtime.rngStreams.get(streamId);
71
+ const { state, value } = mulberry32Next(stream.state);
72
+ stream.state = state;
73
+ return value / 4294967296;
74
+ }
75
+
76
+ function gaussian(runtime, streamId) {
77
+ const u1 = Math.max(rngFloat(runtime, streamId), 1e-9);
78
+ const u2 = rngFloat(runtime, streamId);
79
+ return Math.sqrt(-2 * detMath.log(u1)) * detMath.cos(TWO_PI * u2);
80
+ }
81
+
82
+ function decodeF64(value) {
83
+ return f64FromHex(value);
84
+ }
85
+
86
+ function decodeVec2(value) {
87
+ return { x: decodeF64(value[0]), y: decodeF64(value[1]) };
88
+ }
89
+
90
+ function instantiateComponents(components) {
91
+ return components.map((component) => {
92
+ const properties = component.properties;
93
+ switch (component.type) {
94
+ case 'Transform2D':
95
+ return {
96
+ type: component.type,
97
+ position: decodeVec2(properties.position),
98
+ velocity: decodeVec2(properties.velocity),
99
+ heading: decodeF64(properties.heading),
100
+ };
101
+ case 'TwinStickInput':
102
+ return {
103
+ type: component.type,
104
+ moveField: properties.moveField,
105
+ aimField: properties.aimField,
106
+ fireField: properties.fireField,
107
+ slot: properties.slot,
108
+ move: { x: 0, y: 0 },
109
+ aim: { x: 0, y: 0 },
110
+ fire: false,
111
+ };
112
+ case 'AxisDriveMover':
113
+ return { type: component.type, axisField: properties.axisField, maxSpeedPxPerSec: decodeF64(properties.maxSpeedPxPerSec) };
114
+ case 'HeadingFromVectors':
115
+ return { type: component.type, aimField: properties.aimField, moveField: properties.moveField };
116
+ case 'SpawnShield':
117
+ return { type: component.type, seconds: decodeF64(properties.seconds), remaining: decodeF64(properties.seconds) };
118
+ case 'WanderMover':
119
+ return {
120
+ type: component.type,
121
+ speedPx: decodeF64(properties.speedPx),
122
+ rerollSeconds: decodeF64(properties.rerollSeconds),
123
+ sigmaRad: decodeF64(properties.sigmaRad),
124
+ rngStream: properties.rngStream,
125
+ rerollTimer: decodeF64(properties.rerollSeconds),
126
+ };
127
+ case 'SeekMover':
128
+ return {
129
+ type: component.type,
130
+ baseSpeedPx: decodeF64(properties.baseSpeedPx),
131
+ topSpeedPx: decodeF64(properties.topSpeedPx),
132
+ accelStartSec: decodeF64(properties.accelStartSec),
133
+ accelEndSec: decodeF64(properties.accelEndSec),
134
+ aliveSeconds: 0,
135
+ };
136
+ case 'Telegraph':
137
+ return { type: component.type, remaining: decodeF64(properties.seconds) };
138
+ case 'ArenaBounds':
139
+ return { type: component.type, mode: properties.mode, useRadius: properties.useRadius };
140
+ case 'AutoFireWeapon':
141
+ return {
142
+ type: component.type,
143
+ fireIntervalSec: decodeF64(properties.fireIntervalSec),
144
+ speedPxPerSec: decodeF64(properties.speedPxPerSec),
145
+ spreadRad: decodeF64(properties.spreadRad),
146
+ bulletTemplate: properties.bulletTemplate,
147
+ aimField: properties.aimField,
148
+ fireField: properties.fireField,
149
+ slot: properties.slot,
150
+ cooldown: 0,
151
+ nextBulletId: 1,
152
+ };
153
+ case 'LinearMover':
154
+ return { type: component.type };
155
+ case 'Lifetime':
156
+ return { type: component.type, maxSeconds: decodeF64(properties.maxSeconds), age: 0 };
157
+ case 'Health':
158
+ return { type: component.type, hp: properties.hp };
159
+ case 'Emitter':
160
+ return {
161
+ type: component.type,
162
+ trigger: properties.trigger,
163
+ template: properties.template,
164
+ count: properties.count,
165
+ speedPx: decodeF64(properties.speedPx),
166
+ extraOffsetPx: decodeF64(properties.extraOffsetPx),
167
+ pattern: properties.pattern,
168
+ rngStream: properties.rngStream,
169
+ };
170
+ case 'CollisionCircle':
171
+ return { type: component.type, radius: decodeF64(properties.radius), layer: properties.layer };
172
+ case 'ScoreValue':
173
+ return { type: component.type, basePoints: properties.basePoints };
174
+ case 'ResourceCounter':
175
+ return { type: component.type, score: BigInt(properties.score), deaths: 0n };
176
+ case 'DecayingMultiplier':
177
+ return {
178
+ type: component.type,
179
+ increment: decodeF64(properties.increment),
180
+ cap: decodeF64(properties.cap),
181
+ decayWindow: decodeF64(properties.decayWindow),
182
+ value: 1,
183
+ };
184
+ default:
185
+ throw new Error(`Unknown component type ${component.type}`);
186
+ }
187
+ });
188
+ }
189
+
190
+ function makeEntity(ref, spawnId, components) {
191
+ const entity = {
192
+ ref,
193
+ spawnId,
194
+ components: instantiateComponents(components),
195
+ fields: new Map(),
196
+ alive: true,
197
+ spawnPosition: null,
198
+ };
199
+ entity.byType = new Map(entity.components.map((component) => [component.type, component]));
200
+ const transform = entity.byType.get('Transform2D');
201
+ if (transform) entity.spawnPosition = { x: transform.position.x, y: transform.position.y };
202
+ return entity;
203
+ }
204
+
205
+ export function createF64Runtime(pack, options = {}) {
206
+ const runtime = {
207
+ pack,
208
+ dt: 1 / 60,
209
+ width: options.arenaWidth ?? 1280,
210
+ height: options.arenaHeight ?? 720,
211
+ tick: 0,
212
+ rngStreams: new Map(pack.rngStreams.map((stream) => [stream.id, { id: stream.id, state: stream.seed >>> 0 }])),
213
+ rngStreamOrder: pack.rngStreams.map((stream) => stream.id),
214
+ staticEntities: pack.entities.map((entity) => makeEntity(entity.id, 0n, entity.components)),
215
+ pooled: [],
216
+ nextSpawnId: 1n,
217
+ templates: new Map(pack.templates.map((template) => [template.id, template])),
218
+ poolCounts: new Map(pack.templates.map((template) => [template.id, 0])),
219
+ collisionRules: pack.collisionRules,
220
+ spawnsThisTick: 0,
221
+ // Render-facing event log for the current tick (spawns, kills, deaths).
222
+ // Not part of canonical state or hashing; consumed by render cores.
223
+ frameEvents: [],
224
+ };
225
+ return runtime;
226
+ }
227
+
228
+ export function liveEntities(runtime) {
229
+ return [...runtime.staticEntities, ...runtime.pooled.filter((entity) => entity.alive)];
230
+ }
231
+
232
+ function spawnFromTemplate(runtime, templateId, setup) {
233
+ const template = runtime.templates.get(templateId);
234
+ const liveOfTemplate = runtime.poolCounts.get(templateId);
235
+ if (liveOfTemplate >= template.pool) return null;
236
+ if (runtime.spawnsThisTick >= runtime.pack.budgets.maxSpawnsPerTick) return null;
237
+ const entity = makeEntity(templateId, runtime.nextSpawnId, template.components);
238
+ runtime.nextSpawnId += 1n;
239
+ runtime.spawnsThisTick += 1;
240
+ runtime.poolCounts.set(templateId, liveOfTemplate + 1);
241
+ setup(entity);
242
+ runtime.pooled.push(entity);
243
+ {
244
+ const transform = entity.byType.get('Transform2D');
245
+ if (transform) runtime.frameEvents.push({ type: 'spawned', ref: templateId, x: transform.position.x, y: transform.position.y });
246
+ }
247
+ return entity;
248
+ }
249
+
250
+ function despawn(runtime, entity) {
251
+ if (!entity.alive) return;
252
+ entity.alive = false;
253
+ runtime.poolCounts.set(entity.ref, runtime.poolCounts.get(entity.ref) - 1);
254
+ }
255
+
256
+ function shipIsShielded(entity) {
257
+ const shield = entity.byType.get('SpawnShield');
258
+ return shield ? shield.remaining > 0 : false;
259
+ }
260
+
261
+ function entityTelegraphing(entity) {
262
+ const telegraph = entity.byType.get('Telegraph');
263
+ return telegraph ? telegraph.remaining > 0 : false;
264
+ }
265
+
266
+ function nearestShipPosition(runtime, from) {
267
+ let best = null;
268
+ let bestDistSq = Infinity;
269
+ for (const entity of runtime.staticEntities) {
270
+ if (!entity.byType.get('AxisDriveMover')) continue;
271
+ const transform = entity.byType.get('Transform2D');
272
+ const dx = transform.position.x - from.x;
273
+ const dy = transform.position.y - from.y;
274
+ const distSq = dx * dx + dy * dy;
275
+ if (distSq < bestDistSq) {
276
+ bestDistSq = distSq;
277
+ best = transform.position;
278
+ }
279
+ }
280
+ return best;
281
+ }
282
+
283
+ function applyBounds(runtime, entity, bounds) {
284
+ const transform = entity.byType.get('Transform2D');
285
+ const collision = entity.byType.get('CollisionCircle');
286
+ const radius = bounds.useRadius && collision ? collision.radius : 0;
287
+ const position = transform.position;
288
+ const velocity = transform.velocity;
289
+ if (bounds.mode === 'clamp') {
290
+ if (position.x < radius) position.x = radius;
291
+ if (position.y < radius) position.y = radius;
292
+ if (position.x > runtime.width - radius) position.x = runtime.width - radius;
293
+ if (position.y > runtime.height - radius) position.y = runtime.height - radius;
294
+ return;
295
+ }
296
+ if (bounds.mode === 'cull') {
297
+ if (position.x < 0 || position.x > runtime.width || position.y < 0 || position.y > runtime.height) {
298
+ despawn(runtime, entity);
299
+ }
300
+ return;
301
+ }
302
+ if (bounds.mode === 'reflect-negate') {
303
+ if (position.x < radius) { position.x = radius; velocity.x = -velocity.x; }
304
+ if (position.y < radius) { position.y = radius; velocity.y = -velocity.y; }
305
+ if (position.x > runtime.width - radius) { position.x = runtime.width - radius; velocity.x = -velocity.x; }
306
+ if (position.y > runtime.height - radius) { position.y = runtime.height - radius; velocity.y = -velocity.y; }
307
+ return;
308
+ }
309
+ // reflect-abs
310
+ if (position.x < radius) { position.x = radius; velocity.x = Math.abs(velocity.x); }
311
+ if (position.y < radius) { position.y = radius; velocity.y = Math.abs(velocity.y); }
312
+ if (position.x > runtime.width - radius) { position.x = runtime.width - radius; velocity.x = -Math.abs(velocity.x); }
313
+ if (position.y > runtime.height - radius) { position.y = runtime.height - radius; velocity.y = -Math.abs(velocity.y); }
314
+ }
315
+
316
+ // Installed projectile-family deterministic spread.
317
+ function spreadForBullet(id, spreadRad) {
318
+ const seeded = detMath.sin(id * 12.9898 + 78.233) * 43758.5453;
319
+ const unit = seeded - Math.floor(seeded);
320
+ return (unit * 2 - 1) * spreadRad;
321
+ }
322
+
323
+ function circleHit(a, ra, b, rb) {
324
+ const dx = a.x - b.x;
325
+ const dy = a.y - b.y;
326
+ const r = ra + rb;
327
+ return dx * dx + dy * dy <= r * r;
328
+ }
329
+
330
+ function runCollisionPass(runtime, live, action, events) {
331
+ for (const rule of runtime.collisionRules) {
332
+ if (rule.action !== action) continue;
333
+ const groupA = live.filter((entity) => entity.alive && entity.byType.get('CollisionCircle')?.layer === rule.layerA);
334
+ const groupB = live.filter((entity) => entity.alive && entity.byType.get('CollisionCircle')?.layer === rule.layerB);
335
+ if (action === 'bullet-kill') {
336
+ for (const bullet of groupA) {
337
+ if (!bullet.alive) continue;
338
+ const bulletCircle = bullet.byType.get('CollisionCircle');
339
+ const bulletTransform = bullet.byType.get('Transform2D');
340
+ for (const enemy of groupB) {
341
+ if (!enemy.alive || entityTelegraphing(enemy)) continue;
342
+ const enemyCircle = enemy.byType.get('CollisionCircle');
343
+ const enemyTransform = enemy.byType.get('Transform2D');
344
+ if (!circleHit(bulletTransform.position, bulletCircle.radius, enemyTransform.position, enemyCircle.radius)) continue;
345
+ const health = enemy.byType.get('Health');
346
+ health.hp -= 1;
347
+ despawn(runtime, bullet);
348
+ if (health.hp <= 0) {
349
+ events.push({ kind: 'enemy-killed', entity: enemy });
350
+ }
351
+ break;
352
+ }
353
+ }
354
+ } else if (action === 'ship-death') {
355
+ for (const ship of groupA) {
356
+ if (shipIsShielded(ship)) continue;
357
+ const shipCircle = ship.byType.get('CollisionCircle');
358
+ const shipTransform = ship.byType.get('Transform2D');
359
+ for (const enemy of groupB) {
360
+ if (!enemy.alive || entityTelegraphing(enemy)) continue;
361
+ const enemyCircle = enemy.byType.get('CollisionCircle');
362
+ const enemyTransform = enemy.byType.get('Transform2D');
363
+ if (!circleHit(shipTransform.position, shipCircle.radius, enemyTransform.position, enemyCircle.radius)) continue;
364
+ events.push({ kind: 'ship-death', entity: ship });
365
+ break;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ function handleDeathEvents(runtime, events) {
373
+ const game = runtime.staticEntities.find((entity) => entity.byType.get('ResourceCounter'));
374
+ for (const event of events) {
375
+ {
376
+ const transform = event.entity.byType.get('Transform2D');
377
+ runtime.frameEvents.push({ type: event.kind, ref: event.entity.ref, x: transform.position.x, y: transform.position.y });
378
+ }
379
+ if (event.kind === 'enemy-killed') {
380
+ const emitter = event.entity.byType.get('Emitter');
381
+ if (emitter && emitter.trigger === 'on-death') {
382
+ const transform = event.entity.byType.get('Transform2D');
383
+ const circle = event.entity.byType.get('CollisionCircle');
384
+ const speed = Math.hypot(transform.velocity.x, transform.velocity.y);
385
+ const baseAngle = speed > 1
386
+ ? detMath.atan2(transform.velocity.y, transform.velocity.x)
387
+ : rngFloat(runtime, emitter.rngStream) * TWO_PI;
388
+ const phase = rngFloat(runtime, emitter.rngStream) * TWO_PI;
389
+ const template = runtime.templates.get(emitter.template);
390
+ const templateCircle = template.components.find((component) => component.type === 'CollisionCircle');
391
+ const childRadius = templateCircle ? decodeF64(templateCircle.properties.radius) : 0;
392
+ const spawnOffset = circle.radius + childRadius + emitter.extraOffsetPx;
393
+ for (let index = 0; index < emitter.count; index += 1) {
394
+ const angle = baseAngle + phase + (index / emitter.count) * TWO_PI;
395
+ const cos = detMath.cos(angle);
396
+ const sin = detMath.sin(angle);
397
+ spawnFromTemplate(runtime, emitter.template, (child) => {
398
+ const childTransform = child.byType.get('Transform2D');
399
+ childTransform.position.x = transform.position.x + cos * spawnOffset;
400
+ childTransform.position.y = transform.position.y + sin * spawnOffset;
401
+ childTransform.velocity.x = cos * emitter.speedPx;
402
+ childTransform.velocity.y = sin * emitter.speedPx;
403
+ });
404
+ }
405
+ }
406
+ const scoreValue = event.entity.byType.get('ScoreValue');
407
+ if (scoreValue && game) {
408
+ game.byType.get('ResourceCounter').score += BigInt(scoreValue.basePoints);
409
+ }
410
+ if (event.entity.spawnId === 0n) {
411
+ event.entity.alive = false;
412
+ } else {
413
+ despawn(runtime, event.entity);
414
+ }
415
+ } else if (event.kind === 'ship-death') {
416
+ if (game) game.byType.get('ResourceCounter').deaths += 1n;
417
+ const transform = event.entity.byType.get('Transform2D');
418
+ transform.position.x = event.entity.spawnPosition.x;
419
+ transform.position.y = event.entity.spawnPosition.y;
420
+ transform.velocity.x = 0;
421
+ transform.velocity.y = 0;
422
+ const shield = event.entity.byType.get('SpawnShield');
423
+ if (shield) shield.remaining = shield.seconds;
424
+ }
425
+ }
426
+ }
427
+
428
+ export function tickF64Runtime(runtime, inputs) {
429
+ const dt = runtime.dt;
430
+ runtime.spawnsThisTick = 0;
431
+ runtime.frameEvents.length = 0;
432
+ const live = liveEntities(runtime);
433
+
434
+ // 1. inputs
435
+ for (const entity of live) {
436
+ const twinStick = entity.byType.get('TwinStickInput');
437
+ if (!twinStick) continue;
438
+ const slotInput = inputs[twinStick.slot] ?? { movement: { x: 0, y: 0 }, aim: { x: 0, y: 0 }, fire: false };
439
+ twinStick.move = { x: slotInput.movement.x, y: slotInput.movement.y };
440
+ twinStick.aim = { x: slotInput.aim.x, y: slotInput.aim.y };
441
+ twinStick.fire = slotInput.fire === true;
442
+ entity.fields.set(twinStick.moveField, twinStick.move);
443
+ entity.fields.set(twinStick.aimField, twinStick.aim);
444
+ entity.fields.set(twinStick.fireField, twinStick.fire);
445
+ }
446
+
447
+ // 2. shield + telegraph countdowns
448
+ for (const entity of live) {
449
+ const shield = entity.byType.get('SpawnShield');
450
+ if (shield && shield.remaining > 0) shield.remaining = Math.max(0, shield.remaining - dt);
451
+ const telegraph = entity.byType.get('Telegraph');
452
+ if (telegraph && telegraph.remaining > 0) telegraph.remaining = Math.max(0, telegraph.remaining - dt);
453
+ }
454
+
455
+ // 3. driven movement
456
+ for (const entity of live) {
457
+ const drive = entity.byType.get('AxisDriveMover');
458
+ if (!drive) continue;
459
+ const transform = entity.byType.get('Transform2D');
460
+ const movement = entity.fields.get(drive.axisField) ?? { x: 0, y: 0 };
461
+ const mag = detMath.hypot(movement.x, movement.y);
462
+ if (mag > 0.0001) {
463
+ const nx = movement.x / Math.max(mag, 1);
464
+ const ny = movement.y / Math.max(mag, 1);
465
+ transform.position.x += nx * drive.maxSpeedPxPerSec * dt;
466
+ transform.position.y += ny * drive.maxSpeedPxPerSec * dt;
467
+ }
468
+ const bounds = entity.byType.get('ArenaBounds');
469
+ if (bounds) applyBounds(runtime, entity, bounds);
470
+ const headingComponent = entity.byType.get('HeadingFromVectors');
471
+ if (headingComponent) {
472
+ const aim = entity.fields.get(headingComponent.aimField) ?? { x: 0, y: 0 };
473
+ const aimMag = detMath.hypot(aim.x, aim.y);
474
+ if (aimMag > 0.0001) {
475
+ transform.heading = detMath.atan2(aim.y, aim.x);
476
+ } else if (mag > 0.0001) {
477
+ transform.heading = detMath.atan2(movement.y, movement.x);
478
+ }
479
+ }
480
+ }
481
+
482
+ // 4. auto-fire (BulletSwarm.autoFire transcription)
483
+ for (const entity of live) {
484
+ const weapon = entity.byType.get('AutoFireWeapon');
485
+ if (!weapon) continue;
486
+ const fire = entity.fields.get(weapon.fireField) === true;
487
+ if (!fire) {
488
+ weapon.cooldown = 0;
489
+ continue;
490
+ }
491
+ const aim = entity.fields.get(weapon.aimField) ?? { x: 0, y: 0 };
492
+ const aimMag = detMath.hypot(aim.x, aim.y);
493
+ if (aimMag < 0.0001) continue;
494
+ const transform = entity.byType.get('Transform2D');
495
+ let cooldown = weapon.cooldown - dt;
496
+ let fired = 0;
497
+ while (cooldown <= 0) {
498
+ cooldown += weapon.fireIntervalSec;
499
+ const nx = aim.x / aimMag;
500
+ const ny = aim.y / aimMag;
501
+ const bulletId = weapon.nextBulletId;
502
+ const spread = spreadForBullet(Number(bulletId), weapon.spreadRad);
503
+ const cos = detMath.cos(spread);
504
+ const sin = detMath.sin(spread);
505
+ const sx = nx * cos - ny * sin;
506
+ const sy = nx * sin + ny * cos;
507
+ const spawned = spawnFromTemplate(runtime, weapon.bulletTemplate, (bullet) => {
508
+ const bulletTransform = bullet.byType.get('Transform2D');
509
+ bulletTransform.position.x = transform.position.x;
510
+ bulletTransform.position.y = transform.position.y;
511
+ bulletTransform.velocity.x = sx * weapon.speedPxPerSec;
512
+ bulletTransform.velocity.y = sy * weapon.speedPxPerSec;
513
+ bulletTransform.heading = detMath.atan2(sy, sx);
514
+ });
515
+ if (spawned === null) break;
516
+ weapon.nextBulletId += 1;
517
+ fired += 1;
518
+ if (fired > 100) break;
519
+ }
520
+ weapon.cooldown = cooldown;
521
+ }
522
+
523
+ // refresh live to include this tick's spawns for integration
524
+ const liveAfterFire = liveEntities(runtime);
525
+
526
+ // 5. linear movers integrate + bounds
527
+ for (const entity of liveAfterFire) {
528
+ if (!entity.alive || !entity.byType.get('LinearMover')) continue;
529
+ const transform = entity.byType.get('Transform2D');
530
+ transform.position.x += transform.velocity.x * dt;
531
+ transform.position.y += transform.velocity.y * dt;
532
+ const lifetime = entity.byType.get('Lifetime');
533
+ if (lifetime) lifetime.age += dt;
534
+ const bounds = entity.byType.get('ArenaBounds');
535
+ if (bounds) applyBounds(runtime, entity, bounds);
536
+ }
537
+
538
+ const events = [];
539
+ // 6. pre-AI bullet collisions
540
+ runCollisionPass(runtime, liveAfterFire, 'bullet-kill', events);
541
+ handleDeathEvents(runtime, events);
542
+ events.length = 0;
543
+
544
+ // 7. steering behaviors
545
+ for (const entity of liveEntities(runtime)) {
546
+ if (!entity.alive || entityTelegraphing(entity)) continue;
547
+ const wander = entity.byType.get('WanderMover');
548
+ if (wander) {
549
+ const transform = entity.byType.get('Transform2D');
550
+ wander.rerollTimer -= dt;
551
+ if (wander.rerollTimer <= 0) {
552
+ wander.rerollTimer += wander.rerollSeconds;
553
+ const current = detMath.atan2(transform.velocity.y, transform.velocity.x);
554
+ const nudged = current + gaussian(runtime, wander.rngStream) * wander.sigmaRad;
555
+ transform.velocity.x = detMath.cos(nudged) * wander.speedPx;
556
+ transform.velocity.y = detMath.sin(nudged) * wander.speedPx;
557
+ }
558
+ transform.position.x += transform.velocity.x * dt;
559
+ transform.position.y += transform.velocity.y * dt;
560
+ const bounds = entity.byType.get('ArenaBounds');
561
+ if (bounds) applyBounds(runtime, entity, bounds);
562
+ }
563
+ const seek = entity.byType.get('SeekMover');
564
+ if (seek) {
565
+ const transform = entity.byType.get('Transform2D');
566
+ seek.aliveSeconds += dt;
567
+ const t = seek.aliveSeconds;
568
+ let speed = seek.baseSpeedPx;
569
+ if (t > seek.accelStartSec) {
570
+ const lerp = Math.min(1, (t - seek.accelStartSec) / (seek.accelEndSec - seek.accelStartSec));
571
+ speed = seek.baseSpeedPx + (seek.topSpeedPx - seek.baseSpeedPx) * lerp;
572
+ }
573
+ const target = nearestShipPosition(runtime, transform.position);
574
+ if (target) {
575
+ const dx = target.x - transform.position.x;
576
+ const dy = target.y - transform.position.y;
577
+ const mag = detMath.hypot(dx, dy);
578
+ if (mag > 0.0001) {
579
+ transform.velocity.x = (dx / mag) * speed;
580
+ transform.velocity.y = (dy / mag) * speed;
581
+ }
582
+ }
583
+ transform.position.x += transform.velocity.x * dt;
584
+ transform.position.y += transform.velocity.y * dt;
585
+ const bounds = entity.byType.get('ArenaBounds');
586
+ if (bounds) applyBounds(runtime, entity, bounds);
587
+ }
588
+ }
589
+
590
+ // 8. post-AI collisions
591
+ const liveAfterAi = liveEntities(runtime);
592
+ runCollisionPass(runtime, liveAfterAi, 'bullet-kill', events);
593
+ runCollisionPass(runtime, liveAfterAi, 'ship-death', events);
594
+ handleDeathEvents(runtime, events);
595
+ events.length = 0;
596
+
597
+ // 9. lifetimes
598
+ for (const entity of liveEntities(runtime)) {
599
+ const lifetime = entity.byType.get('Lifetime');
600
+ if (lifetime && lifetime.age >= lifetime.maxSeconds) despawn(runtime, entity);
601
+ }
602
+
603
+ runtime.pooled = runtime.pooled.filter((entity) => entity.alive);
604
+ runtime.tick += 1;
605
+ }
606
+
607
+ export { f64FromHex };