lecodes-cli 0.17.2 → 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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +2376 -755
  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 +39 -4
  8. package/runtime/sdk/compile/compileProject.ts +16 -1
  9. package/runtime/sdk/compile/header.ts +6 -1
  10. package/runtime/sdk/compile/index.ts +31 -0
  11. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  12. package/runtime/sdk/compile/sceneEditor.ts +11 -1
  13. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  14. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  15. package/runtime/sdk/core/Aspect.ts +363 -95
  16. package/runtime/sdk/core/compWrite.ts +42 -0
  17. package/runtime/sdk/core/fields.ts +1 -1
  18. package/runtime/sdk/core/time.ts +81 -0
  19. package/runtime/sdk/g2/Camera2D.ts +8 -1
  20. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  21. package/runtime/sdk/g2/Node2D.ts +80 -10
  22. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  23. package/runtime/sdk/g2/Physics2D.ts +240 -30
  24. package/runtime/sdk/g2/Scene2D.ts +33 -1
  25. package/runtime/sdk/g2/Shape2D.ts +218 -22
  26. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  27. package/runtime/sdk/g2/groups2d.ts +106 -0
  28. package/runtime/sdk/g2/loop.ts +15 -4
  29. package/runtime/sdk/gl/Camera.ts +41 -0
  30. package/runtime/sdk/gl/CameraPlace.ts +52 -0
  31. package/runtime/sdk/gl/CharacterController.ts +184 -55
  32. package/runtime/sdk/gl/Gearbox.ts +212 -0
  33. package/runtime/sdk/gl/Geometry.ts +70 -9
  34. package/runtime/sdk/gl/IK.ts +193 -174
  35. package/runtime/sdk/gl/Light.ts +64 -2
  36. package/runtime/sdk/gl/Lightmap.ts +179 -0
  37. package/runtime/sdk/gl/Material.ts +36 -0
  38. package/runtime/sdk/gl/Mesh.ts +6 -23
  39. package/runtime/sdk/gl/Model.ts +23 -8
  40. package/runtime/sdk/gl/Node.ts +350 -285
  41. package/runtime/sdk/gl/Physics.ts +222 -126
  42. package/runtime/sdk/gl/Scene.ts +175 -8
  43. package/runtime/sdk/gl/Shape.ts +255 -12
  44. package/runtime/sdk/gl/Trigger.ts +1 -6
  45. package/runtime/sdk/gl/Vehicle.ts +473 -0
  46. package/runtime/sdk/gl/Wheel.ts +240 -0
  47. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  48. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  49. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  50. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  51. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  52. package/runtime/sdk/gl/animation/core.ts +294 -0
  53. package/runtime/sdk/gl/scenarios.ts +291 -349
  54. package/runtime/sdk/inject.ts +186 -162
  55. package/runtime/sdk/runtime/app.ts +13 -0
  56. package/runtime/sdk/runtime/input.ts +169 -6
  57. package/runtime/sdk/scene/defineScene.ts +1227 -1016
  58. package/runtime/sdk/scene/gizmos.ts +148 -0
  59. package/runtime/sdk/scene/material.ts +188 -0
  60. package/runtime/sdk-types.json +1 -1
  61. package/runtime/sdk/gl/Animator.ts +0 -642
  62. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -7,12 +7,31 @@
7
7
  // goal.aspect(Shape2D, { circle: 20 }).aspect(Trigger2D)
8
8
  // hero.aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } }).aspect(Physics2D, { motion: 'dynamic' })
9
9
  //
10
- // With no geometry set, Shape2D derives a box from the node's sprite size ("auto").
10
+ // With no geometry set, Shape2D derives a box from WHAT THE NODE DRAWS — the sprite's size scaled by
11
+ // the node, positioned by its anchor. So `aspect(Shape2D, {})` on a sprite anchored at its feet gives
12
+ // a collider that wraps the art instead of one centred on the pivot (half of it underground), which
13
+ // is why every character used to carry a hand-computed offset.
14
+ //
15
+ // RE-CONFIGURING REBUILDS IT IN PLACE: `node.aspect(Shape2D, { capsule: … })` on a live node swaps the
16
+ // fixture while the body keeps its id, its velocity and its place in the world. That is what makes a
17
+ // crouch (and a live editor edit) possible.
11
18
 
12
19
  import { Aspect } from "../core/Aspect"
13
20
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
21
+ import { ALL_GROUPS } from "./groups2d"
14
22
  import type { Node2D } from "./Node2D"
15
23
 
