// pixel-rig.jsx — "Tanu", pixel-art edition. A tiny software rasterizer paints // the foxy-tanuki head onto a low-res canvas (nearest-neighbour upscaled), so it // reads as genuine 16-bit pixel art — and it still reacts: cursor-look, blink, // poke, type-flick, and the full mood set. Head only. Exports window.TanuPixel. (function () { const { useState, useEffect, useRef, useCallback } = React; const GRID = 40; const C = { furL: '#7fc0ac', furM: '#4f8f80', furD: '#2b5b4d', cream: '#fdf6e7', creamD: '#e7d4b4', mask: '#16241f', earin: '#f2dcba', iris: '#e7c089', irisD: '#a9712f', pupil: '#160d06', white: '#ffffff', nose: '#1a2420', blush: '#ef8e74', spark: '#f3c34a', }; // ── geometry helpers (40×40 space, head centred at x=20) ── const inEll = (x, y, cx, cy, rx, ry) => { const dx = (x - cx) / rx, dy = (y - cy) / ry; return dx * dx + dy * dy <= 1; }; const inTri = (px, py, a, b, c) => { const s = (a[0]-c[0])*(py-c[1])-(a[1]-c[1])*(px-c[0]); const t = (b[0]-a[0])*(py-a[1])-(b[1]-a[1])*(px-a[0]); if ((s < 0) !== (t < 0) && s !== 0 && t !== 0) return false; const d = (c[0]-b[0])*(py-b[1])-(c[1]-b[1])*(px-b[0]); return d === 0 || (d < 0) === (s + t <= 0); }; function expr(mood) { switch (mood) { case 'curious': return { eye: 'wide', mouth: 'smile', look: [0, -1], blush: 1, spark: 0 }; case 'thinking': return { eye: 'half', mouth: 'tiny', look: [2, -1], blush: 0, spark: 0 }; case 'listening': return { eye: 'wide', mouth: 'open', look: [0, 0], blush: 1, spark: 0 }; case 'success': return { eye: 'happy', mouth: 'grin', look: [0, 0], blush: 1, spark: 1 }; case 'error': return { eye: 'wide', mouth: 'wavy', look: [0, 1], blush: 0, spark: 0 }; case 'excited': return { eye: 'huge', mouth: 'o', look: [0, 0], blush: 1, spark: 1 }; case 'surprised': return { eye: 'huge', mouth: 'o', look: [0, 0], blush: 0, spark: 0 }; case 'yawn': return { eye: 'happy', mouth: 'open', look: [0, 0], blush: 0, spark: 0 }; default: return { eye: 'open', mouth: 'smirk', look: null, blush: 0, spark: 0 }; } } function paint(P, mood, lookX, lookY, blinkOn) { const E = expr(mood); const g = []; for (let i = 0; i < GRID * GRID; i++) g.push(null); const set = (x, y, c) => { x = Math.round(x); y = Math.round(y); if (x >= 0 && x < GRID && y >= 0 && y < GRID && c) g[y * GRID + x] = c; }; const cx = 20; // ── ears (behind head) ── const ears = [ { tip: [7, 2], bl: [8, 13], br: [17, 9], inn: [[9, 5], [11, 12], [15, 10]] }, { tip: [33, 2], bl: [23, 9], br: [32, 13], inn: [[31, 5], [29, 12], [25, 10]] }, ]; for (const ear of ears) { for (let y = 0; y < 20; y++) for (let x = 0; x < GRID; x++) { if (inTri(x, y, ear.tip, ear.bl, ear.br)) { let col = C.furM; if (y <= 5) col = C.mask; // dark tip else if (inTri(x, y, ear.inn[0], ear.inn[1], ear.inn[2])) col = C.earin; set(x, y, col); } } } // ── head fur (with light/shadow banding) ── for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) { if (inEll(x, y, cx, 21, 14, 13)) { const nx = (x - cx) / 14, ny = (y - 21) / 13; const v = nx * 0.55 + ny; let col = C.furM; if (v < -0.55) col = C.furL; else if (v > 0.62) col = C.furD; set(x, y, col); } } // cheek ruff tufts [[7, 19, 6, 26], [33, 19, 34, 26]].forEach(([x1, y1, x2, y2]) => { set(x1, y1, C.furM); set(x2, (y1 + y2) / 2, C.furD); set(x2, y2, C.furD); }); // ── fox mask (around eyes) ── for (let y = 10; y < 20; y++) for (let x = 4; x < 36; x++) { if (!inEll(x, y, cx, 21, 14, 13)) continue; if (inEll(x, y, 14, 15, 5, 4) || inEll(x, y, 26, 15, 5, 4) || (y >= 12 && y <= 16 && x >= 14 && x <= 26)) { set(x, y, C.mask); } } // ── cream muzzle (lower face) ── for (let y = 20; y < 36; y++) for (let x = 0; x < GRID; x++) { if (inEll(x, y, cx, 28, 8, 7) && inEll(x, y, cx, 21, 14.5, 13.5)) { set(x, y, (y > 31 ? C.creamD : C.cream)); } } // blush if (E.blush) { [[12, 27], [28, 27]].forEach(([x, y]) => { set(x, y, C.blush); set(x + 1, y, C.blush); }); } // ── eyes ── const eyes = [[14, 16], [26, 16]]; const dims = { open: [2.4, 2.8], wide: [2.6, 3.2], huge: [2.9, 3.5], half: [2.4, 1.6] }[E.eye] || [2.4, 2.8]; const lk = E.look || [lookX, lookY]; eyes.forEach(([ex, ey]) => { if (E.eye === 'happy') { // closed, happy arc ^ [[-2, 1], [-1, 0], [0, -1], [1, 0], [2, 1]].forEach(([dx, dy]) => set(ex + dx, ey + dy, C.mask)); return; } if (blinkOn) { for (let dx = -2; dx <= 2; dx++) set(ex + dx, ey, C.mask); return; } const [rx, ry] = dims; for (let y = -4; y <= 4; y++) for (let x = -4; x <= 4; x++) { if (inEll(x, y, 0, 0, rx, ry)) set(ex + x, ey + y, C.white); } for (let y = -3; y <= 3; y++) for (let x = -3; x <= 3; x++) { if (inEll(x, y, 0, 0.3, rx - 0.7, ry - 0.8)) set(ex + x, ey + y, y > 0 ? C.irisD : C.iris); } const px = Math.max(-1, Math.min(1, Math.round(lk[0]))), py = Math.max(-1, Math.min(1, Math.round(lk[1]))); set(ex + px, ey + py, C.pupil); set(ex + px, ey + py + 1, C.pupil); set(ex + px - 1, ey + py - 1, C.white); // catchlight }); // ── nose ── [[19, 24], [20, 24], [21, 24], [20, 25]].forEach(([x, y]) => set(x, y, C.nose)); // ── mouth ── const m = E.mouth, my = 28; if (m === 'smirk') { set(19, my + 1, C.nose); set(20, my + 1, C.nose); set(21, my, C.nose); set(22, my, C.nose); } else if (m === 'smile') { [[17, my], [18, my + 1], [20, my + 1], [22, my + 1], [23, my]].forEach(([x, y]) => set(x, y, C.nose)); } else if (m === 'tiny') { set(19, my, C.nose); set(20, my + 1, C.nose); set(21, my, C.nose); } else if (m === 'grin') { for (let x = 17; x <= 23; x++) { set(x, my, C.nose); set(x, my + 1, C.nose); } set(19, my + 2, C.blush); set(20, my + 2, C.blush); set(21, my + 2, C.blush); } else if (m === 'open') { for (let y = my; y <= my + 2; y++) for (let x = 19; x <= 21; x++) set(x, y, C.nose); } else if (m === 'o') { set(20, my, C.nose); set(19, my + 1, C.nose); set(21, my + 1, C.nose); set(20, my + 2, C.nose); } else if (m === 'wavy') { [[17, my], [18, my + 1], [19, my], [20, my + 1], [21, my], [22, my + 1], [23, my]].forEach(([x, y]) => set(x, y, C.nose)); } // ── sparkles ── if (E.spark) { [[5, 7], [35, 9], [33, 3]].forEach(([x, y]) => { set(x, y, C.spark); set(x - 1, y, C.spark); set(x + 1, y, C.spark); set(x, y - 1, C.spark); set(x, y + 1, C.spark); }); } return g; } function TanuPixel({ mood = null, size = 220, interactive = true, alive = true, greet = false, pulse = 0, onPoke, className = '', styleProp = {} }) { const canvasRef = useRef(null); const wrapRef = useRef(null); const [look, setLook] = useState([0, 0]); const [blink, setBlink] = useState(false); const [transient, setTransient] = useState(null); const [bounce, setBounce] = useState(''); const raf = useRef(0); const effMood = transient || mood || 'idle'; // draw useEffect(() => { const cv = canvasRef.current; if (!cv) return; const ctx = cv.getContext('2d'); ctx.clearRect(0, 0, GRID, GRID); const g = paint(C, effMood, look[0], look[1], blink && alive); for (let y = 0; y < GRID; y++) for (let x = 0; x < GRID; x++) { const c = g[y * GRID + x]; if (!c) continue; ctx.fillStyle = c; ctx.fillRect(x, y, 1, 1); } }, [effMood, look, blink, alive]); // blink + idle useEffect(() => { if (!alive) return; let on = true; const timers = []; const loop = () => { if (!on) return; const t = setTimeout(() => { if (!on) return; setBlink(true); timers.push(setTimeout(() => setBlink(false), 120)); if (!mood && !transient && interactive && Math.random() < 0.4) { const gx = Math.random() < 0.5 ? -1.2 : 1.2; setLook([gx, 0]); timers.push(setTimeout(() => setLook([0, 0]), 900)); } loop(); }, 2600 + Math.random() * 2600); timers.push(t); }; loop(); return () => { on = false; timers.forEach(clearTimeout); }; }, [alive, mood, transient, interactive]); // greet useEffect(() => { if (!greet) return; setTransient('success'); setBounce('fox-bounce'); const a = setTimeout(() => setBounce(''), 900); const b = setTimeout(() => setTransient(null), 1500); return () => { clearTimeout(a); clearTimeout(b); }; }, [greet]); // type pulse const lastPulse = useRef(pulse); useEffect(() => { if (pulse === lastPulse.current) return; lastPulse.current = pulse; if (mood && mood !== 'listening') return; setBlink(true); const a = setTimeout(() => setBlink(false), 90); return () => clearTimeout(a); }, [pulse, mood]); const poke = useCallback((e) => { e && e.preventDefault(); setTransient('surprised'); setBounce('fox-squish'); onPoke && onPoke(); setTimeout(() => { setBounce(''); setTransient('success'); }, 440); setTimeout(() => setTransient(null), 1100); }, [onPoke]); const fxCls = effMood === 'error' ? 'fox-shake' : bounce; return (
); } window.TanuPixel = TanuPixel; })();