lecodes-sdk 2.4.9 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/global.d.ts +35 -0
  2. package/dist/types/core/env.d.ts +25 -0
  3. package/dist/types/core/particlesConfig.d.ts +196 -0
  4. package/dist/types/g2/Font2D.d.ts +19 -0
  5. package/dist/types/g2/Material2D.d.ts +24 -0
  6. package/dist/types/g2/Node2D.d.ts +6 -5
  7. package/dist/types/g2/Particles2D.d.ts +169 -0
  8. package/dist/types/g2/Path2D.d.ts +95 -0
  9. package/dist/types/g2/Physics2D.d.ts +1 -1
  10. package/dist/types/g2/Scene2D.d.ts +15 -17
  11. package/dist/types/g2/Sprite.d.ts +12 -1
  12. package/dist/types/g2/SpriteSheet.d.ts +4 -1
  13. package/dist/types/g2/Text2D.d.ts +96 -0
  14. package/dist/types/g2/Tilemap.d.ts +8 -0
  15. package/dist/types/g2/defineScene2d.d.ts +69 -11
  16. package/dist/types/gl/Foliage.d.ts +0 -7
  17. package/dist/types/gl/Lightmap.d.ts +101 -2
  18. package/dist/types/gl/Material.d.ts +21 -15
  19. package/dist/types/gl/Model.d.ts +8 -3
  20. package/dist/types/gl/Particles.d.ts +3 -135
  21. package/dist/types/gl/Statics.d.ts +56 -0
  22. package/dist/types/gl/staticsFormat.d.ts +101 -0
  23. package/dist/types/inject.d.ts +11 -3
  24. package/dist/types/runtime/env.d.ts +10 -0
  25. package/dist/types/runtime/envValues.d.ts +8 -0
  26. package/dist/types/scene/defineScene.d.ts +17 -12
  27. package/dist/types/server/env.d.ts +14 -0
  28. package/dist/types/server/inject.d.ts +1 -0
  29. package/dist/types/ui/fonts.d.ts +4 -0
  30. package/dist/types/version.d.ts +1 -1
  31. package/dist/types.json +1 -1
  32. package/package.json +1 -1
  33. package/src/bridges/2d.d.ts +65 -5
  34. package/src/bridges/gl.d.ts +34 -15
  35. package/src/compile/bakeMacro.ts +3 -0
  36. package/src/compile/compileProject.ts +25 -4
  37. package/src/compile/envCompile.ts +74 -0
  38. package/src/compile/fontMacro.ts +11 -6
  39. package/src/compile/index.ts +6 -0
  40. package/src/compile/resourceSlots.ts +1 -0
  41. package/src/compile/serverSplit.ts +47 -4
  42. package/src/core/env.ts +47 -0
  43. package/src/core/particlesConfig.ts +422 -0
  44. package/src/g2/Font2D.ts +87 -0
  45. package/src/g2/Material2D.ts +64 -0
  46. package/src/g2/Node2D.ts +10 -6
  47. package/src/g2/Particles2D.ts +337 -0
  48. package/src/g2/Path2D.ts +215 -0
  49. package/src/g2/Physics2D.ts +1 -1
  50. package/src/g2/Scene2D.ts +16 -22
  51. package/src/g2/Sprite.ts +23 -4
  52. package/src/g2/SpriteSheet.ts +9 -5
  53. package/src/g2/Text2D.ts +178 -0
  54. package/src/g2/Tilemap.ts +10 -0
  55. package/src/g2/defineScene2d.ts +108 -21
  56. package/src/g2/groups2d.ts +2 -2
  57. package/src/gl/Foliage.ts +1 -16
  58. package/src/gl/Lightmap.ts +79 -26
  59. package/src/gl/Material.ts +51 -21
  60. package/src/gl/Model.ts +19 -5
  61. package/src/gl/Particles.ts +22 -361
  62. package/src/gl/Statics.ts +301 -0
  63. package/src/gl/nav/NavMesh.ts +4 -1
  64. package/src/gl/staticsFormat.ts +240 -0
  65. package/src/inject.ts +18 -3
  66. package/src/runtime/env.ts +13 -0
  67. package/src/runtime/envValues.ts +8 -0
  68. package/src/runtime/fetch.ts +18 -0
  69. package/src/scene/defineScene.ts +107 -27
  70. package/src/server/env.ts +25 -0
  71. package/src/server/host.ts +6 -0
  72. package/src/server/inject.ts +2 -1
  73. package/src/ui/fonts.ts +46 -0
  74. package/src/version.ts +1 -1
