diff --git a/packages/webgal/src/Core/Modules/stage/stageInterface.ts b/packages/webgal/src/Core/Modules/stage/stageInterface.ts index a19b5e15e..c5d90acbe 100644 --- a/packages/webgal/src/Core/Modules/stage/stageInterface.ts +++ b/packages/webgal/src/Core/Modules/stage/stageInterface.ts @@ -144,6 +144,15 @@ export interface IFreeFigure { key: string; } +/** + * Live2D 自定义绘制范围的归一:未指定与全 0 是同一件事。 + * + * 绘制范围参与立绘身份判定,演算与提交两侧必须用同一个口径,否则会把没变的立绘判成换了一张。 + */ +export function normalizeFigureBounds(bounds?: [number, number, number, number]): [number, number, number, number] { + return bounds ?? [0, 0, 0, 0]; +} + export interface IFigureAssociatedAnimation { mouthAnimation: IMouthAnimationFile; blinkAnimation: IEyesAnimationFile; diff --git a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts index 8ce8b35a6..4284288b4 100644 --- a/packages/webgal/src/Core/Modules/stage/stageStateManager.ts +++ b/packages/webgal/src/Core/Modules/stage/stageStateManager.ts @@ -291,7 +291,10 @@ export class StageStateManager { } else { this.calculationStageState.live2dMotion[index].motion = motion; this.calculationStageState.live2dMotion[index].skin = skin; - this.calculationStageState.live2dMotion[index].overrideBounds = overrideBounds; + // 绘制范围参与立绘身份判定,没指定就沿用旧值,否则只改动作也会被当成换了一张立绘 + if (overrideBounds !== undefined) { + this.calculationStageState.live2dMotion[index].overrideBounds = overrideBounds; + } } } diff --git a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts index c7bd11f75..c07b833ab 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/PixiController.ts @@ -47,6 +47,8 @@ export interface IStageObject { sourceExt: string; sourceType: 'img' | 'live2d' | 'spine' | 'gif' | 'video' | 'stage'; spineAnimation?: string; + /** 创建这个立绘时用的身份,见 syncPixiStageState 的 getFigureIdentity */ + figureIdentity?: string; isExiting?: boolean; } diff --git a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts index d93f1e196..de5d59d3c 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/syncPixiStageState.ts @@ -1,5 +1,10 @@ import type { IEffect, IFigurePosition, IStageState, ITransform } from '@/Core/Modules/stage/stageInterface'; -import { FIGURE_KEYS, FIGURE_POSITIONS, figureStateKeyByPosition } from '@/Core/Modules/stage/stageInterface'; +import { + FIGURE_KEYS, + FIGURE_POSITIONS, + figureStateKeyByPosition, + normalizeFigureBounds, +} from '@/Core/Modules/stage/stageInterface'; import type { IResolvedStageCommitOptions } from '@/Core/Modules/stage/stageStateManager'; import { DEFAULT_BG_IN_DURATION, DEFAULT_BG_OUT_DURATION, DEFAULT_FIG_IN_DURATION } from '@/Core/constants'; import { WebGAL } from '@/Core/WebGAL'; @@ -13,9 +18,22 @@ interface ISyncFigureSlotPayload { key: string; sourceUrl: string; position: IFigurePosition; + /** Live2D 自定义绘制范围,与位置一样只在创建时落地 */ + bounds?: [number, number, number, number]; skipAnimation: boolean; } +/** + * 立绘对象的身份:图片地址、基准位置、Live2D 绘制范围。 + * + * 这三者都只在创建舞台对象时落地(基准位置写进 setBaseX,绘制范围写进模型的 pivot 与遮罩), + * 事后没有就地修改的通路。所以身份一变就必须关掉旧立绘再开新的,否则改动会被静默吞掉。 + * 其余参数(zIndex、blendMode、动作、表情、皮肤、眨眼、注视)都有各自的更新通路,不属于身份。 + */ +function getFigureIdentity({ sourceUrl, position, bounds }: ISyncFigureSlotPayload): string { + return JSON.stringify([sourceUrl, position, normalizeFigureBounds(bounds)]); +} + /** * 取入场过渡时长。 * @@ -94,17 +112,27 @@ function syncBg(stageState: IStageState, skipAnimation: boolean) { } function syncFigures(stageState: IStageState, skipAnimation: boolean) { + const getBounds = (key: string) => stageState.live2dMotion.find((motion) => motion.target === key)?.overrideBounds; + for (const position of FIGURE_POSITIONS) { + const key = `fig-${position}`; syncFigureSlot({ - key: `fig-${position}`, + key, sourceUrl: stageState[figureStateKeyByPosition[position]], position, + bounds: getBounds(key), skipAnimation, }); } for (const fig of stageState.freeFigure) { - syncFigureSlot({ key: fig.key, sourceUrl: fig.name, position: fig.basePosition, skipAnimation }); + syncFigureSlot({ + key: fig.key, + sourceUrl: fig.name, + position: fig.basePosition, + bounds: getBounds(fig.key), + skipAnimation, + }); } const currentFigures = WebGAL.gameplay.pixiStage?.getFigureObjects(); @@ -120,7 +148,8 @@ function syncFigures(stageState: IStageState, skipAnimation: boolean) { } } -function syncFigureSlot({ key, sourceUrl, position, skipAnimation }: ISyncFigureSlotPayload) { +function syncFigureSlot(payload: ISyncFigureSlotPayload) { + const { key, sourceUrl, position, skipAnimation } = payload; const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; const softInAniKey = `${key}-softin`; @@ -128,12 +157,18 @@ function syncFigureSlot({ key, sourceUrl, position, skipAnimation }: ISyncFigure // 旧存档中可能没有新增位置的字段,这里同时容错 undefined if (sourceUrl) { - if (currentFigure?.sourceUrl === sourceUrl) return; + const identity = getFigureIdentity(payload); + if (currentFigure?.figureIdentity === identity) return; if (currentFigure) { removeFig(currentFigure, softInAniKey, skipAnimation); } // 入场动画由 changeFigure 作为演出产出,这里只负责创建舞台对象 addFigure(key, sourceUrl, position); + // 舞台对象是同步入表的,这里记下它是按哪份身份创建的,供下次同步比对 + const newFigure = pixiStage.getStageObjByKey(key); + if (newFigure) { + newFigure.figureIdentity = identity; + } logger.debug(`${key} 立绘已重设`); return; } @@ -204,6 +239,8 @@ function removeBg(bgObject: IStageObject, skipAnimation: boolean): number { function removeFig(figObj: IStageObject, enterTikerKey: string, skipAnimation: boolean) { const pixiStage = WebGAL.gameplay.pixiStage; if (!pixiStage) return; + // 只有真正决定让它退场时才打标记,标记与下面的改名同属一步,不会留给复用中的立绘 + figObj.isExiting = true; pixiStage.removeAnimation(enterTikerKey); if (skipAnimation || WebGAL.gameplay.skipAnimation) { logger.debug('快速模式,立刻关闭立绘'); diff --git a/packages/webgal/src/Core/gameScripts/changeFigure.ts b/packages/webgal/src/Core/gameScripts/changeFigure.ts index cc33fca29..78486e49e 100644 --- a/packages/webgal/src/Core/gameScripts/changeFigure.ts +++ b/packages/webgal/src/Core/gameScripts/changeFigure.ts @@ -1,13 +1,14 @@ import { ISentence } from '@/Core/controller/scene/sceneInterface'; import { IPerform } from '@/Core/Modules/perform/performInterface'; import cloneDeep from 'lodash/cloneDeep'; +import isEqual from 'lodash/isEqual'; import { getBooleanArgByKey, getFigurePositionFromArgs, getNumberArgByKey, getStringArgByKey, } from '@/Core/util/getSentenceArg'; -import { figureStateKeyByPosition, IFreeFigure } from '@/Core/Modules/stage/stageInterface'; +import { figureStateKeyByPosition, IFreeFigure, normalizeFigureBounds } from '@/Core/Modules/stage/stageInterface'; 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'; @@ -109,31 +110,35 @@ export function changeFigure(sentence: ISentence): IPerform { stageStateManager.setStage('figureAssociatedAnimation', filteredFigureAssociatedAnimation); /** - * 如果 url 没变,不移除 + * 立绘的身份:图片地址、基准位置、Live2D 绘制范围。 + * + * 这三者只在创建舞台对象时落地,事后无法就地修改,所以身份一变就是「关掉旧立绘、开一个新的」, + * 判定口径与 syncFigureSlot 保持一致。位置立绘的位置已经编码在 key 里,无需再比。 + * 未写 -bounds 的语句沿用旧绘制范围,不算身份变化。 */ - let isUrlChanged = true; + const currentState = stageStateManager.getCalculationStageState(); + const currentBounds = currentState.live2dMotion.find((e) => e.target === id)?.overrideBounds; + const isBoundsChanged = + !!boundsFromArgs && !isEqual(normalizeFigureBounds(bounds), normalizeFigureBounds(currentBounds)); + let isIdentityChanged = true; if (key !== '') { - const figWithKey = stageStateManager.getCalculationStageState().freeFigure.find((e) => e.key === key); - if (figWithKey) { - if (figWithKey.name === sentence.content) { - isUrlChanged = false; - } + const figWithKey = currentState.freeFigure.find((e) => e.key === key); + if (figWithKey && figWithKey.name === sentence.content && figWithKey.basePosition === pos && !isBoundsChanged) { + isIdentityChanged = false; } - } else if (stageStateManager.getCalculationStageState()[figureStateKeyByPosition[pos]] === sentence.content) { - isUrlChanged = false; + } else if (currentState[figureStateKeyByPosition[pos]] === sentence.content && !isBoundsChanged) { + isIdentityChanged = false; } /** * 处理 Effects + * + * 旧立绘的退场由提交阶段(syncFigureSlot)负责,这里只清演算状态,不碰舞台对象。 */ - if (isUrlChanged) { + if (isIdentityChanged) { // 必须先卸载旧的动画演出:它的 stopFunction 会写回终态,晚于清空 effects 会把旧变换复活 WebGAL.gameplay.performController.unmountPerform(`animation-${id}`, true); stageStateManager.removeEffectByTargetId(id); stageStateManager.removeAnimationSettingsByTarget(id); - const oldStageObject = WebGAL.gameplay.pixiStage?.getStageObjByKey(id); - if (oldStageObject) { - oldStageObject.isExiting = true; - } } const setAnimationNames = (key: string, sentence: ISentence) => { // 如果立绘被关闭了,那么就不用设置了 @@ -193,10 +198,10 @@ export function changeFigure(sentence: ISentence): IPerform { }; function postFigureStateSet() { - if (isUrlChanged) { - // 当 url 发生变化时,即发生新立绘替换 + if (isIdentityChanged) { + // 当身份发生变化时,即发生新立绘替换 // 应当赋予一些参数以默认值,防止从旧立绘的状态获取数据 - bounds = bounds ?? [0, 0, 0, 0]; + bounds = normalizeFigureBounds(bounds); blink = blink ?? cloneDeep(baseBlinkParam); focus = focus ?? cloneDeep(baseFocusParam); zIndex = Math.max(zIndex, 0); @@ -208,7 +213,7 @@ export function changeFigure(sentence: ISentence): IPerform { stageStateManager.setFigureMetaData([key, 'zIndex', zIndex, false]); stageStateManager.setFigureMetaData([key, 'blendMode', blendMode, false]); } else { - // 当 url 没有发生变化时,即没有新立绘替换 + // 当身份没有发生变化时,即没有新立绘替换 // 应当保留旧立绘的状态,仅在需要时更新 if (motion || skin || bounds) { stageStateManager.setLive2dMotion({ target: key, motion, skin, overrideBounds: bounds }); @@ -255,7 +260,7 @@ export function changeFigure(sentence: ISentence): IPerform { * 终态在演算期写入 effects,演出只负责视觉过渡,因此不需要任何延迟结算。 * 与 setTransform 共用 `animation-${key}` 演出名,同目标的动画冲突由演出去重统一裁决。 */ - const isEntering = isUrlChanged && content !== ''; + const isEntering = isIdentityChanged && content !== ''; const enterAnimationSetting = isEntering ? stageStateManager.getCalculationStageState().animationSettings.find((setting) => setting.target === key) : undefined;