Files
oikos/web/src/lib/mascot/render.ts
dtoro 7b1dfbc8aa
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(web): desktop mascot ("Cluck") — egg/chick/adult tamagotchi that roams the desktop, reacts to chat/events, walks on top of windows
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.
2026-07-20 14:27:48 +02:00

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()
}