From 0fb8f741fd47b0e6f756d89a49f76573b6e9f58e Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 13 Jun 2026 23:50:25 +0800 Subject: [PATCH 1/6] flowchart --- packages/webgal/public/game/config.txt | 1 + packages/webgal/public/game/flowchart.json | 172 +++++++++ packages/webgal/src/App.tsx | 2 + packages/webgal/src/Core/Modules/flowchart.ts | 180 ++++++++++ .../src/Core/controller/scene/callScene.ts | 1 + .../src/Core/controller/scene/changeScene.ts | 1 + packages/webgal/src/Core/initializeScript.ts | 1 + .../util/coreInitialFunction/infoFetcher.ts | 1 + .../util/syncWithEditor/previewSyncRuntime.ts | 1 + packages/webgal/src/Core/webgalCore.ts | 2 + .../BottomControlPanel/BottomControlPanel.tsx | 23 ++ .../BottomControlPanelFilm.tsx | 13 + .../webgal/src/UI/Flowchart/Flowchart.tsx | 325 ++++++++++++++++++ .../src/UI/Flowchart/flowchart.module.scss | 196 +++++++++++ .../src/UI/Menu/MenuPanel/MenuIconMap.tsx | 5 +- .../src/UI/Menu/MenuPanel/MenuPanel.tsx | 16 + .../src/UI/Menu/Options/System/System.tsx | 1 + packages/webgal/src/hooks/useHotkey.tsx | 12 + packages/webgal/src/store/GUIReducer.ts | 1 + packages/webgal/src/store/guiInterface.ts | 1 + packages/webgal/src/translations/de.ts | 13 + packages/webgal/src/translations/en.ts | 13 + packages/webgal/src/translations/fr.ts | 13 + packages/webgal/src/translations/jp.ts | 13 + packages/webgal/src/translations/ko.ts | 13 + packages/webgal/src/translations/pt-br.ts | 13 + packages/webgal/src/translations/zh-cn.ts | 13 + packages/webgal/src/translations/zh-tw.ts | 13 + 28 files changed, 1058 insertions(+), 1 deletion(-) create mode 100644 packages/webgal/public/game/flowchart.json create mode 100644 packages/webgal/src/Core/Modules/flowchart.ts create mode 100644 packages/webgal/src/UI/Flowchart/Flowchart.tsx create mode 100644 packages/webgal/src/UI/Flowchart/flowchart.module.scss 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/App.tsx b/packages/webgal/src/App.tsx index 5b1427abc..70a9dba80 100644 --- a/packages/webgal/src/App.tsx +++ b/packages/webgal/src/App.tsx @@ -5,6 +5,7 @@ import { Stage } from '@/Stage/Stage'; import { BottomControlPanel } from '@/UI/BottomControlPanel/BottomControlPanel'; import { BottomControlPanelFilm } from '@/UI/BottomControlPanel/BottomControlPanelFilm'; import { Backlog } from '@/UI/Backlog/Backlog'; +import { Flowchart } from '@/UI/Flowchart/Flowchart'; import Title from '@/UI/Title/Title'; import Logo from '@/UI/Logo/Logo'; import { Extra } from '@/UI/Extra/Extra'; @@ -24,6 +25,7 @@ export default function App() { + <Logo /> <Extra /> diff --git a/packages/webgal/src/Core/Modules/flowchart.ts b/packages/webgal/src/Core/Modules/flowchart.ts new file mode 100644 index 000000000..bb450a177 --- /dev/null +++ b/packages/webgal/src/Core/Modules/flowchart.ts @@ -0,0 +1,180 @@ +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<string, unknown> { + 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<string>(); + private snapshots = new Map<string, ISaveData>(); + + 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<string[]>(this.progressKey()); + this.unlocked = new Set(Array.isArray(unlocked) ? unlocked : []); + this.unlockCurrentScene(); + } 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 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<ISaveData>(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() { + 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); + if (this.unlocked.has(key)) return; + 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.getCalculationStageState()), + 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}`; + } +} + +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/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index 66ac09e04..bbbe920bd 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.unlockCurrentScene(); 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 7a6c1c981..a9d02fd38 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.unlockCurrentScene(); logger.debug('现在切换场景,切换后的结果:', WebGAL.sceneManager.sceneData); shouldAutoNext = !isFastPreviewSceneWrite; }) diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index f6373f6c9..f323400f5 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.unlockCurrentScene(); }); // 获取游戏信息 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<IGameVar> => { }); 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..cb2c8266f 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -210,6 +210,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..ad8fb8984 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 = () => { /> <span className={styles.button_text}>{t('buttons.backlog')}</span> </span> + {enableFlowchart && ( + <span + className={styles.singleButton} + style={{ fontSize }} + onClick={() => { + setComponentVisibility('showFlowchart', true); + setComponentVisibility('showTextBox', false); + playSeClick(); + }} + onMouseEnter={playSeEnter} + > + <TreeDiagram + className={styles.button} + theme="outline" + size={size} + fill="#f5f5f7" + strokeWidth={strokeWidth} + /> + <span className={styles.button_text}>{t('buttons.flowchart')}</span> + </span> + )} <span className={styles.singleButton} style={{ fontSize }} diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx index c55f2a4e8..ed28b8f17 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx @@ -13,6 +13,7 @@ import { useStageState } from '@/hooks/useStageState'; export const BottomControlPanelFilm = () => { 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 = () => { > <span className={styles.button_text}>剧情回想 / BACKLOG</span> </span> + {enableFlowchart && ( + <span + className={styles.singleButton} + onClick={() => { + setComponentVisibility('showFlowchart', true); + setComponentVisibility('showTextBox', false); + showPanel.set(!showPanel.value); + }} + > + <span className={styles.button_text}>流程图 / FLOWCHART</span> + </span> + )} <span className={styles.singleButton} onClick={() => { diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx new file mode 100644 index 000000000..6dade4311 --- /dev/null +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -0,0 +1,325 @@ +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 { CloseSmall } from '@icon-park/react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import styles from './flowchart.module.scss'; + +const NODE_WIDTH = 190; +const NODE_HEIGHT = 78; +const COL_GAP = 270; +const ROW_GAP = 170; +const MARGIN_X = 90; +const MARGIN_Y = 45; +const CONNECTOR_GAP = 12; + +interface LayoutNode extends IFlowchartNode { + x: number; + y: number; +} + +interface LayoutEdge extends IFlowchartEdge { + sourceNode: LayoutNode; + targetNode: LayoutNode; +} + +interface ConnectorSegment { + id: string; + d: string; + unlocked: boolean; + arrow: boolean; + layerY: number; +} + +export const Flowchart = () => { + const t = useTrans('gaming.flowchart.'); + const { playSeClick, playSeEnter } = useSoundEffect(); + const dispatch = useDispatch(); + const GUIStore = useSelector((state: RootState) => state.GUI); + const isOpen = GUIStore.showFlowchart; + const [indexHide, setIndexHide] = useState(true); + const [currentFlowchartId, setCurrentFlowchartId] = useState(''); + const [version, setVersion] = useState(0); + const timeRef = useRef<ReturnType<typeof setTimeout>>(); + const flowcharts = WebGAL.flowchartManager.getFlowcharts(); + const currentFlowchart = flowcharts.find((e) => e.id === currentFlowchartId) ?? flowcharts[0]; + const layout = useMemo(() => layoutFlowchart(currentFlowchart), [currentFlowchart?.id, 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]); + + useEffect(() => { + if (isOpen) { + if (timeRef.current) clearTimeout(timeRef.current); + setIndexHide(false); + } else { + timeRef.current = setTimeout(() => setIndexHide(true), 780); + } + }, [isOpen]); + + const close = () => { + playSeClick(); + dispatch(setVisibility({ component: 'showFlowchart', visibility: false })); + dispatch(setVisibility({ component: 'showTextBox', visibility: true })); + }; + + const jumpToNode = (node: IFlowchartNode) => { + 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: 'showFlowchart', visibility: false })); + dispatch(setVisibility({ component: 'showTextBox', visibility: true })); + }); + }; + + return ( + <div className={`${isOpen ? styles.Flowchart_main : styles.Flowchart_main_out} ${indexHide ? styles.Flowchart_main_out_IndexHide : ''}`}> + <div className={styles.flowchart_top}> + <CloseSmall + className={styles.flowchart_top_icon} + onClick={close} + onMouseEnter={playSeEnter} + theme="outline" + size="4em" + fill="#ffffff" + strokeWidth={3} + /> + <div className={styles.flowchart_title}>{t('title')}</div> + </div> + {isOpen && ( + <div className={styles.flowchart_body}> + <div className={styles.flowchart_sidebar}> + {flowcharts.map((flowchart) => ( + <button + type="button" + key={flowchart.id} + className={`${styles.flowchart_tab} ${currentFlowchart?.id === flowchart.id ? styles.flowchart_tab_active : ''}`} + onClick={() => { + playSeClick(); + setCurrentFlowchartId(flowchart.id); + }} + onMouseEnter={playSeEnter} + > + {flowchart.name} + </button> + ))} + </div> + <div className={styles.flowchart_content}> + {!currentFlowchart ? ( + <div className={styles.flowchart_empty}>{t('empty')}</div> + ) : ( + <svg + className={styles.flowchart_canvas} + width={layout.width} + height={layout.height} + viewBox={`0 0 ${layout.width} ${layout.height}`} + > + <defs> + <marker + id="flowchart-arrow-unlocked" + viewBox="0 0 10 10" + refX="8" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path className={styles.flowchart_arrow_unlocked} d="M 0 0 L 10 5 L 0 10 z" /> + </marker> + <marker + id="flowchart-arrow-locked" + viewBox="0 0 10 10" + refX="8" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path className={styles.flowchart_arrow_locked} d="M 0 0 L 10 5 L 0 10 z" /> + </marker> + </defs> + {getConnectorSegments(layout.edges, currentFlowchart.id) + .sort((a, b) => b.layerY - a.layerY) + .map((segment) => ( + <path + key={segment.id} + className={segment.unlocked ? styles.flowchart_line_unlocked : styles.flowchart_line_locked} + d={segment.d} + markerEnd={ + segment.arrow ? `url(#${segment.unlocked ? 'flowchart-arrow-unlocked' : 'flowchart-arrow-locked'})` : undefined + } + /> + ))} + {layout.nodes.map((node) => { + const unlocked = WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id); + const labelText = unlocked ? node.data?.label || node.id : t('locked'); + return ( + <g + key={node.id} + className={`${styles.flowchart_node} ${unlocked ? styles.flowchart_node_unlocked : styles.flowchart_node_locked}`} + role={unlocked ? 'button' : undefined} + tabIndex={unlocked ? 0 : -1} + onClick={() => unlocked && jumpToNode(node)} + onKeyDown={(event) => { + if (unlocked && (event.key === 'Enter' || event.key === ' ')) jumpToNode(node); + }} + onMouseEnter={unlocked ? playSeEnter : undefined} + > + <title>{labelText} + + + {truncateText(labelText, 10)} + + + ); + })} + + )} + + + )} + + ); +}; + +function layoutFlowchart(flowchart?: IFlowchart) { + if (!flowchart) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 }; + const nodeMap = new Map(flowchart.nodes.map((node) => [node.id, node])); + const validEdges = flowchart.edges.filter((edge) => nodeMap.has(edge.source) && nodeMap.has(edge.target)); + const incomingCount = new Map(flowchart.nodes.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 = flowchart.nodes.filter((node) => node.data?.isRoot || (incomingCount.get(node.id) ?? 0) === 0); + const queue = (roots.length ? roots : flowchart.nodes.slice(0, 1)).map((node) => node.id); + const restIncomingCount = new Map(incomingCount); + const depthMap = new Map(flowchart.nodes.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()); + flowchart.nodes + .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(); + flowchart.nodes.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 maxCols = Math.max(1, ...layers.map((layer) => layer.length)); + const width = MARGIN_X * 2 + NODE_WIDTH + (maxCols - 1) * COL_GAP; + const layoutNodeMap = new Map(); + layers.forEach((layer, depth) => { + const layerWidth = NODE_WIDTH + (layer.length - 1) * COL_GAP; + layer.forEach((node, index) => { + const parentXs = (parentMap.get(node.id) ?? []).map((id) => layoutNodeMap.get(id)?.x).filter((x): x is number => typeof x === 'number'); + layoutNodeMap.set(node.id, { + ...node, + x: layer.length === 1 && parentXs.length > 0 ? parentXs.reduce((sum, x) => sum + x, 0) / parentXs.length : (width - layerWidth) / 2 + index * COL_GAP, + y: MARGIN_Y + depth * ROW_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 getConnectorSegments(edges: LayoutEdge[], flowchartId: string): ConnectorSegment[] { + const edgeGroups = new Map(); + edges.forEach((edge) => edgeGroups.set(edge.source, [...(edgeGroups.get(edge.source) ?? []), edge])); + return [...edgeGroups.values()].flatMap((groupEdges) => getConnectorSegmentsBySource(groupEdges, flowchartId)); +} + +function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string): ConnectorSegment[] { + if (edges.length === 0) return []; + const source = edges[0].sourceNode; + const sx = source.x + NODE_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 + NODE_WIDTH / 2; + const ty = edge.targetNode.y - CONNECTOR_GAP; + const midY = sy + Math.max(30, (ty - sy) / 2); + return [ + { + id: edge.id, + d: sx === tx ? `M ${sx} ${sy} V ${ty}` : `M ${sx} ${sy} V ${midY} H ${tx} V ${ty}`, + unlocked: sourceUnlocked && WebGAL.flowchartManager.isUnlocked(flowchartId, edge.target), + arrow: true, + layerY: ty, + }, + ]; + } + const targetXs = edges.map((edge) => edge.targetNode.x + NODE_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 + NODE_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 truncateText(text: string | undefined, maxLength: number) { + const safeText = text ?? ''; + return safeText.length > maxLength ? `${safeText.slice(0, maxLength)}...` : safeText; +} 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..c6d16559d --- /dev/null +++ b/packages/webgal/src/UI/Flowchart/flowchart.module.scss @@ -0,0 +1,196 @@ +.Flowchart_main { + font-family: "思源宋体", serif; + position: absolute; + top: 0; + width: 100%; + height: 100%; + z-index: 10; + background: rgba(0, 0, 0, 0.8); + padding: 2em 0; + animation: flowchart_soft_in 0.7s ease-out forwards; + box-sizing: border-box; +} + +.Flowchart_main_out { + @extend .Flowchart_main; + animation: flowchart_soft_out 0.7s ease-out forwards; +} + +.Flowchart_main_out_IndexHide { + z-index: -10; +} + +.flowchart_top { + padding: 0 0 0 1em; + display: flex; + height: 10%; +} + +.flowchart_top_icon { + padding: 0.6em 0.6em 0 0.6em; + border-radius: 1000px; + transform: translate(0, -13px); + cursor: pointer; +} + +.flowchart_top_icon:hover { + background: rgba(255, 255, 255, 0.25); +} + +.flowchart_title { + height: 100%; + line-height: 100%; + font-size: 360%; + font-weight: bold; + color: transparent; + background: linear-gradient(150deg, #fff 0%, #fff 35%, rgba(165, 212, 228, 1) 100%); + -webkit-background-clip: text; +} + +.flowchart_body { + display: flex; + gap: 2.5em; + height: 82%; + padding: 0.5em 5em 1em 5em; + box-sizing: border-box; +} + +.flowchart_sidebar { + width: 18em; + flex-shrink: 0; + overflow: auto; + padding: 0.5em 0; +} + +.flowchart_tab { + display: block; + width: 100%; + margin: 0 0 0.75em 0; + padding: 0.65em 0.8em; + border: none; + border-radius: 7px; + color: rgba(255, 255, 255, 0.78); + background: rgba(255, 255, 255, 0.08); + text-align: left; + font-size: 135%; + cursor: pointer; + transition: background-color 0.25s, color 0.25s; +} + +.flowchart_tab:hover, +.flowchart_tab_active { + color: #fff; + background: rgba(255, 255, 255, 0.22); +} + +.flowchart_content { + flex: 1; + overflow: auto; + padding: 0.5em; + box-sizing: border-box; + display: flex; +} + +.flowchart_empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.7); + font-size: 180%; +} + +.flowchart_canvas { + display: block; + flex: 0 0 auto; + margin: 0 auto; + overflow: visible; +} + +.flowchart_arrow_unlocked { + fill: #f5f5f5; + fill-opacity: 1; +} + +.flowchart_arrow_locked { + fill: #8d8d8d; + 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: #f5f5f5; +} + +.flowchart_line_locked { + stroke: #8d8d8d; +} + +.flowchart_node { + font-family: "资源圆体", serif; + outline: none; +} + +.flowchart_node_unlocked { + cursor: pointer; +} + +.flowchart_node_locked { + opacity: 0.45; + cursor: default; +} + +.flowchart_node_rect { + transition: fill 0.25s, stroke 0.25s; +} + +.flowchart_node_rect_unlocked { + fill: rgba(255, 255, 255, 0.12); + stroke: rgba(255, 255, 255, 0.42); + stroke-width: 1; +} + +.flowchart_node_rect_locked { + fill: rgba(255, 255, 255, 0.08); + stroke: rgba(255, 255, 255, 0.28); + stroke-width: 1; +} + +.flowchart_node_unlocked:hover .flowchart_node_rect_unlocked { + fill: rgba(255, 255, 255, 0.24); + stroke: rgba(255, 255, 255, 0.72); +} + +.flowchart_node_label { + fill: #fff; + font-size: 22px; + font-weight: bold; + pointer-events: none; +} + +@keyframes flowchart_soft_in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes flowchart_soft_out { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} 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..628dda817 100644 --- a/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx +++ b/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx @@ -20,6 +20,7 @@ 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}` : ``; @@ -41,6 +42,21 @@ export const MenuPanel = () => { return (
+ {enableFlowchart && ( + { + playSePageChange(); + dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); + dispatch(setVisibility({ component: 'showFlowchart', visibility: true })); + dispatch(setVisibility({ component: 'showTextBox', visibility: false })); + }} + 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..07d73ef2c 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(() => { 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..24df09b7c 100644 --- a/packages/webgal/src/store/guiInterface.ts +++ b/packages/webgal/src/store/guiInterface.ts @@ -20,6 +20,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: { From f3ee885e2a46c9e206c0e5b376c5c29029323642 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 14 Jun 2026 00:17:26 +0800 Subject: [PATCH 2/6] fix: flowchart ui --- .../webgal/src/UI/Flowchart/Flowchart.tsx | 76 ++++++++++++++----- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx index 6dade4311..e202fed18 100644 --- a/packages/webgal/src/UI/Flowchart/Flowchart.tsx +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -10,9 +10,9 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styles from './flowchart.module.scss'; -const NODE_WIDTH = 190; +const NODE_MIN_WIDTH = 190; const NODE_HEIGHT = 78; -const COL_GAP = 270; +const NODE_GAP = 80; const ROW_GAP = 170; const MARGIN_X = 90; const MARGIN_Y = 45; @@ -21,6 +21,7 @@ const CONNECTOR_GAP = 12; interface LayoutNode extends IFlowchartNode { x: number; y: number; + width: number; } interface LayoutEdge extends IFlowchartEdge { @@ -186,12 +187,12 @@ export const Flowchart = () => { }`} x={node.x} y={node.y} - width={NODE_WIDTH} + width={node.width} height={NODE_HEIGHT} rx="7" /> - {truncateText(labelText, 10)} + {labelText} ); @@ -244,18 +245,31 @@ function layoutFlowchart(flowchart?: IFlowchart) { const layers = [...layerMap.entries()] .sort(([a], [b]) => a - b) .map(([, layer]) => layer); - const maxCols = Math.max(1, ...layers.map((layer) => layer.length)); - const width = MARGIN_X * 2 + NODE_WIDTH + (maxCols - 1) * COL_GAP; + const nodeWidthMap = new Map(flowchart.nodes.map((node) => [node.id, getNodeWidth(node)])); + 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 = NODE_WIDTH + (layer.length - 1) * COL_GAP; - layer.forEach((node, index) => { - const parentXs = (parentMap.get(node.id) ?? []).map((id) => layoutNodeMap.get(id)?.x).filter((x): x is number => typeof x === 'number'); + 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, - x: layer.length === 1 && parentXs.length > 0 ? parentXs.reduce((sum, x) => sum + x, 0) / parentXs.length : (width - layerWidth) / 2 + index * COL_GAP, + 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()]; @@ -265,34 +279,54 @@ function layoutFlowchart(flowchart?: IFlowchart) { return { nodes, edges, width, height: MARGIN_Y * 2 + NODE_HEIGHT + (layers.length - 1) * ROW_GAP }; } +function getNodeWidth(node: IFlowchartNode) { + 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 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)); + 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): ConnectorSegment[] { +function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string, targetBendYMap: Map): ConnectorSegment[] { if (edges.length === 0) return []; const source = edges[0].sourceNode; - const sx = source.x + NODE_WIDTH / 2; + 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 + NODE_WIDTH / 2; + const tx = edge.targetNode.x + edge.targetNode.width / 2; const ty = edge.targetNode.y - CONNECTOR_GAP; - const midY = sy + Math.max(30, (ty - sy) / 2); + 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 ${midY} H ${tx} V ${ty}`, + 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 + NODE_WIDTH / 2); + 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)); @@ -307,7 +341,7 @@ function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string): }, ]; edges.forEach((edge) => { - const tx = edge.targetNode.x + NODE_WIDTH / 2; + const tx = edge.targetNode.x + edge.targetNode.width / 2; segments.push({ id: edge.id, d: `M ${tx} ${busY} V ${edge.targetNode.y - CONNECTOR_GAP}`, @@ -319,7 +353,7 @@ function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string): return segments; } -function truncateText(text: string | undefined, maxLength: number) { - const safeText = text ?? ''; - return safeText.length > maxLength ? `${safeText.slice(0, maxLength)}...` : safeText; +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); } From f5948710232ef9d2d2fd91429ccc037c706222f9 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 14 Jun 2026 11:41:01 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E6=B5=81=E7=A8=8B=E5=9B=BE=E8=A7=A3?= =?UTF-8?q?=E9=94=81=E5=92=8C=E5=86=99=E5=85=A5=E7=8A=B6=E6=80=81=E6=97=B6?= =?UTF-8?q?=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgal/src/Core/Modules/flowchart.ts | 39 ++++++++++++++++--- .../Core/controller/gamePlay/nextSentence.ts | 1 + .../controller/gamePlay/startContinueGame.ts | 1 + .../src/Core/controller/scene/callScene.ts | 2 +- .../src/Core/controller/scene/changeScene.ts | 2 +- packages/webgal/src/Core/gameScripts/say.ts | 1 + packages/webgal/src/Core/initializeScript.ts | 2 +- 7 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/webgal/src/Core/Modules/flowchart.ts b/packages/webgal/src/Core/Modules/flowchart.ts index bb450a177..aece2596e 100644 --- a/packages/webgal/src/Core/Modules/flowchart.ts +++ b/packages/webgal/src/Core/Modules/flowchart.ts @@ -44,6 +44,8 @@ export class FlowchartManager { private data: IFlowchartData = { flowcharts: [] }; private unlocked = new Set(); private snapshots = new Map(); + private pendingUnlockCurrentScene = false; + private waitingUnlockSceneKey = ''; public constructor(private readonly sceneManager: SceneManager) {} @@ -59,7 +61,7 @@ export class FlowchartManager { this.data = normalizeFlowchartData(res.data); const unlocked = await localforage.getItem(this.progressKey()); this.unlocked = new Set(Array.isArray(unlocked) ? unlocked : []); - this.unlockCurrentScene(); + this.unlockPendingCurrentScene(); } catch { this.data = { flowcharts: [] }; } @@ -81,6 +83,23 @@ export class FlowchartManager { 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; @@ -98,7 +117,7 @@ export class FlowchartManager { window.dispatchEvent(new Event(FLOWCHART_UPDATED_EVENT)); } - public unlockCurrentScene() { + public unlockCurrentScene(refreshSnapshot = false) { if (!this.hasFlowchart()) return; const sceneNames = new Set([ normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneName), @@ -114,9 +133,12 @@ export class FlowchartManager { let changed = false; matched.forEach(({ flowchart, node }) => { const key = this.nodeKey(flowchart.id, node.id); - if (this.unlocked.has(key)) return; - changed = true; - this.unlocked.add(key); + 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); }); @@ -128,7 +150,7 @@ export class FlowchartManager { private createSnapshot(): ISaveData { return { - nowStageState: cloneDeep(stageStateManager.getCalculationStageState()), + nowStageState: cloneDeep(stageStateManager.getViewStageState()), backlog: [], index: -1, saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }), @@ -157,6 +179,11 @@ export class FlowchartManager { 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 { diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index ab226a8c9..a1a32df85 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -61,6 +61,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 67ec27c09..8edd4cac6 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(); // 开始第一条语句 nextSentence(); }); diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index bbbe920bd..6f143aa90 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -31,7 +31,7 @@ export const callScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - WebGAL.flowchartManager.unlockCurrentScene(); + 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 a9d02fd38..3f39f4aaf 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -25,7 +25,7 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 - WebGAL.flowchartManager.unlockCurrentScene(); + 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 f323400f5..80af20d46 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -51,7 +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.unlockCurrentScene(); + WebGAL.flowchartManager.waitForCurrentSceneDialog(); }); // 获取游戏信息 infoFetcher('./game/config.txt'); From e325e8ecbada6cad5e0de48a8430c63386da4a6c Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 27 Jun 2026 23:28:15 +0800 Subject: [PATCH 4/6] feat: add flowchart locked node visibility --- .../webgal/src/UI/Flowchart/Flowchart.tsx | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx index e202fed18..6b2ea9579 100644 --- a/packages/webgal/src/UI/Flowchart/Flowchart.tsx +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -37,11 +37,14 @@ interface ConnectorSegment { layerY: number; } +type LockedNodeVisibility = 'all' | 'node' | 'none'; + export const Flowchart = () => { const t = useTrans('gaming.flowchart.'); const { playSeClick, playSeEnter } = useSoundEffect(); const dispatch = useDispatch(); const GUIStore = useSelector((state: RootState) => state.GUI); + const lockedNodeVisibility = useSelector((state: RootState) => normalizeLockedNodeVisibility(state.userData.globalGameVar.Flowchart_Locked_Node_Visibility)); const isOpen = GUIStore.showFlowchart; const [indexHide, setIndexHide] = useState(true); const [currentFlowchartId, setCurrentFlowchartId] = useState(''); @@ -49,7 +52,7 @@ export const Flowchart = () => { const timeRef = useRef>(); const flowcharts = WebGAL.flowchartManager.getFlowcharts(); const currentFlowchart = flowcharts.find((e) => e.id === currentFlowchartId) ?? flowcharts[0]; - const layout = useMemo(() => layoutFlowchart(currentFlowchart), [currentFlowchart?.id, version]); + const layout = useMemo(() => layoutFlowchart(currentFlowchart, lockedNodeVisibility), [currentFlowchart?.id, lockedNodeVisibility, version]); useEffect(() => { const update = () => setVersion((v) => v + 1); @@ -167,7 +170,7 @@ export const Flowchart = () => { ))} {layout.nodes.map((node) => { const unlocked = WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id); - const labelText = unlocked ? node.data?.label || node.id : t('locked'); + const labelText = unlocked || lockedNodeVisibility === 'all' ? node.data?.label || node.id : ''; return ( { }} onMouseEnter={unlocked ? playSeEnter : undefined} > - {labelText} + {labelText || t('locked')} { ); }; -function layoutFlowchart(flowchart?: IFlowchart) { +function layoutFlowchart(flowchart?: IFlowchart, lockedNodeVisibility: LockedNodeVisibility = 'node') { if (!flowchart) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 }; - const nodeMap = new Map(flowchart.nodes.map((node) => [node.id, node])); + 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(flowchart.nodes.map((node) => [node.id, 0])); + const incomingCount = new Map(flowchartNodes.map((node) => [node.id, 0])); const adjacency = new Map(); const parentMap = new Map(); validEdges.forEach((edge) => { @@ -218,10 +226,10 @@ function layoutFlowchart(flowchart?: IFlowchart) { adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge]); parentMap.set(edge.target, [...(parentMap.get(edge.target) ?? []), edge.source]); }); - const roots = flowchart.nodes.filter((node) => node.data?.isRoot || (incomingCount.get(node.id) ?? 0) === 0); - const queue = (roots.length ? roots : flowchart.nodes.slice(0, 1)).map((node) => node.id); + 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(flowchart.nodes.map((node) => [node.id, 0])); + const depthMap = new Map(flowchartNodes.map((node) => [node.id, 0])); const visited = new Set(); while (queue.length > 0) { const sourceId = queue.shift()!; @@ -234,18 +242,18 @@ function layoutFlowchart(flowchart?: IFlowchart) { }); } const maxDepth = Math.max(0, ...depthMap.values()); - flowchart.nodes + 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(); - flowchart.nodes.forEach((node) => { + 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(flowchart.nodes.map((node) => [node.id, getNodeWidth(node)])); + 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(); @@ -279,12 +287,17 @@ function layoutFlowchart(flowchart?: IFlowchart) { return { nodes, edges, width, height: MARGIN_Y * 2 + NODE_HEIGHT + (layers.length - 1) * ROW_GAP }; } -function getNodeWidth(node: IFlowchartNode) { +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(); From 4f885cd020ce9d01746e51b173f029fbefd8706e Mon Sep 17 00:00:00 2001 From: Mahiru Date: Wed, 1 Jul 2026 22:11:25 +0800 Subject: [PATCH 5/6] flowchart style change --- packages/webgal/src/App.tsx | 2 - .../BottomControlPanel/BottomControlPanel.tsx | 4 +- .../BottomControlPanelFilm.tsx | 4 +- .../webgal/src/UI/Flowchart/Flowchart.tsx | 350 +++++++++++------- .../src/UI/Flowchart/flowchart.module.scss | 144 +++---- packages/webgal/src/UI/Menu/Menu.tsx | 4 + .../src/UI/Menu/MenuPanel/MenuPanel.tsx | 13 +- packages/webgal/src/hooks/useHotkey.tsx | 4 +- packages/webgal/src/store/guiInterface.ts | 1 + 9 files changed, 304 insertions(+), 222 deletions(-) diff --git a/packages/webgal/src/App.tsx b/packages/webgal/src/App.tsx index 70a9dba80..5b1427abc 100644 --- a/packages/webgal/src/App.tsx +++ b/packages/webgal/src/App.tsx @@ -5,7 +5,6 @@ import { Stage } from '@/Stage/Stage'; import { BottomControlPanel } from '@/UI/BottomControlPanel/BottomControlPanel'; import { BottomControlPanelFilm } from '@/UI/BottomControlPanel/BottomControlPanelFilm'; import { Backlog } from '@/UI/Backlog/Backlog'; -import { Flowchart } from '@/UI/Flowchart/Flowchart'; import Title from '@/UI/Title/Title'; import Logo from '@/UI/Logo/Logo'; import { Extra } from '@/UI/Extra/Extra'; @@ -25,7 +24,6 @@ export default function App() { - <Logo /> <Extra /> diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx index ad8fb8984..6ab5b2be4 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx @@ -150,8 +150,8 @@ export const BottomControlPanel = () => { className={styles.singleButton} style={{ fontSize }} onClick={() => { - setComponentVisibility('showFlowchart', true); - setComponentVisibility('showTextBox', false); + setMenuPanel(MenuPanelTag.Flowchart); + setComponentVisibility('showMenuPanel', true); playSeClick(); }} onMouseEnter={playSeEnter} diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx index ed28b8f17..f36775f16 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx @@ -49,8 +49,8 @@ export const BottomControlPanelFilm = () => { <span className={styles.singleButton} onClick={() => { - setComponentVisibility('showFlowchart', true); - setComponentVisibility('showTextBox', false); + setMenuPanel(MenuPanelTag.Flowchart); + setComponentVisibility('showMenuPanel', true); showPanel.set(!showPanel.value); }} > diff --git a/packages/webgal/src/UI/Flowchart/Flowchart.tsx b/packages/webgal/src/UI/Flowchart/Flowchart.tsx index 6b2ea9579..2a2217602 100644 --- a/packages/webgal/src/UI/Flowchart/Flowchart.tsx +++ b/packages/webgal/src/UI/Flowchart/Flowchart.tsx @@ -5,8 +5,7 @@ import useSoundEffect from '@/hooks/useSoundEffect'; import useTrans from '@/hooks/useTrans'; import { setVisibility } from '@/store/GUIReducer'; import { RootState } from '@/store/store'; -import { CloseSmall } from '@icon-park/react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { MouseEvent as ReactMouseEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import styles from './flowchart.module.scss'; @@ -43,16 +42,20 @@ export const Flowchart = () => { const t = useTrans('gaming.flowchart.'); const { playSeClick, playSeEnter } = useSoundEffect(); const dispatch = useDispatch(); - const GUIStore = useSelector((state: RootState) => state.GUI); - const lockedNodeVisibility = useSelector((state: RootState) => normalizeLockedNodeVisibility(state.userData.globalGameVar.Flowchart_Locked_Node_Visibility)); - const isOpen = GUIStore.showFlowchart; - const [indexHide, setIndexHide] = useState(true); + const lockedNodeVisibility = useSelector((state: RootState) => + normalizeLockedNodeVisibility(state.userData.globalGameVar.Flowchart_Locked_Node_Visibility), + ); const [currentFlowchartId, setCurrentFlowchartId] = useState(''); const [version, setVersion] = useState(0); - const timeRef = useRef<ReturnType<typeof setTimeout>>(); + 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]); + const layout = useMemo( + () => layoutFlowchart(currentFlowchart, lockedNodeVisibility), + [currentFlowchart?.id, lockedNodeVisibility, version], + ); useEffect(() => { const update = () => setVersion((v) => v + 1); @@ -64,147 +67,189 @@ export const Flowchart = () => { if (!currentFlowchartId && flowcharts[0]) setCurrentFlowchartId(flowcharts[0].id); }, [flowcharts[0]?.id, currentFlowchartId]); - useEffect(() => { - if (isOpen) { - if (timeRef.current) clearTimeout(timeRef.current); - setIndexHide(false); - } else { - timeRef.current = setTimeout(() => setIndexHide(true), 780); - } - }, [isOpen]); - - const close = () => { - playSeClick(); - dispatch(setVisibility({ component: 'showFlowchart', visibility: false })); - dispatch(setVisibility({ component: 'showTextBox', visibility: true })); - }; - 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: 'showFlowchart', visibility: false })); + dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); dispatch(setVisibility({ component: 'showTextBox', visibility: true })); }); }; + const dragFlowchart = (event: ReactMouseEvent<HTMLDivElement>) => { + 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 ( - <div className={`${isOpen ? styles.Flowchart_main : styles.Flowchart_main_out} ${indexHide ? styles.Flowchart_main_out_IndexHide : ''}`}> + <div className={styles.Flowchart_main}> <div className={styles.flowchart_top}> - <CloseSmall - className={styles.flowchart_top_icon} - onClick={close} - onMouseEnter={playSeEnter} - theme="outline" - size="4em" - fill="#ffffff" - strokeWidth={3} - /> - <div className={styles.flowchart_title}>{t('title')}</div> + <div className={styles.flowchart_title}> + <div className={styles.flowchart_title_text}>{t('title')}</div> + </div> </div> - {isOpen && ( - <div className={styles.flowchart_body}> - <div className={styles.flowchart_sidebar}> - {flowcharts.map((flowchart) => ( - <button - type="button" - key={flowchart.id} - className={`${styles.flowchart_tab} ${currentFlowchart?.id === flowchart.id ? styles.flowchart_tab_active : ''}`} - onClick={() => { - playSeClick(); - setCurrentFlowchartId(flowchart.id); - }} - onMouseEnter={playSeEnter} - > - {flowchart.name} - </button> - ))} - </div> - <div className={styles.flowchart_content}> - {!currentFlowchart ? ( - <div className={styles.flowchart_empty}>{t('empty')}</div> - ) : ( - <svg - className={styles.flowchart_canvas} - width={layout.width} - height={layout.height} - viewBox={`0 0 ${layout.width} ${layout.height}`} - > - <defs> - <marker - id="flowchart-arrow-unlocked" - viewBox="0 0 10 10" - refX="8" - refY="5" - markerWidth="5" - markerHeight="5" - orient="auto-start-reverse" - > - <path className={styles.flowchart_arrow_unlocked} d="M 0 0 L 10 5 L 0 10 z" /> - </marker> - <marker - id="flowchart-arrow-locked" - viewBox="0 0 10 10" - refX="8" - refY="5" - markerWidth="5" - markerHeight="5" - orient="auto-start-reverse" + <div className={styles.flowchart_body}> + <div className={styles.flowchart_sidebar}> + {flowcharts.map((flowchart) => ( + <button + type="button" + key={flowchart.id} + className={`${styles.flowchart_tab} ${ + currentFlowchart?.id === flowchart.id ? styles.flowchart_tab_active : '' + }`} + onClick={() => { + playSeClick(); + setCurrentFlowchartId(flowchart.id); + }} + onMouseEnter={playSeEnter} + > + {flowchart.name} + </button> + ))} + </div> + <div + className={`${styles.flowchart_content} ${isDragging ? styles.flowchart_content_dragging : ''}`} + onMouseDown={dragFlowchart} + > + {!currentFlowchart ? ( + <div className={styles.flowchart_empty}>{t('empty')}</div> + ) : ( + <svg + className={styles.flowchart_canvas} + width={layout.width} + height={layout.height} + viewBox={`0 0 ${layout.width} ${layout.height}`} + > + <defs> + <linearGradient id="flowchart-node-unlocked" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stopColor="#ffffff" /> + <stop offset="58%" stopColor="#f8fbfc" /> + <stop offset="100%" stopColor="#e4f0f5" /> + </linearGradient> + <linearGradient id="flowchart-node-locked" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stopColor="#ffffff" /> + <stop offset="100%" stopColor="#f0f5f7" /> + </linearGradient> + <filter id="flowchart-node-glow" x="-20%" y="-35%" width="140%" height="170%"> + <feDropShadow dx="0" dy="5" stdDeviation="5" floodColor="#2B5F75" floodOpacity="0.28" /> + </filter> + <marker + id="flowchart-arrow-unlocked" + viewBox="0 0 10 10" + refX="8" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path className={styles.flowchart_arrow_unlocked} d="M 0 0 L 10 5 L 0 10 z" /> + </marker> + <marker + id="flowchart-arrow-locked" + viewBox="0 0 10 10" + refX="8" + refY="5" + markerWidth="5" + markerHeight="5" + orient="auto-start-reverse" + > + <path className={styles.flowchart_arrow_locked} d="M 0 0 L 10 5 L 0 10 z" /> + </marker> + </defs> + {getConnectorSegments(layout.edges, currentFlowchart.id) + .sort((a, b) => b.layerY - a.layerY) + .map((segment) => ( + <path + key={segment.id} + className={segment.unlocked ? styles.flowchart_line_unlocked : styles.flowchart_line_locked} + d={segment.d} + markerEnd={ + segment.arrow + ? `url(#${segment.unlocked ? 'flowchart-arrow-unlocked' : 'flowchart-arrow-locked'})` + : undefined + } + /> + ))} + {layout.nodes.map((node) => { + const unlocked = WebGAL.flowchartManager.isUnlocked(currentFlowchart.id, node.id); + const labelText = unlocked || lockedNodeVisibility === 'all' ? node.data?.label || node.id : ''; + return ( + <g + key={node.id} + className={`${styles.flowchart_node} ${ + unlocked ? styles.flowchart_node_unlocked : styles.flowchart_node_locked + }`} + role={unlocked ? 'button' : undefined} + tabIndex={unlocked ? 0 : -1} + onClick={() => unlocked && jumpToNode(node)} + onKeyDown={(event) => { + if (unlocked && (event.key === 'Enter' || event.key === ' ')) jumpToNode(node); + }} + onMouseEnter={unlocked ? playSeEnter : undefined} > - <path className={styles.flowchart_arrow_locked} d="M 0 0 L 10 5 L 0 10 z" /> - </marker> - </defs> - {getConnectorSegments(layout.edges, currentFlowchart.id) - .sort((a, b) => b.layerY - a.layerY) - .map((segment) => ( - <path - key={segment.id} - className={segment.unlocked ? styles.flowchart_line_unlocked : styles.flowchart_line_locked} - d={segment.d} - markerEnd={ - segment.arrow ? `url(#${segment.unlocked ? 'flowchart-arrow-unlocked' : 'flowchart-arrow-locked'})` : undefined - } + <title>{labelText || t('locked')} + - ))} - {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} - - - ); - })} - - )} -
+ {labelText} + + + ); + })} + + )} - )} + ); }; @@ -250,11 +295,15 @@ function layoutFlowchart(flowchart?: IFlowchart, lockedNodeVisibility: LockedNod 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 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) => { @@ -282,13 +331,18 @@ function layoutFlowchart(flowchart?: IFlowchart, lockedNodeVisibility: LockedNod }); const nodes = [...layoutNodeMap.values()]; const edges = validEdges - .map((edge) => ({ ...edge, sourceNode: layoutNodeMap.get(edge.source), targetNode: layoutNodeMap.get(edge.target) })) + .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; + 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); @@ -302,7 +356,9 @@ function getConnectorSegments(edges: LayoutEdge[], flowchartId: string): Connect 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)); + return [...edgeGroups.values()].flatMap((groupEdges) => + getConnectorSegmentsBySource(groupEdges, flowchartId, targetBendYMap), + ); } function getTargetBendYMap(edges: LayoutEdge[]) { @@ -318,7 +374,11 @@ function getTargetBendYMap(edges: LayoutEdge[]) { return bendYMap; } -function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string, targetBendYMap: Map): ConnectorSegment[] { +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; @@ -344,7 +404,13 @@ function getConnectorSegmentsBySource(edges: LayoutEdge[], flowchartId: string, 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}-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)}`, diff --git a/packages/webgal/src/UI/Flowchart/flowchart.module.scss b/packages/webgal/src/UI/Flowchart/flowchart.module.scss index c6d16559d..292f68c92 100644 --- a/packages/webgal/src/UI/Flowchart/flowchart.module.scss +++ b/packages/webgal/src/UI/Flowchart/flowchart.module.scss @@ -1,58 +1,48 @@ .Flowchart_main { font-family: "思源宋体", serif; position: absolute; - top: 0; width: 100%; - height: 100%; - z-index: 10; - background: rgba(0, 0, 0, 0.8); - padding: 2em 0; - animation: flowchart_soft_in 0.7s ease-out forwards; - box-sizing: border-box; -} - -.Flowchart_main_out { - @extend .Flowchart_main; - animation: flowchart_soft_out 0.7s ease-out forwards; -} - -.Flowchart_main_out_IndexHide { - z-index: -10; + height: 90%; + cursor: default; } .flowchart_top { - padding: 0 0 0 1em; - display: flex; height: 10%; + width: 100%; + display: flex; + justify-content: center; + position: relative; + animation: flowchart_soft_in 0.7s ease-out forwards; } -.flowchart_top_icon { - padding: 0.6em 0.6em 0 0.6em; - border-radius: 1000px; - transform: translate(0, -13px); - cursor: pointer; -} - -.flowchart_top_icon:hover { - background: rgba(255, 255, 255, 0.25); +.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 { - height: 100%; - line-height: 100%; - font-size: 360%; +.flowchart_title_text { font-weight: bold; color: transparent; - background: linear-gradient(150deg, #fff 0%, #fff 35%, rgba(165, 212, 228, 1) 100%); + 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: 82%; + height: 90%; padding: 0.5em 5em 1em 5em; box-sizing: border-box; + animation: flowchart_soft_in 0.7s ease-out forwards; } .flowchart_sidebar { @@ -63,40 +53,59 @@ } .flowchart_tab { + font-family: "资源圆体", serif; display: block; width: 100%; margin: 0 0 0.75em 0; padding: 0.65em 0.8em; - border: none; - border-radius: 7px; - color: rgba(255, 255, 255, 0.78); - background: rgba(255, 255, 255, 0.08); + 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: background-color 0.25s, color 0.25s; + transition: border-color 0.25s, background-color 0.25s, color 0.25s; } .flowchart_tab:hover, .flowchart_tab_active { - color: #fff; - background: rgba(255, 255, 255, 0.22); + 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: auto; - padding: 0.5em; + 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(255, 255, 255, 0.7); + color: rgba(52, 80, 92, 0.72); font-size: 180%; } @@ -108,12 +117,12 @@ } .flowchart_arrow_unlocked { - fill: #f5f5f5; + fill: #2B5F75; fill-opacity: 1; } .flowchart_arrow_locked { - fill: #8d8d8d; + fill: #a9b7c4; fill-opacity: 1; } @@ -128,11 +137,13 @@ } .flowchart_line_unlocked { - stroke: #f5f5f5; + stroke: #2B5F75; + filter: drop-shadow(0 0 6px rgba(43, 95, 117, 0.28)); } .flowchart_line_locked { - stroke: #8d8d8d; + stroke: #a9b7c4; + stroke-dasharray: 7 8; } .flowchart_node { @@ -145,38 +156,46 @@ } .flowchart_node_locked { - opacity: 0.45; + opacity: 0.62; cursor: default; } .flowchart_node_rect { - transition: fill 0.25s, stroke 0.25s; + transition: fill 0.25s, stroke 0.25s, filter 0.25s; } .flowchart_node_rect_unlocked { - fill: rgba(255, 255, 255, 0.12); - stroke: rgba(255, 255, 255, 0.42); - stroke-width: 1; + fill: url(#flowchart-node-unlocked); + stroke: #2B5F75; + stroke-width: 2; + filter: url(#flowchart-node-glow); } .flowchart_node_rect_locked { - fill: rgba(255, 255, 255, 0.08); - stroke: rgba(255, 255, 255, 0.28); - stroke-width: 1; + fill: url(#flowchart-node-locked); + stroke: rgba(154, 174, 194, 0.8); + stroke-width: 1.5; } .flowchart_node_unlocked:hover .flowchart_node_rect_unlocked { - fill: rgba(255, 255, 255, 0.24); - stroke: rgba(255, 255, 255, 0.72); + stroke: #1f4e63; + filter: drop-shadow(0 0 10px rgba(43, 95, 117, 0.42)); } .flowchart_node_label { - fill: #fff; + 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; @@ -185,12 +204,3 @@ opacity: 1; } } - -@keyframes flowchart_soft_out { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} 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/MenuPanel.tsx b/packages/webgal/src/UI/Menu/MenuPanel/MenuPanel.tsx index 628dda817..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'; @@ -26,12 +25,14 @@ export const MenuPanel = () => { 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)`; @@ -39,19 +40,19 @@ 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(setVisibility({ component: 'showMenuPanel', visibility: false })); - dispatch(setVisibility({ component: 'showFlowchart', visibility: true })); - dispatch(setVisibility({ component: 'showTextBox', visibility: false })); + dispatch(setMenuPanelTag(MenuPanelTag.Flowchart)); }} tagName={t('flowchart.title')} key="flowchartButton" diff --git a/packages/webgal/src/hooks/useHotkey.tsx b/packages/webgal/src/hooks/useHotkey.tsx index 07d73ef2c..e47e6cf34 100644 --- a/packages/webgal/src/hooks/useHotkey.tsx +++ b/packages/webgal/src/hooks/useHotkey.tsx @@ -297,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/guiInterface.ts b/packages/webgal/src/store/guiInterface.ts index 24df09b7c..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, // “流程图”选项卡 } /** From 1b54b33814c522ddddef218c3a8a32f40fe2d528 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 4 Jul 2026 11:53:25 +0800 Subject: [PATCH 6/6] fix: comp type --- packages/webgal/src/types/editorPreviewProtocol.ts | 1 + 1 file changed, 1 insertion(+) 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',