lecodes-cli 0.18.0 → 0.18.2

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 +2013 -564
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/core/Aspect.ts +512 -255
  6. package/runtime/sdk/core/compWrite.ts +42 -0
  7. package/runtime/sdk/core/fields.ts +1 -1
  8. package/runtime/sdk/core/time.ts +81 -0
  9. package/runtime/sdk/g2/Camera2D.ts +8 -1
  10. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  11. package/runtime/sdk/g2/Node2D.ts +80 -10
  12. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  13. package/runtime/sdk/g2/Physics2D.ts +240 -30
  14. package/runtime/sdk/g2/Scene2D.ts +33 -1
  15. package/runtime/sdk/g2/Shape2D.ts +218 -22
  16. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  17. package/runtime/sdk/g2/groups2d.ts +106 -0
  18. package/runtime/sdk/g2/loop.ts +15 -4
  19. package/runtime/sdk/gl/Camera.ts +40 -1
  20. package/runtime/sdk/gl/CameraPlace.ts +52 -51
  21. package/runtime/sdk/gl/CharacterController.ts +184 -56
  22. package/runtime/sdk/gl/Gearbox.ts +212 -0
  23. package/runtime/sdk/gl/Geometry.ts +70 -9
  24. package/runtime/sdk/gl/Light.ts +64 -2
  25. package/runtime/sdk/gl/Lightmap.ts +179 -0
  26. package/runtime/sdk/gl/Material.ts +25 -0
  27. package/runtime/sdk/gl/Mesh.ts +6 -23
  28. package/runtime/sdk/gl/Model.ts +16 -2
  29. package/runtime/sdk/gl/Node.ts +119 -39
  30. package/runtime/sdk/gl/Physics.ts +75 -24
  31. package/runtime/sdk/gl/Scene.ts +161 -5
  32. package/runtime/sdk/gl/Shape.ts +42 -3
  33. package/runtime/sdk/gl/Trigger.ts +45 -50
  34. package/runtime/sdk/gl/Vehicle.ts +276 -322
  35. package/runtime/sdk/gl/Wheel.ts +240 -0
  36. package/runtime/sdk/gl/scenarios.ts +4 -30
  37. package/runtime/sdk/gl/state.ts +6 -6
  38. package/runtime/sdk/inject.ts +186 -171
  39. package/runtime/sdk/runtime/device.ts +102 -1
  40. package/runtime/sdk/scene/defineScene.ts +108 -53
  41. package/runtime/sdk/scene/gizmos.ts +31 -11
  42. package/runtime/sdk/scene/material.ts +188 -0
  43. package/runtime/sdk-types.json +1 -1
  44. package/runtime/sdk/compile/aspectMacro.ts +0 -42
  45. package/runtime/sdk/compile/assetIconMacro.ts +0 -384
  46. package/runtime/sdk/compile/assetMacro.ts +0 -45
  47. package/runtime/sdk/compile/assetName.ts +0 -50
  48. package/runtime/sdk/compile/bundler.ts +0 -252
  49. package/runtime/sdk/compile/compileProject.ts +0 -129
  50. package/runtime/sdk/compile/detectEntry.ts +0 -128
  51. package/runtime/sdk/compile/fontMacro.ts +0 -459
  52. package/runtime/sdk/compile/fontRegistry.ts +0 -78
  53. package/runtime/sdk/compile/header.ts +0 -67
  54. package/runtime/sdk/compile/index.ts +0 -85
  55. package/runtime/sdk/compile/libraryImports.ts +0 -52
  56. package/runtime/sdk/compile/liteMaterial.ts +0 -247
  57. package/runtime/sdk/compile/sceneEditor.ts +0 -78
  58. package/runtime/sdk/compile/serverSplit.ts +0 -233
  59. package/runtime/sdk/compile/serverTypes.ts +0 -227
  60. package/runtime/sdk/compile/sfnt.ts +0 -98
  61. package/runtime/sdk/compile/shaderTargets.ts +0 -42
  62. package/runtime/sdk/compile/sourcemap.ts +0 -25
