* feat: 演示模式(合成会话 + 真 git 工作区)用于脱敏截图 真实数据里全是内部项目名、分支名和 commit 标题,不能对外;而且要等真实 会话凑出"1 个在等你 + 2 个在忙 + 1 个跑命令 + 几个闲着"这种展示效果好的 组合纯靠运气。合成数据同时解决不泄露和每次截图完全一致两件事。 实现上往临时目录写 sessions/<PID>.json 再让 reader 指过去,而不是在 store 里塞假数据 —— 这样走的是和生产完全一样的解析路径,截出来的图能反映真实 渲染结果。git 工作区也建真仓库(真分支、真提交、真未提交改动),理由相同。 ClaudeSessionReader 因此需要 requiresLiveProcess 开关:合成会话的进程当然 不存在,kill(pid,0) 会把它们全过滤掉。生产必须保持 true。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(hubctl): 加 HUB_JUDGE 守卫,封死 AI 判定的 hook 回环 Hub 接下来要自己起 claude -p 给滞留的会话生成摘要。那个子进程会照常 触发 Stop/SessionEnd hook,于是 hook → Hub → claude -p → hook 形成 无限回环,每判定一次多生一个会话。 守卫见到 HUB_JUDGE=1 就在发 socket 之前 exit(0)。这道防线不依赖 Claude CLI 的任何行为开关,是唯一保证有效的。 顺带加 HUB_TRACE:指向文件路径时每次执行追加一行。这是本机唯一能把 "Hub 起的那个 claude"和"用户另外八个会话"区分开的探针 —— 环境变量会被 子进程继承,别人的会话没有它。之前试过用 hubctl 的 atime 做间接探测, 全是其他会话的噪音,毫无结论。 实测(trace 文件为证): claude -p --setting-sources '' → 0 行,hook 根本没被调起 claude -p(无开关)+ HUB_JUDGE=1 → 2 行 stop/sessionend,均 judge=true 两道防线各自独立有效,实现时两条都上。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(probe): 滞留检测的确定性探针与五类判定 岛现在只在事件发生那一瞬间提醒(闯入 2 秒然后回落成一个静止徽章), 之后再也不喊。用户给的五张实拍截图都是"那一瞬没看到就永远错过"。 这一层负责回答"哪个会话卡住了、为什么",纯逻辑、不碰 UI。 TranscriptTail —— 只读文件尾部 64 KB。这些 jsonl 单个几十 MB、全目录实测 945 MB,UsageStats 早期整个读进内存导致用量页永远转不出来的教训不能重犯。 窗口切在行中间时残缺的首行必须丢掉,否则会解析出半截内容。 定位不猜编码规则:主路径按"非字母数字换 -"拼(30 个真实样本全中), 拼不中就按 sessionId 全局 glob 兜底 —— UUID 唯一,兜底是精确的。 TaskStateReader —— 读 ~/.claude/tasks/。对过账:gateway-config-management 读出 {completed:2, in_progress:1, pending:1},与 Claude 自己界面上的 "4 tasks (2 done, 1 in progress, 1 open)" 完全一致。 StallDetector —— 五类原因按优先级判定: 断线 > 在提问 > 等授权 > 有后续 > 待验收 断线是异常,立即报不等阈值。提问的启发式只看**最后一个非空行**是否以问号 结尾,不扫全文 —— 正文中间的反问句非常常见,扫全文必然误判。 拿真实数据验证:storefront-a-47 被正确判为"❓ 在问你 · 需要我帮你把这两个提交 精准推送到 live 吗?",正是用户截图里那一条,且全程没用 AI。 分级:只有"干了没多久就安静收工"降级成不主动闯入。干活时长必须自己观测 (Claude 显示的 "Crunched for 29m 54s" 不落盘),观测时排除 waiting —— 否则等了两小时授权的会话会被记成"干了两小时"。 hubprobe stall 子命令:滞留提醒是时间驱动的,靠 UI 验证意味着干等几分钟。 这个子命令把每个会话的判定和判据一次打出来,理由同已有的 usage 子命令。 164 个测试通过(原 131)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(probe): StallJudge —— 用订阅额度给滞留会话生成一句人话 职责刻意收窄:**不判断要不要提醒**,只把事情说清楚(摘要、是不是在提问、 回答选项、建议动作)。让模型决定要不要打扰用户是高风险的,误报一次用户 就会把整个功能关掉。收窄之后它失败的代价很低 —— 解析不出来就退回 "完成了一轮,去看看",提醒照发,只是文案糙一点。 走 claude 二进制而不是直接调 API,用的是用户已登录的订阅额度, 不需要另配 ANTHROPIC_API_KEY。不能用 --bare:它跳过 hooks 的同时也跳过 OAuth(帮助原文写明 keychain is never read),等于用不上订阅额度。 踩到的三个坑,都写进注释了: 1. **--disallowedTools 是变长参数,会吞掉后面的位置参数。** 提示词放在它后面时被当成又一个工具名吃掉,模型压根收不到指令。 表现极具迷惑性:三次调用全部"成功"(有返回、扣了钱),但模型只收到 正文,于是直接去回答正文里的问题,返回散文而不是 JSON。 提示词必须紧跟 -p,排在所有变长选项之前。 2. **正文不能走 stdin。** 待分析的文本经常是直接对着读者提问的 ("需要我帮你推送到 live 吗?"),和指令混在一条消息里时模型分不清 边界,会把它当成对自己说的话去执行。改成全塞进提示词、用 <待分析> 标签把数据围死。顺带 stdin 接 /dev/null,省掉 claude -p 干等 3 秒。 3. **字数限制不能指望模型守约。** 实测它给 5 个选项(要求最多 4 个)、 14 字的 nextAction(要求 ≤10 字)。岛上位置是死的,解析时必须再截一次。 围栏也必须剥:即使明说"只输出 JSON 对象",它照样包 ```json。 实测(hubprobe stall --ai):2 次调用 0 失败,$0.024,约 9 秒/次。 storefront-a-47 拿到 4 个可直接做成岛上按钮的选项。 HUB_TRACE 回环探针全程 0 行 —— 守卫有效。 175 个测试通过(原 164)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(probe): NudgeCoordinator —— 会闭嘴的升级阶梯 0 / 3 / 8 / 20 分钟四档递增,之后每 20 分钟。递增而不是固定间隔: 固定间隔在用户"知道但暂时不想处理"时会变成必须关掉的骚扰。 第一轮静默(刚停下来就叫太急躁,而且这时用户多半就在机器前面), 第二轮起出声,第三轮起换 identifier 重发通知 —— 同 id 的通知在通知中心 里是静默替换,不换 id 用户根本看不到"又催了一次"。 **加了计划里没有的两条封顶**,是拿真实数据逼出来的: 本机有个会话已经 idle 46 小时,按"每 20 分钟一轮、不再加码"会主动弹 138 次。那不是提醒,那是必须被关掉的东西。 maxActiveRounds = 5 主动闯入最多五次 giveUpAfter = 2 小时 之后只留跑马灯和下唇染色 放弃"闯入"不等于放弃"告知"—— passive 列表照常带着它,信息一直在岛上。 giveUpAfter 顺手解决了另一个场景:app 重启时会一次性看到几个隔夜会话, 它们的 since 早就超过阈值,所以启动瞬间不会被一串闯入淹没。 多个会话合并成一次提醒,按原因优先级排序,不排队弹 N 次。 用户跳转或应答只压制一轮 —— 跳过去看一眼不等于处理完了。真正的处理完是 会话重新开工,那时它就从 findings 里消失,状态自然清掉,下次再停是新的一件事。 纯状态机,now 显式传入,10 个时序测试跑完整条阶梯不用真等 20 分钟。 185 个测试通过(原 175)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(island): 滞留提醒的岛上表现 + 应答 + 跨屏跟随 ## 两个新形态 .nudge —— 卡住的会话的像素小人从刘海下沿弹出排开,逐个延迟 60ms (一起弹是一整块在动,错开才读得出"一个一个冒出来")。每个小人上方一个 原因图标、下方截断的会话名,整格可点。刻意不复用 .intrusion:后者是 "某件事刚发生",单会话两秒划过;前者是"你有 N 件事卡着",聚合、停 5 秒、可交互。 .answer —— 显示 AI 的问题和它给的快捷回答。**点了按钮不直接发**, 先出"将发送到 iTerm · xxx:是"让用户确认一次。多这一步是因为刘海很小、 用户可能在剔背景的情况下随手点一下,而那一下可能是往生产推代码。 两态取同一个高度:不同的话点按钮时岛会跳,而那一跳恰好发生在用户 正要去点"确认发送"的时候,按钮会在指针底下移位。 折叠态:有东西卡着时点阵换成跑马灯(点阵回答的是"跑着几个", 卡住时要知道的是"哪个、为什么",这时点阵是纯噪音),整条下唇按最高优先级 原因染色。只在有 stall 时才滚,平时回到静态点阵,不引入常驻动画耗电。 ## 回写终端(唯一不可逆的动作) TerminalReply 的五条护栏:发送前重读会话状态(变 busy 说明用户已经自己 在那边动手了,取消并如实说,绝不硬发);只发一行纯文本 + 回车(换行会被 当成多敲一次回车,控制字符能触发终端快捷键);绑定只走 PID 祖先链, 绝不用窗口名匹配;定位不到就退化成跳转;每次发送留痕。 ## 跨屏跟随 岛以前锁死在内建刘海屏,用户看着外接屏时提醒在物理上不在他视野里 —— 这条不解决,跑马灯和主动闯入全是白做。screenUnderCursor() 其实一开始 就写好了,只是全项目零调用。只在折叠态搬家:展开/审批/应答态搬家会把 用户正在点的东西从手底下挪走,尤其应答态按钮一移位,本来要点"取消"的 那一下可能落到"确认发送"上。 ## 实机验证发现并修掉 点快捷回答 → 出预览而不是直接发(护栏生效);确认发送 → 目标会话不存在时 如实报错。但失败提示在点"取消"回到选项后仍挂着,读起来像这一次也失败了 —— 加 onClearFeedback,任何新动作先抹掉上一次的回显。 199 个测试通过(原 185),含 nudge/answer 的尺寸回归 —— SwiftUI 的布局裁切是完全静默的失败,只能靠算术断言挡住。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: 小人多长了一只右手;滞留信息补进展开态;钉住不再卡死界面 ## 挥手的小人有两只右手(用户在 48pt 详情卡上看出来的) waiting 用挥手姿势,bodyRows 在肩膀那行画了举起来的右手,而 laptopRows 仍然把两只手都画在笔记本两侧 —— 同一个人两只右手。而 waiting 恰恰是 最需要被看清的状态,偏偏是它画错。 9 ##ssssssss##f ← 举起来挥的 13 #LLLggggLLL#f ← 同一只手又画了一遍 新增 SpriteAnatomyTests 断言每侧最多一只手。像素画的错误在 32pt 下肉眼 几乎发现不了,但在字符网格上可以精确断言。**回插 bug 验证过测试会红** (24 条断言失败),不然写了等于没写。 ## 滞留信息只活在 nudge 和跑马灯里 用户一展开列表 —— 也就是他真正要动手的地方 —— 滞留信息就消失了, 反而在最该看到的位置看不到。现在: - 详情卡加滞留横幅(原因 + 具体内容),卡住时**顶掉**提交记录: 两者都要位置,而"这件事在等你"永远比"三天前提交了什么"更急; 是提问的话直接给「回答」按钮进应答态。 - 列表行加原因徽章,优先于笼统的 waitingFor("在问你"比 "input needed" 具体)。 - 折叠态点跑马灯**直达那件事**,不再跳到通用列表让用户自己再找一遍。 - 非提问的滞留点开是展开+选中,不直接把人甩到终端 —— 用户点这一下是想先看清楚是什么事。 ## HUB_ISLAND_STATE 会把界面卡死 实机暴露:岛钉在展开态、又跟着光标跑到了外接屏,用户点收起没反应, 只能去杀进程。钉住的本意是挡掉自动迁移(悬停会把它收回去), 不该把用户主动的操作也冻掉。现在 setState 分 userInitiated, 收起和展开一律放行。 顺带:收起时强制重新对齐到光标所在屏 —— 岛跟随只在折叠态生效, 而刚收起正是最该对齐的时刻(用户很可能已经换屏了)。 203 个测试通过(原 199)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: 换掉占位图标,并入 agentx 系列视觉 原来那个三机器人是占位图,和岛里的像素小人、刘海形态完全是两套语言。 沿用 agentx 系列的记号(青→蓝→紫→品红→橙的斜向渐变 + 四角星芒 + 外辉光, 对着 AgentX 和浏览器扩展的图标取的),但主体换成**灵动岛的胶囊形** —— 那是这个 app 唯一的门面,也是它在无刘海屏上的真实形态。 极简的判据是"16pt 下还认得出":一个胶囊 + 一颗星,没有任何细节要分辨。 实测 16pt 下胶囊清楚,但星芒只剩两三个像素、糊成一个污点反而像脏了, 所以 <32pt 不画星芒 —— 光学尺寸的常规做法,小尺寸减细节。 图标用代码生成(Resources/make-icon.swift)而不是塞一个二进制资产: 可以 diff、可以 code review、改一个参数不用重新导出, 和这个项目里像素小人 art 写在代码里是同一个理由。 swift Resources/make-icon.swift AppIcon.iconset iconutil -c icns AppIcon.iconset -o Resources/AppIcon.icns Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(nudge): 升级阶梯按第一次提醒计时,不是按会话卡住的时刻 实机验证抓出来的。修复前的真实时间线: 17:38:10 第 0 轮 17:38:26 第 1 轮 有声 ← 隔 16 秒,阶梯说该隔 3 分钟 延迟原来从 since(会话变 idle 的时刻)算。product-library-fixes 被发现时 已经 idle 4 分钟(240 秒),于是一次就同时满足第 0 轮(≥0 秒)和 第 1 轮(≥180 秒),下一次扫描(30 秒后)立刻又喊。 关键在于这不是边角情况:**"被发现时已经卡了一会儿"才是常态** —— app 刚启动会看到一批、刚满阈值才被判出来的也都是。等于阶梯对绝大多数 会话都是失效的,会连着把前几轮一次性喊完。 改成记 firstNudgedAt,阶梯从第一次喊开始计时。giveUpAfter 仍然按 since 算 —— 它要挡的正是"这事早就在那儿了,你显然不打算现在处理"。 原来那条阶梯测试让 since 和 now 从同一时刻起步,正好绕开了这个场景, 所以一直是绿的。新测试直接构造"发现时已卡 4 分钟",并**回插 bug 验证过 它会红**(2 条断言失败)。 顺带给 present() 加了提醒日志。滞留提醒是时间驱动的,没有日志根本没法 验证"到底喊没喊、第几轮、为什么"—— 这次的 bug 就是靠它才看出来的。 实机同时确认:多会话合并成一次弹出;idle 了 446 分钟的会话一次都没喊 (闭嘴上限有效);折叠态跑马灯在真实数据上正常滚动。 204 个测试通过(原 203)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(nudge): 封住"反复变原因"绕过两道闭嘴上限的洞 "原因变了就重新计时"本身是对的("断线了"和"待验收"是两件事, 不该沿用旧轮次),但它同时开了两个洞: 1. 每翻一次从第 0 轮重来,于是既不升级、也永远撞不到 maxActiveRounds; 2. since 跟着被推到现在,giveUpAfter 那条"卡了两小时"永远够不着。 结果是一个在 idle / waiting 之间来回翻的会话可以无限期地每隔几分钟 弹一次同一个项目 —— 而这正是用户最容易直接关掉整个功能的那种行为。 两处修正: maxLadderRestarts = 2 重置本身封顶,超过之后原因照常更新但轮次不再 归零,阶梯继续往上走直到闭嘴 firstSeenAt 记"连续卡着从何时开始",原因变了也不重置; giveUpAfter 改按它算 会话真的恢复(从 findings 里消失)时整条记录被丢弃,重置额度也跟着清零 —— 否则一个用了很久的会话会因为历史上的几次翻转而再也不能重新计时。 测试断言的是**最终会安静**这个性质,不是某个具体次数:90 分钟里每 5 分钟 翻一次原因,断言最后半小时完全无声、且进入只做被动提示的状态。 回插两个洞验证过会红 —— 无防护时 18 次翻转全部触发,且换原因能绕过 giveUpAfter。 208 个测试通过(原 204)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: 版本升到 2.1.0 滞留守望、岛上应答、跨屏跟随、新图标 —— 都是新能力,走 minor。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: 截图模式不启动守望;钉住日志打实际形态 守望 30 秒后会用自己对演示会话的判定覆盖预置的那组,而演示会话没有真实 transcript 和任务清单,判出来清一色待验收—— 五类原因的区分恰恰是要展示的。 钉住日志原来只打环境变量的值,钉住失效时照样显示已钉住,把排查带偏过一次。 改成同时打实际形态和滞留条数。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: wuguangan <anjaihd0206@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
413 lines
17 KiB
Swift
413 lines
17 KiB
Swift
import HubCore
|
||
import SwiftUI
|
||
|
||
/// 一帧像素画,已按水平游程合并成矩形列表。
|
||
///
|
||
/// 合并很关键:16×16 的图有 256 个格子,但按行合并连续同色像素之后只剩 40–60 个矩形。
|
||
/// 9 个小人 × 50 矩形 × 12fps ≈ 5400 矩形/秒,对 Canvas 是微不足道的量。
|
||
struct SpriteFrame {
|
||
struct Run {
|
||
let x: Int
|
||
let y: Int
|
||
let width: Int
|
||
let ink: SpriteInk
|
||
}
|
||
|
||
let runs: [Run]
|
||
|
||
/// 从字符网格构造。字符含义见 `SpriteInk`。
|
||
init(_ rows: [String]) {
|
||
var out: [Run] = []
|
||
for (y, row) in rows.enumerated() {
|
||
let chars = Array(row)
|
||
var x = 0
|
||
while x < chars.count {
|
||
guard let ink = SpriteInk(character: chars[x]) else {
|
||
x += 1
|
||
continue
|
||
}
|
||
var width = 1
|
||
while x + width < chars.count, chars[x + width] == chars[x] { width += 1 }
|
||
out.append(Run(x: x, y: y, width: width, ink: ink))
|
||
x += width
|
||
}
|
||
}
|
||
runs = out
|
||
}
|
||
}
|
||
|
||
/// 画笔。
|
||
///
|
||
/// ## 为什么从"状态染色的单色剪影"改成"有肤色和衣服的角色"
|
||
///
|
||
/// 旧版每个小人整体用状态色染,四个 busy 会话画出来一模一样 —— 只是四个蓝色块。
|
||
/// 更糟的是轮廓用的是近黑(`0.04,0.05,0.06`),而岛本身也是近黑,
|
||
/// 轮廓完全溶进背景,剩下的就真的只是一坨颜色。
|
||
///
|
||
/// 现在:**形象归角色,状态归屏幕**。
|
||
/// - 发色 / 肤色 / 衣服由 `sessionId` 决定且**永远不变** ——
|
||
/// 你会记住"橙头发那个是 acme-admin",这是单色剪影给不了的辨识度;
|
||
/// - 状态由笔记本屏幕的光色表达,配合下方的状态色条和状态点,一样一眼可读。
|
||
enum SpriteInk {
|
||
case outline // '#'
|
||
case hair // 'h'
|
||
case hairShade // 'H'
|
||
case skin // 'f'
|
||
case skinShade // 'F'
|
||
case eye // 'e'
|
||
case shirt // 's'
|
||
case shirtShade // 'S'
|
||
case laptop // 'L'
|
||
case laptopEdge // 'l'
|
||
case glow // 'g'
|
||
|
||
init?(character: Character) {
|
||
switch character {
|
||
case "#": self = .outline
|
||
case "h": self = .hair
|
||
case "H": self = .hairShade
|
||
case "f": self = .skin
|
||
case "F": self = .skinShade
|
||
case "e": self = .eye
|
||
case "s": self = .shirt
|
||
case "S": self = .shirtShade
|
||
case "L": self = .laptop
|
||
case "l": self = .laptopEdge
|
||
case "g": self = .glow
|
||
default: return nil // '.' 和空格都是透明
|
||
}
|
||
}
|
||
|
||
func color(character: SpriteCharacter, status: SessionStatus) -> Color {
|
||
// idle 的人要"退到后面去",但**不能变成灰色幽灵** —— 那是旧版最难看的地方。
|
||
// 保留原本的色相,只压饱和与亮度,读起来像"灯关了"而不是"这个人不见了"。
|
||
let dim = character.dim
|
||
switch self {
|
||
case .outline: return Color(red: 0.05, green: 0.06, blue: 0.08).opacity(0.95)
|
||
case .hair: return character.hair.opacity(dim)
|
||
case .hairShade: return character.hair.opacity(dim * 0.62)
|
||
case .skin: return character.skin.opacity(dim)
|
||
case .skinShade: return character.skin.opacity(dim * 0.72)
|
||
case .eye: return Color(red: 0.09, green: 0.10, blue: 0.13).opacity(dim)
|
||
case .shirt: return character.shirt.opacity(dim)
|
||
case .shirtShade: return character.shirt.opacity(dim * 0.66)
|
||
// 笔记本压暗一档:它是配角。太亮的话整张图会被一块灰白色块主导,
|
||
// 而人脸才是要被认出来的那部分。
|
||
case .laptop: return Color(white: 0.66).opacity(dim)
|
||
case .laptopEdge: return Color(white: 0.40).opacity(dim)
|
||
// 屏幕的光是状态的载体。idle 时它就是暗的 —— 屏幕熄了,正是"没在干活"。
|
||
case .glow:
|
||
return status == .idle || status == .unknown
|
||
? Color(white: 0.34).opacity(dim)
|
||
: IslandTheme.color(for: status)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 跨进程稳定的哈希。
|
||
///
|
||
/// **不能用 Swift 的 `String.hashValue`** —— 它每次进程启动都会重新加盐,
|
||
/// 同一个 sessionId 在两次运行里得到的值不同。用它做形象派生的话,
|
||
/// 每次重开 app 所有人的发色衣服都会换一遍,"记住橙头发那个是 acme-admin"
|
||
/// 这件事根本立不住。
|
||
///
|
||
/// FNV-1a:够散、够快、结果只取决于输入。
|
||
enum SpriteSeed {
|
||
static func stable(_ value: String) -> Int {
|
||
var hash: UInt64 = 0xcbf2_9ce4_8422_2325
|
||
for byte in value.utf8 {
|
||
hash ^= UInt64(byte)
|
||
hash = hash &* 0x1000_0000_01b3
|
||
}
|
||
// 收到 Int 的正数区间,调用方普遍在做 `abs(seed) % n`。
|
||
return Int(hash % UInt64(Int.max))
|
||
}
|
||
}
|
||
|
||
/// 一个会话的固定形象。
|
||
///
|
||
/// 全部由 `sessionId` 派生,所以**同一个会话永远长同一个样**,
|
||
/// 换机器、重启 app、`--resume` 都不变(sessionId 是 UUID,不是 PID)。
|
||
struct SpriteCharacter {
|
||
let hair: Color
|
||
let skin: Color
|
||
let shirt: Color
|
||
let style: Int // 发型
|
||
let dim: Double
|
||
|
||
/// 发色。有意做得饱和度高、彼此拉开 —— 这是区分会话的主要通道。
|
||
private static let hairPalette: [Color] = [
|
||
Color(red: 0.96, green: 0.62, blue: 0.20), // 橙
|
||
Color(red: 0.36, green: 0.24, blue: 0.18), // 深棕
|
||
Color(red: 0.95, green: 0.83, blue: 0.42), // 金
|
||
Color(red: 0.72, green: 0.25, blue: 0.28), // 酒红
|
||
Color(red: 0.20, green: 0.22, blue: 0.28), // 黑
|
||
Color(red: 0.55, green: 0.42, blue: 0.72), // 紫
|
||
]
|
||
|
||
private static let skinPalette: [Color] = [
|
||
Color(red: 0.98, green: 0.84, blue: 0.72),
|
||
Color(red: 0.91, green: 0.73, blue: 0.58),
|
||
Color(red: 0.76, green: 0.56, blue: 0.42),
|
||
Color(red: 0.55, green: 0.38, blue: 0.28),
|
||
]
|
||
|
||
private static let shirtPalette: [Color] = [
|
||
Color(red: 0.29, green: 0.55, blue: 0.86), // 蓝
|
||
Color(red: 0.30, green: 0.68, blue: 0.52), // 绿
|
||
Color(red: 0.86, green: 0.42, blue: 0.44), // 珊瑚
|
||
Color(red: 0.42, green: 0.44, blue: 0.52), // 灰蓝
|
||
Color(red: 0.88, green: 0.66, blue: 0.30), // 芥黄
|
||
Color(red: 0.52, green: 0.40, blue: 0.66), // 紫
|
||
]
|
||
|
||
static let styleCount = 4
|
||
|
||
init(seed: Int, status: SessionStatus) {
|
||
// 三个属性各用 seed 的不同段,避免"发色一样的人衣服也一样"。
|
||
// 用 magnitude 而不是 abs:`abs(Int.min)` 会 trap。
|
||
let base = Int(seed.magnitude % UInt(Int.max))
|
||
hair = Self.hairPalette[base % Self.hairPalette.count]
|
||
skin = Self.skinPalette[(base / 7) % Self.skinPalette.count]
|
||
shirt = Self.shirtPalette[(base / 13) % Self.shirtPalette.count]
|
||
style = (base / 31) % Self.styleCount
|
||
dim = (status == .idle || status == .unknown) ? 0.55 : 1.0
|
||
}
|
||
}
|
||
|
||
/// 像素小人素材库。
|
||
///
|
||
/// art 用字符串字面量写在代码里,不做成 PNG 资产:可以 diff、可以 code review、
|
||
/// 改一个像素不用重新导出,还能按角色调色。
|
||
///
|
||
/// 构图参考了"人在笔记本后面"这个经典画法:头 + 肩 + 挡在身前的笔记本,
|
||
/// 双手从两侧露出来。好处是**上半身占满画面**,16×16 这么小的网格里
|
||
/// 五官才有地方放;画全身的话头只有 4px,怎么画都是一坨。
|
||
enum SpriteLibrary {
|
||
|
||
/// 取某状态在某个时钟 tick 下应该显示的帧。
|
||
///
|
||
/// - Parameter seed: 用 sessionId 的 hash。它同时决定**形象**(发色、衣服、发型)
|
||
/// 和**动作相位** —— 9 个人整齐划一地同时敲键盘会很诡异。
|
||
static func frame(for status: SessionStatus, tick: Int, seed: Int) -> SpriteFrame {
|
||
let style = SpriteCharacter(seed: seed, status: status).style
|
||
let phase = tick + Int(seed.magnitude % 4)
|
||
|
||
switch status {
|
||
case .busy:
|
||
return SpriteFrame(rows(style: style, pose: .typing(step: phase / 2 % 4)))
|
||
case .shell:
|
||
return SpriteFrame(rows(style: style, pose: .command(step: phase / 2 % 4)))
|
||
case .waiting:
|
||
// waiting **不错峰**:需要用户行动的状态就该整齐地一起挥手,
|
||
// 那是刻意制造的注意力抓取。
|
||
return SpriteFrame(rows(style: style, pose: .waving(step: tick / 2 % 4)))
|
||
case .idle, .unknown:
|
||
return SpriteFrame(rows(style: style, pose: idlePose(tick: tick, seed: seed)))
|
||
}
|
||
}
|
||
|
||
/// idle 是**状态机**而不是纯循环,这是省电的关键。
|
||
///
|
||
/// 9 个会话里通常 6–7 个是 idle。如果它们都按 6fps 循环,前面为了省电做的
|
||
/// 一切都白费了。所以 idle 平时是静止的闭眼,每 8–14 秒才打一次哈欠,
|
||
/// 而且按 seed 错峰 —— 6 个人同时打哈欠会很出戏。
|
||
private static func idlePose(tick: Int, seed: Int) -> Pose {
|
||
let clock = 12 // 主时钟 12 tick = 1 秒
|
||
// **先取模再运算。** 这里曾经写成 `abs(seed) * 37`,而真实的 seed 来自
|
||
// FNV 哈希(量级接近 Int.max),一乘就整数溢出,Swift 直接 trap ——
|
||
// 表现是 app 一显示小人就闪退。任何拿哈希值做算术的地方都要先收进小区间。
|
||
let magnitude = seed.magnitude
|
||
let period = (8 + Int(magnitude % 7)) * clock
|
||
let phase = (tick + Int(magnitude % UInt(period))) % period
|
||
let yawnLength = 2 * clock
|
||
if phase < yawnLength {
|
||
return .yawning(step: (phase * 3) / yawnLength)
|
||
}
|
||
return .resting
|
||
}
|
||
|
||
// MARK: - 姿势
|
||
|
||
enum Pose {
|
||
case typing(step: Int)
|
||
case command(step: Int)
|
||
case waving(step: Int)
|
||
case resting
|
||
case yawning(step: Int)
|
||
}
|
||
|
||
// MARK: - 组装
|
||
|
||
/// 把发型、脸、身体、笔记本拼成 16×16。
|
||
///
|
||
/// 分段拼装而不是给每个状态各写 4 张完整的图:那样是 16 张手写网格,
|
||
/// 改一处(比如笔记本宽一格)要同步改 16 遍,必然漂。
|
||
static func rows(style: Int, pose: Pose) -> [String] {
|
||
var out: [String] = []
|
||
out.append(contentsOf: hairRows(style: style))
|
||
out.append(contentsOf: faceRows(pose: pose))
|
||
out.append(contentsOf: bodyRows(pose: pose))
|
||
out.append(contentsOf: laptopRows(pose: pose))
|
||
// 拼出来必须正好 16 行 —— 少一行会让笔记本贴到画面外。
|
||
return out
|
||
}
|
||
|
||
/// 发型(第 0–3 行,含顶上一行留白)。
|
||
///
|
||
/// 四种,靠**轮廓形状**区分而不只是颜色 —— 缩到 32pt 时形状比颜色更早可辨,
|
||
/// 而且色觉障碍用户也分得出。
|
||
/// 最后一行统一是 10 格宽,好和下面的脸对齐。
|
||
private static func hairRows(style: Int) -> [String] {
|
||
switch style % 4 {
|
||
case 0: // 齐刘海
|
||
return [
|
||
"................",
|
||
".....######.....",
|
||
"....#hhhhhh#....",
|
||
"...#hhhhhhhh#...",
|
||
]
|
||
case 1: // 侧分
|
||
return [
|
||
"................",
|
||
"....#######.....",
|
||
"...#hhhhhhhh#...",
|
||
"...#hhhHHhhh#...",
|
||
]
|
||
case 2: // 蓬松
|
||
return [
|
||
"................",
|
||
"...##h##h##h....",
|
||
"...#hhhhhhhh#...",
|
||
"...#hHhhhhHh#...",
|
||
]
|
||
default: // 丸子头
|
||
return [
|
||
".......##.......",
|
||
"......#hh#......",
|
||
"....#hhhhhh#....",
|
||
"...#hhhhhhhh#...",
|
||
]
|
||
}
|
||
}
|
||
|
||
/// 脸(第 4–8 行)。
|
||
///
|
||
/// 给了 5 行 —— 初版只给 4 行、笔记本占 7 行,结果脸小得只剩两个点,
|
||
/// 整张图被一块灰色笔记本主导。**这是张人物像,不是张笔记本像。**
|
||
/// 眼睛是这套画法里最值钱的两个像素:有眼睛是个人,没眼睛是个色块。
|
||
private static func faceRows(pose: Pose) -> [String] {
|
||
let closed: Bool
|
||
var mouth = "...#ffffffff#..."
|
||
switch pose {
|
||
case .resting:
|
||
closed = true
|
||
case .yawning(let step):
|
||
closed = true
|
||
// 哈欠张嘴:中间挖成阴影。
|
||
mouth = step >= 1 ? "...#ffFeeFff#..." : "...#ffFFFFff#..."
|
||
default:
|
||
closed = false
|
||
}
|
||
|
||
let eyes = closed
|
||
? "...#feeeeeef#..." // 闭眼画成一条横线
|
||
: "...#feffffef#..."
|
||
|
||
return [
|
||
"...#hffffffh#...", // 两侧留一点鬓角
|
||
eyes,
|
||
mouth,
|
||
"....#ffffff#....",
|
||
".....#ffff#.....", // 下巴收窄
|
||
]
|
||
}
|
||
|
||
/// 肩膀(第 9–10 行)。waving 时右侧多一只挥动的手。
|
||
private static func bodyRows(pose: Pose) -> [String] {
|
||
guard case .waving(let step) = pose else {
|
||
return [
|
||
"..##ssssssss##..",
|
||
".#ssssssssssss#.",
|
||
]
|
||
}
|
||
// 手掌左右摆一格,幅度小但在 32pt 下看得出来。
|
||
return [
|
||
step % 2 == 0 ? "..##ssssssss##f." : "..##ssssssss##.f",
|
||
".#ssssssssssss#.",
|
||
]
|
||
}
|
||
|
||
/// 笔记本(第 9–15 行)。
|
||
///
|
||
/// 从背面看,所以是一块盖板 + 中间发光的标志 —— 屏幕的光就是状态。
|
||
/// 双手从两侧露出来,敲键盘时上下动一格。
|
||
private static func laptopRows(pose: Pose) -> [String] {
|
||
// 手的位置用**这一段内部的行号**(0…4),不是整图行号 ——
|
||
// 用整图行号的话,上面任何一段增减一行都会让手悄悄跑到别的地方去。
|
||
var hands = (left: 2, right: 2)
|
||
// 发光区占 4 格宽 × 2 行。初版只有 2×1,那点面积根本传达不了状态。
|
||
var glow = "gggg"
|
||
|
||
switch pose {
|
||
case .typing(let step):
|
||
// 两只手交替上下,敲键盘的节奏就出来了。
|
||
hands = [(1, 2), (2, 1), (2, 2), (1, 1)][step % 4]
|
||
case .command(let step):
|
||
// 命令行:发光区变成一个左右走的光标,和"敲键盘"最容易区分开。
|
||
glow = ["g...", ".g..", "..g.", "...g"][step % 4]
|
||
hands = (2, 2)
|
||
case .waving:
|
||
// **挥手时右手必须离开键盘。**
|
||
//
|
||
// `bodyRows` 已经在肩膀那行画了举起来的右手,这里再画一只在笔记本
|
||
// 右侧,同一个人就有了两只右手 —— waiting 是最需要被看清的状态,
|
||
// 结果偏偏是它画错。给一个落不到 0…4 的行号,右手就不会被画出来。
|
||
hands = (left: 2, right: -1)
|
||
case .resting, .yawning:
|
||
hands = (2, 2)
|
||
}
|
||
|
||
/// `.` + 左手 + `#` + 盖板 10 格 + `#` + 右手 + `.` = 16 格。
|
||
func lid(_ local: Int, _ core: String) -> String {
|
||
let left = hands.left == local ? "f" : "."
|
||
let right = hands.right == local ? "f" : "."
|
||
return ".\(left)#\(core)#\(right)."
|
||
}
|
||
|
||
return [
|
||
"..############..",
|
||
lid(1, "LLL\(glow)LLL"),
|
||
lid(2, "LLL\(glow)LLL"),
|
||
"..#LLLLLLLLLL#..",
|
||
"..#llllllllll#..",
|
||
]
|
||
}
|
||
|
||
// MARK: - 小尺寸
|
||
|
||
/// 列表行用的 8×8 简化版。
|
||
///
|
||
/// 不复用 16×16 缩小:那些 1px 的眼睛、发型细节在 8px 网格上会退化成噪点。
|
||
/// 这里只保留三件事 —— 头发的颜色块、两只眼睛、发光的笔记本。
|
||
/// 这就是 SF Symbols 的光学尺寸思路:小尺寸要的是另一张图,不是同一张缩小。
|
||
static func micro(for status: SessionStatus, tick: Int, seed: Int) -> SpriteFrame {
|
||
let sleeping = status == .idle || status == .unknown
|
||
// 睁眼的会偶尔眨一下;idle 一直闭着。
|
||
// 同样先把 seed 收进小区间:`abs(Int.min)` 会 trap,加法也可能溢出。
|
||
let blink = !sleeping && (tick / 12 + Int(seed.magnitude % 7)) % 7 == 0
|
||
let eyes = (sleeping || blink) ? "#feef#." : "#fefe#."
|
||
|
||
return SpriteFrame([
|
||
"..####..",
|
||
".#hhhh#.",
|
||
".#Hhhh#.",
|
||
".\(eyes)",
|
||
"#ssssss#",
|
||
"#LLggLL#",
|
||
"#LLggLL#",
|
||
"#llllll#",
|
||
])
|
||
}
|
||
}
|