Flowchart#996
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a comprehensive Flowchart feature to the WebGAL engine, allowing players to visualize game branches and jump to unlocked nodes. It includes the core FlowchartManager logic, integration with gameplay controllers, UI components for rendering the flowchart SVG, and localization across multiple languages. The review feedback highlights several critical and medium-severity issues to address before merging: resolving a potential crash from using the non-standard 'chinese' locale in toLocaleTimeString, adding defensive null checks to prevent TypeErrors when accessing currentScene or globalGameVar during initialization, clearing a setTimeout in Flowchart.tsx to prevent memory leaks, and fixing edge cases in the flowchart layout algorithm that could lead to negative SVG heights or clipped node coordinates.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| nowStageState: cloneDeep(stageStateManager.getViewStageState()), | ||
| backlog: [], | ||
| index: -1, | ||
| saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }), |
There was a problem hiding this comment.
在 createSnapshot 中,toLocaleTimeString('chinese') 使用了非标准的 BCP 47 语言标签 'chinese'。在某些严格的 JS 引擎(如 Node.js 或部分浏览器)中,这会抛出 RangeError: Incorrect locale information provided 错误,导致游戏崩溃。建议将其修改为标准的 'zh-CN' 或 'zh'。
| saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }), | |
| saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('zh-CN', { hour12: false }), |
| public unlockCurrentScene(refreshSnapshot = false) { | ||
| if (!this.hasFlowchart()) return; | ||
| const sceneNames = new Set([ | ||
| normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneName), | ||
| normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneUrl), | ||
| ]); |
There was a problem hiding this comment.
在 unlockCurrentScene 中,直接访问 this.sceneManager.sceneData.currentScene 可能会在场景尚未加载完成时(例如初始化阶段)由于 currentScene 为 undefined 而抛出 TypeError。建议添加安全的空值检查。
public unlockCurrentScene(refreshSnapshot = false) {
if (!this.hasFlowchart()) return;
const currentScene = this.sceneManager?.sceneData?.currentScene;
if (!currentScene) return;
const sceneNames = new Set([
normalizeSceneName(currentScene.sceneName),
normalizeSceneName(currentScene.sceneUrl),
]);| private createSnapshot(): ISaveData { | ||
| return { | ||
| nowStageState: cloneDeep(stageStateManager.getViewStageState()), | ||
| backlog: [], | ||
| index: -1, | ||
| saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }), | ||
| sceneData: { | ||
| currentSentenceId: this.sceneManager.sceneData.currentSentenceId, | ||
| sceneStack: cloneDeep(this.sceneManager.sceneData.sceneStack), | ||
| sceneName: this.sceneManager.sceneData.currentScene.sceneName, | ||
| sceneUrl: this.sceneManager.sceneData.currentScene.sceneUrl, | ||
| }, | ||
| previewImage: '', | ||
| }; | ||
| } |
There was a problem hiding this comment.
在 createSnapshot 中,直接访问 this.sceneManager.sceneData.currentScene 可能会在场景未加载时抛出 TypeError。建议使用可选链或空值保护,以确保防御性编程的安全性。
| private createSnapshot(): ISaveData { | |
| return { | |
| nowStageState: cloneDeep(stageStateManager.getViewStageState()), | |
| backlog: [], | |
| index: -1, | |
| saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }), | |
| sceneData: { | |
| currentSentenceId: this.sceneManager.sceneData.currentSentenceId, | |
| sceneStack: cloneDeep(this.sceneManager.sceneData.sceneStack), | |
| sceneName: this.sceneManager.sceneData.currentScene.sceneName, | |
| sceneUrl: this.sceneManager.sceneData.currentScene.sceneUrl, | |
| }, | |
| previewImage: '', | |
| }; | |
| } | |
| private createSnapshot(): ISaveData { | |
| const currentScene = this.sceneManager?.sceneData?.currentScene; | |
| return { | |
| nowStageState: cloneDeep(stageStateManager.getViewStageState()), | |
| backlog: [], | |
| index: -1, | |
| saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('zh-CN', { hour12: false }), | |
| sceneData: { | |
| currentSentenceId: this.sceneManager.sceneData.currentSentenceId, | |
| sceneStack: cloneDeep(this.sceneManager.sceneData.sceneStack), | |
| sceneName: currentScene?.sceneName ?? '', | |
| sceneUrl: currentScene?.sceneUrl ?? '', | |
| }, | |
| previewImage: '', | |
| }; | |
| } |
| private currentSceneKey() { | ||
| const { currentScene } = this.sceneManager.sceneData; | ||
| return `${normalizeSceneName(currentScene.sceneName)}|${normalizeSceneName(currentScene.sceneUrl)}`; | ||
| } |
There was a problem hiding this comment.
在 currentSceneKey 中,解构 this.sceneManager.sceneData 中的 currentScene 时,如果 currentScene 为空,后续的属性访问会抛出错误。建议添加空值保护。
private currentSceneKey() {
const currentScene = this.sceneManager?.sceneData?.currentScene;
if (!currentScene) return '';
return `${normalizeSceneName(currentScene.sceneName)}|${normalizeSceneName(currentScene.sceneUrl)}`;
}| useEffect(() => { | ||
| if (isOpen) { | ||
| if (timeRef.current) clearTimeout(timeRef.current); | ||
| setIndexHide(false); | ||
| } else { | ||
| timeRef.current = setTimeout(() => setIndexHide(true), 780); | ||
| } | ||
| }, [isOpen]); |
There was a problem hiding this comment.
在 useEffect 中设置了 setTimeout,但没有在组件卸载(unmount)时清除该定时器。如果组件在定时器触发前被卸载,会导致内存泄漏以及在未挂载组件上更新状态的 React 警告。建议在 useEffect 的清除函数中调用 clearTimeout。
useEffect(() => {
if (isOpen) {
if (timeRef.current) clearTimeout(timeRef.current);
setIndexHide(false);
} else {
timeRef.current = setTimeout(() => setIndexHide(true), 780);
}
return () => {
if (timeRef.current) clearTimeout(timeRef.current);
};
}, [isOpen]);
| }; | ||
|
|
||
| function layoutFlowchart(flowchart?: IFlowchart) { | ||
| if (!flowchart) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 }; |
There was a problem hiding this comment.
在 layoutFlowchart 中,如果传入的 flowchart 没有节点(nodes 为空),计算出的 height 可能会变成负数(例如 MARGIN_Y * 2 + NODE_HEIGHT - ROW_GAP),这会导致 SVG 渲染错误。建议在函数开头增加对空节点的安全拦截。
function layoutFlowchart(flowchart?: IFlowchart) {
if (!flowchart || !flowchart.nodes || flowchart.nodes.length === 0) return { nodes: [] as LayoutNode[], edges: [] as LayoutEdge[], width: 0, height: 0 };
| x: | ||
| layer.length === 1 && parentXs.length > 0 | ||
| ? parentXs.reduce((sum, parentX) => sum + parentX, 0) / parentXs.length - nodeWidth / 2 | ||
| : x, |
There was a problem hiding this comment.
当单节点图层根据父节点位置动态居中时,如果父节点位置偏左且节点宽度较大,计算出的 x 坐标可能会变成负数,导致节点在 SVG 左侧被裁剪。建议对 x 坐标进行边界限制,确保其在 MARGIN_X 和 width - MARGIN_X - nodeWidth 之间。
| x: | |
| layer.length === 1 && parentXs.length > 0 | |
| ? parentXs.reduce((sum, parentX) => sum + parentX, 0) / parentXs.length - nodeWidth / 2 | |
| : x, | |
| x: | |
| layer.length === 1 && parentXs.length > 0 | |
| ? Math.max(MARGIN_X, Math.min(width - MARGIN_X - nodeWidth, parentXs.reduce((sum, parentX) => sum + parentX, 0) / parentXs.length - nodeWidth / 2)) | |
| : x, |
| } | ||
| 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); |
There was a problem hiding this comment.
在从 Redux store 中获取 Enable_flowchart 时,如果 globalGameVar 尚未初始化或为 undefined,直接访问其属性会抛出错误。建议使用可选链 ?. 进行安全访问。
| const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); | |
| const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar?.Enable_flowchart === true); |
| export const BottomControlPanelFilm = () => { | ||
| const showPanel = useValue(false); | ||
| const stageState = useStageState(); | ||
| const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); |
There was a problem hiding this comment.
在获取 Enable_flowchart 时,建议使用可选链 ?. 访问 globalGameVar 的属性,以防止在未初始化时抛出错误。
| const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); | |
| const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar?.Enable_flowchart === true); |
|
|
||
| const { playSeClick, playSeDialogOpen, playSePageChange } = useSoundEffect(); | ||
| const GUIState = useSelector((state: RootState) => state.GUI); | ||
| const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); |
There was a problem hiding this comment.
在获取 Enable_flowchart 时,建议使用可选链 ?. 访问 globalGameVar 的属性,以防止在未初始化时抛出错误。
| const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar.Enable_flowchart === true); | |
| const enableFlowchart = useSelector((state: RootState) => state.userData.globalGameVar?.Enable_flowchart === true); |
Deploying webgal-dev with
|
| Latest commit: |
e325e8e
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f578eebe.webgal-dev.pages.dev |
| Branch Preview URL: | https://flowchart.webgal-dev.pages.dev |
为引擎添加流程图功能。
总得来说,就是类似于柚子社游戏,有一个流程图,并在推进章节时记录关键节点以供回溯,至少要包含以下功能:
1、流程图注册、展示
2、经过节点时记录状态,解锁流程图节点
3、分阶段展示流程图
4、支持个人线。