@@ -0,0 +1,42 @@
1
+ // Component writes on SDK-owned vectors — the runtime half of chisel's `comp_write` pass.
2
+ //
3
+ // hero.controller.velocity.y = 7 → __compWrite(hero.controller, "velocity", "y", 7)
4
+ // hero.controller.velocity.y += 3 → __compOp(hero.controller, "velocity", "y", 0, 3)
5
+ //
6
+ // A getter like `velocity` hands out a fresh Vec3, so the literal spelling would write to a copy and
7
+ // silently do nothing. chisel rewrites the exact `<owner>.<prop>.<x|y|z|w> = v` shape into these
8
+ // helpers, which ask the OWNER to apply the component write (no getter call, no allocation) and
9
+ // fall back to the plain write on anything else, so a user record `{ velocity: { x, y } }` behaves
10
+ // exactly as written. Only the direct spelling is covered: `const v = c.velocity; v.y = 7` is still
11
+ // a write to a copy.
12
+ //
13
+ // Owners today: Node / Node2D / Camera2D (`position` → the scalar setters, physics routing included),
14
+ // Physics (`velocity`, `angularVelocity`), CharacterController / CharacterController2D (`velocity`).
15
+ //
16
+ // An owner declares WHICH getters qualify as compile-time data and answers them at runtime:
17
+ //
18
+ // static _comps = ["velocity"] // chisel reads the list, then strips it (0 bytes shipped)
19
+ // _writeComp(prop, axis, value) { … } // called for listed props only; a hook without the
20
+ // // list is a build diagnostic and routes nothing
21
+
22
+ export type CompAxis = "x" | "y" | "z" | "w"
23
+
24
+ /** Implemented by a class whose vector getters accept component writes (see above). The class also
25
+ * carries `static _comps: string[]` naming those getters — compile-time data, absent at runtime. */
26
+ export interface CompWriter {
27
+ _writeComp(prop: string, axis: CompAxis, value: number): void
28
+ }
29
+
30
+ /** `<o>.<prop>.<axis> = value`, routed to the owner when it has the hook. Returns the value. */
31
+ export const __compWrite = (o: any, prop: string, axis: CompAxis, value: number): number => {
32
+ if (o._writeComp !== undefined) o._writeComp(prop, axis, value) // a direct call, not .call — cheaper on QuickJS
33
+ else o[prop][axis] = value
34
+ return value
35
+ }
36
+
37
+ /** `<o>.<prop>.<axis> op= v` — `op`: 0 `+=`, 1 `-=`, 2 `*=`, 3 `/=`. The current component is read
38
+ * through the normal getter (one fresh vector), the result written like `__compWrite`. */
39
+ export const __compOp = (o: any, prop: string, axis: CompAxis, op: number, v: number): number => {
40
+ const cur = o[prop][axis] as number
41
+ return __compWrite(o, prop, axis, op === 0 ? cur + v : op === 1 ? cur - v : op === 2 ? cur * v : cur / v)
42
+ }
@@ -62,7 +62,7 @@ export type AspectClassInfo = {
62
62
 
63
63
  // Instance fields every aspect inherits from the Aspect base — configuration plumbing, not content.
64
64
  // `generated` is declare-only in TS, but the bundler lowers it to a real (undefined) class field.
65
- const BASE_FIELDS = new Set([ "node", "generated", "updateBeforePhysics", "order", "updateWhenVisible" ])
65
+ const BASE_FIELDS = new Set([ "node", "generated", "updateOrder", "updateWhilePaused", "updateWhenVisible" ])
66
66
 
67
67
  /** Infer the editor widget from a default value's shape. */
68
68
  export const inferFieldEditor = (value: unknown): FieldEditor | undefined => {
@@ -0,0 +1,81 @@
1
+ // The game clock: one place that owns "how fast does the game run" — `Time.scale` (slow-motion, 0.5),
2
+ // `Time.paused` (a menu over a frozen world) — and what a frame is worth (`Time.dt`).
3
+ //
4
+ // The aspect dispatcher (core/Aspect.ts) calls `_beginFrame(rawDt)` once per frame with the host's
5
+ // wall-clock delta; `update*(dt)` callbacks then receive the SCALED delta (0 while paused). The same
6
+ // scale is pushed to every engine that registered a sink (creator-gl / creator-2d `setTimeScale`),
7
+ // so physics, animators and particles slow down and freeze together with the game code — a paused
8
+ // aspect and a paused rigid body agree. `Time.unscaledDt` is the wall-clock delta for things that
9
+ // must keep moving while the game is paused (a HUD fade, a pause-menu animation): those aspects set
10
+ // `updateWhilePaused = true` and read it themselves, exactly like Unity's unscaledDeltaTime.
11
+
12
+ type ScaleSink = (scale: number) => void
13
+ const sinks: ScaleSink[] = []
14
+
15
+ class TimeClock {
16
+ private _scale = 1
17
+ private _paused = false
18
+ /** Seconds of GAME time since the app started (scaled; stops while paused). */
19
+ now = 0
20
+ /** Seconds of wall-clock time since the app started (never stops). */
21
+ unscaledNow = 0
22
+ /** This frame's delta in game seconds — what `update(dt)` receives. 0 while paused. */
23
+ dt = 0
24
+ /** This frame's wall-clock delta in seconds (the host's frame time, clamped by the engine). */
25
+ unscaledDt = 0
26
+ /** Frames dispatched since the app started (counts paused frames too). */
27
+ frame = 0
28
+
29
+ /** Speed of the game relative to wall-clock: 1 normal, 0.5 half speed, 2 double. Applied to
30
+ * `dt`, `now` AND the engines' physics / animation / particle clocks. Negative or NaN → 0. */
31
+ get scale(): number { return this._scale }
32
+ set scale(v: number) {
33
+ const s = Number.isFinite(v) && v > 0 ? v : 0
34
+ if (s === this._scale) return
35
+ this._scale = s
36
+ this._push()
37
+ }
38
+
39
+ /** Freeze the game: `dt` reads 0, aspects without `updateWhilePaused` are skipped, the engines'
40
+ * simulations stop. `scale` is remembered and restored on resume. */
41
+ get paused(): boolean { return this._paused }
42
+ set paused(v: boolean) {
43
+ if (v === this._paused) return
44
+ this._paused = v
45
+ this._push()
46
+ }
47
+
48
+ /** The scale the engines currently run at (0 while paused). */
49
+ get effectiveScale(): number { return this._paused ? 0 : this._scale }
50
+
51
+ private _push(): void {
52
+ const s = this.effectiveScale
53
+ for (const sink of sinks) sink(s)
54
+ }
55
+
56
+ /** @internal Called by the aspect dispatcher at the top of every frame with the host's raw delta. */
57
+ _beginFrame(rawDt: number): void {
58
+ const raw = Number.isFinite(rawDt) && rawDt > 0 ? rawDt : 0
59
+ this.frame++
60
+ this.unscaledNow += raw
61
+ this.now += this._phaseDt(raw)
62
+ }
63
+ /** @internal A phase's scaled delta from its raw one; records both as this frame's `dt`. */
64
+ _phaseDt(rawDt: number): number {
65
+ const raw = Number.isFinite(rawDt) && rawDt > 0 ? rawDt : 0
66
+ this.unscaledDt = raw
67
+ this.dt = raw * this.effectiveScale
68
+ return this.dt
69
+ }
70
+ }
71
+
72
+ /** The game clock — scale / pause the whole game, read `dt` / `now` anywhere. */
73
+ export const Time = new TimeClock()
74
+
75
+ /** @internal Engine layers (Scene / Scene2D) register the bridge call that forwards the effective
76
+ * scale to their native clocks. Pushed immediately with the current value so a scene created after
77
+ * `Time.paused = true` starts frozen. */
78
+ export const _registerTimeScaleSink = (sink: ScaleSink): void => {
79
+ sinks.push(sink)
80
+ sink(Time.effectiveScale)
81
+ }
@@ -3,8 +3,9 @@
3
3
  // no built-in follow() (and so nothing to dispose).
4
4
 
5
5
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
6
+ import type { CompAxis, CompWriter } from "../core/compWrite"
6
7
 
7
- export class Camera2D {
8
+ export class Camera2D implements CompWriter {
8
9
  private _x = 0
9
10
  private _y = 0
10
11
  private _zoom = 1
@@ -15,6 +16,12 @@ export class Camera2D {
15
16
 
16
17
  get position(): Vec2 { return new Vec2(this._x, this._y) }
17
18
  set position(v: Vec2Like) { this._x = cx(v); this._y = cy(v); _creator2d.cameraSetPosition(this.sceneId, this._x, this._y) }
19
+ /** @internal chisel `comp_write` (see core/compWrite.ts): `camera.position.<axis> = v` compiles to a call here. */
20
+ static _comps = [ "position" ]
21
+ _writeComp(_prop: string, axis: CompAxis, v: number): void {
22
+ if (axis === "x") this.position = [ v, this._y ]
23
+ else if (axis === "y") this.position = [ this._x, v ]
24
+ }
18
25
 
19
26
  get zoom(): number { return this._zoom }
20
27
  set zoom(z: number) { this._zoom = z; _creator2d.cameraSetZoom(this.sceneId, z) }
@@ -1,76 +1,276 @@
1
- // A platformer-friendly character controller, as an aspect on a Node2D. Built on a fixed-rotation
2
- // dynamic Physics2D body: you drive horizontal intent + jump, and Box2D handles gravity, falling, and
3
- // wall/floor collision response; a short downward ray reports `grounded`.
1
+ // A character controller, as an aspect on a Node2D, backed by Box2D's kinematic MOVER — collide and
2
+ // slide, not a rigid body. Requires a capsule Shape2D (one is derived from the sprite if absent) and
3
+ // does NOT use Physics2D.
4
4
  //
5
5
  // const hero = new Sprite({ texture, anchor: [0.5, 1] })
6
- // .aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } })
7
- // .aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
8
- // .aspect(CharacterController2D, { speed: 200, jumpSpeed: 520 })
9
- // setLoop(() => hero.controller.move((Input.key('ArrowRight') ? 1 : 0) - (Input.key('ArrowLeft') ? 1 : 0)))
10
- // Input.on('keydown', e => { if (e.code === 'Space') hero.controller.jump() })
6
+ // .aspect(Shape2D, { capsule: { from: [0, 8], to: [0, 26], radius: 8 } })
7
+ // .aspect(CharacterController2D)
11
8
  //
12
- // Velocity-control (set velocity each frame) is the standard, robust approach for platformers and
13
- // reuses the physics solver. For pixel-tight movement without any solver bounce/seam quirks, a
14
- // kinematic move-and-slide controller can be added later (see creator-2d PLAN.md, Phase G).
9
+ // setLoop(() => hero.controller.move(((Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0)) * SPEED))
10
+ // Input.on('keydown', e => { if (e.code === 'Space' && hero.controller.grounded) hero.controller.velocityY = 700 })
11
+ //
12
+ // The two halves of the velocity have different NATURES, so they have different channels:
13
+ //
14
+ // • HORIZONTAL — `move(x)`, world units/s, a PER-FRAME COMMAND. A walking character's horizontal
15
+ // velocity is muscle-driven and not conserved: stop pushing, stop moving. So the command expires
16
+ // each frame — no call this frame means standing still, and releasing the keys needs no explicit
17
+ // zero. It is latched for the whole frame, so every physics sub-step of that frame sees it.
18
+ // • VERTICAL — `velocityY`, LATCHED ballistic state. Gravity integrates into it in the engine; you
19
+ // seed it for a jump / bounce pad / explosion. There is deliberately no ground check: coyote
20
+ // time, double jumps and wall jumps all need the caller's own condition.
21
+ //
22
+ // FREE MODE — `gravityScale = 0` turns the character into a swimmer / flyer / ladder climber / the
23
+ // hero of a TOP-DOWN game: no gravity, no ground, no stick-to-floor. There the vertical is
24
+ // muscle-driven too, so `move()` takes BOTH components and the whole velocity becomes a per-frame
25
+ // command. Top-down vs platformer is this one number, not two controllers.
26
+ //
27
+ // `velocity` reads back what the solver ENDED UP with after the last step (post-collision) — walk into
28
+ // a wall and it reads ~0 even though you commanded 300. Everything runs on the engine's fixed clock,
29
+ // so the aspect has no per-frame update: a character costs zero JS work per frame.
30
+ //
31
+ // ROTATION AND SCALE stay the node's: the engine writes only the position (a capsule is symmetric
32
+ // about its axis, so its angle is physically meaningless).
33
+ //
34
+ // The character IS visible to raycasts, overlaps and triggers — it carries a hidden kinematic body for
35
+ // exactly that, so pickups and hazard zones need no special code. Solid contacts do not arrive as
36
+ // 'enter' events (the mover is not a body); read `collisions` for those.
15
37
 
16
38
  import { Aspect } from "../core/Aspect"
17
- import { Vec2 } from "../math/vec"
18
- import { Physics2D } from "./Physics2D"
39
+ import type { FieldMeta } from "../core/fields"
40
+ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
41
+ import type { CompAxis, CompWriter } from "../core/compWrite"
42
+ import { PhysicsGroup2D, _watchGroupChanges, _unwatchGroupChanges, categoryOf, solidMask } from "./groups2d"
19
43
  import { Shape2D } from "./Shape2D"
20
- import type { Node2D } from "./Node2D"
44
+ import { node2dRegistry, type Node2D } from "./Node2D"
21
45
 
22
- export class CharacterController2D extends Aspect<"controller", Node2D> {
23
- static readonly aspect = "controller"
46
+ /** Where the character's feet are, as reported by the solver after the last step. */
47
+ export type GroundState2D = "ground" | "slope" | "air"
48
+ const GROUND_STATES: readonly GroundState2D[] = [ "ground", "slope", "air" ]
24
49
 
25
- /** Horizontal move speed (world units/s). */
26
- speed = 200
27
- /** Jump take-off speed (world units/s). */
28
- jumpSpeed = 500
29
- /** Extra probe distance below the body's feet used for the grounded check (world units). */
30
- groundProbe = 6
31
- /** Distance from the node origin down to the feet. Defaults to half the sprite height. */
32
- footOffset?: number
50
+ /** One surface the mover pushed out of this step. `normal` points back at the character. */
51
+ export type Collision2D = { node: Node2D | null, normal: Vec2 }
33
52
 
34
- private _moveX = 0
35
- private _jumpQueued = false
36
- private _grounded = false
53
+ const MAX_COLLISIONS = 8
54
+ const state = new Float32Array(8)
55
+ let warnedFreeMove = false
37
56
 
38
- /** Horizontal intent in [-1, 1] (e.g. from input). Sticky — set 0 to stop. Applied next frame. */
39
- move(dir: number): void { this._moveX = dir < -1 ? -1 : dir > 1 ? 1 : dir }
40
- /** Queue a jump; consumed on the next frame if grounded. */
41
- jump(): void { this._jumpQueued = true }
42
- /** True while standing on something (updated each frame). */
43
- get grounded(): boolean { return this._grounded }
57
+ export class CharacterController2D extends Aspect<"controller", Node2D> implements CompWriter {
58
+ static readonly aspect = "controller"
44
59
 
45
- private get phys(): Physics2D | undefined { return this.node.get(Physics2D) }
60
+ // Accessor-backed tunables are invisible to describeFields (it enumerates own enumerable fields),
61
+ // so the inspector is told about them explicitly. Defaults still come from the getters.
62
+ static fields: FieldMeta<CharacterController2D> = {
63
+ gravityScale: { editor: "number", min: 0, step: 0.1, label: "Gravity scale" },
64
+ maxSlope: { editor: "number", min: 0, max: 89, step: 1, label: "Max slope°" },
65
+ }
66
+
67
+ private _id = 0
68
+ private _gravityScale = 1
69
+ private _maxSlope = 45
70
+ private _group: PhysicsGroup2D | undefined
71
+ private _dropThrough: Node2D | null = null
72
+ private _repush = (): void => {
73
+ if (this._id) _creator2d.characterSetFilter(this._id, categoryOf(this._group), solidMask(this._group))
74
+ }
46
75
 
47
76
  onAttach(): void {
48
- // Ensure a Shape2D (auto box from the sprite) + a dynamic, fixed-rotation body if not already set up.
77
+ if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
49
78
  if (!this.node.has(Shape2D)) this.node.aspect(Shape2D, {})
50
- if (!this.node.has(Physics2D)) this.node.aspect(Physics2D, { motion: "dynamic", fixedRotation: true })
79
+ const shape = this.node.get(Shape2D)!
80
+ const cap = shape._capsule()
81
+ // Read the options through their PUBLIC accessors — chisel drops an accessor pair whose name is
82
+ // only ever an object-literal key, and the option would silently do nothing (see Physics2D).
83
+ this._id = _creator2d.characterCreate(this.node.id, cap.x1, cap.y1, cap.x2, cap.y2, cap.radius,
84
+ this.maxSlope, categoryOf(this.group), solidMask(this.group))
85
+ if (!this._id) return
86
+ shape._claimedByCharacter()
87
+ _creator2d.characterSetGravityScale(this._id, this.gravityScale)
88
+ this.node._xf = this._id // routes node.position writes to the character
89
+ this.node._xfKind = 1
90
+ // The engine moves this node every step, so its JS transform cache must re-sync on READ — without
91
+ // this the character simulates correctly and node.position never changes.
92
+ this.node._physicsDriven = true
93
+ if (this._group) _watchGroupChanges(this._repush)
51
94
  }
52
95
 
53
- update(_dt: number): void {
54
- const phys = this.phys
55
- if (!phys) return
96
+ onDetach(): void {
97
+ _unwatchGroupChanges(this._repush)
98
+ if (this._id) { _creator2d.characterDestroy(this._id); this._id = 0 }
99
+ this.node._xf = 0
100
+ this.node._xfKind = 0
101
+ this.node._physicsDriven = false
102
+ }
56
103
 
57
- // Grounded probe: a short ray straight down from the feet. The ray originates inside the body,
58
- // so Box2D won't report the body's own shape; we exclude self defensively too.
59
- const pos = this.node.worldPosition
60
- const foot = this.footOffset ?? this._spriteHalfHeight()
61
- const hit = Physics2D.raycast([pos.x, pos.y - foot * 0.5], [pos.x, pos.y - foot - this.groundProbe])
62
- this._grounded = !!hit && hit.node !== this.node
104
+ /** Native character id (0 until attached / no physics support). */
105
+ get id(): number { return this._id }
63
106
 
64
- let vy = phys.velocity.y
65
- if (this._jumpQueued && this._grounded) { vy = this.jumpSpeed; this._grounded = false }
66
- this._jumpQueued = false
107
+ /**
108
+ * This frame's movement command, in world units/s — NOT normalized, NOT a per-frame displacement
109
+ * (that is what Unity's `Move` takes; passing `v * dt` here gives a character 60× too slow).
110
+ *
111
+ * ONE component = horizontal, the everyday platformer call. TWO = the whole velocity, for free mode
112
+ * (`gravityScale = 0`); with gravity on, a two-component call fights the ballistic vertical and the
113
+ * character hangs in the air, so that combination warns once.
114
+ *
115
+ * Sticky only within the frame: the command expires once the engine consumes it. It also **takes the
116
+ * axis back from a latched `velocity`** — commanding is claiming ownership, which is what keeps the
117
+ * two horizontal sources from ever fighting.
118
+ */
119
+ move(x: number): void
120
+ move(x: number, y: number): void
121
+ move(v: Vec2Like): void
122
+ move(x: number | Vec2Like, y?: number): void {
123
+ if (!this._id) return
124
+ if (typeof x === "number") {
125
+ if (y === undefined) { _creator2d.characterMove(this._id, x); return }
126
+ this._warnFree()
127
+ _creator2d.characterMoveFree(this._id, x, y)
128
+ return
129
+ }
130
+ this._warnFree()
131
+ _creator2d.characterMoveFree(this._id, cx(x), cy(x))
132
+ }
67
133
 
68
- // Drive horizontal velocity directly; let the sim own vertical (gravity / jump / landing).
69
- phys.velocity = new Vec2(this._moveX * this.speed, vy)
134
+ private _warnFree(): void {
135
+ if (this._gravityScale === 0 || warnedFreeMove) return
136
+ warnedFreeMove = true
137
+ console.warn("CharacterController2D.move(): the two-component form is the FREE-mode command (top-down / swimming / flying) and expects gravityScale = 0. With gravity on it overwrites the falling speed every frame, so the character will hang in the air. Use move(x) and velocityY for a platformer.")
70
138
  }
71
139
 
72
- private _spriteHalfHeight(): number {
73
- const size = (this.node as { size?: Vec2 }).size
74
- return size ? size.y * 0.5 : 8
140
+ /** Vertical velocity (world units/s) — LATCHED: gravity works on it, you seed it. `= 700` to jump,
141
+ * `+= 300` to stack an explosion on top of the current motion. No ground check: guard it yourself
142
+ * with `grounded` (or don't, for a double jump). Does nothing in free mode. `velocity.y = 700` is
143
+ * the same channel — pick whichever reads better. */
144
+ get velocityY(): number { return this._state()[1] }
145
+ set velocityY(v: number) { if (this._id) _creator2d.characterSetVerticalVelocity(this._id, v) }
146
+
147
+ /**
148
+ * READ — the velocity the solver ended up with after the most recent step (world units/s, fresh
149
+ * Vec2): what HAPPENED, not what you asked for. Walking into a wall reads ~0, sliding along one
150
+ * reads the tangent.
151
+ *
152
+ * WRITE — LATCH the whole velocity: a knockback, a wall jump, a launch pad. Unlike `move()` it does
153
+ * not expire, so the character keeps flying, and gravity still pulls the vertical down into a real
154
+ * ballistic arc. It stays until `move()` takes the axis back — so a game simply doesn't call
155
+ * `move()` while the throw lasts, and ends it on its own terms:
156
+ *
157
+ * hero.controller.velocity = [dir.x * 600, 400] // hit by the blast
158
+ * if (thrown) { if (hero.controller.grounded) thrown = false } // landing ends it
159
+ * else hero.controller.move(ix * SPEED) // …and this reclaims the axis
160
+ *
161
+ * Nothing clears the latch by itself, landing included — a kinematic controller has no friction.
162
+ * Reading is not the inverse of writing: against a wall the read is ~0 and would cancel the throw.
163
+ *
164
+ * `c.velocity.y = 700` (the direct spelling) is a jump — the compiler routes it to the exact
165
+ * `velocityY` channel via `_writeComp` below — and `c.velocity.x = 300` latches the whole vector
166
+ * with the measured vertical filling in. A STORED copy is still a copy (`const v = c.velocity`).
167
+ */
168
+ get velocity(): Vec2 { const s = this._state(); return new Vec2(s[0], s[1]) }
169
+ set velocity(v: Vec2Like) { if (this._id) _creator2d.characterSetVelocity(this._id, cx(v), cy(v)) }
170
+
171
+ /** Compile-time list (chisel reads it, then strips it) — see CharacterController / core/compWrite.ts. */
172
+ static _comps = [ "velocity" ]
173
+
174
+ /** @internal `c.velocity.<axis> = v` compiles to this; see CharacterController. */
175
+ _writeComp(_prop: string, axis: CompAxis, v: number): void {
176
+ if (!this._id) return
177
+ if (axis === "y") { _creator2d.characterSetVerticalVelocity(this._id, v); return }
178
+ const s = this._state()
179
+ _creator2d.characterSetVelocity(this._id, axis === "x" ? v : s[0], s[1])
180
+ }
181
+
182
+ /** True while standing on walkable ground. */
183
+ get grounded(): boolean { return this._state()[2] === 0 }
184
+
185
+ /** Where the feet are after the last step: walkable ground, too-steep ground, or the air. */
186
+ get groundState(): GroundState2D { return GROUND_STATES[this._state()[2]] ?? "air" }
187
+
188
+ /** The surface normal under the feet — for orienting a sprite to a slope, or deciding a slide. */
189
+ get groundNormal(): Vec2 { const s = this._state(); return new Vec2(s[3], s[4]) }
190
+
191
+ /** What the character is standing on: a moving platform, a hazard, an ice patch whose `friction`
192
+ * the game can read. The engine already carries the character along a kinematic platform. */
193
+ get groundNode(): Node2D | null {
194
+ const id = this._state()[5]
195
+ return id ? node2dRegistry.get(id) ?? null : null
196
+ }
197
+
198
+ /**
199
+ * Every surface the mover pushed out of during the last step, with the normal pointing back at the
200
+ * character. This is what wall jumps and box pushing are written against — a kinematic mover is not
201
+ * a body, so those contacts do not arrive as 'enter' events.
202
+ *
203
+ * const wall = c.collisions.find(h => Math.abs(h.normal.x) > 0.7)
204
+ * if (wall && jumpPressed) c.velocity = [-wall.normal.x * KICK, JUMP]
205
+ *
206
+ * for (const h of c.collisions) h.node?.physics?.applyImpulse([-h.normal.x * PUSH, 0])
207
+ */
208
+ get collisions(): Collision2D[] {
209
+ if (!this._id) return []
210
+ const o = _creator2d.characterGetCollisions(this._id, MAX_COLLISIONS)
211
+ const out: Collision2D[] = []
212
+ for (let i = 0; i + 2 < o.length; i += 3) {
213
+ out.push({ node: o[i] ? node2dRegistry.get(o[i]) ?? null : null, normal: new Vec2(o[i + 1], o[i + 2]) })
214
+ }
215
+ return out
216
+ }
217
+
218
+ /** Multiplier over the world gravity; default 1. **0 = free mode**: no gravity, no ground, no
219
+ * stick-to-floor — a swimmer, a drone, a ladder climber, or a top-down hero, driven by the
220
+ * two-component `move()`. */
221
+ get gravityScale(): number { return this._gravityScale }
222
+ set gravityScale(v: number) {
223
+ this._gravityScale = v
224
+ if (this._id) _creator2d.characterSetGravityScale(this._id, v)
225
+ }
226
+
227
+ /** Max ground slope (degrees) the character treats as walkable; default 45. Live. */
228
+ get maxSlope(): number { return this._maxSlope }
229
+ set maxSlope(v: number) {
230
+ this._maxSlope = v
231
+ if (this._id) _creator2d.characterSetMaxSlope(this._id, v)
232
+ }
233
+
234
+ /** Which collision group the character belongs to. Live. */
235
+ get group(): PhysicsGroup2D | undefined { return this._group }
236
+ set group(g: PhysicsGroup2D | undefined) { this._group = g; this._repush() }
237
+
238
+ /**
239
+ * While this names a node, that node's `OneWay2D` surfaces are not solid for this character — how
240
+ * you drop off a semisolid platform. A plain latch: whoever sets it clears it.
241
+ *
242
+ * if (downPressed && jumpPressed && c.grounded) {
243
+ * const platform = c.groundNode
244
+ * c.dropThrough = platform
245
+ * setTimeout(() => { if (c.dropThrough === platform) c.dropThrough = null }, 200)
246
+ * }
247
+ *
248
+ * Because it names ONE node, the timeout is not delicate: too long merely means you could have
249
+ * re-landed on that platform for a moment, and the platform below stays solid either way.
250
+ */
251
+ get dropThrough(): Node2D | null { return this._dropThrough }
252
+ set dropThrough(n: Node2D | null) {
253
+ this._dropThrough = n
254
+ if (this._id) _creator2d.characterSetDropThrough(this._id, n ? n.id : 0)
255
+ }
256
+
257
+ /**
258
+ * True while a requested collider resize hasn't taken — you asked to stand up and there is something
259
+ * overhead. Resizing goes through the `Shape2D` aspect itself:
260
+ *
261
+ * hero.aspect(Shape2D, { capsule: CROUCHED }) // always fits — you are shrinking
262
+ * hero.aspect(Shape2D, { capsule: STANDING }) // may be refused under a low ceiling
263
+ * if (hero.controller.resizing) … // still crouched; call it again next frame
264
+ *
265
+ * A refusal changes nothing, so the retry is just the same call again — and it is an exact headroom
266
+ * test against the real capsule, unlike a hand-rolled raycast (a ray is a line; a capsule has
267
+ * girth). The FEET stay planted across a resize, so the character neither hovers nor sinks.
268
+ */
269
+ get resizing(): boolean { return this.node.get(Shape2D)?._resizeRefused ?? false }
270
+
271
+ private _state(): Float32Array {
272
+ if (!this._id) { state.fill(0); state[2] = 2; state[4] = 1; return state }
273
+ _creator2d.characterGetState(this._id, state)
274
+ return state
75
275
  }
76
276
  }
@@ -10,10 +10,12 @@
10
10
  // trigger objects — attach a Shape2D (geometry) plus a Physics2D (rigid body) or Trigger2D (sensor).
11
11
 
12
12
  import { AspectHost } from "../core/Aspect"
13
+ import type { CompAxis, CompWriter } from "../core/compWrite"
13
14
  import { Registry } from "../core/registry"
14
15
  import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
15
16
  import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
16
17
  import { ensurePointerEvents } from "./touch"
18
+ import { ensurePhysicsEvents } from "./loop"
17
19
 
18
20
  // re-exported so existing `import { Vec2 } from "./Node2D"` sites keep working; the canonical home is math/.
19
21
  export { Vec2, type Vec2Like }
@@ -23,19 +25,26 @@ export type Node2DEvents = {
23
25
  loopReached: (clip: string) => void
24
26
  /** A non-looping animation clip finished. */
25
27
  completed: (clip: string) => void
26
- /** A physics contact / sensor overlap began / ended (the other node). */
27
- enter: (other: Node2D) => void
28
- exit: (other: Node2D) => void
28
+ /** A physics contact / sensor overlap began (the other node, plus where and how hard). The
29
+ * contact's `normal` points AWAY from the other node, so `normal.y > 0.7` reads as "I landed on
30
+ * top of it". Sensor overlaps and `exit` carry zeros. */
31
+ enter: (other: Node2D, contact: Contact2D) => void
32
+ exit: (other: Node2D, contact: Contact2D) => void
29
33
  /** Pointer up over this node's Physics2D shape (a tap/click). */
30
34
  click: (ev: ClickEvent<Node2D | null>) => void
31
35
  /** Pointer down on this node's Physics2D shape. Call ev.track(...) to capture the drag. */
32
36
  touchstart: (ev: TouchStartEvent<Node2D | null>) => void
33
37
  }
34
38
 
39
+ /** Where a contact happened, delivered as the second argument of 'enter'. `speed` is the approach
40
+ * speed at impact (world units/s) — scale an impact sound with it. Zeros on sensor and 'exit'
41
+ * events, which have no manifold. */
42
+ export type Contact2D = { point: Vec2, normal: Vec2, speed: number }
43
+
35
44
  /** id → Node2D, so host callbacks (animation events) route back to the owning JS object. */
36
45
  export const node2dRegistry = new Registry<Node2D>()
37
46
 
38
- export class Node2D extends AspectHost<Node2DEvents> {
47
+ export class Node2D extends AspectHost<Node2DEvents> implements CompWriter {
39
48
  /** Native entity handle. */
40
49
  readonly id: number
41
50
 
@@ -55,6 +64,27 @@ export class Node2D extends AspectHost<Node2DEvents> {
55
64
  * transform, so the transform getters re-sync the cache from native before returning. */
56
65
  _physicsDriven = false
57
66
 
67
+ /** @internal The engine object that mirrors this node's transform, if any — a physics body or a
68
+ * character (0 = none). WRITES to position / x / y / rotation are routed to it as well, because
69
+ * the engine is the authority: on a moving body a plain setPosition is overwritten by the next
70
+ * sync, and on a static / sensor body it would leave the COLLIDER behind while the node moved.
71
+ * Set by Physics2D, Trigger2D and CharacterController2D. This is what replaced `physics.moveTo`
72
+ * and `trigger.moveTo`. */
73
+ _xf = 0
74
+ /** @internal What `_xf` is: 1 = character, 2 = physics body. */
75
+ _xfKind = 0
76
+
77
+ // Push this node's (already written) transform into the engine object that owns it. The world math
78
+ // lives NATIVE — physicsSetFromEntity reads the entity's world matrix — so a parented body converts
79
+ // through its parent chain without this file re-deriving it.
80
+ //
81
+ // NOTE: this fires on a DIRECT write only. A body parented to a node that moves does not follow;
82
+ // keep bodies and sensors at the root and write their position (see docs/2d-physics-plan.md §6).
83
+ private _pushXf(): void {
84
+ if (this._xfKind === 2) _creator2d.physicsSetFromEntity(this._xf)
85
+ else if (this._xfKind === 1) _creator2d.characterSetFromEntity(this._xf)
86
+ }
87
+
58
88
  private _parent: Node2D | null = null
59
89
  private _children: Node2D[] = []
60
90
 
@@ -70,16 +100,32 @@ export class Node2D extends AspectHost<Node2DEvents> {
70
100
  private _pull(): void { if (this._physicsDriven) this._syncLocalFromNative() }
71
101
 
72
102
  get x(): number { this._pull(); return this._x }
73
- set x(v: number) { this._pull(); this._x = v; _creator2d.setPosition(this.id, v, this._y) }
103
+ set x(v: number) { this._pull(); this._x = v; _creator2d.setPosition(this.id, v, this._y); if (this._xf) this._pushXf() }
74
104
  get y(): number { this._pull(); return this._y }
75
- set y(v: number) { this._pull(); this._y = v; _creator2d.setPosition(this.id, this._x, v) }
105
+ set y(v: number) { this._pull(); this._y = v; _creator2d.setPosition(this.id, this._x, v); if (this._xf) this._pushXf() }
106
+
107
+ /** @internal chisel `comp_write` (see core/compWrite.ts): `node.position.<axis> = v` compiles to a call
108
+ * here; the scalar setters carry the physics routing. The list is compile-time data. */
109
+ static _comps = [ "position" ]
110
+ _writeComp(_prop: string, axis: CompAxis, v: number): void {
111
+ if (axis === "x") this.x = v
112
+ else if (axis === "y") this.y = v
113
+ }
76
114
 
77
115
  get position(): Vec2 { this._pull(); return new Vec2(this._x, this._y) }
78
- set position(v: Vec2Like) { this._x = cx(v); this._y = cy(v); _creator2d.setPosition(this.id, this._x, this._y) }
116
+ set position(v: Vec2Like) {
117
+ this._x = cx(v); this._y = cy(v)
118
+ _creator2d.setPosition(this.id, this._x, this._y)
119
+ if (this._xf) this._pushXf()
120
+ }
79
121
 
80
122
  /** Rotation in degrees (CCW). */
81
123
  get rotation(): number { this._pull(); return this._rotation }
82
- set rotation(deg: number) { this._rotation = deg; _creator2d.setRotation(this.id, deg) }
124
+ set rotation(deg: number) {
125
+ this._rotation = deg
126
+ _creator2d.setRotation(this.id, deg)
127
+ if (this._xf) this._pushXf()
128
+ }
83
129
 
84
130
  get scale(): Vec2 { return new Vec2(this._sx, this._sy) }
85
131
  set scale(v: Vec2Like | number) {
@@ -167,11 +213,25 @@ export class Node2D extends AspectHost<Node2DEvents> {
167
213
  _emitAnim(channel: "loopReached" | "completed", clip: string): void { this.dispatch(channel, clip) }
168
214
 
169
215
  /** @internal — physics contact/sensor events route here (loop.ts) → the node's 'enter'/'exit'. */
170
- _emitCollision(channel: "enter" | "exit", other: Node2D): void { this.dispatch(channel, other) }
216
+ _emitCollision(channel: "enter" | "exit", other: Node2D, contact: Contact2D): void {
217
+ this.dispatch(channel, other, contact)
218
+ }
171
219
 
172
- // Lazily register the host pointer callback when the first click/touchstart listener is added.
220
+ /** @internal — this node wants contact events. Set by the first 'enter'/'exit' listener; read by
221
+ * Physics2D.onAttach for the case where the listener was added BEFORE the body existed. */
222
+ _wantsContacts = false
223
+
224
+ // Lazily wire the host callbacks the listener needs. Contact/sensor events used to be registered
225
+ // only by Trigger2D.onAttach, so solid-vs-solid 'enter' never fired in a project with no triggers —
226
+ // and, separately, every solid shape had contact events forced on. Both are fixed here: the channel
227
+ // is registered by the listener, and the body opts in.
173
228
  override addEventListener<K extends keyof Node2DEvents>(channel: K, callback: Node2DEvents[K]): void {
174
229
  if (channel === "click" || channel === "touchstart") ensurePointerEvents()
230
+ else if (channel === "enter" || channel === "exit") {
231
+ ensurePhysicsEvents()
232
+ this._wantsContacts = true
233
+ if (this._xfKind === 2) _creator2d.physicsSetContactEvents(this._xf, true)
234
+ }
175
235
  super.addEventListener(channel, callback)
176
236
  }
177
237
 
@@ -181,6 +241,16 @@ export class Node2D extends AspectHost<Node2DEvents> {
181
241
  }
182
242
 
183
243
  destroy(): void {
244
+ // Aspects go first: onDetach runs (a body-owning aspect releases its handle), updaters are
245
+ // unregistered so a destroyed node never ticks again, listeners are dropped. Children survive
246
+ // (native reparents them to the root), so only this node's aspects detach.
247
+ this._detachAll()
248
+ // Native frees this entity's physics bodies inside destroyEntity (they used to leak — an invisible
249
+ // collider stayed in the world, and recycled entity ids then delivered its contacts to whoever
250
+ // inherited the id). Clear the routing so a stale aspect can't push a dead handle.
251
+ this._xf = 0
252
+ this._xfKind = 0
253
+ this._physicsDriven = false
184
254
  // Detach from parent; native reparents our children to root (keepWorld), so resync their caches.
185
255
  if (this._parent) {
186
256
  const arr = this._parent._children