fix: smooth persona hover transitions
Preserve the original reflection material while adding pointer inertia and a short exit hold. Retain the Rive pointer constraint through the fade and blend rapid re-entry to avoid abrupt changes. Cover timing, re-entry, cancellation, renderer disposal and native Rive frames. The fade regression fails with the previous resource and passes with the updated resource.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Agent Persona 设计
|
# Agent Persona 设计
|
||||||
|
|
||||||
更新日期:2026-10-22。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-resource.md);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。
|
更新日期:2026-10-28。本文保存供水前端当前的 Persona 视觉与交互规格,作为后续调整的基线。资源解析、再生成及缓存实现见 [动画资源说明](agent-persona-resource.md);通用控件和语义表面继续遵循 [样式设计指南](style-design-guide.md)。
|
||||||
|
|
||||||
## 1. 形象与材质
|
## 1. 形象与材质
|
||||||
|
|
||||||
@@ -68,9 +68,9 @@
|
|||||||
|
|
||||||
## 5. 悬停与点击
|
## 5. 悬停与点击
|
||||||
|
|
||||||
悬停沿用原版三层反射的尺寸、渐变、亮度和淡入淡出。白色高光跟随鼠标所在位置:从左侧移到右侧时,高光随之移动;鼠标离开后恢复常态。不能只打开 `hover` 布尔值而遗漏坐标,否则原生指针目标停在默认中心,造成球体中间大片变白。球体、点击范围和常态外围水纹尺寸均保持原定义。
|
悬停沿用原版三层反射的尺寸、渐变和亮度。进入以约 150ms 渐显;白色高光以约 80ms 的跟随时间常数趋近鼠标位置,保留轻微惯性。鼠标移出后先停留 100ms,再以约 600ms 淡出,退场期间保留最后的跟随位置。不能只打开 `hover` 布尔值而遗漏坐标,否则原生指针目标停在默认中心,造成球体中间大片变白。球体、点击范围和常态外围水纹尺寸均保持原定义。
|
||||||
|
|
||||||
共享 `Persona` 保留 Rive 原生指针监听,将主指针的进入、移动、离开和取消转交给画布;坐标转换与资源内的跟随约束由 Rive 完成。触摸及按压不转交动画,不绑定 View Model。悬浮入口和标题形象共用这条通路,画布就绪后才接收交互。
|
共享 `Persona` 保留 Rive 原生指针监听,经由 `persona-hover.ts` 平滑 CSS 坐标后,将主指针的进入、移动、离开和取消转交给画布;坐标转换与资源内的跟随约束由 Rive 完成。150ms / 600ms 渐变由 Rive 时间轴负责,进出切换以 100ms 状态混合衔接;所有时间参数集中在 [persona-hover-timing.json](../src/shared/ai-elements/persona-hover-timing.json)。100ms 停留内重新进入会取消退场,淡出过程中重新进入从当前坐标继续跟随;取消事件立即释放悬停,卸载时清理动画帧和计时器。触摸及按压不转交动画,不绑定 View Model。悬浮入口和标题形象共用这条通路,画布就绪后才接收交互。
|
||||||
|
|
||||||
点击、触摸轻点及 Enter / Space 展开对话,离线时同样可用。球体不添加 Motion 按压缩放、弹簧回弹或按下变暗;触摸按下不模拟 hover。外层面板的布局动画独立维护,Resizable 连续调宽保持直接响应。
|
点击、触摸轻点及 Enter / Space 展开对话,离线时同样可用。球体不添加 Motion 按压缩放、弹簧回弹或按下变暗;触摸按下不模拟 hover。外层面板的布局动画独立维护,Resizable 连续调宽保持直接响应。
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
| 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) |
|
| 资源生成与原版还原 | [build-agent-persona.mjs](../scripts/build-agent-persona.mjs) |
|
||||||
| 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) |
|
| 资源版本与缓存 | [persona-resource.ts](../src/shared/ai-elements/persona-resource.ts) |
|
||||||
|
|
||||||
材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束;改动时同步本文与资源说明。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。
|
材质与离线参数以对应 JSON 为准,悬停沿用原始资源中的几何与指针约束,仅调整时间轴与指针跟随节奏;改动时同步本文与资源说明。原始解析数据保留以便逐字节还原 `.riv`;该数据不等于作者完整的 `.rev` 编辑器工程。
|
||||||
|
|
||||||
验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。
|
验收时检查 64px 桌面入口、64px 标题形象与 40px 手机入口,覆盖默认、悬停进入与退出、快速切换、离线和恢复在线。桌面检查 1024/1440px 视口及 400/640px 面板,手机检查 320/375px。确认左上间距一致、按压无缩放、首次加载无画面硬切、底色能遮蔽地图、离线无外围光圈、缓存刷新后仍加载当前版本。
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ Agent 保留原版 Obsidian 图形与运动结构,采用经实际动画对比
|
|||||||
| [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 |
|
| [运行时源数据](../design/agent/obsidian-runtime.json) | 从原版解析的 775 个对象,包含图形、属性、关键帧、状态机和绑定数据 |
|
||||||
| [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 |
|
| [冰水蓝材质参数](../design/agent/obsidian-material.json) | 已确认的水体、暗部、白色高光和水纹颜色,以及球面底色遮蔽度 |
|
||||||
| [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 |
|
| [离线设计参数](../design/agent/obsidian-offline.json) | 可编辑的播放速度、振幅、混合时长和三层柔光/反射强度 |
|
||||||
|
| [悬停时间参数](../src/shared/ai-elements/persona-hover-timing.json) | 150ms 渐显、80ms 跟随、100ms 停留、600ms 淡出及 100ms 状态混合 |
|
||||||
| [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 |
|
| [生成脚本](../scripts/build-agent-persona.mjs) | 验证原版重编码、修改离线动画,生成本地资源 |
|
||||||
| [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,879 字节资源 |
|
| [应用资源](../public/agent/obsidian-water-v1.riv) | 前端加载的 9,929 字节资源 |
|
||||||
| [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 |
|
| [缓存版本清单](../src/shared/ai-elements/persona-resource-manifest.json) | 生成脚本同步输出的路径、SHA-256 和字节数,用于客户端缓存更新 |
|
||||||
| [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 |
|
| [静态帧生成脚本](../scripts/render-agent-persona-stills.mjs) | 从实际资源渲染在线、离线 PNG,供首次加载、减少动态效果和失败兜底使用 |
|
||||||
| [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 |
|
| [资源对照测试](../tests/browser/agent-persona-resource.e2e.ts) | 真实 WebGL2 运行时定时推进,比较原版与修改版画面 |
|
||||||
@@ -64,13 +65,14 @@ JSON 保存原始运行数据;脚本负责可审查的定制。`--check` 验
|
|||||||
- 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。
|
- 离线循环复用原版三层水纹,时间轴速率从 60 改为 24,约为原速的 40%;这是时间轴播放速度,不是将屏幕渲染限制为 24fps。
|
||||||
- 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。
|
- 位移和呼吸振幅缩至原来的 25%;完整旋转保留首尾角度,避免循环接缝。
|
||||||
- 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。
|
- 离线循环增加六个柔光/光圈节点的透明度轨道,其中节点 190、199、208 的光圈透明度固定为零;在线循环对应增加原始透明度常量轨道,确保恢复连接后完整恢复。
|
||||||
- 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。其他输入、状态层和交互保持原定义。
|
- 三个循环层根据既有 `asleep` 输入切换,使用 600ms 状态混合。
|
||||||
|
- 悬停保持原版反射几何、渐变与亮度,将 `hover_in` / `hover_out` 分别调整为 9 / 36 帧,即 150ms / 600ms,并使用原有贝塞尔插值器。退出时间轴持续保持节点 129 的指针约束,完全淡出后才释放;进出切换使用 100ms 混合。
|
||||||
|
|
||||||
生成结果包含 957 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。
|
生成结果包含 960 个对象、23 段动画。调整设计参数后需再生成并运行对照测试;无需修改前端组件或安装额外依赖。
|
||||||
|
|
||||||
## 前端接入与生命周期
|
## 前端接入与生命周期
|
||||||
|
|
||||||
悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 向画布转交鼠标的进入、移动和离开坐标,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。
|
悬停沿用原版节点 110 的反射尺寸,以及节点 129 对指针目标 1 的跟随约束。`Persona` 保留 Rive 监听器,通过外层 Pointer Events 与 `persona-hover.ts` 向画布转交鼠标的进入、移动和离开坐标;移动坐标以 80ms 时间常数平滑,移出延迟 100ms 后交给资源淡出,Rive 自行完成画布与画板坐标转换。画布内部不直接命中鼠标或触摸,只有就绪的外层接收并筛选主指针;不转交触摸和按压,因此手机点击不会触发白色高光。仅手动设置 `hover` 而禁用原生监听,会使目标停在中心,造成中央大片变白。这个问题由事件通路修复,不通过缩小光圈或改变材质补偿。
|
||||||
|
|
||||||
`persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。
|
`persona-resource.ts` 加载同源的 `/agent/obsidian-water-v1.riv?v=<SHA-256>`。同一页面共用下载 Promise,跨刷新、再次打开页面使用浏览器 Cache Storage 的 `tjwater-agent-persona-v1` 缓存;只共享不可变字节,不共享 Rive 实例或 View Model。
|
||||||
|
|
||||||
@@ -88,4 +90,4 @@ Agent 服务不可用不妨碍前端读取该资源。这项缓存仅覆盖 `.ri
|
|||||||
|
|
||||||
原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。
|
原始图形来源为 [Surge Obsidian 动画](https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv)。解析时参考了 [rive-rs-cli 的格式读取实现](https://github.com/George-RD/rive-rs-cli/blob/main/src/validator/parser.rs)及其从 Rive 定义生成的类型/属性表;应用不依赖或包含该工具。原图形的作者归属不因解析和局部修改而改变。
|
||||||
|
|
||||||
对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。工作台测试验证实际坐标到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。
|
对照测试在同一 WebGL2 运行时以固定时间步推进。就绪、思考、待回复、输出和稳定悬停状态要求与“仅应用相同冰水蓝材质的原版”采样画面逐像素一致,并验证主色由灰变蓝、合成到白底后仍保有蓝色,球体中心采样区域的不透明度不低于 90%。悬停另外比较左右指针位置对应的高光重心,要求局部反射随指针移动且移出后完全恢复。时间采样另外验证 150ms 渐显与 600ms 淡出,中途保持可见且最终恢复常态。指针定时测试覆盖惯性跟随、停留、快速进出与卸载清理;工作台测试验证平滑后的实际坐标最终到达原生监听器。离线检查完整球体、持续变化、初始离线和反复恢复;通过外围像素计数验证光圈完全消失,悬停不会重新出现,恢复在线后再次可见。另用相同运动轨迹、取消离线光照变化的对照资源,验证快速切换后在线明暗完整恢复。该检查覆盖固定采样时刻,不是所有设备、运行时间和状态序列的穷举验证。
|
||||||
|
|||||||
Binary file not shown.
@@ -12,6 +12,7 @@ const designUrl = new URL("../design/agent/obsidian-offline.json", import.meta.u
|
|||||||
const defaultDesign = JSON.parse(await readFile(designUrl, "utf8"));
|
const defaultDesign = JSON.parse(await readFile(designUrl, "utf8"));
|
||||||
const materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url);
|
const materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url);
|
||||||
const defaultMaterial = JSON.parse(await readFile(materialUrl, "utf8"));
|
const defaultMaterial = JSON.parse(await readFile(materialUrl, "utf8"));
|
||||||
|
const hoverTiming = JSON.parse(await readFile(new URL("../src/shared/ai-elements/persona-hover-timing.json", import.meta.url), "utf8"));
|
||||||
|
|
||||||
function uint(value) {
|
function uint(value) {
|
||||||
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid Rive uint: ${value}`);
|
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid Rive uint: ${value}`);
|
||||||
@@ -172,14 +173,40 @@ export function buildOfflinePersona(source, offline = defaultDesign, material =
|
|||||||
const components = objects.slice(artboard, firstAnimation).filter((object) => object.type !== 447);
|
const components = objects.slice(artboard, firstAnimation).filter((object) => object.type !== 447);
|
||||||
|
|
||||||
let animation;
|
let animation;
|
||||||
|
let machineLayer;
|
||||||
for (const object of objects) {
|
for (const object of objects) {
|
||||||
if (object.type === 31) animation = valueOf(object, 55);
|
if (object.type === 31) {
|
||||||
|
animation = valueOf(object, 55);
|
||||||
|
if (animation === "hover_in" || animation === "hover_out") {
|
||||||
|
setValue(object, 57, Math.round((animation === "hover_in" ? hoverTiming.enterMs : hoverTiming.exitMs) * 60 / 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Change timing only, keeping the original reflection geometry and color.
|
||||||
|
if ((animation === "hover_in" || animation === "hover_out") && object.type === 30) {
|
||||||
|
const durationMs = animation === "hover_in" ? hoverTiming.enterMs : hoverTiming.exitMs;
|
||||||
|
setValue(object, 67, Math.round(valueOf(object, 67, 0) * durationMs / 1000));
|
||||||
|
setValue(object, 68, 2);
|
||||||
|
setValue(object, 69, 261);
|
||||||
|
}
|
||||||
|
if (object.type === 57) machineLayer = valueOf(object, 138);
|
||||||
|
if (machineLayer === "hover" && object.type === 65 && valueOf(object, 152, 0) === 12) {
|
||||||
|
setValue(object, 158, hoverTiming.blendMs);
|
||||||
|
}
|
||||||
// These tracks only control opacity and scale. Keep the complete sphere.
|
// These tracks only control opacity and scale. Keep the complete sphere.
|
||||||
if ((animation === "asleep" || animation === "wake") && object.type === 30) {
|
if ((animation === "asleep" || animation === "wake") && object.type === 30) {
|
||||||
setValue(object, 70, 1);
|
setValue(object, 70, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hoverOutEnd = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === "on_loop_1");
|
||||||
|
// Keep the pointer constraint active until the outgoing reflection is fully
|
||||||
|
// transparent. The original hover_off timeline releases it afterwards.
|
||||||
|
objects.splice(hoverOutEnd, 0,
|
||||||
|
{ type: 25, values: [[51, 129]] },
|
||||||
|
{ type: 26, values: [[53, 172]] },
|
||||||
|
{ type: 30, values: [[68, 1], [70, 1]] }
|
||||||
|
);
|
||||||
|
|
||||||
const loops = [];
|
const loops = [];
|
||||||
for (let number = 1; number <= 3; number++) {
|
for (let number = 1; number <= 3; number++) {
|
||||||
const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`);
|
const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"enterMs": 150,
|
||||||
|
"followMs": 80,
|
||||||
|
"exitHoldMs": 100,
|
||||||
|
"exitMs": 600,
|
||||||
|
"blendMs": 100
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createPersonaHover } from "./persona-hover";
|
||||||
|
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const moves: number[] = [];
|
||||||
|
const enter = vi.fn();
|
||||||
|
const exit = vi.fn();
|
||||||
|
canvas.addEventListener("mousemove", (event) => moves.push(event.clientX));
|
||||||
|
canvas.addEventListener("mouseover", enter);
|
||||||
|
canvas.addEventListener("mouseout", exit);
|
||||||
|
return { hover: createPersonaHover(canvas), moves, enter, exit };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Persona hover timing", () => {
|
||||||
|
it("positions the original reflection before entry and follows movement with inertia", () => {
|
||||||
|
const { hover, moves } = setup();
|
||||||
|
hover.enter({ clientX: 16, clientY: 32 });
|
||||||
|
expect(moves).toEqual([16]);
|
||||||
|
hover.move({ clientX: 48, clientY: 32 });
|
||||||
|
expect(moves).toEqual([16]);
|
||||||
|
vi.advanceTimersByTime(80);
|
||||||
|
expect(moves.at(-1)).toBeGreaterThan(32);
|
||||||
|
expect(moves.at(-1)).toBeLessThan(48);
|
||||||
|
vi.advanceTimersByTime(800);
|
||||||
|
expect(moves.at(-1)).toBe(48);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
hover.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the last in-bounds position for 100ms before asking Rive to fade", () => {
|
||||||
|
const { hover, moves, exit } = setup();
|
||||||
|
hover.enter({ clientX: 16, clientY: 32 });
|
||||||
|
hover.move({ clientX: 48, clientY: 32 });
|
||||||
|
hover.leave();
|
||||||
|
vi.advanceTimersByTime(99);
|
||||||
|
expect(exit).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(exit).toHaveBeenCalledOnce();
|
||||||
|
const count = moves.length;
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(moves).toHaveLength(count);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels a pending exit on quick re-entry without replaying the entrance", () => {
|
||||||
|
const { hover, enter, exit } = setup();
|
||||||
|
hover.enter({ clientX: 16, clientY: 32 });
|
||||||
|
hover.leave();
|
||||||
|
vi.advanceTimersByTime(60);
|
||||||
|
hover.enter({ clientX: 48, clientY: 32 });
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(enter).toHaveBeenCalledOnce();
|
||||||
|
expect(exit).not.toHaveBeenCalled();
|
||||||
|
hover.dispose();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases cancellation immediately and removes pending work on disposal", () => {
|
||||||
|
const { hover, moves, exit } = setup();
|
||||||
|
hover.enter({ clientX: 16, clientY: 32 });
|
||||||
|
hover.leave();
|
||||||
|
hover.cancel();
|
||||||
|
expect(exit).toHaveBeenCalledOnce();
|
||||||
|
hover.enter({ clientX: 48, clientY: 32 });
|
||||||
|
hover.move({ clientX: 32, clientY: 32 });
|
||||||
|
hover.leave();
|
||||||
|
hover.dispose();
|
||||||
|
const count = moves.length;
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
expect(moves).toHaveLength(count);
|
||||||
|
expect(exit).toHaveBeenCalledOnce();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues from the visible reflection when re-entering during its fade", () => {
|
||||||
|
const { hover, moves } = setup();
|
||||||
|
hover.enter({ clientX: 16, clientY: 32 });
|
||||||
|
hover.leave();
|
||||||
|
vi.advanceTimersByTime(200);
|
||||||
|
hover.enter({ clientX: 48, clientY: 32 });
|
||||||
|
expect(moves.at(-1)).toBe(16);
|
||||||
|
vi.advanceTimersByTime(80);
|
||||||
|
expect(moves.at(-1)).toBeGreaterThan(16);
|
||||||
|
expect(moves.at(-1)).toBeLessThan(48);
|
||||||
|
hover.leave();
|
||||||
|
vi.advanceTimersByTime(800);
|
||||||
|
hover.enter({ clientX: 32, clientY: 32 });
|
||||||
|
expect(moves.at(-1)).toBe(32);
|
||||||
|
hover.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import timing from "./persona-hover-timing.json";
|
||||||
|
|
||||||
|
type Point = { clientX: number; clientY: number };
|
||||||
|
|
||||||
|
export function createPersonaHover(canvas: HTMLCanvasElement) {
|
||||||
|
let position: Point | undefined;
|
||||||
|
let target: Point | undefined;
|
||||||
|
let frame: number | undefined;
|
||||||
|
let exitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let previousTime = 0;
|
||||||
|
let active = false;
|
||||||
|
let exitedAt = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
const dispatch = (type: "mouseover" | "mousemove" | "mouseout") => {
|
||||||
|
if (position) canvas.dispatchEvent(new MouseEvent(type, position));
|
||||||
|
};
|
||||||
|
const stopFollowing = () => {
|
||||||
|
if (frame !== undefined) cancelAnimationFrame(frame);
|
||||||
|
frame = undefined;
|
||||||
|
};
|
||||||
|
const clearExit = () => {
|
||||||
|
clearTimeout(exitTimer);
|
||||||
|
exitTimer = undefined;
|
||||||
|
};
|
||||||
|
const follow = (time: number) => {
|
||||||
|
frame = undefined;
|
||||||
|
if (!position || !target || !active) return;
|
||||||
|
const weight = 1 - Math.exp(-(time - previousTime) / timing.followMs);
|
||||||
|
previousTime = time;
|
||||||
|
position.clientX += (target.clientX - position.clientX) * weight;
|
||||||
|
position.clientY += (target.clientY - position.clientY) * weight;
|
||||||
|
const settled = Math.hypot(target.clientX - position.clientX, target.clientY - position.clientY) < 0.05;
|
||||||
|
if (settled) position = { ...target };
|
||||||
|
dispatch("mousemove");
|
||||||
|
if (!settled) frame = requestAnimationFrame(follow);
|
||||||
|
};
|
||||||
|
const enter = (point: Point) => {
|
||||||
|
clearExit();
|
||||||
|
target = { clientX: point.clientX, clientY: point.clientY };
|
||||||
|
if (!active) {
|
||||||
|
active = true;
|
||||||
|
if (!position || performance.now() - exitedAt >= timing.exitMs) position = { ...target };
|
||||||
|
// Set the target before revealing the reflection so it never flashes at
|
||||||
|
// the artboard's default center on entry or re-entry.
|
||||||
|
dispatch("mousemove");
|
||||||
|
dispatch("mouseover");
|
||||||
|
}
|
||||||
|
if (frame === undefined) {
|
||||||
|
previousTime = performance.now();
|
||||||
|
frame = requestAnimationFrame(follow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const move = (point: Point) => {
|
||||||
|
if (!active) return enter(point);
|
||||||
|
target = { clientX: point.clientX, clientY: point.clientY };
|
||||||
|
if (frame === undefined) {
|
||||||
|
previousTime = performance.now();
|
||||||
|
frame = requestAnimationFrame(follow);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const exit = () => {
|
||||||
|
clearExit();
|
||||||
|
stopFollowing();
|
||||||
|
if (active) dispatch("mouseout");
|
||||||
|
active = false;
|
||||||
|
exitedAt = performance.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enter,
|
||||||
|
move,
|
||||||
|
leave: () => {
|
||||||
|
clearExit();
|
||||||
|
// Do not follow the pointer's outside coordinates. Finish following the
|
||||||
|
// last in-bounds target, then let Rive fade at that position.
|
||||||
|
if (active) exitTimer = setTimeout(exit, timing.exitHoldMs);
|
||||||
|
},
|
||||||
|
cancel: exit,
|
||||||
|
dispose: () => {
|
||||||
|
clearExit();
|
||||||
|
stopFollowing();
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"path": "/agent/obsidian-water-v1.riv",
|
"path": "/agent/obsidian-water-v1.riv",
|
||||||
"sha256": "ea556c57d3fd966710fc1c31af09dc706c72f6f2570a82a7e4201cde3c332a00",
|
"sha256": "367c4856b54a47bdae868b772edb4281885d0c33653079c8c2a998ecea27fb4d",
|
||||||
"byteLength": 9879
|
"byteLength": 9929
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const riveMocks = vi.hoisted(() => {
|
const riveMocks = vi.hoisted(() => {
|
||||||
const bindViewModelInstance = vi.fn();
|
const bindViewModelInstance = vi.fn();
|
||||||
@@ -41,6 +41,7 @@ beforeEach(() => {
|
|||||||
input.value = false;
|
input.value = false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
describe("Persona", () => {
|
describe("Persona", () => {
|
||||||
it("loads the original bytes after deferred initialization without a View Model or color filter", async () => {
|
it("loads the original bytes after deferred initialization without a View Model or color filter", async () => {
|
||||||
@@ -72,6 +73,7 @@ describe("Persona", () => {
|
|||||||
it("forwards mouse coordinates and exit to Rive without turning touch into hover", async () => {
|
it("forwards mouse coordinates and exit to Rive without turning touch into hover", async () => {
|
||||||
const { getByLabelText } = render(<Persona />);
|
const { getByLabelText } = render(<Persona />);
|
||||||
await waitFor(() => expect(riveMocks.load).toHaveBeenCalled());
|
await waitFor(() => expect(riveMocks.load).toHaveBeenCalled());
|
||||||
|
vi.useFakeTimers();
|
||||||
const wrapper = getByLabelText("Rive Persona").parentElement!;
|
const wrapper = getByLabelText("Rive Persona").parentElement!;
|
||||||
const move = vi.fn();
|
const move = vi.fn();
|
||||||
const exit = vi.fn();
|
const exit = vi.fn();
|
||||||
@@ -85,10 +87,17 @@ describe("Persona", () => {
|
|||||||
pointer("pointermove", "mouse", 16);
|
pointer("pointermove", "mouse", 16);
|
||||||
pointer("pointermove", "mouse", 48);
|
pointer("pointermove", "mouse", 48);
|
||||||
expect(move).toHaveBeenNthCalledWith(1, expect.objectContaining({ clientX: 16, clientY: 48 }));
|
expect(move).toHaveBeenNthCalledWith(1, expect.objectContaining({ clientX: 16, clientY: 48 }));
|
||||||
expect(move).toHaveBeenNthCalledWith(2, expect.objectContaining({ clientX: 48, clientY: 48 }));
|
expect(move).toHaveBeenCalledOnce();
|
||||||
|
vi.advanceTimersByTime(80);
|
||||||
|
const intermediate = (move.mock.lastCall![0] as MouseEvent).clientX;
|
||||||
|
expect(intermediate).toBeGreaterThan(16);
|
||||||
|
expect(intermediate).toBeLessThan(48);
|
||||||
|
vi.advanceTimersByTime(800);
|
||||||
|
expect(move).toHaveBeenLastCalledWith(expect.objectContaining({ clientX: 48, clientY: 48 }));
|
||||||
|
const count = move.mock.calls.length;
|
||||||
pointer("pointermove", "touch", 32);
|
pointer("pointermove", "touch", 32);
|
||||||
pointer("pointerdown", "touch", 32);
|
pointer("pointerdown", "touch", 32);
|
||||||
expect(move).toHaveBeenCalledTimes(2);
|
expect(move).toHaveBeenCalledTimes(count);
|
||||||
pointer("pointercancel", "mouse", 48);
|
pointer("pointercancel", "mouse", 48);
|
||||||
expect(exit).toHaveBeenCalledOnce();
|
expect(exit).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { RiveParameters } from "@rive-app/react-webgl2";
|
import type { RiveParameters } from "@rive-app/react-webgl2";
|
||||||
import { EventType, useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
import { EventType, useRive, useStateMachineInput } from "@rive-app/react-webgl2";
|
||||||
import { memo, useEffect, useState } from "react";
|
import { memo, useEffect, useRef, useState } from "react";
|
||||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { cn } from "@/shared/ui/cn";
|
import { cn } from "@/shared/ui/cn";
|
||||||
import { loadPersonaResource } from "./persona-resource";
|
import { loadPersonaResource } from "./persona-resource";
|
||||||
|
import { createPersonaHover } from "./persona-hover";
|
||||||
|
|
||||||
const STATE_MACHINE = "default";
|
const STATE_MACHINE = "default";
|
||||||
|
|
||||||
@@ -69,6 +70,17 @@ export const Persona = memo(function Persona({
|
|||||||
const thinkingInput = useStateMachineInput(rive, STATE_MACHINE, "thinking");
|
const thinkingInput = useStateMachineInput(rive, STATE_MACHINE, "thinking");
|
||||||
const speakingInput = useStateMachineInput(rive, STATE_MACHINE, "speaking");
|
const speakingInput = useStateMachineInput(rive, STATE_MACHINE, "speaking");
|
||||||
const asleepInput = useStateMachineInput(rive, STATE_MACHINE, "asleep");
|
const asleepInput = useStateMachineInput(rive, STATE_MACHINE, "asleep");
|
||||||
|
const hoverRef = useRef<ReturnType<typeof createPersonaHover> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvas) return;
|
||||||
|
const hover = createPersonaHover(canvas);
|
||||||
|
hoverRef.current = hover;
|
||||||
|
return () => {
|
||||||
|
hover.dispose();
|
||||||
|
hoverRef.current = null;
|
||||||
|
};
|
||||||
|
}, [canvas]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (listeningInput) {
|
if (listeningInput) {
|
||||||
@@ -85,24 +97,21 @@ export const Persona = memo(function Persona({
|
|||||||
}
|
}
|
||||||
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
|
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
|
||||||
|
|
||||||
const forwardPointer = (event: ReactPointerEvent<HTMLSpanElement>, type: "mouseover" | "mousemove" | "mouseout") => {
|
const forwardPointer = (event: ReactPointerEvent<HTMLSpanElement>, type: "enter" | "move" | "leave" | "cancel") => {
|
||||||
if (!event.isPrimary || event.pointerType === "touch") return;
|
if (!event.isPrimary || event.pointerType === "touch") return;
|
||||||
// Let Rive transform CSS coordinates into artboard coordinates and update
|
// Let Rive transform CSS coordinates into artboard coordinates and update
|
||||||
// its original pointer constraint. Only forward hover events, never presses
|
// its original pointer constraint after smoothing CSS coordinates. Only
|
||||||
// or touch-generated mouse events, so mobile taps retain their button action.
|
// hover events enter this path, so mobile taps retain their button action.
|
||||||
canvas?.dispatchEvent(new MouseEvent(type, {
|
hoverRef.current?.[type](event);
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("relative block size-16 shrink-0", className)}
|
className={cn("relative block size-16 shrink-0", className)}
|
||||||
onPointerEnter={(event) => forwardPointer(event, "mouseover")}
|
onPointerEnter={(event) => forwardPointer(event, "enter")}
|
||||||
onPointerMove={(event) => forwardPointer(event, "mousemove")}
|
onPointerMove={(event) => forwardPointer(event, "move")}
|
||||||
onPointerLeave={(event) => forwardPointer(event, "mouseout")}
|
onPointerLeave={(event) => forwardPointer(event, "leave")}
|
||||||
onPointerCancel={(event) => forwardPointer(event, "mouseout")}
|
onPointerCancel={(event) => forwardPointer(event, "cancel")}
|
||||||
>
|
>
|
||||||
<RiveComponent className="pointer-events-none size-full" />
|
<RiveComponent className="pointer-events-none size-full" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ test("renders original Obsidian, caches across reloads and releases offscreen re
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("pointer coordinates reach the native hover listener in the launcher and header", async ({ page }) => {
|
test("pointer coordinates reach the native hover listener in the launcher and header", async ({ page }, testInfo) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const addListener = HTMLCanvasElement.prototype.addEventListener;
|
const addListener = HTMLCanvasElement.prototype.addEventListener;
|
||||||
HTMLCanvasElement.prototype.addEventListener = function (type, listener, options) {
|
HTMLCanvasElement.prototype.addEventListener = function (type, listener, options) {
|
||||||
@@ -144,7 +144,8 @@ test("pointer coordinates reach the native hover listener in the launcher and he
|
|||||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||||
const launcher = page.getByLabel("Agent 折叠栏");
|
const launcher = page.getByLabel("Agent 折叠栏");
|
||||||
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
const panel = page.getByRole("complementary", { name: "Agent 命令面板" });
|
||||||
for (const orb of [launcher.locator("[data-agent-orb]"), panel.locator("header [data-agent-orb]")]) {
|
const orbs = [launcher.locator("[data-agent-orb]"), panel.locator("header [data-agent-orb]")];
|
||||||
|
for (const [index, orb] of orbs.entries()) {
|
||||||
await ready(orb);
|
await ready(orb);
|
||||||
const bounds = (await orb.boundingBox())!;
|
const bounds = (await orb.boundingBox())!;
|
||||||
for (const x of [0.25, 0.75, 0.5]) {
|
for (const x of [0.25, 0.75, 0.5]) {
|
||||||
@@ -154,6 +155,13 @@ test("pointer coordinates reach the native hover listener in the launcher and he
|
|||||||
return point ? JSON.parse(point) : null;
|
return point ? JSON.parse(point) : null;
|
||||||
}).toEqual({ x, y: 0.5 });
|
}).toEqual({ x, y: 0.5 });
|
||||||
}
|
}
|
||||||
|
await testInfo.attach(`original-hover-${index === 0 ? "launcher" : "header"}`, {
|
||||||
|
body: await page.screenshot({
|
||||||
|
path: `/tmp/nexttjwater-persona-hover-${index}.png`,
|
||||||
|
clip: { x: Math.max(0, bounds.x - 12), y: Math.max(0, bounds.y - 12), width: 240, height: 104 }
|
||||||
|
}),
|
||||||
|
contentType: "image/png"
|
||||||
|
});
|
||||||
await page.mouse.move(600, 600);
|
await page.mouse.move(600, 600);
|
||||||
await expect(orb).toHaveAttribute("data-hovered", "false");
|
await expect(orb).toHaveAttribute("data-hovered", "false");
|
||||||
if (!(await panel.isVisible())) await launcher.getByRole("button").click();
|
if (!(await panel.isVisible())) await launcher.getByRole("button").click();
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ window.ready = new Promise(resolve => RuntimeLoader.getInstance(resolve)).then(r
|
|||||||
context.clearRect(0, 0, 192, 192);
|
context.clearRect(0, 0, 192, 192);
|
||||||
context.drawImage(canvas, 0, 0);
|
context.drawImage(canvas, 0, 0);
|
||||||
const pixels = context.getImageData(0, 0, 192, 192).data;
|
const pixels = context.getImageData(0, 0, 192, 192).data;
|
||||||
let visiblePixels = 0, bluePixels = 0, outerPixels = 0, compositedBluePixels = 0;
|
let visiblePixels = 0, bluePixels = 0, outerPixels = 0, compositedBluePixels = 0, redSum = 0;
|
||||||
let minimumBodyAlpha = 255;
|
let minimumBodyAlpha = 255;
|
||||||
for (let i = 0; i < pixels.length; i += 4) {
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
redSum += pixels[i];
|
||||||
const x = (i / 4) % 192 + 0.5 - 96;
|
const x = (i / 4) % 192 + 0.5 - 96;
|
||||||
const y = Math.floor(i / 4 / 192) + 0.5 - 96;
|
const y = Math.floor(i / 4 / 192) + 0.5 - 96;
|
||||||
// Sample inside the feathered rim: map lines should not remain visible
|
// Sample inside the feathered rim: map lines should not remain visible
|
||||||
@@ -60,7 +61,7 @@ window.ready = new Promise(resolve => RuntimeLoader.getInstance(resolve)).then(r
|
|||||||
if ((pixels[i + 2] - pixels[i]) * pixels[i + 3] / 255 > 20) compositedBluePixels++;
|
if ((pixels[i + 2] - pixels[i]) * pixels[i + 3] / 255 > 20) compositedBluePixels++;
|
||||||
if (Math.hypot(x, y) > 79) outerPixels++;
|
if (Math.hypot(x, y) > 79) outerPixels++;
|
||||||
}
|
}
|
||||||
snapshots.push({ image: canvas.toDataURL(), visiblePixels, bluePixels, outerPixels, compositedBluePixels, minimumBodyAlpha });
|
snapshots.push({ image: canvas.toDataURL(), visiblePixels, bluePixels, outerPixels, compositedBluePixels, minimumBodyAlpha, redSum });
|
||||||
}
|
}
|
||||||
return snapshots;
|
return snapshots;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -79,6 +80,7 @@ declare global {
|
|||||||
outerPixels: number;
|
outerPixels: number;
|
||||||
compositedBluePixels: number;
|
compositedBluePixels: number;
|
||||||
minimumBodyAlpha: number;
|
minimumBodyAlpha: number;
|
||||||
|
redSum: number;
|
||||||
}[]>;
|
}[]>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,6 +196,56 @@ test("original-size hover reflections follow the pointer instead of whitening th
|
|||||||
expect(result.recovered).toBe(true);
|
expect(result.recovered).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the original reflection fades in over 150ms and fades out over 600ms without changing its material", async ({ page }, testInfo) => {
|
||||||
|
const result = await page.evaluate(async () => {
|
||||||
|
const steps = [
|
||||||
|
{ state: "idle", frames: 90 },
|
||||||
|
{ state: "hover", frames: 1, pointer: [64, 64] as [number, number] },
|
||||||
|
{ state: "hover", frames: 4 },
|
||||||
|
{ state: "hover", frames: 6 },
|
||||||
|
{ state: "hover", frames: 180 },
|
||||||
|
{ state: "idle", frames: 1 },
|
||||||
|
{ state: "idle", frames: 11 },
|
||||||
|
{ state: "idle", frames: 12 },
|
||||||
|
{ state: "idle", frames: 24 }
|
||||||
|
];
|
||||||
|
const edited = await window.renderResource("modified", steps);
|
||||||
|
const idle = await window.renderResource("modified", steps.map(({ frames }) => ({ state: "idle", frames })));
|
||||||
|
const original = await window.renderResource("recolored", steps);
|
||||||
|
return {
|
||||||
|
brightness: edited.map((frame, index) => frame.redSum - idle[index].redSum),
|
||||||
|
materialPreserved: edited[4].image === original[4].image,
|
||||||
|
restored: edited[8].image === idle[8].image,
|
||||||
|
images: edited.slice(1).map((frame) => frame.image)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.evaluate((images) => {
|
||||||
|
const labels = ["Enter 17ms", "Enter 83ms", "Enter 183ms", "Original reflection", "Exit 17ms", "Exit 200ms", "Exit 400ms", "Settled"];
|
||||||
|
document.body.replaceChildren();
|
||||||
|
document.body.style.cssText = "display:flex;flex-wrap:wrap;gap:16px;background:#f5f7fa";
|
||||||
|
for (const [index, source] of images.entries()) {
|
||||||
|
const figure = document.createElement("figure");
|
||||||
|
figure.style.margin = "0";
|
||||||
|
const image = new Image(192, 192);
|
||||||
|
image.src = source;
|
||||||
|
const caption = document.createElement("figcaption");
|
||||||
|
caption.textContent = labels[index];
|
||||||
|
figure.append(image, caption);
|
||||||
|
document.body.append(figure);
|
||||||
|
}
|
||||||
|
}, result.images);
|
||||||
|
const sequence = await page.screenshot({ path: "/tmp/nexttjwater-persona-timing.png" });
|
||||||
|
await testInfo.attach("original-reflection-timing", { body: sequence, contentType: "image/png" });
|
||||||
|
expect(result.brightness[1]).toBeLessThan(result.brightness[2]);
|
||||||
|
expect(result.brightness[2]).toBeLessThan(result.brightness[3]);
|
||||||
|
expect(result.brightness[3]).toBeGreaterThan(1000);
|
||||||
|
expect(result.materialPreserved).toBe(true);
|
||||||
|
expect(result.brightness[5]).toBeGreaterThan(result.brightness[6]);
|
||||||
|
expect(result.brightness[6]).toBeGreaterThan(result.brightness[7]);
|
||||||
|
expect(result.brightness[7]).toBeGreaterThan(0);
|
||||||
|
expect(result.restored).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("the original disappears offline while the edited resource keeps moving and recovers", async ({ page }) => {
|
test("the original disappears offline while the edited resource keeps moving and recovers", async ({ page }) => {
|
||||||
const result = await page.evaluate(async () => {
|
const result = await page.evaluate(async () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
|
|||||||
Reference in New Issue
Block a user