@linxin666/dsh-pet 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +106 -0
  3. package/assets/whale/pet.json +7 -0
  4. package/assets/whale/previews/failed.gif +0 -0
  5. package/assets/whale/previews/idle.gif +0 -0
  6. package/assets/whale/previews/jumping.gif +0 -0
  7. package/assets/whale/previews/review.gif +0 -0
  8. package/assets/whale/previews/running-left.gif +0 -0
  9. package/assets/whale/previews/running-right.gif +0 -0
  10. package/assets/whale/previews/running.gif +0 -0
  11. package/assets/whale/previews/waiting.gif +0 -0
  12. package/assets/whale/previews/waving.gif +0 -0
  13. package/assets/whale/spritesheet.webp +0 -0
  14. package/cordis.patch.yml +10 -0
  15. package/lib/client.js +1515 -0
  16. package/lib/index.js +642 -0
  17. package/lib/invariant.js +34 -0
  18. package/lib/types/affinity.d.ts +83 -0
  19. package/lib/types/affinity.d.ts.map +1 -0
  20. package/lib/types/client/PetDockEntry.d.ts +44 -0
  21. package/lib/types/client/PetDockEntry.d.ts.map +1 -0
  22. package/lib/types/client/PetSettingsCard.d.ts +67 -0
  23. package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
  24. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  25. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  26. package/lib/types/client/WhalePet.d.ts +49 -0
  27. package/lib/types/client/WhalePet.d.ts.map +1 -0
  28. package/lib/types/client/index.d.ts +46 -0
  29. package/lib/types/client/index.d.ts.map +1 -0
  30. package/lib/types/client/locales.d.ts +101 -0
  31. package/lib/types/client/locales.d.ts.map +1 -0
  32. package/lib/types/client/pet-store.d.ts +49 -0
  33. package/lib/types/client/pet-store.d.ts.map +1 -0
  34. package/lib/types/client/settings-form.d.ts +119 -0
  35. package/lib/types/client/settings-form.d.ts.map +1 -0
  36. package/lib/types/client/slots-augment.d.ts +19 -0
  37. package/lib/types/client/slots-augment.d.ts.map +1 -0
  38. package/lib/types/client/spritesheet.d.ts +69 -0
  39. package/lib/types/client/spritesheet.d.ts.map +1 -0
  40. package/lib/types/index.d.ts +44 -0
  41. package/lib/types/index.d.ts.map +1 -0
  42. package/lib/types/invariant.d.ts +10 -0
  43. package/lib/types/invariant.d.ts.map +1 -0
  44. package/lib/types/persist.d.ts +45 -0
  45. package/lib/types/persist.d.ts.map +1 -0
  46. package/lib/types/routes.d.ts +22 -0
  47. package/lib/types/routes.d.ts.map +1 -0
  48. package/lib/types/service.d.ts +161 -0
  49. package/lib/types/service.d.ts.map +1 -0
  50. package/lib/types/state.d.ts +80 -0
  51. package/lib/types/state.d.ts.map +1 -0
  52. package/lib/types/treats.d.ts +59 -0
  53. package/lib/types/treats.d.ts.map +1 -0
  54. package/package.json +100 -0
  55. package/src/affinity.test.ts +74 -0
  56. package/src/affinity.ts +144 -0
  57. package/src/client/PetDockEntry.tsx +95 -0
  58. package/src/client/PetSettingsCard.tsx +186 -0
  59. package/src/client/PluginSettingsCard.tsx +207 -0
  60. package/src/client/WhalePet.tsx +342 -0
  61. package/src/client/css-modules.d.ts +6 -0
  62. package/src/client/index.ts +261 -0
  63. package/src/client/locales.ts +108 -0
  64. package/src/client/pet-store.ts +80 -0
  65. package/src/client/pet.module.css +185 -0
  66. package/src/client/settings-card.module.css +277 -0
  67. package/src/client/settings-form.ts +294 -0
  68. package/src/client/slots-augment.ts +27 -0
  69. package/src/client/spritesheet.ts +138 -0
  70. package/src/index.ts +148 -0
  71. package/src/invariant.ts +40 -0
  72. package/src/persist.test.ts +96 -0
  73. package/src/persist.ts +128 -0
  74. package/src/routes.ts +171 -0
  75. package/src/service.ts +389 -0
  76. package/src/state.test.ts +65 -0
  77. package/src/state.ts +156 -0
  78. package/src/treats.test.ts +86 -0
  79. package/src/treats.ts +101 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Shared chrome for the plugin settings card: a disclosure header naming the
