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

@@ -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}