lecodes-cli 0.2.0 → 0.5.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.
- package/README.md +3 -0
- package/dist/index.js +883 -507
- package/package.json +9 -4
- package/runtime/sdk/canvas/Canvas.ts +270 -0
- package/runtime/sdk/compile/aspectMacro.ts +42 -0
- package/runtime/sdk/compile/assetMacro.ts +44 -0
- package/runtime/sdk/compile/bundler.ts +173 -0
- package/runtime/sdk/compile/compileProject.ts +84 -0
- package/runtime/sdk/compile/header.ts +46 -0
- package/runtime/sdk/compile/index.ts +12 -0
- package/runtime/sdk/compile/sourcemap.ts +25 -0
- package/runtime/sdk/core/Aspect.ts +82 -26
- package/runtime/sdk/core/signals.ts +277 -0
- package/runtime/sdk/g2/CharacterController2D.ts +5 -4
- package/runtime/sdk/g2/Node2D.ts +1 -1
- package/runtime/sdk/g2/Physics2D.ts +22 -12
- package/runtime/sdk/g2/Scene2D.ts +30 -0
- package/runtime/sdk/g2/{Shape.ts → Shape2D.ts} +10 -10
- package/runtime/sdk/g2/Sprite.ts +16 -6
- package/runtime/sdk/g2/Texture2D.ts +12 -2
- package/runtime/sdk/g2/{Trigger.ts → Trigger2D.ts} +9 -9
- package/runtime/sdk/gl/CharacterController.ts +101 -0
- package/runtime/sdk/gl/Material.ts +10 -3
- package/runtime/sdk/gl/Node.ts +11 -2
- package/runtime/sdk/gl/Physics.ts +65 -41
- package/runtime/sdk/gl/Scene.ts +18 -0
- package/runtime/sdk/gl/Shape.ts +98 -0
- package/runtime/sdk/gl/Texture.ts +13 -2
- package/runtime/sdk/gl/Trigger.ts +46 -0
- package/runtime/sdk/gl/physicsEvents.ts +20 -0
- package/runtime/sdk/inject.ts +14 -3
- package/runtime/sdk/runtime/camera.ts +31 -0
- package/runtime/sdk/runtime/device.ts +26 -0
- package/runtime/sdk/runtime/share.ts +9 -0
- package/runtime/sdk/ui/UI.ts +5 -1
- package/runtime/sdk/ui/UIButton.ts +11 -5
- package/runtime/sdk/ui/UIContainer.ts +28 -13
- package/runtime/sdk/ui/UIImage.ts +31 -11
- package/runtime/sdk/ui/UIInput.ts +10 -0
- package/runtime/sdk/ui/UINode.ts +206 -8
- package/runtime/sdk/ui/UIScreen.ts +17 -7
- package/runtime/sdk/ui/UIScrollable.ts +10 -5
- package/runtime/sdk/ui/UISpacer.ts +4 -0
- package/runtime/sdk/ui/UIText.ts +21 -5
- package/runtime/sdk/ui/UIVideo.ts +5 -0
- package/runtime/sdk/ui/UIVirtualizedList.ts +5 -0
- package/runtime/sdk/ui/UIWidget.ts +10 -5
- package/runtime/sdk/gl/Collider.ts +0 -29
|
@@ -2,15 +2,38 @@
|
|
|
2
2
|
// 2D engine and starts its RAF loop; only the active scene renders. Grown from the Phase 0 seed.
|
|
3
3
|
|
|
4
4
|
import { Color, type ColorInput } from "../core/color"
|
|
5
|
+
import { _installAspectFrames } from "../core/Aspect"
|
|
5
6
|
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
6
7
|
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
7
8
|
import { Camera2D } from "./Camera2D"
|
|
8
9
|
import { node2dRegistry, type Node2D } from "./Node2D"
|
|
9
10
|
import { ensurePointerEvents } from "./touch"
|
|
10
11
|
|
|
12
|
+
// Route aspect update(dt) through the render-synced native phases (c2dRender early = before physics,
|
|
13
|
+
// late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
|
|
14
|
+
// the 2D layer (not core) so a 3D-only bundle never pulls in the 2D bridge. Installed once, lazily on
|
|
15
|
+
// the first Scene2D. On an older wasm without the hooks the installer returns false → setLoop fallback.
|
|
16
|
+
let framesInstalled = false
|
|
17
|
+
const installRenderSyncedFrames = (): void => {
|
|
18
|
+
if (framesInstalled) return
|
|
19
|
+
framesInstalled = true
|
|
20
|
+
_installAspectFrames((early, late) => {
|
|
21
|
+
if (typeof _creator2d.setEarlyUpdate !== "function") return false
|
|
22
|
+
_creator2d.setEarlyUpdate(early)
|
|
23
|
+
_creator2d.setLateUpdate(late)
|
|
24
|
+
return true
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
export type Scene2DOptions = {
|
|
12
29
|
/** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
|
|
13
30
|
background?: ColorInput
|
|
31
|
+
/** Texture sampling for the 2D renderer: `'nearest'` (default — crisp pixel art) or `'linear'`
|
|
32
|
+
* (smooth — for hi-res / non-pixel-art assets). Applied when the scene is opened, so switching
|
|
33
|
+
* scenes restores the active scene's choice. NOTE: this is a GLOBAL renderer setting (one sampler
|
|
34
|
+
* for ALL textures, not per-texture), so the currently-open scene's filter wins. Omit to leave the
|
|
35
|
+
* current setting (engine default is nearest). */
|
|
36
|
+
filter?: 'nearest' | 'linear'
|
|
14
37
|
}
|
|
15
38
|
|
|
16
39
|
/** Returned by scene2d.layer(n); chain .ySort() to enable top-down depth sorting for that layer. */
|
|
@@ -37,9 +60,14 @@ export class Scene2D {
|
|
|
37
60
|
readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
|
|
38
61
|
readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
|
|
39
62
|
|
|
63
|
+
/** Texture filter to (re)assert on open(); the sampler is global, so each scene reclaims it. */
|
|
64
|
+
private readonly _filter?: 'nearest' | 'linear'
|
|
65
|
+
|
|
40
66
|
constructor(options: Scene2DOptions = {}) {
|
|
67
|
+
installRenderSyncedFrames()
|
|
41
68
|
this.id = _creator2d.createScene()
|
|
42
69
|
this.camera = new Camera2D(this.id)
|
|
70
|
+
this._filter = options.filter
|
|
43
71
|
if (options.background !== undefined) this.background = options.background
|
|
44
72
|
}
|
|
45
73
|
|
|
@@ -85,6 +113,8 @@ export class Scene2D {
|
|
|
85
113
|
|
|
86
114
|
/** Make this the active scene (brings up the 2D engine on the canvas; closes any other). */
|
|
87
115
|
open(): this {
|
|
116
|
+
// Reassert this scene's texture filter (the sampler is global — see Scene2DOptions.filter).
|
|
117
|
+
if (this._filter !== undefined) _creator2d.setDefaultFilter(this._filter === 'linear')
|
|
88
118
|
_creator2d.openScene(this.id)
|
|
89
119
|
Scene2D._active = this
|
|
90
120
|
return this
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
// A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a
|
|
2
|
-
// make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a
|
|
3
|
-
// of which require a
|
|
4
|
-
// picking hits, so a node is clickable once it has a
|
|
1
|
+
// A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a Shape2D does not by itself
|
|
2
|
+
// make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a Trigger2D (sensor), both
|
|
3
|
+
// of which require a Shape2D and turn its geometry into a Box2D fixture. The shape is also what pointer
|
|
4
|
+
// picking hits, so a node is clickable once it has a Shape2D + Physics2D/Trigger2D (a body to query).
|
|
5
5
|
//
|
|
6
|
-
// wall.aspect(
|
|
7
|
-
// goal.aspect(
|
|
8
|
-
// hero.aspect(
|
|
6
|
+
// wall.aspect(Shape2D, { box: [200, 16] }).aspect(Physics2D, { motion: 'static' })
|
|
7
|
+
// goal.aspect(Shape2D, { circle: 20 }).aspect(Trigger2D)
|
|
8
|
+
// hero.aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } }).aspect(Physics2D, { motion: 'dynamic' })
|
|
9
9
|
//
|
|
10
|
-
// With no geometry set,
|
|
10
|
+
// With no geometry set, Shape2D derives a box from the node's sprite size ("auto").
|
|
11
11
|
|
|
12
12
|
import { Aspect } from "../core/Aspect"
|
|
13
13
|
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
|
|
14
14
|
import type { Node2D } from "./Node2D"
|
|
15
15
|
|
|
16
|
-
export class
|
|
16
|
+
export class Shape2D extends Aspect<"shape", Node2D> {
|
|
17
17
|
static readonly aspect = "shape"
|
|
18
18
|
|
|
19
19
|
/** Box half-extents [hw, hh] (world units). */
|
|
@@ -30,7 +30,7 @@ export class Shape extends Aspect<"shape", Node2D> {
|
|
|
30
30
|
offset?: Vec2Like
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/
|
|
33
|
+
* @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/Trigger2D).
|
|
34
34
|
* `sensor` selects a sensor (trigger) vs solid fixture; density/friction/restitution are the
|
|
35
35
|
* material (ignored for sensors / segments only use friction+restitution).
|
|
36
36
|
*/
|
package/runtime/sdk/g2/Sprite.ts
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { Color, type ColorInput } from "../core/color"
|
|
6
6
|
import { Vec2, type Vec2Like } from "../math/vec"
|
|
7
|
+
import { Canvas, isCanvas } from "../canvas/Canvas"
|
|
7
8
|
import { Node2D } from "./Node2D"
|
|
8
9
|
import type { Texture2D } from "./Texture2D"
|
|
9
10
|
|
|
11
|
+
/** Anything that can back a sprite: a texture, or a Canvas (baked to a texture on assign). */
|
|
12
|
+
export type TextureSource = Texture2D | Canvas
|
|
13
|
+
|
|
10
14
|
export type SpriteOptions = {
|
|
11
|
-
texture?:
|
|
15
|
+
texture?: TextureSource
|
|
12
16
|
/** Pivot, [0,0] = top-left .. [1,1] = bottom-right. Use [0.5, 1] (feet) for Y-sorted depth. */
|
|
13
17
|
anchor?: Vec2Like
|
|
14
18
|
/** World size; defaults to the texture's pixel dimensions. */
|
|
@@ -44,14 +48,20 @@ export class Sprite extends Node2D {
|
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
get texture(): Texture2D | undefined { return this._texture }
|
|
47
|
-
set texture(t:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
set texture(t: TextureSource | undefined) {
|
|
52
|
+
const tex = isCanvas(t) ? t.texture() : t // a Canvas bakes to a texture on assign
|
|
53
|
+
this._texture = tex
|
|
54
|
+
if (!tex) return
|
|
55
|
+
_creator2d.setSprite(this.id, tex.id) // native resets UV + size to the full (pixel) texture
|
|
56
|
+
// A Canvas is authored in LOGICAL units but baked at logical × pixelRatio, so default the sprite's
|
|
57
|
+
// WORLD size to the canvas's LOGICAL size — pixelRatio then only affects crispness, not on-screen
|
|
58
|
+
// size. (A plain texture keeps the native default: the texture's pixel dimensions.)
|
|
59
|
+
if (!this._size && isCanvas(t)) this._size = new Vec2(t.width, t.height)
|
|
51
60
|
if (this._size) _creator2d.setSpriteSize(this.id, this._size.x, this._size.y)
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
/** Current world size: explicit override if set, else the texture's pixel dimensions
|
|
63
|
+
/** Current world size: explicit override if set, else the texture's pixel dimensions (a Canvas-backed
|
|
64
|
+
* sprite defaults to the canvas's LOGICAL size, so pixelRatio is crispness only, not size). */
|
|
55
65
|
get size(): Vec2 { return this._size ?? (this._texture ? new Vec2(this._texture.width, this._texture.height) : new Vec2(0, 0)) }
|
|
56
66
|
set size(v: Vec2Like) { this._size = new Vec2(v); _creator2d.setSpriteSize(this.id, this._size.x, this._size.y) }
|
|
57
67
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
// Internal SDK code imports injected globals explicitly (the bare-global inject is only for USER
|
|
5
5
|
// code; here `fetch` would otherwise resolve to the node/bun ambient one).
|
|
6
|
-
import { fetch, type FetchResponse } from "../runtime/fetch"
|
|
6
|
+
import { fetch, type FetchResponse, type File } from "../runtime/fetch"
|
|
7
7
|
|
|
8
8
|
export class Texture2D {
|
|
9
9
|
/** Native texture handle. */
|
|
@@ -17,7 +17,17 @@ export class Texture2D {
|
|
|
17
17
|
this.height = height
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// Wrap a Canvas's baked surface as a texture (RGBA8, already rasterized → synchronous, no decode).
|
|
21
|
+
// Structural type avoids a Canvas import cycle (Canvas imports Texture2D).
|
|
22
|
+
static fromCanvas(canvas: { _bakeSurface(): number, width: number, height: number, pixelRatio: number }): Texture2D {
|
|
23
|
+
const surfaceId = canvas._bakeSurface()
|
|
24
|
+
const texId = _creator2d.createTextureFromCanvas(surfaceId)
|
|
25
|
+
return new Texture2D(texId, Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Accepts a URL, an already-fetched response, or a File (e.g. from Canvas.toFile() / openFilePicker) —
|
|
29
|
+
// anything backed by a host buffer. The buffer's encoded bytes are decoded + uploaded by the engine.
|
|
30
|
+
static load(source: string | FetchResponse | File): Promise<Texture2D> {
|
|
21
31
|
if (typeof source === "string") {
|
|
22
32
|
return fetch(source, { useOnce: true }).then((resp) => {
|
|
23
33
|
if (resp.status >= 400) {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// A trigger zone, as an aspect on a Node2D. Requires a
|
|
1
|
+
// A trigger zone, as an aspect on a Node2D. Requires a Shape2D (its geometry); creates a static SENSOR
|
|
2
2
|
// body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
|
|
3
3
|
// instead of colliding. A trigger does not block movement.
|
|
4
4
|
//
|
|
5
5
|
// const goal = new Node2D({ ... })
|
|
6
|
-
// .aspect(
|
|
7
|
-
// .aspect(
|
|
6
|
+
// .aspect(Shape2D, { box: [32, 64] })
|
|
7
|
+
// .aspect(Trigger2D)
|
|
8
8
|
// goal.addEventListener('enter', other => win(other))
|
|
9
9
|
// goal.addEventListener('exit', other => ...)
|
|
10
10
|
//
|
|
@@ -13,21 +13,21 @@
|
|
|
13
13
|
import { Aspect } from "../core/Aspect"
|
|
14
14
|
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
15
15
|
import { ensurePhysicsEvents } from "./loop"
|
|
16
|
-
import {
|
|
16
|
+
import { Shape2D } from "./Shape2D"
|
|
17
17
|
import type { Node2D } from "./Node2D"
|
|
18
18
|
|
|
19
|
-
export class
|
|
19
|
+
export class Trigger2D extends Aspect<"trigger", Node2D> {
|
|
20
20
|
static readonly aspect = "trigger"
|
|
21
21
|
|
|
22
22
|
private _h = 0
|
|
23
|
-
/** Native body
|
|
24
|
-
get
|
|
23
|
+
/** Native physics body id (0 if no physics support). Mirrors 3D `Trigger.id`. */
|
|
24
|
+
get id(): number { return this._h }
|
|
25
25
|
|
|
26
26
|
onAttach(): void {
|
|
27
27
|
if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
|
|
28
|
-
const shape = this.node.get(
|
|
28
|
+
const shape = this.node.get(Shape2D)
|
|
29
29
|
if (!shape) {
|
|
30
|
-
throw new Error("
|
|
30
|
+
throw new Error("Trigger2D requires a Shape2D aspect — add it first: node.aspect(Shape2D, {…}).aspect(Trigger2D)")
|
|
31
31
|
}
|
|
32
32
|
this._h = _creator2d.physicsCreateBody(this.node.id, 0 /* static */)
|
|
33
33
|
if (!this._h) return
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// A character controller, as an aspect on a 3D Node, backed by Jolt's CharacterVirtual — a kinematic
|
|
2
|
+
// collide-and-slide controller (NOT a rigid body: no bouncing/tipping, crisp control, built-in stair
|
|
3
|
+
// stepping + slope handling). Requires a Shape (a capsule is recommended) and does NOT use Physics.
|
|
4
|
+
//
|
|
5
|
+
// const hero = new Mesh(capsuleGeometry(), material)
|
|
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())
|
|
10
|
+
//
|
|
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.
|
|
15
|
+
|
|
16
|
+
import { Aspect } from "../core/Aspect"
|
|
17
|
+
import { Vec3 } from "../math/vec"
|
|
18
|
+
import { Shape } from "./Shape"
|
|
19
|
+
import type { Node } from "./Node"
|
|
20
|
+
|
|
21
|
+
const clamp1 = (v: number): number => (v < -1 ? -1 : v > 1 ? 1 : v)
|
|
22
|
+
|
|
23
|
+
export class CharacterController extends Aspect<"controller", Node> {
|
|
24
|
+
static readonly aspect = "controller"
|
|
25
|
+
|
|
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
|
|
35
|
+
|
|
36
|
+
updateBeforePhysics = true // EARLY phase — set velocity so THIS frame's step consumes it
|
|
37
|
+
|
|
38
|
+
private _charId = 0
|
|
39
|
+
private _mx = 0
|
|
40
|
+
private _mz = 0
|
|
41
|
+
private _jumpQueued = false
|
|
42
|
+
private _vy = 0
|
|
43
|
+
|
|
44
|
+
onAttach(): void {
|
|
45
|
+
if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
|
|
46
|
+
const shape = this.node.get(Shape)
|
|
47
|
+
if (!shape) {
|
|
48
|
+
throw new Error("CharacterController requires a Shape aspect (a capsule is recommended) — add it first: node.aspect(Shape, { capsule: {…} }).aspect(CharacterController)")
|
|
49
|
+
}
|
|
50
|
+
const shapeId = shape._claim() // the character owns the shape; it is not a rigid body
|
|
51
|
+
this._charId = _creator.characterCreate(this.node.id, shapeId, this.maxSlope)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onDetach(): void {
|
|
55
|
+
if (this._charId) { _creator.characterDestroy(this._charId); this._charId = 0 }
|
|
56
|
+
this.node.get(Shape)?._recreatePickBody()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Native character id (0 until attached / no physics support). */
|
|
60
|
+
get id(): number { return this._charId }
|
|
61
|
+
|
|
62
|
+
/** Horizontal move intent; each component in [-1, 1] (e.g. from input). Sticky — set 0 to stop. */
|
|
63
|
+
move(x: number, z: number): void { this._mx = clamp1(x); this._mz = clamp1(z) }
|
|
64
|
+
|
|
65
|
+
/** Queue a jump; consumed on the next frame if grounded. */
|
|
66
|
+
jump(): void { this._jumpQueued = true }
|
|
67
|
+
|
|
68
|
+
/** True while standing on walkable ground (OnGround). */
|
|
69
|
+
get grounded(): boolean {
|
|
70
|
+
return this._charId ? _creator.characterGetGroundState(this._charId) === 0 : false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Ground state: 0 on-ground, 1 on-steep-slope, 2 touching-but-unsupported, 3 in-air. */
|
|
74
|
+
get groundState(): number {
|
|
75
|
+
return this._charId ? _creator.characterGetGroundState(this._charId) : 3
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Current velocity (world units/s, fresh Vec3). */
|
|
79
|
+
get velocity(): Vec3 {
|
|
80
|
+
const o = new Float32Array(3)
|
|
81
|
+
if (this._charId) _creator.characterGetVelocity(this._charId, o)
|
|
82
|
+
return new Vec3(o[0], o[1], o[2])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Teleport to a world position (clears vertical velocity). */
|
|
86
|
+
teleport(x: number, y: number, z: number): this {
|
|
87
|
+
if (this._charId) _creator.characterSetPosition(this._charId, x, y, z)
|
|
88
|
+
this._vy = 0
|
|
89
|
+
return this
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
update(dt: number): void {
|
|
93
|
+
if (!this._charId) return
|
|
94
|
+
const grounded = _creator.characterGetGroundState(this._charId) === 0
|
|
95
|
+
if (grounded && this._vy < 0) this._vy = 0 // landed
|
|
96
|
+
if (this._jumpQueued && grounded) this._vy = this.jumpSpeed
|
|
97
|
+
this._jumpQueued = false
|
|
98
|
+
this._vy += this.gravity * dt // integrate gravity (CharacterVirtual is kinematic)
|
|
99
|
+
_creator.characterSetVelocity(this._charId, this._mx * this.speed, this._vy, this._mz * this.speed)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
import { Color, type ColorInput } from "../core/color"
|
|
7
7
|
import { fetch, type FetchResponse } from "../runtime/fetch"
|
|
8
8
|
import { Texture } from "./Texture"
|
|
9
|
+
// Type-only (erased at build) so a 3D material never pulls the 2D-canvas runtime; the guard is inline.
|
|
10
|
+
import type { Canvas } from "../canvas/Canvas"
|
|
11
|
+
|
|
12
|
+
/** Duck-type a Canvas without importing it (keeps 3D material free of 2D-canvas code). */
|
|
13
|
+
const isCanvasSource = (v: unknown): v is Canvas =>
|
|
14
|
+
typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true
|
|
9
15
|
|
|
10
16
|
type UniformValue = string | Texture | number | boolean | number[] | Float32Array | null
|
|
11
17
|
|
|
@@ -28,7 +34,7 @@ const applyUniform = (id: number, key: string, value: UniformValue): void => {
|
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
|
|
31
|
-
export type MaterialColorOptions = { color?: ColorInput, map?: Texture | null }
|
|
37
|
+
export type MaterialColorOptions = { color?: ColorInput, map?: Texture | Canvas | null }
|
|
32
38
|
|
|
33
39
|
export class Material {
|
|
34
40
|
/** @internal native material-instance handle. */
|
|
@@ -65,11 +71,12 @@ export class Material {
|
|
|
65
71
|
if (this._colorKey) this.uniforms[this._colorKey] = Color.toHexString(c)
|
|
66
72
|
}
|
|
67
73
|
|
|
68
|
-
set map(value: Texture | null) {
|
|
74
|
+
set map(value: Texture | Canvas | null) {
|
|
69
75
|
if (value === null) {
|
|
70
76
|
if (this._useMapFlag) this.uniforms.useBaseColorMap = false
|
|
71
77
|
} else {
|
|
72
|
-
|
|
78
|
+
// A Canvas bakes to a (cached) 3D texture on assign — same as Sprite accepting a Canvas in 2D.
|
|
79
|
+
this.uniforms.baseColorMap = isCanvasSource(value) ? Texture.fromCanvas(value) : value
|
|
73
80
|
if (this._useMapFlag) this.uniforms.useBaseColorMap = true
|
|
74
81
|
}
|
|
75
82
|
}
|
package/runtime/sdk/gl/Node.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// on them. Native (creator-gl) owns the authoritative transform; reads decompose a cached local
|
|
3
3
|
// matrix (re-synced lazily, since physics can write the transform from native each frame).
|
|
4
4
|
//
|
|
5
|
-
// Capabilities are aspects: node.aspect(Physics, …) (→ node.physics), node.aspect(
|
|
5
|
+
// Capabilities are aspects: node.aspect(Physics, …) (→ node.physics), node.aspect(Shape, …).
|
|
6
6
|
// A GLB import is the Model node kind (with the ModelAnimation aspect). Hierarchy lives here.
|
|
7
7
|
|
|
8
8
|
import { AspectHost } from "../core/Aspect"
|
|
@@ -12,6 +12,7 @@ import { Quat, cw, type QuatLike } from "../math/quat"
|
|
|
12
12
|
import { Mat4, type Mat4Like } from "../math/mat4"
|
|
13
13
|
import type { Geometry } from "./Geometry"
|
|
14
14
|
import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
|
|
15
|
+
import { ensurePhysicsEvents } from "./physicsEvents"
|
|
15
16
|
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
16
17
|
|
|
17
18
|
/** id → Node, so host callbacks (touch hits, animation events) route back to the owning object. */
|
|
@@ -28,6 +29,10 @@ export type NodeEvents = {
|
|
|
28
29
|
track: () => void
|
|
29
30
|
/** AR anchors only: the anchor lost tracking. */
|
|
30
31
|
untrack: () => void
|
|
32
|
+
/** A physics contact/trigger overlap began (the other body's node). Needs a Shape + Physics/Trigger. */
|
|
33
|
+
enter: (other: Node) => void
|
|
34
|
+
/** A physics contact/trigger overlap ended. */
|
|
35
|
+
exit: (other: Node) => void
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export class Node extends AspectHost<NodeEvents> {
|
|
@@ -171,7 +176,7 @@ export class Node extends AspectHost<NodeEvents> {
|
|
|
171
176
|
_creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
|
|
172
177
|
}
|
|
173
178
|
|
|
174
|
-
// --- behaviors are aspects: node.aspect(
|
|
179
|
+
// --- behaviors are aspects: node.aspect(Shape, …) / node.aspect(Physics, …) ---
|
|
175
180
|
|
|
176
181
|
/** Orient the node so the given axis (default "-z", "forward") points at a world point. */
|
|
177
182
|
lookAt(point: Vec3Like, mode: "z" | "-z" | "x" | "-x" | "y" | "-y" = "-z", ortho: Vec3Like = [ 0, 1, 0 ]): this {
|
|
@@ -207,6 +212,7 @@ export class Node extends AspectHost<NodeEvents> {
|
|
|
207
212
|
override addEventListener<K extends keyof NodeEvents>(channel: K, callback: NodeEvents[K]): void {
|
|
208
213
|
if (channel === "click") registerTouchEndEvent()
|
|
209
214
|
else if (channel === "touchstart") registerTouchStartEvent()
|
|
215
|
+
else if (channel === "enter" || channel === "exit") ensurePhysicsEvents()
|
|
210
216
|
super.addEventListener(channel, callback)
|
|
211
217
|
}
|
|
212
218
|
|
|
@@ -215,6 +221,9 @@ export class Node extends AspectHost<NodeEvents> {
|
|
|
215
221
|
this.dispatch(channel, ...args)
|
|
216
222
|
}
|
|
217
223
|
|
|
224
|
+
/** @internal — emit a physics contact/trigger event (the physics-event router can't reach dispatch). */
|
|
225
|
+
_emitCollision(channel: "enter" | "exit", other: Node): void { this.dispatch(channel, other) }
|
|
226
|
+
|
|
218
227
|
/** @internal — host animation-complete callback; forwards to the ModelAnimation aspect (if any).
|
|
219
228
|
* Returns whether the clip should keep playing. */
|
|
220
229
|
_onAnimationComplete(clipIndex: number): boolean {
|
|
@@ -1,47 +1,70 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Rigid body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl. Requires a Shape
|
|
2
|
+
// aspect on the same node (its geometry becomes the body's collision shape).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// Physics.configure({ gravity: [0, -9.81, 0] }) // once, before creating bodies
|
|
5
|
+
// ground.aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' })
|
|
6
|
+
// crate.aspect(Shape, {}).aspect(Physics, { mass: 2 }) // auto box from the mesh
|
|
7
|
+
// crate.physics.applyImpulse([0, 6, 0])
|
|
8
|
+
// crate.addEventListener('enter', other => …) // contact / trigger overlap began
|
|
9
|
+
//
|
|
10
|
+
// For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it via
|
|
11
|
+
// velocity / applyImpulse. Reading node.position is free (native already wrote it). Kinematic bodies
|
|
12
|
+
// are driven with moveTo.
|
|
7
13
|
|
|
8
14
|
import { Aspect } from "../core/Aspect"
|
|
9
15
|
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
10
|
-
import type
|
|
16
|
+
import { nodeRegistry, type Node } from "./Node"
|
|
17
|
+
import { Shape } from "./Shape"
|
|
11
18
|
|
|
12
19
|
export type MotionType = "static" | "dynamic" | "kinematic"
|
|
13
20
|
const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
|
|
14
21
|
|
|
15
|
-
export
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
export interface PhysicsConfig {
|
|
23
|
+
/** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
|
|
24
|
+
gravity?: Vec3Like
|
|
25
|
+
/** Max simultaneous bodies (resizes the world; only before any body exists). Default 4096. */
|
|
26
|
+
maxBodies?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Closest raycast hit. */
|
|
30
|
+
export interface RayHit {
|
|
31
|
+
node: Node | null
|
|
32
|
+
point: Vec3
|
|
33
|
+
normal: Vec3
|
|
34
|
+
fraction: number
|
|
35
|
+
}
|
|
20
36
|
|
|
21
37
|
export class Physics extends Aspect<"physics", Node> {
|
|
22
38
|
static readonly aspect = "physics"
|
|
23
39
|
|
|
40
|
+
/** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
|
|
41
|
+
* off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
|
|
42
|
+
* smoothness — bodies then advance in discrete steps. */
|
|
43
|
+
private static _interpolation = true
|
|
44
|
+
static get interpolation(): boolean { return Physics._interpolation }
|
|
45
|
+
static set interpolation(on: boolean) { Physics._interpolation = on; _creator.setInterpolation(on) }
|
|
46
|
+
|
|
24
47
|
/** Default "dynamic". */
|
|
25
48
|
motion: MotionType = "dynamic"
|
|
26
49
|
/** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
|
|
27
50
|
mass = 1
|
|
28
|
-
/** Default { type: "auto" } — a box from the mesh AABB × world scale. */
|
|
29
|
-
shape: BodyShape = { type: "auto" }
|
|
30
51
|
|
|
31
52
|
private _bodyId = 0
|
|
32
53
|
|
|
33
54
|
onAttach(): void {
|
|
34
|
-
// No physics in this build (CREATOR_GL_PHYSICS=OFF) → bodyId stays 0; all methods no-op.
|
|
35
55
|
if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this._bodyId = _creator.physicsCreateSphere(this.node.id, shape.radius, motion, this.mass)
|
|
40
|
-
} else if (shape.type === "cylinder") {
|
|
41
|
-
this._bodyId = _creator.physicsCreateCylinder(this.node.id, shape.halfHeight, shape.radius, motion, this.mass)
|
|
42
|
-
} else {
|
|
43
|
-
this._bodyId = _creator.physicsCreateBox(this.node.id, cx(shape.halfExtents), cy(shape.halfExtents), cz(shape.halfExtents), motion, this.mass)
|
|
56
|
+
const shape = this.node.get(Shape)
|
|
57
|
+
if (!shape) {
|
|
58
|
+
throw new Error("Physics requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics, {…})")
|
|
44
59
|
}
|
|
60
|
+
const shapeId = shape._claim() // drop the pick-only body; reuse its shape
|
|
61
|
+
this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
|
|
62
|
+
shape._ownBody(this._bodyId)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onDetach(): void {
|
|
66
|
+
if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
|
|
67
|
+
this.node.get(Shape)?._recreatePickBody() // keep the node pickable if the Shape stays
|
|
45
68
|
}
|
|
46
69
|
|
|
47
70
|
/** Native Jolt body id. 0 until attached, or if the build has no physics support. */
|
|
@@ -69,27 +92,28 @@ export class Physics extends Aspect<"physics", Node> {
|
|
|
69
92
|
return this
|
|
70
93
|
}
|
|
71
94
|
|
|
72
|
-
|
|
73
|
-
|
|
95
|
+
// --- world (static) -------------------------------------------------------------------------
|
|
96
|
+
/** Configure gravity / limits. Call once before creating bodies. */
|
|
97
|
+
static configure(config: PhysicsConfig = {}): void {
|
|
98
|
+
const g = config.gravity ?? [0, -9.81, 0]
|
|
99
|
+
_creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Whether this build has physics support (CREATOR_GL_PHYSICS). */
|
|
103
|
+
static get supported(): boolean {
|
|
104
|
+
return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
|
|
74
105
|
}
|
|
75
|
-
}
|
|
76
106
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
for (let i = 0; i + 2 < v.length; i += 3) {
|
|
88
|
-
if (v[i] < minX) minX = v[i]; if (v[i] > maxX) maxX = v[i]
|
|
89
|
-
if (v[i + 1] < minY) minY = v[i + 1]; if (v[i + 1] > maxY) maxY = v[i + 1]
|
|
90
|
-
if (v[i + 2] < minZ) minZ = v[i + 2]; if (v[i + 2] > maxZ) maxZ = v[i + 2]
|
|
107
|
+
/** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
|
|
108
|
+
static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
|
|
109
|
+
const out = new Float32Array(7)
|
|
110
|
+
const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
|
|
111
|
+
if (!id) return null
|
|
112
|
+
return {
|
|
113
|
+
node: nodeRegistry.get(id) ?? null,
|
|
114
|
+
point: new Vec3(out[0], out[1], out[2]),
|
|
115
|
+
normal: new Vec3(out[3], out[4], out[5]),
|
|
116
|
+
fraction: out[6],
|
|
91
117
|
}
|
|
92
|
-
hx = (maxX - minX) * 0.5; hy = (maxY - minY) * 0.5; hz = (maxZ - minZ) * 0.5
|
|
93
118
|
}
|
|
94
|
-
return { type: "box", halfExtents: [ hx * scale.x, hy * scale.y, hz * scale.z ] }
|
|
95
119
|
}
|
package/runtime/sdk/gl/Scene.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// direct light + shadows.
|
|
7
7
|
|
|
8
8
|
import { Color, type ColorInput } from "../core/color"
|
|
9
|
+
import { _installAspectFrames } from "../core/Aspect"
|
|
9
10
|
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
10
11
|
import type { FetchResponse } from "../runtime/fetch"
|
|
11
12
|
import { Camera } from "./Camera"
|
|
@@ -29,6 +30,22 @@ export type SceneOptions = {
|
|
|
29
30
|
antialias?: boolean
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// Route aspect update(dt) through the render-synced native phases (render() early = before physics,
|
|
34
|
+
// late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
|
|
35
|
+
// the 3D layer (not core) so a 2D-only bundle never pulls in the 3D bridge. Installed once, lazily on
|
|
36
|
+
// the first Scene. On an older wasm without the hooks the installer returns false → setLoop fallback.
|
|
37
|
+
let framesInstalled = false
|
|
38
|
+
const installRenderSyncedFrames = (): void => {
|
|
39
|
+
if (framesInstalled) return
|
|
40
|
+
framesInstalled = true
|
|
41
|
+
_installAspectFrames((early, late) => {
|
|
42
|
+
if (typeof _creator.setEarlyUpdate !== "function") return false
|
|
43
|
+
_creator.setEarlyUpdate(early)
|
|
44
|
+
_creator.setLateUpdate(late)
|
|
45
|
+
return true
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
export class Scene {
|
|
33
50
|
/** @internal native scene handle. */
|
|
34
51
|
readonly _id: number
|
|
@@ -44,6 +61,7 @@ export class Scene {
|
|
|
44
61
|
static get active(): Scene | null { return Scene._active }
|
|
45
62
|
|
|
46
63
|
constructor(options: SceneOptions = {}) {
|
|
64
|
+
installRenderSyncedFrames()
|
|
47
65
|
const parent = (options as any)._parent as Scene | undefined
|
|
48
66
|
if (parent) {
|
|
49
67
|
this.camera = parent.camera
|