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
@@ -4,44 +4,70 @@
4
4
  //
5
5
  // const hero = new Mesh(capsuleGeometry(), material)
6
6
  // .aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
7
- // .aspect(CharacterController, { speed: 6, jumpSpeed: 8 })
8
- // Input.on('move', (x, z) => hero.controller.move(x, z))
9
- // Input.on('jump', () => hero.controller.jump())
7
+ // .aspect(CharacterController)
8
+ // setLoop(() => hero.controller.move((Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) * SPEED,
9
+ // (Input.key('KeyS') ? 1 : 0) - (Input.key('KeyW') ? 1 : 0) * SPEED))
10
+ // Input.on('keydown', e => { if (e.code === 'Space' && hero.controller.grounded) hero.controller.velocityY = 7 })
10
11
  //
11
- // You drive horizontal intent + jump; the controller integrates gravity and Jolt handles collision
12
- // against the world. `grounded` reports whether it's on walkable ground. It runs in the EARLY update
13
- // phase (feeds this frame's step). Note: it collides with solid bodies but PASSES THROUGH triggers,
14
- // and (v1) is not itself detected by triggers and isn't pointer-pickable while active.
12
+ // The two halves of the velocity have different NATURES, so they have different channels:
13
+ //
14
+ // • HORIZONTAL — `move(x, z)`, 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;
19
+ // you seed it for a jump / dash / bounce pad / explosion (`velocityY = 7`, `velocityY += 3`).
20
+ // There is deliberately no ground check: coyote time, double jumps and wall jumps all need
21
+ // the caller's own condition.
22
+ //
23
+ // `velocity` reads back what the solver ENDED UP with after the last step (post-collision) — walk into
24
+ // a wall and it reads ~0 even though you commanded 5. Everything runs on the engine's fixed clock, so
25
+ // the aspect has no per-frame update: a character costs zero JS work per frame.
26
+ //
27
+ // FREE MODE — `gravityScale = 0` turns the character into a swimmer / flyer / drone: no gravity, and
28
+ // (because Jolt would otherwise snap it down to the floor it passes over) no stick-to-floor and no
29
+ // stair walking either. There the vertical is muscle-driven too, so `move()` takes all three
30
+ // components and the whole velocity becomes a per-frame command.
31
+ //
32
+ // ROTATION AND SCALE stay the node's: the engine writes only the position, so a rig can be yawed and
33
+ // scaled directly instead of needing a child node for the visuals (a capsule is symmetric about its up
34
+ // axis, so its yaw is physically irrelevant either way).
35
+ //
36
+ // CROUCHING goes through the Shape aspect (`node.aspect(Shape, { capsule: … })`) — see `resizing`.
37
+ //
38
+ // Note: it collides with solid bodies but PASSES THROUGH triggers, and (v1) is not itself detected by
39
+ // triggers and isn't pointer-pickable while active.
15
40
 
16
41
  import { Aspect } from "../core/Aspect"
17
- import { Vec3 } from "../math/vec"
42
+ import type { FieldMeta } from "../core/fields"
43
+ import { Vec3, cx, cy, cz, type Vec2Like, type Vec3Like } from "../math/vec"
44
+ import type { CompAxis, CompWriter } from "../core/compWrite"
18
45
  import { Shape } from "./Shape"
19
46
  import type { Node } from "./Node"
20
47
 
21
- const clamp1 = (v: number): number => (v < -1 ? -1 : v > 1 ? 1 : v)
48
+ /** Where the character's feet are, as reported by the solver after the last step. */
49
+ export type GroundState = "ground" | "slope" | "unsupported" | "air"
22
50
 
