diff --git a/packages/webgal/public/game/config.txt b/packages/webgal/public/game/config.txt index 972b4e06c..f363af31d 100644 --- a/packages/webgal/public/game/config.txt +++ b/packages/webgal/public/game/config.txt @@ -4,3 +4,4 @@ Title_img:WebGAL_New_Enter_Image.webp; Title_bgm:s_Title.mp3; Game_Logo:WebGalEnter.webp; Enable_Appreciation:true; +Enable_Editor_Sync:true; diff --git a/packages/webgal/src/Core/initializeScript.test.ts b/packages/webgal/src/Core/initializeScript.test.ts new file mode 100644 index 000000000..9a265377a --- /dev/null +++ b/packages/webgal/src/Core/initializeScript.test.ts @@ -0,0 +1,284 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +interface ActiveModuleState { + logger: { + info: ReturnType; + error: ReturnType; + }; + infoFetcher: ReturnType; + assetSetter: ReturnType; + sceneFetcher: ReturnType; + sceneParser: ReturnType; + bindExtraFunc: ReturnType; + startPreviewSyncRuntime: ReturnType; + uniqWith: ReturnType; + scenePrefetcher: ReturnType; + PixiStage: ReturnType; + axiosGet: ReturnType; + loadTemplate: ReturnType; + WebGAL: Record; +} + +interface InitializeScriptHarness { + infoFetcher: ReturnType; + startPreviewSyncRuntime: ReturnType; + resolveGameConfig: (gameConfig: Record) => void; + sceneFetcherResolve: (rawScene: string) => void; + sceneParser: ReturnType; + scenePrefetcher: ReturnType; + WebGAL: Record; +} + +let activeModuleState: ActiveModuleState | null = null; + +function getActiveModuleState(): ActiveModuleState { + if (activeModuleState === null) { + throw new Error('Expected initializeScript test harness to initialize active module state.'); + } + + return activeModuleState; +} + +vi.doMock('./util/logger', () => ({ + get logger() { + return getActiveModuleState().logger; + }, +})); + +vi.doMock('./util/coreInitialFunction/infoFetcher', () => ({ + get infoFetcher() { + return getActiveModuleState().infoFetcher; + }, +})); + +vi.doMock('./util/gameAssetsAccess/assetSetter', () => ({ + get assetSetter() { + return getActiveModuleState().assetSetter; + }, + fileType: { + scene: 'scene', + }, +})); + +vi.doMock('./controller/scene/sceneFetcher', () => ({ + get sceneFetcher() { + return getActiveModuleState().sceneFetcher; + }, +})); + +vi.doMock('./parser/sceneParser', () => ({ + get sceneParser() { + return getActiveModuleState().sceneParser; + }, +})); + +vi.doMock('@/Core/util/coreInitialFunction/bindExtraFunc', () => ({ + get bindExtraFunc() { + return getActiveModuleState().bindExtraFunc; + }, +})); + +vi.doMock('@/Core/util/syncWithEditor/previewSyncRuntime', () => ({ + get startPreviewSyncRuntime() { + return getActiveModuleState().startPreviewSyncRuntime; + }, +})); + +vi.doMock('lodash/uniqWith', () => ({ + default: (...args: unknown[]) => getActiveModuleState().uniqWith(...args), +})); + +vi.doMock('./util/prefetcher/scenePrefetcher', () => ({ + get scenePrefetcher() { + return getActiveModuleState().scenePrefetcher; + }, +})); + +vi.doMock('@/Core/controller/stage/pixi/PixiController', () => ({ + default: getActiveModuleState().PixiStage, +})); + +vi.doMock('axios', () => ({ + default: { + get: (...args: unknown[]) => getActiveModuleState().axiosGet(...args), + }, +})); + +vi.doMock('@/config/info', () => ({ + __INFO: { + version: 'test-version', + }, +})); + +vi.doMock('@/Core/WebGAL', () => ({ + get WebGAL() { + return getActiveModuleState().WebGAL; + }, +})); + +vi.doMock('@/Core/util/coreInitialFunction/templateLoader', () => ({ + get loadTemplate() { + return getActiveModuleState().loadTemplate; + }, +})); + +function createMockDocument() { + const headElement = { + appendChild: vi.fn(), + }; + + return { + createElement: vi.fn(() => ({ + type: '', + rel: '', + href: '', + })), + getElementsByTagName: vi.fn(() => [headElement]), + }; +} + +async function flushMicrotasks() { + for (let index = 0; index < 5; index += 1) { + await Promise.resolve(); + } +} + +async function setupInitializeScriptHarness(): Promise { + vi.resetModules(); + + const mockDocument = createMockDocument(); + let sceneFetcherResolve!: (rawScene: string) => void; + const scenePromise = new Promise((resolve) => { + sceneFetcherResolve = resolve; + }); + let resolveGameConfig!: (gameConfig: Record) => void; + const gameConfigPromise = new Promise>((resolve) => { + resolveGameConfig = resolve; + }); + const parsedScene = { + sceneName: 'start.txt', + subSceneList: ['scene/a.txt', 'scene/a.txt', 'scene/b.txt'], + }; + + const infoFetcher = vi.fn(() => gameConfigPromise); + const assetSetter = vi.fn(() => 'asset://start.txt'); + const sceneFetcher = vi.fn(() => scenePromise); + const sceneParser = vi.fn(() => parsedScene); + const bindExtraFunc = vi.fn(); + const startPreviewSyncRuntime = vi.fn(); + const uniqWith = vi.fn((list: string[]) => Array.from(new Set(list))); + const scenePrefetcher = vi.fn(); + const PixiStage = vi.fn(function MockPixiStage(this: Record) { + this.kind = 'pixi-stage'; + }); + const axiosGet = vi.fn(async () => ({ + data: [], + })); + const loadTemplate = vi.fn(); + const WebGAL = { + sceneManager: { + sceneData: { + currentScene: null, + }, + settledScenes: [] as string[], + }, + gameplay: { + pixiStage: null, + }, + animationManager: { + addAnimation: vi.fn(), + }, + }; + + activeModuleState = { + logger: { + info: vi.fn(), + error: vi.fn(), + }, + infoFetcher, + assetSetter, + sceneFetcher, + sceneParser, + bindExtraFunc, + startPreviewSyncRuntime, + uniqWith, + scenePrefetcher, + PixiStage, + axiosGet, + loadTemplate, + WebGAL, + }; + + vi.stubGlobal('window', { + __WEBGAL_DEVICE_INFO__: { + isIOS: false, + }, + innerWidth: 1920, + innerHeight: 1080, + }); + vi.stubGlobal('document', mockDocument); + vi.stubGlobal('alert', vi.fn()); + + const { initializeScript } = await import('./initializeScript'); + initializeScript(); + + return { + infoFetcher, + startPreviewSyncRuntime, + resolveGameConfig, + sceneFetcherResolve, + sceneParser, + scenePrefetcher, + WebGAL, + }; +} + +describe('initializeScript preview sync bootstrap wiring', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + activeModuleState = null; + }); + + it('starts preview sync only after config enables it and the initial scene is ready', async () => { + const harness = await setupInitializeScriptHarness(); + + expect(harness.infoFetcher).toHaveBeenCalledWith('./game/config.txt'); + expect(harness.startPreviewSyncRuntime).not.toHaveBeenCalled(); + + harness.resolveGameConfig({ + Enable_Editor_Sync: true, + }); + await flushMicrotasks(); + + expect(harness.startPreviewSyncRuntime).not.toHaveBeenCalled(); + + harness.sceneFetcherResolve('; start scene'); + await flushMicrotasks(); + + expect(harness.sceneParser).toHaveBeenCalledWith('; start scene', 'start.txt', 'asset://start.txt'); + expect(harness.WebGAL.sceneManager.sceneData.currentScene).toEqual({ + sceneName: 'start.txt', + subSceneList: ['scene/a.txt', 'scene/a.txt', 'scene/b.txt'], + }); + expect(harness.scenePrefetcher).toHaveBeenCalledWith(['scene/a.txt', 'scene/b.txt']); + expect(harness.startPreviewSyncRuntime).toHaveBeenCalledTimes(1); + }); + + it('skips preview sync startup when Enable_Editor_Sync is not explicitly enabled', async () => { + const harness = await setupInitializeScriptHarness(); + + harness.resolveGameConfig({ + Enable_Editor_Sync: false, + }); + harness.sceneFetcherResolve('; start scene'); + await flushMicrotasks(); + + expect(harness.startPreviewSyncRuntime).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index a1b17960c..e13f72cb8 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -7,7 +7,7 @@ import { assetSetter, fileType } from './util/gameAssetsAccess/assetSetter'; import { sceneFetcher } from './controller/scene/sceneFetcher'; import { sceneParser } from './parser/sceneParser'; import { bindExtraFunc } from '@/Core/util/coreInitialFunction/bindExtraFunc'; -import { webSocketFunc } from '@/Core/util/syncWithEditor/webSocketFunc'; +import { startPreviewSyncRuntime } from '@/Core/util/syncWithEditor/previewSyncRuntime'; import uniqWith from 'lodash/uniqWith'; import { scenePrefetcher } from './util/prefetcher/scenePrefetcher'; import PixiStage from '@/Core/controller/stage/pixi/PixiController'; @@ -44,12 +44,10 @@ export const initializeScript = (): void => { loadStyle('./game/userStyleSheet.css'); // 获得 user Animation getUserAnimation(); - // 获取游戏信息 - infoFetcher('./game/config.txt'); // 获取start场景 const sceneUrl: string = assetSetter('start.txt', fileType.scene); // 场景写入到运行时 - sceneFetcher(sceneUrl).then((rawScene) => { + const initialSceneReady = sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); // 开始场景的预加载 const subSceneList = WebGAL.sceneManager.sceneData.currentScene.subSceneList; @@ -57,6 +55,20 @@ export const initializeScript = (): void => { const subSceneListUniq = uniqWith(subSceneList); // 去重 scenePrefetcher(subSceneListUniq); }); + // 获取游戏信息 + const gameConfigReady = infoFetcher('./game/config.txt'); + gameConfigReady + .then(async (gameConfig) => { + if (gameConfig.Enable_Editor_Sync !== true) { + return; + } + + await initialSceneReady; + startPreviewSyncRuntime(); + }) + .catch((error) => { + logger.error('启动编辑器同步 V1 runtime 失败', error); + }); /** * 启动Pixi */ @@ -79,7 +91,6 @@ export const initializeScript = (): void => { * 绑定工具函数 */ bindExtraFunc(); - webSocketFunc(); }; function loadStyle(url: string) { diff --git a/packages/webgal/src/Core/util/coreInitialFunction/bindExtraFunc.ts b/packages/webgal/src/Core/util/coreInitialFunction/bindExtraFunc.ts index 6aa3c40d8..4e64a1968 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/bindExtraFunc.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/bindExtraFunc.ts @@ -1,5 +1,5 @@ -import { syncFast } from '@/Core/util/syncWithEditor/syncWithOrigine'; +import { fastForwardToSentence } from '@/Core/util/syncWithEditor/runtime/previewSyncSceneCommand'; export const bindExtraFunc = () => { - (window as any).JMP = syncFast; + (window as any).JMP = fastForwardToSentence; }; diff --git a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts index f49fdca9e..ccf3508ed 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts @@ -14,9 +14,9 @@ import { IGameVar } from '@/store/stageInterface'; * 获取游戏信息 * @param url 游戏信息路径 */ -export const infoFetcher = (url: string) => { +export const infoFetcher = (url: string): Promise => { const dispatch = webgalStore.dispatch; - axios.get(url).then(async (r) => { + return axios.get(url).then(async (r) => { let gameConfigRaw: string = r.data; let gameConfig = WebgalParser.parseConfig(gameConfigRaw); logger.info('获取到游戏信息', gameConfig); @@ -75,5 +75,7 @@ export const infoFetcher = (url: string) => { // @ts-expect-error renderPromiseResolve is a global variable window.renderPromiseResolve(); setStorage(); + + return gameConfigInit; }); }; diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.test.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.test.ts new file mode 100644 index 000000000..20e6ab1cf --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.test.ts @@ -0,0 +1,656 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createRequestEnvelope, createResponseEnvelope } from '../../../types/editorPreviewProtocol'; + +function createMockEventTarget() { + const listeners = new Map void>>(); + + return { + addEventListener: vi.fn((type: string, listener: (event: { type: string }) => void) => { + const typedListeners = listeners.get(type) ?? new Set<(event: { type: string }) => void>(); + typedListeners.add(listener); + listeners.set(type, typedListeners); + }), + removeEventListener: vi.fn((type: string, listener: (event: { type: string }) => void) => { + listeners.get(type)?.delete(listener); + }), + dispatchEvent(event: { type: string }) { + listeners.get(event.type)?.forEach((listener) => listener(event)); + return true; + }, + }; +} + +class MockWebSocket { + public static instances: MockWebSocket[] = []; + + public static reset() { + MockWebSocket.instances = []; + } + + public readonly url: string; + public readonly protocol: string; + public readyState = 0; + public onopen: ((event?: unknown) => void) | null = null; + public onmessage: ((event: { data: unknown }) => void) | null = null; + public onclose: ((event?: unknown) => void) | null = null; + public onerror: ((error?: unknown) => void) | null = null; + public sentMessages: string[] = []; + public closeCallCount = 0; + + public constructor(url: string, protocol: string) { + this.url = url; + this.protocol = protocol; + MockWebSocket.instances.push(this); + } + + public send(data: string) { + this.sentMessages.push(data); + } + + public close() { + this.closeCallCount += 1; + this.readyState = 3; + this.onclose?.(); + } + + public emitOpen() { + this.readyState = 1; + this.onopen?.(); + } + + public emitMessage(data: unknown) { + this.onmessage?.({ data }); + } + + public emitClose() { + this.readyState = 3; + this.onclose?.(); + } +} + +interface ActiveModuleState { + webgalStore: { + subscribe: ReturnType; + dispatch: ReturnType; + getState: ReturnType; + }; + WebGAL: Record; + setVisibility: ReturnType; + setFontOptimization: ReturnType; + executePreviewSyncSceneCommand: ReturnType; + requestEmbeddedLaunchId: ReturnType; + sceneParser: ReturnType; + webgalParserParse: ReturnType; + runScript: ReturnType; + nextSentence: ReturnType; + resetStage: ReturnType; + updateEffect: ReturnType; + loggerInfo: ReturnType; + loggerWarn: ReturnType; + loggerError: ReturnType; +} + +interface WebSocketRuntimeHarness { + dispatch: ReturnType; + emitStoreUpdate: () => void; + executePreviewSyncSceneCommand: ReturnType; + runScript: ReturnType; + socket: MockWebSocket; + stageState: { + effects: Array<{ + target: string; + transform: { + position: { x: number; y: number }; + scale: { x: number; y: number }; + alpha: number; + rotation: number; + }; + }>; + layers: Array<{ id: string }>; + }; + updateEffect: ReturnType; + webgalParserParse: ReturnType; + WebGAL: Record; +} + +let activeModuleState: ActiveModuleState | null = null; +let activeMockWindow: ReturnType | null = null; + +function getActiveModuleState(): ActiveModuleState { + if (activeModuleState === null) { + throw new Error('Expected active module state to be initialized before importing startPreviewSyncRuntime.'); + } + + return activeModuleState; +} + +vi.doMock('@/store/store', () => ({ + get webgalStore() { + return getActiveModuleState().webgalStore; + }, +})); + +vi.doMock('@/Core/WebGAL', () => ({ + get WebGAL() { + return getActiveModuleState().WebGAL; + }, +})); + +vi.doMock('@/store/GUIReducer', () => ({ + get setVisibility() { + return getActiveModuleState().setVisibility; + }, + get setFontOptimization() { + return getActiveModuleState().setFontOptimization; + }, +})); + +vi.doMock('@/store/guiInterface', () => ({})); +vi.doMock('@/Core/parser/sceneParser', () => ({ + get sceneParser() { + return getActiveModuleState().sceneParser; + }, + WebgalParser: { + get parse() { + return getActiveModuleState().webgalParserParse; + }, + }, +})); +vi.doMock('@/Core/controller/gamePlay/runScript', () => ({ + get runScript() { + return getActiveModuleState().runScript; + }, +})); +vi.doMock('@/Core/controller/gamePlay/nextSentence', () => ({ + get nextSentence() { + return getActiveModuleState().nextSentence; + }, +})); +vi.doMock('@/Core/controller/stage/resetStage', () => ({ + get resetStage() { + return getActiveModuleState().resetStage; + }, +})); +vi.doMock('@/Core/util/logger', () => ({ + get logger() { + return { + info: getActiveModuleState().loggerInfo, + warn: getActiveModuleState().loggerWarn, + error: getActiveModuleState().loggerError, + }; + }, +})); +vi.doMock('./runtime/previewSyncSceneCommand', () => ({ + get executePreviewSyncSceneCommand() { + return getActiveModuleState().executePreviewSyncSceneCommand; + }, +})); +vi.doMock('@/store/stageReducer', () => ({ + stageActions: { + get updateEffect() { + return getActiveModuleState().updateEffect; + }, + }, +})); +vi.doMock('@/store/stageInterface', () => ({ + baseTransform: { + position: { x: 0, y: 0 }, + scale: { x: 1, y: 1 }, + alpha: 1, + rotation: 0, + }, +})); +vi.doMock('./runtime/embeddedPreviewBootstrap', () => ({ + get requestEmbeddedLaunchId() { + return getActiveModuleState().requestEmbeddedLaunchId; + }, +})); + +function createMockWindow() { + const target = createMockEventTarget(); + return { + ...target, + location: { + protocol: 'http:', + hostname: '127.0.0.1', + port: '3000', + }, + }; +} + +function createMockDocument() { + const target = createMockEventTarget(); + return { + ...target, + visibilityState: 'visible' as 'visible' | 'hidden', + querySelector: vi.fn(() => null), + }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +async function setupWebSocketRuntimeHarness(): Promise { + vi.resetModules(); + MockWebSocket.reset(); + const mockWindow = createMockWindow(); + activeMockWindow = mockWindow; + const mockDocument = createMockDocument(); + + const subscribeListeners = new Set<() => void>(); + const dispatch = vi.fn(); + const stageState = { + effects: [] as WebSocketRuntimeHarness['stageState']['effects'], + layers: [{ id: 'layer-1' }], + }; + const webgalStore = { + subscribe: vi.fn((listener: () => void) => { + subscribeListeners.add(listener); + return () => { + subscribeListeners.delete(listener); + }; + }), + dispatch, + getState: vi.fn(() => ({ + stage: stageState, + })), + }; + + const WebGAL = { + gameKey: 'game-key-1', + sceneManager: { + sceneData: { + currentScene: { + sceneName: 'scene/start.txt', + }, + currentSentenceId: 7, + }, + }, + events: { + styleUpdate: { + emit: vi.fn(), + }, + }, + gameplay: { + pixiStage: { + removeAnimationByTargetKey: vi.fn(), + }, + }, + }; + + const executePreviewSyncSceneCommand = vi.fn(); + const requestEmbeddedLaunchId = vi.fn(async () => 'embedded-launch-1'); + const sceneParser = vi.fn(); + const webgalParserParse = vi.fn((snippet: string, sceneName: string, sceneUrl: string) => ({ + sceneName, + sceneUrl, + sceneContent: snippet, + sentenceList: [{ id: 'sentence-1' }, { id: 'sentence-2' }], + })); + const runScript = vi.fn(); + const nextSentence = vi.fn(); + const resetStage = vi.fn(); + const updateEffect = vi.fn((payload: unknown) => ({ type: 'stage/updateEffect', payload })); + + activeModuleState = { + webgalStore, + WebGAL, + setVisibility: vi.fn((payload: unknown) => ({ type: 'gui/setVisibility', payload })), + setFontOptimization: vi.fn((payload: unknown) => ({ type: 'gui/setFontOptimization', payload })), + executePreviewSyncSceneCommand, + requestEmbeddedLaunchId, + sceneParser, + webgalParserParse, + runScript, + nextSentence, + resetStage, + updateEffect, + loggerInfo: vi.fn(), + loggerWarn: vi.fn(), + loggerError: vi.fn(), + }; + + vi.stubGlobal('window', mockWindow); + vi.stubGlobal('document', mockDocument); + vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket); + + const { startPreviewSyncRuntime } = await import('./previewSyncRuntime'); + startPreviewSyncRuntime(); + + const socket = MockWebSocket.instances[0]; + if (!socket) { + throw new Error('Expected startPreviewSyncRuntime to create a WebSocket instance.'); + } + + return { + dispatch, + emitStoreUpdate() { + subscribeListeners.forEach((listener) => listener()); + }, + executePreviewSyncSceneCommand, + runScript, + socket, + stageState, + updateEffect, + webgalParserParse, + WebGAL, + }; +} + +function parseSentEnvelope(socket: MockWebSocket, index: number) { + return JSON.parse(socket.sentMessages[index]); +} + +async function openPreviewSyncConnection(harness: WebSocketRuntimeHarness) { + harness.socket.emitOpen(); + await flushMicrotasks(); + + return parseSentEnvelope(harness.socket, 0); +} + +async function completeRegisterPreviewHandshake(harness: WebSocketRuntimeHarness) { + const registerRequest = await openPreviewSyncConnection(harness); + harness.socket.emitMessage( + JSON.stringify(createResponseEnvelope('session.register-preview', registerRequest.requestId, {})), + ); + await flushMicrotasks(); + + return registerRequest; +} + +describe('startPreviewSyncRuntime runtime behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + activeMockWindow?.dispatchEvent({ type: 'pagehide' }); + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + activeModuleState = null; + activeMockWindow = null; + }); + + it('registers preview with canonical payload and publishes ready plus first snapshot after register acceptance', async () => { + const harness = await setupWebSocketRuntimeHarness(); + + const registerRequest = await openPreviewSyncConnection(harness); + + expect(harness.socket.protocol).toBe('webgal-editor-preview-sync.v1'); + expect(registerRequest).toMatchObject({ + kind: 'request', + type: 'session.register-preview', + payload: { + gameId: 'game-key-1', + embeddedLaunchId: 'embedded-launch-1', + }, + }); + + harness.socket.emitMessage( + JSON.stringify(createResponseEnvelope('session.register-preview', registerRequest.requestId, {})), + ); + await flushMicrotasks(); + + expect(parseSentEnvelope(harness.socket, 1)).toEqual({ + kind: 'event', + type: 'preview.ready.updated', + payload: { + ready: true, + }, + }); + expect(parseSentEnvelope(harness.socket, 2)).toEqual({ + kind: 'event', + type: 'stage.snapshot.updated', + payload: { + sceneName: 'scene/start.txt', + sentenceId: 7, + stageState: harness.stageState, + }, + }); + }); + + it('waits for the matching register response before publishing snapshots or accepting commands', async () => { + const harness = await setupWebSocketRuntimeHarness(); + + const registerRequest = await openPreviewSyncConnection(harness); + + harness.socket.emitMessage( + JSON.stringify(createResponseEnvelope('session.register-preview', 'req-other-register', {})), + ); + harness.emitStoreUpdate(); + harness.socket.emitMessage( + JSON.stringify( + createRequestEnvelope('preview.command.sync-scene', 'req-sync-scene', { + sceneName: 'scene/branch.txt', + sentenceId: 12, + syncMode: 'fast', + }), + ), + ); + await flushMicrotasks(); + + expect(harness.executePreviewSyncSceneCommand).not.toHaveBeenCalled(); + expect(harness.socket.sentMessages).toHaveLength(1); + + harness.socket.emitMessage( + JSON.stringify(createResponseEnvelope('session.register-preview', registerRequest.requestId, {})), + ); + await flushMicrotasks(); + + expect(harness.socket.sentMessages).toHaveLength(3); + }); + + it('deduplicates unchanged snapshots and republishes when stage state changes', async () => { + const harness = await setupWebSocketRuntimeHarness(); + + await completeRegisterPreviewHandshake(harness); + + harness.emitStoreUpdate(); + await flushMicrotasks(); + + expect(harness.socket.sentMessages).toHaveLength(3); + + harness.stageState.layers = [{ id: 'layer-2' }]; + harness.WebGAL.sceneManager.sceneData.currentSentenceId = 8; + harness.emitStoreUpdate(); + await flushMicrotasks(); + + expect(harness.socket.sentMessages).toHaveLength(4); + expect(parseSentEnvelope(harness.socket, 3)).toEqual({ + kind: 'event', + type: 'stage.snapshot.updated', + payload: { + sceneName: 'scene/start.txt', + sentenceId: 8, + stageState: { + effects: [], + layers: [{ id: 'layer-2' }], + }, + }, + }); + }); + + it('skips snapshot serialization when the subscribed store update keeps the same snapshot inputs', async () => { + const harness = await setupWebSocketRuntimeHarness(); + const stringifySpy = vi.spyOn(JSON, 'stringify'); + const parseSpy = vi.spyOn(JSON, 'parse'); + + await completeRegisterPreviewHandshake(harness); + stringifySpy.mockClear(); + parseSpy.mockClear(); + + harness.emitStoreUpdate(); + await flushMicrotasks(); + + expect(harness.socket.sentMessages).toHaveLength(3); + expect(stringifySpy).not.toHaveBeenCalled(); + expect(parseSpy).not.toHaveBeenCalled(); + }); + + it('forwards sync-scene requests to the scene sync executor and replies with a success envelope', async () => { + const harness = await setupWebSocketRuntimeHarness(); + + await completeRegisterPreviewHandshake(harness); + + harness.socket.emitMessage( + JSON.stringify( + createRequestEnvelope('preview.command.sync-scene', 'req-sync-scene', { + sceneName: 'scene/branch.txt', + sentenceId: 12, + syncMode: 'fast', + }), + ), + ); + await flushMicrotasks(); + + expect(harness.executePreviewSyncSceneCommand).toHaveBeenCalledWith({ + sceneName: 'scene/branch.txt', + sentenceId: 12, + syncMode: 'fast', + }); + expect(parseSentEnvelope(harness.socket, 3)).toEqual({ + kind: 'response', + type: 'preview.command.sync-scene', + requestId: 'req-sync-scene', + payload: {}, + }); + }); + + it('runs snippets as parsed sentences and replies with a success envelope', async () => { + const harness = await setupWebSocketRuntimeHarness(); + + await completeRegisterPreviewHandshake(harness); + + harness.socket.emitMessage( + JSON.stringify( + createRequestEnvelope('preview.command.run-snippet', 'req-run-snippet', { + snippet: 'say:Hello', + }), + ), + ); + await flushMicrotasks(); + + expect(harness.webgalParserParse).toHaveBeenCalledWith('say:Hello', 'temp.txt', 'temp.txt'); + expect(harness.runScript).toHaveBeenCalledTimes(2); + expect(harness.runScript).toHaveBeenNthCalledWith(1, { id: 'sentence-1' }); + expect(harness.runScript).toHaveBeenNthCalledWith(2, { id: 'sentence-2' }); + expect(parseSentEnvelope(harness.socket, 3)).toEqual({ + kind: 'response', + type: 'preview.command.run-snippet', + requestId: 'req-run-snippet', + payload: {}, + }); + }); + + it('applies effect updates as partial transforms and replies with a success envelope', async () => { + const harness = await setupWebSocketRuntimeHarness(); + harness.stageState.effects = [ + { + target: 'effect-1', + transform: { + position: { x: 1, y: 2 }, + scale: { x: 3, y: 4 }, + alpha: 0.5, + rotation: 10, + }, + }, + ]; + + await completeRegisterPreviewHandshake(harness); + + harness.socket.emitMessage( + JSON.stringify( + createRequestEnvelope('preview.command.set-effect', 'req-set-effect', { + target: 'effect-1', + transform: { + position: { x: 9 }, + scale: { y: 8 }, + alpha: 0.7, + }, + }), + ), + ); + await flushMicrotasks(); + + expect(harness.WebGAL.gameplay.pixiStage.removeAnimationByTargetKey).toHaveBeenCalledWith('effect-1'); + expect(harness.updateEffect).toHaveBeenCalledWith({ + target: 'effect-1', + transform: { + position: { x: 9, y: 2 }, + scale: { x: 3, y: 8 }, + alpha: 0.7, + rotation: 10, + }, + }); + expect(harness.dispatch).toHaveBeenCalledWith({ + type: 'stage/updateEffect', + payload: { + target: 'effect-1', + transform: { + position: { x: 9, y: 2 }, + scale: { x: 3, y: 8 }, + alpha: 0.7, + rotation: 10, + }, + }, + }); + expect(parseSentEnvelope(harness.socket, 3)).toEqual({ + kind: 'response', + type: 'preview.command.set-effect', + requestId: 'req-set-effect', + payload: {}, + }); + }); + + it('re-registers with the same identity and re-publishes ready plus snapshot after reconnect', async () => { + const harness = await setupWebSocketRuntimeHarness(); + + await completeRegisterPreviewHandshake(harness); + + harness.socket.emitClose(); + vi.advanceTimersByTime(1000); + + const reconnectSocket = MockWebSocket.instances[1]; + reconnectSocket.emitOpen(); + await flushMicrotasks(); + + const reconnectRegisterRequest = JSON.parse(reconnectSocket.sentMessages[0]); + expect(reconnectRegisterRequest).toMatchObject({ + kind: 'request', + type: 'session.register-preview', + payload: { + gameId: 'game-key-1', + embeddedLaunchId: 'embedded-launch-1', + }, + }); + + reconnectSocket.emitMessage( + JSON.stringify(createResponseEnvelope('session.register-preview', reconnectRegisterRequest.requestId, {})), + ); + await flushMicrotasks(); + + expect(parseSentEnvelope(reconnectSocket, 1)).toEqual({ + kind: 'event', + type: 'preview.ready.updated', + payload: { + ready: true, + }, + }); + expect(parseSentEnvelope(reconnectSocket, 2)).toEqual({ + kind: 'event', + type: 'stage.snapshot.updated', + payload: { + sceneName: 'scene/start.txt', + sentenceId: 7, + stageState: { + effects: [], + layers: [{ id: 'layer-1' }], + }, + }, + }); + }); +}); diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts new file mode 100644 index 000000000..ee0b0f1af --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -0,0 +1,373 @@ +import { + createEventEnvelope, + createRequestEnvelope, + createResponseEnvelope, + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isPreviewRequestEnvelope, + isProtocolEnvelope, + PreviewRequestPayloadByType, + PreviewRequestType, + PreviewResponsePayloadByType, + RunSceneContentPayload, + RunSnippetPayload, + SetComponentVisibilityPayload, + SetEffectPayload, + SetFontOptimizationPayload, + StageSnapshotUpdatedPayload, + SyncScenePayload, +} from '../../../types/editorPreviewProtocol'; +import { webgalStore } from '@/store/store'; +import { setFontOptimization, setVisibility } from '@/store/GUIReducer'; +import { WebGAL } from '@/Core/WebGAL'; +import { sceneParser, WebgalParser } from '@/Core/parser/sceneParser'; +import { ISentence } from '@/Core/controller/scene/sceneInterface'; +import { runScript } from '@/Core/controller/gamePlay/runScript'; +import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { resetStage } from '@/Core/controller/stage/resetStage'; +import { logger } from '@/Core/util/logger'; +import { stageActions } from '@/store/stageReducer'; +import { baseTransform } from '@/store/stageInterface'; +import type { IStageState } from '@/store/stageInterface'; +import { requestEmbeddedLaunchId } from './runtime/embeddedPreviewBootstrap'; +import { + createPreviewSyncTransport, + PreviewSyncTransport, + PreviewSyncTransportSocket, +} from './runtime/previewSyncTransport'; +import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneCommand'; + +let previewSyncRuntimeStarted = false; +type StageStateSnapshot = IStageState; + +interface RegisterPreviewLogContext { + requestId: string; + gameId: string | undefined; + embeddedLaunchId: string | undefined; +} + +export const startPreviewSyncRuntime = () => { + if (previewSyncRuntimeStarted) { + return; + } + + const protocol = window.location.protocol; + if (protocol !== 'http:' && protocol !== 'https:') { + logger.info('当前环境不支持启动编辑器同步 V1 WebSocket'); + return; + } + + previewSyncRuntimeStarted = true; + + const loc = window.location.hostname; + const port = window.location.port; + const defaultPort = port && port !== '80' && port !== '443' ? `:${port}` : ''; + const wsProtocol = protocol === 'https:' ? 'wss' : 'ws'; + const wsUrl = `${wsProtocol}://${loc}${defaultPort}/api/webgalsync`; + + let disposed = false; + let registered = false; + let pendingRegisterRequestId: string | null = null; + let pendingRegisterContext: RegisterPreviewLogContext | null = null; + let lastPublishedSceneName: string | null = null; + let lastPublishedSentenceId: number | null = null; + let lastPublishedStageState: StageStateSnapshot | null = null; + const embeddedLaunchIdPromise = requestEmbeddedLaunchId(); + let transport!: PreviewSyncTransport; + + const createRequestId = () => + window.crypto?.randomUUID?.() ?? `req-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + const resetRegistrationState = () => { + registered = false; + pendingRegisterRequestId = null; + pendingRegisterContext = null; + lastPublishedSceneName = null; + lastPublishedSentenceId = null; + lastPublishedStageState = null; + }; + + const buildStageStateSnapshot = (stageState: StageStateSnapshot): StageSnapshotUpdatedPayload['stageState'] => { + return JSON.parse(JSON.stringify(stageState)) as StageSnapshotUpdatedPayload['stageState']; + }; + + const publishReady = () => { + transport.send( + createEventEnvelope('preview.ready.updated', { + ready: true, + }), + ); + }; + + const publishStageSnapshot = (force: boolean) => { + if (!registered) { + return; + } + + const stageState = webgalStore.getState().stage; + const sceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; + const sentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; + const snapshotUnchanged = + stageState === lastPublishedStageState && + sceneName === lastPublishedSceneName && + sentenceId === lastPublishedSentenceId; + + if (!force && snapshotUnchanged) { + return; + } + + const payload = { + sceneName, + sentenceId, + stageState: buildStageStateSnapshot(stageState), + }; + + const sent = transport.send(createEventEnvelope('stage.snapshot.updated', payload)); + if (sent) { + lastPublishedSceneName = sceneName; + lastPublishedSentenceId = sentenceId; + lastPublishedStageState = stageState; + } + }; + + const registerPreview = async (socket: PreviewSyncTransportSocket) => { + const requestId = createRequestId(); + pendingRegisterRequestId = requestId; + const embeddedLaunchId = await embeddedLaunchIdPromise; + if (!transport.isActiveSocket(socket) || !transport.isSocketOpen(socket)) { + return; + } + + const registerContext: RegisterPreviewLogContext = { + requestId, + gameId: WebGAL.gameKey || undefined, + embeddedLaunchId, + }; + pendingRegisterContext = registerContext; + logger.info('发送编辑器同步 V1 注册请求', registerContext); + transport.send( + createRequestEnvelope('session.register-preview', requestId, { + gameId: registerContext.gameId, + embeddedLaunchId, + }), + ); + }; + + const handleSyncScene = (payload: SyncScenePayload) => { + executePreviewSyncSceneCommand(payload); + }; + + const handleRunSnippet = (payload: RunSnippetPayload) => { + const scene = WebgalParser.parse(payload.snippet, 'temp.txt', 'temp.txt'); + (scene.sentenceList as unknown as ISentence[]).forEach((sentence) => { + runScript(sentence); + }); + }; + + const applyComponentVisibility = (payload: SetComponentVisibilityPayload) => { + (Object.keys(payload) as Array).forEach((component) => { + const visibility = payload[component]; + if (typeof visibility !== 'boolean') { + return; + } + + webgalStore.dispatch( + setVisibility({ + component, + visibility, + }), + ); + }); + }; + + const handleReloadTemplates = () => { + const title = document.querySelector('.html-body__title-enter') as HTMLElement | null; + if (title) { + title.style.display = 'none'; + } + WebGAL.events.styleUpdate.emit(); + }; + + const handleRunSceneContent = (payload: RunSceneContentPayload) => { + resetStage(true); + WebGAL.sceneManager.sceneData.currentScene = sceneParser(payload.sceneContent, 'temp', './temp.txt'); + applyComponentVisibility({ + showTitle: false, + showMenuPanel: false, + showPanicOverlay: false, + }); + setTimeout(() => { + nextSentence(); + }, 100); + }; + + const handleSetFontOptimization = (payload: SetFontOptimizationPayload) => { + webgalStore.dispatch(setFontOptimization(payload.enabled)); + }; + + const handleSetComponentVisibility = (payload: SetComponentVisibilityPayload) => { + applyComponentVisibility(payload); + }; + + const handleSetEffect = (payload: SetEffectPayload) => { + const targetEffect = webgalStore.getState().stage.effects.find((effect) => effect.target === payload.target); + const targetTransform = targetEffect?.transform ? targetEffect.transform : baseTransform; + const newTransform = { + ...targetTransform, + ...(payload.transform ?? {}), + position: { + ...targetTransform.position, + ...(payload.transform?.position ?? {}), + }, + scale: { + ...targetTransform.scale, + ...(payload.transform?.scale ?? {}), + }, + }; + WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); + webgalStore.dispatch( + stageActions.updateEffect({ + target: payload.target, + transform: newTransform, + }), + ); + }; + + const previewRequestHandlers: { + [K in PreviewRequestType]: (payload: PreviewRequestPayloadByType[K]) => PreviewResponsePayloadByType[K]; + } = { + 'preview.command.sync-scene': (payload: SyncScenePayload) => { + handleSyncScene(payload); + return {}; + }, + 'preview.command.run-scene-content': (payload: RunSceneContentPayload) => { + handleRunSceneContent(payload); + return {}; + }, + 'preview.command.run-snippet': (payload: RunSnippetPayload) => { + handleRunSnippet(payload); + return {}; + }, + 'preview.command.reload-templates': () => { + handleReloadTemplates(); + return {}; + }, + 'preview.command.set-effect': (payload: SetEffectPayload) => { + handleSetEffect(payload); + return {}; + }, + 'preview.command.set-component-visibility': (payload: SetComponentVisibilityPayload) => { + handleSetComponentVisibility(payload); + return {}; + }, + 'preview.command.set-font-optimization': (payload: SetFontOptimizationPayload) => { + handleSetFontOptimization(payload); + return {}; + }, + }; + + const handlePreviewRequest = ( + type: TType, + payload: PreviewRequestPayloadByType[TType], + ): PreviewResponsePayloadByType[TType] => { + const handler = previewRequestHandlers[type] as ( + nextPayload: PreviewRequestPayloadByType[TType], + ) => PreviewResponsePayloadByType[TType]; + + return handler(payload); + }; + + transport = createPreviewSyncTransport({ + url: wsUrl, + subprotocol: EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + onConnecting: resetRegistrationState, + onOpen: registerPreview, + onMessage: (rawData) => { + try { + const envelope = JSON.parse(String(rawData)) as unknown; + if (!isProtocolEnvelope(envelope)) { + logger.warn('收到无法识别的编辑器同步 V1 消息'); + return; + } + + 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); + } + }, + onClose: resetRegistrationState, + logInfo: (message) => logger.info(message), + logError: (message, error) => logger.error(message, error), + logWarn: (message, error) => logger.warn(message, error), + }); + + const storeUnsubscribe = webgalStore.subscribe(() => { + publishStageSnapshot(false); + }); + + const ensureConnected = () => { + if (disposed) { + return; + } + + transport.ensureConnected(); + }; + + const disposeRuntime = () => { + if (disposed) { + return; + } + + disposed = true; + storeUnsubscribe(); + transport.dispose(); + }; + + window.addEventListener('focus', ensureConnected); + window.addEventListener('online', ensureConnected); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + ensureConnected(); + } + }); + window.addEventListener('pagehide', disposeRuntime, { once: true }); + + transport.connect(); +}; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.test.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.test.ts new file mode 100644 index 000000000..0c2e23f48 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + requestEmbeddedLaunchId, + WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE, + WEBGAL_PREVIEW_BOOTSTRAP_REQUEST, +} from './embeddedPreviewBootstrap'; + +interface MockMessageEvent { + data: unknown; + source?: unknown; +} + +function createMockWindow(isEmbedded: boolean) { + const listeners = new Set<(event: MockMessageEvent) => void>(); + const postedMessages: Array<{ message: unknown; targetOrigin: string }> = []; + const timers = new Map void>(); + let nextTimerId = 1; + + const parentWindow = { + postMessage: vi.fn((message: unknown, targetOrigin: string) => { + postedMessages.push({ message, targetOrigin }); + }), + }; + + const selfWindow = { + parent: isEmbedded ? parentWindow : null, + addEventListener: vi.fn((type: string, listener: (event: MockMessageEvent) => void) => { + if (type === 'message') { + listeners.add(listener); + } + }), + removeEventListener: vi.fn((type: string, listener: (event: MockMessageEvent) => void) => { + if (type === 'message') { + listeners.delete(listener); + } + }), + setTimeout: vi.fn((callback: () => void) => { + const id = nextTimerId++; + timers.set(id, callback); + return id; + }), + clearTimeout: vi.fn((id: number) => { + timers.delete(id); + }), + }; + + return { + selfWindow, + parentWindow, + postedMessages, + emitMessage(data: unknown, source = parentWindow) { + listeners.forEach((listener) => listener({ data, source })); + }, + runNextTimer() { + const [id, callback] = timers.entries().next().value ?? []; + if (id !== undefined && callback) { + timers.delete(id); + callback(); + } + }, + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('requestEmbeddedLaunchId', () => { + it('returns undefined immediately when not embedded', async () => { + const { selfWindow, postedMessages } = createMockWindow(false); + + await expect( + requestEmbeddedLaunchId({ + selfWindow, + }), + ).resolves.toBeUndefined(); + expect(postedMessages).toEqual([]); + }); + + it('requests bootstrap from parent and resolves embeddedLaunchId', async () => { + const mockWindow = createMockWindow(true); + const pendingLaunchId = requestEmbeddedLaunchId({ + selfWindow: mockWindow.selfWindow, + }); + + expect(mockWindow.postedMessages).toEqual([ + { + message: { type: WEBGAL_PREVIEW_BOOTSTRAP_REQUEST }, + targetOrigin: '*', + }, + ]); + + mockWindow.emitMessage({ + type: WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE, + embeddedLaunchId: 'embedded-launch-1', + }); + + await expect(pendingLaunchId).resolves.toBe('embedded-launch-1'); + }); + + it('falls back to undefined when bootstrap times out', async () => { + const mockWindow = createMockWindow(true); + const pendingLaunchId = requestEmbeddedLaunchId({ + selfWindow: mockWindow.selfWindow, + timeoutMs: 50, + }); + + mockWindow.runNextTimer(); + + await expect(pendingLaunchId).resolves.toBeUndefined(); + }); +}); diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.ts new file mode 100644 index 000000000..13d0838f8 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/embeddedPreviewBootstrap.ts @@ -0,0 +1,101 @@ +import { logger } from '../../logger'; + +export const WEBGAL_PREVIEW_BOOTSTRAP_REQUEST = 'webgal.preview.bootstrap.request'; +export const WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE = 'webgal.preview.bootstrap.provide'; + +interface BootstrapProvideMessage { + type: typeof WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE; + embeddedLaunchId: string; +} + +interface EmbeddedPreviewBootstrapWindow { + parent: { postMessage: (message: unknown, targetOrigin: string) => void } | null; + addEventListener: (type: 'message', listener: (event: { data: unknown; source?: unknown }) => void) => void; + removeEventListener: (type: 'message', listener: (event: { data: unknown; source?: unknown }) => void) => void; + setTimeout: (...args: any[]) => any; + clearTimeout: (...args: any[]) => void; +} + +export interface RequestEmbeddedLaunchIdOptions { + selfWindow?: EmbeddedPreviewBootstrapWindow; + timeoutMs?: number; +} + +function isBootstrapProvideMessage(value: unknown): value is BootstrapProvideMessage { + if (typeof value !== 'object' || value === null) { + return false; + } + + const maybeMessage = value as Partial; + return ( + maybeMessage.type === WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE && + typeof maybeMessage.embeddedLaunchId === 'string' && + maybeMessage.embeddedLaunchId.length > 0 + ); +} + +function getDefaultSelfWindow(): EmbeddedPreviewBootstrapWindow { + return { + parent: window.parent === window ? null : window.parent, + addEventListener: window.addEventListener.bind(window), + removeEventListener: window.removeEventListener.bind(window), + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + }; +} + +export async function requestEmbeddedLaunchId({ + selfWindow = getDefaultSelfWindow(), + timeoutMs = 1000, +}: RequestEmbeddedLaunchIdOptions = {}): Promise { + const parentWindow = selfWindow.parent; + if (parentWindow === null) { + return undefined; + } + + return new Promise((resolve) => { + let settled = false; + let timerId: any = null; + + const cleanup = () => { + selfWindow.removeEventListener('message', handleMessage); + if (timerId !== null) { + selfWindow.clearTimeout(timerId); + timerId = null; + } + }; + + const finish = (embeddedLaunchId: string | undefined) => { + if (settled) { + return; + } + + settled = true; + cleanup(); + resolve(embeddedLaunchId); + }; + + const handleMessage = (event: { data: unknown; source?: unknown }) => { + if (event.source !== undefined && event.source !== parentWindow) { + return; + } + + if (!isBootstrapProvideMessage(event.data)) { + return; + } + + logger.info('收到 embeddedLaunchId bootstrap', { + embeddedLaunchId: event.data.embeddedLaunchId, + }); + finish(event.data.embeddedLaunchId); + }; + + selfWindow.addEventListener('message', handleMessage); + timerId = selfWindow.setTimeout(() => { + logger.warn('等待 embeddedLaunchId bootstrap 超时,将继续以未绑定模式注册'); + finish(undefined); + }, timeoutMs); + logger.info('开始请求 embeddedLaunchId bootstrap'); + parentWindow.postMessage({ type: WEBGAL_PREVIEW_BOOTSTRAP_REQUEST }, '*'); + }); +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.test.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.test.ts new file mode 100644 index 000000000..3fd0872eb --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { IScene } from '@/Core/controller/scene/sceneInterface'; +import type { SyncScenePayload } from '../../../../types/editorPreviewProtocol'; + +interface ActiveModuleState { + webgalStore: { + dispatch: ReturnType; + }; + WebGAL: Record; + setVisibility: ReturnType; + resetStage: ReturnType; + sceneFetcher: ReturnType; + jumpFromBacklog: ReturnType; + nextSentence: ReturnType; + sceneParser: ReturnType; + loggerWarn: ReturnType; + assetSetter: ReturnType; +} + +interface PreviewSyncSceneCommandHarness { + dispatch: ReturnType; + WebGAL: Record; + resetStage: ReturnType; + jumpFromBacklog: ReturnType; + sceneFetcher: ReturnType; + sceneParser: ReturnType; +} + +let activeModuleState: ActiveModuleState | null = null; + +function getActiveModuleState(): ActiveModuleState { + if (activeModuleState === null) { + throw new Error('Expected active module state to be initialized before importing previewSyncSceneCommand.'); + } + + return activeModuleState; +} + +vi.doMock('@/store/store', () => ({ + get webgalStore() { + return getActiveModuleState().webgalStore; + }, +})); + +vi.doMock('@/store/GUIReducer', () => ({ + get setVisibility() { + return getActiveModuleState().setVisibility; + }, +})); + +vi.doMock('@/Core/WebGAL', () => ({ + get WebGAL() { + return getActiveModuleState().WebGAL; + }, +})); + +vi.doMock('@/Core/controller/stage/resetStage', () => ({ + get resetStage() { + return getActiveModuleState().resetStage; + }, +})); + +vi.doMock('@/Core/controller/scene/sceneFetcher', () => ({ + get sceneFetcher() { + return getActiveModuleState().sceneFetcher; + }, +})); + +vi.doMock('@/Core/controller/storage/jumpFromBacklog', () => ({ + get jumpFromBacklog() { + return getActiveModuleState().jumpFromBacklog; + }, +})); + +vi.doMock('@/Core/controller/gamePlay/nextSentence', () => ({ + get nextSentence() { + return getActiveModuleState().nextSentence; + }, +})); + +vi.doMock('@/Core/parser/sceneParser', () => ({ + get sceneParser() { + return getActiveModuleState().sceneParser; + }, +})); + +vi.doMock('@/Core/util/logger', () => ({ + get logger() { + return { + warn: getActiveModuleState().loggerWarn, + }; + }, +})); + +vi.doMock('@/Core/util/gameAssetsAccess/assetSetter', () => ({ + get assetSetter() { + return getActiveModuleState().assetSetter; + }, + fileType: { + scene: 'scene', + }, +})); + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +async function setupPreviewSyncSceneCommandHarness() { + vi.resetModules(); + vi.stubGlobal('document', { + querySelector: vi.fn(() => null), + }); + + const dispatch = vi.fn(); + const resetStage = vi.fn(); + const jumpFromBacklog = vi.fn(); + const nextSentence = vi.fn(); + const previousScene: IScene = { + sceneName: 'scene/original.txt', + sceneUrl: 'asset://scene/original.txt', + sentenceList: [ + { + command: 0, + commandRaw: 'say:first', + content: 'first', + args: [], + sentenceAssets: [], + subScene: [], + inlineComment: '', + }, + { + command: 0, + commandRaw: 'say:old-second', + content: 'old-second', + args: [], + sentenceAssets: [], + subScene: [], + inlineComment: '', + }, + ], + assetsList: [], + subSceneList: [], + }; + const parsedScene: IScene = { + sceneName: 'scene/branch.txt', + sceneUrl: 'asset://scene/branch.txt', + sentenceList: [ + { + command: 0, + commandRaw: 'say:first', + content: 'first', + args: [], + sentenceAssets: [], + subScene: [], + inlineComment: '', + }, + { + command: 0, + commandRaw: 'say:new-second', + content: 'new-second', + args: [], + sentenceAssets: [], + subScene: [], + inlineComment: '', + }, + ], + assetsList: [], + subSceneList: [], + }; + const sceneFetcher = vi.fn(async () => '; new scene'); + const sceneParser = vi.fn(() => parsedScene); + const assetSetter = vi.fn(() => 'asset://scene/branch.txt'); + const WebGAL = { + sceneManager: { + sceneData: { + currentScene: previousScene, + currentSentenceId: 1, + }, + }, + gameplay: { + isFast: false, + }, + backlogManager: { + getBacklog: vi.fn(() => [ + { + saveScene: { + currentSentenceId: 1, + sceneName: 'scene/branch.txt', + }, + }, + ]), + }, + }; + + activeModuleState = { + webgalStore: { + dispatch, + }, + WebGAL, + setVisibility: vi.fn((payload: unknown) => ({ type: 'gui/setVisibility', payload })), + resetStage, + sceneFetcher, + jumpFromBacklog, + nextSentence, + sceneParser, + loggerWarn: vi.fn(), + assetSetter, + }; + + const module = await import('./previewSyncSceneCommand'); + + return { + dispatch, + WebGAL, + resetStage, + jumpFromBacklog, + sceneFetcher, + sceneParser, + executePreviewSyncSceneCommand: module.executePreviewSyncSceneCommand, + }; +} + +describe('executePreviewSyncSceneCommand', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + activeModuleState = null; + }); + + it('compares backlog recovery against the newly parsed scene instead of the current in-memory scene', async () => { + const harness = await setupPreviewSyncSceneCommandHarness(); + const payload: SyncScenePayload = { + sceneName: 'scene/branch.txt', + sentenceId: 2, + syncMode: 'fast', + }; + + harness.executePreviewSyncSceneCommand(payload); + await flushMicrotasks(); + + expect(harness.sceneFetcher).toHaveBeenCalledWith('asset://scene/branch.txt'); + expect(harness.sceneParser).toHaveBeenCalledWith('; new scene', 'scene/branch.txt', 'asset://scene/branch.txt'); + expect(harness.resetStage).toHaveBeenCalledWith(true); + expect(harness.jumpFromBacklog).not.toHaveBeenCalled(); + expect(harness.WebGAL.sceneManager.sceneData.currentScene).toMatchObject({ + sceneName: 'scene/branch.txt', + }); + }); + + it('avoids JSON stringification while comparing shared sentences for recovery', async () => { + const harness = await setupPreviewSyncSceneCommandHarness(); + const stringifySpy = vi.spyOn(JSON, 'stringify'); + + harness.executePreviewSyncSceneCommand({ + sceneName: 'scene/branch.txt', + sentenceId: 2, + syncMode: 'fast', + }); + await flushMicrotasks(); + + expect(stringifySpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts new file mode 100644 index 000000000..85d072b8a --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -0,0 +1,102 @@ +import { webgalStore } from '@/store/store'; +import { setVisibility } from '@/store/GUIReducer'; +import { WebGAL } from '@/Core/WebGAL'; +import { resetStage } from '@/Core/controller/stage/resetStage'; +import { sceneFetcher } from '@/Core/controller/scene/sceneFetcher'; +import { IScene } from '@/Core/controller/scene/sceneInterface'; +import { jumpFromBacklog } from '@/Core/controller/storage/jumpFromBacklog'; +import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { sceneParser } from '@/Core/parser/sceneParser'; +import { logger } from '@/Core/util/logger'; +import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; +import { SyncScenePayload } from '../../../../types/editorPreviewProtocol'; +import cloneDeep from 'lodash/cloneDeep'; +import isEqual from 'lodash/isEqual'; + +let fastForwardTimeout: ReturnType | undefined; + +export function executePreviewSyncSceneCommand({ sceneName, sentenceId, syncMode }: SyncScenePayload): void { + logger.warn('正在跳转到' + sceneName + ':' + sentenceId); + + const dispatch = webgalStore.dispatch; + dispatch(setVisibility({ component: 'showTitle', visibility: false })); + dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); + dispatch(setVisibility({ component: 'isShowLogo', visibility: false })); + + const title = document.querySelector('.html-body__title-enter') as HTMLElement | null; + if (title) { + title.style.display = 'none'; + } + + const previousScene = cloneDeep(WebGAL.sceneManager.sceneData.currentScene); + const sceneUrl = assetSetter(sceneName, fileType.scene); + + sceneFetcher(sceneUrl).then((rawScene) => { + const nextScene = sceneParser(rawScene, sceneName, sceneUrl); + const lastSharedSentenceId = findLastSharedSentence(previousScene, nextScene, sentenceId); + const recoverySentenceId = Math.min(sentenceId, lastSharedSentenceId); + const backlogIndex = findLastAvailableBacklogEntry(recoverySentenceId, sceneName); + const allowBacklogRecovery = backlogIndex >= 0 && syncMode === 'fast'; + + resetStage(!allowBacklogRecovery); + WebGAL.sceneManager.sceneData.currentScene = nextScene; + WebGAL.gameplay.isFast = true; + + if (allowBacklogRecovery) { + jumpFromBacklog(backlogIndex, false); + } + + if (fastForwardTimeout) { + clearTimeout(fastForwardTimeout); + } + + fastForwardToSentence(sentenceId, WebGAL.sceneManager.sceneData.currentScene.sceneName); + }); +} + +export function fastForwardToSentence(targetSentenceId: number, currentSceneName: string): void { + if ( + WebGAL.sceneManager.sceneData.currentSentenceId < targetSentenceId && + WebGAL.sceneManager.sceneData.currentScene.sceneName === currentSceneName + ) { + nextSentence(); + fastForwardTimeout = setTimeout(() => fastForwardToSentence(targetSentenceId, currentSceneName), 2); + return; + } + + WebGAL.gameplay.isFast = false; +} + +function findLastSharedSentence(previousScene: IScene, currentScene: IScene, targetSentenceId: number): number { + let lastSharedSentenceId = 0; + const comparableSentenceCount = Math.min( + targetSentenceId, + previousScene.sentenceList.length, + currentScene.sentenceList.length, + ); + + for (let index = 0; index < comparableSentenceCount; index += 1) { + if (!isEqual(previousScene.sentenceList[index], currentScene.sentenceList[index])) { + break; + } + + lastSharedSentenceId = index; + } + + return lastSharedSentenceId; +} + +function findLastAvailableBacklogEntry(targetSentenceId: number, sceneName: string): number { + let lastAvailableIndex = -1; + + WebGAL.backlogManager.getBacklog().forEach((entry, index) => { + const backlogSentenceId = entry.saveScene.currentSentenceId; + const backlogSceneName = entry.saveScene.sceneName; + + if (backlogSentenceId <= targetSentenceId && backlogSceneName === sceneName) { + lastAvailableIndex = index; + } + }); + + return lastAvailableIndex; +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.test.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.test.ts new file mode 100644 index 000000000..dd1b7c609 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createPreviewSyncTransport, type PreviewSyncTransportSocket } from './previewSyncTransport'; + +interface MockSocketEvent { + data: unknown; +} + +class MockPreviewSyncSocket implements PreviewSyncTransportSocket { + public readyState = 0; + public onopen: (() => void) | null = null; + public onmessage: ((event: MockSocketEvent) => void) | null = null; + public onclose: (() => void) | null = null; + public onerror: ((error: unknown) => void) | null = null; + public sentMessages: string[] = []; + public closeCallCount = 0; + + public send(data: string) { + this.sentMessages.push(data); + } + + public close() { + this.closeCallCount += 1; + this.readyState = 3; + this.onclose?.(); + } + + public emitOpen() { + this.readyState = 1; + this.onopen?.(); + } + + public emitMessage(data: unknown) { + this.onmessage?.({ + data, + }); + } + + public emitClose() { + this.readyState = 3; + this.onclose?.(); + } + + public emitError(error: unknown) { + this.onerror?.(error); + } +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +function createTransportHarness({ + onOpenImplementation, +}: { + onOpenImplementation?: (socket: PreviewSyncTransportSocket) => void | Promise; +} = {}) { + const sockets: MockPreviewSyncSocket[] = []; + const scheduledTimers = new Map void; delay: number }>(); + let nextTimerId = 1; + + const createSocket = vi.fn(() => { + const socket = new MockPreviewSyncSocket(); + sockets.push(socket); + return socket; + }); + const onOpen = vi.fn((socket: PreviewSyncTransportSocket) => onOpenImplementation?.(socket)); + const onMessage = vi.fn(); + const onClose = vi.fn(); + const onConnecting = vi.fn(); + const logInfo = vi.fn(); + const logError = vi.fn(); + const logWarn = vi.fn(); + + const transport = createPreviewSyncTransport({ + url: 'ws://127.0.0.1/api/webgalsync', + subprotocol: 'webgal-editor-preview-sync.v1', + createSocket, + onConnecting, + onOpen, + onMessage, + onClose, + logInfo, + logError, + logWarn, + setTimeoutFn: ((callback: () => void, delay?: number) => { + const timerId = nextTimerId++; + scheduledTimers.set(timerId, { + callback, + delay: delay ?? 0, + }); + return timerId as unknown as ReturnType; + }) as typeof setTimeout, + clearTimeoutFn: ((timerId: ReturnType) => { + scheduledTimers.delete(timerId as unknown as number); + }) as typeof clearTimeout, + }); + + return { + transport, + sockets, + createSocket, + onOpen, + onMessage, + onClose, + onConnecting, + logInfo, + logError, + logWarn, + getScheduledDelays() { + return Array.from(scheduledTimers.values()).map((timer) => timer.delay); + }, + runNextTimer() { + const [timerId, timer] = scheduledTimers.entries().next().value ?? []; + if (timerId === undefined || timer === undefined) { + return; + } + scheduledTimers.delete(timerId); + timer.callback(); + }, + }; +} + +describe('createPreviewSyncTransport', () => { + it('keeps a single active connection while connecting or open', () => { + const harness = createTransportHarness(); + + harness.transport.connect(); + harness.transport.ensureConnected(); + + expect(harness.createSocket).toHaveBeenCalledTimes(1); + + harness.sockets[0].emitOpen(); + harness.transport.ensureConnected(); + + expect(harness.createSocket).toHaveBeenCalledTimes(1); + expect(harness.onOpen).toHaveBeenCalledTimes(1); + expect(harness.onOpen).toHaveBeenCalledWith(harness.sockets[0]); + }); + + it('reconnects after close and re-runs onOpen for the new socket', () => { + const harness = createTransportHarness(); + + harness.transport.connect(); + harness.sockets[0].emitOpen(); + harness.sockets[0].emitClose(); + + expect(harness.onClose).toHaveBeenCalledTimes(1); + expect(harness.getScheduledDelays()).toEqual([1000]); + + harness.runNextTimer(); + + expect(harness.createSocket).toHaveBeenCalledTimes(2); + expect(harness.onConnecting).toHaveBeenCalledTimes(2); + + harness.sockets[1].emitOpen(); + + expect(harness.onOpen).toHaveBeenCalledTimes(2); + expect(harness.onOpen).toHaveBeenLastCalledWith(harness.sockets[1]); + }); + + it('ignores stale socket messages after a newer socket becomes active', () => { + const harness = createTransportHarness(); + + harness.transport.connect(); + harness.sockets[0].emitOpen(); + harness.sockets[0].emitClose(); + harness.runNextTimer(); + harness.sockets[1].emitOpen(); + + harness.sockets[0].emitMessage('stale-message'); + harness.sockets[1].emitMessage('live-message'); + + expect(harness.onMessage).toHaveBeenCalledTimes(1); + expect(harness.onMessage).toHaveBeenCalledWith('live-message', harness.sockets[1]); + }); + + it('sends only through the active open socket and disposes cleanly', () => { + const harness = createTransportHarness(); + + expect(harness.transport.send({ kind: 'event', type: 'preview.ready.updated' })).toBe(false); + + harness.transport.connect(); + harness.sockets[0].emitOpen(); + + expect(harness.transport.send({ kind: 'event', type: 'preview.ready.updated' })).toBe(true); + expect(harness.sockets[0].sentMessages).toEqual([ + JSON.stringify({ + kind: 'event', + type: 'preview.ready.updated', + }), + ]); + + harness.transport.dispose(); + harness.transport.ensureConnected(); + + expect(harness.sockets[0].closeCallCount).toBe(1); + expect(harness.createSocket).toHaveBeenCalledTimes(1); + }); + + it('logs and retries when async onOpen rejects', async () => { + const openError = new Error('register failed'); + const harness = createTransportHarness({ + onOpenImplementation: async () => { + throw openError; + }, + }); + + harness.transport.connect(); + harness.sockets[0].emitOpen(); + await flushMicrotasks(); + + expect(harness.sockets[0].closeCallCount).toBe(1); + expect(harness.getScheduledDelays()).toEqual([1000]); + }); +}); diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.ts new file mode 100644 index 000000000..0a489be06 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncTransport.ts @@ -0,0 +1,200 @@ +const SOCKET_CONNECTING = 0; +const SOCKET_OPEN = 1; +const DEFAULT_MAX_RECONNECT_DELAY_MS = 3_000; + +export interface PreviewSyncTransportSocket { + readyState: number; + onopen: (() => void) | null; + onmessage: ((event: { data: unknown }) => void) | null; + onclose: (() => void) | null; + onerror: ((error: unknown) => void) | null; + send: (data: string) => void; + close: () => void; +} + +export interface PreviewSyncTransportOptions { + url: string; + subprotocol: string; + createSocket?: (url: string, subprotocol: string) => PreviewSyncTransportSocket; + onConnecting?: () => void; + onOpen: (socket: PreviewSyncTransportSocket) => void | Promise; + onMessage: (data: unknown, socket: PreviewSyncTransportSocket) => void; + onClose?: (socket: PreviewSyncTransportSocket) => void; + logInfo: (message: string) => void; + logError: (message: string, error?: unknown) => void; + logWarn: (message: string, error?: unknown) => void; + setTimeoutFn?: typeof setTimeout; + clearTimeoutFn?: typeof clearTimeout; + maxReconnectDelayMs?: number; +} + +export interface PreviewSyncTransport { + connect: () => void; + ensureConnected: () => void; + dispose: () => void; + send: (envelope: unknown) => boolean; + isSocketOpen: (socket: PreviewSyncTransportSocket | null | undefined) => boolean; + isActiveSocket: (socket: PreviewSyncTransportSocket | null | undefined) => boolean; +} + +function createBrowserSocket(url: string, subprotocol: string): PreviewSyncTransportSocket { + return new WebSocket(url, subprotocol) as unknown as PreviewSyncTransportSocket; +} + +export function createPreviewSyncTransport({ + url, + subprotocol, + createSocket = createBrowserSocket, + onConnecting, + onOpen, + onMessage, + onClose, + logInfo, + logError, + logWarn, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + maxReconnectDelayMs = DEFAULT_MAX_RECONNECT_DELAY_MS, +}: PreviewSyncTransportOptions): PreviewSyncTransport { + let disposed = false; + let activeSocket: PreviewSyncTransportSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectAttempt = 0; + let connectionId = 0; + + const clearReconnectTimer = () => { + if (reconnectTimer) { + clearTimeoutFn(reconnectTimer); + reconnectTimer = null; + } + }; + + const isSocketOpen = (socket: PreviewSyncTransportSocket | null | undefined) => { + return socket !== null && socket !== undefined && socket.readyState === SOCKET_OPEN; + }; + + const isActiveSocket = (socket: PreviewSyncTransportSocket | null | undefined) => { + return socket !== null && socket !== undefined && activeSocket === socket; + }; + + const send = (envelope: unknown): boolean => { + const socket = activeSocket; + if (socket === null || !isSocketOpen(socket)) { + return false; + } + + try { + socket.send(JSON.stringify(envelope)); + return true; + } catch (error) { + logError('发送编辑器同步 V1 消息失败', error); + return false; + } + }; + + const connect = () => { + if (disposed) { + return; + } + + if (activeSocket && (activeSocket.readyState === SOCKET_CONNECTING || activeSocket.readyState === SOCKET_OPEN)) { + return; + } + + connectionId += 1; + const currentConnectionId = connectionId; + onConnecting?.(); + logInfo(`正在启动编辑器同步 V1 WebSocket:${url}`); + const socket = createSocket(url, subprotocol); + activeSocket = socket; + + socket.onopen = () => { + if (disposed || currentConnectionId !== connectionId || !isActiveSocket(socket)) { + return; + } + + clearReconnectTimer(); + reconnectAttempt = 0; + logInfo('编辑器同步 V1 WebSocket 已连接'); + Promise.resolve(onOpen(socket)).catch((error) => { + logError('处理编辑器同步 V1 WebSocket 连接回调失败', error); + if (!disposed && currentConnectionId === connectionId && isActiveSocket(socket)) { + socket.close(); + } + }); + }; + + socket.onmessage = (event) => { + if (disposed || currentConnectionId !== connectionId || !isActiveSocket(socket)) { + return; + } + + onMessage(event.data, socket); + }; + + socket.onclose = () => { + if (currentConnectionId !== connectionId || !isActiveSocket(socket)) { + return; + } + + activeSocket = null; + onClose?.(socket); + if (disposed) { + return; + } + + logInfo('编辑器同步 V1 WebSocket 已关闭'); + clearReconnectTimer(); + const delay = Math.min(1000 * 2 ** reconnectAttempt, maxReconnectDelayMs); + reconnectAttempt += 1; + logInfo(`编辑器同步 V1 WebSocket 将在 ${delay}ms 后重连`); + reconnectTimer = setTimeoutFn(() => { + reconnectTimer = null; + connect(); + }, delay); + }; + + socket.onerror = (error) => { + if (currentConnectionId !== connectionId || !isActiveSocket(socket)) { + return; + } + + logWarn('编辑器同步 V1 WebSocket 发生错误', error); + }; + }; + + const ensureConnected = () => { + if (disposed) { + return; + } + + if (activeSocket && (activeSocket.readyState === SOCKET_OPEN || activeSocket.readyState === SOCKET_CONNECTING)) { + return; + } + + connect(); + }; + + const dispose = () => { + if (disposed) { + return; + } + + disposed = true; + clearReconnectTimer(); + if (activeSocket) { + const socket = activeSocket; + activeSocket = null; + socket.close(); + } + }; + + return { + connect, + ensureConnected, + dispose, + send, + isSocketOpen, + isActiveSocket, + }; +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts b/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts deleted file mode 100644 index 3f1d39750..000000000 --- a/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { webgalStore } from '@/store/store'; -import { setVisibility } from '@/store/GUIReducer'; -import { WebGAL } from '@/Core/WebGAL'; -import { resetStage } from '@/Core/controller/stage/resetStage'; -import { sceneFetcher } from '@/Core/controller/scene/sceneFetcher'; -import { IScene } from '@/Core/controller/scene/sceneInterface'; -import { jumpFromBacklog } from '@/Core/controller/storage/jumpFromBacklog'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; -import { sceneParser } from '@/Core/parser/sceneParser'; -import { logger } from '@/Core/util/logger'; -import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; -import cloneDeep from 'lodash/cloneDeep'; - -let syncFastTimeout: ReturnType | undefined; - -export const syncWithOrigine = (sceneName: string, sentenceId: number, expermental = false) => { - logger.warn('正在跳转到' + sceneName + ':' + sentenceId); - const dispatch = webgalStore.dispatch; - dispatch(setVisibility({ component: 'showTitle', visibility: false })); - dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); - dispatch(setVisibility({ component: 'isShowLogo', visibility: false })); - const title = document.querySelector('.html-body__title-enter') as HTMLElement; - if (title) { - title.style.display = 'none'; - } - const pastScene = cloneDeep(WebGAL.sceneManager.sceneData.currentScene); - // 重新获取场景 - const sceneUrl: string = assetSetter(sceneName, fileType.scene); - // 场景写入到运行时 - sceneFetcher(sceneUrl).then((rawScene) => { - // 等等,先检查一下能不能恢复场景 - const lastSameSentence = findLastSameSentence(pastScene, WebGAL.sceneManager.sceneData.currentScene, sentenceId); - const lastRecoverySentenceId = Math.min(sentenceId, lastSameSentence); - const recId = findLastAvailableBacklog(lastRecoverySentenceId, sceneName); - const isCanRec = recId >= 0 && expermental; - resetStage(!isCanRec); - WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); - // 开始快进到指定语句 - const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - WebGAL.gameplay.isFast = true; - if (isCanRec) { - jumpFromBacklog(recId, false); - } - if (syncFastTimeout) { - // 之前发生的跳转要清理掉 - clearTimeout(syncFastTimeout); - } - syncFast(sentenceId, currentSceneName); - }); -}; - -export function syncFast(sentenceId: number, currentSceneName: string) { - if ( - WebGAL.sceneManager.sceneData.currentSentenceId < sentenceId && - WebGAL.sceneManager.sceneData.currentScene.sceneName === currentSceneName - ) { - nextSentence(); - syncFastTimeout = setTimeout(() => syncFast(sentenceId, currentSceneName), 2); - } else { - WebGAL.gameplay.isFast = false; - } -} - -function findLastSameSentence(oldScene: IScene, newScene: IScene, sentenceId: number): number { - let lastSameSentence = 0; - for (let i = 0; i < sentenceId && i < oldScene.sentenceList.length; i++) { - const oldSentenceStr = JSON.stringify(oldScene.sentenceList[i]); - const newSentenceStr = JSON.stringify(newScene.sentenceList[i]); - if (oldSentenceStr !== newSentenceStr) { - break; - } - lastSameSentence = i; - } - return lastSameSentence; -} - -function findLastAvailableBacklog(targetSentence: number, sceneName: string) { - let lastAvailable = -1; - WebGAL.backlogManager.getBacklog().forEach((e, i) => { - const recSentenceId = e.saveScene.currentSentenceId; - const recSceneName = e.saveScene.sceneName; - if (recSentenceId <= targetSentence && recSceneName === sceneName) { - lastAvailable = i; - } - }); - return lastAvailable; -} diff --git a/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts b/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts deleted file mode 100644 index 6e9fb66f8..000000000 --- a/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { DebugCommand, IComponentVisibilityCommand, IDebugMessage } from '@/types/debugProtocol'; -import { webgalStore } from '@/store/store'; -import { setFontOptimization, setVisibility } from '@/store/GUIReducer'; -import { WebGAL } from '@/Core/WebGAL'; -import { sceneParser, WebgalParser } from '@/Core/parser/sceneParser'; -import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { runScript } from '@/Core/controller/gamePlay/runScript'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; -import { resetStage } from '@/Core/controller/stage/resetStage'; -import { logger } from '@/Core/util/logger'; -import { syncWithOrigine } from './syncWithOrigine'; -import { stageActions } from '@/store/stageReducer'; -import { baseTransform, IEffect } from '@/store/stageInterface'; - -export const webSocketFunc = () => { - const loc: string = window.location.hostname; - const protocol: string = window.location.protocol; - const port: string = window.location.port; // 获取端口号 - - // 默认情况下,不需要在URL中明确指定标准HTTP(80)和HTTPS(443)端口 - let defaultPort = ''; - if (port && port !== '80' && port !== '443') { - // 如果存在非标准端口号,将其包含在URL中 - defaultPort = `:${port}`; - } - - if (protocol !== 'http:' && protocol !== 'https:') { - return; - } - // 根据当前协议构建WebSocket URL,并包括端口号(如果有) - let wsUrl = `ws://${loc}${defaultPort}/api/webgalsync`; - if (protocol === 'https:') { - wsUrl = `wss://${loc}${defaultPort}/api/webgalsync`; - } - logger.info('正在启动socket连接位于:' + wsUrl); - const socket = new WebSocket(wsUrl); - socket.onopen = () => { - logger.info('socket已连接'); - function sendStageSyncMessage() { - const message: IDebugMessage = { - event: 'message', - data: { - command: DebugCommand.SYNCFC, - sceneMsg: { - scene: WebGAL.sceneManager.sceneData.currentScene.sceneName, - sentence: WebGAL.sceneManager.sceneData.currentSentenceId, - }, - stageSyncMsg: webgalStore.getState().stage, - message: 'sync', - }, - }; - socket.send(JSON.stringify(message)); - // logger.debug('传送信息', message); - setTimeout(sendStageSyncMessage, 1000); - } - sendStageSyncMessage(); - }; - socket.onmessage = (e) => { - // logger.info('收到信息', e.data); - const str: string = e.data; - const data: IDebugMessage = JSON.parse(str); - const message = data.data; - if (message.command === DebugCommand.JUMP) { - syncWithOrigine(message.sceneMsg.scene, message.sceneMsg.sentence, message.message === 'exp'); - } - if (message.command === DebugCommand.EXE_COMMAND) { - const command = message.message; - const scene = WebgalParser.parse(command, 'temp.txt', 'temp.txt'); - scene.sentenceList.forEach((sentence: ISentence) => { - runScript(sentence); - }); - } - if (message.command === DebugCommand.REFETCH_TEMPLATE_FILES) { - const title = document.querySelector('.html-body__title-enter') as HTMLElement; - if (title) { - title.style.display = 'none'; - } - WebGAL.events.styleUpdate.emit(); - } - if (message.command === DebugCommand.SET_COMPONENT_VISIBILITY) { - // handle SET_COMPONENT_VISIBILITY message - const command = message.message; - - const commandData = JSON.parse(command) as IComponentVisibilityCommand[]; - commandData.forEach((item) => { - if (item) { - webgalStore.dispatch(setVisibility({ component: item.component, visibility: item.visibility })); - } - }); - } - if (message.command === DebugCommand.TEMP_SCENE) { - const command = message.message; - resetStage(true); - WebGAL.sceneManager.sceneData.currentScene = sceneParser(command, 'temp', './temp.txt'); - webgalStore.dispatch(setVisibility({ component: 'showTitle', visibility: false })); - webgalStore.dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); - webgalStore.dispatch(setVisibility({ component: 'showPanicOverlay', visibility: false })); - setTimeout(() => { - nextSentence(); - }, 100); - } - if (message.command === DebugCommand.FONT_OPTIMIZATION) { - const command = message.message; - webgalStore.dispatch(setFontOptimization(command === 'true')); - } - if (message.command === DebugCommand.SET_EFFECT) { - try { - const effect = JSON.parse(message.message) as IEffect; - const targetEffect = webgalStore.getState().stage.effects.find((e) => e.target === effect.target); - const targetTransform = targetEffect?.transform ? targetEffect.transform : baseTransform; - const newTransform = { - ...targetTransform, - ...(effect.transform ?? {}), - position: { - ...targetTransform.position, - ...(effect.transform?.position ?? {}), - }, - scale: { - ...targetTransform.scale, - ...(effect.transform?.scale ?? {}), - }, - }; - WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(effect.target); - webgalStore.dispatch(stageActions.updateEffect({ target: effect.target, transform: newTransform })); - } catch (e) { - logger.error(`无法设置效果 ${message.message}, ${e}`); - return; - } - } - }; - socket.onerror = () => { - logger.info('当前没有连接到 Terre 编辑器'); - }; -}; diff --git a/packages/webgal/src/types/debugProtocol.ts b/packages/webgal/src/types/debugProtocol.ts deleted file mode 100644 index 60c17e2e4..000000000 --- a/packages/webgal/src/types/debugProtocol.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { IStageState } from '@/store/stageInterface'; - -export enum DebugCommand { - // 跳转 - JUMP, - // 同步自客户端 - SYNCFC, - // 同步自编辑器 - SYNCFE, - // 执行指令 - EXE_COMMAND, - // 重新拉取模板样式文件 - REFETCH_TEMPLATE_FILES, - // 返回主界面 - SET_COMPONENT_VISIBILITY, - // 临时场景 - TEMP_SCENE, - // 字体优化 - FONT_OPTIMIZATION, - // 直接设置效果 - SET_EFFECT, -} - -export interface IDebugMessage { - event: string; - data: { - command: DebugCommand; - sceneMsg: { - sentence: number; - scene: string; - }; - message: string; - stageSyncMsg: IStageState; - }; -} - -export interface IComponentsVisibility { - showStarter: boolean; // 是否显示初始界面(用于使得bgm可以播放) - showTitle: boolean; // 是否显示标题界面 - showMenuPanel: boolean; // 是否显示Menu界面 - showTextBox: boolean; - showControls: boolean; - controlsVisibility: boolean; - showBacklog: boolean; - showExtra: boolean; - showGlobalDialog: boolean; - showPanicOverlay: boolean; - isEnterGame: boolean; - isShowLogo: boolean; -} - -export interface IComponentVisibilityCommand { - component: keyof IComponentsVisibility; - visibility: boolean; -} diff --git a/packages/webgal/src/types/editorPreviewProtocol.test.ts b/packages/webgal/src/types/editorPreviewProtocol.test.ts new file mode 100644 index 000000000..2020f1a52 --- /dev/null +++ b/packages/webgal/src/types/editorPreviewProtocol.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + isHostEventEnvelope, + isHostEventType, + isPreviewCommandRequestEnvelope, + isPreviewCommandType, +} from './editorPreviewProtocol'; + +describe('editorPreviewProtocol artifacts', () => { + it('publishes the v1 subprotocol constant', async () => { + const moduleUrl = new URL('./editorPreviewProtocol.ts', import.meta.url); + + await expect(import(moduleUrl.href)).resolves.toMatchObject({ + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL: 'webgal-editor-preview-sync.v1', + }); + }); + + it('exposes a preview command type guard', () => { + expect(isPreviewCommandType('preview.command.sync-scene')).toBe(true); + expect(isPreviewCommandType('preview.command.run-snippet')).toBe(true); + expect(isPreviewCommandType('preview.command.unknown')).toBe(false); + expect(isPreviewCommandType('session.register-preview')).toBe(false); + }); + + it('accepts only executable preview command requests', () => { + expect( + isPreviewCommandRequestEnvelope({ + kind: 'request', + type: 'preview.command.sync-scene', + requestId: 'req-sync-scene', + payload: { + sceneName: 'scene/start.txt', + sentenceId: 0, + syncMode: 'stable', + }, + }), + ).toBe(true); + + expect( + isPreviewCommandRequestEnvelope({ + kind: 'request', + type: 'session.register-preview', + requestId: 'req-register-preview', + payload: {}, + }), + ).toBe(false); + + expect( + isPreviewCommandRequestEnvelope({ + kind: 'request', + type: 'preview.command.unknown', + requestId: 'req-unknown-command', + payload: {}, + }), + ).toBe(false); + + expect( + isPreviewCommandRequestEnvelope({ + kind: 'event', + type: 'preview.command.sync-scene', + payload: {}, + }), + ).toBe(false); + }); + + it('exposes a host event type guard', () => { + expect(isHostEventType('preview.ready.updated')).toBe(true); + expect(isHostEventType('stage.snapshot.updated')).toBe(true); + expect(isHostEventType('preview.command.sync-scene')).toBe(false); + expect(isHostEventType('preview.event.unknown')).toBe(false); + }); + + it('accepts only supported host events', () => { + expect( + isHostEventEnvelope({ + kind: 'event', + type: 'preview.ready.updated', + payload: { + ready: true, + }, + }), + ).toBe(true); + + expect( + isHostEventEnvelope({ + kind: 'event', + type: 'stage.snapshot.updated', + payload: { + sceneName: 'scene/start.txt', + sentenceId: 0, + stageState: {}, + }, + }), + ).toBe(true); + + expect( + isHostEventEnvelope({ + kind: 'request', + type: 'preview.ready.updated', + requestId: 'req-host-event', + payload: { + ready: true, + }, + }), + ).toBe(false); + + expect( + isHostEventEnvelope({ + kind: 'event', + type: 'preview.event.unknown', + payload: {}, + }), + ).toBe(false); + }); +}); diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts new file mode 100644 index 000000000..fe38ffa71 --- /dev/null +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -0,0 +1,243 @@ +import type { IEffect, ITransform } from '@/store/stageInterface'; +import type { componentsVisibility } from '@/store/guiInterface'; + +export const EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL = 'webgal-editor-preview-sync.v1' as const; + +type EmptyObject = Record; + +export interface SyncScenePayload { + sceneName: string; + sentenceId: number; + syncMode: 'stable' | 'fast'; +} + +export interface RunSceneContentPayload { + sceneContent: string; +} + +export interface RunSnippetPayload { + snippet: string; +} + +export type ReloadTemplatesPayload = EmptyObject; + +export interface SetEffectPayload { + target: IEffect['target']; + transform?: Partial> & { + position?: Partial; + scale?: Partial; + }; +} + +export type SetComponentVisibilityPayload = Partial>; + +export interface SetFontOptimizationPayload { + enabled: boolean; +} + +export interface PreviewCommandPayloadByType { + 'preview.command.sync-scene': SyncScenePayload; + 'preview.command.run-scene-content': RunSceneContentPayload; + 'preview.command.run-snippet': RunSnippetPayload; + 'preview.command.reload-templates': ReloadTemplatesPayload; + 'preview.command.set-effect': SetEffectPayload; + 'preview.command.set-component-visibility': SetComponentVisibilityPayload; + 'preview.command.set-font-optimization': SetFontOptimizationPayload; +} + +export type PreviewCommandType = keyof PreviewCommandPayloadByType; + +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', +] as const satisfies readonly PreviewCommandType[]; + +export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType {} + +export type PreviewRequestType = keyof PreviewRequestPayloadByType; + +const PREVIEW_REQUEST_TYPES = [...PREVIEW_COMMAND_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; +} + +export interface StageSnapshotUpdatedPayload { + sceneName: string; + sentenceId: number; + stageState: JsonObject; +} + +interface EventPayloadByType { + 'preview.ready.updated': PreviewReadyUpdatedPayload; + 'stage.snapshot.updated': StageSnapshotUpdatedPayload; +} + +export type HostEventType = keyof EventPayloadByType; + +const HOST_EVENT_TYPES = [ + 'preview.ready.updated', + 'stage.snapshot.updated', +] as const satisfies readonly HostEventType[]; + +export interface RegisterPreviewRequestPayload { + gameId?: string; + embeddedLaunchId?: string; +} + +interface SessionRequestPayloadByType { + 'session.register-preview': RegisterPreviewRequestPayload; +} + +interface RequestPayloadByType extends SessionRequestPayloadByType, PreviewRequestPayloadByType {} + +export interface PreviewCommandResponsePayloadByType extends Record {} + +export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType {} + +interface SessionResponsePayloadByType { + 'session.register-preview': EmptyObject; +} + +interface ResponsePayloadByType extends SessionResponsePayloadByType, PreviewResponsePayloadByType {} + +export interface EventEnvelope { + kind: 'event'; + type: TType; + payload: TPayload; +} + +export interface RequestEnvelope { + kind: 'request'; + type: TType; + requestId: string; + payload: TPayload; +} + +export interface ResponseEnvelope { + kind: 'response'; + type: TType; + requestId: string; + payload: TPayload; +} + +type EventEnvelopeByType = { + [K in TType]: EventEnvelope; +}[TType]; + +type RequestEnvelopeByType = { + [K in TType]: RequestEnvelope; +}[TType]; + +type ResponseEnvelopeByType = { + [K in TType]: ResponseEnvelope; +}[TType]; + +export type ProtocolEnvelope = EventEnvelopeByType | RequestEnvelopeByType | ResponseEnvelopeByType; + +export function createEventEnvelope( + type: TType, + payload: EventPayloadByType[TType], +): EventEnvelopeByType { + return { + kind: 'event', + type, + payload, + }; +} + +export function createRequestEnvelope( + type: TType, + requestId: string, + payload: RequestPayloadByType[TType], +): RequestEnvelopeByType { + return { + kind: 'request', + type, + requestId, + payload, + }; +} + +export function createResponseEnvelope( + type: TType, + requestId: string, + payload: ResponsePayloadByType[TType], +): ResponseEnvelopeByType { + return { + kind: 'response', + type, + requestId, + payload, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasEnvelopeShape(value: unknown, kind: ProtocolEnvelope['kind']): value is Record { + return ( + isRecord(value) && + value.kind === kind && + typeof value.type === 'string' && + 'payload' in value && + (kind === 'event' || typeof value.requestId === 'string') + ); +} + +function isMessageType(value: unknown, acceptedTypes: readonly TType[]): value is TType { + return typeof value === 'string' && acceptedTypes.includes(value as TType); +} + +function isEventEnvelope(value: unknown): value is EventEnvelope { + return hasEnvelopeShape(value, 'event'); +} + +function isRequestEnvelope(value: unknown): value is RequestEnvelope { + return hasEnvelopeShape(value, 'request'); +} + +function isResponseEnvelope(value: unknown): value is ResponseEnvelope { + return hasEnvelopeShape(value, 'response'); +} + +export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { + return isEventEnvelope(value) || isRequestEnvelope(value) || isResponseEnvelope(value); +} + +export function isPreviewCommandType(value: unknown): value is PreviewCommandType { + return isMessageType(value, PREVIEW_COMMAND_TYPES); +} + +export function isPreviewRequestType(value: unknown): value is PreviewRequestType { + return isMessageType(value, PREVIEW_REQUEST_TYPES); +} + +export function isHostEventType(value: unknown): value is HostEventType { + return isMessageType(value, HOST_EVENT_TYPES); +} + +export function isHostEventEnvelope(value: unknown): value is EventEnvelopeByType { + return isEventEnvelope(value) && isHostEventType(value.type); +} + +export function isPreviewCommandRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isPreviewCommandType(value.type); +} + +export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isPreviewRequestType(value.type); +}