Skip to content
Merged

4.6.4 #1026

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/webgal/src/Core/Modules/stage/stageInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/webgal/src/Core/Modules/stage/stageStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface IStageObject {
sourceExt: string;
sourceType: 'img' | 'live2d' | 'spine' | 'gif' | 'video' | 'stage';
spineAnimation?: string;
/** 创建这个立绘时用的身份,见 syncPixiStageState 的 getFigureIdentity */
figureIdentity?: string;
isExiting?: boolean;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)]);
}

/**
* 取入场过渡时长。
*
Expand Down Expand Up @@ -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();
Expand All @@ -120,20 +148,27 @@ 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`;
const currentFigure = pixiStage.getStageObjByKey(key);

// 旧存档中可能没有新增位置的字段,这里同时容错 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;
}
Expand Down Expand Up @@ -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('快速模式,立刻关闭立绘');
Expand Down
45 changes: 25 additions & 20 deletions packages/webgal/src/Core/gameScripts/changeFigure.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve state when only moving a free figure

When an existing free figure is issued again with the same URL and id but a different preset position, this comparison marks it as a new identity. The postFigureStateSet identity-change branch then replaces omitted Live2D motion, skin, expression, bounds, blink/focus, z-index, and blend mode with empty/default values. Consequently, a position-only move unexpectedly discards all previously configured figure state; distinguish the need to recreate the Pixi object from an actual resource replacement and carry the existing state into the recreated object.

Useful? React with 👍 / 👎.

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) => {
// 如果立绘被关闭了,那么就不用设置了
Expand Down Expand Up @@ -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);
Expand All @@ -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 });
Expand Down Expand Up @@ -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;
Expand Down
Loading