@hayasaka7/haya-pet 0.2.2 → 0.2.3
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/CHANGELOG.md +9 -0
- package/README.md +4 -0
- package/apps/companion/src/main/index.js +35 -10
- package/apps/companion/src/main/preload.cjs +1 -0
- package/apps/companion/src/renderer/index.html +6 -2
- package/apps/companion/src/renderer/pet-window.js +77 -5
- package/apps/companion/src/renderer/styles.css +25 -1
- package/assets/fallback-pet/spritesheet.webp +0 -0
- package/package.json +1 -1
- package/packages/app-state/src/state.js +17 -0
- package/packages/app-state/test/state.test.mjs +37 -0
- package/packages/pet-core/src/pet-scale.js +34 -0
- package/packages/pet-core/test/pet-scale.test.mjs +94 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ All notable changes to Haya Pet are documented here. This project adheres to
|
|
|
7
7
|
> 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
|
|
8
8
|
> ships them.
|
|
9
9
|
|
|
10
|
+
## [0.2.3]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Drag-to-resize the pet.** Hovering the pet reveals a small grip at its
|
|
14
|
+
bottom-right corner; drag it to scale the pet between 0.5× and 2× (aspect
|
|
15
|
+
locked), double-click it to reset to 1×. The size persists across restarts,
|
|
16
|
+
like the pet position. Only the pet scales — session bubbles keep their
|
|
17
|
+
readable size and keep anchoring beside it.
|
|
18
|
+
|
|
10
19
|
## [0.2.2]
|
|
11
20
|
|
|
12
21
|
### Fixed
|
package/README.md
CHANGED
|
@@ -48,6 +48,8 @@ Haya Pet watches all of them and presents one ambient interface:
|
|
|
48
48
|
stays click-through outside the pet and bubbles.
|
|
49
49
|
- 🖱️ **Click / double-click / drag** — click folds/unfolds the bubbles, double-click
|
|
50
50
|
expands them, drag moves the pet (position persists; bubbles stay on-screen).
|
|
51
|
+
- 📏 **Resizable pet** — hover the pet and drag the corner grip to scale it
|
|
52
|
+
0.5×–2× for your screen; double-click the grip to reset. The size persists.
|
|
51
53
|
- 🟢 **Live session bubbles** with per-session status icons and a folder toggle.
|
|
52
54
|
- 🧠 **Normalized state model** — every client maps to a shared state vocabulary
|
|
53
55
|
(`thinking`, `running_tool`, `waiting_approval`, `reviewing`, `failed`, …).
|
|
@@ -224,6 +226,8 @@ frames so everything still works.
|
|
|
224
226
|
| Single click | waves + folds/unfolds the session bubbles |
|
|
225
227
|
| Double click | jumps + expands the bubbles |
|
|
226
228
|
| Drag | moves the pet; position is saved (bubbles follow, always on-screen) |
|
|
229
|
+
| Drag corner grip | resizes the pet 0.5×–2× (grip appears on hover); size is saved |
|
|
230
|
+
| Double-click grip | resets the pet to its normal size |
|
|
227
231
|
| Tray icon → menu | show/hide, display mode, sessions, pets, **reset position**, **Quit** |
|
|
228
232
|
|
|
229
233
|
## Stop / exit the pet
|
|
@@ -11,9 +11,10 @@ import { createProcessSnapshotLister } from "../../../../packages/platform-core/
|
|
|
11
11
|
import { getDefaultPaths } from "../../../../packages/platform-core/src/paths.js";
|
|
12
12
|
import { getPlatformCapabilities } from "../../../../packages/platform-core/src/capabilities.js";
|
|
13
13
|
import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
|
|
14
|
+
import { clampScale } from "../../../../packages/pet-core/src/pet-scale.js";
|
|
14
15
|
import { buildPetWindowOptions, PET_SIZE } from "./window-options.js";
|
|
15
16
|
import { resolveSavedPosition } from "./display-manager.js";
|
|
16
|
-
import { setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
|
|
17
|
+
import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
|
|
17
18
|
import { buildTrayMenu } from "./tray-menu.js";
|
|
18
19
|
import { createStateFile } from "./state-file.js";
|
|
19
20
|
import { discoverPets } from "./pet-loader.js";
|
|
@@ -43,6 +44,8 @@ let runtime;
|
|
|
43
44
|
let currentWorkArea;
|
|
44
45
|
let currentDisplayId;
|
|
45
46
|
let petLocal = { x: 0, y: 0 };
|
|
47
|
+
// User-chosen pet scale (resize grip); the pet occupies PET_SIZE × petScale.
|
|
48
|
+
let petScale = 1;
|
|
46
49
|
let approvalWatch;
|
|
47
50
|
|
|
48
51
|
// Electron singleton: a second launch forwards to the running instance.
|
|
@@ -58,6 +61,7 @@ if (!app.requestSingleInstanceLock()) {
|
|
|
58
61
|
|
|
59
62
|
async function bootstrap() {
|
|
60
63
|
positionState = await stateFile.load();
|
|
64
|
+
petScale = clampScale(getPetScale(positionState));
|
|
61
65
|
pets = await discoverPets(paths.petSearchPaths);
|
|
62
66
|
|
|
63
67
|
// Clients fire no event at the moment the user ACCEPTS a permission prompt
|
|
@@ -164,9 +168,17 @@ function createPetWindow() {
|
|
|
164
168
|
});
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
function scaledPetSize() {
|
|
172
|
+
return {
|
|
173
|
+
width: Math.round(PET_SIZE.width * petScale),
|
|
174
|
+
height: Math.round(PET_SIZE.height * petScale)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
167
178
|
function clampPetLocal(local) {
|
|
168
|
-
const
|
|
169
|
-
const
|
|
179
|
+
const size = scaledPetSize();
|
|
180
|
+
const maxX = Math.max(0, (currentWorkArea?.width ?? size.width) - size.width);
|
|
181
|
+
const maxY = Math.max(0, (currentWorkArea?.height ?? size.height) - size.height);
|
|
170
182
|
return {
|
|
171
183
|
x: Math.min(Math.max(local.x ?? 0, 0), maxX),
|
|
172
184
|
y: Math.min(Math.max(local.y ?? 0, 0), maxY)
|
|
@@ -274,6 +286,15 @@ function registerRendererHandlers() {
|
|
|
274
286
|
persistPetPosition();
|
|
275
287
|
return petLocal;
|
|
276
288
|
});
|
|
289
|
+
|
|
290
|
+
// Resize grip released (or double-clicked to reset): store the new scale and
|
|
291
|
+
// re-clamp the position so a grown pet never sticks out of the work area.
|
|
292
|
+
ipcMain.handle("haya-pet:save-pet-scale", async (_event, scale) => {
|
|
293
|
+
petScale = clampScale(scale);
|
|
294
|
+
petLocal = clampPetLocal(petLocal);
|
|
295
|
+
persistPetPosition();
|
|
296
|
+
return petScale;
|
|
297
|
+
});
|
|
277
298
|
}
|
|
278
299
|
|
|
279
300
|
function buildSessionPayload() {
|
|
@@ -302,7 +323,8 @@ function sendPetConfig() {
|
|
|
302
323
|
? { manifest: selected.manifest, spritesheetUrl: selected.spritesheetUrl }
|
|
303
324
|
: undefined,
|
|
304
325
|
overlayMode: capabilities.transparentOverlay === "required" ? "transparent-overlay" : "fallback-window",
|
|
305
|
-
petPosition: petLocal
|
|
326
|
+
petPosition: petLocal,
|
|
327
|
+
petScale
|
|
306
328
|
});
|
|
307
329
|
}
|
|
308
330
|
|
|
@@ -318,13 +340,15 @@ function persistPetPosition() {
|
|
|
318
340
|
|
|
319
341
|
// Store the pet's absolute on-screen top-left so it can be restored on the
|
|
320
342
|
// right display, mapping the in-window position back to screen coordinates.
|
|
321
|
-
|
|
343
|
+
// The persisted box is the *scaled* size, so display restore clamps correctly.
|
|
344
|
+
const size = scaledPetSize();
|
|
345
|
+
positionState = setPetScale(updateGlobalPetPosition(positionState, {
|
|
322
346
|
x: currentWorkArea.x + petLocal.x,
|
|
323
347
|
y: currentWorkArea.y + petLocal.y,
|
|
324
|
-
width:
|
|
325
|
-
height:
|
|
348
|
+
width: size.width,
|
|
349
|
+
height: size.height,
|
|
326
350
|
displayId: currentDisplayId
|
|
327
|
-
});
|
|
351
|
+
}), petScale);
|
|
328
352
|
|
|
329
353
|
// Debounce disk writes during drag (positionSaveDebounce, plan section 27).
|
|
330
354
|
clearTimeout(persistTimer);
|
|
@@ -348,9 +372,10 @@ function focusPet() {
|
|
|
348
372
|
function resetPosition() {
|
|
349
373
|
// Drop the pet back to the bottom-right corner of its work area.
|
|
350
374
|
const margin = 24;
|
|
375
|
+
const size = scaledPetSize();
|
|
351
376
|
petLocal = clampPetLocal({
|
|
352
|
-
x: (currentWorkArea?.width ??
|
|
353
|
-
y: (currentWorkArea?.height ??
|
|
377
|
+
x: (currentWorkArea?.width ?? size.width) - size.width - margin,
|
|
378
|
+
y: (currentWorkArea?.height ?? size.height) - size.height - margin
|
|
354
379
|
});
|
|
355
380
|
sendPetPosition();
|
|
356
381
|
persistPetPosition();
|
|
@@ -5,6 +5,7 @@ const { contextBridge, ipcRenderer } = require("electron");
|
|
|
5
5
|
contextBridge.exposeInMainWorld("aiPet", {
|
|
6
6
|
listSessions: () => ipcRenderer.invoke("haya-pet:list-sessions"),
|
|
7
7
|
savePetPosition: (local) => ipcRenderer.invoke("haya-pet:save-pet-position", local),
|
|
8
|
+
savePetScale: (scale) => ipcRenderer.invoke("haya-pet:save-pet-scale", scale),
|
|
8
9
|
setMouseIgnore: (ignore) => ipcRenderer.send("haya-pet:set-mouse-ignore", ignore),
|
|
9
10
|
onConfig: (handler) => ipcRenderer.on("haya-pet:config", (_event, config) => handler(config)),
|
|
10
11
|
onSessions: (handler) => ipcRenderer.on("haya-pet:sessions", (_event, payload) => handler(payload)),
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
<link rel="stylesheet" href="styles.css" />
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
|
-
<!-- Layer 1: the global pet overlay.
|
|
11
|
-
|
|
10
|
+
<!-- Layer 1: the global pet overlay. The wrapper carries the position so the
|
|
11
|
+
hover-revealed resize grip travels with the canvas. -->
|
|
12
|
+
<div id="pet">
|
|
13
|
+
<canvas id="pet-canvas" class="interactive" width="192" height="208"></canvas>
|
|
14
|
+
<div id="pet-resize-grip" class="resize-grip interactive" title="Drag to resize · double-click to reset"></div>
|
|
15
|
+
</div>
|
|
12
16
|
|
|
13
17
|
<!-- Layer 2: ongoing-session progress bubbles + folder toggle. -->
|
|
14
18
|
<div id="bubbles" class="bubbles"></div>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// is the browser glue that draws frames and forwards pointer gestures.
|
|
4
4
|
|
|
5
5
|
import { CELL_HEIGHT, CELL_WIDTH, getFrameRect } from "../../../../packages/pet-core/src/atlas.js";
|
|
6
|
+
import { clampScale, DEFAULT_SCALE, resolveScaleFromDrag } from "../../../../packages/pet-core/src/pet-scale.js";
|
|
6
7
|
import { getActionDurationMs, getFrameAt } from "../../../../packages/pet-core/src/animator.js";
|
|
7
8
|
import {
|
|
8
9
|
clearDragAction,
|
|
@@ -19,10 +20,15 @@ import { createInteractionController } from "./interaction-controller.js";
|
|
|
19
20
|
import { createBubbleList } from "./session-bubbles.js";
|
|
20
21
|
|
|
21
22
|
const bridge = window.aiPet;
|
|
23
|
+
const petEl = document.getElementById("pet");
|
|
22
24
|
const canvas = document.getElementById("pet-canvas");
|
|
25
|
+
const gripEl = document.getElementById("pet-resize-grip");
|
|
23
26
|
const ctx = canvas.getContext("2d");
|
|
24
27
|
const panelEl = document.getElementById("bubbles");
|
|
25
28
|
|
|
29
|
+
// The sprite's natural cell size; the canvas is this times the user's scale.
|
|
30
|
+
const BASE_SIZE = Object.freeze({ width: CELL_WIDTH, height: CELL_HEIGHT });
|
|
31
|
+
|
|
26
32
|
const controller = createInteractionController({
|
|
27
33
|
// Click is deferred so a double-click never also fires a wave. Clicking the
|
|
28
34
|
// pet folds/unfolds the session bubbles; double-click forces them open.
|
|
@@ -50,6 +56,8 @@ let previousSessionStates = {};
|
|
|
50
56
|
// The pet lives at this work-area-relative position inside the full-screen
|
|
51
57
|
// overlay window; dragging moves it via CSS (the window never moves).
|
|
52
58
|
let petLocal = { x: 0, y: 0 };
|
|
59
|
+
// User-chosen pet scale (resize grip), persisted like the position.
|
|
60
|
+
let petScale = DEFAULT_SCALE;
|
|
53
61
|
// Linger bookkeeping so a finished session's bubble stays ~2s before vanishing.
|
|
54
62
|
let lingerState = {};
|
|
55
63
|
let lingerTimer;
|
|
@@ -60,6 +68,10 @@ function setupPet(config) {
|
|
|
60
68
|
manifest = config.pet.manifest;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
if (config?.petScale !== undefined) {
|
|
72
|
+
applyPetScale(config.petScale);
|
|
73
|
+
}
|
|
74
|
+
|
|
63
75
|
if (config?.petPosition) {
|
|
64
76
|
applyPetPosition(config.petPosition);
|
|
65
77
|
}
|
|
@@ -75,11 +87,21 @@ function setupPet(config) {
|
|
|
75
87
|
|
|
76
88
|
function applyPetPosition(pos) {
|
|
77
89
|
petLocal = clampPetLocal(pos);
|
|
78
|
-
|
|
79
|
-
|
|
90
|
+
petEl.style.left = `${petLocal.x}px`;
|
|
91
|
+
petEl.style.top = `${petLocal.y}px`;
|
|
80
92
|
placePanel();
|
|
81
93
|
}
|
|
82
94
|
|
|
95
|
+
// Resizes the canvas's pixel size, so each frame re-renders at the new scale
|
|
96
|
+
// (no CSS stretching). Everything that reads canvas.width/height — drag
|
|
97
|
+
// clamping, panel placement — adapts automatically.
|
|
98
|
+
function applyPetScale(scale) {
|
|
99
|
+
petScale = clampScale(scale);
|
|
100
|
+
canvas.width = Math.round(BASE_SIZE.width * petScale);
|
|
101
|
+
canvas.height = Math.round(BASE_SIZE.height * petScale);
|
|
102
|
+
applyPetPosition(petLocal);
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
function clampPetLocal(pos) {
|
|
84
106
|
const maxX = Math.max(0, window.innerWidth - canvas.width);
|
|
85
107
|
const maxY = Math.max(0, window.innerHeight - canvas.height);
|
|
@@ -148,13 +170,13 @@ function draw(action, frameIndex) {
|
|
|
148
170
|
function drawPlaceholder(action, frameIndex) {
|
|
149
171
|
ctx.fillStyle = "rgba(110, 168, 254, 0.85)";
|
|
150
172
|
ctx.beginPath();
|
|
151
|
-
ctx.roundRect ? ctx.roundRect(16, 16,
|
|
173
|
+
ctx.roundRect ? ctx.roundRect(16, 16, canvas.width - 32, canvas.height - 32, 16) : ctx.rect(16, 16, canvas.width - 32, canvas.height - 32);
|
|
152
174
|
ctx.fill();
|
|
153
175
|
ctx.fillStyle = "#001233";
|
|
154
176
|
ctx.font = "14px system-ui";
|
|
155
177
|
ctx.textAlign = "center";
|
|
156
|
-
ctx.fillText(action,
|
|
157
|
-
ctx.fillText(`frame ${frameIndex}`,
|
|
178
|
+
ctx.fillText(action, canvas.width / 2, canvas.height / 2);
|
|
179
|
+
ctx.fillText(`frame ${frameIndex}`, canvas.width / 2, canvas.height / 2 + 20);
|
|
158
180
|
}
|
|
159
181
|
|
|
160
182
|
function playOneShot(action) {
|
|
@@ -188,6 +210,50 @@ canvas.addEventListener("pointerup", (event) => {
|
|
|
188
210
|
}
|
|
189
211
|
});
|
|
190
212
|
|
|
213
|
+
// --- Resize grip: drag to scale the pet, double-click to reset ---
|
|
214
|
+
|
|
215
|
+
let resizeDrag; // { startScale, startPointer } while a grip drag is active
|
|
216
|
+
|
|
217
|
+
gripEl.addEventListener("pointerdown", (event) => {
|
|
218
|
+
gripEl.setPointerCapture(event.pointerId);
|
|
219
|
+
gripEl.classList.add("active");
|
|
220
|
+
resizeDrag = {
|
|
221
|
+
startScale: petScale,
|
|
222
|
+
startPointer: { x: event.clientX, y: event.clientY }
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
gripEl.addEventListener("pointermove", (event) => {
|
|
227
|
+
if (!resizeDrag) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
applyPetScale(resolveScaleFromDrag({
|
|
231
|
+
startScale: resizeDrag.startScale,
|
|
232
|
+
startPointer: resizeDrag.startPointer,
|
|
233
|
+
pointer: { x: event.clientX, y: event.clientY },
|
|
234
|
+
baseSize: BASE_SIZE
|
|
235
|
+
}));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
gripEl.addEventListener("pointerup", () => {
|
|
239
|
+
if (!resizeDrag) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
resizeDrag = undefined;
|
|
243
|
+
gripEl.classList.remove("active");
|
|
244
|
+
bridge?.savePetScale?.(petScale);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
gripEl.addEventListener("pointercancel", () => {
|
|
248
|
+
resizeDrag = undefined;
|
|
249
|
+
gripEl.classList.remove("active");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
gripEl.addEventListener("dblclick", () => {
|
|
253
|
+
applyPetScale(DEFAULT_SCALE);
|
|
254
|
+
bridge?.savePetScale?.(DEFAULT_SCALE);
|
|
255
|
+
});
|
|
256
|
+
|
|
191
257
|
// Re-clamp and re-place when the work area changes (display/resolution change).
|
|
192
258
|
window.addEventListener("resize", () => {
|
|
193
259
|
applyPetPosition(petLocal);
|
|
@@ -247,6 +313,12 @@ let mouseIgnored;
|
|
|
247
313
|
let lastPointer = { x: -1, y: -1 };
|
|
248
314
|
|
|
249
315
|
function refreshMouseIgnore(x, y) {
|
|
316
|
+
// While the grip is captured, the pointer can briefly leave it (the pet only
|
|
317
|
+
// approximately tracks the diagonal); flipping click-through mid-drag would
|
|
318
|
+
// drop the pointerup, so hold interaction until the drag ends.
|
|
319
|
+
if (resizeDrag) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
250
322
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
251
323
|
return;
|
|
252
324
|
}
|
|
@@ -30,10 +30,14 @@ body {
|
|
|
30
30
|
its right. The window itself is large + transparent, and pointer events are
|
|
31
31
|
forwarded so only the marked .interactive regions catch clicks — everything
|
|
32
32
|
else passes through to the desktop (see pet-window.js). */
|
|
33
|
-
#pet
|
|
33
|
+
#pet {
|
|
34
34
|
position: absolute;
|
|
35
35
|
left: 0;
|
|
36
36
|
top: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#pet-canvas {
|
|
40
|
+
display: block;
|
|
37
41
|
image-rendering: pixelated;
|
|
38
42
|
cursor: grab;
|
|
39
43
|
}
|
|
@@ -42,6 +46,26 @@ body {
|
|
|
42
46
|
cursor: grabbing;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/* Resize grip: a small corner triangle that fades in while hovering the pet.
|
|
50
|
+
Dragging it scales the pet (aspect-locked); double-click resets to 1×. */
|
|
51
|
+
.resize-grip {
|
|
52
|
+
position: absolute;
|
|
53
|
+
right: 0;
|
|
54
|
+
bottom: 0;
|
|
55
|
+
width: 14px;
|
|
56
|
+
height: 14px;
|
|
57
|
+
cursor: nwse-resize;
|
|
58
|
+
background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, 0.75) 50%);
|
|
59
|
+
border-radius: 2px;
|
|
60
|
+
opacity: 0;
|
|
61
|
+
transition: opacity 0.15s ease;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#pet:hover .resize-grip,
|
|
65
|
+
.resize-grip.active {
|
|
66
|
+
opacity: 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
45
69
|
/* Panel container is inert; its children opt back into pointer events so the
|
|
46
70
|
gaps between bubbles stay click-through. Its left/top are set by JS
|
|
47
71
|
(panel-placement) so it always sits beside the pet, fully on-screen. The
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -66,6 +66,23 @@ export function getSelectedPetId(state) {
|
|
|
66
66
|
return state?.globalPet?.selectedPetId;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Pet display scale (resize grip). Range clamping lives in pet-core's
|
|
70
|
+
// pet-scale module; this layer only stores and validates the raw value.
|
|
71
|
+
export function setPetScale(state, scale) {
|
|
72
|
+
return {
|
|
73
|
+
...state,
|
|
74
|
+
globalPet: {
|
|
75
|
+
...state.globalPet,
|
|
76
|
+
scale
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getPetScale(state) {
|
|
82
|
+
const scale = state?.globalPet?.scale;
|
|
83
|
+
return Number.isFinite(scale) ? scale : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
export function serializePositionState(state) {
|
|
70
87
|
return `${JSON.stringify(state, null, 2)}\n`;
|
|
71
88
|
}
|
|
@@ -2,7 +2,11 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { test } from "../../../test/harness.mjs";
|
|
3
3
|
import {
|
|
4
4
|
createDefaultPositionState,
|
|
5
|
+
getPetScale,
|
|
5
6
|
getSelectedPetId,
|
|
7
|
+
parsePositionState,
|
|
8
|
+
serializePositionState,
|
|
9
|
+
setPetScale,
|
|
6
10
|
setSelectedPet
|
|
7
11
|
} from "../src/state.js";
|
|
8
12
|
|
|
@@ -34,3 +38,36 @@ test("getSelectedPetId tolerates missing state", () => {
|
|
|
34
38
|
assert.equal(getSelectedPetId(undefined), undefined);
|
|
35
39
|
assert.equal(getSelectedPetId({}), undefined);
|
|
36
40
|
});
|
|
41
|
+
|
|
42
|
+
test("setPetScale stores the scale immutably", () => {
|
|
43
|
+
const state = createDefaultPositionState();
|
|
44
|
+
const next = setPetScale(state, 1.5);
|
|
45
|
+
|
|
46
|
+
assert.equal(getPetScale(next), 1.5);
|
|
47
|
+
assert.equal(getPetScale(state), undefined);
|
|
48
|
+
assert.notEqual(next.globalPet, state.globalPet);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("setPetScale preserves other globalPet fields", () => {
|
|
52
|
+
const state = { ...createDefaultPositionState(), globalPet: { open: true, x: 10, y: 20, manual: true } };
|
|
53
|
+
const next = setPetScale(state, 0.75);
|
|
54
|
+
|
|
55
|
+
assert.equal(next.globalPet.x, 10);
|
|
56
|
+
assert.equal(next.globalPet.manual, true);
|
|
57
|
+
assert.equal(next.globalPet.scale, 0.75);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("getPetScale returns undefined for missing or invalid values", () => {
|
|
61
|
+
assert.equal(getPetScale(undefined), undefined);
|
|
62
|
+
assert.equal(getPetScale(createDefaultPositionState()), undefined);
|
|
63
|
+
assert.equal(getPetScale(setPetScale(createDefaultPositionState(), Number.NaN)), undefined);
|
|
64
|
+
assert.equal(getPetScale({ globalPet: { scale: "big" } }), undefined);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("scale survives a serialize/parse round-trip and old files parse without it", () => {
|
|
68
|
+
const withScale = parsePositionState(serializePositionState(setPetScale(createDefaultPositionState(), 1.25)));
|
|
69
|
+
assert.equal(getPetScale(withScale), 1.25);
|
|
70
|
+
|
|
71
|
+
const legacy = parsePositionState(JSON.stringify({ globalPet: { open: true, x: 1, y: 2 } }));
|
|
72
|
+
assert.equal(getPetScale(legacy), undefined);
|
|
73
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Pure math for the pet resize grip: the user drags the pet's bottom-right
|
|
2
|
+
// corner to scale the sprite between MIN_SCALE and MAX_SCALE, aspect locked.
|
|
3
|
+
|
|
4
|
+
export const MIN_SCALE = 0.5;
|
|
5
|
+
export const MAX_SCALE = 2;
|
|
6
|
+
export const DEFAULT_SCALE = 1;
|
|
7
|
+
|
|
8
|
+
export function clampScale(value) {
|
|
9
|
+
if (!Number.isFinite(value)) {
|
|
10
|
+
return DEFAULT_SCALE;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return Math.min(Math.max(value, MIN_SCALE), MAX_SCALE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Maps a grip drag to the next scale: each axis implies a scale from how far
|
|
17
|
+
// the pointer moved relative to the pet's base size, and the two are averaged
|
|
18
|
+
// so the pet follows the pointer along the diagonal while staying aspect-locked.
|
|
19
|
+
export function resolveScaleFromDrag({ startScale, startPointer, pointer, baseSize } = {}) {
|
|
20
|
+
const safeStart = clampScale(startScale);
|
|
21
|
+
|
|
22
|
+
if (
|
|
23
|
+
!Number.isFinite(startPointer?.x) || !Number.isFinite(startPointer?.y) ||
|
|
24
|
+
!Number.isFinite(pointer?.x) || !Number.isFinite(pointer?.y) ||
|
|
25
|
+
!Number.isFinite(baseSize?.width) || !Number.isFinite(baseSize?.height) ||
|
|
26
|
+
baseSize.width <= 0 || baseSize.height <= 0
|
|
27
|
+
) {
|
|
28
|
+
return safeStart;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const scaleX = safeStart + (pointer.x - startPointer.x) / baseSize.width;
|
|
32
|
+
const scaleY = safeStart + (pointer.y - startPointer.y) / baseSize.height;
|
|
33
|
+
return clampScale((scaleX + scaleY) / 2);
|
|
34
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
clampScale,
|
|
5
|
+
DEFAULT_SCALE,
|
|
6
|
+
MAX_SCALE,
|
|
7
|
+
MIN_SCALE,
|
|
8
|
+
resolveScaleFromDrag
|
|
9
|
+
} from "../src/pet-scale.js";
|
|
10
|
+
|
|
11
|
+
const baseSize = { width: 192, height: 208 };
|
|
12
|
+
|
|
13
|
+
test("scale constants define a sane range around the default", () => {
|
|
14
|
+
assert.equal(DEFAULT_SCALE, 1);
|
|
15
|
+
assert.ok(MIN_SCALE < DEFAULT_SCALE);
|
|
16
|
+
assert.ok(MAX_SCALE > DEFAULT_SCALE);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("clampScale keeps in-range values and clamps out-of-range ones", () => {
|
|
20
|
+
assert.equal(clampScale(1.25), 1.25);
|
|
21
|
+
assert.equal(clampScale(MIN_SCALE), MIN_SCALE);
|
|
22
|
+
assert.equal(clampScale(MAX_SCALE), MAX_SCALE);
|
|
23
|
+
assert.equal(clampScale(0.01), MIN_SCALE);
|
|
24
|
+
assert.equal(clampScale(99), MAX_SCALE);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("clampScale falls back to the default for invalid input", () => {
|
|
28
|
+
assert.equal(clampScale(undefined), DEFAULT_SCALE);
|
|
29
|
+
assert.equal(clampScale(null), DEFAULT_SCALE);
|
|
30
|
+
assert.equal(clampScale(Number.NaN), DEFAULT_SCALE);
|
|
31
|
+
assert.equal(clampScale("big"), DEFAULT_SCALE);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("dragging the grip outward grows the scale", () => {
|
|
35
|
+
const next = resolveScaleFromDrag({
|
|
36
|
+
startScale: 1,
|
|
37
|
+
startPointer: { x: 500, y: 500 },
|
|
38
|
+
pointer: { x: 500 + baseSize.width / 2, y: 500 + baseSize.height / 2 },
|
|
39
|
+
baseSize
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.equal(next, 1.5);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("dragging the grip inward shrinks the scale", () => {
|
|
46
|
+
const next = resolveScaleFromDrag({
|
|
47
|
+
startScale: 1,
|
|
48
|
+
startPointer: { x: 500, y: 500 },
|
|
49
|
+
pointer: { x: 500 - baseSize.width / 4, y: 500 - baseSize.height / 4 },
|
|
50
|
+
baseSize
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(next, 0.75);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a zero drag keeps the starting scale", () => {
|
|
57
|
+
const next = resolveScaleFromDrag({
|
|
58
|
+
startScale: 1.3,
|
|
59
|
+
startPointer: { x: 10, y: 10 },
|
|
60
|
+
pointer: { x: 10, y: 10 },
|
|
61
|
+
baseSize
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(next, 1.3);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("drag results are clamped to the scale range", () => {
|
|
68
|
+
const grown = resolveScaleFromDrag({
|
|
69
|
+
startScale: 1.8,
|
|
70
|
+
startPointer: { x: 0, y: 0 },
|
|
71
|
+
pointer: { x: 1000, y: 1000 },
|
|
72
|
+
baseSize
|
|
73
|
+
});
|
|
74
|
+
const shrunk = resolveScaleFromDrag({
|
|
75
|
+
startScale: 0.6,
|
|
76
|
+
startPointer: { x: 1000, y: 1000 },
|
|
77
|
+
pointer: { x: 0, y: 0 },
|
|
78
|
+
baseSize
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(grown, MAX_SCALE);
|
|
82
|
+
assert.equal(shrunk, MIN_SCALE);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("invalid drag input falls back to the clamped starting scale", () => {
|
|
86
|
+
const next = resolveScaleFromDrag({
|
|
87
|
+
startScale: 1.2,
|
|
88
|
+
startPointer: { x: Number.NaN, y: 0 },
|
|
89
|
+
pointer: { x: 50, y: 50 },
|
|
90
|
+
baseSize
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.equal(next, 1.2);
|
|
94
|
+
});
|