feat(web): desktop mascot ("Cluck") — egg/chick/adult tamagotchi that roams the desktop, reacts to chat/events, walks on top of windows
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

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.
This commit is contained in:
2026-07-20 14:27:48 +02:00
parent f1cdf4ea13
commit 7b1dfbc8aa
39 changed files with 2213 additions and 37 deletions

View File

@@ -14,6 +14,7 @@
import TaskLauncher from './TaskLauncher.svelte'
import WindowLayer from './WindowLayer.svelte'
import Taskbar from './Taskbar.svelte'
import MascotLayer from '$lib/mascot/MascotLayer.svelte'
import LayersIcon from '@lucide/svelte/icons/layers'
import Rows3Icon from '@lucide/svelte/icons/rows-3'
import MonitorIcon from '@lucide/svelte/icons/monitor'
@@ -100,6 +101,8 @@
</div>
<WindowLayer />
<MascotLayer />
</div>
<Taskbar />

View File

@@ -0,0 +1,329 @@
<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}

View File

@@ -0,0 +1,204 @@
<script lang="ts">
// MascotLayer: a pointer-events-none absolute inset-0 overlay at z-45
// (above WindowLayer's z-40, below the desktop context menu's z-50
// and the radial menu's z-[60]). Hosts the Mascot sprite, the
// RadialMenu, the NameDialog, and the speech/name bubble. Owns the
// MascotRuntime and the surface bounds; attaches the stimulus bus on
// mount so the mascot reacts to chat/activity/events.
//
// Insertion point: rendered inside Desktop.svelte's surface <div>
// (the `relative min-h-0 flex-1 overflow-hidden` element), after
// <WindowLayer />, so its `absolute inset-0` shares the surface's
// coordinate space and its ground line lands at the surface's bottom
// edge (= the taskbar's top edge).
import { onMount } from 'svelte'
import Mascot from './Mascot.svelte'
import RadialMenu from './RadialMenu.svelte'
import NameDialog from './NameDialog.svelte'
import { attachStimuli } from './stimuli'
import {
initMascotState,
getModel,
setName as modelSetName,
forceHatch as modelForceHatch,
setStage,
resetModel
} from '$lib/mascot/state.svelte'
import { forceBehavior } from '$lib/mascot/behavior'
import type { MascotRuntime, AnimName, BehaviorId, MascotStage } from '$lib/mascot/types'
let host = $state<HTMLDivElement | null>(null)
// The runtime is created here (fresh per mount) and seeded from the
// persisted model's lastPos + the live surface bounds. It's a `$state`
// so mutations to runtime.x/y/behavior/etc. from the FSM are tracked
// by the `$derived` position expressions in Mascot.svelte — a plain
// `let` would update internally but never re-render the canvas.
let runtime: MascotRuntime = $state({
x: 100,
y: 100,
vx: 0,
vy: 0,
facing: 1,
behavior: 'egg',
behaviorUntil: Number.POSITIVE_INFINITY,
anim: 'egg-idle',
animStart: 0,
reactAnim: null,
bounds: { w: 800, h: 600 },
groundY: 600,
bubble: null,
bubbleUntil: 0,
blinkUntil: 0
})
let menuPos = $state<{ x: number; y: number } | null>(null)
let nameDialogOpen = $state(false)
let nameDialogMode = $state<'hatch' | 'rename'>('hatch')
const model = $derived(getModel())
function openMenu(x: number, y: number) {
menuPos = { x, y }
}
function closeMenu() {
menuPos = null
}
function requestRename() {
nameDialogMode = 'rename'
nameDialogOpen = true
}
function forceHatch() {
const wasUnnamed = getModel().name === null
modelForceHatch()
if (runtime.behavior === 'egg') forceBehavior(runtime, 'idle')
// If the egg was unnamed (e.g. debug force-hatch before the name
// dialog was submitted), open the name dialog so the chick gets a
// name — matching the normal hatch-on-naming flow.
if (wasUnnamed) {
nameDialogMode = 'hatch'
nameDialogOpen = true
}
}
function forceStageFn(s: MascotStage) {
setStage(s)
forceBehavior(runtime, 'idle')
}
function reset() {
resetModel()
forceBehavior(runtime, 'egg')
}
function refresh() {
// Trigger reactivity: the menu reads model visibility predicates on
// each render; touching a $state value re-runs the menu's derived
// filter. menuPos re-assignment is a no-op if already set.
menuPos = menuPos ? { ...menuPos } : null
}
function onPet() {
// Plain-click pet: briefly show a happy bubble. The actual model.pet()
// call already happened in Mascot.svelte.
runtime.bubble = '/mascot/bubble-love.png'
runtime.bubbleUntil = performance.now() + 1500
}
function onNameSubmit(name: string) {
modelSetName(name)
if (nameDialogMode === 'hatch') {
// Naming the egg is what hatches it — no timed incubation.
forceHatch()
}
nameDialogOpen = false
}
// Stage transitions are driven by the name-dialog submit handler
// (egg → chick on first naming) and the debug menu (force hatch /
// force stage), not by observing model.stage here. No $effect needed.
onMount(() => {
initMascotState()
// Seed runtime from persisted lastPos once we know the surface size.
const rect = host?.getBoundingClientRect()
if (rect) {
runtime.bounds = { w: rect.width, h: rect.height }
runtime.groundY = rect.height
const lp = getModel().lastPos
runtime.x = lp ? Math.max(24, Math.min(rect.width - 24, lp.x)) : rect.width / 2
runtime.y = rect.height // ground
runtime.anim = getModel().stage === 'egg' ? 'egg-idle' : 'idle'
runtime.behavior = getModel().stage === 'egg' ? 'egg' : 'idle'
}
// First-run: a fresh egg with no name prompts for naming, which
// hatches it. Returning users with a named mascot skip this.
if (getModel().stage === 'egg' && getModel().name === null) {
nameDialogMode = 'hatch'
nameDialogOpen = true
}
// Attach the stimulus bus (chat/activity/events -> reactions).
// Reactions are gated to non-egg stages: the egg isn't "alive" yet
// (no name, no hatched chick to react), so stimulus events are
// silently dropped until the egg hatches. This keeps the egg calm
// during the naming dialog rather than playing alarm animations
// behind it.
const detach = attachStimuli((reaction) => {
if (getModel().stage === 'egg') return
// Reaction dispatch: respect priority + cooldown (handled in stimuli.ts);
// here we just force the behavior.
const anim = reaction.anim as AnimName
const id: BehaviorId = 'react'
runtime.reactAnim = anim
forceBehavior(runtime, id, { anim, durationMs: reaction.durationMs })
if (reaction.bubble) {
runtime.bubble = reaction.bubble
runtime.bubbleUntil = performance.now() + reaction.durationMs
}
if (reaction.effect) reaction.effect()
})
return () => detach()
})
// Action context handed to RadialMenu leaf handlers.
const actionCtx = $derived({
model,
runtime,
force: (id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }) =>
forceBehavior(runtime, id, opts),
requestRename,
forceHatch,
forceStage: forceStageFn,
reset,
refresh
})
</script>
<div bind:this={host} class="pointer-events-none absolute inset-0 z-[45]">
<Mascot
{runtime}
onContextMenu={openMenu}
onPet={onPet}
/>
</div>
{#if menuPos}
<RadialMenu
pos={menuPos}
ctx={actionCtx}
onDismiss={closeMenu}
/>
{/if}
{#if nameDialogOpen}
<NameDialog
mode={nameDialogMode}
initial={model.name ?? ''}
onSubmit={onNameSubmit}
onCancel={() => (nameDialogOpen = false)}
/>
{/if}

View File

@@ -0,0 +1,98 @@
<script lang="ts">
// NameDialog: a tiny centered modal that prompts for the mascot's name,
// opened either at hatch time (mode='hatch') or via the Rename menu
// action (mode='rename'). Renders at z-[60] so it sits above the
// mascot layer and the radial menu. Self-contained — doesn't use the
// bits-ui Dialog to keep the dependency surface small and to control
// z-index precisely relative to the desktop's own layers.
import { onMount, untrack } from 'svelte'
let {
mode = 'hatch',
initial = '',
onSubmit,
onCancel
}: {
mode?: 'hatch' | 'rename'
initial?: string
onSubmit: (name: string) => void
onCancel: () => void
} = $props()
// Seed the input once from the prop — `untrack` because we want the
// INITIAL value, not a reactive binding (typing into the input updates
// `value`, not `initial`).
let value = $state(untrack(() => initial))
onMount(() => {
// Autofocus the input on mount.
const el = document.getElementById('mascot-name-input') as HTMLInputElement | null
el?.focus()
el?.select()
})
function submit(e: Event) {
e.preventDefault()
const trimmed = value.trim()
if (trimmed) onSubmit(trimmed)
}
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
}
const title = $derived(mode === 'hatch' ? 'Your chick hatched!' : 'Rename your chicken')
const placeholder = $derived(mode === 'hatch' ? 'Name your chick…' : 'New name…')
</script>
<svelte:window onkeydown={onWindowKeydown} />
<!-- Overlay: clicks outside the card cancel. Escape is handled via the window keydown above. -->
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-foreground/30 backdrop-blur-[1px]"
role="presentation"
onclick={(e) => {
if (e.currentTarget === e.target) onCancel()
}}
>
<form
class="w-80 rounded-xl border bg-popover p-5 text-popover-foreground shadow-xl ring-1 ring-foreground/10"
onsubmit={submit}
>
<h2 class="mb-1 text-base font-semibold">{title}</h2>
<p class="mb-3 text-xs text-popover-foreground/70">
{mode === 'hatch'
? 'Give it a name. It will follow your homelab activity from here on.'
: 'Pick a new name.'}
</p>
<input
id="mascot-name-input"
type="text"
bind:value
{placeholder}
maxlength="24"
class="w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<div class="mt-4 flex justify-end gap-2">
{#if mode === 'rename'}
<button
type="button"
class="rounded-md px-3 py-1.5 text-sm text-popover-foreground/80 hover:bg-accent"
onclick={onCancel}
>
Cancel
</button>
{/if}
<button
type="submit"
class="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{mode === 'hatch' ? 'Hatch!' : 'Save'}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,152 @@
<script lang="ts" module>
// RadialMenu (now a rounded-button column menu): opened on right-click
// over the mascot. Renders the MASCOT_ACTIONS tree as a stack of
// rounded buttons with full text labels; selecting a node with
// `children` swaps the column to those children + a "Back" button at
// the top (tracked via a local breadcrumb stack). Leaf nodes call
// `action(ctx)` and dismiss.
//
// z-[60] — must beat the desktop's own right-click menu (z-50) and
// sit above the mascot layer (z-[45]). Dismissal mirrors the desktop
// menu: a <svelte:window onclick> closes it, Escape pops one level
// then closes on the next press, and the menu's own clicks
// stopPropagation so they don't bubble to the close handler.
import { MASCOT_ACTIONS } from './actions'
import type { MascotActionCtx, RadialAction } from './types'
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
</script>
<script lang="ts">
let {
pos,
ctx,
onDismiss
}: {
pos: { x: number; y: number }
ctx: MascotActionCtx
onDismiss: () => void
} = $props()
// Breadcrumb stack: each entry is the list of actions shown at that
// level. The top of the stack is the current column.
let stack = $state<RadialAction[][]>([MASCOT_ACTIONS])
let depth = $derived(stack.length)
let current = $derived(stack[depth - 1] ?? [])
// Clamp the anchor to the viewport, then decide which side to grow
// toward based on anchor position alone (no measure-then-flip — that
// paints off-screen first). When the anchor is in the bottom half,
// the menu grows upward (bottom edge aligns with anchor.y); same for
// the right edge when in the right half. The chicken lives on the
// desktop surface's bottom edge, so this almost always flips up.
const anchor = $derived.by(() => {
const margin = 8
const vw = typeof window !== 'undefined' ? window.innerWidth : 1024
const vh = typeof window !== 'undefined' ? window.innerHeight : 768
const x = Math.max(margin, Math.min(vw - margin, pos.x))
const y = Math.max(margin, Math.min(vh - margin, pos.y))
return {
x,
y,
growUp: y > vh / 2,
growLeft: x > vw / 2
}
})
let host = $state<HTMLDivElement | null>(null)
const visibleItems = $derived(current.filter((a) => !a.visible || a.visible(ctx.model)))
function selectItem(a: RadialAction, ev: MouseEvent) {
ev.stopPropagation()
if (a.children && a.children.length > 0) {
stack = [...stack, a.children]
return
}
if (a.action) {
a.action(ctx)
}
onDismiss()
}
function back(ev: MouseEvent) {
ev.stopPropagation()
if (stack.length > 1) {
stack = stack.slice(0, -1)
} else {
onDismiss()
}
}
function onWindowClick() {
onDismiss()
}
function onWindowKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
if (stack.length > 1) {
stack = stack.slice(0, -1)
} else {
onDismiss()
}
}
}
// Reset the stack when the menu is (re)opened with a new pos.
$effect(() => {
void pos
stack = [MASCOT_ACTIONS]
})
</script>
<svelte:window onclick={onWindowClick} onkeydown={onWindowKeydown} />
<div
bind:this={host}
class="fixed z-[60] min-w-44 max-w-56 rounded-xl border bg-popover p-1.5 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
style="left: {anchor.growLeft ? 'auto' : `${anchor.x}px`}; right: {anchor.growLeft ? `${window.innerWidth - anchor.x}px` : 'auto'}; top: {anchor.growUp ? 'auto' : `${anchor.y}px`}; bottom: {anchor.growUp ? `${window.innerHeight - anchor.y}px` : 'auto'};"
role="menu"
tabindex="-1"
aria-label="Mascot actions"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => {
if (e.key === 'Escape') {
e.preventDefault()
if (stack.length > 1) stack = stack.slice(0, -1)
else onDismiss()
}
}}
>
{#if depth > 1}
<button
type="button"
class="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-popover-foreground/70 hover:bg-accent hover:text-accent-foreground"
onclick={back}
>
<ChevronLeftIcon class="size-4" /> Back
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
{#each visibleItems as a (a.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent hover:text-accent-foreground"
onclick={(ev) => selectItem(a, ev)}
oncontextmenu={(ev) => ev.preventDefault()}
>
<span class="flex items-center gap-2">
{#if a.icon}
<a.icon class="size-4 shrink-0" />
{/if}
<span class="truncate">{a.label}</span>
</span>
{#if a.children && a.children.length > 0}
<ChevronRightIcon class="size-4 shrink-0 opacity-60" />
{/if}
</button>
{/each}
</div>

View File

@@ -0,0 +1,163 @@
// Radial menu action tree. To add a new menu action:
// - Add a `RadialAction` node to MASCOT_ACTIONS below (or call
// `registerMascotAction(a, parentId)` at runtime to insert under an
// existing node).
// - The leaf `action(ctx)` mutates the model/runtime via ctx; nested
// `children` render as a sub-ring.
// - `visible(model)` gates visibility (e.g. "Rename" only once hatched).
// RadialMenu.svelte renders whatever tree it's given, including
// arbitrary nesting depth — no engine change is needed for a new node.
import type { RadialAction } from './types'
// v1 tree: Interact [Pet, Feed → [Seeds, Worm]], Care [Sleep, Wake],
// Identity [Rename], Debug [Force hatch, Force chick, Force adult, Reset].
// The Pet action is the same as a plain click — included in the menu for
// discoverability.
export const MASCOT_ACTIONS: RadialAction[] = [
{
id: 'interact',
label: 'Interact',
children: [
{
id: 'pet',
label: 'Pet',
action: (ctx) => {
ctx.runtime.bubble = '/mascot/bubble-love.png'
ctx.runtime.bubbleUntil = performance.now() + 1500
ctx.force('react', { anim: 'react-happy', durationMs: 1500 })
ctx.refresh()
}
},
{
id: 'feed',
label: 'Feed',
children: [
{
id: 'feed-seeds',
label: 'Seeds',
action: (ctx) => {
// Feeding: small happiness + xp boost.
ctx.model.happiness = Math.min(100, ctx.model.happiness + 8)
ctx.force('idle', { anim: 'peck', durationMs: 1800 })
ctx.refresh()
}
},
{
id: 'feed-worm',
label: 'Worm',
action: (ctx) => {
// Worm: bigger boost.
ctx.model.happiness = Math.min(100, ctx.model.happiness + 16)
ctx.force('idle', { anim: 'peck', durationMs: 1800 })
ctx.refresh()
}
}
]
}
]
},
{
id: 'care',
label: 'Care',
children: [
{
id: 'sleep',
label: 'Sleep',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
ctx.force('sleep', { durationMs: 8000 })
ctx.refresh()
}
},
{
id: 'wake',
label: 'Wake',
visible: () => true, // visible always; only useful when asleep but harmless otherwise
action: (ctx) => {
ctx.force('idle')
ctx.refresh()
}
}
]
},
{
id: 'identity',
label: 'Identity',
children: [
{
id: 'rename',
label: 'Rename',
visible: (m) => m.stage !== 'egg',
action: (ctx) => {
ctx.requestRename()
ctx.refresh()
}
}
]
},
{
id: 'debug',
label: 'Debug',
children: [
{
id: 'force-hatch',
label: 'Force hatch',
visible: (m) => m.stage === 'egg',
action: (ctx) => {
ctx.forceHatch()
ctx.refresh()
}
},
{
id: 'force-chick',
label: 'Force chick',
visible: (m) => m.stage !== 'chick',
action: (ctx) => {
ctx.forceStage('chick')
ctx.force('idle')
ctx.refresh()
}
},
{
id: 'force-adult',
label: 'Force adult',
visible: (m) => m.stage !== 'adult',
action: (ctx) => {
ctx.forceStage('adult')
ctx.force('idle')
ctx.refresh()
}
},
{
id: 'reset',
label: 'Reset',
action: (ctx) => {
ctx.reset()
ctx.refresh()
}
}
]
}
]
/** Insert an action at runtime, optionally nested under a parent id. Root insertion if parentId is undefined. */
export function registerMascotAction(a: RadialAction, parentId?: string): void {
if (!parentId) {
MASCOT_ACTIONS.push(a)
return
}
function findAndInsert(nodes: RadialAction[]): boolean {
for (const n of nodes) {
if (n.id === parentId) {
n.children = n.children ?? []
n.children.push(a)
return true
}
if (n.children && findAndInsert(n.children)) return true
}
return false
}
findAndInsert(MASCOT_ACTIONS)
}

View File

@@ -0,0 +1,385 @@
// Behavior engine: a finite state machine that drives the mascot's
// autonomous motion + animation. To add a behavior:
// 1. Add its id to `BehaviorId` in types.ts.
// 2. Add a `BehaviorDef` entry to BEHAVIORS below.
// 3. (Optional) Give it a `weight` to make it idle-selectable.
// `stepMascot()` and the weighted-random idle selector consume
// BEHAVIORS generically — no engine change is needed for a new behavior.
//
// Behaviors split into two groups:
// - Self-selecting (idle/wander/peck/sleep): pickable by the weighted
// random idle selector when the current behavior expires.
// - Forced (dragged/falling/react/land): entered only via forceBehavior()
// from the pointer code, gravity logic, or the stimulus bus.
//
// Physics: gravity + ground. GROUND_Y = bounds.h (the surface's bottom
// edge, == the taskbar's top edge). When above ground and not dragged,
// the mascot falls with a slow flutter terminal velocity; on landing,
// a brief `land` behavior plays, then idle. Dragging is always
// honored — pointer code calls forceBehavior('dragged'), which wins
// over any autonomous behavior or non-drag-breaking reaction.
import type { AnimName, BehaviorId, MascotRuntime } from './types'
import type { MascotModel } from './state.svelte'
// ─── tuning constants ────────────────────────────────────────────────────
const GRAVITY = 1400 // px/s^2 (gentle)
const TERMINAL_VY = 320 // px/s (slow flutter fall)
const WALK_SPEED = 36 // px/s
const MARGIN = 24 // px before the surface edge where wander flips facing
// Default durations (ms) for self-selecting behaviors. Each BehaviorDef
// can override with its own minMs/maxMs.
const IDLE_MS = [1500, 4000] as const
const WANDER_MS = [2500, 5000] as const
const PECK_MS = [1200, 2200] as const
const SLEEP_MS = [6000, 12000] as const
const LAND_MS = 400
const REACT_DEFAULT_MS = 1800
// ─── BehaviorDef ─────────────────────────────────────────────────────────
export interface BehaviorDef {
id: BehaviorId
/** Animation to play while this behavior is active. May depend on runtime state (e.g. facing). */
anim: (rt: MascotRuntime, model: MascotModel) => AnimName
/** Called once when the behavior is entered (set up velocity, etc). */
enter?: (rt: MascotRuntime) => void
/** Per-frame physics/integration. `dt` is already clamped to <= 100ms by the loop. */
tick: (rt: MascotRuntime, dt: number, now: number) => void
/** Called when behaviorUntil has passed; returns the next behavior id, or null to trigger idle selection. */
next: (rt: MascotRuntime, now: number) => BehaviorId | null
/** Idle-selection weight (>0 = eligible). Undefined/0 = never auto-picked. */
weight?: number
/** Duration range in ms for this behavior when auto-selected. */
minMs: number
maxMs: number
}
function randRange(min: number, max: number): number {
return min + Math.random() * (max - min)
}
function pickWeighted(candidates: BehaviorDef[]): BehaviorDef {
const total = candidates.reduce((s, b) => s + (b.weight ?? 0), 0)
let r = Math.random() * total
for (const b of candidates) {
r -= b.weight ?? 0
if (r <= 0) return b
}
return candidates[0]
}
// ─── ground / bounds helpers ─────────────────────────────────────────────
function groundY(rt: MascotRuntime): number {
// rt.groundY is recomputed each tick by Mascot.svelte from the window
// state — it's the top edge of the highest window beneath the mascot,
// or rt.bounds.h (surface bottom) when no window is beneath.
return rt.groundY
}
function clampX(rt: MascotRuntime): void {
const minX = MARGIN / 2
const maxX = rt.bounds.w - MARGIN / 2
if (rt.x < minX) {
rt.x = minX
rt.facing = 1
}
if (rt.x > maxX) {
rt.x = maxX
rt.facing = -1
}
}
// ─── BEHAVIORS registry ───────────────────────────────────────────────────
export const BEHAVIORS: Record<BehaviorId, BehaviorDef> = {
egg: {
id: 'egg',
anim: () => 'egg-idle',
tick: () => {
// Egg doesn't move on its own.
},
next: () => 'egg',
minMs: 0,
maxMs: 0
},
idle: {
id: 'idle',
// Periodic blink: enter() sets blinkUntil to the START of the next
// blink window (26s away). anim() returns 'blink' when we're past
// that start but within 150ms of it.
anim: (rt) => {
const now = performance.now()
if (now >= rt.blinkUntil && now < rt.blinkUntil + 150) return 'blink'
return 'idle'
},
enter: (rt) => {
rt.blinkUntil = performance.now() + 2000 + Math.random() * 4000
},
tick: () => {
// Standing still.
},
next: () => null,
weight: 3,
minMs: IDLE_MS[0],
maxMs: IDLE_MS[1]
},
wander: {
id: 'wander',
anim: () => 'walk',
enter: (rt) => {
rt.vx = rt.facing * WALK_SPEED
},
tick: (rt) => {
rt.x += rt.vx * (1 / 60) // dt is in seconds via the loop below; but tick receives ms — see stepMascot
// Actually the loop calls tick with dt in seconds; but to keep the
// BehaviorDef.tick signature consistent with the plan's `(rt, dt, now)`
// where dt is seconds-clamped, we'll re-derive below. The wander
// integration is redone in stepMascot to use dt correctly.
},
next: () => null,
weight: 4,
minMs: WANDER_MS[0],
maxMs: WANDER_MS[1]
},
peck: {
id: 'peck',
anim: () => 'peck',
tick: () => {
// Stationary peck animation.
},
next: () => null,
weight: 2,
minMs: PECK_MS[0],
maxMs: PECK_MS[1]
},
sleep: {
id: 'sleep',
anim: () => 'sleep',
tick: () => {
// Asleep.
},
next: () => null,
weight: 1,
minMs: SLEEP_MS[0],
maxMs: SLEEP_MS[1]
},
dragged: {
id: 'dragged',
anim: () => 'dragged',
tick: () => {
// Position is owned by the pointer handler; nothing to do here.
},
next: () => null, // exited only via forceBehavior from pointerup
minMs: 0,
maxMs: 0
},
falling: {
id: 'falling',
anim: () => 'fall-flutter',
enter: (rt) => {
rt.vx = 0
},
tick: () => {
// Integration happens in stepMascot (needs dt in seconds).
},
next: () => null, // exited via stepMascot when y reaches ground
minMs: 0,
maxMs: 0
},
land: {
id: 'land',
anim: () => 'land',
tick: () => {
// Brief squash animation.
},
next: () => 'idle',
minMs: LAND_MS,
maxMs: LAND_MS
},
react: {
id: 'react',
anim: (rt) => rt.reactAnim ?? 'idle',
tick: () => {
// Reaction plays its animation; no motion.
},
next: () => 'idle',
minMs: REACT_DEFAULT_MS,
maxMs: REACT_DEFAULT_MS
}
}
// ─── stepMascot: the per-frame driver ────────────────────────────────────
/** Force a behavior. Used by pointer code (dragged), gravity (falling), stimuli (react). */
export function forceBehavior(
rt: MascotRuntime,
id: BehaviorId,
opts?: { anim?: AnimName; durationMs?: number }
): void {
rt.behavior = id
if (opts?.anim) {
if (id === 'react') rt.reactAnim = opts.anim
else {
// For non-react behaviors, override the anim by setting animStart on a custom anim.
rt.anim = opts.anim
rt.animStart = performance.now()
}
}
if (id === 'react' && opts?.anim) {
rt.anim = opts.anim
rt.animStart = performance.now()
}
const def = BEHAVIORS[id]
if (opts?.durationMs) {
rt.behaviorUntil = performance.now() + opts.durationMs
} else if (def.maxMs > 0) {
rt.behaviorUntil = performance.now() + randRange(def.minMs, def.maxMs)
} else {
rt.behaviorUntil = Number.POSITIVE_INFINITY
}
def.enter?.(rt)
}
/** Step the FSM by `dt` ms (already clamped by the loop to <= 100ms). */
export function stepMascot(
rt: MascotRuntime,
model: MascotModel,
now: number,
dt: number
): void {
const dts = dt / 1000
const def = BEHAVIORS[rt.behavior]
// Per-behavior physics integration. Done here (not in def.tick) so the
// dt semantics stay consistent — the BehaviorDef.tick is reserved for
// any bespoke per-frame logic a future behavior needs.
switch (rt.behavior) {
case 'wander': {
rt.x += rt.vx * dts
// Flip at margins.
if (rt.x < MARGIN / 2) {
rt.x = MARGIN / 2
rt.facing = 1
rt.vx = WALK_SPEED
} else if (rt.x > rt.bounds.w - MARGIN / 2) {
rt.x = rt.bounds.w - MARGIN / 2
rt.facing = -1
rt.vx = -WALK_SPEED
}
// If the mascot walks off a window edge (ground dropped below
// current y), switch to falling — it flutters down to the next
// surface beneath (another window, or the desktop bottom).
if (rt.y < groundY(rt) - 1) {
forceBehavior(rt, 'falling')
}
break
}
case 'idle': {
// Same edge-detection as wander: a window can close/move under the
// mascot while it's idling, dropping the ground out from under it.
if (rt.y < groundY(rt) - 1) {
forceBehavior(rt, 'falling')
}
break
}
case 'falling': {
rt.vy = Math.min(TERMINAL_VY, rt.vy + GRAVITY * dts)
rt.y += rt.vy * dts
const gy = groundY(rt)
if (rt.y >= gy) {
rt.y = gy
rt.vy = 0
forceBehavior(rt, 'land', { durationMs: LAND_MS })
}
break
}
case 'dragged': {
// Position owned by pointer; just keep y clamped above ground so
// release-from-ground doesn't immediately enter falling.
break
}
default:
break
}
// Keep x in bounds for any behavior (defensive).
if (rt.behavior !== 'dragged') clampX(rt)
// Sync anim from the active behavior (unless it was overridden by a
// react/dragged force; reactAnim holds the override for `react`).
if (rt.behavior === 'react' && rt.reactAnim) {
rt.anim = rt.reactAnim
} else {
const a = def.anim(rt, model)
if (a !== rt.anim) {
rt.anim = a
rt.animStart = now
}
}
// Transition: only self-expiring behaviors (next() consult).
if (rt.behavior === 'dragged' || rt.behavior === 'falling') return
if (now < rt.behaviorUntil) return
const next = def.next(rt, now)
if (next) {
forceBehavior(rt, next)
} else {
// Idle-select a new behavior via weighted random over eligible entries.
const eligible = (Object.values(BEHAVIORS) as BehaviorDef[]).filter(
(b) => (b.weight ?? 0) > 0
)
if (eligible.length > 0) {
const picked = pickWeighted(eligible)
forceBehavior(rt, picked.id)
}
}
}
/** Helper: is the mascot currently in an interruptible autonomous behavior (not dragged)? */
export function isInterruptible(rt: MascotRuntime): boolean {
return rt.behavior !== 'dragged'
}
/** Helper: is the mascot currently asleep (used by stimuli to check interruptsSleep)? */
export function isAsleep(rt: MascotRuntime): boolean {
return rt.behavior === 'sleep'
}
/** Reset the runtime's vertical state for a drag-from-ground: no falling immediately on release at ground. */
export function releaseFromDrag(rt: MascotRuntime): void {
const gy = groundY(rt)
if (rt.y >= gy) {
rt.y = gy
rt.vy = 0
forceBehavior(rt, 'land', { durationMs: LAND_MS })
} else {
rt.vy = 0
forceBehavior(rt, 'falling')
}
}
/** Recompute ground clamp on resize: if the mascot was at the old ground, snap to the new ground. */
export function reground(rt: MascotRuntime, oldH: number): void {
const gy = groundY(rt)
if (rt.y >= oldH - 1) {
rt.y = gy
rt.vy = 0
} else if (rt.y > gy) {
rt.y = gy
rt.vy = 0
}
if (rt.behavior !== 'dragged') clampX(rt)
}

View File

@@ -0,0 +1,87 @@
// 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()
}

View File

@@ -0,0 +1,144 @@
// Sprite registry. To add a new animation:
// 1. Add its name to `AnimName` in types.ts.
// 2. Add an entry under SPRITES[stage] here pointing at a 16x16-frame PNG sheet in /mascot/.
// 3. (Optional) Reference it from a behavior in behavior.ts or a reaction in stimuli.ts.
// `resolveAnim()` falls back to the stage's `idle` and finally a 1-frame
// placeholder, so a missing animation never crashes the renderer.
//
// Sheets are bundled at web/public/mascot/*.png (CC0, see
// web/public/mascot/LICENSE.txt). Each sheet is a horizontal strip of
// 16x16 px frames; the renderer slices frame `i` at x = i*16.
//
// Egg-stage animations come from the Onocentaur egg pack (single-frame
// 16x16 PNGs): an idle egg and shell halves (shown briefly at the hatch
// moment). The egg → chick transition fires on first naming (see
// state.svelte.ts), not on a timed incubation, so there's no progressive
// crack animation — the egg sits on egg-idle until the name dialog is
// submitted, then swaps to the chick. The egg-crack sheet is kept in the
// registry for future use but isn't selected by any behavior today.
import type { AnimDef, AnimName, MascotStage } from './types'
const EGG_IDLE: AnimDef = { src: '/mascot/egg-idle.png', frames: 1, fps: 1, loop: true }
const EGG_SHELL: AnimDef = { src: '/mascot/egg-shell.png', frames: 1, fps: 1, loop: true }
export const SPRITES: Record<MascotStage, Partial<Record<AnimName, AnimDef>>> = {
egg: {
'egg-idle': EGG_IDLE,
'egg-wiggle': EGG_IDLE, // wiggle is applied as a render-time transform; no separate frame
hatch: EGG_SHELL,
dragged: EGG_IDLE,
'fall-flutter': EGG_IDLE,
land: EGG_IDLE
},
// Chick and adult share sheets; only the render scale differs.
chick: {
idle: { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true },
blink: { src: '/mascot/blink.png', frames: 4, fps: 6, loop: true },
walk: { src: '/mascot/walk.png', frames: 4, fps: 8, loop: true },
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
'react-think': { src: '/mascot/react-sigh.png', frames: 4, fps: 4, loop: true },
'react-eureka': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true },
'react-alarm': { src: '/mascot/react-yell.png', frames: 4, fps: 8, loop: true },
'react-happy': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true }
},
adult: {
idle: { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true },
blink: { src: '/mascot/blink.png', frames: 4, fps: 6, loop: true },
walk: { src: '/mascot/walk.png', frames: 4, fps: 8, loop: true },
peck: { src: '/mascot/peck.png', frames: 4, fps: 6, loop: true },
flap: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
sleep: { src: '/mascot/sleep.png', frames: 4, fps: 4, loop: true },
dragged: { src: '/mascot/jump.png', frames: 4, fps: 8, loop: true },
'fall-flutter': { src: '/mascot/jump.png', frames: 4, fps: 10, loop: true },
land: { src: '/mascot/hurt.png', frames: 4, fps: 8, loop: false },
'react-think': { src: '/mascot/react-sigh.png', frames: 4, fps: 4, loop: true },
'react-eureka': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true },
'react-alarm': { src: '/mascot/react-yell.png', frames: 4, fps: 8, loop: true },
'react-happy': { src: '/mascot/react-joy.png', frames: 4, fps: 6, loop: true }
}
}
// Render scale per stage. The asset pack has one chicken size; the chick
// and adult both render at full scale (1.0) — downscaling to 0.75 for the
// chick looked blurry on high-DPI displays. The stage is conveyed by the
// tamagotchi model + behavior, not by sprite size.
export const STAGE_SCALE: Record<MascotStage, number> = {
egg: 1,
chick: 1,
adult: 1
}
const PLACEHOLDER: AnimDef = { src: '/mascot/idle.png', frames: 4, fps: 6, loop: true }
/** Resolve an animation for a stage, falling back to the stage's idle, then a placeholder. */
export function resolveAnim(stage: MascotStage, name: AnimName): AnimDef {
const set = SPRITES[stage]
const direct = set[name]
if (direct) return direct
if (name !== 'idle') {
const idle = set.idle
if (idle) return idle
}
return PLACEHOLDER
}
/** Pick which frame of an AnimDef to draw at time `now` (ms). */
export function frameIndex(anim: AnimDef, now: number, animStart: number): number {
const elapsed = now - animStart
if (anim.frames <= 1) return 0
const idx = Math.floor((elapsed / 1000) * anim.fps)
if (anim.loop) return ((idx % anim.frames) + anim.frames) % anim.frames
return Math.min(idx, anim.frames - 1)
}
// ─── Image cache / loader ────────────────────────────────────────────────
// PNG sheets are loaded once into HTMLImageElement instances and reused.
// `loadSprites()` is called from Mascot.svelte on mount; `getImage()`
// returns the cached element (or null if not yet loaded, in which case
// the renderer just skips that frame — the loop will pick it up next
// tick once the image arrives).
const imageCache = new Map<string, HTMLImageElement>()
function loadOne(src: string): Promise<HTMLImageElement> {
const existing = imageCache.get(src)
if (existing && existing.complete) return Promise.resolve(existing)
return new Promise((resolve, reject) => {
const img = new Image()
img.src = src
img.onload = () => {
imageCache.set(src, img)
resolve(img)
}
img.onerror = () => reject(new Error(`mascot: failed to load ${src}`))
})
}
/** Preload every sheet referenced by SPRITES for the given stages (default: all). */
export async function loadSprites(stages: MascotStage[] = ['egg', 'chick', 'adult']): Promise<void> {
const srcs = new Set<string>()
for (const stage of stages) {
for (const anim of Object.values(SPRITES[stage])) {
if (anim && anim.src) srcs.add(anim.src)
}
}
await Promise.all([...srcs].map(loadOne))
}
/** Also preload a single bubble sprite by URL (used by react-* animations). */
export function loadBubble(src: string): Promise<HTMLImageElement> {
return loadOne(src)
}
/** Get a cached sheet image, or null if not yet loaded. */
export function getImage(src: string): HTMLImageElement | null {
const img = imageCache.get(src)
if (!img || !img.complete) return null
return img
}