@@ -0,0 +1,337 @@
1
+ // A 2D particle system, as a Node2D: fire, sparks, smoke, dust, magic, rain — drawn in the sprite batch,
2
+ // so it sorts by renderPriority / z like any node.
3
+ //
4
+ // new Particles2D({ rate: 60, lifetime: { min: 0.6, max: 1 }, size: curve(24).to(4),
5
+ // startVelocity: { dir: [0, 1], speed: { min: 80, max: 140 }, spread: 0.3 },
6
+ // color: colorCurve('#ffd27a').to('#ff3a00'), opacity: curve().fade(0.1, 0.5),
7
+ // material: Material2D.additive(), position: [0, -120] })
8
+ // new Particles2D({ texture: smoke, sheet: [4, 4], rate: 12, gravity: -40, size: curve(40).to(90) })
9
+ // sparks.spawn(30) // a burst
10
+ //
11
+ // One simulation and one vocabulary with 3D: the engine hosts creator-particles (the same sim the 3D
12
+ // `Particles` node runs, one system per entity), and the config is the same [tag, payloadLen, ...]
13
+ // records written by the same writers (core/particlesConfig.ts) — the same `curve()` / `colorCurve()`
14
+ // builders, the same param names (`size`, `rotation`, `opacity`, `frame`, `color`), the same units.
15
+ // What differs in 2D:
16
+ // - the plane: the sim runs PLANAR for a 2D system (the engine sets CPART_TAG_PLANAR at creation):
17
+ // shapes, launch directions, the velocity jitter and the noise field stay in XY, so a `circle` is a
18
+ // true disc (a `thickness` 0 one a ring) and a speed with no direction is spent in the plane. Every
19
+ // vector here is a Vec2 and its z is written 0. Gravity pulls toward -y (the 2D world is Y-up).
20
+ // - no camera sort: within a system the spawn order is the draw order; `z` / `renderPriority` order
21
+ // systems.
22
+ // - the look is a sheet TEXTURE (+ its flipbook grid), not a material: omit it for solid squares of
23
+ // the particle colour. The blend is the node's Material2D — `Material2D.additive()` for fire and
24
+ // sparks. No `Particles2D.additive`-style sugar: Material2D IS the blend.
25
+ // - no mesh mode, no baked light (`lit`), no `order` (z does it), no `stretch`.
26
+ //
27
+ // The node's own `rotation` (degrees, Node2D) is the emitter's; the PARTICLE rotation is the `rotation`
28
+ // option (radians, the 3D convention) — live, through `custom[1]`.
29
+
30
+ import { Vec2, cx, cy, type Vec2Like, type Vec3Like } from "../math/vec"
31
+ import {
32
+ MODE_POINTS, MODE_QUADS, MODE_RIBBON, SLOT_FRAME, SLOT_OPACITY, SLOT_ROTATION, SLOT_SIZE,
33
+ TAG_ACCELERATION, TAG_DRAG, TAG_INHERIT_VELOCITY, TAG_RATE, TAG_RATE_DISTANCE, TAG_RENDER_MODE,
34
+ TAG_SEED, TAG_SMOOTH_PATH, TAG_SPACE,
35
+ pushColor, pushDefaultFrames, pushGround, pushLifetime, pushNoise, pushParam, pushShape,
36
+ pushVelocity, pushVelocityLife,
37
+ type ParticleColor, type ParticleGround, type ParticleNoise, type ParticleShape, type ParticleValue,
38
+ type Range, type VelocityValue,
39
+ } from "../core/particlesConfig"
40
+ import { Node2D } from "./Node2D"
41
+ import type { Material2D } from "./Material2D"
42
+ import type { Texture2D } from "./Texture2D"
43
+
44
+ export type ParticleMode2D = "quads" | "points" | "trail"
45
+
46
+ /** Where particles are born, in the node's local frame.
47
+ * - `point` at `v` (default the origin);
48
+ * - `box` between `min` and `max` (a spawn line: give it no height);
49
+ * - `circle` of `radius` around `center` — a disc, evenly filled; `thickness` 0 = the outline only (a
50
+ * ring). Gives the launch direction (outward) when `startVelocity` has a speed and no direction;
51
+ * - `cone` opening along +y from its base of half-width `radius` at `center`: `angle` (half-angle,
52
+ * radians) flares it, `length` emits that far up the axis too; launches away from the apex.
53
+ * `thickness` 0 = the outline only, 1 (default) = the whole area. */
54
+ export type ParticleShape2D =
55
+ | { type: "point", v?: Vec2Like }
56
+ | { type: "box", min: Vec2Like, max: Vec2Like }
57
+ | { type: "circle", center?: Vec2Like, radius: number, thickness?: number }
58
+ | { type: "cone", center?: Vec2Like, radius: number, angle?: number, length?: number, thickness?: number }
59
+
60
+ /** A start velocity by direction + speed. `dir` launches along a vector, `from` away from a point,
61
+ * `to` toward one; with none, a sphere / cone `shape` gives the direction, else a random one.
62
+ * `spread` jitters the direction by up to ± that angle (radians, in the plane). */
63
+ export type VelocityValue2D = {
64
+ speed?: Range<number>
65
+ spread?: number
66
+ } & ({ from: Vec2Like } | { to: Vec2Like } | { dir: Vec2Like } | {})
67
+
68
+ /** Extra velocity over each particle's life, ADDED as a displacement: `radial` along its birth
69
+ * direction (a burst that decays: `curve(200).to(0)`), `x` / `y` a drift (rising smoke:
70
+ * `y: curve(60).from(0).to(1)`). See `VelocityOverLife` (3D). */
71
+ export type VelocityOverLife2D = { radial?: ParticleValue, x?: ParticleValue, y?: ParticleValue }
72
+
73
+ export type Particles2DOptions = {
74
+ /** The particle image — one picture or a flipbook sheet (`sheet`). Omit = solid squares of `color`. */
75
+ texture?: Texture2D
76
+ /** The texture's flipbook grid: `N` = N×N, `[cols, rows]`; frames run left-to-right, top-to-bottom.
77
+ * Default 1. With more than one frame and no `frame`, each particle plays the sheet once over its
78
+ * life, from `startFrame`. */
79
+ sheet?: number | [cols: number, rows: number]
80
+ /** Blend (+ sampling) — `Material2D.additive()` for fire, sparks, glow. Default alpha. */
81
+ material?: Material2D
82
+ /** Pool capacity (default 1000). */
83
+ maxParticles?: number
84
+ /** `'quads'` (default) a rotated square each; `'points'` the same look from a cheaper stream;
85
+ * `'trail'` one ribbon strip through the particles in spawn order — `size` is its width (pair it
86
+ * with `space: 'world'` and `rateOverDistance` for a trail behind a moving node). */
87
+ mode?: ParticleMode2D
88
+ /** Particles emitted per second. */
89
+ rate?: number
90
+ /** Particles emitted per world unit the emitter MOVES, on top of `rate` (trails; meant for
91
+ * `space: 'world'`). */
92
+ rateOverDistance?: number
93
+ /** Lay a frame's distance spawns along a curve through the emitter's path, not the straight chord. */
94
+ smooth?: boolean
95
+ /** `'local'` (default) — particles ride the node; `'world'` — each stays where it was born as the
96
+ * emitter moves on. Switching at runtime resets live particles. */
97
+ space?: "local" | "world"
98
+ /** Fraction of the emitter's own velocity given to each particle at spawn (`space: 'world'`). */
99
+ inheritVelocity?: number
100
+ shape?: ParticleShape2D
101
+ /** A vector (its length is the speed) or a direction + speed. World units / s. */
102
+ startVelocity?: Vec2Like | VelocityValue2D
103
+ velocityOverLife?: VelocityOverLife2D
104
+ /** Downward acceleration, world units/s² — positive pulls toward -y (sugar for
105
+ * `acceleration: [0, -g]`); negative makes smoke rise. */
106
+ gravity?: number
107
+ /** Constant acceleration (gravity, wind). Wins over `gravity` when both are set. */
108
+ acceleration?: Vec2Like
109
+ drag?: number
110
+ /** Seconds each particle lives. */
111
+ lifetime?: Range<number>
112
+ color?: ParticleColor
113
+ /** Diameter in WORLD units (custom[0]). Default 1 — a 2D world is usually pixel-scaled, so set it. */
114
+ size?: ParticleValue
115
+ /** The particle's spin, radians (custom[1]) — a curve with mode `add` turns it over its life. */
116
+ rotation?: ParticleValue
117
+ /** 0..1 (custom[2]). Default 1. */
118
+ opacity?: ParticleValue
119
+ /** Flipbook frame index (custom[3]). */
120
+ frame?: ParticleValue
121
+ /** Where the default flipbook cycle begins: `'random'` (default) or a frame index. */
122
+ startFrame?: number | "random"
123
+ /** A floor at `height` on the y axis the particles bounce on and rest on; `null` disables it. */
124
+ ground?: ParticleGround | null
125
+ /** Turbulence; `null` turns it off. */
126
+ noise?: ParticleNoise | null
127
+ /** Fixed random seed for deterministic tests. */
128
+ seed?: number
129
+ position?: Vec2Like
130
+ /** Draw order within a renderPriority — see {@link Node2D.z}. */
131
+ z?: number
132
+ /** Coarse draw order 0..7, default 4 — see {@link Node2D.renderPriority}. */
133
+ renderPriority?: number
134
+ }
135
+
136
+ const MODES: Record<ParticleMode2D, number> = { quads: MODE_QUADS, points: MODE_POINTS, trail: MODE_RIBBON }
137
+
138
+ const v3 = (v: Vec2Like): Vec3Like => [ cx(v), cy(v), 0 ]
139
+
140
+ const sheetColsRows = (sheet: number | [number, number] | undefined): [number, number] => {
141
+ const [ c, r ] = typeof sheet === "number" ? [ sheet, sheet ] : sheet ?? [ 1, 1 ]
142
+ return [ Math.max(1, Math.floor(c) || 1), Math.max(1, Math.floor(r) || 1) ]
143
+ }
144
+
145
+ // The 2D shapes as the sim's (z = 0): circle = the sim's sphere, which the planar sim samples as a disc.
146
+ const shape3 = (s: ParticleShape2D): ParticleShape => {
147
+ if (s.type === "point") return { type: "point", v: s.v ? v3(s.v) : [ 0, 0, 0 ] }
148
+ if (s.type === "box") return { type: "box", min: v3(s.min), max: v3(s.max) }
149
+ const center = s.center ? v3(s.center) : [ 0, 0, 0 ]
150
+ if (s.type === "circle") return { type: "sphere", center, radius: s.radius, thickness: s.thickness }
151
+ return { type: "cone", center, radius: s.radius, angle: s.angle, length: s.length, thickness: s.thickness }
152
+ }
153
+
154
+ // The 2D velocity as the sim's: z = 0; `spread` jitters only the in-plane angle (the sim's u axis is
155
+ // in the plane for an in-plane direction, v points out of it).
156
+ const velocity3 = (val: Vec2Like | VelocityValue2D): Vec3Like | VelocityValue => {
157
+ if (Array.isArray(val) || val instanceof Float32Array || val instanceof Vec2) return v3(val as Vec2Like)
158
+ const v = val as VelocityValue2D & { dir?: Vec2Like, from?: Vec2Like, to?: Vec2Like }
159
+ const out: VelocityValue & Record<string, unknown> = { speed: v.speed }
160
+ if (v.dir !== undefined) out.dir = v3(v.dir)
161
+ else if (v.from !== undefined) out.from = v3(v.from)
162
+ else if (v.to !== undefined) out.to = v3(v.to)
163
+ if (v.spread !== undefined) out.randomizeAngle = { min: [ -v.spread, 0, 0 ], max: [ v.spread, 0, 0 ] }
164
+ return out
165
+ }
166
+
167
+ export class Particles2D extends Node2D {
168
+ private _texture?: Texture2D
169
+ private _sheet: [number, number]
170
+ private _material?: Material2D
171
+ private _mode: ParticleMode2D
172
+ private _startFrame: number | "random"
173
+ private _frameSet: boolean
174
+ readonly maxParticles: number
175
+
176
+ /** Curve-param slots 0..3, live: size / rotation / opacity / frame alias 0..3. The particle
177
+ * rotation is `custom[1] = …` (the node's own `rotation` is the emitter's). */
178
+ readonly custom: ParticleValue[]
179
+
180
+ constructor(options: Particles2DOptions = {}) {
181
+ super()
182
+ this._texture = options.texture
183
+ this._sheet = sheetColsRows(options.sheet)
184
+ this._mode = options.mode ?? "quads"
185
+ this._startFrame = options.startFrame ?? "random"
186
+ this._frameSet = options.frame !== undefined
187
+ this.maxParticles = options.maxParticles ?? 0
188
+ // setParticles first: it is what makes the entity a particle system natively (quads by default).
189
+ this._sendParticles()
190
+
191
+ const id = this.id
192
+ const self = this
193
+ this.custom = new Proxy([] as ParticleValue[], {
194
+ set(target, prop: string, value: ParticleValue) {
195
+ const i = parseInt(prop)
196
+ if (Number.isNaN(i)) { (target as any)[prop] = value; return true }
197
+ target[i] = value
198
+ if (i === SLOT_FRAME) self._frameSet = true
199
+ const out: number[] = []
200
+ pushParam(out, i, value)
201
+ _creator2d.particlesConfig(id, Float32Array.from(out))
202
+ return true
203
+ },
204
+ })
205
+
206
+ // every option in ONE config buffer
207
+ const out: number[] = []
208
+ if (this._mode !== "quads") out.push(TAG_RENDER_MODE, 1, MODES[this._mode])
209
+ if (options.ground !== undefined) pushGround(out, options.ground)
210
+ if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
211
+ if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
212
+ if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
213
+ if (options.smooth !== undefined) out.push(TAG_SMOOTH_PATH, 1, options.smooth ? 1 : 0)
214
+ if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
215
+ if (options.shape !== undefined) pushShape(out, shape3(options.shape))
216
+ if (options.velocityOverLife !== undefined) pushVelocityLife(out, options.velocityOverLife)
217
+ if (options.startVelocity !== undefined) pushVelocity(out, velocity3(options.startVelocity))
218
+ if (options.acceleration !== undefined) {
219
+ out.push(TAG_ACCELERATION, 3, cx(options.acceleration), cy(options.acceleration), 0)
220
+ } else if (options.gravity !== undefined) {
221
+ out.push(TAG_ACCELERATION, 3, 0, -options.gravity, 0)
222
+ }
223
+ if (options.drag !== undefined) out.push(TAG_DRAG, 1, options.drag)
224
+ if (options.lifetime !== undefined) pushLifetime(out, options.lifetime)
225
+ if (options.color !== undefined) pushColor(out, options.color)
226
+ if (options.size !== undefined) pushParam(out, SLOT_SIZE, options.size)
227
+ if (options.rotation !== undefined) pushParam(out, SLOT_ROTATION, options.rotation)
228
+ if (options.opacity !== undefined) pushParam(out, SLOT_OPACITY, options.opacity)
229
+ if (options.frame !== undefined) pushParam(out, SLOT_FRAME, options.frame)
230
+ else pushDefaultFrames(out, this._sheet[0], this._sheet[1], this._startFrame)
231
+ if (options.noise !== undefined) pushNoise(out, options.noise)
232
+ if (options.seed !== undefined) out.push(TAG_SEED, 1, options.seed)
233
+ if (out.length > 0) this._send(out)
234
+
235
+ if (options.material) this.material = options.material
236
+ if (options.position) this.position = options.position
237
+ if (options.z !== undefined) this.z = options.z
238
+ if (options.renderPriority !== undefined) this.renderPriority = options.renderPriority
239
+ }
240
+
241
+ private _sendParticles(): void {
242
+ _creator2d.setParticles(this.id, this._texture?.id ?? 0, this._sheet[0], this._sheet[1], this.maxParticles)
243
+ }
244
+
245
+ private _send(out: number[]): void {
246
+ _creator2d.particlesConfig(this.id, Float32Array.from(out))
247
+ }
248
+
249
+ /** Emit `count` particles now, where the emitter is (a burst). */
250
+ spawn(count: number): this { _creator2d.particlesSpawn(this.id, count); return this }
251
+
252
+ /** Live particles right now. */
253
+ get alive(): number { return _creator2d.particlesAlive(this.id) }
254
+
255
+ /** The particle image; undefined = solid squares of the colour. */
256
+ get texture(): Texture2D | undefined { return this._texture }
257
+ set texture(t: Texture2D | undefined) { this._texture = t; this._sendParticles() }
258
+
259
+ /** The texture's flipbook grid [cols, rows]. Without an explicit `frame` the default cycle follows. */
260
+ get sheet(): [number, number] { return [ this._sheet[0], this._sheet[1] ] }
261
+ set sheet(s: number | [number, number]) {
262
+ this._sheet = sheetColsRows(s)
263
+ this._sendParticles()
264
+ if (this._frameSet) return
265
+ const out: number[] = []
266
+ if (this._sheet[0] * this._sheet[1] > 1) pushDefaultFrames(out, this._sheet[0], this._sheet[1], this._startFrame)
267
+ else pushParam(out, SLOT_FRAME, 0)
268
+ this._send(out)
269
+ }
270
+
271
+ /** Blend ({@link Material2D}). Undefined = the default (alpha). */
272
+ get material(): Material2D | undefined { return this._material }
273
+ set material(m: Material2D) { this._material = m; m._apply(this.id) }
274
+
275
+ /** How particles are drawn — see `Particles2DOptions.mode`. */
276
+ get mode(): ParticleMode2D { return this._mode }
277
+ set mode(m: ParticleMode2D) { this._mode = m; this._send([ TAG_RENDER_MODE, 1, MODES[m] ]) }
278
+
279
+ set rate(val: number) { this._send([ TAG_RATE, 1, val ]) }
280
+ /** `'world'` leaves particles behind where they were born (trails). Switching resets live ones. */
281
+ set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
282
+ set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
283
+ set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }
284
+ set smooth(val: boolean) { this._send([ TAG_SMOOTH_PATH, 1, val ? 1 : 0 ]) }
285
+
286
+ set shape(shape: ParticleShape2D) {
287
+ const out: number[] = []
288
+ pushShape(out, shape3(shape))
289
+ this._send(out)
290
+ }
291
+
292
+ set startVelocity(val: Vec2Like | VelocityValue2D) {
293
+ const out: number[] = []
294
+ pushVelocity(out, velocity3(val))
295
+ this._send(out)
296
+ }
297
+
298
+ set velocityOverLife(v: VelocityOverLife2D) {
299
+ const out: number[] = []
300
+ pushVelocityLife(out, v)
301
+ this._send(out)
302
+ }
303
+
304
+ /** Positive pulls toward -y — sugar for `acceleration = [0, -g]`. */
305
+ set gravity(val: number) { this._send([ TAG_ACCELERATION, 3, 0, -val, 0 ]) }
306
+ set acceleration(a: Vec2Like) { this._send([ TAG_ACCELERATION, 3, cx(a), cy(a), 0 ]) }
307
+ set drag(val: number) { this._send([ TAG_DRAG, 1, val ]) }
308
+ set seed(val: number) { this._send([ TAG_SEED, 1, val ]) }
309
+
310
+ set lifetime(val: Range<number>) {
311
+ const out: number[] = []
312
+ pushLifetime(out, val)
313
+ this._send(out)
314
+ }
315
+
316
+ set size(val: ParticleValue) { this.custom[SLOT_SIZE] = val }
317
+ set opacity(val: ParticleValue) { this.custom[SLOT_OPACITY] = val }
318
+ set frame(val: ParticleValue) { this.custom[SLOT_FRAME] = val }
319
+
320
+ set color(value: ParticleColor) {
321
+ const out: number[] = []
322
+ pushColor(out, value)
323
+ this._send(out)
324
+ }
325
+
326
+ set ground(g: ParticleGround | null) {
327
+ const out: number[] = []
328
+ pushGround(out, g)
329
+ this._send(out)
330
+ }
331
+
332
+ set noise(n: ParticleNoise | null) {
333
+ const out: number[] = []
334
+ pushNoise(out, n)
335
+ this._send(out)
336
+ }
337
+ }
@@ -0,0 +1,215 @@
1
+ // A vector 2D node: lines and polygons drawn in the sprite batch (through the engine's white texture,
2
+ // so they batch with sprites and sort by renderPriority / z like any node). What it is for: debug draw,
3
+ // trails and outlines, map roads and areas, UI-ish shapes inside a 2D scene.
4
+ //
5
+ // Path2D.line([[0, 0], [120, 40], [200, 0]], { width: 2, units: 'px', color: '#fc4' }) // a map road
6
+ // Path2D.polygon(area, { fill: '#3a74', stroke: { width: 1, units: 'px' } }) // a map area
7
+ // Path2D.circle(20, { fill: '#e33', position: [100, 50] })
8
+ //
9
+ // The points are in the node's LOCAL frame. Stroke `units: 'px'` is a width in logical screen pixels
10
+ // that stays constant while the camera zooms — what a map wants; the default 'world' scales with the
11
+ // zoom like the rest of the scene. Fills always close the ring; `closed` is about the stroke.
12
+ //
13
+ // The geometry is tessellated natively once per `points` / `closed` / `holes` write (the width and the
14
+ // anti-alias fringe are applied per frame, so zooming costs no rebuild). So when a shape MOVES, animate
15
+ // the node's transform (position / rotation / scale), not its points.
16
+ //
17
+ // A path has no pointer picking and no collider of its own: put a Shape2D (+ Physics2D / Trigger2D) on
18
+ // the same node for that.
19
+
20
+ import { Color, type ColorInput } from "../core/color"
21
+ import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
22
+ import { Node2D } from "./Node2D"
23
+ import type { Material2D } from "./Material2D"
24
+
25
+ export type StrokeJoin2D = "miter" | "round" | "bevel"
26
+ export type StrokeCap2D = "butt" | "round" | "square"
27
+
28
+ export type Stroke2D = {
29
+ /** Line width. In `units: 'world'` (default) world units; in `'px'` logical screen pixels — constant
30
+ * on screen while the camera zooms (map roads, debug lines). */
31
+ width: number
32
+ /** Default white. */
33
+ color?: ColorInput
34
+ units?: "world" | "px"
35
+ /** How segments meet; default 'miter'. */
36
+ join?: StrokeJoin2D
37
+ /** How an OPEN path's ends look; default 'butt'. */
38
+ cap?: StrokeCap2D
39
+ /** A band around the line, `width` in the stroke's units; default colour black. Drawn UNDER every
40
+ * node's body at the same z, so two outlined roads merge at their junction (give a class of roads
41
+ * one z; a different z isolates). */
42
+ outline?: { width: number, color?: ColorInput }
43
+ }
44
+
45
+ export type Path2DOptions = {
46
+ /** The outer ring, in the node's local frame. */
47
+ points: readonly Vec2Like[]
48
+ /** Close the ring (a polygon). Fills always close; this is about the stroke. Default false for
49
+ * line() and a plain constructor, true for polygon() / rect() / circle(). */
50
+ closed?: boolean
51
+ /** A number = `{ width }`. Omit = no stroke. */
52
+ stroke?: Stroke2D | number
53
+ /** Omit = no fill. */
54
+ fill?: ColorInput
55
+ /** Hole rings — fills only. */
56
+ holes?: readonly (readonly Vec2Like[])[]
57
+ /** Edge anti-aliasing (default true). Off for crisp pixel-art lines. */
58
+ antialias?: boolean
59
+ position?: Vec2Like
60
+ /** Degrees, CCW. */
61
+ rotation?: number
62
+ scale?: Vec2Like | number
63
+ /** Draw order within a renderPriority — see {@link Node2D.z}. */
64
+ z?: number
65
+ /** Coarse draw order 0..7, default 4 — see {@link Node2D.renderPriority}. */
66
+ renderPriority?: number
67
+ /** Only `blend` matters for a path (no texture); see Material2D. */
68
+ material?: Material2D
69
+ }
70
+
71
+ type ShapeOptions = Omit<Path2DOptions, "points" | "closed">
72
+
73
+ const JOINS: Record<StrokeJoin2D, number> = { miter: 0, round: 1, bevel: 2 }
74
+ const CAPS: Record<StrokeCap2D, number> = { butt: 0, round: 1, square: 2 }
75
+
76
+ // x,y pairs — the engine's point format.
77
+ const toFloats = (points: readonly Vec2Like[]): Float32Array => {
78
+ const out = new Float32Array(points.length * 2)
79
+ for (let i = 0; i < points.length; i++) { out[i * 2] = cx(points[i]!); out[i * 2 + 1] = cy(points[i]!) }
80
+ return out
81
+ }
82
+
83
+ export class Path2D extends Node2D {
84
+ private _pts: Float32Array = new Float32Array(0)
85
+ private _closed = false
86
+ private _stroke: Stroke2D | null = null
87
+ private _outlined = false
88
+ private _fill: ColorInput | null = null
89
+ private _holes: Float32Array[] = []
90
+ private _antialias = true
91
+ private _material?: Material2D
92
+
93
+ constructor(options: Path2DOptions) {
94
+ super()
95
+ // setPath first: it is what makes the entity a path natively; stroke / fill / holes come after.
96
+ this._closed = options.closed ?? false
97
+ this.points = options.points
98
+ if (options.holes) this.holes = options.holes
99
+ if (options.stroke !== undefined) this.stroke = options.stroke
100
+ if (options.fill !== undefined) this.fill = options.fill
101
+ if (options.antialias !== undefined) this.antialias = options.antialias
102
+ if (options.material) this.material = options.material
103
+ if (options.position) this.position = options.position
104
+ if (options.rotation !== undefined) this.rotation = options.rotation
105
+ if (options.scale !== undefined) this.scale = options.scale
106
+ if (options.z !== undefined) this.z = options.z
107
+ if (options.renderPriority !== undefined) this.renderPriority = options.renderPriority
108
+ }
109
+
110
+ /** An open polyline. */
111
+ static line(
112
+ points: readonly Vec2Like[], stroke: Stroke2D | number,
113
+ options: Omit<Path2DOptions, "points" | "stroke" | "fill" | "closed"> = {},
114
+ ): Path2D {
115
+ return new Path2D({ ...options, points, stroke, closed: false })
116
+ }
117
+
118
+ /** A closed polygon; give it a `fill` and / or a `stroke`. */
119
+ static polygon(points: readonly Vec2Like[], options: ShapeOptions = {}): Path2D {
120
+ return new Path2D({ ...options, points, closed: true })
121
+ }
122
+
123
+ /** A rectangle of `size` centred on the node's origin. */
124
+ static rect(size: Vec2Like, options: ShapeOptions = {}): Path2D {
125
+ const hw = cx(size) / 2, hh = cy(size) / 2
126
+ return Path2D.polygon([ [ -hw, -hh ], [ hw, -hh ], [ hw, hh ], [ -hw, hh ] ], options)
127
+ }
128
+
129
+ /** A circle centred on the node's origin. `segments` defaults to clamp(round(radius / 2), 12, 96) —
130
+ * generous, because a fill is not re-tessellated when the camera zooms in. */
131
+ static circle(radius: number, options: ShapeOptions & { segments?: number } = {}): Path2D {
132
+ const { segments, ...rest } = options
133
+ const n = Math.max(3, Math.round(segments ?? Math.min(96, Math.max(12, Math.round(radius * 0.5)))))
134
+ const points: Vec2Like[] = []
135
+ for (let i = 0; i < n; i++) {
136
+ const a = (i / n) * Math.PI * 2
137
+ points.push([ Math.cos(a) * radius, Math.sin(a) * radius ])
138
+ }
139
+ return Path2D.polygon(points, rest)
140
+ }
141
+
142
+ /** The outer ring (local frame). A write re-tessellates natively — move the node instead when you can. */
143
+ get points(): Vec2[] {
144
+ const out: Vec2[] = []
145
+ for (let i = 0; i < this._pts.length; i += 2) out.push(new Vec2(this._pts[i]!, this._pts[i + 1]!))
146
+ return out
147
+ }
148
+ set points(points: readonly Vec2Like[]) {
149
+ this._pts = toFloats(points)
150
+ _creator2d.setPath(this.id, this._pts, this._pts.length / 2, this._closed)
151
+ }
152
+
153
+ /** Whether the stroke closes the ring (fills always close). */
154
+ get closed(): boolean { return this._closed }
155
+ set closed(v: boolean) {
156
+ this._closed = v
157
+ _creator2d.setPath(this.id, this._pts, this._pts.length / 2, v)
158
+ }
159
+
160
+ /** The stroke, or null = none. A number = `{ width }`. */
161
+ get stroke(): Stroke2D | null { return this._stroke ? { ...this._stroke } : null }
162
+ set stroke(s: Stroke2D | number | null) {
163
+ const st = s === null ? null : typeof s === "number" ? { width: s } : { ...s }
164
+ this._stroke = st
165
+ if (!st) {
166
+ _creator2d.setPathStroke(this.id, 0, false, 0, 0, 0, 0, 0, 0)
167
+ if (this._outlined) { this._outlined = false; _creator2d.setPathOutline(this.id, 0, 0, 0, 0, 0) }
168
+ return
169
+ }
170
+ const [ r, g, b, a ] = st.color !== undefined ? Color.toRgba01(st.color) : [ 1, 1, 1, 1 ]
171
+ _creator2d.setPathStroke(
172
+ this.id, st.width, st.units === "px", JOINS[st.join ?? "miter"], CAPS[st.cap ?? "butt"], r, g, b, a,
173
+ )
174
+ // the outline is sent when there is one, and once more to clear a previous one
175
+ if (st.outline && st.outline.width > 0) {
176
+ this._outlined = true
177
+ const [ orr, og, ob, oa ] = st.outline.color !== undefined ? Color.toRgba01(st.outline.color) : [ 0, 0, 0, 1 ]
178
+ _creator2d.setPathOutline(this.id, st.outline.width, orr, og, ob, oa)
179
+ } else if (this._outlined) {
180
+ this._outlined = false
181
+ _creator2d.setPathOutline(this.id, 0, 0, 0, 0, 0)
182
+ }
183
+ }
184
+
185
+ /** The fill colour, or null = no fill. */
186
+ get fill(): ColorInput | null { return this._fill }
187
+ set fill(c: ColorInput | null) {
188
+ this._fill = c
189
+ if (c === null) { _creator2d.setPathFill(this.id, false, 0, 0, 0, 0); return }
190
+ const [ r, g, b, a ] = Color.toRgba01(c)
191
+ _creator2d.setPathFill(this.id, true, r, g, b, a)
192
+ }
193
+
194
+ /** Hole rings (fills only). A write replaces them all. */
195
+ get holes(): Vec2[][] {
196
+ return this._holes.map((h) => {
197
+ const ring: Vec2[] = []
198
+ for (let i = 0; i < h.length; i += 2) ring.push(new Vec2(h[i]!, h[i + 1]!))
199
+ return ring
200
+ })
201
+ }
202
+ set holes(rings: readonly (readonly Vec2Like[])[]) {
203
+ this._holes = rings.map(toFloats)
204
+ _creator2d.pathClearHoles(this.id)
205
+ for (const h of this._holes) _creator2d.pathAddHole(this.id, h, h.length / 2)
206
+ }
207
+
208
+ /** Edge anti-aliasing (default true); off for crisp pixel-art lines. */
209
+ get antialias(): boolean { return this._antialias }
210
+ set antialias(v: boolean) { this._antialias = v; _creator2d.setPathAntialias(this.id, v) }
211
+
212
+ /** Blend ({@link Material2D}) — only `blend` matters for a path. Undefined = the default (alpha). */
213
+ get material(): Material2D | undefined { return this._material }
214
+ set material(m: Material2D) { this._material = m; m._apply(this.id) }
215
+ }
@@ -269,7 +269,7 @@ export class Physics2D extends Aspect<"physics", Node2D> {
269
269
  }
