lecodes-sdk 2.4.10 → 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 +70 -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
@@ -62,6 +62,24 @@ export const fetch = (url: string, options: FetchOptions = {}): Promise<FetchRes
62
62
  export const fetchLocal = (path: string): FetchResponse =>
63
63
  new FetchResponseImpl({ id: _creatorFetch.local(path) }, 200) // the host's buffer: not owned
64
64
 
65
+ // A local `asset()` compiles to `__localAsset("id:" + _creatorFetch.local(<name>), <name>)` (sdk/src/compile/
66
+ // compileProject.ts): the "id:" url is the host's buffer id, assigned in the order the bundle asks, so it names the
67
+ // asset for THIS run only; the staged NAME is what stays the same across compiles. The pair is kept so a cache
68
+ // written by one run (gl/Statics.ts) can name an asset to the next.
69
+ const localAssetNames = new Map<string, string>()
70
+ const localAssetUrls = new Map<string, string>()
71
+ /** @internal */
72
+ export const __localAsset = (url: string, name: string): string => {
73
+ localAssetNames.set(url, name)
74
+ localAssetUrls.set(name, url)
75
+ return url
76
+ }
77
+ /** @internal The stable name of an asset url: its staged name for a local asset, the url itself otherwise (a server
78
+ * build's urls are paths, stable on their own). */
79
+ export const assetNameOf = (url: string): string => localAssetNames.get(url) ?? url
80
+ /** @internal The url of a stable asset name in this run (the inverse of `assetNameOf`). */
81
+ export const assetUrlOf = (name: string): string => localAssetUrls.get(name) ?? name
82
+
65
83
  /** A handle to a picked / fetched file (see openFilePicker). */
66
84
  export class File {
67
85
  /** @internal The host buffer (not owned — see the header). Same key `FetchResponse` carries, so
@@ -49,10 +49,11 @@ import { CAMERA_DEFAULTS } from "../gl/Camera"
49
49
  import { CameraPlace } from "../gl/CameraPlace"
50
50
  import { Node } from "../gl/Node"
51
51
  import { Mesh } from "../gl/Mesh"
52
- import { Model } from "../gl/Model"
52
+ import { Model, foliageKindOf, type FoliageKind } from "../gl/Model"
53
53
  import { Physics } from "../gl/physics/Physics"
54
54
  import { CharacterController } from "../gl/physics/CharacterController"
55
- import { Lightmap, type LightmapDef } from "../gl/Lightmap"
55
+ import { Lightmap, type LightmapDef, type LightmapPrepared } from "../gl/Lightmap"
56
+ import { coveredRoots, defsDigest, bakeDigest, findNode, open as staticsOpen, build as staticsBuild, snapshot as staticsSnapshot, staticsConfig, toolRun, type CoveredRoot, type OpenedCache } from "../gl/Statics"
56
57
  import { Foliage, type FoliageOptions } from "../gl/Foliage"
57
58
  import { NavMesh } from "../gl/nav/NavMesh"
58
59
  import { Terrain, type TerrainLayer, type TerrainRegion, type TerrainOptions } from "../gl/terrain/Terrain"
@@ -191,19 +192,23 @@ export type SceneNodeDef = {
191
192
  * the GLB instance, so `castShadows: false` on a first-person viewmodel covers the whole gun. */
192
193
  castShadows?: boolean
193
194
  receiveShadows?: boolean
194
- /** Baked lighting (a scene with `env.lightmap`): is this model/mesh node a lightmap STATIC — a
195
- * receiver and an occluder in the bake, real-time shadow casting off once the bake applies?
196
- * Default: static unless a `Physics` aspect moves the node (`dynamic` — Physics' default — or
197
- * `kinematic`). Set it only to override that rule; prefab subtrees inherit the verdict.
198
- * `'cube'` = a static whose every mesh bakes as AMBIENT CUBES instead of atlas texels — an occluder (its shadow and
195
+ /** Is this node STATIC — part of the level that never moves once loaded? A static is what the bake sees (a
196
+ * receiver and an occluder, real-time shadow casting off once the bake applies) and what the level cache
197
+ * snapshots after the first load. Default: static unless a `Physics` aspect moves the node (`dynamic` —
198
+ * Physics' default — or `kinematic`). `false` = built live, lit in real time, never cached. Set it only to
199
+ * override that rule; prefab subtrees inherit the verdict. */
200
+ static?: boolean
201
+ /** `'cube'` = a static whose every mesh bakes as AMBIENT CUBES instead of atlas texels — an occluder (its shadow and
199
202
  * bounce stay in the bake) lit where it stands, the way the bake treats small and thin props on its own: a pipe run, a
200
203
  * fence, a prop whose lightmap seams show. A prefab marked `'cube'` bakes its statics that way too. By name for a whole
201
204
  * kit: the `cube` option of `env.lightmap`'s `bake()`. */
202
- lightmap?: boolean | "cube"
205
+ lightmap?: "cube"
203
206
  /** `model` nodes: vegetation — load through the FOLIAGE tier (wind, touch bending, per-copy tint; see
204
- * `Foliage`). `{ fade: [start, end] }` also thins the cards out over that distance range (m) and drops the
205
- * copy past `end` — for ground cover. The fade is per ASSET: every node of the same GLB shares it. */
206
- foliage?: boolean | { fade?: [number, number] }
207
+ * `Foliage`). `'tree'` (= `true`): a crown, lit by the sky and the real-time sun; `'grass'`: ground cover, lit by
208
+ * the terrain's baked light under it (`Model.load`'s `foliage`). The object form names the kind and may add
209
+ * `fade: [start, end]`: the cards thin out over that distance range (m) and the copy drops past `end` — for
210
+ * ground cover. The fade is per ASSET: every node of the same GLB shares it. */
211
+ foliage?: boolean | "tree" | "grass" | { kind?: "tree" | "grass", fade?: [number, number] }
207
212
  /** Navigation (a scene with `env.navmesh`, see navmesh.md): every STATIC body is walkable by
208
213
  * default. `false` leaves this one out of the bake; `'unwalkable'` cuts its footprint out
209
214
  * (nobody stands on or crosses it). */
