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/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/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index 7e68e1970..52fcbd89c 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -81,6 +81,7 @@ export const commitForward = () => { stageStateManager.commit({ applyPixiEffects: false }); WebGAL.gameplay.performController.commitPendingPerforms(); stageStateManager.applyCommittedPixiEffects(); + WebGAL.flowchartManager.unlockPendingCurrentScene(); }; /** diff --git a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts index e826ed97b..5211023fb 100644 --- a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts +++ b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts @@ -23,6 +23,7 @@ export const startGame = () => { // 场景写入到运行时 sceneFetcher(sceneUrl).then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, 'start.txt', sceneUrl); + WebGAL.flowchartManager.waitForCurrentSceneDialog(); // 开始第一条语句 continueSentence(); }); diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index 29dbcecaa..47d66f0b6 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -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; }) diff --git a/packages/webgal/src/Core/controller/scene/changeScene.ts b/packages/webgal/src/Core/controller/scene/changeScene.ts index 0f2a22833..bef8d1e37 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -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; }) 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/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 560139202..ea6e7e747 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -356,6 +356,7 @@ export const startPreviewSyncRuntime = () => { applyComponentVisibility({ showTitle: false, showMenuPanel: false, + showFlowchart: false, isEnterGame: true, showPanicOverlay: false, }); 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/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 a18ae9c8d..596931e5f 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -79,6 +79,7 @@ export const COMPONENT_VISIBILITY_KEYS = [ 'showStarter', 'showTitle', 'showMenuPanel', + 'showFlowchart', 'showTextBox', 'showControls', 'controlsVisibility',