Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 43 additions & 12 deletions packages/webgal/src/Core/Modules/perform/performController.ts
Original file line number Diff line number Diff line change
@@ -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';
Comment on lines 1 to 6

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

为了解决多个并行演出(parallel performs)在同一帧/同一 Tick 结束或被卸载时,多次触发 goNextWhenOver 导致游戏意外跳过多个句子的潜在 Bug,建议对 goNextWhenOver 进行防抖(debounce)处理。

我们可以在文件顶部声明一个 goNextOverTimeoutMap,用于存储每个 PerformController 实例的定时器。

import { IPerform } from '@/Core/Modules/perform/performInterface';
import { ISentence } from '@/Core/controller/scene/sceneInterface';
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';

const goNextOverTimeoutMap = new WeakMap<any, ReturnType<typeof setTimeout>>();

Expand Down Expand Up @@ -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];
Expand All @@ -136,15 +158,21 @@ export class PerformController {
}
}
stageStateManager.commit();
if (isGoNext) {
nextSentence();
if (isGoNext && goNextWhenOver) {
continueSentence();
}
}

public clearNonHoldPerformsFromStageState() {
stageStateManager.clearUncommittedNonHoldPerforms();
}

/**
* 启动一个已提交的 perform。
*
* startFunction 只会在 commitPendingPerforms 之后运行,因此可以依赖已提交的 stage state。
* 这里同时把脚本层的 -continue 转成 perform.goNextWhenOver。
*/
private startPerform(perform: IPerform, script: ISentence) {
perform.isStarted = true;
perform.startFunction?.();
Expand Down Expand Up @@ -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);
Expand All @@ -212,7 +239,6 @@ export class PerformController {
this.performList.splice(i, 1);
i--;
if (e.goNextWhenOver) {
// nextSentence();
this.goNextWhenOver();
}
/**
Expand Down Expand Up @@ -259,16 +285,15 @@ export class PerformController {
this.clearPerformTimeout(perform);
/**
* 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前
* 因为 goNextWhenOver 会调用 nextSentence,而 nextSentence 会清除目前未结束的演出
* 那么 nextSentence 函数就会删除这个演出,但是此时,在这个上下文,i 已经被确定了
* 因为 goNextWhenOver 会触发继续推进,而继续推进会清除目前未结束的演出
* 那么继续推进就会删除这个演出,但是此时,在这个上下文,i 已经被确定了
* 所以 goNextWhenOver 后的代码会多删东西,解决方法就是在调用 goNextWhenOver 前先删掉这个演出对象
* 此问题对所有 goNextWhenOver 属性为真的演出都有影响,但只有 2 个演出有此问题
*/
this.performList.splice(idx, 1);
this.erasePerformFromState(perform.performName);
stageStateManager.commit();
if (perform.goNextWhenOver) {
// nextSentence();
this.goNextWhenOver();
}
}
Expand Down Expand Up @@ -300,6 +325,12 @@ export class PerformController {
perform.isStarted = false;
}

