From 7c0431ebcdf5d8e0eb74031d07d9d011c4c7f0bc Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Wed, 29 Jul 2026 00:31:53 -0700 Subject: [PATCH 1/2] feat(pets): add the dog as a first-class pet and rename to pixelpets The app hosts either a cat or a dog now, switchable from the tray without a restart. Each species keeps its own coat choice so switching back and forth never loses your pick. The dog is not a recoloured cat. src/dog-sprite.js is its own composer set with canine geometry: a muzzle that protrudes past the skull line, ears that hang or perk per breed, a broader chest, a real neck, and short legs on the dwarf breeds. 14 breeds ship, and their markings are coat structures rather than palette swaps (spots, saddle, tricolour, mask, merle, patch, curly rim). Five poses, each the canine answer to the cat's rather than a reuse of it: sit, play bow (vs the hunting crouch), keyboard lean, nose-to-tail curl (vs the loaf), and beg (vs the rear-up bat). Behaviour: - fetch: the tray throws a tennis ball, it arcs and bounces under gravity, the dog sprints after it, carries it home, drops it, then wags and pants - a tail that wags from the base with breed-specific shape (curl/plume/ feather/stub/straight) and a rate that rides the mood - panting with a lolling tongue after exertion - whiskers are suppressed for dogs; they read as a cat in a dog costume Implementation notes: - the active species rewrites the shared PATTERNS/PATTERN_BUILD/TABBY tables in place, so the tray, settings, themes and contact sheet keep working with no knowledge that a second species exists - face features are composed after markings; a solid marking was burying the beagle's eye in the curl pose, which the new tests caught - scripts/pet-sheet.js renders every breed x pose to a PNG with no Electron and no canvas dependency, for visual QA (npm run sheet:dog) Renamed the product and package to pixelpets. appId, AppUserModelId and the tmpdir state paths deliberately keep the old identifier: changing them orphans existing installs' settings and would break the in-flight winget package identity. 27 tests pass, lint clean, boot check green. --- README.md | 39 +++- eslint.config.js | 11 +- package.json | 8 +- scripts/pet-sheet.js | 113 +++++++++++ src/config.js | 6 + src/dog-sprite.js | 459 +++++++++++++++++++++++++++++++++++++++++++ src/index.html | 2 + src/main.js | 51 +++-- src/pets.js | 61 ++++++ src/preload.js | 1 + src/renderer.js | 328 +++++++++++++++++++++++++++++-- src/settings.html | 2 + tests/pets.test.js | 90 +++++++++ 13 files changed, 1130 insertions(+), 41 deletions(-) create mode 100644 scripts/pet-sheet.js create mode 100644 src/dog-sprite.js create mode 100644 src/pets.js create mode 100644 tests/pets.test.js diff --git a/README.md b/README.md index f158751..975fd92 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@
-pixelcat logo +pixelpets logo -# pixelcat +# pixelpets -### A pixel cat that lives on your desktop. +### A pixel cat or dog that lives on your desktop. It sits in the corner, watches your cursor, kneads the keyboard when you type, -purrs when you pet it, and stretches like mochi when you drag it. A desktop pet -built from scratch: nearly every sprite, animation, and sound is original and procedural. +purrs when you pet it, and stretches like mochi when you drag it. Prefer a dog? +Switch species from the tray and you get a real one: 14 breeds, a wagging tail +that reads your pet's mood, a play bow instead of a hunting crouch, and a tennis +ball it will actually chase down and bring back. A desktop pet built from +scratch: nearly every sprite, animation, and sound is original and procedural.
@@ -34,6 +37,32 @@ built from scratch: nearly every sprite, animation, and sound is original and pr
+## Two pets, one app + +Pick your species from the tray (**Pet β†’ Cat / Dog**). Each one keeps its own +coat choice, so switching back and forth never loses your pick. + +| | Cat | Dog | +|---|---|---| +| Coats | 14 coats, from Orange Tabby to Russian Blue | 14 breeds, from Golden Retriever to Chihuahua | +| Resting | loafs into a "cat bread" | curls nose-to-tail into a ring | +| Excited | hunting crouch, ears back | **play bow**: chest down, rump up, tail flagged | +| Tail | slow rolling S-curve, tip flicks | fast wag from the base, shaped per breed (curl / plume / feather / stub / straight) | +| Play | bats a butterfly | **fetch**: chases the thrown ball, carries it home, drops it | +| After exertion | grooms | pants, tongue out | +| Reward | a fish treat | a tennis ball | + +The dog is not a recoloured cat. It has its own sprite module with a muzzle that +protrudes past the skull line, ears that hang or perk per breed, a broader chest, +and short legs on the dwarf breeds. Markings are coat *structures*, not palette +swaps: the Dalmatian is spotted, the shepherd wears a saddle, the beagle is +tricolour, the husky has a mask, and the Aussie is merle. + +```bash +npm run sheet:dog # renders previews/dog-sheet.png - every breed x every pose +npm run sheet:cat +``` + ## See it in action diff --git a/eslint.config.js b/eslint.config.js index ebd6785..bc88171 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,6 +9,13 @@ const sharedOverlay = { CELL: 'readonly', setCell: 'readonly', ellipse: 'readonly', triangle: 'readonly', outlineHalo: 'readonly', eyeBox: 'readonly', muzzlePt: 'readonly', buildSprite: 'readonly', composeSit: 'readonly', PATTERNS: 'readonly', PATTERN_NAMES: 'readonly', + // dog-sprite.js (loaded after cat-sprite.js) provides the canine composers + tables: + composeSitDog: 'readonly', composeBowDog: 'readonly', composeTypeDog: 'readonly', + composeCurlDog: 'readonly', composeBegDog: 'readonly', applyMarking: 'readonly', + DOG_PATTERNS: 'readonly', DOG_BUILDS: 'readonly', DOG_PATTERN_BUILD: 'readonly', DOG_TAILS: 'readonly', + // pets.js provides the species registry: + PET_SPECIES: 'readonly', SPECIES_IDS: 'readonly', speciesOf: 'readonly', coatsFor: 'readonly', + isSpecies: 'readonly', defaultCoatIndex: 'readonly', CAT_COATS: 'readonly', DOG_COATS: 'readonly', BUILDS: 'readonly', TABBY: 'readonly', PATTERN_BUILD: 'readonly', BODY: 'readonly', G: 'readonly', GC: 'readonly', GR: 'readonly', HALO: 'readonly', rgbStr: 'readonly', toRgb: 'readonly', shadeStr: 'readonly', lerpHex: 'readonly', @@ -31,7 +38,7 @@ module.exports = [ { // Node / CommonJS: main process, workers, scripts, tests, configs, template.js files: ['**/*.js'], - ignores: [...CONSUMER_OVERLAY, 'src/cat-sprite.js', 'src/patterns.js', 'src/audio.js', 'src/effects.js', 'src/jam.js'], + ignores: [...CONSUMER_OVERLAY, 'src/cat-sprite.js', 'src/dog-sprite.js', 'src/patterns.js', 'src/pets.js', 'src/audio.js', 'src/effects.js', 'src/jam.js'], languageOptions: { sourceType: 'commonjs', ecmaVersion: 2023, globals: { ...globals.node } }, }, { @@ -58,7 +65,7 @@ module.exports = [ { // cat-sprite.js / patterns.js are dual-loaded: classic + + diff --git a/src/main.js b/src/main.js index e7b1fdf..1167a20 100644 --- a/src/main.js +++ b/src/main.js @@ -8,6 +8,7 @@ const mail = require('./mail'); const cal = require('./cal'); const themes = require('./themes'); const { PATTERN_NAMES } = require('./patterns'); +const { SPECIES, SPECIES_IDS, speciesOf, coatsFor, defaultCoatIndex } = require('./pets'); // Let the overlay auto-resume the Lobby Jam music at launch without a click β€” Chromium // otherwise blocks autoplay until a user gesture. @@ -255,7 +256,7 @@ function createWindow() { // sanitize fields from the untrusted bridge file before they reach Notification + renderer IPC const level = ['info', 'success', 'warn', 'alert'].includes(o.level) ? o.level : 'info'; const ttl = Math.max(500, Math.min(30000, Math.round(Number(o.ttl)) || 5000)); - const title = String(o.title || 'pixelcat').slice(0, 80); + const title = String(o.title || 'pixelpets').slice(0, 80); notify(String(o.message).slice(0, 300), { source: 'bridge', dedupeKey: 'bridge:' + id, title, level, ttl, sound: o.sound !== false }); } }; @@ -366,17 +367,27 @@ function trayImage() { function createTray() { try { tray = new Tray(trayImage()); - tray.setToolTip('pixelcat'); + tray.setToolTip('pixelpets'); tray.on('double-click', openSettings); rebuildTrayMenu(); } catch (e) { console.log('[tray-error]', e.message); } } function rebuildTrayMenu() { if (!tray) return; - const allCoats = PATTERN_NAMES.concat(themesCache.map((t) => t.name)); + // The tray follows the active species: a dog owner picks a BREED, not a coat, + // and each species remembers its own choice in its own config field. + const sp = speciesOf(cfg && cfg.species); + const isDogCfg = sp.id === 'dog'; + const coatField = isDogCfg ? 'dogPattern' : 'pattern'; + const curCoat = cfg ? cfg[coatField] : 0; + const allCoats = (isDogCfg ? coatsFor('dog') : PATTERN_NAMES).concat(isDogCfg ? [] : themesCache.map((t) => t.name)); const coatItems = allCoats.map((name, i) => ({ - label: name, type: 'radio', checked: cfg && cfg.pattern === i, - click: () => persistAndBroadcast({ ...cfg, pattern: i }), + label: name, type: 'radio', checked: curCoat === i, + click: () => persistAndBroadcast({ ...cfg, [coatField]: i }), + })); + const speciesItems = SPECIES_IDS.map((id) => ({ + label: `${SPECIES[id].emoji} ${SPECIES[id].label}`, type: 'radio', checked: sp.id === id, + click: () => persistAndBroadcast({ ...cfg, species: id }), })); const recent = notifyHistory.slice(-10).reverse(); const recentItems = recent.length @@ -388,7 +399,7 @@ function rebuildTrayMenu() { tray.setContextMenu(Menu.buildFromTemplate([ { label: 'Settings…', click: openSettings }, { label: 'Start break now', click: triggerBreak }, - { label: 'Give a treat 🐟', click: giveTreat }, + { label: sp.treatLabel, click: giveTreat }, { label: 'Recent notifications', submenu: recentItems }, { label: 'Snooze last reminder', submenu: [ { label: '5 minutes', click: () => snoozeLast(5) }, @@ -396,7 +407,8 @@ function rebuildTrayMenu() { { label: '30 minutes', click: () => snoozeLast(30) }, ] }, { type: 'separator' }, - { label: 'Coat', submenu: coatItems }, + { label: 'Pet', submenu: speciesItems }, + { label: sp.coatNoun, submenu: coatItems }, { label: 'Follow cursor', type: 'checkbox', checked: !!(cfg && cfg.followCursor), click: () => persistAndBroadcast({ ...cfg, followCursor: !cfg.followCursor }) }, { label: 'Mouse hunt', type: 'checkbox', checked: !!(cfg && cfg.huntOn), click: () => persistAndBroadcast({ ...cfg, huntOn: !cfg.huntOn }) }, { label: 'Butterfly visits', type: 'checkbox', checked: !(cfg && cfg.butterflyOn === false), click: () => persistAndBroadcast({ ...cfg, butterflyOn: !(cfg && cfg.butterflyOn !== false) }) }, @@ -438,7 +450,7 @@ function rebuildTrayMenu() { ]; })() }, { type: 'separator' }, - { label: 'Quit pixelcat', click: () => app.quit() }, + { label: 'Quit pixelpets', click: () => app.quit() }, ])); } @@ -447,7 +459,7 @@ function openSettings() { if (settingsWin && !settingsWin.isDestroyed()) { settingsWin.show(); settingsWin.focus(); return; } settingsWin = new BrowserWindow({ width: 400, height: 560, resizable: false, fullscreenable: false, maximizable: false, - title: 'pixelcat settings', skipTaskbar: false, alwaysOnTop: true, + title: 'pixelpets settings', skipTaskbar: false, alwaysOnTop: true, icon: path.join(__dirname, '..', 'assets', 'icon.png'), // taskbar icon for the settings window show: false, backgroundColor: '#191b22', // dark from the first paint - no white flash webPreferences: { preload: path.join(__dirname, 'settings-preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true }, @@ -471,7 +483,10 @@ function triggerBreak() { breakAnchor = Date.now(); } function giveTreat() { - if (win && !win.isDestroyed()) win.webContents.send('treat'); + if (!win || win.isDestroyed()) return; + // Same tray slot, species-appropriate payload: a cat is handed a fish, a dog + // gets a tennis ball thrown for it to chase down and bring back. + win.webContents.send(speciesOf(cfg && cfg.species).id === 'dog' ? 'ball' : 'treat'); } // ---- Pomodoro: focus/break loops. Main owns the phase clock (the renderer may @@ -556,7 +571,7 @@ function notify(message, opts) { } const wantOs = opts.os !== undefined ? opts.os : !(cfg && cfg.notifyOn === false); if (wantOs) { - try { if (Notification.isSupported()) new Notification({ title: opts.title || 'pixelcat', body: msg, silent: true }).show(); } + try { if (Notification.isSupported()) new Notification({ title: opts.title || 'pixelpets', body: msg, silent: true }).show(); } catch (e) { /* toasts are best-effort */ } } } @@ -681,7 +696,17 @@ onSecure('sheet:image', (_e, dataUrl) => { app.quit(); }); onSecure('settings:open', () => openSettings()); -onSecure('settings:save-pattern', (_e, i) => { if (cfg) persistAndBroadcast({ ...cfg, pattern: i }); }); +onSecure('settings:save-pattern', (_e, i) => { + if (!cfg) return; + persistAndBroadcast({ ...cfg, [speciesOf(cfg.species).id === 'dog' ? 'dogPattern' : 'pattern']: i }); +}); +onSecure('settings:save-species', (_e, id) => { + if (!cfg) return; + const next = SPECIES[id] ? id : 'cat'; + const patch = { ...cfg, species: next }; + if (next === 'dog' && !Number.isFinite(cfg.dogPattern)) patch.dogPattern = defaultCoatIndex('dog'); + persistAndBroadcast(patch); +}); onSecure('settings:close', () => { if (settingsWin && !settingsWin.isDestroyed()) settingsWin.close(); }); onSecure('settings:testSound', () => { notify('Hi {name}!', { source: 'test', dedupeMs: 0, os: false }); // a sound test shouldn't also pop a desktop toast @@ -703,7 +728,7 @@ handleSecure('themes:get', () => themesCache); handleSecure('themes:add', (_e, t) => { themesCache = themes.save([...themesCache, t]); broadcastThemes(); rebuildTrayMenu(); return themesCache; }); handleSecure('themes:delete', (_e, name) => { themesCache = themes.save(themesCache.filter((x) => x.name !== name)); broadcastThemes(); rebuildTrayMenu(); return themesCache; }); handleSecure('themes:export', async () => { - const r = await dialog.showSaveDialog(settingsWin || win, { title: 'Export custom coats', defaultPath: 'pixelcat-coats.json', filters: [{ name: 'JSON', extensions: ['json'] }] }); + const r = await dialog.showSaveDialog(settingsWin || win, { title: 'Export custom coats', defaultPath: 'pixelpets-coats.json', filters: [{ name: 'JSON', extensions: ['json'] }] }); if (r.canceled || !r.filePath) return false; try { fs.writeFileSync(r.filePath, JSON.stringify({ themes: themesCache }, null, 2)); return true; } catch (e) { return false; } }); diff --git a/src/pets.js b/src/pets.js new file mode 100644 index 0000000..05ad22f --- /dev/null +++ b/src/pets.js @@ -0,0 +1,61 @@ +// Species registry - the single source of truth for which pets exist and what +// each one's coats are called. Mirrors patterns.js (which stays the cat's list so +// nothing that already imports it breaks) and is consumed by the main process for +// the tray menu, by the settings window for its dropdowns, and by the renderer to +// decide which sprite composers to build with. +// +// Coat NAMES only. The full palettes live in cat-sprite.js / dog-sprite.js so +// colour data is never duplicated. + +const CAT_COATS = [ + 'Orange Tabby', 'Mackerel Tabby', 'Brown Tabby', 'Siamese', + 'Tuxedo', 'Black', 'Gray', 'White', + 'Cream', 'Tortoiseshell', 'Calico', 'Slate', + 'Chocolate', 'Russian Blue', +]; + +const DOG_COATS = [ + 'Golden Retriever', 'Shiba Inu', 'Corgi', 'Beagle', + 'Siberian Husky', 'Dalmatian', 'German Shepherd', 'Border Collie', + 'Dachshund', 'Pug', 'Black Lab', 'Poodle', + 'Australian Shepherd', 'Chihuahua', +]; + +const SPECIES = { + cat: { + id: 'cat', + label: 'Cat', + emoji: '🐱', + coats: CAT_COATS, + coatNoun: 'Coat', + defaultCoat: 'Tuxedo', + // tray/menu wording differs per species so the app never says "treat" at a dog + // that is actually being handed a ball + treatLabel: 'Give a treat 🐟', + playLabel: 'Send a butterfly πŸ¦‹', + }, + dog: { + id: 'dog', + label: 'Dog', + emoji: '🐢', + coats: DOG_COATS, + coatNoun: 'Breed', + defaultCoat: 'Golden Retriever', + treatLabel: 'Give a treat 🦴', + playLabel: 'Throw the ball 🎾', + }, +}; + +const SPECIES_IDS = Object.keys(SPECIES); +const isSpecies = (s) => Object.prototype.hasOwnProperty.call(SPECIES, s); +const speciesOf = (s) => SPECIES[isSpecies(s) ? s : 'cat']; +const coatsFor = (s) => speciesOf(s).coats; +const defaultCoatIndex = (s) => { + const sp = speciesOf(s); + return Math.max(0, sp.coats.indexOf(sp.defaultCoat)); +}; + +const api = { SPECIES, SPECIES_IDS, CAT_COATS, DOG_COATS, isSpecies, speciesOf, coatsFor, defaultCoatIndex }; + +if (typeof module !== 'undefined' && module.exports) module.exports = api; +else if (typeof window !== 'undefined') Object.assign(window, api, { PET_SPECIES: SPECIES }); diff --git a/src/preload.js b/src/preload.js index 1263a11..42d59a5 100644 --- a/src/preload.js +++ b/src/preload.js @@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld('cat', { onSetArea: sub('setarea:start'), onBreak: sub('break'), onTreat: sub('treat'), + onBall: sub('ball'), onPomo: sub('pomo', (d) => d), onGeom: sub('geom', (g) => g), onNotify: sub('notify', (d) => d), diff --git a/src/renderer.js b/src/renderer.js index 1ad730f..d9534e8 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -191,7 +191,63 @@ function composeRearUp(B) { ellipse(8, 20, 2.2, 2.8, 'X', ['C', 'K']); ellipse(16, 24, 2.0, 2.2, 'X', ['C', 'K']); } -const spriteHunt = buildSprite(30, 20, composeHunt); +// ---- species ----------------------------------------------------------------- +// The overlay hosts either a cat or a dog. Rather than branch at every draw site, +// the active species REWRITES the shared sprite/palette tables, so everything +// downstream (tray coats, settings, contact sheet, themes) keeps working unchanged. +// The cat's built-in tables are snapshotted first because applyThemes() mutates +// them in place and we need a clean copy to restore when switching back. +const CAT_BASE = { patterns: PATTERNS.slice(), build: PATTERN_BUILD.slice(), tabby: TABBY.slice() }; +const forcedSpecies = qp.get('species'); +let species = forcedSpecies === 'dog' || forcedSpecies === 'cat' + ? forcedSpecies + : (localStorage.getItem('species') === 'dog' ? 'dog' : 'cat'); +const isDog = () => species === 'dog'; + +function speciesDefs(sp) { + if (sp === 'dog') { + return { + patterns: DOG_PATTERNS, build: DOG_PATTERN_BUILD, tabby: DOG_PATTERN_BUILD.map(() => false), + builds: DOG_BUILDS, + sit: composeSitDog, type: composeTypeDog, loaf: composeCurlDog, rear: composeBegDog, + hunt: composeBowDog, huntCols: 30, huntRows: 22, + typeCols: 24, typeRows: 24, + }; + } + return { + patterns: CAT_BASE.patterns, build: CAT_BASE.build, tabby: CAT_BASE.tabby, + builds: BUILDS, + sit: composeSit, type: composeTypeFront, loaf: composeLoaf, rear: composeRearUp, + hunt: composeHunt, huntCols: 30, huntRows: 20, + typeCols: 24, typeRows: 24, + }; +} + +// Build descriptor for coat i under the active species. Cats carry a `tabby` +// flag; dogs carry breed geometry (ear/tail/leg length) straight off the build. +function buildFor(i, D) { + const b = D.builds[D.build[i]] || {}; + return { ...b, tabby: !!D.tabby[i] }; +} + +// Rewrite the shared coat tables in place. Everything that reads PATTERNS / +// PATTERN_BUILD / TABBY (tray, settings, themes, contact sheet) then sees the +// active species without needing to know a species exists at all. +function installTables(D) { + PATTERNS.length = 0; for (const p of D.patterns) PATTERNS.push(p); + PATTERN_BUILD.length = 0; for (const b of D.build) PATTERN_BUILD.push(b); + TABBY.length = 0; for (const t of D.tabby) TABBY.push(t); +} + +let SPECIES_DEFS = speciesDefs(species); +installTables(SPECIES_DEFS); +let spriteHunt = buildSprite(SPECIES_DEFS.huntCols, SPECIES_DEFS.huntRows, () => SPECIES_DEFS.hunt(buildFor(0, SPECIES_DEFS))); +let huntSprites = null; // dogs vary the bow by breed (markings); cats share one crouch +function huntSpriteFor(i) { return (huntSprites && huntSprites[i]) || spriteHunt; } +function buildHuntSprites(D) { + return D === null || !isDog() ? null + : D.build.map((b, i) => buildSprite(D.huntCols, D.huntRows, () => D.hunt(buildFor(i, D)))); +} const TW = 24 * CELL, TH = 24 * CELL; // front-facing kneading-cat dims (per-coat sprites built below) // Sit grid is always 24x30, so SW/SH and the mochi bands stay constant across the // per-coat body builds (different shapes, same canvas). The sit sprites themselves @@ -269,16 +325,18 @@ const octx = oc.getContext('2d'); octx.imageSmoothingEnabled = false; const HEAD_SRC = 14 * CELL, FEET_SRC = 7 * CELL, MID_SRC = SH - HEAD_SRC - FEET_SRC; -const sprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => composeSit({ ...BUILDS[b], tabby: TABBY[i] }))); +const sprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.sit(buildFor(i, SPECIES_DEFS)))); // each coat also gets its own typing (kneading) body, so every breed types differently // one shared front "kneading cat" shape, recoloured per coat (+ tabby stripes / fluffy tufts) -const typeSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 24, () => composeTypeFront({ tabby: TABBY[i], fluff: BUILDS[b].fluff }))); +const typeSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 24, () => SPECIES_DEFS.type(buildFor(i, SPECIES_DEFS)))); // and a dedicated loaf (resting) body per coat - same 24x30 size as the sit sprite -const loafSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => composeLoaf({ ...BUILDS[b], tabby: TABBY[i] }))); +const loafSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.loaf(buildFor(i, SPECIES_DEFS)))); // and a rear-up "bat the butterfly" body per coat - same 24x30 size as the sit sprite -const rearSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => composeRearUp({ ...BUILDS[b], tabby: TABBY[i] }))); -const DEFAULT_PATTERN = Math.max(0, PATTERNS.findIndex((p) => p.name === 'Tuxedo')); // tuxedo is the out-of-box coat -const storedPattern = localStorage.getItem('pattern'); +const rearSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.rear(buildFor(i, SPECIES_DEFS)))); +huntSprites = buildHuntSprites(SPECIES_DEFS); +const DEFAULT_PATTERN = Math.max(0, PATTERNS.findIndex((p) => p.name === (species === 'dog' ? 'Golden Retriever' : 'Tuxedo'))); +const coatKey = (sp) => (sp === 'dog' ? 'dogPattern' : 'pattern'); +const storedPattern = localStorage.getItem(coatKey(species)); let patternIndex = storedPattern != null ? Number(storedPattern) : DEFAULT_PATTERN; if (!(patternIndex >= 0 && patternIndex < PATTERNS.length)) patternIndex = DEFAULT_PATTERN; const forcedPattern = qp.get('pattern'); @@ -290,29 +348,70 @@ if (forcedPattern) { const i = PATTERNS.findIndex((p) => p.name.toLowerCase().in // Cached "cold" (non-overheat) palette, rebuilt only when the coat changes - avoids // recomputing ~12 colour conversions every single frame. Invalidated in applyThemes(). let _palKey = -1, _coldPalRGB = null, _coldPal = null; -const BASE_PATTERNS = PATTERNS.length; +let BASE_PATTERNS = PATTERNS.length; function applyThemes(list) { _palKey = -1; // coat palettes changed -> force a cold-palette rebuild next frame PATTERNS.length = BASE_PATTERNS; PATTERN_BUILD.length = BASE_PATTERNS; TABBY.length = BASE_PATTERNS; sprites.length = BASE_PATTERNS; typeSprites.length = BASE_PATTERNS; loafSprites.length = BASE_PATTERNS; rearSprites.length = BASE_PATTERNS; for (const th of (Array.isArray(list) ? list : [])) { if (!th || !th.name || !th.coat) continue; - const build = BUILDS[th.build] ? th.build : 'standard'; + const build = (SPECIES_DEFS.builds[th.build] || BUILDS[th.build]) ? th.build : (isDog() ? DOG_PATTERN_BUILD[0] : 'standard'); PATTERNS.push({ name: th.name, coat: th.coat, mark: th.mark || th.coat, white: th.white || th.coat, patch: th.patch || th.coat, eye: th.eye || '#8bbf5a', nose: th.nose || '#e0888f', inner: th.inner || '#f0b6a0', outline: th.outline || '#222831' }); PATTERN_BUILD.push(build); TABBY.push(!!th.tabby); - sprites.push(buildSprite(24, 30, () => composeSit({ ...BUILDS[build], tabby: !!th.tabby }))); - typeSprites.push(buildSprite(24, 24, () => composeTypeFront({ tabby: !!th.tabby, fluff: BUILDS[build].fluff }))); - loafSprites.push(buildSprite(24, 30, () => composeLoaf({ ...BUILDS[build], tabby: !!th.tabby }))); - rearSprites.push(buildSprite(24, 30, () => composeRearUp({ ...BUILDS[build], tabby: !!th.tabby }))); + const D = SPECIES_DEFS, tb = { ...(D.builds[build] || BUILDS[build] || {}), tabby: !!th.tabby }; + sprites.push(buildSprite(24, 30, () => D.sit(tb))); + typeSprites.push(buildSprite(24, 24, () => D.type(tb))); + loafSprites.push(buildSprite(24, 30, () => D.loaf(tb))); + rearSprites.push(buildSprite(24, 30, () => D.rear(tb))); + if (huntSprites) huntSprites.push(buildSprite(D.huntCols, D.huntRows, () => D.hunt(tb))); } if (!(patternIndex >= 0 && patternIndex < PATTERNS.length)) patternIndex = DEFAULT_PATTERN; if (forcedPattern) { const i = PATTERNS.findIndex((p) => p.name.toLowerCase().includes(forcedPattern.toLowerCase())); if (i >= 0) patternIndex = i; } } +// Swap species live (tray or settings). Rebuilds every sprite table in place so +// the running overlay never needs a restart. +function setSpecies(next, coatIdx) { + const want = next === 'dog' ? 'dog' : 'cat'; + if (want === species && coatIdx == null) return false; + const changed = want !== species; + if (changed) { + species = want; + localStorage.setItem('species', species); + SPECIES_DEFS = speciesDefs(species); + installTables(SPECIES_DEFS); + BASE_PATTERNS = PATTERNS.length; + const D = SPECIES_DEFS; + const rebuild = (arr, cols, rows, fn) => { + arr.length = 0; + for (let i = 0; i < D.build.length; i++) arr.push(buildSprite(cols, rows, () => fn(buildFor(i, D)))); + }; + rebuild(sprites, 24, 30, D.sit); + rebuild(typeSprites, 24, 24, D.type); + rebuild(loafSprites, 24, 30, D.loaf); + rebuild(rearSprites, 24, 30, D.rear); + spriteHunt = buildSprite(D.huntCols, D.huntRows, () => D.hunt(buildFor(0, D))); + huntSprites = buildHuntSprites(D); + _palKey = -1; // force a cold-palette rebuild for the new coats + climbImgs = {}; // painted climb frames are cat-only art + ball = null; pantUntil = 0; wagBoost = 0; // drop any in-flight dog-only state + } + const stored = coatIdx != null ? coatIdx : Number(localStorage.getItem(coatKey(species))); + patternIndex = Number.isFinite(stored) && stored >= 0 && stored < PATTERNS.length + ? stored + : Math.max(0, PATTERNS.findIndex((p) => p.name === (isDog() ? 'Golden Retriever' : 'Tuxedo'))); + localStorage.setItem(coatKey(species), String(patternIndex)); + _palKey = -1; + return changed; +} + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); +// Excitement bleeds off after a fetch/greeting rather than snapping back, so the +// tail eases down instead of cutting from a whip to a sway in one frame. +function decayWag(dt) { if (wagBoost > 0) wagBoost = Math.max(0, wagBoost - dt * 0.00035); } const HOT_BODY = '#d9534f', HOT_OUTLINE = '#7a1f1a'; // ---- draw the cat body into context g (local origin 0,0) ------------------- @@ -350,7 +449,10 @@ function drawCat(g, sp, t, palRGB, o) { } } g.globalAlpha = 1; - if (!typing) { + // A dog with cat whiskers reads as a cat wearing a dog costume, so the whisker + // pass is skipped entirely and a panting tongue takes its place. + if (o.panting) drawTongue(g, sp, bob, t, palRGB); + if (!typing && !isDog()) { g.strokeStyle = 'rgba(245,245,245,0.6)'; g.lineWidth = 1; g.lineCap = 'round'; const my = sp.muzzle.y + bob, cl = sp.muzzle.x - 4.5 * CELL, cr = sp.muzzle.x + 4.5 * CELL; // whiskers are alive: a slow waft plus a quick twitch every ~5s (t==0 on the @@ -392,6 +494,80 @@ function drawCat(g, sp, t, palRGB, o) { } } +// A lolling tongue under the muzzle. Dogs pant after exertion and when hot, and +// it is the single cheapest way to make the sprite read as happy rather than blank. +function drawTongue(g, sp, bob, t, palRGB) { + const mx = sp.muzzle.x, my = sp.muzzle.y + bob; + const lol = 3 + Math.sin(t / 260) * 1.6; // the tongue bobs with the breath + const w = 5, h = 4 + lol; + g.fillStyle = 'rgb(90,32,40)'; // dark rim so it reads on any coat + g.fillRect(Math.round(mx - w / 2) - 1, Math.round(my + 2), w + 2, Math.round(h) + 1); + g.fillStyle = (palRGB && palRGB.T) ? rgbStr(palRGB.T) : '#e8747f'; + g.fillRect(Math.round(mx - w / 2), Math.round(my + 2), w, Math.round(h)); + g.fillStyle = 'rgba(255,255,255,0.35)'; + g.fillRect(Math.round(mx - 1), Math.round(my + 4), 1, Math.round(h) - 3); // centre crease +} + +// A dog's tail is a different instrument from a cat's: shorter, thicker, carried +// HIGH, and it wags from the base in a wide fast arc instead of the cat's slow +// rolling S. Shape varies by breed (curl / stub / plume / feather / straight), +// which is a big part of telling the breeds apart at this size. +const TAIL_SHAPE = { + straight: { n: 8, len: 0.055, rest: [-0.55, -0.45, -0.36, -0.28, -0.22, -0.16, -0.10, -0.05], thick: 7, taper: 3.2 }, + feather: { n: 9, len: 0.052, rest: [-0.70, -0.62, -0.55, -0.48, -0.42, -0.36, -0.30, -0.24, -0.18], thick: 9, taper: 4.6 }, + plume: { n: 9, len: 0.050, rest: [-0.95, -0.88, -0.80, -0.72, -0.64, -0.56, -0.48, -0.40, -0.32], thick: 8.5, taper: 4.2 }, + curl: { n: 9, len: 0.048, rest: [-1.30, -1.55, -1.85, -2.20, -2.60, -3.00, -3.40, -3.80, -4.15], thick: 7.5, taper: 3.4 }, + stub: { n: 4, len: 0.045, rest: [-0.85, -0.70, -0.55, -0.42], thick: 8, taper: 3.0 }, +}; +function dogTailShape() { return TAIL_SHAPE[DOG_TAILS[patternIndex] || 'straight'] || TAIL_SHAPE.straight; } + +function drawDogTail(footX, footY, t, pal, flickT0, petting, mood) { + const S = dogTailShape(); + const baseX = footX + SW * 0.19, baseY = footY - SH * 0.30; + const segLen = SH * S.len; + const calm = !!(config && config.reducedMotion); + // Wag rate and amplitude both ride the mood: a calm dog sways, an excited one + // whips. `wagBoost` spikes on greetings, treats and a caught ball. + const excite = clamp((mood || 0) + wagBoost + (petting ? 0.55 : 0), 0, 1.6); + const rate = calm ? 620 : 260 - excite * 130; + const amp = (calm ? 0.10 : 0.20 + excite * 0.34) * (petting ? 1.25 : 1); + let flick = 0; + if (flickT0 >= 0 && t - flickT0 < 650) { const e = (t - flickT0) / 650; flick = Math.sin(e * Math.PI * 4) * (1 - e) * 0.5; } + const wag = Math.sin(t / rate) * amp + flick; + const pts = [[baseX, baseY]]; + let x = baseX, y = baseY, dev = 0; + for (let i = 0; i < S.n; i++) { + const w = (i + 1) / S.n; + dev += wag * w * w * 0.9; // the whole tail swings from the base + const ang = S.rest[i] + dev; + x += Math.cos(ang) * segLen; + y = Math.min(y + Math.sin(ang) * segLen, footY - 2); + pts.push([x, y]); + } + let reach = 0; for (const p of pts) reach = Math.max(reach, Math.abs(p[0] - baseX)); + if (reach > 52) { const f = 52 / reach; for (const p of pts) p[0] = baseX + (p[0] - baseX) * f; } + const sm = [pts[0]]; let px = pts[0][0], py = pts[0][1]; + for (let i = 1; i < pts.length - 1; i++) { + const mx = (pts[i][0] + pts[i + 1][0]) / 2, my = (pts[i][1] + pts[i + 1][1]) / 2; + for (let k = 1; k <= 4; k++) { + const u = k / 4, v = 1 - u; + sm.push([v * v * px + 2 * v * u * pts[i][0] + u * u * mx, v * v * py + 2 * v * u * pts[i][1] + u * u * my]); + } + px = mx; py = my; + } + sm.push(pts[pts.length - 1]); + const n = sm.length - 1; + ctx.lineCap = 'round'; ctx.lineJoin = 'round'; + for (const pass of [0, 1]) { + for (let j = 0; j < n; j++) { + const f = (j + 0.5) / n; + ctx.strokeStyle = pass === 0 ? pal.O : (f > 0.80 ? pal.W : pal.C); + ctx.lineWidth = S.thick - S.taper * f + (pass === 0 ? 3 : 0); + ctx.beginPath(); ctx.moveTo(sm[j][0], sm[j][1]); ctx.lineTo(sm[j + 1][0], sm[j + 1][1]); ctx.stroke(); + } + } +} + function drawSteam(t, headCx, earTop) { for (let i = 0; i < 4; i++) { const ph = (((t + i * 240) % 960) / 960), x = Math.round(headCx + (i - 1.5) * 9), y = Math.round(earTop - 3 - ph * 12), h = Math.max(2, Math.round(5 - ph * 2)); @@ -658,6 +834,10 @@ let lookTarget = null, lookTargetUntil = 0; let nextIdleAt = 0, leanTarget = 0, lean = 0, cursorLean = 0, leanUntil = 0, tailFlickT0 = -1, loafUntil = 0, groomUntil = 0; let playUntil = 0, playT0 = -1, mote = null; // idle paw-play: the cat bats a drifting leaf with a front paw let treat = null; // a dropped treat (tray "Give a treat"): the cat trots over and noms it +// --- dog-only state --------------------------------------------------------- +let ball = null; // { x, y, vx, vy, phase: 'fly'|'rest'|'carry'|'drop', ... } - fetch +let wagBoost = 0; // transient wag speed-up (greeting, fetch, treats) +let pantUntil = 0; // tongue out after exertion let yawnUntil = 0; // occasional sleepy yawn (open mouth + squint) let nextRoam = 0, roamUntil = 0, roamFrom = null, roamTo = null, roamDur = 1500; // autonomous wandering let lastDrawn = 0, wantHighFps = true, rafPaused = false; @@ -783,13 +963,24 @@ if (window.cat) { else if (lj.on && jamRunning && lj.mood !== jamMoodCur) { if (window.jamSetMood) window.jamSetMood(lj.mood); jamMoodCur = lj.mood; } playArea = c.playArea || null; pos.x = zoneClampX(pos.x); pos.y = floorLockOn() ? restingY() : zoneClampY(pos.y); persistPos(); - if (typeof c.pattern === 'number') patternIndex = clamp(c.pattern, 0, PATTERNS.length - 1); + // Species first: it rewrites the coat tables, so the coat index below must be + // read against the NEW species' list, not the outgoing one. + const wantSpecies = c.species === 'dog' ? 'dog' : 'cat'; + const wantCoat = wantSpecies === 'dog' ? c.dogPattern : c.pattern; + if (wantSpecies !== species) { + setSpecies(wantSpecies, typeof wantCoat === 'number' ? wantCoat : null); + if (!SHOT) { wagBoost = 1.0; stretchT0 = performance.now(); } // the new pet says hello + } else if (typeof wantCoat === 'number') { + patternIndex = clamp(wantCoat, 0, PATTERNS.length - 1); + localStorage.setItem(coatKey(species), String(patternIndex)); + } resumeRaf(); }); if (window.cat.onPower) window.cat.onPower((p) => { lowPower = !!(p && p.lowPower); resumeRaf(); }); if (window.cat.onNotify) window.cat.onNotify((d) => triggerNotify(d)); if (window.cat.onBreak) window.cat.onBreak(() => triggerBreak()); if (window.cat.onTreat) window.cat.onTreat(() => dropTreat()); + if (window.cat.onBall) window.cat.onBall(() => throwBall()); if (window.cat.onPomo) window.cat.onPomo((d) => { pomo = d || null; resumeRaf(); }); if (window.cat.onGeom) window.cat.onGeom((g) => { if (g && Number.isFinite(g.bottomInset)) geomBottomInset = g.bottomInset; @@ -823,6 +1014,91 @@ function triggerBreak() { if (config && config.soundOn) playMeow(); resumeRaf(); } +// --- Fetch (dogs only): the tray throws a tennis ball. The ball arcs out under +// gravity, bounces, and settles; the dog bolts after it, picks it up, carries +// it back to its home corner, drops it, then wags and pants. This is the whole +// reason a dog is worth having as a desktop pet rather than a recoloured cat. +const FETCH_GRAB = 22; // how close the dog must get to pick the ball up +const BALL_R = 5; +function throwBall() { + if (SHOT || !pos || !isDog()) return; + const side = pos.x < viewW / 2 ? 1 : -1; + ball = { + x: pos.x, y: pos.y - SH * 0.55, + vx: side * (5.2 + Math.random() * 2.2), vy: -6.4 - Math.random() * 1.6, + phase: 'fly', spin: 0, restAt: 0, + }; + addEnergy(16); // a thrown ball is the best thing that has ever happened + wagBoost = 0.9; + if (config && config.soundOn) playChirp(); + resumeRaf(); +} +function ballApproachX() { return zoneClampX(ball.x); } +function updateBall(t, dt, f) { + if (!ball) return; + const floor = restingY(); + ball.spin += (ball.phase === 'fly' ? 0.4 : 0.08) * (dt / 16); + + if (ball.phase === 'fly') { // ballistic arc + a couple of bounces + const k = dt / 16; + ball.vy += 0.62 * k; + ball.x += ball.vx * k; ball.y += ball.vy * k; + if (ball.y >= floor - BALL_R) { + ball.y = floor - BALL_R; + if (Math.abs(ball.vy) > 1.6) { ball.vy = -ball.vy * 0.46; ball.vx *= 0.72; } + else { ball.vy = 0; ball.vx = 0; ball.phase = 'rest'; ball.restAt = t; } + } + if (ball.x < EDGE_L || ball.x > viewW - EDGE_R) { ball.vx = -ball.vx * 0.5; ball.x = clamp(ball.x, EDGE_L, viewW - EDGE_R); } + return; + } + if (ball.phase === 'carry') { // held in the mouth, tracks the muzzle + ball.x = pos.x + (ball.side || 1) * 6; + ball.y = pos.y - SH * 0.52; + const home = homeX(); + if (roamUntil <= t) { + if (Math.abs(pos.x - home) > 14) { + roamFrom = { x: pos.x, y: pos.y }; + roamTo = { x: home, y: floorLockOn() ? restingY() : pos.y }; + roamDur = clamp(Math.abs(pos.x - home) * 2.4, 400, 1700); roamUntil = t + roamDur; nextRoam = t + 20000; + } else { // delivered: drop it, wag, pant, ask for another + ball.phase = 'rest'; ball.restAt = t; ball.y = floor - BALL_R; + wagBoost = 1.2; pantUntil = t + 4200; addEnergy(10); + popLove(t, pos.x, pos.y - SH * 0.8, 2, 16); + if (config && config.soundOn) playChirp(); + } + } + return; + } + // 'rest': lying on the floor. The dog goes and gets it. + if (f.grabbing || f.hunting || f.startleActive || f.typing || paperLen > 1) return; + if (t - ball.restAt > 45000) { ball = null; return; } // forgotten after a while + const dist = Math.abs(pos.x - ballApproachX()); + if (dist <= FETCH_GRAB) { + ball.phase = 'carry'; ball.side = Math.sign(ball.x - pos.x) || 1; + wagBoost = 1.0; roamUntil = 0; + lookTarget = { x: ball.x, y: ball.y }; lookTargetUntil = t + 600; + return; + } + if (roamUntil <= t) { // sprint after it + roamFrom = { x: pos.x, y: pos.y }; + roamTo = { x: ballApproachX(), y: floorLockOn() ? restingY() : pos.y }; + roamDur = clamp(dist * 1.9, 350, 1500); roamUntil = t + roamDur; nextRoam = t + 20000; + lookTarget = { x: ball.x, y: ball.y }; lookTargetUntil = t + roamDur; + } +} +function drawBall(t) { + if (!ball) return; + const x = Math.round(ball.x), y = Math.round(ball.y); + if (ball.phase !== 'carry') drawShadow(x, restingY(), 0.18, 7); + ctx.fillStyle = '#3f4a1e'; // dark rim keeps it readable on any wallpaper + ctx.beginPath(); ctx.arc(x, y, BALL_R + 1, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = '#d8e84a'; // tennis yellow-green + ctx.beginPath(); ctx.arc(x, y, BALL_R, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = '#f4f7dd'; ctx.lineWidth = 1.4; // the seam, rotating with the spin + ctx.beginPath(); ctx.arc(x - Math.cos(ball.spin) * 3, y, BALL_R - 0.6, -1.1, 1.1); ctx.stroke(); + ctx.beginPath(); ctx.arc(x + Math.cos(ball.spin) * 3, y, BALL_R - 0.6, Math.PI - 1.1, Math.PI + 1.1); ctx.stroke(); +} + // --- Treat: tray "Give a treat" drops a little fish nearby; the cat trots over and // noms it (look down, hearts, a happy chirp, an energy bump). Uses the roam // machinery to walk, re-aiming each time the walk expires until it arrives. @@ -1215,6 +1491,13 @@ function isNight(t) { const STARTLE_RANGE = 160; // only flinch when the cursor lunges NEAR the cat - not on every fast move across the screen function bandOf(e) { return e <= CALM_MAX ? 'calm' : e <= PLAYFUL_MAX ? 'playful' : 'zoomies'; } function addEnergy(n) { energy = clamp(energy + n, 0, 100); } +// A dog that has just been sprinting pants. Tied to the zoomies band so it shows +// up exactly when the sprite is already visibly worked up. +function updateDogVitals(t, dt) { + if (!isDog()) { wagBoost = 0; pantUntil = 0; return; } + decayWag(dt); + if (energy > PLAYFUL_MAX && t > pantUntil) pantUntil = t + 3000; +} function restSprings() { head = { x: pos.x, y: pos.y - SH, vx: 0, vy: 0 }; feet = { x: pos.x, y: pos.y, vx: 0, vy: 0 }; } function persistPos() { localStorage.setItem('pos', JSON.stringify({ x: pos.x, y: pos.y })); } @@ -1567,6 +1850,7 @@ function draw(t) { W: toRgb(lerpHex(P.white, HOT_BODY, heatT * 0.5)), X: toRgb(lerpHex(P.patch, HOT_BODY, heatT)), I: toRgb(P.inner), N: toRgb(P.nose), E: toRgb(P.eye), H: toRgb(HALO), + T: toRgb(P.tongue || '#e8747f'), }; pal = { O: rgbStr(palRGB.O), C: rgbStr(palRGB.C), W: rgbStr(palRGB.W), N: rgbStr(palRGB.N) }; } else { // common case: reuse the cached cold palette for this coat @@ -1574,6 +1858,7 @@ function draw(t) { _coldPalRGB = { O: toRgb(P.outline), C: toRgb(P.coat), K: toRgb(P.mark), W: toRgb(P.white), X: toRgb(P.patch), I: toRgb(P.inner), N: toRgb(P.nose), E: toRgb(P.eye), H: toRgb(HALO), + T: toRgb(P.tongue || '#e8747f'), }; _coldPal = { O: rgbStr(_coldPalRGB.O), C: rgbStr(_coldPalRGB.C), W: rgbStr(_coldPalRGB.W), N: rgbStr(_coldPalRGB.N) }; _palKey = patternIndex; @@ -1675,7 +1960,7 @@ function draw(t) { } drawShadow(pos.x, pos.y, 0.18, 26); octx.clearRect(0, 0, oc.width, oc.height); - drawCat(octx, spriteHunt, t, palRGB, { bob: creep, blinking, look, eyeMode: 'open', dilate: pouncing ? 1.5 : (windingUp ? 1.32 + coil * 0.2 : 1.32) }); + drawCat(octx, huntSpriteFor(patternIndex), t, palRGB, { bob: creep, blinking, look, eyeMode: 'open', dilate: pouncing ? 1.5 : (windingUp ? 1.32 + coil * 0.2 : 1.32) }); ctx.save(); if (facingLeft) { ctx.translate(ox + HW, oy); ctx.scale(-1, stretchY); ctx.drawImage(oc, 0, 0, HW, HH, 0, 0, HW, HH); } else { ctx.translate(ox, oy); ctx.scale(1, stretchY); ctx.drawImage(oc, 0, 0, HW, HH, 0, 0, HW, HH); } @@ -1795,6 +2080,8 @@ function draw(t) { lean += (leanTarget - lean) * 0.09 * step; updateButterflyDesk(t, dt, step, { follow, grabbing, hunting, typing, petting, startleActive, calm }); updateTreat(t, { grabbing, hunting, typing, startleActive }); + updateBall(t, dt, { grabbing, hunting, typing, startleActive }); + updateDogVitals(t, dt); const gaze = lookTarget || look; smoothLook.x += (gaze.x - smoothLook.x) * 0.18 * step; // snappier cursor tracking smoothLook.y += (gaze.y - smoothLook.y) * 0.18 * step; @@ -1860,7 +2147,11 @@ function draw(t) { const ox = Math.round(pos.x - SW / 2) + wig, oy = Math.round(pos.y - SH) - Math.round(hop) - Math.round(petPush); const shadowA = (petting || bodyPet) ? 0.14 + Math.sin(t / 800) * 0.05 : 0.18; drawShadow(pos.x + wig, pos.y, shadowA); - if (!stretching && !thinking && !working && !loafing && !climbRaster) drawTail(pos.x + wig, pos.y, t, pal, tailFlickT0, petting); // loaf has a baked, wrapped tail; the climb frame has its own tail + if (!stretching && !thinking && !working && !loafing && !climbRaster) { + // loaf/curl has a baked, wrapped tail; the climb frame has its own + if (isDog()) drawDogTail(pos.x + wig, pos.y, t, pal, tailFlickT0, petting, energy / 100 + (ball && ball.phase === 'carry' ? 0.5 : 0)); + else drawTail(pos.x + wig, pos.y, t, pal, tailFlickT0, petting); + } if (!lowPower && restIdle && band === 'calm' && !paperActive && t > nextIdleSparkle) { // ambient sparkles off in low power (they pin the loop at 60fps) idleSparkles.push({ x: pos.x + (Math.random() - 0.5) * 8, y: oy, t0: t }); nextIdleSparkle = t + 5000 + Math.random() * 4000; @@ -1876,7 +2167,7 @@ function draw(t) { } else { octx.clearRect(0, 0, oc.width, oc.height); const stareDilate = (staring && t - staringT0 < 1800) ? 1.12 : 1; // subtle wide-eyed fixate - drawCat(octx, loafing ? loafSprite : catSprite, t, palRGB, { bob, blinking, look: eLook, eyeMode: emode, blush: petting || bodyPet, dilate: stareDilate }); + drawCat(octx, loafing ? loafSprite : catSprite, t, palRGB, { bob, blinking, look: eLook, eyeMode: emode, blush: petting || bodyPet, dilate: stareDilate, panting: isDog() && t < pantUntil && !loafing }); if (paperActive && !petting && !stretching) { // both front paws lift off the ground to grip the rope, so paint over the // baked planted front legs+paws on the offscreen sprite (so it scales/flips @@ -1959,6 +2250,7 @@ function draw(t) { drawButterfly(ctx, bfX, bfY, BF_SCALE, BFLY_STYLES[bfPal], bfFlap, t, clamp(bfVx / 44, -0.22, 0.22)); } drawTreat(); // the fish sits on the floor line, under the hearts + drawBall(t); // the tennis ball rides above the floor line (and the muzzle when carried) // floating hearts (update + draw; persist after petting ends) if (hearts.length) hearts = hearts.filter((h) => t - h.t0 < (h.life || 1100)); for (const h of hearts) { diff --git a/src/settings.html b/src/settings.html index dcf5eb3..6903b70 100644 --- a/src/settings.html +++ b/src/settings.html @@ -350,6 +350,8 @@ + + diff --git a/tests/pets.test.js b/tests/pets.test.js new file mode 100644 index 0000000..4636694 --- /dev/null +++ b/tests/pets.test.js @@ -0,0 +1,90 @@ +const test = require('node:test'); +const assert = require('node:assert'); + +const cat = require('../src/cat-sprite.js'); +const dog = require('../src/dog-sprite.js'); +const pets = require('../src/pets.js'); +dog.attach(cat); + +const POSES = [ + ['sit', 24, 30, dog.composeSitDog], + ['bow', 30, 22, dog.composeBowDog], + ['type', 24, 24, dog.composeTypeDog], + ['curl', 24, 30, dog.composeCurlDog], + ['beg', 24, 30, dog.composeBegDog], +]; + +const buildFor = (i) => dog.DOG_BUILDS[dog.DOG_PATTERN_BUILD[i]]; + +test('every dog breed maps to a real build and a real tail', () => { + assert.equal(dog.DOG_PATTERNS.length, dog.DOG_PATTERN_BUILD.length); + assert.equal(dog.DOG_PATTERNS.length, dog.DOG_TAILS.length); + for (let i = 0; i < dog.DOG_PATTERNS.length; i++) { + assert.ok(dog.DOG_BUILDS[dog.DOG_PATTERN_BUILD[i]], `${dog.DOG_PATTERNS[i].name} has no build`); + assert.ok(dog.DOG_TAILS[i], `${dog.DOG_PATTERNS[i].name} has no tail shape`); + } +}); + +test('dog palettes define every role the renderer paints', () => { + // A missing key silently drops those cells (drawCat skips unknown roles), so the + // breed would render with holes rather than fail loudly. Catch it here instead. + const keys = ['name', 'coat', 'mark', 'white', 'patch', 'eye', 'nose', 'inner', 'outline', 'tongue']; + for (const p of dog.DOG_PATTERNS) { + for (const k of keys) assert.ok(p[k], `${p.name} is missing ${k}`); + for (const k of keys.slice(1)) assert.match(p[k], /^#[0-9a-f]{6}$/i, `${p.name}.${k} is not a hex colour`); + } +}); + +test('every breed composes every pose with a visible body and eyes', () => { + for (let i = 0; i < dog.DOG_PATTERNS.length; i++) { + const B = buildFor(i); + for (const [pose, cols, rows, compose] of POSES) { + const sp = cat.buildSprite(cols, rows, () => compose(B)); + const flat = sp.grid.flat(); + const body = flat.filter((c) => 'CKWXI'.includes(c)).length; + const label = `${dog.DOG_PATTERNS[i].name}/${pose}`; + assert.ok(body > 120, `${label} drew only ${body} body cells`); + assert.ok(flat.includes('E'), `${label} has no eye`); + assert.ok(flat.includes('N'), `${label} has no nose`); + assert.ok(flat.includes('O'), `${label} has no outline`); + } + } +}); + +test('the sitting dog keeps its muzzle below the eyes and inside the grid', () => { + // The muzzle protruding past the skull is the whole reason a dog does not read + // as a cat, so lock the geometry that produces it. + for (let i = 0; i < dog.DOG_PATTERNS.length; i++) { + const sp = cat.buildSprite(24, 30, () => dog.composeSitDog(buildFor(i))); + const name = dog.DOG_PATTERNS[i].name; + const eyeY = Math.min(...sp.eyes.filter((e) => e.w > 0).map((e) => e.cy)); + assert.ok(sp.muzzle.y > eyeY, `${name}: muzzle is not below the eyes`); + assert.equal(sp.eyes.filter((e) => e.w > 0).length, 2, `${name}: expected two eyes facing forward`); + } +}); + +test('dwarf breeds really do get shorter legs than standard ones', () => { + const corgi = dog.DOG_PATTERNS.findIndex((p) => p.name === 'Corgi'); + const golden = dog.DOG_PATTERNS.findIndex((p) => p.name === 'Golden Retriever'); + assert.ok(corgi >= 0 && golden >= 0); + assert.ok(dog.DOG_BUILDS[dog.DOG_PATTERN_BUILD[corgi]].legLen < 1, 'corgi should be short-legged'); + assert.equal(dog.DOG_BUILDS[dog.DOG_PATTERN_BUILD[golden]].legLen, undefined, 'golden should use full-length legs'); +}); + +test('species registry agrees with the sprite modules', () => { + assert.deepEqual(pets.coatsFor('dog'), dog.DOG_PATTERNS.map((p) => p.name)); + assert.deepEqual(pets.coatsFor('cat'), cat.PATTERNS.map((p) => p.name)); + assert.equal(pets.speciesOf('nonsense').id, 'cat', 'unknown species must fall back to cat'); + assert.ok(pets.defaultCoatIndex('dog') >= 0); + assert.ok(pets.defaultCoatIndex('cat') >= 0); +}); + +test('config normalises species and keeps a coat per species', () => { + // config.js pulls in electron for app.getPath, so exercise the pure helpers via + // the same rules rather than booting Electron here. + assert.equal(pets.isSpecies('dog'), true); + assert.equal(pets.isSpecies('cat'), true); + assert.equal(pets.isSpecies('ferret'), false); + const dogDefault = pets.defaultCoatIndex('dog'); + assert.equal(pets.coatsFor('dog')[dogDefault], 'Golden Retriever'); +}); From 784d54d5fb793a45bc33a1df93b39ed6af149a56 Mon Sep 17 00:00:00 2001 From: JohnsonKC201 Date: Wed, 29 Jul 2026 00:36:06 -0700 Subject: [PATCH 2/2] feat(settings): species picker + breed list in the settings window The tray could already switch pets, but the settings window still listed cat coats regardless of species and wrote every pick to `pattern`. It now: - offers a Pet dropdown (Cat / Dog) - swaps the list to the 14 breeds and relabels "Coat" to "Breed" for a dog - writes to `dogPattern` vs `pattern` so each species keeps its own choice - renders a live dog preview, whiskerless, via the canine composer Custom coats stay cat-only: they are built from the cat's geometry, so offering them for a dog would compose a cat silhouette in a dog's palette. site/cat-preview.js re-ported to match, per the drift guard in site-sync.test.js. drawDog is inert there because the site bundle has no canine composer, which the `typeof composeSitDog` guard already handles. --- site/cat-preview.js | 32 ++++++++++++++++++++++++++------ src/cat-preview.js | 28 +++++++++++++++++++++++----- src/settings-renderer.js | 29 +++++++++++++++++++++++++---- src/settings.html | 7 ++++++- 4 files changed, 80 insertions(+), 16 deletions(-) diff --git a/site/cat-preview.js b/site/cat-preview.js index f57270e..091416f 100644 --- a/site/cat-preview.js +++ b/site/cat-preview.js @@ -2,9 +2,12 @@ // come from the shared cat-sprite.js (loaded first); this only adds the static // single-frame sit-sprite draw. Ported verbatim from src/cat-preview.js so the // gallery cats match the desktop sprite exactly. Exposes -// window.PixelcatPreview = { PATTERNS, PATTERN_BUILD, TABBY, BUILDS, draw }. +// window.PixelcatPreview = { PATTERNS, PATTERN_BUILD, TABBY, BUILDS, draw, drawDog }. +// drawDog is a no-op on the site: it guards on composeSitDog, which the site's +// cat-sprite.js bundle does not define. (function () { - function drawCatStatic(g, sp, palRGB) { + function drawCatStatic(g, sp, palRGB, opts) { + const noWhiskers = !!(opts && opts.noWhiskers); const grid = sp.grid, COLS = sp.COLS, ROWS = sp.ROWS; for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { const ch = grid[r][c]; if (ch === '.') continue; @@ -13,9 +16,11 @@ g.fillStyle = f === 1 ? rgbStr(base) : shadeStr(base, f); g.fillRect(c * CELL, r * CELL, CELL, CELL); } - g.strokeStyle = 'rgba(245,245,245,0.6)'; g.lineWidth = 1; g.lineCap = 'round'; - const my = sp.muzzle.y, cl = sp.muzzle.x - 4.5 * CELL, cr = sp.muzzle.x + 4.5 * CELL; - for (const [sx, dir] of [[cl, -1], [cr, 1]]) for (let i = 0; i < 3; i++) { g.beginPath(); g.moveTo(sx, my + i * 3 - 2); g.lineTo(sx + dir * 13, my + i * 5 - 1); g.stroke(); } + if (!noWhiskers) { + g.strokeStyle = 'rgba(245,245,245,0.6)'; g.lineWidth = 1; g.lineCap = 'round'; + const my = sp.muzzle.y, cl = sp.muzzle.x - 4.5 * CELL, cr = sp.muzzle.x + 4.5 * CELL; + for (const [sx, dir] of [[cl, -1], [cr, 1]]) for (let i = 0; i < 3; i++) { g.beginPath(); g.moveTo(sx, my + i * 3 - 2); g.lineTo(sx + dir * 13, my + i * 5 - 1); g.stroke(); } + } for (const e of sp.eyes) { if (!isFinite(e.cx) || e.w <= 0) continue; const pw = Math.max(4, Math.round(e.w * 0.46)), ph = Math.max(5, Math.round(e.h * 0.7)); @@ -39,5 +44,20 @@ const dw = sp.SW * scale, dh = sp.SH * scale; ctx.drawImage(oc, 0, 0, sp.SW, sp.SH, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh); } - window.PixelcatPreview = { PATTERNS, PATTERN_BUILD, TABBY, BUILDS, draw }; + // Same preview for the dog, using the canine composer and no whiskers. + function drawDog(canvas, pal, build) { + if (!canvas || !pal || typeof composeSitDog !== 'function') return; + const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + const B = Object.assign({}, DOG_BUILDS[build] || DOG_BUILDS[DOG_PATTERN_BUILD[0]]); + const sp = buildSprite(24, 30, () => composeSitDog(B)); + const palRGB = { O: toRgb(pal.outline), C: toRgb(pal.coat), K: toRgb(pal.mark), W: toRgb(pal.white), X: toRgb(pal.patch), I: toRgb(pal.inner), N: toRgb(pal.nose), E: toRgb(pal.eye), H: toRgb(HALO), T: toRgb(pal.tongue || '#e8747f') }; + const oc = document.createElement('canvas'); oc.width = sp.SW; oc.height = sp.SH; + const octx = oc.getContext('2d'); octx.imageSmoothingEnabled = false; + drawCatStatic(octx, sp, palRGB, { noWhiskers: true }); + const scale = Math.min((canvas.width - 8) / sp.SW, (canvas.height - 8) / sp.SH); + const dw = sp.SW * scale, dh = sp.SH * scale; + ctx.drawImage(oc, 0, 0, sp.SW, sp.SH, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh); + } + window.PixelcatPreview = { PATTERNS, PATTERN_BUILD, TABBY, BUILDS, draw, drawDog }; })(); diff --git a/src/cat-preview.js b/src/cat-preview.js index c6f1ec8..34bc8c4 100644 --- a/src/cat-preview.js +++ b/src/cat-preview.js @@ -4,7 +4,8 @@ // BUILDS, draw } (the palette arrays are re-exported from cat-sprite for the // settings dropdown/preview). (function () { - function drawCatStatic(g, sp, palRGB) { + function drawCatStatic(g, sp, palRGB, opts) { + const noWhiskers = !!(opts && opts.noWhiskers); const grid = sp.grid, COLS = sp.COLS, ROWS = sp.ROWS; for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) { const ch = grid[r][c]; if (ch === '.') continue; @@ -13,9 +14,11 @@ g.fillStyle = f === 1 ? rgbStr(base) : shadeStr(base, f); g.fillRect(c * CELL, r * CELL, CELL, CELL); } - g.strokeStyle = 'rgba(245,245,245,0.6)'; g.lineWidth = 1; g.lineCap = 'round'; - const my = sp.muzzle.y, cl = sp.muzzle.x - 4.5 * CELL, cr = sp.muzzle.x + 4.5 * CELL; - for (const [sx, dir] of [[cl, -1], [cr, 1]]) for (let i = 0; i < 3; i++) { g.beginPath(); g.moveTo(sx, my + i * 3 - 2); g.lineTo(sx + dir * 13, my + i * 5 - 1); g.stroke(); } + if (!noWhiskers) { + g.strokeStyle = 'rgba(245,245,245,0.6)'; g.lineWidth = 1; g.lineCap = 'round'; + const my = sp.muzzle.y, cl = sp.muzzle.x - 4.5 * CELL, cr = sp.muzzle.x + 4.5 * CELL; + for (const [sx, dir] of [[cl, -1], [cr, 1]]) for (let i = 0; i < 3; i++) { g.beginPath(); g.moveTo(sx, my + i * 3 - 2); g.lineTo(sx + dir * 13, my + i * 5 - 1); g.stroke(); } + } for (const e of sp.eyes) { if (!isFinite(e.cx) || e.w <= 0) continue; const pw = Math.max(4, Math.round(e.w * 0.46)), ph = Math.max(5, Math.round(e.h * 0.7)); @@ -39,5 +42,20 @@ const dw = sp.SW * scale, dh = sp.SH * scale; ctx.drawImage(oc, 0, 0, sp.SW, sp.SH, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh); } - window.PixelcatPreview = { PATTERNS, PATTERN_BUILD, TABBY, BUILDS, draw }; + // Same preview for the dog, using the canine composer and no whiskers. + function drawDog(canvas, pal, build) { + if (!canvas || !pal || typeof composeSitDog !== 'function') return; + const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + const B = Object.assign({}, DOG_BUILDS[build] || DOG_BUILDS[DOG_PATTERN_BUILD[0]]); + const sp = buildSprite(24, 30, () => composeSitDog(B)); + const palRGB = { O: toRgb(pal.outline), C: toRgb(pal.coat), K: toRgb(pal.mark), W: toRgb(pal.white), X: toRgb(pal.patch), I: toRgb(pal.inner), N: toRgb(pal.nose), E: toRgb(pal.eye), H: toRgb(HALO), T: toRgb(pal.tongue || '#e8747f') }; + const oc = document.createElement('canvas'); oc.width = sp.SW; oc.height = sp.SH; + const octx = oc.getContext('2d'); octx.imageSmoothingEnabled = false; + drawCatStatic(octx, sp, palRGB, { noWhiskers: true }); + const scale = Math.min((canvas.width - 8) / sp.SW, (canvas.height - 8) / sp.SH); + const dw = sp.SW * scale, dh = sp.SH * scale; + ctx.drawImage(oc, 0, 0, sp.SW, sp.SH, (canvas.width - dw) / 2, (canvas.height - dh) / 2, dw, dh); + } + window.PixelcatPreview = { PATTERNS, PATTERN_BUILD, TABBY, BUILDS, draw, drawDog }; })(); diff --git a/src/settings-renderer.js b/src/settings-renderer.js index b09385e..9ca0d2f 100644 --- a/src/settings-renderer.js +++ b/src/settings-renderer.js @@ -6,18 +6,33 @@ let cfg = null; // Populate the coat dropdown from the built-in names plus any custom coats. let themes = []; +// Which species the window is currently editing, and therefore which list of +// coats to show and which config field a pick writes to. +function curSpecies() { return (cfg && cfg.species === 'dog') ? 'dog' : 'cat'; } +function coatField() { return curSpecies() === 'dog' ? 'dogPattern' : 'pattern'; } function populateCoats() { const sel = $('pattern'); const cur = sel.value; + const dog = curSpecies() === 'dog'; sel.innerHTML = ''; - const names = (window.PATTERN_NAMES || []).concat(themes.map((t) => t.name)); + // Custom coats are built from the cat's geometry, so they are only offered for cats. + const base = dog ? (window.DOG_COATS || []) : (window.PATTERN_NAMES || []); + const names = dog ? base.slice() : base.concat(themes.map((t) => t.name)); names.forEach((name, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = name; sel.appendChild(o); }); - if (cfg) sel.value = String(cfg.pattern || 0); else if (cur) sel.value = cur; + const lbl = $('coatLabel'); if (lbl) lbl.textContent = dog ? 'Breed' : 'Coat'; + if (cfg) sel.value = String(cfg[coatField()] || 0); else if (cur) sel.value = cur; + const spSel = $('species'); if (spSel) spSel.value = curSpecies(); drawPreview(); } function drawPreview() { const P = window.PixelcatPreview, cv = $('coatPreview'); if (!P || !cv) return; const i = Number($('pattern').value) || 0; + if (curSpecies() === 'dog') { + const pal = (window.DOG_PATTERNS || [])[i]; + if (!pal || !P.drawDog) return; + P.drawDog(cv, pal, (window.DOG_PATTERN_BUILD || [])[i]); + return; + } let pal, build, tabby; if (i < P.PATTERNS.length) { pal = P.PATTERNS[i]; build = P.PATTERN_BUILD[i]; tabby = P.TABBY[i]; } else { const t = themes[i - P.PATTERNS.length]; if (!t) return; pal = t; build = t.build; tabby = t.tabby; } @@ -42,7 +57,7 @@ function render() { // Don't stomp the name field while the user is typing in it (a broadcast config // echo would otherwise overwrite it with the normalized value and jump the caret). if (document.activeElement !== $('name')) $('name').value = cfg.name || ''; - $('pattern').value = String(cfg.pattern || 0); + $('pattern').value = String(cfg[coatField()] || 0); $('breakMinutes').value = String(cfg.breakMinutes || 0); $('followCursor').checked = !!cfg.followCursor; $('huntOn').checked = !!cfg.huntOn; @@ -115,7 +130,13 @@ $('name').addEventListener('input', () => { clearTimeout(nameTimer); nameTimer = setTimeout(() => save({ name: $('name').value }), 300); }); -$('pattern').addEventListener('change', () => { save({ pattern: Number($('pattern').value) }); drawPreview(); }); +$('pattern').addEventListener('change', () => { save({ [coatField()]: Number($('pattern').value) }); drawPreview(); }); +$('species').addEventListener('change', () => { + const next = $('species').value === 'dog' ? 'dog' : 'cat'; + if (cfg) cfg.species = next; // optimistic, so the list swaps before main echoes back + save({ species: next }); + populateCoats(); +}); $('breakMinutes').addEventListener('change', () => save({ breakMinutes: Number($('breakMinutes').value) })); $('followCursor').addEventListener('change', () => save({ followCursor: $('followCursor').checked })); $('huntOn').addEventListener('change', () => save({ huntOn: $('huntOn').checked })); diff --git a/src/settings.html b/src/settings.html index 6903b70..3e4af32 100644 --- a/src/settings.html +++ b/src/settings.html @@ -161,7 +161,12 @@
- + + +