@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,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 };
|