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); };