@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.
- package/LICENSE +29 -0
- package/README.md +106 -0
- package/assets/whale/pet.json +7 -0
- package/assets/whale/previews/failed.gif +0 -0
- package/assets/whale/previews/idle.gif +0 -0
- package/assets/whale/previews/jumping.gif +0 -0
- package/assets/whale/previews/review.gif +0 -0
- package/assets/whale/previews/running-left.gif +0 -0
- package/assets/whale/previews/running-right.gif +0 -0
- package/assets/whale/previews/running.gif +0 -0
- package/assets/whale/previews/waiting.gif +0 -0
- package/assets/whale/previews/waving.gif +0 -0
- package/assets/whale/spritesheet.webp +0 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +1515 -0
- package/lib/index.js +642 -0
- package/lib/invariant.js +34 -0
- package/lib/types/affinity.d.ts +83 -0
- package/lib/types/affinity.d.ts.map +1 -0
- package/lib/types/client/PetDockEntry.d.ts +44 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -0
- package/lib/types/client/PetSettingsCard.d.ts +67 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/WhalePet.d.ts +49 -0
- package/lib/types/client/WhalePet.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +46 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +101 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pet-store.d.ts +49 -0
- package/lib/types/client/pet-store.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/client/slots-augment.d.ts +19 -0
- package/lib/types/client/slots-augment.d.ts.map +1 -0
- package/lib/types/client/spritesheet.d.ts +69 -0
- package/lib/types/client/spritesheet.d.ts.map +1 -0
- package/lib/types/index.d.ts +44 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +10 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/persist.d.ts +45 -0
- package/lib/types/persist.d.ts.map +1 -0
- package/lib/types/routes.d.ts +22 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/service.d.ts +161 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/state.d.ts +80 -0
- package/lib/types/state.d.ts.map +1 -0
- package/lib/types/treats.d.ts +59 -0
- package/lib/types/treats.d.ts.map +1 -0
- package/package.json +100 -0
- package/src/affinity.test.ts +74 -0
- package/src/affinity.ts +144 -0
- package/src/client/PetDockEntry.tsx +95 -0
- package/src/client/PetSettingsCard.tsx +186 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/WhalePet.tsx +342 -0
- package/src/client/css-modules.d.ts +6 -0
- package/src/client/index.ts +261 -0
- package/src/client/locales.ts +108 -0
- package/src/client/pet-store.ts +80 -0
- package/src/client/pet.module.css +185 -0
- package/src/client/settings-card.module.css +277 -0
- package/src/client/settings-form.ts +294 -0
- package/src/client/slots-augment.ts +27 -0
- package/src/client/spritesheet.ts +138 -0
- package/src/index.ts +148 -0
- package/src/invariant.ts +40 -0
- package/src/persist.test.ts +96 -0
- package/src/persist.ts +128 -0
- package/src/routes.ts +171 -0
- package/src/service.ts +389 -0
- package/src/state.test.ts +65 -0
- package/src/state.ts +156 -0
- package/src/treats.test.ts +86 -0
- 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
|
+
}
|