lecodes-sdk 0.20.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/global.d.ts +35 -9
  2. package/dist/types/animate/tween/Animation.d.ts +69 -0
  3. package/dist/types/animate/tween/Timeline.d.ts +55 -0
  4. package/dist/types/animate/tween/animateValue.d.ts +27 -0
  5. package/dist/types/animate/tween/easing.d.ts +29 -0
  6. package/dist/types/animate/tween/spec.d.ts +178 -0
  7. package/dist/types/canvas/Canvas.d.ts +2 -0
  8. package/dist/types/g2/Node2D.d.ts +16 -0
  9. package/dist/types/g2/Sprite.d.ts +11 -1
  10. package/dist/types/gl/Camera.d.ts +15 -1
  11. package/dist/types/gl/DecalSet.d.ts +48 -3
  12. package/dist/types/gl/Foliage.d.ts +47 -0
  13. package/dist/types/gl/Geometry.d.ts +36 -0
  14. package/dist/types/gl/Light.d.ts +25 -7
  15. package/dist/types/gl/Lightmap.d.ts +90 -51
  16. package/dist/types/gl/Material.d.ts +32 -20
  17. package/dist/types/gl/Mesh.d.ts +7 -1
  18. package/dist/types/gl/Model.d.ts +41 -5
  19. package/dist/types/gl/Node.d.ts +18 -0
  20. package/dist/types/gl/Particles.d.ts +53 -1
  21. package/dist/types/gl/Scene.d.ts +21 -1
  22. package/dist/types/gl/animation/AnimationClip.d.ts +19 -0
  23. package/dist/types/gl/animation/Animator.d.ts +27 -0
  24. package/dist/types/gl/animation/DynamicBone.d.ts +184 -0
  25. package/dist/types/gl/animation/IK.d.ts +109 -0
  26. package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
  27. package/dist/types/gl/animation/Warp.d.ts +2 -1
  28. package/dist/types/gl/animation/core.d.ts +35 -4
  29. package/dist/types/gl/{AudioSource.d.ts → audio/AudioSource.d.ts} +6 -6
  30. package/dist/types/gl/{AudioZone.d.ts → audio/AudioZone.d.ts} +4 -4
  31. package/dist/types/gl/{SceneAudio.d.ts → audio/SceneAudio.d.ts} +1 -1
  32. package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
  33. package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
  34. package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
  35. package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
  36. package/dist/types/gl/physics/Ragdoll.d.ts +161 -0
  37. package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
  38. package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
  39. package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +10 -8
  40. package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
  41. package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
  42. package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
  43. package/dist/types/inject.d.ts +35 -27
  44. package/dist/types/runtime/files.d.ts +24 -1
  45. package/dist/types/scene/defineScene.d.ts +50 -31
  46. package/dist/types/ui/UIButton.d.ts +3 -1
  47. package/dist/types/ui/UIInput.d.ts +5 -1
  48. package/dist/types/ui/UINode.d.ts +24 -24
  49. package/dist/types.json +1 -1
  50. package/package.json +1 -1
  51. package/prompts/README.md +142 -142
  52. package/prompts/core-design.md +27 -4
  53. package/prompts/core.md +35 -6
  54. package/prompts/select.ts +19 -4
  55. package/src/animate/tween/Animation.ts +378 -0
  56. package/src/animate/tween/Timeline.ts +175 -0
  57. package/src/animate/tween/animateValue.ts +100 -0
  58. package/src/animate/tween/easing.ts +172 -0
  59. package/src/animate/tween/spec.ts +479 -0
  60. package/src/bridges.d.ts +1760 -1481
  61. package/src/canvas/Canvas.ts +21 -0
  62. package/src/compile/__tests__/assetMacro.test.ts +26 -0
  63. package/src/compile/__tests__/compile.test.ts +11 -0
  64. package/src/compile/__tests__/detectEntry.test.ts +19 -0
  65. package/src/compile/__tests__/serverSplit.test.ts +27 -0
  66. package/src/compile/bundler.ts +34 -4
  67. package/src/compile/compileProject.ts +31 -1
  68. package/src/compile/detectEntry.ts +8 -3
  69. package/src/compile/header.ts +6 -3
  70. package/src/compile/index.ts +3 -0
  71. package/src/compile/sceneEditor.ts +42 -1
  72. package/src/compile/serverSplit.ts +9 -3
  73. package/src/g2/Node2D.ts +38 -0
  74. package/src/g2/Sprite.ts +20 -1
  75. package/src/gl/Camera.ts +34 -1
  76. package/src/gl/DecalSet.ts +132 -5
  77. package/src/gl/Foliage.ts +102 -0
  78. package/src/gl/Geometry.ts +109 -0
  79. package/src/gl/Light.ts +46 -16
  80. package/src/gl/Lightmap.ts +440 -249
  81. package/src/gl/Material.ts +69 -36
  82. package/src/gl/Mesh.ts +120 -102
  83. package/src/gl/Model.ts +167 -124
  84. package/src/gl/Node.ts +40 -1
  85. package/src/gl/Particles.ts +82 -5
  86. package/src/gl/Scene.ts +35 -2
  87. package/src/gl/animation/AnimationClip.ts +52 -0
  88. package/src/gl/animation/Animator.ts +42 -2
  89. package/src/gl/animation/DynamicBone.ts +482 -0
  90. package/src/gl/animation/IK.ts +214 -0
  91. package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
  92. package/src/gl/animation/Playback.ts +5 -4
  93. package/src/gl/animation/Warp.ts +5 -2
  94. package/src/gl/animation/core.ts +65 -4
  95. package/src/gl/{AudioSource.ts → audio/AudioSource.ts} +7 -7
  96. package/src/gl/{AudioZone.ts → audio/AudioZone.ts} +75 -75
  97. package/src/gl/{SceneAudio.ts → audio/SceneAudio.ts} +2 -2
  98. package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
  99. package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
  100. package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
  101. package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
  102. package/src/gl/physics/Ragdoll.ts +451 -0
  103. package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
  104. package/src/gl/{Trigger.ts → physics/Trigger.ts} +45 -45
  105. package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
  106. package/src/gl/{Terrain.ts → terrain/Terrain.ts} +14 -12
  107. package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
  108. package/src/gl/vehicle/Vehicle.ts +666 -0
  109. package/src/gl/vehicle/Wheel.ts +290 -0
  110. package/src/inject.ts +236 -224
  111. package/src/runtime/files.ts +32 -2
  112. package/src/scene/defineScene.ts +92 -66
  113. package/src/scene/level.ts +2 -2
  114. package/src/ui/UIButton.ts +2 -2
  115. package/src/ui/UIInput.ts +3 -3
  116. package/src/ui/UINode.ts +61 -36
  117. package/dist/types/animate/animate.d.ts +0 -20
  118. package/dist/types/gl/Gearbox.d.ts +0 -86
  119. package/dist/types/gl/IK.d.ts +0 -53
  120. package/dist/types/gl/Ragdoll.d.ts +0 -86
  121. package/dist/types/gl/Vehicle.d.ts +0 -191
  122. package/dist/types/gl/Wheel.d.ts +0 -95
  123. package/src/animate/animate.ts +0 -238
  124. package/src/gl/Gearbox.ts +0 -212
  125. package/src/gl/IK.ts +0 -193
  126. package/src/gl/Ragdoll.ts +0 -270
  127. package/src/gl/Vehicle.ts +0 -473
  128. package/src/gl/Wheel.ts +0 -240
  129. /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
