diff --git "a/dev-docs/\347\253\213\347\273\230\345\267\256\345\210\206\345\210\207\346\215\242.md" "b/dev-docs/\347\253\213\347\273\230\345\267\256\345\210\206\345\210\207\346\215\242.md" new file mode 100644 index 000000000..9f4660736 --- /dev/null +++ "b/dev-docs/\347\253\213\347\273\230\345\267\256\345\210\206\345\210\207\346\215\242.md" @@ -0,0 +1,38 @@ +# 立绘差分切换 + +```text +changeFigure:normal.png -id=hero; +changeFigureDiff:smile.png -id=hero; +``` + +`changeFigure` 是换立绘,`changeFigureDiff` 是同一立绘的差分替换(如换表情)。省略 `-id` 时默认操作中间立绘,也可使用 `-left` 等位置参数。 + +差分只改状态表中的图片地址,立绘的其余状态全部保留:变换、层级(zIndex)与混合模式、入退场设置,以及舞台上的遮挡顺序。旧图不算退场,不播放自定义退场;这些设置留给之后真正换走立绘的 `changeFigure`。 + +口型、眨眼图是与表情配套的整张图,差分后旧图不再适用。因此 `changeFigureDiff` 接受 `-mouthOpen`、`-mouthClose`、`-mouthHalfOpen`、`-eyesOpen`、`-eyesClose`、`-animationFlag`,并按本句参数整体替换,未写的即为不启用。仍在进行的语音口型、眨眼若使用旧表情的图,舞台会丢弃这些换图。 + +```text +changeFigureDiff:smile.png -id=hero -mouthOpen=smile_open.png -mouthClose=smile.png; +``` + +差分优先于口型眨眼:混合从 Sprite 实际显示的图开始(可能是闭嘴图,闭嘴图常与原图相同),混合期间暂停口型眨眼换图,结束后显示新差分的原图。 + +```text +changeFigure:normal.png -id=hero -exit=exitSlide; +changeFigureDiff:smile.png -id=hero; // 差分,不播放 exitSlide +changeFigure:other.png -id=hero; // smile.png 以 exitSlide 退场 +``` + +除上述口型眨眼参数外,只接受定位与流程参数(`-id`、位置、`-next`、`-continue`、`-when`),其余参数会被忽略并给出警告;需要同时改变换、动画或模型参数时使用 `changeFigure`。没有可替换的立绘、或内容为 `none` 时不存在差分,按 `changeFigure` 处理。 + +Live2D 与 Spine 使用各自内置的表情、动作系统,不适用差分:源立绘或目标是模型时,本句不执行。 + +差分演出命名为 `animation-#diff`:登记时不会去重掉同目标已有的动画演出(先 `setAnimation` 再差分时,混合与动作同时进行);之后同目标的非并行 `setTransform`、`setAnimation`、`changeFigure` 会接管并移除它,同时差分标记被清除,上屏走回退,淡入也随之取消(旧图渐出,新图直接执行动作)。混合期间对象被锁定;对象正被其他动画锁定时同样走回退。 + +舞台提交时,新指令留下的临时标记(不进入存档;同一次提交内连续差分以最初的源图为起点)与实际上屏的源图和目标 URL 匹配、基准位置未变,两张静态图片的宽高及像素密度完全一致,且两者 GPU 资源已就绪时,复用容器,以 200 ms 的互补 RGBA 混合过渡。 + +资源未就绪、尺寸不同、跳过动画、标记过期等情况,退回淡出淡入:旧图以默认淡出退场,新对象接替旧对象的遮挡顺序,保持已有变换,仅透明度淡入。不会等待资源来争取混合,也不会为尺寸不同的图片重新计算混合几何。 + +标记由 `WebGAL.figureDiffManager` 实例管理,只记录源 URL 和目标 URL:脚本执行时 `mark`,舞台替换时 `consume`,每次提交结束时 `clear`。后续修改目标的变换或动画设置、丢弃未提交演出、重建舞台状态时也会清除相应标记,不保存状态快照或演出引用。混合或淡入动画注册到现有 Pixi 动画管理,由差分演出负责结束。 + +状态表只保存差分后的图片地址,没有额外差分字段;读档、回溯和快速预览不依赖标记来恢复图片。 diff --git a/packages/parser/package.json b/packages/parser/package.json index 2c86b3b4f..7e64325e3 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "webgal-parser", - "version": "4.6.3", + "version": "4.6.5-beta.1", "description": "WebGAL script parser", "scripts": { "test": "vitest", diff --git a/packages/parser/src/config/scriptConfig.ts b/packages/parser/src/config/scriptConfig.ts index f9c5a3620..6dabe988d 100644 --- a/packages/parser/src/config/scriptConfig.ts +++ b/packages/parser/src/config/scriptConfig.ts @@ -4,6 +4,7 @@ export const SCRIPT_CONFIG = [ { scriptString: 'say', scriptType: commandType.say }, { scriptString: 'changeBg', scriptType: commandType.changeBg }, { scriptString: 'changeFigure', scriptType: commandType.changeFigure }, + { scriptString: 'changeFigureDiff', scriptType: commandType.changeFigureDiff }, { scriptString: 'bgm', scriptType: commandType.bgm }, { scriptString: 'playVideo', scriptType: commandType.video }, { scriptString: 'pixiPerform', scriptType: commandType.pixi }, diff --git a/packages/parser/src/interface/sceneInterface.ts b/packages/parser/src/interface/sceneInterface.ts index d470e6a3b..7501596fc 100644 --- a/packages/parser/src/interface/sceneInterface.ts +++ b/packages/parser/src/interface/sceneInterface.ts @@ -41,6 +41,7 @@ export enum commandType { wait, callSteam, // 调用Steam功能 return, // 从被调用的场景返回 + changeFigureDiff, // 切换等尺寸静态立绘差分;追加以保持旧指令编号 } /** diff --git a/packages/parser/src/scriptParser/assetsScanner.ts b/packages/parser/src/scriptParser/assetsScanner.ts index 8cf74e03e..dfb839b23 100644 --- a/packages/parser/src/scriptParser/assetsScanner.ts +++ b/packages/parser/src/scriptParser/assetsScanner.ts @@ -41,7 +41,7 @@ export const assetsScanner = ( type: fileType.background, }); } - if (command === commandType.changeFigure) { + if (command === commandType.changeFigure || command === commandType.changeFigureDiff) { returnAssetsList.push({ name: content, url: content, diff --git a/packages/parser/src/scriptParser/contentParser.ts b/packages/parser/src/scriptParser/contentParser.ts index cbb6fd161..06404f04b 100644 --- a/packages/parser/src/scriptParser/contentParser.ts +++ b/packages/parser/src/scriptParser/contentParser.ts @@ -22,6 +22,7 @@ export const contentParser = ( case commandType.changeBg: return assetSetter(contentRaw, fileType.background); case commandType.changeFigure: + case commandType.changeFigureDiff: return assetSetter(contentRaw, fileType.figure); case commandType.bgm: return assetSetter(contentRaw, fileType.bgm); diff --git a/packages/webgal/package.json b/packages/webgal/package.json index e76437907..18f4cd605 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -11,6 +11,8 @@ "dependencies": { "@emotion/css": "^11.11.2", "@icon-park/react": "^1.4.2", + "@pixi/assets": "6.5.10", + "@pixi/basis": "6.5.10", "@reduxjs/toolkit": "^1.8.1", "angular-expressions": "^1.4.3", "axios": "^1.13.5", diff --git a/packages/webgal/src/Core/Modules/perform/performController.ts b/packages/webgal/src/Core/Modules/perform/performController.ts index 6531a3397..7306d94df 100644 --- a/packages/webgal/src/Core/Modules/perform/performController.ts +++ b/packages/webgal/src/Core/Modules/perform/performController.ts @@ -169,7 +169,7 @@ export class PerformController { perform.isStarted = true; perform.startFunction?.(); - // 时间到后自动清理演出 + // 演出持续 duration 毫秒后自动回收;同步清理会使演出刚启动就结束。 const stopTimeout = setTimeout(() => { // perform.stopFunction(); // perform.isOver = true; @@ -332,6 +332,7 @@ export class PerformController { }); if (isBlockingNext) { // 有阻塞,提前结束 + // 其他演出仍阻塞推进,间隔重试以等待其结束,避免同步递归占住主线程。 setTimeout(this.goNextWhenOver, 100); } else { continueSentence(); diff --git a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts index 4284288b4..ecc4c0dff 100644 --- a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts +++ b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts @@ -1,4 +1,5 @@ import cloneDeep from 'lodash/cloneDeep'; +import { WebGAL } from '@/Core/WebGAL'; import { isUndefined, omitBy } from 'lodash'; import { commandType } from '@/Core/controller/scene/sceneInterface'; import { STAGE_KEYS } from '@/Core/constants'; @@ -126,11 +127,13 @@ export class StageStateManager { } public replaceCalculationStageState(stageState: IStageState) { + // 读档、回溯与场景重建只恢复普通状态,不能携带上一次演算的换图意图。 + WebGAL.figureDiffManager.clear(); this.calculationStageState = cloneDeep(stageState); } public replaceAllStageState(stageState: IStageState, options?: IStageCommitOptions) { - this.calculationStageState = cloneDeep(stageState); + this.replaceCalculationStageState(stageState); this.commit(options); } @@ -144,6 +147,7 @@ export class StageStateManager { public updateEffect(payload: IEffect) { const { target, transform } = payload; + WebGAL.figureDiffManager.clear(target); const state = this.calculationStageState; const activeTargets = [ STAGE_KEYS.STAGE_MAIN, @@ -188,6 +192,8 @@ export class StageStateManager { public updateAnimationSettings(payload: IUpdateAnimationSettingPayload) { const { target, key, value } = payload; + // 后续普通换图或自定义过渡覆盖了目标;差分指令会在自身状态更新完毕后重新打标。 + WebGAL.figureDiffManager.clear(target); const state = this.calculationStageState; const animationIndex = state.animationSettings.findIndex((a) => a.target === target); if (animationIndex >= 0) { @@ -352,6 +358,8 @@ export class StageStateManager { } public clearUncommittedNonHoldPerforms() { + // 快速预览丢弃演出时,一并丢弃其尚未上屏的差分标记。 + WebGAL.figureDiffManager.clear(); this.calculationStageState.PerformList = this.calculationStageState.PerformList.filter( (perform) => perform.isHoldOn, ); diff --git a/packages/webgal/src/Core/constants.ts b/packages/webgal/src/Core/constants.ts index a24cb2848..fd44bb75f 100644 --- a/packages/webgal/src/Core/constants.ts +++ b/packages/webgal/src/Core/constants.ts @@ -4,7 +4,7 @@ export const STAGE_KEYS = { }; export const WEBGAL_NONE = 'none'; -export const DEFAULT_FIG_IN_DURATION = 300; -export const DEFAULT_FIG_OUT_DURATION = 450; +export const DEFAULT_FIG_IN_DURATION = 200; +export const DEFAULT_FIG_OUT_DURATION = 160; export const DEFAULT_BG_IN_DURATION = 1000; export const DEFAULT_BG_OUT_DURATION = 1500; diff --git a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts index 575ae7d41..e6056a4d8 100644 --- a/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts +++ b/packages/webgal/src/Core/controller/gamePlay/autoPlay.ts @@ -71,6 +71,7 @@ const autoPlay = () => { } // nextSentence(); if (WebGAL.gameplay.autoTimeout === null) { + // 保留自动阅读的停留时间,到时再检查能否推进,不能同步连续跳句。 WebGAL.gameplay.autoTimeout = setTimeout(autoNextSentence, autoPlayDelay); } }; diff --git a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts index f593e5bea..76ab59a5f 100644 --- a/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts +++ b/packages/webgal/src/Core/controller/gamePlay/fastSkip.ts @@ -63,6 +63,7 @@ export const startTemporaryFast = (duration = 150) => { clearTemporaryFast(); if (WebGAL.gameplay.fastInterval !== null) stopFast(); WebGAL.gameplay.isFast = true; + // 临时快进状态持续 duration 毫秒,供此期间的演出判断是否跳过动画。 temporaryFastTimeout = setTimeout(() => { if (WebGAL.gameplay.fastInterval === null) { WebGAL.gameplay.isFast = false; diff --git a/packages/webgal/src/Core/controller/scene/sceneInterface.ts b/packages/webgal/src/Core/controller/scene/sceneInterface.ts index ed9489aea..0cbe0c234 100644 --- a/packages/webgal/src/Core/controller/scene/sceneInterface.ts +++ b/packages/webgal/src/Core/controller/scene/sceneInterface.ts @@ -42,6 +42,7 @@ export enum commandType { wait, callSteam, // 调用Steam功能 return, // 从被调用的场景返回 + changeFigureDiff, // 切换等尺寸静态立绘差分;追加以保持旧指令编号 } /** diff --git a/packages/webgal/src/Core/controller/stage/pixi/GifResource.ts b/packages/webgal/src/Core/controller/stage/pixi/GifResource.ts index f5d0862c3..887cb0b53 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/GifResource.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/GifResource.ts @@ -109,6 +109,8 @@ export class GifResource extends BaseImageResource { this.source.width = canvas.width; this.source.height = canvas.height; + // 预加载期间不推进动画,但要让 prepare 上传真实的首帧。 + this.source.getContext('2d')!.putImageData(this._frames[0].imageData, 0, 0); super.update(); if (this.autoPlay) this.play(); diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index c07b833ab..63e0f3004 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -17,6 +17,10 @@ import { cloneDeep, isEqual } from 'lodash'; import * as PIXI from 'pixi.js'; import { INSTALLED } from 'pixi.js'; import { GifResource } from './GifResource'; +import { figureCash } from '@/Core/gameScripts/vocal/conentsCash'; +import { AssetManager } from './assets/AssetManager'; +import { acquireVideoTexture } from './assets/videoTexture'; +import { ResourceRequest } from './assets/resourceTypes'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { queryStageObjectReferenceBox, type QueryTargetReferenceBoxResult } from './referenceBox'; import { assignPixiTransform } from './stageEffectTransform'; @@ -50,6 +54,13 @@ export interface IStageObject { /** 创建这个立绘时用的身份,见 syncPixiStageState 的 getFigureIdentity */ figureIdentity?: string; isExiting?: boolean; + releaseInstance?: () => void; + /** 口型、眨眼各自最新的换图请求,旧请求完成时据此丢弃 */ + textureRequests?: Partial>; + /** Sprite 当前显示的口型眨眼图地址;未设置时显示的是 sourceUrl */ + textureUrl?: string; + /** 差分混合进行中,口型眨眼暂不换图 */ + isDiffBlending?: boolean; } export interface ILive2DRecord { @@ -88,7 +99,7 @@ export default class PixiStage { public figureObjects = this.createReactiveList([]); public stageWidth = SCREEN_CONSTANTS.width; public stageHeight = SCREEN_CONSTANTS.height; - public assetLoader = new PIXI.Loader(); + public readonly assets = new AssetManager(); public readonly backgroundContainer: PIXI.Container; public backgroundObjects = this.createReactiveList([]); public mainStageObject: IStageObject; @@ -102,7 +113,7 @@ export default class PixiStage { public addSpineBg = addSpineBgImpl.bind(this); // 注册到 Ticker 上的函数 private stageAnimations = this.createReactiveList([]); - private loadQueue: { url: string; callback: () => void; name?: string }[] = []; + private live2dFigureRecorder: Array = []; // 锁定变换对象(对象可能正在执行动画,不能应用变换) private lockTransformTarget: Array = []; @@ -118,7 +129,6 @@ export default class PixiStage { */ private MAX_TEX_COUNT = 10; - private figureCash: any; public constructor() { const app = new PIXI.Application({ backgroundAlpha: 0, @@ -185,13 +195,7 @@ export default class PixiStage { this.backgroundContainer, ); this.currentApp = app; - // loader 防死 - const reload = () => { - setTimeout(reload, 500); - this.callLoader(); - }; - reload(); - this.initialize(); + if (app.renderer instanceof PIXI.Renderer) this.assets.attach(app.renderer); this.requestRender(); } @@ -268,6 +272,10 @@ export default class PixiStage { this.removeAnimationByIndex(index); } + public hasAnimation(key: string): boolean { + return this.stageAnimations.some((animation) => animation.key === key); + } + public removeAnimationByTargetKey(targetKey: string) { let index = this.stageAnimations.findIndex((e) => e.targetKey === targetKey); while (index !== -1) { @@ -295,16 +303,7 @@ export default class PixiStage { closed: targetAnimation.mouthAnimation.close, }; - // Load mouth texture (reuse if already loaded) - this.loadAsset(mouthTextureUrls[mouthState], () => { - const texture = this.assetLoader.resources[mouthTextureUrls[mouthState]].texture; - const sprite = currentFigure?.children?.[0] as PIXI.Sprite; - if (!texture || !sprite) { - return; - } - sprite.texture = texture; - this.requestRender(); - }); + this.swapFigureTexture(key, 'mouth', mouthTextureUrls[mouthState]); } // eslint-disable-next-line max-params @@ -324,16 +323,42 @@ export default class PixiStage { closed: targetAnimation.blinkAnimation.close, }; - // Load eye texture (reuse if already loaded) - this.loadAsset(blinkTextureUrls[blinkState], () => { - const texture = this.assetLoader.resources[blinkTextureUrls[blinkState]].texture; - const sprite = currentFigure?.children?.[0] as PIXI.Sprite; - if (!texture || !sprite) { - return; - } - sprite.texture = texture; - this.requestRender(); - }); + this.swapFigureTexture(key, 'blink', blinkTextureUrls[blinkState]); + } + + /** + * 口型与眨眼各自只采用本通道最新一次请求;空路径表示未配置该差分,不发起加载。 + * 差分优先:混合进行中不换图;语音等演出捕获的旧表情配置已被差分替换,同样丢弃。 + */ + private swapFigureTexture(key: string, channel: 'mouth' | 'blink', url: string) { + const object = this.getStageObjByKey(key); + if (!object || object.isDiffBlending || !url || url.endsWith('/')) return; + if (!this.isCurrentAssociatedTexture(key, url)) return; + const request = uuid(); + object.textureRequests = { ...object.textureRequests, [channel]: request }; + this.loadStageAsset( + object.uuid, + () => { + if (object.textureRequests?.[channel] !== request || object.isDiffBlending) return; + // 加载期间可能已换了差分,完成时再确认一次。 + if (!this.isCurrentAssociatedTexture(key, url)) return; + const texture = this.assets.getReady({ url, kind: 'texture' }); + const sprite = object.pixiContainer?.children?.[0] as PIXI.Sprite | undefined; + if (!texture || !sprite) return; + sprite.texture = texture; + object.textureUrl = url; + this.requestRender(); + }, + { url, kind: 'texture' }, + ); + } + + /** 只接受已提交状态中该立绘当前的口型眨眼图 */ + private isCurrentAssociatedTexture(key: string, url: string) { + const current = stageStateManager.getViewStageState().figureAssociatedAnimation.find((e) => e.targetId === key); + return ( + !!current && [...Object.values(current.mouthAnimation), ...Object.values(current.blinkAnimation)].includes(url) + ); } /** @@ -342,8 +367,6 @@ export default class PixiStage { * @param url 背景图片url */ public addBg(key: string, url: string) { - // const loader = this.assetLoader; - const loader = this.assetLoader; // 准备用于存放这个背景的 Container const thisBgContainer = new WebGALPixiContainer(); @@ -372,46 +395,38 @@ export default class PixiStage { // 完成图片加载后执行的函数 const setup = () => { - // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! - - setTimeout(() => { - const texture = loader.resources?.[url]?.texture; - if (texture && this.getStageObjByUuid(bgUuid)) { - /** - * 重设大小 - */ - const originalWidth = texture.width; - const originalHeight = texture.height; - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - const targetScale = Math.max(scaleX, scaleY); - const bgSprite = new PIXI.Sprite(texture); - bgSprite.scale.x = targetScale; - bgSprite.scale.y = targetScale; - bgSprite.anchor.set(0.5); - bgSprite.position.y = this.stageHeight / 2; - thisBgContainer.setBaseX(this.stageWidth / 2); - thisBgContainer.setBaseY(this.stageHeight / 2); - thisBgContainer.pivot.set(0, this.stageHeight / 2); - - // 挂载 - thisBgContainer.addChild(bgSprite); - this.notifyTargetReferenceBoxChanged(key); - this.requestRender(); - } - }, 0); + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + const texture = this.assets.getReady({ url, kind: 'texture' }); + if (texture && this.getStageObjByUuid(bgUuid)) { + /** + * 重设大小 + */ + const originalWidth = texture.width; + const originalHeight = texture.height; + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + const targetScale = Math.max(scaleX, scaleY); + const bgSprite = new PIXI.Sprite(texture); + bgSprite.scale.x = targetScale; + bgSprite.scale.y = targetScale; + bgSprite.anchor.set(0.5); + bgSprite.position.y = this.stageHeight / 2; + thisBgContainer.setBaseX(this.stageWidth / 2); + thisBgContainer.setBaseY(this.stageHeight / 2); + thisBgContainer.pivot.set(0, this.stageHeight / 2); + + // 挂载 + thisBgContainer.addChild(bgSprite); + if (texture.baseTexture.resource instanceof GifResource) texture.baseTexture.resource.play(); + this.notifyTargetReferenceBoxChanged(key); + this.requestRender(); + } }; /** * 加载器部分 */ - this.cacheGC(); - if (!loader.resources?.[url]?.texture) { - this.loadAsset(url, setup); - } else { - // 复用 - setup(); - } + this.loadStageAsset(bgUuid, setup); } /** @@ -420,7 +435,6 @@ export default class PixiStage { * @param url 背景图片url */ public addVideoBg(key: string, url: string) { - const loader = this.assetLoader; // 准备用于存放这个背景的 Container const thisBgContainer = new WebGALPixiContainer(); @@ -446,57 +460,44 @@ export default class PixiStage { sourceExt: this.getExtName(url), }); - // 完成加载后执行的函数 const setup = () => { - // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! - - setTimeout(() => { - console.debug('start loaded video: ' + url); - const video = document.createElement('video'); - const videoResource = new PIXI.VideoResource(video); - videoResource.src = url; - videoResource.source.preload = 'auto'; - videoResource.source.muted = true; - videoResource.source.loop = true; - videoResource.source.autoplay = true; - videoResource.source.src = url; - // @ts-ignore - const texture = PIXI.Texture.from(videoResource); - if (texture && this.getStageObjByUuid(bgUuid)) { - /** - * 重设大小 - */ - texture.baseTexture.resource.load().then(() => { - const originalWidth = videoResource.source.videoWidth; - const originalHeight = videoResource.source.videoHeight; - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - const targetScale = Math.max(scaleX, scaleY); - const bgSprite = new PIXI.Sprite(texture); - bgSprite.scale.x = targetScale; - bgSprite.scale.y = targetScale; - bgSprite.anchor.set(0.5); - bgSprite.position.y = this.stageHeight / 2; - thisBgContainer.setBaseX(this.stageWidth / 2); - thisBgContainer.setBaseY(this.stageHeight / 2); - thisBgContainer.pivot.set(0, this.stageHeight / 2); - thisBgContainer.addChild(bgSprite); - this.notifyTargetReferenceBoxChanged(key); - }); - } - }, 0); + const prepared = this.assets.getReady({ url, kind: 'texture' }); + if (!prepared) return; + return acquireVideoTexture(prepared, url) + .then(async ({ texture, video, release }) => { + const object = this.getStageObjByUuid(bgUuid); + if (!object) { + release(); + return; + } + object.releaseInstance = release; + await this.assets.prepare([texture]); + if (!this.getStageObjByUuid(bgUuid)) return; + const originalWidth = video.videoWidth; + const originalHeight = video.videoHeight; + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + const targetScale = Math.max(scaleX, scaleY); + const bgSprite = new PIXI.Sprite(texture); + bgSprite.scale.x = targetScale; + bgSprite.scale.y = targetScale; + bgSprite.anchor.set(0.5); + bgSprite.position.y = this.stageHeight / 2; + thisBgContainer.setBaseX(this.stageWidth / 2); + thisBgContainer.setBaseY(this.stageHeight / 2); + thisBgContainer.pivot.set(0, this.stageHeight / 2); + thisBgContainer.addChild(bgSprite); + void video.play().catch((error) => logger.warn('视频背景播放失败', error)); + this.notifyTargetReferenceBoxChanged(key); + this.requestRender(); + }) + .catch((error) => logger.warn(`视频背景加载失败:${url}`, error)); }; /** * 加载器部分 */ - this.cacheGC(); - if (!loader.resources?.[url]?.texture) { - this.loadAsset(url, setup); - } else { - // 复用 - setup(); - } + this.loadStageAsset(bgUuid, setup); } /** @@ -506,7 +507,6 @@ export default class PixiStage { * @param presetPosition */ public addFigure(key: string, url: string, presetPosition: IFigurePosition = 'center') { - const loader = this.assetLoader; // 准备用于存放这个立绘的 Container const thisFigureContainer = new WebGALPixiContainer(); @@ -542,48 +542,41 @@ export default class PixiStage { }); // 完成图片加载后执行的函数 const setup = () => { - // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! - setTimeout(() => { - const texture = loader.resources?.[url]?.texture; - if (texture && this.getStageObjByUuid(figureUuid)) { - /** - * 重设大小 - */ - const originalWidth = texture.width; - const originalHeight = texture.height; - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - const targetScale = Math.min(scaleX, scaleY); - const figureSprite = new PIXI.Sprite(texture); - figureSprite.scale.x = targetScale; - figureSprite.scale.y = targetScale; - figureSprite.anchor.set(0.5); - figureSprite.position.y = this.stageHeight / 2; - const targetWidth = originalWidth * targetScale; - const targetHeight = originalHeight * targetScale; - thisFigureContainer.setBaseY(this.stageHeight / 2); - if (targetHeight < this.stageHeight) { - thisFigureContainer.setBaseY(this.stageHeight / 2 + (this.stageHeight - targetHeight) / 2); - } - thisFigureContainer.setBaseX(getFigureBaseX(presetPosition, this.stageWidth, targetWidth)); - thisFigureContainer.pivot.set(0, this.stageHeight / 2); - thisFigureContainer.addChild(figureSprite); - this.notifyTargetReferenceBoxChanged(key); - this.requestRender(); + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + const texture = this.assets.getReady({ url, kind: 'texture' }); + if (texture && this.getStageObjByUuid(figureUuid)) { + /** + * 重设大小 + */ + const originalWidth = texture.width; + const originalHeight = texture.height; + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + const targetScale = Math.min(scaleX, scaleY); + const figureSprite = new PIXI.Sprite(texture); + figureSprite.scale.x = targetScale; + figureSprite.scale.y = targetScale; + figureSprite.anchor.set(0.5); + figureSprite.position.y = this.stageHeight / 2; + const targetWidth = originalWidth * targetScale; + const targetHeight = originalHeight * targetScale; + thisFigureContainer.setBaseY(this.stageHeight / 2); + if (targetHeight < this.stageHeight) { + thisFigureContainer.setBaseY(this.stageHeight / 2 + (this.stageHeight - targetHeight) / 2); } - }, 0); + thisFigureContainer.setBaseX(getFigureBaseX(presetPosition, this.stageWidth, targetWidth)); + thisFigureContainer.pivot.set(0, this.stageHeight / 2); + thisFigureContainer.addChild(figureSprite); + if (texture.baseTexture.resource instanceof GifResource) texture.baseTexture.resource.play(); + this.notifyTargetReferenceBoxChanged(key); + this.requestRender(); + } }; /** * 加载器部分 */ - this.cacheGC(); - if (!loader.resources?.[url]?.texture) { - this.loadAsset(url, setup); - } else { - // 复用 - setup(); - } + this.loadStageAsset(figureUuid, setup); } /** @@ -592,14 +585,12 @@ export default class PixiStage { */ // eslint-disable-next-line max-params public addLive2dFigure(key: string, jsonPath: string, pos: IFigurePosition) { - if (Live2D.isAvailable !== true) return; try { let stageWidth = this.stageWidth; let stageHeight = this.stageHeight; - this.figureCash.push(jsonPath); + figureCash.push(jsonPath); - const loader = this.assetLoader; // 准备用于存放这个立绘的 Container const thisFigureContainer = new WebGALPixiContainer(); @@ -637,7 +628,9 @@ export default class PixiStage { const setup = () => { if (thisFigureContainer && this.getStageObjByUuid(figureUuid)) { - (async function () { + return (async function () { + await Live2D.ready; + if (!Live2D.isAvailable || !instance.getStageObjByUuid(figureUuid)) return; let overrideBounds: [number, number, number, number] = [0, 0, 0, 0]; const mot = stageStateManager.getViewStageState().live2dMotion.find((e) => e.target === key); if (mot?.overrideBounds) { @@ -657,6 +650,10 @@ export default class PixiStage { ]); models.forEach((model) => { + if (!instance.getStageObjByUuid(figureUuid)) { + model.destroy(); + return; + } const scaleX = stageWidth / model.width; const scaleY = stageHeight / model.height; const targetScale = Math.min(scaleX, scaleY); @@ -725,25 +722,18 @@ export default class PixiStage { thisFigureContainer.addChild(model); instance.notifyTargetReferenceBoxChanged(key); + instance.requestRender(); }); - })(); + })().catch((error) => logger.warn(`Live2D 加载失败:${jsonPath}`, error)); } }; /** * 加载器部分 */ - const resourses = Object.keys(loader.resources); - this.cacheGC(); - if (!resourses.includes(jsonPath)) { - this.loadAsset(jsonPath, () => setup()); - } else { - // 复用 - setup(); - } + this.loadStageAsset(figureUuid, setup); } catch (error) { console.error('Live2d Module err: ' + error); - Live2D.isAvailable = false; } } @@ -973,6 +963,7 @@ export default class PixiStage { resolve(); }; + // 资源加载失败或尺寸通知缺失时也要结束等待,避免参考尺寸查询永久挂起。 timeoutId = window.setTimeout(resolveAndCleanup, timeoutMs); waiters.add(resolveAndCleanup); }); @@ -997,12 +988,14 @@ export default class PixiStage { const bgSprite = this.figureObjects[indexFig]; if (bgSprite.pixiContainer) for (const element of bgSprite.pixiContainer.children) { - element.destroy(); + element.destroy({ children: true }); } if (bgSprite.pixiContainer) { bgSprite.pixiContainer.destroy(); this.figureContainer.removeChild(bgSprite.pixiContainer); } + bgSprite.releaseInstance?.(); + this.assets.release(bgSprite.uuid); bgSprite.pixiContainer = null; this.figureObjects.splice(indexFig, 1); this.notifyTargetReferenceBoxChanged(key); @@ -1011,12 +1004,14 @@ export default class PixiStage { const bgSprite = this.backgroundObjects[indexBg]; if (bgSprite.pixiContainer) for (const element of bgSprite.pixiContainer.children) { - element.destroy(); + element.destroy({ children: true }); } if (bgSprite.pixiContainer) { bgSprite.pixiContainer.destroy(); this.backgroundContainer.removeChild(bgSprite.pixiContainer); } + bgSprite.releaseInstance?.(); + this.assets.release(bgSprite.uuid); bgSprite.pixiContainer = null; this.backgroundObjects.splice(indexBg, 1); this.notifyTargetReferenceBoxChanged(key); @@ -1033,10 +1028,6 @@ export default class PixiStage { // updateCurrentEffects(newEffects); } - public cacheGC() { - PIXI.utils.clearTextureCache(); - } - public getExtName(url: string) { return (url.split(/[?#]/)[0].split('.').pop() ?? 'png').toLowerCase(); } @@ -1045,15 +1036,33 @@ export default class PixiStage { return stageStateManager.getViewStageState().figureMetaData[key]; } - public loadAsset(url: string, callback: () => void, name?: string) { - /** - * Loader 复用疑似有问题,转而采用先前的单独方式 - */ - this.loadQueue.unshift({ url, callback, name }); - /** - * 尝试启动加载 - */ - this.callLoader(); + public loadStageAsset(stageUuid: string, setup: () => void | Promise, request?: ResourceRequest) { + const object = this.getStageObjByUuid(stageUuid); + if (!object) return; + request ??= { + url: object.sourceUrl, + kind: object.sourceType === 'live2d' || object.sourceType === 'spine' ? object.sourceType : 'texture', + }; + this.assets.retain(stageUuid, request); + // 模型实例初始化尚未结束时,即使舞台对象退场,也不能销毁 SDK 正在使用的纹理。 + const pendingOwner = `${stageUuid}:pending:${uuid()}`; + this.assets.retain(pendingOwner, request); + const mount = () => (this.getStageObjByUuid(stageUuid) ? setup() : undefined); + try { + const operation = + this.assets.getReady(request) !== undefined + ? Promise.resolve(mount()) + : this.assets + .ensureReady(request) + .catch(() => this.assets.ensureReady(request!)) + .then(mount); + void operation + .catch((error) => logger.warn(`舞台资源加载失败:${request!.url}`, error)) + .finally(() => this.assets.release(pendingOwner)); + } catch (error) { + this.assets.release(pendingOwner); + logger.warn(`舞台资源挂载失败:${request.url}`, error); + } } private updateL2dMotionByKey(target: string, motion: string) { @@ -1103,37 +1112,6 @@ export default class PixiStage { } } - private callLoader() { - if (!this.assetLoader.loading) { - const front = this.loadQueue.shift(); - if (front) { - try { - if (this.assetLoader.resources[front.url]) { - front.callback(); - this.callLoader(); - } else { - if (front.name) { - this.assetLoader.add(front.name, front.url).load(() => { - front.callback(); - this.callLoader(); - }); - } else { - this.assetLoader.add(front.url).load(() => { - front.callback(); - this.callLoader(); - }); - } - } - } catch (error) { - logger.fatal('PIXI Loader 故障', error); - front.callback(); - // this.assetLoader.reset(); // 暂时先不用重置 - this.callLoader(); - } - } - } - } - private lockStageObject(targetName: string) { this.lockTransformTarget.push(targetName); } @@ -1143,16 +1121,6 @@ export default class PixiStage { if (index >= 0) this.lockTransformTarget.splice(index, 1); } - private async initialize() { - // 动态加载 figureCash - try { - const { figureCash } = await import('@/Core/gameScripts/vocal/conentsCash'); - this.figureCash = figureCash; - } catch (error) { - console.error('Failed to load figureCash:', error); - } - } - private createReactiveList(array: T[]): T[] { return new Proxy(array, { // eslint-disable-next-line max-params diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/AssetManager.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/AssetManager.ts new file mode 100644 index 000000000..d1597c8bf --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/AssetManager.ts @@ -0,0 +1,184 @@ +import { Renderer, Texture } from 'pixi.js'; +import { logger } from '@/Core/util/logger'; +import { loadResource } from './loadResource'; +import { PreparedResource, ResourceRequest, resourceKey, resourceUrl } from './resourceTypes'; + +interface ResourceEntry { + request: ResourceRequest; + dependencies: Set; + resource?: PreparedResource; + operation?: Promise; + ready: boolean; + loading?: boolean; +} + +/** 舞台对象持有资源,剧情窗口只表达预取意愿;两者都不再需要时才释放。 */ +export class AssetManager { + private entries = new Map(); + private owners = new Map>(); + private future = new Set(); + private queue: ResourceRequest[] = []; + private running = false; + private renderer?: Renderer; + private contextVersion = 0; + private lastTouch = -Infinity; + + public attach(renderer: Renderer) { + this.renderer = renderer; + renderer.runners.prerender.add(this); + renderer.runners.contextChange.add(this); + } + + public retain(owner: string, request: ResourceRequest) { + const keys = this.owners.get(owner) ?? new Set(); + keys.add(resourceKey(request)); + this.owners.set(owner, keys); + } + + public release(owner: string) { + this.owners.delete(owner); + this.collect(); + } + + public preload(requests: ResourceRequest[]) { + const unique = new Map(requests.map((request) => [resourceKey(request), request])); + this.future = new Set(unique.keys()); + // 每次用最新窗口替换等待队列,窗口内部仍按最近剧情优先。 + this.queue = [...unique.values()]; + this.collect(); + void this.runQueue(); + } + + public getReady(request: ResourceRequest): T | undefined { + const entry = this.entries.get(resourceKey(request)); + return entry?.ready ? (entry.resource?.value as T) : undefined; + } + + public async ensureReady(request: ResourceRequest): Promise { + const key = resourceKey(request); + let entry = this.entries.get(key); + if (!entry) { + entry = { request: { ...request, url: resourceUrl(request.url) }, dependencies: new Set(), ready: false }; + this.entries.set(key, entry); + } + // 包括卸载:新的请求必须等旧操作结束,再判断是否需要重新加载。 + if (entry.operation) { + await entry.operation; + return this.ensureReady(request); + } + if (entry.ready) return entry.resource!.value as T; + const current = entry; + current.loading = true; + current.operation = this.load(current).finally(() => { + current.operation = undefined; + current.loading = false; + queueMicrotask(() => this.collect()); + }); + return (await current.operation) as T; + } + + private async load(entry: ResourceEntry) { + try { + entry.resource ??= await loadResource(entry.request, async (url: string, kind: ResourceRequest['kind']) => { + const request = { url, kind }; + entry.dependencies.add(resourceKey(request)); + return this.ensureReady(request); + }); + let version: number; + do { + version = this.contextVersion; + await this.prepare(entry.resource.textures ?? []); + } while (version !== this.contextVersion); + entry.ready = true; + return entry.resource.value; + } catch (error) { + // 清掉失败结果,下一次显示或预取请求可以重试。 + await entry.resource?.dispose?.(); + entry.resource = undefined; + entry.dependencies.clear(); + throw error; + } + } + + public async prepare(textures: Texture[]) { + const prepare = this.renderer?.plugins.prepare; + if (!prepare || textures.length === 0) return; + textures.forEach((texture) => prepare.add(texture)); + await new Promise((resolve) => prepare.upload(resolve)); + } + + private wanted() { + const wanted = new Set([...this.future, ...[...this.owners.values()].flatMap((keys) => [...keys])]); + // 被新剧情淘汰的加载仍可能正在解析图集,依赖必须保留到解析结束。 + for (const [key, entry] of this.entries) if (entry.loading) wanted.add(key); + for (const key of wanted) { + this.entries.get(key)?.dependencies.forEach((dependency) => wanted.add(dependency)); + } + return wanted; + } + + public collect() { + const wanted = this.wanted(); + const candidates = [...this.entries].filter(([key, entry]) => !wanted.has(key) && !entry.operation); + if (candidates.length === 0) return; + // 给同一轮舞台替换机会重新持有资源,避免无谓卸载;整批只重算一次持有集合。 + const latest = Promise.resolve().then(() => this.wanted()); + for (const [key, entry] of candidates) { + entry.operation = latest + .then(async (wanted) => { + if (wanted.has(key)) return; + entry.ready = false; + await entry.resource?.dispose?.(); + entry.resource = undefined; + entry.dependencies.clear(); + this.entries.delete(key); + }) + .catch((error) => logger.warn('释放 Pixi 资源失败', error)) + .finally(() => { + entry.operation = undefined; + if (entry.resource) entry.ready = true; + }); + } + } + + private async runQueue() { + if (this.running) return; + this.running = true; + try { + while (this.queue.length) { + const request = this.queue.shift()!; + try { + await this.ensureReady(request); + } catch (error) { + logger.warn(`预加载失败,显示时允许重试:${request.url}`, error); + } + this.collect(); + } + } finally { + this.running = false; + } + } + + /** 只保护仍被持有的纹理,不关闭其他 Pixi 资源的自动 GC。 */ + public prerender() { + const gc = this.renderer!.textureGC; + // GC 只回收闲置超过 maxIdle 帧的纹理,每半个周期刷新一次即可,无需逐帧重算持有集合。 + if (gc.count - this.lastTouch < gc.maxIdle / 2) return; + this.lastTouch = gc.count; + for (const key of this.wanted()) { + this.entries.get(key)?.resource?.textures?.forEach((texture) => { + texture.baseTexture.touched = gc.count; + }); + } + } + + public contextChange() { + this.contextVersion++; + for (const entry of this.entries.values()) entry.ready = false; + this.queue = [...this.wanted()].flatMap((key) => { + const entry = this.entries.get(key); + return entry ? [entry.request] : []; + }); + void this.runQueue(); + } +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/README.md b/packages/webgal/src/Core/controller/stage/pixi/assets/README.md new file mode 100644 index 000000000..dc32a2cd8 --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/README.md @@ -0,0 +1,19 @@ +# Pixi 6 资源管理 + +`AssetManager` 维护舞台对象持有关系、未来剧情窗口和资源依赖。实际下载及缓存由同版本的 +`@pixi/assets` 提供;`@pixi/basis` 是该包必需的运行时依赖,不升级 Pixi。 + +- 舞台以 UUID 持有资源,退场完成、对象销毁后才释放。异步模型初始化另有临时持有者。 +- 同一资源的加载、GPU prepare、卸载互斥。前台请求直接加载,后台只保留最新窗口,按剧情距离加载。 +- 模型依赖作为资源图中的子项保留,共享图集页和 Live2D 纹理不会随单个模型实例销毁。 +- 图片命中缓存时仍同步挂载。几何计算和动画启动留在原有舞台代码中。 +- GIF 预热首帧但不播放;视频预热后首次使用可以直接复用,重叠播放创建独立实例。 +- GPU GC 只保护仍被持有的纹理;上下文恢复后重新 prepare。 +- Live2D 只预取数据及纹理,模型实例由 SDK 独立创建。SDK 使用预取 JSON 的副本。 + +Spine 仍默认关闭。不要将 `pixi-spine` 添加为默认依赖;用户按 + 自行安装并启用 `spine.ts` 中的动态导入。 +启用后,适配器通过该运行时的图集和骨架解析器处理资源,不再共用繁忙的 `PIXI.Loader`。 + +需要运行时回归的场景:缓存命中、慢网换图、快进、退场纹理共享、口型眨眼、读档跳转、 +视频重叠播放、Live2D 初始化中退场、Spine 多页图集失败重试、WebGL 上下文恢复。 diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/assetParsers.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/assetParsers.ts new file mode 100644 index 000000000..e7ca956a1 --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/assetParsers.ts @@ -0,0 +1,55 @@ +import { Assets } from '@pixi/assets'; +import { BaseTexture, Texture, VideoResource } from 'pixi.js'; +import { GifResource } from '../GifResource'; +import { ResourceKind, resourceExtension } from './resourceTypes'; +import { loadVideoResource } from './videoTexture'; + +/** 数据资源按原始字节缓存,同一 URL 的不同用途各自解码,也保证每次解码出独立副本。 */ +export function decodeData(buffer: ArrayBuffer, kind: ResourceKind) { + if (kind === 'binary') return buffer.slice(0); + const text = new TextDecoder().decode(buffer); + return kind === 'text' ? text : JSON.parse(text); +} + +// 保留 Pixi 6 的图片解码、GIF 和视频语义,Assets.loader 负责请求去重和卸载。 +Assets.loader.parsers.unshift({ + test: (_url, asset) => Boolean(asset?.data?.webgalKind), + load: async (url: string, asset?: { data?: { webgalKind?: string } }) => { + const kind = asset?.data?.webgalKind; + if (kind !== 'texture') { + const response = await fetch(url); + if (!response.ok) throw new Error(`资源加载失败 ${response.status}: ${url}`); + return (await response.arrayBuffer()) as T; + } + const ext = resourceExtension(url); + let texture: Texture | undefined; + try { + if (ext === 'gif') { + const resource = new GifResource(url, { autoLoad: false, autoPlay: false }); + resource.internal = true; + texture = new Texture(new BaseTexture(resource)); + await resource.load(); + } else if (['mp4', 'webm', 'mkv'].includes(ext ?? '')) { + const resource = new VideoResource(url, { autoLoad: false, autoPlay: false }); + resource.internal = true; + resource.source.muted = true; + resource.source.loop = true; + texture = new Texture(new BaseTexture(resource)); + await loadVideoResource(resource); + } else { + texture = Texture.from(url, { resourceOptions: { autoLoad: false } }); + await texture.baseTexture.resource.load(); + } + Texture.addToCache(texture, url); + return texture as T; + } catch (error) { + texture?.destroy(true); + throw error; + } + }, + unload: (asset) => { + if (asset instanceof Texture) asset.destroy(true); + }, +}); + +export { Assets }; diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssetCache.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssetCache.ts new file mode 100644 index 000000000..f17531065 --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssetCache.ts @@ -0,0 +1,32 @@ +import type { Live2DLoader, ModelSettings } from 'pixi-live2d-display-webgal'; +import { Assets, decodeData } from './assetParsers'; +import { resourceUrl } from './resourceTypes'; + +interface Live2dSdk { + Live2DLoader: typeof Live2DLoader; + ModelSettings: typeof ModelSettings; +} + +/** SDK 继续创建独立模型,只复用预加载数据;每次解码出独立副本,避免实例相互修改。 */ +export function installLive2dAssetCache({ Live2DLoader, ModelSettings }: Live2dSdk) { + // 与预加载使用同一套 URL 解析规则,SDK 创建纹理时才能命中 TextureCache 中已上传的纹理。 + ModelSettings.prototype.resolveURL = function (path: string) { + return new URL(path, resourceUrl(this.url)).href; + }; + Live2DLoader.middlewares.unshift(async (context, next) => { + const url = resourceUrl(context.settings ? context.settings.resolveURL(context.url) : context.url); + const pending = Assets.loader.promiseCache[url]?.promise; + if (pending && (context.type === 'json' || context.type === 'arraybuffer')) { + try { + const buffer = await pending; + if (buffer instanceof ArrayBuffer) { + context.result = decodeData(buffer, context.type === 'json' ? 'json' : 'binary'); + return; + } + } catch { + // 预取失败后交还 SDK,保留它原有的错误处理与按需加载行为。 + } + } + await next(); + }); +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssets.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssets.ts new file mode 100644 index 000000000..274520385 --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssets.ts @@ -0,0 +1,52 @@ +import type { Texture } from 'pixi.js'; +import { Live2D } from '@/Core/WebGAL'; +import type { LoadDependency, PreparedResource } from './resourceTypes'; + +interface Live2dSettings { + model?: string; + textures?: string[]; + physics?: string; + pose?: string; + expressions?: { file: string }[]; + motions?: Record; + FileReferences?: { + Moc: string; + Textures: string[]; + Physics?: string; + Pose?: string; + Expressions?: { File: string }[]; + Motions?: Record; + }; +} + +/** 只加载数据和纹理;模型实例、动作起点和自定义 bounds 仍由舞台决定。 */ +export async function loadLive2dResources(url: string, load: LoadDependency): Promise { + // 未引入 SDK 时不下载模型;舞台 setup 同样会因不可用而跳过,不产生错误。 + await Live2D.ready; + if (!Live2D.isAvailable) return { value: undefined }; + const settings = await load(url, 'json'); + const files = settings.FileReferences; + const resolve = (file: string) => new URL(file, url).href; + const textures = await Promise.all( + (files?.Textures ?? settings.textures ?? []).map((file) => load(resolve(file), 'texture')), + ); + const model = files?.Moc ?? settings.model; + if (model) await load(resolve(model), 'binary'); + // 可选动作损坏不应让本来能显示的模型加载失败;SDK 会在使用时重试。 + const optional = files + ? [files.Physics, files.Pose, ...(files.Expressions ?? []).map((item) => item.File)] + : [settings.physics, settings.pose, ...(settings.expressions ?? []).map((item) => item.file)]; + const motions = files + ? Object.values(files.Motions ?? {}) + .flat() + .map((item) => item.File) + : Object.values(settings.motions ?? {}) + .flat() + .map((item) => item.file); + void Promise.allSettled([ + ...optional.filter((file): file is string => Boolean(file)).map((file) => load(resolve(file), 'json')), + // Cubism 2 的动作 SDK 要求 ArrayBuffer,即使文件本身看起来像文本。 + ...motions.map((file) => load(resolve(file), files ? 'json' : 'binary')), + ]); + return { value: settings, textures }; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/loadResource.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/loadResource.ts new file mode 100644 index 000000000..0cb781f55 --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/loadResource.ts @@ -0,0 +1,22 @@ +import { Texture } from 'pixi.js'; +import { Assets, decodeData } from './assetParsers'; +import { LoadDependency, PreparedResource, ResourceRequest } from './resourceTypes'; + +export async function loadResource(request: ResourceRequest, dependency: LoadDependency): Promise { + if (request.kind === 'live2d') { + const { loadLive2dResources } = await import('./live2dAssets'); + return loadLive2dResources(request.url, dependency); + } + if (request.kind === 'spine') { + const { loadSpineResources } = await import('./spineAssets'); + return loadSpineResources(request.url, dependency); + } + // 直接使用 loader:AssetManager 已按 kind + URL 管理条目,不需要 Resolver 的别名表。 + const loaded = await Assets.loader.load({ src: request.url, data: { webgalKind: request.kind } }); + const value = loaded instanceof Texture ? loaded : decodeData(loaded, request.kind); + return { + value, + textures: value instanceof Texture ? [value] : [], + dispose: () => Assets.loader.unload(request.url), + }; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/resourceTypes.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/resourceTypes.ts new file mode 100644 index 000000000..d0f5cdefb --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/resourceTypes.ts @@ -0,0 +1,26 @@ +import type { Texture } from 'pixi.js'; + +export type ResourceKind = 'texture' | 'live2d' | 'spine' | 'json' | 'binary' | 'text'; +export interface ResourceRequest { + url: string; + kind: ResourceKind; +} +export interface PreparedResource { + value: unknown; + textures?: Texture[]; + dispose?: () => void | Promise; +} +export type LoadDependency = (url: string, kind: ResourceKind) => Promise; + +export const resourceUrl = (url: string) => new URL(url, document.baseURI).href; +export const resourceKey = ({ url, kind }: ResourceRequest) => `${kind}:${resourceUrl(url)}`; +export const resourceExtension = (url: string) => + new URL(url, document.baseURI).pathname.split('.').pop()?.toLowerCase(); + +export function stageResource(url: string): ResourceRequest { + const parsed = new URL(url, document.baseURI); + const ext = resourceExtension(url); + const kind = + parsed.searchParams.get('type') === 'spine' || ext === 'skel' ? 'spine' : ext === 'json' ? 'live2d' : 'texture'; + return { url, kind }; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/spineAssets.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/spineAssets.ts new file mode 100644 index 000000000..2dbc0d457 --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/spineAssets.ts @@ -0,0 +1,46 @@ +import { ALPHA_MODES, LoaderResource, Texture } from 'pixi.js'; +import { LoadDependency, PreparedResource, resourceExtension } from './resourceTypes'; + +/** 可选运行时仍由 spine.ts 的显式启用入口提供,不引入 Spine 包依赖。 */ +export async function loadSpineResources(url: string, load: LoadDependency): Promise { + const { loadPixiSpine } = await import('../spine'); + const runtime = await loadPixiSpine(); + if (!runtime) throw new Error('Spine 未启用,请按 WebGAL Spine 文档安装并启用运行时'); + const binary = resourceExtension(url) === 'skel'; + const data = await load(url, binary ? 'binary' : 'json'); + const atlasUrl = new URL(url); + atlasUrl.pathname = atlasUrl.pathname.replace(/\.[^.]+$/, '.atlas'); + atlasUrl.search = ''; + atlasUrl.hash = ''; + const atlasText = await load(atlasUrl.href, 'text'); + const textures: Texture[] = []; + const atlas = await new Promise>((resolve, reject) => { + new runtime.TextureAtlas( + atlasText, + (path: string, done: (base: Texture['baseTexture']) => void) => { + void load(new URL(path, atlasUrl).href, 'texture') + .then((texture) => { + if (path.includes('-pma.') && texture.baseTexture.alphaMode !== ALPHA_MODES.PMA) { + texture.baseTexture.alphaMode = ALPHA_MODES.PMA; + // 页面可能已由依赖加载预热,解析出 PMA 属性后须重新上传。 + texture.baseTexture.dispose(); + } + textures.push(texture); + done(texture.baseTexture); + }) + .catch(reject); + }, + resolve, + ); + }); + const parser = new runtime.SpineParser(); + const resource = new LoaderResource(url, url); + parser.parseData( + resource, + binary ? parser.createBinaryParser() : parser.createJsonParser(), + atlas, + binary ? new Uint8Array(data as ArrayBuffer) : data, + ); + // 图集页纹理由 dependency 管理,不能 atlas.dispose() 误销毁共享页面。 + return { value: resource, textures }; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/videoTexture.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/videoTexture.ts new file mode 100644 index 000000000..827f625a3 --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/videoTexture.ts @@ -0,0 +1,55 @@ +import { BaseTexture, Texture, VideoResource } from 'pixi.js'; + +const playingTextures = new WeakSet(); + +/** Pixi 6 的 VideoResource.load 在 error 时不 reject,需要补上失败通路。 */ +export function loadVideoResource(resource: VideoResource): Promise { + return new Promise((resolve, reject) => { + const video = resource.source; + const fail = () => finish(new Error(`视频加载失败:${resource.src}`)); + const timeout = window.setTimeout(fail, 30000); + const finish = (error?: unknown) => { + clearTimeout(timeout); + video.removeEventListener('error', fail); + if (error) reject(error); + else resolve(); + }; + video.addEventListener('error', fail, { once: true }); + void resource.load().then(() => finish(), finish); + }); +} + +/** 视频有播放进度;重叠退场时创建独立实例,不能像普通图片一样共享播放状态。 */ +export async function acquireVideoTexture(prepared: Texture, url: string) { + const shared = !playingTextures.has(prepared); + const texture = shared + ? prepared + : new Texture( + new BaseTexture( + new VideoResource(url, { + autoLoad: false, + autoPlay: false, + }), + ), + ); + playingTextures.add(texture); + const resource = texture.baseTexture.resource as VideoResource; + resource.internal = true; + const video = resource.source; + const release = () => { + if (!playingTextures.has(texture)) return; + video.pause(); + playingTextures.delete(texture); + if (!shared) texture.destroy(true); + }; + try { + video.muted = true; + video.loop = true; + await loadVideoResource(resource); + video.currentTime = 0; + return { texture, video, release }; + } catch (error) { + release(); + throw error; + } +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/figureDiff.ts b/packages/webgal/src/Core/controller/stage/pixi/figureDiff.ts new file mode 100644 index 000000000..60557b7ee --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/figureDiff.ts @@ -0,0 +1,28 @@ +interface FigureDiffMark { + sourceUrl: string; + targetUrl: string; +} + +/** 内核的一次性换图意图,不保存状态快照,也不管理动画。 */ +export class FigureDiffManager { + private readonly marks = new Map(); + + /** 同一次提交内连续差分(A→B→C)时,起点仍是尚在舞台上的 A。 */ + public mark(key: string, mark: FigureDiffMark) { + const sourceUrl = this.marks.get(key)?.sourceUrl ?? mark.sourceUrl; + this.marks.set(key, { sourceUrl, targetUrl: mark.targetUrl }); + } + + /** 只匹配实际上屏的源图;无论是否匹配,本次尝试都会消费标记。 */ + public consume(key: string, sourceUrl: string, targetUrl: string): boolean { + const mark = this.marks.get(key); + this.marks.delete(key); + return mark?.sourceUrl === sourceUrl && mark.targetUrl === targetUrl; + } + + /** 覆盖目标时清除单个标记;演算被丢弃、舞台重建或提交结束时清除全部。 */ + public clear(key?: string) { + if (key === undefined) this.marks.clear(); + else this.marks.delete(key); + } +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/figureTextureBlend.ts b/packages/webgal/src/Core/controller/stage/pixi/figureTextureBlend.ts new file mode 100644 index 000000000..8a0810b6d --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/figureTextureBlend.ts @@ -0,0 +1,75 @@ +import { BLEND_MODES, Container, Renderer, RenderTexture, Sprite, Texture } from 'pixi.js'; +import { DEFAULT_FIG_IN_DURATION } from '@/Core/constants'; +import { logger } from '@/Core/util/logger'; +import PixiStage from './PixiController'; + +/** 在透明离屏纹理里相加预乘 RGBA,再由原 Sprite 合成到舞台一次。 */ +export function createFigureTextureBlend(stage: PixiStage, oldTexture: Texture, newTexture: Texture) { + const renderer = stage.currentApp!.renderer; + if (!(renderer instanceof Renderer) || renderer.context.isLost) return; + const inputs = new Container(); + const oldImage = inputs.addChild(new Sprite(oldTexture)); + const newImage = inputs.addChild(new Sprite(newTexture)); + // ADD 只用于透明离屏目标,使 RGBA 按互补权重相加;不能直接叠加到舞台背景。 + oldImage.blendMode = newImage.blendMode = BLEND_MODES.ADD; + let texture: RenderTexture; + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + inputs.destroy({ children: true }); + texture?.destroy(true); + }; + const draw = (progress: number) => { + oldImage.alpha = 1 - progress; + newImage.alpha = progress; + renderer.render(inputs, { renderTexture: texture, clear: true }); + }; + try { + // 在改动舞台前完成分配与首次绘制,失败时仍可使用原有入退场。 + texture = RenderTexture.create({ + width: newTexture.width, + height: newTexture.height, + resolution: newTexture.baseTexture.resolution, + scaleMode: newTexture.baseTexture.scaleMode, + }); + draw(0); + } catch (error) { + dispose(); + logger.warn('立绘离屏混合不可用,保留默认过渡', error); + return; + } + return { + attach(sprite: Sprite, release: () => void) { + let elapsed = 0; + const finish = () => { + // 动画结束和对象销毁共用收尾,确保临时纹理与资源持有只释放一次。 + if (disposed) return; + if (!sprite.destroyed) sprite.texture = newTexture; + dispose(); + release(); + stage.requestRender(); + }; + sprite.texture = texture; + return { + setStartState: () => {}, + setEndState: finish, + forceStopWithoutSetEndState: finish, + tickerFunc: () => { + if (disposed) return; + elapsed += stage.currentApp!.ticker.deltaMS; + const t = Math.min(elapsed / DEFAULT_FIG_IN_DURATION, 1); + // 与默认入场相同的 quadratic easeInOut,两张图始终使用互补权重。 + const progress = t < 0.5 ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t); + try { + if (t === 1 || renderer.context.isLost) finish(); + else draw(progress); + } catch (error) { + logger.warn('立绘混合中断,显示目标图片', error); + finish(); + } + }, + }; + }, + }; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/prepareFigureDiff.ts b/packages/webgal/src/Core/controller/stage/pixi/prepareFigureDiff.ts new file mode 100644 index 000000000..e4bff6a1b --- /dev/null +++ b/packages/webgal/src/Core/controller/stage/pixi/prepareFigureDiff.ts @@ -0,0 +1,70 @@ +import { ALPHA_MODES, Sprite, Texture, VideoResource } from 'pixi.js'; +import { WebGAL } from '@/Core/WebGAL'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { IStageObject } from './PixiController'; +import { GifResource } from './GifResource'; +import { createFigureTextureBlend } from './figureTextureBlend'; +import { applyTransformToPixiContainer } from './stageEffectTransform'; + +function isStaticTexture(texture?: Texture): texture is Texture { + return ( + !!texture?.valid && + !texture.trim && + !texture.rotate && + texture.baseTexture.alphaMode !== ALPHA_MODES.NPM && + !(texture.baseTexture.resource instanceof GifResource || texture.baseTexture.resource instanceof VideoResource) + ); +} + +/** 不加载、不等待;仅使用 GPU 已就绪的等尺寸图片,否则由调用方正常换图。 */ +export function prepareFigureDiff(object: IStageObject, url: string) { + const stage = WebGAL.gameplay.pixiStage; + const container = object.pixiContainer; + if (!stage?.currentApp || !container || object.sourceType !== 'img' || object.isExiting || object.releaseInstance) + return; + if (WebGAL.gameplay.skipAnimation || stage.getAllLockedObject().includes(object.key)) return; + const sprite = container.children[0]; + if (container.children.length !== 1 || !(sprite instanceof Sprite)) return; + // 起点是 Sprite 实际显示的图,可能是口型眨眼图(闭嘴图常与原图相同);差分优先于口型眨眼。 + const oldRequest = { url: object.textureUrl ?? object.sourceUrl, kind: 'texture' as const }; + const newRequest = { url, kind: 'texture' as const }; + const oldTexture = stage.assets.getReady(oldRequest); + const newTexture = stage.assets.getReady(newRequest); + if (!isStaticTexture(oldTexture) || !isStaticTexture(newTexture) || sprite.texture !== oldTexture) return; + // 复用容器与 Sprite 的几何,要求逻辑尺寸和像素密度同时一致。 + if ( + oldTexture.width !== newTexture.width || + oldTexture.height !== newTexture.height || + oldTexture.baseTexture.resolution !== newTexture.baseTexture.resolution + ) + return; + const blend = createFigureTextureBlend(stage, oldTexture, newTexture); + if (!blend) return; + // 先用临时 owner 保住两张纹理,再将对象的长期持有从旧图移交给新图。 + // 混合结束后释放临时 owner,旧图便可按 AssetManager 的统一规则回收。 + const owner = `${object.uuid}-diff`; + stage.assets.retain(owner, oldRequest); + stage.assets.retain(owner, newRequest); + stage.assets.release(object.uuid); + stage.assets.retain(object.uuid, newRequest); + object.sourceUrl = url; + // 混合期间暂停口型眨眼并丢弃未完成的换图,结束后显示新差分的原图。 + object.textureRequests = undefined; + object.textureUrl = undefined; + object.isDiffBlending = true; + object.sourceExt = stage.getExtName(url); + // 差分保留立绘状态,复用原容器即保持了遮挡顺序;混合期间对象被锁定,先落地最新变换。 + const state = stageStateManager.getCalculationStageState(); + applyTransformToPixiContainer(container, state.effects.find((effect) => effect.target === object.key)?.transform); + const animation = blend.attach(sprite, () => { + stage.assets.release(owner); + object.releaseInstance = undefined; + object.isDiffBlending = false; + }); + object.releaseInstance = () => { + stage.removeAnimation(`${object.key}-softin`); + animation.setEndState(); + }; + stage.requestRender(); + return animation; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/spine.ts b/packages/webgal/src/Core/controller/stage/pixi/spine.ts index f1f34aa80..b0a2a54e1 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/spine.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/spine.ts @@ -12,7 +12,7 @@ let pixiSpineModule: typeof import('pixi-spine') | null = null; // @ts-ignore let pixiSpineLoading: Promise | null = null; -let spineLoader: undefined | PIXI.Loader; +// Spine 必须由用户按 https://docs.openwebgal.com/spine.html 显式启用。 /** * 动态加载 'pixi-spine' 模块,并缓存结果 @@ -31,7 +31,6 @@ export async function loadPixiSpine(): Promise { - // spineLoader = new PIXI.Loader(); // pixiSpineModule = module; // return module; // }) @@ -59,7 +58,6 @@ export async function addSpineFigureImpl( url: string, presetPosition: IFigurePosition = 'center', ) { - const spineId = `spine-${url}`; // 准备用于存放这个立绘的 Container const thisFigureContainer = new WebGALPixiContainer(); @@ -90,98 +88,87 @@ export async function addSpineFigureImpl( sourceExt: this.getExtName(url), spineAnimation: '_initial', }); - const pixiSpine = await loadPixiSpine(); // 完成图片加载后执行的函数 const setup = async () => { - setTimeout(() => { - console.log('Setting up Spine' + key + url); - if (!pixiSpine) { - // 无法加载 'pixi-spine',跳过 Spine 相关逻辑 - logger.warn(`Spine module not loaded. Skipping Spine figure: ${key}`); - return; - } + const pixiSpine = await loadPixiSpine(); + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + console.log('Setting up Spine' + key + url); + if (!pixiSpine) { + // 无法加载 'pixi-spine',跳过 Spine 相关逻辑 + logger.warn(`Spine module not loaded. Skipping Spine figure: ${key}`); + return; + } - const { Spine } = pixiSpine; - const spineResource: any = spineLoader!.resources?.[spineId]; - if (spineResource && this.getStageObjByUuid(figureUuid)) { - const figureSpine = new Spine(spineResource.spineData); - const spineBounds = figureSpine.getLocalBounds(); - const spineCenterX = spineBounds.x + spineBounds.width / 2; - const spineCenterY = spineBounds.y + spineBounds.height / 2; - figureSpine.pivot.set(spineCenterX, spineCenterY); - figureSpine.interactive = false; + const { Spine } = pixiSpine; + const spineResource: any = this.assets.getReady({ url, kind: 'spine' }); + if (spineResource && this.getStageObjByUuid(figureUuid)) { + const figureSpine = new Spine(spineResource.spineData); + const spineBounds = figureSpine.getLocalBounds(); + const spineCenterX = spineBounds.x + spineBounds.width / 2; + const spineCenterY = spineBounds.y + spineBounds.height / 2; + figureSpine.pivot.set(spineCenterX, spineCenterY); + figureSpine.interactive = false; - const motionFromState = stageStateManager.getViewStageState().live2dMotion.find((e) => e.target === key); - let animationToPlay = ''; - if (motionFromState?.skin) { - if (!applySpineSkin(figureSpine, motionFromState.skin)) { - logger.warn(`Spine skin not found: ${motionFromState.skin} on ${key}`); - } + const motionFromState = stageStateManager.getViewStageState().live2dMotion.find((e) => e.target === key); + let animationToPlay = ''; + if (motionFromState?.skin) { + if (!applySpineSkin(figureSpine, motionFromState.skin)) { + logger.warn(`Spine skin not found: ${motionFromState.skin} on ${key}`); } + } - if ( - motionFromState && - figureSpine.spineData.animations.find((anim: any) => anim.name === motionFromState.motion) - ) { - // 使用状态中指定的动画 - animationToPlay = motionFromState.motion; - } else if (figureSpine.spineData.animations.length > 0) { - // 播放默认动画(第一个动画) - animationToPlay = figureSpine.spineData.animations[0].name; - } + if ( + motionFromState && + figureSpine.spineData.animations.find((anim: any) => anim.name === motionFromState.motion) + ) { + // 使用状态中指定的动画 + animationToPlay = motionFromState.motion; + } else if (figureSpine.spineData.animations.length > 0) { + // 播放默认动画(第一个动画) + animationToPlay = figureSpine.spineData.animations[0].name; + } - if (animationToPlay) { - figureSpine.state.setAnimation(0, animationToPlay, false); - figureSpine.autoUpdate = true; - const stageObj = this.getStageObjByUuid(figureUuid); - if (stageObj) { - if (stageObj.spineAnimation) { - stageObj.spineAnimation = animationToPlay; - } + if (animationToPlay) { + figureSpine.state.setAnimation(0, animationToPlay, false); + figureSpine.autoUpdate = true; + const stageObj = this.getStageObjByUuid(figureUuid); + if (stageObj) { + if (stageObj.spineAnimation) { + stageObj.spineAnimation = animationToPlay; } } + } - /** - * 重设大小 - */ - const originalWidth = figureSpine.width; - const originalHeight = figureSpine.height; - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - const targetScale = Math.min(scaleX, scaleY); - const figureSprite = new PIXI.Sprite(); - figureSprite.addChild(figureSpine); - figureSprite.scale.x = targetScale; - figureSprite.scale.y = targetScale; - figureSprite.anchor.set(0.5); - figureSprite.position.y = this.stageHeight / 2; - const targetWidth = originalWidth * targetScale; - const targetHeight = originalHeight * targetScale; - thisFigureContainer.setBaseY(this.stageHeight / 2); - if (targetHeight < this.stageHeight) { - thisFigureContainer.setBaseY(this.stageHeight / 2 + (this.stageHeight - targetHeight) / 2); - } - thisFigureContainer.setBaseX(getFigureBaseX(presetPosition, this.stageWidth, targetWidth)); - thisFigureContainer.pivot.set(0, this.stageHeight / 2); - thisFigureContainer.addChild(figureSprite); - this.notifyTargetReferenceBoxChanged(key); + /** + * 重设大小 + */ + const originalWidth = figureSpine.width; + const originalHeight = figureSpine.height; + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + const targetScale = Math.min(scaleX, scaleY); + const figureSprite = new PIXI.Sprite(); + figureSprite.addChild(figureSpine); + figureSprite.scale.x = targetScale; + figureSprite.scale.y = targetScale; + figureSprite.anchor.set(0.5); + figureSprite.position.y = this.stageHeight / 2; + const targetWidth = originalWidth * targetScale; + const targetHeight = originalHeight * targetScale; + thisFigureContainer.setBaseY(this.stageHeight / 2); + if (targetHeight < this.stageHeight) { + thisFigureContainer.setBaseY(this.stageHeight / 2 + (this.stageHeight - targetHeight) / 2); } - }, 0); + thisFigureContainer.setBaseX(getFigureBaseX(presetPosition, this.stageWidth, targetWidth)); + thisFigureContainer.pivot.set(0, this.stageHeight / 2); + thisFigureContainer.addChild(figureSprite); + this.notifyTargetReferenceBoxChanged(key); + this.requestRender(); + } }; - /** - * 加载器部分 - * 这里不再使用 this.loadAsset,因为我们可能需要单独管理 Spine 资源 - * 但为了避免性能问题,我们继续使用现有的 loader,并确保资源只加载一次 - */ - this.cacheGC(); - if (!spineLoader!.resources?.[spineId]) { - spineLoader!.add(spineId, url).load(setup); - } else { - // 复用 - await setup(); - } + this.loadStageAsset(figureUuid, setup, { url, kind: 'spine' }); } /** @@ -190,7 +177,6 @@ export async function addSpineFigureImpl( * @param url Spine 数据的 URL */ export async function addSpineBgImpl(this: PixiStage, key: string, url: string) { - const spineId = `spine-${url}`; // 准备用于存放这个背景的 Container const thisBgContainer = new WebGALPixiContainer(); @@ -226,55 +212,43 @@ export async function addSpineBgImpl(this: PixiStage, key: string, url: string) } const { Spine } = pixiSpine; - const spineResource: any = spineLoader!.resources?.[spineId]; - // TODO:找一个更好的解法,现在的解法是无论是否复用原来的资源,都设置一个延时以让动画工作正常! - setTimeout(() => { - if (spineResource && this.getStageObjByUuid(bgUuid)) { - const bgSpine = new Spine(spineResource.spineData); - const transY = spineResource?.spineData?.y ?? 0; - /** - * 重设大小 - */ - const originalWidth = bgSpine.width; // TODO: 视图大小可能小于画布大小,应提供参数指定视图大小 - const originalHeight = bgSpine.height; // TODO: 视图大小可能小于画布大小,应提供参数指定视图大小 - const scaleX = this.stageWidth / originalWidth; - const scaleY = this.stageHeight / originalHeight; - logger.debug('bgSpine state', bgSpine.state); - // TODO: 也许应该使用 setAnimation 播放初始动画 - if (bgSpine.spineData.animations.length > 0) { - // 播放首个动画 - bgSpine.state.setAnimation(0, bgSpine.spineData.animations[0].name, true); - } - const targetScale = Math.max(scaleX, scaleY); - const bgSprite = new PIXI.Sprite(); - bgSprite.addChild(bgSpine); - bgSprite.scale.x = targetScale; - bgSprite.scale.y = targetScale; - bgSprite.anchor.set(0.5); - bgSprite.position.y = this.stageHeight / 2; - thisBgContainer.setBaseX(this.stageWidth / 2); - thisBgContainer.setBaseY(this.stageHeight / 2); - thisBgContainer.pivot.set(0, this.stageHeight / 2); - - // 挂载 - thisBgContainer.addChild(bgSprite); - this.notifyTargetReferenceBoxChanged(key); + const spineResource: any = this.assets.getReady({ url, kind: 'spine' }); + // 对象已同步入表,资源就绪后直接挂载;动画由 commit 后的演出启动。 + if (spineResource && this.getStageObjByUuid(bgUuid)) { + const bgSpine = new Spine(spineResource.spineData); + const transY = spineResource?.spineData?.y ?? 0; + /** + * 重设大小 + */ + const originalWidth = bgSpine.width; // TODO: 视图大小可能小于画布大小,应提供参数指定视图大小 + const originalHeight = bgSpine.height; // TODO: 视图大小可能小于画布大小,应提供参数指定视图大小 + const scaleX = this.stageWidth / originalWidth; + const scaleY = this.stageHeight / originalHeight; + logger.debug('bgSpine state', bgSpine.state); + // TODO: 也许应该使用 setAnimation 播放初始动画 + if (bgSpine.spineData.animations.length > 0) { + // 播放首个动画 + bgSpine.state.setAnimation(0, bgSpine.spineData.animations[0].name, true); } - }, 0); + const targetScale = Math.max(scaleX, scaleY); + const bgSprite = new PIXI.Sprite(); + bgSprite.addChild(bgSpine); + bgSprite.scale.x = targetScale; + bgSprite.scale.y = targetScale; + bgSprite.anchor.set(0.5); + bgSprite.position.y = this.stageHeight / 2; + thisBgContainer.setBaseX(this.stageWidth / 2); + thisBgContainer.setBaseY(this.stageHeight / 2); + thisBgContainer.pivot.set(0, this.stageHeight / 2); + + // 挂载 + thisBgContainer.addChild(bgSprite); + this.notifyTargetReferenceBoxChanged(key); + this.requestRender(); + } }; - /** - * 加载器部分 - * 这里不再使用 this.loadAsset,因为我们可能需要单独管理 Spine 资源 - * 但为了避免性能问题,我们继续使用现有的 loader,并确保资源只加载一次 - */ - this.cacheGC(); - if (!spineLoader!.resources?.[spineId]) { - spineLoader!.add(spineId, url).load(setup); - } else { - // 复用 - await setup(); - } + this.loadStageAsset(bgUuid, setup, { url, kind: 'spine' }); } function applySpineSkin(spineObject: any, skinName: string) { diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index de5d59d3c..b11c29b23 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -13,6 +13,8 @@ import { getAnimateDuration, getExitAnimation } from '@/Core/Modules/animationFu import { logger } from '@/Core/util/logger'; import { setEbg } from '@/Core/gameScripts/changeBg/setEbg'; import { applyTransformToPixiContainer } from '@/Core/controller/stage/pixi/stageEffectTransform'; +import { prefetchCurrentSceneByProgress } from '@/Core/util/prefetcher/progressPrefetcher'; +import { prepareFigureDiff } from './prepareFigureDiff'; interface ISyncFigureSlotPayload { key: string; @@ -50,9 +52,14 @@ function getEnterDuration(stageState: IStageState, target: string, isBg: boolean export function syncPixiStageState(stageState: IStageState, options: IResolvedStageCommitOptions) { if (options.syncPixiStage) { syncBg(stageState, options.skipAnimation); - syncFigures(stageState, options.skipAnimation); + try { + syncFigures(stageState, options.skipAnimation); + } finally { + WebGAL.figureDiffManager.clear(); + } syncLive2d(stageState); syncFigureMetaData(stageState); + prefetchCurrentSceneByProgress(); } if (options.applyPixiEffects) { applyStageEffects(stageState.effects); @@ -159,15 +166,32 @@ function syncFigureSlot(payload: ISyncFigureSlotPayload) { if (sourceUrl) { const identity = getFigureIdentity(payload); if (currentFigure?.figureIdentity === identity) return; + const sameGeometry = + currentFigure?.figureIdentity === getFigureIdentity({ ...payload, sourceUrl: currentFigure?.sourceUrl ?? '' }); + const isDiff = + !!currentFigure && sameGeometry && WebGAL.figureDiffManager.consume(key, currentFigure.sourceUrl, sourceUrl); + const diffAnimation = isDiff && !skipAnimation ? prepareFigureDiff(currentFigure!, sourceUrl) : undefined; + if (currentFigure && diffAnimation) { + currentFigure.figureIdentity = identity; + pixiStage.registerAnimation(diffAnimation, softInAniKey, key); + return; + } + // 差分是同一立绘,未能混合时新对象也要接替旧对象的遮挡顺序,而不是排到同层末尾。 + const diffIndex = + isDiff && currentFigure?.pixiContainer + ? pixiStage.figureContainer.getChildIndex(currentFigure.pixiContainer) + : -1; if (currentFigure) { removeFig(currentFigure, softInAniKey, skipAnimation); } - // 入场动画由 changeFigure 作为演出产出,这里只负责创建舞台对象 + // 入场动画由 changeFigure / changeFigureDiff 作为演出产出,这里只负责创建舞台对象 addFigure(key, sourceUrl, position); // 舞台对象是同步入表的,这里记下它是按哪份身份创建的,供下次同步比对 const newFigure = pixiStage.getStageObjByKey(key); if (newFigure) { newFigure.figureIdentity = identity; + if (diffIndex >= 0 && newFigure.pixiContainer) + pixiStage.figureContainer.addChildAt(newFigure.pixiContainer, diffIndex); } logger.debug(`${key} 立绘已重设`); return; @@ -201,6 +225,13 @@ function syncLive2d(stageState: IStageState) { function syncFigureMetaData(stageState: IStageState) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; + for (const animation of stageState.figureAssociatedAnimation) { + const object = pixiStage.getStageObjByKey(animation.targetId); + if (!object || object.isExiting) continue; + for (const url of [...Object.values(animation.mouthAnimation), ...Object.values(animation.blinkAnimation)]) { + if (url && !url.endsWith('/')) pixiStage.loadStageAsset(object.uuid, () => undefined, { url, kind: 'texture' }); + } + } Object.entries(stageState.figureMetaData).forEach(([key, value]) => { const figureObject = pixiStage.getStageObjByKey(key); if (figureObject && !figureObject.isExiting && figureObject.pixiContainer) { @@ -229,6 +260,7 @@ function removeBg(bgObject: IStageObject, skipAnimation: boolean): number { pixiStage.removeStageObjectByKey(oldBgKey); const { duration, animation } = getExitAnimation('bg-main-off', true, bgKey); pixiStage.registerAnimation(animation, bgAniKey, bgKey); + // 保留退出对象直到退场时长结束;同步销毁会让退场动画不可见。 setTimeout(() => { pixiStage.removeAnimation(bgAniKey); pixiStage.removeStageObjectByKey(bgKey); @@ -256,6 +288,7 @@ function removeFig(figObj: IStageObject, enterTikerKey: string, skipAnimation: b // 退出对象的 key 带时间戳,永远不在 effects 白名单里,因此与背景一样走普通动画通道即可 const { duration, animation } = getExitAnimation(figLeaveAniKey, false, figKey); pixiStage.registerAnimation(animation, leaveKey, figKey); + // 保留退出对象直到退场时长结束;同步销毁会让退场动画不可见。 setTimeout(() => { pixiStage.removeAnimation(leaveKey); pixiStage.removeStageObjectByKey(figKey); diff --git a/packages/webgal/src/Core/controller/stage/resetStage.ts b/packages/webgal/src/Core/controller/stage/resetStage.ts index fc62f39d3..6caf0bbb2 100644 --- a/packages/webgal/src/Core/controller/stage/resetStage.ts +++ b/packages/webgal/src/Core/controller/stage/resetStage.ts @@ -2,6 +2,7 @@ import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; import { initState, stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { stopFast } from '@/Core/controller/gamePlay/fastSkip'; +import { clearProgressPrefetch } from '@/Core/util/prefetcher/progressPrefetcher'; export interface ResetStageOptions { commitStageState?: boolean; @@ -22,6 +23,7 @@ export const resetStage = (resetBacklog: boolean, resetSceneAndVar = true, optio // 清空所有演出和timeOut WebGAL.gameplay.pixiStage?.removeAllAnimations(); + clearProgressPrefetch(); stopFast(); WebGAL.gameplay.performController.removeAllPerform(); WebGAL.gameplay.resetGamePlay(); diff --git a/packages/webgal/src/Core/controller/storage/storageController.ts b/packages/webgal/src/Core/controller/storage/storageController.ts index 962e73d45..e35d593ce 100644 --- a/packages/webgal/src/Core/controller/storage/storageController.ts +++ b/packages/webgal/src/Core/controller/storage/storageController.ts @@ -49,6 +49,7 @@ function debounce(func: (...args: T[]) => K, wait: number) { function context(...args: T[]): K { clearTimeout(timeout); let ret!: K; + // 等连续调用停止 wait 毫秒后只执行最后一次,合并频繁的存储写入。 timeout = setTimeout(() => { ret = func.apply(context, args); }, wait); diff --git a/packages/webgal/src/Core/gameScripts/changeFigure.ts b/packages/webgal/src/Core/gameScripts/changeFigure.ts index 78486e49e..3af7698b6 100644 --- a/packages/webgal/src/Core/gameScripts/changeFigure.ts +++ b/packages/webgal/src/Core/gameScripts/changeFigure.ts @@ -12,7 +12,6 @@ import { figureStateKeyByPosition, IFreeFigure, normalizeFigureBounds } from '@/ import { AnimationFrame, IUserAnimation } from '@/Core/Modules/animations'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; -import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; import { logger } from '@/Core/util/logger'; import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; @@ -20,6 +19,7 @@ import { baseBlinkParam, baseFocusParam, BlinkParam, FocusParam } from '@/Core/l import { DEFAULT_FIG_IN_DURATION, DEFAULT_FIG_OUT_DURATION, WEBGAL_NONE } from '../constants'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; import { parseTransformFrame } from './parseTransformFrame'; +import { setFigureAssociatedAnimation } from './figureAssociatedAnimation'; /** * 更改立绘 * @param sentence 语句 @@ -70,14 +70,6 @@ export function changeFigure(sentence: ISentence): IPerform { } } - // 图片立绘差分 - const mouthOpen = assetSetter(getStringArgByKey(sentence, 'mouthOpen') ?? '', fileType.figure); - const mouthClose = assetSetter(getStringArgByKey(sentence, 'mouthClose') ?? '', fileType.figure); - const mouthHalfOpen = assetSetter(getStringArgByKey(sentence, 'mouthHalfOpen') ?? '', fileType.figure); - const eyesOpen = assetSetter(getStringArgByKey(sentence, 'eyesOpen') ?? '', fileType.figure); - const eyesClose = assetSetter(getStringArgByKey(sentence, 'eyesClose') ?? '', fileType.figure); - const animationFlag = getStringArgByKey(sentence, 'animationFlag') ?? ''; - // 其他参数 const transformString = getStringArgByKey(sentence, 'transform'); const ease = getStringArgByKey(sentence, 'ease') ?? ''; @@ -91,23 +83,8 @@ export function changeFigure(sentence: ISentence): IPerform { const exitDuration = getNumberArgByKey(sentence, 'exitDuration') ?? DEFAULT_FIG_OUT_DURATION; const ignoreDefault = getBooleanArgByKey(sentence, 'ignoreDefault') ?? false; - const currentFigureAssociatedAnimation = stageStateManager.getCalculationStageState().figureAssociatedAnimation; - const filteredFigureAssociatedAnimation = currentFigureAssociatedAnimation.filter((item) => item.targetId !== id); - const newFigureAssociatedAnimationItem = { - targetId: id, - animationFlag: animationFlag, - mouthAnimation: { - open: mouthOpen, - close: mouthClose, - halfOpen: mouthHalfOpen, - }, - blinkAnimation: { - open: eyesOpen, - close: eyesClose, - }, - }; - filteredFigureAssociatedAnimation.push(newFigureAssociatedAnimationItem); - stageStateManager.setStage('figureAssociatedAnimation', filteredFigureAssociatedAnimation); + // 图片立绘的口型、眨眼差分 + setFigureAssociatedAnimation(sentence, id); /** * 立绘的身份:图片地址、基准位置、Live2D 绘制范围。 diff --git a/packages/webgal/src/Core/gameScripts/changeFigureDiff.ts b/packages/webgal/src/Core/gameScripts/changeFigureDiff.ts new file mode 100644 index 000000000..0195080cb --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/changeFigureDiff.ts @@ -0,0 +1,84 @@ +import { ISentence } from '@/Core/controller/scene/sceneInterface'; +import { figureStateKeyByPosition, FIGURE_POSITIONS } from '@/Core/Modules/stage/stageInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { createNonePerform, IPerform } from '@/Core/Modules/perform/performInterface'; +import { getFigurePositionFromArgs, getStringArgByKey } from '@/Core/util/getSentenceArg'; +import { logger } from '@/Core/util/logger'; +import { WebGAL } from '@/Core/WebGAL'; +import { DEFAULT_FIG_IN_DURATION, WEBGAL_NONE } from '@/Core/constants'; +import { generateUniversalSoftInAnimationObj } from '@/Core/controller/stage/pixi/animations/universalSoftIn'; +import { applyTransformToPixiContainer } from '@/Core/controller/stage/pixi/stageEffectTransform'; +import { stageResource } from '@/Core/controller/stage/pixi/assets/resourceTypes'; +import { changeFigure } from './changeFigure'; +import { FIGURE_ASSOCIATED_ARGS, setFigureAssociatedAnimation } from './figureAssociatedAnimation'; + +// 差分只换图片及与之配套的口型眨眼图;变换、动画、模型等参数属于 changeFigure。 +const diffArgs = new Set(['id', 'next', 'continue', 'when', ...FIGURE_POSITIONS, ...FIGURE_ASSOCIATED_ARGS]); + +// Live2D、Spine 用内置的表情与动作系统,不属于图片差分。 +const isModel = (url: string) => stageResource(url).kind !== 'texture'; + +/** + * 同一立绘的差分替换(如换表情),与换立绘的 changeFigure 区分: + * 只改图片地址,变换、层级、混合模式与入退场设置都保留; + * 口型眨眼图随表情整体替换为本句参数。 + * 上屏时优先混合;条件不满足时退回淡出淡入,同样不重置立绘状态。 + */ +export function changeFigureDiff(sentence: ISentence): IPerform { + const position = getFigurePositionFromArgs(sentence) || 'center'; + const id = getStringArgByKey(sentence, 'id'); + const key = id || `fig-${position}`; + const state = stageStateManager.getCalculationStageState(); + const freeFigure = id ? state.freeFigure.find((figure) => figure.key === id) : undefined; + const source = id ? freeFigure?.name : state[figureStateKeyByPosition[position]]; + const target = sentence.content; + if ([source, target].some((url) => url && url !== WEBGAL_NONE && isModel(url))) { + logger.warn(`changeFigureDiff 不适用于 Live2D 与 Spine,本句不执行:${target}`); + return createNonePerform(); + } + // 没有可替换的立绘或要移除立绘时,不存在差分,按普通换图的语义处理。 + if (!source || !target || target === WEBGAL_NONE) return changeFigure(sentence); + const ignoredArgs = sentence.args.filter((arg) => !diffArgs.has(arg.key)).map((arg) => arg.key); + if (ignoredArgs.length) logger.warn(`changeFigureDiff 只替换图片,已忽略参数:${ignoredArgs.join(', ')}`); + // 旧表情的口型眨眼图不再适用,换成本句参数;舞台侧会丢弃仍在进行的旧口型眨眼。 + setFigureAssociatedAnimation(sentence, key); + // 已是目标图片时无需替换,也不能对现有对象补淡入。 + if (source === target) return createNonePerform(); + + if (freeFigure) stageStateManager.setFreeFigureByKey({ ...freeFigure, name: target }); + else stageStateManager.setStage(figureStateKeyByPosition[position], target); + // 标记只决定上屏时采用混合还是淡出淡入,不进入存档。 + WebGAL.figureDiffManager.mark(key, { sourceUrl: source, targetUrl: target }); + + return createDiffPerform(key); +} + +/** 混合动画由提交时的舞台同步注册;未能混合时由本演出为新对象补淡入。 */ +function createDiffPerform(key: string): IPerform { + const softInKey = `${key}-softin`; + return { + // 并行式命名:登记时不会去重掉同目标已有的动画演出; + // 之后同目标的非并行 setTransform、setAnimation、changeFigure 会按前缀接管并移除本演出, + // 此时 updateEffect 已清除差分标记,上屏走回退,淡入随本演出一起取消。 + performName: `animation-${key}#diff`, + duration: DEFAULT_FIG_IN_DURATION, + isHoldOn: false, + startFunction: () => { + const stage = WebGAL.gameplay.pixiStage; + // 提交时已注册混合动画就直接沿用;否则为新建的对象补一个只改透明度的淡入。 + if (!stage || WebGAL.gameplay.skipAnimation || stage.hasAnimation(softInKey)) return; + const container = stage.getStageObjByKey(key)?.pixiContainer; + if (!container) return; + // 动画期间对象被锁定,不接收 effects,因此先落地保留下来的变换。 + const effect = stageStateManager.getViewStageState().effects.find((item) => item.target === key); + applyTransformToPixiContainer(container, effect?.transform); + container.alphaFilterVal = 0; + stage.registerAnimation(generateUniversalSoftInAnimationObj(key, DEFAULT_FIG_IN_DURATION), softInKey, key); + }, + stopFunction: () => { + WebGAL.gameplay.pixiStage?.removeAnimation(softInKey); + }, + blockingNext: () => false, + blockingAuto: () => true, + }; +} diff --git a/packages/webgal/src/Core/gameScripts/end.ts b/packages/webgal/src/Core/gameScripts/end.ts index b9d1ebf0d..eab20f9fb 100644 --- a/packages/webgal/src/Core/gameScripts/end.ts +++ b/packages/webgal/src/Core/gameScripts/end.ts @@ -20,7 +20,8 @@ export const end = (sentence: ISentence): IPerform => { const dispatch = webgalStore.dispatch; // 重新获取初始场景 const sceneUrl: string = assetSetter('start.txt', fileType.scene); - // 为了在 scriptExecutor 自增 sentenceId 后再重置场景 + // scriptExecutor 会在命令返回后自增 sentenceId,必须在此之后重置,避免重置后又变为 1。 + // 移除延时需要先让执行器显式处理结束流程,与 Pixi 是否同步无关。 setTimeout(() => { WebGAL.sceneManager.resetScene(); }, 5); diff --git a/packages/webgal/src/Core/gameScripts/figureAssociatedAnimation.ts b/packages/webgal/src/Core/gameScripts/figureAssociatedAnimation.ts new file mode 100644 index 000000000..e3a5f93c3 --- /dev/null +++ b/packages/webgal/src/Core/gameScripts/figureAssociatedAnimation.ts @@ -0,0 +1,36 @@ +import { ISentence } from '@/Core/controller/scene/sceneInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { assetSetter, fileType } from '@/Core/util/gameAssetsAccess/assetSetter'; +import { getStringArgByKey } from '@/Core/util/getSentenceArg'; + +/** 口型、眨眼差分参数,changeFigure 与 changeFigureDiff 共用 */ +export const FIGURE_ASSOCIATED_ARGS = [ + 'mouthOpen', + 'mouthClose', + 'mouthHalfOpen', + 'eyesOpen', + 'eyesClose', + 'animationFlag', +]; + +/** + * 口型、眨眼图是与当前立绘图片配套的整张图,换立绘或换差分后旧图都不再适用, + * 因此每次都按本句参数整体替换,未写的参数即为不启用。 + */ +export function setFigureAssociatedAnimation(sentence: ISentence, targetId: string) { + const figureArg = (key: string) => assetSetter(getStringArgByKey(sentence, key) ?? '', fileType.figure); + const current = stageStateManager.getCalculationStageState().figureAssociatedAnimation; + stageStateManager.setStage('figureAssociatedAnimation', [ + ...current.filter((item) => item.targetId !== targetId), + { + targetId, + animationFlag: getStringArgByKey(sentence, 'animationFlag') ?? '', + mouthAnimation: { + open: figureArg('mouthOpen'), + close: figureArg('mouthClose'), + halfOpen: figureArg('mouthHalfOpen'), + }, + blinkAnimation: { open: figureArg('eyesOpen'), close: figureArg('eyesClose') }, + }, + ]); +} diff --git a/packages/webgal/src/Core/gameScripts/intro.tsx b/packages/webgal/src/Core/gameScripts/intro.tsx index 99a7a37a5..f131d5bce 100644 --- a/packages/webgal/src/Core/gameScripts/intro.tsx +++ b/packages/webgal/src/Core/gameScripts/intro.tsx @@ -82,6 +82,7 @@ export const intro = (sentence: ISentence): IPerform => { // 由于用户操作,相当于时间向前推进,这时候更新这个演出的预计完成时间 baseDuration -= delayTime; if (setBlockingStateTimeout) clearTimeout(setBlockingStateTimeout); + // 按用户推进后的剩余展示时长解除阻塞,不能立即跳过尚未展示的内容。 setBlockingStateTimeout = setTimeout(() => { isBlocking = false; }, baseDuration); @@ -132,6 +133,7 @@ export const intro = (sentence: ISentence): IPerform => { if (timeout) clearTimeout(timeout); // 如果 Hold 了,自然不要自动结束 if (!isHold) { + // 用户提前推进后,按缩短的剩余时长结束非 hold 演出。 timeout = setTimeout(() => { WebGAL.gameplay.performController.unmountPerform(performName); }, baseDuration); @@ -167,6 +169,7 @@ export const intro = (sentence: ISentence): IPerform => { isHoldOn: false, startFunction: () => { isBlocking = true; + // 等文字按 animationDelay 展示完并经过结尾等待后,才允许继续推进。 setBlockingStateTimeout = setTimeout(() => { isBlocking = false; }, baseDuration); diff --git a/packages/webgal/src/Core/gameScripts/playVideo.tsx b/packages/webgal/src/Core/gameScripts/playVideo.tsx index bb93f1d0a..083521c4f 100644 --- a/packages/webgal/src/Core/gameScripts/playVideo.tsx +++ b/packages/webgal/src/Core/gameScripts/playVideo.tsx @@ -21,6 +21,7 @@ export const playVideo = (sentence: ISentence): IPerform => { let isOver = false; let skipVideo = () => {}; const restoreVolumeAndUnmount = () => { + isOver = true; WebGAL.events.fullscreenDbClick.off(skipVideo); /** * 恢复音量 @@ -55,35 +56,34 @@ export const playVideo = (sentence: ISentence): IPerform => {