@@ -417,9 +422,9 @@ const movesByPhysics = (def: SceneNodeDef): boolean =>
417
422
  ((e.ctor as unknown) === Physics && ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") !== "static")
418
423
  || (e.ctor as unknown) === CharacterController)
419
424
 
420
- /** The static verdict for one node def (the editor's "Baked lighting" switch shows the same rule).
421
- * Children of a mover are movers too — buildNodes carries that down, this is the node's own verdict. */
422
- export const isLightmapStatic = (def: SceneNodeDef): boolean => def.lightmap !== undefined ? def.lightmap !== false : !movesByPhysics(def)
425
+ /** The static verdict for one node def (the editor's "Static" switch shows the same rule). Children of a
426
+ * mover are movers too — buildNodes carries that down, this is the node's own verdict. */
427
+ export const isStatic = (def: SceneNodeDef): boolean => def.static !== undefined ? def.static : !movesByPhysics(def)
423
428
 
424
429
  /** A terrain node: from its file or a fresh flat grid; textures load before the node appears (like a
425
430
  * mesh's material). Stamps and holes apply later in buildNodes, once every target node exists. */
@@ -443,23 +448,39 @@ const createTerrain = async (def: TerrainNodeDef, lightmap: boolean): Promise<No
443
448
  // cannot leak into the next node that leaves the flag alone.
444
449
  const modelTemplates = new Map<string, Promise<Model>>()
445
450
 
