From ca7f2f18b94520a27d13822358bd64038aba1704 Mon Sep 17 00:00:00 2001 From: TremblingMoeNew <39522864+TremblingMoeNew@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:16:47 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=B4=B4?= =?UTF-8?q?=E5=9B=BE=E9=A2=84=E8=BD=BD=E5=AF=BC=E8=87=B4=E7=9A=84Cubism=20?= =?UTF-8?q?2=E7=AB=8B=E7=BB=98=E6=98=BE=E7=A4=BA=E9=94=99=E4=B9=B1?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Core/controller/stage/pixi/assets/live2dAssets.ts | 5 ++++- packages/webgal/src/Core/live2DCore.ts | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) 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/live2DCore.ts b/packages/webgal/src/Core/live2DCore.ts index 3f3a812d0..50280b9c7 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,8 @@ export class Live2DCore { public SoundManager: any; public Config: any; + public createCubism2Texture?: (texture: Texture) => Texture; + // 临时记录未初始化前的数据 // 旧版表情混合模式 private _legacyExpressionBlendMode = false; @@ -62,7 +65,7 @@ export class Live2DCore { console.warn('live2d plugin load failed'); return; } - const { Live2DModel, SoundManager, config, Live2DLoader, ModelSettings } = await import( + const { Live2DModel, SoundManager, config, Live2DLoader, ModelSettings, createCubism2Texture } = await import( 'pixi-live2d-display-webgal' ); installLive2dAssetCache({ Live2DLoader, ModelSettings }); @@ -70,6 +73,7 @@ export class Live2DCore { this.SoundManager = SoundManager; this.Config = config; this.isAvailable = true; + this.createCubism2Texture = createCubism2Texture; console.log('Live2D plugin load success'); this.initConfig(); }) From 67949e5bba79d95b4a9fac88756cb3d55c18fcc2 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 27 Sep 2026 10:24:38 +0800 Subject: [PATCH 02/13] update pldw --- packages/webgal/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 67a5ef52b..4790dffa4 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -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.16", "pixi.js": "^6.3.0", "popmotion": "^11.0.5", "react": "^17.0.2", diff --git a/yarn.lock b/yarn.lock index 0b733b019..fc2d5369d 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.16: + version "0.5.16" + resolved "https://registry.yarnpkg.com/pixi-live2d-display-webgal/-/pixi-live2d-display-webgal-0.5.16.tgz#eddc6a4a478f9fef51fce942d0a21faccc2a7074" + integrity sha512-dKKWLHRdC70FUGyo10R0oNtB0fH4q1sCmJxFr3DhHDlUI+rPtsCiGag5PJ8yF6KG9HX+rUpe6R2GrvuHk3N5PA== dependencies: gh-pages "^4.0.0" From fe367728d9fb655a317d5dae027e4801affda261 Mon Sep 17 00:00:00 2001 From: TremblingMoeNew <39522864+TremblingMoeNew@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:32:44 +0800 Subject: [PATCH 03/13] chore: update PLDW version --- packages/webgal/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 4790dffa4..b7d16f4fd 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -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.16", + "pixi-live2d-display-webgal": "^0.5.19", "pixi.js": "^6.3.0", "popmotion": "^11.0.5", "react": "^17.0.2", diff --git a/yarn.lock b/yarn.lock index fc2d5369d..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.16: - version "0.5.16" - resolved "https://registry.yarnpkg.com/pixi-live2d-display-webgal/-/pixi-live2d-display-webgal-0.5.16.tgz#eddc6a4a478f9fef51fce942d0a21faccc2a7074" - integrity sha512-dKKWLHRdC70FUGyo10R0oNtB0fH4q1sCmJxFr3DhHDlUI+rPtsCiGag5PJ8yF6KG9HX+rUpe6R2GrvuHk3N5PA== +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" From d1a5f5dd83b4f9a28c9bd876c979859e976eb842 Mon Sep 17 00:00:00 2001 From: TremblingMoeNew <39522864+TremblingMoeNew@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:35:59 +0800 Subject: [PATCH 04/13] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81cubismcore?= =?UTF-8?q?=E5=86=85=E5=AD=98=E9=A2=84=E6=89=A9=E5=AE=B9=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/webgal/src/Core/live2DCore.ts | 29 +++++++++++++++++-- .../util/coreInitialFunction/infoFetcher.ts | 5 ++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/webgal/src/Core/live2DCore.ts b/packages/webgal/src/Core/live2DCore.ts index 50280b9c7..110197658 100644 --- a/packages/webgal/src/Core/live2DCore.ts +++ b/packages/webgal/src/Core/live2DCore.ts @@ -38,6 +38,7 @@ export class Live2DCore { public Config: any; public createCubism2Texture?: (texture: Texture) => Texture; + public reserveCoreMemory?: (size: number) => void; // 临时记录未初始化前的数据 // 旧版表情混合模式 @@ -52,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(); } @@ -65,17 +78,27 @@ export class Live2DCore { console.warn('live2d plugin load failed'); return; } - const { Live2DModel, SoundManager, config, Live2DLoader, ModelSettings, createCubism2Texture } = 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; + } + } } } }); From 435ccb2a595aec50977d6c1290b2f61e64bea0ca Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sat, 3 Oct 2026 20:01:51 +0800 Subject: [PATCH 05/13] Animation v2 docs --- dev-docs/Animation v2.md | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 dev-docs/Animation v2.md diff --git a/dev-docs/Animation v2.md b/dev-docs/Animation v2.md new file mode 100644 index 000000000..f8b47ee20 --- /dev/null +++ b/dev-docs/Animation v2.md @@ -0,0 +1,58 @@ +目前的动画实现不支持两项关键功能:相对动画和继承关键帧。 + +因此,将开发 Animation v2 接口,这一接口将拓展现有的动画体系。 + +v2 动画的 JSON 是一个对象: + +```json +{ + "version": 2, + "relative": true, + "inherit": true, + "keyframes": [ + { "duration": 250, "position": { "x": -100 }, "ease": "easeOut" }, + { "duration": 500, "position": { "x": 100 }, "ease": "easeInOut" }, + { "duration": 500, "position": { "x": 0 }, "ease": "easeIn" } + ], + "relative-calc": { + "alpha": "multiply-with-zero-fallback", + "blur": "multiply" + } +} +``` + +该动画使舞台对象围绕当前位置左右摇动一次,最终回到原位。 + +关键帧使用持续时间 `duration`(单位为毫秒),代表执行动画到当前关键帧的时间。`ease` 为从上一关键帧到达本帧这一区间所使用的缓动,缺省为 `easeInOut`。同一属性在同一时刻有多个关键帧时发生跳变:以顺序靠前的为插值终点,以顺序靠后的为跳变后的状态。 + +除了关键帧以外,还将加入用于描述动画属性的字段,目前包括: + +1. 动画版本 `version`,对于 Animation v2,这个值是 2。 +2. 是否为相对动画 `relative`,默认为 true。为 true 则启用相对动画,帧中的值基于舞台对象的**当前基准状态**变换;为 false 则帧中的值为绝对值。**当前基准状态**遵循 `transformFrom` 及相关参数的设置,一般为 current(当前的变换),如果为 default 则为 `baseTransform`。各属性按性质决定计算方式: + - 加法:`position`、`rotation`、`blur`、`bevel`、`bevelThickness`、`bevelRotation`、`bevelSoftness`、`bloom`、`bloomBlur`。 + - 乘法:`scale`。如果当前基准状态的 scale 为 0,按 1 处理。 + - 始终绝对:其余属性,如 `alpha`、`brightness`、颜色、滤镜开关等。 +3. 是否启用帧继承 `inherit`,默认为 true。启用时,各属性分别处理,采用类似于 AE 的方式: + 1. 在属性最后一个关键帧后:继承最近关键帧的值。 + 2. 在两个关键帧之间:插值。 + 3. 在属性第一个关键帧前:从**当前基准状态**过渡到第一个关键帧。 + 4. 动画中未涉及的属性:保持不变。 + + 禁用时与 v1 一致:关键帧中未指定的属性取当前基准状态。 +4. 覆盖相对动画预算方式 `relative-calc`,是一个对象,键名是要处理的字段,值可以是 `add`(加法),`multiply`(乘法),`absolute`(绝对),`multiply-with-zero-fallback`(乘法,但是如果被乘数为 0,则视为 1 处理)。 + +以上字段缺省或为非法值时,一律按默认值处理。 + +相对动画重复执行会累积,例如右移 100 执行两次即右移 200。多个并行动画作用于同一属性时,效果叠加。 + +对于旧的动画,即 JSON 文件直接是关键帧数组的,将其称为旧接口,仍然遵循 v1 的行为实现。在编辑器中,选择动画时默认隐藏 v1 动画,引擎可读取,但仅用于兼容旧脚本。 + +实现方案:不分别实现 v1 和 v2。动画在 `AnimationManager.addAnimation` 中统一转换为 v2 格式后写入内核。加载动画文件、`setTempAnimation`、`setTransform`、`changeFigure`、`changeBg` 注册的动画均以此方式处理。 + +- 顶层是对象:按 `version` 处理,缺省为 2。 +- 顶层是数组:v1 动画,设置字段 `version: 1`。 +- `version: 1` 不启用相对动画,不启用帧继承,并保留 v1 的 writeFullEffect 行为:`transformFrom=default` 且非并行时,动画中未涉及的属性重置为 `baseTransform`。转换后的行为应与 v1 等价。 + +对于 `setTempAnimation`,因为一般来说在此处并不方便按照完整的 Animation v2 接口编写动画,因此为了支持在使用类似于 v1 的写法时也能使用 v2 的相对动画、关键帧继承等特性,应当允许传入 `relative`, `inherit` 这样的参数。 + +所有内置动画将重写为 v2,以当前状态为基准,例如 `shake` 围绕当前位置摇动,`exit` 从当前透明度淡出。 From 1e537545a042df8e4b34fde4de628c440e3b6c71 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 4 Oct 2026 00:22:32 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=80=E4=B8=8B=20an?= =?UTF-8?q?imation=20v2=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev-docs/Animation v2.md | 43 +++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/dev-docs/Animation v2.md b/dev-docs/Animation v2.md index f8b47ee20..c72c0ca90 100644 --- a/dev-docs/Animation v2.md +++ b/dev-docs/Animation v2.md @@ -12,34 +12,36 @@ v2 动画的 JSON 是一个对象: "keyframes": [ { "duration": 250, "position": { "x": -100 }, "ease": "easeOut" }, { "duration": 500, "position": { "x": 100 }, "ease": "easeInOut" }, - { "duration": 500, "position": { "x": 0 }, "ease": "easeIn" } - ], - "relative-calc": { - "alpha": "multiply-with-zero-fallback", - "blur": "multiply" - } + { "duration": 250, "position": { "x": 0 }, "ease": "easeIn" } + ] } ``` 该动画使舞台对象围绕当前位置左右摇动一次,最终回到原位。 -关键帧使用持续时间 `duration`(单位为毫秒),代表执行动画到当前关键帧的时间。`ease` 为从上一关键帧到达本帧这一区间所使用的缓动,缺省为 `easeInOut`。同一属性在同一时刻有多个关键帧时发生跳变:以顺序靠前的为插值终点,以顺序靠后的为跳变后的状态。 +关键帧的 `duration`(单位为毫秒)是从上一关键帧到本关键帧的时长。上一关键帧指数组中的前一项,与其指定了哪些属性无关;第一个关键帧的 `duration` 从动画开始计算。`ease` 为从上一关键帧到达本帧这一区间所使用的缓动,缺省为 `easeInOut`。`duration` 为 0 的关键帧与上一关键帧处于同一时刻,构成跳变:以靠前的关键帧为插值终点,以靠后的关键帧为跳变后的状态。 除了关键帧以外,还将加入用于描述动画属性的字段,目前包括: 1. 动画版本 `version`,对于 Animation v2,这个值是 2。 -2. 是否为相对动画 `relative`,默认为 true。为 true 则启用相对动画,帧中的值基于舞台对象的**当前基准状态**变换;为 false 则帧中的值为绝对值。**当前基准状态**遵循 `transformFrom` 及相关参数的设置,一般为 current(当前的变换),如果为 default 则为 `baseTransform`。各属性按性质决定计算方式: - - 加法:`position`、`rotation`、`blur`、`bevel`、`bevelThickness`、`bevelRotation`、`bevelSoftness`、`bloom`、`bloomBlur`。 - - 乘法:`scale`。如果当前基准状态的 scale 为 0,按 1 处理。 - - 始终绝对:其余属性,如 `alpha`、`brightness`、颜色、滤镜开关等。 +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. 动画中未涉及的属性:保持不变。 - 禁用时与 v1 一致:关键帧中未指定的属性取当前基准状态。 -4. 覆盖相对动画预算方式 `relative-calc`,是一个对象,键名是要处理的字段,值可以是 `add`(加法),`multiply`(乘法),`absolute`(绝对),`multiply-with-zero-fallback`(乘法,但是如果被乘数为 0,则视为 1 处理)。 + 禁用时,关键帧中未指定的属性取当前基准状态;在第一个关键帧前,同样从当前基准状态过渡到第一个关键帧。 +4. 覆盖相对动画计算方式 `relativeCalc`,是一个对象,键名为属性名,值为计算方式: + - `add`:基准值加帧中的值。 + - `multiply`:基准值乘帧中的值。 + - `multiplyWithZeroFallback`:同 `multiply`,但基准值为 0 时按 1 处理。 + - `absolute`:直接使用帧中的值。 + + 例如 `"relativeCalc": { "alpha": "multiplyWithZeroFallback" }` 使 `alpha` 按当前透明度的倍数变化。`position`、`scale` 等嵌套属性的设置同时作用于两轴。未指定或值非法的属性按默认计算方式处理。`relative` 为 false 时,此字段不生效。 以上字段缺省或为非法值时,一律按默认值处理。 @@ -50,9 +52,18 @@ v2 动画的 JSON 是一个对象: 实现方案:不分别实现 v1 和 v2。动画在 `AnimationManager.addAnimation` 中统一转换为 v2 格式后写入内核。加载动画文件、`setTempAnimation`、`setTransform`、`changeFigure`、`changeBg` 注册的动画均以此方式处理。 - 顶层是对象:按 `version` 处理,缺省为 2。 -- 顶层是数组:v1 动画,设置字段 `version: 1`。 -- `version: 1` 不启用相对动画,不启用帧继承,并保留 v1 的 writeFullEffect 行为:`transformFrom=default` 且非并行时,动画中未涉及的属性重置为 `baseTransform`。转换后的行为应与 v1 等价。 +- 顶层是数组:v1 动画,以该数组作为 `keyframes`,并设置 `version: 1`。 + +需要与 v1 保持一致的行为,均由 `version: 1` 决定,转换后的行为应与 v1 等价: + +- `relative`、`inherit` 固定为 false,`relativeCalc` 不生效。 +- 禁用帧继承时,在第一个关键帧前保持第一个关键帧的值。 +- 保留 v1 的 writeFullEffect 行为:`transformFrom=default` 且非并行时,动画中未涉及的属性重置为 `baseTransform`。 + +对于 `setTempAnimation`,在此处一般不便按照完整的 Animation v2 接口编写动画。为了在使用类似于 v1 的写法时也能使用相对动画、帧继承等特性,允许传入 `relative`、`inherit` 参数: -对于 `setTempAnimation`,因为一般来说在此处并不方便按照完整的 Animation v2 接口编写动画,因此为了支持在使用类似于 v1 的写法时也能使用 v2 的相对动画、关键帧继承等特性,应当允许传入 `relative`, `inherit` 这样的参数。 +- 传入关键帧数组且未传入这两个参数:按 v1 处理,用于兼容旧脚本。 +- 传入关键帧数组且传入了其中任一参数:以该数组作为 `keyframes` 按 v2 处理,传入的参数覆盖默认值。 +- 传入 v2 对象:传入的参数覆盖对象中对应字段的值。 所有内置动画将重写为 v2,以当前状态为基准,例如 `shake` 围绕当前位置摇动,`exit` 从当前透明度淡出。 From 9eedeaad918263ca03bf84090aee5838ecb95fb4 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 4 Oct 2026 13:42:34 +0800 Subject: [PATCH 07/13] feat: animation v2 --- .../public/game/animation/animationTable.json | 19 +- .../webgal/public/game/animation/blur-v2.json | 9 + .../public/game/animation/dotFilm-v2.json | 9 + .../game/animation/enter-from-bottom-v2.json | 24 +++ .../game/animation/enter-from-left-v2.json | 24 +++ .../game/animation/enter-from-right-v2.json | 24 +++ .../public/game/animation/enter-v2.json | 16 ++ .../webgal/public/game/animation/exit-v2.json | 9 + .../public/game/animation/glitchFilm-v2.json | 9 + .../public/game/animation/godrayFilm-v2.json | 9 + .../animation/move-front-and-back-v2.json | 19 ++ .../public/game/animation/oldFilm-v2.json | 9 + .../game/animation/reflectionFilm-v2.json | 9 + .../public/game/animation/removeFilm-v2.json | 14 ++ .../public/game/animation/rgbFilm-v2.json | 9 + .../public/game/animation/shake-v2.json | 26 +++ .../public/game/animation/shockwaveIn-v2.json | 15 ++ .../game/animation/shockwaveOut-v2.json | 13 ++ .../src/Core/Modules/animationFunctions.ts | 108 ++++++----- .../src/Core/Modules/animationTracks.ts | 137 ++++++++++++++ .../webgal/src/Core/Modules/animations.ts | 103 ++++++++++- .../stage/pixi/animations/timeline.ts | 171 ++++++++---------- .../src/Core/gameScripts/changeBg/index.ts | 5 +- .../src/Core/gameScripts/changeFigure.ts | 5 +- .../src/Core/gameScripts/setTempAnimation.ts | 7 +- .../src/Core/gameScripts/setTransform.ts | 5 +- packages/webgal/src/Core/initializeScript.ts | 6 +- 27 files changed, 635 insertions(+), 178 deletions(-) create mode 100644 packages/webgal/public/game/animation/blur-v2.json create mode 100644 packages/webgal/public/game/animation/dotFilm-v2.json create mode 100644 packages/webgal/public/game/animation/enter-from-bottom-v2.json create mode 100644 packages/webgal/public/game/animation/enter-from-left-v2.json create mode 100644 packages/webgal/public/game/animation/enter-from-right-v2.json create mode 100644 packages/webgal/public/game/animation/enter-v2.json create mode 100644 packages/webgal/public/game/animation/exit-v2.json create mode 100644 packages/webgal/public/game/animation/glitchFilm-v2.json create mode 100644 packages/webgal/public/game/animation/godrayFilm-v2.json create mode 100644 packages/webgal/public/game/animation/move-front-and-back-v2.json create mode 100644 packages/webgal/public/game/animation/oldFilm-v2.json create mode 100644 packages/webgal/public/game/animation/reflectionFilm-v2.json create mode 100644 packages/webgal/public/game/animation/removeFilm-v2.json create mode 100644 packages/webgal/public/game/animation/rgbFilm-v2.json create mode 100644 packages/webgal/public/game/animation/shake-v2.json create mode 100644 packages/webgal/public/game/animation/shockwaveIn-v2.json create mode 100644 packages/webgal/public/game/animation/shockwaveOut-v2.json create mode 100644 packages/webgal/src/Core/Modules/animationTracks.ts 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..55ab5e66a --- /dev/null +++ b/packages/webgal/public/game/animation/blur-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..3c95a58f4 --- /dev/null +++ b/packages/webgal/public/game/animation/dotFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..944121933 --- /dev/null +++ b/packages/webgal/public/game/animation/enter-from-bottom-v2.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "relativeCalc": { + "alpha": "multiplyWithZeroFallback" + }, + "keyframes": [ + { + "duration": 0, + "alpha": 0, + "position": { + "y": 50 + }, + "blur": 5 + }, + { + "duration": 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..6fe6e667c --- /dev/null +++ b/packages/webgal/public/game/animation/enter-from-left-v2.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "relativeCalc": { + "alpha": "multiplyWithZeroFallback" + }, + "keyframes": [ + { + "duration": 0, + "alpha": 0, + "position": { + "x": -50 + }, + "blur": 5 + }, + { + "duration": 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..6820ba6fc --- /dev/null +++ b/packages/webgal/public/game/animation/enter-from-right-v2.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "relativeCalc": { + "alpha": "multiplyWithZeroFallback" + }, + "keyframes": [ + { + "duration": 0, + "alpha": 0, + "position": { + "x": 50 + }, + "blur": 5 + }, + { + "duration": 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..8435e148c --- /dev/null +++ b/packages/webgal/public/game/animation/enter-v2.json @@ -0,0 +1,16 @@ +{ + "version": 2, + "relativeCalc": { + "alpha": "multiplyWithZeroFallback" + }, + "keyframes": [ + { + "duration": 0, + "alpha": 0 + }, + { + "duration": 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..b7f19c7f0 --- /dev/null +++ b/packages/webgal/public/game/animation/exit-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..1fa22d23f --- /dev/null +++ b/packages/webgal/public/game/animation/glitchFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..5e4770a5a --- /dev/null +++ b/packages/webgal/public/game/animation/godrayFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..e12c7a88e --- /dev/null +++ b/packages/webgal/public/game/animation/move-front-and-back-v2.json @@ -0,0 +1,19 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 500, + "scale": { + "x": 1.15, + "y": 1.15 + } + }, + { + "duration": 500, + "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..75fb2cbd9 --- /dev/null +++ b/packages/webgal/public/game/animation/oldFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..ad1307fb4 --- /dev/null +++ b/packages/webgal/public/game/animation/reflectionFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..00e609bbc --- /dev/null +++ b/packages/webgal/public/game/animation/removeFilm-v2.json @@ -0,0 +1,14 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..70ffb9ba8 --- /dev/null +++ b/packages/webgal/public/game/animation/rgbFilm-v2.json @@ -0,0 +1,9 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 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..4cd3eedf5 --- /dev/null +++ b/packages/webgal/public/game/animation/shake-v2.json @@ -0,0 +1,26 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 250, + "position": { + "x": -100 + }, + "ease": "easeOut" + }, + { + "duration": 500, + "position": { + "x": 100 + }, + "ease": "easeInOut" + }, + { + "duration": 250, + "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..99ab7e023 --- /dev/null +++ b/packages/webgal/public/game/animation/shockwaveIn-v2.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 0, + "shockwaveFilter": 0, + "radiusAlphaFilter": 0 + }, + { + "duration": 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..fa129ca6b --- /dev/null +++ b/packages/webgal/public/game/animation/shockwaveOut-v2.json @@ -0,0 +1,13 @@ +{ + "version": 2, + "keyframes": [ + { + "duration": 0, + "shockwaveFilter": 0 + }, + { + "duration": 2000, + "shockwaveFilter": 3 + } + ] +} diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index e46dd5b9e..906cbd4b7 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,78 +19,72 @@ 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; - - 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: '' }); - } +): IAnimationTrack[] | null { + const targetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target); + const currentTransform = targetEffect?.transform ?? baseTransform; + return getAnimationTracks(animationName, currentTransform, writeDefault, writeFullEffect); +} - PixiStage.assignTransform(newEffect, effect, false); - newEffect.duration = effect.duration; - newEffect.ease = effect.ease; - return newEffect; - }); - logger.debug('装载自定义动画', mappedEffects); - return mappedEffects; - } - return null; +/** + * @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; } export function getAnimateDuration(animationName: string) { - const effect = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); - if (effect) { + const userAnimation = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); + if (userAnimation) { let duration = 0; - effect.effects.forEach((e) => { - duration += e.duration; + userAnimation.animation.keyframes.forEach((frame) => { + duration += frame.duration; }); return duration; } diff --git a/packages/webgal/src/Core/Modules/animationTracks.ts b/packages/webgal/src/Core/Modules/animationTracks.ts new file mode 100644 index 000000000..568025b38 --- /dev/null +++ b/packages/webgal/src/Core/Modules/animationTracks.ts @@ -0,0 +1,137 @@ +/** + * @file 把动画拆成每个属性一条的关键帧轨道。 + * + * 相对动画、帧继承以及 v1 / v2 的行为差异都在这里处理,产出的轨道中全部是绝对值, + * 播放时(见 generateTimelineObj)只需按时间在轨道上插值。 + */ +import get from 'lodash/get'; +import set from 'lodash/set'; +import { baseTransform, ITransform } from '@/Core/Modules/stage/stageInterface'; +import { AnimationFrame, IAnimationV2, 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[] = []; + let time = 0; + for (const frame of animation.keyframes) { + time += frame.duration; + if (hasValue(frame, path)) { + points.push({ time, value: applyRelativeCalc(calc, baseValue, get(frame, path)), ease: frame.ease }); + } else if (!animation.inherit) { + // 不继承时,关键帧中未指定的属性取当前基准状态;继承时直接跳过,由前后关键帧插值 + points.push({ 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: AnimationFrame, 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..05710575e 100644 --- a/packages/webgal/src/Core/Modules/animations.ts +++ b/packages/webgal/src/Core/Modules/animations.ts @@ -1,21 +1,112 @@ import { ITransform } from '@/Core/Modules/stage/stageInterface'; +import pickBy from 'lodash/pickBy'; + +/** + * 关键帧。duration 为从上一关键帧到本帧的时长(毫秒),ease 为到达本帧这一区间所用的缓动 + */ +export type AnimationFrame = ITransform & { duration: 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>; + keyframes: AnimationFrame[]; +} 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 处理 + if (Array.isArray(rawAnimation)) { + const hasOverrides = isBoolean(overrides.relative) || isBoolean(overrides.inherit); + return hasOverrides ? toV2({ keyframes: rawAnimation }, overrides) : toV1(rawAnimation); + } + // 顶层是对象:按 version 处理,缺省或非法时为 2 + if (isObject(rawAnimation)) { + return rawAnimation.version === 1 ? toV1(rawAnimation.keyframes) : toV2(rawAnimation, overrides); + } + return toV1([]); +} + +function toV1(keyframes: unknown): IAnimationV2 { + // v1 中帧的值都是绝对值,也没有帧继承 + return { version: 1, relative: false, inherit: false, relativeCalc: {}, keyframes: normalizeKeyframes(keyframes) }; +} + +function toV2(rawAnimation: Record, 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: normalizeKeyframes(rawAnimation.keyframes), + }; +} + +function normalizeKeyframes(keyframes: unknown): AnimationFrame[] { + if (!Array.isArray(keyframes)) return []; + return keyframes.filter(isObject).map((frame) => { + const duration = typeof frame.duration === 'number' && frame.duration >= 0 ? frame.duration : 0; + const ease = typeof frame.ease === 'string' ? frame.ease : 'easeInOut'; + return { ...frame, duration, ease } as AnimationFrame; + }); +} + +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..de78f747e 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 时刻的值 + */ +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/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..f59e538a8 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -3,7 +3,6 @@ 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'; @@ -22,8 +21,10 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { } catch (e) { animationObj = []; } - const newAnimation: IUserAnimation = { name: animationName, effects: animationObj }; - WebGAL.animationManager.addAnimation(newAnimation); + // 传入 relative 或 inherit 时,关键帧数组也按 v2 处理;v2 对象中的对应字段会被覆盖 + const relative = getBooleanArgByKey(sentence, 'relative'); + const inherit = getBooleanArgByKey(sentence, 'inherit'); + WebGAL.animationManager.addAnimation(animationName, animationObj, { relative, inherit }); const animationDuration = getAnimateDuration(animationName); const target = getStringArgByKey(sentence, 'target') ?? '0'; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; 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); } }); } From efaa741b7a1e963050eeb9e5a9c30388db73a7a9 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 4 Oct 2026 14:53:41 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8E=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E9=A2=84=E8=A7=88=E7=9B=B8=E5=85=B3=E7=9A=84?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stage/pixi/animations/timeline.ts | 2 +- .../util/syncWithEditor/previewSyncRuntime.ts | 17 +++++++++++- .../runtime/previewSeekAnimation.ts | 26 +++++++++++++++++++ .../webgal/src/types/editorPreviewProtocol.ts | 11 ++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts 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 de78f747e..5d7c7b92e 100644 --- a/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts +++ b/packages/webgal/src/Core/controller/stage/pixi/animations/timeline.ts @@ -73,7 +73,7 @@ export function generateTimelineObj(tracks: IAnimationTrack[], targetKey: string /** * 取轨道在 time 时刻的值 */ -function sampleTrack(points: ITrackPoint[], time: number): number { +export function sampleTrack(points: ITrackPoint[], time: number): number { // 本区间的插值终点:第一个晚于 time 的关键点 const nextIndex = points.findIndex((point) => point.time > time); // 在最后一个关键点之后:保持最后的值 diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index 2f0864333..ed3e65fae 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); + 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..10d970a43 --- /dev/null +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts @@ -0,0 +1,26 @@ +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 { SeekAnimationPayload } from '@/types/editorPreviewProtocol'; + +/** + * 把动画在 time 时刻的状态写入目标,供编辑器拖动时间轴时预览。 + * 以演算状态中目标的变换为基准状态,结果只写入容器,不写入舞台状态 + */ +export function seekAnimationPreview({ target, animation, time }: SeekAnimationPayload) { + const targetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target); + const base = merge(cloneDeep(baseTransform), 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..ffb686629 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -127,6 +127,16 @@ export interface SetEffectPayload { phase?: SetEffectPhase; } +/** + * 把动画在 time 时刻的状态写入目标,仅用于预览,不写入舞台状态。 + * animation 为动画 JSON(v1 关键帧数组或 v2 对象),以目标的当前变换为基准状态 + */ +export interface SeekAnimationPayload { + target: string; + animation: JsonValue; + time: number; +} + export interface SetFontOptimizationPayload { enabled: boolean; } @@ -194,6 +204,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; From 26e7acdf3c8e1e39808f4ca8079e4e9ff2c5c3f9 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 4 Oct 2026 15:36:23 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E9=A2=84=E8=A7=88=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../util/syncWithEditor/runtime/previewSeekAnimation.ts | 6 +++--- packages/webgal/src/types/editorPreviewProtocol.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts index 10d970a43..b91e7c2db 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts @@ -12,11 +12,11 @@ import type { SeekAnimationPayload } from '@/types/editorPreviewProtocol'; /** * 把动画在 time 时刻的状态写入目标,供编辑器拖动时间轴时预览。 - * 以演算状态中目标的变换为基准状态,结果只写入容器,不写入舞台状态 + * 基准状态优先取编辑器给出的 baseTransform,否则取演算状态中目标的变换;结果只写入容器,不写入舞台状态 */ -export function seekAnimationPreview({ target, animation, time }: SeekAnimationPayload) { +export function seekAnimationPreview({ target, animation, time, baseTransform: previewBase }: SeekAnimationPayload) { const targetEffect = stageStateManager.getCalculationStageState().effects.find((e) => e.target === target); - const base = merge(cloneDeep(baseTransform), targetEffect?.transform); + const base = merge(cloneDeep(baseTransform), previewBase ?? targetEffect?.transform); const tracks = buildAnimationTracks(normalizeAnimation(animation, {}), { base, writeFullEffect: false }); // 从基准状态出发写入各轨道的值,这样动画中删掉的属性也会回到基准状态 const state = cloneDeep(base); diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index ffb686629..a4fc3431e 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -129,12 +129,14 @@ export interface SetEffectPayload { /** * 把动画在 time 时刻的状态写入目标,仅用于预览,不写入舞台状态。 - * animation 为动画 JSON(v1 关键帧数组或 v2 对象),以目标的当前变换为基准状态 + * animation 为动画 JSON(v1 关键帧数组或 v2 对象) */ export interface SeekAnimationPayload { target: string; animation: JsonValue; time: number; + /** 动画开始前目标的状态,与默认变换合并后作为当前基准状态;缺省时取目标在演算状态中的变换 */ + baseTransform?: Transform; } export interface SetFontOptimizationPayload { From 04b30a74b032ff19e718801a6ebf4c211a7e47ee Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 4 Oct 2026 18:45:04 +0800 Subject: [PATCH 10/13] =?UTF-8?q?=E5=A4=9A=E6=AE=B5=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=E5=AF=B9=20animation=20v2=20=E7=9A=84=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E8=B0=83=E8=AF=95=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/util/syncWithEditor/previewSyncRuntime.ts | 2 +- .../syncWithEditor/runtime/previewSeekAnimation.ts | 14 ++++++++++---- packages/webgal/src/types/editorPreviewProtocol.ts | 5 ++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts index ed3e65fae..1c0d759ed 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/previewSyncRuntime.ts @@ -447,7 +447,7 @@ export const startPreviewSyncRuntime = () => { return {}; }, 'preview.command.seek-animation': (payload: SeekAnimationPayload) => { - seekAnimationPreview(payload); + seekAnimationPreview(payload, targetTransformBaselines.getReadyTransformBaselineOverride(payload.target)); return {}; }, }; diff --git a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts index b91e7c2db..d0721aab1 100644 --- a/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts +++ b/packages/webgal/src/Core/util/syncWithEditor/runtime/previewSeekAnimation.ts @@ -8,15 +8,21 @@ 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,否则取演算状态中目标的变换;结果只写入容器,不写入舞台状态 + * 把动画在 time 时刻的状态写入目标,供编辑器拖动时间轴时预览;结果只写入容器,不写入舞台状态。 + * 基准状态依次取:编辑器给出的 baseTransform、目标语句执行前的基线(编辑场景中的动画语句时, + * 预览已执行完这条语句,演算状态是动画的终态)、演算状态中目标的变换 + * @param baselineOverride 目标语句执行前目标的变换,见 targetTransformBaseline */ -export function seekAnimationPreview({ target, animation, time, baseTransform: previewBase }: SeekAnimationPayload) { +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 ?? targetEffect?.transform); + const base = merge(cloneDeep(baseTransform), previewBase ?? baselineOverride ?? targetEffect?.transform); const tracks = buildAnimationTracks(normalizeAnimation(animation, {}), { base, writeFullEffect: false }); // 从基准状态出发写入各轨道的值,这样动画中删掉的属性也会回到基准状态 const state = cloneDeep(base); diff --git a/packages/webgal/src/types/editorPreviewProtocol.ts b/packages/webgal/src/types/editorPreviewProtocol.ts index a4fc3431e..57c1bbdb6 100644 --- a/packages/webgal/src/types/editorPreviewProtocol.ts +++ b/packages/webgal/src/types/editorPreviewProtocol.ts @@ -135,7 +135,10 @@ export interface SeekAnimationPayload { target: string; animation: JsonValue; time: number; - /** 动画开始前目标的状态,与默认变换合并后作为当前基准状态;缺省时取目标在演算状态中的变换 */ + /** + * 动画开始前目标的状态,与默认变换合并后作为当前基准状态。 + * 缺省时取带 transformBaselineRevision 的 sync-scene 记录的目标语句执行前的变换,再缺省时取演算状态中的变换 + */ baseTransform?: Transform; } From 8b0f223a36007807c63f8b57c1d05be53cfadd4d Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 4 Oct 2026 19:19:05 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=E7=9B=B8=E5=AF=B9=E5=8A=A8?= =?UTF-8?q?=E7=94=BB=E6=BC=94=E5=87=BA=E6=81=A2=E5=A4=8D=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Core/Modules/animationFunctions.ts | 7 +++ .../controller/storage/jumpFromBacklog.ts | 43 +++++++++++++++++-- .../src/Core/gameScripts/setTempAnimation.ts | 29 ++++++++----- 3 files changed, 65 insertions(+), 14 deletions(-) diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index 906cbd4b7..1f6d4a217 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -79,6 +79,13 @@ function getAnimationTracks( return tracks; } +/** + * 是否为相对动画。相对动画的结果依赖执行时的舞台状态,重复执行会叠加 + */ +export function isRelativeAnimation(animationName: string) { + return WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName)?.animation.relative ?? false; +} + export function getAnimateDuration(animationName: string) { const userAnimation = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); if (userAnimation) { 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/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index f59e538a8..7fa2ceb7d 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -7,6 +7,7 @@ import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/anima import { WebGAL } from '@/Core/WebGAL'; import { v4 as uuid } from 'uuid'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; +import { IAnimationV2, normalizeAnimation } from '@/Core/Modules/animations'; /** * 设置临时动画 @@ -14,17 +15,7 @@ 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 = []; - } - // 传入 relative 或 inherit 时,关键帧数组也按 v2 处理;v2 对象中的对应字段会被覆盖 - const relative = getBooleanArgByKey(sentence, 'relative'); - const inherit = getBooleanArgByKey(sentence, 'inherit'); - WebGAL.animationManager.addAnimation(animationName, animationObj, { relative, inherit }); + WebGAL.animationManager.addAnimation(animationName, parseTempAnimation(sentence)); const animationDuration = getAnimateDuration(animationName); const target = getStringArgByKey(sentence, 'target') ?? '0'; const keep = getBooleanArgByKey(sentence, 'keep') ?? false; @@ -71,3 +62,19 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { blockingAuto: () => !keep, }; }; + +/** + * 解析语句中的临时动画。 + * 传入 relative 或 inherit 时,关键帧数组也按 v2 处理;v2 对象中的对应字段会被覆盖 + */ +export function parseTempAnimation(sentence: ISentence): IAnimationV2 { + let animationObj; + try { + animationObj = JSON.parse(sentence.content); + } catch (e) { + animationObj = []; + } + const relative = getBooleanArgByKey(sentence, 'relative'); + const inherit = getBooleanArgByKey(sentence, 'inherit'); + return normalizeAnimation(animationObj, { relative, inherit }); +} From a8c6705c895f54e90757094a4a84c9ac2abb869c Mon Sep 17 00:00:00 2001 From: Mahiru Date: Sun, 4 Oct 2026 20:57:50 +0800 Subject: [PATCH 12/13] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=20time=20?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=20duration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dev-docs/Animation v2.md | 14 ++-- .../webgal/public/game/animation/blur-v2.json | 2 +- .../public/game/animation/dotFilm-v2.json | 2 +- .../game/animation/enter-from-bottom-v2.json | 4 +- .../game/animation/enter-from-left-v2.json | 4 +- .../game/animation/enter-from-right-v2.json | 4 +- .../public/game/animation/enter-v2.json | 4 +- .../webgal/public/game/animation/exit-v2.json | 2 +- .../public/game/animation/glitchFilm-v2.json | 2 +- .../public/game/animation/godrayFilm-v2.json | 2 +- .../animation/move-front-and-back-v2.json | 4 +- .../public/game/animation/oldFilm-v2.json | 2 +- .../game/animation/reflectionFilm-v2.json | 2 +- .../public/game/animation/removeFilm-v2.json | 2 +- .../public/game/animation/rgbFilm-v2.json | 2 +- .../public/game/animation/shake-v2.json | 6 +- .../public/game/animation/shockwaveIn-v2.json | 4 +- .../game/animation/shockwaveOut-v2.json | 4 +- .../src/Core/Modules/animationFunctions.ts | 8 +-- .../src/Core/Modules/animationTracks.ts | 10 ++- .../webgal/src/Core/Modules/animations.ts | 69 +++++++++++++++---- .../src/Core/gameScripts/setTempAnimation.ts | 25 ++++--- 22 files changed, 113 insertions(+), 65 deletions(-) diff --git a/dev-docs/Animation v2.md b/dev-docs/Animation v2.md index c72c0ca90..115151642 100644 --- a/dev-docs/Animation v2.md +++ b/dev-docs/Animation v2.md @@ -10,16 +10,16 @@ v2 动画的 JSON 是一个对象: "relative": true, "inherit": true, "keyframes": [ - { "duration": 250, "position": { "x": -100 }, "ease": "easeOut" }, - { "duration": 500, "position": { "x": 100 }, "ease": "easeInOut" }, - { "duration": 250, "position": { "x": 0 }, "ease": "easeIn" } + { "time": 250, "position": { "x": -100 }, "ease": "easeOut" }, + { "time": 750, "position": { "x": 100 }, "ease": "easeInOut" }, + { "time": 1000, "position": { "x": 0 }, "ease": "easeIn" } ] } ``` 该动画使舞台对象围绕当前位置左右摇动一次,最终回到原位。 -关键帧的 `duration`(单位为毫秒)是从上一关键帧到本关键帧的时长。上一关键帧指数组中的前一项,与其指定了哪些属性无关;第一个关键帧的 `duration` 从动画开始计算。`ease` 为从上一关键帧到达本帧这一区间所使用的缓动,缺省为 `easeInOut`。`duration` 为 0 的关键帧与上一关键帧处于同一时刻,构成跳变:以靠前的关键帧为插值终点,以靠后的关键帧为跳变后的状态。 +关键帧的 `time`(单位为毫秒)是该关键帧在动画中所处的时间,从动画开始计算,与 AE 中关键帧所在的时间相同。关键帧按 `time` 先后排列,与其在数组中的位置无关;动画的总时长为最大的 `time`。`time` 缺省或非法(不是数字或小于 0)时按 0 处理。`ease` 为同一属性从上一关键帧到达本帧这一区间所使用的缓动,缺省为 `easeInOut`。`time` 相同的多个关键帧处于同一时刻,构成跳变:数组中靠前的关键帧为插值终点,靠后的关键帧为跳变后的状态。 除了关键帧以外,还将加入用于描述动画属性的字段,目前包括: @@ -51,9 +51,11 @@ v2 动画的 JSON 是一个对象: 实现方案:不分别实现 v1 和 v2。动画在 `AnimationManager.addAnimation` 中统一转换为 v2 格式后写入内核。加载动画文件、`setTempAnimation`、`setTransform`、`changeFigure`、`changeBg` 注册的动画均以此方式处理。 -- 顶层是对象:按 `version` 处理,缺省为 2。 +- 顶层是对象:按 `version` 处理,缺省为 2。`version` 为 1 时,关键帧与 v1 一样使用 `duration`。 - 顶层是数组:v1 动画,以该数组作为 `keyframes`,并设置 `version: 1`。 +v1 关键帧的 `duration`(单位为毫秒)是从上一关键帧到本关键帧的时长,第一个关键帧从动画开始计算。转换时依次累加,得到每个关键帧的 `time`。 + 需要与 v1 保持一致的行为,均由 `version: 1` 决定,转换后的行为应与 v1 等价: - `relative`、`inherit` 固定为 false,`relativeCalc` 不生效。 @@ -63,7 +65,7 @@ v2 动画的 JSON 是一个对象: 对于 `setTempAnimation`,在此处一般不便按照完整的 Animation v2 接口编写动画。为了在使用类似于 v1 的写法时也能使用相对动画、帧继承等特性,允许传入 `relative`、`inherit` 参数: - 传入关键帧数组且未传入这两个参数:按 v1 处理,用于兼容旧脚本。 -- 传入关键帧数组且传入了其中任一参数:以该数组作为 `keyframes` 按 v2 处理,传入的参数覆盖默认值。 +- 传入关键帧数组且传入了其中任一参数:以该数组作为 `keyframes` 按 v2 处理,传入的参数覆盖默认值。数组中的关键帧沿用 v1 的写法,使用 `duration`。 - 传入 v2 对象:传入的参数覆盖对象中对应字段的值。 所有内置动画将重写为 v2,以当前状态为基准,例如 `shake` 围绕当前位置摇动,`exit` 从当前透明度淡出。 diff --git a/packages/webgal/public/game/animation/blur-v2.json b/packages/webgal/public/game/animation/blur-v2.json index 55ab5e66a..e74972c13 100644 --- a/packages/webgal/public/game/animation/blur-v2.json +++ b/packages/webgal/public/game/animation/blur-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 300, + "time": 300, "blur": 5 } ] diff --git a/packages/webgal/public/game/animation/dotFilm-v2.json b/packages/webgal/public/game/animation/dotFilm-v2.json index 3c95a58f4..bb308d1e3 100644 --- a/packages/webgal/public/game/animation/dotFilm-v2.json +++ b/packages/webgal/public/game/animation/dotFilm-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 0, + "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 index 944121933..b06849e39 100644 --- a/packages/webgal/public/game/animation/enter-from-bottom-v2.json +++ b/packages/webgal/public/game/animation/enter-from-bottom-v2.json @@ -5,7 +5,7 @@ }, "keyframes": [ { - "duration": 0, + "time": 0, "alpha": 0, "position": { "y": 50 @@ -13,7 +13,7 @@ "blur": 5 }, { - "duration": 500, + "time": 500, "alpha": 1, "position": { "y": 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 index 6fe6e667c..2b9b979da 100644 --- a/packages/webgal/public/game/animation/enter-from-left-v2.json +++ b/packages/webgal/public/game/animation/enter-from-left-v2.json @@ -5,7 +5,7 @@ }, "keyframes": [ { - "duration": 0, + "time": 0, "alpha": 0, "position": { "x": -50 @@ -13,7 +13,7 @@ "blur": 5 }, { - "duration": 500, + "time": 500, "alpha": 1, "position": { "x": 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 index 6820ba6fc..07ebb05e1 100644 --- a/packages/webgal/public/game/animation/enter-from-right-v2.json +++ b/packages/webgal/public/game/animation/enter-from-right-v2.json @@ -5,7 +5,7 @@ }, "keyframes": [ { - "duration": 0, + "time": 0, "alpha": 0, "position": { "x": 50 @@ -13,7 +13,7 @@ "blur": 5 }, { - "duration": 500, + "time": 500, "alpha": 1, "position": { "x": 0 diff --git a/packages/webgal/public/game/animation/enter-v2.json b/packages/webgal/public/game/animation/enter-v2.json index 8435e148c..1b5473a53 100644 --- a/packages/webgal/public/game/animation/enter-v2.json +++ b/packages/webgal/public/game/animation/enter-v2.json @@ -5,11 +5,11 @@ }, "keyframes": [ { - "duration": 0, + "time": 0, "alpha": 0 }, { - "duration": 300, + "time": 300, "alpha": 1 } ] diff --git a/packages/webgal/public/game/animation/exit-v2.json b/packages/webgal/public/game/animation/exit-v2.json index b7f19c7f0..03a743168 100644 --- a/packages/webgal/public/game/animation/exit-v2.json +++ b/packages/webgal/public/game/animation/exit-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 300, + "time": 300, "alpha": 0 } ] diff --git a/packages/webgal/public/game/animation/glitchFilm-v2.json b/packages/webgal/public/game/animation/glitchFilm-v2.json index 1fa22d23f..7efc3cb15 100644 --- a/packages/webgal/public/game/animation/glitchFilm-v2.json +++ b/packages/webgal/public/game/animation/glitchFilm-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 0, + "time": 0, "glitchFilm": 1 } ] diff --git a/packages/webgal/public/game/animation/godrayFilm-v2.json b/packages/webgal/public/game/animation/godrayFilm-v2.json index 5e4770a5a..e71e67304 100644 --- a/packages/webgal/public/game/animation/godrayFilm-v2.json +++ b/packages/webgal/public/game/animation/godrayFilm-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 0, + "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 index e12c7a88e..95ce4d32d 100644 --- a/packages/webgal/public/game/animation/move-front-and-back-v2.json +++ b/packages/webgal/public/game/animation/move-front-and-back-v2.json @@ -2,14 +2,14 @@ "version": 2, "keyframes": [ { - "duration": 500, + "time": 500, "scale": { "x": 1.15, "y": 1.15 } }, { - "duration": 500, + "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 index 75fb2cbd9..2662d645f 100644 --- a/packages/webgal/public/game/animation/oldFilm-v2.json +++ b/packages/webgal/public/game/animation/oldFilm-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 0, + "time": 0, "oldFilm": 1 } ] diff --git a/packages/webgal/public/game/animation/reflectionFilm-v2.json b/packages/webgal/public/game/animation/reflectionFilm-v2.json index ad1307fb4..eaed81193 100644 --- a/packages/webgal/public/game/animation/reflectionFilm-v2.json +++ b/packages/webgal/public/game/animation/reflectionFilm-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 0, + "time": 0, "reflectionFilm": 1 } ] diff --git a/packages/webgal/public/game/animation/removeFilm-v2.json b/packages/webgal/public/game/animation/removeFilm-v2.json index 00e609bbc..0ca8de6ba 100644 --- a/packages/webgal/public/game/animation/removeFilm-v2.json +++ b/packages/webgal/public/game/animation/removeFilm-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 0, + "time": 0, "oldFilm": 0, "dotFilm": 0, "reflectionFilm": 0, diff --git a/packages/webgal/public/game/animation/rgbFilm-v2.json b/packages/webgal/public/game/animation/rgbFilm-v2.json index 70ffb9ba8..1a5e35d06 100644 --- a/packages/webgal/public/game/animation/rgbFilm-v2.json +++ b/packages/webgal/public/game/animation/rgbFilm-v2.json @@ -2,7 +2,7 @@ "version": 2, "keyframes": [ { - "duration": 0, + "time": 0, "rgbFilm": 1 } ] diff --git a/packages/webgal/public/game/animation/shake-v2.json b/packages/webgal/public/game/animation/shake-v2.json index 4cd3eedf5..810442d4c 100644 --- a/packages/webgal/public/game/animation/shake-v2.json +++ b/packages/webgal/public/game/animation/shake-v2.json @@ -2,21 +2,21 @@ "version": 2, "keyframes": [ { - "duration": 250, + "time": 250, "position": { "x": -100 }, "ease": "easeOut" }, { - "duration": 500, + "time": 750, "position": { "x": 100 }, "ease": "easeInOut" }, { - "duration": 250, + "time": 1000, "position": { "x": 0 }, diff --git a/packages/webgal/public/game/animation/shockwaveIn-v2.json b/packages/webgal/public/game/animation/shockwaveIn-v2.json index 99ab7e023..798c9b178 100644 --- a/packages/webgal/public/game/animation/shockwaveIn-v2.json +++ b/packages/webgal/public/game/animation/shockwaveIn-v2.json @@ -2,12 +2,12 @@ "version": 2, "keyframes": [ { - "duration": 0, + "time": 0, "shockwaveFilter": 0, "radiusAlphaFilter": 0 }, { - "duration": 2000, + "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 index fa129ca6b..02176a17c 100644 --- a/packages/webgal/public/game/animation/shockwaveOut-v2.json +++ b/packages/webgal/public/game/animation/shockwaveOut-v2.json @@ -2,11 +2,11 @@ "version": 2, "keyframes": [ { - "duration": 0, + "time": 0, "shockwaveFilter": 0 }, { - "duration": 2000, + "time": 2000, "shockwaveFilter": 3 } ] diff --git a/packages/webgal/src/Core/Modules/animationFunctions.ts b/packages/webgal/src/Core/Modules/animationFunctions.ts index 1f6d4a217..7ac002f46 100644 --- a/packages/webgal/src/Core/Modules/animationFunctions.ts +++ b/packages/webgal/src/Core/Modules/animationFunctions.ts @@ -89,11 +89,9 @@ export function isRelativeAnimation(animationName: string) { export function getAnimateDuration(animationName: string) { const userAnimation = WebGAL.animationManager.getAnimations().find((ani) => ani.name === animationName); if (userAnimation) { - let duration = 0; - userAnimation.animation.keyframes.forEach((frame) => { - duration += frame.duration; - }); - return duration; + // 关键帧已按时间排序,最后一帧的时间就是动画时长 + 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 index 568025b38..c8b04c978 100644 --- a/packages/webgal/src/Core/Modules/animationTracks.ts +++ b/packages/webgal/src/Core/Modules/animationTracks.ts @@ -7,7 +7,7 @@ import get from 'lodash/get'; import set from 'lodash/set'; import { baseTransform, ITransform } from '@/Core/Modules/stage/stageInterface'; -import { AnimationFrame, IAnimationV2, RelativeCalc } from '@/Core/Modules/animations'; +import { IAnimationV2, Keyframe, RelativeCalc } from '@/Core/Modules/animations'; /** * 轨道上的关键点 @@ -85,14 +85,12 @@ function getAnimatedPaths(animation: IAnimationV2, writeFullEffect: boolean): st function buildTrackPoints(animation: IAnimationV2, path: string, baseValue: number): ITrackPoint[] { const calc = getRelativeCalc(animation, path); const points: ITrackPoint[] = []; - let time = 0; for (const frame of animation.keyframes) { - time += frame.duration; if (hasValue(frame, path)) { - points.push({ time, value: applyRelativeCalc(calc, baseValue, get(frame, path)), ease: frame.ease }); + points.push({ time: frame.time, value: applyRelativeCalc(calc, baseValue, get(frame, path)), ease: frame.ease }); } else if (!animation.inherit) { // 不继承时,关键帧中未指定的属性取当前基准状态;继承时直接跳过,由前后关键帧插值 - points.push({ time, value: baseValue, ease: frame.ease }); + points.push({ time: frame.time, value: baseValue, ease: frame.ease }); } } // 第一个关键帧之前:v2 从当前基准状态过渡到第一个关键帧; @@ -125,7 +123,7 @@ function applyRelativeCalc(calc: RelativeCalc, baseValue: number, frameValue: nu } } -function hasValue(frame: AnimationFrame, path: string): boolean { +function hasValue(frame: Keyframe, path: string): boolean { return typeof get(frame, path) === 'number'; } diff --git a/packages/webgal/src/Core/Modules/animations.ts b/packages/webgal/src/Core/Modules/animations.ts index 05710575e..212677144 100644 --- a/packages/webgal/src/Core/Modules/animations.ts +++ b/packages/webgal/src/Core/Modules/animations.ts @@ -2,10 +2,15 @@ import { ITransform } from '@/Core/Modules/stage/stageInterface'; import pickBy from 'lodash/pickBy'; /** - * 关键帧。duration 为从上一关键帧到本帧的时长(毫秒),ease 为到达本帧这一区间所用的缓动 + * v1 写法的关键帧。duration 为从上一关键帧到本帧的时长(毫秒),ease 为到达本帧这一区间所用的缓动 */ export type AnimationFrame = ITransform & { duration: number; ease: string }; +/** + * 统一后的关键帧。time 为该帧在动画中所处的时间(毫秒,从动画开始计算),ease 为同一属性到达本帧所用的缓动 + */ +export type Keyframe = ITransform & { time: number; ease: string }; + /** * 相对动画中,帧中的值与当前基准状态的计算方式 */ @@ -25,7 +30,8 @@ export interface IAnimationV2 { inherit: boolean; /** 按属性名覆盖相对动画的计算方式,只包含合法值 */ relativeCalc: Partial>; - keyframes: AnimationFrame[]; + /** 按 time 先后排列 */ + keyframes: Keyframe[]; } export interface IUserAnimation { @@ -58,41 +64,76 @@ export class AnimationManager { 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: rawAnimation }, overrides) : toV1(rawAnimation); + return hasOverrides ? toV2({}, keyframes, overrides) : toV1(keyframes); } // 顶层是对象:按 version 处理,缺省或非法时为 2 if (isObject(rawAnimation)) { - return rawAnimation.version === 1 ? toV1(rawAnimation.keyframes) : toV2(rawAnimation, overrides); + if (rawAnimation.version === 1) return toV1(keyframesFromDurations(rawAnimation.keyframes)); + return toV2(rawAnimation, keyframesFromTimes(rawAnimation.keyframes), overrides); } return toV1([]); } -function toV1(keyframes: unknown): IAnimationV2 { +function toV1(keyframes: Keyframe[]): IAnimationV2 { // v1 中帧的值都是绝对值,也没有帧继承 - return { version: 1, relative: false, inherit: false, relativeCalc: {}, keyframes: normalizeKeyframes(keyframes) }; + return { version: 1, relative: false, inherit: false, relativeCalc: {}, keyframes }; } -function toV2(rawAnimation: Record, overrides: IAnimationOverrides): IAnimationV2 { +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: normalizeKeyframes(rawAnimation.keyframes), + keyframes, }; } -function normalizeKeyframes(keyframes: unknown): AnimationFrame[] { - if (!Array.isArray(keyframes)) return []; - return keyframes.filter(isObject).map((frame) => { - const duration = typeof frame.duration === 'number' && frame.duration >= 0 ? frame.duration : 0; - const ease = typeof frame.ease === 'string' ? frame.ease : 'easeInOut'; - return { ...frame, duration, ease } as AnimationFrame; +/** + * 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 {}; // 非法的计算方式视为未指定,之后按默认计算方式处理 diff --git a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts index 7fa2ceb7d..aa42e758c 100644 --- a/packages/webgal/src/Core/gameScripts/setTempAnimation.ts +++ b/packages/webgal/src/Core/gameScripts/setTempAnimation.ts @@ -7,7 +7,7 @@ import { applyAnimationEndState, getAnimateDuration } from '@/Core/Modules/anima import { WebGAL } from '@/Core/WebGAL'; import { v4 as uuid } from 'uuid'; import { generateTimelineObj } from '@/Core/controller/stage/pixi/animations/timeline'; -import { IAnimationV2, normalizeAnimation } from '@/Core/Modules/animations'; +import { IAnimationOverrides, IAnimationV2, normalizeAnimation } from '@/Core/Modules/animations'; /** * 设置临时动画 @@ -15,7 +15,8 @@ import { IAnimationV2, normalizeAnimation } from '@/Core/Modules/animations'; */ export const setTempAnimation = (sentence: ISentence): IPerform => { const animationName = uuid(); - WebGAL.animationManager.addAnimation(animationName, parseTempAnimation(sentence)); + 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; @@ -64,17 +65,25 @@ export const setTempAnimation = (sentence: ISentence): IPerform => { }; /** - * 解析语句中的临时动画。 - * 传入 relative 或 inherit 时,关键帧数组也按 v2 处理;v2 对象中的对应字段会被覆盖 + * 解析语句中的临时动画,规则与 setTempAnimation 注册动画时相同 */ export function parseTempAnimation(sentence: ISentence): IAnimationV2 { - let animationObj; + 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 { - animationObj = JSON.parse(sentence.content); + rawAnimation = JSON.parse(sentence.content); } catch (e) { - animationObj = []; + rawAnimation = []; } const relative = getBooleanArgByKey(sentence, 'relative'); const inherit = getBooleanArgByKey(sentence, 'inherit'); - return normalizeAnimation(animationObj, { relative, inherit }); + return { rawAnimation, overrides: { relative, inherit } }; } From 8df9ce2a764bf4696e1a88bd990ed6dcdff609c2 Mon Sep 17 00:00:00 2001 From: Mahiru Date: Thu, 8 Oct 2026 22:16:10 +0800 Subject: [PATCH 13/13] update version --- package.json | 2 +- packages/webgal/package.json | 2 +- .../webgal/public/game/template/template.json | 2 +- packages/webgal/public/webgal-engine.json | 4 +- releasenote.md | 54 ++++--------------- 5 files changed, 14 insertions(+), 50 deletions(-) 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 b7d16f4fd..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=./", 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/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 立ち絵が正しく表示されない問題を修正しました。