Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/webgal/public/game/config.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ Title_bgm:s_Title.mp3;
Game_Logo:WebGalEnter.webp;
Enable_Appreciation:true;
Enable_Continue:true;
Enable_flowchart:true;
172 changes: 172 additions & 0 deletions packages/webgal/public/game/flowchart.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
]
}
207 changes: 207 additions & 0 deletions packages/webgal/src/Core/Modules/flowchart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { SceneManager } from '@/Core/Modules/scene';
import { stageStateManager } from '@/Core/Modules/stage/stageStateManager';
import { ISaveData } from '@/store/userDataInterface';
import axios from 'axios';
import cloneDeep from 'lodash/cloneDeep';
import localforage from 'localforage';

export interface IFlowchartNodeData extends Record<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>();
private pendingUnlockCurrentScene = false;
private waitingUnlockSceneKey = '';

public constructor(private readonly sceneManager: SceneManager) {}

public async init(gameKey: string, enabled: boolean) {
this.gameKey = gameKey;
this.enabled = enabled;
this.data = { flowcharts: [] };
this.unlocked.clear();
this.snapshots.clear();
if (!enabled || !gameKey) return;
try {
const res = await axios.get('./game/flowchart.json');
this.data = normalizeFlowchartData(res.data);
const unlocked = await localforage.getItem<string[]>(this.progressKey());
this.unlocked = new Set(Array.isArray(unlocked) ? unlocked : []);
this.unlockPendingCurrentScene();
} catch {
this.data = { flowcharts: [] };
}
}

public hasFlowchart() {
return this.enabled && this.data.flowcharts.length > 0;
}

public getFlowcharts() {
return this.data.flowcharts;
}

public getEventName() {
return FLOWCHART_UPDATED_EVENT;
}

public isUnlocked(flowchartId: string, nodeId: string) {
return this.unlocked.has(this.nodeKey(flowchartId, nodeId));
}

public requestUnlockCurrentScene() {
if (this.currentSceneKey() !== this.waitingUnlockSceneKey) return;
this.pendingUnlockCurrentScene = true;
}

public unlockPendingCurrentScene() {
if (!this.pendingUnlockCurrentScene || !this.hasFlowchart()) return;
this.pendingUnlockCurrentScene = false;
this.waitingUnlockSceneKey = '';
this.unlockCurrentScene(true);
}

public waitForCurrentSceneDialog() {
this.pendingUnlockCurrentScene = false;
this.waitingUnlockSceneKey = this.currentSceneKey();
}

public async loadSnapshot(flowchartId: string, nodeId: string) {
const key = this.nodeKey(flowchartId, nodeId);
if (!this.unlocked.has(key)) return null;
const cached = this.snapshots.get(key);
if (cached) return cached;
return await localforage.getItem<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(refreshSnapshot = false) {
if (!this.hasFlowchart()) return;
const sceneNames = new Set([
normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneName),
normalizeSceneName(this.sceneManager.sceneData.currentScene.sceneUrl),
]);
Comment on lines +120 to +125

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

unlockCurrentScene 中,直接访问 this.sceneManager.sceneData.currentScene 可能会在场景尚未加载完成时(例如初始化阶段)由于 currentSceneundefined 而抛出 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),
    ]);

const matched = this.data.flowcharts.flatMap((flowchart) =>
flowchart.nodes
.filter((node) => sceneNames.has(normalizeSceneName(node.data?.sceneName)))
.map((node) => ({ flowchart, node })),
);
if (matched.length === 0) return;
const snapshot = this.createSnapshot();
let changed = false;
matched.forEach(({ flowchart, node }) => {
const key = this.nodeKey(flowchart.id, node.id);
const isUnlocked = this.unlocked.has(key);
if (isUnlocked && !refreshSnapshot) return;
if (!isUnlocked) {
changed = true;
this.unlocked.add(key);
}
this.snapshots.set(key, snapshot);
localforage.setItem(this.snapshotKey(flowchart.id, node.id), snapshot);
});
if (changed) {
localforage.setItem(this.progressKey(), [...this.unlocked]);
window.dispatchEvent(new Event(FLOWCHART_UPDATED_EVENT));
}
}

private createSnapshot(): ISaveData {
return {
nowStageState: cloneDeep(stageStateManager.getViewStageState()),
backlog: [],
index: -1,
saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

createSnapshot 中,toLocaleTimeString('chinese') 使用了非标准的 BCP 47 语言标签 'chinese'。在某些严格的 JS 引擎(如 Node.js 或部分浏览器)中,这会抛出 RangeError: Incorrect locale information provided 错误,导致游戏崩溃。建议将其修改为标准的 'zh-CN''zh'

Suggested change
saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('chinese', { hour12: false }),
saveTime: new Date().toLocaleDateString() + ' ' + new Date().toLocaleTimeString('zh-CN', { 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: '',
};
}
Comment on lines +151 to +165

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

createSnapshot 中,直接访问 this.sceneManager.sceneData.currentScene 可能会在场景未加载时抛出 TypeError。建议使用可选链或空值保护,以确保防御性编程的安全性。

Suggested change
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 progressKey() {
return `${this.gameKey}-flowchart`;
}

private snapshotKey(flowchartId: string, nodeId: string) {
return `${this.gameKey}-flowchart-${flowchartId}-${nodeId}`;
}

private snapshotKeyByNodeKey(key: string) {
return `${this.gameKey}-flowchart-${key}`;
}

private nodeKey(flowchartId: string, nodeId: string) {
return `${flowchartId}-${nodeId}`;
}

private currentSceneKey() {
const { currentScene } = this.sceneManager.sceneData;
return `${normalizeSceneName(currentScene.sceneName)}|${normalizeSceneName(currentScene.sceneUrl)}`;
}
Comment on lines +183 to +186

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

currentSceneKey 中,解构 this.sceneManager.sceneData 中的 currentScene 时,如果 currentScene 为空,后续的属性访问会抛出错误。建议添加空值保护。

  private currentSceneKey() {
    const currentScene = this.sceneManager?.sceneData?.currentScene;
    if (!currentScene) return '';
    return `${normalizeSceneName(currentScene.sceneName)}|${normalizeSceneName(currentScene.sceneUrl)}`;
  }

}

function normalizeFlowchartData(raw: string | IFlowchartData): IFlowchartData {
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
const flowcharts: IFlowchart[] = Array.isArray(data?.flowcharts) ? data.flowcharts : [];
return {
flowcharts: flowcharts.map((flowchart) => ({
...flowchart,
type: flowchart.id === 'main' ? 'main' : flowchart.type || 'character',
nodes: Array.isArray(flowchart.nodes) ? flowchart.nodes : [],
edges: Array.isArray(flowchart.edges) ? flowchart.edges : [],
})),
};
}

function normalizeSceneName(sceneName = '') {
return decodeURI(sceneName)
.replace(/\\/g, '/')
.replace(/^\.?\/?game\/scene\//, '')
.replace(/^\.?\//, '');
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const commitForward = () => {
stageStateManager.commit({ applyPixiEffects: false });
WebGAL.gameplay.performController.commitPendingPerforms();
stageStateManager.applyCommittedPixiEffects();
WebGAL.flowchartManager.unlockPendingCurrentScene();
};

/**
Expand Down
Loading
Loading