diff --git a/packages/webgal/src/Core/Modules/stage/stageInterface.ts b/packages/webgal/src/Core/Modules/stage/stageInterface.ts index 4e8cd25f5..217362291 100644 --- a/packages/webgal/src/Core/Modules/stage/stageInterface.ts +++ b/packages/webgal/src/Core/Modules/stage/stageInterface.ts @@ -1,5 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { BlinkParam, FocusParam } from '@/Core/live2DCore'; +import type { Transform } from '@/types/editorPreviewProtocol'; /** * 游戏内变量 @@ -24,49 +25,7 @@ export interface IChooseItem { isSubScene: boolean; // 是否是子场景调用 } -export interface ITransform { - alpha?: number; - scale?: { - x?: number; - y?: number; - }; - // pivot: { - // x: number; - // y: number; - // }; - position?: { - x?: number; - y?: number; - }; - rotation?: number; - blur?: number; - brightness?: number; - contrast?: number; - saturation?: number; - gamma?: number; - colorRed?: number; - colorGreen?: number; - colorBlue?: number; - bevel?: number; - bevelThickness?: number; - bevelRotation?: number; - bevelSoftness?: number; - bevelRed?: number; - bevelGreen?: number; - bevelBlue?: number; - bloom?: number; - bloomBrightness?: number; - bloomBlur?: number; - bloomThreshold?: number; - oldFilm?: number; - dotFilm?: number; - reflectionFilm?: number; - glitchFilm?: number; - rgbFilm?: number; - godrayFilm?: number; - shockwaveFilter?: number; - radiusAlphaFilter?: number; -} +export type ITransform = Transform; /** * 基本效果接口 @@ -104,10 +63,6 @@ export const baseTransform: ITransform = { x: 1, y: 1, }, - // pivot: { - // x: 0.5, - // y: 0.5, - // }, position: { x: 0, y: 0, diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index 2ec78758a..7e68e1970 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -1,4 +1,4 @@ -import { scriptExecutor } from './scriptExecutor'; +import { scriptExecutor, type ScriptExecutionOptions } from './scriptExecutor'; import { logger } from '../../util/logger'; import { webgalStore } from '@/store/store'; @@ -45,7 +45,11 @@ export const preForward = (continueAfterSettling = false) => { * forward 只推进 calculationStageState,并把命令返回的 perform 收集到 pending 列表; * 它不会提交视图状态,也不会启动 perform。调用方必须在合适时机调用 commitForward。 */ -export const forward = () => { +export interface ForwardOptions { + scriptExecution?: ScriptExecutionOptions; +} + +export const forward = (options: ForwardOptions = {}) => { if (WebGAL.sceneManager.lockSceneWrite) { logger.warn('forward 被场景切换阻塞!'); return false; @@ -60,7 +64,7 @@ export const forward = () => { WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); WebGAL.gameplay.performController.beginCollectingPerforms(); try { - scriptExecutor(); + scriptExecutor(0, options.scriptExecution); } finally { WebGAL.gameplay.performController.endCollectingPerforms(); } diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 6a7e697c2..718e4a4e8 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -16,6 +16,15 @@ import { WEBGAL_NONE } from '@/Core/constants'; const MAX_FORWARD_SCRIPT_EXECUTION = 1000; +export interface ScriptExecutionContext { + sceneName: string; + sentenceId: number; +} + +export interface ScriptExecutionOptions { + beforeSentenceExecute?: (context: ScriptExecutionContext) => void; +} + export const whenChecker = (whenValue: string | undefined): boolean => { if (whenValue === undefined) { return true; @@ -40,7 +49,7 @@ export const whenChecker = (whenValue: string | undefined): boolean => { * 语句执行器 * 执行语句,同步场景状态,并根据情况立即执行下一句或者加入backlog */ -export const scriptExecutor = (depth = 0) => { +export const scriptExecutor = (depth = 0, options: ScriptExecutionOptions = {}) => { if (depth > MAX_FORWARD_SCRIPT_EXECUTION) { logger.error('forward 中执行的语句数量超过限制,可能存在 jumpLabel 或 -next 死循环'); return; @@ -60,8 +69,13 @@ export const scriptExecutor = (depth = 0) => { } return; } + const sentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; + options.beforeSentenceExecute?.({ + sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentenceId, + }); const currentScript: ISentence = cloneDeep( - WebGAL.sceneManager.sceneData.currentScene.sentenceList[WebGAL.sceneManager.sceneData.currentSentenceId], + WebGAL.sceneManager.sceneData.currentScene.sentenceList[sentenceId], ); const interpolationOneItem = (content: string): string => { @@ -106,7 +120,7 @@ export const scriptExecutor = (depth = 0) => { if (!runThis) { logger.warn('不满足条件,跳过本句!'); WebGAL.sceneManager.sceneData.currentSentenceId++; - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } @@ -117,7 +131,7 @@ export const scriptExecutor = (depth = 0) => { logger.warn(`未找到标签 ${currentScript.content},跳过 jumpLabel`); WebGAL.sceneManager.sceneData.currentSentenceId++; } - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } @@ -150,7 +164,7 @@ export const scriptExecutor = (depth = 0) => { if (isNext && !hasPendingBlockingStateCalculationPerform && !WebGAL.sceneManager.lockSceneWrite) { WebGAL.sceneManager.sceneData.currentSentenceId++; saveBacklogIfNeeded(); - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index fab9b1bfc..da395ffcd 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -8,12 +8,12 @@ import { SCREEN_CONSTANTS } from '@/Core/util/constants'; import { logger } from '@/Core/util/logger'; import { v4 as uuid } from 'uuid'; import { cloneDeep, isEqual } from 'lodash'; -import omitBy from 'lodash/omitBy'; -import isUndefined from 'lodash/isUndefined'; import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { queryStageObjectReferenceBox, type QueryTargetReferenceBoxResult } from './referenceBox'; +import { assignPixiTransform } from './stageEffectTransform'; export interface IAnimationObject { setStartState: Function; @@ -69,21 +69,7 @@ INSTALLED.push(GifResource); export default class PixiStage { public static assignTransform(target: T, source?: ITransform, convertAlpha = true) { - if (!source) return; - const targetScale = target.scale; - const targetPosition = target.position; - if (target.scale) Object.assign(targetScale!, omitBy(source.scale || {}, isUndefined)); - if (target.position) Object.assign(targetPosition!, omitBy(source.position || {}, isUndefined)); - Object.assign(target, omitBy(source, isUndefined)); - target.scale = targetScale; - target.position = targetPosition; - if (convertAlpha) { - const sourceAlpha = source.alpha; - if (sourceAlpha !== undefined) { - target.alpha = 1; - (target as any).alphaFilterVal = sourceAlpha; - } - } + assignPixiTransform(target, source, convertAlpha); } /** @@ -120,6 +106,7 @@ export default class PixiStage { private isRenderPending = false; // 更新 ticker 状态的防抖标记 private isTickerUpdatePending = false; + private referenceBoxWaiters = new Map void>>(); /** * 暂时没用上,以后可能用 @@ -467,6 +454,7 @@ export default class PixiStage { // 挂载 thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); this.requestRender(); } }, 0); @@ -551,6 +539,7 @@ export default class PixiStage { thisBgContainer.setBaseY(this.stageHeight / 2); thisBgContainer.pivot.set(0, this.stageHeight / 2); thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); }); } }, 0); @@ -609,7 +598,6 @@ export default class PixiStage { sourceType: sourceExt === 'gif' ? 'gif' : 'img', sourceExt, }); - // 完成图片加载后执行的函数 const setup = () => { // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! @@ -646,6 +634,7 @@ export default class PixiStage { } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); this.requestRender(); } }, 0); @@ -807,6 +796,7 @@ export default class PixiStage { Live2D.SoundManager.volume = 0; // @ts-ignore thisFigureContainer.addChild(model); + instance.notifyTargetReferenceBoxChanged(key); }); })(); } @@ -1030,6 +1020,36 @@ export default class PixiStage { return [...this.figureObjects, ...this.backgroundObjects, this.mainStageObject].find((e) => e.key === key); } + public queryTargetReferenceBox(target: string): QueryTargetReferenceBoxResult { + return queryStageObjectReferenceBox(target, this.getStageObjByKey(target), { + width: this.stageWidth, + height: this.stageHeight, + }); + } + + public waitForTargetReferenceBox(target: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const existingWaiters = this.referenceBoxWaiters.get(target); + const waiters = existingWaiters ?? new Set<() => void>(); + if (!existingWaiters) { + this.referenceBoxWaiters.set(target, waiters); + } + + let timeoutId = 0; + const resolveAndCleanup = () => { + window.clearTimeout(timeoutId); + waiters.delete(resolveAndCleanup); + if (waiters.size === 0) { + this.referenceBoxWaiters.delete(target); + } + resolve(); + }; + + timeoutId = window.setTimeout(resolveAndCleanup, timeoutMs); + waiters.add(resolveAndCleanup); + }); + } + public getStageObjByUuid(objUuid: string) { return [...this.figureObjects, ...this.backgroundObjects, this.mainStageObject].find((e) => e.uuid === objUuid); } @@ -1057,6 +1077,7 @@ export default class PixiStage { } bgSprite.pixiContainer = null; this.figureObjects.splice(indexFig, 1); + this.notifyTargetReferenceBoxChanged(key); } if (indexBg >= 0) { const bgSprite = this.backgroundObjects[indexBg]; @@ -1070,6 +1091,7 @@ export default class PixiStage { } bgSprite.pixiContainer = null; this.backgroundObjects.splice(indexBg, 1); + this.notifyTargetReferenceBoxChanged(key); } // /** // * 删掉相关 Effects,因为已经移除了 @@ -1142,6 +1164,17 @@ export default class PixiStage { } } + public notifyTargetReferenceBoxChanged(target: string): void { + const waiters = this.referenceBoxWaiters.get(target); + if (!waiters) { + return; + } + + for (const resolve of [...waiters]) { + resolve(); + } + } + private callLoader() { if (!this.assetLoader.loading) { const front = this.loadQueue.shift(); diff --git a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts index fc2fc6f14..0dead6a9f 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts @@ -328,6 +328,45 @@ const PROPERTY_CONFIGS: Record = { }, }; +function getChildReferenceBounds(child: PIXI.DisplayObject, containerPivot: PIXI.IPointData): PIXI.Rectangle { + child.transform.updateLocalTransform(); + const bounds = child.getLocalBounds(); + const { a, b, c, d, tx, ty } = child.transform.localTransform; + const x0 = bounds.x; + const x1 = bounds.x + bounds.width; + const y0 = bounds.y; + const y1 = bounds.y + bounds.height; + const pivotX = containerPivot.x; + const pivotY = containerPivot.y; + + const p0x = a * x0 + c * y0 + tx - pivotX; + const p0y = b * x0 + d * y0 + ty - pivotY; + const p1x = a * x1 + c * y0 + tx - pivotX; + const p1y = b * x1 + d * y0 + ty - pivotY; + const p2x = a * x0 + c * y1 + tx - pivotX; + const p2y = b * x0 + d * y1 + ty - pivotY; + const p3x = a * x1 + c * y1 + tx - pivotX; + const p3y = b * x1 + d * y1 + ty - pivotY; + + const minX = Math.min(p0x, p1x, p2x, p3x); + const minY = Math.min(p0y, p1y, p2y, p3y); + const maxX = Math.max(p0x, p1x, p2x, p3x); + const maxY = Math.max(p0y, p1y, p2y, p3y); + + return new PIXI.Rectangle(minX, minY, maxX - minX, maxY - minY); +} + +function mergeBounds(target: PIXI.Rectangle, next: PIXI.Rectangle): void { + const minX = Math.min(target.x, next.x); + const minY = Math.min(target.y, next.y); + const maxX = Math.max(target.x + target.width, next.x + next.width); + const maxY = Math.max(target.y + target.height, next.y + next.height); + target.x = minX; + target.y = minY; + target.width = maxX - minX; + target.height = maxY - minY; +} + export class WebGALPixiContainer extends PIXI.Container { public containerFilters = new Map(); private filterToName = new Map(); @@ -415,6 +454,33 @@ export class WebGALPixiContainer extends PIXI.Container { this.y = old; } + public getBasePosition(): { x: number; y: number } { + return { + x: this.baseX, + y: this.baseY, + }; + } + + public getReferenceLocalBounds(): PIXI.Rectangle | undefined { + if (this.children.length === 0) { + return undefined; + } + + let referenceBounds: PIXI.Rectangle | undefined; + for (const child of this.children) { + const localBounds = getChildReferenceBounds(child, this.pivot); + + if (!referenceBounds) { + referenceBounds = localBounds; + continue; + } + + mergeBounds(referenceBounds, localBounds); + } + + return referenceBounds; + } + // --- Standard Filters --- public get blur(): number { return this._getPropertyValue('blur'); diff --git a/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts b/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts new file mode 100644 index 000000000..d009fa9ac --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts @@ -0,0 +1,101 @@ +import type { ReferenceBox, ReferenceBoxQueryResultPayload } from '@/types/editorPreviewProtocol'; + +interface PointLike { + x: number; + y: number; +} + +interface SizeLike { + width: number; + height: number; +} + +interface BoundsLike extends SizeLike { + x: number; + y: number; +} + +interface ReferenceBoxContainer { + getBasePosition(): PointLike; + getReferenceLocalBounds(): BoundsLike | undefined; +} + +export interface ReferenceBoxStageObject { + pixiContainer: ReferenceBoxContainer | null; + sourceType: 'img' | 'live2d' | 'spine' | 'gif' | 'video' | 'stage'; +} + +export type QueryTargetReferenceBoxResult = ReferenceBoxQueryResultPayload; + +function createReferenceBox(origin: PointLike, localBounds: BoundsLike, stageSize: SizeLike): ReferenceBox { + const anchorX = localBounds.width === 0 ? 0.5 : -localBounds.x / localBounds.width; + const anchorY = localBounds.height === 0 ? 0.5 : -localBounds.y / localBounds.height; + + return { + originX: origin.x, + originY: origin.y, + width: localBounds.width, + height: localBounds.height, + anchorX, + anchorY, + stageWidth: stageSize.width, + stageHeight: stageSize.height, + }; +} + +function createStageFrameReferenceBox(stageSize: SizeLike): ReferenceBox { + return { + originX: stageSize.width / 2, + originY: stageSize.height / 2, + width: stageSize.width, + height: stageSize.height, + anchorX: 0.5, + anchorY: 0.5, + stageWidth: stageSize.width, + stageHeight: stageSize.height, + }; +} + +export function queryStageObjectReferenceBox( + target: string, + stageObject: ReferenceBoxStageObject | undefined, + stageSize: SizeLike, +): QueryTargetReferenceBoxResult { + if (!stageObject) { + return { + target, + status: 'missing', + }; + } + + if (stageObject.sourceType === 'stage') { + return { + target, + status: 'ready', + box: createStageFrameReferenceBox(stageSize), + }; + } + + if (!stageObject.pixiContainer) { + return { + target, + status: 'loading', + reason: 'Pixi 容器不可用', + }; + } + + const localBounds = stageObject.pixiContainer.getReferenceLocalBounds(); + if (!localBounds) { + return { + target, + status: 'loading', + reason: 'reference bounds 不可用', + }; + } + + return { + target, + status: 'ready', + box: createReferenceBox(stageObject.pixiContainer.getBasePosition(), localBounds, stageSize), + }; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/spine.ts b/packages/webgal/src/Core/controller/stage/pixi/spine.ts index f32eb3bb7..22cd07a2a 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/spine.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/spine.ts @@ -172,6 +172,7 @@ export async function addSpineFigureImpl( } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); } }, 0); }; @@ -264,6 +265,7 @@ export async function addSpineBgImpl(this: PixiStage, key: string, url: string) // 挂载 thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); } }, 0); }; diff --git a/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts b/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts new file mode 100644 index 000000000..8c7ab6aea --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts @@ -0,0 +1,45 @@ +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import type { ITransform } from '@/Core/Modules/stage/stageInterface'; +import { isUndefined, omitBy } from 'lodash'; +import type { WebGALPixiContainer } from './WebGALPixiContainer'; + +type PixiTransformPatch = ITransform & { + x?: number; + y?: number; + alphaFilterVal?: number; +}; + +export function assignPixiTransform( + target: T | undefined, + source?: PixiTransformPatch, + convertAlpha = true, +) { + if (!target || !source) return; + const targetScale = target.scale; + const targetPosition = target.position; + if (targetScale) Object.assign(targetScale, omitBy(source.scale || {}, isUndefined)); + if (targetPosition) Object.assign(targetPosition, omitBy(source.position || {}, isUndefined)); + Object.assign(target, omitBy(source, isUndefined)); + target.scale = targetScale; + target.position = targetPosition; + if (convertAlpha) { + const sourceAlpha = source.alpha; + if (sourceAlpha !== undefined) { + target.alpha = 1; + target.alphaFilterVal = sourceAlpha; + } + } +} + +function toPixiTransformPatch(transform: ITransform): PixiTransformPatch { + const { position, ...rest } = transform; + return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); +} + +export function applyTransformToPixiContainer( + container: WebGALPixiContainer | null | undefined, + transform?: ITransform, +) { + if (!container) return; + assignPixiTransform(container, toPixiTransformPatch(transform ?? baseTransform)); +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index 12786e9ba..f3cba8412 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -1,14 +1,12 @@ -import { baseTransform } from '@/Core/Modules/stage/stageInterface'; import type { IEffect, IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; import type { IResolvedStageCommitOptions } from '@/Core/Modules/stage/stageStateManager'; import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; import { WebGAL } from '@/Core/WebGAL'; -import PixiStage from '@/Core/controller/stage/pixi/PixiController'; import type { IStageObject } from '@/Core/controller/stage/pixi/PixiController'; import { getEnterExitAnimation } from '@/Core/Modules/animationFunctions'; import { logger } from '@/Core/util/logger'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; -import { isUndefined, omitBy } from 'lodash'; +import { applyTransformToPixiContainer } from '@/Core/controller/stage/pixi/stageEffectTransform'; export function syncPixiStageState(stageState: IStageState, options: IResolvedStageCommitOptions) { if (options.syncPixiStage) { @@ -31,15 +29,25 @@ export function applyStageEffects(effects: IEffect[]) { const key = stageObj.key; if (lockedStageTargets.includes(key)) continue; const effect = effects.find((effect) => effect.target === key); - const targetPixiContainer = pixiStage.getStageObjByKey(key); - const container = targetPixiContainer?.pixiContainer; + const container = stageObj.pixiContainer; if (!container) continue; - // @ts-ignore WebGALPixiContainer exposes transform-like fields. - PixiStage.assignTransform(container, convertTransform(effect?.transform ?? baseTransform)); + applyTransformToPixiContainer(container, effect?.transform); } pixiStage.requestRender(); } +export function applyStageEffectToTarget(target: string, transform: ITransform | undefined) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + if (pixiStage.getAllLockedObject().includes(target)) return; + + const container = pixiStage.getStageObjByKey(target)?.pixiContainer; + if (!container) return; + + applyTransformToPixiContainer(container, transform); + pixiStage.requestRender(); +} + function syncBg(stageState: IStageState) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; @@ -230,11 +238,3 @@ function addFigure(key: string, url: string, position: 'left' | 'center' | 'righ pixiStage.addFigure(key, url, position); } } - -function convertTransform(transform: ITransform | undefined) { - if (!transform) { - return {}; - } - const { position, ...rest } = transform; - return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); -} diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 0929e7c1f..a76ae090e 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -1,13 +1,23 @@ import { createEventEnvelope, createRequestEnvelope, + createRequestErrorEnvelope, createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isAnyProtocolEnvelope, + isPreviewQueryType, isPreviewRequestEnvelope, - isProtocolEnvelope, - PreviewRequestPayloadByType, +} from '@/types/editorPreviewProtocol'; +import type { + AnyProtocolEnvelope, + FastPreviewTimeoutPayload, + PreviewCommandPayloadByType, + PreviewCommandResponsePayloadByType, + PreviewCommandType, + PreviewQueryType, + PreviewRequestErrorCode, PreviewRequestType, - PreviewResponsePayloadByType, + RequestEnvelopeByType, RunSceneContentPayload, RunSnippetPayload, SetComponentVisibilityPayload, @@ -16,8 +26,7 @@ import { SetTextReadModePayload, StageSnapshotUpdatedPayload, SyncScenePayload, - FastPreviewTimeoutPayload, -} from '../../../types/editorPreviewProtocol'; +} from '@/types/editorPreviewProtocol'; import { webgalStore } from '@/store/store'; import { setFontOptimization, setVisibility } from '@/store/GUIReducer'; import { WebGAL } from '@/Core/WebGAL'; @@ -30,6 +39,7 @@ 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 { applyStageEffectToTarget } from '@/Core/controller/stage/pixi/syncPixiStageState'; import { mergeSetEffectPreviewTransform } from './previewSetEffectTransform'; import { requestEmbeddedLaunchId } from './runtime/embeddedPreviewBootstrap'; import { @@ -40,6 +50,12 @@ import { import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneCommand'; import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; +import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; +import { + cloneBaseTransform, + createTargetTransformBaselineManager, + isTargetTransformBaselineSyncSettled, +} from './runtime/targetTransformBaseline'; let previewSyncRuntimeStarted = false; type StageStateSnapshot = IStageState; @@ -50,6 +66,16 @@ interface RegisterPreviewLogContext { embeddedLaunchId: string | undefined; } +type PreviewRequestEnvelope = RequestEnvelopeByType; +type PreviewQueryEnvelope = RequestEnvelopeByType; +type RawRequestEnvelope = { + kind: 'request'; + type: string; + requestId: string; +}; + +const UNSUPPORTED_REQUEST_MESSAGE = '当前预览运行时不支持该请求类型'; + export const startPreviewSyncRuntime = () => { if (previewSyncRuntimeStarted) { return; @@ -77,11 +103,28 @@ export const startPreviewSyncRuntime = () => { let lastPublishedSentenceId: number | null = null; let lastPublishedStageState: StageStateSnapshot | null = null; const setEffectBaselines = new Map(); + const targetTransformBaselines = createTargetTransformBaselineManager(); const embeddedLaunchIdPromise = requestEmbeddedLaunchId(); let transport!: PreviewSyncTransport; const createRequestId = () => `req-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + + const isRawRequestEnvelope = (value: unknown): value is RawRequestEnvelope => + isRecord(value) && + value.kind === 'request' && + typeof value.type === 'string' && + typeof value.requestId === 'string'; + + const sendRequestError = ( + request: Pick, + code: PreviewRequestErrorCode, + message?: string, + ) => { + transport.send(createRequestErrorEnvelope(request.type, request.requestId, message ? { code, message } : { code })); + }; + const resetRegistrationState = () => { registered = false; pendingRegisterRequestId = null; @@ -90,6 +133,7 @@ export const startPreviewSyncRuntime = () => { lastPublishedSentenceId = null; lastPublishedStageState = null; setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); }; const buildStageStateSnapshot = (stageState: StageStateSnapshot): StageSnapshotUpdatedPayload['stageState'] => { @@ -157,6 +201,19 @@ export const startPreviewSyncRuntime = () => { ); }; + const finishRegisterPreview = () => { + const registeredPreviewContext = pendingRegisterContext; + if (registeredPreviewContext) { + logger.info('编辑器同步 V1 注册完成', registeredPreviewContext); + } + + pendingRegisterRequestId = null; + pendingRegisterContext = null; + registered = true; + publishReady(); + publishStageSnapshot(true); + }; + const emitFastPreviewTimeout = (payload: FastPreviewTimeoutPayload) => { if (!registered) { return; @@ -166,11 +223,44 @@ export const startPreviewSyncRuntime = () => { const handleSyncScene = (payload: SyncScenePayload) => { setEffectBaselines.clear(); - executePreviewSyncSceneCommand(payload, emitFastPreviewTimeout); + const { transformBaselineRevision } = payload; + if (transformBaselineRevision) { + targetTransformBaselines.acceptRevision(transformBaselineRevision); + } else { + targetTransformBaselines.invalidateCurrentRevision(); + } + + executePreviewSyncSceneCommand(payload, { + onFastPreviewTimeout: emitFastPreviewTimeout, + onBeforeTargetScriptExecute: () => { + if (!transformBaselineRevision) { + return; + } + + targetTransformBaselines.captureSnapshot( + transformBaselineRevision, + stageStateManager.getCalculationStageState(), + ); + }, + onSettled: (result) => { + if (!transformBaselineRevision) { + return; + } + + const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload); + if (!isSyncSettled || !targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision)) { + targetTransformBaselines.failRevision(transformBaselineRevision); + return; + } + + setEffectBaselines.clear(); + }, + }); }; const handleRunSnippet = (payload: RunSnippetPayload) => { setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); applyPreviewDebugVariables(payload.debugVariables); const scene = WebgalParser.parse(payload.snippet, 'temp.txt', 'temp.txt'); (scene.sentenceList as unknown as ISentence[]).forEach((sentence) => { @@ -204,6 +294,7 @@ export const startPreviewSyncRuntime = () => { const handleRunSceneContent = (payload: RunSceneContentPayload) => { setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); resetStage(true); applyPreviewDebugVariables(payload.debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(payload.sceneContent, 'temp', './temp.txt'); @@ -236,25 +327,29 @@ export const startPreviewSyncRuntime = () => { return cachedBaseline; } - const currentTransform = stageStateManager - .getCalculationStageState() - .effects.find((effect) => effect.target === target)?.transform; - const baseline = mergeSetEffectPreviewTransform(baseTransform, currentTransform); + const baselineOverride = targetTransformBaselines.getReadyTransformBaselineOverride(target); + const baseline = mergeSetEffectPreviewTransform(baseTransform, baselineOverride); setEffectBaselines.set(target, baseline); return baseline; }; const handleSetEffect = (payload: SetEffectPayload) => { - const newTransform = mergeSetEffectPreviewTransform(getSetEffectBaseline(payload.target), payload.transform); + const baseline = getSetEffectBaseline(payload.target); + const newTransform = mergeSetEffectPreviewTransform(baseline, payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); + if (payload.phase === 'preview') { + applyStageEffectToTarget(payload.target, newTransform); + return; + } + stageStateManager.updateEffectAndCommit({ target: payload.target, transform: newTransform, }); }; - const previewRequestHandlers: { - [K in PreviewRequestType]: (payload: PreviewRequestPayloadByType[K]) => PreviewResponsePayloadByType[K]; + const previewCommandHandlers: { + [K in PreviewCommandType]: (payload: PreviewCommandPayloadByType[K]) => PreviewCommandResponsePayloadByType[K]; } = { 'preview.command.sync-scene': (payload: SyncScenePayload) => { handleSyncScene(payload); @@ -290,73 +385,122 @@ export const startPreviewSyncRuntime = () => { }, }; - const handlePreviewRequest = ( + const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { + return isPreviewQueryType(envelope.type); + }; + + const handlePreviewQuery = (envelope: PreviewQueryEnvelope) => { + switch (envelope.type) { + case 'preview.query.reference-box': + void handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage) + .then((response) => { + transport.send(response); + }) + .catch((error) => { + logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + sendRequestError(envelope, 'internal-error', '预览运行时无法安全完成该请求'); + }); + return; + case 'preview.query.base-transform': + transport.send( + createResponseEnvelope('preview.query.base-transform', envelope.requestId, { + baseTransform: cloneBaseTransform(), + }), + ); + return; + case 'preview.query.transform-baseline': + transport.send( + createResponseEnvelope( + 'preview.query.transform-baseline', + envelope.requestId, + targetTransformBaselines.queryTransformBaseline( + envelope.payload.target, + envelope.payload.transformBaselineRevision, + ), + ), + ); + return; + } + }; + + const handlePreviewCommand = ( type: TType, - payload: PreviewRequestPayloadByType[TType], - ): PreviewResponsePayloadByType[TType] => { - const handler = previewRequestHandlers[type] as ( - nextPayload: PreviewRequestPayloadByType[TType], - ) => PreviewResponsePayloadByType[TType]; + payload: PreviewCommandPayloadByType[TType], + ): PreviewCommandResponsePayloadByType[TType] => { + const handler = previewCommandHandlers[type] as ( + nextPayload: PreviewCommandPayloadByType[TType], + ) => PreviewCommandResponsePayloadByType[TType]; return handler(payload); }; - transport = createPreviewSyncTransport({ - url: wsUrl, - subprotocol: EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, - onConnecting: resetRegistrationState, - onOpen: registerPreview, - onMessage: (rawData) => { - try { - const envelope = JSON.parse(String(rawData)) as unknown; - if (!isProtocolEnvelope(envelope)) { - logger.warn('收到无法识别的编辑器同步 V1 消息'); - return; - } + const respondToPreviewRequest = (envelope: PreviewRequestEnvelope) => { + if (isPreviewQueryEnvelope(envelope)) { + handlePreviewQuery(envelope); + return; + } - if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { - if (pendingRegisterRequestId === null || envelope.requestId !== pendingRegisterRequestId) { - return; - } - - if (pendingRegisterContext) { - logger.info('编辑器同步 V1 注册完成', pendingRegisterContext); - } - pendingRegisterRequestId = null; - pendingRegisterContext = null; - registered = true; - publishReady(); - publishStageSnapshot(true); - return; - } + const responsePayload = handlePreviewCommand(envelope.type, envelope.payload); + transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); + }; - if (!registered) { - if (envelope.kind === 'request') { - logger.warn(`收到注册完成前的编辑器同步 V1 请求:${envelope.type}`); - } - return; - } + const handleProtocolEnvelope = (envelope: AnyProtocolEnvelope) => { + if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { + if (pendingRegisterRequestId !== null && envelope.requestId === pendingRegisterRequestId) { + finishRegisterPreview(); + } + return; + } - if (!isPreviewRequestEnvelope(envelope)) { - if (envelope.kind === 'request') { - logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); - } - return; - } + if (!registered) { + if (envelope.kind === 'request') { + logger.warn(`收到注册完成前的编辑器同步 V1 请求:${envelope.type}`); + } + return; + } + + if (!isPreviewRequestEnvelope(envelope)) { + if (envelope.kind === 'request') { + logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'unsupported-request-type', UNSUPPORTED_REQUEST_MESSAGE); + } + return; + } - let responsePayload: PreviewResponsePayloadByType[typeof envelope.type]; - try { - responsePayload = handlePreviewRequest(envelope.type, envelope.payload); - } catch (error) { - logger.error(`执行编辑器同步 V1 命令失败:${envelope.type}`, error); + try { + respondToPreviewRequest(envelope); + } catch (error) { + logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + sendRequestError(envelope, 'internal-error', '预览运行时无法安全完成该请求'); + } + }; + + const handleRawMessage = (rawData: unknown) => { + try { + const envelope = JSON.parse(String(rawData)) as unknown; + if (!isAnyProtocolEnvelope(envelope)) { + if (isRawRequestEnvelope(envelope)) { + logger.warn(`收到非法的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'bad-request', '请求 envelope 格式不合法'); return; } - transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); - } catch (error) { - logger.error('解析编辑器同步 V1 消息失败', error); + logger.warn('收到无法识别的编辑器同步 V1 消息'); + return; } - }, + + handleProtocolEnvelope(envelope); + } catch (error) { + logger.error('解析编辑器同步 V1 消息失败', error); + } + }; + + transport = createPreviewSyncTransport({ + url: wsUrl, + subprotocol: EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + onConnecting: resetRegistrationState, + onOpen: registerPreview, + onMessage: handleRawMessage, onClose: resetRegistrationState, logInfo: (message) => logger.info(message), logError: (message, error) => logger.error(message, error), diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts new file mode 100644 index 000000000..5e0d74308 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -0,0 +1,36 @@ +import { createResponseEnvelope } from '@/types/editorPreviewProtocol'; +import type { + ReferenceBoxQueryResultPayload, + RequestEnvelopeByType, + ResponseEnvelopeByType, +} from '@/types/editorPreviewProtocol'; + +interface ReferenceBoxQueryStage { + queryTargetReferenceBox(target: string): ReferenceBoxQueryResultPayload; + waitForTargetReferenceBox(target: string, timeoutMs: number): Promise; +} + +const REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS = 300; + +export async function handleReferenceBoxQuery( + request: RequestEnvelopeByType<'preview.query.reference-box'>, + pixiStage: ReferenceBoxQueryStage | null | undefined, +): Promise> { + const { target } = request.payload; + + if (!pixiStage) { + return createResponseEnvelope('preview.query.reference-box', request.requestId, { + target, + status: 'unsupported', + reason: 'Pixi stage 不可用', + }); + } + + let result = pixiStage.queryTargetReferenceBox(target); + if (result.status === 'loading') { + await pixiStage.waitForTargetReferenceBox(target, REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS); + result = pixiStage.queryTargetReferenceBox(target); + } + + return createResponseEnvelope('preview.query.reference-box', request.requestId, result); +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts index aa8862705..0f42cdd8a 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts @@ -3,7 +3,7 @@ import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import type { IGameVar } from '@/Core/Modules/stage/stageInterface'; import { webgalStore } from '@/store/store'; import { setUserData } from '@/store/userDataReducer'; -import type { DebugVariablePayload } from '../../../../types/editorPreviewProtocol'; +import type { DebugVariable } from '@/types/editorPreviewProtocol'; let debugStageVarKeys = new Set(); let debugGlobalBackup: { globalGameVar: IGameVar; scriptManagedGlobalVar: string[] } | null = null; @@ -23,7 +23,7 @@ function clearPreviewDebugVariables() { debugGlobalBackup = null; } -export function applyPreviewDebugVariables(debugVariables: DebugVariablePayload[] = []) { +export function applyPreviewDebugVariables(debugVariables: DebugVariable[] = []) { clearPreviewDebugVariables(); if (debugVariables.some((item) => item.isGlobal)) { const userData = webgalStore.getState().userData; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index 36f8ae0e5..407273fb9 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -8,7 +8,7 @@ import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; import { sceneParser } from '@/Core/parser/sceneParser'; import { logger } from '@/Core/util/logger'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; -import { FastPreviewTimeoutPayload, SyncScenePayload } from '../../../../types/editorPreviewProtocol'; +import type { FastPreviewTimeoutPayload, SyncScenePayload, SyncSceneSettleMode } from '@/types/editorPreviewProtocol'; import { applyPreviewDebugVariables } from './previewDebugVariables'; export const FAST_PREVIEW_MAX_DURATION_MS = 500; @@ -16,9 +16,28 @@ const FAST_PREVIEW_TIMEOUT_CHECK_INTERVAL = 100; export type FastPreviewTimeoutEmitter = (payload: FastPreviewTimeoutPayload) => void; +export interface FastPreviewResult { + sceneName: string; + sentenceId: number; + isTimedOut: boolean; + stopReason: FastPreviewStopReason; +} + +export type FastPreviewStopReason = 'target-reached' | 'timeout' | 'state-calculation-blocked' | 'no-progress'; + +export interface PreviewSyncSceneCommandCallbacks { + onFastPreviewTimeout?: FastPreviewTimeoutEmitter; + onBeforeTargetScriptExecute?: () => void; + onSettled?: (result: FastPreviewResult | null) => void; +} + +interface RunFastPreviewOptions { + onBeforeTargetScriptExecute?: () => void; +} + export function executePreviewSyncSceneCommand( - { sceneName, sentenceId, debugVariables }: SyncScenePayload, - onFastPreviewTimeout?: FastPreviewTimeoutEmitter, + { sceneName, sentenceId, debugVariables, settleMode = 'normal' }: SyncScenePayload, + callbacks: PreviewSyncSceneCommandCallbacks = {}, ): void { logger.warn('正在跳转到' + sceneName + ':' + sentenceId); WebGAL.gameplay.isFastPreview = false; @@ -42,12 +61,22 @@ export function executePreviewSyncSceneCommand( applyPreviewDebugVariables(debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - void runFastPreview(sentenceId, currentSceneName, onFastPreviewTimeout); + void runFastPreview(sentenceId, currentSceneName, callbacks.onFastPreviewTimeout, settleMode, { + onBeforeTargetScriptExecute: callbacks.onBeforeTargetScriptExecute, + }) + .then((result) => { + callbacks.onSettled?.(result); + }) + .catch((error) => { + logger.error('实时预览跳转错误', error); + callbacks.onSettled?.(null); + }); }) .catch((error) => { stopFast(); WebGAL.gameplay.isFastPreview = false; logger.error('实时预览跳转错误', error); + callbacks.onSettled?.(null); }); } @@ -55,22 +84,52 @@ export async function runFastPreview( sentenceId: number, currentSceneName: string, onFastPreviewTimeout?: FastPreviewTimeoutEmitter, -): Promise { + settleMode: SyncSceneSettleMode = 'normal', + options: RunFastPreviewOptions = {}, +): Promise { const fastPreviewStartTime = performance.now(); const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; stopFast(); WebGAL.gameplay.isFastPreview = true; let forwardCount = 0; let isTimedOut = false; + let stopReason: FastPreviewStopReason = 'target-reached'; let timeoutElapsedMs = 0; let suspendedElapsedMs = 0; + let didRunBeforeTargetScriptExecute = false; + + const runBeforeTargetScriptExecute = (sceneName: string, nextSentenceId: number) => { + if ( + settleMode !== 'immediate' || + !options.onBeforeTargetScriptExecute || + didRunBeforeTargetScriptExecute || + sceneName !== currentSceneName || + nextSentenceId !== sentenceId - 1 + ) { + return; + } + + WebGAL.gameplay.performController.discardUncommittedNonHoldPerforms(true); + WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); + options.onBeforeTargetScriptExecute?.(); + didRunBeforeTargetScriptExecute = true; + }; try { while (shouldContinueFastPreview(sentenceId, currentSceneName, baseSceneStackDepth)) { const prevSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; const prevSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - const isForwarded = forward(); + const isForwarded = forward({ + scriptExecution: { + beforeSentenceExecute: ({ sceneName, sentenceId }) => { + // sync-scene 的 sentenceId 是目标语句执行后的停止指针,目标编辑语句是 sentenceId - 1。 + runBeforeTargetScriptExecute(sceneName, sentenceId); + }, + }, + }); forwardCount++; + const postForwardSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; + const postForwardSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; const sceneWriteWaitStart = performance.now(); const awaitedSceneWrite = await waitForPendingSceneWrite(); if (awaitedSceneWrite) { @@ -78,6 +137,7 @@ export async function runFastPreview( } if (!isForwarded && !awaitedSceneWrite) { + stopReason = 'no-progress'; break; } @@ -85,6 +145,7 @@ export async function runFastPreview( const elapsedMs = performance.now() - fastPreviewStartTime - suspendedElapsedMs; if (elapsedMs > FAST_PREVIEW_MAX_DURATION_MS) { isTimedOut = true; + stopReason = 'timeout'; timeoutElapsedMs = Math.round(elapsedMs); break; } @@ -92,15 +153,13 @@ export async function runFastPreview( if (WebGAL.gameplay.performController.hasPendingBlockingStateCalculationPerform()) { logger.warn('实时预览在需要外部输入的语句前停止演算'); + stopReason = 'state-calculation-blocked'; break; } - if ( - WebGAL.sceneManager.sceneData.currentSentenceId === prevSentenceId && - WebGAL.sceneManager.sceneData.currentScene.sceneName === prevSceneName && - !awaitedSceneWrite - ) { + if (postForwardSentenceId === prevSentenceId && postForwardSceneName === prevSceneName && !awaitedSceneWrite) { logger.warn('实时预览跳转停止:本次 forward 没有推进语句指针'); + stopReason = 'no-progress'; break; } } @@ -108,6 +167,11 @@ export async function runFastPreview( WebGAL.gameplay.isFastPreview = false; } + if (settleMode === 'immediate') { + WebGAL.gameplay.performController.discardUncommittedNonHoldPerforms(true); + WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); + } + commitForward(); const forwardedLineCount = @@ -132,6 +196,12 @@ export async function runFastPreview( } logger.info(`实时预览快进完成:快进 ${forwardedLineCount} 行,用时 ${fastPreviewElapsedMs}ms`); + return { + sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentenceId: WebGAL.sceneManager.sceneData.currentSentenceId, + isTimedOut, + stopReason, + }; } function shouldContinueFastPreview(sentenceId: number, currentSceneName: string, baseSceneStackDepth: number): boolean { diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts new file mode 100644 index 000000000..38729b821 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -0,0 +1,259 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { STAGE_KEYS } from '@/Core/constants'; +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import type { IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; +import type { TransformBaselineQueryResultPayload } from '@/types/editorPreviewProtocol'; +import type { FastPreviewResult } from './previewSyncSceneCommand'; + +const FIXED_TARGETS = new Set([ + STAGE_KEYS.STAGE_MAIN, + STAGE_KEYS.BGMAIN, + STAGE_KEYS.FIG_C, + STAGE_KEYS.FIG_L, + STAGE_KEYS.FIG_R, +]); + +type BaselineRevisionState = + | { + status: 'none'; + } + | { + status: 'pending'; + revision: string; + snapshot?: TargetTransformBaselineSnapshot; + } + | { + status: 'ready'; + revision: string; + snapshot: TargetTransformBaselineSnapshot; + } + | { + status: 'unavailable'; + revision: string; + }; + +interface TargetTransformBaselineSnapshot { + knownTargets: Set; + transformsByTarget: Map; +} + +interface TargetTransformBaselineSyncTarget { + sceneName: string; + sentenceId: number; +} + +interface TargetTransformBaselineManager { + acceptRevision: (revision: string) => void; + captureSnapshot: (revision: string, stageState: IStageState) => void; + publishCapturedSnapshot: (revision: string) => boolean; + failRevision: (revision: string) => void; + invalidateCurrentRevision: () => void; + getReadyTransformBaselineOverride: (target: string) => ITransform | undefined; + queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; +} + +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 + ); +} + +export function createTargetTransformBaselineManager(): TargetTransformBaselineManager { + let revisionState: BaselineRevisionState = { status: 'none' }; + + const isLatestRevision = (revision: string) => { + return revisionState.status !== 'none' && revisionState.revision === revision; + }; + + const isPendingRevision = (revision: string) => { + return revisionState.status === 'pending' && revisionState.revision === revision; + }; + + return { + acceptRevision(revision) { + revisionState = { + status: 'pending', + revision, + }; + }, + + captureSnapshot(revision, stageState) { + if (!isPendingRevision(revision)) { + return; + } + + revisionState = { + status: 'pending', + revision, + snapshot: createSnapshot(stageState), + }; + }, + + publishCapturedSnapshot(revision) { + if (revisionState.status !== 'pending' || revisionState.revision !== revision || !revisionState.snapshot) { + return false; + } + + revisionState = { + status: 'ready', + revision, + snapshot: revisionState.snapshot, + }; + return true; + }, + + failRevision(revision) { + if (!isLatestRevision(revision)) { + return; + } + + revisionState = { + status: 'unavailable', + revision, + }; + }, + + invalidateCurrentRevision() { + if (revisionState.status === 'none') { + return; + } + + revisionState = { + status: 'unavailable', + revision: revisionState.revision, + }; + }, + + getReadyTransformBaselineOverride(target) { + if (revisionState.status !== 'ready') { + return undefined; + } + + return getSnapshotTransformOverride(revisionState.snapshot, target); + }, + + queryTransformBaseline(target, revision) { + if (!isLatestRevision(revision)) { + return { + status: 'unavailable', + }; + } + + if (revisionState.status === 'pending') { + return { + status: 'loading', + }; + } + + if (revisionState.status !== 'ready') { + return { + status: 'unavailable', + }; + } + + return querySnapshot(revisionState.snapshot, target); + }, + }; +} + +function createSnapshot(stageState: IStageState): TargetTransformBaselineSnapshot { + const knownTargets = new Set(FIXED_TARGETS); + stageState.freeFigure.forEach((figure) => { + knownTargets.add(figure.key); + }); + + const transformsByTarget = new Map(); + stageState.effects.forEach((effect) => { + if (!knownTargets.has(effect.target) || !effect.transform) { + return; + } + + transformsByTarget.set(effect.target, cloneDeep(effect.transform)); + }); + + return { + knownTargets, + transformsByTarget, + }; +} + +function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TransformBaselineQueryResultPayload { + const transform = getSnapshotTransformOverride(snapshot, target); + if (transform === undefined) { + return { + status: 'unavailable', + }; + } + + return { + status: 'ready', + transform, + }; +} + +function getSnapshotTransformOverride(snapshot: TargetTransformBaselineSnapshot, target: string): ITransform | undefined { + if (!snapshot.knownTargets.has(target)) { + return undefined; + } + + const transform = snapshot.transformsByTarget.get(target); + return transform ? createSparseTransformOverride(transform, baseTransform) : {}; +} + +function createSparseTransformOverride(transform: ITransform, base: ITransform): ITransform { + const result: ITransform = {}; + + const position = createVectorOverride(transform.position, base.position); + if (position) { + result.position = position; + } + + const scale = createVectorOverride(transform.scale, base.scale); + if (scale) { + result.scale = scale; + } + + (Object.keys(transform) as Array).forEach((key) => { + if (key === 'position' || key === 'scale') { + return; + } + + const value = transform[key]; + if (value !== undefined && value !== base[key]) { + (result as Record)[key] = value; + } + }); + + return result; +} + +type TransformVector = NonNullable; + +function createVectorOverride( + transformValue: TransformVector | undefined, + baseValue: TransformVector | undefined, +): TransformVector | undefined { + if (!transformValue) { + return undefined; + } + + const vectorOverride: TransformVector = {}; + (Object.keys(transformValue) as Array).forEach((nestedKey) => { + if (transformValue[nestedKey] !== undefined && transformValue[nestedKey] !== baseValue?.[nestedKey]) { + vectorOverride[nestedKey] = transformValue[nestedKey]; + } + }); + + return Object.keys(vectorOverride).length > 0 ? vectorOverride : undefined; +} diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index 374c47789..a18ae9c8d 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -1,44 +1,131 @@ -import type { IEffect, ITransform } from '@/Core/Modules/stage/stageInterface'; -import type { componentsVisibility } from '@/store/guiInterface'; - export const EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL = 'webgal-editor-preview-sync.v1' as const; +export const SESSION_REGISTER_PREVIEW_TYPE = 'session.register-preview' as const; + type EmptyObject = Record; +type PayloadMap = Record; + +function payload(): TPayload { + return undefined as TPayload; +} + +function definePayloadMap(map: TMap): Readonly { + return Object.freeze(map); +} + +function messageTypes(map: TMap): readonly Extract[] { + return Object.freeze(Object.keys(map) as Extract[]); +} -export interface DebugVariablePayload { +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; + +export interface JsonObject { + [key: string]: JsonValue; +} + +export interface DebugVariable { key: string; value: string; isGlobal?: boolean; } +export interface Point2D { + x?: number; + y?: number; +} + +export interface Transform { + position?: Point2D; + scale?: Point2D; + rotation?: number; + + alpha?: number; + blur?: number; + + brightness?: number; + contrast?: number; + saturation?: number; + gamma?: number; + colorRed?: number; + colorGreen?: number; + colorBlue?: number; + + bloom?: number; + bloomBrightness?: number; + bloomBlur?: number; + bloomThreshold?: number; + + bevel?: number; + bevelThickness?: number; + bevelRotation?: number; + bevelSoftness?: number; + bevelRed?: number; + bevelGreen?: number; + bevelBlue?: number; + + oldFilm?: number; + dotFilm?: number; + reflectionFilm?: number; + glitchFilm?: number; + rgbFilm?: number; + godrayFilm?: number; + + shockwaveFilter?: number; + radiusAlphaFilter?: number; +} + +export const COMPONENT_VISIBILITY_KEYS = [ + 'showStarter', + 'showTitle', + 'showMenuPanel', + 'showTextBox', + 'showControls', + 'controlsVisibility', + 'showBacklog', + 'showExtra', + 'showGlobalDialog', + 'showPanicOverlay', + 'isEnterGame', + 'isShowLogo', + 'enableAppreciationMode', + 'fontOptimization', +] as const; + +export type ComponentVisibilityKey = (typeof COMPONENT_VISIBILITY_KEYS)[number]; + +export type SetComponentVisibilityPayload = Partial>; + +export type SyncSceneSettleMode = 'normal' | 'immediate'; + export interface SyncScenePayload { sceneName: string; sentenceId: number; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; + transformBaselineRevision?: string; + settleMode?: SyncSceneSettleMode; } export interface RunSceneContentPayload { sceneContent: string; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export interface RunSnippetPayload { snippet: string; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export type ReloadTemplatesPayload = EmptyObject; +export type SetEffectPhase = 'preview' | 'commit'; + export interface SetEffectPayload { - target: IEffect['target']; - transform?: Partial> & { - position?: Partial; - scale?: Partial; - }; + target: string; + transform?: Transform; + phase?: SetEffectPhase; } -export type SetComponentVisibilityPayload = Partial>; - export interface SetFontOptimizationPayload { enabled: boolean; } @@ -47,42 +134,93 @@ export interface SetTextReadModePayload { isRead: boolean; } -export interface PreviewCommandPayloadByType { - 'preview.command.sync-scene': SyncScenePayload; - 'preview.command.run-scene-content': RunSceneContentPayload; - 'preview.command.run-snippet': RunSnippetPayload; - 'preview.command.reload-templates': ReloadTemplatesPayload; - 'preview.command.set-effect': SetEffectPayload; - 'preview.command.set-component-visibility': SetComponentVisibilityPayload; - 'preview.command.set-font-optimization': SetFontOptimizationPayload; - 'preview.command.set-text-read-mode': SetTextReadModePayload; +export interface ReferenceBoxQueryPayload { + target: string; } -export type PreviewCommandType = keyof PreviewCommandPayloadByType; +export type BaseTransformQueryPayload = EmptyObject; -const PREVIEW_COMMAND_TYPES = [ - 'preview.command.sync-scene', - 'preview.command.run-scene-content', - 'preview.command.run-snippet', - 'preview.command.reload-templates', - 'preview.command.set-effect', - 'preview.command.set-component-visibility', - 'preview.command.set-font-optimization', - 'preview.command.set-text-read-mode', -] as const satisfies readonly PreviewCommandType[]; +export interface TransformBaselineQueryPayload { + target: string; + transformBaselineRevision: string; +} -export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType {} +export interface ReferenceBox { + originX: number; + originY: number; + width: number; + height: number; + anchorX: number; + anchorY: number; + stageWidth: number; + stageHeight: number; +} -export type PreviewRequestType = keyof PreviewRequestPayloadByType; +export type ReferenceBoxQueryResultPayload = + | { + target: string; + status: 'ready'; + box: ReferenceBox; + } + | { + target: string; + status: 'missing' | 'loading' | 'unsupported'; + reason?: string; + }; -const PREVIEW_REQUEST_TYPES = [...PREVIEW_COMMAND_TYPES] as const satisfies readonly PreviewRequestType[]; +export interface BaseTransformQueryResultPayload { + baseTransform: Transform; +} -type JsonPrimitive = string | number | boolean | null; -type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +export type TransformBaselineQueryResultPayload = + | { + status: 'ready'; + transform: Transform; + } + | { + status: 'loading'; + } + | { + status: 'unavailable'; + }; -interface JsonObject { - [key: string]: JsonValue; -} +export const PREVIEW_COMMAND_PAYLOADS = definePayloadMap({ + 'preview.command.sync-scene': payload(), + 'preview.command.run-scene-content': payload(), + 'preview.command.run-snippet': payload(), + 'preview.command.reload-templates': payload(), + 'preview.command.set-effect': payload(), + 'preview.command.set-component-visibility': payload(), + 'preview.command.set-font-optimization': payload(), + 'preview.command.set-text-read-mode': payload(), +}); + +export type PreviewCommandPayloadByType = typeof PREVIEW_COMMAND_PAYLOADS; + +export type PreviewCommandType = keyof PreviewCommandPayloadByType & string; + +export const PREVIEW_COMMAND_TYPES = messageTypes(PREVIEW_COMMAND_PAYLOADS); + +export const PREVIEW_QUERY_PAYLOADS = definePayloadMap({ + 'preview.query.reference-box': payload(), + 'preview.query.base-transform': payload(), + 'preview.query.transform-baseline': payload(), +}); + +export type PreviewQueryPayloadByType = typeof PREVIEW_QUERY_PAYLOADS; + +export type PreviewQueryType = keyof PreviewQueryPayloadByType & string; + +export const PREVIEW_QUERY_TYPES = messageTypes(PREVIEW_QUERY_PAYLOADS); + +export type PreviewRequestPayloadByType = PreviewCommandPayloadByType & PreviewQueryPayloadByType; + +export type PreviewRequestType = keyof PreviewRequestPayloadByType; + +export const PREVIEW_REQUEST_TYPES = [ + ...PREVIEW_COMMAND_TYPES, + ...PREVIEW_QUERY_TYPES, +] as const satisfies readonly PreviewRequestType[]; export interface PreviewReadyUpdatedPayload { ready: boolean; @@ -103,40 +241,85 @@ export interface FastPreviewTimeoutPayload { maxDurationMs: number; } -interface EventPayloadByType { - 'preview.ready.updated': PreviewReadyUpdatedPayload; - 'stage.snapshot.updated': StageSnapshotUpdatedPayload; - 'preview.event.fast-preview-timeout': FastPreviewTimeoutPayload; -} +export const HOST_EVENT_PAYLOADS = definePayloadMap({ + 'preview.ready.updated': payload(), + 'stage.snapshot.updated': payload(), + 'preview.event.fast-preview-timeout': payload(), +}); + +export type EventPayloadByType = typeof HOST_EVENT_PAYLOADS; -export type HostEventType = keyof EventPayloadByType; +export type HostEventType = keyof EventPayloadByType & string; -const HOST_EVENT_TYPES = [ - 'preview.ready.updated', - 'stage.snapshot.updated', - 'preview.event.fast-preview-timeout', -] as const satisfies readonly HostEventType[]; +export const HOST_EVENT_TYPES = messageTypes(HOST_EVENT_PAYLOADS); export interface RegisterPreviewRequestPayload { gameId?: string; embeddedLaunchId?: string; } -interface SessionRequestPayloadByType { - 'session.register-preview': RegisterPreviewRequestPayload; -} +export const SESSION_REQUEST_PAYLOADS = definePayloadMap({ + [SESSION_REGISTER_PREVIEW_TYPE]: payload(), +}); -interface RequestPayloadByType extends SessionRequestPayloadByType, PreviewRequestPayloadByType {} +export type SessionRequestPayloadByType = typeof SESSION_REQUEST_PAYLOADS; -export interface PreviewCommandResponsePayloadByType extends Record {} +export type SessionRequestType = keyof SessionRequestPayloadByType & string; -export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType {} +export const SESSION_REQUEST_TYPES = messageTypes(SESSION_REQUEST_PAYLOADS); -interface SessionResponsePayloadByType { - 'session.register-preview': EmptyObject; -} +export type RequestPayloadByType = SessionRequestPayloadByType & PreviewRequestPayloadByType; + +export type RequestType = keyof RequestPayloadByType; + +export const REQUEST_TYPES = [ + ...SESSION_REQUEST_TYPES, + ...PREVIEW_REQUEST_TYPES, +] as const satisfies readonly RequestType[]; + +export type PreviewCommandResponsePayloadByType = Record; + +export const PREVIEW_QUERY_RESPONSE_PAYLOADS = definePayloadMap({ + 'preview.query.reference-box': payload(), + 'preview.query.base-transform': payload(), + 'preview.query.transform-baseline': payload(), +}); + +export type PreviewQueryResponsePayloadByType = typeof PREVIEW_QUERY_RESPONSE_PAYLOADS; + +export type PreviewResponsePayloadByType = PreviewCommandResponsePayloadByType & PreviewQueryResponsePayloadByType; + +export type PreviewResponseType = PreviewRequestType; -interface ResponsePayloadByType extends SessionResponsePayloadByType, PreviewResponsePayloadByType {} +export const PREVIEW_RESPONSE_TYPES = PREVIEW_REQUEST_TYPES satisfies readonly PreviewResponseType[]; + +export const SESSION_RESPONSE_PAYLOADS = definePayloadMap({ + [SESSION_REGISTER_PREVIEW_TYPE]: payload(), +}); + +export type SessionResponsePayloadByType = typeof SESSION_RESPONSE_PAYLOADS; + +export type SessionResponseType = keyof SessionResponsePayloadByType & string; + +export const SESSION_RESPONSE_TYPES = messageTypes(SESSION_RESPONSE_PAYLOADS); + +export type ResponsePayloadByType = SessionResponsePayloadByType & PreviewResponsePayloadByType; + +export type ResponseType = keyof ResponsePayloadByType; + +export const RESPONSE_TYPES = [ + ...SESSION_RESPONSE_TYPES, + ...PREVIEW_RESPONSE_TYPES, +] as const satisfies readonly ResponseType[]; + +export const PREVIEW_REQUEST_ERROR_CODES = ['bad-request', 'unsupported-request-type', 'internal-error'] as const; + +export type PreviewRequestErrorCode = (typeof PREVIEW_REQUEST_ERROR_CODES)[number]; + +export interface PreviewRequestError { + code: PreviewRequestErrorCode; + message?: string; +} export interface EventEnvelope { kind: 'event'; @@ -158,19 +341,38 @@ export interface ResponseEnvelope = { +export interface PreviewRequestErrorEnvelope { + kind: 'error'; + type: TType; + requestId: string; + error: PreviewRequestError; +} + +export type AnyProtocolEnvelope = EventEnvelope | RequestEnvelope | ResponseEnvelope | PreviewRequestErrorEnvelope; + +export type EventEnvelopeByType = { [K in TType]: EventEnvelope; }[TType]; -type RequestEnvelopeByType = { +export type RequestEnvelopeByType = { [K in TType]: RequestEnvelope; }[TType]; -type ResponseEnvelopeByType = { +export type ResponseEnvelopeByType = { [K in TType]: ResponseEnvelope; }[TType]; -export type ProtocolEnvelope = EventEnvelopeByType | RequestEnvelopeByType | ResponseEnvelopeByType; +export type PreviewRequestErrorEnvelopeByType = { + [K in TType]: PreviewRequestErrorEnvelope; +}[TType]; + +export type KnownProtocolEnvelope = + | EventEnvelopeByType + | RequestEnvelopeByType + | ResponseEnvelopeByType + | PreviewRequestErrorEnvelopeByType; + +export type ProtocolEnvelope = KnownProtocolEnvelope; export function createEventEnvelope( type: TType, @@ -209,11 +411,27 @@ export function createResponseEnvelope( + type: TType, + requestId: string, + error: PreviewRequestError, +): PreviewRequestErrorEnvelope { + return { + kind: 'error', + type, + requestId, + error, + }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function hasEnvelopeShape(value: unknown, kind: ProtocolEnvelope['kind']): value is Record { +function hasPayloadEnvelopeShape( + value: unknown, + kind: 'event' | 'request' | 'response', +): value is Record { return ( isRecord(value) && value.kind === kind && @@ -227,38 +445,91 @@ function isMessageType(value: unknown, acceptedTypes: read return typeof value === 'string' && acceptedTypes.includes(value as TType); } -function isEventEnvelope(value: unknown): value is EventEnvelope { - return hasEnvelopeShape(value, 'event'); +export function isSessionRequestType(value: unknown): value is SessionRequestType { + return isMessageType(value, SESSION_REQUEST_TYPES); } -function isRequestEnvelope(value: unknown): value is RequestEnvelope { - return hasEnvelopeShape(value, 'request'); +export function isPreviewCommandType(value: unknown): value is PreviewCommandType { + return isMessageType(value, PREVIEW_COMMAND_TYPES); } -function isResponseEnvelope(value: unknown): value is ResponseEnvelope { - return hasEnvelopeShape(value, 'response'); +export function isPreviewQueryType(value: unknown): value is PreviewQueryType { + return isMessageType(value, PREVIEW_QUERY_TYPES); } -export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { - return isEventEnvelope(value) || isRequestEnvelope(value) || isResponseEnvelope(value); +export function isPreviewRequestType(value: unknown): value is PreviewRequestType { + return isMessageType(value, PREVIEW_REQUEST_TYPES); } -export function isPreviewCommandType(value: unknown): value is PreviewCommandType { - return isMessageType(value, PREVIEW_COMMAND_TYPES); +export function isRequestType(value: unknown): value is RequestType { + return isMessageType(value, REQUEST_TYPES); } -export function isPreviewRequestType(value: unknown): value is PreviewRequestType { - return isMessageType(value, PREVIEW_REQUEST_TYPES); +export function isPreviewResponseType(value: unknown): value is PreviewResponseType { + return isMessageType(value, PREVIEW_RESPONSE_TYPES); +} + +export function isSessionResponseType(value: unknown): value is SessionResponseType { + return isMessageType(value, SESSION_RESPONSE_TYPES); +} + +export function isResponseType(value: unknown): value is ResponseType { + return isMessageType(value, RESPONSE_TYPES); } export function isHostEventType(value: unknown): value is HostEventType { return isMessageType(value, HOST_EVENT_TYPES); } +export function isPreviewRequestErrorCode(value: unknown): value is PreviewRequestErrorCode { + return isMessageType(value, PREVIEW_REQUEST_ERROR_CODES); +} + +export function isEventEnvelope(value: unknown): value is EventEnvelope { + return hasPayloadEnvelopeShape(value, 'event'); +} + +export function isRequestEnvelope(value: unknown): value is RequestEnvelope { + return hasPayloadEnvelopeShape(value, 'request'); +} + +export function isResponseEnvelope(value: unknown): value is ResponseEnvelope { + return hasPayloadEnvelopeShape(value, 'response'); +} + +export function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestErrorEnvelope { + return ( + isRecord(value) && + value.kind === 'error' && + typeof value.type === 'string' && + typeof value.requestId === 'string' && + isRecord(value.error) && + isPreviewRequestErrorCode(value.error.code) && + (!('message' in value.error) || typeof value.error.message === 'string') + ); +} + +export function isAnyProtocolEnvelope(value: unknown): value is AnyProtocolEnvelope { + return ( + isEventEnvelope(value) || + isRequestEnvelope(value) || + isResponseEnvelope(value) || + isPreviewRequestErrorEnvelope(value) + ); +} + export function isHostEventEnvelope(value: unknown): value is EventEnvelopeByType { return isEventEnvelope(value) && isHostEventType(value.type); } +export function isKnownRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isRequestType(value.type); +} + +export function isSessionRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isSessionRequestType(value.type); +} + export function isPreviewCommandRequestEnvelope(value: unknown): value is RequestEnvelopeByType { return isRequestEnvelope(value) && isPreviewCommandType(value.type); } @@ -266,3 +537,26 @@ export function isPreviewCommandRequestEnvelope(value: unknown): value is Reques export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelopeByType { return isRequestEnvelope(value) && isPreviewRequestType(value.type); } + +export function isKnownResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { + return isResponseEnvelope(value) && isResponseType(value.type); +} + +export function isPreviewResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { + return isResponseEnvelope(value) && isPreviewResponseType(value.type); +} + +export function isKnownPreviewRequestErrorEnvelope( + value: unknown, +): value is PreviewRequestErrorEnvelopeByType { + return isPreviewRequestErrorEnvelope(value) && isPreviewRequestType(value.type); +} + +export function isKnownProtocolEnvelope(value: unknown): value is KnownProtocolEnvelope { + return ( + isHostEventEnvelope(value) || + isKnownRequestEnvelope(value) || + isKnownResponseEnvelope(value) || + (isPreviewRequestErrorEnvelope(value) && isRequestType(value.type)) + ); +}