@linxin666/dsh-pet 0.3.21 → 0.3.23
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/README.i18n.yaml +2 -2
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/lib/client.js +73 -10
- package/lib/client.js.map +1 -1
- package/lib/index.js +10 -3
- package/lib/types/client/PetDockEntry.js +1 -1
- package/lib/types/client/PetSettingsCard.d.ts +4 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +3 -1
- package/lib/types/client/PetSprite.d.ts +1 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +45 -8
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -1
- package/lib/types/persist.d.ts +32 -0
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +37 -0
- package/lib/types/service.d.ts +3 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +4 -2
- package/package.json +1 -1
- package/src/client/PetDockEntry.test.tsx +1 -1
- package/src/client/PetDockEntry.tsx +1 -1
- package/src/client/PetSettingsCard.tsx +16 -0
- package/src/client/PetSprite.test.tsx +63 -1
- package/src/client/PetSprite.tsx +45 -8
- package/src/client/gameplay-hud.test.tsx +1 -1
- package/src/client/locales.ts +4 -0
- package/src/client/pet-css.test.ts +14 -0
- package/src/client/pet-store.test.ts +1 -1
- package/src/client/pet.module.css +7 -4
- package/src/index.ts +2 -1
- package/src/persist.test.ts +53 -1
- package/src/persist.ts +46 -0
- package/src/service.ts +7 -1
|
@@ -61,7 +61,7 @@ const snapshot: PetStateView = {
|
|
|
61
61
|
petCooldown: false,
|
|
62
62
|
feedCooldown: false,
|
|
63
63
|
},
|
|
64
|
-
display: { visible: true, size: 160, right: 24, bottom: 20 },
|
|
64
|
+
display: { visible: true, size: 160, right: 24, bottom: 20, bubbleScale: 1 },
|
|
65
65
|
pet: { id: 'whale-girl', displayName: '鲸鱼娘', description: '测试用鲸鱼娘' },
|
|
66
66
|
name: '泡泡',
|
|
67
67
|
treats: { stocked: 3, max: 5 },
|
|
@@ -858,6 +858,68 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
|
858
858
|
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
859
859
|
})
|
|
860
860
|
|
|
861
|
+
it('advances uneven per-frame durations on each frame own clock', () => {
|
|
862
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
863
|
+
matches: false,
|
|
864
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
865
|
+
onchange: null,
|
|
866
|
+
addEventListener: () => {},
|
|
867
|
+
removeEventListener: () => {},
|
|
868
|
+
addListener: () => {},
|
|
869
|
+
removeListener: () => {},
|
|
870
|
+
dispatchEvent: () => false,
|
|
871
|
+
})
|
|
872
|
+
let now = 0
|
|
873
|
+
vi.spyOn(performance, 'now').mockImplementation(() => now)
|
|
874
|
+
const timers: { at: number; callback: () => void }[] = []
|
|
875
|
+
let timerId = 0
|
|
876
|
+
vi.spyOn(window, 'setTimeout').mockImplementation(((callback: () => void, delay = 0) => {
|
|
877
|
+
timers.push({ at: now + delay, callback })
|
|
878
|
+
return ++timerId
|
|
879
|
+
}) as typeof window.setTimeout)
|
|
880
|
+
vi.spyOn(window, 'clearTimeout').mockImplementation(() => {})
|
|
881
|
+
const frames: FrameRequestCallback[] = []
|
|
882
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
883
|
+
frames.push(callback)
|
|
884
|
+
return frames.length
|
|
885
|
+
})
|
|
886
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
887
|
+
const uneven: DecorationView = { ...decoration, durations: [100, 300, 50, 50] }
|
|
888
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration: uneven } })
|
|
889
|
+
const el = ornament()!
|
|
890
|
+
const step = (ms: number): void => {
|
|
891
|
+
for (const callback of frames.splice(0)) callback(now)
|
|
892
|
+
now += ms
|
|
893
|
+
for (;;) {
|
|
894
|
+
const due = timers.filter(t => t.at <= now)
|
|
895
|
+
if (due.length === 0) break
|
|
896
|
+
for (const t of due) {
|
|
897
|
+
const idx = timers.indexOf(t)
|
|
898
|
+
if (idx >= 0) timers.splice(idx, 1)
|
|
899
|
+
t.callback()
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
904
|
+
// One 500 ms segment (100 + 300 + 50 + 50) plus 1 ms: the catch-up loop
|
|
905
|
+
// must subtract each frame's own duration, so it lands one full cycle
|
|
906
|
+
// later on frame 0 with 99 ms of its 100 ms left — not on frame 1.
|
|
907
|
+
act(() => { step(501) })
|
|
908
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
909
|
+
act(() => { step(99) })
|
|
910
|
+
expect(el.style.backgroundPosition).toBe('-24px 0px')
|
|
911
|
+
// Frame 1 lasts 300 ms and the next wake uses that duration.
|
|
912
|
+
act(() => { step(299) })
|
|
913
|
+
expect(el.style.backgroundPosition).toBe('-24px 0px')
|
|
914
|
+
act(() => { step(1) })
|
|
915
|
+
expect(el.style.backgroundPosition).toBe('-48px 0px')
|
|
916
|
+
// Frames 2 and 3 are 50 ms each.
|
|
917
|
+
act(() => { step(50) })
|
|
918
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
919
|
+
act(() => { step(50) })
|
|
920
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
921
|
+
})
|
|
922
|
+
|
|
861
923
|
it('holds the segment last frame when the loop is off and stops scheduling', () => {
|
|
862
924
|
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
863
925
|
matches: false,
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactElement, Re
|
|
|
14
14
|
import { createPortal } from 'react-dom'
|
|
15
15
|
import clsx from 'clsx'
|
|
16
16
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
17
|
-
import type
|
|
17
|
+
import { bubbleScaleFor, type PetDisplayConfig } from '../persist.ts'
|
|
18
18
|
import type { PetStateView } from '../service.ts'
|
|
19
19
|
import { announcementFresh, type PetAnnouncement } from '../announce.ts'
|
|
20
20
|
import type { PetDefinition } from '../registry.ts'
|
|
@@ -139,7 +139,7 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
|
|
|
139
139
|
const delta = now - last
|
|
140
140
|
last = now
|
|
141
141
|
elapsed += delta
|
|
142
|
-
|
|
142
|
+
let duration = decoration.durations[index] ?? 120
|
|
143
143
|
// The segment's frame rate (duration ms, typically 90-160) is far
|
|
144
144
|
// below the rAF cadence, so a 60fps loop would spend ~90% of its
|
|
145
145
|
// ticks doing nothing. Schedule by the remaining time to the next
|
|
@@ -151,6 +151,10 @@ function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhas
|
|
|
151
151
|
elapsed -= duration
|
|
152
152
|
if (index < segment.to) index += 1
|
|
153
153
|
else if (decoration.loop) index = segment.from
|
|
154
|
+
// Durations are per frame: a catch-up that crosses frames must
|
|
155
|
+
// subtract and schedule with the frame it lands on, not the one
|
|
156
|
+
// the tick started from.
|
|
157
|
+
duration = decoration.durations[index] ?? 120
|
|
154
158
|
} while (elapsed >= duration)
|
|
155
159
|
// Only advance the background when the frame actually changes.
|
|
156
160
|
el.style.backgroundPosition = position(index)
|
|
@@ -311,15 +315,39 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
311
315
|
// custom visual (pet-center M3) replaces the atlas entirely.
|
|
312
316
|
useEffect(() => {
|
|
313
317
|
if (props.visual !== undefined) return
|
|
318
|
+
setImageReady(false)
|
|
314
319
|
let cancelled = false
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
320
|
+
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
|
321
|
+
let attempt = 0
|
|
322
|
+
const maxAttempts = 3
|
|
323
|
+
let activeImg: HTMLImageElement | null = null
|
|
324
|
+
|
|
325
|
+
const loadAtlas = () => {
|
|
326
|
+
const img = new Image()
|
|
327
|
+
activeImg = img
|
|
328
|
+
img.onload = () => {
|
|
329
|
+
if (!cancelled) setImageReady(true)
|
|
330
|
+
}
|
|
331
|
+
img.onerror = () => {
|
|
332
|
+
if (cancelled) return
|
|
333
|
+
if (attempt < maxAttempts) {
|
|
334
|
+
attempt += 1
|
|
335
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 8000)
|
|
336
|
+
retryTimer = setTimeout(loadAtlas, delay)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
img.src = definition.atlasUrl
|
|
318
340
|
}
|
|
319
|
-
|
|
341
|
+
|
|
342
|
+
loadAtlas()
|
|
343
|
+
|
|
320
344
|
return () => {
|
|
321
345
|
cancelled = true
|
|
322
|
-
|
|
346
|
+
if (retryTimer !== undefined) clearTimeout(retryTimer)
|
|
347
|
+
if (activeImg !== null) {
|
|
348
|
+
activeImg.onload = null
|
|
349
|
+
activeImg.onerror = null
|
|
350
|
+
}
|
|
323
351
|
}
|
|
324
352
|
}, [definition.atlasUrl, props.visual])
|
|
325
353
|
|
|
@@ -484,6 +512,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
484
512
|
const pos = dragPos ?? { right: display.right, bottom: display.bottom }
|
|
485
513
|
const spriteWidth = Math.round(cell.width * spriteScale)
|
|
486
514
|
const spriteHeight = Math.round(cell.height * spriteScale)
|
|
515
|
+
// Bubble typography follows the sprite's own scale (#1549), bounded so a
|
|
516
|
+
// shrunk pet never carries unreadably small text.
|
|
517
|
+
const bubbleScale = bubbleScaleFor(display)
|
|
487
518
|
|
|
488
519
|
// Concurrent sessions share one bubble slot: only the display session
|
|
489
520
|
// speaks by default, and the rest hide behind a '+N' badge until the stack
|
|
@@ -549,7 +580,13 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
549
580
|
<div
|
|
550
581
|
ref={floatRef}
|
|
551
582
|
className={styles.float}
|
|
552
|
-
style={{
|
|
583
|
+
style={{
|
|
584
|
+
right: pos.right,
|
|
585
|
+
bottom: pos.bottom,
|
|
586
|
+
zIndex: 2147483000,
|
|
587
|
+
// Read by .bubble / .bubbleStatus in pet.module.css.
|
|
588
|
+
...({ '--pet-bubble-scale': String(bubbleScale) } as CSSProperties),
|
|
589
|
+
}}
|
|
553
590
|
onPointerEnter={() => {
|
|
554
591
|
clearHideTimer()
|
|
555
592
|
setHovered(true)
|
|
@@ -117,7 +117,7 @@ function snapshot(view: PetGameplayStateView, skin?: string): PetStateView {
|
|
|
117
117
|
sessionActive: false,
|
|
118
118
|
sessions: [],
|
|
119
119
|
affinity: { points: 0, rank: '幼鲸', rankEmoji: '*', pets: 0, feeds: 0, turns: 0, petCooldown: false, feedCooldown: false },
|
|
120
|
-
display: { visible: true, size: 160, right: 24, bottom: 20 },
|
|
120
|
+
display: { visible: true, size: 160, right: 24, bottom: 20, bubbleScale: 1 },
|
|
121
121
|
pet: { id: 'miku', displayName: 'Miku', description: '' },
|
|
122
122
|
name: 'Miku',
|
|
123
123
|
treats: { stocked: 0, max: 5 },
|
package/src/client/locales.ts
CHANGED
|
@@ -69,6 +69,8 @@ export const zh = {
|
|
|
69
69
|
'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
|
|
70
70
|
'settings.size': '大小(px)',
|
|
71
71
|
'settings.sizeHint': '精灵单元高度,范围 32–1024。',
|
|
72
|
+
'settings.bubbleScale': '气泡字号倍率',
|
|
73
|
+
'settings.bubbleScaleHint': '状态、碎碎念与用量气泡的字号会随宠物大小自动缩放;这一项是在此基础上再乘一个倍率(0.5–2,默认 1)。字号本身不小于 10px、不大于 24px。',
|
|
72
74
|
'settings.right': '距右侧(px)',
|
|
73
75
|
'settings.rightHint': '距视口右边缘的水平内缩距离。',
|
|
74
76
|
'settings.bottom': '距底部(px)',
|
|
@@ -153,6 +155,8 @@ export const en = {
|
|
|
153
155
|
'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
|
|
154
156
|
'settings.size': 'Size (px)',
|
|
155
157
|
'settings.sizeHint': 'Sprite cell height, 32\u20131024.',
|
|
158
|
+
'settings.bubbleScale': 'Bubble text multiplier',
|
|
159
|
+
'settings.bubbleScaleHint': 'Status, whisper, and usage bubbles scale with the pet automatically; this multiplies that result (0.5\u20132, default 1). The rendered text stays between 10px and 24px.',
|
|
156
160
|
'settings.right': 'Right inset (px)',
|
|
157
161
|
'settings.rightHint': 'Horizontal inset from the viewport right edge.',
|
|
158
162
|
'settings.bottom': 'Bottom inset (px)',
|
|
@@ -3,6 +3,20 @@ import { describe, expect, it } from 'vitest'
|
|
|
3
3
|
|
|
4
4
|
const css = readFileSync(new URL('./pet.module.css', import.meta.url), 'utf8')
|
|
5
5
|
|
|
6
|
+
describe('pet bubble typography (#1549)', () => {
|
|
7
|
+
it('scales the bubble text and padding from the sprite-supplied variable', () => {
|
|
8
|
+
const bubble = css.match(/\.bubble\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
9
|
+
expect(bubble).toContain('font-size: calc(12px * var(--pet-bubble-scale, 1))')
|
|
10
|
+
expect(bubble).toContain('padding: calc(4px * var(--pet-bubble-scale, 1)) calc(10px * var(--pet-bubble-scale, 1))')
|
|
11
|
+
expect(bubble).not.toContain('font-size: 12px')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('keeps the status bubble width in proportion with its text', () => {
|
|
15
|
+
const status = css.match(/\.bubbleStatus\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
16
|
+
expect(status).toContain('max-width: min(calc(280px * var(--pet-bubble-scale, 1)), calc(100vw - 24px))')
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
6
20
|
describe('pet hover panel css', () => {
|
|
7
21
|
it('anchors the panel below the pet', () => {
|
|
8
22
|
const panel = css.match(/\.panel\s*\{([^}]*)\}/)?.[1] ?? ''
|
|
@@ -21,7 +21,7 @@ const snapshot = (over: Partial<PetStateView> = {}): PetStateView => ({
|
|
|
21
21
|
petCooldown: false,
|
|
22
22
|
feedCooldown: false,
|
|
23
23
|
},
|
|
24
|
-
display: { visible: true, size: 160, right: 24, bottom: 120 },
|
|
24
|
+
display: { visible: true, size: 160, right: 24, bottom: 120, bubbleScale: 1 },
|
|
25
25
|
pet: { id: 'whale-girl', displayName: 'Whale', description: '' },
|
|
26
26
|
name: 'Whale',
|
|
27
27
|
treats: { stocked: 2, max: 10 },
|
|
@@ -26,9 +26,12 @@
|
|
|
26
26
|
bottom: 100%;
|
|
27
27
|
margin-bottom: 6px;
|
|
28
28
|
white-space: nowrap;
|
|
29
|
-
|
|
29
|
+
/* Bubble typography follows the pet's own scale (#1549): the sprite sets
|
|
30
|
+
--pet-bubble-scale from its display config, and 1 keeps the 12px baseline
|
|
31
|
+
this stylesheet was drawn for. */
|
|
32
|
+
padding: calc(4px * var(--pet-bubble-scale, 1)) calc(10px * var(--pet-bubble-scale, 1));
|
|
30
33
|
border-radius: 999px;
|
|
31
|
-
font-size: 12px;
|
|
34
|
+
font-size: calc(12px * var(--pet-bubble-scale, 1));
|
|
32
35
|
line-height: 1.4;
|
|
33
36
|
color: #f4f7ff;
|
|
34
37
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
|
@@ -50,10 +53,10 @@
|
|
|
50
53
|
碎碎念不再单独渲染第二只气泡,也不换色调,而是以 .bubbleWhisper
|
|
51
54
|
接管这同一只气泡的文案,气泡栈内所有气泡共用同一片玻璃。 */
|
|
52
55
|
.bubbleStatus {
|
|
53
|
-
max-width: min(280px, calc(100vw - 24px));
|
|
56
|
+
max-width: min(calc(280px * var(--pet-bubble-scale, 1)), calc(100vw - 24px));
|
|
54
57
|
overflow: hidden;
|
|
55
58
|
text-overflow: ellipsis;
|
|
56
|
-
padding: 5px 12px;
|
|
59
|
+
padding: calc(5px * var(--pet-bubble-scale, 1)) calc(12px * var(--pet-bubble-scale, 1));
|
|
57
60
|
background: linear-gradient(160deg, rgba(19, 28, 54, 0.9), rgba(7, 11, 26, 0.94));
|
|
58
61
|
border: 1px solid rgba(126, 152, 255, 0.45);
|
|
59
62
|
box-shadow:
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,7 @@ import z from 'schemastery'
|
|
|
18
18
|
import { PetService, PET_SETTINGS_NAMESPACE, type PetConfig, type PetSettingsSection } from './service.ts'
|
|
19
19
|
import { makePetRoutes } from './routes.ts'
|
|
20
20
|
import { loadPetRegistry, petPackageRoot } from './registry.ts'
|
|
21
|
-
import { DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
|
|
21
|
+
import { BUBBLE_SCALE_MAX, BUBBLE_SCALE_MIN, DISPLAY_INSET_MAX, DISPLAY_SIZE_MAX, DISPLAY_SIZE_MIN } from './persist.ts'
|
|
22
22
|
import { mountOnce } from './mount-once.ts'
|
|
23
23
|
|
|
24
24
|
export { PetService, MAX_SESSION_BUBBLES } from './service.ts'
|
|
@@ -132,6 +132,7 @@ export function makePetSettingsSchema(fallbackPetId: string) {
|
|
|
132
132
|
size: z.number().step(1).min(DISPLAY_SIZE_MIN).max(DISPLAY_SIZE_MAX).default(160),
|
|
133
133
|
right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
|
|
134
134
|
bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
|
|
135
|
+
bubbleScale: z.number().step(0.05).min(BUBBLE_SCALE_MIN).max(BUBBLE_SCALE_MAX).default(1),
|
|
135
136
|
petId: z.string().default(fallbackPetId),
|
|
136
137
|
enabled: z.boolean().default(true),
|
|
137
138
|
decorationEnabled: z.boolean().default(true),
|
package/src/persist.test.ts
CHANGED
|
@@ -5,6 +5,11 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { AFFINITY_MAX, emptyAffinity } from './affinity.ts'
|
|
6
6
|
import { defaultTreatConfig, emptyTreatLedger } from './treats.ts'
|
|
7
7
|
import {
|
|
8
|
+
BUBBLE_FONT_MAX_PX,
|
|
9
|
+
BUBBLE_FONT_MIN_PX,
|
|
10
|
+
BUBBLE_SCALE_MAX,
|
|
11
|
+
BUBBLE_SCALE_MIN,
|
|
12
|
+
bubbleScaleFor,
|
|
8
13
|
DEFAULT_PET_ID,
|
|
9
14
|
DISPLAY_INSET_MAX,
|
|
10
15
|
DISPLAY_SIZE_MAX,
|
|
@@ -52,7 +57,7 @@ describe('loadPetPersist', () => {
|
|
|
52
57
|
skins: { otter: 'lanhainishang' },
|
|
53
58
|
affinity: { ...emptyAffinity(), points: 42, pets: 3, feeds: 1, turns: 10 },
|
|
54
59
|
treats: { ...emptyTreatLedger(), treats: 7, lastTreatGrantAt: 1234, turnsAtLastTreatGrant: 9 },
|
|
55
|
-
display: { visible: false, size: 200, right: 10, bottom: 40 },
|
|
60
|
+
display: { visible: false, size: 200, right: 10, bottom: 40, bubbleScale: 1.25 },
|
|
56
61
|
gameplay: {
|
|
57
62
|
otter: { stats: { hunger: 55.5 }, currencies: { coins: 12 }, mode: 'work' as const, settledAt: 777 },
|
|
58
63
|
},
|
|
@@ -164,6 +169,53 @@ describe('loadPetPersist', () => {
|
|
|
164
169
|
}
|
|
165
170
|
})
|
|
166
171
|
|
|
172
|
+
it('defaults the bubble scale to the untouched baseline (#1549)', () => {
|
|
173
|
+
const dir = tempDir()
|
|
174
|
+
try {
|
|
175
|
+
expect(loadPetPersist(dir).display.bubbleScale).toBe(1)
|
|
176
|
+
} finally {
|
|
177
|
+
rmSync(dir, { recursive: true, force: true })
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('clamps a persisted bubble scale into the supported range (#1549)', () => {
|
|
182
|
+
const dir = tempDir()
|
|
183
|
+
try {
|
|
184
|
+
writeFileSync(join(dir, 'pet.json'), JSON.stringify({ display: { visible: true, size: 160, right: 0, bottom: 0, bubbleScale: 99 } }), 'utf8')
|
|
185
|
+
expect(loadPetPersist(dir).display.bubbleScale).toBe(BUBBLE_SCALE_MAX)
|
|
186
|
+
writeFileSync(join(dir, 'pet.json'), JSON.stringify({ display: { visible: true, size: 160, right: 0, bottom: 0, bubbleScale: 'big' } }), 'utf8')
|
|
187
|
+
expect(loadPetPersist(dir).display.bubbleScale).toBe(defaultDisplayConfig.bubbleScale)
|
|
188
|
+
writeFileSync(join(dir, 'pet.json'), JSON.stringify({ display: { visible: true, size: 160, right: 0, bottom: 0, bubbleScale: -3 } }), 'utf8')
|
|
189
|
+
expect(loadPetPersist(dir).display.bubbleScale).toBe(BUBBLE_SCALE_MIN)
|
|
190
|
+
} finally {
|
|
191
|
+
rmSync(dir, { recursive: true, force: true })
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('follows the pet size and stays inside the readable band (#1549)', () => {
|
|
196
|
+
// The baseline the stylesheet was drawn for: 12px at the default 160px pet.
|
|
197
|
+
expect(bubbleScaleFor({ size: 160, bubbleScale: 1 })).toBe(1)
|
|
198
|
+
// A shrunk pet shrinks the bubble, but never below the floor (the ratio
|
|
199
|
+
// is rounded to two decimals so the same config always paints the same).
|
|
200
|
+
expect(bubbleScaleFor({ size: 100, bubbleScale: 1 })).toBe(0.83)
|
|
201
|
+
expect(bubbleScaleFor({ size: 32, bubbleScale: 1 })).toBe(0.83)
|
|
202
|
+
// An enlarged pet caps the bubble instead of growing it without bound.
|
|
203
|
+
expect(bubbleScaleFor({ size: 1024, bubbleScale: 1 })).toBe(BUBBLE_FONT_MAX_PX / 12)
|
|
204
|
+
// The user's multiplier rides on top of the automatic following.
|
|
205
|
+
expect(bubbleScaleFor({ size: 160, bubbleScale: 2 })).toBe(2)
|
|
206
|
+
expect(bubbleScaleFor({ size: 160, bubbleScale: 0.5 })).toBe(0.83)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('keeps the baseline when a host omits or corrupts the bubble scale (#1549)', () => {
|
|
210
|
+
// A host that predates the field serves no bubbleScale. NaN would reach
|
|
211
|
+
// --pet-bubble-scale and collapse every bubble's text to zero.
|
|
212
|
+
expect(bubbleScaleFor({ size: 160 })).toBe(1)
|
|
213
|
+
expect(bubbleScaleFor({ size: 160, bubbleScale: Number.NaN })).toBe(1)
|
|
214
|
+
expect(bubbleScaleFor({ size: Number.NaN, bubbleScale: 1 })).toBe(1)
|
|
215
|
+
// A corrupt size must not leak through either; the multiplier still applies.
|
|
216
|
+
expect(bubbleScaleFor({ size: Number.POSITIVE_INFINITY, bubbleScale: 2 })).toBe(2)
|
|
217
|
+
})
|
|
218
|
+
|
|
167
219
|
it('clamps oversized display size to the max', () => {
|
|
168
220
|
const dir = tempDir()
|
|
169
221
|
try {
|
package/src/persist.ts
CHANGED
|
@@ -25,6 +25,13 @@ export interface PetDisplayConfig {
|
|
|
25
25
|
right: number
|
|
26
26
|
/** Vertical inset from the viewport bottom edge, px. */
|
|
27
27
|
bottom: number
|
|
28
|
+
/**
|
|
29
|
+
* Multiplier on the bubble typography's automatic size following (#1549).
|
|
30
|
+
* The rendered bubble scale is `size / 160 * bubbleScale`, bounded by
|
|
31
|
+
* {@link BUBBLE_FONT_MIN_PX}..{@link BUBBLE_FONT_MAX_PX}, so 1 keeps the
|
|
32
|
+
* 12px baseline the stylesheet was drawn for at the default 160px pet.
|
|
33
|
+
*/
|
|
34
|
+
bubbleScale: number
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export const defaultDisplayConfig: PetDisplayConfig = {
|
|
@@ -32,6 +39,7 @@ export const defaultDisplayConfig: PetDisplayConfig = {
|
|
|
32
39
|
size: 160,
|
|
33
40
|
right: 24,
|
|
34
41
|
bottom: 120,
|
|
42
|
+
bubbleScale: 1,
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
/** Display value bounds (shared by load-time validation and setConfig). */
|
|
@@ -39,6 +47,42 @@ export const DISPLAY_SIZE_MIN = 32
|
|
|
39
47
|
export const DISPLAY_SIZE_MAX = 1024
|
|
40
48
|
export const DISPLAY_INSET_MAX = 10_000
|
|
41
49
|
|
|
50
|
+
/** Bubble typography bounds (issue #1549). */
|
|
51
|
+
export const BUBBLE_SCALE_MIN = 0.5
|
|
52
|
+
export const BUBBLE_SCALE_MAX = 2
|
|
53
|
+
/** Pixel size the bubble stylesheet was drawn for, at the default pet size. */
|
|
54
|
+
export const BUBBLE_BASE_FONT_PX = 12
|
|
55
|
+
/** Pet size that baseline matches; other sizes scale the bubble with them. */
|
|
56
|
+
export const BUBBLE_BASE_SIZE_PX = 160
|
|
57
|
+
/** Readability floor and layout ceiling of the scaled bubble text. */
|
|
58
|
+
export const BUBBLE_FONT_MIN_PX = 10
|
|
59
|
+
export const BUBBLE_FONT_MAX_PX = 24
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Bubble typography scale for one display config (issue #1549): the bubble
|
|
63
|
+
* follows the pet's own size so a shrunk pet does not carry a full-size
|
|
64
|
+
* bubble, and the user's multiplier rides on top. The result is a CSS ratio
|
|
65
|
+
* against {@link BUBBLE_BASE_FONT_PX}, bounded so the text never drops below
|
|
66
|
+
* the readability floor or outgrows the pet. `bubbleScale` is optional at
|
|
67
|
+
* runtime: a host that predates the field (a rolling update, or any snapshot
|
|
68
|
+
* that omits it) falls back to the baseline, because a NaN ratio written into
|
|
69
|
+
* `--pet-bubble-scale` collapses every bubble's text to zero.
|
|
70
|
+
* @param display - display config; `bubbleScale` may be absent on older hosts.
|
|
71
|
+
* @returns the ratio written to `--pet-bubble-scale` (always finite).
|
|
72
|
+
*/
|
|
73
|
+
export function bubbleScaleFor(display: Pick<PetDisplayConfig, 'size'> & { bubbleScale?: number }): number {
|
|
74
|
+
// Guard the arithmetic, not just the clamp: Math.min/max propagate NaN, so a
|
|
75
|
+
// missing or non-finite field would otherwise reach the stylesheet.
|
|
76
|
+
const size = Number.isFinite(display.size) ? display.size : BUBBLE_BASE_SIZE_PX
|
|
77
|
+
const multiplier = typeof display.bubbleScale === 'number' && Number.isFinite(display.bubbleScale)
|
|
78
|
+
? display.bubbleScale
|
|
79
|
+
: 1
|
|
80
|
+
const scaled = (size / BUBBLE_BASE_SIZE_PX) * multiplier
|
|
81
|
+
const min = BUBBLE_FONT_MIN_PX / BUBBLE_BASE_FONT_PX
|
|
82
|
+
const max = BUBBLE_FONT_MAX_PX / BUBBLE_BASE_FONT_PX
|
|
83
|
+
return Math.round(Math.min(max, Math.max(min, scaled)) * 100) / 100
|
|
84
|
+
}
|
|
85
|
+
|
|
42
86
|
/** Everything persisted for the pet. */
|
|
43
87
|
export interface PetPersist {
|
|
44
88
|
/** Selected pet id (a registry entry; clamped at service startup). */
|
|
@@ -198,6 +242,8 @@ export function loadPetPersist(dir: string = petHomeDir()): PetPersist {
|
|
|
198
242
|
size: Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, finiteNum(rawDisplay.size, base.display.size)))),
|
|
199
243
|
right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), DISPLAY_INSET_MAX)),
|
|
200
244
|
bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), DISPLAY_INSET_MAX)),
|
|
245
|
+
// Fractional on purpose: the multiplier is a ratio, not a pixel count.
|
|
246
|
+
bubbleScale: Math.min(BUBBLE_SCALE_MAX, Math.max(BUBBLE_SCALE_MIN, finiteNum(rawDisplay.bubbleScale, base.display.bubbleScale))),
|
|
201
247
|
}
|
|
202
248
|
const petId = typeof parsed.petId === 'string' && parsed.petId.trim() !== ''
|
|
203
249
|
? parsed.petId.trim()
|
package/src/service.ts
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
DEFAULT_PET_NAME,
|
|
32
32
|
DISPLAY_INSET_MAX,
|
|
33
33
|
DISPLAY_SIZE_MAX,
|
|
34
|
+
BUBBLE_SCALE_MAX,
|
|
35
|
+
BUBBLE_SCALE_MIN,
|
|
34
36
|
DISPLAY_SIZE_MIN,
|
|
35
37
|
PET_NAME_MAX_LENGTH,
|
|
36
38
|
loadPetPersist,
|
|
@@ -111,6 +113,8 @@ export interface PetSettingsSection {
|
|
|
111
113
|
right: number
|
|
112
114
|
/** Vertical inset from the viewport bottom edge, px. */
|
|
113
115
|
bottom: number
|
|
116
|
+
/** Bubble typography multiplier (#1549); see PetDisplayConfig. */
|
|
117
|
+
bubbleScale?: number
|
|
114
118
|
/** Master switch for the plugin (browser half + host routes). */
|
|
115
119
|
enabled?: boolean
|
|
116
120
|
/**
|
|
@@ -756,12 +760,13 @@ export class PetService extends Service {
|
|
|
756
760
|
return { ok: true, skin }
|
|
757
761
|
}
|
|
758
762
|
|
|
759
|
-
/** RPC: update display config (size / position).
|
|
763
|
+
/** RPC: update display config (size / position / bubble scale). Pixel values are clamped to whole pixels. */
|
|
760
764
|
async setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }> {
|
|
761
765
|
const next = { ...this.ledger.snapshot.display, ...patch }
|
|
762
766
|
next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, next.size)))
|
|
763
767
|
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.right)))
|
|
764
768
|
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, next.bottom)))
|
|
769
|
+
next.bubbleScale = Math.min(BUBBLE_SCALE_MAX, Math.max(BUBBLE_SCALE_MIN, next.bubbleScale))
|
|
765
770
|
this.ledger.setDisplay(next)
|
|
766
771
|
this.flush()
|
|
767
772
|
this.syncSettingsFromPet()
|
|
@@ -800,6 +805,7 @@ export class PetService extends Service {
|
|
|
800
805
|
next.size = Math.round(Math.min(DISPLAY_SIZE_MAX, Math.max(DISPLAY_SIZE_MIN, section.size)))
|
|
801
806
|
next.right = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.right)))
|
|
802
807
|
next.bottom = Math.round(Math.min(DISPLAY_INSET_MAX, Math.max(0, section.bottom)))
|
|
808
|
+
next.bubbleScale = Math.min(BUBBLE_SCALE_MAX, Math.max(BUBBLE_SCALE_MIN, section.bubbleScale ?? next.bubbleScale))
|
|
803
809
|
this.ledger.setDisplay(next)
|
|
804
810
|
this.flush()
|
|
805
811
|
}
|