feat: googly desk pet — face engine, mood, touch reactions, EEPROM persistence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-07-11 23:21:21 -04:00
commit 416bca9168
6 changed files with 475 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.env
.env.*
*.key
*.pem
build/
+30
View File
@@ -0,0 +1,30 @@
# googly
Standalone desk-pet face for Arduino Mega 2560 + 3.5" UNO-form 8-bit ILI9486
touch shield (MCUFRIEND). See IDEA.md for the concept, DECISIONS.md for
settled choices.
## Build / flash
```bash
arduino-cli compile -b arduino:avr:mega googly
arduino-cli upload -b arduino:avr:mega -p /dev/ttyACM0 googly
```
Libraries: MCUFRIEND_kbv, Adafruit GFX, Adafruit TouchScreen (all in
`~/Arduino/libraries`). No SD, no soft-SPI needed here.
## Hardware notes
- Display ID 0x9486, 480x320 in rotation 1 (landscape).
- Touch is resistive, shares LCD pins (XP=6, XM=A2, YP=A1, YM=7). After every
`ts.getPoint()` the code restores A1/A2 to OUTPUT — do not remove that.
- Touch is used pressure-only (tap/hold), never calibrated x/y.
- Full-screen redraws are too slow for animation; only eye/mouth/blush regions
are redrawn. Keep it that way.
- Serial 115200 prints state transitions and mood for debugging.
## Tuning
All behavior knobs (timings, mood rates, touch pressure window) are constants
at the top of `googly/googly.ino`.
+25
View File
@@ -0,0 +1,25 @@
# googly — Decisions
- 2026-07-11: Big procedural face over pixel-art sprite creature — reads as alive
from desk distance, needs zero art assets, and partial-region redraw sidesteps
the 8-bit bus being too slow for full-screen animation.
- 2026-07-11: Light mood state (b) over pure toy (a) or full Tamagotchi (c) —
(a) goes stale, (c)'s neglect mechanic fights weekends (always-dying pet =
guilt, not cute).
- 2026-07-11: Mega 2560 as target — 256KB flash / 8KB RAM vs UNO's 32K/2K which
graphictest already fills to 98%. Firmware backed up before overwrite
(`../backups/mega-*-flash.hex`).
- 2026-07-11: No SD dependency — SD on this shield needs soft-SPI on the Mega
(pins 10-13 aren't Mega hardware SPI) and the face doesn't need assets anyway.
- 2026-07-11: Touch used only as tap/hold/pressure, not calibrated x/y — poke and
pet don't need position, which makes per-shield resistive calibration
unnecessary. Ghost-touch guard: pressure window + 2 consecutive samples.
## Deferred / Rejected
- Serial-dashboard generic firmware — separate idea, better fit for the
Leonardo/UNO (native USB on Leonardo). Not part of googly.
- RTC / real day-night cycle — no RTC on board; sleep is inactivity-based.
Revisit only if a DS3231 module shows up.
- Sound (piezo chirps) — no free pin budget to fight the shield over; also a
desk toy at work that beeps is a desk toy that gets unplugged.
+1
View File
@@ -0,0 +1 @@
/home/claude/bin/GITEA_API.md
+17
View File
@@ -0,0 +1,17 @@
# googly
A standalone desk pet for Seth's desk at Google. Arduino Mega 2560 + 3.5" UNO-form
8-bit ILI9486 touch shield. Plug into any USB power brick and it lives: a big
expressive face (Cozmo/EVE style) that blinks, looks around, sleeps, and reacts
to touch.
- Tap = poke (surprise → happy; rapid pokes → dizzy spiral eyes)
- Long press = pet (closed eyes, blush; hearts if it likes you enough)
- 30 min ignored = sleep (z z z), any touch wakes it
- Rare idle events: sneeze, yawn, watches a fly cross the screen
- One mood value 0100: interaction raises it, uptime decays it. Persisted in
EEPROM (write-throttled). It sulks but never dies — zero-guilt Tamagotchi.
No network, no host, no SD needed. Face is drawn procedurally with GFX
primitives; animation only redraws eye/mouth regions (full-screen redraw is too
slow on the 8-bit bus).
+397
View File
@@ -0,0 +1,397 @@
// googly — standalone desk pet for Mega 2560 + 3.5" 8-bit ILI9486 touch shield.
// Big procedural face: blinks, wanders, sleeps, reacts to pokes and pets.
// Mood (0-100) persists in EEPROM. See ../IDEA.md and ../DECISIONS.md.
#include <MCUFRIEND_kbv.h>
#include <TouchScreen.h>
#include <EEPROM.h>
MCUFRIEND_kbv tft;
// ---- touch (shield shares LCD pins; pressure-only, no x/y calibration) ----
#define XP 6
#define XM A2
#define YP A1
#define YM 7
TouchScreen ts(XP, YP, XM, YM, 300);
const int PRESS_MIN = 80; // ponytail: pressure window is the ghost-touch filter;
const int PRESS_MAX = 1000; // widen/narrow here if pokes misfire on flaky USB power
// ---- tuning knobs ----
const uint32_t SLEEP_AFTER_MS = 30UL * 60 * 1000; // untouched -> sleep
const uint32_t EVENT_MIN_MS = 5UL * 60 * 1000; // rare idle events every 5-15 min
const uint32_t EVENT_MAX_MS = 15UL * 60 * 1000;
const uint32_t MOOD_DECAY_MS = 15UL * 60 * 1000; // -1 mood per 15 min awake
const uint32_t EEPROM_SAVE_MS = 5UL * 60 * 1000; // throttle EEPROM writes
const uint16_t HOLD_MS = 600; // press longer = petting
const uint16_t MULTIPOKE_MS = 1500; // 3 pokes inside this = dizzy
const int8_t MOOD_PER_POKE = 4;
const int8_t MOOD_PER_PET = 12;
// ---- colors (RGB565) ----
#define BG 0x1106 // deep navy
#define SCLERA 0xFFFF
#define PUPIL 0x18E3
#define PINK 0xFB56
#define RED 0xF800
#define FLYCLR 0xFFE0
// ---- face geometry (480x320 landscape) ----
const int EYE_LX = 155, EYE_RX = 325, EYE_Y = 135, EYE_R = 75;
const int PUPIL_R = 26, PUPIL_RANGE = 30;
const int MOUTH_X = 240, MOUTH_Y = 262, MOUTH_HW = 60;
// ---- state ----
enum State : uint8_t { IDLE, SLEEPING, PETTING };
State state = IDLE;
struct Face { // what's currently on screen, so we only redraw what changed
int8_t lid = -1; // 0..100 % closed
int8_t pdx = 0, pdy = 0;
int8_t curve = -128; // -100 frown .. +100 smile
int8_t openR = 0; // >0: round open mouth instead of curve
bool blush = false;
} cur;
struct Pet {
uint8_t magic; // 0x67 'g'
uint8_t mood;
uint32_t pokes;
uint32_t pets;
} pet;
bool petDirty = false;
uint32_t lastSaveMs = 0;
int8_t targPdx = 0, targPdy = 0;
uint32_t lastTouchMs = 0, nextBlinkMs = 0, nextWanderMs = 0, nextEventMs = 0, nextDecayMs = 0;
uint32_t pressStartMs = 0, pokeTimes[3] = {0, 0, 0};
bool pressing = false, wasTouching = false;
uint32_t petSessionGain = 0, zPhaseMs = 0;
uint8_t zFrame = 0;
// ---------------------------------------------------------------- drawing --
void drawEye(int cx, int8_t lid, int8_t pdx, int8_t pdy) {
tft.fillCircle(cx, EYE_Y, EYE_R, SCLERA);
tft.fillCircle(cx + pdx, EYE_Y + pdy, PUPIL_R, PUPIL);
tft.fillCircle(cx + pdx - 8, EYE_Y + pdy - 8, 7, SCLERA); // catchlight
if (lid > 0) {
int h = (int32_t)lid * 2 * EYE_R / 100;
tft.fillRect(cx - EYE_R, EYE_Y - EYE_R, 2 * EYE_R + 1, h, BG);
}
}
void drawEyes(int8_t lid, int8_t pdx, int8_t pdy) {
drawEye(EYE_LX, lid, pdx, pdy);
drawEye(EYE_RX, lid, pdx, pdy);
cur.lid = lid; cur.pdx = pdx; cur.pdy = pdy;
}
void drawMouth(int8_t curve, int8_t openR) {
tft.fillRect(MOUTH_X - MOUTH_HW - 8, MOUTH_Y - 30, 2 * (MOUTH_HW + 8), 64, BG);
if (openR > 0) {
tft.fillCircle(MOUTH_X, MOUTH_Y, openR, PUPIL);
tft.drawCircle(MOUTH_X, MOUTH_Y, openR, SCLERA);
tft.drawCircle(MOUTH_X, MOUTH_Y, openR + 1, SCLERA);
} else {
for (int x = -MOUTH_HW; x <= MOUTH_HW; x += 4) {
int y = MOUTH_Y - (int32_t)curve * (20 - (x * x) / 180) / 100;
tft.fillCircle(MOUTH_X + x, y, 4, SCLERA);
}
}
cur.curve = curve; cur.openR = openR;
}
void drawBlush(bool on) {
const int xs[2] = {105, 375};
for (uint8_t i = 0; i < 2; i++) {
int x = xs[i];
if (on) {
tft.fillCircle(x, 232, 15, PINK);
} else {
tft.fillCircle(x, 232, 16, BG);
}
}
cur.blush = on;
}
void drawHeartEyes() {
const int cxs[2] = {EYE_LX, EYE_RX};
for (uint8_t i = 0; i < 2; i++) {
int cx = cxs[i];
tft.fillCircle(cx, EYE_Y, EYE_R, SCLERA);
int s = 30;
tft.fillCircle(cx - s / 2, EYE_Y - s / 3, s / 2 + 2, RED);
tft.fillCircle(cx + s / 2, EYE_Y - s / 3, s / 2 + 2, RED);
tft.fillTriangle(cx - s - 1, EYE_Y - s / 6, cx + s + 1, EYE_Y - s / 6, cx, EYE_Y + s, RED);
}
}
void setFace(int8_t lid, int8_t pdx, int8_t pdy, int8_t curve, int8_t openR, bool blush) {
if (lid != cur.lid || pdx != cur.pdx || pdy != cur.pdy) drawEyes(lid, pdx, pdy);
if (curve != cur.curve || openR != cur.openR) drawMouth(curve, openR);
if (blush != cur.blush) drawBlush(blush);
}
int8_t moodCurve() { return pet.mood > 66 ? 80 : (pet.mood > 33 ? 30 : -40); }
int8_t moodLid() { return pet.mood > 66 ? 0 : (pet.mood > 33 ? 10 : 30); }
void clearZs() { tft.fillRect(380, 20, 100, 90, BG); }
// ------------------------------------------------------------------ touch --
bool rawTouch() {
TSPoint p = ts.getPoint();
pinMode(YP, OUTPUT); digitalWrite(YP, HIGH); // give shared pins back to the LCD
pinMode(XM, OUTPUT); digitalWrite(XM, HIGH);
return p.z > PRESS_MIN && p.z < PRESS_MAX;
}
bool touched() { // two consecutive samples = ghost-touch debounce
if (!rawTouch()) return false;
delay(4);
return rawTouch();
}
// ------------------------------------------------------------ persistence --
void loadPet() {
EEPROM.get(0, pet);
if (pet.magic != 0x67) { pet = {0x67, 60, 0, 0}; EEPROM.put(0, pet); }
if (pet.mood > 100) pet.mood = 60;
}
void savePet(bool force = false) {
uint32_t now = millis();
if (!petDirty || (!force && now - lastSaveMs < EEPROM_SAVE_MS)) return;
EEPROM.put(0, pet);
petDirty = false; lastSaveMs = now;
Serial.print(F("saved mood=")); Serial.println(pet.mood);
}
void bumpMood(int8_t d) {
int m = pet.mood + d;
pet.mood = m < 0 ? 0 : (m > 100 ? 100 : m);
petDirty = true;
Serial.print(F("mood=")); Serial.println(pet.mood);
}
// -------------------------------------------------- blocking mini-anims ----
// ponytail: short animations just block (<=4s); a poke mid-anim is dropped,
// which reads as the pet being busy emoting. Async engine only if that annoys.
void blinkOnce() {
int8_t base = moodLid();
const int8_t seq[3] = {60, 100, 60};
for (uint8_t i = 0; i < 3; i++) { drawEyes(seq[i], cur.pdx, cur.pdy); delay(35); }
drawEyes(base, cur.pdx, cur.pdy);
}
void wakeAnim() {
clearZs();
drawEyes(100, 0, 0); drawMouth(0, 0); drawBlush(false);
delay(250);
const int8_t seq[6] = {70, 40, 90, 40, 10, 0};
for (uint8_t i = 0; i < 6; i++) { drawEyes(seq[i], 0, 0); delay(70); }
drawEyes(0, -20, 0); delay(280);
drawEyes(0, 20, 0); delay(280);
drawEyes(moodLid(), 0, 0);
drawMouth(moodCurve(), 0);
Serial.println(F("awake"));
}
void pokeAnim() {
pet.pokes++; bumpMood(MOOD_PER_POKE);
drawEyes(0, 0, -6); drawMouth(0, 14); // startled
delay(350);
if (pet.mood > 70) { drawHeartEyes(); drawMouth(90, 0); delay(700); drawEyes(moodLid(), 0, 0); }
else { drawEyes(0, 0, 4); drawMouth(80, 0); delay(500); }
setFace(moodLid(), 0, 0, moodCurve(), 0, false);
}
void dizzyAnim() {
Serial.println(F("dizzy"));
drawMouth(0, 10);
for (int a = 0; a < 720; a += 40) {
float r = a * 0.01745;
drawEyes(0, (int8_t)(PUPIL_RANGE * cos(r)), (int8_t)(PUPIL_RANGE * sin(r)));
delay(28);
}
drawEyes(30, 0, 8); drawMouth(-30, 0); delay(500);
setFace(moodLid(), 0, 0, moodCurve(), 0, false);
}
void yawnAnim() {
Serial.println(F("yawn"));
const int8_t seq[8] = {8, 14, 20, 24, 24, 20, 12, 0};
for (uint8_t i = 0; i < 8; i++) {
drawMouth(0, seq[i]); drawEyes(50, cur.pdx, cur.pdy); delay(140);
}
setFace(moodLid(), cur.pdx, cur.pdy, moodCurve(), 0, false);
}
void sneezeAnim() {
Serial.println(F("sneeze"));
drawEyes(80, 0, 0); drawMouth(0, 10); delay(400);
drawEyes(100, 0, 0); drawMouth(0, 22);
tft.setTextColor(SCLERA); tft.setTextSize(3);
tft.setCursor(186, 16); tft.print(F("achoo!"));
delay(700);
tft.fillRect(180, 10, 130, 32, BG);
setFace(moodLid(), 0, 0, moodCurve(), 0, false);
}
void flyAnim() {
Serial.println(F("fly"));
int px = -1, py = -1;
for (int t = 0; t < 130; t++) {
int fx = 15 + (t * 450) / 130;
int fy = 30 + (int)(14 * sin(t * 0.35));
if (px >= 0) tft.fillCircle(px, py, 3, BG);
tft.fillCircle(fx, fy, 3, FLYCLR);
px = fx; py = fy;
int8_t pdx = constrain((fx - MOUTH_X) / 8, -PUPIL_RANGE, PUPIL_RANGE);
if (pdx != cur.pdx) drawEyes(0, pdx, -14);
delay(30);
if (touched()) break; // a poke scares the fly off
}
if (px >= 0) tft.fillCircle(px, py, 3, BG);
setFace(moodLid(), 0, 0, moodCurve(), 0, false);
}
void sighAnim() {
Serial.println(F("sigh"));
drawEyes(60, 0, 6); drawMouth(0, 8); delay(900);
setFace(moodLid(), 0, 0, moodCurve(), 0, false);
}
void idleEvent() {
long roll = random(100);
if (pet.mood <= 40 && roll < 30) { sighAnim(); return; }
if (roll < 40) flyAnim();
else if (roll < 70) yawnAnim();
else if (roll < 90) sneezeAnim();
else { blinkOnce(); delay(120); blinkOnce(); } // double-blink derp
}
// ------------------------------------------------------------------ setup --
void setup() {
Serial.begin(115200);
randomSeed(analogRead(A5) ^ micros());
loadPet();
Serial.print(F("googly up. mood=")); Serial.print(pet.mood);
Serial.print(F(" pokes=")); Serial.print(pet.pokes);
Serial.print(F(" pets=")); Serial.println(pet.pets);
uint16_t id = tft.readID();
if (id == 0xD3D3) id = 0x9486;
tft.begin(id);
tft.setRotation(1);
tft.fillScreen(BG);
wakeAnim();
uint32_t now = millis();
lastTouchMs = now;
nextBlinkMs = now + 2500;
nextWanderMs = now + 1200;
nextEventMs = now + random(EVENT_MIN_MS, EVENT_MAX_MS);
nextDecayMs = now + MOOD_DECAY_MS;
}
// ------------------------------------------------------------------- loop --
void loop() {
uint32_t now = millis();
bool t = touched();
// ---- gesture edges ----
if (t && !wasTouching) { pressStartMs = now; pressing = true; }
if (state == SLEEPING) {
if (t) {
state = IDLE;
lastTouchMs = now;
bumpMood(3);
wakeAnim();
nextEventMs = now + random(EVENT_MIN_MS, EVENT_MAX_MS);
}
} else if (state == PETTING) {
if (!t) { // hand lifted
pet.pets++; petDirty = true;
if (pet.mood > 70) { drawHeartEyes(); drawMouth(90, 0); delay(800); }
drawBlush(false);
setFace(moodLid(), 0, 0, moodCurve(), 0, false);
state = IDLE;
Serial.println(F("petted"));
} else if (now - zPhaseMs > 500 && petSessionGain < (uint32_t)MOOD_PER_PET) {
bumpMood(1); petSessionGain++; zPhaseMs = now;
}
} else { // IDLE
if (pressing && t && now - pressStartMs > HOLD_MS) {
state = PETTING;
petSessionGain = 0; zPhaseMs = now;
setFace(80, 0, 6, 60, 0, true); // closed-ish eyes, smile, blush
Serial.println(F("petting..."));
} else if (!t && wasTouching && pressing && now - pressStartMs <= HOLD_MS) {
// completed tap = poke
pokeTimes[0] = pokeTimes[1]; pokeTimes[1] = pokeTimes[2]; pokeTimes[2] = now;
if (pokeTimes[2] - pokeTimes[0] < MULTIPOKE_MS) dizzyAnim();
else pokeAnim();
lastTouchMs = now;
}
if (!t) {
// ---- ambient idle behavior ----
if (now > nextBlinkMs) { blinkOnce(); nextBlinkMs = now + random(2500, 6000); }
if (now > nextWanderMs) {
if (cur.pdx == targPdx && cur.pdy == targPdy) {
targPdx = random(-PUPIL_RANGE, PUPIL_RANGE + 1);
targPdy = random(-16, 17);
nextWanderMs = now + random(1500, 3500);
} else {
int8_t sx = constrain(targPdx - cur.pdx, -6, 6);
int8_t sy = constrain(targPdy - cur.pdy, -6, 6);
drawEyes(cur.lid, cur.pdx + sx, cur.pdy + sy);
nextWanderMs = now + 60;
}
}
if (now > nextEventMs) {
idleEvent();
nextEventMs = now + random(EVENT_MIN_MS, EVENT_MAX_MS);
}
if (now - lastTouchMs > SLEEP_AFTER_MS) {
state = SLEEPING;
setFace(100, 0, 0, 10, 0, false);
zPhaseMs = now; zFrame = 0;
savePet(true);
Serial.println(F("zzz"));
}
} else {
lastTouchMs = now;
}
}
// ---- sleeping z's ----
if (state == SLEEPING && now - zPhaseMs > 900) {
clearZs();
tft.setTextColor(SCLERA); tft.setTextSize(2);
for (uint8_t i = 0; i < 3; i++) {
uint8_t ph = (zFrame + i) % 3;
tft.setCursor(395 + i * 22, 88 - ph * 26 - i * 6);
tft.print('z');
}
zFrame++; zPhaseMs = now;
}
// ---- mood decay + persistence ----
if (state != SLEEPING && now > nextDecayMs) {
if (pet.mood > 0) bumpMood(-1);
nextDecayMs = now + MOOD_DECAY_MS;
}
savePet();
if (!t) pressing = false;
wasTouching = t;
delay(25);
}