commit 416bca916842801bcaa9f1b6e9c349a780d07adc Author: Mortdecai Date: Sat Jul 11 23:21:21 2026 -0400 feat: googly desk pet — face engine, mood, touch reactions, EEPROM persistence Co-Authored-By: Claude Fable 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7526cb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.env.* +*.key +*.pem +build/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ee9016a --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..1daf51b --- /dev/null +++ b/DECISIONS.md @@ -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. diff --git a/GITEA_API.md b/GITEA_API.md new file mode 120000 index 0000000..9af9c55 --- /dev/null +++ b/GITEA_API.md @@ -0,0 +1 @@ +/home/claude/bin/GITEA_API.md \ No newline at end of file diff --git a/IDEA.md b/IDEA.md new file mode 100644 index 0000000..86be0e5 --- /dev/null +++ b/IDEA.md @@ -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 0–100: 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). diff --git a/googly/googly.ino b/googly/googly.ino new file mode 100644 index 0000000..a03500d --- /dev/null +++ b/googly/googly.ino @@ -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 +#include +#include + +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); +}