diff --git a/package.json b/package.json index 646a980ef..db4ebdd90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@webgal/base", - "version": "4.6.1", + "version": "4.6.2", "description": "A brand new web Visual Novel engine.", "repository": "https://github.com/OpenWebGAL/WebGAL.git", "author": "Mahiru ", diff --git a/packages/parser/.npmignore b/packages/parser/.npmignore index 7bcd9b489..4b634ed49 100644 --- a/packages/parser/.npmignore +++ b/packages/parser/.npmignore @@ -3,4 +3,3 @@ test .babelrc rollup.config.js tsconfig.json -tsconfig.node.json diff --git a/packages/parser/package.json b/packages/parser/package.json index 6eb112d2e..480e75678 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "webgal-parser", - "version": "4.6.1", + "version": "4.6.2", "description": "WebGAL script parser", "scripts": { "test": "vitest", @@ -17,26 +17,16 @@ "main": "./build/cjs/index.cjs", "author": "Mahiru ", "license": "MPL-2.0", - "dependencies": { - "chevrotain": "^10.5.0", - "cloudlogjs": "^1.0.11", - "lodash": "^4.17.23", - "tsx": "^3.12.7" - }, "devDependencies": { "@rollup/plugin-commonjs": "^23.0.2", - "@rollup/plugin-json": "^5.0.1", "@rollup/plugin-node-resolve": "^15.0.1", - "@rollup/plugin-typescript": "^9.0.2", - "@types/lodash": "^4.14.189", "@types/node": "^18.14.0", "@vitest/coverage-c8": "^0.28.5", + "rimraf": "^3.0.2", "rollup": "^3.29.5", - "rollup-plugin-babel": "^4.4.0", - "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.34.1", - "ts-node": "^10.9.1", "tslib": "^2.4.1", + "tsx": "^3.12.7", "typescript": "^4.9.3", "vitest": "^0.28.5" }, diff --git a/packages/parser/rollup.config.js b/packages/parser/rollup.config.js index 57bce0cc5..85cee0794 100644 --- a/packages/parser/rollup.config.js +++ b/packages/parser/rollup.config.js @@ -53,6 +53,7 @@ export default [ file: "./build/umd/index.global.js", name: 'webgalParser', format: 'iife', + exports: "named", sourcemap: !isProd }, ], diff --git a/packages/parser/src/sceneParser.ts b/packages/parser/src/sceneParser.ts index e6528be25..7a8ff5814 100644 --- a/packages/parser/src/sceneParser.ts +++ b/packages/parser/src/sceneParser.ts @@ -5,7 +5,6 @@ import { ISentence, } from './interface/sceneInterface'; import { scriptParser } from './scriptParser/scriptParser'; -import uniqWith from 'lodash/uniqWith'; import { fileType } from './interface/assets'; import { ConfigMap } from './config/scriptConfig'; @@ -54,7 +53,7 @@ export const sceneParser = ( ); // 开始资源的预加载 - assetsList = uniqWith(assetsList); // 去重 + assetsList = deduplicateAssets(assetsList); assetsPrefetcher(assetsList); return { @@ -65,3 +64,18 @@ export const sceneParser = ( subSceneList: subSceneList, // 子场景列表 }; }; + +const deduplicateAssets = (assetsList: IAsset[]): IAsset[] => { + const seenAssets = new Set(); + return assetsList.filter((asset) => { + if (!asset || typeof asset.url !== 'string' || asset.url === '') { + return false; + } + const assetKey = `${asset.type}:${asset.url}`; + if (seenAssets.has(assetKey)) { + return false; + } + seenAssets.add(assetKey); + return true; + }); +}; diff --git a/packages/parser/src/scriptParser/scriptParser.ts b/packages/parser/src/scriptParser/scriptParser.ts index 4ecb68e73..0fd07d93e 100644 --- a/packages/parser/src/scriptParser/scriptParser.ts +++ b/packages/parser/src/scriptParser/scriptParser.ts @@ -40,7 +40,7 @@ export const scriptParser = ( const commentSplit = sentenceRaw.split(/(? { }); }); +test("scene assets are deduplicated by type and url", async () => { + let prefetchedAssets: IAsset[] = []; + const parser = new SceneParser((assetList) => { + prefetchedAssets = assetList; + }, (fileName, assetType) => { + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`changeBg:shared.webp; +changeFigure:shared.webp; +changeBg:shared.webp;`, 'test', 'test'); + + expect(result.assetsList).toEqual([ + { name: "shared.webp", url: 'shared.webp', type: fileType.background, lineNumber: 0 }, + { name: "shared.webp", url: 'shared.webp', type: fileType.figure, lineNumber: 1 }, + ]); + expect(prefetchedAssets).toEqual(result.assetsList); +}); + +test("scene assets skip entries with empty urls", async () => { + const parser = new SceneParser((assetList) => { + }, (fileName, assetType) => { + if (assetType === fileType.vocal) { + return ''; + } + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`say:123 -vocal=missing.mp3;`, 'test', 'test'); + + expect(result.assetsList).toEqual([]); +}); + test("wait command", async () => { const parser = new SceneParser((assetList) => { }, (fileName, assetType) => { @@ -326,6 +359,25 @@ test("escaped semicolon is preserved in content and inline comment is preserved" expect(result.sentenceList).toContainEqual(expectSentenceItem); }); +test("inline comment preserves following semicolons", async () => { + const parser = new SceneParser((assetList) => { + }, (fileName, assetType) => { + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`say:123; first; second; third`, 'test', 'test'); + const expectSentenceItem: ISentence = { + command: commandType.say, + commandRaw: "say", + content: "123", + args: [], + sentenceAssets: [], + subScene: [], + inlineComment: "first; second; third" + }; + expect(result.sentenceList).toContainEqual(expectSentenceItem); +}); + test("comment-only line keeps comment in content", async () => { const parser = new SceneParser((assetList) => { }, (fileName, assetType) => { @@ -344,3 +396,22 @@ test("comment-only line keeps comment in content", async () => { }; expect(result.sentenceList).toContainEqual(expectSentenceItem); }); + +test("comment-only line preserves following semicolons", async () => { + const parser = new SceneParser((assetList) => { + }, (fileName, assetType) => { + return fileName; + }, ADD_NEXT_ARG_LIST, SCRIPT_CONFIG); + + const result = parser.parse(`; first; second; third`, 'test', 'test'); + const expectSentenceItem: ISentence = { + command: commandType.comment, + commandRaw: "comment", + content: "first; second; third", + args: [{ key: 'next', value: true }], + sentenceAssets: [], + subScene: [], + inlineComment: "" + }; + expect(result.sentenceList).toContainEqual(expectSentenceItem); +}); diff --git a/packages/parser/tsconfig.json b/packages/parser/tsconfig.json index 5fb839743..b456d685f 100644 --- a/packages/parser/tsconfig.json +++ b/packages/parser/tsconfig.json @@ -4,7 +4,7 @@ "declarationDir": "build/types", "target": "ESNext", "useDefineForClassFields": true, - "lib": ["DOM", "DOM.Iterable", "ESNext"], + "lib": ["ESNext"], "allowJs": false, "skipLibCheck": false, "esModuleInterop": false, @@ -17,13 +17,12 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx", + "types": ["node"], "paths": { "@/*": ["./src/*"] } }, "include": [ "src" - ], - "references": [{ "path": "./tsconfig.node.json"}] + ] } diff --git a/packages/parser/tsconfig.node.json b/packages/parser/tsconfig.node.json deleted file mode 100644 index fde6cf327..000000000 --- a/packages/parser/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "module": "esnext", - "moduleResolution": "node" - }, - "include": [ - "vite.config.ts" - ] -} diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 7a216c9f4..f5ffbbc95 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -1,6 +1,6 @@ { "name": "webgal-engine", - "version": "4.6.1", + "version": "4.6.2", "scripts": { "dev": "vite --host --port 3000", "build": "node scripts/update-engine-version.js && cross-env NODE_ENV=production tsc && vite build --base=./", diff --git a/packages/webgal/public/game/config.txt b/packages/webgal/public/game/config.txt index c2c6f05ac..1f617b0fd 100644 --- a/packages/webgal/public/game/config.txt +++ b/packages/webgal/public/game/config.txt @@ -5,3 +5,4 @@ Title_bgm:s_Title.mp3; Game_Logo:WebGalEnter.webp; Enable_Appreciation:true; Enable_Continue:true; +Enable_flowchart:true; diff --git a/packages/webgal/public/game/flowchart.json b/packages/webgal/public/game/flowchart.json new file mode 100644 index 000000000..d01dc3b62 --- /dev/null +++ b/packages/webgal/public/game/flowchart.json @@ -0,0 +1,172 @@ +{ + "flowcharts": [ + { + "id": "main", + "name": "示例游戏主线", + "type": "main", + "nodes": [ + { + "id": "start", + "type": "root", + "position": { "x": 250, "y": 0 }, + "data": { + "label": "入口选择", + "sceneName": "start.txt", + "isRoot": true + } + }, + { + "id": "zh_cn", + "type": "chapter", + "position": { "x": 0, "y": 120 }, + "data": { + "label": "简体中文演示", + "sceneName": "demo_zh_cn.txt" + } + }, + { + "id": "ja", + "type": "chapter", + "position": { "x": 240, "y": 120 }, + "data": { + "label": "日本語デモ", + "sceneName": "demo_ja.txt" + } + }, + { + "id": "en", + "type": "chapter", + "position": { "x": 480, "y": 120 }, + "data": { + "label": "English Demo", + "sceneName": "demo_en.txt" + } + }, + { + "id": "function_test", + "type": "branch", + "position": { "x": 720, "y": 120 }, + "data": { + "label": "功能测试入口", + "sceneName": "function_test.txt" + } + } + ], + "edges": [ + { "id": "e-start-zh-cn", "source": "start", "target": "zh_cn" }, + { "id": "e-start-ja", "source": "start", "target": "ja" }, + { "id": "e-start-en", "source": "start", "target": "en" }, + { "id": "e-start-function-test", "source": "start", "target": "function_test" } + ] + }, + { + "id": "function-tests", + "name": "功能测试分支", + "type": "character", + "nodes": [ + { + "id": "function_test", + "type": "root", + "position": { "x": 550, "y": 0 }, + "data": { + "label": "功能测试入口", + "sceneName": "function_test.txt", + "isRoot": true + } + }, + { + "id": "animation", + "type": "event", + "position": { "x": 0, "y": 120 }, + "data": { + "label": "口型动画测试", + "sceneName": "demo_animation.txt" + } + }, + { + "id": "var", + "type": "event", + "position": { "x": 220, "y": 120 }, + "data": { + "label": "变量插值测试", + "sceneName": "demo_var.txt" + } + }, + { + "id": "change_config", + "type": "event", + "position": { "x": 440, "y": 120 }, + "data": { + "label": "配置修改测试", + "sceneName": "demo_changeConfig.txt" + } + }, + { + "id": "performs", + "type": "event", + "position": { "x": 660, "y": 120 }, + "data": { + "label": "Pixi 演出测试", + "sceneName": "demo_performs.txt" + } + }, + { + "id": "flow_control", + "type": "branch", + "position": { "x": 880, "y": 120 }, + "data": { + "label": "流程控制测试", + "sceneName": "demo_test_flow_control.txt" + } + }, + { + "id": "variable_flow", + "type": "branch", + "position": { "x": 1100, "y": 120 }, + "data": { + "label": "变量流程测试", + "sceneName": "demo_test_variable_flow_control.txt" + } + }, + { + "id": "input_flow", + "type": "branch", + "position": { "x": 1320, "y": 120 }, + "data": { + "label": "用户输入流程测试", + "sceneName": "demo_test_input_flow_control.txt" + } + }, + { + "id": "dom_control", + "type": "event", + "position": { "x": 1540, "y": 120 }, + "data": { + "label": "DOM 生命周期测试", + "sceneName": "demo_test_dom_control.txt" + } + }, + { + "id": "flow_child", + "type": "event", + "position": { "x": 880, "y": 240 }, + "data": { + "label": "callScene 子场景", + "sceneName": "demo_test_flow_control_child.txt" + } + } + ], + "edges": [ + { "id": "e-function-animation", "source": "function_test", "target": "animation" }, + { "id": "e-function-var", "source": "function_test", "target": "var" }, + { "id": "e-function-change-config", "source": "function_test", "target": "change_config" }, + { "id": "e-function-performs", "source": "function_test", "target": "performs" }, + { "id": "e-function-flow-control", "source": "function_test", "target": "flow_control" }, + { "id": "e-function-variable-flow", "source": "function_test", "target": "variable_flow" }, + { "id": "e-function-input-flow", "source": "function_test", "target": "input_flow" }, + { "id": "e-function-dom-control", "source": "function_test", "target": "dom_control" }, + { "id": "e-flow-control-child", "source": "flow_control", "target": "flow_child" } + ] + } + ] +} diff --git a/packages/webgal/public/game/scene/demo_zh_cn.txt b/packages/webgal/public/game/scene/demo_zh_cn.txt index 73f93057c..404faee05 100644 --- a/packages/webgal/public/game/scene/demo_zh_cn.txt +++ b/packages/webgal/public/game/scene/demo_zh_cn.txt @@ -26,7 +26,7 @@ pixiPerform:snow; pixiInit; WebGAL:接下来介绍一些新版本功能! WebGAL:比如这个[注](zhù)[音](yīn)功能,可以为游戏带来更好的体验! -WebGAL:我们也支持了[文本拓展语法](style=color:#B5495B\; style-alltext=-webkit-text-stroke-color:#ffffff\;),可以为[文](wen)[本](ben)带来[富文本支持](style-alltext=font-style:italic\;-webkit-text-stroke-color:#ffffff\; style=color:#66327C\;)、交互等特性。 +WebGAL:我们也支持了[文本拓展语法](style=color:#B5495B\;),可以为[文](wen)[本](ben)带来[富文本支持](style-alltext=font-style:italic\; style=color:#66327C\;)、交互等特性。 changeFigure:thinking.webp -left -transform={"position":{"x":500,"y":0}} -next; WebGAL:新版本添加了特性:获取用户输入,你要尝试一下吗? choose:尝试一下:userInput|算了吧:toNextPart; diff --git a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss index 54a0a6849..637634a4f 100644 --- a/packages/webgal/public/game/template/Stage/TextBox/textbox.scss +++ b/packages/webgal/public/game/template/Stage/TextBox/textbox.scss @@ -177,9 +177,15 @@ overflow: hidden; } +.readText { +} + .readTextOuter { background-image: none; background-clip: border-box; -webkit-background-clip: border-box; color: #C0C0C0; } + +.readTextInner { +} diff --git a/packages/webgal/public/game/template/template.json b/packages/webgal/public/game/template/template.json index 8d2dd8058..981384f04 100644 --- a/packages/webgal/public/game/template/template.json +++ b/packages/webgal/public/game/template/template.json @@ -1,4 +1,4 @@ { "name":"WebGAL Refine 2026", - "webgal-version":"4.6.1" + "webgal-version":"4.6.2" } diff --git a/packages/webgal/public/webgal-engine.json b/packages/webgal/public/webgal-engine.json index f3c2cd034..7cdd18a14 100644 --- a/packages/webgal/public/webgal-engine.json +++ b/packages/webgal/public/webgal-engine.json @@ -2,9 +2,9 @@ "schemaVersion": "1.0.0", "id": "open-webgal.webgal", "name": "WebGAL", - "version": "4.6.1", + "version": "4.6.2", "type": "official", - "webgalVersion": "4.6.1", + "webgalVersion": "4.6.2", "description": "界面美观、功能强大、易于开发的全新网页端视觉小说引擎", "descriptions": { "en": "A brand new web Visual Novel engine with a beautiful interface, powerful features, and easy development", diff --git a/packages/webgal/src/Core/Modules/flowchart.ts b/packages/webgal/src/Core/Modules/flowchart.ts new file mode 100644 index 000000000..aece2596e --- /dev/null +++ b/packages/webgal/src/Core/Modules/flowchart.ts @@ -0,0 +1,207 @@ +import { SceneManager } from '@/Core/Modules/scene'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { ISaveData } from '@/store/userDataInterface'; +import axios from 'axios'; +import cloneDeep from 'lodash/cloneDeep'; +import localforage from 'localforage'; + +export interface IFlowchartNodeData extends Record { + label: string; + sceneName: string; + isRoot?: boolean; +} + +export interface IFlowchartNode { + id: string; + position: { x: number; y: number }; + data: IFlowchartNodeData; + type?: 'root' | 'chapter' | 'branch' | 'ending' | 'event' | string; +} + +export interface IFlowchartEdge { + id: string; + source: string; + target: string; +} + +export interface IFlowchart { + id: string; + name: string; + type?: 'main' | 'character'; + nodes: IFlowchartNode[]; + edges: IFlowchartEdge[]; +} + +export interface IFlowchartData { + flowcharts: IFlowchart[]; +} + +const FLOWCHART_UPDATED_EVENT = 'webgal-flowchart-updated'; + +export class FlowchartManager { + public enabled = false; + private gameKey = ''; + private data: IFlowchartData = { flowcharts: [] }; + private unlocked = new Set(); + private snapshots = new Map(); + private pendingUnlockCurrentScene = false; + private waitingUnlockSceneKey = ''; + + public constructor(private readonly sceneManager: SceneManager) {} + + public async init(gameKey: string, enabled: boolean) { + this.gameKey = gameKey; + this.enabled = enabled; + this.data = { flowcharts: [] }; + this.unlocked.clear(); + this.snapshots.clear(); + if (!enabled || !gameKey) return; + try { + const res = await axios.get('./game/flowchart.json'); + this.data = normalizeFlowchartData(res.data); + const unlocked = await localforage.getItem(this.progressKey()); + this.unlocked = new Set(Array.isArray(unlocked) ? unlocked : []); + this.unlockPendingCurrentScene(); + } catch { + this.data = { flowcharts: [] }; + } + } + + public hasFlowchart() { + return this.enabled && this.data.flowcharts.length > 0; + } + + public getFlowcharts() { + return this.data.flowcharts; + } + + public getEventName() { + return FLOWCHART_UPDATED_EVENT; + } + + public isUnlocked(flowchartId: string, nodeId: string) { + return this.unlocked.has(this.nodeKey(flowchartId, nodeId)); + } + + public requestUnlockCurrentScene() { + if (this.currentSceneKey() !== this.waitingUnlockSceneKey) return; + this.pendingUnlockCurrentScene = true; + } + + public unlockPendingCurrentScene() { + if (!this.pendingUnlockCurrentScene || !this.hasFlowchart()) return; + this.pendingUnlockCurrentScene = false; + this.waitingUnlockSceneKey = ''; + this.unlockCurrentScene(true); + } + + public waitForCurrentSceneDialog() { + this.pendingUnlockCurrentScene = false; + this.waitingUnlockSceneKey = this.currentSceneKey(); + } + + public async loadSnapshot(flowchartId: string, nodeId: string) { + const key = this.nodeKey(flowchartId, nodeId); + if (!this.unlocked.has(key)) return null; + const cached = this.snapshots.get(key); + if (cached) return cached; + return await localforage.getItem(this.snapshotKey(flowchartId, nodeId)); + } + + public async clearProgress() { + const keys = [...this.unlocked]; + this.unlocked.clear(); + this.snapshots.clear(); + await Promise.all(keys.map((key) => localforage.removeItem(this.snapshotKeyByNodeKey(key)))); + await localforage.removeItem(this.progressKey()); + window.dispatchEvent(new Event(FLOWCHART_UPDATED_EVENT)); + } + + public unlockCurrentScene(refreshSnapshot = false) { + if (!this.hasFlowchart()) return; + const sceneNames = new Set([ + normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneName), + normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneUrl), + ]); + const matched = this.data.flowcharts.flatMap((flowchart) => + flowchart.nodes + .filter((node) => sceneNames.has(normalizeSceneName(node.data?.sceneName))) + .map((node) => ({ flowchart, node })), + ); + if (matched.length === 0) return; + const snapshot = this.createSnapshot(); + let changed = false; + matched.forEach(({ flowchart, node }) => { + const key = this.nodeKey(flowchart.id, node.id); + const isUnlocked = this.unlocked.has(key); + if (isUnlocked && !refreshSnapshot) return; + if (!isUnlocked) { + changed = true; + this.unlocked.add(key); + } + this.snapshots.set(key, snapshot); + localforage.setItem(this.snapshotKey(flowchart.id, node.id), snapshot); + }); + if (changed) { + localforage.setItem(this.progressKey(), [...this.unlocked]); + window.dispatchEvent(new Event(FLOWCHART_UPDATED_EVENT)); + } + } + + private createSnapshot(): ISaveData { + return { + nowStageState: cloneDeep(stageStateManager.getViewStageState()), + backlog: [], + index: -1, + saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }), + sceneData: { + currentSentenceId: this.sceneManager.sceneData.currentSentenceId, + sceneStack: cloneDeep(this.sceneManager.sceneData.sceneStack), + sceneName: this.sceneManager.sceneData.currentScene.sceneName, + sceneUrl: this.sceneManager.sceneData.currentScene.sceneUrl, + }, + previewImage: '', + }; + } + + private progressKey() { + return `${this.gameKey}-flowchart`; + } + + private snapshotKey(flowchartId: string, nodeId: string) { + return `${this.gameKey}-flowchart-${flowchartId}-${nodeId}`; + } + + private snapshotKeyByNodeKey(key: string) { + return `${this.gameKey}-flowchart-${key}`; + } + + private nodeKey(flowchartId: string, nodeId: string) { + return `${flowchartId}-${nodeId}`; + } + + private currentSceneKey() { + const { currentScene } = this.sceneManager.sceneData; + return `${normalizeSceneName(currentScene.sceneName)}|${normalizeSceneName(currentScene.sceneUrl)}`; + } +} + +function normalizeFlowchartData(raw: string | IFlowchartData): IFlowchartData { + const data = typeof raw === 'string' ? JSON.parse(raw) : raw; + const flowcharts: IFlowchart[] = Array.isArray(data?.flowcharts) ? data.flowcharts : []; + return { + flowcharts: flowcharts.map((flowchart) => ({ + ...flowchart, + type: flowchart.id === 'main' ? 'main' : flowchart.type || 'character', + nodes: Array.isArray(flowchart.nodes) ? flowchart.nodes : [], + edges: Array.isArray(flowchart.edges) ? flowchart.edges : [], + })), + }; +} + +function normalizeSceneName(sceneName = '') { + return decodeURI(sceneName) + .replace(/\\/g, '/') + .replace(/^\.?\/?game\/scene\//, '') + .replace(/^\.?\//, ''); +} diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index 7d51c3c71..a9ba6969b 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -1,6 +1,6 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { WEBGAL_NONE } from '@/Core/constants'; import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; @@ -110,15 +110,37 @@ export class PerformController { return this.pendingPerformList.some(({ perform }) => perform.blockingStateCalculation?.() ?? false); } + /** + * 是否存在正在运行且阻塞下一步的演出。 + * + * blockingNext 是最强的运行时推进阻塞:用户 next、内部 continue 和 forward 都要等待它解除。 + */ public hasBlockingNextPerform() { return this.performList.some((e) => e.blockingNext()); } + /** + * 是否存在可以被下一步提前结束的普通演出。 + * + * 这不是一种阻塞。它表示当前 next/continue 需要先结算这些非 hold 演出, + * 让它们的 stopFunction 和状态清理完成后,再决定是否继续推进。 + */ public hasUnsettledNonHoldPerform() { return this.performList.some((e) => !e.isHoldOn && !e.skipNextCollect); } - public settleNonHoldPerforms() { + /** + * 结算当前正在运行的普通非 hold 演出。 + * + * 用户 next 调用时,goNextWhenOver 应传 true,保留旧语义: + * 如果被提前结束的演出要求结束后继续,则由 perform 自己触发内部继续。 + * + * 内部 continue 调用时,goNextWhenOver 应传 false: + * 调用方会在结算后立即继续 forward,不能再让旧 perform 额外触发一次继续。 + * + * @param goNextWhenOver 是否消费被结算演出的 goNextWhenOver 标记。 + */ + public settleNonHoldPerforms(goNextWhenOver = true) { let isGoNext = false; for (let i = 0; i < this.performList.length; i++) { const e = this.performList[i]; @@ -136,8 +158,8 @@ export class PerformController { } } stageStateManager.commit(); - if (isGoNext) { - nextSentence(); + if (isGoNext && goNextWhenOver) { + continueSentence(); } } @@ -145,6 +167,12 @@ export class PerformController { stageStateManager.clearUncommittedNonHoldPerforms(); } + /** + * 启动一个已提交的 perform。 + * + * startFunction 只会在 commitPendingPerforms 之后运行,因此可以依赖已提交的 stage state。 + * 这里同时把脚本层的 -continue 转成 perform.goNextWhenOver。 + */ private startPerform(perform: IPerform, script: ISentence) { perform.isStarted = true; perform.startFunction?.(); @@ -186,15 +214,14 @@ export class PerformController { this.clearPerformTimeout(e); /** * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前 - * 因为 goNextWhenOver 会调用 nextSentence,而 nextSentence 会清除目前未结束的演出 - * 那么 nextSentence 函数就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 + * 因为 goNextWhenOver 会触发继续推进,而继续推进会清除目前未结束的演出 + * 那么继续推进就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 * 所以 goNextWhenOver 后的代码会多删东西,解决方法就是在调用 goNextWhenOver 前先删掉这个演出对象 * 此问题对所有 goNextWhenOver 属性为真的演出都有影响,但只有 2 个演出有此问题 */ this.performList.splice(i, 1); i--; if (e.goNextWhenOver) { - // nextSentence(); this.goNextWhenOver(); } this.erasePerformFromState(name); @@ -212,7 +239,6 @@ export class PerformController { this.performList.splice(i, 1); i--; if (e.goNextWhenOver) { - // nextSentence(); this.goNextWhenOver(); } /** @@ -259,8 +285,8 @@ export class PerformController { this.clearPerformTimeout(perform); /** * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前 - * 因为 goNextWhenOver 会调用 nextSentence,而 nextSentence 会清除目前未结束的演出 - * 那么 nextSentence 函数就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 + * 因为 goNextWhenOver 会触发继续推进,而继续推进会清除目前未结束的演出 + * 那么继续推进就会删除这个演出,但是此时,在这个上下文,i 已经被确定了 * 所以 goNextWhenOver 后的代码会多删东西,解决方法就是在调用 goNextWhenOver 前先删掉这个演出对象 * 此问题对所有 goNextWhenOver 属性为真的演出都有影响,但只有 2 个演出有此问题 */ @@ -268,7 +294,6 @@ export class PerformController { this.erasePerformFromState(perform.performName); stageStateManager.commit(); if (perform.goNextWhenOver) { - // nextSentence(); this.goNextWhenOver(); } } @@ -300,6 +325,12 @@ export class PerformController { perform.isStarted = false; } + /** + * perform 结束后的内部继续推进。 + * + * goNextWhenOver 不等于无条件跳下一句;它仍然必须等待所有 blockingNext 演出结束。 + * 等待完成后使用 continueSentence,避免触发 userInteractNext,也避免把自然结束误当成用户点击。 + */ private goNextWhenOver = () => { let isBlockingNext = false; this.performList?.forEach((e) => { @@ -311,7 +342,7 @@ export class PerformController { // 有阻塞,提前结束 setTimeout(this.goNextWhenOver, 100); } else { - nextSentence(); + continueSentence(); } }; } diff --git a/packages/webgal/src/Core/Modules/perform/performInterface.ts b/packages/webgal/src/Core/Modules/perform/performInterface.ts index 578cf4a51..5e635e407 100644 --- a/packages/webgal/src/Core/Modules/perform/performInterface.ts +++ b/packages/webgal/src/Core/Modules/perform/performInterface.ts @@ -17,17 +17,17 @@ export interface IPerform { isStarted?: boolean; // 卸载演出的函数 stopFunction: () => void; - // 演出是否阻塞游戏流程继续(一个函数,返回 boolean类型的结果,判断要不要阻塞) + // 是否阻塞用户/内部继续推进。为 true 时,next/forward/continue 都不能执行下一条语句。 blockingNext: () => boolean; - // 演出是否阻塞自动模式(一个函数,返回 boolean类型的结果,判断要不要阻塞) + // 是否阻塞自动播放计时器触发下一步;只影响 autoPlay,不影响用户点击或内部继续推进。 blockingAuto: () => boolean; - // 演出是否阻塞状态演算;默认不阻塞,只有需要外部输入才能确定后续状态的演出需要覆盖 + // 是否阻塞同一轮 -next 继续演算;只有需要外部输入才能确定后续状态的演出需要覆盖。 blockingStateCalculation?: () => boolean; // 未 commit 的演出被丢弃时,将它的终态同步到演算状态 settleStateOnDiscard?: () => void; - // 演出结束后转到下一句 + // 演出自然结束或被卸载后触发内部继续推进;推进前仍会等待 blockingNext 解除。 goNextWhenOver?: boolean; - // 跳过由 nextSentence 函数引发的演出回收 + // 跳过由 nextSentence/continueSentence 引发的非 hold 演出回收。 skipNextCollect?: boolean; } diff --git a/packages/webgal/src/Core/Modules/stage/stageInterface.ts b/packages/webgal/src/Core/Modules/stage/stageInterface.ts index 4e8cd25f5..217362291 100644 --- a/packages/webgal/src/Core/Modules/stage/stageInterface.ts +++ b/packages/webgal/src/Core/Modules/stage/stageInterface.ts @@ -1,5 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { BlinkParam, FocusParam } from '@/Core/live2DCore'; +import type { Transform } from '@/types/editorPreviewProtocol'; /** * 游戏内变量 @@ -24,49 +25,7 @@ export interface IChooseItem { isSubScene: boolean; // 是否是子场景调用 } -export interface ITransform { - alpha?: number; - scale?: { - x?: number; - y?: number; - }; - // pivot: { - // x: number; - // y: number; - // }; - position?: { - x?: number; - y?: number; - }; - rotation?: number; - blur?: number; - brightness?: number; - contrast?: number; - saturation?: number; - gamma?: number; - colorRed?: number; - colorGreen?: number; - colorBlue?: number; - bevel?: number; - bevelThickness?: number; - bevelRotation?: number; - bevelSoftness?: number; - bevelRed?: number; - bevelGreen?: number; - bevelBlue?: number; - bloom?: number; - bloomBrightness?: number; - bloomBlur?: number; - bloomThreshold?: number; - oldFilm?: number; - dotFilm?: number; - reflectionFilm?: number; - glitchFilm?: number; - rgbFilm?: number; - godrayFilm?: number; - shockwaveFilter?: number; - radiusAlphaFilter?: number; -} +export type ITransform = Transform; /** * 基本效果接口 @@ -104,10 +63,6 @@ export const baseTransform: ITransform = { x: 1, y: 1, }, - // pivot: { - // x: 0.5, - // y: 0.5, - // }, position: { x: 0, y: 0, diff --git a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts index c71b5eb55..b963d794c 100644 --- a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts +++ b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts @@ -23,12 +23,14 @@ export interface IStageCommitOptions { syncPixiStage?: boolean; applyPixiEffects?: boolean; notifyReact?: boolean; + skipAnimation?: boolean; } export interface IResolvedStageCommitOptions { syncPixiStage: boolean; applyPixiEffects: boolean; notifyReact: boolean; + skipAnimation: boolean; } type StageCommitHandler = (stageState: IStageState, options: IResolvedStageCommitOptions) => void; @@ -122,17 +124,17 @@ export class StageStateManager { this.calculationStageState = cloneDeep(stageState); } - public replaceAllStageState(stageState: IStageState) { + public replaceAllStageState(stageState: IStageState, options?: IStageCommitOptions) { this.calculationStageState = cloneDeep(stageState); - this.commit(); + this.commit(options); } public resetCalculationStageState(stageState: IStageState) { this.replaceCalculationStageState(stageState); } - public resetAllStageState(stageState: IStageState) { - this.replaceAllStageState(stageState); + public resetAllStageState(stageState: IStageState, options?: IStageCommitOptions) { + this.replaceAllStageState(stageState, options); } public updateEffect(payload: IEffect) { @@ -344,7 +346,9 @@ export class StageStateManager { } public clearUncommittedNonHoldPerforms() { - this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter((perform) => perform.isHoldOn); + this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter( + (perform) => perform.isHoldOn, + ); } public removeNonHoldPerformsAndCommit() { @@ -357,6 +361,7 @@ export class StageStateManager { syncPixiStage: options.syncPixiStage ?? true, applyPixiEffects: options.applyPixiEffects ?? true, notifyReact: options.notifyReact ?? true, + skipAnimation: options.skipAnimation ?? false, }; this.viewStageState = cloneDeep(this.calculationStageState); this.commitHandler?.(this.viewStageState, resolvedOptions); @@ -370,6 +375,7 @@ export class StageStateManager { syncPixiStage: false, applyPixiEffects: true, notifyReact: false, + skipAnimation: false, }); } diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index ab226a8c9..52fcbd89c 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -1,4 +1,4 @@ -import { scriptExecutor } from './scriptExecutor'; +import { scriptExecutor, type ScriptExecutionOptions } from './scriptExecutor'; import { logger } from '../../util/logger'; import { webgalStore } from '@/store/store'; @@ -6,9 +6,18 @@ import { WebGAL } from '@/Core/WebGAL'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** - * 步进前工作:检查阻塞,并在当前演出未完成时提前结束普通演出。 + * 执行一次推进前检查。 + * + * 这里处理三种“不能直接进入下一条语句”的情况: + * 1. 场景正在异步写入时,任何推进都必须停止。 + * 2. 存在 blockingNext 的演出时,用户推进和内部推进都必须等待。 + * 3. 存在可提前结束的非 hold 演出时,用户推进只负责结束演出并停下; + * 内部继续推进会先结束演出,然后继续执行下一条语句。 + * + * @param continueAfterSettling 是否在清理普通非 hold 演出后继续推进。 + * @returns true 表示可以继续调用 forward/commitForward。 */ -export const preForward = () => { +export const preForward = (continueAfterSettling = false) => { if (WebGAL.sceneManager.lockSceneWrite) { logger.warn('next 被场景切换阻塞!'); return false; @@ -22,17 +31,25 @@ export const preForward = () => { const hasUnsettledNonHoldPerform = WebGAL.gameplay.performController.hasUnsettledNonHoldPerform(); if (hasUnsettledNonHoldPerform) { logger.debug('提前结束被触发,现在清除普通演出'); - WebGAL.gameplay.performController.settleNonHoldPerforms(); - return false; + // 用户 next 不消费 goNextWhenOver;内部继续推进会自己接着 forward,避免重复触发下一步。 + WebGAL.gameplay.performController.settleNonHoldPerforms(!continueAfterSettling); + return continueAfterSettling; } return true; }; /** - * 执行一条语句或由 -next 连接的语句序列,只修改演算状态并收集演出。 + * 执行一条语句或由 -next 连接的语句序列。 + * + * forward 只推进 calculationStageState,并把命令返回的 perform 收集到 pending 列表; + * 它不会提交视图状态,也不会启动 perform。调用方必须在合适时机调用 commitForward。 */ -export const forward = () => { +export interface ForwardOptions { + scriptExecution?: ScriptExecutionOptions; +} + +export const forward = (options: ForwardOptions = {}) => { if (WebGAL.sceneManager.lockSceneWrite) { logger.warn('forward 被场景切换阻塞!'); return false; @@ -47,7 +64,7 @@ export const forward = () => { WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); WebGAL.gameplay.performController.beginCollectingPerforms(); try { - scriptExecutor(); + scriptExecutor(0, options.scriptExecution); } finally { WebGAL.gameplay.performController.endCollectingPerforms(); } @@ -55,16 +72,45 @@ export const forward = () => { }; /** - * 将演算状态提交到当前视图状态,并启动本序列收集到的演出。 + * 将本轮 forward 的演算结果提交到视图,并启动 pending perform。 + * + * 提交流程分三步:先提交 stage state,再启动 perform,最后应用 Pixi effects。 + * 这个顺序保证 startFunction 看到的是已提交的视图状态。 */ export const commitForward = () => { stageStateManager.commit({ applyPixiEffects: false }); WebGAL.gameplay.performController.commitPendingPerforms(); stageStateManager.applyCommittedPixiEffects(); + WebGAL.flowchartManager.unlockPendingCurrentScene(); +}; + +/** + * 内部继续推进。 + * + * 供场景切换完成、perform 自然结束、输入控件提交等内核流程调用。 + * 它不会触发 userInteractNext,因此不会把“内部自动继续”误判为用户点击。 + * 如果当前只剩可提前结束的非 hold 演出,会先结算它们并继续执行下一条语句。 + */ +export const continueSentence = () => { + const GUIState = webgalStore.getState().GUI; + if (GUIState.showTitle) { + return; + } + + if (!preForward(true)) { + return; + } + + forward(); + commitForward(); }; /** * 用户操作步进。 + * + * 供点击、键盘、自动播放和快进等“外部下一步”入口调用。 + * 它会触发 userInteractNext,让 intro 等演出先响应用户输入。 + * 如果当前存在可提前结束的普通演出,本次用户推进只结束演出,不再继续执行下一条语句。 */ export const nextSentence = () => { WebGAL.events.userInteractNext.emit(); diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 8bfbb5cd8..718e4a4e8 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -12,9 +12,19 @@ import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceAr import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { jumpToLabel } from '@/Core/gameScripts/label/jumpToLabel'; import { prefetchCurrentSceneByProgress } from '@/Core/util/prefetcher/progressPrefetcher'; +import { WEBGAL_NONE } from '@/Core/constants'; const MAX_FORWARD_SCRIPT_EXECUTION = 1000; +export interface ScriptExecutionContext { + sceneName: string; + sentenceId: number; +} + +export interface ScriptExecutionOptions { + beforeSentenceExecute?: (context: ScriptExecutionContext) => void; +} + export const whenChecker = (whenValue: string | undefined): boolean => { if (whenValue === undefined) { return true; @@ -39,7 +49,7 @@ export const whenChecker = (whenValue: string | undefined): boolean => { * 语句执行器 * 执行语句,同步场景状态,并根据情况立即执行下一句或者加入backlog */ -export const scriptExecutor = (depth = 0) => { +export const scriptExecutor = (depth = 0, options: ScriptExecutionOptions = {}) => { if (depth > MAX_FORWARD_SCRIPT_EXECUTION) { logger.error('forward 中执行的语句数量超过限制,可能存在 jumpLabel 或 -next 死循环'); return; @@ -59,8 +69,13 @@ export const scriptExecutor = (depth = 0) => { } return; } + const sentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; + options.beforeSentenceExecute?.({ + sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentenceId, + }); const currentScript: ISentence = cloneDeep( - WebGAL.sceneManager.sceneData.currentScene.sentenceList[WebGAL.sceneManager.sceneData.currentSentenceId], + WebGAL.sceneManager.sceneData.currentScene.sentenceList[sentenceId], ); const interpolationOneItem = (content: string): string => { @@ -91,6 +106,7 @@ export const scriptExecutor = (depth = 0) => { }; variableInterpolation(); + if (currentScript.content === WEBGAL_NONE) currentScript.content = ''; // 判断这个脚本要不要执行 let runThis = true; @@ -104,7 +120,7 @@ export const scriptExecutor = (depth = 0) => { if (!runThis) { logger.warn('不满足条件,跳过本句!'); WebGAL.sceneManager.sceneData.currentSentenceId++; - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } @@ -115,7 +131,7 @@ export const scriptExecutor = (depth = 0) => { logger.warn(`未找到标签 ${currentScript.content},跳过 jumpLabel`); WebGAL.sceneManager.sceneData.currentSentenceId++; } - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } @@ -148,7 +164,7 @@ export const scriptExecutor = (depth = 0) => { if (isNext && !hasPendingBlockingStateCalculationPerform && !WebGAL.sceneManager.lockSceneWrite) { WebGAL.sceneManager.sceneData.currentSentenceId++; saveBacklogIfNeeded(); - scriptExecutor(depth + 1); + scriptExecutor(depth + 1, options); return; } diff --git a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts index 67ec27c09..5211023fb 100644 --- a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts +++ b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts @@ -4,7 +4,7 @@ import { sceneParser } from '../../parser/sceneParser'; import { resetStage } from '@/Core/controller/stage/resetStage'; import { webgalStore } from '@/store/store'; import { setVisibility } from '@/store/GUIReducer'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { restorePerform } from '@/Core/controller/storage/jumpFromBacklog'; @@ -23,8 +23,9 @@ export const startGame = () => { // 场景写入到运行时 sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); + WebGAL.flowchartManager.waitForCurrentSceneDialog(); // 开始第一条语句 - nextSentence(); + continueSentence(); }); webgalStore.dispatch(setVisibility({ component: 'showTitle', visibility: false })); }; diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index 66ac09e04..47d66f0b6 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -1,7 +1,7 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -31,6 +31,7 @@ export const callScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.flowchartManager.waitForCurrentSceneDialog(); logger.debug('现在调用场景,调用结果:', WebGAL.sceneManager.sceneData); shouldAutoNext = !isFastPreviewSceneWrite; }) @@ -43,7 +44,8 @@ export const callScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneWritePromise = null; } if (shouldAutoNext) { - nextSentence(); + // 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。 + continueSentence(); } }); WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; diff --git a/packages/webgal/src/Core/controller/scene/changeScene.ts b/packages/webgal/src/Core/controller/scene/changeScene.ts index 7a6c1c981..bef8d1e37 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -1,7 +1,7 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher'; import { WebGAL } from '@/Core/WebGAL'; @@ -25,6 +25,7 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.flowchartManager.waitForCurrentSceneDialog(); logger.debug('现在切换场景,切换后的结果:', WebGAL.sceneManager.sceneData); shouldAutoNext = !isFastPreviewSceneWrite; }) @@ -37,7 +38,8 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneWritePromise = null; } if (shouldAutoNext) { - nextSentence(); + // 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。 + continueSentence(); } }); WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; diff --git a/packages/webgal/src/Core/controller/scene/restoreScene.ts b/packages/webgal/src/Core/controller/scene/restoreScene.ts index d1d671e6a..a41854033 100644 --- a/packages/webgal/src/Core/controller/scene/restoreScene.ts +++ b/packages/webgal/src/Core/controller/scene/restoreScene.ts @@ -1,7 +1,7 @@ import { sceneFetcher } from './sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { logger } from '../../util/logger'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { ISceneEntry } from '@/Core/Modules/scene'; import { WebGAL } from '@/Core/WebGAL'; @@ -34,7 +34,8 @@ export const restoreScene = (entry: ISceneEntry) => { WebGAL.sceneManager.sceneWritePromise = null; } if (shouldAutoNext) { - nextSentence(); + // 场景写入完成后的第一句推进是内核流程,不应触发用户 next 语义。 + continueSentence(); } }); WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; diff --git a/packages/webgal/src/Core/controller/scene/sceneFetcher.ts b/packages/webgal/src/Core/controller/scene/sceneFetcher.ts index c66c04cf1..9f6102c84 100644 --- a/packages/webgal/src/Core/controller/scene/sceneFetcher.ts +++ b/packages/webgal/src/Core/controller/scene/sceneFetcher.ts @@ -6,6 +6,16 @@ import axios from 'axios'; */ export const sceneFetcher = (sceneUrl: string) => { return new Promise((resolve, reject) => { + let scenePath = ''; + try { + scenePath = sceneUrl ? new URL(sceneUrl, window.location.href).pathname : ''; + } catch { + scenePath = ''; + } + if (!scenePath.endsWith('.txt')) { + reject('Scene file must be a txt file'); + return; + } axios .get(sceneUrl) .then((response) => { diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index fab9b1bfc..da395ffcd 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -8,12 +8,12 @@ import { SCREEN_CONSTANTS } from '@/Core/util/constants'; import { logger } from '@/Core/util/logger'; import { v4 as uuid } from 'uuid'; import { cloneDeep, isEqual } from 'lodash'; -import omitBy from 'lodash/omitBy'; -import isUndefined from 'lodash/isUndefined'; import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { queryStageObjectReferenceBox, type QueryTargetReferenceBoxResult } from './referenceBox'; +import { assignPixiTransform } from './stageEffectTransform'; export interface IAnimationObject { setStartState: Function; @@ -69,21 +69,7 @@ INSTALLED.push(GifResource); export default class PixiStage { public static assignTransform(target: T, source?: ITransform, convertAlpha = true) { - if (!source) return; - const targetScale = target.scale; - const targetPosition = target.position; - if (target.scale) Object.assign(targetScale!, omitBy(source.scale || {}, isUndefined)); - if (target.position) Object.assign(targetPosition!, omitBy(source.position || {}, isUndefined)); - Object.assign(target, omitBy(source, isUndefined)); - target.scale = targetScale; - target.position = targetPosition; - if (convertAlpha) { - const sourceAlpha = source.alpha; - if (sourceAlpha !== undefined) { - target.alpha = 1; - (target as any).alphaFilterVal = sourceAlpha; - } - } + assignPixiTransform(target, source, convertAlpha); } /** @@ -120,6 +106,7 @@ export default class PixiStage { private isRenderPending = false; // 更新 ticker 状态的防抖标记 private isTickerUpdatePending = false; + private referenceBoxWaiters = new Map void>>(); /** * 暂时没用上,以后可能用 @@ -467,6 +454,7 @@ export default class PixiStage { // 挂载 thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); this.requestRender(); } }, 0); @@ -551,6 +539,7 @@ export default class PixiStage { thisBgContainer.setBaseY(this.stageHeight / 2); thisBgContainer.pivot.set(0, this.stageHeight / 2); thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); }); } }, 0); @@ -609,7 +598,6 @@ export default class PixiStage { sourceType: sourceExt === 'gif' ? 'gif' : 'img', sourceExt, }); - // 完成图片加载后执行的函数 const setup = () => { // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! @@ -646,6 +634,7 @@ export default class PixiStage { } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); this.requestRender(); } }, 0); @@ -807,6 +796,7 @@ export default class PixiStage { Live2D.SoundManager.volume = 0; // @ts-ignore thisFigureContainer.addChild(model); + instance.notifyTargetReferenceBoxChanged(key); }); })(); } @@ -1030,6 +1020,36 @@ export default class PixiStage { return [...this.figureObjects, ...this.backgroundObjects, this.mainStageObject].find((e) => e.key === key); } + public queryTargetReferenceBox(target: string): QueryTargetReferenceBoxResult { + return queryStageObjectReferenceBox(target, this.getStageObjByKey(target), { + width: this.stageWidth, + height: this.stageHeight, + }); + } + + public waitForTargetReferenceBox(target: string, timeoutMs: number): Promise { + return new Promise((resolve) => { + const existingWaiters = this.referenceBoxWaiters.get(target); + const waiters = existingWaiters ?? new Set<() => void>(); + if (!existingWaiters) { + this.referenceBoxWaiters.set(target, waiters); + } + + let timeoutId = 0; + const resolveAndCleanup = () => { + window.clearTimeout(timeoutId); + waiters.delete(resolveAndCleanup); + if (waiters.size === 0) { + this.referenceBoxWaiters.delete(target); + } + resolve(); + }; + + timeoutId = window.setTimeout(resolveAndCleanup, timeoutMs); + waiters.add(resolveAndCleanup); + }); + } + public getStageObjByUuid(objUuid: string) { return [...this.figureObjects, ...this.backgroundObjects, this.mainStageObject].find((e) => e.uuid === objUuid); } @@ -1057,6 +1077,7 @@ export default class PixiStage { } bgSprite.pixiContainer = null; this.figureObjects.splice(indexFig, 1); + this.notifyTargetReferenceBoxChanged(key); } if (indexBg >= 0) { const bgSprite = this.backgroundObjects[indexBg]; @@ -1070,6 +1091,7 @@ export default class PixiStage { } bgSprite.pixiContainer = null; this.backgroundObjects.splice(indexBg, 1); + this.notifyTargetReferenceBoxChanged(key); } // /** // * 删掉相关 Effects,因为已经移除了 @@ -1142,6 +1164,17 @@ export default class PixiStage { } } + public notifyTargetReferenceBoxChanged(target: string): void { + const waiters = this.referenceBoxWaiters.get(target); + if (!waiters) { + return; + } + + for (const resolve of [...waiters]) { + resolve(); + } + } + private callLoader() { if (!this.assetLoader.loading) { const front = this.loadQueue.shift(); diff --git a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts index fc2fc6f14..0dead6a9f 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/WebGALPixiContainer.ts @@ -328,6 +328,45 @@ const PROPERTY_CONFIGS: Record = { }, }; +function getChildReferenceBounds(child: PIXI.DisplayObject, containerPivot: PIXI.IPointData): PIXI.Rectangle { + child.transform.updateLocalTransform(); + const bounds = child.getLocalBounds(); + const { a, b, c, d, tx, ty } = child.transform.localTransform; + const x0 = bounds.x; + const x1 = bounds.x + bounds.width; + const y0 = bounds.y; + const y1 = bounds.y + bounds.height; + const pivotX = containerPivot.x; + const pivotY = containerPivot.y; + + const p0x = a * x0 + c * y0 + tx - pivotX; + const p0y = b * x0 + d * y0 + ty - pivotY; + const p1x = a * x1 + c * y0 + tx - pivotX; + const p1y = b * x1 + d * y0 + ty - pivotY; + const p2x = a * x0 + c * y1 + tx - pivotX; + const p2y = b * x0 + d * y1 + ty - pivotY; + const p3x = a * x1 + c * y1 + tx - pivotX; + const p3y = b * x1 + d * y1 + ty - pivotY; + + const minX = Math.min(p0x, p1x, p2x, p3x); + const minY = Math.min(p0y, p1y, p2y, p3y); + const maxX = Math.max(p0x, p1x, p2x, p3x); + const maxY = Math.max(p0y, p1y, p2y, p3y); + + return new PIXI.Rectangle(minX, minY, maxX - minX, maxY - minY); +} + +function mergeBounds(target: PIXI.Rectangle, next: PIXI.Rectangle): void { + const minX = Math.min(target.x, next.x); + const minY = Math.min(target.y, next.y); + const maxX = Math.max(target.x + target.width, next.x + next.width); + const maxY = Math.max(target.y + target.height, next.y + next.height); + target.x = minX; + target.y = minY; + target.width = maxX - minX; + target.height = maxY - minY; +} + export class WebGALPixiContainer extends PIXI.Container { public containerFilters = new Map(); private filterToName = new Map(); @@ -415,6 +454,33 @@ export class WebGALPixiContainer extends PIXI.Container { this.y = old; } + public getBasePosition(): { x: number; y: number } { + return { + x: this.baseX, + y: this.baseY, + }; + } + + public getReferenceLocalBounds(): PIXI.Rectangle | undefined { + if (this.children.length === 0) { + return undefined; + } + + let referenceBounds: PIXI.Rectangle | undefined; + for (const child of this.children) { + const localBounds = getChildReferenceBounds(child, this.pivot); + + if (!referenceBounds) { + referenceBounds = localBounds; + continue; + } + + mergeBounds(referenceBounds, localBounds); + } + + return referenceBounds; + } + // --- Standard Filters --- public get blur(): number { return this._getPropertyValue('blur'); diff --git a/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts b/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts new file mode 100644 index 000000000..d009fa9ac --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/referenceBox.ts @@ -0,0 +1,101 @@ +import type { ReferenceBox, ReferenceBoxQueryResultPayload } from '@/types/editorPreviewProtocol'; + +interface PointLike { + x: number; + y: number; +} + +interface SizeLike { + width: number; + height: number; +} + +interface BoundsLike extends SizeLike { + x: number; + y: number; +} + +interface ReferenceBoxContainer { + getBasePosition(): PointLike; + getReferenceLocalBounds(): BoundsLike | undefined; +} + +export interface ReferenceBoxStageObject { + pixiContainer: ReferenceBoxContainer | null; + sourceType: 'img' | 'live2d' | 'spine' | 'gif' | 'video' | 'stage'; +} + +export type QueryTargetReferenceBoxResult = ReferenceBoxQueryResultPayload; + +function createReferenceBox(origin: PointLike, localBounds: BoundsLike, stageSize: SizeLike): ReferenceBox { + const anchorX = localBounds.width === 0 ? 0.5 : -localBounds.x / localBounds.width; + const anchorY = localBounds.height === 0 ? 0.5 : -localBounds.y / localBounds.height; + + return { + originX: origin.x, + originY: origin.y, + width: localBounds.width, + height: localBounds.height, + anchorX, + anchorY, + stageWidth: stageSize.width, + stageHeight: stageSize.height, + }; +} + +function createStageFrameReferenceBox(stageSize: SizeLike): ReferenceBox { + return { + originX: stageSize.width / 2, + originY: stageSize.height / 2, + width: stageSize.width, + height: stageSize.height, + anchorX: 0.5, + anchorY: 0.5, + stageWidth: stageSize.width, + stageHeight: stageSize.height, + }; +} + +export function queryStageObjectReferenceBox( + target: string, + stageObject: ReferenceBoxStageObject | undefined, + stageSize: SizeLike, +): QueryTargetReferenceBoxResult { + if (!stageObject) { + return { + target, + status: 'missing', + }; + } + + if (stageObject.sourceType === 'stage') { + return { + target, + status: 'ready', + box: createStageFrameReferenceBox(stageSize), + }; + } + + if (!stageObject.pixiContainer) { + return { + target, + status: 'loading', + reason: 'Pixi 容器不可用', + }; + } + + const localBounds = stageObject.pixiContainer.getReferenceLocalBounds(); + if (!localBounds) { + return { + target, + status: 'loading', + reason: 'reference bounds 不可用', + }; + } + + return { + target, + status: 'ready', + box: createReferenceBox(stageObject.pixiContainer.getBasePosition(), localBounds, stageSize), + }; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/spine.ts b/packages/webgal/src/Core/controller/stage/pixi/spine.ts index f32eb3bb7..22cd07a2a 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/spine.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/spine.ts @@ -172,6 +172,7 @@ export async function addSpineFigureImpl( } thisFigureContainer.pivot.set(0, this.stageHeight / 2); thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); } }, 0); }; @@ -264,6 +265,7 @@ export async function addSpineBgImpl(this: PixiStage, key: string, url: string) // 挂载 thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); } }, 0); }; diff --git a/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts b/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts new file mode 100644 index 000000000..8c7ab6aea --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/stageEffectTransform.ts @@ -0,0 +1,45 @@ +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import type { ITransform } from '@/Core/Modules/stage/stageInterface'; +import { isUndefined, omitBy } from 'lodash'; +import type { WebGALPixiContainer } from './WebGALPixiContainer'; + +type PixiTransformPatch = ITransform & { + x?: number; + y?: number; + alphaFilterVal?: number; +}; + +export function assignPixiTransform( + target: T | undefined, + source?: PixiTransformPatch, + convertAlpha = true, +) { + if (!target || !source) return; + const targetScale = target.scale; + const targetPosition = target.position; + if (targetScale) Object.assign(targetScale, omitBy(source.scale || {}, isUndefined)); + if (targetPosition) Object.assign(targetPosition, omitBy(source.position || {}, isUndefined)); + Object.assign(target, omitBy(source, isUndefined)); + target.scale = targetScale; + target.position = targetPosition; + if (convertAlpha) { + const sourceAlpha = source.alpha; + if (sourceAlpha !== undefined) { + target.alpha = 1; + target.alphaFilterVal = sourceAlpha; + } + } +} + +function toPixiTransformPatch(transform: ITransform): PixiTransformPatch { + const { position, ...rest } = transform; + return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); +} + +export function applyTransformToPixiContainer( + container: WebGALPixiContainer | null | undefined, + transform?: ITransform, +) { + if (!container) return; + assignPixiTransform(container, toPixiTransformPatch(transform ?? baseTransform)); +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index 12786e9ba..4046064bc 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -1,19 +1,30 @@ -import { baseTransform } from '@/Core/Modules/stage/stageInterface'; import type { IEffect, IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; import type { IResolvedStageCommitOptions } from '@/Core/Modules/stage/stageStateManager'; import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; import { WebGAL } from '@/Core/WebGAL'; -import PixiStage from '@/Core/controller/stage/pixi/PixiController'; import type { IStageObject } from '@/Core/controller/stage/pixi/PixiController'; import { getEnterExitAnimation } from '@/Core/Modules/animationFunctions'; import { logger } from '@/Core/util/logger'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; -import { isUndefined, omitBy } from 'lodash'; +import { applyTransformToPixiContainer } from '@/Core/controller/stage/pixi/stageEffectTransform'; + +interface ISyncFigureSlotPayload { + key: string; + sourceUrl: string; + position: 'left' | 'center' | 'right'; + stageState: IStageState; + skipAnimation: boolean; +} + +interface IRemoveFigOptions { + effects: IEffect[]; + skipAnimation: boolean; +} export function syncPixiStageState(stageState: IStageState, options: IResolvedStageCommitOptions) { if (options.syncPixiStage) { - syncBg(stageState); - syncFigures(stageState); + syncBg(stageState, options.skipAnimation); + syncFigures(stageState, options.skipAnimation); syncLive2d(stageState); syncFigureMetaData(stageState); } @@ -31,16 +42,26 @@ export function applyStageEffects(effects: IEffect[]) { const key = stageObj.key; if (lockedStageTargets.includes(key)) continue; const effect = effects.find((effect) => effect.target === key); - const targetPixiContainer = pixiStage.getStageObjByKey(key); - const container = targetPixiContainer?.pixiContainer; + const container = stageObj.pixiContainer; if (!container) continue; - // @ts-ignore WebGALPixiContainer exposes transform-like fields. - PixiStage.assignTransform(container, convertTransform(effect?.transform ?? baseTransform)); + applyTransformToPixiContainer(container, effect?.transform); } pixiStage.requestRender(); } -function syncBg(stageState: IStageState) { +export function applyStageEffectToTarget(target: string, transform: ITransform | undefined) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + if (pixiStage.getAllLockedObject().includes(target)) return; + + const container = pixiStage.getStageObjByKey(target)?.pixiContainer; + if (!container) return; + + applyTransformToPixiContainer(container, transform); + pixiStage.requestRender(); +} + +function syncBg(stageState: IStageState, skipAnimation: boolean) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; const thisBgKey = 'bg-main'; @@ -50,12 +71,12 @@ function syncBg(stageState: IStageState) { if (bgName !== '') { if (currentBg?.sourceUrl === bgName) return; if (currentBg) { - removeBg(currentBg); + removeBg(currentBg, skipAnimation); } addBg(thisBgKey, bgName); logger.debug('重设背景'); const { duration, animation } = getEnterExitAnimation(thisBgKey, 'enter', true); - if (WebGAL.gameplay.skipAnimation) { + if (skipAnimation || WebGAL.gameplay.skipAnimation) { setEbg(bgName, 0); } else { setEbg(bgName, duration); @@ -66,17 +87,23 @@ function syncBg(stageState: IStageState) { } if (!currentBg) return; - const exitDuration = removeBg(currentBg); + const exitDuration = removeBg(currentBg, skipAnimation); setEbg(bgName, exitDuration, 'cubic-bezier(0.5, 0, 0.75, 0)'); } -function syncFigures(stageState: IStageState) { - syncFigureSlot('fig-center', stageState.figName, 'center', stageState); - syncFigureSlot('fig-left', stageState.figNameLeft, 'left', stageState); - syncFigureSlot('fig-right', stageState.figNameRight, 'right', stageState); +function syncFigures(stageState: IStageState, skipAnimation: boolean) { + syncFigureSlot({ key: 'fig-center', sourceUrl: stageState.figName, position: 'center', stageState, skipAnimation }); + syncFigureSlot({ key: 'fig-left', sourceUrl: stageState.figNameLeft, position: 'left', stageState, skipAnimation }); + syncFigureSlot({ + key: 'fig-right', + sourceUrl: stageState.figNameRight, + position: 'right', + stageState, + skipAnimation, + }); for (const fig of stageState.freeFigure) { - syncFigureSlot(fig.key, fig.name, fig.basePosition, stageState); + syncFigureSlot({ key: fig.key, sourceUrl: fig.name, position: fig.basePosition, stageState, skipAnimation }); } const currentFigures = WebGAL.gameplay.pixiStage?.getFigureObjects(); @@ -92,12 +119,12 @@ function syncFigures(stageState: IStageState) { continue; } if (!freeFigureKeys.has(existFigure.key)) { - removeFig(existFigure, `${existFigure.key}-softin`, stageState.effects); + removeFig(existFigure, `${existFigure.key}-softin`, { effects: stageState.effects, skipAnimation }); } } } -function syncFigureSlot(key: string, sourceUrl: string, position: 'left' | 'center' | 'right', stageState: IStageState) { +function syncFigureSlot({ key, sourceUrl, position, stageState, skipAnimation }: ISyncFigureSlotPayload) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; const softInAniKey = `${key}-softin`; @@ -106,12 +133,12 @@ function syncFigureSlot(key: string, sourceUrl: string, position: 'left' | 'cent if (sourceUrl !== '') { if (currentFigure?.sourceUrl === sourceUrl) return; if (currentFigure) { - removeFig(currentFigure, softInAniKey, stageState.effects); + removeFig(currentFigure, softInAniKey, { effects: stageState.effects, skipAnimation }); } addFigure(key, sourceUrl, position); logger.debug(`${key} 立绘已重设`); const { duration, animation } = getEnterExitAnimation(key, 'enter'); - if (!WebGAL.gameplay.skipAnimation) { + if (!skipAnimation && !WebGAL.gameplay.skipAnimation) { pixiStage.registerPresetAnimation(animation, softInAniKey, key, stageState.effects); setTimeout(() => pixiStage.removeAnimationWithSetEffects(softInAniKey), duration); } @@ -119,7 +146,7 @@ function syncFigureSlot(key: string, sourceUrl: string, position: 'left' | 'cent } if (currentFigure) { - removeFig(currentFigure, softInAniKey, stageState.effects); + removeFig(currentFigure, softInAniKey, { effects: stageState.effects, skipAnimation }); } } @@ -159,11 +186,11 @@ function syncFigureMetaData(stageState: IStageState) { }); } -function removeBg(bgObject: IStageObject): number { +function removeBg(bgObject: IStageObject, skipAnimation: boolean): number { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return DEFAULT_BG_OUT_DURATION; pixiStage.removeAnimationWithSetEffects('bg-main-softin'); - if (WebGAL.gameplay.skipAnimation) { + if (skipAnimation || WebGAL.gameplay.skipAnimation) { pixiStage.removeStageObjectByKey(bgObject.key); return 0; } @@ -181,11 +208,11 @@ function removeBg(bgObject: IStageObject): number { return duration; } -function removeFig(figObj: IStageObject, enterTikerKey: string, effects: IEffect[]) { +function removeFig(figObj: IStageObject, enterTikerKey: string, options: IRemoveFigOptions) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; pixiStage.removeAnimationWithSetEffects(enterTikerKey); - if (WebGAL.gameplay.skipAnimation) { + if (options.skipAnimation || WebGAL.gameplay.skipAnimation) { logger.debug('快速模式,立刻关闭立绘'); pixiStage.removeStageObjectByKey(figObj.key); return; @@ -197,7 +224,7 @@ function removeFig(figObj: IStageObject, enterTikerKey: string, effects: IEffect pixiStage.removeStageObjectByKey(oldFigKey); const leaveKey = figKey + '-softoff'; const { duration, animation } = getEnterExitAnimation(figLeaveAniKey, 'exit', false, figKey); - pixiStage.registerPresetAnimation(animation, leaveKey, figKey, effects); + pixiStage.registerPresetAnimation(animation, leaveKey, figKey, options.effects); setTimeout(() => { pixiStage.removeAnimation(leaveKey); pixiStage.removeStageObjectByKey(figKey); @@ -230,11 +257,3 @@ function addFigure(key: string, url: string, position: 'left' | 'center' | 'righ pixiStage.addFigure(key, url, position); } } - -function convertTransform(transform: ITransform | undefined) { - if (!transform) { - return {}; - } - const { position, ...rest } = transform; - return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); -} diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index 7b9af34c9..fc62f39d3 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -3,7 +3,12 @@ import { WebGAL } from '@/Core/WebGAL'; import { initState, stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; -export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { +export interface ResetStageOptions { + commitStageState?: boolean; +} + +export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true, options: ResetStageOptions = {}) => { + const { commitStageState = true } = options; /** * 清空运行时 */ @@ -24,8 +29,16 @@ export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { // 清空舞台状态表 const initSceneDataCopy = cloneDeep(initState); const currentVars = stageStateManager.getCalculationStageState().GameVar; - stageStateManager.resetAllStageState(initSceneDataCopy); + if (commitStageState) { + stageStateManager.resetAllStageState(initSceneDataCopy, { skipAnimation: true }); + } else { + stageStateManager.resetCalculationStageState(initSceneDataCopy); + } if (!resetSceneAndVar) { - stageStateManager.setStageAndCommit('GameVar', currentVars); + if (commitStageState) { + stageStateManager.setStageAndCommit('GameVar', currentVars); + } else { + stageStateManager.setStage('GameVar', currentVars); + } } }; diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 4672ca5d0..2fd69d8ff 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -14,7 +14,7 @@ import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 恢复演出 */ -export const restorePerform = () => { +export const restorePerform = (skipAnimation = false) => { const stageState = stageStateManager.getCalculationStageState(); const performToRestore = cloneDeep(stageState.PerformList); // 清除状态表中演出序列 @@ -27,7 +27,7 @@ export const restorePerform = () => { } finally { WebGAL.gameplay.performController.endCollectingPerforms(); } - stageStateManager.commit({ applyPixiEffects: false }); + stageStateManager.commit({ applyPixiEffects: false, skipAnimation }); WebGAL.gameplay.performController.commitPendingPerforms(); stageStateManager.applyCommittedPixiEffects(); }; diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index f94290989..dd0900b15 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -61,7 +61,7 @@ export function loadGameFromStageData(stageData: ISaveData) { stageStateManager.replaceCalculationStageState(newStageState); // 恢复演出 - setTimeout(restorePerform, 0); + setTimeout(() => restorePerform(true), 0); // restorePerform(); dispatch(setVisibility({ component: 'showTitle', visibility: false })); @@ -69,5 +69,5 @@ export function loadGameFromStageData(stageData: ISaveData) { /** * 恢复模糊背景 */ - setEbg(newStageState.bgName); + setEbg(newStageState.bgName, 0); } diff --git a/packages/webgal/src/Core/gameScripts/bgm.ts b/packages/webgal/src/Core/gameScripts/bgm.ts index 509e71a59..c6339b0e4 100644 --- a/packages/webgal/src/Core/gameScripts/bgm.ts +++ b/packages/webgal/src/Core/gameScripts/bgm.ts @@ -6,13 +6,15 @@ import { webgalStore } from '@/store/store'; import { unlockBgmInUserData } from '@/store/userDataReducer'; import localforage from 'localforage'; import { WebGAL } from '../WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { WEBGAL_NONE } from '@/Core/constants'; /** * 播放一段bgm * @param sentence */ export const bgm = (sentence: ISentence): IPerform => { - let url: string = sentence.content; // 获取bgm的url + let url: string = sentence.content === WEBGAL_NONE ? '' : sentence.content; // 获取bgm的url const name = getStringArgByKey(sentence, 'unlockname') ?? ''; const series = getStringArgByKey(sentence, 'series') ?? 'default'; let enter = getNumberArgByKey(sentence, 'enter') ?? 0; // 获取bgm的淡入时间 @@ -26,7 +28,11 @@ export const bgm = (sentence: ISentence): IPerform => { localforage.setItem(WebGAL.gameKey, userDataState).then(() => {}); } - playBgm(url, enter, volume); + if (url === '' && enter === 0) { + stageStateManager.setStage('bgm', { src: '', enter, volume }); + } else { + playBgm(url, enter, volume); + } return createNonePerform({ isHoldOn: true }); }; diff --git a/packages/webgal/src/Core/gameScripts/changeBg/index.ts b/packages/webgal/src/Core/gameScripts/changeBg/index.ts index f65d6d350..ded9f103c 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/index.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/index.ts @@ -15,6 +15,7 @@ import { WebGAL } from '@/Core/WebGAL'; import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; import localforage from 'localforage'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { parseTransformFrame } from '../parseTransformFrame'; /** * 进行背景图片的切换 @@ -56,29 +57,15 @@ export const changeBg = (sentence: ISentence): IPerform => { // 处理 transform 和 默认 transform let animationObj: AnimationFrame[]; - if (transformString) { - try { - const frame = JSON.parse(transformString.toString()) as AnimationFrame; - animationObj = generateTransformAnimationObj('bg-main', frame, enterDuration, ease, !ignoreDefault); - // 因为是切换,必须把一开始的 alpha 改为 0 - animationObj[0].alpha = 0; - const animationName = (Math.random() * 10).toString(16); - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); - duration = getAnimateDuration(animationName); - stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }); - } catch (e) { - // 解析都错误了,歇逼吧 - applyDefaultTransform(); - } + const frame = transformString ? parseTransformFrame(transformString) : null; + if (frame) { + applyTransform(frame); } else { applyDefaultTransform(); } - function applyDefaultTransform() { - // 应用默认的 - const frame = {}; - animationObj = generateTransformAnimationObj('bg-main', frame as AnimationFrame, duration, ease, !ignoreDefault); + function applyTransform(frame: AnimationFrame) { + animationObj = generateTransformAnimationObj('bg-main', frame, enterDuration, ease, !ignoreDefault); // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); @@ -87,6 +74,12 @@ export const changeBg = (sentence: ISentence): IPerform => { duration = getAnimateDuration(animationName); stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }); } + + function applyDefaultTransform() { + // 应用默认的 + const frame = {}; + applyTransform(frame as AnimationFrame); + } stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationIgnoreDefault', diff --git a/packages/webgal/src/Core/gameScripts/changeFigure.ts b/packages/webgal/src/Core/gameScripts/changeFigure.ts index b6b64e6e4..54c07859d 100644 --- a/packages/webgal/src/Core/gameScripts/changeFigure.ts +++ b/packages/webgal/src/Core/gameScripts/changeFigure.ts @@ -12,6 +12,7 @@ import { WebGAL } from '@/Core/WebGAL'; import { baseBlinkParam, baseFocusParam, BlinkParam, FocusParam } from '@/Core/live2DCore'; import { DEFAULT_FIG_IN_DURATION, DEFAULT_FIG_OUT_DURATION, WEBGAL_NONE } from '../constants'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { parseTransformFrame } from './parseTransformFrame'; /** * 更改立绘 * @param sentence 语句 @@ -161,30 +162,15 @@ export function changeFigure(sentence: ISentence): IPerform { } // 处理 transform 和 默认 transform let animationObj: AnimationFrame[]; - if (transformString) { - console.log(transformString); - try { - const frame = JSON.parse(transformString) as AnimationFrame; - animationObj = generateTransformAnimationObj(key, frame, duration, ease, !ignoreDefault); - // 因为是切换,必须把一开始的 alpha 改为 0 - animationObj[0].alpha = 0; - const animationName = (Math.random() * 10).toString(16); - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); - duration = getAnimateDuration(animationName); - stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }); - } catch (e) { - // 解析都错误了,歇逼吧 - applyDefaultTransform(); - } + const frame = transformString ? parseTransformFrame(transformString) : null; + if (frame) { + applyTransform(frame); } else { applyDefaultTransform(); } - function applyDefaultTransform() { - // 应用默认的 - const frame = {}; - animationObj = generateTransformAnimationObj(key, frame as AnimationFrame, duration, ease, !ignoreDefault); + function applyTransform(frame: AnimationFrame) { + animationObj = generateTransformAnimationObj(key, frame, duration, ease, !ignoreDefault); // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); @@ -193,6 +179,12 @@ export function changeFigure(sentence: ISentence): IPerform { duration = getAnimateDuration(animationName); stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }); } + + function applyDefaultTransform() { + // 应用默认的 + const frame = {}; + applyTransform(frame as AnimationFrame); + } stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationIgnoreDefault', diff --git a/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx b/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx index c58780518..2709364a3 100644 --- a/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx +++ b/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx @@ -8,7 +8,7 @@ import styles from './getUserInput.module.scss'; import { useSEByWebgalStore } from '@/hooks/useSoundEffect'; import { WebGAL } from '@/Core/WebGAL'; import { getStringArgByKey } from '@/Core/util/getSentenceArg'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { getCurrentFontFamily } from '@/hooks/useFontFamily'; import { logger } from '@/Core/util/logger'; import { tryToRegex } from '@/Core/util/global'; @@ -75,7 +75,7 @@ export const getUserInput = (sentence: ISentence): IPerform => { } playSeClick(); WebGAL.gameplay.performController.unmountPerform('userInput'); - nextSentence(); + continueSentence(); }} className={styles.button} > diff --git a/packages/webgal/src/Core/gameScripts/label/jmp.ts b/packages/webgal/src/Core/gameScripts/label/jmp.ts index 8704b727d..53bd56a73 100644 --- a/packages/webgal/src/Core/gameScripts/label/jmp.ts +++ b/packages/webgal/src/Core/gameScripts/label/jmp.ts @@ -1,9 +1,9 @@ -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { jumpToLabel } from '@/Core/gameScripts/label/jumpToLabel'; export const jmp = (labelName: string, autoNext = true) => { const isJumped = jumpToLabel(labelName); if (isJumped && autoNext) { - setTimeout(nextSentence, 1); + setTimeout(continueSentence, 1); } }; diff --git a/packages/webgal/src/Core/gameScripts/parseTransformFrame.ts b/packages/webgal/src/Core/gameScripts/parseTransformFrame.ts new file mode 100644 index 000000000..0dbcdda52 --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/parseTransformFrame.ts @@ -0,0 +1,21 @@ +import { AnimationFrame } from '@/Core/Modules/animations'; + +export function parseTransformFrame(raw: string): AnimationFrame | null { + const source = raw.trim(); + if (source === '') return null; + + try { + const parsed: unknown = JSON.parse(source); + return isTransformFrame(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function parseSetTransformFrame(raw: string): AnimationFrame | null { + return parseTransformFrame(raw.trim() === '' ? '{}' : raw); +} + +function isTransformFrame(value: unknown): value is AnimationFrame { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/webgal/src/Core/gameScripts/playVideo.tsx b/packages/webgal/src/Core/gameScripts/playVideo.tsx index 3c0c21610..bb93f1d0a 100644 --- a/packages/webgal/src/Core/gameScripts/playVideo.tsx +++ b/packages/webgal/src/Core/gameScripts/playVideo.tsx @@ -37,6 +37,7 @@ export const playVideo = (sentence: ISentence): IPerform => { ReactDOM.render(
, document.getElementById('videoContainer')); }; const endPerform = () => { + if (isOver) return; isOver = true; WebGAL.gameplay.performController.unmountPerform(performInitName); }; @@ -63,8 +64,6 @@ export const playVideo = (sentence: ISentence): IPerform => { if (VocalControl !== null) { VocalControl.currentTime = 0; VocalControl.volume = bgmVol; - // 双击可跳过视频 - WebGAL.events.fullscreenDbClick.on(skipVideo); /** * 把bgm和语音的音量设为0 */ @@ -77,11 +76,12 @@ export const playVideo = (sentence: ISentence): IPerform => { vocalElement.volume = '0'; } - VocalControl?.play().catch(() => {}); - - VocalControl.onended = () => { - endPerform(); - }; + VocalControl.addEventListener('error', () => endPerform()); + VocalControl.addEventListener('ended', () => endPerform()); + VocalControl.play().catch(() => endPerform()); + if (!blockingNextFlag) { + WebGAL.events.fullscreenDbClick.on(skipVideo); + } } }, 1); }, diff --git a/packages/webgal/src/Core/gameScripts/say.ts b/packages/webgal/src/Core/gameScripts/say.ts index 9e2fd94bf..0319e8b68 100644 --- a/packages/webgal/src/Core/gameScripts/say.ts +++ b/packages/webgal/src/Core/gameScripts/say.ts @@ -42,6 +42,7 @@ export const say = (sentence: ISentence): IPerform => { // 设置文本显示 stageStateManager.setStage('showText', dialogToShow); + WebGAL.flowchartManager.requestUnlockCurrentScene(); stageStateManager.setStage('vocal', ''); // 清除语音 diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index f306c34ea..ff72934fd 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -9,6 +9,7 @@ import { WebGAL } from '@/Core/WebGAL'; import { applyAnimationEndState, getAnimateDuration } from '../Modules/animationFunctions'; import { v4 as uuid } from 'uuid'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; +import { parseSetTransformFrame } from './parseTransformFrame'; /** * 设置变换 * @param sentence @@ -31,12 +32,10 @@ export const setTransform = (sentence: ISentence): IPerform => { if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); - try { - const frame = JSON.parse(animationString) as AnimationFrame; + const frame = parseSetTransformFrame(animationString); + if (frame) { animationObj = generateTransformAnimationObj(target, frame, duration, ease, writeFullEffect); - console.log('animationObj:', animationObj); - } catch (e) { - // 解析都错误了,歇逼吧 + } else { animationObj = []; } diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index f6373f6c9..80af20d46 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -51,6 +51,7 @@ export const initializeScript = (): void => { const initialSceneReady = sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 + WebGAL.flowchartManager.waitForCurrentSceneDialog(); }); // 获取游戏信息 infoFetcher('./game/config.txt'); diff --git a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts index 7a239ff32..299e34218 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts @@ -72,6 +72,7 @@ export const infoFetcher = (url: string): Promise => { }); dispatch(setUserData({ key: 'gameConfigInit', value: gameConfigInit })); + await WebGAL.flowchartManager.init(WebGAL.gameKey, gameConfigInit.Enable_flowchart === true); // @ts-expect-error renderPromiseResolve is a global variable window.renderPromiseResolve(); setStorage(); diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index d9fc83b00..ea6e7e747 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -1,13 +1,23 @@ import { createEventEnvelope, createRequestEnvelope, + createRequestErrorEnvelope, createResponseEnvelope, EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + isAnyProtocolEnvelope, + isPreviewQueryType, isPreviewRequestEnvelope, - isProtocolEnvelope, - PreviewRequestPayloadByType, +} from '@/types/editorPreviewProtocol'; +import type { + AnyProtocolEnvelope, + FastPreviewTimeoutPayload, + PreviewCommandPayloadByType, + PreviewCommandResponsePayloadByType, + PreviewCommandType, + PreviewQueryType, + PreviewRequestErrorCode, PreviewRequestType, - PreviewResponsePayloadByType, + RequestEnvelopeByType, RunSceneContentPayload, RunSnippetPayload, SetComponentVisibilityPayload, @@ -16,20 +26,20 @@ import { SetTextReadModePayload, StageSnapshotUpdatedPayload, SyncScenePayload, - FastPreviewTimeoutPayload, -} from '../../../types/editorPreviewProtocol'; +} from '@/types/editorPreviewProtocol'; import { webgalStore } from '@/store/store'; import { setFontOptimization, setVisibility } from '@/store/GUIReducer'; import { WebGAL } from '@/Core/WebGAL'; 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 { continueSentence } from '@/Core/controller/gamePlay/nextSentence'; import { resetStage } from '@/Core/controller/stage/resetStage'; import { logger } from '@/Core/util/logger'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { baseTransform } from '@/Core/Modules/stage/stageInterface'; import type { IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; +import { applyStageEffectToTarget } from '@/Core/controller/stage/pixi/syncPixiStageState'; import { mergeSetEffectPreviewTransform } from './previewSetEffectTransform'; import { requestEmbeddedLaunchId } from './runtime/embeddedPreviewBootstrap'; import { @@ -40,6 +50,12 @@ import { import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneCommand'; import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; +import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; +import { + cloneBaseTransform, + createTargetTransformBaselineManager, + isTargetTransformBaselineSyncSettled, +} from './runtime/targetTransformBaseline'; let previewSyncRuntimeStarted = false; type StageStateSnapshot = IStageState; @@ -50,6 +66,16 @@ interface RegisterPreviewLogContext { embeddedLaunchId: string | undefined; } +type PreviewRequestEnvelope = RequestEnvelopeByType; +type PreviewQueryEnvelope = RequestEnvelopeByType; +type RawRequestEnvelope = { + kind: 'request'; + type: string; + requestId: string; +}; + +const UNSUPPORTED_REQUEST_MESSAGE = '当前预览运行时不支持该请求类型'; + export const startPreviewSyncRuntime = () => { if (previewSyncRuntimeStarted) { return; @@ -77,11 +103,30 @@ export const startPreviewSyncRuntime = () => { let lastPublishedSentenceId: number | null = null; let lastPublishedStageState: StageStateSnapshot | null = null; const setEffectBaselines = new Map(); + const targetTransformBaselines = createTargetTransformBaselineManager(); const embeddedLaunchIdPromise = requestEmbeddedLaunchId(); let transport!: PreviewSyncTransport; + let nextSyncSceneRevision = 0; + let activeSyncSceneRevision: number | null = null; const createRequestId = () => `req-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + + const isRawRequestEnvelope = (value: unknown): value is RawRequestEnvelope => + isRecord(value) && + value.kind === 'request' && + typeof value.type === 'string' && + typeof value.requestId === 'string'; + + const sendRequestError = ( + request: Pick, + code: PreviewRequestErrorCode, + message?: string, + ) => { + transport.send(createRequestErrorEnvelope(request.type, request.requestId, message ? { code, message } : { code })); + }; + const resetRegistrationState = () => { registered = false; pendingRegisterRequestId = null; @@ -90,6 +135,31 @@ export const startPreviewSyncRuntime = () => { lastPublishedSentenceId = null; lastPublishedStageState = null; setEffectBaselines.clear(); + activeSyncSceneRevision = null; + WebGAL.gameplay.isFastPreview = false; + targetTransformBaselines.invalidateCurrentRevision(); + }; + + const startSyncSceneTransaction = () => { + nextSyncSceneRevision += 1; + activeSyncSceneRevision = nextSyncSceneRevision; + return activeSyncSceneRevision; + }; + + const isActiveSyncSceneRevision = (revision: number) => activeSyncSceneRevision === revision; + + const finishSyncSceneTransaction = (revision: number) => { + if (!isActiveSyncSceneRevision(revision)) { + return false; + } + + activeSyncSceneRevision = null; + return true; + }; + + const cancelActiveSyncScene = () => { + activeSyncSceneRevision = null; + WebGAL.gameplay.isFastPreview = false; }; const buildStageStateSnapshot = (stageState: StageStateSnapshot): StageSnapshotUpdatedPayload['stageState'] => { @@ -108,6 +178,9 @@ export const startPreviewSyncRuntime = () => { if (!registered) { return; } + if (activeSyncSceneRevision !== null) { + return; + } const sceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; const sentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; @@ -134,6 +207,10 @@ export const startPreviewSyncRuntime = () => { } }; + const publishSettledStageSnapshot = () => { + publishStageSnapshot(true, stageStateManager.getViewStageState()); + }; + const registerPreview = async (socket: PreviewSyncTransportSocket) => { const requestId = createRequestId(); pendingRegisterRequestId = requestId; @@ -157,6 +234,19 @@ export const startPreviewSyncRuntime = () => { ); }; + const finishRegisterPreview = () => { + const registeredPreviewContext = pendingRegisterContext; + if (registeredPreviewContext) { + logger.info('编辑器同步 V1 注册完成', registeredPreviewContext); + } + + pendingRegisterRequestId = null; + pendingRegisterContext = null; + registered = true; + publishReady(); + publishStageSnapshot(true); + }; + const emitFastPreviewTimeout = (payload: FastPreviewTimeoutPayload) => { if (!registered) { return; @@ -165,12 +255,66 @@ export const startPreviewSyncRuntime = () => { }; const handleSyncScene = (payload: SyncScenePayload) => { + const syncSceneRevision = startSyncSceneTransaction(); + const isLatestSyncScene = () => isActiveSyncSceneRevision(syncSceneRevision); setEffectBaselines.clear(); - executePreviewSyncSceneCommand(payload, emitFastPreviewTimeout); + const { transformBaselineRevision } = payload; + if (transformBaselineRevision) { + targetTransformBaselines.acceptRevision(transformBaselineRevision); + } else { + targetTransformBaselines.invalidateCurrentRevision(); + } + + executePreviewSyncSceneCommand(payload, { + onFastPreviewTimeout: emitFastPreviewTimeout, + isLatest: isLatestSyncScene, + onBeforeTargetScriptExecute: () => { + if (!isLatestSyncScene()) { + return; + } + if (!transformBaselineRevision) { + return; + } + + targetTransformBaselines.captureSnapshot( + transformBaselineRevision, + stageStateManager.getCalculationStageState(), + ); + }, + onSettled: (result) => { + if (!finishSyncSceneTransaction(syncSceneRevision)) { + return; + } + + if (result === null) { + if (transformBaselineRevision) { + targetTransformBaselines.failRevision(transformBaselineRevision); + } + return; + } + + if (!transformBaselineRevision) { + publishSettledStageSnapshot(); + return; + } + + const isSyncSettled = isTargetTransformBaselineSyncSettled(result, payload); + if (!isSyncSettled || !targetTransformBaselines.publishCapturedSnapshot(transformBaselineRevision)) { + targetTransformBaselines.failRevision(transformBaselineRevision); + publishSettledStageSnapshot(); + return; + } + + setEffectBaselines.clear(); + publishSettledStageSnapshot(); + }, + }); }; const handleRunSnippet = (payload: RunSnippetPayload) => { + cancelActiveSyncScene(); setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); applyPreviewDebugVariables(payload.debugVariables); const scene = WebgalParser.parse(payload.snippet, 'temp.txt', 'temp.txt'); (scene.sentenceList as unknown as ISentence[]).forEach((sentence) => { @@ -203,18 +347,21 @@ export const startPreviewSyncRuntime = () => { }; const handleRunSceneContent = (payload: RunSceneContentPayload) => { + cancelActiveSyncScene(); setEffectBaselines.clear(); + targetTransformBaselines.invalidateCurrentRevision(); resetStage(true); applyPreviewDebugVariables(payload.debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(payload.sceneContent, 'temp', './temp.txt'); applyComponentVisibility({ showTitle: false, showMenuPanel: false, + showFlowchart: false, isEnterGame: true, showPanicOverlay: false, }); setTimeout(() => { - nextSentence(); + continueSentence(); }, 100); }; @@ -236,25 +383,29 @@ export const startPreviewSyncRuntime = () => { return cachedBaseline; } - const currentTransform = stageStateManager - .getCalculationStageState() - .effects.find((effect) => effect.target === target)?.transform; - const baseline = mergeSetEffectPreviewTransform(baseTransform, currentTransform); + const baselineOverride = targetTransformBaselines.getReadyTransformBaselineOverride(target); + const baseline = mergeSetEffectPreviewTransform(baseTransform, baselineOverride); setEffectBaselines.set(target, baseline); return baseline; }; const handleSetEffect = (payload: SetEffectPayload) => { - const newTransform = mergeSetEffectPreviewTransform(getSetEffectBaseline(payload.target), payload.transform); + const baseline = getSetEffectBaseline(payload.target); + const newTransform = mergeSetEffectPreviewTransform(baseline, payload.transform); WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(payload.target); + if (payload.phase === 'preview') { + applyStageEffectToTarget(payload.target, newTransform); + return; + } + stageStateManager.updateEffectAndCommit({ target: payload.target, transform: newTransform, }); }; - const previewRequestHandlers: { - [K in PreviewRequestType]: (payload: PreviewRequestPayloadByType[K]) => PreviewResponsePayloadByType[K]; + const previewCommandHandlers: { + [K in PreviewCommandType]: (payload: PreviewCommandPayloadByType[K]) => PreviewCommandResponsePayloadByType[K]; } = { 'preview.command.sync-scene': (payload: SyncScenePayload) => { handleSyncScene(payload); @@ -290,73 +441,122 @@ export const startPreviewSyncRuntime = () => { }, }; - const handlePreviewRequest = ( + const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { + return isPreviewQueryType(envelope.type); + }; + + const handlePreviewQuery = (envelope: PreviewQueryEnvelope) => { + switch (envelope.type) { + case 'preview.query.reference-box': + void handleReferenceBoxQuery(envelope, WebGAL.gameplay.pixiStage) + .then((response) => { + transport.send(response); + }) + .catch((error) => { + logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + sendRequestError(envelope, 'internal-error', '预览运行时无法安全完成该请求'); + }); + return; + case 'preview.query.base-transform': + transport.send( + createResponseEnvelope('preview.query.base-transform', envelope.requestId, { + baseTransform: cloneBaseTransform(), + }), + ); + return; + case 'preview.query.transform-baseline': + transport.send( + createResponseEnvelope( + 'preview.query.transform-baseline', + envelope.requestId, + targetTransformBaselines.queryTransformBaseline( + envelope.payload.target, + envelope.payload.transformBaselineRevision, + ), + ), + ); + return; + } + }; + + const handlePreviewCommand = ( type: TType, - payload: PreviewRequestPayloadByType[TType], - ): PreviewResponsePayloadByType[TType] => { - const handler = previewRequestHandlers[type] as ( - nextPayload: PreviewRequestPayloadByType[TType], - ) => PreviewResponsePayloadByType[TType]; + payload: PreviewCommandPayloadByType[TType], + ): PreviewCommandResponsePayloadByType[TType] => { + const handler = previewCommandHandlers[type] as ( + nextPayload: PreviewCommandPayloadByType[TType], + ) => PreviewCommandResponsePayloadByType[TType]; return handler(payload); }; - transport = createPreviewSyncTransport({ - url: wsUrl, - subprotocol: EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, - onConnecting: resetRegistrationState, - onOpen: registerPreview, - onMessage: (rawData) => { - try { - const envelope = JSON.parse(String(rawData)) as unknown; - if (!isProtocolEnvelope(envelope)) { - logger.warn('收到无法识别的编辑器同步 V1 消息'); - return; - } + const respondToPreviewRequest = (envelope: PreviewRequestEnvelope) => { + if (isPreviewQueryEnvelope(envelope)) { + handlePreviewQuery(envelope); + return; + } - if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { - if (pendingRegisterRequestId === null || envelope.requestId !== pendingRegisterRequestId) { - return; - } + const responsePayload = handlePreviewCommand(envelope.type, envelope.payload); + transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); + }; - if (pendingRegisterContext) { - logger.info('编辑器同步 V1 注册完成', pendingRegisterContext); - } - pendingRegisterRequestId = null; - pendingRegisterContext = null; - registered = true; - publishReady(); - publishStageSnapshot(true); - return; - } + const handleProtocolEnvelope = (envelope: AnyProtocolEnvelope) => { + if (envelope.kind === 'response' && envelope.type === 'session.register-preview') { + if (pendingRegisterRequestId !== null && envelope.requestId === pendingRegisterRequestId) { + finishRegisterPreview(); + } + return; + } - if (!registered) { - if (envelope.kind === 'request') { - logger.warn(`收到注册完成前的编辑器同步 V1 请求:${envelope.type}`); - } - return; - } + if (!registered) { + if (envelope.kind === 'request') { + logger.warn(`收到注册完成前的编辑器同步 V1 请求:${envelope.type}`); + } + return; + } - if (!isPreviewRequestEnvelope(envelope)) { - if (envelope.kind === 'request') { - logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); - } - return; - } + if (!isPreviewRequestEnvelope(envelope)) { + if (envelope.kind === 'request') { + logger.warn(`收到未支持的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'unsupported-request-type', UNSUPPORTED_REQUEST_MESSAGE); + } + return; + } + + try { + respondToPreviewRequest(envelope); + } catch (error) { + logger.error(`执行编辑器同步 V1 请求失败:${envelope.type}`, error); + sendRequestError(envelope, 'internal-error', '预览运行时无法安全完成该请求'); + } + }; - let responsePayload: PreviewResponsePayloadByType[typeof envelope.type]; - try { - responsePayload = handlePreviewRequest(envelope.type, envelope.payload); - } catch (error) { - logger.error(`执行编辑器同步 V1 命令失败:${envelope.type}`, error); + const handleRawMessage = (rawData: unknown) => { + try { + const envelope = JSON.parse(String(rawData)) as unknown; + if (!isAnyProtocolEnvelope(envelope)) { + if (isRawRequestEnvelope(envelope)) { + logger.warn(`收到非法的编辑器同步 V1 请求:${envelope.type}`); + sendRequestError(envelope, 'bad-request', '请求 envelope 格式不合法'); return; } - transport.send(createResponseEnvelope(envelope.type, envelope.requestId, responsePayload)); - } catch (error) { - logger.error('解析编辑器同步 V1 消息失败', error); + logger.warn('收到无法识别的编辑器同步 V1 消息'); + return; } - }, + + handleProtocolEnvelope(envelope); + } catch (error) { + logger.error('解析编辑器同步 V1 消息失败', error); + } + }; + + transport = createPreviewSyncTransport({ + url: wsUrl, + subprotocol: EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL, + onConnecting: resetRegistrationState, + onOpen: registerPreview, + onMessage: handleRawMessage, onClose: resetRegistrationState, logInfo: (message) => logger.info(message), logError: (message, error) => logger.error(message, error), diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts new file mode 100644 index 000000000..5e0d74308 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/handlers/referenceBoxQueryHandler.ts @@ -0,0 +1,36 @@ +import { createResponseEnvelope } from '@/types/editorPreviewProtocol'; +import type { + ReferenceBoxQueryResultPayload, + RequestEnvelopeByType, + ResponseEnvelopeByType, +} from '@/types/editorPreviewProtocol'; + +interface ReferenceBoxQueryStage { + queryTargetReferenceBox(target: string): ReferenceBoxQueryResultPayload; + waitForTargetReferenceBox(target: string, timeoutMs: number): Promise; +} + +const REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS = 300; + +export async function handleReferenceBoxQuery( + request: RequestEnvelopeByType<'preview.query.reference-box'>, + pixiStage: ReferenceBoxQueryStage | null | undefined, +): Promise> { + const { target } = request.payload; + + if (!pixiStage) { + return createResponseEnvelope('preview.query.reference-box', request.requestId, { + target, + status: 'unsupported', + reason: 'Pixi stage 不可用', + }); + } + + let result = pixiStage.queryTargetReferenceBox(target); + if (result.status === 'loading') { + await pixiStage.waitForTargetReferenceBox(target, REFERENCE_BOX_GEOMETRY_READY_TIMEOUT_MS); + result = pixiStage.queryTargetReferenceBox(target); + } + + return createResponseEnvelope('preview.query.reference-box', request.requestId, result); +} diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts index aa8862705..0f42cdd8a 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewDebugVariables.ts @@ -3,7 +3,7 @@ import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import type { IGameVar } from '@/Core/Modules/stage/stageInterface'; import { webgalStore } from '@/store/store'; import { setUserData } from '@/store/userDataReducer'; -import type { DebugVariablePayload } from '../../../../types/editorPreviewProtocol'; +import type { DebugVariable } from '@/types/editorPreviewProtocol'; let debugStageVarKeys = new Set(); let debugGlobalBackup: { globalGameVar: IGameVar; scriptManagedGlobalVar: string[] } | null = null; @@ -23,7 +23,7 @@ function clearPreviewDebugVariables() { debugGlobalBackup = null; } -export function applyPreviewDebugVariables(debugVariables: DebugVariablePayload[] = []) { +export function applyPreviewDebugVariables(debugVariables: DebugVariable[] = []) { clearPreviewDebugVariables(); if (debugVariables.some((item) => item.isGlobal)) { const userData = webgalStore.getState().userData; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts index 36f8ae0e5..46dc17248 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSyncSceneCommand.ts @@ -8,7 +8,7 @@ import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; import { sceneParser } from '@/Core/parser/sceneParser'; import { logger } from '@/Core/util/logger'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; -import { FastPreviewTimeoutPayload, SyncScenePayload } from '../../../../types/editorPreviewProtocol'; +import type { FastPreviewTimeoutPayload, SyncScenePayload, SyncSceneSettleMode } from '@/types/editorPreviewProtocol'; import { applyPreviewDebugVariables } from './previewDebugVariables'; export const FAST_PREVIEW_MAX_DURATION_MS = 500; @@ -16,10 +16,32 @@ const FAST_PREVIEW_TIMEOUT_CHECK_INTERVAL = 100; export type FastPreviewTimeoutEmitter = (payload: FastPreviewTimeoutPayload) => void; +export interface FastPreviewResult { + sceneName: string; + sentenceId: number; + isTimedOut: boolean; + stopReason: FastPreviewStopReason; +} + +export type FastPreviewStopReason = 'target-reached' | 'timeout' | 'state-calculation-blocked' | 'no-progress'; + +export interface PreviewSyncSceneCommandCallbacks { + onFastPreviewTimeout?: FastPreviewTimeoutEmitter; + onBeforeTargetScriptExecute?: () => void; + onSettled?: (result: FastPreviewResult | null) => void; + isLatest?: () => boolean; +} + +interface RunFastPreviewOptions { + onBeforeTargetScriptExecute?: () => void; + isLatest?: () => boolean; +} + export function executePreviewSyncSceneCommand( - { sceneName, sentenceId, debugVariables }: SyncScenePayload, - onFastPreviewTimeout?: FastPreviewTimeoutEmitter, + { sceneName, sentenceId, debugVariables, settleMode = 'normal' }: SyncScenePayload, + callbacks: PreviewSyncSceneCommandCallbacks = {}, ): void { + const isLatest = () => callbacks.isLatest?.() ?? true; logger.warn('正在跳转到' + sceneName + ':' + sentenceId); WebGAL.gameplay.isFastPreview = false; @@ -38,16 +60,43 @@ export function executePreviewSyncSceneCommand( sceneFetcher(sceneUrl) .then((rawScene) => { - resetStage(true); + if (!isLatest()) { + return; + } + + resetStage(true, true, { commitStageState: false }); applyPreviewDebugVariables(debugVariables); WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - void runFastPreview(sentenceId, currentSceneName, onFastPreviewTimeout); + void runFastPreview(sentenceId, currentSceneName, callbacks.onFastPreviewTimeout, settleMode, { + onBeforeTargetScriptExecute: callbacks.onBeforeTargetScriptExecute, + isLatest: callbacks.isLatest, + }) + .then((result) => { + if (!isLatest()) { + return; + } + + callbacks.onSettled?.(result); + }) + .catch((error) => { + if (!isLatest()) { + return; + } + + logger.error('实时预览跳转错误', error); + callbacks.onSettled?.(null); + }); }) .catch((error) => { + if (!isLatest()) { + return; + } + stopFast(); WebGAL.gameplay.isFastPreview = false; logger.error('实时预览跳转错误', error); + callbacks.onSettled?.(null); }); } @@ -55,29 +104,68 @@ export async function runFastPreview( sentenceId: number, currentSceneName: string, onFastPreviewTimeout?: FastPreviewTimeoutEmitter, -): Promise { + settleMode: SyncSceneSettleMode = 'normal', + options: RunFastPreviewOptions = {}, +): Promise { + const isLatest = () => options.isLatest?.() ?? true; const fastPreviewStartTime = performance.now(); const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; stopFast(); WebGAL.gameplay.isFastPreview = true; let forwardCount = 0; let isTimedOut = false; + let stopReason: FastPreviewStopReason = 'target-reached'; let timeoutElapsedMs = 0; let suspendedElapsedMs = 0; + let didRunBeforeTargetScriptExecute = false; + + const runBeforeTargetScriptExecute = (sceneName: string, nextSentenceId: number) => { + if ( + settleMode !== 'immediate' || + !options.onBeforeTargetScriptExecute || + didRunBeforeTargetScriptExecute || + sceneName !== currentSceneName || + nextSentenceId !== sentenceId - 1 + ) { + return; + } + + WebGAL.gameplay.performController.discardUncommittedNonHoldPerforms(true); + WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); + options.onBeforeTargetScriptExecute?.(); + didRunBeforeTargetScriptExecute = true; + }; try { while (shouldContinueFastPreview(sentenceId, currentSceneName, baseSceneStackDepth)) { + if (!isLatest()) { + return null; + } + const prevSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; const prevSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - const isForwarded = forward(); + const isForwarded = forward({ + scriptExecution: { + beforeSentenceExecute: ({ sceneName, sentenceId }) => { + // sync-scene 的 sentenceId 是目标语句执行后的停止指针,目标编辑语句是 sentenceId - 1。 + runBeforeTargetScriptExecute(sceneName, sentenceId); + }, + }, + }); forwardCount++; + const postForwardSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; + const postForwardSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; const sceneWriteWaitStart = performance.now(); const awaitedSceneWrite = await waitForPendingSceneWrite(); if (awaitedSceneWrite) { suspendedElapsedMs += performance.now() - sceneWriteWaitStart; } + if (!isLatest()) { + return null; + } if (!isForwarded && !awaitedSceneWrite) { + stopReason = 'no-progress'; break; } @@ -85,6 +173,7 @@ export async function runFastPreview( const elapsedMs = performance.now() - fastPreviewStartTime - suspendedElapsedMs; if (elapsedMs > FAST_PREVIEW_MAX_DURATION_MS) { isTimedOut = true; + stopReason = 'timeout'; timeoutElapsedMs = Math.round(elapsedMs); break; } @@ -92,20 +181,29 @@ export async function runFastPreview( if (WebGAL.gameplay.performController.hasPendingBlockingStateCalculationPerform()) { logger.warn('实时预览在需要外部输入的语句前停止演算'); + stopReason = 'state-calculation-blocked'; break; } - if ( - WebGAL.sceneManager.sceneData.currentSentenceId === prevSentenceId && - WebGAL.sceneManager.sceneData.currentScene.sceneName === prevSceneName && - !awaitedSceneWrite - ) { + if (postForwardSentenceId === prevSentenceId && postForwardSceneName === prevSceneName && !awaitedSceneWrite) { logger.warn('实时预览跳转停止:本次 forward 没有推进语句指针'); + stopReason = 'no-progress'; break; } } } finally { - WebGAL.gameplay.isFastPreview = false; + if (isLatest()) { + WebGAL.gameplay.isFastPreview = false; + } + } + + if (settleMode === 'immediate') { + WebGAL.gameplay.performController.discardUncommittedNonHoldPerforms(true); + WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); + } + + if (!isLatest()) { + return null; } commitForward(); @@ -132,6 +230,12 @@ export async function runFastPreview( } logger.info(`实时预览快进完成:快进 ${forwardedLineCount} 行,用时 ${fastPreviewElapsedMs}ms`); + return { + sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentenceId: WebGAL.sceneManager.sceneData.currentSentenceId, + isTimedOut, + stopReason, + }; } function shouldContinueFastPreview(sentenceId: number, currentSceneName: string, baseSceneStackDepth: number): boolean { diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts new file mode 100644 index 000000000..38729b821 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/targetTransformBaseline.ts @@ -0,0 +1,259 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { STAGE_KEYS } from '@/Core/constants'; +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import type { IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; +import type { TransformBaselineQueryResultPayload } from '@/types/editorPreviewProtocol'; +import type { FastPreviewResult } from './previewSyncSceneCommand'; + +const FIXED_TARGETS = new Set([ + STAGE_KEYS.STAGE_MAIN, + STAGE_KEYS.BGMAIN, + STAGE_KEYS.FIG_C, + STAGE_KEYS.FIG_L, + STAGE_KEYS.FIG_R, +]); + +type BaselineRevisionState = + | { + status: 'none'; + } + | { + status: 'pending'; + revision: string; + snapshot?: TargetTransformBaselineSnapshot; + } + | { + status: 'ready'; + revision: string; + snapshot: TargetTransformBaselineSnapshot; + } + | { + status: 'unavailable'; + revision: string; + }; + +interface TargetTransformBaselineSnapshot { + knownTargets: Set; + transformsByTarget: Map; +} + +interface TargetTransformBaselineSyncTarget { + sceneName: string; + sentenceId: number; +} + +interface TargetTransformBaselineManager { + acceptRevision: (revision: string) => void; + captureSnapshot: (revision: string, stageState: IStageState) => void; + publishCapturedSnapshot: (revision: string) => boolean; + failRevision: (revision: string) => void; + invalidateCurrentRevision: () => void; + getReadyTransformBaselineOverride: (target: string) => ITransform | undefined; + queryTransformBaseline: (target: string, revision: string) => TransformBaselineQueryResultPayload; +} + +export function cloneBaseTransform(): ITransform { + return cloneDeep(baseTransform); +} + +export function isTargetTransformBaselineSyncSettled( + result: FastPreviewResult | null, + target: TargetTransformBaselineSyncTarget, +): boolean { + return ( + result !== null && + !result.isTimedOut && + result.stopReason === 'target-reached' && + result.sceneName === target.sceneName && + result.sentenceId === target.sentenceId + ); +} + +export function createTargetTransformBaselineManager(): TargetTransformBaselineManager { + let revisionState: BaselineRevisionState = { status: 'none' }; + + const isLatestRevision = (revision: string) => { + return revisionState.status !== 'none' && revisionState.revision === revision; + }; + + const isPendingRevision = (revision: string) => { + return revisionState.status === 'pending' && revisionState.revision === revision; + }; + + return { + acceptRevision(revision) { + revisionState = { + status: 'pending', + revision, + }; + }, + + captureSnapshot(revision, stageState) { + if (!isPendingRevision(revision)) { + return; + } + + revisionState = { + status: 'pending', + revision, + snapshot: createSnapshot(stageState), + }; + }, + + publishCapturedSnapshot(revision) { + if (revisionState.status !== 'pending' || revisionState.revision !== revision || !revisionState.snapshot) { + return false; + } + + revisionState = { + status: 'ready', + revision, + snapshot: revisionState.snapshot, + }; + return true; + }, + + failRevision(revision) { + if (!isLatestRevision(revision)) { + return; + } + + revisionState = { + status: 'unavailable', + revision, + }; + }, + + invalidateCurrentRevision() { + if (revisionState.status === 'none') { + return; + } + + revisionState = { + status: 'unavailable', + revision: revisionState.revision, + }; + }, + + getReadyTransformBaselineOverride(target) { + if (revisionState.status !== 'ready') { + return undefined; + } + + return getSnapshotTransformOverride(revisionState.snapshot, target); + }, + + queryTransformBaseline(target, revision) { + if (!isLatestRevision(revision)) { + return { + status: 'unavailable', + }; + } + + if (revisionState.status === 'pending') { + return { + status: 'loading', + }; + } + + if (revisionState.status !== 'ready') { + return { + status: 'unavailable', + }; + } + + return querySnapshot(revisionState.snapshot, target); + }, + }; +} + +function createSnapshot(stageState: IStageState): TargetTransformBaselineSnapshot { + const knownTargets = new Set(FIXED_TARGETS); + stageState.freeFigure.forEach((figure) => { + knownTargets.add(figure.key); + }); + + const transformsByTarget = new Map(); + stageState.effects.forEach((effect) => { + if (!knownTargets.has(effect.target) || !effect.transform) { + return; + } + + transformsByTarget.set(effect.target, cloneDeep(effect.transform)); + }); + + return { + knownTargets, + transformsByTarget, + }; +} + +function querySnapshot(snapshot: TargetTransformBaselineSnapshot, target: string): TransformBaselineQueryResultPayload { + const transform = getSnapshotTransformOverride(snapshot, target); + if (transform === undefined) { + return { + status: 'unavailable', + }; + } + + return { + status: 'ready', + transform, + }; +} + +function getSnapshotTransformOverride(snapshot: TargetTransformBaselineSnapshot, target: string): ITransform | undefined { + if (!snapshot.knownTargets.has(target)) { + return undefined; + } + + const transform = snapshot.transformsByTarget.get(target); + return transform ? createSparseTransformOverride(transform, baseTransform) : {}; +} + +function createSparseTransformOverride(transform: ITransform, base: ITransform): ITransform { + const result: ITransform = {}; + + const position = createVectorOverride(transform.position, base.position); + if (position) { + result.position = position; + } + + const scale = createVectorOverride(transform.scale, base.scale); + if (scale) { + result.scale = scale; + } + + (Object.keys(transform) as Array).forEach((key) => { + if (key === 'position' || key === 'scale') { + return; + } + + const value = transform[key]; + if (value !== undefined && value !== base[key]) { + (result as Record)[key] = value; + } + }); + + return result; +} + +type TransformVector = NonNullable; + +function createVectorOverride( + transformValue: TransformVector | undefined, + baseValue: TransformVector | undefined, +): TransformVector | undefined { + if (!transformValue) { + return undefined; + } + + const vectorOverride: TransformVector = {}; + (Object.keys(transformValue) as Array).forEach((nestedKey) => { + if (transformValue[nestedKey] !== undefined && transformValue[nestedKey] !== baseValue?.[nestedKey]) { + vectorOverride[nestedKey] = transformValue[nestedKey]; + } + }); + + return Object.keys(vectorOverride).length > 0 ? vectorOverride : undefined; +} diff --git a/packages/webgal/src/Core/webgalCore.ts b/packages/webgal/src/Core/webgalCore.ts index 73f37e51e..88320c516 100644 --- a/packages/webgal/src/Core/webgalCore.ts +++ b/packages/webgal/src/Core/webgalCore.ts @@ -9,10 +9,12 @@ import { SteamIntegration } from '@/Core/integration/steamIntegration'; import { WebgalTemplate } from '@/types/template'; import { IWebGALStyleObj } from 'webgal-parser/build/types/styleParser'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { FlowchartManager } from '@/Core/Modules/flowchart'; export class WebgalCore { public sceneManager = new SceneManager(); public backlogManager = new BacklogManager(this.sceneManager); + public flowchartManager = new FlowchartManager(this.sceneManager); public readHistoryManager = new ReadHistoryManager(this.sceneManager); public animationManager = new AnimationManager(); public gameplay = new Gameplay(); diff --git a/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx b/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx index 1a8ec0c64..d0784def7 100644 --- a/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx +++ b/packages/webgal/src/Stage/TextBox/IMSSTextbox.tsx @@ -31,9 +31,6 @@ export default function IMSSTextbox(props: ITextboxProps) { const applyStyle = useApplyStyle('textbox'); const readTextClassName = isRead ? ` ${applyStyle('readText', styles.readText)}` : ''; - const readTextOuterClassName = isRead - ? ` ${applyStyle('readTextOuter', styles.readTextOuter)}` - : ''; const readTextInnerClassName = isRead ? ` ${applyStyle('readTextInner', styles.readTextInner)}` : ''; useEffect(() => { @@ -138,6 +135,8 @@ export default function IMSSTextbox(props: ITextboxProps) { // if (e === '
') { // return
; // } + const outerClassName = applyStyle('outer', styles.outer); + const readTextOuterClassName = isRead ? ` ${applyStyle('readTextOuter', styles.readTextOuter)}` : ''; let delay = allTextIndex * textDelay; allTextIndex++; let prevLength = currentConcatDialogPrev.length; @@ -157,9 +156,7 @@ export default function IMSSTextbox(props: ITextboxProps) { > {e} - - {e} - + {e} {isUseStroke && ( {e} )} @@ -180,9 +177,7 @@ export default function IMSSTextbox(props: ITextboxProps) { > {e} - - {e} - + {e} {isUseStroke && ( {e} )} diff --git a/packages/webgal/src/Stage/TextBox/textbox.module.scss b/packages/webgal/src/Stage/TextBox/textbox.module.scss index 6521bd13b..e2a518b55 100644 --- a/packages/webgal/src/Stage/TextBox/textbox.module.scss +++ b/packages/webgal/src/Stage/TextBox/textbox.module.scss @@ -236,7 +236,6 @@ $height: 330px; } .readText { - --webgal-read-text: 1; } .readTextOuter { @@ -247,5 +246,4 @@ $height: 330px; } .readTextInner { - --webgal-read-text-inner: 1; } diff --git a/packages/webgal/src/UI/Backlog/Backlog.tsx b/packages/webgal/src/UI/Backlog/Backlog.tsx index 878f79f20..6b86c2c22 100644 --- a/packages/webgal/src/UI/Backlog/Backlog.tsx +++ b/packages/webgal/src/UI/Backlog/Backlog.tsx @@ -10,6 +10,7 @@ import useTrans from '@/hooks/useTrans'; import { compileSentence, EnhancedNode } from '@/Stage/TextBox/TextBox'; import useSoundEffect from '@/hooks/useSoundEffect'; import { WebGAL } from '@/Core/WebGAL'; +import { stopAuto } from '@/Core/controller/gamePlay/autoPlay'; export const Backlog = () => { const t = useTrans('gaming.'); @@ -127,6 +128,21 @@ export const Backlog = () => {
{ playSeClick(); + // 暂停所有 backlog 语音(包括当前;后续会重置并播放当前) + document + .querySelectorAll('[id^="backlog_audio_play_element_"]') + .forEach((audio: any) => { + audio.pause(); + audio.currentTime = 0; + }); + // 暂停游戏内正在播放的语音,避免与 backlog 语音混合 + const currentVocal = document.getElementById('currentVocal') as HTMLAudioElement | null; + if (currentVocal) { + currentVocal.pause(); + currentVocal.currentTime = 0; + } + // 卸载 vocal-play perform,避免其 blockingAuto 阻塞自动播放 + WebGAL.gameplay.performController.unmountPerform('vocal-play', true); // 获取到播放 backlog 语音的元素 const backlog_audio_element: any = document.getElementById( 'backlog_audio_play_element_' + indexOfBacklog, @@ -165,6 +181,7 @@ export const Backlog = () => { useEffect(() => { /* 切换为展示历史记录时触发 */ if (GUIStore.showBacklog) { + stopAuto(); // logger.info('展示backlog'); // 立即清除 防止来回滚动时可能导致的错乱 if (timeRef.current) { diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx index 03e3a021e..6ab5b2be4 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx @@ -27,6 +27,7 @@ import { ReplayMusic, Save, SettingTwo, + TreeDiagram, Unlock, } from '@icon-park/react'; import { useTranslation } from 'react-i18next'; @@ -49,6 +50,7 @@ export const BottomControlPanel = () => { } const { isSupported: isFullscreenSupport, isFullScreen, toggle: toggleFullscreen } = useFullScreen(); const GUIStore = useSelector((state: RootState) => state.GUI); + const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); const stageState = useStageState(); const dispatch = useDispatch(); const setComponentVisibility = (component: keyof componentsVisibility, visibility: boolean) => { @@ -143,6 +145,27 @@ export const BottomControlPanel = () => { /> {t('buttons.backlog')} + {enableFlowchart && ( + { + setMenuPanel(MenuPanelTag.Flowchart); + setComponentVisibility('showMenuPanel', true); + playSeClick(); + }} + onMouseEnter={playSeEnter} + > + + {t('buttons.flowchart')} + + )} { const showPanel = useValue(false); const stageState = useStageState(); + const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); const dispatch = useDispatch(); const setComponentVisibility = (component: keyof componentsVisibility, visibility: boolean) => { dispatch(setVisibility({ component, visibility })); @@ -44,6 +45,18 @@ export const BottomControlPanelFilm = () => { > 剧情回想 / BACKLOG + {enableFlowchart && ( + { + setMenuPanel(MenuPanelTag.Flowchart); + setComponentVisibility('showMenuPanel', true); + showPanel.set(!showPanel.value); + }} + > + 流程图 / FLOWCHART + + )} { diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx new file mode 100644 index 000000000..2a2217602 --- /dev/null +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -0,0 +1,438 @@ +import { loadGameFromStageData } from '@/Core/controller/storage/loadGame'; +import { IFlowchart, IFlowchartEdge, IFlowchartNode } from '@/Core/Modules/flowchart'; +import { WebGAL } from '@/Core/WebGAL'; +import useSoundEffect from '@/hooks/useSoundEffect'; +import useTrans from '@/hooks/useTrans'; +import { setVisibility } from '@/store/GUIReducer'; +import { RootState } from '@/store/store'; +import { MouseEvent as ReactMouseEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styles from './flowchart.module.scss'; + +const NODE_MIN_WIDTH = 190; +const NODE_HEIGHT = 78; +const NODE_GAP = 80; +const ROW_GAP = 170; +const MARGIN_X = 90; +const MARGIN_Y = 45; +const CONNECTOR_GAP = 12; + +interface LayoutNode extends IFlowchartNode { + x: number; + y: number; + width: number; +} + +interface LayoutEdge extends IFlowchartEdge { + sourceNode: LayoutNode; + targetNode: LayoutNode; +} + +interface ConnectorSegment { + id: string; + d: string; + unlocked: boolean; + arrow: boolean; + layerY: number; +} + +type LockedNodeVisibility = 'all' | 'node' | 'none'; + +export const Flowchart = () => { + const t = useTrans('gaming.flowchart.'); + const { playSeClick, playSeEnter } = useSoundEffect(); + const dispatch = useDispatch(); + const lockedNodeVisibility = useSelector((state: RootState) => + normalizeLockedNodeVisibility(state.userData.globalGameVar.Flowchart_Locked_Node_Visibility), + ); + const [currentFlowchartId, setCurrentFlowchartId] = useState(''); + const [version, setVersion] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const dragStateRef = useRef({ moved: false, scrollLeft: 0, scrollTop: 0, startX: 0, startY: 0 }); + const blockClickRef = useRef(false); + const flowcharts = WebGAL.flowchartManager.getFlowcharts(); + const currentFlowchart = flowcharts.find((e) => e.id === currentFlowchartId) ?? flowcharts[0]; + const layout = useMemo( + () => layoutFlowchart(currentFlowchart, lockedNodeVisibility), + [currentFlowchart?.id, lockedNodeVisibility, version], + ); + + useEffect(() => { + const update = () => setVersion((v) => v + 1); + window.addEventListener(WebGAL.flowchartManager.getEventName(), update); + return () => window.removeEventListener(WebGAL.flowchartManager.getEventName(), update); + }, []); + + useEffect(() => { + if (!currentFlowchartId && flowcharts[0]) setCurrentFlowchartId(flowcharts[0].id); + }, [flowcharts[0]?.id, currentFlowchartId]); + + const jumpToNode = (node: IFlowchartNode) => { + if (blockClickRef.current) { + blockClickRef.current = false; + return; + } + if (!currentFlowchart || !WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id)) return; + playSeClick(); + WebGAL.flowchartManager.loadSnapshot(currentFlowchart.id, node.id).then((snapshot) => { + if (!snapshot) return; + loadGameFromStageData(snapshot); + dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); + dispatch(setVisibility({ component: 'showTextBox', visibility: true })); + }); + }; + + const dragFlowchart = (event: ReactMouseEvent) => { + if (event.button !== 0) return; + const target = event.currentTarget; + dragStateRef.current = { + moved: false, + scrollLeft: target.scrollLeft, + scrollTop: target.scrollTop, + startX: event.clientX, + startY: event.clientY, + }; + const onMouseMove = (moveEvent: MouseEvent) => { + const dx = moveEvent.clientX - dragStateRef.current.startX; + const dy = moveEvent.clientY - dragStateRef.current.startY; + if (!dragStateRef.current.moved && Math.abs(dx) + Math.abs(dy) > 3) { + dragStateRef.current.moved = true; + blockClickRef.current = true; + setIsDragging(true); + } + target.scrollLeft = dragStateRef.current.scrollLeft - dx; + target.scrollTop = dragStateRef.current.scrollTop - dy; + }; + const onMouseUp = () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + setIsDragging(false); + if (dragStateRef.current.moved) { + window.setTimeout(() => { + blockClickRef.current = false; + }, 0); + } + }; + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + event.preventDefault(); + }; + + return ( +
+
+
+
{t('title')}
+
+
+
+
+ {flowcharts.map((flowchart) => ( + + ))} +
+
+ {!currentFlowchart ? ( +
{t('empty')}
+ ) : ( + + + + + + + + + + + + + + + + + + + + + + {getConnectorSegments(layout.edges, currentFlowchart.id) + .sort((a, b) => b.layerY - a.layerY) + .map((segment) => ( + + ))} + {layout.nodes.map((node) => { + const unlocked = WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id); + const labelText = unlocked || lockedNodeVisibility === 'all' ? node.data?.label || node.id : ''; + return ( + unlocked && jumpToNode(node)} + onKeyDown={(event) => { + if (unlocked && (event.key === 'Enter' || event.key === ' ')) jumpToNode(node); + }} + onMouseEnter={unlocked ? playSeEnter : undefined} + > + {labelText || t('locked')} + + + {labelText} + + + ); + })} + + )} +
+
+
+ ); +}; + +function layoutFlowchart(flowchart?: IFlowchart, lockedNodeVisibility: LockedNodeVisibility = 'node') { + if (!flowchart) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 }; + const flowchartNodes = + lockedNodeVisibility === 'none' + ? flowchart.nodes.filter((node) => WebGAL.flowchartManager.isUnlocked(flowchart.id, node.id)) + : flowchart.nodes; + if (flowchartNodes.length === 0) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 }; + const nodeMap = new Map(flowchartNodes.map((node) => [node.id, node])); + const validEdges = flowchart.edges.filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target)); + const incomingCount = new Map(flowchartNodes.map((node) => [node.id, 0])); + const adjacency = new Map(); + const parentMap = new Map(); + validEdges.forEach((edge) => { + incomingCount.set(edge.target, (incomingCount.get(edge.target) ?? 0) + 1); + adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge]); + parentMap.set(edge.target, [...(parentMap.get(edge.target) ?? []), edge.source]); + }); + const roots = flowchartNodes.filter((node) => node.data?.isRoot || (incomingCount.get(node.id) ?? 0) === 0); + const queue = (roots.length ? roots : flowchartNodes.slice(0, 1)).map((node) => node.id); + const restIncomingCount = new Map(incomingCount); + const depthMap = new Map(flowchartNodes.map((node) => [node.id, 0])); + const visited = new Set(); + while (queue.length > 0) { + const sourceId = queue.shift()!; + if (visited.has(sourceId)) continue; + visited.add(sourceId); + (adjacency.get(sourceId) ?? []).forEach((edge) => { + depthMap.set(edge.target, Math.max(depthMap.get(edge.target) ?? 0, (depthMap.get(sourceId) ?? 0) + 1)); + restIncomingCount.set(edge.target, (restIncomingCount.get(edge.target) ?? 0) - 1); + if ((restIncomingCount.get(edge.target) ?? 0) <= 0) queue.push(edge.target); + }); + } + const maxDepth = Math.max(0, ...depthMap.values()); + flowchartNodes + .filter((node) => !visited.has(node.id)) + .forEach((node, index) => depthMap.set(node.id, Math.max(depthMap.get(node.id) ?? 0, maxDepth + index + 1))); + const layerMap = new Map(); + flowchartNodes.forEach((node) => { + const depth = depthMap.get(node.id) ?? 0; + layerMap.set(depth, [...(layerMap.get(depth) ?? []), node]); + }); + const layers = [...layerMap.entries()].sort(([a], [b]) => a - b).map(([, layer]) => layer); + const nodeWidthMap = new Map( + flowchartNodes.map((node) => [node.id, getNodeWidth(node, flowchart.id, lockedNodeVisibility)]), + ); + const layerWidths = layers.map( + (layer) => + layer.reduce((sum, node) => sum + (nodeWidthMap.get(node.id) ?? NODE_MIN_WIDTH), 0) + + (layer.length - 1) * NODE_GAP, + ); + const width = MARGIN_X * 2 + Math.max(NODE_MIN_WIDTH, ...layerWidths); + const layoutNodeMap = new Map(); + layers.forEach((layer, depth) => { + const layerWidth = layerWidths[depth]; + let x = (width - layerWidth) / 2; + layer.forEach((node) => { + const nodeWidth = nodeWidthMap.get(node.id) ?? NODE_MIN_WIDTH; + const parentXs = (parentMap.get(node.id) ?? []) + .map((id) => { + const parent = layoutNodeMap.get(id); + return parent ? parent.x + parent.width / 2 : undefined; + }) + .filter((parentX): parentX is number => typeof parentX === 'number'); + layoutNodeMap.set(node.id, { + ...node, + width: nodeWidth, + x: + layer.length === 1 && parentXs.length > 0 + ? parentXs.reduce((sum, parentX) => sum + parentX, 0) / parentXs.length - nodeWidth / 2 + : x, + y: MARGIN_Y + depth * ROW_GAP, + }); + x += nodeWidth + NODE_GAP; + }); + }); + const nodes = [...layoutNodeMap.values()]; + const edges = validEdges + .map((edge) => ({ + ...edge, + sourceNode: layoutNodeMap.get(edge.source), + targetNode: layoutNodeMap.get(edge.target), + })) + .filter((edge): edge is LayoutEdge => Boolean(edge.sourceNode && edge.targetNode)); + return { nodes, edges, width, height: MARGIN_Y * 2 + NODE_HEIGHT + (layers.length - 1) * ROW_GAP }; +} + +function getNodeWidth(node: IFlowchartNode, flowchartId: string, lockedNodeVisibility: LockedNodeVisibility) { + if (lockedNodeVisibility === 'node' && !WebGAL.flowchartManager.isUnlocked(flowchartId, node.id)) + return NODE_MIN_WIDTH; + const label = node.data?.label || node.id; + const textWidth = Array.from(label).reduce((sum, char) => sum + (char.charCodeAt(0) > 255 ? 24 : 14), 0); + return Math.max(NODE_MIN_WIDTH, textWidth + 36); +} + +function normalizeLockedNodeVisibility(value: unknown): LockedNodeVisibility { + return value === 'all' || value === 'none' ? value : 'node'; +} + +function getConnectorSegments(edges: LayoutEdge[], flowchartId: string): ConnectorSegment[] { + const targetBendYMap = getTargetBendYMap(edges); + const edgeGroups = new Map(); + edges.forEach((edge) => edgeGroups.set(edge.source, [...(edgeGroups.get(edge.source) ?? []), edge])); + return [...edgeGroups.values()].flatMap((groupEdges) => + getConnectorSegmentsBySource(groupEdges, flowchartId, targetBendYMap), + ); +} + +function getTargetBendYMap(edges: LayoutEdge[]) { + const targetGroups = new Map(); + edges.forEach((edge) => targetGroups.set(edge.target, [...(targetGroups.get(edge.target) ?? []), edge])); + const bendYMap = new Map(); + targetGroups.forEach((targetEdges, target) => { + if (targetEdges.length < 2) return; + const ty = targetEdges[0].targetNode.y - CONNECTOR_GAP; + const maxSourceY = Math.max(...targetEdges.map((edge) => edge.sourceNode.y + NODE_HEIGHT)); + bendYMap.set(target, getSingleEdgeBendY(maxSourceY, ty)); + }); + return bendYMap; +} + +function getConnectorSegmentsBySource( + edges: LayoutEdge[], + flowchartId: string, + targetBendYMap: Map, +): ConnectorSegment[] { + if (edges.length === 0) return []; + const source = edges[0].sourceNode; + const sx = source.x + source.width / 2; + const sy = source.y + NODE_HEIGHT; + const sourceUnlocked = WebGAL.flowchartManager.isUnlocked(flowchartId, source.id); + if (edges.length === 1) { + const edge = edges[0]; + const tx = edge.targetNode.x + edge.targetNode.width / 2; + const ty = edge.targetNode.y - CONNECTOR_GAP; + const bendY = targetBendYMap.get(edge.target) ?? getSingleEdgeBendY(sy, ty); + return [ + { + id: edge.id, + d: sx === tx ? `M ${sx} ${sy} V ${ty}` : `M ${sx} ${sy} V ${bendY} H ${tx} V ${ty}`, + unlocked: sourceUnlocked && WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target), + arrow: true, + layerY: ty, + }, + ]; + } + const targetXs = edges.map((edge) => edge.targetNode.x + edge.targetNode.width / 2); + const targetY = Math.min(...edges.map((edge) => edge.targetNode.y)) - CONNECTOR_GAP; + const busY = sy + Math.max(34, (targetY - sy) / 2); + const hasUnlockedTarget = edges.some((edge) => WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target)); + const segments: ConnectorSegment[] = [ + { + id: `${source.id}-trunk`, + d: `M ${sx} ${sy} V ${busY}`, + unlocked: sourceUnlocked && hasUnlockedTarget, + arrow: false, + layerY: busY, + }, + { + id: `${source.id}-bus`, + d: `M ${Math.min(...targetXs)} ${busY} H ${Math.max(...targetXs)}`, + unlocked: sourceUnlocked && hasUnlockedTarget, + arrow: false, + layerY: busY, + }, + ]; + edges.forEach((edge) => { + const tx = edge.targetNode.x + edge.targetNode.width / 2; + segments.push({ + id: edge.id, + d: `M ${tx} ${busY} V ${edge.targetNode.y - CONNECTOR_GAP}`, + unlocked: sourceUnlocked && WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target), + arrow: true, + layerY: edge.targetNode.y - CONNECTOR_GAP, + }); + }); + return segments; +} + +function getSingleEdgeBendY(sy: number, ty: number) { + const spanY = ty - sy; + return spanY > ROW_GAP ? ty - Math.max(34, Math.min(70, spanY / 4)) : sy + Math.max(30, spanY / 2); +} diff --git a/packages/webgal/src/UI/Flowchart/flowchart.module.scss b/packages/webgal/src/UI/Flowchart/flowchart.module.scss new file mode 100644 index 000000000..292f68c92 --- /dev/null +++ b/packages/webgal/src/UI/Flowchart/flowchart.module.scss @@ -0,0 +1,206 @@ +.Flowchart_main { + font-family: "思源宋体", serif; + position: absolute; + width: 100%; + height: 90%; + cursor: default; +} + +.flowchart_top { + height: 10%; + width: 100%; + display: flex; + justify-content: center; + position: relative; + animation: flowchart_soft_in 0.7s ease-out forwards; +} + +.flowchart_title { + font-size: 500%; + min-width: 350px; + display: flex; + align-items: center; + position: absolute; + left: 20px; + top: 0; + opacity: 0.22; + transform: translateY(-10px); + pointer-events: none; +} + +.flowchart_title_text { + font-weight: bold; + color: transparent; + background: linear-gradient(135deg, #2B5F75 0%, #7ba8bc 100%); + text-shadow: 2px 2px 15px rgba(255, 255, 255, 0.5); + -webkit-background-clip: text; +} + +.flowchart_body { + display: flex; + gap: 2.5em; + height: 90%; + padding: 0.5em 5em 1em 5em; + box-sizing: border-box; + animation: flowchart_soft_in 0.7s ease-out forwards; +} + +.flowchart_sidebar { + width: 18em; + flex-shrink: 0; + overflow: auto; + padding: 0.5em 0; +} + +.flowchart_tab { + font-family: "资源圆体", serif; + display: block; + width: 100%; + margin: 0 0 0.75em 0; + padding: 0.65em 0.8em; + border: 1px solid rgba(255, 255, 255, 0.55); + border-radius: 4px; + color: rgba(52, 80, 92, 0.78); + background: linear-gradient(90deg, rgba(255, 255, 255, 0.78), rgba(239, 247, 250, 0.72)); + text-align: left; + font-size: 135%; + cursor: pointer; + transition: border-color 0.25s, background-color 0.25s, color 0.25s; +} + +.flowchart_tab:hover, +.flowchart_tab_active { + color: #2B5F75; + border-color: rgba(43, 95, 117, 0.36); + background: linear-gradient(90deg, rgba(255, 255, 255, 0.95), rgba(226, 240, 246, 0.85)); +} + +.flowchart_content { + flex: 1; + overflow-x: hidden; + overflow-y: auto; + padding: 1em; + box-sizing: border-box; + display: flex; + cursor: grab; + background: linear-gradient(-45deg, rgba(255, 255, 255, 0.32), rgba(226, 240, 246, 0.2)); + border: 1px solid rgba(255, 255, 255, 0.65); + border-radius: 4px; + box-shadow: inset 0 0 24px rgba(255, 255, 255, 0.38), 0 10px 28px rgba(43, 95, 117, 0.06); + overscroll-behavior: contain; + user-select: none; +} + +.flowchart_content_dragging { + cursor: grabbing; +} + +.flowchart_content_dragging .flowchart_node_unlocked { + cursor: grabbing; +} + +.flowchart_empty { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(52, 80, 92, 0.72); + font-size: 180%; +} + +.flowchart_canvas { + display: block; + flex: 0 0 auto; + margin: 0 auto; + overflow: visible; +} + +.flowchart_arrow_unlocked { + fill: #2B5F75; + fill-opacity: 1; +} + +.flowchart_arrow_locked { + fill: #a9b7c4; + fill-opacity: 1; +} + +.flowchart_line_unlocked, +.flowchart_line_locked { + fill: none; + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + stroke-opacity: 1; + pointer-events: none; +} + +.flowchart_line_unlocked { + stroke: #2B5F75; + filter: drop-shadow(0 0 6px rgba(43, 95, 117, 0.28)); +} + +.flowchart_line_locked { + stroke: #a9b7c4; + stroke-dasharray: 7 8; +} + +.flowchart_node { + font-family: "资源圆体", serif; + outline: none; +} + +.flowchart_node_unlocked { + cursor: pointer; +} + +.flowchart_node_locked { + opacity: 0.62; + cursor: default; +} + +.flowchart_node_rect { + transition: fill 0.25s, stroke 0.25s, filter 0.25s; +} + +.flowchart_node_rect_unlocked { + fill: url(#flowchart-node-unlocked); + stroke: #2B5F75; + stroke-width: 2; + filter: url(#flowchart-node-glow); +} + +.flowchart_node_rect_locked { + fill: url(#flowchart-node-locked); + stroke: rgba(154, 174, 194, 0.8); + stroke-width: 1.5; +} + +.flowchart_node_unlocked:hover .flowchart_node_rect_unlocked { + stroke: #1f4e63; + filter: drop-shadow(0 0 10px rgba(43, 95, 117, 0.42)); +} + +.flowchart_node_label { + fill: #243f4b; + stroke: rgba(255, 255, 255, 0.85); + stroke-width: 3px; + paint-order: stroke; + font-size: 22px; + font-weight: bold; + pointer-events: none; +} + +.flowchart_node_locked .flowchart_node_label { + fill: rgba(97, 115, 133, 0.72); +} + +@keyframes flowchart_soft_in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/packages/webgal/src/UI/Menu/Menu.tsx b/packages/webgal/src/UI/Menu/Menu.tsx index 70e329f54..29fe7efc2 100644 --- a/packages/webgal/src/UI/Menu/Menu.tsx +++ b/packages/webgal/src/UI/Menu/Menu.tsx @@ -7,6 +7,7 @@ import { Options } from './Options/Options'; import { useSelector } from 'react-redux'; import { RootState } from '@/store/store'; import { MenuPanelTag } from '@/store/guiInterface'; +import { Flowchart } from '@/UI/Flowchart/Flowchart'; /** * Menu 页面,包括存读档、选项等 @@ -29,6 +30,9 @@ const Menu: FC = () => { currentTag = ; // menuBgColor = 'linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%)'; break; + case MenuPanelTag.Flowchart: + currentTag = ; + break; } return ( <> diff --git a/packages/webgal/src/UI/Menu/MenuPanel/MenuIconMap.tsx b/packages/webgal/src/UI/Menu/MenuPanel/MenuIconMap.tsx index 460c2d2f2..1d3a44fc0 100644 --- a/packages/webgal/src/UI/Menu/MenuPanel/MenuIconMap.tsx +++ b/packages/webgal/src/UI/Menu/MenuPanel/MenuIconMap.tsx @@ -1,5 +1,5 @@ import { IMenuPanel } from '@/UI/Menu/MenuPanel/menuPanelInterface'; -import { FolderOpen, Home, Logout, Save, SettingTwo } from '@icon-park/react'; +import { FolderOpen, Home, Logout, Save, SettingTwo, TreeDiagram } from '@icon-park/react'; /** * 通过图标名称返回正确的图标 @@ -12,6 +12,9 @@ export const MenuIconMap = (props: IMenuPanel) => { case 'save': returnIcon = ; break; + case 'flowchart': + returnIcon = ; + break; case 'load': returnIcon = ; break; diff --git a/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx b/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx index 85e1100d6..ef080a28a 100644 --- a/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx +++ b/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx @@ -1,6 +1,5 @@ import styles from './menuPanel.module.scss'; import { MenuPanelButton } from './MenuPanelButton'; -import { playBgm } from '@/Core/controller/stage/playBgm'; import { MenuPanelTag } from '@/store/guiInterface'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from '@/store/store'; @@ -20,17 +19,20 @@ export const MenuPanel = () => { const { playSeClick, playSeDialogOpen, playSePageChange } = useSoundEffect(); const GUIState = useSelector((state: RootState) => state.GUI); + const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); const dispatch = useDispatch(); // 设置Menu按钮的高亮 const SaveTagOn = GUIState.currentMenuTag === MenuPanelTag.Save ? ` ${styles.MenuPanel_button_hl}` : ``; const LoadTagOn = GUIState.currentMenuTag === MenuPanelTag.Load ? ` ${styles.MenuPanel_button_hl}` : ``; const OptionTagOn = GUIState.currentMenuTag === MenuPanelTag.Option ? ` ${styles.MenuPanel_button_hl}` : ``; + const FlowchartTagOn = GUIState.currentMenuTag === MenuPanelTag.Flowchart ? ` ${styles.MenuPanel_button_hl}` : ``; // 设置Menu按钮的颜色 const SaveTagColor = GUIState.currentMenuTag === MenuPanelTag.Save ? `rgba(74, 34, 93, 0.9)` : `rgba(123,144,169,1)`; const LoadTagColor = GUIState.currentMenuTag === MenuPanelTag.Load ? `rgba(11, 52, 110, 0.9)` : `rgba(123,144,169,1)`; const OptionTagColor = GUIState.currentMenuTag === MenuPanelTag.Option ? `rgba(81, 110, 65, 0.9)` : `rgba(123,144,169,1)`; + const FlowchartTagColor = GUIState.currentMenuTag === MenuPanelTag.Flowchart ? `#2B5F75` : `rgba(123,144,169,1)`; // 设置Menu图标的颜色 const SaveIconColor = GUIState.currentMenuTag === MenuPanelTag.Save ? `rgba(74, 34, 93, 0.9)` : `rgba(123,144,169,1)`; @@ -38,9 +40,24 @@ export const MenuPanel = () => { GUIState.currentMenuTag === MenuPanelTag.Load ? `rgba(11, 52, 110, 0.9)` : `rgba(123,144,169,1)`; const OptionIconColor = GUIState.currentMenuTag === MenuPanelTag.Option ? `rgba(81, 110, 65, 0.9)` : `rgba(123,144,169,1)`; + const FlowchartIconColor = GUIState.currentMenuTag === MenuPanelTag.Flowchart ? `#2B5F75` : `rgba(123,144,169,1)`; return (
+ {enableFlowchart && ( + { + playSePageChange(); + dispatch(setMenuPanelTag(MenuPanelTag.Flowchart)); + }} + tagName={t('flowchart.title')} + key="flowchartButton" + /> + )} { dispatch(resetAllData()); + WebGAL.flowchartManager.clearProgress(); dumpToStorageFast(); dispatch(saveActions.resetSaves()); dumpSavesToStorage(0, 200); diff --git a/packages/webgal/src/hooks/useHotkey.tsx b/packages/webgal/src/hooks/useHotkey.tsx index f8aeb6176..e47e6cf34 100644 --- a/packages/webgal/src/hooks/useHotkey.tsx +++ b/packages/webgal/src/hooks/useHotkey.tsx @@ -57,6 +57,7 @@ export function useMouseRightClickHotKey() { const setComponentVisibility = useSetComponentVisibility(); const isGameActive = useGameActive(GUIStore); const isInBackLog = useIsInBackLog(GUIStore); + const isInFlowchart = useIsInFlowchart(GUIStore); const isOpenedDialog = useIsOpenedDialog(GUIStore); const validMenuPanelTag = useValidMenuPanelTag(GUIStore); const isShowExtra = useIsOpenedExtra(GUIStore); @@ -76,6 +77,10 @@ export function useMouseRightClickHotKey() { setComponentVisibility('showBacklog', false); setComponentVisibility('showTextBox', true); } + if (isInFlowchart()) { + setComponentVisibility('showFlowchart', false); + setComponentVisibility('showTextBox', true); + } if (validMenuPanelTag()) { setComponentVisibility('showMenuPanel', false); } @@ -250,6 +255,7 @@ function useGameActive(GUIStore: T & any): () => boolean { !GUIStore.current.showTitle && !GUIStore.current.showMenuPanel && !GUIStore.current.showBacklog && + !GUIStore.current.showFlowchart && !GUIStore.current.showPanicOverlay ); }, [GUIStore]); @@ -262,6 +268,12 @@ function useIsInBackLog(GUIStore: T & any): () => boolean { }, [GUIStore]); } +function useIsInFlowchart(GUIStore: T & any): () => boolean { + return useCallback(() => { + return GUIStore.current.showFlowchart; + }, [GUIStore]); +} + // 判断是否打开了全局对话框 function useIsOpenedDialog(GUIStore: T & any): () => boolean { return useCallback(() => { @@ -285,7 +297,9 @@ function useIsPanicOverlayOpen(GUIStore: T & any): () => boolean { // 验证是否在存档 / 读档 / 选项页面 function useValidMenuPanelTag(GUIStore: T & any): () => boolean { return useCallback(() => { - return [MenuPanelTag.Save, MenuPanelTag.Load, MenuPanelTag.Option].includes(GUIStore.current.currentMenuTag); + return [MenuPanelTag.Save, MenuPanelTag.Load, MenuPanelTag.Option, MenuPanelTag.Flowchart].includes( + GUIStore.current.currentMenuTag, + ); }, [GUIStore]); } diff --git a/packages/webgal/src/store/GUIReducer.ts b/packages/webgal/src/store/GUIReducer.ts index 7d87e2439..8c1357fd6 100644 --- a/packages/webgal/src/store/GUIReducer.ts +++ b/packages/webgal/src/store/GUIReducer.ts @@ -16,6 +16,7 @@ import { DEFAULT_FONT_OPTIONS } from '@/Core/util/fonts/fontOptions'; const initState: IGuiState = { fontOptions: [...DEFAULT_FONT_OPTIONS], showBacklog: false, + showFlowchart: false, showStarter: true, showTitle: true, showMenuPanel: false, diff --git a/packages/webgal/src/store/guiInterface.ts b/packages/webgal/src/store/guiInterface.ts index 1fc4fdaeb..a26ad9de9 100644 --- a/packages/webgal/src/store/guiInterface.ts +++ b/packages/webgal/src/store/guiInterface.ts @@ -5,6 +5,7 @@ export enum MenuPanelTag { Save, // “保存”选项卡 Load, // “读取”选项卡 Option, // “设置”选项卡 + Flowchart, // “流程图”选项卡 } /** @@ -20,6 +21,7 @@ export interface IGuiState { controlsVisibility: boolean; currentMenuTag: MenuPanelTag; // 当前Menu界面的选项卡 showBacklog: boolean; + showFlowchart: boolean; titleBgm: string; // 标题背景音乐 titleBg: string; // 标题背景图片 logoImage: string[]; diff --git a/packages/webgal/src/translations/de.ts b/packages/webgal/src/translations/de.ts index a40875a38..cfd7e3488 100644 --- a/packages/webgal/src/translations/de.ts +++ b/packages/webgal/src/translations/de.ts @@ -127,6 +127,9 @@ const de = { loadSaving: { title: 'LADEN', }, + flowchart: { + title: 'ABLAUF', + }, title: { title: 'TITEL', }, @@ -169,6 +172,7 @@ const de = { hide: 'Verstecken', show: 'Anzeigen', backlog: 'Verlauf', + flowchart: 'Ablauf', replay: 'Wiedergabe', auto: 'Auto', forward: 'Überspringen', @@ -180,6 +184,15 @@ const de = { options: 'Optionen', title: 'Titel', }, + flowchart: { + title: 'Ablauf', + empty: 'Kein Ablauf', + locked: 'Gesperrt', + main: 'Haupt', + character: 'Route', + root: 'Start', + chapter: 'Kapitel', + }, }, extra: { diff --git a/packages/webgal/src/translations/en.ts b/packages/webgal/src/translations/en.ts index 801e41296..e3614da57 100644 --- a/packages/webgal/src/translations/en.ts +++ b/packages/webgal/src/translations/en.ts @@ -134,6 +134,9 @@ const en = { loadSaving: { title: 'LOAD', }, + flowchart: { + title: 'FLOWCHART', + }, title: { title: 'TITLE', }, @@ -176,6 +179,7 @@ const en = { hide: 'Hide', show: 'Show', backlog: 'Backlog', + flowchart: 'Flowchart', replay: 'Replay', auto: 'Auto', forward: 'Forward', @@ -188,6 +192,15 @@ const en = { title: 'Title', titleTips: 'Confirm return to the title screen', }, + flowchart: { + title: 'Flowchart', + empty: 'No flowchart', + locked: 'Locked', + main: 'Main', + character: 'Route', + root: 'Start', + chapter: 'Chapter', + }, }, extra: { diff --git a/packages/webgal/src/translations/fr.ts b/packages/webgal/src/translations/fr.ts index faf3296dd..a3309e227 100644 --- a/packages/webgal/src/translations/fr.ts +++ b/packages/webgal/src/translations/fr.ts @@ -127,6 +127,9 @@ const fr = { loadSaving: { title: 'CHARGER', }, + flowchart: { + title: 'PARCOURS', + }, title: { title: 'TITRE', }, @@ -169,6 +172,7 @@ const fr = { hide: 'Masquer', show: 'Afficher', backlog: 'Journal', + flowchart: 'Parcours', replay: 'Rejouer', auto: 'Automatique', forward: 'Avancer', @@ -181,6 +185,15 @@ const fr = { title: 'Titre', titleTips: "Confirmer le retour à l'écran titre ?", }, + flowchart: { + title: 'Parcours', + empty: 'Aucun parcours', + locked: 'Verrouillé', + main: 'Principal', + character: 'Route', + root: 'Début', + chapter: 'Chapitre', + }, }, extra: { diff --git a/packages/webgal/src/translations/jp.ts b/packages/webgal/src/translations/jp.ts index d54154471..dcf96ba74 100644 --- a/packages/webgal/src/translations/jp.ts +++ b/packages/webgal/src/translations/jp.ts @@ -137,6 +137,9 @@ const jp = { loadSaving: { title: 'LOAD', }, + flowchart: { + title: 'FLOWCHART', + }, title: { title: 'HOME', }, @@ -179,6 +182,7 @@ const jp = { hide: 'CLOSE', show: 'SHOW', backlog: 'LOG', + flowchart: 'FLOWCHART', replay: 'REPLAY', auto: 'AUTO', forward: 'SKIP', @@ -191,6 +195,15 @@ const jp = { title: 'HOME', titleTips: 'タイトル画面に戻りますか?', }, + flowchart: { + title: 'Flowchart', + empty: 'フローチャートなし', + locked: '未解放', + main: 'メイン', + character: 'ルート', + root: '開始', + chapter: '章', + }, }, extra: { diff --git a/packages/webgal/src/translations/ko.ts b/packages/webgal/src/translations/ko.ts index 8bd161988..40f8b60ff 100644 --- a/packages/webgal/src/translations/ko.ts +++ b/packages/webgal/src/translations/ko.ts @@ -137,6 +137,9 @@ const ko = { loadSaving: { title: '불러오기', }, + flowchart: { + title: '플로차트', + }, title: { title: '타이틀', options: { @@ -183,6 +186,7 @@ const ko = { hide: '숨김', show: '표시', backlog: '백로그', + flowchart: '플로차트', replay: '다시 재생', auto: '자동 재생', forward: '빠른 재생', @@ -195,6 +199,15 @@ const ko = { title: '메인 화면', titleTips: '메인 화면으로 돌아가시겠습니까?', }, + flowchart: { + title: '플로차트', + empty: '플로차트 없음', + locked: '잠김', + main: '메인', + character: '루트', + root: '시작', + chapter: '챕터', + }, }, extra: { diff --git a/packages/webgal/src/translations/pt-br.ts b/packages/webgal/src/translations/pt-br.ts index af45456e2..1fb979990 100644 --- a/packages/webgal/src/translations/pt-br.ts +++ b/packages/webgal/src/translations/pt-br.ts @@ -134,6 +134,9 @@ const ptBr = { loadSaving: { title: 'CARREGAR', }, + flowchart: { + title: 'FLUXO', + }, title: { title: 'TÍTULO', }, @@ -176,6 +179,7 @@ const ptBr = { hide: 'Esconder', show: 'Exibir', backlog: 'Histórico', + flowchart: 'Fluxo', replay: 'Repetir', auto: 'Auto', forward: 'Avançar', @@ -188,6 +192,15 @@ const ptBr = { title: 'Título', titleTips: 'Confirma o retorno para a tela de título', }, + flowchart: { + title: 'Fluxo', + empty: 'Nenhum fluxo', + locked: 'Bloqueado', + main: 'Principal', + character: 'Rota', + root: 'Início', + chapter: 'Capítulo', + }, }, extra: { diff --git a/packages/webgal/src/translations/zh-cn.ts b/packages/webgal/src/translations/zh-cn.ts index 8a178b558..797f95899 100644 --- a/packages/webgal/src/translations/zh-cn.ts +++ b/packages/webgal/src/translations/zh-cn.ts @@ -137,6 +137,9 @@ const zhCn = { loadSaving: { title: '读档', }, + flowchart: { + title: '流程图', + }, title: { title: '标题', options: { @@ -183,6 +186,7 @@ const zhCn = { hide: '隐藏', show: '显示', backlog: '回想', + flowchart: '流程图', replay: '重播', auto: '自动', forward: '快进', @@ -195,6 +199,15 @@ const zhCn = { title: '标题', titleTips: '确定要返回标题界面吗?', }, + flowchart: { + title: '流程图', + empty: '暂无流程图', + locked: '未解锁', + main: '主线', + character: '个人线', + root: '开始', + chapter: '章节', + }, }, extra: { diff --git a/packages/webgal/src/translations/zh-tw.ts b/packages/webgal/src/translations/zh-tw.ts index 0da6c9f43..1cd6f8a0f 100644 --- a/packages/webgal/src/translations/zh-tw.ts +++ b/packages/webgal/src/translations/zh-tw.ts @@ -137,6 +137,9 @@ const zhTw = { loadSaving: { title: '讀檔', }, + flowchart: { + title: '流程圖', + }, title: { title: '主選單', options: { @@ -183,6 +186,7 @@ const zhTw = { hide: '隱藏', show: '顯示', backlog: '回想', + flowchart: '流程圖', replay: '重播', auto: '自動', forward: '加速', @@ -195,6 +199,15 @@ const zhTw = { title: '主選單', titleTips: '確定要返回主選單嗎?', }, + flowchart: { + title: '流程圖', + empty: '暫無流程圖', + locked: '未解鎖', + main: '主線', + character: '個人線', + root: '開始', + chapter: '章節', + }, }, extra: { diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index 374c47789..596931e5f 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -1,44 +1,132 @@ -import type { IEffect, ITransform } from '@/Core/Modules/stage/stageInterface'; -import type { componentsVisibility } from '@/store/guiInterface'; - export const EDITOR_PREVIEW_PROTOCOL_V1_SUBPROTOCOL = 'webgal-editor-preview-sync.v1' as const; +export const SESSION_REGISTER_PREVIEW_TYPE = 'session.register-preview' as const; + type EmptyObject = Record; +type PayloadMap = Record; + +function payload(): TPayload { + return undefined as TPayload; +} + +function definePayloadMap(map: TMap): Readonly { + return Object.freeze(map); +} + +function messageTypes(map: TMap): readonly Extract[] { + return Object.freeze(Object.keys(map) as Extract[]); +} -export interface DebugVariablePayload { +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; + +export interface JsonObject { + [key: string]: JsonValue; +} + +export interface DebugVariable { key: string; value: string; isGlobal?: boolean; } +export interface Point2D { + x?: number; + y?: number; +} + +export interface Transform { + position?: Point2D; + scale?: Point2D; + rotation?: number; + + alpha?: number; + blur?: number; + + brightness?: number; + contrast?: number; + saturation?: number; + gamma?: number; + colorRed?: number; + colorGreen?: number; + colorBlue?: number; + + bloom?: number; + bloomBrightness?: number; + bloomBlur?: number; + bloomThreshold?: number; + + bevel?: number; + bevelThickness?: number; + bevelRotation?: number; + bevelSoftness?: number; + bevelRed?: number; + bevelGreen?: number; + bevelBlue?: number; + + oldFilm?: number; + dotFilm?: number; + reflectionFilm?: number; + glitchFilm?: number; + rgbFilm?: number; + godrayFilm?: number; + + shockwaveFilter?: number; + radiusAlphaFilter?: number; +} + +export const COMPONENT_VISIBILITY_KEYS = [ + 'showStarter', + 'showTitle', + 'showMenuPanel', + 'showFlowchart', + 'showTextBox', + 'showControls', + 'controlsVisibility', + 'showBacklog', + 'showExtra', + 'showGlobalDialog', + 'showPanicOverlay', + 'isEnterGame', + 'isShowLogo', + 'enableAppreciationMode', + 'fontOptimization', +] as const; + +export type ComponentVisibilityKey = (typeof COMPONENT_VISIBILITY_KEYS)[number]; + +export type SetComponentVisibilityPayload = Partial>; + +export type SyncSceneSettleMode = 'normal' | 'immediate'; + export interface SyncScenePayload { sceneName: string; sentenceId: number; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; + transformBaselineRevision?: string; + settleMode?: SyncSceneSettleMode; } export interface RunSceneContentPayload { sceneContent: string; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export interface RunSnippetPayload { snippet: string; - debugVariables?: DebugVariablePayload[]; + debugVariables?: DebugVariable[]; } export type ReloadTemplatesPayload = EmptyObject; +export type SetEffectPhase = 'preview' | 'commit'; + export interface SetEffectPayload { - target: IEffect['target']; - transform?: Partial> & { - position?: Partial; - scale?: Partial; - }; + target: string; + transform?: Transform; + phase?: SetEffectPhase; } -export type SetComponentVisibilityPayload = Partial>; - export interface SetFontOptimizationPayload { enabled: boolean; } @@ -47,42 +135,93 @@ export interface SetTextReadModePayload { isRead: boolean; } -export interface PreviewCommandPayloadByType { - 'preview.command.sync-scene': SyncScenePayload; - 'preview.command.run-scene-content': RunSceneContentPayload; - 'preview.command.run-snippet': RunSnippetPayload; - 'preview.command.reload-templates': ReloadTemplatesPayload; - 'preview.command.set-effect': SetEffectPayload; - 'preview.command.set-component-visibility': SetComponentVisibilityPayload; - 'preview.command.set-font-optimization': SetFontOptimizationPayload; - 'preview.command.set-text-read-mode': SetTextReadModePayload; +export interface ReferenceBoxQueryPayload { + target: string; } -export type PreviewCommandType = keyof PreviewCommandPayloadByType; +export type BaseTransformQueryPayload = EmptyObject; -const PREVIEW_COMMAND_TYPES = [ - 'preview.command.sync-scene', - 'preview.command.run-scene-content', - 'preview.command.run-snippet', - 'preview.command.reload-templates', - 'preview.command.set-effect', - 'preview.command.set-component-visibility', - 'preview.command.set-font-optimization', - 'preview.command.set-text-read-mode', -] as const satisfies readonly PreviewCommandType[]; +export interface TransformBaselineQueryPayload { + target: string; + transformBaselineRevision: string; +} -export interface PreviewRequestPayloadByType extends PreviewCommandPayloadByType {} +export interface ReferenceBox { + originX: number; + originY: number; + width: number; + height: number; + anchorX: number; + anchorY: number; + stageWidth: number; + stageHeight: number; +} -export type PreviewRequestType = keyof PreviewRequestPayloadByType; +export type ReferenceBoxQueryResultPayload = + | { + target: string; + status: 'ready'; + box: ReferenceBox; + } + | { + target: string; + status: 'missing' | 'loading' | 'unsupported'; + reason?: string; + }; -const PREVIEW_REQUEST_TYPES = [...PREVIEW_COMMAND_TYPES] as const satisfies readonly PreviewRequestType[]; +export interface BaseTransformQueryResultPayload { + baseTransform: Transform; +} -type JsonPrimitive = string | number | boolean | null; -type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +export type TransformBaselineQueryResultPayload = + | { + status: 'ready'; + transform: Transform; + } + | { + status: 'loading'; + } + | { + status: 'unavailable'; + }; -interface JsonObject { - [key: string]: JsonValue; -} +export const PREVIEW_COMMAND_PAYLOADS = definePayloadMap({ + 'preview.command.sync-scene': payload(), + 'preview.command.run-scene-content': payload(), + 'preview.command.run-snippet': payload(), + 'preview.command.reload-templates': payload(), + 'preview.command.set-effect': payload(), + 'preview.command.set-component-visibility': payload(), + 'preview.command.set-font-optimization': payload(), + 'preview.command.set-text-read-mode': payload(), +}); + +export type PreviewCommandPayloadByType = typeof PREVIEW_COMMAND_PAYLOADS; + +export type PreviewCommandType = keyof PreviewCommandPayloadByType & string; + +export const PREVIEW_COMMAND_TYPES = messageTypes(PREVIEW_COMMAND_PAYLOADS); + +export const PREVIEW_QUERY_PAYLOADS = definePayloadMap({ + 'preview.query.reference-box': payload(), + 'preview.query.base-transform': payload(), + 'preview.query.transform-baseline': payload(), +}); + +export type PreviewQueryPayloadByType = typeof PREVIEW_QUERY_PAYLOADS; + +export type PreviewQueryType = keyof PreviewQueryPayloadByType & string; + +export const PREVIEW_QUERY_TYPES = messageTypes(PREVIEW_QUERY_PAYLOADS); + +export type PreviewRequestPayloadByType = PreviewCommandPayloadByType & PreviewQueryPayloadByType; + +export type PreviewRequestType = keyof PreviewRequestPayloadByType; + +export const PREVIEW_REQUEST_TYPES = [ + ...PREVIEW_COMMAND_TYPES, + ...PREVIEW_QUERY_TYPES, +] as const satisfies readonly PreviewRequestType[]; export interface PreviewReadyUpdatedPayload { ready: boolean; @@ -103,40 +242,85 @@ export interface FastPreviewTimeoutPayload { maxDurationMs: number; } -interface EventPayloadByType { - 'preview.ready.updated': PreviewReadyUpdatedPayload; - 'stage.snapshot.updated': StageSnapshotUpdatedPayload; - 'preview.event.fast-preview-timeout': FastPreviewTimeoutPayload; -} +export const HOST_EVENT_PAYLOADS = definePayloadMap({ + 'preview.ready.updated': payload(), + 'stage.snapshot.updated': payload(), + 'preview.event.fast-preview-timeout': payload(), +}); + +export type EventPayloadByType = typeof HOST_EVENT_PAYLOADS; -export type HostEventType = keyof EventPayloadByType; +export type HostEventType = keyof EventPayloadByType & string; -const HOST_EVENT_TYPES = [ - 'preview.ready.updated', - 'stage.snapshot.updated', - 'preview.event.fast-preview-timeout', -] as const satisfies readonly HostEventType[]; +export const HOST_EVENT_TYPES = messageTypes(HOST_EVENT_PAYLOADS); export interface RegisterPreviewRequestPayload { gameId?: string; embeddedLaunchId?: string; } -interface SessionRequestPayloadByType { - 'session.register-preview': RegisterPreviewRequestPayload; -} +export const SESSION_REQUEST_PAYLOADS = definePayloadMap({ + [SESSION_REGISTER_PREVIEW_TYPE]: payload(), +}); -interface RequestPayloadByType extends SessionRequestPayloadByType, PreviewRequestPayloadByType {} +export type SessionRequestPayloadByType = typeof SESSION_REQUEST_PAYLOADS; -export interface PreviewCommandResponsePayloadByType extends Record {} +export type SessionRequestType = keyof SessionRequestPayloadByType & string; -export interface PreviewResponsePayloadByType extends PreviewCommandResponsePayloadByType {} +export const SESSION_REQUEST_TYPES = messageTypes(SESSION_REQUEST_PAYLOADS); -interface SessionResponsePayloadByType { - 'session.register-preview': EmptyObject; -} +export type RequestPayloadByType = SessionRequestPayloadByType & PreviewRequestPayloadByType; + +export type RequestType = keyof RequestPayloadByType; + +export const REQUEST_TYPES = [ + ...SESSION_REQUEST_TYPES, + ...PREVIEW_REQUEST_TYPES, +] as const satisfies readonly RequestType[]; + +export type PreviewCommandResponsePayloadByType = Record; + +export const PREVIEW_QUERY_RESPONSE_PAYLOADS = definePayloadMap({ + 'preview.query.reference-box': payload(), + 'preview.query.base-transform': payload(), + 'preview.query.transform-baseline': payload(), +}); + +export type PreviewQueryResponsePayloadByType = typeof PREVIEW_QUERY_RESPONSE_PAYLOADS; + +export type PreviewResponsePayloadByType = PreviewCommandResponsePayloadByType & PreviewQueryResponsePayloadByType; + +export type PreviewResponseType = PreviewRequestType; -interface ResponsePayloadByType extends SessionResponsePayloadByType, PreviewResponsePayloadByType {} +export const PREVIEW_RESPONSE_TYPES = PREVIEW_REQUEST_TYPES satisfies readonly PreviewResponseType[]; + +export const SESSION_RESPONSE_PAYLOADS = definePayloadMap({ + [SESSION_REGISTER_PREVIEW_TYPE]: payload(), +}); + +export type SessionResponsePayloadByType = typeof SESSION_RESPONSE_PAYLOADS; + +export type SessionResponseType = keyof SessionResponsePayloadByType & string; + +export const SESSION_RESPONSE_TYPES = messageTypes(SESSION_RESPONSE_PAYLOADS); + +export type ResponsePayloadByType = SessionResponsePayloadByType & PreviewResponsePayloadByType; + +export type ResponseType = keyof ResponsePayloadByType; + +export const RESPONSE_TYPES = [ + ...SESSION_RESPONSE_TYPES, + ...PREVIEW_RESPONSE_TYPES, +] as const satisfies readonly ResponseType[]; + +export const PREVIEW_REQUEST_ERROR_CODES = ['bad-request', 'unsupported-request-type', 'internal-error'] as const; + +export type PreviewRequestErrorCode = (typeof PREVIEW_REQUEST_ERROR_CODES)[number]; + +export interface PreviewRequestError { + code: PreviewRequestErrorCode; + message?: string; +} export interface EventEnvelope { kind: 'event'; @@ -158,19 +342,38 @@ export interface ResponseEnvelope = { +export interface PreviewRequestErrorEnvelope { + kind: 'error'; + type: TType; + requestId: string; + error: PreviewRequestError; +} + +export type AnyProtocolEnvelope = EventEnvelope | RequestEnvelope | ResponseEnvelope | PreviewRequestErrorEnvelope; + +export type EventEnvelopeByType = { [K in TType]: EventEnvelope; }[TType]; -type RequestEnvelopeByType = { +export type RequestEnvelopeByType = { [K in TType]: RequestEnvelope; }[TType]; -type ResponseEnvelopeByType = { +export type ResponseEnvelopeByType = { [K in TType]: ResponseEnvelope; }[TType]; -export type ProtocolEnvelope = EventEnvelopeByType | RequestEnvelopeByType | ResponseEnvelopeByType; +export type PreviewRequestErrorEnvelopeByType = { + [K in TType]: PreviewRequestErrorEnvelope; +}[TType]; + +export type KnownProtocolEnvelope = + | EventEnvelopeByType + | RequestEnvelopeByType + | ResponseEnvelopeByType + | PreviewRequestErrorEnvelopeByType; + +export type ProtocolEnvelope = KnownProtocolEnvelope; export function createEventEnvelope( type: TType, @@ -209,11 +412,27 @@ export function createResponseEnvelope( + type: TType, + requestId: string, + error: PreviewRequestError, +): PreviewRequestErrorEnvelope { + return { + kind: 'error', + type, + requestId, + error, + }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -function hasEnvelopeShape(value: unknown, kind: ProtocolEnvelope['kind']): value is Record { +function hasPayloadEnvelopeShape( + value: unknown, + kind: 'event' | 'request' | 'response', +): value is Record { return ( isRecord(value) && value.kind === kind && @@ -227,38 +446,91 @@ function isMessageType(value: unknown, acceptedTypes: read return typeof value === 'string' && acceptedTypes.includes(value as TType); } -function isEventEnvelope(value: unknown): value is EventEnvelope { - return hasEnvelopeShape(value, 'event'); +export function isSessionRequestType(value: unknown): value is SessionRequestType { + return isMessageType(value, SESSION_REQUEST_TYPES); } -function isRequestEnvelope(value: unknown): value is RequestEnvelope { - return hasEnvelopeShape(value, 'request'); +export function isPreviewCommandType(value: unknown): value is PreviewCommandType { + return isMessageType(value, PREVIEW_COMMAND_TYPES); } -function isResponseEnvelope(value: unknown): value is ResponseEnvelope { - return hasEnvelopeShape(value, 'response'); +export function isPreviewQueryType(value: unknown): value is PreviewQueryType { + return isMessageType(value, PREVIEW_QUERY_TYPES); } -export function isProtocolEnvelope(value: unknown): value is ProtocolEnvelope { - return isEventEnvelope(value) || isRequestEnvelope(value) || isResponseEnvelope(value); +export function isPreviewRequestType(value: unknown): value is PreviewRequestType { + return isMessageType(value, PREVIEW_REQUEST_TYPES); } -export function isPreviewCommandType(value: unknown): value is PreviewCommandType { - return isMessageType(value, PREVIEW_COMMAND_TYPES); +export function isRequestType(value: unknown): value is RequestType { + return isMessageType(value, REQUEST_TYPES); } -export function isPreviewRequestType(value: unknown): value is PreviewRequestType { - return isMessageType(value, PREVIEW_REQUEST_TYPES); +export function isPreviewResponseType(value: unknown): value is PreviewResponseType { + return isMessageType(value, PREVIEW_RESPONSE_TYPES); +} + +export function isSessionResponseType(value: unknown): value is SessionResponseType { + return isMessageType(value, SESSION_RESPONSE_TYPES); +} + +export function isResponseType(value: unknown): value is ResponseType { + return isMessageType(value, RESPONSE_TYPES); } export function isHostEventType(value: unknown): value is HostEventType { return isMessageType(value, HOST_EVENT_TYPES); } +export function isPreviewRequestErrorCode(value: unknown): value is PreviewRequestErrorCode { + return isMessageType(value, PREVIEW_REQUEST_ERROR_CODES); +} + +export function isEventEnvelope(value: unknown): value is EventEnvelope { + return hasPayloadEnvelopeShape(value, 'event'); +} + +export function isRequestEnvelope(value: unknown): value is RequestEnvelope { + return hasPayloadEnvelopeShape(value, 'request'); +} + +export function isResponseEnvelope(value: unknown): value is ResponseEnvelope { + return hasPayloadEnvelopeShape(value, 'response'); +} + +export function isPreviewRequestErrorEnvelope(value: unknown): value is PreviewRequestErrorEnvelope { + return ( + isRecord(value) && + value.kind === 'error' && + typeof value.type === 'string' && + typeof value.requestId === 'string' && + isRecord(value.error) && + isPreviewRequestErrorCode(value.error.code) && + (!('message' in value.error) || typeof value.error.message === 'string') + ); +} + +export function isAnyProtocolEnvelope(value: unknown): value is AnyProtocolEnvelope { + return ( + isEventEnvelope(value) || + isRequestEnvelope(value) || + isResponseEnvelope(value) || + isPreviewRequestErrorEnvelope(value) + ); +} + export function isHostEventEnvelope(value: unknown): value is EventEnvelopeByType { return isEventEnvelope(value) && isHostEventType(value.type); } +export function isKnownRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isRequestType(value.type); +} + +export function isSessionRequestEnvelope(value: unknown): value is RequestEnvelopeByType { + return isRequestEnvelope(value) && isSessionRequestType(value.type); +} + export function isPreviewCommandRequestEnvelope(value: unknown): value is RequestEnvelopeByType { return isRequestEnvelope(value) && isPreviewCommandType(value.type); } @@ -266,3 +538,26 @@ export function isPreviewCommandRequestEnvelope(value: unknown): value is Reques export function isPreviewRequestEnvelope(value: unknown): value is RequestEnvelopeByType { return isRequestEnvelope(value) && isPreviewRequestType(value.type); } + +export function isKnownResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { + return isResponseEnvelope(value) && isResponseType(value.type); +} + +export function isPreviewResponseEnvelope(value: unknown): value is ResponseEnvelopeByType { + return isResponseEnvelope(value) && isPreviewResponseType(value.type); +} + +export function isKnownPreviewRequestErrorEnvelope( + value: unknown, +): value is PreviewRequestErrorEnvelopeByType { + return isPreviewRequestErrorEnvelope(value) && isPreviewRequestType(value.type); +} + +export function isKnownProtocolEnvelope(value: unknown): value is KnownProtocolEnvelope { + return ( + isHostEventEnvelope(value) || + isKnownRequestEnvelope(value) || + isKnownResponseEnvelope(value) || + (isPreviewRequestErrorEnvelope(value) && isRequestType(value.type)) + ); +} diff --git a/releasenote.md b/releasenote.md index 8b45dea69..8e2afab25 100644 --- a/releasenote.md +++ b/releasenote.md @@ -8,29 +8,35 @@ #### 新功能 -优化编辑器实时预览,提升场景跳转和状态同步的稳定性,并支持调试变量、模板刷新和更多界面预览设置。 +新增流程图功能。玩家可以在菜单或底部控制栏查看已解锁的剧情节点,并跳转回已解锁的场景。 -changeBg / changeFigure / setTransition / setAnimation / setTempAnimation / setTransform 支持 ignoreDefault 参数,可让自定义动画忽略未声明的默认变换和效果。 +流程图支持多条线路、节点解锁记录和未解锁节点显示控制,重置游戏数据时会一并清理流程图进度。 -CG 鉴赏支持按 series 分组并按 order 排序,系列图片可堆叠显示并依次预览。 +优化编辑器实时预览。调整背景、立绘、Spine、Live2D 和特效时,预览定位与实际画面更一致,快速切换预览目标时同步结果更稳定。 -新增 Enable_Continue 配置项,可控制继续游戏按钮是否显示;无自动存档时按钮会置灰,游戏结束时会清理自动存档。 - -新增巴西葡萄牙语和韩语,并将语言设置优化为下拉选择。 +优化读档、回到流程图节点和自动继续时的画面与声音恢复,减少状态不一致。 #### 修复 -修复使用 vocal 参数指定语音时资源路径解析错误的问题。 +修复打开 Backlog 后自动播放仍可能继续推进的问题。 + +修复播放 Backlog 语音时,多个回想语音或当前游戏语音可能同时播放的问题。 + +修复视频播放失败时流程可能卡住,以及 skipOff 视频仍可被双击跳过的问题。 + +修复 next 连续执行中接场景跳转时,后续流程可能失效的问题。 -修复快速预览、滚轮推进和重置舞台后,快进与动画状态可能不一致的问题。 +修复场景跳转目标异常时可能加载无效场景文件的问题。 -修复长场景连续推进时可能发生调用栈溢出的问题。 +修复 bgm:none 无法正确停止背景音乐的问题。 -修复自定义模板样式仍受引擎默认样式干扰的问题。 +修复 changeBg / changeFigure / setTransform 的变换参数为空或格式异常时,动画表现可能不正确的问题。 -修复非官方引擎构建时自身版本号被错误覆盖的问题。 +修复自定义文本框模板中的已读文本样式部分不生效的问题。 -修复自定义模板未包含 game/tex 纹理文件时,内置雨、雪和樱花特效无法显示的问题。 +修复脚本注释包含多个分号时后续内容丢失的问题。 + +修复资源预加载可能重复处理同一资源,或包含无效空路径资源的问题。 ## Release Notes @@ -43,29 +49,35 @@ CG 鉴赏支持按 series 分组并按 order 排序,系列图片可堆叠显 #### New Features -Improved editor live preview with more reliable scene navigation and state synchronization, plus support for debug variables, template refreshes, and additional interface preview settings. - -changeBg / changeFigure / setTransition / setAnimation / setTempAnimation / setTransform now support the ignoreDefault argument, allowing custom animations to ignore undeclared default transforms and effects. +Added the flowchart feature. Players can view unlocked story nodes from the menu or bottom control panel and jump back to unlocked scenes. -The CG gallery now supports grouping by series and sorting by order, with series images displayed as a stack for sequential preview. +Flowcharts support multiple routes, node unlock progress, and locked-node visibility controls; resetting game data now also clears flowchart progress. -Added the Enable_Continue configuration option to control whether the Continue button is shown; it is disabled without an autosave, and autosaves are cleared when the game ends. +Improved editor live preview. When adjusting backgrounds, figures, Spine, Live2D, and effects, preview positioning is closer to the actual screen, and synchronization is more stable when switching preview targets quickly. -Added Brazilian Portuguese and Korean translations, and improved language settings with a dropdown selector. +Improved screen and audio restoration after loading saves, returning to flowchart nodes, or continuing automatically, reducing state mismatches. #### Fixes -Fixed incorrect voice asset path resolution when specifying voice files with the vocal argument. +Fixed autoplay possibly continuing after opening the Backlog. + +Fixed multiple backlog voices, or backlog voice and current game voice, playing at the same time. + +Fixed video playback failures possibly blocking progress, and fixed skipOff videos still being skippable by double-clicking. -Fixed fast-forward and animation states becoming inconsistent after fast preview, mouse-wheel advancement, or stage reset. +Fixed follow-up flow possibly failing when a next chain leads into a scene jump. -Fixed possible call stack overflow when advancing continuously through long scenes. +Fixed abnormal scene jump targets possibly loading invalid scene files. -Fixed custom template styles still being affected by engine default styles. +Fixed bgm:none not stopping background music correctly. -Fixed version numbers of unofficial engine packages being overwritten incorrectly during builds. +Fixed incorrect animation behavior when changeBg / changeFigure / setTransform receive empty or malformed transform arguments. -Fixed built-in rain, snow, and cherry blossom effects not displaying when custom templates do not include the game/tex texture files. +Fixed some read-text styles in custom textbox templates not taking effect. + +Fixed script comments losing content after additional semicolons. + +Fixed resource preloading possibly processing the same resource repeatedly or including invalid empty resource paths. ## リリースノート @@ -78,26 +90,32 @@ Fixed built-in rain, snow, and cherry blossom effects not displaying when custom #### 新機能 -エディターのリアルタイムプレビューを改善し、シーン移動と状態同期の安定性を向上しました。また、デバッグ変数、テンプレート更新、より多くの画面プレビュー設定に対応しました。 +フローチャート機能を追加しました。プレイヤーはメニューまたは下部コントロールから解放済みのストーリーノードを確認し、解放済みのシーンへ戻れるようになります。 -changeBg / changeFigure / setTransition / setAnimation / setTempAnimation / setTransform が ignoreDefault 引数に対応し、カスタムアニメーションで未指定のデフォルト変換やエフェクトを無視できるようになりました。 +フローチャートは複数ルート、ノード解放状態、未解放ノードの表示制御に対応しました。ゲームデータをリセットすると、フローチャートの進行状況も一緒に削除されます。 -CG 鑑賞が series によるグループ化と order による並べ替えに対応し、シリーズ画像を重ねて表示して順番にプレビューできるようになりました。 +エディターのリアルタイムプレビューを改善しました。背景、立ち絵、Spine、Live2D、エフェクトを調整する際、プレビュー上の位置が実際の画面により近くなり、プレビュー対象を素早く切り替えた時の同期も安定しました。 -続きからボタンの表示を制御する Enable_Continue 設定を追加しました。自動セーブがない場合はボタンが無効になり、ゲーム終了時には自動セーブが削除されます。 - -ブラジルポルトガル語と韓国語を追加し、言語設定をドロップダウン選択に改善しました。 +ロード、フローチャートノードへの復帰、自動継続時の画面と音声の復元を改善し、状態のずれを減らしました。 #### 修正 -vocal 引数でボイスを指定した際、アセットパスが正しく解決されない問題を修正しました。 +バックログを開いた後もオート再生が進み続ける場合がある問題を修正しました。 + +バックログ音声が複数同時に再生されたり、ゲーム内の現在のボイスと重なって再生されたりする問題を修正しました。 + +動画の再生に失敗した時に進行が止まる場合がある問題と、skipOff の動画をダブルクリックでスキップできてしまう問題を修正しました。 + +next の連続実行中にシーン移動が続くと、その後の進行が失敗する場合がある問題を修正しました。 + +異常なシーン移動先によって無効なシーンファイルが読み込まれる場合がある問題を修正しました。 -高速プレビュー、マウスホイールによる進行、舞台リセット後に、早送りとアニメーションの状態が一致しない問題を修正しました。 +bgm:none で BGM が正しく停止しない問題を修正しました。 -長いシーンを連続して進めた際に、コールスタックのオーバーフローが発生する場合がある問題を修正しました。 +changeBg / changeFigure / setTransform の変換引数が空、または不正な形式の場合に、アニメーション表示が正しくならない問題を修正しました。 -カスタムテンプレートのスタイルがエンジンのデフォルトスタイルの影響を受ける問題を修正しました。 +カスタムテキストボックステンプレートの既読テキストスタイルが一部反映されない問題を修正しました。 -非公式エンジンのビルド時に拡張パッケージのバージョン番号が誤って上書きされる問題を修正しました。 +スクリプトコメントに複数のセミコロンが含まれると、後続の内容が失われる問題を修正しました。 -カスタムテンプレートに game/tex のテクスチャファイルが含まれていない場合、内蔵の雨、雪、桜エフェクトが表示されない問題を修正しました。 +リソースのプリロードで同じリソースが重複処理されたり、無効な空パスのリソースが含まれたりする問題を修正しました。 diff --git a/yarn.lock b/yarn.lock index 9ef0a5ec1..eb638a574 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,7 +15,7 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.1", "@babel/code-frame@^7.24.2": version "7.24.2" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== @@ -99,7 +99,7 @@ dependencies: "@babel/types" "^7.22.5" -"@babel/helper-module-imports@^7.0.0", "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15": +"@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.22.15": version "7.24.3" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz#6ac476e6d168c7c23ff3ba3cf4f7841d46ac8128" integrity sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg== @@ -249,40 +249,6 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@chevrotain/cst-dts-gen@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz#922ebd8cc59d97241bb01b1b17561a5c1ae0124e" - integrity sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw== - dependencies: - "@chevrotain/gast" "10.5.0" - "@chevrotain/types" "10.5.0" - lodash "4.17.21" - -"@chevrotain/gast@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-10.5.0.tgz#e4e614bc46d17a8892742f38e56cd33f1f3ad162" - integrity sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A== - dependencies: - "@chevrotain/types" "10.5.0" - lodash "4.17.21" - -"@chevrotain/types@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-10.5.0.tgz#52a97d74a8cfbc197f054636d93ecd8912d33d21" - integrity sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A== - -"@chevrotain/utils@10.5.0": - version "10.5.0" - resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-10.5.0.tgz#0ee36f65b49b447fbac71b9e5af5c5c6c98ac057" - integrity sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ== - -"@cspotcode/source-map-support@^0.8.0": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" - integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== - dependencies: - "@jridgewell/trace-mapping" "0.3.9" - "@emotion/babel-plugin@^11.11.0": version "11.11.0" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz#c2d872b6a7767a9d176d007f5b31f7d504bb5d6c" @@ -698,7 +664,7 @@ "@jridgewell/sourcemap-codec" "^1.4.10" "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": +"@jridgewell/resolve-uri@^3.1.0": version "3.1.2" resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== @@ -708,27 +674,11 @@ resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== -"@jridgewell/source-map@^0.3.3": - version "0.3.6" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a" - integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.13", "@jridgewell/sourcemap-codec@^1.4.14": version "1.4.15" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== -"@jridgewell/trace-mapping@0.3.9": - version "0.3.9" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" - integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" @@ -1146,13 +1096,6 @@ is-reference "1.2.1" magic-string "^0.27.0" -"@rollup/plugin-json@^5.0.1": - version "5.0.2" - resolved "https://registry.yarnpkg.com/@rollup/plugin-json/-/plugin-json-5.0.2.tgz#d7dbbac62ff74064876b3e5d0d863cb3ad1e7cdb" - integrity sha512-D1CoOT2wPvadWLhVcmpkDnesTzjhNIQRWLsc3fA49IFOP2Y84cFOOJ+nKGYedvXHKUsPeq07HR4hXpBBr+CHlA== - dependencies: - "@rollup/pluginutils" "^5.0.1" - "@rollup/plugin-node-resolve@^15.0.1": version "15.2.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz#e5e0b059bd85ca57489492f295ce88c2d4b0daf9" @@ -1165,14 +1108,6 @@ is-module "^1.0.0" resolve "^1.22.1" -"@rollup/plugin-typescript@^9.0.2": - version "9.0.2" - resolved "https://registry.yarnpkg.com/@rollup/plugin-typescript/-/plugin-typescript-9.0.2.tgz#c0cdfa39e267f306ff7316405a35406d5821eaa7" - integrity sha512-/sS93vmHUMjzDUsl5scNQr1mUlNE1QjBBvOhmRwJCH8k2RRhDIm3c977B3wdu3t3Ap17W6dDeXP3hj1P1Un1bA== - dependencies: - "@rollup/pluginutils" "^5.0.1" - resolve "^1.22.1" - "@rollup/pluginutils@^4.1.2": version "4.2.1" resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-4.2.1.tgz#e6c6c3aba0744edce3fb2074922d3776c0af2a6d" @@ -1315,26 +1250,6 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz#3afdb30405f6d4248df5e72e1ca86c5eab55fab8" integrity sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w== -"@tsconfig/node10@^1.0.7": - version "1.0.10" - resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.10.tgz#b7ebd3adfa7750628d100594f6726b054d2c33b2" - integrity sha512-PiaIWIoPvO6qm6t114ropMCagj6YAF24j9OkCA2mJDXFnlionEwhsBCJ8yek4aib575BI3OkART/90WsgHgLWw== - -"@tsconfig/node12@^1.0.7": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" - integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== - -"@tsconfig/node14@^1.0.0": - version "1.0.3" - resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" - integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== - -"@tsconfig/node16@^1.0.2": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" - integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== - "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1413,7 +1328,7 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== -"@types/lodash@^4.14.180", "@types/lodash@^4.14.189": +"@types/lodash@^4.14.180": version "4.17.0" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.0.tgz#d774355e41f372d5350a4d0714abb48194a489c3" integrity sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA== @@ -1673,12 +1588,12 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.1.1, acorn-walk@^8.2.0: +acorn-walk@^8.2.0: version "8.3.2" resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.2.tgz#7703af9415f1b6db9315d6895503862e231d34aa" integrity sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A== -acorn@^8.10.0, acorn@^8.11.3, acorn@^8.4.1, acorn@^8.8.1, acorn@^8.8.2: +acorn@^8.10.0, acorn@^8.11.3, acorn@^8.8.1: version "8.11.3" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== @@ -1772,11 +1687,6 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -arg@^4.1.0: - version "4.1.3" - resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" - integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== - argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -2155,18 +2065,6 @@ check-error@^1.0.3: dependencies: get-func-name "^2.0.2" -chevrotain@^10.5.0: - version "10.5.0" - resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-10.5.0.tgz#9c1dc62ef0753bb562dbe521b5f72d041bad624e" - integrity sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A== - dependencies: - "@chevrotain/cst-dts-gen" "10.5.0" - "@chevrotain/gast" "10.5.0" - "@chevrotain/types" "10.5.0" - "@chevrotain/utils" "10.5.0" - lodash "4.17.21" - regexp-to-ast "0.5.0" - "chokidar@>=3.0.0 <4.0.0": version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -2281,7 +2179,7 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^2.18.0, commander@^2.20.0: +commander@^2.18.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -2349,11 +2247,6 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" -create-require@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" - integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== - cross-env@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf" @@ -2509,11 +2402,6 @@ detect-libc@^2.0.0: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== -diff@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" - integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== - diff@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" @@ -2948,11 +2836,6 @@ estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== -estree-walker@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362" - integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w== - estree-walker@^2.0.1, estree-walker@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" @@ -3979,15 +3862,6 @@ iterator.prototype@^1.1.2: reflect.getprototypeof "^1.0.4" set-function-name "^2.0.1" -jest-worker@^26.2.1: - version "26.6.2" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" - integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ== - dependencies: - "@types/node" "*" - merge-stream "^2.0.0" - supports-color "^7.0.0" - js-binary-schema-parser@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz#3d7848748e8586e63b34e8911b643f59cfb6396e" @@ -4163,7 +4037,7 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@4.17.21, lodash@^4.17.14: +lodash@^4.17.14: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4232,11 +4106,6 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" -make-error@^1.1.1: - version "1.3.6" - resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" - integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== - math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -5012,13 +4881,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -5160,11 +5022,6 @@ regenerator-runtime@^0.14.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== -regexp-to-ast@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz#56c73856bee5e1fef7f73a00f1473452ab712a24" - integrity sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw== - regexp.prototype.flags@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" @@ -5238,24 +5095,6 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup-plugin-babel@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/rollup-plugin-babel/-/rollup-plugin-babel-4.4.0.tgz#d15bd259466a9d1accbdb2fe2fff17c52d030acb" - integrity sha512-Lek/TYp1+7g7I+uMfJnnSJ7YWoD58ajo6Oarhlex7lvUce+RCKRuGRSgztDO3/MF/PuGKmUL5iTHKf208UNszw== - dependencies: - "@babel/helper-module-imports" "^7.0.0" - rollup-pluginutils "^2.8.1" - -rollup-plugin-terser@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz#e8fbba4869981b2dc35ae7e8a502d5c6c04d324d" - integrity sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ== - dependencies: - "@babel/code-frame" "^7.10.4" - jest-worker "^26.2.1" - serialize-javascript "^4.0.0" - terser "^5.0.0" - rollup-plugin-typescript2@^0.34.1: version "0.34.1" resolved "https://registry.yarnpkg.com/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.34.1.tgz#c457f155a71d133c142689213fce78694e30d0be" @@ -5277,13 +5116,6 @@ rollup-plugin-visualizer@^5.6.0: source-map "^0.7.4" yargs "^17.5.1" -rollup-pluginutils@^2.8.1: - version "2.8.2" - resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e" - integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== - dependencies: - estree-walker "^0.6.1" - rollup@^3.27.1, rollup@^3.29.5: version "3.29.5" resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" @@ -5349,7 +5181,7 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -5426,13 +5258,6 @@ send@0.19.0: range-parser "~1.2.1" statuses "2.0.1" -serialize-javascript@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" - integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw== - dependencies: - randombytes "^2.1.0" - serve-static@1.16.2: version "1.16.2" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.2.tgz#b6a5343da47f6bdd2673848bf45754941e803296" @@ -5566,7 +5391,7 @@ source-map-js@^1.2.1: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== -source-map-support@^0.5.21, source-map-support@~0.5.20: +source-map-support@^0.5.21: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -5757,7 +5582,7 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -5795,16 +5620,6 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" -terser@^5.0.0: - version "5.29.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.29.2.tgz#c17d573ce1da1b30f21a877bffd5655dd86fdb35" - integrity sha512-ZiGkhUBIM+7LwkNjXYJq8svgkd+QK3UUr0wJqY4MieaezBSAIPgbSPZyIx0idM6XWK5CMzSWa8MJIzmRcB8Caw== - dependencies: - "@jridgewell/source-map" "^0.3.3" - acorn "^8.8.2" - commander "^2.20.0" - source-map-support "~0.5.20" - test-exclude@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" @@ -5876,25 +5691,6 @@ ts-api-utils@^2.4.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== -ts-node@^10.9.1: - version "10.9.2" - resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" - integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== - dependencies: - "@cspotcode/source-map-support" "^0.8.0" - "@tsconfig/node10" "^1.0.7" - "@tsconfig/node12" "^1.0.7" - "@tsconfig/node14" "^1.0.0" - "@tsconfig/node16" "^1.0.2" - acorn "^8.4.1" - acorn-walk "^8.1.1" - arg "^4.1.0" - create-require "^1.1.0" - diff "^4.0.1" - make-error "^1.1.1" - v8-compile-cache-lib "^3.0.1" - yn "3.1.1" - tslib@2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" @@ -6094,11 +5890,6 @@ uuid@^9.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== -v8-compile-cache-lib@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" - integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== - v8-to-istanbul@^9.0.0: version "9.2.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" @@ -6360,11 +6151,6 @@ yargs@^17.5.1: y18n "^5.0.5" yargs-parser "^21.1.1" -yn@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" - integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== - yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"