lecodes-cli 0.1.6 → 0.2.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 +202 -84
- package/package.json +4 -4
- package/runtime/sdk/animate/animate.ts +238 -0
- package/runtime/sdk/animate/bezier.ts +138 -0
- package/runtime/sdk/animate/easings.ts +126 -0
- package/runtime/sdk/core/Aspect.ts +151 -0
- package/runtime/sdk/core/color.ts +66 -0
- package/runtime/sdk/core/events.ts +38 -0
- package/runtime/sdk/core/registry.ts +23 -0
- package/runtime/sdk/g2/Camera2D.ts +33 -0
- package/runtime/sdk/g2/CharacterController2D.ts +75 -0
- package/runtime/sdk/g2/Node2D.ts +194 -0
- package/runtime/sdk/g2/Physics2D.ts +126 -0
- package/runtime/sdk/g2/Scene2D.ts +101 -0
- package/runtime/sdk/g2/Shape.ts +63 -0
- package/runtime/sdk/g2/Sprite.ts +79 -0
- package/runtime/sdk/g2/SpriteAnimation.ts +90 -0
- package/runtime/sdk/g2/Texture2D.ts +37 -0
- package/runtime/sdk/g2/Tilemap.ts +41 -0
- package/runtime/sdk/g2/Trigger.ts +47 -0
- package/runtime/sdk/g2/loop.ts +39 -0
- package/runtime/sdk/g2/touch.ts +83 -0
- package/runtime/sdk/gl/Camera.ts +40 -0
- package/runtime/sdk/gl/Collider.ts +29 -0
- package/runtime/sdk/gl/Geometry.ts +218 -0
- package/runtime/sdk/gl/Light.ts +37 -0
- package/runtime/sdk/gl/Material.ts +124 -0
- package/runtime/sdk/gl/Mesh.ts +100 -0
- package/runtime/sdk/gl/Model.ts +40 -0
- package/runtime/sdk/gl/ModelAnimation.ts +64 -0
- package/runtime/sdk/gl/Node.ts +234 -0
- package/runtime/sdk/gl/Noise.ts +30 -0
- package/runtime/sdk/gl/Particles.ts +185 -0
- package/runtime/sdk/gl/Physics.ts +95 -0
- package/runtime/sdk/gl/Plane.ts +53 -0
- package/runtime/sdk/gl/Ray.ts +16 -0
- package/runtime/sdk/gl/Scene.ts +179 -0
- package/runtime/sdk/gl/Texture.ts +35 -0
- package/runtime/sdk/gl/controls.ts +95 -0
- package/runtime/sdk/gl/state.ts +6 -0
- package/runtime/sdk/gl/touch.ts +56 -0
- package/runtime/sdk/inject.ts +85 -0
- package/runtime/sdk/math/Mathf.ts +118 -0
- package/runtime/sdk/math/mat4.ts +278 -0
- package/runtime/sdk/math/quat.ts +232 -0
- package/runtime/sdk/math/vec.ts +255 -0
- package/runtime/sdk/plugins/qr.ts +36 -0
- package/runtime/sdk/runtime/device.ts +33 -0
- package/runtime/sdk/runtime/fetch.ts +71 -0
- package/runtime/sdk/runtime/files.ts +23 -0
- package/runtime/sdk/runtime/input.ts +12 -0
- package/runtime/sdk/runtime/media.ts +111 -0
- package/runtime/sdk/runtime/misc.ts +12 -0
- package/runtime/sdk/runtime/net.ts +36 -0
- package/runtime/sdk/runtime/storage.ts +13 -0
- package/runtime/sdk/runtime/touch.ts +50 -0
- package/runtime/sdk/ui/UI.ts +19 -0
- package/runtime/sdk/ui/UIButton.ts +84 -0
- package/runtime/sdk/ui/UIContainer.ts +100 -0
- package/runtime/sdk/ui/UIImage.ts +68 -0
- package/runtime/sdk/ui/UIInput.ts +78 -0
- package/runtime/sdk/ui/UINode.ts +342 -0
- package/runtime/sdk/ui/UIScreen.ts +122 -0
- package/runtime/sdk/ui/UIScrollable.ts +70 -0
- package/runtime/sdk/ui/UISpacer.ts +14 -0
- package/runtime/sdk/ui/UIText.ts +40 -0
- package/runtime/sdk/ui/UIVideo.ts +38 -0
- package/runtime/sdk/ui/UIVirtualizedList.ts +222 -0
- package/runtime/sdk/ui/UIWidget.ts +97 -0
- package/runtime/sdk/ui/fonts.ts +10 -0
- package/runtime/sdk/ui/router.ts +73 -0
- package/runtime/inject.js +0 -4629
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a Shape does not by itself
|
|
2
|
+
// make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a Trigger (sensor), both
|
|
3
|
+
// of which require a Shape 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 Shape + Physics2D/Trigger (a body to query).
|
|
5
|
+
//
|
|
6
|
+
// wall.aspect(Shape, { box: [200, 16] }).aspect(Physics2D, { motion: 'static' })
|
|
7
|
+
// goal.aspect(Shape, { circle: 20 }).aspect(Trigger)
|
|
8
|
+
// hero.aspect(Shape, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } }).aspect(Physics2D, { motion: 'dynamic' })
|
|
9
|
+
//
|
|
10
|
+
// With no geometry set, Shape derives a box from the node's sprite size ("auto").
|
|
11
|
+
|
|
12
|
+
import { Aspect } from "../core/Aspect"
|
|
13
|
+
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
|
|
14
|
+
import type { Node2D } from "./Node2D"
|
|
15
|
+
|
|
16
|
+
export class Shape extends Aspect<"shape", Node2D> {
|
|
17
|
+
static readonly aspect = "shape"
|
|
18
|
+
|
|
19
|
+
/** Box half-extents [hw, hh] (world units). */
|
|
20
|
+
box?: Vec2Like
|
|
21
|
+
/** Circle radius. */
|
|
22
|
+
circle?: number
|
|
23
|
+
/** Capsule between two local points, with a radius. */
|
|
24
|
+
capsule?: { from: Vec2Like, to: Vec2Like, radius: number }
|
|
25
|
+
/** Thin segment (an edge — for static ground/walls/slopes) between two local points. */
|
|
26
|
+
segment?: { from: Vec2Like, to: Vec2Like }
|
|
27
|
+
/** Convex polygon, up to 8 local points (a convex hull is computed). */
|
|
28
|
+
polygon?: Vec2Like[]
|
|
29
|
+
/** Local offset of the shape from the node origin. */
|
|
30
|
+
offset?: Vec2Like
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @internal — add this geometry as a fixture to `handle` (a body created by Physics2D/Trigger).
|
|
34
|
+
* `sensor` selects a sensor (trigger) vs solid fixture; density/friction/restitution are the
|
|
35
|
+
* material (ignored for sensors / segments only use friction+restitution).
|
|
36
|
+
*/
|
|
37
|
+
_addTo(handle: number, sensor: boolean, density: number, friction: number, restitution: number): void {
|
|
38
|
+
const ox = this.offset ? cx(this.offset) : 0
|
|
39
|
+
const oy = this.offset ? cy(this.offset) : 0
|
|
40
|
+
if (this.circle !== undefined) {
|
|
41
|
+
_creator2d.physicsAddCircle(handle, this.circle, ox, oy, density, friction, restitution, sensor)
|
|
42
|
+
} else if (this.capsule) {
|
|
43
|
+
const { from, to, radius } = this.capsule
|
|
44
|
+
_creator2d.physicsAddCapsule(handle, cx(from), cy(from), cx(to), cy(to), radius, density, friction, restitution, sensor)
|
|
45
|
+
} else if (this.segment) {
|
|
46
|
+
_creator2d.physicsAddSegment(handle, cx(this.segment.from), cy(this.segment.from), cx(this.segment.to), cy(this.segment.to), friction, restitution)
|
|
47
|
+
} else if (this.polygon) {
|
|
48
|
+
const pts = new Float32Array(this.polygon.length * 2)
|
|
49
|
+
this.polygon.forEach((p, i) => { pts[i * 2] = cx(p); pts[i * 2 + 1] = cy(p) })
|
|
50
|
+
_creator2d.physicsAddPolygon(handle, pts, this.polygon.length, density, friction, restitution, sensor)
|
|
51
|
+
} else {
|
|
52
|
+
// box: explicit half-extents, or "auto" from the node's sprite size (fallback 8×8).
|
|
53
|
+
let hw = 8, hh = 8
|
|
54
|
+
if (this.box) {
|
|
55
|
+
hw = cx(this.box); hh = cy(this.box)
|
|
56
|
+
} else {
|
|
57
|
+
const size = (this.node as { size?: Vec2 }).size
|
|
58
|
+
if (size) { hw = size.x * 0.5; hh = size.y * 0.5 }
|
|
59
|
+
}
|
|
60
|
+
_creator2d.physicsAddBox(handle, hw, hh, ox, oy, density, friction, restitution, sensor)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// A textured 2D node. Cheap setters proxy straight to the native sprite; the data lives in
|
|
2
|
+
// creator-2d. Sprite-sheet animation is no longer built in — attach the SpriteAnimation aspect:
|
|
3
|
+
// new Sprite({ texture }).aspect(SpriteAnimation, { size: [32, 48], idle: [0,1,2,3] }).anim.play('idle')
|
|
4
|
+
|
|
5
|
+
import { Color, type ColorInput } from "../core/color"
|
|
6
|
+
import { Vec2, type Vec2Like } from "../math/vec"
|
|
7
|
+
import { Node2D } from "./Node2D"
|
|
8
|
+
import type { Texture2D } from "./Texture2D"
|
|
9
|
+
|
|
10
|
+
export type SpriteOptions = {
|
|
11
|
+
texture?: Texture2D
|
|
12
|
+
/** Pivot, [0,0] = top-left .. [1,1] = bottom-right. Use [0.5, 1] (feet) for Y-sorted depth. */
|
|
13
|
+
anchor?: Vec2Like
|
|
14
|
+
/** World size; defaults to the texture's pixel dimensions. */
|
|
15
|
+
size?: Vec2Like
|
|
16
|
+
/** Normalized UV sub-rect [u0,v0,u1,v1]. */
|
|
17
|
+
frame?: [number, number, number, number]
|
|
18
|
+
/** Tint multiplied with the texture. */
|
|
19
|
+
color?: ColorInput
|
|
20
|
+
opacity?: number
|
|
21
|
+
layer?: number
|
|
22
|
+
position?: Vec2Like
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class Sprite extends Node2D {
|
|
26
|
+
private _texture?: Texture2D
|
|
27
|
+
private _anchor = new Vec2(0.5, 0.5)
|
|
28
|
+
private _color: ColorInput = 0xffffff
|
|
29
|
+
private _opacity = 1
|
|
30
|
+
private _flipX = false
|
|
31
|
+
private _flipY = false
|
|
32
|
+
private _size: Vec2 | null = null
|
|
33
|
+
|
|
34
|
+
constructor(options: SpriteOptions = {}) {
|
|
35
|
+
super()
|
|
36
|
+
if (options.texture) this.texture = options.texture
|
|
37
|
+
if (options.size) this.size = options.size
|
|
38
|
+
if (options.anchor) this.anchor = options.anchor
|
|
39
|
+
if (options.frame) this.frame = options.frame
|
|
40
|
+
if (options.color !== undefined) this.color = options.color
|
|
41
|
+
if (options.opacity !== undefined) this.opacity = options.opacity
|
|
42
|
+
if (options.layer !== undefined) this.layer = options.layer
|
|
43
|
+
if (options.position) this.position = options.position
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get texture(): Texture2D | undefined { return this._texture }
|
|
47
|
+
set texture(t: Texture2D | undefined) {
|
|
48
|
+
this._texture = t
|
|
49
|
+
if (!t) return
|
|
50
|
+
_creator2d.setSprite(this.id, t.id) // native resets UV + size to the full texture
|
|
51
|
+
if (this._size) _creator2d.setSpriteSize(this.id, this._size.x, this._size.y)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Current world size: explicit override if set, else the texture's pixel dimensions. */
|
|
55
|
+
get size(): Vec2 { return this._size ?? (this._texture ? new Vec2(this._texture.width, this._texture.height) : new Vec2(0, 0)) }
|
|
56
|
+
set size(v: Vec2Like) { this._size = new Vec2(v); _creator2d.setSpriteSize(this.id, this._size.x, this._size.y) }
|
|
57
|
+
|
|
58
|
+
get anchor(): Vec2 { return this._anchor.clone() }
|
|
59
|
+
set anchor(v: Vec2Like) { this._anchor = new Vec2(v); _creator2d.setSpriteAnchor(this.id, this._anchor.x, this._anchor.y) }
|
|
60
|
+
|
|
61
|
+
/** Set the UV sub-rect (normalized). */
|
|
62
|
+
set frame(uv: [number, number, number, number]) { _creator2d.setSpriteFrame(this.id, uv[0], uv[1], uv[2], uv[3]) }
|
|
63
|
+
/** Set the sub-rect in texture pixels. */
|
|
64
|
+
setFramePx(px: number, py: number, pw: number, ph: number): this {
|
|
65
|
+
_creator2d.setSpriteFramePx(this.id, px, py, pw, ph)
|
|
66
|
+
return this
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get color(): ColorInput { return this._color }
|
|
70
|
+
set color(c: ColorInput) { this._color = c; const [ r, g, b ] = Color.toRgb01(c); _creator2d.setSpriteColor(this.id, r, g, b) }
|
|
71
|
+
|
|
72
|
+
get opacity(): number { return this._opacity }
|
|
73
|
+
set opacity(a: number) { this._opacity = a; _creator2d.setSpriteOpacity(this.id, a) }
|
|
74
|
+
|
|
75
|
+
get flipX(): boolean { return this._flipX }
|
|
76
|
+
set flipX(v: boolean) { this._flipX = v; _creator2d.setSpriteFlip(this.id, v, this._flipY) }
|
|
77
|
+
get flipY(): boolean { return this._flipY }
|
|
78
|
+
set flipY(v: boolean) { this._flipY = v; _creator2d.setSpriteFlip(this.id, this._flipX, v) }
|
|
79
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Sprite-sheet animation as an aspect on a Sprite. Configured as DATA (frame size + named clips) in
|
|
2
|
+
// the attach call, then driven by name:
|
|
3
|
+
// sprite.aspect(SpriteAnimation, { size: [32, 48], fps: 10, clips: { idle: [0,1], walk: [4,5,6,7] } })
|
|
4
|
+
// sprite.anim.play('walk')
|
|
5
|
+
// Once defined, clips advance entirely in native code; the aspect just translates clip ids back to
|
|
6
|
+
// names for the node's 'loopReached' / 'completed' events.
|
|
7
|
+
|
|
8
|
+
import { Aspect } from "../core/Aspect"
|
|
9
|
+
import { ensureAnimEvents } from "./loop"
|
|
10
|
+
import type { Sprite } from "./Sprite"
|
|
11
|
+
|
|
12
|
+
/** Per-clip frame list, or an object for per-clip fps/loop overrides. */
|
|
13
|
+
export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean }
|
|
14
|
+
|
|
15
|
+
export class SpriteAnimation extends Aspect<"anim", Sprite> {
|
|
16
|
+
static readonly aspect = "anim"
|
|
17
|
+
|
|
18
|
+
/** Grid cell size in texture pixels (defaults to the full texture). */
|
|
19
|
+
size?: [number, number]
|
|
20
|
+
/** Default fps for clips that don't override it. */
|
|
21
|
+
fps = 12
|
|
22
|
+
/** Default loop for clips that don't override it. */
|
|
23
|
+
loop = true
|
|
24
|
+
/** Named clips; frame indices are row-major into the grid. Set via the attach opts. */
|
|
25
|
+
clips: Record<string, Clip> = {}
|
|
26
|
+
|
|
27
|
+
private _clipIds: Record<string, number> = {}
|
|
28
|
+
private _clipNames: Record<number, string> = {}
|
|
29
|
+
private _current: string | null = null
|
|
30
|
+
|
|
31
|
+
onAttach(): void {
|
|
32
|
+
this._define()
|
|
33
|
+
// route native animation events for this entity to named 'loopReached'/'completed' events.
|
|
34
|
+
;(this.node as { _dispatchAnimEvent: (clipId: number, type: number) => void })._dispatchAnimEvent =
|
|
35
|
+
(clipId, type) => this.node._emitAnim(type === 1 ? "completed" : "loopReached", this._clipNames[clipId] ?? "")
|
|
36
|
+
ensureAnimEvents()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** (Re)build native clips from `this.clips`. Called on attach; call again after changing clips. */
|
|
40
|
+
define(): this { this._define(); return this }
|
|
41
|
+
|
|
42
|
+
private _define(): void {
|
|
43
|
+
const node = this.node
|
|
44
|
+
const tex = node.texture
|
|
45
|
+
if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before adding the aspect")
|
|
46
|
+
const [ fw, fh ] = this.size ?? [ tex.width, tex.height ]
|
|
47
|
+
const cols = Math.max(1, Math.floor(tex.width / fw))
|
|
48
|
+
|
|
49
|
+
for (const name of Object.keys(this.clips)) {
|
|
50
|
+
const value = this.clips[name]
|
|
51
|
+
const indices = Array.isArray(value) ? value : value.frames
|
|
52
|
+
const fps = Array.isArray(value) ? this.fps : (value.fps ?? this.fps)
|
|
53
|
+
const loop = Array.isArray(value) ? this.loop : (value.loop ?? this.loop)
|
|
54
|
+
|
|
55
|
+
const frames = new Float32Array(indices.length * 4)
|
|
56
|
+
indices.forEach((idx, i) => {
|
|
57
|
+
const px = (idx % cols) * fw
|
|
58
|
+
const py = Math.floor(idx / cols) * fh
|
|
59
|
+
frames[i * 4 + 0] = px / tex.width
|
|
60
|
+
frames[i * 4 + 1] = py / tex.height
|
|
61
|
+
frames[i * 4 + 2] = (px + fw) / tex.width
|
|
62
|
+
frames[i * 4 + 3] = (py + fh) / tex.height
|
|
63
|
+
})
|
|
64
|
+
const clipId = _creator2d.defineAnimation(node.id, frames, fps, loop)
|
|
65
|
+
this._clipIds[name] = clipId
|
|
66
|
+
this._clipNames[clipId] = name
|
|
67
|
+
}
|
|
68
|
+
if (this.size) _creator2d.setSpriteSize(node.id, fw, fh)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Play a clip by name. Re-playing the active clip is a no-op (keeps it running smoothly). */
|
|
72
|
+
play(name: string): this {
|
|
73
|
+
const id = this._clipIds[name]
|
|
74
|
+
if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${name}"`)
|
|
75
|
+
if (this._current === name) return this
|
|
76
|
+
this._current = name
|
|
77
|
+
_creator2d.playAnimation(this.node.id, id)
|
|
78
|
+
return this
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
stop(): this {
|
|
82
|
+
this._current = null
|
|
83
|
+
_creator2d.stopAnimation(this.node.id)
|
|
84
|
+
return this
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
set speed(value: number) { _creator2d.setAnimationSpeed(this.node.id, value) }
|
|
88
|
+
get current(): string | null { return this._current }
|
|
89
|
+
get frame(): number { return _creator2d.getAnimationFrame(this.node.id) }
|
|
90
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// A GPU texture decoded natively by creator-2d (stb_image). Load from a URL (or an already-fetched
|
|
2
|
+
// response); the raw file bytes are handed to the engine, which decodes + uploads them.
|
|
3
|
+
|
|
4
|
+
// Internal SDK code imports injected globals explicitly (the bare-global inject is only for USER
|
|
5
|
+
// code; here `fetch` would otherwise resolve to the node/bun ambient one).
|
|
6
|
+
import { fetch, type FetchResponse } from "../runtime/fetch"
|
|
7
|
+
|
|
8
|
+
export class Texture2D {
|
|
9
|
+
/** Native texture handle. */
|
|
10
|
+
readonly id: number
|
|
11
|
+
readonly width: number
|
|
12
|
+
readonly height: number
|
|
13
|
+
|
|
14
|
+
constructor(id: number, width: number, height: number) {
|
|
15
|
+
this.id = id
|
|
16
|
+
this.width = width
|
|
17
|
+
this.height = height
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
static load(source: string | FetchResponse): Promise<Texture2D> {
|
|
21
|
+
if (typeof source === "string") {
|
|
22
|
+
return fetch(source, { useOnce: true }).then((resp) => {
|
|
23
|
+
if (resp.status >= 400) {
|
|
24
|
+
return Promise.reject(new Error(`Failed to fetch texture from ${source}. HTTP ${resp.status}`))
|
|
25
|
+
}
|
|
26
|
+
return Texture2D.load(resp)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
return new Promise<Texture2D>((resolve, reject) => {
|
|
30
|
+
_creator2d.createTexture((source as any).id ?? (source as any)._id, (id, w, h) => resolve(new Texture2D(id, w, h)), reject)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
destroy(): void {
|
|
35
|
+
_creator2d.destroyTexture(this.id)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A tilemap node. The grid is uploaded once and rendered natively with per-frame view culling; the
|
|
2
|
+
// node's position is the bottom-left of the map. Edit cells with setTile.
|
|
3
|
+
|
|
4
|
+
import { Node2D } from "./Node2D"
|
|
5
|
+
import type { Texture2D } from "./Texture2D"
|
|
6
|
+
|
|
7
|
+
export type TilemapOptions = {
|
|
8
|
+
texture: Texture2D
|
|
9
|
+
/** Map grid size (in tiles). */
|
|
10
|
+
cols: number
|
|
11
|
+
rows: number
|
|
12
|
+
/** On-screen cell size in world units — a single number (square) or [w, h]. */
|
|
13
|
+
tile: number | [number, number]
|
|
14
|
+
/** Atlas grid: how the texture is sliced into tiles, [atlasCols, atlasRows]. */
|
|
15
|
+
atlas: [number, number]
|
|
16
|
+
/** Row-major tile indices into the atlas (-1 = empty), length cols*rows. Row 0 is the top row. */
|
|
17
|
+
data: number[] | Int32Array
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class Tilemap extends Node2D {
|
|
21
|
+
readonly cols: number
|
|
22
|
+
readonly rows: number
|
|
23
|
+
|
|
24
|
+
constructor(options: TilemapOptions) {
|
|
25
|
+
const data = options.data instanceof Int32Array ? options.data : Int32Array.from(options.data)
|
|
26
|
+
const tileW = typeof options.tile === "number" ? options.tile : options.tile[0]
|
|
27
|
+
const tileH = typeof options.tile === "number" ? options.tile : options.tile[1]
|
|
28
|
+
const [ atlasCols, atlasRows ] = options.atlas
|
|
29
|
+
const id = _creator2d.createTilemap(
|
|
30
|
+
options.texture.id, options.cols, options.rows, tileW, tileH, atlasCols, atlasRows, data,
|
|
31
|
+
)
|
|
32
|
+
super(id) // wrap the native tilemap entity
|
|
33
|
+
this.cols = options.cols
|
|
34
|
+
this.rows = options.rows
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setTile(x: number, y: number, index: number): this {
|
|
38
|
+
_creator2d.setTile(this.id, x, y, index)
|
|
39
|
+
return this
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A trigger zone, as an aspect on a Node2D. Requires a Shape (its geometry); creates a static SENSOR
|
|
2
|
+
// body from it, so a physics body overlapping the zone fires the node's 'enter' / 'exit' events
|
|
3
|
+
// instead of colliding. A trigger does not block movement.
|
|
4
|
+
//
|
|
5
|
+
// const goal = new Node2D({ ... })
|
|
6
|
+
// .aspect(Shape, { box: [32, 64] })
|
|
7
|
+
// .aspect(Trigger)
|
|
8
|
+
// goal.addEventListener('enter', other => win(other))
|
|
9
|
+
// goal.addEventListener('exit', other => ...)
|
|
10
|
+
//
|
|
11
|
+
// (A trigger is still pointer-pickable — clicks hit sensor shapes too.)
|
|
12
|
+
|
|
13
|
+
import { Aspect } from "../core/Aspect"
|
|
14
|
+
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
15
|
+
import { ensurePhysicsEvents } from "./loop"
|
|
16
|
+
import { Shape } from "./Shape"
|
|
17
|
+
import type { Node2D } from "./Node2D"
|
|
18
|
+
|
|
19
|
+
export class Trigger extends Aspect<"trigger", Node2D> {
|
|
20
|
+
static readonly aspect = "trigger"
|
|
21
|
+
|
|
22
|
+
private _h = 0
|
|
23
|
+
/** Native body handle (0 if no physics support). */
|
|
24
|
+
get handle(): number { return this._h }
|
|
25
|
+
|
|
26
|
+
onAttach(): void {
|
|
27
|
+
if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
|
|
28
|
+
const shape = this.node.get(Shape)
|
|
29
|
+
if (!shape) {
|
|
30
|
+
throw new Error("Trigger requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Trigger)")
|
|
31
|
+
}
|
|
32
|
+
this._h = _creator2d.physicsCreateBody(this.node.id, 0 /* static */)
|
|
33
|
+
if (!this._h) return
|
|
34
|
+
shape._addTo(this._h, true /* sensor */, 1, 0, 0)
|
|
35
|
+
ensurePhysicsEvents() // routes contact/sensor events → the node's 'enter' / 'exit'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
onDetach(): void {
|
|
39
|
+
if (this._h) { _creator2d.physicsRemoveBody(this._h); this._h = 0 }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Reposition the trigger zone to a world point. */
|
|
43
|
+
moveTo(p: Vec2Like): this {
|
|
44
|
+
if (this._h) _creator2d.physicsSetTransform(this._h, cx(p), cy(p), 0)
|
|
45
|
+
return this
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Internal per-frame plumbing for the 2D engine. NOT user-facing — user game logic uses the host
|
|
2
|
+
// `setLoop` global. `onUpdate` taps the engine's render-synced pre-frame hook (_creator2d.onUpdate),
|
|
3
|
+
// for anything that must move in lockstep with the native render (no 1-frame lag).
|
|
4
|
+
//
|
|
5
|
+
// The animation-event registration is lazy (first use) so a 2D project that never animates a sprite
|
|
6
|
+
// pulls none of this in.
|
|
7
|
+
|
|
8
|
+
import { node2dRegistry } from "./Node2D"
|
|
9
|
+
|
|
10
|
+
export const onUpdate = (callback: (dt: number) => void): void => _creator2d.onUpdate(callback)
|
|
11
|
+
export const offUpdate = (callback: (dt: number) => void): void => _creator2d.offUpdate(callback)
|
|
12
|
+
|
|
13
|
+
// Route native animation events (queued in C++, drained once per frame) to the owning node, which
|
|
14
|
+
// translates the native clip id to a clip name and emits 'loopReached' / 'completed'.
|
|
15
|
+
let animEventsRegistered = false
|
|
16
|
+
export const ensureAnimEvents = (): void => {
|
|
17
|
+
if (animEventsRegistered) return
|
|
18
|
+
animEventsRegistered = true
|
|
19
|
+
_creator2d.setOnAnimEvent(({ entityId, clipId, type }) => {
|
|
20
|
+
node2dRegistry.get(entityId)?._dispatchAnimEvent(clipId, type)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Route native physics contact/sensor events to the owning nodes' 'enter'/'exit' events. Each event
|
|
25
|
+
// is delivered to BOTH nodes with the other as the argument. type: 0 contactBegin, 1 contactEnd,
|
|
26
|
+
// 2 sensorBegin, 3 sensorEnd. Registered lazily on the first physics body.
|
|
27
|
+
let physicsEventsRegistered = false
|
|
28
|
+
export const ensurePhysicsEvents = (): void => {
|
|
29
|
+
if (physicsEventsRegistered) return
|
|
30
|
+
physicsEventsRegistered = true
|
|
31
|
+
_creator2d.setOnPhysicsEvent((a, b, type) => {
|
|
32
|
+
const na = node2dRegistry.get(a)
|
|
33
|
+
const nb = node2dRegistry.get(b)
|
|
34
|
+
if (!na || !nb) return
|
|
35
|
+
const channel = type === 1 || type === 3 ? "exit" : "enter"
|
|
36
|
+
na._emitCollision(channel, nb)
|
|
37
|
+
nb._emitCollision(channel, na)
|
|
38
|
+
})
|
|
39
|
+
}
|
|
@@ -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,40 @@
|
|
|
1
|
+
// The scene camera — a Node (so position / lookAt / transform all work) plus projection reads and
|
|
2
|
+
// screen↔world ray helpers. Created and attached by Scene. Ported from worker/src/components/Camera.ts.
|
|
3
|
+
|
|
4
|
+
import { Vec3 } from "../math/vec"
|
|
5
|
+
import { Node } from "./Node"
|
|
6
|
+
import { Ray } from "./Ray"
|
|
7
|
+
|
|
8
|
+
export class Camera extends Node {
|
|
9
|
+
private _sceneId = -1
|
|
10
|
+
/** Live projection matrix (updated by the host when the viewport/fov changes). */
|
|
11
|
+
readonly projectionMatrix = new Float32Array(16)
|
|
12
|
+
|
|
13
|
+
/** @internal — Scene wires the camera to its native scene id + projection callback. */
|
|
14
|
+
_attach(sceneId: number): void {
|
|
15
|
+
this._sceneId = sceneId
|
|
16
|
+
_creator.attachCameraToAR(sceneId, this.id, (mat) => {
|
|
17
|
+
for (let i = 0; i < 16; i++) this.projectionMatrix[i] = mat[i]
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get fov(): number { return _creator.getCameraFov(this._sceneId, 0) }
|
|
22
|
+
get horizontalFov(): number { return _creator.getCameraFov(this._sceneId, 1) }
|
|
23
|
+
get displaySize(): [number, number] {
|
|
24
|
+
const s = _creator.getDisplaySize()
|
|
25
|
+
return [ s[0], s[1] ]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** World-space ray direction through a screen pixel. */
|
|
29
|
+
getViewDirection(screenX: number, screenY: number): Vec3 {
|
|
30
|
+
if (this._sceneId < 0) throw new Error("Camera is not attached to a scene")
|
|
31
|
+
const v = new Float32Array(3)
|
|
32
|
+
_creator.getCameraViewDirection(this._sceneId, screenX, screenY, v)
|
|
33
|
+
return new Vec3(v[0], v[1], v[2])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A pick ray from the camera through a screen pixel. */
|
|
37
|
+
getRay(screenX: number, screenY: number): Ray {
|
|
38
|
+
return new Ray(this.worldPosition, this.getViewDirection(screenX, screenY))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -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
|
+
}
|