feat(web): desktop mascot ("Cluck") — egg/chick/adult tamagotchi that roams the desktop, reacts to chat/events, walks on top of windows
Implements plans/2026-07-20-desktop-mascot.md. New code under web/src/lib/mascot/ (types/sprites/render/state/behavior/actions/ stimuli + Mascot/MascotLayer/RadialMenu/NameDialog components) plus CC0 sprite sheets at web/public/mascot/ (chicken + Onocentaur egg pack + reaction bubbles). MascotLayer is inserted into Desktop.svelte after WindowLayer; <2-line integration. Tamagotchi: egg -> chick -> adult lifecycle persisted to localStorage['oikos-mascot'] (debounced 300ms). Egg hatches on first naming (no timed incubation per implementation deviation). Chick/adult wander, peck, sleep, blink autonomously via a weighted-random FSM; the chicken walks above windows (ground line = highest window top edge beneath its x, recomputed each tick from wmState; rides the ground when the window beneath is dragged). Interaction: draggable with flutter-fall physics on release mid-air; plain click = pet (heart bubble + happy anim); right-click opens a rounded-button radial menu (Interact/Care/Identity/Debug nested groups) mirroring the desktop's own right-click menu styling; auto-flips above/ left near screen edges. Awareness: stimulus bus subscribes to chat.ts streaming, activity.ts activityLog (knowledge-entry diff), events.ts liveEvents (critical/ signal -> alarmed, execution -> happy), with priority+cooldown gating. Egg-stage reactions are suppressed. Reaction bubbles are anti-aliased. Sprite loop runs at ~60fps via setTimeout (not rAF) per GraphBackground convention, dt clamped to 100ms; position via transform: translate3d + will-change: transform for compositor-friendly motion. Z-index ordering: WindowLayer z-40 < MascotLayer z-[45] < desktop context menu z-50 < RadialMenu/NameDialog z-[60]. Docs: plan + docs/mascot/README.md (MBSE subsystem model) updated to Implemented with a deviations note covering hatch-on-naming, PNG-sheet art, button-column radial menu, 60fps loop, egg-reaction suppression, and window-walking ground model. VERSION bumped 0.7.13 -> 0.8.0.
This commit is contained in:
204
web/src/lib/mascot/MascotLayer.svelte
Normal file
204
web/src/lib/mascot/MascotLayer.svelte
Normal 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}
|
||||
Reference in New Issue
Block a user