Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
<div align="center">

<img src="assets/logo-mark.png" alt="pixelcat logo" width="112" />
<img src="assets/logo-mark.png" alt="pixelpets logo" width="112" />

# 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.

<br />

Expand All @@ -34,6 +37,32 @@ built from scratch: nearly every sprite, animation, and sound is original and pr

</div>

## 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

<table align="center">
Expand Down
11 changes: 9 additions & 2 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 } },
},
{
Expand All @@ -58,7 +65,7 @@ module.exports = [
{
// cat-sprite.js / patterns.js are dual-loaded: classic <script> in the overlay AND
// CommonJS modules in Node (make-app-icon.js / main.js). They DEFINE shared globals.
files: ['src/cat-sprite.js', 'src/patterns.js'],
files: ['src/cat-sprite.js', 'src/dog-sprite.js', 'src/patterns.js', 'src/pets.js'],
languageOptions: { sourceType: 'commonjs', ecmaVersion: 2023, globals: { ...globals.node, ...globals.browser } },
},
{
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "pixelcat",
"name": "pixelpets",
"version": "0.2.0",
"description": "A pixel cat that lives on your desktop",
"description": "A pixel cat or dog that lives on your desktop",
"main": "src/main.js",
"author": "JOhnsonKC201",
"license": "MIT",
Expand All @@ -13,6 +13,8 @@
"start": "electron .",
"autostart:off": "electron . --autostart=off",
"sheet": "electron . --sheet",
"sheet:dog": "node scripts/pet-sheet.js dog",
"sheet:cat": "node scripts/pet-sheet.js cat",
"icon": "node scripts/make-logo-icons.js",
"social": "node scripts/make-social-card.js",
"climb-frames": "node scripts/embed-climb-frames.js",
Expand Down Expand Up @@ -45,7 +47,7 @@
},
"build": {
"appId": "com.johnsonkc.pixelcat",
"productName": "pixelcat",
"productName": "pixelpets",
"npmRebuild": false,
"directories": {
"output": "dist"
Expand Down
113 changes: 113 additions & 0 deletions scripts/pet-sheet.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Headless QA contact sheet: renders every breed x pose for a species straight to
// a PNG, with no Electron and no canvas dependency. This is how the dog poses get
// checked while they are being drawn - reading grid code cannot tell you whether a
// silhouette actually reads as a dog.
// node scripts/pet-sheet.js dog -> previews/dog-sheet.png
// node scripts/pet-sheet.js cat -> previews/cat-sheet.png
const fs = require('fs');
const zlib = require('zlib');
const path = require('path');

const cat = require('../src/cat-sprite.js');
const dog = require('../src/dog-sprite.js');
dog.attach(cat); // the dog module composes with cat-sprite's grid primitives

const SCALE = 3; // px per sprite cell in the sheet
const PAD = 10;
const BG = [24, 26, 34, 255];

const SPECIES = {
cat: {
patterns: cat.PATTERNS,
build: cat.PATTERN_BUILD,
builds: cat.BUILDS,
extra: (i) => ({ tabby: cat.TABBY[i] }),
poses: [
['sit', 24, 30, (B) => cat.composeSit(B)],
],
},
dog: {
patterns: dog.DOG_PATTERNS,
build: dog.DOG_PATTERN_BUILD,
builds: dog.DOG_BUILDS,
extra: () => ({}),
poses: [
['sit', 24, 30, (B) => dog.composeSitDog(B)],
['bow', 30, 22, (B) => dog.composeBowDog(B)],
['type', 24, 24, (B) => dog.composeTypeDog(B)],
['curl', 24, 30, (B) => dog.composeCurlDog(B)],
['beg', 24, 30, (B) => dog.composeBegDog(B)],
],
},
};

// role char -> palette key
const ROLE = { C: 'coat', K: 'mark', W: 'white', X: 'patch', I: 'inner', N: 'nose', E: 'eye', O: 'outline', T: 'tongue' };

function hexToRgb(h) { const n = parseInt(String(h).slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }

function blitSprite(buf, W, sp, ox, oy, pal) {
for (let r = 0; r < sp.ROWS; r++) for (let c = 0; c < sp.COLS; c++) {
const ch = sp.grid[r][c];
if (ch === '.') continue;
let rgb, a = 255;
if (ch === 'H') { rgb = [251, 253, 255]; a = 110; }
else {
const key = ROLE[ch];
if (!key || !pal[key]) continue;
rgb = hexToRgb(pal[key]);
// same top-lit body shading the renderer uses, so the sheet matches the app
if ('CKWXI'.includes(ch)) { const f = Math.max(0.82, 1.12 - (r / sp.ROWS) * 0.34); rgb = rgb.map((v) => Math.min(255, Math.round(v * f))); }
else if (ch === 'O') { const f = 1.16 - (r / sp.ROWS) * 0.30; rgb = rgb.map((v) => Math.min(255, Math.round(v * f))); }
}
for (let dy = 0; dy < SCALE; dy++) for (let dx = 0; dx < SCALE; dx++) {
const X = ox + c * SCALE + dx, Y = oy + r * SCALE + dy;
const i = (Y * W + X) * 4, al = a / 255;
buf[i] = Math.round(rgb[0] * al + buf[i] * (1 - al));
buf[i + 1] = Math.round(rgb[1] * al + buf[i + 1] * (1 - al));
buf[i + 2] = Math.round(rgb[2] * al + buf[i + 2] * (1 - al));
buf[i + 3] = 255;
}
}
}

function crc(b) { let c = ~0; for (let i = 0; i < b.length; i++) { c ^= b[i]; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); } return ~c >>> 0; }
function chunk(t, d) { const l = Buffer.alloc(4); l.writeUInt32BE(d.length, 0); const b = Buffer.concat([Buffer.from(t), d]); const cc = Buffer.alloc(4); cc.writeUInt32BE(crc(b), 0); return Buffer.concat([l, b, cc]); }
function encodePng(rgba, w, h) {
const ih = Buffer.alloc(13); ih.writeUInt32BE(w, 0); ih.writeUInt32BE(h, 4); ih[8] = 8; ih[9] = 6;
const stride = w * 4, raw = Buffer.alloc(h * (stride + 1));
for (let y = 0; y < h; y++) { raw[y * (stride + 1)] = 0; for (let x = 0; x < stride; x++) raw[y * (stride + 1) + 1 + x] = rgba[y * stride + x]; }
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', ih), chunk('IDAT', zlib.deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0))]);
}

function main() {
const which = (process.argv[2] || 'dog').toLowerCase();
const S = SPECIES[which];
if (!S) { console.error(`unknown species "${which}" (expected cat|dog)`); process.exit(1); }

const maxW = Math.max(...S.poses.map((p) => p[1])) * SCALE;
const maxH = Math.max(...S.poses.map((p) => p[2])) * SCALE;
const cellW = maxW + PAD, cellH = maxH + PAD;
const W = S.poses.length * cellW + PAD, H = S.patterns.length * cellH + PAD;

const buf = new Uint8ClampedArray(W * H * 4);
for (let i = 0; i < W * H; i++) { buf[i * 4] = BG[0]; buf[i * 4 + 1] = BG[1]; buf[i * 4 + 2] = BG[2]; buf[i * 4 + 3] = 255; }

S.patterns.forEach((pal, i) => {
const B = { ...S.builds[S.build[i]], ...S.extra(i) };
S.poses.forEach(([, cols, rows, compose], j) => {
const sp = cat.buildSprite(cols, rows, () => compose(B));
const ox = PAD + j * cellW + Math.floor((maxW - cols * SCALE) / 2);
const oy = PAD + i * cellH + (maxH - rows * SCALE);
blitSprite(buf, W, sp, ox, oy, pal);
});
});

const out = path.join(__dirname, '..', 'previews', `${which}-sheet.png`);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, encodePng(buf, W, H));
console.log(`${out} (${W}x${H}) ${S.patterns.length} breeds x ${S.poses.length} poses`);
S.patterns.forEach((p, i) => console.log(` ${String(i).padStart(2)} ${p.name.padEnd(22)} ${S.build[i]}`));
}

main();
32 changes: 26 additions & 6 deletions site/cat-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand All @@ -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 };
})();
28 changes: 23 additions & 5 deletions src/cat-preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand All @@ -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 };
})();
6 changes: 6 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ const fs = require('fs');
const path = require('path');

