Skip to content

Navigation Menu

Sign in
Sign up

refactor(story-player): 抽出共享 DOTween ease 曲线库与 TweenRunner ease/loop 选项 - #407

Open
daflyinbed wants to merge 2 commits into
main from
refactor/story-player-tween-infra
Open

refactor(story-player): 抽出共享 DOTween ease 曲线库与 TweenRunner ease/loop 选项 #407
daflyinbed wants to merge 2 commits into
main from
refactor/story-player-tween-infra

Conversation

@daflyinbed

@daflyinbed daflyinbed commented Sep 3, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

背景

backgroundtween / imagetween / largebgtween / largeimgtween 的 ease/loop 修复(#358/#348/#361/#362)此前各自携带一份 DOTween 曲线实现(#361/#362 的 TweenRunner 内联表逐字节相同,#348 又在 PixiStoryRenderer 内联一份)。本 PR 把这份共享基础设施抽成独立模块,四个命令 PR 改为基于本 PR。

native 依据(2.7.61 / build 2761 GameAssembly.dll IDA 复核)

  • 曲线本体是内嵌 DOTween:DG.Tweening.Core.Easing.EaseManager.Evaluate @ 0x184140b40(Robert Penner 方程;超枚举域数值走 default 分支 = OutQuad 抛物线,INTERNAL_Zero 恒 1),Bounce/Flash 辅助类 @ 0x18413fb50-0x1841438a0。
  • AVG executor 统一经 DotNetExtensionMethods.GetEnum<Ease>(param, "ease", 默认立即数 1 = Ease.Linear, ignoreCase: false) 选曲线:枚举名大小写敏感,整数字符串按 ordinal(ease="6" = OutQuad,负数与超表 ordinal 走 EaseManager default 分支 = OutQuad),解析失败回落 Linear。
  • loopSetLoops(2*!loop - 1):true → -1 即无限 Restart 循环(每轮回 From 重放,OnComplete 永不触发),false → 1 单次。

修复内容

1. 新增 core/DotweenEase.ts(曲线库,取自 #358 的完整移植)

2. core/TweenRunner.run 增加 options?: TweenRunOptions

  • options.ease:逐周期曲线(默认 Linear);
  • options.loops:SetLoops Restart 语义,负数无限循环——每周期从 From 重放,仅在 isAlive 失效时 settle(调用方不得对其 await 阻塞)。

3. PixiStoryRenderer 私有 tween helper 透传 options

命令侧调用点统一 { ease: dotweenEaseCurve(input.ease), loops: input.loop ? -1 : 1 } 形状,由各命令 PR 提供。

测试

  • tests/tweenEase.spec.ts:名字/ordinal(含负数与超表)/Flash/端点锚定;
  • tests/tweenRunner.spec.ts:ease 步进、Restart 周期重放、无限循环仅在 isAlive 失效时 settle。

后续

StarHeartHunt pushed a commit that referenced this pull request Sep 6, 2026
review 跟进,两处收尾:
- setImageTween 原本自己算 dotweenEaseCurve 再在 step 回调里 ease(raw),
 绕过了 #407 新加的 TweenRunOptions。数值等价,但这样一来整个 stack 里
 options 就没有任何生产调用方,也和 #407 约定的 { ease, loops } 调用形状
 不一致。改为透传 options.ease;对应单测的 tween stub 也照 run 的方式
 施加曲线,顺带把"曲线确实经 options 传下去"锁进断言。
- imageSprite 字段在 root/sprite 拆分后已无人读取(生产代码全部改用
 imageRoot),原 PR 把它从 private 放开成 public 只是为了给单测取内层
 sprite。直接删掉,单测改读 imageRoot.children[0]——noUnusedLocals
 本来也不允许留一个只写不读的私有字段。

Copy link
×ばつ1.525 与 native 一致),Bounce 族对照 `0x1841f0180` / `0x1841f03a0` 也一致。 ### 2. 序数 0(`Ease.Unset`)应落 OutQuad,不是 Linear `Evaluate` 的入口守卫: ```c if ( (unsigned int)(easeType - 1) > 0x24 ) // 只有 1..37 有 case goto LABEL_127; // -(t/d)*(t/d-2),即 OutQuad 抛物线 ``` `easeType = 0` → `0xFFFFFFFF > 0x24` → default 分支。而 `SetEase` 是无条件赋值(见上),没有 Unset→默认值 的重映射。原注释 `0: Ease.Unset — GetEnum's parse fallback value` 把两件事混了:GetEnum 的解析回落是**序数 1(Linear)**,`_ExecuteImageTween` @ `0x183f05a3f` 传的就是立即数 `1`。原 `tests/tweenEase.spec.ts` 里 `expect(dotweenEaseCurve("0")(0.5)).toBe(0.5)` 把错值锁进了断言,已改为 0.75。 语料里没有 `ease="0"`(只有 ×ばつ18 / ×ばつ8 / ×ばつ2),无可见回归。 ### 3. 曲线表按名取值会命中 `Object.prototype` `dotweenEaseCurves` 是对象字面量,`dotweenEaseCurves[ease]` 会拿到继承成员,而它们非 null,`?? linearEase` 兜不住: ``` ease="toString" → 返回字符串 → progress 变 NaN → transform 全 NaN ease="valueOf" → 抛 TypeError(在 tween step 回调里,吃掉整个动画帧) ease="hasOwnProperty" → 抛 TypeError ease="__proto__" → 拿到 prototype 对象,在 setImageTween 里当场抛 ``` 剧本文本是 wiki 可编辑内容,一个手滑的 ease 值就能打崩整条命令;native 对应行为只是 `DLog.LogError("Can not parse enum ...")` 然后回落 Linear。已改为 `Object.hasOwn` 自有属性查找。 顺带按 native 补齐了 int32 溢出的序数回落——`Enum.Parse` 走 `Int32.Parse`,溢出即抛,`GetEnum` 在 `0x1846a7cd0` 的 catch 里 `LogError` 后 `mov eax, defaultEnum`。**这里也确认了 PR 描述中"解析失败回落 Linear"是对的**,catch 块确实存在。 --- ### 另外一并处理的 - **Flash 族的 `(-1 / 0)` 前提是错的**,实际是 `SetEase` 截断后的 `amplitude = (int)1.70158 = 1`、`period = 0`。不过 amp=1 时 `Flash.Ease` 只剩单波(`stepIndex=1`、`dir=1`、波进度 = `time`),`WeightedEase` @ `0x1841f3ed0` 又在 `period == 0` 时短路成 `min(1, res)`——**与 -1 的假设数值上完全等价**,我逐点比对过 Flash/InFlash/OutFlash,语料里唯一那条 `ease="OutFlash"`(`story_mizuki_1_1.txt:515`)渲染不变。所以不是 bug,但那 ~60 行多波机械按构造不可达,已塌缩成 `min(1, wave(time))` 并订正出处注释。 - **`CgItemPanel.easeProgress` 并进来了**。main 上一直还有第三份 ease 实现,只有 3 条曲线,而且用 `ease.toLowerCase()`——**大小写不敏感**,和它自己紧挨着的注释(`ignoreCase: false`)以及本 PR 的大小写敏感解析直接冲突。既然本 PR 的立意就是消灭重复曲线表,顺手合流,顺便让 cgitem/hidecgitem 拿到另外 34 条曲线。现有单测的 ease 全是 `"Linear"`,行为不变。 - `TweenRunner` 按 `SetLoops` @ `0x184885f00` 钳位(`0 → 1`、`< -1 → -1`),并删掉 `loops === 1` 时与取模分支恒等的死代码。 - 曲线表改用 `satisfies`,序数表里写错键名(`dotweenEaseCurves.OutQaud`)现在会编译失败——之前配上仓库的 `noUncheckedIndexedAccess: false` 会静默变成 `undefined` → OutQuad。 - `tests/tweenEase.spec.ts` 里「`ease="34"` 是 OutFlash(story_mizuki_1_1)」的注释不准:语料那条是按**名字**写的 `ease="OutFlash"`,全语料没有序数 34。已改成两者都断言。 - 出处地址统一到 2.7.71 (build 2771)。 ### 没做的一项 native 在 ease 无法解析时会 `DLog.LogError("Can not parse enum ...")`,port 是静默的。要镜像成 runtime warning 需要 `DotweenEase` 往外暴露"是否可解析",并且会同时影响 cgitem/hidecgitem 和另外三个还没合的命令 PR 的告警输出(以及 log corpus 快照),超出本 PR 范围,留着后续统一处理。 验证:`pnpm test` 全绿、`pnpm exec vue-tsc -b` 通过、改动文件 `eslint` 通过。" data-view-component="true"> Copy Markdown
Member

对着 2.7.71 (build 2771)GameAssembly.dll 复核了整份曲线库,发现三处与 native 不符,已直接推到本分支(c630bf3)。核心结论是:SetEase(Ease) 单参重载从不重置 easeOvershootOrAmplitude / easePeriod,所以曲线拿到的是库默认值 1.70158 / 0,而不是各条曲线"教科书形式"里隐含的默认值。

链路证据:

  • TweenSettingsExtensions.SetEase<T>(t, Ease) @ 0x1848859d0 —— 只写 easeType,外加对 flash 族做一次 (int) 截断,别的什么都不碰:
    HIDWORD(t[11].klass) = ease;
    if ( IsFlashEase(ease) ) *(float*)&t[12].klass = (float)(int)*(float*)&t[12].klass;
    t[11].monitor = nullptr; // customEase = null
  • DOTween..cctor @ 0x1841bb710defaultEaseOvershootOrAmplitude = 1.70158defaultEasePeriod = 0
  • Tweener.Setup<Vector3,...> @ 0x184896a60 → 把这两个值拷进每个 tween

1. Elastic 三条曲线的 amplitude 用错(数值偏差可观)

Evaluate @ 0x1841f1170 的 elastic 分支是这样的:

comiss xmm9(1.0), xmm12(overshootOrAmplitude)
ja loc_1841F1955 ; 仅当 1.0 > overshoot :amp = 1, s = period * 0.25
; 否则Asin 分支:amp 保持原值,s = period / 2pi * asin(1 / amp)

overshoot = 1.70158 ≥ 1,所以 ja 不成立、走 else:振幅乘子保持 1.70158,相移是 period/2π·asin(1/amp) 而非 period/4。常量已逐个核对:0x186a782a4 = 0.3(In/Out 的 period 默认)、0x186a9416c = 0.45(InOut)、0x186a782c8 = 2π0x186a77d9c = 0.25

原实现用的是 easings.net 那套 amplitude=1 形式(2π/32π/4.5 折叠常量),实测差异:

native 修复前
OutElastic(0.1) 1.84614 1.25
OutElastic(0.25) 0.70084 0.91161
InElastic(0.9) +0.34614 −0.25 ← 符号都反了
InOutElastic(0.4) −0.05860 −0.11746

端点有 early-out 所以现有的"端点锚定"用例测不出来,已补上中间点断言。全语料没有 Elastic ease,属潜伏问题,不影响线上。

顺带确认 Back 族是对的(1.70158、InOutBack ×ばつ1.525 与 native 一致),Bounce 族对照 0x1841f0180 / 0x1841f03a0 也一致。

2. 序数 0(Ease.Unset)应落 OutQuad,不是 Linear

Evaluate 的入口守卫:

if ( (unsigned int)(easeType - 1) > 0x24 ) // 只有 1..37 有 case
 goto LABEL_127; // -(t/d)*(t/d-2),即 OutQuad 抛物线

easeType = 00xFFFFFFFF > 0x24 → default 分支。而 SetEase 是无条件赋值(见上),没有 Unset→默认值 的重映射。原注释 0: Ease.Unset — GetEnum's parse fallback value 把两件事混了:GetEnum 的解析回落是序数 1(Linear),_ExecuteImageTween @ 0x183f05a3f 传的就是立即数 1。原 tests/tweenEase.spec.tsexpect(dotweenEaseCurve("0")(0.5)).toBe(0.5) 把错值锁进了断言,已改为 0.75。

语料里没有 ease="0"(只有 "1"×ばつ18 / "6"×ばつ8 / "7"×ばつ2),无可见回归。

3. 曲线表按名取值会命中 Object.prototype

dotweenEaseCurves 是对象字面量,dotweenEaseCurves[ease] 会拿到继承成员,而它们非 null,?? linearEase 兜不住:

ease="toString" → 返回字符串 → progress 变 NaN → transform 全 NaN
ease="valueOf" → 抛 TypeError(在 tween step 回调里,吃掉整个动画帧)
ease="hasOwnProperty" → 抛 TypeError
ease="__proto__" → 拿到 prototype 对象,在 setImageTween 里当场抛

剧本文本是 wiki 可编辑内容,一个手滑的 ease 值就能打崩整条命令;native 对应行为只是 DLog.LogError("Can not parse enum ...") 然后回落 Linear。已改为 Object.hasOwn 自有属性查找。

顺带按 native 补齐了 int32 溢出的序数回落——Enum.ParseInt32.Parse,溢出即抛,GetEnum0x1846a7cd0 的 catch 里 LogErrormov eax, defaultEnum这里也确认了 PR 描述中"解析失败回落 Linear"是对的,catch 块确实存在。


另外一并处理的

  • Flash 族的 (-1 / 0) 前提是错的,实际是 SetEase 截断后的 amplitude = (int)1.70158 = 1period = 0。不过 amp=1 时 Flash.Ease 只剩单波(stepIndex=1dir=1、波进度 = time),WeightedEase @ 0x1841f3ed0 又在 period == 0 时短路成 min(1, res)——与 -1 的假设数值上完全等价,我逐点比对过 Flash/InFlash/OutFlash,语料里唯一那条 ease="OutFlash"(story_mizuki_1_1.txt:515)渲染不变。所以不是 bug,但那 ~60 行多波机械按构造不可达,已塌缩成 min(1, wave(time)) 并订正出处注释。
  • CgItemPanel.easeProgress 并进来了。main 上一直还有第三份 ease 实现,只有 3 条曲线,而且用 ease.toLowerCase()——大小写不敏感,和它自己紧挨着的注释(ignoreCase: false)以及本 PR 的大小写敏感解析直接冲突。既然本 PR 的立意就是消灭重复曲线表,顺手合流,顺便让 cgitem/hidecgitem 拿到另外 34 条曲线。现有单测的 ease 全是 "Linear",行为不变。
  • TweenRunnerSetLoops @ 0x184885f00 钳位(0 → 1< -1 → -1),并删掉 loops === 1 时与取模分支恒等的死代码。
  • 曲线表改用 satisfies,序数表里写错键名(dotweenEaseCurves.OutQaud)现在会编译失败——之前配上仓库的 noUncheckedIndexedAccess: false 会静默变成 undefined → OutQuad。
  • tests/tweenEase.spec.ts 里「ease="34" 是 OutFlash(story_mizuki_1_1)」的注释不准:语料那条是按名字写的 ease="OutFlash",全语料没有序数 34。已改成两者都断言。
  • 出处地址统一到 2.7.71 (build 2771)。

没做的一项

native 在 ease 无法解析时会 DLog.LogError("Can not parse enum ..."),port 是静默的。要镜像成 runtime warning 需要 DotweenEase 往外暴露"是否可解析",并且会同时影响 cgitem/hidecgitem 和另外三个还没合的命令 PR 的告警输出(以及 log corpus 快照),超出本 PR 范围,留着后续统一处理。

验证:pnpm test 全绿、pnpm exec vue-tsc -b 通过、改动文件 eslint 通过。

StarHeartHunt force-pushed the refactor/story-player-tween-infra branch from c630bf3 to 41063a1 Compare September 7, 2026 09:26
daflyinbed and others added 2 commits September 7, 2026 17:33
...op 选项
- core/DotweenEase.ts: DOTween Ease 枚举全曲线移植(含 Flash 族与
 EaseManager.Evaluate 的超枚举 default 分支),dotweenEaseCurve 按
 GetEnum<Ease>(ignoreCase:false) 语义解析枚举名/整数串(含负数)
- core/TweenRunner.run 增加 options(ease/loops),loops<0 映射
 SetLoops 无限 Restart 循环,仅在 isAlive 失效时 settle
- PixiStoryRenderer 私有 tween helper 透传 options
- tests: tweenEase.spec / tweenRunner.spec
2.7.71 复核发现曲线库有三处与 native 不符,并补上 CgItemPanel 的合流。
1. Elastic 三条曲线的 amplitude 用错。`SetEase<T>(t, Ease)` @ 0x1848859d0
 只写 easeType(flash 族额外做一次 int 截断),从不重置 overshoot/period;
 `DOTween..cctor` @ 0x1841bb710 的 defaultEaseOvershootOrAmplitude = 1.70158
 由 `Tweener.Setup` @ 0x184896a60 拷进每个 tween。因此 `Evaluate`
 @ 0x1841f1170 的 elastic 分支不走 `overshootOrAmplitude < 1` 的钳位:
 amplitude 保持 1.70158,相移是 period/2pi*asin(1/amplitude) 而非 period/4。
 原实现用的是 easings.net 的 amplitude=1 形式(2pi/3、2pi/4.5 常量),
 偏差可观 —— OutElastic(0.1) 应为 1.84614 而非 1.25,InElastic(0.9)
 应为 +0.34614 而非 -0.25(连符号都相反)。语料暂无 Elastic ease,属潜伏问题。
2. 序数 0(Ease.Unset)应落 OutQuad 而非 Linear。`Evaluate` 的入口守卫是
 `(uint)(easeType - 1) > 0x24`,只有 1..37 有 case,Unset 与超表序数一样
 走 default 分支(OutQuad 抛物线)。GetEnum 的解析回落是序数 1(Linear),
 与序数 0 是两回事,原注释把两者混为一谈。
3. 曲线表是对象字面量,按名取值会命中 Object.prototype 的继承成员:
 ease="toString" 返回字符串(progress 变 NaN),ease="valueOf" /
 "hasOwnProperty" / "__proto__" 直接抛 TypeError,全都能绕过 ?? 兜底。
 剧本文本是 wiki 可编辑内容,改为 Object.hasOwn 自有属性查找。
 顺带按 native 补上 int32 溢出的序数回落(Int32.Parse 抛错 → GetEnum
 捕获 → Linear)。
另外:
- Flash 族的 (-1 / 0) 前提是错的,实际是 SetEase 截断后的 amplitude=1、
 period=0;二者数值恰好等价(已逐点比对,ease="OutFlash" 渲染不变),
 但 WeightedEase/多波循环按构造不可达,塌缩为 min(1, wave(time))。
- CgItemPanel 仍带着一份 3 条曲线、且 `ease.toLowerCase()` 大小写不敏感的
 私有实现,与它自己注释里的 ignoreCase: false 相悖;并入 dotweenEaseCurve。
- TweenRunner 按 `SetLoops` @ 0x184885f00 钳位(0 → 1,< -1 → -1),
 并删掉 loops === 1 时与取模分支恒等的死代码。
- 曲线表改用 satisfies,序数表里写错键名现在会编译失败。
- 出处地址统一到 2.7.71 (build 2771)。
StarHeartHunt force-pushed the refactor/story-player-tween-infra branch from 41063a1 to d7a8db5 Compare September 7, 2026 09:33
StarHeartHunt added a commit that referenced this pull request Sep 7, 2026
review 跟进,两处收尾:
- setImageTween 原本自己算 dotweenEaseCurve 再在 step 回调里 ease(raw),
 绕过了 #407 新加的 TweenRunOptions。数值等价,但这样一来整个 stack 里
 options 就没有任何生产调用方,也和 #407 约定的 { ease, loops } 调用形状
 不一致。改为透传 options.ease;对应单测的 tween stub 也照 run 的方式
 施加曲线,顺带把"曲线确实经 options 传下去"锁进断言。
- imageSprite 字段在 root/sprite 拆分后已无人读取(生产代码全部改用
 imageRoot),原 PR 把它从 private 放开成 public 只是为了给单测取内层
 sprite。直接删掉,单测改读 imageRoot.children[0]——noUnusedLocals
 本来也不允许留一个只写不读的私有字段。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

AltStyle によって変換されたページ (->オリジナル) /