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.
330 lines
13 KiB
Svelte
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}
|