diff --git a/docs/api.md b/docs/api.md index 540953c..fa164f1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,7 +10,7 @@ live UI, with the chart's music and sound effects, auto-played at Perfect. The d |---|---| | `ournotes-player` | `ChartPlayer`, `ChartSession`, `AssetStore`, `loadLiveStore`, `defineOurnotesPlayer`, `OurnotesPlayerElement`, `LIVE_SPEEDS`, `LIVE_OPTIONS`, `LIVE_OPTION_GROUPS`, `LiveSettingsError`, `PLAYER_LANGUAGES`, `formatTime` | | `ournotes-player/element` | the same exports; importing it defines `` | -| `ournotes-player/ui` (experimental) | `UILibrary`, `UISession`, `UIPlayer`, `OurnotesUIElement`, `defineOurnotesUI`, `cameraProjection`; [prefab preview API and limits](ui-preview.md) | +| `ournotes-player/ui` (experimental) | `UIPack`, `UI_FORMAT`, `UI_LIBRARY`, `UI_PRODUCT`, `UILibrary`, `UISession`, `UIPlayer`, `OurnotesUIElement`, `defineOurnotesUI`, `cameraProjection`; [prefab preview API and limits](ui-preview.md) | | `ournotes-player/ui/element` (experimental) | the same UI exports; importing it defines `` | | `ournotes-player/replay/preset` | `parseJustJudgementTypes`, `applyAccuracyPreset`, `applySegmentPreset`: declared input plans that set the judgement grades of an `ournotes.replay/1` request in place | diff --git a/docs/ui-preview.md b/docs/ui-preview.md index 33e6f44..6b6331a 100644 --- a/docs/ui-preview.md +++ b/docs/ui-preview.md @@ -1,28 +1,29 @@ # UI prefab preview (experimental) -The optional `ournotes-player/ui` module previews serialized UI assets exported by `nnnotes ui`. It uses Canvas 2D +The optional `ournotes-player/ui` module previews serialized UI assets exported by `nnnotes export ui`. It uses Canvas 2D and is an inspector, not the game's GPU renderer or an implementation of every page Presenter. It does not change the chart, story or Live2D entry points. The repository, example and npm package include no game assets. ## Load a library -Export data outside this repository with [nnnotes ui](https://github.com/MetaSekaiLab/nnnotes), then serve that data +Export data outside this repository with [nnnotes export ui](https://github.com/MetaSekaiLab/nnnotes), then serve that data over HTTP. Cross-origin resources need CORS. The index and packs use the -`ournotes-ui-library` / `ournotes-ui-pack` format (schema 1). Legacy library indexes with -`dependency-controllers.json` are also accepted. +`nnnotes.ui-library/1` index and `nnnotes.product/1` manifests for `ui.pack`. Each product contains `ui.json` +(`nnnotes.ui-pack/1`) and the shared object files of its prefabs, controllers, clips, images and fonts. +See [ui-library schema](../schema/ui-library.schema.json) and [ui-pack schema](../schema/ui-pack.schema.json). ```js import { UILibrary, UIPlayer } from 'ournotes-player/ui'; const library = await UILibrary.load('/ui-data/index.json'); -const entry = library.entries.find(e => e.kind === 'prefab' && e.file); +const entry = library.entries.find(e => e.kind === 'prefab' && e.manifest); const player = await UIPlayer.create(document.querySelector('#stage'), { library, entry }); await player.playState('Open', { animator: player.animators[0].index }); player.play(); ``` -`UIPlayer.create(host, {src: '/ui-data/packs/ID.json'})` loads a standalone pack. A standalone pack can use an -inline controller; separately exported controllers require a `UILibrary`. Importing `ournotes-player/ui` does not +`UIPlayer.create(host, {src: '/ui-data/uipacks/ID.json'})` loads one product, including its controllers and clips. +`UIPack.load(url)` loads the same native object data for a DOM-independent `UISession`. Importing `ournotes-player/ui` does not register elements or require a DOM at import time. Constructing the player requires a browser Canvas 2D context. The [example viewer](../examples/ui/index.html) takes a user-supplied library URL and provides selection and motion controls; it has no bundled sample data. @@ -48,9 +49,9 @@ an entry record. Ambiguous names throw; same-named controllers resolve by their `UISession(pack, {bindings: true})` is DOM-independent. It clones the input and provides: -- `edit(node, component, field, value)` for a node index, stable nodeId or unique path/name. `component: null` +- `edit(node, component, field, value)` for a node index, stable object ID or unique path/name. `component: null` edits a node field; dotted field paths are supported and prototype-related paths are rejected. -- `setController(node, {document, resources})`, `playState(nameOrIndex)`, `selectClip(nameOrIndex)` and +- `setController(node, pack.controller(nodeIndex))`, `playState(nameOrIndex)`, `selectClip(nameOrIndex)` and `selectSequence(node)`. - `setParameter(name, value)`, `seek(seconds)`, `update(deltaSeconds)`, `reset()` and `prepare()`. @@ -128,7 +129,7 @@ fail explicitly. Runtime camera composition and Presenter-driven scene sizing re `framing: 'content'` uses visible graphic bounds, including labels and graphics outside the root, and crops only the output canvas's transparent margins; source geometry is unchanged. Its bounds are conservative RectTransform/mask bounds, not an alpha-pixel scan. Default `framing: 'root'` retains the previous viewport. -Render results expose `regions[nodeId || path]`, four projected corners in top-left, top-right, bottom-right, +Render results expose `regions[id || path]`, four projected corners in top-left, top-right, bottom-right, bottom-left order for visible nodes. Coordinates are logical root coordinates before canvas padding/scale; convert a point to bitmap coordinates as `(point - bounds.min + padding) * scale`. Stable IDs avoid ambiguity for duplicate instance paths. Regions describe the node rectangle; caller input handling and mask-aware hit @@ -141,18 +142,15 @@ crossfade are preview approximations; long Animator advances are limited to 1,00 unbound images or active flags may be placeholders, not evidence that a component is obsolete. Use bindings, edits or your own fixtures for its real use case. -The rendering review was based on international Android 1.0.1 (versionCode 25), not a full JP UI comparison. -The data reader accepts a separately exported JP library; differing schemas/fonts can remain unsupported. -Neither region is advertised as pixel-identical or runtime-complete. +## Data and verification -## Evidence and verification +Prefab nodes retain `pathId`, transforms and native `components[{type, script, fields}]`. A `UIPack` owns mutable +node copies while sharing immutable `AssetStore` data; `clone()` copies only the preview state. `UIController.document` +is the native controller, whose `clips` are references; `UIController.clip(index)` reads a clip by object ID. +Animation binding names come from the product's per-prefab table. Texture/font reads follow object references. -Game-specific state-binding rules were studied in the international binary's methods such as UIToggle.SwitchObjects -(`0x6ac4e74`), UIToggleButtonFrame (`0x647fcb8` / `0x64888e0`), DOTweenSequence.CreateSequence (`0x6aded88`) and -SimpleAnimationTrigger.PlayAnimation (`0x6b1f2f8`). Unity geometry/layout semantics are marked ENGINE in the helpers. -GetChildSizes' force-expand operation was checked in native instructions at `0xbd29fec..0xbd2a004`; its C dump -omitted that branch. These addresses are version-specific evidence, not runtime offsets or JP method identities. +`UIPack.browserFontFamily` optionally supplies a CSS font family for preview text. Otherwise embedded font files +are loaded per referenced font face, and numeric font glyphs use their native atlas pages and metrics. -Unit tests use only synthetic records/fonts and cover loading, cancellation, lifecycle, duplicate instances, -curves, state seek, sequence timing and layout invariants. Separate local visual review uses user-supplied data -outside the repository. A successful static render/curve audit is not pixel-fidelity verification. +Synthetic tests cover product loading, cancellation, lifecycle, duplicate instances, curves, state seek, sequence +timing and layout invariants. diff --git a/examples/ui/index.html b/examples/ui/index.html index 82405f4..c66bfb8 100644 --- a/examples/ui/index.html +++ b/examples/ui/index.html @@ -10,7 +10,7 @@
-

Choose a library

Export with nnnotes ui and serve the output over HTTP.

+

Choose a library

Export with nnnotes export ui and serve the output over HTTP.

Your exported prefab will appear here.

