lecodes-cli 0.18.0 → 0.18.1

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 (48) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +1222 -293
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +52 -8
  6. package/runtime/sdk/compile/assetMacro.ts +116 -15
  7. package/runtime/sdk/compile/bundler.ts +38 -3
  8. package/runtime/sdk/compile/index.ts +15 -0
  9. package/runtime/sdk/compile/sceneEditor.ts +11 -1
  10. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  11. package/runtime/sdk/compile/shaderTargets.ts +42 -3
  12. package/runtime/sdk/core/Aspect.ts +512 -255
  13. package/runtime/sdk/core/compWrite.ts +42 -0
  14. package/runtime/sdk/core/fields.ts +1 -1
  15. package/runtime/sdk/core/time.ts +81 -0
  16. package/runtime/sdk/g2/Camera2D.ts +8 -1
  17. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  18. package/runtime/sdk/g2/Node2D.ts +80 -10
  19. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  20. package/runtime/sdk/g2/Physics2D.ts +240 -30
  21. package/runtime/sdk/g2/Scene2D.ts +33 -1
  22. package/runtime/sdk/g2/Shape2D.ts +218 -22
  23. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  24. package/runtime/sdk/g2/groups2d.ts +106 -0
  25. package/runtime/sdk/g2/loop.ts +15 -4
  26. package/runtime/sdk/gl/Camera.ts +1 -1
  27. package/runtime/sdk/gl/CameraPlace.ts +4 -3
  28. package/runtime/sdk/gl/CharacterController.ts +184 -56
  29. package/runtime/sdk/gl/Gearbox.ts +212 -0
  30. package/runtime/sdk/gl/Geometry.ts +70 -9
  31. package/runtime/sdk/gl/Light.ts +64 -2
  32. package/runtime/sdk/gl/Lightmap.ts +179 -0
  33. package/runtime/sdk/gl/Material.ts +25 -0
  34. package/runtime/sdk/gl/Mesh.ts +6 -23
  35. package/runtime/sdk/gl/Model.ts +16 -2
  36. package/runtime/sdk/gl/Node.ts +119 -39
  37. package/runtime/sdk/gl/Physics.ts +75 -24
  38. package/runtime/sdk/gl/Scene.ts +148 -5
  39. package/runtime/sdk/gl/Shape.ts +42 -3
  40. package/runtime/sdk/gl/Trigger.ts +1 -6
  41. package/runtime/sdk/gl/Vehicle.ts +276 -322
  42. package/runtime/sdk/gl/Wheel.ts +240 -0
  43. package/runtime/sdk/gl/scenarios.ts +291 -317
  44. package/runtime/sdk/inject.ts +186 -171
  45. package/runtime/sdk/scene/defineScene.ts +1227 -1172
  46. package/runtime/sdk/scene/gizmos.ts +148 -128
  47. package/runtime/sdk/scene/material.ts +188 -0
  48. package/runtime/sdk-types.json +1 -1
