diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 6b9e93dba..2f0864333 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -38,7 +38,7 @@ import { resetStage } from '@/Core/controller/stage/resetStage'; 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 type { IStageState } from '@/Core/Modules/stage/stageInterface'; import { applyStageEffectToTarget } from '@/Core/controller/stage/pixi/syncPixiStageState'; import { mergeSetEffectPreviewTransform } from './previewSetEffectTransform'; import { requestEmbeddedLaunchId } from './runtime/embeddedPreviewBootstrap'; @@ -102,7 +102,6 @@ export const startPreviewSyncRuntime = () => { let lastPublishedSceneName: string | null = null; let lastPublishedSentenceId: number | null = null; let lastPublishedStageState: StageStateSnapshot | null = null; - const setEffectBaselines = new Map(); const targetTransformBaselines = createTargetTransformBaselineManager(); const embeddedLaunchIdPromise = requestEmbeddedLaunchId(); let transport!: PreviewSyncTransport; @@ -134,10 +133,9 @@ export const startPreviewSyncRuntime = () => { lastPublishedSceneName = null; lastPublishedSentenceId = null; lastPublishedStageState = null; - setEffectBaselines.clear(); activeSyncSceneRevision = null; WebGAL.gameplay.isFastPreview = false; - targetTransformBaselines.invalidateCurrentRevision(); + targetTransformBaselines.invalidateBaselines(); }; const startSyncSceneTransaction = () => { @@ -257,13 +255,14 @@ export const startPreviewSyncRuntime = () => { const handleSyncScene = (payload: SyncScenePayload) => { const syncSceneRevision = startSyncSceneTransaction(); const isLatestSyncScene = () => isActiveSyncSceneRevision(syncSceneRevision); - setEffectBaselines.clear(); const { transformBaselineRevision } = payload; - if (transformBaselineRevision) { - targetTransformBaselines.acceptRevision(transformBaselineRevision); - } else { - targetTransformBaselines.invalidateCurrentRevision(); - } + targetTransformBaselines.applySyncSceneTarget( + { + sceneName: payload.sceneName, + sentenceId: payload.sentenceId, + }, + transformBaselineRevision, + ); executePreviewSyncSceneCommand(payload, { onFastPreviewTimeout: emitFastPreviewTimeout, @@ -280,6 +279,7 @@ export const startPreviewSyncRuntime = () => { transformBaselineRevision, stageStateManager.getCalculationStageState(), ); + targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision); }, onSettled: (result) => { if (!finishSyncSceneTransaction(syncSceneRevision)) { @@ -298,14 +298,19 @@ export const startPreviewSyncRuntime = () => { return; } - const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload); + const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload.sceneName); + if (!isSyncSettled) { + logger.warn( + `revision-bound sync 未停在目标停点,失效基线: scene=${result.sceneName}, sentence=${result.sentenceId}, reason=${result.stopReason}`, + ); + } + if (!isSyncSettled || !targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision)) { targetTransformBaselines.failRevision(transformBaselineRevision); publishSettledStageSnapshot(); return; } - setEffectBaselines.clear(); publishSettledStageSnapshot(); }, }); @@ -313,8 +318,7 @@ export const startPreviewSyncRuntime = () => { const handleRunSnippet = (payload: RunSnippetPayload) => { cancelActiveSyncScene(); - setEffectBaselines.clear(); - targetTransformBaselines.invalidateCurrentRevision(); + targetTransformBaselines.invalidateBaselines(); applyPreviewDebugVariables(payload.debugVariables); const scene = WebgalParser.parse(payload.snippet, 'temp.txt', 'temp.txt'); (scene.sentenceList as unknown as ISentence[]).forEach((sentence) => { @@ -348,8 +352,7 @@ export const startPreviewSyncRuntime = () => { const handleRunSceneContent = (payload: RunSceneContentPayload) => { cancelActiveSyncScene(); - setEffectBaselines.clear(); - targetTransformBaselines.invalidateCurrentRevision(); + targetTransformBaselines.invalidateBaselines(); resetStage(true); applyPreviewDebugVariables(payload.debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(payload.sceneContent, 'temp', './temp.txt'); @@ -379,20 +382,11 @@ export const startPreviewSyncRuntime = () => { setDebugTextReadMode(payload.isRead); }; - const getSetEffectBaseline = (target: string): ITransform => { - const cachedBaseline = setEffectBaselines.get(target); - if (cachedBaseline) { - return cachedBaseline; - } - - const baselineOverride = targetTransformBaselines.getReadyTransformBaselineOverride(target); - const baseline = mergeSetEffectPreviewTransform(baseTransform, baselineOverride); - setEffectBaselines.set(target, baseline); - return baseline; - }; - const handleSetEffect = (payload: SetEffectPayload) => { - const baseline = getSetEffectBaseline(payload.target); + // 基线每次现算:snapshot 不可用时只回退 base default(fail closed),不做第二份缓存, + // 否则缓存会与 snapshot 各自拥有一套有效期,重新引入「基线已失效但预览仍用旧值」的问题。 + const baselineOverride = targetTransformBaselines.getReadyTransformBaselineOverride(payload.target); + const baseline = mergeSetEffectPreviewTransform(baseTransform, baselineOverride); const newTransform = mergeSetEffectPreviewTransform(baseline, payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); if (payload.phase === 'preview') { diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewStopPoint.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewStopPoint.ts new file mode 100644 index 000000000..6049aa3b7 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewStopPoint.ts @@ -0,0 +1,41 @@ +import type { FastPreviewStopReason } from './previewSyncSceneCommand'; + +/** + * `resolveStopSentenceId` 只依赖语句列表的续行占位标记。 + */ +interface StopSentenceCandidate { + isLineBreakHolder: boolean; +} + +/** + * 把停止指针从多行语句的续行占位上回退到该语句首行之后。 + * + * 续行占位是带 -next 的注释,一次 forward 会沿着 -next 一路穿过它们, + * 顺带把下一条真实语句也执行掉。而编辑器不论把指针落在续行的哪一行, + * 想要的都是「执行完这条多行语句就停下」,即停在它首行之后。 + */ +export function resolveStopSentenceId(sentenceList: readonly StopSentenceCandidate[], sentenceId: number): number { + let stopSentenceId = sentenceId; + while (stopSentenceId > 0 && sentenceList[stopSentenceId - 1]?.isLineBreakHolder) { + stopSentenceId--; + } + + return stopSentenceId; +} + +/** + * replay 是否停在解析后的目标停点上。 + * + * 不能用 `currentSentenceId === payload.sentenceId`:payload 的指针可能落在多行语句的续行上,而停点已经被 + * `resolveStopSentenceId` 归一到语句首行之后。也不能用严格相等比较停点:一次 forward 会执行完整个 `-next` + * 链,合法地越过停点。因此判定是「同场景 + 指针不早于停点」。 + */ +export function didFastPreviewStopAtTarget( + stopReason: FastPreviewStopReason, + sceneName: string, + sentenceId: number, + currentSceneName: string, + stopSentenceId: number, +): boolean { + return stopReason === 'target-reached' && sceneName === currentSceneName && sentenceId >= stopSentenceId; +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index b5d77b952..48256f5cd 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -7,10 +7,10 @@ import { commitForward, forward } from '@/Core/controller/gamePlay/nextSentence' import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; import { sceneParser } from '@/Core/parser/sceneParser'; import { logger } from '@/Core/util/logger'; -import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; import type { FastPreviewTimeoutPayload, SyncScenePayload, SyncSceneSettleMode } from '@/types/editorPreviewProtocol'; import { applyPreviewDebugVariables } from './previewDebugVariables'; +import { didFastPreviewStopAtTarget, resolveStopSentenceId } from './previewStopPoint'; export const FAST_PREVIEW_MAX_DURATION_MS = 500; const FAST_PREVIEW_TIMEOUT_CHECK_INTERVAL = 100; @@ -20,6 +20,14 @@ export type FastPreviewTimeoutEmitter = (payload: FastPreviewTimeoutPayload) => export interface FastPreviewResult { sceneName: string; sentenceId: number; + /** + * 是否停在解析后的目标停点上(同场景且指针不早于停点)。 + * + * 不能拿 `sentenceId` 与 payload 指针比较:编辑器指针可能落在多行语句的续行上,停点会被归一到语句首行 + * 之后;一次 forward 还会执行完整个 `-next` 链而合法越过停点。判定实现在 `didFastPreviewStopAtTarget`, + * settle 成功条件见 `isTargetTransformBaselineSyncSettled`。 + */ + stoppedAtTarget: boolean; isTimedOut: boolean; stopReason: FastPreviewStopReason; } @@ -109,7 +117,7 @@ export async function runFastPreview( options: RunFastPreviewOptions = {}, ): Promise { const isLatest = () => options.isLatest?.() ?? true; - const targetSentenceId = resolveStopSentenceId(sentenceId); + const targetSentenceId = resolveStopSentenceId(WebGAL.sceneManager.sceneData.currentScene.sentenceList, sentenceId); const fastPreviewStartTime = performance.now(); const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; stopFast(); @@ -232,31 +240,23 @@ export async function runFastPreview( } logger.info(`实时预览快进完成:快进 ${forwardedLineCount} 行,用时 ${fastPreviewElapsedMs}ms`); + const settledSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; + const settledSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; return { - sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, - sentenceId: WebGAL.sceneManager.sceneData.currentSentenceId, + sceneName: settledSceneName, + sentenceId: settledSentenceId, + stoppedAtTarget: didFastPreviewStopAtTarget( + stopReason, + settledSceneName, + settledSentenceId, + currentSceneName, + targetSentenceId, + ), isTimedOut, stopReason, }; } -/** - * 把停止指针从多行语句的续行占位上回退到该语句首行之后。 - * - * 续行占位是带 -next 的注释,一次 forward 会沿着 -next 一路穿过它们, - * 顺带把下一条真实语句也执行掉。而编辑器不论把指针落在续行的哪一行, - * 想要的都是「执行完这条多行语句就停下」,即停在它首行之后。 - */ -function resolveStopSentenceId(sentenceId: number): number { - const sentenceList: ISentence[] = WebGAL.sceneManager.sceneData.currentScene.sentenceList; - let stopSentenceId = sentenceId; - while (stopSentenceId > 0 && sentenceList[stopSentenceId - 1]?.isLineBreakHolder) { - stopSentenceId--; - } - - return stopSentenceId; -} - function shouldContinueFastPreview(sentenceId: number, currentSceneName: string, baseSceneStackDepth: number): boolean { const sceneData = WebGAL.sceneManager.sceneData; if (sceneData.currentScene.sceneName === currentSceneName) { diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index 219e84909..f24bfa8ec 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -37,11 +37,14 @@ interface TargetTransformBaselineSyncTarget { } interface TargetTransformBaselineManager { - acceptRevision: (revision: string) => void; + /** + * 登记一次 `sync-scene`:编辑指针变化时失效当前 baseline,携带 token 时接受新 attempt。 + */ + applySyncSceneTarget: (target: TargetTransformBaselineSyncTarget, revision: string | undefined) => void; captureSnapshot: (revision: string, stageState: IStageState) => void; publishCapturedSnapshot: (revision: string) => boolean; failRevision: (revision: string) => void; - invalidateCurrentRevision: () => void; + invalidateBaselines: () => void; getReadyTransformBaselineOverride: (target: string) => ITransform | undefined; queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; } @@ -50,21 +53,20 @@ export function cloneBaseTransform(): ITransform { return cloneDeep(baseTransform); } -export function isTargetTransformBaselineSyncSettled( - result: FastPreviewResult | null, - target: TargetTransformBaselineSyncTarget, -): boolean { - return ( - result !== null && - !result.isTimedOut && - result.stopReason === 'target-reached' && - result.sceneName === target.sceneName && - result.sentenceId === target.sentenceId - ); +/** + * revision-bound sync 的 settle 成功条件:replay 精确停在目标停点上。 + * + * 不能用 `result.sentenceId === payload.sentenceId` 判定:`payload.sentenceId` 是编辑器指针(可能落在多行 + * 语句的续行上),而 replay 的停点是 `resolveStopSentenceId` 解析后的首行之后,`-next` 链还会一次性越过它。 + * 停点判定已由 `runFastPreview` 写进 `result.stoppedAtTarget`,这里只需再用 payload 的 sceneName 确认场景。 + */ +export function isTargetTransformBaselineSyncSettled(result: FastPreviewResult | null, sceneName: string): boolean { + return result !== null && !result.isTimedOut && result.stoppedAtTarget && result.sceneName === sceneName; } export function createTargetTransformBaselineManager(): TargetTransformBaselineManager { let revisionState: BaselineRevisionState = { status: 'none' }; + let lastSyncTarget: TargetTransformBaselineSyncTarget | null = null; const isLatestRevision = (revision: string) => { return revisionState.status !== 'none' && revisionState.revision === revision; @@ -74,8 +76,61 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM return revisionState.status === 'pending' && revisionState.revision === revision; }; + // 用 payload 的原始 sentenceId 比较编辑指针,不解析续行占位:同一多行语句的不同续行会被判成指针变化, + // 方向是 fail closed(多一次失效,不会沿用错基线)。 + const isSameSyncTarget = (target: TargetTransformBaselineSyncTarget) => { + return ( + lastSyncTarget !== null && + lastSyncTarget.sceneName === target.sceneName && + lastSyncTarget.sentenceId === target.sentenceId + ); + }; + + /** + * 失效 baseline 的全部来源:编辑指针、token 与已发布 snapshot。 + */ + const invalidateBaselines = () => { + lastSyncTarget = null; + if (revisionState.status === 'none') { + return; + } + + revisionState = { + status: 'unavailable', + revision: revisionState.revision, + }; + }; + return { - acceptRevision(revision) { + applySyncSceneTarget(target, revision) { + // snapshot 与 set-effect 基线的语义边界是编辑指针,而不是「最近一次携带 token 的 sync」: + // 同一指针上的重复 sync(保存场景、自动保存、预览刷新)重放的是同一段脚本,重放终点前的 + // 计算态与已发布 snapshot 同源,因此不失效;指针变化时旧 snapshot 已不代表目标语句前状态。 + // 前提是本次 sync 重放同一段脚本(调用方义务):保存若带进指针之前的编辑,runtime 只按 + // (sceneName, sentenceId) 无法识别,必须由客户端在保存落地后重建 baseline。 + if (!isSameSyncTarget(target)) { + invalidateBaselines(); + } + + lastSyncTarget = { + sceneName: target.sceneName, + sentenceId: target.sentenceId, + }; + + if (!revision) { + // 当前 token 仍是 pending 就说明那次 sync 还在途中(settle 回调一定会先结算 token 再清在途标记), + // 无 token 的本次 sync 已经让它的 snapshot 采样点不可达:不能让 token 永久停在 pending, + // 也不能让 query 继续等到一个不会到来的结果。已 ready 的 snapshot 不受影响。 + if (revisionState.status === 'pending') { + revisionState = { + status: 'unavailable', + revision: revisionState.revision, + }; + } + + return; + } + revisionState = { status: 'pending', revision, @@ -95,7 +150,17 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }, publishCapturedSnapshot(revision) { - if (revisionState.status !== 'pending' || revisionState.revision !== revision || !revisionState.snapshot) { + if (!isLatestRevision(revision)) { + return false; + } + + // baseline 已经由本次同 token attempt 在目标语句执行前发布:settle 时的再次 publish + // 只是确认该 attempt 成功,不能因此判失败。 + if (revisionState.status === 'ready') { + return true; + } + + if (revisionState.status !== 'pending' || !revisionState.snapshot) { return false; } @@ -118,16 +183,7 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, - invalidateCurrentRevision() { - if (revisionState.status === 'none') { - return; - } - - revisionState = { - status: 'unavailable', - revision: revisionState.revision, - }; - }, + invalidateBaselines, getReadyTransformBaselineOverride(target) { if (revisionState.status !== 'ready') {