package/src/gl/Gearbox.ts DELETED
@@ -1,212 +0,0 @@
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
- }
package/src/gl/IK.ts DELETED
@@ -1,193 +0,0 @@
1
- // IK — precise character actions as late-phase aspects on BONES (docs/animation-plan.md §2.5).
2
- // They run after the animation wrote the pose (base anim or Animator) and before the skin is
3
- // flushed, so their bone writes land in the skin. Attach to the END bone of the chain; the chain is
4
- // walked up through parents — one instance per end effector, any Node hierarchy works.
5
- //
6
- // const foot = hero.bone('LeftFoot')!
7
- // foot.aspect(IK.TwoBone, { target: footTarget, pole: kneeHint }) // upLeg → leg → foot
8
- // foot.ik.weight = grounded ? 1 : 0 // blend in/out
9
- // hero.bone('Head')!.aspect(IK.LookAt, { target: camera, limit: 70 }) // head tracks the camera
10
- //
11
- // Pure SDK/JS: a handful of world-matrix reads + two quaternion writes per solve. A native solver can
12
- // slot in later behind the same API.
13
-
14
- import { Aspect } from "../core/Aspect"
15
- import { Vec3, type Vec3Like } from "../math/vec"
16
- import { Quat } from "../math/quat"
17
- import type { Node } from "./Node"
18
-
19
- type Target = Node | Vec3Like
20
-
21
- const targetPos = (t: Target | undefined): Vec3 | null => {
22
- if (t === undefined || t === null) return null
23
- const n = t as Node
24
- if (typeof (n as { worldPosition?: unknown }).worldPosition === "object" && typeof n.id === "number") return n.worldPosition
25
- return new Vec3(t as Vec3Like)
26
- }
27
-
28
- /** World rotation of a node (parent chain composed natively). */
29
- const worldRot = (n: Node): Quat => n.worldMatrix.rotation
30
-
31
- /** Write a WORLD rotation to a node by converting through the parent's world rotation. */
32
- const setWorldRot = (n: Node, q: Quat): void => {
33
- const p = n.parent
34
- n.quaternion = p ? worldRot(p).invert().mul(q).normalize() : q.normalize()
35
- }
36
-
37
- const clampAngle = (q: Quat, maxRad: number): Quat => {
38
- // angle of a unit quaternion = 2·acos(|w|)
39
- const w = Math.min(1, Math.abs(q.w))
40
- const ang = 2 * Math.acos(w)
41
- if (ang <= maxRad || ang < 1e-6) return q
42
- const s = Math.sqrt(1 - w * w)
43
- const axis = new Vec3(q.x / s, q.y / s, q.z / s)
44
- return Quat.fromAxisAngle(axis, q.w < 0 ? -maxRad : maxRad)
45
- }
46
-
47
- /** Two-bone analytic IK (limbs). Attach to the END bone: `foot.aspect(IK.TwoBone, { target })`
48
- * solves upper (grandparent) + mid (parent) so the end reaches `target`; `pole` steers the bend
49
- * (knee/elbow) — a Node or world point. */
50
- class TwoBone extends Aspect<"ik", Node> {
51
- static readonly aspect = "ik"
52
-
53
- /** Where the end bone should be (Node or world position). */
54
- target?: Target
55
- /** Bend hint — the mid joint is pulled toward it (Node or world position). */
56
- pole?: Target
57
- /** World rotation the END bone takes after the solve — a Node (its world rotation) or a Quat.
58
- * Unset = the end bone keeps its animated local rotation (a hand on a grip, a foot on a slope
59
- * want it set). Blended by `weight · rotationWeight`. */
60
- rotation?: Node | Quat
61
- /** 0–1 contribution of `rotation` (on top of `weight`). */
62
- rotationWeight = 1
63
- /** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
64
- weight = 1
65
- /** Solve every frame (default). Set false to drive `solve()` yourself. */
66
- enabled = true
67
-
68
- update(): void {
69
- if (this.enabled) this.solve()
70
- }
71
-
72
- /** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
73
- solve(): void {
74
- const t = targetPos(this.target)
75
- if (!t || this.weight <= 0) return
76
- const end = this.node
77
- const mid = end.parent
78
- const root = mid?.parent
79
- if (!mid || !root) return
80
-
81
- const w = Math.min(1, this.weight)
82
- const midLocal0 = mid.quaternion
83
- const rootLocal0 = root.quaternion
84
-
85
- const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
86
- const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
87
- if (l1 < 1e-6 || l2 < 1e-6) return
88
- const eps = 1e-4
89
- const toT = t.sub(a)
90
- const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
91
-
92
- // 1. bend the mid joint to the angle the target distance demands (law of cosines)
93
- const ba = a.sub(b), bc = c.sub(b)
94
- const cur = ba.angle(bc)
95
- const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
96
- // a positive rotation about ba × bc moves `bc` AWAY from `ba`, i.e. opens the joint — so rotating the
97
- // mid bone by (want − cur) about it opens/closes exactly as needed. (bc × ba has the opposite sense:
98
- // with it every already-bent limb — which is every animated limb — bends the wrong way; the straight
99
- // case below picks its axis as ba × ref, the same sense as ba × bc for a limb barely bent toward ref.)
100
- let axis = ba.cross(bc)
101
- if (axis.lengthSq() < 1e-10) {
102
- // straight limb: bend toward the pole (or any perpendicular)
103
- const p = targetPos(this.pole)
104
- const ref = p ? p.sub(b) : new Vec3(0, 0, 1)
105
- axis = ba.cross(ref)
106
- if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(0, 1, 0))
107
- if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(1, 0, 0))
108
- }
109
- axis = axis.normalize()
110
- // rotating mid by (want - cur) about `axis` opens/closes the angle between ba and bc
111
- const midWorld = worldRot(mid)
112
- setWorldRot(mid, Quat.fromAxisAngle(axis, want - cur).mul(midWorld))
113
-
114
- // 2. swing the root so the end lands on the target direction
115
- const c2 = end.worldPosition
116
- const rootWorld = worldRot(root)
117
- setWorldRot(root, Quat.fromTo(c2.sub(a), toT).mul(rootWorld))
118
-
119
- // 3. pole: spin the root about the root→target axis so the mid joint lies toward the pole
120
- const p = targetPos(this.pole)
121
- if (p) {
122
- const dir = toT.normalize()
123
- const b2 = mid.worldPosition
124
- const midProj = b2.sub(a).sub(dir.scale(b2.sub(a).dot(dir)))
125
- const poleProj = p.sub(a).sub(dir.scale(p.sub(a).dot(dir)))
126
- if (midProj.lengthSq() > 1e-10 && poleProj.lengthSq() > 1e-10) {
127
- setWorldRot(root, Quat.fromTo(midProj, poleProj).mul(worldRot(root)))
128
- }
129
- }
130
-
131
- // 4. weight: blend from the animated pose to the solved one
132
- if (w < 1) {
133
- root.quaternion = rootLocal0.slerp(root.quaternion, w)
134
- mid.quaternion = midLocal0.slerp(mid.quaternion, w)
135
- }
136
-
137
- // 5. end-bone rotation: take the target's world rotation (a hand stays on its grip, a foot
138
- // flat on the slope) — the solve above only placed the end's ORIGIN
139
- const r = this.rotation
140
- if (r !== undefined && r !== null) {
141
- const rw = w * Math.min(1, Math.max(0, this.rotationWeight))
142
- if (rw > 0) {
143
- const want = (r as Node).id !== undefined && typeof (r as Node).worldMatrix === "object" ? worldRot(r as Node) : (r as Quat)
144
- const endLocal0 = end.quaternion
145
- setWorldRot(end, want)
146
- if (rw < 1) end.quaternion = endLocal0.slerp(end.quaternion, rw)
147
- }
148
- }
149
- }
150
- }
151
-
152
- /** Aim a bone at a target (head / eyes / turret). Attach to the bone itself:
153
- * `head.aspect(IK.LookAt, { target: camera, limit: 70 })`. `axis` is the bone's LOCAL forward
154
- * (the direction that should point at the target) — rigs differ; default −Z, Mixamo heads look
155
- * along +Z of the head bone in most exports, so pass `axis: [0, 0, 1]` there if it faces backwards. */
156
- class LookAt extends Aspect<"lookAt", Node> {
157
- static readonly aspect = "lookAt"
158
-
159
- target?: Target
160
- /** The bone's local axis that should point at the target. */
161
- axis: Vec3Like = [ 0, 0, -1 ]
162
- /** Max deflection from the animated direction, in degrees (default 80). */
163
- limit = 80
164
- /** 0–1 contribution. */
165
- weight = 1
166
- enabled = true
167
-
168
- update(): void {
169
- if (this.enabled) this.solve()
170
- }
171
-
172
- solve(): void {
173
- const t = targetPos(this.target)
174
- if (!t || this.weight <= 0) return
175
- const bone = this.node
176
- const w = Math.min(1, this.weight)
177
- const local0 = bone.quaternion
178
- const from = bone.worldPosition
179
- const dir = t.sub(from)
180
- if (dir.lengthSq() < 1e-10) return
181
- const rot = worldRot(bone)
182
- const current = new Vec3(this.axis).rotate(rot)
183
- let delta = Quat.fromTo(current, dir)
184
- delta = clampAngle(delta, (this.limit * Math.PI) / 180)
185
- setWorldRot(bone, delta.mul(rot))
186
- if (w < 1) bone.quaternion = local0.slerp(bone.quaternion, w)
187
- }
188
- }
189
-
190
- /** Inverse kinematics aspects — attach to bones (see file header). */
191
- export const IK = { TwoBone, LookAt }
192
- export type IKTwoBone = TwoBone
193
- export type IKLookAt = LookAt
package/src/gl/Ragdoll.ts DELETED
@@ -1,270 +0,0 @@
1
- // A ragdoll: the model's skeleton handed to physics. One rigid body per listed bone — a capsule from
2
- // the bone's origin to the next joint down the chain — joined to its parent part by a swing-twist
3
- // constraint (a cone + a twist range, or a one-way hinge for knees and elbows). Built once at
4
- // attach from the pose the model is in (that pose is the joints' neutral for the limits), and
5
- // switched on and off at will:
6
- //
7
- // const rag = hero.aspect(Ragdoll).get(Ragdoll)! // 'humanoid': the parts by bone name
8
- // rag.activate({ velocity: capsule.controller.velocity }) // drops from its CURRENT pose, keeps its speed
9
- // …
10
- // rag.deactivate() // the animator owns the bones again
11
- //
12
- // While active the engine writes the bodies' poses onto the bones every frame AFTER the animator, so
13
- // the listed bones hide whatever plays underneath; bones NOT listed (fingers, toes, the rest of a
14
- // spine chain between two parts) keep their animated local pose under the ragdolled parents, and
15
- // anything mounted on a bone (a weapon in the hand) rides along. Raycasts hit the parts and report
16
- // the bone node, so a shot knows which limb it struck — `impulse(bone, …)` pushes that part.
17
- //
18
- // The character CONTROLLER is yours to switch off (`node.removeAspect(CharacterController)`): a
19
- // capsule left standing would keep the fallen body from settling. `root` (the hips) tells you where
20
- // the body ended up when it is time to get back up.
21
- //
22
- // A fallen body is scenery, not an obstacle: by default (`collide: 'static'`) only the static world
23
- // and other ragdolls stop it — dynamic bodies, character controllers and vehicles pass through, so it
24
- // never shoves or blocks anything and the solver never pairs it with them — and once every part has
25
- // come to rest (`freeze`, or `freezeAfter` seconds at the latest) the parts turn static where they
26
- // lie: the pose holds, nothing can wake them, the solver skips them. Raycasts hit it either way, and
27
- // a frozen `'all'` body is an obstacle you can stand on.
28
-
29
- import { Aspect } from "../core/Aspect"
30
- import type { FieldMeta } from "../core/fields"
31
- import { cx, cy, cz, type Vec3Like } from "../math/vec"
32
- import { Node } from "./Node"
33
- import { DEFAULT_FRICTION } from "./Physics"
34
-
35
- export interface RagdollHinge {
36
- /** The hinge axis in the MODEL's space (its rest pose: +Z forward, +Y up, +X the model's left in
37
- * glTF). Perpendicular to the bone; the engine drops any component along it. */
38
- axis: Vec3Like
39
- /** The bend range about `axis` in degrees, relative to the pose the ragdoll was built in — a knee
40
- * is `{ axis: [1, 0, 0], min: 0, max: 140 }`: it folds one way only. */
41
- min: number
42
- max: number
43
- }
44
-
45
- export interface RagdollPart {
46
- /** The bone the body sits on — its origin is the joint. */
47
- bone: string
48
- /** The bone the capsule points at (the next joint down). Omitted = a leaf: `length` along the line
49
- * from the parent part's origin (a head, a hand, a foot without toes). */
50
- to?: string
51
- /** The bone of the part this one hangs from (default: the previous part in the list). The root
52
- * has none. */
53
- parent?: string
54
- /** Capsule radius in metres (default 0.05). */
55
- radius?: number
56
- /** Length in metres: a leaf's, or an override of the distance to `to`. */
57
- length?: number
58
- /** This part's share of the ragdoll's `mass` (relative weight, default 1). */
59
- mass?: number
60
- /** Cone half-angle in degrees the part may swing off its parent (default 30). Ignored with `hinge`. */
61
- swing?: number
62
- /** Twist half-angle in degrees about its own axis (default 20). */
63
- twist?: number
64
- /** A one-way joint instead of the cone. */
65
- hinge?: RagdollHinge
66
- }
67
-
68
- const VERSION = 2
69
- const HEADER = 12
70
- const STRIDE = 12
71
-
72
- // Bone names per humanoid role, tried in order: Mixamo / MotusMan / Unity-style first, then UE-style.
73
- // `node.bone()` already matches the part after a `:` / `|` prefix (`mixamorig:Hips`).
74
- const HUMANOID: Record<string, string[]> = {
75
- hips: [ "Hips", "pelvis", "Pelvis", "hip" ],
76
- spine: [ "Spine", "spine_01", "spine", "Spine1" ],
77
- neck: [ "Neck", "neck_01", "neck" ],
78
- head: [ "Head", "head" ],
79
- upperArmL: [ "LeftArm", "upperarm_l", "LeftUpperArm", "UpperArm_L", "L_UpperArm" ],
80
- foreArmL: [ "LeftForeArm", "lowerarm_l", "LeftLowerArm", "LowerArm_L", "L_Forearm" ],
81
- handL: [ "LeftHand", "hand_l", "Hand_L", "L_Hand" ],
82
- upLegL: [ "LeftUpLeg", "thigh_l", "LeftUpperLeg", "UpperLeg_L", "L_Thigh" ],
83
- legL: [ "LeftLeg", "calf_l", "LeftLowerLeg", "LowerLeg_L", "L_Calf" ],
84
- footL: [ "LeftFoot", "foot_l", "Foot_L", "L_Foot" ],
85
- toeL: [ "LeftToeBase", "ball_l", "LeftToe", "Toe_L", "L_Toe" ],
86
- upperArmR: [ "RightArm", "upperarm_r", "RightUpperArm", "UpperArm_R", "R_UpperArm" ],
87
- foreArmR: [ "RightForeArm", "lowerarm_r", "RightLowerArm", "LowerArm_R", "R_Forearm" ],
88
- handR: [ "RightHand", "hand_r", "Hand_R", "R_Hand" ],
89
- upLegR: [ "RightUpLeg", "thigh_r", "RightUpperLeg", "UpperLeg_R", "R_Thigh" ],
90
- legR: [ "RightLeg", "calf_r", "RightLowerLeg", "LowerLeg_R", "R_Calf" ],
91
- footR: [ "RightFoot", "foot_r", "Foot_R", "R_Foot" ],
92
- toeR: [ "RightToeBase", "ball_r", "RightToe", "Toe_R", "R_Toe" ],
93
- }
94
-
95
- /** The standard humanoid layout (hips, one spine capsule up to the neck, head, two-segment arms and
96
- * legs, hands and feet where the rig has them): sizes for a ~1.8 m human, mass shares from body
97
- * segment tables, knees and elbows as hinges that fold the natural way for a model standing in a
98
- * T- or A-pose facing +Z. */
99
- const humanoidParts = (find: (role: string) => string | null): RagdollPart[] => {
100
- const hips = find("hips"), spine = find("spine"), neck = find("neck"), head = find("head")
101
- if (!hips) throw new Error("Ragdoll 'humanoid': no hips bone (Hips / pelvis) on this model — list the parts by name")
102
- const parts: RagdollPart[] = []
103
- parts.push({ bone: hips, to: spine ?? undefined, length: spine ? 0 : 0.2, radius: 0.12, mass: 15 })
104
- const torso = spine ?? hips
105
- if (spine) parts.push({ bone: spine, to: neck ?? head ?? undefined, length: neck || head ? 0 : 0.4, parent: hips, radius: 0.11, mass: 25, swing: 25, twist: 25 })
106
- if (head) parts.push({ bone: head, parent: torso, radius: 0.1, length: 0.2, mass: 6, swing: 35, twist: 45 })
107
- for (const side of [ "L", "R" ] as const) {
108
- const upper = find(`upperArm${side}`), fore = find(`foreArm${side}`), hand = find(`hand${side}`)
109
- if (upper) parts.push({ bone: upper, to: fore ?? undefined, length: fore ? 0 : 0.3, parent: torso, radius: 0.05, mass: 3, swing: 80, twist: 45 })
110
- // the elbow: a T-pose arm along ±X folds its hand forward (+Z) — a negative turn about +Y on the
111
- // left, a positive one on the right
112
- if (upper && fore) parts.push({ bone: fore, to: hand ?? undefined, length: hand ? 0 : 0.28, parent: upper, radius: 0.04, mass: 2, twist: 30, hinge: { axis: [ 0, 1, 0 ], min: side === "L" ? -140 : 0, max: side === "L" ? 0 : 140 } })
113
- if (upper && fore && hand) parts.push({ bone: hand, parent: fore, radius: 0.035, length: 0.16, mass: 1, swing: 30, twist: 20 })
114
- const up = find(`upLeg${side}`), leg = find(`leg${side}`), foot = find(`foot${side}`), toe = find(`toe${side}`)
115
- if (up) parts.push({ bone: up, to: leg ?? undefined, length: leg ? 0 : 0.45, parent: hips, radius: 0.08, mass: 10, swing: 60, twist: 30 })
116
- // the knee: the shin (pointing down) swings its foot backward (−Z) for a positive turn about +X
117
- if (up && leg) parts.push({ bone: leg, to: foot ?? undefined, length: foot ? 0 : 0.42, parent: up, radius: 0.06, mass: 5, twist: 10, hinge: { axis: [ 1, 0, 0 ], min: 0, max: 140 } })
118
- if (up && leg && foot) parts.push({ bone: foot, to: toe ?? undefined, length: toe ? 0 : 0.2, parent: leg, radius: 0.04, mass: 2, swing: 30, twist: 15 })
119
- }
120
- return parts
121
- }
122
-
123
- export class Ragdoll extends Aspect<"ragdoll", Node> {
124
- static readonly aspect = "ragdoll"
125
-
126
- /** The parts: `'humanoid'` (default) finds the standard bones by name; a list places bodies on any
127
- * bones — parents before children. */
128
- parts: RagdollPart[] | "humanoid" = "humanoid"
129
- /** Total mass in kg (default 70), split between the parts by their `mass` shares. */
130
- mass = 70
131
- /** Friction of every part (default 0.6 — the physics default). */
132
- friction = DEFAULT_FRICTION
133
- /** Linear / angular damping of every part; 0 = Jolt's own 0.05. More angular damping settles a
134
- * flailing body sooner. */
135
- linearDamping = 0
136
- angularDamping = 0
137
- /** What the parts collide with. `'static'` (default): the static geometry and other such ragdolls
138
- * only — dynamic bodies, character controllers and vehicles pass through a fallen body, which then
139
- * never shoves or blocks anything and costs nothing when they meet. `'all'`: a regular dynamic
140
- * body that bumps into everything (and gets kicked awake by everything). */
141
- collide: "static" | "all" = "static"
142
- /** Once every part has come to rest the parts turn static where they lie (default true): the pose
143
- * holds, nothing can wake a settled body and it costs the solver nothing — `deactivate()` /
144
- * `activate()` still work (activate makes it dynamic again). */
145
- freeze = true
146
- /** Seconds after `activate()` at which the body freezes whatever it is doing — a twitch on a slope
147
- * or a pile never sleeps on its own. 0 (default) = no cap. */
148
- freezeAfter = 0
149
-
150
- static fields: FieldMeta<Ragdoll> = {
151
- mass: { min: 1, step: 5 },
152
- friction: { min: 0, max: 2, step: 0.05 },
153
- linearDamping: { label: "Linear damping", min: 0, max: 5, step: 0.05 },
154
- angularDamping: { label: "Angular damping", min: 0, max: 5, step: 0.05 },
155
- collide: { options: [ "static", "all" ] },
156
- freezeAfter: { label: "Freeze after (s)", min: 0, max: 60, step: 0.5 },
157
- parts: { hidden: true },
158
- }
159
-
160
- private _id = 0
161
- private _active = false
162
- private _bones: { name: string; node: Node }[] = []
163
-
164
- onAttach(): void {
165
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
166
- if (!_creator.ragdollCreate) {
167
- console.warn("Ragdoll needs a newer host (no ragdollCreate) — the body will not fall")
168
- return
169
- }
170
- const find = (role: string): string | null => {
171
- for (const name of HUMANOID[role] ?? []) if (this.node.bone(name)) return name
172
- return null
173
- }
174
- const parts = this.parts === "humanoid" ? humanoidParts(find) : this.parts
175
- if (parts.length === 0) throw new Error("Ragdoll: no parts")
176
-
177
- const n = parts.length
178
- const bones = new Uint32Array(n * 2)
179
- const s = new Float32Array(HEADER + n * STRIDE)
180
- s[0] = VERSION; s[1] = n; s[2] = STRIDE; s[3] = this.friction; s[4] = this.linearDamping; s[5] = this.angularDamping
181
- s[6] = this.collide === "all" ? 0 : 1; s[7] = this.freeze ? 1 : 0; s[8] = Math.max(0, this.freezeAfter)
182
- let shares = 0
183
- for (const p of parts) shares += p.mass ?? 1
184
- this._bones = []
185
- for (let i = 0; i < n; i++) {
186
- const p = parts[i]!
187
- const bone = this.node.bone(p.bone)
188
- if (!bone) throw new Error(`Ragdoll: no bone '${p.bone}' on '${this.node.name}'`)
189
- const to = p.to ? this.node.bone(p.to) : null
190
- if (p.to && !to) throw new Error(`Ragdoll: no bone '${p.to}' (the 'to' of '${p.bone}') on '${this.node.name}'`)
191
- let parent = -1
192
- if (i > 0) {
193
- parent = p.parent ? parts.findIndex((q, j) => j < i && q.bone === p.parent) : i - 1
194
- if (parent < 0) throw new Error(`Ragdoll: the parent '${p.parent}' of '${p.bone}' must be listed before it`)
195
- }
196
- bones[i * 2] = bone.id
197
- bones[i * 2 + 1] = to ? to.id : 0
198
- const o = HEADER + i * STRIDE
199
- s[o] = parent
200
- s[o + 1] = p.radius ?? 0.05
201
- s[o + 2] = p.length ?? 0
202
- s[o + 3] = this.mass * (p.mass ?? 1) / shares
203
- s[o + 4] = p.swing ?? 30
204
- s[o + 5] = p.twist ?? 20
205
- if (p.hinge) {
206
- s[o + 6] = 1
207
- s[o + 7] = cx(p.hinge.axis); s[o + 8] = cy(p.hinge.axis); s[o + 9] = cz(p.hinge.axis)
208
- s[o + 10] = p.hinge.min; s[o + 11] = p.hinge.max
209
- }
210
- this._bones.push({ name: p.bone, node: bone })
211
- }
212
- this._id = _creator.ragdollCreate(this.node.id, bones, s)
213
- if (!this._id) console.warn(`Ragdoll: the host refused the parts on '${this.node.name}'`)
214
- }
215
-
216
- onDetach(): void {
217
- if (this._id) {
218
- if (this._active) _creator.ragdollDeactivate!(this._id)
219
- _creator.ragdollDestroy!(this._id)
220
- this._id = 0
221
- }
222
- this._active = false
223
- this._bones = []
224
- }
225
-
226
- /** Hand the bones to physics from the pose they are in right now. `velocity` (world m/s) is given
227
- * to every part — pass the character's, so a running body keeps travelling. */
228
- activate(opts: { velocity?: Vec3Like } = {}): boolean {
229
- if (!this._id) return false
230
- const v = opts.velocity ?? [ 0, 0, 0 ]
231
- this._active = _creator.ragdollActivate!(this._id, cx(v), cy(v), cz(v))
232
- return this._active
233
- }
234
-
235
- /** Take the bones back: the animator's pose shows again from the next frame. */
236
- deactivate(): void {
237
- if (!this._id || !this._active) return
238
- _creator.ragdollDeactivate!(this._id)
239
- this._active = false
240
- }
241
-
242
- /** Physics owns the bones right now. */
243
- get active(): boolean { return this._active }
244
-
245
- /** Active and every part asleep (or frozen) — the body has come to rest. */
246
- get settled(): boolean {
247
- return this._active && !!this._id && !_creator.ragdollActive!(this._id)
248
- }
249
-
250
- /** The root part's bone (the hips): where the body is. */
251
- get root(): Node | null { return this._bones[0]?.node ?? null }
252
-
253
- /** The bones that carry a part, in order. */
254
- get bones(): string[] { return this._bones.map((b) => b.name) }
255
-
256
- /** The rigid-body id of a part (0 if none) — for the plain body calls. */
257
- bodyOf(bone: string): number {
258
- const i = this._bones.findIndex((b) => b.name === bone)
259
- return i < 0 || !this._id ? 0 : _creator.ragdollBodyId!(this._id, i)
260
- }
261
-
262
- /** Push one part: an impulse in N·s, at a world point (a hit) or through its centre. Wakes the body. */
263
- impulse(bone: string, v: Vec3Like, at?: Vec3Like): boolean {
264
- const id = this.bodyOf(bone)
265
- if (!id) return false
266
- if (at && _creator.physicsApplyImpulseAt) _creator.physicsApplyImpulseAt(id, cx(v), cy(v), cz(v), cx(at), cy(at), cz(at))
267
- else _creator.physicsApplyImpulse(id, cx(v), cy(v), cz(v))
268
- return true
269
- }
270
- }