diff --git a/README.en.md b/README.en.md index efcbdcd..d27ee8d 100644 --- a/README.en.md +++ b/README.en.md @@ -14,6 +14,10 @@ and the npm package contain no game assets: the player reads data prepared in th [nnnotes](https://github.com/MetaSekaiLab/nnnotes) toolkit produces that data from the user's own game files. BanG Dream! and related names and trademarks belong to their respective owners. +An independent experimental [UI prefab preview](docs/ui-preview.md) is available through `ournotes-player/ui` +and ``. It inspects `nnnotes ui` exports with Canvas 2D and a motion subset, without pixel-fidelity or +full game-runtime claims. The [example viewer](examples/ui) loads a library supplied by the user. + ## Components | | Live charts | Stories | Live2D models | diff --git a/README.md b/README.md index 3577bfc..8ec4233 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ ournotes-player 在网页中重现 BanG Dream! Our Notes 的三类画面:Live 本项目为非官方爱好者项目,与游戏的开发、发行和运营方无关。仓库与 npm 包不包含任何游戏资源:播放器读取按数据格式([Live 与 Live2D](docs/data-format.md)、[剧情](docs/story-data-format.md))准备的数据,数据由使用者自行提供;[nnnotes](https://github.com/MetaSekaiLab/nnnotes) 工具包可以从使用者自己的游戏文件生成这种数据。BanG Dream! 及相关名称与商标归各自权利人所有。 +另有独立、实验性的 [UI 预制体预览](docs/ui-preview.md):`ournotes-player/ui` 与 `` 读取 `nnnotes ui` 导出的数据,以 Canvas 2D 检查布局和部分动画,不宣称像素一致或完整游戏业务逻辑。可使用 [examples/ui](examples/ui) 打开自己的库。 + ## 组件 | | Live 谱面 | 剧情 | Live2D 模型 | diff --git a/docs/api.md b/docs/api.md index 2ab5a99..1c93515 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,8 +10,10 @@ live UI, with the chart's music and sound effects, auto-played at Perfect. The d |---|---| | `ournotes-player` | `ChartPlayer`, `ChartSession`, `AssetStore`, `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`; [prefab preview API and limits](ui-preview.md) | +| `ournotes-player/ui/element` (experimental) | the same UI exports; importing it defines `` | -Every time in the API is in milliseconds of chart time. +Chart API times are in milliseconds of chart time. The independent UI preview API uses seconds. ## `` diff --git a/docs/fidelity.md b/docs/fidelity.md index 72850cd..a791032 100644 --- a/docs/fidelity.md +++ b/docs/fidelity.md @@ -3,6 +3,9 @@ What the player reproduces from the game, under which settings, what it adds as a viewer, and where it relies on the documented behaviour of the engine rather than on the game's own code. +The independent experimental [UI prefab preview](ui-preview.md) has a separate fidelity scope. Its Canvas 2D +graphics and motion are inspection approximations; the GPU-rendering claims below do not apply to it. + ## Source of the behaviour The player follows the game's own managed code; comments in `src/` name the game class and method each piece @@ -562,4 +565,12 @@ Every `ENGINE:` note in `src/`, by file. `npm test` checks that this list matche - Animator.writeDefaultValuesOnDisable is not applied: every animated node here is invisible while disabled and resampled on its first update after enable. - UniTask's DOTween awaiter completes on the tween's kill callback, so a flash killed by the next Flash call (or Refresh) ends its await inside DOKill and hides the view before the new flash shows it. +**`src/ui/layout.js`** + +- RectTransform sizes remain signed. A negative intermediate rect can + +**`src/ui/text-layout.js`** + +- TMP's vertical anchor and preferred height use the visible text + diff --git a/docs/ui-preview.md b/docs/ui-preview.md new file mode 100644 index 0000000..a2834a9 --- /dev/null +++ b/docs/ui-preview.md @@ -0,0 +1,108 @@ +# UI prefab preview (experimental) + +The optional `ournotes-player/ui` module previews serialized UI assets exported by `nnnotes 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 +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. + +```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 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 +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. + +## Custom element + +```html + + +``` + +Without `entry`, a library's first exported prefab is selected. A standalone pack URL needs no `entry`. +`src` and `entry` are also properties. `show-hidden` and `bounds` are boolean inspection attributes. +`ready` resolves to the current UIPlayer; replacing an in-flight source or disconnecting rejects that pending +promise with AbortError. Reconnecting creates a fresh player. A transient DOM move keeps the current player. +`playState`, `selectClip`, `selectSequence` and `seek` wait for ready; `play` and `pause` act on the loaded player. + +## API and state + +All UI motion times are **seconds**, unlike chart/story API times. `UILibrary.load(src, {fetch, signal})` loads an +index, while packs and controllers load on demand. `find` accepts an entry ID, exact catalog key, unique name or +an entry record. Ambiguous names throw; same-named controllers resolve by their serialized IDs. + +`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` + edits a node field; dotted field paths are supported and prototype-related paths are rejected. +- `setController(node, {document, resources})`, `playState(nameOrIndex)`, `selectClip(nameOrIndex)` and + `selectSequence(node)`. +- `setParameter(name, value)`, `seek(seconds)`, `update(deltaSeconds)`, `reset()` and `prepare()`. + +`prepare` returns a new preview pack. It applies edits, optional component bindings and the selected motion; +the input is unchanged. `bindings: false` leaves serialized state intact except for explicit edits and motion. +`reset` clears edits and motion while retaining loaded controllers. Parameter history is replayed on seek. + +UIPlayer wraps the same session with `load`, `controller`, `playState`, `selectClip`, `selectSequence`, +`setParameter`, `edit`, `applyFixture`, `seek`, `reset`, `render`, `play`, `pause` and `destroy`. Options include +`viewport: [1920,1080]`, `assetBase`, `showHidden`, `bounds` and `bindings`. The player exposes `canvas`, `nodes`, +`animators`, `sequences`, `time`, `duration`, `paused`, `report` and `session`. `applyFixture({patches})` applies +caller-supplied `{node, component, field, value}` patches; `_previewSource` image URLs resolve against its optional +baseURL. Page-specific sample data and business rules belong to the caller. + +Events are `ready`, `render`, `play`, `pause`, `timechange` and `error`, with CustomEvent details. Reports retain +applied numeric/Sprite counts, unsupported/missing-binding diagnostics and recorded animation events. +Recorded events do not execute arbitrary native callbacks. Destroying a player stops scheduled playback and +invalidates pending loads/renders. + +## Rendering and limits + +Implemented preview paths include Sprite crops, nine-slice/fill/tiled images, tint/gradients, basic masks, +RectTransform geometry, linear/grid layout, content/aspect fitting, basic text, component state bindings, +numeric and Sprite-reference curves, single-layer direct-motion Animator states, parameters/triggers, +exit-time transitions, crossfade, and the supported DOTween sequence/target subset. Unsupported motion and +callback cases are reported rather than executed. + +Signed intermediate rectangles are retained; their positive stretched children must not be widened by clamping +the parent to zero. Isolated prefab canvases include graphics outside the root, while a screen Canvas retains +the requested viewport. Fixed-size layout children remain fixed inside flexible cells. Trailing text line feeds +do not add to visible vertical alignment. VibeMO ASCII faces use separate exported glyph metrics; available FZ +TTFs provide the fallback text face. Other font faces fall back to the browser font. + +TMP rich text, exact glyph layout/antialiasing, width-dependent preferred height and uGUI rebuild ordering, +CanvasGroup/mask details, custom GPU materials, particles, localization, dynamic lists, Presenter data, +Live2D/video/camera composition and complex Animator layers/BlendTrees are incomplete. Animator stepping and +crossfade are preview approximations; long Animator advances are limited to 1,000 seconds. Initial prefab text, +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. + +## Evidence and verification + +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. + +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. diff --git a/examples/ui/index.html b/examples/ui/index.html new file mode 100644 index 0000000..82405f4 --- /dev/null +++ b/examples/ui/index.html @@ -0,0 +1,23 @@ + + + + +Our Notes · UI preview + + +
OUR NOTES

UI preview Experimental

Inspect a prefab library from your own APK.

+
+
+ +
+

Choose a library

Export with nnnotes ui and serve the output over HTTP.

+
+
+

Your exported prefab will appear here.

+
0.000 s
+

Serialized initial states may include placeholder text and unbound images.

+
Binding and motion diagnostics
No motion selected.
+
+
+ + diff --git a/examples/ui/ui.css b/examples/ui/ui.css new file mode 100644 index 0000000..8cdd015 --- /dev/null +++ b/examples/ui/ui.css @@ -0,0 +1 @@ +:root{font-family:system-ui,sans-serif;color:#302b3e;background:#f7f5fa;accent-color:#7254a2}*{box-sizing:border-box}body{margin:0}header{padding:24px 32px 16px;display:flex;justify-content:space-between;align-items:end;background:white}.brand{font-size:11px;font-weight:700;letter-spacing:.15em;color:#76638f}h1{font-size:24px;margin:6px 0}h2{font-size:17px;margin:0}p{color:#706779;font-size:13px}.badge{font-size:11px;vertical-align:middle;background:#eee6f8;color:#62418b;border-radius:10px;padding:4px 8px}form{display:flex;align-items:center;gap:12px;padding:14px 32px;background:white;border-block:1px solid #e6e0ed}input,select,button{font:inherit;border:1px solid #d8cee3;border-radius:7px;padding:8px;color:inherit;background:white}button{cursor:pointer;background:#705099;color:white;border-color:#705099;white-space:nowrap}button:disabled,select:disabled{opacity:.5;cursor:default}input[type=url]{flex:1;min-width:100px}label{font-size:12px}main{display:grid;grid-template-columns:230px minmax(0,1fr);gap:16px;padding:20px 32px}aside{display:flex;flex-direction:column;gap:10px}#assets{width:100%;min-height:360px;border:0;background:transparent;padding:0}#assets option{padding:10px 8px;border-radius:6px}#assets option:checked{background:#e6ddf0}.viewer{min-width:0;background:white;border:1px solid #e3ddea;border-radius:12px;overflow:hidden}.title{display:flex;gap:14px;align-items:center;padding:18px}.title>div{flex:1;min-width:0}#key{overflow-wrap:anywhere;margin:5px 0 0;font-size:11px}.motions{display:flex;flex-wrap:wrap;gap:8px;padding:0 18px 14px}.motions select{max-width:220px;font-size:12px}.stage{min-height:350px;height:48vh;display:flex;align-items:center;justify-content:center;overflow:auto;padding:24px;background-color:#f0ecf4;background-image:linear-gradient(45deg,#e8e2ee 25%,transparent 25%),linear-gradient(-45deg,#e8e2ee 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#e8e2ee 75%),linear-gradient(-45deg,transparent 75%,#e8e2ee 75%);background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0}#canvas{max-width:100%;max-height:100%;object-fit:contain}#canvas[hidden]{display:none!important}.timeline{display:flex;align-items:center;gap:12px;padding:14px 18px}.timeline input{flex:1;min-width:50px}.timeline output{font-size:12px;font-variant-numeric:tabular-nums}#parameters{display:flex;flex-wrap:wrap;gap:10px;padding:0 18px 12px}#parameters:empty{display:none}#parameters input[type=number]{width:80px;margin-left:6px}#status{padding:0 18px;margin:0 0 14px}details{border-top:1px solid #e8e2ee;padding:12px 18px;font-size:12px}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:180px;overflow:auto}@media(max-width:720px){header,form{padding:16px}header{display:block}header p{margin-bottom:0}form{flex-wrap:wrap}form label{width:100%}main{grid-template-columns:1fr;padding:16px}#assets{min-height:100px;height:120px}.title{flex-wrap:wrap}.title>div{flex-basis:100%}.stage{height:42vh;min-height:240px}} diff --git a/examples/ui/ui.js b/examples/ui/ui.js new file mode 100644 index 0000000..c2e65b1 --- /dev/null +++ b/examples/ui/ui.js @@ -0,0 +1,57 @@ +import {UILibrary,UIPlayer} from '../../src/ui/index.js'; + +const $=s=>document.querySelector(s),canvas=$('#canvas'); +let library=null,player=null,entries=[],generation=0; +canvas.hidden=true; +const option=(text,value)=>{const o=document.createElement('option');o.textContent=text;o.value=value;return o;}; +const fill=(select,label,rows)=>{select.replaceChildren(option(label,''),...rows.map(r=>option(r.name,r.value)));select.disabled=!rows.length;}; +const error=e=>{$('#status').textContent=e.message||String(e);}; +const run=fn=>async()=>{try{await fn();refresh();}catch(e){error(e);}}; + +function refresh(){ + if(!player)return; + const motion=player.session?.motion;$('#play').disabled=!motion;$('#time').disabled=!motion;$('#play').textContent=player.paused?'Play':'Pause'; + $('#time').max=Math.max(1,player.duration,player.time);$('#time').value=player.time;$('#seconds').value=player.time.toFixed(3)+' s'; + $('#parameters').hidden=motion?.kind!=='state';$('#diagnostics').textContent=JSON.stringify(player.report,null,2); + const result=player.lastResult; + if(result)$('#status').textContent=`${Math.round(result.width||canvas.width)} × ${Math.round(result.height||canvas.height)} · ${player.nodes.length} nodes · ${player.report?.diagnostics.length||0} motion diagnostics`; +} + +function list(){ + const query=$('#search').value.trim().toLowerCase(),visible=entries.filter(e=>(e.name+' '+(e.key||'')).toLowerCase().includes(query)); + $('#assets').replaceChildren(...visible.map(e=>option(e.name,e.id)));$('#count').textContent=visible.length+' prefabs'; +} + +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(); + 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; + fill($('#animator'),'Animator',player.animators.map(a=>({name:a.name+' · '+a.path.split('/').at(-1),value:a.index}))); + fill($('#sequence'),'Sequence',player.sequences.map(a=>({name:a.name,value:a.index}))); + for(const event of ['render','play','pause'])player.addEventListener(event,refresh);player.addEventListener('error',e=>error(e.detail.error));refresh(); +} + +$('#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;} +}; +$('#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}))); + 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);} + } +}); +$('#state').onchange=run(async()=>{if($('#state').value==='')return;await player.playState(Number($('#state').value),{animator:Number($('#animator').value)});$('#clip').value=$('#sequence').value='';}); +$('#clip').onchange=run(async()=>{if($('#clip').value==='')return;await player.selectClip(Number($('#clip').value),{animator:Number($('#animator').value)});$('#state').value=$('#sequence').value='';}); +$('#sequence').onchange=run(async()=>{if($('#sequence').value==='')return;await player.selectSequence(Number($('#sequence').value));$('#state').value=$('#clip').value='';}); +$('#play').onclick=()=>{if(!player)return;player.paused?player.play():player.pause();refresh();}; +$('#time').oninput=run(()=>player.seek(Number($('#time').value)));$('#reset').onclick=run(async()=>{await player.reset();$('#state').value=$('#clip').value=$('#sequence').value='';}); +for(const [id,key] of [['hidden','showHidden'],['bounds','bounds']])$('#'+id).onchange=run(async()=>{if(player){player.options[key]=$('#'+id).checked;await player.render();}}); + +const src=new URL(location.href).searchParams.get('src');if(src){$('#source').value=new URL(src,location.href).href;$('#open').requestSubmit();} diff --git a/package.json b/package.json index 9ed3500..eb280f2 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,14 @@ "types": "./types/story.d.ts", "default": "./src/story/define.js" }, + "./ui": { + "types": "./types/ui.d.ts", + "default": "./src/ui/index.js" + }, + "./ui/element": { + "types": "./types/ui.d.ts", + "default": "./src/ui/define.js" + }, "./dist/*": "./dist/*", "./schema/*": "./schema/*", "./package.json": "./package.json" @@ -65,6 +73,9 @@ "./src/story/define.js", "./src/story/player-core.js", "./src/story/features/*.js", + "./src/ui/define.js", + "./dist/ournotes-player.ui.element*.js", + "./dist/ournotes-player.ui.global*.js", "./dist/ournotes-player.story.element*.js", "./dist/ournotes-player.story.global*.js" ], @@ -81,6 +92,7 @@ "examples/songs/", "examples/story/", "examples/story-list/", + "examples/ui/", "LICENSE", "LICENSE-EXCEPTION" ], diff --git a/scripts/build.mjs b/scripts/build.mjs index 62f8049..6f298d0 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -38,6 +38,9 @@ const BUNDLES = [ { entry: "src/story/index.js", out: "ournotes-player.story", format: "esm" }, { entry: "src/story/define.js", out: "ournotes-player.story.element", format: "esm" }, { entry: "src/story/define.js", out: "ournotes-player.story.global", format: "iife", globalName: "OurnotesStory" }, + { entry: "src/ui/index.js", out: "ournotes-player.ui", format: "esm" }, + { entry: "src/ui/define.js", out: "ournotes-player.ui.element", format: "esm" }, + { entry: "src/ui/define.js", out: "ournotes-player.ui.global", format: "iife", globalName: "OurnotesUI" }, ]; async function build(outdir) { diff --git a/scripts/engine-notes.mjs b/scripts/engine-notes.mjs index 42ea9d3..3b05e72 100644 --- a/scripts/engine-notes.mjs +++ b/scripts/engine-notes.mjs @@ -20,13 +20,16 @@ const files = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) // [{file, notes: [text]}] in the order of the "/"-separated paths (the same on every platform); a note is the rest of // the line after `// ENGINE:` -export const collect = () => files(path.join(root, "src")).map((f) => { +export const notesFromSource = (source) => { const notes = []; - for (const line of fs.readFileSync(f, "utf8").split("\n")) { + for (const line of source.split(/\r?\n/)) { const m = line.match(/\/\/\s*ENGINE:\s*(.*)$/); if (m && m[1] && !m[1].startsWith("`")) notes.push(m[1].trim()); } - return { file: path.relative(root, f).split(path.sep).join("/"), notes }; + return notes; +}; +export const collect = () => files(path.join(root, "src")).map((f) => { + return { file: path.relative(root, f).split(path.sep).join("/"), notes: notesFromSource(fs.readFileSync(f, "utf8")) }; }).filter((x) => x.notes.length).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)); export const render = (groups) => { diff --git a/src/ui/animator.js b/src/ui/animator.js new file mode 100644 index 0000000..5158c64 --- /dev/null +++ b/src/ui/animator.js @@ -0,0 +1,180 @@ +import {clipPose,writePose,nodeMap} from './runtime.js'; + +const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +const mod=(v,n)=>((v%n)+n)%n; +const field=(obj,path)=>path.split('.').reduce((v,k)=>v?.[k],obj); + +export function controllerParameters(controller){ + const data=controller.defaultValues?.data||controller.defaultValues||{}; + return Object.fromEntries((controller.parameters||[]).map(p=>{ + const values=data[p.type===1?'m_FloatValues':p.type===3?'m_IntValues':'m_BoolValues']||[]; + return [p.name,p.type===9?false:values[p.index]??(p.type===4?false:0)]; + })); +} + +export function stateMotion(controller,state){ + 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; + return {clip,duration:Math.max(.000001,clip?(clip.stopTime-clip.startTime):1)}; +} + +export function transitionConditions(transition,parameters){ + return (transition.conditions||[]).every(c=>{ + const v=parameters[c.event]; + switch(c.mode){case 1:return !!v;case 2:return !v;case 3:return v>c.threshold;case 4:return vo.value));if(length)for(const [key,op] of ops)result.set(key,{...op,value:op.value/length});} + return result; +} + +// Serialized single-layer Animator controllers used by this UI library. +// 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.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(); + } + reset(){ + this.parameters={...this.defaults};this.timelineTime=0;this.current={index:this.startState,normalized:0}; + this.transition=null;this.exited=false;this.heldPose=new Map();this.pendingStates=[];this.events=[];this.lastResult=null; + this.parameterCursor=0;return this; + } + play(index){ + if(!this.machine?.states[index])throw Error('Animator state does not exist: '+index); + this.startState=index;this.parameterEvents=[];this.reset();this.advance(0);return this; + } + crossFade(index,normalizedDuration=0){ + if(!this.machine?.states[index])throw Error('Animator state does not exist: '+index); + this.beginTransition({destination:index,duration:normalizedDuration,fixedDuration:false,offset:0,conditions:[]});return this; + } + setParameter(name,value){ + const p=this.controller.parameters?.find(p=>p.name===name);if(!p)throw Error('Animator parameter does not exist: '+name); + if(p.type===4||p.type===9)value=!!value;else if(p.type===3)value=Math.round(value);else value=Number(value); + if(typeof value==='number'&&!Number.isFinite(value))throw Error('Animator parameter must be finite: '+name); + this.parameterEvents=this.parameterEvents.filter(e=>e.time<=this.timelineTime); + this.parameterEvents.push({time:this.timelineTime,name,value});this.parameterCursor=this.parameterEvents.length; + this.parameters[name]=value;this.advance(0);return this; + } + seek(time){ + const history=this.parameterEvents.slice();this.reset();this.parameterEvents=history; + 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 speed=(state?.speed??1)*(state?.speedParam?Number(this.parameters[state.speedParam]??1):1); + return {state,...motion,speed}; + } + sampleTime(cursor){ + const {state,clip,duration}=this.stateInfo(cursor);if(!clip)return 0; + let normalized=state.timeParam?Number(this.parameters[state.timeParam]??0):cursor.normalized; + normalized+=(state.cycleOffset||0)+(clip.cycleOffset||0); + normalized=state.loop||clip.loopTime?mod(normalized,1):clamp(normalized,0,1); + return (clip.startTime||0)+normalized*duration; + } + eligible(t,cursor){ + // Unity ignores transitions with neither a condition nor an exit time. + if(!t.hasExitTime&&!(t.conditions||[]).length)return false; + if(!transitionConditions(t,this.parameters))return false; + if(t.destination===cursor.index&&!t.canTransitionToSelf)return false; + if(!t.hasExitTime)return true; + const {state,clip}=this.stateInfo(cursor),exit=t.exitTime||0; + if((state?.loop||clip?.loopTime)&&exit<1){const progress=mod(cursor.normalized,1);return progress+1e-8>=exit&&progress<=exit+1/60;} + return cursor.normalized+1e-8>=exit; + } + beginTransition(t){ + const from={...this.current},info=this.stateInfo(from),duration=Math.max(0,(t.duration||0)*(t.fixedDuration?1:info.duration)); + const exit=t.destination===30001; + if(!exit&&!this.machine.states[t.destination]){if(!this.warnings.includes('unsupported_destination:'+t.destination))this.warnings.push('unsupported_destination:'+t.destination);return false;} + for(const c of t.conditions||[])if(this.controller.parameters?.some(p=>p.name===c.event&&p.type===9))this.parameters[c.event]=false; + if(!duration){this.pendingStates.push(from);this.current=exit?from:{index:t.destination,normalized:t.offset||0};this.exited=exit;return true;} + this.transition={from,to:exit?null:{index:t.destination,normalized:t.offset||0},elapsed:0,duration,name:t.name};return true; + } + checkTransitions(){ + if(this.transition||this.exited||!this.machine)return; + const state=this.stateInfo().state; + const candidates=[...(this.machine.anyStateTransitions||[]),...(state?.transitions||[])]; + const next=candidates.find(t=>this.eligible(t,this.current));if(next)this.beginTransition(next); + } + collectEvents(cursor,before,after){ + const {state,clip,duration}=this.stateInfo(cursor);if(!clip?.events?.length||afterbefore+1e-8&&at<=after+1e-8)this.events.push({...event,clip:clip.clip,state:state.name,at:this.timelineTime}); + } + } + if(this.events.length>100)this.events.splice(0,this.events.length-100); + } + advanceCursor(cursor,dt){ + const info=this.stateInfo(cursor);if(!info.state)return; + const before=cursor.normalized;cursor.normalized+=dt*info.speed/info.duration;this.collectEvents(cursor,before,cursor.normalized); + } + advance(delta){ + if(!Number.isFinite(delta)||delta<0)throw Error('Animator delta must be a nonnegative finite number'); + const end=this.timelineTime+delta;let count=0; + do{ + while(this.parameterCursor0){ + if(this.transition){ + const t=this.transition;this.advanceCursor(t.from,dt);if(t.to)this.advanceCursor(t.to,dt);t.elapsed+=dt; + if(t.elapsed+1e-8>=t.duration){this.pendingStates.push(t.from);this.current=t.to||t.from;this.exited=!t.to;this.transition=null;} + }else if(!this.exited)this.advanceCursor(this.current,dt); + this.timelineTime+=dt; + } + if(++count>120000)throw Error('Animator preview exceeded 1000 seconds'); + }while(this.timelineTime+1e-8{ + const {state,clip,unsupported}=this.stateInfo(cursor); + if(unsupported&&!this.warnings.includes(unsupported))this.warnings.push(unsupported); + const report=clip?.bindings?clipPose(pack,clip,this.rootPath,this.sampleTime(cursor)):{pose:new Map(),applied:0,numericApplied:0,objectApplied:0,missing:[],diagnostics:[]}; + if(collect)reports.push(report);return {state,pose:report.pose}; + }; + for(const cursor of this.pendingStates){const r=sample(cursor,false);if(r.state?.writeDefaultValues)this.heldPose.clear();for(const [key,op] of r.pose)this.heldPose.set(key,op);} + this.pendingStates=[]; + let pose; + if(this.transition){ + const t=this.transition,a=sample(t.from),b=t.to?sample(t.to):{state:null,pose:a.pose}; + const from=new Map(a.state?.writeDefaultValues?[]:this.heldPose);for(const [k,v] of a.pose)from.set(k,v); + const to=new Map(b.state?.writeDefaultValues?[]:from);for(const [k,v] of b.pose)to.set(k,v); + pose=blendPose(from,to,clamp(t.elapsed/t.duration,0,1),pack); + }else{ + const r=sample(this.current);pose=new Map(r.state?.writeDefaultValues?[]:this.heldPose);for(const [key,op] of r.pose)pose.set(key,op); + } + writePose(pack,pose);this.heldPose=pose; + const state=this.stateInfo(this.transition?.to||this.current).state; + this.lastResult={state:state?.name,normalizedTime:this.current.normalized,exited:this.exited,transition:this.transition?.name||null, + applied:reports.reduce((n,r)=>n+r.applied,0),numericApplied:reports.reduce((n,r)=>n+r.numericApplied,0),objectApplied:reports.reduce((n,r)=>n+r.objectApplied,0), + missing:reports.flatMap(r=>r.missing),diagnostics:reports.flatMap(r=>r.diagnostics),events:this.events,warnings:this.warnings}; + return this.lastResult; + } +} diff --git a/src/ui/define.js b/src/ui/define.js new file mode 100644 index 0000000..81ae904 --- /dev/null +++ b/src/ui/define.js @@ -0,0 +1,3 @@ +import {defineOurnotesUI} from './element.js'; +defineOurnotesUI(); +export * from './index.js'; diff --git a/src/ui/element.js b/src/ui/element.js new file mode 100644 index 0000000..2066ae7 --- /dev/null +++ b/src/ui/element.js @@ -0,0 +1,41 @@ +import {UIPlayer} from './player.js'; + +const Base=globalThis.HTMLElement||class {}; + +export class OurnotesUIElement extends Base { + static get observedAttributes(){return ['src','entry','show-hidden','bounds'];} + constructor(){ + super();this.attachShadow({mode:'open'});const style=document.createElement('style');style.textContent=':host{display:block;position:relative;contain:content}:host([hidden]){display:none}canvas{max-width:100%;max-height:100%;object-fit:contain}'; + this.canvas=document.createElement('canvas');this.shadowRoot.append(style,this.canvas);this.player=null;this._generation=0;this._resetReady(); + } + get src(){return this.getAttribute('src')||'';}set src(value){this.setAttribute('src',value);} + get entry(){return this.getAttribute('entry')||'';}set entry(value){if(value)this.setAttribute('entry',value);else this.removeAttribute('entry');} + get ready(){return this._ready;} + get time(){return this.player?.time||0;} + connectedCallback(){clearTimeout(this._disposeTimer);if(this.src&&!this.player&&!this._loading)this._load();} + disconnectedCallback(){this._disposeTimer=setTimeout(()=>{if(!this.isConnected){this._generation++;this.player?.destroy();this.player=null;this._loading=false;if(!this._settled){this._settled=true;this._reject(new DOMException('UI element disconnected','AbortError'));}}},0);} + attributeChangedCallback(name,old,value){ + if(old===value)return;if(name==='src'||name==='entry'){if(this.isConnected)this._load();} + else if(this.player){this.player.options[name==='show-hidden'?'showHidden':'bounds']=value!==null;this.player.render().catch(error=>this.dispatchEvent(new CustomEvent('error',{detail:{error}})));} + } + _resetReady(){if(this._ready&&!this._settled)this._reject(new DOMException('UI load superseded','AbortError'));this._settled=false;this._ready=new Promise((resolve,reject)=>{this._resolve=resolve;this._reject=reject;});this._ready.catch(()=>{});} + async _load(){ + const generation=++this._generation;this.player?.destroy();this.player=null;if(this._settled||this._loading)this._resetReady();this._loading=true; + try{ + let player; + if(this.entry){const {UILibrary}=await import('./library.js'),library=await UILibrary.load(this.src);player=await UIPlayer.create(this.canvas,{library,entry:this.entry,showHidden:this.hasAttribute('show-hidden'),bounds:this.hasAttribute('bounds')});} + else player=await UIPlayer.create(this.canvas,{src:this.src,showHidden:this.hasAttribute('show-hidden'),bounds:this.hasAttribute('bounds')}); + if(generation!==this._generation||!this.isConnected){player.destroy();return;} + this.player=player;for(const event of ['render','play','pause','timechange','error'])player.addEventListener(event,e=>this.dispatchEvent(new CustomEvent(event,{detail:e.detail}))); + this._settled=true;this._resolve(player);this.dispatchEvent(new CustomEvent('ready',{detail:{player}})); + }catch(error){if(generation===this._generation){this._settled=true;this._reject(error);this.dispatchEvent(new CustomEvent('error',{detail:{error}}));}} + finally{if(generation===this._generation)this._loading=false;} + } + async playState(state,options){return (await this.ready).playState(state,options);} + async selectClip(clip,options){return (await this.ready).selectClip(clip,options);} + async selectSequence(node){return (await this.ready).selectSequence(node);} + async seek(time){return (await this.ready).seek(time);} + play(){this.player?.play();}pause(){this.player?.pause();} +} + +export function defineOurnotesUI(name='ournotes-ui'){if(!customElements.get(name))customElements.define(name,OurnotesUIElement);return OurnotesUIElement;} diff --git a/src/ui/game-font.js b/src/ui/game-font.js new file mode 100644 index 0000000..8fc0f68 --- /dev/null +++ b/src/ui/game-font.js @@ -0,0 +1,29 @@ +// 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); +} +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 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; + let cursor=x-(ctx.textAlign==='center'?width/2:ctx.textAlign==='right'?width:0); + const baseline=y+(font.face.m_AscentLine+font.face.m_DescentLine)*scale/2; + for(const char of text){ + const glyph=font.glyphs[font.characters[char.codePointAt(0)]],m=glyph.m_Metrics,r=glyph.m_GlyphRect; + if(m.m_Width>0&&m.m_Height>0){ + const key=[char,size,...['r','g','b','a'].map(k=>color[k]??1)].join(':'); + 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 pixels=c.getImageData(0,0,canvas.width,canvas.height); + for(let i=0;i[m[0]*x+m[2]*y+m[4],m[1]*x+m[3]*y+m[5]]);return {minX:Math.min(...points.map(p=>p[0])),minY:Math.min(...points.map(p=>p[1])),maxX:Math.max(...points.map(p=>p[0])),maxY:Math.max(...points.map(p=>p[1]))};} +}; +export function intersect(a,b){if(!b)return a;const r={minX:Math.max(a.minX,b.minX),minY:Math.max(a.minY,b.minY),maxX:Math.min(a.maxX,b.maxX),maxY:Math.min(a.maxY,b.maxY)};return r.maxX>r.minX&&r.maxY>r.minY?r:null;} + +// LayoutUtility: a negative property does not participate, highest priority +// wins and equal priorities take the larger value. +export function layoutProperty(elements,property,fallback=0){ + let value=fallback,priority=-Infinity; + for(const element of elements){ + const v=element[property],p=element.priority??0; + if(!Number.isFinite(v)||v<0||ppriority){value=v;priority=p;}else value=Math.max(value,v); + } + return value; +} +function groupSettings(group){ + const p=group.m_Padding||{},align=group.m_ChildAlignment||0; + return {vertical:group.class!=='HorizontalLayoutGroup',spacing:group.m_Spacing||0, + padding:[(p.m_Left||0)+(p.m_Right||0),(p.m_Top||0)+(p.m_Bottom||0)],start:[p.m_Left||0,p.m_Top||0], + align:[align%3/2,Math.floor(align/3)/2],control:[!!group.m_ChildControlWidth,!!group.m_ChildControlHeight], + expand:[!!group.m_ChildForceExpandWidth,!!group.m_ChildForceExpandHeight],scale:[!!group.m_ChildScaleWidth,!!group.m_ChildScaleHeight]}; +} +function childSizes(child,axis,g){ + const controlled=g.control[axis],size=child.size[axis],scale=g.scale[axis]?child.scale?.[axis]??1:1; + const min=controlled?child.min[axis]:size,preferred=controlled?child.preferred[axis]:size; + // The Ghidra C omits this fmaxnm/fcsel branch; it is present at + // 0xbd29fec..0xbd2a004 in the hash-checked native instructions. + const flexible=Math.max(controlled?child.flexible[axis]:0,g.expand[axis]?1:0); + return {min,preferred,flexible,scale}; +} +function groupAxis(g,children,axis){ + const other=g.vertical!==(axis===1),pad=g.padding[axis]; + let min=pad,preferred=pad,flexible=0; + for(const child of children){ + const s=childSizes(child,axis,g); + if(other){min=Math.max(min,s.min*s.scale+pad);preferred=Math.max(preferred,s.preferred*s.scale+pad);flexible=Math.max(flexible,s.flexible*s.scale);} + else {min+=s.min*s.scale+g.spacing;preferred+=s.preferred*s.scale+g.spacing;flexible+=s.flexible*s.scale;} + } + if(!other&&children.length){min-=g.spacing;preferred-=g.spacing;} + return {min,preferred:Math.max(min,preferred),flexible}; +} +export function linearGroupInput(group,children){ + const g=groupSettings(group),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(); + 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; + const place=(child,pos,allocated,s)=>{ + const actual=g.control[axis]?allocated:child.size[axis],pivot=axis===0?child.pivot?.x??.5:1-(child.pivot?.y??.5),box=result.get(child.i); + box[axis===0?'x':'y']=pos+actual*pivot*(s.scale-1); + box[axis===0?'w':'h']=actual; + }; + if(other){ + const inner=size[axis]-g.padding[axis]; + for(const child of children){const s=childSizes(child,axis,g),required=Math.max(s.min,Math.min(inner,s.flexible>0?size[axis]:s.preferred));place(child,start(required*s.scale)+(g.control[axis]?0:(required-child.size[axis])*align),required,s);} + }else{ + const surplus=size[axis]-total.preferred,flex=surplus>0&&total.flexible>0?surplus/total.flexible:0,lerp=total.min===total.preferred?0:Math.max(0,Math.min(1,(size[axis]-total.min)/(total.preferred-total.min))); + let cursor=surplus>0&&!total.flexible?start(total.preferred-g.padding[axis]):g.start[axis]; + for(const child of children){const s=childSizes(child,axis,g),allocated=s.min+(s.preferred-s.min)*lerp+s.flexible*flex;place(child,cursor+(g.control[axis]?0:(allocated-child.size[axis])*align),allocated,s);cursor+=allocated*s.scale+g.spacing;} + } + } + return result; +} diff --git a/src/ui/library.js b/src/ui/library.js new file mode 100644 index 0000000..cd717b1 --- /dev/null +++ b/src/ui/library.js @@ -0,0 +1,49 @@ +import {component} from './runtime.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; + } + 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); + } + 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}; + } + 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); + } +} + +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; +} diff --git a/src/ui/player.js b/src/ui/player.js new file mode 100644 index 0000000..7e30c6c --- /dev/null +++ b/src/ui/player.js @@ -0,0 +1,82 @@ +import {Renderer} from './renderer.js'; +import {UISession} from './session.js'; +import {UILibrary,resourceURLs} from './library.js'; +import {component,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. +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}={}){ + super();if(!host)throw Error('UIPlayer requires a host element'); + this.canvas=host.tagName?.toLowerCase()==='canvas'?host:document.createElement('canvas');if(this.canvas!==host)host.append(this.canvas); + this.canvas.style.cssText='display:block;max-width:100%;max-height:100%;object-fit:contain';this.context=this.canvas.getContext('2d');if(!this.context)throw Error('Canvas 2D unavailable'); + this.library=library;this.assetBase=assetBase;this._explicitAssetBase=assetBase;this.options={viewport,showHidden,bounds,bindings};this.session=null;this.paused=true;this.destroyed=false;this._revision=0;this._loadRevision=0;this._playRevision=0;this._raf=null; + } + emit(name,detail){this.dispatchEvent(new CustomEvent(name,{detail}));} + get nodes(){return this.session?.nodes||[];} + get animators(){return this.session?.animators||[];} + get sequences(){return this.session?.sequences||[];} + get time(){return this.session?.time||0;} + get report(){return this.session?.report||null;} + get duration(){const m=this.session?.motion;return m?.kind==='clip'?m.clip.stopTime:m?.kind==='sequence'?m.runtime.plan.duration:m?.kind==='state'?m.runtime.stateInfo().duration:0;} + 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;} + 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(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; + } + async controller(selector=this.animators[0]?.index){ + 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.destroyed||revision!==this._loadRevision||session!==this.session)throw new DOMException('UI document changed while loading its controller','AbortError'); + session.setController(index,controller);return controller; + } + async playState(state,{animator=this.animators[0]?.index}={}){this.pause();const controller=await this.controller(animator);this.session.setController(animator,controller).playState(state);await this.render();return this;} + async selectClip(clip,{animator=this.animators[0]?.index}={}){this.pause();const controller=await this.controller(animator);this.session.setController(animator,controller).selectClip(clip);await this.render();return this;} + async selectSequence(selector){ + this.pause();const index=resolveNodeIndex(this.session.raw,selector),plan=sequencePlan(this.session.raw,index); + for(const call of sequenceAnimatorCalls(this.session.raw,plan).filter(c=>c.supported))await this.controller(call.rootIndex); + this.session.selectSequence(index);await this.render();return this; + } + async setParameter(name,value){this.session.setParameter(name,value);await this.render();return this;} + async edit(node,componentName,field,value){this.session.edit(node,componentName,field,value);await this.render();return this;} + async applyFixture(fixture,baseURL=this.assetBase){ + for(const patch of fixture.patches||[]){let value=patch.value;if(patch.field==='_previewSource'&&typeof value==='string')value=new URL(value,baseURL).href;this.session.edit(patch.node,patch.component??null,patch.field,value);} + await this.render();return this; + } + async seek(time){this.pause();this.session.seek(time);await this.render();this.emit('timechange',{time:this.time});return this;} + async reset(){this.pause();this.session.reset();await this.render();return this;} + async render(){ + if(this.destroyed||!this.session)return null;const revision=++this._revision,pack=this.session.prepare(),renderer=new Renderer(pack,{...this.options,assetBase:this.assetBase}); + const result=renderer.nodes.length?await renderer.render(this.session.root):await renderer.renderAsset(); + if(revision!==this._revision||this.destroyed)return null;this.canvas.width=result.canvas.width;this.canvas.height=result.canvas.height;this.context.drawImage(result.canvas,0,0);this.lastResult=result; + this.emit('render',{time:this.time,width:result.width,height:result.height,metrics:result.metrics,report:this.report});return result; + } + play(){ + if(!this.session?.motion||!this.paused||this.destroyed)return;this.paused=false;const generation=++this._playRevision;let previous=performance.now();this.emit('play',{time:this.time}); + const frame=async now=>{ + if(this.paused||this.destroyed||generation!==this._playRevision)return;this.session.update(Math.max(0,(now-previous)/1000));previous=now; + try{ + await this.render();if(this.paused||this.destroyed||generation!==this._playRevision)return;const m=this.session.motion; + if(m?.kind==='clip'&&!m.clip.loopTime&&this.time>=m.clip.stopTime||m?.kind==='state'&&m.runtime.exited||m?.kind==='sequence'&&!m.runtime.plan.sequence._isLoop&&this.time>=m.runtime.plan.duration)this.pause(); + if(!this.paused&&!this.destroyed)this._raf=requestAnimationFrame(frame); + }catch(error){if(generation===this._playRevision){this.pause();this.emit('error',{error});}} + }; + this._raf=requestAnimationFrame(frame); + } + pause(){this._playRevision++;if(this._raf!==null){cancelAnimationFrame(this._raf);this._raf=null;}const changed=!this.paused;this.paused=true;if(changed)this.emit('pause',{time:this.time});} + destroy(){if(this.destroyed)return;this.pause();this.destroyed=true;this._revision++;this._loadRevision++;this.session=null;} +} diff --git a/src/ui/renderer.js b/src/ui/renderer.js new file mode 100644 index 0000000..735d93b --- /dev/null +++ b/src/ui/renderer.js @@ -0,0 +1,253 @@ +import {component as comp} from './runtime.js'; +import {loadGameFont,hasGameGlyphs,gameTextWidth,drawGameText} from './game-font.js'; +import {rectBox,aspectBox,imageAspectBox,affine,intersect,layoutProperty,linearGroupInput,linearGroupLayout} from './layout.js'; +import {plainText,visibleLines,wrappingEnabled,textHeight} from './text-layout.js'; +export const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +const number=(v,fallback=0)=>Number.isFinite(v)?v:fallback; +const white={r:1,g:1,b:1,a:1},zeroBorder={x:0,y:0,z:0,w:0}; +export const color=(v=white)=>`rgba(${clamp(number(v.r,1),0,1)*255},${clamp(number(v.g,1),0,1)*255},${clamp(number(v.b,1),0,1)*255},${clamp(number(v.a,1),0,1)})`; +const makeCanvas=(w,h)=>{const c=document.createElement('canvas');c.width=Math.max(1,Math.ceil(w));c.height=Math.max(1,Math.ceil(h));return c;}; +const imageCache=new Map(); +export function loadImage(url){ + if(!imageCache.has(url)){ + if(imageCache.size>96)imageCache.delete(imageCache.keys().next().value); + imageCache.set(url,new Promise((resolve,reject)=>{const im=new Image();im.crossOrigin='anonymous';im.onload=()=>resolve(im);im.onerror=()=>{imageCache.delete(url);reject(Error(`图片加载失败: ${url}`));};im.src=url;})); + } + return imageCache.get(url); +} +const fonts=new Map(); +export async function loadFonts(pack,resolve){ + const gameFonts=new Map();let fontFamily='sans-serif'; + 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); + } + return {gameFonts,fontFamily}; +} +export function buildNodes(pack){ + const stack=[],nodes=(pack.document.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; + }); + for(const n of nodes)if(n.parent>=0)nodes[n.parent].children.push(n.i); + return nodes; +} +function sliced(ctx,im,w,h,b=zeroBorder,ppu=1,fill=true){ + ppu=Math.max(.001,ppu);const left=b.x/ppu,right=b.z/ppu,top=b.w/ppu,bottom=b.y/ppu; + const hs=Math.min(1,w/Math.max(.001,left+right)),vs=Math.min(1,h/Math.max(.001,top+bottom)); + const sx=[0,b.x,im.width-b.z,im.width],sy=[0,b.w,im.height-b.y,im.height]; + const dx=[0,left*hs,w-right*hs,w],dy=[0,top*vs,h-bottom*vs,h]; + for(let y=0;y<3;y++)for(let x=0;x<3;x++){ + if(!fill&&x===1&&y===1)continue; + const dw=dx[x+1]-dx[x],dh=dy[y+1]-dy[y];if(dw<=0||dh<=0)continue; + // Unity permits a zero-width middle UV strip. Sample the boundary texel. + const sw=Math.max(1,sx[x+1]-sx[x]),sh=Math.max(1,sy[y+1]-sy[y]); + ctx.drawImage(im,Math.min(sx[x],im.width-sw),Math.min(sy[y],im.height-sh),sw,sh,dx[x],dy[y],dw,dh); + } +} +function gradientAt(g,t){ + const sample=(prefix,count,getter)=>{ + const keys=Array.from({length:count},(_,i)=>({t:g[prefix+i]/65535,v:getter(g['key'+i])})).sort((a,b)=>a.t-b.t); + if(!keys.length)return null; + const end=keys.findIndex(k=>k.t>=t);if(end<0)return keys.at(-1).v;if(end===0)return keys[0].v; + const a=keys[end-1],b=keys[end];if(g.m_Mode===1)return a.v; + const f=(t-a.t)/(b.t-a.t||1);return a.v.map((v,i)=>v+(b.v[i]-v)*f); + }; + return [...(sample('ctime',g.m_NumColorKeys,k=>[k.r,k.g,k.b])||[1,1,1]),(sample('atime',g.m_NumAlphaKeys,k=>[k.a])||[1])[0]]; +} +function tint(ctx,w,h,tintColor,gradient){ + const c=tintColor||white,g=gradient?._gradient; + if(!g&&c.r===1&&c.g===1&&c.b===1&&c.a===1)return; + const pixels=ctx.getImageData(0,0,w,h),rad=(gradient?._angleDeg||0)*Math.PI/180,vx=Math.cos(rad),vy=-Math.sin(rad); + const length=Math.abs(vx)*w+Math.abs(vy)*h; + const lut=g?Array.from({length:257},(_,i)=>gradientAt(g,i/256)):null; + for(let y=0;y=1){ctx.rect(0,0,w,h);return;} + if(a<=0){ctx.rect(0,0,0,0);return;} + if(method===0){ctx.rect(origin?w*(1-a):0,0,w*a,h);return;} + if(method===1){ctx.rect(0,origin?0:h*(1-a),w,h*a);return;} + const clockwise=!!im.m_FillClockwise; + let center,start,sweep; + if(method===4){center=[w/2,h/2];start=[Math.PI/2,Math.PI,Math.PI*1.5,0][origin];sweep=Math.PI*2;} + else if(method===3){center=[[w/2,h],[0,h/2],[w/2,0],[w,h/2]][origin];start=[Math.PI,Math.PI*1.5,0,Math.PI/2][origin];sweep=Math.PI;} + else {center=[[0,h],[0,0],[w,0],[w,h]][origin];start=[Math.PI*1.5,0,Math.PI/2,Math.PI][origin];sweep=Math.PI/2;} + if(!clockwise)start+=sweep; + ctx.moveTo(...center);ctx.arc(...center,Math.hypot(w,h)*2,start,start+(clockwise?1:-1)*sweep*a,!clockwise);ctx.closePath(); +} + +export class Renderer { + constructor(pack,options={}){ + this.pack=pack;this.options=options;this.nodes=buildNodes(pack);this.sprites=new Map();this.preferredCache=new Map();this.images=new Map();this.fontFamily='sans-serif';this.gameFonts=new Map(); + this.metrics={drawn:0,hidden:0,transparent:0,unboundImages:0,particles:0}; + this.measure=makeCanvas(1,1).getContext('2d'); + } + url(file){if(!file)throw Error('UI resource path missing');return new URL(file,this.options.assetBase||document.baseURI).href;} + 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)ctx.drawImage(im,r.x,im.height-r.y-r.height,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; + } + children(n){return n.children.map(i=>this.nodes[i]).filter(c=>c.active&&!comp(c,'LayoutElement')?.m_IgnoreLayout);} + layoutInput(n){ + if(this.preferredCache.has(n.i))return this.preferredCache.get(n.i); + 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]); + } + if(image&&image.m_Enabled!==0){ + const sprite=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); + 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(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; + } + preferred(n){return this.layoutInput(n).preferred;} + layoutChildren(n){return this.children(n).map(c=>({...this.layoutInput(c),i:c.i,size:[c.rect?.m_SizeDelta?.x||0,c.rect?.m_SizeDelta?.y||0],scale:[c.localScale?.x??1,c.localScale?.y??1],pivot:c.rect?.m_Pivot}));} + layout(n,pw,ph,forced){ + if(forced)return forced; + const nativeCanvas=comp(n,'Canvas'); + if(nativeCanvas&&nativeCanvas.m_RenderMode!==2)return {x:0,y:0,w:pw,h:ph,pivot:{x:0,y:1}}; + const r=n.rect||{},min=r.m_AnchorMin||{x:.5,y:.5},max=r.m_AnchorMax||min,pos=r.m_AnchoredPosition||{x:0,y:0},sz=r.m_SizeDelta||{x:0,y:0},pivot=r.m_Pivot||{x:.5,y:.5}; + let w=pw*(max.x-min.x)+sz.x,h=ph*(max.y-min.y)+sz.y; + const fit=comp(n,'ContentSizeFitter');if(fit&&fit.m_Enabled!==0){const input=this.layoutInput(n);if(fit.m_HorizontalFit)w=(fit.m_HorizontalFit===1?input.min:input.preferred)[0];if(fit.m_VerticalFit)h=(fit.m_VerticalFit===1?input.min:input.preferred)[1];} + const ar=comp(n,'AspectRatioFitter');let aspect=ar?.m_AspectRatio||1; + const bound=n.children.map(i=>comp(this.nodes[i],'Image')).find(im=>im?._previewSource); + if(bound&&this.images.has(bound._previewSource)){const im=this.images.get(bound._previewSource);aspect=im.width/im.height;} + if(ar?.m_Enabled!==0){if(ar?.m_AspectMode===1)h=w/aspect;if(ar?.m_AspectMode===2)w=h*aspect;} + if(ar&&ar.m_Enabled!==0&&[3,4].includes(ar.m_AspectMode))return aspectBox(pw,ph,aspect,ar.m_AspectMode,pivot); + return rectBox(r,pw,ph,number(w),number(h)); + } + childLayout(n,w,h){ + const result=new Map(),row=comp(n,'HorizontalLayoutGroup'),col=comp(n,'VerticalLayoutGroup')||comp(n,'RectFitVerticalLayoutGroup'),grid=comp(n,'GridLayoutGroup'),g=row||col||grid; + if(!g||g.m_Enabled===0)return result; + const p=g.m_Padding||{},left=p.m_Left||0,top=p.m_Top||0,aw=Math.max(0,w-left-(p.m_Right||0)),ah=Math.max(0,h-top-(p.m_Bottom||0)); + const children=this.children(n),align=g.m_ChildAlignment||0,ax=(align%3)/2,ay=Math.floor(align/3)/2; + if(grid){ + const cell=grid.m_CellSize,space=grid.m_Spacing,vertical=grid.m_StartAxis===1; + let cols=grid.m_Constraint===1?Math.max(1,grid.m_ConstraintCount):Math.max(1,Math.floor((aw+space.x)/(cell.x+space.x))); + let rows=grid.m_Constraint===2?Math.max(1,grid.m_ConstraintCount):Math.ceil(children.length/cols); + if(grid.m_Constraint===2)cols=Math.ceil(children.length/rows); + if(grid.m_Constraint===0&&vertical){rows=Math.max(1,Math.floor((ah+space.y)/(cell.y+space.y)));cols=Math.ceil(children.length/rows);} + 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); + } + async imageLayer(n,im,w,h){ + if(w<=0||h<=0)return null; + let sp; + if(im._previewSource)sp={canvas:this.images.get(im._previewSource)||await loadImage(im._previewSource),meta:{border:zeroBorder,pixelsPerUnit:100}}; + else if(im.m_Sprite?.spriteRef)sp=await this.sprite(im.m_Sprite.spriteRef); + 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 { + 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);} + 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; + } + drawText(n,ctx,w,h){ + 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 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; + const tx=align===2?margin.x+available/2:align===4?w-margin.z:margin.x; + let ty=vertical===256?margin.y+lineHeight/2:vertical===1024?h-margin.w-th+lineHeight/2:margin.y+(h-margin.y-margin.w-th)/2+lineHeight/2; + ctx.save();if([1,2,3].includes(t.m_overflowMode)){ctx.beginPath();ctx.rect(0,0,w,h);ctx.clip();} + ctx.font=font();ctx.textAlign=align===2?'center':align===4?'right':'left';ctx.textBaseline='middle';ctx.fillStyle=color(t.m_fontColor||t.m_Color); + const shadow=comp(n,'Shadow'),outline=comp(n,'Outline');if(shadow){ctx.shadowColor=color(shadow.m_EffectColor);ctx.shadowOffsetX=shadow.m_EffectDistance?.x||0;ctx.shadowOffsetY=-(shadow.m_EffectDistance?.y||0);} + lines.forEach((line,i)=>{if(native){drawGameText(ctx,line,tx,ty+i*lineHeight,fs,t.m_fontColor||t.m_Color||white,gameFont);return;}if(outline){ctx.strokeStyle=color(outline.m_EffectColor);ctx.lineWidth=Math.max(Math.abs(outline.m_EffectDistance?.x||1),Math.abs(outline.m_EffectDistance?.y||1))*2;ctx.strokeText(line,tx,ty+i*lineHeight);}ctx.fillText(line,tx,ty+i*lineHeight);});ctx.restore();if(plain)this.metrics.drawn++; + } + async drawNode(n,ctx,pw,ph,forced,isRoot=false){ + if(this.options.hidden?.has(n.i))return; + if(!isRoot&&!n.active&&!this.options.showHidden){this.metrics.hidden++;return;} + const b=isRoot?{x:0,y:0,w:pw,h:ph,pivot:n.rect?.m_Pivot||{x:.5,y:.5}}:this.layout(n,pw,ph,forced),{w,h}=b,pivot=b.pivot||{x:.5,y:.5}; + ctx.save();try{ + ctx.translate(b.x+w*pivot.x,b.y+h*(1-pivot.y));const q=n.localRotation||{z:0,w:1};ctx.rotate(n.euler?-n.euler.z*Math.PI/180:-2*Math.atan2(q.z,q.w)); + const nativeCanvas=comp(n,'Canvas'); + const s=isRoot||(nativeCanvas&&nativeCanvas.m_RenderMode!==2)?{x:1,y:1}:(n.localScale||{x:1,y:1});ctx.scale(number(s.x,1),number(s.y,1));ctx.translate(-w*pivot.x,-h*(1-pivot.y)); + const cg=comp(n,'CanvasGroup');if(cg&&!this.options.showHidden)ctx.globalAlpha*=clamp(number(cg.m_Alpha,1),0,1); + if(ctx.globalAlpha<.001){this.metrics.transparent++;return;} + const im=comp(n,'Image'),mask=comp(n,'Mask'),hasMask=mask&&mask.m_Enabled!==0; + 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++; + } + 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);} + const layouts=this.childLayout(n,w,h); + if(hasMask&&layer&&w>0&&h>0){ + // Sprite-alpha masks need an isolated subtree, including when its graphic is hidden. + const maskScale=Math.min(1,4096/w,4096/h),sub=makeCanvas(w*maskScale,h*maskScale),sx=sub.getContext('2d');sx.scale(maskScale,maskScale); + for(const i of n.children)await this.drawNode(this.nodes[i],sx,w,h,layouts.get(i)); + sx.globalCompositeOperation='destination-in';sx.drawImage(layer,0,0,w,h);ctx.drawImage(sub,0,0,w,h); + }else{ + if(hasMask||comp(n,'RectMask2D')||comp(n,'SoftMask')){ctx.beginPath();ctx.rect(0,0,w,h);ctx.clip();} + for(const i of n.children)await this.drawNode(this.nodes[i],ctx,w,h,layouts.get(i)); + } + }catch(error){if(!error.nodePath)error.nodePath=n.path;throw error;}finally{ctx.restore();} + } + visibleBounds(root,w,h){ + let bounds={minX:0,minY:0,maxX:w,maxY:h}; + const visit=(n,pw,ph,matrix,forced,clip,alpha,isRoot=false)=>{ + if(this.options.hidden?.has(n.i)||!isRoot&&!n.active&&!this.options.showHidden)return; + const box=isRoot?{x:0,y:0,w:pw,h:ph,pivot:n.rect?.m_Pivot||{x:.5,y:.5}}:this.layout(n,pw,ph,forced),canvas=comp(n,'Canvas'),scale=isRoot||canvas&&canvas.m_RenderMode!==2?{x:1,y:1}:n.localScale||{x:1,y:1},q=n.localRotation||{z:0,w:1}; + const local=affine.box(box,n.euler?-n.euler.z*Math.PI/180:-2*Math.atan2(q.z,q.w),scale),m=affine.mul(matrix,local),group=comp(n,'CanvasGroup'); + if(group&&!this.options.showHidden)alpha*=clamp(number(group.m_Alpha,1),0,1);if(alpha<.001)return; + 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 world=affine.bounds(m,box.w,box.h); + if(graphic&&box.w>0&&box.h>0){const extent=intersect(world,clip);if(extent)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)};} + 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); + }; + visit(root,w,h,[1,0,0,1,0,0],null,null,1,true); + return comp(root,'Canvas')?{minX:0,minY:0,maxX:w,maxY:h}:bounds; + } + async render(rootIndex=0){ + 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)))))); + const root=this.nodes[rootIndex];if(!root)throw Error('预制体没有根节点'); + const vp=this.options.viewport||[1920,1080],natural=this.layout(root,...vp),w=natural.w>0?natural.w:vp[0],h=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); + await this.drawNode(root,ctx,w,h,null,true);return {canvas,width:w,height:h,bounds,metrics:this.metrics}; + } + 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 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);} + return {canvas,sprites:sprites.length}; + } +} diff --git a/src/ui/runtime.js b/src/ui/runtime.js new file mode 100644 index 0000000..36cae4f --- /dev/null +++ b/src/ui/runtime.js @@ -0,0 +1,184 @@ +// 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]));} +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);}); + 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 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&&jn.name===selector?[i]:[]); + if(matches.length===1)return matches[0];if(matches.length>1)throw Error('Ambiguous UI node: '+selector); + } + throw Error('UI node not found: '+selector); +} +export function catalogs(pack){ + const result=new Map(); + function walk(v){ + if(Array.isArray(v))v.forEach(walk); + else if(v&&typeof v==='object'){ + if(v.asset&&Object.keys(v).length>2)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){ + if(c.m_Enabled===0)continue; + const ref=c._spriteCatalog||c._gradientCatalog; + const cat=ref&&(assets.get(`${ref.asset}:${ref.name}`)||ref); + const sprites=c._spriteMap||cat?._spritesByKey; + if(sprites&&c._key!==undefined){ + const chosen=sprites._list.find(x=>x.Key===c._key)?.Value||c._defaultSprite; + const im=component(target(c._image,n),'Image');if(im)im.m_Sprite=chosen||null; + const countText=component(target(c._countText),'TextMeshProUGUI');if(countText)countText.m_text=String(c._key); + } + if(cat?._gradientsByKey&&c._key!==undefined){ + 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'){ + 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; + const fill=target(c.m_FillRect),handle=target(c.m_HandleRect),im=component(fill,'Image'); + if(im?.m_Type===3)im.m_FillAmount=t; + 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'){ + 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){ + 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'){ + 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')); + for(const item of states){ + if(item.class!=='ButtonActiveState')continue;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]; + } + } + } + } +} + +// AnimationClip path hashes use Unity's CRC32 over UTF-8 relative transform paths. +const crcTable=Array.from({length:256},(_,i)=>{for(let b=0;b<8;b++)i=i&1?0xedb88320^(i>>>1):i>>>1;return i>>>0;}); +export function pathCRC(s){let crc=0xffffffff;for(const b of new TextEncoder().encode(s))crc=crcTable[(crc^b)&255]^(crc>>>8);return (crc^0xffffffff)>>>0;} +export function sampleClip(clip,time){ + const streamed=clip.streamed||{},dense=clip.dense||{},values=[]; + const keys=Array.from({length:streamed.curveCount||0},()=>null); + for(const [t,rows] of streamed.frames||[]){if(t>time)break;for(const row of rows)if(row[0]time)break;for(const row of rows){const i=row[0]-start;if(i>=0&&itypeof 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 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; + for(let i=rootIndex;i{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 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; + 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); + let ok=false; + if(b.pptr){ + const index=Math.round(v[0]),mapping=clip.pptrCurveMapping||[]; + if(index>=mapping.length){fail(b,'object_reference_not_found',{index});continue;} + 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(vector){ + const field={m_LocalPosition:'localPosition',m_LocalScale:'localScale',m_LocalRotation:'localRotation',localEulerAnglesRaw:'euler'}[b.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.')){ + 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'){ + 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);} + 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}); + } + 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]; + 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 new file mode 100644 index 0000000..2b586d4 --- /dev/null +++ b/src/ui/session.js @@ -0,0 +1,49 @@ +import {component,applyBindings,applyClip,mergeResources,resolveNodeIndex} from './runtime.js'; +import {AnimatorRuntime} from './animator.js'; +import {SequenceRuntime} from './tween.js'; + +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; + } + 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 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];} + 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); + 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); + 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;} + setParameter(name,value){if(this.motion?.kind!=='state')throw Error('Select a UI Animator state first');this.motion.runtime.setParameter(name,value);return this;} + seek(time){if(!Number.isFinite(time)||time<0)throw Error('UI time must be a nonnegative finite number');this.time=time;if(this.motion?.kind==='state')this.motion.runtime.seek(time);return this;} + 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);} + 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 this.report={applied:0,numericApplied:0,objectApplied:0,missing:[],diagnostics:[]}; + return pack; + } +} diff --git a/src/ui/text-layout.js b/src/ui/text-layout.js new file mode 100644 index 0000000..8bb0622 --- /dev/null +++ b/src/ui/text-layout.js @@ -0,0 +1,15 @@ +// ENGINE: TMP's vertical anchor and preferred height use the visible text +// descender. A trailing line feed does not add a visible line (see the player +// TMP implementation and its CalculatePreferredValues tests). +export function plainText(text){ + return String(text??'').replace(/\r\n?/g,'\n').replace(//gi,'\n').replace(/<[^>]*>/g,''); +} +export function visibleLines(lines){ + let count=lines.length; + while(count&&!lines[count-1].trim())count--; + return lines.slice(0,count); +} +export function wrappingEnabled(text){ + return text.m_TextWrappingMode!==undefined?[1,2].includes(text.m_TextWrappingMode):text.m_enableWordWrapping!==0; +} +export function textHeight(lines,lineHeight){return visibleLines(lines).length*lineHeight;} diff --git a/src/ui/tween.js b/src/ui/tween.js new file mode 100644 index 0000000..33c7796 --- /dev/null +++ b/src/ui/tween.js @@ -0,0 +1,144 @@ +import {component,mergeResources,referenceNode,resolveNodeIndex,nodeReferences} from './runtime.js'; +import {AnimatorRuntime} from './animator.js'; + +const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)); +const mod=(v,n)=>((v%n)+n)%n; +const refPath=ref=>ref?.gameObject||ref?.transform; +const calls=event=>(event?.m_PersistentCalls?.m_Calls||[]).filter(c=>c.m_CallState!==0&&c.m_Target&&c.m_MethodName); + +export function tweenEase(type,t){ + t=clamp(t,0,1); + if(type===0||type===1)return t; + if(type===2)return 1-Math.cos(t*Math.PI/2); + if(type===3)return Math.sin(t*Math.PI/2); + if(type===4)return (1-Math.cos(t*Math.PI))/2; + if(type>=5&&type<=16){const power=Math.floor((type-5)/3)+2,mode=(type-5)%3;return mode===0?t**power:mode===1?1-(1-t)**power:t<.5?(2*t)**power/2:1-(2*(1-t))**power/2;} + if(type===17)return t===0?0:2**(10*t-10); + if(type===18)return t===1?1:1-2**(-10*t); + if(type===19)return t===0||t===1?t:t<.5?2**(20*t-10)/2:(2-2**(-20*t+10))/2; + if(type===20)return 1-Math.sqrt(1-t*t); + if(type===21)return Math.sqrt(1-(t-1)**2); + if(type===22)return t<.5?(1-Math.sqrt(1-(2*t)**2))/2:(Math.sqrt(1-(-2*t+2)**2)+1)/2; + throw Error('Unsupported DOTween ease: '+type); +} + +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. +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; + 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; + if(!selected)diagnostics.push({reason:'tween_not_found',index,node:refPath(command._tweenAnimation)}); + else if(selected.class==='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 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); + } + return {rootPath,rootIndex,sequence,duration,items,diagnostics}; +} + +export function sequenceAnimatorCalls(pack,plan){ + const result=[]; + for(const item of plan.items.filter(item=>item.kind==='callback')){ + 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')}); + } + return result; +} + +function progress(animation,time){ + const elapsed=time-(animation.delay||0),duration=Math.max(.000001,animation.duration||0),loops=animation.loops<0?Infinity:Math.max(1,animation.loops||1); + if(elapsed<0)return null; + const complete=elapsed>=duration*loops,cycle=complete?loops-1:Math.floor(elapsed/duration); + let t=complete?1:mod(elapsed,duration)/duration;if(animation.loopType===1&&cycle%2)t=1-t; + return {value:tweenEase(animation.easeType,t),cycle:animation.loopType===2?cycle:0}; +} + +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); + return target||{}; +} + +// Existing UI data uses LocalMove, world Z rotation, CanvasGroup Fade, and +// UIWidthHeight. Additional basic transform/graphic cases share these rules. +export function applyTween(pack,baseline,owner,a,time){ + const diagnostics=[],result={applied:0,diagnostics}; + if(!a.isActive||!a.isValid)return result; + if(a.isSpeedBased){diagnostics.push({reason:'speed_based_tween_not_supported',node:owner});return result;} + let p;try{p=progress(a,time);}catch(error){diagnostics.push({reason:error.message,node:owner});return result;}if(!p)return result; + const {node,index}=tweenTarget(pack,owner,a),original=tweenTarget(baseline,owner,a).node; + if(!node||!original){diagnostics.push({reason:'tween_target_not_found',node:owner});return result;} + const interpolate=(start,end,relative=a.isRelative)=>{ + let from=a.isFrom?(relative?start+end:end):start,to=a.isFrom?start:(relative?start+end:end); + const difference=to-from;return from+difference*(p.value+p.cycle); + }; + const axes=['x','y','z']; + switch(a.animationType){ + case 2: + for(const axis of axes){const start=original.localPosition?.[axis]||0;let value=interpolate(start,a.endValueV3[axis]);if(a.optionalBool0)value=Math.round(value);node.localPosition[axis]=value;if(node.rect&&axis!=='z')node.rect.m_AnchoredPosition[axis]=original.rect.m_AnchoredPosition[axis]+value-start;}break; + case 3:case 4:{ + if(a.optionalRotationMode>1){diagnostics.push({reason:'rotation_mode_not_supported',node:owner});return result;} + const q=original.localRotation||{z:0,w:1},initial=original.euler||{x:0,y:0,z:2*Math.atan2(q.z,q.w)*180/Math.PI}; + let parentZ=0; + if(a.animationType===3){const refs=nodeReferences(pack);for(let i=refs.parents[index];i>=0;i=refs.parents[i]){const parent=refs.nodes[i],r=parent.localRotation;parentZ+=parent.euler?.z??(r?2*Math.atan2(r.z,r.w)*180/Math.PI:0);}} + node.euler={}; + for(const axis of axes){const start=initial[axis]||0;let end=a.endValueV3[axis]-(axis==='z'&&!a.isRelative?parentZ:0);if(a.optionalRotationMode===0&&!a.isRelative)end=start+mod(end-start+180,360)-180;node.euler[axis]=interpolate(start,end);}break; + } + case 5: + for(const axis of axes)node.localScale[axis]=interpolate(original.localScale[axis],a.optionalBool0?a.endValueFloat:a.endValueV3[axis]);break; + case 6:case 7:{ + const cls={2:'CanvasGroup',3:'Image',7:'SpriteRenderer',10:'Text',14:'TextMeshPro',15:'TextMeshProUGUI'}[a.targetType],target=component(node,cls),base=component(original,cls); + if(!target||!base){diagnostics.push({reason:'tween_component_not_found',node:node.path,class:cls});return result;} + if(a.animationType===7){if(cls==='CanvasGroup')target.m_Alpha=interpolate(base.m_Alpha,a.endValueFloat,false);else{const field=cls.startsWith('TextMeshPro')?'m_fontColor':'m_Color';target[field].a=interpolate(base[field].a,a.endValueFloat,false);}} + else{const field=cls.startsWith('TextMeshPro')?'m_fontColor':'m_Color';if(!target[field]){diagnostics.push({reason:'tween_color_not_found',node:node.path});return result;}for(const axis of ['r','g','b','a'])target[field][axis]=interpolate(base[field][axis],a.endValueColor[axis],false);}break; + } + case 21: + if(!node.rect){diagnostics.push({reason:'rect_transform_not_found',node:node.path});return result;} + for(const axis of ['x','y'])node.rect.m_SizeDelta[axis]=interpolate(original.rect.m_SizeDelta[axis],a.optionalBool0?a.endValueFloat:a.endValueV2[axis]);break; + default:diagnostics.push({reason:'tween_animation_not_supported',node:owner,type:a.animationType});return result; + } + result.applied=1;return result; +} + +export class SequenceRuntime { + constructor(pack,rootPath,controllers=new Map()){ + this.plan=sequencePlan(pack,rootPath);this.baseline=pack;this.controllers=controllers;this.calls=sequenceAnimatorCalls(pack,this.plan); + } + apply(pack,time){ + const {plan,baseline}=this,diagnostics=[...plan.diagnostics],events=[],animators=new Map();let tweens=0; + const local=plan.sequence?._isLoop&&plan.duration>0?mod(time,plan.duration):time; + for(const item of plan.items.filter(item=>item.kind==='tween')){ + const result=applyTween(pack,baseline,item.ownerIndex,item.animation,local-item.start);tweens+=result.applied;diagnostics.push(...result.diagnostics); + } + for(const item of this.calls){ + if(item.start>local)continue; + if(!item.supported){diagnostics.push({reason:'sequence_callback_not_supported',node:item.node,method:item.call.m_MethodName});continue;} + // SimpleAnimationTrigger checks activeInHierarchy before CrossFade. + 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);} + 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; + events.push({node:item.node,state:item.state,at:item.start}); + } + let numeric=0,objects=0; + for(const entry of animators.values()){entry.runtime.advance(Math.max(0,local-entry.time));const result=entry.runtime.apply(pack);numeric+=result.numericApplied;objects+=result.objectApplied;diagnostics.push(...result.diagnostics);} + return {applied:tweens+numeric+objects,tweens,numericApplied:numeric,objectApplied:objects,missing:diagnostics,diagnostics,events,duration:plan.duration}; + } +} diff --git a/tests/types/ui.ts b/tests/types/ui.ts new file mode 100644 index 0000000..14dfedd --- /dev/null +++ b/tests/types/ui.ts @@ -0,0 +1,16 @@ +import {UILibrary,UISession,UIPlayer,OurnotesUIElement,defineOurnotesUI,type UIPack,type UIReport} from 'ournotes-player/ui'; +import 'ournotes-player/ui/element'; + +const source: UIPack={document:{nodes:[]},resources:{}}; +const session=new UISession(source); +session.edit(0,null,'active',false).seek(.2).reset(); +const library=new UILibrary({assets:[]},'https://example.invalid/'); +async function load(host: HTMLElement){ + const player=await UIPlayer.create(host,{library,entry:'example'}); + const report: UIReport|null=player.report; + await player.edit('root','CanvasGroup','m_Alpha',.5); + await player.playState('Open',{animator:0});await player.selectSequence(1);await player.seek(.1); + player.play();player.pause();player.destroy();return report; +} +const element: OurnotesUIElement=document.createElement('ournotes-ui'); +element.src='pack.json';element.entry='root';void element.ready;defineOurnotesUI();void source;void load; diff --git a/tests/unit/fidelity.test.mjs b/tests/unit/fidelity.test.mjs index 8e7ad08..2633221 100644 --- a/tests/unit/fidelity.test.mjs +++ b/tests/unit/fidelity.test.mjs @@ -4,10 +4,16 @@ import fs from "node:fs"; import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { collect, current, render } from "../../scripts/engine-notes.mjs"; +import { collect, current, render, notesFromSource } from "../../scripts/engine-notes.mjs"; const doc = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "docs", "fidelity.md"); +test("engine-note collection is the same for LF and CRLF source files", () => { + const lines = ["// ordinary comment", "// ENGINE: keep the signed size.", "// ENGINE: `literal example`"]; + assert.deepEqual(notesFromSource(lines.join("\n")), ["keep the signed size."]); + assert.deepEqual(notesFromSource(lines.join("\r\n")), ["keep the signed size."]); +}); + test("the ENGINE: list in docs/fidelity.md matches the sources", () => { const groups = collect(); assert.ok(groups.length > 0); diff --git a/tests/unit/ui-element.test.mjs b/tests/unit/ui-element.test.mjs new file mode 100644 index 0000000..6dbce62 --- /dev/null +++ b/tests/unit/ui-element.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +class Element extends EventTarget { + constructor(){super();this.attributes=new Map();this.isConnected=false;} + attachShadow(){return this.shadowRoot={append(){}};} + setAttribute(name,value){const old=this.getAttribute(name);this.attributes.set(name,String(value));if(this.constructor.observedAttributes?.includes(name))this.attributeChangedCallback(name,old,String(value));} + getAttribute(name){return this.attributes.get(name)??null;} + hasAttribute(name){return this.attributes.has(name);} + removeAttribute(name){const old=this.getAttribute(name);this.attributes.delete(name);this.attributeChangedCallback(name,old,null);} +} +const canvas=()=>({tagName:'CANVAS',style:{},width:1,height:1,getContext(){return {globalAlpha:1,save(){},restore(){},scale(){},translate(){},rotate(){},drawImage(){}};}}); +globalThis.HTMLElement=Element; +globalThis.document={baseURI:'https://example.invalid/',createElement:name=>name==='canvas'?canvas():{style:{}}}; +globalThis.requestAnimationFrame=()=>0;globalThis.cancelAnimationFrame=()=>{}; +const {OurnotesUIElement}=await import('../../src/ui/element.js'); +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:{}}); + +test('ready captured before connecting settles on the first load',async t=>{ + const result=fakePlayer();t.mock.method(UIPlayer,'create',async()=>result); + const element=new OurnotesUIElement(),ready=element.ready;element.src='pack.json';element.isConnected=true;element.connectedCallback(); + assert.equal(await ready,result); +}); + +test('disconnecting an in-flight element rejects the old ready and reconnecting starts a fresh load',async t=>{ + const old=deferred(),fresh=fakePlayer();let loads=0;t.mock.method(UIPlayer,'create',()=>++loads===1?old.promise:Promise.resolve(fresh)); + const element=new OurnotesUIElement();element.src='pack.json';element.isConnected=true;element.connectedCallback();const ready=element.ready; + element.isConnected=false;element.disconnectedCallback();await tick();await assert.rejects(ready,{name:'AbortError'}); + element.isConnected=true;element.connectedCallback();assert.equal(await element.ready,fresh);assert.equal(loads,2); + const stale=fakePlayer();old.resolve(stale);await tick();assert.equal(stale.destroyed,true);assert.equal(element.player,fresh); +}); + +test('a superseded source cannot replace the new player or leave its ready pending',async t=>{ + const old=deferred(),fresh=fakePlayer();let loads=0;t.mock.method(UIPlayer,'create',()=>++loads===1?old.promise:Promise.resolve(fresh)); + const element=new OurnotesUIElement();element.isConnected=true;element.src='old.json';const ready=element.ready;element.src='new.json'; + await assert.rejects(ready,{name:'AbortError'});assert.equal(await element.ready,fresh); + const stale=fakePlayer();old.resolve(stale);await tick();assert.equal(stale.destroyed,true); +}); + +test('UIPlayer loading only emits ready for the newest document',async()=>{ + const player=new UIPlayer(canvas()),old=deferred();let renders=0,ready=0;player.render=()=>++renders===1?old.promise:Promise.resolve(null);player.addEventListener('ready',()=>ready++); + const a=player.load(pack());await player.load(pack());old.resolve(null);await a;assert.equal(ready,1);player.destroy(); +}); + +test('a late controller cannot be attached to a replacement session',async()=>{ + const old=deferred(),player=new UIPlayer(canvas(),{library:{loadController:()=>old.promise}});await player.load(pack()); + const pending=player.controller(0);await player.load(pack());old.resolve({document:{}}); + await assert.rejects(pending,{name:'AbortError'});assert.equal(player.session.controllers.size,0);player.destroy(); +}); + +test('an old awaited playback frame cannot schedule another loop after pause and restart',async t=>{ + const queued=new Map();let serial=0;t.mock.method(globalThis,'requestAnimationFrame',fn=>{queued.set(++serial,fn);return serial;});t.mock.method(globalThis,'cancelAnimationFrame',id=>queued.delete(id)); + const player=new UIPlayer(canvas()),gate=deferred();player.session={time:0,motion:{kind:'clip',clip:{loopTime:true}},update(){}};player.render=()=>gate.promise; + player.play();const first=queued.get(1);queued.delete(1);const pending=first(performance.now()+50);player.pause();player.play();gate.resolve(null);await pending; + assert.equal(queued.size,1);assert.ok(queued.has(2));player.destroy();assert.equal(queued.size,0); +}); diff --git a/tests/unit/ui.test.mjs b/tests/unit/ui.test.mjs new file mode 100644 index 0000000..ff827d3 --- /dev/null +++ b/tests/unit/ui.test.mjs @@ -0,0 +1,127 @@ +// Synthetic serialized records only; no game fixtures or artwork. +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 {sequencePlan,SequenceRuntime} 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'; + +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('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 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); +}); + +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('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); + 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); + 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); +}); + +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); +}); + +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(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'}]}; + 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); +}); + +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); + 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'); +}); + +test('serialized animation field names cannot write prototype or inherited fields',()=>{ + const source=pack(),original=Object.prototype.toString; + const data={constant:[0],bindings:[{path:'',class:'CanvasGroup',attribute:'__proto__.toString'}]}; + assert.equal(applyClip(source,data,0,0).applied,0);assert.equal(Object.prototype.toString,original); + assert.equal(assignField({},'constructor.prototype.toString',0),false);assert.equal(assignField({},'toString',0),false); +}); + +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:{}}; + 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(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); +}); + +test('signed rects, flexible fixed-size cells, layout priority and trailing line feeds preserve geometry',()=>{ + const stretch=x=>({m_AnchorMin:{x:0,y:0},m_AnchorMax:{x:1,y:1},m_SizeDelta:{x,y:0}}); + 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); + 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'); +}); diff --git a/tests/unit/uitext.test.mjs b/tests/unit/uitext.test.mjs index 4685ee7..df7385f 100644 --- a/tests/unit/uitext.test.mjs +++ b/tests/unit/uitext.test.mjs @@ -262,6 +262,15 @@ test("layout is deterministic", () => { assert.deepEqual(JSON.stringify(a.meshes()), JSON.stringify(b.meshes())); }); +test("middle alignment ignores trailing line feeds in the visible text bounds", () => { + const over = { m_VerticalAlignment: TMP_V.Middle }, rect = { x: 0, y: -30, w: 150, h: 30 }; + const single = make("Lv.", over, rect), trailing = make("Lv.\n", over, rect); + assert.deepEqual(trailing.chars[0].quad, single.chars[0].quad); + close(trailing.anchor.y, single.anchor.y); + const internal = make("Lv.\n\n50", over, rect); + assert.notEqual(internal.anchor.y, single.anchor.y, "internal blank lines still separate visible text"); +}); + // a sprite asset without face info (point size 0): 32 x 32 glyphs, bearing (0, 28.8), advance 32, on a 512 x 512 sheet; // U+1F600 twice in the table (the first counts), U+1F602 without its glyph (left out), U+1F603 without texels const EMOJI = "\u{1F600}"; diff --git a/types/ui.d.ts b/types/ui.d.ts new file mode 100644 index 0000000..3d581f4 --- /dev/null +++ b/types/ui.d.ts @@ -0,0 +1,46 @@ +/** 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; [key: string]: any } } +export interface UIController { document: Record; resources?: UIPack['resources'] } +export interface UIReport { applied: number; numericApplied: number; objectApplied: number; missing: unknown[]; diagnostics: unknown[]; state?: string; events?: unknown[]; [key: string]: unknown } +export interface UIOptions { src?: string; entry?: UIEntry | string; library?: UILibrary; assetBase?: string; viewport?: [number, number]; showHidden?: boolean; bounds?: boolean; bindings?: boolean } +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); + 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; +} +export class UISession { + constructor(pack: UIPack, options?: { bindings?: boolean }); + readonly raw: UIPack; readonly nodes: Array>; readonly animators: Array<{index: number; path: string; name: string}>; readonly sequences: Array<{index: number; path: string; name: string}>; + time: number; root: number; bindings: boolean; readonly report: UIReport | null; + edit(node: UINodeSelector, component: string | null, field: string, value: unknown): this; + setController(node: UINodeSelector, controller: UIController): this; + playState(state: string | number): this; selectClip(clip: string | number): this; selectSequence(node: UINodeSelector): this; + setParameter(name: string, value: number | boolean): this; seek(time: number): this; update(delta: number): this; reset(): this; prepare(): UIPack; +} +export class UIPlayer extends EventTarget { + static create(host: HTMLElement, options: UIOptions): Promise; + constructor(host: HTMLElement, options?: UIOptions); + readonly canvas: HTMLCanvasElement; readonly nodes: UISession['nodes']; readonly animators: UISession['animators']; readonly sequences: UISession['sequences']; + readonly time: number; readonly duration: number; readonly report: UIReport | null; readonly paused: boolean; readonly destroyed: boolean; + readonly session: UISession | null; options: Omit; + load(source: string | UIEntry | UIPack): Promise; + controller(node?: UINodeSelector): Promise; + playState(state: string | number, options?: { animator?: UINodeSelector }): Promise; + selectClip(clip: string | number, options?: { animator?: UINodeSelector }): Promise; + selectSequence(node: UINodeSelector): Promise; + setParameter(name: string, value: number | boolean): Promise; edit(node: UINodeSelector, component: string | null, field: string, value: unknown): Promise; + applyFixture(fixture: { patches: Array<{node: UINodeSelector; component?: string | null; field: string; value: unknown}> }, baseURL?: string): Promise; + seek(time: number): Promise; reset(): Promise; render(): Promise; play(): void; pause(): void; destroy(): void; +} +export class OurnotesUIElement extends HTMLElement { + src: string; entry: string; readonly ready: Promise; readonly player: UIPlayer | null; readonly time: number; + playState(state: string | number, options?: { animator?: UINodeSelector }): Promise; selectClip(clip: string | number, options?: { animator?: UINodeSelector }): Promise; + selectSequence(node: UINodeSelector): Promise; seek(time: number): Promise; play(): void; pause(): void; +} +export function defineOurnotesUI(name?: string): typeof OurnotesUIElement; +declare global { interface HTMLElementTagNameMap { 'ournotes-ui': OurnotesUIElement } }