@@ -0,0 +1,66 @@
1
+ // A one-way surface, as an aspect on a Node2D that already carries a Shape2D and a Physics2D body:
2
+ // solid from one side, passable from the other. The semisolid ledge of every platformer.
3
+ //
4
+ // const ledge = new Sprite({ texture: plank, size: [128, 8] })
5
+ // .aspect(Shape2D, {})
6
+ // .aspect(Physics2D, { motion: 'static' })
7
+ // .aspect(OneWay2D) // jump up through it, stand on top
8
+ //
9
+ // It is an ASPECT and not a flag on Shape2D on purpose: Shape2D is geometry, and which side of a
10
+ // surface is solid is behaviour. Keeping them apart is also what lets `normal` generalise the thing —
11
+ // a one-way WALL you can only walk through from the left is the same aspect turned sideways.
12
+ //
13
+ // Honoured by the character's mover AND, through a native pre-solve callback, by ordinary rigid
14
+ // bodies (a crate falls onto the ledge from above and passes up through it from below). Neither path
15
+ // costs any JS per frame.
16
+ //
17
+ // Dropping DOWN through one is the character's business, not the surface's — see
18
+ // `CharacterController2D.dropThrough`.
19
+
20
+ import { Aspect } from "../core/Aspect"
21
+ import { cx, cy, type Vec2Like } from "../math/vec"
22
+ import { Physics2D } from "./Physics2D"
23
+ import type { Node2D } from "./Node2D"
24
+
25
+ export class OneWay2D extends Aspect<"oneWay", Node2D> {
26
+ static readonly aspect = "oneWay"
27
+
28
+ private _normal: Vec2Like = [ 0, 1 ]
29
+ private _arc = 90
30
+ private _enabled = true
31
+
32
+ /** Which side is solid. Default `[0, 1]` — solid from above, i.e. a floor you pass through from
33
+ * below. `[1, 0]` is a wall you can only enter from the left. */
34
+ get normal(): Vec2Like { return this._normal }
35
+ set normal(v: Vec2Like) { this._normal = v; this._push() }
36
+
37
+ /** How far off `normal` an approach still counts as coming from the solid side, in degrees.
38
+ * Default 90 — the whole half-plane, which is what a platform wants. */
39
+ get arc(): number { return this._arc }
40
+ set arc(v: number) { this._arc = v; this._push() }
41
+
42
+ /** Turn the one-way behaviour off, making the surface solid from both sides again. */
43
+ get enabled(): boolean { return this._enabled }
44
+ set enabled(v: boolean) { this._enabled = v; this._push() }
45
+
46
+ onAttach(): void {
47
+ if (!this.node.has(Physics2D)) {
48
+ throw new Error("OneWay2D needs a Physics2D body on the same node — add it first: node.aspect(Shape2D, {…}).aspect(Physics2D, { motion: 'static' }).aspect(OneWay2D)")
49
+ }
50
+ this._push()
51
+ }
52
+
53
+ onDetach(): void {
54
+ const id = this.node.get(Physics2D)?.id
55
+ if (id) _creator2d.physicsSetOneWay(id, 0, 1, 90, false)
56
+ }
57
+
58
+ onReconfigure(): void { this._push() }
59
+
60
+ private _push(): void {
61
+ const id = this.node.get(Physics2D)?.id
62
+ // via the public accessors on purpose: an accessor pair that is only ever an object-literal key
63
+ // gets tree-shaken away, and the option would silently do nothing (see Physics2D.onAttach).
64
+ if (id) _creator2d.physicsSetOneWay(id, cx(this.normal), cy(this.normal), this.arc, this.enabled)
65
+ }
66
+ }
@@ -1,29 +1,42 @@
1
1
  // A rigid body, as an aspect on a Node2D, backed by Box2D v3 in creator-2d. Mirrors the 3D Physics
2
2
  // aspect. Requires a Shape2D aspect on the same node (its geometry becomes the body's solid fixture).
3
3
  //
4
- // Physics2D.configure({ gravity: [0, -980] }) // once, before creating bodies
5
- // ground.aspect(Shape2D, { segment: { from: [-500,0], to: [500,0] } }).aspect(Physics2D, { motion: 'static' })
6
- // hero.aspect(Shape2D, { box: [12, 20] }).aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
7
- // hero.physics.applyImpulse([0, 400]) // jump
8
- // hero.addEventListener('enter', other => …) // contact / sensor overlap began
4
+ // ground.aspect(Shape2D, { box: [500, 8] }).aspect(Physics2D, { motion: 'static' })
5
+ // crate.aspect(Shape2D, {}).aspect(Physics2D, { mass: 4, friction: 0.7, bounce: 0.1 })
6
+ // crate.physics.applyImpulse([0, 400])
7
+ // crate.addEventListener('enter', (other, c) => spark(c.point, c.normal))
8
+ //
9
+ // Physics2D.configure() is OPTIONAL — the world is created on the first body with sane defaults, and
10
+ // calling configure later just changes gravity (it used to destroy and rebuild the world, orphaning
11
+ // every handle that was already out).
9
12
  //
10
13
  // For a `dynamic` body, physics OWNS the transform — don't set node.position per frame; drive it via
