Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/parser/src/config/scriptConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const SCRIPT_CONFIG = [
{ scriptString: 'applyStyle', scriptType: commandType.applyStyle },
{ scriptString: 'wait', scriptType: commandType.wait },
{ scriptString: 'callSteam', scriptType: commandType.callSteam },
{ scriptString: 'return', scriptType: commandType.return },
];
export const ADD_NEXT_ARG_LIST = [
commandType.bgm,
Expand Down
2 changes: 2 additions & 0 deletions packages/parser/src/interface/runtimeInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface sceneEntry {
sceneName: string; // 场景名称
sceneUrl: string; // 场景url
continueLine: number; // 继续原场景的行号
locals?: Record<string, any>; // 该帧的局部变量
writeReturnTo?: string; // 返回值写回该帧的哪个变量
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/parser/src/interface/sceneInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export enum commandType {
applyStyle,
wait,
callSteam, // 调用Steam功能
return, // 从被调用的场景返回
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/webgal/src/Core/Modules/backlog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class BacklogManager {
sceneStack: cloneDeep(this.sceneManager.sceneData.sceneStack), // 场景栈
sceneName: this.sceneManager.sceneData.currentScene.sceneName, // 场景名称
sceneUrl: this.sceneManager.sceneData.currentScene.sceneUrl, // 场景url
currentLocals: cloneDeep(this.sceneManager.sceneData.currentLocals), // 当前帧的局部变量
},
};
this.getBacklog().push(backlogElement);
Expand Down
38 changes: 38 additions & 0 deletions packages/webgal/src/Core/Modules/scene.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { ISceneData } from '@/Core/controller/scene/sceneInterface';
import { IGameVar } from '@/Core/Modules/stage/stageInterface';
import cloneDeep from 'lodash/cloneDeep';

/**
* 场景栈的深度上限,防止 callScene 无限递归
*/
export const MAX_SCENE_STACK_DEPTH = 64;

export interface ISceneEntry {
sceneName: string; // 场景名称
sceneUrl: string; // 场景url
continueLine: number; // 继续原场景的行号
locals?: IGameVar; // 该帧的局部变量
writeReturnTo?: string; // 返回值写回该帧的哪个变量
}

/**
Expand All @@ -21,6 +29,7 @@ export const initSceneData = {
assetsList: [], // 资源列表
subSceneList: [], // 子场景列表
},
currentLocals: {}, // 当前帧的局部变量
};

export class SceneManager {
Expand All @@ -34,8 +43,37 @@ export class SceneManager {
this.sceneData.currentSentenceId = 0;
this.sceneData.sceneStack = [];
this.sceneData.currentScene = cloneDeep(initSceneData.currentScene);
this.sceneData.currentLocals = {};
this.sceneWritePromise = null;
this.settledScenes.clear();
this.settledAssets.clear();
}

/**
* 压入一个调用帧。把当前帧(调用方)存进场景栈,并切换到被调用场景的局部变量。
* 场景栈与 currentLocals 是同一个栈的两截,必须经由本方法与 popFrame 一起变更。
* @param locals 被调用场景的局部变量
* @param writeReturnTo 返回值写回当前帧的哪个变量
*/
public pushFrame(locals: IGameVar, writeReturnTo?: string) {
this.sceneData.sceneStack.push({
sceneName: this.sceneData.currentScene.sceneName,
sceneUrl: this.sceneData.currentScene.sceneUrl,
continueLine: this.sceneData.currentSentenceId,
locals: this.sceneData.currentLocals,
writeReturnTo,
});
this.sceneData.currentLocals = locals;
}

/**
* 弹出一个调用帧,并恢复调用方的局部变量。栈为空时返回 undefined。
*/
public popFrame(): ISceneEntry | undefined {
const entry = this.sceneData.sceneStack.pop();
if (entry) {
this.sceneData.currentLocals = entry.locals ?? {}; // 旧存档的栈条目没有 locals
}
return entry;
}
}
11 changes: 3 additions & 8 deletions packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { commandType, ISentence } from '@/Core/controller/scene/sceneInterface';
import { runScript } from './runScript';
import { logger } from '../../util/logger';
import { restoreScene } from '../scene/restoreScene';
import { returnFromScene } from '../scene/returnFromScene';
import { webgalStore } from '@/store/store';
import { getValueFromStateElseKey } from '@/Core/gameScripts/setVar';
import { strIf } from '@/Core/controller/gamePlay/strIf';
import cloneDeep from 'lodash/cloneDeep';
import { ISceneEntry } from '@/Core/Modules/scene';
import { WebGAL } from '@/Core/WebGAL';
import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg';
import { stageStateManager } from '@/Core/Modules/stage/stageStateManager';
Expand Down Expand Up @@ -61,12 +60,8 @@ export const scriptExecutor = (depth = 0, options: ScriptExecutionOptions = {})
WebGAL.sceneManager.sceneData.currentSentenceId >
WebGAL.sceneManager.sceneData.currentScene.sentenceList.length - 1
) {
if (WebGAL.sceneManager.sceneData.sceneStack.length !== 0 && !WebGAL.sceneManager.lockSceneWrite) {
const sceneToRestore: ISceneEntry | undefined = WebGAL.sceneManager.sceneData.sceneStack.pop();
if (sceneToRestore !== undefined) {
restoreScene(sceneToRestore);
}
}
// 没有 return 语句而自然结束,返回空值
returnFromScene();
return;
}
const sentenceId = WebGAL.sceneManager.sceneData.currentSentenceId;
Expand Down
16 changes: 10 additions & 6 deletions packages/webgal/src/Core/controller/scene/callScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,29 @@ import { continueSentence } from '@/Core/controller/gamePlay/nextSentence';
import { clearPrefetchLinks } from '@/Core/util/prefetcher/assetsPrefetcher';

