From 759bfaa41205e7e79bd14940859061d416b101fd Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Sat, 15 Aug 2026 00:51:06 +0800 Subject: [PATCH 01/18] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E4=BA=A7=E7=89=A9=E4=B8=AD=E7=9A=84=20gz=20=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgal/package.json | 3 +-- packages/webgal/vite.config.ts | 4 ---- yarn.lock | 9 --------- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/webgal/package.json b/packages/webgal/package.json index b9df98064..9bebb157e 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -53,7 +53,6 @@ "typescript": "^4.9.5", "typescript-eslint": "^8.65.0", "unplugin-info": "^1.2.2", - "vite": "^5.4.21", - "vite-plugin-compression": "^0.5.1" + "vite": "^5.4.21" } } diff --git a/packages/webgal/vite.config.ts b/packages/webgal/vite.config.ts index 703e6cd27..c53c5d671 100644 --- a/packages/webgal/vite.config.ts +++ b/packages/webgal/vite.config.ts @@ -3,7 +3,6 @@ import react from '@vitejs/plugin-react'; import loadVersion from 'vite-plugin-package-version'; import { resolve } from 'path'; import Info from 'unplugin-info/vite'; -import viteCompression from 'vite-plugin-compression'; // https://vitejs.dev/config/ @@ -16,9 +15,6 @@ export default defineConfig({ react(), loadVersion(), Info(), - viteCompression({ - filter: /^(.*assets).*\.(js|css|ttf)$/, - }), // @ts-ignore // visualizer(), ], diff --git a/yarn.lock b/yarn.lock index c188ab5aa..a7c70ff27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6476,15 +6476,6 @@ vite-node@0.28.5: source-map-support "^0.5.21" vite "^3.0.0 || ^4.0.0" -vite-plugin-compression@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz#a75b0d8f48357ebb377b65016da9f20885ef39b6" - integrity sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg== - dependencies: - chalk "^4.1.2" - debug "^4.3.3" - fs-extra "^10.0.0" - vite-plugin-package-version@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/vite-plugin-package-version/-/vite-plugin-package-version-1.1.0.tgz#7d8088955aa21e4ec93353c98992b3f58c4bf13c" From a006b538c124b0daa2615bdbea593f4cdb22a1bd Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:00:15 +0800 Subject: [PATCH 02/18] =?UTF-8?q?fix(parser):=20=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E4=B8=8E=E5=AD=90=E5=9C=BA=E6=99=AF=E6=94=B9=E4=B8=BA=E5=B0=B1?= =?UTF-8?q?=E5=9C=B0=E7=B4=AF=E7=A7=AF=EF=BC=8C=E6=B6=88=E9=99=A4=20=CE=98?= =?UTF-8?q?(=E8=B5=84=E6=BA=90=E6=95=B0=C2=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sceneParser 在逐语句 map 里用 [...assetsList, ...sentenceAssets] 复制整个 已累积列表,资源密集脚本的整体复杂度因此是 Θ(资源数²):8000 条语句 (5333 个资源引用)解析耗时 47.8ms,而同样语句数、不带资源的脚本只要 6.7ms(线性)。 改为就地 push 追加,结果内容与顺序完全不变。新增回归测试: - 多条语句的资源/子场景累积顺序、类型去重与 prefetcher 入参保持一致 - 语句数 ×4 时耗时不得超过 ×8(旧实现在该测试下比值约 22) --- packages/parser/src/sceneParser.ts | 13 +++- packages/parser/test/parser.test.ts | 24 +++++++ .../parser/test/parserAssetsScaling.test.ts | 70 +++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 packages/parser/test/parserAssetsScaling.test.ts diff --git a/packages/parser/src/sceneParser.ts b/packages/parser/src/sceneParser.ts index 32d0ae08f..67a785175 100644 --- a/packages/parser/src/sceneParser.ts +++ b/packages/parser/src/sceneParser.ts @@ -51,9 +51,16 @@ export const sceneParser = ( SCRIPT_CONFIG_MAP, index, ); - // 在这里解析出语句可能携带的资源和场景,合并到 assetsList 和 subSceneList - assetsList = [...assetsList, ...returnSentence.sentenceAssets]; - subSceneList = [...subSceneList, ...returnSentence.subScene]; + // 在这里解析出语句可能携带的资源和场景,合并到 assetsList 和 subSceneList。 + // 必须就地追加:如果写成 [...assetsList, ...sentenceAssets],每条语句都要复制 + // 一次已累积的列表,整体退化成 Θ(资源数²)——资源密集的场景脚本(几乎每条语句 + // 都带 bg / figure / vocal)在数千条语句规模下会明显变慢。 + for (const asset of returnSentence.sentenceAssets) { + assetsList.push(asset); + } + for (const subScene of returnSentence.subScene) { + subSceneList.push(subScene); + } return returnSentence; }, ); diff --git a/packages/parser/test/parser.test.ts b/packages/parser/test/parser.test.ts index 26c205807..f875bbd20 100644 --- a/packages/parser/test/parser.test.ts +++ b/packages/parser/test/parser.test.ts @@ -291,6 +291,30 @@ test("scene assets skip entries with empty urls", async () => { expect(result.assetsList).toEqual([]); }); +test("scene assets and subscenes are accumulated from every statement", 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:a.webp; +changeFigure:b.png -id=b; +say:voice line -vocal=c.mp3; +changeBg:a.webp; +callScene:chapter1.txt; +callScene:chapter2.txt;`, 'test', 'test'); + + expect(result.assetsList).toEqual([ + { name: 'a.webp', url: 'a.webp', type: fileType.background, lineNumber: 0 }, + { name: 'b.png', url: 'b.png', type: fileType.figure, lineNumber: 1 }, + { name: 'c.mp3', url: 'c.mp3', type: fileType.vocal, lineNumber: 2 }, + ]); + expect(prefetchedAssets).toEqual(result.assetsList); + expect(result.subSceneList).toEqual(['chapter1.txt', 'chapter2.txt']); +}); + test("wait command", async () => { const parser = new SceneParser((assetList) => { }, (fileName, assetType) => { diff --git a/packages/parser/test/parserAssetsScaling.test.ts b/packages/parser/test/parserAssetsScaling.test.ts new file mode 100644 index 000000000..c95c32391 --- /dev/null +++ b/packages/parser/test/parserAssetsScaling.test.ts @@ -0,0 +1,70 @@ +import { expect, test } from 'vitest'; + +import { ADD_NEXT_ARG_LIST, SCRIPT_CONFIG } from '../src/config/scriptConfig'; +import SceneParser from '../src/index'; + +/** + * 资源密集脚本:几乎每条语句都带一个资源引用(bg / figure / vocal)。 + * sceneParser 会把每条语句的资源累积到场景级 assetsList。 + */ +function buildAssetDenseScene(statementCount: number): string { + const lines: string[] = []; + for (let index = 0; index < statementCount; index++) { + switch (index % 3) { + case 0: { + lines.push(`changeBg:background-${index % 40}.webp;`); + break; + } + case 1: { + lines.push( + `character-${index % 6}:line ${index} -vocal=voice-${index}.mp3;`, + ); + break; + } + default: { + lines.push( + `changeFigure:figure-${index % 12}.png -id=figure-${index % 12};`, + ); + } + } + } + return lines.join('\n'); +} + +const SMALL_STATEMENT_COUNT = 4000; +const LARGE_STATEMENT_COUNT = SMALL_STATEMENT_COUNT * 4; +/** 语句数 ×4 时允许的最大耗时倍率,阈值刻意放宽,只用于拦截复杂度回退 */ +const MAX_TIME_RATIO = 8; + +function measureParseTime(source: string): number { + const parser = new SceneParser( + () => { }, + (fileName) => fileName, + ADD_NEXT_ARG_LIST, + SCRIPT_CONFIG, + ); + + let best = Number.POSITIVE_INFINITY; + for (let round = 0; round < 3; round++) { + const start = performance.now(); + parser.parse(source, 'scaling', '/scaling.txt'); + best = Math.min(best, performance.now() - start); + } + return best; +} + +test('asset accumulation stays linear in the number of asset references', () => { + const smallSource = buildAssetDenseScene(SMALL_STATEMENT_COUNT); + const largeSource = buildAssetDenseScene(LARGE_STATEMENT_COUNT); + + // 预热:把首次调用的编译开销排除在测量之外 + measureParseTime(smallSource); + + const smallMs = measureParseTime(smallSource); + const largeMs = measureParseTime(largeSource); + + // 就地追加累积时,语句数 ×4 的耗时约为 4 倍; + // 旧的全量复制实现([...assetsList, ...sentenceAssets])会退化成 Θ(资源数²), + // 同样的 4 倍规模下耗时约为 12 倍以上。 + expect(largeMs / smallMs).toBeLessThan(MAX_TIME_RATIO); +}); From 7469e0f4fb7140522ea4a283762030495935e362 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:37:11 +0800 Subject: [PATCH 03/18] =?UTF-8?q?feat(parser):=20assetsPrefetcher=20?= =?UTF-8?q?=E5=8F=AF=E9=80=89=EF=BC=8C=E7=9C=81=E7=95=A5=E6=97=B6=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=E8=B5=84=E6=BA=90=E6=94=B6=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑器类调用方只需要语句结构,却要传一个空函数来满足签名,并为此付出每条语句 资源扫描、场景级汇总与去重的开销。把 assetsPrefetcher 改成可选:省略时不收集 资源,sentenceAssets / subScene / assetsList / subSceneList 均为空数组。 现有调用方行为不变(传了回调就照旧收集)。每条语句都带资源引用的脚本上,解析 耗时约降低 1/5 到 1/3(8000 条语句 10.0ms → 7.4ms)。 --- packages/parser/src/index.ts | 6 +- .../parser/src/interface/sceneInterface.ts | 8 +- packages/parser/src/sceneParser.ts | 17 +++- .../parser/src/scriptParser/scriptParser.ts | 7 +- .../parser/test/parserOptionalAssets.test.ts | 91 +++++++++++++++++++ 5 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 packages/parser/test/parserOptionalAssets.test.ts diff --git a/packages/parser/src/index.ts b/packages/parser/src/index.ts index 454fc8018..b4142b3d8 100644 --- a/packages/parser/src/index.ts +++ b/packages/parser/src/index.ts @@ -17,8 +17,12 @@ import { export default class SceneParser { private readonly SCRIPT_CONFIG_MAP: ConfigMap; + /** + * @param assetsPrefetcher 资源预取回调。省略时不收集资源:`sentenceAssets`、`subScene`、 + * `assetsList`、`subSceneList` 均为空数组,适用于只需要语句结构的调用方(编辑器语法解析等)。 + */ constructor( - private readonly assetsPrefetcher: (assetList: IAsset[]) => void, + private readonly assetsPrefetcher: ((assetList: IAsset[]) => void) | undefined, private readonly assetSetter: ( fileName: string, assetType: fileType, diff --git a/packages/parser/src/interface/sceneInterface.ts b/packages/parser/src/interface/sceneInterface.ts index 657ea53ae..d470e6a3b 100644 --- a/packages/parser/src/interface/sceneInterface.ts +++ b/packages/parser/src/interface/sceneInterface.ts @@ -72,8 +72,8 @@ export interface ISentence { commandRaw: string; // 命令的原始内容,方便调试 content: string; // 语句内容 args: Array; // 参数列表 - sentenceAssets: Array; // 语句携带的资源列表 - subScene: Array; // 语句包含子场景列表 + sentenceAssets: Array; // 语句携带的资源列表;SceneParser 未提供 assetsPrefetcher 时为空 + subScene: Array; // 语句包含子场景列表;未提供 assetsPrefetcher 时为空 inlineComment: string; // 行内注释 /** * 本语句在原始场景文件中占据的行范围(0-based,含首尾)。 @@ -94,8 +94,8 @@ export interface IScene { sceneName: string; // 场景名称 sceneUrl: string; // 场景url sentenceList: Array; // 语句列表 - assetsList: Array; // 资源列表 - subSceneList: Array; // 子场景的url列表 + assetsList: Array; // 资源列表;未提供 assetsPrefetcher 时为空 + subSceneList: Array; // 子场景的url列表;未提供 assetsPrefetcher 时为空 } /** diff --git a/packages/parser/src/sceneParser.ts b/packages/parser/src/sceneParser.ts index 67a785175..a0fe61c87 100644 --- a/packages/parser/src/sceneParser.ts +++ b/packages/parser/src/sceneParser.ts @@ -27,11 +27,14 @@ export const sceneParser = ( rawScene: string, sceneName: string, sceneUrl: string, - assetsPrefetcher: (assetList: Array) => void, + assetsPrefetcher: ((assetList: Array) => void) | undefined, assetSetter: (fileName: string, assetType: fileType) => string, ADD_NEXT_ARG_LIST: commandType[], SCRIPT_CONFIG_MAP: ConfigMap, ): IScene => { + // 没有预取回调时不需要场景级资源列表,连同每条语句的资源扫描一起跳过 + const collectAssets = assetsPrefetcher !== undefined; + // 预处理把多行语句折叠进它的首行,并用占位行补齐被折叠掉的行, // 因此这里的行数与原始场景严格一致,「语句 index == 文件行号」始终成立。 const rawSentenceList = sceneTextPreProcess(rawScene).split('\n'); // 原始句子列表 @@ -50,7 +53,11 @@ export const sceneParser = ( ADD_NEXT_ARG_LIST, SCRIPT_CONFIG_MAP, index, + collectAssets, ); + if (!collectAssets) { + return returnSentence; + } // 在这里解析出语句可能携带的资源和场景,合并到 assetsList 和 subSceneList。 // 必须就地追加:如果写成 [...assetsList, ...sentenceAssets],每条语句都要复制 // 一次已累积的列表,整体退化成 Θ(资源数²)——资源密集的场景脚本(几乎每条语句 @@ -67,9 +74,11 @@ export const sceneParser = ( markMultilineRanges(sentenceList, rawSentenceList); - // 开始资源的预加载 - assetsList = deduplicateAssets(assetsList); - assetsPrefetcher(assetsList); + if (collectAssets) { + // 开始资源的预加载 + assetsList = deduplicateAssets(assetsList); + assetsPrefetcher(assetsList); + } return { sceneName: sceneName, // 场景名称 diff --git a/packages/parser/src/scriptParser/scriptParser.ts b/packages/parser/src/scriptParser/scriptParser.ts index dc2acd526..c0e558f57 100644 --- a/packages/parser/src/scriptParser/scriptParser.ts +++ b/packages/parser/src/scriptParser/scriptParser.ts @@ -18,6 +18,8 @@ import { ConfigMap } from '../config/scriptConfig'; * @param assetSetter * @param ADD_NEXT_ARG_LIST * @param SCRIPT_CONFIG_MAP + * @param lineNumber 语句所在行号 + * @param collectAssets 是否扫描语句携带的资源与子场景;为 false 时两者都返回空数组 */ export const scriptParser = ( sentenceRaw: string, @@ -25,6 +27,7 @@ export const scriptParser = ( ADD_NEXT_ARG_LIST: commandType[], SCRIPT_CONFIG_MAP: ConfigMap, lineNumber = 0, + collectAssets = true, ): ISentence => { let command: commandType; // 默认为对话 let content: string; // 语句内容 @@ -109,8 +112,8 @@ export const scriptParser = ( } content = contentParser(newSentenceRaw.trim(), command, assetSetter); // 将语句内容里的文件名转为相对或绝对路径 - sentenceAssets = assetsScanner(command, content, args, lineNumber); // 扫描语句携带资源 - subScene = subSceneScanner(command, content); // 扫描语句携带子场景 + sentenceAssets = collectAssets ? assetsScanner(command, content, args, lineNumber) : []; // 扫描语句携带资源 + subScene = collectAssets ? subSceneScanner(command, content) : []; // 扫描语句携带子场景 return { command: command, // 语句类型 commandRaw: commandRaw.trim(), // 命令原始内容,方便调试 diff --git a/packages/parser/test/parserOptionalAssets.test.ts b/packages/parser/test/parserOptionalAssets.test.ts new file mode 100644 index 000000000..f68c86d1b --- /dev/null +++ b/packages/parser/test/parserOptionalAssets.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from 'vitest'; + +import { ADD_NEXT_ARG_LIST, SCRIPT_CONFIG } from '../src/config/scriptConfig'; +import SceneParser from '../src/index'; +import { IAsset } from '../src/interface/sceneInterface'; + +const SCENE = `changeBg:a.webp; +character:hello -vocal=b.mp3; +callScene:chapter.txt;`; + +const identityAssetSetter = (fileName: string) => fileName; + +test('省略 assetsPrefetcher 时资源与子场景都不收集', () => { + const parser = new SceneParser( + undefined, + identityAssetSetter, + ADD_NEXT_ARG_LIST, + SCRIPT_CONFIG, + ); + + const result = parser.parse(SCENE, 'test', 'test'); + + expect(result.sentenceList).toHaveLength(3); + expect(result.assetsList).toEqual([]); + expect(result.subSceneList).toEqual([]); + expect(result.sentenceList.map((sentence) => sentence.sentenceAssets)).toEqual([ + [], + [], + [], + ]); + expect(result.sentenceList.map((sentence) => sentence.subScene)).toEqual([ + [], + [], + [], + ]); +}); + +test('提供 assetsPrefetcher 时收集行为与之前一致', () => { + let prefetched: IAsset[] = []; + const parser = new SceneParser( + (assetList) => { + prefetched = assetList; + }, + identityAssetSetter, + ADD_NEXT_ARG_LIST, + SCRIPT_CONFIG, + ); + + const result = parser.parse(SCENE, 'test', 'test'); + + expect(result.assetsList.map((asset) => asset.url)).toEqual([ + 'a.webp', + 'b.mp3', + ]); + expect(prefetched).toEqual(result.assetsList); + expect(result.subSceneList).toEqual(['chapter.txt']); + expect(result.sentenceList[1]?.sentenceAssets).toHaveLength(1); + expect(result.sentenceList[2]?.subScene).toEqual(['chapter.txt']); +}); + +test('跳过资源收集不影响语句切分与行范围', () => { + const multiLineScene = [ + 'changeFigure:hero.png', + ' -id=hero -left;', + 'say:next;', + ].join('\n'); + + const withAssets = new SceneParser( + () => { }, + identityAssetSetter, + ADD_NEXT_ARG_LIST, + SCRIPT_CONFIG, + ).parse(multiLineScene, 'test', 'test'); + const withoutAssets = new SceneParser( + undefined, + identityAssetSetter, + ADD_NEXT_ARG_LIST, + SCRIPT_CONFIG, + ).parse(multiLineScene, 'test', 'test'); + + const pickStructure = (scene: typeof withAssets) => + scene.sentenceList.map((sentence) => [ + sentence.command, + sentence.content, + sentence.startLine, + sentence.endLine, + sentence.args, + ]); + + expect(pickStructure(withoutAssets)).toEqual(pickStructure(withAssets)); +}); From a491d3823d76dafe4be969a70e6c7460d0383d2d Mon Sep 17 00:00:00 2001 From: Mahiru Date: Wed, 16 Sep 2026 00:33:16 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E8=A1=A5=E5=85=85=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/controller/gamePlay/nextSentence.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index 6d00bfc56..8ae5fba40 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -11,11 +11,13 @@ import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; * 这里处理三种“不能直接进入下一条语句”的情况: * 1. 场景正在异步写入时,任何推进都必须停止。 * 2. 存在 blockingNext 的演出时,用户推进和内部推进都必须等待。 - * 3. 存在可提前结束的非 hold 演出时,用户推进只负责结束演出并停下; - * 内部继续推进会先结束演出,然后继续执行下一条语句。 + * 3. 存在可提前结束的非 hold 演出时,用户推进将所有可回收的非 hold 演出提前结束, + * 这在 galgame 中很常见,用户并非想要推进到下一条语句,而是想要让当前对话的演出先推进到终态, + * 比如立刻展示完整文字并将立绘动画结束并推到终态; + * 内部继续推进会先结束演出(但是实际上在很多情况下,其他演出已经结束了),然后继续执行下一条语句。 * * @param continueAfterSettling 是否在清理普通非 hold 演出后继续推进。 - * @returns true 表示可以继续调用 forward/commitForward。 + * @returns true 表示可以继续调用 forward/commitForward,也就是推进到下一条语句。 */ export const preForward = (continueAfterSettling = false) => { if (WebGAL.sceneManager.lockSceneWrite) { @@ -31,11 +33,21 @@ export const preForward = (continueAfterSettling = false) => { const hasUnsettledNonHoldPerform = WebGAL.gameplay.performController.hasUnsettledNonHoldPerform(); if (hasUnsettledNonHoldPerform) { logger.debug('提前结束被触发,现在清除普通演出'); - // 用户 next 不消费 goNextWhenOver;内部继续推进会自己接着 forward,避免重复触发下一步。 + // 对于用户推进,传入 true, + // 这样执行 settleNonHoldPerforms 的时候,遇到明确要在结束后继续推进到下一条语句的演出时, + // 就会触发内部推进,尝试推进到下一语句。比如 intro 演出请求了结束后继续,按语义应该继续。 + // ------ + // 而对于内部推进,传入 false,因为调用方已经打算在提前结算演出后继续,不需要再触发一次。 + // 例如,一个演出序列里有 2 个不同时长的演出都设置了 goNextWhenOver。 + // 但是目标都是一致的,那就是结束后转到下一句。快的那个触发了内部继续,在这里结束了慢的那个演出。 + // 慢的演出要求的内部继续已经可以被调用方满足,因此无需重复触发。 WebGAL.gameplay.performController.settleNonHoldPerforms(!continueAfterSettling); + // 用户推进时,返回 false,因为无论是遇到了提前结束当前句演出还是引发了内部推进,这里都不能让调用方继续语句推进流程了。 + // 内部推进时,返回 true,让调用方继续到下一步的语句推进流程。 return continueAfterSettling; } + // 没有阻塞,也没有需要提前结束的演出,允许调用方继续推进语句。 return true; }; @@ -111,6 +123,7 @@ export const continueSentence = () => { * 供点击、键盘、自动播放和快进等“外部下一步”入口调用。 * 它会触发 userInteractNext,让 intro 等演出先响应用户输入。 * 如果当前存在可提前结束的普通演出,本次用户推进只结束演出,不再继续执行下一条语句。 + * 结算过程仍可能依据 goNextWhenOver 触发内部继续,那是演出要求的,需要满足。 */ export const nextSentence = () => { WebGAL.events.userInteractNext.emit(); From 14ffc6a28c9112a710855c592617ea78529face1 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Wed, 16 Sep 2026 22:32:15 +0800 Subject: [PATCH 05/18] feat: use transformFrom arg --- .../src/Core/gameScripts/setAnimation.ts | 5 +- .../src/Core/gameScripts/setTempAnimation.ts | 5 +- .../src/Core/gameScripts/setTransform.ts | 10 ++- .../webgal/src/Core/util/getSentenceArg.ts | 61 +++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/packages/webgal/src/Core/gameScripts/setAnimation.ts b/packages/webgal/src/Core/gameScripts/setAnimation.ts index 3db4f43da..ad89e042c 100644 --- a/packages/webgal/src/Core/gameScripts/setAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setAnimation.ts @@ -1,6 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; +import { getBooleanArgByKey, getStringArgByKey, resolveTransformArgs } from '@/Core/util/getSentenceArg'; import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; @@ -17,10 +17,9 @@ export const setAnimation = (sentence: ISentence): IPerform => { const animationDuration = getAnimateDuration(animationName); let target = getStringArgByKey(sentence, 'target') ?? ''; target = target !== '' ? target : 'default_id'; - const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; - const writeFullEffect = !parallel && !(getBooleanArgByKey(sentence, 'ignoreDefault') ?? false); + const { writeDefault, writeFullEffect } = resolveTransformArgs(sentence, parallel); const key = `${target}-${animationName}-${animationDuration}`; const performInitName = `animation-${target}`; diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index d0789ff74..a69b80290 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -1,6 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; +import { getBooleanArgByKey, getStringArgByKey, resolveTransformArgs } from '@/Core/util/getSentenceArg'; import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; import { IUserAnimation } from '../Modules/animations'; @@ -26,10 +26,9 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { WebGAL.animationManager.addAnimation(newAnimation); const animationDuration = getAnimateDuration(animationName); const target = getStringArgByKey(sentence, 'target') ?? '0'; - const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; - const writeFullEffect = !parallel && !(getBooleanArgByKey(sentence, 'ignoreDefault') ?? false); + const { writeDefault, writeFullEffect } = resolveTransformArgs(sentence, parallel); const key = `${target}-${animationName}-${animationDuration}`; const performInitName = `animation-${target}`; diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index b18d23142..dd6f59e7d 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -1,6 +1,11 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { getBooleanArgByKey, getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; +import { + getBooleanArgByKey, + getNumberArgByKey, + getStringArgByKey, + resolveTransformArgs, +} from '@/Core/util/getSentenceArg'; import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; import { AnimationFrame, IUserAnimation } from '../Modules/animations'; @@ -21,11 +26,10 @@ export const setTransform = (sentence: ISentence): IPerform => { const duration = getNumberArgByKey(sentence, 'duration') ?? 500; const ease = getStringArgByKey(sentence, 'ease') ?? ''; - const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; const target = getStringArgByKey(sentence, 'target') ?? '0'; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; const parallel = getBooleanArgByKey(sentence, 'parallel') ?? false; - const writeFullEffect = !parallel && !(getBooleanArgByKey(sentence, 'ignoreDefault') ?? false); + const { writeDefault, writeFullEffect } = resolveTransformArgs(sentence, parallel); const performInitName = `animation-${target}`; const performName = parallel ? `${performInitName}#${animationName}` : performInitName; diff --git a/packages/webgal/src/Core/util/getSentenceArg.ts b/packages/webgal/src/Core/util/getSentenceArg.ts index 629c9b9ab..851438b95 100644 --- a/packages/webgal/src/Core/util/getSentenceArg.ts +++ b/packages/webgal/src/Core/util/getSentenceArg.ts @@ -31,3 +31,64 @@ export function getStringArgByKey(sentence: ISentence, argKey: string): string | export function getFigurePositionFromArgs(sentence: ISentence): IFigurePosition | '' { return FIGURE_POSITIONS.find((position) => getBooleanArgByKey(sentence, position)) ?? ''; } + +export type TransformFromMode = 'current' | 'default'; + +export interface ITransformConfig { + writeDefault: boolean; + writeFullEffect: boolean; + transformFrom: TransformFromMode; +} + +/** + * 解析动画变换基准与写入模式 + * 1. 若有 transformFrom 则优先使用该参数,无论如何不考虑旧参数: + * - 'default': writeDefault = true, writeFullEffect = !parallel + * - 'current' (及其他非 default 值): writeDefault = false, writeFullEffect = false + * 2. 若无 transformFrom,但显式传了旧参数 (-writeDefault 或 -ignoreDefault),走旧参数逻辑: + * - writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false + * - writeFullEffect = !parallel && !(getBooleanArgByKey(sentence, 'ignoreDefault') ?? false) + * 3. 若无 transformFrom 且未传旧参数,默认也是用 current (writeDefault = false, writeFullEffect = false) + * + * @param sentence 语句对象 + * @param parallel 是否并行动画 (parallel 模式下强制 writeFullEffect = false) + */ +export function resolveTransformArgs(sentence: ISentence, parallel = false): ITransformConfig { + const transformFrom = getStringArgByKey(sentence, 'transformFrom'); + + // 1. 如果有 transformFrom 就用这个参数,无论如何 + if (transformFrom !== null && transformFrom !== '') { + if (transformFrom === 'default') { + return { + transformFrom: 'default', + writeDefault: true, + writeFullEffect: !parallel, + }; + } + return { + transformFrom: 'current', + writeDefault: false, + writeFullEffect: false, + }; + } + + // 2. 如果没有 transformFrom,并且非要传旧参数,那才完全不考虑 transformFrom + const hasOldWriteDefault = getSentenceArgByKey(sentence, 'writeDefault') !== null; + const hasOldIgnoreDefault = getSentenceArgByKey(sentence, 'ignoreDefault') !== null; + if (hasOldWriteDefault || hasOldIgnoreDefault) { + const writeDefault = getBooleanArgByKey(sentence, 'writeDefault') ?? false; + const writeFullEffect = !parallel && !(getBooleanArgByKey(sentence, 'ignoreDefault') ?? false); + return { + transformFrom: writeDefault ? 'default' : 'current', + writeDefault, + writeFullEffect, + }; + } + + // 3. 如果没有 transformFrom,也是用 current + return { + transformFrom: 'current', + writeDefault: false, + writeFullEffect: false, + }; +} From ed9e94e3e69022bb5af704778e98fb66fadd03f0 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 19 Sep 2026 00:26:46 +0800 Subject: [PATCH 06/18] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E5=9C=A8=E6=94=B9=E9=80=A0=E5=86=85=E6=A0=B8=E5=90=8E?= =?UTF-8?q?=E4=B8=8D=E9=9C=80=E8=A6=81=E7=9A=84=20setTimeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Modules/perform/performController.ts | 3 +- .../src/Core/controller/gamePlay/autoPlay.ts | 1 + .../src/Core/controller/gamePlay/fastSkip.ts | 1 + .../controller/stage/pixi/PixiController.ts | 183 ++++++++--------- .../src/Core/controller/stage/pixi/spine.ts | 193 +++++++++--------- .../stage/pixi/syncPixiStageState.ts | 2 + .../controller/storage/storageController.ts | 1 + packages/webgal/src/Core/gameScripts/end.ts | 3 +- .../webgal/src/Core/gameScripts/intro.tsx | 3 + .../webgal/src/Core/gameScripts/playVideo.tsx | 54 ++--- packages/webgal/src/Core/gameScripts/say.ts | 1 + .../src/Core/gameScripts/vocal/index.ts | 3 + .../Core/gameScripts/vocal/vocalAnimation.ts | 3 + .../Core/util/prefetcher/assetsPrefetcher.ts | 1 + .../Core/util/prefetcher/scenePrefetcher.ts | 1 + .../util/syncWithEditor/previewSyncRuntime.ts | 2 + .../runtime/embeddedPreviewBootstrap.ts | 1 + 17 files changed, 234 insertions(+), 222 deletions(-) diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index 6531a3397..7306d94df 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -169,7 +169,7 @@ export class PerformController { perform.isStarted = true; perform.startFunction?.(); - // 时间到后自动清理演出 + // 演出持续 duration 毫秒后自动回收;同步清理会使演出刚启动就结束。 const stopTimeout = setTimeout(() => { // perform.stopFunction(); // perform.isOver = true; @@ -332,6 +332,7 @@ export class PerformController { }); if (isBlockingNext) { // 有阻塞,提前结束 + // 其他演出仍阻塞推进,间隔重试以等待其结束,避免同步递归占住主线程。 setTimeout(this.goNextWhenOver, 100); } else { continueSentence(); diff --git a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts index 575ae7d41..e6056a4d8 100644 --- a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts +++ b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts @@ -71,6 +71,7 @@ const autoPlay = () => { } // nextSentence(); if (WebGAL.gameplay.autoTimeout === null) { + // 保留自动阅读的停留时间,到时再检查能否推进,不能同步连续跳句。 WebGAL.gameplay.autoTimeout = setTimeout(autoNextSentence, autoPlayDelay); } }; diff --git a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts index f593e5bea..76ab59a5f 100644 --- a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts +++ b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts @@ -63,6 +63,7 @@ export const startTemporaryFast = (duration = 150) => { clearTemporaryFast(); if (WebGAL.gameplay.fastInterval !== null) stopFast(); WebGAL.gameplay.isFast = true; + // 临时快进状态持续 duration 毫秒,供此期间的演出判断是否跳过动画。 temporaryFastTimeout = setTimeout(() => { if (WebGAL.gameplay.fastInterval === null) { WebGAL.gameplay.isFast = false; diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index c07b833ab..07e1bf37b 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -185,7 +185,7 @@ export default class PixiStage { this.backgroundContainer, ); this.currentApp = app; - // loader 防死 + // 每 500ms 兜底尝试推进加载队列;同步递归会阻塞主线程,也无法等待 loader 结束忙碌。 const reload = () => { setTimeout(reload, 500); this.callLoader(); @@ -372,34 +372,31 @@ export default class PixiStage { // 完成图片加载后执行的函数 const setup = () => { - // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! - - setTimeout(() => { - const texture = loader.resources?.[url]?.texture; - if (texture && this.getStageObjByUuid(bgUuid)) { - /** - * 重设大小 - */ - const originalWidth = texture.width; - const originalHeight = texture.height; - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - const targetScale = Math.max(scaleX, scaleY); - const bgSprite = new PIXI.Sprite(texture); - bgSprite.scale.x = targetScale; - bgSprite.scale.y = targetScale; - bgSprite.anchor.set(0.5); - bgSprite.position.y = this.stageHeight / 2; - thisBgContainer.setBaseX(this.stageWidth / 2); - thisBgContainer.setBaseY(this.stageHeight / 2); - thisBgContainer.pivot.set(0, this.stageHeight / 2); - - // 挂载 - thisBgContainer.addChild(bgSprite); - this.notifyTargetReferenceBoxChanged(key); - this.requestRender(); - } - }, 0); + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + const texture = loader.resources?.[url]?.texture; + if (texture && this.getStageObjByUuid(bgUuid)) { + /** + * 重设大小 + */ + const originalWidth = texture.width; + const originalHeight = texture.height; + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + const targetScale = Math.max(scaleX, scaleY); + const bgSprite = new PIXI.Sprite(texture); + bgSprite.scale.x = targetScale; + bgSprite.scale.y = targetScale; + bgSprite.anchor.set(0.5); + bgSprite.position.y = this.stageHeight / 2; + thisBgContainer.setBaseX(this.stageWidth / 2); + thisBgContainer.setBaseY(this.stageHeight / 2); + thisBgContainer.pivot.set(0, this.stageHeight / 2); + + // 挂载 + thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); + this.requestRender(); + } }; /** @@ -448,43 +445,40 @@ export default class PixiStage { // 完成加载后执行的函数 const setup = () => { - // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! - - setTimeout(() => { - console.debug('start loaded video: ' + url); - const video = document.createElement('video'); - const videoResource = new PIXI.VideoResource(video); - videoResource.src = url; - videoResource.source.preload = 'auto'; - videoResource.source.muted = true; - videoResource.source.loop = true; - videoResource.source.autoplay = true; - videoResource.source.src = url; - // @ts-ignore - const texture = PIXI.Texture.from(videoResource); - if (texture && this.getStageObjByUuid(bgUuid)) { - /** - * 重设大小 - */ - texture.baseTexture.resource.load().then(() => { - const originalWidth = videoResource.source.videoWidth; - const originalHeight = videoResource.source.videoHeight; - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - const targetScale = Math.max(scaleX, scaleY); - const bgSprite = new PIXI.Sprite(texture); - bgSprite.scale.x = targetScale; - bgSprite.scale.y = targetScale; - bgSprite.anchor.set(0.5); - bgSprite.position.y = this.stageHeight / 2; - thisBgContainer.setBaseX(this.stageWidth / 2); - thisBgContainer.setBaseY(this.stageHeight / 2); - thisBgContainer.pivot.set(0, this.stageHeight / 2); - thisBgContainer.addChild(bgSprite); - this.notifyTargetReferenceBoxChanged(key); - }); - } - }, 0); + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + console.debug('start loaded video: ' + url); + const video = document.createElement('video'); + const videoResource = new PIXI.VideoResource(video); + videoResource.src = url; + videoResource.source.preload = 'auto'; + videoResource.source.muted = true; + videoResource.source.loop = true; + videoResource.source.autoplay = true; + videoResource.source.src = url; + // @ts-ignore + const texture = PIXI.Texture.from(videoResource); + if (texture && this.getStageObjByUuid(bgUuid)) { + /** + * 重设大小 + */ + texture.baseTexture.resource.load().then(() => { + const originalWidth = videoResource.source.videoWidth; + const originalHeight = videoResource.source.videoHeight; + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + const targetScale = Math.max(scaleX, scaleY); + const bgSprite = new PIXI.Sprite(texture); + bgSprite.scale.x = targetScale; + bgSprite.scale.y = targetScale; + bgSprite.anchor.set(0.5); + bgSprite.position.y = this.stageHeight / 2; + thisBgContainer.setBaseX(this.stageWidth / 2); + thisBgContainer.setBaseY(this.stageHeight / 2); + thisBgContainer.pivot.set(0, this.stageHeight / 2); + thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); + }); + } }; /** @@ -542,36 +536,34 @@ export default class PixiStage { }); // 完成图片加载后执行的函数 const setup = () => { - // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! - setTimeout(() => { - const texture = loader.resources?.[url]?.texture; - if (texture && this.getStageObjByUuid(figureUuid)) { - /** - * 重设大小 - */ - const originalWidth = texture.width; - const originalHeight = texture.height; - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - const targetScale = Math.min(scaleX, scaleY); - const figureSprite = new PIXI.Sprite(texture); - figureSprite.scale.x = targetScale; - figureSprite.scale.y = targetScale; - figureSprite.anchor.set(0.5); - figureSprite.position.y = this.stageHeight / 2; - const targetWidth = originalWidth * targetScale; - const targetHeight = originalHeight * targetScale; - thisFigureContainer.setBaseY(this.stageHeight / 2); - if (targetHeight < this.stageHeight) { - thisFigureContainer.setBaseY(this.stageHeight / 2 + (this.stageHeight - targetHeight) / 2); - } - thisFigureContainer.setBaseX(getFigureBaseX(presetPosition, this.stageWidth, targetWidth)); - thisFigureContainer.pivot.set(0, this.stageHeight / 2); - thisFigureContainer.addChild(figureSprite); - this.notifyTargetReferenceBoxChanged(key); - this.requestRender(); + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + const texture = loader.resources?.[url]?.texture; + if (texture && this.getStageObjByUuid(figureUuid)) { + /** + * 重设大小 + */ + const originalWidth = texture.width; + const originalHeight = texture.height; + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + const targetScale = Math.min(scaleX, scaleY); + const figureSprite = new PIXI.Sprite(texture); + figureSprite.scale.x = targetScale; + figureSprite.scale.y = targetScale; + figureSprite.anchor.set(0.5); + figureSprite.position.y = this.stageHeight / 2; + const targetWidth = originalWidth * targetScale; + const targetHeight = originalHeight * targetScale; + thisFigureContainer.setBaseY(this.stageHeight / 2); + if (targetHeight < this.stageHeight) { + thisFigureContainer.setBaseY(this.stageHeight / 2 + (this.stageHeight - targetHeight) / 2); } - }, 0); + thisFigureContainer.setBaseX(getFigureBaseX(presetPosition, this.stageWidth, targetWidth)); + thisFigureContainer.pivot.set(0, this.stageHeight / 2); + thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); + this.requestRender(); + } }; /** @@ -973,6 +965,7 @@ export default class PixiStage { resolve(); }; + // 资源加载失败或尺寸通知缺失时也要结束等待,避免参考尺寸查询永久挂起。 timeoutId = window.setTimeout(resolveAndCleanup, timeoutMs); waiters.add(resolveAndCleanup); }); diff --git a/packages/webgal/src/Core/controller/stage/pixi/spine.ts b/packages/webgal/src/Core/controller/stage/pixi/spine.ts index f1f34aa80..6a897240f 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/spine.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/spine.ts @@ -94,80 +94,79 @@ export async function addSpineFigureImpl( // 完成图片加载后执行的函数 const setup = async () => { - setTimeout(() => { - console.log('Setting up Spine' + key + url); - if (!pixiSpine) { - // 无法加载 'pixi-spine',跳过 Spine 相关逻辑 - logger.warn(`Spine module not loaded. Skipping Spine figure: ${key}`); - return; - } + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + console.log('Setting up Spine' + key + url); + if (!pixiSpine) { + // 无法加载 'pixi-spine',跳过 Spine 相关逻辑 + logger.warn(`Spine module not loaded. Skipping Spine figure: ${key}`); + return; + } - const { Spine } = pixiSpine; - const spineResource: any = spineLoader!.resources?.[spineId]; - if (spineResource && this.getStageObjByUuid(figureUuid)) { - const figureSpine = new Spine(spineResource.spineData); - const spineBounds = figureSpine.getLocalBounds(); - const spineCenterX = spineBounds.x + spineBounds.width / 2; - const spineCenterY = spineBounds.y + spineBounds.height / 2; - figureSpine.pivot.set(spineCenterX, spineCenterY); - figureSpine.interactive = false; + const { Spine } = pixiSpine; + const spineResource: any = spineLoader!.resources?.[spineId]; + if (spineResource && this.getStageObjByUuid(figureUuid)) { + const figureSpine = new Spine(spineResource.spineData); + const spineBounds = figureSpine.getLocalBounds(); + const spineCenterX = spineBounds.x + spineBounds.width / 2; + const spineCenterY = spineBounds.y + spineBounds.height / 2; + figureSpine.pivot.set(spineCenterX, spineCenterY); + figureSpine.interactive = false; - const motionFromState = stageStateManager.getViewStageState().live2dMotion.find((e) => e.target === key); - let animationToPlay = ''; - if (motionFromState?.skin) { - if (!applySpineSkin(figureSpine, motionFromState.skin)) { - logger.warn(`Spine skin not found: ${motionFromState.skin} on ${key}`); - } + const motionFromState = stageStateManager.getViewStageState().live2dMotion.find((e) => e.target === key); + let animationToPlay = ''; + if (motionFromState?.skin) { + if (!applySpineSkin(figureSpine, motionFromState.skin)) { + logger.warn(`Spine skin not found: ${motionFromState.skin} on ${key}`); } + } - if ( - motionFromState && - figureSpine.spineData.animations.find((anim: any) => anim.name === motionFromState.motion) - ) { - // 使用状态中指定的动画 - animationToPlay = motionFromState.motion; - } else if (figureSpine.spineData.animations.length > 0) { - // 播放默认动画(第一个动画) - animationToPlay = figureSpine.spineData.animations[0].name; - } + if ( + motionFromState && + figureSpine.spineData.animations.find((anim: any) => anim.name === motionFromState.motion) + ) { + // 使用状态中指定的动画 + animationToPlay = motionFromState.motion; + } else if (figureSpine.spineData.animations.length > 0) { + // 播放默认动画(第一个动画) + animationToPlay = figureSpine.spineData.animations[0].name; + } - if (animationToPlay) { - figureSpine.state.setAnimation(0, animationToPlay, false); - figureSpine.autoUpdate = true; - const stageObj = this.getStageObjByUuid(figureUuid); - if (stageObj) { - if (stageObj.spineAnimation) { - stageObj.spineAnimation = animationToPlay; - } + if (animationToPlay) { + figureSpine.state.setAnimation(0, animationToPlay, false); + figureSpine.autoUpdate = true; + const stageObj = this.getStageObjByUuid(figureUuid); + if (stageObj) { + if (stageObj.spineAnimation) { + stageObj.spineAnimation = animationToPlay; } } + } - /** - * 重设大小 - */ - const originalWidth = figureSpine.width; - const originalHeight = figureSpine.height; - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - const targetScale = Math.min(scaleX, scaleY); - const figureSprite = new PIXI.Sprite(); - figureSprite.addChild(figureSpine); - figureSprite.scale.x = targetScale; - figureSprite.scale.y = targetScale; - figureSprite.anchor.set(0.5); - figureSprite.position.y = this.stageHeight / 2; - const targetWidth = originalWidth * targetScale; - const targetHeight = originalHeight * targetScale; - thisFigureContainer.setBaseY(this.stageHeight / 2); - if (targetHeight < this.stageHeight) { - thisFigureContainer.setBaseY(this.stageHeight / 2 + (this.stageHeight - targetHeight) / 2); - } - thisFigureContainer.setBaseX(getFigureBaseX(presetPosition, this.stageWidth, targetWidth)); - thisFigureContainer.pivot.set(0, this.stageHeight / 2); - thisFigureContainer.addChild(figureSprite); - this.notifyTargetReferenceBoxChanged(key); + /** + * 重设大小 + */ + const originalWidth = figureSpine.width; + const originalHeight = figureSpine.height; + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + const targetScale = Math.min(scaleX, scaleY); + const figureSprite = new PIXI.Sprite(); + figureSprite.addChild(figureSpine); + figureSprite.scale.x = targetScale; + figureSprite.scale.y = targetScale; + figureSprite.anchor.set(0.5); + figureSprite.position.y = this.stageHeight / 2; + const targetWidth = originalWidth * targetScale; + const targetHeight = originalHeight * targetScale; + thisFigureContainer.setBaseY(this.stageHeight / 2); + if (targetHeight < this.stageHeight) { + thisFigureContainer.setBaseY(this.stageHeight / 2 + (this.stageHeight - targetHeight) / 2); } - }, 0); + thisFigureContainer.setBaseX(getFigureBaseX(presetPosition, this.stageWidth, targetWidth)); + thisFigureContainer.pivot.set(0, this.stageHeight / 2); + thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); + } }; /** @@ -227,40 +226,38 @@ export async function addSpineBgImpl(this: PixiStage, key: string, url: string) const { Spine } = pixiSpine; const spineResource: any = spineLoader!.resources?.[spineId]; - // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! - setTimeout(() => { - if (spineResource && this.getStageObjByUuid(bgUuid)) { - const bgSpine = new Spine(spineResource.spineData); - const transY = spineResource?.spineData?.y ?? 0; - /** - * 重设大小 - */ - const originalWidth = bgSpine.width; // TODO: 视图大小可能小于画布大小,应提供参数指定视图大小 - const originalHeight = bgSpine.height; // TODO: 视图大小可能小于画布大小,应提供参数指定视图大小 - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - logger.debug('bgSpine state', bgSpine.state); - // TODO: 也许应该使用 setAnimation 播放初始动画 - if (bgSpine.spineData.animations.length > 0) { - // 播放首个动画 - bgSpine.state.setAnimation(0, bgSpine.spineData.animations[0].name, true); - } - const targetScale = Math.max(scaleX, scaleY); - const bgSprite = new PIXI.Sprite(); - bgSprite.addChild(bgSpine); - bgSprite.scale.x = targetScale; - bgSprite.scale.y = targetScale; - bgSprite.anchor.set(0.5); - bgSprite.position.y = this.stageHeight / 2; - thisBgContainer.setBaseX(this.stageWidth / 2); - thisBgContainer.setBaseY(this.stageHeight / 2); - thisBgContainer.pivot.set(0, this.stageHeight / 2); - - // 挂载 - thisBgContainer.addChild(bgSprite); - this.notifyTargetReferenceBoxChanged(key); + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + if (spineResource && this.getStageObjByUuid(bgUuid)) { + const bgSpine = new Spine(spineResource.spineData); + const transY = spineResource?.spineData?.y ?? 0; + /** + * 重设大小 + */ + const originalWidth = bgSpine.width; // TODO: 视图大小可能小于画布大小,应提供参数指定视图大小 + const originalHeight = bgSpine.height; // TODO: 视图大小可能小于画布大小,应提供参数指定视图大小 + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + logger.debug('bgSpine state', bgSpine.state); + // TODO: 也许应该使用 setAnimation 播放初始动画 + if (bgSpine.spineData.animations.length > 0) { + // 播放首个动画 + bgSpine.state.setAnimation(0, bgSpine.spineData.animations[0].name, true); } - }, 0); + const targetScale = Math.max(scaleX, scaleY); + const bgSprite = new PIXI.Sprite(); + bgSprite.addChild(bgSpine); + bgSprite.scale.x = targetScale; + bgSprite.scale.y = targetScale; + bgSprite.anchor.set(0.5); + bgSprite.position.y = this.stageHeight / 2; + thisBgContainer.setBaseX(this.stageWidth / 2); + thisBgContainer.setBaseY(this.stageHeight / 2); + thisBgContainer.pivot.set(0, this.stageHeight / 2); + + // 挂载 + thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); + } }; /** diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index de5d59d3c..685c1e0f4 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -229,6 +229,7 @@ function removeBg(bgObject: IStageObject, skipAnimation: boolean): number { pixiStage.removeStageObjectByKey(oldBgKey); const { duration, animation } = getExitAnimation('bg-main-off', true, bgKey); pixiStage.registerAnimation(animation, bgAniKey, bgKey); + // 保留退出对象直到退场时长结束;同步销毁会让退场动画不可见。 setTimeout(() => { pixiStage.removeAnimation(bgAniKey); pixiStage.removeStageObjectByKey(bgKey); @@ -256,6 +257,7 @@ function removeFig(figObj: IStageObject, enterTikerKey: string, skipAnimation: b // 退出对象的 key 带时间戳,永远不在 effects 白名单里,因此与背景一样走普通动画通道即可 const { duration, animation } = getExitAnimation(figLeaveAniKey, false, figKey); pixiStage.registerAnimation(animation, leaveKey, figKey); + // 保留退出对象直到退场时长结束;同步销毁会让退场动画不可见。 setTimeout(() => { pixiStage.removeAnimation(leaveKey); pixiStage.removeStageObjectByKey(figKey); diff --git a/packages/webgal/src/Core/controller/storage/storageController.ts b/packages/webgal/src/Core/controller/storage/storageController.ts index 962e73d45..e35d593ce 100644 --- a/packages/webgal/src/Core/controller/storage/storageController.ts +++ b/packages/webgal/src/Core/controller/storage/storageController.ts @@ -49,6 +49,7 @@ function debounce(func: (...args: T[]) => K, wait: number) { function context(...args: T[]): K { clearTimeout(timeout); let ret!: K; + // 等连续调用停止 wait 毫秒后只执行最后一次,合并频繁的存储写入。 timeout = setTimeout(() => { ret = func.apply(context, args); }, wait); diff --git a/packages/webgal/src/Core/gameScripts/end.ts b/packages/webgal/src/Core/gameScripts/end.ts index b9d1ebf0d..eab20f9fb 100644 --- a/packages/webgal/src/Core/gameScripts/end.ts +++ b/packages/webgal/src/Core/gameScripts/end.ts @@ -20,7 +20,8 @@ export const end = (sentence: ISentence): IPerform => { const dispatch = webgalStore.dispatch; // 重新获取初始场景 const sceneUrl: string = assetSetter('start.txt', fileType.scene); - // 为了在 scriptExecutor 自增 sentenceId 后再重置场景 + // scriptExecutor 会在命令返回后自增 sentenceId,必须在此之后重置,避免重置后又变为 1。 + // 移除延时需要先让执行器显式处理结束流程,与 Pixi 是否同步无关。 setTimeout(() => { WebGAL.sceneManager.resetScene(); }, 5); diff --git a/packages/webgal/src/Core/gameScripts/intro.tsx b/packages/webgal/src/Core/gameScripts/intro.tsx index 99a7a37a5..f131d5bce 100644 --- a/packages/webgal/src/Core/gameScripts/intro.tsx +++ b/packages/webgal/src/Core/gameScripts/intro.tsx @@ -82,6 +82,7 @@ export const intro = (sentence: ISentence): IPerform => { // 由于用户操作,相当于时间向前推进,这时候更新这个演出的预计完成时间 baseDuration -= delayTime; if (setBlockingStateTimeout) clearTimeout(setBlockingStateTimeout); + // 按用户推进后的剩余展示时长解除阻塞,不能立即跳过尚未展示的内容。 setBlockingStateTimeout = setTimeout(() => { isBlocking = false; }, baseDuration); @@ -132,6 +133,7 @@ export const intro = (sentence: ISentence): IPerform => { if (timeout) clearTimeout(timeout); // 如果 Hold 了,自然不要自动结束 if (!isHold) { + // 用户提前推进后,按缩短的剩余时长结束非 hold 演出。 timeout = setTimeout(() => { WebGAL.gameplay.performController.unmountPerform(performName); }, baseDuration); @@ -167,6 +169,7 @@ export const intro = (sentence: ISentence): IPerform => { isHoldOn: false, startFunction: () => { isBlocking = true; + // 等文字按 animationDelay 展示完并经过结尾等待后,才允许继续推进。 setBlockingStateTimeout = setTimeout(() => { isBlocking = false; }, baseDuration); diff --git a/packages/webgal/src/Core/gameScripts/playVideo.tsx b/packages/webgal/src/Core/gameScripts/playVideo.tsx index bb93f1d0a..083521c4f 100644 --- a/packages/webgal/src/Core/gameScripts/playVideo.tsx +++ b/packages/webgal/src/Core/gameScripts/playVideo.tsx @@ -21,6 +21,7 @@ export const playVideo = (sentence: ISentence): IPerform => { let isOver = false; let skipVideo = () => {}; const restoreVolumeAndUnmount = () => { + isOver = true; WebGAL.events.fullscreenDbClick.off(skipVideo); /** * 恢复音量 @@ -55,35 +56,34 @@ export const playVideo = (sentence: ISentence): IPerform => {