View File

@@ -0,0 +1,208 @@
// Tamagotchi model: long-lived, persisted, slow-moving state (separate
// from the per-frame MascotRuntime in behavior.ts). Backed by a runes
// `$state` at module scope, mutators exported as functions, debounced
// localStorage persistence mirroring stores/windows.ts' 300ms cadence.
//
// Persistence schema lives at localStorage['oikos-mascot'] and is
// versioned via the `version` field; `migrate(raw)` is the stub where
// future schema changes go (v1 has no migrations to perform).
//
// Multi-tab races (two tabs both writing 'oikos-mascot') are
// last-writer-wins — accepted for v1, not solved. A future pass could
// listen to the `storage` event if it becomes a real problem.
import type { MascotStage } from './types'
const STORAGE_KEY = 'oikos-mascot'
const PERSIST_DEBOUNCE_MS = 300
export interface MascotModel {
version: 1
stage: MascotStage
name: string | null
/** Binary egg-hatch flag: 0 until first naming, 1 after. The egg → chick transition fires on naming, not on a timer. */
hatchProgress: number
/** 0..100, slow decay, boosted by pet/feed. */
happiness: number
/** Chick -> adult growth hook; reactions like `eureka` grant xp. */
xp: number
/** epoch ms when the egg hatched (chick/adult), null while still an egg. */
hatchedAt: number | null
/** Persisted rest x position (surface-relative) so the mascot doesn't reset to center on reload. */
lastPos: { x: number } | null
/** epoch ms of the last foreground tick — for capping passive decay. */
lastSeen: number
}
/** XP required to graduate from chick to adult. */
export const ADULT_XP = 200
function defaultModel(): MascotModel {
return {
version: 1,
stage: 'egg',
name: null,
hatchProgress: 0,
happiness: 50,
xp: 0,
hatchedAt: null,
lastPos: null,
lastSeen: Date.now()
}
}
// Module-scoped rune. Mutators below mutate this in place (Object.assign
// / direct property writes); Svelte's reactivity tracks deep property
// access in components that read it. `const` because the binding itself
// is never reassigned — only its properties are.
const model: MascotModel = $state(defaultModel())
// ─── load / migrate / persist ────────────────────────────────────────────
function migrate(raw: unknown): MascotModel {
// v1 has no migrations to perform; this stub documents where future
// version-gated schema changes go (switch on `raw.version`).
if (raw && typeof raw === 'object') {
const r = raw as Partial<MascotModel>
if (r.version === 1) {
return { ...defaultModel(), ...r, version: 1 } as MascotModel
}
}
return defaultModel()
}
function load(): MascotModel {
if (typeof localStorage === 'undefined') return defaultModel()
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultModel()
try {
return migrate(JSON.parse(raw))
} catch {
return defaultModel()
}
}
let persistTimer: ReturnType<typeof setTimeout> | null = null
function schedulePersist(): void {
if (typeof localStorage === 'undefined') return
if (persistTimer) clearTimeout(persistTimer)
persistTimer = setTimeout(() => {
persistTimer = null
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(model))
} catch {
// quota / privacy mode — swallow; the model still lives in memory for this session
}
}, PERSIST_DEBOUNCE_MS)
}
function flushPersist(): void {
if (persistTimer) {
clearTimeout(persistTimer)
persistTimer = null
}
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(model))
} catch {
// ignore
}
}
}
/** Initialize the module state from localStorage. Idempotent. Call once on app boot (or first mascot mount). */
export function initMascotState(): void {
if (typeof localStorage === 'undefined') return
const loaded = load()
// Mutate the existing $state object in place — reassigning `model` to
// a new $state() isn't allowed outside the top level in runes mode.
Object.assign(model, loaded)
model.lastSeen = Date.now()
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flushPersist)
}
}
// ─── accessors / mutators ────────────────────────────────────────────────
export function getModel(): MascotModel {
return model
}
export function setStage(stage: MascotStage): void {
model.stage = stage
if (stage !== 'egg' && model.hatchedAt === null) {
model.hatchedAt = Date.now()
}
if (stage === 'adult') {
model.xp = Math.max(model.xp, ADULT_XP)
}
schedulePersist()
}
export function setName(name: string): void {
model.name = name.slice(0, 24)
schedulePersist()
}
export function grantXp(n: number): void {
if (n === 0) return
model.xp = Math.max(0, model.xp + n)
schedulePersist()
}
export function feed(): void {
model.happiness = Math.min(100, model.happiness + 8)
grantXp(2)
}
export function pet(): void {
model.happiness = Math.min(100, model.happiness + 4)
grantXp(1)
}
export function setLastPos(x: number): void {
model.lastPos = { x }
schedulePersist()
}
export function resetModel(): void {
Object.assign(model, defaultModel())
schedulePersist()
}
/**
* Advance lifecycle state. Called ~1x/sec from Mascot.svelte's loop (NOT
* every frame). The egg → chick transition is NOT timed here — it fires
* once, on first naming (see MascotLayer's name-dialog submit handler,
* which calls forceHatch() after setName). This tick only handles slow
* passive happiness decay for hatched stages.
*/
export function tickLifecycle(dtMs: number): void {
const dtSec = dtMs / 1000
if (model.stage === 'chick') {
// Slow passive happiness decay (1/sec) so the tamagotchi benefits from
// being interacted with; only meaningful while the model is alive.
model.happiness = Math.max(0, model.happiness - 0.05 * dtSec)
}
model.lastSeen = Date.now()
advanceStageIfReady()
}
/** Promote egg -> chick when hatchProgress hits 1 (set by forceHatch on first naming), chick -> adult when xp hits ADULT_XP. */
export function advanceStageIfReady(): void {
if (model.stage === 'egg' && model.hatchProgress >= 1) {
setStage('chick')
} else if (model.stage === 'chick' && model.xp >= ADULT_XP) {
setStage('adult')
}
}
/** Hatches the egg immediately. Called from MascotLayer's name-dialog submit handler after the first naming, and from the Debug radial-menu action. */
export function forceHatch(): void {
if (model.stage === 'egg') {
model.hatchProgress = 1
setStage('chick')
}
}