const { PATTERN_NAMES } = require('./patterns');
const { isSpecies, coatsFor, defaultCoatIndex } = require('./pets');
const MAX_PATTERN = PATTERN_NAMES.length - 1;
const DEFAULT_PATTERN = Math.max(0, PATTERN_NAMES.indexOf('Tuxedo')); // tuxedo is the out-of-box coat

const DEFAULTS = {
name: '',
species: 'cat', // 'cat' | 'dog' - which pet lives on the desktop
pattern: DEFAULT_PATTERN,
dogPattern: defaultCoatIndex('dog'), // the dog's breed, kept separately so switching
// species back and forth never loses either choice
breakMinutes: 0, // 0 = break timer off
soundOn: true,
huntOn: true, // Comnyang hunts the cursor by default; user-toggleable
Expand Down Expand Up @@ -66,7 +70,9 @@ function normalize(cfg) {
const reminders = Array.isArray(c.reminders) ? c.reminders : [];
return {
name: String(c.name == null ? '' : c.name).trim().slice(0, 24),
species: isSpecies(c.species) ? c.species : 'cat',
pattern: clampInt(c.pattern, 0, MAX_PATTERN, DEFAULT_PATTERN),
dogPattern: clampInt(c.dogPattern, 0, coatsFor('dog').length - 1, defaultCoatIndex('dog')),
breakMinutes: clampInt(c.breakMinutes, 0, 240, 0),
soundOn: c.soundOn === undefined ? true : !!c.soundOn,
huntOn: c.huntOn === undefined ? true : !!c.huntOn,
Expand Down
Loading