feat(web): Lisa Room — ambient, state-driven pixel-art living space#214
Conversation
…/room)
A new immersive scene where the full-body Lisa character "lives", reachable
from the GUI sidebar (⌂ Room) and at GET /room. Unlike a scripted companion
wallpaper, every layer is driven by Lisa's REAL state — not a canned loop.
Character (full-body animatable spritesheet, not a bust):
- lisa-idle.png — 2-frame sheet (eyes open|closed): breathing via CSS transform,
blink flips the frame via a JS timer.
- lisa-sit.png / lisa-sleep.png — pose sprites, swapped by mood/state
(working/reading → sit w/ glowing laptop; napping / Reve dreaming → sleep).
- Generated via an anchor → keyframes → chroma-key + foot-anchor pipeline
(cf. Ludo.ai / chongdashu/ai-game-spritesheets), gemini-2.5-flash-image on
Vertex; keyed + normalized with PIL. Fixes the earlier floating-bust look.
Scene:
- room-{day,dusk,night}.png — one pixel room, three time-of-day variants
(image-to-image kept furniture identical); crossfaded by the LOCAL clock.
- Layered DOM/CSS 2.5D diorama (no canvas/WebGL/bundler): backdrop-fill,
contained square stage foot-anchored to the floor, mood lighting, weather +
firefly particles, monitor "thinking" glow, dreaming dim + floating Z's,
a desk "letter" that reads Lisa's ★ while-you-were-away note.
Wiring (reuses existing signals, adds no backend state, read-only projection):
- GET /room serves a self-contained page (mirrors src/web/island.ts).
- Subscribes to /events (mood / chat_start·end / idle_*) + polls
/api/island/ping (mood, current_desire, unread) — shared with the Island.
- GUI: ⌂ Room nav tab + #viewRoom iframe, lazily loaded on first open.
Verified end-to-end on a real `lisa serve --web`: route, ping (real soul
desire), SSE, the GUI tab → iframe → /room, full-body render, real-clock dusk,
no console errors. State transitions (sit/sleep/thinking/letter/blink/day-night)
verified via faithful SSE replay. Design + build notes in docs/PLAN_ROOM_v1.0.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
oratis
left a comment
There was a problem hiding this comment.
Review — Lisa Room (/room)
整体质量很高:自包含 ROOM_HTML 完全对齐 island.ts 先例;SSE 走无名 message 事件 + m.type 与服务端 broadcast() 格式一致(含 idle_error);/assets/room/* 走既有静态路由(有 .. 穿越防护,SW cache-first 懒缓存自动覆盖);iframe 懒加载 + .view.active 是 flex column,flex:1 能正常撑满;idleText/desire 均走 textContent,无注入面。只读投影承诺成立——没有触碰 soul/mood/heartbeat 的任何写路径。
发现 8 个可优化点(1 个小 bug + 2 个性能 + 2 个状态映射误判 + 2 个 UX + 1 个清理),全部见行内评论。没有 blocker。
总体:批准方向,建议全部修完再合。
🤖 Generated with Claude Code
| state.dreaming = false; body.classList.remove('dreaming'); applyTOD(); applyPose(); | ||
| state.unread = true; state.idleText = (m.text || '').slice(0, 4000); | ||
| refreshDot(); refreshCaption(); refreshLetter(); | ||
| document.body.animate([{ filter: 'brightness(1.25)' }, { filter: 'brightness(1)' }], { duration: 700 }); |
There was a problem hiding this comment.
Bug(小):idle_message 把 state.dreaming 置回 false,但没有调 refreshDesire()。dreaming 期间 desire ticker 被隐藏(refreshDesire 里 !state.dreaming 条件),梦结束收到信之后 ticker 会一直藏着,最长要等 30s 的下一次 ping 才恢复。idle_start/idle_done 分支都调了,这里漏了。
| var weather = $('weather'); | ||
| function clearAmbient() { while (weather.firstChild) weather.removeChild(weather.firstChild); } | ||
| function refreshWeather() { rebuildAmbient(); } | ||
| function rebuildAmbient() { |
There was a problem hiding this comment.
性能:setMood() → refreshWeather() → rebuildAmbient() 会在每一条 mood SSE 上清空并重建最多 70 个粒子 DOM 节点,即使天气模式完全没变(比如从 happy 切到 excited,两者都无天气)。建议记一个 ambient key(weatherOf(mood) + ':' + tod + ':' + dreaming),key 不变就直接 return,避免无谓的 DOM churn 和动画重启。
| tx = (e.clientX / window.innerWidth - 0.5); | ||
| ty = (e.clientY / window.innerHeight - 0.5); | ||
| }); | ||
| function raf() { |
There was a problem hiding this comment.
性能:视差 rAF 循环无条件 60fps 常驻——标签页隐藏时 rAF 会被浏览器节流但仍在跑,且每帧都写 stage.style.transform(即使指针根本没动)。这个页面的定位是常驻氛围窗,建议:① document.hidden 时跳过写入;② 增量小于阈值时不写 transform;③ 顺带尊重 prefers-reduced-motion(CSS 里已经处理了动画,但视差是 JS 驱动的,漏了)。
| 'sleepy': 'sleepy', 'shy': 'a little shy', 'surprised': 'surprised', 'neutral': 'at home', | ||
| }; | ||
| // Weather-flavored moods → particle mode. | ||
| function weatherOf(slug) { |
There was a problem hiding this comment.
状态映射误判:/rain|storm/ 会命中 outfit 立绘 raincoat——她只是穿了雨衣,房间里就下起雨来了 😄。建议收紧为 /rain(?!coat)|storm/ 或改为显式 slug 集合(in-rain / stormy)。
| // ── Full-body pose. The sprite is pose-based (stand / sit / sleep); facial | ||
| // mood nuance is carried by the caption + lighting, not 114 faces. ───────── | ||
| var lisaWrap = $('lisa-wrap'); | ||
| function poseFor() { |
There was a problem hiding this comment.
状态映射误判:/sleep|nap/ 会命中 emotion 立绘 sleepy——她只是犯困,不该直接切到蜷睡姿势躺下(caption 还显示 'sleepy',状态栏说她困了、画面里人已经睡着,自相矛盾)。建议收紧为 /sleeping|napping/。
| $('reader-close').addEventListener('click', dismissUnread); | ||
|
|
||
| // ── Open the full chat (native bridge if inside a Lisa app window). ────── | ||
| function openChat() { |
There was a problem hiding this comment.
UX:Room 的主要入口是主 GUI 里的 iframe(#roomFrame)。在这个场景下点 "Talk to her" 或点 Lisa 本人会走 window.open('/', '_blank')——弹出第二个完整 GUI 标签页,而用户其实就在 GUI 里,只差切回 chat 视图。建议:嵌在 iframe 里时(window.self !== window.top)向 parent postMessage,由 lisa-client.ts 监听并 showView('chat');native bridge 和独立打开的分支保持不变。
| <div id="offline"> | ||
| <div class="z">☾</div> | ||
| <div>Lisa is asleep</div> | ||
| <div class="chip" id="chip-wake">Wake her — run <code>lisa serve --web</code></div> |
There was a problem hiding this comment.
UX(小):#chip-wake 用了 .chip 样式(cursor:pointer + hover 抬升),看起来可点,但没有绑任何 handler——而且此时服务器已经下线,页面本身也无法代为执行 lisa serve --web。建议去掉交互态(非 chip 的纯文本样式),或者点击时把命令复制到剪贴板。
| a transparent bust; we anchor her bottom near the rug and let her breathe. */ | ||
| /* Lisa — a FULL-BODY animated sprite standing on the rug (not a bust). The | ||
| idle sheet has 2 frames (eyes open | eyes closed) side by side; breathing | ||
| is a CSS transform, blinking flips the background frame. Pose swaps |
There was a problem hiding this comment.
清理:这里留了两段相邻的角色注释,上面这段还是 v1 半身像("transparent bust")时代的旧文案,和紧随其后的 full-body 新注释矛盾,删掉旧的。另外 :root 里的 --stage: min(100vh, 100vw) 定义后全文没有引用(#stage 直接写了 min(100vw,100vh)),是死变量,删掉或真正用上。
|
CI 红灯(review 补充 #9):这个 PR 改了 🤖 Generated with Claude Code |
Review fixes for #214: - idle_message now calls refreshDesire() so the desire ticker reappears as soon as dreaming ends (was hidden up to 30s until the next ping) - rebuildAmbient() keyed on weather:tod:dreaming — mood pulses no longer rebuild ~70 particle nodes when the ambient mode didn't change - parallax rAF skips work while the tab is hidden or the easing has settled, and stays off under prefers-reduced-motion - weatherOf(): 'raincoat' outfit no longer summons indoor rain - poseFor(): 'sleepy' no longer forces the sleep pose (sleeping/napping only) - openChat() inside the GUI's #roomFrame posts room_open_chat to the parent (lisa-client switches to the chat view) instead of opening a duplicate GUI tab - offline "Wake her" chip → non-interactive hint (it had no handler) - dropped the stale bust-era comment + unused --stage var - recomputed MAIN_HTML snapshot constants (nav item + #viewRoom iframe + message listener) — un-reds CI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- package.json 0.14.0 → 0.15.0 (npm version; also syncs the stale 0.13.0 in package-lock.json) - docs/RELEASE_v0.15.0.md — headline: Lisa Room (#214); plus ElevenLabs-first ASR (#174), Cloud Run Anthropic relay (#209), docs (#212, #213) Typecheck green, 915 tests green on this tree. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
What
A new Room — an immersive pixel-art scene where the full-body Lisa character "lives", reachable from the GUI sidebar (⌂ Room) and at
GET /room. Unlike a scripted companion wallpaper (N0va / BSide), every layer is driven by Lisa's real state — a read-only projection of what she's actually doing.Character — full-body animatable spritesheet (not a bust)
The earlier version composited her half-body mood portrait into the room (looked like a floating head). Replaced with a full-body sprite standing on the floor:
lisa-idle.png— 2-frame sheet (eyes open | closed): breathing via CSS transform, blink flips the frame on a JS timer.lisa-sit.png/lisa-sleep.png— pose sprites, swapped by mood/state:working/reading→ sits with a glowing laptop;napping/ Revedreaming→ curled asleep.chongdashu/ai-game-spritesheets) usinggemini-2.5-flash-imageon Vertex, keyed + normalized with PIL.Scene
room-{day,dusk,night}.png— one pixel room, three time-of-day variants (image-to-image kept the furniture identical), crossfaded by the local clock.src/web/island.ts. Blurred backdrop fill, contained square stage foot-anchored to the floor, mood lighting, weather + firefly particles, a "thinking" monitor glow, dreaming dim + floating Z's, and a desk letter that reads her ★ while-you-were-away note.Wiring (no new backend state, no new deps)
GET /roomserves a self-contained page./events(mood/chat_start·end/idle_*) + polls/api/island/ping(mood,current_desire, unread) — shared with the Island.#viewRoomiframe, lazily loaded on first open.Verified end-to-end
On a real
lisa serve --web(isolatedLISA_HOME, spare port):/roomroute,/api/island/ping(real soul desire), SSE/events, the GUI ⌂ Room tab → iframe →/room, full-body render, real-clock dusk, zero console errors. State transitions (sit / sleep / thinking / letter / blink / day-night) verified via faithful SSE replay.Design + build notes:
docs/PLAN_ROOM_v1.0.md.Files
src/web/room.ts,src/web/assets/room/(3 room bgs + 3 character sprites),docs/PLAN_ROOM_v1.0.mdsrc/web/server.ts(route),src/web/lisa-html.ts+src/web/lisa-client.ts(nav tab + iframe)Not in this PR / next
Manual outfit + room-theme switching ·
current_desireas an in-room prop · you→her desk note · standalone wallpaper window (Swift, like the Island) · more emotion poses · aligning the sprite pipeline with Luddi's actual Seedance frame flow.🤖 Generated with Claude Code