Implements plans/2026-07-20-desktop-mascot.md. New code under web/src/lib/mascot/ (types/sprites/render/state/behavior/actions/ stimuli + Mascot/MascotLayer/RadialMenu/NameDialog components) plus CC0 sprite sheets at web/public/mascot/ (chicken + Onocentaur egg pack + reaction bubbles). MascotLayer is inserted into Desktop.svelte after WindowLayer; <2-line integration. Tamagotchi: egg -> chick -> adult lifecycle persisted to localStorage['oikos-mascot'] (debounced 300ms). Egg hatches on first naming (no timed incubation per implementation deviation). Chick/adult wander, peck, sleep, blink autonomously via a weighted-random FSM; the chicken walks above windows (ground line = highest window top edge beneath its x, recomputed each tick from wmState; rides the ground when the window beneath is dragged). Interaction: draggable with flutter-fall physics on release mid-air; plain click = pet (heart bubble + happy anim); right-click opens a rounded-button radial menu (Interact/Care/Identity/Debug nested groups) mirroring the desktop's own right-click menu styling; auto-flips above/ left near screen edges. Awareness: stimulus bus subscribes to chat.ts streaming, activity.ts activityLog (knowledge-entry diff), events.ts liveEvents (critical/ signal -> alarmed, execution -> happy), with priority+cooldown gating. Egg-stage reactions are suppressed. Reaction bubbles are anti-aliased. Sprite loop runs at ~60fps via setTimeout (not rAF) per GraphBackground convention, dt clamped to 100ms; position via transform: translate3d + will-change: transform for compositor-friendly motion. Z-index ordering: WindowLayer z-40 < MascotLayer z-[45] < desktop context menu z-50 < RadialMenu/NameDialog z-[60]. Docs: plan + docs/mascot/README.md (MBSE subsystem model) updated to Implemented with a deviations note covering hatch-on-naming, PNG-sheet art, button-column radial menu, 60fps loop, egg-reaction suppression, and window-walking ground model. VERSION bumped 0.7.13 -> 0.8.0.
88 lines
3.0 KiB
TypeScript
88 lines
3.0 KiB
TypeScript
// Stateless canvas painter for the mascot. Single render path: slice a
|
|
// 16x16 frame from a PNG sheet and draw it bottom-anchored, horizontally
|
|
// centered, optionally flipped (for left-facing) and optionally scaled
|
|
// (chick is smaller). Egg-stage sheets are also 16x16 PNGs (from the
|
|
// Onocentaur egg pack), so no special-case vector path is needed.
|
|
//
|
|
// The renderer is generic over the SPRITES registry — adding a new
|
|
// sheet to sprites.ts requires no change here.
|
|
|
|
import type { AnimDef, MascotStage } from './types'
|
|
import { getImage } from './sprites'
|
|
|
|
/**
|
|
* Logical canvas size (CSS px) the mascot is drawn onto. The sprite's
|
|
* feet land on the bottom row. The canvas is taller than wide (20x28)
|
|
* so there's room above the sprite for the reaction bubble — the sprite
|
|
* bottom-anchors at y=CANVAS_H, and the bubble renders in the top
|
|
* ~8 logical px without being clipped.
|
|
*/
|
|
export const CANVAS_W = 20
|
|
export const CANVAS_H = 28
|
|
/** Source frame size for the bundled sheets (px). */
|
|
const FRAME = 16
|
|
|
|
export interface DrawOpts {
|
|
/** Render scale; usually STAGE_SCALE[stage]. */
|
|
scale: number
|
|
/** Horizontal facing — when -1, draw the sheet mirrored. */
|
|
facing: 1 | -1
|
|
/** Wiggle phase (radians) for the egg wobble; ignored for chicken stages. 0 disables. */
|
|
wiggle: number
|
|
}
|
|
|
|
/** Draw one animation frame into the given 2D context (which is already sized CANVAS_W x CANVAS_H in CSS px). */
|
|
export function drawFrame(
|
|
ctx: CanvasRenderingContext2D,
|
|
_stage: MascotStage,
|
|
anim: AnimDef,
|
|
frameIdx: number,
|
|
opts: DrawOpts
|
|
): void {
|
|
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H)
|
|
const img = anim.src ? getImage(anim.src) : null
|
|
if (!img) return // not yet loaded — skip; the loop picks it up next tick
|
|
const idx = Math.max(0, Math.min(frameIdx, anim.frames - 1))
|
|
const sx = idx * FRAME
|
|
const scale = opts.scale
|
|
const drawW = FRAME * scale
|
|
const drawH = FRAME * scale
|
|
// Bottom-anchor the 16x16 frame in the 20x20 canvas, then scale.
|
|
const dx = (CANVAS_W - drawW) / 2 + Math.sin(opts.wiggle) * 1.2
|
|
const dy = CANVAS_H - drawH
|
|
ctx.save()
|
|
if (opts.facing === -1) {
|
|
ctx.translate(CANVAS_W, 0)
|
|
ctx.scale(-1, 1)
|
|
}
|
|
ctx.imageSmoothingEnabled = false
|
|
ctx.drawImage(img, sx, 0, FRAME, FRAME, dx, dy, drawW, drawH)
|
|
ctx.restore()
|
|
}
|
|
|
|
/** Draw a reaction bubble above the sprite (call after drawFrame, same context). */
|
|
export function drawBubble(
|
|
ctx: CanvasRenderingContext2D,
|
|
bubbleSrc: string,
|
|
facing: 1 | -1
|
|
): void {
|
|
const img = getImage(bubbleSrc)
|
|
if (!img) return
|
|
// Bubbles are 16x16 single-frame PNGs drawn in the top portion of the
|
|
// 20x28 canvas (above the sprite's head). imageSmoothingEnabled is
|
|
// TRUE here — bubbles are crisp UI, not pixel-art, so nearest-neighbor
|
|
// downscale looks blocky.
|
|
const drawW = 14
|
|
const drawH = 14
|
|
const dx = (CANVAS_W - drawW) / 2
|
|
const dy = 0
|
|
ctx.save()
|
|
if (facing === -1) {
|
|
ctx.translate(CANVAS_W, 0)
|
|
ctx.scale(-1, 1)
|
|
}
|
|
ctx.imageSmoothingEnabled = true
|
|
ctx.drawImage(img, 0, 0, img.width, img.height, dx, dy, drawW, drawH)
|
|
ctx.restore()
|
|
}
|