diff --git a/package.json b/package.json index 9a7d7ff63..3622586c3 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "workspaces": { "packages": [ "packages/dev-server", + "packages/editor-preview-protocol", "packages/terre2", "packages/origine2" ], diff --git a/packages/editor-preview-protocol/.gitignore b/packages/editor-preview-protocol/.gitignore new file mode 100644 index 000000000..849ddff3b --- /dev/null +++ b/packages/editor-preview-protocol/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/editor-preview-protocol/package.json b/packages/editor-preview-protocol/package.json new file mode 100644 index 000000000..b51be7c34 --- /dev/null +++ b/packages/editor-preview-protocol/package.json @@ -0,0 +1,16 @@ +{ + "name": "@webgal/editor-preview-protocol", + "private": true, + "version": "1.0.0", + "license": "MPL-2.0", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/types/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "rimraf dist", + "build": "tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json" + } +} diff --git a/packages/editor-preview-protocol/src/index.ts b/packages/editor-preview-protocol/src/index.ts new file mode 100644 index 000000000..4ea53a06a --- /dev/null +++ b/packages/editor-preview-protocol/src/index.ts @@ -0,0 +1,263 @@ +import type { Transform } from './stage' + +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: string; + transform?: Transform; +} + +export type SetComponentVisibilityPayload = { + showStarter?: boolean; + showTitle?: boolean; + showMenuPanel?: boolean; + showTextBox?: boolean; + showControls?: boolean; + controlsVisibility?: boolean; + showBacklog?: boolean; + showExtra?: boolean; + showGlobalDialog?: boolean; + showPanicOverlay?: boolean; + isEnterGame?: boolean; + isShowLogo?: boolean; + enableAppreciationMode?: boolean; + fontOptimization?: boolean; +}; + +export interface SetFontOptimizationPayload { + enabled: boolean; +} + +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; + +export 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; + +export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType {} + +export type PreviewRequestType = keyof PreviewRequestPayloadByType; + +const PREVIEW_REQUEST_TYPES = PREVIEW_COMMAND_TYPES; + +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; +} + +export interface EventPayloadByType { + 'preview.ready.updated': PreviewReadyUpdatedPayload; + 'stage.snapshot.updated': StageSnapshotUpdatedPayload; +} + +export type HostEventType = keyof EventPayloadByType; + +export const HOST_EVENT_TYPES = [ + 'preview.ready.updated', + 'stage.snapshot.updated', +] as const; + +export interface RegisterPreviewRequestPayload { + gameId?: string; + embeddedLaunchId?: string; +} + +export interface SessionRequestPayloadByType { + 'session.register-preview': RegisterPreviewRequestPayload; +} + +export interface RequestPayloadByType extends SessionRequestPayloadByType, PreviewRequestPayloadByType {} + +export interface PreviewCommandResponsePayloadByType extends Record {} + +export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType {} + +export interface SessionResponsePayloadByType { + 'session.register-preview': EmptyObject; +} + +export 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< + TPayload = unknown, + TType extends string = string, +> { + 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< + TType extends keyof ResponsePayloadByType, +>( + 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); +} + +export function isEventEnvelope(value: unknown): value is EventEnvelope { + return hasEnvelopeShape(value, 'event'); +} + +export function isRequestEnvelope(value: unknown): value is RequestEnvelope { + return hasEnvelopeShape(value, 'request'); +} + +export 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); +} diff --git a/packages/editor-preview-protocol/src/stage.ts b/packages/editor-preview-protocol/src/stage.ts new file mode 100644 index 000000000..5552392d0 --- /dev/null +++ b/packages/editor-preview-protocol/src/stage.ts @@ -0,0 +1,46 @@ +export interface Point2D { + x?: number + y?: number +} + +export type FilterFlag = 0 | 1 + +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?: FilterFlag + dotFilm?: FilterFlag + reflectionFilm?: FilterFlag + glitchFilm?: FilterFlag + rgbFilm?: FilterFlag + godrayFilm?: FilterFlag + + shockwaveFilter?: number + radiusAlphaFilter?: number +} diff --git a/packages/editor-preview-protocol/tsconfig.cjs.json b/packages/editor-preview-protocol/tsconfig.cjs.json new file mode 100644 index 000000000..e440dffa5 --- /dev/null +++ b/packages/editor-preview-protocol/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "declaration": false, + "outDir": "./dist/cjs" + } +} diff --git a/packages/editor-preview-protocol/tsconfig.esm.json b/packages/editor-preview-protocol/tsconfig.esm.json new file mode 100644 index 000000000..cb369e230 --- /dev/null +++ b/packages/editor-preview-protocol/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "declaration": true, + "declarationMap": true, + "outDir": "./dist/esm", + "declarationDir": "./dist/types" + } +} diff --git a/packages/editor-preview-protocol/tsconfig.json b/packages/editor-preview-protocol/tsconfig.json new file mode 100644 index 000000000..0b27064ce --- /dev/null +++ b/packages/editor-preview-protocol/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2019", + "strict": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "skipLibCheck": true, + "types": [], + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/origine2/.eslintrc.js b/packages/origine2/.eslintrc.js index 1e64b73da..de77c2a88 100644 --- a/packages/origine2/.eslintrc.js +++ b/packages/origine2/.eslintrc.js @@ -1,5 +1,6 @@ module.exports = { extends: ['alloy', 'alloy/react', 'alloy/typescript'], + ignorePatterns: ['package.json'], env: { // 你的环境变量(包含多个预定义的全局变量) // diff --git a/packages/origine2/package.json b/packages/origine2/package.json index 0dda0a9bb..e6d7e2071 100644 --- a/packages/origine2/package.json +++ b/packages/origine2/package.json @@ -4,9 +4,10 @@ "version": "4.5.18", "license": "MPL-2.0", "scripts": { - "dev": "lingui extract --clean && lingui compile --typescript && vite --host", - "build": "lingui extract --clean && lingui compile --typescript && tsc && vite build --base=./", - "build-lowram": "lingui extract --clean && lingui compile --typescript && tsc && node --max_old_space_size=512000 ./node_modules/bin/vite build --base=./", + "sync:editor-preview-protocol": "yarn workspace @webgal/editor-preview-protocol build", + "dev": "yarn run sync:editor-preview-protocol && lingui extract --clean && lingui compile --typescript && vite --host", + "build": "yarn run sync:editor-preview-protocol && lingui extract --clean && lingui compile --typescript && tsc && vite build --base=./", + "build-lowram": "yarn run sync:editor-preview-protocol && lingui extract --clean && lingui compile --typescript && tsc && node --max_old_space_size=512000 ./node_modules/bin/vite build --base=./", "preview": "vite preview", "lint": "eslint src/** --fix", "openapi": "tsx openapi.ts", @@ -33,6 +34,7 @@ "@icon-park/react": "^1.4.2", "@lingui/macro": "^4.8.0", "@lingui/react": "^4.8.0", + "@webgal/editor-preview-protocol": "1.0.0", "@monaco-editor/react": "^4.4.5", "@types/parse-path": "^7.1.0", "@uiw/react-json-view": "^2.0.0-alpha.12", diff --git a/packages/origine2/src/pages/editor/EditorSidebar/EditorSidebar.tsx b/packages/origine2/src/pages/editor/EditorSidebar/EditorSidebar.tsx index c9faef6ea..9ccf9af18 100644 --- a/packages/origine2/src/pages/editor/EditorSidebar/EditorSidebar.tsx +++ b/packages/origine2/src/pages/editor/EditorSidebar/EditorSidebar.tsx @@ -1,5 +1,5 @@ import styles from "./editorSidebar.module.scss"; -import Assets, { IFile, IFileConfig, IFileFunction } from "@/components/Assets/Assets"; +import Assets, { IFileConfig, IFileFunction } from "@/components/Assets/Assets"; import React, { useEffect, useRef } from "react"; import { eventBus } from "@/utils/eventBus"; import { Button, Switch, Tab, TabList } from "@fluentui/react-components"; @@ -8,8 +8,10 @@ import { useGameEditorContext } from "@/store/useGameEditorStore"; import { IGameEditorSidebarTabs, ITag } from "@/types/gameEditor"; import { t } from "@lingui/macro"; import { ArrowClockwiseFilled, ArrowClockwiseRegular, LiveFilled, LiveOffFilled, LiveOffRegular, LiveRegular, OpenFilled, OpenRegular, bundleIcon } from "@fluentui/react-icons"; -import { WsUtil } from "@/utils/wsUtil"; +import { EditorPreviewClient } from "@/utils/editorPreviewClient"; +import { createPreviewBootstrapProvide, isPreviewBootstrapRequest } from "@/utils/editorPreviewBootstrap"; import TransformableBox from '@/pages/editor/TransformableBox/TransformableBox'; +import { createId } from '@/utils/createId'; let startX = 0; let prevXvalue = 0; @@ -38,18 +40,71 @@ export default function EditorSideBar() { const PreviewControlRef = useRef(null); const ifRef = useRef(null); + const embeddedLaunchIdRef = useRef(createId()); + + useEffect(() => { + EditorPreviewClient.ensureConnected(); + }, []); + useEffect(() => { - if (ifRef.current) { - // @ts-ignore - ifRef!.current!.contentWindow.console.log = function () { - }; + const handleBootstrapMessage = (event: MessageEvent) => { + const iframeWindow = ifRef.current?.contentWindow; + if (!iframeWindow || event.source !== iframeWindow || !isPreviewBootstrapRequest(event.data)) { + return; + } - ifRef.current.onload = () => setTimeout(() => WsUtil.sendFontOptimizationCommand(isUseFontOptimization), 1000); + iframeWindow.postMessage(createPreviewBootstrapProvide(embeddedLaunchIdRef.current), '*'); + }; + + window.addEventListener('message', handleBootstrapMessage); + return () => { + window.removeEventListener('message', handleBootstrapMessage); + }; + }, []); + + useEffect(() => { + if (isShowPreview) { + embeddedLaunchIdRef.current = createId(); } - }); + }, [gameDir, isShowPreview]); useEffect(() => { - WsUtil.sendFontOptimizationCommand(isUseFontOptimization); + const iframeElement = ifRef.current; + if (!iframeElement) { + return; + } + + iframeElement.onload = () => { + const iframeWindow = iframeElement.contentWindow; + if (!iframeWindow) { + return; + } + + (iframeWindow as Window & { console: { log: (...args: unknown[]) => void } }).console.log = function () {}; + }; + + return () => { + iframeElement.onload = null; + }; + }, [gameDir, isShowPreview]); + + useEffect(() => { + const handlePreviewReady = ({ ready }: { ready: boolean }) => { + if (!ready) { + return; + } + + EditorPreviewClient.setFontOptimization(isUseFontOptimization); + }; + + eventBus.on('editor-preview:ready', handlePreviewReady); + return () => { + eventBus.off('editor-preview:ready', handlePreviewReady); + }; + }, [isUseFontOptimization]); + + useEffect(() => { + EditorPreviewClient.setFontOptimization(isUseFontOptimization); }, [isUseFontOptimization]); useEffect(() => { @@ -82,7 +137,7 @@ export default function EditorSideBar() { }; - const handleDragEnd = (event: MouseEvent) => { + const handleDragEnd = (_event: MouseEvent) => { setTimeout(() => { const prevX = document.body.style.getPropertyValue("--sidebar-width"); prevXvalue = parseInt(prevX.substring(0, prevX.length - 2), 10); @@ -103,7 +158,10 @@ export default function EditorSideBar() { }; }, []); - const refreshGame = () => ifRef.current?.contentWindow?.location.reload(); + const refreshGame = () => { + embeddedLaunchIdRef.current = createId(); + ifRef.current?.contentWindow?.location.reload(); + }; useEffect(() => { eventBus.on('iframe:refresh-game', refreshGame); diff --git a/packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx b/packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx index 362c6133b..a3a0059f4 100644 --- a/packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx +++ b/packages/origine2/src/pages/editor/GraphicalEditor/GraphicalEditor.tsx @@ -2,7 +2,7 @@ import { useValue } from "../../../hooks/useValue"; import { parseScene } from "./parser"; import axios from "axios"; import { useEffect, useMemo } from "react"; -import { WsUtil } from "../../../utils/wsUtil"; +import { EditorPreviewClient } from "../../../utils/editorPreviewClient"; import { mergeToString, splitToArray } from "./utils/sceneTextProcessor"; import styles from "./graphicalEditor.module.scss"; import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; @@ -93,7 +93,11 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) { path: props.targetPath }).then(() => { const targetValue = newSentences[index].content; - WsUtil.sendSyncCommand(props.targetPath, updateIndex, targetValue); + EditorPreviewClient.sendSyncScene({ + scenePath: props.targetPath, + lineNumber: updateIndex, + lineCommandString: targetValue, + }); fetchScene(); }); } @@ -158,7 +162,12 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) { function syncToIndex(index: number) { const targetValue = sentenceData.value[index]?.content || ""; - WsUtil.sendSyncCommand(props.targetPath, index + 1, targetValue, true); + EditorPreviewClient.sendSyncScene({ + scenePath: props.targetPath, + lineNumber: index + 1, + lineCommandString: targetValue, + force: true, + }); editorLineHolder.recordSceneEditingLine(props.targetPath, index + 1); // 传递假消息,为了在不使用此功能的时候清除拖拽框 eventBus.emit('editor:pixi-sync-command', { @@ -207,7 +216,11 @@ export default function GraphicalEditor(props: IGraphicalEditorProps) { useEffect(() => { const handleDragUpdate = (data: any) => { fetchScene(); - WsUtil.sendSyncCommand(data.targetPath, data.lineNumber, data.newCommand); + EditorPreviewClient.sendSyncScene({ + scenePath: data.targetPath, + lineNumber: data.lineNumber, + lineCommandString: data.newCommand, + }); }; eventBus.on('editor:drag-update-scene', handleDragUpdate); return () => { diff --git a/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/ChangeBg.tsx b/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/ChangeBg.tsx index 0722649b9..9eb351b5e 100644 --- a/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/ChangeBg.tsx +++ b/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/ChangeBg.tsx @@ -15,7 +15,7 @@ import { combineSubmitString } from "@/utils/combineSubmitString"; import { extNameMap } from "../../ChooseFile/chooseFileConfig"; import WheelDropdown from "../components/WheelDropdown"; import { useEaseTypeOptions } from "@/hooks/useEaseTypeOptions"; -import { WsUtil } from "@/utils/wsUtil"; +import { EditorPreviewClient } from "@/utils/editorPreviewClient"; export default function ChangeBg(props: ISentenceEditorProps) { const isNoFile = props.sentence.content === ""; @@ -223,8 +223,7 @@ export default function ChangeBg(props: ISentenceEditorProps) { submit(); }} onUpdate={(transform) => { - const newEffect = { target: 'bg-main', transform: transform }; - WsUtil.sendSetEffectCommand(JSON.stringify(newEffect)); + EditorPreviewClient.setEffect({ target: 'bg-main', transform }); }} sentence={props.sentence} index={props.index} diff --git a/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/ChangeFigure.tsx b/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/ChangeFigure.tsx index d82473efd..b6b633f92 100644 --- a/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/ChangeFigure.tsx +++ b/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/ChangeFigure.tsx @@ -18,7 +18,7 @@ import { combineSubmitString } from "@/utils/combineSubmitString"; import { extNameMap } from "../../ChooseFile/chooseFileConfig"; import SearchableCascader from "@/pages/editor/GraphicalEditor/components/SearchableCascader"; import { useEaseTypeOptions } from "@/hooks/useEaseTypeOptions"; -import { WsUtil } from "@/utils/wsUtil"; +import { EditorPreviewClient } from "@/utils/editorPreviewClient"; import { OptionCategory } from "../components/OptionCategory"; type FigurePosition = "" | "left" | "right"; @@ -327,8 +327,7 @@ export default function ChangeFigure(props: ISentenceEditorProps) { target = "fig-center"; } } - const newEffect = { target: target, transform: transform }; - WsUtil.sendSetEffectCommand(JSON.stringify(newEffect)); + EditorPreviewClient.setEffect({ target, transform }); }} sentence={props.sentence} index={props.index} diff --git a/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/SetTempAnimation.tsx b/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/SetTempAnimation.tsx index c7344ecfc..aa13bd865 100644 --- a/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/SetTempAnimation.tsx +++ b/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/SetTempAnimation.tsx @@ -9,7 +9,7 @@ import WheelDropdown from "@/pages/editor/GraphicalEditor/components/WheelDropdo import { combineSubmitString } from "@/utils/combineSubmitString"; import { TerrePanel } from "../components/TerrePanel"; import { EffectEditor } from "../components/EffectEditor"; -import { WsUtil } from "@/utils/wsUtil"; +import { EditorPreviewClient } from "@/utils/editorPreviewClient"; import { Button, Menu, MenuItem, MenuList, MenuPopover, MenuTrigger, Text } from "@fluentui/react-components"; import useEditorStore from "@/store/useEditorStore"; import { useEaseTypeOptions } from "@/hooks/useEaseTypeOptions"; @@ -228,8 +228,7 @@ export default function SetTempAnimation(props: ISentenceEditorProps) { submit(); }} onUpdate={(transform)=>{ - const newEffect = { target: target.value, transform: transform }; - WsUtil.sendSetEffectCommand(JSON.stringify(newEffect)); + EditorPreviewClient.setEffect({ target: target.value, transform }); }} sentence={props.sentence} index={props.index} diff --git a/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/SetTransform.tsx b/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/SetTransform.tsx index 341597848..1bcc7048d 100644 --- a/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/SetTransform.tsx +++ b/packages/origine2/src/pages/editor/GraphicalEditor/SentenceEditor/SetTransform.tsx @@ -12,7 +12,7 @@ import useEditorStore from "@/store/useEditorStore"; import { t } from "@lingui/macro"; import { combineSubmitString } from "@/utils/combineSubmitString"; import { useEaseTypeOptions } from "@/hooks/useEaseTypeOptions"; -import { WsUtil } from "@/utils/wsUtil"; +import { EditorPreviewClient } from "@/utils/editorPreviewClient"; type PresetTarget = "fig-left" | "fig-center" | "fig-right" | "bg-main" | "stage-main"; @@ -73,8 +73,7 @@ export default function SetTransform(props: ISentenceEditorProps) { submit(); }} onUpdate={(transform) => { - const newEffect = { target: target.value, transform: transform }; - WsUtil.sendSetEffectCommand(JSON.stringify(newEffect)); + EditorPreviewClient.setEffect({ target: target.value, transform }); }} sentence={props.sentence} index={props.index} diff --git a/packages/origine2/src/pages/editor/GraphicalEditor/components/EffectEditor.tsx b/packages/origine2/src/pages/editor/GraphicalEditor/components/EffectEditor.tsx index c4fcfa974..5f6c2c0af 100644 --- a/packages/origine2/src/pages/editor/GraphicalEditor/components/EffectEditor.tsx +++ b/packages/origine2/src/pages/editor/GraphicalEditor/components/EffectEditor.tsx @@ -17,7 +17,7 @@ import type { import { rgbToColor } from '@/pages/editor/GraphicalEditor/utils/rgbToColor'; import WheelDropdown from './WheelDropdown'; import useEditorStore from '@/store/useEditorStore'; -import { WsUtil } from '@/utils/wsUtil'; +import { EditorPreviewClient } from '@/utils/editorPreviewClient'; import { eventBus } from '@/utils/eventBus'; import { ISentence } from 'webgal-parser/src/interface/sceneInterface'; @@ -466,7 +466,11 @@ export function EffectEditor(props: { useEffect(() => { const lineContent = sentenceToRawLine(props.sentence); if (lineContent.startsWith("changeFigure") || lineContent.startsWith("setTransform")) { - WsUtil.sendSyncCommand(props.targetPath, props.index, lineContent); + EditorPreviewClient.sendSyncScene({ + scenePath: props.targetPath, + lineNumber: props.index, + lineCommandString: lineContent, + }); eventBus.emit('editor:pixi-sync-command', { targetPath: props.targetPath, lineNumber: props.index, diff --git a/packages/origine2/src/pages/editor/MainArea/EditorDebugger/EditorDebugger.tsx b/packages/origine2/src/pages/editor/MainArea/EditorDebugger/EditorDebugger.tsx index a7646c7c3..633e22b15 100644 --- a/packages/origine2/src/pages/editor/MainArea/EditorDebugger/EditorDebugger.tsx +++ b/packages/origine2/src/pages/editor/MainArea/EditorDebugger/EditorDebugger.tsx @@ -6,42 +6,28 @@ import {ReactNode, useEffect} from "react"; import {eventBus} from "@/utils/eventBus"; import {Terminal} from 'primereact/terminal'; import {TerminalService} from 'primereact/terminalservice'; -import {WsUtil} from "@/utils/wsUtil"; -import {IDebugMessage} from "@/types/debugProtocol"; +import {EditorPreviewClient} from "@/utils/editorPreviewClient"; export default function EditorDebugger() { const mode = useValue<'state' | 'console'>('console'); - const editorValue = useValue({}); + const editorValue = useValue(EditorPreviewClient.getLastStageSnapshot()?.stageState ?? {}); useEffect(() => { - - const handleMessage = ({ message }: { message: string }) => { - let obj = {}; - try { - const result = JSON.parse(message) as IDebugMessage; - if(result) obj=result.data; - }catch (e){ - // 什么也不做 - // 错误处理,你为什么只是看着!!!!!! - } - // @ts-ignore - const value = obj?.stageSyncMsg; - if (value) { - editorValue.set(value); - } + const handleStageSnapshot = ({ snapshot }: { snapshot: { stageState: object } }) => { + editorValue.set(snapshot.stageState); }; - eventBus.on('web-socket:on-message', handleMessage); + eventBus.on('editor-preview:stage-snapshot', handleStageSnapshot); return () => { - eventBus.off('web-socket:on-message', handleMessage); + eventBus.off('editor-preview:stage-snapshot', handleStageSnapshot); }; }, []); useEffect(() => { const commandHandler = (text: string) => { - WsUtil.sendExeCommand(text); + EditorPreviewClient.runSnippet(text); TerminalService.emit('response', 'Command sent.'); }; diff --git a/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx b/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx index 27d61487f..7e7398fd8 100644 --- a/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx +++ b/packages/origine2/src/pages/editor/TextEditor/TextEditor.tsx @@ -8,7 +8,7 @@ import debounce from 'lodash/debounce'; // 语法高亮文件 import { editorLineHolder, lspSceneName, WG_ORIGINE_RUNTIME } from '../../../runtime/WG_ORIGINE_RUNTIME'; -import { WsUtil } from '../../../utils/wsUtil'; +import { EditorPreviewClient } from '../../../utils/editorPreviewClient'; import { eventBus } from '@/utils/eventBus'; import useEditorStore from '@/store/useEditorStore'; import { useGameEditorContext } from '@/store/useGameEditorStore'; @@ -50,7 +50,11 @@ export default function TextEditor(props: ITextEditorProps) { const targetValue = editorValue.split('\n')[event.position.lineNumber - 1]; if (event.reason === monaco.editor.CursorChangeReason.Explicit) { if (event.position.lineNumber !== previousCursorPosition.lineNumber) { - WsUtil.sendSyncCommand(target?.path ?? '', event.position.lineNumber, targetValue); + EditorPreviewClient.sendSyncScene({ + scenePath: target?.path ?? '', + lineNumber: event.position.lineNumber, + lineCommandString: targetValue, + }); } } editorLineHolder.recordSceneEditingPosition(props.targetPath, event.position); @@ -119,7 +123,11 @@ export default function TextEditor(props: ITextEditorProps) { eventBus.emit('editor:update-scene', { scene: currentText.current }); api.assetsControllerEditTextFile({textFile: currentText.current, path: props.targetPath}).then((res) => { const targetValue = currentText.current.split('\n')[lineNumber - 1]; - WsUtil.sendSyncCommand(target?.path ?? '', lineNumber, targetValue); + EditorPreviewClient.sendSyncScene({ + scenePath: target?.path ?? '', + lineNumber, + lineCommandString: targetValue, + }); }); }, 500); diff --git a/packages/origine2/src/pages/editor/Topbar/tabs/GameConfig/GameConfig.tsx b/packages/origine2/src/pages/editor/Topbar/tabs/GameConfig/GameConfig.tsx index bfb16ee7a..19763ce2b 100644 --- a/packages/origine2/src/pages/editor/Topbar/tabs/GameConfig/GameConfig.tsx +++ b/packages/origine2/src/pages/editor/Topbar/tabs/GameConfig/GameConfig.tsx @@ -17,7 +17,7 @@ import {api} from "@/api"; import {t, Trans} from "@lingui/macro"; import useSWR from "swr"; import axios from "axios"; -import {WsUtil} from "@/utils/wsUtil"; +import {EditorPreviewClient} from "@/utils/editorPreviewClient"; import { TemplateConfigDto, TemplateInfoDto } from "@/api/Api"; import { IconWithTextItem } from "../../components/IconWithTextItem"; import IconCreator from "@/components/IconCreator/IconCreator"; @@ -63,7 +63,7 @@ export default function GameConfig() { if (selectedTemplate) { await api.manageTemplateControllerApplyTemplateToGame({gameDir, templateDir: selectedTemplate.dir}); // 更新模板后,让游戏再去拉一次模板的样式文件 - WsUtil.sendTemplateRefetchCommand(); + EditorPreviewClient.reloadTemplates(); await currentTemplateResp.mutate(); } } diff --git a/packages/origine2/src/pages/templateEditor/TemplateEditor.tsx b/packages/origine2/src/pages/templateEditor/TemplateEditor.tsx index b5303b52c..6b951f857 100644 --- a/packages/origine2/src/pages/templateEditor/TemplateEditor.tsx +++ b/packages/origine2/src/pages/templateEditor/TemplateEditor.tsx @@ -3,7 +3,7 @@ import TemplateEditorSidebar from "./TemplateEditorSidebar/TemplateEditorSidebar import TemplateEditorMainAria from "./TemplateEditorMainAria/TemplateEditorMainAria"; import styles from "./templateEditor.module.scss"; import { useTemplateEditorContext } from "@/store/useTemplateEditorStore"; -import { WsUtil } from "@/utils/wsUtil"; +import { EditorPreviewClient } from "@/utils/editorPreviewClient"; import { useComponentTreeChoose, useComponentTreeTextbox, @@ -24,18 +24,17 @@ export default function TemplateEditor() { export const sendComponentPreviewMessage = (componentPath: string, componentClass: string)=> { if (componentPath.includes(useComponentTreeTitle().path)) { - // set scene to title - WsUtil.setComponentVisibility([ - { component: "showTitle", visibility: true }, - { component: "showPanicOverlay", visibility: false }, - ]); + EditorPreviewClient.setComponentVisibility({ + showTitle: true, + showPanicOverlay: false, + }); } else if (componentPath.includes(useComponentTreeTextbox().path)) { - const miniAvatar = !componentClass.toLowerCase().includes("miniavataroff") ? "miniavatar.webp" : ""; - WsUtil.runTempScene(`changeBg:bg.webp -next;\nminiAvatar:${miniAvatar} -next;\n${useTemplateTempScene().textbox}`); + const miniAvatar = componentClass.toLowerCase().includes("miniavataroff") ? "" : "miniavatar.webp"; + EditorPreviewClient.runSceneContent(`changeBg:bg.webp -next;\nminiAvatar:${miniAvatar} -next;\n${useTemplateTempScene().textbox}`); } else if (componentPath.includes(useComponentTreeChoose().path)) { - WsUtil.runTempScene(`changeBg:bg.webp -next;\n${useTemplateTempScene().choose}`); + EditorPreviewClient.runSceneContent(`changeBg:bg.webp -next;\n${useTemplateTempScene().choose}`); } }; diff --git a/packages/origine2/src/pages/templateEditor/TemplateGraphicalEditor/TemplateGraphicalEditor.tsx b/packages/origine2/src/pages/templateEditor/TemplateGraphicalEditor/TemplateGraphicalEditor.tsx index 78264005e..a53032e6a 100644 --- a/packages/origine2/src/pages/templateEditor/TemplateGraphicalEditor/TemplateGraphicalEditor.tsx +++ b/packages/origine2/src/pages/templateEditor/TemplateGraphicalEditor/TemplateGraphicalEditor.tsx @@ -5,7 +5,7 @@ import {extractCss} from "@/pages/templateEditor/TemplateGraphicalEditor/utils/e import {formCss} from "@/pages/templateEditor/TemplateGraphicalEditor/utils/formCss"; import {updateScssFile} from "@/pages/templateEditor/TemplateGraphicalEditor/utils/updateScssFile"; import WebgalClassEditor from "@/pages/templateEditor/TemplateGraphicalEditor/WebgalClassEditor"; -import {WsUtil} from "@/utils/wsUtil"; +import {EditorPreviewClient} from "@/utils/editorPreviewClient"; import WithStateEditor from "@/pages/templateEditor/TemplateGraphicalEditor/withStateEditor"; import {t} from "@lingui/macro"; import s from './templateGraphicalEditor.module.scss'; @@ -34,7 +34,7 @@ export default function TemplateGraphicalEditor(props: ITemplateGraphicalEditorP const handleSubmit = async () => { await updateScssFile(path, className, formCss(extracted)); await classDataResp.mutate(); - WsUtil.sendTemplateRefetchCommand(); + EditorPreviewClient.reloadTemplates(); }; function addStateToCss(state:string){ diff --git a/packages/origine2/src/pages/templateEditor/TextEditor/TextEditor.tsx b/packages/origine2/src/pages/templateEditor/TextEditor/TextEditor.tsx index 6a184fc07..0d1525373 100644 --- a/packages/origine2/src/pages/templateEditor/TextEditor/TextEditor.tsx +++ b/packages/origine2/src/pages/templateEditor/TextEditor/TextEditor.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import useSWR, { useSWRConfig } from 'swr'; import axios from 'axios'; import { api } from '@/api'; -import { WsUtil } from '@/utils/wsUtil'; +import { EditorPreviewClient } from '@/utils/editorPreviewClient'; import useEditorStore from '@/store/useEditorStore'; export default function TextEditor({ path }: { path: string }) { @@ -21,7 +21,7 @@ export default function TextEditor({ path }: { path: string }) { const update = async (text: string) => { await api.assetsControllerEditTextFile({ textFile: text, path: path }); await mutate(path); - WsUtil.sendTemplateRefetchCommand(); + EditorPreviewClient.reloadTemplates(); }; const iframeRef = useRef(null); diff --git a/packages/origine2/src/types/debugProtocol.ts b/packages/origine2/src/types/debugProtocol.ts deleted file mode 100644 index d4c51bbd3..000000000 --- a/packages/origine2/src/types/debugProtocol.ts +++ /dev/null @@ -1,55 +0,0 @@ -import {IStageState} from "@/types/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/origine2/src/utils/editorPreviewBootstrap.ts b/packages/origine2/src/utils/editorPreviewBootstrap.ts new file mode 100644 index 000000000..d0bddacc4 --- /dev/null +++ b/packages/origine2/src/utils/editorPreviewBootstrap.ts @@ -0,0 +1,22 @@ +export const WEBGAL_PREVIEW_BOOTSTRAP_REQUEST = 'webgal.preview.bootstrap.request'; +export const WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE = 'webgal.preview.bootstrap.provide'; + +export interface PreviewBootstrapRequest { + type: typeof WEBGAL_PREVIEW_BOOTSTRAP_REQUEST; +} + +export interface PreviewBootstrapProvide { + type: typeof WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE; + embeddedLaunchId: string; +} + +export function isPreviewBootstrapRequest(value: unknown): value is PreviewBootstrapRequest { + return typeof value === 'object' && value !== null && (value as PreviewBootstrapRequest).type === WEBGAL_PREVIEW_BOOTSTRAP_REQUEST; +} + +export function createPreviewBootstrapProvide(embeddedLaunchId: string): PreviewBootstrapProvide { + return { + type: WEBGAL_PREVIEW_BOOTSTRAP_PROVIDE, + embeddedLaunchId, + }; +} diff --git a/packages/origine2/src/utils/editorPreviewClient.ts b/packages/origine2/src/utils/editorPreviewClient.ts new file mode 100644 index 000000000..61c0651f6 --- /dev/null +++ b/packages/origine2/src/utils/editorPreviewClient.ts @@ -0,0 +1,214 @@ +import useEditorStore from '@/store/useEditorStore'; +import { createId } from '@/utils/createId'; +import { eventBus } from '@/utils/eventBus'; +import { getWsUrl } from '@/utils/getWsUrl'; +import { logger } from '@/utils/logger'; +import { createEditorPreviewTransport, type EditorPreviewTransport } from '@/utils/editorPreviewTransport'; +import { + createRequestEnvelope, + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isHostEventEnvelope, + type EventEnvelope, + type PreviewCommandType, + type PreviewReadyUpdatedPayload, + type RequestPayloadByType, + type SetComponentVisibilityPayload, + type SetEffectPayload, + type StageSnapshotUpdatedPayload, +} from '@webgal/editor-preview-protocol'; + +let editorPreviewTransport: EditorPreviewTransport | null = null; +let editorPreviewClientStarted = false; +let lifecycleBound = false; +let lastStageSnapshot: StageSnapshotUpdatedPayload | null = null; + +interface SyncSceneInput { + scenePath: string; + lineNumber: number; + lineCommandString: string; + force?: boolean; +} + +type HostEventEnvelope = + | EventEnvelope + | EventEnvelope; + +function normalizeSceneName(scenePath: string): string { + const normalizedPath = scenePath.replace(/\\/g, '/'); + const parts = normalizedPath.split('/'); + const sceneIndex = parts.indexOf('scene'); + if (sceneIndex < 0) { + return normalizedPath; + } + + return parts.slice(sceneIndex + 1).join('/'); +} + +function shouldSyncCurrentLine(currentLineValue: string | null): boolean { + const command = currentLineValue?.split(':')[0] ?? ''; + if (command === 'unlockCg' || command === 'unlockBgm') { + return !!currentLineValue?.match(/;/g); + } + + return true; +} + +function parseHostEvent(rawData: unknown): HostEventEnvelope | undefined { + let parsedMessage: unknown; + try { + parsedMessage = JSON.parse(String(rawData)); + } catch (error) { + logger.warn('解析编辑器预览同步 V1 消息失败', error); + return undefined; + } + + if (!isHostEventEnvelope(parsedMessage)) { + return undefined; + } + + return parsedMessage; +} + +function consumeHostEvent(event: HostEventEnvelope) { + switch (event.type) { + case 'preview.ready.updated': + eventBus.emit('editor-preview:ready', event.payload); + return; + case 'stage.snapshot.updated': + lastStageSnapshot = event.payload; + eventBus.emit('editor-preview:stage-snapshot', { + snapshot: event.payload, + }); + return; + } +} + +function handleIncomingMessage(rawData: unknown) { + const hostEvent = parseHostEvent(rawData); + if (!hostEvent) { + return; + } + + consumeHostEvent(hostEvent); +} + +function bindLifecycleEvents() { + if (lifecycleBound) { + return; + } + + lifecycleBound = true; + const ensureConnected = () => { + editorPreviewTransport?.ensureConnected(); + }; + + window.addEventListener('focus', ensureConnected); + window.addEventListener('online', ensureConnected); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + ensureConnected(); + } + }); +} + +function ensureEditorPreviewClientStarted() { + if (editorPreviewClientStarted) { + return; + } + + const wsUrl = getWsUrl('api/webgalsync'); + if (!wsUrl) { + logger.info('当前环境不支持启动编辑器预览同步 V1 WebSocket'); + return; + } + + editorPreviewTransport = createEditorPreviewTransport({ + url: wsUrl, + subprotocol: EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + onMessage: handleIncomingMessage, + logInfo: (message) => logger.info(message), + logError: (message, error) => logger.error(message, error), + logWarn: (message, error) => logger.warn(message, error), + }); + editorPreviewClientStarted = true; + bindLifecycleEvents(); + editorPreviewTransport.connect(); +} + +function sendPreviewCommand( + type: TType, + payload: RequestPayloadByType[TType], +): boolean { + ensureEditorPreviewClientStarted(); + editorPreviewTransport?.ensureConnected(); + if (!editorPreviewTransport) { + return false; + } + + return editorPreviewTransport.send(createRequestEnvelope(type, createId(), payload)); +} + +export class EditorPreviewClient { + public static ensureConnected() { + ensureEditorPreviewClientStarted(); + editorPreviewTransport?.ensureConnected(); + } + + public static getLastStageSnapshot() { + return lastStageSnapshot; + } + + public static sendSyncScene({ + scenePath, + lineNumber, + lineCommandString, + force = false, + }: SyncSceneInput) { + const editorState = useEditorStore.getState(); + if (!editorState.isEnableLivePreview && !force) { + return false; + } + + if (!shouldSyncCurrentLine(lineCommandString)) { + return false; + } + + return sendPreviewCommand('preview.command.sync-scene', { + sceneName: normalizeSceneName(scenePath), + sentenceId: lineNumber, + syncMode: editorState.isUseExpFastSync ? 'fast' : 'stable', + }); + } + + public static runSnippet(snippet: string) { + return sendPreviewCommand('preview.command.run-snippet', { + snippet, + }); + } + + public static runSceneContent(sceneContent: string) { + return sendPreviewCommand('preview.command.run-scene-content', { + sceneContent, + }); + } + + public static reloadTemplates() { + return sendPreviewCommand('preview.command.reload-templates', {}); + } + + public static setFontOptimization(enabled: boolean) { + return sendPreviewCommand('preview.command.set-font-optimization', { + enabled, + }); + } + + public static setEffect(payload: SetEffectPayload) { + return sendPreviewCommand('preview.command.set-effect', payload); + } + + public static setComponentVisibility( + payload: SetComponentVisibilityPayload, + ) { + return sendPreviewCommand('preview.command.set-component-visibility', payload); + } +} diff --git a/packages/origine2/src/utils/editorPreviewTransport.ts b/packages/origine2/src/utils/editorPreviewTransport.ts new file mode 100644 index 000000000..349d11c46 --- /dev/null +++ b/packages/origine2/src/utils/editorPreviewTransport.ts @@ -0,0 +1,198 @@ +const SOCKET_CONNECTING = 0; +const SOCKET_OPEN = 1; +const DEFAULT_MAX_RECONNECT_DELAY_MS = 10_000; + +export interface EditorPreviewTransportSocket { + 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 EditorPreviewTransportOptions { + url: string; + subprotocol: string; + createSocket?: (url: string, subprotocol: string) => EditorPreviewTransportSocket; + onConnecting?: () => void; + onOpen?: (socket: EditorPreviewTransportSocket) => void | Promise; + onMessage: (data: unknown, socket: EditorPreviewTransportSocket) => void; + onClose?: (socket: EditorPreviewTransportSocket) => 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 EditorPreviewTransport { + connect: () => void; + ensureConnected: () => void; + dispose: () => void; + send: (envelope: unknown) => boolean; +} + +function createBrowserSocket(url: string, subprotocol: string): EditorPreviewTransportSocket { + return new WebSocket(url, subprotocol) as unknown as EditorPreviewTransportSocket; +} + +export function createEditorPreviewTransport({ + url, + subprotocol, + createSocket = createBrowserSocket, + onConnecting, + onOpen, + onMessage, + onClose, + logInfo, + logError, + logWarn, + setTimeoutFn = setTimeout, + clearTimeoutFn = clearTimeout, + maxReconnectDelayMs = DEFAULT_MAX_RECONNECT_DELAY_MS, +}: EditorPreviewTransportOptions): EditorPreviewTransport { + let disposed = false; + let activeSocket: EditorPreviewTransportSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let reconnectAttempt = 0; + let connectionId = 0; + + const clearReconnectTimer = () => { + if (!reconnectTimer) { + return; + } + + clearTimeoutFn(reconnectTimer); + reconnectTimer = null; + }; + + const isSocketOpen = (socket: EditorPreviewTransportSocket | null | undefined) => { + return socket !== null && socket !== undefined && socket.readyState === SOCKET_OPEN; + }; + + const isActiveSocket = (socket: EditorPreviewTransportSocket | 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, + }; +} diff --git a/packages/origine2/src/utils/eventBus.ts b/packages/origine2/src/utils/eventBus.ts index b977c6814..3c7f4408b 100644 --- a/packages/origine2/src/utils/eventBus.ts +++ b/packages/origine2/src/utils/eventBus.ts @@ -1,9 +1,8 @@ import mitt from 'mitt'; - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type WebSocketEvents = { - 'web-socket:on-message': { message: string }; -}; +import type { + PreviewReadyUpdatedPayload, + StageSnapshotUpdatedPayload, +} from '@webgal/editor-preview-protocol'; // eslint-disable-next-line @typescript-eslint/consistent-type-definitions type IframeEvents = { @@ -23,6 +22,14 @@ type EditorEvents = { 'editor:drag-update-scene': { targetPath: string; lineNumber: number; newCommand: string }; }; -type Events = WebSocketEvents & IframeEvents & EditorEvents; +interface EditorPreviewEvents { + 'editor-preview:ready': PreviewReadyUpdatedPayload; + 'editor-preview:stage-snapshot': { snapshot: StageSnapshotUpdatedPayload }; +} + +type Events = Record & + IframeEvents & + EditorEvents & + EditorPreviewEvents; export const eventBus = mitt(); diff --git a/packages/origine2/src/utils/wsUtil.ts b/packages/origine2/src/utils/wsUtil.ts deleted file mode 100644 index 078545537..000000000 --- a/packages/origine2/src/utils/wsUtil.ts +++ /dev/null @@ -1,194 +0,0 @@ -import {logger} from "./logger"; -import {DebugCommand, IComponentVisibilityCommand, IDebugMessage} from "@/types/debugProtocol"; -import useEditorStore from "@/store/useEditorStore"; -import {eventBus} from "@/utils/eventBus"; - -const wsState = { - isInit: false -}; - -export class WsUtil { - - public static async init(){ - return new Promise((resolve,reject) => { - if(wsState.isInit){ - resolve(true); - return; - } - try { - 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`; - } - - console.log('正在启动socket连接位于:' + wsUrl); - const socket = new WebSocket(wsUrl); - socket.onopen = () => { - console.log('socket已连接'); - socket.send('WebGAL Origine 已和 Terre 建立连接'); - wsState.isInit = true; - resolve(true); - }; - socket.onmessage = (e) => { - eventBus.emit('web-socket:on-message', { message: e.data }); - }; - // @ts-ignore - window['currentWs'] = socket; - } catch (e) { - console.warn('ws连接失败'); - reject(e); - } - }); - } - - public static async sendMessageToCurrentWs(data: IDebugMessage['data'], event?: IDebugMessage['event']){ - await this.init(); - // @ts-ignore - if (window["currentWs"]) { - logger.debug("编辑器开始发送同步数据"); - const sendMessage: IDebugMessage = { - event: event ?? "message", - data: data - }; - // @ts-ignore - window["currentWs"].send(JSON.stringify(sendMessage)); - } - } - - public static setComponentVisibility(message: IComponentVisibilityCommand[]) { - const sendMessage = JSON.stringify(message); - this.sendMessageToCurrentWs({ - command: DebugCommand.SET_COMPONENT_VISIBILITY, - sceneMsg: { - scene: "", - sentence: 0 - },// @ts-ignore - stageSyncMsg: {}, - message: sendMessage, - }); - } - - public static runTempScene(command: string) { - this.sendMessageToCurrentWs({ - command: DebugCommand.TEMP_SCENE, - sceneMsg: { - scene: "", - sentence: 0 - },// @ts-ignore - stageSyncMsg: {}, - message: command, - }); - } - - // eslint-disable-next-line max-params - public static sendSyncCommand(scenePath: string, lineNumber: number, lineCommandString: string, force?: boolean) { - function extractPathAfterScene(scenePath: string): string { - // Normalize path separators to "/" - const normalizedPath = scenePath.replace(/\\/g, '/'); - - // Split the path into parts - const parts = normalizedPath.split('/'); - - // Find the index of the "scene" segment - const sceneIndex = parts.indexOf('scene'); - - // Extract the parts after "scene" - const afterSceneParts = parts.slice(sceneIndex + 1); - - // Join the parts back into a string with "/" - return afterSceneParts.join('/'); - } - - const sceneName = extractPathAfterScene(scenePath); - if (!useEditorStore.getState().isEnableLivePreview && !force) { - return; - } - - // @ts-ignore - if (this.getIsCurrentLineJump(lineCommandString)) { - this.sendMessageToCurrentWs({ - command: DebugCommand.JUMP, - sceneMsg: { - scene: sceneName, - sentence: lineNumber - },// @ts-ignore - stageSyncMsg: {}, - message: useEditorStore.getState().isUseExpFastSync? 'exp':'Sync', - }); - } - } - - public static sendExeCommand(command: string) { - this.sendMessageToCurrentWs({ - command: DebugCommand.EXE_COMMAND, - sceneMsg: { - scene: 'temp', - sentence: 0 - },// @ts-ignore - stageSyncMsg: {}, - message: command - }); - } - - public static sendTemplateRefetchCommand(){ - this.sendMessageToCurrentWs({ - command: DebugCommand.REFETCH_TEMPLATE_FILES, - sceneMsg: { - scene: 'temp', - sentence: 0 - },// @ts-ignore - stageSyncMsg: {}, - message: '' - }); - }; - - public static sendFontOptimizationCommand(command: boolean) { - this.sendMessageToCurrentWs({ - command: DebugCommand.FONT_OPTIMIZATION, - sceneMsg: { - scene: "", - sentence: 0 - },// @ts-ignore - stageSyncMsg: {}, - message: command.toString(), - }); - }; - - public static sendSetEffectCommand(newEffect: string) { - this.sendMessageToCurrentWs({ - command: DebugCommand.SET_EFFECT, - sceneMsg: { - scene: "", - sentence: 0 - },// @ts-ignore - stageSyncMsg: {}, - message: newEffect, - }); - }; - - private static getIsCurrentLineJump(currentLineValue: string | null): boolean { - const command = currentLineValue?.split(":")[0] ?? ""; - if (command === "unlockCg" || command === "unlockBgm") { - if (!currentLineValue?.match(/;/g)) - return false; - } - return true; - } - -} diff --git a/packages/terre2/.eslintrc.js b/packages/terre2/.eslintrc.js index 7a551da46..55e5c7de1 100644 --- a/packages/terre2/.eslintrc.js +++ b/packages/terre2/.eslintrc.js @@ -15,7 +15,7 @@ module.exports = { node: true, jest: true, }, - ignorePatterns: ['.eslintrc.js'], + ignorePatterns: ['.eslintrc.js', 'package.json'], rules: { '@typescript-eslint/interface-name-prefix': 'off', '@typescript-eslint/explicit-function-return-type': 'off', diff --git a/packages/terre2/package.json b/packages/terre2/package.json index b3f61dde4..33dcb364b 100644 --- a/packages/terre2/package.json +++ b/packages/terre2/package.json @@ -6,22 +6,23 @@ "private": true, "license": "MPL-2.0", "scripts": { + "sync:editor-preview-protocol": "yarn workspace @webgal/editor-preview-protocol build", "update-engine": "tsx update-webgal.ts", "prebuild": "rimraf dist", - "build": "tsx update-webgal.ts && nest build", - "build-standalone": "tsx update-webgal.ts && nest build --webpack --webpackPath=./standalone.js", - "build-standalone:intl": "tsx update-webgal.ts && nest build --webpack --webpackPath=./standalone.js -- --intl", + "build": "yarn run sync:editor-preview-protocol && tsx update-webgal.ts && nest build", + "build-standalone": "yarn run sync:editor-preview-protocol && tsx update-webgal.ts && nest build --webpack --webpackPath=./standalone.js", + "build-standalone:intl": "yarn run sync:editor-preview-protocol && tsx update-webgal.ts && nest build --webpack --webpackPath=./standalone.js -- --intl", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", - "start": "nest start", - "start:dev": "rimraf dist && nest start --watch", - "start:debug": "cross-env NODE_ENV=development nest start --debug --watch", + "start": "yarn run sync:editor-preview-protocol && nest start", + "start:dev": "yarn run sync:editor-preview-protocol && rimraf dist && nest start --watch", + "start:debug": "yarn run sync:editor-preview-protocol && cross-env NODE_ENV=development nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage", - "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json", + "test": "yarn run sync:editor-preview-protocol && jest", + "test:watch": "yarn run sync:editor-preview-protocol && jest --watch", + "test:cov": "yarn run sync:editor-preview-protocol && jest --coverage", + "test:debug": "yarn run sync:editor-preview-protocol && node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "yarn run sync:editor-preview-protocol && jest --config ./test/jest-e2e.json", "pkg": "tsx pkg-build.ts", "pkg:linux-arm64": "cross-env PKG_TARGET=node22-linux-arm64 tsx pkg-build.ts", "pkg:mac-x64": "cross-env PKG_TARGET=node22-macos-x64 tsx pkg-build.ts", @@ -41,6 +42,7 @@ "@nestjs/websockets": "^10.3.6", "@types/image-size": "^0.7.0", "@types/parse-path": "^7.1.0", + "@webgal/editor-preview-protocol": "1.0.0", "@yao-pkg/pkg": "6.4.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", diff --git a/packages/terre2/src/Modules/websocket/editorPreviewHost.spec.ts b/packages/terre2/src/Modules/websocket/editorPreviewHost.spec.ts new file mode 100644 index 000000000..52e688524 --- /dev/null +++ b/packages/terre2/src/Modules/websocket/editorPreviewHost.spec.ts @@ -0,0 +1,530 @@ +import { + createEventEnvelope, + createRequestEnvelope, + createResponseEnvelope, + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, +} from '@webgal/editor-preview-protocol'; +import { + EditorPreviewHost, + LegacyEditorPreviewAdapter, + type HostConnectionKind, +} from './editorPreviewHost'; + +class MockSocket { + public readonly sentMessages: string[] = []; + + public readonly closeCalls: Array<{ code?: number; reason?: string }> = []; + + public constructor(public protocol = '') {} + + public send(data: string) { + this.sentMessages.push(data); + } + + public close(code?: number, reason?: string | Buffer) { + this.closeCalls.push({ + code, + reason: typeof reason === 'string' ? reason : reason?.toString(), + }); + } +} + +const LEGACY_DEBUG_COMMAND = { + JUMP: 0, + SYNCFC: 1, + EXE_COMMAND: 3, + REFETCH_TEMPLATE_FILES: 4, + SET_COMPONENT_VISIBILITY: 5, + TEMP_SCENE: 6, + FONT_OPTIMIZATION: 7, + SET_EFFECT: 8, +} as const; + +function createUpgradeRequest(protocolHeader?: string) { + return { + headers: protocolHeader + ? { + 'sec-websocket-protocol': protocolHeader, + } + : {}, + }; +} + +function connectV1Client( + host: EditorPreviewHost, + socket: MockSocket, +): HostConnectionKind { + return host.acceptConnection( + socket as never, + createUpgradeRequest(EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL) as never, + ); +} + +function registerPreview( + host: EditorPreviewHost, + socket: MockSocket, + payload: { + gameId?: string; + embeddedLaunchId?: string; + }, +) { + host.handleMessage( + socket as never, + JSON.stringify( + createRequestEnvelope( + 'session.register-preview', + 'req-register-preview', + payload, + ), + ), + ); +} + +function clearSentMessages(...sockets: MockSocket[]) { + for (const socket of sockets) { + socket.sentMessages.length = 0; + } +} + +describe('EditorPreviewHost', () => { + it('treats bare websocket connections as legacy and rejects unsupported subprotocols', () => { + const host = new EditorPreviewHost(); + + const legacySocket = new MockSocket(''); + const legacyKind = host.acceptConnection( + legacySocket as never, + createUpgradeRequest() as never, + ); + expect(legacyKind).toBe('legacy'); + + const v1Socket = new MockSocket(EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL); + const v1Kind = connectV1Client(host, v1Socket); + expect(v1Kind).toBe('v1'); + + const unsupportedSocket = new MockSocket('webgal-editor-preview-sync.v2'); + const unsupportedKind = host.acceptConnection( + unsupportedSocket as never, + createUpgradeRequest('webgal-editor-preview-sync.v2') as never, + ); + expect(unsupportedKind).toBe('rejected'); + expect(unsupportedSocket.closeCalls).toHaveLength(1); + expect(unsupportedSocket.closeCalls[0]?.code).toBe(1002); + }); + + it('fans out preview commands to all active V1 previews and replies to the editor once', () => { + const host = new EditorPreviewHost(); + const editorSocket = new MockSocket(EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL); + const embeddedPreviewSocket = new MockSocket( + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + ); + const externalPreviewSocket = new MockSocket( + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + ); + + expect(connectV1Client(host, editorSocket)).toBe('v1'); + expect(connectV1Client(host, embeddedPreviewSocket)).toBe('v1'); + expect(connectV1Client(host, externalPreviewSocket)).toBe('v1'); + + registerPreview(host, embeddedPreviewSocket, { + gameId: 'game-key-1', + embeddedLaunchId: 'embedded-launch-1', + }); + registerPreview(host, externalPreviewSocket, { + gameId: 'game-key-1', + }); + clearSentMessages(embeddedPreviewSocket, externalPreviewSocket); + + host.handleMessage( + editorSocket as never, + JSON.stringify( + createRequestEnvelope('preview.command.sync-scene', 'req-sync-scene', { + sceneName: 'scene/start.txt', + sentenceId: 12, + syncMode: 'fast', + }), + ), + ); + + const expectedForwardedRequest = JSON.stringify( + createRequestEnvelope('preview.command.sync-scene', 'req-sync-scene', { + sceneName: 'scene/start.txt', + sentenceId: 12, + syncMode: 'fast', + }), + ); + expect(embeddedPreviewSocket.sentMessages).toEqual([ + expectedForwardedRequest, + ]); + expect(externalPreviewSocket.sentMessages).toEqual([ + expectedForwardedRequest, + ]); + expect(editorSocket.sentMessages).toEqual([ + JSON.stringify( + createResponseEnvelope( + 'preview.command.sync-scene', + 'req-sync-scene', + {}, + ), + ), + ]); + }); + + it('forwards stage snapshots only from previews that still belong to the active game', () => { + const host = new EditorPreviewHost(); + const editorSocket = new MockSocket(EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL); + const stalePreviewSocket = new MockSocket( + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + ); + const embeddedPreviewSocket = new MockSocket( + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + ); + + connectV1Client(host, editorSocket); + connectV1Client(host, stalePreviewSocket); + connectV1Client(host, embeddedPreviewSocket); + + registerPreview(host, stalePreviewSocket, { + gameId: 'game-key-1', + }); + registerPreview(host, embeddedPreviewSocket, { + gameId: 'game-key-2', + embeddedLaunchId: 'embedded-launch-2', + }); + clearSentMessages(stalePreviewSocket, embeddedPreviewSocket); + + host.handleMessage( + stalePreviewSocket as never, + JSON.stringify( + createEventEnvelope('stage.snapshot.updated', { + sceneName: 'scene/old.txt', + sentenceId: 3, + stageState: { + source: 'stale', + }, + }), + ), + ); + expect(editorSocket.sentMessages).toHaveLength(0); + + host.handleMessage( + embeddedPreviewSocket as never, + JSON.stringify( + createEventEnvelope('stage.snapshot.updated', { + sceneName: 'scene/new.txt', + sentenceId: 9, + stageState: { + source: 'active', + }, + }), + ), + ); + expect(editorSocket.sentMessages).toEqual([ + JSON.stringify( + createEventEnvelope('stage.snapshot.updated', { + sceneName: 'scene/new.txt', + sentenceId: 9, + stageState: { + source: 'active', + }, + }), + ), + ]); + }); +}); + +describe('LegacyEditorPreviewAdapter', () => { + it('translates V1 preview commands into legacy debug envelopes', () => { + const adapter = new LegacyEditorPreviewAdapter(); + const legacySocket = new MockSocket(''); + + adapter.addConnection(legacySocket as never); + + adapter.forwardPreviewCommand( + createRequestEnvelope('preview.command.sync-scene', 'req-sync-scene', { + sceneName: 'start.txt', + sentenceId: 12, + syncMode: 'fast', + }), + ); + adapter.forwardPreviewCommand( + createRequestEnvelope('preview.command.run-snippet', 'req-run-snippet', { + snippet: 'show bg room;', + }), + ); + adapter.forwardPreviewCommand( + createRequestEnvelope( + 'preview.command.run-scene-content', + 'req-run-scene-content', + { + sceneContent: 'show bg room;', + }, + ), + ); + adapter.forwardPreviewCommand( + createRequestEnvelope( + 'preview.command.reload-templates', + 'req-reload-templates', + {}, + ), + ); + adapter.forwardPreviewCommand( + createRequestEnvelope( + 'preview.command.set-component-visibility', + 'req-set-component-visibility', + { + showTitle: false, + showTextBox: true, + }, + ), + ); + adapter.forwardPreviewCommand( + createRequestEnvelope( + 'preview.command.set-font-optimization', + 'req-set-font-optimization', + { + enabled: true, + }, + ), + ); + adapter.forwardPreviewCommand( + createRequestEnvelope('preview.command.set-effect', 'req-set-effect', { + target: 'fig-center', + transform: { + position: { + x: 120, + }, + }, + }), + ); + + expect(legacySocket.sentMessages).toEqual([ + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.JUMP, + sceneMsg: { + scene: 'start.txt', + sentence: 12, + }, + stageSyncMsg: {}, + message: 'exp', + }, + }), + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.EXE_COMMAND, + sceneMsg: { + scene: 'temp', + sentence: 0, + }, + stageSyncMsg: {}, + message: 'show bg room;', + }, + }), + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.TEMP_SCENE, + sceneMsg: { + scene: '', + sentence: 0, + }, + stageSyncMsg: {}, + message: 'show bg room;', + }, + }), + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.REFETCH_TEMPLATE_FILES, + sceneMsg: { + scene: 'temp', + sentence: 0, + }, + stageSyncMsg: {}, + message: '', + }, + }), + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.SET_COMPONENT_VISIBILITY, + sceneMsg: { + scene: '', + sentence: 0, + }, + stageSyncMsg: {}, + message: JSON.stringify([ + { + component: 'showTitle', + visibility: false, + }, + { + component: 'showTextBox', + visibility: true, + }, + ]), + }, + }), + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.FONT_OPTIMIZATION, + sceneMsg: { + scene: '', + sentence: 0, + }, + stageSyncMsg: {}, + message: 'true', + }, + }), + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.SET_EFFECT, + sceneMsg: { + scene: '', + sentence: 0, + }, + stageSyncMsg: {}, + message: JSON.stringify({ + target: 'fig-center', + transform: { + position: { + x: 120, + }, + }, + }), + }, + }), + ]); + }); + + it('translates legacy stage sync payloads into V1 ready and snapshot events', () => { + const forwardedEvents: string[] = []; + const adapter = new LegacyEditorPreviewAdapter({ + forwardHostEventToV1Editors: (envelope) => { + forwardedEvents.push(JSON.stringify(envelope)); + }, + }); + const legacySocket = new MockSocket(''); + + adapter.addConnection(legacySocket as never); + adapter.handleMessage( + legacySocket as never, + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.SYNCFC, + sceneMsg: { + scene: 'legacy-start.txt', + sentence: 7, + }, + stageSyncMsg: { + effects: [], + currentSentence: 'legacy', + }, + message: 'sync', + }, + }), + ); + + expect(forwardedEvents).toEqual([ + JSON.stringify( + createEventEnvelope('preview.ready.updated', { + ready: true, + }), + ), + JSON.stringify( + createEventEnvelope('stage.snapshot.updated', { + sceneName: 'legacy-start.txt', + sentenceId: 7, + stageState: { + effects: [], + currentSentence: 'legacy', + }, + }), + ), + ]); + }); + + it('rebroadcasts legacy message payloads only to legacy clients', () => { + const adapter = new LegacyEditorPreviewAdapter(); + const firstLegacySocket = new MockSocket(''); + const secondLegacySocket = new MockSocket(''); + + adapter.addConnection(firstLegacySocket as never); + adapter.addConnection(secondLegacySocket as never); + + adapter.handleMessage( + firstLegacySocket as never, + JSON.stringify({ + event: 'message', + data: { + message: 'legacy-debug-payload', + }, + }), + ); + + const expectedBroadcast = JSON.stringify({ + event: 'message', + data: { + message: 'legacy-debug-payload', + }, + }); + expect(firstLegacySocket.sentMessages).toEqual([expectedBroadcast]); + expect(secondLegacySocket.sentMessages).toEqual([expectedBroadcast]); + }); +}); + +describe('V1 / legacy bridge', () => { + it('forwards V1 preview commands into the configured legacy adapter bridge', () => { + const adapter = new LegacyEditorPreviewAdapter(); + const legacySocket = new MockSocket(''); + const host = new EditorPreviewHost({ + forwardPreviewCommandToLegacy: (envelope) => { + adapter.forwardPreviewCommand(envelope); + }, + }); + const editorSocket = new MockSocket(EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL); + + adapter.addConnection(legacySocket as never); + connectV1Client(host, editorSocket); + + host.handleMessage( + editorSocket as never, + JSON.stringify( + createRequestEnvelope( + 'preview.command.reload-templates', + 'req-reload-templates', + {}, + ), + ), + ); + + expect(legacySocket.sentMessages).toEqual([ + JSON.stringify({ + event: 'message', + data: { + command: LEGACY_DEBUG_COMMAND.REFETCH_TEMPLATE_FILES, + sceneMsg: { + scene: 'temp', + sentence: 0, + }, + stageSyncMsg: {}, + message: '', + }, + }), + ]); + expect(editorSocket.sentMessages).toEqual([ + JSON.stringify( + createResponseEnvelope( + 'preview.command.reload-templates', + 'req-reload-templates', + {}, + ), + ), + ]); + }); +}); diff --git a/packages/terre2/src/Modules/websocket/editorPreviewHost.ts b/packages/terre2/src/Modules/websocket/editorPreviewHost.ts new file mode 100644 index 000000000..40cf6521c --- /dev/null +++ b/packages/terre2/src/Modules/websocket/editorPreviewHost.ts @@ -0,0 +1,485 @@ +import type { IncomingMessage } from 'http'; +import { + createEventEnvelope, + createResponseEnvelope, + EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isPreviewCommandRequestEnvelope, + isProtocolEnvelope, + type EventEnvelope, + type ProtocolEnvelope, + type PreviewReadyUpdatedPayload, + type RegisterPreviewRequestPayload, + type ReloadTemplatesPayload, + type RunSceneContentPayload, + type RunSnippetPayload, + type RequestEnvelope, + type SetComponentVisibilityPayload, + type SetEffectPayload, + type SetFontOptimizationPayload, + type SyncScenePayload, + type StageSnapshotUpdatedPayload, +} from '@webgal/editor-preview-protocol'; +import type WebSocket from 'ws'; + +export type HostConnectionKind = 'legacy' | 'v1' | 'rejected'; + +interface EditorPreviewHostOptions { + forwardPreviewCommandToLegacy?: ( + envelope: PreviewCommandRequestEnvelope, + ) => void; +} + +interface LegacyEditorPreviewAdapterOptions { + forwardHostEventToV1Editors?: (envelope: ForwardedHostEventEnvelope) => void; +} + +type V1ConnectionRole = 'unknown' | 'editor' | 'preview'; + +interface V1ConnectionState { + role: V1ConnectionRole; + gameId?: string; + embeddedLaunchId?: string; +} + +const REGISTER_PREVIEW_TYPE = 'session.register-preview'; +const FORWARDED_EVENT_TYPES = new Set([ + 'preview.ready.updated', + 'stage.snapshot.updated', +]); + +type ForwardedHostEventEnvelope = + | EventEnvelope + | EventEnvelope; + +type RegisterPreviewRequestEnvelope = RequestEnvelope< + RegisterPreviewRequestPayload, + typeof REGISTER_PREVIEW_TYPE +>; + +type PreviewCommandRequestEnvelope = + | RequestEnvelope + | RequestEnvelope + | RequestEnvelope + | RequestEnvelope + | RequestEnvelope + | RequestEnvelope< + SetComponentVisibilityPayload, + 'preview.command.set-component-visibility' + > + | RequestEnvelope< + SetFontOptimizationPayload, + 'preview.command.set-font-optimization' + >; + +const LEGACY_DEBUG_COMMAND = { + JUMP: 0, + SYNCFC: 1, + EXE_COMMAND: 3, + REFETCH_TEMPLATE_FILES: 4, + SET_COMPONENT_VISIBILITY: 5, + TEMP_SCENE: 6, + FONT_OPTIMIZATION: 7, + SET_EFFECT: 8, +} as const; + +type LegacyDebugCommandValue = + (typeof LEGACY_DEBUG_COMMAND)[keyof typeof LEGACY_DEBUG_COMMAND]; + +interface LegacyDebugEnvelope { + event: string; + data: { + command: LegacyDebugCommandValue; + sceneMsg: { + sentence: number; + scene: string; + }; + message: string; + stageSyncMsg: Record; + }; +} + +interface LegacyRawEnvelope { + event: string; + data: unknown; +} + +function parseRequestedProtocols(request: IncomingMessage): string[] { + const headerValue = request.headers['sec-websocket-protocol']; + if (Array.isArray(headerValue)) { + return headerValue + .flatMap((item) => item.split(',')) + .map((item) => item.trim()) + .filter(Boolean); + } + + if (typeof headerValue !== 'string') { + return []; + } + + return headerValue + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isForwardedPreviewEvent( + envelope: EventEnvelope, +): envelope is ForwardedHostEventEnvelope { + return FORWARDED_EVENT_TYPES.has(envelope.type); +} + +function isRegisterPreviewRequest( + envelope: ProtocolEnvelope, +): envelope is RegisterPreviewRequestEnvelope { + return envelope.kind === 'request' && envelope.type === REGISTER_PREVIEW_TYPE; +} + +function sendEnvelope(socket: WebSocket, envelope: unknown) { + socket.send(JSON.stringify(envelope)); +} + +function createLegacyDebugEnvelope( + command: LegacyDebugCommandValue, + options?: { + scene?: string; + sentence?: number; + message?: string; + stageSyncMsg?: Record; + }, +): LegacyDebugEnvelope { + return { + event: 'message', + data: { + command, + sceneMsg: { + scene: options?.scene ?? '', + sentence: options?.sentence ?? 0, + }, + stageSyncMsg: options?.stageSyncMsg ?? {}, + message: options?.message ?? '', + }, + }; +} + +function translatePreviewCommandToLegacyEnvelope( + envelope: PreviewCommandRequestEnvelope, +): LegacyDebugEnvelope { + switch (envelope.type) { + case 'preview.command.sync-scene': + return createLegacyDebugEnvelope(LEGACY_DEBUG_COMMAND.JUMP, { + scene: envelope.payload.sceneName, + sentence: envelope.payload.sentenceId, + message: envelope.payload.syncMode === 'fast' ? 'exp' : 'Sync', + }); + case 'preview.command.run-snippet': + return createLegacyDebugEnvelope(LEGACY_DEBUG_COMMAND.EXE_COMMAND, { + scene: 'temp', + message: envelope.payload.snippet, + }); + case 'preview.command.run-scene-content': + return createLegacyDebugEnvelope(LEGACY_DEBUG_COMMAND.TEMP_SCENE, { + message: envelope.payload.sceneContent, + }); + case 'preview.command.reload-templates': + return createLegacyDebugEnvelope( + LEGACY_DEBUG_COMMAND.REFETCH_TEMPLATE_FILES, + { + scene: 'temp', + }, + ); + case 'preview.command.set-component-visibility': + return createLegacyDebugEnvelope( + LEGACY_DEBUG_COMMAND.SET_COMPONENT_VISIBILITY, + { + message: JSON.stringify( + Object.entries(envelope.payload) + .filter( + (entry): entry is [string, boolean] => + typeof entry[1] === 'boolean', + ) + .map(([component, visibility]) => ({ + component, + visibility, + })), + ), + }, + ); + case 'preview.command.set-font-optimization': + return createLegacyDebugEnvelope(LEGACY_DEBUG_COMMAND.FONT_OPTIMIZATION, { + message: envelope.payload.enabled.toString(), + }); + case 'preview.command.set-effect': + return createLegacyDebugEnvelope(LEGACY_DEBUG_COMMAND.SET_EFFECT, { + message: JSON.stringify(envelope.payload), + }); + } +} + +export class EditorPreviewHost { + public constructor(private readonly options: EditorPreviewHostOptions = {}) {} + + private readonly v1Connections = new Map(); + + private activeEmbeddedPreview: WebSocket | null = null; + + private activeGameId: string | undefined; + + public acceptConnection( + client: WebSocket, + request: IncomingMessage, + ): HostConnectionKind { + const requestedProtocols = parseRequestedProtocols(request); + if (requestedProtocols.length === 0) { + return 'legacy'; + } + + if (!requestedProtocols.includes(EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL)) { + client.close(1002, 'Unsupported WebSocket subprotocol.'); + return 'rejected'; + } + + this.v1Connections.set(client, { role: 'unknown' }); + return 'v1'; + } + + public removeConnection(client: WebSocket) { + const removedState = this.v1Connections.get(client); + if (!removedState) { + return; + } + + this.v1Connections.delete(client); + if (this.activeEmbeddedPreview === client) { + this.activeEmbeddedPreview = null; + } + } + + public forwardHostEventToEditors(envelope: ForwardedHostEventEnvelope) { + for (const [socket, state] of this.v1Connections) { + if (state.role === 'preview') { + continue; + } + + sendEnvelope(socket, envelope); + } + } + + public handleMessage(client: WebSocket, rawMessage: string) { + const connectionState = this.v1Connections.get(client); + if (!connectionState) { + return; + } + + let parsedMessage: unknown; + try { + parsedMessage = JSON.parse(rawMessage); + } catch { + return; + } + + if (!isProtocolEnvelope(parsedMessage)) { + return; + } + + const envelope = parsedMessage; + if (isRegisterPreviewRequest(envelope)) { + this.handlePreviewRegistration(client, connectionState, envelope); + return; + } + + if (isPreviewCommandRequestEnvelope(envelope)) { + this.handlePreviewCommandRequest(client, connectionState, envelope); + return; + } + + if (envelope.kind === 'event' && isForwardedPreviewEvent(envelope)) { + this.handlePreviewEvent(client, connectionState, envelope); + } + } + + private handlePreviewRegistration( + client: WebSocket, + connectionState: V1ConnectionState, + envelope: RegisterPreviewRequestEnvelope, + ) { + const payload = envelope.payload; + connectionState.role = 'preview'; + connectionState.gameId = payload.gameId; + connectionState.embeddedLaunchId = payload.embeddedLaunchId; + + if (payload.embeddedLaunchId) { + this.activeEmbeddedPreview = client; + this.activeGameId = payload.gameId; + } + + sendEnvelope( + client, + createResponseEnvelope(REGISTER_PREVIEW_TYPE, envelope.requestId, {}), + ); + } + + private handlePreviewCommandRequest( + client: WebSocket, + connectionState: V1ConnectionState, + envelope: PreviewCommandRequestEnvelope, + ) { + connectionState.role = 'editor'; + + for (const [previewSocket, previewState] of this.v1Connections) { + if (!this.isActivePreview(previewSocket, previewState)) { + continue; + } + + sendEnvelope(previewSocket, envelope); + } + + this.options.forwardPreviewCommandToLegacy?.(envelope); + + sendEnvelope( + client, + createResponseEnvelope(envelope.type, envelope.requestId, {}), + ); + } + + private handlePreviewEvent( + client: WebSocket, + connectionState: V1ConnectionState, + envelope: ForwardedHostEventEnvelope, + ) { + if (!this.isActivePreview(client, connectionState)) { + return; + } + + this.forwardHostEventToEditors(envelope); + } + + private isActivePreview( + client: WebSocket, + connectionState: V1ConnectionState, + ): boolean { + if (connectionState.role !== 'preview') { + return false; + } + + if (this.activeEmbeddedPreview === client) { + return true; + } + + if (!this.activeGameId || !connectionState.gameId) { + return true; + } + + return connectionState.gameId === this.activeGameId; + } +} + +export class LegacyEditorPreviewAdapter { + public constructor( + private readonly options: LegacyEditorPreviewAdapterOptions = {}, + ) {} + + private readonly connections = new Set(); + + private readonly readyConnections = new Set(); + + public addConnection(client: WebSocket) { + this.connections.add(client); + } + + public removeConnection(client: WebSocket) { + this.connections.delete(client); + this.readyConnections.delete(client); + } + + public forwardPreviewCommand(envelope: PreviewCommandRequestEnvelope) { + const legacyEnvelope = translatePreviewCommandToLegacyEnvelope(envelope); + const serializedMessage = JSON.stringify(legacyEnvelope); + for (const socket of this.connections) { + socket.send(serializedMessage); + } + } + + public handleMessage(client: WebSocket, rawMessage: string) { + let parsedMessage: unknown; + try { + parsedMessage = JSON.parse(rawMessage); + } catch { + return; + } + + if (!this.isLegacyEnvelope(parsedMessage)) { + return; + } + + const serializedMessage = JSON.stringify(parsedMessage); + for (const socket of this.connections) { + socket.send(serializedMessage); + } + + this.forwardLegacyHostEvents(client, parsedMessage); + } + + private forwardLegacyHostEvents( + client: WebSocket, + envelope: LegacyRawEnvelope, + ) { + if (!this.isLegacyStageSyncEnvelope(envelope)) { + return; + } + + if ( + envelope.data.command !== LEGACY_DEBUG_COMMAND.SYNCFC || + !this.options.forwardHostEventToV1Editors + ) { + return; + } + + if (!this.readyConnections.has(client)) { + this.readyConnections.add(client); + this.options.forwardHostEventToV1Editors( + createEventEnvelope('preview.ready.updated', { + ready: true, + }), + ); + } + + const stageState: StageSnapshotUpdatedPayload['stageState'] = isRecord( + envelope.data.stageSyncMsg, + ) + ? (envelope.data + .stageSyncMsg as StageSnapshotUpdatedPayload['stageState']) + : {}; + + this.options.forwardHostEventToV1Editors( + createEventEnvelope('stage.snapshot.updated', { + sceneName: envelope.data.sceneMsg.scene, + sentenceId: envelope.data.sceneMsg.sentence, + stageState, + }), + ); + } + + private isLegacyEnvelope(value: unknown): value is LegacyRawEnvelope { + return ( + isRecord(value) && typeof value.event === 'string' && 'data' in value + ); + } + + private isLegacyStageSyncEnvelope( + value: LegacyRawEnvelope, + ): value is LegacyDebugEnvelope { + return ( + isRecord(value.data) && + typeof value.data.command === 'number' && + isRecord(value.data.sceneMsg) && + typeof value.data.sceneMsg.scene === 'string' && + typeof value.data.sceneMsg.sentence === 'number' && + typeof value.data.message === 'string' && + isRecord(value.data.stageSyncMsg) + ); + } +} diff --git a/packages/terre2/src/Modules/websocket/websocketGateway.spec.ts b/packages/terre2/src/Modules/websocket/websocketGateway.spec.ts new file mode 100644 index 000000000..7a586483f --- /dev/null +++ b/packages/terre2/src/Modules/websocket/websocketGateway.spec.ts @@ -0,0 +1,28 @@ +import type { RawData } from 'ws'; +import { WebGalWebSocketGateway } from './websocketGateway'; + +function normalizeRawMessageForTest( + gateway: WebGalWebSocketGateway, + rawData: RawData, +) { + return ( + gateway as unknown as { + normalizeRawMessage(rawData: RawData): string; + } + ).normalizeRawMessage(rawData); +} + +describe('WebGalWebSocketGateway', () => { + it('normalizes ArrayBuffer payloads into the original message string', () => { + const gateway = new WebGalWebSocketGateway(); + const payload = JSON.stringify({ + kind: 'request', + type: 'preview.command.reload-templates', + requestId: 'req-reload-templates', + payload: {}, + }); + const rawData = Uint8Array.from(Buffer.from(payload)).buffer; + + expect(normalizeRawMessageForTest(gateway, rawData)).toBe(payload); + }); +}); diff --git a/packages/terre2/src/Modules/websocket/websocketGateway.ts b/packages/terre2/src/Modules/websocket/websocketGateway.ts index e492f5d2a..96a889292 100644 --- a/packages/terre2/src/Modules/websocket/websocketGateway.ts +++ b/packages/terre2/src/Modules/websocket/websocketGateway.ts @@ -1,44 +1,88 @@ +import type { IncomingMessage } from 'http'; +import { EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL } from '@webgal/editor-preview-protocol'; +import { WebSocketGateway } from '@nestjs/websockets'; +import type { RawData, WebSocket } from 'ws'; import { - // ConnectedSocket, - MessageBody, - SubscribeMessage, - WebSocketGateway, - WebSocketServer, -} from '@nestjs/websockets'; -import { Server, WebSocket } from 'ws'; - -@WebSocketGateway({ path: '/api/webgalsync', transports: 'websocket' }) + EditorPreviewHost, + LegacyEditorPreviewAdapter, +} from './editorPreviewHost'; + +export function selectEditorPreviewSubprotocol( + protocols: Set, +): string | false { + if (protocols.has(EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL)) { + return EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL; + } + + return false; +} + +@WebSocketGateway({ + path: '/api/webgalsync', + transports: 'websocket', + handleProtocols: (protocols: Set) => + selectEditorPreviewSubprotocol(protocols), +}) export class WebGalWebSocketGateway { - @WebSocketServer() - private server: Server; + private readonly editorPreviewHost: EditorPreviewHost; - private connectionList: WebSocket[] = []; + private readonly legacyAdapter: LegacyEditorPreviewAdapter; - afterInit(server: Server) { - this.server = server; + public constructor() { + this.legacyAdapter = new LegacyEditorPreviewAdapter({ + forwardHostEventToV1Editors: (envelope) => { + this.editorPreviewHost.forwardHostEventToEditors(envelope); + }, + }); + this.editorPreviewHost = new EditorPreviewHost({ + forwardPreviewCommandToLegacy: (envelope) => { + this.legacyAdapter.forwardPreviewCommand(envelope); + }, + }); } - handleConnection(client: WebSocket) { - this.connectionList.push(client); - } + public handleConnection(client: WebSocket, request: IncomingMessage) { + const connectionKind = this.editorPreviewHost.acceptConnection( + client, + request, + ); + if (connectionKind === 'legacy') { + this.legacyAdapter.addConnection(client); + } - handleDisconnect(client: WebSocket) { - const index = this.connectionList.indexOf(client); - if (index !== -1) { - this.connectionList.splice(index, 1); + if (connectionKind === 'rejected') { + return; } - } - @SubscribeMessage('message') - handleMessage( - @MessageBody() data: string, // @ConnectedSocket() client: WebSocket, - ): void { - this.connectionList.forEach((socket) => { - const sendData = JSON.stringify({ - event: 'message', - data, - }); - socket.send(sendData); + client.on('message', (rawData: RawData) => { + const rawMessage = this.normalizeRawMessage(rawData); + if (connectionKind === 'legacy') { + this.legacyAdapter.handleMessage(client, rawMessage); + return; + } + + this.editorPreviewHost.handleMessage(client, rawMessage); }); } + + public handleDisconnect(client: WebSocket) { + this.editorPreviewHost.removeConnection(client); + this.legacyAdapter.removeConnection(client); + } + + private normalizeRawMessage(rawData: RawData): string { + if (typeof rawData === 'string') { + return rawData; + } + + if (Array.isArray(rawData)) { + return Buffer.concat(rawData).toString(); + } + + if (rawData instanceof ArrayBuffer) { + return Buffer.from(rawData).toString(); + } + + return rawData.toString(); + } }