diff --git a/README.md b/README.md index ad33f901..6cab42b8 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ - 特勤经验表 [Widget:SOExpCalc/dev](https://prts.wiki/w/Widget:SOExpCalc/dev) - AudioPlayerV2 [Widget:AudioPlayerV2/dev](https://prts.wiki/w/Widget:AudioPlayerV2/dev) - 道具一览 [Widget:ItemList/dev](https://prts.wiki/w/Widget:ItemList/dev) +- 剧情播放器 [Widget:StoryPlayer/dev](https://prts.wiki/w/Widget:StoryPlayer/dev) ## 贡献代码 diff --git a/eslint.config.js b/eslint.config.js index 8928c1e0..5078c577 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -119,7 +119,7 @@ const javascript = [ "no-useless-backreference": "error", "no-useless-catch": "error", "no-useless-escape": "error", - "no-void": "error", + "no-void": ["error", { allowAsStatement: true }], "no-with": "error", "object-shorthand": [ "error", @@ -139,7 +139,7 @@ const javascript = [ "prefer-rest-params": "error", "prefer-spread": "error", "prefer-template": "error", - "require-await": "error", + "require-await": "off", "require-yield": "error", "unicode-bom": ["error", "never"], "use-isnan": [ diff --git a/package.json b/package.json index 3ce259b4..9a80e25e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "preview": "vite preview", "lint": "eslint .", "lint:fix": "eslint --fix .", + "test": "vitest run", + "test:watch": "vitest", "prepare": "simple-git-hooks" }, "dependencies": { @@ -29,6 +31,8 @@ "lodash-es": "^4.18.1", "md5": "^2.3.0", "naive-ui": "^2.44.1", + "@pixi/sound": "^6.0.1", + "pixi.js": "8.16.0", "vue": "^3.5.39", "vue-draggable-plus": "^0.6.1", "workbox-routing": "^7.4.1", @@ -59,6 +63,7 @@ "eslint-plugin-unicorn": "^64.0.0", "eslint-plugin-vue": "^10.9.2", "globals": "^17.7.0", + "happy-dom": "^18.0.1", "prettier": "^3.9.4", "rollup-plugin-visualizer": "^7.0.1", "sass-embedded": "^1.100.0", @@ -69,6 +74,7 @@ "typescript-eslint": "^8.62.1", "unocss": "^66.7.4", "vite": "^8.1.0", + "vitest": "^4.1.10", "vue-eslint-parser": "^10.4.1", "vue-tsc": "^3.3.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0761abe..bf2611cd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@pixi/sound': + specifier: ^6.0.1 + version: 6.0.1(pixi.js@8.16.0) '@sentry/browser': specifier: ^10.62.0 version: 10.62.0 @@ -44,6 +47,9 @@ importers: naive-ui: specifier: ^2.44.1 version: 2.44.1(vue@3.5.39(typescript@6.0.3)) + pixi.js: + specifier: 8.16.0 + version: 8.16.0 vue: specifier: ^3.5.39 version: 3.5.39(typescript@6.0.3) @@ -129,6 +135,9 @@ importers: globals: specifier: ^17.7.0 version: 17.7.0 + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 prettier: specifier: ^3.9.4 version: 3.9.4 @@ -159,6 +168,9 @@ importers: vite: specifier: ^8.1.0 version: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.2)(happy-dom@18.0.1)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4)) vue-eslint-parser: specifier: ^10.4.1 version: 10.4.1(eslint@10.6.0(jiti@2.7.0)) @@ -1187,6 +1199,14 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@pixi/colord@2.9.6': + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + + '@pixi/sound@6.0.1': + resolution: {integrity: sha512-hpFlQSScAR2L/CsEoIvDi7s1oGsu8y9Zd742Vd3982N+q3IF2vFuIfV9HN4ryjlGnhCT8yBlEgs1gp4G9Rt9TA==} + peerDependencies: + pixi.js: ^8.0.0 + '@pkgr/core@0.3.6': resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1319,6 +1339,9 @@ packages: resolution: {integrity: sha512-rWp4hBhZOmdQhisxcKzAwTGiRk/LvWnNaElWe7nbRhjsM/usp2095yfjq4iJ47v9MtO7xxY6eUz++fLBycqXKg==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -1328,9 +1351,18 @@ packages: '@types/ali-oss@6.23.3': resolution: {integrity: sha512-huSf6njkqhSeR37OMJTGMCyUg2d9Zp2AioefhYuUMeh0vNM+WybctrBor7bO/RcCbLCQI6sHn6NtPBIWALYVJg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1358,6 +1390,9 @@ packages: '@types/md5@2.3.6': resolution: {integrity: sha512-WD69gNXtRBnpknfZcb4TRQ0XJQbUPZcai/Qdhmka3sxUR3Et8NrXoeAoknG/LghYHTf4ve795rInVYHBTQdNVA==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} @@ -1367,6 +1402,9 @@ packages: '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@typescript-eslint/eslint-plugin@8.62.1': resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1651,6 +1689,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -1725,8 +1792,16 @@ packages: peerDependencies: vue: ^3.5.0 + '@webgpu/types@0.1.71': + resolution: {integrity: sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==} + + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + '@zumer/snapdom@2.12.9': resolution: {integrity: sha512-Zufl1vEthT931kcZfmAaRSnucAGIRLGi9N6JI8ZsdyHww5hHHLk4t5LU1DxS5pNAPi1MptPHcGON1pkXEBf5iw==} + deprecated: Old versions have known fidelity bugs (text re-wrap, width freezing, KaTeX clipping). Upgrade to ^2.22 — drop-in for any 2.x. https://github.com/zumerlab/snapdom/releases acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1767,6 +1842,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} @@ -1845,6 +1924,10 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} @@ -1998,6 +2081,9 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + earcut@3.2.3: + resolution: {integrity: sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2026,6 +2112,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2165,9 +2254,16 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + evtd@0.2.4: resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -2249,6 +2345,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -2269,6 +2368,10 @@ packages: resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} engines: {node: '>=0.8.0'} + happy-dom@18.0.1: + resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2373,6 +2476,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + isstream@0.1.2: resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} @@ -2383,6 +2489,9 @@ packages: js-base64@2.6.4: resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==} + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + js-cookie@3.0.8: resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} @@ -2607,6 +2716,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} @@ -2651,6 +2764,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -2681,6 +2797,9 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pixi.js@8.16.0: + resolution: {integrity: sha512-gu2xw3sZGAn3cWBtk0HqTQT+v19YAfiaYXwUGgWoJl5NKz4cEZJUgWrwkmdfDszGyYBAGqOvJNbd2M9+vzLLMg==} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -2973,6 +3092,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-git-hooks@2.13.1: resolution: {integrity: sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ==} hasBin: true @@ -3000,10 +3122,16 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stream-http@2.8.2: resolution: {integrity: sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==} @@ -3064,6 +3192,13 @@ packages: through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -3072,6 +3207,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + to-arraybuffer@1.0.1: resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==} @@ -3124,6 +3263,9 @@ packages: unconfig@7.5.0: resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -3248,6 +3390,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vooks@0.2.12: resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} peerDependencies: @@ -3293,11 +3476,20 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + win-release@1.1.1: resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==} engines: {node: '>=0.10.0'} @@ -4384,6 +4576,12 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@pixi/colord@2.9.6': {} + + '@pixi/sound@6.0.1(pixi.js@8.16.0)': + dependencies: + pixi.js: 8.16.0 + '@pkgr/core@0.3.6': {} '@polka/url@1.0.0-next.29': {} @@ -4471,6 +4669,8 @@ snapshots: '@sentry/browser-utils': 10.62.0 '@sentry/core': 10.62.0 + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -4483,8 +4683,17 @@ snapshots: '@types/ali-oss@6.23.3': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/crypto-js@4.2.2': {} + '@types/deep-eql@4.0.2': {} + + '@types/earcut@3.0.0': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -4505,6 +4714,10 @@ snapshots: '@types/md5@2.3.6': {} + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@types/node@24.13.2': dependencies: undici-types: 7.18.2 @@ -4513,6 +4726,8 @@ snapshots: '@types/web-bluetooth@0.0.21': {} + '@types/whatwg-mimetype@3.0.2': {} + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.6.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4866,6 +5081,47 @@ snapshots: vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4) vue: 3.5.39(typescript@6.0.3) + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@volar/language-core@2.4.28': dependencies: '@volar/source-map': 2.4.28 @@ -4975,6 +5231,10 @@ snapshots: dependencies: vue: 3.5.39(typescript@6.0.3) + '@webgpu/types@0.1.71': {} + + '@xmldom/xmldom@0.8.13': {} + '@zumer/snapdom@2.12.9': {} acorn-jsx@5.3.2(acorn@8.17.0): @@ -5035,6 +5295,8 @@ snapshots: any-promise@1.3.0: {} + assertion-error@2.0.1: {} + async-validator@4.2.5: {} babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): @@ -5110,6 +5372,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + chai@6.2.2: {} + change-case@5.4.4: {} charenc@0.0.2: {} @@ -5227,6 +5491,8 @@ snapshots: duplexer@0.1.2: {} + earcut@3.2.3: {} + ee-first@1.1.1: {} electron-to-chromium@1.5.376: {} @@ -5245,6 +5511,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -5433,8 +5701,12 @@ snapshots: esutils@2.0.3: {} + eventemitter3@5.0.4: {} + evtd@0.2.4: {} + expect-type@1.4.0: {} + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -5511,6 +5783,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -5525,6 +5801,12 @@ snapshots: hammerjs@2.0.8: {} + happy-dom@18.0.1: + dependencies: + '@types/node': 20.19.43 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5601,12 +5883,16 @@ snapshots: isexe@2.0.0: {} + ismobilejs@1.1.1: {} + isstream@0.1.2: {} jiti@2.7.0: {} js-base64@2.6.4: {} + js-binary-schema-parser@2.0.3: {} + js-cookie@3.0.8: {} js-tokens@4.0.0: {} @@ -5799,6 +6085,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.4: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -5876,6 +6164,8 @@ snapshots: package-manager-detector@1.6.0: {} + parse-svg-path@0.1.2: {} + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -5896,6 +6186,19 @@ snapshots: picomatch@4.0.4: {} + pixi.js@8.16.0: + dependencies: + '@pixi/colord': 2.9.6 + '@types/earcut': 3.0.0 + '@webgpu/types': 0.1.71 + '@xmldom/xmldom': 0.8.13 + earcut: 3.2.3 + eventemitter3: 5.0.4 + gifuct-js: 2.1.2 + ismobilejs: 1.1.1 + parse-svg-path: 0.1.2 + tiny-lru: 11.4.7 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -6172,6 +6475,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + simple-git-hooks@2.13.1: {} sirv@3.0.2: @@ -6193,8 +6498,12 @@ snapshots: stable-hash-x@0.2.0: {} + stackback@0.0.2: {} + statuses@1.5.0: {} + std-env@4.2.0: {} + stream-http@2.8.2: dependencies: builtin-status-codes: 3.0.0 @@ -6256,6 +6565,10 @@ snapshots: through@2.3.8: {} + tiny-lru@11.4.7: {} + + tinybench@2.9.0: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -6263,6 +6576,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.1: {} + to-arraybuffer@1.0.1: {} totalist@3.0.1: {} @@ -6315,6 +6630,8 @@ snapshots: quansync: 1.0.0 unconfig-core: 7.5.0 + undici-types@6.21.0: {} + undici-types@7.18.2: {} unescape@1.0.1: @@ -6452,6 +6769,34 @@ snapshots: terser: 5.48.0 tsx: 4.22.4 + vitest@4.1.10(@types/node@24.13.2)(happy-dom@18.0.1)(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.1.0(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.48.0)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 + happy-dom: 18.0.1 + transitivePeerDependencies: + - msw + vooks@0.2.12(vue@3.5.39(typescript@6.0.3)): dependencies: evtd: 0.2.4 @@ -6504,10 +6849,17 @@ snapshots: webpack-virtual-modules@0.6.2: {} + whatwg-mimetype@3.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + win-release@1.1.1: dependencies: semver: 5.7.2 diff --git a/src/entries/StoryPlayer.ts b/src/entries/StoryPlayer.ts new file mode 100644 index 00000000..80c7ef44 --- /dev/null +++ b/src/entries/StoryPlayer.ts @@ -0,0 +1,11 @@ +import { createApp } from "vue"; + +import "virtual:uno.css"; +import StoryPlayer from "../widgets/StoryPlayer/index.vue"; + +const ele = document.querySelector("#root"); +if (ele?.dataset?.path) { + createApp(StoryPlayer, { path: ele.dataset.path }).mount(ele); +} else { + console.error("data-path or ele not found", ele); +} diff --git a/src/entries/sentry.ts b/src/entries/sentry.ts index 58c4d57e..3e49f149 100644 --- a/src/entries/sentry.ts +++ b/src/entries/sentry.ts @@ -1,5 +1,10 @@ import * as Sentry from "@sentry/browser"; +const feedback = Sentry.feedbackIntegration({ + autoInject: false, + colorScheme: "system", +}); + Sentry.init({ dsn: location.host.includes("prts") ? "https://73af36ee35564fe4946285b451a8405a@ingest.sentry.mooncell.wiki/4507366072188928" @@ -12,6 +17,7 @@ Sentry.init({ Sentry.browserTracingIntegration(), Sentry.httpClientIntegration(), Sentry.contextLinesIntegration(), + feedback, ], sampleRate: 0.01, @@ -69,6 +75,11 @@ Sentry.init({ window.Sentry = { showReportDialog: Sentry.showReportDialog, captureException: Sentry.captureException, + showFeedback: async (tags) => { + const form = await feedback.createForm({ tags }); + form.appendToDom(); + form.open(); + }, }; (window.RLQ = window.RLQ || []).push([ diff --git a/src/mw.d.ts b/src/mw.d.ts index c22976bc..df80135e 100644 --- a/src/mw.d.ts +++ b/src/mw.d.ts @@ -1,5 +1,11 @@ declare interface Window { RLQ?: any[]; - Sentry: Record; + Sentry?: { + captureException?: (error: unknown) => string; + showFeedback?: ( + tags?: Record, + ) => Promise; + showReportDialog?: (...args: any[]) => void; + }; mw: any; } diff --git a/src/widgets/StoryPlayer/assets.ts b/src/widgets/StoryPlayer/assets.ts new file mode 100644 index 00000000..53fef098 --- /dev/null +++ b/src/widgets/StoryPlayer/assets.ts @@ -0,0 +1,34 @@ +// 本地静态贴图资源。 +// +// 这些是 APK 内置的 UI 贴片(对话框黑条渐变、地区邮戳动画零件)。源仓库 +// (arknights-story-player) 把它们放在 public/ui/ 下由 Vite 直接服务到 /ui/; +// 本仓库产物部署到 OSS(base = https://static.prts.wiki/widgets/production), +// 没有 public/ 直出机制,且引擎里写死的 /ui/ 绝对路径在 wiki 页面里会解析 +// 错,必须随 widget 打包。 +// +// 关键点:必须用 `new URL('./x.png', import.meta.url)` 而非 `import x from +// './x.png'`。本 widget 在 prts.wiki 页面里跨源加载(页面在 prts.wiki,脚本 +// 在 dev server / OSS),普通 import 在 dev 下被解析成绝对路径 +// `/src/.../*.png`,浏览器按「页面源」解析成 https://prts.wiki/src/... → 404。 +// `import.meta.url` 以「当前模块」为基准,dev 下自然指向 dev server;build 下 +// Vite 会把它替换成带 hash 的 OSS 资产 URL,小图还会内联成 base64 data URI。 +// +// 注意:`new URL` 必须用字符串字面量作为第一参数直接内联,Vite 才能静态分析 +// 出资产依赖并打包;不能用函数包装或变量拼接。 + +/** 对话框顶部/底部黑条渐变纹理 */ +export const DIALOG_FRAME_URL = new URL( + "assets/ui/sprite_avg_cutscene.png", + import.meta.url, +).href; + +/** 地区邮戳(animtext group_location_stamp)各零件贴图 */ +export const STAMP_ASSETS = { + back_gradient: new URL("assets/ui/back_gradient.png", import.meta.url).href, + back_shadow: new URL("assets/ui/back_shadow.png", import.meta.url).href, + frame_inner: new URL("assets/ui/frame_inner.png", import.meta.url).href, + frame_outer: new URL("assets/ui/frame_outer.png", import.meta.url).href, + icon_back: new URL("assets/ui/icon_back.png", import.meta.url).href, + icon_comps: new URL("assets/ui/icon_comps.png", import.meta.url).href, + icon_start: new URL("assets/ui/icon_start.png", import.meta.url).href, +} as const; diff --git a/src/widgets/StoryPlayer/assets/ui/back_gradient.png b/src/widgets/StoryPlayer/assets/ui/back_gradient.png new file mode 100644 index 00000000..f35ee7ec Binary files /dev/null and b/src/widgets/StoryPlayer/assets/ui/back_gradient.png differ diff --git a/src/widgets/StoryPlayer/assets/ui/back_shadow.png b/src/widgets/StoryPlayer/assets/ui/back_shadow.png new file mode 100644 index 00000000..ed172919 Binary files /dev/null and b/src/widgets/StoryPlayer/assets/ui/back_shadow.png differ diff --git a/src/widgets/StoryPlayer/assets/ui/frame_inner.png b/src/widgets/StoryPlayer/assets/ui/frame_inner.png new file mode 100644 index 00000000..7b615f03 Binary files /dev/null and b/src/widgets/StoryPlayer/assets/ui/frame_inner.png differ diff --git a/src/widgets/StoryPlayer/assets/ui/frame_outer.png b/src/widgets/StoryPlayer/assets/ui/frame_outer.png new file mode 100644 index 00000000..35b8d23c Binary files /dev/null and b/src/widgets/StoryPlayer/assets/ui/frame_outer.png differ diff --git a/src/widgets/StoryPlayer/assets/ui/icon_back.png b/src/widgets/StoryPlayer/assets/ui/icon_back.png new file mode 100644 index 00000000..ecacdee0 Binary files /dev/null and b/src/widgets/StoryPlayer/assets/ui/icon_back.png differ diff --git a/src/widgets/StoryPlayer/assets/ui/icon_comps.png b/src/widgets/StoryPlayer/assets/ui/icon_comps.png new file mode 100644 index 00000000..6cdbffb6 Binary files /dev/null and b/src/widgets/StoryPlayer/assets/ui/icon_comps.png differ diff --git a/src/widgets/StoryPlayer/assets/ui/icon_start.png b/src/widgets/StoryPlayer/assets/ui/icon_start.png new file mode 100644 index 00000000..9aeec569 Binary files /dev/null and b/src/widgets/StoryPlayer/assets/ui/icon_start.png differ diff --git a/src/widgets/StoryPlayer/assets/ui/sprite_avg_cutscene.png b/src/widgets/StoryPlayer/assets/ui/sprite_avg_cutscene.png new file mode 100644 index 00000000..8aae35ba Binary files /dev/null and b/src/widgets/StoryPlayer/assets/ui/sprite_avg_cutscene.png differ diff --git a/src/widgets/StoryPlayer/components/LogAllList.vue b/src/widgets/StoryPlayer/components/LogAllList.vue new file mode 100644 index 00000000..503573c8 --- /dev/null +++ b/src/widgets/StoryPlayer/components/LogAllList.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/src/widgets/StoryPlayer/components/LogAllPanel.vue b/src/widgets/StoryPlayer/components/LogAllPanel.vue new file mode 100644 index 00000000..eebe2729 --- /dev/null +++ b/src/widgets/StoryPlayer/components/LogAllPanel.vue @@ -0,0 +1,78 @@ + + + diff --git a/src/widgets/StoryPlayer/context.ts b/src/widgets/StoryPlayer/context.ts new file mode 100644 index 00000000..8fbbbae9 --- /dev/null +++ b/src/widgets/StoryPlayer/context.ts @@ -0,0 +1,210 @@ +import { resolveAssetUrl } from "./engine/asset"; +import { parseStory } from "./engine/parser"; + +import type { StoryLinkNode, StoryMetadata } from "./engine/types"; + +const RAW_ASSET_BASE = "https://torappu.prts.wiki/assets"; +const STORY_BASE = "https://torappu.prts.wiki/gamedata/latest/story"; + +export interface Context { + script: readonly string[]; + scriptText?: string; + storyMetadata?: StoryMetadata; + audioVariables?: Record; + charMap?: Record; // Legacy fallback. New rendering path reads from linkMap(character.json). + linkMap: Record; +} + +function assetUrl(path: string): string { + const normalized = path.replace(/^\/+/, ""); + return resolveAssetUrl(`${RAW_ASSET_BASE}/${normalized}`); +} + +function storyUrl(path: string): string { + const normalized = path.replace(/^\/+/, ""); + return `${STORY_BASE}/${normalized}`; +} + +function toNumber(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) + return Number(value); + return fallback; +} + +function asObject(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + return value as Record; +} + +function normalizeStoryScriptPath(storyPath: string): string { + return storyPath + .trim() + .replace(/^\/?story\//, "") + .replace(/^\/+/, ""); +} + +function normalizeCharacterMap(raw: unknown): Record { + const root = asObject(raw); + if (!root) return {}; + + const output: Record = {}; + + for (const [base, rawNode] of Object.entries(root)) { + const node = asObject(rawNode); + if (!node) continue; + + const posRaw = asObject(node.pos) ?? {}; + const sizeRaw = asObject(node.size) ?? {}; + + const groupsRaw = Array.isArray(node.groups) ? node.groups : []; + const groups: StoryLinkNode["groups"] = groupsRaw.map((rawGroup) => { + const group = asObject(rawGroup); + if (!group) return { mode: "single" }; + + if (group.mode === "face_overlay" && typeof group.base === "string") { + const faceRectRaw = asObject(group.faceRect) ?? {}; + return { + mode: "face_overlay", + base: group.base, + faceRect: { + x: Math.round(toNumber(faceRectRaw.x, 0)), + y: Math.round(toNumber(faceRectRaw.y, 0)), + w: Math.round(toNumber(faceRectRaw.w, 0)), + h: Math.round(toNumber(faceRectRaw.h, 0)), + }, + }; + } + + return { mode: "single" }; + }); + + const arrayRaw = Array.isArray(node.array) ? node.array : []; + const array: StoryLinkNode["array"] = []; + + for (const rawItem of arrayRaw) { + const item = asObject(rawItem); + if (!item || typeof item.name !== "string") continue; + + const name = item.name; + const alias = typeof item.alias === "string" ? item.alias : ""; + const group = Math.trunc(toNumber(item.group, -1)); + + if (group === -1) { + array.push({ + alias, + group: -1, + image: typeof item.image === "string" ? item.image : name, + name, + }); + continue; + } + + if (typeof item.face === "string") { + array.push({ + alias, + face: item.face, + group, + name, + }); + continue; + } + + if (typeof item.image === "string") { + array.push({ + alias, + group: -1, + image: item.image, + name, + }); + } + } + + output[base] = { + array, + groups, + pos: { + x: toNumber(posRaw.x, 0), + y: toNumber(posRaw.y, 0), + }, + size: { + x: toNumber(sizeRaw.x, 0), + y: toNumber(sizeRaw.y, 0), + }, + }; + } + + return output; +} + +const cachedStoryAudioVariables: Record = {}; + +function mergeStoryVariables(raw: unknown): void { + const root = asObject(raw); + if (!root) return; + + for (const [key, value] of Object.entries(root)) + cachedStoryAudioVariables[key.toLowerCase()] = value; +} + +let loadingStoryVariables: Promise | null = null; +let storyVariablesLoaded = false; + +async function ensureStoryVariables(): Promise> { + if (!storyVariablesLoaded) { + if (!loadingStoryVariables) { + loadingStoryVariables = (async () => { + try { + const response = await fetch(storyUrl("story_variables.json")); + if (!response.ok) return; + mergeStoryVariables(await response.json()); + storyVariablesLoaded = true; + } catch { + // Keep runtime resilient: script loading still works without variables. + } finally { + loadingStoryVariables = null; + } + })(); + } + + await loadingStoryVariables; + } + + return { ...cachedStoryAudioVariables }; +} + +export async function fetchStoryScriptByPath( + storyPath: string, +): Promise { + const normalizedPath = normalizeStoryScriptPath(storyPath); + const response = await fetch(storyUrl(`${normalizedPath}.txt`)); + if (!response.ok) + throw new Error(`failed to fetch story script: ${storyPath}`); + + return response.text(); +} + +async function fetchStoryCharacterMap(): Promise< + Record +> { + const response = await fetch(assetUrl("avg/character.json")); + if (!response.ok) throw new Error("failed to fetch avg/character.json"); + + return normalizeCharacterMap(await response.json()); +} + +export async function loadContextByPath(storyPath: string): Promise { + const [audioVariables, scriptText, linkMap] = await Promise.all([ + ensureStoryVariables(), + fetchStoryScriptByPath(storyPath), + fetchStoryCharacterMap(), + ]); + + return { + script: scriptText.replace(/\r\n?/g, "\n").split("\n"), + scriptText, + storyMetadata: parseStory(scriptText).metadata, + audioVariables, + linkMap, + }; +} diff --git a/src/widgets/StoryPlayer/engine/asset.ts b/src/widgets/StoryPlayer/engine/asset.ts new file mode 100644 index 00000000..f738c4f0 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/asset.ts @@ -0,0 +1,83 @@ +const TORAPPU_ORIGIN = "https://torappu.prts.wiki"; + +/** + * Native provenance: `Torappu.ResourceRouter.GetBackgroundPath`, `GetImagePath`, + * `GetCharacterPath`, `GetItemPath`, `GetMusicPath`, and `GetAudioPath`. + * + * Ports the AVG asset-family routing. HTTP URLs, extension conversion, and URL + * escaping are web delivery adaptations rather than native behavior. + * + */ + +function normalizeImageKey(rawKey: string): string { + return rawKey.trim().toLowerCase(); +} + +function encodePathSegments(path: string): string { + return path + .split("/") + .map((part) => encodeURIComponent(part)) + .join("/"); +} + +function audioUrlFromValue(value: string): string { + const assetsBase = `${TORAPPU_ORIGIN}/assets/`; + return `${value.toLowerCase().replace("sound_beta_2", `${assetsBase}audio`)}.mp3`; +} + +function normalizeAssetPath(rawPath: string): string { + return rawPath.trim().replace(/^\/+/, ""); +} + +export function resolveAssetUrl(raw: string): string { + return raw; +} + +export function resolveStoryAssetByKey( + rawKey: string, + isBackground: boolean, +): string | null { + const key = normalizeImageKey(rawKey); + if (!key) return null; + + const folder = isBackground ? "background" : "images"; + return `${TORAPPU_ORIGIN}/assets/avg/${folder}/${key}.png`; +} + +export function resolveStoryCharacterAssetByKey(rawKey: string): string | null { + const key = rawKey.trim(); + if (!key) return null; + return `${TORAPPU_ORIGIN}/assets/avg/characters/${encodePathSegments(key)}.png`; +} + +export function resolveStoryVideoByKey(rawKey: string): string | null { + const key = normalizeAssetPath(rawKey); + if (!key) return null; + + if (/^https?:\/\//i.test(key)) return key; + + return `${TORAPPU_ORIGIN}/assets/${encodePathSegments(key.toLowerCase())}`; +} + +export function resolveStoryAudioByKey( + rawKey: string, + variables?: Record | null, +): string | null { + const key = rawKey.trim(); + if (!key) return null; + + if (key.startsWith("$")) { + if (!variables) return null; + + const variableKey = key.slice(1).toLowerCase(); + const variableValue = variables[variableKey]; + if (typeof variableValue !== "string") return null; + return audioUrlFromValue(variableValue); + } + + if (key.startsWith("@")) return `${TORAPPU_ORIGIN}/assets/${key.slice(1)}`; + + if (/^https?:\/\//i.test(key)) return key; + + return audioUrlFromValue(key); +} diff --git a/src/widgets/StoryPlayer/engine/audio.ts b/src/widgets/StoryPlayer/engine/audio.ts new file mode 100644 index 00000000..ff9957ae --- /dev/null +++ b/src/widgets/StoryPlayer/engine/audio.ts @@ -0,0 +1,338 @@ +import { Assets } from "pixi.js"; + +import { resolveStoryAudioByKey } from "./asset"; + +import type { Context } from "../context"; +import type { PlayMusicInput, PlaySoundInput, StoryAudio } from "./types"; +import type { IMediaInstance, Sound } from "@pixi/sound"; + +function nowMs(): number { + if (typeof performance !== "undefined") return performance.now(); + return Date.now(); +} + +interface ActivePlayback { + identity: string; + instance: IMediaInstance | null; + sound: Sound; +} + +export class HtmlStoryAudio implements StoryAudio { + private readonly context: Context; + private destroyed = false; + private musicAudio: ActivePlayback | null = null; + private musicRequestId = 0; + /** + * Native provenance: `Torappu.AVG.CommonExecutors._ExecutePlayMusicCommand`, + * `_ExecuteMusicVolumeCommand`, `_ExecutePlaySoundCommand`, + * `_ExecuteSoundVolumeCommand`, `_ExecuteStopMusicCommand`, and + * `_ExecuteStopSoundCommand`; downstream `AudioManager` / `AudioChannel`. + * + * Ports persistent MUSIC and `avgsound_` base-volume state so a + * following volume/stop command observes an in-flight async web load. PIXI + * loading and browser playback are web adaptations of the native backend. + * + */ + private musicBaseVolume = 1; + private readonly soundBaseVolumes = new Map(); + private readonly soundRequestIds = new Map(); + private readonly soundCache = new Map(); + private readonly soundChannels = new Map(); + + constructor(context: Context) { + this.context = context; + } + + destroy(): void { + this.destroyed = true; + + this.stopPlayback(this.musicAudio); + this.musicAudio = null; + + for (const sound of this.soundChannels.values()) this.stopPlayback(sound); + this.soundChannels.clear(); + this.soundBaseVolumes.clear(); + this.soundRequestIds.clear(); + } + + async playMusic(input: PlayMusicInput): Promise { + if (this.destroyed) return; + + const mainUrl = this.resolveAudioUrl(input.key); + if (!mainUrl) return; + + const introUrl = input.intro ? this.resolveAudioUrl(input.intro) : null; + const identity = `${introUrl ?? ""}\n${mainUrl}`; + this.musicBaseVolume = input.volume; + if (this.musicAudio?.identity === identity) { + this.setVolume(this.musicAudio, input.volume); + return; + } + + const requestId = ++this.musicRequestId; + if (input.delayMs > 0) + await new Promise((resolve) => setTimeout(resolve, input.delayMs)); + if (this.destroyed || requestId !== this.musicRequestId) return; + + const next = await this.createPlayback(introUrl ?? mainUrl, identity); + if (!next || this.destroyed || requestId !== this.musicRequestId) return; + + const previous = this.musicAudio; + const crossfade = previous !== null && input.crossfadeMs > 10; + this.musicAudio = next; + next.instance = await this.playPlayback(next, { + complete: introUrl + ? () => { + if (this.destroyed || this.musicAudio !== next) return; + // Native provenance: `AudioManager.PlayMusic` / `AudioChannel`. + // Preserve the current channel base volume while switching from + // intro to loop, so an intervening musicvolume command is retained. + void this.playLoopMusic(mainUrl, identity, requestId); + } + : undefined, + loop: !introUrl, + volume: crossfade ? 0 : this.musicBaseVolume, + }); + + if (previous) { + if (crossfade) void this.fadeAndStop(previous, input.crossfadeMs); + else this.stopPlayback(previous); + } + if (crossfade) + void this.fadeVolume(next, this.musicBaseVolume, input.crossfadeMs); + } + + async stopMusic(fadeMs: number): Promise { + this.musicRequestId++; + if (!this.musicAudio) return; + + const current = this.musicAudio; + if (this.musicAudio === current) this.musicAudio = null; + await this.fadeAndStop(current, fadeMs); + } + + async playSound(input: PlaySoundInput): Promise { + if (this.destroyed) return; + + const url = this.resolveAudioUrl(input.key); + if (!url) return; + + // Native provenance: `CommonExecutors._GenAVGSoundChannelName` and + // `_ExecutePlaySoundCommand`. Ports deterministic channel ownership before + // playback begins, so a following soundvolume/stopsound targets this sound. + const channel = this.soundChannelName(input.channel); + const requestId = (this.soundRequestIds.get(channel) ?? 0) + 1; + this.soundRequestIds.set(channel, requestId); + this.soundBaseVolumes.set(channel, input.volume); + + if (input.delayMs > 0) + await new Promise((resolve) => setTimeout(resolve, input.delayMs)); + if (this.destroyed || this.soundRequestIds.get(channel) !== requestId) + return; + + const prev = this.soundChannels.get(channel); + if (prev) { + this.stopPlayback(prev); + this.soundChannels.delete(channel); + } + + const sound = await this.createPlayback(url, url); + if ( + !sound || + this.destroyed || + this.soundRequestIds.get(channel) !== requestId + ) + return; + this.soundChannels.set(channel, sound); + + const instance = await this.playPlayback(sound, { + complete: input.loop + ? undefined + : () => { + if (this.soundChannels.get(channel) === sound) + this.soundChannels.delete(channel); + }, + loop: input.loop, + volume: this.soundBaseVolumes.get(channel) ?? input.volume, + }); + + if (this.soundChannels.get(channel) !== sound) { + this.stopPlayback(sound); + return; + } + + sound.instance = instance; + } + + async stopSound(channel: string, fadeMs: number): Promise { + const channelName = this.soundChannelName(channel); + // Web adaptation: cancellation also covers pending PIXI loads. The native + // command resolves the same named channel through AudioManager. + this.soundRequestIds.set( + channelName, + (this.soundRequestIds.get(channelName) ?? 0) + 1, + ); + this.soundBaseVolumes.delete(channelName); + + const sound = this.soundChannels.get(channelName); + if (!sound) return; + + this.soundChannels.delete(channelName); + await this.fadeAndStop(sound, fadeMs); + } + + async setSoundVolume( + channel: string, + volume: number, + fadeMs: number, + ): Promise { + const channelName = this.soundChannelName(channel); + this.soundBaseVolumes.set(channelName, volume); + + const sound = this.soundChannels.get(channelName); + if (!sound) return; + + await this.fadeVolume(sound, volume, fadeMs); + } + + async setMusicVolume(volume: number, fadeMs: number): Promise { + // Native provenance: `CommonExecutors._ExecuteMusicVolumeCommand` and + // `AudioChannel.TweenVolume`. MUSIC base volume outlives a playback instance, + // so retain it while PIXI is still loading. + this.musicBaseVolume = volume; + if (!this.musicAudio) return; + + await this.fadeVolume(this.musicAudio, volume, fadeMs); + } + + private async createPlayback( + url: string, + identity: string, + ): Promise { + const sound = await this.getSound(url); + if (!sound) return null; + + return { + identity, + instance: null, + sound, + }; + } + + private async fadeVolume( + playback: ActivePlayback, + targetVolume: number, + durationMs: number, + ): Promise { + const startVolume = this.getVolume(playback); + if (durationMs <= 0) { + this.setVolume(playback, targetVolume); + return; + } + + const start = nowMs(); + + await new Promise((resolve) => { + const tick = () => { + if (this.destroyed) { + resolve(); + return; + } + + const elapsed = nowMs() - start; + const progress = Math.min(1, elapsed / durationMs); + this.setVolume( + playback, + startVolume + (targetVolume - startVolume) * progress, + ); + + if (progress >= 1) { + resolve(); + return; + } + + requestAnimationFrame(tick); + }; + + requestAnimationFrame(tick); + }); + } + + private async playLoopMusic( + url: string, + identity: string, + requestId: number, + ): Promise { + const music = await this.createPlayback(url, identity); + if (!music || this.destroyed || requestId !== this.musicRequestId) return; + this.musicAudio = music; + music.instance = await this.playPlayback(music, { + loop: true, + volume: this.musicBaseVolume, + }); + } + + private async fadeAndStop( + playback: ActivePlayback, + durationMs: number, + ): Promise { + await this.fadeVolume(playback, 0, durationMs); + this.stopPlayback(playback); + } + + private soundChannelName(channel: string): string { + return `avgsound_${channel}`; + } + + private getVolume(playback: ActivePlayback): number { + return playback.instance?.volume ?? 1; + } + + private setVolume(playback: ActivePlayback, volume: number): void { + if (playback.instance) playback.instance.volume = volume; + } + + private stopPlayback(playback: ActivePlayback | null): void { + if (!playback) return; + + playback.instance?.stop(); + playback.instance = null; + } + + private async getSound(url: string): Promise { + const existing = this.soundCache.get(url); + if (existing) return existing; + + const cached = Assets.get(url); + if (cached) { + this.soundCache.set(url, cached); + return cached; + } + + try { + const loaded = await Assets.load(url); + this.soundCache.set(url, loaded); + return loaded; + } catch { + return null; + } + } + + private async playPlayback( + playback: ActivePlayback, + options: Parameters[0], + ): Promise { + try { + const result = playback.sound.play(options); + if (result instanceof Promise) return await result.catch(() => null); + return result; + } catch { + return null; + } + } + + private resolveAudioUrl(key: string): string | null { + const raw = resolveStoryAudioByKey(key, this.context.audioVariables); + return raw ?? null; + } +} diff --git a/src/widgets/StoryPlayer/engine/commandRegistry.ts b/src/widgets/StoryPlayer/engine/commandRegistry.ts new file mode 100644 index 00000000..18b04672 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/commandRegistry.ts @@ -0,0 +1,53 @@ +import type { ParsedCommandLine } from "./types"; + +export type CommandExecutor = ( + command: ParsedCommandLine, +) => TResult | Promise; + +/** + * Native provenance: `Torappu.AVG.AVGController._InitExecutors`, + * `_GetCommandExecutors`, and `_ExecuteExecutor`. + * + * Ports the lower-cased command-key lookup and the fact that several active + * `ExecutorComponent.GetExecutors` registrations may subscribe to one command. + * Waiting/force-end policy remains in `StoryRuntime`; this is only the web + * dispatch container. + * + */ +export class CommandRegistry { + private readonly executors = new Map< + string, + Array> + >(); + + register(command: string, executor: CommandExecutor): () => void { + const key = command.toLowerCase(); + const list = this.executors.get(key) ?? []; + list.push(executor); + this.executors.set(key, list); + return () => { + const current = this.executors.get(key); + if (!current) return; + const index = current.indexOf(executor); + if (index !== -1) current.splice(index, 1); + if (current.length === 0) this.executors.delete(key); + }; + } + + has(command: string): boolean { + return this.executors.has(command.toLowerCase()); + } + + get(command: string): ReadonlyArray> | null { + const list = this.executors.get(command.toLowerCase()); + return list ? [...list] : null; + } + + async execute(command: ParsedCommandLine): Promise { + const list = this.executors.get(command.command); + if (!list) return null; + const results: TResult[] = []; + for (const executor of list) results.push(await executor(command)); + return results; + } +} diff --git a/src/widgets/StoryPlayer/engine/createStoryPlayer.ts b/src/widgets/StoryPlayer/engine/createStoryPlayer.ts new file mode 100644 index 00000000..74f6a288 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/createStoryPlayer.ts @@ -0,0 +1,107 @@ +import { sound } from "@pixi/sound"; + +import { HtmlStoryAudio } from "./audio"; +import { PixiStoryRenderer } from "./renderer"; +import { StoryRuntime } from "./runtime"; + +import type { Context } from "../context"; +import type { AutoPlayMode, PlayerState, StoryPlayer } from "./types"; + +// Register the @pixi/sound loader with PIXI's Assets system. This is a +// side-effecting import: merely `import type` (as audio.ts does) is erased and +// leaves Assets unable to resolve audio URLs, so playMusic/playSound would +// silently no-op. The source repo did this in its main.ts entry; here the +// engine owns its own bootstrap. +sound.disableAutoPause = true; + +export function createStoryPlayer(context: Context): StoryPlayer { + let runtime: StoryRuntime | null = null; + let renderer: PixiStoryRenderer | null = null; + let audio: HtmlStoryAudio | null = null; + let mounted = false; + + const ensureRuntime = () => { + if (runtime) return runtime; + + renderer = new PixiStoryRenderer(context, (detail) => + console.warn(`[renderer] ${detail}`), + ); + audio = new HtmlStoryAudio(context); + runtime = new StoryRuntime(context, renderer, audio, { + onWarning: (warning) => + console.warn( + "[runtime]", + warning.type, + warning.detail ?? "", + `@${warning.cursor}`, + ), + typingIntervalMs: 40, + }); + + return runtime; + }; + + return { + async mount(host: HTMLElement): Promise { + ensureRuntime(); + if (mounted) return; + + await renderer!.mount(host); + mounted = true; + }, + + async start(): Promise { + if (!mounted) throw new Error("StoryPlayer is not mounted yet"); + await ensureRuntime().start(); + }, + + async advance(): Promise { + if (!mounted) throw new Error("StoryPlayer is not mounted yet"); + await ensureRuntime().advance(); + }, + + canSkipNode(): boolean { + return ensureRuntime().canSkipNode(); + }, + + destroy(): void { + runtime?.destroy(); + audio?.destroy(); + renderer?.destroy(); + + runtime = null; + audio = null; + renderer = null; + mounted = false; + }, + + getAutoPlayState() { + return ensureRuntime().getAutoPlayState(); + }, + + getDecisionSelectValue(): number { + return runtime?.getDecisionSelectValue() ?? 0; + }, + + getDisplayedLineIndex(): number | null { + return runtime?.getDisplayedLineIndex() ?? null; + }, + + getState(): PlayerState { + return runtime?.getState() ?? "idle"; + }, + + setAutoPlayMode(mode: AutoPlayMode): void { + ensureRuntime().setAutoPlayMode(mode); + }, + + setAutoPlaySpeedLevel(level: number): void { + ensureRuntime().setAutoPlaySpeedLevel(level); + }, + + async skipNode(): Promise { + if (!mounted) throw new Error("StoryPlayer is not mounted yet"); + await ensureRuntime().skipNode(); + }, + }; +} diff --git a/src/widgets/StoryPlayer/engine/execution.ts b/src/widgets/StoryPlayer/engine/execution.ts new file mode 100644 index 00000000..90bc281c --- /dev/null +++ b/src/widgets/StoryPlayer/engine/execution.ts @@ -0,0 +1,49 @@ +export type ExecutionEndReason = "complete" | "force_end" | "reset"; + +export interface ExecutionHandle { + readonly blocking: boolean; + readonly completion: Promise; + end: (reason?: ExecutionEndReason) => void; +} + +/** + * Native provenance: `Torappu.AVG.CommandExecutorWrapper` completion and + * force-end lifecycle. + * + * Ports one-shot command completion with an explicit blocking flag. A Promise + * is the web adaptation of native finish callbacks and coroutine resumption. + * + */ +export function createExecutionHandle( + blocking: boolean, + onEnd?: (reason: ExecutionEndReason) => void, +): ExecutionHandle { + let settled = false; + let resolve!: (reason: ExecutionEndReason) => void; + const completion = new Promise((done) => { + resolve = done; + }); + return { + blocking, + completion, + end(reason = "complete") { + if (settled) return; + settled = true; + onEnd?.(reason); + resolve(reason); + }, + }; +} + +export interface AnimationClock { + now: () => number; + requestFrame: (callback: () => void) => number; + cancelFrame: (id: number) => void; +} + +export const browserAnimationClock: AnimationClock = { + cancelFrame: (id) => cancelAnimationFrame(id), + now: () => + typeof performance === "undefined" ? Date.now() : performance.now(), + requestFrame: (callback) => requestAnimationFrame(callback), +}; diff --git a/src/widgets/StoryPlayer/engine/font.ts b/src/widgets/StoryPlayer/engine/font.ts new file mode 100644 index 00000000..e68a75fa --- /dev/null +++ b/src/widgets/StoryPlayer/engine/font.ts @@ -0,0 +1,53 @@ +/** + * Asset provenance: the AVG default font configured by + * `Torappu.Resource.AbFontConfig` is `SourceHanSansCN-Bold`. + * + * This module is a web adaptation: it explicitly registers the exported font + * with the browser before PIXI measures dialogue text. Native loading uses a + * Unity Font asset and ResourceManager instead. + * + */ +// 对话 UI 字体的显式预加载。 +// 为什么不用纯 CSS @font-face:浏览器对 @font-face 是惰性加载, +// 只有当文档里实际出现用该 family 名的 DOM 文本时才会发起请求。 +// 我们的对话文本是 PIXI 在 canvas 里用 ctx.font 渲染的, +// 浏览器不一定认为"文档使用了该字体",导致 F12 Network 看不到请求、 +// 字体永远不会下载。 +// 用 FontFace API 显式 fetch + register + load,可保证资源被请求, +// 且返回的 Promise resolve 后 measureText 测量的是真实字体 metrics +// (BestFit / 动态 Y 调整依赖准确测量)。 +// DIALOG_FONT_FAMILY 是 PIXI 内部使用的逻辑名(仅出现在各面板 TextStyle 的 +// fontFamily 里,无 CSS/DOM 引用),与实际加载的字体文件无关。当前加载的是 +// 思源黑体 CN 的 Bold 字重,故 DIALOG_FONT_WEIGHT 为 700;family 名沿用历史 +// 名称只是避免改动各面板。 + +export const DIALOG_FONT_FAMILY = "NotoSansHans-Medium"; +export const DIALOG_FONT_WEIGHT = 700; +export const DIALOG_FONT_URL = + "https://static.prts.wiki/SourceHanSansCN-Bold.woff2"; + +let loadPromise: Promise | null = null; + +export function preloadDialogFont(): Promise { + if (loadPromise) return loadPromise; + + loadPromise = (async () => { + // 环境无 FontFace(如 jsdom)直接跳过,不阻塞启动。 + if (typeof FontFace === "undefined") return; + + try { + const face = new FontFace(DIALOG_FONT_FAMILY, `url(${DIALOG_FONT_URL})`, { + style: "normal", + weight: String(DIALOG_FONT_WEIGHT), + display: "swap", + }); + await face.load(); + document.fonts.add(face); + } catch (error) { + // 字体加载失败不应阻塞剧情播放,降级到回退字体。 + console.warn("[story] dialog font load failed, falling back:", error); + } + })(); + + return loadPromise; +} diff --git a/src/widgets/StoryPlayer/engine/logAll.ts b/src/widgets/StoryPlayer/engine/logAll.ts new file mode 100644 index 00000000..a0704d8d --- /dev/null +++ b/src/widgets/StoryPlayer/engine/logAll.ts @@ -0,0 +1,334 @@ +import { parseRichChars } from "./richtext"; +import { expandStoryText } from "./textVariables"; + +import type { ParsedLine } from "./types"; + +/** + * Log All 数据模型。 + * + * 对齐本播放器 runtime 的实际播放行为(单层序列式过滤,无嵌套栈): + * - decision 让玩家选一个 value; + * - 后续每个 predicate(references=X) 把脚本划成一段,仅当玩家所选值 ∈ X 时执行; + * - 裸 [predicate](无 references)结束分支模式。 + * + * 因此 LogAll 把脚本整理为:顶层条目 + decision 节点(选项 + 共享段 + 各 predicate 分支段), + * 与玩家实际能走到的路径一一对应,不展开未选择的分支也不引入嵌套怪癖。 + * + * Native provenance: `Torappu.AVG.DecisionCommandPredicator` and + * `Torappu.AVG.DecisionPanel._ExecuteDecision` / `_ExecutePredicate`. + * This static reader projection ports predicate gating only; the Log All tree, + * route expansion, and reader UI are web-only adaptations. + * + */ + +export interface LogAllTextSpan { + text: string; + /** 来自 标签;null 表示默认色 */ + color: string | null; +} + +export type LogAllLineSource = + "dialogue" | "multiline" | "narration" | "sticker" | "subtitle"; + +/** 文本条目:对白/旁白/sticker/subtitle/multiline 累积结果 */ +export interface LogAllLineEntry { + kind: "line"; + /** 源 ParsedLine.lineNumber;multiline 合成条目取最后一条指令的行号。用于和 runtime 显示位置对齐 */ + lineIndex: number; + speaker: string; + spans: LogAllTextSpan[]; + source: LogAllLineSource; +} + +/** decision 的一个分支段(由 predicate 划出) */ +export interface LogAllBranch { + /** 该分支段对应的 predicate references 值;与 runtime 的 decisionReferences 一致 */ + references: number[]; + /** references 映射回所属 decision 选项的文本,便于直接展示「选了哪些选项会看到这段」 */ + labels: string[]; + /** 分支段内条目(runtime 中玩家选值 ∈ references 时才执行这些) */ + entries: LogAllEntry[]; +} + +/** 从一个 decision 选项出发,玩家实际会依次看到的专属条目。 */ +export interface LogAllDecisionRoute { + option: { label: string; value: number }; + /** 按脚本顺序合并所有 references 包含该选项 value 的 predicate 段。 */ + entries: LogAllEntry[]; +} + +/** decision 节点:所有选项 + 共享段 + 各分支段 */ +export interface LogAllDecisionEntry { + kind: "decision"; + options: { label: string; value: number }[]; + /** decision 之后、第一个 predicate 之前的文本(runtime 中无条件执行) */ + shared: LogAllEntry[]; + /** 按 predicate 划分的分支段 */ + branches: LogAllBranch[]; + /** 面向展示的选项路径;每个选项直接对应其会执行的全部分支内容。 */ + routes: LogAllDecisionRoute[]; +} + +export type LogAllEntry = LogAllDecisionEntry | LogAllLineEntry; + +interface MultilineAccumulator { + name: string; + text: string; + /** 合成 entry 的 lineIndex,取最后一条累积指令的行号 */ + lineIndex: number; +} + +/** 把含 ... 的文本拆成连续同色的 span */ +function toSpans( + text: string, + variables: Record, +): LogAllTextSpan[] { + const chars = parseRichChars(expandStoryText(text, variables)); + if (chars.length === 0) return []; + + const spans: LogAllTextSpan[] = []; + let buffer = ""; + let currentColor: string | null = null; + + const flush = (): void => { + if (buffer) { + spans.push({ text: buffer, color: currentColor }); + buffer = ""; + } + }; + + for (const { char, color } of chars) { + if (color !== currentColor) { + flush(); + currentColor = color; + } + buffer += char; + } + flush(); + return spans; +} + +function toNumberList(value: unknown): number[] { + if (typeof value !== "string") return []; + + return value.split(";").map((segment) => { + const parsed = Number.parseInt(segment.trim(), 10); + return Number.isNaN(parsed) ? 0 : parsed; + }); +} + +function toStringList(value: unknown): string[] { + if (typeof value !== "string") return []; + + return value.split(";"); +} + +/** + * 静态预扫脚本,按 runtime 实际过滤行为整理 decision/predicate 分支。 + * + * 用一个 target 栈跟踪「当前文本应落入的数组」: + * - 默认栈底为 root(顶层流); + * - 进入 decision 时 push 该 decision.shared; + * - predicate 在当前 decision 下新建分支段,target 栈顶替换为 branch.entries; + * - 裸 [predicate] 弹栈到当前 decision 之外(对齐 runtime 结束分支模式)。 + * 嵌套 decision 因此天然支持:每个 decision 独立持有自己的 shared/branches。 + */ +export function buildLogAll( + lines: readonly ParsedLine[], + variables: Record = {}, +): LogAllEntry[] { + const root: LogAllEntry[] = []; + let multilineAccum: MultilineAccumulator | null = null; + /** 当前文本应 append 到的数组栈;栈底恒为 root */ + const targetStack: LogAllEntry[][] = [root]; + /** 与 targetStack 并行:每层对应的 decision 节点(栈底为 null) */ + const decisionStack: (LogAllDecisionEntry | null)[] = [null]; + + const currentTarget = (): LogAllEntry[] => targetStack.at(-1)!; + const currentDecision = (): LogAllDecisionEntry | null => + decisionStack.at(-1) ?? null; + + const flushMultiline = (): void => { + if (!multilineAccum || !multilineAccum.text) return; + + currentTarget().push({ + kind: "line", + lineIndex: multilineAccum.lineIndex, + speaker: expandStoryText(multilineAccum.name, variables), + spans: toSpans(multilineAccum.text, variables), + source: "multiline", + }); + multilineAccum = null; + }; + + const appendLine = ( + lineIndex: number, + speaker: string, + text: string, + source: LogAllLineSource, + ): void => { + if (!text) return; + + flushMultiline(); + currentTarget().push({ + kind: "line", + lineIndex, + speaker: expandStoryText(speaker, variables), + spans: toSpans(text, variables), + source, + }); + }; + + for (const line of lines) { + const lineIndex = line.lineNumber; + if (line.kind === "dialogue") { + appendLine(lineIndex, line.speaker, line.text, "dialogue"); + continue; + } + + if (line.kind === "narration") { + appendLine(lineIndex, "", line.text, "narration"); + continue; + } + + // line.kind === 'command' + switch (line.command) { + case "decision": { + flushMultiline(); + + const labels = toStringList(line.args.options).map((label) => + expandStoryText(label, variables), + ); + const values = toNumberList(line.args.values); + if (labels.length === 0 || values.length === 0) break; // 无效 decision,按 runtime 行为忽略(runtime 会 warn 并 continue) + + const count = Math.min(labels.length, values.length); + const options: { label: string; value: number }[] = []; + for (let i = 0; i < count; i += 1) + options.push({ label: labels[i], value: values[i] }); + + const decision: LogAllDecisionEntry = { + branches: [], + kind: "decision", + options, + routes: options.map((option) => ({ entries: [], option })), + shared: [], + }; + currentTarget().push(decision); + // 新 decision 的执行会覆盖 runtime 中上一组选值,但它本身仍受执行前的 + // predicate 闸门约束。因此若它出现在某个分支段内,结构上必须留在该 + // 分支路径里;随后再以新 decision 为 owner 解释它自己的 predicate。 + targetStack.push(decision.shared); + decisionStack.push(decision); + break; + } + + case "predicate": { + flushMultiline(); + + // 裸 [predicate](无 references):runtime 设 decisionReferences=null,结束分支模式 + if (!line.paramPresent || line.args.references === undefined) { + // 弹出当前 decision 层(若有),回到外层流 + if (targetStack.length > 1) { + targetStack.pop(); + decisionStack.pop(); + } + break; + } + + const references = toNumberList(line.args.references); + const owner = currentDecision(); + if (references.length === 0 || !owner) break; // 无 enclosing decision 或 references 为空:忽略 + + // references 覆盖当前 decision 的全部选项时,这是常见的显式汇合点。 + // 从这里开始的内容对所有选项都相同,应回到 decision 的父级流; + // 否则紧随其后的新 decision 会被错误显示成每条选项路径的子节点。 + if ( + owner.options.every((option) => references.includes(option.value)) + ) { + targetStack.pop(); + decisionStack.pop(); + break; + } + + // references 映射回所属 decision 的选项文本(value -> label) + const labelByValue = new Map( + owner.options.map((o) => [o.value, o.label]), + ); + const labels = references + .map((ref) => labelByValue.get(ref)) + .filter((label): label is string => label !== undefined); + + // 在当前 decision 下新建分支段,替换栈顶 target + const branch: LogAllBranch = { entries: [], labels, references }; + owner.branches.push(branch); + targetStack[targetStack.length - 1] = branch.entries; + break; + } + + case "multiline": { + const text = line.trailingText; + const name = typeof line.args.name === "string" ? line.args.name : ""; + if (!text) break; + + if (multilineAccum) { + multilineAccum.text += text; + multilineAccum.lineIndex = lineIndex; + } else { + multilineAccum = { lineIndex, name, text }; + } + + if (line.args.end === true) flushMultiline(); + break; + } + + case "sticker": + case "subtitle": { + // PlaybackPanel and the reader-mode adapter both skip AddCell when + // hidelog is set; the scene-side executor ignores the key entirely. + if (line.args.hidelog === true) break; + + const text = typeof line.args.text === "string" ? line.args.text : ""; + if (!text) break; + + // multi=true 时累积进 multiline(对齐 runtime multiline 累积语义) + if (line.args.multi === true) { + if (multilineAccum) { + multilineAccum.text += text; + multilineAccum.lineIndex = lineIndex; + } else { + multilineAccum = { lineIndex, name: "", text }; + } + break; + } + + appendLine(lineIndex, "", text, line.command); + break; + } + + default: { + // 其它纯控制命令(background/character/audio 等)不产生日志条目 + break; + } + } + } + + flushMultiline(); + + // route 创建时 branch.entries 尚为空;预扫结束后再按脚本顺序组装每条完整路径。 + const populateRoutes = (entries: LogAllEntry[]): void => { + for (const entry of entries) { + if (entry.kind !== "decision") continue; + entry.routes = entry.options.map((option) => ({ + entries: entry.branches + .filter((branch) => branch.references.includes(option.value)) + .flatMap((branch) => branch.entries), + option, + })); + populateRoutes(entry.shared); + for (const branch of entry.branches) populateRoutes(branch.entries); + } + }; + populateRoutes(root); + return root; +} diff --git a/src/widgets/StoryPlayer/engine/parser.ts b/src/widgets/StoryPlayer/engine/parser.ts new file mode 100644 index 00000000..5a330cfd --- /dev/null +++ b/src/widgets/StoryPlayer/engine/parser.ts @@ -0,0 +1,272 @@ +import type { + ParsedDialogueLine, + ParsedLine, + StoryCommandArgs, + StoryCommandValue, + StoryMetadata, +} from "./types"; + +/** + * Native provenance: `Torappu.AVG.AVGParser.cctor` and + * `Torappu.AVG.AVGParser._ParseCommand`. + * + * Ports the command-tag shape and lower-cased command names. Parameter keys + * deliberately retain source case, matching the native parameter dictionary. + * + */ +const commandRegex = /^\[\s*(?:(.*?)\((.*)\)|(?:([.|\w]*)|(.*)))\s*\]\s*(.*)/; +// Param keys are case-sensitive in the native dictionary, so `[Name="X"]` is a +// dialog line whose `name` lookup misses -- it renders without a speaker rather +// than treating X as one. +const dialogueInnerRegex = /^name\s*=\s*"((?:\\.|[^"])*)"$/; + +function splitArgs(raw: string): string[] { + const output: string[] = []; + let current = ""; + let depth = 0; + let quote: '"' | "'" | null = null; + let escaped = false; + + for (const char of raw) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === "\\" && quote) { + current += char; + escaped = true; + continue; + } + if (quote) { + current += char; + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth = Math.max(0, depth - 1); + } else if (char === "," && depth === 0) { + if (current.trim()) output.push(current.trim()); + current = ""; + continue; + } + current += char; + } + + if (current.trim()) output.push(current.trim()); + return output; +} + +function unescapeQuoted(value: string): string { + const quote = value[0]; + const body = value.slice(1, -1); + return body.replace(/\\(.)/g, (_match, escaped: string) => { + if (escaped === "n") return "\n"; + if (escaped === "r") return "\r"; + if (escaped === "t") return "\t"; + if (escaped === quote || escaped === "\\") return escaped; + return `\\${escaped}`; + }); +} + +function parseValue(raw: string): StoryCommandValue { + const value = raw.trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + return unescapeQuoted(value); + } + if (value === "true") return true; + if (value === "false") return false; + if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value); + return value; +} + +function parseArgs(raw: string): StoryCommandArgs { + const args: StoryCommandArgs = {}; + for (const part of splitArgs(raw)) { + const separator = part.indexOf("="); + if (separator === -1) continue; + const key = part.slice(0, separator).trim(); + if (key) args[key] = parseValue(part.slice(separator + 1)); + } + return args; +} + +function parseDialogueSpeaker(inner: string): string | null { + const match = inner.match(dialogueInnerRegex); + return match ? unescapeQuoted(`"${match[1]}"`) : null; +} + +export function parseLine(raw: string, lineNumber = 1): ParsedLine { + const match = raw.match(commandRegex); + if (match) { + const parenthesizedName = match[1]; + const parenthesizedArgs = match[2]; + const bareName = match[3]; + const bareFallback = match[4]; + const content = match[5] ?? ""; + const speaker = bareFallback + ? parseDialogueSpeaker(bareFallback.trim()) + : null; + + if (speaker !== null) { + const dialogue: ParsedDialogueLine = { + kind: "dialogue", + lineNumber, + raw, + speaker, + text: content, + }; + return dialogue; + } + + // When neither the parenthesized nor the bare name group matches, _ParseCommand + // falls back to the sentinel "dialog" -- the same key DialogPanel registers + // _ExecuteDialog under. `[imagegroup=...]` and friends are dialog commands + // carrying params, not unregistered commands. + const command = (bareName || parenthesizedName || "dialog") + .trim() + .toLowerCase(); + if (command) { + return { + args: parseArgs(parenthesizedArgs ?? bareFallback ?? ""), + command, + content, + kind: "command", + lineNumber, + paramPresent: parenthesizedArgs !== undefined || Boolean(bareFallback), + raw, + trailingText: content, + }; + } + } + + return { + kind: "narration", + lineNumber, + raw, + text: raw, + }; +} + +interface LogicalLine { + lineNumber: number; + raw: string; +} + +/** + * Native provenance: `Torappu.AVG.AVGParser._ReadNextBlock` and + * `Torappu.AVG.AVGParser.TryParse(string, List)`. + * + * Ports backslash continuation, comment/blank-line filtering, and physical + * first-line numbering. The web parser intentionally keeps its own lightweight + * argument parser rather than embedding Json.NET's complete error surface. + * + */ +function logicalLines(source: string | readonly string[]): LogicalLine[] { + const physicalLines = + typeof source === "string" + ? source.replace(/\r\n?/g, "\n").split("\n") + : [...source]; + const output: LogicalLine[] = []; + + for (let index = 0; index < physicalLines.length; index += 1) { + const lineNumber = index + 1; + let raw = physicalLines[index] ?? ""; + while (raw.endsWith("\\") && index + 1 < physicalLines.length) { + raw = raw.slice(0, -1) + (physicalLines[index + 1] ?? ""); + index += 1; + } + if (!raw || /^\s+$/.test(raw) || /^\s*\/\//.test(raw)) continue; + output.push({ lineNumber, raw }); + } + return output; +} + +export function parseScript(source: string | readonly string[]): ParsedLine[] { + const sourceLines = logicalLines(source); + const lines = sourceLines.map((line) => parseLine(line.raw, line.lineNumber)); + if (lines.length > 0) { + const last = lines.at(-1); + if (!(last?.kind === "command" && last.command === "endtip")) { + const lineNumber = sourceLines.at(-1)?.lineNumber ?? 1; + // Native provenance: `Torappu.AVG.AVGParser.TryParse(string, List)` + // and `Torappu.AVG.AVGUtils.GenerateEndtipCommand`. Ports the implicit + // terminal command for a non-empty script. + lines.push({ + args: { block: true }, + command: "endtip", + content: "", + kind: "command", + lineNumber, + paramPresent: true, + raw: "[endtip(block=true)]", + trailingText: "", + }); + } + } + return lines; +} + +function metadataValue( + args: StoryCommandArgs, + key: string, +): StoryCommandValue | undefined { + return args[key]; +} + +export function parseStory(source: string | readonly string[]): { + lines: ParsedLine[]; + metadata: StoryMetadata; +} { + const lines = parseScript(source); + const first = lines[0]; + const header = + first?.kind === "command" && first.command === "header" ? first : undefined; + const fitMode = String( + metadataValue(header?.args ?? {}, "fit_mode") ?? "DEFAULT", + ).toUpperCase(); + const isTutorial = metadataValue(header?.args ?? {}, "is_tutorial") === true; + const isVideoOnly = + metadataValue(header?.args ?? {}, "is_video_only") === true; + const autoable = metadataValue(header?.args ?? {}, "is_autoable"); + const skippable = metadataValue(header?.args ?? {}, "is_skippable"); + return { + lines, + metadata: { + args: header?.args ?? {}, + characterSortType: String( + metadataValue(header?.args ?? {}, "char_sort_type") ?? + "BY_GAIN_TIME_DOWN", + ).toUpperCase(), + denyAutoSwitchScene: + metadataValue(header?.args ?? {}, "deny_auto_switch_scene") === true, + dontClearGameObjectPoolOnStart: + metadataValue( + header?.args ?? {}, + "dont_clear_gameobjectpool_onstart", + ) === true, + fitMode: fitMode === "BLACK_MASK" ? "BLACK_MASK" : "DEFAULT", + // Native provenance: `Torappu.AVG.AVGParser.TryParse(string, StoryParam, + // out Story)`. Ports the HEADER-to-Story metadata mapping: the story id + // comes from `key`, rather than the unrelated `id` parameter name. + id: String(metadataValue(header?.args ?? {}, "key") ?? ""), + isAutoable: + typeof autoable === "boolean" ? autoable : !isTutorial && !isVideoOnly, + isSkippable: typeof skippable === "boolean" ? skippable : !isTutorial, + isTutorial, + isVideoOnly, + title: header?.content ?? "", + }, + }; +} diff --git a/src/widgets/StoryPlayer/engine/preload.ts b/src/widgets/StoryPlayer/engine/preload.ts new file mode 100644 index 00000000..deba32a9 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/preload.ts @@ -0,0 +1,359 @@ +import { Assets } from "pixi.js"; + +import { + resolveAssetUrl, + resolveStoryAssetByKey, + resolveStoryAudioByKey, + resolveStoryCharacterAssetByKey, +} from "./asset"; +import { parseScript } from "./parser"; +import { DIALOG_FRAME_URL } from "./renderer"; + +import type { Context } from "../context"; + +function argAsString(raw: unknown): string { + return typeof raw === "string" ? raw.trim() : ""; +} + +function resolveGroupedImageSelection( + imageGroup: string, + cgGroup: string, + isBackgroundCommand: boolean, +): { asBackground: boolean; group: string } | null { + if (imageGroup) + return { asBackground: isBackgroundCommand, group: imageGroup }; + if (cgGroup) return { asBackground: false, group: cgGroup }; + return null; +} + +function addImageUrl( + urls: Set, + rawKey: string, + asBackground: boolean, +): void { + const key = rawKey.trim(); + if (!key) return; + + const rawUrl = resolveStoryAssetByKey(key, asBackground); + if (!rawUrl) return; + urls.add(resolveAssetUrl(rawUrl)); +} + +function addAudioUrl( + urls: Set, + context: Context, + rawKey: string, +): void { + const key = rawKey.trim(); + if (!key) return; + + const rawUrl = resolveStoryAudioByKey(key, context.audioVariables); + if (!rawUrl) return; + urls.add(rawUrl); +} + +function addCharacterUrl(urls: Set, rawKey: string): void { + const key = rawKey.trim(); + if (!key) return; + + const rawUrl = resolveStoryCharacterAssetByKey(key); + if (!rawUrl) return; + urls.add(resolveAssetUrl(rawUrl)); +} + +/** + * Native provenance: the active panels' `IContainsResRefs` collectors, + * including `AVGShowItemPanel.InternalResRefCollector.GatherResRefs`, + * `AVGCgItemPanel.InternalResRefCollector.GatherResRefs`, and the image / + * background / character panel collectors. + * + * Ports which script commands declare image dependencies. A single eager PIXI + * batch substitutes for native asset-reference collection and loading. + * + */ +function addScriptImageUrls(urls: Set, context: Context): void { + for (const line of parseScript(context.scriptText ?? context.script)) { + if (line.kind !== "command") continue; + + const image = argAsString(line.args.image); + if (line.command === "background") { + addImageUrl(urls, image, true); + continue; + } + if (line.command === "avgdisplay") { + const rawStyle = argAsString(line.args.style); + const styleMap: Record = { 5: "bg", 7: "character" }; + const style = styleMap[rawStyle] ?? rawStyle; + const name = argAsString(line.args.name); + if (style === "bg") addImageUrl(urls, name, true); + else if (style === "character") addCharacterUrl(urls, name); + continue; + } + if ( + line.command === "image" || + line.command === "showitem" || + line.command === "cgitem" || + line.command === "blocker" + ) { + addImageUrl(urls, image, false); + continue; + } + if (line.command === "interlude") { + const type = argAsString(line.args.type); + const numericType = + typeof line.args.type === "number" ? line.args.type : Number(type); + const name = argAsString(line.args.name); + if (type === "bg" || numericType === 2) addImageUrl(urls, name, true); + else if (type === "uichar" || numericType === 1) + addImageUrl(urls, name, false); + continue; + } + + if ( + line.command === "gridbg" || + line.command === "verticalbg" || + line.command === "largebg" || + line.command === "largeimg" + ) { + const imageGroup = argAsString(line.args.imagegroup); + const cgGroup = argAsString(line.args.cggroup); + const groupSelection = resolveGroupedImageSelection( + imageGroup, + cgGroup, + line.command.endsWith("bg"), + ); + if (!groupSelection) continue; + for (const key of groupSelection.group.split("/")) + addImageUrl(urls, key, groupSelection.asBackground); + } + } +} + +/** + * Native provenance: `Torappu.AVG.InternalMusicRefCollector` and + * `Torappu.AVG.InternalSoundRefCollector.GatherResRefs`. + * + * Ports music key/intro and sound key discovery; browser preload timing and + * progress reporting are web-only behavior. + * + */ +function addScriptAudioUrls(urls: Set, context: Context): void { + for (const line of parseScript(context.scriptText ?? context.script)) { + if (line.kind !== "command") continue; + + if (line.command !== "playmusic" && line.command !== "playsound") continue; + + addAudioUrl(urls, context, argAsString(line.args.key)); + if (line.command === "playmusic") + addAudioUrl(urls, context, argAsString(line.args.intro)); + } +} + +/** + * Native provenance: `Torappu.AVG.AVGCharacterslotPanel` character-reference + * resolution and `Torappu.ResourceRouter.GetCharacterPath`. + * + * Ports the story-facing name forms needed to identify character assets. The + * `character.json` link-map representation and face-overlay reconstruction are + * web asset-pipeline adaptations, not a direct native data structure. + * + */ +function resolveCharacterSelection( + context: Context, + rawRef: string, +): { base: string; expression: string } | null { + const cleaned = rawRef.trim(); + if (!cleaned) return null; + const normalized = cleaned.toLowerCase(); + + const directLink = context.linkMap[normalized]; + if (directLink?.array[0]?.name) { + return { + base: normalized, + expression: directLink.array[0].name, + }; + } + + for (const [base, link] of Object.entries(context.linkMap)) { + const prefix = `${base}-`; + if (!normalized.startsWith(prefix)) continue; + + const expression = normalized.slice(prefix.length); + if (!expression) continue; + + const found = link.array.find( + (item) => item.name.toLowerCase() === expression, + ); + if (!found) continue; + + return { base, expression: found.name }; + } + + const match = normalized.match( + /^([^@#$]+)(?:#(\d+)(?:\$(\d+))?|@([\w-]+)|\$(\d+))?$/, + ); + if (!match) return null; + + const base = match[1]; + const link = context.linkMap[base]; + if (!link) return null; + + const hashIndex = match[2] ? Number.parseInt(match[2], 10) : undefined; + const hashGroup = match[3] ? Number.parseInt(match[3], 10) : undefined; + const alias = match[4]; + const dollarGroup = match[5] ? Number.parseInt(match[5], 10) : undefined; + + const pickInGroup = (group: number, index = 1): string | null => { + const grouped = link.array.filter((entry) => + entry.name.endsWith(`$${group}`), + ); + if (grouped.length === 0) return null; + return grouped[Math.max(0, index - 1)]?.name ?? grouped[0].name; + }; + + if (alias) { + const found = link.array.find( + (item) => String(item.alias).toLowerCase() === alias, + ); + const expression = found?.name ?? link.array[0]?.name ?? null; + if (!expression) return null; + return { base, expression }; + } + + if (hashGroup) { + const expression = pickInGroup(hashGroup, hashIndex || 1); + if (!expression) return null; + return { base, expression }; + } + + if (dollarGroup) { + const expression = pickInGroup(dollarGroup, 1); + if (!expression) return null; + return { base, expression }; + } + + if (hashIndex) { + const found = link.array[Math.max(0, hashIndex - 1)] ?? link.array[0]; + if (!found?.name) return null; + return { base, expression: found.name }; + } + + if (!link.array[0]?.name) return null; + return { base, expression: link.array[0].name }; +} + +function addCharacterUrls(urls: Set, context: Context): void { + for (const line of parseScript(context.scriptText ?? context.script)) { + if (line.kind !== "command") continue; + + const refs: string[] = []; + if (line.command === "charslot") refs.push(argAsString(line.args.name)); + else if (line.command === "character") + refs.push(argAsString(line.args.name), argAsString(line.args.name2)); + else if ( + line.command === "interlude" && + (line.args.type === 3 || argAsString(line.args.type) === "char") + ) + refs.push(argAsString(line.args.name)); + else continue; + + for (const nameRef of refs) { + if (!nameRef) continue; + + const resolved = resolveCharacterSelection(context, nameRef); + if (!resolved) continue; + + const link = context.linkMap[resolved.base]; + if (!link) continue; + + const item = link.array.find( + (entry) => entry.name === resolved.expression, + ); + if (!item) continue; + + if (item.group === -1 && "image" in item && item.image) { + addCharacterUrl(urls, item.image); + continue; + } + + if (!("face" in item) || !item.face || item.group < 0) continue; + + addCharacterUrl(urls, item.face); + + const group = link.groups[item.group]; + if (group?.mode === "face_overlay") addCharacterUrl(urls, group.base); + } + } +} + +function uniqueResolvedUrls(context: Context): string[] { + const urls = new Set(); + if (context.charMap) { + for (const rawUrl of Object.values(context.charMap)) + urls.add(resolveAssetUrl(rawUrl)); + } + + addCharacterUrls(urls, context); + + addScriptAudioUrls(urls, context); + addScriptImageUrls(urls, context); + + // 固定 UI 资源(对话框上下黑条纹理),随每次 preload 一起预热进 pixi Assets 缓存, + // renderer 在 createUi 时再 Assets.load 即可命中缓存。 + urls.add(DIALOG_FRAME_URL); + + return [...urls]; +} + +export async function preloadContextAssets( + context: Context, + onProgress?: (progress: number) => void, +): Promise { + const urls = uniqueResolvedUrls(context); + console.log(`[preload] ${urls.length} unique asset URL(s) collected`); + if (urls.length === 0) { + console.warn( + "[preload] no assets resolved — script may have no collectable commands or resolvers returned empty", + ); + onProgress?.(1); + return; + } + + // 打印前若干条 URL 便于核对路径前缀和大小写,过长则只展示首尾各几条。 + const preview = + urls.length <= 8 + ? urls + : [ + ...urls.slice(0, 5), + `... (${urls.length - 7} more)`, + ...urls.slice(-2), + ]; + for (const [index, url] of preview.entries()) + console.log(`[preload] [${index}] ${url}`); + + let lastReported = -1; + await Assets.load(urls, { + onProgress: (progress) => { + const percent = Math.round(progress * 100); + // 只在整百分比变化时打印,避免 100 条资源刷屏。 + if (percent !== lastReported) { + lastReported = percent; + console.log( + `[preload] progress ${percent}% (${Math.round(progress * urls.length)}/${urls.length})`, + ); + } + onProgress?.(progress); + }, + onError: (error, url) => { + const urlStr = + typeof url === "string" + ? url + : ((url as { src?: string })?.src ?? String(url)); + console.warn( + `[preload] asset failed: ${urlStr}`, + error?.message ?? error, + ); + }, + }); + console.log(`[preload] done: ${urls.length} asset(s) resolved`); +} diff --git a/src/widgets/StoryPlayer/engine/renderer.ts b/src/widgets/StoryPlayer/engine/renderer.ts new file mode 100644 index 00000000..6dc212ea --- /dev/null +++ b/src/widgets/StoryPlayer/engine/renderer.ts @@ -0,0 +1,4 @@ +export { + DIALOG_FRAME_URL, + PixiStoryRenderer, +} from "./rendering/PixiStoryRenderer"; diff --git a/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts new file mode 100644 index 00000000..acfa9bfb --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/PixiStoryRenderer.ts @@ -0,0 +1,3677 @@ +import { + Application, + Assets, + ColorMatrixFilter, + Container, + FillGradient, + Graphics, + Sprite, + Text, + TextStyle, + Texture, +} from "pixi.js"; + +import { + resolveAssetUrl, + resolveStoryAssetByKey, + resolveStoryCharacterAssetByKey, +} from "../asset"; +import { + buildTagStyles, + collectColors, + parseRichChars, + richCharsToTaggedText, + type RichChar, +} from "../richtext"; +import { computeLegacyShowItemLayout } from "../showitem"; +import { + STORY_HEIGHT, + STORY_WIDTH, + type AnimTextInput, + type AvgDisplayInput, + type BackgroundInput, + type BackgroundTweenInput, + type BlockerInput, + type CameraShakeInput, + type CgItemInput, + type CharacterActionInput, + type CharacterCutinInput, + type CharacterSlotInput, + type CurtainInput, + type FocusOutInput, + type FocusParamInput, + type GridBackgroundInput, + type ImageRotateInput, + type ImageTweenInput, + type InterludeInput, + type LargeBackgroundTweenInput, + type ShowItemInput, + type SpellStickerInput, + type StickerInput, + type StoryRenderer, + type SubtitleInput, + type TimerClearInput, + type TimerStickerInput, +} from "../types"; + +import { LayerGraph } from "./core/LayerGraph"; +import { + applyCenteredTransform as applyCenteredTransformToRoot, + buildGridBackgroundRoot as buildGridRoot, + readCenteredTransform as readRootTransform, + rotateTweenDelta, +} from "./core/SceneGeometry"; +import { buildShakePath, sampleShakePath } from "./core/ShakePath"; +import { TweenRunner } from "./core/TweenRunner"; +import { AnimTextPanel } from "./panels/AnimTextPanel"; +import { AvgDisplayPanel } from "./panels/AvgDisplayPanel"; +import { CgItemPanel } from "./panels/CgItemPanel"; +import { DecisionPanel } from "./panels/DecisionPanel"; +import { DialogPanel } from "./panels/DialogPanel"; +import { FocusEffectPanel } from "./panels/FocusEffectPanel"; +import { InterludePanel } from "./panels/InterludePanel"; +import { SpellStickerPanel } from "./panels/SpellStickerPanel"; +import { VideoPanel } from "./panels/VideoPanel"; + +import type { Context } from "../../context"; + +function isFiniteNumber(value: number | undefined): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +/** Width of AVGCurtain's `_gradientImg` feather strip, in logical pixels. */ +const CURTAIN_GRADIENT_PX = 20; + +const CURTAIN_STAGE_CORNERS: ReadonlyArray<{ x: number; y: number }> = [ + { x: 0, y: 0 }, + { x: STORY_WIDTH, y: 0 }, + { x: STORY_WIDTH, y: STORY_HEIGHT }, + { x: 0, y: STORY_HEIGHT }, +]; + +/** + * 对话框顶部/底部黑条用到的同一张渐变纹理(APK 中 `sprite_avg_cutscene`, + * 4×310 RGBA,自上而下 alpha 0→1)。放在 `public/` 下由 Vite 直接服务, + * 预加载由 preload.ts 把该 URL 加入预加载集来预热 pixi Assets 缓存。 + */ + +interface CharacterBuiltVisual { + sourceHeight: number; + sourceWidth: number; + visual: Container; +} + +interface CharacterRenderState { + actionX: number; + actionY: number; + baseScaleX: number; + baseScaleY: number; + baseX: number; + baseY: number; + characterKey?: string; + contentAlpha: number; + focusBrightness: number; + expression?: string; + height: number; + jumpOffsetY: number; + jumpSessionId: number; + motionLayer: Container; + opacitySessionId: number; + replaceFadeSessionId: number; + root: Container; + rotationDeg: number; + rotationLayer: Container; + rotateSessionId: number; + rotateTimeout: ReturnType | null; + scaleX: number; + scaleY: number; + shakeOffsetX: number; + shakeOffsetY: number; + shakeSessionId: number; + shakeTimeout: ReturnType | null; + slot: string; + sourceHeight: number; + sourceWidth: number; + transformSessionId: number; + visual: Container; + width: number; +} + +interface CurtainRenderState { + alpha: number; + direction: number; + fill: number; + grad: boolean; + graphic: Graphics; + tweenSessionId: number; +} + +/** + * Web/PIXI renderer for the AVG command surfaces. Each command method ports + * the documented observable state and blocking boundaries, while containers, + * filters, and browser timing are adaptations rather than a Unity scene port. + * Command-specific native provenance is recorded at the behavior boundaries. + */ +export class PixiStoryRenderer implements StoryRenderer { + private app: Application | null = null; + private readonly layers = new LayerGraph(); + private readonly backgroundLayer = this.layers.background; + private backgroundRoot: Container | null = null; + /** Current background visual; exposed for renderer diagnostics and tests. */ + backgroundSprite: Sprite | null = null; + private backgroundTweenSessionId = 0; + private readonly context: Context; + private readonly charLayer = this.layers.characters; + private readonly characterSlots = new Map(); + private readonly cutinLayer = this.layers.cutins; + private readonly cutinStates = new Map< + string, + { fadeStyle: number; sessionId: number; sprite: Sprite } + >(); + private readonly cutinStoredPositions = new Map< + string, + { + endX: number; + endY: number; + fullHeight: number; + fullWidth: number; + startX: number; + startY: number; + } + >(); + + private readonly curtainLayer = this.layers.curtains; + private readonly curtains = new Map(); + private readonly gridBackgroundLayer = this.layers.gridBackground; + private gridBackgroundSessionId = 0; + private largeBackgroundRoot: Container | null = null; + private largeBackgroundTweenSessionId = 0; + private readonly imageLayer = this.layers.images; + private largeImageRoot: Container | null = null; + private readonly largeImageRoots = new Set(); + private largeImageSessionId = 0; + private largeImageTweenSessionId = 0; + private imageSprite: Sprite | null = null; + private imageRotateSessionId = 0; + private readonly itemLayer = this.layers.items; + private readonly onWarning?: (detail: string) => void; + private readonly sceneLayer = this.layers.scene; + private readonly uiLayer = this.layers.ui; + private readonly worldLayer = this.layers.world; + private readonly tweenRunner = new TweenRunner(() => Boolean(this.app)); + private readonly videoPanel: VideoPanel; + private readonly dialogPanel: DialogPanel; + private readonly decisionPanel: DecisionPanel; + private readonly interludePanel: InterludePanel; + private readonly animTextPanel: AnimTextPanel; + private readonly avgDisplayPanel: AvgDisplayPanel; + private readonly spellStickerPanel: SpellStickerPanel; + private readonly focusEffectPanel: FocusEffectPanel; + private readonly cgItemPanel: CgItemPanel; + + private blockerSprite: Sprite | null = null; + private cameraShakeSessionId = 0; + private cameraShakeWaitResolve: (() => void) | null = null; + private grayscaleAmount = 0; + private inverseAmount = 0; + private grayscaleFilter: ColorMatrixFilter | null = null; + private readonly stickerFadeSessionIds = new Map(); + private readonly stickerTexts = new Map(); + private readonly stickerTypingTargets = new Map< + string, + { + alignment: StickerInput["alignment"]; + baseX: number; + fullText: string; + widthPx: number; + } + >(); + private readonly stickerTypingSessionIds = new Map(); + private subtitleFadeSessionId = 0; + private subtitleText: Text | null = null; + private subtitleTypingTarget: { + alignment: SubtitleInput["alignment"]; + baseX: number; + fullText: string; + widthPx: number; + } | null = null; + private subtitleTypingSessionId = 0; + private readonly stickerRichChars = new Map(); + private timerStickerInterval: ReturnType | null = null; + private timerStickerText: Text | null = null; + + constructor(context: Context, onWarning?: (detail: string) => void) { + this.context = context; + this.onWarning = onWarning; + this.videoPanel = new VideoPanel(this.uiLayer, onWarning); + this.dialogPanel = new DialogPanel(this.uiLayer, onWarning); + this.decisionPanel = new DecisionPanel(this.uiLayer); + this.interludePanel = new InterludePanel( + this.cutinLayer, + (input) => this.textureForInterlude(input), + (durationMs, update, complete) => + this.tween(durationMs, update, complete), + onWarning, + ); + this.animTextPanel = new AnimTextPanel( + this.itemLayer, + (durationMs, update, complete) => + this.tween(durationMs, update, complete), + onWarning, + ); + this.avgDisplayPanel = new AvgDisplayPanel( + { + bgover: this.layers.avgDisplayBackground, + cgover: this.layers.avgDisplayCg, + charover: this.layers.avgDisplayCharacter, + }, + (name) => this.textureForImageKey(name, "background"), + (durationMs, update, complete) => + this.tween(durationMs, update, complete), + onWarning, + ); + this.spellStickerPanel = new SpellStickerPanel(this.uiLayer, onWarning); + this.focusEffectPanel = new FocusEffectPanel( + (type, id) => this.resolveFocusTargets(type, id), + (durationMs, update, complete) => + this.tween(durationMs, update, complete), + ); + this.cgItemPanel = new CgItemPanel( + this.layers.cgItems, + (key) => this.textureForImageKey(key, "image"), + (duration, update, complete) => this.tween(duration, update, complete), + onWarning, + ); + } + + async mount(host: HTMLElement): Promise { + if (this.app) return; + + const app = new Application(); + await app.init({ + antialias: true, + autoDensity: true, + background: "#000000", + height: STORY_HEIGHT, + preference: "webgpu", + width: STORY_WIDTH, + }); + + host.innerHTML = ""; + host.style.position = "relative"; + app.canvas.style.width = "100%"; + app.canvas.style.height = "100%"; + app.canvas.style.display = "block"; + host.append(app.canvas); + this.videoPanel.mount(host); + + this.app = app; + + await this.createUi(); + this.layers.attach(app.stage); + + this.setDialogue("", ""); + } + + destroy(): void { + this.stopVideo(); + this.stopCameraShake(); + this.decisionPanel.destroy(); + this.interludePanel.destroy(); + this.animTextPanel.destroy(); + this.avgDisplayPanel.destroy(); + this.spellStickerPanel.destroy(); + this.focusEffectPanel.destroy(); + this.cgItemPanel.destroy(); + this.backgroundRoot = null; + this.backgroundSprite = null; + this.backgroundTweenSessionId += 1; + this.backgroundLayer.removeChildren(); + this.gridBackgroundLayer.removeChildren(); + // panel_large_background is a permanent sibling in front of panel_background, + // so it goes back as child 0 rather than being re-appended on each gridbg. + this.backgroundLayer.addChild(this.gridBackgroundLayer); + this.blockerSprite = null; + this.gridBackgroundSessionId += 1; + this.largeBackgroundRoot = null; + this.largeBackgroundTweenSessionId += 1; + this.largeImageRoot = null; + this.largeImageSessionId += 1; + this.largeImageTweenSessionId += 1; + this.largeImageRoots.clear(); + this.imageSprite = null; + this.imageLayer.removeChildren(); + this.itemLayer.removeChildren(); + for (const state of this.cutinStates.values()) state.sprite.destroy(); + this.cutinStates.clear(); + this.cutinStoredPositions.clear(); + this.cutinLayer.removeChildren(); + for (const state of this.characterSlots.values()) + this.disposeCharacterState(state); + this.characterSlots.clear(); + for (const state of this.curtains.values()) this.disposeCurtainState(state); + this.curtains.clear(); + this.app?.destroy(true, { children: true }); + this.app = null; + this.dialogPanel.destroy(); + this.subtitleText = null; + this.timerStickerText = null; + this.clearTimerInterval(); + this.stickerTexts.clear(); + this.stickerFadeSessionIds.clear(); + this.stickerTypingTargets.clear(); + this.stickerTypingSessionIds.clear(); + this.subtitleFadeSessionId += 1; + this.subtitleTypingTarget = null; + this.subtitleTypingSessionId += 1; + this.stickerRichChars.clear(); + this.videoPanel.destroy(); + } + + async playVideo(url: string): Promise { + await this.videoPanel.play(url); + } + + async setAnimText(input: AnimTextInput): Promise { + await this.animTextPanel.show(input); + } + + async setAvgDisplay(input: AvgDisplayInput): Promise { + await this.avgDisplayPanel.display(input); + } + + clearAvgDisplays(): void { + this.avgDisplayPanel.clear(); + } + + clearAnimTexts(): void { + this.animTextPanel.clear(); + } + + stopVideo(): void { + this.videoPanel.stop(); + } + + finishTextTyping(): boolean { + if (this.subtitleText && this.subtitleTypingTarget) { + this.subtitleTypingSessionId += 1; + this.subtitleText.text = this.subtitleTypingTarget.fullText; + this.layoutSubtitle( + this.subtitleText, + this.subtitleTypingTarget.baseX, + this.subtitleTypingTarget.widthPx, + this.subtitleTypingTarget.alignment, + ); + this.subtitleTypingTarget = null; + return true; + } + + for (const [id, target] of this.stickerTypingTargets.entries()) { + const sticker = this.stickerTexts.get(id); + if (!sticker) continue; + this.stickerTypingSessionIds.set( + id, + (this.stickerTypingSessionIds.get(id) ?? 0) + 1, + ); + sticker.text = target.fullText; + this.layoutSubtitle( + sticker, + target.baseX, + target.widthPx, + target.alignment, + ); + this.stickerTypingTargets.delete(id); + return true; + } + + return false; + } + + setDialogue( + speaker: string, + text: string, + tagStyles?: Record, + ): void { + this.dialogPanel.setDialogue(speaker, text, tagStyles); + } + + async showDecision(options: string[], values: number[]): Promise { + return this.decisionPanel.show(options, values); + } + + /** + * Port scope: `Torappu.AVG.AVGImagePanel._ExecuteImage` / `_LoadImage` for + * the `background` path: replacement, initial transform, cross-fade, and + * block boundary. PIXI roots replace the two Unity Image widgets. + */ + async setBackground(key: string, input?: BackgroundInput): Promise { + const texture = await this.textureForImageKey(key, "background"); + if (!texture) return; + + this.backgroundTweenSessionId += 1; + const root = new Container(); + const sprite = new Sprite(texture); + sprite.anchor.set(0.5); + this.layoutImageForScreenAdapt(sprite, input?.screenAdapt, true); + this.applyCenteredTransform(root, { + scaleX: input?.scaleX ?? 1.2, + scaleY: input?.scaleY ?? 1.2, + x: input?.x ?? 0, + y: input?.y ?? 0, + }); + root.addChild(sprite); + + const previous = this.backgroundRoot; + this.backgroundRoot = root; + this.backgroundSprite = sprite; + this.backgroundLayer.addChild(root); + const fadeMs = input?.fadeMs ?? 0; + root.alpha = fadeMs > 0 ? 0 : 1; + if (fadeMs <= 0) { + previous?.removeFromParent(); + return; + } + const run = this.tween( + fadeMs, + (progress) => (root.alpha = progress), + () => previous?.removeFromParent(), + ); + if (input?.block) await run; + else void run; + } + + async clearBackground(fadeMs = 0, block = false): Promise { + this.backgroundTweenSessionId += 1; + const root = this.backgroundRoot; + this.backgroundRoot = null; + this.backgroundSprite = null; + if (!root) return; + if (fadeMs <= 0) { + root.removeFromParent(); + return; + } + const startAlpha = root.alpha; + const run = this.tween( + fadeMs, + (progress) => (root.alpha = startAlpha * (1 - progress)), + () => root.removeFromParent(), + ); + if (block) await run; + else void run; + } + + /** + * Port of `Torappu.AVG.AVGImagePanel._ExecuteImageTween`'s foreground + * transform semantics. Browser interpolation replaces the DOTween sequence. + */ + async setBackgroundTween(input: BackgroundTweenInput): Promise { + const root = this.backgroundRoot; + if (!root || root.parent !== this.backgroundLayer) return; + + const current = this.readCenteredTransform(root); + const from = { + scaleX: isFiniteNumber(input.xScaleFrom) + ? input.xScaleFrom + : current.scaleX, + scaleY: isFiniteNumber(input.yScaleFrom) + ? input.yScaleFrom + : current.scaleY, + x: isFiniteNumber(input.xFrom) ? input.xFrom : current.x, + y: isFiniteNumber(input.yFrom) ? input.yFrom : current.y, + }; + const to = { + scaleX: isFiniteNumber(input.xScaleTo) ? input.xScaleTo : current.scaleX, + scaleY: isFiniteNumber(input.yScaleTo) ? input.yScaleTo : current.scaleY, + x: isFiniteNumber(input.xTo) ? input.xTo : current.x, + y: isFiniteNumber(input.yTo) ? input.yTo : current.y, + }; + + const sessionId = ++this.backgroundTweenSessionId; + this.applyCenteredTransform(root, from); + + if (input.durationMs <= 0) { + this.applyCenteredTransform(root, to); + return; + } + + const run = this.tween( + input.durationMs, + (progress) => { + if (!this.isActiveBackground(root, sessionId)) return; + this.applyCenteredTransform(root, { + scaleX: from.scaleX + (to.scaleX - from.scaleX) * progress, + scaleY: from.scaleY + (to.scaleY - from.scaleY) * progress, + x: from.x + (to.x - from.x) * progress, + y: from.y + (to.y - from.y) * progress, + }); + }, + () => { + if (!this.isActiveBackground(root, sessionId)) return; + this.applyCenteredTransform(root, to); + }, + ); + + if (input.block) await run; + else void run; + } + + /** + * Port scope: `Torappu.AVG.LargeBackgroundPanel._ExecuteGridBG` and + * `_LoadImage`, including all-or-nothing asset loading and replacement. + * `Container` composition is the Web adaptation of Unity RectTransforms. + */ + async setGridBackground(input: GridBackgroundInput): Promise { + const sessionId = ++this.gridBackgroundSessionId; + this.largeBackgroundTweenSessionId += 1; + const textures = await Promise.all( + input.imageKeys.map((key) => + this.textureForImageKey(key, input.assetKind ?? "background"), + ), + ); + if (!this.app || sessionId !== this.gridBackgroundSessionId) return; + + if (textures.some((texture) => !texture)) return; + + const root = this.buildGridBackgroundRoot(input, textures as Texture[]); + const previous = [...this.gridBackgroundLayer.children]; + this.largeBackgroundRoot = input.layout === "large" ? root : null; + + root.alpha = input.fadeMs > 0 ? 0 : 1; + this.gridBackgroundLayer.addChild(root); + + if (input.fadeMs <= 0) { + for (const child of previous) child.removeFromParent(); + return; + } + + const run = this.tween( + input.fadeMs, + (progress) => { + if (!this.app || sessionId !== this.gridBackgroundSessionId) return; + root.alpha = progress; + }, + () => { + if (!this.app || sessionId !== this.gridBackgroundSessionId) return; + root.alpha = 1; + for (const child of previous) child.removeFromParent(); + }, + ); + + if (input.block) await run; + else void run; + } + + async clearGridBackground(fadeMs = 0, block = false): Promise { + const sessionId = ++this.gridBackgroundSessionId; + this.largeBackgroundRoot = null; + this.largeBackgroundTweenSessionId += 1; + const roots = [...this.gridBackgroundLayer.children]; + if (roots.length === 0) return; + + if (fadeMs <= 0) { + for (const root of roots) root.removeFromParent(); + return; + } + + const startAlphas = roots.map((root) => root.alpha); + const run = this.tween( + fadeMs, + (progress) => { + if (!this.app || sessionId !== this.gridBackgroundSessionId) return; + const nextAlpha = 1 - progress; + for (const [index, root] of roots.entries()) { + root.alpha = startAlphas[index]! * nextAlpha; + } + }, + () => { + if (!this.app || sessionId !== this.gridBackgroundSessionId) return; + for (const root of roots) root.removeFromParent(); + }, + ); + + if (block) await run; + else void run; + } + + /** + * Web-only legacy compatibility surface. The investigated client has no + * `largeimg` command or corresponding native executor, so this must not be + * represented as a port; it remains isolated from the real `image` path. + */ + async setLargeImage(input: GridBackgroundInput): Promise { + const sessionId = ++this.largeImageSessionId; + this.largeImageTweenSessionId += 1; + const textures = await Promise.all( + input.imageKeys.map((key) => + this.textureForImageKey(key, input.assetKind ?? "image"), + ), + ); + if (!this.app || sessionId !== this.largeImageSessionId) return; + + if (textures.some((texture) => !texture)) return; + + const root = this.buildGridBackgroundRoot(input, textures as Texture[]); + // largeimg is a legacy-only command and retains its existing transform. + root.scale.set(1.2); + const previous = [...this.largeImageRoots]; + this.largeImageRoot = root; + + root.alpha = input.fadeMs > 0 ? 0 : 1; + this.largeImageRoots.add(root); + this.imageLayer.addChild(root); + + if (input.fadeMs <= 0) { + for (const child of previous) { + child.removeFromParent(); + this.largeImageRoots.delete(child); + } + return; + } + + const run = this.tween( + input.fadeMs, + (progress) => { + if (!this.app || sessionId !== this.largeImageSessionId) return; + root.alpha = progress; + }, + () => { + if (!this.app || sessionId !== this.largeImageSessionId) return; + root.alpha = 1; + for (const child of previous) { + child.removeFromParent(); + this.largeImageRoots.delete(child); + } + }, + ); + + if (input.block) await run; + else void run; + } + + async clearLargeImage(fadeMs = 0, block = false): Promise { + const sessionId = ++this.largeImageSessionId; + this.largeImageRoot = null; + this.largeImageTweenSessionId += 1; + const roots = [...this.largeImageRoots]; + if (roots.length === 0) return; + + if (fadeMs <= 0) { + for (const root of roots) { + root.removeFromParent(); + this.largeImageRoots.delete(root); + } + return; + } + + const startAlphas = roots.map((root) => root.alpha); + const run = this.tween( + fadeMs, + (progress) => { + if (!this.app || sessionId !== this.largeImageSessionId) return; + const nextAlpha = 1 - progress; + for (const [index, root] of roots.entries()) { + root.alpha = startAlphas[index]! * nextAlpha; + } + }, + () => { + if (!this.app || sessionId !== this.largeImageSessionId) return; + for (const root of roots) { + root.removeFromParent(); + this.largeImageRoots.delete(root); + } + }, + ); + + if (block) await run; + else void run; + } + + /** + * Port scope: `Torappu.AVG.AVGImagePanel._ExecuteImage` / `_LoadImage` for + * the `image` path. Sprite replacement and fade behavior are preserved; + * PIXI geometry is an adaptation of the native Image/RectTransform pair. + */ + async setImage(key: string, input?: BackgroundInput): Promise { + const texture = await this.textureForImageKey(key, "image"); + if (!texture) return; + + this.imageRotateSessionId += 1; + const sprite = new Sprite(texture); + sprite.anchor.set(0.5); + this.layoutImageForScreenAdapt(sprite, input?.screenAdapt); + sprite.position.set( + STORY_WIDTH / 2 + (input?.x ?? 0), + STORY_HEIGHT / 2 - (input?.y ?? 0), + ); + sprite.scale.x *= input?.scaleX ?? 1; + sprite.scale.y *= input?.scaleY ?? 1; + + const previous = this.imageSprite; + this.imageSprite = sprite; + this.imageLayer.addChild(sprite); + const fadeMs = input?.fadeMs ?? 0; + sprite.alpha = fadeMs > 0 ? 0 : 1; + if (fadeMs <= 0) { + previous?.removeFromParent(); + return; + } + const run = this.tween( + fadeMs, + (progress) => (sprite.alpha = progress), + () => previous?.removeFromParent(), + ); + if (input?.block) await run; + else void run; + } + + async clearImage(fadeMs = 0, block = false): Promise { + this.imageRotateSessionId += 1; + const sprite = this.imageSprite; + this.imageSprite = null; + if (!sprite) return; + if (fadeMs <= 0) { + sprite.removeFromParent(); + return; + } + const startAlpha = sprite.alpha; + const run = this.tween( + fadeMs, + (progress) => (sprite.alpha = startAlpha * (1 - progress)), + () => sprite.removeFromParent(), + ); + if (block) await run; + else void run; + } + + async clearCharacterCutin(widgetId?: string): Promise { + if (!widgetId) { + for (const state of this.cutinStates.values()) { + state.sessionId += 1; + state.sprite.destroy(); + } + this.cutinStates.clear(); + this.cutinLayer.removeChildren(); + return; + } + + const id = `cutin_${widgetId.replace(/ /g, "_")}`; + const state = this.cutinStates.get(id); + if (!state) return; + + state.sessionId += 1; + state.sprite.destroy(); + this.cutinStates.delete(id); + } + + async clearInterludes(): Promise { + await this.interludePanel.clearAll(); + } + + async setInterlude(input: InterludeInput): Promise { + await this.interludePanel.run(input); + } + + /** + * Port scope: `Torappu.AVG.AVGCharacterCutinPanel._ExecuteCharacterCutin` + * and `AVGCharacterCutinSlot.Show`/hide fade styles. Crop expansion is + * reproduced with PIXI dimensions and masks rather than Unity UI widgets. + */ + async setCharacterCutin(input: CharacterCutinInput): Promise { + if (!this.app) return; + + if (!input.characterKey || !input.expression) { + const id = `cutin_${input.widgetId.replace(/ /g, "_")}`; + const existing = this.cutinStates.get(id); + if (!existing) return; + + const sessionId = ++existing.sessionId; + const fadeStyle = existing.fadeStyle; + const sprite = existing.sprite; + const durationMs = input.fadeMs; + + if (fadeStyle === 0) { + const run = this.tween( + durationMs, + (progress) => { + if (existing.sessionId !== sessionId) return; + sprite.alpha = 1 - progress; + }, + () => { + if (existing.sessionId !== sessionId) return; + sprite.destroy(); + this.cutinStates.delete(id); + }, + ); + + if (input.block) await run; + else void run; + } else { + const stored = this.cutinStoredPositions.get(id); + if (!stored) { + sprite.destroy(); + this.cutinStates.delete(id); + return; + } + + const isHorizontal = fadeStyle >= 1 && fadeStyle <= 3; + const run = this.tween( + durationMs, + (progress) => { + if (existing.sessionId !== sessionId) return; + if (isHorizontal) { + sprite.width = stored.fullWidth * (1 - progress); + sprite.x = + stored.startX + (stored.endX - stored.startX) * progress; + if (sprite.children.length > 0) { + const mask = sprite.children[0] as Graphics; + mask + .clear() + .rect(0, 0, sprite.width, sprite.height) + .fill(0xff_ff_ff); + } + } else { + sprite.height = stored.fullHeight * (1 - progress); + sprite.y = + stored.startY + (stored.endY - stored.startY) * progress; + } + }, + () => { + if (existing.sessionId !== sessionId) return; + sprite.destroy(); + this.cutinStates.delete(id); + this.cutinStoredPositions.delete(id); + }, + ); + + if (input.block) await run; + else void run; + } + return; + } + + const link = this.context.linkMap[input.characterKey]; + if (!link) { + this.onWarning?.(`missing character base: ${input.characterKey}`); + return; + } + + const item = link.array.find((entry) => entry.name === input.expression); + if (!item) { + this.onWarning?.( + `missing character expression: ${input.characterKey}#${input.expression}`, + ); + return; + } + + let assetKey: string | null = null; + if (item.group === -1 && "image" in item && item.image) { + assetKey = item.image; + } else if (item.group >= 0 && "face" in item && item.face) { + const g = link.groups[item.group]; + assetKey = g && g.mode === "face_overlay" ? g.base : null; + } + + if (!assetKey) { + this.onWarning?.( + `invalid cutin character: ${input.characterKey}#${input.expression}`, + ); + return; + } + + const texture = await this.textureForCharacterKey(assetKey); + if (!texture) return; + + const id = `cutin_${input.widgetId.replace(/ /g, "_")}`; + const previous = this.cutinStates.get(id); + if (previous) { + previous.sessionId += 1; + previous.sprite.destroy(); + } + + const sprite = new Sprite(texture); + sprite.anchor.set(0, 0); + + const centerX = STORY_WIDTH / 2; + const cropW = input.width; + const cropH = input.height; + const halfW = cropW / 2; + const halfH = cropH / 2; + + const endLeft = centerX + input.offsetX - halfW; + const endTop = -input.offsetY; + + let fadeStyle: number; + let startLeft = endLeft; + let startTop = endTop; + + switch (input.fadeStyle) { + case "horiz_expand_center": { + fadeStyle = 1; + startLeft += halfW; + break; + } + case "horiz_expand_left2right": { + fadeStyle = 2; + break; + } + case "horiz_expand_right2left": { + fadeStyle = 3; + startLeft += cropW; + break; + } + case "vert_expand_center": { + fadeStyle = 4; + startTop += halfH; + break; + } + case "vert_expand_top2buttom": { + fadeStyle = 5; + break; + } + case "vert_expand_buttom2top": { + fadeStyle = 6; + startTop += cropH; + break; + } + default: { + fadeStyle = 0; + break; + } + } + + this.cutinStoredPositions.set(id, { + endX: endLeft, + endY: endTop, + fullHeight: cropH, + fullWidth: cropW, + startX: startLeft, + startY: startTop, + }); + + const durationMs = input.fadeMs; + const sessionId = previous ? previous.sessionId + 1 : 1; + + if (fadeStyle === 0) { + sprite.x = endLeft; + sprite.y = endTop; + sprite.width = cropW; + sprite.height = cropH; + sprite.alpha = 0; + this.cutinLayer.addChild(sprite); + + const state = { fadeStyle, sessionId, sprite }; + this.cutinStates.set(id, state); + + const run = this.tween(durationMs, (progress) => { + if (state.sessionId !== sessionId) return; + sprite.alpha = progress; + }); + + if (input.block) await run; + else void run; + } else { + const isHorizontal = fadeStyle >= 1 && fadeStyle <= 3; + sprite.x = startLeft; + sprite.y = startTop; + + if (isHorizontal) { + sprite.width = 0; + sprite.height = cropH; + } else { + sprite.width = cropW; + sprite.height = 0; + } + + this.cutinLayer.addChild(sprite); + + const state = { fadeStyle, sessionId, sprite }; + this.cutinStates.set(id, state); + + const run = this.tween(durationMs, (progress) => { + if (state.sessionId !== sessionId) return; + if (isHorizontal) { + const currentWidth = cropW * progress; + sprite.width = currentWidth; + sprite.x = startLeft + (endLeft - startLeft) * progress; + } else { + const currentHeight = cropH * progress; + sprite.height = currentHeight; + sprite.y = startTop + (endTop - startTop) * progress; + } + }); + + if (input.block) await run; + else void run; + } + } + + /** + * Port scope: `Torappu.AVG.AVGShowItemPanel._ExecuteShowItem` / `_ShowItem`. + * It retains the single-slot replacement and photo fade; PIXI drawing is an + * approximation of the serialized show-item prefab. + */ + async showItem(input: ShowItemInput): Promise { + const texture = await this.textureForImageKey(input.key, "image"); + if (!texture) return; + + // `AVGShowItemPanel._ExecuteShowItem` owns one slot, so a second showitem + // replaces the first rather than stacking on it. See the command evidence + // above; PIXI children are only the storage adaptation. + this.itemLayer.removeChildren(); + + const sprite = new Sprite(texture); + sprite.anchor.set(0.5); + + const layout = computeLegacyShowItemLayout(texture.width, texture.height); + sprite.scale.set(layout.scale); + + const border = new Graphics(); + border.rect( + -layout.contentWidth / 2 - layout.borderPx, + -layout.contentHeight / 2 - layout.borderPx, + layout.contentWidth + layout.borderPx * 2, + layout.borderPx, + ); + border.fill(0xff_ff_ff); + border.rect( + -layout.contentWidth / 2 - layout.borderPx, + layout.contentHeight / 2, + layout.contentWidth + layout.borderPx * 2, + layout.borderPx, + ); + border.fill(0xff_ff_ff); + border.rect( + -layout.contentWidth / 2 - layout.borderPx, + -layout.contentHeight / 2, + layout.borderPx, + layout.contentHeight, + ); + border.fill(0xff_ff_ff); + border.rect( + layout.contentWidth / 2, + -layout.contentHeight / 2, + layout.borderPx, + layout.contentHeight, + ); + border.fill(0xff_ff_ff); + + const root = new Container(); + root.alpha = input.fadeMs > 0 ? 0 : 1; + if (input.blackAlpha > 0) { + const backdrop = new Graphics(); + backdrop.rect(0, 0, STORY_WIDTH, STORY_HEIGHT); + backdrop.fill({ alpha: input.blackAlpha, color: 0x00_00_00 }); + root.addChild(backdrop); + } + + const content = new Container(); + content.position.set( + STORY_WIDTH / 2 + input.offsetX, + STORY_HEIGHT / 2 + input.offsetY, + ); + content.addChild(border); + content.addChild(sprite); + root.addChild(content); + this.itemLayer.addChild(root); + + if (input.fadeMs <= 0) return; + + const run = this.tween(input.fadeMs, (progress) => { + root.alpha = progress; + }); + + if (input.block) await run; + else void run; + } + + async showCgItem(input: CgItemInput): Promise { + await this.cgItemPanel.show(input); + } + + async clearCgItems( + key?: string, + fadeMs = 130, + ease = "OutQuad", + block = false, + ): Promise { + await this.cgItemPanel.hide(key, fadeMs, ease, block); + } + + async clearItems(fadeMs = 0, block = false): Promise { + const roots = [...this.itemLayer.children]; + if (roots.length === 0) return; + + if (fadeMs <= 0) { + for (const root of roots) root.removeFromParent(); + return; + } + + const startAlphas = roots.map((root) => root.alpha); + const run = this.tween( + fadeMs, + (progress) => { + const nextAlpha = 1 - progress; + for (const [index, root] of roots.entries()) { + root.alpha = startAlphas[index]! * nextAlpha; + } + }, + () => { + for (const root of roots) root.removeFromParent(); + }, + ); + + if (block) await run; + else void run; + } + + /** + * Port of `Torappu.AVG.AVGImagePanel._ExecuteImageTween` for the foreground + * sprite. PIXI interpolation substitutes for native DOTween. + */ + async setImageTween(input: ImageTweenInput): Promise { + if (!this.imageSprite) return; + + const sprite = this.imageSprite; + const fromX = isFiniteNumber(input.xFrom) + ? STORY_WIDTH / 2 + input.xFrom + : sprite.position.x; + const fromY = isFiniteNumber(input.yFrom) + ? STORY_HEIGHT / 2 - input.yFrom + : sprite.position.y; + const targetPositionX = isFiniteNumber(input.xTo) + ? STORY_WIDTH / 2 + input.xTo + : sprite.position.x; + const targetPositionY = isFiniteNumber(input.yTo) + ? STORY_HEIGHT / 2 - input.yTo + : sprite.position.y; + const fromScaleX = isFiniteNumber(input.xScaleFrom) + ? input.xScaleFrom + : sprite.scale.x; + const fromScaleY = isFiniteNumber(input.yScaleFrom) + ? input.yScaleFrom + : sprite.scale.y; + const targetX = isFiniteNumber(input.xScaleTo) + ? input.xScaleTo + : sprite.scale.x; + const targetY = isFiniteNumber(input.yScaleTo) + ? input.yScaleTo + : sprite.scale.y; + + sprite.position.set(fromX, fromY); + sprite.scale.set(fromScaleX, fromScaleY); + + if (input.durationMs <= 0) { + sprite.scale.set(targetX, targetY); + sprite.position.set(targetPositionX, targetPositionY); + return; + } + + const run = this.tween(input.durationMs, (progress) => { + const nextX = fromScaleX + (targetX - fromScaleX) * progress; + const nextY = fromScaleY + (targetY - fromScaleY) * progress; + sprite.scale.set(nextX, nextY); + sprite.position.set( + fromX + (targetPositionX - fromX) * progress, + fromY + (targetPositionY - fromY) * progress, + ); + }); + + if (input.block) await run; + else void run; + } + + /** + * Port of `Torappu.AVG.LargeBackgroundPanel._ExecuteImageTween` for + * `largebg`; it intentionally uses the panel's direct tween timing. + */ + async setLargeBackgroundTween( + input: LargeBackgroundTweenInput, + ): Promise { + const root = this.largeBackgroundRoot; + if (!root || root.parent !== this.gridBackgroundLayer) return; + + const current = this.readCenteredTransform(root); + const from = { + scaleX: isFiniteNumber(input.xScaleFrom) + ? input.xScaleFrom + : current.scaleX, + scaleY: isFiniteNumber(input.yScaleFrom) + ? input.yScaleFrom + : current.scaleY, + x: isFiniteNumber(input.xFrom) ? input.xFrom : current.x, + y: isFiniteNumber(input.yFrom) ? input.yFrom : current.y, + }; + const to = { + scaleX: isFiniteNumber(input.xScaleTo) ? input.xScaleTo : current.scaleX, + scaleY: isFiniteNumber(input.yScaleTo) ? input.yScaleTo : current.scaleY, + x: isFiniteNumber(input.xTo) ? input.xTo : current.x, + y: isFiniteNumber(input.yTo) ? input.yTo : current.y, + }; + + const sessionId = ++this.largeBackgroundTweenSessionId; + this.applyCenteredTransform(root, from); + + if (input.durationMs <= 0) { + this.applyCenteredTransform(root, to); + return; + } + + const run = this.tween( + input.durationMs, + (progress) => { + if (!this.isActiveLargeBackground(root, sessionId)) return; + this.applyCenteredTransform(root, { + scaleX: from.scaleX + (to.scaleX - from.scaleX) * progress, + scaleY: from.scaleY + (to.scaleY - from.scaleY) * progress, + x: from.x + (to.x - from.x) * progress, + y: from.y + (to.y - from.y) * progress, + }); + }, + () => { + if (!this.isActiveLargeBackground(root, sessionId)) return; + this.applyCenteredTransform(root, to); + }, + ); + + if (input.block) await run; + else void run; + } + + /** + * Web-only companion to the legacy `setLargeImage` surface: the investigated + * client has no `largeimgtween` executor. It is intentionally not a native + * provenance claim. + */ + async setLargeImageTween(input: LargeBackgroundTweenInput): Promise { + const root = this.largeImageRoot; + if (!root || root.parent !== this.imageLayer) return; + + const current = this.readCenteredTransform(root); + const from = { + scaleX: isFiniteNumber(input.xScaleFrom) + ? input.xScaleFrom + : current.scaleX, + scaleY: isFiniteNumber(input.yScaleFrom) + ? input.yScaleFrom + : current.scaleY, + x: isFiniteNumber(input.xFrom) ? input.xFrom : current.x, + y: isFiniteNumber(input.yFrom) ? input.yFrom : current.y, + }; + const to = { + scaleX: isFiniteNumber(input.xScaleTo) ? input.xScaleTo : current.scaleX, + scaleY: isFiniteNumber(input.yScaleTo) ? input.yScaleTo : current.scaleY, + x: isFiniteNumber(input.xTo) ? input.xTo : current.x, + y: isFiniteNumber(input.yTo) ? input.yTo : current.y, + }; + + const sessionId = ++this.largeImageTweenSessionId; + this.applyCenteredTransform(root, from); + + if (input.durationMs <= 0) { + this.applyCenteredTransform(root, to); + return; + } + + const run = this.tween( + input.durationMs, + (progress) => { + if (!this.isActiveLargeImage(root, sessionId)) return; + this.applyCenteredTransform(root, { + scaleX: from.scaleX + (to.scaleX - from.scaleX) * progress, + scaleY: from.scaleY + (to.scaleY - from.scaleY) * progress, + x: from.x + (to.x - from.x) * progress, + y: from.y + (to.y - from.y) * progress, + }); + }, + () => { + if (!this.isActiveLargeImage(root, sessionId)) return; + this.applyCenteredTransform(root, to); + }, + ); + + if (input.block) await run; + else void run; + } + + /** + * Port of `Torappu.AVG.AVGImagePanel._ExecuteImageRotate`: rotate the panel + * transform rather than its foreground Image, preserving angle across image + * swaps. `imageLayer` is the corresponding PIXI adaptation. + */ + async setImageRotate(input: ImageRotateInput): Promise { + const target = this.imageLayer; + const sessionId = ++this.imageRotateSessionId; + const startAngle = target.angle; + const delta = rotateTweenDelta( + startAngle, + input.angleDeg, + input.circles, + input.inverse, + ); + const endAngle = startAngle + delta; + + if (input.durationMs <= 0) { + target.angle = endAngle; + return; + } + + const run = this.tween( + input.durationMs, + (progress) => { + if (this.imageRotateSessionId !== sessionId) return; + target.angle = startAngle + delta * progress; + }, + () => { + if (this.imageRotateSessionId !== sessionId) return; + target.angle = endAngle; + }, + ); + + if (input.block) await run; + else void run; + } + + /** + * Port scope: `Torappu.AVG.CharacterPanel._ExecuteCharacter` and its + * character-slot branch. Slot state and transitions are preserved; display + * hierarchy and texture composition are Web/PIXI adaptations. + */ + async setCharacter(input: CharacterSlotInput): Promise { + if (this.isCharacterSlotCommand(input)) { + await this.setCharacterSlot(input); + return; + } + + if (!input.characterKey || !input.expression) return; + + const link = this.context.linkMap[input.characterKey]; + if (!link) { + this.onWarning?.(`missing character base: ${input.characterKey}`); + return; + } + + const item = link.array.find((entry) => entry.name === input.expression); + if (!item) { + this.onWarning?.( + `missing character expression: ${input.characterKey}#${input.expression}`, + ); + return; + } + + const slot = this.normalizeCharacterSlot(input.slot); + const slotBaseX: Record = { + // Derived from legacy 960-space anchors [330, 480, 630], scaled to 1280. + l: 440, + m: 640, + r: 840, + }; + + const built = await this.buildCharacterVisual( + input.characterKey, + input.expression, + input.blackStart, + input.blackEnd, + ); + if (!built) return; + + const { sourceHeight, sourceWidth, visual } = built; + + const sizeX = link.size.x || sourceWidth; + const sizeY = link.size.y || sourceHeight; + const offsetX = link.pos.x || 0; + const offsetY = link.pos.y || 0; + + // Match legacy char layout semantics in 1280x720 space. + const targetX = + input.absolutePosition?.x ?? + (slotBaseX[slot] ?? slotBaseX.m) - sizeX / 2 + offsetX; + const targetY = + input.absolutePosition?.y ?? STORY_HEIGHT - sizeY / 2 - offsetY; + const enterOffset = this.enterOffset(input.enterFrom); + const root = new Container(); + const motionLayer = new Container(); + const rotationLayer = new Container(); + rotationLayer.addChild(visual); + motionLayer.addChild(rotationLayer); + root.addChild(motionLayer); + root.x = targetX; + root.y = targetY; + + const safeWidth = Math.max(1, sourceWidth); + const safeHeight = Math.max(1, sourceHeight); + const baseScaleX = sizeX / safeWidth; + const baseScaleY = sizeY / safeHeight; + motionLayer.scale.set(baseScaleX, baseScaleY); + rotationLayer.pivot.set(sourceWidth / 2, sourceHeight / 2); + rotationLayer.position.set(sourceWidth / 2, sourceHeight / 2); + + const focusBrightness = input.dimmed ? 0.5 : 1; + const durationMs = Math.max(0, Math.round(input.durationMs ?? 0)); + const previous = this.characterSlots.get(slot); + const state: CharacterRenderState = { + actionX: 0, + actionY: 0, + baseScaleX, + baseScaleY, + baseX: targetX, + baseY: targetY, + characterKey: input.characterKey, + contentAlpha: 1, + focusBrightness, + expression: input.expression, + height: sizeY, + jumpOffsetY: 0, + jumpSessionId: 0, + motionLayer, + opacitySessionId: 0, + replaceFadeSessionId: 0, + root, + rotationDeg: 0, + rotationLayer, + rotateSessionId: 0, + rotateTimeout: null, + scaleX: 1, + scaleY: 1, + shakeOffsetX: 0, + shakeOffsetY: 0, + shakeSessionId: 0, + shakeTimeout: null, + slot, + sourceHeight, + sourceWidth, + transformSessionId: 0, + visual, + width: sizeX, + }; + this.updateCharacterState(state); + + if (durationMs > 0) { + root.alpha = 0; + root.x = targetX + enterOffset.x; + root.y = targetY + enterOffset.y; + } else { + this.updateCharacterOpacity(state); + } + + if (previous && durationMs <= 0) this.disposeCharacterState(previous); + this.characterSlots.set(slot, state); + this.charLayer.addChild(root); + + if (previous && durationMs > 0) + void this.fadeOutAndRemove(previous.root, durationMs, () => + this.disposeCharacterState(previous), + ); + + if (durationMs > 0) { + const run = this.tween( + durationMs, + (progress) => { + root.alpha = progress; + root.x = targetX + enterOffset.x * (1 - progress); + root.y = targetY + enterOffset.y * (1 - progress); + }, + () => { + this.updateCharacterOpacity(state); + root.x = targetX; + root.y = targetY; + }, + ); + + void run; + } + } + + private async setCharacterSlot(input: CharacterSlotInput): Promise { + const slot = this.normalizeCharacterSlot(input.slot); + let state = this.characterSlots.get(slot); + const hasCharacter = Boolean(input.characterKey && input.expression); + + if (hasCharacter) { + const built = await this.buildCharacterSlotState(input, slot, state); + if (!built) return; + state = built; + } + + if (!state) { + if (input.focusMode) + this.applyCharacterSlotFocus(input.focusMode, slot, input.focusSlots); + return; + } + + this.applyCharacterSlotFocus(input.focusMode, slot, input.focusSlots); + + const fromTransform = this.characterSlotFromTransform(state, input); + const toTransform = this.characterSlotToTransform( + state, + input, + fromTransform, + ); + const durationMs = Math.max(0, Math.round(input.durationMs ?? 0)); + + state.actionX = fromTransform.x; + state.actionY = fromTransform.y; + state.scaleX = fromTransform.scaleX; + state.scaleY = fromTransform.scaleY; + this.updateCharacterState(state); + + if (input.action === "shake" || input.action === "shakemove") { + this.startShakeAction(state, { + block: false, + durationMs, + power: Math.max(0, input.power ?? 0), + randomness: input.randomness ?? 90, + rotationFromDeg: 0, + rotationLeftDeg: -15, + rotationRightDeg: 15, + slot, + stop: Boolean(input.stop), + times: input.times ?? 1, + type: "shake", + xOffset: 0, + yOffset: 0, + }); + } + + if (input.action === "jump") + this.startJumpAction( + state, + Math.max(0, input.power ?? 0), + input.times ?? 1, + durationMs, + ); + + if (input.action === "rotate" && input.angle !== undefined) { + const sessionId = ++state.rotateSessionId; + const fromAngle = state.rotationDeg; + const direction = input.inverse ? -1 : 1; + const toAngle = direction * (input.angle + 360 * (input.circles ?? 0)); + void this.tween(durationMs, (progress) => { + if ( + !this.isActiveCharacterState(state) || + state.rotateSessionId !== sessionId + ) + return; + state.rotationDeg = fromAngle + (toAngle - fromAngle) * progress; + this.updateCharacterState(state); + }); + } + + this.applyCharacterSlotOpacity(state, input, durationMs); + if (input.action === "shake") return; + + const sessionId = ++state.transformSessionId; + const transformDurationMs = input.action === "setpos" ? 0 : durationMs; + const run = this.tween( + transformDurationMs, + (progress) => { + if (!this.isActiveCharacterState(state, sessionId)) return; + state.actionX = + fromTransform.x + (toTransform.x - fromTransform.x) * progress; + state.actionY = + fromTransform.y + (toTransform.y - fromTransform.y) * progress; + state.scaleX = + fromTransform.scaleX + + (toTransform.scaleX - fromTransform.scaleX) * progress; + state.scaleY = + fromTransform.scaleY + + (toTransform.scaleY - fromTransform.scaleY) * progress; + this.updateCharacterState(state); + }, + () => { + if (!this.isActiveCharacterState(state, sessionId)) return; + state.actionX = toTransform.x; + state.actionY = toTransform.y; + state.scaleX = toTransform.scaleX; + state.scaleY = toTransform.scaleY; + this.updateCharacterState(state); + }, + ); + + if (!input.block || transformDurationMs <= 0) void run; + else await run; + } + + /** + * Port scope: `Torappu.AVG.CharacterPanel._ExecuteCharacterAction` and its + * move/jump/shake/zoom/exit handlers. Browser tween sampling adapts DOTween, + * but keeps the per-action state and completion boundary. + */ + async runCharacterAction(input: CharacterActionInput): Promise { + const state = this.characterSlots.get( + this.normalizeCharacterSlot(input.slot), + ); + if (!state) { + this.onWarning?.(`missing character slot: ${input.slot}`); + return; + } + + switch (input.type) { + case "move": { + await this.runMoveAction( + state, + input.xOffset, + input.yOffset, + input.durationMs, + input.block, + ); + return; + } + case "jump": { + this.startJumpAction(state, input.power, input.times, input.durationMs); + await this.runMoveAction( + state, + input.xOffset, + input.yOffset, + input.durationMs, + input.block, + ); + return; + } + case "shake": { + this.startShakeAction(state, input); + return; + } + case "zoom": { + await this.runScaleAction( + state, + input.scaleX, + input.scaleY, + input.xOffset, + input.yOffset, + input.durationMs, + input.block, + ); + return; + } + case "exit": { + await this.runExitAction( + state, + input.durationMs, + input.direction, + input.yOffset, + input.block, + ); + } + } + } + + async clearCharacters(slot?: string, fadeMs = 0): Promise { + if (slot) { + const normalized = this.normalizeCharacterSlot(slot); + const state = this.characterSlots.get(normalized); + if (!state) return; + this.characterSlots.delete(normalized); + if (fadeMs > 0) + await this.fadeOutAndRemove(state.root, fadeMs, () => + this.disposeCharacterState(state), + ); + else this.disposeCharacterState(state); + return; + } + + const states = [...this.characterSlots.values()]; + this.characterSlots.clear(); + if (fadeMs > 0) { + await Promise.all( + states.map((state) => + this.fadeOutAndRemove(state.root, fadeMs, () => + this.disposeCharacterState(state), + ), + ), + ); + return; + } + for (const state of states) this.disposeCharacterState(state); + } + + async clearCurtains(fadeMs = 0, block = false): Promise { + const states = [...this.curtains.values()]; + if (states.length === 0) return; + + if (fadeMs <= 0) { + for (const state of states) + this.removeCurtainState(state.direction, state); + return; + } + + const run = Promise.all( + states.map((state) => + this.setCurtain({ + alphaFrom: state.alpha, + alphaTo: state.alpha, + block, + direction: state.direction, + delayMs: 0, + fadeMs, + fillFrom: state.fill, + fillTo: 0, + grad: false, + }), + ), + ); + + if (block) await run; + else void run; + } + + async clearSubtitle(fadeMs = 0): Promise { + this.subtitleTypingSessionId += 1; + this.subtitleFadeSessionId += 1; + this.subtitleTypingTarget = null; + + const subtitle = this.subtitleText; + if (!subtitle) return; + + if (!subtitle.visible || !subtitle.text) { + subtitle.text = ""; + subtitle.visible = false; + subtitle.alpha = 1; + return; + } + + const sessionId = this.subtitleFadeSessionId; + const startAlpha = subtitle.alpha; + if (fadeMs <= 0) { + subtitle.text = ""; + subtitle.visible = false; + subtitle.alpha = 1; + return; + } + + await this.tween( + fadeMs, + (progress) => { + if (this.subtitleFadeSessionId !== sessionId) return; + subtitle.alpha = startAlpha * (1 - progress); + }, + () => { + if (this.subtitleFadeSessionId !== sessionId) return; + subtitle.text = ""; + subtitle.visible = false; + subtitle.alpha = 1; + }, + ); + } + + async clearSticker(id?: string, fadeMs = 0): Promise { + if (!id) { + await this.clearStickers(fadeMs); + return; + } + + const sticker = this.stickerTexts.get(id); + if (!sticker) return; + + this.bumpStickerSessions(id); + this.stickerTypingTargets.delete(id); + this.stickerRichChars.delete(id); + if (!sticker.visible || !sticker.text) { + sticker.text = ""; + sticker.visible = false; + sticker.alpha = 1; + return; + } + + const sessionId = this.stickerFadeSessionIds.get(id) ?? 0; + const startAlpha = sticker.alpha; + if (fadeMs <= 0) { + sticker.text = ""; + sticker.visible = false; + sticker.alpha = 1; + return; + } + + void this.tween( + fadeMs, + (progress) => { + if ((this.stickerFadeSessionIds.get(id) ?? 0) !== sessionId) return; + sticker.alpha = startAlpha * (1 - progress); + }, + () => { + if ((this.stickerFadeSessionIds.get(id) ?? 0) !== sessionId) return; + sticker.text = ""; + sticker.visible = false; + sticker.alpha = 1; + }, + ); + } + + async clearStickers(fadeMs = 0): Promise { + this.stickerRichChars.clear(); + for (const id of this.stickerTexts.keys()) + await this.clearSticker(id, fadeMs); + } + + clearSpellStickers(): void { + this.spellStickerPanel.clear(); + } + + hideSpellSticker(id: string): void { + this.spellStickerPanel.hide(id); + } + + async clearTimerSticker(input?: TimerClearInput): Promise { + this.clearTimerInterval(); + + const timer = this.timerStickerText; + if (!timer) return; + + if (!input) { + timer.text = ""; + timer.visible = false; + timer.alpha = 1; + return; + } + + const fromAlpha = timer.alpha; + if (input.durationMs <= 0) { + timer.alpha = 0; + timer.visible = false; + return; + } + + void this.tween( + input.durationMs, + (progress) => { + timer.alpha = fromAlpha * (1 - progress); + }, + () => { + timer.alpha = 0; + timer.visible = false; + }, + ); + } + + /** + * Port scope: `Torappu.AVG.AVGBlockerPanel._ExecuteBlocker`, including the + * zero-duration and idempotent inverse branches. A PIXI sprite stands in for + * the native blocker view. + */ + async setBlocker(input: BlockerInput): Promise { + const blocker = this.ensureBlocker(); + if (input.style === "default" && input.image) { + const url = resolveStoryAssetByKey(input.image, false); + if (url) { + try { + blocker.texture = await Assets.load(url); + blocker.width = STORY_WIDTH; + blocker.height = STORY_HEIGHT; + } catch { + this.onWarning?.(`missing blocker image: ${input.image}`); + } + } + } else if (input.style !== "default") { + blocker.texture = Texture.WHITE; + this.onWarning?.(`unsupported_visual blocker:${input.style}`); + } + + const current = this.readBlockerColor(blocker); + const from = { + a: Number.isFinite(input.from.a) ? input.from.a : current.a, + b: Number.isFinite(input.from.b) ? input.from.b : current.b, + g: Number.isFinite(input.from.g) ? input.from.g : current.g, + r: Number.isFinite(input.from.r) ? input.from.r : current.r, + }; + this.writeBlockerColor(blocker, from); + + if (input.fadeMs <= 0) { + // The zero-fadetime path returns before the inverse block, so scale is + // untouched here. + this.writeBlockerColor(blocker, input.to); + return; + } + + // `AVGBlockerPanel._ExecuteBlocker` assigns literal -1 rather than negating + // the current axis: repeated inverse commands are idempotent, and only reset + if (input.inverse) { + if (input.style === "slider") blocker.scale.x = -1; + else if (input.style === "verticalslider") blocker.scale.y = -1; + } + + const run = this.tween( + input.fadeMs, + (progress) => { + this.writeBlockerColor(blocker, { + a: from.a + (input.to.a - from.a) * progress, + b: from.b + (input.to.b - from.b) * progress, + g: from.g + (input.to.g - from.g) * progress, + r: from.r + (input.to.r - from.r) * progress, + }); + }, + () => { + if (Math.abs(input.to.a) <= 1e-6) { + blocker.texture = Texture.WHITE; + blocker.visible = false; + } + }, + ); + + if (input.block) await run; + else void run; + } + + /** + * Port scope: `Torappu.AVG.AVGCurtainPanel._ExecuteCurtain`. It keeps the + * direction state, delayed size/alpha transition, and zero-duration branch; + * Graphics rectangles are a Web/PIXI adaptation of `AVGCurtain` widgets. + */ + async setCurtain(input: CurtainInput): Promise { + const direction = input.direction; + const vector = this.resolveCurtainVector(direction); + if (!vector) { + this.onWarning?.(`unsupported curtain direction: ${input.direction}`); + return; + } + + const state = this.ensureCurtainState(direction); + const fromFill = clamp01(input.fillFrom ?? state.fill); + const toFill = clamp01(input.fillTo); + const fromAlpha = clamp01(input.alphaFrom ?? state.alpha); + const toAlpha = clamp01(input.alphaTo ?? fromAlpha); + + state.fill = fromFill; + state.alpha = fromAlpha; + state.grad = input.grad; + this.updateCurtainState(state, vector); + + if (input.fadeMs <= 0) { + state.fill = toFill; + state.alpha = toAlpha; + this.updateCurtainState(state, vector); + if (toFill <= 0) this.removeCurtainState(direction, state); + return; + } + + const sessionId = ++state.tweenSessionId; + const animate = () => + this.tween( + input.fadeMs, + (progress) => { + if (!this.isActiveCurtainState(direction, state, sessionId)) return; + state.fill = fromFill + (toFill - fromFill) * progress; + state.alpha = fromAlpha + (toAlpha - fromAlpha) * progress; + this.updateCurtainState(state, vector); + }, + () => { + if (!this.isActiveCurtainState(direction, state, sessionId)) return; + state.fill = toFill; + state.alpha = toAlpha; + this.updateCurtainState(state, vector); + if (toFill <= 0) this.removeCurtainState(direction, state); + }, + ); + const run = + input.delayMs > 0 + ? new Promise((resolve) => + setTimeout(resolve, input.delayMs), + ).then(animate) + : animate(); + + if (input.block) await run; + else void run; + } + + /** + * Port scope: `Torappu.AVG.AVGCameraEffect._ExecuteCameraEffect` for the + * grayscale/inverse branches and their completion behavior. A PIXI color + * filter substitutes for `AVGSceneEffectManager`'s camera post-process. + */ + async setCameraEffect( + effect: "Colorinverse" | "Grayscale", + amount: number, + durationMs: number, + block: boolean, + keep: boolean, + initialAmount?: number, + ): Promise { + if (effect === "Colorinverse") { + this.inverseAmount = amount; + this.updateCameraFilter(); + return; + } + const from = initialAmount ?? this.grayscaleAmount; + this.grayscaleAmount = from; + this.updateCameraFilter(); + const run = this.tween( + durationMs, + (progress) => { + this.grayscaleAmount = from + (amount - from) * progress; + this.updateCameraFilter(); + }, + () => { + this.grayscaleAmount = keep ? amount : 0; + this.updateCameraFilter(); + }, + ); + if (block) await run; + else void run; + } + + async setFocusOut(input: FocusOutInput): Promise { + await this.focusEffectPanel.setFocus(input); + } + + setFocusParam(input: FocusParamInput): void { + this.focusEffectPanel.setParam(input); + } + + private updateCameraFilter(): void { + if (this.grayscaleAmount === 0 && this.inverseAmount === 0) { + this.sceneLayer.filters = []; + this.grayscaleFilter = null; + return; + } + + if (!this.grayscaleFilter) this.grayscaleFilter = new ColorMatrixFilter(); + + this.grayscaleFilter.reset(); + if (this.grayscaleAmount !== 0) + this.grayscaleFilter.grayscale(this.grayscaleAmount, false); + if (this.inverseAmount !== 0) this.grayscaleFilter.negative(false); + this.sceneLayer.filters = [this.grayscaleFilter]; + } + + private resolveFocusTargets(type: string, id: string): Container[] { + switch (type) { + case "bg": { + return [this.backgroundLayer]; + } + case "char": { + return [this.charLayer]; + } + // `AVGImagePanel._PostDisplayKey` registers ck_cg_1/2 for `image`'s + // fore/back images, not CG objects. + case "cg": { + return [this.imageLayer]; + } + // `LargeBackgroundPanel._PostDisplayKey` registers ck_lbg_1..4 over its + // `_images` list, which only `largebg` fills. + case "lbg": { + return this.largeBackgroundRoot ? [this.largeBackgroundRoot] : []; + } + case "cgitem": { + return this.cgItemPanel.targets(id); + } + case "customchar": { + if (!id) + return [...this.characterSlots.values()].map((state) => state.root); + const state = this.characterSlots.get(this.normalizeCharacterSlot(id)); + return state ? [state.root] : []; + } + default: { + return []; + } + } + } + + /** + * Port of `Torappu.AVG.AVGCameraEffect._ExecuteCameraShake`: it moves the + * scene root, not independent visual layers. Path sampling is a Web/PIXI + * adaptation of DOTween's shake tween. + */ + async shakeCamera(input: CameraShakeInput): Promise { + this.stopCameraShake(); + + if (input.stop || input.durationMs <= 0) return; + + const sessionId = ++this.cameraShakeSessionId; + const path = buildShakePath(input.durationMs, input); + const runCycle = async (): Promise => { + await this.tween(input.durationMs, (progress) => { + if (sessionId !== this.cameraShakeSessionId) return; + const offset = sampleShakePath(path, progress); + this.sceneLayer.position.set(offset.x, offset.y); + }); + if (sessionId !== this.cameraShakeSessionId) return; + if (input.infinite) { + void runCycle(); + return; + } + this.stopCameraShake(sessionId); + }; + + let wait: Promise | undefined; + if (input.block) { + wait = new Promise((resolve) => { + this.cameraShakeWaitResolve = resolve; + }); + } + + void runCycle(); + + if (wait) { + await wait; + this.cameraShakeWaitResolve = null; + } + } + + /** + * Port scope: `Torappu.AVG.SubtitlePanel._ExecuteSubtitle` normal-playback + * semantics. PIXI text/typewriter scheduling adapts `AVGTypeWriterText`; + * playback/reader-mode executor variants are intentionally not represented. + */ + async setSubtitle(input: SubtitleInput): Promise { + const subtitle = this.subtitleText; + if (!subtitle) return; + + this.subtitleTypingSessionId += 1; + this.subtitleFadeSessionId += 1; + this.subtitleTypingTarget = null; + subtitle.alpha = 0; + subtitle.visible = true; + + const fadeSessionId = this.subtitleFadeSessionId; + void this.tween( + 150, + (progress) => { + if (this.subtitleFadeSessionId === fadeSessionId) + subtitle.alpha = progress; + }, + () => { + if (this.subtitleFadeSessionId === fadeSessionId) subtitle.alpha = 1; + }, + ); + + const prevChars: RichChar[] = []; + const newChars = parseRichChars(input.text); + const allChars = [...prevChars, ...newChars]; + + const colors = collectColors(allChars); + const style = this.createOverlayTextStyle(input.sizePx, input.widthPx); + if (colors.length > 0) style.tagStyles = buildTagStyles(colors); + subtitle.style = style; + subtitle.y = input.y; + + const fullText = richCharsToTaggedText(allChars); + if (input.delayMs <= 0) { + subtitle.text = fullText; + this.layoutSubtitle(subtitle, input.x, input.widthPx, input.alignment); + return; + } + + const sessionId = this.subtitleTypingSessionId; + subtitle.text = richCharsToTaggedText(prevChars); + this.layoutSubtitle(subtitle, input.x, input.widthPx, input.alignment); + this.subtitleTypingTarget = { + alignment: input.alignment, + baseX: input.x, + fullText, + widthPx: input.widthPx, + }; + void this.runSubtitleTyping(sessionId, subtitle, { + alignment: input.alignment, + baseX: input.x, + delayMs: input.delayMs, + prevChars, + newChars, + widthPx: input.widthPx, + }); + } + + /** + * Port scope: `Torappu.AVG.StickerPanel._ExecuteSticker` and its append, + * fade, and typewriter state. PIXI Text replaces the native sticker prefab. + */ + async setSticker(input: StickerInput): Promise { + const sticker = this.ensureStickerText(input.id); + const wasActive = this.stickerRichChars.has(input.id); + this.bumpStickerSessions(input.id); + sticker.alpha = wasActive ? sticker.alpha : 0; + sticker.visible = true; + + if (!wasActive) { + const fadeSessionId = this.stickerFadeSessionIds.get(input.id) ?? 0; + void this.tween( + input.fadeMs, + (progress) => { + if ((this.stickerFadeSessionIds.get(input.id) ?? 0) === fadeSessionId) + sticker.alpha = progress; + }, + () => { + if ((this.stickerFadeSessionIds.get(input.id) ?? 0) === fadeSessionId) + sticker.alpha = 1; + }, + ); + } + + const prevChars = input.append + ? (this.stickerRichChars.get(input.id) ?? []) + : []; + const newChars = parseRichChars(input.text); + const allChars = [...prevChars, ...newChars]; + this.stickerRichChars.set(input.id, allChars); + + const colors = collectColors(allChars); + const style = this.createOverlayTextStyle(input.sizePx, input.widthPx); + if (colors.length > 0) style.tagStyles = buildTagStyles(colors); + sticker.style = style; + sticker.y = input.y; + + const fullText = richCharsToTaggedText(allChars); + if (input.delayMs <= 0) { + sticker.text = fullText; + this.layoutSubtitle(sticker, input.x, input.widthPx, input.alignment); + return; + } + + const sessionId = this.stickerTypingSessionIds.get(input.id) ?? 0; + sticker.text = richCharsToTaggedText(prevChars); + this.layoutSubtitle(sticker, input.x, input.widthPx, input.alignment); + this.stickerTypingTargets.set(input.id, { + alignment: input.alignment, + baseX: input.x, + fullText, + widthPx: input.widthPx, + }); + void this.runStickerTyping(input.id, sessionId, sticker, { + alignment: input.alignment, + baseX: input.x, + delayMs: input.delayMs, + prevChars, + newChars, + widthPx: input.widthPx, + }); + } + + setSpellSticker(input: SpellStickerInput): void { + this.spellStickerPanel.show(input); + } + + /** + * Port scope: `Torappu.AVG.StickerPanel._ExcuteTimerSticker` (native spelling) + * and `AVGTimerView.RenderTimer`. Browser intervals and PIXI text adapt the + * native timer view; this command itself never supplies a block boundary. + */ + async setTimerSticker(input: TimerStickerInput): Promise { + const timer = this.ensureTimerStickerText(); + this.clearTimerInterval(); + + timer.style = this.createOverlayTextStyle(input.sizePx, input.widthPx); + timer.alpha = input.fromAlpha; + timer.visible = true; + timer.x = input.x; + timer.y = input.y; + timer.text = this.formatTimer(0); + + void this.tween( + input.durationMs > 0 ? input.durationMs : 130, + (progress) => { + timer.alpha = + input.fromAlpha + (input.toAlpha - input.fromAlpha) * progress; + }, + () => { + timer.alpha = input.toAlpha; + }, + ); + + if (input.limitSeconds === undefined || input.limitSeconds <= 0) return; + + let remainingSeconds = input.limitSeconds; + this.timerStickerInterval = setInterval(() => { + remainingSeconds = Math.max(0, remainingSeconds - 1); + if (!this.timerStickerText) return; + + this.timerStickerText.text = this.formatTimer(remainingSeconds); + if (remainingSeconds <= 0) this.clearTimerInterval(); + }, 1000); + } + + private async createUi(): Promise { + await this.dialogPanel.mount(); + this.subtitleText = new Text({ + style: this.createOverlayTextStyle(18, 675), + text: "", + }); + this.subtitleText.visible = false; + this.timerStickerText = new Text({ + style: this.createOverlayTextStyle(18, 220), + text: "", + }); + this.timerStickerText.visible = false; + + this.uiLayer.addChild(this.subtitleText); + this.uiLayer.addChild(this.timerStickerText); + } + + private ensureBlocker(): Sprite { + if (this.blockerSprite) return this.blockerSprite; + + const blocker = new Sprite(Texture.WHITE); + blocker.width = STORY_WIDTH; + blocker.height = STORY_HEIGHT; + blocker.tint = 0x00_00_00; + blocker.alpha = 0; + + this.blockerSprite = blocker; + this.worldLayer.addChild(blocker); + + return blocker; + } + + private ensureCurtainState(direction: number): CurtainRenderState { + const existing = this.curtains.get(direction); + if (existing) return existing; + + const graphic = new Graphics(); + graphic.visible = false; + this.curtainLayer.addChild(graphic); + + const state: CurtainRenderState = { + alpha: 1, + direction, + fill: 0, + grad: false, + graphic, + tweenSessionId: 0, + }; + this.curtains.set(direction, state); + return state; + } + + private updateCurtainState( + state: CurtainRenderState, + vector: { x: number; y: number }, + ): void { + state.graphic.alpha = clamp01(state.alpha); + state.graphic.clear(); + + if (state.graphic.alpha <= 0 || state.fill <= 0) { + state.graphic.visible = false; + return; + } + + const threshold = this.curtainThreshold(vector, state.fill); + if (threshold === null) { + state.graphic.visible = false; + return; + } + + state.graphic.visible = true; + + // The curtain body is a solid black quad (_curtainImg is sprite_white tinted + // (0,0,0,1)). `grad` adds a separate CURTAIN_GRADIENT_PX strip on the inner + // edge, opaque on the curtain side and fully transparent toward the screen + // centre -- it does not fade the whole body. + const bodyEnd = state.grad ? threshold - CURTAIN_GRADIENT_PX : threshold; + const body = this.buildCurtainBand(vector, null, bodyEnd); + if (body.length >= 6) state.graphic.poly(body).fill(0x00_00_00); + + if (!state.grad) return; + + const feather = this.buildCurtainBand(vector, bodyEnd, threshold); + if (feather.length < 6) return; + + state.graphic.poly(feather).fill( + new FillGradient({ + colorStops: [ + { color: "rgba(0, 0, 0, 1)", offset: 0 }, + { color: "rgba(0, 0, 0, 0)", offset: 1 }, + ], + end: { x: vector.x * threshold, y: vector.y * threshold }, + start: { x: vector.x * bodyEnd, y: vector.y * bodyEnd }, + textureSpace: "global", + type: "linear", + }), + ); + } + + /** Projection along `vector` where the curtain's inner edge sits. */ + private curtainThreshold( + vector: { x: number; y: number }, + fill: number, + ): number | null { + const clampedFill = clamp01(fill); + if (clampedFill <= 0) return null; + + const projections = CURTAIN_STAGE_CORNERS.map( + (point) => point.x * vector.x + point.y * vector.y, + ); + const min = Math.min(...projections); + const max = Math.max(...projections); + if (Math.abs(max - min) <= 1e-6) return null; + + return min + (max - min) * clampedFill; + } + + /** Stage rect clipped to `low <= projection <= high`; `null` low means unbounded. */ + private buildCurtainBand( + vector: { x: number; y: number }, + low: number | null, + high: number, + ): number[] { + let polygon = this.clipCurtainPolygon( + [...CURTAIN_STAGE_CORNERS], + vector, + high, + ); + if (low !== null) { + polygon = this.clipCurtainPolygon( + polygon, + { x: -vector.x, y: -vector.y }, + -low, + ); + } + if (polygon.length < 3) return []; + return polygon.flatMap((point) => [point.x, point.y]); + } + + private clipCurtainPolygon( + polygon: Array<{ x: number; y: number }>, + vector: { x: number; y: number }, + threshold: number, + ): Array<{ x: number; y: number }> { + const output: Array<{ x: number; y: number }> = []; + const last = polygon.at(-1); + if (!last) return output; + let previous = last; + let previousInside = this.isCurtainPointInside(previous, vector, threshold); + + for (const current of polygon) { + const currentInside = this.isCurtainPointInside( + current, + vector, + threshold, + ); + if (currentInside !== previousInside) + output.push( + this.intersectCurtainEdge(previous, current, vector, threshold), + ); + if (currentInside) output.push(current); + previous = current; + previousInside = currentInside; + } + + return output; + } + + private isCurtainPointInside( + point: { x: number; y: number }, + vector: { x: number; y: number }, + threshold: number, + ): boolean { + return point.x * vector.x + point.y * vector.y <= threshold + 1e-6; + } + + private intersectCurtainEdge( + start: { x: number; y: number }, + end: { x: number; y: number }, + vector: { x: number; y: number }, + threshold: number, + ): { x: number; y: number } { + const startProjection = start.x * vector.x + start.y * vector.y; + const endProjection = end.x * vector.x + end.y * vector.y; + const delta = endProjection - startProjection; + if (Math.abs(delta) <= 1e-6) return { ...start }; + + const ratio = (threshold - startProjection) / delta; + return { + x: start.x + (end.x - start.x) * ratio, + y: start.y + (end.y - start.y) * ratio, + }; + } + + private resolveCurtainVector( + direction: number, + ): { x: number; y: number } | null { + const mappedAngles: Record = { + 0: 180, + 1: 225, + 2: 270, + 3: 315, + 4: 0, + 5: 45, + 6: 90, + 7: 135, + }; + + const angleDeg = mappedAngles[direction]; + if (angleDeg === undefined) return null; + + const radians = (angleDeg * Math.PI) / 180; + return { + x: Math.sin(radians), + y: -Math.cos(radians), + }; + } + + private async buildCharacterVisual( + characterKey: string, + expression: string, + blackStart?: number, + blackEnd?: number, + ): Promise { + const link = this.context.linkMap[characterKey]; + if (!link) { + this.onWarning?.(`missing character base: ${characterKey}`); + return null; + } + + const item = link.array.find((entry) => entry.name === expression); + if (!item) { + this.onWarning?.( + `missing character expression: ${characterKey}#${expression}`, + ); + return null; + } + + const visual = new Container(); + const content = new Container(); + visual.addChild(content); + + let sourceWidth: number; + let sourceHeight: number; + + if (item.group === -1 && "image" in item && item.image) { + const texture = await this.textureForCharacterKey(item.image); + if (!texture) return null; + + const sprite = new Sprite(texture); + sprite.anchor.set(0, 0); + content.addChild(sprite); + sourceWidth = texture.width; + sourceHeight = texture.height; + } else if (item.group >= 0 && "face" in item && item.face) { + const group = link.groups[item.group]; + if (!group || group.mode !== "face_overlay") { + this.onWarning?.( + `unsupported character group: ${characterKey}#${expression}`, + ); + return null; + } + + const [baseTexture, faceTexture] = await Promise.all([ + this.textureForCharacterKey(group.base), + this.textureForCharacterKey(item.face), + ]); + if (!baseTexture || !faceTexture) return null; + + const baseSprite = new Sprite(baseTexture); + baseSprite.anchor.set(0, 0); + content.addChild(baseSprite); + + const faceSprite = new Sprite(faceTexture); + faceSprite.anchor.set(0, 0); + faceSprite.x = group.faceRect.x; + faceSprite.y = group.faceRect.y; + faceSprite.width = group.faceRect.w; + faceSprite.height = group.faceRect.h; + content.addChild(faceSprite); + + sourceWidth = baseTexture.width; + sourceHeight = baseTexture.height; + } else { + this.onWarning?.(`invalid character item: ${characterKey}#${expression}`); + return null; + } + + this.applyCharacterBlackGradient( + visual, + content, + sourceWidth, + sourceHeight, + blackStart, + blackEnd, + ); + return { sourceHeight, sourceWidth, visual }; + } + + private async buildCharacterSlotState( + input: CharacterSlotInput, + slot: string, + current?: CharacterRenderState, + ): Promise { + if (!input.characterKey || !input.expression) return current ?? null; + + const link = this.context.linkMap[input.characterKey]; + if (!link) { + this.onWarning?.(`missing character base: ${input.characterKey}`); + return null; + } + + const built = await this.buildCharacterVisual( + input.characterKey, + input.expression, + input.blackStart, + input.blackEnd, + ); + if (!built) return null; + + const slotBaseX: Record = { l: 440, m: 640, r: 840 }; + const sizeX = link.size.x || built.sourceWidth; + const sizeY = link.size.y || built.sourceHeight; + const offsetX = link.pos.x || 0; + const offsetY = link.pos.y || 0; + const baseX = (slotBaseX[slot] ?? slotBaseX.m) - sizeX / 2 + offsetX; + const baseY = STORY_HEIGHT - sizeY / 2 - offsetY; + const baseScaleX = sizeX / Math.max(1, built.sourceWidth); + const baseScaleY = sizeY / Math.max(1, built.sourceHeight); + + if (!current) { + const root = new Container(); + const motionLayer = new Container(); + const rotationLayer = new Container(); + rotationLayer.addChild(built.visual); + motionLayer.addChild(rotationLayer); + root.addChild(motionLayer); + root.x = baseX; + root.y = baseY; + rotationLayer.pivot.set(built.sourceWidth / 2, built.sourceHeight / 2); + rotationLayer.position.set(built.sourceWidth / 2, built.sourceHeight / 2); + + const state: CharacterRenderState = { + actionX: 0, + actionY: 0, + baseScaleX, + baseScaleY, + baseX, + baseY, + characterKey: input.characterKey, + contentAlpha: 1, + focusBrightness: 1, + expression: input.expression, + height: sizeY, + jumpOffsetY: 0, + jumpSessionId: 0, + motionLayer, + opacitySessionId: 0, + replaceFadeSessionId: 0, + root, + rotationDeg: 0, + rotationLayer, + rotateSessionId: 0, + rotateTimeout: null, + scaleX: 1, + scaleY: 1, + shakeOffsetX: 0, + shakeOffsetY: 0, + shakeSessionId: 0, + shakeTimeout: null, + slot, + sourceHeight: built.sourceHeight, + sourceWidth: built.sourceWidth, + transformSessionId: 0, + visual: built.visual, + width: sizeX, + }; + this.updateCharacterState(state); + this.updateCharacterOpacity(state); + this.characterSlots.set(slot, state); + this.charLayer.addChild(root); + return state; + } + + const previousVisual = current.visual; + current.baseScaleX = baseScaleX; + current.baseScaleY = baseScaleY; + current.baseX = baseX; + current.baseY = baseY; + current.characterKey = input.characterKey; + current.expression = input.expression; + current.height = sizeY; + current.root.x = baseX; + current.root.y = baseY; + current.sourceHeight = built.sourceHeight; + current.sourceWidth = built.sourceWidth; + current.visual = built.visual; + current.width = sizeX; + current.rotationLayer.pivot.set( + built.sourceWidth / 2, + built.sourceHeight / 2, + ); + current.rotationLayer.position.set( + built.sourceWidth / 2, + built.sourceHeight / 2, + ); + current.rotationLayer.addChild(built.visual); + + const fadeMs = Math.max(0, Math.round(input.replaceFadeMs ?? 0)); + if (fadeMs > 0) { + const sessionId = ++current.replaceFadeSessionId; + const previousAlpha = previousVisual?.alpha ?? current.contentAlpha; + built.visual.alpha = 0; + void this.tween( + fadeMs, + (progress) => { + if ( + !this.isActiveCharacterState(current) || + current.replaceFadeSessionId !== sessionId + ) + return; + built.visual.alpha = current.contentAlpha * progress; + if (previousVisual && previousVisual !== built.visual) + previousVisual.alpha = previousAlpha * (1 - progress); + }, + () => { + if ( + !this.isActiveCharacterState(current) || + current.replaceFadeSessionId !== sessionId + ) + return; + built.visual.alpha = current.contentAlpha; + previousVisual?.removeFromParent(); + }, + ); + } else { + built.visual.alpha = current.contentAlpha; + previousVisual?.removeFromParent(); + } + + return current; + } + + private applyCharacterBlackGradient( + visual: Container, + content: Container, + width: number, + height: number, + blackStart = Number.NaN, + blackEnd = Number.NaN, + ): void { + if (!Number.isFinite(blackStart) || !Number.isFinite(blackEnd)) return; + + const start = Math.max(0, Math.min(1, blackStart ?? 0)); + const end = Math.max(0, Math.min(1, blackEnd ?? 0)); + if (end <= 0) return; + + const startY = height * Math.min(start, end); + const endY = height * Math.max(start, end); + const overlayHeight = Math.max(1, endY); + const gradient = new FillGradient({ + colorStops: [ + { color: "rgba(0, 0, 0, 1)", offset: 0 }, + { color: "rgba(0, 0, 0, 1)", offset: startY / overlayHeight }, + { color: "rgba(0, 0, 0, 0)", offset: 1 }, + ], + end: { x: 0, y: 1 }, + start: { x: 0, y: 0 }, + textureSpace: "local", + type: "linear", + }); + + const overlay = new Graphics(); + overlay.rect(0, 0, width, overlayHeight).fill(gradient); + overlay.mask = content; + visual.addChild(overlay); + } + + private characterSlotFromTransform( + state: CharacterRenderState, + input: CharacterSlotInput, + ): { scaleX: number; scaleY: number; x: number; y: number } { + const preserve = + input.resetTransform === false || input.preserveTransform === true; + const baseX = preserve ? state.actionX : 0; + const baseY = preserve ? state.actionY : 0; + const baseScaleX = preserve ? state.scaleX : 1; + const baseScaleY = preserve ? state.scaleY : 1; + + if (input.positionFrom) { + return { + scaleX: baseScaleX, + scaleY: baseScaleY, + x: input.positionFrom.x, + y: input.positionFrom.y, + }; + } + + return { + scaleX: baseScaleX, + scaleY: baseScaleY, + x: baseX, + y: baseY, + }; + } + + private characterSlotToTransform( + state: CharacterRenderState, + input: CharacterSlotInput, + from: { scaleX: number; scaleY: number; x: number; y: number }, + ): { scaleX: number; scaleY: number; x: number; y: number } { + let toX = from.x; + let toY = from.y; + if (input.positionTo) { + if (input.positionFrom) { + toX = input.positionTo.x; + toY = input.positionTo.y; + } else { + toX += input.positionTo.x; + toY += input.positionTo.y; + } + } + + let toScaleX = from.scaleX; + let toScaleY = from.scaleY; + if (input.action === "zoom") { + if (isFiniteNumber(input.scaleX)) toScaleX = input.scaleX; + if (isFiniteNumber(input.scaleY)) toScaleY = input.scaleY; + if (input.posZoom) { + toX += (0.5 - input.posZoom.x) * toScaleX * state.width; + toY += (input.posZoom.y - 0.5) * toScaleY * state.height; + } + } + + return { scaleX: toScaleX, scaleY: toScaleY, x: toX, y: toY }; + } + + private applyCharacterSlotFocus( + focusMode: CharacterSlotInput["focusMode"], + slot: string, + focusSlots?: string[], + ): void { + if (!focusMode) return; + + if (focusMode === "none") { + const state = this.characterSlots.get(slot); + if (!state) return; + state.focusBrightness = 0.5; + this.updateCharacterOpacity(state); + return; + } + + const active = + focusMode === "current_only" + ? new Set([slot]) + : new Set( + (focusSlots ?? []).map((value) => + this.normalizeCharacterSlot(value), + ), + ); + + for (const [slotKey, state] of this.characterSlots.entries()) { + state.focusBrightness = active.has(slotKey) ? 1 : 0.5; + this.updateCharacterOpacity(state); + } + } + + private applyCharacterSlotOpacity( + state: CharacterRenderState, + input: CharacterSlotInput, + durationMs: number, + ): void { + const hasFrom = isFiniteNumber(input.alphaFrom); + const hasTo = isFiniteNumber(input.alphaTo); + if (!hasFrom && !hasTo) return; + + const fromAlpha = hasFrom ? input.alphaFrom! : state.contentAlpha; + const toAlpha = hasTo ? input.alphaTo! : fromAlpha; + state.contentAlpha = fromAlpha; + this.updateCharacterOpacity(state); + + if (durationMs <= 0 || fromAlpha === toAlpha) { + state.contentAlpha = toAlpha; + this.updateCharacterOpacity(state); + return; + } + + const sessionId = ++state.opacitySessionId; + void this.tween( + durationMs, + (progress) => { + if ( + !this.isActiveCharacterState(state) || + state.opacitySessionId !== sessionId + ) + return; + state.contentAlpha = fromAlpha + (toAlpha - fromAlpha) * progress; + this.updateCharacterOpacity(state); + }, + () => { + if ( + !this.isActiveCharacterState(state) || + state.opacitySessionId !== sessionId + ) + return; + state.contentAlpha = toAlpha; + this.updateCharacterOpacity(state); + }, + ); + } + + private enterOffset(direction?: CharacterSlotInput["enterFrom"]): { + x: number; + y: number; + } { + switch (direction) { + case "left": { + return { x: -STORY_WIDTH, y: 0 }; + } + case "right": { + return { x: STORY_WIDTH, y: 0 }; + } + case "up": { + return { x: 0, y: -STORY_HEIGHT }; + } + case "down": { + return { x: 0, y: STORY_HEIGHT }; + } + default: { + return { x: 0, y: 0 }; + } + } + } + + private async fadeOutAndRemove( + container: Container, + durationMs: number, + done?: () => void, + ): Promise { + const startAlpha = container.alpha; + await this.tween( + durationMs, + (progress) => { + container.alpha = startAlpha * (1 - progress); + }, + () => { + container.removeFromParent(); + done?.(); + }, + ); + } + + private updateCharacterState(state: CharacterRenderState): void { + state.motionLayer.x = state.actionX + state.shakeOffsetX; + state.motionLayer.y = + state.shakeOffsetY - state.actionY - state.jumpOffsetY; + state.motionLayer.scale.set( + state.baseScaleX * state.scaleX, + state.baseScaleY * state.scaleY, + ); + state.rotationLayer.rotation = (state.rotationDeg * Math.PI) / 180; + this.updateCharacterOpacity(state); + } + + private updateCharacterOpacity(state: CharacterRenderState): void { + const channel = Math.round(0xff * clamp01(state.focusBrightness)); + state.visual.tint = (channel << 16) | (channel << 8) | channel; + state.visual.alpha = state.contentAlpha; + } + + private disposeCharacterState(state: CharacterRenderState): void { + state.opacitySessionId += 1; + state.replaceFadeSessionId += 1; + state.transformSessionId += 1; + state.jumpSessionId += 1; + this.stopRotateAction(state); + this.stopShakeAction(state); + state.root.removeFromParent(); + } + + private normalizeCharacterSlot(slot?: string): string { + switch ((slot ?? "").trim().toLowerCase()) { + case "char_left": + case "l": + case "left": { + return "l"; + } + case "c": + case "center": + case "char_middle": + case "m": + case "middle": { + return "m"; + } + case "char_right": + case "r": + case "right": { + return "r"; + } + default: { + return "m"; + } + } + } + + private isCharacterSlotCommand(input: CharacterSlotInput): boolean { + return ( + input.focusMode !== undefined || + input.focusSlots !== undefined || + input.positionFrom !== undefined || + input.positionTo !== undefined || + input.alphaFrom !== undefined || + input.alphaTo !== undefined || + input.action !== undefined || + input.scaleX !== undefined || + input.scaleY !== undefined || + input.posZoom !== undefined || + input.preserveTransform !== undefined || + input.resetTransform !== undefined || + input.replaceFadeMs !== undefined || + input.power !== undefined || + input.randomness !== undefined || + input.stop !== undefined || + input.times !== undefined || + input.characterKey === undefined || + input.expression === undefined + ); + } + + private stopShakeAction(state: CharacterRenderState): void { + state.shakeSessionId += 1; + if (state.shakeTimeout) { + clearTimeout(state.shakeTimeout); + state.shakeTimeout = null; + } + state.shakeOffsetX = 0; + state.shakeOffsetY = 0; + this.updateCharacterState(state); + } + + private stopRotateAction(state: CharacterRenderState): void { + state.rotateSessionId += 1; + if (state.rotateTimeout) { + clearTimeout(state.rotateTimeout); + state.rotateTimeout = null; + } + state.rotationDeg = 0; + this.updateCharacterState(state); + } + + private disposeCurtainState(state: CurtainRenderState): void { + state.tweenSessionId += 1; + state.graphic.removeFromParent(); + state.graphic.destroy(); + } + + private removeCurtainState( + direction: number, + state: CurtainRenderState, + ): void { + if (this.curtains.get(direction) !== state) return; + this.curtains.delete(direction); + this.disposeCurtainState(state); + } + + private async runMoveAction( + state: CharacterRenderState, + xOffset: number, + yOffset: number, + durationMs: number, + block: boolean, + ): Promise { + const fromX = state.actionX; + const fromY = state.actionY; + const toX = fromX + xOffset; + const toY = fromY + yOffset; + const sessionId = ++state.transformSessionId; + + const run = this.tween( + durationMs, + (progress) => { + if (!this.isActiveCharacterState(state, sessionId)) return; + state.actionX = fromX + (toX - fromX) * progress; + state.actionY = fromY + (toY - fromY) * progress; + this.updateCharacterState(state); + }, + () => { + if (!this.isActiveCharacterState(state, sessionId)) return; + state.actionX = toX; + state.actionY = toY; + this.updateCharacterState(state); + }, + ); + + if (block && durationMs > 0) await run; + } + + private async runScaleAction( + state: CharacterRenderState, + scaleX: number | undefined, + scaleY: number | undefined, + xOffset: number, + yOffset: number, + durationMs: number, + block: boolean, + ): Promise { + const fromX = state.actionX; + const fromY = state.actionY; + const fromScaleX = state.scaleX; + const fromScaleY = state.scaleY; + const toX = fromX + xOffset; + const toY = fromY + yOffset; + const toScaleX = isFiniteNumber(scaleX) ? scaleX : state.scaleX; + const toScaleY = isFiniteNumber(scaleY) ? scaleY : state.scaleY; + const sessionId = ++state.transformSessionId; + + const run = this.tween( + durationMs, + (progress) => { + if (!this.isActiveCharacterState(state, sessionId)) return; + state.actionX = fromX + (toX - fromX) * progress; + state.actionY = fromY + (toY - fromY) * progress; + state.scaleX = fromScaleX + (toScaleX - fromScaleX) * progress; + state.scaleY = fromScaleY + (toScaleY - fromScaleY) * progress; + this.updateCharacterState(state); + }, + () => { + if (!this.isActiveCharacterState(state, sessionId)) return; + state.actionX = toX; + state.actionY = toY; + state.scaleX = toScaleX; + state.scaleY = toScaleY; + this.updateCharacterState(state); + }, + ); + + if (block && durationMs > 0) await run; + } + + private async runExitAction( + state: CharacterRenderState, + durationMs: number, + direction: CharacterActionInput["direction"], + yOffset: number, + block: boolean, + ): Promise { + const toX = + direction === "left" + ? -(state.baseX + state.width + 64) + : STORY_WIDTH - state.baseX + 64; + const fromX = state.actionX; + const fromY = state.actionY; + const toY = fromY + yOffset; + const sessionId = ++state.transformSessionId; + + const run = this.tween( + durationMs, + (progress) => { + if (!this.isActiveCharacterState(state, sessionId)) return; + state.actionX = fromX + (toX - fromX) * progress; + state.actionY = fromY + (toY - fromY) * progress; + this.updateCharacterState(state); + }, + () => { + if (!this.isActiveCharacterState(state, sessionId)) return; + state.actionX = toX; + state.actionY = toY; + this.updateCharacterState(state); + }, + ); + + if (block && durationMs > 0) await run; + } + + private startJumpAction( + state: CharacterRenderState, + power: number, + times: number, + durationMs: number, + ): void { + const sessionId = ++state.jumpSessionId; + state.jumpOffsetY = 0; + this.updateCharacterState(state); + + if (power <= 0 || durationMs <= 0) return; + + const iterations = Math.max(1, times); + void (async () => { + for (let index = 0; index < iterations; index += 1) { + if ( + !this.isActiveCharacterState(state) || + state.jumpSessionId !== sessionId + ) + return; + + await this.tween(durationMs, (progress) => { + if ( + !this.isActiveCharacterState(state) || + state.jumpSessionId !== sessionId + ) + return; + const cycle = progress <= 0.5 ? progress / 0.5 : (1 - progress) / 0.5; + state.jumpOffsetY = Math.max(0, power * cycle); + this.updateCharacterState(state); + }); + } + + if ( + !this.isActiveCharacterState(state) || + state.jumpSessionId !== sessionId + ) + return; + state.jumpOffsetY = 0; + this.updateCharacterState(state); + })(); + } + + private startShakeAction( + state: CharacterRenderState, + input: CharacterActionInput, + ): void { + this.stopShakeAction(state); + if (input.stop || input.power <= 0) return; + + const sessionId = state.shakeSessionId; + const intervalMs = + input.times > 0 + ? Math.max(1, Math.round(Math.max(input.durationMs, 1) / input.times)) + : Math.max(1, input.durationMs || 33); + let remainingTicks = input.times < 0 ? -1 : Math.max(1, input.times); + + const tick = (): void => { + if ( + !this.isActiveCharacterState(state) || + state.shakeSessionId !== sessionId + ) + return; + + state.shakeOffsetX = this.pickShakeOffset(input.power, input.randomness); + state.shakeOffsetY = this.pickShakeOffset(input.power, input.randomness); + this.updateCharacterState(state); + + if (remainingTicks > 0) remainingTicks -= 1; + + if (remainingTicks === 0) { + state.shakeTimeout = null; + state.shakeOffsetX = 0; + state.shakeOffsetY = 0; + this.updateCharacterState(state); + return; + } + + state.shakeTimeout = setTimeout(tick, intervalMs); + }; + + tick(); + } + + private isActiveCharacterState( + state: CharacterRenderState, + transformSessionId?: number, + ): boolean { + if (!this.app) return false; + const current = this.characterSlots.get(state.slot); + if (current !== state) return false; + if ( + transformSessionId !== undefined && + state.transformSessionId !== transformSessionId + ) + return false; + return true; + } + + private isActiveCurtainState( + direction: number, + state: CurtainRenderState, + tweenSessionId?: number, + ): boolean { + if (!this.app) return false; + const current = this.curtains.get(direction); + if (current !== state) return false; + if (tweenSessionId !== undefined && state.tweenSessionId !== tweenSessionId) + return false; + return true; + } + + private bumpStickerSessions(id: string): void { + this.stickerTypingSessionIds.set( + id, + (this.stickerTypingSessionIds.get(id) ?? 0) + 1, + ); + this.stickerFadeSessionIds.set( + id, + (this.stickerFadeSessionIds.get(id) ?? 0) + 1, + ); + } + + private clearTimerInterval(): void { + if (!this.timerStickerInterval) return; + clearInterval(this.timerStickerInterval); + this.timerStickerInterval = null; + } + + private stopCameraShake(sessionId?: number): void { + if (sessionId !== undefined && sessionId !== this.cameraShakeSessionId) + return; + + this.cameraShakeSessionId += 1; + this.sceneLayer.position.set(0, 0); + this.cameraShakeWaitResolve?.(); + this.cameraShakeWaitResolve = null; + } + + private readBlockerColor(blocker: Sprite): { + a: number; + b: number; + g: number; + r: number; + } { + return { + a: blocker.alpha, + b: (blocker.tint & 0xff) / 255, + g: ((blocker.tint >> 8) & 0xff) / 255, + r: ((blocker.tint >> 16) & 0xff) / 255, + }; + } + + private writeBlockerColor( + blocker: Sprite, + color: { a: number; b: number; g: number; r: number }, + ): void { + const channel = (value: number) => + Math.round(clamp01(value > 1 ? value / 255 : value) * 255); + blocker.tint = + (channel(color.r) << 16) | (channel(color.g) << 8) | channel(color.b); + blocker.alpha = clamp01(color.a); + blocker.visible = blocker.alpha > 0; + } + + // CharacterAction still uses the legacy per-tick shake model; CameraShake uses ShakePath. + private pickShakeOffset(strength: number, randomness: number): number { + const roll = Math.floor(Math.random() * 99) + 1; + const directions = [-1, -0.707, 0, 1, 0.707]; + const index = + roll < randomness ? Math.floor(Math.random() * directions.length) : 0; + return directions[index] * strength; + } + + private createOverlayTextStyle(fontSize: number, widthPx: number): TextStyle { + return new TextStyle({ + breakWords: true, + dropShadow: { + alpha: 0.9, + angle: Math.PI / 4, + blur: 4, + color: "#404040", + distance: 0, + }, + fill: "#ffffff", + fontFamily: ["Noto Sans SC", "Microsoft YaHei", "sans-serif"], + fontSize, + whiteSpace: "pre-line", + wordWrap: true, + wordWrapWidth: widthPx, + }); + } + + private ensureStickerText(id: string): Text { + const existing = this.stickerTexts.get(id); + if (existing) return existing; + + const sticker = new Text({ + style: this.createOverlayTextStyle(18, 675), + text: "", + }); + sticker.visible = false; + this.stickerTexts.set(id, sticker); + this.uiLayer.addChild(sticker); + return sticker; + } + + private ensureTimerStickerText(): Text { + if (!this.timerStickerText) { + this.timerStickerText = new Text({ + style: this.createOverlayTextStyle(18, 220), + text: "", + }); + this.timerStickerText.visible = false; + this.uiLayer.addChild(this.timerStickerText); + } + return this.timerStickerText; + } + + private formatTimer(totalSeconds: number): string { + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = Math.floor(totalSeconds % 60); + return [hours, minutes, seconds] + .map((value) => value.toString().padStart(2, "0")) + .join(":"); + } + + private layoutSubtitle( + subtitle: Text, + baseX: number, + widthPx: number, + alignment: SubtitleInput["alignment"], + ): void { + const renderedWidth = Math.min(subtitle.width, widthPx); + switch (alignment) { + case "center": { + subtitle.x = baseX + Math.max(widthPx - renderedWidth, 0) / 2; + return; + } + case "right": { + subtitle.x = baseX + Math.max(widthPx - renderedWidth, 0); + return; + } + default: { + subtitle.x = baseX; + } + } + } + + private async runSubtitleTyping( + sessionId: number, + subtitle: Text, + input: { + alignment: SubtitleInput["alignment"]; + baseX: number; + delayMs: number; + prevChars: RichChar[]; + newChars: RichChar[]; + widthPx: number; + }, + ): Promise { + for (let index = 0; index < input.newChars.length; index += 1) { + if (!this.app || this.subtitleTypingSessionId !== sessionId) return; + + await new Promise((resolve) => setTimeout(resolve, input.delayMs)); + + if (!this.app || this.subtitleTypingSessionId !== sessionId) return; + + subtitle.text = richCharsToTaggedText([ + ...input.prevChars, + ...input.newChars.slice(0, index + 1), + ]); + this.layoutSubtitle( + subtitle, + input.baseX, + input.widthPx, + input.alignment, + ); + } + + if (this.subtitleTypingSessionId === sessionId) + this.subtitleTypingTarget = null; + } + + private async runStickerTyping( + id: string, + sessionId: number, + sticker: Text, + input: { + alignment: StickerInput["alignment"]; + baseX: number; + delayMs: number; + prevChars: RichChar[]; + newChars: RichChar[]; + widthPx: number; + }, + ): Promise { + for (let index = 0; index < input.newChars.length; index += 1) { + if ( + !this.app || + (this.stickerTypingSessionIds.get(id) ?? 0) !== sessionId + ) + return; + + await new Promise((resolve) => setTimeout(resolve, input.delayMs)); + + if ( + !this.app || + (this.stickerTypingSessionIds.get(id) ?? 0) !== sessionId + ) + return; + + sticker.text = richCharsToTaggedText([ + ...input.prevChars, + ...input.newChars.slice(0, index + 1), + ]); + this.layoutSubtitle(sticker, input.baseX, input.widthPx, input.alignment); + } + + if ((this.stickerTypingSessionIds.get(id) ?? 0) === sessionId) + this.stickerTypingTargets.delete(id); + } + + private layoutImageForScreenAdapt( + sprite: Sprite, + mode?: BackgroundInput["screenAdapt"], + useViewportSizeByDefault = false, + ): void { + const sourceWidth = Math.max(1, sprite.texture.width); + const sourceHeight = Math.max(1, sprite.texture.height); + // Unity swaps Image.sprite without calling SetNativeSize, so the AVG + // background Image keeps its scene-authored 1280x720 RectTransform when + // screenadapt is omitted. Pixi otherwise defaults to the texture's native + // size (many extracted backgrounds are 1024x576), leaving visible borders. + let width = useViewportSizeByDefault ? STORY_WIDTH : sourceWidth; + let height = useViewportSizeByDefault ? STORY_HEIGHT : sourceHeight; + + if (mode === "fill") { + width = STORY_WIDTH; + height = STORY_HEIGHT; + } else if ( + mode === "width" || + (mode === "showall" && + sourceWidth / sourceHeight > STORY_WIDTH / STORY_HEIGHT) || + (mode === "coverall" && + sourceWidth / sourceHeight < STORY_WIDTH / STORY_HEIGHT) + ) { + width = STORY_WIDTH; + height = (sourceHeight * STORY_WIDTH) / sourceWidth; + } else if (mode === "height" || mode === "showall" || mode === "coverall") { + height = STORY_HEIGHT; + width = (sourceWidth * STORY_HEIGHT) / sourceHeight; + } + + sprite.width = width; + sprite.height = height; + sprite.position.set(0, 0); + } + + private readCenteredTransform(root: Container): { + scaleX: number; + scaleY: number; + x: number; + y: number; + } { + return readRootTransform(root); + } + + private applyCenteredTransform( + root: Container, + transform: { scaleX: number; scaleY: number; x: number; y: number }, + ): void { + applyCenteredTransformToRoot(root, transform); + } + + private isActiveBackground(root: Container, sessionId: number): boolean { + return Boolean( + this.app && + this.backgroundRoot === root && + this.backgroundTweenSessionId === sessionId && + root.parent === this.backgroundLayer, + ); + } + + private isActiveLargeBackground(root: Container, sessionId: number): boolean { + return Boolean( + this.app && + this.largeBackgroundRoot === root && + this.largeBackgroundTweenSessionId === sessionId && + root.parent === this.gridBackgroundLayer, + ); + } + + private isActiveLargeImage(root: Container, sessionId: number): boolean { + return Boolean( + this.app && + this.largeImageRoot === root && + this.largeImageTweenSessionId === sessionId && + root.parent === this.imageLayer, + ); + } + + private buildGridBackgroundRoot( + input: GridBackgroundInput, + textures: Texture[], + ): Container { + return buildGridRoot(input, textures); + } + + private async textureForImageKey( + key: string, + kind: "background" | "image", + ): Promise { + const rawUrl = resolveStoryAssetByKey(key, kind === "background"); + if (!rawUrl) { + this.onWarning?.(`missing ${kind}: ${key}`); + return null; + } + + return this.textureForUrl(rawUrl, kind, key); + } + + private async textureForCharacterKey(key: string): Promise { + const rawUrl = resolveStoryCharacterAssetByKey(key); + if (!rawUrl) { + this.onWarning?.(`missing character: ${key}`); + return null; + } + + return this.textureForUrl(rawUrl, "character", key); + } + + private async textureForInterlude( + input: InterludeInput, + ): Promise { + if (input.type !== 3) + return this.textureForImageKey( + input.name, + input.type === 2 ? "background" : "image", + ); + + const base = input.avatarCharacterKey; + const expression = input.avatarExpression; + if (!base || !expression) return null; + const link = this.context.linkMap[base]; + const item = link?.array.find((entry) => entry.name === expression); + if (!item) return null; + if (item.group === -1 && item.image) + return this.textureForCharacterKey(item.image); + const group = item.group >= 0 ? link.groups[item.group] : undefined; + if (group?.mode === "face_overlay") + return this.textureForCharacterKey(group.base); + return null; + } + + private async textureForUrl( + rawUrl: string, + kind: string, + key: string, + ): Promise { + const url = resolveAssetUrl(rawUrl); + try { + const texture = await Assets.load(url); + return texture; + } catch { + this.onWarning?.(`failed ${kind}: ${key}`); + return null; + } + } + + private tween( + durationMs: number, + step: (progress: number) => void, + done?: () => void, + ): Promise { + return this.tweenRunner.run(durationMs, step, done); + } +} + +export { DIALOG_FRAME_URL } from "../../assets"; diff --git a/src/widgets/StoryPlayer/engine/rendering/core/LayerGraph.ts b/src/widgets/StoryPlayer/engine/rendering/core/LayerGraph.ts new file mode 100644 index 00000000..b3e42c55 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/core/LayerGraph.ts @@ -0,0 +1,48 @@ +import { Container } from "pixi.js"; + +/** + * Web/PIXI adaptation of the scene roots consumed by + * `Torappu.AVG.AVGCameraEffect._ExecuteCameraShake` and the AVG panels. + * It preserves the documented `SceneCanvas/panel_avg` sibling relationships, + * while flattening Unity canvases into PIXI containers rather than porting + * Unity's Canvas implementation. + */ +export class LayerGraph { + readonly background = new Container(); + readonly avgDisplayBackground = new Container(); + readonly avgDisplayCharacter = new Container(); + readonly avgDisplayCg = new Container(); + readonly characters = new Container(); + readonly cgItems = new Container(); + readonly cutins = new Container(); + readonly curtains = new Container(); + readonly gridBackground = new Container(); + readonly images = new Container(); + readonly items = new Container(); + readonly scene = new Container(); + readonly ui = new Container(); + readonly world = new Container(); + + attach(stage: Container): void { + this.background.addChild(this.gridBackground); + // Unity SceneCanvas sibling order: large background, background, image, + // character cutin/items, then characters. Existing panels are attached to + // their nearest stable web equivalent until their strict pass is migrated. + this.scene.addChild(this.background); + this.scene.addChild(this.avgDisplayBackground); + this.scene.addChild(this.images); + this.scene.addChild(this.avgDisplayCg); + this.scene.addChild(this.cutins); + this.scene.addChild(this.items); + this.scene.addChild(this.characters); + this.scene.addChild(this.avgDisplayCharacter); + this.world.addChild(this.scene); + // CgItemCanvas is a separate root canvas after SceneCanvas, so it sits + // outside `scene`: camerashake moves AVGCameraEffect._sceneRoot, which is + // the SceneCanvas-side panel_avg only, and must not drag CG items along. + this.world.addChild(this.cgItems); + this.world.addChild(this.curtains); + stage.addChild(this.world); + stage.addChild(this.ui); + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts b/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts new file mode 100644 index 00000000..850ab325 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry.ts @@ -0,0 +1,215 @@ +import { Container, Sprite, type Texture } from "pixi.js"; + +import { + STORY_HEIGHT, + STORY_WIDTH, + type GridBackgroundInput, +} from "../../types"; + +export interface CenteredTransform { + scaleX: number; + scaleY: number; + x: number; + y: number; +} + +const largeBackgroundInitOffsets = new WeakMap< + Container, + { x: number; y: number } +>(); + +function largeBackgroundInitOffset(input: GridBackgroundInput): { + x: number; + y: number; +} { + if (input.layout !== "large" || input.initPositionMode === undefined) + return { x: 0, y: 0 }; + + const widths = input.solidWidths; + const height = input.solidHeights[0] ?? 0; + // Unity's children use a top-left anchor/pivot, while the flattened Pixi + // root uses a centered pivot. The latter already contributes -height / 2, + // so convert native initOffset.y into that centered coordinate space. + const centeredY = (nativeY: number) => nativeY + height / 2; + switch (input.initPositionMode) { + case "center": { + return { x: 0, y: centeredY(0) }; + } + case "upperleft": { + return { + x: (widths.reduce((sum, width) => sum + width, 0) - STORY_WIDTH) / 2, + y: centeredY((STORY_HEIGHT - height * 2) / 2), + }; + } + case "lowercenter": { + return { x: 0, y: centeredY((height * 2 - STORY_HEIGHT) / 2) }; + } + default: { + return { x: (widths[1] ?? 0) / 2, y: centeredY(-height / 2) }; + } + } +} + +function repeat360(value: number): number { + const wrapped = value - Math.floor(value / 360) * 360; + return Math.min(360, Math.max(0, wrapped)); +} + +/** + * Port of `Torappu.AVG.AVGUtils.CreateRotateTween`'s signed sweep for + * `AVGImagePanel._ExecuteImageRotate`; this only reproduces the angle choice, + * not DOTween's Unity transform tween. + * + * `inverse` is a direction switch, not just a sign for `circles`: with + * `circles = 0` a clockwise rotation still rewrites any positive delta into + * delta - 360, so `angle=90` sweeps -270 rather than +90. + */ +export function rotateTweenDelta( + currentAngle: number, + targetAngle: number, + circles: number, + inverse: boolean, +): number { + const current = repeat360(currentAngle); + const end = repeat360(targetAngle); + let delta = repeat360(end - current); + if (delta > 180) delta -= 360; + + const circlesDeg = circles * 360; + if (inverse) { + if (delta < 0) delta += 360; + return delta + circlesDeg; + } + if (delta > 0) delta -= 360; + return delta - circlesDeg; +} + +export function layoutCover( + sprite: Sprite, + x = STORY_WIDTH / 2, + y = STORY_HEIGHT / 2, +): void { + const ratio = Math.max( + STORY_WIDTH / Math.max(1, sprite.texture.width), + STORY_HEIGHT / Math.max(1, sprite.texture.height), + ); + sprite.scale.set(ratio); + sprite.position.set(x, y); +} + +export function readCenteredTransform(root: Container): CenteredTransform { + const initOffset = largeBackgroundInitOffsets.get(root) ?? { x: 0, y: 0 }; + return { + scaleX: root.scale.x, + scaleY: root.scale.y, + x: root.position.x - STORY_WIDTH / 2 - initOffset.x, + y: STORY_HEIGHT / 2 - root.position.y - initOffset.y, + }; +} + +export function applyCenteredTransform( + root: Container, + transform: CenteredTransform, +): void { + const initOffset = largeBackgroundInitOffsets.get(root) ?? { x: 0, y: 0 }; + root.position.set( + STORY_WIDTH / 2 + initOffset.x + transform.x, + STORY_HEIGHT / 2 - initOffset.y - transform.y, + ); + root.scale.set(transform.scaleX, transform.scaleY); +} + +export function buildGridBackgroundRoot( + input: GridBackgroundInput, + textures: Texture[], +): Container { + const root = new Container(); + const initOffset = largeBackgroundInitOffset(input); + largeBackgroundInitOffsets.set(root, initOffset); + root.position.set( + STORY_WIDTH / 2 + initOffset.x + input.x, + STORY_HEIGHT / 2 - initOffset.y - input.y, + ); + root.scale.set(input.scaleX, input.scaleY); + + if (input.layout === "vertical") { + const width = input.solidWidths[0]!; + let offsetY = 0; + for (const [index, texture] of textures.entries()) { + const height = input.solidHeights[index]!; + const sprite = new Sprite(texture); + sprite.width = width; + sprite.height = height; + sprite.position.set(0, offsetY); + root.addChild(sprite); + offsetY += height; + } + // `LargeBackgroundPanel._ExecuteVerticalBG` sizes its RectTransform from + // the first two heights but still places every child. This PIXI pivot is + // the coordinate-system adaptation of that quirk. + const pivotHeight = + (input.solidHeights[0] ?? 0) + (input.solidHeights[1] ?? 0); + root.pivot.set(width / 2, pivotHeight / 2); + return root; + } + + if (input.layout === "large") { + let offsetX = 0; + for (const [index, texture] of textures.entries()) { + const width = input.solidWidths[index]!; + const height = input.solidHeights[0]!; + const sprite = new Sprite(texture); + sprite.width = width; + sprite.height = height; + sprite.position.set(offsetX, 0); + root.addChild(sprite); + offsetX += width; + } + const totalWidth = input.solidWidths[0]! + input.solidWidths[1]!; + const totalHeight = input.solidHeights[0]!; + root.pivot.set(totalWidth / 2, totalHeight / 2); + return root; + } + + const rows: Array< + Array<{ height: number; texture: Texture; width: number }> + > = []; + for (let index = 0; index < textures.length; index += 2) { + rows.push( + textures.slice(index, index + 2).map((texture, offset) => ({ + height: input.solidHeights[Math.floor((index + offset) / 2)]!, + texture, + width: input.solidWidths[(index + offset) % 2]!, + })), + ); + } + let totalWidth = 0; + let totalHeight = 0; + for (const row of rows) { + let rowWidth = 0; + let rowHeight = 0; + for (const item of row) { + rowWidth += item.width; + rowHeight = Math.max(rowHeight, item.height); + } + totalWidth = Math.max(totalWidth, rowWidth); + totalHeight += rowHeight; + } + let offsetY = 0; + for (const row of rows) { + let offsetX = 0; + let rowHeight = 0; + for (const item of row) { + const sprite = new Sprite(item.texture); + sprite.width = item.width; + sprite.height = item.height; + sprite.position.set(offsetX, offsetY); + root.addChild(sprite); + offsetX += item.width; + rowHeight = Math.max(rowHeight, item.height); + } + offsetY += rowHeight; + } + root.pivot.set(totalWidth / 2, totalHeight / 2); + return root; +} diff --git a/src/widgets/StoryPlayer/engine/rendering/core/ShakePath.ts b/src/widgets/StoryPlayer/engine/rendering/core/ShakePath.ts new file mode 100644 index 00000000..7a8ced84 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/core/ShakePath.ts @@ -0,0 +1,91 @@ +export interface ShakePoint { + durationRatio: number; + x: number; + y: number; +} + +export interface ShakePathInput { + fadeOut: boolean; + randomness: number; + vibrato: number; + xStrength: number; + yStrength: number; +} + +type RandomSource = () => number; + +function randomRange(min: number, max: number, random: RandomSource): number { + return min + (max - min) * random(); +} + +/** + * Ports the waypoint selection used by + * `Torappu.AVG.AVGCameraEffect._ExecuteCameraShake` through DOTween's vector + * shake overload. Sampling and scheduling below are a Web/PIXI adaptation. + */ +export function buildShakePath( + durationMs: number, + input: ShakePathInput, + random: RandomSource = Math.random, +): ShakePoint[] { + const durationSeconds = Math.max(0, durationMs) / 1000; + const count = Math.max( + 2, + Math.trunc(Math.max(0, input.vibrato) * durationSeconds), + ); + const randomness = Math.max(0, input.randomness); + const rawDurations = Array.from({ length: count }, (_, index) => + input.fadeOut ? index + 1 : 1, + ); + const durationTotal = rawDurations.reduce((total, value) => total + value, 0); + let angle = randomRange(0, 360, random); + + return rawDurations.map((rawDuration, index) => { + if (index === count - 1) + return { durationRatio: rawDuration / durationTotal, x: 0, y: 0 }; + + if (index > 0) + angle = angle - 180 + randomRange(-randomness, randomness, random); + + const radians = (angle * Math.PI) / 180; + // Full mode also rotates the waypoint around Vector3.up by an independent + // random angle. CameraShake has zStrength=0, so the resulting Z component + // is discarded and only the attenuated X component remains visible. + const tilt = (randomRange(-randomness, randomness, random) * Math.PI) / 180; + const fade = input.fadeOut ? 1 - index / count : 1; + return { + durationRatio: rawDuration / durationTotal, + x: Math.cos(radians) * Math.cos(tilt) * input.xStrength * fade, + y: Math.sin(radians) * input.yStrength * fade, + }; + }); +} + +export function sampleShakePath( + path: ShakePoint[], + progress: number, +): { x: number; y: number } { + const target = Math.min(1, Math.max(0, progress)); + let elapsed = 0; + let fromX = 0; + let fromY = 0; + + for (const point of path) { + const end = elapsed + point.durationRatio; + if (target <= end || point === path.at(-1)) { + const localProgress = + point.durationRatio > 0 + ? Math.min(1, (target - elapsed) / point.durationRatio) + : 1; + return { + x: fromX + (point.x - fromX) * localProgress, + y: fromY + (point.y - fromY) * localProgress, + }; + } + elapsed = end; + fromX = point.x; + fromY = point.y; + } + + return { x: 0, y: 0 }; +} diff --git a/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts b/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts new file mode 100644 index 00000000..5fcefda1 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/core/TweenRunner.ts @@ -0,0 +1,44 @@ +import { browserAnimationClock, type AnimationClock } from "../../execution"; + +/** + * Web-only animation adapter. AVG executors create DOTween sequences; callers + * preserve their documented command timing while this class uses browser frames + * instead of claiming a one-to-one native port. + */ +export class TweenRunner { + constructor( + private readonly isAlive: () => boolean, + private readonly clock: AnimationClock = browserAnimationClock, + ) {} + + run( + durationMs: number, + step: (progress: number) => void, + done?: () => void, + ): Promise { + if (durationMs <= 0) { + step(1); + done?.(); + return Promise.resolve(); + } + return new Promise((resolve) => { + const start = this.clock.now(); + const tick = () => { + if (!this.isAlive()) { + done?.(); + resolve(); + return; + } + const progress = Math.min(1, (this.clock.now() - start) / durationMs); + step(progress); + if (progress >= 1) { + done?.(); + resolve(); + return; + } + this.clock.requestFrame(tick); + }; + this.clock.requestFrame(tick); + }); + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts new file mode 100644 index 00000000..58e6af19 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/AnimTextPanel.ts @@ -0,0 +1,271 @@ +import { + Assets, + Container, + Sprite, + Text, + TextStyle, + type Texture, +} from "pixi.js"; + +import { STAMP_ASSETS } from "../../../assets"; +import { STORY_HEIGHT, STORY_WIDTH, type AnimTextInput } from "../../types"; + +const ANIMATION_MS = 5000; + +interface StampView { + root: Container; + sessionId: number; +} + +function lerpKeyframes( + time: number, + frames: ReadonlyArray, +): number { + if (time <= frames[0]![0]) return frames[0]![1]; + for (let index = 1; index < frames.length; index += 1) { + const next = frames[index]!; + const previous = frames[index - 1]!; + if (time <= next[0]) { + const progress = (time - previous[0]) / (next[0] - previous[0]); + return previous[1] + (next[1] - previous[1]) * progress; + } + } + return frames.at(-1)![1]; +} + +function splitContent(content: string): string[] { + return [...content.matchAll(/(.*?)<\/>/gs)].map( + (match) => match[1] ?? "", + ); +} + +/** + * Port scope: `Torappu.AVG.AVGDisplayableExecutor._ExecuteAnimatedText` and + * the serialized `AVG/AnimateText/group_location_stamp` prefab's visible + * timeline. Sprite construction and keyframe playback are a Web/PIXI + * adaptation, not a port of Unity Animator internals. + */ +export class AnimTextPanel { + private readonly stamps: StampView[] = []; + private sessionId = 0; + + constructor( + private readonly layer: Container, + private readonly tween: ( + durationMs: number, + update: (progress: number) => void, + complete?: () => void, + ) => Promise, + private readonly onWarning?: (detail: string) => void, + ) {} + + async show(input: AnimTextInput): Promise { + if (input.name !== "group_location_stamp") { + this.onWarning?.(`unsupported_visual animtext:${input.name}`); + return; + } + + const names = [ + "back_shadow", + "frame_outer", + "icon_back", + "frame_inner", + "icon_comps", + "icon_start", + "back_gradient", + ] as const; + let textures: Record<(typeof names)[number], Texture>; + try { + const loaded = await Promise.all( + names.map((name) => Assets.load(STAMP_ASSETS[name])), + ); + textures = Object.fromEntries( + names.map((name, index) => [name, loaded[index]]), + ) as Record<(typeof names)[number], Texture>; + } catch { + this.onWarning?.("missing animtext prefab sprites: group_location_stamp"); + return; + } + + const root = new Container(); + root.position.set( + STORY_WIDTH / 2 + input.position.x, + STORY_HEIGHT / 2 - input.position.y, + ); + + const shadow = this.sprite( + textures.back_shadow, + root, + -244.14, + 0, + 520, + 209.06, + 0, + 0.5, + ); + const outer = this.sprite(textures.frame_outer, root, -143.42, 0, 123, 123); + const iconGroup = new Container(); + iconGroup.position.set(-143.42, 0); + root.addChild(iconGroup); + const iconBack = this.sprite(textures.icon_back, iconGroup, 0, 0, 74, 74); + iconBack.alpha = 0.6; + const inner = this.sprite(textures.frame_inner, iconGroup, 0, 0, 53, 53); + inner.alpha = 0.6; + const comps = this.sprite(textures.icon_comps, iconGroup, 0, 0, 14, 42); + comps.alpha = 0; + const bricks = [ + [-5.5, -5.5], + [5.5, -5.5], + [5.5, 5.5], + [-5.5, 5.5], + ].map(() => { + const brick = this.sprite(textures.icon_start, iconGroup, 0, 0, 11, 11); + brick.alpha = 0; + return brick; + }); + + const textGroup = new Container(); + textGroup.position.set(-93.42, 0); + root.addChild(textGroup); + const gradient = this.sprite( + textures.back_gradient, + textGroup, + -70, + 0, + 260, + 87, + ); + gradient.anchor.set(0.5); + gradient.alpha = 0.15; + const parts = splitContent(input.content); + const main = new Text({ + style: new TextStyle({ + fill: "#ffffff", + fontFamily: "Noto Sans SC, sans-serif", + fontSize: 30, + }), + text: parts[0] ?? "", + }); + const sub = new Text({ + style: new TextStyle({ + fill: "#ffffff", + fontFamily: "Noto Sans SC, sans-serif", + fontSize: 26, + }), + text: parts[1] ?? "", + }); + main.anchor.set(0, 0.5); + sub.anchor.set(0, 0.5); + main.position.set(0, -20); + sub.position.set(0, 20); + main.visible = input.style !== "avg_only_medium"; + sub.visible = input.style !== "avg_only_heavy"; + textGroup.addChild(main, sub); + + root.alpha = 0; + this.layer.addChild(root); + const view = { root, sessionId: ++this.sessionId }; + this.stamps.push(view); + const run = this.tween(ANIMATION_MS, (progress) => { + if (!this.stamps.includes(view)) return; + const time = progress * 5; + root.alpha = lerpKeyframes(time, [ + [0, 0], + [1 / 3, 1], + [4, 1], + [5, 0], + ]); + outer.width = outer.height = lerpKeyframes(time, [ + [0, 25], + [1 / 3, 109.74], + [5 / 6, 123], + [5, 140.01], + ]); + inner.width = inner.height = lerpKeyframes(time, [ + [0, 16], + [1 / 3, 50.1], + [5 / 6, 53], + [5, 55.81], + ]); + outer.alpha = lerpKeyframes(time, [ + [0, 1], + [2.5, 1], + [4.5, 0], + ]); + iconGroup.alpha = lerpKeyframes(time, [ + [0, 1], + [4.5, 1], + [5, 0], + ]); + comps.alpha = lerpKeyframes(time, [ + [0, 0], + [0.95, 0], + [1.1167, 1], + ]); + textGroup.x = lerpKeyframes(time, [ + [0, -121.03], + [1 / 3, -97.31], + [5 / 6, -93.42], + [5, -85.35], + ]); + for (const [index, brick] of bricks.entries()) { + const [endX, endY] = [ + [-5.5, -5.5], + [5.5, -5.5], + [5.5, 5.5], + [-5.5, 5.5], + ][index]!; + brick.alpha = lerpKeyframes(time, [ + [0, 0], + [1 / 12, 1], + [0.8167, 1], + [0.9667, 0], + ]); + brick.position.set( + lerpKeyframes(time, [ + [0, 0], + [2 / 3, endX * 0.92], + [0.9167, endX], + ]), + lerpKeyframes(time, [ + [0, 0], + [2 / 3, endY * 0.92], + [0.9167, endY], + ]), + ); + } + shadow.visible = true; + }); + if (input.block) await run; + else void run; + } + + clear(): void { + this.sessionId += 1; + for (const stamp of this.stamps) stamp.root.destroy({ children: true }); + this.stamps.length = 0; + } + + destroy(): void { + this.clear(); + } + + private sprite( + texture: Texture, + parent: Container, + x: number, + y: number, + width: number, + height: number, + anchorX = 0.5, + anchorY = 0.5, + ): Sprite { + const sprite = new Sprite(texture); + sprite.anchor.set(anchorX, anchorY); + sprite.position.set(x, y); + sprite.width = width; + sprite.height = height; + parent.addChild(sprite); + return sprite; + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts new file mode 100644 index 00000000..d018d35a --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/AvgDisplayPanel.ts @@ -0,0 +1,188 @@ +import { Container, Sprite, type Texture } from "pixi.js"; + +import type { AvgDisplayInput, AvgDisplaySlot } from "../../types"; + +interface AvgDisplayState { + name: string; + root: Container; + sessionId: number; + slot: AvgDisplaySlot; + style: string; +} + +type TextureLoader = (name: string) => Promise; +type Tween = ( + durationMs: number, + update: (progress: number) => void, + complete?: () => void, +) => Promise; + +/** + * Port scope: `Torappu.AVG.AVGDisplayableExecutor._ExecuteAVGDisplayable`. + * It retains id replacement, slot/style routing, and command timing; PIXI + * containers and the currently supported `bg` asset are Web adaptations. + */ +export class AvgDisplayPanel { + private readonly states = new Map(); + + constructor( + private readonly slots: Record<"bgover" | "cgover" | "charover", Container>, + private readonly loadBackground: TextureLoader, + private readonly tween: Tween, + private readonly onWarning?: (detail: string) => void, + ) {} + + clear(): void { + for (const state of this.states.values()) + state.root.destroy({ children: true }); + this.states.clear(); + } + + destroy(): void { + this.clear(); + } + + async display(input: AvgDisplayInput): Promise { + if (!input.name) { + this.remove(input.id); + return; + } + + const parent = this.containerFor(input.slot); + if (!parent) { + this.onWarning?.( + `invalid_parameter avgdisplay slot:${input.slot || "(empty)"}`, + ); + return; + } + + let state = this.states.get(input.id); + if (state && (state.slot !== input.slot || state.style !== input.style)) { + this.remove(input.id); + state = undefined; + } + + if (!state) { + state = { + name: "", + root: new Container(), + sessionId: 0, + slot: input.slot, + style: input.style, + }; + parent.addChild(state.root); + this.states.set(input.id, state); + } + + if (state.name !== input.name) { + for (const child of state.root.removeChildren()) child.destroy(); + state.name = input.name; + await this.generateContent(state, input); + } + + const sessionId = ++state.sessionId; + const root = state.root; + root.zIndex = input.layer; + parent.sortableChildren = true; + + const currentX = root.position.x - 640; + const currentY = 360 - root.position.y; + const startX = input.xFrom ?? input.x ?? currentX; + const startY = input.yFrom ?? input.y ?? currentY; + const endX = input.xTo ?? startX; + const endY = input.yTo ?? startY; + const startScaleX = input.scaleXFrom ?? input.scaleX ?? root.scale.x; + const startScaleY = input.scaleYFrom ?? input.scaleY ?? root.scale.y; + const endScaleX = input.scaleXTo ?? startScaleX; + const endScaleY = input.scaleYTo ?? startScaleY; + const supportsPosition = + input.style === "effect" || + input.style === "bgeffect" || + input.style === "animekv" || + input.style === "character"; + const supportsScale = input.style === "animekv"; + const supportsFade = input.style === "bg"; + const hasEntry = + input.style === "animekv" && + (input.entryFrom !== undefined || input.entryTo !== undefined); + + if (supportsPosition) root.position.set(640 + startX, 360 - startY); + if (supportsScale) root.scale.set(startScaleX, startScaleY); + if ( + input.style === "effect" || + input.style === "bgeffect" || + input.style === "character" + ) + root.rotation = (input.rotationZ * Math.PI) / 180; + if (supportsFade) root.alpha = input.alphaFrom ?? 1; + + const hasPositionTween = + supportsPosition && (startX !== endX || startY !== endY); + const hasScaleTween = + supportsScale && (startScaleX !== endScaleX || startScaleY !== endScaleY); + const alphaTo = input.alphaTo ?? 0; + const hasFadeTween = supportsFade && root.alpha !== alphaTo; + const hasTween = + input.durationMs > 0 && + (hasPositionTween || hasScaleTween || hasFadeTween || hasEntry); + + const apply = (progress: number) => { + if (state?.sessionId !== sessionId) return; + if (supportsPosition) + root.position.set( + 640 + startX + (endX - startX) * progress, + 360 - (startY + (endY - startY) * progress), + ); + if (supportsScale) + root.scale.set( + startScaleX + (endScaleX - startScaleX) * progress, + startScaleY + (endScaleY - startScaleY) * progress, + ); + if (supportsFade) + root.alpha = + (input.alphaFrom ?? 1) + + (alphaTo - (input.alphaFrom ?? 1)) * progress; + }; + + if (!hasTween) { + apply(1); + return; + } + const run = this.tween(input.durationMs, apply); + if (input.block) await run; + else void run; + } + + private containerFor(slot: AvgDisplaySlot): Container | null { + if (slot === "bgover" || slot === "cgover" || slot === "charover") + return this.slots[slot]; + return null; + } + + private async generateContent( + state: AvgDisplayState, + input: AvgDisplayInput, + ): Promise { + if (input.style === "bg") { + const texture = await this.loadBackground(input.name); + if (!texture) return; + const sprite = new Sprite(texture); + sprite.anchor.set(0.5); + sprite.width = 1280; + sprite.height = 720; + state.root.addChild(sprite); + return; + } + this.onWarning?.( + `unsupported_visual avgdisplay:${input.style}:${input.name}`, + ); + } + + private remove(id: string): void { + const state = this.states.get(id); + if (!state) return; + state.sessionId += 1; + state.root.destroy({ children: true }); + this.states.delete(id); + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts new file mode 100644 index 00000000..d7a2014e --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel.ts @@ -0,0 +1,247 @@ +import { Container, Sprite, type Texture } from "pixi.js"; + +import { STORY_HEIGHT, STORY_WIDTH, type CgItemInput } from "../../types"; + +type TextureLoader = (key: string) => Promise; +type Tween = ( + durationMs: number, + update: (progress: number) => void, + complete?: () => void, +) => Promise; + +interface CgItemState { + root: Container; + sessionId: number; + sprite: Sprite; +} + +function lerp(from: number, to: number, progress: number): number { + return from + (to - from) * progress; +} + +function colorChannel(value: number): number { + return Math.max(0, Math.min(255, Math.round(value * 255))); +} + +function easeProgress(raw: number, ease: string): number { + switch (ease.toLowerCase()) { + case "linear": { + return raw; + } + case "inquad": { + return raw * raw; + } + case "inoutquad": { + return raw < 0.5 ? 2 * raw * raw : 1 - (-2 * raw + 2) ** 2 / 2; + } + default: { + return 1 - (1 - raw) * (1 - raw); + } + } +} + +function rgb(color: { r: number; g: number; b: number }): number { + return ( + (colorChannel(color.r) << 16) | + (colorChannel(color.g) << 8) | + colorChannel(color.b) + ); +} + +/** + * Port scope: `Torappu.AVG.AVGCgItemPanel._ExecuteShowCgItem` and + * `_ExecuteHideCgItem`, including independent delayed tracks and clear-all's + * non-blocking completion. PIXI sprites substitute for `AVGShowItemCgSlot`. + */ +export class CgItemPanel { + private readonly states = new Map(); + + constructor( + private readonly layer: Container, + private readonly loadTexture: TextureLoader, + private readonly tween: Tween, + private readonly onWarning?: (detail: string) => void, + ) {} + + async show(input: CgItemInput): Promise { + const texture = await this.loadTexture(input.assetKey); + if (!texture) { + this.onWarning?.(`cgitem asset is missing: ${input.assetKey}`); + return; + } + + this.dispose(input.key); + const root = new Container(); + const sprite = new Sprite(texture); + sprite.anchor.set(0.5); + sprite.setSize(texture.width, texture.height); + root.position.set(STORY_WIDTH / 2, STORY_HEIGHT / 2); + root.addChild(sprite); + this.layer.addChild(root); + const state = { root, sessionId: 1, sprite }; + this.states.set(input.key, state); + const sessionId = state.sessionId; + + if (input.width > 0 && input.height > 0) + sprite.setSize(input.width, input.height); + + const runs: Promise[] = []; + const run = ( + delayMs: number, + durationMs: number, + update: (progress: number) => void, + ) => { + const totalMs = Math.max(0, delayMs) + Math.max(0, durationMs); + runs.push( + this.tween(totalMs, (raw) => { + if (state.sessionId !== sessionId) return; + const elapsed = raw * totalMs; + const local = + durationMs <= 0 + ? 1 + : Math.max(0, Math.min(1, (elapsed - delayMs) / durationMs)); + update(easeProgress(local, input.ease)); + }), + ); + }; + + // `Torappu.AVG.AVGShowItemCgSlot.Show` drives Transform.localPosition and + // `_GenPosByRaw` keeps the raw y sign. PIXI uses sprite.position as the + // coordinate-system adaptation, so do not inherit sticker's UI-y inversion. + if (input.positionFrom && input.positionTo) { + if (input.positionDurationMs > 0) { + sprite.position.set(input.positionFrom.x, input.positionFrom.y); + run(input.positionDelayMs, input.positionDurationMs, (progress) => + sprite.position.set( + lerp(input.positionFrom!.x, input.positionTo!.x, progress), + lerp(input.positionFrom!.y, input.positionTo!.y, progress), + ), + ); + } else { + sprite.position.set(input.positionTo.x, input.positionTo.y); + } + } + + if (input.scaleFrom !== input.scaleTo) { + if (input.scaleDurationMs > 0) { + sprite.scale.set(input.scaleFrom); + run(input.scaleDelayMs, input.scaleDurationMs, (progress) => + sprite.scale.set(lerp(input.scaleFrom, input.scaleTo, progress)), + ); + } else { + sprite.scale.set(input.scaleTo); + } + } + + if (input.colorFrom && input.colorTo) { + if (input.alphaDurationMs > 0) { + sprite.tint = rgb(input.colorFrom); + sprite.alpha = input.colorFrom.a; + run(input.alphaDelayMs, input.alphaDurationMs, (progress) => { + const color = { + a: lerp(input.colorFrom!.a, input.colorTo!.a, progress), + b: lerp(input.colorFrom!.b, input.colorTo!.b, progress), + g: lerp(input.colorFrom!.g, input.colorTo!.g, progress), + r: lerp(input.colorFrom!.r, input.colorTo!.r, progress), + }; + sprite.tint = rgb(color); + sprite.alpha = color.a; + }); + } else { + sprite.tint = rgb(input.colorTo); + sprite.alpha = input.colorTo.a; + } + } else if (input.alphaFrom !== input.alphaTo) { + if (input.alphaDurationMs > 0) { + sprite.alpha = input.alphaFrom; + run(input.alphaDelayMs, input.alphaDurationMs, (progress) => { + sprite.alpha = lerp(input.alphaFrom, input.alphaTo, progress); + }); + } else { + sprite.alpha = input.alphaTo; + } + } + + // `AVGCgItemPanel._ExecuteShowCgItem` intentionally guards this branch + // with `rfrom > 0`; retain that behavior despite the surprising condition. + if (input.rotationFrom <= 0) { + if (input.rotationDurationMs > 0) { + sprite.angle += input.rotationFrom; + run(0, input.rotationDurationMs, (progress) => { + sprite.angle = lerp(input.rotationFrom, input.rotationTo, progress); + }); + } else { + sprite.angle += input.rotationTo; + } + } + + const completion = Promise.all(runs).then(() => {}); + if (input.block) await completion; + else void completion; + } + + async hide( + key: string | undefined, + fadeMs: number, + ease: string, + block: boolean, + ): Promise { + if (this.states.size === 0) return; + if (key) { + const state = this.states.get(key); + if (!state) return; + const sessionId = ++state.sessionId; + const startAlpha = state.root.alpha; + const task = this.tween( + fadeMs, + (raw) => { + state.root.alpha = lerp(startAlpha, 0, easeProgress(raw, ease)); + }, + () => { + if (state.sessionId === sessionId) this.dispose(key); + }, + ); + if (block) await task; + else void task; + return; + } + + const entries = [...this.states.entries()]; + this.states.clear(); + for (const [, state] of entries) { + const startAlpha = state.root.alpha; + void this.tween( + fadeMs, + (raw) => { + state.root.alpha = lerp(startAlpha, 0, easeProgress(raw, ease)); + }, + () => state.root.destroy({ children: true }), + ); + } + // `_ExecuteHideCgItem` completes clear-all immediately even if `block=true`. + } + + targets(key: string): Container[] { + if (!key) return [...this.states.values()].map((state) => state.root); + const state = this.states.get(key); + return state ? [state.root] : []; + } + + clear(): void { + for (const state of this.states.values()) + state.root.destroy({ children: true }); + this.states.clear(); + } + + destroy(): void { + this.clear(); + } + + private dispose(key: string): void { + const state = this.states.get(key); + if (!state) return; + state.sessionId += 1; + state.root.destroy({ children: true }); + this.states.delete(key); + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts new file mode 100644 index 00000000..615c4583 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/DecisionPanel.ts @@ -0,0 +1,102 @@ +import { + Container, + Graphics, + Text, + TextStyle, + type Container as ContainerType, +} from "pixi.js"; + +import { DIALOG_FONT_FAMILY } from "../../font"; +import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; + +function paintButton( + background: Graphics, + color: number, + width: number, + height: number, +): void { + background + .clear() + .roundRect(0, 0, width, height, 4) + .fill({ color }) + .stroke({ color: 0xff_ff_ff, width: 2 }); +} + +/** + * Web/PIXI presentation for `Torappu.AVG.DecisionPanel._ExecuteDecision`. + * The runtime owns predicate and skip policy; this class only blocks for a + * selected value and adapts native option widgets to browser pointer events. + */ +export class DecisionPanel { + private container: Container | null = null; + private resolve: ((value: number) => void) | null = null; + + constructor(private readonly layer: ContainerType) {} + + show(options: string[], values: number[]): Promise { + this.clear(); + const container = new Container(); + const overlay = new Graphics() + .rect(0, 0, STORY_WIDTH, STORY_HEIGHT) + .fill({ alpha: 0.4, color: 0x00_00_00 }); + overlay.eventMode = "static"; + container.addChild(overlay); + const buttonWidth = 480; + const buttonHeight = 42; + const stride = 64; + const startY = + (STORY_HEIGHT - (options.length * stride - (stride - buttonHeight))) / 2; + const startX = (STORY_WIDTH - buttonWidth) / 2; + + for (const [index, option] of options.entries()) { + const button = new Container(); + button.position.set(startX, startY + index * stride); + button.eventMode = "static"; + button.cursor = "pointer"; + const background = new Graphics(); + paintButton(background, 0x30_30_30, buttonWidth, buttonHeight); + const label = new Text({ + style: new TextStyle({ + align: "center", + fill: "#ffffff", + fontFamily: [DIALOG_FONT_FAMILY, "sans-serif"], + fontSize: 20, + }), + text: option, + }); + label.anchor.set(0.5); + label.position.set(buttonWidth / 2, buttonHeight / 2); + button.addChild(background, label); + button.on("pointerover", () => + paintButton(background, 0x50_50_50, buttonWidth, buttonHeight), + ); + button.on("pointerout", () => + paintButton(background, 0x30_30_30, buttonWidth, buttonHeight), + ); + button.on("pointertap", () => { + const resolve = this.resolve; + this.clear(); + resolve?.(values[index] ?? index + 1); + }); + container.addChild(button); + } + this.layer.addChild(container); + this.container = container; + return new Promise((resolve) => { + this.resolve = resolve; + }); + } + + clear(resolveValue?: number): void { + const resolve = this.resolve; + this.resolve = null; + this.container?.removeFromParent(); + this.container?.destroy({ children: true }); + this.container = null; + if (resolveValue !== undefined) resolve?.(resolveValue); + } + + destroy(): void { + this.clear(0); + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts new file mode 100644 index 00000000..2fb8a15a --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/DialogPanel.ts @@ -0,0 +1,195 @@ +import { + Assets, + CanvasTextMetrics, + Sprite, + Text, + TextStyle, + Texture, + type Container, +} from "pixi.js"; + +import { DIALOG_FRAME_URL } from "../../../assets"; +import { DIALOG_FONT_FAMILY } from "../../font"; +import { STORY_HEIGHT, STORY_WIDTH } from "../../types"; + +/** + * Web/PIXI reconstruction of the visual surface used by + * `Torappu.AVG.DialogPanel._ExecuteDialog`. Command sequencing, typewriter, + * and click semantics remain in the runtime; this class adapts scene-authored + * Unity UI layout and text measurement to PIXI. + */ +export class DialogPanel { + private bottomGradient: Sprite | null = null; + private dialogue: Text | null = null; + private panel: Sprite | null = null; + private speaker: Text | null = null; + private topGradient: Sprite | null = null; + + private static readonly BOTTOM_HEIGHT = 182; + private static readonly NAME_LEFT = 20.7501; + private static readonly NAME_WIDTH = 312.3; + private static readonly NAME_TOP = 84.9; + private static readonly NAME_FONT_MAX = 30; + private static readonly NAME_FONT_MIN = 25; + private static readonly MESSAGE_LEFT = 23.88; + private static readonly MESSAGE_ANCHOR_X = 0.28; + private static readonly MESSAGE_TOP = 93.5; + private static readonly MESSAGE_WIDTH = 735; + private static readonly MESSAGE_MAX_HEIGHT = 89; + private static readonly NAME_MAX_HEIGHT = 65; + + constructor( + private readonly layer: Container, + private readonly warn?: (detail: string) => void, + ) {} + + async mount(): Promise { + let top: Sprite | null = null; + let bottom: Sprite | null = null; + try { + const texture = await Assets.load(DIALOG_FRAME_URL); + top = new Sprite(texture); + top.width = STORY_WIDTH; + top.height = 102; + top.anchor.set(0, 1); + top.scale.y *= -1; + top.position.set(0, 0); + top.tint = 0x20_21_25; + top.alpha = 0.7059; + bottom = new Sprite(texture); + bottom.width = STORY_WIDTH; + bottom.height = DialogPanel.BOTTOM_HEIGHT; + bottom.position.set(0, STORY_HEIGHT - DialogPanel.BOTTOM_HEIGHT); + } catch { + this.warn?.("failed ui: sprite_avg_cutscene"); + } + + const panel = new Sprite(Texture.WHITE); + Object.assign(panel, { + alpha: 0.72, + height: 170, + tint: 0x00_00_00, + width: STORY_WIDTH - 40, + x: 20, + y: STORY_HEIGHT - 190, + }); + const speaker = new Text({ + style: new TextStyle({ + align: "right", + fill: "#929292", + fontFamily: [DIALOG_FONT_FAMILY, "sans-serif"], + fontSize: DialogPanel.NAME_FONT_MAX, + fontWeight: "700", + }), + text: "", + }); + speaker.anchor.set(1, 0); + speaker.position.set( + DialogPanel.NAME_LEFT + DialogPanel.NAME_WIDTH, + STORY_HEIGHT - DialogPanel.BOTTOM_HEIGHT + DialogPanel.NAME_TOP, + ); + const dialogue = new Text({ + style: new TextStyle({ + align: "left", + breakWords: true, + fill: "#ffffff", + fontFamily: [DIALOG_FONT_FAMILY, "sans-serif"], + fontSize: 24, + fontWeight: "700", + lineHeight: 0, + whiteSpace: "pre", + wordWrap: true, + wordWrapWidth: DialogPanel.MESSAGE_WIDTH, + }), + text: "", + }); + dialogue.position.set( + STORY_WIDTH * DialogPanel.MESSAGE_ANCHOR_X + DialogPanel.MESSAGE_LEFT, + STORY_HEIGHT - DialogPanel.MESSAGE_TOP, + ); + + this.topGradient = top; + this.bottomGradient = bottom; + this.panel = panel; + this.speaker = speaker; + this.dialogue = dialogue; + if (top) this.layer.addChild(top); + if (bottom) this.layer.addChild(bottom); + this.layer.addChild(speaker, dialogue); + } + + setDialogue( + speaker: string, + text: string, + tagStyles?: Record, + ): void { + if (this.speaker) { + this.speaker.text = speaker; + this.applyNameBestFit(speaker); + } + if (this.dialogue) { + if (tagStyles) this.dialogue.style.tagStyles = tagStyles; + this.dialogue.text = text; + } + this.applyLayout(); + const visible = Boolean(speaker || text); + for (const item of [ + this.topGradient, + this.bottomGradient, + this.panel, + this.speaker, + this.dialogue, + ]) { + if (item) item.visible = visible; + } + } + + destroy(): void { + this.topGradient = null; + this.bottomGradient = null; + this.panel = null; + this.speaker = null; + this.dialogue = null; + } + + private applyNameBestFit(name: string): void { + if (!this.speaker) return; + for ( + let size = DialogPanel.NAME_FONT_MAX; + size >= DialogPanel.NAME_FONT_MIN; + size -= 1 + ) { + this.speaker.style.fontSize = size; + if ( + !name || + CanvasTextMetrics.measureText(name, this.speaker.style).width <= + DialogPanel.NAME_WIDTH + ) + return; + } + } + + private applyLayout(): void { + if (this.dialogue) { + const height = this.dialogue.text + ? CanvasTextMetrics.measureText(this.dialogue.text, this.dialogue.style) + .height + : 0; + this.dialogue.y = + STORY_HEIGHT - + DialogPanel.MESSAGE_TOP - + Math.max(0, height - DialogPanel.MESSAGE_MAX_HEIGHT); + } + if (this.speaker) { + const height = this.speaker.text + ? CanvasTextMetrics.measureText(this.speaker.text, this.speaker.style) + .height + : 0; + this.speaker.y = + STORY_HEIGHT - + DialogPanel.BOTTOM_HEIGHT + + DialogPanel.NAME_TOP - + Math.max(0, height - DialogPanel.NAME_MAX_HEIGHT); + } + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts new file mode 100644 index 00000000..54093afe --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/FocusEffectPanel.ts @@ -0,0 +1,109 @@ +import { BlurFilter, ColorMatrixFilter, type Container } from "pixi.js"; + +import type { FocusOutInput, FocusParamInput } from "../../types"; + +type Tween = ( + durationMs: number, + update: (progress: number) => void, + complete?: () => void, +) => Promise; +type ResolveTargets = (type: string, id: string) => Container[]; + +interface FocusState { + amount: number; + id: string; + sessionId: number; + type: string; +} + +/** + * Port scope: `Torappu.AVG.AVGCameraEffect._ExecuteFocusout` and + * `_ExecuteFocusParam` channel state. Applying `BlurFilter` and + * `ColorMatrixFilter` per PIXI container is a Web adaptation of the native + * `AVGSceneEffectManager` post-processing pipeline. + */ +export class FocusEffectPanel { + private readonly states = new Map(); + private readonly filteredTargets = new Set(); + private config: FocusParamInput = { blur: true, color: "None" }; + + constructor( + private readonly resolveTargets: ResolveTargets, + private readonly tween: Tween, + ) {} + + setParam(input: FocusParamInput): void { + this.config = input; + this.render(); + } + + async setFocus(input: FocusOutInput): Promise { + const key = `${input.type}\u0000${input.id}`; + const state = this.states.get(key) ?? { + amount: 0, + id: input.id, + sessionId: 0, + type: input.type, + }; + this.states.set(key, state); + + if (input.from !== undefined) { + state.amount = input.from; + this.render(); + } + + const from = state.amount; + const sessionId = ++state.sessionId; + const run = this.tween( + input.durationMs, + (progress) => { + if (state.sessionId !== sessionId) return; + state.amount = from + (input.to - from) * progress; + this.render(); + }, + () => { + if (state.sessionId !== sessionId) return; + state.amount = input.to; + this.render(); + }, + ); + + if (input.block && input.durationMs > 0) await run; + else void run; + } + + destroy(): void { + for (const state of this.states.values()) state.sessionId += 1; + this.states.clear(); + for (const target of this.filteredTargets) target.filters = []; + this.filteredTargets.clear(); + } + + private render(): void { + const amounts = new Map(); + for (const state of this.states.values()) { + for (const target of this.resolveTargets(state.type, state.id)) + amounts.set(target, Math.max(amounts.get(target) ?? 0, state.amount)); + } + + for (const target of this.filteredTargets) { + if (!amounts.has(target)) target.filters = []; + } + this.filteredTargets.clear(); + + for (const [target, rawAmount] of amounts) { + const amount = Math.max(0, Math.min(1, rawAmount)); + const filters = []; + if (amount > 0 && this.config.blur) + filters.push(new BlurFilter({ strength: amount * 8 })); + if (amount > 0 && this.config.color !== "None") { + const color = new ColorMatrixFilter(); + if (this.config.color === "Grayscale") color.grayscale(amount, false); + else color.negative(false); + filters.push(color); + } + target.filters = filters; + if (filters.length > 0) this.filteredTargets.add(target); + } + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts new file mode 100644 index 00000000..70087344 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel.ts @@ -0,0 +1,226 @@ +import { + Container, + Graphics, + Sprite, + Text, + TextStyle, + type Texture, +} from "pixi.js"; + +import { STORY_HEIGHT, STORY_WIDTH, type InterludeInput } from "../../types"; + +type TextureLoader = (input: InterludeInput) => Promise; +type Tween = ( + durationMs: number, + update: (progress: number) => void, + complete?: () => void, +) => Promise; + +interface ChannelState { + elements: Map; + mask: Graphics; + root: Container; + sessionId: number; + size: { x: number; y: number }; +} + +function lerp(from: number, to: number, progress: number): number { + return from + (to - from) * progress; +} + +/** + * Port scope: `Torappu.AVG.AVGCharacterCutinPanel._ExecuteInterlude`, including + * channel replacement, template masking, and block timing. Containers/masks + * are a Web/PIXI adaptation of the native character-cutin prefab hierarchy. + */ +export class InterludePanel { + private readonly channels = new Map(); + + constructor( + private readonly layer: Container, + private readonly loadTexture: TextureLoader, + private readonly tween: Tween, + private readonly onWarning?: (detail: string) => void, + ) {} + + async run(input: InterludeInput): Promise { + if (input.clear) { + await this.clear(input); + return; + } + if (input.channel < 0) { + this.onWarning?.("interlude has no channel and is not a clear command"); + return; + } + + const state = this.channels.get(input.channel) ?? this.createChannel(input); + const sessionId = ++state.sessionId; + this.updateTemplate(state, input); + + if (input.type === 0) return; + + const slot = input.slot || "m"; + let element = state.elements.get(slot); + if (input.name) { + const texture = await this.loadTexture(input); + if (!texture) { + this.onWarning?.(`interlude element asset is missing: ${input.name}`); + return; + } + element?.destroy({ children: true }); + element = new Container(); + const sprite = new Sprite(texture); + sprite.anchor.set(0.5); + element.addChild(sprite); + state.root.addChild(element); + state.elements.set(slot, element); + element.alpha = Math.max(input.alphaFrom, 0); + } + if (!element) { + this.onWarning?.(`interlude element slot is missing: ${slot}`); + return; + } + + element.visible = input.switchOn; + if (input.positionFrom && input.positionTo) + element.position.set(input.positionFrom.x, -input.positionFrom.y); + if (input.scaleFrom) + element.scale.set(input.scaleFrom.x, input.scaleFrom.y); + + const duration = Math.max( + input.durationMs, + input.scaleDurationMs, + input.alphaDurationMs, + ); + const startAlpha = input.alphaFrom >= 0 ? input.alphaFrom : element.alpha; + const endAlpha = input.alphaTo >= 0 ? input.alphaTo : 1; + const run = this.tween(duration, (progress) => { + if (state.sessionId !== sessionId) return; + if (input.positionFrom && input.positionTo) { + element!.position.set( + lerp(input.positionFrom.x, input.positionTo.x, progress), + -lerp(input.positionFrom.y, input.positionTo.y, progress), + ); + } + if (input.scaleFrom && input.scaleTo) { + element!.scale.set( + lerp(input.scaleFrom.x, input.scaleTo.x, progress), + lerp(input.scaleFrom.y, input.scaleTo.y, progress), + ); + } + element!.alpha = lerp(startAlpha, endAlpha, progress); + }); + if (input.block) await run; + else void run; + } + + async clearAll(): Promise { + for (const state of this.channels.values()) + state.root.destroy({ children: true }); + this.channels.clear(); + } + + destroy(): void { + void this.clearAll(); + } + + private createChannel(input: InterludeInput): ChannelState { + const root = new Container(); + const mask = new Graphics(); + root.position.set( + STORY_WIDTH / 2 + input.offset.x, + STORY_HEIGHT / 2 - input.offset.y, + ); + root.addChild(mask); + root.mask = mask; + this.layer.addChild(root); + const state = { + elements: new Map(), + mask, + root, + sessionId: 0, + size: { ...input.size }, + }; + this.channels.set(input.channel, state); + return state; + } + + private updateTemplate(state: ChannelState, input: InterludeInput): void { + if (input.size.x > 0) state.size.x = input.size.x; + if (input.size.y > 0) state.size.y = input.size.y; + state.root.position.set( + STORY_WIDTH / 2 + input.offset.x, + STORY_HEIGHT / 2 - input.offset.y, + ); + state.root.visible = input.switchOn; + this.drawMask(state.mask, state.size.x, state.size.y); + + const oldLabel = state.root.getChildByLabel("interlude-char"); + oldLabel?.destroy(); + if (input.charName) { + const label = new Text({ + style: new TextStyle({ fill: 0xff_ff_ff, fontSize: 24 }), + text: input.charName, + }); + label.label = "interlude-char"; + label.anchor.set(0.5); + state.root.addChild(label); + } + + if (input.templateSizeDurationMs > 0) { + const from = input.templateSizeFrom; + const to = input.templateSizeTo; + void this.tween(input.templateSizeDurationMs, (progress) => { + this.drawMask( + state.mask, + state.size.x * lerp(from.x, to.x, progress), + state.size.y * lerp(from.y, to.y, progress), + ); + }); + } + } + + private async clear(input: InterludeInput): Promise { + const states = + input.channel < 0 + ? [...this.channels.entries()] + : [...this.channels.entries()].filter( + ([channel]) => channel === input.channel, + ); + const duration = input.templateSizeDurationMs; + const tasks = states.map(async ([channel, state]) => { + const sessionId = ++state.sessionId; + const run = this.tween( + duration, + (progress) => { + if (state.sessionId !== sessionId) return; + const x = lerp( + input.templateSizeFrom.x, + input.templateSizeTo.x, + progress, + ); + const y = lerp( + input.templateSizeFrom.y, + input.templateSizeTo.y, + progress, + ); + this.drawMask(state.mask, state.size.x * x, state.size.y * y); + }, + () => { + state.root.destroy({ children: true }); + this.channels.delete(channel); + }, + ); + if (input.block) await run; + else void run; + }); + if (input.block) await Promise.all(tasks); + } + + private drawMask(mask: Graphics, width: number, height: number): void { + mask + .clear() + .rect(-width / 2, -height / 2, width, height) + .fill(0xff_ff_ff); + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts new file mode 100644 index 00000000..00634cad --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel.ts @@ -0,0 +1,126 @@ +import { + Container, + Text, + TextStyle, + type Container as ContainerType, +} from "pixi.js"; + +import { DIALOG_FONT_FAMILY } from "../../font"; +import { STORY_HEIGHT, STORY_WIDTH, type SpellStickerInput } from "../../types"; + +interface SpellStickerView { + root: Container; + style: string; +} + +function splitContent(content: string): [string, string] { + const parts = new Map(); + const pattern = /([\s\S]*?)<\/>/gi; + for (const match of content.matchAll(pattern)) + parts.set(Number(match[1]), match[2] ?? ""); + return [parts.get(1) ?? "", parts.get(2) ?? ""]; +} + +/** + * Port scope: `Torappu.AVG.SpellStickerPanel._ExecuteSpellSticker` and + * `_ExecuteSpellStickerClear` state transitions. The two supported styles are + * lightweight PIXI approximations of their prefab visuals, not Animator ports. + */ +export class SpellStickerPanel { + private readonly orphans = new Set(); + private readonly views = new Map(); + + constructor( + private readonly layer: ContainerType, + private readonly warn?: (detail: string) => void, + ) {} + + show(input: SpellStickerInput): void { + const style = input.style.toLowerCase(); + if (style !== "sami" && style !== "fire") { + this.warn?.(`missing spellsticker style: ${input.style}`); + return; + } + + let view = this.views.get(input.id); + if (view && view.style !== style) { + this.views.delete(input.id); + this.orphans.add(view.root); + view = undefined; + } + if (!view) { + view = this.createView(style); + this.views.set(input.id, view); + } + + const [mainText, subText] = splitContent(input.content); + const main = view.root.getChildByLabel("text_spell_main") as Text; + const sub = view.root.getChildByLabel("text_spell_sub") as Text; + main.text = mainText; + sub.text = subText; + view.root.visible = true; + view.root.alpha = Math.max(0, Math.min(1, input.alpha)); + if (input.x !== undefined) view.root.x = STORY_WIDTH / 2 + input.x; + if (input.y !== undefined) view.root.y = STORY_HEIGHT / 2 - input.y; + if (input.xScale !== undefined) view.root.scale.x = input.xScale; + if (input.yScale !== undefined) view.root.scale.y = input.yScale; + if (input.angle !== undefined) view.root.angle = input.angle; + this.warn?.(`unsupported_visual spellsticker:${style}`); + } + + hide(id: string): void { + const view = this.views.get(id); + if (view) view.root.visible = false; + } + + clear(): void { + for (const view of this.views.values()) this.destroyRoot(view.root); + for (const root of this.orphans) this.destroyRoot(root); + this.views.clear(); + this.orphans.clear(); + } + + destroy(): void { + this.clear(); + } + + private createView(style: string): SpellStickerView { + const root = new Container(); + root.position.set(STORY_WIDTH / 2, STORY_HEIGHT / 2); + const sami = style === "sami"; + const main = new Text({ + label: "text_spell_main", + style: new TextStyle({ + fill: "#ffffff", + fontFamily: [DIALOG_FONT_FAMILY, "sans-serif"], + fontSize: sami ? 32 : 24, + fontStyle: "italic", + fontWeight: "bold", + }), + text: "", + }); + main.scale.set(sami ? 0.9 : 1, sami ? 1.1 : 1); + main.position.set(36, -52); + const sub = new Text({ + label: "text_spell_sub", + style: new TextStyle({ + fill: "rgba(255,255,255,0.5608)", + fontFamily: [DIALOG_FONT_FAMILY, "sans-serif"], + fontSize: sami ? 16 : 14, + fontStyle: sami ? "italic" : "normal", + fontWeight: sami ? "bold" : "normal", + }), + text: "", + }); + sub.scale.set(sami ? 0.9 : 0.88, sami ? 1.2 : 1); + sub.position.set(sami ? -147.5 : -189.4, 19.5); + root.addChild(main, sub); + this.layer.addChild(root); + return { root, style }; + } + + private destroyRoot(root: Container): void { + root.removeFromParent(); + root.destroy({ children: true }); + } +} diff --git a/src/widgets/StoryPlayer/engine/rendering/panels/VideoPanel.ts b/src/widgets/StoryPlayer/engine/rendering/panels/VideoPanel.ts new file mode 100644 index 00000000..45fb5ab3 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/rendering/panels/VideoPanel.ts @@ -0,0 +1,109 @@ +import type { Container } from "pixi.js"; + +/** + * Web adaptation of `Torappu.AVG.AVGVideoPanel._ExecuteVideo` / `_PlayVideo`. + * It preserves full-screen UI suppression and completion timing, while native + * Unity video/player lifecycle is represented by an HTMLVideoElement. + */ +export class VideoPanel { + private active: HTMLVideoElement | null = null; + private host: HTMLDivElement | null = null; + private sessionId = 0; + + constructor( + private readonly uiLayer: Container, + private readonly warn?: (detail: string) => void, + ) {} + + mount(parent: HTMLElement): void { + if (this.host) return; + const host = document.createElement("div"); + Object.assign(host.style, { + alignItems: "center", + background: "#000", + display: "none", + inset: "0", + justifyContent: "center", + overflow: "hidden", + position: "absolute", + }); + parent.append(host); + this.host = host; + } + + async play(url: string): Promise { + if (!this.host) return; + this.stop(); + const host = this.host; + const video = document.createElement("video"); + const sessionId = ++this.sessionId; + this.active = video; + this.uiLayer.visible = false; + host.style.display = "flex"; + host.replaceChildren(video); + Object.assign(video, { + autoplay: true, + playsInline: true, + preload: "auto", + src: url, + }); + Object.assign(video.style, { + background: "#000", + display: "block", + height: "100%", + width: "100%", + }); + + await new Promise((resolve) => { + let settled = false; + let onEnded!: () => void; + let onError!: () => void; + const finish = (failed: boolean) => { + if (settled) return; + settled = true; + if (failed) this.warn?.(`video playback failed: ${url}`); + video.removeEventListener("ended", onEnded); + video.removeEventListener("error", onError); + if (this.active === video) this.active = null; + video.pause(); + video.removeAttribute("src"); + video.load(); + video.remove(); + if (this.sessionId === sessionId && this.host) { + this.host.style.display = "none"; + this.host.replaceChildren(); + this.uiLayer.visible = true; + } + resolve(); + }; + onEnded = () => finish(false); + onError = () => finish(true); + video.addEventListener("ended", onEnded); + video.addEventListener("error", onError); + void video.play().catch(onError); + }); + } + + stop(): void { + this.sessionId += 1; + const video = this.active; + this.active = null; + if (video) { + video.pause(); + video.removeAttribute("src"); + video.load(); + video.remove(); + } + if (this.host) { + this.host.style.display = "none"; + this.host.replaceChildren(); + } + this.uiLayer.visible = true; + } + + destroy(): void { + this.stop(); + this.host?.remove(); + this.host = null; + } +} diff --git a/src/widgets/StoryPlayer/engine/richtext.ts b/src/widgets/StoryPlayer/engine/richtext.ts new file mode 100644 index 00000000..23f5a705 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/richtext.ts @@ -0,0 +1,85 @@ +export interface RichChar { + char: string; + color: string | null; +} + +const COLOR_TAG_RE = /]+)>([\s\S]*?)<\/color>/gi; + +/** + * Native provenance: Unity `UnityEngine.UI.Text` rich-text handling as used by + * `Torappu.AVG.AVGTypeWriterText.BeginText` and the dialog/sticker/subtitle + * executors. + * + * Ports only `` spans into PIXI tag styles. Other Unity rich-text tags + * remain outside this focused web adaptation. + * + */ + +export function parseRichChars(text: string): RichChar[] { + const re = new RegExp(COLOR_TAG_RE.source, COLOR_TAG_RE.flags); + const chars: RichChar[] = []; + let lastIndex = 0; + let match = re.exec(text); + while (match !== null) { + const plain = text.slice(lastIndex, match.index); + for (const char of plain) { + chars.push({ char, color: null }); + } + const color = match[1]; + for (const char of match[2]) { + chars.push({ char, color }); + } + lastIndex = match.index + match[0].length; + match = re.exec(text); + } + const remaining = text.slice(lastIndex); + for (const char of remaining) { + chars.push({ char, color: null }); + } + return chars; +} + +export function collectColors(chars: RichChar[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const { color } of chars) { + if (color != null && !seen.has(color)) { + seen.add(color); + result.push(color); + } + } + return result; +} + +function colorTagName(color: string): string { + return `_c${color.replace("#", "")}`; +} + +export function richCharsToTaggedText(chars: RichChar[]): string { + if (chars.length === 0) return ""; + + const parts: string[] = []; + let currentColor: string | null = null; + + for (const { char, color } of chars) { + if (color !== currentColor) { + if (currentColor !== null) parts.push(``); + if (color !== null) parts.push(`<${colorTagName(color)}>`); + currentColor = color; + } + parts.push(char); + } + if (currentColor !== null) parts.push(``); + + return parts.join(""); +} + +export function buildTagStyles( + colors: string[], +): Record { + const styles: Record = {}; + for (const color of colors) { + styles[colorTagName(color)] = { fill: color }; + } + return styles; +} diff --git a/src/widgets/StoryPlayer/engine/runtime.ts b/src/widgets/StoryPlayer/engine/runtime.ts new file mode 100644 index 00000000..096615c5 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/runtime.ts @@ -0,0 +1,2819 @@ +import { resolveStoryAudioByKey, resolveStoryVideoByKey } from "./asset"; +import { CommandRegistry } from "./commandRegistry"; +import { parseScript } from "./parser"; +import { + buildTagStyles, + collectColors, + parseRichChars, + richCharsToTaggedText, + type RichChar, +} from "./richtext"; +import { expandStoryText } from "./textVariables"; + +import type { Context } from "../context"; +import type { + AutoPlayMode, + AutoPlayState, + AvgDisplayInput, + BackgroundInput, + CharacterActionInput, + CharacterSlotInput, + InterludeElementType, + InterludeInput, + ParsedCommandLine, + PlayerState, + RuntimeOptions, + RuntimeWarning, + SpellStickerInput, + StickerInput, + StoryAudio, + StoryCommandArgs, + StoryRenderer, + SubtitleInput, + TimerClearInput, + TimerStickerInput, +} from "./types"; + +type CommandResult = "continue" | "wait_input"; + +interface AutoSpeed { + animateRatio: number; + autoWaitBaseTime: number; + autoWaitTimePerText: number; + typeWriterDelayMs: number; +} + +const BUTTON_AUTO_SPEEDS: readonly AutoSpeed[] = [ + { + animateRatio: 1, + autoWaitBaseTime: 1.5, + autoWaitTimePerText: 0.03, + typeWriterDelayMs: 40, + }, + { + animateRatio: 1, + autoWaitBaseTime: 0.5, + autoWaitTimePerText: 0.015, + typeWriterDelayMs: 10, + }, + { + animateRatio: 1, + autoWaitBaseTime: 0.25, + autoWaitTimePerText: 0.01, + typeWriterDelayMs: 10, + }, +]; + +const QUICK_AUTO_SPEEDS: readonly AutoSpeed[] = [ + { + animateRatio: 0, + autoWaitBaseTime: 0.2, + autoWaitTimePerText: 0, + typeWriterDelayMs: 10, + }, + { + animateRatio: 0, + autoWaitBaseTime: 0.1, + autoWaitTimePerText: 0, + typeWriterDelayMs: 5, + }, + { + animateRatio: 0, + autoWaitBaseTime: 0.05, + autoWaitTimePerText: 0, + typeWriterDelayMs: 2.5, + }, + { + animateRatio: 0, + autoWaitBaseTime: 0.025, + autoWaitTimePerText: 0, + typeWriterDelayMs: 1.25, + }, +]; +const TYPE_WRITER_ORIGIN_DELAY_MS = 40; + +const builtinCommandNames = [ + "endtip", + "dialog", + "delay", + "video", + "skipnode", + "skiptothis", + "background", + "backgroundtween", + "gridbg", + "verticalbg", + "largebg", + "largeimg", + "image", + "showitem", + "hideitem", + "imagetween", + "imagerotate", + "largebgtween", + "largeimgtween", + "blocker", + "curtain", + "charslot", + "character", + "characteraction", + "charactercutin", + "interlude", + "animtext", + "avgdisplay", + "playmusic", + "stopmusic", + "musicvolume", + "playsound", + "stopsound", + "soundvolume", + "cameraeffect", + "camerashake", + "focusout", + "focusparam", + "multiline", + "subtitle", + "sticker", + "timersticker", + "timerclear", + "stickerclear", + "spellsticker", + "spellstickerclear", + "theater", + "decision", + "predicate", + "bgeffect", + "cgitem", + "effect", + "hidecgitem", +] as const; + +const unsupportedAd8Commands = new Set(["bgeffect", "effect"]); + +interface InterruptibleWait { + id: number; + interrupt?: () => void; + resolve: () => void; +} + +function sleepWithTimeout(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function toNumber(value: unknown, fallback = 0): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) + return Number(value); + return fallback; +} + +function toOptionalNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) + return Number(value); + return undefined; +} + +function toBoolean(value: unknown, fallback = false): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + if (value === "true") return true; + if (value === "false") return false; + } + return fallback; +} + +function toString(value: unknown, fallback = ""): string { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + return fallback; +} + +function parseNumberSequence(value: unknown): number[] { + if (typeof value === "number" && Number.isFinite(value)) return [value]; + if (typeof value !== "string") return []; + + return value + .split("/") + .map((part) => { + const normalized = part.trim().replaceAll(",", ""); + return Number(normalized); + }) + .filter((item) => Number.isFinite(item)); +} + +function parseVector2(value: unknown): { x: number; y: number } | undefined { + if (typeof value !== "string" || !value.trim()) return undefined; + const parts = value.split(","); + if (parts.length < 2) return undefined; + const x = Number(parts[0]!.trim()); + const y = Number(parts[1]!.trim()); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined; +} + +function parseCgVector2(value: unknown): { x: number; y: number } | undefined { + if (typeof value !== "string") return undefined; + const parts = value.split(","); + if (parts.length !== 2) return undefined; + const x = Number.parseFloat(parts[0]!.trim()); + const y = Number.parseFloat(parts[1]!.trim()); + return { + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0, + }; +} + +function parseCgColor( + value: unknown, +): { r: number; g: number; b: number; a: number } | undefined { + if (typeof value !== "string") return undefined; + const parts = value.split(","); + if (parts.length !== 4) return undefined; + const values = parts.map((part) => { + const parsed = Number.parseFloat(part.trim()); + return Number.isFinite(parsed) ? parsed : 1; + }); + return { a: values[3]!, b: values[2]!, g: values[1]!, r: values[0]! }; +} + +function parseInterludeType(value: unknown): InterludeElementType { + const raw = toString(value).trim(); + if (raw === "uichar") return 1; + if (raw === "bg") return 2; + if (raw === "char") return 3; + const numeric = Math.trunc(toNumber(value, 0)); + return numeric === 1 || numeric === 2 || numeric === 3 ? numeric : 0; +} + +function resolveGroupedAssetSelection( + imageGroup: string, + cgGroup: string, + imageGroupKind: "background" | "image" = "background", +): { assetKind: "background" | "image"; groupValue: string } | null { + if (imageGroup) return { assetKind: imageGroupKind, groupValue: imageGroup }; + if (cgGroup) return { assetKind: "image", groupValue: cgGroup }; + return null; +} + +type SkipMode = "skip" | "nofirstskip"; + +interface SkipNodeLabel { + lineIndex: number; + mode: SkipMode; +} + +function preprocessSkipNodes( + lines: ReturnType, +): SkipNodeLabel[] { + const labels: SkipNodeLabel[] = []; + + for (const [index, line] of lines.entries()) { + if (line.kind !== "command" || line.command !== "skipnode") continue; + + // Native port: Torappu.AVG.AVGStoryCache.CalSkipMode. Only the exact + // lower-case sentinel maps to FIRST_CANNOT_SKIP; every other value, + const mode = + toString(line.args.mode, "skip") === "nofirstskip" + ? "nofirstskip" + : "skip"; + labels.push({ lineIndex: index, mode }); + } + + return labels; +} + +function preprocessSkipToIndex(lines: ReturnType): number { + return lines.findIndex( + (line) => line.kind === "command" && line.command === "skiptothis", + ); +} + +export class StoryRuntime { + private readonly audio: StoryAudio; + private readonly defaultSpeed: AutoSpeed; + private autoPlayMode: AutoPlayMode = "default"; + private autoClickGeneration = 0; + private buttonSpeedLevel = 0; + private readonly commandRegistry = new CommandRegistry(); + private readonly context: Context; + private cursor = 0; + /** 当前屏幕对话框正在显示的源行 lineNumber(cursor-1 那条)。null 表示无显示文本(初始/finished/video-only/纯命令推进) */ + private displayedLineIndex: number | null = null; + private decisionSelectValue = 0; + private decisionReferences: number[] | null = null; + private destroyed = false; + private readonly lines: ReturnType; + private readonly firstRead: boolean; + private pendingWait: InterruptibleWait | null = null; + private pendingWaitId = 0; + private processing = false; + private processingCompletion: Promise | null = null; + private resolveProcessingCompletion: (() => void) | null = null; + private readonly renderer: StoryRenderer; + private readonly sleep: (ms: number) => Promise; + private currentSkipMode: SkipMode = "skip"; + private readonly skipNodeLabels: SkipNodeLabel[]; + private skipNodeQueueIndex = 0; + private readonly skipToIndex: number; + /** + * Native port: Torappu.AVG.AVGShowItemPanel's single `_slotInUse`. + * It determines whether `_ExecuteHideItem` blocks. + */ + private itemSlotInUse = false; + private theaterMode = false; + private theaterAutoPlayCache: AutoPlayState | null = null; + private typingSession: { + id: number; + speaker: string; + richChars: RichChar[]; + tagStyles?: Record; + } | null = null; + private typingSessionId = 0; + private quickSpeedLevel = 0; + private currentMessageLength = 0; + private currentTypingComplete = false; + private state: PlayerState = "idle"; + private multilineText = ""; + private multilineEnd = false; + private readonly stickerIds = new Set(); + private pendingInputEffect: (() => Promise | void) | null = null; + + constructor( + context: Context, + renderer: StoryRenderer, + audio: StoryAudio, + options: RuntimeOptions = {}, + ) { + this.context = context; + this.lines = parseScript(context.scriptText ?? context.script); + this.defaultSpeed = { + animateRatio: Math.max(0, options.animateRatio ?? 1), + autoWaitBaseTime: 1.5, + autoWaitTimePerText: 0.03, + typeWriterDelayMs: Math.max(0, Math.round(options.typingIntervalMs ?? 0)), + }; + this.firstRead = options.firstRead ?? true; + this.renderer = renderer; + this.audio = audio; + this.sleep = options.sleep ?? sleepWithTimeout; + this.skipNodeLabels = preprocessSkipNodes(this.lines); + this.skipToIndex = preprocessSkipToIndex(this.lines); + this.onWarning = options.onWarning; + for (const command of builtinCommandNames) + this.commandRegistry.register(command, (line) => + this.executeBuiltinCommand(line), + ); + } + + readonly onWarning?: (warning: RuntimeWarning) => void; + + getState(): PlayerState { + return this.state; + } + + /** 当前屏幕对话框正在显示的源行 lineNumber;null 表示当前没有显示文本 */ + getDisplayedLineIndex(): number | null { + return this.displayedLineIndex; + } + + /** 最近一次 decision 玩家所选的 value(0 = 未做选择/默认分支) */ + getDecisionSelectValue(): number { + return this.decisionSelectValue; + } + + getAutoPlayState(): AutoPlayState { + return { + buttonSpeedLevel: this.buttonSpeedLevel, + mode: this.autoPlayMode, + quickSpeedLevel: this.quickSpeedLevel, + }; + } + + setAutoPlayMode(mode: AutoPlayMode): void { + if (this.destroyed || this.state === "finished" || this.state === "error") + return; + if (this.autoPlayMode === mode) return; + + this.autoPlayMode = mode; + this.cancelAutoClick(); + + if (mode === "quick_play" && this.state === "waiting_input") { + void this.advanceFromClick(); + return; + } + if (mode !== "default" && this.currentTypingComplete) + this.scheduleAutoClick(this.currentMessageLength); + } + + setAutoPlaySpeedLevel(level: number): void { + const normalized = Math.max(0, Math.trunc(level)); + if (this.autoPlayMode === "quick_play") + this.quickSpeedLevel = Math.min(normalized, QUICK_AUTO_SPEEDS.length - 1); + else + this.buttonSpeedLevel = Math.min( + normalized, + BUTTON_AUTO_SPEEDS.length - 1, + ); + + this.cancelAutoClick(); + if (this.autoPlayMode !== "default" && this.currentTypingComplete) + this.scheduleAutoClick(this.currentMessageLength); + } + + canSkipNode(): boolean { + const running = + this.state !== "idle" && + this.state !== "error" && + this.state !== "finished"; + const hasSkipAnchor = + this.skipToIndex >= 0 || this.skipNodeLabels.length > 0; + // This widget exposes a segment-skip button rather than native's whole + // story StopStory action. Keep it disabled when the script has no remaining + // SkipToThis/skipnode destination at all. + return running && hasSkipAnchor; + } + + async start(): Promise { + if (this.destroyed) return; + if (this.state !== "idle") return; + + // The normal AVG command loop is not entered for VIDEO_ONLY stories. Their + // media is owned by the separate entry-video flow in the game host. + if (this.context.storyMetadata?.isVideoOnly) { + this.state = "finished"; + return; + } + + this.state = "running"; + await this.processLoop(); + } + + async advance(): Promise { + if (this.destroyed) return; + + if (this.state === "idle") { + await this.start(); + return; + } + + if (this.state !== "waiting_input") return; + + if (this.theaterMode) return; + + this.setAutoPlayMode("default"); + await this.advanceFromClick(); + } + + private async advanceFromClick(): Promise { + if (this.state !== "waiting_input") return; + + if (this.finishTypingNow()) { + this.onTypingComplete(); + return; + } + if (this.renderer.finishTextTyping()) { + this.onTypingComplete(); + return; + } + + this.cancelAutoClick(); + if (this.multilineEnd) this.resetMultiline(); + const inputEffect = this.pendingInputEffect; + this.pendingInputEffect = null; + await inputEffect?.(); + this.state = "running"; + await this.processLoop(); + } + + async skipNode(): Promise { + if (this.destroyed || !this.canSkipNode()) return; + + const hadActiveProcessLoop = this.processing; + const activeProcessCompletion = this.processingCompletion; + let shouldResume = false; + + // m_executeIndex points at the command currently being waited on while our + // cursor already points at the next command, hence cursor <= anchorIndex is + // the native m_executeIndex < m_skipToIndex forward-only test. + if (this.skipToIndex >= 0 && this.cursor <= this.skipToIndex) { + this.cursor = this.skipToIndex + 1; + shouldResume = true; + } else if (this.skipToIndex >= 0) { + // SkipToThis is forward-only. Native does not fall back to skipnode or + // StopStory after playback has already crossed the anchor. + return; + } else if (this.skipToIndex < 0) { + const nextLabel = this.skipNodeLabels[this.skipNodeQueueIndex]; + if (nextLabel) { + this.skipNodeQueueIndex += 1; + this.currentSkipMode = nextLabel.mode; + } + + const tutorial = this.context.storyMetadata?.isTutorial ?? false; + const isSkippable = + !tutorial && + (this.currentSkipMode !== "nofirstskip" || !this.firstRead); + if (nextLabel && !isSkippable) { + // Native stores the anchor index and the coroutine's leading increment + // resumes at the following command. + this.cursor = nextLabel.lineIndex + 1; + shouldResume = true; + } else { + this.cursor = this.lines.length; + } + } + + this.pendingInputEffect = null; + await this.renderer.clearInterludes(); + await this.renderer.clearSpellStickers(); + + this.cancelTyping(); + if (shouldResume) { + this.state = "running"; + } else if (!hadActiveProcessLoop) { + this.state = "finished"; + } + + // Establish the destination state before releasing a blocking executor. + // Its existing processLoop owns continuation; starting another loop would + // race it and can leave state="running" with no loop alive. + this.interruptPendingWait(); + if (hadActiveProcessLoop) await activeProcessCompletion; + else if (shouldResume) await this.processLoop(); + } + + private getCurrentSpeed(): AutoSpeed { + if (this.autoPlayMode === "button_auto") + return ( + BUTTON_AUTO_SPEEDS[this.buttonSpeedLevel] ?? BUTTON_AUTO_SPEEDS[0]! + ); + if (this.autoPlayMode === "quick_play") + return QUICK_AUTO_SPEEDS[this.quickSpeedLevel] ?? QUICK_AUTO_SPEEDS[0]!; + return this.defaultSpeed; + } + + /** + * Native port: Torappu.AVG.AVGUtils.CalculateFadetime, adapted from seconds + * to milliseconds. It applies animateRatio * fadetime. + * + * Only panels implementing IFadeTimeRatio route through it. AVGImagePanel does + * (background / image / imagerotate); its tween executors bypass it, and the + * whole LargeBackgroundPanel family (largebg / verticalbg / gridbg / + */ + private calculateFadeMs(seconds: unknown, fallback = 0): number { + return Math.max( + 0, + toNumber(seconds, fallback) * this.getCurrentSpeed().animateRatio * 1000, + ); + } + + /** + * `delay` on sticker is a scale factor against the asset's `originDelay`, not + * an absolute per-character time: typeWriterDelay = global * delay / + * originDelay. `delay=0` therefore means "print the whole line at once", + * which is what the samples writing delay=0 rely on. Same rule multiline uses. + */ + private stickerTypeDelayMs(value: unknown): number { + const originDelaySeconds = TYPE_WRITER_ORIGIN_DELAY_MS / 1000; + const scale = + value === undefined + ? 1 + : toNumber(value, originDelaySeconds) / originDelaySeconds; + return Math.max(0, this.getCurrentSpeed().typeWriterDelayMs * scale); + } + + private cancelAutoClick(): void { + this.autoClickGeneration += 1; + } + + private scheduleAutoClick(messageLength: number): void { + this.cancelAutoClick(); + if (this.autoPlayMode === "default") return; + const generation = this.autoClickGeneration; + const speed = this.getCurrentSpeed(); + const delayMs = + (speed.autoWaitBaseTime + messageLength * speed.autoWaitTimePerText) * + 1000; + void this.sleep(delayMs).then(() => { + if ( + this.destroyed || + generation !== this.autoClickGeneration || + this.autoPlayMode === "default" || + this.state !== "waiting_input" + ) { + return; + } + void this.advanceFromClick(); + }); + } + + destroy(): void { + this.destroyed = true; + this.cancelTyping(); + this.cancelAutoClick(); + this.interruptPendingWait(); + this.pendingInputEffect = null; + } + + private async processLoop(): Promise { + if (this.processing) return; + + this.processing = true; + this.processingCompletion = new Promise((resolve) => { + this.resolveProcessingCompletion = resolve; + }); + + try { + while (!this.destroyed) { + if (this.cursor >= this.lines.length) { + this.renderer.clearAnimTexts(); + this.renderer.clearAvgDisplays(); + this.state = "finished"; + return; + } + + const line = this.lines[this.cursor]; + this.cursor += 1; + + if ( + this.decisionSelectValue !== 0 && + this.decisionReferences !== null && + this.decisionReferences.length > 0 && + !(line.kind === "command" && line.command === "predicate") && + !this.decisionReferences.includes(this.decisionSelectValue) + ) { + continue; + } + + if (line.kind === "dialogue") { + this.resetMultiline(); + if (!line.text) { + this.cancelTyping(); + this.renderer.setDialogue("", ""); + continue; + } + this.displayedLineIndex = line.lineNumber; + this.waitForDialogueInput(line.speaker, line.text); + return; + } + + if (line.kind === "narration") { + if (!line.text) continue; + this.resetMultiline(); + this.displayedLineIndex = line.lineNumber; + this.waitForDialogueInput("", line.text); + return; + } + + const result = await this.executeCommand(line); + if (result === "wait_input") { + this.state = "waiting_input"; + return; + } + } + } catch (error) { + this.state = "error"; + const detail = + error instanceof Error ? error.message : "unknown runtime error"; + this.onWarning?.({ cursor: this.cursor, detail, type: "error" }); + } finally { + this.processing = false; + this.resolveProcessingCompletion?.(); + this.resolveProcessingCompletion = null; + this.processingCompletion = null; + } + } + + private arg(args: StoryCommandArgs, key: string): unknown { + if (Object.prototype.hasOwnProperty.call(args, key)) return args[key]; + const matched = Object.keys(args).find( + (candidate) => candidate.toLowerCase() === key.toLowerCase(), + ); + return matched === undefined ? undefined : args[matched]; + } + + private exactArg(args: StoryCommandArgs, key: string): unknown { + return Object.prototype.hasOwnProperty.call(args, key) + ? args[key] + : undefined; + } + + private warn( + type: RuntimeWarning["type"], + detail?: string, + lineNumber?: number, + command?: string, + ): void { + this.onWarning?.({ + ...(command === undefined ? {} : { command }), + cursor: this.cursor, + detail, + ...(lineNumber === undefined ? {} : { lineNumber }), + type, + }); + } + + private resolveImageKey(key: string): string | null { + const normalized = key.trim().toLowerCase(); + if (!normalized) return null; + return normalized; + } + + private hasAudioKey(key: string): boolean { + return Boolean(resolveStoryAudioByKey(key, this.context.audioVariables)); + } + + private interruptPendingWait(): void { + const wait = this.pendingWait; + this.pendingWait = null; + wait?.interrupt?.(); + wait?.resolve(); + } + + private async waitInterruptiblePromise( + task: Promise, + interrupt?: () => void, + ): Promise { + const id = ++this.pendingWaitId; + const interrupted = new Promise((resolve) => { + this.pendingWait = { + id, + interrupt, + resolve: () => resolve(true), + }; + }); + + const completed = await Promise.race([task.then(() => false), interrupted]); + + if (this.pendingWait?.id === id) this.pendingWait = null; + + return completed; + } + + private async waitInterruptible(ms: number): Promise { + return this.waitInterruptiblePromise(this.sleep(Math.max(0, ms))); + } + + private resolveCharacterName( + ref: string, + ): { base: string; expression: string } | null { + const cleaned = ref.trim(); + if (!cleaned) return null; + const normalized = cleaned.toLowerCase(); + + const directLink = this.context.linkMap[normalized]; + if (directLink?.array[0]?.name) { + return { + base: normalized, + expression: directLink.array[0].name, + }; + } + + for (const [base, link] of Object.entries(this.context.linkMap)) { + const prefix = `${base}-`; + if (!normalized.startsWith(prefix)) continue; + + const expression = normalized.slice(prefix.length); + if (!expression) continue; + + const found = link.array.find((item) => item.name === expression); + if (!found) continue; + + return { base, expression: found.name }; + } + + const match = normalized.match( + /^([^@#$]+)(?:#(\d+)(?:\$(\d+))?|@([\w-]+)|\$(\d+))?$/, + ); + if (!match) return null; + + const base = match[1]; + const link = this.context.linkMap[base]; + if (!link) return null; + + const hashIndex = match[2] ? Number.parseInt(match[2], 10) : undefined; + const hashGroup = match[3] ? Number.parseInt(match[3], 10) : undefined; + const alias = match[4]; + const dollarGroup = match[5] ? Number.parseInt(match[5], 10) : undefined; + + const pickInGroup = (group: number, index?: number): string | null => { + const grouped = link.array.filter((entry) => + entry.name.endsWith(`$${group}`), + ); + if (grouped.length === 0) return null; + const picked = grouped[Math.max(0, (index ?? 1) - 1)] ?? grouped[0]; + return picked?.name ?? null; + }; + + if (alias) { + const entry = link.array.find( + (item) => String(item.alias).toLowerCase() === alias.toLowerCase(), + ); + const expression = entry?.name ?? link.array[0]?.name ?? null; + if (!expression) return null; + return { base, expression }; + } + + if (hashGroup) { + const expression = pickInGroup(hashGroup, hashIndex); + if (!expression) return null; + return { base, expression }; + } + + if (dollarGroup) { + const expression = pickInGroup(dollarGroup, 1); + if (!expression) return null; + return { base, expression }; + } + + if (hashIndex) { + const entry = link.array[Math.max(0, hashIndex - 1)] ?? link.array[0]; + if (!entry?.name) return null; + return { base, expression: entry.name }; + } + + if (!link.array[0]?.name) return null; + return { + base, + expression: link.array[0].name, + }; + } + + private executeCommand(line: ParsedCommandLine): Promise { + // HEADER remains in Story.commands but has no registered executor. + if (line.command === "header") return Promise.resolve("continue"); + const executors = this.commandRegistry.get(line.command); + if (executors === null) { + this.warn("unknown_command", line.command); + return Promise.resolve("continue"); + } + if (executors.length === 1) { + const result = executors[0]!(line); + return result instanceof Promise ? result : Promise.resolve(result); + } + return Promise.all(executors.map((executor) => executor(line))).then( + (results) => (results.includes("wait_input") ? "wait_input" : "continue"), + ); + } + + private async executeBuiltinCommand( + line: ParsedCommandLine, + ): Promise { + const args = line.args; + + switch (line.command) { + case "endtip": { + // Native port: Torappu.AVG.DialogPanel._ExecuteEndtip and + // AVGStoryCache.shouldProcessEndtip. It only shows and blocks in auto + // play for non-video-only stories; manual mode is a no-op. + const shouldProcessEndtip = + (this.autoPlayMode === "button_auto" || + this.autoPlayMode === "quick_play") && + !(this.context.storyMetadata?.isVideoOnly ?? false); + if (!shouldProcessEndtip) return "continue"; + + this.setAutoPlayMode("default"); + this.resetMultiline(); + this.startTypingDialogue("", line.content); + return "wait_input"; + } + + case "dialog": { + // Native port: Torappu.AVG.DialogPanel._ExecuteDialog. `[Dialog]`, + // `[name="X"]` and bare narration all reach it under the sentinel `dialog`; + // the multi-param forms + // (`[name="X",avatarId=1]`, `[Delay=2]`, `[imagegroup=...]`) land here + // too. Only the content-bearing branch resets multiline and blocks; an + // empty content just hides the box and returns false. + if (line.content) { + this.resetMultiline(); + this.displayedLineIndex = line.lineNumber; + this.waitForDialogueInput( + toString(this.exactArg(args, "name")), + line.content, + ); + return "wait_input"; + } + this.cancelTyping(); + this.renderer.setDialogue("", ""); + return "continue"; + } + + case "delay": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteDelayCommand. + // Only `time` is read and is scaled by animateRatio. + const durationMs = Math.max( + 0, + toNumber(this.exactArg(args, "time"), 0) * + this.getCurrentSpeed().animateRatio * + 1000, + ); + this.state = "waiting_timer"; + await this.waitInterruptible(durationMs); + this.state = "running"; + return "continue"; + } + + case "video": { + // Native port: Torappu.AVG.VideoPanel._ExecuteVideo / _PlayVideo. `url` + // takes priority and is used verbatim; only the `res` fallback is resolved + const url = toString(this.exactArg(args, "url")); + const res = toString(this.exactArg(args, "res")); + if (!url && !res) { + this.warn("invalid_parameter", "video: no url or res"); + return "continue"; + } + + const resolved = url + ? resolveStoryVideoByKey(url) + : resolveStoryVideoByKey(res); + if (!resolved) { + this.warn("missing_asset", `video: ${url || res}`); + return "continue"; + } + + this.state = "waiting_video"; + const interrupted = await this.waitInterruptiblePromise( + this.renderer.playVideo(resolved), + () => this.renderer.stopVideo(), + ); + // PLAYEND/ERROR finish on the next frame. Force-end is released by the + // wrapper immediately and deliberately bypasses this yield. + if (!interrupted) await this.sleep(0); + this.state = "running"; + return "continue"; + } + + case "skipnode": { + const label = this.skipNodeLabels[this.skipNodeQueueIndex]; + this.skipNodeQueueIndex += 1; + this.currentSkipMode = label?.mode ?? "skip"; + return "continue"; + } + + case "skiptothis": { + // Native port: Torappu.AVG.AVGController._PreprocessCommands. This is a + // preprocess-only command with no executor, so normal playback advances + return "continue"; + } + + case "background": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImage as registered by + // BackgroundPanel. This covers its clear/load-failure and scaled-fade + const image = toString(this.exactArg(args, "image")); + const fadeMs = this.calculateFadeMs(this.exactArg(args, "fadetime")); + const block = + fadeMs > 0 && toBoolean(this.exactArg(args, "block"), false); + if (!image) { + await this.renderer.clearBackground(fadeMs, block); + return "continue"; + } + + const resolved = this.resolveImageKey(image); + if (!resolved) { + this.warn("missing_asset", `background: ${image}`); + await this.renderer.clearBackground(fadeMs, block); + return "continue"; + } + + await this.renderer.setBackground(resolved, { + block, + fadeMs, + scaleX: toNumber(this.exactArg(args, "xScale"), 1), + scaleY: toNumber(this.exactArg(args, "yScale"), 1), + screenAdapt: this.parseScreenAdapt( + this.exactArg(args, "screenadapt"), + ), + tiled: toBoolean(this.exactArg(args, "tiled"), false), + x: toNumber(this.exactArg(args, "x"), 0), + y: toNumber(this.exactArg(args, "y"), 0), + }); + return "continue"; + } + + case "backgroundtween": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImageTween. Duration is + const duration = this.exactArg(args, "duration"); + + await this.renderer.setBackgroundTween({ + // duration defaults to 0.0; <= 0 completes both tweens instantly. + block: + toNumber(duration, 0) > 0 && + toBoolean(this.exactArg(args, "block"), false), + durationMs: Math.max(0, toNumber(duration, 0) * 1000), + xFrom: toOptionalNumber(this.exactArg(args, "xFrom")), + xScaleFrom: toOptionalNumber(this.exactArg(args, "xScaleFrom")), + xScaleTo: toOptionalNumber(this.exactArg(args, "xScaleTo")), + xTo: toOptionalNumber(this.exactArg(args, "xTo")), + yFrom: toOptionalNumber(this.exactArg(args, "yFrom")), + yScaleFrom: toOptionalNumber(this.exactArg(args, "yScaleFrom")), + yScaleTo: toOptionalNumber(this.exactArg(args, "yScaleTo")), + yTo: toOptionalNumber(this.exactArg(args, "yTo")), + }); + return "continue"; + } + + case "gridbg": { + // Native port: Torappu.AVG.LargeBackgroundPanel._ExecuteGridBG. This + // validates a 2×2 tile set and keeps fadetime unscaled. + const imageGroup = toString(this.exactArg(args, "imagegroup")); + const cgGroup = toString(this.exactArg(args, "cggroup")); + const groupSelection = resolveGroupedAssetSelection( + imageGroup, + cgGroup, + ); + const fadeMs = Math.max( + 0, + toNumber(this.exactArg(args, "fadetime"), 0) * 1000, + ); + const block = + fadeMs > 0 && toBoolean(this.exactArg(args, "block"), false); + + if (!groupSelection) { + await this.renderer.clearGridBackground(fadeMs, block); + return "continue"; + } + + const imageKeys = groupSelection.groupValue + .split("/") + .map((item) => this.resolveImageKey(item)) + .filter((key): key is string => key !== null); + const solidWidths = parseNumberSequence( + this.exactArg(args, "solidwidth"), + ).slice(0, 2); + const solidHeights = parseNumberSequence( + this.exactArg(args, "solidheight"), + ).slice(0, 2); + + if (imageKeys.length === 0) { + this.warn("parse", "gridbg imagegroup is empty"); + return "continue"; + } + + if ( + imageKeys.length !== 4 || + solidWidths.length !== 2 || + solidHeights.length !== 2 + ) { + this.warn( + "parse", + `gridbg expects ${imageKeys.length} widths/heights, got ${solidWidths.length}/${solidHeights.length}`, + ); + return "continue"; + } + + await this.renderer.setGridBackground({ + assetKind: groupSelection.assetKind, + block, + fadeMs, + imageKeys, + layout: "grid", + scaleX: toNumber(this.exactArg(args, "xScale"), 1), + scaleY: toNumber(this.exactArg(args, "yScale"), 1), + solidHeights, + solidWidths, + x: toNumber(this.exactArg(args, "x"), 0), + y: toNumber(this.exactArg(args, "y"), 0), + }); + return "continue"; + } + + case "verticalbg": { + // Native port: Torappu.AVG.LargeBackgroundPanel._ExecuteVerticalBG. + // It accepts one width and up to four vertically stacked tiles; fadetime + const imageGroup = toString(this.exactArg(args, "imagegroup")); + const cgGroup = toString(this.exactArg(args, "cggroup")); + const groupSelection = resolveGroupedAssetSelection( + imageGroup, + cgGroup, + ); + const fadeMs = Math.max( + 0, + toNumber(this.exactArg(args, "fadetime"), 0) * 1000, + ); + const block = + fadeMs > 0 && toBoolean(this.exactArg(args, "block"), false); + + if (!groupSelection) { + await this.renderer.clearGridBackground(fadeMs, block); + return "continue"; + } + + const imageKeys = groupSelection.groupValue + .split("/") + .map((item) => this.resolveImageKey(item)) + .filter((key): key is string => key !== null); + const solidWidth = toNumber(this.exactArg(args, "solidwidth"), 0); + const solidWidths = solidWidth > 0 ? [solidWidth] : []; + const solidHeights = parseNumberSequence( + this.exactArg(args, "solidheight"), + ).slice(0, imageKeys.length); + + if (imageKeys.length === 0) { + this.warn("parse", "verticalbg imagegroup is empty"); + return "continue"; + } + + if ( + imageKeys.length > 4 || + solidWidths.length !== 1 || + solidHeights.length !== imageKeys.length + ) { + this.warn( + "parse", + `verticalbg expects width_count * height_count === image_count, got ${solidWidths.length} * ${solidHeights.length} !== ${imageKeys.length}`, + ); + return "continue"; + } + + await this.renderer.setGridBackground({ + assetKind: groupSelection.assetKind, + block, + fadeMs, + imageKeys, + layout: "vertical", + scaleX: toNumber(this.exactArg(args, "xScale"), 1), + scaleY: toNumber(this.exactArg(args, "yScale"), 1), + solidHeights, + solidWidths, + x: toNumber(this.exactArg(args, "x"), 0), + y: toNumber(this.exactArg(args, "y"), 0), + }); + return "continue"; + } + + case "largebg": { + // Native port: Torappu.AVG.LargeBackgroundPanel._ExecuteImage (the + // LargeBackgroundPanel method, not AVGImagePanel's namesake). It composes + // two horizontal tiles and keeps fadetime literal. + const imageGroup = toString(this.exactArg(args, "imagegroup")); + const cgGroup = toString(this.exactArg(args, "cggroup")); + const groupSelection = resolveGroupedAssetSelection( + imageGroup, + cgGroup, + ); + const initPositionModeArg = toString( + this.exactArg(args, "initposmode"), + "default", + ); + const initPositionMode = + initPositionModeArg === "center" || + initPositionModeArg === "lowercenter" || + initPositionModeArg === "upperleft" + ? initPositionModeArg + : "default"; + const fadeMs = Math.max( + 0, + toNumber(this.exactArg(args, "fadetime"), 0) * 1000, + ); + const block = + fadeMs > 0 && toBoolean(this.exactArg(args, "block"), false); + + if (!groupSelection) { + await this.renderer.clearGridBackground(fadeMs, block); + return "continue"; + } + + const imageKeys = groupSelection.groupValue + .split("/") + .map((item) => this.resolveImageKey(item)) + .filter((key): key is string => key !== null); + const solidWidths = parseNumberSequence( + this.exactArg(args, "solidwidth"), + ).slice(0, 2); + const solidHeight = toNumber(this.exactArg(args, "solidheight"), 0); + const solidHeights = solidHeight > 0 ? [solidHeight] : []; + + if (imageKeys.length === 0) { + this.warn("parse", "largebg imagegroup is empty"); + return "continue"; + } + + if ( + imageKeys.length !== 2 || + solidWidths.length !== 2 || + solidHeights.length !== 1 + ) { + this.warn( + "parse", + `largebg expects width_count * height_count === image_count, got ${solidWidths.length} * ${solidHeights.length} !== ${imageKeys.length}`, + ); + return "continue"; + } + + await this.renderer.setGridBackground({ + assetKind: groupSelection.assetKind, + block, + fadeMs, + imageKeys, + initPositionMode, + layout: "large", + scaleX: toNumber(this.exactArg(args, "xScale"), 1), + scaleY: toNumber(this.exactArg(args, "yScale"), 1), + solidHeights, + solidWidths, + x: toNumber(this.exactArg(args, "x"), 0), + y: toNumber(this.exactArg(args, "y"), 0), + }); + return "continue"; + } + + case "largeimg": { + // Web-only compatibility extension: no native panel registers `largeimg` + // in the documented client; native `image` is its closest equivalent. + const imageGroup = toString(this.arg(args, "imagegroup")); + const cgGroup = toString(this.arg(args, "cggroup")); + const groupSelection = resolveGroupedAssetSelection( + imageGroup, + cgGroup, + "image", + ); + const groupValue = groupSelection?.groupValue ?? ""; + const fadeMs = Math.max( + 0, + Math.round( + toNumber(this.arg(args, "fadetime"), groupValue ? 0.15 : 0) * 1000, + ), + ); + const block = toBoolean( + this.arg(args, "blok"), + toBoolean( + this.arg(args, "isblock"), + toBoolean(this.arg(args, "block"), false), + ), + ); + + if (!groupSelection) { + await this.renderer.clearLargeImage(fadeMs, block); + return "continue"; + } + + const imageKeys = groupSelection.groupValue + .split("/") + .map((item) => this.resolveImageKey(item)) + .filter((key): key is string => key !== null); + const solidWidths = parseNumberSequence(this.arg(args, "solidwidth")); + const solidHeights = parseNumberSequence(this.arg(args, "solidheight")); + + if (imageKeys.length === 0) { + this.warn("parse", "largeimg imagegroup is empty"); + return "continue"; + } + + const expectedTileCount = solidWidths.length * solidHeights.length; + if ( + solidWidths.length === 0 || + solidHeights.length === 0 || + expectedTileCount !== imageKeys.length + ) { + this.warn( + "parse", + `largeimg expects width_count * height_count === image_count, got ${solidWidths.length} * ${solidHeights.length} !== ${imageKeys.length}`, + ); + return "continue"; + } + + await this.renderer.setLargeImage({ + assetKind: groupSelection.assetKind, + block, + fadeMs, + imageKeys, + layout: "large", + scaleX: Math.max(0, toNumber(this.arg(args, "xscale"), 1)), + scaleY: Math.max(0, toNumber(this.arg(args, "yscale"), 1)), + solidHeights, + solidWidths, + x: toNumber(this.arg(args, "x"), 0), + y: toNumber(this.arg(args, "y"), 0), + }); + return "continue"; + } + + case "image": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImage. This is the + // foreground-panel registration of the same inherited executor used by + const image = toString(this.exactArg(args, "image")); + const fadeMs = this.calculateFadeMs(this.exactArg(args, "fadetime")); + const block = + fadeMs > 0 && toBoolean(this.exactArg(args, "block"), false); + if (!image) { + await this.renderer.clearImage(fadeMs, block); + return "continue"; + } + + const resolved = this.resolveImageKey(image); + if (!resolved) { + this.warn("missing_asset", `image: ${image}`); + await this.renderer.clearImage(fadeMs, block); + return "continue"; + } + + await this.renderer.setImage(resolved, { + block, + fadeMs, + scaleX: toNumber(this.exactArg(args, "xScale"), 1), + scaleY: toNumber(this.exactArg(args, "yScale"), 1), + screenAdapt: this.parseScreenAdapt( + this.exactArg(args, "screenadapt"), + ), + tiled: toBoolean(this.exactArg(args, "tiled"), false), + x: toNumber(this.exactArg(args, "x"), 0), + y: toNumber(this.exactArg(args, "y"), 0), + }); + return "continue"; + } + + case "showitem": { + // Native port: Torappu.AVG.AVGShowItemPanel._ExecuteShowItem. `_slotStyles` + // only registers `photo` and `cutin`; `cg` and anything else + // miss _FindSlotStyle, which logs an error and finishes the command + // without rendering. Every shipped sample is the default `photo`. + const style = toString(this.exactArg(args, "style"), "photo"); + if (style !== "photo" && style !== "cutin") { + this.warn( + "invalid_parameter", + `showitem style is not registered: ${style}`, + line.lineNumber, + line.command, + ); + return "continue"; + } + if (style === "cutin") + this.warn( + "unsupported_visual", + "showitem style=cutin uses the cutin slot expand animation", + line.lineNumber, + line.command, + ); + + const image = toString(this.arg(args, "image")); + if (!image) return "continue"; + + const resolved = this.resolveImageKey(image); + if (!resolved) { + this.warn("missing_asset", `showitem: ${image}`); + return "continue"; + } + + // Native port scope: AVGShowItemPanel._ExecuteShowItem / _ExecuteHideItem. + // Neither executor reads `block`: showitem always returns true, and + // hideitem returns true only while a slot is actually in use. The photo + // slot's serialized defaults are _defaultFadeTime 0.5 and + // _defaultBlackAlpha 0.4; offsetx/offsety default to a hardcoded 0. + await this.renderer.showItem({ + blackAlpha: clamp(toNumber(this.exactArg(args, "black"), 0.4), 0, 1), + block: true, + fadeMs: Math.max( + 0, + Math.round(toNumber(this.exactArg(args, "fadetime"), 0.5) * 1000), + ), + key: resolved, + offsetX: toNumber(this.exactArg(args, "offsetx"), 0), + offsetY: toNumber(this.exactArg(args, "offsety"), 0), + }); + this.itemSlotInUse = true; + return "continue"; + } + + case "hideitem": { + const hadItem = this.itemSlotInUse; + this.itemSlotInUse = false; + await this.renderer.clearItems( + Math.max( + 0, + Math.round(toNumber(this.exactArg(args, "fadetime"), 0.5) * 1000), + ), + hadItem, + ); + return "continue"; + } + + case "cgitem": { + // Native port: Torappu.AVG.AVGCgItemPanel._ExecuteShowCgItem. This + // adapter preserves the id-derived slot key and independent transform + const image = toString(this.exactArg(args, "image")); + const id = toString(this.exactArg(args, "id")); + const key = id ? `${image}_${id}` : image; + const style = toString(this.exactArg(args, "style"), "cg"); + if (style === "blocker") { + // Native port scope: AVGCgItemPanel._ShowItem's `blocker` branch uses a + // scene-resident sprite and deliberately skips m_slotsInUseDict. No + this.warn( + "unsupported_command", + "cgitem style=blocker uses a native-only scene sprite", + ); + return "continue"; + } + if (style !== "cg") { + this.warn( + "invalid_parameter", + `cgitem style is not registered: ${style}`, + ); + return "continue"; + } + if (!image) { + this.warn("missing_asset", "cgitem: (empty)"); + return "continue"; + } + const resolved = this.resolveImageKey(image); + if (!resolved) { + this.warn("missing_asset", `cgitem: ${image}`); + return "continue"; + } + const seconds = (name: string) => + Math.max(0, toNumber(this.exactArg(args, name), 0) * 1000); + const colorFrom = parseCgColor(this.exactArg(args, "cfrom")); + const colorTo = parseCgColor(this.exactArg(args, "cto")); + await this.renderer.showCgItem({ + alphaDelayMs: seconds("adelay"), + alphaDurationMs: seconds("aduration"), + alphaFrom: toNumber(this.exactArg(args, "afrom"), 1), + alphaTo: toNumber(this.exactArg(args, "ato"), 1), + assetKey: resolved, + block: toBoolean(this.exactArg(args, "block"), false), + colorFrom: colorFrom && colorTo ? colorFrom : undefined, + colorTo: colorFrom && colorTo ? colorTo : undefined, + ease: toString(this.exactArg(args, "ease"), "OutQuad"), + height: Math.trunc(toNumber(this.exactArg(args, "height"), 0)), + key, + positionDelayMs: seconds("pdelay"), + positionDurationMs: seconds("pduration"), + positionFrom: parseCgVector2(this.exactArg(args, "pfrom")), + positionTo: parseCgVector2(this.exactArg(args, "pto")), + rotationDurationMs: seconds("rduration"), + rotationFrom: toNumber(this.exactArg(args, "rfrom"), -1), + rotationTo: toNumber(this.exactArg(args, "rto"), 0), + scaleDelayMs: seconds("sdelay"), + scaleDurationMs: seconds("sduration"), + scaleFrom: toNumber(this.exactArg(args, "sfrom"), 1), + scaleTo: toNumber(this.exactArg(args, "sto"), 1), + width: Math.trunc(toNumber(this.exactArg(args, "width"), 0)), + }); + return "continue"; + } + + case "hidecgitem": { + // Native port: Torappu.AVG.AVGCgItemPanel._ExecuteHideCgItem. An omitted + // key clears all slots; a supplied image/id selects one. + const image = toString(this.exactArg(args, "image")); + const id = toString(this.exactArg(args, "id")); + const key = image || id ? (id ? `${image}_${id}` : image) : undefined; + await this.renderer.clearCgItems( + key, + Math.max( + 0, + Math.round(toNumber(this.exactArg(args, "fadetime"), 0.13) * 1000), + ), + toString(this.exactArg(args, "ease"), "OutQuad"), + toBoolean(this.exactArg(args, "block"), false), + ); + return "continue"; + } + + case "imagetween": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImageTween. Its duration + const durationMs = Math.max( + 0, + toNumber(this.exactArg(args, "duration"), 0) * 1000, + ); + await this.renderer.setImageTween({ + block: + durationMs > 0 && toBoolean(this.exactArg(args, "block"), false), + durationMs, + xFrom: toOptionalNumber(this.exactArg(args, "xFrom")), + xScaleFrom: toOptionalNumber(this.exactArg(args, "xScaleFrom")), + xScaleTo: toOptionalNumber(this.exactArg(args, "xScaleTo")), + xTo: toOptionalNumber(this.exactArg(args, "xTo")), + yFrom: toOptionalNumber(this.exactArg(args, "yFrom")), + yScaleFrom: toOptionalNumber(this.exactArg(args, "yScaleFrom")), + yScaleTo: toOptionalNumber(this.exactArg(args, "yScaleTo")), + yTo: toOptionalNumber(this.exactArg(args, "yTo")), + }); + return "continue"; + } + + case "imagerotate": { + // Native port: Torappu.AVG.AVGImagePanel._ExecuteImageRotate. Unlike the + await this.renderer.setImageRotate({ + angleDeg: toNumber(this.exactArg(args, "angle"), 0), + block: toBoolean(this.exactArg(args, "block"), false), + circles: Math.trunc(toNumber(this.exactArg(args, "circles"), 0)), + durationMs: this.calculateFadeMs(this.exactArg(args, "fadetime")), + inverse: toBoolean(this.exactArg(args, "inverse"), false), + }); + return "continue"; + } + + case "largebgtween": { + // Native port: Torappu.AVG.LargeBackgroundPanel._ExecuteImageTween. + // It transforms the existing large-background container without loading + const durationMs = Math.max( + 0, + toNumber(this.exactArg(args, "duration"), 0) * 1000, + ); + + await this.renderer.setLargeBackgroundTween({ + block: + durationMs > 0 && toBoolean(this.exactArg(args, "block"), false), + durationMs, + xFrom: toOptionalNumber(this.exactArg(args, "xFrom")), + xScaleFrom: toOptionalNumber(this.exactArg(args, "xScaleFrom")), + xScaleTo: toOptionalNumber(this.exactArg(args, "xScaleTo")), + xTo: toOptionalNumber(this.exactArg(args, "xTo")), + yFrom: toOptionalNumber(this.exactArg(args, "yFrom")), + yScaleFrom: toOptionalNumber(this.exactArg(args, "yScaleFrom")), + yScaleTo: toOptionalNumber(this.exactArg(args, "yScaleTo")), + yTo: toOptionalNumber(this.exactArg(args, "yTo")), + }); + return "continue"; + } + + case "largeimgtween": { + // Web-only compatibility extension paired with `largeimg`; it has no + const xScale = toOptionalNumber(this.arg(args, "xscale")); + const yScale = toOptionalNumber(this.arg(args, "yscale")); + const x = toOptionalNumber(this.arg(args, "x")); + const y = toOptionalNumber(this.arg(args, "y")); + + await this.renderer.setLargeImageTween({ + block: toBoolean(this.arg(args, "block"), false), + durationMs: Math.max( + 0, + toNumber(this.arg(args, "duration"), 0.15) * 1000, + ), + xFrom: toOptionalNumber(this.arg(args, "xfrom")) ?? x, + xScaleFrom: toOptionalNumber(this.arg(args, "xscalefrom")) ?? xScale, + xScaleTo: toOptionalNumber(this.arg(args, "xscaleto")) ?? xScale, + xTo: toOptionalNumber(this.arg(args, "xto")) ?? x, + yFrom: toOptionalNumber(this.arg(args, "yfrom")) ?? y, + yScaleFrom: toOptionalNumber(this.arg(args, "yscalefrom")) ?? yScale, + yScaleTo: toOptionalNumber(this.arg(args, "yscaleto")) ?? yScale, + yTo: toOptionalNumber(this.arg(args, "yto")) ?? y, + }); + return "continue"; + } + + case "blocker": { + // Native port: Torappu.AVG.AVGBlockerPanel._ExecuteBlocker. The malformed + // default `defualt` is intentional, and fadetime is scaled. + const styleArg = toString(this.exactArg(args, "style"), "defualt"); + const style = + styleArg === "slider" || styleArg === "verticalslider" + ? styleArg + : "default"; + const fadeMs = Math.max( + 0, + toNumber(this.exactArg(args, "fadetime"), 0.4) * 1000, + ); + + await this.renderer.setBlocker({ + block: fadeMs > 0 && toBoolean(this.exactArg(args, "block"), false), + fadeMs, + from: { + a: toNumber(this.exactArg(args, "afrom"), Number.NaN), + b: toNumber(this.exactArg(args, "bfrom"), Number.NaN), + g: toNumber(this.exactArg(args, "gfrom"), Number.NaN), + r: toNumber(this.exactArg(args, "rfrom"), Number.NaN), + }, + image: toString(this.exactArg(args, "image")) || undefined, + inverse: toBoolean(this.exactArg(args, "inverse"), false), + style, + to: { + a: toNumber(this.exactArg(args, "a"), 1), + b: toNumber(this.exactArg(args, "b"), 0), + g: toNumber(this.exactArg(args, "g"), 0), + r: toNumber(this.exactArg(args, "r"), 0), + }, + }); + return "continue"; + } + + case "curtain": { + const direction = Math.trunc( + toNumber(this.exactArg(args, "direction"), -1), + ); + const fadeMs = Math.max( + 0, + Math.round(toNumber(this.exactArg(args, "fadetime"), 0.4) * 1000), + ); + // Native port: Torappu.AVG.AVGCurtainPanel._ExecuteCurtain. It zeroes its + // closure's block field on both the + // zero-fadetime and the direction < 0 paths, so only a real curtain tween + // can block -- the same short circuit blocker has. + const block = + fadeMs > 0 && toBoolean(this.exactArg(args, "block"), false); + + if (direction < 0) { + await this.renderer.clearCurtains(fadeMs, false); + return "continue"; + } + + if (direction > 7) { + this.warn("invalid_parameter", `curtain direction:${direction}`); + return "continue"; + } + const alpha = toNumber(this.exactArg(args, "a"), -1); + + await this.renderer.setCurtain({ + alphaFrom: toOptionalNumber(this.exactArg(args, "afrom")), + alphaTo: alpha >= 0 ? alpha : undefined, + block, + delayMs: Math.max( + 0, + toNumber(this.exactArg(args, "delay"), 0) * 1000, + ), + direction, + fadeMs, + fillFrom: toNumber(this.exactArg(args, "fillfrom"), 1), + fillTo: toNumber(this.exactArg(args, "fillto"), 0), + grad: toBoolean(this.exactArg(args, "grad"), false), + }); + return "continue"; + } + + case "charslot": { + const rawSlot = toString(args.slot).trim(); + const slot = this.parseCharacterSlot(rawSlot); + const nameRef = toString(args.name); + // Native port: Torappu.AVG.AVGCharacterslotPanel._ExecuteCharslot. Duration + // defaults to 0.0 and goes through CalculateFadetime; zero skips animation. + const durationMs = Math.round(this.calculateFadeMs(args.duration, 0)); + const block = toBoolean(args.isblock, false); + + // The clear branch keys off an empty `slot`, never an empty `name`, and it + // only touches the three built-in slots (custom slots are left alone). + if (!rawSlot) { + const clearPromise = this.renderer.clearCharacters( + undefined, + durationMs, + ); + if (block) await clearPromise; + else void clearPromise; + return "continue"; + } + + if (!slot) { + // `custom` and any other unknown slot name resolve through + // _TryPrepareCustomSlotCommand / _GetSlotWithName, which we do not model. + this.warn( + "unsupported_visual", + `charslot slot is not a built-in slot: ${rawSlot}`, + line.lineNumber, + line.command, + ); + return "continue"; + } + + const resolved = nameRef ? this.resolveCharacterName(nameRef) : null; + if (nameRef && !resolved) { + this.warn("missing_asset", `character: ${nameRef}`); + return "continue"; + } + + await this.renderer.setCharacter({ + action: this.parseCharacterSlotAction(args.action), + ...(args.angle === undefined + ? {} + : { angle: toNumber(args.angle, 0) }), + alphaFrom: + args.afrom === undefined + ? undefined + : clamp(toNumber(args.afrom, 1), 0, 1), + alphaTo: + args.ato === undefined + ? undefined + : clamp(toNumber(args.ato, 1), 0, 1), + blackEnd: + args.bend === undefined + ? undefined + : toNumber(args.bend, Number.NaN), + blackStart: + args.bstart === undefined + ? undefined + : toNumber(args.bstart, Number.NaN), + block, + characterKey: resolved?.base, + ...(args.circles === undefined + ? {} + : { circles: Math.trunc(toNumber(args.circles, 0)) }), + durationMs, + expression: resolved?.expression, + focusMode: this.resolveCharacterSlotFocusMode(args, Boolean(nameRef)), + focusSlots: this.resolveCharacterSlotFocusSlots(args), + ...(args.inverse === undefined + ? {} + : { inverse: toBoolean(args.inverse, false) }), + positionFrom: this.parseCharacterSlotPoint(args.posfrom), + positionTo: this.parseCharacterSlotPoint(args.posto), + posZoom: this.parseCharacterSlotPoint(args.poszoom), + power: Math.max(0, toNumber(args.power, 0)), + preserveTransform: + toString(args.end).trim().toLowerCase() === "false", + randomness: clamp(toNumber(args.random, 90), 0, 100), + replaceFadeMs: Math.max( + 0, + Math.round(toNumber(args.fadetime, 0) * 1000), + ), + resetTransform: toString(args.end).trim().toLowerCase() !== "false", + scaleX: + args.xscale === undefined && args.scale === undefined + ? undefined + : Math.max(0, toNumber(args.xscale, toNumber(args.scale, 1))), + scaleY: + args.yscale === undefined && args.scale === undefined + ? undefined + : Math.max(0, toNumber(args.yscale, toNumber(args.scale, 1))), + slot, + stop: toBoolean(args.stop, false), + times: Math.trunc(toNumber(args.times, 1)), + }); + return "continue"; + } + + case "character": { + const nameRef = toString(args.name); + const name2Ref = toString(args.name2); + const focus = toNumber(args.focus, 0); + const durationMs = this.calculateFadeMs(args.fadetime, 0.15); + // Native port: Torappu.AVG.CharacterPanel._ExecuteCharacter. It reads + // `isblock`, not `block`, and returns it raw without registering any tween + // callback -- returning true would + // deadlock the queue since nothing ever calls FinishCommand. Scripts only + // ever write `block=`, which is not a key this command reads, so the + // command is effectively never blocking. Reproduce that, not the deadlock. + const block = false; + const hasRightCharacter = Boolean(name2Ref); + const updates = [ + { + blackEnd: args.blackend, + blackStart: args.blackstart, + enter: args.enter, + name: hasRightCharacter ? nameRef : "", + slot: "l", + x: args.xpos1, + y: args.ypos1, + }, + { + blackEnd: args.blackend, + blackStart: args.blackstart, + enter: args.enter, + name: nameRef && !name2Ref ? nameRef : "", + slot: "m", + x: args.xpos1, + y: args.ypos1, + }, + { + blackEnd: args.blackend2, + blackStart: args.blackstart2, + enter: args.enter2, + name: name2Ref, + slot: "r", + x: args.xpos2, + y: args.ypos2, + }, + ] as const; + + for (const update of updates) { + if (!update.name) { + void this.renderer.clearCharacters(update.slot, durationMs); + continue; + } + const resolved = this.resolveCharacterName(update.name); + if (!resolved) { + this.warn("missing_asset", `character: ${update.name}`); + void this.renderer.clearCharacters(update.slot, durationMs); + continue; + } + await this.renderer.setCharacter({ + absolutePosition: { + x: update.x === undefined ? undefined : toNumber(update.x, 0), + y: update.y === undefined ? undefined : toNumber(update.y, 0), + }, + blackEnd: toNumber(update.blackEnd, Number.NaN), + blackStart: toNumber(update.blackStart, Number.NaN), + block, + characterKey: resolved.base, + dimmed: update.slot === "r" ? ![0, 2].includes(focus) : focus > 1, + durationMs, + enterFrom: this.parseEnterDirection(update.enter), + expression: resolved.expression, + slot: update.slot, + }); + } + + return "continue"; + } + + case "characteraction": { + // Native port: Torappu.AVG.CharacterPanel._ExecuteCharacterAction. It uses + // literal fadetime and accepts both legacy block spellings. + const type = this.parseCharacterActionType(args.type); + if (!type) { + this.warn( + "parse", + `characteraction type is invalid: ${toString(args.type)}`, + ); + return "continue"; + } + const slot = this.parseCharacterActionSlot(args.name, type); + + const scaleFallback = + args.scale === undefined + ? undefined + : Math.max(0, toNumber(args.scale, 1)); + const xScale = + args.xscale === undefined + ? scaleFallback + : Math.max(0, toNumber(args.xscale, scaleFallback ?? 1)); + const yScale = + args.yscale === undefined + ? scaleFallback + : Math.max(0, toNumber(args.yscale, scaleFallback ?? 1)); + + await this.renderer.runCharacterAction({ + block: toBoolean(args.block, toBoolean(args.isblock, false)), + direction: this.parseCharacterActionDirection(args.direction), + durationMs: Math.max( + 0, + Math.round(toNumber(args.fadetime, 0.5) * 1000), + ), + power: Math.max(0, toNumber(args.power, 0)), + randomness: clamp(toNumber(args.randomness, 90), 0, 100), + rotationFromDeg: toNumber(args.start, 0), + rotationLeftDeg: + args.leftend === undefined ? -15 : -toNumber(args.leftend, 15), + rotationRightDeg: + args.rightend === undefined ? 15 : toNumber(args.rightend, 15), + scaleX: xScale, + scaleY: yScale, + slot, + stop: toBoolean(args.stop, false), + times: Math.trunc(toNumber(args.times, 1)), + type, + xOffset: toNumber(args.xpos, 0), + yOffset: toNumber(args.ypos, 0), + } satisfies CharacterActionInput); + return "continue"; + } + + case "charactercutin": { + const widgetId = toString(args.widgetID); + if (!widgetId) { + this.warn("parse", "charactercutin widgetID is empty"); + return "continue"; + } + + const nameRef = toString(args.name).trim(); + const resolved = nameRef ? this.resolveCharacterName(nameRef) : null; + // Native port: Torappu.AVG.AVGCharacterCutinPanel._ExecuteCharacterCutin. + // Defaults come from the cutin slot's serialized fields + // (_defaultFadetime = 0.4, _defaultSlotWidth = 200), not from PRTS's + // 0.14 / 150. `block` really is the key here, unlike the rest of the + const fadeMs = Math.max( + 0, + Math.round(toNumber(args.fadetime, 0.4) * 1000), + ); + const block = toBoolean(args.block, false); + + if (nameRef && !resolved) { + this.warn("missing_asset", `charactercutin: ${nameRef}`); + return "continue"; + } + + const fadeStyleRaw = toString(args.fadestyle).trim().toLowerCase(); + const validFadeStyles = [ + "fade", + "horiz_expand_center", + "horiz_expand_left2right", + "horiz_expand_right2left", + "vert_expand_center", + "vert_expand_top2buttom", + "vert_expand_buttom2top", + ] as const; + const fadeStyle = validFadeStyles.includes( + fadeStyleRaw as (typeof validFadeStyles)[number], + ) + ? (fadeStyleRaw as (typeof validFadeStyles)[number]) + : undefined; + + await this.renderer.setCharacterCutin({ + block, + characterKey: resolved?.base, + expression: resolved?.expression, + fadeMs, + fadeStyle, + height: toNumber(args.height, 540), + offsetX: toNumber(args.offsetx, 0), + offsetY: toNumber(args.offsety, 0), + widgetId, + width: toNumber(args.width, 200), + }); + return "continue"; + } + + case "interlude": { + // Native port: Torappu.AVG.AVGCharacterCutinPanel._ExecuteInterlude and + // _GenCutinParamWithCommand. Validation uses default 0, while CutinParam + const checkedChannel = Math.trunc( + toNumber(this.exactArg(args, "channel"), 0), + ); + if (checkedChannel < 0) { + this.warn( + "invalid_parameter", + `interlude channel is invalid: ${checkedChannel}`, + ); + return "continue"; + } + + const channelArg = this.exactArg(args, "channel"); + const channel = + channelArg === undefined ? -1 : Math.trunc(toNumber(channelArg, -1)); + const type = parseInterludeType(this.exactArg(args, "type")); + const name = toString(this.exactArg(args, "name")); + const resolvedAvatar = + type === 3 && name ? this.resolveCharacterName(name) : null; + if (type === 3 && name && !resolvedAvatar) + this.warn("missing_asset", `interlude avatar: ${name}`); + + const positionFrom = parseVector2(this.exactArg(args, "pfrom")); + const positionTo = parseVector2(this.exactArg(args, "pto")); + const scaleFrom = parseVector2(this.exactArg(args, "sfrom")); + const scaleTo = parseVector2(this.exactArg(args, "sto")); + const duration = (key: string): number => + Math.max( + 0, + Math.round( + toNumber(this.exactArg(args, key), 0) * + this.getCurrentSpeed().animateRatio * + 1000, + ), + ); + + await this.renderer.setInterlude({ + alphaDurationMs: duration("aduration"), + alphaFrom: toNumber(this.exactArg(args, "afrom"), -1), + alphaTo: toNumber(this.exactArg(args, "ato"), -1), + avatarCharacterKey: resolvedAvatar?.base, + avatarExpression: resolvedAvatar?.expression, + block: toBoolean(this.exactArg(args, "block"), false), + channel, + charName: toString(this.exactArg(args, "char")), + clear: toBoolean(this.exactArg(args, "clear"), false), + direction: toString(this.exactArg(args, "direction")), + durationMs: duration("duration"), + maskId: toString(this.exactArg(args, "maskid")), + name, + offset: parseVector2(this.exactArg(args, "offset")) ?? { x: 0, y: 0 }, + positionFrom: positionFrom && positionTo ? positionFrom : undefined, + positionTo: positionFrom && positionTo ? positionTo : undefined, + scaleDurationMs: duration("sduration"), + scaleFrom: scaleFrom && scaleTo ? scaleFrom : undefined, + scaleTo: scaleFrom && scaleTo ? scaleTo : undefined, + size: parseVector2(this.exactArg(args, "size")) ?? { x: 0, y: 0 }, + slot: toString(this.exactArg(args, "slot")), + style: Math.trunc(toNumber(this.exactArg(args, "style"), 0)), + switchOn: toBoolean(this.exactArg(args, "switch"), false), + templateSizeDurationMs: duration("tsduration"), + templateSizeFrom: parseVector2(this.exactArg(args, "tsfrom")) ?? { + x: 0, + y: 0, + }, + templateSizeTo: parseVector2(this.exactArg(args, "tsto")) ?? { + x: 0, + y: 0, + }, + type, + } satisfies InterludeInput); + return "continue"; + } + + case "animtext": { + // Native port: Torappu.AVG.AVGDisplayableExecutor._ExecuteAnimatedText. + const rawPosition = this.exactArg(args, "pos"); + const parsedPosition = parseVector2(rawPosition); + if (rawPosition !== undefined && !parsedPosition) + this.warn( + "invalid_parameter", + `animtext pos parse failed: ${toString(rawPosition)}`, + ); + const position = parsedPosition ?? { x: 0, y: 0 }; + await this.renderer.setAnimText({ + block: toBoolean(this.exactArg(args, "block"), false), + content: this.translateText(line.trailingText), + id: toString(this.exactArg(args, "id")), + name: toString(this.exactArg(args, "name")), + position, + style: toString(this.exactArg(args, "style")), + }); + return "continue"; + } + + case "avgdisplay": { + // Native port: Torappu.AVG.AVGDisplayableExecutor._ExecuteAVGDisplayable + const optional = (key: string) => + toOptionalNumber(this.exactArg(args, key)); + const rawStyle = toString(this.exactArg(args, "style")); + const rawSlot = toString(this.exactArg(args, "slot")); + const styleMap: Record = { + 1: "animetext", + 2: "spine", + 3: "effect", + 4: "bgeffect", + 5: "bg", + 6: "animekv", + 7: "character", + }; + const slotMap: Record = { + 1: "bgover", + 2: "charover", + 3: "cgover", + }; + const style = styleMap[rawStyle] ?? rawStyle; + const slot = slotMap[rawSlot] ?? rawSlot; + // All three avgdisplay features read the same `duration` key and each + // scales it through CalculateFadetime. + const durationMs = this.calculateFadeMs( + this.exactArg(args, "duration"), + style === "bg" ? 0.1 : 0, + ); + await this.renderer.setAvgDisplay({ + alphaFrom: optional("afrom"), + alphaTo: optional("ato"), + block: + durationMs > 0 && toBoolean(this.exactArg(args, "block"), false), + durationMs, + entryFrom: optional("entryfrom"), + entryIndex: Math.trunc( + toNumber(this.exactArg(args, "entryindex"), 0), + ), + entryTo: optional("entryto"), + id: toString(this.exactArg(args, "id")), + layer: Math.trunc(toNumber(this.exactArg(args, "layer"), 1)), + name: toString(this.exactArg(args, "name")), + rotationX: toNumber(this.exactArg(args, "rox"), 0), + rotationY: toNumber(this.exactArg(args, "roy"), 0), + rotationZ: toNumber(this.exactArg(args, "roz"), 0), + scaleX: optional("scalex"), + scaleXFrom: optional("scalexfrom"), + scaleXTo: optional("scalexto"), + scaleY: optional("scaley"), + scaleYFrom: optional("scaleyfrom"), + scaleYTo: optional("scaleyto"), + slot, + style, + x: optional("x"), + xFrom: optional("xfrom"), + xTo: optional("xto"), + y: optional("y"), + yFrom: optional("yfrom"), + yTo: optional("yto"), + } satisfies AvgDisplayInput); + return "continue"; + } + + case "playmusic": { + // Native port: Torappu.AVG.CommonExecutors._ExecutePlayMusicCommand. + // Audio calls are asynchronous and do not block the command loop. + const key = toString(this.exactArg(args, "key")); + if (!key) { + this.warn("parse", "playmusic key is empty"); + return "continue"; + } + if (!this.hasAudioKey(key)) { + this.warn("missing_asset", `music: ${key}`); + return "continue"; + } + + const intro = toString(this.exactArg(args, "intro")) || undefined; + if (intro && !this.hasAudioKey(intro)) + this.warn("missing_asset", `music intro: ${intro}`); + + void this.audio.playMusic({ + crossfadeMs: Math.max( + 0, + toNumber(this.exactArg(args, "crossfade"), 0.4) * 1000, + ), + delayMs: Math.max( + 0, + toNumber(this.exactArg(args, "delay"), 0) * 1000, + ), + intro, + key, + volume: toNumber(this.exactArg(args, "volume"), 1), + }); + return "continue"; + } + + case "stopmusic": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteStopMusicCommand. + void this.audio.stopMusic( + Math.max(0, toNumber(this.exactArg(args, "fadetime"), 0) * 1000), + ); + return "continue"; + } + + case "musicvolume": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteMusicVolumeCommand. + void this.audio.setMusicVolume( + toNumber(this.exactArg(args, "volume"), 1), + Math.max(0, toNumber(this.exactArg(args, "fadetime"), 0) * 1000), + ); + return "continue"; + } + + case "playsound": { + // Native port: Torappu.AVG.CommonExecutors._ExecutePlaySoundCommand. + // Channel defaults to key; the call is non-blocking. + const key = toString(this.exactArg(args, "key")); + if (!key) { + this.warn("parse", "playsound key is empty"); + return "continue"; + } + if (!this.hasAudioKey(key)) { + this.warn("missing_asset", `sound: ${key}`); + return "continue"; + } + + void this.audio.playSound({ + channel: toString(this.exactArg(args, "channel"), key), + delayMs: Math.max( + 0, + toNumber(this.exactArg(args, "delay"), 0) * 1000, + ), + key, + loop: toBoolean(this.exactArg(args, "loop"), false), + volume: toNumber(this.exactArg(args, "volume"), 1), + }); + return "continue"; + } + + case "stopsound": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteStopSoundCommand. + const key = toString(this.exactArg(args, "key")); + void this.audio.stopSound( + toString(this.exactArg(args, "channel"), key), + Math.max(0, toNumber(this.exactArg(args, "fadetime"), 0) * 1000), + ); + return "continue"; + } + + case "soundvolume": { + // Native port: Torappu.AVG.CommonExecutors._ExecuteSoundVolumeCommand. + void this.audio.setSoundVolume( + toString(this.exactArg(args, "channel")), + toNumber(this.exactArg(args, "volume"), 1), + Math.max(0, toNumber(this.exactArg(args, "fadetime"), 0) * 1000), + ); + return "continue"; + } + + case "cameraeffect": { + // Native port: Torappu.AVG.AVGCameraEffect._ExecuteCameraEffect. This + // covers its Grayscale/Colorinverse/Chaos dispatch and scaled fadetime. + const effect = toString(this.exactArg(args, "effect")); + const block = toBoolean(this.exactArg(args, "block"), false); + const keep = toBoolean(this.exactArg(args, "keep"), false); + // AVGCameraEffect._defaultFadetime is serialized as 0.4 on + // AVGController > AVGCamera_Scene; a 0 default would make every + // fadetime-less cameraeffect invisible. + const fadeMs = Math.max( + 0, + toNumber(this.exactArg(args, "fadetime"), 0.4) * 1000, + ); + if (effect === "Chaos") { + this.warn("unsupported_visual", "cameraeffect:Chaos"); + return "continue"; + } + if (effect === "Colorinverse") { + await this.renderer.setCameraEffect( + effect, + keep ? 1 : 0, + 0, + false, + true, + ); + return "continue"; + } + if (effect !== "Grayscale") return "continue"; + await this.renderer.setCameraEffect( + effect, + toNumber(this.exactArg(args, "amount"), 1), + fadeMs, + block, + keep, + toOptionalNumber(this.exactArg(args, "initamount")), + ); + return "continue"; + } + + case "camerashake": { + // Native port: Torappu.AVG.AVGCameraEffect._ExecuteCameraShake. Duration, + await this.renderer.shakeCamera({ + block: toBoolean(this.exactArg(args, "block"), false), + durationMs: + toNumber(this.exactArg(args, "duration"), -1) < 0 + ? 10_000 + : Math.max( + 0, + toNumber(this.exactArg(args, "duration"), 0) * 1000, + ), + fadeOut: toBoolean(this.exactArg(args, "fadeout"), false), + infinite: toNumber(this.exactArg(args, "duration"), -1) < 0, + randomness: toNumber(this.exactArg(args, "randomness"), 90), + stop: toBoolean(this.exactArg(args, "stop"), false), + vibrato: Math.trunc(toNumber(this.exactArg(args, "vibrato"), 10)), + xStrength: toNumber(this.exactArg(args, "xstrength"), 1), + yStrength: toNumber(this.exactArg(args, "ystrength"), 0), + }); + return "continue"; + } + + case "focusout": { + // Native port: Torappu.AVG.AVGCameraEffect._ExecuteFocusout. Its duration + const durationMs = Math.max( + 0, + toNumber(this.exactArg(args, "duration"), 0) * 1000, + ); + const from = toOptionalNumber(this.exactArg(args, "from")); + await this.renderer.setFocusOut({ + block: + durationMs > 0 && toBoolean(this.exactArg(args, "block"), false), + durationMs, + from: from !== undefined && from >= 0 ? from : undefined, + id: toString(this.exactArg(args, "id")), + to: toNumber(this.exactArg(args, "to"), 0), + type: toString(this.exactArg(args, "type")), + }); + return "continue"; + } + + case "focusparam": { + // Native port: Torappu.AVG.AVGCameraEffect._ExecuteFocusParam. This is a + const effect = toString(this.exactArg(args, "effect")); + this.renderer.setFocusParam({ + blur: toBoolean(this.exactArg(args, "blur"), true), + color: + effect === "Grayscale" || effect === "Colorinverse" + ? effect + : "None", + }); + return "continue"; + } + + case "multiline": { + // Native port: Torappu.AVG.DialogPanel._ExecuteMultiline. Consecutive + // fragments append until `end`; they share the typewriter timing model. + const text = line.trailingText; + if (!text) { + this.renderer.setDialogue("", ""); + return "continue"; + } + this.multilineText += text; + this.multilineEnd = toBoolean(this.exactArg(args, "end"), false); + this.displayedLineIndex = line.lineNumber; + this.startTypingDialogue( + toString(this.exactArg(args, "name")), + this.multilineText, + this.exactArg(args, "delay") === undefined + ? 1 + : toNumber( + this.exactArg(args, "delay"), + TYPE_WRITER_ORIGIN_DELAY_MS / 1000, + ) / + (TYPE_WRITER_ORIGIN_DELAY_MS / 1000), + ); + return "wait_input"; + } + + case "subtitle": { + // Native port: Torappu.AVG.SubtitlePanel._ExecuteSubtitle. The script's + // `delay` parameter is ignored; typing speed is global. + const text = this.translateText(toString(this.exactArg(args, "text"))); + + if (!text) { + void this.renderer.clearSubtitle(150); + return "continue"; + } + + const x = toNumber(this.exactArg(args, "x"), 0); + const y = toNumber(this.exactArg(args, "y"), 0); + if (x < 0 || x > 1280 || y < 0 || y > 720) return "continue"; + + this.displayedLineIndex = line.lineNumber; + await this.renderer.setSubtitle({ + alignment: + this.parseSubtitleAlignment(this.exactArg(args, "alignment")) ?? + "left", + delayMs: this.getCurrentSpeed().typeWriterDelayMs, + sizePx: toNumber(this.exactArg(args, "size"), 24), + text, + widthPx: Math.min( + toNumber(this.exactArg(args, "width"), 1280), + 1280 - x, + ), + x, + y, + } satisfies SubtitleInput); + return "wait_input"; + } + + case "sticker": { + // Native port: Torappu.AVG.StickerPanel._ExecuteSticker. The state is a + // multi-id slot dictionary, with show/append/hide behavior selected by id + const id = toString(this.exactArg(args, "id")); + if (!id) { + this.warn("parse", "sticker id is empty"); + return "continue"; + } + + const multi = toBoolean(this.exactArg(args, "multi"), false); + // `block` defaults differ per branch: true when the id is new (show), + // false once the id already exists (append or hide). + const block = toBoolean( + this.exactArg(args, "block"), + !this.stickerIds.has(id), + ); + const duration = toNumber(this.exactArg(args, "duration"), -1); + // RenderSticker keeps `duration` verbatim when it is >= 0 (0 means an + // instant show) and only falls back to 0.15 when the parameter is absent. + // HideSticker is different: it uses 0.15 for anything <= 0. + const showFadeMs = (duration >= 0 ? duration : 0.15) * 1000; + const hideFadeMs = (duration > 0 ? duration : 0.15) * 1000; + if (this.stickerIds.has(id) && !multi) { + this.stickerIds.delete(id); + await this.renderer.clearSticker(id, hideFadeMs); + return block ? "wait_input" : "continue"; + } + + const x = toNumber(this.exactArg(args, "x"), 0); + const y = toNumber(this.exactArg(args, "y"), 0); + if (x < 0 || x > 1280 || y < 0 || y > 720) return "continue"; + this.stickerIds.add(id); + + await this.renderer.setSticker({ + alignment: + this.parseSubtitleAlignment(this.exactArg(args, "alignment")) ?? + "left", + append: multi, + delayMs: this.stickerTypeDelayMs(this.exactArg(args, "delay")), + fadeMs: showFadeMs, + id, + sizePx: toNumber(this.exactArg(args, "size"), 24), + text: this.translateText(toString(this.exactArg(args, "text"))), + widthPx: Math.min( + toNumber(this.exactArg(args, "width"), 1280), + 1280 - x, + ), + x, + y, + } satisfies StickerInput); + return block ? "wait_input" : "continue"; + } + + case "timersticker": { + // Native port: Torappu.AVG.StickerPanel._ExcuteTimerSticker. This is a + await this.renderer.setTimerSticker({ + durationMs: + Math.max(0, toNumber(this.exactArg(args, "duration"), 1) * 1000) || + 130, + fromAlpha: toNumber(this.exactArg(args, "afrom"), 0), + limitSeconds: + this.exactArg(args, "time") === undefined + ? undefined + : Math.round(toNumber(this.exactArg(args, "time"), -1)), + sizePx: toNumber(this.exactArg(args, "size"), 24), + toAlpha: toNumber(this.exactArg(args, "ato"), 1), + widthPx: toNumber(this.exactArg(args, "width"), 1280), + x: toNumber(this.exactArg(args, "x"), 0), + y: toNumber(this.exactArg(args, "y"), 0), + } satisfies TimerStickerInput); + return "continue"; + } + + case "timerclear": { + // Native port: Torappu.AVG.StickerPanel._ExcuteTimerClier / AVGTimerView. + // StopTimer hides the reusable slot rather than destroying it. + await this.renderer.clearTimerSticker({ + durationMs: Math.max( + 0, + toNumber(this.exactArg(args, "duration"), 0.13) * 1000, + ), + } satisfies TimerClearInput); + return "continue"; + } + + case "stickerclear": { + // Native port: Torappu.AVG.StickerPanel._ExcuteClear / _RecycleStickers. + // It clears all sticker slots and also stops the timer-sticker path. + this.stickerIds.clear(); + void this.renderer.clearStickers(150); + void this.renderer.clearTimerSticker({ durationMs: 0 }); + return toBoolean(this.exactArg(args, "block"), false) + ? "wait_input" + : "continue"; + } + + case "spellsticker": { + // Native port: Torappu.AVG.AVGSpellStickerPanel._ExecuteSpellSticker. + // `block` waits for a click, whose follow-up hides the sticker. + const id = toString(this.exactArg(args, "id")); + if (!id) return "continue"; + const action = toString(this.exactArg(args, "action"), "show"); + const block = toBoolean(this.exactArg(args, "block"), false); + await (action.toLowerCase() === "hide" + ? this.renderer.hideSpellSticker(id) + : this.renderer.setSpellSticker({ + alpha: clamp(toNumber(this.exactArg(args, "alpha"), 1), 0, 1), + angle: toOptionalNumber(this.exactArg(args, "angle")), + content: this.translateText(line.content), + id, + style: toString(this.exactArg(args, "style"), "sami"), + x: toOptionalNumber(this.exactArg(args, "x")), + xScale: toOptionalNumber(this.exactArg(args, "xScale")), + y: toOptionalNumber(this.exactArg(args, "y")), + yScale: toOptionalNumber(this.exactArg(args, "yScale")), + } satisfies SpellStickerInput)); + if (block) { + this.pendingInputEffect = + action.toLowerCase() === "hide" + ? null + : () => this.renderer.hideSpellSticker(id); + return "wait_input"; + } + return "continue"; + } + + case "spellstickerclear": { + // Native port: Torappu.AVG.AVGSpellStickerPanel._ExecuteSpellStickerClear. + await this.renderer.clearSpellStickers(); + this.pendingInputEffect = null; + return toBoolean(this.exactArg(args, "block"), false) + ? "wait_input" + : "continue"; + } + + case "theater": { + // Native port: Torappu.AVG.AVGTheaterLabel._ExecuteTheaterNode. Web adapts + // the native theater UI mode to autoplay state only. + const mode = toBoolean(this.exactArg(args, "mode"), true); + if (mode && !this.theaterMode) { + this.theaterAutoPlayCache = this.getAutoPlayState(); + this.theaterMode = true; + this.buttonSpeedLevel = 0; + this.setAutoPlayMode("button_auto"); + } else if (!mode && this.theaterMode) { + this.theaterMode = false; + const cached = this.theaterAutoPlayCache; + this.theaterAutoPlayCache = null; + if (cached) { + this.buttonSpeedLevel = cached.buttonSpeedLevel; + this.quickSpeedLevel = cached.quickSpeedLevel; + this.setAutoPlayMode( + cached.mode === "quick_play" ? "default" : cached.mode, + ); + } + } + return "continue"; + } + + case "decision": { + // Native port: Torappu.AVG.DecisionPanel._ExecuteDecision. Selection writes + // a value for the command-loop predicate gate; it is not a label jump. + const autoPlayCache = this.autoPlayMode; + this.setAutoPlayMode("default"); + this.decisionSelectValue = 0; + this.decisionReferences = null; + const optionsValue = this.exactArg(args, "options"); + if (optionsValue === undefined) { + this.warn( + "invalid_parameter", + "decision options parameter is missing", + ); + return "continue"; + } + + const options = toString(optionsValue) + .split(";") + .map((option) => this.translateText(option)); + const values = toString(this.exactArg(args, "values")) + .split(";") + .map((value) => { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; + }); + + this.state = "waiting_decision"; + const selectedValue = await this.renderer.showDecision(options, values); + this.decisionSelectValue = selectedValue; + this.setAutoPlayMode(autoPlayCache); + this.state = "running"; + return "continue"; + } + + case "predicate": { + // Native port: Torappu.AVG.DecisionPanel._ExecutePredicate. This ghost + // command only replaces the current reference-value gate. + const references = this.exactArg(args, "references"); + this.decisionReferences = + references === undefined + ? null + : toString(references) + .split(";") + .map((value) => { + const parsed = Number.parseInt(value, 10); + return Number.isNaN(parsed) ? 0 : parsed; + }); + return "continue"; + } + + default: { + if (unsupportedAd8Commands.has(line.command)) { + this.warn("unsupported_command", line.command); + return "continue"; + } + + this.warn("unknown_command", line.command); + return "continue"; + } + } + } + + private waitForDialogueInput(speaker: string, text: string): void { + this.startTypingDialogue(speaker, text); + this.state = "waiting_input"; + } + + private startTypingDialogue( + speaker: string, + text: string, + delayScale = 1, + ): void { + this.cancelTyping(); + + const translatedSpeaker = this.translateText(speaker); + const richChars = parseRichChars(this.translateText(text)); + this.currentMessageLength = richChars.length; + this.currentTypingComplete = false; + const initialDelayMs = this.getTypeWriterDelayMs(delayScale); + if (richChars.length === 0 || initialDelayMs <= 0) { + const tagged = richCharsToTaggedText(richChars); + const colors = collectColors(richChars); + const ts = colors.length > 0 ? buildTagStyles(colors) : undefined; + this.renderer.setDialogue(translatedSpeaker, tagged, ts); + this.onTypingComplete(); + return; + } + + const sessionId = ++this.typingSessionId; + const colors = collectColors(richChars); + const tagStyles = colors.length > 0 ? buildTagStyles(colors) : undefined; + this.typingSession = { + id: sessionId, + speaker: translatedSpeaker, + richChars, + tagStyles, + }; + this.renderer.setDialogue(translatedSpeaker, "", tagStyles); + + void this.runTyping(sessionId, translatedSpeaker, richChars, delayScale); + } + + private translateText(text: string): string { + return expandStoryText(text, this.context.audioVariables); + } + + private getTypeWriterDelayMs(delayScale: number): number { + const delayMs = this.getCurrentSpeed().typeWriterDelayMs * delayScale; + return delayMs < 0 ? TYPE_WRITER_ORIGIN_DELAY_MS : delayMs; + } + + private async runTyping( + sessionId: number, + speaker: string, + richChars: RichChar[], + delayScale: number, + ): Promise { + for (let index = 0; index < richChars.length; index += 1) { + if ( + !this.typingSession || + this.typingSession.id !== sessionId || + this.destroyed + ) + return; + + const delayMs = this.getTypeWriterDelayMs(delayScale); + if (delayMs > 0) await this.sleep(delayMs); + if ( + !this.typingSession || + this.typingSession.id !== sessionId || + this.destroyed + ) + return; + + const visible = richCharsToTaggedText(richChars.slice(0, index + 1)); + this.renderer.setDialogue(speaker, visible); + } + + const finalDelayMs = this.getTypeWriterDelayMs(delayScale); + if (finalDelayMs > 0) await this.sleep(finalDelayMs); + if (this.typingSession?.id === sessionId) { + this.typingSession = null; + this.onTypingComplete(); + } + } + + private onTypingComplete(): void { + this.currentTypingComplete = true; + this.scheduleAutoClick(this.currentMessageLength); + } + + private finishTypingNow(): boolean { + if (!this.typingSession) return false; + + const { speaker, richChars, tagStyles } = this.typingSession; + this.cancelTyping(); + this.renderer.setDialogue( + speaker, + richCharsToTaggedText(richChars), + tagStyles, + ); + return true; + } + + private resetMultiline(): void { + this.multilineText = ""; + this.multilineEnd = false; + } + + private cancelTyping(): void { + this.typingSession = null; + this.typingSessionId += 1; + this.currentTypingComplete = false; + this.cancelAutoClick(); + } + + private parseEnterDirection(value: unknown): CharacterSlotInput["enterFrom"] { + const normalized = toString(value).toLowerCase(); + if ( + normalized === "left" || + normalized === "right" || + normalized === "up" || + normalized === "down" + ) + return normalized; + return undefined; + } + + private parseScreenAdapt(value: unknown): BackgroundInput["screenAdapt"] { + const mode = toString(value); + if ( + mode === "coverall" || + mode === "fill" || + mode === "height" || + mode === "showall" || + mode === "width" + ) + return mode; + return undefined; + } + + private parseCharacterSlot(value: unknown): string | undefined { + switch (toString(value).trim().toLowerCase()) { + case "char_left": + case "l": + case "left": { + return "l"; + } + case "c": + case "center": + case "char_middle": + case "m": + case "middle": { + return "m"; + } + case "char_right": + case "r": + case "right": { + return "r"; + } + default: { + return undefined; + } + } + } + + private parseCharacterSlotAction( + value: unknown, + ): CharacterSlotInput["action"] | undefined { + const normalized = toString(value).trim().toLowerCase(); + if ( + [ + "jump", + "move", + "rotate", + "setpos", + "shake", + "shakemove", + "zoom", + ].includes(normalized) + ) + return normalized as CharacterSlotInput["action"]; + return undefined; + } + + private parseCharacterSlotPoint( + value: unknown, + ): { x: number; y: number } | undefined { + const raw = toString(value).trim(); + if (!raw) return undefined; + + const [xRaw, yRaw] = raw.split(",").map((part) => part.trim()); + if (!xRaw || !yRaw) return undefined; + if (!/^-?\d+(?:\.\d+)?$/.test(xRaw) || !/^-?\d+(?:\.\d+)?$/.test(yRaw)) + return undefined; + + return { + x: Number(xRaw), + y: Number(yRaw), + }; + } + + private resolveCharacterSlotFocusMode( + args: StoryCommandArgs, + hasName: boolean, + ): CharacterSlotInput["focusMode"] | undefined { + const focus = toString(args.focus).trim().toLowerCase(); + if (!focus) return hasName ? "current_only" : undefined; + if (focus === "n" || focus === "none") return "none"; + if (focus === "a" || focus === "all") return "subset"; + if (this.resolveCharacterSlotFocusSlots(args)?.length) return "subset"; + return hasName ? "current_only" : undefined; + } + + private resolveCharacterSlotFocusSlots( + args: StoryCommandArgs, + ): string[] | undefined { + const focus = toString(args.focus).trim().toLowerCase(); + if (!focus) return undefined; + if (focus === "a" || focus === "all") return ["l", "m", "r"]; + + const slots = focus + .split(",") + .map((value) => this.parseCharacterSlot(value)) + .filter((slot): slot is string => slot !== undefined); + return slots.length > 0 ? [...new Set(slots)] : undefined; + } + + private parseCharacterActionSlot( + value: unknown, + type: CharacterActionInput["type"], + ): string { + const normalized = toString(value).trim().toLowerCase(); + if (normalized === "left") return "l"; + if (normalized === "right") return "r"; + if (normalized === "middle") return "m"; + return type === "exit" ? "m" : "l"; + } + + private parseCharacterActionType( + value: unknown, + ): CharacterActionInput["type"] | undefined { + const normalized = toString(value).trim().toLowerCase(); + if ( + normalized === "exit" || + normalized === "jump" || + normalized === "move" || + normalized === "shake" || + normalized === "zoom" + ) { + return normalized; + } + return undefined; + } + + private parseCharacterActionDirection( + value: unknown, + ): CharacterActionInput["direction"] | undefined { + const normalized = toString(value).trim().toLowerCase(); + if (normalized === "left" || normalized === "right") return normalized; + return undefined; + } + + private parseSubtitleAlignment( + value: unknown, + ): SubtitleInput["alignment"] | undefined { + const normalized = toString(value).toLowerCase(); + if ( + normalized === "left" || + normalized === "center" || + normalized === "right" + ) + return normalized; + return undefined; + } +} diff --git a/src/widgets/StoryPlayer/engine/showitem.ts b/src/widgets/StoryPlayer/engine/showitem.ts new file mode 100644 index 00000000..60a08afa --- /dev/null +++ b/src/widgets/StoryPlayer/engine/showitem.ts @@ -0,0 +1,33 @@ +import { STORY_HEIGHT, STORY_WIDTH } from "./types"; + +const LEGACY_STORY_WIDTH = 960; +const LEGACY_STORY_HEIGHT = 540; +const LEGACY_SHOWITEM_CONTENT_SCALE = 0.6; +const LEGACY_SHOWITEM_BORDER_PX = 7.5; + +export interface LegacyShowItemLayout { + borderPx: number; + contentHeight: number; + contentWidth: number; + scale: number; +} + +export function computeLegacyShowItemLayout( + sourceWidth: number, + sourceHeight: number, +): LegacyShowItemLayout { + const width = Math.max(1, sourceWidth); + const height = Math.max(1, sourceHeight); + const viewportScale = Math.min( + STORY_WIDTH / LEGACY_STORY_WIDTH, + STORY_HEIGHT / LEGACY_STORY_HEIGHT, + ); + const scale = LEGACY_SHOWITEM_CONTENT_SCALE * viewportScale; + + return { + borderPx: LEGACY_SHOWITEM_BORDER_PX * viewportScale, + contentHeight: height * scale, + contentWidth: width * scale, + scale, + }; +} diff --git a/src/widgets/StoryPlayer/engine/textVariables.ts b/src/widgets/StoryPlayer/engine/textVariables.ts new file mode 100644 index 00000000..752214ec --- /dev/null +++ b/src/widgets/StoryPlayer/engine/textVariables.ts @@ -0,0 +1,29 @@ +const TEXT_VARIABLE_RE = /\{@([a-zA-Z0-9_.]+)\}/g; +const BUILTIN_TEXT_VARIABLES: Record = { + nbs: "\u00A0", +}; + +export function getStoryNickname(): string { + const mediaWiki = ( + globalThis as { + mw?: { config?: { get?: (key: string) => unknown } }; + } + ).mw; + const name = mediaWiki?.config?.get?.("wgUserName"); + return typeof name === "string" && name + ? name.replace(/[Dd][Rr]\./, "") + : "博士"; +} + +export function expandStoryText( + text: string, + variables: Record = {}, +): string { + return text.replace(TEXT_VARIABLE_RE, (_match, rawName: string) => { + const name = rawName.toLowerCase(); + if (name === "nickname") return getStoryNickname(); + + const value = variables[name] ?? BUILTIN_TEXT_VARIABLES[name]; + return value == null ? "" : String(value); + }); +} diff --git a/src/widgets/StoryPlayer/engine/types.ts b/src/widgets/StoryPlayer/engine/types.ts new file mode 100644 index 00000000..ca776812 --- /dev/null +++ b/src/widgets/StoryPlayer/engine/types.ts @@ -0,0 +1,595 @@ +export const STORY_WIDTH = 1280; +export const STORY_HEIGHT = 720; + +export type StoryCommandValue = boolean | number | string; +export type StoryCommandArgs = Record; + +export interface StoryFaceRect { + h: number; + w: number; + x: number; + y: number; +} + +export interface StoryCharacterGroupFaceOverlay { + base: string; + faceRect: StoryFaceRect; + mode: "face_overlay"; +} + +export interface StoryCharacterGroupSingle { + mode: "single"; +} + +export type StoryCharacterGroup = + StoryCharacterGroupFaceOverlay | StoryCharacterGroupSingle; + +export interface StoryCharacterEntry { + alias: string; + face?: string; + group: number; + image?: string; + name: string; +} + +export interface StoryLinkNode { + array: StoryCharacterEntry[]; + groups: StoryCharacterGroup[]; + pos: { x: number; y: number }; + size: { x: number; y: number }; +} + +export interface ParsedCommandLine { + args: StoryCommandArgs; + command: string; + content: string; + kind: "command"; + lineNumber: number; + paramPresent: boolean; + raw: string; + trailingText: string; +} + +export interface ParsedDialogueLine { + kind: "dialogue"; + lineNumber: number; + raw: string; + speaker: string; + text: string; +} + +export interface ParsedNarrationLine { + kind: "narration"; + lineNumber: number; + raw: string; + text: string; +} + +export type ParsedLine = + ParsedCommandLine | ParsedDialogueLine | ParsedNarrationLine; + +export interface StoryMetadata { + args: StoryCommandArgs; + characterSortType: string; + denyAutoSwitchScene: boolean; + dontClearGameObjectPoolOnStart: boolean; + fitMode: "BLACK_MASK" | "DEFAULT"; + id: string; + isAutoable: boolean; + isSkippable: boolean; + isTutorial: boolean; + isVideoOnly: boolean; + title: string; +} + +export type PlayerState = + | "error" + | "finished" + | "idle" + | "running" + | "waiting_decision" + | "waiting_input" + | "waiting_timer" + | "waiting_video"; +export type AutoPlayMode = "button_auto" | "default" | "quick_play"; + +export interface AutoPlayState { + buttonSpeedLevel: number; + mode: AutoPlayMode; + quickSpeedLevel: number; +} + +export interface RuntimeWarning { + command?: string; + cursor: number; + detail?: string; + lineNumber?: number; + type: + | "error" + | "invalid_parameter" + | "missing_asset" + | "parse" + | "unsupported_command" + | "unsupported_visual" + | "unknown_command"; +} + +export interface CharacterSlotInput { + absolutePosition?: { x?: number; y?: number }; + action?: + "jump" | "move" | "rotate" | "setpos" | "shake" | "shakemove" | "zoom"; + angle?: number; + alphaFrom?: number; + alphaTo?: number; + blackEnd?: number; + blackStart?: number; + block?: boolean; + characterKey?: string; + circles?: number; + dimmed?: boolean; + durationMs?: number; + enterFrom?: "down" | "left" | "right" | "up"; + expression?: string; + focusMode?: "current_only" | "none" | "subset"; + focusSlots?: string[]; + inverse?: boolean; + positionFrom?: { x: number; y: number }; + positionTo?: { x: number; y: number }; + posZoom?: { x: number; y: number }; + power?: number; + preserveTransform?: boolean; + randomness?: number; + replaceFadeMs?: number; + resetTransform?: boolean; + scaleX?: number; + scaleY?: number; + slot: string; + stop?: boolean; + times?: number; +} + +export interface CharacterActionInput { + block: boolean; + direction?: "left" | "right"; + durationMs: number; + power: number; + randomness: number; + rotationFromDeg: number; + rotationLeftDeg: number; + rotationRightDeg: number; + scaleX?: number; + scaleY?: number; + slot: string; + stop: boolean; + times: number; + type: "exit" | "jump" | "move" | "shake" | "zoom"; + xOffset: number; + yOffset: number; +} + +export interface BlockerInput { + block: boolean; + fadeMs: number; + from: { a: number; b: number; g: number; r: number }; + image?: string; + inverse: boolean; + style: "default" | "slider" | "verticalslider"; + to: { a: number; b: number; g: number; r: number }; +} + +export interface CurtainInput { + alphaFrom?: number; + alphaTo?: number; + block: boolean; + delayMs: number; + direction: number; + fadeMs: number; + fillFrom?: number; + fillTo: number; + grad: boolean; +} + +export interface CameraShakeInput { + block: boolean; + durationMs: number; + fadeOut: boolean; + infinite: boolean; + randomness: number; + stop: boolean; + vibrato: number; + xStrength: number; + yStrength: number; +} + +export type FocusColorMode = "Colorinverse" | "Grayscale" | "None"; + +export interface FocusOutInput { + block: boolean; + durationMs: number; + from?: number; + id: string; + to: number; + type: string; +} + +export interface FocusParamInput { + blur: boolean; + color: FocusColorMode; +} + +export interface BackgroundInput { + block: boolean; + fadeMs: number; + scaleX: number; + scaleY: number; + screenAdapt?: "coverall" | "fill" | "height" | "showall" | "width"; + tiled: boolean; + x: number; + y: number; +} + +export interface BackgroundTweenInput { + block: boolean; + durationMs: number; + xFrom?: number; + xScaleFrom?: number; + xScaleTo?: number; + xTo?: number; + yFrom?: number; + yScaleFrom?: number; + yScaleTo?: number; + yTo?: number; +} + +export interface ImageTweenInput { + block: boolean; + durationMs: number; + xFrom?: number; + xScaleFrom?: number; + xScaleTo?: number; + xTo?: number; + yFrom?: number; + yScaleFrom?: number; + yScaleTo?: number; + yTo?: number; +} + +export interface ImageRotateInput { + angleDeg: number; + block: boolean; + circles: number; + durationMs: number; + inverse: boolean; +} + +export interface LargeBackgroundTweenInput { + block: boolean; + durationMs: number; + xFrom?: number; + xScaleFrom?: number; + xScaleTo?: number; + xTo?: number; + yFrom?: number; + yScaleFrom?: number; + yScaleTo?: number; + yTo?: number; +} + +export interface GridBackgroundInput { + assetKind?: "background" | "image"; + block: boolean; + fadeMs: number; + imageKeys: string[]; + initPositionMode?: "center" | "default" | "lowercenter" | "upperleft"; + layout?: "grid" | "large" | "vertical"; + scaleX: number; + scaleY: number; + solidHeights: number[]; + solidWidths: number[]; + x: number; + y: number; +} + +export interface ShowItemInput { + /** Alpha of the full-screen black backdrop the photo slot puts behind the item. */ + blackAlpha: number; + block: boolean; + fadeMs: number; + key: string; + offsetX: number; + offsetY: number; +} + +export interface CgItemInput { + assetKey: string; + alphaDelayMs: number; + alphaDurationMs: number; + alphaFrom: number; + alphaTo: number; + block: boolean; + colorFrom?: { r: number; g: number; b: number; a: number }; + colorTo?: { r: number; g: number; b: number; a: number }; + ease: string; + height: number; + key: string; + positionDelayMs: number; + positionDurationMs: number; + positionFrom?: { x: number; y: number }; + positionTo?: { x: number; y: number }; + rotationDurationMs: number; + rotationFrom: number; + rotationTo: number; + scaleDelayMs: number; + scaleDurationMs: number; + scaleFrom: number; + scaleTo: number; + width: number; +} + +export interface SubtitleInput { + alignment: "center" | "left" | "right"; + delayMs: number; + sizePx: number; + text: string; + widthPx: number; + x: number; + y: number; +} + +export interface StickerInput extends SubtitleInput { + append: boolean; + fadeMs: number; + id: string; +} + +export interface SpellStickerInput { + alpha: number; + angle?: number; + content: string; + id: string; + style: string; + x?: number; + xScale?: number; + y?: number; + yScale?: number; +} + +export interface AnimTextInput { + block: boolean; + content: string; + id: string; + name: string; + position: { x: number; y: number }; + style: string; +} + +export type AvgDisplayStyle = + "animekv" | "bgeffect" | "bg" | "character" | "effect" | "spine" | string; +export type AvgDisplaySlot = "bgover" | "cgover" | "charover" | string; + +export interface AvgDisplayInput { + alphaFrom?: number; + alphaTo?: number; + block: boolean; + durationMs: number; + entryFrom?: number; + entryIndex: number; + entryTo?: number; + id: string; + layer: number; + name: string; + rotationX: number; + rotationY: number; + rotationZ: number; + scaleX?: number; + scaleXFrom?: number; + scaleXTo?: number; + scaleY?: number; + scaleYFrom?: number; + scaleYTo?: number; + slot: AvgDisplaySlot; + style: AvgDisplayStyle; + x?: number; + xFrom?: number; + xTo?: number; + y?: number; + yFrom?: number; + yTo?: number; +} + +export interface TimerStickerInput { + durationMs: number; + fromAlpha: number; + limitSeconds?: number; + sizePx: number; + toAlpha: number; + widthPx: number; + x: number; + y: number; +} + +export interface TimerClearInput { + durationMs: number; +} + +export type CharacterCutinFadeStyle = + | "fade" + | "horiz_expand_center" + | "horiz_expand_left2right" + | "horiz_expand_right2left" + | "vert_expand_center" + | "vert_expand_top2buttom" + | "vert_expand_buttom2top"; + +export interface CharacterCutinInput { + block: boolean; + characterKey?: string; + expression?: string; + fadeMs: number; + fadeStyle?: CharacterCutinFadeStyle; + height: number; + offsetX: number; + offsetY: number; + widgetId: string; + width: number; +} + +export type InterludeElementType = 0 | 1 | 2 | 3; + +export interface InterludeInput { + alphaDurationMs: number; + alphaFrom: number; + alphaTo: number; + avatarCharacterKey?: string; + avatarExpression?: string; + block: boolean; + channel: number; + charName: string; + clear: boolean; + direction: string; + durationMs: number; + maskId: string; + name: string; + offset: { x: number; y: number }; + positionFrom?: { x: number; y: number }; + positionTo?: { x: number; y: number }; + scaleDurationMs: number; + scaleFrom?: { x: number; y: number }; + scaleTo?: { x: number; y: number }; + size: { x: number; y: number }; + slot: string; + style: number; + switchOn: boolean; + templateSizeDurationMs: number; + templateSizeFrom: { x: number; y: number }; + templateSizeTo: { x: number; y: number }; + type: InterludeElementType; +} + +export interface StoryRenderer { + clearCgItems: ( + key?: string, + fadeMs?: number, + ease?: string, + block?: boolean, + ) => Promise | void; + clearAvgDisplays: () => Promise | void; + clearAnimTexts: () => Promise | void; + setCameraEffect: ( + effect: "Colorinverse" | "Grayscale", + amount: number, + durationMs: number, + block: boolean, + keep: boolean, + initialAmount?: number, + ) => Promise | void; + setFocusOut: (input: FocusOutInput) => Promise | void; + setFocusParam: (input: FocusParamInput) => void; + clearBackground: (fadeMs?: number, block?: boolean) => Promise | void; + clearCharacters: (slot?: string, fadeMs?: number) => Promise | void; + clearCurtains: (fadeMs?: number, block?: boolean) => Promise | void; + clearGridBackground: ( + fadeMs?: number, + block?: boolean, + ) => Promise | void; + clearImage: (fadeMs?: number, block?: boolean) => Promise | void; + clearLargeImage: (fadeMs?: number, block?: boolean) => Promise | void; + clearItems: (fadeMs?: number, block?: boolean) => Promise | void; + clearSticker: (id?: string, fadeMs?: number) => Promise | void; + clearStickers: (fadeMs?: number) => Promise | void; + clearSpellStickers: () => Promise | void; + clearSubtitle: (fadeMs?: number) => Promise | void; + clearTimerSticker: (input?: TimerClearInput) => Promise | void; + clearCharacterCutin: (widgetId?: string) => Promise; + clearInterludes: () => Promise; + destroy: () => void; + finishTextTyping: () => boolean; + mount: (host: HTMLElement) => Promise; + playVideo: (url: string) => Promise; + setAnimText: (input: AnimTextInput) => Promise; + setAvgDisplay: (input: AvgDisplayInput) => Promise; + setBackground: (key: string, input?: BackgroundInput) => Promise; + setBackgroundTween: (input: BackgroundTweenInput) => Promise; + setBlocker: (input: BlockerInput) => Promise; + setCharacter: (input: CharacterSlotInput) => Promise; + setCharacterCutin: (input: CharacterCutinInput) => Promise; + setCurtain: (input: CurtainInput) => Promise; + setGridBackground: (input: GridBackgroundInput) => Promise; + setLargeImage: (input: GridBackgroundInput) => Promise; + runCharacterAction: (input: CharacterActionInput) => Promise; + setDialogue: ( + speaker: string, + text: string, + tagStyles?: Record, + ) => void; + setImage: (key: string, input?: BackgroundInput) => Promise; + setImageRotate: (input: ImageRotateInput) => Promise; + setImageTween: (input: ImageTweenInput) => Promise; + setInterlude: (input: InterludeInput) => Promise; + setLargeBackgroundTween: (input: LargeBackgroundTweenInput) => Promise; + setLargeImageTween: (input: LargeBackgroundTweenInput) => Promise; + showItem: (input: ShowItemInput) => Promise; + showCgItem: (input: CgItemInput) => Promise; + setSticker: (input: StickerInput) => Promise | void; + setSpellSticker: (input: SpellStickerInput) => Promise | void; + hideSpellSticker: (id: string) => Promise | void; + setSubtitle: (input: SubtitleInput) => Promise | void; + setTimerSticker: (input: TimerStickerInput) => Promise | void; + shakeCamera: (input: CameraShakeInput) => Promise; + showDecision: (options: string[], values: number[]) => Promise; + stopVideo: () => void; +} + +export interface PlayMusicInput { + crossfadeMs: number; + delayMs: number; + intro?: string; + key: string; + volume: number; +} + +export interface PlaySoundInput { + channel: string; + delayMs: number; + key: string; + loop: boolean; + volume: number; +} + +export interface StoryAudio { + destroy: () => void; + playMusic: (input: PlayMusicInput) => Promise; + playSound: (input: PlaySoundInput) => Promise; + setMusicVolume: (volume: number, fadeMs: number) => Promise; + setSoundVolume: ( + channel: string, + volume: number, + fadeMs: number, + ) => Promise; + stopMusic: (fadeMs: number) => Promise; + stopSound: (channel: string, fadeMs: number) => Promise; +} + +export interface StoryPlayer { + advance: () => Promise; + canSkipNode: () => boolean; + destroy: () => void; + getAutoPlayState: () => AutoPlayState; + /** 当前屏幕对话框正在显示的源行 lineNumber;null 表示无显示文本 */ + getDecisionSelectValue: () => number; + getDisplayedLineIndex: () => number | null; + getState: () => PlayerState; + mount: (host: HTMLElement) => Promise; + setAutoPlayMode: (mode: AutoPlayMode) => void; + setAutoPlaySpeedLevel: (level: number) => void; + skipNode: () => Promise; + start: () => Promise; +} + +export interface RuntimeOptions { + animateRatio?: number; + firstRead?: boolean; + onWarning?: (warning: RuntimeWarning) => void; + sleep?: (ms: number) => Promise; + typingIntervalMs?: number; +} diff --git a/src/widgets/StoryPlayer/index.vue b/src/widgets/StoryPlayer/index.vue new file mode 100644 index 00000000..65c2b735 --- /dev/null +++ b/src/widgets/StoryPlayer/index.vue @@ -0,0 +1,556 @@ + + + + + diff --git a/templates/StoryPlayer.html b/templates/StoryPlayer.html new file mode 100644 index 00000000..fb0e1f7b --- /dev/null +++ b/templates/StoryPlayer.html @@ -0,0 +1 @@ +
{{Documentation}}[[分类:由机器人维护的小部件]] \ No newline at end of file diff --git a/tests/asset.spec.ts b/tests/asset.spec.ts new file mode 100644 index 00000000..231b68dd --- /dev/null +++ b/tests/asset.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveAssetUrl, + resolveStoryAssetByKey, + resolveStoryVideoByKey, +} from "../src/widgets/StoryPlayer/engine/asset"; + +describe("resolveAssetUrl", () => { + it("keeps torappu host unchanged", () => { + const url = resolveAssetUrl( + "https://torappu.prts.wiki/assets/audio/avg/a.mp3", + ); + expect(url).toBe("https://torappu.prts.wiki/assets/audio/avg/a.mp3"); + }); + + it("keeps arbitrary urls unchanged", () => { + const raw = "https://torappu.prts.wiki/assets/audio/avg/a.mp3"; + expect(resolveAssetUrl(raw)).toBe(raw); + }); +}); + +describe("story image resolvers", () => { + it("resolves plain image key to avg images path", () => { + expect(resolveStoryAssetByKey("XyZ_01", false)).toBe( + "https://torappu.prts.wiki/assets/avg/images/xyz_01.png", + ); + }); + + it("resolves background key with bg_ prefix to avg background path", () => { + expect(resolveStoryAssetByKey("bg_lungmen_n", true)).toBe( + "https://torappu.prts.wiki/assets/avg/background/bg_lungmen_n.png", + ); + }); + + it("resolves background key without bg_ prefix to avg background path", () => { + expect(resolveStoryAssetByKey("lungmen_n", true)).toBe( + "https://torappu.prts.wiki/assets/avg/background/lungmen_n.png", + ); + }); +}); + +describe("story video resolver", () => { + it("resolves relative video paths from the shared assets root", () => { + expect(resolveStoryVideoByKey("video/Act15Side/IW01.mp4")).toBe( + "https://torappu.prts.wiki/assets/video/act15side/iw01.mp4", + ); + }); +}); diff --git a/tests/cgItemPanel.spec.ts b/tests/cgItemPanel.spec.ts new file mode 100644 index 00000000..3db23086 --- /dev/null +++ b/tests/cgItemPanel.spec.ts @@ -0,0 +1,85 @@ +import { Container, Texture } from "pixi.js"; +import { describe, expect, it } from "vitest"; + +import { CgItemPanel } from "../src/widgets/StoryPlayer/engine/rendering/panels/CgItemPanel"; + +import type { CgItemInput } from "../src/widgets/StoryPlayer/engine/types"; + +function input(key: string, overrides: Partial = {}): CgItemInput { + return { + alphaDelayMs: 0, + alphaDurationMs: 0, + alphaFrom: 1, + alphaTo: 1, + assetKey: key, + block: false, + ease: "OutQuad", + height: 0, + key, + positionDelayMs: 0, + positionDurationMs: 0, + rotationDurationMs: 0, + rotationFrom: -1, + rotationTo: 0, + scaleDelayMs: 0, + scaleDurationMs: 0, + scaleFrom: 1, + scaleTo: 1, + width: 0, + ...overrides, + }; +} + +async function tweenImmediately( + _duration: number, + update: (progress: number) => void, + complete?: () => void, +): Promise { + update(1); + complete?.(); +} + +describe("CgItemPanel", () => { + it("keeps different keys and replaces an equal key in place", async () => { + const layer = new Container(); + const panel = new CgItemPanel( + layer, + async () => Texture.WHITE, + tweenImmediately, + ); + + await panel.show(input("a")); + const oldA = panel.targets("a")[0]; + await panel.show(input("b")); + await panel.show( + input("a", { + positionFrom: { x: 0, y: 0 }, + positionTo: { x: 10, y: 20 }, + }), + ); + + expect(panel.targets("a")).toHaveLength(1); + expect(panel.targets("b")).toHaveLength(1); + expect(panel.targets("a")[0]).not.toBe(oldA); + expect(layer.children).toHaveLength(2); + }); + + it("hides one key or clears all keys independently of block", async () => { + const layer = new Container(); + const panel = new CgItemPanel( + layer, + async () => Texture.WHITE, + tweenImmediately, + ); + await panel.show(input("a")); + await panel.show(input("b")); + + await panel.hide("a", 130, "OutQuad", true); + expect(panel.targets("a")).toEqual([]); + expect(panel.targets("b")).toHaveLength(1); + + await panel.hide(undefined, 130, "OutQuad", true); + expect(panel.targets("")).toEqual([]); + expect(layer.children).toHaveLength(0); + }); +}); diff --git a/tests/execution.spec.ts b/tests/execution.spec.ts new file mode 100644 index 00000000..0ed4404e --- /dev/null +++ b/tests/execution.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; + +import { CommandRegistry } from "../src/widgets/StoryPlayer/engine/commandRegistry"; +import { createExecutionHandle } from "../src/widgets/StoryPlayer/engine/execution"; + +import type { ParsedCommandLine } from "../src/widgets/StoryPlayer/engine/types"; + +function command(name: string): ParsedCommandLine { + return { + args: {}, + command: name, + content: "", + kind: "command", + lineNumber: 1, + paramPresent: false, + raw: `[${name}]`, + trailingText: "", + }; +} + +describe("command execution primitives", () => { + it("runs multiple subscribers in registration order", async () => { + const registry = new CommandRegistry(); + registry.register("dialog", () => 1); + registry.register("dialog", async () => 2); + await expect(registry.execute(command("dialog"))).resolves.toEqual([1, 2]); + }); + + it("settles an execution handle once with its end reason", async () => { + const onEnd = vi.fn(); + const handle = createExecutionHandle(true, onEnd); + handle.end("force_end"); + handle.end("reset"); + await expect(handle.completion).resolves.toBe("force_end"); + expect(onEnd).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/interlude.spec.ts b/tests/interlude.spec.ts new file mode 100644 index 00000000..e82b4d05 --- /dev/null +++ b/tests/interlude.spec.ts @@ -0,0 +1,88 @@ +import { Container, Texture } from "pixi.js"; +import { describe, expect, it, vi } from "vitest"; + +import { InterludePanel } from "../src/widgets/StoryPlayer/engine/rendering/panels/InterludePanel"; + +import type { InterludeInput } from "../src/widgets/StoryPlayer/engine/types"; + +function input(overrides: Partial = {}): InterludeInput { + return { + alphaDurationMs: 0, + alphaFrom: -1, + alphaTo: -1, + block: true, + channel: 3, + charName: "", + clear: false, + direction: "", + durationMs: 0, + maskId: "square", + name: "", + offset: { x: 0, y: 0 }, + scaleDurationMs: 0, + size: { x: 300, y: 300 }, + slot: "", + style: 0, + switchOn: true, + templateSizeDurationMs: 0, + templateSizeFrom: { x: 1, y: 1 }, + templateSizeTo: { x: 1, y: 1 }, + type: 0, + ...overrides, + }; +} + +async function tweenImmediately( + _duration: number, + update: (progress: number) => void, + complete?: () => void, +): Promise { + update(1); + complete?.(); +} + +describe("InterludePanel", () => { + it("reuses a channel, adds an element, toggles it, and clears the channel", async () => { + const layer = new Container(); + const loadTexture = vi.fn().mockResolvedValue(Texture.EMPTY); + const tween = vi.fn( + async ( + _duration: number, + update: (progress: number) => void, + complete?: () => void, + ) => { + update(1); + complete?.(); + }, + ); + const panel = new InterludePanel(layer, loadTexture, tween); + + await panel.run(input()); + expect(layer.children).toHaveLength(1); + + await panel.run(input({ name: "avg_test", slot: "m", type: 2 })); + expect(layer.children).toHaveLength(1); + expect(loadTexture).toHaveBeenCalledOnce(); + + await panel.run(input({ slot: "m", switchOn: false, type: 2 })); + const state = (panel as any).channels.get(3); + expect(state.elements.get("m").visible).toBe(false); + + await panel.run(input({ clear: true })); + expect(layer.children).toHaveLength(0); + }); + + it("clears every channel when channel is negative", async () => { + const layer = new Container(); + const panel = new InterludePanel( + layer, + async () => Texture.EMPTY, + tweenImmediately, + ); + await panel.run(input({ channel: 3 })); + await panel.run(input({ channel: 4 })); + + await panel.run(input({ channel: -1, clear: true })); + expect(layer.children).toHaveLength(0); + }); +}); diff --git a/tests/logAll.spec.ts b/tests/logAll.spec.ts new file mode 100644 index 00000000..363081f3 --- /dev/null +++ b/tests/logAll.spec.ts @@ -0,0 +1,347 @@ +import { describe, expect, it } from "vitest"; + +import { buildLogAll } from "../src/widgets/StoryPlayer/engine/logAll"; +import { parseScript } from "../src/widgets/StoryPlayer/engine/parser"; + +const run = (source: string | readonly string[]) => + buildLogAll(parseScript(source)); + +describe("buildLogAll", () => { + it("collects linear dialogue and narration as flat line entries", () => { + const entries = run(['[name="卢西恩"]你好。', "旁白文本一行。"]); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ + kind: "line", + speaker: "卢西恩", + source: "dialogue", + }); + expect(entries[0].kind === "line" && entries[0].spans[0]?.text).toBe( + "你好。", + ); + expect(entries[1]).toMatchObject({ + kind: "line", + speaker: "", + source: "narration", + }); + }); + + it("keeps hidelog stickers and subtitles out of the review list", () => { + const entries = run([ + '[Sticker(id="a",text="进列表")]', + '[Sticker(id="b",text="不进列表",hidelog=true)]', + '[Subtitle(text="也不进",hidelog=true)]', + ]); + expect(entries).toHaveLength(1); + expect(entries[0].kind === "line" && entries[0].spans[0]?.text).toBe( + "进列表", + ); + }); + + it("skips empty dialogue/narration text", () => { + const entries = run(['[name="卢西恩"]', "", '[name="A"]有内容']); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ kind: "line", speaker: "A" }); + }); + + it("stamps each line entry with its source lineNumber (1-based, matches runtime)", () => { + const lines = parseScript([ + '[name="卢西恩"]你好。', // line 1 + "旁白文本一行。", // line 2 + "[delay(time=0.1)]", // line 3 (控制命令,不产生条目) + '[name="阿米娅"]第三句。', // line 4 + ]); + const entries = buildLogAll(lines); + expect(entries).toHaveLength(3); + expect(entries[0].kind).toBe("line"); + expect(entries[0].kind === "line" && entries[0].lineIndex).toBe(1); + expect(entries[1].kind === "line" && entries[1].lineIndex).toBe(2); + expect(entries[2].kind === "line" && entries[2].lineIndex).toBe(4); + }); + + it("uses the last accumulated command line for multiline composite entries", () => { + const lines = parseScript([ + '[multiline(name="A")]第一段', // line 1 + "[multiline]第二段", // line 2 + "[multiline(end=true)]第三段", // line 3 — flush 点,lineIndex 取这里 + ]); + const entries = buildLogAll(lines); + expect(entries).toHaveLength(1); + expect(entries[0].kind === "line" && entries[0].lineIndex).toBe(3); + expect(entries[0].kind === "line" && entries[0].spans[0]?.text).toBe( + "第一段第二段第三段", + ); + }); + + it("stamps lineIndex inside decision branches (per-route copy)", () => { + const lines = parseScript([ + '[decision(options="A;B", values="1;2")]', // line 1 + '[name="公共"]共享', // line 2 + '[predicate(references="1")]', // line 3 + '[name="A"]分支A', // line 4 + '[predicate(references="2")]', // line 5 + '[name="B"]分支B', // line 6 + "[predicate]", // line 7 + ]); + const entries = buildLogAll(lines); + if (entries[0].kind !== "decision") return; + const decision = entries[0]; + // shared 段的行 + expect( + decision.shared[0].kind === "line" && decision.shared[0].lineIndex, + ).toBe(2); + // 两个分支段各自的行 + expect( + decision.branches[0].entries[0].kind === "line" && + decision.branches[0].entries[0].lineIndex, + ).toBe(4); + expect( + decision.branches[1].entries[0].kind === "line" && + decision.branches[1].entries[0].lineIndex, + ).toBe(6); + // routes 投影后也保留同样的 lineIndex + expect( + decision.routes[0].entries[0].kind === "line" && + decision.routes[0].entries[0].lineIndex, + ).toBe(4); + expect( + decision.routes[1].entries[0].kind === "line" && + decision.routes[1].entries[0].lineIndex, + ).toBe(6); + }); + + it("expands a decision into shared + one branch per predicate (matching runtime filtering)", () => { + const entries = run([ + '[decision(options="选A;选B", values="1;2")]', + '[name="公共"]共享文本', + '[predicate(references="1")]', + '[name="A"]甲分支文本', + '[predicate(references="2")]', + '[name="B"]乙分支文本', + "[predicate]", + ]); + expect(entries).toHaveLength(1); + expect(entries[0].kind).toBe("decision"); + if (entries[0].kind !== "decision") return; + + expect(entries[0].options).toEqual([ + { label: "选A", value: 1 }, + { label: "选B", value: 2 }, + ]); + // 共享段:decision 后、第一个 predicate 前(runtime 无条件执行) + expect(entries[0].shared).toHaveLength(1); + expect(entries[0].shared[0]).toMatchObject({ + kind: "line", + speaker: "公共", + }); + // 两个分支段,references 与 runtime decisionReferences 一一对应 + expect(entries[0].branches).toHaveLength(2); + expect(entries[0].branches[0].references).toEqual([1]); + expect(entries[0].branches[0].labels).toEqual(["选A"]); + expect(entries[0].branches[0].entries).toHaveLength(1); + expect(entries[0].branches[0].entries[0]).toMatchObject({ + kind: "line", + speaker: "A", + }); + expect(entries[0].branches[1].references).toEqual([2]); + expect(entries[0].branches[1].labels).toEqual(["选B"]); + expect(entries[0].branches[1].entries[0]).toMatchObject({ + kind: "line", + speaker: "B", + }); + expect(entries[0].routes).toHaveLength(2); + expect(entries[0].routes[0].option).toEqual({ label: "选A", value: 1 }); + expect(entries[0].routes[0].entries[0]).toMatchObject({ speaker: "A" }); + expect(entries[0].routes[1].option).toEqual({ label: "选B", value: 2 }); + expect(entries[0].routes[1].entries[0]).toMatchObject({ speaker: "B" }); + }); + + it("bare [predicate] ends branch mode so following text returns to top level", () => { + const entries = run([ + '[decision(options="A;B", values="1;2")]', + '[predicate(references="1")]', + '[name="A"]分支内', + "[predicate]", + '[name="后"]决策之后', + ]); + expect(entries).toHaveLength(2); + expect(entries[0].kind).toBe("decision"); + expect(entries[1]).toMatchObject({ kind: "line", speaker: "后" }); + }); + + it("predicate with multiple references collects them into one branch segment", () => { + // 对齐 runtime:predicate(references="1;2") 表示选 1 或 2 的玩家都走这一段 + const entries = run([ + '[decision(options="A;B;C", values="1;2;3")]', + '[predicate(references="1;2")]', + '[name="AB"]A和B共享文本', + '[predicate(references="3")]', + '[name="C"]C分支文本', + "[predicate]", + ]); + if (entries[0].kind !== "decision") return; + expect(entries[0].branches).toHaveLength(2); + expect(entries[0].branches[0].references).toEqual([1, 2]); + expect(entries[0].branches[0].labels).toEqual(["A", "B"]); + expect(entries[0].branches[0].entries[0]).toMatchObject({ speaker: "AB" }); + expect(entries[0].branches[1].references).toEqual([3]); + expect(entries[0].branches[1].labels).toEqual(["C"]); + expect(entries[0].branches[1].entries[0]).toMatchObject({ speaker: "C" }); + expect(entries[0].routes[0].entries[0]).toMatchObject({ speaker: "AB" }); + expect(entries[0].routes[1].entries[0]).toMatchObject({ speaker: "AB" }); + expect(entries[0].routes[2].entries[0]).toMatchObject({ speaker: "C" }); + }); + + it("keeps partial matches in routes and moves an all-options match to the parent flow", () => { + const entries = run([ + '[decision(options="A;B", values="1;2")]', + '[predicate(references="1")]', + '[name="A1"]第一段', + '[predicate(references="1;2")]', + '[name="AB"]共同分支段', + "[predicate]", + ]); + if (entries[0].kind !== "decision") return; + + expect( + entries[0].routes[0].entries.map( + (entry) => entry.kind === "line" && entry.speaker, + ), + ).toEqual(["A1"]); + expect(entries[0].routes[1].entries).toEqual([]); + expect(entries[1]).toMatchObject({ kind: "line", speaker: "AB" }); + }); + + it("returns to the parent flow when a predicate covers every option", () => { + const entries = run([ + '[decision(options="第一A;第一B", values="1;2")]', + '[predicate(references="1;2")]', + '[name="共同"]第一组选择后的共同文本', + '[decision(options="第二A;第二B", values="1;2")]', + '[predicate(references="1;2")]', + '[name="后续"]第二组选择后的共同文本', + ]); + + expect(entries).toHaveLength(4); + expect(entries[0]).toMatchObject({ kind: "decision" }); + expect(entries[1]).toMatchObject({ kind: "line", speaker: "共同" }); + expect(entries[2]).toMatchObject({ kind: "decision" }); + expect(entries[3]).toMatchObject({ kind: "line", speaker: "后续" }); + if (entries[0].kind !== "decision" || entries[2].kind !== "decision") + return; + expect(entries[0].routes.every((route) => route.entries.length === 0)).toBe( + true, + ); + expect(entries[2].routes.every((route) => route.entries.length === 0)).toBe( + true, + ); + }); + + it("returns an inner all-options convergence to its enclosing option path", () => { + const entries = run([ + '[decision(options="外A;外B", values="1;2")]', + '[predicate(references="1")]', + '[decision(options="内X;内Y", values="10;11")]', + '[predicate(references="10;11")]', + '[name="内层汇合"]仍然只在外A路径中', + "[predicate]", + ]); + + if (entries[0].kind !== "decision") return; + const outerARoute = entries[0].routes[0].entries; + expect(outerARoute[0]).toMatchObject({ kind: "decision" }); + expect(outerARoute[1]).toMatchObject({ kind: "line", speaker: "内层汇合" }); + expect(entries[0].routes[1].entries).toEqual([]); + }); + + it("keeps an explicit empty route for an option without a matching predicate", () => { + const entries = run([ + '[decision(options="A;B", values="1;2")]', + '[predicate(references="1")]', + '[name="A"]A分支', + "[predicate]", + ]); + if (entries[0].kind !== "decision") return; + + expect(entries[0].routes[1]).toMatchObject({ + entries: [], + option: { label: "B", value: 2 }, + }); + }); + + it("accumulates multiline text and flushes on end=true", () => { + const entries = run([ + '[multiline(name="卢西恩")]第一段,', + '[multiline(name="卢西恩", end=true)]第二段。', + ]); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + kind: "line", + source: "multiline", + speaker: "卢西恩", + }); + if (entries[0].kind === "line") + expect(entries[0].spans.map((s) => s.text).join("")).toBe( + "第一段,第二段。", + ); + }); + + it("includes sticker and subtitle text with the right source", () => { + const entries = run([ + '[sticker(id="s1", text="贴纸文本")]', + '[subtitle(text="字幕文本")]', + ]); + expect(entries).toHaveLength(2); + expect(entries[0]).toMatchObject({ kind: "line", source: "sticker" }); + expect(entries[1]).toMatchObject({ kind: "line", source: "subtitle" }); + }); + + it("preserves color tags as separate spans", () => { + const entries = run(['[name="A"]
普通']); + expect(entries).toHaveLength(1); + if (entries[0].kind !== "line") return; + expect(entries[0].spans).toEqual([ + { text: "红", color: "#ff0000" }, + { text: "普通", color: null }, + ]); + }); + + it("does not crash on decision without options", () => { + const entries = run(['[decision(values="1")]', '[name="A"]正文']); + // 无 options 的 decision 被 runtime warn 并忽略,仅留下对白 + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ kind: "line", speaker: "A" }); + }); + + it("skips predicate without an enclosing decision", () => { + const entries = run(['[predicate(references="1")]', '[name="A"]正文']); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ kind: "line", speaker: "A" }); + }); + + it("keeps a decision inside the outer option path that can reach it", () => { + const entries = run([ + '[decision(options="外A;外B", values="1;2")]', + '[predicate(references="1")]', + '[name="外A"]外层分支文本', + '[decision(options="内X;内Y", values="10;11")]', + '[predicate(references="10")]', + '[name="X"]内层分支文本', + "[predicate]", + ]); + expect(entries).toHaveLength(1); + expect(entries[0].kind).toBe("decision"); + if (entries[0].kind !== "decision") return; + + expect(entries[0].options.map((o) => o.label)).toEqual(["外A", "外B"]); + expect(entries[0].branches).toHaveLength(1); + expect(entries[0].branches[0].references).toEqual([1]); + expect(entries[0].branches[0].entries[0]).toMatchObject({ speaker: "外A" }); + + const nested = entries[0].routes[0].entries[1]; + expect(nested?.kind).toBe("decision"); + if (!nested || nested.kind !== "decision") return; + expect(nested.options.map((o) => o.label)).toEqual(["内X", "内Y"]); + expect(nested.routes[0].entries[0]).toMatchObject({ speaker: "X" }); + expect(entries[0].routes[1].entries).toEqual([]); + }); +}); diff --git a/tests/parser.spec.ts b/tests/parser.spec.ts new file mode 100644 index 00000000..5f109782 --- /dev/null +++ b/tests/parser.spec.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + parseLine, + parseScript, + parseStory, +} from "../src/widgets/StoryPlayer/engine/parser"; + +describe("parser", () => { + it("parses command line with args and trailing text", () => { + const line = parseLine('[multiline(name="卢西恩",end=true)]她自己。'); + expect(line.kind).toBe("command"); + + if (line.kind !== "command") return; + + expect(line.command).toBe("multiline"); + expect(line.args.name).toBe("卢西恩"); + expect(line.args.end).toBe(true); + expect(line.trailingText).toBe("她自己。"); + }); + + it("parses dialogue line", () => { + const line = parseLine( + '[name="剧团长"]如若你是指自己解决了那个名叫莫伊拉的孩子......', + ); + expect(line.kind).toBe("dialogue"); + + if (line.kind !== "dialogue") return; + + expect(line.speaker).toBe("剧团长"); + expect(line.text.startsWith("如若你是指")).toBe(true); + }); + + it("parses narration line", () => { + const line = parseLine("卢西恩轻轻颔首。"); + expect(line.kind).toBe("narration"); + + if (line.kind !== "narration") return; + + expect(line.text).toBe("卢西恩轻轻颔首。"); + }); + + it("appends the generated endtip command", () => { + const input = ["[dialog]", "文本"]; + const output = parseScript(input); + expect(output).toHaveLength(3); + expect(output[0].kind).toBe("command"); + expect(output[1].kind).toBe("narration"); + expect(output[2]).toMatchObject({ command: "endtip", kind: "command" }); + }); + + it("joins continuations before filtering comments and blank lines", () => { + const output = parseScript( + '[Character(name="a",\\\n xScale=1.2)]\n // comment\n\ntext', + ); + expect(output).toHaveLength(3); + expect(output[0]).toMatchObject({ kind: "command", lineNumber: 1 }); + expect(output[1]).toMatchObject({ + kind: "narration", + lineNumber: 5, + text: "text", + }); + }); + + it("normalizes command names but preserves parameter key casing", () => { + const line = parseLine("[BackgroundTween(xScaleFrom=1.2,xscale=2)]"); + expect(line).toMatchObject({ + args: { xScaleFrom: 1.2, xscale: 2 }, + command: "backgroundtween", + kind: "command", + }); + }); + + it("preserves narration whitespace", () => { + const line = parseLine(" ......好热...... "); + expect(line).toMatchObject({ + kind: "narration", + text: " ......好热...... ", + }); + }); + + it("extracts header metadata without consuming the header command", () => { + // The story id comes from `key`; `id` is not a HEADER param at all. + const story = parseStory( + '[HEADER(key="x",fit_mode="BLACK_MASK",is_video_only=true)] T', + ); + expect(story.metadata).toMatchObject({ + fitMode: "BLACK_MASK", + id: "x", + isVideoOnly: true, + title: "T", + }); + expect(story.lines[0]).toMatchObject({ + command: "header", + kind: "command", + }); + }); + + it("ignores a HEADER id param", () => { + const story = parseStory('[HEADER(id="x")] T'); + expect(story.metadata.id).toBe(""); + }); +}); diff --git a/tests/preload.spec.ts b/tests/preload.spec.ts new file mode 100644 index 00000000..20a12efb --- /dev/null +++ b/tests/preload.spec.ts @@ -0,0 +1,258 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { preloadContextAssets } from "../src/widgets/StoryPlayer/engine/preload"; + +import type { Context } from "../src/widgets/StoryPlayer/context"; + +const { loadMock } = vi.hoisted(() => ({ + // 对齐 pixi v8 Assets.load 第二参数签名:可以是裸 progress 回调,也可以是 + // LoadOptions({ onProgress, onError, ... })。两种都从 onProgress 求值。 + loadMock: vi.fn( + async ( + urls: string[], + onProgressOrOptions?: + | ((progress: number) => void) + | { onProgress?: (progress: number) => void }, + ) => { + const cb = + typeof onProgressOrOptions === "function" + ? onProgressOrOptions + : onProgressOrOptions?.onProgress; + cb?.(1); + return urls; + }, + ), +})); + +vi.mock("pixi.js", () => ({ + Assets: { + load: loadMock, + }, +})); + +function createContext(script: readonly string[]): Context { + return { + audioVariables: {}, + linkMap: { + avg_1012_skadisp_1: { + array: [ + { + alias: "", + group: -1, + image: "avg_1012_skadisp_1/avg_1012_skadisp_1", + name: "avg_1012_skadisp_1", + }, + { + alias: "", + group: -1, + image: "avg_1012_skadisp_1/avg_1012_skadisp_2", + name: "avg_1012_skadisp_2", + }, + ], + groups: [], + pos: { x: 0, y: 0 }, + size: { x: 0, y: 0 }, + }, + avg_npc_180: { + array: [ + { + alias: "", + group: -1, + image: "avg_npc_180/avg_npc_180_1", + name: "avg_npc_180_1", + }, + { + alias: "", + group: -1, + image: "avg_npc_180/avg_npc_180_2", + name: "avg_npc_180_2", + }, + { + alias: "", + group: -1, + image: "avg_npc_180/avg_npc_180_3", + name: "avg_npc_180_3", + }, + ], + groups: [], + pos: { x: 0, y: 0 }, + size: { x: 0, y: 0 }, + }, + char_empty: { + array: [ + { + alias: "", + group: -1, + image: "char_empty/char_empty", + name: "char_empty", + }, + ], + groups: [], + pos: { x: 0, y: 0 }, + size: { x: 0, y: 0 }, + }, + }, + script, + }; +} + +describe("preloadContextAssets", () => { + beforeEach(() => { + loadMock.mockClear(); + }); + + it("preloads character command portraits using case-insensitive refs", async () => { + const progress = vi.fn(); + const context = createContext([ + '[character(name="avg_1012_skadiSP_1#2",name2="char_empty")]', + '[charslot(slot="m",name="avg_npc_180#3")]', + ]); + + await preloadContextAssets(context, progress); + + expect(loadMock).toHaveBeenCalledTimes(1); + expect(loadMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/characters/avg_1012_skadisp_1/avg_1012_skadisp_2.png", + "https://torappu.prts.wiki/assets/avg/characters/char_empty/char_empty.png", + "https://torappu.prts.wiki/assets/avg/characters/avg_npc_180/avg_npc_180_3.png", + ]), + ); + expect(progress).toHaveBeenCalledWith(1); + }); + + it("preloads background command images as background assets", async () => { + const context = createContext(['[background(image="bg_rhodes_day")]']); + + await preloadContextAssets(context); + + expect(loadMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/background/bg_rhodes_day.png", + ]), + ); + }); + + it("preloads avgdisplay bg content as a background asset", async () => { + const context = createContext([ + '[avgdisplay(id="1",style="bg",name="bg_black",slot="bgover")]', + '[avgdisplay(id="1")]', + ]); + + await preloadContextAssets(context); + + expect(loadMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/background/bg_black.png", + ]), + ); + }); + + it("preloads gridbg tiles as background assets", async () => { + const context = createContext([ + '[gridbg(imagegroup="47_g14_skyovercast_L1/47_g14_skyovercast_R1/47_g14_skyovercast_L2/47_g14_skyovercast_R2",solidwidth="1280/1280/1280/1280",solidheight="720/720/720/720")]', + ]); + + await preloadContextAssets(context); + + expect(loadMock).toHaveBeenCalledTimes(1); + expect(loadMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/background/47_g14_skyovercast_l1.png", + "https://torappu.prts.wiki/assets/avg/background/47_g14_skyovercast_r1.png", + "https://torappu.prts.wiki/assets/avg/background/47_g14_skyovercast_l2.png", + "https://torappu.prts.wiki/assets/avg/background/47_g14_skyovercast_r2.png", + ]), + ); + }); + + it("preloads verticalbg cggroup tiles as image assets", async () => { + const context = createContext([ + '[verticalbg(cggroup="69_i12_1/69_i12_2",solidwidth="1600",solidheight="900/900")]', + ]); + + await preloadContextAssets(context); + + expect(loadMock).toHaveBeenCalledTimes(1); + expect(loadMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/images/69_i12_1.png", + "https://torappu.prts.wiki/assets/avg/images/69_i12_2.png", + ]), + ); + }); + + it("prefers imagegroup background tiles when verticalbg also includes cggroup", async () => { + const context = createContext([ + '[verticalbg(imagegroup="47_g14_skyovercast_L1/47_g14_skyovercast_R1",cggroup="69_i12_1/69_i12_2",solidwidth="1280/1280",solidheight="720")]', + ]); + + await preloadContextAssets(context); + + expect(loadMock).toHaveBeenCalledTimes(1); + expect(loadMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/background/47_g14_skyovercast_l1.png", + "https://torappu.prts.wiki/assets/avg/background/47_g14_skyovercast_r1.png", + ]), + ); + expect(loadMock.mock.calls[0]?.[0]).not.toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/images/47_g14_skyovercast_l1.png", + "https://torappu.prts.wiki/assets/avg/images/47_g14_skyovercast_r1.png", + ]), + ); + }); + + it("preloads largeimg imagegroup tiles as image assets", async () => { + const context = createContext([ + '[largeimg(imagegroup="61_i12/61_i11",solidwidth="1600/1600",solidheight="900")]', + ]); + + await preloadContextAssets(context); + + expect(loadMock).toHaveBeenCalledTimes(1); + expect(loadMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/images/61_i12.png", + "https://torappu.prts.wiki/assets/avg/images/61_i11.png", + ]), + ); + expect(loadMock.mock.calls[0]?.[0]).not.toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/avg/background/61_i12.png", + "https://torappu.prts.wiki/assets/avg/background/61_i11.png", + ]), + ); + }); + + it("collects only playmusic key and intro plus playsound key", async () => { + const context = createContext([ + '[playmusic(key="$m",intro="$intro")]', + '[playsound(key="$s",intro="$ignored")]', + '[musicvolume(key="$ignored")]', + '[stopsound(key="$ignored")]', + ]); + context.audioVariables = { + ignored: "sound_beta_2/avg/ignored", + intro: "sound_beta_2/avg/intro", + m: "sound_beta_2/avg/music", + s: "sound_beta_2/avg/sound", + }; + + await preloadContextAssets(context); + + expect(loadMock.mock.calls[0]?.[0]).toEqual( + expect.arrayContaining([ + "https://torappu.prts.wiki/assets/audio/avg/music.mp3", + "https://torappu.prts.wiki/assets/audio/avg/intro.mp3", + "https://torappu.prts.wiki/assets/audio/avg/sound.mp3", + ]), + ); + expect(loadMock.mock.calls[0]?.[0]).not.toContain( + "https://torappu.prts.wiki/assets/audio/avg/ignored.mp3", + ); + }); +}); diff --git a/tests/renderer.spec.ts b/tests/renderer.spec.ts new file mode 100644 index 00000000..49e7a989 --- /dev/null +++ b/tests/renderer.spec.ts @@ -0,0 +1,524 @@ +import { Container, Texture } from "pixi.js"; +import { describe, expect, it, vi } from "vitest"; + +import { PixiStoryRenderer } from "../src/widgets/StoryPlayer/engine/renderer"; + +import type { Context } from "../src/widgets/StoryPlayer/context"; +import type { GridBackgroundInput } from "../src/widgets/StoryPlayer/engine/types"; + +function createContext(): Context { + return { + linkMap: {}, + script: [], + }; +} + +function createGridBackgroundInput(): GridBackgroundInput { + return { + block: false, + fadeMs: 0, + imageKeys: ["l1", "r1", "l2", "r2"], + scaleX: 0.5, + scaleY: 0.75, + solidHeights: [720, 720, 720, 720], + solidWidths: [1280, 1280, 1280, 1280], + x: -640, + y: 320, + }; +} + +describe("PixiStoryRenderer", () => { + it("dims unfocused characters with tint without making them transparent", () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const root = new Container(); + const visual = new Container(); + const state = { + contentAlpha: 1, + focusBrightness: 0.5, + root, + visual, + }; + + renderer.updateCharacterOpacity(state); + + expect(root.alpha).toBe(1); + expect(visual.alpha).toBe(1); + expect(visual.tint).toBe(0x80_80_80); + }); + + it("keeps the large background behind the background regardless of update order", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const input = createGridBackgroundInput(); + + renderer.app = {}; + renderer.backgroundLayer.addChild(renderer.gridBackgroundLayer); + renderer.textureForImageKey = vi.fn().mockResolvedValue(Texture.EMPTY); + + // panel_large_background is a permanent sibling in front of panel_background + // in SceneCanvas, so it always renders underneath -- executing gridbg after + // background must not lift the puzzle above it. + await renderer.setGridBackground(input); + expect(renderer.backgroundLayer.children.at(0)).toBe( + renderer.gridBackgroundLayer, + ); + + await renderer.setBackground("bg_test"); + expect(renderer.backgroundLayer.children.at(0)).toBe( + renderer.gridBackgroundLayer, + ); + expect(renderer.backgroundLayer.children.at(-1)).toBe( + renderer.backgroundRoot, + ); + + await renderer.setGridBackground(input); + expect(renderer.backgroundLayer.children.at(0)).toBe( + renderer.gridBackgroundLayer, + ); + expect(renderer.backgroundLayer.children.at(-1)).toBe( + renderer.backgroundRoot, + ); + }); + + it("draws a curtain as a solid body plus a fixed-width feather strip", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + renderer.app = {}; + + const fills: unknown[] = []; + const state = { + alpha: 1, + fill: 0.5, + grad: true, + graphic: { + alpha: 1, + clear: () => {}, + poly() { + return this; + }, + fill(style: unknown) { + fills.push(style); + return this; + }, + visible: false, + }, + }; + + // direction 6 maps to a rightward sweep, so the inner edge is a vertical + // line at x = 640 and the feather occupies x = 620..640. + renderer.updateCurtainState(state, { x: 1, y: 0 }); + + expect(state.graphic.visible).toBe(true); + // Body is opaque black; only the strip carries the gradient. + expect(fills[0]).toBe(0x00_00_00); + expect(fills).toHaveLength(2); + const gradient = fills[1] as any; + expect(gradient.start).toEqual({ x: 620, y: 0 }); + expect(gradient.end).toEqual({ x: 640, y: 0 }); + }); + + it("applies background transforms in a dedicated transform space", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + renderer.app = {}; + renderer.textureForImageKey = vi.fn().mockResolvedValue(Texture.EMPTY); + + await renderer.setBackground("bg_test", { + scaleX: 2.04, + scaleY: 1.68, + x: 24, + y: -36, + }); + + expect(renderer.backgroundRoot.scale.x).toBe(2.04); + expect(renderer.backgroundRoot.scale.y).toBe(1.68); + expect(renderer.backgroundRoot.position.x).toBe(664); + expect(renderer.backgroundRoot.position.y).toBe(396); + expect(renderer.backgroundSprite.position.x).toBe(0); + expect(renderer.backgroundSprite.position.y).toBe(0); + }); + + it("keeps the Unity background rect size when screenadapt is omitted", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + renderer.app = {}; + renderer.textureForImageKey = vi.fn().mockResolvedValue(Texture.EMPTY); + + await renderer.setBackground("bg_festival_2"); + + expect(renderer.backgroundSprite.width).toBe(1280); + expect(renderer.backgroundSprite.height).toBe(720); + }); + + it("keeps an image at its asset size when screenadapt is omitted", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + renderer.app = {}; + renderer.textureForImageKey = vi.fn().mockResolvedValue(Texture.EMPTY); + + await renderer.setImage("ac3_title1"); + + expect(renderer.imageSprite.width).toBe(Texture.EMPTY.width); + expect(renderer.imageSprite.height).toBe(Texture.EMPTY.height); + }); + + it("applies the strict gridbg xScale and yScale transform", () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const root = renderer.buildGridBackgroundRoot(createGridBackgroundInput(), [ + Texture.EMPTY, + Texture.EMPTY, + Texture.EMPTY, + Texture.EMPTY, + ]); + + expect(root.scale.x).toBe(0.5); + expect(root.scale.y).toBe(0.75); + }); + + it("builds verticalbg as a single-column stacked background", () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const root = renderer.buildGridBackgroundRoot( + { + ...createGridBackgroundInput(), + imageKeys: ["v1", "v2", "v3"], + layout: "vertical", + solidHeights: [720, 720, 625], + solidWidths: [1280], + y: 540, + }, + [Texture.EMPTY, Texture.EMPTY, Texture.EMPTY], + ); + + expect(root.children).toHaveLength(3); + expect(root.position.x).toBe(0); + expect(root.position.y).toBe(-180); + expect(root.pivot.x).toBe(640); + expect(root.pivot.y).toBe(720); + expect(root.scale.x).toBe(0.5); + expect(root.scale.y).toBe(0.75); + }); + + it("stacks verticalbg tiles from top to bottom", () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const textures = [ + new Texture({ label: "tile-0" }), + new Texture({ label: "tile-1" }), + new Texture({ label: "tile-2" }), + new Texture({ label: "tile-3" }), + ]; + const root = renderer.buildGridBackgroundRoot( + { + ...createGridBackgroundInput(), + imageKeys: ["tile-0", "tile-1", "tile-2", "tile-3"], + layout: "vertical", + solidHeights: [360, 360, 360, 360], + solidWidths: [640], + x: 0, + y: 0, + }, + textures, + ); + + expect(root.children).toHaveLength(4); + expect( + root.children.map((child: any) => ({ + label: child.texture.label, + x: child.position.x, + y: child.position.y, + })), + ).toEqual([ + { label: "tile-0", x: 0, y: 0 }, + { label: "tile-1", x: 0, y: 360 }, + { label: "tile-2", x: 0, y: 720 }, + { label: "tile-3", x: 0, y: 1080 }, + ]); + }); + + it("lays out largebg as exactly two horizontal tiles", () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const textures = [ + new Texture({ label: "tile-0" }), + new Texture({ label: "tile-1" }), + ]; + const root = renderer.buildGridBackgroundRoot( + { + ...createGridBackgroundInput(), + imageKeys: ["tile-0", "tile-1"], + layout: "large", + solidHeights: [360], + solidWidths: [640, 640], + x: 0, + y: 0, + }, + textures, + ); + + expect(root.children).toHaveLength(2); + expect( + root.children.map((child: any) => ({ + label: child.texture.label, + x: child.position.x, + y: child.position.y, + })), + ).toEqual([ + { label: "tile-0", x: 0, y: 0 }, + { label: "tile-1", x: 640, y: 0 }, + ]); + }); + + it("applies largebgtween in largebg transform space", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const root = renderer.buildGridBackgroundRoot( + { + ...createGridBackgroundInput(), + imageKeys: ["tile-0", "tile-1"], + initPositionMode: "default", + layout: "large", + solidHeights: [720], + solidWidths: [920, 920], + x: -180, + y: 0, + }, + [Texture.EMPTY, Texture.EMPTY], + ); + const snapshots: Array<{ + scaleX: number; + scaleY: number; + x: number; + y: number; + }> = []; + + expect(root.position.y).toBe(360); + renderer.app = {}; + renderer.gridBackgroundLayer.addChild(root); + renderer.largeBackgroundRoot = root; + renderer.tween = vi.fn( + async ( + _durationMs: number, + step: (progress: number) => void, + done?: () => void, + ) => { + snapshots.push(renderer.readCenteredTransform(root)); + step(0.5); + snapshots.push(renderer.readCenteredTransform(root)); + done?.(); + snapshots.push(renderer.readCenteredTransform(root)); + }, + ); + + await renderer.setLargeBackgroundTween({ + block: true, + durationMs: 1000, + xFrom: -180, + xScaleFrom: 1.2, + xScaleTo: 0.8, + xTo: -720, + yFrom: 0, + yScaleFrom: 1.2, + yScaleTo: 0.6, + yTo: 360, + }); + + expect(snapshots).toEqual([ + { scaleX: 1.2, scaleY: 1.2, x: -180, y: 0 }, + { scaleX: 1, scaleY: 0.899_999_999_999_999_9, x: -450, y: 180 }, + { scaleX: 0.8, scaleY: 0.6, x: -720, y: 360 }, + ]); + // Native default initposmode keeps its +width[1]/2 parent offset while + // largebgtween moves only the child offset transform. + expect(root.position.x).toBe(380); + }); + + it("applies backgroundtween in background transform space", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const root = new Texture({ label: "bg-test" }); + const snapshots: Array<{ + scaleX: number; + scaleY: number; + x: number; + y: number; + }> = []; + + renderer.app = {}; + renderer.textureForImageKey = vi.fn().mockResolvedValue(root); + renderer.tween = vi.fn( + async ( + _durationMs: number, + step: (progress: number) => void, + done?: () => void, + ) => { + snapshots.push({ + scaleX: renderer.backgroundRoot.scale.x, + scaleY: renderer.backgroundRoot.scale.y, + x: renderer.backgroundRoot.position.x - 640, + y: 360 - renderer.backgroundRoot.position.y, + }); + step(0.5); + snapshots.push({ + scaleX: renderer.backgroundRoot.scale.x, + scaleY: renderer.backgroundRoot.scale.y, + x: renderer.backgroundRoot.position.x - 640, + y: 360 - renderer.backgroundRoot.position.y, + }); + done?.(); + snapshots.push({ + scaleX: renderer.backgroundRoot.scale.x, + scaleY: renderer.backgroundRoot.scale.y, + x: renderer.backgroundRoot.position.x - 640, + y: 360 - renderer.backgroundRoot.position.y, + }); + }, + ); + + await renderer.setBackground("bg_test", { + scaleX: 2.04, + scaleY: 1.68, + x: 24, + y: -36, + }); + await renderer.setBackgroundTween({ + block: true, + durationMs: 1000, + xFrom: 24, + xScaleFrom: 2.04, + xScaleTo: 1.3, + xTo: -120, + yFrom: -36, + yScaleFrom: 1.68, + yScaleTo: 1.1, + yTo: 80, + }); + + expect(snapshots).toEqual([ + { scaleX: 2.04, scaleY: 1.68, x: 24, y: -36 }, + { scaleX: 1.67, scaleY: 1.390_000_000_000_000_1, x: -48, y: 22 }, + { scaleX: 1.3, scaleY: 1.1, x: -120, y: 80 }, + ]); + }); + + it("applies largeimgtween in largeimg transform space", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const root = renderer.buildGridBackgroundRoot( + { + ...createGridBackgroundInput(), + imageKeys: ["tile-0", "tile-1"], + layout: "large", + solidHeights: [900], + solidWidths: [1600, 1600], + x: -160, + }, + [Texture.EMPTY, Texture.EMPTY], + ); + const snapshots: Array<{ + scaleX: number; + scaleY: number; + x: number; + y: number; + }> = []; + + renderer.app = {}; + renderer.imageLayer.addChild(root); + renderer.largeImageRoot = root; + renderer.tween = vi.fn( + async ( + _durationMs: number, + step: (progress: number) => void, + done?: () => void, + ) => { + snapshots.push({ + scaleX: root.scale.x, + scaleY: root.scale.y, + x: root.position.x - 640, + y: 360 - root.position.y, + }); + step(0.5); + snapshots.push({ + scaleX: root.scale.x, + scaleY: root.scale.y, + x: root.position.x - 640, + y: 360 - root.position.y, + }); + done?.(); + snapshots.push({ + scaleX: root.scale.x, + scaleY: root.scale.y, + x: root.position.x - 640, + y: 360 - root.position.y, + }); + }, + ); + + await renderer.setLargeImageTween({ + block: true, + durationMs: 1000, + xFrom: -160, + xScaleFrom: 1.2, + xScaleTo: 0.8, + xTo: -720, + yFrom: 0, + yScaleFrom: 1.2, + yScaleTo: 0.6, + yTo: 360, + }); + + expect(snapshots).toEqual([ + { scaleX: 1.2, scaleY: 1.2, x: -160, y: 0 }, + { scaleX: 1, scaleY: 0.899_999_999_999_999_9, x: -440, y: 180 }, + { scaleX: 0.8, scaleY: 0.6, x: -720, y: 360 }, + ]); + }); + + it("applies zero-duration largeimgtween immediately and cancels stale tweens", async () => { + const renderer = new PixiStoryRenderer(createContext()) as any; + const root = renderer.buildGridBackgroundRoot( + { + ...createGridBackgroundInput(), + imageKeys: ["tile-0", "tile-1"], + layout: "large", + solidHeights: [900], + solidWidths: [1600, 1600], + x: -160, + }, + [Texture.EMPTY, Texture.EMPTY], + ); + let pendingStep: ((progress: number) => void) | null = null; + let pendingDone: (() => void) | null = null; + + renderer.app = {}; + renderer.imageLayer.addChild(root); + renderer.largeImageRoot = root; + renderer.tween = vi.fn( + async ( + _durationMs: number, + step: (progress: number) => void, + done?: () => void, + ) => { + pendingStep = step; + pendingDone = done ?? null; + }, + ); + + await renderer.setLargeImageTween({ + block: false, + durationMs: 1000, + xFrom: -160, + xTo: -720, + yFrom: 0, + yTo: 0, + }); + + const applySpy = vi.spyOn(renderer, "applyCenteredTransform"); + + await renderer.setLargeImageTween({ + block: true, + durationMs: 0, + xFrom: -320, + }); + + expect(applySpy.mock.calls).toEqual([ + [root, { scaleX: 0.5, scaleY: 0.75, x: -320, y: 0 }], + [root, { scaleX: 0.5, scaleY: 0.75, x: -160, y: 0 }], + ]); + + (pendingStep as ((progress: number) => void) | null)?.(1); + (pendingDone as (() => void) | null)?.(); + + expect(root.position.x - 640).toBe(-160); + expect(360 - root.position.y).toBe(0); + }); +}); diff --git a/tests/rotate-tween.spec.ts b/tests/rotate-tween.spec.ts new file mode 100644 index 00000000..656b1b4b --- /dev/null +++ b/tests/rotate-tween.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { rotateTweenDelta } from "../src/widgets/StoryPlayer/engine/rendering/core/SceneGeometry"; + +describe("AVGUtils.CreateRotateTween sweep", () => { + it("forces a negative sweep clockwise even without circles", () => { + // inverse defaults to false, and any positive delta is rewritten to + // delta - 360, so angle=90 from 0 travels -270 rather than +90. + expect(rotateTweenDelta(0, 90, 0, false)).toBe(-270); + expect(rotateTweenDelta(0, 30, 0, false)).toBe(-330); + }); + + it("forces a positive sweep counter-clockwise even without circles", () => { + expect(rotateTweenDelta(0, -90, 0, true)).toBe(270); + expect(rotateTweenDelta(0, 270, 0, true)).toBe(270); + }); + + it("stacks whole circles on top of the direction-corrected delta", () => { + expect(rotateTweenDelta(0, 90, 2, false)).toBe(-270 - 720); + expect(rotateTweenDelta(0, 90, 2, true)).toBe(90 + 720); + }); + + it("wraps the current and target angles with Repeat rather than clamping", () => { + expect(rotateTweenDelta(720, 90, 0, true)).toBe(90); + expect(rotateTweenDelta(-90, 0, 0, true)).toBe(90); + expect(rotateTweenDelta(45, 405, 0, true)).toBe(0); + }); + + it("keeps a 180 degree delta positive before the direction correction", () => { + // Repeat puts delta at exactly 180, which the `> 180` test leaves alone. + expect(rotateTweenDelta(0, 180, 0, true)).toBe(180); + expect(rotateTweenDelta(0, 180, 0, false)).toBe(-180); + }); +}); diff --git a/tests/runtime.spec.ts b/tests/runtime.spec.ts new file mode 100644 index 00000000..2dd9f060 --- /dev/null +++ b/tests/runtime.spec.ts @@ -0,0 +1,2773 @@ +import { describe, expect, it, vi } from "vitest"; + +import { StoryRuntime } from "../src/widgets/StoryPlayer/engine/runtime"; + +import type { Context } from "../src/widgets/StoryPlayer/context"; +import type { + AnimTextInput, + AvgDisplayInput, + BackgroundInput, + BackgroundTweenInput, + BlockerInput, + CameraShakeInput, + CgItemInput, + CharacterActionInput, + CharacterCutinInput, + CharacterSlotInput, + CurtainInput, + FocusOutInput, + FocusParamInput, + GridBackgroundInput, + ImageRotateInput, + InterludeInput, + LargeBackgroundTweenInput, + PlayMusicInput, + PlaySoundInput, + RuntimeWarning, + ShowItemInput, + SpellStickerInput, + StickerInput, + StoryAudio, + StoryRenderer, + SubtitleInput, + TimerClearInput, + TimerStickerInput, +} from "../src/widgets/StoryPlayer/engine/types"; + +class FakeRenderer implements StoryRenderer { + animTextCalls: AnimTextInput[] = []; + avgDisplayCalls: AvgDisplayInput[] = []; + actionCalls: CharacterActionInput[] = []; + backgroundCalls: Array<{ input?: BackgroundInput; key: string }> = []; + backgroundTweenCalls: BackgroundTweenInput[] = []; + blockerCalls: BlockerInput[] = []; + cameraEffectCalls: Array<{ + amount: number; + block: boolean; + durationMs: number; + effect: string; + initialAmount?: number; + keep: boolean; + }> = []; + characterCalls: CharacterSlotInput[] = []; + characterCutinCalls: CharacterCutinInput[] = []; + clearedSlots: Array<{ fadeMs?: number; slot?: string }> = []; + clearItemsCalls: Array<{ block: boolean; fadeMs: number }> = []; + clearCgItemCalls: Array<{ + block: boolean; + ease: string; + fadeMs: number; + key?: string; + }> = []; + cgItemCalls: CgItemInput[] = []; + curtainCalls: CurtainInput[] = []; + curtainClearCalls: Array<{ block: boolean; fadeMs: number }> = []; + focusOutCalls: FocusOutInput[] = []; + focusParamCalls: FocusParamInput[] = []; + clearCharactersHandler?: ( + slot?: string, + fadeMs?: number, + ) => Promise | void; + gridBackgroundCalls: GridBackgroundInput[] = []; + gridBackgroundClearCalls: Array<{ block: boolean; fadeMs: number }> = []; + imageRotateCalls: ImageRotateInput[] = []; + interludeCalls: InterludeInput[] = []; + largeBackgroundTweenCalls: LargeBackgroundTweenInput[] = []; + largeImageTweenCalls: LargeBackgroundTweenInput[] = []; + lastDialogue = { speaker: "", text: "" }; + showItemCalls: ShowItemInput[] = []; + stickerCalls: StickerInput[] = []; + spellStickerCalls: SpellStickerInput[] = []; + spellStickerHideCalls: string[] = []; + spellStickerClearCount = 0; + stickerClearCalls: Array<{ fadeMs: number; id?: string }> = []; + stickersClearCalls: number[] = []; + subtitleCalls: SubtitleInput[] = []; + subtitleClearCalls: number[] = []; + shakeCalls: CameraShakeInput[] = []; + timerClearCalls: TimerClearInput[] = []; + timerStickerCalls: TimerStickerInput[] = []; + typingActive = false; + videoCalls: string[] = []; + videoStopped = false; + videoWaiter: Promise = Promise.resolve(); + decisionValue = 0; + private resolveVideoWaiter: (() => void) | null = null; + + setCameraEffect( + effect: "Colorinverse" | "Grayscale", + amount: number, + durationMs: number, + block: boolean, + keep: boolean, + initialAmount?: number, + ): void { + this.cameraEffectCalls.push({ + amount, + block, + durationMs, + effect, + initialAmount, + keep, + }); + } + + setFocusOut(input: FocusOutInput): void { + this.focusOutCalls.push(input); + } + + setFocusParam(input: FocusParamInput): void { + this.focusParamCalls.push(input); + } + + clearBackground(): void {} + clearAvgDisplays(): void {} + clearAnimTexts(): void {} + clearCharacters(slot?: string, fadeMs?: number): Promise | void { + this.clearedSlots.push({ fadeMs, slot }); + return this.clearCharactersHandler?.(slot, fadeMs); + } + + clearCurtains(fadeMs = 0, block = false): void { + this.curtainClearCalls.push({ block, fadeMs }); + } + + clearGridBackground(fadeMs = 0, block = false): void { + this.gridBackgroundClearCalls.push({ block, fadeMs }); + } + + clearImage(): void {} + clearLargeImage(fadeMs = 0, block = false): void { + this.gridBackgroundClearCalls.push({ block, fadeMs }); + } + + clearItems(fadeMs = 0, block = false): void { + this.clearItemsCalls.push({ block, fadeMs }); + } + + clearCgItems( + key?: string, + fadeMs = 130, + ease = "OutQuad", + block = false, + ): void { + this.clearCgItemCalls.push({ block, ease, fadeMs, key }); + } + + clearSticker(id?: string, fadeMs = 0): void { + this.stickerClearCalls.push({ fadeMs, id }); + } + + clearStickers(fadeMs = 0): void { + this.stickersClearCalls.push(fadeMs); + } + + clearSpellStickers(): void { + this.spellStickerClearCount += 1; + } + + hideSpellSticker(id: string): void { + this.spellStickerHideCalls.push(id); + } + + clearSubtitle(fadeMs = 0): void { + this.subtitleClearCalls.push(fadeMs); + } + + clearTimerSticker(input?: TimerClearInput): void { + if (input) this.timerClearCalls.push(input); + } + + clearCharacterCutin(_widgetId?: string): Promise { + return Promise.resolve(); + } + + clearInterludes(): Promise { + return Promise.resolve(); + } + + destroy(): void {} + finishTextTyping(): boolean { + if (!this.typingActive) return false; + this.typingActive = false; + return true; + } + + async mount(): Promise {} + async playVideo(url: string): Promise { + this.videoCalls.push(url); + this.videoStopped = false; + this.videoWaiter = new Promise((resolve) => { + this.resolveVideoWaiter = resolve; + }); + await this.videoWaiter; + } + + async setAnimText(input: AnimTextInput): Promise { + this.animTextCalls.push(input); + } + + async setAvgDisplay(input: AvgDisplayInput): Promise { + this.avgDisplayCalls.push(input); + } + + async setBackground(key: string, input?: BackgroundInput): Promise { + this.backgroundCalls.push({ input, key }); + } + + async setBackgroundTween(input: BackgroundTweenInput): Promise { + this.backgroundTweenCalls.push(input); + } + + async setBlocker(input: BlockerInput): Promise { + this.blockerCalls.push(input); + } + + async setCharacter(input: CharacterSlotInput): Promise { + this.characterCalls.push(input); + } + + async setCharacterCutin(input: CharacterCutinInput): Promise { + this.characterCutinCalls.push(input); + } + + async setCurtain(input: CurtainInput): Promise { + this.curtainCalls.push(input); + } + + async setGridBackground(input: GridBackgroundInput): Promise { + this.gridBackgroundCalls.push(input); + } + + async setLargeImage(input: GridBackgroundInput): Promise { + this.gridBackgroundCalls.push(input); + } + + async runCharacterAction(input: CharacterActionInput): Promise { + this.actionCalls.push(input); + } + + setDialogue(speaker: string, text: string): void { + this.lastDialogue = { speaker, text }; + } + + async setImage(): Promise {} + async setImageRotate(input: ImageRotateInput): Promise { + this.imageRotateCalls.push(input); + } + + async setImageTween(): Promise {} + async setInterlude(input: InterludeInput): Promise { + this.interludeCalls.push(input); + } + + async setLargeBackgroundTween( + input: LargeBackgroundTweenInput, + ): Promise { + this.largeBackgroundTweenCalls.push(input); + } + + async setLargeImageTween(input: LargeBackgroundTweenInput): Promise { + this.largeImageTweenCalls.push(input); + } + + async showItem(input: ShowItemInput): Promise { + this.showItemCalls.push(input); + } + + async showCgItem(input: CgItemInput): Promise { + this.cgItemCalls.push(input); + } + + async setSticker(input: StickerInput): Promise { + this.stickerCalls.push(input); + this.typingActive = input.delayMs > 0; + } + + setSpellSticker(input: SpellStickerInput): void { + this.spellStickerCalls.push(input); + } + + async setSubtitle(input: SubtitleInput): Promise { + this.subtitleCalls.push(input); + this.typingActive = input.delayMs > 0; + } + + async setTimerSticker(input: TimerStickerInput): Promise { + this.timerStickerCalls.push(input); + } + + async shakeCamera(input: CameraShakeInput): Promise { + this.shakeCalls.push(input); + } + + async showDecision(): Promise { + return this.decisionValue; + } + + stopVideo(): void { + this.videoStopped = true; + this.resolveVideoWaiter?.(); + this.resolveVideoWaiter = null; + } + + finishVideo(): void { + this.resolveVideoWaiter?.(); + this.resolveVideoWaiter = null; + } +} + +class FakeAudio implements StoryAudio { + musicVolumeCalls: Array<{ fadeMs: number; volume: number }> = []; + playMusicCalls: PlayMusicInput[] = []; + playSoundCalls: PlaySoundInput[] = []; + soundVolumeCalls: Array<{ channel: string; fadeMs: number; volume: number }> = + []; + stopMusicCalls: number[] = []; + stopSoundCalls: Array<{ channel: string; fadeMs: number }> = []; + + async playMusic(input: PlayMusicInput): Promise { + this.playMusicCalls.push(input); + } + + async playSound(input: PlaySoundInput): Promise { + this.playSoundCalls.push(input); + } + + async setMusicVolume(volume: number, fadeMs: number): Promise { + this.musicVolumeCalls.push({ fadeMs, volume }); + } + + async setSoundVolume( + channel: string, + volume: number, + fadeMs: number, + ): Promise { + this.soundVolumeCalls.push({ channel, fadeMs, volume }); + } + + async stopMusic(fadeMs: number): Promise { + this.stopMusicCalls.push(fadeMs); + } + + async stopSound(channel: string, fadeMs: number): Promise { + this.stopSoundCalls.push({ channel, fadeMs }); + } + + destroy(): void {} +} + +function createContext(script: readonly string[]): Context { + return { + audioVariables: { + m: "sound_beta_2/avg/m", + s: "sound_beta_2/avg/s", + }, + linkMap: { + avg_npc_1: { + array: [{ alias: "", group: -1, image: "avg_npc_1/1$1", name: "1$1" }], + groups: [], + pos: { x: 0, y: 100 }, + size: { x: 100, y: 100 }, + }, + avg_1012_skadisp_1: { + array: [ + { + alias: "", + group: -1, + image: "avg_1012_skadisp_1/avg_1012_skadisp_1", + name: "avg_1012_skadisp_1", + }, + { + alias: "", + group: -1, + image: "avg_1012_skadisp_1/avg_1012_skadisp_2", + name: "avg_1012_skadisp_2", + }, + ], + groups: [], + pos: { x: 0, y: 175 }, + size: { x: 1150, y: 1150 }, + }, + }, + script, + }; +} + +describe("StoryRuntime", () => { + it("maps spellsticker parameters and hides the blocking view before advancing", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[spellsticker(id="spell1",style="SAMI",x=-130,y=0,xScale=1.3,yScale=1.2,angle=5,alpha=2,block=true)]副', + '[name="A"]after', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.spellStickerCalls).toEqual([ + { + alpha: 1, + angle: 5, + content: "副", + id: "spell1", + style: "SAMI", + x: -130, + xScale: 1.3, + y: 0, + yScale: 1.2, + }, + ]); + + await runtime.advance(); + expect(renderer.spellStickerHideCalls).toEqual(["spell1"]); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "after" }); + }); + + it("keeps spellstickerclear independent and honors block", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[spellstickerclear(block=true)]", '[name="A"]after']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + expect(renderer.spellStickerClearCount).toBe(1); + expect(renderer.stickersClearCalls).toEqual([]); + expect(runtime.getState()).toBe("waiting_input"); + await runtime.advance(); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "after" }); + }); + + it("clears spellstickers when skipping the story", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[spellsticker(id="s",block=true)]x', + '[skipnode(mode="skip")]', + ]), + renderer, + new FakeAudio(), + ); + await runtime.start(); + await runtime.skipNode(); + expect(renderer.spellStickerClearCount).toBe(1); + expect(renderer.spellStickerHideCalls).toEqual([]); + }); + + it("maps animtext prefab parameters and content without honoring ghost parameters", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[animtext(id="at1",name="group_location_stamp",style="avg_both",pos="-400,-200",block=false,duration=9,type="effect",clear=true)]地点时间', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.animTextCalls).toEqual([ + { + block: false, + content: "地点时间", + id: "at1", + name: "group_location_stamp", + position: { x: -400, y: -200 }, + style: "avg_both", + }, + ]); + }); + + it("maps avgdisplay lifecycle and strict feature parameters", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[avgdisplay(id="1",name="bg_black",style="bg",slot="bgover",layer=2,afrom=0,ato=0.6,duration=2,isblock=true)]', + '[avgdisplay(id="2",name="act3mainss_01",style="animekv",slot="cgover",x=-200,y=80,scalex=1.2,scaley=1.2,entryfrom=0,entryto=0.2,duration=5,block=true)]', + '[avgdisplay(id="1")]', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.avgDisplayCalls).toEqual([ + expect.objectContaining({ + alphaFrom: 0, + alphaTo: 0.6, + block: false, + durationMs: 2000, + id: "1", + layer: 2, + name: "bg_black", + slot: "bgover", + style: "bg", + }), + expect.objectContaining({ + block: true, + durationMs: 5000, + entryFrom: 0, + entryTo: 0.2, + id: "2", + name: "act3mainss_01", + scaleX: 1.2, + scaleY: 1.2, + slot: "cgover", + style: "animekv", + x: -200, + y: 80, + }), + expect.objectContaining({ + block: false, + id: "1", + name: "", + }), + ]); + }); + + it("enters waiting_input on first dialogue", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[dialog]", '[name="A"]hello']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "hello" }); + }); + + it("runs delay with provided sleep handler", async () => { + const sleep = vi.fn(async () => {}); + const runtime = new StoryRuntime( + createContext(["[Delay(time=1)]", '[name="A"]done']), + new FakeRenderer(), + new FakeAudio(), + { sleep }, + ); + + await runtime.start(); + + expect(sleep).toHaveBeenCalledWith(1000); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("scales delay once by animateRatio and yields a frame for zero", async () => { + const sleep = vi.fn(async () => {}); + const runtime = new StoryRuntime( + createContext(["[delay(time=2)]", "[delay(time=0)]", '[name="A"]done']), + new FakeRenderer(), + new FakeAudio(), + { animateRatio: 0.25, sleep }, + ); + + await runtime.start(); + + expect(sleep).toHaveBeenNthCalledWith(1, 500); + expect(sleep).toHaveBeenNthCalledWith(2, 0); + }); + + it("uses value predicates without treating them as label jumps", async () => { + const renderer = new FakeRenderer(); + renderer.decisionValue = 2; + const runtime = new StoryRuntime( + createContext([ + '[decision(options="left;right",values="1;2")]', + '[predicate(references="1")]', + '[showitem(image="left")]', + '[predicate(references="2")]', + '[showitem(image="right")]', + '[predicate(references="1;2")]', + '[name="A"]merged', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.showItemCalls.map((call) => call.key)).toEqual(["right"]); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "merged" }); + }); + + it("uses native defaults for bare theater and restores manual input on exit", async () => { + vi.useFakeTimers(); + try { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + "[theater]", + '[name="A"]auto', + "[theater(mode=false)]", + '[name="B"]manual', + ]), + renderer, + new FakeAudio(), + ); + + const start = runtime.start(); + await vi.advanceTimersByTimeAsync(1820); + await start; + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "manual" }); + } finally { + vi.useRealTimers(); + } + }); + + it("auto-advances after the native button-auto delay and stops at endtip", async () => { + vi.useFakeTimers(); + try { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[name="A"]hi', '[name="B"]next']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + runtime.setAutoPlayMode("button_auto"); + expect(runtime.getAutoPlayState().mode).toBe("button_auto"); + + await vi.advanceTimersByTimeAsync(1560); + await vi.advanceTimersByTimeAsync(200); + expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "next" }); + + await vi.advanceTimersByTimeAsync(1620); + expect(runtime.getAutoPlayState().mode).toBe("default"); + expect(runtime.getState()).toBe("waiting_input"); + } finally { + vi.useRealTimers(); + } + }); + + it("uses the selected auto speed and manual input disables auto mode", async () => { + vi.useFakeTimers(); + try { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[name="A"]abcd', '[name="B"]next']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + runtime.setAutoPlayMode("button_auto"); + runtime.setAutoPlaySpeedLevel(1); + expect(runtime.getAutoPlayState().buttonSpeedLevel).toBe(1); + + await vi.advanceTimersByTimeAsync(559); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "abcd" }); + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(50); + expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "next" }); + + await runtime.advance(); + expect(runtime.getAutoPlayState().mode).toBe("default"); + } finally { + vi.useRealTimers(); + } + }); + + it("quick mode emits an immediate click and uses its own speed level", async () => { + vi.useFakeTimers(); + try { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[name="A"]first', '[name="B"]second']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + runtime.setAutoPlayMode("quick_play"); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(70); + expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "second" }); + + runtime.setAutoPlaySpeedLevel(3); + expect(runtime.getAutoPlayState()).toMatchObject({ + mode: "quick_play", + quickSpeedLevel: 3, + }); + await vi.advanceTimersByTimeAsync(25); + expect(runtime.getAutoPlayState().mode).toBe("default"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not run normal commands for video-only story metadata", async () => { + const renderer = new FakeRenderer(); + const context = createContext(['[video(res="video/entry.mp4")]']); + context.storyMetadata = { + args: { is_video_only: true }, + characterSortType: "BY_GAIN_TIME_DOWN", + denyAutoSwitchScene: false, + dontClearGameObjectPoolOnStart: false, + fitMode: "DEFAULT", + id: "", + isAutoable: false, + isSkippable: true, + isTutorial: false, + isVideoOnly: true, + title: "", + }; + const runtime = new StoryRuntime(context, renderer, new FakeAudio()); + + await runtime.start(); + + expect(runtime.getState()).toBe("finished"); + expect(renderer.videoCalls).toEqual([]); + }); + + it("rejects showitem styles the panel does not register", async () => { + const renderer = new FakeRenderer(); + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext(['[ShowItem(image="item_a",style="cg")]', '[name="A"]ok']), + renderer, + new FakeAudio(), + { onWarning: (warning) => warnings.push(warning) }, + ); + + await runtime.start(); + + // _slotStyles has only photo and cutin, so `cg` misses _FindSlotStyle and + // the command finishes without rendering anything. + expect(renderer.showItemCalls).toEqual([]); + expect(warnings).toEqual([ + expect.objectContaining({ + command: "showitem", + detail: "showitem style is not registered: cg", + type: "invalid_parameter", + }), + ]); + }); + + it("treats a bracketed key=value tag as an empty dialog line", async () => { + const renderer = new FakeRenderer(); + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext(["[Delay=2]", '[name="A"]ok']), + renderer, + new FakeAudio(), + { onWarning: (warning) => warnings.push(warning) }, + ); + + await runtime.start(); + + // `[Delay=2]` matches the parser's group-4 fallback, so it becomes a dialog + // command with param {Delay: 2} and no content -- which hides the box and + // returns false. It is not the delay command and it does not pause. + expect(warnings).toEqual([]); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "ok" }); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("skips endtip entirely in manual mode", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[name="A"]ok', "[endtip]tip"]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + await runtime.advance(); + + // shouldProcessEndtip is false unless button-auto or quick-play is running, + // and a click drops back to manual mode anyway, so the executor returns + // without touching the dialog box and the story ends. + expect(runtime.getState()).toBe("finished"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "ok" }); + }); + + it("plays a video whose res carries no .mp4 extension", async () => { + const renderer = new FakeRenderer(); + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext(['[video(res="video/act15side/IW01")]', '[name="A"]after']), + renderer, + new FakeAudio(), + { onWarning: (warning) => warnings.push(warning) }, + ); + + const startPromise = runtime.start(); + await Promise.resolve(); + + // IsMp4VideoPath has no call sites; the extension gates nothing. + expect(renderer.videoCalls).toEqual([ + "https://torappu.prts.wiki/assets/video/act15side/iw01", + ]); + expect(warnings).toEqual([]); + + renderer.finishVideo(); + await startPromise; + }); + + it("prefers url over res and warns when neither is given", async () => { + const renderer = new FakeRenderer(); + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext(["[video]", '[name="A"]after']), + renderer, + new FakeAudio(), + { onWarning: (warning) => warnings.push(warning) }, + ); + + await runtime.start(); + + expect(renderer.videoCalls).toEqual([]); + expect(warnings).toEqual([ + expect.objectContaining({ + detail: "video: no url or res", + type: "invalid_parameter", + }), + ]); + }); + + it("warns and skips unsupported command", async () => { + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext(['[bgeffect(name="$eb_blackmask")]', '[name="A"]ok']), + new FakeRenderer(), + new FakeAudio(), + { + onWarning: (warning) => warnings.push(warning), + }, + ); + + await runtime.start(); + + expect(runtime.getState()).toBe("waiting_input"); + expect(warnings.some((item) => item.type === "unsupported_command")).toBe( + true, + ); + }); + + it("blocks on video command until playback completes", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[video(res="video/act15side/IW01.mp4")]', + '[name="A"]after', + ]), + renderer, + new FakeAudio(), + ); + + const startPromise = runtime.start(); + + await Promise.resolve(); + + expect(runtime.getState()).toBe("waiting_video"); + expect(renderer.videoCalls).toEqual([ + "https://torappu.prts.wiki/assets/video/act15side/iw01.mp4", + ]); + + renderer.finishVideo(); + await startPromise; + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "after" }); + }); + + it("nofirstskip handles first-read skip by jumping to the next protected anchor", async () => { + const sleep = vi.fn(() => new Promise(() => {})); + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + "[delay(time=30)]", + '[skipnode(mode="nofirstskip")]', + '[showitem(image="avg_npc_1",x=10,y=20)]', + '[skipnode(mode="skip")]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + { sleep }, + ); + + const startPromise = runtime.start(); + + await Promise.resolve(); + + expect(runtime.getState()).toBe("waiting_timer"); + expect(runtime.canSkipNode()).toBe(true); + expect(sleep).toHaveBeenCalledWith(30_000); + + await runtime.skipNode(); + await startPromise; + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "ok" }); + }); + + it("does not lose the active process loop while skip cleanup is asynchronous", async () => { + let finishCleanup!: () => void; + const cleanup = new Promise((resolve) => { + finishCleanup = resolve; + }); + const sleep = vi.fn(() => new Promise(() => {})); + const renderer = new FakeRenderer(); + vi.spyOn(renderer, "clearInterludes").mockReturnValue(cleanup); + const runtime = new StoryRuntime( + createContext([ + "[delay(time=30)]", + '[skipnode(mode="nofirstskip")]', + '[name="A"]after', + ]), + renderer, + new FakeAudio(), + { sleep }, + ); + + const startPromise = runtime.start(); + await Promise.resolve(); + const skipPromise = runtime.skipNode(); + await Promise.resolve(); + + expect(runtime.getState()).toBe("waiting_timer"); + finishCleanup(); + await skipPromise; + await startPromise; + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "after" }); + }); + + it("nofirstskip permits skip after the story has been read", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[skipnode(mode="nofirstskip")]', + '[video(res="video/02.mp4")]', + '[skipnode(mode="skip")]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + { firstRead: false }, + ); + + const startPromise = runtime.start(); + + await Promise.resolve(); + + expect(runtime.getState()).toBe("waiting_video"); + expect(runtime.canSkipNode()).toBe(true); + + await runtime.skipNode(); + await startPromise; + + expect(renderer.videoStopped).toBe(true); + expect(runtime.getState()).toBe("finished"); + expect(runtime.canSkipNode()).toBe(false); + }); + + it("maps unrecognized skipnode modes to can-skip like native CalSkipMode", async () => { + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext(['[skipnode(mode="later")]', '[name="A"]ok']), + new FakeRenderer(), + new FakeAudio(), + { + onWarning: (warning) => warnings.push(warning), + }, + ); + + await runtime.start(); + + expect(warnings).toEqual([]); + expect(runtime.canSkipNode()).toBe(true); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("supports multiline command as dialogue wait", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[multiline(name="A")]line1']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "line1" }); + }); + + it("exposes the displayed line index and decision selection for logAll highlighting", async () => { + const renderer = new FakeRenderer(); + renderer.decisionValue = 2; + const runtime = new StoryRuntime( + createContext([ + '[name="A"]第一句', // line 1 → 显示中 + '[name=""]', // line 2 空对白,不更新显示行 + '[decision(options="A;B", values="1;2")]', // line 3 + '[predicate(references="1")]', // line 4 + '[name="A路"]', // line 5 (被 decisionSelectValue=2 过滤掉) + '[predicate(references="2")]', // line 6 + '[multiline(name="B")]合并', // line 7 → 显示中 + ]), + renderer, + new FakeAudio(), + ); + + // 初始无显示 + expect(runtime.getDisplayedLineIndex()).toBeNull(); + expect(runtime.getDecisionSelectValue()).toBe(0); + + // 停在第一句对白 + await runtime.start(); + expect(runtime.getState()).toBe("waiting_input"); + expect(runtime.getDisplayedLineIndex()).toBe(1); + + // 推进过空对白 + decision(玩家选 2)+ predicate 过滤,停在 multiline + await runtime.advance(); + expect(runtime.getState()).toBe("waiting_input"); + expect(runtime.getDecisionSelectValue()).toBe(2); + expect(runtime.getDisplayedLineIndex()).toBe(7); + expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "合并" }); + }); + + it("shows and hides story items with legacy defaults", async () => { + const renderer = new FakeRenderer(); + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext([ + '[showitem(image="item_act12side_1")]', + "[hideitem]", + '[name="Tips"]ok', + ]), + renderer, + new FakeAudio(), + { + onWarning: (warning) => warnings.push(warning), + }, + ); + + await runtime.start(); + + // The photo slot's serialized defaults: _defaultFadeTime 0.5 and + // _defaultBlackAlpha 0.4, with offsetx/offsety hardcoded to 0. + expect(renderer.showItemCalls).toEqual([ + { + blackAlpha: 0.4, + block: true, + fadeMs: 500, + key: "item_act12side_1", + offsetX: 0, + offsetY: 0, + }, + ]); + // hideitem does not read `block` either: it blocks whenever a slot was in + // use, which it is here because the preceding showitem filled it. + expect(renderer.clearItemsCalls).toEqual([{ block: true, fadeMs: 500 }]); + expect(warnings).toEqual([]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("places a single character in the middle slot without ever blocking", async () => { + const renderer = new FakeRenderer(); + const sleep = vi.fn(async () => {}); + const runtime = new StoryRuntime( + createContext([ + '[character(name="avg_npc_1",block=true,fadetime=0.2,enter="left",blackstart=0.2,blackend=0.8)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + { sleep }, + ); + + await runtime.start(); + + expect(renderer.clearedSlots).toEqual([ + { fadeMs: 200, slot: "l" }, + { fadeMs: 200, slot: "r" }, + ]); + // `block` is not a key _ExecuteCharacter reads (it reads `isblock`), and the + // executor never registers a completion callback, so the command never waits. + expect(renderer.characterCalls).toEqual([ + { + absolutePosition: { x: undefined, y: undefined }, + blackEnd: 0.8, + blackStart: 0.2, + block: false, + characterKey: "avg_npc_1", + dimmed: false, + durationMs: 200, + enterFrom: "left", + expression: "1$1", + slot: "m", + }, + ]); + expect(sleep).not.toHaveBeenCalledWith(200); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps dual character layout and focus", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[character(name="avg_npc_1",name2="avg_npc_1#1",focus=1)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.clearedSlots).toEqual([{ fadeMs: 150, slot: "m" }]); + expect(renderer.characterCalls).toEqual([ + { + absolutePosition: { x: undefined, y: undefined }, + blackEnd: Number.NaN, + blackStart: Number.NaN, + block: false, + characterKey: "avg_npc_1", + dimmed: false, + durationMs: 150, + enterFrom: undefined, + expression: "1$1", + slot: "l", + }, + { + absolutePosition: { x: undefined, y: undefined }, + blackEnd: Number.NaN, + blackStart: Number.NaN, + block: false, + characterKey: "avg_npc_1", + dimmed: true, + durationMs: 150, + enterFrom: undefined, + expression: "1$1", + slot: "r", + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("keeps name2 in the right slot when the primary name is empty", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[character(name2="avg_npc_1#1")]', '[name="A"]ok']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.clearedSlots).toEqual([ + { fadeMs: 150, slot: "l" }, + { fadeMs: 150, slot: "m" }, + ]); + expect(renderer.characterCalls).toEqual([ + expect.objectContaining({ + characterKey: "avg_npc_1", + expression: "1$1", + slot: "r", + }), + ]); + }); + + it("uses exact character parameter keys and explicit positions", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[character(name="avg_npc_1",Name2="avg_npc_1",xpos1=123,ypos1=456)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.characterCalls).toHaveLength(1); + expect(renderer.characterCalls[0]).toMatchObject({ + absolutePosition: { x: 123, y: 456 }, + slot: "m", + }); + expect(renderer.clearedSlots).toEqual([ + { fadeMs: 150, slot: "l" }, + { fadeMs: 150, slot: "r" }, + ]); + }); + + it("requires the case-sensitive charactercutin widgetID key", async () => { + const renderer = new FakeRenderer(); + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext([ + '[charactercutin(widgetid="wrong",name="avg_npc_1")]', + '[charactercutin(widgetID="right",name="avg_npc_1",fadestyle="fade")]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + { onWarning: (warning) => warnings.push(warning) }, + ); + + await runtime.start(); + + expect(renderer.characterCutinCalls).toHaveLength(1); + expect(renderer.characterCutinCalls[0]).toMatchObject({ + fadeStyle: "fade", + widgetId: "right", + }); + expect(warnings).toEqual([ + expect.objectContaining({ + detail: "charactercutin widgetID is empty", + type: "parse", + }), + ]); + }); + + it("maps interlude parameters and preserves the native channel default mismatch", async () => { + const renderer = new FakeRenderer(); + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext([ + '[interlude(channel=3,type=2,slot="m",name="bg_test",maskid="square",size="290,320",offset="10,-20",pfrom="1,2",pto="3,4",duration=2,sfrom="1,1",sto="2,2",sduration=1,afrom=0.2,ato=0.8,aduration=0.5,switch=true,block=true)]', + "[interlude(clear=true)]", + "[interlude(channel=-1,clear=true)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + { animateRatio: 0.5, onWarning: (warning) => warnings.push(warning) }, + ); + + await runtime.start(); + + expect(renderer.interludeCalls).toHaveLength(2); + expect(renderer.interludeCalls[0]).toMatchObject({ + alphaDurationMs: 250, + alphaFrom: 0.2, + alphaTo: 0.8, + block: true, + channel: 3, + durationMs: 1000, + maskId: "square", + offset: { x: 10, y: -20 }, + positionFrom: { x: 1, y: 2 }, + positionTo: { x: 3, y: 4 }, + scaleDurationMs: 500, + scaleFrom: { x: 1, y: 1 }, + scaleTo: { x: 2, y: 2 }, + size: { x: 290, y: 320 }, + slot: "m", + switchOn: true, + type: 2, + }); + expect(renderer.interludeCalls[1]).toMatchObject({ + channel: -1, + clear: true, + }); + expect(warnings).toEqual([ + expect.objectContaining({ + detail: "interlude channel is invalid: -1", + type: "invalid_parameter", + }), + ]); + }); + + it("resolves uppercase character keys like legacy runtime", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[charslot(slot="m",name="avg_1012_skadiSP_1#2")]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.characterCalls).toEqual([ + { + action: undefined, + alphaFrom: undefined, + alphaTo: undefined, + blackEnd: undefined, + blackStart: undefined, + block: false, + characterKey: "avg_1012_skadisp_1", + // charslot's `duration` defaults to 0.0, not to DEFAULT_FADE_TIME. + durationMs: 0, + expression: "avg_1012_skadisp_2", + focusMode: "current_only", + focusSlots: undefined, + positionFrom: undefined, + positionTo: undefined, + posZoom: undefined, + power: 0, + preserveTransform: false, + randomness: 90, + replaceFadeMs: 0, + resetTransform: true, + scaleX: undefined, + scaleY: undefined, + slot: "m", + stop: false, + times: 1, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps charslot slot aliases and duration blocking like legacy runtime", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[charslot(slot="left",name="avg_npc_1",duration=0.4,isblock=true)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.characterCalls[0]).toMatchObject({ + block: true, + durationMs: 400, + focusMode: "current_only", + slot: "l", + }); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("keeps charslot updates without name on the existing slot", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[charslot(slot="m",name="avg_npc_1")]', + '[charslot(slot="middle",focus="none",posto="10,20",duration=0.2)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.clearedSlots).toEqual([]); + expect(renderer.characterCalls).toHaveLength(2); + expect(renderer.characterCalls[1]).toMatchObject({ + characterKey: undefined, + focusMode: "none", + positionTo: { x: 10, y: 20 }, + slot: "m", + }); + }); + + it("clears all characters with charslot duration when slot is omitted", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[charslot(duration=0.5,isblock=true)]", '[name="A"]ok']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.clearedSlots).toEqual([{ fadeMs: 500, slot: undefined }]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("does not block on full-scene charslot clear unless explicitly requested", async () => { + let resolveClear: (() => void) | undefined; + const renderer = new FakeRenderer(); + renderer.clearCharactersHandler = () => + new Promise((resolve) => { + resolveClear = resolve; + }); + const runtime = new StoryRuntime( + createContext(["[charslot(duration=0.5)]", '[name="A"]ok']), + renderer, + new FakeAudio(), + ); + + const startPromise = runtime.start(); + + await Promise.resolve(); + + expect(runtime.getState()).toBe("waiting_input"); + + resolveClear?.(); + await startPromise; + }); + + it("maps camerashake to native defaults and parameters", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + "[camerashake(xstrength=12,ystrength=8)]", + "[camerashake(duration=0.5,randomness=40,vibrato=18,block=false,stop=true)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.shakeCalls).toEqual([ + { + block: false, + durationMs: 10_000, + fadeOut: false, + infinite: true, + randomness: 90, + stop: false, + vibrato: 10, + xStrength: 12, + yStrength: 8, + }, + { + block: false, + durationMs: 500, + fadeOut: false, + infinite: false, + randomness: 40, + stop: true, + vibrato: 18, + xStrength: 1, + yStrength: 0, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps blocker RGBA endpoints, styles, and native zero-duration blocking", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[blocker(a=1,r=255,g=128,b=0,afrom=0,rfrom=0,gfrom=0,bfrom=0,style=slider,inverse=true,fadetime=0,block=true,image="mask")]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.blockerCalls).toEqual([ + { + block: false, + fadeMs: 0, + from: { a: 0, b: 0, g: 0, r: 0 }, + image: "mask", + inverse: true, + style: "slider", + to: { a: 1, b: 0, g: 128, r: 255 }, + }, + ]); + }); + + it("keeps cameraeffect values case-sensitive and degrades Chaos structurally", async () => { + const renderer = new FakeRenderer(); + const warnings: RuntimeWarning[] = []; + const runtime = new StoryRuntime( + createContext([ + "[cameraeffect(effect=Grayscale,initamount=0.2,amount=0.8,fadetime=0.5,block=true,keep=true)]", + "[cameraeffect(effect=Colorinverse,keep=true,block=true)]", + "[cameraeffect(effect=Chaos,keep=true)]", + "[cameraeffect(effect=grayscale,amount=1)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + { onWarning: (warning) => warnings.push(warning) }, + ); + + await runtime.start(); + + expect(renderer.cameraEffectCalls).toEqual([ + { + amount: 0.8, + block: true, + durationMs: 500, + effect: "Grayscale", + initialAmount: 0.2, + keep: true, + }, + { + amount: 1, + block: false, + durationMs: 0, + effect: "Colorinverse", + initialAmount: undefined, + keep: true, + }, + ]); + expect(warnings).toContainEqual( + expect.objectContaining({ + detail: "cameraeffect:Chaos", + type: "unsupported_visual", + }), + ); + }); + + it("maps focusout duration, from and native blocking semantics without reading fadetime", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[focusout(duration=1.5,type="bg",from=0.25,to=1,block=true)]', + '[focusout(type="cgitem",id="cgitem_61_i02",from=-1,to=0.5,block=true,fadetime=9)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.focusOutCalls).toEqual([ + { block: true, durationMs: 1500, from: 0.25, id: "", to: 1, type: "bg" }, + { + block: false, + durationMs: 0, + from: undefined, + id: "cgitem_61_i02", + to: 0.5, + type: "cgitem", + }, + ]); + }); + + it("maps focusparam defaults and keeps effect values case-sensitive", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[focusparam(effect="Grayscale",blur=false)]', + '[focusparam(effect="Colorinverse")]', + '[focusparam(effect="grayscale",blur=true)]', + "[focusparam]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.focusParamCalls).toEqual([ + { blur: false, color: "Grayscale" }, + { blur: true, color: "Colorinverse" }, + { blur: true, color: "None" }, + { blur: true, color: "None" }, + ]); + }); + + it("maps curtain commands with native alpha and block args", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + "[curtain(direction=0,fillfrom=0.01,fillto=0.2,fadetime=1.5,isblock=true)]", + "[curtain(direction=4,fillto=0.08,a=0.5,fadetime=0.25,block=true)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.curtainCalls).toEqual([ + { + alphaFrom: undefined, + alphaTo: undefined, + block: false, + delayMs: 0, + direction: 0, + fadeMs: 1500, + fillFrom: 0.01, + fillTo: 0.2, + grad: false, + }, + { + alphaFrom: undefined, + alphaTo: 0.5, + block: true, + delayMs: 0, + direction: 4, + fadeMs: 250, + fillFrom: 1, + fillTo: 0.08, + grad: false, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("ignores the non-native curtain ato key", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + "[curtain(direction=4,fillfrom=0.18,fillto=0.18,afrom=0,ato=1,fadetime=0.1,block=true)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.curtainCalls).toEqual([ + { + alphaFrom: 0, + alphaTo: undefined, + block: true, + delayMs: 0, + direction: 4, + fadeMs: 100, + fillFrom: 0.18, + fillTo: 0.18, + grad: false, + }, + ]); + }); + + it("clears all curtains when direction is omitted", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[curtain(fadetime=0.3,block=true)]", '[name="A"]ok']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + // _HideAllCurtains returns a literal false from every exit, so the clear path + // never blocks even when the script asks for it. + expect(renderer.curtainClearCalls).toEqual([ + { + block: false, + fadeMs: 300, + }, + ]); + expect(renderer.curtainCalls).toEqual([]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps gridbg into a tiled background layer", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[gridbg(imagegroup="47_g14_skyovercast_L1/47_g14_skyovercast_R1/47_g14_skyovercast_L2/47_g14_skyovercast_R2",solidwidth="1280/1280",solidheight="720/720",x=-640,y=320,xScale=0.5,yScale=0.75,fadetime=1)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([ + { + assetKind: "background", + block: false, + fadeMs: 1000, + imageKeys: [ + "47_g14_skyovercast_l1", + "47_g14_skyovercast_r1", + "47_g14_skyovercast_l2", + "47_g14_skyovercast_r2", + ], + layout: "grid", + scaleX: 0.5, + scaleY: 0.75, + solidHeights: [720, 720], + solidWidths: [1280, 1280], + x: -640, + y: 320, + }, + ]); + expect(renderer.gridBackgroundClearCalls).toEqual([]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("clears gridbg independently and supports legacy blok typo", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[gridbg(fadetime=2,block=true)]", '[name="A"]ok']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([]); + expect(renderer.gridBackgroundClearCalls).toEqual([ + { + block: true, + fadeMs: 2000, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps verticalbg into a vertically tiled composed background layer", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[verticalbg(imagegroup="66_i15_4/66_i15_3/66_i15_2/66_i15_1",solidwidth=1280,solidheight="720/720/720/625",y=540,xScale=0.9,yScale=0.9,fadetime=1)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([ + { + assetKind: "background", + block: false, + fadeMs: 1000, + imageKeys: ["66_i15_4", "66_i15_3", "66_i15_2", "66_i15_1"], + layout: "vertical", + scaleX: 0.9, + scaleY: 0.9, + solidHeights: [720, 720, 720, 625], + solidWidths: [1280], + x: 0, + y: 540, + }, + ]); + expect(renderer.gridBackgroundClearCalls).toEqual([]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("supports verticalbg cggroup assets and comma-separated solid heights", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[verticalbg(cggroup="69_i12_1/69_i12_2",solidwidth="1600",solidheight="1,454/1,454",y=200,fadetime=0)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([ + { + assetKind: "image", + block: false, + fadeMs: 0, + imageKeys: ["69_i12_1", "69_i12_2"], + layout: "vertical", + scaleX: 1, + scaleY: 1, + solidHeights: [1454, 1454], + solidWidths: [1600], + x: 0, + y: 200, + }, + ]); + }); + + it("prefers imagegroup assets when verticalbg also includes cggroup", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[verticalbg(imagegroup="47_g14_skyovercast_L1/47_g14_skyovercast_R1",cggroup="69_i12_1/69_i12_2",solidwidth=1280,solidheight="720/720",fadetime=0)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([ + { + assetKind: "background", + block: false, + fadeMs: 0, + imageKeys: ["47_g14_skyovercast_l1", "47_g14_skyovercast_r1"], + layout: "vertical", + scaleX: 1, + scaleY: 1, + solidHeights: [720, 720], + solidWidths: [1280], + x: 0, + y: 0, + }, + ]); + }); + + it("clears verticalbg with fade parameters when no image group is provided", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[verticalbg(fadetime=3,block=true)]", '[name="A"]ok']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([]); + expect(renderer.gridBackgroundClearCalls).toEqual([ + { + block: true, + fadeMs: 3000, + }, + ]); + }); + + it("maps largebg into a legacy large tiled background layer", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[largebg(imagegroup="bg_beach_1/bg_beach_2",solidwidth="920/920",solidheight="720",x=-180,fadetime=1)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([ + { + assetKind: "background", + block: false, + fadeMs: 1000, + imageKeys: ["bg_beach_1", "bg_beach_2"], + initPositionMode: "default", + layout: "large", + scaleX: 1, + scaleY: 1, + solidHeights: [720], + solidWidths: [920, 920], + x: -180, + y: 0, + }, + ]); + expect(renderer.gridBackgroundClearCalls).toEqual([]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("supports largebg cggroup assets and clear commands", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[largebg(cggroup="61_i12/61_i11",solidwidth="1600/1600",solidheight=900,x=-160,yScale=0.8,fadetime=0)]', + "[largebg(fadetime=0.2,block=true)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([ + { + assetKind: "image", + block: false, + fadeMs: 0, + imageKeys: ["61_i12", "61_i11"], + initPositionMode: "default", + layout: "large", + scaleX: 1, + scaleY: 0.8, + solidHeights: [900], + solidWidths: [1600, 1600], + x: -160, + y: 0, + }, + ]); + expect(renderer.gridBackgroundClearCalls).toEqual([ + { + block: true, + fadeMs: 200, + }, + ]); + }); + + it("maps largeimg into a legacy large tiled image layer", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[largeimg(imagegroup="61_i12/61_i11",solidwidth="1600/1600",solidheight="900",x=-160,yscale=0.8,fadetime=0)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([ + { + assetKind: "image", + block: false, + fadeMs: 0, + imageKeys: ["61_i12", "61_i11"], + layout: "large", + scaleX: 1, + scaleY: 0.8, + solidHeights: [900], + solidWidths: [1600, 1600], + x: -160, + y: 0, + }, + ]); + expect(renderer.gridBackgroundClearCalls).toEqual([]); + }); + + it("clears largeimg with fade parameters when no image group is provided", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[largeimg(fadetime=0.2,blok=true)]", '[name="A"]ok']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.gridBackgroundCalls).toEqual([]); + expect(renderer.gridBackgroundClearCalls).toEqual([ + { + block: true, + fadeMs: 200, + }, + ]); + }); + + it("maps characteraction move with legacy slot aliases and block args", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[characteraction(name="left",type="move",xpos=-200,ypos=60,fadetime=0.1,isblock=true)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.actionCalls).toEqual([ + { + block: true, + direction: undefined, + durationMs: 100, + power: 0, + randomness: 90, + rotationFromDeg: 0, + rotationLeftDeg: -15, + rotationRightDeg: 15, + scaleX: undefined, + scaleY: undefined, + slot: "l", + stop: false, + times: 1, + type: "move", + xOffset: -200, + yOffset: 60, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("uses the five strict characteraction branches and native slot fallbacks", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[characteraction(name="middle",type="rotate",duration=0.5,start=3,leftend=20,rightend=10,times=-1,stop=false)]', + '[characteraction(name="char_right",type="zoom",scale=1.2,yscale=0.8,block=true)]', + '[characteraction(name="r",type="exit",direction="left",block=false)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.actionCalls).toEqual([ + { + block: true, + direction: undefined, + durationMs: 500, + power: 0, + randomness: 90, + rotationFromDeg: 0, + rotationLeftDeg: -15, + rotationRightDeg: 15, + scaleX: 1.2, + scaleY: 0.8, + slot: "l", + stop: false, + times: 1, + type: "zoom", + xOffset: 0, + yOffset: 0, + }, + { + block: false, + direction: "left", + durationMs: 500, + power: 0, + randomness: 90, + rotationFromDeg: 0, + rotationLeftDeg: -15, + rotationRightDeg: 15, + scaleX: undefined, + scaleY: undefined, + slot: "m", + stop: false, + times: 1, + type: "exit", + xOffset: 0, + yOffset: 0, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps imagerotate strict parameters", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + "[imagerotate(angle=-5,fadetime=0.1,block=true)]", + '[imagerotate(angle=0,fadetime=10,isblock=false,image="70_i11")]', + "[imagerotate]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.imageRotateCalls).toEqual([ + { + angleDeg: -5, + block: true, + circles: 0, + durationMs: 100, + inverse: false, + }, + { + angleDeg: 0, + block: false, + circles: 0, + durationMs: 10_000, + inverse: false, + }, + { + angleDeg: 0, + block: false, + circles: 0, + durationMs: 0, + inverse: false, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps background with strict transform keys and ignores width/height", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[background(image="beach_1",x=24,y=-36,xScale=1.7,height=1.4)]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.backgroundCalls).toEqual([ + { + input: { + scaleX: 1.7, + scaleY: 1, + block: false, + fadeMs: 0, + screenAdapt: undefined, + tiled: false, + x: 24, + y: -36, + }, + key: "beach_1", + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps backgroundtween with strict CamelCase keys", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[background(image="beach_1",x=24,y=-36,xScale=1.7,height=1.4)]', + "[backgroundtween(xFrom=0,xTo=-720,duration=25,block=false)]", + "[backgroundtween(duration=0.5,xScaleFrom=0.75,xScaleTo=0.8,yScaleFrom=0.75,yScaleTo=0.8)]", + "[backgroundtween(y=360)]", + "[backgroundtween(y=180,block=false)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.backgroundTweenCalls).toEqual([ + { + block: false, + durationMs: 25_000, + xFrom: 0, + xScaleFrom: undefined, + xScaleTo: undefined, + xTo: -720, + yFrom: undefined, + yScaleFrom: undefined, + yScaleTo: undefined, + yTo: undefined, + }, + { + block: false, + durationMs: 500, + xFrom: undefined, + xScaleFrom: 0.75, + xScaleTo: 0.8, + xTo: undefined, + yFrom: undefined, + yScaleFrom: 0.75, + yScaleTo: 0.8, + yTo: undefined, + }, + // `duration` defaults to 0.0, and <= 0 completes both tweens immediately. + { + block: false, + durationMs: 0, + xFrom: undefined, + xScaleFrom: undefined, + xScaleTo: undefined, + xTo: undefined, + yFrom: undefined, + yScaleFrom: undefined, + yScaleTo: undefined, + yTo: undefined, + }, + { + block: false, + durationMs: 0, + xFrom: undefined, + xScaleFrom: undefined, + xScaleTo: undefined, + xTo: undefined, + yFrom: undefined, + yScaleFrom: undefined, + yScaleTo: undefined, + yTo: undefined, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps largebgtween with strict CamelCase keys", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[largebg(imagegroup="bg_beach_1/bg_beach_2",solidwidth="920/920",solidheight="720",x=-180,fadetime=0)]', + '[largebgtween(xFrom=0,xTo=-720,duration=25,ease="1",block=false)]', + "[largebgtween(duration=0.5,xScaleFrom=0.75,xScaleTo=0.8,yScaleFrom=0.75,yScaleTo=0.8)]", + "[largebgtween(y=360,block=true)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.largeBackgroundTweenCalls).toEqual([ + { + block: false, + durationMs: 25_000, + xFrom: 0, + xScaleFrom: undefined, + xScaleTo: undefined, + xTo: -720, + yFrom: undefined, + yScaleFrom: undefined, + yScaleTo: undefined, + yTo: undefined, + }, + { + block: false, + durationMs: 500, + xFrom: undefined, + xScaleFrom: 0.75, + xScaleTo: 0.8, + xTo: undefined, + yFrom: undefined, + yScaleFrom: 0.75, + yScaleTo: 0.8, + yTo: undefined, + }, + { + block: false, + durationMs: 0, + xFrom: undefined, + xScaleFrom: undefined, + xScaleTo: undefined, + xTo: undefined, + yFrom: undefined, + yScaleFrom: undefined, + yScaleTo: undefined, + yTo: undefined, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps largeimgtween with legacy aliases and current-transform fallbacks", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[largeimg(imagegroup="61_i12/61_i11",solidwidth="1600/1600",solidheight="900",x=-160,yscale=0.8,fadetime=0)]', + "[largeimgtween(xFrom=0,xTo=-720,duration=25,block=false)]", + "[largeimgtween(duration=0.5,xScaleFrom=0.75,xScaleTo=0.8,yScaleFrom=0.75,yScaleTo=0.8)]", + "[largeimgtween(y=360,block=true)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.largeImageTweenCalls).toEqual([ + { + block: false, + durationMs: 25_000, + xFrom: 0, + xScaleFrom: undefined, + xScaleTo: undefined, + xTo: -720, + yFrom: undefined, + yScaleFrom: undefined, + yScaleTo: undefined, + yTo: undefined, + }, + { + block: false, + durationMs: 500, + xFrom: undefined, + xScaleFrom: 0.75, + xScaleTo: 0.8, + xTo: undefined, + yFrom: undefined, + yScaleFrom: 0.75, + yScaleTo: 0.8, + yTo: undefined, + }, + { + block: true, + durationMs: 150, + xFrom: undefined, + xScaleFrom: undefined, + xScaleTo: undefined, + xTo: undefined, + yFrom: 360, + yScaleFrom: undefined, + yScaleTo: undefined, + yTo: 360, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps musicvolume and soundvolume commands", async () => { + const audio = new FakeAudio(); + const runtime = new StoryRuntime( + createContext([ + "[musicvolume(volume=0.25,fadetime=1.5)]", + '[soundvolume(channel="b",volume=0.75,fadetime=0.5)]', + '[name="A"]ok', + ]), + new FakeRenderer(), + audio, + ); + + await runtime.start(); + + expect(audio.musicVolumeCalls).toEqual([ + { + fadeMs: 1500, + volume: 0.25, + }, + ]); + expect(audio.soundVolumeCalls).toEqual([ + { + channel: "b", + fadeMs: 500, + volume: 0.75, + }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps native audio defaults, exact parameter casing, and raw channels", async () => { + const audio = new FakeAudio(); + const runtime = new StoryRuntime( + createContext([ + '[playmusic(key="m",intro="s",volume=1.5,delay=0.2,crossfade=0.8,Volume=0.1)]', + '[playsound(key="s",volume=1.5,delay=0.3,loop=true,Channel="wrong")]', + '[stopsound(key="s",fadetime=0.4)]', + '[soundvolume(key="s",volume=0.2)]', + '[name="A"]ok', + ]), + new FakeRenderer(), + audio, + ); + + await runtime.start(); + + expect(audio.playMusicCalls).toEqual([ + { + crossfadeMs: 800, + delayMs: 200, + intro: "s", + key: "m", + volume: 1.5, + }, + ]); + expect(audio.playSoundCalls).toEqual([ + { + channel: "s", + delayMs: 300, + key: "s", + loop: true, + volume: 1.5, + }, + ]); + expect(audio.stopSoundCalls).toEqual([{ channel: "s", fadeMs: 400 }]); + expect(audio.soundVolumeCalls).toEqual([ + { channel: "", fadeMs: 0, volume: 0.2 }, + ]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("maps subtitle show and clear commands", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[subtitle(text="HELLO",alignment="center",size=24,width=400,x=100,y=200,delay=0.1,fadetime=0.2,multi=true)]', + "[subtitle(fadetime=0.3)]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.subtitleCalls).toEqual([ + { + alignment: "center", + delayMs: 0, + sizePx: 24, + text: "HELLO", + widthPx: 400, + x: 100, + y: 200, + }, + ]); + expect(renderer.subtitleClearCalls).toEqual([]); + expect(runtime.getState()).toBe("waiting_input"); + expect(runtime.getDisplayedLineIndex()).toBe(1); + + await runtime.advance(); + expect(renderer.subtitleClearCalls).toEqual([150]); + expect(runtime.getState()).toBe("waiting_input"); + expect(runtime.getDisplayedLineIndex()).toBe(3); + }); + + it("maps sticker and timer sticker commands", async () => { + const renderer = new FakeRenderer(); + const sleep = vi.fn(async () => {}); + const runtime = new StoryRuntime( + createContext([ + '[sticker(id="tip",text="LEFT",alignment="left",size=20,width=200,x=40,y=60,delay=0.05,multi=true,fadetime=0.1)]', + '[sticker(id="tip",fadetime=0.2)]', + "[timersticker(x=30,y=90,size=24,time=10)]", + "[timerclear(afrom=0.8,ato=0.2,duration=0.5)]", + "[stickerclear]", + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + { sleep }, + ); + + await runtime.start(); + + expect(renderer.stickerCalls).toEqual([ + { + alignment: "left", + append: true, + // `delay` scales the global typewriter interval (0 here) rather than + // setting an absolute per-character time. + delayMs: 0, + fadeMs: 150, + id: "tip", + sizePx: 20, + text: "LEFT", + widthPx: 200, + x: 40, + y: 60, + }, + ]); + + // A sticker with a fresh id takes the show branch, whose `block` defaults to + // true, so it waits for a click before the rest of the script runs. + expect(runtime.getState()).toBe("waiting_input"); + await runtime.advance(); + + expect(renderer.stickerClearCalls).toEqual([{ fadeMs: 150, id: "tip" }]); + expect(renderer.timerStickerCalls).toEqual([ + { + durationMs: 1000, + fromAlpha: 0, + limitSeconds: 10, + sizePx: 24, + toAlpha: 1, + widthPx: 1280, + x: 30, + y: 90, + }, + ]); + expect(renderer.timerClearCalls).toEqual([ + { + durationMs: 500, + }, + { durationMs: 0 }, + ]); + expect(renderer.stickersClearCalls).toEqual([150]); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("click finishes subtitle typing before advancing", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[subtitle(text="HELLO",alignment="center")]', + '[name="A"]ok', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + expect(runtime.getState()).toBe("waiting_input"); + + await runtime.advance(); + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "ok" }); + }); + + it("click skips typewriter first, then advances", async () => { + const sleep = vi.fn(() => new Promise(() => {})); + + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[name="A"]hello']), + renderer, + new FakeAudio(), + { + sleep, + typingIntervalMs: 30, + }, + ); + + await runtime.start(); + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "" }); + expect(sleep).toHaveBeenCalled(); + + await runtime.advance(); + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "hello" }); + + // The auto-appended endtip is a no-op in manual mode (shouldProcessEndtip is + // false unless button-auto or quick-play is running), so the story ends here. + await runtime.advance(); + expect(runtime.getState()).toBe("finished"); + }); + + it("uses native sticker id state for show, append, and hide", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[sticker(id="a",text="one",block=false)]', + '[sticker(id="a",text="two",multi=true,block=false)]', + '[sticker(id="a",text="ignored",duration=2,block=false)]', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect( + renderer.stickerCalls.map((call) => ({ + append: call.append, + text: call.text, + })), + ).toEqual([ + { append: false, text: "one" }, + { append: true, text: "two" }, + ]); + expect(renderer.stickerClearCalls).toEqual([{ fadeMs: 2000, id: "a" }]); + }); + + it("hides an empty name line without blocking", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[name="A"]', '[name="B"]next']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "next" }); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("appends multiline content until an end line is advanced", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[multiline(name="A")]one', + '[multiline(name="A",end=true)]two', + '[multiline(name="A")]three', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + expect(renderer.lastDialogue.text).toBe("one"); + await runtime.advance(); + expect(renderer.lastDialogue.text).toBe("onetwo"); + await runtime.advance(); + expect(renderer.lastDialogue.text).toBe("three"); + }); + + it("treats multiline delay as a ratio of the native 40ms origin delay", async () => { + vi.useFakeTimers(); + try { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[multiline(name="A",delay=0.08)]ab']), + renderer, + new FakeAudio(), + { typingIntervalMs: 20 }, + ); + + await runtime.start(); + expect(renderer.lastDialogue.text).toBe(""); + await vi.advanceTimersByTimeAsync(39); + expect(renderer.lastDialogue.text).toBe(""); + await vi.advanceTimersByTimeAsync(1); + expect(renderer.lastDialogue.text).toBe("a"); + await vi.advanceTimersByTimeAsync(40); + expect(renderer.lastDialogue.text).toBe("ab"); + } finally { + vi.useRealTimers(); + } + }); + + it("ignores subtitle ghost parameters and falls back invalid alignment to left", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[subtitle(text="x",alignment="middle",delay=9,fadetime=9,multi=true)]', + ]), + renderer, + new FakeAudio(), + { typingIntervalMs: 25 }, + ); + + await runtime.start(); + + expect(renderer.subtitleCalls[0]).toMatchObject({ + alignment: "left", + delayMs: 25, + widthPx: 1280, + }); + }); + + it("maps cgitem tween parameters and hidecgitem keys with native defaults", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[cgitem(image="cgitem_test",id="left",style="cg",pfrom="1,2",pto="3,4",pduration=2,pdelay=0.5,sfrom=0.8,sto=1.2,sduration=3,sdelay=0.25,afrom=0,ato=1,aduration=1,adelay=0.1,rfrom=-10,rto=20,rduration=4,width=640,height=360,ease="Linear",block=true,layer=9,fadetime=8)]', + '[hidecgitem(image="cgitem_test",id="left",block=true)]', + ]), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + + expect(renderer.cgItemCalls).toEqual([ + expect.objectContaining({ + alphaDelayMs: 100, + alphaDurationMs: 1000, + assetKey: "cgitem_test", + block: true, + ease: "Linear", + height: 360, + key: "cgitem_test_left", + positionDelayMs: 500, + positionDurationMs: 2000, + positionFrom: { x: 1, y: 2 }, + positionTo: { x: 3, y: 4 }, + rotationDurationMs: 4000, + scaleDelayMs: 250, + scaleDurationMs: 3000, + width: 640, + }), + ]); + expect(renderer.clearCgItemCalls).toEqual([ + { + block: true, + ease: "OutQuad", + fadeMs: 130, + key: "cgitem_test_left", + }, + ]); + }); + + it("maps parameterless hidecgitem to clear-all", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[hidecgitem(fadetime=1,ease="Linear")]']), + renderer, + new FakeAudio(), + ); + await runtime.start(); + expect(renderer.clearCgItemCalls).toEqual([ + { block: false, ease: "Linear", fadeMs: 1000, key: undefined }, + ]); + }); + + it("skipnode can finish the story from a dialogue input wait", async () => { + const sleep = vi.fn(() => new Promise(() => {})); + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext([ + '[skipnode(mode="nofirstskip")]', + '[name="A"]hello', + '[skipnode(mode="skip")]', + '[name="B"]next', + ]), + renderer, + new FakeAudio(), + { + sleep, + typingIntervalMs: 30, + }, + ); + + await runtime.start(); + + expect(runtime.getState()).toBe("waiting_input"); + expect(runtime.canSkipNode()).toBe(true); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "" }); + + await runtime.skipNode(); + + expect(runtime.getState()).toBe("finished"); + expect(runtime.canSkipNode()).toBe(false); + }); + + it("prioritizes SkipToThis and resumes from the command after its anchor", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(['[name="A"]before', "[SkipToThis]", '[name="B"]after']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + await runtime.skipNode(); + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "B", text: "after" }); + }); + + it("disables segment skip when the story has no skip anchors", async () => { + const runtime = new StoryRuntime( + createContext(['[name="A"]before', '[name="B"]after']), + new FakeRenderer(), + new FakeAudio(), + ); + + await runtime.start(); + expect(runtime.canSkipNode()).toBe(false); + await runtime.skipNode(); + expect(runtime.getState()).toBe("waiting_input"); + }); + + it("does not expose segment skip during an active command without anchors", async () => { + const sleep = vi.fn(() => new Promise(() => {})); + const runtime = new StoryRuntime( + createContext(["[delay(time=30)]", '[name="A"]unreachable']), + new FakeRenderer(), + new FakeAudio(), + { sleep }, + ); + + const startPromise = runtime.start(); + await Promise.resolve(); + expect(runtime.canSkipNode()).toBe(false); + await runtime.skipNode(); + expect(runtime.getState()).toBe("waiting_timer"); + void startPromise; + }); + + it("does not jump backward after passing SkipToThis", async () => { + const renderer = new FakeRenderer(); + const runtime = new StoryRuntime( + createContext(["[SkipToThis]", '[name="A"]after', '[name="B"]later']), + renderer, + new FakeAudio(), + ); + + await runtime.start(); + await runtime.skipNode(); + + expect(runtime.getState()).toBe("waiting_input"); + expect(renderer.lastDialogue).toEqual({ speaker: "A", text: "after" }); + }); +}); diff --git a/tests/shake-path.spec.ts b/tests/shake-path.spec.ts new file mode 100644 index 00000000..263335c7 --- /dev/null +++ b/tests/shake-path.spec.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { + buildShakePath, + sampleShakePath, +} from "../src/widgets/StoryPlayer/engine/rendering/core/ShakePath"; + +describe("DOTween-compatible shake path", () => { + it("builds vibrato * duration waypoints and returns to zero", () => { + const path = buildShakePath( + 2000, + { + fadeOut: true, + randomness: 60, + vibrato: 30, + xStrength: 14, + yStrength: 6, + }, + () => 0.5, + ); + + expect(path).toHaveLength(60); + expect(path.at(-1)).toMatchObject({ x: 0, y: 0 }); + expect( + path.reduce((sum, point) => sum + point.durationRatio, 0), + ).toBeCloseTo(1); + expect(path[1].durationRatio).toBeGreaterThan(path[0].durationRatio); + }); + + it("treats randomness as angular deviation instead of a probability", () => { + const path = buildShakePath( + 1000, + { + fadeOut: false, + randomness: 0, + vibrato: 3, + xStrength: 10, + yStrength: 10, + }, + () => 0, + ); + + expect(path[0].x).toBeCloseTo(10); + expect(path[1].x).toBeCloseTo(-10); + expect(path[0].y).toBeCloseTo(0); + expect(path[1].y).toBeCloseTo(0); + }); + + it("discards the Full-mode tilt that rotates into the disabled Z axis", () => { + const values = [0, 1]; + const path = buildShakePath( + 1000, + { + fadeOut: false, + randomness: 60, + vibrato: 2, + xStrength: 10, + yStrength: 6, + }, + () => values.shift() ?? 0.5, + ); + + expect(path[0].x).toBeCloseTo(5); + expect(path[0].y).toBeCloseTo(0); + }); + + it("interpolates continuously between waypoints", () => { + const path = [ + { durationRatio: 0.5, x: 10, y: 4 }, + { durationRatio: 0.5, x: 0, y: 0 }, + ]; + expect(sampleShakePath(path, 0.25)).toEqual({ x: 5, y: 2 }); + expect(sampleShakePath(path, 1)).toEqual({ x: 0, y: 0 }); + }); +}); diff --git a/tests/showitem.spec.ts b/tests/showitem.spec.ts new file mode 100644 index 00000000..4c0a4615 --- /dev/null +++ b/tests/showitem.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { computeLegacyShowItemLayout } from "../src/widgets/StoryPlayer/engine/showitem"; + +describe("computeLegacyShowItemLayout", () => { + it("translates the legacy 960x540 showitem sizing into the 1280x720 stage", () => { + const layout = computeLegacyShowItemLayout(941, 529); + expect(layout.borderPx).toBe(10); + expect(layout.contentHeight).toBeCloseTo(423.2); + expect(layout.contentWidth).toBeCloseTo(752.8); + expect(layout.scale).toBeCloseTo(0.8); + }); + + it("preserves the legacy border thickness ratio", () => { + expect(computeLegacyShowItemLayout(200, 100).borderPx).toBe(10); + }); +}); diff --git a/tests/spellStickerPanel.spec.ts b/tests/spellStickerPanel.spec.ts new file mode 100644 index 00000000..b3bfa4fe --- /dev/null +++ b/tests/spellStickerPanel.spec.ts @@ -0,0 +1,61 @@ +import { Container, type Text } from "pixi.js"; +import { describe, expect, it, vi } from "vitest"; + +import { SpellStickerPanel } from "../src/widgets/StoryPlayer/engine/rendering/panels/SpellStickerPanel"; + +describe("SpellStickerPanel", () => { + it("renders the two native text slots in centered coordinates and hides without removing", () => { + const layer = new Container(); + const panel = new SpellStickerPanel(layer); + panel.show({ + alpha: 2, + angle: 5, + content: "mainsub", + id: "spell1", + style: "SAMI", + x: -130, + xScale: 1.3, + y: 20, + yScale: 1.2, + }); + + const root = layer.children[0] as Container; + expect(root.position.x).toBe(510); + expect(root.position.y).toBe(340); + expect(root.scale.x).toBe(1.3); + expect(root.scale.y).toBe(1.2); + expect(root.angle).toBe(5); + expect(root.alpha).toBe(1); + expect((root.getChildByLabel("text_spell_main") as Text).text).toBe("main"); + expect((root.getChildByLabel("text_spell_sub") as Text).text).toBe("sub"); + + panel.hide("spell1"); + expect(root.visible).toBe(false); + expect(layer.children).toHaveLength(1); + + panel.show({ + alpha: 0.5, + content: "again", + id: "spell1", + style: "sami", + }); + expect(layer.children[0]).toBe(root); + expect(root.visible).toBe(true); + }); + + it("keeps a style-switched view orphaned until clear and rejects unknown styles", () => { + const layer = new Container(); + const warning = vi.fn(); + const panel = new SpellStickerPanel(layer, warning); + panel.show({ alpha: 1, content: "", id: "spell1", style: "sami" }); + panel.show({ alpha: 1, content: "", id: "spell1", style: "fire" }); + expect(layer.children).toHaveLength(2); + + panel.show({ alpha: 1, content: "", id: "bad", style: "unknown" }); + expect(layer.children).toHaveLength(2); + expect(warning).toHaveBeenCalledWith("missing spellsticker style: unknown"); + + panel.clear(); + expect(layer.children).toHaveLength(0); + }); +}); diff --git a/tests/textVariables.spec.ts b/tests/textVariables.spec.ts new file mode 100644 index 00000000..04d30a8c --- /dev/null +++ b/tests/textVariables.spec.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + expandStoryText, + getStoryNickname, +} from "../src/widgets/StoryPlayer/engine/textVariables"; + +afterEach(() => { + window.mw = undefined; +}); + +describe("story text variables", () => { + it("uses the MediaWiki username and removes Dr. case-insensitively", () => { + window.mw = { config: { get: () => "Dr.Kal'tsit" } }; + + expect(getStoryNickname()).toBe("Kal'tsit"); + expect(expandStoryText("欢迎,{@nickname}。{@nickname}!")).toBe( + "欢迎,Kal'tsit。Kal'tsit!", + ); + expect(expandStoryText("欢迎,{@Nickname}!")).toBe("欢迎,Kal'tsit!"); + }); + + it("falls back to 博士 for anonymous users", () => { + window.mw = { config: { get: () => null } }; + + expect(getStoryNickname()).toBe("博士"); + expect(expandStoryText("Dr.{@nickname}。")).toBe("Dr.博士。"); + }); + + it("resolves story variables case-insensitively and removes unknown ones", () => { + expect(expandStoryText("Ave{@NBS}Mujica")).toBe("Ave\u00A0Mujica"); + expect(expandStoryText("{@answer}/{@missing}", { answer: 42 })).toBe("42/"); + }); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json index 99e8d705..ca4df618 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -31,5 +31,5 @@ "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false }, - "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "tests/**/*.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 308a4734..11e3d51c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -88,6 +88,7 @@ export default defineConfig(({ command }) => { if (id.includes("sentry")) return "sentry"; if (id.includes("naive-ui")) return "naive-ui"; if (id.includes("howler")) return "howler"; + if (id.includes("pixi")) return "pixi"; if ( id.includes("@zumer/snapdom") || id.includes("vue-draggable-plus") diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..7684e31f --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "happy-dom", + include: ["tests/**/*.spec.ts"], + }, +});