diff --git a/package.json b/package.json index ccb65ea02..f7bd1712a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@webgal/base", - "version": "4.5", + "version": "4.6", "description": "A brand new web Visual Novel engine.", "repository": "https://github.com/OpenWebGAL/WebGAL.git", "author": "Mahiru ", diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 001d1d7c5..19107aa8f 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -1,6 +1,6 @@ { "name": "webgal-engine", - "version": "4.5.20", + "version": "4.6.0", "scripts": { "dev": "vite --host --port 3000", "build": "node scripts/update-engine-version.js && cross-env NODE_ENV=production tsc && vite build --base=./", diff --git a/packages/webgal/public/game/scene/demo_parallel_animation.txt b/packages/webgal/public/game/scene/demo_parallel_animation.txt index 796ac524b..124f1e0d3 100644 --- a/packages/webgal/public/game/scene/demo_parallel_animation.txt +++ b/packages/webgal/public/game/scene/demo_parallel_animation.txt @@ -1,31 +1,33 @@ changeBg:WebGalEnter.webp -next; changeFigure:stand.webp -id=figure01 -transform={"position":{"x":1000,"y":720}}; -;演示setAnimation平行执行 +接下来演示setAnimation平行执行 setAnimation:shockwaveIn -target=figure01 -next setAnimation:move-front-and-back -target=figure01 -parallel -;演示通过-continue接续执行两个常规setTransform正常运作、不被打断 +接下来演示通过-continue接续执行两个常规setTransform正常运作、不被打断 setTransform:{"position":{"x":-1000}} -duration=5000 -target=figure01 -continue setTransform:{"position":{"x":1000}} -duration=5000 -target=figure01 -;演示setTransform平行执行 +接下来演示setTransform平行执行 setTransform:{"position":{"x":-1000},"scale":{"x":0.5},"contrast":0.5} -duration=5000 -target=figure01 -ease=easeOut -next -keep wait:2000 setTransform:{"position":{"y":0},"scale":{"y":0.5},"saturation":0} -duration=5000 -target=figure01 -ease=linear -parallel -continue setTransform:{"position":{"y":-720},"scale":{"y":1},"saturation":1} -duration=5000 -target=figure01 -ease=linear -next setTransform:{"position":{"x":1000},"scale":{"x":1},"contrast":1} -duration=5000 -target=figure01 -ease=easeIn -parallel; -;演示参数解耦改动后setTempAnimation普通运作是否正常 +接下来演示参数解耦改动后setTempAnimation普通运作是否正常 setTempAnimation:[{"duration":0}, {"duration":500,"position":{"x":-1000}}, {"duration":500,"position":{"y":720},"scale":{"y":0.5},"saturation":0}, {"duration":500,"position":{"x":-1000, "y":720}}, {"duration":500}, {"duration":500,"position":{"x":-1000},"scale":{"x":0.5},"contrast":0.5}] -target=figure01 setTempAnimation:[{"duration":0}, {"duration":500,"position":{"x":1000}}, {"duration":500,"position":{"y":720}}, {"duration":500,"position":{"x":1000, "y":720}}, {"duration":500}, {"duration":500,"position":{"x":1000}}] -target=figure01 -;演示参数解耦改动后setTransform的-writeDefault参数是否运作正常 +接下来演示参数解耦改动后setTransform的-writeDefault参数是否运作正常 setTransform:{} -writeDefault -target=figure01 -duration=500 setTransform:{"position":{"x":1000,"y":720}} -target=figure01 -next; setAnimation:shockwaveOut -target=figure01 -parallel -;演示setTempAnimation平行执行 +接下来演示setTempAnimation平行执行 +setTransform:{"alpha":1} -target=figure01 -next; setTempAnimation:[{"duration":0},{"position":{"x":-1000},"scale":{"x":0.5},"contrast":0.5,"duration":5000,"ease":"easeOut"},{"position":{"x":-1000},"scale":{"x":0.5},"contrast":0.5,"duration":2000},{"position":{"x":600},"scale":{"x":1},"contrast":1,"duration":5000,"ease":"easeIn"},{"duration":2000}] -target=figure01 -next; setTempAnimation:[{"duration":2000},{"position":{"y":0},"scale":{"y":0.5},"saturation":0,"duration":5000,"ease":"linear"},{"position":{"y":-720},"scale":{"y":1},"saturation":1,"duration":5000,"ease":"linear"},{"duration":2000}] -target=figure01 -parallel -continue; -;演示并行执行多条终止时间点不一致的setTransform +演示并行执行多条终止时间点不一致的setTransform setTransform:{"position":{"x":-1000}} -duration=5000 -next -target=figure01; setTransform:{"position":{"y":0}} -duration=3000 -parallel -target=figure01; setTransform:{"position":{"x":1000}} -duration=3000 -next -target=figure01; setTransform:{"position":{"y":-720}} -duration=5000 -parallel -target=figure01; +结束,接下来退场。 changeBg: -next; changeFigure: -id=figure01 diff --git a/packages/webgal/public/game/scene/demo_test_dom_control.txt b/packages/webgal/public/game/scene/demo_test_dom_control.txt new file mode 100644 index 000000000..01526c00c --- /dev/null +++ b/packages/webgal/public/game/scene/demo_test_dom_control.txt @@ -0,0 +1,30 @@ +DOM 管理测试 1 开始:验证 intro 创建 introContainer 后会自动清理。 +intro:DOM 测试第一段|自动结束 -delayTime=80 -fontSize=small -animation=fadeIn; +DOM 管理测试 1 结束:预期状态:第一段 intro 结束后回到对话,introContainer 不应继续遮挡画面。 + +DOM 管理测试 2 开始:验证连续 intro 不残留前一个 DOM 内容。 +intro:DOM 测试第二段|如果第一段文字仍可见就是异常 -delayTime=80 -fontSize=small -animation=slideIn -backgroundColor=rgba(16,16,16,1); +DOM 管理测试 2 结束:预期状态:只显示过第二段 intro,结束后没有残留黑屏内容。 + +DOM 管理测试 3 开始:验证 chooseContainer 在选择后清理;快速预览默认选择第 2 项。 +choose:手动 DOM 路径:dom_choice_manual|快速预览 DOM 路径:dom_choice_fast -defaultChoose=2; +label:dom_choice_manual; +setVar:domChoice=manual; +DOM 管理测试 3 手动路径:手动选择第 1 项时会看到这里。 +jumpLabel:dom_choice_after; +label:dom_choice_fast; +setVar:domChoice=fast; +DOM 管理测试 3 默认路径:快速预览默认选择第 2 项。 +label:dom_choice_after; +DOM 管理测试 3 结束:预期状态:domChoice={domChoice};选择结束后 chooseContainer 不应残留按钮。 + +DOM 管理测试 4 开始:验证 getUserInput 的输入框 DOM 在确认后清理,快速预览写入默认值。 +getUserInput:domInput -title=DOM 输入测试 -buttonText=确认 -defaultValue=DOMDefault; +DOM 管理测试 4 结束:预期状态:快速预览时 domInput={domInput},应为 DOMDefault;普通运行或普通快进确认后输入框 DOM 被清理。 + +DOM 管理测试 5 开始:验证 setTextbox hide/on 后文本框能恢复。 +setTextbox:hide -next; +wait:300; +setTextbox:on -next; +DOM 管理测试 5 结束:预期状态:这句话可见,文本框已恢复显示,introContainer 和 chooseContainer 没有可见残留。 +end; diff --git a/packages/webgal/public/game/scene/demo_test_flow_control.txt b/packages/webgal/public/game/scene/demo_test_flow_control.txt new file mode 100644 index 000000000..5342f4af8 --- /dev/null +++ b/packages/webgal/public/game/scene/demo_test_flow_control.txt @@ -0,0 +1,32 @@ +流程控制测试 1 开始:验证 jumpLabel 会跳过当前路径并继续执行。 +jumpLabel:flow_jump_target; +流程控制测试 1 异常路径:如果看到这句话,说明 jumpLabel 没有跳过中间语句。 +label:flow_jump_target; +流程控制测试 1 结束:预期状态:没有看到“异常路径”,并成功到达 flow_jump_target。 + +流程控制测试 2 开始:验证 choose 跳转到标签;快速预览默认选择第 2 项。 +choose:路径 A:flow_choose_a|路径 B:flow_choose_b -defaultChoose=2; +label:flow_choose_a; +setVar:flowChoice=A; +流程控制测试 2 路径 A:手动选择 A 时会看到这里。 +jumpLabel:flow_choose_end; +label:flow_choose_b; +setVar:flowChoice=B; +流程控制测试 2 路径 B:快速预览默认选择 B 时会看到这里。 +label:flow_choose_end; +流程控制测试 2 结束:预期状态:快速预览时 flowChoice={flowChoice},应为 B;手动运行时应等于所选路径。 + +流程控制测试 3 开始:验证 callScene 子场景执行后会回到父场景继续。 +setVar:flowChildDone=0; +callScene:demo_test_flow_control_child.txt; +流程控制测试 3 结束:预期状态:已从子场景返回,flowChildDone={flowChildDone},应为 1。 + +流程控制测试 4 开始:验证连续 jumpLabel 能落到统一出口,不进入中间路径。 +jumpLabel:flow_chain_1; +流程控制测试 4 异常路径 A:如果看到这句话,说明第一段跳转失败。 +label:flow_chain_1; +jumpLabel:flow_chain_2; +流程控制测试 4 异常路径 B:如果看到这句话,说明第二段跳转失败。 +label:flow_chain_2; +流程控制测试 4 结束:预期状态:直接到达 flow_chain_2,未显示异常路径。 +end; diff --git a/packages/webgal/public/game/scene/demo_test_flow_control_child.txt b/packages/webgal/public/game/scene/demo_test_flow_control_child.txt new file mode 100644 index 000000000..15bb08aec --- /dev/null +++ b/packages/webgal/public/game/scene/demo_test_flow_control_child.txt @@ -0,0 +1,3 @@ +流程控制子场景测试开始:验证 callScene 会压栈进入子场景。 +setVar:flowChildDone=1; +流程控制子场景测试结束:预期状态:flowChildDone={flowChildDone},下一句应回到父场景。; diff --git a/packages/webgal/public/game/scene/demo_test_input_flow_control.txt b/packages/webgal/public/game/scene/demo_test_input_flow_control.txt new file mode 100644 index 000000000..bac62a14e --- /dev/null +++ b/packages/webgal/public/game/scene/demo_test_input_flow_control.txt @@ -0,0 +1,34 @@ +用户输入流程控制测试 1 开始:验证快速预览下 getUserInput 会写入 defaultValue。 +getUserInput:previewName -title=输入测试 -buttonText=确认 -defaultValue=FastPreviewName; +jumpLabel:input_default_ok -when=previewName=="FastPreviewName"; +用户输入流程控制测试 1 手动输入路径:previewName={previewName},与默认值不同。 +jumpLabel:input_default_end; +label:input_default_ok; +用户输入流程控制测试 1 默认值路径:previewName={previewName},快速预览默认值命中。 +label:input_default_end; +用户输入流程控制测试 1 结束:预期状态:快速预览时 previewName={previewName},应为 FastPreviewName;普通运行或普通快进时应等待用户确认,并按实际输入分支。 + +用户输入流程控制测试 2 开始:验证快速预览下 choose 的 defaultChoose 会决定流程。 +choose:手动路径:input_manual_path|快速预览默认路径:input_fast_path -defaultChoose=2; +label:input_manual_path; +setVar:inputChoice=manual; +用户输入流程控制测试 2 手动路径:手动选择第 1 项时会看到这里。 +jumpLabel:input_choice_after; +label:input_fast_path; +setVar:inputChoice=fastPreviewDefault; +用户输入流程控制测试 2 默认路径:快速预览默认选择第 2 项。 +label:input_choice_after; +用户输入流程控制测试 2 结束:预期状态:快速预览时 inputChoice={inputChoice},应为 fastPreviewDefault;普通运行或普通快进时应等待玩家选择。 + +用户输入流程控制测试 3 开始:验证输入变量可以参与后续 choose 的显示条件。 +choose:(previewName=="FastPreviewName")->默认输入路径:input_name_default|(previewName!="FastPreviewName")->其它输入路径:input_name_other -defaultChoose=1; +label:input_name_default; +setVar:inputNameBranch=defaultName; +用户输入流程控制测试 3 默认输入路径:快速预览默认输入会显示并选择这里。 +jumpLabel:input_name_after; +label:input_name_other; +setVar:inputNameBranch=otherName; +用户输入流程控制测试 3 其它输入路径:手动输入其它名字时可选择这里。 +label:input_name_after; +用户输入流程控制测试 3 结束:预期状态:快速预览时 inputNameBranch={inputNameBranch},应为 defaultName。 +end; diff --git a/packages/webgal/public/game/scene/demo_test_variable_flow_control.txt b/packages/webgal/public/game/scene/demo_test_variable_flow_control.txt new file mode 100644 index 000000000..73ecb08c5 --- /dev/null +++ b/packages/webgal/public/game/scene/demo_test_variable_flow_control.txt @@ -0,0 +1,38 @@ +变量流程控制测试 1 开始:验证数值变量驱动 jumpLabel -when。 +setVar:varScore=2; +jumpLabel:var_score_high -when=varScore>1; +变量流程控制测试 1 异常路径:varScore>1 时不应看到这里。 +jumpLabel:var_score_end; +label:var_score_high; +变量流程控制测试 1 命中路径:varScore={varScore},条件跳转生效。 +label:var_score_end; +变量流程控制测试 1 结束:预期状态:varScore={varScore},应为 2,并且只经过命中路径。 + +变量流程控制测试 2 开始:验证 choose 的显示条件、启用条件和默认选择。 +setVar:varHasTicket=true; +setVar:varDoorPower=1; +choose:(varHasTicket==true)->进入可见路径:var_ticket_ok|[varDoorPower>1]->能量路径:var_power_path|(varScore<0)->隐藏路径:var_hidden -defaultChoose=1; +label:var_ticket_ok; +setVar:varChoice=ticket; +变量流程控制测试 2 可见路径:快速预览默认选择这里。 +jumpLabel:var_choose_end; +label:var_power_path; +setVar:varChoice=power; +变量流程控制测试 2 能量路径:只有 varDoorPower>1 时才能手动选择。 +jumpLabel:var_choose_end; +label:var_hidden; +setVar:varChoice=hidden; +变量流程控制测试 2 隐藏路径:varScore<0 时才应显示。 +label:var_choose_end; +变量流程控制测试 2 结束:预期状态:快速预览时 varChoice={varChoice},应为 ticket;能量路径应显示但不可选,隐藏路径不应显示。 + +变量流程控制测试 3 开始:验证快速预览下后续测试继承前面变量状态。 +setVar:varScore=varScore + 3; +jumpLabel:var_inherited_ok -when=varScore>4; +变量流程控制测试 3 异常路径:varScore 继承并加 3 后应大于 4。 +jumpLabel:var_inherited_end; +label:var_inherited_ok; +变量流程控制测试 3 命中路径:varScore={varScore},varChoice={varChoice}。 +label:var_inherited_end; +变量流程控制测试 3 结束:预期状态:varScore={varScore},应为 5;varChoice={varChoice},应保持 ticket。 +end; diff --git a/packages/webgal/public/game/scene/function_test.txt b/packages/webgal/public/game/scene/function_test.txt index f6bec3f05..ea3970a30 100644 --- a/packages/webgal/public/game/scene/function_test.txt +++ b/packages/webgal/public/game/scene/function_test.txt @@ -1 +1 @@ -choose: Lip Sync Animation Test:demo_animation.txt | Variable interpolation test:demo_var.txt | Change Config:demo_changeConfig.txt | Performs:demo_performs.txt; +choose: Lip Sync Animation Test:demo_animation.txt | Variable interpolation test:demo_var.txt | Change Config:demo_changeConfig.txt | Performs:demo_performs.txt | Flow Control Test:demo_test_flow_control.txt | Variable Flow Control Test:demo_test_variable_flow_control.txt | Input Flow Control Test:demo_test_input_flow_control.txt | DOM Lifecycle Test:demo_test_dom_control.txt; diff --git a/packages/webgal/public/game/template/template.json b/packages/webgal/public/game/template/template.json index bf412bbfd..e99ce4989 100644 --- a/packages/webgal/public/game/template/template.json +++ b/packages/webgal/public/game/template/template.json @@ -1,4 +1,4 @@ { "name":"WebGAL Refine 2026", - "webgal-version":"4.5.20" + "webgal-version":"4.6.0" } diff --git a/packages/webgal/public/webgal-engine.json b/packages/webgal/public/webgal-engine.json index 8fe0e7f10..9fb944cdb 100644 --- a/packages/webgal/public/webgal-engine.json +++ b/packages/webgal/public/webgal-engine.json @@ -2,9 +2,9 @@ "schemaVersion": "1.0.0", "id": "open-webgal.webgal", "name": "WebGAL", - "version": "4.5.20", + "version": "4.6.0", "type": "official", - "webgalVersion": "4.5.20", + "webgalVersion": "4.6.0", "description": "界面美观、功能强大、易于开发的全新网页端视觉小说引擎", "descriptions": { "en": "A brand new web Visual Novel engine with a beautiful interface, powerful features, and easy development", diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index 740cf9112..7cbc5ea37 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -1,9 +1,8 @@ import { generateUniversalSoftInAnimationObj } from '@/Core/controller/stage/pixi/animations/universalSoftIn'; import { logger } from '@/Core/util/logger'; import { generateUniversalSoftOffAnimationObj } from '@/Core/controller/stage/pixi/animations/universalSoftOff'; -import { webgalStore } from '@/store/store'; import cloneDeep from 'lodash/cloneDeep'; -import { baseTransform } from '@/store/stageInterface'; +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'; @@ -15,7 +14,8 @@ import { DEFAULT_FIG_IN_DURATION, DEFAULT_FIG_OUT_DURATION, } from '../constants'; -import { stageActions } from '@/store/stageReducer'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { AnimationFrame } from '@/Core/Modules/animations'; // eslint-disable-next-line max-params export function getAnimationObject( @@ -24,7 +24,34 @@ export function getAnimationObject( duration: number, writeDefault: boolean, writeFullEffect = true, + syncEndStateToStageState = true, ) { + const mappedEffects = getAnimationTimeline(animationName, target, writeDefault, writeFullEffect); + if (mappedEffects) { + return generateTimelineObj(mappedEffects, target, duration, syncEndStateToStageState); + } + return null; +} + +export function applyAnimationEndState( + animationName: string, + target: string, + writeDefault: boolean, + writeFullEffect = true, +) { + const mappedEffects = getAnimationTimeline(animationName, target, writeDefault, writeFullEffect); + if (!mappedEffects || mappedEffects.length === 0) return null; + const { duration, ease, ...endState } = mappedEffects[mappedEffects.length - 1]; + stageStateManager.updateEffect({ target, transform: endState }); + return mappedEffects; +} + +export function getAnimationTimeline( + animationName: string, + target: string, + writeDefault: boolean, + writeFullEffect = true, +): AnimationFrame[] | null { const effect = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); if (effect) { const unionKeys = new Set(); @@ -38,7 +65,7 @@ export function getAnimationObject( }); } const mappedEffects = effect.effects.map((effect) => { - const targetSetEffect = webgalStore.getState().stage.effects.find((e) => e.target === target); + const targetSetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target); let newEffect; if (!writeDefault && targetSetEffect && targetSetEffect.transform) { @@ -62,7 +89,7 @@ export function getAnimationObject( return newEffect; }); logger.debug('装载自定义动画', mappedEffects); - return generateTimelineObj(mappedEffects, target, duration); + return mappedEffects; } return null; } @@ -95,17 +122,18 @@ export function getEnterExitAnimation( duration = DEFAULT_BG_IN_DURATION; } duration = - webgalStore.getState().stage.animationSettings.find((setting) => setting.target === target)?.enterDuration ?? + stageStateManager.getCalculationStageState().animationSettings.find((setting) => setting.target === target) + ?.enterDuration ?? duration; // 走默认动画 let animation: IAnimationObject | null = generateUniversalSoftInAnimationObj(realTarget ?? target, duration); - const transformState = webgalStore.getState().stage.effects; + const transformState = stageStateManager.getCalculationStageState().effects; const targetEffect = transformState.find((effect) => effect.target === target); - const animationName = webgalStore - .getState() - .stage.animationSettings.find((setting) => setting.target === target)?.enterAnimationName; + const animationName = stageStateManager + .getCalculationStageState() + .animationSettings.find((setting) => setting.target === target)?.enterAnimationName; if (animationName && !targetEffect) { logger.debug('取代默认进入动画', target); animation = getAnimationObject(animationName, realTarget ?? target, getAnimateDuration(animationName), false); @@ -118,9 +146,9 @@ export function getEnterExitAnimation( if (isBg) { duration = DEFAULT_BG_OUT_DURATION; } - const animationSettings = webgalStore - .getState() - .stage.animationSettings.find((setting) => setting.target === target); + const animationSettings = stageStateManager + .getCalculationStageState() + .animationSettings.find((setting) => setting.target === target); duration = animationSettings?.exitDuration ?? duration; // 走默认动画 let animation: IAnimationObject | null = generateUniversalSoftOffAnimationObj(realTarget ?? target, duration); @@ -132,7 +160,7 @@ export function getEnterExitAnimation( } if (animationSettings) { // 退出动画拿完后,删了这个设定 - webgalStore.dispatch(stageActions.removeAnimationSettingsByTargetOff(target)); + stageStateManager.removeAnimationSettingsByTargetOff(target); logger.debug('删除退出动画设定', target); } return { duration, animation }; diff --git a/packages/webgal/src/Core/Modules/animations.ts b/packages/webgal/src/Core/Modules/animations.ts index 35512aefa..142c50b00 100644 --- a/packages/webgal/src/Core/Modules/animations.ts +++ b/packages/webgal/src/Core/Modules/animations.ts @@ -1,4 +1,4 @@ -import { ITransform } from '@/store/stageInterface'; +import { ITransform } from '@/Core/Modules/stage/stageInterface'; export interface IUserAnimation { name: string; diff --git a/packages/webgal/src/Core/Modules/backlog.ts b/packages/webgal/src/Core/Modules/backlog.ts index 8d9c9e8c8..349e84829 100644 --- a/packages/webgal/src/Core/Modules/backlog.ts +++ b/packages/webgal/src/Core/Modules/backlog.ts @@ -1,13 +1,13 @@ /** * 当前的backlog */ -import { IEffect, IStageState } from '@/store/stageInterface'; -import { webgalStore } from '@/store/store'; +import { IEffect, IStageState } from '@/Core/Modules/stage/stageInterface'; import { ISaveScene } from '@/store/userDataInterface'; import cloneDeep from 'lodash/cloneDeep'; import { SYSTEM_CONFIG } from '@/config'; import { SceneManager } from '@/Core/Modules/scene'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; export interface IBacklogItem { currentStageState: IStageState; @@ -40,7 +40,7 @@ export class BacklogManager { } public saveCurrentStateToBacklog() { // 存一下 Backlog - const currentStageState = webgalStore.getState().stage; + const currentStageState = stageStateManager.getCalculationStageState(); const stageStateToBacklog = cloneDeep(currentStageState); stageStateToBacklog.PerformList.forEach((ele) => { ele.script.args.forEach((argelement) => { diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index 43e24f44b..7d51c3c71 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -1,12 +1,9 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { webgalStore } from '@/store/store'; -import cloneDeep from 'lodash/cloneDeep'; -import { resetStageState, stageActions } from '@/store/stageReducer'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; -import { IRunPerform } from '@/store/stageInterface'; import { WEBGAL_NONE } from '@/Core/constants'; import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 获取随机演出名称 @@ -15,8 +12,17 @@ export const getRandomPerformName = (): string => { return Math.random().toString().substring(0, 10); }; +interface IPendingPerform { + perform: IPerform; + script: ISentence; + syncPerformState: boolean; +} + export class PerformController { public performList: Array = []; + private pendingPerformList: Array = []; + private isCollectingPerforms = false; + private stopTimeoutMap = new WeakMap>(); /** * 判断 perform 名称是否匹配(支持前缀匹配,用于清理并行演出) @@ -26,6 +32,14 @@ export class PerformController { return performName === name || performName.startsWith(name + '#'); } + public beginCollectingPerforms() { + this.isCollectingPerforms = true; + } + + public endCollectingPerforms() { + this.isCollectingPerforms = false; + } + public arrangeNewPerform(perform: IPerform, script: ISentence, syncPerformState = true) { // 检查演出列表内是否有相同的演出,如果有,一定是出了什么问题 // 并行演出的 performName 带有唯一后缀,因此不会命中去重 @@ -35,26 +49,108 @@ export class PerformController { for (let i = 0; i < this.performList.length; i++) { const e = this.performList[i]; if (e.performName === perform.performName) { - e.stopFunction(); - clearTimeout(e.stopTimeout as unknown as number); + this.stopStartedPerform(e); + this.clearPerformTimeout(e); this.performList.splice(i, 1); i--; } } } + perform.isStarted = false; + this.pendingPerformList = this.pendingPerformList.filter((p) => p.perform.performName !== perform.performName); // 语句不执行演出 if (perform.performName === WEBGAL_NONE) { return; } + // 同步演出状态 if (syncPerformState) { const performToAdd = { id: perform.performName, isHoldOn: perform.isHoldOn, script: script }; - webgalStore.dispatch(stageActions.addPerform(performToAdd)); + if (this.isCollectingPerforms) { + stageStateManager.addPerform(performToAdd); + } else { + stageStateManager.addPerform(performToAdd); + stageStateManager.commit({ applyPixiEffects: false }); + } + } + + if (this.isCollectingPerforms) { + this.pendingPerformList.push({ perform, script, syncPerformState }); + return; + } + + this.startPerform(perform, script); + if (!this.isCollectingPerforms) { + stageStateManager.applyCommittedPixiEffects(); + } + } + + public commitPendingPerforms() { + const performsToStart = this.pendingPerformList; + this.pendingPerformList = []; + performsToStart.forEach(({ perform, script }) => { + this.startPerform(perform, script); + }); + } + + public discardUncommittedNonHoldPerforms(settleDiscardedState = false) { + this.pendingPerformList = this.pendingPerformList.filter(({ perform }) => { + if (perform.isHoldOn) { + return true; + } + if (settleDiscardedState) { + perform.settleStateOnDiscard?.(); + } + return false; + }); + } + + public hasPendingBlockingStateCalculationPerform() { + return this.pendingPerformList.some(({ perform }) => perform.blockingStateCalculation?.() ?? false); + } + + public hasBlockingNextPerform() { + return this.performList.some((e) => e.blockingNext()); + } + + public hasUnsettledNonHoldPerform() { + return this.performList.some((e) => !e.isHoldOn && !e.skipNextCollect); + } + + public settleNonHoldPerforms() { + let isGoNext = false; + for (let i = 0; i < this.performList.length; i++) { + const e = this.performList[i]; + if (!e.isHoldOn) { + if (e.goNextWhenOver) { + isGoNext = true; + } + if (!e.skipNextCollect) { + this.stopStartedPerform(e); + this.clearPerformTimeout(e); + this.performList.splice(i, 1); + i--; + this.erasePerformFromState(e.performName); + } + } } + stageStateManager.commit(); + if (isGoNext) { + nextSentence(); + } + } + + public clearNonHoldPerformsFromStageState() { + stageStateManager.clearUncommittedNonHoldPerforms(); + } + + private startPerform(perform: IPerform, script: ISentence) { + perform.isStarted = true; + perform.startFunction?.(); // 时间到后自动清理演出 - perform.stopTimeout = setTimeout(() => { + const stopTimeout = setTimeout(() => { // perform.stopFunction(); // perform.isOver = true; if (!perform.isHoldOn) { @@ -62,6 +158,7 @@ export class PerformController { this.softUnmountPerformObject(perform); } }, perform.duration); + this.stopTimeoutMap.set(perform, stopTimeout); const hasContinue = getBooleanArgByKey(script, 'continue') ?? false; if (hasContinue) perform.goNextWhenOver = true; @@ -70,12 +167,23 @@ export class PerformController { } public unmountPerform(name: string, force = false) { + let isPendingRemoved = false; + this.pendingPerformList = this.pendingPerformList.filter(({ perform }) => { + const matched = this.matchPerformName(perform.performName, name); + if ((force && matched) || (matched && !perform.isHoldOn)) { + isPendingRemoved = true; + } + return force ? !matched : !(matched && !perform.isHoldOn); + }); + if (isPendingRemoved) { + this.erasePerformFromState(name); + } if (!force) { for (let i = 0; i < this.performList.length; i++) { const e = this.performList[i]; if (!e.isHoldOn && this.matchPerformName(e.performName, name)) { - e.stopFunction(); - clearTimeout(e.stopTimeout as unknown as number); + this.stopStartedPerform(e); + this.clearPerformTimeout(e); /** * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前 * 因为 goNextWhenOver 会调用 nextSentence,而 nextSentence 会清除目前未结束的演出 @@ -89,14 +197,15 @@ export class PerformController { // nextSentence(); this.goNextWhenOver(); } + this.erasePerformFromState(name); } } } else { for (let i = 0; i < this.performList.length; i++) { const e = this.performList[i]; if (this.matchPerformName(e.performName, name)) { - e.stopFunction(); - clearTimeout(e.stopTimeout as unknown as number); + this.stopStartedPerform(e); + this.clearPerformTimeout(e); /** * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前(同上) */ @@ -115,11 +224,39 @@ export class PerformController { } } + public unmountPerformByPrefix(prefix: string, force = false) { + let isPendingRemoved = false; + this.pendingPerformList = this.pendingPerformList.filter(({ perform }) => { + const matched = perform.performName.startsWith(prefix); + if ((force && matched) || (matched && !perform.isHoldOn)) { + isPendingRemoved = true; + } + return force ? !matched : !(matched && !perform.isHoldOn); + }); + if (isPendingRemoved) { + stageStateManager.removePerformByPrefix(prefix); + } + + for (let i = 0; i < this.performList.length; i++) { + const e = this.performList[i]; + if (e.performName.startsWith(prefix) && (force || !e.isHoldOn)) { + this.stopStartedPerform(e); + this.clearPerformTimeout(e); + this.performList.splice(i, 1); + i--; + if (e.goNextWhenOver) { + this.goNextWhenOver(); + } + this.erasePerformFromState(e.performName); + } + } + } + public softUnmountPerformObject(perform: IPerform) { const idx = this.performList.indexOf(perform); if (idx < 0) return; - perform.stopFunction(); - clearTimeout(perform.stopTimeout as unknown as number); + this.stopStartedPerform(perform); + this.clearPerformTimeout(perform); /** * 在演出列表里删除演出对象的操作必须在调用 goNextWhenOver 之前 * 因为 goNextWhenOver 会调用 nextSentence,而 nextSentence 会清除目前未结束的演出 @@ -128,6 +265,8 @@ export class PerformController { * 此问题对所有 goNextWhenOver 属性为真的演出都有影响,但只有 2 个演出有此问题 */ this.performList.splice(idx, 1); + this.erasePerformFromState(perform.performName); + stageStateManager.commit(); if (perform.goNextWhenOver) { // nextSentence(); this.goNextWhenOver(); @@ -135,29 +274,44 @@ export class PerformController { } public erasePerformFromState(name: string) { - webgalStore.dispatch(stageActions.removePerformByName(name)); + stageStateManager.removePerformByName(name); } public removeAllPerform() { + this.pendingPerformList = []; for (const e of this.performList) { - clearTimeout(e.stopTimeout); - e.stopFunction(); + this.clearPerformTimeout(e); + this.stopStartedPerform(e); } this.performList = []; } - private goNextWhenOver() { - let isBlockingAuto = false; + private clearPerformTimeout(perform: IPerform) { + const stopTimeout = this.stopTimeoutMap.get(perform); + if (stopTimeout) { + clearTimeout(stopTimeout); + this.stopTimeoutMap.delete(perform); + } + } + + private stopStartedPerform(perform: IPerform) { + if (!perform.isStarted) return; + perform.stopFunction(); + perform.isStarted = false; + } + + private goNextWhenOver = () => { + let isBlockingNext = false; this.performList?.forEach((e) => { - if (e.blockingAuto()) + if (e.blockingNext()) // 阻塞且没有结束的演出 - isBlockingAuto = true; + isBlockingNext = true; }); - if (isBlockingAuto) { + if (isBlockingNext) { // 有阻塞,提前结束 setTimeout(this.goNextWhenOver, 100); } else { nextSentence(); } - } + }; } diff --git a/packages/webgal/src/Core/Modules/perform/performInterface.ts b/packages/webgal/src/Core/Modules/perform/performInterface.ts index 8e00b2f90..578cf4a51 100644 --- a/packages/webgal/src/Core/Modules/perform/performInterface.ts +++ b/packages/webgal/src/Core/Modules/perform/performInterface.ts @@ -1,3 +1,5 @@ +import { WEBGAL_NONE } from '@/Core/constants'; + /** * 描述演出的接口,主要用于控制演出,而不是执行(在演出开始时被调用演出的执行器返回) * @interface IPerform @@ -9,18 +11,22 @@ export interface IPerform { duration: number; // 演出是不是一个保持类型的演出 isHoldOn: boolean; + // 启动演出的函数;只在状态 commit 后调用 + startFunction?: () => void; + // 演出是否已经启动;未 commit 的演出被清理时不调用卸载函数 + isStarted?: boolean; // 卸载演出的函数 stopFunction: () => void; // 演出是否阻塞游戏流程继续(一个函数,返回 boolean类型的结果,判断要不要阻塞) blockingNext: () => boolean; // 演出是否阻塞自动模式(一个函数,返回 boolean类型的结果,判断要不要阻塞) blockingAuto: () => boolean; - // 自动回收使用的 Timeout - stopTimeout: undefined | ReturnType; + // 演出是否阻塞状态演算;默认不阻塞,只有需要外部输入才能确定后续状态的演出需要覆盖 + blockingStateCalculation?: () => boolean; + // 未 commit 的演出被丢弃时,将它的终态同步到演算状态 + settleStateOnDiscard?: () => void; // 演出结束后转到下一句 goNextWhenOver?: boolean; - // 对于延迟触发的演出,使用 Promise - arrangePerformPromise?: Promise; // 跳过由 nextSentence 函数引发的演出回收 skipNextCollect?: boolean; } @@ -40,5 +46,21 @@ export const initPerform: IPerform = { stopFunction: () => {}, blockingNext: () => false, blockingAuto: () => true, - stopTimeout: undefined, }; + +export interface INonePerformOptions { + isHoldOn?: boolean; + blockingAuto?: boolean; +} + +export function createNonePerform(options: INonePerformOptions = {}): IPerform { + const { isHoldOn = false, blockingAuto = true } = options; + return { + performName: WEBGAL_NONE, + duration: 0, + isHoldOn, + stopFunction: () => {}, + blockingNext: () => false, + blockingAuto: () => blockingAuto, + }; +} diff --git a/packages/webgal/src/Core/Modules/readHistory.ts b/packages/webgal/src/Core/Modules/readHistory.ts index aa5336abb..3c82b7f29 100644 --- a/packages/webgal/src/Core/Modules/readHistory.ts +++ b/packages/webgal/src/Core/Modules/readHistory.ts @@ -5,8 +5,8 @@ import { webgalStore } from "@/store/store"; import { SceneManager } from "./scene"; import { setReadHistory } from "@/store/userDataReducer"; -import { setStage } from "@/store/stageReducer"; import { setStorage } from "../controller/storage/storageController"; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; export class ReadHistoryManager { private history: Map = new Map(); @@ -107,10 +107,7 @@ export class ReadHistoryManager { const bitset = this.history.get(scenarioName)!; isRead = (bitset[index >> 3] & (1 << (index & 7))) !== 0; } - webgalStore.dispatch(setStage({ - key: 'isRead', - value: isRead, - })); + stageStateManager.setStage('isRead', isRead); if (!isRead) { this.addReadHistory(); } diff --git a/packages/webgal/src/Core/Modules/scene.ts b/packages/webgal/src/Core/Modules/scene.ts index 89816968b..10c4f7bd4 100644 --- a/packages/webgal/src/Core/Modules/scene.ts +++ b/packages/webgal/src/Core/Modules/scene.ts @@ -28,11 +28,13 @@ export class SceneManager { public settledAssets: Set = new Set(); public sceneData: ISceneData = cloneDeep(initSceneData); public lockSceneWrite = false; + public sceneWritePromise: Promise | null = null; public resetScene() { this.sceneData.currentSentenceId = 0; this.sceneData.sceneStack = []; this.sceneData.currentScene = cloneDeep(initSceneData.currentScene); + this.sceneWritePromise = null; this.settledScenes.clear(); this.settledAssets.clear(); } diff --git a/packages/webgal/src/store/stageInterface.ts b/packages/webgal/src/Core/Modules/stage/stageInterface.ts similarity index 100% rename from packages/webgal/src/store/stageInterface.ts rename to packages/webgal/src/Core/Modules/stage/stageInterface.ts diff --git a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts new file mode 100644 index 000000000..c71b5eb55 --- /dev/null +++ b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts @@ -0,0 +1,393 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { isUndefined, omitBy } from 'lodash'; +import { commandType } from '@/Core/controller/scene/sceneInterface'; +import { STAGE_KEYS } from '@/Core/constants'; +import { baseBlinkParam, baseFocusParam } from '@/Core/live2DCore'; +import { + baseTransform, + IEffect, + IFigureMetadata, + IFreeFigure, + ILive2DBlink, + ILive2DExpression, + ILive2DFocus, + ILive2DMotion, + IRunPerform, + ISetGameVar, + IStageState, + IUpdateAnimationSettingPayload, +} from '@/Core/Modules/stage/stageInterface'; + +type StageStateListener = (stageState: IStageState) => void; +export interface IStageCommitOptions { + syncPixiStage?: boolean; + applyPixiEffects?: boolean; + notifyReact?: boolean; +} + +export interface IResolvedStageCommitOptions { + syncPixiStage: boolean; + applyPixiEffects: boolean; + notifyReact: boolean; +} + +type StageCommitHandler = (stageState: IStageState, options: IResolvedStageCommitOptions) => void; + +export const initState: IStageState = { + oldBgName: '', + bgName: '', + figName: '', + figNameLeft: '', + figNameRight: '', + freeFigure: [], + figureAssociatedAnimation: [], + isRead: false, + showText: '', + showTextSize: -1, + showName: '', + command: '', + choose: [], + vocal: '', + playVocal: '', + vocalVolume: 100, + bgm: { + src: '', + enter: 0, + volume: 100, + }, + uiSe: '', + miniAvatar: '', + GameVar: {}, + effects: [ + { + target: 'stage-main', + transform: baseTransform, + }, + ], + animationSettings: [], + bgFilter: '', + bgTransform: '', + PerformList: [], + currentDialogKey: 'initial', + live2dMotion: [], + live2dExpression: [], + live2dBlink: [], + live2dFocus: [], + currentConcatDialogPrev: '', + enableFilm: '', + isDisableTextbox: false, + replacedUIlable: {}, + figureMetaData: {}, +}; + +/** + * WebGAL 5 stage state machine. + * + * calculationStageState is mutated by script execution during forward. + * viewStageState is the committed state observed by React/Pixi/audio views. + */ +export class StageStateManager { + private calculationStageState: IStageState = cloneDeep(initState); + private viewStageState: IStageState = cloneDeep(initState); + private listeners = new Set(); + private commitHandler: StageCommitHandler | null = null; + + public getCalculationStageState(): IStageState { + return this.calculationStageState; + } + + public getViewStageState(): IStageState { + return this.viewStageState; + } + + public setStage(key: K, value: IStageState[K]) { + this.calculationStageState[key] = value; + } + + public setStageAndCommit(key: K, value: IStageState[K]) { + this.setStage(key, value); + this.commit(); + } + + public setStageVar(payload: ISetGameVar) { + this.calculationStageState.GameVar[payload.key] = payload.value; + } + + public setStageVarAndCommit(payload: ISetGameVar) { + this.setStageVar(payload); + this.commit(); + } + + public replaceCalculationStageState(stageState: IStageState) { + this.calculationStageState = cloneDeep(stageState); + } + + public replaceAllStageState(stageState: IStageState) { + this.calculationStageState = cloneDeep(stageState); + this.commit(); + } + + public resetCalculationStageState(stageState: IStageState) { + this.replaceCalculationStageState(stageState); + } + + public resetAllStageState(stageState: IStageState) { + this.replaceAllStageState(stageState); + } + + public updateEffect(payload: IEffect) { + const { target, transform } = payload; + const state = this.calculationStageState; + const activeTargets = [ + STAGE_KEYS.STAGE_MAIN, + STAGE_KEYS.BGMAIN, + STAGE_KEYS.FIG_C, + STAGE_KEYS.FIG_L, + STAGE_KEYS.FIG_R, + ...state.freeFigure.map((figure) => figure.key), + ]; + if (!activeTargets.includes(target)) return; + + const effectIndex = state.effects.findIndex((e) => e.target === target); + if (effectIndex >= 0) { + if (!state.effects[effectIndex].transform) { + state.effects[effectIndex].transform = transform; + } else if (transform) { + const targetScale = state.effects[effectIndex].transform!.scale || {}; + const targetPosition = state.effects[effectIndex].transform!.position || {}; + if (transform.scale) Object.assign(targetScale, omitBy(transform.scale, isUndefined)); + if (transform.position) Object.assign(targetPosition, omitBy(transform.position, isUndefined)); + Object.assign(state.effects[effectIndex].transform!, omitBy(transform, isUndefined)); + state.effects[effectIndex].transform!.scale = targetScale; + state.effects[effectIndex].transform!.position = targetPosition; + } + } else { + state.effects.push({ + target, + transform: transform ? { ...baseTransform, ...transform } : { ...baseTransform }, + }); + } + } + + public updateEffectAndCommit(payload: IEffect) { + this.updateEffect(payload); + this.commit(); + } + + public removeEffectByTargetId(target: string) { + const index = this.calculationStageState.effects.findIndex((e) => e.target === target); + if (index >= 0) { + this.calculationStageState.effects.splice(index, 1); + } + } + + public updateAnimationSettings(payload: IUpdateAnimationSettingPayload) { + const { target, key, value } = payload; + const state = this.calculationStageState; + const animationIndex = state.animationSettings.findIndex((a) => a.target === target); + if (animationIndex >= 0) { + state.animationSettings[animationIndex] = { + ...state.animationSettings[animationIndex], + [key]: value, + }; + } else { + state.animationSettings.push({ + target, + [key]: value, + }); + } + } + + public removeAnimationSettingsByTarget(target: string) { + const state = this.calculationStageState; + const index = state.animationSettings.findIndex((a) => a.target === target); + if (index >= 0) { + const prev = state.animationSettings[index]; + state.animationSettings.splice(index, 1); + + if (prev.exitAnimationName || prev.exitDuration !== undefined) { + const prevTarget = `${target}-off`; + const prevSetting = { + ...prev, + target: prevTarget, + }; + const prevIndex = state.animationSettings.findIndex((a) => a.target === prevTarget); + if (prevIndex >= 0) { + state.animationSettings.splice(prevIndex, 1, prevSetting); + } else { + state.animationSettings.push(prevSetting); + } + } + } + } + + public removeAnimationSettingsByTargetOff(target: string) { + const index = this.calculationStageState.animationSettings.findIndex((a) => a.target === target); + if (index >= 0) { + this.calculationStageState.animationSettings.splice(index, 1); + } + } + + public addPerform(performToAdd: IRunPerform) { + const dupId = performToAdd.id; + this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter((p) => p.id !== dupId); + this.calculationStageState.PerformList.push(performToAdd); + } + + public removePerformByName(name: string) { + this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter( + (performItem) => performItem.id !== name && !performItem.id.startsWith(name + '#'), + ); + } + + public removePerformByPrefix(prefix: string) { + this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter( + (performItem) => !performItem.id.startsWith(prefix), + ); + } + + public removeAllPerform() { + this.calculationStageState.PerformList.splice(0, this.calculationStageState.PerformList.length); + } + + public removeAllPixiPerforms() { + this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter( + (performItem) => performItem.script.command !== commandType.pixi, + ); + } + + public setFreeFigureByKey(newFigure: IFreeFigure) { + const state = this.calculationStageState; + const currentFreeFigures = state.freeFigure; + const index = currentFreeFigures.findIndex((figure) => figure.key === newFigure.key); + if (index >= 0) { + if (newFigure.name === '') { + currentFreeFigures.splice(index, 1); + const figureAssociatedAnimationIndex = state.figureAssociatedAnimation.findIndex( + (a) => a.targetId === newFigure.key, + ); + if (figureAssociatedAnimationIndex >= 0) { + state.figureAssociatedAnimation.splice(figureAssociatedAnimationIndex, 1); + } + } else { + currentFreeFigures[index].basePosition = newFigure.basePosition; + currentFreeFigures[index].name = newFigure.name; + } + } else if (newFigure.name !== '') { + currentFreeFigures.push(newFigure); + } + } + + public setLive2dMotion(payload: ILive2DMotion) { + const { target, motion, skin, overrideBounds } = payload; + const index = this.calculationStageState.live2dMotion.findIndex((e) => e.target === target); + if (index < 0) { + this.calculationStageState.live2dMotion.push({ target, motion, skin, overrideBounds }); + } else { + this.calculationStageState.live2dMotion[index].motion = motion; + this.calculationStageState.live2dMotion[index].skin = skin; + this.calculationStageState.live2dMotion[index].overrideBounds = overrideBounds; + } + } + + public setLive2dExpression(payload: ILive2DExpression) { + const { target, expression } = payload; + const index = this.calculationStageState.live2dExpression.findIndex((e) => e.target === target); + if (index < 0) { + this.calculationStageState.live2dExpression.push({ target, expression }); + } else { + this.calculationStageState.live2dExpression[index].expression = expression; + } + } + + public setLive2dBlink(payload: ILive2DBlink) { + const { target, blink } = payload; + const index = this.calculationStageState.live2dBlink.findIndex((e) => e.target === target); + if (index < 0) { + this.calculationStageState.live2dBlink.push({ target, blink: { ...baseBlinkParam, ...blink } }); + } else { + this.calculationStageState.live2dBlink[index].blink = { + ...this.calculationStageState.live2dBlink[index].blink, + ...blink, + }; + } + } + + public setLive2dFocus(payload: ILive2DFocus) { + const { target, focus } = payload; + const index = this.calculationStageState.live2dFocus.findIndex((e) => e.target === target); + if (index < 0) { + this.calculationStageState.live2dFocus.push({ target, focus: { ...baseFocusParam, ...focus } }); + } else { + this.calculationStageState.live2dFocus[index].focus = { + ...this.calculationStageState.live2dFocus[index].focus, + ...focus, + }; + } + } + + public replaceUIlable(payload: [string, string]) { + this.calculationStageState.replacedUIlable[payload[0]] = payload[1]; + } + + public setFigureMetaData(payload: [string, keyof IFigureMetadata, any, undefined | boolean]) { + if (payload[3]) { + if (this.calculationStageState.figureMetaData[payload[0]]) { + delete this.calculationStageState.figureMetaData[payload[0]]; + } + } else { + if (!this.calculationStageState.figureMetaData[payload[0]]) { + this.calculationStageState.figureMetaData[payload[0]] = {}; + } + this.calculationStageState.figureMetaData[payload[0]][payload[1]] = payload[2]; + } + } + + public clearUncommittedNonHoldPerforms() { + this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter((perform) => perform.isHoldOn); + } + + public removeNonHoldPerformsAndCommit() { + this.clearUncommittedNonHoldPerforms(); + this.commit(); + } + + public commit(options: IStageCommitOptions = {}) { + const resolvedOptions: IResolvedStageCommitOptions = { + syncPixiStage: options.syncPixiStage ?? true, + applyPixiEffects: options.applyPixiEffects ?? true, + notifyReact: options.notifyReact ?? true, + }; + this.viewStageState = cloneDeep(this.calculationStageState); + this.commitHandler?.(this.viewStageState, resolvedOptions); + if (resolvedOptions.notifyReact) { + this.notify(); + } + } + + public applyCommittedPixiEffects() { + this.commitHandler?.(this.viewStageState, { + syncPixiStage: false, + applyPixiEffects: true, + notifyReact: false, + }); + } + + public setCommitHandler(handler: StageCommitHandler | null) { + this.commitHandler = handler; + } + + public subscribe(listener: StageStateListener) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + private notify() { + const stageState = this.viewStageState; + this.listeners.forEach((listener) => listener(stageState)); + } +} + +export const stageStateManager = new StageStateManager(); diff --git a/packages/webgal/src/Core/controller/gamePlay/backToTitle.ts b/packages/webgal/src/Core/controller/gamePlay/backToTitle.ts index e30a7c8c7..7711349f9 100644 --- a/packages/webgal/src/Core/controller/gamePlay/backToTitle.ts +++ b/packages/webgal/src/Core/controller/gamePlay/backToTitle.ts @@ -1,10 +1,10 @@ import { webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; import { setVisibility } from '@/store/GUIReducer'; import { stopAllPerform } from '@/Core/controller/gamePlay/stopAllPerform'; import { stopAuto } from '@/Core/controller/gamePlay/autoPlay'; import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; export const backToTitle = () => { if (webgalStore.getState().GUI.showTitle) return; @@ -13,7 +13,7 @@ export const backToTitle = () => { stopAuto(); stopFast(); // 清除语音 - dispatch(setStage({ key: 'playVocal', value: '' })); + stageStateManager.setStageAndCommit('playVocal', ''); // 重新打开标题界面 dispatch(setVisibility({ component: 'showTitle', visibility: true })); /** diff --git a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts index 848e0a064..d4a9c39a4 100644 --- a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts +++ b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts @@ -6,6 +6,7 @@ import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; import { WebGAL } from '@/Core/WebGAL'; import { webgalStore } from "@/store/store"; import { SYSTEM_CONFIG } from '@/config'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 设置 fast 按钮的激活与否 @@ -44,7 +45,7 @@ export const startFast = (force = false) => { WebGAL.gameplay.isFast = true; const skipAll = force || webgalStore.getState().userData.optionData.skipAll; WebGAL.gameplay.fastInterval = setInterval(() => { - if (!skipAll && !webgalStore.getState().stage.isRead) { + if (!skipAll && !stageStateManager.getCalculationStageState().isRead) { stopFast(); return; } diff --git a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts index 7cb4a9f78..ab226a8c9 100644 --- a/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts +++ b/packages/webgal/src/Core/controller/gamePlay/nextSentence.ts @@ -1,87 +1,83 @@ import { scriptExecutor } from './scriptExecutor'; import { logger } from '../../util/logger'; import { webgalStore } from '@/store/store'; -import { resetStageState } from '@/store/stageReducer'; -import cloneDeep from 'lodash/cloneDeep'; -import { IBacklogItem } from '@/Core/Modules/backlog'; -import { SYSTEM_CONFIG } from '@/config'; import { WebGAL } from '@/Core/WebGAL'; -import { IRunPerform } from '@/store/stageInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** - * 进行下一句 + * 步进前工作:检查阻塞,并在当前演出未完成时提前结束普通演出。 + */ +export const preForward = () => { + if (WebGAL.sceneManager.lockSceneWrite) { + logger.warn('next 被场景切换阻塞!'); + return false; + } + + if (WebGAL.gameplay.performController.hasBlockingNextPerform()) { + logger.warn('next 被阻塞!'); + return false; + } + + const hasUnsettledNonHoldPerform = WebGAL.gameplay.performController.hasUnsettledNonHoldPerform(); + if (hasUnsettledNonHoldPerform) { + logger.debug('提前结束被触发,现在清除普通演出'); + WebGAL.gameplay.performController.settleNonHoldPerforms(); + return false; + } + + return true; +}; + +/** + * 执行一条语句或由 -next 连接的语句序列,只修改演算状态并收集演出。 + */ +export const forward = () => { + if (WebGAL.sceneManager.lockSceneWrite) { + logger.warn('forward 被场景切换阻塞!'); + return false; + } + + if (WebGAL.gameplay.performController.hasBlockingNextPerform()) { + logger.warn('forward 被阻塞!'); + return false; + } + + WebGAL.gameplay.performController.discardUncommittedNonHoldPerforms(WebGAL.gameplay.isFastPreview); + WebGAL.gameplay.performController.clearNonHoldPerformsFromStageState(); + WebGAL.gameplay.performController.beginCollectingPerforms(); + try { + scriptExecutor(); + } finally { + WebGAL.gameplay.performController.endCollectingPerforms(); + } + return true; +}; + +/** + * 将演算状态提交到当前视图状态,并启动本序列收集到的演出。 + */ +export const commitForward = () => { + stageStateManager.commit({ applyPixiEffects: false }); + WebGAL.gameplay.performController.commitPendingPerforms(); + stageStateManager.applyCommittedPixiEffects(); +}; + +/** + * 用户操作步进。 */ export const nextSentence = () => { - /** - * 发送 “发生点击下一句” 事件。 - */ WebGAL.events.userInteractNext.emit(); - // 如果当前显示标题,那么不进行下一句 const GUIState = webgalStore.getState().GUI; if (GUIState.showTitle) { return; } - // 第一步,检查是否存在 blockNext 的演出 - let isBlockingNext = false; - WebGAL.gameplay.performController.performList.forEach((e) => { - if (e.blockingNext()) - // 阻塞且没有结束的演出 - isBlockingNext = true; - }); - if (isBlockingNext) { - // 有阻塞,提前结束 - logger.warn('next 被阻塞!'); - return; - } - - // 检查是否处于演出完成状态,不是则结束所有普通演出(保持演出不算做普通演出) - let allSettled = true; - WebGAL.gameplay.performController.performList.forEach((e) => { - if (!e.isHoldOn && !e.skipNextCollect) allSettled = false; - }); - if (allSettled) { - // 所有普通演出已经结束 - // if (WebGAL.backlogManager.isSaveBacklogNext) { - // WebGAL.backlogManager.isSaveBacklogNext = false; - // } - // 清除状态表的演出序列(因为这时候已经准备进行下一句了) - const stageState = webgalStore.getState().stage; - const newStageState = cloneDeep(stageState); - for (let i = 0; i < newStageState.PerformList.length; i++) { - const e: IRunPerform = newStageState.PerformList[i]; - if (!e.isHoldOn) { - newStageState.PerformList.splice(i, 1); - i--; - } - } - webgalStore.dispatch(resetStageState(newStageState)); - scriptExecutor(); + if (!preForward()) { return; } - // 不处于 allSettled 状态,清除所有普通演出,强制进入settled。 - logger.debug('提前结束被触发,现在清除普通演出'); - let isGoNext = false; - for (let i = 0; i < WebGAL.gameplay.performController.performList.length; i++) { - const e = WebGAL.gameplay.performController.performList[i]; - if (!e.isHoldOn) { - if (e.goNextWhenOver) { - isGoNext = true; - } // 先检查是不是要跳过收集 - if (!e.skipNextCollect) { - // 由于提前结束使用的不是 unmountPerform 标准 API,所以不会触发两次 nextSentence - e.stopFunction(); - clearTimeout(e.stopTimeout as unknown as number); - WebGAL.gameplay.performController.performList.splice(i, 1); - i--; - } - } - } - if (isGoNext) { - // 由于不使用 unmountPerform 标准 API,这里需要手动收集一下 isGoNext - nextSentence(); - } + forward(); + commitForward(); }; diff --git a/packages/webgal/src/Core/controller/gamePlay/runScript.ts b/packages/webgal/src/Core/controller/gamePlay/runScript.ts index e76b76174..6ee72e290 100644 --- a/packages/webgal/src/Core/controller/gamePlay/runScript.ts +++ b/packages/webgal/src/Core/controller/gamePlay/runScript.ts @@ -15,11 +15,5 @@ export const runScript = (script: ISentence) => { // 调用脚本对应的函数 perform = funcToRun(script); - if (perform.arrangePerformPromise) { - perform.arrangePerformPromise.then((resolovedPerform) => - WebGAL.gameplay.performController.arrangeNewPerform(resolovedPerform, script), - ); - } else { - WebGAL.gameplay.performController.arrangeNewPerform(perform, script); - } + WebGAL.gameplay.performController.arrangeNewPerform(perform, script); }; diff --git a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts index 6670ca15d..94dfc53ba 100644 --- a/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts +++ b/packages/webgal/src/Core/controller/gamePlay/scriptExecutor.ts @@ -1,20 +1,20 @@ import { commandType, ISentence } from '@/Core/controller/scene/sceneInterface'; import { runScript } from './runScript'; import { logger } from '../../util/logger'; -import { IStageState } from '@/store/stageInterface'; import { restoreScene } from '../scene/restoreScene'; import { webgalStore } from '@/store/store'; import { getValueFromStateElseKey } from '@/Core/gameScripts/setVar'; import { strIf } from '@/Core/controller/gamePlay/strIf'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; import cloneDeep from 'lodash/cloneDeep'; import { ISceneEntry } from '@/Core/Modules/scene'; -import { IBacklogItem } from '@/Core/Modules/backlog'; -import { SYSTEM_CONFIG } from '@/config'; import { WebGAL } from '@/Core/WebGAL'; import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { jumpToLabel } from '@/Core/gameScripts/label/jumpToLabel'; import { prefetchCurrentSceneByProgress } from '@/Core/util/prefetcher/progressPrefetcher'; +const MAX_FORWARD_SCRIPT_EXECUTION = 10000; + export const whenChecker = (whenValue: string | undefined): boolean => { if (whenValue === undefined) { return true; @@ -39,7 +39,12 @@ export const whenChecker = (whenValue: string | undefined): boolean => { * 语句执行器 * 执行语句,同步场景状态,并根据情况立即执行下一句或者加入backlog */ -export const scriptExecutor = () => { +export const scriptExecutor = (depth = 0) => { + if (depth > MAX_FORWARD_SCRIPT_EXECUTION) { + logger.error('forward 中执行的语句数量超过限制,可能存在 jumpLabel 或 -next 死循环'); + return; + } + prefetchCurrentSceneByProgress(); // 超过总语句数量,则从场景栈拿出一个需要继续的场景,然后继续流程。若场景栈清空,则停止流程 if ( @@ -99,9 +104,21 @@ export const scriptExecutor = () => { if (!runThis) { logger.warn('不满足条件,跳过本句!'); WebGAL.sceneManager.sceneData.currentSentenceId++; - nextSentence(); + scriptExecutor(depth + 1); return; } + + if (currentScript.command === commandType.jumpLabel) { + // jumpLabel 是内核流程控制:只改变语句指针,并在本次 forward 内继续演算,不触发 commit。 + const isJumped = jumpToLabel(currentScript.content); + if (!isJumped) { + logger.warn(`未找到标签 ${currentScript.content},跳过 jumpLabel`); + WebGAL.sceneManager.sceneData.currentSentenceId++; + } + scriptExecutor(depth + 1); + return; + } + WebGAL.readHistoryManager.checkIsRead(); runScript(currentScript); // 是否要进行下一句 @@ -112,8 +129,6 @@ export const scriptExecutor = () => { const hasNotEnd = getBooleanArgByKey(currentScript, 'notend') ?? false; isSaveBacklog = isSaveBacklog && !hasNotEnd; - let currentStageState: IStageState; - // 执行至指定 sentenceID // if (runToSentence >= 0 && runtime_currentSceneData.currentSentenceId < runToSentence) { // runtime_currentSceneData.currentSentenceId++; @@ -121,29 +136,29 @@ export const scriptExecutor = () => { // return; // } - // 执行“下一句” - if (isNext) { + const hasPendingBlockingStateCalculationPerform = + WebGAL.gameplay.performController.hasPendingBlockingStateCalculationPerform(); + const saveBacklogIfNeeded = () => { + if (isSaveBacklog) { + WebGAL.backlogManager.saveCurrentStateToBacklog(); + } + }; + + // 执行“下一句”。只有需要外部输入才能确定后续状态的演出,才会阻塞状态演算。 + if (isNext && !hasPendingBlockingStateCalculationPerform && !WebGAL.sceneManager.lockSceneWrite) { WebGAL.sceneManager.sceneData.currentSentenceId++; - scriptExecutor(); + saveBacklogIfNeeded(); + scriptExecutor(depth + 1); return; } - /** - * 为了让 backlog 拿到连续执行了多条语句后正确的数据,放到下一个宏任务中执行(我也不知道为什么这样能正常,有能力的可以研究一下 - */ - setTimeout(() => { - // 同步当前舞台数据 - currentStageState = webgalStore.getState().stage; - const allState = { - currentStageState: currentStageState, - globalGameVar: webgalStore.getState().userData.globalGameVar, - }; - logger.debug('本条语句执行结果', allState); - // 保存 backlog - if (isSaveBacklog) { - // WebGAL.backlogManager.isSaveBacklogNext = true; - WebGAL.backlogManager.saveCurrentStateToBacklog(); - } - }, 0); WebGAL.sceneManager.sceneData.currentSentenceId++; + const currentStageState = stageStateManager.getCalculationStageState(); + const allState = { + currentStageState: currentStageState, + globalGameVar: webgalStore.getState().userData.globalGameVar, + }; + logger.debug('本条语句执行结果', allState); + // 保存 backlog + saveBacklogIfNeeded(); }; diff --git a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts index e3998acd5..b61cdd9ec 100644 --- a/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts +++ b/packages/webgal/src/Core/controller/gamePlay/startContinueGame.ts @@ -10,6 +10,7 @@ import { restorePerform } from '@/Core/controller/storage/jumpFromBacklog'; import { hasFastSaveRecord, loadFastSaveGame } from '@/Core/controller/storage/fastSaveLoad'; import { WebGAL } from '@/Core/WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 从头开始游戏 @@ -32,7 +33,7 @@ export async function continueGame() { /** * 重设模糊背景 */ - setEbg(webgalStore.getState().stage.bgName); + setEbg(stageStateManager.getViewStageState().bgName); // 当且仅当游戏未开始时使用快速存档 // 当游戏开始后 使用原来的逻辑 if ((await hasFastSaveRecord()) && WebGAL.sceneManager.sceneData.currentSentenceId === 0) { diff --git a/packages/webgal/src/Core/controller/gamePlay/stopAllPerform.ts b/packages/webgal/src/Core/controller/gamePlay/stopAllPerform.ts index fd3746d81..dcac1984a 100644 --- a/packages/webgal/src/Core/controller/gamePlay/stopAllPerform.ts +++ b/packages/webgal/src/Core/controller/gamePlay/stopAllPerform.ts @@ -4,11 +4,5 @@ import { WebGAL } from '@/Core/WebGAL'; export const stopAllPerform = () => { logger.warn('清除所有演出'); - for (let i = 0; i < WebGAL.gameplay.performController.performList.length; i++) { - const e = WebGAL.gameplay.performController.performList[i]; - e.stopFunction(); - clearTimeout(e.stopTimeout as unknown as number); - WebGAL.gameplay.performController.performList.splice(i, 1); - i--; - } + WebGAL.gameplay.performController.removeAllPerform(); }; diff --git a/packages/webgal/src/Core/controller/scene/callScene.ts b/packages/webgal/src/Core/controller/scene/callScene.ts index 53aadd554..66ac09e04 100644 --- a/packages/webgal/src/Core/controller/scene/callScene.ts +++ b/packages/webgal/src/Core/controller/scene/callScene.ts @@ -16,6 +16,8 @@ export const callScene = (sceneUrl: string, sceneName: string) => { return; } WebGAL.sceneManager.lockSceneWrite = true; + const isFastPreviewSceneWrite = WebGAL.gameplay.isFastPreview; + let shouldAutoNext = false; // 先将本场景压入场景栈 WebGAL.sceneManager.sceneData.sceneStack.push({ sceneName: WebGAL.sceneManager.sceneData.currentScene.sceneName, @@ -23,18 +25,26 @@ export const callScene = (sceneUrl: string, sceneName: string) => { continueLine: WebGAL.sceneManager.sceneData.currentSentenceId, }); // 场景写入到运行时 - sceneFetcher(sceneUrl) + const sceneWritePromise = sceneFetcher(sceneUrl) .then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 logger.debug('现在调用场景,调用结果:', WebGAL.sceneManager.sceneData); - WebGAL.sceneManager.lockSceneWrite = false; - nextSentence(); + shouldAutoNext = !isFastPreviewSceneWrite; }) .catch((e) => { logger.error('场景调用错误', e); + }) + .finally(() => { WebGAL.sceneManager.lockSceneWrite = false; + if (WebGAL.sceneManager.sceneWritePromise === sceneWritePromise) { + WebGAL.sceneManager.sceneWritePromise = null; + } + if (shouldAutoNext) { + nextSentence(); + } }); + WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; }; diff --git a/packages/webgal/src/Core/controller/scene/changeScene.ts b/packages/webgal/src/Core/controller/scene/changeScene.ts index 4bac9b37d..7a6c1c981 100644 --- a/packages/webgal/src/Core/controller/scene/changeScene.ts +++ b/packages/webgal/src/Core/controller/scene/changeScene.ts @@ -16,19 +16,29 @@ export const changeScene = (sceneUrl: string, sceneName: string) => { return; } WebGAL.sceneManager.lockSceneWrite = true; + const isFastPreviewSceneWrite = WebGAL.gameplay.isFastPreview; + let shouldAutoNext = false; // 场景写入到运行时 - sceneFetcher(sceneUrl) + const sceneWritePromise = sceneFetcher(sceneUrl) .then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); WebGAL.sceneManager.sceneData.currentSentenceId = 0; clearPrefetchLinks(); WebGAL.sceneManager.settledScenes.add(sceneUrl); // 放入已加载场景列表,避免递归加载相同场景 logger.debug('现在切换场景,切换后的结果:', WebGAL.sceneManager.sceneData); - WebGAL.sceneManager.lockSceneWrite = false; - nextSentence(); + shouldAutoNext = !isFastPreviewSceneWrite; }) .catch((e) => { logger.error('场景调用错误', e); + }) + .finally(() => { WebGAL.sceneManager.lockSceneWrite = false; + if (WebGAL.sceneManager.sceneWritePromise === sceneWritePromise) { + WebGAL.sceneManager.sceneWritePromise = null; + } + if (shouldAutoNext) { + nextSentence(); + } }); + WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; }; diff --git a/packages/webgal/src/Core/controller/scene/restoreScene.ts b/packages/webgal/src/Core/controller/scene/restoreScene.ts index 1486c272c..d1d671e6a 100644 --- a/packages/webgal/src/Core/controller/scene/restoreScene.ts +++ b/packages/webgal/src/Core/controller/scene/restoreScene.ts @@ -15,17 +15,27 @@ export const restoreScene = (entry: ISceneEntry) => { return; } WebGAL.sceneManager.lockSceneWrite = true; + const isFastPreviewSceneWrite = WebGAL.gameplay.isFastPreview; + let shouldAutoNext = false; // 场景写入到运行时 - sceneFetcher(entry.sceneUrl) + const sceneWritePromise = sceneFetcher(entry.sceneUrl) .then((rawScene) => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, entry.sceneName, entry.sceneUrl); WebGAL.sceneManager.sceneData.currentSentenceId = entry.continueLine + 1; // 重设场景 logger.debug('现在恢复场景,恢复后场景:', WebGAL.sceneManager.sceneData.currentScene); - WebGAL.sceneManager.lockSceneWrite = false; - nextSentence(); + shouldAutoNext = !isFastPreviewSceneWrite; }) .catch((e) => { logger.error('场景调用错误', e); + }) + .finally(() => { WebGAL.sceneManager.lockSceneWrite = false; + if (WebGAL.sceneManager.sceneWritePromise === sceneWritePromise) { + WebGAL.sceneManager.sceneWritePromise = null; + } + if (shouldAutoNext) { + nextSentence(); + } }); + WebGAL.sceneManager.sceneWritePromise = sceneWritePromise; }; diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index c0ae8c2f0..fab9b1bfc 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -1,6 +1,4 @@ -import { webgalStore } from '@/store/store'; -import { IEffect, IFigureAssociatedAnimation, IFigureMetadata, ITransform } from '@/store/stageInterface'; -import { setStage, stageActions } from '@/store/stageReducer'; +import { IEffect, IFigureAssociatedAnimation, IFigureMetadata, ITransform } from '@/Core/Modules/stage/stageInterface'; import { Live2D, WebGAL } from '@/Core/WebGAL'; import { baseBlinkParam, baseFocusParam, BlinkParam, FocusParam } from '@/Core/live2DCore'; import { isIOS } from '@/Core/initializeScript'; @@ -15,6 +13,7 @@ import isUndefined from 'lodash/isUndefined'; import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; export interface IAnimationObject { setStartState: Function; @@ -262,6 +261,7 @@ export default class PixiStage { const container = targetPixiContainer.pixiContainer; if (container) PixiStage.assignTransform(container, effect.transform); } + this.requestRender(); return; } this.stageAnimations.push({ uuid: uuid(), animationObject, key: key, targetKey: target, type: 'preset' }); @@ -339,8 +339,8 @@ export default class PixiStage { target: thisTickerFunc.targetKey, transform: endStateEffect, }; - webgalStore.dispatch(stageActions.updateEffect(effect)); - // if (!this.notUpdateBacklogEffects) updateCurrentBacklogEffects(webgalStore.getState().stage.effects); + stageStateManager.updateEffect(effect); + // if (!this.notUpdateBacklogEffects) updateCurrentBacklogEffects(stageStateManager.getViewStageState().effects); } } this.stageAnimations.splice(index, 1); @@ -716,7 +716,7 @@ export default class PixiStage { if (thisFigureContainer && this.getStageObjByUuid(figureUuid)) { (async function () { let overrideBounds: [number, number, number, number] = [0, 0, 0, 0]; - const mot = webgalStore.getState().stage.live2dMotion.find((e) => e.target === key); + const mot = stageStateManager.getViewStageState().live2dMotion.find((e) => e.target === key); if (mot?.overrideBounds) { overrideBounds = mot.overrideBounds; } @@ -767,7 +767,7 @@ export default class PixiStage { // motion let motionToSet = ''; - const motionFromState = webgalStore.getState().stage.live2dMotion.find((e) => e.target === key); + const motionFromState = stageStateManager.getViewStageState().live2dMotion.find((e) => e.target === key); if (motionFromState) { motionToSet = motionFromState.motion; } @@ -776,7 +776,9 @@ export default class PixiStage { // expression let expressionToSet = ''; - const expressionFromState = webgalStore.getState().stage.live2dExpression.find((e) => e.target === key); + const expressionFromState = stageStateManager + .getViewStageState() + .live2dExpression.find((e) => e.target === key); if (expressionFromState) { expressionToSet = expressionFromState.expression; } @@ -785,7 +787,7 @@ export default class PixiStage { // blink let blinkToSet: BlinkParam = baseBlinkParam; - const blinkFromState = webgalStore.getState().stage.live2dBlink.find((e) => e.target === key); + const blinkFromState = stageStateManager.getViewStageState().live2dBlink.find((e) => e.target === key); if (blinkFromState) { blinkToSet = { ...blinkToSet, ...blinkFromState.blink }; } @@ -794,7 +796,7 @@ export default class PixiStage { // focus let focusToSet: FocusParam = baseFocusParam; - const focusFromState = webgalStore.getState().stage.live2dFocus.find((e) => e.target === key); + const focusFromState = stageStateManager.getViewStageState().live2dFocus.find((e) => e.target === key); if (focusFromState) { focusToSet = { ...focusToSet, ...focusFromState.focus }; } @@ -1072,7 +1074,7 @@ export default class PixiStage { // /** // * 删掉相关 Effects,因为已经移除了 // */ - // const prevEffects = webgalStore.getState().stage.effects; + // const prevEffects = stageStateManager.getViewStageState().effects; // const newEffects = __.cloneDeep(prevEffects); // const index = newEffects.findIndex((e) => e.target === key); // if (index >= 0) { @@ -1090,7 +1092,7 @@ export default class PixiStage { } public getFigureMetadataByKey(key: string): IFigureMetadata | undefined { - return webgalStore.getState().stage.figureMetaData[key]; + return stageStateManager.getViewStageState().figureMetaData[key]; } public loadAsset(url: string, callback: () => void, name?: string) { @@ -1253,5 +1255,5 @@ function updateCurrentBacklogEffects(newEffects: IEffect[]) { WebGAL.backlogManager.editLastBacklogItemEffect(cloneDeep(newEffects)); }, 50); - webgalStore.dispatch(setStage({ key: 'effects', value: newEffects })); + stageStateManager.setStageAndCommit('effects', newEffects); } diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts index dbbe8c0ba..844423b52 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/generateTransformAnimationObj.ts @@ -1,7 +1,7 @@ import { AnimationFrame } from '@/Core/Modules/animations'; -import { webgalStore } from '@/store/store'; import { has, pickBy } from 'lodash'; import isNull from 'lodash/isNull'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; type AnimationObj = Array; @@ -15,7 +15,7 @@ export function generateTransformAnimationObj( ): AnimationObj { let animationObj; // 获取那个 target 的当前变换 - const transformState = webgalStore.getState().stage.effects; + const transformState = stageStateManager.getCalculationStageState().effects; const targetEffect = transformState.find((effect) => effect.target === target); applyFrame.duration = 500; diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/testblur.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/testblur.ts index 5d88127c5..532a94b83 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/testblur.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/testblur.ts @@ -52,9 +52,14 @@ export function generateTestblurAnimationObj(targetKey: string, duration: number } } + function getEndStateEffect() { + return { alpha: 1 }; + } + return { setStartState, setEndState, tickerFunc, + getEndStateEffect, }; } diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts index b16bc7833..2e1e9b9b3 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts @@ -1,12 +1,11 @@ -import { ITransform } from '@/store/stageInterface'; +import { ITransform } from '@/Core/Modules/stage/stageInterface'; import * as popmotion from 'popmotion'; import { WebGAL } from '@/Core/WebGAL'; -import { webgalStore } from '@/store/store'; -import { stageActions } from '@/store/stageReducer'; import omitBy from 'lodash/omitBy'; import isUndefined from 'lodash/isUndefined'; import PixiStage, { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { AnimationFrame } from '@/Core/Modules/animations'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 动画创建模板 @@ -18,6 +17,7 @@ export function generateTimelineObj( timeline: Array, targetKey: string, duration: number, + syncEndStateToStageState = true, ): IAnimationObject { const target = WebGAL.gameplay.pixiStage!.getStageObjByKey(targetKey); let currentDelay = 0; @@ -63,8 +63,10 @@ export function generateTimelineObj( }); } - const { duration: sliceDuration, ...endState } = getEndStateEffect(); - webgalStore.dispatch(stageActions.updateEffect({ target: targetKey, transform: endState })); + if (syncEndStateToStageState) { + const { duration: sliceDuration, ease, ...endState } = getEndStateEffect(); + stageStateManager.updateEffect({ target: targetKey, transform: endState }); + } /** * 在此书写为动画设置初态的操作 diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts index 385b8c15b..1af1a6cac 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftIn.ts @@ -53,9 +53,14 @@ export function generateUniversalSoftInAnimationObj(targetKey: string, duration: } } + function getEndStateEffect() { + return { alpha: 1 }; + } + return { setStartState, setEndState, tickerFunc, + getEndStateEffect, }; } diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftOff.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftOff.ts index 305c9962b..f7c2a1050 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftOff.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/universalSoftOff.ts @@ -54,9 +54,14 @@ export function generateUniversalSoftOffAnimationObj(targetKey: string, duration } } + function getEndStateEffect() { + return { alpha: 0 }; + } + return { setStartState, setEndState, tickerFunc, + getEndStateEffect, }; } diff --git a/packages/webgal/src/Core/controller/stage/pixi/spine.ts b/packages/webgal/src/Core/controller/stage/pixi/spine.ts index 28e084ad4..f32eb3bb7 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/spine.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/spine.ts @@ -4,7 +4,7 @@ import { v4 as uuid } from 'uuid'; import * as PIXI from 'pixi.js'; import PixiStage from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; -import { webgalStore } from '@/store/store'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; // utils/loadPixiSpine.ts // @ts-ignore let pixiSpineModule: typeof import('pixi-spine') | null = null; @@ -111,7 +111,7 @@ export async function addSpineFigureImpl( figureSpine.pivot.set(spineCenterX, spineCenterY); figureSpine.interactive = false; - const motionFromState = webgalStore.getState().stage.live2dMotion.find((e) => e.target === key); + const motionFromState = stageStateManager.getViewStageState().live2dMotion.find((e) => e.target === key); let animationToPlay = ''; if (motionFromState?.skin) { if (!applySpineSkin(figureSpine, motionFromState.skin)) { diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts new file mode 100644 index 000000000..38eaa542c --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -0,0 +1,240 @@ +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import type { IEffect, IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; +import type { IResolvedStageCommitOptions } from '@/Core/Modules/stage/stageStateManager'; +import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; +import { WebGAL } from '@/Core/WebGAL'; +import PixiStage from '@/Core/controller/stage/pixi/PixiController'; +import type { IStageObject } from '@/Core/controller/stage/pixi/PixiController'; +import { getEnterExitAnimation } from '@/Core/Modules/animationFunctions'; +import { logger } from '@/Core/util/logger'; +import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; +import { isUndefined, omitBy } from 'lodash'; + +export function syncPixiStageState(stageState: IStageState, options: IResolvedStageCommitOptions) { + if (options.syncPixiStage) { + syncBg(stageState); + syncFigures(stageState); + syncLive2d(stageState); + syncFigureMetaData(stageState); + } + if (options.applyPixiEffects) { + applyStageEffects(stageState.effects); + } +} + +export function applyStageEffects(effects: IEffect[]) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + const stageObjects = pixiStage.getAllStageObj(); + const lockedStageTargets = pixiStage.getAllLockedObject(); + for (const stageObj of stageObjects) { + const key = stageObj.key; + if (lockedStageTargets.includes(key)) continue; + const effect = effects.find((effect) => effect.target === key); + const targetPixiContainer = pixiStage.getStageObjByKey(key); + const container = targetPixiContainer?.pixiContainer; + if (!container) continue; + // @ts-ignore WebGALPixiContainer exposes transform-like fields. + PixiStage.assignTransform(container, convertTransform(effect?.transform ?? baseTransform)); + } + pixiStage.requestRender(); +} + +function syncBg(stageState: IStageState) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + const thisBgKey = 'bg-main'; + const bgName = stageState.bgName; + const currentBg = pixiStage.getStageObjByKey(thisBgKey); + + if (bgName !== '') { + if (currentBg?.sourceUrl === bgName) return; + if (currentBg) { + removeBg(currentBg); + } + addBg(thisBgKey, bgName); + logger.debug('重设背景'); + const { duration, animation } = getEnterExitAnimation(thisBgKey, 'enter', true); + if (WebGAL.gameplay.isFast) { + setEbg(bgName, 0); + } else { + setEbg(bgName, duration); + pixiStage.registerPresetAnimation(animation, 'bg-main-softin', thisBgKey, stageState.effects); + setTimeout(() => pixiStage.removeAnimationWithSetEffects('bg-main-softin'), duration); + } + return; + } + + if (!currentBg) return; + const exitDuration = removeBg(currentBg); + setEbg(bgName, exitDuration, 'cubic-bezier(0.5, 0, 0.75, 0)'); +} + +function syncFigures(stageState: IStageState) { + syncFigureSlot('fig-center', stageState.figName, 'center', stageState); + syncFigureSlot('fig-left', stageState.figNameLeft, 'left', stageState); + syncFigureSlot('fig-right', stageState.figNameRight, 'right', stageState); + + for (const fig of stageState.freeFigure) { + syncFigureSlot(fig.key, fig.name, fig.basePosition, stageState); + } + + const currentFigures = WebGAL.gameplay.pixiStage?.getFigureObjects(); + if (!currentFigures) return; + const freeFigureKeys = new Set(stageState.freeFigure.map((fig) => fig.key)); + for (const existFigure of [...currentFigures]) { + if ( + existFigure.key === 'fig-left' || + existFigure.key === 'fig-center' || + existFigure.key === 'fig-right' || + existFigure.key.endsWith('-off') + ) { + continue; + } + if (!freeFigureKeys.has(existFigure.key)) { + removeFig(existFigure, `${existFigure.key}-softin`, stageState.effects); + } + } +} + +function syncFigureSlot(key: string, sourceUrl: string, position: 'left' | 'center' | 'right', stageState: IStageState) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + const softInAniKey = `${key}-softin`; + const currentFigure = pixiStage.getStageObjByKey(key); + + if (sourceUrl !== '') { + if (currentFigure?.sourceUrl === sourceUrl) return; + if (currentFigure) { + removeFig(currentFigure, softInAniKey, stageState.effects); + } + addFigure(key, sourceUrl, position); + logger.debug(`${key} 立绘已重设`); + const { duration, animation } = getEnterExitAnimation(key, 'enter'); + if (!WebGAL.gameplay.isFast) { + pixiStage.registerPresetAnimation(animation, softInAniKey, key, stageState.effects); + setTimeout(() => pixiStage.removeAnimationWithSetEffects(softInAniKey), duration); + } + return; + } + + if (currentFigure) { + removeFig(currentFigure, softInAniKey, stageState.effects); + } +} + +function syncLive2d(stageState: IStageState) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + for (const motion of stageState.live2dMotion) { + if (motion.skin) { + pixiStage.changeSpineSkinByKey(motion.target, motion.skin); + } + pixiStage.changeModelMotionByKey(motion.target, motion.motion); + } + for (const expression of stageState.live2dExpression) { + pixiStage.changeModelExpressionByKey(expression.target, expression.expression); + } + for (const blink of stageState.live2dBlink) { + pixiStage.changeModelBlinkByKey(blink.target, blink.blink); + } + for (const focus of stageState.live2dFocus) { + pixiStage.changeModelFocusByKey(focus.target, focus.focus); + } +} + +function syncFigureMetaData(stageState: IStageState) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + Object.entries(stageState.figureMetaData).forEach(([key, value]) => { + const figureObject = pixiStage.getStageObjByKey(key); + if (figureObject && !figureObject.isExiting && figureObject.pixiContainer) { + if (value.zIndex !== undefined) { + figureObject.pixiContainer.zIndex = value.zIndex; + } + if (value.blendMode !== undefined) { + figureObject.pixiContainer.blendMode = value.blendMode; + } + } + }); +} + +function removeBg(bgObject: IStageObject): number { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return DEFAULT_BG_OUT_DURATION; + pixiStage.removeAnimationWithSetEffects('bg-main-softin'); + if (WebGAL.gameplay.isFast) { + pixiStage.removeStageObjectByKey(bgObject.key); + return 0; + } + const oldBgKey = bgObject.key; + bgObject.key = 'bg-main-off' + String(new Date().getTime()); + const bgKey = bgObject.key; + const bgAniKey = bgObject.key + '-softoff'; + pixiStage.removeStageObjectByKey(oldBgKey); + const { duration, animation } = getEnterExitAnimation('bg-main-off', 'exit', true, bgKey); + pixiStage.registerAnimation(animation, bgAniKey, bgKey); + setTimeout(() => { + pixiStage.removeAnimation(bgAniKey); + pixiStage.removeStageObjectByKey(bgKey); + }, duration); + return duration; +} + +function removeFig(figObj: IStageObject, enterTikerKey: string, effects: IEffect[]) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + pixiStage.removeAnimationWithSetEffects(enterTikerKey); + if (WebGAL.gameplay.isFast) { + logger.debug('快速模式,立刻关闭立绘'); + pixiStage.removeStageObjectByKey(figObj.key); + return; + } + const oldFigKey = figObj.key; + const figLeaveAniKey = oldFigKey + '-off'; + figObj.key = oldFigKey + String(new Date().getTime()) + '-off'; + const figKey = figObj.key; + pixiStage.removeStageObjectByKey(oldFigKey); + const leaveKey = figKey + '-softoff'; + const { duration, animation } = getEnterExitAnimation(figLeaveAniKey, 'exit', false, figKey); + pixiStage.registerPresetAnimation(animation, leaveKey, figKey, effects); + setTimeout(() => { + pixiStage.removeAnimation(leaveKey); + pixiStage.removeStageObjectByKey(figKey); + }, duration); +} + +function addBg(key: string, url: string) { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + if (['mp4', 'webm', 'mkv'].some((e) => url.toLocaleLowerCase().endsWith(e))) { + pixiStage.addVideoBg(key, url); + } else if (url.toLocaleLowerCase().endsWith('.skel')) { + pixiStage.addSpineBg(key, url); + } else { + pixiStage.addBg(key, url); + } +} + +function addFigure(key: string, url: string, position: 'left' | 'center' | 'right') { + const pixiStage = WebGAL.gameplay.pixiStage; + if (!pixiStage) return; + const baseUrl = window.location.origin; + const urlObject = new URL(url, baseUrl); + const figureType = urlObject.searchParams.get('type') as 'image' | 'live2D' | 'spine' | null; + if (url.endsWith('.json')) { + pixiStage.addLive2dFigure(key, url, position); + } else if (url.endsWith('.skel') || figureType === 'spine') { + pixiStage.addSpineFigure(key, url, position); + } else { + pixiStage.addFigure(key, url, position); + } +} + +function convertTransform(transform: ITransform | undefined) { + if (!transform) { + return {}; + } + const { position, ...rest } = transform; + return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); +} diff --git a/packages/webgal/src/Core/controller/stage/playBgm.ts b/packages/webgal/src/Core/controller/stage/playBgm.ts index b6e76c2e8..8b3f553eb 100644 --- a/packages/webgal/src/Core/controller/stage/playBgm.ts +++ b/packages/webgal/src/Core/controller/stage/playBgm.ts @@ -1,6 +1,5 @@ -import { webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; import { logger } from '@/Core/util/logger'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; // /** // * 停止bgm @@ -14,11 +13,9 @@ import { logger } from '@/Core/util/logger'; // if (!VocalControl.paused) VocalControl.pause(); // } // // 获得舞台状态并设置 -// webgalStore.dispatch(setStage({key: 'bgm', value: ''})); +// stageStateManager.setStage('bgm', ''); // }; -let emptyBgmTimeout: ReturnType; - /** * 播放bgm * @param url bgm路径 @@ -28,21 +25,9 @@ let emptyBgmTimeout: ReturnType; export function playBgm(url: string, enter = 0, volume = 100): void { logger.debug('playing bgm' + url); if (url === '') { - emptyBgmTimeout = setTimeout(() => { - // 淡入淡出效果结束后,将 bgm 置空 - webgalStore.dispatch(setStage({ key: 'bgm', value: { src: '', enter: 0, volume: 100 } })); - }, enter); - const lastSrc = webgalStore.getState().stage.bgm.src; - webgalStore.dispatch(setStage({ key: 'bgm', value: { src: lastSrc, enter: -enter, volume: volume } })); + const lastSrc = stageStateManager.getCalculationStageState().bgm.src; + stageStateManager.setStage('bgm', { src: lastSrc, enter: -enter, volume: volume }); } else { - // 不要清除bgm了! - clearTimeout(emptyBgmTimeout); - webgalStore.dispatch(setStage({ key: 'bgm', value: { src: url, enter: enter, volume: volume } })); + stageStateManager.setStage('bgm', { src: url, enter: enter, volume: volume }); } - setTimeout(() => { - const audioElement = document.getElementById('currentBgm') as HTMLAudioElement; - if (audioElement.src) { - audioElement?.play(); - } - }, 0); } diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index ffe1f7823..7dd84ec72 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -1,8 +1,6 @@ -import { initState, resetStageState, setStage } from '@/store/stageReducer'; -import { webgalStore } from '@/store/store'; import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; -import { saveActions } from '@/store/savesReducer'; +import { initState, stageStateManager } from '@/Core/Modules/stage/stageStateManager'; export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { /** @@ -23,9 +21,9 @@ export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true) => { // 清空舞台状态表 const initSceneDataCopy = cloneDeep(initState); - const currentVars = webgalStore.getState().stage.GameVar; - webgalStore.dispatch(resetStageState(initSceneDataCopy)); + const currentVars = stageStateManager.getCalculationStageState().GameVar; + stageStateManager.resetAllStageState(initSceneDataCopy); if (!resetSceneAndVar) { - webgalStore.dispatch(setStage({ key: 'GameVar', value: currentVars })); + stageStateManager.setStageAndCommit('GameVar', currentVars); } }; diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index ae7bd2b61..4672ca5d0 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -1,27 +1,35 @@ import { logger } from '../../util/logger'; import { sceneFetcher } from '../scene/sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; -import { IStageState } from '@/store/stageInterface'; +import { IStageState } from '@/Core/Modules/stage/stageInterface'; import { webgalStore } from '@/store/store'; -import { resetStageState, stageActions } from '@/store/stageReducer'; import { setVisibility } from '@/store/GUIReducer'; import { runScript } from '@/Core/controller/gamePlay/runScript'; import { stopAllPerform } from '@/Core/controller/gamePlay/stopAllPerform'; import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 恢复演出 */ export const restorePerform = () => { - const stageState = webgalStore.getState().stage; + const stageState = stageStateManager.getCalculationStageState(); const performToRestore = cloneDeep(stageState.PerformList); // 清除状态表中演出序列 - webgalStore.dispatch(stageActions.removeAllPerform()); - performToRestore.forEach((e) => { - runScript(e.script); - }); + stageStateManager.removeAllPerform(); + WebGAL.gameplay.performController.beginCollectingPerforms(); + try { + performToRestore.forEach((e) => { + runScript(e.script); + }); + } finally { + WebGAL.gameplay.performController.endCollectingPerforms(); + } + stageStateManager.commit({ applyPixiEffects: false }); + WebGAL.gameplay.performController.commitPendingPerforms(); + stageStateManager.applyCommittedPixiEffects(); }; /** @@ -64,7 +72,7 @@ export const jumpFromBacklog = (index: number, refetchScene = true) => { // 确保原先未读的文本在使用 backlog 时能正确显示为已读文本 newStageState.isRead = true; - dispatch(resetStageState(newStageState)); + stageStateManager.replaceCalculationStageState(newStageState); // 恢复演出 setTimeout(restorePerform, 0); diff --git a/packages/webgal/src/Core/controller/storage/loadGame.ts b/packages/webgal/src/Core/controller/storage/loadGame.ts index 4daeac8e8..f94290989 100644 --- a/packages/webgal/src/Core/controller/storage/loadGame.ts +++ b/packages/webgal/src/Core/controller/storage/loadGame.ts @@ -3,7 +3,6 @@ import { logger } from '../../util/logger'; import { sceneFetcher } from '../scene/sceneFetcher'; import { sceneParser } from '../../parser/sceneParser'; import { webgalStore } from '@/store/store'; -import { resetStageState } from '@/store/stageReducer'; import { setVisibility } from '@/store/GUIReducer'; import { restorePerform } from './jumpFromBacklog'; import { stopAllPerform } from '@/Core/controller/gamePlay/stopAllPerform'; @@ -11,6 +10,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { WebGAL } from '@/Core/WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 读取游戏存档 @@ -58,7 +58,7 @@ export function loadGameFromStageData(stageData: ISaveData) { // 确保原先未读的文本在 load 时能正确显示为已读文本 newStageState.isRead = true; const dispatch = webgalStore.dispatch; - dispatch(resetStageState(newStageState)); + stageStateManager.replaceCalculationStageState(newStageState); // 恢复演出 setTimeout(restorePerform, 0); @@ -69,5 +69,5 @@ export function loadGameFromStageData(stageData: ISaveData) { /** * 恢复模糊背景 */ - setEbg(webgalStore.getState().stage.bgName); + setEbg(newStageState.bgName); } diff --git a/packages/webgal/src/Core/controller/storage/saveGame.ts b/packages/webgal/src/Core/controller/storage/saveGame.ts index c729b334c..6e69f07b1 100644 --- a/packages/webgal/src/Core/controller/storage/saveGame.ts +++ b/packages/webgal/src/Core/controller/storage/saveGame.ts @@ -8,6 +8,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; import { saveActions } from '@/store/savesReducer'; import { dumpSavesToStorage } from '@/Core/controller/storage/savesController'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 保存游戏 @@ -24,7 +25,7 @@ export const saveGame = (index: number) => { * @param index 游戏的档位 */ export function generateCurrentStageData(index: number, isSavePreviewImage = true) { - const stageState = webgalStore.getState().stage; + const stageState = stageStateManager.getCalculationStageState(); const saveBacklog = cloneDeep(WebGAL.backlogManager.getBacklog()); /** diff --git a/packages/webgal/src/Core/gameScripts/SCRIPT_AUTHORING.md b/packages/webgal/src/Core/gameScripts/SCRIPT_AUTHORING.md new file mode 100644 index 000000000..cf69aef44 --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/SCRIPT_AUTHORING.md @@ -0,0 +1,156 @@ +# 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/applyStyle.ts b/packages/webgal/src/Core/gameScripts/applyStyle.ts index ca7e85d7e..4522d2a10 100644 --- a/packages/webgal/src/Core/gameScripts/applyStyle.ts +++ b/packages/webgal/src/Core/gameScripts/applyStyle.ts @@ -1,7 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { webgalStore } from '@/store/store'; -import { stageActions } from '@/store/stageReducer'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 语句执行的模板代码 @@ -15,16 +14,8 @@ export const applyStyle = (sentence: ISentence): IPerform => { if (splitSegment.length >= 2) { const classNameToBeChange = splitSegment[0]; const classNameChangeTo = splitSegment[1]; - webgalStore.dispatch(stageActions.replaceUIlable([classNameToBeChange, classNameChangeTo])); + stageStateManager.replaceUIlable([classNameToBeChange, classNameChangeTo]); } } - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/bgm.ts b/packages/webgal/src/Core/gameScripts/bgm.ts index d39a88ed7..509e71a59 100644 --- a/packages/webgal/src/Core/gameScripts/bgm.ts +++ b/packages/webgal/src/Core/gameScripts/bgm.ts @@ -1,5 +1,5 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { playBgm } from '@/Core/controller/stage/playBgm'; import { getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; import { webgalStore } from '@/store/store'; @@ -28,13 +28,5 @@ export const bgm = (sentence: ISentence): IPerform => { playBgm(url, enter, volume); - return { - performName: 'none', - duration: 0, - isHoldOn: true, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform({ isHoldOn: true }); }; diff --git a/packages/webgal/src/Core/gameScripts/callSceneScript.ts b/packages/webgal/src/Core/gameScripts/callSceneScript.ts index 48aec5494..e7f9a5848 100644 --- a/packages/webgal/src/Core/gameScripts/callSceneScript.ts +++ b/packages/webgal/src/Core/gameScripts/callSceneScript.ts @@ -1,5 +1,5 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { callScene } from '../controller/scene/callScene'; /** @@ -10,13 +10,5 @@ export const callSceneScript = (sentence: ISentence): IPerform => { const sceneNameArray: Array = sentence.content.split('/'); const sceneName = sceneNameArray[sceneNameArray.length - 1]; callScene(sentence.content, sceneName); - return { - performName: 'none', - duration: 0, - isHoldOn: true, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform({ isHoldOn: true }); }; diff --git a/packages/webgal/src/Core/gameScripts/callSteam.ts b/packages/webgal/src/Core/gameScripts/callSteam.ts index f717f3a9b..735268b2d 100644 --- a/packages/webgal/src/Core/gameScripts/callSteam.ts +++ b/packages/webgal/src/Core/gameScripts/callSteam.ts @@ -1,9 +1,8 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { WebGAL } from '@/Core/WebGAL'; import { logger } from '@/Core/util/logger'; import { getStringArgByKey } from '@/Core/util/getSentenceArg'; -import { WEBGAL_NONE } from '../constants'; /** * Unlocks a Steam achievement via the renderer → Electron bridge. @@ -25,15 +24,5 @@ export const callSteam = (sentence: ISentence): IPerform => { } } } - const noperform: IPerform = { - performName: WEBGAL_NONE, - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, - }; - - return noperform; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/changeBg/index.ts b/packages/webgal/src/Core/gameScripts/changeBg/index.ts index c40c98e3b..cfcc01b7b 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/index.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/index.ts @@ -3,18 +3,18 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; // import {getRandomPerformName} from '../../../util/getRandomPerformName'; import styles from '@/Stage/stage.module.scss'; import { webgalStore } from '@/store/store'; -import { setStage, stageActions } from '@/store/stageReducer'; import { getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; import { unlockCgInUserData } from '@/store/userDataReducer'; import { logger } from '@/Core/util/logger'; -import { ITransform } from '@/store/stageInterface'; +import { ITransform } from '@/Core/Modules/stage/stageInterface'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { AnimationFrame, IUserAnimation } from '@/Core/Modules/animations'; import cloneDeep from 'lodash/cloneDeep'; -import { getAnimateDuration } from '@/Core/Modules/animationFunctions'; +import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; import localforage from 'localforage'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 进行背景图片的切换 @@ -42,14 +42,14 @@ export const changeBg = (sentence: ISentence): IPerform => { /** * 判断背景 URL 是否发生了变化 */ - const isUrlChanged = webgalStore.getState().stage.bgName !== sentence.content; + const isUrlChanged = stageStateManager.getCalculationStageState().bgName !== sentence.content; /** * 删掉相关 Effects,因为已经移除了 */ if (isUrlChanged) { - dispatch(stageActions.removeEffectByTargetId(`bg-main`)); - dispatch(stageActions.removeAnimationSettingsByTarget(`bg-main`)); + stageStateManager.removeEffectByTargetId(`bg-main`); + stageStateManager.removeAnimationSettingsByTarget(`bg-main`); } // 处理 transform 和 默认 transform @@ -64,9 +64,7 @@ export const changeBg = (sentence: ISentence): IPerform => { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); duration = getAnimateDuration(animationName); - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }), - ); + stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }); } catch (e) { // 解析都错误了,歇逼吧 applyDefaultTransform(); @@ -85,35 +83,25 @@ export const changeBg = (sentence: ISentence): IPerform => { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); duration = getAnimateDuration(animationName); - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }), - ); + stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }); } // 应用动画的优先级更高一点 const enterAnimation = getStringArgByKey(sentence, 'enter'); const exitAnimation = getStringArgByKey(sentence, 'exit'); if (enterAnimation) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: enterAnimation }), - ); + stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: enterAnimation }); duration = getAnimateDuration(enterAnimation); } if (exitAnimation) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: 'bg-main', key: 'exitAnimationName', value: exitAnimation }), - ); + stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'exitAnimationName', value: exitAnimation }); duration = getAnimateDuration(exitAnimation); } if (enterDuration >= 0) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: 'bg-main', key: 'enterDuration', value: enterDuration }), - ); + stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterDuration', value: enterDuration }); } if (exitDuration >= 0) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: 'bg-main', key: 'exitDuration', value: exitDuration }), - ); + stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'exitDuration', value: exitDuration }); } /** @@ -127,17 +115,27 @@ export const changeBg = (sentence: ISentence): IPerform => { } postBgStateSet(); - dispatch(setStage({ key: 'bgName', value: sentence.content })); + stageStateManager.setStage('bgName', sentence.content); return { performName: `bg-main-${sentence.content}`, duration, isHoldOn: false, + settleStateOnDiscard: () => { + if (sentence.content === '' || !isUrlChanged) { + return; + } + const animationName = stageStateManager + .getCalculationStageState() + .animationSettings.find((setting) => setting.target === 'bg-main')?.enterAnimationName; + if (animationName) { + applyAnimationEndState(animationName, 'bg-main', false); + } + }, stopFunction: () => { WebGAL.gameplay.pixiStage?.stopPresetAnimationOnTarget('bg-main'); }, blockingNext: () => false, blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/gameScripts/changeFigure.ts b/packages/webgal/src/Core/gameScripts/changeFigure.ts index 6c6767a84..91fb96f5f 100644 --- a/packages/webgal/src/Core/gameScripts/changeFigure.ts +++ b/packages/webgal/src/Core/gameScripts/changeFigure.ts @@ -1,18 +1,17 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { webgalStore } from '@/store/store'; -import { setStage, stageActions } from '@/store/stageReducer'; import cloneDeep from 'lodash/cloneDeep'; import { getBooleanArgByKey, getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; -import { IFreeFigure, IStageState, ITransform } from '@/store/stageInterface'; +import { IFreeFigure, IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; import { AnimationFrame, IUserAnimation } from '@/Core/Modules/animations'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; import { logger } from '@/Core/util/logger'; -import { getAnimateDuration } from '@/Core/Modules/animationFunctions'; +import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; import { baseBlinkParam, baseFocusParam, BlinkParam, FocusParam } from '@/Core/live2DCore'; import { DEFAULT_FIG_IN_DURATION, DEFAULT_FIG_OUT_DURATION, WEBGAL_NONE } from '../constants'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 更改立绘 * @param sentence 语句 @@ -97,9 +96,7 @@ export function changeFigure(sentence: ISentence): IPerform { duration = enterDuration; const exitDuration = getNumberArgByKey(sentence, 'exitDuration') ?? DEFAULT_FIG_OUT_DURATION; - const dispatch = webgalStore.dispatch; - - const currentFigureAssociatedAnimation = webgalStore.getState().stage.figureAssociatedAnimation; + const currentFigureAssociatedAnimation = stageStateManager.getCalculationStageState().figureAssociatedAnimation; const filteredFigureAssociatedAnimation = currentFigureAssociatedAnimation.filter((item) => item.targetId !== id); const newFigureAssociatedAnimationItem = { targetId: id, @@ -115,14 +112,14 @@ export function changeFigure(sentence: ISentence): IPerform { }, }; filteredFigureAssociatedAnimation.push(newFigureAssociatedAnimationItem); - dispatch(setStage({ key: 'figureAssociatedAnimation', value: filteredFigureAssociatedAnimation })); + stageStateManager.setStage('figureAssociatedAnimation', filteredFigureAssociatedAnimation); /** * 如果 url 没变,不移除 */ let isUrlChanged = true; if (key !== '') { - const figWithKey = webgalStore.getState().stage.freeFigure.find((e) => e.key === key); + const figWithKey = stageStateManager.getCalculationStageState().freeFigure.find((e) => e.key === key); if (figWithKey) { if (figWithKey.name === sentence.content) { isUrlChanged = false; @@ -130,17 +127,17 @@ export function changeFigure(sentence: ISentence): IPerform { } } else { if (pos === 'center') { - if (webgalStore.getState().stage.figName === sentence.content) { + if (stageStateManager.getCalculationStageState().figName === sentence.content) { isUrlChanged = false; } } if (pos === 'left') { - if (webgalStore.getState().stage.figNameLeft === sentence.content) { + if (stageStateManager.getCalculationStageState().figNameLeft === sentence.content) { isUrlChanged = false; } } if (pos === 'right') { - if (webgalStore.getState().stage.figNameRight === sentence.content) { + if (stageStateManager.getCalculationStageState().figNameRight === sentence.content) { isUrlChanged = false; } } @@ -149,8 +146,8 @@ export function changeFigure(sentence: ISentence): IPerform { * 处理 Effects */ if (isUrlChanged) { - webgalStore.dispatch(stageActions.removeEffectByTargetId(id)); - webgalStore.dispatch(stageActions.removeAnimationSettingsByTarget(id)); + stageStateManager.removeEffectByTargetId(id); + stageStateManager.removeAnimationSettingsByTarget(id); const oldStageObject = WebGAL.gameplay.pixiStage?.getStageObjByKey(id); if (oldStageObject) { oldStageObject.isExiting = true; @@ -174,9 +171,7 @@ export function changeFigure(sentence: ISentence): IPerform { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); duration = getAnimateDuration(animationName); - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }), - ); + stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }); } catch (e) { // 解析都错误了,歇逼吧 applyDefaultTransform(); @@ -195,32 +190,22 @@ export function changeFigure(sentence: ISentence): IPerform { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); duration = getAnimateDuration(animationName); - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }), - ); + stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }); } if (enterAnimation) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: enterAnimation }), - ); + stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: enterAnimation }); duration = getAnimateDuration(enterAnimation); } if (exitAnimation) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: key, key: 'exitAnimationName', value: exitAnimation }), - ); + stageStateManager.updateAnimationSettings({ target: key, key: 'exitAnimationName', value: exitAnimation }); duration = getAnimateDuration(exitAnimation); } if (enterDuration >= 0) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: key, key: 'enterDuration', value: enterDuration }), - ); + stageStateManager.updateAnimationSettings({ target: key, key: 'enterDuration', value: enterDuration }); } if (exitDuration >= 0) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: key, key: 'exitDuration', value: exitDuration }), - ); + stageStateManager.updateAnimationSettings({ target: key, key: 'exitDuration', value: exitDuration }); } }; @@ -235,32 +220,32 @@ export function changeFigure(sentence: ISentence): IPerform { focus = focus ?? cloneDeep(baseFocusParam); zIndex = Math.max(zIndex, 0); blendMode = blendMode ?? 'normal'; - dispatch(stageActions.setLive2dMotion({ target: key, motion, skin, overrideBounds: bounds })); - dispatch(stageActions.setLive2dExpression({ target: key, expression })); - dispatch(stageActions.setLive2dBlink({ target: key, blink })); - dispatch(stageActions.setLive2dFocus({ target: key, focus })); - dispatch(stageActions.setFigureMetaData([key, 'zIndex', zIndex, false])); - dispatch(stageActions.setFigureMetaData([key, 'blendMode', blendMode, false])); + stageStateManager.setLive2dMotion({ target: key, motion, skin, overrideBounds: bounds }); + stageStateManager.setLive2dExpression({ target: key, expression }); + stageStateManager.setLive2dBlink({ target: key, blink }); + stageStateManager.setLive2dFocus({ target: key, focus }); + stageStateManager.setFigureMetaData([key, 'zIndex', zIndex, false]); + stageStateManager.setFigureMetaData([key, 'blendMode', blendMode, false]); } else { // 当 url 没有发生变化时,即没有新立绘替换 // 应当保留旧立绘的状态,仅在需要时更新 if (motion || skin || bounds) { - dispatch(stageActions.setLive2dMotion({ target: key, motion, skin, overrideBounds: bounds })); + stageStateManager.setLive2dMotion({ target: key, motion, skin, overrideBounds: bounds }); } if (expression) { - dispatch(stageActions.setLive2dExpression({ target: key, expression })); + stageStateManager.setLive2dExpression({ target: key, expression }); } if (blink) { - dispatch(stageActions.setLive2dBlink({ target: key, blink })); + stageStateManager.setLive2dBlink({ target: key, blink }); } if (focus) { - dispatch(stageActions.setLive2dFocus({ target: key, focus })); + stageStateManager.setLive2dFocus({ target: key, focus }); } if (zIndex >= 0) { - dispatch(stageActions.setFigureMetaData([key, 'zIndex', zIndex, false])); + stageStateManager.setFigureMetaData([key, 'zIndex', zIndex, false]); } if (blendMode) { - dispatch(stageActions.setFigureMetaData([key, 'blendMode', blendMode, false])); + stageStateManager.setFigureMetaData([key, 'blendMode', blendMode, false]); } } } @@ -272,7 +257,7 @@ export function changeFigure(sentence: ISentence): IPerform { const freeFigureItem: IFreeFigure = { key, name: content, basePosition: pos }; setAnimationNames(key, sentence); postFigureStateSet(); - dispatch(stageActions.setFreeFigureByKey(freeFigureItem)); + stageStateManager.setFreeFigureByKey(freeFigureItem); } else { /** * 下面的代码是设置与位置关联的立绘的 @@ -291,19 +276,29 @@ export function changeFigure(sentence: ISentence): IPerform { key = positionMap[pos]; setAnimationNames(key, sentence); postFigureStateSet(); - dispatch(setStage({ key: dispatchMap[pos], value: content })); + stageStateManager.setStage(dispatchMap[pos], content); } return { performName: `enter-${key}`, duration, isHoldOn: false, + settleStateOnDiscard: () => { + if (content === '' || !isUrlChanged) { + return; + } + const animationName = stageStateManager + .getCalculationStageState() + .animationSettings.find((setting) => setting.target === key)?.enterAnimationName; + if (animationName) { + applyAnimationEndState(animationName, key, false); + } + }, stopFunction: () => { WebGAL.gameplay.pixiStage?.stopPresetAnimationOnTarget(key); }, blockingNext: () => false, blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; } diff --git a/packages/webgal/src/Core/gameScripts/changeSceneScript.ts b/packages/webgal/src/Core/gameScripts/changeSceneScript.ts index 91a2d17a7..ed50f8fb2 100644 --- a/packages/webgal/src/Core/gameScripts/changeSceneScript.ts +++ b/packages/webgal/src/Core/gameScripts/changeSceneScript.ts @@ -1,5 +1,5 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { changeScene } from '../controller/scene/changeScene'; /** @@ -10,13 +10,5 @@ export const changeSceneScript = (sentence: ISentence): IPerform => { const sceneNameArray: Array = sentence.content.split('/'); const sceneName = sceneNameArray[sceneNameArray.length - 1]; changeScene(sentence.content, sceneName); - return { - performName: 'none', - duration: 0, - isHoldOn: true, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform({ isHoldOn: true }); }; diff --git a/packages/webgal/src/Core/gameScripts/choose/index.tsx b/packages/webgal/src/Core/gameScripts/choose/index.tsx index 6b48a1758..c803a8507 100644 --- a/packages/webgal/src/Core/gameScripts/choose/index.tsx +++ b/packages/webgal/src/Core/gameScripts/choose/index.tsx @@ -1,12 +1,11 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { changeScene } from '@/Core/controller/scene/changeScene'; import { jmp } from '@/Core/gameScripts/label/jmp'; import ReactDOM from 'react-dom'; import React from 'react'; import styles from './choose.module.scss'; import { webgalStore } from '@/store/store'; -import { PerformController } from '@/Core/Modules/perform/performController'; import { useSEByWebgalStore } from '@/hooks/useSoundEffect'; import { WebGAL } from '@/Core/WebGAL'; import { whenChecker } from '@/Core/controller/gamePlay/scriptExecutor'; @@ -60,36 +59,43 @@ export const choose = (sentence: ISentence): IPerform => { const chooseOptionScripts = sentence.content.split(/(? ChooseOption.parse(e.trim())); const defaultChoose = getNumberArgByKey(sentence, 'defaultChoose'); - const previewChoice = getDefaultPreviewChoice(chooseOptions, defaultChoose); + const defaultPreviewChoice = getDefaultPreviewChoice(chooseOptions, defaultChoose); - // eslint-disable-next-line react/no-deprecated - ReactDOM.render( - - - , - document.getElementById('chooseContainer'), - ); - if (previewChoice) { - setTimeout(() => { - selectChooseOption(previewChoice); - WebGAL.gameplay.performController.unmountPerform('choose'); - }, 0); + if (defaultPreviewChoice) { + selectChooseOption(defaultPreviewChoice, false); + if (!defaultPreviewChoice.jumpToScene) { + // The default preview choice is resolved during script calculation. + // Let scriptExecutor continue from the target label in this same forward. + sentence.args.push({ key: 'next', value: true }); + } + return createNonePerform({ blockingAuto: false }); } + return { performName: 'choose', duration: 1000 * 60 * 60 * 24, isHoldOn: false, + startFunction: () => { + // eslint-disable-next-line react/no-deprecated + ReactDOM.render( + + + , + document.getElementById('chooseContainer'), + ); + }, stopFunction: () => { // eslint-disable-next-line react/no-deprecated ReactDOM.render(
, document.getElementById('chooseContainer')); }, blockingNext: () => true, blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 + blockingStateCalculation: () => true, }; }; function getDefaultPreviewChoice(chooseOptions: ChooseOption[], defaultChoose: number | null): ChooseOption | null { + // Only realtime preview may consume defaultChoose automatically; ordinary fast-forward must still wait. if (!WebGAL.gameplay.isFastPreview || defaultChoose === null) { return null; } @@ -98,14 +104,17 @@ function getDefaultPreviewChoice(chooseOptions: ChooseOption[], defaultChoose: n return null; } const defaultOption = chooseOptions[chooseIndex]; - return defaultOption ?? null; + if (!defaultOption || !whenChecker(defaultOption.showCondition) || !whenChecker(defaultOption.enableCondition)) { + return null; + } + return defaultOption; } -function selectChooseOption(option: ChooseOption) { +function selectChooseOption(option: ChooseOption, autoNext = true) { if (option.jumpToScene) { changeScene(option.jump, option.text); } else { - jmp(option.jump); + jmp(option.jump, autoNext); } } @@ -125,8 +134,8 @@ function Choose(props: { chooseOptions: ChooseOption[] }) { const onClick = enable ? () => { playSeClick(); - selectChooseOption(e); WebGAL.gameplay.performController.unmountPerform('choose'); + selectChooseOption(e); } : () => {}; return ( diff --git a/packages/webgal/src/Core/gameScripts/comment.ts b/packages/webgal/src/Core/gameScripts/comment.ts index 8d2b9917d..1380ce5c9 100644 --- a/packages/webgal/src/Core/gameScripts/comment.ts +++ b/packages/webgal/src/Core/gameScripts/comment.ts @@ -1,5 +1,5 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { logger } from '@/Core/util/logger'; /** @@ -8,13 +8,5 @@ import { logger } from '@/Core/util/logger'; */ export const comment = (sentence: ISentence): IPerform => { logger.debug(`脚本内注释${sentence.content}`); - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/end.ts b/packages/webgal/src/Core/gameScripts/end.ts index 3902472bf..b2c041f60 100644 --- a/packages/webgal/src/Core/gameScripts/end.ts +++ b/packages/webgal/src/Core/gameScripts/end.ts @@ -1,5 +1,5 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; import { sceneFetcher } from '@/Core/controller/scene/sceneFetcher'; import { sceneParser } from '@/Core/parser/sceneParser'; @@ -32,13 +32,5 @@ export const end = (sentence: ISentence): IPerform => { }); dispatch(setVisibility({ component: 'showTitle', visibility: true })); playBgm(webgalStore.getState().GUI.titleBgm); - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/filmMode.ts b/packages/webgal/src/Core/gameScripts/filmMode.ts index 3009cc215..d33fa0e8a 100644 --- a/packages/webgal/src/Core/gameScripts/filmMode.ts +++ b/packages/webgal/src/Core/gameScripts/filmMode.ts @@ -1,7 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 语句执行的模板代码 @@ -9,17 +8,9 @@ import { setStage } from '@/store/stageReducer'; */ export const filmMode = (sentence: ISentence): IPerform => { if (sentence.content !== '' && sentence.content !== 'none') { - webgalStore.dispatch(setStage({ key: 'enableFilm', value: sentence.content })); + stageStateManager.setStage('enableFilm', sentence.content); } else { - webgalStore.dispatch(setStage({ key: 'enableFilm', value: '' })); + stageStateManager.setStage('enableFilm', ''); } - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx b/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx index 606aa8adc..c58780518 100644 --- a/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx +++ b/packages/webgal/src/Core/gameScripts/getUserInput/index.tsx @@ -1,21 +1,19 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { changeScene } from '@/Core/controller/scene/changeScene'; import { jmp } from '@/Core/gameScripts/label/jmp'; import ReactDOM from 'react-dom'; import React from 'react'; import styles from './getUserInput.module.scss'; -import { webgalStore } from '@/store/store'; -import { PerformController } from '@/Core/Modules/perform/performController'; import { useSEByWebgalStore } from '@/hooks/useSoundEffect'; import { WebGAL } from '@/Core/WebGAL'; import { getStringArgByKey } from '@/Core/util/getSentenceArg'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; -import { setStageVar } from '@/store/stageReducer'; import { getCurrentFontFamily } from '@/hooks/useFontFamily'; import { logger } from '@/Core/util/logger'; import { tryToRegex } from '@/Core/util/global'; import { showGlogalDialog } from '@/UI/GlobalDialog/GlobalDialog'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 显示选择枝 @@ -34,6 +32,15 @@ export const getUserInput = (sentence: ISentence): IPerform => { const ruleText = getStringArgByKey(sentence, 'ruleText'); const ruleButtonText = getStringArgByKey(sentence, 'ruleButtonText') ?? 'OK'; + // Only realtime preview may synthesize input; ordinary fast-forward must still wait for the user. + if (WebGAL.gameplay.isFastPreview) { + stageStateManager.setStageVar({ + key: varKey, + value: defaultValue ?? '', + }); + return createNonePerform({ blockingAuto: false }); + } + const font = getCurrentFontFamily(); const { playSeEnter, playSeClick } = useSEByWebgalStore(); @@ -41,7 +48,7 @@ export const getUserInput = (sentence: ISentence): IPerform => {
{title}
- +
{ @@ -61,12 +68,10 @@ export const getUserInput = (sentence: ISentence): IPerform => { } } if (userInput) { - webgalStore.dispatch( - setStageVar({ - key: varKey, - value: userInput?.value || defaultValue || ' ', - }), - ); + stageStateManager.setStageVarAndCommit({ + key: varKey, + value: userInput?.value || defaultValue || ' ', + }); } playSeClick(); WebGAL.gameplay.performController.unmountPerform('userInput'); @@ -79,21 +84,23 @@ export const getUserInput = (sentence: ISentence): IPerform => {
); - // eslint-disable-next-line react/no-deprecated - ReactDOM.render( -
{chooseElements}
, - document.getElementById('chooseContainer'), - ); return { performName: 'userInput', duration: 1000 * 60 * 60 * 24, isHoldOn: false, + startFunction: () => { + // eslint-disable-next-line react/no-deprecated + ReactDOM.render( +
{chooseElements}
, + document.getElementById('chooseContainer'), + ); + }, stopFunction: () => { // eslint-disable-next-line react/no-deprecated ReactDOM.render(
, document.getElementById('chooseContainer')); }, blockingNext: () => true, blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 + blockingStateCalculation: () => true, }; }; diff --git a/packages/webgal/src/Core/gameScripts/intro.tsx b/packages/webgal/src/Core/gameScripts/intro.tsx index 14c04f58a..99a7a37a5 100644 --- a/packages/webgal/src/Core/gameScripts/intro.tsx +++ b/packages/webgal/src/Core/gameScripts/intro.tsx @@ -3,11 +3,7 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; import React from 'react'; import ReactDOM from 'react-dom'; import styles from '@/Stage/FullScreenPerform/fullScreenPerform.module.scss'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; -import { PerformController } from '@/Core/Modules/perform/performController'; -import { logger } from '@/Core/util/logger'; import { WebGAL } from '@/Core/WebGAL'; -import { get, replace } from 'lodash'; import useEscape from '@/hooks/useEscape'; import { getBooleanArgByKey, getNumberArgByKey, getStringArgByKey } from '../util/getSentenceArg'; /** @@ -78,16 +74,14 @@ export const intro = (sentence: ISentence): IPerform => { let baseDuration = endWait + delayTime * introArray.length; const duration = isHold ? 1000 * 60 * 60 * 24 : 1000 + delayTime * introArray.length; let isBlocking = true; - let setBlockingStateTimeout = setTimeout(() => { - isBlocking = false; - }, baseDuration); - let timeout = setTimeout(() => {}); + let setBlockingStateTimeout: ReturnType | undefined; + let timeout: ReturnType | undefined; const toNextIntroElement = () => { const introContainer = document.getElementById('introContainer'); // 由于用户操作,相当于时间向前推进,这时候更新这个演出的预计完成时间 baseDuration -= delayTime; - clearTimeout(setBlockingStateTimeout); + if (setBlockingStateTimeout) clearTimeout(setBlockingStateTimeout); setBlockingStateTimeout = setTimeout(() => { isBlocking = false; }, baseDuration); @@ -112,8 +106,8 @@ export const intro = (sentence: ISentence): IPerform => { } } if (isEnd) { - clearTimeout(timeout); - clearTimeout(setBlockingStateTimeout); + if (timeout) clearTimeout(timeout); + if (setBlockingStateTimeout) clearTimeout(setBlockingStateTimeout); WebGAL.gameplay.performController.unmountPerform(performName); } return; @@ -129,13 +123,13 @@ export const intro = (sentence: ISentence): IPerform => { if (index === len - 1) { // 并且已经完全显示了,这时候进行下一步 if (currentDelay === 0) { - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); WebGAL.gameplay.performController.unmountPerform(performName); // 卸载函数发生在 nextSentence 生效前,所以不需要做下一行的操作。 // setTimeout(nextSentence, 0); } else { // 还没有完全显示,但是因为时间的推进,要提前完成演出,更新用于结束演出的计时器 - clearTimeout(timeout); + if (timeout) clearTimeout(timeout); // 如果 Hold 了,自然不要自动结束 if (!isHold) { timeout = setTimeout(() => { @@ -149,10 +143,8 @@ export const intro = (sentence: ISentence): IPerform => { }; /** - * 接受 next 事件 + * 构造 intro 视图。真正挂载必须等 commit 后的 startFunction。 */ - WebGAL.events.userInteractNext.on(toNextIntroElement); - const showIntro = introArray.map((e, i) => (
{
{showIntro}
); - // eslint-disable-next-line react/no-deprecated - ReactDOM.render(intro, document.getElementById('introContainer')); - const introContainer = document.getElementById('introContainer'); - - if (introContainer) { - introContainer.style.display = 'block'; - } return { performName, duration, isHoldOn: false, + startFunction: () => { + isBlocking = true; + setBlockingStateTimeout = setTimeout(() => { + isBlocking = false; + }, baseDuration); + WebGAL.events.userInteractNext.on(toNextIntroElement); + // eslint-disable-next-line react/no-deprecated + ReactDOM.render(intro, document.getElementById('introContainer')); + const introContainer = document.getElementById('introContainer'); + + if (introContainer) { + introContainer.style.display = 'block'; + } + }, stopFunction: () => { const introContainer = document.getElementById('introContainer'); if (introContainer) { introContainer.style.display = 'none'; } + if (timeout) clearTimeout(timeout); + if (setBlockingStateTimeout) clearTimeout(setBlockingStateTimeout); WebGAL.events.userInteractNext.off(toNextIntroElement); }, blockingNext: () => isBlocking, blockingAuto: () => isBlocking, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 goNextWhenOver: true, }; }; diff --git a/packages/webgal/src/Core/gameScripts/jumpLabel.ts b/packages/webgal/src/Core/gameScripts/jumpLabel.ts index 9baaf2b4c..4388a9379 100644 --- a/packages/webgal/src/Core/gameScripts/jumpLabel.ts +++ b/packages/webgal/src/Core/gameScripts/jumpLabel.ts @@ -1,5 +1,5 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { jmp } from '@/Core/gameScripts/label/jmp'; /** @@ -8,13 +8,5 @@ import { jmp } from '@/Core/gameScripts/label/jmp'; */ export const jumpLabel = (sentence: ISentence): IPerform => { jmp(sentence.content); - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/label/index.ts b/packages/webgal/src/Core/gameScripts/label/index.ts index c0e2f4b99..e6d52203f 100644 --- a/packages/webgal/src/Core/gameScripts/label/index.ts +++ b/packages/webgal/src/Core/gameScripts/label/index.ts @@ -1,18 +1,10 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; /** * 标签代码,什么也不做 * @param sentence */ export const label = (sentence: ISentence): IPerform => { - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/label/jmp.ts b/packages/webgal/src/Core/gameScripts/label/jmp.ts index 0e3f4d1d7..8704b727d 100644 --- a/packages/webgal/src/Core/gameScripts/label/jmp.ts +++ b/packages/webgal/src/Core/gameScripts/label/jmp.ts @@ -1,17 +1,9 @@ -import { commandType } from '@/Core/controller/scene/sceneInterface'; import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { jumpToLabel } from '@/Core/gameScripts/label/jumpToLabel'; -import { WebGAL } from '@/Core/WebGAL'; - -export const jmp = (labelName: string) => { - // 在当前场景中找到指定的标签。 - const currentLine = WebGAL.sceneManager.sceneData.currentSentenceId; - let result = currentLine; - WebGAL.sceneManager.sceneData.currentScene.sentenceList.forEach((sentence, index) => { - if (sentence.command === commandType.label && sentence.content === labelName && index !== currentLine) { - result = index; - } - }); - WebGAL.sceneManager.sceneData.currentSentenceId = result; - setTimeout(nextSentence, 1); +export const jmp = (labelName: string, autoNext = true) => { + const isJumped = jumpToLabel(labelName); + if (isJumped && autoNext) { + setTimeout(nextSentence, 1); + } }; diff --git a/packages/webgal/src/Core/gameScripts/label/jumpToLabel.ts b/packages/webgal/src/Core/gameScripts/label/jumpToLabel.ts new file mode 100644 index 000000000..4a6bf7104 --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/label/jumpToLabel.ts @@ -0,0 +1,22 @@ +import { commandType } from '@/Core/controller/scene/sceneInterface'; +import { WebGAL } from '@/Core/WebGAL'; + +export const jumpToLabel = (labelName: string) => { + const currentLine = WebGAL.sceneManager.sceneData.currentSentenceId; + const currentSentence = WebGAL.sceneManager.sceneData.currentScene.sentenceList[currentLine]; + if (currentSentence?.command === commandType.label && currentSentence.content === labelName) { + return true; + } + + let targetLine = -1; + WebGAL.sceneManager.sceneData.currentScene.sentenceList.forEach((sentence, index) => { + if (sentence.command === commandType.label && sentence.content === labelName && index !== currentLine) { + targetLine = index; + } + }); + if (targetLine < 0) { + return false; + } + WebGAL.sceneManager.sceneData.currentSentenceId = targetLine; + return true; +}; diff --git a/packages/webgal/src/Core/gameScripts/miniAvatar.ts b/packages/webgal/src/Core/gameScripts/miniAvatar.ts index fb5a40351..5d044a36a 100644 --- a/packages/webgal/src/Core/gameScripts/miniAvatar.ts +++ b/packages/webgal/src/Core/gameScripts/miniAvatar.ts @@ -1,7 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 显示小头像 @@ -12,14 +11,6 @@ export const miniAvatar = (sentence: ISentence): IPerform => { if (sentence.content === 'none' || sentence.content === '') { content = ''; } - webgalStore.dispatch(setStage({ key: 'miniAvatar', value: content })); - return { - performName: 'none', - duration: 0, - isHoldOn: true, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + stageStateManager.setStage('miniAvatar', content); + return createNonePerform({ isHoldOn: true }); }; diff --git a/packages/webgal/src/Core/gameScripts/pixi/index.ts b/packages/webgal/src/Core/gameScripts/pixi/index.ts index dbd6dec96..512f8e7fe 100644 --- a/packages/webgal/src/Core/gameScripts/pixi/index.ts +++ b/packages/webgal/src/Core/gameScripts/pixi/index.ts @@ -11,27 +11,18 @@ import { WebGAL } from '@/Core/WebGAL'; */ export const pixi = (sentence: ISentence): IPerform => { const pixiPerformName = 'PixiPerform' + sentence.content; - WebGAL.gameplay.performController.performList.forEach((e) => { - if (e.performName === pixiPerformName) { - return { - performName: 'none', - duration: 0, - isOver: false, - isHoldOn: true, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => false, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; - } - }); - const res: IResult = call(sentence.content); - const { fg, bg } = res; + let fg: IResult['fg']; + let bg: IResult['bg']; return { performName: pixiPerformName, duration: 0, isHoldOn: true, + startFunction: () => { + const res: IResult = call(sentence.content); + fg = res.fg; + bg = res.bg; + }, stopFunction: () => { logger.warn('现在正在卸载pixi演出'); if (fg) { @@ -47,6 +38,5 @@ export const pixi = (sentence: ISentence): IPerform => { }, blockingNext: () => false, blockingAuto: () => false, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/gameScripts/pixi/pixiInit.ts b/packages/webgal/src/Core/gameScripts/pixi/pixiInit.ts index 60c7a5aef..d44190b18 100644 --- a/packages/webgal/src/Core/gameScripts/pixi/pixiInit.ts +++ b/packages/webgal/src/Core/gameScripts/pixi/pixiInit.ts @@ -1,46 +1,17 @@ import { commandType, ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { logger } from '@/Core/util/logger'; -import { webgalStore } from '@/store/store'; -import { resetStageState, stageActions } from '@/store/stageReducer'; -import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; -import { IRunPerform } from '@/store/stageInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 初始化pixi * @param sentence */ export const pixiInit = (sentence: ISentence): IPerform => { - WebGAL.gameplay.performController.performList.forEach((e) => { - if (e.performName.match(/PixiPerform/)) { - logger.warn('pixi 被脚本重新初始化', e.performName); - /** - * 卸载演出 - */ - for (let i = 0; i < WebGAL.gameplay.performController.performList.length; i++) { - const e2 = WebGAL.gameplay.performController.performList[i]; - if (e2.performName === e.performName) { - e2.stopFunction(); - clearTimeout(e2.stopTimeout as unknown as number); - WebGAL.gameplay.performController.performList.splice(i, 1); - i--; - } - } - /** - * 从状态表里清除演出 - */ - webgalStore.dispatch(stageActions.removeAllPixiPerforms()); - } - }); - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + logger.warn('pixi 被脚本重新初始化'); + WebGAL.gameplay.performController.unmountPerformByPrefix('PixiPerform', true); + stageStateManager.removeAllPixiPerforms(); + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/playEffect.ts b/packages/webgal/src/Core/gameScripts/playEffect.ts index f23d202cb..c9c010a76 100644 --- a/packages/webgal/src/Core/gameScripts/playEffect.ts +++ b/packages/webgal/src/Core/gameScripts/playEffect.ts @@ -1,12 +1,10 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { logger } from '@/Core/util/logger'; -import { RootState, webgalStore } from '@/store/store'; +import { webgalStore } from '@/store/store'; import { getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { useSelector } from 'react-redux'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { WebGAL } from '@/Core/WebGAL'; import { WEBGAL_NONE } from '@/Core/constants'; -import { end } from './end'; /** * 播放一段效果音 @@ -29,85 +27,52 @@ export const playEffect = (sentence: ISentence): IPerform => { isLoop = true; } let isOver = false; + let seElement: HTMLAudioElement | null = null; if (!url || url === WEBGAL_NONE) { - return { - performName: WEBGAL_NONE, - duration: 0, - isHoldOn: false, - blockingAuto(): boolean { - return false; - }, - blockingNext(): boolean { - return false; - }, - stopFunction(): void {}, - stopTimeout: undefined, - }; + return createNonePerform({ blockingAuto: false }); } return { - performName: 'none', + performName: performInitName, + duration: 1000 * 60 * 60, + isHoldOn: isLoop, + skipNextCollect: true, + startFunction: () => { + let volume = getNumberArgByKey(sentence, 'volume') ?? 100; // 获取音量比 + volume = Math.max(0, Math.min(volume, 100)); // 限制音量在 0-100 之间 + seElement = document.createElement('audio'); + seElement.src = url; + if (isLoop) { + seElement.loop = true; + } + const userDataState = webgalStore.getState().userData; + const mainVol = userDataState.optionData.volumeMain; + const seVol = mainVol * 0.01 * (userDataState.optionData?.seVolume ?? 100) * 0.01 * volume * 0.01; + seElement.volume = seVol; + seElement.currentTime = 0; + const endFunc = () => { + isOver = true; + WebGAL.gameplay.performController.unmountPerform(performInitName); + }; + seElement.onended = endFunc; + seElement.addEventListener('error', () => { + logger.error(`播放效果音失败: ${url}`); + endFunc(); + }); + seElement.play().catch(() => {}); + }, blockingAuto(): boolean { - return false; + if (isLoop) return false; + return !isOver; }, blockingNext(): boolean { return false; }, - isHoldOn: false, - stopFunction(): void {}, - stopTimeout: undefined, - - duration: 1000 * 60 * 60, - arrangePerformPromise: new Promise((resolve) => { - // 播放效果音 - setTimeout(() => { - let volume = getNumberArgByKey(sentence, 'volume') ?? 100; // 获取音量比 - volume = Math.max(0, Math.min(volume, 100)); // 限制音量在 0-100 之间 - let seElement = document.createElement('audio'); - seElement.src = url; - if (isLoop) { - seElement.loop = true; - } - const userDataState = webgalStore.getState().userData; - const mainVol = userDataState.optionData.volumeMain; - const seVol = mainVol * 0.01 * (userDataState.optionData?.seVolume ?? 100) * 0.01 * volume * 0.01; - seElement.volume = seVol; - seElement.currentTime = 0; - const perform: IPerform = { - performName: performInitName, - duration: 1000 * 60 * 60, - isHoldOn: isLoop, - skipNextCollect: true, - stopFunction: () => { - // 演出已经结束了,所以不用播放效果音了 - seElement.pause(); - seElement.remove(); - }, - blockingNext: () => false, - blockingAuto: () => { - // loop 的话就不 block auto - if (isLoop) return false; - return !isOver; - }, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; - resolve(perform); - seElement?.play(); - const endFunc = () => { - for (const e of WebGAL.gameplay.performController.performList) { - if (e.performName === performInitName) { - isOver = true; - e.stopFunction(); - WebGAL.gameplay.performController.unmountPerform(e.performName); - } - } - }; - seElement.onended = endFunc; - seElement.addEventListener('error', (e) => { - logger.error(`播放效果音失败: ${url}`); - // 播放失败提前结束 - endFunc(); - }); - }, 1); - }), + stopFunction(): void { + if (!seElement) return; + seElement.onended = null; + seElement.pause(); + seElement.remove(); + seElement = null; + }, }; }; diff --git a/packages/webgal/src/Core/gameScripts/playVideo.tsx b/packages/webgal/src/Core/gameScripts/playVideo.tsx index d3dbd4e69..3c0c21610 100644 --- a/packages/webgal/src/Core/gameScripts/playVideo.tsx +++ b/packages/webgal/src/Core/gameScripts/playVideo.tsx @@ -4,7 +4,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import styles from '@/Stage/FullScreenPerform/fullScreenPerform.module.scss'; import { webgalStore } from '@/store/store'; -import { getRandomPerformName, PerformController } from '@/Core/Modules/perform/performController'; +import { getRandomPerformName } from '@/Core/Modules/perform/performController'; import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; import { WebGAL } from '@/Core/WebGAL'; /** @@ -18,24 +18,43 @@ export const playVideo = (sentence: ISentence): IPerform => { const performInitName: string = getRandomPerformName(); let blockingNextFlag = getBooleanArgByKey(sentence, 'skipOff') ?? false; - - // eslint-disable-next-line react/no-deprecated - ReactDOM.render( -
-
, - document.getElementById('videoContainer'), - ); let isOver = false; + let skipVideo = () => {}; + const restoreVolumeAndUnmount = () => { + WebGAL.events.fullscreenDbClick.off(skipVideo); + /** + * 恢复音量 + */ + const bgmElement: any = document.getElementById('currentBgm'); + if (bgmElement) { + bgmElement.volume = bgmVol.toString(); + } + const vocalElement: any = document.getElementById('currentVocal'); + if (vocalElement) { + vocalElement.volume = vocalVol.toString(); + } + // eslint-disable-next-line react/no-deprecated + ReactDOM.render(
, document.getElementById('videoContainer')); + }; + const endPerform = () => { + isOver = true; + WebGAL.gameplay.performController.unmountPerform(performInitName); + }; + skipVideo = () => { + endPerform(); + }; return { - performName: 'none', - duration: 0, + performName: performInitName, + duration: 1000 * 60 * 60, isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => blockingNextFlag, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - arrangePerformPromise: new Promise((resolve) => { + startFunction: () => { + // eslint-disable-next-line react/no-deprecated + ReactDOM.render( +
+
, + document.getElementById('videoContainer'), + ); /** * 启动视频播放 */ @@ -44,71 +63,31 @@ export const playVideo = (sentence: ISentence): IPerform => { if (VocalControl !== null) { VocalControl.currentTime = 0; VocalControl.volume = bgmVol; - const endPerform = () => { - for (const e of WebGAL.gameplay.performController.performList) { - if (e.performName === performInitName) { - isOver = true; - e.stopFunction(); - WebGAL.gameplay.performController.unmountPerform(e.performName); - } - } - }; - const skipVideo = () => { - endPerform(); - }; // 双击可跳过视频 WebGAL.events.fullscreenDbClick.on(skipVideo); - // 播放并作为一个特别演出加入 - const perform = { - performName: performInitName, - duration: 1000 * 60 * 60, - isOver: false, - isHoldOn: false, - stopFunction: () => { - WebGAL.events.fullscreenDbClick.off(skipVideo); - /** - * 恢复音量 - */ - const bgmElement: any = document.getElementById('currentBgm'); - if (bgmElement) { - bgmElement.volume = bgmVol.toString(); - } - const vocalElement: any = document.getElementById('currentVocal'); - if (bgmElement) { - vocalElement.volume = vocalVol.toString(); - } - // eslint-disable-next-line react/no-deprecated - ReactDOM.render(
, document.getElementById('videoContainer')); - }, - blockingNext: () => blockingNextFlag, - blockingAuto: () => { - return !isOver; - }, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - goNextWhenOver: true, - }; - resolve(perform); /** * 把bgm和语音的音量设为0 */ - const vocalVol2 = 0; - const bgmVol2 = 0; const bgmElement: any = document.getElementById('currentBgm'); if (bgmElement) { - bgmElement.volume = bgmVol2.toString(); + bgmElement.volume = '0'; } const vocalElement: any = document.getElementById('currentVocal'); - if (bgmElement) { - vocalElement.volume = vocalVol2.toString(); + if (vocalElement) { + vocalElement.volume = '0'; } - VocalControl?.play(); + VocalControl?.play().catch(() => {}); VocalControl.onended = () => { endPerform(); }; } }, 1); - }), + }, + stopFunction: restoreVolumeAndUnmount, + blockingNext: () => blockingNextFlag, + blockingAuto: () => !isOver, + goNextWhenOver: true, }; }; diff --git a/packages/webgal/src/Core/gameScripts/say.ts b/packages/webgal/src/Core/gameScripts/say.ts index 020f422c5..9e2fd94bf 100644 --- a/packages/webgal/src/Core/gameScripts/say.ts +++ b/packages/webgal/src/Core/gameScripts/say.ts @@ -2,15 +2,15 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; import { playVocal } from './vocal'; import { webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; import { useTextAnimationDuration, useTextDelay } from '@/hooks/useTextOptions'; -import { getRandomPerformName, PerformController } from '@/Core/Modules/perform/performController'; +import { getRandomPerformName } from '@/Core/Modules/perform/performController'; import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; import { textSize, voiceOption } from '@/store/userDataInterface'; import { WebGAL } from '@/Core/WebGAL'; import { compileSentence } from '@/Stage/TextBox/TextBox'; import { performMouthAnimation } from '@/Core/gameScripts/vocal/vocalAnimation'; import { match } from '@/Core/util/match'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 进行普通对话的显示 @@ -18,9 +18,8 @@ import { match } from '@/Core/util/match'; * @return {IPerform} 执行的演出 */ export const say = (sentence: ISentence): IPerform => { - const stageState = webgalStore.getState().stage; + const stageState = stageStateManager.getCalculationStageState(); const userDataState = webgalStore.getState().userData; - const dispatch = webgalStore.dispatch; let dialogKey = Math.random().toString(); // 生成一个随机的key let dialogToShow = sentence.content; // 获取对话内容 if (dialogToShow) { @@ -36,23 +35,23 @@ export const say = (sentence: ISentence): IPerform => { if (isConcat) { dialogKey = stageState.currentDialogKey; dialogToShow = stageState.showText + dialogToShow; - dispatch(setStage({ key: 'currentConcatDialogPrev', value: stageState.showText })); + stageStateManager.setStage('currentConcatDialogPrev', stageState.showText); } else { - dispatch(setStage({ key: 'currentConcatDialogPrev', value: '' })); + stageStateManager.setStage('currentConcatDialogPrev', ''); } // 设置文本显示 - dispatch(setStage({ key: 'showText', value: dialogToShow })); - dispatch(setStage({ key: 'vocal', value: '' })); + stageStateManager.setStage('showText', dialogToShow); + stageStateManager.setStage('vocal', ''); // 清除语音 if (!(userDataState.optionData.voiceInterruption === voiceOption.no && vocal === null)) { // 只有开关设置为不中断,并且没有语音的时候,才需要不中断 - dispatch(setStage({ key: 'playVocal', value: '' })); + stageStateManager.setStage('playVocal', ''); WebGAL.gameplay.performController.unmountPerform('vocal-play', true); } // 设置key - dispatch(setStage({ key: 'currentDialogKey', value: dialogKey })); + stageStateManager.setStage('currentDialogKey', dialogKey); // 计算延迟 const textDelay = useTextDelay(userDataState.optionData.textSpeed); // 本句延迟 @@ -63,16 +62,16 @@ export const say = (sentence: ISentence): IPerform => { const fontSizeFromArgs = getStringArgByKey(sentence, 'fontSize'); switch (fontSizeFromArgs) { case 'small': - dispatch(setStage({ key: 'showTextSize', value: textSize.small })); + stageStateManager.setStage('showTextSize', textSize.small); break; case 'medium': - dispatch(setStage({ key: 'showTextSize', value: textSize.medium })); + stageStateManager.setStage('showTextSize', textSize.medium); break; case 'large': - dispatch(setStage({ key: 'showTextSize', value: textSize.large })); + stageStateManager.setStage('showTextSize', textSize.large); break; default: - dispatch(setStage({ key: 'showTextSize', value: -1 })); + stageStateManager.setStage('showTextSize', -1); break; } @@ -84,11 +83,10 @@ export const say = (sentence: ISentence): IPerform => { if (clear) { showName = ''; } - dispatch(setStage({ key: 'showName', value: showName })); + stageStateManager.setStage('showName', showName); // 模拟说话 let performSimulateVocalTimeout: ReturnType | null = null; - let performSimulateVocalDelay = 0; let pos: '' | 'center' | 'left' | 'right' = ''; const leftFromArgs = getBooleanArgByKey(sentence, 'left') ?? false; @@ -109,7 +107,7 @@ export const say = (sentence: ISentence): IPerform => { } // 确保结果在 25 到 100 之间 audioLevel = Math.max(15, Math.min(nextAudioLevel, 100)); - const currentStageState = webgalStore.getState().stage; + const currentStageState = stageStateManager.getCalculationStageState(); const figureAssociatedAnimation = currentStageState.figureAssociatedAnimation; const animationItem = figureAssociatedAnimation.find((tid) => tid.targetId === key); const targetKey = key ? key : `fig-${pos}`; @@ -130,11 +128,10 @@ export const say = (sentence: ISentence): IPerform => { }; // 播放一段语音 if (vocal) { - playVocal(sentence); - } else if (key || pos) { - performSimulateVocalDelay = len * 250; - performSimulateVocal(); + WebGAL.gameplay.performController.arrangeNewPerform(playVocal(sentence), sentence, false); } + const shouldSimulateVocal = !vocal && (key !== '' || pos !== ''); + const performSimulateVocalDelay = shouldSimulateVocal ? len * 250 : 0; const performInitName: string = getRandomPerformName(); let endDelay = useTextAnimationDuration(userDataState.optionData.textSpeed) / 2; @@ -147,6 +144,11 @@ export const say = (sentence: ISentence): IPerform => { performName: performInitName, duration: sentenceDelay + endDelay + performSimulateVocalDelay, isHoldOn: false, + startFunction: () => { + if (shouldSimulateVocal) { + performSimulateVocal(); + } + }, stopFunction: () => { WebGAL.events.textSettle.emit(); if (performSimulateVocalTimeout) { @@ -156,7 +158,6 @@ export const say = (sentence: ISentence): IPerform => { }, blockingNext: () => false, blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 goNextWhenOver: isNotend, }; }; diff --git a/packages/webgal/src/Core/gameScripts/setAnimation.ts b/packages/webgal/src/Core/gameScripts/setAnimation.ts index d904823bf..b9c563661 100644 --- a/packages/webgal/src/Core/gameScripts/setAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setAnimation.ts @@ -3,10 +3,10 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; -import { webgalStore } from '@/store/store'; -import { getAnimateDuration, getAnimationObject } from '@/Core/Modules/animationFunctions'; +import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; +import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; /** * 设置背景动画 @@ -27,43 +27,37 @@ export const setAnimation = (sentence: ISentence): IPerform => { let keepAnimationStopped = false; if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); + const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, !parallel); - let stopFunction; - setTimeout(() => { + const startFunction = () => { if (keep && keepAnimationStopped) { return; } WebGAL.gameplay.pixiStage?.stopPresetAnimationOnTarget(target); - const animationObj: IAnimationObject | null = getAnimationObject( - animationName, - target, - animationDuration, - writeDefault, - !parallel, - ); + const animationObj: IAnimationObject | null = animationTimeline + ? generateTimelineObj(animationTimeline, target, animationDuration, false) + : null; if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); WebGAL.gameplay.pixiStage?.registerAnimation(animationObj, key, target); } - }, 0); - stopFunction = () => { + }; + const stopFunction = () => { if (keep) { WebGAL.gameplay.pixiStage?.removeAnimationWithoutSetEndState(key); keepAnimationStopped = true; return; } - setTimeout(() => { - WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects(key); - }, 0); + WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects(key); }; return { performName: performName, duration: animationDuration, isHoldOn: keep, + startFunction, stopFunction, blockingNext: () => false, blockingAuto: () => !keep, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/gameScripts/setComplexAnimation.ts b/packages/webgal/src/Core/gameScripts/setComplexAnimation.ts index bd0a15210..0d4435fa9 100644 --- a/packages/webgal/src/Core/gameScripts/setComplexAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setComplexAnimation.ts @@ -4,16 +4,16 @@ import { getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg import { webgalAnimations } from '@/Core/controller/stage/pixi/animations'; import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; -import { webgalStore } from '@/store/store'; import { WebGAL } from '@/Core/WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 设置背景动画 * @param sentence */ export const setComplexAnimation = (sentence: ISentence): IPerform => { - const startDialogKey = webgalStore.getState().stage.currentDialogKey; + const startDialogKey = stageStateManager.getCalculationStageState().currentDialogKey; const animationName = sentence.content; const animationDuration = getNumberArgByKey(sentence, 'duration') ?? 0; const target = getStringArgByKey(sentence, 'target') ?? '0'; @@ -21,13 +21,21 @@ export const setComplexAnimation = (sentence: ISentence): IPerform => { const key = `${target}-${animationName}-${animationDuration}`; const animationFunction: Function | null = getAnimationObject(animationName); let stopFunction: () => void = () => {}; + let startFunction: () => void = () => {}; if (animationFunction) { - logger.debug(`动画${animationName}作用在${target}`, animationDuration); - const animationObj: IAnimationObject = animationFunction(target, animationDuration); - WebGAL.gameplay.pixiStage?.stopPresetAnimationOnTarget(target); - WebGAL.gameplay.pixiStage?.registerAnimation(animationObj, key, target); + const calculationAnimationObj: IAnimationObject = animationFunction(target, animationDuration); + const endStateEffect = calculationAnimationObj.getEndStateEffect?.(); + if (endStateEffect) { + stageStateManager.updateEffect({ target, transform: endStateEffect }); + } + startFunction = () => { + logger.debug(`动画${animationName}作用在${target}`, animationDuration); + const animationObj: IAnimationObject = animationFunction(target, animationDuration); + WebGAL.gameplay.pixiStage?.stopPresetAnimationOnTarget(target); + WebGAL.gameplay.pixiStage?.registerAnimation(animationObj, key, target); + }; stopFunction = () => { - const endDialogKey = webgalStore.getState().stage.currentDialogKey; + const endDialogKey = stageStateManager.getCalculationStageState().currentDialogKey; const isHasNext = startDialogKey !== endDialogKey; WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects(key); }; @@ -36,10 +44,10 @@ export const setComplexAnimation = (sentence: ISentence): IPerform => { performName: key, duration: animationDuration, isHoldOn: false, + startFunction, stopFunction, blockingNext: () => false, blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/gameScripts/setFilter.ts b/packages/webgal/src/Core/gameScripts/setFilter.ts index 90a5d9e8f..fae958fc6 100644 --- a/packages/webgal/src/Core/gameScripts/setFilter.ts +++ b/packages/webgal/src/Core/gameScripts/setFilter.ts @@ -1,18 +1,10 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; /** * 设置背景效果 * @param sentence */ export const setFilter = (sentence: ISentence): IPerform => { - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index 8bbe766de..d7c31c273 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -3,14 +3,11 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; import { getBooleanArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; -import { webgalStore } from '@/store/store'; -import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; -import cloneDeep from 'lodash/cloneDeep'; -import { baseTransform } from '@/store/stageInterface'; import { IUserAnimation } from '../Modules/animations'; -import { getAnimateDuration, getAnimationObject } from '@/Core/Modules/animationFunctions'; +import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; import { v4 as uuid } from 'uuid'; +import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; /** * 设置临时动画 @@ -39,43 +36,37 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { let keepAnimationStopped = false; if (!parallel) WebGAL.gameplay.performController.unmountPerform(performInitName, true); + const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, !parallel); - let stopFunction = () => {}; - setTimeout(() => { + const startFunction = () => { if (keep && keepAnimationStopped) { return; } WebGAL.gameplay.pixiStage?.stopPresetAnimationOnTarget(target); - const animationObj: IAnimationObject | null = getAnimationObject( - animationName, - target, - animationDuration, - writeDefault, - !parallel, - ); + const animationObj: IAnimationObject | null = animationTimeline + ? generateTimelineObj(animationTimeline, target, animationDuration, false) + : null; if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); WebGAL.gameplay.pixiStage?.registerAnimation(animationObj, key, target); } - }, 0); - stopFunction = () => { + }; + const stopFunction = () => { if (keep) { WebGAL.gameplay.pixiStage?.removeAnimationWithoutSetEndState(key); keepAnimationStopped = true; return; } - setTimeout(() => { - WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects(key); - }, 0); + WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects(key); }; return { performName: performName, duration: animationDuration, isHoldOn: keep, + startFunction, stopFunction, blockingNext: () => false, blockingAuto: () => !keep, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/gameScripts/setTextbox.ts b/packages/webgal/src/Core/gameScripts/setTextbox.ts index f112cee41..aca2fea93 100644 --- a/packages/webgal/src/Core/gameScripts/setTextbox.ts +++ b/packages/webgal/src/Core/gameScripts/setTextbox.ts @@ -1,7 +1,6 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 语句执行的模板代码 @@ -9,17 +8,9 @@ import { setStage } from '@/store/stageReducer'; */ export function setTextbox(sentence: ISentence): IPerform { if (sentence.content === 'hide') { - webgalStore.dispatch(setStage({ key: 'isDisableTextbox', value: true })); + stageStateManager.setStage('isDisableTextbox', true); } else { - webgalStore.dispatch(setStage({ key: 'isDisableTextbox', value: false })); + stageStateManager.setStage('isDisableTextbox', false); } - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); } diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index 3bd29819a..ef6bc8f30 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -1,17 +1,14 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; import { getBooleanArgByKey, getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; -import PixiStage, { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; +import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; -import { webgalStore } from '@/store/store'; -import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; -import cloneDeep from 'lodash/cloneDeep'; -import { baseTransform, ITransform } from '@/store/stageInterface'; import { AnimationFrame, IUserAnimation } from '../Modules/animations'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { WebGAL } from '@/Core/WebGAL'; -import { getAnimateDuration, getAnimationObject } from '../Modules/animationFunctions'; +import { applyAnimationEndState, getAnimateDuration } from '../Modules/animationFunctions'; import { v4 as uuid } from 'uuid'; +import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; /** * 设置变换 * @param sentence @@ -46,43 +43,38 @@ export const setTransform = (sentence: ISentence): IPerform => { const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; WebGAL.animationManager.addAnimation(newAnimation); const animationDuration = getAnimateDuration(animationName); + const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, !parallel); const key = `${target}-${animationName}-${animationDuration}`; let keepAnimationStopped = false; - setTimeout(() => { + const startFunction = () => { if (keep && keepAnimationStopped) { return; } WebGAL.gameplay.pixiStage?.stopPresetAnimationOnTarget(target); - const animationObj: IAnimationObject | null = getAnimationObject( - animationName, - target, - animationDuration, - writeDefault, - !parallel, - ); + const animationObj: IAnimationObject | null = animationTimeline + ? generateTimelineObj(animationTimeline, target, animationDuration, false) + : null; if (animationObj) { logger.debug(`动画${animationName}作用在${target}`, animationDuration); WebGAL.gameplay.pixiStage?.registerAnimation(animationObj, key, target); } - }, 0); + }; const stopFunction = () => { if (keep) { WebGAL.gameplay.pixiStage?.removeAnimationWithoutSetEndState(key); keepAnimationStopped = true; return; } - setTimeout(() => { - WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects(key); - }, 0); + WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects(key); }; return { performName: performName, duration: animationDuration, isHoldOn: keep, + startFunction, stopFunction, blockingNext: () => false, blockingAuto: () => !keep, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/gameScripts/setTransition.ts b/packages/webgal/src/Core/gameScripts/setTransition.ts index c2361ac46..1f9f82685 100644 --- a/packages/webgal/src/Core/gameScripts/setTransition.ts +++ b/packages/webgal/src/Core/gameScripts/setTransition.ts @@ -1,10 +1,7 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; -import { webgalStore } from '@/store/store'; -import cloneDeep from 'lodash/cloneDeep'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { getStringArgByKey } from '@/Core/util/getSentenceArg'; -import { setStage, stageActions } from '@/store/stageReducer'; -import { WebGAL } from '@/Core/WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 设置转场效果 @@ -16,22 +13,10 @@ export const setTransition = (sentence: ISentence): IPerform => { const enterAnimation = getStringArgByKey(sentence, 'enter'); const exitAnimation = getStringArgByKey(sentence, 'exit'); if (enterAnimation) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: enterAnimation }), - ); + stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: enterAnimation }); } if (exitAnimation) { - webgalStore.dispatch( - stageActions.updateAnimationSettings({ target: key, key: 'exitAnimationName', value: exitAnimation }), - ); + stageStateManager.updateAnimationSettings({ target: key, key: 'exitAnimationName', value: exitAnimation }); } - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => false, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform({ blockingAuto: false }); }; diff --git a/packages/webgal/src/Core/gameScripts/setVar.ts b/packages/webgal/src/Core/gameScripts/setVar.ts index 75fdfa801..584de1f93 100644 --- a/packages/webgal/src/Core/gameScripts/setVar.ts +++ b/packages/webgal/src/Core/gameScripts/setVar.ts @@ -1,17 +1,16 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { webgalStore } from '@/store/store'; -import { setStageVar } from '@/store/stageReducer'; import { logger } from '@/Core/util/logger'; import { compile } from 'angular-expressions'; import { setScriptManagedGlobalVar } from '@/store/userDataReducer'; -import { ActionCreatorWithPayload } from '@reduxjs/toolkit'; -import { ISetGameVar } from '@/store/stageInterface'; +import { ISetGameVar } from '@/Core/Modules/stage/stageInterface'; import { dumpToStorageFast } from '@/Core/controller/storage/storageController'; import expression from 'angular-expressions'; import get from 'lodash/get'; import random from 'lodash/random'; import { getBooleanArgByKey } from '../util/getSentenceArg'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 设置变量 @@ -19,18 +18,19 @@ import { getBooleanArgByKey } from '../util/getSentenceArg'; */ export const setVar = (sentence: ISentence): IPerform => { let setGlobal = getBooleanArgByKey(sentence, 'global') ?? false; - let targetReducerFunction: ActionCreatorWithPayload; - if (setGlobal) { - targetReducerFunction = setScriptManagedGlobalVar; - } else { - targetReducerFunction = setStageVar; - } + const setGameVar = (payload: ISetGameVar) => { + if (setGlobal) { + webgalStore.dispatch(setScriptManagedGlobalVar(payload)); + } else { + stageStateManager.setStageVar(payload); + } + }; // 先把表达式拆分为变量名和赋值语句 if (sentence.content.match(/\s*=\s*/)) { const key = sentence.content.split(/\s*=\s*/)[0]; const valExp = sentence.content.split(/\s*=\s*/)[1]; if (/^\s*[a-zA-Z_$][\w$]*\s*\(.*\)\s*$/.test(valExp)) { - webgalStore.dispatch(targetReducerFunction({ key, value: EvaluateExpression(valExp) })); + setGameVar({ key, value: EvaluateExpression(valExp) }); } else if (valExp.match(/[+\-*\/()]/)) { // 如果包含加减乘除号,则运算 // 先取出运算表达式中的变量 @@ -53,40 +53,32 @@ export const setVar = (sentence: ISentence): IPerform => { } catch (e) { logger.error('expression compile error', e); } - webgalStore.dispatch(targetReducerFunction({ key, value: result })); + setGameVar({ key, value: result }); } else if (valExp.match(/true|false/)) { if (valExp.match(/true/)) { - webgalStore.dispatch(targetReducerFunction({ key, value: true })); + setGameVar({ key, value: true }); } if (valExp.match(/false/)) { - webgalStore.dispatch(targetReducerFunction({ key, value: false })); + setGameVar({ key, value: false }); } } else if (valExp.length === 0) { - webgalStore.dispatch(targetReducerFunction({ key, value: '' })); + setGameVar({ key, value: '' }); } else { if (!isNaN(Number(valExp))) { - webgalStore.dispatch(targetReducerFunction({ key, value: Number(valExp) })); + setGameVar({ key, value: Number(valExp) }); } else { // 字符串 - webgalStore.dispatch(targetReducerFunction({ key, value: getValueFromStateElseKey(valExp, true) })); + setGameVar({ key, value: getValueFromStateElseKey(valExp, true) }); } } if (setGlobal) { logger.debug('设置全局变量:', { key, value: webgalStore.getState().userData.globalGameVar[key] }); dumpToStorageFast(); } else { - logger.debug('设置变量:', { key, value: webgalStore.getState().stage.GameVar[key] }); + logger.debug('设置变量:', { key, value: stageStateManager.getCalculationStageState().GameVar[key] }); } } - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; type BaseVal = string | number | boolean | undefined; @@ -108,7 +100,7 @@ function EvaluateExpression(val: string) { */ export function getValueFromState(key: string) { let ret: any; - const stage = webgalStore.getState().stage; + const stage = stageStateManager.getCalculationStageState(); const userData = webgalStore.getState().userData; const _Merge = { stage, userData }; // 不要直接合并到一起,防止可能的键冲突 if (stage.GameVar.hasOwnProperty(key)) { diff --git a/packages/webgal/src/Core/gameScripts/showVars.ts b/packages/webgal/src/Core/gameScripts/showVars.ts index 1e677d176..6346c2b0c 100644 --- a/packages/webgal/src/Core/gameScripts/showVars.ts +++ b/packages/webgal/src/Core/gameScripts/showVars.ts @@ -1,11 +1,11 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; import { webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; import { logger } from '@/Core/util/logger'; import { getRandomPerformName } from '@/Core/Modules/perform/performController'; import { PERFORM_CONFIG } from '@/config'; import { WebGAL } from '@/Core/WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 进行普通对话的显示 @@ -13,31 +13,31 @@ import { WebGAL } from '@/Core/WebGAL'; * @return {IPerform} 执行的演出 */ export const showVars = (sentence: ISentence): IPerform => { - const stageState = webgalStore.getState().stage; + const stageState = stageStateManager.getCalculationStageState(); const userDataState = webgalStore.getState().userData; - const dispatch = webgalStore.dispatch; // 设置文本显示 const allVar = { stageGameVar: stageState.GameVar, globalGameVar: userDataState.globalGameVar, }; - dispatch(setStage({ key: 'showText', value: JSON.stringify(allVar) })); - dispatch(setStage({ key: 'showName', value: '展示变量' })); + stageStateManager.setStage('showText', JSON.stringify(allVar)); + stageStateManager.setStage('showName', '展示变量'); logger.debug('展示变量:', allVar); - setTimeout(() => { - WebGAL.events.textSettle.emit(); - }, 0); const performInitName: string = getRandomPerformName(); const endDelay = 750 - userDataState.optionData.textSpeed * 250; return { performName: performInitName, duration: endDelay, isHoldOn: false, + startFunction: () => { + setTimeout(() => { + WebGAL.events.textSettle.emit(); + }, 0); + }, stopFunction: () => { WebGAL.events.textSettle.emit(); }, blockingNext: () => false, blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/gameScripts/template.ts b/packages/webgal/src/Core/gameScripts/template.ts index 4945e9d4a..63a199902 100644 --- a/packages/webgal/src/Core/gameScripts/template.ts +++ b/packages/webgal/src/Core/gameScripts/template.ts @@ -1,18 +1,10 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; /** * 语句执行的模板代码 * @param sentence */ export const template = (sentence: ISentence): IPerform => { - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/unlockBgm.ts b/packages/webgal/src/Core/gameScripts/unlockBgm.ts index 72170dcd0..ce2326a23 100644 --- a/packages/webgal/src/Core/gameScripts/unlockBgm.ts +++ b/packages/webgal/src/Core/gameScripts/unlockBgm.ts @@ -1,5 +1,5 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { webgalStore } from '@/store/store'; import { unlockBgmInUserData } from '@/store/userDataReducer'; import localforage from 'localforage'; @@ -20,13 +20,5 @@ export const unlockBgm = (sentence: ISentence): IPerform => { webgalStore.dispatch(unlockBgmInUserData({ name, url, series })); const userDataState = webgalStore.getState().userData; localforage.setItem(WebGAL.gameKey, userDataState).then(() => {}); - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/unlockCg.ts b/packages/webgal/src/Core/gameScripts/unlockCg.ts index e61add63f..3d8cca97d 100644 --- a/packages/webgal/src/Core/gameScripts/unlockCg.ts +++ b/packages/webgal/src/Core/gameScripts/unlockCg.ts @@ -1,5 +1,5 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; -import { IPerform } from '@/Core/Modules/perform/performInterface'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; import { webgalStore } from '@/store/store'; import { unlockCgInUserData } from '@/store/userDataReducer'; import { logger } from '@/Core/util/logger'; @@ -20,13 +20,5 @@ export const unlockCg = (sentence: ISentence): IPerform => { webgalStore.dispatch(unlockCgInUserData({ name, url, series })); const userDataState = webgalStore.getState().userData; localforage.setItem(WebGAL.gameKey, userDataState).then(() => {}); - return { - performName: 'none', - duration: 0, - isHoldOn: false, - stopFunction: () => {}, - blockingNext: () => false, - blockingAuto: () => true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; + return createNonePerform(); }; diff --git a/packages/webgal/src/Core/gameScripts/vocal/index.ts b/packages/webgal/src/Core/gameScripts/vocal/index.ts index a080b957b..a519f9914 100644 --- a/packages/webgal/src/Core/gameScripts/vocal/index.ts +++ b/packages/webgal/src/Core/gameScripts/vocal/index.ts @@ -1,9 +1,7 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { logger } from '@/Core/util/logger'; -import { webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; import { getBooleanArgByKey, getNumberArgByKey, getStringArgByKey } from '@/Core/util/getSentenceArg'; -import { IStageState } from '@/store/stageInterface'; +import { IStageState } from '@/Core/Modules/stage/stageInterface'; import { audioContextWrapper, ensureAudioContextReady, @@ -13,8 +11,8 @@ import { resetMaxAudioLevel, updateThresholds, } from '@/Core/gameScripts/vocal/vocalAnimation'; -import { match } from '../../util/match'; import { WebGAL } from '@/Core/WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 播放一段语音 @@ -29,7 +27,7 @@ export const playVocal = (sentence: ISentence) => { volume = Math.max(0, Math.min(volume, 100)); // 限制音量在 0-100 之间 let currentStageState: IStageState; - currentStageState = webgalStore.getState().stage; + currentStageState = stageStateManager.getCalculationStageState(); let pos: 'center' | 'left' | 'right' = 'center'; const leftFromArgs = getBooleanArgByKey(sentence, 'left') ?? false; @@ -46,155 +44,138 @@ export const playVocal = (sentence: ISentence) => { const lerpSpeed = 1; // 先停止之前的语音 - let VocalControl: any = document.getElementById('currentVocal'); WebGAL.gameplay.performController.unmountPerform('vocal-play', true); - if (VocalControl !== null) { - VocalControl.currentTime = 0; - VocalControl.pause(); - } // 获得舞台状态 - webgalStore.dispatch(setStage({ key: 'playVocal', value: url })); - webgalStore.dispatch(setStage({ key: 'vocal', value: url })); + stageStateManager.setStage('playVocal', url); + stageStateManager.setStage('vocal', url); + stageStateManager.setStage('vocalVolume', volume); let isOver = false; + let startTimer: ReturnType | undefined; + let blinkEndTimer: ReturnType | undefined; + + const finishPerform = (error?: unknown) => { + if (error) { + logger.warn('Vocal play was blocked by browser autoplay policy or audio activation state.', error); + } + isOver = true; + WebGAL.gameplay.performController.unmountPerform(performInitName); + }; /** * 嘴型同步 */ return { - arrangePerformPromise: new Promise((resolve) => { - // 播放语音 - setTimeout(async () => { - let VocalControl: any = document.getElementById('currentVocal'); - // 设置语音音量 - webgalStore.dispatch(setStage({ key: 'vocalVolume', value: volume })); - // 设置语音 - if (VocalControl !== null) { - VocalControl.currentTime = 0; - // 播放并作为一个特别演出加入 - const perform = { - performName: performInitName, - duration: 1000 * 60 * 60, - isOver: false, - isHoldOn: false, - stopFunction: () => { - clearInterval(audioContextWrapper.audioLevelInterval); - VocalControl.pause(); - key = key ? key : `fig-${pos}`; - const animationItem = figureAssociatedAnimation.find((tid) => tid.targetId === key); - performMouthAnimation({ - audioLevel: 0, - OPEN_THRESHOLD: 1, - HALF_OPEN_THRESHOLD: 1, - currentMouthValue, - lerpSpeed, - key, - animationItem, - pos, - }); - clearTimeout(audioContextWrapper.blinkTimerID); - }, - blockingNext: () => false, - blockingAuto: () => { - return !isOver; - }, - skipNextCollect: true, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 - }; - WebGAL.gameplay.performController.arrangeNewPerform(perform, sentence, false); - const finishPerform = () => { - for (const e of WebGAL.gameplay.performController.performList) { - if (e.performName === performInitName) { - isOver = true; - e.stopFunction(); - WebGAL.gameplay.performController.unmountPerform(e.performName); - } - } - }; - - key = key ? key : `fig-${pos}`; - const animationItem = figureAssociatedAnimation.find((tid) => tid.targetId === key); - if (animationItem) { - resetMaxAudioLevel(); - - const foundFigure = freeFigure.find((figure) => figure.key === key); + performName: performInitName, + duration: 1000 * 60 * 60, + isHoldOn: false, + skipNextCollect: true, + startFunction: () => { + startTimer = setTimeout(async () => { + const VocalControl = document.getElementById('currentVocal') as HTMLMediaElement | null; + if (VocalControl === null) { + isOver = true; + return; + } + VocalControl.currentTime = 0; + key = key ? key : `fig-${pos}`; + const animationItem = figureAssociatedAnimation.find((tid) => tid.targetId === key); + if (animationItem) { + resetMaxAudioLevel(); + const foundFigure = freeFigure.find((figure) => figure.key === key); + + if (foundFigure) { + pos = foundFigure.basePosition; + } - if (foundFigure) { - pos = foundFigure.basePosition; + const isAudioContextReady = await ensureAudioContextReady(); + if (isAudioContextReady && audioContextWrapper.audioContext) { + if (!audioContextWrapper.analyser) { + audioContextWrapper.analyser = audioContextWrapper.audioContext.createAnalyser(); + audioContextWrapper.analyser.fftSize = 256; } - const isAudioContextReady = await ensureAudioContextReady(); - if (isAudioContextReady && audioContextWrapper.audioContext) { - if (!audioContextWrapper.analyser) { - audioContextWrapper.analyser = audioContextWrapper.audioContext.createAnalyser(); - audioContextWrapper.analyser.fftSize = 256; - } - - bufferLength = audioContextWrapper.analyser.frequencyBinCount; - audioContextWrapper.dataArray = new Uint8Array(bufferLength); - let vocalControl = document.getElementById('currentVocal') as HTMLMediaElement; + bufferLength = audioContextWrapper.analyser.frequencyBinCount; + audioContextWrapper.dataArray = new Uint8Array(bufferLength); + const vocalControl = document.getElementById('currentVocal') as HTMLMediaElement; - if (!audioContextWrapper.source || audioContextWrapper.source.mediaElement !== vocalControl) { - if (audioContextWrapper.source) { - audioContextWrapper.source.disconnect(); - } - audioContextWrapper.source = audioContextWrapper.audioContext.createMediaElementSource(vocalControl); - audioContextWrapper.source.connect(audioContextWrapper.analyser); + if (!audioContextWrapper.source || audioContextWrapper.source.mediaElement !== vocalControl) { + if (audioContextWrapper.source) { + audioContextWrapper.source.disconnect(); } - - audioContextWrapper.analyser.connect(audioContextWrapper.audioContext.destination); - - // Lip-sync Animation - audioContextWrapper.audioLevelInterval = setInterval(() => { - const audioLevel = getAudioLevel( - audioContextWrapper.analyser!, - audioContextWrapper.dataArray!, - bufferLength, - ); - const { OPEN_THRESHOLD, HALF_OPEN_THRESHOLD } = updateThresholds(audioLevel); - - performMouthAnimation({ - audioLevel, - OPEN_THRESHOLD, - HALF_OPEN_THRESHOLD, - currentMouthValue, - lerpSpeed, - key, - animationItem, - pos, - }); - }, 50); - } else { - logger.warn('AudioContext is not ready, skip lip-sync analyzer for this vocal.'); + audioContextWrapper.source = audioContextWrapper.audioContext.createMediaElementSource(vocalControl); + audioContextWrapper.source.connect(audioContextWrapper.analyser); } - // blinkAnimation - let animationEndTime: number; + audioContextWrapper.analyser.connect(audioContextWrapper.audioContext.destination); - // 10sec - animationEndTime = Date.now() + 10000; - performBlinkAnimation({ key, animationItem, pos, animationEndTime }); + // Lip-sync Animation + audioContextWrapper.audioLevelInterval = setInterval(() => { + const audioLevel = getAudioLevel( + audioContextWrapper.analyser!, + audioContextWrapper.dataArray!, + bufferLength, + ); + const { OPEN_THRESHOLD, HALF_OPEN_THRESHOLD } = updateThresholds(audioLevel); - // 10sec - setTimeout(() => { - clearTimeout(audioContextWrapper.blinkTimerID); - }, 10000); + performMouthAnimation({ + audioLevel, + OPEN_THRESHOLD, + HALF_OPEN_THRESHOLD, + currentMouthValue, + lerpSpeed, + key, + animationItem, + pos, + }); + }, 50); + } else { + logger.warn('AudioContext is not ready, skip lip-sync analyzer for this vocal.'); } - const playPromise = VocalControl?.play(); + const animationEndTime = Date.now() + 10000; + performBlinkAnimation({ key, animationItem, pos, animationEndTime }); - if (playPromise?.catch) { - playPromise.catch((error: unknown) => { - logger.warn('Vocal play was blocked by browser autoplay policy or audio activation state.', error); - finishPerform(); - }); - } - - VocalControl.onended = finishPerform; + blinkEndTimer = setTimeout(() => { + clearTimeout(audioContextWrapper.blinkTimerID); + }, 10000); } + + VocalControl.play().catch(finishPerform); + + VocalControl.onended = () => { + finishPerform(); + }; }, 1); - }), + }, + stopFunction: () => { + if (startTimer) clearTimeout(startTimer); + if (blinkEndTimer) clearTimeout(blinkEndTimer); + clearInterval(audioContextWrapper.audioLevelInterval); + const VocalControl = document.getElementById('currentVocal') as HTMLMediaElement | null; + if (VocalControl) { + VocalControl.pause(); + VocalControl.onended = null; + } + key = key ? key : `fig-${pos}`; + const animationItem = figureAssociatedAnimation.find((tid) => tid.targetId === key); + performMouthAnimation({ + audioLevel: 0, + OPEN_THRESHOLD: 1, + HALF_OPEN_THRESHOLD: 1, + currentMouthValue, + lerpSpeed, + key, + animationItem, + pos, + }); + clearTimeout(audioContextWrapper.blinkTimerID); + }, + blockingNext: () => false, + blockingAuto: () => { + return !isOver; + }, }; }; diff --git a/packages/webgal/src/Core/gameScripts/wait.ts b/packages/webgal/src/Core/gameScripts/wait.ts index 73d7ce752..028202f5b 100644 --- a/packages/webgal/src/Core/gameScripts/wait.ts +++ b/packages/webgal/src/Core/gameScripts/wait.ts @@ -21,6 +21,5 @@ export const wait = (sentence: ISentence): IPerform => { }, blockingNext: () => nobreak, blockingAuto: () => nobreak, - stopTimeout: undefined, // 暂时不用,后面会交给自动清除 }; }; diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index 2cba7ae38..f2428f344 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -9,10 +9,12 @@ import { sceneParser } from './parser/sceneParser'; import { bindExtraFunc } from '@/Core/util/coreInitialFunction/bindExtraFunc'; import { webSocketFunc } from '@/Core/util/syncWithEditor/webSocketFunc'; import PixiStage from '@/Core/controller/stage/pixi/PixiController'; +import { syncPixiStageState } from '@/Core/controller/stage/pixi/syncPixiStageState'; import axios from 'axios'; import { __INFO } from '@/config/info'; import { WebGAL } from '@/Core/WebGAL'; import { loadTemplate } from '@/Core/util/coreInitialFunction/templateLoader'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; export const isIOS = window.__WEBGAL_DEVICE_INFO__?.isIOS ?? false; // 判断是否是 iOS 终端 @@ -55,6 +57,7 @@ export const initializeScript = (): void => { * 启动Pixi */ WebGAL.gameplay.pixiStage = new PixiStage(); + stageStateManager.setCommitHandler(syncPixiStageState); /** * iOS 设备 卸载所有 Service Worker diff --git a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts index f49fdca9e..2268bcafc 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts @@ -8,7 +8,7 @@ import { initKey } from '@/Core/controller/storage/fastSaveLoad'; import { getFastSaveFromStorage, getSavesFromStorage } from '@/Core/controller/storage/savesController'; import { logger } from '@/Core/util/logger'; import axios from 'axios'; -import { IGameVar } from '@/store/stageInterface'; +import { IGameVar } from '@/Core/Modules/stage/stageInterface'; /** * 获取游戏信息 diff --git a/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts b/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts index c666bfbc6..945a0991a 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/syncWithOrigine.ts @@ -3,93 +3,146 @@ import { setVisibility } from '@/store/GUIReducer'; import { WebGAL } from '@/Core/WebGAL'; import { resetStage } from '@/Core/controller/stage/resetStage'; import { sceneFetcher } from '@/Core/controller/scene/sceneFetcher'; -import { IScene } from '@/Core/controller/scene/sceneInterface'; -import { jumpFromBacklog } from '@/Core/controller/storage/jumpFromBacklog'; -import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; +import { commitForward, forward } from '@/Core/controller/gamePlay/nextSentence'; import { sceneParser } from '@/Core/parser/sceneParser'; import { logger } from '@/Core/util/logger'; import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; -import cloneDeep from 'lodash/cloneDeep'; +import type { IFastPreviewTimeoutPayload } from '@/types/debugProtocol'; -let syncFastTimeout: ReturnType | undefined; +const FAST_PREVIEW_MAX_DURATION_MS = 500; +const FAST_PREVIEW_TIMEOUT_CHECK_INTERVAL = 100; -export const syncWithOrigine = (sceneName: string, sentenceId: number, expermental = false) => { +type FastPreviewTimeoutHandler = (payload: IFastPreviewTimeoutPayload) => void; + +export const syncWithOrigine = ( + sceneName: string, + sentenceId: number, + onFastPreviewTimeout?: FastPreviewTimeoutHandler, +) => { logger.warn('正在跳转到' + sceneName + ':' + sentenceId); WebGAL.gameplay.isFastPreview = false; const dispatch = webgalStore.dispatch; dispatch(setVisibility({ component: 'showTitle', visibility: false })); dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); + dispatch(setVisibility({ component: 'isEnterGame', visibility: true })); dispatch(setVisibility({ component: 'isShowLogo', visibility: false })); const title = document.querySelector('.html-body__title-enter') as HTMLElement; if (title) { title.style.display = 'none'; } - const pastScene = cloneDeep(WebGAL.sceneManager.sceneData.currentScene); // 重新获取场景 const sceneUrl: string = assetSetter(sceneName, fileType.scene); // 场景写入到运行时 sceneFetcher(sceneUrl) .then((rawScene) => { - // 等等,先检查一下能不能恢复场景 - const lastSameSentence = findLastSameSentence(pastScene, WebGAL.sceneManager.sceneData.currentScene, sentenceId); - const lastRecoverySentenceId = Math.min(sentenceId, lastSameSentence); - const recId = findLastAvailableBacklog(lastRecoverySentenceId, sceneName); - const isCanRec = recId >= 0 && expermental; - resetStage(!isCanRec); + resetStage(true); WebGAL.sceneManager.sceneData.currentScene = sceneParser(rawScene, sceneName, sceneUrl); // 开始快进到指定语句 const currentSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; - WebGAL.gameplay.isFast = true; - WebGAL.gameplay.isFastPreview = true; - if (isCanRec) { - jumpFromBacklog(recId, false); - } - if (syncFastTimeout) { - // 之前发生的跳转要清理掉 - clearTimeout(syncFastTimeout); - } - syncFast(sentenceId, currentSceneName); + void syncFast(sentenceId, currentSceneName, onFastPreviewTimeout); }) .catch((e) => { + WebGAL.gameplay.isFast = false; WebGAL.gameplay.isFastPreview = false; - logger.error('快速预览跳转错误', e); + logger.error('实时预览跳转错误', e); }); }; -export function syncFast(sentenceId: number, currentSceneName: string) { - if ( - WebGAL.sceneManager.sceneData.currentSentenceId < sentenceId && - WebGAL.sceneManager.sceneData.currentScene.sceneName === currentSceneName - ) { - nextSentence(); - syncFastTimeout = setTimeout(() => syncFast(sentenceId, currentSceneName), 2); - } else { +export async function syncFast( + sentenceId: number, + currentSceneName: string, + onFastPreviewTimeout?: FastPreviewTimeoutHandler, +) { + const fastPreviewStartTime = performance.now(); + const baseSceneStackDepth = WebGAL.sceneManager.sceneData.sceneStack.length; + WebGAL.gameplay.isFast = true; + WebGAL.gameplay.isFastPreview = true; + let forwardCount = 0; + let isTimedOut = false; + let timeoutElapsedMs = 0; + let suspendedElapsedMs = 0; + + try { + while (shouldContinueFastPreview(sentenceId, currentSceneName, baseSceneStackDepth)) { + const prevSentenceId = WebGAL.sceneManager.sceneData.currentSentenceId; + const prevSceneName = WebGAL.sceneManager.sceneData.currentScene.sceneName; + const isForwarded = forward(); + forwardCount++; + const sceneWriteWaitStart = performance.now(); + const awaitedSceneWrite = await waitForPendingSceneWrite(); + if (awaitedSceneWrite) { + suspendedElapsedMs += performance.now() - sceneWriteWaitStart; + } + + if (!isForwarded && !awaitedSceneWrite) { + break; + } + + if (forwardCount % FAST_PREVIEW_TIMEOUT_CHECK_INTERVAL === 0) { + const elapsedMs = performance.now() - fastPreviewStartTime - suspendedElapsedMs; + if (elapsedMs > FAST_PREVIEW_MAX_DURATION_MS) { + isTimedOut = true; + timeoutElapsedMs = Math.round(elapsedMs); + break; + } + } + + if (WebGAL.gameplay.performController.hasPendingBlockingStateCalculationPerform()) { + logger.warn('实时预览在需要外部输入的语句前停止演算'); + break; + } + + if ( + WebGAL.sceneManager.sceneData.currentSentenceId === prevSentenceId && + WebGAL.sceneManager.sceneData.currentScene.sceneName === prevSceneName && + !awaitedSceneWrite + ) { + logger.warn('实时预览跳转停止:本次 forward 没有推进语句指针'); + break; + } + + } + } finally { WebGAL.gameplay.isFast = false; WebGAL.gameplay.isFastPreview = false; } + + commitForward(); + const forwardedLineCount = + WebGAL.sceneManager.sceneData.currentScene.sceneName === currentSceneName + ? Math.min(WebGAL.sceneManager.sceneData.currentSentenceId, sentenceId) + : sentenceId; + const fastPreviewElapsedMs = Math.round(performance.now() - fastPreviewStartTime - suspendedElapsedMs); + if (isTimedOut) { + const payload: IFastPreviewTimeoutPayload = { + scene: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentence: WebGAL.sceneManager.sceneData.currentSentenceId, + targetSentence: sentenceId, + forwardedLineCount, + elapsedMs: Math.max(timeoutElapsedMs, fastPreviewElapsedMs), + maxDurationMs: FAST_PREVIEW_MAX_DURATION_MS, + }; + logger.warn( + `实时预览快进停止:超过最大耗时 ${FAST_PREVIEW_MAX_DURATION_MS}ms,已快进 ${forwardedLineCount} 行,用时 ${payload.elapsedMs}ms`, + ); + onFastPreviewTimeout?.(payload); + } + logger.info(`实时预览快进完成:快进 ${forwardedLineCount} 行,用时 ${fastPreviewElapsedMs}ms`); } -function findLastSameSentence(oldScene: IScene, newScene: IScene, sentenceId: number): number { - let lastSameSentence = 0; - for (let i = 0; i < sentenceId && i < oldScene.sentenceList.length; i++) { - const oldSentenceStr = JSON.stringify(oldScene.sentenceList[i]); - const newSentenceStr = JSON.stringify(newScene.sentenceList[i]); - if (oldSentenceStr !== newSentenceStr) { - break; - } - lastSameSentence = i; +function shouldContinueFastPreview(sentenceId: number, currentSceneName: string, baseSceneStackDepth: number) { + const sceneData = WebGAL.sceneManager.sceneData; + if (sceneData.currentScene.sceneName === currentSceneName) { + return sceneData.currentSentenceId < sentenceId; } - return lastSameSentence; + return sceneData.sceneStack.length > baseSceneStackDepth; } -function findLastAvailableBacklog(targetSentence: number, sceneName: string) { - let lastAvailable = -1; - WebGAL.backlogManager.getBacklog().forEach((e, i) => { - const recSentenceId = e.saveScene.currentSentenceId; - const recSceneName = e.saveScene.sceneName; - if (recSentenceId <= targetSentence && recSceneName === sceneName) { - lastAvailable = i; - } - }); - return lastAvailable; +async function waitForPendingSceneWrite() { + const sceneWritePromise = WebGAL.sceneManager.sceneWritePromise; + if (!sceneWritePromise) { + return false; + } + await sceneWritePromise; + return true; } diff --git a/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts b/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts index 6e9fb66f8..c7b46c3c7 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/webSocketFunc.ts @@ -1,4 +1,9 @@ -import { DebugCommand, IComponentVisibilityCommand, IDebugMessage } from '@/types/debugProtocol'; +import { + DebugCommand, + IComponentVisibilityCommand, + IDebugMessage, + type IFastPreviewTimeoutPayload, +} from '@/types/debugProtocol'; import { webgalStore } from '@/store/store'; import { setFontOptimization, setVisibility } from '@/store/GUIReducer'; import { WebGAL } from '@/Core/WebGAL'; @@ -9,8 +14,36 @@ import { nextSentence } from '@/Core/controller/gamePlay/nextSentence'; import { resetStage } from '@/Core/controller/stage/resetStage'; import { logger } from '@/Core/util/logger'; import { syncWithOrigine } from './syncWithOrigine'; -import { stageActions } from '@/store/stageReducer'; -import { baseTransform, IEffect } from '@/store/stageInterface'; +import { baseTransform, IEffect } from '@/Core/Modules/stage/stageInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; + +let editorSocket: WebSocket | null = null; + +export function sendDebugMessageToEditor(data: IDebugMessage['data']) { + if (!editorSocket || editorSocket.readyState !== WebSocket.OPEN) { + return false; + } + + const message: IDebugMessage = { + event: 'message', + data, + }; + + editorSocket.send(JSON.stringify(message)); + return true; +} + +function sendFastPreviewTimeoutMessage(payload: IFastPreviewTimeoutPayload) { + sendDebugMessageToEditor({ + command: DebugCommand.FAST_PREVIEW_TIMEOUT, + sceneMsg: { + scene: payload.scene, + sentence: payload.sentence, + }, + stageSyncMsg: stageStateManager.getCalculationStageState(), + message: JSON.stringify(payload), + }); +} export const webSocketFunc = () => { const loc: string = window.location.hostname; @@ -36,20 +69,17 @@ export const webSocketFunc = () => { const socket = new WebSocket(wsUrl); socket.onopen = () => { logger.info('socket已连接'); + editorSocket = socket; function sendStageSyncMessage() { - const message: IDebugMessage = { - event: 'message', - data: { - command: DebugCommand.SYNCFC, - sceneMsg: { - scene: WebGAL.sceneManager.sceneData.currentScene.sceneName, - sentence: WebGAL.sceneManager.sceneData.currentSentenceId, - }, - stageSyncMsg: webgalStore.getState().stage, - message: 'sync', + sendDebugMessageToEditor({ + command: DebugCommand.SYNCFC, + sceneMsg: { + scene: WebGAL.sceneManager.sceneData.currentScene.sceneName, + sentence: WebGAL.sceneManager.sceneData.currentSentenceId, }, - }; - socket.send(JSON.stringify(message)); + stageSyncMsg: stageStateManager.getCalculationStageState(), + message: 'sync', + }); // logger.debug('传送信息', message); setTimeout(sendStageSyncMessage, 1000); } @@ -61,7 +91,7 @@ export const webSocketFunc = () => { const data: IDebugMessage = JSON.parse(str); const message = data.data; if (message.command === DebugCommand.JUMP) { - syncWithOrigine(message.sceneMsg.scene, message.sceneMsg.sentence, message.message === 'exp'); + syncWithOrigine(message.sceneMsg.scene, message.sceneMsg.sentence, sendFastPreviewTimeoutMessage); } if (message.command === DebugCommand.EXE_COMMAND) { const command = message.message; @@ -94,6 +124,7 @@ export const webSocketFunc = () => { WebGAL.sceneManager.sceneData.currentScene = sceneParser(command, 'temp', './temp.txt'); webgalStore.dispatch(setVisibility({ component: 'showTitle', visibility: false })); webgalStore.dispatch(setVisibility({ component: 'showMenuPanel', visibility: false })); + webgalStore.dispatch(setVisibility({ component: 'isEnterGame', visibility: true })); webgalStore.dispatch(setVisibility({ component: 'showPanicOverlay', visibility: false })); setTimeout(() => { nextSentence(); @@ -106,7 +137,7 @@ export const webSocketFunc = () => { if (message.command === DebugCommand.SET_EFFECT) { try { const effect = JSON.parse(message.message) as IEffect; - const targetEffect = webgalStore.getState().stage.effects.find((e) => e.target === effect.target); + const targetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === effect.target); const targetTransform = targetEffect?.transform ? targetEffect.transform : baseTransform; const newTransform = { ...targetTransform, @@ -121,7 +152,7 @@ export const webSocketFunc = () => { }, }; WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(effect.target); - webgalStore.dispatch(stageActions.updateEffect({ target: effect.target, transform: newTransform })); + stageStateManager.updateEffectAndCommit({ target: effect.target, transform: newTransform }); } catch (e) { logger.error(`无法设置效果 ${message.message}, ${e}`); return; @@ -129,6 +160,10 @@ export const webSocketFunc = () => { } }; socket.onerror = () => { + editorSocket = null; logger.info('当前没有连接到 Terre 编辑器'); }; + socket.onclose = () => { + editorSocket = null; + }; }; diff --git a/packages/webgal/src/Core/webgalCore.ts b/packages/webgal/src/Core/webgalCore.ts index 71e85d770..73f37e51e 100644 --- a/packages/webgal/src/Core/webgalCore.ts +++ b/packages/webgal/src/Core/webgalCore.ts @@ -8,6 +8,7 @@ import { Events } from '@/Core/Modules/events'; import { SteamIntegration } from '@/Core/integration/steamIntegration'; import { WebgalTemplate } from '@/types/template'; import { IWebGALStyleObj } from 'webgal-parser/build/types/styleParser'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; export class WebgalCore { public sceneManager = new SceneManager(); @@ -18,6 +19,7 @@ export class WebgalCore { public gameName = ''; public gameKey = ''; public events = new Events(); + public stageManager = stageStateManager; public steam = new SteamIntegration(); public template: WebgalTemplate | null = null; public styleObjects: Map = new Map(); diff --git a/packages/webgal/src/Stage/AudioContainer/AudioContainer.tsx b/packages/webgal/src/Stage/AudioContainer/AudioContainer.tsx index c82e80c92..764f828e2 100644 --- a/packages/webgal/src/Stage/AudioContainer/AudioContainer.tsx +++ b/packages/webgal/src/Stage/AudioContainer/AudioContainer.tsx @@ -1,11 +1,12 @@ import { useSelector } from 'react-redux'; -import { RootState, webgalStore } from '@/store/store'; -import { setStage } from '@/store/stageReducer'; +import { RootState } from '@/store/store'; import { useEffect, useState } from 'react'; import { logger } from '@/Core/util/logger'; +import { useStageState } from '@/hooks/useStageState'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; export const AudioContainer = () => { - const stageStore = useSelector((webgalStore: RootState) => webgalStore.stage); + const stageStore = useStageState(); const titleBgm = useSelector((webgalStore: RootState) => webgalStore.GUI.titleBgm); const isShowTitle = useSelector((webgalStore: RootState) => webgalStore.GUI.showTitle); const userDataState = useSelector((state: RootState) => state.userData); @@ -45,7 +46,7 @@ export const AudioContainer = () => { // 如果音量接近或达到最小值,则设置最终音量(淡出) bgm.volume = 0; // 淡出效果结束后,将 bgm 置空 - webgalStore.dispatch(setStage({ key: 'bgm', value: { src: '', enter: 0, volume: 100 } })); + stageStateManager.setStageAndCommit('bgm', { src: '', enter: 0, volume: 100 }); } else { // 否则增加音量,并递归调用 bgm.volume += volumeStep; @@ -67,8 +68,11 @@ export const AudioContainer = () => { // 如果当前背景音乐元素存在,则淡入淡出 if (bgmElement) { bgmEnter === 0 ? (bgmElement.volume = bgmVol) : bgmFadeIn(bgmElement, bgmVol, bgmEnter); + if (bgmElement.src && isEnterGame) { + bgmElement.play().catch(() => {}); + } } - }, [isShowTitle, titleBgm, stageStore.bgm.src, bgmVol, bgmEnter]); + }, [isShowTitle, titleBgm, stageStore.bgm.src, bgmVol, bgmEnter, isEnterGame]); useEffect(() => { logger.debug(`设置背景音量:${bgmVol}`); @@ -105,7 +109,7 @@ export const AudioContainer = () => { // Processing after sound effects are played uiSeAudioElement.remove(); }); - webgalStore.dispatch(setStage({ key: 'uiSe', value: '' })); + stageStateManager.setStageAndCommit('uiSe', ''); }, [uiSoundEffects]); useEffect(() => { diff --git a/packages/webgal/src/Stage/FigureContainer/FigureContainer.tsx b/packages/webgal/src/Stage/FigureContainer/FigureContainer.tsx index d2f30d6ca..3637f031a 100644 --- a/packages/webgal/src/Stage/FigureContainer/FigureContainer.tsx +++ b/packages/webgal/src/Stage/FigureContainer/FigureContainer.tsx @@ -1,9 +1,8 @@ import styles from './figureContainer.module.scss'; -import { useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; +import { useStageState } from '@/hooks/useStageState'; export const FigureContainer = () => { - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); return (
diff --git a/packages/webgal/src/Stage/FullScreenPerform/FullScreenPerform.tsx b/packages/webgal/src/Stage/FullScreenPerform/FullScreenPerform.tsx index 6c6de634d..7dece6108 100644 --- a/packages/webgal/src/Stage/FullScreenPerform/FullScreenPerform.tsx +++ b/packages/webgal/src/Stage/FullScreenPerform/FullScreenPerform.tsx @@ -1,9 +1,8 @@ import styles from './fullScreenPerform.module.scss'; -import { useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; +import { useStageState } from '@/hooks/useStageState'; export const FullScreenPerform = () => { - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); let stageWidth = '100%'; let stageHeight = '100%'; let top = '0'; diff --git a/packages/webgal/src/Stage/MainStage/MainStage.tsx b/packages/webgal/src/Stage/MainStage/MainStage.tsx deleted file mode 100644 index ac2c6eb0f..000000000 --- a/packages/webgal/src/Stage/MainStage/MainStage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; -import { useSetBg } from '@/Stage/MainStage/useSetBg'; -import { useSetFigure } from '@/Stage/MainStage/useSetFigure'; -import { setStageObjectEffects } from '@/Stage/MainStage/useSetEffects'; - -export function MainStage() { - const stageState = useSelector((state: RootState) => state.stage); - useSetBg(stageState); - useSetFigure(stageState); - setStageObjectEffects(stageState); - return
; -} diff --git a/packages/webgal/src/Stage/MainStage/useSetBg.ts b/packages/webgal/src/Stage/MainStage/useSetBg.ts deleted file mode 100644 index 2c2a684be..000000000 --- a/packages/webgal/src/Stage/MainStage/useSetBg.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { IStageState } from '@/store/stageInterface'; -import { useEffect } from 'react'; -import { logger } from '@/Core/util/logger'; -import { IStageObject } from '@/Core/controller/stage/pixi/PixiController'; -import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; - -import { getEnterExitAnimation } from '@/Core/Modules/animationFunctions'; -import { WebGAL } from '@/Core/WebGAL'; -import { DEFAULT_BG_OUT_DURATION } from '@/Core/constants'; - -export function useSetBg(stageState: IStageState) { - const bgName = stageState.bgName; - - /** - * 设置背景 - */ - useEffect(() => { - const thisBgKey = 'bg-main'; - if (bgName !== '') { - const currentBg = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisBgKey); - if (currentBg) { - if (currentBg.sourceUrl !== bgName) { - removeBg(currentBg); - } - } - addBg(undefined, thisBgKey, bgName); - logger.debug('重设背景'); - const { duration, animation } = getEnterExitAnimation('bg-main', 'enter', true); - setEbg(bgName, duration); - WebGAL.gameplay.pixiStage!.registerPresetAnimation(animation, 'bg-main-softin', thisBgKey, stageState.effects); - setTimeout(() => WebGAL.gameplay.pixiStage!.removeAnimationWithSetEffects('bg-main-softin'), duration); - } else { - let exitDuration = DEFAULT_BG_OUT_DURATION; - const currentBg = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisBgKey); - if (currentBg) { - exitDuration = removeBg(currentBg); - } - setEbg(bgName, exitDuration, 'cubic-bezier(0.5, 0, 0.75, 0)'); - } - }, [bgName]); -} - -function removeBg(bgObject: IStageObject): number { - WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects('bg-main-softin'); - const oldBgKey = bgObject.key; - bgObject.key = 'bg-main-off' + String(new Date().getTime()); - const bgKey = bgObject.key; - const bgAniKey = bgObject.key + '-softoff'; - WebGAL.gameplay.pixiStage?.removeStageObjectByKey(oldBgKey); - const { duration, animation } = getEnterExitAnimation('bg-main-off', 'exit', true, bgKey); - WebGAL.gameplay.pixiStage!.registerAnimation(animation, bgAniKey, bgKey); - setTimeout(() => { - WebGAL.gameplay.pixiStage?.removeAnimation(bgAniKey); - WebGAL.gameplay.pixiStage?.removeStageObjectByKey(bgKey); - }, duration); - return duration; -} - -function addBg(type?: 'image' | 'spine', ...args: any[]) { - const url: string = args[1]; - if (['mp4', 'webm', 'mkv'].some((e) => url.toLocaleLowerCase().endsWith(e))) { - // @ts-ignore - return WebGAL.gameplay.pixiStage?.addVideoBg(...args); - } else if (url.toLocaleLowerCase().endsWith('.skel')) { - // @ts-ignore - return WebGAL.gameplay.pixiStage?.addSpineBg(...args); - } else { - // @ts-ignore - return WebGAL.gameplay.pixiStage?.addBg(...args); - } -} diff --git a/packages/webgal/src/Stage/MainStage/useSetEffects.ts b/packages/webgal/src/Stage/MainStage/useSetEffects.ts deleted file mode 100644 index 049d04b02..000000000 --- a/packages/webgal/src/Stage/MainStage/useSetEffects.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { baseTransform, IEffect, IStageState, ITransform } from '@/store/stageInterface'; - -import { WebGAL } from '@/Core/WebGAL'; -import PixiStage from '@/Core/controller/stage/pixi/PixiController'; -import { isUndefined, omitBy } from 'lodash'; - -export function setStageObjectEffects(stageState: IStageState) { - const effects = stageState.effects; - setTimeout(() => { - setStageEffects(effects); - }, 10); -} - -export function setStageEffects(effects: IEffect[]) { - const stageObjects = WebGAL.gameplay.pixiStage?.getAllStageObj() ?? []; - for (const stageObj of stageObjects) { - const key = stageObj.key; - const effect = effects.find((effect) => effect.target === key); - const lockedStageTargets = WebGAL.gameplay.pixiStage?.getAllLockedObject() ?? []; - if (!lockedStageTargets.includes(key)) { - if (effect) { - // logger.debug('应用effects', key); - const targetPixiContainer = WebGAL.gameplay.pixiStage?.getStageObjByKey(key); - if (targetPixiContainer) { - const container = targetPixiContainer.pixiContainer; - // @ts-ignore 没有引入新的子对象 - PixiStage.assignTransform(container, convertTransform(effect.transform)); - } - } else { - const targetPixiContainer = WebGAL.gameplay.pixiStage?.getStageObjByKey(key); - if (targetPixiContainer) { - const container = targetPixiContainer.pixiContainer; - // @ts-ignore 没有引入新的子对象 - PixiStage.assignTransform(container, convertTransform(baseTransform)); - } - } - } - } - WebGAL.gameplay.pixiStage?.requestRender(); -} - -function convertTransform(transform: ITransform | undefined) { - if (!transform) { - return {}; - } - const { position, ...rest } = transform; - return omitBy({ ...rest, x: position?.x, y: position?.y }, isUndefined); -} diff --git a/packages/webgal/src/Stage/MainStage/useSetFigure.ts b/packages/webgal/src/Stage/MainStage/useSetFigure.ts deleted file mode 100644 index 3d103e834..000000000 --- a/packages/webgal/src/Stage/MainStage/useSetFigure.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { IEffect, IStageState } from '@/store/stageInterface'; -import { useEffect } from 'react'; -import { logger } from '@/Core/util/logger'; -import { generateUniversalSoftInAnimationObj } from '@/Core/controller/stage/pixi/animations/universalSoftIn'; -import { IStageObject } from '@/Core/controller/stage/pixi/PixiController'; -import { generateUniversalSoftOffAnimationObj } from '@/Core/controller/stage/pixi/animations/universalSoftOff'; - -import { getEnterExitAnimation } from '@/Core/Modules/animationFunctions'; -import { WebGAL } from '@/Core/WebGAL'; - -export function useSetFigure(stageState: IStageState) { - const { - figNameLeft, - figName, - figNameRight, - freeFigure, - live2dMotion, - live2dExpression, - live2dBlink, - live2dFocus, - figureMetaData, - } = stageState; - - /** - * 同步 motion - */ - useEffect(() => { - for (const motion of live2dMotion) { - if (motion.skin) { - WebGAL.gameplay.pixiStage?.changeSpineSkinByKey(motion.target, motion.skin); - } - WebGAL.gameplay.pixiStage?.changeModelMotionByKey(motion.target, motion.motion); - } - }, [live2dMotion]); - - /** - * 同步 expression - */ - useEffect(() => { - for (const expression of live2dExpression) { - WebGAL.gameplay.pixiStage?.changeModelExpressionByKey(expression.target, expression.expression); - } - }, [live2dExpression]); - - /** - * 同步 blink - */ - useEffect(() => { - for (const blink of live2dBlink) { - WebGAL.gameplay.pixiStage?.changeModelBlinkByKey(blink.target, blink.blink); - } - }, [live2dBlink]); - - /** - * 同步 focus - */ - useEffect(() => { - for (const focus of live2dFocus) { - WebGAL.gameplay.pixiStage?.changeModelFocusByKey(focus.target, focus.focus); - } - }, [live2dFocus]); - - /** - * 同步元数据 - */ - useEffect(() => { - Object.entries(figureMetaData).forEach(([key, value]) => { - const figureObject = WebGAL.gameplay.pixiStage?.getStageObjByKey(key); - if (figureObject && !figureObject.isExiting && figureObject.pixiContainer) { - if (value.zIndex !== undefined) { - figureObject.pixiContainer.zIndex = value.zIndex; - } - if (value.blendMode !== undefined) { - figureObject.pixiContainer.blendMode = value.blendMode; - } - } - }); - }, [figureMetaData]); - - /** - * 设置立绘 - */ - useEffect(() => { - /** - * 特殊处理:中间立绘 - */ - const thisFigKey = 'fig-center'; - const softInAniKey = 'fig-center-softin'; - if (figName !== '') { - const currentFigCenter = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisFigKey); - if (currentFigCenter) { - if (currentFigCenter.sourceUrl !== figName) { - removeFig(currentFigCenter, softInAniKey, stageState.effects); - } - } - addFigure(undefined, thisFigKey, figName, 'center'); - logger.debug('中立绘已重设'); - const { duration, animation } = getEnterExitAnimation(thisFigKey, 'enter'); - WebGAL.gameplay.pixiStage!.registerPresetAnimation(animation, softInAniKey, thisFigKey, stageState.effects); - setTimeout(() => WebGAL.gameplay.pixiStage!.removeAnimationWithSetEffects(softInAniKey), duration); - } else { - logger.debug('移除中立绘'); - const currentFigCenter = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisFigKey); - if (currentFigCenter) { - if (currentFigCenter.sourceUrl !== figName) { - removeFig(currentFigCenter, softInAniKey, stageState.effects); - } - } - } - }, [figName]); - - useEffect(() => { - /** - * 特殊处理:左侧立绘 - */ - const thisFigKey = 'fig-left'; - const softInAniKey = 'fig-left-softin'; - if (figNameLeft !== '') { - const currentFigLeft = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisFigKey); - if (currentFigLeft) { - if (currentFigLeft.sourceUrl !== figNameLeft) { - removeFig(currentFigLeft, softInAniKey, stageState.effects); - } - } - addFigure(undefined, thisFigKey, figNameLeft, 'left'); - logger.debug('左立绘已重设'); - const { duration, animation } = getEnterExitAnimation(thisFigKey, 'enter'); - WebGAL.gameplay.pixiStage!.registerPresetAnimation(animation, softInAniKey, thisFigKey, stageState.effects); - setTimeout(() => WebGAL.gameplay.pixiStage!.removeAnimationWithSetEffects(softInAniKey), duration); - } else { - logger.debug('移除左立绘'); - const currentFigLeft = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisFigKey); - if (currentFigLeft) { - if (currentFigLeft.sourceUrl !== figNameLeft) { - removeFig(currentFigLeft, softInAniKey, stageState.effects); - } - } - } - }, [figNameLeft]); - - useEffect(() => { - /** - * 特殊处理:右侧立绘 - */ - const thisFigKey = 'fig-right'; - const softInAniKey = 'fig-right-softin'; - if (figNameRight !== '') { - const currentFigRight = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisFigKey); - if (currentFigRight) { - if (currentFigRight.sourceUrl !== figNameRight) { - removeFig(currentFigRight, softInAniKey, stageState.effects); - } - } - addFigure(undefined, thisFigKey, figNameRight, 'right'); - logger.debug('右立绘已重设'); - const { duration, animation } = getEnterExitAnimation(thisFigKey, 'enter'); - WebGAL.gameplay.pixiStage!.registerPresetAnimation(animation, softInAniKey, thisFigKey, stageState.effects); - setTimeout(() => WebGAL.gameplay.pixiStage!.removeAnimationWithSetEffects(softInAniKey), duration); - } else { - const currentFigRight = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisFigKey); - if (currentFigRight) { - if (currentFigRight.sourceUrl !== figNameRight) { - removeFig(currentFigRight, softInAniKey, stageState.effects); - } - } - } - }, [figNameRight]); - - useEffect(() => { - // 自由立绘 - for (const fig of freeFigure) { - /** - * 特殊处理:自由立绘 - */ - const thisFigKey = `${fig.key}`; - const softInAniKey = `${fig.key}-softin`; - /** - * 非空 - */ - if (fig.name !== '') { - const currentFigThisKey = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisFigKey); - if (currentFigThisKey) { - if (currentFigThisKey.sourceUrl !== fig.name) { - removeFig(currentFigThisKey, softInAniKey, stageState.effects); - addFigure(undefined, thisFigKey, fig.name, fig.basePosition); - logger.debug(`${fig.key}立绘已重设`); - const { duration, animation } = getEnterExitAnimation(thisFigKey, 'enter'); - WebGAL.gameplay.pixiStage!.registerPresetAnimation(animation, softInAniKey, thisFigKey, stageState.effects); - setTimeout(() => WebGAL.gameplay.pixiStage!.removeAnimationWithSetEffects(softInAniKey), duration); - } - } else { - addFigure(undefined, thisFigKey, fig.name, fig.basePosition); - logger.debug(`${fig.key}立绘已重设`); - const { duration, animation } = getEnterExitAnimation(thisFigKey, 'enter'); - WebGAL.gameplay.pixiStage!.registerPresetAnimation(animation, softInAniKey, thisFigKey, stageState.effects); - setTimeout(() => WebGAL.gameplay.pixiStage!.removeAnimationWithSetEffects(softInAniKey), duration); - } - } else { - const currentFigThisKey = WebGAL.gameplay.pixiStage?.getStageObjByKey(thisFigKey); - if (currentFigThisKey) { - if (currentFigThisKey.sourceUrl !== fig.name) { - removeFig(currentFigThisKey, softInAniKey, stageState.effects); - } - } - } - } - - /** - * 移除不在状态表中的立绘 - */ - const currentFigures = WebGAL.gameplay.pixiStage?.getFigureObjects(); - if (currentFigures) { - for (const existFigure of currentFigures) { - if ( - existFigure.key === 'fig-left' || - existFigure.key === 'fig-center' || - existFigure.key === 'fig-right' || - existFigure.key.endsWith('-off') - ) { - // 什么也不做 - } else { - const existKey = existFigure.key; - const existFigInState = freeFigure.findIndex((fig) => fig.key === existKey); - if (existFigInState < 0) { - const softInAniKey = `${existFigure.key}-softin`; - removeFig(existFigure, softInAniKey, stageState.effects); - } - } - } - } - }, [freeFigure]); -} - -function removeFig(figObj: IStageObject, enterTikerKey: string, effects: IEffect[]) { - WebGAL.gameplay.pixiStage?.removeAnimationWithSetEffects(enterTikerKey); - // 快进,跳过退出动画 - if (WebGAL.gameplay.isFast) { - logger.debug('快速模式,立刻关闭立绘'); - WebGAL.gameplay.pixiStage?.removeStageObjectByKey(figObj.key); - return; - } - const oldFigKey = figObj.key; - const figLeaveAniKey = oldFigKey + '-off'; - figObj.key = oldFigKey + String(new Date().getTime()) + '-off'; - const figKey = figObj.key; - WebGAL.gameplay.pixiStage?.removeStageObjectByKey(oldFigKey); - const leaveKey = figKey + '-softoff'; - const { duration, animation } = getEnterExitAnimation(figLeaveAniKey, 'exit', false, figKey); - WebGAL.gameplay.pixiStage!.registerPresetAnimation(animation, leaveKey, figKey, effects); - setTimeout(() => { - WebGAL.gameplay.pixiStage?.removeAnimation(leaveKey); - WebGAL.gameplay.pixiStage?.removeStageObjectByKey(figKey); - }, duration); -} - -function addFigure(type?: 'image' | 'live2D' | 'spine', ...args: any[]) { - const url = args[1]; - const baseUrl = window.location.origin; - const urlObject = new URL(url, baseUrl); - const _type = urlObject.searchParams.get('type') as 'image' | 'live2D' | 'spine' | null; - if (url.endsWith('.json')) { - return addLive2dFigure(...args); - } else if (url.endsWith('.skel') || _type === 'spine') { - // @ts-ignore - return WebGAL.gameplay.pixiStage?.addSpineFigure(...args); - } else { - // @ts-ignore - return WebGAL.gameplay.pixiStage?.addFigure(...args); - } -} - -/** - * 如果要使用 Live2D,取消这里的注释 - * @param args - */ -function addLive2dFigure(...args: any[]) { - // @ts-ignore - return WebGAL.gameplay.pixiStage?.addLive2dFigure(...args); -} diff --git a/packages/webgal/src/Stage/OldStage/OldStage.tsx b/packages/webgal/src/Stage/OldStage/OldStage.tsx index fd3b0ccd9..67c219012 100644 --- a/packages/webgal/src/Stage/OldStage/OldStage.tsx +++ b/packages/webgal/src/Stage/OldStage/OldStage.tsx @@ -1,14 +1,14 @@ // import styles from '@/Components/Stage/stage.module.scss'; // import { FigureContainer } from '@/Components/Stage/FigureContainer/FigureContainer'; // import { useEffect } from 'react'; -// import { IEffect } from '@/store/stageInterface'; +// import { IEffect } from '@/Core/Modules/stage/stageInterface'; // import { useSelector } from 'react-redux'; // import { RootState } from '@/store/store'; export default function OldStage() { - // const stageState = useSelector((state: RootState) => state.stage); - // const oldBg = useSelector((state: RootState) => state.stageTemp.oldBg); - // const oldBgKey = useSelector((state: RootState) => state.stageTemp.oldBgKey); + // const stageState = useStageState(); + // const oldBg = ''; + // const oldBgKey = ''; // // /** // * 设置效果 diff --git a/packages/webgal/src/Stage/Stage.tsx b/packages/webgal/src/Stage/Stage.tsx index e0fc176bc..89ba3f516 100644 --- a/packages/webgal/src/Stage/Stage.tsx +++ b/packages/webgal/src/Stage/Stage.tsx @@ -10,12 +10,12 @@ import { RootState, webgalStore } from '@/store/store'; import { setVisibility } from '@/store/GUIReducer'; import { TextBoxFilm } from '@/Stage/TextBox/TextBoxFilm'; import { useHotkey } from '@/hooks/useHotkey'; -import { MainStage } from '@/Stage/MainStage/MainStage'; import IntroContainer from '@/Stage/introContainer/IntroContainer'; import { isIOS } from '@/Core/initializeScript'; import { WebGAL } from '@/Core/WebGAL'; import { IGuiState } from '@/store/guiInterface'; -import { IStageState } from '@/store/stageInterface'; +import { IStageState } from '@/Core/Modules/stage/stageInterface'; +import { useStageState } from '@/hooks/useStageState'; // import OldStage from '@/Components/Stage/OldStage/OldStage'; let timeoutEventHandle: ReturnType | null = null; @@ -74,7 +74,7 @@ function updateControlsVisibility( } export const Stage: FC = () => { - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); const GUIState = useSelector((state: RootState) => state.GUI); const dispatch = useDispatch(); @@ -85,7 +85,6 @@ export const Stage: FC = () => { {/* 已弃用旧的立绘与背景舞台 */} {/* */} -
{GUIState.showTextBox && stageState.enableFilm === '' && !stageState.isDisableTextbox && } diff --git a/packages/webgal/src/Stage/TextBox/TextBox.tsx b/packages/webgal/src/Stage/TextBox/TextBox.tsx index f6c1d028c..c0f76b8fd 100644 --- a/packages/webgal/src/Stage/TextBox/TextBox.tsx +++ b/packages/webgal/src/Stage/TextBox/TextBox.tsx @@ -9,6 +9,7 @@ import { textSize } from '@/store/userDataInterface'; import IMSSTextbox from '@/Stage/TextBox/IMSSTextbox'; import { SCREEN_CONSTANTS } from '@/Core/util/constants'; import useEscape from '@/hooks/useEscape'; +import { useStageState } from '@/hooks/useStageState'; const userAgent = navigator.userAgent; const isFirefox = /firefox/i.test(userAgent); @@ -20,7 +21,7 @@ export interface EnhancedNode { } export const TextBox = () => { - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); const guiState = useSelector((state: RootState) => state.GUI); const userDataState = useSelector((state: RootState) => state.userData); const textDelay = useTextDelay(userDataState.optionData.textSpeed); diff --git a/packages/webgal/src/Stage/TextBox/TextBoxFilm.tsx b/packages/webgal/src/Stage/TextBox/TextBoxFilm.tsx index 6d14d9921..f23359036 100644 --- a/packages/webgal/src/Stage/TextBox/TextBoxFilm.tsx +++ b/packages/webgal/src/Stage/TextBox/TextBoxFilm.tsx @@ -4,9 +4,10 @@ import { useSelector } from 'react-redux'; import { RootState } from '@/store/store'; import { PERFORM_CONFIG } from '@/config'; +import { useStageState } from '@/hooks/useStageState'; export const TextBoxFilm = () => { - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); const userDataState = useSelector((state: RootState) => state.userData); useEffect(() => {}); const textDelay = PERFORM_CONFIG.textInitialDelay - 20 * userDataState.optionData.textSpeed; diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx index d2df8848e..03e3a021e 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanel.tsx @@ -32,6 +32,7 @@ import { import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import styles from './bottomControlPanel.module.scss'; +import { useStageState } from '@/hooks/useStageState'; export const BottomControlPanel = () => { const t = useTrans('gaming.'); @@ -48,7 +49,7 @@ export const BottomControlPanel = () => { } const { isSupported: isFullscreenSupport, isFullScreen, toggle: toggleFullscreen } = useFullScreen(); const GUIStore = useSelector((state: RootState) => state.GUI); - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); const dispatch = useDispatch(); const setComponentVisibility = (component: keyof componentsVisibility, visibility: boolean) => { dispatch(setVisibility({ component, visibility })); diff --git a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx index b6ed7b2e4..c55f2a4e8 100644 --- a/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx +++ b/packages/webgal/src/UI/BottomControlPanel/BottomControlPanelFilm.tsx @@ -8,10 +8,11 @@ import { componentsVisibility, MenuPanelTag } from '@/store/guiInterface'; import { backToTitle } from '@/Core/controller/gamePlay/backToTitle'; import { useValue } from '@/hooks/useValue'; import { HamburgerButton } from '@icon-park/react'; +import { useStageState } from '@/hooks/useStageState'; export const BottomControlPanelFilm = () => { const showPanel = useValue(false); - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); const dispatch = useDispatch(); const setComponentVisibility = (component: keyof componentsVisibility, visibility: boolean) => { dispatch(setVisibility({ component, visibility })); diff --git a/packages/webgal/src/UI/DevPanel/DevPanel.tsx b/packages/webgal/src/UI/DevPanel/DevPanel.tsx index 2fbb702a1..701cc512c 100644 --- a/packages/webgal/src/UI/DevPanel/DevPanel.tsx +++ b/packages/webgal/src/UI/DevPanel/DevPanel.tsx @@ -2,11 +2,10 @@ import styles from './devPanel.module.scss'; import { useValue } from '@/hooks/useValue'; import { getPixiSscreenshot } from '@/UI/DevPanel/devFunctions/getPixiSscreenshot'; import { useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; import { useTranslation } from 'react-i18next'; import { WebGAL } from '@/Core/WebGAL'; +import { useStageState } from '@/hooks/useStageState'; export default function DevPanel() { // 控制显隐 @@ -16,7 +15,7 @@ export default function DevPanel() { } const isOpenDevPanel = useValue(false); const hash = useValue(window.location.hash); - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); useEffect(() => { window.onhashchange = () => { hash.set(window.location.hash); diff --git a/packages/webgal/src/UI/Extra/ExtraBgm.tsx b/packages/webgal/src/UI/Extra/ExtraBgm.tsx index 7c9bba9b2..7115a1842 100644 --- a/packages/webgal/src/UI/Extra/ExtraBgm.tsx +++ b/packages/webgal/src/UI/Extra/ExtraBgm.tsx @@ -3,7 +3,6 @@ import { RootState } from '@/store/store'; import React from 'react'; import styles from '@/UI/Extra/extra.module.scss'; import { useValue } from '@/hooks/useValue'; -import { setStage } from '@/store/stageReducer'; import { GoEnd, GoStart, MusicList, PlayOne, SquareSmall } from '@icon-park/react'; import useSoundEffect from '@/hooks/useSoundEffect'; import { setGuiAsset } from '@/store/GUIReducer'; diff --git a/packages/webgal/src/UI/Menu/Options/TextPreview/TextPreview.tsx b/packages/webgal/src/UI/Menu/Options/TextPreview/TextPreview.tsx index 1a40ccc2d..e46068bd0 100644 --- a/packages/webgal/src/UI/Menu/Options/TextPreview/TextPreview.tsx +++ b/packages/webgal/src/UI/Menu/Options/TextPreview/TextPreview.tsx @@ -8,11 +8,12 @@ import { getTextSize } from '@/UI/getTextSize'; import IMSSTextbox from '@/Stage/TextBox/IMSSTextbox'; import { compileSentence } from '@/Stage/TextBox/TextBox'; import { useState } from 'react'; +import { useStageState } from '@/hooks/useStageState'; export const TextPreview = (props: any) => { const t = useTrans('menu.options.pages.display.options.'); const userDataState = useSelector((state: RootState) => state.userData); - const stageState = useSelector((state: RootState) => state.stage); + const stageState = useStageState(); const previewBackground = stageState.bgName; const textDelay = useTextDelay(userDataState.optionData.textSpeed); const textDuration = useTextAnimationDuration(userDataState.optionData.textSpeed); diff --git a/packages/webgal/src/hooks/useApplyStyle.ts b/packages/webgal/src/hooks/useApplyStyle.ts index 46622c66d..518a4ba61 100644 --- a/packages/webgal/src/hooks/useApplyStyle.ts +++ b/packages/webgal/src/hooks/useApplyStyle.ts @@ -4,14 +4,13 @@ import axios from 'axios'; import { scss2cssinjsParser } from '@/Core/controller/customUI/scss2cssinjsParser'; import { useValue } from '@/hooks/useValue'; import { css, injectGlobal } from '@emotion/css'; -import { useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; import { IWebGALStyleObj } from 'webgal-parser/build/types/styleParser'; import { logger } from '@/Core/util/logger'; +import { useStageState } from '@/hooks/useStageState'; export default function useApplyStyle(ui: string) { const styleObject = useValue(WebGAL.styleObjects.get(ui) ?? { classNameStyles: {}, others: '' }); - const replaced = useSelector((state: RootState) => state.stage.replacedUIlable); + const replaced = useStageState().replacedUIlable; const applyStyle = (classNameLable: string, fallbackClassName: string) => { // 先看看是否被用户用 applyStyle 指令替换了类名 diff --git a/packages/webgal/src/hooks/useSoundEffect.ts b/packages/webgal/src/hooks/useSoundEffect.ts index f17cab4b8..a3abf17f1 100644 --- a/packages/webgal/src/hooks/useSoundEffect.ts +++ b/packages/webgal/src/hooks/useSoundEffect.ts @@ -1,34 +1,29 @@ -import { setStage } from '@/store/stageReducer'; - import page_flip_1 from '@/assets/se/page-flip-1.mp3'; import switch_1 from '@/assets/se/switch-1.mp3'; import mouse_enter from '@/assets/se/mouse-enter.mp3'; import dialog_se from '@/assets/se/dialog.mp3'; import click_se from '@/assets/se/click.mp3'; -import { useDispatch } from 'react-redux'; -import { webgalStore } from '@/store/store'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; /** * 调用音效 */ const useSoundEffect = () => { - const dispatch = useDispatch(); - const playSeEnter = () => { - dispatch(setStage({ key: 'uiSe', value: mouse_enter })); + stageStateManager.setStageAndCommit('uiSe', mouse_enter); }; const playSeClick = () => { - dispatch(setStage({ key: 'uiSe', value: click_se })); + stageStateManager.setStageAndCommit('uiSe', click_se); }; const playSeSwitch = () => { - dispatch(setStage({ key: 'uiSe', value: switch_1 })); + stageStateManager.setStageAndCommit('uiSe', switch_1); }; const playSePageChange = () => { - dispatch(setStage({ key: 'uiSe', value: page_flip_1 })); + stageStateManager.setStageAndCommit('uiSe', page_flip_1); }; const playSeDialogOpen = () => { - dispatch(setStage({ key: 'uiSe', value: dialog_se })); + stageStateManager.setStageAndCommit('uiSe', dialog_se); }; return { @@ -45,10 +40,10 @@ const useSoundEffect = () => { */ export const useSEByWebgalStore = () => { const playSeEnter = () => { - webgalStore.dispatch(setStage({ key: 'uiSe', value: mouse_enter })); + stageStateManager.setStageAndCommit('uiSe', mouse_enter); }; const playSeClick = () => { - webgalStore.dispatch(setStage({ key: 'uiSe', value: click_se })); + stageStateManager.setStageAndCommit('uiSe', click_se); }; return { playSeEnter, // 鼠标进入 diff --git a/packages/webgal/src/hooks/useStageState.ts b/packages/webgal/src/hooks/useStageState.ts new file mode 100644 index 000000000..84cfdd403 --- /dev/null +++ b/packages/webgal/src/hooks/useStageState.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { IStageState } from '@/Core/Modules/stage/stageInterface'; + +export function useStageState(): IStageState { + const [stageState, setStageState] = useState(() => stageStateManager.getViewStageState()); + + useEffect(() => { + return stageStateManager.subscribe((nextStageState) => { + setStageState(nextStageState); + }); + }, []); + + return stageState; +} diff --git a/packages/webgal/src/store/stageReducer.ts b/packages/webgal/src/store/stageReducer.ts deleted file mode 100644 index f34017cad..000000000 --- a/packages/webgal/src/store/stageReducer.ts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * 所有会被Save和Backlog记录下的信息,构成当前的舞台状态(也包括游戏运行时变量) - * 舞台状态是演出结束后的“终态”,在读档时不发生演出,只是将舞台状态替换为读取的状态。 - */ - -import { - baseTransform, - IEffect, - IFigureMetadata, - IFreeFigure, - ILive2DBlink, - ILive2DExpression, - ILive2DFocus, - ILive2DMotion, - IRunPerform, - ISetGameVar, - ISetStagePayload, - IStageAnimationSetting, - IStageState, - IUpdateAnimationSettingPayload, -} from '@/store/stageInterface'; -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import cloneDeep from 'lodash/cloneDeep'; -import { commandType } from '@/Core/controller/scene/sceneInterface'; -import { STAGE_KEYS } from '@/Core/constants'; -import { baseBlinkParam, baseFocusParam } from '@/Core/live2DCore'; -import { isUndefined, omitBy } from 'lodash'; - -// 初始化舞台数据 - -export const initState: IStageState = { - oldBgName: '', - bgName: '', // 背景文件地址(相对或绝对) - figName: '', // 立绘_中 文件地址(相对或绝对) - figNameLeft: '', // 立绘_左 文件地址(相对或绝对) - figNameRight: '', // 立绘_右 文件地址(相对或绝对) - freeFigure: [], - figureAssociatedAnimation: [], - isRead: false, - showText: '', // 文字 - showTextSize: -1, - showName: '', // 人物名 - command: '', // 语句指令 - choose: [], // 选项列表,现在不用,先预留 - vocal: '', // 语音 文件地址(相对或绝对) - playVocal: '', // 语音,真实的播放音频 - vocalVolume: 100, // 语音 音量调整(0 - 100) - bgm: { - // 背景音乐 - src: '', // 背景音乐 文件地址(相对或绝对) - enter: 0, // 背景音乐 淡入或淡出的毫秒数 - volume: 100, // 背景音乐 音量调整(0 - 100) - }, - uiSe: '', // 用户界面音效 文件地址(相对或绝对) - miniAvatar: '', // 小头像 文件地址(相对或绝对) - GameVar: {}, // 游戏内变量 - // 应用的效果 - effects: [ - { - target: 'stage-main', - transform: baseTransform, - }, - ], - animationSettings: [], - bgFilter: '', // 现在不用,先预留 - bgTransform: '', // 现在不用,先预留 - PerformList: [], // 要启动的演出列表 - currentDialogKey: 'initial', - live2dMotion: [], - live2dExpression: [], - live2dBlink: [], - live2dFocus: [], - // currentPerformDelay: 0 - currentConcatDialogPrev: '', - enableFilm: '', - isDisableTextbox: false, - replacedUIlable: {}, - figureMetaData: {}, -}; - -/** - * 创建舞台的状态管理 - */ -const stageSlice = createSlice({ - name: 'stage', - initialState: cloneDeep(initState), - reducers: { - /** - * 替换舞台状态 - * @param state 当前状态 - * @param action 替换的状态 - */ - resetStageState: (state, action: PayloadAction) => { - Object.assign(state, action.payload); - }, - /** - * 设置舞台状态 - * @param state 当前状态 - * @param action 要替换的键值对 - */ - setStage: (state, action: PayloadAction) => { - // @ts-ignore - state[action.payload.key] = action.payload.value; - }, - /** - * 修改舞台状态变量 - * @param state 当前状态 - * @param action 要改变或添加的变量 - */ - setStageVar: (state, action: PayloadAction) => { - state.GameVar[action.payload.key] = action.payload.value; - }, - updateEffect: (state, action: PayloadAction) => { - const { target, transform } = action.payload; - // 如果找不到目标,不能设置 transform - const activeTargets = [ - STAGE_KEYS.STAGE_MAIN, - STAGE_KEYS.BGMAIN, - STAGE_KEYS.FIG_C, - STAGE_KEYS.FIG_L, - STAGE_KEYS.FIG_R, - ...state.freeFigure.map((figure) => figure.key), - ]; - if (!activeTargets.includes(target)) return; - // 尝试找到待修改的 Effect - const effectIndex = state.effects.findIndex((e) => e.target === target); - if (effectIndex >= 0) { - // Update the existing effect - if (!state.effects[effectIndex].transform) { - state.effects[effectIndex].transform = transform; - } else if (transform) { - const targetScale = state.effects[effectIndex].transform!.scale || {}; - const targetPosition = state.effects[effectIndex].transform!.position || {}; - if (transform.scale) Object.assign(targetScale, omitBy(transform.scale, isUndefined)); - if (transform.position) Object.assign(targetPosition, omitBy(transform.position, isUndefined)); - Object.assign(state.effects[effectIndex].transform!, omitBy(transform, isUndefined)); - state.effects[effectIndex].transform!.scale = targetScale; - state.effects[effectIndex].transform!.position = targetPosition; - } - } else { - // Add a new effect, use baseTransform as default to ensure completeness - state.effects.push({ - target, - transform: transform ? { ...baseTransform, ...transform } : { ...baseTransform }, - }); - } - }, - removeEffectByTargetId: (state, action: PayloadAction) => { - const index = state.effects.findIndex((e) => e.target === action.payload); - if (index >= 0) { - state.effects.splice(index, 1); - } - }, - updateAnimationSettings: (state, action: PayloadAction) => { - const { target, key, value } = action.payload; - const animationIndex = state.animationSettings.findIndex((a) => a.target === target); - if (animationIndex >= 0) { - state.animationSettings[animationIndex] = { - ...state.animationSettings[animationIndex], - [key]: value, - }; - } else { - state.animationSettings.push({ - target, - [key]: value, - }); - } - }, - removeAnimationSettingsByTarget: (state, action: PayloadAction) => { - const index = state.animationSettings.findIndex((a) => a.target === action.payload); - if (index >= 0) { - const prev = state.animationSettings[index]; - state.animationSettings.splice(index, 1); - - if (prev.exitAnimationName || prev.exitDuration !== undefined) { - // 如果有退出动画设定,保留一个 -off 的设定 - const prevTarget = `${action.payload}-off`; - const prevSetting = { - ...prev, - target: prevTarget, - }; - - const prevIndex = state.animationSettings.findIndex((a) => a.target === prevTarget); - - if (prevIndex >= 0) { - state.animationSettings.splice(prevIndex, 1, prevSetting); - } else { - state.animationSettings.push(prevSetting); - } - } - } - }, - removeAnimationSettingsByTargetOff: (state, action: PayloadAction) => { - // 这里不加 -off 因为传入的就是带 -off 的 - const index = state.animationSettings.findIndex((a) => a.target === `${action.payload}`); - if (index >= 0) { - state.animationSettings.splice(index, 1); - } - }, - addPerform: (state, action: PayloadAction) => { - // 先检查是否有重复的,全部干掉 - const dupPerformIndex = state.PerformList.findIndex((p) => p.id === action.payload.id); - if (dupPerformIndex > -1) { - const dupId = action.payload.id; - // 删除全部重复演出 - for (let i = 0; i < state.PerformList.length; i++) { - const performItem: IRunPerform = state.PerformList[i]; - if (performItem.id === dupId) { - state.PerformList.splice(i, 1); - i--; - } - } - } - state.PerformList.push(action.payload); - }, - removePerformByName: (state, action: PayloadAction) => { - const name = action.payload; - for (let i = 0; i < state.PerformList.length; i++) { - const performItem: IRunPerform = state.PerformList[i]; - if (performItem.id === name || performItem.id.startsWith(name + '#')) { - state.PerformList.splice(i, 1); - i--; - } - } - }, - removeAllPerform: (state) => { - state.PerformList.splice(0, state.PerformList.length); - }, - removeAllPixiPerforms: (state, action: PayloadAction) => { - for (let i = 0; i < state.PerformList.length; i++) { - const performItem: IRunPerform = state.PerformList[i]; - if (performItem.script.command === commandType.pixi) { - state.PerformList.splice(i, 1); - i--; - } - } - }, - setFreeFigureByKey: (state, action: PayloadAction) => { - const currentFreeFigures = state.freeFigure; - const newFigure = action.payload; - const index = currentFreeFigures.findIndex((figure) => figure.key === newFigure.key); - if (index >= 0) { - if (newFigure.name === '') { - // 删掉立绘和相关的动画 - currentFreeFigures.splice(index, 1); - const figureAssociatedAnimationIndex = state.figureAssociatedAnimation.findIndex( - (a) => a.targetId === newFigure.key, - ); - state.figureAssociatedAnimation.splice(figureAssociatedAnimationIndex, 1); - } else { - currentFreeFigures[index].basePosition = newFigure.basePosition; - currentFreeFigures[index].name = newFigure.name; - } - } else { - // 新加 - if (newFigure.name !== '') currentFreeFigures.push(newFigure); - } - }, - setLive2dMotion: (state, action: PayloadAction) => { - const { target, motion, skin, overrideBounds } = action.payload; - - const index = state.live2dMotion.findIndex((e) => e.target === target); - - if (index < 0) { - // Add a new motion - state.live2dMotion.push({ target, motion, skin, overrideBounds }); - } else { - // Update the existing motion - state.live2dMotion[index].motion = motion; - state.live2dMotion[index].skin = skin; - state.live2dMotion[index].overrideBounds = overrideBounds; - } - }, - setLive2dExpression: (state, action: PayloadAction) => { - const { target, expression } = action.payload; - - const index = state.live2dExpression.findIndex((e) => e.target === target); - - if (index < 0) { - // Add a new expression - state.live2dExpression.push({ target, expression }); - } else { - // Update the existing expression - state.live2dExpression[index].expression = expression; - } - }, - setLive2dBlink: (state, action: PayloadAction) => { - const { target, blink } = action.payload; - - const index = state.live2dBlink.findIndex((e) => e.target === target); - if (index < 0) { - // Add a new blink - const fullBlink = { ...baseBlinkParam, ...blink }; - state.live2dBlink.push({ target, blink: fullBlink }); - } else { - // Update the existing blink - const fullBlink = { ...state.live2dBlink[index].blink, ...blink }; - state.live2dBlink[index].blink = fullBlink; - } - }, - setLive2dFocus: (state, action: PayloadAction) => { - const { target, focus } = action.payload; - - const index = state.live2dFocus.findIndex((e) => e.target === target); - if (index < 0) { - // Add a new focus - const fullFocus = { ...baseFocusParam, ...focus }; - state.live2dFocus.push({ target, focus: fullFocus }); - } else { - // Update the existing focus - const fullFocus = { ...state.live2dFocus[index].focus, ...focus }; - state.live2dFocus[index].focus = fullFocus; - } - }, - replaceUIlable: (state, action: PayloadAction<[string, string]>) => { - state.replacedUIlable[action.payload[0]] = action.payload[1]; - }, - /** - * 设置 figure 元数据 [立绘 key, metadata key, 值, 是否重设] - * @param state - * @param action - */ - setFigureMetaData: (state, action: PayloadAction<[string, keyof IFigureMetadata, any, undefined | boolean]>) => { - // 立绘退出,重设 - if (action.payload[3]) { - if (state.figureMetaData[action.payload[0]]) delete state.figureMetaData[action.payload[0]]; - } else { - // 初始化对象 - if (!state.figureMetaData[action.payload[0]]) { - state.figureMetaData[action.payload[0]] = {}; - } - state.figureMetaData[action.payload[0]][action.payload[1]] = action.payload[2]; - } - }, - }, -}); - -export const { resetStageState, setStage, setStageVar } = stageSlice.actions; -export const stageActions = stageSlice.actions; -export default stageSlice.reducer; - -// /** -// * 创建舞台的状态管理 -// * @return {IStageState} 舞台状态 -// * @return {function} 改变舞台状态 -// */ -// export function stageStateStore():StageStore { -// const [stageState, setStageState] = useState(_.cloneDeep(initState)); -// -// /** -// * 设置舞台状态,以后会改 -// * @param key -// * @param value -// */ -// const setStage = (key: K, value: any) => { -// -// setStageState(state => { -// state[key] = value; -// return {...state}; -// }); -// -// }; -// -// const getStageState = () => { -// return stageState; -// }; -// -// const restoreStage = (newState: IStageState) => { -// setStageState((state) => ({ ...state, ...newState })); -// }; -// -// return { -// stageState, -// setStage, -// getStageState, -// restoreStage, -// }; -// } diff --git a/packages/webgal/src/store/store.ts b/packages/webgal/src/store/store.ts index 9f48161d8..f2280af28 100644 --- a/packages/webgal/src/store/store.ts +++ b/packages/webgal/src/store/store.ts @@ -1,5 +1,4 @@ import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'; -import stageReducer from '@/store/stageReducer'; import GUIReducer from '@/store/GUIReducer'; import userDataReducer from '@/store/userDataReducer'; import savesReducer from '@/store/savesReducer'; @@ -9,7 +8,6 @@ import savesReducer from '@/store/savesReducer'; */ export const webgalStore = configureStore({ reducer: { - stage: stageReducer, GUI: GUIReducer, userData: userDataReducer, saveData: savesReducer, diff --git a/packages/webgal/src/store/userDataInterface.ts b/packages/webgal/src/store/userDataInterface.ts index e0245c2a1..9098ff20d 100644 --- a/packages/webgal/src/store/userDataInterface.ts +++ b/packages/webgal/src/store/userDataInterface.ts @@ -1,4 +1,4 @@ -import { IGameVar, IStageState } from './stageInterface'; +import { IGameVar, IStageState } from '@/Core/Modules/stage/stageInterface'; import { language } from '@/config/language'; import { IBacklogItem } from '@/Core/Modules/backlog'; import { ISceneEntry } from '@/Core/Modules/scene'; diff --git a/packages/webgal/src/store/userDataReducer.ts b/packages/webgal/src/store/userDataReducer.ts index 856b74ef7..3807fb63c 100644 --- a/packages/webgal/src/store/userDataReducer.ts +++ b/packages/webgal/src/store/userDataReducer.ts @@ -18,7 +18,7 @@ import { } from '@/store/userDataInterface'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import cloneDeep from 'lodash/cloneDeep'; -import { ISetGameVar } from './stageInterface'; +import { ISetGameVar } from '@/Core/Modules/stage/stageInterface'; const initialOptionSet: IOptionData = { slPage: 1, diff --git a/packages/webgal/src/types/debugProtocol.ts b/packages/webgal/src/types/debugProtocol.ts index 60c17e2e4..7d06030a5 100644 --- a/packages/webgal/src/types/debugProtocol.ts +++ b/packages/webgal/src/types/debugProtocol.ts @@ -1,4 +1,4 @@ -import { IStageState } from '@/store/stageInterface'; +import { IStageState } from '@/Core/Modules/stage/stageInterface'; export enum DebugCommand { // 跳转 @@ -19,6 +19,17 @@ export enum DebugCommand { FONT_OPTIMIZATION, // 直接设置效果 SET_EFFECT, + // 实时预览快进超时 + FAST_PREVIEW_TIMEOUT, +} + +export interface IFastPreviewTimeoutPayload { + scene: string; + sentence: number; + targetSentence: number; + forwardedLineCount: number; + elapsedMs: number; + maxDurationMs: number; } export interface IDebugMessage {