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:
@@ -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 />
|
||||
|
||||
329
web/src/lib/mascot/Mascot.svelte
Normal file
329
web/src/lib/mascot/Mascot.svelte
Normal 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}
|
||||
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}
|
||||
98
web/src/lib/mascot/NameDialog.svelte
Normal file
98
web/src/lib/mascot/NameDialog.svelte
Normal 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>
|
||||
152
web/src/lib/mascot/RadialMenu.svelte
Normal file
152
web/src/lib/mascot/RadialMenu.svelte
Normal 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>
|
||||
163
web/src/lib/mascot/actions.ts
Normal file
163
web/src/lib/mascot/actions.ts
Normal 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)
|
||||
}
|
||||
385
web/src/lib/mascot/behavior.ts
Normal file
385
web/src/lib/mascot/behavior.ts
Normal 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 (2–6s 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)
|
||||
}
|
||||
87
web/src/lib/mascot/render.ts
Normal file
87
web/src/lib/mascot/render.ts
Normal 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()
|
||||
}
|
||||
144
web/src/lib/mascot/sprites.ts
Normal file
144
web/src/lib/mascot/sprites.ts
Normal 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
|
||||
}
|
||||
208
web/src/lib/mascot/state.svelte.ts
Normal file
208
web/src/lib/mascot/state.svelte.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
191
web/src/lib/mascot/stimuli.ts
Normal file
191
web/src/lib/mascot/stimuli.ts
Normal 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
112
web/src/lib/mascot/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user