From 5c1964eae029876a1769e7a657cbe480b272b602 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:44:19 +0800 Subject: [PATCH 01/12] feat: preview protocol add `preview.query.reference-box` --- .../controller/stage/pixi/PixiController.ts | 8 + .../stage/pixi/WebGALPixiContainer.ts | 55 ++++++ .../controller/stage/pixi/referenceBox.ts | 101 +++++++++++ .../util/syncWithEditor/previewSyncRuntime.ts | 171 +++++++++++------- .../handlers/referenceBoxQueryHandler.ts | 35 ++++ .../runtime/previewDebugVariables.ts | 4 +- .../webgal/src/types/editorPreviewProtocol.ts | 68 ++++++- 7 files changed, 370 insertions(+), 72 deletions(-) create mode 100644 packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts create mode 100644 packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index fab9b1bfc..34e98c52d 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -14,6 +14,7 @@ import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { queryStageObjectReferenceBox, QueryTargetReferenceBoxResult } from './referenceBox'; export interface IAnimationObject { setStartState: Function; @@ -1030,6 +1031,13 @@ 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 getStageObjByUuid(objUuid: string) { return [...this.figureObjects, ...this.backgroundObjects, this.mainStageObject].find((e) => e.uuid === objUuid); } diff --git a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts index fc2fc6f14..c815db6fb 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts @@ -328,6 +328,34 @@ const PROPERTY_CONFIGS: Record = { }, }; +function getChildReferenceBounds(child: PIXI.DisplayObject, containerPivot: PIXI.IPointData): PIXI.Rectangle { + child.transform.updateLocalTransform(); + const bounds = child.getLocalBounds(); + const transform = child.transform.localTransform; + const left = bounds.x * child.scale.x + transform.tx - containerPivot.x; + const right = (bounds.x + bounds.width) * child.scale.x + transform.tx - containerPivot.x; + const top = bounds.y * child.scale.y + transform.ty - containerPivot.y; + const bottom = (bounds.y + bounds.height) * child.scale.y + transform.ty - containerPivot.y; + + return new PIXI.Rectangle( + Math.min(left, right), + Math.min(top, bottom), + Math.abs(right - left), + Math.abs(bottom - top), + ); +} + +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 +443,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/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index d9fc83b00..5f064904e 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -3,11 +3,18 @@ import { createRequestEnvelope, createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isPreviewCommandType, isPreviewRequestEnvelope, isProtocolEnvelope, - PreviewRequestPayloadByType, +} from '@/types/editorPreviewProtocol'; +import type { + FastPreviewTimeoutPayload, + PreviewCommandPayloadByType, + PreviewCommandResponsePayloadByType, + PreviewCommandType, + PreviewQueryType, PreviewRequestType, - PreviewResponsePayloadByType, + ProtocolEnvelope, RunSceneContentPayload, RunSnippetPayload, SetComponentVisibilityPayload, @@ -16,8 +23,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'; @@ -40,6 +46,7 @@ import { import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneCommand'; import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; +import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; let previewSyncRuntimeStarted = false; type StageStateSnapshot = IStageState; @@ -50,6 +57,10 @@ interface RegisterPreviewLogContext { embeddedLaunchId: string | undefined; } +type PreviewRequestEnvelope = Extract; +type PreviewQueryEnvelope = Extract; +type PreviewQueryHandler = (envelope: PreviewQueryEnvelope) => void; + export const startPreviewSyncRuntime = () => { if (previewSyncRuntimeStarted) { return; @@ -73,6 +84,7 @@ export const startPreviewSyncRuntime = () => { let registered = false; let pendingRegisterRequestId: string | null = null; let pendingRegisterContext: RegisterPreviewLogContext | null = null; + let isEmbeddedPreview = false; let lastPublishedSceneName: string | null = null; let lastPublishedSentenceId: number | null = null; let lastPublishedStageState: StageStateSnapshot | null = null; @@ -86,6 +98,7 @@ export const startPreviewSyncRuntime = () => { registered = false; pendingRegisterRequestId = null; pendingRegisterContext = null; + isEmbeddedPreview = false; lastPublishedSceneName = null; lastPublishedSentenceId = null; lastPublishedStageState = null; @@ -157,6 +170,20 @@ export const startPreviewSyncRuntime = () => { ); }; + const finishRegisterPreview = () => { + const registeredPreviewContext = pendingRegisterContext; + if (registeredPreviewContext) { + logger.info('编辑器同步 V1 注册完成', registeredPreviewContext); + } + + pendingRegisterRequestId = null; + pendingRegisterContext = null; + registered = true; + isEmbeddedPreview = Boolean(registeredPreviewContext?.embeddedLaunchId); + publishReady(); + publishStageSnapshot(true); + }; + const emitFastPreviewTimeout = (payload: FastPreviewTimeoutPayload) => { if (!registered) { return; @@ -253,8 +280,8 @@ export const startPreviewSyncRuntime = () => { }); }; - 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 +317,91 @@ export const startPreviewSyncRuntime = () => { }, }; - const handlePreviewRequest = ( + const previewQueryHandlers: Record = { + 'preview.query.reference-box': (envelope) => { + transport.send(handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview)); + }, + }; + + const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { + return envelope.type in previewQueryHandlers; + }; + + 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); }; + const respondToPreviewRequest = (envelope: PreviewRequestEnvelope) => { + if (isPreviewQueryEnvelope(envelope)) { + previewQueryHandlers[envelope.type](envelope); + return; + } + + if (!isPreviewCommandType(envelope.type)) { + logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + return; + } + + const responsePayload = handlePreviewCommand(envelope.type, envelope.payload); + transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); + }; + + const handleProtocolEnvelope = (envelope: ProtocolEnvelope) => { + if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { + if (pendingRegisterRequestId !== null && envelope.requestId === pendingRegisterRequestId) { + finishRegisterPreview(); + } + 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}`); + } + return; + } + + try { + respondToPreviewRequest(envelope); + } catch (error) { + logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + } + }; + + const handleRawMessage = (rawData: unknown) => { + try { + const envelope = JSON.parse(String(rawData)) as unknown; + if (!isProtocolEnvelope(envelope)) { + 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: (rawData) => { - try { - const envelope = JSON.parse(String(rawData)) as unknown; - if (!isProtocolEnvelope(envelope)) { - logger.warn('收到无法识别的编辑器同步 V1 消息'); - 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; - } - - if (!registered) { - if (envelope.kind === 'request') { - logger.warn(`收到注册完成前的编辑器同步 V1 请求:${envelope.type}`); - } - return; - } - - if (!isPreviewRequestEnvelope(envelope)) { - if (envelope.kind === 'request') { - logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); - } - return; - } - - let responsePayload: PreviewResponsePayloadByType[typeof envelope.type]; - try { - responsePayload = handlePreviewRequest(envelope.type, envelope.payload); - } catch (error) { - logger.error(`执行编辑器同步 V1 命令失败:${envelope.type}`, error); - return; - } - - transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); - } catch (error) { - logger.error('解析编辑器同步 V1 消息失败', error); - } - }, + 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..c4e785a4d --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -0,0 +1,35 @@ +import { + createResponseEnvelope, + ReferenceBoxQueryPayload, + ReferenceBoxQueryResultPayload, + RequestEnvelope, + ResponseEnvelope, +} from '@/types/editorPreviewProtocol'; + +interface ReferenceBoxQueryStage { + queryTargetReferenceBox(target: string): ReferenceBoxQueryResultPayload; +} + +export function handleReferenceBoxQuery( + request: RequestEnvelope, + pixiStage: ReferenceBoxQueryStage | null | undefined, + isSupported = true, +): ResponseEnvelope { + if (!isSupported) { + return createResponseEnvelope('preview.query.reference-box', request.requestId, { + target: request.payload.target, + status: 'unsupported', + reason: '当前预览不支持 transform overlay reference box 查询', + }); + } + + const { target } = request.payload; + + const result = pixiStage?.queryTargetReferenceBox(target) ?? { + target, + status: 'unsupported' as const, + reason: 'Pixi stage 不可用', + }; + + 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/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index 374c47789..e5fd1fa35 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -5,7 +5,7 @@ export const EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL = 'webgal-editor-preview-syn type EmptyObject = Record; -export interface DebugVariablePayload { +export interface DebugVariable { key: string; value: string; isGlobal?: boolean; @@ -14,17 +14,17 @@ export interface DebugVariablePayload { export interface SyncScenePayload { sceneName: string; sentenceId: number; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export interface RunSceneContentPayload { sceneContent: string; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export interface RunSnippetPayload { snippet: string; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export type ReloadTemplatesPayload = EmptyObject; @@ -47,6 +47,33 @@ export interface SetTextReadModePayload { isRead: boolean; } +export interface ReferenceBoxQueryPayload { + target: string; +} + +export interface ReferenceBox { + originX: number; + originY: number; + width: number; + height: number; + anchorX: number; + anchorY: number; + stageWidth: number; + stageHeight: number; +} + +export type ReferenceBoxQueryResultPayload = + | { + target: string; + status: 'ready'; + box: ReferenceBox; + } + | { + target: string; + status: 'missing' | 'loading' | 'unsupported'; + reason?: string; + }; + export interface PreviewCommandPayloadByType { 'preview.command.sync-scene': SyncScenePayload; 'preview.command.run-scene-content': RunSceneContentPayload; @@ -71,11 +98,24 @@ const PREVIEW_COMMAND_TYPES = [ 'preview.command.set-text-read-mode', ] as const satisfies readonly PreviewCommandType[]; -export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType {} +export interface PreviewQueryPayloadByType { + 'preview.query.reference-box': ReferenceBoxQueryPayload; +} + +export type PreviewQueryType = keyof PreviewQueryPayloadByType; + +const PREVIEW_QUERY_TYPES = [ + 'preview.query.reference-box', +] as const satisfies readonly PreviewQueryType[]; + +export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType, PreviewQueryPayloadByType {} export type PreviewRequestType = keyof PreviewRequestPayloadByType; -const PREVIEW_REQUEST_TYPES = [...PREVIEW_COMMAND_TYPES] as const satisfies readonly PreviewRequestType[]; +const PREVIEW_REQUEST_TYPES = [ + ...PREVIEW_COMMAND_TYPES, + ...PREVIEW_QUERY_TYPES, +] as const satisfies readonly PreviewRequestType[]; type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; @@ -130,7 +170,13 @@ interface RequestPayloadByType extends SessionRequestPayloadByType, PreviewReque export interface PreviewCommandResponsePayloadByType extends Record {} -export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType {} +export interface PreviewQueryResponsePayloadByType { + 'preview.query.reference-box': ReferenceBoxQueryResultPayload; +} + +export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType, PreviewQueryResponsePayloadByType {} + +export type PreviewResponseType = PreviewRequestType interface SessionResponsePayloadByType { 'session.register-preview': EmptyObject; @@ -251,6 +297,10 @@ export function isPreviewRequestType(value: unknown): value is PreviewRequestTyp return isMessageType(value, PREVIEW_REQUEST_TYPES); } +export function isPreviewResponseType(value: unknown): value is PreviewResponseType { + return isPreviewRequestType(value) +} + export function isHostEventType(value: unknown): value is HostEventType { return isMessageType(value, HOST_EVENT_TYPES); } @@ -266,3 +316,7 @@ export function isPreviewCommandRequestEnvelope(value: unknown): value is Reques export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelopeByType { return isRequestEnvelope(value) && isPreviewRequestType(value.type); } + +export function isPreviewResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { + return isResponseEnvelope(value) && isPreviewResponseType(value.type) +} From eda54bf7f7112b018335b1dd69197106c06caceb Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:03:21 +0800 Subject: [PATCH 02/12] feat: implement preview request failure envelope --- .../util/syncWithEditor/previewSyncRuntime.ts | 34 ++++++++ .../webgal/src/types/editorPreviewProtocol.ts | 77 +++++++++++++++++-- 2 files changed, 105 insertions(+), 6 deletions(-) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 5f064904e..89b2e0095 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -1,6 +1,7 @@ import { createEventEnvelope, createRequestEnvelope, + createRequestErrorEnvelope, createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, isPreviewCommandType, @@ -13,6 +14,7 @@ import type { PreviewCommandResponsePayloadByType, PreviewCommandType, PreviewQueryType, + PreviewRequestErrorCode, PreviewRequestType, ProtocolEnvelope, RunSceneContentPayload, @@ -60,6 +62,13 @@ interface RegisterPreviewLogContext { type PreviewRequestEnvelope = Extract; type PreviewQueryEnvelope = Extract; type PreviewQueryHandler = (envelope: PreviewQueryEnvelope) => void; +type RawRequestEnvelope = { + kind: 'request'; + type: string; + requestId: string; +}; + +const UNSUPPORTED_REQUEST_MESSAGE = '当前预览运行时不支持该请求类型'; export const startPreviewSyncRuntime = () => { if (previewSyncRuntimeStarted) { @@ -94,6 +103,22 @@ export const startPreviewSyncRuntime = () => { 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, code, message)); + }; + const resetRegistrationState = () => { registered = false; pendingRegisterRequestId = null; @@ -346,6 +371,7 @@ export const startPreviewSyncRuntime = () => { if (!isPreviewCommandType(envelope.type)) { logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'unsupported-request-type', UNSUPPORTED_REQUEST_MESSAGE); return; } @@ -371,6 +397,7 @@ export const startPreviewSyncRuntime = () => { if (!isPreviewRequestEnvelope(envelope)) { if (envelope.kind === 'request') { logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'unsupported-request-type', UNSUPPORTED_REQUEST_MESSAGE); } return; } @@ -379,6 +406,7 @@ export const startPreviewSyncRuntime = () => { respondToPreviewRequest(envelope); } catch (error) { logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + sendRequestError(envelope, 'internal-error', '预览运行时无法安全完成该请求'); } }; @@ -386,6 +414,12 @@ export const startPreviewSyncRuntime = () => { try { const envelope = JSON.parse(String(rawData)) as unknown; if (!isProtocolEnvelope(envelope)) { + if (isRawRequestEnvelope(envelope)) { + logger.warn(`收到非法的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'bad-request', '请求 envelope 格式不合法'); + return; + } + logger.warn('收到无法识别的编辑器同步 V1 消息'); return; } diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index e5fd1fa35..46129a7ac 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -176,7 +176,15 @@ export interface PreviewQueryResponsePayloadByType { export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType, PreviewQueryResponsePayloadByType {} -export type PreviewResponseType = PreviewRequestType +export type PreviewResponseType = PreviewRequestType; + +export type PreviewRequestErrorCode = 'bad-request' | 'unsupported-request-type' | 'internal-error'; + +const PREVIEW_REQUEST_ERROR_CODES = [ + 'bad-request', + 'unsupported-request-type', + 'internal-error', +] as const satisfies readonly PreviewRequestErrorCode[]; interface SessionResponsePayloadByType { 'session.register-preview': EmptyObject; @@ -204,6 +212,16 @@ export interface ResponseEnvelope { + kind: 'error'; + type: TType; + requestId: string; + error: { + code: PreviewRequestErrorCode; + message?: string; + }; +} + type EventEnvelopeByType = { [K in TType]: EventEnvelope; }[TType]; @@ -216,7 +234,11 @@ type ResponseEnvelopeByType; }[TType]; -export type ProtocolEnvelope = EventEnvelopeByType | RequestEnvelopeByType | ResponseEnvelopeByType; +export type ProtocolEnvelope = + | EventEnvelopeByType + | RequestEnvelopeByType + | ResponseEnvelopeByType + | PreviewRequestErrorEnvelope; export function createEventEnvelope( type: TType, @@ -255,11 +277,33 @@ export function createResponseEnvelope( + type: TType, + requestId: string, + code: PreviewRequestErrorCode, + message?: string, +): PreviewRequestErrorEnvelope { + const error: PreviewRequestErrorEnvelope['error'] = { code }; + if (message) { + error.message = message; + } + + 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 hasEnvelopeShape( + value: unknown, + kind: Exclude, +): value is Record { return ( isRecord(value) && value.kind === kind && @@ -273,6 +317,10 @@ function isMessageType(value: unknown, acceptedTypes: read return typeof value === 'string' && acceptedTypes.includes(value as TType); } +function isPreviewRequestErrorCode(value: unknown): value is PreviewRequestErrorCode { + return isMessageType(value, PREVIEW_REQUEST_ERROR_CODES); +} + function isEventEnvelope(value: unknown): value is EventEnvelope { return hasEnvelopeShape(value, 'event'); } @@ -285,8 +333,25 @@ function isResponseEnvelope(value: unknown): value is ResponseEnvelope { return hasEnvelopeShape(value, 'response'); } +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 isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { - return isEventEnvelope(value) || isRequestEnvelope(value) || isResponseEnvelope(value); + return ( + isEventEnvelope(value) || + isRequestEnvelope(value) || + isResponseEnvelope(value) || + isPreviewRequestErrorEnvelope(value) + ); } export function isPreviewCommandType(value: unknown): value is PreviewCommandType { @@ -298,7 +363,7 @@ export function isPreviewRequestType(value: unknown): value is PreviewRequestTyp } export function isPreviewResponseType(value: unknown): value is PreviewResponseType { - return isPreviewRequestType(value) + return isPreviewRequestType(value); } export function isHostEventType(value: unknown): value is HostEventType { @@ -318,5 +383,5 @@ export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelo } export function isPreviewResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { - return isResponseEnvelope(value) && isPreviewResponseType(value.type) + return isResponseEnvelope(value) && isPreviewResponseType(value.type); } From 176407a7af5a1d7c5961808a5290834b9132fc67 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:39:47 +0800 Subject: [PATCH 03/12] feat: add target transform baseline queries --- .../util/syncWithEditor/previewSyncRuntime.ts | 70 +++++- .../runtime/previewSyncSceneCommand.ts | 52 +++- .../runtime/targetTransformBaseline.ts | 226 ++++++++++++++++++ .../webgal/src/types/editorPreviewProtocol.ts | 51 +++- 4 files changed, 380 insertions(+), 19 deletions(-) create mode 100644 packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 89b2e0095..f7f4b7735 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -5,6 +5,7 @@ import { createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, isPreviewCommandType, + isPreviewQueryType, isPreviewRequestEnvelope, isProtocolEnvelope, } from '@/types/editorPreviewProtocol'; @@ -49,6 +50,11 @@ import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneComman import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; +import { + canPublishTargetTransformBaselineSnapshot, + cloneBaseTransform, + createTargetTransformBaselineManager, +} from './runtime/targetTransformBaseline'; let previewSyncRuntimeStarted = false; type StageStateSnapshot = IStageState; @@ -61,7 +67,6 @@ interface RegisterPreviewLogContext { type PreviewRequestEnvelope = Extract; type PreviewQueryEnvelope = Extract; -type PreviewQueryHandler = (envelope: PreviewQueryEnvelope) => void; type RawRequestEnvelope = { kind: 'request'; type: string; @@ -98,6 +103,7 @@ 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; @@ -128,6 +134,7 @@ export const startPreviewSyncRuntime = () => { lastPublishedSentenceId = null; lastPublishedStageState = null; setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); }; const buildStageStateSnapshot = (stageState: StageStateSnapshot): StageSnapshotUpdatedPayload['stageState'] => { @@ -218,11 +225,34 @@ export const startPreviewSyncRuntime = () => { const handleSyncScene = (payload: SyncScenePayload) => { setEffectBaselines.clear(); - executePreviewSyncSceneCommand(payload, emitFastPreviewTimeout); + const { previewSyncRevision } = payload; + if (previewSyncRevision) { + targetTransformBaselines.acceptRevision(previewSyncRevision); + } else { + targetTransformBaselines.invalidateCurrentRevision(); + } + + executePreviewSyncSceneCommand(payload, { + onFastPreviewTimeout: emitFastPreviewTimeout, + onSettled: (result) => { + if (!previewSyncRevision) { + return; + } + + const canPublishSnapshot = canPublishTargetTransformBaselineSnapshot(result, payload); + if (!canPublishSnapshot) { + targetTransformBaselines.failRevision(previewSyncRevision); + return; + } + + targetTransformBaselines.publishSnapshot(previewSyncRevision, stageStateManager.getCalculationStageState()); + }, + }); }; 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) => { @@ -256,6 +286,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'); @@ -342,14 +373,35 @@ export const startPreviewSyncRuntime = () => { }, }; - const previewQueryHandlers: Record = { - 'preview.query.reference-box': (envelope) => { - transport.send(handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview)); - }, + const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { + return isPreviewQueryType(envelope.type); }; - const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { - return envelope.type in previewQueryHandlers; + const handlePreviewQuery = (envelope: PreviewQueryEnvelope) => { + switch (envelope.type) { + case 'preview.query.reference-box': + transport.send(handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview)); + return; + case 'preview.query.base-transform': + transport.send( + createResponseEnvelope('preview.query.base-transform', envelope.requestId, { + baseTransform: cloneBaseTransform(), + }), + ); + return; + case 'preview.query.target-transform': + transport.send( + createResponseEnvelope( + 'preview.query.target-transform', + envelope.requestId, + targetTransformBaselines.queryTargetTransform( + envelope.payload.target, + envelope.payload.previewSyncRevision, + ), + ), + ); + return; + } }; const handlePreviewCommand = ( @@ -365,7 +417,7 @@ export const startPreviewSyncRuntime = () => { const respondToPreviewRequest = (envelope: PreviewRequestEnvelope) => { if (isPreviewQueryEnvelope(envelope)) { - previewQueryHandlers[envelope.type](envelope); + handlePreviewQuery(envelope); return; } diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index 36f8ae0e5..56095d7c6 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -8,7 +8,11 @@ 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 +20,23 @@ 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; + onSettled?: (result: FastPreviewResult | null) => 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 +60,20 @@ 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) + .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,13 +81,15 @@ export async function runFastPreview( sentenceId: number, currentSceneName: string, onFastPreviewTimeout?: FastPreviewTimeoutEmitter, -): Promise { + settleMode: SyncSceneSettleMode = 'normal', +): 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; @@ -85,6 +113,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,6 +121,7 @@ export async function runFastPreview( if (WebGAL.gameplay.performController.hasPendingBlockingStateCalculationPerform()) { logger.warn('实时预览在需要外部输入的语句前停止演算'); + stopReason = 'state-calculation-blocked'; break; } @@ -101,6 +131,7 @@ export async function runFastPreview( !awaitedSceneWrite ) { logger.warn('实时预览跳转停止:本次 forward 没有推进语句指针'); + stopReason = 'no-progress'; break; } } @@ -108,6 +139,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 +168,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..4619c5b66 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -0,0 +1,226 @@ +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 { TargetTransformQueryResultPayload } 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; + } + | { + status: 'ready'; + revision: string; + snapshot: TargetTransformBaselineSnapshot; + } + | { + status: 'unavailable'; + revision: string; + }; + +interface TargetTransformBaselineSnapshot { + knownTargets: Set; + transformsByTarget: Map; +} + +interface TargetTransformBaselinePublishTarget { + sceneName: string; + sentenceId: number; +} + +interface TargetTransformBaselineManager { + acceptRevision: (revision: string) => void; + publishSnapshot: (revision: string, stageState: IStageState) => void; + failRevision: (revision: string) => void; + invalidateCurrentRevision: () => void; + queryTargetTransform: (target: string, revision: string) => TargetTransformQueryResultPayload; +} + +export function cloneBaseTransform(): ITransform { + return cloneDeep(baseTransform); +} + +export function canPublishTargetTransformBaselineSnapshot( + result: FastPreviewResult | null, + target: TargetTransformBaselinePublishTarget, +): 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, + }; + }, + + publishSnapshot(revision, stageState) { + if (!isPendingRevision(revision)) { + return; + } + + revisionState = { + status: 'ready', + revision, + snapshot: createSnapshot(stageState), + }; + }, + + failRevision(revision) { + if (!isLatestRevision(revision)) { + return; + } + + revisionState = { + status: 'unavailable', + revision, + }; + }, + + invalidateCurrentRevision() { + if (revisionState.status === 'none') { + return; + } + + revisionState = { + status: 'unavailable', + revision: revisionState.revision, + }; + }, + + queryTargetTransform(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): TargetTransformQueryResultPayload { + if (!snapshot.knownTargets.has(target)) { + return { + status: 'unavailable', + }; + } + + const transform = snapshot.transformsByTarget.get(target); + return { + status: 'ready', + transform: 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 46129a7ac..857c33594 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -15,8 +15,12 @@ export interface SyncScenePayload { sceneName: string; sentenceId: number; debugVariables?: DebugVariable[]; + previewSyncRevision?: string; + settleMode?: SyncSceneSettleMode; } +export type SyncSceneSettleMode = 'normal' | 'immediate'; + export interface RunSceneContentPayload { sceneContent: string; debugVariables?: DebugVariable[]; @@ -29,12 +33,14 @@ export interface RunSnippetPayload { export type ReloadTemplatesPayload = EmptyObject; +export type Transform = Partial> & { + position?: Partial; + scale?: Partial; +}; + export interface SetEffectPayload { target: IEffect['target']; - transform?: Partial> & { - position?: Partial; - scale?: Partial; - }; + transform?: Transform; } export type SetComponentVisibilityPayload = Partial>; @@ -51,6 +57,29 @@ export interface ReferenceBoxQueryPayload { target: string; } +export type BaseTransformQueryPayload = EmptyObject; + +export interface BaseTransformQueryResultPayload { + baseTransform: Transform; +} + +export interface TargetTransformQueryPayload { + target: string; + previewSyncRevision: string; +} + +export type TargetTransformQueryResultPayload = + | { + status: 'ready'; + transform: Transform; + } + | { + status: 'loading'; + } + | { + status: 'unavailable'; + }; + export interface ReferenceBox { originX: number; originY: number; @@ -100,12 +129,16 @@ const PREVIEW_COMMAND_TYPES = [ export interface PreviewQueryPayloadByType { 'preview.query.reference-box': ReferenceBoxQueryPayload; + 'preview.query.base-transform': BaseTransformQueryPayload; + 'preview.query.target-transform': TargetTransformQueryPayload; } export type PreviewQueryType = keyof PreviewQueryPayloadByType; const PREVIEW_QUERY_TYPES = [ 'preview.query.reference-box', + 'preview.query.base-transform', + 'preview.query.target-transform', ] as const satisfies readonly PreviewQueryType[]; export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType, PreviewQueryPayloadByType {} @@ -172,9 +205,13 @@ export interface PreviewCommandResponsePayloadByType extends Record Date: Mon, 22 Jun 2026 20:56:00 +0800 Subject: [PATCH 04/12] refactor: restructure the editor preview protocol type system --- .../src/Core/Modules/stage/stageInterface.ts | 49 +- .../util/syncWithEditor/previewSyncRuntime.ts | 15 +- .../handlers/referenceBoxQueryHandler.ts | 13 +- .../webgal/src/types/editorPreviewProtocol.ts | 427 ++++++++++++------ 4 files changed, 295 insertions(+), 209 deletions(-) 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/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index f7f4b7735..4fdb63a9a 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -4,12 +4,13 @@ import { createRequestErrorEnvelope, createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isAnyProtocolEnvelope, isPreviewCommandType, isPreviewQueryType, isPreviewRequestEnvelope, - isProtocolEnvelope, } from '@/types/editorPreviewProtocol'; import type { + AnyProtocolEnvelope, FastPreviewTimeoutPayload, PreviewCommandPayloadByType, PreviewCommandResponsePayloadByType, @@ -17,7 +18,7 @@ import type { PreviewQueryType, PreviewRequestErrorCode, PreviewRequestType, - ProtocolEnvelope, + RequestEnvelopeByType, RunSceneContentPayload, RunSnippetPayload, SetComponentVisibilityPayload, @@ -65,8 +66,8 @@ interface RegisterPreviewLogContext { embeddedLaunchId: string | undefined; } -type PreviewRequestEnvelope = Extract; -type PreviewQueryEnvelope = Extract; +type PreviewRequestEnvelope = RequestEnvelopeByType; +type PreviewQueryEnvelope = RequestEnvelopeByType; type RawRequestEnvelope = { kind: 'request'; type: string; @@ -122,7 +123,7 @@ export const startPreviewSyncRuntime = () => { code: PreviewRequestErrorCode, message?: string, ) => { - transport.send(createRequestErrorEnvelope(request.type, request.requestId, code, message)); + transport.send(createRequestErrorEnvelope(request.type, request.requestId, message ? { code, message } : { code })); }; const resetRegistrationState = () => { @@ -431,7 +432,7 @@ export const startPreviewSyncRuntime = () => { transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); }; - const handleProtocolEnvelope = (envelope: ProtocolEnvelope) => { + const handleProtocolEnvelope = (envelope: AnyProtocolEnvelope) => { if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { if (pendingRegisterRequestId !== null && envelope.requestId === pendingRegisterRequestId) { finishRegisterPreview(); @@ -465,7 +466,7 @@ export const startPreviewSyncRuntime = () => { const handleRawMessage = (rawData: unknown) => { try { const envelope = JSON.parse(String(rawData)) as unknown; - if (!isProtocolEnvelope(envelope)) { + if (!isAnyProtocolEnvelope(envelope)) { if (isRawRequestEnvelope(envelope)) { logger.warn(`收到非法的编辑器同步 V1 请求:${envelope.type}`); sendRequestError(envelope, 'bad-request', '请求 envelope 格式不合法'); diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts index c4e785a4d..cc621a772 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -1,9 +1,8 @@ -import { - createResponseEnvelope, - ReferenceBoxQueryPayload, +import { createResponseEnvelope } from '@/types/editorPreviewProtocol'; +import type { ReferenceBoxQueryResultPayload, - RequestEnvelope, - ResponseEnvelope, + RequestEnvelopeByType, + ResponseEnvelopeByType, } from '@/types/editorPreviewProtocol'; interface ReferenceBoxQueryStage { @@ -11,10 +10,10 @@ interface ReferenceBoxQueryStage { } export function handleReferenceBoxQuery( - request: RequestEnvelope, + request: RequestEnvelopeByType<'preview.query.reference-box'>, pixiStage: ReferenceBoxQueryStage | null | undefined, isSupported = true, -): ResponseEnvelope { +): ResponseEnvelopeByType<'preview.query.reference-box'> { if (!isSupported) { return createResponseEnvelope('preview.query.reference-box', request.requestId, { target: request.payload.target, diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index 857c33594..c3f4af6f4 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -1,9 +1,28 @@ -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 type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; + +export interface JsonObject { + [key: string]: JsonValue; +} export interface DebugVariable { key: string; @@ -11,6 +30,74 @@ export interface DebugVariable { 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; @@ -19,8 +106,6 @@ export interface SyncScenePayload { settleMode?: SyncSceneSettleMode; } -export type SyncSceneSettleMode = 'normal' | 'immediate'; - export interface RunSceneContentPayload { sceneContent: string; debugVariables?: DebugVariable[]; @@ -33,18 +118,11 @@ export interface RunSnippetPayload { export type ReloadTemplatesPayload = EmptyObject; -export type Transform = Partial> & { - position?: Partial; - scale?: Partial; -}; - export interface SetEffectPayload { - target: IEffect['target']; + target: string; transform?: Transform; } -export type SetComponentVisibilityPayload = Partial>; - export interface SetFontOptimizationPayload { enabled: boolean; } @@ -59,27 +137,11 @@ export interface ReferenceBoxQueryPayload { export type BaseTransformQueryPayload = EmptyObject; -export interface BaseTransformQueryResultPayload { - baseTransform: Transform; -} - export interface TargetTransformQueryPayload { target: string; previewSyncRevision: string; } -export type TargetTransformQueryResultPayload = - | { - status: 'ready'; - transform: Transform; - } - | { - status: 'loading'; - } - | { - status: 'unavailable'; - }; - export interface ReferenceBox { originX: number; originY: number; @@ -103,60 +165,60 @@ export type ReferenceBoxQueryResultPayload = reason?: string; }; -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 BaseTransformQueryResultPayload { + baseTransform: Transform; } -export type PreviewCommandType = keyof PreviewCommandPayloadByType; +export type TargetTransformQueryResultPayload = + | { + status: 'ready'; + transform: Transform; + } + | { + status: 'loading'; + } + | { + status: 'unavailable'; + }; -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 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 interface PreviewQueryPayloadByType { - 'preview.query.reference-box': ReferenceBoxQueryPayload; - 'preview.query.base-transform': BaseTransformQueryPayload; - 'preview.query.target-transform': TargetTransformQueryPayload; -} +export type PreviewCommandPayloadByType = typeof PREVIEW_COMMAND_PAYLOADS; + +export type PreviewCommandType = keyof PreviewCommandPayloadByType & string; + +export const PREVIEW_COMMAND_TYPES = messageTypes(PREVIEW_COMMAND_PAYLOADS); -export type PreviewQueryType = keyof PreviewQueryPayloadByType; +export const PREVIEW_QUERY_PAYLOADS = definePayloadMap({ + 'preview.query.reference-box': payload(), + 'preview.query.base-transform': payload(), + 'preview.query.target-transform': payload(), +}); -const PREVIEW_QUERY_TYPES = [ - 'preview.query.reference-box', - 'preview.query.base-transform', - 'preview.query.target-transform', -] as const satisfies readonly PreviewQueryType[]; +export type PreviewQueryPayloadByType = typeof PREVIEW_QUERY_PAYLOADS; -export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType, PreviewQueryPayloadByType {} +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; -const PREVIEW_REQUEST_TYPES = [ +export const PREVIEW_REQUEST_TYPES = [ ...PREVIEW_COMMAND_TYPES, ...PREVIEW_QUERY_TYPES, ] as const satisfies readonly PreviewRequestType[]; -type JsonPrimitive = string | number | boolean | null; -type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; - -interface JsonObject { - [key: string]: JsonValue; -} - export interface PreviewReadyUpdatedPayload { ready: boolean; } @@ -176,58 +238,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 HostEventType = keyof EventPayloadByType; +export type EventPayloadByType = typeof HOST_EVENT_PAYLOADS; -const HOST_EVENT_TYPES = [ - 'preview.ready.updated', - 'stage.snapshot.updated', - 'preview.event.fast-preview-timeout', -] as const satisfies readonly HostEventType[]; +export type HostEventType = keyof EventPayloadByType & string; + +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 PreviewQueryResponsePayloadByType { - 'preview.query.reference-box': ReferenceBoxQueryResultPayload; - 'preview.query.base-transform': BaseTransformQueryResultPayload; - 'preview.query.target-transform': TargetTransformQueryResultPayload; -} +export const SESSION_REQUEST_TYPES = messageTypes(SESSION_REQUEST_PAYLOADS); + +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 interface PreviewResponsePayloadByType - extends PreviewCommandResponsePayloadByType, - PreviewQueryResponsePayloadByType {} +export type PreviewCommandResponsePayloadByType = Record; + +export const PREVIEW_QUERY_RESPONSE_PAYLOADS = definePayloadMap({ + 'preview.query.reference-box': payload(), + 'preview.query.base-transform': payload(), + 'preview.query.target-transform': payload(), +}); + +export type PreviewQueryResponsePayloadByType = typeof PREVIEW_QUERY_RESPONSE_PAYLOADS; + +export type PreviewResponsePayloadByType = PreviewCommandResponsePayloadByType & PreviewQueryResponsePayloadByType; export type PreviewResponseType = PreviewRequestType; -export type PreviewRequestErrorCode = 'bad-request' | 'unsupported-request-type' | 'internal-error'; +export const PREVIEW_RESPONSE_TYPES = PREVIEW_REQUEST_TYPES satisfies readonly PreviewResponseType[]; -const PREVIEW_REQUEST_ERROR_CODES = [ - 'bad-request', - 'unsupported-request-type', - 'internal-error', -] as const satisfies readonly PreviewRequestErrorCode[]; +export const SESSION_RESPONSE_PAYLOADS = definePayloadMap({ + [SESSION_REGISTER_PREVIEW_TYPE]: payload(), +}); -interface SessionResponsePayloadByType { - 'session.register-preview': EmptyObject; -} +export type SessionResponsePayloadByType = typeof SESSION_RESPONSE_PAYLOADS; -interface ResponsePayloadByType extends SessionResponsePayloadByType, PreviewResponsePayloadByType {} +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'; @@ -253,29 +342,34 @@ export interface PreviewRequestErrorEnvelope { kind: 'error'; type: TType; requestId: string; - error: { - code: PreviewRequestErrorCode; - message?: string; - }; + error: PreviewRequestError; } -type EventEnvelopeByType = { +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 = +export type PreviewRequestErrorEnvelopeByType = { + [K in TType]: PreviewRequestErrorEnvelope; +}[TType]; + +export type KnownProtocolEnvelope = | EventEnvelopeByType | RequestEnvelopeByType | ResponseEnvelopeByType - | PreviewRequestErrorEnvelope; + | PreviewRequestErrorEnvelopeByType; + +export type ProtocolEnvelope = KnownProtocolEnvelope; export function createEventEnvelope( type: TType, @@ -317,14 +411,8 @@ export function createResponseEnvelope( type: TType, requestId: string, - code: PreviewRequestErrorCode, - message?: string, + error: PreviewRequestError, ): PreviewRequestErrorEnvelope { - const error: PreviewRequestErrorEnvelope['error'] = { code }; - if (message) { - error.message = message; - } - return { kind: 'error', type, @@ -337,9 +425,9 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function hasEnvelopeShape( +function hasPayloadEnvelopeShape( value: unknown, - kind: Exclude, + kind: 'event' | 'request' | 'response', ): value is Record { return ( isRecord(value) && @@ -354,23 +442,59 @@ function isMessageType(value: unknown, acceptedTypes: read return typeof value === 'string' && acceptedTypes.includes(value as TType); } -function isPreviewRequestErrorCode(value: unknown): value is PreviewRequestErrorCode { +export function isSessionRequestType(value: unknown): value is SessionRequestType { + return isMessageType(value, SESSION_REQUEST_TYPES); +} + +export function isPreviewCommandType(value: unknown): value is PreviewCommandType { + return isMessageType(value, PREVIEW_COMMAND_TYPES); +} + +export function isPreviewQueryType(value: unknown): value is PreviewQueryType { + return isMessageType(value, PREVIEW_QUERY_TYPES); +} + +export function isPreviewRequestType(value: unknown): value is PreviewRequestType { + return isMessageType(value, PREVIEW_REQUEST_TYPES); +} + +export function isRequestType(value: unknown): value is RequestType { + return isMessageType(value, 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); } -function isEventEnvelope(value: unknown): value is EventEnvelope { - return hasEnvelopeShape(value, 'event'); +export function isEventEnvelope(value: unknown): value is EventEnvelope { + return hasPayloadEnvelopeShape(value, 'event'); } -function isRequestEnvelope(value: unknown): value is RequestEnvelope { - return hasEnvelopeShape(value, 'request'); +export function isRequestEnvelope(value: unknown): value is RequestEnvelope { + return hasPayloadEnvelopeShape(value, 'request'); } -function isResponseEnvelope(value: unknown): value is ResponseEnvelope { - return hasEnvelopeShape(value, 'response'); +export function isResponseEnvelope(value: unknown): value is ResponseEnvelope { + return hasPayloadEnvelopeShape(value, 'response'); } -function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestErrorEnvelope { +export function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestErrorEnvelope { return ( isRecord(value) && value.kind === 'error' && @@ -382,7 +506,7 @@ function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestE ); } -export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { +export function isAnyProtocolEnvelope(value: unknown): value is AnyProtocolEnvelope { return ( isEventEnvelope(value) || isRequestEnvelope(value) || @@ -391,28 +515,16 @@ export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { ); } -export function isPreviewCommandType(value: unknown): value is PreviewCommandType { - return isMessageType(value, PREVIEW_COMMAND_TYPES); -} - -export function isPreviewQueryType(value: unknown): value is PreviewQueryType { - return isMessageType(value, PREVIEW_QUERY_TYPES); -} - -export function isPreviewRequestType(value: unknown): value is PreviewRequestType { - return isMessageType(value, PREVIEW_REQUEST_TYPES); -} - -export function isPreviewResponseType(value: unknown): value is PreviewResponseType { - return isPreviewRequestType(value); +export function isHostEventEnvelope(value: unknown): value is EventEnvelopeByType { + return isEventEnvelope(value) && isHostEventType(value.type); } -export function isHostEventType(value: unknown): value is HostEventType { - return isMessageType(value, HOST_EVENT_TYPES); +export function isKnownRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isRequestType(value.type); } -export function isHostEventEnvelope(value: unknown): value is EventEnvelopeByType { - return isEventEnvelope(value) && isHostEventType(value.type); +export function isSessionRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isSessionRequestType(value.type); } export function isPreviewCommandRequestEnvelope(value: unknown): value is RequestEnvelopeByType { @@ -423,6 +535,25 @@ export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelo 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)) + ); +} From 758fc5048316c34b8f796a7deeac32befe7255f5 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:42:42 +0800 Subject: [PATCH 05/12] fix: Invalid pending target transform baseline --- .../Core/util/syncWithEditor/previewSyncRuntime.ts | 1 + .../runtime/targetTransformBaseline.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 4fdb63a9a..ab247b93d 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -329,6 +329,7 @@ export const startPreviewSyncRuntime = () => { }; const handleSetEffect = (payload: SetEffectPayload) => { + targetTransformBaselines.invalidatePendingRevision(); const newTransform = mergeSetEffectPreviewTransform(getSetEffectBaseline(payload.target), payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); stageStateManager.updateEffectAndCommit({ diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index 4619c5b66..f284f747c 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -45,6 +45,7 @@ interface TargetTransformBaselineManager { acceptRevision: (revision: string) => void; publishSnapshot: (revision: string, stageState: IStageState) => void; failRevision: (revision: string) => void; + invalidatePendingRevision: () => void; invalidateCurrentRevision: () => void; queryTargetTransform: (target: string, revision: string) => TargetTransformQueryResultPayload; } @@ -108,6 +109,17 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, + invalidatePendingRevision() { + if (revisionState.status !== 'pending') { + return; + } + + revisionState = { + status: 'unavailable', + revision: revisionState.revision, + }; + }, + invalidateCurrentRevision() { if (revisionState.status === 'none') { return; From 4505d150c66e753823d42607fee37960179caf02 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:57:36 +0800 Subject: [PATCH 06/12] fix: adjusted the sync timing of the editor preview transform baseline --- .../Core/controller/gamePlay/nextSentence.ts | 10 ++-- .../controller/gamePlay/scriptExecutor.ts | 24 +++++++-- .../util/syncWithEditor/previewSyncRuntime.ts | 35 ++++++++----- .../runtime/previewSyncSceneCommand.ts | 51 ++++++++++++++----- .../runtime/targetTransformBaseline.ts | 35 +++++++++---- .../webgal/src/types/editorPreviewProtocol.ts | 12 ++--- 6 files changed, 117 insertions(+), 50 deletions(-) diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index ab226a8c9..94a7ab77d 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'; @@ -32,7 +32,11 @@ export const preForward = () => { /** * 执行一条语句或由 -next 连接的语句序列,只修改演算状态并收集演出。 */ -export const forward = () => { +export interface ForwardOptions { + scriptExecution?: ScriptExecutionOptions; +} + +export const forward = (options: ForwardOptions = {}) => { if (WebGAL.sceneManager.lockSceneWrite) { logger.warn('forward 被场景切换阻塞!'); return false; @@ -47,7 +51,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 8bfbb5cd8..a5be2e53d 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -15,6 +15,15 @@ import { prefetchCurrentSceneByProgress } from '@/Core/util/prefetcher/progressP 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; @@ -39,7 +48,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; @@ -59,8 +68,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 => { @@ -104,7 +118,7 @@ export const scriptExecutor = (depth = 0) => { if (!runThis) { logger.warn('不满足条件,跳过本句!'); WebGAL.sceneManager.sceneData.currentSentenceId++; - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } @@ -115,7 +129,7 @@ export const scriptExecutor = (depth = 0) => { logger.warn(`未找到标签 ${currentScript.content},跳过 jumpLabel`); WebGAL.sceneManager.sceneData.currentSentenceId++; } - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } @@ -148,7 +162,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/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index ab247b93d..341aa7efb 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -52,9 +52,9 @@ import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; import { - canPublishTargetTransformBaselineSnapshot, cloneBaseTransform, createTargetTransformBaselineManager, + isTargetTransformBaselineSyncSettled, } from './runtime/targetTransformBaseline'; let previewSyncRuntimeStarted = false; @@ -226,27 +226,34 @@ export const startPreviewSyncRuntime = () => { const handleSyncScene = (payload: SyncScenePayload) => { setEffectBaselines.clear(); - const { previewSyncRevision } = payload; - if (previewSyncRevision) { - targetTransformBaselines.acceptRevision(previewSyncRevision); + const { transformBaselineRevision } = payload; + if (transformBaselineRevision) { + targetTransformBaselines.acceptRevision(transformBaselineRevision); } else { targetTransformBaselines.invalidateCurrentRevision(); } executePreviewSyncSceneCommand(payload, { onFastPreviewTimeout: emitFastPreviewTimeout, - onSettled: (result) => { - if (!previewSyncRevision) { + onBeforeTargetScriptExecute: () => { + if (!transformBaselineRevision) { return; } - const canPublishSnapshot = canPublishTargetTransformBaselineSnapshot(result, payload); - if (!canPublishSnapshot) { - targetTransformBaselines.failRevision(previewSyncRevision); + targetTransformBaselines.captureSnapshot( + transformBaselineRevision, + stageStateManager.getCalculationStageState(), + ); + }, + onSettled: (result) => { + if (!transformBaselineRevision) { return; } - targetTransformBaselines.publishSnapshot(previewSyncRevision, stageStateManager.getCalculationStageState()); + const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload); + if (!isSyncSettled || !targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision)) { + targetTransformBaselines.failRevision(transformBaselineRevision); + } }, }); }; @@ -391,14 +398,14 @@ export const startPreviewSyncRuntime = () => { }), ); return; - case 'preview.query.target-transform': + case 'preview.query.transform-baseline': transport.send( createResponseEnvelope( - 'preview.query.target-transform', + 'preview.query.transform-baseline', envelope.requestId, - targetTransformBaselines.queryTargetTransform( + targetTransformBaselines.queryTransformBaseline( envelope.payload.target, - envelope.payload.previewSyncRevision, + envelope.payload.transformBaselineRevision, ), ), ); diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index 56095d7c6..cdd9a3ba1 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -8,11 +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 type { - FastPreviewTimeoutPayload, - SyncScenePayload, - SyncSceneSettleMode, -} from '@/types/editorPreviewProtocol'; +import type { FastPreviewTimeoutPayload, SyncScenePayload, SyncSceneSettleMode } from '@/types/editorPreviewProtocol'; import { applyPreviewDebugVariables } from './previewDebugVariables'; export const FAST_PREVIEW_MAX_DURATION_MS = 500; @@ -31,9 +27,14 @@ export type FastPreviewStopReason = 'target-reached' | 'timeout' | 'state-calcul export interface PreviewSyncSceneCommandCallbacks { onFastPreviewTimeout?: FastPreviewTimeoutEmitter; + onBeforeTargetScriptExecute?: () => void; onSettled?: (result: FastPreviewResult | null) => void; } +interface RunFastPreviewOptions { + onBeforeTargetScriptExecute?: () => void; +} + export function executePreviewSyncSceneCommand( { sceneName, sentenceId, debugVariables, settleMode = 'normal' }: SyncScenePayload, callbacks: PreviewSyncSceneCommandCallbacks = {}, @@ -60,7 +61,9 @@ export function executePreviewSyncSceneCommand( applyPreviewDebugVariables(debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - void runFastPreview(sentenceId, currentSceneName, callbacks.onFastPreviewTimeout, settleMode) + void runFastPreview(sentenceId, currentSceneName, callbacks.onFastPreviewTimeout, settleMode, { + onBeforeTargetScriptExecute: callbacks.onBeforeTargetScriptExecute, + }) .then((result) => { callbacks.onSettled?.(result); }) @@ -82,6 +85,7 @@ export async function runFastPreview( currentSceneName: string, onFastPreviewTimeout?: FastPreviewTimeoutEmitter, settleMode: SyncSceneSettleMode = 'normal', + options: RunFastPreviewOptions = {}, ): Promise { const fastPreviewStartTime = performance.now(); const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; @@ -92,13 +96,40 @@ export async function runFastPreview( 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) { @@ -125,11 +156,7 @@ export async function runFastPreview( 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; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index f284f747c..fa43311c0 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -2,7 +2,7 @@ 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 { TargetTransformQueryResultPayload } from '@/types/editorPreviewProtocol'; +import type { TransformBaselineQueryResultPayload } from '@/types/editorPreviewProtocol'; import type { FastPreviewResult } from './previewSyncSceneCommand'; const FIXED_TARGETS = new Set([ @@ -20,6 +20,7 @@ type BaselineRevisionState = | { status: 'pending'; revision: string; + snapshot?: TargetTransformBaselineSnapshot; } | { status: 'ready'; @@ -36,27 +37,28 @@ interface TargetTransformBaselineSnapshot { transformsByTarget: Map; } -interface TargetTransformBaselinePublishTarget { +interface TargetTransformBaselineSyncTarget { sceneName: string; sentenceId: number; } interface TargetTransformBaselineManager { acceptRevision: (revision: string) => void; - publishSnapshot: (revision: string, stageState: IStageState) => void; + captureSnapshot: (revision: string, stageState: IStageState) => void; + publishCapturedSnapshot: (revision: string) => boolean; failRevision: (revision: string) => void; invalidatePendingRevision: () => void; invalidateCurrentRevision: () => void; - queryTargetTransform: (target: string, revision: string) => TargetTransformQueryResultPayload; + queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; } export function cloneBaseTransform(): ITransform { return cloneDeep(baseTransform); } -export function canPublishTargetTransformBaselineSnapshot( +export function isTargetTransformBaselineSyncSettled( result: FastPreviewResult | null, - target: TargetTransformBaselinePublishTarget, + target: TargetTransformBaselineSyncTarget, ): boolean { return ( result !== null && @@ -86,18 +88,31 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, - publishSnapshot(revision, stageState) { + captureSnapshot(revision, stageState) { if (!isPendingRevision(revision)) { return; } revisionState = { - status: 'ready', + 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; @@ -131,7 +146,7 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, - queryTargetTransform(target, revision) { + queryTransformBaseline(target, revision) { if (!isLatestRevision(revision)) { return { status: 'unavailable', @@ -176,7 +191,7 @@ function createSnapshot(stageState: IStageState): TargetTransformBaselineSnapsho }; } -function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TargetTransformQueryResultPayload { +function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TransformBaselineQueryResultPayload { if (!snapshot.knownTargets.has(target)) { return { status: 'unavailable', diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index c3f4af6f4..b76546755 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -102,7 +102,7 @@ export interface SyncScenePayload { sceneName: string; sentenceId: number; debugVariables?: DebugVariable[]; - previewSyncRevision?: string; + transformBaselineRevision?: string; settleMode?: SyncSceneSettleMode; } @@ -137,9 +137,9 @@ export interface ReferenceBoxQueryPayload { export type BaseTransformQueryPayload = EmptyObject; -export interface TargetTransformQueryPayload { +export interface TransformBaselineQueryPayload { target: string; - previewSyncRevision: string; + transformBaselineRevision: string; } export interface ReferenceBox { @@ -169,7 +169,7 @@ export interface BaseTransformQueryResultPayload { baseTransform: Transform; } -export type TargetTransformQueryResultPayload = +export type TransformBaselineQueryResultPayload = | { status: 'ready'; transform: Transform; @@ -201,7 +201,7 @@ 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.target-transform': payload(), + 'preview.query.transform-baseline': payload(), }); export type PreviewQueryPayloadByType = typeof PREVIEW_QUERY_PAYLOADS; @@ -279,7 +279,7 @@ export type PreviewCommandResponsePayloadByType = Record(), 'preview.query.base-transform': payload(), - 'preview.query.target-transform': payload(), + 'preview.query.transform-baseline': payload(), }); export type PreviewQueryResponsePayloadByType = typeof PREVIEW_QUERY_RESPONSE_PAYLOADS; From 2457b1509bcea775e77913e34cce55f79a1335ed Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:58:07 +0800 Subject: [PATCH 07/12] fix: stabilize set-effect preview baseline --- .../util/syncWithEditor/previewSyncRuntime.ts | 19 ++++++++++----- .../runtime/targetTransformBaseline.ts | 24 ++++++++++++++++--- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 341aa7efb..ea0457f65 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -321,23 +321,30 @@ export const startPreviewSyncRuntime = () => { setDebugTextReadMode(payload.isRead); }; - const getSetEffectBaseline = (target: string): ITransform => { + const getSetEffectBaseline = (target: string): ITransform | undefined => { const cachedBaseline = setEffectBaselines.get(target); if (cachedBaseline) { return cachedBaseline; } - const currentTransform = stageStateManager - .getCalculationStageState() - .effects.find((effect) => effect.target === target)?.transform; - const baseline = mergeSetEffectPreviewTransform(baseTransform, currentTransform); + const baselineOverride = targetTransformBaselines.getReadyTransformBaselineOverride(target); + if (baselineOverride === undefined) { + return undefined; + } + + const baseline = mergeSetEffectPreviewTransform(baseTransform, baselineOverride); setEffectBaselines.set(target, baseline); return baseline; }; const handleSetEffect = (payload: SetEffectPayload) => { targetTransformBaselines.invalidatePendingRevision(); - const newTransform = mergeSetEffectPreviewTransform(getSetEffectBaseline(payload.target), payload.transform); + const baseline = getSetEffectBaseline(payload.target); + if (!baseline) { + return; + } + + const newTransform = mergeSetEffectPreviewTransform(baseline, payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); stageStateManager.updateEffectAndCommit({ target: payload.target, diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index fa43311c0..4eeee0c0b 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -49,6 +49,7 @@ interface TargetTransformBaselineManager { failRevision: (revision: string) => void; invalidatePendingRevision: () => void; invalidateCurrentRevision: () => void; + getReadyTransformBaselineOverride: (target: string) => ITransform | undefined; queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; } @@ -146,6 +147,14 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, + getReadyTransformBaselineOverride(target) { + if (revisionState.status !== 'ready') { + return undefined; + } + + return getSnapshotTransformOverride(revisionState.snapshot, target); + }, + queryTransformBaseline(target, revision) { if (!isLatestRevision(revision)) { return { @@ -192,19 +201,28 @@ function createSnapshot(stageState: IStageState): TargetTransformBaselineSnapsho } function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TransformBaselineQueryResultPayload { - if (!snapshot.knownTargets.has(target)) { + const transform = getSnapshotTransformOverride(snapshot, target); + if (transform === undefined) { return { status: 'unavailable', }; } - const transform = snapshot.transformsByTarget.get(target); return { status: 'ready', - transform: transform ? createSparseTransformOverride(transform, baseTransform) : {}, + 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 = {}; From b3ecb287108421f710164ac94bf86e8b88ff452c Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:36:25 +0800 Subject: [PATCH 08/12] fix: correct preview set-effect transform baseline --- .../Core/util/syncWithEditor/previewSyncRuntime.ts | 14 ++++---------- .../runtime/targetTransformBaseline.ts | 12 ------------ 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index ea0457f65..aad97a9ea 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -253,7 +253,10 @@ export const startPreviewSyncRuntime = () => { const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload); if (!isSyncSettled || !targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision)) { targetTransformBaselines.failRevision(transformBaselineRevision); + return; } + + setEffectBaselines.clear(); }, }); }; @@ -321,29 +324,20 @@ export const startPreviewSyncRuntime = () => { setDebugTextReadMode(payload.isRead); }; - const getSetEffectBaseline = (target: string): ITransform | undefined => { + const getSetEffectBaseline = (target: string): ITransform => { const cachedBaseline = setEffectBaselines.get(target); if (cachedBaseline) { return cachedBaseline; } const baselineOverride = targetTransformBaselines.getReadyTransformBaselineOverride(target); - if (baselineOverride === undefined) { - return undefined; - } - const baseline = mergeSetEffectPreviewTransform(baseTransform, baselineOverride); setEffectBaselines.set(target, baseline); return baseline; }; const handleSetEffect = (payload: SetEffectPayload) => { - targetTransformBaselines.invalidatePendingRevision(); const baseline = getSetEffectBaseline(payload.target); - if (!baseline) { - return; - } - const newTransform = mergeSetEffectPreviewTransform(baseline, payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); stageStateManager.updateEffectAndCommit({ diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts index 4eeee0c0b..38729b821 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -47,7 +47,6 @@ interface TargetTransformBaselineManager { captureSnapshot: (revision: string, stageState: IStageState) => void; publishCapturedSnapshot: (revision: string) => boolean; failRevision: (revision: string) => void; - invalidatePendingRevision: () => void; invalidateCurrentRevision: () => void; getReadyTransformBaselineOverride: (target: string) => ITransform | undefined; queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; @@ -125,17 +124,6 @@ export function createTargetTransformBaselineManager(): TargetTransformBaselineM }; }, - invalidatePendingRevision() { - if (revisionState.status !== 'pending') { - return; - } - - revisionState = { - status: 'unavailable', - revision: revisionState.revision, - }; - }, - invalidateCurrentRevision() { if (revisionState.status === 'none') { return; From 3eb259052edb4c2263aee6469d78427f0b51eb85 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Wed, 24 Jun 2026 00:59:33 +0800 Subject: [PATCH 09/12] fix: wait for reference box geometry before replying --- .../controller/stage/pixi/PixiController.ts | 42 ++++++++++++++++++- .../src/Core/controller/stage/pixi/spine.ts | 2 + .../util/syncWithEditor/previewSyncRuntime.ts | 9 +++- .../handlers/referenceBoxQueryHandler.ts | 25 +++++++---- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 34e98c52d..992e9a701 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -121,6 +121,7 @@ export default class PixiStage { private isRenderPending = false; // 更新 ticker 状态的防抖标记 private isTickerUpdatePending = false; + private referenceBoxWaiters = new Map void>>(); /** * 暂时没用上,以后可能用 @@ -468,6 +469,7 @@ export default class PixiStage { // 挂载 thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); this.requestRender(); } }, 0); @@ -552,6 +554,7 @@ export default class PixiStage { thisBgContainer.setBaseY(this.stageHeight / 2); thisBgContainer.pivot.set(0, this.stageHeight / 2); thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); }); } }, 0); @@ -610,7 +613,6 @@ export default class PixiStage { sourceType: sourceExt === 'gif' ? 'gif' : 'img', sourceExt, }); - // 完成图片加载后执行的函数 const setup = () => { // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! @@ -647,6 +649,7 @@ export default class PixiStage { } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); this.requestRender(); } }, 0); @@ -808,6 +811,7 @@ export default class PixiStage { Live2D.SoundManager.volume = 0; // @ts-ignore thisFigureContainer.addChild(model); + instance.notifyTargetReferenceBoxChanged(key); }); })(); } @@ -1038,6 +1042,29 @@ export default class PixiStage { }); } + 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); } @@ -1065,6 +1092,7 @@ export default class PixiStage { } bgSprite.pixiContainer = null; this.figureObjects.splice(indexFig, 1); + this.notifyTargetReferenceBoxChanged(key); } if (indexBg >= 0) { const bgSprite = this.backgroundObjects[indexBg]; @@ -1078,6 +1106,7 @@ export default class PixiStage { } bgSprite.pixiContainer = null; this.backgroundObjects.splice(indexBg, 1); + this.notifyTargetReferenceBoxChanged(key); } // /** // * 删掉相关 Effects,因为已经移除了 @@ -1150,6 +1179,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/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/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index aad97a9ea..4dd3ddb09 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -390,7 +390,14 @@ export const startPreviewSyncRuntime = () => { const handlePreviewQuery = (envelope: PreviewQueryEnvelope) => { switch (envelope.type) { case 'preview.query.reference-box': - transport.send(handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview)); + void handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview) + .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( diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts index cc621a772..e55e1da84 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -7,13 +7,16 @@ import type { interface ReferenceBoxQueryStage { queryTargetReferenceBox(target: string): ReferenceBoxQueryResultPayload; + waitForTargetReferenceBox(target: string, timeoutMs: number): Promise; } -export function handleReferenceBoxQuery( +const REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS = 300; + +export async function handleReferenceBoxQuery( request: RequestEnvelopeByType<'preview.query.reference-box'>, pixiStage: ReferenceBoxQueryStage | null | undefined, isSupported = true, -): ResponseEnvelopeByType<'preview.query.reference-box'> { +): Promise> { if (!isSupported) { return createResponseEnvelope('preview.query.reference-box', request.requestId, { target: request.payload.target, @@ -24,11 +27,19 @@ export function handleReferenceBoxQuery( const { target } = request.payload; - const result = pixiStage?.queryTargetReferenceBox(target) ?? { - target, - status: 'unsupported' as const, - reason: 'Pixi stage 不可用', - }; + 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); } From b2d1068b82c2b8e0cb1118c6fc5daa7fcd5c8140 Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:21:17 +0800 Subject: [PATCH 10/12] refactor: clean up editor preview transform queries --- .../src/Core/controller/stage/pixi/PixiController.ts | 2 +- .../Core/util/syncWithEditor/previewSyncRuntime.ts | 12 +----------- .../runtime/handlers/referenceBoxQueryHandler.ts | 9 --------- .../runtime/previewSyncSceneCommand.ts | 1 + 4 files changed, 3 insertions(+), 21 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 992e9a701..202bf96d1 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -14,7 +14,7 @@ import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; -import { queryStageObjectReferenceBox, QueryTargetReferenceBoxResult } from './referenceBox'; +import { queryStageObjectReferenceBox, type QueryTargetReferenceBoxResult } from './referenceBox'; export interface IAnimationObject { setStartState: Function; diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 4dd3ddb09..f20681995 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -5,7 +5,6 @@ import { createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, isAnyProtocolEnvelope, - isPreviewCommandType, isPreviewQueryType, isPreviewRequestEnvelope, } from '@/types/editorPreviewProtocol'; @@ -99,7 +98,6 @@ export const startPreviewSyncRuntime = () => { let registered = false; let pendingRegisterRequestId: string | null = null; let pendingRegisterContext: RegisterPreviewLogContext | null = null; - let isEmbeddedPreview = false; let lastPublishedSceneName: string | null = null; let lastPublishedSentenceId: number | null = null; let lastPublishedStageState: StageStateSnapshot | null = null; @@ -130,7 +128,6 @@ export const startPreviewSyncRuntime = () => { registered = false; pendingRegisterRequestId = null; pendingRegisterContext = null; - isEmbeddedPreview = false; lastPublishedSceneName = null; lastPublishedSentenceId = null; lastPublishedStageState = null; @@ -212,7 +209,6 @@ export const startPreviewSyncRuntime = () => { pendingRegisterRequestId = null; pendingRegisterContext = null; registered = true; - isEmbeddedPreview = Boolean(registeredPreviewContext?.embeddedLaunchId); publishReady(); publishStageSnapshot(true); }; @@ -390,7 +386,7 @@ export const startPreviewSyncRuntime = () => { const handlePreviewQuery = (envelope: PreviewQueryEnvelope) => { switch (envelope.type) { case 'preview.query.reference-box': - void handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage, isEmbeddedPreview) + void handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage) .then((response) => { transport.send(response); }) @@ -438,12 +434,6 @@ export const startPreviewSyncRuntime = () => { return; } - if (!isPreviewCommandType(envelope.type)) { - logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); - sendRequestError(envelope, 'unsupported-request-type', UNSUPPORTED_REQUEST_MESSAGE); - return; - } - const responsePayload = handlePreviewCommand(envelope.type, envelope.payload); transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); }; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts index e55e1da84..5e0d74308 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -15,16 +15,7 @@ const REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS = 300; export async function handleReferenceBoxQuery( request: RequestEnvelopeByType<'preview.query.reference-box'>, pixiStage: ReferenceBoxQueryStage | null | undefined, - isSupported = true, ): Promise> { - if (!isSupported) { - return createResponseEnvelope('preview.query.reference-box', request.requestId, { - target: request.payload.target, - status: 'unsupported', - reason: '当前预览不支持 transform overlay reference box 查询', - }); - } - const { target } = request.payload; if (!pixiStage) { diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index cdd9a3ba1..407273fb9 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -137,6 +137,7 @@ export async function runFastPreview( } if (!isForwarded && !awaitedSceneWrite) { + stopReason = 'no-progress'; break; } From 1b5cb8f16bb6c53e26314054a8f4ea9a6d62316d Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Thu, 25 Jun 2026 06:40:42 +0800 Subject: [PATCH 11/12] fix: correct rotated reference bounds --- .../stage/pixi/WebGALPixiContainer.ts | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts index c815db6fb..0dead6a9f 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts @@ -331,18 +331,29 @@ const PROPERTY_CONFIGS: Record = { function getChildReferenceBounds(child: PIXI.DisplayObject, containerPivot: PIXI.IPointData): PIXI.Rectangle { child.transform.updateLocalTransform(); const bounds = child.getLocalBounds(); - const transform = child.transform.localTransform; - const left = bounds.x * child.scale.x + transform.tx - containerPivot.x; - const right = (bounds.x + bounds.width) * child.scale.x + transform.tx - containerPivot.x; - const top = bounds.y * child.scale.y + transform.ty - containerPivot.y; - const bottom = (bounds.y + bounds.height) * child.scale.y + transform.ty - containerPivot.y; - - return new PIXI.Rectangle( - Math.min(left, right), - Math.min(top, bottom), - Math.abs(right - left), - Math.abs(bottom - top), - ); + 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 { From 85954a276cc182737d0cfce61fa160188378ca1e Mon Sep 17 00:00:00 2001 From: Akirami <66513481+A-kirami@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:52:07 +0800 Subject: [PATCH 12/12] perf: avoid full stage commits for set-effect preview updates --- .../controller/stage/pixi/PixiController.ts | 19 +------- .../stage/pixi/stageEffectTransform.ts | 45 +++++++++++++++++++ .../stage/pixi/syncPixiStageState.ts | 30 ++++++------- .../util/syncWithEditor/previewSyncRuntime.ts | 6 +++ .../webgal/src/types/editorPreviewProtocol.ts | 3 ++ 5 files changed, 71 insertions(+), 32 deletions(-) create mode 100644 packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index 202bf96d1..da395ffcd 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -8,13 +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; @@ -70,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); } /** 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 f20681995..79f400ea5 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -39,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 { @@ -336,6 +337,11 @@ export const startPreviewSyncRuntime = () => { 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, diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index b76546755..a18ae9c8d 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -118,9 +118,12 @@ export interface RunSnippetPayload { export type ReloadTemplatesPayload = EmptyObject; +export type SetEffectPhase = 'preview' | 'commit'; + export interface SetEffectPayload { target: string; transform?: Transform; + phase?: SetEffectPhase; } export interface SetFontOptimizationPayload {