它都会些什么
同一只狗,两个容器
挂件态 ↔ 窗口态是即时换容器重画——同一份 SVG,同一副骨架, 同一个状态对象。下面这张是把窗口态拆开的两块实机裁片。


左栏的胶囊环标的是你设的基线,徽章显示的是它现在的样子—— 两者不一样时是对的,不是坏了。 中间会多一行边注写出原因。两个状态点说的是同一件事:顶栏右上那个和底栏左下那个 (模型已就绪)必须同时变色,不一致会被你立刻看见。
挂件态220 × 240
无边框、透明、置顶、不占任务栏。画布上只有小狗、它脚下的一小片接触阴影、 可选的一枚挂牌。不加底板、不加阴影容器——小狗就得直接落在桌面上。
窗口态980 × 648
双栏。左边它自己(小狗卡 / 心情 / 人格 / 模型),右边对话。 窗口态里它也活着:同一个骨架、同一个行为状态,只是不再走动—— 省下的是「走动」,不是「活着」。
未读点最多到 9
它说了话而你没看见时,缎带上会多一颗小圆点。位置按当前姿态算、 钉在缎带上——缎带随头走,头一动它就得跟着挪。宠物不是收件箱,所以数到 9 就不涨了。
挂牌200ms 展开
行李牌挂在缎带上垂下,白瓷盘 + hairline + 三角指针,里面有最近一条消息与一个输入框。 入口只在悬停时出现——所以挂件态平时只有狗和它自己说的话。

首启引导牌
一个没有边框、没有标题栏的悬浮物,必须自己说明怎么用。拖我搬家 · 点我摸头 · 双击打开聊天—— 它挂在缎带上,指向小狗头顶那枚蝴蝶结。只出现一次, 任一交互即收走,之后不再打扰你。
四种姿势,一条路径都不用重画
马尔济斯是一团白毛,所以本体不是贴图,是由「绒球」拼出来的: 绕椭圆取样若干点,点与点之间用向外鼓起的圆弧连接,形成单一的蓬松轮廓。
六种心情 · 六条缎带
缎带的色相就是心情。全站唯一允许的饱和色,在这一张图里出现了六次—— 这是它唯一的例外,因为这里展示的正是「同一只狗戴不同缎带」这件事本身。 姿态也跟着心情走:心情是唯一真相来源,姿态只是它的表现。

