lecodes-cli 0.1.7 → 0.3.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 +7 -1
- package/dist/index.js +67 -101
- package/package.json +4 -5
- package/runtime/sdk/canvas/Canvas.ts +184 -0
- package/runtime/sdk/canvas/Label.ts +57 -0
- package/runtime/sdk/core/Aspect.ts +207 -0
- package/runtime/sdk/g2/Camera2D.ts +3 -43
- package/runtime/sdk/g2/CharacterController2D.ts +75 -0
- package/runtime/sdk/g2/Node2D.ts +117 -27
- package/runtime/sdk/g2/Physics2D.ts +126 -0
- package/runtime/sdk/g2/Scene2D.ts +47 -1
- package/runtime/sdk/g2/Shape.ts +63 -0
- package/runtime/sdk/g2/Sprite.ts +13 -93
- package/runtime/sdk/g2/SpriteAnimation.ts +90 -0
- package/runtime/sdk/g2/Texture2D.ts +8 -0
- package/runtime/sdk/g2/Trigger.ts +47 -0
- package/runtime/sdk/g2/loop.ts +20 -3
- package/runtime/sdk/g2/touch.ts +83 -0
- package/runtime/sdk/gl/Collider.ts +29 -0
- package/runtime/sdk/gl/Material.ts +10 -3
- package/runtime/sdk/gl/Mesh.ts +2 -25
- package/runtime/sdk/gl/Model.ts +40 -0
- package/runtime/sdk/gl/ModelAnimation.ts +64 -0
- package/runtime/sdk/gl/Node.ts +13 -86
- package/runtime/sdk/gl/{Body.ts → Physics.ts} +18 -24
- package/runtime/sdk/gl/Texture.ts +11 -0
- package/runtime/sdk/inject.ts +17 -0
- package/runtime/sdk/runtime/touch.ts +6 -0
- package/runtime/sdk/ui/UIImage.ts +26 -11
- package/runtime/sdk/ui/UIScreen.ts +7 -2
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// 2D pointer dispatch + track (drag) system. The host (createViewer2d canvas / Creator2DView) delivers
|
|
2
|
+
// raw pointer phases via _creator2d.setOnPointer; this module hit-tests in the active scene and routes
|
|
3
|
+
// to the struck node and the scene's listeners. Mirrors the 3D gl/touch.ts, but the hit-test is done
|
|
4
|
+
// here (screen→world via the camera, then a physics overlap), not by the host.
|
|
5
|
+
//
|
|
6
|
+
// What can be hit: a node with a Physics2D shape (precise) → its 'touchstart'/'click' events; the
|
|
7
|
+
// active Scene2D always gets the event too (target = the hit node or null). A node listener can call
|
|
8
|
+
// ev.track({ onMove, onEnd, onCancel }) to capture the rest of the gesture (drag/pan).
|
|
9
|
+
|
|
10
|
+
import { ClickEvent, TouchStartEvent, type TrackHandler } from "../runtime/touch"
|
|
11
|
+
import { Physics2D } from "./Physics2D"
|
|
12
|
+
import { Scene2D } from "./Scene2D"
|
|
13
|
+
import type { Node2D } from "./Node2D"
|
|
14
|
+
|
|
15
|
+
type Active = { tracker?: TrackHandler, lastX: number, lastY: number }
|
|
16
|
+
const active = new Map<number, Active>()
|
|
17
|
+
|
|
18
|
+
let registered = false
|
|
19
|
+
/** Register the host pointer callback once, on the first click/touchstart listener (node or scene). */
|
|
20
|
+
export const ensurePointerEvents = (): void => {
|
|
21
|
+
if (registered) return
|
|
22
|
+
registered = true
|
|
23
|
+
_creator2d.setOnPointer((phase, id, x, y) => {
|
|
24
|
+
if (phase === 0) onDown(id, x, y)
|
|
25
|
+
else if (phase === 1) onMove(id, x, y)
|
|
26
|
+
else if (phase === 2) onUp(id, x, y)
|
|
27
|
+
else onCancel(id)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Hit-test: screen (logical px) → world → topmost physics body at the point.
|
|
32
|
+
const hitTest = (scene: Scene2D, sx: number, sy: number): { node: Node2D | null, wx: number, wy: number } => {
|
|
33
|
+
const w = scene.camera.screenToWorld(sx, sy)
|
|
34
|
+
return { node: Physics2D.overlapPoint(w), wx: w.x, wy: w.y }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const onDown = (id: number, sx: number, sy: number): void => {
|
|
38
|
+
const scene = Scene2D.active
|
|
39
|
+
if (!scene) return
|
|
40
|
+
const { node, wx, wy } = hitTest(scene, sx, sy)
|
|
41
|
+
const ev = new TouchStartEvent<Node2D | null>(sx, sy, id)
|
|
42
|
+
ev.worldX = wx; ev.worldY = wy
|
|
43
|
+
ev.target = node
|
|
44
|
+
|
|
45
|
+
let tracker: TrackHandler | undefined
|
|
46
|
+
if (node) { node._dispatchEvent("touchstart", ev); tracker = ev._trackHandler }
|
|
47
|
+
if (!tracker) {
|
|
48
|
+
for (const l of scene._touchStartListeners.slice()) {
|
|
49
|
+
l(ev)
|
|
50
|
+
if (ev._trackHandler) { tracker = ev._trackHandler; break }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
active.set(id, { tracker, lastX: sx, lastY: sy })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const onMove = (id: number, sx: number, sy: number): void => {
|
|
57
|
+
const a = active.get(id)
|
|
58
|
+
if (!a) return
|
|
59
|
+
a.tracker?.onMove?.({ clientX: sx, clientY: sy, deltaX: sx - a.lastX, deltaY: sy - a.lastY })
|
|
60
|
+
a.lastX = sx; a.lastY = sy
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const onUp = (id: number, sx: number, sy: number): void => {
|
|
64
|
+
const a = active.get(id)
|
|
65
|
+
active.delete(id)
|
|
66
|
+
a?.tracker?.onEnd?.({ clientX: sx, clientY: sy, deltaX: a ? sx - a.lastX : 0, deltaY: a ? sy - a.lastY : 0 })
|
|
67
|
+
|
|
68
|
+
// A pointer-up is also a click, dispatched at the up location (independent of any track).
|
|
69
|
+
const scene = Scene2D.active
|
|
70
|
+
if (!scene) return
|
|
71
|
+
const { node, wx, wy } = hitTest(scene, sx, sy)
|
|
72
|
+
const ev = new ClickEvent<Node2D | null>(sx, sy, id)
|
|
73
|
+
ev.worldX = wx; ev.worldY = wy
|
|
74
|
+
ev.target = node
|
|
75
|
+
if (node) node._dispatchEvent("click", ev)
|
|
76
|
+
for (const l of scene._clickListeners.slice()) l(ev)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onCancel = (id: number): void => {
|
|
80
|
+
const a = active.get(id)
|
|
81
|
+
active.delete(id)
|
|
82
|
+
a?.tracker?.onCancel?.()
|
|
83
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// A 3D collider for click/touch hit-testing, as an aspect. Derives a box from the mesh by default.
|
|
2
|
+
// (3D is the default engine, so this is the unsuffixed Collider; the 2D one is Collider2D.)
|
|
3
|
+
// node.aspect(Collider, { shape: 'box' })
|
|
4
|
+
// node.aspect(Collider, { shape: 'box', center: [0,1,0], size: [2,2,2] })
|
|
5
|
+
|
|
6
|
+
import { Aspect } from "../core/Aspect"
|
|
7
|
+
import { cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
8
|
+
import type { Node } from "./Node"
|
|
9
|
+
|
|
10
|
+
export class Collider extends Aspect<"collider", Node> {
|
|
11
|
+
static readonly aspect = "collider"
|
|
12
|
+
shape: "box" | "sphere" = "box"
|
|
13
|
+
/** Explicit box: center + full size. */
|
|
14
|
+
center?: Vec3Like
|
|
15
|
+
size?: Vec3Like
|
|
16
|
+
/** Explicit sphere: center + radius. */
|
|
17
|
+
radius?: number
|
|
18
|
+
|
|
19
|
+
onAttach(): void {
|
|
20
|
+
const id = this.node.id
|
|
21
|
+
if (this.shape === "box" && this.center && this.size) {
|
|
22
|
+
_creator.setColliderBox(id, cx(this.center), cy(this.center), cz(this.center), cx(this.size), cy(this.size), cz(this.size))
|
|
23
|
+
} else if (this.shape === "sphere" && this.center && this.radius !== undefined) {
|
|
24
|
+
_creator.setColliderSphere(id, cx(this.center), cy(this.center), cz(this.center), this.radius)
|
|
25
|
+
} else {
|
|
26
|
+
_creator.setColliderFromMesh(id, id, this.shape === "sphere" ? 1 : 0)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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/Mesh.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// A renderable 3D node
|
|
2
|
-
// Mesh.
|
|
3
|
-
// worker/src/components/Mesh.ts — folded into the node (no component).
|
|
1
|
+
// A renderable 3D node from raw geometry — the primitive shapes (box/sphere/cylinder/plane) and
|
|
2
|
+
// Mesh.from for a custom Geometry. A GLB import is a different kind: see Model.load.
|
|
4
3
|
|
|
5
4
|
import { cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
6
|
-
import { fetch, type FetchResponse } from "../runtime/fetch"
|
|
7
5
|
import {
|
|
8
6
|
Geometry,
|
|
9
7
|
type CylinderOptions,
|
|
@@ -87,27 +85,6 @@ export class Mesh extends Node {
|
|
|
87
85
|
static from(geometry: Geometry, options: MeshOptions = {}): Mesh {
|
|
88
86
|
return apply(new Mesh(geometry, options.material), options)
|
|
89
87
|
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Load a GLB model. Returns the model's root Node (a GLB is a node hierarchy); its animation clips
|
|
93
|
-
* are available via node.clips and driven with node.play(name|index).
|
|
94
|
-
*/
|
|
95
|
-
static load(source: string | FetchResponse, options: { culling?: boolean, onProgress?: (p: { loaded: number, total?: number }) => void } = {}): Promise<Node> {
|
|
96
|
-
if (typeof source === "string") {
|
|
97
|
-
return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
|
|
98
|
-
if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
|
|
99
|
-
return Mesh.load(resp, options)
|
|
100
|
-
})
|
|
101
|
-
}
|
|
102
|
-
return new Promise<Node>((resolve, reject) => {
|
|
103
|
-
_creator.createGlb((source as any)._id, (entityId) => {
|
|
104
|
-
if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
|
|
105
|
-
const node = new Node(entityId)
|
|
106
|
-
if (options.culling !== true) _creator.setGlbCulling(entityId, false)
|
|
107
|
-
resolve(node)
|
|
108
|
-
}, reject)
|
|
109
|
-
})
|
|
110
|
-
}
|
|
111
88
|
}
|
|
112
89
|
|
|
113
90
|
const sizeToVec = (s: Vec3Like | number): [number, number, number] => (typeof s === "number" ? [ s, s, s ] : [ cx(s), cy(s), cz(s) ])
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
|
|
2
|
+
// distinct from Mesh (raw primitives, no animation). Animation is always present, reached as
|
|
3
|
+
// model.anim:
|
|
4
|
+
// const hero = await Model.load(asset('./hero.glb'))
|
|
5
|
+
// hero.anim.play('Run')
|
|
6
|
+
|
|
7
|
+
import { fetch, type FetchResponse } from "../runtime/fetch"
|
|
8
|
+
import { Node } from "./Node"
|
|
9
|
+
import { ModelAnimation } from "./ModelAnimation"
|
|
10
|
+
|
|
11
|
+
export class Model extends Node {
|
|
12
|
+
/** GLB skeletal animation — always present on a Model. */
|
|
13
|
+
declare readonly anim: ModelAnimation
|
|
14
|
+
|
|
15
|
+
constructor(internalId: number) {
|
|
16
|
+
super(internalId)
|
|
17
|
+
this.aspect(ModelAnimation)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
|
|
21
|
+
static load(
|
|
22
|
+
source: string | FetchResponse,
|
|
23
|
+
options: { culling?: boolean, onProgress?: (p: { loaded: number, total?: number }) => void } = {},
|
|
24
|
+
): Promise<Model> {
|
|
25
|
+
if (typeof source === "string") {
|
|
26
|
+
return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
|
|
27
|
+
if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
|
|
28
|
+
return Model.load(resp, options)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
return new Promise<Model>((resolve, reject) => {
|
|
32
|
+
_creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
|
|
33
|
+
if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
|
|
34
|
+
const model = new Model(entityId)
|
|
35
|
+
if (options.culling !== true) _creator.setGlbCulling(entityId, false)
|
|
36
|
+
resolve(model)
|
|
37
|
+
}, reject)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// GLB skeletal animation as an aspect. Auto-attached by Model (a glb always has its clips), so you
|
|
2
|
+
// reach it directly: model.anim.play('Run'). Clips come baked in the glb; play by name or index.
|
|
3
|
+
|
|
4
|
+
import { Aspect } from "../core/Aspect"
|
|
5
|
+
import { nodeRegistry } from "./Node"
|
|
6
|
+
import type { Model } from "./Model"
|
|
7
|
+
|
|
8
|
+
let animatorRegistered = false
|
|
9
|
+
const ensureAnimator = (): void => {
|
|
10
|
+
if (animatorRegistered) return
|
|
11
|
+
animatorRegistered = true
|
|
12
|
+
_creator.setOnAnimationComplete((entityId, clipIndex) => nodeRegistry.get(entityId)?._onAnimationComplete(clipIndex) ?? true)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ModelAnimation extends Aspect<"anim", Model> {
|
|
16
|
+
static readonly aspect = "anim"
|
|
17
|
+
|
|
18
|
+
private _playing = false
|
|
19
|
+
private _clip = 0
|
|
20
|
+
private _speed = 1
|
|
21
|
+
private _loop = false
|
|
22
|
+
|
|
23
|
+
onAttach(): void {
|
|
24
|
+
this.node._animComplete = (clipIndex) => {
|
|
25
|
+
if (clipIndex !== this._clip) return true
|
|
26
|
+
if (!this._loop) { this._playing = false; this.node._emitAnim("completed", clipIndex) }
|
|
27
|
+
else this.node._emitAnim("loopReached", clipIndex)
|
|
28
|
+
return this._playing
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The clips baked into the glb. */
|
|
33
|
+
get clips(): { name: string, duration: number }[] { return _creator.getGlbAnimations(this.node.id) }
|
|
34
|
+
|
|
35
|
+
get playing(): boolean { return this._playing }
|
|
36
|
+
set playing(value: boolean) {
|
|
37
|
+
this._playing = value
|
|
38
|
+
_creator.setAnimation(this.node.id, value, this._clip, this._speed)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Play a clip by name or index. Chainable. */
|
|
42
|
+
play(clip?: string | number, options: { loop?: boolean } = {}): this {
|
|
43
|
+
if (typeof clip === "number") this._clip = clip
|
|
44
|
+
else if (typeof clip === "string") {
|
|
45
|
+
const i = this.clips.findIndex((c) => c.name === clip)
|
|
46
|
+
if (i >= 0) this._clip = i
|
|
47
|
+
}
|
|
48
|
+
if (options.loop !== undefined) this._loop = options.loop
|
|
49
|
+
this.playing = true
|
|
50
|
+
ensureAnimator()
|
|
51
|
+
return this
|
|
52
|
+
}
|
|
53
|
+
stop(): this { this.playing = false; return this }
|
|
54
|
+
|
|
55
|
+
get loop(): boolean { return this._loop }
|
|
56
|
+
set loop(v: boolean) { this._loop = v }
|
|
57
|
+
get speed(): number { return this._speed }
|
|
58
|
+
set speed(v: number) {
|
|
59
|
+
this._speed = v
|
|
60
|
+
_creator.setAnimation(this.node.id, this._playing, this._clip, v)
|
|
61
|
+
}
|
|
62
|
+
get time(): number { return _creator.getAnimationTime(this.node.id) }
|
|
63
|
+
set time(v: number) { _creator.setAnimationTime(this.node.id, this._clip, v) }
|
|
64
|
+
}
|
package/runtime/sdk/gl/Node.ts
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
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
|
-
//
|
|
6
|
-
// (
|
|
5
|
+
// Capabilities are aspects: node.aspect(Physics, …) (→ node.physics), node.aspect(Collider, …).
|
|
6
|
+
// A GLB import is the Model node kind (with the ModelAnimation aspect). Hierarchy lives here.
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { AspectHost } from "../core/Aspect"
|
|
9
9
|
import { Registry } from "../core/registry"
|
|
10
10
|
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
11
11
|
import { Quat, cw, type QuatLike } from "../math/quat"
|
|
12
12
|
import { Mat4, type Mat4Like } from "../math/mat4"
|
|
13
|
-
import { Body, type PhysicsOptions } from "./Body"
|
|
14
13
|
import type { Geometry } from "./Geometry"
|
|
15
14
|
import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
|
|
16
15
|
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
@@ -31,29 +30,10 @@ export type NodeEvents = {
|
|
|
31
30
|
untrack: () => void
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
export
|
|
35
|
-
shape?: "box" | "sphere"
|
|
36
|
-
/** Explicit box: center + full size. */
|
|
37
|
-
center?: Vec3Like
|
|
38
|
-
size?: Vec3Like
|
|
39
|
-
/** Explicit sphere: center + radius. */
|
|
40
|
-
radius?: number
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
let animatorRegistered = false
|
|
44
|
-
const ensureAnimator = (): void => {
|
|
45
|
-
if (animatorRegistered) return
|
|
46
|
-
animatorRegistered = true
|
|
47
|
-
_creator.setOnAnimationComplete((entityId, clipIndex) => nodeRegistry.get(entityId)?._onAnimationComplete(clipIndex) ?? true)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export class Node extends Emitter<NodeEvents> {
|
|
33
|
+
export class Node extends AspectHost<NodeEvents> {
|
|
51
34
|
/** Native entity handle. */
|
|
52
35
|
readonly id: number
|
|
53
36
|
|
|
54
|
-
/** Physics handle once .physics() is called. */
|
|
55
|
-
body?: Body
|
|
56
|
-
|
|
57
37
|
/** AR anchors only — whether the anchor is currently being tracked. Kept in step with the
|
|
58
38
|
* 'track' / 'untrack' events; `false` for any non-anchor node. */
|
|
59
39
|
isTracked = false
|
|
@@ -63,10 +43,8 @@ export class Node extends Emitter<NodeEvents> {
|
|
|
63
43
|
private _worldMatrix?: Float32Array
|
|
64
44
|
private _scaleCache?: [number, number, number]
|
|
65
45
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
private _animSpeed = 1
|
|
69
|
-
private _animLoop = false
|
|
46
|
+
/** @internal — the active animation aspect's clip-complete handler (installed by ModelAnimation). */
|
|
47
|
+
_animComplete?: (clipIndex: number) => boolean
|
|
70
48
|
|
|
71
49
|
constructor(internalId?: number) {
|
|
72
50
|
super()
|
|
@@ -193,26 +171,7 @@ export class Node extends Emitter<NodeEvents> {
|
|
|
193
171
|
_creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
|
|
194
172
|
}
|
|
195
173
|
|
|
196
|
-
// --- behaviors ---
|
|
197
|
-
|
|
198
|
-
/** Add a physics body (reach it at node.body). Chainable. */
|
|
199
|
-
physics(options: PhysicsOptions = {}): this {
|
|
200
|
-
this.body = new Body(this, options)
|
|
201
|
-
return this
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Add a collider for click/touch hit-testing. Derives from the mesh by default. Chainable. */
|
|
205
|
-
collider(options: ColliderOptions = {}): this {
|
|
206
|
-
const form = options.shape ?? "box"
|
|
207
|
-
if (form === "box" && options.center && options.size) {
|
|
208
|
-
_creator.setColliderBox(this.id, cx(options.center), cy(options.center), cz(options.center), cx(options.size), cy(options.size), cz(options.size))
|
|
209
|
-
} else if (form === "sphere" && options.center && options.radius !== undefined) {
|
|
210
|
-
_creator.setColliderSphere(this.id, cx(options.center), cy(options.center), cz(options.center), options.radius)
|
|
211
|
-
} else {
|
|
212
|
-
_creator.setColliderFromMesh(this.id, this.id, form === "sphere" ? 1 : 0)
|
|
213
|
-
}
|
|
214
|
-
return this
|
|
215
|
-
}
|
|
174
|
+
// --- behaviors are aspects: node.aspect(Physics, …) / node.aspect(Collider, …) ---
|
|
216
175
|
|
|
217
176
|
/** Orient the node so the given axis (default "-z", "forward") points at a world point. */
|
|
218
177
|
lookAt(point: Vec3Like, mode: "z" | "-z" | "x" | "-x" | "y" | "-y" = "-z", ortho: Vec3Like = [ 0, 1, 0 ]): this {
|
|
@@ -244,39 +203,6 @@ export class Node extends Emitter<NodeEvents> {
|
|
|
244
203
|
return this
|
|
245
204
|
}
|
|
246
205
|
|
|
247
|
-
// --- GLB animation (no-op until a GLB is loaded as this node) ---
|
|
248
|
-
get clips(): { name: string, duration: number }[] { return _creator.getGlbAnimations(this.id) }
|
|
249
|
-
|
|
250
|
-
get animationPlaying(): boolean { return this._animPlaying }
|
|
251
|
-
set animationPlaying(value: boolean) {
|
|
252
|
-
this._animPlaying = value
|
|
253
|
-
_creator.setAnimation(this.id, value, this._animClip, this._animSpeed)
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/** Play a GLB clip by name or index. Chainable. */
|
|
257
|
-
play(clip?: string | number, options: { loop?: boolean } = {}): this {
|
|
258
|
-
if (typeof clip === "number") this._animClip = clip
|
|
259
|
-
else if (typeof clip === "string") {
|
|
260
|
-
const i = this.clips.findIndex((c) => c.name === clip)
|
|
261
|
-
if (i >= 0) this._animClip = i
|
|
262
|
-
}
|
|
263
|
-
if (options.loop !== undefined) this._animLoop = options.loop
|
|
264
|
-
this.animationPlaying = true
|
|
265
|
-
ensureAnimator()
|
|
266
|
-
return this
|
|
267
|
-
}
|
|
268
|
-
stop(): this { this.animationPlaying = false; return this }
|
|
269
|
-
|
|
270
|
-
get animationLoop(): boolean { return this._animLoop }
|
|
271
|
-
set animationLoop(v: boolean) { this._animLoop = v }
|
|
272
|
-
get animationSpeed(): number { return this._animSpeed }
|
|
273
|
-
set animationSpeed(v: number) {
|
|
274
|
-
this._animSpeed = v
|
|
275
|
-
_creator.setAnimation(this.id, this._animPlaying, this._animClip, v)
|
|
276
|
-
}
|
|
277
|
-
get animationTime(): number { return _creator.getAnimationTime(this.id) }
|
|
278
|
-
set animationTime(v: number) { _creator.setAnimationTime(this.id, this._animClip, v) }
|
|
279
|
-
|
|
280
206
|
// --- events ---
|
|
281
207
|
override addEventListener<K extends keyof NodeEvents>(channel: K, callback: NodeEvents[K]): void {
|
|
282
208
|
if (channel === "click") registerTouchEndEvent()
|
|
@@ -289,14 +215,15 @@ export class Node extends Emitter<NodeEvents> {
|
|
|
289
215
|
this.dispatch(channel, ...args)
|
|
290
216
|
}
|
|
291
217
|
|
|
292
|
-
/** @internal — host animation-complete callback;
|
|
218
|
+
/** @internal — host animation-complete callback; forwards to the ModelAnimation aspect (if any).
|
|
219
|
+
* Returns whether the clip should keep playing. */
|
|
293
220
|
_onAnimationComplete(clipIndex: number): boolean {
|
|
294
|
-
|
|
295
|
-
if (!this._animLoop) { this._animPlaying = false; this.dispatch("completed", clipIndex) }
|
|
296
|
-
else this.dispatch("loopReached", clipIndex)
|
|
297
|
-
return this._animPlaying
|
|
221
|
+
return this._animComplete?.(clipIndex) ?? true
|
|
298
222
|
}
|
|
299
223
|
|
|
224
|
+
/** @internal — emit a GLB animation event (the ModelAnimation aspect can't reach protected dispatch). */
|
|
225
|
+
_emitAnim(channel: "loopReached" | "completed", clip: number): void { this.dispatch(channel, clip) }
|
|
226
|
+
|
|
300
227
|
destroy(): void {
|
|
301
228
|
nodeRegistry.delete(this.id)
|
|
302
229
|
_creator.destroyEntity(this.id)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
// Physics
|
|
2
|
-
//
|
|
1
|
+
// Physics body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl.
|
|
2
|
+
// node.aspect(Physics, { motion: 'dynamic' }); node.physics.applyImpulse([0, 6, 0])
|
|
3
3
|
//
|
|
4
4
|
// For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it
|
|
5
5
|
// via velocity / applyImpulse. Reading node.position is free (native already wrote it). Kinematic
|
|
6
|
-
// bodies are driven with moveTo.
|
|
6
|
+
// bodies are driven with moveTo. (Ported from the former Body class.)
|
|
7
7
|
|
|
8
|
+
import { Aspect } from "../core/Aspect"
|
|
8
9
|
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
9
10
|
import type { Node } from "./Node"
|
|
10
11
|
|
|
@@ -17,40 +18,34 @@ export type BodyShape =
|
|
|
17
18
|
| { type: "sphere", radius: number }
|
|
18
19
|
| { type: "cylinder", halfHeight: number, radius: number }
|
|
19
20
|
|
|
20
|
-
export
|
|
21
|
+
export class Physics extends Aspect<"physics", Node> {
|
|
22
|
+
static readonly aspect = "physics"
|
|
23
|
+
|
|
21
24
|
/** Default "dynamic". */
|
|
22
|
-
motion
|
|
25
|
+
motion: MotionType = "dynamic"
|
|
23
26
|
/** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
|
|
24
|
-
mass
|
|
27
|
+
mass = 1
|
|
25
28
|
/** Default { type: "auto" } — a box from the mesh AABB × world scale. */
|
|
26
|
-
shape
|
|
27
|
-
}
|
|
29
|
+
shape: BodyShape = { type: "auto" }
|
|
28
30
|
|
|
29
|
-
export class Body {
|
|
30
31
|
private _bodyId = 0
|
|
31
|
-
private readonly _motion: MotionType
|
|
32
|
-
|
|
33
|
-
constructor(node: Node, options: PhysicsOptions = {}) {
|
|
34
|
-
this._motion = options.motion ?? "dynamic"
|
|
35
|
-
const mass = options.mass ?? 1
|
|
36
32
|
|
|
33
|
+
onAttach(): void {
|
|
37
34
|
// No physics in this build (CREATOR_GL_PHYSICS=OFF) → bodyId stays 0; all methods no-op.
|
|
38
35
|
if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
const shape = resolveShape(node, options.shape ?? { type: "auto" })
|
|
36
|
+
const motion = MOTION[this.motion]
|
|
37
|
+
const shape = resolveShape(this.node, this.shape)
|
|
42
38
|
if (shape.type === "sphere") {
|
|
43
|
-
this._bodyId = _creator.physicsCreateSphere(node.id, shape.radius, motion, mass)
|
|
39
|
+
this._bodyId = _creator.physicsCreateSphere(this.node.id, shape.radius, motion, this.mass)
|
|
44
40
|
} else if (shape.type === "cylinder") {
|
|
45
|
-
this._bodyId = _creator.physicsCreateCylinder(node.id, shape.halfHeight, shape.radius, motion, mass)
|
|
41
|
+
this._bodyId = _creator.physicsCreateCylinder(this.node.id, shape.halfHeight, shape.radius, motion, this.mass)
|
|
46
42
|
} else {
|
|
47
|
-
this._bodyId = _creator.physicsCreateBox(node.id, cx(shape.halfExtents), cy(shape.halfExtents), cz(shape.halfExtents), motion, mass)
|
|
43
|
+
this._bodyId = _creator.physicsCreateBox(this.node.id, cx(shape.halfExtents), cy(shape.halfExtents), cz(shape.halfExtents), motion, this.mass)
|
|
48
44
|
}
|
|
49
45
|
}
|
|
50
46
|
|
|
51
|
-
/** Native Jolt body id. 0 until
|
|
47
|
+
/** Native Jolt body id. 0 until attached, or if the build has no physics support. */
|
|
52
48
|
get id(): number { return this._bodyId }
|
|
53
|
-
get motion(): MotionType { return this._motion }
|
|
54
49
|
|
|
55
50
|
/** Linear velocity in world units/second (fresh Vec3 on read). */
|
|
56
51
|
get velocity(): Vec3 {
|
|
@@ -74,8 +69,7 @@ export class Body {
|
|
|
74
69
|
return this
|
|
75
70
|
}
|
|
76
71
|
|
|
77
|
-
|
|
78
|
-
remove(): void {
|
|
72
|
+
onDetach(): void {
|
|
79
73
|
if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
|
|
80
74
|
}
|
|
81
75
|
}
|
|
@@ -19,6 +19,17 @@ export class Texture {
|
|
|
19
19
|
this._id = id
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Wrap a Canvas's baked surface as a 3D texture (RGBA8, already rasterized → synchronous, no decode).
|
|
23
|
+
// The id is cached on the canvas (opaque _tex3dId), so backing several materials shares one texture and
|
|
24
|
+
// canvas.update() re-uploads it. Structural type avoids a Canvas import (keeps 3D free of 2D-canvas code).
|
|
25
|
+
static fromCanvas(canvas: { _bakeSurface(): number, width: number, height: number, pixelRatio: number, _tex3dId: number }): Texture {
|
|
26
|
+
if (!canvas._tex3dId) {
|
|
27
|
+
const surfaceId = canvas._bakeSurface()
|
|
28
|
+
canvas._tex3dId = _creator.createTextureFromCanvas(surfaceId)
|
|
29
|
+
}
|
|
30
|
+
return new Texture(Math.round(canvas.width * canvas.pixelRatio), Math.round(canvas.height * canvas.pixelRatio), canvas._tex3dId)
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
static load(source: string | FetchResponse): Promise<Texture> {
|
|
23
34
|
if (typeof source === "string") {
|
|
24
35
|
return fetch(source, { useOnce: true }).then((resp) => {
|
package/runtime/sdk/inject.ts
CHANGED
|
@@ -14,6 +14,13 @@ export { Quat } from "./math/quat"
|
|
|
14
14
|
export { Mat4 } from "./math/mat4"
|
|
15
15
|
export { Color } from "./core/color"
|
|
16
16
|
|
|
17
|
+
// ---- aspects (attachable node capabilities) ----
|
|
18
|
+
export { Aspect, type With } from "./core/Aspect"
|
|
19
|
+
|
|
20
|
+
// ---- canvas (core 2D drawing that bakes to a texture; shared by 2D/3D/UI) ----
|
|
21
|
+
export { Canvas } from "./canvas/Canvas"
|
|
22
|
+
export { Label } from "./canvas/Label"
|
|
23
|
+
|
|
17
24
|
// ---- platform runtime (injected globals; the host provides the lower-level _creator* bridges) ----
|
|
18
25
|
export { toast, SvgSource } from "./runtime/misc"
|
|
19
26
|
export { fetch, fetchLocal, FormData, File } from "./runtime/fetch"
|
|
@@ -28,7 +35,13 @@ export { ClickEvent, TouchStartEvent } from "./runtime/touch"
|
|
|
28
35
|
// ---- 2D engine (creator-2d) ----
|
|
29
36
|
export { Scene2D } from "./g2/Scene2D"
|
|
30
37
|
export { Node2D } from "./g2/Node2D"
|
|
38
|
+
export { Camera2D } from "./g2/Camera2D"
|
|
31
39
|
export { Sprite } from "./g2/Sprite"
|
|
40
|
+
export { SpriteAnimation } from "./g2/SpriteAnimation"
|
|
41
|
+
export { Shape } from "./g2/Shape"
|
|
42
|
+
export { Physics2D } from "./g2/Physics2D"
|
|
43
|
+
export { Trigger } from "./g2/Trigger"
|
|
44
|
+
export { CharacterController2D } from "./g2/CharacterController2D"
|
|
32
45
|
export { Tilemap } from "./g2/Tilemap"
|
|
33
46
|
export { Texture2D } from "./g2/Texture2D"
|
|
34
47
|
|
|
@@ -61,6 +74,10 @@ export { QRScanner } from "./plugins/qr"
|
|
|
61
74
|
export { Scene, ARScene } from "./gl/Scene"
|
|
62
75
|
export { Node } from "./gl/Node"
|
|
63
76
|
export { Mesh } from "./gl/Mesh"
|
|
77
|
+
export { Model } from "./gl/Model"
|
|
78
|
+
export { Physics } from "./gl/Physics"
|
|
79
|
+
export { Collider } from "./gl/Collider"
|
|
80
|
+
export { ModelAnimation } from "./gl/ModelAnimation"
|
|
64
81
|
export { Light } from "./gl/Light"
|
|
65
82
|
export { Material } from "./gl/Material"
|
|
66
83
|
export { Geometry } from "./gl/Geometry"
|
|
@@ -8,6 +8,9 @@ export class ClickEvent<T = any> {
|
|
|
8
8
|
readonly pointerId: number
|
|
9
9
|
readonly clientX: number
|
|
10
10
|
readonly clientY: number
|
|
11
|
+
/** World-space hit position (2D scenes only; undefined in 3D). */
|
|
12
|
+
worldX?: number
|
|
13
|
+
worldY?: number
|
|
11
14
|
target!: T
|
|
12
15
|
constructor(clientX: number, clientY: number, pointerId: number) {
|
|
13
16
|
this.clientX = clientX
|
|
@@ -28,6 +31,9 @@ export class TouchStartEvent<T = any> {
|
|
|
28
31
|
readonly pointerId: number
|
|
29
32
|
readonly clientX: number
|
|
30
33
|
readonly clientY: number
|
|
34
|
+
/** World-space hit position (2D scenes only; undefined in 3D). */
|
|
35
|
+
worldX?: number
|
|
36
|
+
worldY?: number
|
|
31
37
|
target!: T
|
|
32
38
|
/** @internal — read by the dispatch system after the handler runs. */
|
|
33
39
|
_trackHandler?: TrackHandler
|