import { WebGAL } from '@/Core/WebGAL';
import { IGameVar } from '@/Core/Modules/stage/stageInterface';
import { MAX_SCENE_STACK_DEPTH } from '@/Core/Modules/scene';

/**
* 调用场景
* @param sceneUrl 场景路径
* @param sceneName 场景名称
* @param locals 传入被调用场景的局部变量
* @param writeReturnTo 返回值写回本场景的哪个变量
*/
export const callScene = (sceneUrl: string, sceneName: string) => {
export const callScene = (sceneUrl: string, sceneName: string, locals: IGameVar = {}, writeReturnTo?: string) => {
if (WebGAL.sceneManager.lockSceneWrite) {
return;
}
if (WebGAL.sceneManager.sceneData.sceneStack.length >= MAX_SCENE_STACK_DEPTH) {
logger.error(`场景调用层数超过 ${MAX_SCENE_STACK_DEPTH},可能存在 callScene 无限递归`, sceneUrl);
return;
}
WebGAL.sceneManager.lockSceneWrite = true;
const isFastPreviewSceneWrite = WebGAL.gameplay.isFastPreview;
let shouldAutoNext = false;
// 先将本场景压入场景栈
WebGAL.sceneManager.sceneData.sceneStack.push({
sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName,
sceneUrl: WebGAL.sceneManager.sceneData.currentScene.sceneUrl,
continueLine: WebGAL.sceneManager.sceneData.currentSentenceId,
});
WebGAL.sceneManager.pushFrame(locals, writeReturnTo);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Roll back the frame when scene loading fails

When sceneFetcher rejects—for example because a referenced scene is missing or not a .txt file—pushFrame has already replaced currentLocals, but the catch handler only logs the error. The caller therefore resumes with the callee's arguments as its locals, which can shadow its variables and be persisted in later saves; pop the newly pushed frame in the failure path before unlocking.

Useful? React with 👍 / 👎.

// 场景写入到运行时
const sceneWritePromise = sceneFetcher(sceneUrl)
.then((rawScene) => {
Expand Down
24 changes: 24 additions & 0 deletions packages/webgal/src/Core/controller/scene/returnFromScene.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { restoreScene } from './restoreScene';
import { setGameVar } from '@/Core/gameScripts/setVar';

import { WebGAL } from '@/Core/WebGAL';

/**
* 从被调用的场景返回:弹出调用帧,把返回值写回调用方,再恢复调用方场景。
* 场景栈为空(顶层场景)或场景正在写入时不做任何事。
* @param returnValue 返回值,没有 return 语句而自然结束时为空值
*/
export const returnFromScene = (returnValue: string | boolean | number = '') => {
// 必须在弹栈之前判定,否则 restoreScene 提前返回会丢掉这一帧
if (WebGAL.sceneManager.lockSceneWrite) {
return;
}
const entry = WebGAL.sceneManager.popFrame();
if (!entry) {
return;
}
if (entry.writeReturnTo) {
setGameVar({ key: entry.writeReturnTo, value: returnValue });
Comment on lines +20 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Write returns into an existing caller-local target

When a nested caller already has a local with the writeReturnTo name, popFrame() restores that local and this line writes the result only to the stage variable map. Since variable lookup prioritizes currentLocals, subsequent {name} reads still return the caller's old local value and the returned result is inaccessible; update the restored local when that target exists, falling back to normal setVar storage otherwise.

Useful? React with 👍 / 👎.

}
restoreScene(entry);
};
3 changes: 3 additions & 0 deletions packages/webgal/src/Core/controller/scene/sceneInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
import { fileType } from '@/Core/util/gameAssetsAccess/assetSetter';
import { ISceneEntry } from '@/Core/Modules/scene';
import { IGameVar } from '@/Core/Modules/stage/stageInterface';

export enum commandType {
say, // 对话
Expand Down Expand Up @@ -40,6 +41,7 @@ export enum commandType {
applyStyle,
wait,
callSteam, // 调用Steam功能
return, // 从被调用的场景返回
}

/**
Expand Down Expand Up @@ -96,6 +98,7 @@ export interface ISceneData {
currentSentenceId: number; // 当前语句ID
sceneStack: Array<ISceneEntry>; // 场景栈
currentScene: IScene; // 当前场景数据
currentLocals: IGameVar; // 当前帧的局部变量
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/webgal/src/Core/controller/storage/fastSaveLoad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ function dumpFastSaveToStorageSerial() {
*/
export async function fastSaveGame() {
const showTitle = webgalStore.getState().GUI.showTitle;
if (showTitle || WebGAL.sceneManager.sceneData.currentSentenceId === 0) {
// 如果在标题界面或游戏未开始,不进行快速保存
if (showTitle || WebGAL.sceneManager.sceneData.currentSentenceId === 0 || WebGAL.sceneManager.lockSceneWrite) {
// 如果在标题界面、游戏未开始或场景正在写入(此时状态是撕裂的),不进行快速保存
return;
}
const saveData: ISaveData = generateCurrentStageData(-1, false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => {
});
WebGAL.sceneManager.sceneData.currentSentenceId = backlogFile.saveScene.currentSentenceId;
WebGAL.sceneManager.sceneData.sceneStack = cloneDeep(backlogFile.saveScene.sceneStack);
WebGAL.sceneManager.sceneData.currentLocals = cloneDeep(backlogFile.saveScene.currentLocals ?? {}); // 旧存档没有此字段

// 强制停止所有演出
stopAllPerform();
Expand Down
1 change: 1 addition & 0 deletions packages/webgal/src/Core/controller/storage/loadGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function loadGameFromStageData(stageData: ISaveData) {
});
WebGAL.sceneManager.sceneData.currentSentenceId = loadFile.sceneData.currentSentenceId;
WebGAL.sceneManager.sceneData.sceneStack = cloneDeep(loadFile.sceneData.sceneStack);
WebGAL.sceneManager.sceneData.currentLocals = cloneDeep(loadFile.sceneData.currentLocals ?? {}); // 旧存档没有此字段

// 强制停止所有演出
stopAllPerform();
Expand Down
6 changes: 6 additions & 0 deletions packages/webgal/src/Core/controller/storage/saveGame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ import { stageStateManager } from '@/Core/Modules/stage/stageStateManager';
* @param index 游戏的档位
*/
export const saveGame = (index: number) => {
if (WebGAL.sceneManager.lockSceneWrite) {
// 场景写入期间状态是撕裂的:场景栈已变更,但当前场景与语句ID尚未切换
logger.warn('场景切换中,忽略本次存档');
return;
}
const saveData: ISaveData = generateCurrentStageData(index);
webgalStore.dispatch(saveActions.saveGame({ index, saveData }));
dumpSavesToStorage(index, index);
Expand Down Expand Up @@ -54,6 +59,7 @@ export function generateCurrentStageData(index: number, isSavePreviewImage = tru
sceneStack: cloneDeep(WebGAL.sceneManager.sceneData.sceneStack), // 场景栈
sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, // 场景名称
sceneUrl: WebGAL.sceneManager.sceneData.currentScene.sceneUrl, // 场景url
currentLocals: cloneDeep(WebGAL.sceneManager.sceneData.currentLocals), // 当前帧的局部变量
},
previewImage: urlToSave,
};
Expand Down
23 changes: 22 additions & 1 deletion packages/webgal/src/Core/gameScripts/callSceneScript.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
import { ISentence } from '@/Core/controller/scene/sceneInterface';
import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface';
import { IGameVar } from '@/Core/Modules/stage/stageInterface';
import { callScene } from '../controller/scene/callScene';

/**
* 还原参数值的类型。参数经过变量插值后恒为字符串,这里按解析器的规则重新判定。
* @see packages/parser/src/scriptParser/argsParser.ts
*/
const restoreArgValueType = (value: string | boolean | number) => {
if (typeof value !== 'string') return value;
if (value === 'true' || value === 'false') return value === 'true';
if (!isNaN(Number(value))) return Number(value);
return value;
};

/**
* 调用一个场景,在场景结束后回到调用这个场景的父场景。
* @param sentence
*/
export const callSceneScript = (sentence: ISentence): IPerform => {
const sceneNameArray: Array<string> = sentence.content.split('/');
const sceneName = sceneNameArray[sceneNameArray.length - 1];
callScene(sentence.content, sceneName);
// 所有参数原样成为被调用场景的局部变量,包括 when、next 等通用参数,子场景用不用随意
const locals: IGameVar = {};
let writeReturnTo: string | undefined;
sentence.args.forEach(({ key, value }) => {
locals[key] = restoreArgValueType(value);
if (key === 'writeReturnTo' && typeof value === 'string') {
writeReturnTo = value;
}
});
callScene(sentence.content, sceneName, locals, writeReturnTo);
return createNonePerform({ isHoldOn: true });
};
15 changes: 15 additions & 0 deletions packages/webgal/src/Core/gameScripts/returnScript.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ISentence } from '@/Core/controller/scene/sceneInterface';
import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface';
import { returnFromScene } from '@/Core/controller/scene/returnFromScene';
import { resolveSetVarValue } from './setVar';

/**
* 从被调用的场景返回,可携带返回值。返回值在被调用场景的作用域内求值。
* @param sentence
*/
export const returnScript = (sentence: ISentence): IPerform => {
// 不写冒号时(`return;`)解析器会把整条命令留在 content 里,此时视为无返回值
const valExp = sentence.content === sentence.commandRaw ? '' : sentence.content;
returnFromScene(resolveSetVarValue(valExp));
return createNonePerform({ isHoldOn: true });
};
28 changes: 18 additions & 10 deletions packages/webgal/src/Core/gameScripts/setVar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import get from 'lodash/get';
import random from 'lodash/random';
import { getBooleanArgByKey } from '../util/getSentenceArg';
import { stageStateManager } from '@/Core/Modules/stage/stageStateManager';
import { WebGAL } from '@/Core/WebGAL';

interface ISetGameVarFromExpressionPayload {
key: string;
Expand All @@ -19,6 +20,17 @@ interface ISetGameVarFromExpressionPayload {
persistGlobal?: boolean;
}

/**
* 写入游戏变量。setVar 与场景返回值共用这一条写入路径。
*/
export const setGameVar = (payload: ISetGameVar, isGlobal = false) => {
if (isGlobal) {
webgalStore.dispatch(setScriptManagedGlobalVar(payload));
} else {
stageStateManager.setStageVar(payload);
}
};

/**
* 设置变量表达式。
*/
Expand All @@ -28,19 +40,11 @@ export const setGameVarFromExpression = ({
isGlobal = false,
persistGlobal = true,
}: ISetGameVarFromExpressionPayload) => {
const setGameVar = (payload: ISetGameVar) => {
if (isGlobal) {
webgalStore.dispatch(setScriptManagedGlobalVar(payload));
} else {
stageStateManager.setStageVar(payload);
}
};

const normalizedKey = key.trim();
if (!normalizedKey) {
return;
}
setGameVar({ key: normalizedKey, value: resolveSetVarValue(value) });
setGameVar({ key: normalizedKey, value: resolveSetVarValue(value) }, isGlobal);
if (isGlobal) {
logger.debug('设置全局变量:', {
key: normalizedKey,
Expand Down Expand Up @@ -131,10 +135,14 @@ function EvaluateExpression(val: string) {
*/
export function getValueFromState(key: string) {
let ret: any;
const locals = WebGAL.sceneManager.sceneData.currentLocals;
const stage = stageStateManager.getCalculationStageState();
const userData = webgalStore.getState().userData;
const _Merge = { stage, userData }; // 不要直接合并到一起,防止可能的键冲突
if (stage.GameVar.hasOwnProperty(key)) {
// 查找链:当前帧局部变量 -> 舞台变量 -> 全局变量
if (locals.hasOwnProperty(key)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check local ownership without invoking user data

Because every argument name is accepted as a local, a call such as callScene:x.txt -hasOwnProperty=value replaces this method on the plain locals object. Any subsequent interpolation or expression lookup then executes the string as a function and throws; use Object.prototype.hasOwnProperty.call(locals, key) or create a null-prototype map so argument names cannot break lookup.

Useful? React with 👍 / 👎.

ret = locals[key];
} else if (stage.GameVar.hasOwnProperty(key)) {
ret = stage.GameVar[key];
} else if (userData.globalGameVar.hasOwnProperty(key)) {
ret = userData.globalGameVar[key];
Expand Down
2 changes: 2 additions & 0 deletions packages/webgal/src/Core/parser/sceneParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { setTransition } from '@/Core/gameScripts/setTransition';
import { unlockBgm } from '@/Core/gameScripts/unlockBgm';
import { unlockCg } from '@/Core/gameScripts/unlockCg';
import { callSteam } from '@/Core/gameScripts/callSteam';
import { returnScript } from '@/Core/gameScripts/returnScript';
import { end } from '../gameScripts/end';
import { jumpLabel } from '../gameScripts/jumpLabel';
import { pixiInit } from '../gameScripts/pixi/pixiInit';
Expand Down Expand Up @@ -74,6 +75,7 @@ export const SCRIPT_TAG_MAP = defineScripts({
applyStyle: ScriptConfig(commandType.applyStyle, applyStyle, { next: true }),
wait: ScriptConfig(commandType.wait, wait),
callSteam: ScriptConfig(commandType.callSteam, callSteam, { next: true }),
return: ScriptConfig(commandType.return, returnScript),
});

export const SCRIPT_CONFIG: IConfigInterface[] = Object.values(SCRIPT_TAG_MAP);
Expand Down
1 change: 1 addition & 0 deletions packages/webgal/src/store/userDataInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface ISaveScene {
sceneStack: Array<ISceneEntry>; // 场景栈
sceneName: string; // 场景名称
sceneUrl: string; // 场景url
currentLocals?: IGameVar; // 当前帧的局部变量,旧存档没有此字段
}

/**
Expand Down
Loading