Files
ClassHelper/packages/desktop-client/src/island/spring.ts
T
LoveChengke 7a4668cb1b feat(island): 彻底照搬 WinIsland 架构重写灵动岛(固定包围盒窗口 + 窗口内弹簧形变)
背景:上一版仍沿用"每帧移动/缩放透明窗口"的做法,实机上会出现形变抖动与残影
(用户反馈"越改越烂、开合震动还在")。本次按 WinIsland 的真实做法重做架构。

主进程(packages/desktop-client/src/main/island.ts):
- 窗口一次创建为**固定包围盒**(最大形态 + ISLAND_SHADOW_PAD 阴影留白),
  开合过程中不再调用 setBounds;只在"尺寸/位置设置变化"时重排一次窗口
- 新增 setInteractive():默认 setIgnoreMouseEvents(true,{forward:true}) 整块穿透,
  由渲染层命中测试回调切换(照搬 WinIsland set_cursor_hittest),保证不吞桌面点击
- 移除逐帧几何代码(animateBounds/boundsFor/computeBounds/resolveAnchor 用于窗口定位的简化版保留)
- 空闲细缝不再受 Windows 36px 最小窗口高度限制(窗口固定,岛可真正缩到 6px 宽)

渲染层(IslandApp.vue + 新增 spring.ts):
- 岛是**单一元素**,宽/高/圆角由 WinIsland 弹簧逐帧逼近:
  force=(target-value)*0.10*dt → velocity=(velocity+force)*0.68^dt → value+=velocity*dt
  (dt 以帧计;换向保留 35% 动量、单帧位移 ≤ 剩余距离 20%),并加零过冲钳制
- 胶囊层与展开层常驻并交叉淡入(展开 progress²、胶囊 1-1.5×progress),形变全程无缩放回弹
- 形状层改为显式像素宽高 + preserveAspectRatio="none"(避免 viewBox 缩放导致形状错位/残留角)
- 修两个实机可见缺陷:① 展开层漏了 flex-direction: column(展开卡内容横排、元素散落);
  ② 隐藏且无细缝时不再把岛缩成细缝(否则下次弹出会"从细缝长大")
- 尺寸增量 ISLAND_SIZE_DELTA / 阴影留白移入 @classhelper/shared,主/渲染两端共用一份

验证(verify:desktop 53 → 54 项,全绿):
- 新增「开合过程中窗口全程恒定」(逐帧窗口签名恒为 476x282@482,-2,岛 386x230→…→268x44 单调收缩,
  SVG 形状 bbox 与卡片框一致 ±2px)
- 新增「岛外鼠标穿透」(岛内 interactive=true / 岛外 false)
- 开合不震动改为按"岛"判定:窗口冻结 + 岛锚点 0.00px + 无过冲;截图按岛矩形裁剪并统计岛外红像素=0
- 其余能力(通知/作业/叫人/紧急、标为已读、上课隐藏、6 锚点、字号可调、设置立即生效、空闲细缝)全部保留
2026-09-12 18:46:20 +08:00

116 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WinIsland 同款弹簧(照搬 `src/utils/physics.rs` 与 `src/window/app/frame.rs` 的参数与更新式)。
*
* 原始实现(Rust,每帧调用一次,`dt` 以**帧**为单位):
* ```text
* force = (target - value) * stiffness * dt
* velocity = (velocity + force) * damping^dt
* value += velocity * dt
* ```
* 主岛尺寸用的参数:`stiffness = 0.10`、`damping = 0.68`(阻尼比 ζ≈0.977,接近临界阻尼,
* 理论过冲 ~1e-6,肉眼不可见)。换向时还有两个限制(`redirect_velocity_towards`):
* 反向动量只保留 35%、单帧位移不超过剩余距离的 20%。
*
* 我们在此基础上**额外加了零过冲钳制**(越过目标立刻吸附并清零速度):
* 即便某帧卡顿导致 dt 变大,也绝不会出现"超过目标再弹回来"的震动。
*/
export interface SpringOptions {
/** 刚度(WinIsland 主岛尺寸:0.10) */
stiffness?: number;
/** 速度保留率(WinIsland 主岛尺寸:0.68) */
damping?: number;
/** 吸附阈值(px) */
snapThreshold?: number;
}
const MOMENTUM_RETENTION = 0.35;
const MAX_DISTANCE_RATIO = 0.2;
const MAX_DT = 6;
/** 一帧按 60fps 折算(与 WinIsland 的 dt 单位一致) */
const FRAME_MS = 1000 / 60;
export class SpringValue {
value: number;
velocity = 0;
target: number;
private readonly stiffness: number;
private readonly damping: number;
private readonly snapThreshold: number;
constructor(initial: number, options: SpringOptions = {}) {
this.value = initial;
this.target = initial;
this.stiffness = options.stiffness ?? 0.1;
this.damping = options.damping ?? 0.68;
this.snapThreshold = options.snapThreshold ?? 0.05;
}
setTarget(target: number): void {
if (target === this.target) return;
this.redirectVelocity(target);
this.target = target;
}
/** 直接落位(关闭动画 / 首次同步时使用) */
snap(target: number): void {
this.target = target;
this.value = target;
this.velocity = 0;
}
get settled(): boolean {
return Math.abs(this.target - this.value) <= this.snapThreshold && Math.abs(this.velocity) <= 0.01;
}
/**
* 换向限速(WinIsland):反向时只保留 35% 动量,并把速度幅值限制在"剩余距离的 20%"以内,
* 保证不会因为方向切换产生突跳。
*/
private redirectVelocity(target: number): void {
const distance = target - this.value;
if (distance === 0) {
this.velocity = 0;
return;
}
const sameDirection = Math.sign(distance) === Math.sign(this.velocity);
if (!sameDirection) this.velocity *= MOMENTUM_RETENTION;
const maxVelocity = Math.abs(distance) * MAX_DISTANCE_RATIO;
if (Math.abs(this.velocity) > maxVelocity) {
this.velocity = Math.sign(this.velocity) * maxVelocity;
}
}
/** 推进一帧;返回是否仍在运动 */
step(deltaMs: number): boolean {
const dt = Math.min(MAX_DT, Math.max(0.1, deltaMs / FRAME_MS));
const distance = this.target - this.value;
if (distance === 0 && Math.abs(this.velocity) <= 0.01) {
this.velocity = 0;
this.value = this.target;
return false;
}
const force = distance * this.stiffness * dt;
this.velocity = (this.velocity + force) * Math.pow(this.damping, dt);
const previous = this.value;
this.value += this.velocity * dt;
// 零过冲钳制:越过目标(或已足够接近)就吸附到目标并停止
const crossed = (this.target - previous) * (this.target - this.value) <= 0;
if (crossed || Math.abs(this.target - this.value) <= this.snapThreshold) {
this.value = this.target;
this.velocity = 0;
return false;
}
if (!Number.isFinite(this.value) || !Number.isFinite(this.velocity)) {
this.value = this.target;
this.velocity = 0;
return false;
}
return true;
}
}