diff --git a/dev-docs/Animation v2.md b/dev-docs/Animation v2.md new file mode 100644 index 000000000..115151642 --- /dev/null +++ b/dev-docs/Animation v2.md @@ -0,0 +1,71 @@ +目前的动画实现不支持两项关键功能:相对动画和继承关键帧。 + +因此,将开发 Animation v2 接口,这一接口将拓展现有的动画体系。 + +v2 动画的 JSON 是一个对象: + +```json +{ + "version": 2, + "relative": true, + "inherit": true, + "keyframes": [ + { "time": 250, "position": { "x": -100 }, "ease": "easeOut" }, + { "time": 750, "position": { "x": 100 }, "ease": "easeInOut" }, + { "time": 1000, "position": { "x": 0 }, "ease": "easeIn" } + ] +} +``` + +该动画使舞台对象围绕当前位置左右摇动一次,最终回到原位。 + +关键帧的 `time`(单位为毫秒)是该关键帧在动画中所处的时间,从动画开始计算,与 AE 中关键帧所在的时间相同。关键帧按 `time` 先后排列,与其在数组中的位置无关;动画的总时长为最大的 `time`。`time` 缺省或非法(不是数字或小于 0)时按 0 处理。`ease` 为同一属性从上一关键帧到达本帧这一区间所使用的缓动,缺省为 `easeInOut`。`time` 相同的多个关键帧处于同一时刻,构成跳变:数组中靠前的关键帧为插值终点,靠后的关键帧为跳变后的状态。 + +除了关键帧以外,还将加入用于描述动画属性的字段,目前包括: + +1. 动画版本 `version`,对于 Animation v2,这个值是 2。 +2. 是否为相对动画 `relative`,默认为 true。为 true 则启用相对动画,帧中的值基于舞台对象的**当前基准状态**变换;为 false 则帧中的值为绝对值。**当前基准状态**遵循 `transformFrom` 及相关参数的设置,一般为 current(当前的变换),如果为 default 则为 `baseTransform`。各属性默认的计算方式如下(取值含义见第 4 项): + - `add`:`position`、`rotation`、`blur`、`bevel`、`bevelThickness`、`bevelRotation`、`bevelSoftness`、`bloom`、`bloomBlur`。 + - `multiplyWithZeroFallback`:`scale`。 + - `absolute`:其余属性,如 `alpha`、`brightness`、颜色、滤镜开关等。 +3. 是否启用帧继承 `inherit`,默认为 true。启用时,各属性分别处理,采用类似于 AE 的方式: + 1. 在属性最后一个关键帧后:继承最近关键帧的值。 + 2. 在两个关键帧之间:插值。 + 3. 在属性第一个关键帧前:从**当前基准状态**过渡到第一个关键帧。 + 4. 动画中未涉及的属性:保持不变。 + + 禁用时,关键帧中未指定的属性取当前基准状态;在第一个关键帧前,同样从当前基准状态过渡到第一个关键帧。 +4. 覆盖相对动画计算方式 `relativeCalc`,是一个对象,键名为属性名,值为计算方式: + - `add`:基准值加帧中的值。 + - `multiply`:基准值乘帧中的值。 + - `multiplyWithZeroFallback`:同 `multiply`,但基准值为 0 时按 1 处理。 + - `absolute`:直接使用帧中的值。 + + 例如 `"relativeCalc": { "alpha": "multiplyWithZeroFallback" }` 使 `alpha` 按当前透明度的倍数变化。`position`、`scale` 等嵌套属性的设置同时作用于两轴。未指定或值非法的属性按默认计算方式处理。`relative` 为 false 时,此字段不生效。 + +以上字段缺省或为非法值时,一律按默认值处理。 + +相对动画重复执行会累积,例如右移 100 执行两次即右移 200。多个并行动画作用于同一属性时,效果叠加。 + +对于旧的动画,即 JSON 文件直接是关键帧数组的,将其称为旧接口,仍然遵循 v1 的行为实现。在编辑器中,选择动画时默认隐藏 v1 动画,引擎可读取,但仅用于兼容旧脚本。 + +实现方案:不分别实现 v1 和 v2。动画在 `AnimationManager.addAnimation` 中统一转换为 v2 格式后写入内核。加载动画文件、`setTempAnimation`、`setTransform`、`changeFigure`、`changeBg` 注册的动画均以此方式处理。 + +- 顶层是对象:按 `version` 处理,缺省为 2。`version` 为 1 时,关键帧与 v1 一样使用 `duration`。 +- 顶层是数组:v1 动画,以该数组作为 `keyframes`,并设置 `version: 1`。 + +v1 关键帧的 `duration`(单位为毫秒)是从上一关键帧到本关键帧的时长,第一个关键帧从动画开始计算。转换时依次累加,得到每个关键帧的 `time`。 + +需要与 v1 保持一致的行为,均由 `version: 1` 决定,转换后的行为应与 v1 等价: + +- `relative`、`inherit` 固定为 false,`relativeCalc` 不生效。 +- 禁用帧继承时,在第一个关键帧前保持第一个关键帧的值。 +- 保留 v1 的 writeFullEffect 行为:`transformFrom=default` 且非并行时,动画中未涉及的属性重置为 `baseTransform`。 + +对于 `setTempAnimation`,在此处一般不便按照完整的 Animation v2 接口编写动画。为了在使用类似于 v1 的写法时也能使用相对动画、帧继承等特性,允许传入 `relative`、`inherit` 参数: + +- 传入关键帧数组且未传入这两个参数:按 v1 处理,用于兼容旧脚本。 +- 传入关键帧数组且传入了其中任一参数:以该数组作为 `keyframes` 按 v2 处理,传入的参数覆盖默认值。数组中的关键帧沿用 v1 的写法,使用 `duration`。 +- 传入 v2 对象:传入的参数覆盖对象中对应字段的值。 + +所有内置动画将重写为 v2,以当前状态为基准,例如 `shake` 围绕当前位置摇动,`exit` 从当前透明度淡出。 diff --git a/package.json b/package.json index b09966689..f73c2076d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@webgal/base", - "version": "4.6.5", + "version": "4.6.6", "description": "A brand new web Visual Novel engine.", "repository": "https://github.com/OpenWebGAL/WebGAL.git", "author": "Mahiru ", diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 67a5ef52b..f646a8e37 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -1,6 +1,6 @@ { "name": "webgal-engine", - "version": "4.6.5", + "version": "4.6.6", "scripts": { "dev": "vite --host --port 3000", "build": "node scripts/update-engine-version.js && cross-env NODE_ENV=production tsc && vite build --base=./", @@ -24,7 +24,7 @@ "mitt": "^3.0.0", "modern-css-reset": "^1.4.0", "pixi-filters": "^4.2.0", - "pixi-live2d-display-webgal": "^0.5.12", + "pixi-live2d-display-webgal": "^0.5.19", "pixi.js": "^6.3.0", "popmotion": "^11.0.5", "react": "^17.0.2", diff --git a/packages/webgal/public/game/animation/animationTable.json b/packages/webgal/public/game/animation/animationTable.json index f4ed953b6..7f6a78bad 100644 --- a/packages/webgal/public/game/animation/animationTable.json +++ b/packages/webgal/public/game/animation/animationTable.json @@ -15,5 +15,22 @@ "godrayFilm", "removeFilm", "shockwaveIn", - "shockwaveOut" + "shockwaveOut", + "enter-from-left-v2", + "enter-from-bottom-v2", + "enter-from-right-v2", + "shake-v2", + "move-front-and-back-v2", + "enter-v2", + "exit-v2", + "blur-v2", + "oldFilm-v2", + "dotFilm-v2", + "reflectionFilm-v2", + "glitchFilm-v2", + "rgbFilm-v2", + "godrayFilm-v2", + "removeFilm-v2", + "shockwaveIn-v2", + "shockwaveOut-v2" ] diff --git a/packages/webgal/public/game/animation/blur-v2.json b/packages/webgal/public/game/animation/blur-v2.json new file mode 100644 index 000000000..e74972c13 --- /dev/null +++ b/packages/webgal/public/game/animation/blur-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 300, + "blur": 5 + } + ] +} diff --git a/packages/webgal/public/game/animation/dotFilm-v2.json b/packages/webgal/public/game/animation/dotFilm-v2.json new file mode 100644 index 000000000..bb308d1e3 --- /dev/null +++ b/packages/webgal/public/game/animation/dotFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "dotFilm": 1 + } + ] +} diff --git a/packages/webgal/public/game/animation/enter-from-bottom-v2.json b/packages/webgal/public/game/animation/enter-from-bottom-v2.json new file mode 100644 index 000000000..b06849e39 --- /dev/null +++ b/packages/webgal/public/game/animation/enter-from-bottom-v2.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "relativeCalc": { + "alpha": "multiplyWithZeroFallback" + }, + "keyframes": [ + { + "time": 0, + "alpha": 0, + "position": { + "y": 50 + }, + "blur": 5 + }, + { + "time": 500, + "alpha": 1, + "position": { + "y": 0 + }, + "blur": 0 + } + ] +} diff --git a/packages/webgal/public/game/animation/enter-from-left-v2.json b/packages/webgal/public/game/animation/enter-from-left-v2.json new file mode 100644 index 000000000..2b9b979da --- /dev/null +++ b/packages/webgal/public/game/animation/enter-from-left-v2.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "relativeCalc": { + "alpha": "multiplyWithZeroFallback" + }, + "keyframes": [ + { + "time": 0, + "alpha": 0, + "position": { + "x": -50 + }, + "blur": 5 + }, + { + "time": 500, + "alpha": 1, + "position": { + "x": 0 + }, + "blur": 0 + } + ] +} diff --git a/packages/webgal/public/game/animation/enter-from-right-v2.json b/packages/webgal/public/game/animation/enter-from-right-v2.json new file mode 100644 index 000000000..07ebb05e1 --- /dev/null +++ b/packages/webgal/public/game/animation/enter-from-right-v2.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "relativeCalc": { + "alpha": "multiplyWithZeroFallback" + }, + "keyframes": [ + { + "time": 0, + "alpha": 0, + "position": { + "x": 50 + }, + "blur": 5 + }, + { + "time": 500, + "alpha": 1, + "position": { + "x": 0 + }, + "blur": 0 + } + ] +} diff --git a/packages/webgal/public/game/animation/enter-v2.json b/packages/webgal/public/game/animation/enter-v2.json new file mode 100644 index 000000000..1b5473a53 --- /dev/null +++ b/packages/webgal/public/game/animation/enter-v2.json @@ -0,0 +1,16 @@ +{ + "version": 2, + "relativeCalc": { + "alpha": "multiplyWithZeroFallback" + }, + "keyframes": [ + { + "time": 0, + "alpha": 0 + }, + { + "time": 300, + "alpha": 1 + } + ] +} diff --git a/packages/webgal/public/game/animation/exit-v2.json b/packages/webgal/public/game/animation/exit-v2.json new file mode 100644 index 000000000..03a743168 --- /dev/null +++ b/packages/webgal/public/game/animation/exit-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 300, + "alpha": 0 + } + ] +} diff --git a/packages/webgal/public/game/animation/glitchFilm-v2.json b/packages/webgal/public/game/animation/glitchFilm-v2.json new file mode 100644 index 000000000..7efc3cb15 --- /dev/null +++ b/packages/webgal/public/game/animation/glitchFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "glitchFilm": 1 + } + ] +} diff --git a/packages/webgal/public/game/animation/godrayFilm-v2.json b/packages/webgal/public/game/animation/godrayFilm-v2.json new file mode 100644 index 000000000..e71e67304 --- /dev/null +++ b/packages/webgal/public/game/animation/godrayFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "godrayFilm": 1 + } + ] +} diff --git a/packages/webgal/public/game/animation/move-front-and-back-v2.json b/packages/webgal/public/game/animation/move-front-and-back-v2.json new file mode 100644 index 000000000..95ce4d32d --- /dev/null +++ b/packages/webgal/public/game/animation/move-front-and-back-v2.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 500, + "scale": { + "x": 1.15, + "y": 1.15 + } + }, + { + "time": 1000, + "scale": { + "x": 1, + "y": 1 + } + } + ] +} diff --git a/packages/webgal/public/game/animation/oldFilm-v2.json b/packages/webgal/public/game/animation/oldFilm-v2.json new file mode 100644 index 000000000..2662d645f --- /dev/null +++ b/packages/webgal/public/game/animation/oldFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "oldFilm": 1 + } + ] +} diff --git a/packages/webgal/public/game/animation/reflectionFilm-v2.json b/packages/webgal/public/game/animation/reflectionFilm-v2.json new file mode 100644 index 000000000..eaed81193 --- /dev/null +++ b/packages/webgal/public/game/animation/reflectionFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "reflectionFilm": 1 + } + ] +} diff --git a/packages/webgal/public/game/animation/removeFilm-v2.json b/packages/webgal/public/game/animation/removeFilm-v2.json new file mode 100644 index 000000000..0ca8de6ba --- /dev/null +++ b/packages/webgal/public/game/animation/removeFilm-v2.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "oldFilm": 0, + "dotFilm": 0, + "reflectionFilm": 0, + "glitchFilm": 0, + "rgbFilm": 0, + "godrayFilm": 0 + } + ] +} diff --git a/packages/webgal/public/game/animation/rgbFilm-v2.json b/packages/webgal/public/game/animation/rgbFilm-v2.json new file mode 100644 index 000000000..1a5e35d06 --- /dev/null +++ b/packages/webgal/public/game/animation/rgbFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "rgbFilm": 1 + } + ] +} diff --git a/packages/webgal/public/game/animation/shake-v2.json b/packages/webgal/public/game/animation/shake-v2.json new file mode 100644 index 000000000..810442d4c --- /dev/null +++ b/packages/webgal/public/game/animation/shake-v2.json @@ -0,0 +1,26 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 250, + "position": { + "x": -100 + }, + "ease": "easeOut" + }, + { + "time": 750, + "position": { + "x": 100 + }, + "ease": "easeInOut" + }, + { + "time": 1000, + "position": { + "x": 0 + }, + "ease": "easeIn" + } + ] +} diff --git a/packages/webgal/public/game/animation/shockwaveIn-v2.json b/packages/webgal/public/game/animation/shockwaveIn-v2.json new file mode 100644 index 000000000..798c9b178 --- /dev/null +++ b/packages/webgal/public/game/animation/shockwaveIn-v2.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "shockwaveFilter": 0, + "radiusAlphaFilter": 0 + }, + { + "time": 2000, + "shockwaveFilter": 3.05, + "radiusAlphaFilter": 1.05 + } + ] +} diff --git a/packages/webgal/public/game/animation/shockwaveOut-v2.json b/packages/webgal/public/game/animation/shockwaveOut-v2.json new file mode 100644 index 000000000..02176a17c --- /dev/null +++ b/packages/webgal/public/game/animation/shockwaveOut-v2.json @@ -0,0 +1,13 @@ +{ + "version": 2, + "keyframes": [ + { + "time": 0, + "shockwaveFilter": 0 + }, + { + "time": 2000, + "shockwaveFilter": 3 + } + ] +} diff --git a/packages/webgal/public/game/template/template.json b/packages/webgal/public/game/template/template.json index 3db0e2913..f5ef7b268 100644 --- a/packages/webgal/public/game/template/template.json +++ b/packages/webgal/public/game/template/template.json @@ -1,4 +1,4 @@ { "name":"WebGAL Refine 2026", - "webgal-version":"4.6.5" + "webgal-version":"4.6.6" } diff --git a/packages/webgal/public/webgal-engine.json b/packages/webgal/public/webgal-engine.json index d2965879b..d80836d07 100644 --- a/packages/webgal/public/webgal-engine.json +++ b/packages/webgal/public/webgal-engine.json @@ -2,9 +2,9 @@ "schemaVersion": "1.0.0", "id": "open-webgal.webgal", "name": "WebGAL", - "version": "4.6.5", + "version": "4.6.6", "type": "official", - "webgalVersion": "4.6.5", + "webgalVersion": "4.6.6", "description": "界面美观、功能强大、易于开发的全新网页端视觉小说引擎", "descriptions": { "en": "A brand new web Visual Novel engine with a beautiful interface, powerful features, and easy development", diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index e46dd5b9e..7ac002f46 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -1,16 +1,16 @@ import { logger } from '@/Core/util/logger'; import { generateUniversalSoftOffAnimationObj } from '@/Core/controller/stage/pixi/animations/universalSoftOff'; -import cloneDeep from 'lodash/cloneDeep'; -import { baseTransform } from '@/Core/Modules/stage/stageInterface'; -import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; +import { baseTransform, ITransform } from '@/Core/Modules/stage/stageInterface'; +import { generateTimelineObj, readContainerTransform } from '@/Core/controller/stage/pixi/animations/timeline'; import { WebGAL } from '@/Core/WebGAL'; -import PixiStage, { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; -import { IUserAnimation } from './animations'; -import { pickBy } from 'lodash'; +import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { DEFAULT_BG_OUT_DURATION, DEFAULT_FIG_OUT_DURATION } from '../constants'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; -import { AnimationFrame } from '@/Core/Modules/animations'; +import { buildAnimationTracks, getTracksEndState, IAnimationTrack } from '@/Core/Modules/animationTracks'; +/** + * 生成动画对象。目前只用于退出动画:退场的舞台对象在演算状态中已没有变换记录,因此从容器读取它的当前状态 + */ // eslint-disable-next-line max-params export function getAnimationObject( animationName: string, @@ -19,80 +19,79 @@ export function getAnimationObject( writeDefault: boolean, writeFullEffect = true, ) { - const mappedEffects = getAnimationTimeline(animationName, target, writeDefault, writeFullEffect); - if (mappedEffects) { - return generateTimelineObj(mappedEffects, target, duration); + const container = WebGAL.gameplay.pixiStage?.getStageObjByKey(target)?.pixiContainer; + const currentTransform = container ? readContainerTransform(container) : baseTransform; + const tracks = getAnimationTracks(animationName, currentTransform, writeDefault, writeFullEffect); + if (tracks) { + return generateTimelineObj(tracks, target, duration); } return null; } +/** + * 在演算期把动画终态写入 effects,并返回动画轨道供演出播放 + */ export function applyAnimationEndState( animationName: string, target: string, writeDefault: boolean, writeFullEffect = true, ) { - const mappedEffects = getAnimationTimeline(animationName, target, writeDefault, writeFullEffect); - if (!mappedEffects || mappedEffects.length === 0) return null; - const { duration, ease, ...endState } = mappedEffects[mappedEffects.length - 1]; - stageStateManager.updateEffect({ target, transform: endState }); - return mappedEffects; + const tracks = getAnimationTimeline(animationName, target, writeDefault, writeFullEffect); + if (!tracks || tracks.length === 0) return null; + stageStateManager.updateEffect({ target, transform: getTracksEndState(tracks) }); + return tracks; } +/** + * 以演算状态中目标的当前变换为起点,生成动画轨道 + */ export function getAnimationTimeline( animationName: string, target: string, writeDefault: boolean, writeFullEffect = true, -): AnimationFrame[] | null { - const effect = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); - if (effect) { - const unionKeys = new Set(); - const unionScaleKeys = new Set(); - const unionPositionKeys = new Set(); - if (!writeFullEffect) { - effect.effects.forEach((effect) => { - Object.keys(effect).forEach((k) => unionKeys.add(k)); - if (effect.scale) Object.keys(effect.scale).forEach((k) => unionScaleKeys.add(k)); - if (effect.position) Object.keys(effect.position).forEach((k) => unionPositionKeys.add(k)); - }); - } - const mappedEffects = effect.effects.map((effect) => { - const targetSetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target); - const sourceTransform = - !writeDefault && targetSetEffect && targetSetEffect.transform ? targetSetEffect.transform : baseTransform; - let newEffect; +): IAnimationTrack[] | null { + const targetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target); + const currentTransform = targetEffect?.transform ?? baseTransform; + return getAnimationTracks(animationName, currentTransform, writeDefault, writeFullEffect); +} - if (writeFullEffect) { - newEffect = cloneDeep({ ...sourceTransform, duration: 0, ease: '' }); - } else { - const targetScale = pickBy(sourceTransform.scale || {}, (source, key) => unionScaleKeys.has(key)); - const targetPosition = pickBy(sourceTransform.position || {}, (s, key) => unionPositionKeys.has(key)); - const originalTransform = { ...pickBy(sourceTransform, (source, key) => unionKeys.has(key)) }; - if (unionScaleKeys.size > 0) originalTransform.scale = targetScale; - if (unionPositionKeys.size > 0) originalTransform.position = targetPosition; - newEffect = cloneDeep({ ...originalTransform, duration: 0, ease: '' }); - } +/** + * @param currentTransform 动画开始前目标的变换 + * @param writeDefault 为 true 时以 baseTransform 为当前基准状态(transformFrom=default) + * @param writeFullEffect v1 动画中未涉及的属性是否也写入基准状态 + */ +// eslint-disable-next-line max-params +function getAnimationTracks( + animationName: string, + currentTransform: ITransform, + writeDefault: boolean, + writeFullEffect: boolean, +): IAnimationTrack[] | null { + const userAnimation = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); + if (!userAnimation) return null; + const tracks = buildAnimationTracks(userAnimation.animation, { + base: writeDefault ? baseTransform : currentTransform, + writeFullEffect, + }); + logger.debug('装载自定义动画', tracks); + return tracks; +} - PixiStage.assignTransform(newEffect, effect, false); - newEffect.duration = effect.duration; - newEffect.ease = effect.ease; - return newEffect; - }); - logger.debug('装载自定义动画', mappedEffects); - return mappedEffects; - } - return null; +/** + * 是否为相对动画。相对动画的结果依赖执行时的舞台状态,重复执行会叠加 + */ +export function isRelativeAnimation(animationName: string) { + return WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName)?.animation.relative ?? false; } export function getAnimateDuration(animationName: string) { - const effect = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); - if (effect) { - let duration = 0; - effect.effects.forEach((e) => { - duration += e.duration; - }); - return duration; + const userAnimation = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); + if (userAnimation) { + // 关键帧已按时间排序,最后一帧的时间就是动画时长 + const keyframes = userAnimation.animation.keyframes; + return keyframes.length > 0 ? keyframes[keyframes.length - 1].time : 0; } return 0; } diff --git a/packages/webgal/src/Core/Modules/animationTracks.ts b/packages/webgal/src/Core/Modules/animationTracks.ts new file mode 100644 index 000000000..c8b04c978 --- /dev/null +++ b/packages/webgal/src/Core/Modules/animationTracks.ts @@ -0,0 +1,135 @@ +/** + * @file 把动画拆成每个属性一条的关键帧轨道。 + * + * 相对动画、帧继承以及 v1 / v2 的行为差异都在这里处理,产出的轨道中全部是绝对值, + * 播放时(见 generateTimelineObj)只需按时间在轨道上插值。 + */ +import get from 'lodash/get'; +import set from 'lodash/set'; +import { baseTransform, ITransform } from '@/Core/Modules/stage/stageInterface'; +import { IAnimationV2, Keyframe, RelativeCalc } from '@/Core/Modules/animations'; + +/** + * 轨道上的关键点 + */ +export interface ITrackPoint { + /** 距动画开始的时间(毫秒) */ + time: number; + value: number; + /** 从上一个关键点到达本点所用的缓动 */ + ease: string; +} + +export interface IAnimationTrack { + /** 属性路径,如 'alpha'、'position.x' */ + path: string; + /** 按时间先后排列;时间相同的关键点构成跳变 */ + points: ITrackPoint[]; +} + +export interface ITrackContext { + /** 当前基准状态:相对计算的基准,也是关键帧中缺省属性的取值 */ + base: ITransform; + /** v1 的 writeFullEffect:动画中未涉及的属性也取 base 的值 */ + writeFullEffect: boolean; +} + +/** + * 所有可动画的属性路径,以 baseTransform 为准;position、scale 展开为两轴 + */ +export const ANIMATABLE_PATHS = Object.entries(baseTransform).flatMap(([key, value]) => + typeof value === 'object' ? Object.keys(value).map((axis) => `${key}.${axis}`) : [key], +); + +/** + * 未在 relativeCalc 中指定时,各属性的默认计算方式;表中没有的属性为 absolute + */ +const DEFAULT_RELATIVE_CALC: Partial> = { + position: 'add', + rotation: 'add', + blur: 'add', + bevel: 'add', + bevelThickness: 'add', + bevelRotation: 'add', + bevelSoftness: 'add', + bloom: 'add', + bloomBlur: 'add', + scale: 'multiplyWithZeroFallback', +}; + +export function buildAnimationTracks(animation: IAnimationV2, context: ITrackContext): IAnimationTrack[] { + if (animation.keyframes.length === 0) return []; + return getAnimatedPaths(animation, context.writeFullEffect).map((path) => ({ + path, + points: buildTrackPoints(animation, path, readValue(context.base, path)), + })); +} + +/** + * 动画终态:每条轨道最后一个关键点的值 + */ +export function getTracksEndState(tracks: IAnimationTrack[]): ITransform { + const endState: ITransform = {}; + tracks.forEach((track) => set(endState, track.path, track.points[track.points.length - 1].value)); + return endState; +} + +/** + * 动画会改变哪些属性:关键帧中出现过的属性。v1 开启 writeFullEffect 时为全部属性 + */ +function getAnimatedPaths(animation: IAnimationV2, writeFullEffect: boolean): string[] { + if (animation.version === 1 && writeFullEffect) return ANIMATABLE_PATHS; + return ANIMATABLE_PATHS.filter((path) => animation.keyframes.some((frame) => hasValue(frame, path))); +} + +function buildTrackPoints(animation: IAnimationV2, path: string, baseValue: number): ITrackPoint[] { + const calc = getRelativeCalc(animation, path); + const points: ITrackPoint[] = []; + for (const frame of animation.keyframes) { + if (hasValue(frame, path)) { + points.push({ time: frame.time, value: applyRelativeCalc(calc, baseValue, get(frame, path)), ease: frame.ease }); + } else if (!animation.inherit) { + // 不继承时,关键帧中未指定的属性取当前基准状态;继承时直接跳过,由前后关键帧插值 + points.push({ time: frame.time, value: baseValue, ease: frame.ease }); + } + } + // 第一个关键帧之前:v2 从当前基准状态过渡到第一个关键帧; + // v1 保持第一个关键帧的值,不需要额外的点,因为采样时早于第一个点会取第一个点的值 + if (animation.version === 2) { + points.unshift({ time: 0, value: baseValue, ease: 'linear' }); + } + return points; +} + +/** + * 属性的相对计算方式。position.x 这样的嵌套属性按顶层属性名(position)查找,即设置同时作用于两轴 + */ +function getRelativeCalc(animation: IAnimationV2, path: string): RelativeCalc { + if (!animation.relative) return 'absolute'; + const key = path.split('.')[0]; + return animation.relativeCalc[key] ?? DEFAULT_RELATIVE_CALC[key] ?? 'absolute'; +} + +function applyRelativeCalc(calc: RelativeCalc, baseValue: number, frameValue: number): number { + switch (calc) { + case 'add': + return baseValue + frameValue; + case 'multiply': + return baseValue * frameValue; + case 'multiplyWithZeroFallback': + return (baseValue === 0 ? 1 : baseValue) * frameValue; + case 'absolute': + return frameValue; + } +} + +function hasValue(frame: Keyframe, path: string): boolean { + return typeof get(frame, path) === 'number'; +} + +/** + * 读取变换中的属性值,变换中没有时取 baseTransform 的值(演算状态中的变换可能不完整) + */ +function readValue(transform: ITransform, path: string): number { + return get(transform, path) ?? get(baseTransform, path); +} diff --git a/packages/webgal/src/Core/Modules/animations.ts b/packages/webgal/src/Core/Modules/animations.ts index 142c50b00..212677144 100644 --- a/packages/webgal/src/Core/Modules/animations.ts +++ b/packages/webgal/src/Core/Modules/animations.ts @@ -1,21 +1,153 @@ import { ITransform } from '@/Core/Modules/stage/stageInterface'; +import pickBy from 'lodash/pickBy'; + +/** + * v1 写法的关键帧。duration 为从上一关键帧到本帧的时长(毫秒),ease 为到达本帧这一区间所用的缓动 + */ +export type AnimationFrame = ITransform & { duration: number; ease: string }; + +/** + * 统一后的关键帧。time 为该帧在动画中所处的时间(毫秒,从动画开始计算),ease 为同一属性到达本帧所用的缓动 + */ +export type Keyframe = ITransform & { time: number; ease: string }; + +/** + * 相对动画中,帧中的值与当前基准状态的计算方式 + */ +export type RelativeCalc = 'add' | 'multiply' | 'multiplyWithZeroFallback' | 'absolute'; + +const RELATIVE_CALCS: RelativeCalc[] = ['add', 'multiply', 'multiplyWithZeroFallback', 'absolute']; + +/** + * 统一后的动画格式(Animation v2),详见 dev-docs/Animation v2.md + */ +export interface IAnimationV2 { + /** 1 表示由旧接口(关键帧数组)转换而来,按 v1 的行为执行 */ + version: 1 | 2; + /** 帧中的值是否基于当前基准状态变换 */ + relative: boolean; + /** 是否启用帧继承:各属性按自己的关键帧分别插值 */ + inherit: boolean; + /** 按属性名覆盖相对动画的计算方式,只包含合法值 */ + relativeCalc: Partial>; + /** 按 time 先后排列 */ + keyframes: Keyframe[]; +} export interface IUserAnimation { name: string; - effects: Array; + animation: IAnimationV2; } -export type AnimationFrame = ITransform & { duration: number; ease: string }; +/** + * setTempAnimation 传入的参数,会覆盖动画中对应字段的值;null 表示未传入 + */ +export interface IAnimationOverrides { + relative?: boolean | null; + inherit?: boolean | null; +} export class AnimationManager { - // public nextEnterAnimationName: Map = new Map(); - // public nextExitAnimationName: Map = new Map(); private animations: Array = []; - public addAnimation(animation: IUserAnimation) { - this.animations.push(animation); + /** + * 注册动画。无论传入的是旧接口的关键帧数组还是 v2 对象,都统一转换为 v2 格式保存 + */ + public addAnimation(name: string, rawAnimation: unknown, overrides: IAnimationOverrides = {}) { + this.animations.push({ name, animation: normalizeAnimation(rawAnimation, overrides) }); } + public getAnimations() { return this.animations; } } + +export function normalizeAnimation(rawAnimation: unknown, overrides: IAnimationOverrides): IAnimationV2 { + // 旧接口:顶层直接是关键帧数组。只有 setTempAnimation 传入了 relative 或 inherit 时才按 v2 处理 + // 数组中的关键帧都是 v1 写法(duration) + if (Array.isArray(rawAnimation)) { + const keyframes = keyframesFromDurations(rawAnimation); + const hasOverrides = isBoolean(overrides.relative) || isBoolean(overrides.inherit); + return hasOverrides ? toV2({}, keyframes, overrides) : toV1(keyframes); + } + // 顶层是对象:按 version 处理,缺省或非法时为 2 + if (isObject(rawAnimation)) { + if (rawAnimation.version === 1) return toV1(keyframesFromDurations(rawAnimation.keyframes)); + return toV2(rawAnimation, keyframesFromTimes(rawAnimation.keyframes), overrides); + } + return toV1([]); +} + +function toV1(keyframes: Keyframe[]): IAnimationV2 { + // v1 中帧的值都是绝对值,也没有帧继承 + return { version: 1, relative: false, inherit: false, relativeCalc: {}, keyframes }; +} + +function toV2( + rawAnimation: Record, + keyframes: Keyframe[], + overrides: IAnimationOverrides, +): IAnimationV2 { + return { + version: 2, + relative: toBoolean(overrides.relative ?? rawAnimation.relative, true), + inherit: toBoolean(overrides.inherit ?? rawAnimation.inherit, true), + relativeCalc: normalizeRelativeCalc(rawAnimation.relativeCalc), + keyframes, + }; +} + +/** + * v1 写法:duration 是距上一关键帧的时长,依次累加得到每帧的 time + */ +function keyframesFromDurations(frames: unknown): Keyframe[] { + let time = 0; + return toFrameObjects(frames).map(({ duration, ...frame }) => { + time += toNonNegativeNumber(duration); + return toKeyframe(frame, time); + }); +} + +/** + * v2 写法:time 就是该帧在动画中的时间。 + * 按 time 排序;sort 是稳定的,time 相同的关键帧(跳变)保持在数组中的先后 + */ +function keyframesFromTimes(frames: unknown): Keyframe[] { + return toFrameObjects(frames) + .map(({ time, ...frame }) => toKeyframe(frame, toNonNegativeNumber(time))) + .sort((a, b) => a.time - b.time); +} + +function toFrameObjects(frames: unknown): Record[] { + return Array.isArray(frames) ? frames.filter(isObject) : []; +} + +function toKeyframe(frame: Record, time: number): Keyframe { + const ease = typeof frame.ease === 'string' ? frame.ease : 'easeInOut'; + return { ...frame, time, ease } as Keyframe; +} + +/** + * 缺省或非法(不是数字或小于 0)时为 0 + */ +function toNonNegativeNumber(value: unknown): number { + return typeof value === 'number' && value >= 0 ? value : 0; +} + +function normalizeRelativeCalc(relativeCalc: unknown): IAnimationV2['relativeCalc'] { + if (!isObject(relativeCalc)) return {}; + // 非法的计算方式视为未指定,之后按默认计算方式处理 + return pickBy(relativeCalc, (calc) => RELATIVE_CALCS.includes(calc as RelativeCalc)) as IAnimationV2['relativeCalc']; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +function toBoolean(value: unknown, fallback: boolean): boolean { + return isBoolean(value) ? value : fallback; +} diff --git a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts index 8c3b4890f..5d7c7b92e 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts @@ -1,84 +1,45 @@ -import { ITransform } from '@/Core/Modules/stage/stageInterface'; import * as popmotion from 'popmotion'; +import get from 'lodash/get'; +import set from 'lodash/set'; import { WebGAL } from '@/Core/WebGAL'; -import omitBy from 'lodash/omitBy'; -import isUndefined from 'lodash/isUndefined'; -import PixiStage, { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; -import { AnimationFrame } from '@/Core/Modules/animations'; +import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; +import type { WebGALPixiContainer } from '@/Core/controller/stage/pixi/WebGALPixiContainer'; +import { ITransform } from '@/Core/Modules/stage/stageInterface'; +import { ANIMATABLE_PATHS, IAnimationTrack, ITrackPoint } from '@/Core/Modules/animationTracks'; /** * 动画创建模板 - * @param timeline + * @param tracks 各属性的关键帧轨道,见 buildAnimationTracks * @param targetKey 作用目标 * @param duration 持续时间 */ -export function generateTimelineObj( - timeline: Array, - targetKey: string, - duration: number, -): IAnimationObject { - const target = WebGAL.gameplay.pixiStage!.getStageObjByKey(targetKey); - let currentDelay = 0; - const values = []; - const easeArray: Array = []; - const times: number[] = []; - for (let i = 0; i < timeline.length; i++) { - const segment = timeline[i]; - const segmentDuration = segment.duration; - currentDelay += segmentDuration; - const { position, scale, ...segmentValues } = segment; - // 移除所有值类型不是 number 的属性 - const filteredSegmentValues = omitBy(segmentValues, (value) => typeof value !== 'number'); - // 不能用 scale,因为 popmotion 不能用嵌套 - values.push({ x: position?.x, y: position?.y, scaleX: scale?.x, scaleY: scale?.y, ...filteredSegmentValues }); - // Easing 需要比 values 的长度少一个 - if (i > 0) { - easeArray.push(stringToEasing(segment.ease)); - } - if (duration !== 0) { - times.push(currentDelay / duration); - } else times.push(0); - } - const container = target?.pixiContainer; +export function generateTimelineObj(tracks: IAnimationTrack[], targetKey: string, duration: number): IAnimationObject { + const container = WebGAL.gameplay.pixiStage!.getStageObjByKey(targetKey)?.pixiContainer; let animateInstance: ReturnType | null = null; - // 只有有 duration 且 timeline 长度大于 1 的时候才有动画 - if (duration > 0 && timeline.length > 1) { - animateInstance = popmotion.animate({ - to: values, - offset: times, - duration, - ease: easeArray, - onUpdate: (updateValue) => { - if (container) { - const { scaleX, scaleY, ...val } = updateValue; - // @ts-ignore - PixiStage.assignTransform(container, omitBy(val, isUndefined)); - // 因为 popmotion 不能用嵌套,scale 要手动设置 - if (!isUndefined(scaleX)) container.scale.x = scaleX; - if (!isUndefined(scaleY)) container.scale.y = scaleY; - } - }, - }); + + /** + * 把动画在 time 时刻的状态写入容器 + */ + function applyStateAt(time: number) { + if (!container) return; + for (const track of tracks) { + setContainerValue(container, track.path, sampleTrack(track.points, time)); + } } /** * 在此书写为动画设置初态的操作 */ function setStartState() { - if (target?.pixiContainer) { - // 不能赋值到 position,因为 x 和 y 被 WebGALPixiContainer 代理,而 position 属性没有代理 - const { position, scale, ...state } = getStartStateEffect(); - const assignValue = omitBy({ x: position?.x, y: position?.y, ...state }, isUndefined); - // @ts-ignore - PixiStage.assignTransform(target?.pixiContainer, assignValue); - if (scale && target?.pixiContainer) { - if (!isUndefined(scale?.x)) { - target.pixiContainer.scale.x = scale.x; - } - if (!isUndefined(scale?.y)) { - target.pixiContainer.scale.y = scale.y; - } - } + applyStateAt(0); + if (duration > 0) { + animateInstance = popmotion.animate({ + from: 0, + to: duration, + duration, + ease: popmotion.linear, + onUpdate: (time) => applyStateAt(time), + }); } } @@ -86,43 +47,16 @@ export function generateTimelineObj( * 在此书写为动画设置终态的操作 */ function setEndState() { - if (!container) { - return; - } - if (animateInstance) animateInstance.stop(); - animateInstance = null; - if (target?.pixiContainer) { - // 不能赋值到 position,因为 x 和 y 被 WebGALPixiContainer 代理,而 position 属性没有代理 - // 不能赋值到 position,因为 x 和 y 被 WebGALPixiContainer 代理,而 position 属性没有代理 - const { position, scale, ...state } = getEndStateEffect(); - const assignValue = omitBy({ x: position?.x, y: position?.y, ...state }, isUndefined); - // @ts-ignore - PixiStage.assignTransform(target?.pixiContainer, assignValue); - if (scale && target?.pixiContainer) { - if (!isUndefined(scale?.x)) { - target.pixiContainer.scale.x = scale.x; - } - if (!isUndefined(scale?.y)) { - target.pixiContainer.scale.y = scale.y; - } - } - } + forceStopWithoutSetEndState(); + applyStateAt(duration); } /** - * 在此书写动画每一帧执行的函数 + * 在此书写动画每一帧执行的函数。时间由 popmotion 驱动,这里不需要做什么 * @param delta */ function tickerFunc(delta: number) {} - function getStartStateEffect() { - return timeline[0]; - } - - function getEndStateEffect() { - return timeline[timeline.length - 1]; - } - function forceStopWithoutSetEndState() { if (animateInstance) animateInstance.stop(); animateInstance = null; @@ -132,11 +66,54 @@ export function generateTimelineObj( setStartState, setEndState, tickerFunc, - getEndStateEffect, forceStopWithoutSetEndState, }; } +/** + * 取轨道在 time 时刻的值 + */ +export function sampleTrack(points: ITrackPoint[], time: number): number { + // 本区间的插值终点:第一个晚于 time 的关键点 + const nextIndex = points.findIndex((point) => point.time > time); + // 在最后一个关键点之后:保持最后的值 + if (nextIndex === -1) return points[points.length - 1].value; + // 在第一个关键点之前:保持第一个关键点的值 + if (nextIndex === 0) return points[0].value; + // 本区间的起点。时间相同的多个关键点中,靠后的是跳变后的状态,正好是 nextIndex 的前一个 + const prev = points[nextIndex - 1]; + const next = points[nextIndex]; + const progress = (time - prev.time) / (next.time - prev.time); + return prev.value + (next.value - prev.value) * stringToEasing(next.ease)(progress); +} + +/** + * 变换属性在容器上的对应属性:透明度由 alpha 滤镜实现,位置由 WebGALPixiContainer 代理的 x、y 实现, + * 与 assignPixiTransform 的写法一致。其余属性同名 + */ +const CONTAINER_PROPERTY: Partial> = { + alpha: 'alphaFilterVal', + 'position.x': 'x', + 'position.y': 'y', +}; + +function getContainerValue(container: WebGALPixiContainer, path: string): number { + return get(container, CONTAINER_PROPERTY[path] ?? path); +} + +function setContainerValue(container: WebGALPixiContainer, path: string, value: number) { + set(container, CONTAINER_PROPERTY[path] ?? path, value); +} + +/** + * 从容器读取当前变换,用于演算状态中已没有记录的舞台对象(如正在退场的立绘) + */ +export function readContainerTransform(container: WebGALPixiContainer): ITransform { + const transform: ITransform = {}; + ANIMATABLE_PATHS.forEach((path) => set(transform, path, getContainerValue(container, path))); + return transform; +} + const stringToEasing = (ease: string): popmotion.Easing => { let easeType = popmotion.easeInOut; switch (ease) { diff --git a/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssets.ts b/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssets.ts index 274520385..1abebd84b 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssets.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/assets/live2dAssets.ts @@ -27,9 +27,12 @@ export async function loadLive2dResources(url: string, load: LoadDependency): Pr const settings = await load(url, 'json'); const files = settings.FileReferences; const resolve = (file: string) => new URL(file, url).href; - const textures = await Promise.all( + const rawTextures = await Promise.all( (files?.Textures ?? settings.textures ?? []).map((file) => load(resolve(file), 'texture')), ); + const adaptor = Live2D.createCubism2Texture; + const textures = files?.Moc || !adaptor ? rawTextures : rawTextures.map((texture) => adaptor(texture)); + const model = files?.Moc ?? settings.model; if (model) await load(resolve(model), 'binary'); // 可选动作损坏不应让本来能显示的模型加载失败;SDK 会在使用时重试。 diff --git a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts index a1a72a5c8..0ad29b04e 100644 --- a/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts +++ b/packages/webgal/src/Core/controller/storage/jumpFromBacklog.ts @@ -10,9 +10,43 @@ import cloneDeep from 'lodash/cloneDeep'; import { WebGAL } from '@/Core/WebGAL'; import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; -import { commandType } from '@/Core/controller/scene/sceneInterface'; +import { commandType, ISentence } from '@/Core/controller/scene/sceneInterface'; import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; import { createSayPerform } from '@/Core/gameScripts/say/createSayPerform'; +import { parseTempAnimation } from '@/Core/gameScripts/setTempAnimation'; +import { isRelativeAnimation } from '@/Core/Modules/animationFunctions'; + +/** + * 恢复演出的方式: + * - rerun:重新执行语句(默认) + * - rebuildSayPerform:只重建对话演出,不重新执行语句 + * - skip:不恢复 + */ +type RestoreMode = 'rerun' | 'rebuildSayPerform' | 'skip'; + +/** + * 需要特殊处理的语句,以及如何处理。不在表中的语句按 rerun 处理 + */ +const specialRestoreScripts: Array<{ isMatch: (script: ISentence) => boolean; mode: RestoreMode }> = [ + // 正文和分段已经在存档中,只重建演出,不能重跑 say 再追加一次 concat + { + isMatch: (script) => script.command === commandType.say, + mode: 'rebuildSayPerform', + }, + // 存档已保存了动画执行后的舞台状态,相对动画重新执行会在此基础上再叠加一次,因此不重播 + { + isMatch: (script) => script.command === commandType.setTempAnimation && parseTempAnimation(script).relative, + mode: 'skip', + }, + { + isMatch: (script) => script.command === commandType.setAnimation && isRelativeAnimation(script.content), + mode: 'skip', + }, +]; + +function getRestoreMode(script: ISentence): RestoreMode { + return specialRestoreScripts.find((item) => item.isMatch(script))?.mode ?? 'rerun'; +} /** * 恢复演出 @@ -25,8 +59,11 @@ export const restorePerform = (skipAnimation = false) => { WebGAL.gameplay.performController.beginCollectingPerforms(); try { performToRestore.forEach((e) => { - if (e.script.command === commandType.say) { - // 正文和分段已经在存档中,只重建演出,不能重跑 say 再追加一次 concat。 + const restoreMode = getRestoreMode(e.script); + if (restoreMode === 'skip') { + return; + } + if (restoreMode === 'rebuildSayPerform') { if (stageState.isDialogNotend === undefined) { stageStateManager.setStage('isDialogNotend', getBooleanArgByKey(e.script, 'notend') ?? false); } diff --git a/packages/webgal/src/Core/gameScripts/changeBg/index.ts b/packages/webgal/src/Core/gameScripts/changeBg/index.ts index 2a6d30976..b898e44ab 100644 --- a/packages/webgal/src/Core/gameScripts/changeBg/index.ts +++ b/packages/webgal/src/Core/gameScripts/changeBg/index.ts @@ -9,7 +9,7 @@ import { logger } from '@/Core/util/logger'; import { ITransform } from '@/Core/Modules/stage/stageInterface'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; -import { AnimationFrame, IUserAnimation } from '@/Core/Modules/animations'; +import { AnimationFrame } from '@/Core/Modules/animations'; import cloneDeep from 'lodash/cloneDeep'; import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; @@ -72,8 +72,7 @@ export const changeBg = (sentence: ISentence): IPerform => { // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); + WebGAL.animationManager.addAnimation(animationName, animationObj); duration = getAnimateDuration(animationName); stageStateManager.updateAnimationSettings({ target: 'bg-main', key: 'enterAnimationName', value: animationName }); } diff --git a/packages/webgal/src/Core/gameScripts/changeFigure.ts b/packages/webgal/src/Core/gameScripts/changeFigure.ts index 3af7698b6..d9788582e 100644 --- a/packages/webgal/src/Core/gameScripts/changeFigure.ts +++ b/packages/webgal/src/Core/gameScripts/changeFigure.ts @@ -9,7 +9,7 @@ import { getStringArgByKey, } from '@/Core/util/getSentenceArg'; import { figureStateKeyByPosition, IFreeFigure, normalizeFigureBounds } from '@/Core/Modules/stage/stageInterface'; -import { AnimationFrame, IUserAnimation } from '@/Core/Modules/animations'; +import { AnimationFrame } from '@/Core/Modules/animations'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; import { logger } from '@/Core/util/logger'; @@ -136,8 +136,7 @@ export function changeFigure(sentence: ISentence): IPerform { // 因为是切换,必须把一开始的 alpha 改为 0 animationObj[0].alpha = 0; const animationName = (Math.random() * 10).toString(16); - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); + WebGAL.animationManager.addAnimation(animationName, animationObj); duration = getAnimateDuration(animationName); stageStateManager.updateAnimationSettings({ target: key, key: 'enterAnimationName', value: animationName }); } diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index a69b80290..aa42e758c 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -3,11 +3,11 @@ import { IPerform } from '@/Core/Modules/perform/performInterface'; import { getBooleanArgByKey, getStringArgByKey, resolveTransformArgs } from '@/Core/util/getSentenceArg'; import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; -import { IUserAnimation } from '../Modules/animations'; import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/animationFunctions'; import { WebGAL } from '@/Core/WebGAL'; import { v4 as uuid } from 'uuid'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; +import { IAnimationOverrides, IAnimationV2, normalizeAnimation } from '@/Core/Modules/animations'; /** * 设置临时动画 @@ -15,15 +15,8 @@ import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/tim */ export const setTempAnimation = (sentence: ISentence): IPerform => { const animationName = uuid(); - const animationString = sentence.content; - let animationObj; - try { - animationObj = JSON.parse(animationString); - } catch (e) { - animationObj = []; - } - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); + const { rawAnimation, overrides } = readTempAnimation(sentence); + WebGAL.animationManager.addAnimation(animationName, rawAnimation, overrides); const animationDuration = getAnimateDuration(animationName); const target = getStringArgByKey(sentence, 'target') ?? '0'; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; @@ -70,3 +63,27 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { blockingAuto: () => !keep, }; }; + +/** + * 解析语句中的临时动画,规则与 setTempAnimation 注册动画时相同 + */ +export function parseTempAnimation(sentence: ISentence): IAnimationV2 { + const { rawAnimation, overrides } = readTempAnimation(sentence); + return normalizeAnimation(rawAnimation, overrides); +} + +/** + * 读取语句中的动画 JSON 与 relative、inherit 参数。 + * 传入 relative 或 inherit 时,关键帧数组也按 v2 处理;v2 对象中的对应字段会被覆盖 + */ +function readTempAnimation(sentence: ISentence): { rawAnimation: unknown; overrides: IAnimationOverrides } { + let rawAnimation; + try { + rawAnimation = JSON.parse(sentence.content); + } catch (e) { + rawAnimation = []; + } + const relative = getBooleanArgByKey(sentence, 'relative'); + const inherit = getBooleanArgByKey(sentence, 'inherit'); + return { rawAnimation, overrides: { relative, inherit } }; +} diff --git a/packages/webgal/src/Core/gameScripts/setTransform.ts b/packages/webgal/src/Core/gameScripts/setTransform.ts index dd6f59e7d..738058fe3 100644 --- a/packages/webgal/src/Core/gameScripts/setTransform.ts +++ b/packages/webgal/src/Core/gameScripts/setTransform.ts @@ -8,7 +8,7 @@ import { } from '@/Core/util/getSentenceArg'; import { IAnimationObject } from '@/Core/controller/stage/pixi/PixiController'; import { logger } from '@/Core/util/logger'; -import { AnimationFrame, IUserAnimation } from '../Modules/animations'; +import { AnimationFrame } from '../Modules/animations'; import { generateTransformAnimationObj } from '@/Core/controller/stage/pixi/animations/generateTransformAnimationObj'; import { WebGAL } from '@/Core/WebGAL'; import { applyAnimationEndState, getAnimateDuration } from '../Modules/animationFunctions'; @@ -43,8 +43,7 @@ export const setTransform = (sentence: ISentence): IPerform => { animationObj = []; } - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); + WebGAL.animationManager.addAnimation(animationName, animationObj); const animationDuration = getAnimateDuration(animationName); const animationTimeline = applyAnimationEndState(animationName, target, writeDefault, writeFullEffect); const key = `${target}-${animationName}-${animationDuration}`; diff --git a/packages/webgal/src/Core/initializeScript.ts b/packages/webgal/src/Core/initializeScript.ts index 80af20d46..1cb1ed09c 100644 --- a/packages/webgal/src/Core/initializeScript.ts +++ b/packages/webgal/src/Core/initializeScript.ts @@ -99,11 +99,7 @@ function getUserAnimation() { for (const animationName of animations) { axios.get(`./game/animation/${animationName}.json`).then((res) => { if (res.data) { - const userAnimation = { - name: animationName, - effects: res.data, - }; - WebGAL.animationManager.addAnimation(userAnimation); + WebGAL.animationManager.addAnimation(animationName, res.data); } }); } diff --git a/packages/webgal/src/Core/live2DCore.ts b/packages/webgal/src/Core/live2DCore.ts index 3f3a812d0..110197658 100644 --- a/packages/webgal/src/Core/live2DCore.ts +++ b/packages/webgal/src/Core/live2DCore.ts @@ -1,3 +1,4 @@ +import { Texture } from 'pixi.js'; import { installLive2dAssetCache } from './controller/stage/pixi/assets/live2dAssetCache'; /** 眨眼参数,毫秒 */ export interface BlinkParam { @@ -36,6 +37,9 @@ export class Live2DCore { public SoundManager: any; public Config: any; + public createCubism2Texture?: (texture: Texture) => Texture; + public reserveCoreMemory?: (size: number) => void; + // 临时记录未初始化前的数据 // 旧版表情混合模式 private _legacyExpressionBlendMode = false; @@ -49,6 +53,18 @@ export class Live2DCore { } } + // CubismCore预留堆内存容量,单位MB;PLDW的对应API的单位是byte + private _cubismMemoryReservedSize = 32 + public get CubismMemoryReservedSize() { + return this._cubismMemoryReservedSize; + } + public set CubismMemoryReservedSize(size: number) { + this._cubismMemoryReservedSize = size; + if (this.isAvailable && this.reserveCoreMemory) { + this.reserveCoreMemory(this.CubismMemoryReservedSize * 1024 * 1024); + } + } + public constructor() { this.ready = this.initLive2D(); } @@ -62,16 +78,27 @@ export class Live2DCore { console.warn('live2d plugin load failed'); return; } - const { Live2DModel, SoundManager, config, Live2DLoader, ModelSettings } = await import( - 'pixi-live2d-display-webgal' - ); + const { + Live2DModel, + SoundManager, + config, + Live2DLoader, + ModelSettings, + createCubism2Texture, + reserveCoreMemory, + } = await import('pixi-live2d-display-webgal'); installLive2dAssetCache({ Live2DLoader, ModelSettings }); this.Live2DModel = Live2DModel; this.SoundManager = SoundManager; this.Config = config; this.isAvailable = true; + this.createCubism2Texture = createCubism2Texture; + this.reserveCoreMemory = reserveCoreMemory; console.log('Live2D plugin load success'); this.initConfig(); + if (this.reserveCoreMemory) { + this.reserveCoreMemory(this.CubismMemoryReservedSize * 1024 * 1024); + } }) .catch((error) => { this.isAvailable = false; diff --git a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts index 299e34218..5e8510955 100644 --- a/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts +++ b/packages/webgal/src/Core/util/coreInitialFunction/infoFetcher.ts @@ -67,6 +67,11 @@ export const infoFetcher = (url: string): Promise => { const appId = String(res); WebGAL.steam.initialize(appId); } + if (command === 'Cubism_Memory_Reserved_Size_MB') { + if (typeof res === 'number') { + Live2D.CubismMemoryReservedSize = res; + } + } } } }); diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 2f0864333..1c0d759ed 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -20,6 +20,7 @@ import type { RequestEnvelopeByType, RunSceneContentPayload, RunSnippetPayload, + SeekAnimationPayload, SetComponentVisibilityPayload, SetEffectPayload, SetFontOptimizationPayload, @@ -51,6 +52,7 @@ import { executePreviewSyncSceneCommand } from './runtime/previewSyncSceneComman import { setDebugTextReadMode } from '@/Core/Modules/readHistory'; import { applyPreviewDebugVariables } from './runtime/previewDebugVariables'; import { handleReferenceBoxQuery } from './runtime/handlers/referenceBoxQueryHandler'; +import { seekAnimationPreview } from './runtime/previewSeekAnimation'; import { cloneBaseTransform, createTargetTransformBaselineManager, @@ -342,11 +344,18 @@ export const startPreviewSyncRuntime = () => { }); }; - const handleReloadTemplates = () => { + /** + * 隐藏“点击屏幕开始”的落地页,与 sync-scene 一致 + */ + const hideTitleEnter = () => { const title = document.querySelector('.html-body__title-enter') as HTMLElement | null; if (title) { title.style.display = 'none'; } + }; + + const handleReloadTemplates = () => { + hideTitleEnter(); WebGAL.events.styleUpdate.emit(); }; @@ -361,8 +370,10 @@ export const startPreviewSyncRuntime = () => { showMenuPanel: false, showFlowchart: false, isEnterGame: true, + isShowLogo: false, showPanicOverlay: false, }); + hideTitleEnter(); // 暂留旧版等待:resetStage 不清除进行中的场景写入锁,立即推进可能被 preForward 拦截。 // 100ms 不保证解锁;替换前需处理旧加载结果失效和解锁后的推进,避免覆盖新预览。 setTimeout(() => { @@ -435,6 +446,10 @@ export const startPreviewSyncRuntime = () => { handleSetTextReadMode(payload); return {}; }, + 'preview.command.seek-animation': (payload: SeekAnimationPayload) => { + seekAnimationPreview(payload, targetTransformBaselines.getReadyTransformBaselineOverride(payload.target)); + return {}; + }, }; const isPreviewQueryEnvelope = (envelope: PreviewRequestEnvelope): envelope is PreviewQueryEnvelope => { diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts new file mode 100644 index 000000000..d0721aab1 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts @@ -0,0 +1,32 @@ +import cloneDeep from 'lodash/cloneDeep'; +import merge from 'lodash/merge'; +import set from 'lodash/set'; +import { WebGAL } from '@/Core/WebGAL'; +import { baseTransform } from '@/Core/Modules/stage/stageInterface'; +import { stageStateManager } from '@/Core/Modules/stage/stageStateManager'; +import { normalizeAnimation } from '@/Core/Modules/animations'; +import { buildAnimationTracks } from '@/Core/Modules/animationTracks'; +import { sampleTrack } from '@/Core/controller/stage/pixi/animations/timeline'; +import { applyStageEffectToTarget } from '@/Core/controller/stage/pixi/syncPixiStageState'; +import type { ITransform } from '@/Core/Modules/stage/stageInterface'; +import type { SeekAnimationPayload } from '@/types/editorPreviewProtocol'; + +/** + * 把动画在 time 时刻的状态写入目标,供编辑器拖动时间轴时预览;结果只写入容器,不写入舞台状态。 + * 基准状态依次取:编辑器给出的 baseTransform、目标语句执行前的基线(编辑场景中的动画语句时, + * 预览已执行完这条语句,演算状态是动画的终态)、演算状态中目标的变换 + * @param baselineOverride 目标语句执行前目标的变换,见 targetTransformBaseline + */ +export function seekAnimationPreview( + { target, animation, time, baseTransform: previewBase }: SeekAnimationPayload, + baselineOverride?: ITransform, +) { + const targetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target); + const base = merge(cloneDeep(baseTransform), previewBase ?? baselineOverride ?? targetEffect?.transform); + const tracks = buildAnimationTracks(normalizeAnimation(animation, {}), { base, writeFullEffect: false }); + // 从基准状态出发写入各轨道的值,这样动画中删掉的属性也会回到基准状态 + const state = cloneDeep(base); + tracks.forEach((track) => set(state, track.path, sampleTrack(track.points, time))); + WebGAL.gameplay.pixiStage?.removeAnimationByTargetKey(target); + applyStageEffectToTarget(target, state); +} diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index 596931e5f..57c1bbdb6 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -127,6 +127,21 @@ export interface SetEffectPayload { phase?: SetEffectPhase; } +/** + * 把动画在 time 时刻的状态写入目标,仅用于预览,不写入舞台状态。 + * animation 为动画 JSON(v1 关键帧数组或 v2 对象) + */ +export interface SeekAnimationPayload { + target: string; + animation: JsonValue; + time: number; + /** + * 动画开始前目标的状态,与默认变换合并后作为当前基准状态。 + * 缺省时取带 transformBaselineRevision 的 sync-scene 记录的目标语句执行前的变换,再缺省时取演算状态中的变换 + */ + baseTransform?: Transform; +} + export interface SetFontOptimizationPayload { enabled: boolean; } @@ -194,6 +209,7 @@ export const PREVIEW_COMMAND_PAYLOADS = definePayloadMap({ 'preview.command.set-component-visibility': payload(), 'preview.command.set-font-optimization': payload(), 'preview.command.set-text-read-mode': payload(), + 'preview.command.seek-animation': payload(), }); export type PreviewCommandPayloadByType = typeof PREVIEW_COMMAND_PAYLOADS; diff --git a/releasenote.md b/releasenote.md index 88b72fa21..e19b5d47b 100644 --- a/releasenote.md +++ b/releasenote.md @@ -8,25 +8,13 @@ #### 新功能 -新增立绘差分切换。用于为立绘更换表情等差分。执行此指令时,位置、效果和层级都保持不变,尺寸相同的图片之间会平滑过渡。 +新增新版动画格式。动画可以基于立绘或背景的当前状态进行变换,关键帧中未设置的属性会沿用之前关键帧的值。同时新增一组以 -v2 结尾、基于当前状态执行的内置动画。 -设置变换与动画时,可以选择从立绘当前的状态开始,还是从默认状态开始。 - -优化资源的预加载与缓存,切换背景和立绘更加流畅。 +新增 Live2D 内存预留设置,可在游戏配置中调整 Live2D 预先分配的内存大小。 #### 修复 -修复自动播放时,等待之后接续的对话会瞬间全部显示,以及等待时间被提前结束的问题。 - -修复对话最后几个字还在渐显时突然全部显示的问题。文字速度设置现在也会影响每个字渐显的快慢。 - -修复接续的对话中包含英文或标点时,文字显示异常的问题。 - -修复设置页中的文字速度预览可能被正在进行的剧情打断的问题。 - -修复在图形化编辑器中调整效果时,实时预览中的立绘可能丢失缩放、滤镜等效果的问题。 - -修复资源较多的长场景加载缓慢的问题。 +修复部分 Cubism 2 立绘显示错乱的问题。 ## Release Notes @@ -39,25 +27,13 @@ #### New Features -Added figure variant switching, which changes a figure's variant, such as its expression. When this command runs, the figure's position, effects, and layer stay the same, and images of the same size blend smoothly. - -When setting transforms and animations, you can now choose whether to start from the figure's current state or from the default state. +Added a new animation format. Animations can now transform figures or backgrounds relative to their current state, and properties not set in a keyframe carry over values from previous keyframes. Also added a set of built-in animations ending in -v2 that run from the current state. -Improved resource preloading and caching, so switching backgrounds and figures is smoother. +Added a Live2D memory reservation setting, which lets you adjust how much memory Live2D allocates in advance in the game config. #### Fixes -Fixed continued dialogue appearing all at once after a wait during auto-play, and the wait ending early. - -Fixed the last few characters of dialogue appearing all at once while they were still fading in. The text speed setting now also controls how fast each character fades in. - -Fixed display issues when continued dialogue contained English words or punctuation. - -Fixed the text speed preview on the settings page being interrupted by the ongoing story. - -Fixed figures in the graphical editor's live preview possibly losing effects such as scale and filters while adjusting effects. - -Fixed slow loading of long scenes with many resources. +Fixed some Cubism 2 figures displaying incorrectly. ## リリースノート @@ -70,22 +46,10 @@ Fixed slow loading of long scenes with many resources. #### 新機能 -立ち絵の差分切り替えを追加しました。立ち絵の表情などの差分を変更するためのものです。この命令を実行しても、位置、エフェクト、レイヤーはそのまま保持され、同じサイズの画像同士はなめらかに切り替わります。 +新しいアニメーション形式を追加しました。立ち絵や背景の現在の状態を基準に変換できるようになり、キーフレームで指定していないプロパティは前のキーフレームの値を引き継ぎます。あわせて、現在の状態を基準に実行される、名前が -v2 で終わる組み込みアニメーションを追加しました。 -変換やアニメーションを設定するときに、立ち絵の現在の状態から始めるか、デフォルトの状態から始めるかを選べるようになりました。 - -リソースの先読みとキャッシュを改善し、背景や立ち絵の切り替えがよりスムーズになりました。 +Live2D のメモリ予約設定を追加しました。ゲーム設定で、Live2D が事前に確保するメモリの大きさを調整できます。 #### 修正 -オートプレイ中、待機のあとに続くセリフが一度にすべて表示される問題と、待機時間が途中で打ち切られる問題を修正しました。 - -セリフの最後の数文字がフェードイン中に突然すべて表示される問題を修正しました。文字速度の設定が、各文字のフェードインの速さにも反映されるようになりました。 - -続きのセリフに英単語や句読点が含まれる場合に、文字が正しく表示されない問題を修正しました。 - -設定画面の文字速度プレビューが、進行中のストーリーによって中断されることがある問題を修正しました。 - -グラフィカルエディターでエフェクトを調整する際、リアルタイムプレビューの立ち絵から拡大縮小やフィルターなどのエフェクトが失われることがある問題を修正しました。 - -リソースの多い長いシーンの読み込みが遅い問題を修正しました。 +一部の Cubism 2 立ち絵が正しく表示されない問題を修正しました。 diff --git a/yarn.lock b/yarn.lock index 0b733b019..6889b121e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4993,10 +4993,10 @@ pixi-filters@^4.2.0: "@pixi/filter-twist" "4.2.0" "@pixi/filter-zoom-blur" "4.2.0" -pixi-live2d-display-webgal@^0.5.12: - version "0.5.12" - resolved "https://registry.yarnpkg.com/pixi-live2d-display-webgal/-/pixi-live2d-display-webgal-0.5.12.tgz#f920f926e37bacf222c77343782b5b6fc3d209ab" - integrity sha512-93UDIM8ducia8zIgVtidqxlDamxlwEK4+ddUnl/8XcEKBinbr2Qlu0bBN9i1f4c5rzX5OPgEKc0DhjlDa7fJVg== +pixi-live2d-display-webgal@^0.5.19: + version "0.5.19" + resolved "https://registry.yarnpkg.com/pixi-live2d-display-webgal/-/pixi-live2d-display-webgal-0.5.19.tgz#3bec5c75b55477ab6896d72bceaa2ae139ed1224" + integrity sha512-486kQpM4O0wpxKaQUTefSI46xmTGe1GbSNsCv7Fs4+2isvTSBB3frbTIEgVOSaU2uH8MyfN/WqE50Q3+9j5zwA== dependencies: gh-pages "^4.0.0"