24
+ /** @internal How an owner (Physics2D / Trigger2D) wants the geometry realised. */
25
+ export type ShapeFixture2D = {
26
+ sensor: boolean
27
+ friction: number
28
+ bounce: number
29
+ category?: number
30
+ mask?: number
31
+ /** Chains carry no mass, so Box2D only accepts them on a static body. */
32
+ staticBody?: boolean
33
+ }
34
+
16
35
  export class Shape2D extends Aspect<"shape", Node2D> {
17
36
  static readonly aspect = "shape"
18
37
 
@@ -22,42 +41,219 @@ export class Shape2D extends Aspect<"shape", Node2D> {
22
41
  circle?: number
23
42
  /** Capsule between two local points, with a radius. */
24
43
  capsule?: { from: Vec2Like, to: Vec2Like, radius: number }
25
- /** Thin segment (an edge — for static ground/walls/slopes) between two local points. */
44
+ /** Thin segment (an edge) between two local points. */
26
45
  segment?: { from: Vec2Like, to: Vec2Like }
27
- /** Convex polygon, up to 8 local points (a convex hull is computed). */
46
+ /** Convex polygon, up to 8 local points (a convex hull is computed — a CONCAVE outline silently
47
+ * becomes its hull; use `chain` for those). */
28
48
  polygon?: Vec2Like[]
29
- /** Local offset of the shape from the node origin. */
30
- offset?: Vec2Like
49
+ /**
50
+ * A polyline of connected segments — the one shape that expresses a long CONCAVE surface (rolling
51
+ * terrain, a cave wall) in one piece, seam-free. Pass only the points that should be solid; the
52
+ * tangent points Box2D needs at the ends are synthesised here.
53
+ *
54
+ * STATIC bodies only (a polyline has no area, so no mass), at least 2 points (4 with `loop`), and it
55
+ * must not self-intersect. It is ONE-SIDED: solid on the right of the point order — walk the points
56
+ * so that the solid material is on your right, or set `flip`.
57
+ */
58
+ chain?: Vec2Like[]
59
+ /** `chain`: close the contour (an island / a room boundary). Needs at least 4 points. */
60
+ loop?: boolean
61
+ /** `chain`: put the solid side on the other side of the line. */
62
+ flip?: boolean
63
+ /** The collider's centre relative to the node, in world units. Applies to EVERY kind — it
64
+ * translates a capsule's / segment's / polygon's / chain's points too, unlike the old `offset`. */
65
+ origin?: Vec2Like
66
+
67
+ /** @internal Geometry as of the last build — an unchanged signature means nothing to rebuild. */
68
+ private _sig = ""
69
+ /** @internal The last resize the CHARACTER refused (backs `controller.resizing`). */
70
+ _resizeRefused = false
71
+ /** @internal How the owner asked for it, so a rebuild can replay the same fixture. */
72
+ private _fx: ShapeFixture2D | null = null
73
+ private _handle = 0
74
+
75
+ /** @internal — the character claimed this shape; record the geometry so a later re-configure is
76
+ * compared against it. */
77
+ _claimedByCharacter(): void { this._sig = this._signature(); this._resizeRefused = false }
78
+
79
+ /** @internal — realise this geometry as a fixture on `handle` (a body from Physics2D/Trigger2D). */
80
+ _addTo(handle: number, fx: ShapeFixture2D): void {
81
+ this._handle = handle
82
+ this._fx = fx
83
+ this._sig = this._signature()
84
+ this._create()
85
+ }
86
+
87
+ /** @internal — the owner's material changed live; keep it for the next rebuild. */
88
+ _setMaterial(friction: number, bounce: number): void {
89
+ if (this._fx) { this._fx.friction = friction; this._fx.bounce = bounce }
90
+ }
91
+
92
+ /** @internal — the owner's filter changed live. */
93
+ _setFilter(category: number, mask: number): void {
94
+ if (this._fx) { this._fx.category = category; this._fx.mask = mask }
95
+ }
96
+
97
+ onDetach(): void { this._handle = 0; this._fx = null; this._sig = ""; this._resizeRefused = false }
31
98
 
32
99
  /**
33
- * @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/Trigger2D).
34
- * `sensor` selects a sensor (trigger) vs solid fixture; density/friction/restitution are the
35
- * material (ignored for sensors / segments only use friction+restitution).
100
+ * Re-configuring the aspect swaps the fixture in place — same body, same id, same velocity, same
101
+ * position. Nothing happens when the geometry is unchanged, so re-applying the same options in a
102
+ * loop is free.
36
103
  */
37
- _addTo(handle: number, sensor: boolean, density: number, friction: number, restitution: number): void {
38
- const ox = this.offset ? cx(this.offset) : 0
39
- const oy = this.offset ? cy(this.offset) : 0
40
- if (this.circle !== undefined) {
41
- _creator2d.physicsAddCircle(handle, this.circle, ox, oy, density, friction, restitution, sensor)
104
+ onReconfigure(): void {
105
+ const sig = this._signature()
106
+ if (sig === this._sig) return
107
+
108
+ // A CharacterController2D owns this shape instead of a body: resizing goes to the mover, which
109
+ // VALIDATES it (you cannot stand up under a low pipe) and can refuse.
110
+ if (this.node._xfKind === 1) {
111
+ const cap = this._capsule()
112
+ const ok = _creator2d.characterSetCapsule(this.node._xf, cap.x1, cap.y1, cap.x2, cap.y2, cap.radius)
113
+ // On refusal NOTHING changed — so DON'T commit the signature. The next identical
114
+ // `aspect(Shape2D, …)` then still sees a difference and retries; committing here would swallow
115
+ // every retry for good, which is the whole trap in this design.
116
+ this._resizeRefused = !ok
117
+ if (ok) this._sig = sig
118
+ return
119
+ }
120
+
121
+ if (!this._handle || !this._fx) return
122
+ this._sig = sig
123
+ _creator2d.physicsClearShapes(this._handle)
124
+ this._create()
125
+ }
126
+
127
+ /**
128
+ * @internal — this geometry as a CAPSULE, for CharacterController2D (the mover takes nothing else).
129
+ * An explicit capsule is used as given; anything else is derived from the drawn rect, so
130
+ * `aspect(Shape2D, {})` on a sprite gives a capsule that wraps the art.
131
+ */
132
+ _capsule(): { x1: number, y1: number, x2: number, y2: number, radius: number } {
133
+ const ox = this.origin ? cx(this.origin) : 0
134
+ const oy = this.origin ? cy(this.origin) : 0
135
+ if (this.capsule) {
136
+ const { from, to, radius } = this.capsule
137
+ return { x1: cx(from) + ox, y1: cy(from) + oy, x2: cx(to) + ox, y2: cy(to) + oy, radius }
138
+ }
139
+ if (this.circle !== undefined) return { x1: ox, y1: oy, x2: ox, y2: oy, radius: this.circle }
140
+ let hw = 8, hh = 8, bx = ox, by = oy
141
+ if (this.box) { hw = cx(this.box); hh = cy(this.box) }
142
+ else {
143
+ const a = this._autoBox()
144
+ hw = a.hw; hh = a.hh
145
+ if (!this.origin) { bx = a.x; by = a.y }
146
+ }
147
+ const radius = Math.min(hw, hh)
148
+ const half = Math.max(0, hh - radius)
149
+ return { x1: bx, y1: by - half, x2: bx, y2: by + half, radius }
150
+ }
151
+
152
+ // ---- internals ---------------------------------------------------------------------------------
153
+
154
+ private _create(): void {
155
+ const fx = this._fx!
156
+ const handle = this._handle
157
+ const category = fx.category ?? ALL_GROUPS
158
+ const mask = fx.mask ?? ALL_GROUPS
159
+ const ox = this.origin ? cx(this.origin) : 0
160
+ const oy = this.origin ? cy(this.origin) : 0
161
+
162
+ if (this.chain) {
163
+ this._createChain(handle, fx, category, mask, ox, oy)
164
+ } else if (this.circle !== undefined) {
165
+ _creator2d.physicsAddCircle(handle, this.circle, ox, oy, fx.friction, fx.bounce, fx.sensor, category, mask)
42
166
  } else if (this.capsule) {
43
167
  const { from, to, radius } = this.capsule
44
- _creator2d.physicsAddCapsule(handle, cx(from), cy(from), cx(to), cy(to), radius, density, friction, restitution, sensor)
168
+ _creator2d.physicsAddCapsule(handle, cx(from) + ox, cy(from) + oy, cx(to) + ox, cy(to) + oy, radius,
169
+ fx.friction, fx.bounce, fx.sensor, category, mask)
45
170
  } else if (this.segment) {
46
- _creator2d.physicsAddSegment(handle, cx(this.segment.from), cy(this.segment.from), cx(this.segment.to), cy(this.segment.to), friction, restitution)
171
+ const { from, to } = this.segment
172
+ _creator2d.physicsAddSegment(handle, cx(from) + ox, cy(from) + oy, cx(to) + ox, cy(to) + oy,
173
+ fx.friction, fx.bounce, category, mask)
47
174
  } else if (this.polygon) {
48
175
  const pts = new Float32Array(this.polygon.length * 2)
49
- this.polygon.forEach((p, i) => { pts[i * 2] = cx(p); pts[i * 2 + 1] = cy(p) })
50
- _creator2d.physicsAddPolygon(handle, pts, this.polygon.length, density, friction, restitution, sensor)
176
+ this.polygon.forEach((p, i) => { pts[i * 2] = cx(p) + ox; pts[i * 2 + 1] = cy(p) + oy })
177
+ _creator2d.physicsAddPolygon(handle, pts, this.polygon.length, fx.friction, fx.bounce, fx.sensor, category, mask)
51
178
  } else {
52
- // box: explicit half-extents, or "auto" from the node's sprite size (fallback 8×8).
53
- let hw = 8, hh = 8
179
+ // box: explicit half-extents, or "auto" — the rect the node actually draws.
180
+ let hw = 8, hh = 8, bx = ox, by = oy
54
181
  if (this.box) {
55
182
  hw = cx(this.box); hh = cy(this.box)
56
183
  } else {
57
- const size = (this.node as { size?: Vec2 }).size
58
- if (size) { hw = size.x * 0.5; hh = size.y * 0.5 }
184
+ const auto = this._autoBox()
185
+ hw = auto.hw; hh = auto.hh
186
+ if (!this.origin) { bx = auto.x; by = auto.y } // an explicit origin still wins
59
187
  }
60
- _creator2d.physicsAddBox(handle, hw, hh, ox, oy, density, friction, restitution, sensor)
188
+ _creator2d.physicsAddBox(handle, hw, hh, bx, by, fx.friction, fx.bounce, fx.sensor, category, mask)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Box2D's chain wants the two TANGENT points as extra entries and does not collide on its first or
194
+ * last edge — pass it your outline verbatim and you lose both end segments (four points give ONE).
195
+ * So the caller passes only the solid points and the ends are extrapolated here: `p0 + (p0 - p1)`.
196
+ * A loop needs none of that (every point is interior) but does need 4 of them.
197
+ */
198
+ private _createChain(handle: number, fx: ShapeFixture2D, category: number, mask: number, ox: number, oy: number): void {
199
+ const src = this.chain!
200
+ const loop = this.loop === true
201
+ if (src.length < (loop ? 4 : 2)) {
202
+ throw new Error(`Shape2D chain needs at least ${loop ? 4 : 2} points (got ${src.length})${loop ? " — a closed contour of 3 is a polygon" : ""}`)
203
+ }
204
+ if (fx.staticBody === false) {
205
+ throw new Error("Shape2D { chain } works on a STATIC body only — a polyline has no area, so no mass. Use polygon/box for a dynamic body.")
61
206
  }
207
+ const order = this.flip === true ? [ ...src ].reverse() : src
208
+ const p: number[] = []
209
+ if (!loop) {
210
+ // leading ghost: mirror the first point across the second
211
+ p.push(2 * cx(order[0]) - cx(order[1]) + ox, 2 * cy(order[0]) - cy(order[1]) + oy)
212
+ }
213
+ for (const v of order) p.push(cx(v) + ox, cy(v) + oy)
214
+ if (!loop) {
215
+ const n = order.length
216
+ p.push(2 * cx(order[n - 1]) - cx(order[n - 2]) + ox, 2 * cy(order[n - 1]) - cy(order[n - 2]) + oy)
217
+ }
218
+ _creator2d.physicsAddChain(handle, new Float32Array(p), p.length / 2, loop, fx.friction, fx.bounce, category, mask)
219
+ }
220
+
221
+ // Everything that changes the GEOMETRY, so a re-configure that only touches the material is free.
222
+ private _signature(): string {
223
+ const o = this.origin ? `${cx(this.origin)},${cy(this.origin)}` : ""
224
+ if (this.chain) return `chain:${this.chain.map(v => `${cx(v)},${cy(v)}`).join(";")}|${this.loop}|${this.flip}|${o}`
225
+ if (this.circle !== undefined) return `circle:${this.circle}|${o}`
226
+ if (this.capsule) {
227
+ const { from, to, radius } = this.capsule
228
+ return `cap:${cx(from)},${cy(from)},${cx(to)},${cy(to)},${radius}|${o}`
229
+ }
230
+ if (this.segment) {
231
+ const { from, to } = this.segment
232
+ return `seg:${cx(from)},${cy(from)},${cx(to)},${cy(to)}|${o}`
233
+ }
234
+ if (this.polygon) return `poly:${this.polygon.map(v => `${cx(v)},${cy(v)}`).join(";")}|${o}`
235
+ if (this.box) return `box:${cx(this.box)},${cy(this.box)}|${o}`
236
+ const a = this._autoBox()
237
+ return `auto:${a.hw},${a.hh},${a.x},${a.y}|${o}`
238
+ }
239
+
240
+ /**
241
+ * @internal — half-extents and centre of the rect this node DRAWS, in the node's own frame.
242
+ *
243
+ * Sprite anchor space is Y-DOWN while the world is Y-up (see docs/conventions.md), so `anchor: [.5, 1]`
244
+ * ("feet") puts the art ABOVE the origin: its centre sits at +h/2. A node with no sprite falls back
245
+ * to 8x8 at the origin. This is a SNAPSHOT taken when the fixture is built — growing the sprite later
246
+ * does not resize the collider; re-configure the aspect to re-measure.
247
+ */
248
+ private _autoBox(): { hw: number, hh: number, x: number, y: number } {
249
+ const n = this.node as { size?: Vec2, anchor?: Vec2, scale?: Vec2 }
250
+ const size = n.size
251
+ if (!size || !(size.x > 0) || !(size.y > 0)) return { hw: 8, hh: 8, x: 0, y: 0 }
252
+ const sx = n.scale ? Math.abs(n.scale.x) : 1
253
+ const sy = n.scale ? Math.abs(n.scale.y) : 1
254
+ const w = size.x * sx, h = size.y * sy
255
+ const ax = n.anchor ? n.anchor.x : 0.5
256
+ const ay = n.anchor ? n.anchor.y : 0.5
257
+ return { hw: w * 0.5, hh: h * 0.5, x: (0.5 - ax) * w, y: (ay - 0.5) * h }
62
258
  }
63
259
  }
@@ -1,25 +1,43 @@
1
- // A trigger zone, as an aspect on a Node2D. Requires a Shape2D (its geometry); creates a static SENSOR
2
- // body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
3
- // instead of colliding. A trigger does not block movement.
1
+ // A trigger zone, as an aspect on a Node2D. Requires a Shape2D (its geometry); creates a SENSOR body
2
+ // from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events instead of
3
+ // colliding. A trigger does not block movement.
4
4
  //
5
- // const goal = new Node2D({ ... })
5
+ // const goal = new Node2D()
6
6
  // .aspect(Shape2D, { box: [32, 64] })
7
7
  // .aspect(Trigger2D)
8
8
  // goal.addEventListener('enter', other => win(other))
9
- // goal.addEventListener('exit', other => ...)
9
+ // goal.position = [x, y] // the zone follows — it is kinematic, not static
10
+ // goal.trigger.enabled = false // switch it off instead of parking it off-screen
10
11
  //
11
12
  // (A trigger is still pointer-pickable — clicks hit sensor shapes too.)
13
+ //
14
+ // Keep triggers on ROOT nodes: a sensor parented to a moving node does not follow it (see
15
+ // docs/2d-physics-plan.md §6).
12
16
 
13
17
  import { Aspect } from "../core/Aspect"
14
- import { cx, cy, type Vec2Like } from "../math/vec"
15
18
  import { ensurePhysicsEvents } from "./loop"
16
19
  import { Shape2D } from "./Shape2D"
20
+ import { PhysicsGroup2D, _watchGroupChanges, _unwatchGroupChanges, categoryOf, sensorMask } from "./groups2d"
17
21
  import type { Node2D } from "./Node2D"
18
22
 
19
23
  export class Trigger2D extends Aspect<"trigger", Node2D> {
20
24
  static readonly aspect = "trigger"
21
25
 
22
26
  private _h = 0
27
+ private _enabled = true
28
+ private _group: PhysicsGroup2D | undefined
29
+ private _repush = (): void => {
30
+ if (this._h) _creator2d.physicsSetFilter(this._h, categoryOf(this._group), sensorMask(this._group))
31
+ }
32
+
33
+ /** Which collision group this zone belongs to. Live. */
34
+ get group(): PhysicsGroup2D | undefined { return this._group }
35
+ set group(g: PhysicsGroup2D | undefined) {
36
+ this._group = g
37
+ this.node.get(Shape2D)?._setFilter(categoryOf(g), sensorMask(g))
38
+ this._repush()
39
+ }
40
+
23
41
  /** Native physics body id (0 if no physics support). Mirrors 3D `Trigger.id`. */
24
42
  get id(): number { return this._h }
25
43
 
@@ -29,19 +47,31 @@ export class Trigger2D extends Aspect<"trigger", Node2D> {
29
47
  if (!shape) {
30
48
  throw new Error("Trigger2D requires a Shape2D aspect — add it first: node.aspect(Shape2D, {…}).aspect(Trigger2D)")
31
49
  }
32
- this._h = _creator2d.physicsCreateBody(this.node.id, 0 /* static */)
50
+ // KINEMATIC, not static: a zone has to be able to ride an elevator, a patrolling enemy or a
51
+ // fighter's fist. It used to be static, which is why games parked spent triggers at [-9999,-9999].
52
+ this._h = _creator2d.physicsCreateBody(this.node.id, 1 /* kinematic */)
33
53
  if (!this._h) return
34
- shape._addTo(this._h, true /* sensor */, 1, 0, 0)
54
+ // sensorMask always keeps the character bit: a kinematic mover is not a Box2D body, so its shadow
55
+ // body is what triggers actually see, and no game should have to know that.
56
+ shape._addTo(this._h, { sensor: true, friction: 0, bounce: 0, category: categoryOf(this.group), mask: sensorMask(this.group) })
57
+ if (this._group) _watchGroupChanges(this._repush)
58
+ this.node._xf = this._h // node.position moves the zone
59
+ this.node._xfKind = 2
60
+ if (!this.enabled) _creator2d.physicsSetEnabled(this._h, false) // via the accessor on purpose — see Physics2D.onAttach
35
61
  ensurePhysicsEvents() // routes contact/sensor events → the node's 'enter' / 'exit'
36
62
  }
37
63
 
38
64
  onDetach(): void {
65
+ _unwatchGroupChanges(this._repush)
39
66
  if (this._h) { _creator2d.physicsRemoveBody(this._h); this._h = 0 }
67
+ this.node._xf = 0
68
+ this.node._xfKind = 0
40
69
  }
41
70
 
42
- /** Reposition the trigger zone to a world point. */
43
- moveTo(p: Vec2Like): this {
44
- if (this._h) _creator2d.physicsSetTransform(this._h, cx(p), cy(p), 0)
45
- return this
71
+ /** Switch the zone off and on — a spent pickup, a hitbox that is only live during a swing. */
72
+ get enabled(): boolean { return this._enabled }
73
+ set enabled(v: boolean) {
74
+ this._enabled = v
75
+ if (this._h) _creator2d.physicsSetEnabled(this._h, v)
46
76
  }
47
77
  }
@@ -0,0 +1,106 @@
1
+ // Collision groups — which bodies are allowed to touch which. Called groups, not layers, because
2
+ // `layer` already means DRAW order in 2D (`node.layer`, `scene2d.layer(n).ySort()`).
3
+ //
4
+ // // game/groups.ts
5
+ // export const player = Physics2D.addGroup()
6
+ // export const bullets = Physics2D.addGroup().ignoreSelf().ignore(player)
7
+ //
8
+ // bullet.aspect(Physics2D, { motion: 'dynamic', group: bullets })
9
+ //
10
+ // Two decisions worth knowing, both deliberate:
11
+ //
12
+ // SUBTRACTIVE, not enumerative. A group collides with everything EXCEPT what it ignores, so adding a
13
+ // new group later cannot silently stop existing pairs from colliding. The enumerative form —
14
+ // "collides only with [ground, enemy]" — decays: introduce `crate` a month later and the bullets
15
+ // start flying through it, with nothing to notice.
16
+ //
17
+ // SYMMETRIC. Box2D's filter is an AND of two masks, so declaring the relation on one side only is the
18
+ // classic trap ("A allowed B but B didn't allow A" → they silently pass through). Both sides are set
19
+ // from one undirected edge here, so naming it once is enough.
20
+ //
21
+ // Budget: 32 bits, because a category/mask must cross the bridge as a plain JS number and only 2^32 is
22
+ // exactly representable there. One bit is the implicit default group, one is reserved for the
23
+ // character's shadow body — 30 user groups.
24
+
25
+ /** Collides with everything. */
26
+ export const ALL_GROUPS = 0xffffffff
27
+
28
+ /** @internal Bit 0 — every body that never names a group. */
29
+ export const DEFAULT_BIT = 1 << 0
30
+ /** @internal Bit 1 — the character's shadow body: solid shapes never see it, sensors always do. */
31
+ export const CHARACTER_BIT = 1 << 1
32
+
33
+ const MAX_USER_GROUPS = 30
34
+ let nextBit = 2
35
+ const all: PhysicsGroup2D[] = []
36
+
37
+ // Bodies whose filter has to be re-pushed when the topology changes. Only bodies that actually named
38
+ // a group register, so a project that never touches groups keeps an empty set.
39
+ const listeners = new Set<() => void>()
40
+ /** @internal */
41
+ export const _watchGroupChanges = (fn: () => void): void => { listeners.add(fn) }
42
+ /** @internal */
43
+ export const _unwatchGroupChanges = (fn: () => void): void => { listeners.delete(fn) }
44
+
45
+ export class PhysicsGroup2D {
46
+ /** The single category bit this group owns. */
47
+ readonly bit: number
48
+ /** Optional debug label, so an inspector says "bullets" rather than "group #3". */
49
+ readonly label: string
50
+
51
+ private _ignored = 0
52
+
53
+ /** @internal — use `Physics2D.addGroup()`. */
54
+ constructor(bit: number, label: string) {
55
+ this.bit = bit
56
+ this.label = label
57
+ }
58
+
59
+ /** @internal The Box2D pair for a shape in this group. */
60
+ get category(): number { return this.bit }
61
+ /** @internal */
62
+ get mask(): number { return (ALL_GROUPS & ~this._ignored) >>> 0 }
63
+
64
+ /**
65
+ * Stop colliding with these groups — symmetric, so naming it on one side is enough. Safe to call
66
+ * after bodies exist: their filters are re-pushed.
67
+ */
68
+ ignore(...groups: PhysicsGroup2D[]): this {
69
+ for (const g of groups) {
70
+ this._ignored |= g.bit
71
+ g._ignored |= this.bit
72
+ }
73
+ for (const fn of listeners) fn()
74
+ return this
75
+ }
76
+
77
+ /** Members of this group don't collide with EACH OTHER — bullets, debris, a ragdoll's own bones. */
78
+ ignoreSelf(): this {
79
+ this._ignored |= this.bit
80
+ for (const fn of listeners) fn()
81
+ return this
82
+ }
83
+ }
84
+
85
+ /** @internal The implicit group of every body that never names one. */
86
+ export const defaultGroup = new PhysicsGroup2D(DEFAULT_BIT, "default")
87
+
88
+ /** @internal Register a new group (see `Physics2D.addGroup`). */
89
+ export const _addGroup = (label = ""): PhysicsGroup2D => {
90
+ if (nextBit >= 2 + MAX_USER_GROUPS) {
91
+ throw new Error(`Physics2D.addGroup: out of collision groups (${MAX_USER_GROUPS} max — a category has to cross the bridge as a 32-bit number, and two bits are reserved).`)
92
+ }
93
+ const g = new PhysicsGroup2D((1 << nextBit) >>> 0, label || `group${nextBit - 1}`)
94
+ nextBit++
95
+ all.push(g)
96
+ return g
97
+ }
98
+
99
+ /** @internal Sensors must always be visible to the character's shadow body, solids never. */
100
+ export const sensorMask = (g: PhysicsGroup2D | undefined): number =>
101
+ (((g ?? defaultGroup).mask | CHARACTER_BIT) & ALL_GROUPS) >>> 0
102
+ /** @internal */
103
+ export const solidMask = (g: PhysicsGroup2D | undefined): number =>
104
+ (((g ?? defaultGroup).mask & ~CHARACTER_BIT) & ALL_GROUPS) >>> 0
105
+ /** @internal */
106
+ export const categoryOf = (g: PhysicsGroup2D | undefined): number => (g ?? defaultGroup).category
@@ -5,6 +5,7 @@
5
5
  // The animation-event registration is lazy (first use) so a 2D project that never animates a sprite
6
6
  // pulls none of this in.
7
7
 
8
+ import { Vec2 } from "../math/vec"
8
9
  import { node2dRegistry } from "./Node2D"
9
10
 
10
11
  export const onUpdate = (callback: (dt: number) => void): void => _creator2d.onUpdate(callback)
@@ -23,17 +24,27 @@ export const ensureAnimEvents = (): void => {
23
24
 
24
25
  // Route native physics contact/sensor events to the owning nodes' 'enter'/'exit' events. Each event
25
26
  // is delivered to BOTH nodes with the other as the argument. type: 0 contactBegin, 1 contactEnd,
26
- // 2 sensorBegin, 3 sensorEnd. Registered lazily on the first physics body.
27
+ // 2 sensorBegin, 3 sensorEnd. Registered lazily by the first 'enter'/'exit' listener (Node2D) or the
28
+ // first Trigger2D — it used to hang off Trigger2D alone, so solid-vs-solid contacts silently never
29
+ // fired in a project without a single trigger.
30
+ //
31
+ // A contact BEGIN also carries the manifold, which the listener gets as a second argument: the world
32
+ // point, the surface normal, and the approach speed. The normal points from A to B, so it is FLIPPED
33
+ // for the second delivery — each side gets the normal pointing away from the thing it hit, which is
34
+ // what "did I land on top or hit a wall" needs.
27
35
  let physicsEventsRegistered = false
28
36
  export const ensurePhysicsEvents = (): void => {
29
37
  if (physicsEventsRegistered) return
30
38
  physicsEventsRegistered = true
31
- _creator2d.setOnPhysicsEvent((a, b, type) => {
39
+ _creator2d.setOnPhysicsEvent((a, b, type, px, py, nx, ny, speed) => {
32
40
  const na = node2dRegistry.get(a)
33
41
  const nb = node2dRegistry.get(b)
34
42
  if (!na || !nb) return
35
43
  const channel = type === 1 || type === 3 ? "exit" : "enter"
36
- na._emitCollision(channel, nb)
37
- nb._emitCollision(channel, na)
44
+ // Box2D's manifold normal points from A TOWARD B. Each listener wants the normal of the surface
45
+ // it hit, pointing back at itself — so A gets it negated and B gets it as-is. Landing on a floor
46
+ // then reads normal.y ≈ +1 for the falling body, which is the test a game actually writes.
47
+ na._emitCollision(channel, nb, { point: new Vec2(px, py), normal: new Vec2(-nx, -ny), speed })
48
+ nb._emitCollision(channel, na, { point: new Vec2(px, py), normal: new Vec2(nx, ny), speed })
38
49
  })
39
50
  }
@@ -2,9 +2,23 @@
2
2
  // screen↔world ray helpers. Created and attached by Scene. Ported from worker/src/components/Camera.ts.
3
3
 
4
4
  import { Vec3 } from "../math/vec"
5
+ import { Aspect } from "../core/Aspect"
5
6
  import { Node } from "./Node"
6
7
  import { Ray } from "./Ray"
7
8
 
9
+ /** @internal `camera.follow(node)`: copies the node's world pose onto the camera every LATE frame
10
+ * (order 1000 — after every other aspect has moved things). One per camera; lives on the target. */
11
+ export class CameraFollowRig extends Aspect<"__cameraFollow"> {
12
+ static readonly aspect = "__cameraFollow"
13
+ /** @internal */ _camera!: Camera
14
+ constructor() { super(); this.updateOrder = 1000 }
15
+ update(): void {
16
+ const cam = this._camera
17
+ cam.position = this.node.worldPosition
18
+ cam.quaternion = this.node.worldQuaternion
19
+ }
20
+ }
21
+
8
22
  /** Projection every host starts a scene with (creator-gl scene.cpp / the lite core agree on these). */
9
23
  export const CAMERA_DEFAULTS = { fov: 60, near: 0.01, far: 1000 }
10
24
 
@@ -60,6 +74,33 @@ export class Camera extends Node {
60
74
  if (this._sceneId < 0 || typeof _creator.setCameraProjection !== "function") return
61
75
  _creator.setCameraProjection(this._sceneId, this._fov, this._near, this._far)
62
76
  }
77
+ private _following: Node | null = null
78
+ /** The node the camera currently follows (`follow()`), or null. */
79
+ get following(): Node | null { return this._following }
80
+
81
+ /**
82
+ * Make the camera ride a node: every late frame the camera takes the node's WORLD pose (−Z =
83
+ * view direction), so movement aspects / animation on that node are camera moves — dolly shots,
84
+ * a cutscene path, a `CameraPlace` in a scene file. A node carrying a `CameraPlace` also hands
85
+ * over its projection (fov / near / far). `follow(null)` releases the camera where it is.
86
+ * One camera per scene: following a new node stops following the previous one.
87
+ */
88
+ follow(target: Node | { node: Node, applyTo(camera: Camera): void } | null): this {
89
+ const node = target === null ? null : target instanceof Node ? target : target.node
90
+ if (this._following && this._following !== node) this._following.removeAspect(CameraFollowRig)
91
+ this._following = node
92
+ if (!node) return this
93
+ // a CameraPlace on the node (or passed directly) sets the lens too
94
+ const place = target instanceof Node
95
+ ? (node as unknown as { cameraPlace?: { applyTo(camera: Camera): void } }).cameraPlace
96
+ : target as { applyTo(camera: Camera): void }
97
+ place?.applyTo(this)
98
+ node.aspect(CameraFollowRig, { _camera: this })
99
+ this.position = node.worldPosition
100
+ this.quaternion = node.worldQuaternion
101
+ return this
102
+ }
103
+
63
104
  get displaySize(): [number, number] {
64
105
  const s = _creator.getDisplaySize()
65
106
  return [ s[0], s[1] ]
@@ -0,0 +1,52 @@
1
+ // CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
2
+ // a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
3
+ // side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
4
+ // empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
5
+ //
6
+ // • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
7
+ // `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
8
+ // • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
9
+ // owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
10
+ // or anchors on it (`inst.alignTo(inst.nodes.eye)`).
11
+ // • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
12
+ // releases.
13
+ // • Edit mode: draws an anchored frustum gizmo (the editor's camera preview reads the same place).
14
+ // See docs/scene-camera-place-plan.md.
15
+
16
+ import { Aspect } from "../core/Aspect"
17
+ import type { FieldMeta } from "../core/fields"
18
+ import { Gizmos } from "../scene/gizmos"
19
+ import type { Camera } from "./Camera"
20
+ import type { Node } from "./Node"
21
+
22
+ export class CameraPlace extends Aspect<"cameraPlace", Node> {
23
+ static readonly aspect = "cameraPlace"
24
+ /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
25
+ fov = 60
26
+ /** Near clip distance. */
27
+ near = 0.01
28
+ /** Far clip distance = view range. */
29
+ far = 1000
30
+ /** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
31
+ * ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
32
+ active = false
33
+ /** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
34
+ static fields: FieldMeta<CameraPlace> = {
35
+ fov: { label: "FOV", min: 1, max: 179, step: 1 },
36
+ near: { min: 0.001, max: 10, step: 0.01 },
37
+ far: { min: 1, max: 100000, step: 1 },
38
+ active: { label: "Active camera" },
39
+ }
40
+ static editor = { rebuild: true }
41
+
42
+ /** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
43
+ applyTo(camera: Camera): void {
44
+ camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
45
+ }
46
+
47
+ /** Edit mode: the frustum marker (−Z = view), anchored on the node so it follows drags live and
48
+ * clicking it selects the node. Play mode never calls this. */
49
+ rebuild(): void {
50
+ Gizmos.frustum(this.fov, { node: this.node, color: this.active ? "#ffffff" : "#9aa3b2" })
51
+ }
52
+ }