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,126 @@
|
|
|
1
|
+
// A rigid body, as an aspect on a Node2D, backed by Box2D v3 in creator-2d. Mirrors the 3D Physics
|
|
2
|
+
// aspect. Requires a Shape aspect on the same node (its geometry becomes the body's solid fixture).
|
|
3
|
+
//
|
|
4
|
+
// Physics2D.configure({ gravity: [0, -980] }) // once, before creating bodies
|
|
5
|
+
// ground.aspect(Shape, { segment: { from: [-500,0], to: [500,0] } }).aspect(Physics2D, { motion: 'static' })
|
|
6
|
+
// hero.aspect(Shape, { box: [12, 20] }).aspect(Physics2D, { motion: 'dynamic', fixedRotation: true })
|
|
7
|
+
// hero.physics.applyImpulse([0, 400]) // jump
|
|
8
|
+
// hero.addEventListener('enter', other => …) // contact / sensor overlap began
|
|
9
|
+
//
|
|
10
|
+
// For a `dynamic` body, physics OWNS the transform — don't set node.position per frame; drive it via
|
|
11
|
+
// velocity / applyImpulse. Reading node.position works: the node is flagged _physicsDriven and lazily
|
|
12
|
+
// syncs its cache from native on read. Kinematic bodies are driven with moveTo. Best practice: keep
|
|
13
|
+
// bodies on root nodes (see creator-2d PLAN.md).
|
|
14
|
+
|
|
15
|
+
import { Aspect } from "../core/Aspect"
|
|
16
|
+
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
|
|
17
|
+
import { node2dRegistry, type Node2D } from "./Node2D"
|
|
18
|
+
import { Shape } from "./Shape"
|
|
19
|
+
|
|
20
|
+
export type MotionType = "static" | "kinematic" | "dynamic"
|
|
21
|
+
const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
|
|
22
|
+
|
|
23
|
+
export interface PhysicsConfig {
|
|
24
|
+
/** Gravity in world units/s² (Y-up: down is negative). Default [0, -980]. */
|
|
25
|
+
gravity?: Vec2Like
|
|
26
|
+
/** Pixels per meter — tunes Box2D's internal tolerances; you still author in world units. Default 64. */
|
|
27
|
+
pixelsPerMeter?: number
|
|
28
|
+
/** Solver sub-steps per fixed step. Default 4. */
|
|
29
|
+
subSteps?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Closest raycast hit. */
|
|
33
|
+
export interface RayHit {
|
|
34
|
+
node: Node2D | null
|
|
35
|
+
point: Vec2
|
|
36
|
+
normal: Vec2
|
|
37
|
+
fraction: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve a native entity id to its Node2D (0 = none).
|
|
41
|
+
const node2dById = (id: number): Node2D | null => (id ? node2dRegistry.get(id) ?? null : null)
|
|
42
|
+
|
|
43
|
+
export class Physics2D extends Aspect<"physics", Node2D> {
|
|
44
|
+
static readonly aspect = "physics"
|
|
45
|
+
|
|
46
|
+
/** Default "dynamic". */
|
|
47
|
+
motion: MotionType = "dynamic"
|
|
48
|
+
density = 1
|
|
49
|
+
friction = 0.3
|
|
50
|
+
restitution = 0
|
|
51
|
+
/** Lock rotation (essential for platformer characters). */
|
|
52
|
+
fixedRotation = false
|
|
53
|
+
gravityScale = 1
|
|
54
|
+
/** Continuous collision for fast-moving bodies (projectiles). */
|
|
55
|
+
bullet = false
|
|
56
|
+
|
|
57
|
+
private _h = 0 // native body handle (0 = none / no physics support)
|
|
58
|
+
|
|
59
|
+
/** Native body handle. 0 until attached, or if the build has no physics support. */
|
|
60
|
+
get handle(): number { return this._h }
|
|
61
|
+
|
|
62
|
+
onAttach(): void {
|
|
63
|
+
if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
|
|
64
|
+
const shape = this.node.get(Shape)
|
|
65
|
+
if (!shape) {
|
|
66
|
+
throw new Error("Physics2D requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics2D, {…})")
|
|
67
|
+
}
|
|
68
|
+
this._h = _creator2d.physicsCreateBody(this.node.id, MOTION[this.motion])
|
|
69
|
+
if (!this._h) return
|
|
70
|
+
shape._addTo(this._h, false /* solid */, this.density, this.friction, this.restitution)
|
|
71
|
+
// dynamic/kinematic bodies are moved by the native step → the node must re-sync its transform
|
|
72
|
+
// cache from native on read. Static bodies never move, so they keep the free cache.
|
|
73
|
+
if (this.motion !== "static") this.node._physicsDriven = true
|
|
74
|
+
if (this.fixedRotation) _creator2d.physicsSetFixedRotation(this._h, true)
|
|
75
|
+
if (this.gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
|
|
76
|
+
if (this.bullet) _creator2d.physicsSetBullet(this._h, true)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onDetach(): void {
|
|
80
|
+
if (this._h) { _creator2d.physicsRemoveBody(this._h); this._h = 0 }
|
|
81
|
+
this.node._physicsDriven = false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Linear velocity in world units/second (fresh Vec2 on read). */
|
|
85
|
+
get velocity(): Vec2 {
|
|
86
|
+
const o = this._h ? _creator2d.physicsGetLinearVelocity(this._h) : null
|
|
87
|
+
return o ? new Vec2(o[0], o[1]) : new Vec2(0, 0)
|
|
88
|
+
}
|
|
89
|
+
set velocity(v: Vec2Like) { if (this._h) _creator2d.physicsSetLinearVelocity(this._h, cx(v), cy(v)) }
|
|
90
|
+
|
|
91
|
+
/** Angular velocity in degrees/second. */
|
|
92
|
+
set angularVelocity(degPerSec: number) { if (this._h) _creator2d.physicsSetAngularVelocity(this._h, degPerSec) }
|
|
93
|
+
|
|
94
|
+
set gravityFactor(scale: number) { this.gravityScale = scale; if (this._h) _creator2d.physicsSetGravityScale(this._h, scale) }
|
|
95
|
+
set linearDamping(d: number) { if (this._h) _creator2d.physicsSetLinearDamping(this._h, d) }
|
|
96
|
+
set enabled(v: boolean) { if (this._h) _creator2d.physicsSetEnabled(this._h, v) }
|
|
97
|
+
|
|
98
|
+
/** Apply an instantaneous impulse (world units) and wake the body. */
|
|
99
|
+
applyImpulse(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyLinearImpulse(this._h, cx(v), cy(v)); return this }
|
|
100
|
+
/** Apply a continuous force (world units) and wake the body. */
|
|
101
|
+
applyForce(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyForce(this._h, cx(v), cy(v)); return this }
|
|
102
|
+
/** Teleport / drive a kinematic (or dynamic) body to a world position (+ optional rotation degrees). */
|
|
103
|
+
moveTo(p: Vec2Like, deg = 0): this { if (this._h) _creator2d.physicsSetTransform(this._h, cx(p), cy(p), deg); return this }
|
|
104
|
+
|
|
105
|
+
// --- world (static) -------------------------------------------------------------------------
|
|
106
|
+
/** Create / reset the physics world. Call once before creating bodies. */
|
|
107
|
+
static configure(config: PhysicsConfig = {}): void {
|
|
108
|
+
const g = config.gravity ?? [0, -980]
|
|
109
|
+
_creator2d.physicsConfigure(cx(g), cy(g), config.pixelsPerMeter ?? 64, config.subSteps ?? 4)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether this build has physics support (CREATOR_2D_PHYSICS). */
|
|
113
|
+
static get supported(): boolean { return !!_creator2d.physicsHasSupport && _creator2d.physicsHasSupport() }
|
|
114
|
+
|
|
115
|
+
/** Closest body hit by the segment from→to, or null. `node` resolves the hit entity. */
|
|
116
|
+
static raycast(from: Vec2Like, to: Vec2Like): RayHit | null {
|
|
117
|
+
const o = _creator2d.physicsRaycastClosest(cx(from), cy(from), cx(to), cy(to))
|
|
118
|
+
if (o[0] < 0.5) return null
|
|
119
|
+
return { node: node2dById(o[1]), point: new Vec2(o[2], o[3]), normal: new Vec2(o[4], o[5]), fraction: o[6] }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Topmost body (solid or sensor) whose shape contains the world point, or null. */
|
|
123
|
+
static overlapPoint(p: Vec2Like): Node2D | null {
|
|
124
|
+
return node2dById(_creator2d.physicsOverlapPoint(cx(p), cy(p)))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -2,8 +2,28 @@
|
|
|
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"
|
|
6
|
+
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
7
|
+
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
|
|
5
8
|
import { Camera2D } from "./Camera2D"
|
|
6
|
-
import type
|
|
9
|
+
import { node2dRegistry, type Node2D } from "./Node2D"
|
|
10
|
+
import { ensurePointerEvents } from "./touch"
|
|
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
|
+
}
|
|
7
27
|
|
|
8
28
|
export type Scene2DOptions = {
|
|
9
29
|
/** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
|
|
@@ -30,7 +50,12 @@ export class Scene2D {
|
|
|
30
50
|
private static _active: Scene2D | null = null
|
|
31
51
|
static get active(): Scene2D | null { return Scene2D._active }
|
|
32
52
|
|
|
53
|
+
/** @internal — scene-level pointer listeners (the catch-all; target = hit node or null). */
|
|
54
|
+
readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
|
|
55
|
+
readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
|
|
56
|
+
|
|
33
57
|
constructor(options: Scene2DOptions = {}) {
|
|
58
|
+
installRenderSyncedFrames()
|
|
34
59
|
this.id = _creator2d.createScene()
|
|
35
60
|
this.camera = new Camera2D(this.id)
|
|
36
61
|
if (options.background !== undefined) this.background = options.background
|
|
@@ -46,6 +71,27 @@ export class Scene2D {
|
|
|
46
71
|
return new LayerHandle(this.id, n)
|
|
47
72
|
}
|
|
48
73
|
|
|
74
|
+
/** Topmost visible sprite whose world bounds contain the world point, or null. Body-free
|
|
75
|
+
* hit-test for tap/click picking — pair with camera.screenToWorld(). */
|
|
76
|
+
pick(worldPoint: Vec2Like): Node2D | null {
|
|
77
|
+
const id = _creator2d.pickSprite(this.id, cx(worldPoint), cy(worldPoint))
|
|
78
|
+
return id ? node2dRegistry.get(id) ?? null : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Scene-level pointer events. The handler fires for every tap/press in the scene, with
|
|
82
|
+
* `ev.target` = the hit Physics2D node (or null). On 'touchstart' you can ev.track(...) a drag. */
|
|
83
|
+
addEventListener(channel: "click", callback: (ev: ClickEvent<Node2D | null>) => void): void
|
|
84
|
+
addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<Node2D | null>) => void): void
|
|
85
|
+
addEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
|
|
86
|
+
const list = channel === "click" ? this._clickListeners : this._touchStartListeners
|
|
87
|
+
if (!list.includes(callback)) { list.push(callback); ensurePointerEvents() }
|
|
88
|
+
}
|
|
89
|
+
removeEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
|
|
90
|
+
const list = channel === "click" ? this._clickListeners : this._touchStartListeners
|
|
91
|
+
const i = list.indexOf(callback)
|
|
92
|
+
if (i >= 0) list.splice(i, 1)
|
|
93
|
+
}
|
|
94
|
+
|
|
49
95
|
add(...nodes: Node2D[]): this {
|
|
50
96
|
for (const n of nodes) _creator2d.addEntityToScene(this.id, n.id)
|
|
51
97
|
return this
|
|
@@ -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
|
+
}
|
package/runtime/sdk/g2/Sprite.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// A textured 2D node. Cheap setters proxy straight to the native sprite; the data lives in
|
|
2
|
-
// creator-2d. Sprite-sheet animation is built in
|
|
3
|
-
// new Sprite({ texture }).
|
|
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
4
|
|
|
5
5
|
import { Color, type ColorInput } from "../core/color"
|
|
6
6
|
import { Vec2, type Vec2Like } from "../math/vec"
|
|
7
|
-
import {
|
|
7
|
+
import { Canvas, isCanvas } from "../canvas/Canvas"
|
|
8
8
|
import { Node2D } from "./Node2D"
|
|
9
9
|
import type { Texture2D } from "./Texture2D"
|
|
10
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
|
+
|
|
11
14
|
export type SpriteOptions = {
|
|
12
|
-
texture?:
|
|
15
|
+
texture?: TextureSource
|
|
13
16
|
/** Pivot, [0,0] = top-left .. [1,1] = bottom-right. Use [0.5, 1] (feet) for Y-sorted depth. */
|
|
14
17
|
anchor?: Vec2Like
|
|
15
|
-
/** World size; defaults to the texture's pixel dimensions
|
|
18
|
+
/** World size; defaults to the texture's pixel dimensions. */
|
|
16
19
|
size?: Vec2Like
|
|
17
20
|
/** Normalized UV sub-rect [u0,v0,u1,v1]. */
|
|
18
21
|
frame?: [number, number, number, number]
|
|
@@ -23,20 +26,6 @@ export type SpriteOptions = {
|
|
|
23
26
|
position?: Vec2Like
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
/** Per-clip frame list, or an object for per-clip fps/loop overrides. */
|
|
27
|
-
export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean }
|
|
28
|
-
|
|
29
|
-
/** Animation definition: the grid frame size + named clips (+ optional shared fps/loop defaults). */
|
|
30
|
-
export type SpriteClips = {
|
|
31
|
-
/** Grid cell size in texture pixels. */
|
|
32
|
-
size: [number, number]
|
|
33
|
-
/** Default fps for clips that don't override it (default 12). */
|
|
34
|
-
fps?: number
|
|
35
|
-
/** Default loop for clips that don't override it (default true). */
|
|
36
|
-
loop?: boolean
|
|
37
|
-
[clip: string]: Clip | [number, number] | number | boolean | undefined
|
|
38
|
-
}
|
|
39
|
-
|
|
40
29
|
export class Sprite extends Node2D {
|
|
41
30
|
private _texture?: Texture2D
|
|
42
31
|
private _anchor = new Vec2(0.5, 0.5)
|
|
@@ -46,10 +35,6 @@ export class Sprite extends Node2D {
|
|
|
46
35
|
private _flipY = false
|
|
47
36
|
private _size: Vec2 | null = null
|
|
48
37
|
|
|
49
|
-
private _clipIds: Record<string, number> = {}
|
|
50
|
-
private _clipNames: Record<number, string> = {}
|
|
51
|
-
private _current: string | null = null
|
|
52
|
-
|
|
53
38
|
constructor(options: SpriteOptions = {}) {
|
|
54
39
|
super()
|
|
55
40
|
if (options.texture) this.texture = options.texture
|
|
@@ -63,10 +48,11 @@ export class Sprite extends Node2D {
|
|
|
63
48
|
}
|
|
64
49
|
|
|
65
50
|
get texture(): Texture2D | undefined { return this._texture }
|
|
66
|
-
set texture(t:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 texture
|
|
70
56
|
if (this._size) _creator2d.setSpriteSize(this.id, this._size.x, this._size.y)
|
|
71
57
|
}
|
|
72
58
|
|
|
@@ -95,70 +81,4 @@ export class Sprite extends Node2D {
|
|
|
95
81
|
set flipX(v: boolean) { this._flipX = v; _creator2d.setSpriteFlip(this.id, v, this._flipY) }
|
|
96
82
|
get flipY(): boolean { return this._flipY }
|
|
97
83
|
set flipY(v: boolean) { this._flipY = v; _creator2d.setSpriteFlip(this.id, this._flipX, v) }
|
|
98
|
-
|
|
99
|
-
// --- animation (chainable) ---
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Define named sprite-sheet clips from a uniform grid. Frame indices are row-major into the grid.
|
|
103
|
-
* Sets the sprite's display size to one cell. Once defined, clips advance entirely in native code.
|
|
104
|
-
*/
|
|
105
|
-
clips(def: SpriteClips): this {
|
|
106
|
-
const tex = this._texture
|
|
107
|
-
if (!tex) throw new Error("Sprite.clips(): set a texture before defining clips")
|
|
108
|
-
const [ fw, fh ] = def.size
|
|
109
|
-
const defFps = typeof def.fps === "number" ? def.fps : 12
|
|
110
|
-
const defLoop = typeof def.loop === "boolean" ? def.loop : true
|
|
111
|
-
const cols = Math.max(1, Math.floor(tex.width / fw))
|
|
112
|
-
|
|
113
|
-
for (const name of Object.keys(def)) {
|
|
114
|
-
if (name === "size" || name === "fps" || name === "loop") continue
|
|
115
|
-
const value = def[name] as Clip
|
|
116
|
-
const indices = Array.isArray(value) ? value : value.frames
|
|
117
|
-
const fps = Array.isArray(value) ? defFps : (value.fps ?? defFps)
|
|
118
|
-
const loop = Array.isArray(value) ? defLoop : (value.loop ?? defLoop)
|
|
119
|
-
|
|
120
|
-
const frames = new Float32Array(indices.length * 4)
|
|
121
|
-
indices.forEach((idx, i) => {
|
|
122
|
-
const cx = (idx % cols) * fw
|
|
123
|
-
const cy = Math.floor(idx / cols) * fh
|
|
124
|
-
frames[i * 4 + 0] = cx / tex.width
|
|
125
|
-
frames[i * 4 + 1] = cy / tex.height
|
|
126
|
-
frames[i * 4 + 2] = (cx + fw) / tex.width
|
|
127
|
-
frames[i * 4 + 3] = (cy + fh) / tex.height
|
|
128
|
-
})
|
|
129
|
-
const clipId = _creator2d.defineAnimation(this.id, frames, fps, loop)
|
|
130
|
-
this._clipIds[name] = clipId
|
|
131
|
-
this._clipNames[clipId] = name
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (!this._size) _creator2d.setSpriteSize(this.id, fw, fh)
|
|
135
|
-
ensureAnimEvents()
|
|
136
|
-
return this
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Play a clip by name. Re-playing the active clip is a no-op (keeps it running smoothly). */
|
|
140
|
-
play(name: string): this {
|
|
141
|
-
const id = this._clipIds[name]
|
|
142
|
-
if (id === undefined) throw new Error(`Sprite.play(): unknown clip "${name}"`)
|
|
143
|
-
if (this._current === name) return this
|
|
144
|
-
this._current = name
|
|
145
|
-
_creator2d.playAnimation(this.id, id)
|
|
146
|
-
return this
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
stop(): this {
|
|
150
|
-
this._current = null
|
|
151
|
-
_creator2d.stopAnimation(this.id)
|
|
152
|
-
return this
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
set animationSpeed(value: number) { _creator2d.setAnimationSpeed(this.id, value) }
|
|
156
|
-
get currentClip(): string | null { return this._current }
|
|
157
|
-
get animationFrame(): number { return _creator2d.getAnimationFrame(this.id) }
|
|
158
|
-
|
|
159
|
-
/** @internal — translate a native clip id back to its name and emit the animation event. */
|
|
160
|
-
override _dispatchAnimEvent(clipId: number, type: number): void {
|
|
161
|
-
const name = this._clipNames[clipId] ?? ""
|
|
162
|
-
this.dispatch(type === 1 ? "completed" : "loopReached", name)
|
|
163
|
-
}
|
|
164
84
|
}
|
|
@@ -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
|
+
}
|
|
@@ -17,6 +17,14 @@ export class Texture2D {
|
|
|
17
17
|
this.height = height
|
|
18
18
|
}
|
|
19
19
|
|
|
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
|
+
|
|
20
28
|
static load(source: string | FetchResponse): Promise<Texture2D> {
|
|
21
29
|
if (typeof source === "string") {
|
|
22
30
|
return fetch(source, { useOnce: true }).then((resp) => {
|
|
@@ -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
|
+
}
|
package/runtime/sdk/g2/loop.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Internal per-frame plumbing for the 2D engine. NOT user-facing — user game logic uses the host
|
|
2
2
|
// `setLoop` global. `onUpdate` taps the engine's render-synced pre-frame hook (_creator2d.onUpdate),
|
|
3
|
-
//
|
|
3
|
+
// for anything that must move in lockstep with the native render (no 1-frame lag).
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
7
|
|
|
8
8
|
import { node2dRegistry } from "./Node2D"
|
|
9
9
|
|
|
@@ -20,3 +20,20 @@ export const ensureAnimEvents = (): void => {
|
|
|
20
20
|
node2dRegistry.get(entityId)?._dispatchAnimEvent(clipId, type)
|
|
21
21
|
})
|
|
22
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
|
+
}
|