11
- // velocity / applyImpulse. Reading node.position works: the node is flagged _physicsDriven and lazily
12
- // syncs its cache from native on read. Kinematic bodies are driven with moveTo. Best practice: keep
13
- // bodies on root nodes (see creator-2d PLAN.md).
14
+ // velocity / applyImpulse. A `position` write is a TELEPORT that reaches the body (Node2D routes it),
15
+ // which is also how a kinematic body is placed. Reading node.position always works.
16
+ //
17
+ // Keep bodies on ROOT nodes: a body parented to a moving node does not follow it (see
18
+ // docs/2d-physics-plan.md §6).
14
19
 
15
20
  import { Aspect } from "../core/Aspect"
16
21
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
17
22
  import { node2dRegistry, type Node2D } from "./Node2D"
18
23
  import { Shape2D } from "./Shape2D"
24
+ import {
25
+ PhysicsGroup2D, _addGroup, _watchGroupChanges, _unwatchGroupChanges,
26
+ categoryOf, solidMask, defaultGroup,
27
+ } from "./groups2d"
19
28
 
20
29
  export type MotionType = "static" | "kinematic" | "dynamic"
21
30
  const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
22
31
 
32
+ /** A neutral solid surface. Deliberately the same default as 3D `Physics.friction`. */
33
+ export const DEFAULT_FRICTION = 0.6
34
+
23
35
  export interface PhysicsConfig {
24
36
  /** Gravity in world units/s² (Y-up: down is negative). Default [0, -980]. */
25
37
  gravity?: Vec2Like
26
- /** Pixels per meter — tunes Box2D's internal tolerances; you still author in world units. Default 64. */
38
+ /** Pixels per meter — tunes Box2D's internal tolerances; you still author in world units. Default 64.
39
+ * Only applies before the world exists (it is a global solver tolerance). */
27
40
  pixelsPerMeter?: number
28
41
  /** Solver sub-steps per fixed step. Default 4. */
29
42
  subSteps?: number
@@ -43,22 +56,75 @@ const node2dById = (id: number): Node2D | null => (id ? node2dRegistry.get(id) ?
43
56
  export class Physics2D extends Aspect<"physics", Node2D> {
44
57
  static readonly aspect = "physics"
45
58
 
46
- /** Default "dynamic". */
47
- motion: MotionType = "dynamic"
48
- density = 1
49
- friction = 0.3
50
- restitution = 0
59
+ private _motion: MotionType = "dynamic"
60
+ /** Static (walls, floors), kinematic (script-driven platforms), or dynamic (fully simulated).
61
+ * Default "dynamic", and LIVE: freeze a crate into `'static'`, hand a ragdoll to `'dynamic'`. */
62
+ get motion(): MotionType { return this._motion }
63
+ set motion(m: MotionType) {
64
+ this._motion = m
65
+ this.node._physicsDriven = m !== "static"
66
+ if (this._h) _creator2d.physicsSetMotionType(this._h, MOTION[m])
67
+ }
51
68
  /** Lock rotation (essential for platformer characters). */
52
69
  fixedRotation = false
53
70
  /** Continuous collision for fast-moving bodies (projectiles). */
54
71
  bullet = false
55
72
 
73
+ private _mass = 0 // 0 = derive from the collider area
74
+ private _friction = DEFAULT_FRICTION
75
+ private _bounce = 0
56
76
  private _gravityScale = 1
77
+ private _linearDamping = 0
78
+ private _angularDamping = 0
79
+ private _group: PhysicsGroup2D | undefined
80
+ private _repush = (): void => {
81
+ if (this._h) _creator2d.physicsSetFilter(this._h, categoryOf(this._group), solidMask(this._group))
82
+ }
57
83
  private _h = 0 // native body id (0 = none / no physics support)
58
84
 
85
+ /** Which collision group this body belongs to (see Physics2D.addGroup). Live. */
86
+ get group(): PhysicsGroup2D | undefined { return this._group }
87
+ set group(g: PhysicsGroup2D | undefined) {
88
+ this._group = g
89
+ this.node.get(Shape2D)?._setFilter(categoryOf(g), solidMask(g))
90
+ this._repush()
91
+ }
92
+
93
+ /** Mass in world units. Omitted (or 0) = derived from the collider's AREA, so a big crate really is
94
+ * heavier than a small one. Setting it scales the shape-derived mass data, keeping the rotational
95
+ * inertia in the same ratio — a lopsided collider still tips the way its geometry says. */
96
+ get mass(): number { return this._h ? _creator2d.physicsGetMass(this._h) : this._mass }
97
+ set mass(v: number) { this._mass = v; if (this._h) _creator2d.physicsSetMass(this._h, v) }
98
+
99
+ /** Surface friction. 0 is frictionless, ~1 grippy; two bodies COMBINE as sqrt(a×b), so the lower
100
+ * value wins the argument. Default 0.6 — a neutral solid, matching 3D. */
101
+ get friction(): number { return this._friction }
102
+ set friction(v: number) { this._friction = v; this._pushMaterial() }
103
+
104
+ /** Bounciness, 0..1 — the fraction of the approach speed returned on impact. Two bodies combine as
105
+ * MAX (the bouncier one wins — the opposite of friction), and below ~64 units/s no bounce is
106
+ * applied at all, which is what stops a ball jittering on the floor forever. */
107
+ get bounce(): number { return this._bounce }
108
+ set bounce(v: number) { this._bounce = v; this._pushMaterial() }
109
+
110
+ // The live setters go to the BODY, but a later collider rebuild re-creates the fixture from the
111
+ // Shape2D — so the shape keeps its own copy of the material or a crouch would silently reset it.
112
+ private _pushMaterial(): void {
113
+ if (!this._h) return
114
+ _creator2d.physicsSetFriction(this._h, this._friction)
115
+ _creator2d.physicsSetBounce(this._h, this._bounce)
116
+ this.node.get(Shape2D)?._setMaterial(this._friction, this._bounce)
117
+ }
118
+
59
119
  /** Native physics body id. 0 until attached, or if the build has no physics support. Mirrors 3D `Physics.id`. */
60
120
  get id(): number { return this._h }
61
121
 
122
+ // NOTE — read every option through its PUBLIC accessor here, never the `_backing` field. chisel
123
+ // tree-shakes class members and keeps `_*` unconditionally, but it DROPS an accessor pair whose
124
+ // name no bundled code reads as a PROPERTY: an object-literal key (`aspect(Physics2D, { gravityScale: 0 })`)
125
+ // does NOT count. With the pair gone, Object.assign writes a plain own property, the backing field
126
+ // keeps its default, and the option silently does nothing — measured: a `gravityScale: 0` body fell.
127
+ // One `this.gravityScale` read in bundled SDK code keeps the whole pair alive.
62
128
  onAttach(): void {
63
129
  if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
64
130
  const shape = this.node.get(Shape2D)
@@ -67,18 +133,47 @@ export class Physics2D extends Aspect<"physics", Node2D> {
67
133
  }
68
134
  this._h = _creator2d.physicsCreateBody(this.node.id, MOTION[this.motion])
69
135
  if (!this._h) return
70
- shape._addTo(this._h, false /* solid */, this.density, this.friction, this.restitution)
136
+ shape._addTo(this._h, {
137
+ sensor: false, friction: this.friction, bounce: this.bounce,
138
+ category: categoryOf(this.group), mask: solidMask(this.group),
139
+ staticBody: this.motion === "static",
140
+ })
141
+ // Only a body that named a group cares when the topology changes.
142
+ if (this._group) _watchGroupChanges(this._repush)
71
143
  // dynamic/kinematic bodies are moved by the native step → the node must re-sync its transform
72
144
  // cache from native on read. Static bodies never move, so they keep the free cache.
73
145
  if (this.motion !== "static") this.node._physicsDriven = true
146
+ // Route node.position / .rotation writes to the body (this is what replaced physics.moveTo).
147
+ this.node._xf = this._h
148
+ this.node._xfKind = 2
149
+ if (this._mass > 0) _creator2d.physicsSetMass(this._h, this.mass)
74
150
  if (this.fixedRotation) _creator2d.physicsSetFixedRotation(this._h, true)
75
- if (this._gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this._gravityScale)
151
+ if (this.gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
152
+ if (this.linearDamping !== 0) _creator2d.physicsSetLinearDamping(this._h, this.linearDamping)
153
+ if (this.angularDamping !== 0) _creator2d.physicsSetAngularDamping(this._h, this.angularDamping)
76
154
  if (this.bullet) _creator2d.physicsSetBullet(this._h, true)
155
+ // The listener may have been added before the body existed; contact events are per-body opt-in.
156
+ if (this.node._wantsContacts) _creator2d.physicsSetContactEvents(this._h, true)
77
157
  }
78
158
 
79
159
  onDetach(): void {
160
+ _unwatchGroupChanges(this._repush)
80
161
  if (this._h) { _creator2d.physicsRemoveBody(this._h); this._h = 0 }
81
162
  this.node._physicsDriven = false
163
+ this.node._xf = 0
164
+ this.node._xfKind = 0
165
+ }
166
+
167
+ /** Re-applying options (`node.aspect(Physics2D, {…})`) pushes the live ones through. */
168
+ onReconfigure(): void {
169
+ if (!this._h) return
170
+ this._pushMaterial()
171
+ _creator2d.physicsSetFixedRotation(this._h, this.fixedRotation)
172
+ _creator2d.physicsSetBullet(this._h, this.bullet)
173
+ _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
174
+ _creator2d.physicsSetLinearDamping(this._h, this.linearDamping)
175
+ _creator2d.physicsSetAngularDamping(this._h, this.angularDamping)
176
+ if (this._mass > 0) _creator2d.physicsSetMass(this._h, this.mass)
82
177
  }
83
178
 
84
179
  /** Linear velocity in world units/second (fresh Vec2 on read). */
@@ -89,30 +184,55 @@ export class Physics2D extends Aspect<"physics", Node2D> {
89
184
  set velocity(v: Vec2Like) { if (this._h) _creator2d.physicsSetLinearVelocity(this._h, cx(v), cy(v)) }
90
185
 
91
186
  /** Angular velocity in degrees/second. */
187
+ get angularVelocity(): number { return this._h ? _creator2d.physicsGetAngularVelocity(this._h) : 0 }
92
188
  set angularVelocity(degPerSec: number) { if (this._h) _creator2d.physicsSetAngularVelocity(this._h, degPerSec) }
93
189
 
94
- /** Per-body gravity multiplier: 1 = full world gravity, 0 = floats, <0 = repelled. Set at attach
95
- * time (as an option) or live. */
190
+ /** Per-body gravity multiplier: 1 = full world gravity, 0 = floats, <0 = repelled. */
96
191
  get gravityScale(): number { return this._gravityScale }
97
192
  set gravityScale(scale: number) { this._gravityScale = scale; if (this._h) _creator2d.physicsSetGravityScale(this._h, scale) }
98
- set linearDamping(d: number) { if (this._h) _creator2d.physicsSetLinearDamping(this._h, d) }
193
+
194
+ get linearDamping(): number { return this._linearDamping }
195
+ set linearDamping(d: number) { this._linearDamping = d; if (this._h) _creator2d.physicsSetLinearDamping(this._h, d) }
196
+
197
+ get angularDamping(): number { return this._angularDamping }
198
+ set angularDamping(d: number) { this._angularDamping = d; if (this._h) _creator2d.physicsSetAngularDamping(this._h, d) }
199
+
200
+ /** Pause this body — it stops simulating and stops colliding until re-enabled. */
99
201
  set enabled(v: boolean) { if (this._h) _creator2d.physicsSetEnabled(this._h, v) }
100
202
 
101
- /** Apply an instantaneous impulse (world units) and wake the body. */
203
+ /** Box2D puts resting bodies to sleep; wake one to make it react to a change you made by hand. */
204
+ set awake(v: boolean) { if (this._h) _creator2d.physicsSetAwake(this._h, v) }
205
+
206
+ /** Apply an instantaneous impulse at the centre of mass (never spins the body) and wake it. */
102
207
  applyImpulse(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyLinearImpulse(this._h, cx(v), cy(v)); return this }
208
+ /** Apply an impulse at a WORLD point — the lever arm becomes spin. Use this when the push has a real
209
+ * point of contact: a bullet's hit point, a blast pushing a prop at its base. */
210
+ applyImpulseAt(v: Vec2Like, worldPoint: Vec2Like): this {
211
+ if (this._h) _creator2d.physicsApplyImpulseAt(this._h, cx(v), cy(v), cx(worldPoint), cy(worldPoint))
212
+ return this
213
+ }
103
214
  /** Apply a continuous force (world units) and wake the body. */
104
215
  applyForce(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyForce(this._h, cx(v), cy(v)); return this }
105
- /** Teleport / drive a kinematic (or dynamic) body to a world position (+ optional rotation degrees). */
106
- moveTo(p: Vec2Like, deg = 0): this { if (this._h) _creator2d.physicsSetTransform(this._h, cx(p), cy(p), deg); return this }
107
216
 
108
217
  // --- world (static) -------------------------------------------------------------------------
109
- /** Create / reset the physics world. Call once before creating bodies. */
218
+ /** Set the world's gravity / solver settings. OPTIONAL: the world is created on the first body with
219
+ * gravity [0, -980], 64 px/m and 4 sub-steps. Calling this later changes gravity LIVE and leaves
220
+ * bodies alone. */
110
221
  static configure(config: PhysicsConfig = {}): void {
111
222
  const g = config.gravity ?? [0, -980]
112
223
  _creator2d.physicsConfigure(cx(g), cy(g), config.pixelsPerMeter ?? 64, config.subSteps ?? 4)
113
224
  }
114
225
 
115
- /** Whether this build has physics support (CREATOR_2D_PHYSICS). */
226
+ /** World gravity, live. */
227
+ static get gravity(): Vec2 { return new Vec2(Physics2D._gravity) }
228
+ static set gravity(g: Vec2Like) {
229
+ Physics2D._gravity = [ cx(g), cy(g) ]
230
+ _creator2d.physicsConfigure(cx(g), cy(g), 0, 0)
231
+ }
232
+ private static _gravity: [number, number] = [ 0, -980 ]
233
+
234
+ /** Whether this build has physics support (CREATOR_2D_PHYSICS). NOTE: this reports the BUILD, not
235
+ * the world — there is nothing else to guard, since the world creates itself. */
116
236
  static get supported(): boolean { return !!_creator2d.physicsHasSupport && _creator2d.physicsHasSupport() }
117
237
 
118
238
  /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
@@ -122,15 +242,105 @@ export class Physics2D extends Aspect<"physics", Node2D> {
122
242
  static get interpolation(): boolean { return Physics2D._interpolation }
123
243
  static set interpolation(on: boolean) { Physics2D._interpolation = on; _creator2d.setInterpolation(on) }
124
244
 
125
- /** Closest body hit by the segment from→to, or null. `node` resolves the hit entity. */
126
- static raycast(from: Vec2Like, to: Vec2Like): RayHit | null {
127
- const o = _creator2d.physicsRaycastClosest(cx(from), cy(from), cx(to), cy(to))
245
+ /**
246
+ * Register a collision group. Groups are SUBTRACTIVE and SYMMETRIC (see groups2d.ts):
247
+ *
248
+ * const player = Physics2D.addGroup()
249
+ * const bullets = Physics2D.addGroup().ignoreSelf().ignore(player)
250
+ */
251
+ static addGroup(label?: string): PhysicsGroup2D { return _addGroup(label) }
252
+
253
+ /** The implicit group of every body that never named one - name it to exclude "everything else". */
254
+ static get defaultGroup(): PhysicsGroup2D { return defaultGroup }
255
+
256
+ // --- queries (static) -----------------------------------------------------------------------
257
+ /** Closest body hit by the segment, or null. `node` resolves the hit entity. */
258
+ static raycast(from: Vec2Like, to: Vec2Like, q?: Query2D): RayHit | null {
259
+ const o = _creator2d.physicsRaycastClosest(cx(from), cy(from), cx(to), cy(to), maskOf(q), ignoreOf(q), ignoreCount(q))
128
260
  if (o[0] < 0.5) return null
129
261
  return { node: node2dById(o[1]), point: new Vec2(o[2], o[3]), normal: new Vec2(o[4], o[5]), fraction: o[6] }
130
262
  }
131
263
 
132
- /** Topmost body (solid or sensor) whose shape contains the world point, or null. */
133
- static overlapPoint(p: Vec2Like): Node2D | null {
134
- return node2dById(_creator2d.physicsOverlapPoint(cx(p), cy(p)))
264
+ /** Every body along the segment, NEAREST FIRST - a piercing bullet, a laser, "how many walls are
265
+ * between us". Capped at 32 hits. */
266
+ static raycastAll(from: Vec2Like, to: Vec2Like, q?: Query2D): RayHit[] {
267
+ const o = _creator2d.physicsRaycastAll(cx(from), cy(from), cx(to), cy(to), maskOf(q), ignoreOf(q), ignoreCount(q), MAX_HITS)
268
+ const hits: RayHit[] = []
269
+ for (let i = 0; i + 5 < o.length; i += 6) {
270
+ if (o[i] === 0) break
271
+ hits.push({ node: node2dById(o[i]), point: new Vec2(o[i + 1], o[i + 2]), normal: new Vec2(o[i + 3], o[i + 4]), fraction: o[i + 5] })
272
+ }
273
+ return hits
274
+ }
275
+
276
+ /** Topmost body (solid or sensor) whose shape contains the world point, or null. "Topmost" means
277
+ * by DRAW order - layer, then z - so picking agrees with what the player sees. */
278
+ static overlapPoint(p: Vec2Like, q?: Query2D): Node2D | null {
279
+ return node2dById(_creator2d.physicsOverlapPoint(cx(p), cy(p), maskOf(q), ignoreOf(q), ignoreCount(q)))
280
+ }
281
+
282
+ /** Everything within `radius` of a point - an explosion, an aura, an aggro range. */
283
+ static overlapCircle(centre: Vec2Like, radius: number, q?: Query2D): Node2D[] {
284
+ return overlapNodes([ cx(centre), cy(centre) ], radius, q)
285
+ }
286
+
287
+ /** Everything inside an axis-aligned box (centre + HALF-extents) - a room, a camera bound. */
288
+ static overlapBox(centre: Vec2Like, half: Vec2Like, q?: Query2D): Node2D[] {
289
+ const x = cx(centre), y = cy(centre), hw = cx(half), hh = cy(half)
290
+ return overlapNodes([ x - hw, y - hh, x + hw, y - hh, x + hw, y + hh, x - hw, y + hh ], 0, q)
291
+ }
292
+
293
+ /**
294
+ * Everything touching a capsule from-to of the given radius. A capsule is the shape a DIRECTED
295
+ * question has: a beam with width, a sword arc, "does my radius fit through this gap" - none of
296
+ * which an axis-aligned box can express.
297
+ *
298
+ * It is also the SWEPT test: a circle of radius r moving from A to B during a frame sweeps exactly
299
+ * this capsule, so a fast projectile checked this way cannot tunnel through a thin wall the way two
300
+ * separate position checks do.
301
+ */
302
+ static overlapCapsule(from: Vec2Like, to: Vec2Like, radius: number, q?: Query2D): Node2D[] {
303
+ return overlapNodes([ cx(from), cy(from), cx(to), cy(to) ], radius, q)
304
+ }
305
+ }
306
+
307
+ /** Which groups a query may hit, and what to skip. */
308
+ export type Query2D = {
309
+ /** Only hit bodies in these groups. Omitted = everything. */
310
+ groups?: PhysicsGroup2D[]
311
+ /** Skip these nodes - the "don't hit myself" case a ray fired from inside its own body needs. */
312
+ ignore?: Node2D | Node2D[]
313
+ }
314
+
315
+ const MAX_HITS = 32
316
+ const emptyIgnore = new Float32Array(0)
317
+ let ignoreBuf = new Float32Array(8)
318
+
319
+ const maskOf = (q?: Query2D): number => {
320
+ if (!q?.groups || q.groups.length === 0) return 0 // 0 = everything, native-side
321
+ let m = 0
322
+ for (const g of q.groups) m |= g.category
323
+ return m >>> 0
324
+ }
325
+ const ignoreCount = (q?: Query2D): number =>
326
+ !q?.ignore ? 0 : Array.isArray(q.ignore) ? q.ignore.length : 1
327
+ const ignoreOf = (q?: Query2D): Float32Array => {
328
+ const n = ignoreCount(q)
329
+ if (n === 0) return emptyIgnore
330
+ if (ignoreBuf.length < n) ignoreBuf = new Float32Array(n)
331
+ const ig = q!.ignore!
332
+ if (Array.isArray(ig)) ig.forEach((node, i) => { ignoreBuf[i] = node.id })
333
+ else ignoreBuf[0] = (ig as Node2D).id
334
+ return ignoreBuf
335
+ }
336
+ const overlapNodes = (pts: number[], radius: number, q?: Query2D): Node2D[] => {
337
+ const o = _creator2d.physicsOverlap(new Float32Array(pts), pts.length / 2, radius,
338
+ maskOf(q), ignoreOf(q), ignoreCount(q), MAX_HITS)
339
+ const out: Node2D[] = []
340
+ for (let i = 0; i < o.length; i++) {
341
+ if (o[i] === 0) break
342
+ const n = node2dById(o[i])
343
+ if (n) out.push(n)
135
344
  }
345
+ return out
136
346
  }
@@ -2,7 +2,7 @@
2
2
  // 2D engine and starts its RAF loop; only the active scene renders. Grown from the Phase 0 seed.
3
3
 
4
4
  import { Color, type ColorInput } from "../core/color"
5
- import { _installAspectFrames } from "../core/Aspect"
5
+ import { _installAspectFrames, _installTimeScale, _attachSystem, _detachSystem, _detachAllSystems, type Aspect, type AspectCtor, type FieldOf, type TargetOf } from "../core/Aspect"
6
6
  import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
7
7
  import { cx, cy, type Vec2Like } from "../math/vec"
8
8
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
@@ -24,6 +24,8 @@ const installRenderSyncedFrames = (): void => {
24
24
  _creator2d.setLateUpdate(late)
25
25
  return true
26
26
  })
27
+ // Time.scale / Time.paused reach the engine's physics + animation clocks (feature-detected).
28
+ _installTimeScale((scale) => { _creator2d.setTimeScale?.(scale) })
27
29
  }
28
30
 
29
31
  export type Scene2DOptions = {
@@ -112,6 +114,35 @@ export class Scene2D implements Presentable {
112
114
  return this
113
115
  }
114
116
 
117
+ // ---- systems: aspects of the scene (core/Aspect.ts `System<'x', Scene2D>`) ----
118
+ /** @internal class → instance, the authoritative store (named accessors mirror this). */
119
+ readonly _systems = new Map<Function, Aspect<any, any, any>>()
120
+
121
+ /** Attach (and configure) a system, or reconfigure it if already present. Returns the scene typed
122
+ * as now-having it (`scene.system(Hud).hud`). A 3D `System<'x'>` (target Scene) is rejected here. */
123
+ // `this: Self` (not `Self & Scene2D`): with the intersection TS infers Self = Scene2D and a chain
124
+ // `scene.system(A).system(B)` loses A's accessor from the result type.
125
+ system<Self extends TargetOf<A>, A extends Aspect<any, any, any>>(
126
+ this: Self,
127
+ ctor: AspectCtor<A>,
128
+ opts?: Partial<A>,
129
+ ): Self & FieldOf<A> {
130
+ return _attachSystem(this as unknown as Scene2D, ctor, opts) as unknown as Self & FieldOf<A>
131
+ }
132
+ /** Safe access — undefined if the system isn't attached. */
133
+ get<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): A | undefined {
134
+ return this._systems.get(ctor) as A | undefined
135
+ }
136
+ /** Existence check AND type guard: inside `if (scene.has(Hud))`, `scene.hud` is present. */
137
+ has<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
138
+ return this._systems.has(ctor)
139
+ }
140
+ /** Detach a system (runs its onDetach). Chainable. */
141
+ removeSystem<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this {
142
+ _detachSystem(this, ctor)
143
+ return this
144
+ }
145
+
115
146
  // ---- Presentable (docs/navigation-presentable-plan.md) ----
116
147
 
117
148
  /** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
@@ -171,6 +202,7 @@ export class Scene2D implements Presentable {
171
202
  return this
172
203
  }
173
204
  destroy(): void {
205
+ _detachAllSystems(this) // systems go first (last-attached first), while the scene still exists
174
206
  this.close()
175
207
  _creator2d.destroyScene(this.id)
176
208
  }