开心♥ happy · sit兴奋★ excited · stand平静☁ calm · lie困倦z sleepy · curl亲昵♥ loving · sit顽皮● playful · stand
这一张由 maltese.exe --dump-pose-sheet 直接渲染
身体怎么画的fluffPath()
每条轮廓是一串向外鼓起的圆弧。绒边数与蓬松度是姿态表里的两个数字—— 换个姿势就是换一组数,路径一条都不用重画。
头与尾局部坐标 + 平移旋转
头和尾在各自的局部坐标里画好,再由姿态给出平移与旋转。 所以「趴着」和「蜷着」的头,画的是同一条路径。
体积只用一个线索occl
头压在身上的一道遮挡影,是唯一的立体暗示。不用渐变、不用多层高光—— 白瓷要么干净,要么就脏了。
姿态归心情管mood.pose
四种姿态 stand / sit / lie / curl 分别属于亢奋、安稳、平静、困倦这四种心情。姿态不能自由设:一旦允许它独立变化,用户刚点的「兴奋」就会和眼前趴着的狗打架。
缎带在飘2.6s
缎带上两条飘带一直在轻轻摆。唯一饱和色上的一点动,值这个成本。 蜷睡时尾巴收在身前,所以摇尾会停——它睡着的时候尾巴不该还在摇。
三层来源,只渲染一层
心情不是一个「只有用户能改的字段」。 只要它是一个字段,就必然有多个写入方,而它们必然互相覆盖—— 被摸出来的亲昵盖掉刚设的心情,醒来又盖掉亲昵。
基线base · 永久
你点心情胶囊写的。持久化存的就是这一层—— 若存当前值,某次深夜退出后「困倦」就成了新的默认,你下次打开会发现小狗永远困着。
漂移drift · 几分钟
小狗自己的状态写的。刻意的只有一条:聊得太起劲 → 兴奋。 「困」交给打盹统一处理,因为同一个行为有两种表达方式时, 你得选那个能落到单一状态上的——否则「它到底睡没睡」在界面上无法判断。
即时反应react · 9 秒
被摸头写的:连摸两下切到「亲昵」,9 秒后自己落回基线。 一直摸就一直续期,而不是「从第一次摸算起 9 秒后必然回落」。
优先级effectiveMood()
三层显式分开之后,每个写入方只写自己那一层,优先级由一处决定。渲染的永远只是「当前生效」的那一层,恢复时一定落回基线。
自主性必须可见边注
小狗自己把心情改成「困倦」却不说话,用户只会认为心情按钮坏了。 所以徽章显示现状、胶囊环显示你的设定、中间一行边注写原因:睡着了 / 聊得太起劲 / 被你摸得开心。自主性不可见,就等于 bug。
手动出口点已选中的那枚
分层解决了「互相覆盖」,却引入一个死角:狗漂到别的心情时, 用户点那枚已选中的胶囊想回到基线,会被当成重复点击忽略掉。所以点自己已选中的那枚 = 清掉漂移与即时反应,立刻回到基线。
开关不上色墨色轨道
设置里有两三个开关同时打开,若都填缎带红,一屏就有三四块饱和色, 「唯一饱和色」这个前提当场失效。所以开关与滑杆一律墨色, 缎带红只留给这一屏你该按的那个按钮。
每 2.6–6.4 秒醒一次的循环
挂件态下有一个加权随机的行为循环。权重是设计的一部分,不是随手填的常数。
踱步42% · 280–560ms/步
走到桌面上的新落点,easeInOut,身体随步伐上下 3.5px。朝向左时整只狗水平翻一下,否则会「倒着走」。
抖一下20% · 520ms
原地一个小跳。像刚洗完澡那样。
伸懒腰8% · 900ms
纵向压缩再回弹。
什么都不做30%
只有呼吸、眨眼、摇尾。安静也是宠物的一部分—— 如果它每一秒都在动,那不叫活的,那叫动画。
打盹45s / 夜 20s
长时间没人理就自己蜷起来睡。夜里它困得更快:23:00–06:00 门槛缩到 20 秒。 打盹与形态无关——它是状态,不是动作,所以窗口开着一整个下午它也会困。
凝视380px · 上限 3.2px
鼠标进到它头周围 380 像素内,眼睛就朝鼠标偏。它在看你打字—— 窗口态也生效,它趴在卡片上看着你。
谁让位给谁stopWalk()
拖拽、键盘微调、打开挂牌、右键菜单、生成回复、模型加载——这几处都会先掐掉在途的踱步。 否则自主补间与拖拽会同时写同一个位置,而且踱步走到终点时还会把刚拖好的位置存盘覆盖掉。
三套词库,不是三个语气开关
同一句情境,三种狗说三样话。 人格如果只多一句自我介绍、之后说话一模一样,这个控件就不该存在。
| 人格 | 它是谁 | 心情好时它会说 |
|---|---|---|
| 小狗 | 标准马尔济斯:爱说话、爱撒娇 | 尾巴都摇酸了,你看见了吗? |
| 黏人 | 分离焦虑,总在确认你还在 | 别走开好不好?就再待一会儿。 |
| 顽皮 | 球、追、捣乱,感叹号高频 | 汪!心情好得想咬自己尾巴! |
触发词 ≠ 说出口的词words / topic
words 是拿来匹配的(什么形态都有:饭 / 饿 / 外卖),topic 是真正填进句子的那个词(吃饭)。早先拿命中词直接填模板,于是出现「去饭吧」 「好,饭。」「你球的时候」——能匹配到,不等于说得出口。
收紧词表的标准误伤之后还得成立
困→困难、忙→帮忙 保留 (「困难啊……那先别撑着」仍然是一句合适的安慰),赶→赶紧、吃→吃惊、球→地球 一律删掉——它们会说出胡话。 但删掉裸词之后必须用复合词补回覆盖,否则「晚饭还没吃」会一个意图都匹配不到。收得过头和收得不够一样是坏的。
记忆是看得见的3 条 · 20 分钟
侧栏常驻一行 记得 累 · 吃饭。最多三条,超过 20 分钟就忘,说出口就抹掉。 三件事各解决一个问题:看不见的记忆只是魔法;不过期就变成「它半小时后还惦记你下午随口说的两个字」; 不抹掉就不是记忆而是存档——记住了却永远不提,等于没记。
回访要等一等≥ 12s
刚说完就「回访」不叫惦记,叫复读。所以回访要等条目至少搁了 12 秒, 而且要挑够旧的那条——不是最新的那条。
温度只管选句0.0 – 1.0
低温固定挑第一句、高温随机挑。它不影响速度——一步一个 token,温度不改变延迟。 所以文案写的是「低 = 说得稳;高 = 更跳(不影响速度)」。
没有模型也活--no-model
不加载模型时,回复走内置台词表。同样是那三层顺序:会说话的是台词表,不是模型的说法不成立——说什么时候说,一直由这套结构决定。
住在桌面上要处理的事
一只宠物要在 Windows 桌面上待一整天,这些细节比动画重要。
它看得到壁纸的明暗8 个采样点
接触阴影是暗色的,放在深色壁纸上等于不存在,狗会「浮」起来。 所以它会读挂件背后 8 个点的像素,算一下这块是亮是暗,在最暗影与最亮影之间切。 白瓷的 hairline 描边是第二道保险:就算阴影完全失效,轮廓依然成立。 采样做过节流——最快 250ms 一次,位置不动时每秒一次。
托盘是第二个入口Shell_NotifyIconW
右键出菜单:切心情、换个说法、打开窗口、设置、退出。 挂件态没有任务栏按钮,所以托盘是它唯一的「系统级」存在。
只允许开一只Local\Maltese.SingleInstance
命名互斥体。第二个实例直接退出(返回码 1)——否则两只狗会抢着写同一份存档。
位置跨会话记住widget {x, y}
拖到哪儿,下次打开就在哪儿。位置和心情、人格、对话一起写进 maltese_state.json。
音效4 档 · 程序内合成
摸头(两个变体:一声「汪」、一声更闷的「哼」)、 睡着呼噜(气流 × 呼吸包络 + 82Hz 低频打底)、 回复到达的两声「叮咚」、打字机(每一下的中心频率与间隔都随机——真打字不是节拍器)。 波形是程序自己算出来的,不是打包进去的音频文件:采样率 44100,音高、包络、噪声中心频率全都写在 src/sound.rs 里,逐条照抄就能复现同一批声音——首页那只狗下面有一个开关,可以在浏览器里直接听。 默认打开,关掉就整套静音。
出口maltese_chat_YYYYMMDD_HHMMSS.txt
「导出对话」真的写文件,完成后弹一枚白瓷提示条。 提示条从窗口顶部中间落下,位置落在聊天标题线之下—— 它盖住的必须是最不重要的那个元素,绝不能压输入框。
设置里只有四件事显示 / 采样 / 键盘 / 关于
显示状态栏、显示模型信息、音效、窗口置顶。小节标题用铭牌标签,不用图标。人格与模型不在这里——它们是左栏那两个瓷盘式下拉。
键盘路径是完整的Tab → ↑↓ → Enter
挂件态:Tab 聚焦小狗 → 方向键微调位置(Shift 加速)→ Enter 说话 → Esc 关闭。 焦点环恒为墨色,不随心情变——平静与顽皮对素瓷只有约 2.6:1, 而焦点环是键盘用户唯一的位置线索。
换你自己的模型models/*.json[.gz]
下拉里会列出 exe 同级 models/ 里找到的检查点。 读不出来的那个不会被「认下来」——界面上的一次失败,不该变成持久状态里的一句谎。