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.
- package/README.md +1 -1
- package/dist/index.js +2013 -564
- package/package.json +4 -4
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/core/Aspect.ts +512 -255
- package/runtime/sdk/core/compWrite.ts +42 -0
- package/runtime/sdk/core/fields.ts +1 -1
- package/runtime/sdk/core/time.ts +81 -0
- package/runtime/sdk/g2/Camera2D.ts +8 -1
- package/runtime/sdk/g2/CharacterController2D.ts +253 -53
- package/runtime/sdk/g2/Node2D.ts +80 -10
- package/runtime/sdk/g2/OneWay2D.ts +66 -0
- package/runtime/sdk/g2/Physics2D.ts +240 -30
- package/runtime/sdk/g2/Scene2D.ts +33 -1
- package/runtime/sdk/g2/Shape2D.ts +218 -22
- package/runtime/sdk/g2/Trigger2D.ts +42 -12
- package/runtime/sdk/g2/groups2d.ts +106 -0
- package/runtime/sdk/g2/loop.ts +15 -4
- package/runtime/sdk/gl/Camera.ts +40 -1
- package/runtime/sdk/gl/CameraPlace.ts +52 -51
- package/runtime/sdk/gl/CharacterController.ts +184 -56
- package/runtime/sdk/gl/Gearbox.ts +212 -0
- package/runtime/sdk/gl/Geometry.ts +70 -9
- package/runtime/sdk/gl/Light.ts +64 -2
- package/runtime/sdk/gl/Lightmap.ts +179 -0
- package/runtime/sdk/gl/Material.ts +25 -0
- package/runtime/sdk/gl/Mesh.ts +6 -23
- package/runtime/sdk/gl/Model.ts +16 -2
- package/runtime/sdk/gl/Node.ts +119 -39
- package/runtime/sdk/gl/Physics.ts +75 -24
- package/runtime/sdk/gl/Scene.ts +161 -5
- package/runtime/sdk/gl/Shape.ts +42 -3
- package/runtime/sdk/gl/Trigger.ts +45 -50
- package/runtime/sdk/gl/Vehicle.ts +276 -322
- package/runtime/sdk/gl/Wheel.ts +240 -0
- package/runtime/sdk/gl/scenarios.ts +4 -30
- package/runtime/sdk/gl/state.ts +6 -6
- package/runtime/sdk/inject.ts +186 -171
- package/runtime/sdk/runtime/device.ts +102 -1
- package/runtime/sdk/scene/defineScene.ts +108 -53
- package/runtime/sdk/scene/gizmos.ts +31 -11
- package/runtime/sdk/scene/material.ts +188 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +0 -42
- package/runtime/sdk/compile/assetIconMacro.ts +0 -384
- package/runtime/sdk/compile/assetMacro.ts +0 -45
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -252
- package/runtime/sdk/compile/compileProject.ts +0 -129
- package/runtime/sdk/compile/detectEntry.ts +0 -128
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -67
- package/runtime/sdk/compile/index.ts +0 -85
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/liteMaterial.ts +0 -247
- package/runtime/sdk/compile/sceneEditor.ts +0 -78
- package/runtime/sdk/compile/serverSplit.ts +0 -233
- package/runtime/sdk/compile/serverTypes.ts +0 -227
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/shaderTargets.ts +0 -42
- package/runtime/sdk/compile/sourcemap.ts +0 -25
|
@@ -1,51 +1,52 @@
|
|
|
1
|
-
// CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
|
|
2
|
-
// a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
|
|
3
|
-
// side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
|
|
4
|
-
// empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
|
|
5
|
-
//
|
|
6
|
-
// • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
|
|
7
|
-
// `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
|
|
8
|
-
// • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
|
|
9
|
-
// owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
|
|
10
|
-
// or anchors on it (`inst.alignTo(inst.nodes.eye)`).
|
|
11
|
-
// • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
|
|
12
|
-
// releases.
|
|
13
|
-
// • Edit mode: draws
|
|
14
|
-
// See docs/scene-camera-place-plan.md.
|
|
15
|
-
|
|
16
|
-
import { Aspect } from "../core/Aspect"
|
|
17
|
-
import type { FieldMeta } from "../core/fields"
|
|
18
|
-
import { Gizmos } from "../scene/gizmos"
|
|
19
|
-
import type { Camera } from "./Camera"
|
|
20
|
-
import type { Node } from "./Node"
|
|
21
|
-
|
|
22
|
-
export class CameraPlace extends Aspect<"cameraPlace", Node> {
|
|
23
|
-
static readonly aspect = "cameraPlace"
|
|
24
|
-
/** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
|
|
25
|
-
fov = 60
|
|
26
|
-
/** Near clip distance. */
|
|
27
|
-
near = 0.01
|
|
28
|
-
/** Far clip distance = view range. */
|
|
29
|
-
far = 1000
|
|
30
|
-
/** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
|
|
31
|
-
* ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
|
|
32
|
-
active = false
|
|
33
|
-
/** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
|
|
34
|
-
static fields: FieldMeta<CameraPlace> = {
|
|
35
|
-
fov: { label: "FOV", min: 1, max: 179, step: 1 },
|
|
36
|
-
near: { min: 0.001, max: 10, step: 0.01 },
|
|
37
|
-
far: { min: 1, max: 100000, step: 1 },
|
|
38
|
-
active: { label: "Active camera" },
|
|
39
|
-
}
|
|
40
|
-
static editor = { rebuild: true }
|
|
41
|
-
|
|
42
|
-
/** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
|
|
43
|
-
applyTo(camera: Camera): void {
|
|
44
|
-
camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Edit mode: the frustum marker (−Z = view)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
1
|
+
// CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
|
|
2
|
+
// a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
|
|
3
|
+
// side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
|
|
4
|
+
// empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
|
|
5
|
+
//
|
|
6
|
+
// • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
|
|
7
|
+
// `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
|
|
8
|
+
// • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
|
|
9
|
+
// owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
|
|
10
|
+
// or anchors on it (`inst.alignTo(inst.nodes.eye)`).
|
|
11
|
+
// • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
|
|
12
|
+
// releases.
|
|
13
|
+
// • Edit mode: draws an anchored frustum gizmo (the editor's camera preview reads the same place).
|
|
14
|
+
// See docs/scene-camera-place-plan.md.
|
|
15
|
+
|
|
16
|
+
import { Aspect } from "../core/Aspect"
|
|
17
|
+
import type { FieldMeta } from "../core/fields"
|
|
18
|
+
import { Gizmos } from "../scene/gizmos"
|
|
19
|
+
import type { Camera } from "./Camera"
|
|
20
|
+
import type { Node } from "./Node"
|
|
21
|
+
|
|
22
|
+
export class CameraPlace extends Aspect<"cameraPlace", Node> {
|
|
23
|
+
static readonly aspect = "cameraPlace"
|
|
24
|
+
/** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
|
|
25
|
+
fov = 60
|
|
26
|
+
/** Near clip distance. */
|
|
27
|
+
near = 0.01
|
|
28
|
+
/** Far clip distance = view range. */
|
|
29
|
+
far = 1000
|
|
30
|
+
/** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
|
|
31
|
+
* ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
|
|
32
|
+
active = false
|
|
33
|
+
/** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
|
|
34
|
+
static fields: FieldMeta<CameraPlace> = {
|
|
35
|
+
fov: { label: "FOV", min: 1, max: 179, step: 1 },
|
|
36
|
+
near: { min: 0.001, max: 10, step: 0.01 },
|
|
37
|
+
far: { min: 1, max: 100000, step: 1 },
|
|
38
|
+
active: { label: "Active camera" },
|
|
39
|
+
}
|
|
40
|
+
static editor = { rebuild: true }
|
|
41
|
+
|
|
42
|
+
/** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
|
|
43
|
+
applyTo(camera: Camera): void {
|
|
44
|
+
camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Edit mode: the frustum marker (−Z = view), anchored on the node so it follows drags live and
|
|
48
|
+
* clicking it selects the node. Play mode never calls this. */
|
|
49
|
+
rebuild(): void {
|
|
50
|
+
Gizmos.frustum(this.fov, { node: this.node, color: this.active ? "#ffffff" : "#9aa3b2" })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -4,45 +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
|
|
8
|
-
// setLoop(() => hero.controller.move((Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0),
|
|
9
|
-
//
|
|
10
|
-
// Input.on('keydown', e => { if (e.code === 'Space'
|
|
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 })
|
|
11
11
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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.
|
|
16
40
|
|
|
17
41
|
import { Aspect } from "../core/Aspect"
|
|
18
|
-
import {
|
|
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"
|
|
19
45
|
import { Shape } from "./Shape"
|
|
20
46
|
import type { Node } from "./Node"
|
|
21
47
|
|
|
22
|
-
|
|
48
|
+
/** Where the character's feet are, as reported by the solver after the last step. */
|
|
49
|
+
export type GroundState = "ground" | "slope" | "unsupported" | "air"
|
|
23
50
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
26
56
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/** Jump take-off speed (world units/s). */
|
|
30
|
-
jumpSpeed = 7
|
|
31
|
-
/** Character-tuned gravity (world units/s²), separate from the world gravity — usually stronger for
|
|
32
|
-
* snappier feel. Applied to the vertical velocity each frame. */
|
|
33
|
-
gravity = -20
|
|
34
|
-
/** Max ground slope (degrees) the character treats as walkable. Set before attach. */
|
|
35
|
-
maxSlope = 45
|
|
57
|
+
export class CharacterController extends Aspect<"controller", Node> implements CompWriter {
|
|
58
|
+
static readonly aspect = "controller"
|
|
36
59
|
|
|
37
|
-
|
|
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
|
+
}
|
|
38
67
|
|
|
39
68
|
private _charId = 0
|
|
40
|
-
private
|
|
41
|
-
private
|
|
42
|
-
private _jumpQueued = false
|
|
43
|
-
private _vy = 0
|
|
44
|
-
private _ex = 0 // external horizontal velocity (world units/s) — root motion from an Animator
|
|
45
|
-
private _ez = 0
|
|
69
|
+
private _gravityScale = 2
|
|
70
|
+
private _maxSlope = 45
|
|
46
71
|
|
|
47
72
|
onAttach(): void {
|
|
48
73
|
if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
|
|
@@ -54,57 +79,160 @@ export class CharacterController extends Aspect<"controller", Node> {
|
|
|
54
79
|
throw new Error("CharacterController needs a convex Shape (capsule recommended) — { mesh: true } is a triangle mesh; use { mesh: 'convex' } or a capsule")
|
|
55
80
|
}
|
|
56
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.
|
|
57
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
|
|
58
91
|
}
|
|
59
92
|
|
|
60
93
|
onDetach(): void {
|
|
61
94
|
if (this._charId) { _creator.characterDestroy(this._charId); this._charId = 0 }
|
|
62
|
-
this.node.
|
|
95
|
+
this.node._xf = 0
|
|
96
|
+
this.node._xfKind = 0
|
|
97
|
+
this.node.get(Shape)?._recreatePickBody() // hands the routing back to the pick body
|
|
63
98
|
}
|
|
64
99
|
|
|
65
100
|
/** Native character id (0 until attached / no physics support). */
|
|
66
101
|
get id(): number { return this._charId }
|
|
67
102
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
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
|
+
}
|
|
70
129
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
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
|
+
}
|
|
73
142
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
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
|
+
}
|
|
76
151
|
|
|
77
|
-
/**
|
|
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. */
|
|
78
176
|
get grounded(): boolean {
|
|
79
177
|
return this._charId ? _creator.characterGetGroundState(this._charId) === 0 : false
|
|
80
178
|
}
|
|
81
179
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
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"
|
|
85
184
|
}
|
|
86
185
|
|
|
87
|
-
/**
|
|
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
|
+
*/
|
|
88
215
|
get velocity(): Vec3 {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return new Vec3(
|
|
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])
|
|
92
219
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
teleport(x: number, y: number, z: number): this {
|
|
96
|
-
if (this._charId) _creator.characterSetPosition(this._charId, x, y, z)
|
|
97
|
-
this._vy = 0
|
|
98
|
-
return this
|
|
220
|
+
set velocity(v: Vec3Like) {
|
|
221
|
+
if (this._charId) _creator.characterSetVelocity(this._charId, cx(v), cy(v), cz(v))
|
|
99
222
|
}
|
|
100
223
|
|
|
101
|
-
|
|
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 {
|
|
102
231
|
if (!this._charId) return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
_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])
|
|
109
237
|
}
|
|
110
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
|
+
}
|