lecodes-cli 0.11.0 → 0.12.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/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "dependencies": {
5
- "@letary/chisel": "^0.6.0"
5
+ "@letary/chisel": "^0.6.0",
6
+ "jimp": "^1.6.1"
6
7
  },
7
8
  "devDependencies": {
8
9
  "lecodes-design": "workspace:*",
@@ -12,7 +13,7 @@
12
13
  "sdk": "workspace:*"
13
14
  },
14
15
  "peerDependencies": {
15
- "lecodes-design": "^0.3.2",
16
+ "lecodes-design": "^0.4.0",
16
17
  "lecodes-renderer": "^0.7.1",
17
18
  "lecodes-3d-editor": "^0.1.0"
18
19
  },
@@ -39,6 +39,9 @@ export class Node2D extends AspectHost<Node2DEvents> {
39
39
  /** Native entity handle. */
40
40
  readonly id: number
41
41
 
42
+ /** Optional debug/editor name (plain JS data — scene2d files stamp their node names here). */
43
+ name?: string
44
+
42
45
  protected _x = 0
43
46
  protected _y = 0
44
47
  protected _rotation = 0
@@ -4,32 +4,55 @@
4
4
  // sprite.anim.play('walk')
5
5
  // Once defined, clips advance entirely in native code; the aspect just translates clip ids back to
6
6
  // names for the node's 'loopReached' / 'completed' events.
7
+ //
8
+ // Directional sheets (one texture row per facing): set `directions` in row order and mark clips
9
+ // `perDirection` — each expands to `name_DIR` with the frames offset by that row. Then
10
+ // `play('walk', 'SE')` or `play('walk', moveVector)` picks the row (compass tokens: E/NE/N/NW/W/SW/S/SE),
11
+ // and the last direction sticks, so a later `play('idle')` keeps facing.
12
+ //
13
+ // The native clips need the texture's dimensions, so when the Sprite has no texture yet (SpriteSheet's
14
+ // deferred load), the define is postponed: play() calls queue by name and start once define() runs.
7
15
 
8
16
  import { Aspect } from "../core/Aspect"
17
+ import { cx, cy, type Vec2Like } from "../math/vec"
9
18
  import { ensureAnimEvents } from "./loop"
10
19
  import type { Sprite } from "./Sprite"
11
20
 
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 }
21
+ /** Per-clip frame list, or an object for per-clip fps/loop/direction overrides. */
22
+ export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean, perDirection?: boolean }
23
+
24
+ // Compass angles in Y-up world degrees (E = +x, N = +y) for vector → direction resolution.
25
+ const COMPASS: Record<string, number> = { E: 0, NE: 45, N: 90, NW: 135, W: 180, SW: 225, S: 270, SE: 315 }
14
26
 