3
+ * plugin and what its settings govern, the controls inside, and the save that
4
+ * writes them. Renders nothing while the namespace is unavailable — a
5
+ * deployment that does not compose the owning plugin should show no trace of
6
+ * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
7
+ * slice (this package must not depend on a sibling UI package).
8
+ */
9
+
10
+ import { useState, type ReactNode } from 'react'
11
+ import type { CardShell } from './settings-form.ts'
12
+ import type { SettingsCardKey } from './locales.ts'
13
+ import css from './settings-card.module.css'
14
+
15
+ /** Card chrome shared by every plugin settings card. */
16
+ export interface PluginSettingsCardProps {
17
+ /** Locale reader for this card's copy. */
18
+ t: (key: SettingsCardKey) => string
19
+ /** Locale key of the plugin's name. */
20
+ titleKey: SettingsCardKey
21
+ /** Locale key of the line describing what this plugin's settings govern. */
22
+ descriptionKey: SettingsCardKey
23
+ /** The card's form state: availability, writability, and what a save would do. */
24
+ state: CardShell
25
+ /** Write every staged edit. */
26
+ onSave: () => void
27
+ /** Drop every staged edit. */
28
+ onDiscard: () => void
29
+ /** The plugin's controls. */
30
+ children: ReactNode
31
+ }
32
+
33
+ /**
34
+ * Render one plugin settings card.
35
+ * @param props - the plugin's copy keys, its form state, and its controls.
36
+ * @returns the card, or nothing when the namespace is unavailable.
37
+ */
38
+ export function PluginSettingsCard(props: PluginSettingsCardProps) {
39
+ const [open, setOpen] = useState(false)
40
+ const { state } = props
41
+ if (!state.available) return null
42
+ const title = props.t(props.titleKey)
43
+ const blocked = !state.dirty || state.invalid || state.saving
44
+ return (
45
+ <li className={css.card}>
46
+ <button
47
+ type="button"
48
+ className={css.header}
49
+ aria-expanded={open}
50
+ aria-label={`${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
51
+ onClick={() => { setOpen(!open) }}
52
+ >
53
+ <span className={css.headText}>
54
+ <span className={css.name}>{title}</span>
55
+ <span className={css.description}>{props.t(props.descriptionKey)}</span>
56
+ </span>
57
+ {state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
58
+ <span className={open ? css.chevronOpen : css.chevron}>▾</span>
59
+ </button>
60
+ {open
61
+ ? (
62
+ <div className={css.body}>
63
+ {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
64
+ {props.children}
65
+ <div className={css.footer}>
66
+ {state.failed ? <p className={css.failed} role="status">{props.t('settings.saveFailed')}</p> : null}
67
+ <button
68
+ type="button"
69
+ className={css.discard}
70
+ disabled={!state.dirty || state.saving}
71
+ onClick={props.onDiscard}
72
+ >
73
+ {props.t('settings.discard')}
74
+ </button>
75
+ <button
76
+ type="button"
77
+ className={css.save}
78
+ disabled={blocked}
79
+ onClick={props.onSave}
80
+ >
81
+ {props.t(!state.saving ? 'settings.save' : 'settings.saving')}
82
+ </button>
83
+ </div>
84
+ </div>
85
+ )
86
+ : null}
87
+ </li>
88
+ )
89
+ }
90
+
91
+ /** Props every field control needs regardless of its value type. */
92
+ export interface FieldProps {
93
+ /** Stable id associating the label with its control. */
94
+ id: string
95
+ /** Visible label. */
96
+ label: string
97
+ /** One-line explanation rendered under the control. */
98
+ hint: string
99
+ /** Draft text this control renders. */
100
+ text: string
101
+ /** True when saving would leave a user-layer entry for this field. */
102
+ overridden: boolean
103
+ /** True when the draft is not a value this field accepts. */
104
+ invalid: boolean
105
+ /** Copy for the overridden badge. */
106
+ overriddenLabel: string
107
+ /** Copy for the reset control. */
108
+ resetLabel: string
109
+ /** Copy shown in place of the hint while the draft is invalid. */
110
+ invalidLabel: string
111
+ /** Disables every control (read-only document, or an unavailable namespace). */
112
+ disabled: boolean
113
+ /** Stage draft text. */
114
+ onEdit: (text: string) => void
115
+ /** Stage a clear so the field re-inherits the composition layer. */
116
+ onReset: () => void
117
+ }
118
+
119
+ /** A staged value field. `numeric` only hints the keypad: which drafts a field accepts is decided by its spec. */
120
+ export function ValueField(props: FieldProps & {
121
+ /** Hints a numeric keypad without narrowing what the control accepts. */
122
+ numeric?: boolean
123
+ /** Placeholder shown while the draft is empty. */
124
+ placeholder?: string
125
+ }) {
126
+ return (
127
+ <div className={css.field}>
128
+ <div className={css.head}>
129
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
130
+ {props.overridden
131
+ ? (
132
+ <span className={css.badges}>
133
+ <span className={css.badge}>{props.overriddenLabel}</span>
134
+ <button
135
+ type="button"
136
+ className={css.reset}
137
+ disabled={props.disabled}
138
+ onClick={props.onReset}
139
+ >
140
+ {props.resetLabel}
141
+ </button>
142
+ </span>
143
+ )
144
+ : null}
145
+ </div>
146
+ <input
147
+ id={props.id}
148
+ className={props.invalid ? css.inputInvalid : css.input}
149
+ type="text"
150
+ {...props.numeric === true ? { inputMode: 'numeric' as const } : {}}
151
+ {...props.invalid ? { 'aria-invalid': true } : {}}
152
+ value={props.text}
153
+ placeholder={props.placeholder ?? ''}
154
+ disabled={props.disabled}
155
+ onChange={(event) => { props.onEdit(event.target.value) }}
156
+ />
157
+ <p className={props.invalid ? css.invalid : css.hint}>
158
+ {props.invalid ? props.invalidLabel : props.hint}
159
+ </p>
160
+ </div>
161
+ )
162
+ }
163
+
164
+ /** A staged boolean field: 继承 / 开 / 关. */
165
+ export function BooleanField(props: FieldProps & {
166
+ /** Copy for the inherit option. */
167
+ inheritLabel: string
168
+ /** Copy for the on option. */
169
+ onLabel: string
170
+ /** Copy for the off option. */
171
+ offLabel: string
172
+ }) {
173
+ return (
174
+ <div className={css.field}>
175
+ <div className={css.head}>
176
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
177
+ {props.overridden
178
+ ? (
179
+ <span className={css.badges}>
180
+ <span className={css.badge}>{props.overriddenLabel}</span>
181
+ <button
182
+ type="button"
183
+ className={css.reset}
184
+ disabled={props.disabled}
185
+ onClick={props.onReset}
186
+ >
187
+ {props.resetLabel}
188
+ </button>
189
+ </span>
190
+ )
191
+ : null}
192
+ </div>
193
+ <select
194
+ id={props.id}
195
+ className={css.select}
196
+ value={props.text}
197
+ disabled={props.disabled}
198
+ onChange={(event) => { props.onEdit(event.target.value) }}
199
+ >
200
+ <option value="">{props.inheritLabel}</option>
201
+ <option value="true">{props.onLabel}</option>
202
+ <option value="false">{props.offLabel}</option>
203
+ </select>
204
+ <p className={css.hint}>{props.hint}</p>
205
+ </div>
206
+ )
207
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Whale-girl companion component — the browser half's centerpiece. Renders a
3
+ * fixed-position floating sprite (React portal onto document.body), plays
4
+ * the spritesheet track matching the host animation snapshot, and exposes
5
+ * the interaction surface: click to pet, hover panel with feed/hide, drag to
6
+ * reposition (persisted via setConfig).
7
+ * @module @linxin666/dsh-pet/client/WhalePet
8
+ */
9
+
10
+ import { useEffect, useRef, useState } from 'react'
11
+ import type { PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
12
+ import { createPortal } from 'react-dom'
13
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
14
+ import type { PetDisplayConfig } from '../persist.ts'
15
+ import type { PetStateView } from '../service.ts'
16
+ import type { PetFeedback } from './pet-store.ts'
17
+ import { framePosition, FRAME_WIDTH, FRAME_HEIGHT, FRAME_COLUMNS, TRACKS, rowOfTrack, trimTrack, detectFrameCounts } from './spritesheet.ts'
18
+ import type { PetAnimation } from '../state.ts'
19
+ import { NS } from './locales.ts'
20
+ import styles from './pet.module.css'
21
+
22
+ /** Browser URL of the whale-girl atlas (served by the host half's own route). */
23
+ export const PET_SPRITESHEET_URL = '/pet/whale/spritesheet.webp'
24
+
25
+ /** Browser URL of the whale-girl manifest (authoritative per-row frame counts). */
26
+ export const PET_MANIFEST_URL = '/pet/whale/pet.json'
27
+
28
+ /** Props injected by the slot registration (store actions + locale). */
29
+ export interface WhalePetProps {
30
+ /** Latest host snapshot; null while loading. */
31
+ snapshot: PetStateView | null
32
+ /** Display configuration (persisted by the host). */
33
+ display: PetDisplayConfig
34
+ /** Active reaction bubble, if any. */
35
+ feedback: PetFeedback | null
36
+ /** Pet the whale girl (click). */
37
+ onPet: () => void
38
+ /** Feed the whale girl (panel button). */
39
+ onFeed: () => void
40
+ /** Hide the whale girl (panel button). */
41
+ onHide: () => void
42
+ /** Persist a drag position. */
43
+ onDragEnd: (right: number, bottom: number) => void
44
+ /** Rename the pet (persisted by the host). */
45
+ onRename: (name: string) => void
46
+ /** Clear the reaction bubble (after its CSS animation). */
47
+ onFeedbackDone: () => void
48
+ /** Locale translate seat (namespace-bound). */
49
+ t: TranslateNS<typeof NS>
50
+ }
51
+
52
+ /** Clamp a drag offset inside the viewport with a margin. */
53
+ function clampOffset(value: number, max: number): number {
54
+ return Math.max(0, Math.min(max, value))
55
+ }
56
+
57
+ /**
58
+ * The floating pet. The spritesheet frame advances on requestAnimationFrame
59
+ * with per-frame durations from TRACKS; the atlas image is loaded once and
60
+ * the background position is written straight to the sprite element (no
61
+ * per-frame React state).
62
+ */
63
+ export function WhalePet(props: WhalePetProps): ReactPortal {
64
+ const { snapshot, display, feedback } = props
65
+ const spriteRef = useRef<HTMLDivElement | null>(null)
66
+ const floatRef = useRef<HTMLDivElement | null>(null)
67
+ const [imageReady, setImageReady] = useState(false)
68
+ const [frameCounts, setFrameCounts] = useState<number[] | null>(null)
69
+ const [hovered, setHovered] = useState(false)
70
+ const [renaming, setRenaming] = useState(false)
71
+ const [nameDraft, setNameDraft] = useState('')
72
+ const [dragPos, setDragPos] = useState<{ right: number; bottom: number } | null>(null)
73
+ const dragRef = useRef<{ startX: number; startY: number; right: number; bottom: number } | null>(null)
74
+ const frameRef = useRef<{ track: PetAnimation | null; index: number; elapsed: number }>({
75
+ track: null,
76
+ index: 0,
77
+ elapsed: 0,
78
+ })
79
+
80
+ // Load the atlas once; then resolve per-row frame counts so tracks never
81
+ // play the transparent trailing cells of a short row. One decoded Image
82
+ // feeds both the sprite render and the frame-count detection. The counts
83
+ // prefer the authoritatively recorded `frames` field on the pet.json
84
+ // manifest route and only fall back to the getImageData atlas scan when
85
+ // that field is absent (older manifests).
86
+ useEffect(() => {
87
+ let cancelled = false
88
+ const img = new Image()
89
+ img.onload = () => {
90
+ if (cancelled) return
91
+ setImageReady(true)
92
+ fetch(PET_MANIFEST_URL)
93
+ .then((res) => (res.ok ? res.json() : Promise.resolve<{ frames?: unknown }>({})))
94
+ .then((manifest: { frames?: unknown }) => {
95
+ if (cancelled) return
96
+ const frames = manifest.frames
97
+ if (Array.isArray(frames) && frames.length === 9 && frames.every((n) => typeof n === 'number')) {
98
+ setFrameCounts(frames as number[])
99
+ } else {
100
+ setFrameCounts(detectFrameCounts(img))
101
+ }
102
+ })
103
+ .catch(() => {
104
+ if (!cancelled) setFrameCounts(detectFrameCounts(img))
105
+ })
106
+ }
107
+ img.src = PET_SPRITESHEET_URL
108
+ return () => {
109
+ cancelled = true
110
+ img.onload = null
111
+ }
112
+ }, [])
113
+
114
+ // Frame loop: advance the current track and write background-position.
115
+ // Offsets must be in SCALED coordinates (background-position applies to the
116
+ // scaled background image), so the current sprite scale rides a ref that
117
+ // the loop reads every tick. Under prefers-reduced-motion the sprite holds
118
+ // its track's first frame instead of animating (presentation-only; the
119
+ // animation state machine is untouched).
120
+ const spriteScale = display.size / FRAME_HEIGHT
121
+ const animation = snapshot?.animation ?? 'idle'
122
+ const scaleRef = useRef(spriteScale)
123
+ scaleRef.current = spriteScale
124
+ useEffect(() => {
125
+ const reduceMotion = typeof window !== 'undefined'
126
+ && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
127
+ // Paint one static sprite frame up front either way, so the pet is never
128
+ // blank while the loop heat-up runs.
129
+ const row = rowOfTrack(animation)
130
+ const track = frameCounts === null
131
+ ? TRACKS[animation]
132
+ : trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)
133
+ const leadCol = track.frames[0]!
134
+ const lead = framePosition(row, leadCol, scaleRef.current)
135
+ if (spriteRef.current !== null) {
136
+ spriteRef.current.style.backgroundPosition = `${lead.x}px ${lead.y}px`
137
+ }
138
+ if (reduceMotion) return
139
+ let raf = 0
140
+ let last = performance.now()
141
+ const tick = (ts: number): void => {
142
+ const delta = ts - last
143
+ last = ts
144
+ // Trim the track to the row's real frame count (transparent cells
145
+ // would render as a vanishing pet).
146
+ const row = rowOfTrack(animation)
147
+ const track = frameCounts === null
148
+ ? TRACKS[animation]
149
+ : trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)
150
+ const st = frameRef.current
151
+ if (st.track !== animation) {
152
+ st.track = animation
153
+ st.index = 0
154
+ st.elapsed = 0
155
+ }
156
+ st.elapsed += delta
157
+ const maxIndex = track.frames.length - 1
158
+ while (st.elapsed >= (track.durations[st.index] ?? 0) && st.index < maxIndex) {
159
+ st.elapsed -= track.durations[st.index] ?? 0
160
+ st.index += 1
161
+ }
162
+ if (st.elapsed >= (track.durations[st.index] ?? 0)) {
163
+ if (track.loop) {
164
+ st.elapsed = 0
165
+ st.index = 0
166
+ } else {
167
+ st.index = maxIndex // hold the final frame; the host switches tracks
168
+ }
169
+ }
170
+ const col = track.frames[st.index]!
171
+ const { x, y } = framePosition(row, col, scaleRef.current)
172
+ if (spriteRef.current !== null) {
173
+ spriteRef.current.style.backgroundPosition = `${x}px ${y}px`
174
+ }
175
+ raf = requestAnimationFrame(tick)
176
+ }
177
+ raf = requestAnimationFrame(tick)
178
+ return () => cancelAnimationFrame(raf)
179
+ }, [animation, frameCounts])
180
+
181
+ // Auto-clear the feedback bubble after its CSS animation. The callback
182
+ // rides a ref so re-renders never reset the timer: the 800ms poll rebuilds
183
+ // `props` every tick, and depending on it would starve the timeout.
184
+ const feedbackDoneRef = useRef(props.onFeedbackDone)
185
+ feedbackDoneRef.current = props.onFeedbackDone
186
+ useEffect(() => {
187
+ if (feedback === null) return
188
+ const timer = window.setTimeout(() => feedbackDoneRef.current(), 2600)
189
+ return () => window.clearTimeout(timer)
190
+ }, [feedback])
191
+
192
+ // Dragging: pointer events on the sprite; position is right/bottom based.
193
+ // `draggedRef` records whether the pointer actually moved, so the browser's
194
+ // trailing click (fired after pointerup) does not pet the whale.
195
+ const draggedRef = useRef(false)
196
+ const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
197
+ e.preventDefault()
198
+ ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
199
+ const current = dragPos ?? { right: display.right, bottom: display.bottom }
200
+ dragRef.current = { startX: e.clientX, startY: e.clientY, ...current }
201
+ draggedRef.current = false
202
+ setHovered(false)
203
+ }
204
+ const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>): void => {
205
+ const drag = dragRef.current
206
+ if (drag === null) return
207
+ const dx = e.clientX - drag.startX
208
+ const dy = e.clientY - drag.startY
209
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) draggedRef.current = true
210
+ const right = clampOffset(drag.right - dx, window.innerWidth - 40)
211
+ const bottom = clampOffset(drag.bottom - dy, window.innerHeight - 40)
212
+ setDragPos({ right, bottom })
213
+ }
214
+ const onPointerUp = (): void => {
215
+ if (dragRef.current === null) return
216
+ dragRef.current = null
217
+ if (dragPos !== null) props.onDragEnd(dragPos.right, dragPos.bottom)
218
+ }
219
+
220
+ const pos = dragPos ?? { right: display.right, bottom: display.bottom }
221
+ const spriteWidth = Math.round(FRAME_WIDTH * spriteScale)
222
+ const spriteHeight = Math.round(FRAME_HEIGHT * spriteScale)
223
+
224
+ const float = (
225
+ <div
226
+ ref={floatRef}
227
+ className={styles.float}
228
+ style={{ right: pos.right, bottom: pos.bottom, zIndex: 2147483000 }}
229
+ onPointerEnter={() => setHovered(true)}
230
+ onPointerLeave={(e) => {
231
+ // The panel and bubble render OUTSIDE the container's box (absolute,
232
+ // above the sprite), so moving onto them fires pointerleave on the
233
+ // container. Treat a target still inside the container's DOM (the
234
+ // overflowed panel) as "still hovering".
235
+ const next = e.relatedTarget
236
+ if (next instanceof Node && floatRef.current?.contains(next)) return
237
+ setHovered(false)
238
+ }}
239
+ >
240
+ <div
241
+ ref={spriteRef}
242
+ className={styles.sprite}
243
+ style={{
244
+ width: spriteWidth,
245
+ height: spriteHeight,
246
+ backgroundImage: imageReady ? `url(${PET_SPRITESHEET_URL})` : undefined,
247
+ backgroundSize: `${FRAME_WIDTH * FRAME_COLUMNS * spriteScale}px ${FRAME_HEIGHT * 9 * spriteScale}px`,
248
+ backgroundRepeat: 'no-repeat',
249
+ backgroundPosition: '0 0',
250
+ cursor: dragRef.current === null ? 'grab' : 'grabbing',
251
+ }}
252
+ onPointerDown={onPointerDown}
253
+ onPointerMove={onPointerMove}
254
+ onPointerUp={onPointerUp}
255
+ onClick={() => {
256
+ // A pointer sequence that moved (dragged) still fires a trailing
257
+ // click; skip the pet when that happened.
258
+ if (draggedRef.current) return
259
+ props.onPet()
260
+ }}
261
+ role="button"
262
+ aria-label="whale girl"
263
+ />
264
+ {feedback !== null && (
265
+ <div key={feedback.at} className={`${styles.bubble} ${feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet}`}>
266
+ {feedback.text}
267
+ </div>
268
+ )}
269
+ {hovered && dragRef.current === null && (
270
+ <div className={styles.panel}>
271
+ {renaming ? (
272
+ <div className={styles.renameRow}>
273
+ <input
274
+ className={styles.nameInput}
275
+ value={nameDraft}
276
+ maxLength={20}
277
+ placeholder={props.t('pet.namePlaceholder')}
278
+ autoFocus
279
+ onChange={(e) => setNameDraft(e.target.value)}
280
+ onKeyDown={(e) => {
281
+ if (e.key === 'Enter') {
282
+ const trimmed = nameDraft.trim()
283
+ if (trimmed !== '') {
284
+ props.onRename(trimmed)
285
+ setRenaming(false)
286
+ }
287
+ } else if (e.key === 'Escape') {
288
+ setRenaming(false)
289
+ }
290
+ }}
291
+ />
292
+ <button
293
+ type="button"
294
+ className={styles.action}
295
+ onClick={() => {
296
+ const trimmed = nameDraft.trim()
297
+ if (trimmed !== '') {
298
+ props.onRename(trimmed)
299
+ setRenaming(false)
300
+ }
301
+ }}
302
+ >
303
+ {props.t('pet.confirm')}
304
+ </button>
305
+ </div>
306
+ ) : (
307
+ <>
308
+ <div className={styles.rankRow}>
309
+ <span className={styles.nameCell}>{snapshot?.name ?? '鲸鱼娘'}</span>
310
+ <span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
311
+ </div>
312
+ <div className={styles.rankRow}>
313
+ <span>{props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
314
+ <span>{props.t('pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
315
+ </div>
316
+ <div className={styles.actions}>
317
+ <button type="button" className={styles.action} onClick={props.onFeed}>
318
+ {props.t('pet.feed')}
319
+ </button>
320
+ <button
321
+ type="button"
322
+ className={styles.action}
323
+ onClick={() => {
324
+ setNameDraft(snapshot?.name ?? '')
325
+ setRenaming(true)
326
+ }}
327
+ >
328
+ {props.t('pet.rename')}
329
+ </button>
330
+ <button type="button" className={styles.action} onClick={props.onHide}>
331
+ {props.t('pet.hide')}
332
+ </button>
333
+ </div>
334
+ </>
335
+ )}
336
+ </div>
337
+ )}
338
+ </div>
339
+ )
340
+
341
+ return createPortal(float, document.body)
342
+ }
@@ -0,0 +1,6 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>
3
+ export default classes
4
+ }
5
+
6
+ declare module '*.css'