PanBadge/tama.c

1209 lines
36 KiB
C

/*
* PANAGOTCHI -- a Tamagotchi-style virtual pet for the CH32V003F4P6 badge.
*
* Hardware (see pinout.md):
* PA1 indicator LED (active-high)
* PC0 OK button (active-low, internal pull-up)
* PD5 Right button (active-low, internal pull-up)
* PD6 Left button (active-low, internal pull-up)
* PC1/PC2 SSD1306 128x64 OLED over I2C1 (driven by ssd1306_i2c.h)
* PD2 passive piezo buzzer (square-wave bit-bang tone)
*
* Controls: Left / Right move the menu cursor, OK activates the selected
* action. On the stats / dead screens any button returns / restarts.
*/
#include "ch32fun.h"
// The bundled I2C driver calls printf() only to log a timeout we can't act on
// (by then the display is dead). Stub it to a no-op so the ~1 KB printf
// formatter is never referenced and the linker discards it. We format our own
// numbers with the tiny s_cat/u_cat helpers below.
#define printf(...) ((void)0)
#define SSD1306_128X64 // 128x64 panel (see pinout.md / OLED size)
#include "ssd1306_i2c.h"
#include "ssd1306.h"
#include "sprites.h"
// ----------------------------------------------------------------- pins
#define LED_PIN PA1
#define BTN_OK PC0
#define BTN_RIGHT PD5
#define BTN_LEFT PD6
#define BUZZER PD2
#define IR_TX PD4 // IR LED, bit-bang 38 kHz carrier
#define IR_RX PD3 // IR demodulator data (active-low)
#define IR_RX_VCC PC4 // IR receiver power gate (high = on)
// ----------------------------------------------------------------- tuning
#define FRAME_MS 60 // main loop period
#define TICK_FRAMES 250 // stat update every ~15 s (250 * 60 ms)
#define ANIM_FRAMES 12 // how long an action reaction is shown
#define TICK_MS (TICK_FRAMES * FRAME_MS) // 15000 ms: also the screen-off check period
// With the values below an untended pet survives roughly half an hour:
// a need empties in ~20 min, after which health bleeds down over ~10 more.
// ----------------------------------------------------------------- state
enum { SCR_SELECT, SCR_MAIN, SCR_STATS, SCR_DEAD, SCR_SETTINGS, SCR_IR, SCR_TV };
// notification thresholds: gentle chirp at <50%, urgent at <20%
enum { N_FOOD, N_FUN, N_ENGY, N_WASH, N_COUNT };
typedef struct {
int8_t satiety; // 0..100 (fullness)
int8_t happy; // 0..100
int8_t energy; // 0..100
int8_t hygiene; // 0..100
int8_t health; // 0..100
uint16_t age_min; // age in game-minutes (one per tick)
uint16_t xp; // experience earned by good care
uint8_t species; // SP_* starter type chosen on the welcome screen
uint8_t level; // 1..MAX_LEVEL
uint8_t form; // FORM_* (display + life stage), derived from level
uint8_t ascend; // 0 undecided, 1 Claude, 2 glitch (latched at max lvl)
uint8_t poop; // 0..3 piles on the floor
uint8_t sleeping; // 1 while napping
uint8_t alive;
} Pet;
// Hacker privilege-escalation life stages: proc -> user -> root, then the pet
// either ascends to "Claude" (well-tended) or rots into a glitch.
enum { FORM_PROC, FORM_USER, FORM_ROOT, FORM_CLAUDE, FORM_GLITCH, FORM_COUNT };
static const char *const form_names[FORM_COUNT] = {
"proc", "user", "root", "Claude", "glitch",
};
#define MAX_LEVEL 4
// cumulative XP required to reach level (index = level-1)
static const uint16_t level_xp[MAX_LEVEL] = { 0, 45, 120, 240 };
static Pet pet;
static uint8_t screen;
static uint8_t cursor; // selected menu icon 0..ICON_COUNT-1
static uint8_t anim_exp; // expression forced during a reaction
static uint8_t anim_timer; // frames remaining for the reaction
static uint32_t frame; // free-running frame counter
static uint16_t tick_acc; // frames since last stat tick
static uint8_t warn_flag[N_COUNT]; // already chirped a <50% warning
static uint8_t crit_flag[N_COUNT]; // already chirped a <20% warning
static uint8_t evo_anim; // frames remaining for an evolution banner
// settings
static uint8_t sound_on = 1;
static uint8_t timeout_idx = 1; // index into screen_timeout_opts (default 10 s)
static uint8_t bright_idx = 1; // index into brightness_opts (default Med)
static uint8_t set_sel = 0; // highlighted row on the settings screen
static uint8_t sel_species = 0; // highlighted species on the welcome screen
// OLED contrast (brightness) levels written to SSD1306_SETCONTRAST (0x81).
// Lower also means less charge-pump current, which is gentler on the coin cell.
static const uint8_t brightness_opts[] = { 0x01, 0x30, 0x80, 0xCF };
static const char *const brightness_names[] = { "Low", "Med", "High", "Max" };
#define BRIGHT_OPT_COUNT 4
// screen-off-on-inactivity options, in frames (0 = never sleep the display).
// Shorter timeouts blank the OLED sooner, which is the single biggest power
// win on a CR2032 — the panel draws far more than the dozing MCU.
static const uint16_t screen_timeout_opts[] = { 83, 167, 250, 500, 1000, 0 };
static const char *const screen_timeout_names[] = { "5s", "10s", "15s", "30s", "60s", "Off" };
#define TIMEOUT_OPT_COUNT 6
#define SETTINGS_ROWS 4 // screen-off, brightness, sound, back
// IR screen state
// Modes: 0=ZAPP (send ping), 1=RECV (listen), 2=BEAM (send pet data), 3=TV remote, 4=BACK
enum { IR_ZAPP, IR_RECV, IR_BEAM, IR_TV, IR_BACK, IR_MODE_COUNT };
static uint8_t ir_mode = 0;
// ir_status: 0=idle, 1=sent, 2=got signal, 3=nothing, 4=waiting
static uint8_t ir_status = 0;
static uint8_t ir_stimer = 0; // frames to show a status message
// NEC protocol constants for badge-to-badge comms
#define PANA_ADDR 0x50 // 'P' for Panagotchi
// ----------------------------------------------------------------- TV remote
// Commands fired at every brand in sequence; TV responds to whichever matches.
// Order must match tv_cmd_names[]. BACK is a navigation item, not an IR code.
#define TV_CMD_COUNT 6
#define TV_NAV_COUNT 7 // TV_CMD_COUNT + 1 (BACK entry)
#define TV_BRAND_COUNT 5
static const char *const tv_cmd_names[TV_NAV_COUNT] = {
"POWER", "VOL+", "VOL-", "MUTE", "CH+", "CH-", "BACK",
};
// NEC addresses per brand
static const uint8_t tv_addr[TV_BRAND_COUNT] = {
0x04, // LG
0x40, // Panasonic
0xA5, // Pioneer
0x05, // Mitsubishi
0x00, // Generic / CHUNGHWA
};
// Commands: [brand][POWER, VOL+, VOL-, MUTE, CH+, CH-]
static const uint8_t tv_cmds[TV_BRAND_COUNT][TV_CMD_COUNT] = {
{ 0x08, 0x02, 0x03, 0x09, 0x00, 0x01 }, // LG
{ 0x3D, 0x20, 0x21, 0x04, 0x34, 0x35 }, // Panasonic
{ 0x1C, 0x14, 0x15, 0x19, 0x01, 0x02 }, // Pioneer
{ 0x18, 0x07, 0x0B, 0x0D, 0x02, 0x06 }, // Mitsubishi
{ 0x45, 0x46, 0x15, 0x09, 0x09, 0x15 }, // Generic
};
static uint8_t tv_cmd = 0; // currently selected command (0..TV_NAV_COUNT-1)
// display power state
static uint8_t disp_on = 1;
static uint32_t idle_frames = 0; // frames since the last button activity
// reset cause latched at boot (helps tell a brown-out from a software reset)
static uint32_t reset_cause = 0;
// menu icon order must match sprites.h menu_icons[] / menu_labels[]
enum { ACT_FEED, ACT_PLAY, ACT_SLEEP, ACT_CLEAN, ACT_HEAL, ACT_STATS, ACT_SETTINGS, ACT_IR };
// ----------------------------------------------------------------- helpers
static int8_t clamp100(int v) { return v < 0 ? 0 : (v > 100 ? 100 : v); }
// Tiny string builders used instead of snprintf: we only ever paste short
// strings and small unsigned ints, and avoiding <stdio.h> keeps the whole
// printf machinery (~1 KB) out of the image. Each returns the new end pointer
// so calls chain; remember to NUL-terminate the final buffer yourself.
static char *s_cat(char *p, const char *s) { while (*s) *p++ = *s++; return p; }
static char *u_cat(char *p, unsigned v)
{
char tmp[5];
int n = 0;
do { tmp[n++] = '0' + v % 10; v /= 10; } while (v);
while (n) *p++ = tmp[--n];
return p;
}
// short square-wave beep on the passive piezo (blocking, ms-scale only)
static void beep(uint16_t freq, uint16_t ms)
{
if (!sound_on) return;
if (freq == 0) { Delay_Ms(ms); return; }
uint32_t half = 500000u / freq; // half-period in us
uint32_t cycles = ((uint32_t)ms * 1000u) / (half * 2u);
for (uint32_t i = 0; i < cycles; i++) {
funDigitalWrite(BUZZER, FUN_HIGH);
Delay_Us(half);
funDigitalWrite(BUZZER, FUN_LOW);
Delay_Us(half);
}
}
static void led_blink(void)
{
funDigitalWrite(LED_PIN, FUN_HIGH);
Delay_Ms(30);
funDigitalWrite(LED_PIN, FUN_LOW);
}
// ----------------------------------------------------------------- IR TX
// Bit-bang 38 kHz carrier on IR_TX for `us` microseconds (mark).
static void ir_mark(uint16_t us)
{
// half-period ≈ 13 µs → cycles ≈ us/26
uint16_t cycles = us / 26u + 1u;
for (uint16_t i = 0; i < cycles; i++) {
funDigitalWrite(IR_TX, FUN_HIGH);
Delay_Us(13);
funDigitalWrite(IR_TX, FUN_LOW);
Delay_Us(13);
}
}
static void ir_space(uint16_t us)
{
funDigitalWrite(IR_TX, FUN_LOW);
Delay_Us(us);
}
// Send one NEC frame: 9ms mark + 4.5ms space + 32 bits + stop.
static void ir_send_nec(uint8_t addr, uint8_t cmd)
{
ir_mark(9000); ir_space(4500);
uint8_t bytes[4] = { addr, (uint8_t)~addr, cmd, (uint8_t)~cmd };
for (uint8_t b = 0; b < 4; b++) {
for (uint8_t i = 0; i < 8; i++) {
ir_mark(562);
ir_space((bytes[b] & 1) ? 1687u : 562u);
bytes[b] >>= 1;
}
}
ir_mark(562);
funDigitalWrite(IR_TX, FUN_LOW);
}
// ----------------------------------------------------------------- IR RX
// Wait up to `wait_us` for IR_RX to reach `lvl`, then measure how long it
// holds (up to `hold_us`). Returns ~duration in µs (10 µs resolution), or 0
// on timeout. The IR demodulator output is active-low: LOW = carrier present.
static uint32_t ir_pulse(uint8_t lvl, uint32_t wait_us, uint32_t hold_us)
{
uint32_t t;
for (t = 0; t < wait_us; t += 10) {
if ((uint8_t)funDigitalRead(IR_RX) == lvl) goto measure;
Delay_Us(10);
}
return 0;
measure:;
for (t = 0; t < hold_us; t += 10) {
if ((uint8_t)funDigitalRead(IR_RX) != lvl) return t;
Delay_Us(10);
}
return 0; // pulse held too long
}
// Try to receive one NEC frame. Blocks up to ~2 s waiting for the leader.
// Returns 1 on a valid frame, 0 otherwise. addr/cmd are set on success.
static uint8_t ir_recv_nec(uint8_t *addr_out, uint8_t *cmd_out)
{
// leader mark ~9000 µs LOW; wait up to 2 s
uint32_t t = ir_pulse(FUN_LOW, 2000000u, 12000u);
if (t < 7000u) return 0;
// leader space ~4500 µs HIGH
t = ir_pulse(FUN_HIGH, 8000u, 6000u);
if (t < 3000u) return 0;
uint32_t bits = 0;
for (uint8_t i = 0; i < 32; i++) {
if (!ir_pulse(FUN_LOW, 1500u, 1500u)) return 0;
t = ir_pulse(FUN_HIGH, 2500u, 2500u);
if (!t) return 0;
bits >>= 1;
if (t > 1000u) bits |= 0x80000000UL;
}
ir_pulse(FUN_LOW, 1500u, 1500u); // stop bit
*addr_out = (uint8_t)(bits & 0xFF);
*cmd_out = (uint8_t)((bits >> 16) & 0xFF);
return ((bits >> 8) & 0xFF) == (uint8_t)(~*addr_out) &&
((bits >> 24) & 0xFF) == (uint8_t)(~*cmd_out);
}
// push the currently selected brightness to the panel's contrast register
static void apply_brightness(void)
{
ssd1306_cmd(SSD1306_SETCONTRAST);
ssd1306_cmd(brightness_opts[bright_idx]);
}
static void pet_reset(void)
{
pet.satiety = 80;
pet.happy = 80;
pet.energy = 90;
pet.hygiene = 100;
pet.health = 100;
pet.age_min = 0;
pet.xp = 0;
pet.species = sel_species;
pet.level = 1;
pet.form = FORM_PROC;
pet.ascend = 0;
pet.poop = 0;
pet.sleeping = 0;
pet.alive = 1;
screen = SCR_MAIN;
cursor = 0;
evo_anim = 0;
for (int i = 0; i < N_COUNT; i++) { warn_flag[i] = 0; crit_flag[i] = 0; }
}
// Map the current level (and, at the top, care quality) to a display form.
static void compute_form(void)
{
switch (pet.level) {
case 1: pet.form = FORM_PROC; break;
case 2: pet.form = FORM_USER; break;
case 3: pet.form = FORM_ROOT; break;
default: // max level: branch once, latched
if (!pet.ascend) pet.ascend = (pet.health >= 60) ? 1 : 2;
pet.form = (pet.ascend == 1) ? FORM_CLAUDE : FORM_GLITCH;
break;
}
}
// Award XP; promote (and trigger the evolution banner) when a tier is reached.
static void add_xp(uint8_t n)
{
if (!pet.alive) return;
if (pet.xp < 60000) pet.xp += n;
uint8_t nl = 1;
for (uint8_t i = 1; i < MAX_LEVEL; i++)
if (pet.xp >= level_xp[i]) nl = i + 1;
if (nl > pet.level) {
pet.level = nl;
compute_form();
evo_anim = 30; // ~1.8 s celebratory banner
beep(784, 60); beep(988, 60); beep(1175, 60); beep(1568, 140);
led_blink();
}
}
// Chirp once when a need first drops below 50% (gentle) or 20% (urgent),
// re-arming only after it recovers. Audible even while the screen is off,
// so it works as a "tend to me" reminder.
static void notify(uint8_t idx, int8_t val)
{
if (val < 20) {
if (!crit_flag[idx]) { // urgent: descending triple
beep(1568, 60); beep(1175, 60); beep(880, 90);
crit_flag[idx] = 1; warn_flag[idx] = 1;
}
} else if (val < 50) {
if (!warn_flag[idx]) { // gentle: single chirp
beep(1568, 50);
warn_flag[idx] = 1;
}
crit_flag[idx] = 0;
} else {
warn_flag[idx] = 0; crit_flag[idx] = 0;
}
}
static void check_notifications(void)
{
notify(N_FOOD, pet.satiety);
notify(N_FUN, pet.happy);
notify(N_ENGY, pet.energy);
notify(N_WASH, pet.hygiene);
}
// pick the expression frame to draw this instant
static uint8_t current_expression(void)
{
if (!pet.alive) return EXP_DEAD;
if (anim_timer) return anim_exp;
if (pet.sleeping) return EXP_SLEEP;
if (pet.health < 30 || pet.happy < 25 || pet.satiety < 20)
return EXP_SAD;
// idle, with an occasional blink
if ((frame % 50) < 4) return EXP_BLINK;
return EXP_IDLE;
}
// ----------------------------------------------------------------- actions
static void react(uint8_t exp) { anim_exp = exp; anim_timer = ANIM_FRAMES; }
static void do_action(uint8_t act)
{
switch (act) {
case ACT_FEED:
pet.satiety = clamp100(pet.satiety + 30);
pet.hygiene = clamp100(pet.hygiene - 5);
react(EXP_EAT);
beep(880, 60); beep(1175, 80);
led_blink();
add_xp(4);
break;
case ACT_PLAY:
if (pet.energy < 15) { beep(220, 120); break; } // too tired
pet.happy = clamp100(pet.happy + 25);
pet.energy = clamp100(pet.energy - 12);
react(EXP_HAPPY);
beep(988, 60); beep(1319, 60); beep(1568, 90);
led_blink();
add_xp(6);
break;
case ACT_SLEEP:
pet.sleeping = !pet.sleeping;
beep(pet.sleeping ? 440 : 660, 120);
break;
case ACT_CLEAN:
pet.poop = 0;
pet.hygiene = 100;
pet.happy = clamp100(pet.happy + 5);
beep(1319, 50); beep(1047, 50);
add_xp(3);
break;
case ACT_HEAL:
pet.health = clamp100(pet.health + 35);
beep(784, 60); beep(1047, 60); beep(1319, 90);
led_blink();
add_xp(4);
break;
case ACT_STATS:
screen = SCR_STATS;
beep(659, 40);
break;
case ACT_SETTINGS:
screen = SCR_SETTINGS;
set_sel = 0;
beep(659, 40);
break;
case ACT_IR:
screen = SCR_IR;
ir_mode = 0;
ir_status = 0;
ir_stimer = 0;
beep(659, 40);
break;
}
}
// ----------------------------------------------------------------- game tick
static void game_tick(void)
{
pet.age_min++;
if (pet.sleeping) {
pet.energy = clamp100(pet.energy + 6);
// needs still drift down slowly even while asleep
pet.satiety = clamp100(pet.satiety - 1);
if (pet.energy >= 100) pet.sleeping = 0; // wakes when rested
} else {
pet.satiety = clamp100(pet.satiety - 1);
pet.energy = clamp100(pet.energy - 1);
pet.happy = clamp100(pet.happy - 1);
}
// hygiene drifts every other tick
if (pet.age_min & 1) pet.hygiene = clamp100(pet.hygiene - 1);
// occasional bowel movements (derived from counters, no RNG needed)
if (!pet.sleeping && pet.poop < 3 &&
((pet.age_min * 7u + frame) & 0x1F) == 0)
pet.poop++;
// being dirty / full of poop slowly saps happiness
if (pet.poop && (pet.age_min & 1)) pet.happy = clamp100(pet.happy - 1);
if (pet.hygiene < 20 && (pet.age_min & 1)) pet.happy = clamp100(pet.happy - 1);
// health responds to overall wellbeing
int8_t lo = pet.satiety;
if (pet.happy < lo) lo = pet.happy;
if (pet.energy < lo) lo = pet.energy;
if (pet.hygiene < lo) lo = pet.hygiene;
if (lo < 15) pet.health = clamp100(pet.health - 3);
else if (lo > 50) pet.health = clamp100(pet.health + 2);
// a well-kept pet matures: passive XP while every need stays healthy
if (lo > 50 && !pet.sleeping) add_xp(3);
check_notifications();
if (pet.health <= 0) {
pet.alive = 0;
screen = SCR_DEAD;
beep(330, 200); beep(247, 200); beep(165, 400);
}
}
// ----------------------------------------------------------------- drawing
static void draw_center_str(int y, const char *s)
{
int len = 0;
while (s[len]) len++;
int x = (SSD1306_W - len * 8) / 2;
if (x < 0) x = 0;
ssd1306_drawstr(x, y, s, 1);
}
static void draw_menu_bar(void)
{
// 8 icons across the bottom (16 px slots, no gap), selected one boxed
for (uint8_t i = 0; i < ICON_COUNT; i++) {
int x = i * 16;
ssd1306_drawImage(x, 48, menu_icons[i], ICON_W, ICON_H, 0);
if (i == cursor)
ssd1306_drawRect(x, 47, ICON_W, ICON_H + 1, 1);
}
}
// Per-species eye geometry for accessory rendering (shades at user, headset at root).
// When a custom species is present, sprites.h emits CUSTOM_EYE_* defines; update
// those values to match where the eyes sit in your custom_idle.png.
#ifdef CUSTOM_EYE_CY
static const uint8_t eye_cy[SPECIES_COUNT] = { 11, 17, 12, CUSTOM_EYE_CY };
static const uint8_t eye_lx[SPECIES_COUNT] = { 11, 11, 9, CUSTOM_EYE_LX };
static const uint8_t eye_rx[SPECIES_COUNT] = { 20, 21, 23, CUSTOM_EYE_RX };
#else
static const uint8_t eye_cy[SPECIES_COUNT] = { 11, 17, 12 };
static const uint8_t eye_lx[SPECIES_COUNT] = { 11, 11, 9 };
static const uint8_t eye_rx[SPECIES_COUNT] = { 20, 21, 23 };
#endif
// Hacker garb earned as the pet levels up, drawn dark over the lit body so it
// reads as black gear.
static void draw_accessories(int x, int y)
{
uint8_t sp = pet.species;
uint8_t cy = eye_cy[sp], lx = eye_lx[sp], rx = eye_rx[sp];
if (pet.form >= FORM_USER) { // shades
ssd1306_fillRect(x + lx - 3, y + cy - 2, 7, 4, 0);
ssd1306_fillRect(x + rx - 3, y + cy - 2, 7, 4, 0);
if (rx - lx > 7)
ssd1306_drawFastHLine(x + lx + 4, y + cy - 1, rx - lx - 7, 0);
}
if (pet.form >= FORM_ROOT) { // gaming headset + mic boom
ssd1306_drawLine(x + 3, y + 10, x + 9, y + 2, 0);
ssd1306_drawLine(x + 9, y + 2, x + 23, y + 2, 0);
ssd1306_drawLine(x + 23, y + 2, x + 29, y + 10, 0);
ssd1306_fillRect(x + 1, y + 12, 4, 7, 0);
ssd1306_drawLine(x + 3, y + 18, x + 10, y + 24, 0);
}
}
static void draw_creature(int x, int y)
{
const unsigned char *spr;
if (pet.form == FORM_CLAUDE) spr = spr_claude;
else if (pet.form == FORM_GLITCH) spr = spr_glitch;
else spr = species_frames[pet.species][current_expression()];
ssd1306_drawImage(x, y, spr, PANA_W, PANA_H, 0);
// growing forms wear gear; the ascended/glitched forms speak for themselves
if (spr != spr_claude && spr != spr_glitch && !pet.sleeping)
draw_accessories(x, y);
// Claude radiates: a few twinkling pixels that shift each frame
if (pet.form == FORM_CLAUDE) {
ssd1306_drawPixel(x - 2 + (frame & 3), y + 4, 1);
ssd1306_drawPixel(x + PANA_W + (frame & 1), y + 10, 1);
}
}
static void draw_evo_banner(void)
{
ssd1306_fillRect(6, 18, SSD1306_W - 12, 24, 0);
ssd1306_drawRect(6, 18, SSD1306_W - 12, 24, 1);
draw_center_str(21, ">> evolving");
char b[17], *p = b;
p = s_cat(p, "[ ");
p = s_cat(p, form_names[pet.form]);
p = s_cat(p, " ]");
*p = 0;
draw_center_str(31, b);
}
static void draw_main(void)
{
ssd1306_setbuf(0);
// top bar: action label + level badge, with a tiny low-health warning
draw_center_str(0, menu_labels[cursor]);
if (pet.alive && pet.health < 30 && (frame & 8))
ssd1306_drawstr(0, 0, "!", 1);
char badge[6], *bp = badge;
*bp++ = 'L';
bp = u_cat(bp, pet.level);
*bp = 0;
ssd1306_drawstr(SSD1306_W - 2 * 8, 0, badge, 1);
ssd1306_drawFastHLine(0, 10, SSD1306_W, 1);
// pet: gentle horizontal stroll + vertical bob (still while asleep)
int px, py;
if (pet.sleeping) {
px = 48; py = 16;
ssd1306_drawstr(px + PANA_W - 2, 14, "z", 1);
ssd1306_drawstr(px + PANA_W + 4, 12, "Z", 1);
} else {
uint32_t t = frame % 80;
px = (t < 40) ? 30 + t : 30 + (80 - t); // 30..70..30
py = 14 + ((frame % 16) < 8 ? 0 : 1);
}
draw_creature(px, py);
// floor + poop piles
ssd1306_drawFastHLine(0, 46, SSD1306_W, 1);
for (uint8_t i = 0; i < pet.poop; i++)
ssd1306_drawImage(8 + i * 12, 38, spr_poop, 8, 8, 0);
draw_menu_bar();
if (evo_anim) draw_evo_banner(); // celebratory overlay on top
ssd1306_refresh();
}
static void draw_bar(int y, const char *label, int8_t val)
{
ssd1306_drawstr(0, y, label, 1);
int bx = 48, bw = 76;
ssd1306_drawRect(bx, y, bw, 7, 1);
int fill = (val * (bw - 2)) / 100;
if (fill > 0) ssd1306_fillRect(bx + 1, y + 1, fill, 5, 1);
}
static void draw_stats(void)
{
ssd1306_setbuf(0);
draw_center_str(0, "~/pet $ stat");
draw_bar(10, "FOOD", pet.satiety);
draw_bar(19, "HAPPY", pet.happy);
draw_bar(28, "ENERGY", pet.energy);
draw_bar(37, "CLEAN", pet.hygiene);
draw_bar(46, "HEALTH", pet.health);
// no form name here: keep room for the XP-to-next-level readout
char line[17], *p = line;
*p++ = 'L';
p = u_cat(p, pet.level);
p = s_cat(p, " xp ");
if (pet.level < MAX_LEVEL) {
p = u_cat(p, pet.xp);
*p++ = '/';
p = u_cat(p, level_xp[pet.level]);
} else {
p = s_cat(p, "MAX");
}
*p = 0;
ssd1306_drawstr(0, 56, line, 1);
ssd1306_refresh();
}
static void draw_dead(void)
{
ssd1306_setbuf(0);
ssd1306_drawImage(48, 2, spr_grave, 32, 32, 0);
draw_center_str(36, pet.ascend == 2 ? "** CORRUPTED **" : "SEGFAULT (11)");
char line[17], *p = line;
p = s_cat(p, "core dumped L");
p = u_cat(p, pet.level);
*p = 0;
draw_center_str(46, line);
draw_center_str(56, "OK: respawn");
ssd1306_refresh();
}
// welcome screen: choose a starter species before booting the pet
static void draw_select(void)
{
ssd1306_setbuf(0);
draw_center_str(0, "~ $ adopt --pet");
ssd1306_drawFastHLine(0, 10, SSD1306_W, 1);
// big idle preview of the highlighted species (no gear at level 1)
ssd1306_drawImage(48, 13, species_frames[sel_species][EXP_IDLE],
PANA_W, PANA_H, 0);
char line[17], *p = line;
p = s_cat(p, "< ");
p = s_cat(p, species_names[sel_species]);
p = s_cat(p, " >");
*p = 0;
draw_center_str(47, line);
draw_center_str(56, "L/R pick OK go");
ssd1306_refresh();
}
static void draw_settings(void)
{
ssd1306_setbuf(0);
draw_center_str(0, "- SETTINGS -");
ssd1306_drawFastHLine(0, 10, SSD1306_W, 1);
char rows[SETTINGS_ROWS][17];
*s_cat(s_cat(rows[0], "Screen off:"), screen_timeout_names[timeout_idx]) = 0;
*s_cat(s_cat(rows[1], "Bright: "), brightness_names[bright_idx]) = 0;
*s_cat(s_cat(rows[2], "Sound: "), sound_on ? "On" : "Off") = 0;
*s_cat(rows[3], "Back") = 0;
for (uint8_t r = 0; r < SETTINGS_ROWS; r++) {
int y = 12 + r * 11;
if (r == set_sel) ssd1306_fillRect(0, y - 1, SSD1306_W, 10, 1);
// selected row drawn inverted (color 0 on the lit bar)
ssd1306_drawstr(4, y, rows[r], r == set_sel ? 0 : 1);
}
draw_center_str(56, "L/R move OK use");
ssd1306_refresh();
}
static const char *const ir_mode_names[IR_MODE_COUNT] = {
"ZAPP", "RECV", "BEAM", "TV", "BACK",
};
static void draw_ir(void)
{
ssd1306_setbuf(0);
draw_center_str(0, "~ IR BEAM ~");
ssd1306_drawFastHLine(0, 10, SSD1306_W, 1);
// mode selector with arrows
char mline[17], *p = mline;
p = s_cat(p, "< ");
p = s_cat(p, ir_mode_names[ir_mode]);
p = s_cat(p, " >");
*p = 0;
draw_center_str(20, mline);
// status line
if (ir_stimer) {
const char *msg = "?";
if (ir_status == 1) msg = "SENT!";
else if (ir_status == 2) msg = "GOT IT! +xp";
else if (ir_status == 3) msg = "NOTHING";
else if (ir_status == 4) msg = "WAITING...";
draw_center_str(35, msg);
} else {
const char *hint = "?";
if (ir_mode == IR_ZAPP) hint = "OK: send ping";
else if (ir_mode == IR_RECV) hint = "OK: listen 2s";
else if (ir_mode == IR_BEAM) hint = "OK: beam pet";
else if (ir_mode == IR_TV) hint = "OK: enter";
else if (ir_mode == IR_BACK) hint = "OK: back";
draw_center_str(35, hint);
}
draw_center_str(56, "L/R mode OK go");
ssd1306_refresh();
}
// Called when OK is pressed on the IR screen.
static void do_ir_action(void)
{
if (ir_mode == IR_BACK) {
screen = SCR_MAIN;
beep(880, 30);
return;
}
if (ir_mode == IR_ZAPP) {
ir_send_nec(PANA_ADDR, 0x01); // fixed ping command
beep(988, 40); beep(1319, 60);
led_blink();
ir_status = 1; ir_stimer = 30;
return;
}
if (ir_mode == IR_BEAM) {
// encode species in high nibble, level in low nibble
uint8_t cmd = (uint8_t)((pet.species << 4) | (pet.level & 0x0F));
ir_send_nec(PANA_ADDR, cmd);
beep(880, 40); beep(1175, 40); beep(1568, 70);
led_blink();
ir_status = 1; ir_stimer = 30;
return;
}
if (ir_mode == IR_TV) {
screen = SCR_TV;
tv_cmd = 0;
beep(659, 40);
return;
}
if (ir_mode == IR_RECV) {
// show WAITING... while blocking
ir_status = 4; ir_stimer = 255;
draw_ir();
// power up the IR receiver
funDigitalWrite(IR_RX_VCC, FUN_HIGH);
Delay_Ms(5); // stabilise
uint8_t addr = 0, cmd = 0;
uint8_t ok = ir_recv_nec(&addr, &cmd);
funDigitalWrite(IR_RX_VCC, FUN_LOW); // power down receiver
if (ok) {
if (addr == PANA_ADDR) {
if (cmd == 0x01) {
// ping from another badge: mood boost
pet.happy = clamp100(pet.happy + 10);
add_xp(5);
} else {
// BEAM packet: species in high nibble, level in low nibble
uint8_t lvl = cmd & 0x0F;
if (lvl > pet.level) add_xp(8); else add_xp(3);
pet.happy = clamp100(pet.happy + 8);
}
} else {
// generic NEC signal: small happy bump
pet.happy = clamp100(pet.happy + 3);
}
beep(784, 60); beep(988, 60); beep(1175, 90);
led_blink();
ir_status = 2;
} else {
beep(330, 150);
ir_status = 3;
}
ir_stimer = 45;
}
}
static void draw_tv(void)
{
ssd1306_setbuf(0);
draw_center_str(0, "~ TV REMOTE ~");
ssd1306_drawFastHLine(0, 10, SSD1306_W, 1);
// command selector
char line[17], *p = line;
p = s_cat(p, "< ");
p = s_cat(p, tv_cmd_names[tv_cmd]);
p = s_cat(p, " >");
*p = 0;
draw_center_str(22, line);
if (tv_cmd < TV_CMD_COUNT)
draw_center_str(36, "blast 5 brands");
// BACK entry has no sub-label
ssd1306_drawFastHLine(0, 46, SSD1306_W, 1);
draw_center_str(50, "L/R cmd OK go");
ssd1306_refresh();
}
// ----------------------------------------------------------------- input
typedef struct { uint8_t ok, left, right; } Buttons;
static Buttons read_buttons(void)
{
// active-low: pressed reads 0
Buttons b;
b.ok = !funDigitalRead(BTN_OK);
b.left = !funDigitalRead(BTN_LEFT);
b.right = !funDigitalRead(BTN_RIGHT);
return b;
}
// ----------------------------------------------------------------- low power
// Two wake sources let the core idle in WFI light-sleep while the OLED is off:
// * a button press (EXTI falling edge) -> light the screen back up
// * a periodic TIM2 overflow (~once per game tick) -> run a quick check
// WFI only gates the CPU clock, so SRAM/registers (and the whole Pet) survive
// untouched; full standby would wipe them and force a reboot/splash on wake.
static volatile uint8_t exti_wake; // a button interrupt fired
static volatile uint8_t tick_wake; // the periodic check timer fired
// OK=PC0->EXTI0, Right=PD5->EXTI5, Left=PD6->EXTI6 all share this vector.
void EXTI7_0_IRQHandler(void) INTERRUPT_DECORATOR;
void EXTI7_0_IRQHandler(void)
{
EXTI->INTFR = EXTI_Line0 | EXTI_Line5 | EXTI_Line6; // clear pending
exti_wake = 1;
}
void TIM2_IRQHandler(void) INTERRUPT_DECORATOR;
void TIM2_IRQHandler(void)
{
TIM2->INTFR = (uint16_t)~TIM_FLAG_Update; // clear update flag
tick_wake = 1;
}
static void lowpower_setup(void)
{
// --- button wake: falling-edge EXTI on the three button pins ---
// (AFIO + GPIO clocks were already enabled by funGpioInitAll.)
AFIO->EXTICR |= AFIO_EXTICR_EXTI0_PC | // OK on PC0
AFIO_EXTICR_EXTI5_PD | // Right on PD5
AFIO_EXTICR_EXTI6_PD; // Left on PD6
EXTI->INTENR |= EXTI_Line0 | EXTI_Line5 | EXTI_Line6;
EXTI->FTENR |= EXTI_Line0 | EXTI_Line5 | EXTI_Line6; // press == falling edge
NVIC_EnableIRQ(EXTI7_0_IRQn);
// --- periodic wake: TIM2 update IRQ, one overflow per game tick ---
RCC->APB1PCENR |= RCC_APB1Periph_TIM2;
TIM2->PSC = 23999; // 24 MHz / 24000 = 1 kHz (1 ms/count)
TIM2->ATRLR = TICK_MS - 1; // overflow once per game tick
TIM2->SWEVGR = TIM_PSCReloadMode_Immediate; // latch PSC/ARR now
TIM2->INTFR = (uint16_t)~TIM_FLAG_Update;
TIM2->DMAINTENR |= TIM_UIE; // enable update interrupt
NVIC_EnableIRQ(TIM2_IRQn);
// the counter itself stays disabled until we actually enter low-power sleep
}
// Doze in WFI until a button or the periodic timer wakes us. Returns only once
// the display has been switched back on (button press, or the pet dying while
// we slept); otherwise it keeps running background ticks and sleeping again.
static void enter_low_power(void)
{
for (;;) {
exti_wake = 0;
tick_wake = 0;
TIM2->CNT = 0;
TIM2->INTFR = (uint16_t)~TIM_FLAG_Update;
TIM2->CTLR1 |= TIM_CEN; // arm the periodic wake
__WFI(); // core clock gated until an IRQ
TIM2->CTLR1 &= ~TIM_CEN; // halt it while we're awake
if (exti_wake) { // a button: bring the screen back
ssd1306_cmd(SSD1306_DISPLAYON);
disp_on = 1;
idle_frames = 0;
tick_acc = 0;
return;
}
// otherwise it was the periodic check: advance the pet a tick. This
// still chirps low-need reminders (and the death tune) over the buzzer.
if (pet.alive) {
game_tick();
if (!pet.alive) { // it died in its sleep: show the grave
ssd1306_cmd(SSD1306_DISPLAYON);
disp_on = 1;
idle_frames = 0;
return;
}
}
}
}
// ----------------------------------------------------------------- setup
static void gpio_setup(void)
{
funGpioInitAll();
funPinMode(LED_PIN, GPIO_Speed_10MHz | GPIO_CNF_OUT_PP);
funDigitalWrite(LED_PIN, FUN_LOW);
funPinMode(BUZZER, GPIO_Speed_10MHz | GPIO_CNF_OUT_PP);
funDigitalWrite(BUZZER, FUN_LOW);
funPinMode(IR_TX, GPIO_Speed_10MHz | GPIO_CNF_OUT_PP);
funDigitalWrite(IR_TX, FUN_LOW);
funPinMode(IR_RX_VCC, GPIO_Speed_10MHz | GPIO_CNF_OUT_PP);
funDigitalWrite(IR_RX_VCC, FUN_LOW); // receiver off at boot
funPinMode(IR_RX, GPIO_CNF_IN_PUPD);
funDigitalWrite(IR_RX, FUN_HIGH); // pull-up (idle = HIGH)
// buttons: input with internal pull-up (ODR high selects pull-up)
funPinMode(BTN_OK, GPIO_CNF_IN_PUPD);
funPinMode(BTN_LEFT, GPIO_CNF_IN_PUPD);
funPinMode(BTN_RIGHT, GPIO_CNF_IN_PUPD);
funDigitalWrite(BTN_OK, FUN_HIGH);
funDigitalWrite(BTN_LEFT, FUN_HIGH);
funDigitalWrite(BTN_RIGHT, FUN_HIGH);
}
static void splash(void)
{
// fake terminal boot sequence, revealed line by line
static const char *const boot[] = {
"panagotchi v2",
"booting kernel..",
"mount /pet ok",
"spawn shell ok",
"> ./tamagotchi_",
};
ssd1306_setbuf(0);
for (uint8_t i = 0; i < 5; i++) {
ssd1306_drawstr(0, i * 10, boot[i], 1);
ssd1306_refresh();
beep(1200 + i * 120, 25);
Delay_Ms(220);
}
// show what woke the chip: POR == power dip / brown-out (e.g. OLED inrush
// sagging the coin cell), PIN == reset pin, SFT == software, none == we got
// here without a real hardware reset.
char rc[20], *rp = rc;
rp = s_cat(rp, "rst:");
if (reset_cause & RCC_LPWRRSTF) rp = s_cat(rp, "LPW ");
if (reset_cause & RCC_WWDGRSTF) rp = s_cat(rp, "WWD ");
if (reset_cause & RCC_IWDGRSTF) rp = s_cat(rp, "WDG ");
if (reset_cause & RCC_SFTRSTF) rp = s_cat(rp, "SFT ");
if (reset_cause & RCC_PORRSTF) rp = s_cat(rp, "POR ");
if (reset_cause & RCC_PINRSTF) rp = s_cat(rp, "PIN ");
if (rp == rc + 4) rp = s_cat(rp, "none");
*rp = 0;
ssd1306_drawstr(0, 44, rc, 1);
draw_center_str(54, "press any key");
ssd1306_refresh();
beep(988, 120);
// wait until a button is pressed (or ~4 s timeout)
for (int i = 0; i < 200; i++) {
Buttons b = read_buttons();
if (b.ok || b.left || b.right) break;
Delay_Ms(20);
}
}
// ----------------------------------------------------------------- main
int main(void)
{
SystemInit();
reset_cause = RCC->RSTSCKR & 0xFC000000; // latch reset flags...
RCC->RSTSCKR |= RCC_RMVF; // ...then clear for next boot
gpio_setup();
lowpower_setup(); // button-wake EXTI + periodic-check timer
Delay_Ms(120); // let the OLED rail settle
if (ssd1306_i2c_init()) { // non-zero == failure
// no display: fall back to a heartbeat blink so the board still shows life
while (1) { led_blink(); Delay_Ms(700); }
}
ssd1306_init();
apply_brightness(); // start at the configured (dimmer) brightness
splash();
screen = SCR_SELECT; // pick a species before a pet is born
Buttons prev = {0, 0, 0};
while (1) {
if (!disp_on) {
// Display is off: hand the core to WFI light-sleep, waking only
// for button presses or the periodic background check. It returns
// once the screen is lit again; swallow whatever press woke us so
// it just wakes the display rather than also firing an action.
enter_low_power();
prev = read_buttons();
continue;
}
Buttons b = read_buttons();
uint8_t ok_edge = b.ok && !prev.ok;
uint8_t left_edge = b.left && !prev.left;
uint8_t right_edge = b.right && !prev.right;
uint8_t any_edge = ok_edge | left_edge | right_edge;
prev = b;
if (any_edge) idle_frames = 0;
{
switch (screen) {
case SCR_SELECT:
if (left_edge) {
sel_species = (sel_species + SPECIES_COUNT - 1) % SPECIES_COUNT;
beep(1047, 25);
}
if (right_edge) {
sel_species = (sel_species + 1) % SPECIES_COUNT;
beep(1047, 25);
}
if (ok_edge) { // born! boot the chosen species
pet_reset(); // sets screen = SCR_MAIN, species = sel_species
beep(659, 80); beep(988, 120);
}
draw_select();
break;
case SCR_MAIN:
if (left_edge) {
cursor = (cursor + ICON_COUNT - 1) % ICON_COUNT;
beep(1047, 25);
}
if (right_edge) {
cursor = (cursor + 1) % ICON_COUNT;
beep(1047, 25);
}
if (ok_edge) do_action(cursor);
draw_main();
break;
case SCR_STATS:
if (any_edge) { screen = SCR_MAIN; beep(880, 30); }
draw_stats();
break;
case SCR_SETTINGS:
if (left_edge) {
set_sel = (set_sel + SETTINGS_ROWS - 1) % SETTINGS_ROWS;
beep(1047, 25);
}
if (right_edge) {
set_sel = (set_sel + 1) % SETTINGS_ROWS;
beep(1047, 25);
}
if (ok_edge) {
if (set_sel == 0) { // cycle screen-off timeout
timeout_idx = (timeout_idx + 1) % TIMEOUT_OPT_COUNT;
beep(880, 40);
} else if (set_sel == 1) { // cycle brightness
bright_idx = (bright_idx + 1) % BRIGHT_OPT_COUNT;
apply_brightness(); // takes effect immediately
beep(880, 40);
} else if (set_sel == 2) { // toggle sound
sound_on = !sound_on;
beep(988, 60); // only audible if just enabled
} else { // Back
screen = SCR_MAIN;
beep(880, 30);
}
}
draw_settings();
break;
case SCR_DEAD:
if (ok_edge) { // back to the welcome screen to re-pick
screen = SCR_SELECT;
beep(659, 80); beep(988, 120);
}
draw_dead();
break;
case SCR_IR:
if (left_edge) {
ir_mode = (ir_mode + IR_MODE_COUNT - 1) % IR_MODE_COUNT;
beep(1047, 25);
}
if (right_edge) {
ir_mode = (ir_mode + 1) % IR_MODE_COUNT;
beep(1047, 25);
}
if (ok_edge) do_ir_action();
if (ir_stimer) ir_stimer--;
draw_ir();
break;
case SCR_TV:
if (left_edge) {
tv_cmd = (tv_cmd + TV_NAV_COUNT - 1) % TV_NAV_COUNT;
beep(1047, 25);
}
if (right_edge) {
tv_cmd = (tv_cmd + 1) % TV_NAV_COUNT;
beep(1047, 25);
}
if (ok_edge) {
if (tv_cmd == TV_CMD_COUNT) { // BACK entry
screen = SCR_IR;
beep(880, 30);
} else {
// blast selected command at all brands in turn
for (uint8_t b = 0; b < TV_BRAND_COUNT; b++) {
ir_send_nec(tv_addr[b], tv_cmds[b][tv_cmd]);
Delay_Ms(40); // inter-frame gap
}
beep(988, 40); beep(1319, 80);
led_blink();
}
}
draw_tv();
break;
}
// sleep the display after the configured idle period
uint16_t timeout = screen_timeout_opts[timeout_idx];
if (timeout && idle_frames >= timeout) {
ssd1306_cmd(SSD1306_DISPLAYOFF);
disp_on = 0;
}
}
if (anim_timer) anim_timer--;
if (evo_anim) evo_anim--;
// advance game time independent of screen / display state
if (pet.alive && ++tick_acc >= TICK_FRAMES) {
tick_acc = 0;
game_tick();
}
frame++;
idle_frames++;
Delay_Ms(FRAME_MS);
}
}