23
- export class CharacterController extends Aspect<"controller", Node> {
24
- static readonly aspect = "controller"
51
+ // Index = JPH::EGroundState (0 OnGround / 1 OnSteepGround / 2 NotSupported / 3 InAir).
52
+ const GROUND_STATES: readonly GroundState[] = ["ground", "slope", "unsupported", "air"]
53
+
54
+ const scratch = new Float32Array(3)
55
+ let warnedFreeMove = false
25
56
 
26
- /** Horizontal move speed (world units/s). */
27
- speed = 5
28
- /** Jump take-off speed (world units/s). */
29
- jumpSpeed = 7
30
- /** Character-tuned gravity (world units/s²), separate from the world gravity — usually stronger for
31
- * snappier feel. Applied to the vertical velocity each frame. */
32
- gravity = -20
33
- /** Max ground slope (degrees) the character treats as walkable. Set before attach. */
34
- maxSlope = 45
57
+ export class CharacterController extends Aspect<"controller", Node> implements CompWriter {
58
+ static readonly aspect = "controller"
35
59
 
36
- updateBeforePhysics = true // EARLY phase — set velocity so THIS frame's step consumes it
60
+ // Both tunables are accessor-backed (they write through to the engine), and describeFields only
61
+ // enumerates own enumerable fields — so the inspector is told about them explicitly. The default
62
+ // VALUES still come from a fresh instance through the getters; this only supplies the keys.
63
+ static fields: FieldMeta<CharacterController> = {
64
+ gravityScale: { editor: "number", min: 0, step: 0.1, label: "Gravity scale" },
65
+ maxSlope: { editor: "number", min: 0, max: 89, step: 1, label: "Max slope°" },
66
+ }
37
67
 
38
68
  private _charId = 0
39
- private _mx = 0
40
- private _mz = 0
41
- private _jumpQueued = false
42
- private _vy = 0
43
- private _ex = 0 // external horizontal velocity (world units/s) — root motion from an Animator
44
- private _ez = 0
69
+ private _gravityScale = 2
70
+ private _maxSlope = 45
45
71
 
46
72
  onAttach(): void {
47
73
  if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
@@ -53,57 +79,160 @@ export class CharacterController extends Aspect<"controller", Node> {
53
79
  throw new Error("CharacterController needs a convex Shape (capsule recommended) — { mesh: true } is a triangle mesh; use { mesh: 'convex' } or a capsule")
54
80
  }
55
81
  const shapeId = shape._claim() // the character owns the shape; it is not a rigid body
82
+ // Read the options through their PUBLIC accessors, never the `_backing` fields: chisel drops an
83
+ // accessor pair whose name is only ever an object-literal key, and then
84
+ // `aspect(CharacterController, { gravityScale: 0 })` silently leaves the default in place (the
85
+ // character keeps falling instead of entering free mode). One property read here keeps the pair.
56
86
  this._charId = _creator.characterCreate(this.node.id, shapeId, this.maxSlope)
87
+ if (!this._charId) return
88
+ _creator.characterSetGravityScale(this._charId, this.gravityScale)
89
+ this.node._xf = this._charId // routes node.position / .x / .y / .z / .matrix writes to the character
90
+ this.node._xfKind = 1
57
91
  }
58
92
 
59
93
  onDetach(): void {
60
94
  if (this._charId) { _creator.characterDestroy(this._charId); this._charId = 0 }
61
- this.node.get(Shape)?._recreatePickBody()
95
+ this.node._xf = 0
96
+ this.node._xfKind = 0
97
+ this.node.get(Shape)?._recreatePickBody() // hands the routing back to the pick body
62
98
  }
63
99
 
64
100
  /** Native character id (0 until attached / no physics support). */
65
101
  get id(): number { return this._charId }
66
102
 
67
- /** Horizontal move intent; each component in [-1, 1] (e.g. from input). Sticky — set 0 to stop. */
68
- move(x: number, z: number): void { this._mx = clamp1(x); this._mz = clamp1(z) }
103
+ /**
104
+ * This frame's movement command, in world units/s — NOT normalized, NOT a per-frame displacement
105
+ * (that is what Unity's `Move` takes; passing `v * dt` here gives a character 60× too slow).
106
+ *
107
+ * Two components = horizontal `(x, z)`, the everyday call. Three = the whole velocity, for free mode
108
+ * (`gravityScale = 0`); with gravity on, the vertical component fights the ballistic one and the
109
+ * character barely falls, so that combination warns.
110
+ *
111
+ * Sticky only within the frame: the command expires once the engine consumes it. It also **takes the
112
+ * axis back from a latched `velocity`** — commanding is claiming ownership, which is what keeps the
113
+ * two horizontal sources from ever fighting.
114
+ */
115
+ move(x: number, z: number): void
116
+ move(v: Vec2Like | Vec3Like): void
117
+ move(x: number | Vec2Like | Vec3Like, z?: number): void {
118
+ if (!this._charId) return
119
+ if (typeof x === "number") { _creator.characterMove(this._charId, x, z ?? 0); return }
120
+ // cz() is typed for 3-component inputs; a Vec2 legitimately has no `z` and reads undefined here.
121
+ const third = cz(x as Vec3Like) // undefined for a Vec2 / 2-tuple / 2-long typed array
122
+ if (third === undefined) { _creator.characterMove(this._charId, cx(x), cy(x)); return }
123
+ if (this._gravityScale !== 0 && !warnedFreeMove) {
124
+ warnedFreeMove = true
125
+ console.warn("CharacterController.move(): the 3-component form is the FREE-mode command (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, z) and velocityY for a walking character.")
126
+ }
127
+ _creator.characterMoveFree(this._charId, cx(x), cy(x), third)
128
+ }
69
129
 
70
- /** Queue a jump; consumed on the next frame if grounded. */
71
- jump(): void { this._jumpQueued = true }
130
+ /** Vertical velocity (world units/s) — LATCHED: gravity works on it, you seed it. `= 7` to jump,
131
+ * `+= 3` to stack an explosion on top of the current motion. No ground check: guard it yourself
132
+ * with `grounded` (or don't, for a double jump). Overridden every frame by a 3-component `move()`.
133
+ * `velocity.y = 7` is the same channel — pick whichever reads better. */
134
+ get velocityY(): number {
135
+ if (!this._charId) return 0
136
+ _creator.characterGetVelocity(this._charId, scratch)
137
+ return scratch[1]
138
+ }
139
+ set velocityY(v: number) {
140
+ if (this._charId) _creator.characterSetVerticalVelocity(this._charId, v)
141
+ }
72
142
 
73
- /** @internal — extra world-space horizontal velocity added on top of `move()` this frame (root motion). */
74
- _setExternalVelocity(x: number, _y: number, z: number): void { this._ex = x; this._ez = z }
143
+ /** Multiplier over the world gravity (`Physics.configure({ gravity })`); default 2, for the snappier
144
+ * fall games want. **0 = free mode**: no gravity, no stick-to-floor, no stair stepping — a swimmer
145
+ * or a drone, driven by the 3-component `move()`. */
146
+ get gravityScale(): number { return this._gravityScale }
147
+ set gravityScale(v: number) {
148
+ this._gravityScale = v
149
+ if (this._charId) _creator.characterSetGravityScale(this._charId, v)
150
+ }
75
151
 
76
- /** True while standing on walkable ground (OnGround). */
152
+ /** Max ground slope (degrees) the character treats as walkable; default 45. Live. */
153
+ get maxSlope(): number { return this._maxSlope }
154
+ set maxSlope(v: number) {
155
+ this._maxSlope = v
156
+ if (this._charId) _creator.characterSetMaxSlope(this._charId, v)
157
+ }
158
+
159
+ /**
160
+ * True while a requested collider resize hasn't taken — you asked to stand up and there is something
161
+ * overhead. Resizing goes through the `Shape` aspect itself:
162
+ *
163
+ * hero.aspect(Shape, { capsule: CROUCHED }) // always fits — you are shrinking
164
+ * hero.aspect(Shape, { capsule: STANDING }) // may be refused under a low ceiling
165
+ * if (hero.controller.resizing) … // still crouched; call it again next frame
166
+ *
167
+ * A refusal changes nothing, so the retry is just the same call again — and it is an exact headroom
168
+ * test against the real capsule, unlike a hand-rolled raycast (a ray is a line; a capsule has girth).
169
+ * The FEET stay planted across a resize, so the character neither hovers nor sinks.
170
+ */
171
+ get resizing(): boolean {
172
+ return this.node.get(Shape)?._resizeRefused ?? false
173
+ }
174
+
175
+ /** True while standing on walkable ground. */
77
176
  get grounded(): boolean {
78
177
  return this._charId ? _creator.characterGetGroundState(this._charId) === 0 : false
79
178
  }
80
179
 
81
- /** Ground state: 0 on-ground, 1 on-steep-slope, 2 touching-but-unsupported, 3 in-air. */
82
- get groundState(): number {
83
- return this._charId ? _creator.characterGetGroundState(this._charId) : 3
180
+ /** Where the feet are after the last step: on walkable ground, on too-steep ground, touching
181
+ * something that can't support it, or in the air. */
182
+ get groundState(): GroundState {
183
+ return this._charId ? (GROUND_STATES[_creator.characterGetGroundState(this._charId)] ?? "air") : "air"
84
184
  }
85
185
 
86
- /** Current velocity (world units/s, fresh Vec3). */
186
+ /**
187
+ * READ — the velocity the solver ended up with after the most recent step (world units/s, fresh
188
+ * Vec3): what HAPPENED, not what you asked for. Walking into a wall reads ~0, sliding along one
189
+ * reads the tangent. Unlike Unity's synchronous `Move`, our step runs later in the frame, so a read
190
+ * is up to one frame old — irrelevant for animation speed, fall damage or "am I blocked", which is
191
+ * what it is for. (Exact wall contact would need a contact normal; the engine has none yet.)
192
+ *
193
+ * WRITE — LATCH the whole velocity: a knockback, a wall jump, a launch pad. Unlike `move()` it does
194
+ * not expire, so the character keeps flying, and gravity still pulls the vertical down into a real
195
+ * ballistic arc. It stays until `move()` takes the axis back — so a game simply doesn't call
196
+ * `move()` while the throw lasts, and ends it on its own terms:
197
+ *
198
+ * hero.controller.velocity = [dir.x * 12, 6, dir.z * 12] // hit by the blast
199
+ * // …in the loop:
200
+ * if (thrown) { if (hero.controller.grounded) thrown = false } // landing ends it
201
+ * else hero.controller.move(ix * SPEED, iz * SPEED) // …and this reclaims the axis
202
+ *
203
+ * Note nothing clears the latch by itself, landing included — so a thrown character that never gets
204
+ * a `move()` keeps sliding along the ground (a kinematic controller has no friction).
205
+ *
206
+ * Reading is not the inverse of writing: `c.velocity = c.velocity` is NOT a no-op, because the read
207
+ * reports the measured result (against a wall it is ~0 and would cancel the throw). In flight the
208
+ * two agree closely, so read-modify-write mid-air behaves as expected.
209
+ *
210
+ * `c.velocity.y = 7` (the direct spelling) is a jump — the compiler routes it to the exact
211
+ * `velocityY` channel via `_writeComp` below, no vector allocated — and `c.velocity.x = 3` latches
212
+ * the whole vector with the measured velocity filling in the other two, i.e. the read-modify-write
213
+ * above spelled naturally. A STORED copy is still a copy: `const v = c.velocity; v.y = 7` does nothing.
214
+ */
87
215
  get velocity(): Vec3 {
88
- const o = new Float32Array(3)
89
- if (this._charId) _creator.characterGetVelocity(this._charId, o)
90
- return new Vec3(o[0], o[1], o[2])
216
+ if (!this._charId) return new Vec3(0, 0, 0)
217
+ _creator.characterGetVelocity(this._charId, scratch)
218
+ return new Vec3(scratch[0], scratch[1], scratch[2])
91
219
  }
92
-
93
- /** Teleport to a world position (clears vertical velocity). */
94
- teleport(x: number, y: number, z: number): this {
95
- if (this._charId) _creator.characterSetPosition(this._charId, x, y, z)
96
- this._vy = 0
97
- return this
220
+ set velocity(v: Vec3Like) {
221
+ if (this._charId) _creator.characterSetVelocity(this._charId, cx(v), cy(v), cz(v))
98
222
  }
99
223
 
100
- update(dt: number): void {
224
+ /** Compile-time list (chisel reads it, then strips it): the getters whose `c.<getter>.<axis> = v`
225
+ * spelling is routed to `_writeComp` below. See core/compWrite.ts. */
226
+ static _comps = [ "velocity" ]
227
+
228
+ /** @internal `c.velocity.<axis> = v` compiles to this: the vertical axis is the precise `velocityY`
229
+ * channel, any other latches the whole vector with the measured rest. */
230
+ _writeComp(_prop: string, axis: CompAxis, v: number): void {
101
231
  if (!this._charId) return
102
- const grounded = _creator.characterGetGroundState(this._charId) === 0
103
- if (grounded && this._vy < 0) this._vy = 0 // landed
104
- if (this._jumpQueued && grounded) this._vy = this.jumpSpeed
105
- this._jumpQueued = false
106
- this._vy += this.gravity * dt // integrate gravity (CharacterVirtual is kinematic)
107
- _creator.characterSetVelocity(this._charId, this._mx * this.speed + this._ex, this._vy, this._mz * this.speed + this._ez)
232
+ if (axis === "y") { _creator.characterSetVerticalVelocity(this._charId, v); return }
233
+ _creator.characterGetVelocity(this._charId, scratch)
234
+ if (axis === "x") scratch[0] = v
235
+ else if (axis === "z") scratch[2] = v
236
+ _creator.characterSetVelocity(this._charId, scratch[0], scratch[1], scratch[2])
108
237
  }
109
238
  }
@@ -0,0 +1,212 @@
1
+ // The gearbox of a `Vehicle`, as an aspect on the same node. It is deliberately NOT in the engine:
2
+ // picking a gear is policy, not physics — two RPM thresholds and a latency timer — and every game
3
+ // wants its own. The engine keeps only the physical half (the clutch's stiffness and the coupled
4
+ // engine/wheel solve), and is told one ratio and one clutch scalar per frame.
5
+ //
6
+ // car.aspect(Gearbox, { ratios: [2.66, 1.78, 1.3, 1.0, 0.74], reverse: -2.9 })
7
+ //
8
+ // A `Vehicle` with no `Gearbox` attaches this one with its defaults, so a car that never mentions
9
+ // gears still drives.
10
+ //
11
+ // Custom behaviour is a subclass overriding ONE method:
12
+ //
13
+ // class SportBox extends Gearbox {
14
+ // select(v: Vehicle): number { return v.rpm > 6200 ? this.gear + 1 : this.gear }
15
+ // }
16
+ //
17
+ // The clutch is two things multiplied. The SHIFT envelope (`shiftTime`/`clutchTime`) stays with the
18
+ // base class on purpose: a forgotten ramp breaks a car silently — no drive at all, or a slammed
19
+ // clutch. The TAKE-UP (`takeUp`) is the left-foot half, and is overridable for a car that launches
20
+ // unusually — a torque converter, a rally launch control.
21
+
22
+ import { Aspect } from "../core/Aspect"
23
+ import type { FieldMeta } from "../core/fields"
24
+ import type { Node } from "./Node"
25
+ import type { Vehicle } from "./Vehicle"
26
+
27
+ // How far above idle counts as "revs to spare". A simulated engine cannot fall below its idle — the
28
+ // physics clamps it — so being AT idle is the observable form of "the wheels are dragging me under".
29
+ // This is the tolerance for reading that clamp, not a tuning knob.
30
+ const IDLE_MARGIN = 1.05
31
+
32
+ // The car this gearbox belongs to. Read through the aspect accessor rather than node.get(Vehicle),
33
+ // so this file needs Vehicle only as a TYPE and the two do not import each other at runtime.
34
+ const carOf = (n: Node): Vehicle | null => (n as unknown as { vehicle?: Vehicle }).vehicle ?? null
35
+
36
+ export class Gearbox extends Aspect<"gearbox", Node> {
37
+ static readonly aspect = "gearbox"
38
+
39
+ /** Forward gear ratios, first gear first. Engine RPM = wheel speed × ratio × the differential. */
40
+ ratios: number[] = [ 2.66, 1.78, 1.3, 1.0, 0.74 ]
41
+ /** Reverse ratio — negative, because a ratio's SIGN is what reverse means to the engine. */
42
+ reverse = -2.9
43
+ /** Shift up when the engine passes this fraction of its redline. */
44
+ shiftUp = 0.67
45
+ /** Shift down when it falls below this fraction. */
46
+ shiftDown = 0.33
47
+ /** How long a shift takes, in seconds — the clutch is out for this long. */
48
+ shiftTime = 0.2
49
+ /** How long the clutch takes to re-engage afterwards. */
50
+ clutchTime = 0.3
51
+ /** How long to wait after a shift before another is allowed — without it a car sitting on a
52
+ * threshold hunts between two gears. */
53
+ latency = 0.5
54
+
55
+ /** How far the clutch bites the instant drive is asked for from rest, 0..1. It only has to COUPLE
56
+ * the shafts — how much torque that passes is the engine's business, so a feathered throttle
57
+ * still pulls away gently at the same bite point. */
58
+ bite = 0.4
59
+
60
+ static fields: FieldMeta<Gearbox> = {
61
+ reverse: { max: -0.1, step: 0.1 },
62
+ shiftUp: { min: 0.1, max: 1, step: 0.01 },
63
+ shiftDown: { min: 0, max: 0.9, step: 0.01 },
64
+ shiftTime: { min: 0, max: 3, step: 0.05 },
65
+ clutchTime: { min: 0, max: 3, step: 0.05 },
66
+ latency: { min: 0, max: 3, step: 0.05 },
67
+ bite: { min: 0, max: 1, step: 0.05 },
68
+ ratios: { hidden: true },
69
+ }
70
+
71
+ /** Current gear: −1 reverse, 0 neutral, 1… forward. Read it for a HUD. */
72
+ gear = 0
73
+ /** True while a shift is in progress — the clutch is out and the engine is not driving the wheels. */
74
+ get shifting(): boolean { return this._shiftLeft > 0 }
75
+
76
+ private _shiftLeft = 0 // seconds of clutch-out left
77
+ private _engageLeft = 0 // seconds of clutch re-engagement left
78
+ private _waitLeft = 0 // anti-hunting
79
+ private _take = 0 // how far the CAR's own state wants the clutch in, latched per frame
80
+
81
+ /** EARLY phase: the pair has to be latched before this frame's step consumes it. */
82
+ updateBefore(dt: number): void {
83
+ const v = carOf(this.node)
84
+ if (!v || !v.id) return
85
+
86
+ this._waitLeft = Math.max(0, this._waitLeft - dt)
87
+
88
+ if (this._shiftLeft > 0) {
89
+ // Mid-shift: clutch fully out, and the moment it ends the re-engagement ramp starts.
90
+ this._shiftLeft = Math.max(0, this._shiftLeft - dt)
91
+ if (this._shiftLeft === 0) this._engageLeft = this.clutchTime
92
+ } else {
93
+ const want = this.select(v)
94
+ if (want !== this.gear) this.shift(want)
95
+ else this._engageLeft = Math.max(0, this._engageLeft - dt)
96
+ }
97
+
98
+ // The pedal only moves while no shift is in flight. During one the revs say nothing about
99
+ // whether the wheels can sustain them: an engine still spinning down from the last gear reads as
100
+ // "revs to spare" and would drop the clutch straight back in — enough to shove a stopped car
101
+ // several km/h with no throttle at all. Freezing it is what a driver does anyway; the foot is
102
+ // already down, and the envelope owns the clutch until the gear is in.
103
+ if (this._shiftLeft === 0 && this._engageLeft === 0) {
104
+ this._take = Math.max(0, Math.min(1, this.takeUp(v, dt)))
105
+ }
106
+
107
+ v._setTransmission(this.ratio, this.clutch)
108
+ }
109
+
110
+ /**
111
+ * The driver's left foot: where the clutch pedal is, ignoring shifts. Returns the new engagement.
112
+ *
113
+ * A driver does not compute anything. They watch the tacho: **revs sinking to idle means the
114
+ * wheels are about to drag the engine under, and that is when you push the clutch in.** That
115
+ * single test replaces every ratio and threshold — the crossover it finds is the exact speed where
116
+ * engine braking stops and the engine would start PUSHING instead, and it finds it per gear
117
+ * without being told the gear.
118
+ *
119
+ * The pedal comes back UP only for the throttle. Healthy revs are not on their own a reason to
120
+ * engage: a car standing still shows healthy revs too — the wheels simply are not turning to say
121
+ * otherwise — and a clutch dropped on that evidence lurches the car off with no throttle at all.
122
+ *
123
+ * Three things fall out of it for free:
124
+ *
125
+ * - **A braked car stops.** The engine cannot stall, so a clutch left in at 0 km/h feeds idle
126
+ * torque to the wheels forever and the car creeps against its own brakes. Here the revs pin at
127
+ * idle, so the pedal goes down and the brakes have nothing to fight.
128
+ * - **Reverse is not a special case.** No ratio takes part, so it needs no separate number — which
129
+ * is exactly why a fixed road-speed threshold got reverse wrong.
130
+ * - **Hill starts judder instead of stalling.** Let the pedal out, the load pulls the revs down,
131
+ * the rule pushes it straight back in, and the clutch sits slipping at the bite point until the
132
+ * car moves. So the test deliberately ignores the throttle — gating it on "no throttle" would
133
+ * stall the car under load. Throttle only holds the floor at `bite`.
134
+ *
135
+ * The cost is honest and small: once the pedal is down the revs sit at idle and cannot rise on
136
+ * their own, so **coasting downhill gives no engine braking until you touch the throttle** — which
137
+ * is precisely what a car with the clutch in does. Slipping it permanently to keep listening would
138
+ * bring the creep straight back.
139
+ */
140
+ protected takeUp(v: Vehicle, dt: number): number {
141
+ if (this.gear === 0) return 0
142
+ const idle = v.engine.idleRpm ?? 1000
143
+ const step = dt / Math.max(this.clutchTime, dt)
144
+ const asked = v.throttle !== 0
145
+ let take = this._take
146
+ if (v.rpm <= idle * IDLE_MARGIN) take -= step // being dragged under: pedal goes down
147
+ else if (asked) take += step // revs to spare AND somewhere to go: let it up
148
+ // Asking for drive also holds the pedal at the bite point however far down the rule wants it;
149
+ // that is what makes a launch — and a hill start — slip instead of collapsing to nothing.
150
+ return asked ? Math.max(take, this.bite) : take
151
+ }
152
+
153
+ /**
154
+ * Which gear the car should be in. The default is an ordinary automatic: pull away from neutral in
155
+ * the direction of the throttle, shift up past `shiftUp` of the redline and down below `shiftDown`,
156
+ * and refuse to shift again for `latency` seconds. Override this and nothing else.
157
+ */
158
+ select(v: Vehicle): number {
159
+ const throttle = v.throttle
160
+ // Neutral, or asked to go the other way: engage once the car has (nearly) stopped.
161
+ if (this.gear === 0 || throttle * this.gear < 0) {
162
+ if (Math.abs(v.speed) > 1 && this.gear !== 0) return this.gear
163
+ return throttle > 0 ? 1 : throttle < 0 ? -1 : this.gear
164
+ }
165
+ if (this.gear < 0 || this._waitLeft > 0 || this.shifting) return this.gear
166
+ const rpm = v.rpm
167
+ const max = v.engine.maxRpm ?? 6000
168
+ // Upshifting needs COUPLED revs. With the clutch out the engine is free, so a blip at a
169
+ // standstill reads as 6000 rpm and the box would climb to top gear without the car moving —
170
+ // then launch in it. Downshifting stays open either way: it can only take you to a lower gear,
171
+ // which is where a car with dying revs belongs.
172
+ if (rpm > max * this.shiftUp && this.clutch >= 1 && this.gear < this.ratios.length) return this.gear + 1
173
+ if (rpm < max * this.shiftDown && this.gear > 1) return this.gear - 1
174
+ return this.gear
175
+ }
176
+
177
+ /** Change gear now, with the clutch envelope. Safe to call from `select()` or from game code
178
+ * (a sequential manual is `if (Input.key('KeyE')) car.gearbox.shift(car.gearbox.gear + 1)`). */
179
+ shift(gear: number): void {
180
+ const clamped = Math.max(-1, Math.min(this.ratios.length, Math.round(gear)))
181
+ if (clamped === this.gear) return
182
+ // The envelope is for swapping one ENGAGED gear for another. If nothing is coupled there is
183
+ // nothing to disengage, so it is skipped: neutral at either end, and equally a car sitting still
184
+ // with the pedal already down — picking reverse there should pull away the moment it is asked
185
+ // to, not sit out a shift it never had to make.
186
+ const swapping = this.gear !== 0 && clamped !== 0 && this._take > 0
187
+ this.gear = clamped
188
+ this._shiftLeft = swapping ? this.shiftTime : 0
189
+ this._engageLeft = swapping ? this.clutchTime : 0
190
+ this._waitLeft = this.latency + this._shiftLeft
191
+ }
192
+
193
+ /** The ratio the engine should be running: 0 in neutral, negative in reverse. */
194
+ get ratio(): number {
195
+ if (this.gear === 0) return 0
196
+ if (this.gear < 0) return this.reverse
197
+ return this.ratios[this.gear - 1] ?? 1
198
+ }
199
+
200
+ /**
201
+ * The clutch scalar 0..1 the engine is given: the SHIFT envelope (out during a shift, ramping back
202
+ * over `clutchTime`) times the driver's pedal (`takeUp`). Two independent reasons for the clutch
203
+ * to be out, so they multiply: mid-shift is out however healthy the revs are, and an engine being
204
+ * dragged to idle is out whatever gear it just picked.
205
+ */
206
+ get clutch(): number {
207
+ if (this._shiftLeft > 0) return 0
208
+ const envelope =
209
+ this._engageLeft > 0 && this.clutchTime > 0 ? 1 - this._engageLeft / this.clutchTime : 1
210
+ return envelope * this._take
211
+ }
212
+ }
@@ -20,6 +20,11 @@ export class Geometry {
20
20
  normals: Float32Array
21
21
  indices: Uint16Array
22
22
  uv: Float32Array
23
+ /** Lightmap UV set (2 floats per vertex, non-overlapping, inside the unit square) — the
24
+ * `Lightmap` atlas samples a Mesh through it. The box and cylinder builders fill it (their `uv`
25
+ * tiles per face, which would fold every face onto the same texels); absent = the host reuses
26
+ * `uv`, which is right for a plane. */
27
+ uv1?: Float32Array
23
28
  /** @internal native mesh-type enum (0 triangles / 1 edges / 2 vertices). */
24
29
  _meshKind = 0
25
30
 
@@ -50,29 +55,75 @@ export class Geometry {
50
55
  this._meshKind = kind === "edges" ? 1 : kind === "vertices" ? 2 : 0
51
56
  }
52
57
 
53
- static box(): Geometry { return createCube() }
58
+ /** A box; `size` (default 1) scales the vertices AND lays the lightmap chart out in proportion to
59
+ * the faces' areas (a floor's top face gets the texels, not its 1 m side strips). */
60
+ static box(size: Vec3Like | number = 1): Geometry { return createCube(size) }
54
61
  static sphere(options?: SphereOptions): Geometry { return createSphere(options) }
55
62
  static cylinder(options?: CylinderOptions): Geometry { return createCylinder(options) }
56
63
  static plane(options?: PlaneOptions): Geometry { return createPlane(options) }
57
64
  }
58
65
 
66
+ // Gutter between lightmap charts (fraction of the chart square): the bake dilates each rect by a
67
+ // few texels and the shader samples bilinearly, so neighbouring faces must not touch in uv1.
68
+ const LM_GUTTER = 0.03
69
+
70
+ type Rect = { x: number, y: number, w: number, h: number }
71
+
72
+ /** Shelf-pack rectangles (metres) into the unit square at the largest uniform scale — every face
73
+ * gets texels in proportion to its area, so a 64×1×64 floor's top face isn't squeezed into the
74
+ * same cell as its 1 m side strips. Returns rects in input order. */
75
+ const packCharts = (dims: [number, number][]): Rect[] => {
76
+ const order = dims.map((_, i) => i).sort((a, b) => dims[b][1] - dims[a][1])
77
+ const attempt = (scale: number): Rect[] | null => {
78
+ const out: Rect[] = new Array(dims.length)
79
+ let x = 0, y = 0, rowH = 0
80
+ for (const i of order) {
81
+ const w = dims[i][0] * scale, h = dims[i][1] * scale
82
+ if (x > 0 && x + w > 1) { y += rowH + LM_GUTTER; x = 0; rowH = 0 }
83
+ if (w > 1 || y + h > 1) return null
84
+ out[i] = { x, y, w, h }
85
+ x += w + LM_GUTTER
86
+ rowH = Math.max(rowH, h)
87
+ }
88
+ return out
89
+ }
90
+ let lo = 0, hi = 1 / Math.max(...dims.map((d) => Math.max(d[0], d[1])))
91
+ let best = attempt(hi)
92
+ if (!best) {
93
+ for (let it = 0; it < 40; it++) {
94
+ const mid = (lo + hi) / 2
95
+ const r = attempt(mid)
96
+ if (r) { best = r; lo = mid } else hi = mid
97
+ }
98
+ }
99
+ return best ?? dims.map(() => ({ x: 0, y: 0, w: 1, h: 1 }))
100
+ }
101
+
59
102
  // --- cube ---
60
- const createCube = (): Geometry => {
103
+ const createCube = (size: Vec3Like | number = 1): Geometry => {
104
+ const sx = typeof size === "number" ? size : cx(size)
105
+ const sy = typeof size === "number" ? size : cy(size)
106
+ const sz = typeof size === "number" ? size : cz(size)
61
107
  const vertices = new Float32Array(6 * 4 * 3)
62
108
  const normals = new Float32Array(6 * 4 * 3)
63
- const uv = new Float32Array(6 * 4 * 3)
109
+ const uv = new Float32Array(6 * 4 * 2)
110
+ const uv1 = new Float32Array(6 * 4 * 2)
64
111
  const indices = new Uint16Array(6 * 6)
65
112
  let cnt = 0, indicesCnt = 0
66
113
  const sides = [ -0.5, 0.5 ]
114
+ // face extents along the (a, b) parameters below: ±x faces span (z, y), ±y faces (x, z), ±z faces (x, y)
115
+ const charts = packCharts([ [ sz, sy ], [ sz, sy ], [ sx, sz ], [ sx, sz ], [ sx, sy ], [ sx, sy ] ])
67
116
  for (let i = 0; i < 6; i++) {
68
117
  const s = cnt
69
118
  const side = i % 2 === 0 ? -0.5 : 0.5
119
+ const c = charts[i]
70
120
  for (const a of sides) {
71
121
  for (const b of sides) {
72
122
  uv.set([ a + 0.5, b + 0.5 ], cnt * 2)
73
- if (i === 0 || i === 1) { vertices.set([ side, b, -a ], cnt * 3); normals.set([ side * 2, 0, 0 ], cnt * 3) }
74
- if (i === 2 || i === 3) { vertices.set([ a, side, -b ], cnt * 3); normals.set([ 0, side * 2, 0 ], cnt * 3) }
75
- if (i === 4 || i === 5) { vertices.set([ a, b, side ], cnt * 3); normals.set([ 0, 0, side * 2 ], cnt * 3) }
123
+ uv1.set([ c.x + (a + 0.5) * c.w, c.y + (b + 0.5) * c.h ], cnt * 2)
124
+ if (i === 0 || i === 1) { vertices.set([ side * sx, b * sy, -a * sz ], cnt * 3); normals.set([ side * 2, 0, 0 ], cnt * 3) }
125
+ if (i === 2 || i === 3) { vertices.set([ a * sx, side * sy, -b * sz ], cnt * 3); normals.set([ 0, side * 2, 0 ], cnt * 3) }
126
+ if (i === 4 || i === 5) { vertices.set([ a * sx, b * sy, side * sz ], cnt * 3); normals.set([ 0, 0, side * 2 ], cnt * 3) }
76
127
  cnt++
77
128
  }
78
129
  }
@@ -81,7 +132,9 @@ const createCube = (): Geometry => {
81
132
  else indices.set([ s2, s1, s, s1, s2, s3 ], indicesCnt)
82
133
  indicesCnt += 6
83
134
  }
84
- return new Geometry(vertices, normals, indices, uv)
135
+ const g = new Geometry(vertices, normals, indices, uv)
136
+ g.uv1 = uv1
137
+ return g
85
138
  }
86
139
 
87
140
  // --- sphere ---
@@ -143,6 +196,8 @@ const createCylinder = (options: CylinderOptions = {}): Geometry => {
143
196
  const vertices = new Float32Array(verticesCount * 3)
144
197
  const normals = new Float32Array(verticesCount * 3)
145
198
  const uv = new Float32Array(verticesCount * 2)
199
+ // lightmap chart: the side strip across the upper half, the two caps as discs in the lower half
200
+ const uv1 = new Float32Array(verticesCount * 2)
146
201
  const indices = new Uint16Array(edges * 6 + edges * 6)
147
202
  const sides = [ -0.5, 0.5 ]
148
203
  let cnt = 0, indicesCnt = 0
@@ -159,6 +214,7 @@ const createCylinder = (options: CylinderOptions = {}): Geometry => {
159
214
  if (smooth) normals.set([ x, 0, y ], cnt * 3)
160
215
  else normals.set([ normalX, 0, normalY ], cnt * 3)
161
216
  uv.set([ (i + j) / edges, side + 0.5 ], cnt * 2)
217
+ uv1.set([ (i + j) / edges, 0.55 + (side + 0.5) * 0.43 ], cnt * 2)
162
218
  cnt++
163
219
  }
164
220
  }
@@ -168,19 +224,24 @@ const createCylinder = (options: CylinderOptions = {}): Geometry => {
168
224
  }
169
225
  for (const side of sides) {
170
226
  const center = cnt
171
- vertices.set([ 0, side, 0 ], cnt * 3); normals.set([ 0, side * 2, 0 ], cnt * 3); cnt++
227
+ const capU = side > 0 ? 0.75 : 0.25 // lightmap disc centre per cap
228
+ vertices.set([ 0, side, 0 ], cnt * 3); normals.set([ 0, side * 2, 0 ], cnt * 3)
229
+ uv1.set([ capU, 0.25 ], cnt * 2); cnt++
172
230
  for (let i = 0; i < edges; i++) {
173
231
  const x = Math.cos(i / edges * Math.PI * 2), y = Math.sin(i / edges * Math.PI * 2)
174
232
  if (side > 0) vertices.set([ x * radiusTop, side, y * radiusTop ], cnt * 3)
175
233
  else vertices.set([ x * radiusBottom, side, y * radiusBottom ], cnt * 3)
176
234
  normals.set([ 0, side * 2, 0 ], cnt * 3)
235
+ uv1.set([ capU + x * 0.22, 0.25 + y * 0.22 ], cnt * 2)
177
236
  if (side < 0) indices.set([ center, center + i + 1, center + 1 + ((i + 1) % edges) ], indicesCnt)
178
237
  else indices.set([ center + 1 + ((i + 1) % edges), center + i + 1, center ], indicesCnt)
179
238
  indicesCnt += 3
180
239
  cnt++
181
240
  }
182
241
  }
183
- return new Geometry(vertices, normals, indices, uv)
242
+ const g = new Geometry(vertices, normals, indices, uv)
243
+ g.uv1 = uv1
244
+ return g
184
245
  }
185
246
 
186
247
  // --- plane ---