15
27
  export class SpriteAnimation extends Aspect<"anim", Sprite> {
16
28
  static readonly aspect = "anim"
17
29
 
18
30
  /** Grid cell size in texture pixels (defaults to the full texture). */
19
31
  size?: [number, number]
32
+ /** Grid columns override — for sheets with padding; defaults to floor(texWidth / cellWidth). */
33
+ cols?: number
34
+ /** Pixel origin of the grid in the texture — cells index from here (a SpriteSheet region). */
35
+ origin?: [number, number]
20
36
  /** Default fps for clips that don't override it. */
21
37
  fps = 12
22
38
  /** Default loop for clips that don't override it. */
23
39
  loop = true
24
40
  /** Named clips; frame indices are row-major into the grid. Set via the attach opts. */
25
41
  clips: Record<string, Clip> = {}
42
+ /** Facing names in texture-row order (row i = directions[i]) for `perDirection` clips. */
43
+ directions?: string[]
26
44
 
27
45
  private _clipIds: Record<string, number> = {}
28
46
  private _clipNames: Record<number, string> = {}
29
47
  private _current: string | null = null
48
+ private _defined = false
49
+ private _queued: string | null = null
50
+ private _dir: string | null = null
30
51
 
31
52
  onAttach(): void {
32
- this._define()
53
+ // No texture yet (deferred sheet load): postpone the native define — SpriteSheet (or the user)
54
+ // calls define() after assigning the texture; play() calls queue until then.
55
+ if (this.node.texture) this._define()
33
56
  // route native animation events for this entity to named 'loopReached'/'completed' events.
34
57
  ;(this.node as { _dispatchAnimEvent: (clipId: number, type: number) => void })._dispatchAnimEvent =
35
58
  (clipId, type) => this.node._emitAnim(type === 1 ? "completed" : "loopReached", this._clipNames[clipId] ?? "")
@@ -42,20 +65,16 @@ export class SpriteAnimation extends Aspect<"anim", Sprite> {
42
65
  private _define(): void {
43
66
  const node = this.node
44
67
  const tex = node.texture
45
- if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before adding the aspect")
68
+ if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before defining clips")
46
69
  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)
70
+ const cols = this.cols ?? Math.max(1, Math.floor(tex.width / fw))
71
+ const [ ox, oy ] = this.origin ?? [ 0, 0 ]
54
72
 
73
+ const defineOne = (name: string, indices: number[], fps: number, loop: boolean): void => {
55
74
  const frames = new Float32Array(indices.length * 4)
56
75
  indices.forEach((idx, i) => {
57
- const px = (idx % cols) * fw
58
- const py = Math.floor(idx / cols) * fh
76
+ const px = ox + (idx % cols) * fw
77
+ const py = oy + Math.floor(idx / cols) * fh
59
78
  frames[i * 4 + 0] = px / tex.width
60
79
  frames[i * 4 + 1] = py / tex.height
61
80
  frames[i * 4 + 2] = (px + fw) / tex.width
@@ -65,26 +84,88 @@ export class SpriteAnimation extends Aspect<"anim", Sprite> {
65
84
  this._clipIds[name] = clipId
66
85
  this._clipNames[clipId] = name
67
86
  }
87
+
88
+ for (const name of Object.keys(this.clips)) {
89
+ const value = this.clips[name]
90
+ const indices = Array.isArray(value) ? value : value.frames
91
+ const fps = Array.isArray(value) ? this.fps : (value.fps ?? this.fps)
92
+ const loop = Array.isArray(value) ? this.loop : (value.loop ?? this.loop)
93
+ const dirs = !Array.isArray(value) && value.perDirection ? this.directions : undefined
94
+
95
+ if (dirs && dirs.length > 0) {
96
+ // one clip per facing: row i's frames are the base (row-0) frames shifted down i rows.
97
+ dirs.forEach((dir, row) => defineOne(`${name}_${dir}`, indices.map((f) => f + row * cols), fps, loop))
98
+ } else {
99
+ defineOne(name, indices, fps, loop)
100
+ }
101
+ }
68
102
  if (this.size) _creator2d.setSpriteSize(node.id, fw, fh)
103
+ this._defined = true
104
+
105
+ if (this._queued) {
106
+ const queued = this._queued
107
+ this._queued = null
108
+ const id = this._clipIds[queued]
109
+ if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${queued}"`)
110
+ _creator2d.playAnimation(node.id, id)
111
+ }
69
112
  }
70
113
 
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
114
+ /**
115
+ * Play a clip by name. Re-playing the active clip is a no-op (keeps it running smoothly).
116
+ * For a `perDirection` clip, `dir` picks the facing — a direction name or a movement vector
117
+ * (nearest compass row wins); omitted, the last direction (or the first row) is kept.
118
+ */
119
+ play(name: string, dir?: string | Vec2Like): this {
120
+ let target = name
121
+ const clip = this.clips[name]
122
+ if (clip && !Array.isArray(clip) && clip.perDirection && this.directions?.length) {
123
+ const resolved = this._resolveDir(dir)
124
+ if (resolved) { this._dir = resolved; target = `${name}_${resolved}` }
125
+ }
126
+ if (this._current === target) return this
127
+ this._current = target
128
+ if (!this._defined) { this._queued = target; return this }
129
+ const id = this._clipIds[target]
130
+ if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${target}"`)
77
131
  _creator2d.playAnimation(this.node.id, id)
78
132
  return this
79
133
  }
80
134
 
81
135
  stop(): this {
82
136
  this._current = null
83
- _creator2d.stopAnimation(this.node.id)
137
+ this._queued = null
138
+ if (this._defined) _creator2d.stopAnimation(this.node.id)
84
139
  return this
85
140
  }
86
141
 
142
+ /** Current facing (last direction resolved by play()), or null before the first directional play. */
143
+ get direction(): string | null { return this._dir }
144
+
87
145
  set speed(value: number) { _creator2d.setAnimationSpeed(this.node.id, value) }
88
146
  get current(): string | null { return this._current }
89
147
  get frame(): number { return _creator2d.getAnimationFrame(this.node.id) }
148
+
149
+ // A direction name passes through; a vector resolves to the nearest compass row this sheet has.
150
+ // A zero vector (standing still) keeps the current facing.
151
+ private _resolveDir(dir?: string | Vec2Like): string | null {
152
+ const dirs = this.directions!
153
+ if (typeof dir === "string") return dir
154
+ if (dir !== undefined) {
155
+ const x = cx(dir), y = cy(dir)
156
+ if (x !== 0 || y !== 0) {
157
+ const angle = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360
158
+ let best: string | null = null
159
+ let bestDist = Infinity
160
+ for (const d of dirs) {
161
+ const a = COMPASS[d]
162
+ if (a === undefined) continue
163
+ const dist = Math.min(Math.abs(a - angle), 360 - Math.abs(a - angle))
164
+ if (dist < bestDist) { bestDist = dist; best = d }
165
+ }
166
+ if (best) return best
167
+ }
168
+ }
169
+ return this._dir ?? dirs[0] ?? null
170
+ }
90
171
  }
@@ -0,0 +1,166 @@
1
+ // Sprite sheets as data: one `.sprite.ts` file per image, `export default defineSpriteSheet({...})`.
2
+ // ONE model: every named entry is a region of the image (a rect), optionally SLICED into an
3
+ // animation grid, optionally carrying named clips over that grid — so game code (and the scene
4
+ // editor) reference frames by NAME and never hardcode rects:
5
+ //
6
+ // import props from './props.sprite'
7
+ // import hero from './hero.sprite'
8
+ // scene.add(props.make('tree_orange', { position: [80, 144], layer: 1 }))
9
+ // scene.add(props.make('water', { position: [0, 0] })) // sliced, loops on its own
10
+ // const player = hero.make('hero', { clip: 'idle', layer: 1 })
11
+ // player.anim.play('walk', inputVector)
12
+ //
13
+ // A sheet describes ART only — rects, slices, clips, anchors. World semantics (colliders, bodies)
14
+ // are the SCENE's job: attach Shape2D / Physics2D / Trigger2D aspects to the placed node (a
15
+ // `.scene2d.ts` `aspects:` list, or `.aspect(...)` in code). Each tool owns its own part.
16
+ //
17
+ // The texture loads lazily on the first make()/load(); sprites created before it resolves get their
18
+ // texture/frame applied on arrival (the world size is known up front from the rect / slice cell, so
19
+ // layout doesn't wait). The sprite editor reads and writes these files — hand edits are preserved
20
+ // (see packages/scene-doc).
21
+
22
+ import { type Vec2Like } from "../math/vec"
23
+ import { Sprite } from "./Sprite"
24
+ import { SpriteAnimation, type Clip } from "./SpriteAnimation"
25
+ import { Texture2D } from "./Texture2D"
26
+
27
+ /**
28
+ * A named region of the image. Just a rect is a static sprite; `slice` subdivides the rect into an
29
+ * animation grid ([cols, rows] — the cell is rect size / counts); `clips` names frame runs over
30
+ * that grid (local, row-major). A sliced entry with NO clips loops all of its cells in order.
31
+ */
32
+ export type SheetSprite = {
33
+ /** Pixel rect [x, y, w, h] in the image. */
34
+ rect?: [number, number, number, number]
35
+ /** Subdivide the rect into an animation grid: [cols, rows]. */
36
+ slice?: [number, number]
37
+ /** Playback fps for this sprite's animation (defaults to the sheet fps, then 12). */
38
+ fps?: number
39
+ /** Named clips over the local grid (see SpriteAnimation). Omit to loop every cell. */
40
+ clips?: Record<string, Clip>
41
+ /** Facing names in local-row order for `perDirection` clips (compass tokens: S/SE/E/…). */
42
+ directions?: string[]
43
+ /** Overrides the sheet anchor. */
44
+ anchor?: Vec2Like
45
+ }
46
+
47
+ export type SpriteSheetDef = {
48
+ /** The image URL — write `asset('./sheet.png')`. */
49
+ image: string
50
+ /** Default pivot for every sprite made from this sheet ([0.5, 1] = feet, for Y-sorted worlds). */
51
+ anchor?: Vec2Like
52
+ /** Default fps for sliced sprites / clips that don't override it. */
53
+ fps?: number
54
+ /** The named sprites. */
55
+ sprites?: Record<string, SheetSprite>
56
+ }
57
+
58
+ export type SpriteMakeOptions = {
59
+ position?: Vec2Like
60
+ layer?: number
61
+ /** Overrides the sheet/sprite anchor. */
62
+ anchor?: Vec2Like
63
+ /** Start this clip immediately (sliced sprites with clips). */
64
+ clip?: string
65
+ /** Initial facing for `perDirection` clips. */
66
+ direction?: string
67
+ }
68
+
69
+ /** The name a sliced-but-clipless sprite's implicit everything-loop plays under. */
70
+ const LOOP_ALL = "all"
71
+
72
+ export class SpriteSheet {
73
+ private _def: SpriteSheetDef
74
+ private _tex: Texture2D | null = null
75
+ private _loading: Promise<Texture2D> | null = null
76
+
77
+ constructor(def: SpriteSheetDef) {
78
+ this._def = def
79
+ }
80
+
81
+ /** The sheet definition (read-only by convention — the editor owns the file). */
82
+ get def(): SpriteSheetDef { return this._def }
83
+
84
+ /** The loaded texture, or null before load() resolves. */
85
+ get texture(): Texture2D | null { return this._tex }
86
+
87
+ /** Load the sheet's texture (idempotent). make() starts this automatically. */
88
+ load(): Promise<Texture2D> {
89
+ if (!this._loading) {
90
+ this._loading = Texture2D.load(this._def.image).then((tex) => { this._tex = tex; return tex })
91
+ }
92
+ return this._loading
93
+ }
94
+
95
+ /** Names of the sheet's sprites. */
96
+ get spriteNames(): string[] { return Object.keys(this._def.sprites ?? {}) }
97
+
98
+ /** Build a named sprite; starting a clip hands back the animation handle. */
99
+ make(name: string, opts: SpriteMakeOptions & { clip: string }): Sprite & { anim: SpriteAnimation }
100
+ make(name: string, opts?: SpriteMakeOptions): Sprite
101
+ make(name: string, opts: SpriteMakeOptions = {}): Sprite {
102
+ const def = this._def
103
+ const named = def.sprites?.[name]
104
+ if (!named) {
105
+ throw new Error(`SpriteSheet.make(): unknown sprite "${name}" (have: ${this.spriteNames.join(", ") || "none"})`)
106
+ }
107
+ const rect = named.rect
108
+ const slice = named.slice
109
+
110
+ const sprite = new Sprite({
111
+ anchor: opts.anchor ?? named.anchor ?? def.anchor,
112
+ layer: opts.layer,
113
+ position: opts.position,
114
+ })
115
+
116
+ // The world size is known without the texture (the rect, or its slice cell) — set it up front
117
+ // so layout, Y-sort and colliders are correct even while the image is still loading.
118
+ if (rect) sprite.size = slice ? [ rect[2] / slice[0], rect[3] / slice[1] ] : [ rect[2], rect[3] ]
119
+
120
+ // sliced sprite: all animation flows through SpriteAnimation, anchored at the rect's origin.
121
+ // No clips declared = one implicit clip looping every cell (animated tiles).
122
+ if (rect && slice) {
123
+ const [ cols, rows ] = slice
124
+ const clips = named.clips && Object.keys(named.clips).length > 0
125
+ ? named.clips
126
+ : { [LOOP_ALL]: Array.from({ length: cols * rows }, (_, i) => i) }
127
+ const animOpts: Partial<SpriteAnimation> = {
128
+ size: [ rect[2] / cols, rect[3] / rows ],
129
+ cols,
130
+ origin: [ rect[0], rect[1] ],
131
+ clips,
132
+ }
133
+ const fps = named.fps ?? def.fps
134
+ if (fps !== undefined) animOpts.fps = fps
135
+ if (named.directions) animOpts.directions = named.directions
136
+ sprite.aspect(SpriteAnimation, animOpts)
137
+ const start = opts.clip ?? (named.clips ? undefined : LOOP_ALL)
138
+ if (start) (sprite as Sprite & { anim: SpriteAnimation }).anim.play(start, opts.direction)
139
+ }
140
+
141
+ // texture + frame: now if loaded, else on arrival
142
+ if (this._tex) this._applyTexture(sprite, named, this._tex)
143
+ else this.load().then((tex) => this._applyTexture(sprite, named, tex))
144
+ return sprite
145
+ }
146
+
147
+ /** The pixel rect [x, y, w, h] of a named sprite's REGION (the whole rect, sliced or not). */
148
+ rectOf(name: string): [number, number, number, number] | null {
149
+ const rect = this._def.sprites?.[name]?.rect
150
+ return rect ? [ ...rect ] : null
151
+ }
152
+
153
+ private _applyTexture(sprite: Sprite, named: SheetSprite, tex: Texture2D): void {
154
+ sprite.texture = tex // native resets UV/size; the Sprite setter re-applies our explicit size
155
+ const rect = named.rect
156
+ if (rect) {
157
+ const [ cw, ch ] = named.slice ? [ rect[2] / named.slice[0], rect[3] / named.slice[1] ] : [ rect[2], rect[3] ]
158
+ sprite.setFramePx(rect[0], rect[1], cw, ch) // cell 0 (or the whole static rect)
159
+ }
160
+ const anim = (sprite as Partial<{ anim: SpriteAnimation }>).anim
161
+ if (anim) anim.define()
162
+ }
163
+ }
164
+
165
+ /** Declare a sprite sheet (the default export of a `.sprite.ts` file). */
166
+ export const defineSpriteSheet = (def: SpriteSheetDef): SpriteSheet => new SpriteSheet(def)
@@ -0,0 +1,71 @@
1
+ // Tilesets as data: one `.tiles.ts` file per atlas, `export default defineTileset({...})` — the
2
+ // material vocabulary (autotile rules) for maps painted by material id. The pure model + deriver
3
+ // live in ./autotile.ts; this file is the runtime handle plus the `Autotile2D` aspect that binds a
4
+ // tilemap node to its tileset:
5
+ //
6
+ // // world.tiles.ts
7
+ // export default defineTileset({
8
+ // image: asset('./TileSet.png'),
9
+ // tile: 32,
10
+ // materials: {
11
+ // grass: { id: 1, kind: 'patch', color: '#4a7c3a', fill: [261, [262, 0.1]],
12
+ // over: { '*': { edges: [197, 262, 325, 260], outer: [198, 326, 324, 196],
13
+ // inner: [199, 327, 391, 263] } } },
14
+ // road: { id: 2, kind: 'path', color: '#8a8578', straights: [520, 521], corners: [522, 523, 524, 525] },
15
+ // },
16
+ // })
17
+ //
18
+ // // meadow.scene2d.ts
19
+ // import world from './world.tiles'
20
+ // ground: { tilemap: { texture: asset('./TileSet.png'), tile: 32, atlas: [64, 64],
21
+ // cells: cells('…') }, // cells hold MATERIAL ids here
22
+ // aspects: [use(Autotile2D, { tileset: world, seed: 3 })] }
23
+ //
24
+ // The aspect flips the meaning of the same `cells('…')` payload from raw atlas indices to material
25
+ // ids; the scene2d loader runs the deriver BEFORE the Tilemap is constructed (aspects are data on
26
+ // the node def), so no post-construction data path exists and derived indices are never persisted.
27
+
28
+ import { Aspect } from "../core/Aspect"
29
+ import type { Node2D } from "./Node2D"
30
+ import { deriveCells, type TilesetDef, type TileMaterial } from "./autotile"
31
+
32
+ export class Tileset {
33
+ private _def: TilesetDef
34
+
35
+ constructor(def: TilesetDef) { this._def = def }
36
+
37
+ /** The tileset definition (read-only by convention — the editor owns the file). */
38
+ get def(): TilesetDef { return this._def }
39
+
40
+ /** Names of the declared materials, in file order. */
41
+ get materialNames(): string[] { return Object.keys(this._def.materials ?? {}) }
42
+
43
+ /** A material by name. */
44
+ material(name: string): TileMaterial | undefined { return this._def.materials?.[name] }
45
+
46
+ /** The cell value maps store for a named material (what you paint / pass in `data`). */
47
+ id(name: string): number | undefined { return this._def.materials?.[name]?.id }
48
+
49
+ /** Derive display atlas indices from a material-id grid (what Autotile2D does at load). */
50
+ derive(cols: number, rows: number, data: ArrayLike<number>, seed = 0): Int32Array {
51
+ return deriveCells(this._def, cols, rows, data, seed)
52
+ }
53
+ }
54
+
55
+ /** Declare a tileset (the default export of a `.tiles.ts` file). */
56
+ export const defineTileset = (def: TilesetDef): Tileset => new Tileset(def)
57
+
58
+ /**
59
+ * Marks a tilemap node's `cells` as MATERIAL ids of a tileset instead of raw atlas indices:
60
+ * `aspects: [use(Autotile2D, { tileset: world })]`. The scene2d loader derives the display
61
+ * indices (neighbor-mask autotile + seeded scatter, deterministic) before building the tilemap;
62
+ * the aspect itself holds no behavior. Change `seed` to reshuffle scatter variants.
63
+ */
64
+ export class Autotile2D extends Aspect<"autotile", Node2D> {
65
+ static readonly aspect = "autotile"
66
+
67
+ /** The imported `.tiles.ts` handle. */
68
+ tileset: Tileset | null = null
69
+ /** Scatter seed — same seed, same map, same result on every platform. */
70
+ seed = 0
71
+ }