mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
The main view says nothing about what the model is doing. A turn can run, or fail to run, and the stage looks the same either way. This draws a comic-style bubble beside the character's head: three dots while a chat turn is in flight, and a panel with a count when there are unread messages. ## Visual changes | Before | After | |---|---| |  |  | | Stage / Live2D (Pixi) | Stage / Live2D (Pixi) | |  |  | | Stage / VRM (Three) | Stage / VRM (Three) | Both captured through Settings → Developer → Presence Bubble, which drives the state directly so the shot does not depend on a live model turn. ## It is drawn in the canvas A DOM overlay follows `requestAnimationFrame`. The Live2D canvas repaints at whatever `settings/live2d/max-fps` allows. Under a capped frame rate the two run at different rates and the bubble slides off the head while the character moves. So the bubble is painted into the same canvas as the character, by the same ticker. `packages/stage-shared/src/presence-bubble/` owns everything that is not a renderer: - `painter.ts` traces the panel and its tail as **one** outline, parameterised by arc length around the rounded rectangle. The tail is a stretch of the boundary, not a second shape welded on, so it cannot come apart from the panel at any angle. - `placement.ts` splits the choice of side from the position. The side is chosen from the settled box with hysteresis, so a bubble near a boundary does not flip back and forth; the position is then resolved against the measured box each frame. - `follow.ts` is a critically damped spring, which gives the inertia the character drags the bubble with. - `content.ts` steps the dots through a fixed number of phases, so they read as limited animation rather than a smooth fade, and drops to a single frame under `prefers-reduced-motion`. - `clock.ts` hands out the frame delta once, so a resize landing between two ticks does not advance the spring twice over the same stretch of time. Two adapters consume it: a Pixi `Sprite` for Live2D, a Three `Sprite` for VRM. Both run the same placement and the same painter. ## The head is asked for, not guessed Both formats state where the head is, so nothing here is measured against the sample model in front of me: - **VRM** — `humanoid.getNormalizedBoneNode('head')`. The head bone is required by the format, so this holds for every VRM. - **Live2D** — the head hit area first. When a model declares none, the tracker turns the `ParamAngleX/Y/Z` parameters to their far end with physics running and takes the drawables that moved. Across the seven official Cubism SDK samples, three declare a head hit area and six answer the parameter probe; Mark answers the probe alone. A model that states neither gets no bubble, rather than a bubble over its chest. ## What is not here - The spoken TTS line. Deferred by request — it comes after this. - A real unread source. The count is wired to the developer control only; the `presenceBubbleIdle` / `presenceBubbleThinking` states and the store are in place for a producer to fill. - Sound. Deferred. ## How tested ```bash pnpm -F @proj-airi/stage-shared exec vitest run src/presence-bubble pnpm -F @proj-airi/stage-ui-live2d exec vitest run src/composables/live2d/head-anchor.test.ts pnpm -F @proj-airi/stage-ui exec vitest run src/stores/presence-bubble.browser.test.ts pnpm typecheck pnpm lint ``` The head-anchor tests drive the real tracker through the narrow `Live2DHeadSource` contract with a fake rig, with no mocks or casts. The store test covers cross-window replication and asserts a remote snapshot produces no proposal back. Screenshots captured with `vishot capture --target electron` against the merge base and this branch, same scenario, same viewport, same two preset models. ## Left open - Parameter coverage across community models is unknown beyond the seven official samples. A model with no hit area and no angle parameters shows no bubble. - Tachie has no bubble; only Live2D and VRM are wired. - stage-web and stage-pocket are untested. The shared code is renderer-agnostic but neither surface mounts the adapters yet. ## ADR `docs/ai/adr/2026-09-24-presence-bubble-in-canvas.md` 🤖 Generated with [Claude Code](https://claude.com/claude-code)