/**
* perform 结束后的内部继续推进。
*
* goNextWhenOver 不等于无条件跳下一句;它仍然必须等待所有 blockingNext 演出结束。
* 等待完成后使用 continueSentence,避免触发 userInteractNext,也避免把自然结束误当成用户点击。
*/
private goNextWhenOver = () => {
let isBlockingNext = false;
this.performList?.forEach((e) => {
Comment on lines 334 to 336

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

在这里,我们通过 goNextOverTimeoutMap 获取并清除已有的定时器,然后将执行逻辑包装在 setTimeout(..., 0) 中。这样在同一个 Tick 内多次调用 goNextWhenOver 时,只有最后一次会真正执行,从而完美避免了多重推进(double-stepping)的 Bug。

  private goNextWhenOver = () => {
    const existingTimeout = goNextOverTimeoutMap.get(this);
    if (existingTimeout) clearTimeout(existingTimeout);
    const timeout = setTimeout(() => {
      goNextOverTimeoutMap.delete(this);
      let isBlockingNext = false;
      this.performList?.forEach((e) => {

Expand All @@ -311,7 +342,7 @@ export class PerformController {
// 有阻塞,提前结束
setTimeout(this.goNextWhenOver, 100);
} else {
nextSentence();
continueSentence();
}
};
Comment on lines 342 to 347

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

配合顶部的防抖逻辑,在定时器回调中,如果检测到仍有阻塞的演出,则在 100ms 后重新调度;否则安全地调用 continueSentence()。最后将定时器存入 goNextOverTimeoutMap

      // 有阻塞,提前结束
      const retryTimeout = setTimeout(this.goNextWhenOver, 100);
      goNextOverTimeoutMap.set(this, retryTimeout);
    } else {
      continueSentence();
    }
  }, 0);
  goNextOverTimeoutMap.set(this, timeout);
};

}
10 changes: 5 additions & 5 deletions packages/webgal/src/Core/Modules/perform/performInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
53 changes: 47 additions & 6 deletions packages/webgal/src/Core/controller/gamePlay/nextSentence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -55,16 +68,44 @@ export const forward = () => {
};

/**
* 将演算状态提交到当前视图状态,并启动本序列收集到的演出。
* 将本轮 forward 的演算结果提交到视图,并启动 pending perform。
*
* 提交流程分三步:先提交 stage state,再启动 perform,最后应用 Pixi effects。
* 这个顺序保证 startFunction 看到的是已提交的视图状态。
*/
export const commitForward = () => {
stageStateManager.commit({ applyPixiEffects: false });
WebGAL.gameplay.performController.commitPendingPerforms();
stageStateManager.applyCommittedPixiEffects();
};

/**
* 内部继续推进。
*
* 供场景切换完成、perform 自然结束、输入控件提交等内核流程调用。
* 它不会触发 userInteractNext,因此不会把“内部自动继续”误判为用户点击。
* 如果当前只剩可提前结束的非 hold 演出,会先结算它们并继续执行下一条语句。
*/
export const continueSentence = () => {
const GUIState = webgalStore.getState().GUI;
if (GUIState.showTitle) {
return;
}

if (!preForward(true)) {
return;
}

forward();
commitForward();
Comment on lines +99 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

forward() 函数会返回一个布尔值,表示推进是否成功(如果被场景切换或阻塞演出拦截,会返回 false)。
为了防御性编程,建议在调用 commitForward() 之前先检查 forward() 的返回值,避免在推进失败时错误地提交状态。

  if (forward()) {
    commitForward();
  }

};

/**
* 用户操作步进。
*
* 供点击、键盘、自动播放和快进等“外部下一步”入口调用。
* 它会触发 userInteractNext,让 intro 等演出先响应用户输入。
* 如果当前存在可提前结束的普通演出,本次用户推进只结束演出,不再继续执行下一条语句。
*/
export const nextSentence = () => {
WebGAL.events.userInteractNext.emit();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 }));
};
Expand Down
5 changes: 3 additions & 2 deletions packages/webgal/src/Core/controller/scene/callScene.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -43,7 +43,8 @@ export const callScene = (sceneUrl: string, sceneName: string) => {
WebGAL.sceneManager.sceneWritePromise = null;
}
if (shouldAutoNext) {
nextSentence();
// 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。
continueSentence();
}
});
WebGAL.sceneManager.sceneWritePromise = sceneWritePromise;
Expand Down
5 changes: 3 additions & 2 deletions packages/webgal/src/Core/controller/scene/changeScene.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,7 +37,8 @@ export const changeScene = (sceneUrl: string, sceneName: string) => {
WebGAL.sceneManager.sceneWritePromise = null;
}
if (shouldAutoNext) {
nextSentence();
// 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。
continueSentence();
}
});
WebGAL.sceneManager.sceneWritePromise = sceneWritePromise;
Expand Down
5 changes: 3 additions & 2 deletions packages/webgal/src/Core/controller/scene/restoreScene.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,7 +34,8 @@ export const restoreScene = (entry: ISceneEntry) => {
WebGAL.sceneManager.sceneWritePromise = null;
}
if (shouldAutoNext) {
nextSentence();
// 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。
continueSentence();
}
});
WebGAL.sceneManager.sceneWritePromise = sceneWritePromise;
Expand Down
4 changes: 2 additions & 2 deletions packages/webgal/src/Core/gameScripts/getUserInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -75,7 +75,7 @@ export const getUserInput = (sentence: ISentence): IPerform => {
}
playSeClick();
WebGAL.gameplay.performController.unmountPerform('userInput');
nextSentence();
continueSentence();
}}
className={styles.button}
>
Expand Down
4 changes: 2 additions & 2 deletions packages/webgal/src/Core/gameScripts/label/jmp.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -214,7 +214,7 @@ export const startPreviewSyncRuntime = () => {
showPanicOverlay: false,
});
setTimeout(() => {
nextSentence();
continueSentence();
}, 100);
};

Expand Down
Loading