diff --git a/packages/parser/src/config/scriptConfig.ts b/packages/parser/src/config/scriptConfig.ts index 1e1db77e9..eee477795 100644 --- a/packages/parser/src/config/scriptConfig.ts +++ b/packages/parser/src/config/scriptConfig.ts @@ -1,4 +1,4 @@ -import {commandType} from '../interface/sceneInterface'; +import { commandType } from '../interface/sceneInterface'; export const SCRIPT_CONFIG = [ { scriptString: 'say', scriptType: commandType.say }, @@ -31,7 +31,10 @@ export const SCRIPT_CONFIG = [ { scriptString: 'setTextbox', scriptType: commandType.setTextbox }, { scriptString: 'setAnimation', scriptType: commandType.setAnimation }, { scriptString: 'playEffect', scriptType: commandType.playEffect }, - { scriptString: 'setTempAnimation', scriptType: commandType.setTempAnimation }, + { + scriptString: 'setTempAnimation', + scriptType: commandType.setTempAnimation, + }, // comment? { scriptString: 'setTransform', scriptType: commandType.setTransform }, { scriptString: 'setTransition', scriptType: commandType.setTransition }, @@ -39,6 +42,8 @@ export const SCRIPT_CONFIG = [ { scriptString: 'applyStyle', scriptType: commandType.applyStyle }, { scriptString: 'wait', scriptType: commandType.wait }, { scriptString: 'callSteam', scriptType: commandType.callSteam }, + { scriptString: 'createIframe', scriptType: commandType.createIframe }, + { scriptString: 'removeIframe', scriptType: commandType.removeIframe }, ]; export const ADD_NEXT_ARG_LIST = [ commandType.bgm, diff --git a/packages/parser/src/interface/sceneInterface.ts b/packages/parser/src/interface/sceneInterface.ts index 1b05024c8..937bc8dfc 100644 --- a/packages/parser/src/interface/sceneInterface.ts +++ b/packages/parser/src/interface/sceneInterface.ts @@ -40,6 +40,8 @@ export enum commandType { applyStyle, wait, callSteam, // 调用Steam功能 + createIframe, // 创建框架 + removeIframe, // 移除框架 } /** diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index 1ba79e854..8378a48fc 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -6,7 +6,6 @@ import { baseTransform } from '@/Core/Modules/stage/stageInterface'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; import { WebGAL } from '@/Core/WebGAL'; import PixiStage, { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; -import { IUserAnimation } from './animations'; import { pickBy } from 'lodash'; import { DEFAULT_BG_IN_DURATION, diff --git a/packages/webgal/src/Core/Modules/events.ts b/packages/webgal/src/Core/Modules/events.ts index 1d27b1af7..eb4b34961 100644 --- a/packages/webgal/src/Core/Modules/events.ts +++ b/packages/webgal/src/Core/Modules/events.ts @@ -12,6 +12,9 @@ export class Events { public fullscreenDbClick = formEvent('fullscreen-dbclick'); public styleUpdate = formEvent('style-update'); public afterStyleUpdate = formEvent('after-style-update'); + public save = formEvent('save'); + public load = formEvent('load'); + public sceneChange = formEvent('scene-change'); } const eventBus = mitt(); diff --git a/packages/webgal/src/Core/Modules/stage/stageInterface.ts b/packages/webgal/src/Core/Modules/stage/stageInterface.ts index 4e8cd25f5..7b90804f6 100644 --- a/packages/webgal/src/Core/Modules/stage/stageInterface.ts +++ b/packages/webgal/src/Core/Modules/stage/stageInterface.ts @@ -1,5 +1,20 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { BlinkParam, FocusParam } from '@/Core/live2DCore'; +import { CSSProperties } from 'react'; + +export interface IIFrame { + id: string; + src: string; + sandbox: string; + width?: string; + height?: string; + style: CSSProperties; + isActive?: boolean; + wait?: boolean; // 是否等待iframe完成 + returnValue?: string | null; // iframe返回值的变量名 + persistentData?: Record; // iframe的持久化数据 + injectArgs: Record; // iframe注入的参数,将挂载在window.webgal.params中 +} /** * 游戏内变量 @@ -253,6 +268,7 @@ export interface IStageState { isDisableTextbox: boolean; replacedUIlable: Record; figureMetaData: figureMetaData; + iframes: IIFrame[]; } /** diff --git a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts index c71b5eb55..e9e16c0a5 100644 --- a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts +++ b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts @@ -8,6 +8,7 @@ import { IEffect, IFigureMetadata, IFreeFigure, + IIFrame, ILive2DBlink, ILive2DExpression, ILive2DFocus, @@ -78,6 +79,7 @@ export const initState: IStageState = { isDisableTextbox: false, replacedUIlable: {}, figureMetaData: {}, + iframes: [], }; /** @@ -344,7 +346,9 @@ export class StageStateManager { } public clearUncommittedNonHoldPerforms() { - this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter((perform) => perform.isHoldOn); + this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter( + (perform) => perform.isHoldOn, + ); } public removeNonHoldPerformsAndCommit() { @@ -352,6 +356,41 @@ export class StageStateManager { this.commit(); } + public addIframe(payload: IIFrame) { + payload.isActive = true; + const existing = this.calculationStageState.iframes.findIndex((e) => e.id === payload.id); + if (existing > -1) { + this.calculationStageState.iframes[existing] = payload; + } else { + this.calculationStageState.iframes.push(payload); + } + this.commit(); + } + + public removeIframe(payload: { id: string; save?: boolean }) { + if (payload.save) { + this.calculationStageState.iframes = this.calculationStageState.iframes.map((e) => + e.id === payload.id ? { ...e, isActive: false } : e, + ); + } else { + this.calculationStageState.iframes = this.calculationStageState.iframes.filter((e) => e.id !== payload.id); + } + this.commit(); + } + + public resetIframe() { + this.calculationStageState.iframes = []; + this.commit(); + } + + public updateIframePersistentData(payload: { id: string; persistentData: Record }) { + const iframe = this.calculationStageState.iframes.find((e) => e.id === payload.id); + if (iframe) { + iframe.persistentData = { ...iframe.persistentData, ...payload.persistentData }; + } + this.commit(); + } + public commit(options: IStageCommitOptions = {}) { const resolvedOptions: IResolvedStageCommitOptions = { syncPixiStage: options.syncPixiStage ?? true, diff --git a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts index 575ae7d41..9f89d7390 100644 --- a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts +++ b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts @@ -33,6 +33,17 @@ export const stopAuto = () => { } }; +/** + * 开始自动播放 + */ +export const startAuto = () => { + if (WebGAL.gameplay.autoInterval !== null) { + return; + } + WebGAL.gameplay.isAuto = true; + WebGAL.gameplay.autoInterval = setInterval(autoPlay, 100); +}; + /** * 切换自动播放状态 */ @@ -42,8 +53,7 @@ export const switchAuto = () => { stopAuto(); } else { // 当前不在自动播放 - WebGAL.gameplay.isAuto = true; - WebGAL.gameplay.autoInterval = setInterval(autoPlay, 100); + startAuto(); } }; diff --git a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts index 67ec27c09..5e343365d 100644 --- a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts +++ b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts @@ -34,9 +34,20 @@ export async function continueGame() { * 重设模糊背景 */ setEbg(stageStateManager.getViewStageState().bgName); - if ((await hasFastSaveRecord())) { + if (await hasFastSaveRecord()) { webgalStore.dispatch(setVisibility({ component: 'showTitle', visibility: false })); // 恢复记录 await loadFastSaveGame(); + return; + } + stageStateManager.resetIframe(); + if ( + WebGAL.sceneManager.sceneData.currentSentenceId === 0 && + WebGAL.sceneManager.sceneData.currentScene.sceneName === 'start.txt' + ) { + // 如果游戏没有开始,开始游戏 + nextSentence(); + } else { + restorePerform(); } } diff --git a/packages/webgal/src/Core/controller/scene/sceneInterface.ts b/packages/webgal/src/Core/controller/scene/sceneInterface.ts index 3e29d0ee2..54a766fb2 100644 --- a/packages/webgal/src/Core/controller/scene/sceneInterface.ts +++ b/packages/webgal/src/Core/controller/scene/sceneInterface.ts @@ -40,6 +40,8 @@ export enum commandType { applyStyle, wait, callSteam, // 调用Steam功能 + createIframe, // 创建框架 + removeIframe, // 移除框架 } /** diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index 7b9af34c9..6810ec3df 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -28,4 +28,7 @@ export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { if (!resetSceneAndVar) { stageStateManager.setStageAndCommit('GameVar', currentVars); } + + // 清空frames + stageStateManager.resetIframe(); }; diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index f94290989..3def1f269 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -23,6 +23,7 @@ export const loadGame = (index: number) => { logger.debug('读取的存档数据', loadFile); // 加载存档 loadGameFromStageData(loadFile); + WebGAL.events.load.emit(index); }; export function loadGameFromStageData(stageData: ISaveData) { @@ -45,6 +46,8 @@ export function loadGameFromStageData(stageData: ISaveData) { // 强制停止所有演出 stopAllPerform(); + // 清空frames + stageStateManager.resetIframe(); // 恢复backlog const newBacklog = loadFile.backlog; @@ -55,10 +58,21 @@ export function loadGameFromStageData(stageData: ISaveData) { // 恢复舞台状态 const newStageState = cloneDeep(loadFile.nowStageState); + // 保存iframes的持久化数据 + const iframePersistentData = new Map>(); + newStageState.iframes.forEach((iframe) => { + if (iframe.persistentData) { + iframePersistentData.set(iframe.id, iframe.persistentData); + } + }); + // iframes将被指令创建,我们不需要使用存档中的iframes + newStageState.iframes = []; // 确保原先未读的文本在 load 时能正确显示为已读文本 newStageState.isRead = true; const dispatch = webgalStore.dispatch; stageStateManager.replaceCalculationStageState(newStageState); + // 将持久化数据存储到全局变量中,供后续创建iframe时使用 + (window as any).__iframePersistentData = iframePersistentData; // 恢复演出 setTimeout(restorePerform, 0); diff --git a/packages/webgal/src/Core/controller/storage/saveGame.ts b/packages/webgal/src/Core/controller/storage/saveGame.ts index 6e69f07b1..badff78eb 100644 --- a/packages/webgal/src/Core/controller/storage/saveGame.ts +++ b/packages/webgal/src/Core/controller/storage/saveGame.ts @@ -18,6 +18,7 @@ export const saveGame = (index: number) => { const saveData: ISaveData = generateCurrentStageData(index); webgalStore.dispatch(saveActions.saveGame({ index, saveData })); dumpSavesToStorage(index, index); + WebGAL.events.save.emit(index); }; /** diff --git a/packages/webgal/src/Core/gameScripts/SCRIPT_AUTHORING.md b/packages/webgal/src/Core/gameScripts/SCRIPT_AUTHORING.md deleted file mode 100644 index cf69aef44..000000000 --- a/packages/webgal/src/Core/gameScripts/SCRIPT_AUTHORING.md +++ /dev/null @@ -1,156 +0,0 @@ -# GameScript 实现指南 - -这里的 gameScript 指 `packages/webgal/src/Core/gameScripts` 下的内核命令实现。它不是给游戏作者看的脚本语法文档,而是给内核命令维护者看的执行模型说明。 - -核心原则:命令函数负责推进可恢复的演算状态,`IPerform` 负责 commit 后的运行时演出。不要把这两件事混在一起。 - -## 一条命令做什么 - -一个命令实现接收 `ISentence`,返回 `IPerform`。 - -```ts -export function someCommand(sentence: ISentence): IPerform { - // 1. 解析 sentence.content / sentence.args - // 2. 修改 calculationStageState 中可恢复、可被后续命令读取的状态 - // 3. 返回 perform,让 commit 后的运行时层启动或清理演出 -} -``` - -命令注册入口在 `Core/parser/sceneParser.ts`。没有运行时演出的命令应返回 `createNonePerform()`。 - -## 执行顺序 - -用户正常步进时,主流程是: - -1. `preForward()` 检查当前正在运行的 perform 是否阻塞下一步。 -2. `forward()` 清理未提交的临时 perform,开始收集本轮 perform。 -3. `scriptExecutor()` 执行当前句;如果有 `-next`,会在同一轮 `forward()` 内继续执行后续句。 -4. 命令函数只修改 `calculationStageState`,并把返回的 perform 放进 pending 列表。 -5. `commitForward()` 调用 `stageStateManager.commit({ applyPixiEffects: false })`,把演算态提交成 `viewStageState`。 -6. stage commit handler 同步 Pixi/React/audio 等视图对象。 -7. `performController.commitPendingPerforms()` 启动 pending perform 的 `startFunction`。 -8. `stageStateManager.applyCommittedPixiEffects()` 把提交后的 `effects` 应用到未被动画锁定的 Pixi 对象。 - -因此,命令函数里不要主动 `commit()`。命令可能运行在 `-next` 链、快速预览、回放恢复、跳转恢复等流程里,提前 commit 会破坏统一提交点。 - -## 两种状态 - -`calculationStageState` 是脚本执行期间的权威状态。后续命令、条件判断、快速预览都会从这里继续算。 - -`viewStageState` 是提交后的视图状态。React、Pixi 同步层和运行时演出应基于提交后的状态工作。 - -如果后续命令需要读取某个结果,这个结果必须在命令函数阶段写进 `calculationStageState`,或者在特殊的 pending discard 结算钩子里补写。不要只写在 `startFunction`、Pixi loader 回调、动画结束回调里;这些代码在快速预览历史行里可能根本不会执行。 - -## IPerform 生命周期 - -`IPerform` 有三个常见状态: - -1. pending:命令函数已经返回,但本轮还没有 commit,`startFunction` 还没执行。 -2. running:commit 后 `startFunction` 已执行,perform 在 `performList` 中等待自然结束或手动卸载。 -3. discarded:pending perform 在 commit 前被丢弃,不会进入 running。 - -字段职责: - -- `performName`:用于去重、保存和卸载。目标相关演出应使用稳定前缀,例如 `animation-${target}`。 -- `duration`:非 hold 演出的自动回收时间。 -- `isHoldOn`:是否为保持型演出。保持型演出会留在状态中,直到显式卸载。 -- `startFunction`:只做运行时动作,例如注册 Pixi 动画、播放媒体、挂载 UI。它只在 commit 后执行。 -- `stopFunction`:清理已经启动的运行时动作。它只应该假设 `startFunction` 已经执行过。 -- `blockingNext`:是否阻塞用户下一步。 -- `blockingAuto`:是否阻塞自动播放。 -- `blockingStateCalculation`:是否阻塞继续演算后续状态。只有需要外部输入才能确定后续状态时才使用,例如选项和用户输入。 -- `settleStateOnDiscard`:pending perform 被“结算式丢弃”时的补偿钩子。它不是正常生命周期,不在 commit、start、stop、自然结束时执行。 - -## settleStateOnDiscard - -`settleStateOnDiscard` 只解决一个窄问题:某条历史命令的 pending perform 在 commit 前被跳过,但它的最终状态又必须影响后续演算。 - -当前触发点是 `forward()` 开头: - -```ts -performController.discardUncommittedNonHoldPerforms(WebGAL.gameplay.isFastPreview); -``` - -也就是说,只有调用方传入 `settleDiscardedState = true` 时,被丢弃的非 hold pending perform 才会执行 `settleStateOnDiscard`。目前这个 true 只用于实时预览快进。普通 discard 不会执行这个钩子。 - -实现要求: - -- 必须同步执行,不能等待 loader、timer、动画帧或网络。 -- 必须幂等,重复调用不能把状态越写越偏。 -- 只写 `calculationStageState` 中可恢复、可被后续命令依赖的状态。 -- 不要操作 Pixi 对象、DOM、音频实例、ticker。 -- 不要调用 `commit()`。 - -什么时候需要实现: - -- 命令返回一个非 hold perform。 -- 命令的最终状态没有在命令函数阶段直接写入。 -- 这个最终状态对后续命令有意义。 -- 如果在命令函数阶段直接写入终态,会破坏当前行的正常视觉表现。 - -什么时候不需要实现: - -- 命令已经在函数阶段写好了后续命令需要的状态,例如普通 `setAnimation`、`setTransform`。 -- 命令只产生一次性声音、日志、UI 提示,后续演算不依赖它。 -- perform 是 hold,并且本来就应该保留到后续状态里。 - -## 快速预览为什么特殊 - -实时预览快进会连续调用 `forward()`,中间不 commit,只在到达目标位置后提交一次。 - -这会带来一个差异:前一轮 `forward()` 收集到的非 hold pending perform,在下一轮 `forward()` 开头会被丢弃。被丢弃的 perform 不会执行: - -- `startFunction` -- `stopFunction` -- Pixi 注册动画后的结束回调 -- `setTimeout` 自动卸载逻辑 - -所以,如果某个命令把终态延迟到了这些阶段,快速预览历史行就会丢状态。 - -这次 `changeFigure -transform` 的问题就是这个类型: - -1. `changeFigure` 创建新立绘,并把 transform 做成进入动画。 -2. 正常播放时,进入动画由 Pixi sync 注册,终态会在动画结束或 preset 结算时写入 `effects`。 -3. 快速预览时,这条 enter perform 作为历史行被丢弃,没有机会注册进入动画。 -4. 后续 `setAnimation -parallel` 读取不到 figure 的 position,只能从默认 transform 开始算。 -5. 因此 `changeFigure` 需要在 `settleStateOnDiscard` 中把进入动画终态补写到 `calculationStageState.effects`。 - -## 动画命令 - -动画命令要区分两件事: - -- 演算终态:后续命令、存档、恢复、快速预览要读取的状态。 -- 运行时动画:当前画面上逐帧播放的效果。 - -`setAnimation`、`setTransform`、`setTempAnimation` 这类命令通常应该在命令函数阶段调用 `applyAnimationEndState()` 或等价逻辑,把终态写入 `calculationStageState.effects`。运行时动画再由 returned perform 的 `startFunction` 注册。 - -`-parallel` 下只能写动画实际控制的字段。例如只改 `scale` 的并行动画不应该把 `position` 重置成默认值。生成 timeline 或写终态时要使用局部字段合并,而不是完整覆盖目标 transform。 - -新背景、新立绘的进入动画是特殊情况。当前行正常播放时不能无条件提前写入目标 `effects`,因为 `registerPresetAnimation()` 会把已有 effect 解释为目标已经结算,于是直接应用终态并跳过进入动画。它们应该在正常路径交给 Pixi preset 动画结算,在快速预览历史行被丢弃时再通过 `settleStateOnDiscard` 补写终态。 - -## 参数和资源 - -参数解析使用 `getStringArgByKey`、`getNumberArgByKey`、`getBooleanArgByKey` 等工具。注意区分参数缺省和显式传入 `false`。 - -资源路径使用 `assetSetter()` 处理,不要在命令里手写资源目录拼接。 - -JSON 参数必须 try/catch。解析失败时应回退到旧语义或安全默认值,不能让脚本执行器抛出异常中断整个 `forward()`。 - -## 状态更新原则 - -只把可恢复、可存档、可被后续命令依赖的内容写入 stage state。临时 DOM、Pixi ticker、timer、音频实例、loader 中间状态都不应该写进 stage state。 - -直接访问 `WebGAL.gameplay.pixiStage` 的代码尽量放在 `startFunction`、`stopFunction` 或 stage sync 层。命令函数阶段如果必须访问 Pixi,只能用于不会决定可恢复状态的标记或清理。 - -修改已有目标时,先判断 URL、id、target 是否真的变化。资源未变化时应保留旧 transform、Live2D 参数、metadata 等状态,只更新显式传入的字段。 - -## 检查清单 - -- 后续命令需要读取的状态是否已经写入 `calculationStageState`? -- perform 在快速预览历史行中被丢弃时,最终状态是否仍然正确? -- `settleStateOnDiscard` 是否只处理 pending discard 补偿,没有混入正常生命周期逻辑? -- `startFunction` 是否只依赖已 commit 的状态? -- `stopFunction` 是否只清理已经启动过的运行时动作? -- `-next`、`-continue`、`-parallel`、`-keep` 下状态是否一致? -- 并行动画是否只写自己控制的 transform 字段? -- 没有运行时演出的命令是否使用了 `createNonePerform()`? diff --git a/packages/webgal/src/Core/gameScripts/choose/index.tsx b/packages/webgal/src/Core/gameScripts/choose/index.tsx index c803a8507..727d4e3c6 100644 --- a/packages/webgal/src/Core/gameScripts/choose/index.tsx +++ b/packages/webgal/src/Core/gameScripts/choose/index.tsx @@ -139,7 +139,11 @@ function Choose(props: { chooseOptions: ChooseOption[] }) { } : () => {}; return ( -
+
{e.text}
diff --git a/packages/webgal/src/Core/gameScripts/createIframe.ts b/packages/webgal/src/Core/gameScripts/createIframe.ts new file mode 100644 index 000000000..31e724bac --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/createIframe.ts @@ -0,0 +1,163 @@ +import { ISentence } from '@/Core/controller/scene/sceneInterface'; +import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { getBooleanArgByKey, getStringArgByKey } from '../util/getSentenceArg'; +import { CSSProperties } from 'react'; +import { IIFrame } from '../Modules/stage/stageInterface'; +import { stageStateManager } from '../Modules/stage/stageStateManager'; + +const SAME_ORIGIN = window.location.origin; + +const allSandboxProperties = { + 'allow-forms': 'allowForms', // 允许iframe内提交表单 + 'allow-scripts': 'allowScripts', // 允许iframe内执行JavaScript脚本(包括定时器、事件等) + 'allow-same-origin': 'allowSameOrigin', // 允许iframe内容拥有同源身份,可访问自身Cookie/LocalStorage等 + 'allow-top-navigation': 'allowTopNavigation', // 允许iframe内的链接跳转到父页面(主页面)的上下文 + 'allow-popups': 'allowPopups', // 允许iframe通过window.open()等方式弹出新窗口 + 'allow-modals': 'allowModals', // 允许iframe弹出模态窗口(如alert()、confirm()、prompt()) + 'allow-pointer-lock': 'allowPointerLock', // 允许iframe使用Pointer Lock API(如游戏鼠标锁定) + 'allow-popups-to-escape-sandbox': 'allowPopupsToEscapeSandbox', // 允许iframe弹出的新窗口不受当前沙箱限制 + 'allow-downloads': 'allowDownloads', // 允许iframe内触发文件下载操作 + 'allow-presentation': 'allowPresentation', // 允许iframe使用Presentation API(投屏/演示功能) + 'allow-top-navigation-by-user-activation': 'allowTopNavigationByUserActivation', // 仅允许用户主动触发(如点击)的顶级导航操作 + 'allow-storage-access-by-user-activation': 'allowStorageAccessByUserActivation', // 允许用户主动触发后访问父页面的存储权限 + 'allow-orientation-lock': 'allowOrientationLock', // 允许iframe使用Screen Orientation API锁定屏幕方向 +}; + +/** + * 创建框架 + * @param sentence + */ +export const createIframe = (sentence: ISentence): IPerform => { + const src = sentence.content; + const id = getStringArgByKey(sentence, 'id') ?? ''; + const wait = getBooleanArgByKey(sentence, 'wait') ?? false; + const hidden = getBooleanArgByKey(sentence, 'hidden') ?? false; + const returnValue = getStringArgByKey(sentence, 'returnValue') ?? null; + const width = getStringArgByKey(sentence, 'width') ?? undefined; + const height = getStringArgByKey(sentence, 'height') ?? undefined; + if (!id || !src) { + return { + performName: 'none', + duration: 0, + isHoldOn: false, + stopFunction: () => {}, + blockingNext: () => false, + blockingAuto: () => true, + }; + } + + // 可能后续会增加更多的样式,所以我们先定义一个空对象 + // 还没想好如何优雅的使用指令定义样式,所以暂时使用已知参数定义样式 + let styleCSSProperties: CSSProperties = {}; + if (hidden) { + styleCSSProperties.display = 'none'; + styleCSSProperties.opacity = 0; + } + + let rawSrc = src; + if ( + !rawSrc.startsWith('http://') && + !rawSrc.startsWith('https://') && + !rawSrc.startsWith('about:') && + !rawSrc.startsWith('data:') + ) { + rawSrc = './game/' + rawSrc; + } + + // 查询所有参数(以@开头) + const args = sentence.args; + const injectArgs: Record = {}; + args.forEach((arg) => { + if (arg.key.startsWith('@')) { + injectArgs[arg.key.slice(1)] = arg.value; + } + }); + + const frameData: IIFrame = { + id, + src: rawSrc, + sandbox: '', + width, + height, + isActive: true, + wait, + returnValue, + injectArgs, + style: styleCSSProperties, + }; + + for (const [key, value] of Object.entries(allSandboxProperties)) { + const v = getStringArgByKey(sentence, value) ?? ''; + if (v) { + frameData.sandbox += key + ' '; + } + } + + stageStateManager.addIframe(frameData); + + // 如果该id的iframe已存在,先移除旧监听器(通过标记避免重复注册) + const listenerKey = `__webgal-iframe-listener-${id}`; + if (!(window as any)[listenerKey]) { + (window as any)[listenerKey] = true; + } else { + // 已有同id的监听器在运行,先清理 + window.removeEventListener('message', (window as any)[`__webgal-iframe-handler-${id}`]); + } + + // 如果需要等待iframe完成,则返回阻塞的perform + if (wait) { + let isCompleted = false; + // 监听iframe完成消息 + const handleFrameComplete = (event: MessageEvent) => { + if (event.origin !== SAME_ORIGIN) return; + if ( + event.data && + typeof event.data === 'object' && + event.data.type === 'webgal-frame-complete' && + event.data.frameId === id && + !isCompleted + ) { + isCompleted = true; + // 移除事件监听器 + window.removeEventListener('message', handleFrameComplete); + delete (window as any)[`__webgal-iframe-handler-${id}`]; + delete (window as any)[listenerKey]; + // 如果有returnValue,则存储到游戏变量中 + if (returnValue && event.data.returnValue !== undefined) { + stageStateManager.setStageVar({ + key: returnValue, + value: event.data.returnValue, + }); + } + } + }; + + // 保存handler引用,便于去重清理 + (window as any)[`__webgal-iframe-handler-${id}`] = handleFrameComplete; + + // 添加事件监听器 + window.addEventListener('message', handleFrameComplete); + + return { + performName: `frame-wait-${id}`, + duration: 0, + isHoldOn: true, + stopFunction: () => { + window.removeEventListener('message', handleFrameComplete); + delete (window as any)[`__webgal-iframe-handler-${id}`]; + delete (window as any)[listenerKey]; + }, + blockingNext: () => !isCompleted, + blockingAuto: () => !isCompleted, + }; + } + + return { + performName: 'none', + duration: 0, + isHoldOn: false, + stopFunction: () => {}, + blockingNext: () => false, + blockingAuto: () => false, + }; +}; diff --git a/packages/webgal/src/Core/gameScripts/removeIframe.ts b/packages/webgal/src/Core/gameScripts/removeIframe.ts new file mode 100644 index 000000000..b547bb682 --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/removeIframe.ts @@ -0,0 +1,34 @@ +import { ISentence } from '@/Core/controller/scene/sceneInterface'; +import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { getBooleanArgByKey } from '../util/getSentenceArg'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; + +/** + * 移除框架 + * @param sentence + */ +export const removeIframe = (sentence: ISentence): IPerform => { + const id = sentence.content; + const hideOnly = getBooleanArgByKey(sentence, 'save') ?? false; + if (!id) { + return { + performName: 'none', + duration: 0, + isHoldOn: false, + stopFunction: () => {}, + blockingNext: () => false, + blockingAuto: () => true, + }; + } + + stageStateManager.removeIframe({ id, save: hideOnly }); + + return { + performName: 'none', + duration: 0, + isHoldOn: false, + stopFunction: () => {}, + blockingNext: () => false, + blockingAuto: () => true, + }; +}; diff --git a/packages/webgal/src/Core/parser/sceneParser.ts b/packages/webgal/src/Core/parser/sceneParser.ts index 85b3ebea3..a3bf33200 100644 --- a/packages/webgal/src/Core/parser/sceneParser.ts +++ b/packages/webgal/src/Core/parser/sceneParser.ts @@ -28,6 +28,8 @@ 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 { createIframe } from '@/Core/gameScripts/createIframe'; +import { removeIframe } from '@/Core/gameScripts/removeIframe'; import { end } from '../gameScripts/end'; import { jumpLabel } from '../gameScripts/jumpLabel'; import { pixiInit } from '../gameScripts/pixi/pixiInit'; @@ -74,6 +76,8 @@ 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 }), + createIframe: ScriptConfig(commandType.createIframe, createIframe), + removeIframe: ScriptConfig(commandType.removeIframe, removeIframe), }); export const SCRIPT_CONFIG: IConfigInterface[] = Object.values(SCRIPT_TAG_MAP); diff --git a/packages/webgal/src/Stage/Iframe/Iframe.tsx b/packages/webgal/src/Stage/Iframe/Iframe.tsx new file mode 100644 index 000000000..29ac2b7c2 --- /dev/null +++ b/packages/webgal/src/Stage/Iframe/Iframe.tsx @@ -0,0 +1,371 @@ +import { useEffect, useMemo, useRef, useCallback, SyntheticEvent } from 'react'; +import { webgalStore } from '@/store/store'; +import { IWatchers, IWebGALBridge, WebGalAPIEventsKeyNames } from './interface'; +import { setScriptManagedGlobalVar } from '@/store/userDataReducer'; +import { WebGAL } from '@/Core/WebGAL'; +import { nextSentence as nextSentenceController } from '@/Core/controller/gamePlay/nextSentence'; +import { IIFrame } from '@/Core/Modules/stage/stageInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { useStageState } from '@/hooks/useStageState'; +import { stopAuto, startAuto } from '@/Core/controller/gamePlay/autoPlay'; +import { stopFast, startFast } from '@/Core/controller/gamePlay/fastSkip'; +import { playBgm } from '@/Core/controller/stage/playBgm'; +import { jumpToLabel } from '@/Core/gameScripts/label/jumpToLabel'; +import { logger } from '@/Core/util/logger'; +import { IShowGlobalDialogProps, showGlogalDialog } from '@/UI/GlobalDialog/GlobalDialog'; + +const SAME_ORIGIN = window.location.origin; + +// 验证sandbox属性:默认仅允许脚本执行,除非脚本显式指定同源权限 +function getSandboxAttr(sandbox: string): string { + const trimmed = sandbox?.trim() || ''; + if (trimmed === '') { + return 'allow-scripts'; + } + return trimmed; +} + +export default function Iframe({ id, sandbox, src, width, height, wait, injectArgs, style }: IIFrame) { + const idString = `iframe-${id}`; + const iframeRef = useRef(null); + const stage = useStageState(); + + useEffect(() => { + const globalPersistentData = (window as any).__iframePersistentData; + if (globalPersistentData?.has(id)) { + const persistentData = globalPersistentData.get(id); + stageStateManager.updateIframePersistentData({ id, persistentData }); + globalPersistentData.delete(id); + } + }, [id]); + + const stageRef = useRef(stage); + stageRef.current = stage; + + const gameVarWatchersRef = useRef(null); + const prevGameVarRef = useRef>({}); + + const eventsMap = useMemo( + () => ({ + save: WebGAL.events.save, + load: WebGAL.events.load, + nextSentence: WebGAL.events.userInteractNext, + }), + [], + ); + + const apiRef = useRef(null); + if (!apiRef.current) { + const api: IWebGALBridge = Object.create(null); + + // variable: game variables, scene, stage, audio, iframe + const gameVarWatchers: IWatchers = {}; + gameVarWatchersRef.current = gameVarWatchers; + + // flow: navigation and playback controls + api.flow = { + next: () => nextSentenceController(), + autoOn: () => startAuto(), + autoOff: () => stopAuto(), + fastSkipOn: () => startFast(), + fastSkipOff: () => stopFast(), + showDialog: (props: IShowGlobalDialogProps) => showGlogalDialog(props), + }; + + api.variable = { + get: (key: string) => { + const val = stageRef.current.GameVar[key]; + return val !== undefined ? val : null; + }, + set: (key: string, value: string | number | boolean, options?: { global?: boolean }) => { + if (options?.global) { + webgalStore.dispatch(setScriptManagedGlobalVar({ key, value })); + } else { + stageStateManager.setStageVarAndCommit({ key, value }); + } + }, + onChange: (key: string, callback: (newValue: any) => void) => { + if (!gameVarWatchers[key]) { + gameVarWatchers[key] = []; + } + const watcher = { callback }; + gameVarWatchers[key].push(watcher); + return () => { + const index = gameVarWatchers[key].indexOf(watcher); + if (index > -1) gameVarWatchers[key].splice(index, 1); + }; + }, + }; + + api.scene = { + jumpTo: (sceneUrl: string, label?: string) => { + if (label) { + jumpToLabel(label); + } + }, + }; + + api.stage = { + getBackground: () => stageRef.current.bgName, + getFigures: () => ({ + left: stageRef.current.figNameLeft, + center: stageRef.current.figName, + right: stageRef.current.figNameRight, + }), + getCurrentText: () => stageRef.current.showText, + getCurrentSpeaker: () => stageRef.current.showName, + getPerformList: () => { + const performs = stageRef.current.PerformList; + return performs.map((p) => ({ id: p.id, isHoldOn: p.isHoldOn })); + }, + isBlockSentence: () => { + return WebGAL.gameplay.performController.hasBlockingNextPerform(); + }, + }; + + api.audio = { + playBgm: (url: string, options?: { volume?: number; fade?: number }) => { + playBgm(url, options?.fade ?? 0, options?.volume ?? 100); + stageStateManager.commit(); + const VocalControl = document.getElementById('currentBgm') as HTMLMediaElement; + if (VocalControl !== null) { + VocalControl.currentTime = 0; + if (VocalControl.paused) VocalControl.play(); + } + }, + stopBgm: () => { + const VocalControl = document.getElementById('currentBgm') as HTMLMediaElement; + if (VocalControl !== null) { + VocalControl.currentTime = 0; + if (!VocalControl.paused) VocalControl.pause(); + } + }, + setVolume: (type: 'bgm' | 'vocal', volume: number) => { + const currentStage = stageRef.current; + if (type === 'bgm') { + stageStateManager.setStage('bgm', { ...currentStage.bgm, volume }); + } else if (type === 'vocal') { + stageStateManager.setStage('vocalVolume', volume); + } + }, + }; + + api.iframe = { + close: () => stageStateManager.removeIframe({ id }), + resize: (width: number, height: number) => { + const iframeElement = document.getElementById(`iframe-${id}`) as HTMLIFrameElement; + if (iframeElement) { + iframeElement.style.width = `${width}px`; + iframeElement.style.height = `${height}px`; + } + }, + move: (x: number, y: number) => { + const iframeElement = document.getElementById(`iframe-${id}`) as HTMLIFrameElement; + if (iframeElement) { + iframeElement.style.transform = `translate(${x}px, ${y}px)`; + } + }, + getPosition: () => { + const iframeElement = document.getElementById(`iframe-${id}`) as HTMLIFrameElement; + if (iframeElement) { + const rect = iframeElement.getBoundingClientRect(); + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; + } + return { x: 0, y: 0, width: 0, height: 0 }; + }, + complete: (returnValue?: any) => { + if (wait) { + window.parent.postMessage( + { + type: 'webgal-frame-complete', + frameId: id, + returnValue, + }, + SAME_ORIGIN, + ); + } + }, + openIframe: (key?: string) => { + const targetId = key ?? id; + const iframe = stageRef.current.iframes.find((e: any) => e.id === targetId); + if (iframe) { + iframe.isActive = true; + stageStateManager.commit(); + } + }, + closeIframe: (key?: string) => { + const targetId = key ?? id; + stageStateManager.removeIframe({ id: targetId }); + }, + }; + + // event: event handling + api.event = { + on: (event: WebGalAPIEventsKeyNames, callback: (data?: any) => void) => { + if (!eventsMap[event]) { + logger.error(`无效的事件类型: ${event}`); + return; + } + eventsMap[event].on(callback); + }, + off: (event: WebGalAPIEventsKeyNames, callback: (data?: any) => void) => { + if (!eventsMap[event]) { + logger.error(`无效的事件类型: ${event}`); + return; + } + eventsMap[event].off(callback); + }, + postIframeMessage: (key: string, data?: any) => { + const targetIframe = stageRef.current.iframes.find((e: any) => e.id === key); + if (!targetIframe) { + logger.error(`找不到id为${key}的iframe`); + return; + } + const targetIframeElement = document.getElementById(`iframe-${key}`) as HTMLIFrameElement | null; + if (!targetIframeElement) { + logger.error(`找不到id为iframe-${key}的iframe元素`); + return; + } + try { + const targetWindow = targetIframeElement.contentWindow; + if (!targetWindow) return; + const targetOrigin = targetWindow.origin === 'null' ? '*' : SAME_ORIGIN; + targetWindow.postMessage( + { + type: 'webgal-iframe-message', + sourceId: id, + data, + }, + targetOrigin, + ); + } catch (e) { + logger.error(`向iframe ${key} 发送消息失败:`, e); + } + }, + }; + + // store: persistent data + api.store = { + getPersistentData: (key?: string) => { + const iframe = stageRef.current.iframes.find((e: any) => e.id === id); + if (!iframe?.persistentData) return key ? undefined : {}; + return key ? iframe.persistentData[key] : iframe.persistentData; + }, + setPersistentData: (key: string, value: any) => { + const iframe = stageRef.current.iframes.find((e: any) => e.id === id); + if (!iframe) { + logger.error(`找不到id为${id}的iframe`); + return; + } + const currentData = iframe.persistentData || {}; + stageStateManager.updateIframePersistentData({ + id, + persistentData: { ...currentData, [key]: value }, + }); + }, + clearPersistentData: (key?: string) => { + const iframe = stageRef.current.iframes.find((e: any) => e.id === id); + if (!iframe) { + logger.error(`找不到id为${id}的iframe`); + return null; + } + if (key) { + if (iframe.persistentData?.[key]) { + const newData = { ...iframe.persistentData }; + delete newData[key]; + stageStateManager.updateIframePersistentData({ + id, + persistentData: newData, + }); + } + } else { + stageStateManager.updateIframePersistentData({ id, persistentData: {} }); + } + }, + }; + + api.frameId = id; + + apiRef.current = api; + } + + const apiInstance = apiRef.current!; + + useEffect(() => { + const gameVarWatchers = gameVarWatchersRef.current; + const prevVars = prevGameVarRef.current; + + if (!gameVarWatchers) return; + + const currentVars = stageRef.current.GameVar; + + for (const key of Object.keys(gameVarWatchers)) { + if (currentVars[key] !== prevVars[key]) { + prevVars[key] = currentVars[key]; + gameVarWatchers[key].forEach((watcher: { callback: (newValue: any) => void }) => { + watcher.callback(currentVars[key]); + }); + } + } + }, [stage]); + + const onError = useCallback( + (e: SyntheticEvent) => { + logger.error('iframe加载失败:', e); + stageStateManager.removeIframe({ id, save: true }); + }, + [id], + ); + + if (!src || !id) { + return null; + } + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe?.contentWindow) return; + try { + Object.defineProperty(iframe.contentWindow, 'webgal', { + value: apiInstance, + configurable: true, + enumerable: true, + }); + } catch (e) { + logger.warn('无法注入webgal API到跨域iframe:', e); + } + + if (injectArgs && Object.keys(injectArgs).length > 0) { + Object.defineProperty(apiInstance, 'params', { + value: injectArgs, + configurable: true, + enumerable: true, + }); + } + return () => { + if (iframe?.contentWindow) { + try { + delete (iframe.contentWindow as any).webgal; + } catch { + // cross-origin is expected + } + } + }; + }, [injectArgs, apiInstance]); + + return ( +