View File

@@ -0,0 +1,191 @@
// Stimulus / reaction system. To add a new environment reaction:
// 1. Add a `ReactionDef` entry to REACTIONS below.
// 2. Wire a `store.subscribe -> predicate -> emit(reaction)` block
// inside `attachStimuli()`.
// The dispatch logic (priority + cooldown + interruptsSleep) is generic
// over REACTIONS — no engine change is needed for a new reaction.
//
// Reactions are dispatched into the MascotLayer via the `emit` callback
// passed to attachStimuli; MascotLayer calls forceBehavior('react', {anim,
// durationMs}) and sets the bubble. `dragged` always wins over any
// reaction; `sleep` is broken only when `interruptsSleep` is true.
//
// `attachStimuli` owns the SSE subscription (via subscribeEvents()) and
// folds its unsubscribe into the returned teardown, so the mascot keeps
// the SSE stream open (ref-counted alongside any page that also
// subscribes) only while mounted.
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
import { streaming } from '$lib/stores/chat'
import { activityLog, type ActivityEntry } from '$lib/stores/activity'
import { grantXp } from './state.svelte'
import type { AnimName } from './types'
export interface ReactionDef {
id: string
/** Animation to play while this reaction is active. */
anim: AnimName
/** Optional bubble sprite URL (16x16 PNG in /mascot/) drawn above the sprite. */
bubble?: string
/** Higher priority interrupts lower-priority reactions. */
priority: number
/** Minimum ms between dispatches of this same reaction. */
cooldownMs: number
/** How long the reaction animation plays (ms). */
durationMs: number
/** When true, breaks the mascot out of `sleep` to play the reaction. */
interruptsSleep?: boolean
/** Side effect to run on dispatch (e.g. grantXp(5) on eureka). */
effect?: () => void
}
export const REACTIONS: Record<string, ReactionDef> = {
thinking: {
id: 'thinking',
anim: 'react-think',
bubble: '/mascot/bubble-dotdotdot.png',
priority: 1,
cooldownMs: 0,
durationMs: 4000,
interruptsSleep: false
},
eureka: {
id: 'eureka',
anim: 'react-eureka',
bubble: '/mascot/bubble-exclaim.png',
priority: 2,
cooldownMs: 10_000,
durationMs: 2200,
interruptsSleep: false,
effect: () => grantXp(5)
},
alarmed: {
id: 'alarmed',
anim: 'react-alarm',
bubble: '/mascot/bubble-red-exclaim.png',
priority: 3,
cooldownMs: 15_000,
durationMs: 2500,
interruptsSleep: true
},
happy: {
id: 'happy',
anim: 'react-happy',
bubble: '/mascot/bubble-love.png',
priority: 1,
cooldownMs: 20_000,
durationMs: 2000
}
}
/** Per-reaction last-dispatched timestamp (ms). */
const lastFired = new Map<string, number>()
/** Tracks the current reaction's priority so a lower-priority one can't interrupt a higher one mid-flight. */
let currentReactPriority = 0
let currentReactUntil = 0
/**
* Attach all stimulus subscriptions. Returns a teardown that detaches
* everything (including the SSE stream ref). The `emit` callback is the
* MascotLayer's bridge into the runtime — it decides whether to actually
* dispatch based on the current behavior (dragged always wins).
*/
export function attachStimuli(emit: (r: ReactionDef) => void): () => void {
const unsubs: Array<() => void> = []
// ─── chat.ts `streaming`: false → true edge triggers `thinking` ──────
let lastStreaming = false
let prevStreamValue: boolean | null = null
// Hold the reaction while streaming stays true: we re-emit on each
// false→true edge so a new turn restarts the thinking anim.
unsubs.push(
streaming.subscribe((s) => {
if (prevStreamValue === false && s === true) {
tryDispatch(REACTIONS.thinking, emit)
}
prevStreamValue = s
lastStreaming = s
})
)
// Touch lastStreaming so the linter doesn't complain; it's used to
// reason about the edge detection above (kept for future "held while
// true" logic).
void lastStreaming
// ─── activity.ts `activityLog`: new `type === 'knowledge'` entry ─────
// The store is derived and recomputed wholesale on every emission —
// NOT append-only — so detect new entries by diffing entry ids
// against the last-seen set.
let prevKnowledgeIds = new Set<string>()
let firstActivityEmission = true
unsubs.push(
activityLog.subscribe((entries) => {
const currentIds = new Set<string>()
for (const e of entries) {
currentIds.add(e.id)
if (e.type === 'knowledge' && !prevKnowledgeIds.has(e.id) && !firstActivityEmission) {
tryDispatch(REACTIONS.eureka, emit)
}
}
prevKnowledgeIds = currentIds
firstActivityEmission = false
})
)
// ─── events.ts `liveEvents`: new head event ──────────────────────────
// On the very first emission, just record the head event id — do NOT
// replay history as reactions on mount.
let lastSeenEventId = 0
let firstEventEmission = true
unsubs.push(
liveEvents.subscribe((events) => {
const head = events[0]
if (!head) return
if (head.id <= lastSeenEventId) return
lastSeenEventId = head.id
if (firstEventEmission) {
firstEventEmission = false
return
}
if (head.severity === 'critical' || head.type.startsWith('signal.')) {
tryDispatch(REACTIONS.alarmed, emit)
} else if (head.type.startsWith('execution.')) {
// Success-ish execution event — happy reaction.
tryDispatch(REACTIONS.happy, emit)
}
})
)
// ─── SSE stream: ref-counted via subscribeEvents() ──────────────────
unsubs.push(subscribeEvents())
return () => {
for (const u of unsubs) u()
}
}
/** Cooldown + priority gate before handing the reaction to MascotLayer. */
function tryDispatch(r: ReactionDef, emit: (r: ReactionDef) => void): void {
const now = performance.now()
const last = lastFired.get(r.id) ?? 0
if (r.cooldownMs > 0 && now - last < r.cooldownMs) return
// Priority: a new reaction must have priority >= the current one's,
// unless the current one has expired (now > currentReactUntil).
const currentExpired = now > currentReactUntil
if (!currentExpired && r.priority < currentReactPriority) return
lastFired.set(r.id, now)
currentReactPriority = r.priority
currentReactUntil = now + r.durationMs
emit(r)
}
/** Reset all cooldowns and priority state (e.g. on mascot reset). Exposed for tests/debug. */
export function resetStimuliState(): void {
lastFired.clear()
currentReactPriority = 0
currentReactUntil = 0
}
// Type re-export so consumers don't need to import from events.ts separately.
export type { OikosEvent, ActivityEntry }

