Companion: selectable companions with a picker
Click the mascot to open a bilingual picker and switch between the Sleepy Cat (always asleep, still talks in her sleep) and the Happy Dog (awake/bouncy). Choice persists in localStorage. - companions.ts is the roster; add one by dropping a pure-vector Lottie JSON in animations/ and appending a COMPANIONS entry. - napping = companion.alwaysAsleep || mood === 'sleeping' drives the sway/zzz. - Removed the old single MOOD_ANIMATIONS map (animations/index.ts). Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { SaveStatus } from '../../hooks/useAutoSave'
|
||||
import { useCompanion, type Mood } from './useCompanion'
|
||||
import { LottiePlayer } from './LottiePlayer'
|
||||
import { MOOD_ANIMATIONS } from './animations'
|
||||
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
|
||||
|
||||
interface Props {
|
||||
wordCount: number
|
||||
@@ -10,29 +11,113 @@ interface Props {
|
||||
acceptTick: number
|
||||
}
|
||||
|
||||
// Emoji placeholder per mood, used until a Lottie asset is wired for that mood.
|
||||
// Emoji placeholder per mood, used for any mood a companion has no Lottie for.
|
||||
const MOOD_EMOJI: Record<Mood, string> = {
|
||||
idle: '🐱',
|
||||
idle: '🐾',
|
||||
happy: '😺',
|
||||
talking: '😸',
|
||||
celebrate: '😻',
|
||||
sleeping: '😴',
|
||||
}
|
||||
|
||||
// PetalCompanion is the cozy corner kitten: it watches the writing session via
|
||||
const STORAGE_KEY = 'petal.companion'
|
||||
|
||||
// PetalCompanion is the cozy corner mascot: it watches the writing session via
|
||||
// useCompanion and shows a Mandarin-first speech bubble for cheers, tips, and
|
||||
// break reminders. Animation is Lottie when assets exist, emoji otherwise.
|
||||
// break reminders. Clicking the mascot opens a picker to switch companions
|
||||
// (the choice persists in localStorage).
|
||||
export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }: Props) {
|
||||
const { mood, bubble, dismiss } = useCompanion({ wordCount, saveStatus, editTick, acceptTick })
|
||||
// This mascot is always asleep (every mood maps to the sleeping-cat loop, see
|
||||
// animations/index.ts) — so she always gets the calm sway + drifting zzz, even
|
||||
// while she mumbles tips in her sleep. Flip to `mood === 'sleeping'` if you
|
||||
// ever swap in an awake mascot.
|
||||
const napping = true
|
||||
|
||||
const [companionId, setCompanionId] = useState<string>(() => {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION
|
||||
} catch {
|
||||
return DEFAULT_COMPANION
|
||||
}
|
||||
})
|
||||
const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0]
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Always-asleep mascots keep the calm sway + zzz whatever the engine's mood.
|
||||
const napping = companion.alwaysAsleep || mood === 'sleeping'
|
||||
|
||||
function choose(id: string) {
|
||||
setCompanionId(id)
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
} catch {
|
||||
/* private mode / storage disabled — selection just won't persist */
|
||||
}
|
||||
setPickerOpen(false)
|
||||
}
|
||||
|
||||
// Click outside the corner closes the picker.
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) setPickerOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [pickerOpen])
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2">
|
||||
{bubble && (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="pointer-events-none fixed bottom-4 right-4 z-40 flex flex-col items-end gap-2"
|
||||
>
|
||||
{pickerOpen && (
|
||||
<div
|
||||
className="petal-bubble pointer-events-auto flex flex-col gap-0.5 p-1.5"
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
fontFamily: 'var(--font-ui)',
|
||||
minWidth: 180,
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="px-2 pb-1 pt-0.5 text-[0.7rem] font-bold"
|
||||
style={{ color: 'var(--color-muted)' }}
|
||||
>
|
||||
选个小伙伴 · Choose a companion
|
||||
</p>
|
||||
{COMPANIONS.map((c) => {
|
||||
const active = c.id === companion.id
|
||||
return (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => choose(c.id)}
|
||||
className="flex items-center gap-2.5 rounded-xl px-2 py-1.5 text-left text-sm transition-colors"
|
||||
style={{
|
||||
background: active ? 'var(--color-surface-alt)' : 'transparent',
|
||||
color: 'var(--color-plum)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'var(--color-surface-alt)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!active) e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20, lineHeight: 1 }}>{c.emoji}</span>
|
||||
<span className="flex-1">
|
||||
<span className="font-bold">{c.zh}</span>{' '}
|
||||
<span style={{ color: 'var(--color-muted)' }}>{c.name}</span>
|
||||
</span>
|
||||
{active && <span style={{ color: 'var(--color-accent)' }}>✓</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bubble && !pickerOpen && (
|
||||
<div
|
||||
role="status"
|
||||
onClick={dismiss}
|
||||
@@ -56,11 +141,16 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen((o) => !o)}
|
||||
title="Choose a companion"
|
||||
aria-label="Choose a companion"
|
||||
className={`petal-companion pointer-events-auto select-none ${napping ? 'petal-companion-sleep' : ''}`}
|
||||
style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
padding: 0,
|
||||
borderRadius: 'var(--radius-pill)',
|
||||
background: 'var(--color-surface-alt)',
|
||||
border: '1px solid var(--color-border)',
|
||||
@@ -69,10 +159,10 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
||||
placeItems: 'center',
|
||||
position: 'relative',
|
||||
}}
|
||||
aria-label="Petal companion"
|
||||
>
|
||||
<LottiePlayer
|
||||
animationData={MOOD_ANIMATIONS[mood]}
|
||||
key={companion.id}
|
||||
animationData={companion.animations[mood]}
|
||||
className="h-14 w-14"
|
||||
fallback={<span style={{ fontSize: 32, lineHeight: 1 }}>{MOOD_EMOJI[mood]}</span>}
|
||||
/>
|
||||
@@ -81,7 +171,7 @@ export function PetalCompanion({ wordCount, saveStatus, editTick, acceptTick }:
|
||||
z
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Mood } from '../useCompanion'
|
||||
import sleepingCat from './sleeping-cat.json'
|
||||
// A Happy Dog clip is also in this folder (happy-dog.json) if you ever want an
|
||||
// awake, bouncy mascot — import it and map it to the awake/active moods below.
|
||||
|
||||
// Lottie animation assets, keyed by mood. Assets are pure vector (no
|
||||
// expressions/images) so they render on the lottie-web light build, fully
|
||||
// offline; LottiePlayer auto-crops each to its content bbox.
|
||||
//
|
||||
// Per the resident feline: the cat is ALWAYS asleep. Every mood maps to the
|
||||
// same sleeping-cat loop, so she snoozes peacefully in the corner — and yet
|
||||
// still somehow talks in her sleep, dispensing tips and encouragement through
|
||||
// the speech bubble. (Yes, this is intentional. It's funnier this way.)
|
||||
export const MOOD_ANIMATIONS: Partial<Record<Mood, object>> = {
|
||||
idle: sleepingCat,
|
||||
happy: sleepingCat,
|
||||
talking: sleepingCat,
|
||||
celebrate: sleepingCat,
|
||||
sleeping: sleepingCat,
|
||||
}
|
||||
49
web/src/components/Companion/companions.ts
Normal file
49
web/src/components/Companion/companions.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Mood } from './useCompanion'
|
||||
import sleepingCat from './animations/sleeping-cat.json'
|
||||
import happyDog from './animations/happy-dog.json'
|
||||
|
||||
export interface Companion {
|
||||
id: string
|
||||
name: string // English label
|
||||
zh: string // Chinese label (she reads Mandarin — north star Note #17)
|
||||
emoji: string // shown in the picker + used as the per-mood fallback base
|
||||
// mood → Lottie asset. Unmapped moods fall back to the per-mood emoji.
|
||||
animations: Partial<Record<Mood, object>>
|
||||
// When true the mascot keeps its calm sway + drifting zzz regardless of mood
|
||||
// (e.g. a cat that's always asleep but still talks in its sleep).
|
||||
alwaysAsleep?: boolean
|
||||
}
|
||||
|
||||
// The roster. Add a companion by dropping a pure-vector Lottie JSON into
|
||||
// ./animations and appending an entry here — that's the whole change.
|
||||
export const COMPANIONS: Companion[] = [
|
||||
{
|
||||
id: 'cat',
|
||||
name: 'Sleepy Cat',
|
||||
zh: '瞌睡猫',
|
||||
emoji: '😴',
|
||||
alwaysAsleep: true,
|
||||
animations: {
|
||||
idle: sleepingCat,
|
||||
happy: sleepingCat,
|
||||
talking: sleepingCat,
|
||||
celebrate: sleepingCat,
|
||||
sleeping: sleepingCat,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dog',
|
||||
name: 'Happy Dog',
|
||||
zh: '开心狗',
|
||||
emoji: '🐶',
|
||||
animations: {
|
||||
idle: happyDog,
|
||||
happy: happyDog,
|
||||
talking: happyDog,
|
||||
celebrate: happyDog,
|
||||
// no sleeping clip → naps with the 😴 emoji fallback
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_COMPANION = 'cat'
|
||||
Reference in New Issue
Block a user