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
52 changes: 23 additions & 29 deletions packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, ITransform>();
const targetTransformBaselines = createTargetTransformBaselineManager();
const embeddedLaunchIdPromise = requestEmbeddedLaunchId();
let transport!: PreviewSyncTransport;
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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,
Expand All @@ -280,6 +279,7 @@ export const startPreviewSyncRuntime = () => {
transformBaselineRevision,
stageStateManager.getCalculationStageState(),
);
targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision);
},
onSettled: (result) => {
if (!finishSyncSceneTransaction(syncSceneRevision)) {
Expand All @@ -298,23 +298,27 @@ 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();
},
});
};

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) => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -109,7 +117,7 @@ export async function runFastPreview(
options: RunFastPreviewOptions = {},
): Promise<FastPreviewResult | null> {
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();
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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;
}

Expand All @@ -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') {
Expand Down
Loading