feat(m192): 动效帧泵——design motion 令牌接进 iced 的有界帧订阅 + 首批 3 条入场动效
令牌早就在 `crates/design/src/motion/` 里齐着(16 档时长 / 可采样缓动 / 弹簧二维表 / MotionScale 降级),但适配层从没消费过,所以一切状态变化都是硬切。本次补的正是那条中间层: 新增 `components/motion/`(时间轴,不是控件家族) - `pump.rs`:`Slot`(boot/screen/detail 三个互斥触发点)× `Spec`(时长档 × 缓动档, **唯一一张表**,页面拿不到「给我 250ms」这种口子,D31)→ 进度采样;时间轴按 16ms 结算 (≈60 次/秒),到期轨道**当场删除**。 - `mod.rs`:进程内单时间轴(thread-local;iced 在事件循环线程上串行调 update/view, 与 `App::update(&mut self)` 同一条纪律,测试也因此天然隔离)+ `enter`/`shifted` 两个页面入口。 - **默认 NONE**:只有产品 `on_boot` 按档位打开;`pages_demo` 这类截图 harness 从不打开。 接线(都接在既有那条链上,不另开循环) - `App::needs_frames` 多一条「有轨道」分支 → 与截图 / Busy / toast / watch_until 共享**同一个布尔**, `window::frames()` 在源码里仍只有一处(守卫:双订阅就红)。 - `on_frame` 每帧推进时间轴;`on_heartbeat` 不变(有帧时不推进)。 - 定档策略集中在 `update/boot_env.rs::motion_scale`:截图档 NONE(D30 出图可复现)、 CPU 渲染档 HALF、GPU 档 STANDARD,`ULL_MOTION` 可强制。 首批 3 条动效(`pages::render` 一个漏斗,静息恒为 (0,0) ⇒ 既有截图基线一张都不用重出) - 首屏进入 16px / 换页 12px(纵向) / 列表↔详情 8px(横向); 时长 400/300/350ms 全部取自 `DurationToken`,曲线 `Easing::Standard` (`EmphasizedDecelerate` 把 75% 位移压在 51ms 内走完,12px 的整页平移会像跳一格)。 守卫与取证 - 11 条帧泵算术守卫 + 4 条接线守卫(含「NONE 档帧采样次数必须为 0」与「帧订阅源码只出现一处」); - 5 条变异证明全部复现(删退订 / 删 NONE 短路 / 删 needs_frames 分支 / 删 16ms 闸门 / 加第二条帧订阅 ⇒ 对应用例红); - 多帧序列图 12 张在 `DevDocs/artifacts/m192/`(t=0 / 半程 / 末 + 静息基线, `--diff --diff-scan` 实测换页 12px/6px、详情 8px); - 空闲实证:`ULL_TICK_TRACE=1` 跑真窗口,帧订阅窗口一过 `clock=idle-1hz` 且 `frames=` 冻结。
This commit is contained in:
1 parent
393d6f188f
commit
a1876a08c0
11 files changed
+1052
-8
No files matched your search
@@ -20,11 +20,14 @@
|
||||
//!
|
||||
//! | 场景 | 时钟 | 周期 | 谁触发 |
|
||||
//! |---|---|---|---|
|
||||
//! | 截图 / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
|
||||
//! | 截图 / **有动画在跑(M192)** / 有 Busy-Running 实例 / 有 toast / 派发后 3 s 观察窗 | `window::frames()` + 闸门 | 33 ms | [`App::needs_frames`] |
|
||||
//! | 其余(空闲) | `Heartbeat` 自续链 | 1 s | [`App::heartbeat`] |
|
||||
//!
|
||||
//! 两条时钟不会同时干活:心跳在 `needs_frames()` 为真时**只续排、不推进**,忙碌结束自动接回。
|
||||
//! 动效那条**不新开循环**:它只是给同一个布尔加一个分支(有轨道才为真),轨道到期当场清空,
|
||||
//! 下一拍就退回心跳(见 `components::motion`)。
|
||||
|
||||
use crate::app::components::motion;
|
||||
use crate::app::diag::diag;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -183,6 +186,10 @@ impl App {
|
||||
fn on_boot(&mut self) -> Task<Message> {
|
||||
let mut tasks = vec![window::latest().map(Message::WindowReady)];
|
||||
|
||||
// M192:给动效帧泵定档(截图档关、CPU 档半速、GPU 档正常;`ULL_MOTION*` 可覆盖)。
|
||||
// 必须在第一次 `view()` 之前:页面主体的入场由 `pages::render` 采样,配置晚了首屏就没有动效。
|
||||
self.configure_motion();
|
||||
|
||||
// M117:启动日志一行 —— `[auth] msa client_id=configured|missing source=env|build|none`。
|
||||
// 判据(配没配 + 从哪来)在 `core::auth::microsoft::client_id_status()`,零网络;
|
||||
// 本 crate 不直接依赖 core,所以经 `unlucky_cli`(已是本 crate 的依赖)出面,
|
||||
@@ -242,6 +249,25 @@ impl App {
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
/// M192:给帧泵定档(策略全在 [`boot_env::motion_scale`],这里只搬输入)。
|
||||
fn configure_motion(&mut self) {
|
||||
let pin = boot_env::env_motion_pin();
|
||||
let gpu = matches!(
|
||||
super::backend::effective_kind(),
|
||||
Some(crate::renderer::Kind::Wgpu)
|
||||
);
|
||||
let scale = boot_env::motion_scale(self.options.screenshot.is_some(), gpu, pin.is_some());
|
||||
motion::configure(scale, pin);
|
||||
// 打**帧泵实际拿到的**档位(不是刚算出的那个):配置没生效时这一行会露馅。
|
||||
diag!(
|
||||
"[motion] scale={:.2} pin={:?} gpu={} screenshot={}",
|
||||
motion::scale().factor(),
|
||||
pin,
|
||||
gpu,
|
||||
self.options.screenshot.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
/// 空闲心跳:只有「不需要帧节拍」时才推进一拍,然后**无条件续排**下一拍。
|
||||
///
|
||||
/// 为什么无条件续排:忙碌结束(游戏退出、toast 过期、观察窗关闭)之后没有别的东西会叫醒
|
||||
@@ -256,13 +282,52 @@ impl App {
|
||||
heartbeat()
|
||||
}
|
||||
|
||||
/// 一帧画完:①够 33 ms 就推进一拍 presenter ②截图模式在第 [`CAPTURE_AT_FRAME`] 帧出图。
|
||||
/// 出图取证:让这一次进程真的发生一次「换页」或「列表 → 详情」(见 [`boot_env::MotionFixture`])。
|
||||
fn motion_fixture(&mut self) {
|
||||
use boot_env::MotionFixture;
|
||||
let Some(fixture) = boot_env::env_motion_fixture() else {
|
||||
return;
|
||||
};
|
||||
let route = match (fixture, self.frames) {
|
||||
// 列表 → 详情:第 1 帧先回列表(详情收起来),第 2 帧再点进第一条实例。
|
||||
(MotionFixture::Detail, 1) => Route::Instances,
|
||||
(MotionFixture::Detail, 2) => {
|
||||
let Some(first) = self
|
||||
.state
|
||||
.tiles()
|
||||
.first()
|
||||
.map(|cell| cell.instance.id.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
Route::Detail(first)
|
||||
}
|
||||
(MotionFixture::Route(route), 1) => route,
|
||||
_ => return,
|
||||
};
|
||||
diag!("[motion] ULL_MOTION_FIXTURE → {route:?}");
|
||||
self.state.navigate(route);
|
||||
self.view_dirty = true;
|
||||
self.refresh_view();
|
||||
}
|
||||
|
||||
/// 一帧画完:①动效时间轴推进 ②够 33 ms 就推进一拍 presenter ③截图模式在第
|
||||
/// [`CAPTURE_AT_FRAME`] 帧出图。
|
||||
///
|
||||
/// 时间闸门按**真实流逝时间**判(不是数帧):掉帧不会把游戏启动进度拖慢,
|
||||
/// 也不会在慢机器上把 1.5 s 的下载阶段拉成 4 s。
|
||||
fn on_frame(&mut self) -> Task<Message> {
|
||||
self.frames += 1;
|
||||
|
||||
// M192:动效时间轴。**每帧都调**,帧率上限在帧泵内部(16 ms 一次结算),所以忙碌态的
|
||||
// 近千 fps 不会变成近千次重排;动画结束后帧泵自动清空,下一拍订阅表就不再有 `frames()`。
|
||||
motion::advance();
|
||||
// 出图取证(`ULL_MOTION_FIXTURE`):前两帧各做一次**真的**状态变化。
|
||||
// 没有这步,截图通道每一帧画的都是同一屏,页面切换 / 列表↔详情的入场就没有可拍的相位。
|
||||
if self.frames <= 2 {
|
||||
self.motion_fixture();
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_tick);
|
||||
if elapsed >= TICK {
|
||||
|
||||
Reference in new issue
Block a user