@hklmtt/falinks
Advanced tools
| import { createReadStream, existsSync, statSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| const MAX_LOG = 200; | ||
| /** 聚合 roster + 最近 200 条消息(时间升序) + 待答问题,供前端单次轮询。 */ | ||
| export function buildOfficeState(deps) { | ||
| const now = deps.now ?? Date.now; | ||
| const roster = deps.router.roster().map((a) => ({ | ||
| name: a.name, | ||
| role: a.role ?? '', | ||
| status: a.status, | ||
| virtual: !!a.virtual, | ||
| lead: !!a.lead, | ||
| unresponsive: !!a.unresponsive, | ||
| // 队列深度 = 该 agent 仍在 inbox 排队、尚未投出的消息数,用于表现繁忙程度。 | ||
| // 优先取真实 inbox.length;mock/测试可直接给 queue;拿不到则 0(健壮)。 | ||
| queue: Array.isArray(a.inbox) ? a.inbox.length : typeof a.queue === 'number' ? a.queue : 0, | ||
| })); | ||
| const all = deps.router.messages(); | ||
| const log = all.slice(-MAX_LOG).map((m) => ({ id: m.id, from: m.from, to: m.to, body: m.body, ts: m.ts, thread: m.thread })); | ||
| const questions = deps.questions.list().map((q) => ({ id: q.id, from: q.from, question: q.question, options: q.options, ts: q.ts })); | ||
| return { ts: now(), roster, log, questions }; | ||
| } | ||
| const CONTENT_TYPES = { | ||
| '.html': 'text/html; charset=utf-8', | ||
| '.css': 'text/css; charset=utf-8', | ||
| '.js': 'text/javascript; charset=utf-8', | ||
| '.mjs': 'text/javascript; charset=utf-8', | ||
| '.json': 'application/json; charset=utf-8', | ||
| '.svg': 'image/svg+xml', | ||
| '.png': 'image/png', | ||
| '.jpg': 'image/jpeg', | ||
| '.jpeg': 'image/jpeg', | ||
| '.gif': 'image/gif', | ||
| '.ico': 'image/x-icon', | ||
| '.webp': 'image/webp', | ||
| '.woff': 'font/woff', | ||
| '.woff2': 'font/woff2', | ||
| '.map': 'application/json; charset=utf-8', | ||
| }; | ||
| function defaultWebRoot() { | ||
| return path.join(path.dirname(fileURLToPath(import.meta.url)), 'web'); | ||
| } | ||
| function serveFile(res, abs) { | ||
| if (!existsSync(abs) || !statSync(abs).isFile()) { | ||
| res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); | ||
| res.end('not found'); | ||
| return; | ||
| } | ||
| const ext = path.extname(abs).toLowerCase(); | ||
| res.writeHead(200, { 'content-type': CONTENT_TYPES[ext] ?? 'application/octet-stream' }); | ||
| const stream = createReadStream(abs); | ||
| stream.on('error', () => { if (!res.headersSent) | ||
| res.writeHead(500); res.end(); }); | ||
| stream.pipe(res); | ||
| } | ||
| /** | ||
| * 命中 /office、/office/state、/office/<asset> 时处理并返回 true;否则返回 false(让原有分发继续)。 | ||
| * 仅 GET;静态文件含 content-type;路径穿越(.. / 解析逃逸出 web 根)→ 403。 | ||
| */ | ||
| export function handleOfficeRequest(req, res, deps) { | ||
| const url = new URL(req.url ?? '', `http://${req.headers.host ?? '127.0.0.1'}`); | ||
| const pathname = url.pathname; | ||
| if (pathname !== '/office' && pathname !== '/office/' && !pathname.startsWith('/office/')) | ||
| return false; | ||
| if (req.method && req.method !== 'GET' && req.method !== 'HEAD') { | ||
| res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' }); | ||
| res.end('method not allowed'); | ||
| return true; | ||
| } | ||
| if (pathname === '/office/state') { | ||
| res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); | ||
| res.end(JSON.stringify(buildOfficeState(deps))); | ||
| return true; | ||
| } | ||
| const root = deps.webRoot ?? defaultWebRoot(); | ||
| if (pathname === '/office' || pathname === '/office/') { | ||
| serveFile(res, path.join(root, 'index.html')); | ||
| return true; | ||
| } | ||
| // /office/<asset> — 净化:先 decode 再判 .. ,再 resolve 校验确实落在 root 内。 | ||
| let rel; | ||
| try { | ||
| rel = decodeURIComponent(pathname.slice('/office/'.length)); | ||
| } | ||
| catch { | ||
| rel = pathname.slice('/office/'.length); | ||
| } | ||
| if (rel.includes('..') || rel.includes('\0') || path.isAbsolute(rel)) { | ||
| res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }); | ||
| res.end('forbidden'); | ||
| return true; | ||
| } | ||
| const abs = path.resolve(root, rel); | ||
| const rootResolved = path.resolve(root); | ||
| if (abs !== rootResolved && !abs.startsWith(rootResolved + path.sep)) { | ||
| res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }); | ||
| res.end('forbidden'); | ||
| return true; | ||
| } | ||
| serveFile(res, abs); | ||
| return true; | ||
| } |
Sorry, the diff of this file is not supported yet
| This asset pack is released under the Creative Commons Public Domain Dedication License (aka CC0 or CC Zero). | ||
| CC0 is a public dedication tool, which allows creators to give up their copyright and put their works into the worldwide public domain. CC0 allows users to distribute, remix, adapt, and build upon the material in any medium or format, with no conditions. | ||
| More info here: | ||
| https://creativecommons.org/publicdomain/zero/1.0/ | ||
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| ASSET PACK CONTENTS | ||
| - Five human characters | ||
| - Two animal drawings | ||
| - A bunch of office themed assets (furniture, computers, plants, decor, etc.) | ||
| Assets are provided in PNG and Aseprite formats with an example scene. | ||
| LICENSE INFORMATION | ||
| This is released under the Creative Commons Public Domain Dedication License (aka CC0 or CC Zero), and can be read here: https://creativecommons.org/publicdomain/zero/1.0/ | ||
| No attribution is required but it is greatly appreciated =). | ||
| DONATIONS | ||
| If you like the assets please consider leaving a donation. | ||
| Kindly, | ||
| 2dPig |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| # 像素办公室 sprite 使用说明(前端引用清单) | ||
| > 素材加工产出,供 `src/office/web/office.js` 渲染用。 | ||
| > 画风遵循 `docs/SPRITE-SPEC.md`(暖色 cozy 俯视)。预览见 `docs/office-ref/sprite-preview.png`。 | ||
| > 重新生成所有图:`python3 src/office/build-sprites.py` | ||
| > **致谢**:Office sprites by 2dPig (CC0)。 | ||
| ## 文件一览(均在 `src/office/web/assets/`) | ||
| | 文件 | 内容 | 来源 | | ||
| |---|---|---| | ||
| | `sprites.json` | 全部坐标 / 调色板 / 状态 / 图层约定(**单一事实源**,前端读它) | — | | ||
| | `2dpig/PixelOfficeAssets.png` | 原始图集:装饰/家具/动物坐标都指向它 | 2dPig CC0 | | ||
| | `people.png` | 5 个小人**坐姿半身**(头+肩),每人 2 帧 | 由站姿合成 | | ||
| | `floor.png` | 暖米棋盘格地砖(2 块 16×16:A 浅 / B 深) | 程序化(暖板) | | ||
| | `wall.png` | 暖墙竖切片 16×40(暖壁+木墙裙+踢脚线) | 程序化(暖板) | | ||
| | `workstation.png` | 木桌+显示器,5 状态横排(前层,盖住小人下半身) | 程序化(暖板) | | ||
| 原始冷蓝地板/墙**没用**,已按 SPRITE-SPEC 暖板程序化重做;2dpig 原图保留未改。 | ||
| ## 怎么画一个工位(图层从下到上) | ||
| `sprites.json.layering` = `["floor","wall","chair","person(bust)","workstation","floater"]` | ||
| ```js | ||
| const S = await fetch('assets/sprites.json').then(r=>r.json()); | ||
| // 1) 地砖:棋盘格交替 A/B —— floor.png 取 S.floor.tiles.A / .B(16px) | ||
| // 2) 后墙:wall.png 平铺(仅房间顶部带) | ||
| // 3) 椅子:从 2dpig 图集取 S.atlas.sprites.chair_orange([x,y,w,h]) | ||
| // 4) 小人半身:people.png | ||
| // col = S.people.order.indexOf(empKey) // 列 → x = col*CELL_W | ||
| // row = busy ? S.people.frames.type : .rest // 行 → y = row*CELL_H | ||
| // cell = S.people.cell = [20,15] | ||
| // 摆放:头顶高出显示器上沿 ~6px(bust 顶 = 显示器顶 - 6),肩部被显示器挡住 | ||
| // 5) workstation.png:col = S.workstation.states[status](idle/busy/waiting/done/offline) | ||
| // cell = [36,22],桌沿压在小人腰前 | ||
| // 6) 头顶浮标:见状态表(CSS 画,不是图) | ||
| ``` | ||
| 所有图 `image-rendering: pixelated;`,整体放大用整数倍(建议 ×3~×4)。 | ||
| ## 状态系统(`sprites.json.status`,5 态) | ||
| | 状态 | 状态色 | 脚下 tile(一级·主) | 头顶浮标(二级·冗余) | 小人/屏 | | ||
| |---|---|---|---|---| | ||
| | idle | `#8893A8` | 不发光 | 无 | 肩慢呼吸;屏 2 行暗码 | | ||
| | busy | `#5FB84A` | 绿**脉冲**发光 | 实心圆 `●` | 用 type 帧(头微点);屏满行 | | ||
| | waiting | `#F2A33C` | 琥珀**呼吸**发光 | `…` 三点 | 静止;屏 1 行 | | ||
| | done | `#2FA4C8` | 青**闪 1 次**后常亮 1.5s | `✓` 弹出 | 屏青码 | | ||
| | offline | `#5A5A60` | tile **压暗** | `!` | 小人 **50% 透明**;屏黑 | | ||
| - **一级 = 脚下地砖整格染状态色 + 发光**(CSS:在该工位地砖格上叠 `box-shadow`/径向 glow + `@keyframes` 脉冲/呼吸/闪)。这是远看主通道。 | ||
| - **二级 = 头顶形状浮标**(CSS 画的小圆/点/勾/叹号,1px 深描边底),保证缩略图/静帧也能分。 | ||
| - workstation.png 的 5 列已是对应屏幕状态,直接选列即可。 | ||
| - offline 小人透明:对 people 那一格上 `opacity:.5`。 | ||
| 红线(来自 SPRITE-SPEC §6):状态绝不能只靠身体色或仅靠动画;脚下 tile 发光为主、头顶浮标为辅;静帧也要可分。 | ||
| ## 角色 → 素材对应建议 | ||
| `S.people.order` = `["p1_dark","p2_auburn","p3_glasses","p4_pony","p5_long"]` | ||
| - `p3_glasses`(银发+眼镜)建议给 **lead**(`S.people.leadSuggest`),气质最像组长;也可另叠小皇冠/徽标浮标。 | ||
| - `boss`(virtual) 不渲染工位,或放在 `elevator` 门口/`sofa_red` 休息区。 | ||
| - 真实员工按 roster 顺序循环取 order 即可;同人复用同列。 | ||
| ## 可选装饰(`S.atlas.sprites`,点缀房间,按需取) | ||
| 椅子 `chair_{orange,yellow,green,blue,white,gray}`、沙发 `sofa_{red,green,blue,gray}`(暖调优先 `sofa_red`)、 | ||
| `plant_tall`、`elevator`(暖光双门)、`vending1/2`、`win_blue1/2`、`win_tall`、`counter`、 | ||
| `cat`(黑猫)、`corgi`(柯基)、`bench_sm/lg`。坐标全在 `sprites.json`。 |
| { | ||
| "_credit": "Office sprites by 2dPig (CC0). Warm recolor + seated busts + workstation for falinks.", | ||
| "palette": { | ||
| "tileA": "#E8D5A8", | ||
| "tileB": "#D9C088", | ||
| "seam": "#C8A86A", | ||
| "skirt": "#8A5A38", | ||
| "skirtHi": "#A6724A", | ||
| "skirtLo": "#6E4423", | ||
| "wall": "#B8946A", | ||
| "wallLo": "#9C7850", | ||
| "base": "#5C3A20", | ||
| "wood": "#9C6B3E", | ||
| "woodHi": "#C08B54", | ||
| "woodLo": "#7A4F2A", | ||
| "woodSeam": "#5C3A20", | ||
| "screen": "#1E2A33", | ||
| "code": "#5FB0C8", | ||
| "idle": "#8893A8", | ||
| "busy": "#5FB84A", | ||
| "waiting": "#F2A33C", | ||
| "stuck": "#D2483A", | ||
| "done": "#2FA4C8", | ||
| "offline": "#5A5A60", | ||
| "warmGlow": "#F2C879" | ||
| }, | ||
| "status": { | ||
| "idle": { | ||
| "color": "#8893A8", | ||
| "tileGlow": false, | ||
| "floater": null, | ||
| "note": "本色地砖, 肩慢呼吸" | ||
| }, | ||
| "busy": { | ||
| "color": "#5FB84A", | ||
| "tileGlow": "pulse", | ||
| "floater": "dot", | ||
| "note": "绿脉冲, 打字头微点+屏滚动" | ||
| }, | ||
| "waiting": { | ||
| "color": "#F2A33C", | ||
| "tileGlow": "breath", | ||
| "floater": "dots", | ||
| "note": "琥珀地台(强对比+深描边), 屏顶琥珀提示条" | ||
| }, | ||
| "stuck": { | ||
| "color": "#D2483A", | ||
| "tileGlow": "pulse-fast", | ||
| "floater": "bang-tri", | ||
| "note": "卡住/无响应: 警示红快脉冲(0.7s)+深环, 三角!浮标, 小人微抖" | ||
| }, | ||
| "done": { | ||
| "color": "#2FA4C8", | ||
| "tileGlow": "flash", | ||
| "floater": "check", | ||
| "note": "青光闪1次后余辉常亮再淡出(共3s), ✓ pop 入场" | ||
| }, | ||
| "offline": { | ||
| "color": "#5A5A60", | ||
| "tileGlow": "dim", | ||
| "floater": "cross", | ||
| "note": "熄灭, 小人50%透明屏黑, ✕灰空心浮标" | ||
| } | ||
| }, | ||
| "atlas": { | ||
| "image": "2dpig/PixelOfficeAssets.png", | ||
| "sprites": { | ||
| "chair_orange": [ | ||
| 6, | ||
| 41, | ||
| 11, | ||
| 22 | ||
| ], | ||
| "chair_yellow": [ | ||
| 19, | ||
| 41, | ||
| 11, | ||
| 22 | ||
| ], | ||
| "chair_green": [ | ||
| 32, | ||
| 41, | ||
| 11, | ||
| 22 | ||
| ], | ||
| "chair_blue": [ | ||
| 45, | ||
| 41, | ||
| 11, | ||
| 22 | ||
| ], | ||
| "chair_white": [ | ||
| 58, | ||
| 41, | ||
| 11, | ||
| 22 | ||
| ], | ||
| "chair_gray": [ | ||
| 71, | ||
| 41, | ||
| 11, | ||
| 22 | ||
| ], | ||
| "bench_sm": [ | ||
| 85, | ||
| 47, | ||
| 26, | ||
| 16 | ||
| ], | ||
| "bench_lg": [ | ||
| 115, | ||
| 47, | ||
| 40, | ||
| 16 | ||
| ], | ||
| "counter": [ | ||
| 171, | ||
| 44, | ||
| 79, | ||
| 17 | ||
| ], | ||
| "sofa_gray": [ | ||
| 119, | ||
| 66, | ||
| 33, | ||
| 15 | ||
| ], | ||
| "sofa_blue": [ | ||
| 120, | ||
| 83, | ||
| 33, | ||
| 16 | ||
| ], | ||
| "sofa_green": [ | ||
| 120, | ||
| 102, | ||
| 33, | ||
| 16 | ||
| ], | ||
| "sofa_red": [ | ||
| 120, | ||
| 121, | ||
| 31, | ||
| 16 | ||
| ], | ||
| "plant_tall": [ | ||
| 170, | ||
| 65, | ||
| 14, | ||
| 19 | ||
| ], | ||
| "elevator": [ | ||
| 185, | ||
| 60, | ||
| 49, | ||
| 30 | ||
| ], | ||
| "vending1": [ | ||
| 159, | ||
| 123, | ||
| 24, | ||
| 34 | ||
| ], | ||
| "vending2": [ | ||
| 184, | ||
| 126, | ||
| 24, | ||
| 31 | ||
| ], | ||
| "win_blue1": [ | ||
| 59, | ||
| 96, | ||
| 26, | ||
| 21 | ||
| ], | ||
| "win_blue2": [ | ||
| 88, | ||
| 96, | ||
| 26, | ||
| 21 | ||
| ], | ||
| "win_tall": [ | ||
| 98, | ||
| 120, | ||
| 16, | ||
| 31 | ||
| ], | ||
| "cat": [ | ||
| 65, | ||
| 129, | ||
| 16, | ||
| 13 | ||
| ], | ||
| "corgi": [ | ||
| 59, | ||
| 146, | ||
| 24, | ||
| 11 | ||
| ], | ||
| "monitor_unit_green": [ | ||
| 114, | ||
| 143, | ||
| 9, | ||
| 14 | ||
| ], | ||
| "monitor_unit_red": [ | ||
| 124, | ||
| 143, | ||
| 10, | ||
| 14 | ||
| ], | ||
| "monitor_unit_blue": [ | ||
| 134, | ||
| 143, | ||
| 11, | ||
| 14 | ||
| ], | ||
| "pc_pokeball": [ | ||
| 147, | ||
| 140, | ||
| 9, | ||
| 17 | ||
| ] | ||
| } | ||
| }, | ||
| "people": { | ||
| "image": "people.png", | ||
| "cell": [ | ||
| 20, | ||
| 15 | ||
| ], | ||
| "frames": { | ||
| "rest": 0, | ||
| "type": 1 | ||
| }, | ||
| "order": [ | ||
| "p1_dark", | ||
| "p2_auburn", | ||
| "p3_glasses", | ||
| "p4_red", | ||
| "p5_long" | ||
| ], | ||
| "leadSuggest": "p3_glasses", | ||
| "note": "统一 16-骨架坐姿半身(头+肩); 全员同头/眼骨架, 只换发色+衫色, lead 加眼镜. 眼固定行→露出锚点齐. 置于显示器上沿之后(下层)." | ||
| }, | ||
| "floor": { | ||
| "image": "floor.png", | ||
| "tile": 16, | ||
| "tiles": { | ||
| "A": [ | ||
| 0, | ||
| 0, | ||
| 16, | ||
| 16 | ||
| ], | ||
| "B": [ | ||
| 16, | ||
| 0, | ||
| 16, | ||
| 16 | ||
| ] | ||
| } | ||
| }, | ||
| "wall": { | ||
| "image": "wall.png", | ||
| "size": [ | ||
| 16, | ||
| 40 | ||
| ] | ||
| }, | ||
| "workstation": { | ||
| "image": "workstation.png", | ||
| "cell": [ | ||
| 36, | ||
| 22 | ||
| ], | ||
| "states": { | ||
| "idle": 0, | ||
| "busy": 1, | ||
| "waiting": 2, | ||
| "stuck": 2, | ||
| "done": 3, | ||
| "offline": 4 | ||
| }, | ||
| "note": "木桌+显示器(前层). stuck 暂复用 waiting 列(列2), 新列归 P1. 图层: 地砖 < 椅子 < 小人半身 < workstation < 头顶浮标." | ||
| }, | ||
| "layering": [ | ||
| "floor", | ||
| "wall", | ||
| "chair", | ||
| "person(bust)", | ||
| "workstation", | ||
| "floater" | ||
| ] | ||
| } |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
| <!DOCTYPE html> | ||
| <html lang="zh-CN"> | ||
| <head> | ||
| <meta charset="UTF-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| <base href="/office/"> | ||
| <title>falinks · 像素办公室</title> | ||
| <link rel="stylesheet" href="office.css"> | ||
| </head> | ||
| <body> | ||
| <div id="app"> | ||
| <header id="topbar"> | ||
| <span class="logo">falinks</span> | ||
| <span class="sub" id="subtitle">像素办公室</span> | ||
| <span class="clock" id="clock"></span> | ||
| </header> | ||
| <main id="stage-wrap"> | ||
| <div id="stage"> | ||
| <div id="wall"></div> | ||
| <div id="floor"></div> | ||
| <div id="decor"></div> | ||
| <div id="lounge"></div> | ||
| <div id="desks"></div> | ||
| <div id="bubbles"></div> | ||
| </div> | ||
| <div id="empty" class="hidden">暂无成员</div> | ||
| </main> | ||
| <aside id="panel" class="empty"> | ||
| <div id="panel-overview"> | ||
| <div class="ov-title" id="ov-title">团队概览</div> | ||
| <div class="ov-stats" id="ov-stats"></div> | ||
| <div class="ov-legend-title" id="ov-legend-title">状态图例</div> | ||
| <ul class="ov-legend" id="ov-legend"></ul> | ||
| <div id="panel-hint">点击工位查看详情</div> | ||
| </div> | ||
| <div id="panel-body" class="hidden"> | ||
| <div id="panel-head"> | ||
| <span id="panel-name"></span> | ||
| <span id="panel-status" class="status-pill"></span> | ||
| </div> | ||
| <div id="panel-role"></div> | ||
| <div id="panel-done" class="hidden"></div> | ||
| <div id="panel-q" class="hidden"></div> | ||
| <div id="panel-msgs-label">相关消息</div> | ||
| <ul id="panel-msgs"></ul> | ||
| </div> | ||
| </aside> | ||
| </div> | ||
| <div id="banner" class="hidden">连接已断开,正在重试…</div> | ||
| <script src="office.js"></script> | ||
| </body> | ||
| </html> |
| /* falinks 像素办公室 — 暖色 cozy 俯视微透视。 | ||
| 画风/调色板/状态约定见 docs/SPRITE-SPEC.md,数据源 assets/sprites.json。 | ||
| 状态两级冗余:一级=脚下 tile 染色发光,二级=头顶形状浮标。 */ | ||
| :root { | ||
| --s: 4; /* 像素整数放大倍数 */ | ||
| /* 调色板由 office.js 从 sprites.json 注入,这里给兜底默认 */ | ||
| --tileA: #E8D5A8; | ||
| --tileB: #D9C088; | ||
| --seam: #C8A86A; | ||
| --wood: #9C6B3E; | ||
| --woodLo: #7A4F2A; | ||
| --base: #5C3A20; | ||
| --busy: #5FB84A; | ||
| --waiting: #F2A33C; /* hue≈34° 实心琥珀核:距裸地砖(232,213,168)欧氏≈138,杜绝被暖底吞 */ | ||
| /* ⚠️ 调色板运行时事实源是 assets/sprites.json:office.js 把 palette 注入 :root 行内变量, | ||
| 会覆盖这里的 --busy/--waiting/--done/... 默认值。此处仅作兜底,须与 sprites.json 保持一致, | ||
| 改色请改 sprites.json(否则像 #DE8428 那样写在这里也不会生效)。环#8A4A12/外发光#DE7A1E 见 @keyframes。 */ | ||
| --done: #2FA4C8; | ||
| --offline: #5A5A60; | ||
| --stuck: #D2483A; /* 警示红 hue≈6°;仅小面积 tile/浮标,警示而非装饰 */ | ||
| --idle: #8893A8; | ||
| --warmGlow: #F2C879; | ||
| --rug: #B8503C; /* 休息角地毯暖红(SPEC) */ | ||
| --rugEdge: #EAD9B0; | ||
| } | ||
| * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| html, body { height: 100%; } | ||
| body { | ||
| background: #15120e; | ||
| color: #ece3d2; | ||
| font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; | ||
| overflow: hidden; | ||
| } | ||
| img, [class*="spr"] { image-rendering: pixelated; image-rendering: crisp-edges; } | ||
| #app { | ||
| display: grid; | ||
| grid-template-rows: auto 1fr; | ||
| grid-template-columns: 1fr 320px; | ||
| grid-template-areas: "top top" "stage panel"; | ||
| height: 100vh; | ||
| } | ||
| /* ---- 顶栏 ---- */ | ||
| #topbar { | ||
| grid-area: top; | ||
| display: flex; align-items: baseline; gap: 12px; | ||
| padding: 10px 18px; | ||
| background: #1c1812; | ||
| border-bottom: 2px solid var(--base); | ||
| } | ||
| .logo { font-weight: 700; font-size: 18px; letter-spacing: .5px; color: var(--warmGlow); } | ||
| .sub { font-size: 13px; color: #b39c78; } | ||
| .clock { margin-left: auto; font-variant-numeric: tabular-nums; font-size: 13px; color: #8a7a5e; } | ||
| /* ---- 舞台 ---- */ | ||
| #stage-wrap { | ||
| grid-area: stage; | ||
| overflow: auto; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| padding: 16px; | ||
| } | ||
| #stage { | ||
| position: relative; | ||
| flex: none; | ||
| border: 3px solid var(--base); | ||
| border-radius: 4px; | ||
| overflow: hidden; | ||
| box-shadow: 0 10px 40px rgba(0,0,0,.5); | ||
| transform-origin: center center; /* fitStage 叠 scale(k) 铺满视口 */ | ||
| } | ||
| /* 后墙:wall.png 横向平铺 */ | ||
| #wall { | ||
| position: absolute; top: 0; left: 0; right: 0; | ||
| background-repeat: repeat-x; | ||
| z-index: 0; | ||
| } | ||
| /* 地面:暖米棋盘格(用调色板色,像素对齐) */ | ||
| #floor { | ||
| position: absolute; left: 0; right: 0; bottom: 0; | ||
| background-color: var(--tileA); | ||
| background-image: | ||
| conic-gradient(var(--tileA) 0 .25turn, var(--tileB) 0 .5turn, | ||
| var(--tileA) 0 .75turn, var(--tileB) 0); | ||
| z-index: 0; | ||
| } | ||
| /* 砖缝 */ | ||
| #floor::after { | ||
| content: ""; position: absolute; inset: 0; | ||
| background-image: | ||
| repeating-linear-gradient(90deg, transparent 0 calc(var(--cell) - 1px), var(--seam) calc(var(--cell) - 1px) var(--cell)), | ||
| repeating-linear-gradient(0deg, transparent 0 calc(var(--cell) - 1px), var(--seam) calc(var(--cell) - 1px) var(--cell)); | ||
| opacity: .5; | ||
| } | ||
| #decor, #lounge, #desks { position: absolute; inset: 0; z-index: 1; pointer-events: none; } | ||
| #desks { pointer-events: auto; } | ||
| /* 窗/门冷蓝是全屋最饱和冷色,会稀释 done「全屋唯一冷色」的辨识(SPEC§5 窗应暖光透入)。 | ||
| 降饱和 + 暖日光偏色,既贴 cozy 又把唯一冷色让位给 done。(此滤镜本就正常,勿动; | ||
| "黄窗"真凶是已删除的 elevator 装饰盖住窗,非本滤镜。) */ | ||
| [class*="decor-win"] { filter: saturate(.42) sepia(.28) brightness(1.06) hue-rotate(-8deg); } | ||
| /* 第二张沙发用中性灰 sprite(sofa_gray, 见 renderLounge), 不再用降饱和滤镜(鲜蓝降饱和易发灰发闷)。 */ | ||
| /* 通用 sprite 块(背景图裁切,尺寸由 JS 注入 --w/--h/--bx/--by/--bw/--bh) */ | ||
| .spr { | ||
| position: absolute; | ||
| width: calc(var(--w) * var(--s) * 1px); | ||
| height: calc(var(--h) * var(--s) * 1px); | ||
| background-repeat: no-repeat; | ||
| background-position: calc(var(--bx) * var(--s) * -1px) calc(var(--by) * var(--s) * -1px); | ||
| background-size: calc(var(--bw) * var(--s) * 1px) calc(var(--bh) * var(--s) * 1px); | ||
| } | ||
| /* ---- 工位 ---- */ | ||
| .station { | ||
| position: absolute; | ||
| width: calc(36 * var(--s) * 1px); | ||
| height: calc(28 * var(--s) * 1px); /* 6px 头顶留白 + 22px 工位 */ | ||
| cursor: pointer; | ||
| } | ||
| .station .tile { /* 一级通道:脚下地台发光 */ | ||
| position: absolute; left: 50%; bottom: calc(-4 * var(--s) * 1px); | ||
| width: calc(34 * var(--s) * 1px); height: calc(18 * var(--s) * 1px); | ||
| transform: translateX(-50%); | ||
| border-radius: 50%; | ||
| z-index: 0; | ||
| } | ||
| .station .chair { /* 椅子(小人身后,桌下露出) */ | ||
| left: 50%; transform: translateX(-50%); | ||
| bottom: calc(-2 * var(--s) * 1px); | ||
| z-index: 1; | ||
| } | ||
| .station .person { /* 小人坐姿半身 */ | ||
| left: 50%; transform: translateX(-50%); | ||
| top: 0; | ||
| z-index: 2; | ||
| } | ||
| .station .ws { /* 木桌+显示器,盖住小人肩部 */ | ||
| left: 0; top: calc(6 * var(--s) * 1px); | ||
| z-index: 3; | ||
| } | ||
| .station .crown { /* lead 皇冠:CSS 像素冠形,戴在头顶发际上 */ | ||
| position: absolute; | ||
| left: 50%; transform: translateX(-50%); | ||
| top: calc(-4 * var(--s) * 1px); | ||
| width: calc(9 * var(--s) * 1px); | ||
| height: calc(6 * var(--s) * 1px); | ||
| z-index: 5; pointer-events: none; | ||
| background: var(--warmGlow); | ||
| clip-path: polygon(0% 100%, 0% 30%, 25% 62%, 50% 4%, 75% 62%, 100% 30%, 100% 100%); | ||
| filter: drop-shadow(0 1px 0 rgba(0,0,0,.7)) drop-shadow(0 0 1px rgba(0,0,0,.7)); | ||
| } | ||
| .station .name { /* 名字上桌:落在 .ws 桌前下部木立面, 收进 station 盒(不再 bottom 负值外溢→消跨排遮挡) */ | ||
| position: absolute; left: 50%; transform: translateX(-50%); | ||
| top: calc(20 * var(--s) * 1px); | ||
| max-width: calc(34 * var(--s) * 1px); | ||
| overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | ||
| font-size: 11px; line-height: 1.1; | ||
| color: #f0e6d2; | ||
| background: rgba(20,14,8,.62); | ||
| border: 1px solid rgba(0,0,0,.5); | ||
| border-radius: 3px; padding: 1px 5px; | ||
| z-index: 4; pointer-events: none; /* 高于 .ws(3)、低于 .floater(5) */ | ||
| } | ||
| .station.sel .name { color: var(--warmGlow); font-weight: 700; background: rgba(20,14,8,.72); } | ||
| .station.sel .ws { outline: calc(.5 * var(--s) * 1px) solid var(--warmGlow); outline-offset: 1px; } | ||
| /* 二级通道:头顶形状浮标(深色描边底,缩略图/色弱也能分) */ | ||
| .station .floater { | ||
| position: absolute; left: 50%; transform: translateX(-50%); | ||
| top: calc(-7 * var(--s) * 1px); | ||
| z-index: 5; pointer-events: none; | ||
| display: flex; align-items: center; justify-content: center; | ||
| min-width: calc(7 * var(--s) * 1px); height: calc(7 * var(--s) * 1px); | ||
| padding: 0 calc(.6 * var(--s) * 1px); | ||
| border-radius: calc(2 * var(--s) * 1px); | ||
| font-size: calc(5 * var(--s) * 1px); font-weight: 700; line-height: 1; | ||
| color: #fff; | ||
| border: 2px solid rgba(0,0,0,.6); | ||
| box-shadow: 0 1px 2px rgba(0,0,0,.5); | ||
| text-shadow: 0 1px 1px rgba(0,0,0,.6); | ||
| } | ||
| .floater.waiting { background: var(--waiting); } | ||
| .floater.done { background: var(--done); animation: floater-pop .35s ease-out; } | ||
| .floater.offline { /* 空心灰 ✕:断电下班感,与暖色休息角标明确分语义 */ | ||
| background: transparent; border-color: var(--offline); | ||
| color: var(--offline); text-shadow: none; | ||
| } | ||
| /* busy = 实心圆(无字形,靠形状识别) */ | ||
| .floater.busy { | ||
| background: var(--busy); border-radius: 50%; | ||
| width: calc(7 * var(--s) * 1px); min-width: 0; padding: 0; | ||
| } | ||
| /* stuck = 红三角 !(形状本身即编码,与 waiting 的圆角矩形区分,色弱也能认) */ | ||
| .floater.stuck { | ||
| background: var(--stuck); border: none; border-radius: 0; min-width: 0; | ||
| width: calc(9 * var(--s) * 1px); height: calc(8 * var(--s) * 1px); | ||
| clip-path: polygon(50% 2%, 98% 98%, 2% 98%); | ||
| align-items: flex-end; padding: 0 0 calc(.7 * var(--s) * 1px); | ||
| filter: drop-shadow(0 1px 0 rgba(0,0,0,.7)) drop-shadow(0 0 1px rgba(0,0,0,.85)); | ||
| } | ||
| @keyframes floater-pop { from { transform: translateX(-50%) scale(1.35); } to { transform: translateX(-50%) scale(1); } } | ||
| /* ---- 脚下 tile:状态地台 ---- */ | ||
| /* 核用「实心高饱和」椭圆(静帧可测:与裸地砖欧氏距离 ≥120、HSV 饱和 ≥0.58); | ||
| 只让外发光边脉冲/呼吸,不动核色 → 截图测量稳定。深色描边环常驻割暖底。 */ | ||
| .tile.idle { background: none; box-shadow: none; } | ||
| .tile.busy { | ||
| background: radial-gradient(ellipse at center, | ||
| var(--busy) 0 56%, color-mix(in srgb, var(--busy) 50%, transparent) 74%, transparent 84%); | ||
| animation: glow-busy 1.1s ease-in-out infinite; | ||
| } | ||
| .tile.waiting { /* 琥珀与暖底同色相易被吞 → 实心 #F2A33C 琥珀核(hue≈34° 距裸地砖≈138) + 常驻 2px 深描边硬环 #8A4A12 立形 */ | ||
| background: radial-gradient(ellipse at center, | ||
| var(--waiting) 0 62%, | ||
| color-mix(in srgb, var(--waiting) 65%, transparent) 78%, transparent 88%); | ||
| animation: glow-wait 1.5s ease-in-out infinite; | ||
| } | ||
| .tile.stuck { /* 警示红核(hue≈6° 与 waiting 琥珀34° 拉开≥25°) + 深环 + 快脉冲(0.7s) */ | ||
| background: radial-gradient(ellipse at center, | ||
| var(--stuck) 0 60%, | ||
| color-mix(in srgb, var(--stuck) 60%, transparent) 78%, transparent 88%); | ||
| animation: glow-stuck 0.7s ease-in-out infinite; | ||
| } | ||
| .tile.done { | ||
| background: radial-gradient(ellipse at center, | ||
| var(--done) 0 56%, color-mix(in srgb, var(--done) 50%, transparent) 74%, transparent 84%); | ||
| animation: glow-done 3s ease-out 1 forwards; | ||
| } | ||
| .tile.offline { /* 熄灭/压暗,无饱和要求 */ | ||
| background: radial-gradient(ellipse at center, rgba(0,0,0,.55), transparent 74%); | ||
| box-shadow: 0 0 0 calc(.5 * var(--s) * 1px) rgba(0,0,0,.45); | ||
| } | ||
| /* 深环常驻 + 外发光呼吸(只变 blur/spread/亮度,核色不动) */ | ||
| @keyframes glow-busy { | ||
| 0%,100% { box-shadow: 0 0 0 calc(.5*var(--s)*1px) rgba(16,28,10,.72), | ||
| 0 0 calc(2*var(--s)*1px) calc(.4*var(--s)*1px) color-mix(in srgb, var(--busy) 45%, transparent); | ||
| transform: translateX(-50%) scale(.96); } | ||
| 50% { box-shadow: 0 0 0 calc(.5*var(--s)*1px) rgba(16,28,10,.72), | ||
| 0 0 calc(6*var(--s)*1px) calc(1.6*var(--s)*1px) color-mix(in srgb, var(--busy) 80%, transparent); | ||
| transform: translateX(-50%) scale(1.04); } | ||
| } | ||
| @keyframes glow-wait { /* 2px 深描边硬环常驻 #8A4A12(inset,hue 无关,专治同色相被吞) + 更橙外发光呼吸 */ | ||
| 0%,100% { box-shadow: inset 0 0 0 calc(2*var(--s)*1px) #8A4A12, | ||
| 0 0 0 calc(.7*var(--s)*1px) #6E3A0E, | ||
| 0 0 calc(3*var(--s)*1px) calc(.8*var(--s)*1px) color-mix(in srgb, #DE7A1E 65%, transparent); } | ||
| 50% { box-shadow: inset 0 0 0 calc(2*var(--s)*1px) #8A4A12, | ||
| 0 0 0 calc(.7*var(--s)*1px) #6E3A0E, | ||
| 0 0 calc(7.5*var(--s)*1px) calc(2.2*var(--s)*1px) color-mix(in srgb, #DE7A1E 92%, transparent); } | ||
| } | ||
| @keyframes glow-stuck { /* 警示红快脉冲(0.7s, 比 busy/wait 更急促=警报感); 环/光均由 var(--stuck) 派生, 不另设硬编码色 */ | ||
| 0%,100% { box-shadow: inset 0 0 0 calc(1.5*var(--s)*1px) color-mix(in srgb, var(--stuck) 50%, #000), | ||
| 0 0 0 calc(.6*var(--s)*1px) color-mix(in srgb, var(--stuck) 35%, #000), | ||
| 0 0 calc(2.5*var(--s)*1px) calc(.6*var(--s)*1px) color-mix(in srgb, var(--stuck) 55%, transparent); | ||
| transform: translateX(-50%) scale(.95); } | ||
| 50% { box-shadow: inset 0 0 0 calc(1.5*var(--s)*1px) color-mix(in srgb, var(--stuck) 50%, #000), | ||
| 0 0 0 calc(.6*var(--s)*1px) color-mix(in srgb, var(--stuck) 35%, #000), | ||
| 0 0 calc(6.5*var(--s)*1px) calc(2*var(--s)*1px) color-mix(in srgb, var(--stuck) 92%, transparent); | ||
| transform: translateX(-50%) scale(1.06); } | ||
| } | ||
| @keyframes glow-done { /* 闪1次 → 0.5s 渐隐为余辉 → 常亮 ~1.8s → 淡出, 共 3s 可感知 */ | ||
| 0% { box-shadow: 0 0 0 calc(.5*var(--s)*1px) rgba(0,30,40,.72), | ||
| 0 0 calc(8*var(--s)*1px) calc(2.4*var(--s)*1px) color-mix(in srgb, var(--done) 92%, transparent); | ||
| transform: translateX(-50%) scale(1.22); opacity: 1; } | ||
| 16% { box-shadow: 0 0 0 calc(.5*var(--s)*1px) rgba(0,30,40,.72), | ||
| 0 0 calc(4*var(--s)*1px) calc(1.2*var(--s)*1px) color-mix(in srgb, var(--done) 70%, transparent); | ||
| transform: translateX(-50%) scale(1); } | ||
| 75% { box-shadow: 0 0 0 calc(.5*var(--s)*1px) rgba(0,30,40,.6), | ||
| 0 0 calc(3.5*var(--s)*1px) calc(1*var(--s)*1px) color-mix(in srgb, var(--done) 60%, transparent); | ||
| opacity: 1; } | ||
| 100% { box-shadow: 0 0 0 calc(.5*var(--s)*1px) rgba(0,30,40,.2), | ||
| 0 0 calc(2*var(--s)*1px) calc(.4*var(--s)*1px) color-mix(in srgb, var(--done) 18%, transparent); | ||
| transform: translateX(-50%) scale(1); opacity: .55; } | ||
| } | ||
| /* offline 小人压暗透明 */ | ||
| .station.s-offline .person { opacity: .45; filter: grayscale(.6); } | ||
| .station.s-offline .ws { filter: brightness(.6); } | ||
| /* stuck 小人微抖(警示, ±1px) */ | ||
| .station.s-stuck .person { animation: shake .5s ease-in-out infinite; } | ||
| @keyframes shake { | ||
| 0%,100% { transform: translateX(-50%); } | ||
| 25% { transform: translateX(calc(-50% - 1px)); } | ||
| 75% { transform: translateX(calc(-50% + 1px)); } | ||
| } | ||
| /* launching 渐入 */ | ||
| .station.s-launching { animation: fadein .8s ease-out; } | ||
| @keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; } } | ||
| /* ---- 繁忙强度分档(仅 busy 内部子维度; busy 仍绿●、不改 6 态/图例) ---- */ | ||
| /* 杠杆①: 地台脉冲周期随档加快(L0 默认 1.1s; L1/L2/L3=0.9/0.7/0.5s) */ | ||
| .station.b1 .tile.busy { animation-duration: .9s; } | ||
| .station.b2 .tile.busy { animation-duration: .7s; } | ||
| .station.b3 .tile.busy { animation-duration: .5s; } | ||
| /* 杠杆②: 小人 transform 微抖(整数 px + steps(), 无 rotate; L1/L2=±1px, L3=±2px) */ | ||
| .station.b1 .person, .station.b2 .person { animation: busy-shake1 .24s steps(2, end) infinite; } | ||
| .station.b3 .person { animation: busy-shake2 .18s steps(2, end) infinite; } | ||
| @keyframes busy-shake1 { | ||
| 0%, 100% { transform: translateX(calc(-50% - 1px)); } | ||
| 50% { transform: translateX(calc(-50% + 1px)); } | ||
| } | ||
| @keyframes busy-shake2 { | ||
| 0%, 100% { transform: translateX(calc(-50% - 2px)); } | ||
| 50% { transform: translateX(calc(-50% + 2px)); } | ||
| } | ||
| /* L3 额外: 每 ~2.5s 一次拍桌 jolt(±3px 单帧, 挂在 station 层避免与 .person 的 transform 抖动冲突) */ | ||
| .station.b3 { animation: busy-jolt 2.5s steps(1, end) infinite; } | ||
| @keyframes busy-jolt { | ||
| 0%, 88%, 100% { transform: translate(0, 0); } | ||
| 90% { transform: translate(3px, -3px); } /* 拍桌/抓头, 单帧整数位移 */ | ||
| 94% { transform: translate(-3px, 0); } | ||
| } | ||
| /* prefers-reduced-motion: 封顶 L1(L2/L3 降为 L1 振幅/周期, 去 jolt) */ | ||
| @media (prefers-reduced-motion: reduce) { | ||
| .station.b2 .tile.busy, .station.b3 .tile.busy { animation-duration: .9s; } | ||
| .station.b3 .person { animation: busy-shake1 .24s steps(2, end) infinite; } | ||
| .station.b3 { animation: none; } | ||
| } | ||
| /* ---- idle 打瞌睡(idle 态内部随机休眠子维度; 不破 6 态: 无地台/无状态浮标/不换色) ---- */ | ||
| /* 每 station 随机 --doze-dur(清醒+打盹) + --doze-delay(负相位错峰); 前~72% 静止, 后段点头+Zzz */ | ||
| .zzz { /* 头顶 Zzz: 中性奶白像素字, 禁状态色; 默认隐, z 低于气泡(10) */ | ||
| position: absolute; left: 50%; transform: translateX(-50%); | ||
| top: calc(-9 * var(--s) * 1px); | ||
| font-size: 12px; font-weight: 700; letter-spacing: 1px; | ||
| color: #E8DCC0; text-shadow: 0 1px 0 #5C3A20, 0 0 2px rgba(0,0,0,.6); | ||
| opacity: 0; z-index: 7; pointer-events: none; | ||
| } | ||
| .station.dozing .person { animation: doze-nod var(--doze-dur, 16s) steps(1, end) var(--doze-delay, 0s) infinite; } | ||
| .station.dozing .zzz { animation: doze-zzz var(--doze-dur, 16s) ease-in-out var(--doze-delay, 0s) infinite; } | ||
| /* 气泡优先: 说话(=醒着)挂起打盹 */ | ||
| .station.talking .person { animation: none; } | ||
| .station.talking .zzz { animation: none; opacity: 0; } | ||
| @keyframes doze-nod { /* 仅后段点头: translateY 0→+2px, 整数像素(steps), 不 rotate */ | ||
| 0%, 72%, 100% { transform: translateX(-50%) translateY(0); } | ||
| 76%, 86%, 96% { transform: translateX(-50%) translateY(2px); } | ||
| 81%, 91% { transform: translateX(-50%) translateY(0); } | ||
| } | ||
| @keyframes doze-zzz { /* 打盹段冒 2 次 Zzz, 飘升淡出 ~-10px */ | ||
| 0%, 73% { opacity: 0; transform: translateX(-50%) translateY(0); } | ||
| 80% { opacity: .95; transform: translateX(-50%) translateY(-6px); } | ||
| 86% { opacity: 0; transform: translateX(-50%) translateY(-10px); } | ||
| 87% { opacity: 0; transform: translateX(-50%) translateY(0); } | ||
| 94% { opacity: .95; transform: translateX(-50%) translateY(-6px); } | ||
| 100% { opacity: 0; transform: translateX(-50%) translateY(-10px); } | ||
| } | ||
| @media (prefers-reduced-motion: reduce) { /* 去点头; Zzz 不显 */ | ||
| .station.dozing .person { animation: none; } | ||
| .station.dozing .zzz { animation: none; opacity: 0; } | ||
| } | ||
| /* ---- 头顶说话气泡(独立 #bubbles 层; 暖白像素, 禁状态色) ---- */ | ||
| #bubbles { position: absolute; inset: 0; z-index: 10; pointer-events: none; } | ||
| .bubble { | ||
| position: absolute; /* left/bottom 由 JS 按头顶锚点定位(stage 坐标) */ | ||
| transform-origin: bottom center; | ||
| max-width: calc(1.6 * 36 * var(--s) * 1px); /* ≤1.6× 桌宽 */ | ||
| min-width: calc(9 * var(--s) * 1px); | ||
| background: #fffdf5; color: #2a2014; /* 暖白底 + 深暖字, 非任何状态色 */ | ||
| font-size: 11px; line-height: 1.3; | ||
| padding: 4px 8px; border-radius: 6px; | ||
| border: 2px solid var(--base); /* 木色硬描边(像素游戏感) */ | ||
| box-shadow: 0 2px 0 rgba(0,0,0,.25), 0 3px 7px rgba(0,0,0,.4); | ||
| /* 最多 2 行, 行末省略号 */ | ||
| display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; | ||
| overflow: hidden; text-overflow: ellipsis; word-break: break-word; | ||
| z-index: 10; pointer-events: none; | ||
| } | ||
| .bubble::after { /* 向下尖角, 贴边平移时随 --tail-dx 仍指说话人 */ | ||
| content: ""; position: absolute; | ||
| left: calc(50% + var(--tail-dx, 0px)); bottom: -7px; transform: translateX(-50%); | ||
| border: 5px solid transparent; border-top-color: var(--base); | ||
| } | ||
| @keyframes bubble-pop { /* scale 0.9→1 + 淡入(steps 像素感) */ | ||
| from { opacity: 0; transform: scale(.9); } | ||
| to { opacity: 1; transform: scale(1); } | ||
| } | ||
| .bubble.fade { animation: bubble-fade .22s ease-out forwards; } | ||
| @keyframes bubble-fade { from { opacity: 1; } to { opacity: 0; transform: translateY(-2px); } } | ||
| @media (prefers-reduced-motion: reduce) { /* 仅淡入淡出, 去 pop 缩放/位移 */ | ||
| .bubble { animation: none !important; } | ||
| .bubble.fade { animation: bubble-fade-rm .22s linear forwards; } | ||
| @keyframes bubble-fade-rm { from { opacity: 1; } to { opacity: 0; } } | ||
| } | ||
| /* ---- 休息区(纯装饰: 沙发/地毯/猫狗, 不坐人) + boss 坐镇主位 ---- */ | ||
| .rug { /* 休息角地毯: 圆角矩形(收住矩形沙发角, 非纯椭圆) */ | ||
| position: absolute; | ||
| border-radius: calc(5 * var(--s) * 1px); | ||
| background: radial-gradient(ellipse at center, var(--rug) 0 70%, color-mix(in srgb, var(--rug) 78%, #000) 100%); | ||
| box-shadow: inset 0 0 0 calc(.6 * var(--s) * 1px) var(--rugEdge); | ||
| opacity: .92; z-index: 0; | ||
| } | ||
| /* boss 暖地毯(纯装饰, 不发光) */ | ||
| .boss-rug { | ||
| position: absolute; border-radius: 50%; | ||
| background: radial-gradient(ellipse at center, var(--warmGlow) 0 60%, color-mix(in srgb, var(--warmGlow) 70%, #7A4F2A) 80%, transparent 90%); | ||
| box-shadow: inset 0 0 0 calc(.5 * var(--s) * 1px) #C8A86A; | ||
| opacity: .85; z-index: 0; pointer-events: none; | ||
| } | ||
| /* boss 大班桌(CSS 木桌: 比员工桌更宽更厚; 暖木 cozy, 无 status 屏) */ | ||
| .boss-desk { | ||
| position: absolute; pointer-events: none; | ||
| background: linear-gradient(var(--woodHi) 0 18%, var(--wood) 18% 58%, var(--woodLo) 58% 100%); | ||
| border: 2px solid var(--base); border-radius: 2px; | ||
| box-shadow: 0 calc(.5*var(--s)*1px) 0 var(--base), 0 calc(1.5*var(--s)*1px) calc(2*var(--s)*1px) rgba(0,0,0,.45); | ||
| } | ||
| /* 桌上暖光台灯(纯 CSS, 非 status) */ | ||
| .boss-lamp { | ||
| position: absolute; pointer-events: none; | ||
| width: calc(3 * var(--s) * 1px); height: calc(5 * var(--s) * 1px); | ||
| background: var(--woodLo); border-radius: 1px; | ||
| } | ||
| .boss-lamp::after { | ||
| content: ''; position: absolute; left: 50%; top: calc(-3 * var(--s) * 1px); transform: translateX(-50%); | ||
| width: calc(4 * var(--s) * 1px); height: calc(4 * var(--s) * 1px); border-radius: 50%; | ||
| background: var(--warmGlow); | ||
| box-shadow: 0 0 calc(2 * var(--s) * 1px) calc(1 * var(--s) * 1px) color-mix(in srgb, var(--warmGlow) 85%, transparent); | ||
| } | ||
| /* 桌上合盖笔记本(深色小矩形, 非 code 屏) */ | ||
| .boss-laptop { | ||
| position: absolute; pointer-events: none; | ||
| width: calc(11 * var(--s) * 1px); height: calc(3 * var(--s) * 1px); | ||
| background: #2a2a30; border: 1px solid #15151a; border-radius: 1px; | ||
| } | ||
| /* boss 桌前铭牌(嵌大班桌前立面: 深木底 + 暖字, 老板版更大气) */ | ||
| .boss-plate { | ||
| position: absolute; transform: translateX(-50%); | ||
| background: var(--base); color: var(--warmGlow); | ||
| font-size: 11px; font-weight: 700; white-space: nowrap; | ||
| padding: 2px 9px; border-radius: 3px; | ||
| border: 1px solid var(--woodLo); | ||
| box-shadow: 0 1px 2px rgba(0,0,0,.5); | ||
| z-index: 5; pointer-events: none; | ||
| } | ||
| .boss-hit { position: absolute; z-index: 6; cursor: pointer; pointer-events: auto; } | ||
| /* 猫狗 idle 微动(纯 CSS transform; 平时静止, 偶尔 ~0.5s 微动; ≤2px 整数 step; 无 rotate; 错峰) */ | ||
| @keyframes pet-hop { /* 猫: 小跳 */ | ||
| 0%, 88%, 100% { transform: translateY(0); } | ||
| 92% { transform: translateY(-2px); } | ||
| 96% { transform: translateY(-1px); } | ||
| } | ||
| @keyframes pet-shift { /* 狗: 小挪 */ | ||
| 0%, 90%, 100% { transform: translateX(0); } | ||
| 94% { transform: translateX(2px); } | ||
| 97% { transform: translateX(1px); } | ||
| } | ||
| .decor-cat { animation: pet-hop 6.5s steps(1, end) infinite; } | ||
| .decor-corgi { animation: pet-shift 8.3s steps(1, end) infinite; animation-delay: 2.5s; } | ||
| @media (prefers-reduced-motion: reduce) { | ||
| .decor-cat, .decor-corgi { animation: none; } | ||
| } | ||
| /* ---- 右侧详情面板 ---- */ | ||
| #panel { | ||
| grid-area: panel; | ||
| background: #1c1812; | ||
| border-left: 2px solid var(--base); | ||
| padding: 16px; | ||
| overflow-y: auto; | ||
| } | ||
| #panel-hint { color: #6e6048; font-size: 12px; text-align: center; margin-top: 22px; } | ||
| #panel.empty #panel-hint { display: block; } | ||
| #panel:not(.empty) #panel-hint { display: none; } | ||
| /* ---- 未选中态:团队概览 + 状态图例 ---- */ | ||
| .ov-title, .ov-legend-title { | ||
| font-size: 12px; color: #8a7a5e; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 10px; | ||
| } | ||
| .ov-legend-title { margin-top: 6px; } | ||
| .ov-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 5px; margin-bottom: 20px; font-size: 12px; color: #c8b896; } | ||
| .ov-stat b { color: #f0e6d2; font-size: 14px; font-variant-numeric: tabular-nums; margin-right: 2px; } | ||
| .ov-stat.busy b { color: var(--busy); } | ||
| .ov-stat.waiting b { color: var(--waiting); } | ||
| .ov-stat.stuck b { color: var(--stuck); } | ||
| .ov-dot { color: #5a4f3a; font-style: normal; } | ||
| .ov-legend { list-style: none; display: grid; grid-template-columns: 1fr 1fr; gap: 12px 10px; --s: 2.2; } | ||
| .ov-legend li { display: flex; align-items: center; gap: 9px; } | ||
| .ov-legend .lg-ico { /* 图例小样 = 场内浮标同款形+色(复用 .floater.<state>) */ | ||
| position: static; flex: none; | ||
| display: inline-flex; align-items: center; justify-content: center; | ||
| width: calc(7 * var(--s) * 1px); height: calc(7 * var(--s) * 1px); min-width: calc(7 * var(--s) * 1px); | ||
| border-radius: calc(2 * var(--s) * 1px); border: 2px solid rgba(0,0,0,.55); | ||
| color: #fff; font-size: calc(5 * var(--s) * 1px); font-weight: 700; line-height: 1; | ||
| text-shadow: 0 1px 1px rgba(0,0,0,.6); | ||
| animation: none !important; /* 图例不动(关 pop/脉冲) */ | ||
| } | ||
| .ov-legend .lg-ico.idle { background: var(--idle); } | ||
| .ov-legend .lg-ico.busy { border-radius: 50%; } /* 图例 busy 小样为圆, 对齐场内 ● 浮标 */ | ||
| .ov-legend .lg-label { font-size: 12px; color: #d8c8a8; } | ||
| #panel-done { font-size: 12px; color: var(--done); margin: -6px 0 12px; font-variant-numeric: tabular-nums; } | ||
| #panel-head { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } | ||
| #panel-name { font-size: 17px; font-weight: 700; color: var(--warmGlow); } | ||
| .status-pill { | ||
| font-size: 11px; padding: 2px 8px; border-radius: 999px; | ||
| color: #15120e; font-weight: 700; | ||
| } | ||
| .status-pill.idle { background: var(--idle); } | ||
| .status-pill.busy { background: var(--busy); } | ||
| .status-pill.waiting { background: var(--waiting); } | ||
| .status-pill.stuck { background: var(--stuck); color: #fff; } | ||
| .status-pill.done { background: var(--done); } | ||
| .status-pill.offline { background: var(--offline); color: #ccc; } | ||
| .status-pill.boss { background: var(--warmGlow); color: #15120e; } /* 坐镇: 中性暖色, 非状态色 */ | ||
| #panel-role { font-size: 12px; color: #b39c78; margin-bottom: 14px; } | ||
| #panel-q { | ||
| background: color-mix(in srgb, var(--waiting) 22%, #1c1812); | ||
| border: 1px solid var(--waiting); | ||
| border-radius: 6px; padding: 8px 10px; margin-bottom: 14px; font-size: 12px; | ||
| } | ||
| #panel-q .q-title { color: var(--waiting); font-weight: 700; margin-bottom: 4px; } | ||
| #panel-q .q-opt { color: #d8c8a8; padding-left: 8px; } | ||
| #panel-msgs-label { font-size: 12px; color: #8a7a5e; margin-bottom: 8px; text-transform: uppercase; letter-spacing: .5px; } | ||
| #panel-msgs { list-style: none; display: flex; flex-direction: column; gap: 8px; } | ||
| #panel-msgs li { | ||
| background: #242019; border-radius: 6px; padding: 7px 9px; font-size: 12px; line-height: 1.4; | ||
| } | ||
| #panel-msgs .m-head { display: flex; justify-content: space-between; gap: 8px; margin-bottom: 3px; } | ||
| #panel-msgs .m-route { color: var(--warmGlow); font-weight: 600; } | ||
| #panel-msgs .m-time { color: #6e6048; font-variant-numeric: tabular-nums; } | ||
| #panel-msgs .m-body { color: #d8ccb4; word-break: break-word; } | ||
| #panel-msgs .m-empty { color: #6e6048; font-style: italic; } | ||
| /* ---- 断连横幅 ---- */ | ||
| #banner { | ||
| position: fixed; top: 0; left: 0; right: 0; | ||
| background: #6e2318; color: #ffd9c8; | ||
| text-align: center; padding: 8px; font-size: 13px; font-weight: 600; | ||
| z-index: 100; border-bottom: 2px solid #8a2c1e; | ||
| animation: slidedown .25s ease-out; | ||
| } | ||
| @keyframes slidedown { from { transform: translateY(-100%); } to { transform: translateY(0); } } | ||
| #stage-wrap.stale { filter: grayscale(.5) brightness(.7); transition: filter .3s; } | ||
| .hidden { display: none !important; } | ||
| #empty { color: #8a7a5e; font-size: 15px; } |
| /* falinks 像素办公室 — 渲染与轮询(只读)。 | ||
| 数据:GET /office/state(1s 轮询)。素材清单:assets/sprites.json(单一事实源)。 | ||
| 状态两级冗余:一级=脚下 tile 染色发光,二级=头顶形状浮标。 */ | ||
| (() => { | ||
| 'use strict'; | ||
| const ASSETS = 'assets/'; | ||
| const POLL_MS = 1000; | ||
| const FRAME_MS = 250; // 打字微动 ~4fps | ||
| const BUBBLE_MS = 4500; | ||
| const DONE_MS = 3000; // busy→idle done 可感知窗口(青余辉, SPEC P0-2 D) | ||
| const MAX_MISS = 2; // 连续轮询失败数后判定断连 | ||
| // ---- i18n(内联小词典,?lang= 切换;key 对齐 zh/en) ---- | ||
| const LANG = (new URLSearchParams(location.search).get('lang') || 'zh').toLowerCase().startsWith('en') ? 'en' : 'zh'; | ||
| const T = { | ||
| zh: { subtitle: '像素办公室', empty: '暂无成员', hint: '点击工位查看详情', disconnected: '连接已断开,正在重试…', | ||
| role: '角色', messages: '相关消息', waiting: '待回答', noMessages: '暂无相关消息', | ||
| overview: '团队概览', legend: '状态图例', lastDone: '上次完成', resting: '离座', boss: '坐镇', | ||
| st: { idle:'空闲', busy:'忙碌', waiting:'等待', stuck:'卡住', done:'完成', offline:'离线' }, | ||
| stat: { idle:'在岗', busy:'忙', waiting:'等待', stuck:'卡住', offline:'离线' } }, | ||
| en: { subtitle: 'Pixel Office', empty: 'No members', hint: 'Click a desk for details', disconnected: 'Disconnected — retrying…', | ||
| role: 'Role', messages: 'Related messages', waiting: 'Awaiting answer', noMessages: 'No related messages', | ||
| overview: 'Team', legend: 'Legend', lastDone: 'Last done', resting: 'Away', boss: 'Overseeing', | ||
| st: { idle:'Idle', busy:'Busy', waiting:'Waiting', stuck:'Stuck', done:'Done', offline:'Offline' }, | ||
| stat: { idle:'Idle', busy:'Busy', waiting:'Wait', stuck:'Stuck', offline:'Offline' } }, | ||
| }[LANG]; | ||
| document.documentElement.lang = LANG === 'en' ? 'en' : 'zh-CN'; | ||
| document.getElementById('subtitle').textContent = T.subtitle; | ||
| document.getElementById('empty').textContent = T.empty; | ||
| document.getElementById('panel-hint').textContent = T.hint; | ||
| document.getElementById('ov-title').textContent = T.overview; | ||
| document.getElementById('ov-legend-title').textContent = T.legend; | ||
| document.getElementById('panel-msgs-label').textContent = T.messages; | ||
| document.getElementById('banner').textContent = T.disconnected; | ||
| // ---- 元素 ---- | ||
| const $stageWrap = document.getElementById('stage-wrap'); | ||
| const $stage = document.getElementById('stage'); | ||
| const $wall = document.getElementById('wall'); | ||
| const $floor = document.getElementById('floor'); | ||
| const $decor = document.getElementById('decor'); | ||
| const $lounge = document.getElementById('lounge'); | ||
| const $desks = document.getElementById('desks'); | ||
| const $bubbles = document.getElementById('bubbles'); | ||
| const $empty = document.getElementById('empty'); | ||
| const $banner = document.getElementById('banner'); | ||
| const $panel = document.getElementById('panel'); | ||
| const $overview = document.getElementById('panel-overview'); | ||
| const $ovStats = document.getElementById('ov-stats'); | ||
| const $ovLegend = document.getElementById('ov-legend'); | ||
| // ---- 几何(与 SPRITE-SPEC 一致) ---- | ||
| const SCALE = 4; | ||
| const CELL = 16 * SCALE; // 64 地砖像素 | ||
| const WS_W = 36, WS_H = 22; // workstation cell | ||
| const STA_W = WS_W * SCALE; // 144 工位占位宽 | ||
| const STA_H = 28 * SCALE; // 112 (6 头顶 + 22 工位) | ||
| const GAP_X = 30, GAP_Y = 46; // 工位间距 | ||
| const COLS_MAX = 5; // 单行工位上限(超出分行成方阵, 消灭单人尾行) | ||
| const PAD = 28; // 房间内边距 | ||
| const WALL_H = 40 * SCALE; // 后墙带高 | ||
| const COMMONS_H = 150; // 工位下方公共休息带高 | ||
| const TOP_GAP = 24; // (保留) | ||
| const VPAD = 30; // 地面区(墙带以下)内 desk 块上下对称留白 → 含墙光学居中 | ||
| const ROOM_ASPECT = 1.4; // 房间最小横宽比(消左右大留白 / 大屏铺满; 1.4 兼顾 1280 与 1920) | ||
| const FIT_MIN = 0.5, FIT_MAX = 2;// fit 缩放系数夹取区间(下限<1 让窄屏 shrink-to-fit, 不溢出不裁切) | ||
| let S = null; // sprites.json | ||
| let imgs = {}; // 预加载图(含 naturalWidth/Height) | ||
| let state = null; // 最近一次 /office/state | ||
| let selected = null; // 选中成员名 | ||
| let frame = 0; // 打字帧 | ||
| let misses = 0; // 连续失败计数 | ||
| let booted = false; | ||
| const lastMsgTs = {}; // name -> 最近一次已展示气泡的消息 ts | ||
| const bubbleState = {}; // name -> { el, timer } 说话气泡(每人至多 1) | ||
| let curL = null; // 最近一次布局(供气泡锚点/边界用) | ||
| let bossOnScreen = false; // 当前 roster 是否含 boss(气泡锚点判定) | ||
| const doneUntil = {}; // name -> ts(ms) done 闪结束时刻 | ||
| const lastDoneAt = {}; // name -> 最近一次 busy→idle 完成时刻(ms) | ||
| const prevStatus = {}; // name -> 上次原始 status | ||
| const busyTierShown = {}; // name -> 当前显示的繁忙强度档(0-3) | ||
| const busyDownSince = {}; // name -> 开始满足降档的时刻(ms; 1s 迟滞防抖) | ||
| const stations = new Map(); // name -> {el, parts...} | ||
| // ---------- 工具 ---------- | ||
| function loadImg(src) { | ||
| return new Promise((res, rej) => { | ||
| const im = new Image(); | ||
| im.onload = () => res(im); | ||
| im.onerror = () => rej(new Error('load fail: ' + src)); | ||
| im.src = src; | ||
| }); | ||
| } | ||
| // 在 el 上设置一个 sprite(背景裁切)。coords=[x,y,w,h],img 提供整图尺寸。 | ||
| function setSprite(el, img, coords) { | ||
| const [x, y, w, h] = coords; | ||
| el.style.setProperty('--bx', x); | ||
| el.style.setProperty('--by', y); | ||
| el.style.setProperty('--w', w); | ||
| el.style.setProperty('--h', h); | ||
| el.style.setProperty('--bw', img.naturalWidth); | ||
| el.style.setProperty('--bh', img.naturalHeight); | ||
| el.style.backgroundImage = `url(${img.src})`; | ||
| } | ||
| function mkSpr(cls, img, coords) { | ||
| const el = document.createElement('div'); | ||
| el.className = 'spr ' + cls; | ||
| setSprite(el, img, coords); | ||
| return el; | ||
| } | ||
| // ---- 原始 roster status → 6 态视觉(状态视觉编码规范 单一事实源见 OFFICE-REDESIGN.md) ---- | ||
| function visualState(agent, questionFroms) { | ||
| const raw = agent.status; | ||
| if (raw === 'dead') return 'offline'; | ||
| if (questionFroms.has(agent.name)) return 'waiting'; // 有待答问题=正常等待(优先于 stuck) | ||
| if (raw === 'stuck' || agent.unresponsive) return 'stuck'; // 卡住/无响应=需介入(从 waiting 拆出) | ||
| if (raw === 'busy') return 'busy'; | ||
| if (raw === 'launching') return 'idle'; | ||
| // busy→idle 的短暂 done 闪 | ||
| if (doneUntil[agent.name] && Date.now() < doneUntil[agent.name]) return 'done'; | ||
| return 'idle'; | ||
| } | ||
| function statusLabel(v) { return T.st[v] || v; } | ||
| // boss 主位:独立分支渲染,不参与 6 态/强度。按 name 识别(falinks 约定的老板)。 | ||
| function isBoss(a) { return !!a && a.name === 'boss'; } | ||
| // 繁忙强度分档(仅 busy 内部子维度,不改 6 态):队列越长动得越凶。 | ||
| // 档位 L0 queue0 / L1 1-2 / L2 3-4 / L3 ≥5;升档即时,降档 1s 迟滞防抖。 | ||
| function busyTierFor(name, queue) { | ||
| const q = queue || 0; | ||
| const target = q >= 5 ? 3 : q >= 3 ? 2 : q >= 1 ? 1 : 0; | ||
| const shown = busyTierShown[name] || 0; | ||
| let next; | ||
| if (target >= shown) { // 升档(或持平)即时 | ||
| next = target; busyDownSince[name] = 0; | ||
| } else { // 降档需持续 1s | ||
| if (!busyDownSince[name]) busyDownSince[name] = Date.now(); | ||
| if (Date.now() - busyDownSince[name] >= 1000) { next = target; busyDownSince[name] = 0; } | ||
| else next = shown; | ||
| } | ||
| busyTierShown[name] = next; | ||
| return next; | ||
| } | ||
| // 浮标字形:唯一事实源 = sprites.json.status[*].floater(key) → 字符。 | ||
| // 头顶浮标 / 右栏图例 都走这里,杜绝第二处硬编码(防漂移)。 | ||
| const FLOATER_GLYPH = { dot: '', dots: '…', check: '✓', 'bang-tri': '!', cross: '✕' }; | ||
| function floaterKey(vis) { return S && S.status[vis] && S.status[vis].floater; } | ||
| function floaterGlyph(vis) { const f = floaterKey(vis); return f ? (FLOATER_GLYPH[f] || '') : ''; } | ||
| function fmtTime(ts) { | ||
| const d = new Date(ts); | ||
| const p = (n) => String(n).padStart(2, '0'); | ||
| return `${p(d.getHours())}:${p(d.getMinutes())}`; | ||
| } | ||
| // ---------- 静态房间 ---------- | ||
| // 几何: 均衡分行(方阵) + 横宽比下限(消左右大留白) + 工位块垂直居中 + 休息角坐标。 | ||
| function computeLayout(deskCount, hasBoss) { | ||
| const rows = Math.max(1, Math.ceil(deskCount / COLS_MAX)); | ||
| const perRow = Math.max(1, Math.ceil(deskCount / rows)); | ||
| const deskBlockW = perRow * STA_W + (perRow - 1) * GAP_X; | ||
| const deskBlockH = rows * STA_H + (rows - 1) * GAP_Y; | ||
| const floorTop = Math.round(WALL_H * 0.62); // 地面贴墙裙(铺到墙带下沿之后) | ||
| const MID_GAP = 14; | ||
| // boss 主位占顶部一带(大班椅+暖毯+铭牌 + 与首排纵向错开≥1CELL); 无 boss 时退化为对称留白 | ||
| const BOSS_BAND = hasBoss ? 146 : 0; | ||
| const desksTop = WALL_H + (hasBoss ? BOSS_BAND : VPAD); | ||
| const commonsTop = desksTop + deskBlockH + MID_GAP; | ||
| const commonsBottom = commonsTop + COMMONS_H; | ||
| const roomH = commonsBottom + VPAD; | ||
| const minW = PAD * 2 + deskBlockW; | ||
| const roomW = Math.max(minW, Math.round(roomH * ROOM_ASPECT)); | ||
| const deskLeft0 = Math.round((roomW - deskBlockW) / 2); | ||
| // boss 主位几何: 房间上方正中、椅背靠墙 | ||
| // 不变量: boss 坐镇区恒落在后墙带之下(seatTop≈WALL_H),而窗都贴墙带顶部(底沿 < WALL_H), | ||
| // 二者纵向永不重叠 → 挤窗判定(≥0.5CELL 或 纵向不重叠)始终满足、中轴位恒成立, | ||
| // 故 OFFICE-REDESIGN 的"先挪中央窗→退左上角"回退分支当前为死分支(YAGNI 未实现)。 | ||
| // ⚠️ 若日后改动 boss 位置或窗位置/尺寸,需重新评估此不变量与回退分支。 | ||
| // 注: 左双窗已左移(x→PAD / PAD+128)让位 boss 身后书架排; 该不变量只涉纵向, 仍成立。 | ||
| const bossCX = Math.round(roomW / 2); | ||
| const bossSeatTop = WALL_H - 4; | ||
| // 休息角(纯装饰: 沙发+地毯+猫狗, 不再坐人)落在公共带左中 | ||
| const rugW = Math.max(180, Math.min(280, Math.round(roomW * 0.30))); | ||
| const rugH = 96; | ||
| const loungeX = Math.round(roomW * 0.30 - rugW / 2); | ||
| const loungeY = commonsTop + 26; | ||
| return { rows, perRow, deskCount, deskBlockW, deskBlockH, roomW, roomH, | ||
| floorTop, desksTop, commonsTop, commonsBottom, deskLeft0, | ||
| hasBoss, bossCX, bossSeatTop, rugW, rugH, loungeX, loungeY }; | ||
| } | ||
| function layoutRoom(deskCount, hasBoss) { | ||
| const L = computeLayout(deskCount, hasBoss); | ||
| $stage.style.width = L.roomW + 'px'; | ||
| $stage.style.height = L.roomH + 'px'; | ||
| $stage.style.setProperty('--cell', CELL + 'px'); | ||
| $wall.style.height = WALL_H + 'px'; | ||
| $wall.style.backgroundImage = `url(${imgs.wall.src})`; | ||
| $wall.style.backgroundSize = `${imgs.wall.naturalWidth * SCALE}px ${WALL_H}px`; | ||
| $floor.style.top = L.floorTop + 'px'; | ||
| $floor.style.backgroundSize = `${CELL * 2}px ${CELL * 2}px`; | ||
| return L; | ||
| } | ||
| // fit-to-viewport: 固定 SCALE 之上叠 transform:scale(k), 铺满 wrap 又不糊像素。 | ||
| function fitStage() { | ||
| if (!S || !$stage.offsetWidth) return; | ||
| const PADW = 16; // 与 #stage-wrap padding 对齐 | ||
| const availW = Math.max(1, $stageWrap.clientWidth - PADW * 2); | ||
| const availH = Math.max(1, $stageWrap.clientHeight - PADW * 2); | ||
| let k = Math.min(availW / $stage.offsetWidth, availH / $stage.offsetHeight); | ||
| k = Math.floor(k * 4) / 4; // 吸附 0.25 倍, 防非整数缩放糊像素 | ||
| k = Math.max(FIT_MIN, Math.min(FIT_MAX, k)); | ||
| $stage.style.transformOrigin = 'center center'; | ||
| $stage.style.transform = 'scale(' + k + ')'; | ||
| } | ||
| // 家具按 4 功能区错落布置(角落锚 + 对角, 杜绝一字排/孤件); 任一件离工位行/浮标/boss ≥0.75CELL(SPEC红线5)。 | ||
| function buildDecor(L) { | ||
| $decor.innerHTML = ''; | ||
| const atlas = imgs.atlas, sp = S.atlas.sprites; | ||
| const add = (key, left, top, z) => { | ||
| if (!sp[key]) return null; | ||
| const el = mkSpr('decor-' + key, atlas, sp[key]); | ||
| el.style.left = Math.round(left) + 'px'; | ||
| el.style.top = Math.round(top) + 'px'; | ||
| if (z != null) el.style.zIndex = z; | ||
| $decor.appendChild(el); | ||
| return el; | ||
| }; | ||
| const sw = (k) => (sp[k] ? sp[k][2] * SCALE : 0); | ||
| const sh = (k) => (sp[k] ? sp[k][3] * SCALE : 0); | ||
| const W = L.roomW, s = SCALE; | ||
| const deskRight = L.deskLeft0 + L.deskBlockW; | ||
| const cBot = L.commonsBottom; | ||
| const CLEAR = Math.round(0.75 * CELL); // 离工位/boss 最小净距 | ||
| // 墙上窗(暖光透入): 左双窗左移让位 boss 身后书架排(只动 x→PAD/PAD+128, 纵向不变) | ||
| add('win_blue1', PAD, WALL_H * 0.18); | ||
| add('win_blue2', PAD + (sp.win_blue1[2] + 6) * s, WALL_H * 0.18); // = PAD+128, 右缘≈260, 距书架排 ≥42px | ||
| add('win_tall', W * 0.80, WALL_H * 0.08); | ||
| // 墙带·boss 正后方书架排: 3 个 vending2 当书架嵌墙(top=2s), 等距 12px 两两不相交 | ||
| // 放 $decor(z 低于 boss 所在 $lounge) → 渲染在 boss 身后; cx 跟随 bossCX 恒居中 | ||
| [-39, -12, 15].forEach((dx) => add('vending2', L.bossCX + dx * s, 2 * s, 1)); | ||
| // ── A 休息区(底左角): 沙发并排留缝(无侧面 sprite) + 圆角矩形暖毯锚 + 猫狗(ux 精确坐标) ── | ||
| const aX = PAD, gB = L.roomH - 2 * s; // gB = 舞台底基线(贴底, 留满间距) | ||
| const rug = document.createElement('div'); rug.className = 'rug'; | ||
| rug.style.left = Math.round(aX) + 'px'; | ||
| rug.style.top = Math.round(gB - 32 * s) + 'px'; | ||
| rug.style.width = Math.round(71 * s) + 'px'; | ||
| rug.style.height = Math.round(34 * s) + 'px'; | ||
| $decor.appendChild(rug); | ||
| if (sp.sofa_gray) add('sofa_gray', aX + 2 * s, gB - 29 * s, 2); // 灰沙发(左) | ||
| add('sofa_red', aX + 37 * s, gB - 30 * s, 2); // 红沙发(右, 与灰留 8px 缝、底对齐) | ||
| add('cat', aX + 3 * s, gB - 13 * s, 3); // 猫狗趴毯前缘, 间隙 12px 不相交 | ||
| add('corgi', aX + 22 * s, gB - 11 * s, 3); | ||
| // ── B 茶水间(底右角): counter 已删 / vending2 移作书架排; 绿植+小长椅成组填脚印(8px 相邻, 非孤件) ── | ||
| // 长椅右缘对齐 desk 块右沿 → 整组落在 zone C(deskRight+CLEAR)左侧, 两区互不相撞 | ||
| const bBase = cBot - 4; // 公共带底基线 | ||
| const benchL = deskRight - sw('bench_sm'); | ||
| add('bench_sm', benchL, bBase - sh('bench_sm'), 2); | ||
| add('plant_tall', benchL - sw('plant_tall') - 2 * s, bBase - sh('plant_tall'), 1); // 绿植贴长椅左(8px 相邻, 底对齐) | ||
| // ── C 绿植/洽谈角(右侧 desk 列右侧, 填右半空白 P1#18): 绿植+小长椅 紧凑成组(竖向, 离桌 ≥0.75CELL) ── | ||
| const cX = deskRight + CLEAR; | ||
| if (cX + sw('plant_tall') < W - PAD) { | ||
| add('plant_tall', cX, L.desksTop + 2 * s, 1); | ||
| if (sp.bench_sm) add('bench_sm', cX, L.desksTop + 22 * s, 1); | ||
| add('plant_tall', cX + 2 * s, L.desksTop + 38 * s, 1); | ||
| } | ||
| // ── D 左侧收边绿植(左 desk 列左侧, 与 A 错开; 离桌 ≥0.75CELL) ── | ||
| const dX = L.deskLeft0 - sw('plant_tall') - CLEAR; | ||
| if (dX > PAD) { | ||
| add('plant_tall', dX, L.desksTop + 4 * s, 1); | ||
| add('plant_tall', dX, L.desksTop + L.deskBlockH - sh('plant_tall'), 1); | ||
| } | ||
| } | ||
| // ---------- boss 主位(独立渲染分支: 不经 makeStation/updateStation, 无状态/无浮标/无强度) ---------- | ||
| function renderBoss(boss, L) { | ||
| $lounge.innerHTML = ''; | ||
| if (!boss) return; | ||
| const sp = S.atlas.sprites; | ||
| const [pw, ph] = S.people.cell; | ||
| const cx = L.bossCX, top = L.bossSeatTop, s = SCALE; | ||
| const mk = (key, img, coords, left, t, z) => { | ||
| const el = mkSpr(key, img, coords); | ||
| el.style.left = Math.round(left) + 'px'; el.style.top = Math.round(t) + 'px'; | ||
| el.style.zIndex = z; $lounge.appendChild(el); return el; | ||
| }; | ||
| const div = (cls, left, t, w, h, z) => { | ||
| const el = document.createElement('div'); el.className = cls; | ||
| el.style.left = Math.round(left) + 'px'; el.style.top = Math.round(t) + 'px'; | ||
| if (w != null) el.style.width = Math.round(w) + 'px'; | ||
| if (h != null) el.style.height = Math.round(h) + 'px'; | ||
| if (z != null) el.style.zIndex = z; | ||
| $lounge.appendChild(el); return el; | ||
| }; | ||
| // 大班桌几何(≥1.4× 员工桌): 盖住 boss 下半身, 比员工桌更宽更厚 | ||
| const deskW = 44 * s, deskH = 12 * s, deskTop = top + 8 * s, deskLeft = cx - deskW / 2; | ||
| // 层级(从下到上): 暖地毯 → 大班椅 → boss 半身 → 大班桌 → 桌前铭牌 → 桌上小物 | ||
| // 暖地毯(纯装饰不发光, 略宽于桌作区域锚) | ||
| const rugW = 50 * s, rugH = 9 * s; | ||
| div('boss-rug', cx - rugW / 2, top + 9 * s, rugW, rugH, 0); | ||
| // 两侧绿植围区 | ||
| mk('decor-plant_tall', imgs.atlas, sp.plant_tall, cx - 30 * s, top - 2 * s, 1); | ||
| mk('decor-plant_tall', imgs.atlas, sp.plant_tall, cx + 20 * s, top - 2 * s, 1); | ||
| // 大班椅 + boss 半身 | ||
| const chairW = sp.chair_white[2] * s; | ||
| mk('chair', imgs.atlas, sp.chair_white, cx - chairW / 2, top + 2 * s, 2); | ||
| const bcol = (S.people.order.indexOf('p2_auburn') + 0) % S.people.order.length; | ||
| mk('person', imgs.people, [(bcol < 0 ? 0 : bcol) * pw, 0, pw, ph], cx - (pw * s) / 2, top - 2 * s, 3); | ||
| // 大班桌(CSS 木桌, 盖下半身) | ||
| div('boss-desk', deskLeft, deskTop, deskW, deskH, 4); | ||
| // 桌上小物(老板办公桌非工位: 暖光台灯 + 合盖笔记本; 无 status 屏) | ||
| div('boss-lamp', deskLeft + 5 * s, deskTop - 4 * s, null, null, 5); | ||
| div('boss-laptop', deskLeft + deskW - 15 * s, deskTop - 2 * s, null, null, 5); | ||
| // 桌前立面铭牌(替代孤立飘牌) | ||
| const plate = div('boss-plate', cx, deskTop + deskH - 7 * s, null, null, 5); | ||
| plate.textContent = boss.name; | ||
| // 点选区 | ||
| const hit = div('boss-hit', cx - 24 * s, top - 4 * s, 48 * s, 26 * s, 6); | ||
| hit.addEventListener('click', () => selectAgent(boss.name)); | ||
| } | ||
| // ---------- 工位 ---------- | ||
| function makeStation(agent, idx, desks) { | ||
| const el = document.createElement('div'); | ||
| el.className = 'station'; | ||
| el.dataset.name = agent.name; | ||
| const tile = document.createElement('div'); tile.className = 'tile'; | ||
| el.appendChild(tile); | ||
| // 椅子(暖色循环) | ||
| const chairKeys = ['chair_orange', 'chair_yellow', 'chair_green', 'chair_blue', 'chair_white']; | ||
| const chair = mkSpr('chair', imgs.atlas, S.atlas.sprites[chairKeys[idx % chairKeys.length]]); | ||
| el.appendChild(chair); | ||
| // 小人半身:lead 用 leadSuggest,其余从剩余列里唯一分配(避免撞脸) | ||
| const order = S.people.order; | ||
| const leadCol = order.indexOf(S.people.leadSuggest); | ||
| let col; | ||
| if (agent.lead && leadCol >= 0) { | ||
| col = leadCol; | ||
| } else { | ||
| const others = order.map((_, i) => i).filter((i) => i !== leadCol); | ||
| const rank = desks.filter((a, i) => i < idx && !a.lead).length; | ||
| col = others.length ? others[rank % others.length] : (idx % order.length); | ||
| } | ||
| const [pw, ph] = S.people.cell; | ||
| const person = mkSpr('person', imgs.people, [col * pw, 0, pw, ph]); | ||
| person.dataset.col = col; | ||
| el.appendChild(person); | ||
| // 工位(木桌+显示器,状态列) | ||
| const ws = mkSpr('ws', imgs.workstation, [0, 0, WS_W, WS_H]); | ||
| el.appendChild(ws); | ||
| if (agent.lead) { | ||
| const crown = document.createElement('div'); | ||
| crown.className = 'crown'; | ||
| el.appendChild(crown); | ||
| } | ||
| const floater = document.createElement('div'); floater.className = 'floater hidden'; | ||
| el.appendChild(floater); | ||
| const name = document.createElement('div'); name.className = 'name'; | ||
| name.textContent = agent.name; | ||
| el.appendChild(name); | ||
| // idle 打瞌睡用: 头顶 Zzz + 每人随机错峰(纯 CSS, 共用 --doze-dur/--doze-delay) | ||
| const zzz = document.createElement('div'); zzz.className = 'zzz'; zzz.textContent = 'Zzz'; | ||
| el.appendChild(zzz); | ||
| const dur = 11 + Math.random() * 11; // 清醒+打盹 ~11-22s | ||
| el.style.setProperty('--doze-dur', dur.toFixed(2) + 's'); | ||
| el.style.setProperty('--doze-delay', (-Math.random() * dur).toFixed(2) + 's'); // 随机相位错峰 | ||
| el.addEventListener('click', () => selectAgent(agent.name)); | ||
| $desks.appendChild(el); | ||
| const rec = { el, tile, person, ws, floater, col }; | ||
| stations.set(agent.name, rec); | ||
| return rec; | ||
| } | ||
| function positionStation(el, idx, L) { | ||
| const r = Math.floor(idx / L.perRow), c = idx % L.perRow; | ||
| // 每行居中(末行不足列数时也居中, 不左挤) | ||
| const itemsInRow = (r < L.rows - 1) ? L.perRow : (L.deskCount - L.perRow * (L.rows - 1)); | ||
| const rowW = itemsInRow * STA_W + (itemsInRow - 1) * GAP_X; | ||
| const rowLeft = Math.round((L.roomW - rowW) / 2); | ||
| el.style.left = (rowLeft + c * (STA_W + GAP_X)) + 'px'; | ||
| el.style.top = (L.desksTop + r * (STA_H + GAP_Y)) + 'px'; | ||
| } | ||
| function updateStation(rec, agent, vis) { | ||
| const el = rec.el; | ||
| el.classList.remove('s-offline', 's-launching', 's-stuck', 'b1', 'b2', 'b3'); | ||
| el.classList.toggle('dozing', vis === 'idle'); // 仅 idle 可能打盹; 切走→秒停 | ||
| if (vis === 'offline') el.classList.add('s-offline'); | ||
| if (vis === 'stuck') el.classList.add('s-stuck'); | ||
| if (agent.status === 'launching') el.classList.add('s-launching'); | ||
| // 繁忙强度分档(仅 busy 内部子维度: 队列越长动得越凶; 升即时/降 1s 迟滞) | ||
| if (vis === 'busy') { | ||
| const tier = busyTierFor(agent.name, agent.queue); | ||
| if (tier > 0) el.classList.add('b' + tier); | ||
| } else { | ||
| busyTierShown[agent.name] = 0; busyDownSince[agent.name] = 0; | ||
| } | ||
| // 工位屏幕状态列 | ||
| const wsCol = S.workstation.states[vis] ?? 0; | ||
| rec.ws.style.setProperty('--bx', wsCol * WS_W); | ||
| // 脚下 tile 发光 | ||
| rec.tile.className = 'tile ' + vis; | ||
| // 头顶浮标:busy=实心圆(CSS 形状); stuck=三角!; offline=空心✕; waiting…/done✓ 圆角矩形。 | ||
| // 字形与图例同走 floaterGlyph()(单一事实源 sprites.json.status[*].floater)。 | ||
| const f = floaterKey(vis); | ||
| if (f) { | ||
| rec.floater.textContent = FLOATER_GLYPH[f] || ''; | ||
| rec.floater.className = 'floater ' + vis; | ||
| } else { | ||
| rec.floater.className = 'floater hidden'; | ||
| } | ||
| // 打字微动:busy 用 type 帧(行1),其余 rest(行0) | ||
| const [, ph] = S.people.cell; | ||
| const typing = vis === 'busy'; | ||
| const row = (typing && frame) ? S.people.frames.type : S.people.frames.rest; | ||
| rec.person.style.setProperty('--by', row * ph); | ||
| el.classList.toggle('sel', selected === agent.name); | ||
| } | ||
| // ---------- 说话气泡(独立 #bubbles 层: 不挂 .person/.station → 不被繁忙抖动/jolt 晃; 含 boss) ---------- | ||
| // 头顶锚点(stage 坐标): 返回 {x: 头顶中线, y: 气泡底边(浮标带之上)}; 非在屏→null。 | ||
| function bubbleAnchor(name) { | ||
| if (name === 'boss' && bossOnScreen && curL) { | ||
| return { x: curL.bossCX, y: curL.bossSeatTop - 8 }; // 压低让尾巴尖贴近 boss 头顶(无浮标) | ||
| } | ||
| const rec = stations.get(name); | ||
| if (!rec) return null; | ||
| const left = parseFloat(rec.el.style.left) || 0, top = parseFloat(rec.el.style.top) || 0; | ||
| return { x: left + STA_W / 2, y: top - 7.5 * SCALE }; // 压低 9s→7.5s: 尾巴尖贴近浮标/头顶(连上), 仍在浮标带之上不压浮标 | ||
| } | ||
| function positionBubble(b, anchor) { | ||
| const halfW = (b.offsetWidth || 80) / 2; | ||
| const roomW = curL ? curL.roomW : 0, roomH = curL ? curL.roomH : 0; | ||
| let cx = anchor.x; | ||
| cx = Math.max(4 + halfW, Math.min(roomW - 4 - halfW, cx)); // 整体平移回舞台(≥4px 边距) | ||
| b.style.left = Math.round(cx - halfW) + 'px'; | ||
| b.style.bottom = Math.round(roomH - anchor.y) + 'px'; | ||
| b.style.setProperty('--tail-dx', Math.round(anchor.x - cx) + 'px'); // 尾巴仍指说话人 | ||
| } | ||
| function fadeOutBubble(name) { | ||
| const st = bubbleState[name]; | ||
| if (!st || !st.el) return; | ||
| const el = st.el; | ||
| el.classList.add('fade'); | ||
| setTimeout(() => { if (el.parentNode) el.remove(); }, 260); | ||
| bubbleState[name] = null; | ||
| const rec = stations.get(name); // 气泡结束 → 恢复打盹随机循环 | ||
| if (rec) rec.el.classList.remove('talking'); | ||
| } | ||
| function showBubble(name, body) { | ||
| const anchor = bubbleAnchor(name); | ||
| if (!anchor) return; // 非在屏不弹 | ||
| const text = body.length > 48 ? body.slice(0, 47) + '…' : body; | ||
| let st = bubbleState[name]; | ||
| let b; | ||
| if (st && st.el && st.el.isConnected) { // 同人已有气泡 → 替换文本(只显最新, 不排队) | ||
| b = st.el; b.classList.remove('fade'); | ||
| } else { | ||
| b = document.createElement('div'); b.className = 'bubble'; | ||
| $bubbles.appendChild(b); | ||
| b.style.animation = 'bubble-pop .15s steps(3, end)'; // pop 仅新气泡 | ||
| st = bubbleState[name] = { el: b, timer: 0 }; | ||
| } | ||
| b.textContent = text; | ||
| positionBubble(b, anchor); // 量 offsetWidth 后定位+夹边 | ||
| const dwell = Math.max(2500, Math.min(6000, 2500 + 35 * text.length)); | ||
| clearTimeout(st.timer); | ||
| st.timer = setTimeout(() => fadeOutBubble(name), dwell); | ||
| const rec = stations.get(name); // 说话=醒着 → 挂起打盹(气泡优先) | ||
| if (rec) rec.el.classList.add('talking'); | ||
| } | ||
| // 每次重绘按当前头顶坐标重定位活动气泡(防 roster 重排/缩放后气泡飘离头顶); 说话人离屏则移除。 | ||
| function repositionBubbles() { | ||
| for (const name in bubbleState) { | ||
| const st = bubbleState[name]; | ||
| if (!st || !st.el || !st.el.isConnected) continue; | ||
| const anchor = bubbleAnchor(name); | ||
| if (anchor) positionBubble(st.el, anchor); | ||
| else { clearTimeout(st.timer); st.el.remove(); bubbleState[name] = null; } | ||
| } | ||
| } | ||
| // 未选中态:团队概览 + 常驻状态图例(图例小样与场内浮标同款) | ||
| function renderOverview() { | ||
| if (!state) return; | ||
| const qFroms = new Set(state.questions.map((q) => q.from)); | ||
| const counts = { idle:0, busy:0, waiting:0, stuck:0, offline:0 }; | ||
| for (const a of state.roster) { | ||
| if (a.virtual || isBoss(a)) continue; // boss 坐镇 / 纯装饰虚拟(intern) 不计入状态统计 | ||
| const v = visualState(a, qFroms); | ||
| if (counts[v] != null) counts[v]++; // done 瞬态不计入概览 | ||
| } | ||
| const order = ['idle', 'busy', 'waiting', 'stuck', 'offline']; | ||
| $ovStats.innerHTML = order.map((k) => | ||
| '<span class="ov-stat ' + k + '"><b>' + counts[k] + '</b> ' + esc(T.stat[k]) + '</span>' | ||
| ).join('<i class="ov-dot">·</i>'); | ||
| // 图例小样: 形(.floater.<state> 同 CSS) + 字形(floaterGlyph 同事实源) + 色(var(--state)) 三处与场内同款 | ||
| const legend = ['busy', 'idle', 'waiting', 'stuck', 'done', 'offline']; | ||
| $ovLegend.innerHTML = legend.map((k) => | ||
| '<li><span class="lg-ico floater ' + k + '">' + esc(floaterGlyph(k)) + '</span>' + | ||
| '<span class="lg-label">' + esc(T.st[k]) + '</span></li>' | ||
| ).join(''); | ||
| } | ||
| // ---------- 详情面板 ---------- | ||
| function selectAgent(name) { | ||
| selected = name; | ||
| try { history.replaceState(null, '', '#sel=' + encodeURIComponent(name)); } catch (e) { /* ignore */ } | ||
| stations.forEach((rec, n) => rec.el.classList.toggle('sel', n === name)); | ||
| renderPanel(); | ||
| } | ||
| function showOverview() { | ||
| $panel.classList.add('empty'); | ||
| document.getElementById('panel-body').classList.add('hidden'); | ||
| $overview.classList.remove('hidden'); | ||
| renderOverview(); | ||
| } | ||
| function renderPanel() { | ||
| if (!selected || !state) { showOverview(); return; } | ||
| const agent = state.roster.find((a) => a.name === selected); | ||
| if (!agent) { showOverview(); return; } | ||
| $panel.classList.remove('empty'); | ||
| $overview.classList.add('hidden'); | ||
| document.getElementById('panel-body').classList.remove('hidden'); | ||
| const qFroms = new Set(state.questions.map((q) => q.from)); | ||
| document.getElementById('panel-name').textContent = agent.name; | ||
| const pill = document.getElementById('panel-status'); | ||
| const $done = document.getElementById('panel-done'); | ||
| const $q = document.getElementById('panel-q'); | ||
| document.getElementById('panel-role').textContent = (T.role + ':') + (agent.role || '—'); | ||
| if (isBoss(agent)) { | ||
| // boss 坐镇: 中性标(非状态色), 不参与 6 态/强度, 无待答/无完成时间 | ||
| pill.textContent = T.boss; | ||
| pill.className = 'status-pill boss'; | ||
| $done.classList.add('hidden'); | ||
| $q.classList.add('hidden'); | ||
| } else { | ||
| const vis = visualState(agent, qFroms); | ||
| pill.textContent = statusLabel(vis); | ||
| pill.className = 'status-pill ' + vis; | ||
| // 上次完成时间(即便错过 done 动画也能查) | ||
| if (lastDoneAt[selected]) { | ||
| $done.classList.remove('hidden'); | ||
| $done.textContent = '✓ ' + T.lastDone + ':' + fmtTime(lastDoneAt[selected]); | ||
| } else { | ||
| $done.classList.add('hidden'); | ||
| } | ||
| // 待答问题 | ||
| const myQ = state.questions.filter((q) => q.from === selected); | ||
| if (myQ.length) { | ||
| $q.classList.remove('hidden'); | ||
| $q.innerHTML = '<div class="q-title">' + T.waiting + '</div>' + | ||
| myQ.map((q) => '<div>' + esc(q.question) + '</div>' + | ||
| (q.options || []).map((o) => '<div class="q-opt">· ' + esc(o) + '</div>').join('')).join(''); | ||
| } else { | ||
| $q.classList.add('hidden'); | ||
| } | ||
| } | ||
| // 相关消息:from==name || to==name | ||
| const $ul = document.getElementById('panel-msgs'); | ||
| const msgs = state.log.filter((m) => m.from === selected || m.to === selected).slice(-25); | ||
| if (!msgs.length) { | ||
| $ul.innerHTML = '<li class="m-empty">' + T.noMessages + '</li>'; | ||
| } else { | ||
| $ul.innerHTML = msgs.map((m) => | ||
| '<li><div class="m-head"><span class="m-route">' + esc(m.from) + ' → ' + esc(m.to) + | ||
| '</span><span class="m-time">' + fmtTime(m.ts) + '</span></div>' + | ||
| '<div class="m-body">' + esc(m.body) + '</div></li>').join(''); | ||
| } | ||
| } | ||
| function esc(s) { | ||
| return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"' }[c])); | ||
| } | ||
| // ---------- 渲染一帧状态 ---------- | ||
| function render() { | ||
| if (!state || !S) return; | ||
| const roster = state.roster; | ||
| const bossAgent = roster.find((a) => isBoss(a)); | ||
| // 只渲染 ①真实工位成员(含 offline,留在工位屏黑灰) ②boss 主位; 其余纯装饰虚拟(intern 等)不渲染小人 | ||
| const desks = roster.filter((a) => !a.virtual && !isBoss(a)); | ||
| $empty.classList.toggle('hidden', roster.length > 0); | ||
| const L = layoutRoom(desks.length, !!bossAgent); | ||
| curL = L; | ||
| bossOnScreen = !!bossAgent; | ||
| // done 闪:检测 busy→idle(boss 不参与状态, 跳过) | ||
| for (const a of roster) { | ||
| if (isBoss(a)) continue; | ||
| const prev = prevStatus[a.name]; | ||
| if (prev === 'busy' && a.status === 'idle') { | ||
| doneUntil[a.name] = Date.now() + DONE_MS; | ||
| lastDoneAt[a.name] = Date.now(); | ||
| } | ||
| prevStatus[a.name] = a.status; | ||
| } | ||
| const qFroms = new Set(state.questions.map((q) => q.from)); | ||
| // 增删工位(boss 与其它虚拟不建工位) | ||
| const want = new Set(desks.map((a) => a.name)); | ||
| for (const [name, rec] of stations) { | ||
| if (!want.has(name)) { rec.el.remove(); stations.delete(name); } | ||
| } | ||
| desks.forEach((a, idx) => { | ||
| let rec = stations.get(a.name); | ||
| if (!rec) rec = makeStation(a, idx, desks); | ||
| positionStation(rec.el, idx, L); | ||
| updateStation(rec, a, visualState(a, qFroms)); | ||
| }); | ||
| if (!booted) booted = true; | ||
| buildDecor(L); | ||
| renderBoss(bossAgent, L); | ||
| renderPanel(); | ||
| repositionBubbles(); // 重排后按当前头顶坐标重定位活动气泡 | ||
| fitStage(); | ||
| } | ||
| // 仅打字帧刷新(不重排) | ||
| function tickFrame() { | ||
| frame ^= 1; | ||
| if (!state || !S) return; | ||
| const qFroms = new Set(state.questions.map((q) => q.from)); | ||
| const [, ph] = S.people.cell; | ||
| for (const a of state.roster) { | ||
| const rec = stations.get(a.name); | ||
| if (!rec) continue; | ||
| const vis = visualState(a, qFroms); | ||
| if (vis === 'busy') { | ||
| rec.person.style.setProperty('--by', (frame ? S.people.frames.type : S.people.frames.rest) * ph); | ||
| } | ||
| } | ||
| } | ||
| // ---------- 轮询 ---------- | ||
| function onScreen(name) { // 在屏说话人: 工位成员 或 boss 主位(其余未渲染虚拟不弹) | ||
| return stations.has(name) || (name === 'boss' && bossOnScreen); | ||
| } | ||
| function diffBubbles() { | ||
| if (!state) return; | ||
| for (const m of state.log) { | ||
| const prev = lastMsgTs[m.from] || 0; | ||
| if (m.ts <= prev) continue; | ||
| lastMsgTs[m.from] = m.ts; | ||
| if (onScreen(m.from)) showBubble(m.from, m.body); // 只弹 from 且在屏(含 boss) | ||
| } | ||
| } | ||
| function seedLastTs() { | ||
| if (!state) return; | ||
| for (const m of state.log) lastMsgTs[m.from] = Math.max(lastMsgTs[m.from] || 0, m.ts); | ||
| } | ||
| async function poll() { | ||
| try { | ||
| const r = await fetch('state', { cache: 'no-store' }); | ||
| if (!r.ok) throw new Error('http ' + r.status); | ||
| const data = await r.json(); | ||
| const first = state === null; | ||
| state = data; | ||
| misses = 0; | ||
| $banner.classList.add('hidden'); | ||
| $stageWrap.classList.remove('stale'); | ||
| render(); | ||
| if (first) seedLastTs(); else diffBubbles(); | ||
| } catch (e) { | ||
| misses++; | ||
| if (misses >= MAX_MISS) { | ||
| $banner.classList.remove('hidden'); | ||
| $stageWrap.classList.add('stale'); | ||
| } | ||
| } | ||
| } | ||
| // ---------- 启动 ---------- | ||
| async function boot() { | ||
| try { | ||
| S = await fetch(ASSETS + 'sprites.json', { cache: 'no-store' }).then((r) => r.json()); | ||
| } catch (e) { | ||
| $banner.textContent = 'sprites.json 加载失败'; | ||
| $banner.classList.remove('hidden'); | ||
| return; | ||
| } | ||
| // 注入调色板到 CSS 变量 | ||
| const root = document.documentElement.style; | ||
| const p = S.palette || {}; | ||
| const map = { tileA:'tileA', tileB:'tileB', seam:'seam', wood:'wood', woodHi:'woodHi', woodLo:'woodLo', base:'base', | ||
| busy:'busy', waiting:'waiting', stuck:'stuck', done:'done', offline:'offline', idle:'idle', warmGlow:'warmGlow' }; | ||
| for (const k in map) if (p[k]) root.setProperty('--' + map[k], p[k]); | ||
| root.setProperty('--s', SCALE); | ||
| const [people, ws, wall, atlas] = await Promise.all([ | ||
| loadImg(ASSETS + S.people.image), | ||
| loadImg(ASSETS + S.workstation.image), | ||
| loadImg(ASSETS + S.wall.image), | ||
| loadImg(ASSETS + S.atlas.image), | ||
| ]); | ||
| imgs = { people, workstation: ws, wall, atlas }; | ||
| // 深链:#sel=<name> 预选某成员(分享/直达详情) | ||
| const m = /(?:^|#|&)sel=([^&]+)/.exec(location.hash); | ||
| if (m) { try { selected = decodeURIComponent(m[1]); } catch (e) { selected = m[1]; } } | ||
| await poll(); | ||
| setInterval(poll, POLL_MS); | ||
| setInterval(tickFrame, FRAME_MS); | ||
| // 窗口缩放时重新 fit(debounce 100ms) | ||
| let rzT = null; | ||
| window.addEventListener('resize', () => { | ||
| clearTimeout(rzT); | ||
| rzT = setTimeout(fitStage, 100); | ||
| }); | ||
| } | ||
| boot(); | ||
| })(); |
| import { spawn } from 'node:child_process'; | ||
| /** | ||
| * 跨平台在系统默认浏览器打开 url。darwin→open / linux→xdg-open / win32→start。 | ||
| * 异步 detached 启动并 unref,失败吞掉不抛(打不开浏览器不该让 console 报错)。 | ||
| */ | ||
| export function openBrowser(url) { | ||
| try { | ||
| const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; | ||
| const child = spawn(cmd, [url], { stdio: 'ignore', detached: true }); | ||
| // 子进程启动失败(如命令不存在)走 error 事件而非抛异常,挂个空 handler 防 unhandled。 | ||
| child.on('error', () => { }); | ||
| child.unref(); | ||
| } | ||
| catch { | ||
| // 吞掉:打不开浏览器不影响 console 继续运行。 | ||
| } | ||
| } |
@@ -6,2 +6,3 @@ import http from 'node:http'; | ||
| import { t } from '../i18n/index.js'; | ||
| import { handleOfficeRequest } from '../office/serve.js'; | ||
| const PATH_RE = /^\/agent\/([^/]+)\/mcp$/; | ||
@@ -46,3 +47,3 @@ /** 进程内的待答问题存储(ask 工具写入,控制台轮询 /admin/questions 读,/admin/answer 取走)。 */ | ||
| return `target dead: ${to}`; | ||
| return `blocked by guardrail (turn cap / loop / rate limit), message NOT delivered: ${to}`; | ||
| return t().undeliverableBlocked(to); | ||
| }; | ||
@@ -81,3 +82,4 @@ server.registerTool('register', { description: t().toolDescRegister, inputSchema: {} }, async () => { | ||
| const id = questions.add({ from: agentName, question, options }); | ||
| return ok({ ok: true, id, pending: true }); | ||
| // pending 返回里带显式停手指令:让 agent 在工具结果里直接看到"现在该 idle 等待,别空转"。 | ||
| return ok({ ok: true, id, pending: true, note: t().askPendingNote }); | ||
| } | ||
@@ -158,2 +160,5 @@ const body = t().askToPeer(question, options.map((o, i) => `${i + 1}. ${o}`).join('\n'), agentName); | ||
| const url = new URL(req.url ?? '', `http://${req.headers.host}`); | ||
| // ---- /office 像素办公室彩蛋(只读静态页 + state 聚合)---- | ||
| if (handleOfficeRequest(req, res, { router: deps.router, questions })) | ||
| return; | ||
| // ---- admin 路由(人/老板入口)---- | ||
@@ -160,0 +165,0 @@ if (url.pathname.startsWith('/admin/')) { |
+67
-5
@@ -14,3 +14,4 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; | ||
| import { saveClipboardImage, expandImageTokens } from './clipboard.js'; | ||
| import { t, setLocale } from '../i18n/index.js'; | ||
| import { t, setLocale, getLocale } from '../i18n/index.js'; | ||
| import { openBrowser } from '../util/open-browser.js'; | ||
| const PKG = (() => { | ||
@@ -27,2 +28,6 @@ try { | ||
| const DIAG_WINDOW_MS = 5 * 60_000; | ||
| // 1007 滚轮消歧:孤立单个方向键缓冲的极短窗口。窗口内又来同向方向键即判为滚轮连发。 | ||
| // 取 40ms——远大于滚轮把一格拆成跨 chunk 单发的间隔(亚毫秒~数十毫秒),又小于 macOS | ||
| // 默认键重复间隔(~80ms+),故键盘单按/常速按住仍单发结算,不误判为滚轮。见下方 onData。 | ||
| const WHEEL_COALESCE_MS = 40; | ||
| async function admin(port, method, path, body) { | ||
@@ -206,2 +211,7 @@ const res = await fetch(`http://127.0.0.1:${port}${path}`, { | ||
| } | ||
| if (a.kind === 'office') { | ||
| openBrowser(`http://127.0.0.1:${port}/office?lang=${getLocale()}`); | ||
| setStatus(t().officeOpened); | ||
| return; | ||
| } | ||
| if (a.kind === 'say') { | ||
@@ -391,4 +401,10 @@ const r = await admin(port, 'POST', '/admin/say', { to: a.to, message: expand(a.message), urgent: a.urgent }); | ||
| if (ev.type === 'ctrl' && ev.key === 'c') { | ||
| if (input.length > 0) { | ||
| setInput(''); | ||
| setCursor(0); | ||
| setHistIdx(null); | ||
| return; | ||
| } // 有内容:先清空输入,不退出(标准 REPL 手感) | ||
| setConfirmExit(true); | ||
| return; | ||
| return; // 输入为空:走退出确认 | ||
| } | ||
@@ -859,9 +875,55 @@ // 滚轮(1007 burst)/PageUp/PageDown:滚回看视口。浮层菜单开着时滚轮代行 ↑/↓ 选项(CC 同款手感)。 | ||
| return; | ||
| // 1007 滚轮 ≈ 方向键(为保终端原生拖选复制,我们故意不开鼠标上报),字节层无法区分滚轮与键盘 ↑↓。 | ||
| // 同 chunk 连发 ≥2 个方向键铁定是滚轮(wheelBurst),立即作 scroll、无延迟。 | ||
| // 但部分终端把一格滚轮拆成**跨 chunk 的多个单发**、或每格只发 1 个 ↑/↓——这些单发会被 decodeKey | ||
| // 当普通方向键,在贴底(scrollOff===0)时落进"输入历史"分支 → 滚轮改了输入框内容(boss 报的 bug)。 | ||
| // 对策:孤立单个 ↑/↓ 先进缓冲、延 WHEEL_COALESCE_MS 结算;窗口内又来同向方向键就累加 → 判为滚轮连发 | ||
| // (n≥2 → scroll 回看);窗口内无后续才结算为真实键盘方向键(贴底翻输入历史 / 回看态逐行滚)。 | ||
| // 这样滚轮永不触发输入历史,而键盘 ↑↓ 行为不变;原生复制不受影响(未改成鼠标上报)。 | ||
| let pend = null; | ||
| let pendTimer = null; | ||
| const flushPend = () => { | ||
| if (pendTimer) { | ||
| clearTimeout(pendTimer); | ||
| pendTimer = null; | ||
| } | ||
| if (!pend) | ||
| return; | ||
| const { dir, n } = pend; | ||
| pend = null; | ||
| handleKeyRef.current(n >= 2 ? { type: 'scroll', dir, n } : { type: dir }); | ||
| }; | ||
| const onData = (d) => { | ||
| const s = d.toString(); | ||
| const w = wheelBurst(s); // 1007 滚轮 burst 先于按键解码(同 chunk 连发方向键不可能是打字) | ||
| handleKeyRef.current(w ? { type: 'scroll', dir: w.dir, n: w.n } : decodeKey(s)); | ||
| const w = wheelBurst(s); | ||
| if (w) { // 同 chunk 连发:铁定滚轮——并入任何同向待定单发后立即派发为 scroll | ||
| if (pend && pend.dir === w.dir) { | ||
| w.n += pend.n; | ||
| pend = null; | ||
| if (pendTimer) { | ||
| clearTimeout(pendTimer); | ||
| pendTimer = null; | ||
| } | ||
| } | ||
| else | ||
| flushPend(); | ||
| handleKeyRef.current({ type: 'scroll', dir: w.dir, n: w.n }); | ||
| return; | ||
| } | ||
| const ev = decodeKey(s); | ||
| if (ev.type === 'up' || ev.type === 'down') { // 单发方向键:进缓冲,等窗口判滚轮/键盘 | ||
| if (pend && pend.dir !== ev.type) | ||
| flushPend(); // 反向先结算旧的 | ||
| pend = { dir: ev.type, n: (pend?.n ?? 0) + 1 }; | ||
| if (pendTimer) | ||
| clearTimeout(pendTimer); | ||
| pendTimer = setTimeout(flushPend, WHEEL_COALESCE_MS); | ||
| return; | ||
| } | ||
| flushPend(); // 其它键:先结算待定方向键(保序),再处理本键 | ||
| handleKeyRef.current(ev); | ||
| }; | ||
| stdin.on('data', onData); | ||
| return () => { stdin.off('data', onData); }; | ||
| return () => { stdin.off('data', onData); if (pendTimer) | ||
| clearTimeout(pendTimer); }; | ||
| }, [stdin]); | ||
@@ -868,0 +930,0 @@ const color = (s) => (s === 'idle' ? 'green' : s === 'busy' ? 'yellow' : s === 'dead' ? 'red' : 'gray'); |
@@ -11,2 +11,3 @@ import { t } from '../i18n/index.js'; | ||
| { name: 'lead', usage: '/lead', noArgs: true, get hint() { return t().cmdHint.lead; } }, | ||
| { name: 'office', usage: '/office', noArgs: true, get hint() { return t().cmdHint.office; } }, | ||
| { name: 'help', usage: '/help', noArgs: true, get hint() { return t().cmdHint.help; } }, | ||
@@ -13,0 +14,0 @@ ]; |
@@ -32,2 +32,4 @@ import { t } from '../i18n/index.js'; | ||
| return { kind: 'help' }; | ||
| if (cmd === 'office') | ||
| return { kind: 'office' }; | ||
| if (cmd === 'lang') { | ||
@@ -34,0 +36,0 @@ if (args.length > 0) |
+7
-2
@@ -122,2 +122,3 @@ /** 英文词典:typeof zh 钉死 key 与签名——漏译/签名不符直接编译失败。 */ | ||
| lead: 'Designate the lead (coordinator): opens a picker', | ||
| office: 'Open the pixel office (live view of workers) in your browser', | ||
| help: 'show usage', | ||
@@ -133,2 +134,4 @@ restart: "restart an agent's CLI (with falinks config; add fresh = brand-new session)", | ||
| usageLead: 'Usage: /lead (pick from the menu)', | ||
| officeOpened: 'Pixel office opened in your browser', | ||
| officeHint: 'Type /office to view the pixel office in your browser', | ||
| unknownCommand: (cmd) => `unknown command: /${cmd}`, | ||
@@ -212,4 +215,6 @@ usageMention: 'usage: @<name> <message> or @all <message>', | ||
| toolDescSendmsg: 'Send a message to a coworker/role', | ||
| toolDescIdle: 'Wrap up this turn, release to idle state', | ||
| toolDescAsk: 'Pose a multiple-choice question. Send to the boss (to="boss") and the boss sees clickable options and picks one; send to a coworker and they receive a message with numbered options and reply via sendmsg with their pick.', | ||
| toolDescIdle: 'Wrap up this turn and release to idle. Call it whenever you have nothing to do right now (waiting on a reply / waiting for the next message / just asked the boss) — a new message will automatically wake you again. This is the correct way to wait; do NOT busy-wait by repeatedly calling tools (who / resend / re-ask).', | ||
| toolDescAsk: 'Pose a multiple-choice question. Send to the boss (to="boss") and the boss sees clickable options and picks one; send to a coworker and they receive a message with numbered options and reply via sendmsg with their pick. Note: asking the boss returns immediately as pending (non-blocking; the boss may not answer right away). After asking, **call idle immediately to end your turn** and wait — the boss\'s answer arrives as a new message that wakes you again. Do NOT poll, re-ask, or busy-resend (it gets blocked by guardrails and wastes turns).', | ||
| askPendingNote: 'Handed off to the boss (awaiting their answer). Call idle now to end this turn — the boss\'s answer will arrive as a new message and wake you again. Do not poll, do not re-ask, do not busy-resend.', | ||
| undeliverableBlocked: (to) => `Detected repeated sending / busy-waiting (blocked by the loop guardrail); message NOT delivered: ${to}. Call idle now to end this turn and wait — do not resend or re-ask; a new message will wake you again.`, | ||
| askBlockedInTodo: '[todolist running · unattended] Asking the boss is disabled — nobody is watching to answer, and a question would stall the long-running task. Proceed with your best recommended option based on available info, and record your assumptions and trade-offs in the task result / project state doc.', | ||
@@ -216,0 +221,0 @@ toolDescWho: 'View the online roster', |
+7
-2
@@ -130,2 +130,3 @@ /** 中文基准词典:全部用户可见/注入员工的文案都从这里出。纯文本=字符串,带变量=箭头函数。 */ | ||
| lead: '指定组长(协调者):弹出选择器选一个员工', | ||
| office: '在浏览器打开像素办公室(实时看员工干活)', | ||
| help: '显示用法', | ||
@@ -141,2 +142,4 @@ restart: '重启某员工的 CLI(带 falinks 配置;加 fresh=全新会话)', | ||
| usageLead: '用法: /lead(按提示选组长)', | ||
| officeOpened: '已在浏览器打开像素办公室', | ||
| officeHint: '输入 /office 在浏览器查看像素办公室', | ||
| unknownCommand: (cmd) => `未知命令: /${cmd}`, | ||
@@ -220,4 +223,6 @@ usageMention: '用法: @<name> <message> 或 @all <message>', | ||
| toolDescSendmsg: '给某个同事/角色发消息', | ||
| toolDescIdle: '本回合收尾,释放空闲状态', | ||
| toolDescAsk: '出选择题。发给老板(to="boss")老板会看到可点选项并回选;发给同事则对方收到带编号选项的消息,用 sendmsg 回选哪个。', | ||
| toolDescIdle: '本回合收尾、释放为空闲。当你此刻没有要立刻执行的动作时(在等回复 / 等下一条消息 / 刚向 boss 提完问)就调用它结束本回合——之后有新消息会自动重新唤起你。这才是正确的等待姿势;不要靠反复调用工具(who / 重发 / 重问)空转等待。', | ||
| toolDescAsk: '出选择题。发给老板(to="boss")老板会看到可点选项并回选;发给同事则对方收到带编号选项的消息,用 sendmsg 回选哪个。注意:向 boss 提问会立即返回 pending(不阻塞、老板未必马上答)。提问后请**立刻调用 idle 结束本回合**等待——老板回答会作为新消息重新唤起你;切勿轮询、重复提问或空转重发(会被护栏拦下、徒耗回合)。', | ||
| askPendingNote: '已转交 boss(等待其回答)。请现在就调用 idle 结束本回合——老板回答后会作为新消息重新唤起你。不要轮询、不要重复提问、不要空转重发。', | ||
| undeliverableBlocked: (to) => `检测到你在重复发送 / 空转(被循环护栏拦下),消息未送达:${to}。请立刻调用 idle 结束本回合等待——不要重发或重问;有新消息会自动重新唤起你。`, | ||
| askBlockedInTodo: '【todo 模式运行中·无人值守】禁止向 boss 提问——没人会即时回答,提问会卡死长任务。请基于现有信息按你的最优推荐方案继续推进,并在任务结果/项目状态档里写明你的假设与取舍。', | ||
@@ -224,0 +229,0 @@ toolDescWho: '查看在线花名册', |
+2
-2
| { | ||
| "name": "@hklmtt/falinks", | ||
| "version": "0.13.3", | ||
| "version": "0.14.0", | ||
| "description": "把多个终端 AI CLI(Claude Code、Codex…)编排成一间办公室:分屏真实窗口里的员工,通过 MCP 总线互相对话协作,外加一个控制台。", | ||
@@ -43,3 +43,3 @@ "keywords": [ | ||
| "scripts": { | ||
| "build": "tsc -p tsconfig.build.json", | ||
| "build": "tsc -p tsconfig.build.json && node scripts/copy-office-web.mjs", | ||
| "test": "vitest run", | ||
@@ -46,0 +46,0 @@ "test:watch": "vitest", |
+4
-0
@@ -152,4 +152,8 @@ ``` | ||
| ## Credits | ||
| - Office sprites by [2dPig](https://2dpig.itch.io/) (CC0) — used in the `/office` pixel-office easter egg. | ||
| ## License | ||
| MIT |
+4
-0
@@ -146,4 +146,8 @@ ``` | ||
| ## 致谢 | ||
| - 办公室像素素材 by [2dPig](https://2dpig.itch.io/)(CC0)——用于 `/office` 像素办公室彩蛋。 | ||
| ## License | ||
| MIT |
Mixed license
LicensePackage contains multiple licenses.
484868
36.35%68
30.77%7406
29.95%159
2.58%1
Infinity%8
33.33%