446
- const loadModel = (source: string, lightmap: boolean, foliage: boolean): Promise<Model> => {
447
- const key = `${lightmap}\u0000${foliage ? "foliage" : ""}\u0000${source}`
451
+ /** What one `load()` spent where — the `[load]` line instantiate prints (always on, like the host's
452
+ * `[startup]`): a slow open names its stage. `glb` is the wall from the first template's fetch to the last
453
+ * one's resolve (they overlap); `clone` / `aspects` are sums of synchronous work. */
454
+ const loadStats = { glb: 0, glbFirst: 0, glbLast: 0, clones: 0, cloneMs: 0, aspectsMs: 0 }
455
+ const resetLoadStats = (): void => { loadStats.glb = 0; loadStats.glbFirst = 0; loadStats.glbLast = 0; loadStats.clones = 0; loadStats.cloneMs = 0; loadStats.aspectsMs = 0 }
456
+
457
+ const templateModel = (source: string, lightmap: boolean, foliage: FoliageKind): Promise<Model> => {
458
+ const key = `${lightmap}\u0000${foliage || ""}\u0000${source}`
448
459
  let template = modelTemplates.get(key)
449
460
  if (template === undefined) {
450
- template = Model.load(source, { lightmap, foliage })
461
+ if (loadStats.glb++ === 0) loadStats.glbFirst = Date.now()
462
+ template = Model.load(source, { lightmap, foliage }).then((m) => { loadStats.glbLast = Date.now(); return m })
451
463
  modelTemplates.set(key, template)
452
464
  // a failed load must not poison the cache — the next node retries
453
465
  void template.catch(() => modelTemplates.delete(key))
454
466
  }
455
- return template.then((t) => t.clone())
467
+ return template
456
468
  }
457
469
 
470
+ const loadModel = (source: string, lightmap: boolean, foliage: FoliageKind): Promise<Model> =>
471
+ templateModel(source, lightmap, foliage).then((t) => {
472
+ const t0 = Date.now()
473
+ const c = t.clone()
474
+ loadStats.clones++
475
+ loadStats.cloneMs += Date.now() - t0
476
+ return c
477
+ })
478
+
458
479
  /** @internal */
459
480
  export const createSource = (path: string, def: SceneNodeDef, lightmap: boolean): Node | Promise<Node> => {
460
481
  const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera, def.terrain ].filter((s) => s !== undefined).length
461
482
  if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera/terrain)`)
462
- if (def.model !== undefined) return loadModel(def.model, lightmap, !!def.foliage)
483
+ if (def.model !== undefined) return loadModel(def.model, lightmap, foliageKindOf(def.foliage))
463
484
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
464
485
  if (def.terrain !== undefined) return createTerrain(def.terrain, lightmap)
465
486
  if (def.light !== undefined) return createLight(path, def.light)
@@ -848,7 +869,7 @@ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string
848
869
  }
849
870
 
850
871
  /** @internal */
851
- export const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>, lm: LightmapCtx | null): Promise<void> => {
872
+ export const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>, lm: LightmapCtx | null, covered?: Map<string, CoveredRoot & { node?: Node }>): Promise<void> => {
852
873
  const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
853
874
  if (!pdef || typeof pdef !== "object") {
854
875
  console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
@@ -862,7 +883,7 @@ export const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDe
862
883
  }
863
884
  const local: Record<string, Node> = {}
864
885
  const localRuns: EditorRun[] = [] // discarded: instance internals render, but aren't editor-tracked
865
- await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef), lm)
886
+ await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef), lm, covered, `${path}/`)
866
887
  const rows = prefabPartRows(pdef.nodes ?? {}, local, "", 0)
867
888
  ;(wrapper as { _prefabParts?: ModelPartRow[] })._prefabParts = rows
868
889
  if (def.overrides) applyOverridesToRows(rows, def.overrides)
@@ -874,13 +895,13 @@ export const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDe
874
895
  const buildNodes = async (
875
896
  defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
876
897
  nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
877
- lm: LightmapCtx | null = null,
898
+ lm: LightmapCtx | null = null, covered: Map<string, CoveredRoot & { node?: Node }> = new Map(), coveredPrefix = "",
878
899
  ): Promise<void> => {
879
900
  const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
880
901
  const moverRoots: Node[] = []
881
902
 
882
903
  // `parentMoves`: a child of a mover moves with it (the body under the hero's capsule) — it never
883
- // bakes, whatever its own def says short of an explicit `lightmap: true`
904
+ // bakes, whatever its own def says short of an explicit `static: true`
884
905
  const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null, parentPath: string, parentMoves = false): Promise<void> => {
885
906
  // names are path segments — '/' would fork the path, ':' would ambiguate editor card keys
886
907
  if (name === "" || name.includes("/") || name.includes(":")) {
@@ -889,7 +910,19 @@ const buildNodes = async (
889
910
  // the path is derived from def keys BEFORE any await, so it is deterministic even though
890
911
  // Promise.all makes build completion (and record insertion) order nondeterministic
891
912
  const path = parentPath === "" ? name : `${parentPath}/${name}`
892
- const lmStatic = lm !== null && (nd.lightmap !== undefined ? nd.lightmap !== false : !parentMoves && isLightmapStatic(nd))
913
+ // a subtree the static level cache rebuilds natively (gl/Statics.ts): one plain JS node, the clones under it
914
+ // (`coveredPrefix`: this build's full-path prefix — a prefab's internals are keyed from the level's root)
915
+ const cov = covered.size > 0 ? covered.get(coveredPrefix + path) : undefined
916
+ if (cov) {
917
+ const node = new Node()
918
+ if (parentNode) attachHost(parentNode, nd, path).add(node)
919
+ scene.add(node)
920
+ applyNode(node, name, nd)
921
+ cov.node = node
922
+ nodes[path] = node
923
+ return
924
+ }
925
+ const lmStatic = lm !== null && (nd.static !== undefined ? nd.static : !parentMoves && isStatic(nd))
893
926
  const lmCube = lmStatic && (nd.lightmap === "cube" || lm!.cube === true)
894
927
  // a mover in a level with a light volume reads the volume (models: the engine binds it; meshes: registered below)
895
928
  const node = await createSource(path, nd, lmStatic)
@@ -905,7 +938,7 @@ const buildNodes = async (
905
938
  if (lm !== null && !lmStatic && !parentMoves) moverRoots.push(node)
906
939
  if (EDITOR && isEditMode()) addEditorMarker(node, nd)
907
940
  // a prefab under a static wrapper bakes; under a mover its internals stay movers (volume-lit)
908
- if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/`, cube: lmCube } : null)
941
+ if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/`, cube: lmCube } : null, covered.size > 0 ? covered : undefined)
909
942
  pending.push({ path, node, def: nd })
910
943
  nodes[path] = node
911
944
  await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path, parentMoves || (lm !== null && !lmStatic))))
@@ -913,12 +946,14 @@ const buildNodes = async (
913
946
 
914
947
  await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent, "")))
915
948
  const makeWaits: Promise<void>[] = []
949
+ const tAspects = Date.now()
916
950
  for (const p of pending) {
917
951
  const aspectWait = attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
918
952
  if (aspectWait) makeWaits.push(aspectWait)
919
953
  const wait = attachMake(p.node, p.path, p.def, nodes, scene, editorRuns)
920
954
  if (wait) makeWaits.push(wait)
921
955
  }
956
+ loadStats.aspectsMs += Date.now() - tAspects
922
957
  if (makeWaits.length > 0) await Promise.all(makeWaits)
923
958
 
924
959
  // terrain stamps + holes: every node of the file exists now (make() subtrees included), and a
@@ -973,7 +1008,29 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
973
1008
  // baked lighting is a play-mode concern; the level owns the registry (a rebuild starts clean)
974
1009
  const lm: LightmapCtx | null = lightmap && !(EDITOR && isEditMode()) ? { prefix: "" } : null
975
1010
  if (lm) Lightmap.clear()
976
- await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm)
1011
+ resetLoadStats()
1012
+ const tLoad = Date.now()
1013
+ // THE STATIC LEVEL CACHE (gl/Statics.ts): with a fresh `<level>.static` beside the bake the covered subtree is
1014
+ // rebuilt natively and the JS builds the rest. Under a tool run (a bake, the snapshot itself) and in the editor
1015
+ // the level always builds the slow, complete way.
1016
+ const staticsCfg = staticsConfig()
1017
+ const roots = lm && lightmap && (staticsCfg || !toolRun()) ? coveredRoots(def.nodes, def) : []
1018
+ let prepared: LightmapPrepared | null = null
1019
+ let opened: OpenedCache | null = null
1020
+ if (roots.length > 0 && lightmap?.statics && lm && !staticsCfg && !toolRun()) {
1021
+ prepared = await Lightmap.prepare(scene, lightmap) // the pages first: the engine binds the records as it clones
1022
+ opened = await staticsOpen(lightmap.statics, defsDigest(roots, def), bakeDigest(prepared?.data ?? null))
1023
+ if (opened && opened.roots !== roots.length) { console.warn(`[static] the cache covers ${opened.roots} subtree(s), the scene has ${roots.length} — building the level the slow way`); opened = null }
1024
+ }
1025
+ const covered = new Map<string, CoveredRoot & { node?: Node }>()
1026
+ if (opened) for (const r of roots) covered.set(r.path, r)
1027
+ await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm, covered)
1028
+ let cachedMade = 0
1029
+ if (opened) {
1030
+ const pages = prepared ? Lightmap.pageIds(prepared) : new Uint32Array(0)
1031
+ cachedMade = await staticsBuild(opened, scene, roots.map((r) => covered.get(r.path)!.node!), pages, templateModel)
1032
+ }
1033
+ const nodesMs = Date.now() - tLoad
977
1034
  if (navmesh && !(EDITOR && isEditMode())) registerNavOverrides(def.nodes, nodes)
978
1035
 
979
1036
  // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
@@ -1004,12 +1061,35 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
1004
1061
  }
1005
1062
 
1006
1063
  // the level is complete: apply the bake — or, under `lecodes lightmap bake`, run it
1007
- if (lm && lightmap) await Lightmap.load(scene, lightmap)
1064
+ const tLm = Date.now()
1065
+ if (lm && lightmap) { if (prepared) await Lightmap.apply(prepared); else await Lightmap.load(scene, lightmap) }
1066
+ const lightmapMs = Date.now() - tLm
1008
1067
  // navigation: load the baked mesh — or, under `lecodes navmesh bake`, dump the level's collision
1009
1068
  // geometry for it (the static bodies exist by now; edit mode never loads it — v1)
1069
+ const tNav = Date.now()
1010
1070
  if (navmesh && !(EDITOR && isEditMode())) {
1011
1071
  await NavMesh.load(scene, { data: navmesh.data, agent: navmesh.agent, areas: navmesh.areas })
1012
1072
  }
1073
+ const navMs = Date.now() - tNav
1074
+ const glbMs = loadStats.glb > 0 ? Math.max(0, loadStats.glbLast - loadStats.glbFirst) : 0
1075
+ console.log(`[load] ${Date.now() - tLoad} ms: nodes ${nodesMs} (${Object.keys(nodes).length} top, glb ${loadStats.glb} in ${glbMs}, clones ${loadStats.clones} in ${loadStats.cloneMs}, aspects ${loadStats.aspectsMs}${cachedMade ? `, ${cachedMade} static placement(s) from the cache` : ""}), lightmap ${lightmapMs}, navmesh ${navMs}`)
1076
+
1077
+ // `lecodes static`: the level is complete and bound — snapshot the covered subtree beside the bake
1078
+ if (staticsCfg && lightmap) {
1079
+ const out = `${staticsCfg.root}/${lightmap.path.replace(/\.bake$/, ".static")}`
1080
+ const live = roots.map((r) => ({ root: r, node: findNode(nodes, r.path) }))
1081
+ const lost = live.filter((r) => !r.node)
1082
+ if (roots.length === 0) console.error("[static] error: nothing to cache — the level has no static subtree (models and their colliders under plain nodes)")
1083
+ else if (lost.length > 0) console.error(`[static] error: no live node for ${lost.map((r) => r.root.path).join(", ")}`)
1084
+ else if (!_creator.staticsWrite) console.error("[static] error: this host cannot write the cache (build the desktop host)")
1085
+ else {
1086
+ const bake = Lightmap._bakeData()
1087
+ const snap = staticsSnapshot(live as { root: CoveredRoot, node: Node }[], def, bake, defsDigest(roots, def))
1088
+ if (_creator.staticsWrite(out, snap.bytes)) console.log(`[static] wrote ${out}: ${snap.placements} placement(s) of ${snap.kinds} kind(s), ${snap.records} bake record(s), ${(snap.bytes.length / 1024).toFixed(0)} KB${bake ? "" : " (no bake bound)"}`)
1089
+ else console.error(`[static] error: could not write ${out}`)
1090
+ }
1091
+ console.log("[static] done")
1092
+ }
1013
1093
 
1014
1094
  return { scene, nodes }
1015
1095
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `env` on the SERVER — every key of the project (src/core/env.ts says what `env` is). The values
3
+ * (Settings → Keys on le.codes, the project's `.env` on this machine) are handed to the bundle by the
4
+ * host that runs it (`setEnv`) through the `globalThis.__lecodesEnv` seam — a global on purpose,
5
+ * like `__lecodesRequest`: the bundle carries its own SDK copy, so the host can't pass a value by
6
+ * import. The compile lists the names the server code reads (serverSplit.ts `scanEnvReads`) so a
7
+ * deploy can warn before any call does.
8
+ */
9
+
10
+ import { makeEnv, type Env } from "../core/env"
11
+ export { ENV_NAME, PUBLIC_ENV_PREFIX, isPublicEnvName, EnvError, type Env } from "../core/env"
12
+
13
+ const seam = globalThis as unknown as { __lecodesEnv?: Record<string, string> }
14
+
15
+ /** Host hook (runner / local backend / tests): the values the bundle reads. `null` = none set. */
16
+ export const setEnv = (values: Record<string, string> | null) => {
17
+ if (values) seam.__lecodesEnv = { ...values }
18
+ else delete seam.__lecodesEnv
19
+ }
20
+
21
+ /** @internal */
22
+ export const currentEnv = (): Record<string, string> => seam.__lecodesEnv ?? {}
23
+
24
+ /** The project's keys. A missing key throws `EnvError` on read. */
25
+ export const env: Env = makeEnv(currentEnv)
@@ -10,6 +10,8 @@ export {
10
10
  } from "./runtime"
11
11
  export type { LoadedServer, InvokeResult, InvokerOptions, ServerRegistration, SeedOutcome } from "./runtime"
12
12
  export type { RequestContext } from "./context"
13
+ export { setEnv, EnvError, ENV_NAME, PUBLIC_ENV_PREFIX, isPublicEnvName } from "./env"
14
+ export type { Env } from "./env"
13
15
  export { createChannelHub } from "./channelHub"
14
16
  export type { ChannelHub, ChannelHubOptions, HubConnection, HubFanout } from "./channelHub"
15
17
  export type { ChannelGroup, ClientFrame, ServerFrame } from "../runtime/wire"
@@ -61,6 +63,9 @@ export type ServerDescription = {
61
63
  auth: { model: string | null }
62
64
  /** `app.json` name (when the compile embedded one). */
63
65
  appName?: string
66
+ /** The keys the server code reads (`env.NAME`, serverSplit.ts `scanEnvReads`): what a deploy checks
67
+ * against the keys that are set, so a missing one is a warning before it is a failed call. */
68
+ env: string[]
64
69
  }
65
70
 
66
71
  const describeField = (f: Field<any, any>): FieldDescription => {
@@ -107,5 +112,6 @@ export const describeServer = (server: LoadedServer): ServerDescription => {
107
112
  pushChannels: [...server.pushChannels.keys()],
108
113
  auth: { model: db?.$auth?.model ?? null },
109
114
  appName: server.manifest.app?.name,
115
+ env: [...(server.manifest.env ?? [])],
110
116
  }
111
117
  }
@@ -3,12 +3,13 @@
3
3
  * chisel injects these into the server bundle exactly like ./inject.ts does for the client bundle;
4
4
  * nothing here may reach the client bundle. Keep this the ONLY file that exports server globals.
5
5
  *
6
- * Globals: t, model, defineDb, ref (schema + queries), ApiError, channel, pushChannel, request. Sign-in is
6
+ * Globals: t, model, defineDb, ref (schema + queries), ApiError, channel, pushChannel, request, env. Sign-in is
7
7
  * not a global: it is `db.auth` of a db made with `defineDb({...}).withAuth({ model })`.
8
8
  */
9
9
 
10
10
  export { t, model, defineDb, ref } from "./db"
11
11
  export { ApiError } from "./errors"
12
+ export { env, type Env } from "./env"
12
13
  export { channel } from "./channel"
13
14
  export { pushChannel, type PushChannel, type PushMessage, type PushNotification, type PushTarget } from "./pushChannel"
14
15
  export { request } from "./context"
package/src/ui/fonts.ts CHANGED
@@ -11,3 +11,49 @@ export async function registerFont (fontFamily: string, url: string, options: Fo
11
11
  _creatorTree.registerFont(fontFamily, url, options.weight ?? 400, options.style === "italic" ? 2 : 0, res, rej)
12
12
  })
13
13
  }
14
+
15
+ // ---- the font() macro's faces --------------------------------------------------------------------
16
+ // The `font("inter")` compile macro (compile/fontMacro.ts) emits one `__registerFont(...)` per face
17
+ // into a generated `.__fonts.ts` module. That registers the face with the UI tree (in the host's
18
+ // preferred container — woff2 on browsers) AND records its TTF URL here, so a `Font2D.load("Inter")`
19
+ // (whose native parser reads TTF/OTF only, never woff2) takes the same identifier UI text takes.
20
+ // The map is created on first use — no top-level side effect for chisel to drop.
21
+
22
+ type FaceRecord = { family: string, weight: number, italic: boolean, url: string }
23
+
24
+ let registeredFaces: Map<string, FaceRecord[]> | undefined // lower-cased family → its faces
25
+ const facesMap = (): Map<string, FaceRecord[]> => (registeredFaces ??= new Map())
26
+
27
+ /** The macro's registration call (a bundle global, like `__rpc`). `url` is either a full URL (a
28
+ * project / vendored entry) or a cdn base ending in "." — the format is appended here: the host's
29
+ * for the UI tree, always ttf for 2D text. */
30
+ export function __registerFont (family: string, url: string, weight: number, italic: boolean): void {
31
+ const base = url.endsWith(".")
32
+ const ttfUrl = base ? url + "ttf" : url
33
+ const key = family.toLowerCase()
34
+ const faces = facesMap().get(key) ?? []
35
+ const same = faces.find((f) => f.weight === weight && f.italic === italic)
36
+ if (same) same.url = ttfUrl
37
+ else faces.push({ family, weight, italic, url: ttfUrl })
38
+ facesMap().set(key, faces)
39
+ _creatorTree.registerFont(family, base ? url + (_creatorTree.fontFormat ?? "ttf") : url, weight, italic ? 2 : 0, () => {}, () => {})
40
+ }
41
+
42
+ /** @internal The TTF URL of a `font()`-registered face: the exact (weight, italic) face first, else
43
+ * the nearest registered weight of that family in that style (a tie goes to the heavier), else
44
+ * undefined. Family names match case-insensitively. */
45
+ export function _fontUrlOf (family: string, weight = 400, italic = false): string | undefined {
46
+ const faces = registeredFaces?.get(family.toLowerCase())?.filter((f) => f.italic === italic)
47
+ if (!faces || faces.length === 0) return undefined
48
+ let best = faces[0]!
49
+ for (const f of faces) {
50
+ const d = Math.abs(f.weight - weight), bd = Math.abs(best.weight - weight)
51
+ if (d < bd || (d === bd && f.weight > best.weight)) best = f
52
+ }
53
+ return best.url
54
+ }
55
+
56
+ /** @internal The registered families (their declared spelling) — for "unknown family" errors. */
57
+ export function _registeredFontFamilies (): string[] {
58
+ return registeredFaces ? [ ...registeredFaces.values() ].map((faces) => faces[0]!.family) : []
59
+ }
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.4.10"
7
+ export const SDK_VERSION = "2.5.0"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {