diff --git a/packages/parser/src/config/scriptConfig.ts b/packages/parser/src/config/scriptConfig.ts index 1e1db77e9..f9c5a3620 100644 --- a/packages/parser/src/config/scriptConfig.ts +++ b/packages/parser/src/config/scriptConfig.ts @@ -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, diff --git a/packages/parser/src/interface/runtimeInterface.ts b/packages/parser/src/interface/runtimeInterface.ts index 981bd5754..841a02c43 100644 --- a/packages/parser/src/interface/runtimeInterface.ts +++ b/packages/parser/src/interface/runtimeInterface.ts @@ -6,6 +6,8 @@ export interface sceneEntry { sceneName: string; // 场景名称 sceneUrl: string; // 场景url continueLine: number; // 继续原场景的行号 + locals?: Record; // 该帧的局部变量 + writeReturnTo?: string; // 返回值写回该帧的哪个变量 } /** diff --git a/packages/parser/src/interface/sceneInterface.ts b/packages/parser/src/interface/sceneInterface.ts index 1b05024c8..260aabf91 100644 --- a/packages/parser/src/interface/sceneInterface.ts +++ b/packages/parser/src/interface/sceneInterface.ts @@ -40,6 +40,7 @@ export enum commandType { applyStyle, wait, callSteam, // 调用Steam功能 + return, // 从被调用的场景返回 } /** diff --git a/packages/webgal/src/Core/Modules/backlog.ts b/packages/webgal/src/Core/Modules/backlog.ts index 349e84829..ca4c44e67 100644 --- a/packages/webgal/src/Core/Modules/backlog.ts +++ b/packages/webgal/src/Core/Modules/backlog.ts @@ -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); diff --git a/packages/webgal/src/Core/Modules/scene.ts b/packages/webgal/src/Core/Modules/scene.ts index 10c4f7bd4..cc217b23c 100644 --- a/packages/webgal/src/Core/Modules/scene.ts +++ b/packages/webgal/src/Core/Modules/scene.ts @@ -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; // 返回值写回该帧的哪个变量 } /** @@ -21,6 +29,7 @@ export const initSceneData = { assetsList: [], // 资源列表 subSceneList: [], // 子场景列表 }, + currentLocals: {}, // 当前帧的局部变量 }; export class SceneManager { @@ -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; + } } diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index fa6debe69..5bda07e97 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -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'; @@ -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; diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index 47d66f0b6..26045c25a 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -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); // 场景写入到运行时 const sceneWritePromise = sceneFetcher(sceneUrl) .then((rawScene) => { @@ -36,6 +40,8 @@ export const callScene = (sceneUrl: string, sceneName: string) => { shouldAutoNext = !isFastPreviewSceneWrite; }) .catch((e) => { + // 场景没写进来,之前压入的帧要弹回去,否则调用方会带着被调用方的局部变量继续跑 + WebGAL.sceneManager.popFrame(); logger.error('场景调用错误', e); }) .finally(() => { diff --git a/packages/webgal/src/Core/controller/scene/returnFromScene.ts b/packages/webgal/src/Core/controller/scene/returnFromScene.ts new file mode 100644 index 000000000..bcf00b7e6 --- /dev/null +++ b/packages/webgal/src/Core/controller/scene/returnFromScene.ts @@ -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 }); + } + restoreScene(entry); +}; diff --git a/packages/webgal/src/Core/controller/scene/sceneInterface.ts b/packages/webgal/src/Core/controller/scene/sceneInterface.ts index 3e29d0ee2..76513a595 100644 --- a/packages/webgal/src/Core/controller/scene/sceneInterface.ts +++ b/packages/webgal/src/Core/controller/scene/sceneInterface.ts @@ -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, // 对话 @@ -40,6 +41,7 @@ export enum commandType { applyStyle, wait, callSteam, // 调用Steam功能 + return, // 从被调用的场景返回 } /** @@ -96,6 +98,7 @@ export interface ISceneData { currentSentenceId: number; // 当前语句ID sceneStack: Array; // 场景栈 currentScene: IScene; // 当前场景数据 + currentLocals: IGameVar; // 当前帧的局部变量 } /** diff --git a/packages/webgal/src/Core/controller/storage/fastSaveLoad.ts b/packages/webgal/src/Core/controller/storage/fastSaveLoad.ts index b666b6ed6..e8538299f 100644 --- a/packages/webgal/src/Core/controller/storage/fastSaveLoad.ts +++ b/packages/webgal/src/Core/controller/storage/fastSaveLoad.ts @@ -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); diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index 2fd69d8ff..b13e94340 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -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(); diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index dd0900b15..090abbf66 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -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(); diff --git a/packages/webgal/src/Core/controller/storage/saveGame.ts b/packages/webgal/src/Core/controller/storage/saveGame.ts index 6e69f07b1..9d67b4589 100644 --- a/packages/webgal/src/Core/controller/storage/saveGame.ts +++ b/packages/webgal/src/Core/controller/storage/saveGame.ts @@ -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); @@ -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, }; diff --git a/packages/webgal/src/Core/gameScripts/callSceneScript.ts b/packages/webgal/src/Core/gameScripts/callSceneScript.ts index e7f9a5848..da4f1a400 100644 --- a/packages/webgal/src/Core/gameScripts/callSceneScript.ts +++ b/packages/webgal/src/Core/gameScripts/callSceneScript.ts @@ -1,7 +1,19 @@ 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 @@ -9,6 +21,15 @@ import { callScene } from '../controller/scene/callScene'; export const callSceneScript = (sentence: ISentence): IPerform => { const sceneNameArray: Array = 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 }); }; diff --git a/packages/webgal/src/Core/gameScripts/returnScript.ts b/packages/webgal/src/Core/gameScripts/returnScript.ts new file mode 100644 index 000000000..af636a2f8 --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/returnScript.ts @@ -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 }); +}; diff --git a/packages/webgal/src/Core/gameScripts/setVar.ts b/packages/webgal/src/Core/gameScripts/setVar.ts index a7ac12ced..18a4b72e3 100644 --- a/packages/webgal/src/Core/gameScripts/setVar.ts +++ b/packages/webgal/src/Core/gameScripts/setVar.ts @@ -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; @@ -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); + } +}; + /** * 设置变量表达式。 */ @@ -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, @@ -73,6 +77,8 @@ export const setVar = (sentence: ISentence): IPerform => { type BaseVal = string | number | boolean | undefined; +const hasOwn = (obj: object, key: string) => Object.prototype.hasOwnProperty.call(obj, key); + export function resolveSetVarValue(valExp: string): string | boolean | number { if (/^\s*[a-zA-Z_$][\w$]*\s*\(.*\)\s*$/.test(valExp)) { return EvaluateExpression(valExp); @@ -131,12 +137,17 @@ 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)) { + // 查找链:当前帧局部变量 -> 舞台变量 -> 全局变量 + // 变量名由脚本作者决定,不能用实例上的 hasOwnProperty,否则 hasOwnProperty 这种名字会把方法本身覆盖掉 + if (hasOwn(locals, key)) { + ret = locals[key]; + } else if (hasOwn(stage.GameVar, key)) { ret = stage.GameVar[key]; - } else if (userData.globalGameVar.hasOwnProperty(key)) { + } else if (hasOwn(userData.globalGameVar, key)) { ret = userData.globalGameVar[key]; } else if (key.startsWith('$')) { const propertyKey = key.replace('$', ''); diff --git a/packages/webgal/src/Core/parser/sceneParser.ts b/packages/webgal/src/Core/parser/sceneParser.ts index 85b3ebea3..2bde9e89e 100644 --- a/packages/webgal/src/Core/parser/sceneParser.ts +++ b/packages/webgal/src/Core/parser/sceneParser.ts @@ -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'; @@ -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); diff --git a/packages/webgal/src/store/userDataInterface.ts b/packages/webgal/src/store/userDataInterface.ts index fcec40710..f5d55bc56 100644 --- a/packages/webgal/src/store/userDataInterface.ts +++ b/packages/webgal/src/store/userDataInterface.ts @@ -58,6 +58,7 @@ export interface ISaveScene { sceneStack: Array; // 场景栈 sceneName: string; // 场景名称 sceneUrl: string; // 场景url + currentLocals?: IGameVar; // 当前帧的局部变量,旧存档没有此字段 } /**