Files
oikos/web/src/lib/mascot/Mascot.svelte
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

330 lines
13 KiB
Svelte

<script lang="ts">
// The desktop mascot sprite: a small canvas that renders the current
// animation frame at ~30fps (via setTimeout, not rAF — matches
// GraphBackground.svelte's convention for hidden-tab embedding safety),
// and handles pointer drag, plain-click (pet), and right-click (open
// the radial menu). Position/physics live in MascotRuntime, owned
// here; long-lived tamagotchi state lives in state.svelte.ts.
//
// The mascot renders above the window layer (z-45 via MascotLayer) but
// its pointer hitbox is exactly the canvas element — no oversized
// invisible padding — so it only occludes clicks on window chrome
// directly beneath the sprite, per the "renders above windows" design
// decision in plans/2026-07-20-desktop-mascot.md.
import { onMount } from 'svelte'
import { loadSprites, loadBubble, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
import { drawFrame, drawBubble, CANVAS_W, CANVAS_H } from '$lib/mascot/render'
import {
stepMascot,
forceBehavior,
releaseFromDrag,
reground
} from '$lib/mascot/behavior'
import type { MascotRuntime, MascotStage, AnimName } from '$lib/mascot/types'
import {
initMascotState,
getModel,
tickLifecycle,
advanceStageIfReady,
setLastPos,
pet as modelPet
} from '$lib/mascot/state.svelte'
import { wmState } from '$lib/stores/windows'
// MascotRuntime is created fresh per mount; the long-lived MascotModel
// (with lastPos, stage, name, ...) persists across mounts via localStorage.
let { runtime, onContextMenu, onPet }: { runtime: MascotRuntime; onContextMenu: (screenX: number, screenY: number) => void; onPet: () => void } = $props()
const SCALE_PX = 3 // CSS scale: 20 logical px * 3 = 60px sprite
const DRAG_THRESHOLD = 5
// 60fps for the sprite loop: the mascot has faster motion (drag, fall)
// than GraphBackground's slow ambient drift, and 30fps position updates
// look choppy on 60Hz+ displays. setTimeout (not rAF) per the repo
// convention — some embedding contexts report document.hidden=true and
// suspend rAF; setTimeout keeps ticking. dt is clamped below so a
// throttled/backgrounded tab doesn't produce a physics-breaking huge
// step on resume.
const LOOP_MS = 16
const DT_CLAMP_MS = 100
const LIFECYCLE_TICK_MS = 1000
let canvas = $state<HTMLCanvasElement | null>(null)
let ctx2d: CanvasRenderingContext2D | null = null
let timer: ReturnType<typeof setTimeout> | 0 = 0
let lastFrame = 0
let lastLifecycle = 0
let dragging = $state(false)
let dragPointerId: number | null = null
let dragStartClient = { x: 0, y: 0 }
let moved = false
// Wiggle phase for the egg (render-time only, not persisted).
let wigglePhase = 0
// Snapshot of the current window manager state, refreshed by
// subscription. Read inside the tick to compute the ground line at the
// mascot's x — the highest non-minimized window top edge beneath it,
// or the surface bottom when no window is beneath.
let currentWindows: typeof $wmState = { order: [], windows: {}, focusedId: null }
// Previous ground y, used to detect when the window beneath the
// mascot moved so the mascot can ride along (stick to the ground)
// instead of floating in place while the window drifts out from
// under it.
let prevGroundY = 0
const model = $derived(getModel())
function stage(): MascotStage {
return model.stage
}
/**
* Compute the ground line at the mascot's x: the top edge (y) of the
* highest non-minimized window whose horizontal span covers the
* mascot's x, or the surface bottom (bounds.h) when no window is
* beneath. This is what lets the mascot walk ON TOP of windows —
* when it strolls over a window, the ground rises to that window's
* top edge; when it walks off the side, the ground drops back to the
* desktop surface and it flutters-falls.
*/
function computeGroundAt(x: number): number {
let ground = runtime.bounds.h
for (const id of currentWindows.order) {
const win = currentWindows.windows[id]
if (!win || win.stage === 'minimized') continue
const b = win.bounds
// Window top edge counts as ground only if the mascot's x is
// within the window's horizontal span (with a small margin so the
// mascot doesn't immediately fall off the very corner).
if (x >= b.x - 4 && x <= b.x + b.width + 4) {
// Walking on top = ground at the window's top edge.
if (b.y < ground) ground = b.y
}
}
return ground
}
function currentAnim(): { anim: ReturnType<typeof resolveAnim>; name: AnimName } {
const name = runtime.anim
return { anim: resolveAnim(stage(), name), name }
}
function tick(now: number): void {
const dt = Math.min(DT_CLAMP_MS, now - lastFrame)
lastFrame = now
// Refresh the ground line at the mascot's current x — this is what
// lets the mascot walk on top of windows (the ground rises to a
// window's top edge when the mascot strolls over it).
const newGround = computeGroundAt(runtime.x)
// Ride the ground: when the mascot is grounded (not falling/dragged)
// and the ground moved (the window beneath was dragged/resized),
// translate the mascot with it so it sticks to the surface instead
// of floating in place while the window drifts out from under it.
if (
runtime.behavior !== 'falling' &&
runtime.behavior !== 'dragged' &&
runtime.y >= prevGroundY - 1 &&
newGround !== prevGroundY
) {
runtime.y += newGround - prevGroundY
}
runtime.groundY = newGround
prevGroundY = newGround
stepMascot(runtime, model, now, dt)
// Lifecycle (egg incubation, happiness decay) ticks ~1x/sec, not per frame.
if (lastLifecycle === 0) lastLifecycle = now
if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
tickLifecycle(now - lastLifecycle)
lastLifecycle = now
advanceStageIfReady()
// If the egg just hatched, swap the runtime behavior out of 'egg'
// (the egg behavior's next() returns 'egg' forever, so we have to
// nudge it here). The NameDialog is opened by MascotLayer observing
// the stage change.
if (model.stage !== 'egg' && runtime.behavior === 'egg') {
forceBehavior(runtime, 'idle')
}
}
}
function renderSprite(): void {
if (!canvas || !ctx2d) return
const now = performance.now()
// Egg wobble: only when stage is egg and behavior isn't dragged/react.
const isEgg = stage() === 'egg'
if (isEgg && runtime.behavior !== 'dragged') {
wigglePhase += 0.08
} else {
wigglePhase = 0
}
const { anim } = currentAnim()
const idx = frameIndex(anim, now, runtime.animStart)
const scale = STAGE_SCALE[stage()]
drawFrame(ctx2d, stage(), anim, idx, {
scale,
facing: runtime.facing,
wiggle: wigglePhase
})
// Reaction bubble: drawn above the sprite while runtime.bubble is set
// and hasn't expired. Cleared when bubbleUntil passes.
if (runtime.bubble && now < runtime.bubbleUntil) {
drawBubble(ctx2d, runtime.bubble, runtime.facing)
} else if (runtime.bubble && now >= runtime.bubbleUntil) {
runtime.bubble = null
runtime.bubbleUntil = 0
}
}
// Single loop: tick physics + draw sprite + reschedule. (The previous
// version had `loop` reschedule itself AND call `draw` which also
// rescheduled itself — two timers fought over the shared `timer` var,
// causing jitter.)
function loop(): void {
timer = setTimeout(loop, LOOP_MS)
const now = performance.now()
tick(now)
renderSprite()
}
function onPointerDown(e: PointerEvent) {
if (e.button !== 0) return
const el = e.currentTarget as HTMLElement
dragPointerId = e.pointerId
dragStartClient = { x: e.clientX, y: e.clientY }
moved = false
el.setPointerCapture(e.pointerId)
forceBehavior(runtime, 'dragged')
dragging = true
}
function onPointerMove(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
const dx = e.clientX - dragStartClient.x
const dy = e.clientY - dragStartClient.y
if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) moved = true
if (moved) {
// Surface-relative coords: the sprite container is positioned at the
// surface origin, so clientX/Y - surfaceRect gives surface coords.
// MascotLayer binds the host's bounding rect; we read it fresh here.
const host = (e.currentTarget as HTMLElement).parentElement?.parentElement
const rect = host?.getBoundingClientRect()
if (rect) {
runtime.x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
runtime.y = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
}
}
}
function onPointerUp(e: PointerEvent) {
if (dragPointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
el.releasePointerCapture(e.pointerId)
dragPointerId = null
dragging = false
if (!moved) {
// Plain click = pet: a brief happy reaction with a heart bubble.
modelPet()
onPet()
runtime.bubble = '/mascot/bubble-love.png'
runtime.bubbleUntil = performance.now() + 1500
forceBehavior(runtime, 'react', { anim: 'react-happy', durationMs: 1500 })
} else {
// Drag ended — release into falling or land.
setLastPos(runtime.x)
releaseFromDrag(runtime)
}
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
onContextMenu(e.clientX, e.clientY)
}
function syncCanvasSize() {
if (!canvas) return
canvas.width = CANVAS_W
canvas.height = CANVAS_H
ctx2d = canvas.getContext('2d')
if (ctx2d) ctx2d.imageSmoothingEnabled = false
}
onMount(async () => {
initMascotState()
syncCanvasSize()
await loadSprites()
// Preload reaction bubbles (not in SPRITES registry, so loadSprites
// doesn't pick them up). Swallow errors — a missing bubble just
// doesn't render, the reaction anim still plays.
await Promise.allSettled([
loadBubble('/mascot/bubble-love.png'),
loadBubble('/mascot/bubble-exclaim.png'),
loadBubble('/mascot/bubble-red-exclaim.png'),
loadBubble('/mascot/bubble-dotdotdot.png')
])
lastFrame = performance.now()
lastLifecycle = 0
// Seed prevGroundY so the first tick's ride-the-ground delta is zero
// (otherwise the mascot would snap to the ground on mount if it
// started above it — e.g. an egg at the surface bottom).
runtime.groundY = computeGroundAt(runtime.x)
prevGroundY = runtime.groundY
loop()
// Re-ground on surface resize (viewport resize, taskbar height changes).
const host = canvas?.parentElement?.parentElement
let prevH = runtime.bounds.h
const ro = new ResizeObserver(() => {
const rect = host?.getBoundingClientRect()
if (rect) {
const newH = rect.height
runtime.bounds = { w: rect.width, h: newH }
reground(runtime, prevH)
prevH = newH
}
})
if (host) ro.observe(host)
// Track the window manager's state so computeGroundAt() can find the
// highest window beneath the mascot's x each tick — this is what lets
// the mascot walk on top of windows rather than always falling to the
// desktop surface bottom.
const unsubWm = wmState.subscribe((s) => {
currentWindows = s
})
return () => {
if (timer) clearTimeout(timer)
ro.disconnect()
unsubWm()
}
})
// The sprite's CSS position uses bottom-left anchored coords from
// MascotRuntime: x = sprite bottom-center, y = sprite bottom. We
// position via `transform: translate3d` (compositor-friendly, no
// layout reflow) rather than `left`/`top` so motion stays smooth at
// high refresh rates. A squash transform is composed in during the
// `land` behavior (brief scaleY(0.82)) to sell the impact.
const tx = $derived(runtime.x - (CANVAS_W * SCALE_PX) / 2)
const ty = $derived(runtime.y - CANVAS_H * SCALE_PX)
const squash = $derived(runtime.behavior === 'land' ? ' scaleY(0.82)' : '')
const transform = $derived(`translate3d(${tx}px, ${ty}px, 0)${squash}`)
</script>
<canvas
bind:this={canvas}
class="pointer-events-auto absolute left-0 top-0 select-none {dragging ? 'cursor-grabbing' : 'cursor-grab'}"
style="width: {CANVAS_W * SCALE_PX}px; height: {CANVAS_H * SCALE_PX}px; transform: {transform}; image-rendering: pixelated; will-change: transform; transform-origin: bottom center;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
oncontextmenu={handleContextMenu}
title={model.name ?? 'Cluck'}
></canvas>
{#if model.name}
<div
class="pointer-events-none absolute left-0 top-0 select-none whitespace-nowrap rounded-full bg-popover/90 px-2 py-0.5 text-[11px] font-medium text-popover-foreground shadow-sm ring-1 ring-foreground/10"
style="transform: translate3d({tx + (CANVAS_W * SCALE_PX) / 2}px, {ty - 8}px, 0); will-change: transform;"
>
{model.name}
</div>
{/if}