diff --git a/examples/ui/ui.js b/examples/ui/ui.js index c2e65b1..2f72933 100644 --- a/examples/ui/ui.js +++ b/examples/ui/ui.js @@ -24,7 +24,7 @@ function list(){ async function select(id){ const revision=++generation,entry=library.find(id);player?.destroy();player=null;canvas.hidden=true;$('#empty').hidden=false;$('#empty').textContent='Loading prefab…'; - $('#name').textContent=entry.name;$('#key').textContent=entry.key||entry.file;$('#parameters').replaceChildren(); + $('#name').textContent=entry.name;$('#key').textContent=entry.key||entry.manifest;$('#parameters').replaceChildren(); fill($('#state'),'State',[]);fill($('#clip'),'Clip',[]);$('#play').disabled=$('#time').disabled=$('#reset').disabled=true; const loaded=await UIPlayer.create(canvas,{library,entry,showHidden:$('#hidden').checked,bounds:$('#bounds').checked}); if(revision!==generation){loaded.destroy();return;}player=loaded;canvas.hidden=false;$('#empty').hidden=true;$('#reset').disabled=false; @@ -35,13 +35,13 @@ async function select(id){ $('#open').onsubmit=async e=>{ e.preventDefault();const button=e.currentTarget.querySelector('button');button.disabled=true; - try{library=await UILibrary.load($('#source').value);entries=library.entries.filter(e=>['prefab','embedded'].includes(e.kind)&&e.file&&e.status!=='failed');list();if(!entries.length)throw Error('No exported prefabs in this library');$('#assets').value=entries[0].id;await select(entries[0].id);}catch(e){error(e);}finally{button.disabled=false;} + try{library=await UILibrary.load($('#source').value);entries=library.entries.filter(e=>e.kind==='prefab'&&e.manifest);list();if(!entries.length)throw Error('No exported prefabs in this library');$('#assets').value=entries[0].id;await select(entries[0].id);}catch(e){error(e);}finally{button.disabled=false;} }; $('#search').oninput=list;$('#assets').onchange=run(()=>select($('#assets').value)); $('#animator').onchange=run(async()=>{ $('#parameters').replaceChildren();fill($('#state'),'State',[]);fill($('#clip'),'Clip',[]);await player.reset();$('#sequence').value='';if(!$('#animator').value)return; const exported=await player.controller(Number($('#animator').value)),controller=exported.document,machine=controller.stateMachines?.[controller.layers?.[0]?.stateMachine||0]; - fill($('#state'),'State',(machine?.states||[]).map((s,i)=>({name:s.name,value:i})));fill($('#clip'),'Clip',(controller.clips||[]).map((c,i)=>({name:c.clip,value:i}))); + fill($('#state'),'State',(machine?.states||[]).map((s,i)=>({name:s.name,value:i})));fill($('#clip'),'Clip',(controller.clips||[]).map((_,i)=>({name:exported.clip(i).name,value:i}))); for(const parameter of controller.parameters||[]){ if(parameter.type===9){const button=document.createElement('button');button.textContent=parameter.name;button.onclick=run(()=>player.setParameter(parameter.name,true));$('#parameters').append(button);} else{const label=document.createElement('label');label.textContent=parameter.name;const input=document.createElement('input');input.type=parameter.type===4?'checkbox':'number';input.step=parameter.type===3?'1':'any';input.value='0';input.onchange=run(()=>player.setParameter(parameter.name,input.type==='checkbox'?input.checked:Number(input.value)));label.append(input);$('#parameters').append(label);} diff --git a/schema/ui-library.schema.json b/schema/ui-library.schema.json new file mode 100644 index 0000000..10b2f48 --- /dev/null +++ b/schema/ui-library.schema.json @@ -0,0 +1,148 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/empty-sekai/ournotes-player/main/schema/ui-library.schema.json", + "type": "object", + "required": [ + "format", + "assets", + "embedded", + "controllers" + ], + "properties": { + "format": { + "const": "nnnotes.ui-library/1" + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "manifest", + "key", + "kind", + "roots" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "manifest": { + "type": "string", + "minLength": 1 + }, + "roots": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "key": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string" + }, + "nodeCount": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "embedded": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "manifest", + "roots", + "kind" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "manifest": { + "type": "string", + "minLength": 1 + }, + "roots": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "key": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string" + }, + "nodeCount": { + "type": "integer", + "minimum": 0 + } + } + } + }, + "controllers": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "manifest" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "manifest": { + "type": "string", + "minLength": 1 + }, + "roots": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "key": { + "type": "string", + "minLength": 1 + }, + "kind": { + "type": "string" + }, + "nodeCount": { + "type": "integer", + "minimum": 0 + } + } + } + } + }, + "title": "UI product library" +} diff --git a/schema/ui-pack.schema.json b/schema/ui-pack.schema.json new file mode 100644 index 0000000..6eb7850 --- /dev/null +++ b/schema/ui-pack.schema.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/empty-sekai/ournotes-player/main/schema/ui-pack.schema.json", + "type": "object", + "required": [ + "format", + "key", + "roots", + "prefabs", + "controllers", + "entries", + "bindings", + "shaders" + ], + "properties": { + "format": { + "const": "nnnotes.ui-pack/1" + }, + "key": { + "type": "string", + "minLength": 1 + }, + "roots": { + "type": "array", + "items": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + }, + "minItems": 1 + }, + "prefabs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + }, + "controllers": { + "type": "array", + "items": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "class" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "class": { + "type": "string" + }, + "nodeCount": { + "type": "integer", + "minimum": 0 + }, + "classes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + } + } + } + }, + "bindings": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "required": [ + "path", + "attribute" + ], + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "attribute": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "propertyNames": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + } + }, + "propertyNames": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + }, + "shaders": { + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^.+:-?[0-9]+$" + } + } + }, + "title": "UI object product" +} diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs index ff5a393..2cc64c5 100644 --- a/scripts/validate-data.mjs +++ b/scripts/validate-data.mjs @@ -61,7 +61,7 @@ const S = { stories: schema("stories"), storyManifest: schema("story-manifest"), story: schema("story"), episode: schema("episode"), storyFonts: schema("story-fonts"), storyLanguage: schema("story-language"), storyScene: schema("story-scene"), storyUI: schema("story-ui"), storySimpleUI: schema("story-simple-ui"), - storyHost: schema("story-host"), storyAsset: schema("story-asset"), + storyHost: schema("story-host"), storyAsset: schema("story-asset"), uiPack: schema("ui-pack"), uiLibrary: schema("ui-library"), }; const SCHEMA_STORY_FONTS = S.storyFonts; @@ -431,7 +431,7 @@ class LiveChecks { object(id, cls, where) { if (typeof id !== "string" || !this.S.hasObject(id)) { this.err(where, `object ${id} not in the store`); return false; } const c = this.S.object(id).class; - if (c !== cls) { this.err(where, `${id}: a ${c}, not a ${cls}`); return false; } + if (cls !== null && c !== cls) { this.err(where, `${id}: a ${c}, not a ${cls}`); return false; } return true; } @@ -980,8 +980,40 @@ class Part extends Product { } } -// A live's product manifest (lives/.json, the product `live`) with the products its live.json `parts` names, each -// checked once (Site.part). +// A UI product and the library entries that select its prefabs and controllers. +class UI extends Product { + run(entry) { + for (const e of S.product(this.man)) this.err("manifest", e); + if (this.man.product !== "ui.pack") this.err("manifest", "expected ui.pack"); + if (this.errs.length) return this.errs; + this.checkAssets(); + if (this.errs.length) return this.errs; + const store = new SiteStore(this.site, this.man.files, this.man.objects); + const C = new LiveChecks(this.site, store, (where, message) => this.err(where, message)); + const ui = C.doc("ui.json", S.uiPack); + if (!ui) return this.errs; + if (ui.key !== this.man.subject) this.err("ui.json", "key differs from product subject"); + for (const [field, cls] of [["roots", null], ["prefabs", "GameObject"], ["controllers", "AnimatorController"]]) { + if (new Set(ui[field]).size !== ui[field].length) this.err("ui.json", `${field}: duplicate object id`); + for (const id of ui[field]) C.object(id, cls, `ui.json ${field}`); + } + for (const id of ui.prefabs) C.prefab(id, "ui.json prefabs"); + C.tables("ui.json", ui); + // Canvas 2D preview reads object data and images; it selects no GPU material variants. + C.objects({ selectedPrograms: true, materialKeywords: new Map(Object.entries(this.man.objects) + .filter(([, o]) => o.class === "Material").map(([id]) => [id, []])) }); + if (entry && (entry.key !== ui.key || !sameJson(entry.roots, ui.roots))) this.err("index.json", "entry differs from UI product roots"); + for (const [group, cls, ids] of [["embedded", "GameObject", ui.prefabs], ["controllers", "AnimatorController", ui.controllers]]) + for (const row of this.site.uiIndex?.[group] || []) if (row.manifest === this.where) { + C.object(row.id, cls, `index.json ${group}`); + if (!ids.includes(row.id)) this.err("index.json", `${group}: ${row.id} not listed in the UI product`); + if (group === "embedded" && !sameJson(row.roots, [row.id])) this.err("index.json", `embedded: ${row.id} roots differ from its object id`); + } + return this.errs; + } +} + +// A live manifest with the products its live.json parts name, each checked once (Site.part). class Live extends Product { run() { const m = this.man; @@ -1536,26 +1568,49 @@ function main(argv) { stories = [...files("stories"), ...list("stories").filter((e) => e.isDirectory()).map((e) => e.name).sort() .flatMap((r) => files(`stories/${r}`))]; } + let uiPacks = !fs.existsSync(site.file("uipacks")) ? [] : fs.readdirSync(site.file("uipacks")).filter(f => f.endsWith(".json")).sort() + .map(f => ({ id: f.slice(0, -5), manifest: `uipacks/${f}`, entry: null })); + if (fs.existsSync(site.file("index.json"))) { + const index = JSON.parse(fs.readFileSync(site.file("index.json"), "utf8")); + if (index.format === "nnnotes.ui-library/1") { + site.uiIndex = index; + out.push(...S.uiLibrary(index).map(e => `index.json: ${e}`)); + if (!out.length) { + for (const group of [index.assets, index.embedded, index.controllers]) { + const ids = new Set(); + for (const entry of group) { + if (ids.has(entry.id)) out.push(`index.json: duplicate id ${entry.id}`); + ids.add(entry.id); + if (!uiPacks.some(p => p.manifest === entry.manifest)) out.push(`index.json: missing product ${entry.manifest}`); + } + } + uiPacks = uiPacks.map(p => ({ ...p, entry: index.assets.find(e => e.manifest === p.manifest) || null })); + } + } + } let liveList = lives; if (only.length) { const want = new Set(only), kinds = `${lives.length ? "live, " : ""}chart, model${stories.length ? " or story" : ""}`; + uiPacks = uiPacks.filter(e => want.has(e.id)); liveList = lives.filter((e) => want.has(e.id)); entries = entries.filter((e) => want.has(e.id)); models = models.filter((e) => want.has(e.id)); stories = stories.filter((e) => want.has(e.id)); for (const id of want) - if (![liveList, entries, models, stories].some((l) => l.some((e) => e.id === id))) { console.error(`${kinds} ${id} not found`); return 2; } + if (![liveList, entries, models, stories, uiPacks].some((l) => l.some((e) => e.id === id))) { console.error(`${kinds} ${id} not found`); return 2; } } + const okUI = runAll(site, uiPacks, UI); const okLives = runAll(site, liveList, Live); const ok = runAll(site, entries, Chart); const okModels = runAll(site, models, Model); const okStories = runAll(site, stories, Story); for (const e of out) console.log(e); + if (uiPacks.length) console.log(`${okUI}/${uiPacks.length} UI products valid`); if (liveList.length) console.log(`${okLives}/${liveList.length} lives valid`); - if (entries.length || (!liveList.length && !models.length && !stories.length)) console.log(`${ok}/${entries.length} charts valid`); + if (entries.length || (!liveList.length && !models.length && !stories.length && !uiPacks.length)) console.log(`${ok}/${entries.length} charts valid`); if (models.length) console.log(`${okModels}/${models.length} models valid`); if (stories.length) console.log(`${okStories}/${stories.length} stories valid${site.storyLanguages ? ` (languages: ${site.storyLanguages.join(", ")})` : ""}`); - return okLives === liveList.length && ok === entries.length && okModels === models.length && okStories === stories.length + return okUI === uiPacks.length && okLives === liveList.length && ok === entries.length && okModels === models.length && okStories === stories.length && !out.length ? 0 : 1; } diff --git a/src/ui/animator.js b/src/ui/animator.js index 5158c64..6963b6c 100644 --- a/src/ui/animator.js +++ b/src/ui/animator.js @@ -12,12 +12,12 @@ export function controllerParameters(controller){ })); } -export function stateMotion(controller,state){ +export function stateMotion(controller,state,clipOf){ const trees=state?.blendTrees||[],nodes=trees.flat(); if(nodes.some(n=>n.children?.length))return {unsupported:'blend_tree',duration:1}; const motions=nodes.filter(n=>Number.isInteger(n.clip)&&controller.clips[n.clip]); if(motions.length>1)return {unsupported:'multiple_motions',duration:1}; - const clip=motions.length?controller.clips[motions[0].clip]:null; + const clip=motions.length?clipOf(motions[0].clip):null; return {clip,duration:Math.max(.000001,clip?(clip.stopTime-clip.startTime):1)}; } @@ -31,7 +31,7 @@ export function transitionConditions(transition,parameters){ function blendPose(from,to,weight,pack){ const nodes=nodeMap(pack),result=new Map(); for(const key of new Set([...from.keys(),...to.keys()])){ - const a=from.get(key),b=to.get(key),op=b||a,n=op.nodeIndex!==undefined?pack.document.nodes[op.nodeIndex]:nodes.get(op.node),target=op.componentIndex===null?n:n?.components[op.componentIndex]; + const a=from.get(key),b=to.get(key),op=b||a,n=op.nodeIndex!==undefined?pack.nodes[op.nodeIndex]:nodes.get(op.node),target=op.componentIndex===null?n:n?.components[op.componentIndex]?.fields; const base=field(target,op.field),av=a?a.value:base,bv=b?b.value:base; if(av===undefined&&bv===undefined)continue; const value=typeof av==='number'&&typeof bv==='number'?av+(bv-av)*weight:weight<.5?(av??bv):(bv??av); @@ -50,7 +50,7 @@ function blendPose(from,to,weight,pack){ // Animation events are collected as data; native callbacks are not executed. export class AnimatorRuntime { constructor(controller,rootPath){ - this.controller=controller;this.rootPath=rootPath;this.machine=controller.stateMachines?.[controller.layers?.[0]?.stateMachine||0]; + this.clipOf=controller.clip;this.controller=controller.document;controller=controller.document;this.rootPath=rootPath;this.machine=controller.stateMachines?.[controller.layers?.[0]?.stateMachine||0]; this.defaults=controllerParameters(controller);this.parameterEvents=[];this.startState=this.machine?.defaultState||0; this.warnings=[];if((controller.layers||[]).length!==1)this.warnings.push('unsupported_layer_count'); this.reset(); @@ -81,7 +81,7 @@ export class AnimatorRuntime { this.advance(Math.max(0,time));return this; } stateInfo(cursor=this.current){ - const state=this.machine?.states[cursor?.index],motion=stateMotion(this.controller,state); + const state=this.machine?.states[cursor?.index],motion=stateMotion(this.controller,state,this.clipOf); const speed=(state?.speed??1)*(state?.speedParam?Number(this.parameters[state.speedParam]??1):1); return {state,...motion,speed}; } @@ -123,7 +123,7 @@ export class AnimatorRuntime { const normalized=((event.time||0)-(clip.startTime||0))/duration; const first=loop?Math.floor(before+offset)-1:0,last=loop?Math.ceil(after+offset):0; for(let cycle=first;cycle<=last;cycle++){ - const at=normalized+cycle-offset;if(at>before+1e-8&&at<=after+1e-8)this.events.push({...event,clip:clip.clip,state:state.name,at:this.timelineTime}); + const at=normalized+cycle-offset;if(at>before+1e-8&&at<=after+1e-8)this.events.push({...event,clip:clip.id,state:state.name,at:this.timelineTime}); } } if(this.events.length>100)this.events.splice(0,this.events.length-100); diff --git a/src/ui/game-font.js b/src/ui/game-font.js index 8fc0f68..841494f 100644 --- a/src/ui/game-font.js +++ b/src/ui/game-font.js @@ -1,10 +1,16 @@ // Game glyph shapes and advances; CPU distance-field antialiasing is approximate. -const fonts=new Map(); -export async function loadGameFont(loadImage,url,resolve){ - if(!fonts.has(url))fonts.set(url,fetch(url).then(r=>{if(!r.ok)throw Error(`UI font metrics: ${r.status} ${url}`);return r.json();}).then(async data=>({...data,atlas:await loadImage(data.textureBase==='metrics'?new URL(data.texture,url).href:resolve(data.texture)),cache:new Map()})).catch(error=>{fonts.delete(url);throw error;})); - return fonts.get(url); +const fonts=new WeakMap(); +export async function loadGameFont(pack,id){ + if(!fonts.has(pack.store))fonts.set(pack.store,new Map()); + const cache=fonts.get(pack.store); + if(!cache.has(id))cache.set(id,(async()=>{ + const data=pack.objects.mono(id); + return {face:data.m_FaceInfo,characters:Object.fromEntries(data.m_CharacterTable.map(c=>[c.m_Unicode,c.m_GlyphIndex])), + glyphs:Object.fromEntries(data.m_GlyphTable.map(g=>[g.m_Index,g])),atlases:await Promise.all(data.m_AtlasTextures.map(ref=>pack.image(ref))),cache:new Map()}; + })().catch(error=>{cache.delete(id);throw error;})); + return cache.get(id); } -export function hasGameGlyphs(text,asset,font){return font&&asset?.name?.includes('VibeMO')&&[...text].every(c=>c==='\n'||font.characters[c.codePointAt(0)]!==undefined);} +export function hasGameGlyphs(text,font){return font&&[...text].every(c=>c==='\n'||font.characters[c.codePointAt(0)]!==undefined);} export function gameTextWidth(text,size,font){return [...text].reduce((total,c)=>total+(font.glyphs[font.characters[c.codePointAt(0)]]?.m_Metrics.m_HorizontalAdvance||0),0)*size/font.face.m_PointSize;} export function drawGameText(ctx,text,x,y,size,color,font){ const cache=font.cache,width=gameTextWidth(text,size,font),scale=size/font.face.m_PointSize; @@ -17,7 +23,7 @@ export function drawGameText(ctx,text,x,y,size,color,font){ if(!cache.has(key)){ if(cache.size>2048)cache.delete(cache.keys().next().value); const canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(r.m_Width*scale));canvas.height=Math.max(1,Math.round(r.m_Height*scale));const c=canvas.getContext('2d'); - c.drawImage(font.atlas,r.m_X,font.atlas.height-r.m_Y-r.m_Height,r.m_Width,r.m_Height,0,0,canvas.width,canvas.height); + const atlas=font.atlases[glyph.m_AtlasIndex||0];c.drawImage(atlas,r.m_X,atlas.height-r.m_Y-r.m_Height,r.m_Width,r.m_Height,0,0,canvas.width,canvas.height); const pixels=c.getImageData(0,0,canvas.width,canvas.height); for(let i=0;igroupAxis(g,children,axis)); +export function linearGroupInput(group,children,horizontal=false){ + const g=groupSettings(group,horizontal),axes=[0,1].map(axis=>groupAxis(g,children,axis)); return {min:axes.map(a=>a.min),preferred:axes.map(a=>a.preferred),flexible:axes.map(a=>a.flexible)}; } // SetChildrenAlongAxis: cells may expand while an uncontrolled child keeps its // serialized size. Positions include SetChildAlongAxisWithScale's pivot term. -export function linearGroupLayout(group,input,w,h){ - const g=groupSettings(group),children=group.m_ReverseArrangement?[...input].reverse():input,size=[w,h],result=new Map(); +export function linearGroupLayout(group,input,w,h,horizontal=false){ + const g=groupSettings(group,horizontal),children=group.m_ReverseArrangement?[...input].reverse():input,size=[w,h],result=new Map(); for(const child of children)result.set(child.i,{x:0,y:0,w:child.size[0],h:child.size[1],pivot:child.pivot}); for(const axis of [0,1]){ const total=groupAxis(g,children,axis),other=g.vertical!==(axis===1),align=g.align[axis],start=required=>g.start[axis]+(size[axis]-g.padding[axis]-required)*align; diff --git a/src/ui/library.js b/src/ui/library.js index cd717b1..487054b 100644 --- a/src/ui/library.js +++ b/src/ui/library.js @@ -1,49 +1,38 @@ -import {component} from './runtime.js'; -const defaultFetch=(...args)=>globalThis.fetch(...args); +import { UIPack, UI_LIBRARY } from './objects.js'; +const defaultFetch = (...args) => globalThis.fetch(...args); export class UILibrary { - static async load(src,{fetch:fetcher=defaultFetch,signal}={}){ - const url=new URL(src,globalThis.document?.baseURI||undefined),response=await fetcher(url.href,{signal}); - if(!response.ok)throw Error(`UI library: ${response.status} ${url}`); - const index=await response.json();if(!Array.isArray(index.assets))throw Error('UI library index requires assets'); - const library=new UILibrary(index,new URL('.',url).href,fetcher); - if(!Array.isArray(index.controllers)){ - const extra=await fetcher(new URL('dependency-controllers.json',library.baseURL).href,{signal}); - if(extra.ok)library.controllers.push(...(await extra.json()).controllers||[]);else if(extra.status!==404)throw Error(`UI controllers: ${extra.status}`); - } - library.controllers=[...new Map(library.controllers.map(e=>[e.id||e.file,e])).values()];return library; + static async load(src, { fetch = defaultFetch, signal } = {}) { + const url = new URL(src, globalThis.document?.baseURI), response = await fetch(url.href, { signal }); + if (!response.ok) throw Error(`UI library: ${response.status} ${url}`); + return new UILibrary(await response.json(), new URL('.', url).href, fetch); } - constructor(index,baseURL,fetcher=defaultFetch){ - this.index=index;this.baseURL=baseURL;this.fetcher=(...args)=>fetcher(...args);this.cache=new Map(); - this.entries=[...index.assets,...(index.embedded||[]).map(e=>({...e,kind:'embedded'}))]; - this.controllers=[...new Map([...index.assets.filter(e=>e.kind==='controller'),...(index.controllers||[])].map(e=>[e.id||e.file,e])).values()]; - } - find(key){ - if(key&&typeof key==='object'&&key.file)return key; - const exact=this.entries.filter(e=>e.id===key||e.key===key);if(exact.length===1)return exact[0];if(exact.length>1)throw Error('Ambiguous UI entry: '+key); - const named=this.entries.filter(e=>e.name===key);if(named.length===1)return named[0]; - throw Error((named.length>1?'Ambiguous UI entry: ':'UI entry not found: ')+key); + + constructor(index, baseURL, fetch = defaultFetch) { + if (index.format !== UI_LIBRARY || !['assets','embedded','controllers'].every(k => Array.isArray(index[k]))) throw Error(`UI library: expected ${UI_LIBRARY}`); + this.index = index; this.baseURL = baseURL; this.fetcher = (...args) => fetch(...args); this.cache = new Map(); + this.entries = [...index.assets, ...index.embedded]; + this.controllers = index.controllers; } - async loadPack(entry,{signal}={}){ - entry=this.find(entry);if(!entry.file)throw Error(entry.error||'UI entry has no exported file'); - const url=new URL(entry.file,this.baseURL).href; - const request=async()=>{const response=await this.fetcher(url,{signal});if(!response.ok)throw Error(`UI pack: ${response.status} ${url}`);const pack=await response.json();return {pack,assetBase:pack.resourceBase?new URL(pack.resourceBase,url).href:this.baseURL};}; - if(!signal&&!this.cache.has(url))this.cache.set(url,request().catch(error=>{this.cache.delete(url);throw error;})); - const result=await (signal?request():this.cache.get(url));return {...result,pack:structuredClone(result.pack),entry}; + + find(key) { + if (key && typeof key === 'object' && key.manifest) return key; + const exact = this.entries.filter(e => e.id === key || e.key === key); + if (exact.length === 1) return exact[0]; + if (exact.length > 1) throw Error('Ambiguous UI entry: ' + key); + const named = this.entries.filter(e => e.name === key); + if (named.length === 1) return named[0]; + throw Error((named.length > 1 ? 'Ambiguous UI entry: ' : 'UI entry not found: ') + key); } - async loadController(pack,nodeIndex,entry,{signal}={}){ - const node=pack.document.nodes[nodeIndex],ref=component(node,'Animator')?.m_Controller;if(!ref)throw Error('UI node has no Animator'); - const name=ref.name||ref.controller,candidates=this.controllers.filter(e=>ref.id?e.id===ref.id:e.name===name); - const matches=candidates.filter(e=>e.bindings?.some(b=>b.path===node.path&&b.source_key===entry?.key)); - const selected=matches.length===1?matches[0]:candidates.length===1?candidates[0]:null; - if(selected){const result=await this.loadPack(selected,{signal});return {document:result.pack.document,resources:resourceURLs(result.pack.resources,result.assetBase)};} - if(ref.clips)return {document:ref,resources:pack.resources}; - throw Error(candidates.length>1?'Ambiguous UI controller: '+name:'UI controller not exported: '+name); + + async loadPack(entry, { signal } = {}) { + entry = this.find(entry); + const url = new URL(entry.manifest, this.baseURL).href; + const request = () => UIPack.load(url, { fetch: this.fetcher, signal }); + if (!signal && !this.cache.has(url)) this.cache.set(url, request().catch(error => { this.cache.delete(url); throw error; })); + const source = await (signal ? request() : this.cache.get(url)); + return { pack: new UIPack(source.store, source.ui, entry.roots), assetBase: this.baseURL, entry }; } -} -export function resourceURLs(resources,baseURL){ - const result={...resources};for(const kind of ['textures','fonts'])result[kind]=Object.fromEntries(Object.entries(resources?.[kind]||{}).map(([key,file])=>[key,new URL(file,baseURL).href])); - if(resources?.fontMetrics)result.fontMetrics=new URL(resources.fontMetrics,baseURL).href; - if(resources?.fontMetricsByAsset)result.fontMetricsByAsset=Object.fromEntries(Object.entries(resources.fontMetricsByAsset).map(([name,file])=>[name,new URL(file,baseURL).href]));return result; + async loadController(pack, nodeIndex) { return pack.controller(nodeIndex); } } diff --git a/src/ui/objects.js b/src/ui/objects.js new file mode 100644 index 0000000..2904017 --- /dev/null +++ b/src/ui/objects.js @@ -0,0 +1,66 @@ +import { AssetStore } from '../data/assets.js'; +import { ObjectResources } from '../engine/objects.js'; +import { componentClass } from '../engine/prefab.js'; + +export const UI_PRODUCT = 'ui.pack'; +export const UI_FORMAT = 'nnnotes.ui-pack/1'; +export const UI_LIBRARY = 'nnnotes.ui-library/1'; + +// A UI preview owns mutable native prefab nodes and shares the immutable product objects. +export class UIPack { + static async load(url, { fetch = globalThis.fetch, signal, roots, decode } = {}) { + signal?.throwIfAborted(); + const href = new URL(url, globalThis.document?.baseURI).href; + const response = await fetch(href, { signal }); + if (!response.ok) throw Error(`UI product: ${response.status} ${href}`); + const manifest = await response.json(); + if (manifest.schema !== 'nnnotes.product/1' || manifest.product !== UI_PRODUCT) + throw Error(`UI product: expected ${UI_PRODUCT}`); + const store = await AssetStore.fromManifest(href, { fetch: (u, options) => String(u) === href + ? Promise.resolve(new Response(JSON.stringify(manifest))) : fetch(u, options), + signal, decode, base: new URL(manifest.root || '../', href).href }); + return new UIPack(store, store.json('ui.json'), roots); + } + + constructor(store, ui, roots = ui.roots) { + if (ui.format !== UI_FORMAT) throw Error(`UI document: expected ${UI_FORMAT}`); + this.store = store; this.ui = ui; this.roots = roots.slice(); + this.objects = new ObjectResources(store, { bindings: [ui.bindings], shaders: ui.shaders }); + this.nodes = this.roots.filter(id => store.object(id).class === 'GameObject').flatMap(id => { + const file = id.slice(0, id.lastIndexOf(':')); + return structuredClone(store.prefab(id).nodes).map(n => ({ ...n, prefabId: id, id: `${file}:${n.pathId}`, + transformId: `${file}:${n.transformPathId}` })); + }); + this._images = new Map(); this._clips = new Map(); + } + + clone() { return Object.assign(Object.create(UIPack.prototype), this, { nodes: structuredClone(this.nodes) }); } + + clip(ref) { + const id = this.objects.id(ref, 'AnimationClip'); + if (!this._clips.has(id)) this._clips.set(id, { ...this.store.clip(id), id }); + return this._clips.get(id); + } + + controller(index) { + const component = this.nodes[index]?.components.find(c => componentClass(c) === 'Animator'); + const id = this.store.ref(component?.fields.m_Controller, 'AnimatorController'); + if (!id) throw Error('UI node has no Animator controller'); + const document = this.store.objectJson(id); + return { id, document, clip: i => this.clip(document.clips[i]) }; + } + + bindingNames(clip, rootIndex) { + const root = this.nodes[rootIndex], names = this.objects.bindings.names(root.prefabId, root.path, clip.id); + if (names.length !== clip.bindings.length) throw Error(`UI clip ${clip.id}: binding count differs`); + return names; + } + + image(ref, role = 'image') { + const id = this.objects.id(ref), key = `${id}#${role}`; + if (!this._images.has(key)) this._images.set(key, createImageBitmap( + new Blob([this.store.objectBytes(id, role)], { type: 'image/png' }), + { premultiplyAlpha: 'none', colorSpaceConversion: 'none' }).catch(error => { this._images.delete(key); throw error; })); + return this._images.get(key); + } +} diff --git a/src/ui/player.js b/src/ui/player.js index 5e4192e..01c1a54 100644 --- a/src/ui/player.js +++ b/src/ui/player.js @@ -1,11 +1,11 @@ import {Renderer} from './renderer.js'; import {UISession} from './session.js'; -import {UILibrary,resourceURLs} from './library.js'; -import {component,resolveNodeIndex} from './runtime.js'; +import {UILibrary} from './library.js'; +import {UIPack,UI_LIBRARY} from './objects.js'; +import {resolveNodeIndex} from './runtime.js'; import {sequencePlan,sequenceAnimatorCalls} from './tween.js'; -// Canvas 2D prefab preview, distinct from the game's WebGL render paths. -// This exposes the serialized UI restoration work without claiming pixel parity. +// Canvas 2D preview of serialized prefab, layout and animation state. export class UIPlayer extends EventTarget { static async create(host,options={}){const player=new UIPlayer(host,options);try{await player.load(options.entry??options.src);return player;}catch(error){player.destroy();throw error;}} constructor(host,{library=null,assetBase,viewport=[1920,1080],showHidden=false,bounds=false,bindings=true,projection,framing='root'}={}){ @@ -24,13 +24,13 @@ export class UIPlayer extends EventTarget { async load(source){ if(this.destroyed)throw Error('UIPlayer is destroyed');this.pause();const revision=++this._loadRevision; let pack,base,entry,library=this.library; - if(source?.document){pack=source;base=this.assetBase||document.baseURI;} + if(source instanceof UIPack){pack=source;base=this.assetBase||document.baseURI;} else if(library&&(typeof source!=='string'||library.entries.some(e=>e.id===source||e.key===source||e.name===source))){const result=await library.loadPack(source);pack=result.pack;base=result.assetBase;entry=result.entry;} else{ if(!source)throw Error('UIPlayer requires src or entry');const url=new URL(source,document.baseURI),response=await fetch(url); if(!response.ok)throw Error(`UI document: ${response.status} ${url}`);const doc=await response.json(); - if(Array.isArray(doc.assets)){library=await UILibrary.load(url.href);const selected=library.entries.find(e=>e.kind==='prefab'&&e.file&&e.status!=='failed');if(!selected)throw Error('UI library has no exported prefab');const result=await library.loadPack(selected);pack=result.pack;base=result.assetBase;entry=result.entry;} - else{library=null;pack=doc;base=new URL(pack.resourceBase||'.',url).href;} + if(doc.format===UI_LIBRARY){library=await UILibrary.load(url.href);const selected=library.entries.find(e=>e.kind==='prefab'&&e.manifest);if(!selected)throw Error('UI library has no exported prefab');const result=await library.loadPack(selected);pack=result.pack;base=result.assetBase;entry=result.entry;} + else{library=null;pack=await UIPack.load(url.href);base=new URL(doc.root||'../',url).href;} } if(revision!==this._loadRevision||this.destroyed)return this; this.library=library;this.assetBase=this._explicitAssetBase||base;this.entry=entry;this.session=new UISession(pack,{bindings:this.options.bindings});await this.render();if(revision===this._loadRevision&&!this.destroyed)this.emit('ready',{player:this,entry});return this; @@ -39,8 +39,8 @@ export class UIPlayer extends EventTarget { if(!this.session)throw Error('UIPlayer has no document');const session=this.session,revision=this._loadRevision,index=resolveNodeIndex(session.raw,selector); if(session.controllers.has(index))return session.controllers.get(index); let controller; - if(this.library)controller=await this.library.loadController(session.raw,index,this.entry); - else{const ref=component(this.nodes[index],'Animator')?.m_Controller;if(!ref?.clips)throw Error('External UI controllers require a UILibrary');controller={document:ref,resources:resourceURLs(this.session.raw.resources,this.assetBase)};} + if(this.library)controller=await this.library.loadController(session.raw,index); + else controller=session.raw.controller(index); if(this.destroyed||revision!==this._loadRevision||session!==this.session)throw new DOMException('UI document changed while loading its controller','AbortError'); session.setController(index,controller);return controller; } diff --git a/src/ui/renderer.js b/src/ui/renderer.js index 4655701..f6108a5 100644 --- a/src/ui/renderer.js +++ b/src/ui/renderer.js @@ -1,3 +1,4 @@ +import { refId } from '../data/assets.js'; import {component as comp} from './runtime.js'; import {loadGameFont,hasGameGlyphs,gameTextWidth,drawGameText} from './game-font.js'; import {rectBox,aspectBox,imageAspectBox,intersect,layoutProperty,linearGroupInput,linearGroupLayout} from './layout.js'; @@ -17,22 +18,30 @@ export function loadImage(url){ } return imageCache.get(url); } -const fonts=new Map(); -export async function loadFonts(pack,resolve){ - const gameFonts=new Map();let fontFamily='sans-serif'; - // A pack that names a CSS font family draws its text with it; no font or glyph metrics file is loaded. - if(typeof pack.resources?.browserFontFamily==='string'&&pack.resources.browserFontFamily.trim())return {gameFonts,fontFamily:pack.resources.browserFontFamily}; - const names=new Set((pack.document.nodes||[]).map(n=>comp(n,'TextMeshProUGUI')?.m_fontAsset?.name).filter(n=>n?.includes('VibeMO'))); - await Promise.all([...names].map(async name=>gameFonts.set(name,await loadGameFont(loadImage,resolve(pack.resources.fontMetricsByAsset?.[name]||pack.resources.fontMetrics||'fonts/vibemo.json'),resolve)))); - for(const [name,file] of Object.entries(pack.resources?.fonts||{})){ - if(!name.includes('FZLTH'))continue; - const url=resolve(file);if(!fonts.has(url)){const family='OurnotesUI'+fonts.size;fonts.set(url,new FontFace(family,`url(${JSON.stringify(url)})`).load().then(f=>{document.fonts.add(f);return family;}).catch(error=>{fonts.delete(url);throw error;}));} - fontFamily=await fonts.get(url); +const fonts=new WeakMap(); +let fontSerial=0; +export async function loadFonts(pack){ + const gameFonts=new Map(),fontFamilies=new Map();let fontFamily='sans-serif'; + if(pack.browserFontFamily?.trim())return {gameFonts,fontFamilies,fontFamily:pack.browserFontFamily}; + if(!fonts.has(pack.store))fonts.set(pack.store,new Map()); + const cache=fonts.get(pack.store),ids=new Set(pack.nodes.map(n=>refId(comp(n,'TextMeshProUGUI')?.m_fontAsset)).filter(Boolean)); + for(const id of ids){ + const asset=pack.objects.mono(id); + if(asset.m_Name.includes('VibeMO'))gameFonts.set(id,await loadGameFont(pack,id)); + else{ + const source=refId(asset.m_SourceFontFile); + if(!source||!pack.store.hasObject(source)||!pack.store.roles(source).includes('font'))continue; + if(!cache.has(source)){ + const family='OurnotesUI'+fontSerial++; + cache.set(source,new FontFace(family,pack.store.objectBytes(source,'font')).load().then(face=>{document.fonts.add(face);return family;}).catch(error=>{cache.delete(source);throw error;})); + } + fontFamilies.set(id,await cache.get(source)); + } } - return {gameFonts,fontFamily}; + return {gameFonts,fontFamilies,fontFamily}; } export function buildNodes(pack){ - const stack=[],nodes=(pack.document.nodes||[]).map((n,i)=>{ + const stack=[],nodes=(pack.nodes||[]).map((n,i)=>{ const depth=n.path.split('/').length; while(stack.length&&stack.at(-1).depth>=depth)stack.pop(); const row={...n,i,depth,parent:stack.at(-1)?.i??-1,children:[]};stack.push(row);return row; @@ -93,7 +102,7 @@ function fillPath(ctx,w,h,im){ export class Renderer { constructor(pack,options={}){ - this.pack=pack;this.options=options;this.nodes=buildNodes(pack);this.sprites=new Map();this.spriteCrops=new Map();this.preferredCache=new Map();this.images=new Map();this.fontFamily='sans-serif';this.gameFonts=new Map(); + this.pack=pack;this.options=options;this.nodes=buildNodes(pack);this.sprites=new Map();this.spriteCrops=new Map();this.preferredCache=new Map();this.images=new Map();this.fontFamily='sans-serif';this.gameFonts=new Map();this.fontFamilies=new Map(); this.projection=options.projection?validateProjection(options.projection):null;this.regions=Object.create(null); this.metrics={drawn:0,hidden:0,transparent:0,unboundImages:0,particles:0}; this.measure=makeCanvas(1,1).getContext('2d'); @@ -116,12 +125,15 @@ export class Renderer { return {canvas:this.spriteCrops.get(key),...plan.source}; } async sprite(ref){ - if(this.sprites.has(ref))return this.sprites.get(ref); - const s=this.pack.resources.sprites[ref];if(!s)throw Error(`缺失 Sprite: ${ref}`); - const file=this.pack.resources.textures[s.textureRef];if(!file)throw Error(`缺失贴图: ${s.textureRef}`); - const im=await loadImage(this.url(file)),c=makeCanvas(s.rect.width,s.rect.height),ctx=c.getContext('2d'),r=s.textureRect,o=s.textureRectOffset; - if(r.width>0&&r.height>0){const source=this.spriteCrop(im,s.textureRef,r);ctx.drawImage(source.canvas,source.x,source.y,r.width,r.height,o.x,c.height-o.y-r.height,r.width,r.height);} - const result={canvas:c,meta:s};this.sprites.set(ref,result);return result; + const s=this.pack.objects.sprite(ref); + if(this.sprites.has(s.id))return this.sprites.get(s.id); + const packed=!!(s.settingsRaw&1),rotation=(s.settingsRaw>>2)&15,tight=!(s.settingsRaw&2); + if(tight||packed&&rotation||(s.downscaleMultiplier??1)!==1){ + const result={canvas:await this.pack.image(s.id),meta:s};this.sprites.set(s.id,result);return result; + } + const im=await this.pack.image(s.texture.id),c=makeCanvas(s.rect.width,s.rect.height),ctx=c.getContext('2d'),r=s.textureRect,o=s.textureRectOffset; + if(r.width>0&&r.height>0){const source=this.spriteCrop(im,s.texture.id,r);ctx.drawImage(source.canvas,source.x,source.y,r.width,r.height,o.x,c.height-o.y-r.height,r.width,r.height);} + const result={canvas:c,meta:s};this.sprites.set(s.id,result);return result; } children(n){return n.children.map(i=>this.nodes[i]).filter(c=>c.active&&!comp(c,'LayoutElement')?.m_IgnoreLayout);} layoutInput(n){ @@ -129,16 +141,16 @@ export class Renderer { const elements=[[],[]],el=comp(n,'LayoutElement'),text=comp(n,'TextMeshProUGUI'),image=comp(n,'Image'); const add=(min,preferred,flexible,priority=0)=>[0,1].forEach(axis=>elements[axis].push({priority,min:min[axis],preferred:preferred[axis],flexible:flexible[axis]})); if(text&&text.m_Enabled!==0){ - const fs=text.m_fontSize||24;this.measure.font=`${text.m_fontStyle&1?'bold ':''}${fs}px ${this.fontFamily},sans-serif`; - const lines=visibleLines(plainText(text.m_text).split('\n')),margin=text.m_margin||zeroBorder,gameFont=this.gameFonts.get(text.m_fontAsset?.name); - add([0,0],[Math.max(...lines.map(s=>hasGameGlyphs(s,text.m_fontAsset,gameFont)?gameTextWidth(s,fs,gameFont):this.measure.measureText(s).width),0)+Math.max(0,margin.x)+Math.max(0,margin.z),textHeight(lines,fs*1.16)+Math.max(0,margin.y)+Math.max(0,margin.w)],[-1,-1]); + const fs=text.m_fontSize||24;this.measure.font=`${text.m_fontStyle&1?'bold ':''}${fs}px ${this.fontFamilies.get(refId(text.m_fontAsset))||this.fontFamily},sans-serif`; + const lines=visibleLines(plainText(text.m_text).split('\n')),margin=text.m_margin||zeroBorder,gameFont=this.gameFonts.get(refId(text.m_fontAsset)); + add([0,0],[Math.max(...lines.map(s=>hasGameGlyphs(s,gameFont)?gameTextWidth(s,fs,gameFont):this.measure.measureText(s).width),0)+Math.max(0,margin.x)+Math.max(0,margin.z),textHeight(lines,fs*1.16)+Math.max(0,margin.y)+Math.max(0,margin.w)],[-1,-1]); } if(image&&image.m_Enabled!==0){ - const sprite=image._previewSource&&image._previewSpriteGeometry||this.pack.resources?.sprites?.[image.m_Sprite?.spriteRef],border=sprite?.border||zeroBorder,ppu=(sprite?.pixelsPerUnit||100)/100*(image.m_PixelsPerUnitMultiplier||1),sliced=[1,2].includes(image.m_Type); + const sprite=image._previewSource&&image._previewSpriteGeometry||(image.m_Sprite?this.pack.objects.sprite(image.m_Sprite):null),border=sprite?.border||zeroBorder,ppu=(sprite?.pixelsToUnits||sprite?.pixelsPerUnit||100)/100*(image.m_PixelsPerUnitMultiplier||1),sliced=[1,2].includes(image.m_Type); add([0,0],sprite?[(sliced?border.x+border.z:sprite.rect.width)/ppu,(sliced?border.y+border.w:sprite.rect.height)/ppu]:[0,0],[-1,-1]); } const g=comp(n,'HorizontalLayoutGroup')||comp(n,'VerticalLayoutGroup')||comp(n,'RectFitVerticalLayoutGroup'); - if(g&&g.m_Enabled!==0){const value=linearGroupInput(g,this.layoutChildren(n));add(value.min,value.preferred,value.flexible);} + if(g&&g.m_Enabled!==0){const value=linearGroupInput(g,this.layoutChildren(n),!!comp(n,'HorizontalLayoutGroup'));add(value.min,value.preferred,value.flexible);} if(el&&el.m_Enabled!==0)add([el.m_MinWidth,el.m_MinHeight],[el.m_PreferredWidth,el.m_PreferredHeight],[el.m_FlexibleWidth,el.m_FlexibleHeight],el.m_LayoutPriority??1); const min=elements.map(e=>layoutProperty(e,'min')),result={min,preferred:elements.map((e,axis)=>Math.max(min[axis],layoutProperty(e,'preferred'))),flexible:elements.map(e=>layoutProperty(e,'flexible'))}; this.preferredCache.set(n.i,result);return result; @@ -173,7 +185,7 @@ export class Renderer { const usedCols=Math.min(cols,children.length),usedRows=Math.min(rows,children.length),tw=usedCols*cell.x+Math.max(0,usedCols-1)*space.x,th=usedRows*cell.y+Math.max(0,usedRows-1)*space.y; children.forEach((c,i)=>{let x=vertical?Math.floor(i/rows):i%cols,y=vertical?i%rows:Math.floor(i/cols);if(grid.m_StartCorner%2)x=usedCols-1-x;if(grid.m_StartCorner>=2)y=usedRows-1-y;result.set(c.i,{x:left+(aw-tw)*ax+x*(cell.x+space.x),y:top+(ah-th)*ay+y*(cell.y+space.y),w:cell.x,h:cell.y,pivot:c.rect?.m_Pivot});});return result; } - return linearGroupLayout(g,this.layoutChildren(n),w,h); + return linearGroupLayout(g,this.layoutChildren(n),w,h,!!row); } async imageLayer(n,im,w,h){ if(w<=0||h<=0)return null; @@ -186,16 +198,16 @@ export class Renderer { sp={canvas,meta:im._previewSpriteGeometry}; }else sp={canvas:source,meta:{border:zeroBorder,pixelsPerUnit:100}}; } - else if(im.m_Sprite?.spriteRef)sp=await this.sprite(im.m_Sprite.spriteRef); + else if(im.m_Sprite)sp=await this.sprite(refId(im.m_Sprite)); const scale=Math.min(1,4096/w,4096/h,Math.sqrt(8388608/(w*h))),layer=makeCanvas(w*scale,h*scale),ctx=layer.getContext('2d'); ctx.scale(scale,scale);ctx.save();const flip=comp(n,'UIFlipImage'); if(flip?.m_Enabled!==0&&flip){ctx.translate(flip._isFlipX?w:0,flip._isFlipY?h:0);ctx.scale(flip._isFlipX?-1:1,flip._isFlipY?-1:1);} if(!sp){ctx.fillStyle='white';ctx.fillRect(0,0,w,h);this.metrics.unboundImages++;} - else if(im.m_Type===1)sliced(ctx,sp.canvas,w,h,sp.meta.border,(sp.meta.pixelsPerUnit||100)/100*(im.m_PixelsPerUnitMultiplier||1),im.m_FillCenter!==0); + else if(im.m_Type===1)sliced(ctx,sp.canvas,w,h,sp.meta.border,(sp.meta.pixelsToUnits||sp.meta.pixelsPerUnit||100)/100*(im.m_PixelsPerUnitMultiplier||1),im.m_FillCenter!==0); else { const drawing=im.m_PreserveAspect?imageAspectBox(w,h,sp.canvas.width,sp.canvas.height,n.rect?.m_Pivot):{x:0,y:0,w,h}; fillPath(ctx,w,h,im);ctx.clip(); - if(im.m_Type===2){const ppu=(sp.meta.pixelsPerUnit||100)/100*(im.m_PixelsPerUnitMultiplier||1);ctx.scale(1/ppu,1/ppu);ctx.fillStyle=ctx.createPattern(sp.canvas,'repeat');ctx.fillRect(0,0,w*ppu,h*ppu);} + if(im.m_Type===2){const ppu=(sp.meta.pixelsToUnits||sp.meta.pixelsPerUnit||100)/100*(im.m_PixelsPerUnitMultiplier||1);ctx.scale(1/ppu,1/ppu);ctx.fillStyle=ctx.createPattern(sp.canvas,'repeat');ctx.fillRect(0,0,w*ppu,h*ppu);} else ctx.drawImage(sp.canvas,drawing.x,drawing.y,drawing.w,drawing.h); } ctx.restore();ctx.setTransform(1,0,0,1,0,0);tint(ctx,layer.width,layer.height,im.m_Color,comp(n,'UIGradientImage'));return layer; @@ -204,8 +216,8 @@ export class Renderer { const t=comp(n,'TextMeshProUGUI');if(!t||t.m_Enabled===0)return; const plain=plainText(t.m_text),margin=t.m_margin||zeroBorder,available=Math.max(1,w-margin.x-margin.z); let fs=t.m_fontSize||24; - const font=()=>`${t.m_fontStyle&1?'bold ':''}${t.m_fontStyle&2?'italic ':''}${fs}px ${this.fontFamily},sans-serif`; - const gameFont=this.gameFonts.get(t.m_fontAsset?.name),native=hasGameGlyphs(plain,t.m_fontAsset,gameFont),width=s=>native?gameTextWidth(s,fs,gameFont):ctx.measureText(s).width; + const font=()=>`${t.m_fontStyle&1?'bold ':''}${t.m_fontStyle&2?'italic ':''}${fs}px ${this.fontFamilies.get(refId(t.m_fontAsset))||this.fontFamily},sans-serif`; + const gameFont=this.gameFonts.get(refId(t.m_fontAsset)),native=hasGameGlyphs(plain,gameFont),width=s=>native?gameTextWidth(s,fs,gameFont):ctx.measureText(s).width; const wrap=()=>{ctx.font=font();const result=[];for(const line of plain.split('\n')){if(!wrappingEnabled(t)){result.push(line);continue;}let current='';for(const ch of line){if(current&&width(current+ch)>available){result.push(current);current=ch;}else current+=ch;}result.push(current);}return visibleLines(result);}; let lines=wrap();if(t.m_enableAutoSizing){const min=t.m_fontSizeMin||fs;fs=Math.min(t.m_fontSizeMax||fs,fs);lines=wrap();while(fs>min&&(textHeight(lines,fs*1.16)>h-margin.y-margin.w||lines.some(l=>width(l)>available))){fs=Math.max(min,fs-1);lines=wrap();}} const align=t.m_HorizontalAlignment||1,vertical=t.m_VerticalAlignment||512,lineHeight=fs*1.16+(t.m_lineSpacing||0),th=lines.length*lineHeight; @@ -251,7 +263,7 @@ export class Renderer { const layer=im&&im.m_Enabled!==0?await this.imageLayer(n,im,w,h):null; if(layer&&(!hasMask||mask.m_ShowMaskGraphic)){ctx.drawImage(layer,0,0,w,h);this.metrics.drawn++;} const raw=comp(n,'RawImage');if(raw?.m_Enabled!==0&&raw&&w>0&&h>0){ - const file=this.pack.resources.textures[raw.m_Texture?.textureRef];if(file){const image=await loadImage(this.url(file)),uv=raw.m_UVRect||{x:0,y:0,width:1,height:1};ctx.save();ctx.globalAlpha*=raw.m_Color?.a??1;ctx.drawImage(image,uv.x*image.width,(1-uv.y-uv.height)*image.height,uv.width*image.width,uv.height*image.height,0,0,w,h);ctx.restore();this.metrics.drawn++;}else this.metrics.unboundImages++; + const texture=refId(raw.m_Texture);if(texture){const image=await this.pack.image(texture),uv=raw.m_UVRect||{x:0,y:0,width:1,height:1};ctx.save();ctx.globalAlpha*=raw.m_Color?.a??1;ctx.drawImage(image,uv.x*image.width,(1-uv.y-uv.height)*image.height,uv.width*image.width,uv.height*image.height,0,0,w,h);ctx.restore();this.metrics.drawn++;}else this.metrics.unboundImages++; } this.drawText(n,ctx,w,h);if(comp(n,'ParticleSystem'))this.metrics.particles++; if(this.options.bounds){ctx.strokeStyle='#cf4d90';ctx.lineWidth=.6;ctx.strokeRect(0,0,w,h);} @@ -277,7 +289,7 @@ export class Renderer { const im=comp(n,'Image'),raw=comp(n,'RawImage'),text=comp(n,'TextMeshProUGUI'),mask=comp(n,'Mask'),masking=mask&&mask.m_Enabled!==0; const graphic=this.options.bounds||im&&im.m_Enabled!==0&&(!masking||mask.m_ShowMaskGraphic)&&number(im.m_Color?.a,1)>0||raw&&raw.m_Enabled!==0&&raw.m_Texture||text&&text.m_Enabled!==0&&text.m_text; const quad=this.projection?projectedQuad(m,{x:0,y:0,w:box.w,h:box.h},this.projection):flatQuad(m,{x:0,y:0,w:box.w,h:box.h}),world=quadBounds(quad); - this.regions[n.nodeId||n.path]=quad.map(({x,y})=>({x,y})); + this.regions[n.id||n.path]=quad.map(({x,y})=>({x,y})); if(graphic&&box.w>0&&box.h>0){const extent=intersect(world,clip);if(extent)bounds=bounds?{minX:Math.min(bounds.minX,extent.minX),minY:Math.min(bounds.minY,extent.minY),maxX:Math.max(bounds.maxX,extent.maxX),maxY:Math.max(bounds.maxY,extent.maxY)}:extent;} if(masking||comp(n,'RectMask2D')||comp(n,'SoftMask')){clip=intersect(world,clip);if(!clip)return;} const layouts=this.childLayout(n,box.w,box.h);for(const i of n.children)visit(this.nodes[i],box.w,box.h,m,layouts.get(i),clip,alpha); @@ -286,18 +298,24 @@ export class Renderer { return comp(root,'Canvas')&&this.options.framing!=='content'?rootBounds:bounds||rootBounds; } async render(rootIndex=0,rootSize=null,resourcesReady=false){ - if(!resourcesReady){Object.assign(this,await loadFonts(this.pack,file=>this.url(file)));await Promise.all(this.nodes.flatMap(n=>n.components.filter(c=>c._previewSource).map(async c=>this.images.set(c._previewSource,await loadImage(this.url(c._previewSource))))));} + if(!resourcesReady){Object.assign(this,await loadFonts(this.pack));await Promise.all(this.nodes.flatMap(n=>n.components.map(c=>c.fields).filter(c=>c._previewSource).map(async c=>this.images.set(c._previewSource,await loadImage(this.url(c._previewSource))))));} const root=this.nodes[rootIndex];if(!root)throw Error('预制体没有根节点'); const vp=this.options.viewport||[1920,1080],natural=this.layout(root,...vp),w=rootSize?.[0]??(natural.w>0?natural.w:vp[0]),h=rootSize?.[1]??(natural.h>0?natural.h:vp[1]); const bounds=this.visibleBounds(root,w,h),width=Math.ceil(bounds.maxX-bounds.minX)+48,height=Math.ceil(bounds.maxY-bounds.minY)+48,scale=Math.min(1,4096/width,4096/height),canvas=makeCanvas(width*scale,height*scale),ctx=canvas.getContext('2d');ctx.scale(scale,scale);ctx.translate(24-bounds.minX,24-bounds.minY); this.baseTransform=this.projection?ctx.getTransform():null;await this.drawNode(root,ctx,w,h,null,true);return {canvas,width:w,height:h,bounds,metrics:this.metrics,regions:this.regions,scale,padding:24}; } async renderAsset(){ - const doc=this.pack.document,refs=doc.sprites?(Array.isArray(doc.sprites)?doc.sprites:Object.values(doc.sprites)):doc.spriteRef?[doc]:[]; - const sprites=await Promise.all(refs.filter(x=>x?.spriteRef).map(x=>this.sprite(x.spriteRef))); + const spriteIds=this.pack.roots.flatMap(id=>{ + const kind=this.pack.store.object(id).class; + if(kind==='Sprite')return [id]; + if(kind==='SpriteAtlas')return this.pack.store.objectJson(id).m_PackedSprites.map(refId); + return []; + }); + const sprites=await Promise.all(spriteIds.map(id=>this.sprite(id))); const canvas=makeCanvas(1200,Math.max(250,Math.ceil(sprites.length/8)*150)),ctx=canvas.getContext('2d'); sprites.forEach((s,i)=>{const f=Math.min(125/s.canvas.width,115/s.canvas.height,1);ctx.drawImage(s.canvas,i%8*150+12,Math.floor(i/8)*150+8,s.canvas.width*f,s.canvas.height*f);ctx.fillStyle='#463c56';ctx.font='10px sans-serif';ctx.fillText(s.meta.name.slice(0,23),i%8*150+8,Math.floor(i/8)*150+140);}); - if(!sprites.length&&doc.textureRef){const im=await loadImage(this.url(this.pack.resources.textures[doc.textureRef]));canvas.width=im.width;canvas.height=im.height;ctx.drawImage(im,0,0);} + const texture=this.pack.roots.find(id=>this.pack.store.object(id).class==='Texture2D'); + if(!sprites.length&&texture){const im=await this.pack.image(texture);canvas.width=im.width;canvas.height=im.height;ctx.drawImage(im,0,0);} return {canvas,sprites:sprites.length}; } } diff --git a/src/ui/runtime.js b/src/ui/runtime.js index 36cae4f..b18fb0b 100644 --- a/src/ui/runtime.js +++ b/src/ui/runtime.js @@ -1,16 +1,19 @@ +import { refId } from '../data/assets.js'; +import { componentClass } from '../engine/prefab.js'; // Serialized UI bindings. Edits apply only to the in-memory preview copy. -export const component=(n,name)=>n?.components?.find(c=>(c.class||c.type)===name); -export function nodeMap(pack){return new Map((pack.document.nodes||[]).map(n=>[n.path,n]));} +export const componentRecord=(n,name)=>n?.components?.find(c=>componentClass(c)===name||name==='TextMeshProUGUI'&&['RubyTextMeshProUGUI','RubyEmojiTextMeshProUGUI'].includes(componentClass(c))); +export const component=(n,name)=>componentRecord(n,name)?.fields; +export function nodeMap(pack){return new Map((pack.nodes||[]).map(n=>[n.path,n]));} const referenceTables=new WeakMap(); export function nodeReferences(pack){ if(referenceTables.has(pack))return referenceTables.get(pack); - const nodes=pack.document.nodes||[],paths=new Map(),ids=new Map(),parents=[],ends=[],stack=[]; - nodes.forEach((n,i)=>{const depth=n.path.split('/').length;while(stack.length&&stack.at(-1).depth>=depth)ends[stack.pop().index]=i;parents[i]=stack.at(-1)?.index??-1;stack.push({depth,index:i});paths.set(n.path,[...(paths.get(n.path)||[]),i]);if(n.nodeId)ids.set(n.nodeId,i);}); + const nodes=pack.nodes||[],paths=new Map(),ids=new Map(),parents=[],ends=[],stack=[]; + nodes.forEach((n,i)=>{const depth=n.path.split('/').length;while(stack.length&&stack.at(-1).depth>=depth)ends[stack.pop().index]=i;parents[i]=stack.at(-1)?.index??-1;stack.push({depth,index:i});paths.set(n.path,[...(paths.get(n.path)||[]),i]);if(n.id)ids.set(n.id,i);if(n.transformId)ids.set(n.transformId,i);for(const c of n.components)ids.set(n.prefabId.slice(0,n.prefabId.lastIndexOf(':'))+':'+c.pathId,i);}); while(stack.length)ends[stack.pop().index]=nodes.length; const table={nodes,paths,ids,parents,ends};referenceTables.set(pack,table);return table; } export function referenceNode(pack,ref,origin=-1){ - const t=nodeReferences(pack);if(ref?.nodeId&&t.ids.has(ref.nodeId))return {node:t.nodes[t.ids.get(ref.nodeId)],index:t.ids.get(ref.nodeId)}; + const t=nodeReferences(pack);const id=ref?.$ref?refId(ref):typeof ref==='string'&&t.ids.has(ref)?ref:null;if(id&&t.ids.has(id))return {node:t.nodes[t.ids.get(id)],index:t.ids.get(id)}; const path=typeof ref==='string'?ref:ref?.gameObject||ref?.transform,matches=t.paths.get(path)||[]; if(matches.length===1)return {node:t.nodes[matches[0]],index:matches[0]}; for(let i=origin;i>=0;i=t.parents[i]){const scope=matches.filter(j=>j>=i&&j2)result.set(`${v.asset}:${v.name}`,v); - Object.values(v).forEach(walk); - } - } - walk(pack.document);return result; -} export function applyBindings(pack){ - const assets=catalogs(pack);let origin=-1;const target=(ref,fallback)=>referenceNode(pack,ref,origin)?.node||fallback; - for(const n of pack.document.nodes||[]){origin++;for(const c of n.components){ + let origin=-1;const target=(ref,fallback)=>referenceNode(pack,ref,origin)?.node||fallback; + for(const n of pack.nodes||[]){origin++;for(const record of n.components){ + const c=record.fields,cls=componentClass(record); if(c.m_Enabled===0)continue; const ref=c._spriteCatalog||c._gradientCatalog; - const cat=ref&&(assets.get(`${ref.asset}:${ref.name}`)||ref); + const cat=ref?pack.objects.mono(ref):null; const sprites=c._spriteMap||cat?._spritesByKey; if(sprites&&c._key!==undefined){ const chosen=sprites._list.find(x=>x.Key===c._key)?.Value||c._defaultSprite; @@ -52,7 +45,7 @@ export function applyBindings(pack){ const chosen=cat._gradientsByKey._list.find(x=>x.Key===c._key)?.Value; const grad=component(target(c._gradientImage,n),'UIGradientImage');if(chosen&&grad)grad._gradient=chosen; } - if(c.class==='Slider'){ + if(cls==='Slider'){ const value=c.m_WholeNumbers?Math.round(c.m_Value):c.m_Value; const t=Math.max(0,Math.min(1,(value-c.m_MinValue)/(c.m_MaxValue-c.m_MinValue||1))); const axis=c.m_Direction<2?'x':'y',reverse=c.m_Direction===1||c.m_Direction===3; @@ -61,25 +54,25 @@ export function applyBindings(pack){ else if(fill?.rect){fill.rect.m_AnchorMin={x:0,y:0};fill.rect.m_AnchorMax={x:1,y:1};fill.rect[reverse?'m_AnchorMin':'m_AnchorMax'][axis]=reverse?1-t:t;} if(handle?.rect){handle.rect.m_AnchorMin={x:0,y:0};handle.rect.m_AnchorMax={x:1,y:1};handle.rect.m_AnchorMin[axis]=handle.rect.m_AnchorMax[axis]=reverse?1-t:t;} } - if(c.class==='Toggle'){ + if(cls==='Toggle'){ const graphic=target(c.graphic),im=component(graphic,'Image'); if(im)im.m_Color={...im.m_Color,a:c.m_IsOn?1:0}; } - // UIToggle.SwitchObjects, ELF 0x6ac4e74 (hash-checked C in data/evidence). - if(c.class==='UIToggle'&&c._previewIsOn!==undefined){ + // UIToggle.SwitchObjects selects the checked and unchecked objects. + if(cls==='UIToggle'&&c._previewIsOn!==undefined){ const checked=target(c._checked),unchecked=target(c._unchecked); if(checked)checked.active=c._previewIsOn;if(unchecked)unchecked.active=!c._previewIsOn; } - // ApplyToggleVisual/GetResourceSet, ELF 0x647fcb8 / 0x64888e0. - if(c.class==='UIToggleButtonFrame'){ + // ApplyToggleVisual selects the frame and shadow sprites for the toggle state. + if(cls==='UIToggleButtonFrame'){ const set=c._toggleImages?.find(x=>x.VisualType===c._toggleVisualType&&x.State===c._state); if(set){const frame=component(target(c._frameImage),'Image'),shadow=component(target(c._shadowImage),'Image');if(frame)frame.m_Sprite=set.ToggleSprite;if(shadow)shadow.m_Sprite=set.ShadowSprite;} } - if(c.class==='UIButton'&&c._previewState){ - const states=(c._buttonStates||[]).map(ref=>component(target(ref),ref.class)).filter(Boolean); - states.push(...n.components.filter(x=>x.class==='ButtonActiveState')); + if(cls==='UIButton'&&c._previewState){ + const states=(c._buttonStates||[]).map(ref=>component(target(ref),'ButtonActiveState')).filter(Boolean); + states.push(...n.components.filter(x=>componentClass(x)==='ButtonActiveState').map(x=>x.fields)); for(const item of states){ - if(item.class!=='ButtonActiveState')continue;const go=target(item._target); + const go=target(item._target); const field={normal:'_normaledActive',highlighted:'_highlightedActive',pressed:'_pressedActive',selected:'_selectedActive',disabled:'_disabledActive'}[c._previewState]; if(go&&field)go.active=!!item[field]; } @@ -117,13 +110,8 @@ const unsafeFields=new Set(['__proto__','prototype','constructor']); const fieldParts=path=>typeof path==='string'&&path&&!path.split('.').some(p=>!p||unsafeFields.has(p))?path.split('.'):null; export function assignField(obj,path,value){const parts=fieldParts(path);if(!parts)return false;let parent=obj;for(const p of parts.slice(0,-1)){if(!parent||!Object.hasOwn(parent,p))return false;parent=parent[p];}if(!parent||!Object.hasOwn(parent,parts.at(-1)))return false;const key=parts.at(-1);parent[key]=typeof parent[key]==='boolean'&&typeof value==='number'?value>=.5:value;return true;} const readField=(obj,path)=>fieldParts(path)?.reduce((v,key)=>v&&Object.hasOwn(v,key)?v[key]:undefined,obj); -function findAttribute(obj,crc){ - const candidates=[]; - function walk(v,prefix,depth){if(depth>3||!v||typeof v!=='object'||Array.isArray(v))return;for(const [key,value] of Object.entries(v)){const path=prefix?prefix+'.'+key:key;if(pathCRC(path)===crc)candidates.push(path);else if(value&&typeof value==='object')walk(value,path,depth+1);}} - walk(obj,'',0);return candidates.length===1?candidates[0]:null; -} export function clipPose(pack,clip,rootPath,time){ - const nodes=pack.document.nodes||[],paths=new Map(),crcs=new Map(),indices=new Map(),values=sampleClip(clip,time),objects=sampleObjectCurves(clip,time),missing=[],diagnostics=[],pose=new Map(); + const nodes=pack.nodes||[],paths=new Map(),crcs=new Map(),indices=new Map(),values=sampleClip(clip,time),objects=sampleObjectCurves(clip,time),missing=[],diagnostics=[],pose=new Map(); const rootIndex=typeof rootPath==='number'?rootPath:nodes.findIndex(n=>n.path===rootPath),root=nodes[rootIndex],rootMatches=nodes.filter(n=>n.path===root?.path).length; if(root&&(typeof rootPath==='number'||rootMatches===1)){ const depth=root.path.split('/').length; @@ -133,16 +121,17 @@ export function clipPose(pack,clip,rootPath,time){ } } let offset=0,objectOffset=0,applied=0,numericApplied=0,objectApplied=0; - const put=(n,ci,field,value)=>{const target=ci===null?n:n.components[ci];if(readField(target,field)===undefined)return false;const nodeIndex=indices.get(n),key=nodeIndex+'|'+ci+'|'+field;pose.set(key,{node:n.path,nodeIndex,componentIndex:ci,field,value});return true;}; + const put=(n,ci,field,value)=>{const target=ci===null?n:n.components[ci].fields;if(readField(target,field)===undefined)return false;const nodeIndex=indices.get(n),key=nodeIndex+'|'+ci+'|'+field;pose.set(key,{node:n.path,nodeIndex,componentIndex:ci,field,value});return true;}; const fail=(binding,reason,extra={})=>{missing.push(binding);diagnostics.push({binding,reason,...extra});}; - for(const b of clip.bindings||[]){ - const matches=b.pathCrc!==undefined?crcs.get(b.pathCrc):b.path!=null?paths.get(b.path):null,n=matches?.length===1?matches[0]:null; - const vector=['Transform','RectTransform'].includes(b.class)&&['m_LocalPosition','m_LocalScale','m_LocalRotation','localEulerAnglesRaw'].includes(b.attribute); - const size=b.curves||(vector?(b.attribute==='m_LocalRotation'?4:3):1),v=b.pptr?[objects[objectOffset++]]:values.slice(offset,offset+size);if(!b.pptr)offset+=size; + const names=root?pack.bindingNames(clip,rootIndex):[]; + for(const [bi,b] of (clip.bindings||[]).entries()){ + const named=names[bi],cls=b.scriptClass?.split('|').at(-1)||b.class,attribute=named?.attribute; + const matches=named?.path!=null?paths.get(named.path):null,n=matches?.length===1?matches[0]:null; + const vector=['Transform','RectTransform'].includes(cls)&&['m_LocalPosition','m_LocalScale','m_LocalRotation','localEulerAnglesRaw'].includes(attribute); + const size=b.curves||(vector?(attribute==='m_LocalRotation'?4:3):1),v=b.pptr?[objects[objectOffset++]]:values.slice(offset,offset+size);if(!b.pptr)offset+=size; if(!n){fail(b,matches?.length>1||rootMatches>1&&typeof rootPath!=='number'?'ambiguous_path':'target_not_found');continue;} if(v.length!==size&&!b.pptr||v.some(x=>!Number.isFinite(x))){fail(b,'sample_not_found');continue;} - const ci=n.components.findIndex(c=>(c.class||c.type)===b.class),c=n.components[ci]; - const attribute=b.attribute||findAttribute(c,b.attributeCrc); + const ci=n.components.findIndex(c=>componentClass(c)===cls); let ok=false; if(b.pptr){ const index=Math.round(v[0]),mapping=clip.pptrCurveMapping||[]; @@ -150,35 +139,32 @@ export function clipPose(pack,clip,rootPath,time){ if(ci<0){fail(b,'component_not_found',{node:n.path});continue;} ok=attribute&&put(n,ci,attribute,index<0?null:mapping[index]); } - else if(b.class==='GameObject'&&b.attribute==='m_IsActive')ok=put(n,null,'active',v[0]>=.5); + else if(cls==='GameObject'&&attribute==='m_IsActive')ok=put(n,null,'active',v[0]>=.5); else if(vector){ - const field={m_LocalPosition:'localPosition',m_LocalScale:'localScale',m_LocalRotation:'localRotation',localEulerAnglesRaw:'euler'}[b.attribute]; + const field={m_LocalPosition:'localPosition',m_LocalScale:'localScale',m_LocalRotation:'localRotation',localEulerAnglesRaw:'euler'}[attribute]; if(field==='localPosition'&&n.rect){for(const [i,axis] of ['x','y'].entries())put(n,null,'rect.m_AnchoredPosition.'+axis,n.rect.m_AnchoredPosition[axis]+v[i]-(n.localPosition?.[axis]||0));} // Euler is a runtime channel, absent from the original quaternion transform. if(field==='euler')pose.set(indices.get(n)+'|null|euler',{node:n.path,nodeIndex:indices.get(n),componentIndex:null,field,value:{x:v[0],y:v[1],z:v[2]}}); else for(const [i,axis] of ['x','y','z','w'].slice(0,size).entries())put(n,null,field+'.'+axis,v[i]); ok=true; - }else if(b.class==='RectTransform'&&attribute?.startsWith('m_LocalPosition.')){ + }else if(cls==='RectTransform'&&attribute?.startsWith('m_LocalPosition.')){ const axis=attribute.split('.').at(-1);if(n.rect&&['x','y'].includes(axis))put(n,null,'rect.m_AnchoredPosition.'+axis,n.rect.m_AnchoredPosition[axis]+v[0]-(n.localPosition?.[axis]||0));ok=put(n,null,'localPosition.'+axis,v[0]); - }else if(attribute&&b.class==='RectTransform')ok=put(n,null,'rect.'+attribute,v[0]); - else if(attribute&&b.class==='Transform'){ + }else if(attribute&&cls==='RectTransform')ok=put(n,null,'rect.'+attribute,v[0]); + else if(attribute&&cls==='Transform'){ const field=attribute.replace(/^m_Local(Position|Scale|Rotation)/,(_,s)=>'local'+s);ok=put(n,null,field,v[0]); - }else if(attribute&&ci>=0){const value=b.int?Math.round(v[0]):v[0];ok=put(n,ci,attribute,typeof readField(c,attribute)==='boolean'?value>=.5:value);} + }else if(attribute&&ci>=0){const value=b.int?Math.round(v[0]):v[0];ok=put(n,ci,attribute,typeof readField(n.components[ci].fields,attribute)==='boolean'?value>=.5:value);} if(ok){applied++;b.pptr?objectApplied++:numericApplied++;} - else fail(b,ci<0&&!['Transform','RectTransform','GameObject'].includes(b.class)?'component_not_found':attribute?'field_not_found':'attribute_not_resolved',{node:n.path}); + else fail(b,ci<0&&!['Transform','RectTransform','GameObject'].includes(cls)?'component_not_found':attribute?'field_not_found':'attribute_not_resolved',{node:n.path}); } return {applied,numericApplied,objectApplied,missing,diagnostics,pose}; } export function writePose(pack,pose){ const nodes=nodeMap(pack); for(const op of pose.values()){ - const n=op.nodeIndex!==undefined?pack.document.nodes[op.nodeIndex]:nodes.get(op.node);if(!n)continue;const target=op.componentIndex===null?n:n.components[op.componentIndex]; + const n=op.nodeIndex!==undefined?pack.nodes[op.nodeIndex]:nodes.get(op.node);if(!n)continue;const target=op.componentIndex===null?n:n.components[op.componentIndex].fields; if(op.field==='euler'&&op.componentIndex===null)target.euler=op.value;else assignField(target,op.field,op.value); } } export function applyClip(pack,clip,rootPath,time){ const result=clipPose(pack,clip,rootPath,time);writePose(pack,result.pose);const {pose,...report}=result;return report; } -export function mergeResources(pack,resources){ - pack.resources||={};for(const kind of ['sprites','textures','fonts','fontMetricsByAsset'])pack.resources[kind]={...pack.resources[kind],...resources?.[kind]};if(resources?.fontMetrics)pack.resources.fontMetrics=resources.fontMetrics;return pack; -} diff --git a/src/ui/session.js b/src/ui/session.js index 2b586d4..68b8263 100644 --- a/src/ui/session.js +++ b/src/ui/session.js @@ -1,4 +1,5 @@ -import {component,applyBindings,applyClip,mergeResources,resolveNodeIndex} from './runtime.js'; +import { componentClass } from '../engine/prefab.js'; +import {component,applyBindings,applyClip,resolveNodeIndex} from './runtime.js'; import {AnimatorRuntime} from './animator.js'; import {SequenceRuntime} from './tween.js'; @@ -7,27 +8,27 @@ const unsafe=new Set(['__proto__','prototype','constructor']); // UI preview state independent of the DOM. Serialized originals stay untouched. export class UISession { constructor(pack,{bindings=true}={}){ - if(!pack?.document||!pack.resources)throw Error('UI pack requires document and resources'); - this.raw=structuredClone(pack);this.bindings=bindings;this.edits=new Map();this.controllers=new Map();this.time=0;this.root=0;this.motion=null;this.report=null; + if(typeof pack?.clone!=='function')throw Error('UI session requires a UIPack'); + this.raw=pack.clone();this.bindings=bindings;this.edits=new Map();this.controllers=new Map();this.time=0;this.root=0;this.motion=null;this.report=null; } - get nodes(){return this.raw.document.nodes||[];} - get animators(){return this.nodes.flatMap((n,index)=>component(n,'Animator')?[{index,path:n.path,name:component(n,'Animator').m_Controller?.name||component(n,'Animator').m_Controller?.controller}]:[]);} + get nodes(){return this.raw.nodes||[];} + get animators(){return this.nodes.flatMap((n,index)=>component(n,'Animator')?[{index,path:n.path,name:this.raw.controller(index).document.name}]:[]);} get sequences(){return this.nodes.flatMap((n,index)=>component(n,'DOTweenSequence')?[{index,path:n.path,name:n.name}]:[]);} edit(selector,componentName,field,value){ const index=resolveNodeIndex(this.raw,selector),node=this.nodes[index],parts=field.split('.');if(parts.some(p=>unsafe.has(p)))throw Error('Invalid UI field'); - const ci=componentName===null?null:node.components.findIndex(c=>(c.class||c.type)===componentName);if(ci===-1)throw Error('UI component not found: '+componentName); - const target=ci===null?node:node.components[ci];let parent=target;for(const p of parts.slice(0,-1)){if(!parent||typeof parent[p]!=='object')throw Error('UI field not found: '+field);parent=parent[p];} + const ci=componentName===null?null:node.components.findIndex(c=>componentClass(c)===componentName);if(ci===-1)throw Error('UI component not found: '+componentName); + const target=ci===null?node:node.components[ci].fields;let parent=target;for(const p of parts.slice(0,-1)){if(!parent||typeof parent[p]!=='object')throw Error('UI field not found: '+field);parent=parent[p];} this.edits.set(`${index}:${ci}:${field}`,{index,ci,parts,value:structuredClone(value)});return this; } setController(selector,controller){const index=resolveNodeIndex(this.raw,selector);this.controllers.set(index,controller);this.animatorIndex=index;return this;} playState(state){ const controller=this.controllers.get(this.animatorIndex);if(!controller)throw Error('Select a UI Animator first'); - const runtime=new AnimatorRuntime(controller.document,this.animatorIndex),index=typeof state==='number'?state:runtime.machine.states.findIndex(s=>s.name===state||s.path===state); + const runtime=new AnimatorRuntime(controller,this.animatorIndex),index=typeof state==='number'?state:runtime.machine.states.findIndex(s=>s.name===state||s.path===state); runtime.play(index);this.motion={kind:'state',runtime,controller};this.time=0;return this; } selectClip(clip){ const controller=this.controllers.get(this.animatorIndex);if(!controller)throw Error('Select a UI Animator first'); - const selected=typeof clip==='number'?controller.document.clips[clip]:controller.document.clips.find(c=>c.clip===clip); + const selected=typeof clip==='number'?controller.clip(clip):controller.document.clips.map((_,i)=>controller.clip(i)).find(c=>c.id===clip||c.name===clip); if(!selected?.bindings)throw Error('UI clip not found: '+clip);this.motion={kind:'clip',clip:selected,root:this.animatorIndex,controller};this.time=selected.startTime||0;return this; } selectSequence(selector){const index=resolveNodeIndex(this.raw,selector);this.motion={kind:'sequence',runtime:new SequenceRuntime(this.raw,index,this.controllers)};this.time=0;return this;} @@ -36,13 +37,12 @@ export class UISession { update(delta){if(!Number.isFinite(delta)||delta<0)throw Error('UI delta must be a nonnegative finite number');this.time+=delta;if(this.motion?.kind==='state')this.motion.runtime.advance(delta);return this;} reset(){this.motion=null;this.time=0;this.edits.clear();this.report=null;this.root=0;return this;} prepare(){ - const pack=structuredClone(this.raw);for(const {index,ci,parts,value} of this.edits.values()){let target=ci===null?pack.document.nodes[index]:pack.document.nodes[index].components[ci];for(const key of parts.slice(0,-1))target=target[key];target[parts.at(-1)]=structuredClone(value);} + const pack=this.raw.clone();for(const {index,ci,parts,value} of this.edits.values()){let target=ci===null?pack.nodes[index]:pack.nodes[index].components[ci].fields;for(const key of parts.slice(0,-1))target=target[key];target[parts.at(-1)]=structuredClone(value);} if(this.bindings)applyBindings(pack); const m=this.motion; - if(m?.controller)mergeResources(pack,m.controller.resources); if(m?.kind==='state')this.report=m.runtime.apply(pack); else if(m?.kind==='clip'){let time=this.time;const c=m.clip,length=c.stopTime-c.startTime;if(c.loopTime&&length>0)time=c.startTime+((time-c.startTime)%length+length)%length;else time=Math.min(c.stopTime,time);this.report=applyClip(pack,c,m.root,time);} - else if(m?.kind==='sequence'){m.runtime.baseline=structuredClone(pack);this.report=m.runtime.apply(pack,this.time);} + else if(m?.kind==='sequence'){m.runtime.baseline=pack.clone();this.report=m.runtime.apply(pack,this.time);} else this.report={applied:0,numericApplied:0,objectApplied:0,missing:[],diagnostics:[]}; return pack; } diff --git a/src/ui/tween.js b/src/ui/tween.js index 33c7796..e923ad1 100644 --- a/src/ui/tween.js +++ b/src/ui/tween.js @@ -1,4 +1,5 @@ -import {component,mergeResources,referenceNode,resolveNodeIndex,nodeReferences} from './runtime.js'; +import { componentClass } from '../engine/prefab.js'; +import {component,componentRecord,referenceNode,resolveNodeIndex,nodeReferences} from './runtime.js'; import {AnimatorRuntime} from './animator.js'; const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); @@ -24,24 +25,23 @@ export function tweenEase(type,t){ function animationDuration(c){return Math.max(0,c.delay||0)+Math.max(0,c.duration||0)*(c.loops<0?1:Math.max(1,c.loops||1));} -// Timing is from hash-checked CreateSequence (ELF 0x6aded88): Insert atPosition, -// then advance by the command's positive serialized _duration. Zero durations -// place consecutive tweens in parallel, rather than appending them. +// CreateSequence inserts atPosition, then advances by each command's positive _duration. +// Consecutive tweens with zero duration start at the same time. export function sequencePlan(pack,rootPath,stack=new Set()){ - const rootIndex=resolveNodeIndex(pack,rootPath),node=pack.document.nodes[rootIndex],sequence=component(node,'DOTweenSequence'),items=[],diagnostics=[];rootPath=node.path; + const rootIndex=resolveNodeIndex(pack,rootPath),node=pack.nodes[rootIndex],sequence=component(node,'DOTweenSequence'),items=[],diagnostics=[];rootPath=node.path; if(!sequence)return {rootPath,duration:0,items,diagnostics:[{reason:'sequence_not_found',node:rootPath}]}; if(stack.has(rootIndex))return {rootPath,duration:0,items,diagnostics:[{reason:'sequence_cycle',node:rootPath}]}; stack=new Set([...stack,rootIndex]);let position=0,duration=0; for(const [index,command] of sequence._list.entries()){ if(command._commandType===1){for(const call of calls(command._tweenEvent))items.push({kind:'callback',start:position,call,index,ownerIndex:rootIndex});} else if(command._commandType===0){ - const resolved=referenceNode(pack,command._tweenAnimation,rootIndex),target=resolved?.node,animations=target?.components.filter(c=>['DOTweenAnimation','DOTweenSequence'].includes(c.class))||[]; - const selected=animations[command._tweenIndex]||component(target,command._tweenAnimation?.class); - if(target?.path===rootPath&&selected===sequence)continue; + const resolved=referenceNode(pack,command._tweenAnimation,rootIndex),target=resolved?.node,animations=target?.components.filter(c=>['DOTweenAnimation','DOTweenSequence'].includes(componentClass(c)))||[]; + const selected=animations[command._tweenIndex]||componentRecord(target,command._tweenAnimation?.class); + if(target?.path===rootPath&&selected?.fields===sequence)continue; if(!selected)diagnostics.push({reason:'tween_not_found',index,node:refPath(command._tweenAnimation)}); - else if(selected.class==='DOTweenSequence'){ + else if(componentClass(selected)==='DOTweenSequence'){ const nested=sequencePlan(pack,resolved.index,stack);items.push(...nested.items.map(item=>({...item,start:item.start+position})));diagnostics.push(...nested.diagnostics);duration=Math.max(duration,position+nested.duration); - }else{items.push({kind:'tween',start:position,animation:selected,owner:target.path,ownerIndex:resolved.index,index});duration=Math.max(duration,position+animationDuration(selected));} + }else{items.push({kind:'tween',start:position,animation:selected.fields,owner:target.path,ownerIndex:resolved.index,index});duration=Math.max(duration,position+animationDuration(selected.fields));} }else if(command._commandType!==2)diagnostics.push({reason:'command_not_supported',index,type:command._commandType}); position+=Math.max(0,command._duration||0);duration=Math.max(duration,position); } @@ -54,7 +54,7 @@ export function sequenceAnimatorCalls(pack,plan){ const resolved=referenceNode(pack,item.call.m_Target,item.ownerIndex??plan.rootIndex),node=resolved?.node,trigger=component(node,'SimpleAnimationTrigger'); const animator=trigger?._animator?referenceNode(pack,trigger._animator,resolved.index):resolved,root=animator?.node.path; result.push({...item,node:node?.path,nodeIndex:resolved?.index,root,rootIndex:animator?.index,state:item.call.m_Arguments?.m_StringArgument,blendTime:trigger?._blendTime||0, - supported:item.call.m_Target?.class==='SimpleAnimationTrigger'&&item.call.m_MethodName==='PlayAnimation'&&item.call.m_Mode===5&&!!trigger&&!!component(animator?.node,'Animator')}); + supported:item.call.m_MethodName==='PlayAnimation'&&item.call.m_Mode===5&&!!trigger&&!!component(animator?.node,'Animator')}); } return result; } @@ -68,7 +68,7 @@ function progress(animation,time){ } function tweenTarget(pack,owner,a){ - const ownerIndex=resolveNodeIndex(pack,owner),target=a.targetIsSelf?{node:pack.document.nodes[ownerIndex],index:ownerIndex}:referenceNode(pack,a.targetGO||a.target,ownerIndex); + const ownerIndex=resolveNodeIndex(pack,owner),target=a.targetIsSelf?{node:pack.nodes[ownerIndex],index:ownerIndex}:referenceNode(pack,a.targetGO||a.target,ownerIndex); return target||{}; } @@ -130,8 +130,7 @@ export class SequenceRuntime { const refs=nodeReferences(pack);let active=true;for(let i=item.nodeIndex;i>=0;i=refs.parents[i])if(refs.nodes[i].active===false)active=false; if(!active){events.push({node:item.node,state:item.state,at:item.start,skipped:'inactive_target'});continue;} const exported=this.controllers.get(item.rootIndex)||this.controllers.get(item.root);if(!exported){diagnostics.push({reason:'sequence_controller_not_found',node:item.root});continue;} - mergeResources(pack,exported.resources); - let entry=animators.get(item.rootIndex);if(!entry){entry={runtime:new AnimatorRuntime(exported.document,item.rootIndex),time:0};animators.set(item.rootIndex,entry);} + let entry=animators.get(item.rootIndex);if(!entry){entry={runtime:new AnimatorRuntime(exported,item.rootIndex),time:0};animators.set(item.rootIndex,entry);} const machine=entry.runtime.machine,index=machine?.states.findIndex(s=>s.name===item.state||s.path===item.state); if(index===undefined||index<0){diagnostics.push({reason:'sequence_state_not_found',node:item.root,state:item.state});continue;} entry.runtime.advance(Math.max(0,item.start-entry.time));entry.runtime.crossFade(index,item.blendTime);entry.time=item.start; diff --git a/tests/types/ui.ts b/tests/types/ui.ts index b8d5831..1a927a6 100644 --- a/tests/types/ui.ts +++ b/tests/types/ui.ts @@ -1,10 +1,12 @@ -import {UILibrary,UISession,UIPlayer,OurnotesUIElement,defineOurnotesUI,cameraProjection,type UIPack,type UIReport} from 'ournotes-player/ui'; +import {AssetStore} from 'ournotes-player'; +import {UILibrary,UISession,UIPlayer,OurnotesUIElement,defineOurnotesUI,cameraProjection,UIPack,type UIReport} from 'ournotes-player/ui'; import 'ournotes-player/ui/element'; -const source: UIPack={document:{nodes:[]},resources:{browserFontFamily:'sans-serif'}}; +const source=new UIPack(new AssetStore(),{format:'nnnotes.ui-pack/1',key:'example',roots:[],prefabs:[],controllers:[],entries:[],bindings:{},shaders:{}}); +source.browserFontFamily='sans-serif'; const session=new UISession(source); session.edit(0,null,'active',false).seek(.2).reset(); -const library=new UILibrary({assets:[]},'https://example.invalid/'); +const library=new UILibrary({format:'nnnotes.ui-library/1',assets:[],embedded:[],controllers:[]},'https://example.invalid/'); async function load(host: HTMLElement){ const projection=cameraProjection({orthographic:false,'field of view':20,'near clip plane':.3},{m_RenderMode:1,m_PlaneDistance:100},[1920,1080]); const player=await UIPlayer.create(host,{library,entry:'example',projection,framing:'content'}); diff --git a/tests/unit/ui-element.test.mjs b/tests/unit/ui-element.test.mjs index 6dbce62..3892a34 100644 --- a/tests/unit/ui-element.test.mjs +++ b/tests/unit/ui-element.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import {test} from 'node:test'; +import {node,pack as uiPack} from './ui-fixture.mjs'; class Element extends EventTarget { constructor(){super();this.attributes=new Map();this.isConnected=false;} @@ -18,7 +19,7 @@ const {UIPlayer}=await import('../../src/ui/player.js'); const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};}; const fakePlayer=()=>({destroyed:false,destroy(){this.destroyed=true;},addEventListener(){}}); const tick=()=>new Promise(r=>setTimeout(r,0)); -const pack=()=>({document:{nodes:[{path:'Root',name:'Root',nodeId:'root',active:true,components:[],rect:{m_SizeDelta:{x:100,y:50}}}]},resources:{}}); +const pack=()=>uiPack([{...node('Root','root'),rect:{m_SizeDelta:{x:100,y:50}}}]); test('ready captured before connecting settles on the first load',async t=>{ const result=fakePlayer();t.mock.method(UIPlayer,'create',async()=>result); diff --git a/tests/unit/ui-fixture.mjs b/tests/unit/ui-fixture.mjs new file mode 100644 index 0000000..53f2042 --- /dev/null +++ b/tests/unit/ui-fixture.mjs @@ -0,0 +1,80 @@ +import { createHash } from 'node:crypto'; +import { AssetStore } from '../../src/data/assets.js'; +import { UIPack, UI_FORMAT, UI_LIBRARY } from '../../src/ui/objects.js'; +import { applyClip as applyNativeClip } from '../../src/ui/runtime.js'; + +const ids = new Map(); +const number = name => { if (!ids.has(name)) ids.set(name, String(ids.size + 1)); return ids.get(name); }; +export const id = name => `ui:${number(name)}`; +export const ref = name => ({ $ref: { file: 'ui', pathId: number(name) } }); +const nativeTypes = new Set(['CanvasGroup', 'Animator', 'Canvas', 'Camera', 'SpriteRenderer', 'ParticleSystem']); +export const component = (name, fields = {}, key = name) => ({ type: nativeTypes.has(name) ? name : 'MonoBehaviour', + pathId: number(`component:${key}`), ...(nativeTypes.has(name) ? {} : { script: `Example||${name}` }), fields }); + +export const node = (path, key, components = []) => ({ path, name: path.split('/').at(-1), pathId: number(key), + transformPathId: number(`transform:${key}`), active: true, components: components.map((c, i) => { + if (c.fields) return c; + const { type, class: cls, ...fields } = c; + return component(cls || type, fields, `${key}:${i}`); + }), + localScale: { x: 1, y: 1, z: 1 }, localPosition: { x: 0, y: 0, z: 0 }, localRotation: { x: 0, y: 0, z: 0, w: 1 } }); +export const clip = (name, value) => ({ id: id(`clip:${name}`), name, startTime: 0, stopTime: 1, + bindings: [{ path: '', class: 'CanvasGroup', attribute: 'm_Alpha' }], constant: [value] }); + +const controllerDoc = () => ({ name: 'Same', layers: [{ stateMachine: 0 }], + parameters: [{ name: 'Go', type: 9, index: 0 }], clips: [ref('clip:A'), ref('clip:B')], + stateMachines: [{ defaultState: 0, anyStateTransitions: [], states: [ + { name: 'A', speed: 1, writeDefaultValues: true, blendTrees: [[{ clip: 0, children: [] }]], + transitions: [{ destination: 1, conditions: [{ mode: 1, event: 'Go' }], duration: .5, fixedDuration: true }] }, + { name: 'B', speed: 1, writeDefaultValues: true, blendTrees: [[{ clip: 1, children: [] }]], transitions: [] }, + ] }] }); + +export const bind = (pack, value) => { + for (const n of pack.nodes) pack.objects.bindings.table.set(`${n.prefabId}\0${n.path}\0${value.id}`, + value.bindings.map(b => ({ path: b.path ?? '', attribute: b.attribute }))); +}; + +export const pack = (nodes = [node('Root', 'root', [component('CanvasGroup', { m_Alpha: .3 })])]) => { + const objects = {}, root = id('prefab'); + const add = (key, cls, doc) => { objects[key] = { class: cls, name: doc.name ?? doc.m_Name, files: { json: JSON.stringify(doc) } }; }; + add(root, 'GameObject', { name: 'Root', nodes }); + add(id('controller'), 'AnimatorController', controllerDoc()); + add(id('other-controller'), 'AnimatorController', controllerDoc()); + const a = clip('A', 0), b = clip('B', 1); + add(a.id, 'AnimationClip', a); add(b.id, 'AnimationClip', b); + const ui = { format: UI_FORMAT, key: 'EmbUI/Box', roots: [root], prefabs: [root], controllers: [id('controller')], + entries: [], shaders: {}, bindings: { [root]: Object.fromEntries(nodes.map(n => [n.path, { + [a.id]: a.bindings.map(({ path, attribute }) => ({ path, attribute })), + [b.id]: b.bindings.map(({ path, attribute }) => ({ path, attribute })), + }])) } }; + return new UIPack(new AssetStore({ objects }), ui); +}; + +export const controller = () => { + const p = pack([node('Root', 'root', [component('Animator', { m_Controller: ref('controller') })])]); + return p.controller(0); +}; + +// Low-level pose tests provide names alongside the synthetic curves they exercise. +export const applyClip = (p, value, root, time) => { + const data = { id: id('clip:sample'), ...value }; bind(p, data); + return applyNativeClip(p, data, root, time); +}; + +export const network = (p = pack([node('Root', 'root', [component('Animator', { m_Controller: ref('controller') })])])) => { + const files = new Map(), encoder = new TextEncoder(), requests = []; + const put = (path, text) => { const bytes = encoder.encode(text); files.set(path, bytes); return { asset: path, size: bytes.length }; }; + const asset = text => put(`assets/${createHash('sha256').update(text).digest('hex')}.json`, text); + const manifest = { schema: 'nnnotes.product/1', product: 'ui.pack', subject: p.ui.key, + files: { 'ui.json': asset(JSON.stringify(p.ui)) }, objects: {} }; + for (const [oid, object] of p.store._objects) manifest.objects[oid] = { class: object.class, name: object.name, + files: Object.fromEntries([...object.files].map(([role, data]) => [role, asset(data)])) }; + const index = { format: UI_LIBRARY, assets: [{ id: 'one', key: p.ui.key, name: 'Box', kind: 'prefab', + manifest: 'uipacks/one.json', roots: p.ui.roots }], embedded: [], controllers: [] }; + put('uipacks/one.json', JSON.stringify(manifest)); put('index.json', JSON.stringify(index)); + return { index, files, requests, manifest, asset, fetch: async (url, options = {}) => { + options.signal?.throwIfAborted(); requests.push(String(url)); + const bytes = files.get(new URL(url).pathname.replace('/data/', '')); + return bytes ? new Response(bytes) : new Response(null, { status: 404 }); + } }; +}; diff --git a/tests/unit/ui-projection.test.mjs b/tests/unit/ui-projection.test.mjs index d586c18..e69e0a1 100644 --- a/tests/unit/ui-projection.test.mjs +++ b/tests/unit/ui-projection.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import {test} from 'node:test'; import {cameraProjection,validateProjection,nodeMatrix4,multiply4,identity4,projectedQuad,quadPoint,outOfPlane} from '../../src/ui/projection.js'; import {Renderer} from '../../src/ui/renderer.js'; +import {node as nativeNode,pack as uiPack,id,component} from './ui-fixture.mjs'; const near=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`); const camera=()=>cameraProjection({orthographic:false,'field of view':90,'near clip plane':.1,m_NormalizedViewPortRect:{x:0,y:0,width:1,height:1}}, {m_RenderMode:1,m_PlaneDistance:10},[100,100]); @@ -34,23 +35,23 @@ test('homogeneous interpolation matches projection of an interior source point', function context(){return {globalAlpha:1,save(){},restore(){},scale(){},translate(){},rotate(){},setTransform(){},getTransform(){return {};},transform(){},drawImage(){},fillRect(){},beginPath(){},rect(){},clip(){},moveTo(){},lineTo(){},closePath(){},getImageData(_x,_y,w,h){return {data:new Uint8ClampedArray(w*h*4)};},createImageData(w,h){return {data:new Uint8ClampedArray(w*h*4)};},putImageData(){}};} globalThis.document={baseURI:'https://example.invalid/',createElement:()=>({width:1,height:1,getContext:()=>context()})}; -const node=(path,id,w,h,components=[],pos={x:0,y:0})=>({path,nodeId:id,active:true,components,rect:{m_SizeDelta:{x:w,y:h},m_AnchorMin:{x:0,y:1},m_AnchorMax:{x:0,y:1},m_AnchoredPosition:pos,m_Pivot:{x:0,y:1}}}); +const node=(path,id,w,h,components=[],pos={x:0,y:0})=>({...nativeNode(path,id,components),rect:{m_SizeDelta:{x:w,y:h},m_AnchorMin:{x:0,y:1},m_AnchorMax:{x:0,y:1},m_AnchoredPosition:pos,m_Pivot:{x:0,y:1}}}); const image={class:'Image',m_Color:{r:1,g:1,b:1,a:1}}; test('content framing crops transparent root space while projected container regions retain source order',async()=>{ const root=node('Root','root',100,100),slot=node('Root/Slot','slot',20,40,[],{x:20,y:-30});slot.euler={x:0,y:30,z:0}; const front=node('Root/Slot/Foreground','fg',24,44,[image],{x:-2,y:2}),hidden=node('Root/Slot/Hidden','hidden',50,50,[image]);hidden.active=false; - const pack={document:{nodes:[root,slot,front,hidden]},resources:{}},before=JSON.stringify(pack); + const pack=uiPack([root,slot,front,hidden]),before=JSON.stringify(pack); const result=await new Renderer(pack,{projection:camera(),framing:'content'}).render(); assert.equal(result.metrics.drawn,1);assert.equal(result.padding,24);assert.equal(result.width,100); assert.ok(result.bounds.minX>0&&result.bounds.minY>0&&result.bounds.maxX<100&&result.bounds.maxY<100); - assert.equal(result.regions.slot.length,4);assert.ok(result.regions.slot[0].y!==result.regions.slot[1].y);assert.equal(result.regions.hidden,undefined); - assert.ok(result.bounds.minXp.x)),'foreground extends beyond slot box'); + assert.equal(result.regions[id('slot')].length,4);assert.ok(result.regions[id('slot')][0].y!==result.regions[id('slot')][1].y);assert.equal(result.regions[id('hidden')],undefined); + assert.ok(result.bounds.minXp.x)),'foreground extends beyond slot box'); assert.equal(JSON.stringify(pack),before); }); test('nested tilted surfaces and cross-mask projective composition report limits instead of silently flattening',async()=>{ const root=node('Root','root',100,100),slot=node('Root/Slot','slot',20,40);slot.euler={x:0,y:30,z:0}; const child=node('Root/Slot/Child','child',10,10,[image]);child.euler={x:0,y:20,z:0}; - await assert.rejects(new Renderer({document:{nodes:[root,slot,child]},resources:{}},{projection:camera()}).render(),/nested nonplanar/); - root.components=[{class:'RectMask2D',m_Enabled:1}];child.euler={x:0,y:0,z:0}; - await assert.rejects(new Renderer({document:{nodes:[root,slot,child]},resources:{}},{projection:camera()}).render(),/inside a mask/); + await assert.rejects(new Renderer(uiPack([root,slot,child]),{projection:camera()}).render(),/nested nonplanar/); + root.components=[component('RectMask2D',{m_Enabled:1})];child.euler={x:0,y:0,z:0}; + await assert.rejects(new Renderer(uiPack([root,slot,child]),{projection:camera()}).render(),/inside a mask/); }); diff --git a/tests/unit/ui-sprite-geometry.test.mjs b/tests/unit/ui-sprite-geometry.test.mjs index 257f72c..9ab1e3b 100644 --- a/tests/unit/ui-sprite-geometry.test.mjs +++ b/tests/unit/ui-sprite-geometry.test.mjs @@ -1,6 +1,20 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {previewSpriteGeometryPlan,previewSpriteAspect,spriteCropPlan} from '../../src/ui/sprite-geometry.js'; +import {Renderer} from '../../src/ui/renderer.js'; + +test('packed and tight Sprite previews use the restored object image and share reference identity',async()=>{ + for(const settingsRaw of [0,1,7,11,15,19]){ + const image={width:24,height:40},reads=[],sprite={id:'example:1',settingsRaw,texture:{id:'example:2'},rect:{width:24,height:40}}; + const renderer=Object.assign(Object.create(Renderer.prototype),{sprites:new Map(),pack:{ + objects:{sprite:()=>sprite},image:async id=>{reads.push(id);return image;}, + }}); + const first=await renderer.sprite('example:1'); + assert.equal(first.canvas,image); + assert.equal(await renderer.sprite({$ref:{file:'example',pathId:'1'}}),first); + assert.deepEqual(reads,['example:1']); + } +}); test('trimmed bitmap uses original Sprite aspect and restores upward trim offset',()=>{ // Synthetic geometry: no extracted game records are bundled with the test. diff --git a/tests/unit/ui.test.mjs b/tests/unit/ui.test.mjs index 978130f..4dd4540 100644 --- a/tests/unit/ui.test.mjs +++ b/tests/unit/ui.test.mjs @@ -1,98 +1,91 @@ -// Synthetic serialized records only; no game fixtures or artwork. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {spawnSync} from 'node:child_process'; +// Synthetic serialized UI records. import assert from 'node:assert/strict'; import {test} from 'node:test'; import {UILibrary,UISession} from '../../src/ui/index.js'; -import {resourceURLs} from '../../src/ui/library.js'; -import {applyClip,sampleClip,sampleObjectCurves,referenceNode,resolveNodeIndex,mergeResources,assignField} from '../../src/ui/runtime.js'; +import {node,pack,clip,controller,applyClip,id,ref,component,network} from './ui-fixture.mjs'; +import {sampleClip,sampleObjectCurves,referenceNode,resolveNodeIndex,assignField} from '../../src/ui/runtime.js'; import {sequencePlan} from '../../src/ui/tween.js'; import {rectBox,affine,linearGroupLayout,layoutProperty} from '../../src/ui/layout.js'; import {visibleLines,textHeight,wrappingEnabled} from '../../src/ui/text-layout.js'; import {loadFonts} from '../../src/ui/renderer.js'; -const node=(path,id,components=[])=>({path,name:path.split('/').at(-1),nodeId:id,active:true,components,localScale:{x:1,y:1,z:1},localPosition:{x:0,y:0,z:0}}); -const pack=()=>({document:{nodes:[node('Root','root',[{type:'CanvasGroup',m_Alpha:.3}])]},resources:{}}); -const clip=(name,value)=>({clip:name,startTime:0,stopTime:1,bindings:[{path:'',class:'CanvasGroup',attribute:'m_Alpha'}],constant:[value]}); -const controller=()=>({document:{layers:[{stateMachine:0}],parameters:[{name:'Go',type:9,index:0}],clips:[clip('A',0),clip('B',1)],stateMachines:[{defaultState:0,anyStateTransitions:[],states:[ - {name:'A',speed:1,writeDefaultValues:true,blendTrees:[[{clip:0,children:[]}]],transitions:[{destination:1,conditions:[{mode:1,event:'Go'}],duration:.5,fixedDuration:true}]}, - {name:'B',speed:1,writeDefaultValues:true,blendTrees:[[{clip:1,children:[]}]],transitions:[]} -]}]}}); - -test('library normalizes resource bases, caches copies and handles same-named controllers by ID',async()=>{ - const index={assets:[{id:'one',key:'Box',name:'Box',file:'packs/one.json',kind:'prefab'}],controllers:[{id:'c1',name:'Same',file:'controllers/one.json'},{id:'c2',name:'Same',file:'controllers/two.json'}]}; - let reads=0;const fetcher=async url=>{reads++;return {ok:true,json:async()=>url.includes('/controllers/')?{document:{name:'chosen'},resourceBase:'../',resources:{textures:{t:'textures/a.png'},fontMetricsByAsset:{Font:'fonts/f.json'}}}:{...pack(),resourceBase:'../',document:{nodes:[node('Root','root',[{class:'Animator',m_Controller:{id:'c2',name:'Same'}}])]}}};}; - const library=new UILibrary(index,'https://example.invalid/data/',fetcher); - const first=await library.loadPack('one');first.pack.document.nodes[0].name='changed'; - const second=await library.loadPack('Box');assert.equal(second.pack.document.nodes[0].name,'Root');assert.equal(reads,1); - assert.equal(second.assetBase,'https://example.invalid/data/'); - const selected=await library.loadController(second.pack,0,second.entry);assert.equal(selected.document.name,'chosen'); - assert.equal(selected.resources.fontMetricsByAsset.Font,'https://example.invalid/data/fonts/f.json'); - assert.equal(selected.resources.textures.t,'https://example.invalid/data/textures/a.png'); +test('library loads product identities and returns isolated native node copies',async()=>{ + const n=network(),library=new UILibrary(n.index,'https://example.invalid/data/',n.fetch); + const first=await library.loadPack('one'),reads=n.requests.length;first.pack.nodes[0].name='changed'; + const second=await library.loadPack('Box');assert.equal(second.pack.nodes[0].name,'Root');assert.equal(n.requests.length,reads); + const selected=await library.loadController(second.pack,0);assert.equal(selected.id,id('controller'));assert.equal(selected.document.name,'Same'); + assert.equal(selected.clip(0).id,id('clip:A')); }); test('a cancelled pack request does not poison another caller and a failed cached load retries',async()=>{ - const index={assets:[{id:'one',file:'packs/one.json',kind:'prefab'}]};let reads=0; - const library=new UILibrary(index,'https://example.invalid/',async(_,{signal}={})=>{reads++;if(signal?.aborted)throw signal.reason;if(reads===2)throw Error('temporary');return {ok:true,json:async()=>pack()};}); + const n=network();let first=true; + const library=new UILibrary(n.index,'https://example.invalid/data/',async(...args)=>{if(first){first=false;throw Error('temporary');}return n.fetch(...args);}); const abort=new AbortController();abort.abort(); await assert.rejects(library.loadPack('one',{signal:abort.signal}),{name:'AbortError'}); await assert.rejects(library.loadPack('one'),/temporary/); - await library.loadPack('one');assert.equal(reads,3); + const loaded=await library.loadPack('one');assert.equal(loaded.pack.nodes[0].name,'Root'); }); -test('ambiguous names are explicit and resource URL conversion leaves the input untouched',()=>{ - const library=new UILibrary({assets:[{id:'a',name:'Same'},{id:'b',name:'Same'}]},'https://example.invalid/'); - assert.throws(()=>library.find('Same'),/Ambiguous/); - const input={textures:{a:'../a.png'},fonts:{a:'font.ttf'},fontMetricsByAsset:{A:'a.json'}}; - assert.equal(resourceURLs(input,'https://example.invalid/data/').textures.a,'https://example.invalid/a.png');assert.equal(input.textures.a,'../a.png'); +test('library names must be unambiguous and the index format is checked',()=>{ + const n=network();n.index.assets.push({...n.index.assets[0],id:'two'}); + const library=new UILibrary(n.index,'https://example.invalid/data/'); + assert.throws(()=>library.find('Box'),/Ambiguous/); + assert.throws(()=>new UILibrary({assets:[]},'https://example.invalid/data/'),/expected nnnotes.ui-library/); }); -test('custom fetch is called as a function rather than with a UILibrary receiver',async()=>{ - const fetcher=async function(){assert.equal(this,undefined);return {ok:true,json:async()=>pack()};}; - const library=new UILibrary({assets:[{id:'one',file:'one.json'}]},'https://example.invalid/',fetcher); +test('custom fetch is called without a UILibrary receiver',async()=>{ + const n=network(),fetcher=async function(...args){assert.equal(this,undefined);return n.fetch(...args);}; + const library=await UILibrary.load('https://example.invalid/data/index.json',{fetch:fetcher}); await library.loadPack('one'); }); -test('binding duplicate sibling instances resolves actual IDs and legacy references in the nearest subtree',()=>{ - const nodes=[node('Root','root'),node('Root/Item','left',[{class:'UIToggle',_previewIsOn:true,_checked:{nodeId:'left-check',gameObject:'Root/Item/Check'},_unchecked:{nodeId:'left-off'}}]),node('Root/Item/Check','left-check'),node('Root/Item/Off','left-off'),node('Root/Item','right',[{class:'UIToggle',_previewIsOn:false,_checked:{gameObject:'Root/Item/Check'}}]),node('Root/Item/Check','right-check')]; - const original={document:{nodes},resources:{}};const session=new UISession(original);const bound=session.prepare(); - assert.equal(bound.document.nodes[2].active,true);assert.equal(bound.document.nodes[3].active,false);assert.equal(bound.document.nodes[5].active,false); - assert.equal(original.document.nodes[5].active,true); +test('binding duplicate sibling instances resolves object IDs and hierarchy references in the nearest subtree',()=>{ + const nodes=[node('Root','root'),node('Root/Item','left',[{class:'UIToggle',_previewIsOn:true,_checked:ref('left-check'),_unchecked:ref('left-off')}]),node('Root/Item/Check','left-check'),node('Root/Item/Off','left-off'),node('Root/Item','right',[{class:'UIToggle',_previewIsOn:false,_checked:{gameObject:'Root/Item/Check'}}]),node('Root/Item/Check','right-check')]; + const original=pack(nodes);const session=new UISession(original);const bound=session.prepare(); + assert.equal(bound.nodes[2].active,true);assert.equal(bound.nodes[3].active,false);assert.equal(bound.nodes[5].active,false); + assert.equal(original.nodes[5].active,true); assert.equal(referenceNode(original,{gameObject:'Root/Item/Check'},4).index,5); assert.throws(()=>resolveNodeIndex(original,'Root/Item'),/Ambiguous/); }); test('edits are immutable, reject prototype paths, and respect disabled component binding',()=>{ - const source=pack();source.document.nodes[0].components.push({class:'Slider',m_Value:3,m_MinValue:0,m_MaxValue:10,m_WholeNumbers:false}); - const session=new UISession(source,{bindings:false});session.edit('root','CanvasGroup','m_Alpha',.7); - assert.equal(session.prepare().document.nodes[0].components[0].m_Alpha,.7);assert.equal(source.document.nodes[0].components[0].m_Alpha,.3); - assert.throws(()=>session.edit('root',null,'__proto__.bad',true),/Invalid UI field/); - assert.throws(()=>session.seek(Infinity),/finite/);session.reset();assert.equal(session.prepare().document.nodes[0].components[0].m_Alpha,.3); + const source=pack();source.nodes[0].components.push(component('Slider',{m_Value:3,m_MinValue:0,m_MaxValue:10,m_WholeNumbers:false})); + const session=new UISession(source,{bindings:false});session.edit(id('root'),'CanvasGroup','m_Alpha',.7); + assert.equal(session.prepare().nodes[0].components[0].fields.m_Alpha,.7);assert.equal(source.nodes[0].components[0].fields.m_Alpha,.3); + assert.throws(()=>session.edit(id('root'),null,'__proto__.bad',true),/Invalid UI field/); + assert.throws(()=>session.seek(Infinity),/finite/);session.reset();assert.equal(session.prepare().nodes[0].components[0].fields.m_Alpha,.3); }); test('Trigger transitions, numeric crossfade and deterministic seek follow the saved parameter history',()=>{ const session=new UISession(pack()).setController(0,controller()).playState('A');session.setParameter('Go',true);session.seek(.25); - const mid=session.prepare();assert.ok(Math.abs(mid.document.nodes[0].components[0].m_Alpha-.5)<1e-6);assert.equal(session.motion.runtime.parameters.Go,false); - session.seek(1);assert.equal(session.prepare().document.nodes[0].components[0].m_Alpha,1); - session.seek(.25);assert.ok(Math.abs(session.prepare().document.nodes[0].components[0].m_Alpha-.5)<1e-6); + const mid=session.prepare();assert.ok(Math.abs(mid.nodes[0].components[0].fields.m_Alpha-.5)<1e-6);assert.equal(session.motion.runtime.parameters.Go,false); + session.seek(1);assert.equal(session.prepare().nodes[0].components[0].fields.m_Alpha,1); + session.seek(.25);assert.ok(Math.abs(session.prepare().nodes[0].components[0].fields.m_Alpha-.5)<1e-6); }); test('unsupported motions report their limit and keep a finite preview clock without writing a pose',()=>{ const exported=controller();exported.document.stateMachines[0].states[0].blendTrees=[[{clip:0,children:[1]}]]; const session=new UISession(pack()).setController(0,exported).playState('A').update(.5),out=session.prepare(); - assert.ok(Number.isFinite(session.motion.runtime.current.normalized));assert.equal(out.document.nodes[0].components[0].m_Alpha,.3); + assert.ok(Number.isFinite(session.motion.runtime.current.normalized));assert.equal(out.nodes[0].components[0].fields.m_Alpha,.3); assert.ok(session.report.warnings.includes('blend_tree')); }); test('discrete Sprite curves do not displace numeric constants and negative object indices clear a Sprite',()=>{ - const source=pack();source.document.nodes[0].components.push({class:'Image',m_Sprite:null,m_Color:{a:1}}); - const data={streamed:{curveCount:1,discreteCurveCount:1,frames:[[0,[[0,0,0,0,1],[1,0,0,0,0]]]]},constant:[.2],bindings:[{path:'',class:'CanvasGroup',attribute:'m_Alpha'},{path:'',class:'Image',attribute:'m_Sprite',pptr:true},{path:'',class:'Image',attribute:'m_Color.a'}],pptrCurveMapping:[{spriteRef:'synthetic'}]}; + const source=pack();source.nodes[0].components.push(component('Image',{m_Sprite:null,m_Color:{a:1}})); + const data={streamed:{curveCount:1,discreteCurveCount:1,frames:[[0,[[0,0,0,0,1],[1,0,0,0,0]]]]},constant:[.2],bindings:[{path:'',class:'CanvasGroup',attribute:'m_Alpha'},{path:'',class:'Image',attribute:'m_Sprite',pptr:true},{path:'',class:'Image',attribute:'m_Color.a'}],pptrCurveMapping:[ref('sprite')]}; assert.deepEqual(sampleClip(data,0),[1,.2]);assert.deepEqual(sampleObjectCurves(data,0),[0]); - assert.equal(applyClip(source,data,0,0).objectApplied,1);assert.equal(source.document.nodes[0].components[1].m_Color.a,.2); - data.streamed.frames[0][1][1][4]=-1;applyClip(source,data,0,0);assert.equal(source.document.nodes[0].components[1].m_Sprite,null); + assert.equal(applyClip(source,data,0,0).objectApplied,1);assert.equal(source.nodes[0].components[1].fields.m_Color.a,.2); + data.streamed.frames[0][1][1][4]=-1;applyClip(source,data,0,0);assert.equal(source.nodes[0].components[1].fields.m_Sprite,null); }); test('clip poses only affect the chosen duplicate subtree and retain explicit missing-binding reasons',()=>{ - const source={document:{nodes:[node('Root','root'),node('Root/Item','a',[{type:'CanvasGroup',m_Alpha:.2}]),node('Root/Item','b',[{type:'CanvasGroup',m_Alpha:.8}])]},resources:{}}; - assert.equal(applyClip(source,clip('set',1),1,0).applied,1);assert.equal(source.document.nodes[2].components[0].m_Alpha,.8); + const source=pack([node('Root','root'),node('Root/Item','a',[{type:'CanvasGroup',m_Alpha:.2}]),node('Root/Item','b',[{type:'CanvasGroup',m_Alpha:.8}])]); + assert.equal(applyClip(source,clip('set',1),1,0).applied,1);assert.equal(source.nodes[2].components[0].fields.m_Alpha,.8); assert.equal(applyClip(source,clip('set',1),'Root/Item',0).diagnostics[0].reason,'ambiguous_path'); assert.equal(applyClip(source,clip('set',1),0,0).diagnostics[0].reason,'component_not_found'); }); @@ -106,13 +99,13 @@ test('serialized animation field names cannot write prototype or inherited field test('zero-duration sequence inserts stay parallel, seek uses the baseline, and unsupported callbacks are diagnostic',()=>{ const animation={class:'DOTweenAnimation',animationType:2,targetIsSelf:true,isActive:true,isValid:true,duration:1,easeType:1,endValueV3:{x:10,y:0,z:0}}; - const source={document:{nodes:[node('Root','root',[{class:'DOTweenSequence',_list:[{_commandType:0,_tweenAnimation:{nodeId:'one'},_tweenIndex:0,_duration:0},{_commandType:0,_tweenAnimation:{nodeId:'two'},_tweenIndex:0,_duration:0},{_commandType:1,_duration:0,_tweenEvent:{m_PersistentCalls:{m_Calls:[{m_CallState:1,m_Target:{nodeId:'one'},m_MethodName:'Unknown',m_Mode:5}]}}}]}]),node('Root/Child','one',[animation]),node('Root/Child','two',[structuredClone(animation)])]},resources:{}}; + const source=pack([node('Root','root',[{class:'DOTweenSequence',_list:[{_commandType:0,_tweenAnimation:ref('one'),_tweenIndex:0,_duration:0},{_commandType:0,_tweenAnimation:ref('two'),_tweenIndex:0,_duration:0},{_commandType:1,_duration:0,_tweenEvent:{m_PersistentCalls:{m_Calls:[{m_CallState:1,m_Target:ref('one'),m_MethodName:'Unknown',m_Mode:5}]}}}]}]),node('Root/Child','one',[animation]),node('Root/Child','two',[structuredClone(animation)])]); assert.equal(sequencePlan(source,0).duration,1);assert.deepEqual(sequencePlan(source,0).items.slice(0,2).map(i=>i.start),[0,0]); const session=new UISession(source).selectSequence(0).seek(.5),out=session.prepare(); - assert.equal(out.document.nodes[1].localPosition.x,5);assert.equal(out.document.nodes[2].localPosition.x,5); + assert.equal(out.nodes[1].localPosition.x,5);assert.equal(out.nodes[2].localPosition.x,5); assert.equal(session.report.diagnostics[0].reason,'sequence_callback_not_supported'); - session.seek(.25);assert.equal(session.prepare().document.nodes[1].localPosition.x,2.5); - assert.equal(source.document.nodes[1].localPosition.x,0); + session.seek(.25);assert.equal(session.prepare().nodes[1].localPosition.x,2.5); + assert.equal(source.nodes[1].localPosition.x,0); }); test('signed rects, flexible fixed-size cells, layout priority and trailing line feeds preserve geometry',()=>{ @@ -120,17 +113,30 @@ test('signed rects, flexible fixed-size cells, layout priority and trailing line const parent=rectBox(stretch(-120),100,100),child=rectBox(stretch(120),parent.w,parent.h); assert.equal(parent.w,-20);assert.deepEqual(affine.bounds(affine.mul(affine.box(parent),affine.box(child)),child.w,child.h),{minX:0,minY:0,maxX:100,maxY:100}); const children=[0,1].map(i=>({i,size:[50,20],min:[0,0],preferred:[90,40],flexible:[0,0],scale:[1,1],pivot:{x:.5,y:.5}})); - const boxes=linearGroupLayout({class:'HorizontalLayoutGroup',m_ChildForceExpandWidth:1,m_ChildAlignment:4},children,300,60); + const boxes=linearGroupLayout({m_ChildForceExpandWidth:1,m_ChildAlignment:4},children,300,60,true); assert.equal(boxes.get(0).x,50);assert.equal(boxes.get(1).x,200);assert.equal(boxes.get(0).w,50); assert.equal(layoutProperty([{priority:1,preferred:-1},{priority:0,preferred:30}],'preferred'),30); assert.deepEqual(visibleLines(['A','','B','']),['A','','B']);assert.equal(textHeight(['A',''],20),20);assert.equal(wrappingEnabled({m_TextWrappingMode:0}),false); - const merged=pack();mergeResources(merged,{fontMetricsByAsset:{A:'https://example.invalid/a.json'}});assert.equal(merged.resources.fontMetricsByAsset.A,'https://example.invalid/a.json'); }); test('a pack naming a browser font family loads no font resource',async()=>{ - const nodes=[{components:[{class:'TextMeshProUGUI',m_fontAsset:{name:'VibeMO SDF'}}]}],resolved=[]; - const resources={browserFontFamily:'"Noto Sans JP", sans-serif',fonts:{FZLTH:'fonts/a.ttf'},fontMetrics:'fonts/vibemo.json'}; - const loaded=await loadFonts({document:{nodes},resources},file=>{resolved.push(file);return file;}); - assert.equal(loaded.fontFamily,'"Noto Sans JP", sans-serif'); - assert.equal(loaded.gameFonts.size,0); - assert.deepEqual(resolved,[]); + const p=pack();p.browserFontFamily='"Noto Sans JP", sans-serif';p.objects.mono=()=>{throw Error('unexpected font load');}; + const loaded=await loadFonts(p); + assert.equal(loaded.fontFamily,'"Noto Sans JP", sans-serif');assert.equal(loaded.gameFonts.size,0); +}); + +const validateUI=(change=()=>{})=>{ + const n=network(),dir=fs.mkdtempSync(path.join(os.tmpdir(),'ui-products-')); + try{ + change(n); + for(const [file,bytes] of n.files){const target=path.join(dir,file);fs.mkdirSync(path.dirname(target),{recursive:true});fs.writeFileSync(target,bytes);} + const r=spawnSync(process.execPath,[fileURLToPath(new URL('../../scripts/validate-data.mjs',import.meta.url)),dir],{encoding:'utf8'}); + return {status:r.status,text:r.stdout+r.stderr}; + }finally{fs.rmSync(dir,{recursive:true,force:true});} +}; +test('UI validation checks the product graph, binding names and library references',()=>{ + const valid=validateUI();assert.equal(valid.status,0,valid.text);assert.match(valid.text,/1\/1 UI products valid/); + const edit=(n,fn)=>{const d=JSON.parse(new TextDecoder().decode(n.files.get(n.manifest.files['ui.json'].asset)));fn(d);n.manifest.files['ui.json']=n.asset(JSON.stringify(d));n.files.set('uipacks/one.json',new TextEncoder().encode(JSON.stringify(n.manifest)));}; + const bad=validateUI(n=>edit(n,d=>d.roots.push(d.roots[0])));assert.equal(bad.status,1);assert.match(bad.text,/duplicate object id/); + const bindings=validateUI(n=>edit(n,d=>Object.values(Object.values(Object.values(d.bindings)[0])[0])[0].pop()));assert.equal(bindings.status,1);assert.match(bindings.text,/names, the clip has/); + const missing=validateUI(n=>{n.index.controllers=[{id:'ui:99999',name:'Missing',manifest:'uipacks/one.json'}];n.files.set('index.json',new TextEncoder().encode(JSON.stringify(n.index)));});assert.equal(missing.status,1);assert.match(missing.text,/not in the store/); }); diff --git a/types/ui.d.ts b/types/ui.d.ts index 2b8944c..c015201 100644 --- a/types/ui.d.ts +++ b/types/ui.d.ts @@ -1,8 +1,23 @@ +import type {AssetStore, FromManifestOptions} from './index.js'; + /** Serialized prefab preview. Canvas 2D output does not claim Unity pixel parity. */ export type UINodeSelector = number | string; -export interface UIEntry { id: string; name: string; key?: string; kind: string; file?: string; status?: string; [key: string]: unknown } -export interface UIPack { schema?: number; resourceBase?: string; document: { nodes?: Array>; [key: string]: any }; resources: { textures?: Record; sprites?: Record; fonts?: Record; fontMetrics?: string; browserFontFamily?: string; [key: string]: any } } -export interface UIController { document: Record; resources?: UIPack['resources'] } +export interface UIEntry { id: string; name: string; key?: string; kind?: string; manifest: string; roots?: string[]; [key: string]: unknown } +export interface UINode { id: string; prefabId: string; path: string; name: string; pathId: string; components: Array<{type: string; pathId: string; script?: string; fields: Record}>; [key: string]: any } +export interface UIProduct { format: "nnnotes.ui-pack/1"; key: string; roots: string[]; prefabs: string[]; controllers: string[]; bindings: Record; shaders: Record; entries: Array> } +export interface UIIndex { format: "nnnotes.ui-library/1"; assets: UIEntry[]; embedded: UIEntry[]; controllers: UIEntry[] } +export const UI_PRODUCT: "ui.pack"; +export const UI_FORMAT: "nnnotes.ui-pack/1"; +export const UI_LIBRARY: "nnnotes.ui-library/1"; +export class UIPack { + static load(src: string | URL, options?: Pick & {roots?: string[]}): Promise; + constructor(store: AssetStore, ui: UIProduct, roots?: string[]); + readonly store: AssetStore; readonly ui: UIProduct; readonly roots: string[]; readonly nodes: UINode[]; + browserFontFamily?: string; + clone(): UIPack; clip(ref: string | Record): Record; controller(nodeIndex: number): UIController; + image(ref: string | Record, role?: string): Promise; +} +export interface UIController { id: string; document: Record; clip(index: number): Record } export interface UIReport { applied: number; numericApplied: number; objectApplied: number; missing: unknown[]; diagnostics: unknown[]; state?: string; events?: unknown[]; [key: string]: unknown } export interface UIProjection { fieldOfView: number; referenceViewport: [number, number]; canvasPlaneDistance: number; nearClipPlane?: number } export interface UIOptions { src?: string; entry?: UIEntry | string; library?: UILibrary; assetBase?: string; viewport?: [number, number]; showHidden?: boolean; bounds?: boolean; bindings?: boolean; projection?: UIProjection; framing?: 'root' | 'content' } @@ -11,11 +26,11 @@ export interface UIRenderResult { canvas: HTMLCanvasElement; width: number; heig export function cameraProjection(camera: Record, canvas: Record, referenceViewport: [number, number]): UIProjection; export class UILibrary { static load(src: string, options?: { fetch?: typeof fetch; signal?: AbortSignal }): Promise; - constructor(index: { assets: UIEntry[]; embedded?: UIEntry[]; [key: string]: any }, baseURL: string, fetcher?: typeof fetch); + constructor(index: UIIndex, baseURL: string, fetcher?: typeof fetch); readonly entries: UIEntry[]; readonly controllers: UIEntry[]; readonly baseURL: string; find(key: string | UIEntry): UIEntry; loadPack(entry: string | UIEntry, options?: { signal?: AbortSignal }): Promise<{ pack: UIPack; entry: UIEntry; assetBase: string }>; - loadController(pack: UIPack, nodeIndex: number, entry?: UIEntry, options?: { signal?: AbortSignal }): Promise; + loadController(pack: UIPack, nodeIndex: number): Promise; } export class UISession { constructor(pack: UIPack, options?: { bindings?: boolean });