270
270
 
271
271
  /** Topmost body (solid or sensor) whose shape contains the world point, or null. "Topmost" means
272
- * by DRAW order - layer, then z - so picking agrees with what the player sees. */
272
+ * by DRAW order - renderPriority, then z - so picking agrees with what the player sees. */
273
273
  static overlapPoint(p: Vec2Like, q?: Query2D): Node2D | null {
274
274
  return node2dById(_creator2d.physicsOverlapPoint(cx(p), cy(p), maskOf(q), ignoreOf(q), ignoreCount(q)))
275
275
  }
package/src/g2/Scene2D.ts CHANGED
@@ -34,24 +34,15 @@ const installRenderSyncedFrames = (): void => {
34
34
  export type Scene2DOptions = {
35
35
  /** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
36
36
  background?: ColorInput
37
- /** Texture sampling for the 2D renderer: `'nearest'` (default — crisp pixel art) or `'linear'`
38
- * (smooth — for hi-res / non-pixel-art assets). Applied when the scene is opened, so switching
39
- * scenes restores the active scene's choice. NOTE: this is a GLOBAL renderer setting (one sampler
40
- * for ALL textures, not per-texture), so the currently-open scene's filter wins. Omit to leave the
41
- * current setting (engine default is nearest). */
37
+ /** The renderer's DEFAULT texture sampling: `'nearest'` (crisp pixel art — the engine default) or
38
+ * `'linear'` (smooth — hi-res / non-pixel-art assets). A node's `Material2D` `filter` overrides it
39
+ * per sprite / tilemap; this is what a material without one samples with. A GLOBAL renderer
40
+ * setting, so it is reasserted when the scene is opened (switching scenes restores the active
41
+ * scene's choice). Omit to leave the current setting. */
42
42
  filter?: 'nearest' | 'linear'
43
- }
44
-
45
- /** Returned by scene2d.layer(n); chain .ySort() to enable top-down depth sorting for that layer. */
46
- class LayerHandle {
47
- private readonly sceneId: number
48
- private readonly layer: number
49
- constructor(sceneId: number, layer: number) { this.sceneId = sceneId; this.layer = layer }
50
- /** In this layer, lower-on-screen sprites overlap higher ones (top-down feet sorting). */
51
- ySort(enabled = true): this {
52
- _creator2d.setLayerYSort(this.sceneId, this.layer, enabled)
53
- return this
54
- }
43
+ /** Top-down depth: among nodes of one renderPriority and one z, the one lower on screen (smaller
44
+ * world Y) draws on top. See {@link Scene2D.ySort}. */
45
+ ySort?: boolean
55
46
  }
56
47
 
57
48
  export class Scene2D implements Presentable {
@@ -72,8 +63,9 @@ export class Scene2D implements Presentable {
72
63
  readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
73
64
  readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
74
65
 
75
- /** Texture filter to (re)assert on open(); the sampler is global, so each scene reclaims it. */
66
+ /** Default texture filter to (re)assert on open(); the setting is global, so each scene reclaims it. */
76
67
  private readonly _filter?: 'nearest' | 'linear'
68
+ private _ySort = false
77
69
 
78
70
  constructor(options: Scene2DOptions = {}) {
79
71
  if (!Scene2D.supported) throw new Error("Scene2D: this host has no 2D engine (Web Lite runs UI and 3D apps; a 3d / core engine variant has no 2D) — check Scene2D.supported before creating one")
@@ -82,6 +74,7 @@ export class Scene2D implements Presentable {
82
74
  this.camera = new Camera2D(this.id)
83
75
  this._filter = options.filter
84
76
  if (options.background !== undefined) this.background = options.background
77
+ if (options.ySort !== undefined) this.ySort = options.ySort
85
78
  }
86
79
 
87
80
  set background(color: ColorInput) {
@@ -89,10 +82,11 @@ export class Scene2D implements Presentable {
89
82
  _creator2d.sceneSetClearColor(this.id, r, g, b, a)
90
83
  }
91
84
 
92
- /** Address a draw layer to configure it, e.g. scene2d.layer(1).ySort(). */
93
- layer(n: number): LayerHandle {
94
- return new LayerHandle(this.id, n)
95
- }
85
+ /** Top-down depth sorting, one flag for the scene: among nodes of one `renderPriority` and one `z`,
86
+ * the one lower on screen (smaller world Y) draws on top — give characters and props a feet anchor
87
+ * (`[0.5, 1]`). Draw order is renderPriority, then z, then this. Default off. */
88
+ get ySort(): boolean { return this._ySort }
89
+ set ySort(v: boolean) { this._ySort = v; _creator2d.setYSort(this.id, v) }
96
90
 
97
91
  /** Topmost visible sprite whose world bounds contain the world point, or null. Body-free
98
92
  * hit-test for tap/click picking — pair with camera.screenToWorld(). */