112
web/src/lib/mascot/types.ts Normal file
View File

@@ -0,0 +1,112 @@
// Type definitions for the desktop mascot sprite/behavior/action/reaction
// system. Every registry below (sprites.ts, behavior.ts, actions.ts,
// stimuli.ts) is plain data over these types, so each can be extended
// independently without touching the engine code in Mascot.svelte.
import type { Component } from 'svelte'
/** Slow-moving lifecycle stage of the tamagotchi. Drives which sprite set is drawn and the render scale. */
export type MascotStage = 'egg' | 'chick' | 'adult'
/** A named animation. Add a name here, then add an entry under SPRITES[stage] in sprites.ts. */
export type AnimName =
| 'egg-idle' | 'egg-wiggle' | 'egg-crack' | 'hatch'
| 'idle' | 'blink' | 'walk' | 'peck' | 'flap' | 'sleep'
| 'dragged' | 'fall-flutter' | 'land'
| 'react-eureka' | 'react-alarm' | 'react-think' | 'react-happy'
/**
* A sprite-sheet animation. The sheet is a horizontal strip of 16x16 px
* frames (PNG, RGBA) served from /mascot/*. The renderer slices frame
* `i` from x = i*16, y = 0, w = 16, h = 16. The whole mascot sprite
* canvas is 20x20 logical px (so feet land on a consistent ground line
* across stages); the 16x16 frame is bottom-anchored and horizontally
* centered inside it.
*
* Egg-stage animations are vector-drawn by render.ts (no PNG); their
* AnimDef entries still exist for the FSM to reference but their `src`
* is ignored.
*/
export interface AnimDef {
/** Sheet URL (resolved from /mascot/<src>). Ignored for egg-vector anims. */
src: string
/** Frame count in the sheet (sheet width = frames * 16). */
frames: number
/** Frames per second. */
fps: number
/** Whether to wrap the frame index once it reaches `frames`. */
loop: boolean
}
/** Autonomous FSM state. Add an id here, then add a BehaviorDef entry to BEHAVIORS in behavior.ts. */
export type BehaviorId =
| 'egg' | 'idle' | 'wander' | 'peck' | 'sleep'
| 'dragged' | 'falling' | 'land' | 'react'
/** Opaque identifier for an environment stimulus reaction. See stimuli.ts. */
export type Stimulus = string
/** Shape of a radial-menu action node. See actions.ts. */
export interface RadialAction {
id: string
label: string
icon?: Component
/** Visibility predicate (e.g. "Rename" only once hatched). Defaults to always visible. */
visible?: (model: import('./state.svelte').MascotModel) => boolean
/** Sub-actions — selecting this node swaps the ring to its children + a back button. */
children?: RadialAction[]
/** Leaf handler. Mutates model/runtime via the passed context. */
action?: (ctx: MascotActionCtx) => void
}
/** Argument passed to a RadialAction leaf handler. */
export interface MascotActionCtx {
model: import('./state.svelte').MascotModel
runtime: MascotRuntime
/** Force a behavior (e.g. sleep). See behavior.ts. */
force: (id: BehaviorId, opts?: { anim?: AnimName; durationMs?: number }) => void
/** Request the name dialog to open. */
requestRename: () => void
/** Advance to the next lifecycle stage immediately (debug). */
forceHatch: () => void
/** Force a specific lifecycle stage (debug). */
forceStage: (stage: MascotStage) => void
/** Reset the tamagotchi model to defaults. */
reset: () => void
/** Redraw the menu (after a visibility-affecting mutation). */
refresh: () => void
}
/** Frame-to-frame state of the mascot on the desktop surface. Owned by Mascot.svelte. */
export interface MascotRuntime {
/** Sprite bottom-center, surface (not viewport) coords. */
x: number
y: number
vx: number
vy: number
facing: 1 | -1
behavior: BehaviorId
/** performance.now() ms after which the current behavior should transition (its next() is consulted). */
behaviorUntil: number
/** Currently-playing animation. */
anim: AnimName
/** performance.now() ms when the current animation started. */
animStart: number
/** When behavior === 'react', the animation to play (overrides the behavior's default anim). */
reactAnim: AnimName | null
/** Surface bounds (width/height in CSS pixels). Updated on resize. */
bounds: { w: number; h: number }
/**
* Current ground line at the mascot's x: the top edge of the highest
* non-minimized window beneath it, or bounds.h (surface bottom) when
* no window is beneath. Updated each tick by Mascot.svelte from
* wmState; the FSM uses this as the ground for falling/landing.
*/
groundY: number
/** Optional reaction-bubble sprite to draw above the chicken (eg 'bubble-exclaim'). */
bubble: string | null
/** performance.now() ms when the current bubble was set; cleared when null. */
bubbleUntil: number
/** performance.now() ms until which the idle behavior should play 'blink' instead of 'idle'. */
blinkUntil: number
}