From 4ad5d06f5b7cf577c79151dbed03bfb52d6bab8c Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:08:09 +0800 Subject: [PATCH 01/39] =?UTF-8?q?fix(parser):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E8=B5=84=E6=BA=90=E9=A2=84=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E7=9A=84=E5=8E=BB=E9=87=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/parser/src/sceneParser.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/parser/src/sceneParser.ts b/packages/parser/src/sceneParser.ts index e6528be25..8fe1114c5 100644 --- a/packages/parser/src/sceneParser.ts +++ b/packages/parser/src/sceneParser.ts @@ -5,7 +5,6 @@ import { ISentence, } from './interface/sceneInterface'; import { scriptParser } from './scriptParser/scriptParser'; -import uniqWith from 'lodash/uniqWith'; import { fileType } from './interface/assets'; import { ConfigMap } from './config/scriptConfig'; @@ -54,7 +53,7 @@ export const sceneParser = ( ); // 开始资源的预加载 - assetsList = uniqWith(assetsList); // 去重 + assetsList = deduplicateAssets(assetsList); assetsPrefetcher(assetsList); return { @@ -65,3 +64,15 @@ export const sceneParser = ( subSceneList: subSceneList, // 子场景列表 }; }; + +const deduplicateAssets = (assetsList: IAsset[]): IAsset[] => { + const seenAssets = new Set(); + return assetsList.filter((asset) => { + const assetKey = `${asset.type}:${asset.url}`; + if (seenAssets.has(assetKey)) { + return false; + } + seenAssets.add(assetKey); + return true; + }); +}; From 5e9019b05c1c8ec677b2f94777919be9b06252e6 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:08:17 +0800 Subject: [PATCH 02/39] =?UTF-8?q?chore(parser):=20=E6=94=B6=E6=95=9B=20par?= =?UTF-8?q?ser=20=E6=9E=84=E5=BB=BA=E4=BE=9D=E8=B5=96=E4=B8=8E=20TypeScrip?= =?UTF-8?q?t=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/parser/.npmignore | 1 - packages/parser/package.json | 14 +- packages/parser/rollup.config.js | 1 + packages/parser/tsconfig.json | 7 +- packages/parser/tsconfig.node.json | 10 -- yarn.lock | 236 ++--------------------------- 6 files changed, 17 insertions(+), 252 deletions(-) delete mode 100644 packages/parser/tsconfig.node.json diff --git a/packages/parser/.npmignore b/packages/parser/.npmignore index 7bcd9b489..4b634ed49 100644 --- a/packages/parser/.npmignore +++ b/packages/parser/.npmignore @@ -3,4 +3,3 @@ test .babelrc rollup.config.js tsconfig.json -tsconfig.node.json diff --git a/packages/parser/package.json b/packages/parser/package.json index 6eb112d2e..5a7393280 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -17,26 +17,16 @@ "main": "./build/cjs/index.cjs", "author": "Mahiru ", "license": "MPL-2.0", - "dependencies": { - "chevrotain": "^10.5.0", - "cloudlogjs": "^1.0.11", - "lodash": "^4.17.23", - "tsx": "^3.12.7" - }, "devDependencies": { "@rollup/plugin-commonjs": "^23.0.2", - "@rollup/plugin-json": "^5.0.1", "@rollup/plugin-node-resolve": "^15.0.1", - "@rollup/plugin-typescript": "^9.0.2", - "@types/lodash": "^4.14.189", "@types/node": "^18.14.0", "@vitest/coverage-c8": "^0.28.5", + "rimraf": "^3.0.2", "rollup": "^3.29.5", - "rollup-plugin-babel": "^4.4.0", - "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.34.1", - "ts-node": "^10.9.1", "tslib": "^2.4.1", + "tsx": "^3.12.7", "typescript": "^4.9.3", "vitest": "^0.28.5" }, diff --git a/packages/parser/rollup.config.js b/packages/parser/rollup.config.js index 57bce0cc5..85cee0794 100644 --- a/packages/parser/rollup.config.js +++ b/packages/parser/rollup.config.js @@ -53,6 +53,7 @@ export default [ file: "./build/umd/index.global.js", name: 'webgalParser', format: 'iife', + exports: "named", sourcemap: !isProd }, ], diff --git a/packages/parser/tsconfig.json b/packages/parser/tsconfig.json index 5fb839743..b456d685f 100644 --- a/packages/parser/tsconfig.json +++ b/packages/parser/tsconfig.json @@ -4,7 +4,7 @@ "declarationDir": "build/types", "target": "ESNext", "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], + "lib": ["ESNext"], "allowJs": false, "skipLibCheck": false, "esModuleInterop": false, @@ -17,13 +17,12 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx", + "types": ["node"], "paths": { "@/*": ["./src/*"] } }, "include": [ "src" - ], - "references": [{ "path": "./tsconfig.node.json"}] + ] } diff --git a/packages/parser/tsconfig.node.json b/packages/parser/tsconfig.node.json deleted file mode 100644 index fde6cf327..000000000 --- a/packages/parser/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "module": "esnext", - "moduleResolution": "node" - }, - "include": [ - "vite.config.ts" - ] -} diff --git a/yarn.lock b/yarn.lock index 9ef0a5ec1..eb638a574 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,7 +15,7 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": version "7.24.2" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== @@ -99,7 +99,7 @@ dependencies: "@babel/types" "^7.22.5" -"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15": +"@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15": version "7.24.3" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz#6ac476e6d168c7c23ff3ba3cf4f7841d46ac8128" integrity sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg== @@ -249,40 +249,6 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@chevrotain/cst-dts-gen@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz#922ebd8cc59d97241bb01b1b17561a5c1ae0124e" - integrity sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw== - dependencies: - "@chevrotain/gast" "10.5.0" - "@chevrotain/types" "10.5.0" - lodash "4.17.21" - -"@chevrotain/gast@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-10.5.0.tgz#e4e614bc46d17a8892742f38e56cd33f1f3ad162" - integrity sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A== - dependencies: - "@chevrotain/types" "10.5.0" - lodash "4.17.21" - -"@chevrotain/types@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-10.5.0.tgz#52a97d74a8cfbc197f054636d93ecd8912d33d21" - integrity sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A== - -"@chevrotain/utils@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-10.5.0.tgz#0ee36f65b49b447fbac71b9e5af5c5c6c98ac057" - integrity sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ== - -"@cspotcode/source-map-support@^0.8.0": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" - integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== - dependencies: - "@jridgewell/trace-mapping" "0.3.9" - "@emotion/babel-plugin@^11.11.0": version "11.11.0" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c" @@ -698,7 +664,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": +"@jridgewell/resolve-uri@^3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== @@ -708,27 +674,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== -"@jridgewell/source-map@^0.3.3": - version "0.3.6" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a" - integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14": version "1.4.15" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== -"@jridgewell/trace-mapping@0.3.9": - version "0.3.9" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" - integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" @@ -1146,13 +1096,6 @@ is-reference "1.2.1" magic-string "^0.27.0" -"@rollup/plugin-json@^5.0.1": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-5.0.2.tgz#d7dbbac62ff74064876b3e5d0d863cb3ad1e7cdb" - integrity sha512-D1CoOT2wPvadWLhVcmpkDnesTzjhNIQRWLsc3fA49IFOP2Y84cFOOJ+nKGYedvXHKUsPeq07HR4hXpBBr+CHlA== - dependencies: - "@rollup/pluginutils" "^5.0.1" - "@rollup/plugin-node-resolve@^15.0.1": version "15.2.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz#e5e0b059bd85ca57489492f295ce88c2d4b0daf9" @@ -1165,14 +1108,6 @@ is-module "^1.0.0" resolve "^1.22.1" -"@rollup/plugin-typescript@^9.0.2": - version "9.0.2" - resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-9.0.2.tgz#c0cdfa39e267f306ff7316405a35406d5821eaa7" - integrity sha512-/sS93vmHUMjzDUsl5scNQr1mUlNE1QjBBvOhmRwJCH8k2RRhDIm3c977B3wdu3t3Ap17W6dDeXP3hj1P1Un1bA== - dependencies: - "@rollup/pluginutils" "^5.0.1" - resolve "^1.22.1" - "@rollup/pluginutils@^4.1.2": version "4.2.1" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" @@ -1315,26 +1250,6 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz#3afdb30405f6d4248df5e72e1ca86c5eab55fab8" integrity sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w== -"@tsconfig/node10@^1.0.7": - version "1.0.10" - resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.10.tgz#b7ebd3adfa7750628d100594f6726b054d2c33b2" - integrity sha512-PiaIWIoPvO6qm6t114ropMCagj6YAF24j9OkCA2mJDXFnlionEwhsBCJ8yek4aib575BI3OkART/90WsgHgLWw== - -"@tsconfig/node12@^1.0.7": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" - integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== - -"@tsconfig/node14@^1.0.0": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" - integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== - -"@tsconfig/node16@^1.0.2": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" - integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== - "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1413,7 +1328,7 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== -"@types/lodash@^4.14.180", "@types/lodash@^4.14.189": +"@types/lodash@^4.14.180": version "4.17.0" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3" integrity sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA== @@ -1673,12 +1588,12 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.1.1, acorn-walk@^8.2.0: +acorn-walk@^8.2.0: version "8.3.2" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== -acorn@^8.10.0, acorn@^8.11.3, acorn@^8.4.1, acorn@^8.8.1, acorn@^8.8.2: +acorn@^8.10.0, acorn@^8.11.3, acorn@^8.8.1: version "8.11.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== @@ -1772,11 +1687,6 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -arg@^4.1.0: - version "4.1.3" - resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" - integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== - argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -2155,18 +2065,6 @@ check-error@^1.0.3: dependencies: get-func-name "^2.0.2" -chevrotain@^10.5.0: - version "10.5.0" - resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-10.5.0.tgz#9c1dc62ef0753bb562dbe521b5f72d041bad624e" - integrity sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A== - dependencies: - "@chevrotain/cst-dts-gen" "10.5.0" - "@chevrotain/gast" "10.5.0" - "@chevrotain/types" "10.5.0" - "@chevrotain/utils" "10.5.0" - lodash "4.17.21" - regexp-to-ast "0.5.0" - "chokidar@>=3.0.0 <4.0.0": version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -2281,7 +2179,7 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^2.18.0, commander@^2.20.0: +commander@^2.18.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -2349,11 +2247,6 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" -create-require@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" - integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== - cross-env@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -2509,11 +2402,6 @@ detect-libc@^2.0.0: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== -diff@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" - integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== - diff@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" @@ -2948,11 +2836,6 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== -estree-walker@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" - integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== - estree-walker@^2.0.1, estree-walker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" @@ -3979,15 +3862,6 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" -jest-worker@^26.2.1: - version "26.6.2" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" - integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== - dependencies: - "@types/node" "*" - merge-stream "^2.0.0" - supports-color "^7.0.0" - js-binary-schema-parser@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz#3d7848748e8586e63b34e8911b643f59cfb6396e" @@ -4163,7 +4037,7 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@4.17.21, lodash@^4.17.14: +lodash@^4.17.14: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4232,11 +4106,6 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" -make-error@^1.1.1: - version "1.3.6" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" - integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== - math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -5012,13 +4881,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -5160,11 +5022,6 @@ regenerator-runtime@^0.14.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== -regexp-to-ast@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz#56c73856bee5e1fef7f73a00f1473452ab712a24" - integrity sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw== - regexp.prototype.flags@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" @@ -5238,24 +5095,6 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup-plugin-babel@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-babel/-/rollup-plugin-babel-4.4.0.tgz#d15bd259466a9d1accbdb2fe2fff17c52d030acb" - integrity sha512-Lek/TYp1+7g7I+uMfJnnSJ7YWoD58ajo6Oarhlex7lvUce+RCKRuGRSgztDO3/MF/PuGKmUL5iTHKf208UNszw== - dependencies: - "@babel/helper-module-imports" "^7.0.0" - rollup-pluginutils "^2.8.1" - -rollup-plugin-terser@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d" - integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ== - dependencies: - "@babel/code-frame" "^7.10.4" - jest-worker "^26.2.1" - serialize-javascript "^4.0.0" - terser "^5.0.0" - rollup-plugin-typescript2@^0.34.1: version "0.34.1" resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.34.1.tgz#c457f155a71d133c142689213fce78694e30d0be" @@ -5277,13 +5116,6 @@ rollup-plugin-visualizer@^5.6.0: source-map "^0.7.4" yargs "^17.5.1" -rollup-pluginutils@^2.8.1: - version "2.8.2" - resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" - integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== - dependencies: - estree-walker "^0.6.1" - rollup@^3.27.1, rollup@^3.29.5: version "3.29.5" resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" @@ -5349,7 +5181,7 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -5426,13 +5258,6 @@ send@0.19.0: range-parser "~1.2.1" statuses "2.0.1" -serialize-javascript@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" - integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== - dependencies: - randombytes "^2.1.0" - serve-static@1.16.2: version "1.16.2" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" @@ -5566,7 +5391,7 @@ source-map-js@^1.2.1: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== -source-map-support@^0.5.21, source-map-support@~0.5.20: +source-map-support@^0.5.21: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -5757,7 +5582,7 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -5795,16 +5620,6 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" -terser@^5.0.0: - version "5.29.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.29.2.tgz#c17d573ce1da1b30f21a877bffd5655dd86fdb35" - integrity sha512-ZiGkhUBIM+7LwkNjXYJq8svgkd+QK3UUr0wJqY4MieaezBSAIPgbSPZyIx0idM6XWK5CMzSWa8MJIzmRcB8Caw== - dependencies: - "@jridgewell/source-map" "^0.3.3" - acorn "^8.8.2" - commander "^2.20.0" - source-map-support "~0.5.20" - test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -5876,25 +5691,6 @@ ts-api-utils@^2.4.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== -ts-node@^10.9.1: - version "10.9.2" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" - integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== - dependencies: - "@cspotcode/source-map-support" "^0.8.0" - "@tsconfig/node10" "^1.0.7" - "@tsconfig/node12" "^1.0.7" - "@tsconfig/node14" "^1.0.0" - "@tsconfig/node16" "^1.0.2" - acorn "^8.4.1" - acorn-walk "^8.1.1" - arg "^4.1.0" - create-require "^1.1.0" - diff "^4.0.1" - make-error "^1.1.1" - v8-compile-cache-lib "^3.0.1" - yn "3.1.1" - tslib@2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" @@ -6094,11 +5890,6 @@ uuid@^9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== -v8-compile-cache-lib@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" - integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== - v8-to-istanbul@^9.0.0: version "9.2.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" @@ -6360,11 +6151,6 @@ yargs@^17.5.1: y18n "^5.0.5" yargs-parser "^21.1.1" -yn@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" - integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== - yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" From f11702007c6b09722658ca286733fee10454a9c0 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:31:06 +0800 Subject: [PATCH 03/39] =?UTF-8?q?fix(parser):=20=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E6=97=A0=E6=95=88=E8=B5=84=E6=BA=90=E9=A2=84=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/parser/src/sceneParser.ts | 3 +++ packages/parser/test/parser.test.ts | 35 ++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/parser/src/sceneParser.ts b/packages/parser/src/sceneParser.ts index 8fe1114c5..7a8ff5814 100644 --- a/packages/parser/src/sceneParser.ts +++ b/packages/parser/src/sceneParser.ts @@ -68,6 +68,9 @@ export const sceneParser = ( const deduplicateAssets = (assetsList: IAsset[]): IAsset[] => { const seenAssets = new Set(); return assetsList.filter((asset) => { + if (!asset || typeof asset.url !== 'string' || asset.url === '') { + return false; + } const assetKey = `${asset.type}:${asset.url}`; if (seenAssets.has(assetKey)) { return false; diff --git a/packages/parser/test/parser.test.ts b/packages/parser/test/parser.test.ts index f2cafaa39..07e5aa2c0 100644 --- a/packages/parser/test/parser.test.ts +++ b/packages/parser/test/parser.test.ts @@ -1,7 +1,7 @@ import SceneParser from "../src/index"; import { ADD_NEXT_ARG_LIST, SCRIPT_CONFIG } from "../src/config/scriptConfig"; import { expect, test } from "vitest"; -import { commandType, ISentence } from "../src/interface/sceneInterface"; +import { commandType, IAsset, ISentence } from "../src/interface/sceneInterface"; import * as fsp from 'fs/promises'; import { fileType } from "../src/interface/assets"; @@ -223,6 +223,39 @@ test("say statement applies asset setter to vocal named argument", async () => { }); }); +test("scene assets are deduplicated by type and url", async () => { + let prefetchedAssets: IAsset[] = []; + const parser = new SceneParser((assetList) => { + prefetchedAssets = assetList; + }, (fileName, assetType) => { + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`changeBg:shared.webp; +changeFigure:shared.webp; +changeBg:shared.webp;`, 'test', 'test'); + + expect(result.assetsList).toEqual([ + { name: "shared.webp", url: 'shared.webp', type: fileType.background, lineNumber: 0 }, + { name: "shared.webp", url: 'shared.webp', type: fileType.figure, lineNumber: 1 }, + ]); + expect(prefetchedAssets).toEqual(result.assetsList); +}); + +test("scene assets skip entries with empty urls", async () => { + const parser = new SceneParser((assetList) => { + }, (fileName, assetType) => { + if (assetType === fileType.vocal) { + return ''; + } + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`say:123 -vocal=missing.mp3;`, 'test', 'test'); + + expect(result.assetsList).toEqual([]); +}); + test("wait command", async () => { const parser = new SceneParser((assetList) => { }, (fileName, assetType) => { From 0fb8f741fd47b0e6f756d89a49f76573b6e9f58e Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 13 Jun 2026 23:50:25 +0800 Subject: [PATCH 04/39] flowchart --- packages/webgal/public/game/config.txt | 1 + packages/webgal/public/game/flowchart.json | 172 +++++++++ packages/webgal/src/App.tsx | 2 + packages/webgal/src/Core/Modules/flowchart.ts | 180 ++++++++++ .../src/Core/controller/scene/callScene.ts | 1 + .../src/Core/controller/scene/changeScene.ts | 1 + packages/webgal/src/Core/initializeScript.ts | 1 + .../util/coreInitialFunction/infoFetcher.ts | 1 + .../util/syncWithEditor/previewSyncRuntime.ts | 1 + packages/webgal/src/Core/webgalCore.ts | 2 + .../BottomControlPanel/BottomControlPanel.tsx | 23 ++ .../BottomControlPanelFilm.tsx | 13 + .../webgal/src/UI/Flowchart/Flowchart.tsx | 325 ++++++++++++++++++ .../src/UI/Flowchart/flowchart.module.scss | 196 +++++++++++ .../src/UI/Menu/MenuPanel/MenuIconMap.tsx | 5 +- .../src/UI/Menu/MenuPanel/MenuPanel.tsx | 16 + .../src/UI/Menu/Options/System/System.tsx | 1 + packages/webgal/src/hooks/useHotkey.tsx | 12 + packages/webgal/src/store/GUIReducer.ts | 1 + packages/webgal/src/store/guiInterface.ts | 1 + packages/webgal/src/translations/de.ts | 13 + packages/webgal/src/translations/en.ts | 13 + packages/webgal/src/translations/fr.ts | 13 + packages/webgal/src/translations/jp.ts | 13 + packages/webgal/src/translations/ko.ts | 13 + packages/webgal/src/translations/pt-br.ts | 13 + packages/webgal/src/translations/zh-cn.ts | 13 + packages/webgal/src/translations/zh-tw.ts | 13 + 28 files changed, 1058 insertions(+), 1 deletion(-) create mode 100644 packages/webgal/public/game/flowchart.json create mode 100644 packages/webgal/src/Core/Modules/flowchart.ts create mode 100644 packages/webgal/src/UI/Flowchart/Flowchart.tsx create mode 100644 packages/webgal/src/UI/Flowchart/flowchart.module.scss diff --git a/packages/webgal/public/game/config.txt b/packages/webgal/public/game/config.txt index c2c6f05ac..1f617b0fd 100644 --- a/packages/webgal/public/game/config.txt +++ b/packages/webgal/public/game/config.txt @@ -5,3 +5,4 @@ Title_bgm:s_Title.mp3; Game_Logo:WebGalEnter.webp; Enable_Appreciation:true; Enable_Continue:true; +Enable_flowchart:true; diff --git a/packages/webgal/public/game/flowchart.json b/packages/webgal/public/game/flowchart.json new file mode 100644 index 000000000..d01dc3b62 --- /dev/null +++ b/packages/webgal/public/game/flowchart.json @@ -0,0 +1,172 @@ +{ + "flowcharts": [ + { + "id": "main", + "name": "示例游戏主线", + "type": "main", + "nodes": [ + { + "id": "start", + "type": "root", + "position": { "x": 250, "y": 0 }, + "data": { + "label": "入口选择", + "sceneName": "start.txt", + "isRoot": true + } + }, + { + "id": "zh_cn", + "type": "chapter", + "position": { "x": 0, "y": 120 }, + "data": { + "label": "简体中文演示", + "sceneName": "demo_zh_cn.txt" + } + }, + { + "id": "ja", + "type": "chapter", + "position": { "x": 240, "y": 120 }, + "data": { + "label": "日本語デモ", + "sceneName": "demo_ja.txt" + } + }, + { + "id": "en", + "type": "chapter", + "position": { "x": 480, "y": 120 }, + "data": { + "label": "English Demo", + "sceneName": "demo_en.txt" + } + }, + { + "id": "function_test", + "type": "branch", + "position": { "x": 720, "y": 120 }, + "data": { + "label": "功能测试入口", + "sceneName": "function_test.txt" + } + } + ], + "edges": [ + { "id": "e-start-zh-cn", "source": "start", "target": "zh_cn" }, + { "id": "e-start-ja", "source": "start", "target": "ja" }, + { "id": "e-start-en", "source": "start", "target": "en" }, + { "id": "e-start-function-test", "source": "start", "target": "function_test" } + ] + }, + { + "id": "function-tests", + "name": "功能测试分支", + "type": "character", + "nodes": [ + { + "id": "function_test", + "type": "root", + "position": { "x": 550, "y": 0 }, + "data": { + "label": "功能测试入口", + "sceneName": "function_test.txt", + "isRoot": true + } + }, + { + "id": "animation", + "type": "event", + "position": { "x": 0, "y": 120 }, + "data": { + "label": "口型动画测试", + "sceneName": "demo_animation.txt" + } + }, + { + "id": "var", + "type": "event", + "position": { "x": 220, "y": 120 }, + "data": { + "label": "变量插值测试", + "sceneName": "demo_var.txt" + } + }, + { + "id": "change_config", + "type": "event", + "position": { "x": 440, "y": 120 }, + "data": { + "label": "配置修改测试", + "sceneName": "demo_changeConfig.txt" + } + }, + { + "id": "performs", + "type": "event", + "position": { "x": 660, "y": 120 }, + "data": { + "label": "Pixi 演出测试", + "sceneName": "demo_performs.txt" + } + }, + { + "id": "flow_control", + "type": "branch", + "position": { "x": 880, "y": 120 }, + "data": { + "label": "流程控制测试", + "sceneName": "demo_test_flow_control.txt" + } + }, + { + "id": "variable_flow", + "type": "branch", + "position": { "x": 1100, "y": 120 }, + "data": { + "label": "变量流程测试", + "sceneName": "demo_test_variable_flow_control.txt" + } + }, + { + "id": "input_flow", + "type": "branch", + "position": { "x": 1320, "y": 120 }, + "data": { + "label": "用户输入流程测试", + "sceneName": "demo_test_input_flow_control.txt" + } + }, + { + "id": "dom_control", + "type": "event", + "position": { "x": 1540, "y": 120 }, + "data": { + "label": "DOM 生命周期测试", + "sceneName": "demo_test_dom_control.txt" + } + }, + { + "id": "flow_child", + "type": "event", + "position": { "x": 880, "y": 240 }, + "data": { + "label": "callScene 子场景", + "sceneName": "demo_test_flow_control_child.txt" + } + } + ], + "edges": [ + { "id": "e-function-animation", "source": "function_test", "target": "animation" }, + { "id": "e-function-var", "source": "function_test", "target": "var" }, + { "id": "e-function-change-config", "source": "function_test", "target": "change_config" }, + { "id": "e-function-performs", "source": "function_test", "target": "performs" }, + { "id": "e-function-flow-control", "source": "function_test", "target": "flow_control" }, + { "id": "e-function-variable-flow", "source": "function_test", "target": "variable_flow" }, + { "id": "e-function-input-flow", "source": "function_test", "target": "input_flow" }, + { "id": "e-function-dom-control", "source": "function_test", "target": "dom_control" }, + { "id": "e-flow-control-child", "source": "flow_control", "target": "flow_child" } + ] + } + ] +} diff --git a/packages/webgal/src/App.tsx b/packages/webgal/src/App.tsx index 5b1427abc..70a9dba80 100644 --- a/packages/webgal/src/App.tsx +++ b/packages/webgal/src/App.tsx @@ -5,6 +5,7 @@ import { Stage } from '@/Stage/Stage'; import { BottomControlPanel } from '@/UI/BottomControlPanel/BottomControlPanel'; import { BottomControlPanelFilm } from '@/UI/BottomControlPanel/BottomControlPanelFilm'; import { Backlog } from '@/UI/Backlog/Backlog'; +import { Flowchart } from '@/UI/Flowchart/Flowchart'; import Title from '@/UI/Title/Title'; import Logo from '@/UI/Logo/Logo'; import { Extra } from '@/UI/Extra/Extra'; @@ -24,6 +25,7 @@ export default function App() { + <Logo /> <Extra /> diff --git a/packages/webgal/src/Core/Modules/flowchart.ts b/packages/webgal/src/Core/Modules/flowchart.ts new file mode 100644 index 000000000..bb450a177 --- /dev/null +++ b/packages/webgal/src/Core/Modules/flowchart.ts @@ -0,0 +1,180 @@ +import { SceneManager } from '@/Core/Modules/scene'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { ISaveData } from '@/store/userDataInterface'; +import axios from 'axios'; +import cloneDeep from 'lodash/cloneDeep'; +import localforage from 'localforage'; + +export interface IFlowchartNodeData extends Record<string, unknown> { + label: string; + sceneName: string; + isRoot?: boolean; +} + +export interface IFlowchartNode { + id: string; + position: { x: number; y: number }; + data: IFlowchartNodeData; + type?: 'root' | 'chapter' | 'branch' | 'ending' | 'event' | string; +} + +export interface IFlowchartEdge { + id: string; + source: string; + target: string; +} + +export interface IFlowchart { + id: string; + name: string; + type?: 'main' | 'character'; + nodes: IFlowchartNode[]; + edges: IFlowchartEdge[]; +} + +export interface IFlowchartData { + flowcharts: IFlowchart[]; +} + +const FLOWCHART_UPDATED_EVENT = 'webgal-flowchart-updated'; + +export class FlowchartManager { + public enabled = false; + private gameKey = ''; + private data: IFlowchartData = { flowcharts: [] }; + private unlocked = new Set<string>(); + private snapshots = new Map<string, ISaveData>(); + + public constructor(private readonly sceneManager: SceneManager) {} + + public async init(gameKey: string, enabled: boolean) { + this.gameKey = gameKey; + this.enabled = enabled; + this.data = { flowcharts: [] }; + this.unlocked.clear(); + this.snapshots.clear(); + if (!enabled || !gameKey) return; + try { + const res = await axios.get('./game/flowchart.json'); + this.data = normalizeFlowchartData(res.data); + const unlocked = await localforage.getItem<string[]>(this.progressKey()); + this.unlocked = new Set(Array.isArray(unlocked) ? unlocked : []); + this.unlockCurrentScene(); + } catch { + this.data = { flowcharts: [] }; + } + } + + public hasFlowchart() { + return this.enabled && this.data.flowcharts.length > 0; + } + + public getFlowcharts() { + return this.data.flowcharts; + } + + public getEventName() { + return FLOWCHART_UPDATED_EVENT; + } + + public isUnlocked(flowchartId: string, nodeId: string) { + return this.unlocked.has(this.nodeKey(flowchartId, nodeId)); + } + + public async loadSnapshot(flowchartId: string, nodeId: string) { + const key = this.nodeKey(flowchartId, nodeId); + if (!this.unlocked.has(key)) return null; + const cached = this.snapshots.get(key); + if (cached) return cached; + return await localforage.getItem<ISaveData>(this.snapshotKey(flowchartId, nodeId)); + } + + public async clearProgress() { + const keys = [...this.unlocked]; + this.unlocked.clear(); + this.snapshots.clear(); + await Promise.all(keys.map((key) => localforage.removeItem(this.snapshotKeyByNodeKey(key)))); + await localforage.removeItem(this.progressKey()); + window.dispatchEvent(new Event(FLOWCHART_UPDATED_EVENT)); + } + + public unlockCurrentScene() { + if (!this.hasFlowchart()) return; + const sceneNames = new Set([ + normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneName), + normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneUrl), + ]); + const matched = this.data.flowcharts.flatMap((flowchart) => + flowchart.nodes + .filter((node) => sceneNames.has(normalizeSceneName(node.data?.sceneName))) + .map((node) => ({ flowchart, node })), + ); + if (matched.length === 0) return; + const snapshot = this.createSnapshot(); + let changed = false; + matched.forEach(({ flowchart, node }) => { + const key = this.nodeKey(flowchart.id, node.id); + if (this.unlocked.has(key)) return; + changed = true; + this.unlocked.add(key); + this.snapshots.set(key, snapshot); + localforage.setItem(this.snapshotKey(flowchart.id, node.id), snapshot); + }); + if (changed) { + localforage.setItem(this.progressKey(), [...this.unlocked]); + window.dispatchEvent(new Event(FLOWCHART_UPDATED_EVENT)); + } + } + + private createSnapshot(): ISaveData { + return { + nowStageState: cloneDeep(stageStateManager.getCalculationStageState()), + backlog: [], + index: -1, + saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }), + sceneData: { + currentSentenceId: this.sceneManager.sceneData.currentSentenceId, + sceneStack: cloneDeep(this.sceneManager.sceneData.sceneStack), + sceneName: this.sceneManager.sceneData.currentScene.sceneName, + sceneUrl: this.sceneManager.sceneData.currentScene.sceneUrl, + }, + previewImage: '', + }; + } + + private progressKey() { + return `${this.gameKey}-flowchart`; + } + + private snapshotKey(flowchartId: string, nodeId: string) { + return `${this.gameKey}-flowchart-${flowchartId}-${nodeId}`; + } + + private snapshotKeyByNodeKey(key: string) { + return `${this.gameKey}-flowchart-${key}`; + } + + private nodeKey(flowchartId: string, nodeId: string) { + return `${flowchartId}-${nodeId}`; + } +} + +function normalizeFlowchartData(raw: string | IFlowchartData): IFlowchartData { + const data = typeof raw === 'string' ? JSON.parse(raw) : raw; + const flowcharts: IFlowchart[] = Array.isArray(data?.flowcharts) ? data.flowcharts : []; + return { + flowcharts: flowcharts.map((flowchart) => ({ + ...flowchart, + type: flowchart.id === 'main' ? 'main' : flowchart.type || 'character', + nodes: Array.isArray(flowchart.nodes) ? flowchart.nodes : [], + edges: Array.isArray(flowchart.edges) ? flowchart.edges : [], + })), + }; +} + +function normalizeSceneName(sceneName = '') { + return decodeURI(sceneName) + .replace(/\\/g, '/') + .replace(/^\.?\/?game\/scene\//, '') + .replace(/^\.?\//, ''); +} diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index 66ac09e04..bbbe920bd 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -31,6 +31,7 @@ export const callScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.flowchartManager.unlockCurrentScene(); logger.debug('现在调用场景,调用结果:', WebGAL.sceneManager.sceneData); shouldAutoNext = !isFastPreviewSceneWrite; }) diff --git a/packages/webgal/src/Core/controller/scene/changeScene.ts b/packages/webgal/src/Core/controller/scene/changeScene.ts index 7a6c1c981..a9d02fd38 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -25,6 +25,7 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.flowchartManager.unlockCurrentScene(); logger.debug('现在切换场景,切换后的结果:', WebGAL.sceneManager.sceneData); shouldAutoNext = !isFastPreviewSceneWrite; }) diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index f6373f6c9..f323400f5 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -51,6 +51,7 @@ export const initializeScript = (): void => { const initialSceneReady = sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.flowchartManager.unlockCurrentScene(); }); // 获取游戏信息 infoFetcher('./game/config.txt'); diff --git a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts index 7a239ff32..299e34218 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts @@ -72,6 +72,7 @@ export const infoFetcher = (url: string): Promise<IGameVar> => { }); dispatch(setUserData({ key: 'gameConfigInit', value: gameConfigInit })); + await WebGAL.flowchartManager.init(WebGAL.gameKey, gameConfigInit.Enable_flowchart === true); // @ts-expect-error renderPromiseResolve is a global variable window.renderPromiseResolve(); setStorage(); diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index d9fc83b00..cb2c8266f 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -210,6 +210,7 @@ export const startPreviewSyncRuntime = () => { applyComponentVisibility({ showTitle: false, showMenuPanel: false, + showFlowchart: false, isEnterGame: true, showPanicOverlay: false, }); diff --git a/packages/webgal/src/Core/webgalCore.ts b/packages/webgal/src/Core/webgalCore.ts index 73f37e51e..88320c516 100644 --- a/packages/webgal/src/Core/webgalCore.ts +++ b/packages/webgal/src/Core/webgalCore.ts @@ -9,10 +9,12 @@ import { SteamIntegration } from '@/Core/integration/steamIntegration'; import { WebgalTemplate } from '@/types/template'; import { IWebGALStyleObj } from 'webgal-parser/build/types/styleParser'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { FlowchartManager } from '@/Core/Modules/flowchart'; export class WebgalCore { public sceneManager = new SceneManager(); public backlogManager = new BacklogManager(this.sceneManager); + public flowchartManager = new FlowchartManager(this.sceneManager); public readHistoryManager = new ReadHistoryManager(this.sceneManager); public animationManager = new AnimationManager(); public gameplay = new Gameplay(); diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx index 03e3a021e..ad8fb8984 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx @@ -27,6 +27,7 @@ import { ReplayMusic, Save, SettingTwo, + TreeDiagram, Unlock, } from '@icon-park/react'; import { useTranslation } from 'react-i18next'; @@ -49,6 +50,7 @@ export const BottomControlPanel = () => { } const { isSupported: isFullscreenSupport, isFullScreen, toggle: toggleFullscreen } = useFullScreen(); const GUIStore = useSelector((state: RootState) => state.GUI); + const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); const stageState = useStageState(); const dispatch = useDispatch(); const setComponentVisibility = (component: keyof componentsVisibility, visibility: boolean) => { @@ -143,6 +145,27 @@ export const BottomControlPanel = () => { /> <span className={styles.button_text}>{t('buttons.backlog')}</span> </span> + {enableFlowchart && ( + <span + className={styles.singleButton} + style={{ fontSize }} + onClick={() => { + setComponentVisibility('showFlowchart', true); + setComponentVisibility('showTextBox', false); + playSeClick(); + }} + onMouseEnter={playSeEnter} + > + <TreeDiagram + className={styles.button} + theme="outline" + size={size} + fill="#f5f5f7" + strokeWidth={strokeWidth} + /> + <span className={styles.button_text}>{t('buttons.flowchart')}</span> + </span> + )} <span className={styles.singleButton} style={{ fontSize }} diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx index c55f2a4e8..ed28b8f17 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx @@ -13,6 +13,7 @@ import { useStageState } from '@/hooks/useStageState'; export const BottomControlPanelFilm = () => { const showPanel = useValue(false); const stageState = useStageState(); + const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); const dispatch = useDispatch(); const setComponentVisibility = (component: keyof componentsVisibility, visibility: boolean) => { dispatch(setVisibility({ component, visibility })); @@ -44,6 +45,18 @@ export const BottomControlPanelFilm = () => { > <span className={styles.button_text}>剧情回想 / BACKLOG</span> </span> + {enableFlowchart && ( + <span + className={styles.singleButton} + onClick={() => { + setComponentVisibility('showFlowchart', true); + setComponentVisibility('showTextBox', false); + showPanel.set(!showPanel.value); + }} + > + <span className={styles.button_text}>流程图 / FLOWCHART</span> + </span> + )} <span className={styles.singleButton} onClick={() => { diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx new file mode 100644 index 000000000..6dade4311 --- /dev/null +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -0,0 +1,325 @@ +import { loadGameFromStageData } from '@/Core/controller/storage/loadGame'; +import { IFlowchart, IFlowchartEdge, IFlowchartNode } from '@/Core/Modules/flowchart'; +import { WebGAL } from '@/Core/WebGAL'; +import useSoundEffect from '@/hooks/useSoundEffect'; +import useTrans from '@/hooks/useTrans'; +import { setVisibility } from '@/store/GUIReducer'; +import { RootState } from '@/store/store'; +import { CloseSmall } from '@icon-park/react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styles from './flowchart.module.scss'; + +const NODE_WIDTH = 190; +const NODE_HEIGHT = 78; +const COL_GAP = 270; +const ROW_GAP = 170; +const MARGIN_X = 90; +const MARGIN_Y = 45; +const CONNECTOR_GAP = 12; + +interface LayoutNode extends IFlowchartNode { + x: number; + y: number; +} + +interface LayoutEdge extends IFlowchartEdge { + sourceNode: LayoutNode; + targetNode: LayoutNode; +} + +interface ConnectorSegment { + id: string; + d: string; + unlocked: boolean; + arrow: boolean; + layerY: number; +} + +export const Flowchart = () => { + const t = useTrans('gaming.flowchart.'); + const { playSeClick, playSeEnter } = useSoundEffect(); + const dispatch = useDispatch(); + const GUIStore = useSelector((state: RootState) => state.GUI); + const isOpen = GUIStore.showFlowchart; + const [indexHide, setIndexHide] = useState(true); + const [currentFlowchartId, setCurrentFlowchartId] = useState(''); + const [version, setVersion] = useState(0); + const timeRef = useRef<ReturnType<typeof setTimeout>>(); + const flowcharts = WebGAL.flowchartManager.getFlowcharts(); + const currentFlowchart = flowcharts.find((e) => e.id === currentFlowchartId) ?? flowcharts[0]; + const layout = useMemo(() => layoutFlowchart(currentFlowchart), [currentFlowchart?.id, version]); + + useEffect(() => { + const update = () => setVersion((v) => v + 1); + window.addEventListener(WebGAL.flowchartManager.getEventName(), update); + return () => window.removeEventListener(WebGAL.flowchartManager.getEventName(), update); + }, []); + + useEffect(() => { + if (!currentFlowchartId && flowcharts[0]) setCurrentFlowchartId(flowcharts[0].id); + }, [flowcharts[0]?.id, currentFlowchartId]); + + useEffect(() => { + if (isOpen) { + if (timeRef.current) clearTimeout(timeRef.current); + setIndexHide(false); + } else { + timeRef.current = setTimeout(() => setIndexHide(true), 780); + } + }, [isOpen]); + + const close = () => { + playSeClick(); + dispatch(setVisibility({ component: 'showFlowchart', visibility: false })); + dispatch(setVisibility({ component: 'showTextBox', visibility: true })); + }; + + const jumpToNode = (node: IFlowchartNode) => { + if (!currentFlowchart || !WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id)) return; + playSeClick(); + WebGAL.flowchartManager.loadSnapshot(currentFlowchart.id, node.id).then((snapshot) => { + if (!snapshot) return; + loadGameFromStageData(snapshot); + dispatch(setVisibility({ component: 'showFlowchart', visibility: false })); + dispatch(setVisibility({ component: 'showTextBox', visibility: true })); + }); + }; + + return ( + <div className={`${isOpen ? styles.Flowchart_main : styles.Flowchart_main_out} ${indexHide ? styles.Flowchart_main_out_IndexHide : ''}`}> + <div className={styles.flowchart_top}> + <CloseSmall + className={styles.flowchart_top_icon} + onClick={close} + onMouseEnter={playSeEnter} + theme="outline" + size="4em" + fill="#ffffff" + strokeWidth={3} + /> + <div className={styles.flowchart_title}>{t('title')}</div> + </div> + {isOpen && ( + <div className={styles.flowchart_body}> + <div className={styles.flowchart_sidebar}> + {flowcharts.map((flowchart) => ( + <button + type="button" + key={flowchart.id} + className={`${styles.flowchart_tab} ${currentFlowchart?.id === flowchart.id ? styles.flowchart_tab_active : ''}`} + onClick={() => { + playSeClick(); + setCurrentFlowchartId(flowchart.id); + }} + onMouseEnter={playSeEnter} + > + {flowchart.name} + </button> + ))} + </div> + <div className={styles.flowchart_content}> + {!currentFlowchart ? ( + <div className={styles.flowchart_empty}>{t('empty')}</div> + ) : ( + <svg + className={styles.flowchart_canvas} + width={layout.width} + height={layout.height} + viewBox={`0 0 ${layout.width} ${layout.height}`} + > + <defs> + <marker + id="flowchart-arrow-unlocked" + viewBox="0 0 10 10" + refX="8" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path className={styles.flowchart_arrow_unlocked} d="M 0 0 L 10 5 L 0 10 z" /> + </marker> + <marker + id="flowchart-arrow-locked" + viewBox="0 0 10 10" + refX="8" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path className={styles.flowchart_arrow_locked} d="M 0 0 L 10 5 L 0 10 z" /> + </marker> + </defs> + {getConnectorSegments(layout.edges, currentFlowchart.id) + .sort((a, b) => b.layerY - a.layerY) + .map((segment) => ( + <path + key={segment.id} + className={segment.unlocked ? styles.flowchart_line_unlocked : styles.flowchart_line_locked} + d={segment.d} + markerEnd={ + segment.arrow ? `url(#${segment.unlocked ? 'flowchart-arrow-unlocked' : 'flowchart-arrow-locked'})` : undefined + } + /> + ))} + {layout.nodes.map((node) => { + const unlocked = WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id); + const labelText = unlocked ? node.data?.label || node.id : t('locked'); + return ( + <g + key={node.id} + className={`${styles.flowchart_node} ${unlocked ? styles.flowchart_node_unlocked : styles.flowchart_node_locked}`} + role={unlocked ? 'button' : undefined} + tabIndex={unlocked ? 0 : -1} + onClick={() => unlocked && jumpToNode(node)} + onKeyDown={(event) => { + if (unlocked && (event.key === 'Enter' || event.key === ' ')) jumpToNode(node); + }} + onMouseEnter={unlocked ? playSeEnter : undefined} + > + <title>{labelText} + + + {truncateText(labelText, 10)} + + + ); + })} + + )} + + + )} + + ); +}; + +function layoutFlowchart(flowchart?: IFlowchart) { + if (!flowchart) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 }; + const nodeMap = new Map(flowchart.nodes.map((node) => [node.id, node])); + const validEdges = flowchart.edges.filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target)); + const incomingCount = new Map(flowchart.nodes.map((node) => [node.id, 0])); + const adjacency = new Map(); + const parentMap = new Map(); + validEdges.forEach((edge) => { + incomingCount.set(edge.target, (incomingCount.get(edge.target) ?? 0) + 1); + adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge]); + parentMap.set(edge.target, [...(parentMap.get(edge.target) ?? []), edge.source]); + }); + const roots = flowchart.nodes.filter((node) => node.data?.isRoot || (incomingCount.get(node.id) ?? 0) === 0); + const queue = (roots.length ? roots : flowchart.nodes.slice(0, 1)).map((node) => node.id); + const restIncomingCount = new Map(incomingCount); + const depthMap = new Map(flowchart.nodes.map((node) => [node.id, 0])); + const visited = new Set(); + while (queue.length > 0) { + const sourceId = queue.shift()!; + if (visited.has(sourceId)) continue; + visited.add(sourceId); + (adjacency.get(sourceId) ?? []).forEach((edge) => { + depthMap.set(edge.target, Math.max(depthMap.get(edge.target) ?? 0, (depthMap.get(sourceId) ?? 0) + 1)); + restIncomingCount.set(edge.target, (restIncomingCount.get(edge.target) ?? 0) - 1); + if ((restIncomingCount.get(edge.target) ?? 0) <= 0) queue.push(edge.target); + }); + } + const maxDepth = Math.max(0, ...depthMap.values()); + flowchart.nodes + .filter((node) => !visited.has(node.id)) + .forEach((node, index) => depthMap.set(node.id, Math.max(depthMap.get(node.id) ?? 0, maxDepth + index + 1))); + const layerMap = new Map(); + flowchart.nodes.forEach((node) => { + const depth = depthMap.get(node.id) ?? 0; + layerMap.set(depth, [...(layerMap.get(depth) ?? []), node]); + }); + const layers = [...layerMap.entries()] + .sort(([a], [b]) => a - b) + .map(([, layer]) => layer); + const maxCols = Math.max(1, ...layers.map((layer) => layer.length)); + const width = MARGIN_X * 2 + NODE_WIDTH + (maxCols - 1) * COL_GAP; + const layoutNodeMap = new Map(); + layers.forEach((layer, depth) => { + const layerWidth = NODE_WIDTH + (layer.length - 1) * COL_GAP; + layer.forEach((node, index) => { + const parentXs = (parentMap.get(node.id) ?? []).map((id) => layoutNodeMap.get(id)?.x).filter((x): x is number => typeof x === 'number'); + layoutNodeMap.set(node.id, { + ...node, + x: layer.length === 1 && parentXs.length > 0 ? parentXs.reduce((sum, x) => sum + x, 0) / parentXs.length : (width - layerWidth) / 2 + index * COL_GAP, + y: MARGIN_Y + depth * ROW_GAP, + }); + }); + }); + const nodes = [...layoutNodeMap.values()]; + const edges = validEdges + .map((edge) => ({ ...edge, sourceNode: layoutNodeMap.get(edge.source), targetNode: layoutNodeMap.get(edge.target) })) + .filter((edge): edge is LayoutEdge => Boolean(edge.sourceNode && edge.targetNode)); + return { nodes, edges, width, height: MARGIN_Y * 2 + NODE_HEIGHT + (layers.length - 1) * ROW_GAP }; +} + +function getConnectorSegments(edges: LayoutEdge[], flowchartId: string): ConnectorSegment[] { + const edgeGroups = new Map(); + edges.forEach((edge) => edgeGroups.set(edge.source, [...(edgeGroups.get(edge.source) ?? []), edge])); + return [...edgeGroups.values()].flatMap((groupEdges) => getConnectorSegmentsBySource(groupEdges, flowchartId)); +} + +function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string): ConnectorSegment[] { + if (edges.length === 0) return []; + const source = edges[0].sourceNode; + const sx = source.x + NODE_WIDTH / 2; + const sy = source.y + NODE_HEIGHT; + const sourceUnlocked = WebGAL.flowchartManager.isUnlocked(flowchartId, source.id); + if (edges.length === 1) { + const edge = edges[0]; + const tx = edge.targetNode.x + NODE_WIDTH / 2; + const ty = edge.targetNode.y - CONNECTOR_GAP; + const midY = sy + Math.max(30, (ty - sy) / 2); + return [ + { + id: edge.id, + d: sx === tx ? `M ${sx} ${sy} V ${ty}` : `M ${sx} ${sy} V ${midY} H ${tx} V ${ty}`, + unlocked: sourceUnlocked && WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target), + arrow: true, + layerY: ty, + }, + ]; + } + const targetXs = edges.map((edge) => edge.targetNode.x + NODE_WIDTH / 2); + const targetY = Math.min(...edges.map((edge) => edge.targetNode.y)) - CONNECTOR_GAP; + const busY = sy + Math.max(34, (targetY - sy) / 2); + const hasUnlockedTarget = edges.some((edge) => WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target)); + const segments: ConnectorSegment[] = [ + { id: `${source.id}-trunk`, d: `M ${sx} ${sy} V ${busY}`, unlocked: sourceUnlocked && hasUnlockedTarget, arrow: false, layerY: busY }, + { + id: `${source.id}-bus`, + d: `M ${Math.min(...targetXs)} ${busY} H ${Math.max(...targetXs)}`, + unlocked: sourceUnlocked && hasUnlockedTarget, + arrow: false, + layerY: busY, + }, + ]; + edges.forEach((edge) => { + const tx = edge.targetNode.x + NODE_WIDTH / 2; + segments.push({ + id: edge.id, + d: `M ${tx} ${busY} V ${edge.targetNode.y - CONNECTOR_GAP}`, + unlocked: sourceUnlocked && WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target), + arrow: true, + layerY: edge.targetNode.y - CONNECTOR_GAP, + }); + }); + return segments; +} + +function truncateText(text: string | undefined, maxLength: number) { + const safeText = text ?? ''; + return safeText.length > maxLength ? `${safeText.slice(0, maxLength)}...` : safeText; +} diff --git a/packages/webgal/src/UI/Flowchart/flowchart.module.scss b/packages/webgal/src/UI/Flowchart/flowchart.module.scss new file mode 100644 index 000000000..c6d16559d --- /dev/null +++ b/packages/webgal/src/UI/Flowchart/flowchart.module.scss @@ -0,0 +1,196 @@ +.Flowchart_main { + font-family: "思源宋体", serif; + position: absolute; + top: 0; + width: 100%; + height: 100%; + z-index: 10; + background: rgba(0, 0, 0, 0.8); + padding: 2em 0; + animation: flowchart_soft_in 0.7s ease-out forwards; + box-sizing: border-box; +} + +.Flowchart_main_out { + @extend .Flowchart_main; + animation: flowchart_soft_out 0.7s ease-out forwards; +} + +.Flowchart_main_out_IndexHide { + z-index: -10; +} + +.flowchart_top { + padding: 0 0 0 1em; + display: flex; + height: 10%; +} + +.flowchart_top_icon { + padding: 0.6em 0.6em 0 0.6em; + border-radius: 1000px; + transform: translate(0, -13px); + cursor: pointer; +} + +.flowchart_top_icon:hover { + background: rgba(255, 255, 255, 0.25); +} + +.flowchart_title { + height: 100%; + line-height: 100%; + font-size: 360%; + font-weight: bold; + color: transparent; + background: linear-gradient(150deg, #fff 0%, #fff 35%, rgba(165, 212, 228, 1) 100%); + -webkit-background-clip: text; +} + +.flowchart_body { + display: flex; + gap: 2.5em; + height: 82%; + padding: 0.5em 5em 1em 5em; + box-sizing: border-box; +} + +.flowchart_sidebar { + width: 18em; + flex-shrink: 0; + overflow: auto; + padding: 0.5em 0; +} + +.flowchart_tab { + display: block; + width: 100%; + margin: 0 0 0.75em 0; + padding: 0.65em 0.8em; + border: none; + border-radius: 7px; + color: rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 255, 0.08); + text-align: left; + font-size: 135%; + cursor: pointer; + transition: background-color 0.25s, color 0.25s; +} + +.flowchart_tab:hover, +.flowchart_tab_active { + color: #fff; + background: rgba(255, 255, 255, 0.22); +} + +.flowchart_content { + flex: 1; + overflow: auto; + padding: 0.5em; + box-sizing: border-box; + display: flex; +} + +.flowchart_empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.7); + font-size: 180%; +} + +.flowchart_canvas { + display: block; + flex: 0 0 auto; + margin: 0 auto; + overflow: visible; +} + +.flowchart_arrow_unlocked { + fill: #f5f5f5; + fill-opacity: 1; +} + +.flowchart_arrow_locked { + fill: #8d8d8d; + fill-opacity: 1; +} + +.flowchart_line_unlocked, +.flowchart_line_locked { + fill: none; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + stroke-opacity: 1; + pointer-events: none; +} + +.flowchart_line_unlocked { + stroke: #f5f5f5; +} + +.flowchart_line_locked { + stroke: #8d8d8d; +} + +.flowchart_node { + font-family: "资源圆体", serif; + outline: none; +} + +.flowchart_node_unlocked { + cursor: pointer; +} + +.flowchart_node_locked { + opacity: 0.45; + cursor: default; +} + +.flowchart_node_rect { + transition: fill 0.25s, stroke 0.25s; +} + +.flowchart_node_rect_unlocked { + fill: rgba(255, 255, 255, 0.12); + stroke: rgba(255, 255, 255, 0.42); + stroke-width: 1; +} + +.flowchart_node_rect_locked { + fill: rgba(255, 255, 255, 0.08); + stroke: rgba(255, 255, 255, 0.28); + stroke-width: 1; +} + +.flowchart_node_unlocked:hover .flowchart_node_rect_unlocked { + fill: rgba(255, 255, 255, 0.24); + stroke: rgba(255, 255, 255, 0.72); +} + +.flowchart_node_label { + fill: #fff; + font-size: 22px; + font-weight: bold; + pointer-events: none; +} + +@keyframes flowchart_soft_in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes flowchart_soft_out { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/packages/webgal/src/UI/Menu/MenuPanel/MenuIconMap.tsx b/packages/webgal/src/UI/Menu/MenuPanel/MenuIconMap.tsx index 460c2d2f2..1d3a44fc0 100644 --- a/packages/webgal/src/UI/Menu/MenuPanel/MenuIconMap.tsx +++ b/packages/webgal/src/UI/Menu/MenuPanel/MenuIconMap.tsx @@ -1,5 +1,5 @@ import { IMenuPanel } from '@/UI/Menu/MenuPanel/menuPanelInterface'; -import { FolderOpen, Home, Logout, Save, SettingTwo } from '@icon-park/react'; +import { FolderOpen, Home, Logout, Save, SettingTwo, TreeDiagram } from '@icon-park/react'; /** * 通过图标名称返回正确的图标 @@ -12,6 +12,9 @@ export const MenuIconMap = (props: IMenuPanel) => { case 'save': returnIcon = ; break; + case 'flowchart': + returnIcon = ; + break; case 'load': returnIcon = ; break; diff --git a/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx b/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx index 85e1100d6..628dda817 100644 --- a/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx +++ b/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx @@ -20,6 +20,7 @@ export const MenuPanel = () => { const { playSeClick, playSeDialogOpen, playSePageChange } = useSoundEffect(); const GUIState = useSelector((state: RootState) => state.GUI); + const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); const dispatch = useDispatch(); // 设置Menu按钮的高亮 const SaveTagOn = GUIState.currentMenuTag === MenuPanelTag.Save ? ` ${styles.MenuPanel_button_hl}` : ``; @@ -41,6 +42,21 @@ export const MenuPanel = () => { return (
+ {enableFlowchart && ( + { + playSePageChange(); + dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); + dispatch(setVisibility({ component: 'showFlowchart', visibility: true })); + dispatch(setVisibility({ component: 'showTextBox', visibility: false })); + }} + tagName={t('flowchart.title')} + key="flowchartButton" + /> + )} { dispatch(resetAllData()); + WebGAL.flowchartManager.clearProgress(); dumpToStorageFast(); dispatch(saveActions.resetSaves()); dumpSavesToStorage(0, 200); diff --git a/packages/webgal/src/hooks/useHotkey.tsx b/packages/webgal/src/hooks/useHotkey.tsx index f8aeb6176..07d73ef2c 100644 --- a/packages/webgal/src/hooks/useHotkey.tsx +++ b/packages/webgal/src/hooks/useHotkey.tsx @@ -57,6 +57,7 @@ export function useMouseRightClickHotKey() { const setComponentVisibility = useSetComponentVisibility(); const isGameActive = useGameActive(GUIStore); const isInBackLog = useIsInBackLog(GUIStore); + const isInFlowchart = useIsInFlowchart(GUIStore); const isOpenedDialog = useIsOpenedDialog(GUIStore); const validMenuPanelTag = useValidMenuPanelTag(GUIStore); const isShowExtra = useIsOpenedExtra(GUIStore); @@ -76,6 +77,10 @@ export function useMouseRightClickHotKey() { setComponentVisibility('showBacklog', false); setComponentVisibility('showTextBox', true); } + if (isInFlowchart()) { + setComponentVisibility('showFlowchart', false); + setComponentVisibility('showTextBox', true); + } if (validMenuPanelTag()) { setComponentVisibility('showMenuPanel', false); } @@ -250,6 +255,7 @@ function useGameActive(GUIStore: T & any): () => boolean { !GUIStore.current.showTitle && !GUIStore.current.showMenuPanel && !GUIStore.current.showBacklog && + !GUIStore.current.showFlowchart && !GUIStore.current.showPanicOverlay ); }, [GUIStore]); @@ -262,6 +268,12 @@ function useIsInBackLog(GUIStore: T & any): () => boolean { }, [GUIStore]); } +function useIsInFlowchart(GUIStore: T & any): () => boolean { + return useCallback(() => { + return GUIStore.current.showFlowchart; + }, [GUIStore]); +} + // 判断是否打开了全局对话框 function useIsOpenedDialog(GUIStore: T & any): () => boolean { return useCallback(() => { diff --git a/packages/webgal/src/store/GUIReducer.ts b/packages/webgal/src/store/GUIReducer.ts index 7d87e2439..8c1357fd6 100644 --- a/packages/webgal/src/store/GUIReducer.ts +++ b/packages/webgal/src/store/GUIReducer.ts @@ -16,6 +16,7 @@ import { DEFAULT_FONT_OPTIONS } from '@/Core/util/fonts/fontOptions'; const initState: IGuiState = { fontOptions: [...DEFAULT_FONT_OPTIONS], showBacklog: false, + showFlowchart: false, showStarter: true, showTitle: true, showMenuPanel: false, diff --git a/packages/webgal/src/store/guiInterface.ts b/packages/webgal/src/store/guiInterface.ts index 1fc4fdaeb..24df09b7c 100644 --- a/packages/webgal/src/store/guiInterface.ts +++ b/packages/webgal/src/store/guiInterface.ts @@ -20,6 +20,7 @@ export interface IGuiState { controlsVisibility: boolean; currentMenuTag: MenuPanelTag; // 当前Menu界面的选项卡 showBacklog: boolean; + showFlowchart: boolean; titleBgm: string; // 标题背景音乐 titleBg: string; // 标题背景图片 logoImage: string[]; diff --git a/packages/webgal/src/translations/de.ts b/packages/webgal/src/translations/de.ts index a40875a38..cfd7e3488 100644 --- a/packages/webgal/src/translations/de.ts +++ b/packages/webgal/src/translations/de.ts @@ -127,6 +127,9 @@ const de = { loadSaving: { title: 'LADEN', }, + flowchart: { + title: 'ABLAUF', + }, title: { title: 'TITEL', }, @@ -169,6 +172,7 @@ const de = { hide: 'Verstecken', show: 'Anzeigen', backlog: 'Verlauf', + flowchart: 'Ablauf', replay: 'Wiedergabe', auto: 'Auto', forward: 'Überspringen', @@ -180,6 +184,15 @@ const de = { options: 'Optionen', title: 'Titel', }, + flowchart: { + title: 'Ablauf', + empty: 'Kein Ablauf', + locked: 'Gesperrt', + main: 'Haupt', + character: 'Route', + root: 'Start', + chapter: 'Kapitel', + }, }, extra: { diff --git a/packages/webgal/src/translations/en.ts b/packages/webgal/src/translations/en.ts index 801e41296..e3614da57 100644 --- a/packages/webgal/src/translations/en.ts +++ b/packages/webgal/src/translations/en.ts @@ -134,6 +134,9 @@ const en = { loadSaving: { title: 'LOAD', }, + flowchart: { + title: 'FLOWCHART', + }, title: { title: 'TITLE', }, @@ -176,6 +179,7 @@ const en = { hide: 'Hide', show: 'Show', backlog: 'Backlog', + flowchart: 'Flowchart', replay: 'Replay', auto: 'Auto', forward: 'Forward', @@ -188,6 +192,15 @@ const en = { title: 'Title', titleTips: 'Confirm return to the title screen', }, + flowchart: { + title: 'Flowchart', + empty: 'No flowchart', + locked: 'Locked', + main: 'Main', + character: 'Route', + root: 'Start', + chapter: 'Chapter', + }, }, extra: { diff --git a/packages/webgal/src/translations/fr.ts b/packages/webgal/src/translations/fr.ts index faf3296dd..a3309e227 100644 --- a/packages/webgal/src/translations/fr.ts +++ b/packages/webgal/src/translations/fr.ts @@ -127,6 +127,9 @@ const fr = { loadSaving: { title: 'CHARGER', }, + flowchart: { + title: 'PARCOURS', + }, title: { title: 'TITRE', }, @@ -169,6 +172,7 @@ const fr = { hide: 'Masquer', show: 'Afficher', backlog: 'Journal', + flowchart: 'Parcours', replay: 'Rejouer', auto: 'Automatique', forward: 'Avancer', @@ -181,6 +185,15 @@ const fr = { title: 'Titre', titleTips: "Confirmer le retour à l'écran titre ?", }, + flowchart: { + title: 'Parcours', + empty: 'Aucun parcours', + locked: 'Verrouillé', + main: 'Principal', + character: 'Route', + root: 'Début', + chapter: 'Chapitre', + }, }, extra: { diff --git a/packages/webgal/src/translations/jp.ts b/packages/webgal/src/translations/jp.ts index d54154471..dcf96ba74 100644 --- a/packages/webgal/src/translations/jp.ts +++ b/packages/webgal/src/translations/jp.ts @@ -137,6 +137,9 @@ const jp = { loadSaving: { title: 'LOAD', }, + flowchart: { + title: 'FLOWCHART', + }, title: { title: 'HOME', }, @@ -179,6 +182,7 @@ const jp = { hide: 'CLOSE', show: 'SHOW', backlog: 'LOG', + flowchart: 'FLOWCHART', replay: 'REPLAY', auto: 'AUTO', forward: 'SKIP', @@ -191,6 +195,15 @@ const jp = { title: 'HOME', titleTips: 'タイトル画面に戻りますか?', }, + flowchart: { + title: 'Flowchart', + empty: 'フローチャートなし', + locked: '未解放', + main: 'メイン', + character: 'ルート', + root: '開始', + chapter: '章', + }, }, extra: { diff --git a/packages/webgal/src/translations/ko.ts b/packages/webgal/src/translations/ko.ts index 8bd161988..40f8b60ff 100644 --- a/packages/webgal/src/translations/ko.ts +++ b/packages/webgal/src/translations/ko.ts @@ -137,6 +137,9 @@ const ko = { loadSaving: { title: '불러오기', }, + flowchart: { + title: '플로차트', + }, title: { title: '타이틀', options: { @@ -183,6 +186,7 @@ const ko = { hide: '숨김', show: '표시', backlog: '백로그', + flowchart: '플로차트', replay: '다시 재생', auto: '자동 재생', forward: '빠른 재생', @@ -195,6 +199,15 @@ const ko = { title: '메인 화면', titleTips: '메인 화면으로 돌아가시겠습니까?', }, + flowchart: { + title: '플로차트', + empty: '플로차트 없음', + locked: '잠김', + main: '메인', + character: '루트', + root: '시작', + chapter: '챕터', + }, }, extra: { diff --git a/packages/webgal/src/translations/pt-br.ts b/packages/webgal/src/translations/pt-br.ts index af45456e2..1fb979990 100644 --- a/packages/webgal/src/translations/pt-br.ts +++ b/packages/webgal/src/translations/pt-br.ts @@ -134,6 +134,9 @@ const ptBr = { loadSaving: { title: 'CARREGAR', }, + flowchart: { + title: 'FLUXO', + }, title: { title: 'TÍTULO', }, @@ -176,6 +179,7 @@ const ptBr = { hide: 'Esconder', show: 'Exibir', backlog: 'Histórico', + flowchart: 'Fluxo', replay: 'Repetir', auto: 'Auto', forward: 'Avançar', @@ -188,6 +192,15 @@ const ptBr = { title: 'Título', titleTips: 'Confirma o retorno para a tela de título', }, + flowchart: { + title: 'Fluxo', + empty: 'Nenhum fluxo', + locked: 'Bloqueado', + main: 'Principal', + character: 'Rota', + root: 'Início', + chapter: 'Capítulo', + }, }, extra: { diff --git a/packages/webgal/src/translations/zh-cn.ts b/packages/webgal/src/translations/zh-cn.ts index 8a178b558..797f95899 100644 --- a/packages/webgal/src/translations/zh-cn.ts +++ b/packages/webgal/src/translations/zh-cn.ts @@ -137,6 +137,9 @@ const zhCn = { loadSaving: { title: '读档', }, + flowchart: { + title: '流程图', + }, title: { title: '标题', options: { @@ -183,6 +186,7 @@ const zhCn = { hide: '隐藏', show: '显示', backlog: '回想', + flowchart: '流程图', replay: '重播', auto: '自动', forward: '快进', @@ -195,6 +199,15 @@ const zhCn = { title: '标题', titleTips: '确定要返回标题界面吗?', }, + flowchart: { + title: '流程图', + empty: '暂无流程图', + locked: '未解锁', + main: '主线', + character: '个人线', + root: '开始', + chapter: '章节', + }, }, extra: { diff --git a/packages/webgal/src/translations/zh-tw.ts b/packages/webgal/src/translations/zh-tw.ts index 0da6c9f43..1cd6f8a0f 100644 --- a/packages/webgal/src/translations/zh-tw.ts +++ b/packages/webgal/src/translations/zh-tw.ts @@ -137,6 +137,9 @@ const zhTw = { loadSaving: { title: '讀檔', }, + flowchart: { + title: '流程圖', + }, title: { title: '主選單', options: { @@ -183,6 +186,7 @@ const zhTw = { hide: '隱藏', show: '顯示', backlog: '回想', + flowchart: '流程圖', replay: '重播', auto: '自動', forward: '加速', @@ -195,6 +199,15 @@ const zhTw = { title: '主選單', titleTips: '確定要返回主選單嗎?', }, + flowchart: { + title: '流程圖', + empty: '暫無流程圖', + locked: '未解鎖', + main: '主線', + character: '個人線', + root: '開始', + chapter: '章節', + }, }, extra: { From f3ee885e2a46c9e206c0e5b376c5c29029323642 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 14 Jun 2026 00:17:26 +0800 Subject: [PATCH 05/39] fix: flowchart ui --- .../webgal/src/UI/Flowchart/Flowchart.tsx | 76 ++++++++++++++----- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx index 6dade4311..e202fed18 100644 --- a/packages/webgal/src/UI/Flowchart/Flowchart.tsx +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -10,9 +10,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styles from './flowchart.module.scss'; -const NODE_WIDTH = 190; +const NODE_MIN_WIDTH = 190; const NODE_HEIGHT = 78; -const COL_GAP = 270; +const NODE_GAP = 80; const ROW_GAP = 170; const MARGIN_X = 90; const MARGIN_Y = 45; @@ -21,6 +21,7 @@ const CONNECTOR_GAP = 12; interface LayoutNode extends IFlowchartNode { x: number; y: number; + width: number; } interface LayoutEdge extends IFlowchartEdge { @@ -186,12 +187,12 @@ export const Flowchart = () => { }`} x={node.x} y={node.y} - width={NODE_WIDTH} + width={node.width} height={NODE_HEIGHT} rx="7" /> - {truncateText(labelText, 10)} + {labelText} ); @@ -244,18 +245,31 @@ function layoutFlowchart(flowchart?: IFlowchart) { const layers = [...layerMap.entries()] .sort(([a], [b]) => a - b) .map(([, layer]) => layer); - const maxCols = Math.max(1, ...layers.map((layer) => layer.length)); - const width = MARGIN_X * 2 + NODE_WIDTH + (maxCols - 1) * COL_GAP; + const nodeWidthMap = new Map(flowchart.nodes.map((node) => [node.id, getNodeWidth(node)])); + const layerWidths = layers.map((layer) => layer.reduce((sum, node) => sum + (nodeWidthMap.get(node.id) ?? NODE_MIN_WIDTH), 0) + (layer.length - 1) * NODE_GAP); + const width = MARGIN_X * 2 + Math.max(NODE_MIN_WIDTH, ...layerWidths); const layoutNodeMap = new Map(); layers.forEach((layer, depth) => { - const layerWidth = NODE_WIDTH + (layer.length - 1) * COL_GAP; - layer.forEach((node, index) => { - const parentXs = (parentMap.get(node.id) ?? []).map((id) => layoutNodeMap.get(id)?.x).filter((x): x is number => typeof x === 'number'); + const layerWidth = layerWidths[depth]; + let x = (width - layerWidth) / 2; + layer.forEach((node) => { + const nodeWidth = nodeWidthMap.get(node.id) ?? NODE_MIN_WIDTH; + const parentXs = (parentMap.get(node.id) ?? []) + .map((id) => { + const parent = layoutNodeMap.get(id); + return parent ? parent.x + parent.width / 2 : undefined; + }) + .filter((parentX): parentX is number => typeof parentX === 'number'); layoutNodeMap.set(node.id, { ...node, - x: layer.length === 1 && parentXs.length > 0 ? parentXs.reduce((sum, x) => sum + x, 0) / parentXs.length : (width - layerWidth) / 2 + index * COL_GAP, + width: nodeWidth, + x: + layer.length === 1 && parentXs.length > 0 + ? parentXs.reduce((sum, parentX) => sum + parentX, 0) / parentXs.length - nodeWidth / 2 + : x, y: MARGIN_Y + depth * ROW_GAP, }); + x += nodeWidth + NODE_GAP; }); }); const nodes = [...layoutNodeMap.values()]; @@ -265,34 +279,54 @@ function layoutFlowchart(flowchart?: IFlowchart) { return { nodes, edges, width, height: MARGIN_Y * 2 + NODE_HEIGHT + (layers.length - 1) * ROW_GAP }; } +function getNodeWidth(node: IFlowchartNode) { + const label = node.data?.label || node.id; + const textWidth = Array.from(label).reduce((sum, char) => sum + (char.charCodeAt(0) > 255 ? 24 : 14), 0); + return Math.max(NODE_MIN_WIDTH, textWidth + 36); +} + function getConnectorSegments(edges: LayoutEdge[], flowchartId: string): ConnectorSegment[] { + const targetBendYMap = getTargetBendYMap(edges); const edgeGroups = new Map(); edges.forEach((edge) => edgeGroups.set(edge.source, [...(edgeGroups.get(edge.source) ?? []), edge])); - return [...edgeGroups.values()].flatMap((groupEdges) => getConnectorSegmentsBySource(groupEdges, flowchartId)); + return [...edgeGroups.values()].flatMap((groupEdges) => getConnectorSegmentsBySource(groupEdges, flowchartId, targetBendYMap)); +} + +function getTargetBendYMap(edges: LayoutEdge[]) { + const targetGroups = new Map(); + edges.forEach((edge) => targetGroups.set(edge.target, [...(targetGroups.get(edge.target) ?? []), edge])); + const bendYMap = new Map(); + targetGroups.forEach((targetEdges, target) => { + if (targetEdges.length < 2) return; + const ty = targetEdges[0].targetNode.y - CONNECTOR_GAP; + const maxSourceY = Math.max(...targetEdges.map((edge) => edge.sourceNode.y + NODE_HEIGHT)); + bendYMap.set(target, getSingleEdgeBendY(maxSourceY, ty)); + }); + return bendYMap; } -function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string): ConnectorSegment[] { +function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string, targetBendYMap: Map): ConnectorSegment[] { if (edges.length === 0) return []; const source = edges[0].sourceNode; - const sx = source.x + NODE_WIDTH / 2; + const sx = source.x + source.width / 2; const sy = source.y + NODE_HEIGHT; const sourceUnlocked = WebGAL.flowchartManager.isUnlocked(flowchartId, source.id); if (edges.length === 1) { const edge = edges[0]; - const tx = edge.targetNode.x + NODE_WIDTH / 2; + const tx = edge.targetNode.x + edge.targetNode.width / 2; const ty = edge.targetNode.y - CONNECTOR_GAP; - const midY = sy + Math.max(30, (ty - sy) / 2); + const bendY = targetBendYMap.get(edge.target) ?? getSingleEdgeBendY(sy, ty); return [ { id: edge.id, - d: sx === tx ? `M ${sx} ${sy} V ${ty}` : `M ${sx} ${sy} V ${midY} H ${tx} V ${ty}`, + d: sx === tx ? `M ${sx} ${sy} V ${ty}` : `M ${sx} ${sy} V ${bendY} H ${tx} V ${ty}`, unlocked: sourceUnlocked && WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target), arrow: true, layerY: ty, }, ]; } - const targetXs = edges.map((edge) => edge.targetNode.x + NODE_WIDTH / 2); + const targetXs = edges.map((edge) => edge.targetNode.x + edge.targetNode.width / 2); const targetY = Math.min(...edges.map((edge) => edge.targetNode.y)) - CONNECTOR_GAP; const busY = sy + Math.max(34, (targetY - sy) / 2); const hasUnlockedTarget = edges.some((edge) => WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target)); @@ -307,7 +341,7 @@ function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string): }, ]; edges.forEach((edge) => { - const tx = edge.targetNode.x + NODE_WIDTH / 2; + const tx = edge.targetNode.x + edge.targetNode.width / 2; segments.push({ id: edge.id, d: `M ${tx} ${busY} V ${edge.targetNode.y - CONNECTOR_GAP}`, @@ -319,7 +353,7 @@ function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string): return segments; } -function truncateText(text: string | undefined, maxLength: number) { - const safeText = text ?? ''; - return safeText.length > maxLength ? `${safeText.slice(0, maxLength)}...` : safeText; +function getSingleEdgeBendY(sy: number, ty: number) { + const spanY = ty - sy; + return spanY > ROW_GAP ? ty - Math.max(34, Math.min(70, spanY / 4)) : sy + Math.max(30, spanY / 2); } From f5948710232ef9d2d2fd91429ccc037c706222f9 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 14 Jun 2026 11:41:01 +0800 Subject: [PATCH 06/39] =?UTF-8?q?fix:=20=E6=B5=81=E7=A8=8B=E5=9B=BE?= =?UTF-8?q?=E8=A7=A3=E9=94=81=E5=92=8C=E5=86=99=E5=85=A5=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgal/src/Core/Modules/flowchart.ts | 39 ++++++++++++++++--- .../Core/controller/gamePlay/nextSentence.ts | 1 + .../controller/gamePlay/startContinueGame.ts | 1 + .../src/Core/controller/scene/callScene.ts | 2 +- .../src/Core/controller/scene/changeScene.ts | 2 +- packages/webgal/src/Core/gameScripts/say.ts | 1 + packages/webgal/src/Core/initializeScript.ts | 2 +- 7 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/webgal/src/Core/Modules/flowchart.ts b/packages/webgal/src/Core/Modules/flowchart.ts index bb450a177..aece2596e 100644 --- a/packages/webgal/src/Core/Modules/flowchart.ts +++ b/packages/webgal/src/Core/Modules/flowchart.ts @@ -44,6 +44,8 @@ export class FlowchartManager { private data: IFlowchartData = { flowcharts: [] }; private unlocked = new Set(); private snapshots = new Map(); + private pendingUnlockCurrentScene = false; + private waitingUnlockSceneKey = ''; public constructor(private readonly sceneManager: SceneManager) {} @@ -59,7 +61,7 @@ export class FlowchartManager { this.data = normalizeFlowchartData(res.data); const unlocked = await localforage.getItem(this.progressKey()); this.unlocked = new Set(Array.isArray(unlocked) ? unlocked : []); - this.unlockCurrentScene(); + this.unlockPendingCurrentScene(); } catch { this.data = { flowcharts: [] }; } @@ -81,6 +83,23 @@ export class FlowchartManager { return this.unlocked.has(this.nodeKey(flowchartId, nodeId)); } + public requestUnlockCurrentScene() { + if (this.currentSceneKey() !== this.waitingUnlockSceneKey) return; + this.pendingUnlockCurrentScene = true; + } + + public unlockPendingCurrentScene() { + if (!this.pendingUnlockCurrentScene || !this.hasFlowchart()) return; + this.pendingUnlockCurrentScene = false; + this.waitingUnlockSceneKey = ''; + this.unlockCurrentScene(true); + } + + public waitForCurrentSceneDialog() { + this.pendingUnlockCurrentScene = false; + this.waitingUnlockSceneKey = this.currentSceneKey(); + } + public async loadSnapshot(flowchartId: string, nodeId: string) { const key = this.nodeKey(flowchartId, nodeId); if (!this.unlocked.has(key)) return null; @@ -98,7 +117,7 @@ export class FlowchartManager { window.dispatchEvent(new Event(FLOWCHART_UPDATED_EVENT)); } - public unlockCurrentScene() { + public unlockCurrentScene(refreshSnapshot = false) { if (!this.hasFlowchart()) return; const sceneNames = new Set([ normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneName), @@ -114,9 +133,12 @@ export class FlowchartManager { let changed = false; matched.forEach(({ flowchart, node }) => { const key = this.nodeKey(flowchart.id, node.id); - if (this.unlocked.has(key)) return; - changed = true; - this.unlocked.add(key); + const isUnlocked = this.unlocked.has(key); + if (isUnlocked && !refreshSnapshot) return; + if (!isUnlocked) { + changed = true; + this.unlocked.add(key); + } this.snapshots.set(key, snapshot); localforage.setItem(this.snapshotKey(flowchart.id, node.id), snapshot); }); @@ -128,7 +150,7 @@ export class FlowchartManager { private createSnapshot(): ISaveData { return { - nowStageState: cloneDeep(stageStateManager.getCalculationStageState()), + nowStageState: cloneDeep(stageStateManager.getViewStageState()), backlog: [], index: -1, saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }), @@ -157,6 +179,11 @@ export class FlowchartManager { private nodeKey(flowchartId: string, nodeId: string) { return `${flowchartId}-${nodeId}`; } + + private currentSceneKey() { + const { currentScene } = this.sceneManager.sceneData; + return `${normalizeSceneName(currentScene.sceneName)}|${normalizeSceneName(currentScene.sceneUrl)}`; + } } function normalizeFlowchartData(raw: string | IFlowchartData): IFlowchartData { diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index ab226a8c9..a1a32df85 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -61,6 +61,7 @@ export const commitForward = () => { stageStateManager.commit({ applyPixiEffects: false }); WebGAL.gameplay.performController.commitPendingPerforms(); stageStateManager.applyCommittedPixiEffects(); + WebGAL.flowchartManager.unlockPendingCurrentScene(); }; /** diff --git a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts index 67ec27c09..8edd4cac6 100644 --- a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts +++ b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts @@ -23,6 +23,7 @@ export const startGame = () => { // 场景写入到运行时 sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); + WebGAL.flowchartManager.waitForCurrentSceneDialog(); // 开始第一条语句 nextSentence(); }); diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index bbbe920bd..6f143aa90 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -31,7 +31,7 @@ export const callScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - WebGAL.flowchartManager.unlockCurrentScene(); + WebGAL.flowchartManager.waitForCurrentSceneDialog(); logger.debug('现在调用场景,调用结果:', WebGAL.sceneManager.sceneData); shouldAutoNext = !isFastPreviewSceneWrite; }) diff --git a/packages/webgal/src/Core/controller/scene/changeScene.ts b/packages/webgal/src/Core/controller/scene/changeScene.ts index a9d02fd38..3f39f4aaf 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -25,7 +25,7 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - WebGAL.flowchartManager.unlockCurrentScene(); + WebGAL.flowchartManager.waitForCurrentSceneDialog(); logger.debug('现在切换场景,切换后的结果:', WebGAL.sceneManager.sceneData); shouldAutoNext = !isFastPreviewSceneWrite; }) diff --git a/packages/webgal/src/Core/gameScripts/say.ts b/packages/webgal/src/Core/gameScripts/say.ts index 9e2fd94bf..0319e8b68 100644 --- a/packages/webgal/src/Core/gameScripts/say.ts +++ b/packages/webgal/src/Core/gameScripts/say.ts @@ -42,6 +42,7 @@ export const say = (sentence: ISentence): IPerform => { // 设置文本显示 stageStateManager.setStage('showText', dialogToShow); + WebGAL.flowchartManager.requestUnlockCurrentScene(); stageStateManager.setStage('vocal', ''); // 清除语音 diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index f323400f5..80af20d46 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -51,7 +51,7 @@ export const initializeScript = (): void => { const initialSceneReady = sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - WebGAL.flowchartManager.unlockCurrentScene(); + WebGAL.flowchartManager.waitForCurrentSceneDialog(); }); // 获取游戏信息 infoFetcher('./game/config.txt'); From 37b0c40efdba0ffcfcd8fa5029558b2a6c41903c Mon Sep 17 00:00:00 2001 From: PleGGen Date: Sat, 13 Jun 2026 19:17:03 +0800 Subject: [PATCH 07/39] fix(backlog): stop other backlog audio and game vocal on click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog 界面点击语音按钮时只调用 play(),未暂停其他 正在播放的 backlog 音频元素,导致多条语音同时播放。 同时未暂停游戏内正在播放的 currentVocal 语音, 点击 backlog 语音时与游戏内语音混合。 通过 querySelectorAll 匹配所有 backlog audio 元素, 在播放新语音前暂停并重置其他元素;同时暂停并重置 currentVocal 元素以避免与 backlog 语音混合。 Closes #866 --- packages/webgal/src/UI/Backlog/Backlog.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/webgal/src/UI/Backlog/Backlog.tsx b/packages/webgal/src/UI/Backlog/Backlog.tsx index 878f79f20..89adba5cb 100644 --- a/packages/webgal/src/UI/Backlog/Backlog.tsx +++ b/packages/webgal/src/UI/Backlog/Backlog.tsx @@ -127,10 +127,24 @@ export const Backlog = () => {
{ playSeClick(); + // 暂停其他所有正在播放的 backlog 语音,避免多条音频混合 + const currentAudioId = 'backlog_audio_play_element_' + indexOfBacklog; + document + .querySelectorAll('[id^="backlog_audio_play_element_"]') + .forEach((audio: any) => { + if (audio.id !== currentAudioId) { + audio.pause(); + audio.currentTime = 0; + } + }); + // 暂停游戏内正在播放的语音,避免与 backlog 语音混合 + const currentVocal = document.getElementById('currentVocal') as HTMLAudioElement | null; + if (currentVocal) { + currentVocal.pause(); + currentVocal.currentTime = 0; + } // 获取到播放 backlog 语音的元素 - const backlog_audio_element: any = document.getElementById( - 'backlog_audio_play_element_' + indexOfBacklog, - ); + const backlog_audio_element: any = document.getElementById(currentAudioId); if (backlog_audio_element) { backlog_audio_element.currentTime = 0; const userDataStore = webgalStore.getState().userData; From 5b5b79dc874ae2592c1d97ac8300218f554d57e1 Mon Sep 17 00:00:00 2001 From: PleGGen Date: Sat, 13 Jun 2026 19:17:28 +0800 Subject: [PATCH 08/39] fix(autoplay): pause auto play while Backlog is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog 打开时自动播放不应推进剧情,否则在历史界面 点选 backlog 语音暂停 currentVocal 后,auto 会立即推进 到下一句对话并触发新的 vocal 播放,导致 Backlog 中听到 两条声音。 - autoPlay 在 Backlog 打开时直接 return,避免推进 - Backlog 点击 backlog 语音时同时 unmount vocal-play perform,清理外部 pause 留下的状态不一致 属于 #866 修复的延伸场景。 --- packages/webgal/src/Core/controller/gamePlay/autoPlay.ts | 4 ++++ packages/webgal/src/UI/Backlog/Backlog.tsx | 2 ++ 2 files changed, 6 insertions(+) diff --git a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts index 575ae7d41..9f2052fc3 100644 --- a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts +++ b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts @@ -59,6 +59,10 @@ const autoPlay = () => { const data = webgalStore.getState().userData.optionData.autoSpeed; // 范围为 [250, 1750] const autoPlayDelay = 250 + (100 - data) * 15; + // Backlog 打开时不推进,避免在历史界面里自动播放导致剧情继续 + if (webgalStore.getState().GUI.showBacklog) { + return; + } let isBlockingAuto = false; WebGAL.gameplay.performController.performList.forEach((e) => { if (e.blockingAuto()) diff --git a/packages/webgal/src/UI/Backlog/Backlog.tsx b/packages/webgal/src/UI/Backlog/Backlog.tsx index 89adba5cb..d33188c8a 100644 --- a/packages/webgal/src/UI/Backlog/Backlog.tsx +++ b/packages/webgal/src/UI/Backlog/Backlog.tsx @@ -143,6 +143,8 @@ export const Backlog = () => { currentVocal.pause(); currentVocal.currentTime = 0; } + // 卸载 vocal-play perform,避免其 blockingAuto 阻塞自动播放 + WebGAL.gameplay.performController.unmountPerform('vocal-play', true); // 获取到播放 backlog 语音的元素 const backlog_audio_element: any = document.getElementById(currentAudioId); if (backlog_audio_element) { From c7cd1bcc84704b7608b8e0ce7e792970101fad17 Mon Sep 17 00:00:00 2001 From: PleGGen Date: Mon, 15 Jun 2026 21:25:18 +0800 Subject: [PATCH 09/39] refactor(backlog): unconditionally pause all backlog audios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 'audio.id !== currentAudioId' 排除判断。后续逻辑会 重置并播放当前音频(currentTime = 0 + play()),故前置 pause 不需要排除当前。简化代码,无功能差异。 反馈来源:PR review --- packages/webgal/src/UI/Backlog/Backlog.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/webgal/src/UI/Backlog/Backlog.tsx b/packages/webgal/src/UI/Backlog/Backlog.tsx index d33188c8a..30234d533 100644 --- a/packages/webgal/src/UI/Backlog/Backlog.tsx +++ b/packages/webgal/src/UI/Backlog/Backlog.tsx @@ -127,15 +127,12 @@ export const Backlog = () => {
{ playSeClick(); - // 暂停其他所有正在播放的 backlog 语音,避免多条音频混合 - const currentAudioId = 'backlog_audio_play_element_' + indexOfBacklog; + // 暂停所有 backlog 语音(包括当前;后续会重置并播放当前) document .querySelectorAll('[id^="backlog_audio_play_element_"]') .forEach((audio: any) => { - if (audio.id !== currentAudioId) { - audio.pause(); - audio.currentTime = 0; - } + audio.pause(); + audio.currentTime = 0; }); // 暂停游戏内正在播放的语音,避免与 backlog 语音混合 const currentVocal = document.getElementById('currentVocal') as HTMLAudioElement | null; @@ -146,7 +143,9 @@ export const Backlog = () => { // 卸载 vocal-play perform,避免其 blockingAuto 阻塞自动播放 WebGAL.gameplay.performController.unmountPerform('vocal-play', true); // 获取到播放 backlog 语音的元素 - const backlog_audio_element: any = document.getElementById(currentAudioId); + const backlog_audio_element: any = document.getElementById( + 'backlog_audio_play_element_' + indexOfBacklog, + ); if (backlog_audio_element) { backlog_audio_element.currentTime = 0; const userDataStore = webgalStore.getState().userData; From 4454fd7160dbb8a2cb7e30423c518d93c964dc4d Mon Sep 17 00:00:00 2001 From: ChangeSuger Date: Thu, 18 Jun 2026 00:00:07 +0800 Subject: [PATCH 10/39] fix(parser): preserve semicolons in inline comments - Keep all text after the first unescaped semicolon when parsing inline comments, instead of only taking the first split segment. - Add tests for normal inline comments and comment-only lines with multiple semicolons. --- .../parser/src/scriptParser/scriptParser.ts | 2 +- packages/parser/test/parser.test.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/parser/src/scriptParser/scriptParser.ts b/packages/parser/src/scriptParser/scriptParser.ts index 4ecb68e73..0fd07d93e 100644 --- a/packages/parser/src/scriptParser/scriptParser.ts +++ b/packages/parser/src/scriptParser/scriptParser.ts @@ -40,7 +40,7 @@ export const scriptParser = ( const commentSplit = sentenceRaw.split(/(? { + const parser = new SceneParser((assetList) => { + }, (fileName, assetType) => { + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`say:123; first; second; third`, 'test', 'test'); + const expectSentenceItem: ISentence = { + command: commandType.say, + commandRaw: "say", + content: "123", + args: [], + sentenceAssets: [], + subScene: [], + inlineComment: "first; second; third" + }; + expect(result.sentenceList).toContainEqual(expectSentenceItem); +}); + test("comment-only line keeps comment in content", async () => { const parser = new SceneParser((assetList) => { }, (fileName, assetType) => { @@ -344,3 +363,22 @@ test("comment-only line keeps comment in content", async () => { }; expect(result.sentenceList).toContainEqual(expectSentenceItem); }); + +test("comment-only line preserves following semicolons", async () => { + const parser = new SceneParser((assetList) => { + }, (fileName, assetType) => { + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`; first; second; third`, 'test', 'test'); + const expectSentenceItem: ISentence = { + command: commandType.comment, + commandRaw: "comment", + content: "first; second; third", + args: [{ key: 'next', value: true }], + sentenceAssets: [], + subScene: [], + inlineComment: "" + }; + expect(result.sentenceList).toContainEqual(expectSentenceItem); +}); From 5c1964eae029876a1769e7a657cbe480b272b602 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:44:19 +0800 Subject: [PATCH 11/39] feat: preview protocol add `preview.query.reference-box` --- .../controller/stage/pixi/PixiController.ts | 8 + .../stage/pixi/WebGALPixiContainer.ts | 55 ++++++ .../controller/stage/pixi/referenceBox.ts | 101 +++++++++++ .../util/syncWithEditor/previewSyncRuntime.ts | 171 +++++++++++------- .../handlers/referenceBoxQueryHandler.ts | 35 ++++ .../runtime/previewDebugVariables.ts | 4 +- .../webgal/src/types/editorPreviewProtocol.ts | 68 ++++++- 7 files changed, 370 insertions(+), 72 deletions(-) create mode 100644 packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts create mode 100644 packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index fab9b1bfc..34e98c52d 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -14,6 +14,7 @@ import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { queryStageObjectReferenceBox, QueryTargetReferenceBoxResult } from './referenceBox'; export interface IAnimationObject { setStartState: Function; @@ -1030,6 +1031,13 @@ export default class PixiStage { return [...this.figureObjects, ...this.backgroundObjects, this.mainStageObject].find((e) => e.key === key); } + public queryTargetReferenceBox(target: string): QueryTargetReferenceBoxResult { + return queryStageObjectReferenceBox(target, this.getStageObjByKey(target), { + width: this.stageWidth, + height: this.stageHeight, + }); + } + public getStageObjByUuid(objUuid: string) { return [...this.figureObjects, ...this.backgroundObjects, this.mainStageObject].find((e) => e.uuid === objUuid); } diff --git a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts index fc2fc6f14..c815db6fb 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts @@ -328,6 +328,34 @@ const PROPERTY_CONFIGS: Record = { }, }; +function getChildReferenceBounds(child: PIXI.DisplayObject, containerPivot: PIXI.IPointData): PIXI.Rectangle { + child.transform.updateLocalTransform(); + const bounds = child.getLocalBounds(); + const transform = child.transform.localTransform; + const left = bounds.x * child.scale.x + transform.tx - containerPivot.x; + const right = (bounds.x + bounds.width) * child.scale.x + transform.tx - containerPivot.x; + const top = bounds.y * child.scale.y + transform.ty - containerPivot.y; + const bottom = (bounds.y + bounds.height) * child.scale.y + transform.ty - containerPivot.y; + + return new PIXI.Rectangle( + Math.min(left, right), + Math.min(top, bottom), + Math.abs(right - left), + Math.abs(bottom - top), + ); +} + +function mergeBounds(target: PIXI.Rectangle, next: PIXI.Rectangle): void { + const minX = Math.min(target.x, next.x); + const minY = Math.min(target.y, next.y); + const maxX = Math.max(target.x + target.width, next.x + next.width); + const maxY = Math.max(target.y + target.height, next.y + next.height); + target.x = minX; + target.y = minY; + target.width = maxX - minX; + target.height = maxY - minY; +} + export class WebGALPixiContainer extends PIXI.Container { public containerFilters = new Map(); private filterToName = new Map(); @@ -415,6 +443,33 @@ export class WebGALPixiContainer extends PIXI.Container { this.y = old; } + public getBasePosition(): { x: number; y: number } { + return { + x: this.baseX, + y: this.baseY, + }; + } + + public getReferenceLocalBounds(): PIXI.Rectangle | undefined { + if (this.children.length === 0) { + return undefined; + } + + let referenceBounds: PIXI.Rectangle | undefined; + for (const child of this.children) { + const localBounds = getChildReferenceBounds(child, this.pivot); + + if (!referenceBounds) { + referenceBounds = localBounds; + continue; + } + + mergeBounds(referenceBounds, localBounds); + } + + return referenceBounds; + } + // --- Standard Filters --- public get blur(): number { return this._getPropertyValue('blur'); diff --git a/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts b/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts new file mode 100644 index 000000000..d009fa9ac --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts @@ -0,0 +1,101 @@ +import type { ReferenceBox, ReferenceBoxQueryResultPayload } from '@/types/editorPreviewProtocol'; + +interface PointLike { + x: number; + y: number; +} + +interface SizeLike { + width: number; + height: number; +} + +interface BoundsLike extends SizeLike { + x: number; + y: number; +} + +interface ReferenceBoxContainer { + getBasePosition(): PointLike; + getReferenceLocalBounds(): BoundsLike | undefined; +} + +export interface ReferenceBoxStageObject { + pixiContainer: ReferenceBoxContainer | null; + sourceType: 'img' | 'live2d' | 'spine' | 'gif' | 'video' | 'stage'; +} + +export type QueryTargetReferenceBoxResult = ReferenceBoxQueryResultPayload; + +function createReferenceBox(origin: PointLike, localBounds: BoundsLike, stageSize: SizeLike): ReferenceBox { + const anchorX = localBounds.width === 0 ? 0.5 : -localBounds.x / localBounds.width; + const anchorY = localBounds.height === 0 ? 0.5 : -localBounds.y / localBounds.height; + + return { + originX: origin.x, + originY: origin.y, + width: localBounds.width, + height: localBounds.height, + anchorX, + anchorY, + stageWidth: stageSize.width, + stageHeight: stageSize.height, + }; +} + +function createStageFrameReferenceBox(stageSize: SizeLike): ReferenceBox { + return { + originX: stageSize.width / 2, + originY: stageSize.height / 2, + width: stageSize.width, + height: stageSize.height, + anchorX: 0.5, + anchorY: 0.5, + stageWidth: stageSize.width, + stageHeight: stageSize.height, + }; +} + +export function queryStageObjectReferenceBox( + target: string, + stageObject: ReferenceBoxStageObject | undefined, + stageSize: SizeLike, +): QueryTargetReferenceBoxResult { + if (!stageObject) { + return { + target, + status: 'missing', + }; + } + + if (stageObject.sourceType === 'stage') { + return { + target, + status: 'ready', + box: createStageFrameReferenceBox(stageSize), + }; + } + + if (!stageObject.pixiContainer) { + return { + target, + status: 'loading', + reason: 'Pixi 容器不可用', + }; + } + + const localBounds = stageObject.pixiContainer.getReferenceLocalBounds(); + if (!localBounds) { + return { + target, + status: 'loading', + reason: 'reference bounds 不可用', + }; + } + + return { + target, + status: 'ready', + box: createReferenceBox(stageObject.pixiContainer.getBasePosition(), localBounds, stageSize), + }; +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index d9fc83b00..5f064904e 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -3,11 +3,18 @@ import { createRequestEnvelope, createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isPreviewCommandType, isPreviewRequestEnvelope, isProtocolEnvelope, - PreviewRequestPayloadByType, +} from '@/types/editorPreviewProtocol'; +import type { + FastPreviewTimeoutPayload, + PreviewCommandPayloadByType, + PreviewCommandResponsePayloadByType, + PreviewCommandType, + PreviewQueryType, PreviewRequestType, - PreviewResponsePayloadByType, + ProtocolEnvelope, RunSceneContentPayload, RunSnippetPayload, SetComponentVisibilityPayload, @@ -16,8 +23,7 @@ import { SetTextReadModePayload, StageSnapshotUpdatedPayload, SyncScenePayload, - FastPreviewTimeoutPayload, -} from '../../../types/editorPreviewProtocol'; +} from '@/types/editorPreviewProtocol'; import { webgalStore } from '@/store/store'; import { setFontOptimization, setVisibility } from '@/store/GUIReducer'; import { WebGAL } from '@/Core/WebGAL'; @@ -40,6 +46,7 @@ import { import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneCommand'; import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; +import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; let previewSyncRuntimeStarted = false; type StageStateSnapshot = IStageState; @@ -50,6 +57,10 @@ interface RegisterPreviewLogContext { embeddedLaunchId: string | undefined; } +type PreviewRequestEnvelope = Extract; +type PreviewQueryEnvelope = Extract; +type PreviewQueryHandler = (envelope: PreviewQueryEnvelope) => void; + export const startPreviewSyncRuntime = () => { if (previewSyncRuntimeStarted) { return; @@ -73,6 +84,7 @@ export const startPreviewSyncRuntime = () => { let registered = false; let pendingRegisterRequestId: string | null = null; let pendingRegisterContext: RegisterPreviewLogContext | null = null; + let isEmbeddedPreview = false; let lastPublishedSceneName: string | null = null; let lastPublishedSentenceId: number | null = null; let lastPublishedStageState: StageStateSnapshot | null = null; @@ -86,6 +98,7 @@ export const startPreviewSyncRuntime = () => { registered = false; pendingRegisterRequestId = null; pendingRegisterContext = null; + isEmbeddedPreview = false; lastPublishedSceneName = null; lastPublishedSentenceId = null; lastPublishedStageState = null; @@ -157,6 +170,20 @@ export const startPreviewSyncRuntime = () => { ); }; + const finishRegisterPreview = () => { + const registeredPreviewContext = pendingRegisterContext; + if (registeredPreviewContext) { + logger.info('编辑器同步 V1 注册完成', registeredPreviewContext); + } + + pendingRegisterRequestId = null; + pendingRegisterContext = null; + registered = true; + isEmbeddedPreview = Boolean(registeredPreviewContext?.embeddedLaunchId); + publishReady(); + publishStageSnapshot(true); + }; + const emitFastPreviewTimeout = (payload: FastPreviewTimeoutPayload) => { if (!registered) { return; @@ -253,8 +280,8 @@ export const startPreviewSyncRuntime = () => { }); }; - const previewRequestHandlers: { - [K in PreviewRequestType]: (payload: PreviewRequestPayloadByType[K]) => PreviewResponsePayloadByType[K]; + const previewCommandHandlers: { + [K in PreviewCommandType]: (payload: PreviewCommandPayloadByType[K]) => PreviewCommandResponsePayloadByType[K]; } = { 'preview.command.sync-scene': (payload: SyncScenePayload) => { handleSyncScene(payload); @@ -290,73 +317,91 @@ export const startPreviewSyncRuntime = () => { }, }; - const handlePreviewRequest = ( + const previewQueryHandlers: Record = { + 'preview.query.reference-box': (envelope) => { + transport.send(handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview)); + }, + }; + + const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { + return envelope.type in previewQueryHandlers; + }; + + const handlePreviewCommand = ( type: TType, - payload: PreviewRequestPayloadByType[TType], - ): PreviewResponsePayloadByType[TType] => { - const handler = previewRequestHandlers[type] as ( - nextPayload: PreviewRequestPayloadByType[TType], - ) => PreviewResponsePayloadByType[TType]; + payload: PreviewCommandPayloadByType[TType], + ): PreviewCommandResponsePayloadByType[TType] => { + const handler = previewCommandHandlers[type] as ( + nextPayload: PreviewCommandPayloadByType[TType], + ) => PreviewCommandResponsePayloadByType[TType]; return handler(payload); }; + const respondToPreviewRequest = (envelope: PreviewRequestEnvelope) => { + if (isPreviewQueryEnvelope(envelope)) { + previewQueryHandlers[envelope.type](envelope); + return; + } + + if (!isPreviewCommandType(envelope.type)) { + logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + return; + } + + const responsePayload = handlePreviewCommand(envelope.type, envelope.payload); + transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); + }; + + const handleProtocolEnvelope = (envelope: ProtocolEnvelope) => { + if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { + if (pendingRegisterRequestId !== null && envelope.requestId === pendingRegisterRequestId) { + finishRegisterPreview(); + } + return; + } + + if (!registered) { + if (envelope.kind === 'request') { + logger.warn(`收到注册完成前的编辑器同步 V1 请求:${envelope.type}`); + } + return; + } + + if (!isPreviewRequestEnvelope(envelope)) { + if (envelope.kind === 'request') { + logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + } + return; + } + + try { + respondToPreviewRequest(envelope); + } catch (error) { + logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + } + }; + + const handleRawMessage = (rawData: unknown) => { + try { + const envelope = JSON.parse(String(rawData)) as unknown; + if (!isProtocolEnvelope(envelope)) { + logger.warn('收到无法识别的编辑器同步 V1 消息'); + return; + } + + handleProtocolEnvelope(envelope); + } catch (error) { + logger.error('解析编辑器同步 V1 消息失败', error); + } + }; + transport = createPreviewSyncTransport({ url: wsUrl, subprotocol: EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, onConnecting: resetRegistrationState, onOpen: registerPreview, - onMessage: (rawData) => { - try { - const envelope = JSON.parse(String(rawData)) as unknown; - if (!isProtocolEnvelope(envelope)) { - logger.warn('收到无法识别的编辑器同步 V1 消息'); - return; - } - - if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { - if (pendingRegisterRequestId === null || envelope.requestId !== pendingRegisterRequestId) { - return; - } - - if (pendingRegisterContext) { - logger.info('编辑器同步 V1 注册完成', pendingRegisterContext); - } - pendingRegisterRequestId = null; - pendingRegisterContext = null; - registered = true; - publishReady(); - publishStageSnapshot(true); - return; - } - - if (!registered) { - if (envelope.kind === 'request') { - logger.warn(`收到注册完成前的编辑器同步 V1 请求:${envelope.type}`); - } - return; - } - - if (!isPreviewRequestEnvelope(envelope)) { - if (envelope.kind === 'request') { - logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); - } - return; - } - - let responsePayload: PreviewResponsePayloadByType[typeof envelope.type]; - try { - responsePayload = handlePreviewRequest(envelope.type, envelope.payload); - } catch (error) { - logger.error(`执行编辑器同步 V1 命令失败:${envelope.type}`, error); - return; - } - - transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); - } catch (error) { - logger.error('解析编辑器同步 V1 消息失败', error); - } - }, + onMessage: handleRawMessage, onClose: resetRegistrationState, logInfo: (message) => logger.info(message), logError: (message, error) => logger.error(message, error), diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts new file mode 100644 index 000000000..c4e785a4d --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -0,0 +1,35 @@ +import { + createResponseEnvelope, + ReferenceBoxQueryPayload, + ReferenceBoxQueryResultPayload, + RequestEnvelope, + ResponseEnvelope, +} from '@/types/editorPreviewProtocol'; + +interface ReferenceBoxQueryStage { + queryTargetReferenceBox(target: string): ReferenceBoxQueryResultPayload; +} + +export function handleReferenceBoxQuery( + request: RequestEnvelope, + pixiStage: ReferenceBoxQueryStage | null | undefined, + isSupported = true, +): ResponseEnvelope { + if (!isSupported) { + return createResponseEnvelope('preview.query.reference-box', request.requestId, { + target: request.payload.target, + status: 'unsupported', + reason: '当前预览不支持 transform overlay reference box 查询', + }); + } + + const { target } = request.payload; + + const result = pixiStage?.queryTargetReferenceBox(target) ?? { + target, + status: 'unsupported' as const, + reason: 'Pixi stage 不可用', + }; + + return createResponseEnvelope('preview.query.reference-box', request.requestId, result); +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts index aa8862705..0f42cdd8a 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts @@ -3,7 +3,7 @@ import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import type { IGameVar } from '@/Core/Modules/stage/stageInterface'; import { webgalStore } from '@/store/store'; import { setUserData } from '@/store/userDataReducer'; -import type { DebugVariablePayload } from '../../../../types/editorPreviewProtocol'; +import type { DebugVariable } from '@/types/editorPreviewProtocol'; let debugStageVarKeys = new Set(); let debugGlobalBackup: { globalGameVar: IGameVar; scriptManagedGlobalVar: string[] } | null = null; @@ -23,7 +23,7 @@ function clearPreviewDebugVariables() { debugGlobalBackup = null; } -export function applyPreviewDebugVariables(debugVariables: DebugVariablePayload[] = []) { +export function applyPreviewDebugVariables(debugVariables: DebugVariable[] = []) { clearPreviewDebugVariables(); if (debugVariables.some((item) => item.isGlobal)) { const userData = webgalStore.getState().userData; diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index 374c47789..e5fd1fa35 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -5,7 +5,7 @@ export const EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL = 'webgal-editor-preview-syn type EmptyObject = Record; -export interface DebugVariablePayload { +export interface DebugVariable { key: string; value: string; isGlobal?: boolean; @@ -14,17 +14,17 @@ export interface DebugVariablePayload { export interface SyncScenePayload { sceneName: string; sentenceId: number; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export interface RunSceneContentPayload { sceneContent: string; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export interface RunSnippetPayload { snippet: string; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export type ReloadTemplatesPayload = EmptyObject; @@ -47,6 +47,33 @@ export interface SetTextReadModePayload { isRead: boolean; } +export interface ReferenceBoxQueryPayload { + target: string; +} + +export interface ReferenceBox { + originX: number; + originY: number; + width: number; + height: number; + anchorX: number; + anchorY: number; + stageWidth: number; + stageHeight: number; +} + +export type ReferenceBoxQueryResultPayload = + | { + target: string; + status: 'ready'; + box: ReferenceBox; + } + | { + target: string; + status: 'missing' | 'loading' | 'unsupported'; + reason?: string; + }; + export interface PreviewCommandPayloadByType { 'preview.command.sync-scene': SyncScenePayload; 'preview.command.run-scene-content': RunSceneContentPayload; @@ -71,11 +98,24 @@ const PREVIEW_COMMAND_TYPES = [ 'preview.command.set-text-read-mode', ] as const satisfies readonly PreviewCommandType[]; -export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType {} +export interface PreviewQueryPayloadByType { + 'preview.query.reference-box': ReferenceBoxQueryPayload; +} + +export type PreviewQueryType = keyof PreviewQueryPayloadByType; + +const PREVIEW_QUERY_TYPES = [ + 'preview.query.reference-box', +] as const satisfies readonly PreviewQueryType[]; + +export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType, PreviewQueryPayloadByType {} export type PreviewRequestType = keyof PreviewRequestPayloadByType; -const PREVIEW_REQUEST_TYPES = [...PREVIEW_COMMAND_TYPES] as const satisfies readonly PreviewRequestType[]; +const PREVIEW_REQUEST_TYPES = [ + ...PREVIEW_COMMAND_TYPES, + ...PREVIEW_QUERY_TYPES, +] as const satisfies readonly PreviewRequestType[]; type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; @@ -130,7 +170,13 @@ interface RequestPayloadByType extends SessionRequestPayloadByType, PreviewReque export interface PreviewCommandResponsePayloadByType extends Record {} -export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType {} +export interface PreviewQueryResponsePayloadByType { + 'preview.query.reference-box': ReferenceBoxQueryResultPayload; +} + +export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType, PreviewQueryResponsePayloadByType {} + +export type PreviewResponseType = PreviewRequestType interface SessionResponsePayloadByType { 'session.register-preview': EmptyObject; @@ -251,6 +297,10 @@ export function isPreviewRequestType(value: unknown): value is PreviewRequestTyp return isMessageType(value, PREVIEW_REQUEST_TYPES); } +export function isPreviewResponseType(value: unknown): value is PreviewResponseType { + return isPreviewRequestType(value) +} + export function isHostEventType(value: unknown): value is HostEventType { return isMessageType(value, HOST_EVENT_TYPES); } @@ -266,3 +316,7 @@ export function isPreviewCommandRequestEnvelope(value: unknown): value is Reques export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelopeByType { return isRequestEnvelope(value) && isPreviewRequestType(value.type); } + +export function isPreviewResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { + return isResponseEnvelope(value) && isPreviewResponseType(value.type) +} From 2890d55e39dce0acf66adc18da50458a880d30db Mon Sep 17 00:00:00 2001 From: xiaoxustudio Date: Sat, 20 Jun 2026 23:27:51 +0800 Subject: [PATCH 12/39] fix: validate scene file extension --- packages/webgal/src/Core/controller/scene/sceneFetcher.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/webgal/src/Core/controller/scene/sceneFetcher.ts b/packages/webgal/src/Core/controller/scene/sceneFetcher.ts index c66c04cf1..78720c6f5 100644 --- a/packages/webgal/src/Core/controller/scene/sceneFetcher.ts +++ b/packages/webgal/src/Core/controller/scene/sceneFetcher.ts @@ -6,6 +6,10 @@ import axios from 'axios'; */ export const sceneFetcher = (sceneUrl: string) => { return new Promise((resolve, reject) => { + if (!sceneUrl.endsWith('.txt')) { + reject('Scene file must be a txt file'); + return; + } axios .get(sceneUrl) .then((response) => { From 2f2d47103b08dad6c5f2e6fda90330ca52a463a5 Mon Sep 17 00:00:00 2001 From: xiaoxustudio Date: Sun, 21 Jun 2026 00:12:05 +0800 Subject: [PATCH 13/39] fix: conditionally enable video skip on double click --- packages/webgal/src/Core/gameScripts/playVideo.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/playVideo.tsx b/packages/webgal/src/Core/gameScripts/playVideo.tsx index 3c0c21610..de122ce6d 100644 --- a/packages/webgal/src/Core/gameScripts/playVideo.tsx +++ b/packages/webgal/src/Core/gameScripts/playVideo.tsx @@ -63,8 +63,6 @@ export const playVideo = (sentence: ISentence): IPerform => { if (VocalControl !== null) { VocalControl.currentTime = 0; VocalControl.volume = bgmVol; - // 双击可跳过视频 - WebGAL.events.fullscreenDbClick.on(skipVideo); /** * 把bgm和语音的音量设为0 */ @@ -78,6 +76,9 @@ export const playVideo = (sentence: ISentence): IPerform => { } VocalControl?.play().catch(() => {}); + if (!blockingNextFlag) { + WebGAL.events.fullscreenDbClick.on(skipVideo); + } VocalControl.onended = () => { endPerform(); From eda54bf7f7112b018335b1dd69197106c06caceb Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:03:21 +0800 Subject: [PATCH 14/39] feat: implement preview request failure envelope --- .../util/syncWithEditor/previewSyncRuntime.ts | 34 ++++++++ .../webgal/src/types/editorPreviewProtocol.ts | 77 +++++++++++++++++-- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 5f064904e..89b2e0095 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -1,6 +1,7 @@ import { createEventEnvelope, createRequestEnvelope, + createRequestErrorEnvelope, createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, isPreviewCommandType, @@ -13,6 +14,7 @@ import type { PreviewCommandResponsePayloadByType, PreviewCommandType, PreviewQueryType, + PreviewRequestErrorCode, PreviewRequestType, ProtocolEnvelope, RunSceneContentPayload, @@ -60,6 +62,13 @@ interface RegisterPreviewLogContext { type PreviewRequestEnvelope = Extract; type PreviewQueryEnvelope = Extract; type PreviewQueryHandler = (envelope: PreviewQueryEnvelope) => void; +type RawRequestEnvelope = { + kind: 'request'; + type: string; + requestId: string; +}; + +const UNSUPPORTED_REQUEST_MESSAGE = '当前预览运行时不支持该请求类型'; export const startPreviewSyncRuntime = () => { if (previewSyncRuntimeStarted) { @@ -94,6 +103,22 @@ export const startPreviewSyncRuntime = () => { const createRequestId = () => `req-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + + const isRawRequestEnvelope = (value: unknown): value is RawRequestEnvelope => + isRecord(value) && + value.kind === 'request' && + typeof value.type === 'string' && + typeof value.requestId === 'string'; + + const sendRequestError = ( + request: Pick, + code: PreviewRequestErrorCode, + message?: string, + ) => { + transport.send(createRequestErrorEnvelope(request.type, request.requestId, code, message)); + }; + const resetRegistrationState = () => { registered = false; pendingRegisterRequestId = null; @@ -346,6 +371,7 @@ export const startPreviewSyncRuntime = () => { if (!isPreviewCommandType(envelope.type)) { logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'unsupported-request-type', UNSUPPORTED_REQUEST_MESSAGE); return; } @@ -371,6 +397,7 @@ export const startPreviewSyncRuntime = () => { if (!isPreviewRequestEnvelope(envelope)) { if (envelope.kind === 'request') { logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'unsupported-request-type', UNSUPPORTED_REQUEST_MESSAGE); } return; } @@ -379,6 +406,7 @@ export const startPreviewSyncRuntime = () => { respondToPreviewRequest(envelope); } catch (error) { logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + sendRequestError(envelope, 'internal-error', '预览运行时无法安全完成该请求'); } }; @@ -386,6 +414,12 @@ export const startPreviewSyncRuntime = () => { try { const envelope = JSON.parse(String(rawData)) as unknown; if (!isProtocolEnvelope(envelope)) { + if (isRawRequestEnvelope(envelope)) { + logger.warn(`收到非法的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'bad-request', '请求 envelope 格式不合法'); + return; + } + logger.warn('收到无法识别的编辑器同步 V1 消息'); return; } diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index e5fd1fa35..46129a7ac 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -176,7 +176,15 @@ export interface PreviewQueryResponsePayloadByType { export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType, PreviewQueryResponsePayloadByType {} -export type PreviewResponseType = PreviewRequestType +export type PreviewResponseType = PreviewRequestType; + +export type PreviewRequestErrorCode = 'bad-request' | 'unsupported-request-type' | 'internal-error'; + +const PREVIEW_REQUEST_ERROR_CODES = [ + 'bad-request', + 'unsupported-request-type', + 'internal-error', +] as const satisfies readonly PreviewRequestErrorCode[]; interface SessionResponsePayloadByType { 'session.register-preview': EmptyObject; @@ -204,6 +212,16 @@ export interface ResponseEnvelope { + kind: 'error'; + type: TType; + requestId: string; + error: { + code: PreviewRequestErrorCode; + message?: string; + }; +} + type EventEnvelopeByType = { [K in TType]: EventEnvelope; }[TType]; @@ -216,7 +234,11 @@ type ResponseEnvelopeByType; }[TType]; -export type ProtocolEnvelope = EventEnvelopeByType | RequestEnvelopeByType | ResponseEnvelopeByType; +export type ProtocolEnvelope = + | EventEnvelopeByType + | RequestEnvelopeByType + | ResponseEnvelopeByType + | PreviewRequestErrorEnvelope; export function createEventEnvelope( type: TType, @@ -255,11 +277,33 @@ export function createResponseEnvelope( + type: TType, + requestId: string, + code: PreviewRequestErrorCode, + message?: string, +): PreviewRequestErrorEnvelope { + const error: PreviewRequestErrorEnvelope['error'] = { code }; + if (message) { + error.message = message; + } + + return { + kind: 'error', + type, + requestId, + error, + }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function hasEnvelopeShape(value: unknown, kind: ProtocolEnvelope['kind']): value is Record { +function hasEnvelopeShape( + value: unknown, + kind: Exclude, +): value is Record { return ( isRecord(value) && value.kind === kind && @@ -273,6 +317,10 @@ function isMessageType(value: unknown, acceptedTypes: read return typeof value === 'string' && acceptedTypes.includes(value as TType); } +function isPreviewRequestErrorCode(value: unknown): value is PreviewRequestErrorCode { + return isMessageType(value, PREVIEW_REQUEST_ERROR_CODES); +} + function isEventEnvelope(value: unknown): value is EventEnvelope { return hasEnvelopeShape(value, 'event'); } @@ -285,8 +333,25 @@ function isResponseEnvelope(value: unknown): value is ResponseEnvelope { return hasEnvelopeShape(value, 'response'); } +function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestErrorEnvelope { + return ( + isRecord(value) && + value.kind === 'error' && + typeof value.type === 'string' && + typeof value.requestId === 'string' && + isRecord(value.error) && + isPreviewRequestErrorCode(value.error.code) && + (!('message' in value.error) || typeof value.error.message === 'string') + ); +} + export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { - return isEventEnvelope(value) || isRequestEnvelope(value) || isResponseEnvelope(value); + return ( + isEventEnvelope(value) || + isRequestEnvelope(value) || + isResponseEnvelope(value) || + isPreviewRequestErrorEnvelope(value) + ); } export function isPreviewCommandType(value: unknown): value is PreviewCommandType { @@ -298,7 +363,7 @@ export function isPreviewRequestType(value: unknown): value is PreviewRequestTyp } export function isPreviewResponseType(value: unknown): value is PreviewResponseType { - return isPreviewRequestType(value) + return isPreviewRequestType(value); } export function isHostEventType(value: unknown): value is HostEventType { @@ -318,5 +383,5 @@ export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelo } export function isPreviewResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { - return isResponseEnvelope(value) && isPreviewResponseType(value.type) + return isResponseEnvelope(value) && isPreviewResponseType(value.type); } From 176407a7af5a1d7c5961808a5290834b9132fc67 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:39:47 +0800 Subject: [PATCH 15/39] feat: add target transform baseline queries --- .../util/syncWithEditor/previewSyncRuntime.ts | 70 +++++- .../runtime/previewSyncSceneCommand.ts | 52 +++- .../runtime/targetTransformBaseline.ts | 226 ++++++++++++++++++ .../webgal/src/types/editorPreviewProtocol.ts | 51 +++- 4 files changed, 380 insertions(+), 19 deletions(-) create mode 100644 packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 89b2e0095..f7f4b7735 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -5,6 +5,7 @@ import { createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, isPreviewCommandType, + isPreviewQueryType, isPreviewRequestEnvelope, isProtocolEnvelope, } from '@/types/editorPreviewProtocol'; @@ -49,6 +50,11 @@ import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneComman import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; +import { + canPublishTargetTransformBaselineSnapshot, + cloneBaseTransform, + createTargetTransformBaselineManager, +} from './runtime/targetTransformBaseline'; let previewSyncRuntimeStarted = false; type StageStateSnapshot = IStageState; @@ -61,7 +67,6 @@ interface RegisterPreviewLogContext { type PreviewRequestEnvelope = Extract; type PreviewQueryEnvelope = Extract; -type PreviewQueryHandler = (envelope: PreviewQueryEnvelope) => void; type RawRequestEnvelope = { kind: 'request'; type: string; @@ -98,6 +103,7 @@ export const startPreviewSyncRuntime = () => { let lastPublishedSentenceId: number | null = null; let lastPublishedStageState: StageStateSnapshot | null = null; const setEffectBaselines = new Map(); + const targetTransformBaselines = createTargetTransformBaselineManager(); const embeddedLaunchIdPromise = requestEmbeddedLaunchId(); let transport!: PreviewSyncTransport; @@ -128,6 +134,7 @@ export const startPreviewSyncRuntime = () => { lastPublishedSentenceId = null; lastPublishedStageState = null; setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); }; const buildStageStateSnapshot = (stageState: StageStateSnapshot): StageSnapshotUpdatedPayload['stageState'] => { @@ -218,11 +225,34 @@ export const startPreviewSyncRuntime = () => { const handleSyncScene = (payload: SyncScenePayload) => { setEffectBaselines.clear(); - executePreviewSyncSceneCommand(payload, emitFastPreviewTimeout); + const { previewSyncRevision } = payload; + if (previewSyncRevision) { + targetTransformBaselines.acceptRevision(previewSyncRevision); + } else { + targetTransformBaselines.invalidateCurrentRevision(); + } + + executePreviewSyncSceneCommand(payload, { + onFastPreviewTimeout: emitFastPreviewTimeout, + onSettled: (result) => { + if (!previewSyncRevision) { + return; + } + + const canPublishSnapshot = canPublishTargetTransformBaselineSnapshot(result, payload); + if (!canPublishSnapshot) { + targetTransformBaselines.failRevision(previewSyncRevision); + return; + } + + targetTransformBaselines.publishSnapshot(previewSyncRevision, stageStateManager.getCalculationStageState()); + }, + }); }; const handleRunSnippet = (payload: RunSnippetPayload) => { setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); applyPreviewDebugVariables(payload.debugVariables); const scene = WebgalParser.parse(payload.snippet, 'temp.txt', 'temp.txt'); (scene.sentenceList as unknown as ISentence[]).forEach((sentence) => { @@ -256,6 +286,7 @@ export const startPreviewSyncRuntime = () => { const handleRunSceneContent = (payload: RunSceneContentPayload) => { setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); resetStage(true); applyPreviewDebugVariables(payload.debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(payload.sceneContent, 'temp', './temp.txt'); @@ -342,14 +373,35 @@ export const startPreviewSyncRuntime = () => { }, }; - const previewQueryHandlers: Record = { - 'preview.query.reference-box': (envelope) => { - transport.send(handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview)); - }, + const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { + return isPreviewQueryType(envelope.type); }; - const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { - return envelope.type in previewQueryHandlers; + const handlePreviewQuery = (envelope: PreviewQueryEnvelope) => { + switch (envelope.type) { + case 'preview.query.reference-box': + transport.send(handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview)); + return; + case 'preview.query.base-transform': + transport.send( + createResponseEnvelope('preview.query.base-transform', envelope.requestId, { + baseTransform: cloneBaseTransform(), + }), + ); + return; + case 'preview.query.target-transform': + transport.send( + createResponseEnvelope( + 'preview.query.target-transform', + envelope.requestId, + targetTransformBaselines.queryTargetTransform( + envelope.payload.target, + envelope.payload.previewSyncRevision, + ), + ), + ); + return; + } }; const handlePreviewCommand = ( @@ -365,7 +417,7 @@ export const startPreviewSyncRuntime = () => { const respondToPreviewRequest = (envelope: PreviewRequestEnvelope) => { if (isPreviewQueryEnvelope(envelope)) { - previewQueryHandlers[envelope.type](envelope); + handlePreviewQuery(envelope); return; } diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index 36f8ae0e5..56095d7c6 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -8,7 +8,11 @@ import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; import { sceneParser } from '@/Core/parser/sceneParser'; import { logger } from '@/Core/util/logger'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; -import { FastPreviewTimeoutPayload, SyncScenePayload } from '../../../../types/editorPreviewProtocol'; +import type { + FastPreviewTimeoutPayload, + SyncScenePayload, + SyncSceneSettleMode, +} from '@/types/editorPreviewProtocol'; import { applyPreviewDebugVariables } from './previewDebugVariables'; export const FAST_PREVIEW_MAX_DURATION_MS = 500; @@ -16,9 +20,23 @@ const FAST_PREVIEW_TIMEOUT_CHECK_INTERVAL = 100; export type FastPreviewTimeoutEmitter = (payload: FastPreviewTimeoutPayload) => void; +export interface FastPreviewResult { + sceneName: string; + sentenceId: number; + isTimedOut: boolean; + stopReason: FastPreviewStopReason; +} + +export type FastPreviewStopReason = 'target-reached' | 'timeout' | 'state-calculation-blocked' | 'no-progress'; + +export interface PreviewSyncSceneCommandCallbacks { + onFastPreviewTimeout?: FastPreviewTimeoutEmitter; + onSettled?: (result: FastPreviewResult | null) => void; +} + export function executePreviewSyncSceneCommand( - { sceneName, sentenceId, debugVariables }: SyncScenePayload, - onFastPreviewTimeout?: FastPreviewTimeoutEmitter, + { sceneName, sentenceId, debugVariables, settleMode = 'normal' }: SyncScenePayload, + callbacks: PreviewSyncSceneCommandCallbacks = {}, ): void { logger.warn('正在跳转到' + sceneName + ':' + sentenceId); WebGAL.gameplay.isFastPreview = false; @@ -42,12 +60,20 @@ export function executePreviewSyncSceneCommand( applyPreviewDebugVariables(debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - void runFastPreview(sentenceId, currentSceneName, onFastPreviewTimeout); + void runFastPreview(sentenceId, currentSceneName, callbacks.onFastPreviewTimeout, settleMode) + .then((result) => { + callbacks.onSettled?.(result); + }) + .catch((error) => { + logger.error('实时预览跳转错误', error); + callbacks.onSettled?.(null); + }); }) .catch((error) => { stopFast(); WebGAL.gameplay.isFastPreview = false; logger.error('实时预览跳转错误', error); + callbacks.onSettled?.(null); }); } @@ -55,13 +81,15 @@ export async function runFastPreview( sentenceId: number, currentSceneName: string, onFastPreviewTimeout?: FastPreviewTimeoutEmitter, -): Promise { + settleMode: SyncSceneSettleMode = 'normal', +): Promise { const fastPreviewStartTime = performance.now(); const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; stopFast(); WebGAL.gameplay.isFastPreview = true; let forwardCount = 0; let isTimedOut = false; + let stopReason: FastPreviewStopReason = 'target-reached'; let timeoutElapsedMs = 0; let suspendedElapsedMs = 0; @@ -85,6 +113,7 @@ export async function runFastPreview( const elapsedMs = performance.now() - fastPreviewStartTime - suspendedElapsedMs; if (elapsedMs > FAST_PREVIEW_MAX_DURATION_MS) { isTimedOut = true; + stopReason = 'timeout'; timeoutElapsedMs = Math.round(elapsedMs); break; } @@ -92,6 +121,7 @@ export async function runFastPreview( if (WebGAL.gameplay.performController.hasPendingBlockingStateCalculationPerform()) { logger.warn('实时预览在需要外部输入的语句前停止演算'); + stopReason = 'state-calculation-blocked'; break; } @@ -101,6 +131,7 @@ export async function runFastPreview( !awaitedSceneWrite ) { logger.warn('实时预览跳转停止:本次 forward 没有推进语句指针'); + stopReason = 'no-progress'; break; } } @@ -108,6 +139,11 @@ export async function runFastPreview( WebGAL.gameplay.isFastPreview = false; } + if (settleMode === 'immediate') { + WebGAL.gameplay.performController.discardUncommittedNonHoldPerforms(true); + WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); + } + commitForward(); const forwardedLineCount = @@ -132,6 +168,12 @@ export async function runFastPreview( } logger.info(`实时预览快进完成:快进 ${forwardedLineCount} 行,用时 ${fastPreviewElapsedMs}ms`); + return { + sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentenceId: WebGAL.sceneManager.sceneData.currentSentenceId, + isTimedOut, + stopReason, + }; } function shouldContinueFastPreview(sentenceId: number, currentSceneName: string, baseSceneStackDepth: number): boolean { diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts new file mode 100644 index 000000000..4619c5b66 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -0,0 +1,226 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { STAGE_KEYS } from '@/Core/constants'; +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import type { IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; +import type { TargetTransformQueryResultPayload } from '@/types/editorPreviewProtocol'; +import type { FastPreviewResult } from './previewSyncSceneCommand'; + +const FIXED_TARGETS = new Set([ + STAGE_KEYS.STAGE_MAIN, + STAGE_KEYS.BGMAIN, + STAGE_KEYS.FIG_C, + STAGE_KEYS.FIG_L, + STAGE_KEYS.FIG_R, +]); + +type BaselineRevisionState = + | { + status: 'none'; + } + | { + status: 'pending'; + revision: string; + } + | { + status: 'ready'; + revision: string; + snapshot: TargetTransformBaselineSnapshot; + } + | { + status: 'unavailable'; + revision: string; + }; + +interface TargetTransformBaselineSnapshot { + knownTargets: Set; + transformsByTarget: Map; +} + +interface TargetTransformBaselinePublishTarget { + sceneName: string; + sentenceId: number; +} + +interface TargetTransformBaselineManager { + acceptRevision: (revision: string) => void; + publishSnapshot: (revision: string, stageState: IStageState) => void; + failRevision: (revision: string) => void; + invalidateCurrentRevision: () => void; + queryTargetTransform: (target: string, revision: string) => TargetTransformQueryResultPayload; +} + +export function cloneBaseTransform(): ITransform { + return cloneDeep(baseTransform); +} + +export function canPublishTargetTransformBaselineSnapshot( + result: FastPreviewResult | null, + target: TargetTransformBaselinePublishTarget, +): boolean { + return ( + result !== null && + !result.isTimedOut && + result.stopReason === 'target-reached' && + result.sceneName === target.sceneName && + result.sentenceId === target.sentenceId + ); +} + +export function createTargetTransformBaselineManager(): TargetTransformBaselineManager { + let revisionState: BaselineRevisionState = { status: 'none' }; + + const isLatestRevision = (revision: string) => { + return revisionState.status !== 'none' && revisionState.revision === revision; + }; + + const isPendingRevision = (revision: string) => { + return revisionState.status === 'pending' && revisionState.revision === revision; + }; + + return { + acceptRevision(revision) { + revisionState = { + status: 'pending', + revision, + }; + }, + + publishSnapshot(revision, stageState) { + if (!isPendingRevision(revision)) { + return; + } + + revisionState = { + status: 'ready', + revision, + snapshot: createSnapshot(stageState), + }; + }, + + failRevision(revision) { + if (!isLatestRevision(revision)) { + return; + } + + revisionState = { + status: 'unavailable', + revision, + }; + }, + + invalidateCurrentRevision() { + if (revisionState.status === 'none') { + return; + } + + revisionState = { + status: 'unavailable', + revision: revisionState.revision, + }; + }, + + queryTargetTransform(target, revision) { + if (!isLatestRevision(revision)) { + return { + status: 'unavailable', + }; + } + + if (revisionState.status === 'pending') { + return { + status: 'loading', + }; + } + + if (revisionState.status !== 'ready') { + return { + status: 'unavailable', + }; + } + + return querySnapshot(revisionState.snapshot, target); + }, + }; +} + +function createSnapshot(stageState: IStageState): TargetTransformBaselineSnapshot { + const knownTargets = new Set(FIXED_TARGETS); + stageState.freeFigure.forEach((figure) => { + knownTargets.add(figure.key); + }); + + const transformsByTarget = new Map(); + stageState.effects.forEach((effect) => { + if (!knownTargets.has(effect.target) || !effect.transform) { + return; + } + + transformsByTarget.set(effect.target, cloneDeep(effect.transform)); + }); + + return { + knownTargets, + transformsByTarget, + }; +} + +function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TargetTransformQueryResultPayload { + if (!snapshot.knownTargets.has(target)) { + return { + status: 'unavailable', + }; + } + + const transform = snapshot.transformsByTarget.get(target); + return { + status: 'ready', + transform: transform ? createSparseTransformOverride(transform, baseTransform) : {}, + }; +} + +function createSparseTransformOverride(transform: ITransform, base: ITransform): ITransform { + const result: ITransform = {}; + + const position = createVectorOverride(transform.position, base.position); + if (position) { + result.position = position; + } + + const scale = createVectorOverride(transform.scale, base.scale); + if (scale) { + result.scale = scale; + } + + (Object.keys(transform) as Array).forEach((key) => { + if (key === 'position' || key === 'scale') { + return; + } + + const value = transform[key]; + if (value !== undefined && value !== base[key]) { + (result as Record)[key] = value; + } + }); + + return result; +} + +type TransformVector = NonNullable; + +function createVectorOverride( + transformValue: TransformVector | undefined, + baseValue: TransformVector | undefined, +): TransformVector | undefined { + if (!transformValue) { + return undefined; + } + + const vectorOverride: TransformVector = {}; + (Object.keys(transformValue) as Array).forEach((nestedKey) => { + if (transformValue[nestedKey] !== undefined && transformValue[nestedKey] !== baseValue?.[nestedKey]) { + vectorOverride[nestedKey] = transformValue[nestedKey]; + } + }); + + return Object.keys(vectorOverride).length > 0 ? vectorOverride : undefined; +} diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index 46129a7ac..857c33594 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -15,8 +15,12 @@ export interface SyncScenePayload { sceneName: string; sentenceId: number; debugVariables?: DebugVariable[]; + previewSyncRevision?: string; + settleMode?: SyncSceneSettleMode; } +export type SyncSceneSettleMode = 'normal' | 'immediate'; + export interface RunSceneContentPayload { sceneContent: string; debugVariables?: DebugVariable[]; @@ -29,12 +33,14 @@ export interface RunSnippetPayload { export type ReloadTemplatesPayload = EmptyObject; +export type Transform = Partial> & { + position?: Partial; + scale?: Partial; +}; + export interface SetEffectPayload { target: IEffect['target']; - transform?: Partial> & { - position?: Partial; - scale?: Partial; - }; + transform?: Transform; } export type SetComponentVisibilityPayload = Partial>; @@ -51,6 +57,29 @@ export interface ReferenceBoxQueryPayload { target: string; } +export type BaseTransformQueryPayload = EmptyObject; + +export interface BaseTransformQueryResultPayload { + baseTransform: Transform; +} + +export interface TargetTransformQueryPayload { + target: string; + previewSyncRevision: string; +} + +export type TargetTransformQueryResultPayload = + | { + status: 'ready'; + transform: Transform; + } + | { + status: 'loading'; + } + | { + status: 'unavailable'; + }; + export interface ReferenceBox { originX: number; originY: number; @@ -100,12 +129,16 @@ const PREVIEW_COMMAND_TYPES = [ export interface PreviewQueryPayloadByType { 'preview.query.reference-box': ReferenceBoxQueryPayload; + 'preview.query.base-transform': BaseTransformQueryPayload; + 'preview.query.target-transform': TargetTransformQueryPayload; } export type PreviewQueryType = keyof PreviewQueryPayloadByType; const PREVIEW_QUERY_TYPES = [ 'preview.query.reference-box', + 'preview.query.base-transform', + 'preview.query.target-transform', ] as const satisfies readonly PreviewQueryType[]; export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType, PreviewQueryPayloadByType {} @@ -172,9 +205,13 @@ export interface PreviewCommandResponsePayloadByType extends Record Date: Mon, 22 Jun 2026 20:56:00 +0800 Subject: [PATCH 16/39] refactor: restructure the editor preview protocol type system --- .../src/Core/Modules/stage/stageInterface.ts | 49 +- .../util/syncWithEditor/previewSyncRuntime.ts | 15 +- .../handlers/referenceBoxQueryHandler.ts | 13 +- .../webgal/src/types/editorPreviewProtocol.ts | 427 ++++++++++++------ 4 files changed, 295 insertions(+), 209 deletions(-) diff --git a/packages/webgal/src/Core/Modules/stage/stageInterface.ts b/packages/webgal/src/Core/Modules/stage/stageInterface.ts index 4e8cd25f5..217362291 100644 --- a/packages/webgal/src/Core/Modules/stage/stageInterface.ts +++ b/packages/webgal/src/Core/Modules/stage/stageInterface.ts @@ -1,5 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { BlinkParam, FocusParam } from '@/Core/live2DCore'; +import type { Transform } from '@/types/editorPreviewProtocol'; /** * 游戏内变量 @@ -24,49 +25,7 @@ export interface IChooseItem { isSubScene: boolean; // 是否是子场景调用 } -export interface ITransform { - alpha?: number; - scale?: { - x?: number; - y?: number; - }; - // pivot: { - // x: number; - // y: number; - // }; - position?: { - x?: number; - y?: number; - }; - rotation?: number; - blur?: number; - brightness?: number; - contrast?: number; - saturation?: number; - gamma?: number; - colorRed?: number; - colorGreen?: number; - colorBlue?: number; - bevel?: number; - bevelThickness?: number; - bevelRotation?: number; - bevelSoftness?: number; - bevelRed?: number; - bevelGreen?: number; - bevelBlue?: number; - bloom?: number; - bloomBrightness?: number; - bloomBlur?: number; - bloomThreshold?: number; - oldFilm?: number; - dotFilm?: number; - reflectionFilm?: number; - glitchFilm?: number; - rgbFilm?: number; - godrayFilm?: number; - shockwaveFilter?: number; - radiusAlphaFilter?: number; -} +export type ITransform = Transform; /** * 基本效果接口 @@ -104,10 +63,6 @@ export const baseTransform: ITransform = { x: 1, y: 1, }, - // pivot: { - // x: 0.5, - // y: 0.5, - // }, position: { x: 0, y: 0, diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index f7f4b7735..4fdb63a9a 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -4,12 +4,13 @@ import { createRequestErrorEnvelope, createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isAnyProtocolEnvelope, isPreviewCommandType, isPreviewQueryType, isPreviewRequestEnvelope, - isProtocolEnvelope, } from '@/types/editorPreviewProtocol'; import type { + AnyProtocolEnvelope, FastPreviewTimeoutPayload, PreviewCommandPayloadByType, PreviewCommandResponsePayloadByType, @@ -17,7 +18,7 @@ import type { PreviewQueryType, PreviewRequestErrorCode, PreviewRequestType, - ProtocolEnvelope, + RequestEnvelopeByType, RunSceneContentPayload, RunSnippetPayload, SetComponentVisibilityPayload, @@ -65,8 +66,8 @@ interface RegisterPreviewLogContext { embeddedLaunchId: string | undefined; } -type PreviewRequestEnvelope = Extract; -type PreviewQueryEnvelope = Extract; +type PreviewRequestEnvelope = RequestEnvelopeByType; +type PreviewQueryEnvelope = RequestEnvelopeByType; type RawRequestEnvelope = { kind: 'request'; type: string; @@ -122,7 +123,7 @@ export const startPreviewSyncRuntime = () => { code: PreviewRequestErrorCode, message?: string, ) => { - transport.send(createRequestErrorEnvelope(request.type, request.requestId, code, message)); + transport.send(createRequestErrorEnvelope(request.type, request.requestId, message ? { code, message } : { code })); }; const resetRegistrationState = () => { @@ -431,7 +432,7 @@ export const startPreviewSyncRuntime = () => { transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); }; - const handleProtocolEnvelope = (envelope: ProtocolEnvelope) => { + const handleProtocolEnvelope = (envelope: AnyProtocolEnvelope) => { if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { if (pendingRegisterRequestId !== null && envelope.requestId === pendingRegisterRequestId) { finishRegisterPreview(); @@ -465,7 +466,7 @@ export const startPreviewSyncRuntime = () => { const handleRawMessage = (rawData: unknown) => { try { const envelope = JSON.parse(String(rawData)) as unknown; - if (!isProtocolEnvelope(envelope)) { + if (!isAnyProtocolEnvelope(envelope)) { if (isRawRequestEnvelope(envelope)) { logger.warn(`收到非法的编辑器同步 V1 请求:${envelope.type}`); sendRequestError(envelope, 'bad-request', '请求 envelope 格式不合法'); diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts index c4e785a4d..cc621a772 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -1,9 +1,8 @@ -import { - createResponseEnvelope, - ReferenceBoxQueryPayload, +import { createResponseEnvelope } from '@/types/editorPreviewProtocol'; +import type { ReferenceBoxQueryResultPayload, - RequestEnvelope, - ResponseEnvelope, + RequestEnvelopeByType, + ResponseEnvelopeByType, } from '@/types/editorPreviewProtocol'; interface ReferenceBoxQueryStage { @@ -11,10 +10,10 @@ interface ReferenceBoxQueryStage { } export function handleReferenceBoxQuery( - request: RequestEnvelope, + request: RequestEnvelopeByType<'preview.query.reference-box'>, pixiStage: ReferenceBoxQueryStage | null | undefined, isSupported = true, -): ResponseEnvelope { +): ResponseEnvelopeByType<'preview.query.reference-box'> { if (!isSupported) { return createResponseEnvelope('preview.query.reference-box', request.requestId, { target: request.payload.target, diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index 857c33594..c3f4af6f4 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -1,9 +1,28 @@ -import type { IEffect, ITransform } from '@/Core/Modules/stage/stageInterface'; -import type { componentsVisibility } from '@/store/guiInterface'; - export const EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL = 'webgal-editor-preview-sync.v1' as const; +export const SESSION_REGISTER_PREVIEW_TYPE = 'session.register-preview' as const; + type EmptyObject = Record; +type PayloadMap = Record; + +function payload(): TPayload { + return undefined as TPayload; +} + +function definePayloadMap(map: TMap): Readonly { + return Object.freeze(map); +} + +function messageTypes(map: TMap): readonly Extract[] { + return Object.freeze(Object.keys(map) as Extract[]); +} + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; + +export interface JsonObject { + [key: string]: JsonValue; +} export interface DebugVariable { key: string; @@ -11,6 +30,74 @@ export interface DebugVariable { isGlobal?: boolean; } +export interface Point2D { + x?: number; + y?: number; +} + +export interface Transform { + position?: Point2D; + scale?: Point2D; + rotation?: number; + + alpha?: number; + blur?: number; + + brightness?: number; + contrast?: number; + saturation?: number; + gamma?: number; + colorRed?: number; + colorGreen?: number; + colorBlue?: number; + + bloom?: number; + bloomBrightness?: number; + bloomBlur?: number; + bloomThreshold?: number; + + bevel?: number; + bevelThickness?: number; + bevelRotation?: number; + bevelSoftness?: number; + bevelRed?: number; + bevelGreen?: number; + bevelBlue?: number; + + oldFilm?: number; + dotFilm?: number; + reflectionFilm?: number; + glitchFilm?: number; + rgbFilm?: number; + godrayFilm?: number; + + shockwaveFilter?: number; + radiusAlphaFilter?: number; +} + +export const COMPONENT_VISIBILITY_KEYS = [ + 'showStarter', + 'showTitle', + 'showMenuPanel', + 'showTextBox', + 'showControls', + 'controlsVisibility', + 'showBacklog', + 'showExtra', + 'showGlobalDialog', + 'showPanicOverlay', + 'isEnterGame', + 'isShowLogo', + 'enableAppreciationMode', + 'fontOptimization', +] as const; + +export type ComponentVisibilityKey = (typeof COMPONENT_VISIBILITY_KEYS)[number]; + +export type SetComponentVisibilityPayload = Partial>; + +export type SyncSceneSettleMode = 'normal' | 'immediate'; + export interface SyncScenePayload { sceneName: string; sentenceId: number; @@ -19,8 +106,6 @@ export interface SyncScenePayload { settleMode?: SyncSceneSettleMode; } -export type SyncSceneSettleMode = 'normal' | 'immediate'; - export interface RunSceneContentPayload { sceneContent: string; debugVariables?: DebugVariable[]; @@ -33,18 +118,11 @@ export interface RunSnippetPayload { export type ReloadTemplatesPayload = EmptyObject; -export type Transform = Partial> & { - position?: Partial; - scale?: Partial; -}; - export interface SetEffectPayload { - target: IEffect['target']; + target: string; transform?: Transform; } -export type SetComponentVisibilityPayload = Partial>; - export interface SetFontOptimizationPayload { enabled: boolean; } @@ -59,27 +137,11 @@ export interface ReferenceBoxQueryPayload { export type BaseTransformQueryPayload = EmptyObject; -export interface BaseTransformQueryResultPayload { - baseTransform: Transform; -} - export interface TargetTransformQueryPayload { target: string; previewSyncRevision: string; } -export type TargetTransformQueryResultPayload = - | { - status: 'ready'; - transform: Transform; - } - | { - status: 'loading'; - } - | { - status: 'unavailable'; - }; - export interface ReferenceBox { originX: number; originY: number; @@ -103,60 +165,60 @@ export type ReferenceBoxQueryResultPayload = reason?: string; }; -export interface PreviewCommandPayloadByType { - 'preview.command.sync-scene': SyncScenePayload; - 'preview.command.run-scene-content': RunSceneContentPayload; - 'preview.command.run-snippet': RunSnippetPayload; - 'preview.command.reload-templates': ReloadTemplatesPayload; - 'preview.command.set-effect': SetEffectPayload; - 'preview.command.set-component-visibility': SetComponentVisibilityPayload; - 'preview.command.set-font-optimization': SetFontOptimizationPayload; - 'preview.command.set-text-read-mode': SetTextReadModePayload; +export interface BaseTransformQueryResultPayload { + baseTransform: Transform; } -export type PreviewCommandType = keyof PreviewCommandPayloadByType; +export type TargetTransformQueryResultPayload = + | { + status: 'ready'; + transform: Transform; + } + | { + status: 'loading'; + } + | { + status: 'unavailable'; + }; -const PREVIEW_COMMAND_TYPES = [ - 'preview.command.sync-scene', - 'preview.command.run-scene-content', - 'preview.command.run-snippet', - 'preview.command.reload-templates', - 'preview.command.set-effect', - 'preview.command.set-component-visibility', - 'preview.command.set-font-optimization', - 'preview.command.set-text-read-mode', -] as const satisfies readonly PreviewCommandType[]; +export const PREVIEW_COMMAND_PAYLOADS = definePayloadMap({ + 'preview.command.sync-scene': payload(), + 'preview.command.run-scene-content': payload(), + 'preview.command.run-snippet': payload(), + 'preview.command.reload-templates': payload(), + 'preview.command.set-effect': payload(), + 'preview.command.set-component-visibility': payload(), + 'preview.command.set-font-optimization': payload(), + 'preview.command.set-text-read-mode': payload(), +}); -export interface PreviewQueryPayloadByType { - 'preview.query.reference-box': ReferenceBoxQueryPayload; - 'preview.query.base-transform': BaseTransformQueryPayload; - 'preview.query.target-transform': TargetTransformQueryPayload; -} +export type PreviewCommandPayloadByType = typeof PREVIEW_COMMAND_PAYLOADS; + +export type PreviewCommandType = keyof PreviewCommandPayloadByType & string; + +export const PREVIEW_COMMAND_TYPES = messageTypes(PREVIEW_COMMAND_PAYLOADS); -export type PreviewQueryType = keyof PreviewQueryPayloadByType; +export const PREVIEW_QUERY_PAYLOADS = definePayloadMap({ + 'preview.query.reference-box': payload(), + 'preview.query.base-transform': payload(), + 'preview.query.target-transform': payload(), +}); -const PREVIEW_QUERY_TYPES = [ - 'preview.query.reference-box', - 'preview.query.base-transform', - 'preview.query.target-transform', -] as const satisfies readonly PreviewQueryType[]; +export type PreviewQueryPayloadByType = typeof PREVIEW_QUERY_PAYLOADS; -export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType, PreviewQueryPayloadByType {} +export type PreviewQueryType = keyof PreviewQueryPayloadByType & string; + +export const PREVIEW_QUERY_TYPES = messageTypes(PREVIEW_QUERY_PAYLOADS); + +export type PreviewRequestPayloadByType = PreviewCommandPayloadByType & PreviewQueryPayloadByType; export type PreviewRequestType = keyof PreviewRequestPayloadByType; -const PREVIEW_REQUEST_TYPES = [ +export const PREVIEW_REQUEST_TYPES = [ ...PREVIEW_COMMAND_TYPES, ...PREVIEW_QUERY_TYPES, ] as const satisfies readonly PreviewRequestType[]; -type JsonPrimitive = string | number | boolean | null; -type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; - -interface JsonObject { - [key: string]: JsonValue; -} - export interface PreviewReadyUpdatedPayload { ready: boolean; } @@ -176,58 +238,85 @@ export interface FastPreviewTimeoutPayload { maxDurationMs: number; } -interface EventPayloadByType { - 'preview.ready.updated': PreviewReadyUpdatedPayload; - 'stage.snapshot.updated': StageSnapshotUpdatedPayload; - 'preview.event.fast-preview-timeout': FastPreviewTimeoutPayload; -} +export const HOST_EVENT_PAYLOADS = definePayloadMap({ + 'preview.ready.updated': payload(), + 'stage.snapshot.updated': payload(), + 'preview.event.fast-preview-timeout': payload(), +}); -export type HostEventType = keyof EventPayloadByType; +export type EventPayloadByType = typeof HOST_EVENT_PAYLOADS; -const HOST_EVENT_TYPES = [ - 'preview.ready.updated', - 'stage.snapshot.updated', - 'preview.event.fast-preview-timeout', -] as const satisfies readonly HostEventType[]; +export type HostEventType = keyof EventPayloadByType & string; + +export const HOST_EVENT_TYPES = messageTypes(HOST_EVENT_PAYLOADS); export interface RegisterPreviewRequestPayload { gameId?: string; embeddedLaunchId?: string; } -interface SessionRequestPayloadByType { - 'session.register-preview': RegisterPreviewRequestPayload; -} +export const SESSION_REQUEST_PAYLOADS = definePayloadMap({ + [SESSION_REGISTER_PREVIEW_TYPE]: payload(), +}); -interface RequestPayloadByType extends SessionRequestPayloadByType, PreviewRequestPayloadByType {} +export type SessionRequestPayloadByType = typeof SESSION_REQUEST_PAYLOADS; -export interface PreviewCommandResponsePayloadByType extends Record {} +export type SessionRequestType = keyof SessionRequestPayloadByType & string; -export interface PreviewQueryResponsePayloadByType { - 'preview.query.reference-box': ReferenceBoxQueryResultPayload; - 'preview.query.base-transform': BaseTransformQueryResultPayload; - 'preview.query.target-transform': TargetTransformQueryResultPayload; -} +export const SESSION_REQUEST_TYPES = messageTypes(SESSION_REQUEST_PAYLOADS); + +export type RequestPayloadByType = SessionRequestPayloadByType & PreviewRequestPayloadByType; + +export type RequestType = keyof RequestPayloadByType; + +export const REQUEST_TYPES = [ + ...SESSION_REQUEST_TYPES, + ...PREVIEW_REQUEST_TYPES, +] as const satisfies readonly RequestType[]; -export interface PreviewResponsePayloadByType - extends PreviewCommandResponsePayloadByType, - PreviewQueryResponsePayloadByType {} +export type PreviewCommandResponsePayloadByType = Record; + +export const PREVIEW_QUERY_RESPONSE_PAYLOADS = definePayloadMap({ + 'preview.query.reference-box': payload(), + 'preview.query.base-transform': payload(), + 'preview.query.target-transform': payload(), +}); + +export type PreviewQueryResponsePayloadByType = typeof PREVIEW_QUERY_RESPONSE_PAYLOADS; + +export type PreviewResponsePayloadByType = PreviewCommandResponsePayloadByType & PreviewQueryResponsePayloadByType; export type PreviewResponseType = PreviewRequestType; -export type PreviewRequestErrorCode = 'bad-request' | 'unsupported-request-type' | 'internal-error'; +export const PREVIEW_RESPONSE_TYPES = PREVIEW_REQUEST_TYPES satisfies readonly PreviewResponseType[]; -const PREVIEW_REQUEST_ERROR_CODES = [ - 'bad-request', - 'unsupported-request-type', - 'internal-error', -] as const satisfies readonly PreviewRequestErrorCode[]; +export const SESSION_RESPONSE_PAYLOADS = definePayloadMap({ + [SESSION_REGISTER_PREVIEW_TYPE]: payload(), +}); -interface SessionResponsePayloadByType { - 'session.register-preview': EmptyObject; -} +export type SessionResponsePayloadByType = typeof SESSION_RESPONSE_PAYLOADS; -interface ResponsePayloadByType extends SessionResponsePayloadByType, PreviewResponsePayloadByType {} +export type SessionResponseType = keyof SessionResponsePayloadByType & string; + +export const SESSION_RESPONSE_TYPES = messageTypes(SESSION_RESPONSE_PAYLOADS); + +export type ResponsePayloadByType = SessionResponsePayloadByType & PreviewResponsePayloadByType; + +export type ResponseType = keyof ResponsePayloadByType; + +export const RESPONSE_TYPES = [ + ...SESSION_RESPONSE_TYPES, + ...PREVIEW_RESPONSE_TYPES, +] as const satisfies readonly ResponseType[]; + +export const PREVIEW_REQUEST_ERROR_CODES = ['bad-request', 'unsupported-request-type', 'internal-error'] as const; + +export type PreviewRequestErrorCode = (typeof PREVIEW_REQUEST_ERROR_CODES)[number]; + +export interface PreviewRequestError { + code: PreviewRequestErrorCode; + message?: string; +} export interface EventEnvelope { kind: 'event'; @@ -253,29 +342,34 @@ export interface PreviewRequestErrorEnvelope { kind: 'error'; type: TType; requestId: string; - error: { - code: PreviewRequestErrorCode; - message?: string; - }; + error: PreviewRequestError; } -type EventEnvelopeByType = { +export type AnyProtocolEnvelope = EventEnvelope | RequestEnvelope | ResponseEnvelope | PreviewRequestErrorEnvelope; + +export type EventEnvelopeByType = { [K in TType]: EventEnvelope; }[TType]; -type RequestEnvelopeByType = { +export type RequestEnvelopeByType = { [K in TType]: RequestEnvelope; }[TType]; -type ResponseEnvelopeByType = { +export type ResponseEnvelopeByType = { [K in TType]: ResponseEnvelope; }[TType]; -export type ProtocolEnvelope = +export type PreviewRequestErrorEnvelopeByType = { + [K in TType]: PreviewRequestErrorEnvelope; +}[TType]; + +export type KnownProtocolEnvelope = | EventEnvelopeByType | RequestEnvelopeByType | ResponseEnvelopeByType - | PreviewRequestErrorEnvelope; + | PreviewRequestErrorEnvelopeByType; + +export type ProtocolEnvelope = KnownProtocolEnvelope; export function createEventEnvelope( type: TType, @@ -317,14 +411,8 @@ export function createResponseEnvelope( type: TType, requestId: string, - code: PreviewRequestErrorCode, - message?: string, + error: PreviewRequestError, ): PreviewRequestErrorEnvelope { - const error: PreviewRequestErrorEnvelope['error'] = { code }; - if (message) { - error.message = message; - } - return { kind: 'error', type, @@ -337,9 +425,9 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function hasEnvelopeShape( +function hasPayloadEnvelopeShape( value: unknown, - kind: Exclude, + kind: 'event' | 'request' | 'response', ): value is Record { return ( isRecord(value) && @@ -354,23 +442,59 @@ function isMessageType(value: unknown, acceptedTypes: read return typeof value === 'string' && acceptedTypes.includes(value as TType); } -function isPreviewRequestErrorCode(value: unknown): value is PreviewRequestErrorCode { +export function isSessionRequestType(value: unknown): value is SessionRequestType { + return isMessageType(value, SESSION_REQUEST_TYPES); +} + +export function isPreviewCommandType(value: unknown): value is PreviewCommandType { + return isMessageType(value, PREVIEW_COMMAND_TYPES); +} + +export function isPreviewQueryType(value: unknown): value is PreviewQueryType { + return isMessageType(value, PREVIEW_QUERY_TYPES); +} + +export function isPreviewRequestType(value: unknown): value is PreviewRequestType { + return isMessageType(value, PREVIEW_REQUEST_TYPES); +} + +export function isRequestType(value: unknown): value is RequestType { + return isMessageType(value, REQUEST_TYPES); +} + +export function isPreviewResponseType(value: unknown): value is PreviewResponseType { + return isMessageType(value, PREVIEW_RESPONSE_TYPES); +} + +export function isSessionResponseType(value: unknown): value is SessionResponseType { + return isMessageType(value, SESSION_RESPONSE_TYPES); +} + +export function isResponseType(value: unknown): value is ResponseType { + return isMessageType(value, RESPONSE_TYPES); +} + +export function isHostEventType(value: unknown): value is HostEventType { + return isMessageType(value, HOST_EVENT_TYPES); +} + +export function isPreviewRequestErrorCode(value: unknown): value is PreviewRequestErrorCode { return isMessageType(value, PREVIEW_REQUEST_ERROR_CODES); } -function isEventEnvelope(value: unknown): value is EventEnvelope { - return hasEnvelopeShape(value, 'event'); +export function isEventEnvelope(value: unknown): value is EventEnvelope { + return hasPayloadEnvelopeShape(value, 'event'); } -function isRequestEnvelope(value: unknown): value is RequestEnvelope { - return hasEnvelopeShape(value, 'request'); +export function isRequestEnvelope(value: unknown): value is RequestEnvelope { + return hasPayloadEnvelopeShape(value, 'request'); } -function isResponseEnvelope(value: unknown): value is ResponseEnvelope { - return hasEnvelopeShape(value, 'response'); +export function isResponseEnvelope(value: unknown): value is ResponseEnvelope { + return hasPayloadEnvelopeShape(value, 'response'); } -function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestErrorEnvelope { +export function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestErrorEnvelope { return ( isRecord(value) && value.kind === 'error' && @@ -382,7 +506,7 @@ function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestE ); } -export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { +export function isAnyProtocolEnvelope(value: unknown): value is AnyProtocolEnvelope { return ( isEventEnvelope(value) || isRequestEnvelope(value) || @@ -391,28 +515,16 @@ export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { ); } -export function isPreviewCommandType(value: unknown): value is PreviewCommandType { - return isMessageType(value, PREVIEW_COMMAND_TYPES); -} - -export function isPreviewQueryType(value: unknown): value is PreviewQueryType { - return isMessageType(value, PREVIEW_QUERY_TYPES); -} - -export function isPreviewRequestType(value: unknown): value is PreviewRequestType { - return isMessageType(value, PREVIEW_REQUEST_TYPES); -} - -export function isPreviewResponseType(value: unknown): value is PreviewResponseType { - return isPreviewRequestType(value); +export function isHostEventEnvelope(value: unknown): value is EventEnvelopeByType { + return isEventEnvelope(value) && isHostEventType(value.type); } -export function isHostEventType(value: unknown): value is HostEventType { - return isMessageType(value, HOST_EVENT_TYPES); +export function isKnownRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isRequestType(value.type); } -export function isHostEventEnvelope(value: unknown): value is EventEnvelopeByType { - return isEventEnvelope(value) && isHostEventType(value.type); +export function isSessionRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isSessionRequestType(value.type); } export function isPreviewCommandRequestEnvelope(value: unknown): value is RequestEnvelopeByType { @@ -423,6 +535,25 @@ export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelo return isRequestEnvelope(value) && isPreviewRequestType(value.type); } +export function isKnownResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { + return isResponseEnvelope(value) && isResponseType(value.type); +} + export function isPreviewResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { return isResponseEnvelope(value) && isPreviewResponseType(value.type); } + +export function isKnownPreviewRequestErrorEnvelope( + value: unknown, +): value is PreviewRequestErrorEnvelopeByType { + return isPreviewRequestErrorEnvelope(value) && isPreviewRequestType(value.type); +} + +export function isKnownProtocolEnvelope(value: unknown): value is KnownProtocolEnvelope { + return ( + isHostEventEnvelope(value) || + isKnownRequestEnvelope(value) || + isKnownResponseEnvelope(value) || + (isPreviewRequestErrorEnvelope(value) && isRequestType(value.type)) + ); +} From 758fc5048316c34b8f796a7deeac32befe7255f5 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:42:42 +0800 Subject: [PATCH 17/39] fix: Invalid pending target transform baseline --- .../Core/util/syncWithEditor/previewSyncRuntime.ts | 1 + .../runtime/targetTransformBaseline.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 4fdb63a9a..ab247b93d 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -329,6 +329,7 @@ export const startPreviewSyncRuntime = () => { }; const handleSetEffect = (payload: SetEffectPayload) => { + targetTransformBaselines.invalidatePendingRevision(); const newTransform = mergeSetEffectPreviewTransform(getSetEffectBaseline(payload.target), payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); stageStateManager.updateEffectAndCommit({ diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index 4619c5b66..f284f747c 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -45,6 +45,7 @@ interface TargetTransformBaselineManager { acceptRevision: (revision: string) => void; publishSnapshot: (revision: string, stageState: IStageState) => void; failRevision: (revision: string) => void; + invalidatePendingRevision: () => void; invalidateCurrentRevision: () => void; queryTargetTransform: (target: string, revision: string) => TargetTransformQueryResultPayload; } @@ -108,6 +109,17 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, + invalidatePendingRevision() { + if (revisionState.status !== 'pending') { + return; + } + + revisionState = { + status: 'unavailable', + revision: revisionState.revision, + }; + }, + invalidateCurrentRevision() { if (revisionState.status === 'none') { return; From 4505d150c66e753823d42607fee37960179caf02 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:57:36 +0800 Subject: [PATCH 18/39] fix: adjusted the sync timing of the editor preview transform baseline --- .../Core/controller/gamePlay/nextSentence.ts | 10 ++-- .../controller/gamePlay/scriptExecutor.ts | 24 +++++++-- .../util/syncWithEditor/previewSyncRuntime.ts | 35 ++++++++----- .../runtime/previewSyncSceneCommand.ts | 51 ++++++++++++++----- .../runtime/targetTransformBaseline.ts | 35 +++++++++---- .../webgal/src/types/editorPreviewProtocol.ts | 12 ++--- 6 files changed, 117 insertions(+), 50 deletions(-) diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index ab226a8c9..94a7ab77d 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -1,4 +1,4 @@ -import { scriptExecutor } from './scriptExecutor'; +import { scriptExecutor, type ScriptExecutionOptions } from './scriptExecutor'; import { logger } from '../../util/logger'; import { webgalStore } from '@/store/store'; @@ -32,7 +32,11 @@ export const preForward = () => { /** * 执行一条语句或由 -next 连接的语句序列,只修改演算状态并收集演出。 */ -export const forward = () => { +export interface ForwardOptions { + scriptExecution?: ScriptExecutionOptions; +} + +export const forward = (options: ForwardOptions = {}) => { if (WebGAL.sceneManager.lockSceneWrite) { logger.warn('forward 被场景切换阻塞!'); return false; @@ -47,7 +51,7 @@ export const forward = () => { WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); WebGAL.gameplay.performController.beginCollectingPerforms(); try { - scriptExecutor(); + scriptExecutor(0, options.scriptExecution); } finally { WebGAL.gameplay.performController.endCollectingPerforms(); } diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 8bfbb5cd8..a5be2e53d 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -15,6 +15,15 @@ import { prefetchCurrentSceneByProgress } from '@/Core/util/prefetcher/progressP const MAX_FORWARD_SCRIPT_EXECUTION = 1000; +export interface ScriptExecutionContext { + sceneName: string; + sentenceId: number; +} + +export interface ScriptExecutionOptions { + beforeSentenceExecute?: (context: ScriptExecutionContext) => void; +} + export const whenChecker = (whenValue: string | undefined): boolean => { if (whenValue === undefined) { return true; @@ -39,7 +48,7 @@ export const whenChecker = (whenValue: string | undefined): boolean => { * 语句执行器 * 执行语句,同步场景状态,并根据情况立即执行下一句或者加入backlog */ -export const scriptExecutor = (depth = 0) => { +export const scriptExecutor = (depth = 0, options: ScriptExecutionOptions = {}) => { if (depth > MAX_FORWARD_SCRIPT_EXECUTION) { logger.error('forward 中执行的语句数量超过限制,可能存在 jumpLabel 或 -next 死循环'); return; @@ -59,8 +68,13 @@ export const scriptExecutor = (depth = 0) => { } return; } + const sentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; + options.beforeSentenceExecute?.({ + sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentenceId, + }); const currentScript: ISentence = cloneDeep( - WebGAL.sceneManager.sceneData.currentScene.sentenceList[WebGAL.sceneManager.sceneData.currentSentenceId], + WebGAL.sceneManager.sceneData.currentScene.sentenceList[sentenceId], ); const interpolationOneItem = (content: string): string => { @@ -104,7 +118,7 @@ export const scriptExecutor = (depth = 0) => { if (!runThis) { logger.warn('不满足条件,跳过本句!'); WebGAL.sceneManager.sceneData.currentSentenceId++; - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } @@ -115,7 +129,7 @@ export const scriptExecutor = (depth = 0) => { logger.warn(`未找到标签 ${currentScript.content},跳过 jumpLabel`); WebGAL.sceneManager.sceneData.currentSentenceId++; } - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } @@ -148,7 +162,7 @@ export const scriptExecutor = (depth = 0) => { if (isNext && !hasPendingBlockingStateCalculationPerform && !WebGAL.sceneManager.lockSceneWrite) { WebGAL.sceneManager.sceneData.currentSentenceId++; saveBacklogIfNeeded(); - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index ab247b93d..341aa7efb 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -52,9 +52,9 @@ import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; import { - canPublishTargetTransformBaselineSnapshot, cloneBaseTransform, createTargetTransformBaselineManager, + isTargetTransformBaselineSyncSettled, } from './runtime/targetTransformBaseline'; let previewSyncRuntimeStarted = false; @@ -226,27 +226,34 @@ export const startPreviewSyncRuntime = () => { const handleSyncScene = (payload: SyncScenePayload) => { setEffectBaselines.clear(); - const { previewSyncRevision } = payload; - if (previewSyncRevision) { - targetTransformBaselines.acceptRevision(previewSyncRevision); + const { transformBaselineRevision } = payload; + if (transformBaselineRevision) { + targetTransformBaselines.acceptRevision(transformBaselineRevision); } else { targetTransformBaselines.invalidateCurrentRevision(); } executePreviewSyncSceneCommand(payload, { onFastPreviewTimeout: emitFastPreviewTimeout, - onSettled: (result) => { - if (!previewSyncRevision) { + onBeforeTargetScriptExecute: () => { + if (!transformBaselineRevision) { return; } - const canPublishSnapshot = canPublishTargetTransformBaselineSnapshot(result, payload); - if (!canPublishSnapshot) { - targetTransformBaselines.failRevision(previewSyncRevision); + targetTransformBaselines.captureSnapshot( + transformBaselineRevision, + stageStateManager.getCalculationStageState(), + ); + }, + onSettled: (result) => { + if (!transformBaselineRevision) { return; } - targetTransformBaselines.publishSnapshot(previewSyncRevision, stageStateManager.getCalculationStageState()); + const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload); + if (!isSyncSettled || !targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision)) { + targetTransformBaselines.failRevision(transformBaselineRevision); + } }, }); }; @@ -391,14 +398,14 @@ export const startPreviewSyncRuntime = () => { }), ); return; - case 'preview.query.target-transform': + case 'preview.query.transform-baseline': transport.send( createResponseEnvelope( - 'preview.query.target-transform', + 'preview.query.transform-baseline', envelope.requestId, - targetTransformBaselines.queryTargetTransform( + targetTransformBaselines.queryTransformBaseline( envelope.payload.target, - envelope.payload.previewSyncRevision, + envelope.payload.transformBaselineRevision, ), ), ); diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index 56095d7c6..cdd9a3ba1 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -8,11 +8,7 @@ import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; import { sceneParser } from '@/Core/parser/sceneParser'; import { logger } from '@/Core/util/logger'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; -import type { - FastPreviewTimeoutPayload, - SyncScenePayload, - SyncSceneSettleMode, -} from '@/types/editorPreviewProtocol'; +import type { FastPreviewTimeoutPayload, SyncScenePayload, SyncSceneSettleMode } from '@/types/editorPreviewProtocol'; import { applyPreviewDebugVariables } from './previewDebugVariables'; export const FAST_PREVIEW_MAX_DURATION_MS = 500; @@ -31,9 +27,14 @@ export type FastPreviewStopReason = 'target-reached' | 'timeout' | 'state-calcul export interface PreviewSyncSceneCommandCallbacks { onFastPreviewTimeout?: FastPreviewTimeoutEmitter; + onBeforeTargetScriptExecute?: () => void; onSettled?: (result: FastPreviewResult | null) => void; } +interface RunFastPreviewOptions { + onBeforeTargetScriptExecute?: () => void; +} + export function executePreviewSyncSceneCommand( { sceneName, sentenceId, debugVariables, settleMode = 'normal' }: SyncScenePayload, callbacks: PreviewSyncSceneCommandCallbacks = {}, @@ -60,7 +61,9 @@ export function executePreviewSyncSceneCommand( applyPreviewDebugVariables(debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - void runFastPreview(sentenceId, currentSceneName, callbacks.onFastPreviewTimeout, settleMode) + void runFastPreview(sentenceId, currentSceneName, callbacks.onFastPreviewTimeout, settleMode, { + onBeforeTargetScriptExecute: callbacks.onBeforeTargetScriptExecute, + }) .then((result) => { callbacks.onSettled?.(result); }) @@ -82,6 +85,7 @@ export async function runFastPreview( currentSceneName: string, onFastPreviewTimeout?: FastPreviewTimeoutEmitter, settleMode: SyncSceneSettleMode = 'normal', + options: RunFastPreviewOptions = {}, ): Promise { const fastPreviewStartTime = performance.now(); const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; @@ -92,13 +96,40 @@ export async function runFastPreview( let stopReason: FastPreviewStopReason = 'target-reached'; let timeoutElapsedMs = 0; let suspendedElapsedMs = 0; + let didRunBeforeTargetScriptExecute = false; + + const runBeforeTargetScriptExecute = (sceneName: string, nextSentenceId: number) => { + if ( + settleMode !== 'immediate' || + !options.onBeforeTargetScriptExecute || + didRunBeforeTargetScriptExecute || + sceneName !== currentSceneName || + nextSentenceId !== sentenceId - 1 + ) { + return; + } + + WebGAL.gameplay.performController.discardUncommittedNonHoldPerforms(true); + WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); + options.onBeforeTargetScriptExecute?.(); + didRunBeforeTargetScriptExecute = true; + }; try { while (shouldContinueFastPreview(sentenceId, currentSceneName, baseSceneStackDepth)) { const prevSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; const prevSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - const isForwarded = forward(); + const isForwarded = forward({ + scriptExecution: { + beforeSentenceExecute: ({ sceneName, sentenceId }) => { + // sync-scene 的 sentenceId 是目标语句执行后的停止指针,目标编辑语句是 sentenceId - 1。 + runBeforeTargetScriptExecute(sceneName, sentenceId); + }, + }, + }); forwardCount++; + const postForwardSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; + const postForwardSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; const sceneWriteWaitStart = performance.now(); const awaitedSceneWrite = await waitForPendingSceneWrite(); if (awaitedSceneWrite) { @@ -125,11 +156,7 @@ export async function runFastPreview( break; } - if ( - WebGAL.sceneManager.sceneData.currentSentenceId === prevSentenceId && - WebGAL.sceneManager.sceneData.currentScene.sceneName === prevSceneName && - !awaitedSceneWrite - ) { + if (postForwardSentenceId === prevSentenceId && postForwardSceneName === prevSceneName && !awaitedSceneWrite) { logger.warn('实时预览跳转停止:本次 forward 没有推进语句指针'); stopReason = 'no-progress'; break; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index f284f747c..fa43311c0 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -2,7 +2,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { STAGE_KEYS } from '@/Core/constants'; import { baseTransform } from '@/Core/Modules/stage/stageInterface'; import type { IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; -import type { TargetTransformQueryResultPayload } from '@/types/editorPreviewProtocol'; +import type { TransformBaselineQueryResultPayload } from '@/types/editorPreviewProtocol'; import type { FastPreviewResult } from './previewSyncSceneCommand'; const FIXED_TARGETS = new Set([ @@ -20,6 +20,7 @@ type BaselineRevisionState = | { status: 'pending'; revision: string; + snapshot?: TargetTransformBaselineSnapshot; } | { status: 'ready'; @@ -36,27 +37,28 @@ interface TargetTransformBaselineSnapshot { transformsByTarget: Map; } -interface TargetTransformBaselinePublishTarget { +interface TargetTransformBaselineSyncTarget { sceneName: string; sentenceId: number; } interface TargetTransformBaselineManager { acceptRevision: (revision: string) => void; - publishSnapshot: (revision: string, stageState: IStageState) => void; + captureSnapshot: (revision: string, stageState: IStageState) => void; + publishCapturedSnapshot: (revision: string) => boolean; failRevision: (revision: string) => void; invalidatePendingRevision: () => void; invalidateCurrentRevision: () => void; - queryTargetTransform: (target: string, revision: string) => TargetTransformQueryResultPayload; + queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; } export function cloneBaseTransform(): ITransform { return cloneDeep(baseTransform); } -export function canPublishTargetTransformBaselineSnapshot( +export function isTargetTransformBaselineSyncSettled( result: FastPreviewResult | null, - target: TargetTransformBaselinePublishTarget, + target: TargetTransformBaselineSyncTarget, ): boolean { return ( result !== null && @@ -86,18 +88,31 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, - publishSnapshot(revision, stageState) { + captureSnapshot(revision, stageState) { if (!isPendingRevision(revision)) { return; } revisionState = { - status: 'ready', + status: 'pending', revision, snapshot: createSnapshot(stageState), }; }, + publishCapturedSnapshot(revision) { + if (revisionState.status !== 'pending' || revisionState.revision !== revision || !revisionState.snapshot) { + return false; + } + + revisionState = { + status: 'ready', + revision, + snapshot: revisionState.snapshot, + }; + return true; + }, + failRevision(revision) { if (!isLatestRevision(revision)) { return; @@ -131,7 +146,7 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, - queryTargetTransform(target, revision) { + queryTransformBaseline(target, revision) { if (!isLatestRevision(revision)) { return { status: 'unavailable', @@ -176,7 +191,7 @@ function createSnapshot(stageState: IStageState): TargetTransformBaselineSnapsho }; } -function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TargetTransformQueryResultPayload { +function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TransformBaselineQueryResultPayload { if (!snapshot.knownTargets.has(target)) { return { status: 'unavailable', diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index c3f4af6f4..b76546755 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -102,7 +102,7 @@ export interface SyncScenePayload { sceneName: string; sentenceId: number; debugVariables?: DebugVariable[]; - previewSyncRevision?: string; + transformBaselineRevision?: string; settleMode?: SyncSceneSettleMode; } @@ -137,9 +137,9 @@ export interface ReferenceBoxQueryPayload { export type BaseTransformQueryPayload = EmptyObject; -export interface TargetTransformQueryPayload { +export interface TransformBaselineQueryPayload { target: string; - previewSyncRevision: string; + transformBaselineRevision: string; } export interface ReferenceBox { @@ -169,7 +169,7 @@ export interface BaseTransformQueryResultPayload { baseTransform: Transform; } -export type TargetTransformQueryResultPayload = +export type TransformBaselineQueryResultPayload = | { status: 'ready'; transform: Transform; @@ -201,7 +201,7 @@ export const PREVIEW_COMMAND_TYPES = messageTypes(PREVIEW_COMMAND_PAYLOADS); export const PREVIEW_QUERY_PAYLOADS = definePayloadMap({ 'preview.query.reference-box': payload(), 'preview.query.base-transform': payload(), - 'preview.query.target-transform': payload(), + 'preview.query.transform-baseline': payload(), }); export type PreviewQueryPayloadByType = typeof PREVIEW_QUERY_PAYLOADS; @@ -279,7 +279,7 @@ export type PreviewCommandResponsePayloadByType = Record(), 'preview.query.base-transform': payload(), - 'preview.query.target-transform': payload(), + 'preview.query.transform-baseline': payload(), }); export type PreviewQueryResponsePayloadByType = typeof PREVIEW_QUERY_RESPONSE_PAYLOADS; From 2457b1509bcea775e77913e34cce55f79a1335ed Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:58:07 +0800 Subject: [PATCH 19/39] fix: stabilize set-effect preview baseline --- .../util/syncWithEditor/previewSyncRuntime.ts | 19 ++++++++++----- .../runtime/targetTransformBaseline.ts | 24 ++++++++++++++++--- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 341aa7efb..ea0457f65 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -321,23 +321,30 @@ export const startPreviewSyncRuntime = () => { setDebugTextReadMode(payload.isRead); }; - const getSetEffectBaseline = (target: string): ITransform => { + const getSetEffectBaseline = (target: string): ITransform | undefined => { const cachedBaseline = setEffectBaselines.get(target); if (cachedBaseline) { return cachedBaseline; } - const currentTransform = stageStateManager - .getCalculationStageState() - .effects.find((effect) => effect.target === target)?.transform; - const baseline = mergeSetEffectPreviewTransform(baseTransform, currentTransform); + const baselineOverride = targetTransformBaselines.getReadyTransformBaselineOverride(target); + if (baselineOverride === undefined) { + return undefined; + } + + const baseline = mergeSetEffectPreviewTransform(baseTransform, baselineOverride); setEffectBaselines.set(target, baseline); return baseline; }; const handleSetEffect = (payload: SetEffectPayload) => { targetTransformBaselines.invalidatePendingRevision(); - const newTransform = mergeSetEffectPreviewTransform(getSetEffectBaseline(payload.target), payload.transform); + const baseline = getSetEffectBaseline(payload.target); + if (!baseline) { + return; + } + + const newTransform = mergeSetEffectPreviewTransform(baseline, payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); stageStateManager.updateEffectAndCommit({ target: payload.target, diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index fa43311c0..4eeee0c0b 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -49,6 +49,7 @@ interface TargetTransformBaselineManager { failRevision: (revision: string) => void; invalidatePendingRevision: () => void; invalidateCurrentRevision: () => void; + getReadyTransformBaselineOverride: (target: string) => ITransform | undefined; queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; } @@ -146,6 +147,14 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, + getReadyTransformBaselineOverride(target) { + if (revisionState.status !== 'ready') { + return undefined; + } + + return getSnapshotTransformOverride(revisionState.snapshot, target); + }, + queryTransformBaseline(target, revision) { if (!isLatestRevision(revision)) { return { @@ -192,19 +201,28 @@ function createSnapshot(stageState: IStageState): TargetTransformBaselineSnapsho } function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TransformBaselineQueryResultPayload { - if (!snapshot.knownTargets.has(target)) { + const transform = getSnapshotTransformOverride(snapshot, target); + if (transform === undefined) { return { status: 'unavailable', }; } - const transform = snapshot.transformsByTarget.get(target); return { status: 'ready', - transform: transform ? createSparseTransformOverride(transform, baseTransform) : {}, + transform, }; } +function getSnapshotTransformOverride(snapshot: TargetTransformBaselineSnapshot, target: string): ITransform | undefined { + if (!snapshot.knownTargets.has(target)) { + return undefined; + } + + const transform = snapshot.transformsByTarget.get(target); + return transform ? createSparseTransformOverride(transform, baseTransform) : {}; +} + function createSparseTransformOverride(transform: ITransform, base: ITransform): ITransform { const result: ITransform = {}; From b3ecb287108421f710164ac94bf86e8b88ff452c Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:36:25 +0800 Subject: [PATCH 20/39] fix: correct preview set-effect transform baseline --- .../Core/util/syncWithEditor/previewSyncRuntime.ts | 14 ++++---------- .../runtime/targetTransformBaseline.ts | 12 ------------ 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index ea0457f65..aad97a9ea 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -253,7 +253,10 @@ export const startPreviewSyncRuntime = () => { const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload); if (!isSyncSettled || !targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision)) { targetTransformBaselines.failRevision(transformBaselineRevision); + return; } + + setEffectBaselines.clear(); }, }); }; @@ -321,29 +324,20 @@ export const startPreviewSyncRuntime = () => { setDebugTextReadMode(payload.isRead); }; - const getSetEffectBaseline = (target: string): ITransform | undefined => { + const getSetEffectBaseline = (target: string): ITransform => { const cachedBaseline = setEffectBaselines.get(target); if (cachedBaseline) { return cachedBaseline; } const baselineOverride = targetTransformBaselines.getReadyTransformBaselineOverride(target); - if (baselineOverride === undefined) { - return undefined; - } - const baseline = mergeSetEffectPreviewTransform(baseTransform, baselineOverride); setEffectBaselines.set(target, baseline); return baseline; }; const handleSetEffect = (payload: SetEffectPayload) => { - targetTransformBaselines.invalidatePendingRevision(); const baseline = getSetEffectBaseline(payload.target); - if (!baseline) { - return; - } - const newTransform = mergeSetEffectPreviewTransform(baseline, payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); stageStateManager.updateEffectAndCommit({ diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index 4eeee0c0b..38729b821 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -47,7 +47,6 @@ interface TargetTransformBaselineManager { captureSnapshot: (revision: string, stageState: IStageState) => void; publishCapturedSnapshot: (revision: string) => boolean; failRevision: (revision: string) => void; - invalidatePendingRevision: () => void; invalidateCurrentRevision: () => void; getReadyTransformBaselineOverride: (target: string) => ITransform | undefined; queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; @@ -125,17 +124,6 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, - invalidatePendingRevision() { - if (revisionState.status !== 'pending') { - return; - } - - revisionState = { - status: 'unavailable', - revision: revisionState.revision, - }; - }, - invalidateCurrentRevision() { if (revisionState.status === 'none') { return; From 3eb259052edb4c2263aee6469d78427f0b51eb85 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:59:33 +0800 Subject: [PATCH 21/39] fix: wait for reference box geometry before replying --- .../controller/stage/pixi/PixiController.ts | 42 ++++++++++++++++++- .../src/Core/controller/stage/pixi/spine.ts | 2 + .../util/syncWithEditor/previewSyncRuntime.ts | 9 +++- .../handlers/referenceBoxQueryHandler.ts | 25 +++++++---- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 34e98c52d..992e9a701 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -121,6 +121,7 @@ export default class PixiStage { private isRenderPending = false; // 更新 ticker 状态的防抖标记 private isTickerUpdatePending = false; + private referenceBoxWaiters = new Map void>>(); /** * 暂时没用上,以后可能用 @@ -468,6 +469,7 @@ export default class PixiStage { // 挂载 thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); this.requestRender(); } }, 0); @@ -552,6 +554,7 @@ export default class PixiStage { thisBgContainer.setBaseY(this.stageHeight / 2); thisBgContainer.pivot.set(0, this.stageHeight / 2); thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); }); } }, 0); @@ -610,7 +613,6 @@ export default class PixiStage { sourceType: sourceExt === 'gif' ? 'gif' : 'img', sourceExt, }); - // 完成图片加载后执行的函数 const setup = () => { // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! @@ -647,6 +649,7 @@ export default class PixiStage { } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); this.requestRender(); } }, 0); @@ -808,6 +811,7 @@ export default class PixiStage { Live2D.SoundManager.volume = 0; // @ts-ignore thisFigureContainer.addChild(model); + instance.notifyTargetReferenceBoxChanged(key); }); })(); } @@ -1038,6 +1042,29 @@ export default class PixiStage { }); } + public waitForTargetReferenceBox(target: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const existingWaiters = this.referenceBoxWaiters.get(target); + const waiters = existingWaiters ?? new Set<() => void>(); + if (!existingWaiters) { + this.referenceBoxWaiters.set(target, waiters); + } + + let timeoutId = 0; + const resolveAndCleanup = () => { + window.clearTimeout(timeoutId); + waiters.delete(resolveAndCleanup); + if (waiters.size === 0) { + this.referenceBoxWaiters.delete(target); + } + resolve(); + }; + + timeoutId = window.setTimeout(resolveAndCleanup, timeoutMs); + waiters.add(resolveAndCleanup); + }); + } + public getStageObjByUuid(objUuid: string) { return [...this.figureObjects, ...this.backgroundObjects, this.mainStageObject].find((e) => e.uuid === objUuid); } @@ -1065,6 +1092,7 @@ export default class PixiStage { } bgSprite.pixiContainer = null; this.figureObjects.splice(indexFig, 1); + this.notifyTargetReferenceBoxChanged(key); } if (indexBg >= 0) { const bgSprite = this.backgroundObjects[indexBg]; @@ -1078,6 +1106,7 @@ export default class PixiStage { } bgSprite.pixiContainer = null; this.backgroundObjects.splice(indexBg, 1); + this.notifyTargetReferenceBoxChanged(key); } // /** // * 删掉相关 Effects,因为已经移除了 @@ -1150,6 +1179,17 @@ export default class PixiStage { } } + public notifyTargetReferenceBoxChanged(target: string): void { + const waiters = this.referenceBoxWaiters.get(target); + if (!waiters) { + return; + } + + for (const resolve of [...waiters]) { + resolve(); + } + } + private callLoader() { if (!this.assetLoader.loading) { const front = this.loadQueue.shift(); diff --git a/packages/webgal/src/Core/controller/stage/pixi/spine.ts b/packages/webgal/src/Core/controller/stage/pixi/spine.ts index f32eb3bb7..22cd07a2a 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/spine.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/spine.ts @@ -172,6 +172,7 @@ export async function addSpineFigureImpl( } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); } }, 0); }; @@ -264,6 +265,7 @@ export async function addSpineBgImpl(this: PixiStage, key: string, url: string) // 挂载 thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); } }, 0); }; diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index aad97a9ea..4dd3ddb09 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -390,7 +390,14 @@ export const startPreviewSyncRuntime = () => { const handlePreviewQuery = (envelope: PreviewQueryEnvelope) => { switch (envelope.type) { case 'preview.query.reference-box': - transport.send(handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview)); + void handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview) + .then((response) => { + transport.send(response); + }) + .catch((error) => { + logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + sendRequestError(envelope, 'internal-error', '预览运行时无法安全完成该请求'); + }); return; case 'preview.query.base-transform': transport.send( diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts index cc621a772..e55e1da84 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -7,13 +7,16 @@ import type { interface ReferenceBoxQueryStage { queryTargetReferenceBox(target: string): ReferenceBoxQueryResultPayload; + waitForTargetReferenceBox(target: string, timeoutMs: number): Promise; } -export function handleReferenceBoxQuery( +const REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS = 300; + +export async function handleReferenceBoxQuery( request: RequestEnvelopeByType<'preview.query.reference-box'>, pixiStage: ReferenceBoxQueryStage | null | undefined, isSupported = true, -): ResponseEnvelopeByType<'preview.query.reference-box'> { +): Promise> { if (!isSupported) { return createResponseEnvelope('preview.query.reference-box', request.requestId, { target: request.payload.target, @@ -24,11 +27,19 @@ export function handleReferenceBoxQuery( const { target } = request.payload; - const result = pixiStage?.queryTargetReferenceBox(target) ?? { - target, - status: 'unsupported' as const, - reason: 'Pixi stage 不可用', - }; + if (!pixiStage) { + return createResponseEnvelope('preview.query.reference-box', request.requestId, { + target, + status: 'unsupported', + reason: 'Pixi stage 不可用', + }); + } + + let result = pixiStage.queryTargetReferenceBox(target); + if (result.status === 'loading') { + await pixiStage.waitForTargetReferenceBox(target, REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS); + result = pixiStage.queryTargetReferenceBox(target); + } return createResponseEnvelope('preview.query.reference-box', request.requestId, result); } From b2d1068b82c2b8e0cb1118c6fc5daa7fcd5c8140 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:21:17 +0800 Subject: [PATCH 22/39] refactor: clean up editor preview transform queries --- .../src/Core/controller/stage/pixi/PixiController.ts | 2 +- .../Core/util/syncWithEditor/previewSyncRuntime.ts | 12 +----------- .../runtime/handlers/referenceBoxQueryHandler.ts | 9 --------- .../runtime/previewSyncSceneCommand.ts | 1 + 4 files changed, 3 insertions(+), 21 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 992e9a701..202bf96d1 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -14,7 +14,7 @@ import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; -import { queryStageObjectReferenceBox, QueryTargetReferenceBoxResult } from './referenceBox'; +import { queryStageObjectReferenceBox, type QueryTargetReferenceBoxResult } from './referenceBox'; export interface IAnimationObject { setStartState: Function; diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 4dd3ddb09..f20681995 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -5,7 +5,6 @@ import { createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, isAnyProtocolEnvelope, - isPreviewCommandType, isPreviewQueryType, isPreviewRequestEnvelope, } from '@/types/editorPreviewProtocol'; @@ -99,7 +98,6 @@ export const startPreviewSyncRuntime = () => { let registered = false; let pendingRegisterRequestId: string | null = null; let pendingRegisterContext: RegisterPreviewLogContext | null = null; - let isEmbeddedPreview = false; let lastPublishedSceneName: string | null = null; let lastPublishedSentenceId: number | null = null; let lastPublishedStageState: StageStateSnapshot | null = null; @@ -130,7 +128,6 @@ export const startPreviewSyncRuntime = () => { registered = false; pendingRegisterRequestId = null; pendingRegisterContext = null; - isEmbeddedPreview = false; lastPublishedSceneName = null; lastPublishedSentenceId = null; lastPublishedStageState = null; @@ -212,7 +209,6 @@ export const startPreviewSyncRuntime = () => { pendingRegisterRequestId = null; pendingRegisterContext = null; registered = true; - isEmbeddedPreview = Boolean(registeredPreviewContext?.embeddedLaunchId); publishReady(); publishStageSnapshot(true); }; @@ -390,7 +386,7 @@ export const startPreviewSyncRuntime = () => { const handlePreviewQuery = (envelope: PreviewQueryEnvelope) => { switch (envelope.type) { case 'preview.query.reference-box': - void handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview) + void handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage) .then((response) => { transport.send(response); }) @@ -438,12 +434,6 @@ export const startPreviewSyncRuntime = () => { return; } - if (!isPreviewCommandType(envelope.type)) { - logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); - sendRequestError(envelope, 'unsupported-request-type', UNSUPPORTED_REQUEST_MESSAGE); - return; - } - const responsePayload = handlePreviewCommand(envelope.type, envelope.payload); transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); }; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts index e55e1da84..5e0d74308 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -15,16 +15,7 @@ const REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS = 300; export async function handleReferenceBoxQuery( request: RequestEnvelopeByType<'preview.query.reference-box'>, pixiStage: ReferenceBoxQueryStage | null | undefined, - isSupported = true, ): Promise> { - if (!isSupported) { - return createResponseEnvelope('preview.query.reference-box', request.requestId, { - target: request.payload.target, - status: 'unsupported', - reason: '当前预览不支持 transform overlay reference box 查询', - }); - } - const { target } = request.payload; if (!pixiStage) { diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index cdd9a3ba1..407273fb9 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -137,6 +137,7 @@ export async function runFastPreview( } if (!isForwarded && !awaitedSceneWrite) { + stopReason = 'no-progress'; break; } From 1b5cb8f16bb6c53e26314054a8f4ea9a6d62316d Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Thu, 25 Jun 2026 06:40:42 +0800 Subject: [PATCH 23/39] fix: correct rotated reference bounds --- .../stage/pixi/WebGALPixiContainer.ts | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts index c815db6fb..0dead6a9f 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts @@ -331,18 +331,29 @@ const PROPERTY_CONFIGS: Record = { function getChildReferenceBounds(child: PIXI.DisplayObject, containerPivot: PIXI.IPointData): PIXI.Rectangle { child.transform.updateLocalTransform(); const bounds = child.getLocalBounds(); - const transform = child.transform.localTransform; - const left = bounds.x * child.scale.x + transform.tx - containerPivot.x; - const right = (bounds.x + bounds.width) * child.scale.x + transform.tx - containerPivot.x; - const top = bounds.y * child.scale.y + transform.ty - containerPivot.y; - const bottom = (bounds.y + bounds.height) * child.scale.y + transform.ty - containerPivot.y; - - return new PIXI.Rectangle( - Math.min(left, right), - Math.min(top, bottom), - Math.abs(right - left), - Math.abs(bottom - top), - ); + const { a, b, c, d, tx, ty } = child.transform.localTransform; + const x0 = bounds.x; + const x1 = bounds.x + bounds.width; + const y0 = bounds.y; + const y1 = bounds.y + bounds.height; + const pivotX = containerPivot.x; + const pivotY = containerPivot.y; + + const p0x = a * x0 + c * y0 + tx - pivotX; + const p0y = b * x0 + d * y0 + ty - pivotY; + const p1x = a * x1 + c * y0 + tx - pivotX; + const p1y = b * x1 + d * y0 + ty - pivotY; + const p2x = a * x0 + c * y1 + tx - pivotX; + const p2y = b * x0 + d * y1 + ty - pivotY; + const p3x = a * x1 + c * y1 + tx - pivotX; + const p3y = b * x1 + d * y1 + ty - pivotY; + + const minX = Math.min(p0x, p1x, p2x, p3x); + const minY = Math.min(p0y, p1y, p2y, p3y); + const maxX = Math.max(p0x, p1x, p2x, p3x); + const maxY = Math.max(p0y, p1y, p2y, p3y); + + return new PIXI.Rectangle(minX, minY, maxX - minX, maxY - minY); } function mergeBounds(target: PIXI.Rectangle, next: PIXI.Rectangle): void { From de21fd0c1c35deb2e133ffa4e1ec3ccc78394a32 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:33:08 +0800 Subject: [PATCH 24/39] fix: normalize transform frame parsing Treat empty setTransform content as an empty transform frame so -writeDefault follows the same base default path as setTransform:{}. Accept only object-shaped transform JSON for setTransform, changeFigure, and changeBg. Non-object values from malformed or empty transform input fall back to the default transform path instead of entering animation frames. --- .../src/Core/gameScripts/changeBg/index.ts | 31 +++++++----------- .../src/Core/gameScripts/changeFigure.ts | 32 +++++++------------ .../src/Core/gameScripts/setTransform.ts | 9 +++--- 3 files changed, 28 insertions(+), 44 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/changeBg/index.ts b/packages/webgal/src/Core/gameScripts/changeBg/index.ts index f65d6d350..ded9f103c 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/index.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/index.ts @@ -15,6 +15,7 @@ import { WebGAL } from '@/Core/WebGAL'; import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; import localforage from 'localforage'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { parseTransformFrame } from '../parseTransformFrame'; /** * 进行背景图片的切换 @@ -56,29 +57,15 @@ export const changeBg = (sentence: ISentence): IPerform => { // 处理 transform 和 默认 transform let animationObj: AnimationFrame[]; - if (transformString) { - try { - const frame = JSON.parse(transformString.toString()) as AnimationFrame; - animationObj = generateTransformAnimationObj('bg-main', frame, enterDuration, ease, !ignoreDefault); - // 因为是切换,必须把一开始的 alpha 改为 0 - animationObj[0].alpha = 0; - const animationName = (Math.random() * 10).toString(16); - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); - duration = getAnimateDuration(animationName); - stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }); - } catch (e) { - // 解析都错误了,歇逼吧 - applyDefaultTransform(); - } + const frame = transformString ? parseTransformFrame(transformString) : null; + if (frame) { + applyTransform(frame); } else { applyDefaultTransform(); } - function applyDefaultTransform() { - // 应用默认的 - const frame = {}; - animationObj = generateTransformAnimationObj('bg-main', frame as AnimationFrame, duration, ease, !ignoreDefault); + function applyTransform(frame: AnimationFrame) { + animationObj = generateTransformAnimationObj('bg-main', frame, enterDuration, ease, !ignoreDefault); // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); @@ -87,6 +74,12 @@ export const changeBg = (sentence: ISentence): IPerform => { duration = getAnimateDuration(animationName); stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }); } + + function applyDefaultTransform() { + // 应用默认的 + const frame = {}; + applyTransform(frame as AnimationFrame); + } stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationIgnoreDefault', diff --git a/packages/webgal/src/Core/gameScripts/changeFigure.ts b/packages/webgal/src/Core/gameScripts/changeFigure.ts index b6b64e6e4..54c07859d 100644 --- a/packages/webgal/src/Core/gameScripts/changeFigure.ts +++ b/packages/webgal/src/Core/gameScripts/changeFigure.ts @@ -12,6 +12,7 @@ import { WebGAL } from '@/Core/WebGAL'; import { baseBlinkParam, baseFocusParam, BlinkParam, FocusParam } from '@/Core/live2DCore'; import { DEFAULT_FIG_IN_DURATION, DEFAULT_FIG_OUT_DURATION, WEBGAL_NONE } from '../constants'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { parseTransformFrame } from './parseTransformFrame'; /** * 更改立绘 * @param sentence 语句 @@ -161,30 +162,15 @@ export function changeFigure(sentence: ISentence): IPerform { } // 处理 transform 和 默认 transform let animationObj: AnimationFrame[]; - if (transformString) { - console.log(transformString); - try { - const frame = JSON.parse(transformString) as AnimationFrame; - animationObj = generateTransformAnimationObj(key, frame, duration, ease, !ignoreDefault); - // 因为是切换,必须把一开始的 alpha 改为 0 - animationObj[0].alpha = 0; - const animationName = (Math.random() * 10).toString(16); - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); - duration = getAnimateDuration(animationName); - stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }); - } catch (e) { - // 解析都错误了,歇逼吧 - applyDefaultTransform(); - } + const frame = transformString ? parseTransformFrame(transformString) : null; + if (frame) { + applyTransform(frame); } else { applyDefaultTransform(); } - function applyDefaultTransform() { - // 应用默认的 - const frame = {}; - animationObj = generateTransformAnimationObj(key, frame as AnimationFrame, duration, ease, !ignoreDefault); + function applyTransform(frame: AnimationFrame) { + animationObj = generateTransformAnimationObj(key, frame, duration, ease, !ignoreDefault); // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); @@ -193,6 +179,12 @@ export function changeFigure(sentence: ISentence): IPerform { duration = getAnimateDuration(animationName); stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }); } + + function applyDefaultTransform() { + // 应用默认的 + const frame = {}; + applyTransform(frame as AnimationFrame); + } stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationIgnoreDefault', diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index f306c34ea..ff72934fd 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -9,6 +9,7 @@ import { WebGAL } from '@/Core/WebGAL'; import { applyAnimationEndState, getAnimateDuration } from '../Modules/animationFunctions'; import { v4 as uuid } from 'uuid'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; +import { parseSetTransformFrame } from './parseTransformFrame'; /** * 设置变换 * @param sentence @@ -31,12 +32,10 @@ export const setTransform = (sentence: ISentence): IPerform => { if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); - try { - const frame = JSON.parse(animationString) as AnimationFrame; + const frame = parseSetTransformFrame(animationString); + if (frame) { animationObj = generateTransformAnimationObj(target, frame, duration, ease, writeFullEffect); - console.log('animationObj:', animationObj); - } catch (e) { - // 解析都错误了,歇逼吧 + } else { animationObj = []; } From a28217e0745f8c6ae235b1bac4603e78764b4594 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:38:12 +0800 Subject: [PATCH 25/39] fix: add missing files --- .../Core/gameScripts/parseTransformFrame.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/webgal/src/Core/gameScripts/parseTransformFrame.ts diff --git a/packages/webgal/src/Core/gameScripts/parseTransformFrame.ts b/packages/webgal/src/Core/gameScripts/parseTransformFrame.ts new file mode 100644 index 000000000..0dbcdda52 --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/parseTransformFrame.ts @@ -0,0 +1,21 @@ +import { AnimationFrame } from '@/Core/Modules/animations'; + +export function parseTransformFrame(raw: string): AnimationFrame | null { + const source = raw.trim(); + if (source === '') return null; + + try { + const parsed: unknown = JSON.parse(source); + return isTransformFrame(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function parseSetTransformFrame(raw: string): AnimationFrame | null { + return parseTransformFrame(raw.trim() === '' ? '{}' : raw); +} + +function isTransformFrame(value: unknown): value is AnimationFrame { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} From 85954a276cc182737d0cfce61fa160188378ca1e Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:52:07 +0800 Subject: [PATCH 26/39] perf: avoid full stage commits for set-effect preview updates --- .../controller/stage/pixi/PixiController.ts | 19 +------- .../stage/pixi/stageEffectTransform.ts | 45 +++++++++++++++++++ .../stage/pixi/syncPixiStageState.ts | 30 ++++++------- .../util/syncWithEditor/previewSyncRuntime.ts | 6 +++ .../webgal/src/types/editorPreviewProtocol.ts | 3 ++ 5 files changed, 71 insertions(+), 32 deletions(-) create mode 100644 packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 202bf96d1..da395ffcd 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -8,13 +8,12 @@ import { SCREEN_CONSTANTS } from '@/Core/util/constants'; import { logger } from '@/Core/util/logger'; import { v4 as uuid } from 'uuid'; import { cloneDeep, isEqual } from 'lodash'; -import omitBy from 'lodash/omitBy'; -import isUndefined from 'lodash/isUndefined'; import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { queryStageObjectReferenceBox, type QueryTargetReferenceBoxResult } from './referenceBox'; +import { assignPixiTransform } from './stageEffectTransform'; export interface IAnimationObject { setStartState: Function; @@ -70,21 +69,7 @@ INSTALLED.push(GifResource); export default class PixiStage { public static assignTransform(target: T, source?: ITransform, convertAlpha = true) { - if (!source) return; - const targetScale = target.scale; - const targetPosition = target.position; - if (target.scale) Object.assign(targetScale!, omitBy(source.scale || {}, isUndefined)); - if (target.position) Object.assign(targetPosition!, omitBy(source.position || {}, isUndefined)); - Object.assign(target, omitBy(source, isUndefined)); - target.scale = targetScale; - target.position = targetPosition; - if (convertAlpha) { - const sourceAlpha = source.alpha; - if (sourceAlpha !== undefined) { - target.alpha = 1; - (target as any).alphaFilterVal = sourceAlpha; - } - } + assignPixiTransform(target, source, convertAlpha); } /** diff --git a/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts b/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts new file mode 100644 index 000000000..8c7ab6aea --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts @@ -0,0 +1,45 @@ +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import type { ITransform } from '@/Core/Modules/stage/stageInterface'; +import { isUndefined, omitBy } from 'lodash'; +import type { WebGALPixiContainer } from './WebGALPixiContainer'; + +type PixiTransformPatch = ITransform & { + x?: number; + y?: number; + alphaFilterVal?: number; +}; + +export function assignPixiTransform( + target: T | undefined, + source?: PixiTransformPatch, + convertAlpha = true, +) { + if (!target || !source) return; + const targetScale = target.scale; + const targetPosition = target.position; + if (targetScale) Object.assign(targetScale, omitBy(source.scale || {}, isUndefined)); + if (targetPosition) Object.assign(targetPosition, omitBy(source.position || {}, isUndefined)); + Object.assign(target, omitBy(source, isUndefined)); + target.scale = targetScale; + target.position = targetPosition; + if (convertAlpha) { + const sourceAlpha = source.alpha; + if (sourceAlpha !== undefined) { + target.alpha = 1; + target.alphaFilterVal = sourceAlpha; + } + } +} + +function toPixiTransformPatch(transform: ITransform): PixiTransformPatch { + const { position, ...rest } = transform; + return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); +} + +export function applyTransformToPixiContainer( + container: WebGALPixiContainer | null | undefined, + transform?: ITransform, +) { + if (!container) return; + assignPixiTransform(container, toPixiTransformPatch(transform ?? baseTransform)); +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index 12786e9ba..f3cba8412 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -1,14 +1,12 @@ -import { baseTransform } from '@/Core/Modules/stage/stageInterface'; import type { IEffect, IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; import type { IResolvedStageCommitOptions } from '@/Core/Modules/stage/stageStateManager'; import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; import { WebGAL } from '@/Core/WebGAL'; -import PixiStage from '@/Core/controller/stage/pixi/PixiController'; import type { IStageObject } from '@/Core/controller/stage/pixi/PixiController'; import { getEnterExitAnimation } from '@/Core/Modules/animationFunctions'; import { logger } from '@/Core/util/logger'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; -import { isUndefined, omitBy } from 'lodash'; +import { applyTransformToPixiContainer } from '@/Core/controller/stage/pixi/stageEffectTransform'; export function syncPixiStageState(stageState: IStageState, options: IResolvedStageCommitOptions) { if (options.syncPixiStage) { @@ -31,15 +29,25 @@ export function applyStageEffects(effects: IEffect[]) { const key = stageObj.key; if (lockedStageTargets.includes(key)) continue; const effect = effects.find((effect) => effect.target === key); - const targetPixiContainer = pixiStage.getStageObjByKey(key); - const container = targetPixiContainer?.pixiContainer; + const container = stageObj.pixiContainer; if (!container) continue; - // @ts-ignore WebGALPixiContainer exposes transform-like fields. - PixiStage.assignTransform(container, convertTransform(effect?.transform ?? baseTransform)); + applyTransformToPixiContainer(container, effect?.transform); } pixiStage.requestRender(); } +export function applyStageEffectToTarget(target: string, transform: ITransform | undefined) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + if (pixiStage.getAllLockedObject().includes(target)) return; + + const container = pixiStage.getStageObjByKey(target)?.pixiContainer; + if (!container) return; + + applyTransformToPixiContainer(container, transform); + pixiStage.requestRender(); +} + function syncBg(stageState: IStageState) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; @@ -230,11 +238,3 @@ function addFigure(key: string, url: string, position: 'left' | 'center' | 'righ pixiStage.addFigure(key, url, position); } } - -function convertTransform(transform: ITransform | undefined) { - if (!transform) { - return {}; - } - const { position, ...rest } = transform; - return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); -} diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index f20681995..79f400ea5 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -39,6 +39,7 @@ import { logger } from '@/Core/util/logger'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { baseTransform } from '@/Core/Modules/stage/stageInterface'; import type { IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; +import { applyStageEffectToTarget } from '@/Core/controller/stage/pixi/syncPixiStageState'; import { mergeSetEffectPreviewTransform } from './previewSetEffectTransform'; import { requestEmbeddedLaunchId } from './runtime/embeddedPreviewBootstrap'; import { @@ -336,6 +337,11 @@ export const startPreviewSyncRuntime = () => { const baseline = getSetEffectBaseline(payload.target); const newTransform = mergeSetEffectPreviewTransform(baseline, payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); + if (payload.phase === 'preview') { + applyStageEffectToTarget(payload.target, newTransform); + return; + } + stageStateManager.updateEffectAndCommit({ target: payload.target, transform: newTransform, diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index b76546755..a18ae9c8d 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -118,9 +118,12 @@ export interface RunSnippetPayload { export type ReloadTemplatesPayload = EmptyObject; +export type SetEffectPhase = 'preview' | 'commit'; + export interface SetEffectPayload { target: string; transform?: Transform; + phase?: SetEffectPhase; } export interface SetFontOptimizationPayload { From 1d5882ef65720aecc523ce6347c1ea0a3c160251 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 27 Jun 2026 16:05:11 +0800 Subject: [PATCH 27/39] =?UTF-8?q?fix:=20#987=20next=20=E5=90=8E=E6=8E=A5?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E8=B7=B3=E8=BD=AC=E5=A4=B1=E6=95=88=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Modules/perform/performController.ts | 55 +++++++++++++++---- .../Core/Modules/perform/performInterface.ts | 10 ++-- .../Core/controller/gamePlay/nextSentence.ts | 53 ++++++++++++++++-- .../controller/gamePlay/startContinueGame.ts | 4 +- .../src/Core/controller/scene/callScene.ts | 5 +- .../src/Core/controller/scene/changeScene.ts | 5 +- .../src/Core/controller/scene/restoreScene.ts | 5 +- .../Core/gameScripts/getUserInput/index.tsx | 4 +- .../webgal/src/Core/gameScripts/label/jmp.ts | 4 +- .../util/syncWithEditor/previewSyncRuntime.ts | 4 +- 10 files changed, 112 insertions(+), 37 deletions(-) diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index 7d51c3c71..a9ba6969b 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -1,6 +1,6 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { WEBGAL_NONE } from '@/Core/constants'; import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; @@ -110,15 +110,37 @@ export class PerformController { return this.pendingPerformList.some(({ perform }) => perform.blockingStateCalculation?.() ?? false); } + /** + * 是否存在正在运行且阻塞下一步的演出。 + * + * blockingNext 是最强的运行时推进阻塞:用户 next、内部 continue 和 forward 都要等待它解除。 + */ public hasBlockingNextPerform() { return this.performList.some((e) => e.blockingNext()); } + /** + * 是否存在可以被下一步提前结束的普通演出。 + * + * 这不是一种阻塞。它表示当前 next/continue 需要先结算这些非 hold 演出, + * 让它们的 stopFunction 和状态清理完成后,再决定是否继续推进。 + */ public hasUnsettledNonHoldPerform() { return this.performList.some((e) => !e.isHoldOn && !e.skipNextCollect); } - public settleNonHoldPerforms() { + /** + * 结算当前正在运行的普通非 hold 演出。 + * + * 用户 next 调用时,goNextWhenOver 应传 true,保留旧语义: + * 如果被提前结束的演出要求结束后继续,则由 perform 自己触发内部继续。 + * + * 内部 continue 调用时,goNextWhenOver 应传 false: + * 调用方会在结算后立即继续 forward,不能再让旧 perform 额外触发一次继续。 + * + * @param goNextWhenOver 是否消费被结算演出的 goNextWhenOver 标记。 + */ + public settleNonHoldPerforms(goNextWhenOver = true) { let isGoNext = false; for (let i = 0; i < this.performList.length; i++) { const e = this.performList[i]; @@ -136,8 +158,8 @@ export class PerformController { } } stageStateManager.commit(); - if (isGoNext) { - nextSentence(); + if (isGoNext && goNextWhenOver) { + continueSentence(); } } @@ -145,6 +167,12 @@ export class PerformController { stageStateManager.clearUncommittedNonHoldPerforms(); } + /** + * 启动一个已提交的 perform。 + * + * startFunction 只会在 commitPendingPerforms 之后运行,因此可以依赖已提交的 stage state。 + * 这里同时把脚本层的 -continue 转成 perform.goNextWhenOver。 + */ private startPerform(perform: IPerform, script: ISentence) { perform.isStarted = true; perform.startFunction?.(); @@ -186,15 +214,14 @@ export class PerformController { this.clearPerformTimeout(e); /** * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前 - * 因为 goNextWhenOver 会调用 nextSentence,而 nextSentence 会清除目前未结束的演出 - * 那么 nextSentence 函数就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 + * 因为 goNextWhenOver 会触发继续推进,而继续推进会清除目前未结束的演出 + * 那么继续推进就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 * 所以 goNextWhenOver 后的代码会多删东西,解决方法就是在调用 goNextWhenOver 前先删掉这个演出对象 * 此问题对所有 goNextWhenOver 属性为真的演出都有影响,但只有 2 个演出有此问题 */ this.performList.splice(i, 1); i--; if (e.goNextWhenOver) { - // nextSentence(); this.goNextWhenOver(); } this.erasePerformFromState(name); @@ -212,7 +239,6 @@ export class PerformController { this.performList.splice(i, 1); i--; if (e.goNextWhenOver) { - // nextSentence(); this.goNextWhenOver(); } /** @@ -259,8 +285,8 @@ export class PerformController { this.clearPerformTimeout(perform); /** * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前 - * 因为 goNextWhenOver 会调用 nextSentence,而 nextSentence 会清除目前未结束的演出 - * 那么 nextSentence 函数就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 + * 因为 goNextWhenOver 会触发继续推进,而继续推进会清除目前未结束的演出 + * 那么继续推进就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 * 所以 goNextWhenOver 后的代码会多删东西,解决方法就是在调用 goNextWhenOver 前先删掉这个演出对象 * 此问题对所有 goNextWhenOver 属性为真的演出都有影响,但只有 2 个演出有此问题 */ @@ -268,7 +294,6 @@ export class PerformController { this.erasePerformFromState(perform.performName); stageStateManager.commit(); if (perform.goNextWhenOver) { - // nextSentence(); this.goNextWhenOver(); } } @@ -300,6 +325,12 @@ export class PerformController { perform.isStarted = false; } + /** + * perform 结束后的内部继续推进。 + * + * goNextWhenOver 不等于无条件跳下一句;它仍然必须等待所有 blockingNext 演出结束。 + * 等待完成后使用 continueSentence,避免触发 userInteractNext,也避免把自然结束误当成用户点击。 + */ private goNextWhenOver = () => { let isBlockingNext = false; this.performList?.forEach((e) => { @@ -311,7 +342,7 @@ export class PerformController { // 有阻塞,提前结束 setTimeout(this.goNextWhenOver, 100); } else { - nextSentence(); + continueSentence(); } }; } diff --git a/packages/webgal/src/Core/Modules/perform/performInterface.ts b/packages/webgal/src/Core/Modules/perform/performInterface.ts index 578cf4a51..5e635e407 100644 --- a/packages/webgal/src/Core/Modules/perform/performInterface.ts +++ b/packages/webgal/src/Core/Modules/perform/performInterface.ts @@ -17,17 +17,17 @@ export interface IPerform { isStarted?: boolean; // 卸载演出的函数 stopFunction: () => void; - // 演出是否阻塞游戏流程继续(一个函数,返回 boolean类型的结果,判断要不要阻塞) + // 是否阻塞用户/内部继续推进。为 true 时,next/forward/continue 都不能执行下一条语句。 blockingNext: () => boolean; - // 演出是否阻塞自动模式(一个函数,返回 boolean类型的结果,判断要不要阻塞) + // 是否阻塞自动播放计时器触发下一步;只影响 autoPlay,不影响用户点击或内部继续推进。 blockingAuto: () => boolean; - // 演出是否阻塞状态演算;默认不阻塞,只有需要外部输入才能确定后续状态的演出需要覆盖 + // 是否阻塞同一轮 -next 继续演算;只有需要外部输入才能确定后续状态的演出需要覆盖。 blockingStateCalculation?: () => boolean; // 未 commit 的演出被丢弃时,将它的终态同步到演算状态 settleStateOnDiscard?: () => void; - // 演出结束后转到下一句 + // 演出自然结束或被卸载后触发内部继续推进;推进前仍会等待 blockingNext 解除。 goNextWhenOver?: boolean; - // 跳过由 nextSentence 函数引发的演出回收 + // 跳过由 nextSentence/continueSentence 引发的非 hold 演出回收。 skipNextCollect?: boolean; } diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index ab226a8c9..2ec78758a 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -6,9 +6,18 @@ import { WebGAL } from '@/Core/WebGAL'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** - * 步进前工作:检查阻塞,并在当前演出未完成时提前结束普通演出。 + * 执行一次推进前检查。 + * + * 这里处理三种“不能直接进入下一条语句”的情况: + * 1. 场景正在异步写入时,任何推进都必须停止。 + * 2. 存在 blockingNext 的演出时,用户推进和内部推进都必须等待。 + * 3. 存在可提前结束的非 hold 演出时,用户推进只负责结束演出并停下; + * 内部继续推进会先结束演出,然后继续执行下一条语句。 + * + * @param continueAfterSettling 是否在清理普通非 hold 演出后继续推进。 + * @returns true 表示可以继续调用 forward/commitForward。 */ -export const preForward = () => { +export const preForward = (continueAfterSettling = false) => { if (WebGAL.sceneManager.lockSceneWrite) { logger.warn('next 被场景切换阻塞!'); return false; @@ -22,15 +31,19 @@ export const preForward = () => { const hasUnsettledNonHoldPerform = WebGAL.gameplay.performController.hasUnsettledNonHoldPerform(); if (hasUnsettledNonHoldPerform) { logger.debug('提前结束被触发,现在清除普通演出'); - WebGAL.gameplay.performController.settleNonHoldPerforms(); - return false; + // 用户 next 不消费 goNextWhenOver;内部继续推进会自己接着 forward,避免重复触发下一步。 + WebGAL.gameplay.performController.settleNonHoldPerforms(!continueAfterSettling); + return continueAfterSettling; } return true; }; /** - * 执行一条语句或由 -next 连接的语句序列,只修改演算状态并收集演出。 + * 执行一条语句或由 -next 连接的语句序列。 + * + * forward 只推进 calculationStageState,并把命令返回的 perform 收集到 pending 列表; + * 它不会提交视图状态,也不会启动 perform。调用方必须在合适时机调用 commitForward。 */ export const forward = () => { if (WebGAL.sceneManager.lockSceneWrite) { @@ -55,7 +68,10 @@ export const forward = () => { }; /** - * 将演算状态提交到当前视图状态,并启动本序列收集到的演出。 + * 将本轮 forward 的演算结果提交到视图,并启动 pending perform。 + * + * 提交流程分三步:先提交 stage state,再启动 perform,最后应用 Pixi effects。 + * 这个顺序保证 startFunction 看到的是已提交的视图状态。 */ export const commitForward = () => { stageStateManager.commit({ applyPixiEffects: false }); @@ -63,8 +79,33 @@ export const commitForward = () => { stageStateManager.applyCommittedPixiEffects(); }; +/** + * 内部继续推进。 + * + * 供场景切换完成、perform 自然结束、输入控件提交等内核流程调用。 + * 它不会触发 userInteractNext,因此不会把“内部自动继续”误判为用户点击。 + * 如果当前只剩可提前结束的非 hold 演出,会先结算它们并继续执行下一条语句。 + */ +export const continueSentence = () => { + const GUIState = webgalStore.getState().GUI; + if (GUIState.showTitle) { + return; + } + + if (!preForward(true)) { + return; + } + + forward(); + commitForward(); +}; + /** * 用户操作步进。 + * + * 供点击、键盘、自动播放和快进等“外部下一步”入口调用。 + * 它会触发 userInteractNext,让 intro 等演出先响应用户输入。 + * 如果当前存在可提前结束的普通演出,本次用户推进只结束演出,不再继续执行下一条语句。 */ export const nextSentence = () => { WebGAL.events.userInteractNext.emit(); diff --git a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts index 67ec27c09..e826ed97b 100644 --- a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts +++ b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts @@ -4,7 +4,7 @@ import { sceneParser } from '../../parser/sceneParser'; import { resetStage } from '@/Core/controller/stage/resetStage'; import { webgalStore } from '@/store/store'; import { setVisibility } from '@/store/GUIReducer'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { restorePerform } from '@/Core/controller/storage/jumpFromBacklog'; @@ -24,7 +24,7 @@ export const startGame = () => { sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); // 开始第一条语句 - nextSentence(); + continueSentence(); }); webgalStore.dispatch(setVisibility({ component: 'showTitle', visibility: false })); }; diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index 66ac09e04..29dbcecaa 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -1,7 +1,7 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -43,7 +43,8 @@ export const callScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneWritePromise = null; } if (shouldAutoNext) { - nextSentence(); + // 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。 + continueSentence(); } }); WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; diff --git a/packages/webgal/src/Core/controller/scene/changeScene.ts b/packages/webgal/src/Core/controller/scene/changeScene.ts index 7a6c1c981..0f2a22833 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -1,7 +1,7 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -37,7 +37,8 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneWritePromise = null; } if (shouldAutoNext) { - nextSentence(); + // 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。 + continueSentence(); } }); WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; diff --git a/packages/webgal/src/Core/controller/scene/restoreScene.ts b/packages/webgal/src/Core/controller/scene/restoreScene.ts index d1d671e6a..a41854033 100644 --- a/packages/webgal/src/Core/controller/scene/restoreScene.ts +++ b/packages/webgal/src/Core/controller/scene/restoreScene.ts @@ -1,7 +1,7 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { ISceneEntry } from '@/Core/Modules/scene'; import { WebGAL } from '@/Core/WebGAL'; @@ -34,7 +34,8 @@ export const restoreScene = (entry: ISceneEntry) => { WebGAL.sceneManager.sceneWritePromise = null; } if (shouldAutoNext) { - nextSentence(); + // 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。 + continueSentence(); } }); WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; diff --git a/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx b/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx index c58780518..2709364a3 100644 --- a/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx +++ b/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx @@ -8,7 +8,7 @@ import styles from './getUserInput.module.scss'; import { useSEByWebgalStore } from '@/hooks/useSoundEffect'; import { WebGAL } from '@/Core/WebGAL'; import { getStringArgByKey } from '@/Core/util/getSentenceArg'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { getCurrentFontFamily } from '@/hooks/useFontFamily'; import { logger } from '@/Core/util/logger'; import { tryToRegex } from '@/Core/util/global'; @@ -75,7 +75,7 @@ export const getUserInput = (sentence: ISentence): IPerform => { } playSeClick(); WebGAL.gameplay.performController.unmountPerform('userInput'); - nextSentence(); + continueSentence(); }} className={styles.button} > diff --git a/packages/webgal/src/Core/gameScripts/label/jmp.ts b/packages/webgal/src/Core/gameScripts/label/jmp.ts index 8704b727d..53bd56a73 100644 --- a/packages/webgal/src/Core/gameScripts/label/jmp.ts +++ b/packages/webgal/src/Core/gameScripts/label/jmp.ts @@ -1,9 +1,9 @@ -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { jumpToLabel } from '@/Core/gameScripts/label/jumpToLabel'; export const jmp = (labelName: string, autoNext = true) => { const isJumped = jumpToLabel(labelName); if (isJumped && autoNext) { - setTimeout(nextSentence, 1); + setTimeout(continueSentence, 1); } }; diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index d9fc83b00..0929e7c1f 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -24,7 +24,7 @@ import { WebGAL } from '@/Core/WebGAL'; import { sceneParser, WebgalParser } from '@/Core/parser/sceneParser'; import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { runScript } from '@/Core/controller/gamePlay/runScript'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { resetStage } from '@/Core/controller/stage/resetStage'; import { logger } from '@/Core/util/logger'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; @@ -214,7 +214,7 @@ export const startPreviewSyncRuntime = () => { showPanicOverlay: false, }); setTimeout(() => { - nextSentence(); + continueSentence(); }, 100); }; From 2c5559a8aa2397b74c6c5fb5838f74af2315a8a1 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 27 Jun 2026 22:38:17 +0800 Subject: [PATCH 28/39] fix: handle none bgm command --- .../src/Core/controller/gamePlay/scriptExecutor.ts | 2 ++ packages/webgal/src/Core/gameScripts/bgm.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 8bfbb5cd8..6a7e697c2 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -12,6 +12,7 @@ import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceAr import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { jumpToLabel } from '@/Core/gameScripts/label/jumpToLabel'; import { prefetchCurrentSceneByProgress } from '@/Core/util/prefetcher/progressPrefetcher'; +import { WEBGAL_NONE } from '@/Core/constants'; const MAX_FORWARD_SCRIPT_EXECUTION = 1000; @@ -91,6 +92,7 @@ export const scriptExecutor = (depth = 0) => { }; variableInterpolation(); + if (currentScript.content === WEBGAL_NONE) currentScript.content = ''; // 判断这个脚本要不要执行 let runThis = true; diff --git a/packages/webgal/src/Core/gameScripts/bgm.ts b/packages/webgal/src/Core/gameScripts/bgm.ts index 509e71a59..c6339b0e4 100644 --- a/packages/webgal/src/Core/gameScripts/bgm.ts +++ b/packages/webgal/src/Core/gameScripts/bgm.ts @@ -6,13 +6,15 @@ import { webgalStore } from '@/store/store'; import { unlockBgmInUserData } from '@/store/userDataReducer'; import localforage from 'localforage'; import { WebGAL } from '../WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { WEBGAL_NONE } from '@/Core/constants'; /** * 播放一段bgm * @param sentence */ export const bgm = (sentence: ISentence): IPerform => { - let url: string = sentence.content; // 获取bgm的url + let url: string = sentence.content === WEBGAL_NONE ? '' : sentence.content; // 获取bgm的url const name = getStringArgByKey(sentence, 'unlockname') ?? ''; const series = getStringArgByKey(sentence, 'series') ?? 'default'; let enter = getNumberArgByKey(sentence, 'enter') ?? 0; // 获取bgm的淡入时间 @@ -26,7 +28,11 @@ export const bgm = (sentence: ISentence): IPerform => { localforage.setItem(WebGAL.gameKey, userDataState).then(() => {}); } - playBgm(url, enter, volume); + if (url === '' && enter === 0) { + stageStateManager.setStage('bgm', { src: '', enter, volume }); + } else { + playBgm(url, enter, volume); + } return createNonePerform({ isHoldOn: true }); }; From e325e8ecbada6cad5e0de48a8430c63386da4a6c Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 27 Jun 2026 23:28:15 +0800 Subject: [PATCH 29/39] feat: add flowchart locked node visibility --- .../webgal/src/UI/Flowchart/Flowchart.tsx | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx index e202fed18..6b2ea9579 100644 --- a/packages/webgal/src/UI/Flowchart/Flowchart.tsx +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -37,11 +37,14 @@ interface ConnectorSegment { layerY: number; } +type LockedNodeVisibility = 'all' | 'node' | 'none'; + export const Flowchart = () => { const t = useTrans('gaming.flowchart.'); const { playSeClick, playSeEnter } = useSoundEffect(); const dispatch = useDispatch(); const GUIStore = useSelector((state: RootState) => state.GUI); + const lockedNodeVisibility = useSelector((state: RootState) => normalizeLockedNodeVisibility(state.userData.globalGameVar.Flowchart_Locked_Node_Visibility)); const isOpen = GUIStore.showFlowchart; const [indexHide, setIndexHide] = useState(true); const [currentFlowchartId, setCurrentFlowchartId] = useState(''); @@ -49,7 +52,7 @@ export const Flowchart = () => { const timeRef = useRef>(); const flowcharts = WebGAL.flowchartManager.getFlowcharts(); const currentFlowchart = flowcharts.find((e) => e.id === currentFlowchartId) ?? flowcharts[0]; - const layout = useMemo(() => layoutFlowchart(currentFlowchart), [currentFlowchart?.id, version]); + const layout = useMemo(() => layoutFlowchart(currentFlowchart, lockedNodeVisibility), [currentFlowchart?.id, lockedNodeVisibility, version]); useEffect(() => { const update = () => setVersion((v) => v + 1); @@ -167,7 +170,7 @@ export const Flowchart = () => { ))} {layout.nodes.map((node) => { const unlocked = WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id); - const labelText = unlocked ? node.data?.label || node.id : t('locked'); + const labelText = unlocked || lockedNodeVisibility === 'all' ? node.data?.label || node.id : ''; return ( { }} onMouseEnter={unlocked ? playSeEnter : undefined} > - {labelText} + {labelText || t('locked')} { ); }; -function layoutFlowchart(flowchart?: IFlowchart) { +function layoutFlowchart(flowchart?: IFlowchart, lockedNodeVisibility: LockedNodeVisibility = 'node') { if (!flowchart) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 }; - const nodeMap = new Map(flowchart.nodes.map((node) => [node.id, node])); + const flowchartNodes = + lockedNodeVisibility === 'none' + ? flowchart.nodes.filter((node) => WebGAL.flowchartManager.isUnlocked(flowchart.id, node.id)) + : flowchart.nodes; + if (flowchartNodes.length === 0) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 }; + const nodeMap = new Map(flowchartNodes.map((node) => [node.id, node])); const validEdges = flowchart.edges.filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target)); - const incomingCount = new Map(flowchart.nodes.map((node) => [node.id, 0])); + const incomingCount = new Map(flowchartNodes.map((node) => [node.id, 0])); const adjacency = new Map(); const parentMap = new Map(); validEdges.forEach((edge) => { @@ -218,10 +226,10 @@ function layoutFlowchart(flowchart?: IFlowchart) { adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge]); parentMap.set(edge.target, [...(parentMap.get(edge.target) ?? []), edge.source]); }); - const roots = flowchart.nodes.filter((node) => node.data?.isRoot || (incomingCount.get(node.id) ?? 0) === 0); - const queue = (roots.length ? roots : flowchart.nodes.slice(0, 1)).map((node) => node.id); + const roots = flowchartNodes.filter((node) => node.data?.isRoot || (incomingCount.get(node.id) ?? 0) === 0); + const queue = (roots.length ? roots : flowchartNodes.slice(0, 1)).map((node) => node.id); const restIncomingCount = new Map(incomingCount); - const depthMap = new Map(flowchart.nodes.map((node) => [node.id, 0])); + const depthMap = new Map(flowchartNodes.map((node) => [node.id, 0])); const visited = new Set(); while (queue.length > 0) { const sourceId = queue.shift()!; @@ -234,18 +242,18 @@ function layoutFlowchart(flowchart?: IFlowchart) { }); } const maxDepth = Math.max(0, ...depthMap.values()); - flowchart.nodes + flowchartNodes .filter((node) => !visited.has(node.id)) .forEach((node, index) => depthMap.set(node.id, Math.max(depthMap.get(node.id) ?? 0, maxDepth + index + 1))); const layerMap = new Map(); - flowchart.nodes.forEach((node) => { + flowchartNodes.forEach((node) => { const depth = depthMap.get(node.id) ?? 0; layerMap.set(depth, [...(layerMap.get(depth) ?? []), node]); }); const layers = [...layerMap.entries()] .sort(([a], [b]) => a - b) .map(([, layer]) => layer); - const nodeWidthMap = new Map(flowchart.nodes.map((node) => [node.id, getNodeWidth(node)])); + const nodeWidthMap = new Map(flowchartNodes.map((node) => [node.id, getNodeWidth(node, flowchart.id, lockedNodeVisibility)])); const layerWidths = layers.map((layer) => layer.reduce((sum, node) => sum + (nodeWidthMap.get(node.id) ?? NODE_MIN_WIDTH), 0) + (layer.length - 1) * NODE_GAP); const width = MARGIN_X * 2 + Math.max(NODE_MIN_WIDTH, ...layerWidths); const layoutNodeMap = new Map(); @@ -279,12 +287,17 @@ function layoutFlowchart(flowchart?: IFlowchart) { return { nodes, edges, width, height: MARGIN_Y * 2 + NODE_HEIGHT + (layers.length - 1) * ROW_GAP }; } -function getNodeWidth(node: IFlowchartNode) { +function getNodeWidth(node: IFlowchartNode, flowchartId: string, lockedNodeVisibility: LockedNodeVisibility) { + if (lockedNodeVisibility === 'node' && !WebGAL.flowchartManager.isUnlocked(flowchartId, node.id)) return NODE_MIN_WIDTH; const label = node.data?.label || node.id; const textWidth = Array.from(label).reduce((sum, char) => sum + (char.charCodeAt(0) > 255 ? 24 : 14), 0); return Math.max(NODE_MIN_WIDTH, textWidth + 36); } +function normalizeLockedNodeVisibility(value: unknown): LockedNodeVisibility { + return value === 'all' || value === 'none' ? value : 'node'; +} + function getConnectorSegments(edges: LayoutEdge[], flowchartId: string): ConnectorSegment[] { const targetBendYMap = getTargetBendYMap(edges); const edgeGroups = new Map(); From 4f885cd020ce9d01746e51b173f029fbefd8706e Mon Sep 17 00:00:00 2001 From: Mahiru Date: Wed, 1 Jul 2026 22:11:25 +0800 Subject: [PATCH 30/39] flowchart style change --- packages/webgal/src/App.tsx | 2 - .../BottomControlPanel/BottomControlPanel.tsx | 4 +- .../BottomControlPanelFilm.tsx | 4 +- .../webgal/src/UI/Flowchart/Flowchart.tsx | 350 +++++++++++------- .../src/UI/Flowchart/flowchart.module.scss | 144 +++---- packages/webgal/src/UI/Menu/Menu.tsx | 4 + .../src/UI/Menu/MenuPanel/MenuPanel.tsx | 13 +- packages/webgal/src/hooks/useHotkey.tsx | 4 +- packages/webgal/src/store/guiInterface.ts | 1 + 9 files changed, 304 insertions(+), 222 deletions(-) diff --git a/packages/webgal/src/App.tsx b/packages/webgal/src/App.tsx index 70a9dba80..5b1427abc 100644 --- a/packages/webgal/src/App.tsx +++ b/packages/webgal/src/App.tsx @@ -5,7 +5,6 @@ import { Stage } from '@/Stage/Stage'; import { BottomControlPanel } from '@/UI/BottomControlPanel/BottomControlPanel'; import { BottomControlPanelFilm } from '@/UI/BottomControlPanel/BottomControlPanelFilm'; import { Backlog } from '@/UI/Backlog/Backlog'; -import { Flowchart } from '@/UI/Flowchart/Flowchart'; import Title from '@/UI/Title/Title'; import Logo from '@/UI/Logo/Logo'; import { Extra } from '@/UI/Extra/Extra'; @@ -25,7 +24,6 @@ export default function App() { - <Logo /> <Extra /> diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx index ad8fb8984..6ab5b2be4 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx @@ -150,8 +150,8 @@ export const BottomControlPanel = () => { className={styles.singleButton} style={{ fontSize }} onClick={() => { - setComponentVisibility('showFlowchart', true); - setComponentVisibility('showTextBox', false); + setMenuPanel(MenuPanelTag.Flowchart); + setComponentVisibility('showMenuPanel', true); playSeClick(); }} onMouseEnter={playSeEnter} diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx index ed28b8f17..f36775f16 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx @@ -49,8 +49,8 @@ export const BottomControlPanelFilm = () => { <span className={styles.singleButton} onClick={() => { - setComponentVisibility('showFlowchart', true); - setComponentVisibility('showTextBox', false); + setMenuPanel(MenuPanelTag.Flowchart); + setComponentVisibility('showMenuPanel', true); showPanel.set(!showPanel.value); }} > diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx index 6b2ea9579..2a2217602 100644 --- a/packages/webgal/src/UI/Flowchart/Flowchart.tsx +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -5,8 +5,7 @@ import useSoundEffect from '@/hooks/useSoundEffect'; import useTrans from '@/hooks/useTrans'; import { setVisibility } from '@/store/GUIReducer'; import { RootState } from '@/store/store'; -import { CloseSmall } from '@icon-park/react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { MouseEvent as ReactMouseEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styles from './flowchart.module.scss'; @@ -43,16 +42,20 @@ export const Flowchart = () => { const t = useTrans('gaming.flowchart.'); const { playSeClick, playSeEnter } = useSoundEffect(); const dispatch = useDispatch(); - const GUIStore = useSelector((state: RootState) => state.GUI); - const lockedNodeVisibility = useSelector((state: RootState) => normalizeLockedNodeVisibility(state.userData.globalGameVar.Flowchart_Locked_Node_Visibility)); - const isOpen = GUIStore.showFlowchart; - const [indexHide, setIndexHide] = useState(true); + const lockedNodeVisibility = useSelector((state: RootState) => + normalizeLockedNodeVisibility(state.userData.globalGameVar.Flowchart_Locked_Node_Visibility), + ); const [currentFlowchartId, setCurrentFlowchartId] = useState(''); const [version, setVersion] = useState(0); - const timeRef = useRef<ReturnType<typeof setTimeout>>(); + const [isDragging, setIsDragging] = useState(false); + const dragStateRef = useRef({ moved: false, scrollLeft: 0, scrollTop: 0, startX: 0, startY: 0 }); + const blockClickRef = useRef(false); const flowcharts = WebGAL.flowchartManager.getFlowcharts(); const currentFlowchart = flowcharts.find((e) => e.id === currentFlowchartId) ?? flowcharts[0]; - const layout = useMemo(() => layoutFlowchart(currentFlowchart, lockedNodeVisibility), [currentFlowchart?.id, lockedNodeVisibility, version]); + const layout = useMemo( + () => layoutFlowchart(currentFlowchart, lockedNodeVisibility), + [currentFlowchart?.id, lockedNodeVisibility, version], + ); useEffect(() => { const update = () => setVersion((v) => v + 1); @@ -64,147 +67,189 @@ export const Flowchart = () => { if (!currentFlowchartId && flowcharts[0]) setCurrentFlowchartId(flowcharts[0].id); }, [flowcharts[0]?.id, currentFlowchartId]); - useEffect(() => { - if (isOpen) { - if (timeRef.current) clearTimeout(timeRef.current); - setIndexHide(false); - } else { - timeRef.current = setTimeout(() => setIndexHide(true), 780); - } - }, [isOpen]); - - const close = () => { - playSeClick(); - dispatch(setVisibility({ component: 'showFlowchart', visibility: false })); - dispatch(setVisibility({ component: 'showTextBox', visibility: true })); - }; - const jumpToNode = (node: IFlowchartNode) => { + if (blockClickRef.current) { + blockClickRef.current = false; + return; + } if (!currentFlowchart || !WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id)) return; playSeClick(); WebGAL.flowchartManager.loadSnapshot(currentFlowchart.id, node.id).then((snapshot) => { if (!snapshot) return; loadGameFromStageData(snapshot); - dispatch(setVisibility({ component: 'showFlowchart', visibility: false })); + dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); dispatch(setVisibility({ component: 'showTextBox', visibility: true })); }); }; + const dragFlowchart = (event: ReactMouseEvent<HTMLDivElement>) => { + if (event.button !== 0) return; + const target = event.currentTarget; + dragStateRef.current = { + moved: false, + scrollLeft: target.scrollLeft, + scrollTop: target.scrollTop, + startX: event.clientX, + startY: event.clientY, + }; + const onMouseMove = (moveEvent: MouseEvent) => { + const dx = moveEvent.clientX - dragStateRef.current.startX; + const dy = moveEvent.clientY - dragStateRef.current.startY; + if (!dragStateRef.current.moved && Math.abs(dx) + Math.abs(dy) > 3) { + dragStateRef.current.moved = true; + blockClickRef.current = true; + setIsDragging(true); + } + target.scrollLeft = dragStateRef.current.scrollLeft - dx; + target.scrollTop = dragStateRef.current.scrollTop - dy; + }; + const onMouseUp = () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + setIsDragging(false); + if (dragStateRef.current.moved) { + window.setTimeout(() => { + blockClickRef.current = false; + }, 0); + } + }; + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + event.preventDefault(); + }; + return ( - <div className={`${isOpen ? styles.Flowchart_main : styles.Flowchart_main_out} ${indexHide ? styles.Flowchart_main_out_IndexHide : ''}`}> + <div className={styles.Flowchart_main}> <div className={styles.flowchart_top}> - <CloseSmall - className={styles.flowchart_top_icon} - onClick={close} - onMouseEnter={playSeEnter} - theme="outline" - size="4em" - fill="#ffffff" - strokeWidth={3} - /> - <div className={styles.flowchart_title}>{t('title')}</div> + <div className={styles.flowchart_title}> + <div className={styles.flowchart_title_text}>{t('title')}</div> + </div> </div> - {isOpen && ( - <div className={styles.flowchart_body}> - <div className={styles.flowchart_sidebar}> - {flowcharts.map((flowchart) => ( - <button - type="button" - key={flowchart.id} - className={`${styles.flowchart_tab} ${currentFlowchart?.id === flowchart.id ? styles.flowchart_tab_active : ''}`} - onClick={() => { - playSeClick(); - setCurrentFlowchartId(flowchart.id); - }} - onMouseEnter={playSeEnter} - > - {flowchart.name} - </button> - ))} - </div> - <div className={styles.flowchart_content}> - {!currentFlowchart ? ( - <div className={styles.flowchart_empty}>{t('empty')}</div> - ) : ( - <svg - className={styles.flowchart_canvas} - width={layout.width} - height={layout.height} - viewBox={`0 0 ${layout.width} ${layout.height}`} - > - <defs> - <marker - id="flowchart-arrow-unlocked" - viewBox="0 0 10 10" - refX="8" - refY="5" - markerWidth="5" - markerHeight="5" - orient="auto-start-reverse" - > - <path className={styles.flowchart_arrow_unlocked} d="M 0 0 L 10 5 L 0 10 z" /> - </marker> - <marker - id="flowchart-arrow-locked" - viewBox="0 0 10 10" - refX="8" - refY="5" - markerWidth="5" - markerHeight="5" - orient="auto-start-reverse" + <div className={styles.flowchart_body}> + <div className={styles.flowchart_sidebar}> + {flowcharts.map((flowchart) => ( + <button + type="button" + key={flowchart.id} + className={`${styles.flowchart_tab} ${ + currentFlowchart?.id === flowchart.id ? styles.flowchart_tab_active : '' + }`} + onClick={() => { + playSeClick(); + setCurrentFlowchartId(flowchart.id); + }} + onMouseEnter={playSeEnter} + > + {flowchart.name} + </button> + ))} + </div> + <div + className={`${styles.flowchart_content} ${isDragging ? styles.flowchart_content_dragging : ''}`} + onMouseDown={dragFlowchart} + > + {!currentFlowchart ? ( + <div className={styles.flowchart_empty}>{t('empty')}</div> + ) : ( + <svg + className={styles.flowchart_canvas} + width={layout.width} + height={layout.height} + viewBox={`0 0 ${layout.width} ${layout.height}`} + > + <defs> + <linearGradient id="flowchart-node-unlocked" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stopColor="#ffffff" /> + <stop offset="58%" stopColor="#f8fbfc" /> + <stop offset="100%" stopColor="#e4f0f5" /> + </linearGradient> + <linearGradient id="flowchart-node-locked" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stopColor="#ffffff" /> + <stop offset="100%" stopColor="#f0f5f7" /> + </linearGradient> + <filter id="flowchart-node-glow" x="-20%" y="-35%" width="140%" height="170%"> + <feDropShadow dx="0" dy="5" stdDeviation="5" floodColor="#2B5F75" floodOpacity="0.28" /> + </filter> + <marker + id="flowchart-arrow-unlocked" + viewBox="0 0 10 10" + refX="8" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path className={styles.flowchart_arrow_unlocked} d="M 0 0 L 10 5 L 0 10 z" /> + </marker> + <marker + id="flowchart-arrow-locked" + viewBox="0 0 10 10" + refX="8" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path className={styles.flowchart_arrow_locked} d="M 0 0 L 10 5 L 0 10 z" /> + </marker> + </defs> + {getConnectorSegments(layout.edges, currentFlowchart.id) + .sort((a, b) => b.layerY - a.layerY) + .map((segment) => ( + <path + key={segment.id} + className={segment.unlocked ? styles.flowchart_line_unlocked : styles.flowchart_line_locked} + d={segment.d} + markerEnd={ + segment.arrow + ? `url(#${segment.unlocked ? 'flowchart-arrow-unlocked' : 'flowchart-arrow-locked'})` + : undefined + } + /> + ))} + {layout.nodes.map((node) => { + const unlocked = WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id); + const labelText = unlocked || lockedNodeVisibility === 'all' ? node.data?.label || node.id : ''; + return ( + <g + key={node.id} + className={`${styles.flowchart_node} ${ + unlocked ? styles.flowchart_node_unlocked : styles.flowchart_node_locked + }`} + role={unlocked ? 'button' : undefined} + tabIndex={unlocked ? 0 : -1} + onClick={() => unlocked && jumpToNode(node)} + onKeyDown={(event) => { + if (unlocked && (event.key === 'Enter' || event.key === ' ')) jumpToNode(node); + }} + onMouseEnter={unlocked ? playSeEnter : undefined} > - <path className={styles.flowchart_arrow_locked} d="M 0 0 L 10 5 L 0 10 z" /> - </marker> - </defs> - {getConnectorSegments(layout.edges, currentFlowchart.id) - .sort((a, b) => b.layerY - a.layerY) - .map((segment) => ( - <path - key={segment.id} - className={segment.unlocked ? styles.flowchart_line_unlocked : styles.flowchart_line_locked} - d={segment.d} - markerEnd={ - segment.arrow ? `url(#${segment.unlocked ? 'flowchart-arrow-unlocked' : 'flowchart-arrow-locked'})` : undefined - } + <title>{labelText || t('locked')} + - ))} - {layout.nodes.map((node) => { - const unlocked = WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id); - const labelText = unlocked || lockedNodeVisibility === 'all' ? node.data?.label || node.id : ''; - return ( - unlocked && jumpToNode(node)} - onKeyDown={(event) => { - if (unlocked && (event.key === 'Enter' || event.key === ' ')) jumpToNode(node); - }} - onMouseEnter={unlocked ? playSeEnter : undefined} + - {labelText || t('locked')} - - - {labelText} - - - ); - })} - - )} -
+ {labelText} + + + ); + })} + + )}
- )} +
); }; @@ -250,11 +295,15 @@ function layoutFlowchart(flowchart?: IFlowchart, lockedNodeVisibility: LockedNod const depth = depthMap.get(node.id) ?? 0; layerMap.set(depth, [...(layerMap.get(depth) ?? []), node]); }); - const layers = [...layerMap.entries()] - .sort(([a], [b]) => a - b) - .map(([, layer]) => layer); - const nodeWidthMap = new Map(flowchartNodes.map((node) => [node.id, getNodeWidth(node, flowchart.id, lockedNodeVisibility)])); - const layerWidths = layers.map((layer) => layer.reduce((sum, node) => sum + (nodeWidthMap.get(node.id) ?? NODE_MIN_WIDTH), 0) + (layer.length - 1) * NODE_GAP); + const layers = [...layerMap.entries()].sort(([a], [b]) => a - b).map(([, layer]) => layer); + const nodeWidthMap = new Map( + flowchartNodes.map((node) => [node.id, getNodeWidth(node, flowchart.id, lockedNodeVisibility)]), + ); + const layerWidths = layers.map( + (layer) => + layer.reduce((sum, node) => sum + (nodeWidthMap.get(node.id) ?? NODE_MIN_WIDTH), 0) + + (layer.length - 1) * NODE_GAP, + ); const width = MARGIN_X * 2 + Math.max(NODE_MIN_WIDTH, ...layerWidths); const layoutNodeMap = new Map(); layers.forEach((layer, depth) => { @@ -282,13 +331,18 @@ function layoutFlowchart(flowchart?: IFlowchart, lockedNodeVisibility: LockedNod }); const nodes = [...layoutNodeMap.values()]; const edges = validEdges - .map((edge) => ({ ...edge, sourceNode: layoutNodeMap.get(edge.source), targetNode: layoutNodeMap.get(edge.target) })) + .map((edge) => ({ + ...edge, + sourceNode: layoutNodeMap.get(edge.source), + targetNode: layoutNodeMap.get(edge.target), + })) .filter((edge): edge is LayoutEdge => Boolean(edge.sourceNode && edge.targetNode)); return { nodes, edges, width, height: MARGIN_Y * 2 + NODE_HEIGHT + (layers.length - 1) * ROW_GAP }; } function getNodeWidth(node: IFlowchartNode, flowchartId: string, lockedNodeVisibility: LockedNodeVisibility) { - if (lockedNodeVisibility === 'node' && !WebGAL.flowchartManager.isUnlocked(flowchartId, node.id)) return NODE_MIN_WIDTH; + if (lockedNodeVisibility === 'node' && !WebGAL.flowchartManager.isUnlocked(flowchartId, node.id)) + return NODE_MIN_WIDTH; const label = node.data?.label || node.id; const textWidth = Array.from(label).reduce((sum, char) => sum + (char.charCodeAt(0) > 255 ? 24 : 14), 0); return Math.max(NODE_MIN_WIDTH, textWidth + 36); @@ -302,7 +356,9 @@ function getConnectorSegments(edges: LayoutEdge[], flowchartId: string): Connect const targetBendYMap = getTargetBendYMap(edges); const edgeGroups = new Map(); edges.forEach((edge) => edgeGroups.set(edge.source, [...(edgeGroups.get(edge.source) ?? []), edge])); - return [...edgeGroups.values()].flatMap((groupEdges) => getConnectorSegmentsBySource(groupEdges, flowchartId, targetBendYMap)); + return [...edgeGroups.values()].flatMap((groupEdges) => + getConnectorSegmentsBySource(groupEdges, flowchartId, targetBendYMap), + ); } function getTargetBendYMap(edges: LayoutEdge[]) { @@ -318,7 +374,11 @@ function getTargetBendYMap(edges: LayoutEdge[]) { return bendYMap; } -function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string, targetBendYMap: Map): ConnectorSegment[] { +function getConnectorSegmentsBySource( + edges: LayoutEdge[], + flowchartId: string, + targetBendYMap: Map, +): ConnectorSegment[] { if (edges.length === 0) return []; const source = edges[0].sourceNode; const sx = source.x + source.width / 2; @@ -344,7 +404,13 @@ function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string, const busY = sy + Math.max(34, (targetY - sy) / 2); const hasUnlockedTarget = edges.some((edge) => WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target)); const segments: ConnectorSegment[] = [ - { id: `${source.id}-trunk`, d: `M ${sx} ${sy} V ${busY}`, unlocked: sourceUnlocked && hasUnlockedTarget, arrow: false, layerY: busY }, + { + id: `${source.id}-trunk`, + d: `M ${sx} ${sy} V ${busY}`, + unlocked: sourceUnlocked && hasUnlockedTarget, + arrow: false, + layerY: busY, + }, { id: `${source.id}-bus`, d: `M ${Math.min(...targetXs)} ${busY} H ${Math.max(...targetXs)}`, diff --git a/packages/webgal/src/UI/Flowchart/flowchart.module.scss b/packages/webgal/src/UI/Flowchart/flowchart.module.scss index c6d16559d..292f68c92 100644 --- a/packages/webgal/src/UI/Flowchart/flowchart.module.scss +++ b/packages/webgal/src/UI/Flowchart/flowchart.module.scss @@ -1,58 +1,48 @@ .Flowchart_main { font-family: "思源宋体", serif; position: absolute; - top: 0; width: 100%; - height: 100%; - z-index: 10; - background: rgba(0, 0, 0, 0.8); - padding: 2em 0; - animation: flowchart_soft_in 0.7s ease-out forwards; - box-sizing: border-box; -} - -.Flowchart_main_out { - @extend .Flowchart_main; - animation: flowchart_soft_out 0.7s ease-out forwards; -} - -.Flowchart_main_out_IndexHide { - z-index: -10; + height: 90%; + cursor: default; } .flowchart_top { - padding: 0 0 0 1em; - display: flex; height: 10%; + width: 100%; + display: flex; + justify-content: center; + position: relative; + animation: flowchart_soft_in 0.7s ease-out forwards; } -.flowchart_top_icon { - padding: 0.6em 0.6em 0 0.6em; - border-radius: 1000px; - transform: translate(0, -13px); - cursor: pointer; -} - -.flowchart_top_icon:hover { - background: rgba(255, 255, 255, 0.25); +.flowchart_title { + font-size: 500%; + min-width: 350px; + display: flex; + align-items: center; + position: absolute; + left: 20px; + top: 0; + opacity: 0.22; + transform: translateY(-10px); + pointer-events: none; } -.flowchart_title { - height: 100%; - line-height: 100%; - font-size: 360%; +.flowchart_title_text { font-weight: bold; color: transparent; - background: linear-gradient(150deg, #fff 0%, #fff 35%, rgba(165, 212, 228, 1) 100%); + background: linear-gradient(135deg, #2B5F75 0%, #7ba8bc 100%); + text-shadow: 2px 2px 15px rgba(255, 255, 255, 0.5); -webkit-background-clip: text; } .flowchart_body { display: flex; gap: 2.5em; - height: 82%; + height: 90%; padding: 0.5em 5em 1em 5em; box-sizing: border-box; + animation: flowchart_soft_in 0.7s ease-out forwards; } .flowchart_sidebar { @@ -63,40 +53,59 @@ } .flowchart_tab { + font-family: "资源圆体", serif; display: block; width: 100%; margin: 0 0 0.75em 0; padding: 0.65em 0.8em; - border: none; - border-radius: 7px; - color: rgba(255, 255, 255, 0.78); - background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.55); + border-radius: 4px; + color: rgba(52, 80, 92, 0.78); + background: linear-gradient(90deg, rgba(255, 255, 255, 0.78), rgba(239, 247, 250, 0.72)); text-align: left; font-size: 135%; cursor: pointer; - transition: background-color 0.25s, color 0.25s; + transition: border-color 0.25s, background-color 0.25s, color 0.25s; } .flowchart_tab:hover, .flowchart_tab_active { - color: #fff; - background: rgba(255, 255, 255, 0.22); + color: #2B5F75; + border-color: rgba(43, 95, 117, 0.36); + background: linear-gradient(90deg, rgba(255, 255, 255, 0.95), rgba(226, 240, 246, 0.85)); } .flowchart_content { flex: 1; - overflow: auto; - padding: 0.5em; + overflow-x: hidden; + overflow-y: auto; + padding: 1em; box-sizing: border-box; display: flex; + cursor: grab; + background: linear-gradient(-45deg, rgba(255, 255, 255, 0.32), rgba(226, 240, 246, 0.2)); + border: 1px solid rgba(255, 255, 255, 0.65); + border-radius: 4px; + box-shadow: inset 0 0 24px rgba(255, 255, 255, 0.38), 0 10px 28px rgba(43, 95, 117, 0.06); + overscroll-behavior: contain; + user-select: none; +} + +.flowchart_content_dragging { + cursor: grabbing; +} + +.flowchart_content_dragging .flowchart_node_unlocked { + cursor: grabbing; } .flowchart_empty { height: 100%; + width: 100%; display: flex; align-items: center; justify-content: center; - color: rgba(255, 255, 255, 0.7); + color: rgba(52, 80, 92, 0.72); font-size: 180%; } @@ -108,12 +117,12 @@ } .flowchart_arrow_unlocked { - fill: #f5f5f5; + fill: #2B5F75; fill-opacity: 1; } .flowchart_arrow_locked { - fill: #8d8d8d; + fill: #a9b7c4; fill-opacity: 1; } @@ -128,11 +137,13 @@ } .flowchart_line_unlocked { - stroke: #f5f5f5; + stroke: #2B5F75; + filter: drop-shadow(0 0 6px rgba(43, 95, 117, 0.28)); } .flowchart_line_locked { - stroke: #8d8d8d; + stroke: #a9b7c4; + stroke-dasharray: 7 8; } .flowchart_node { @@ -145,38 +156,46 @@ } .flowchart_node_locked { - opacity: 0.45; + opacity: 0.62; cursor: default; } .flowchart_node_rect { - transition: fill 0.25s, stroke 0.25s; + transition: fill 0.25s, stroke 0.25s, filter 0.25s; } .flowchart_node_rect_unlocked { - fill: rgba(255, 255, 255, 0.12); - stroke: rgba(255, 255, 255, 0.42); - stroke-width: 1; + fill: url(#flowchart-node-unlocked); + stroke: #2B5F75; + stroke-width: 2; + filter: url(#flowchart-node-glow); } .flowchart_node_rect_locked { - fill: rgba(255, 255, 255, 0.08); - stroke: rgba(255, 255, 255, 0.28); - stroke-width: 1; + fill: url(#flowchart-node-locked); + stroke: rgba(154, 174, 194, 0.8); + stroke-width: 1.5; } .flowchart_node_unlocked:hover .flowchart_node_rect_unlocked { - fill: rgba(255, 255, 255, 0.24); - stroke: rgba(255, 255, 255, 0.72); + stroke: #1f4e63; + filter: drop-shadow(0 0 10px rgba(43, 95, 117, 0.42)); } .flowchart_node_label { - fill: #fff; + fill: #243f4b; + stroke: rgba(255, 255, 255, 0.85); + stroke-width: 3px; + paint-order: stroke; font-size: 22px; font-weight: bold; pointer-events: none; } +.flowchart_node_locked .flowchart_node_label { + fill: rgba(97, 115, 133, 0.72); +} + @keyframes flowchart_soft_in { 0% { opacity: 0; @@ -185,12 +204,3 @@ opacity: 1; } } - -@keyframes flowchart_soft_out { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} diff --git a/packages/webgal/src/UI/Menu/Menu.tsx b/packages/webgal/src/UI/Menu/Menu.tsx index 70e329f54..29fe7efc2 100644 --- a/packages/webgal/src/UI/Menu/Menu.tsx +++ b/packages/webgal/src/UI/Menu/Menu.tsx @@ -7,6 +7,7 @@ import { Options } from './Options/Options'; import { useSelector } from 'react-redux'; import { RootState } from '@/store/store'; import { MenuPanelTag } from '@/store/guiInterface'; +import { Flowchart } from '@/UI/Flowchart/Flowchart'; /** * Menu 页面,包括存读档、选项等 @@ -29,6 +30,9 @@ const Menu: FC = () => { currentTag = ; // menuBgColor = 'linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%)'; break; + case MenuPanelTag.Flowchart: + currentTag = ; + break; } return ( <> diff --git a/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx b/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx index 628dda817..ef080a28a 100644 --- a/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx +++ b/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx @@ -1,6 +1,5 @@ import styles from './menuPanel.module.scss'; import { MenuPanelButton } from './MenuPanelButton'; -import { playBgm } from '@/Core/controller/stage/playBgm'; import { MenuPanelTag } from '@/store/guiInterface'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '@/store/store'; @@ -26,12 +25,14 @@ export const MenuPanel = () => { const SaveTagOn = GUIState.currentMenuTag === MenuPanelTag.Save ? ` ${styles.MenuPanel_button_hl}` : ``; const LoadTagOn = GUIState.currentMenuTag === MenuPanelTag.Load ? ` ${styles.MenuPanel_button_hl}` : ``; const OptionTagOn = GUIState.currentMenuTag === MenuPanelTag.Option ? ` ${styles.MenuPanel_button_hl}` : ``; + const FlowchartTagOn = GUIState.currentMenuTag === MenuPanelTag.Flowchart ? ` ${styles.MenuPanel_button_hl}` : ``; // 设置Menu按钮的颜色 const SaveTagColor = GUIState.currentMenuTag === MenuPanelTag.Save ? `rgba(74, 34, 93, 0.9)` : `rgba(123,144,169,1)`; const LoadTagColor = GUIState.currentMenuTag === MenuPanelTag.Load ? `rgba(11, 52, 110, 0.9)` : `rgba(123,144,169,1)`; const OptionTagColor = GUIState.currentMenuTag === MenuPanelTag.Option ? `rgba(81, 110, 65, 0.9)` : `rgba(123,144,169,1)`; + const FlowchartTagColor = GUIState.currentMenuTag === MenuPanelTag.Flowchart ? `#2B5F75` : `rgba(123,144,169,1)`; // 设置Menu图标的颜色 const SaveIconColor = GUIState.currentMenuTag === MenuPanelTag.Save ? `rgba(74, 34, 93, 0.9)` : `rgba(123,144,169,1)`; @@ -39,19 +40,19 @@ export const MenuPanel = () => { GUIState.currentMenuTag === MenuPanelTag.Load ? `rgba(11, 52, 110, 0.9)` : `rgba(123,144,169,1)`; const OptionIconColor = GUIState.currentMenuTag === MenuPanelTag.Option ? `rgba(81, 110, 65, 0.9)` : `rgba(123,144,169,1)`; + const FlowchartIconColor = GUIState.currentMenuTag === MenuPanelTag.Flowchart ? `#2B5F75` : `rgba(123,144,169,1)`; return (
{enableFlowchart && ( { playSePageChange(); - dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); - dispatch(setVisibility({ component: 'showFlowchart', visibility: true })); - dispatch(setVisibility({ component: 'showTextBox', visibility: false })); + dispatch(setMenuPanelTag(MenuPanelTag.Flowchart)); }} tagName={t('flowchart.title')} key="flowchartButton" diff --git a/packages/webgal/src/hooks/useHotkey.tsx b/packages/webgal/src/hooks/useHotkey.tsx index 07d73ef2c..e47e6cf34 100644 --- a/packages/webgal/src/hooks/useHotkey.tsx +++ b/packages/webgal/src/hooks/useHotkey.tsx @@ -297,7 +297,9 @@ function useIsPanicOverlayOpen(GUIStore: T & any): () => boolean { // 验证是否在存档 / 读档 / 选项页面 function useValidMenuPanelTag(GUIStore: T & any): () => boolean { return useCallback(() => { - return [MenuPanelTag.Save, MenuPanelTag.Load, MenuPanelTag.Option].includes(GUIStore.current.currentMenuTag); + return [MenuPanelTag.Save, MenuPanelTag.Load, MenuPanelTag.Option, MenuPanelTag.Flowchart].includes( + GUIStore.current.currentMenuTag, + ); }, [GUIStore]); } diff --git a/packages/webgal/src/store/guiInterface.ts b/packages/webgal/src/store/guiInterface.ts index 24df09b7c..a26ad9de9 100644 --- a/packages/webgal/src/store/guiInterface.ts +++ b/packages/webgal/src/store/guiInterface.ts @@ -5,6 +5,7 @@ export enum MenuPanelTag { Save, // “保存”选项卡 Load, // “读取”选项卡 Option, // “设置”选项卡 + Flowchart, // “流程图”选项卡 } /** From aa1edf80f722e1f470b1f4e8e38f12f24f9fe41c Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:31:47 +0800 Subject: [PATCH 31/39] fix: stabilize preview sync-scene updates Prevent sync-scene from exposing reset-stage intermediate state while fast preview is settling. The preview runtime now suppresses stage snapshots during sync-scene, only publishes the settled view state, and ignores stale sync-scene runs before they can commit over the latest preview target. --- .../src/Core/controller/stage/resetStage.ts | 19 ++++++- .../util/syncWithEditor/previewSyncRuntime.ts | 55 +++++++++++++++++++ .../runtime/previewSyncSceneCommand.ts | 40 +++++++++++++- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index 7b9af34c9..3d56d868d 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -3,7 +3,12 @@ import { WebGAL } from '@/Core/WebGAL'; import { initState, stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; -export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { +export interface ResetStageOptions { + commitStageState?: boolean; +} + +export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true, options: ResetStageOptions = {}) => { + const { commitStageState = true } = options; /** * 清空运行时 */ @@ -24,8 +29,16 @@ export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { // 清空舞台状态表 const initSceneDataCopy = cloneDeep(initState); const currentVars = stageStateManager.getCalculationStageState().GameVar; - stageStateManager.resetAllStageState(initSceneDataCopy); + if (commitStageState) { + stageStateManager.resetAllStageState(initSceneDataCopy); + } else { + stageStateManager.resetCalculationStageState(initSceneDataCopy); + } if (!resetSceneAndVar) { - stageStateManager.setStageAndCommit('GameVar', currentVars); + if (commitStageState) { + stageStateManager.setStageAndCommit('GameVar', currentVars); + } else { + stageStateManager.setStage('GameVar', currentVars); + } } }; diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index a76ae090e..560139202 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -106,6 +106,8 @@ export const startPreviewSyncRuntime = () => { const targetTransformBaselines = createTargetTransformBaselineManager(); const embeddedLaunchIdPromise = requestEmbeddedLaunchId(); let transport!: PreviewSyncTransport; + let nextSyncSceneRevision = 0; + let activeSyncSceneRevision: number | null = null; const createRequestId = () => `req-${Date.now()}-${Math.random().toString(16).slice(2)}`; @@ -133,9 +135,33 @@ export const startPreviewSyncRuntime = () => { lastPublishedSentenceId = null; lastPublishedStageState = null; setEffectBaselines.clear(); + activeSyncSceneRevision = null; + WebGAL.gameplay.isFastPreview = false; targetTransformBaselines.invalidateCurrentRevision(); }; + const startSyncSceneTransaction = () => { + nextSyncSceneRevision += 1; + activeSyncSceneRevision = nextSyncSceneRevision; + return activeSyncSceneRevision; + }; + + const isActiveSyncSceneRevision = (revision: number) => activeSyncSceneRevision === revision; + + const finishSyncSceneTransaction = (revision: number) => { + if (!isActiveSyncSceneRevision(revision)) { + return false; + } + + activeSyncSceneRevision = null; + return true; + }; + + const cancelActiveSyncScene = () => { + activeSyncSceneRevision = null; + WebGAL.gameplay.isFastPreview = false; + }; + const buildStageStateSnapshot = (stageState: StageStateSnapshot): StageSnapshotUpdatedPayload['stageState'] => { return JSON.parse(JSON.stringify(stageState)) as StageSnapshotUpdatedPayload['stageState']; }; @@ -152,6 +178,9 @@ export const startPreviewSyncRuntime = () => { if (!registered) { return; } + if (activeSyncSceneRevision !== null) { + return; + } const sceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; const sentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; @@ -178,6 +207,10 @@ export const startPreviewSyncRuntime = () => { } }; + const publishSettledStageSnapshot = () => { + publishStageSnapshot(true, stageStateManager.getViewStageState()); + }; + const registerPreview = async (socket: PreviewSyncTransportSocket) => { const requestId = createRequestId(); pendingRegisterRequestId = requestId; @@ -222,6 +255,8 @@ export const startPreviewSyncRuntime = () => { }; const handleSyncScene = (payload: SyncScenePayload) => { + const syncSceneRevision = startSyncSceneTransaction(); + const isLatestSyncScene = () => isActiveSyncSceneRevision(syncSceneRevision); setEffectBaselines.clear(); const { transformBaselineRevision } = payload; if (transformBaselineRevision) { @@ -232,7 +267,11 @@ export const startPreviewSyncRuntime = () => { executePreviewSyncSceneCommand(payload, { onFastPreviewTimeout: emitFastPreviewTimeout, + isLatest: isLatestSyncScene, onBeforeTargetScriptExecute: () => { + if (!isLatestSyncScene()) { + return; + } if (!transformBaselineRevision) { return; } @@ -243,22 +282,37 @@ export const startPreviewSyncRuntime = () => { ); }, onSettled: (result) => { + if (!finishSyncSceneTransaction(syncSceneRevision)) { + return; + } + + if (result === null) { + if (transformBaselineRevision) { + targetTransformBaselines.failRevision(transformBaselineRevision); + } + return; + } + if (!transformBaselineRevision) { + publishSettledStageSnapshot(); return; } const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload); if (!isSyncSettled || !targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision)) { targetTransformBaselines.failRevision(transformBaselineRevision); + publishSettledStageSnapshot(); return; } setEffectBaselines.clear(); + publishSettledStageSnapshot(); }, }); }; const handleRunSnippet = (payload: RunSnippetPayload) => { + cancelActiveSyncScene(); setEffectBaselines.clear(); targetTransformBaselines.invalidateCurrentRevision(); applyPreviewDebugVariables(payload.debugVariables); @@ -293,6 +347,7 @@ export const startPreviewSyncRuntime = () => { }; const handleRunSceneContent = (payload: RunSceneContentPayload) => { + cancelActiveSyncScene(); setEffectBaselines.clear(); targetTransformBaselines.invalidateCurrentRevision(); resetStage(true); diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index 407273fb9..46dc17248 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -29,16 +29,19 @@ export interface PreviewSyncSceneCommandCallbacks { onFastPreviewTimeout?: FastPreviewTimeoutEmitter; onBeforeTargetScriptExecute?: () => void; onSettled?: (result: FastPreviewResult | null) => void; + isLatest?: () => boolean; } interface RunFastPreviewOptions { onBeforeTargetScriptExecute?: () => void; + isLatest?: () => boolean; } export function executePreviewSyncSceneCommand( { sceneName, sentenceId, debugVariables, settleMode = 'normal' }: SyncScenePayload, callbacks: PreviewSyncSceneCommandCallbacks = {}, ): void { + const isLatest = () => callbacks.isLatest?.() ?? true; logger.warn('正在跳转到' + sceneName + ':' + sentenceId); WebGAL.gameplay.isFastPreview = false; @@ -57,22 +60,39 @@ export function executePreviewSyncSceneCommand( sceneFetcher(sceneUrl) .then((rawScene) => { - resetStage(true); + if (!isLatest()) { + return; + } + + resetStage(true, true, { commitStageState: false }); applyPreviewDebugVariables(debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; void runFastPreview(sentenceId, currentSceneName, callbacks.onFastPreviewTimeout, settleMode, { onBeforeTargetScriptExecute: callbacks.onBeforeTargetScriptExecute, + isLatest: callbacks.isLatest, }) .then((result) => { + if (!isLatest()) { + return; + } + callbacks.onSettled?.(result); }) .catch((error) => { + if (!isLatest()) { + return; + } + logger.error('实时预览跳转错误', error); callbacks.onSettled?.(null); }); }) .catch((error) => { + if (!isLatest()) { + return; + } + stopFast(); WebGAL.gameplay.isFastPreview = false; logger.error('实时预览跳转错误', error); @@ -86,7 +106,8 @@ export async function runFastPreview( onFastPreviewTimeout?: FastPreviewTimeoutEmitter, settleMode: SyncSceneSettleMode = 'normal', options: RunFastPreviewOptions = {}, -): Promise { +): Promise { + const isLatest = () => options.isLatest?.() ?? true; const fastPreviewStartTime = performance.now(); const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; stopFast(); @@ -117,6 +138,10 @@ export async function runFastPreview( try { while (shouldContinueFastPreview(sentenceId, currentSceneName, baseSceneStackDepth)) { + if (!isLatest()) { + return null; + } + const prevSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; const prevSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; const isForwarded = forward({ @@ -135,6 +160,9 @@ export async function runFastPreview( if (awaitedSceneWrite) { suspendedElapsedMs += performance.now() - sceneWriteWaitStart; } + if (!isLatest()) { + return null; + } if (!isForwarded && !awaitedSceneWrite) { stopReason = 'no-progress'; @@ -164,7 +192,9 @@ export async function runFastPreview( } } } finally { - WebGAL.gameplay.isFastPreview = false; + if (isLatest()) { + WebGAL.gameplay.isFastPreview = false; + } } if (settleMode === 'immediate') { @@ -172,6 +202,10 @@ export async function runFastPreview( WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); } + if (!isLatest()) { + return null; + } + commitForward(); const forwardedLineCount = From 96245792de79990f3d3eeb63302d3ceb34f907f1 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 10:12:13 +0800 Subject: [PATCH 32/39] =?UTF-8?q?fix:=20=E5=B7=B2=E8=AF=BB=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=A8=A1=E6=9D=BF=E5=BA=94=E7=94=A8=E9=97=AE=E9=A2=98?= =?UTF-8?q?=20#999?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgal/public/game/scene/demo_zh_cn.txt | 2 +- .../public/game/template/Stage/TextBox/textbox.scss | 6 ++++++ packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx | 13 ++++--------- .../webgal/src/Stage/TextBox/textbox.module.scss | 2 -- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/webgal/public/game/scene/demo_zh_cn.txt b/packages/webgal/public/game/scene/demo_zh_cn.txt index 73f93057c..404faee05 100644 --- a/packages/webgal/public/game/scene/demo_zh_cn.txt +++ b/packages/webgal/public/game/scene/demo_zh_cn.txt @@ -26,7 +26,7 @@ pixiPerform:snow; pixiInit; WebGAL:接下来介绍一些新版本功能! WebGAL:比如这个[注](zhù)[音](yīn)功能,可以为游戏带来更好的体验! -WebGAL:我们也支持了[文本拓展语法](style=color:#B5495B\; style-alltext=-webkit-text-stroke-color:#ffffff\;),可以为[文](wen)[本](ben)带来[富文本支持](style-alltext=font-style:italic\;-webkit-text-stroke-color:#ffffff\; style=color:#66327C\;)、交互等特性。 +WebGAL:我们也支持了[文本拓展语法](style=color:#B5495B\;),可以为[文](wen)[本](ben)带来[富文本支持](style-alltext=font-style:italic\; style=color:#66327C\;)、交互等特性。 changeFigure:thinking.webp -left -transform={"position":{"x":500,"y":0}} -next; WebGAL:新版本添加了特性:获取用户输入,你要尝试一下吗? choose:尝试一下:userInput|算了吧:toNextPart; diff --git a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss index 54a0a6849..637634a4f 100644 --- a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss +++ b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss @@ -177,9 +177,15 @@ overflow: hidden; } +.readText { +} + .readTextOuter { background-image: none; background-clip: border-box; -webkit-background-clip: border-box; color: #C0C0C0; } + +.readTextInner { +} diff --git a/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx b/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx index 1a8ec0c64..d0784def7 100644 --- a/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx +++ b/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx @@ -31,9 +31,6 @@ export default function IMSSTextbox(props: ITextboxProps) { const applyStyle = useApplyStyle('textbox'); const readTextClassName = isRead ? ` ${applyStyle('readText', styles.readText)}` : ''; - const readTextOuterClassName = isRead - ? ` ${applyStyle('readTextOuter', styles.readTextOuter)}` - : ''; const readTextInnerClassName = isRead ? ` ${applyStyle('readTextInner', styles.readTextInner)}` : ''; useEffect(() => { @@ -138,6 +135,8 @@ export default function IMSSTextbox(props: ITextboxProps) { // if (e === '
') { // return
; // } + const outerClassName = applyStyle('outer', styles.outer); + const readTextOuterClassName = isRead ? ` ${applyStyle('readTextOuter', styles.readTextOuter)}` : ''; let delay = allTextIndex * textDelay; allTextIndex++; let prevLength = currentConcatDialogPrev.length; @@ -157,9 +156,7 @@ export default function IMSSTextbox(props: ITextboxProps) { > {e} - - {e} - + {e} {isUseStroke && ( {e} )} @@ -180,9 +177,7 @@ export default function IMSSTextbox(props: ITextboxProps) { > {e} - - {e} - + {e} {isUseStroke && ( {e} )} diff --git a/packages/webgal/src/Stage/TextBox/textbox.module.scss b/packages/webgal/src/Stage/TextBox/textbox.module.scss index 6521bd13b..e2a518b55 100644 --- a/packages/webgal/src/Stage/TextBox/textbox.module.scss +++ b/packages/webgal/src/Stage/TextBox/textbox.module.scss @@ -236,7 +236,6 @@ $height: 330px; } .readText { - --webgal-read-text: 1; } .readTextOuter { @@ -247,5 +246,4 @@ $height: 330px; } .readTextInner { - --webgal-read-text-inner: 1; } From 68d45acf67adf9099f02e0183f6e885949737b5c Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 10:25:31 +0800 Subject: [PATCH 33/39] fix: skip stage exit animation on restore --- .../Core/Modules/stage/stageStateManager.ts | 16 +++-- .../stage/pixi/syncPixiStageState.ts | 61 ++++++++++++------- .../src/Core/controller/stage/resetStage.ts | 2 +- .../controller/storage/jumpFromBacklog.ts | 4 +- .../src/Core/controller/storage/loadGame.ts | 4 +- 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts index c71b5eb55..b963d794c 100644 --- a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts +++ b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts @@ -23,12 +23,14 @@ export interface IStageCommitOptions { syncPixiStage?: boolean; applyPixiEffects?: boolean; notifyReact?: boolean; + skipAnimation?: boolean; } export interface IResolvedStageCommitOptions { syncPixiStage: boolean; applyPixiEffects: boolean; notifyReact: boolean; + skipAnimation: boolean; } type StageCommitHandler = (stageState: IStageState, options: IResolvedStageCommitOptions) => void; @@ -122,17 +124,17 @@ export class StageStateManager { this.calculationStageState = cloneDeep(stageState); } - public replaceAllStageState(stageState: IStageState) { + public replaceAllStageState(stageState: IStageState, options?: IStageCommitOptions) { this.calculationStageState = cloneDeep(stageState); - this.commit(); + this.commit(options); } public resetCalculationStageState(stageState: IStageState) { this.replaceCalculationStageState(stageState); } - public resetAllStageState(stageState: IStageState) { - this.replaceAllStageState(stageState); + public resetAllStageState(stageState: IStageState, options?: IStageCommitOptions) { + this.replaceAllStageState(stageState, options); } public updateEffect(payload: IEffect) { @@ -344,7 +346,9 @@ export class StageStateManager { } public clearUncommittedNonHoldPerforms() { - this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter((perform) => perform.isHoldOn); + this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter( + (perform) => perform.isHoldOn, + ); } public removeNonHoldPerformsAndCommit() { @@ -357,6 +361,7 @@ export class StageStateManager { syncPixiStage: options.syncPixiStage ?? true, applyPixiEffects: options.applyPixiEffects ?? true, notifyReact: options.notifyReact ?? true, + skipAnimation: options.skipAnimation ?? false, }; this.viewStageState = cloneDeep(this.calculationStageState); this.commitHandler?.(this.viewStageState, resolvedOptions); @@ -370,6 +375,7 @@ export class StageStateManager { syncPixiStage: false, applyPixiEffects: true, notifyReact: false, + skipAnimation: false, }); } diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index f3cba8412..4046064bc 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -8,10 +8,23 @@ import { logger } from '@/Core/util/logger'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { applyTransformToPixiContainer } from '@/Core/controller/stage/pixi/stageEffectTransform'; +interface ISyncFigureSlotPayload { + key: string; + sourceUrl: string; + position: 'left' | 'center' | 'right'; + stageState: IStageState; + skipAnimation: boolean; +} + +interface IRemoveFigOptions { + effects: IEffect[]; + skipAnimation: boolean; +} + export function syncPixiStageState(stageState: IStageState, options: IResolvedStageCommitOptions) { if (options.syncPixiStage) { - syncBg(stageState); - syncFigures(stageState); + syncBg(stageState, options.skipAnimation); + syncFigures(stageState, options.skipAnimation); syncLive2d(stageState); syncFigureMetaData(stageState); } @@ -48,7 +61,7 @@ export function applyStageEffectToTarget(target: string, transform: ITransform | pixiStage.requestRender(); } -function syncBg(stageState: IStageState) { +function syncBg(stageState: IStageState, skipAnimation: boolean) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; const thisBgKey = 'bg-main'; @@ -58,12 +71,12 @@ function syncBg(stageState: IStageState) { if (bgName !== '') { if (currentBg?.sourceUrl === bgName) return; if (currentBg) { - removeBg(currentBg); + removeBg(currentBg, skipAnimation); } addBg(thisBgKey, bgName); logger.debug('重设背景'); const { duration, animation } = getEnterExitAnimation(thisBgKey, 'enter', true); - if (WebGAL.gameplay.skipAnimation) { + if (skipAnimation || WebGAL.gameplay.skipAnimation) { setEbg(bgName, 0); } else { setEbg(bgName, duration); @@ -74,17 +87,23 @@ function syncBg(stageState: IStageState) { } if (!currentBg) return; - const exitDuration = removeBg(currentBg); + const exitDuration = removeBg(currentBg, skipAnimation); setEbg(bgName, exitDuration, 'cubic-bezier(0.5, 0, 0.75, 0)'); } -function syncFigures(stageState: IStageState) { - syncFigureSlot('fig-center', stageState.figName, 'center', stageState); - syncFigureSlot('fig-left', stageState.figNameLeft, 'left', stageState); - syncFigureSlot('fig-right', stageState.figNameRight, 'right', stageState); +function syncFigures(stageState: IStageState, skipAnimation: boolean) { + syncFigureSlot({ key: 'fig-center', sourceUrl: stageState.figName, position: 'center', stageState, skipAnimation }); + syncFigureSlot({ key: 'fig-left', sourceUrl: stageState.figNameLeft, position: 'left', stageState, skipAnimation }); + syncFigureSlot({ + key: 'fig-right', + sourceUrl: stageState.figNameRight, + position: 'right', + stageState, + skipAnimation, + }); for (const fig of stageState.freeFigure) { - syncFigureSlot(fig.key, fig.name, fig.basePosition, stageState); + syncFigureSlot({ key: fig.key, sourceUrl: fig.name, position: fig.basePosition, stageState, skipAnimation }); } const currentFigures = WebGAL.gameplay.pixiStage?.getFigureObjects(); @@ -100,12 +119,12 @@ function syncFigures(stageState: IStageState) { continue; } if (!freeFigureKeys.has(existFigure.key)) { - removeFig(existFigure, `${existFigure.key}-softin`, stageState.effects); + removeFig(existFigure, `${existFigure.key}-softin`, { effects: stageState.effects, skipAnimation }); } } } -function syncFigureSlot(key: string, sourceUrl: string, position: 'left' | 'center' | 'right', stageState: IStageState) { +function syncFigureSlot({ key, sourceUrl, position, stageState, skipAnimation }: ISyncFigureSlotPayload) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; const softInAniKey = `${key}-softin`; @@ -114,12 +133,12 @@ function syncFigureSlot(key: string, sourceUrl: string, position: 'left' | 'cent if (sourceUrl !== '') { if (currentFigure?.sourceUrl === sourceUrl) return; if (currentFigure) { - removeFig(currentFigure, softInAniKey, stageState.effects); + removeFig(currentFigure, softInAniKey, { effects: stageState.effects, skipAnimation }); } addFigure(key, sourceUrl, position); logger.debug(`${key} 立绘已重设`); const { duration, animation } = getEnterExitAnimation(key, 'enter'); - if (!WebGAL.gameplay.skipAnimation) { + if (!skipAnimation && !WebGAL.gameplay.skipAnimation) { pixiStage.registerPresetAnimation(animation, softInAniKey, key, stageState.effects); setTimeout(() => pixiStage.removeAnimationWithSetEffects(softInAniKey), duration); } @@ -127,7 +146,7 @@ function syncFigureSlot(key: string, sourceUrl: string, position: 'left' | 'cent } if (currentFigure) { - removeFig(currentFigure, softInAniKey, stageState.effects); + removeFig(currentFigure, softInAniKey, { effects: stageState.effects, skipAnimation }); } } @@ -167,11 +186,11 @@ function syncFigureMetaData(stageState: IStageState) { }); } -function removeBg(bgObject: IStageObject): number { +function removeBg(bgObject: IStageObject, skipAnimation: boolean): number { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return DEFAULT_BG_OUT_DURATION; pixiStage.removeAnimationWithSetEffects('bg-main-softin'); - if (WebGAL.gameplay.skipAnimation) { + if (skipAnimation || WebGAL.gameplay.skipAnimation) { pixiStage.removeStageObjectByKey(bgObject.key); return 0; } @@ -189,11 +208,11 @@ function removeBg(bgObject: IStageObject): number { return duration; } -function removeFig(figObj: IStageObject, enterTikerKey: string, effects: IEffect[]) { +function removeFig(figObj: IStageObject, enterTikerKey: string, options: IRemoveFigOptions) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; pixiStage.removeAnimationWithSetEffects(enterTikerKey); - if (WebGAL.gameplay.skipAnimation) { + if (options.skipAnimation || WebGAL.gameplay.skipAnimation) { logger.debug('快速模式,立刻关闭立绘'); pixiStage.removeStageObjectByKey(figObj.key); return; @@ -205,7 +224,7 @@ function removeFig(figObj: IStageObject, enterTikerKey: string, effects: IEffect pixiStage.removeStageObjectByKey(oldFigKey); const leaveKey = figKey + '-softoff'; const { duration, animation } = getEnterExitAnimation(figLeaveAniKey, 'exit', false, figKey); - pixiStage.registerPresetAnimation(animation, leaveKey, figKey, effects); + pixiStage.registerPresetAnimation(animation, leaveKey, figKey, options.effects); setTimeout(() => { pixiStage.removeAnimation(leaveKey); pixiStage.removeStageObjectByKey(figKey); diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index 7b9af34c9..26327d9bb 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -24,7 +24,7 @@ export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { // 清空舞台状态表 const initSceneDataCopy = cloneDeep(initState); const currentVars = stageStateManager.getCalculationStageState().GameVar; - stageStateManager.resetAllStageState(initSceneDataCopy); + stageStateManager.resetAllStageState(initSceneDataCopy, { skipAnimation: true }); if (!resetSceneAndVar) { stageStateManager.setStageAndCommit('GameVar', currentVars); } diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 4672ca5d0..2fd69d8ff 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -14,7 +14,7 @@ import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 恢复演出 */ -export const restorePerform = () => { +export const restorePerform = (skipAnimation = false) => { const stageState = stageStateManager.getCalculationStageState(); const performToRestore = cloneDeep(stageState.PerformList); // 清除状态表中演出序列 @@ -27,7 +27,7 @@ export const restorePerform = () => { } finally { WebGAL.gameplay.performController.endCollectingPerforms(); } - stageStateManager.commit({ applyPixiEffects: false }); + stageStateManager.commit({ applyPixiEffects: false, skipAnimation }); WebGAL.gameplay.performController.commitPendingPerforms(); stageStateManager.applyCommittedPixiEffects(); }; diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index f94290989..dd0900b15 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -61,7 +61,7 @@ export function loadGameFromStageData(stageData: ISaveData) { stageStateManager.replaceCalculationStageState(newStageState); // 恢复演出 - setTimeout(restorePerform, 0); + setTimeout(() => restorePerform(true), 0); // restorePerform(); dispatch(setVisibility({ component: 'showTitle', visibility: false })); @@ -69,5 +69,5 @@ export function loadGameFromStageData(stageData: ISaveData) { /** * 恢复模糊背景 */ - setEbg(newStageState.bgName); + setEbg(newStageState.bgName, 0); } From 957fdeeb1ac49a3b5e29cf9256e26e4542d8523e Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 10:42:36 +0800 Subject: [PATCH 34/39] fix: handle play video errors --- packages/webgal/src/Core/gameScripts/playVideo.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/playVideo.tsx b/packages/webgal/src/Core/gameScripts/playVideo.tsx index de122ce6d..bb93f1d0a 100644 --- a/packages/webgal/src/Core/gameScripts/playVideo.tsx +++ b/packages/webgal/src/Core/gameScripts/playVideo.tsx @@ -37,6 +37,7 @@ export const playVideo = (sentence: ISentence): IPerform => { ReactDOM.render(
, document.getElementById('videoContainer')); }; const endPerform = () => { + if (isOver) return; isOver = true; WebGAL.gameplay.performController.unmountPerform(performInitName); }; @@ -75,14 +76,12 @@ export const playVideo = (sentence: ISentence): IPerform => { vocalElement.volume = '0'; } - VocalControl?.play().catch(() => {}); + VocalControl.addEventListener('error', () => endPerform()); + VocalControl.addEventListener('ended', () => endPerform()); + VocalControl.play().catch(() => endPerform()); if (!blockingNextFlag) { WebGAL.events.fullscreenDbClick.on(skipVideo); } - - VocalControl.onended = () => { - endPerform(); - }; } }, 1); }, From 86d292ae27f043ce9adb85c13bc178dbb9736618 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 10:53:41 +0800 Subject: [PATCH 35/39] fix: validate parsed scene url path --- packages/webgal/src/Core/controller/scene/sceneFetcher.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/webgal/src/Core/controller/scene/sceneFetcher.ts b/packages/webgal/src/Core/controller/scene/sceneFetcher.ts index 78720c6f5..9f6102c84 100644 --- a/packages/webgal/src/Core/controller/scene/sceneFetcher.ts +++ b/packages/webgal/src/Core/controller/scene/sceneFetcher.ts @@ -6,7 +6,13 @@ import axios from 'axios'; */ export const sceneFetcher = (sceneUrl: string) => { return new Promise((resolve, reject) => { - if (!sceneUrl.endsWith('.txt')) { + let scenePath = ''; + try { + scenePath = sceneUrl ? new URL(sceneUrl, window.location.href).pathname : ''; + } catch { + scenePath = ''; + } + if (!scenePath.endsWith('.txt')) { reject('Scene file must be a txt file'); return; } From 23181f76b5deb9c58f179b3c4d57e893587526fa Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 11:38:05 +0800 Subject: [PATCH 36/39] fix: stop auto play when opening backlog --- packages/webgal/src/Core/controller/gamePlay/autoPlay.ts | 4 ---- packages/webgal/src/UI/Backlog/Backlog.tsx | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts index 9f2052fc3..575ae7d41 100644 --- a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts +++ b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts @@ -59,10 +59,6 @@ const autoPlay = () => { const data = webgalStore.getState().userData.optionData.autoSpeed; // 范围为 [250, 1750] const autoPlayDelay = 250 + (100 - data) * 15; - // Backlog 打开时不推进,避免在历史界面里自动播放导致剧情继续 - if (webgalStore.getState().GUI.showBacklog) { - return; - } let isBlockingAuto = false; WebGAL.gameplay.performController.performList.forEach((e) => { if (e.blockingAuto()) diff --git a/packages/webgal/src/UI/Backlog/Backlog.tsx b/packages/webgal/src/UI/Backlog/Backlog.tsx index 30234d533..6b86c2c22 100644 --- a/packages/webgal/src/UI/Backlog/Backlog.tsx +++ b/packages/webgal/src/UI/Backlog/Backlog.tsx @@ -10,6 +10,7 @@ import useTrans from '@/hooks/useTrans'; import { compileSentence, EnhancedNode } from '@/Stage/TextBox/TextBox'; import useSoundEffect from '@/hooks/useSoundEffect'; import { WebGAL } from '@/Core/WebGAL'; +import { stopAuto } from '@/Core/controller/gamePlay/autoPlay'; export const Backlog = () => { const t = useTrans('gaming.'); @@ -180,6 +181,7 @@ export const Backlog = () => { useEffect(() => { /* 切换为展示历史记录时触发 */ if (GUIStore.showBacklog) { + stopAuto(); // logger.info('展示backlog'); // 立即清除 防止来回滚动时可能导致的错乱 if (timeRef.current) { From 1b54b33814c522ddddef218c3a8a32f40fe2d528 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 11:53:25 +0800 Subject: [PATCH 37/39] fix: comp type --- packages/webgal/src/types/editorPreviewProtocol.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index a18ae9c8d..596931e5f 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -79,6 +79,7 @@ export const COMPONENT_VISIBILITY_KEYS = [ 'showStarter', 'showTitle', 'showMenuPanel', + 'showFlowchart', 'showTextBox', 'showControls', 'controlsVisibility', From e7e75e4d8e5346509f151cf71e5911aa0ef51866 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 13:33:26 +0800 Subject: [PATCH 38/39] update version --- package.json | 2 +- packages/parser/package.json | 2 +- packages/webgal/package.json | 2 +- .../webgal/public/game/template/template.json | 2 +- packages/webgal/public/webgal-engine.json | 4 +- releasenote.md | 90 +++++++++++-------- 6 files changed, 60 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 646a980ef..db4ebdd90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@webgal/base", - "version": "4.6.1", + "version": "4.6.2", "description": "A brand new web Visual Novel engine.", "repository": "https://github.com/OpenWebGAL/WebGAL.git", "author": "Mahiru ", diff --git a/packages/parser/package.json b/packages/parser/package.json index 5a7393280..480e75678 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "webgal-parser", - "version": "4.6.1", + "version": "4.6.2", "description": "WebGAL script parser", "scripts": { "test": "vitest", diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 7a216c9f4..f5ffbbc95 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -1,6 +1,6 @@ { "name": "webgal-engine", - "version": "4.6.1", + "version": "4.6.2", "scripts": { "dev": "vite --host --port 3000", "build": "node scripts/update-engine-version.js && cross-env NODE_ENV=production tsc && vite build --base=./", diff --git a/packages/webgal/public/game/template/template.json b/packages/webgal/public/game/template/template.json index 8d2dd8058..981384f04 100644 --- a/packages/webgal/public/game/template/template.json +++ b/packages/webgal/public/game/template/template.json @@ -1,4 +1,4 @@ { "name":"WebGAL Refine 2026", - "webgal-version":"4.6.1" + "webgal-version":"4.6.2" } diff --git a/packages/webgal/public/webgal-engine.json b/packages/webgal/public/webgal-engine.json index f3c2cd034..7cdd18a14 100644 --- a/packages/webgal/public/webgal-engine.json +++ b/packages/webgal/public/webgal-engine.json @@ -2,9 +2,9 @@ "schemaVersion": "1.0.0", "id": "open-webgal.webgal", "name": "WebGAL", - "version": "4.6.1", + "version": "4.6.2", "type": "official", - "webgalVersion": "4.6.1", + "webgalVersion": "4.6.2", "description": "界面美观、功能强大、易于开发的全新网页端视觉小说引擎", "descriptions": { "en": "A brand new web Visual Novel engine with a beautiful interface, powerful features, and easy development", diff --git a/releasenote.md b/releasenote.md index 8b45dea69..44a393d17 100644 --- a/releasenote.md +++ b/releasenote.md @@ -8,29 +8,35 @@ #### 新功能 -优化编辑器实时预览,提升场景跳转和状态同步的稳定性,并支持调试变量、模板刷新和更多界面预览设置。 +新增流程图功能。启用 Enable_flowchart 并配置 game/flowchart.json 后,玩家可以在菜单或底部控制栏查看已解锁的剧情节点,并跳转回已解锁的场景。 -changeBg / changeFigure / setTransition / setAnimation / setTempAnimation / setTransform 支持 ignoreDefault 参数,可让自定义动画忽略未声明的默认变换和效果。 +流程图支持多条线路、节点解锁记录和未解锁节点显示控制,重置游戏数据时会一并清理流程图进度。 -CG 鉴赏支持按 series 分组并按 order 排序,系列图片可堆叠显示并依次预览。 +优化编辑器实时预览。调整背景、立绘、Spine、Live2D 和特效时,预览定位与实际画面更一致,快速切换预览目标时同步结果更稳定。 -新增 Enable_Continue 配置项,可控制继续游戏按钮是否显示;无自动存档时按钮会置灰,游戏结束时会清理自动存档。 - -新增巴西葡萄牙语和韩语,并将语言设置优化为下拉选择。 +优化读档、回到流程图节点和自动继续时的画面与声音恢复,减少状态不一致。 #### 修复 -修复使用 vocal 参数指定语音时资源路径解析错误的问题。 +修复打开 Backlog 后自动播放仍可能继续推进的问题。 + +修复播放 Backlog 语音时,多个回想语音或当前游戏语音可能同时播放的问题。 + +修复视频播放失败时流程可能卡住,以及 skipOff 视频仍可被双击跳过的问题。 + +修复 next 连续执行中接场景跳转时,后续流程可能失效的问题。 -修复快速预览、滚轮推进和重置舞台后,快进与动画状态可能不一致的问题。 +修复场景跳转目标异常时可能加载无效场景文件的问题。 -修复长场景连续推进时可能发生调用栈溢出的问题。 +修复 bgm:none 无法正确停止背景音乐的问题。 -修复自定义模板样式仍受引擎默认样式干扰的问题。 +修复 changeBg / changeFigure / setTransform 的变换参数为空或格式异常时,动画表现可能不正确的问题。 -修复非官方引擎构建时自身版本号被错误覆盖的问题。 +修复自定义文本框模板中的已读文本样式部分不生效的问题。 -修复自定义模板未包含 game/tex 纹理文件时,内置雨、雪和樱花特效无法显示的问题。 +修复脚本注释包含多个分号时后续内容丢失的问题。 + +修复资源预加载可能重复处理同一资源,或包含无效空路径资源的问题。 ## Release Notes @@ -43,29 +49,35 @@ CG 鉴赏支持按 series 分组并按 order 排序,系列图片可堆叠显 #### New Features -Improved editor live preview with more reliable scene navigation and state synchronization, plus support for debug variables, template refreshes, and additional interface preview settings. - -changeBg / changeFigure / setTransition / setAnimation / setTempAnimation / setTransform now support the ignoreDefault argument, allowing custom animations to ignore undeclared default transforms and effects. +Added the flowchart feature. After enabling Enable_flowchart and configuring game/flowchart.json, players can view unlocked story nodes from the menu or bottom control panel and jump back to unlocked scenes. -The CG gallery now supports grouping by series and sorting by order, with series images displayed as a stack for sequential preview. +Flowcharts support multiple routes, node unlock progress, and locked-node visibility controls; resetting game data now also clears flowchart progress. -Added the Enable_Continue configuration option to control whether the Continue button is shown; it is disabled without an autosave, and autosaves are cleared when the game ends. +Improved editor live preview. When adjusting backgrounds, figures, Spine, Live2D, and effects, preview positioning is closer to the actual screen, and synchronization is more stable when switching preview targets quickly. -Added Brazilian Portuguese and Korean translations, and improved language settings with a dropdown selector. +Improved screen and audio restoration after loading saves, returning to flowchart nodes, or continuing automatically, reducing state mismatches. #### Fixes -Fixed incorrect voice asset path resolution when specifying voice files with the vocal argument. +Fixed autoplay possibly continuing after opening the Backlog. + +Fixed multiple backlog voices, or backlog voice and current game voice, playing at the same time. + +Fixed video playback failures possibly blocking progress, and fixed skipOff videos still being skippable by double-clicking. -Fixed fast-forward and animation states becoming inconsistent after fast preview, mouse-wheel advancement, or stage reset. +Fixed follow-up flow possibly failing when a next chain leads into a scene jump. -Fixed possible call stack overflow when advancing continuously through long scenes. +Fixed abnormal scene jump targets possibly loading invalid scene files. -Fixed custom template styles still being affected by engine default styles. +Fixed bgm:none not stopping background music correctly. -Fixed version numbers of unofficial engine packages being overwritten incorrectly during builds. +Fixed incorrect animation behavior when changeBg / changeFigure / setTransform receive empty or malformed transform arguments. -Fixed built-in rain, snow, and cherry blossom effects not displaying when custom templates do not include the game/tex texture files. +Fixed some read-text styles in custom textbox templates not taking effect. + +Fixed script comments losing content after additional semicolons. + +Fixed resource preloading possibly processing the same resource repeatedly or including invalid empty resource paths. ## リリースノート @@ -78,26 +90,32 @@ Fixed built-in rain, snow, and cherry blossom effects not displaying when custom #### 新機能 -エディターのリアルタイムプレビューを改善し、シーン移動と状態同期の安定性を向上しました。また、デバッグ変数、テンプレート更新、より多くの画面プレビュー設定に対応しました。 +フローチャート機能を追加しました。Enable_flowchart を有効にして game/flowchart.json を設定すると、プレイヤーはメニューまたは下部コントロールから解放済みのストーリーノードを確認し、解放済みのシーンへ戻れるようになります。 -changeBg / changeFigure / setTransition / setAnimation / setTempAnimation / setTransform が ignoreDefault 引数に対応し、カスタムアニメーションで未指定のデフォルト変換やエフェクトを無視できるようになりました。 +フローチャートは複数ルート、ノード解放状態、未解放ノードの表示制御に対応しました。ゲームデータをリセットすると、フローチャートの進行状況も一緒に削除されます。 -CG 鑑賞が series によるグループ化と order による並べ替えに対応し、シリーズ画像を重ねて表示して順番にプレビューできるようになりました。 +エディターのリアルタイムプレビューを改善しました。背景、立ち絵、Spine、Live2D、エフェクトを調整する際、プレビュー上の位置が実際の画面により近くなり、プレビュー対象を素早く切り替えた時の同期も安定しました。 -続きからボタンの表示を制御する Enable_Continue 設定を追加しました。自動セーブがない場合はボタンが無効になり、ゲーム終了時には自動セーブが削除されます。 - -ブラジルポルトガル語と韓国語を追加し、言語設定をドロップダウン選択に改善しました。 +ロード、フローチャートノードへの復帰、自動継続時の画面と音声の復元を改善し、状態のずれを減らしました。 #### 修正 -vocal 引数でボイスを指定した際、アセットパスが正しく解決されない問題を修正しました。 +バックログを開いた後もオート再生が進み続ける場合がある問題を修正しました。 + +バックログ音声が複数同時に再生されたり、ゲーム内の現在のボイスと重なって再生されたりする問題を修正しました。 + +動画の再生に失敗した時に進行が止まる場合がある問題と、skipOff の動画をダブルクリックでスキップできてしまう問題を修正しました。 + +next の連続実行中にシーン移動が続くと、その後の進行が失敗する場合がある問題を修正しました。 + +異常なシーン移動先によって無効なシーンファイルが読み込まれる場合がある問題を修正しました。 -高速プレビュー、マウスホイールによる進行、舞台リセット後に、早送りとアニメーションの状態が一致しない問題を修正しました。 +bgm:none で BGM が正しく停止しない問題を修正しました。 -長いシーンを連続して進めた際に、コールスタックのオーバーフローが発生する場合がある問題を修正しました。 +changeBg / changeFigure / setTransform の変換引数が空、または不正な形式の場合に、アニメーション表示が正しくならない問題を修正しました。 -カスタムテンプレートのスタイルがエンジンのデフォルトスタイルの影響を受ける問題を修正しました。 +カスタムテキストボックステンプレートの既読テキストスタイルが一部反映されない問題を修正しました。 -非公式エンジンのビルド時に拡張パッケージのバージョン番号が誤って上書きされる問題を修正しました。 +スクリプトコメントに複数のセミコロンが含まれると、後続の内容が失われる問題を修正しました。 -カスタムテンプレートに game/tex のテクスチャファイルが含まれていない場合、内蔵の雨、雪、桜エフェクトが表示されない問題を修正しました。 +リソースのプリロードで同じリソースが重複処理されたり、無効な空パスのリソースが含まれたりする問題を修正しました。 From 6b4cbf667ba34b4df643250f9863a9336cba5110 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 17:32:48 +0800 Subject: [PATCH 39/39] update release note --- releasenote.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/releasenote.md b/releasenote.md index 44a393d17..8e2afab25 100644 --- a/releasenote.md +++ b/releasenote.md @@ -8,7 +8,7 @@ #### 新功能 -新增流程图功能。启用 Enable_flowchart 并配置 game/flowchart.json 后,玩家可以在菜单或底部控制栏查看已解锁的剧情节点,并跳转回已解锁的场景。 +新增流程图功能。玩家可以在菜单或底部控制栏查看已解锁的剧情节点,并跳转回已解锁的场景。 流程图支持多条线路、节点解锁记录和未解锁节点显示控制,重置游戏数据时会一并清理流程图进度。 @@ -49,7 +49,7 @@ #### New Features -Added the flowchart feature. After enabling Enable_flowchart and configuring game/flowchart.json, players can view unlocked story nodes from the menu or bottom control panel and jump back to unlocked scenes. +Added the flowchart feature. Players can view unlocked story nodes from the menu or bottom control panel and jump back to unlocked scenes. Flowcharts support multiple routes, node unlock progress, and locked-node visibility controls; resetting game data now also clears flowchart progress. @@ -90,7 +90,7 @@ Fixed resource preloading possibly processing the same resource repeatedly or in #### 新機能 -フローチャート機能を追加しました。Enable_flowchart を有効にして game/flowchart.json を設定すると、プレイヤーはメニューまたは下部コントロールから解放済みのストーリーノードを確認し、解放済みのシーンへ戻れるようになります。 +フローチャート機能を追加しました。プレイヤーはメニューまたは下部コントロールから解放済みのストーリーノードを確認し、解放済みのシーンへ戻れるようになります。 フローチャートは複数ルート、ノード解放状態、未解放ノードの表示制御に対応しました。ゲームデータをリセットすると、フローチャートの進行状況も一緒に削除されます。