353 lines
9.0 KiB
C++
353 lines
9.0 KiB
C++
#include "FaceRenderer.h"
|
|
|
|
static int clampInt(int v, int lo, int hi) {
|
|
if (v < lo) return lo;
|
|
if (v > hi) return hi;
|
|
return v;
|
|
}
|
|
|
|
unsigned long FaceRenderer::randRange(unsigned long lo, unsigned long hi) {
|
|
if (hi <= lo) return lo;
|
|
return lo + (unsigned long)random((long)(hi - lo + 1UL));
|
|
}
|
|
|
|
int8_t FaceRenderer::randRangeI8(int8_t lo, int8_t hi) {
|
|
if (hi <= lo) return lo;
|
|
return (int8_t)(lo + random((int)(hi - lo + 1)));
|
|
}
|
|
|
|
void FaceRenderer::begin(Display& display, Settings& settings) {
|
|
_display = &display;
|
|
_settings = &settings;
|
|
|
|
randomSeed((uint32_t)esp_random());
|
|
|
|
unsigned long now = millis();
|
|
_nextFrameMs = now;
|
|
_nextBlinkMs = now + randRange(3000, 9000);
|
|
_nextSillyMs = now + randRange(20000, 60000);
|
|
_nextGazeMs = now + randRange(800, 2000);
|
|
}
|
|
|
|
void FaceRenderer::loop(const MoistureSensor& moisture, const BatterySensor& battery) {
|
|
unsigned long now = millis();
|
|
if ((long)(now - _nextFrameMs) < 0) return;
|
|
_nextFrameMs = now + FRAME_MS;
|
|
|
|
updateMood(moisture.percent());
|
|
updateDeathMode(now);
|
|
updateBatteryLowMode(battery.percent());
|
|
|
|
if (_deadMode) {
|
|
renderDead(now, battery);
|
|
return;
|
|
}
|
|
|
|
if (_batteryLowMode) {
|
|
renderBatteryLow(now, battery);
|
|
return;
|
|
}
|
|
|
|
if (_mood == HAPPY) updateHappy(now);
|
|
else if (_mood == DRY) updateDry(now);
|
|
else updateTooWet(now);
|
|
|
|
renderNormal(now, battery);
|
|
}
|
|
|
|
void FaceRenderer::updateMood(int moisturePct) {
|
|
PlantThresholds t = _settings->thresholds();
|
|
|
|
if (moisturePct >= t.tooWetPct) _mood = TOO_WET;
|
|
else if (moisturePct <= t.dryPct) _mood = DRY;
|
|
else _mood = HAPPY;
|
|
}
|
|
|
|
void FaceRenderer::updateDeathMode(unsigned long now) {
|
|
if (_settings->kidsMode()) {
|
|
_deadMode = false;
|
|
_dryStartMs = 0;
|
|
return;
|
|
}
|
|
|
|
if (_mood == DRY) {
|
|
if (_dryStartMs == 0) _dryStartMs = now;
|
|
if ((now - _dryStartMs) > DRY_DEADLINE_MS) {
|
|
_deadMode = true;
|
|
_nextDeadToggleMs = now + 3000;
|
|
}
|
|
} else {
|
|
_dryStartMs = 0;
|
|
_deadMode = false;
|
|
}
|
|
}
|
|
|
|
void FaceRenderer::updateBatteryLowMode(int batteryPercent) {
|
|
// Enter nervous mode when battery is critically low (< 10%)
|
|
_batteryLowMode = (batteryPercent < 10);
|
|
}
|
|
|
|
void FaceRenderer::updateHappy(unsigned long now) {
|
|
if ((long)(now - _nextGazeMs) >= 0) {
|
|
_targetX = randRangeI8(-3, 3);
|
|
_targetY = randRangeI8(-2, 2);
|
|
_nextGazeMs = now + randRange(800, 2200);
|
|
}
|
|
|
|
_gazeX += clampInt(_targetX - _gazeX, -1, 1);
|
|
_gazeY += clampInt(_targetY - _gazeY, -1, 1);
|
|
|
|
bool sillyDue = (long)(now - _nextSillyMs) >= 0;
|
|
if (!_silly && sillyDue) {
|
|
_silly = true;
|
|
_sillyVariant = random(0, 3);
|
|
_sillyUntilMs = now + (_settings->kidsMode() ? 4000 : 2000);
|
|
_nextSillyMs = now + (_settings->kidsMode() ? randRange(15000, 35000)
|
|
: randRange(60000, 180000));
|
|
}
|
|
|
|
if (_silly && (long)(now - _sillyUntilMs) >= 0) _silly = false;
|
|
|
|
if ((now - _lastBlinkMs) > 60000 || (long)(now - _nextBlinkMs) >= 0) {
|
|
_blinking = true;
|
|
_blinkUntilMs = now + 90;
|
|
_lastBlinkMs = now;
|
|
_nextBlinkMs = now + randRange(5000, 15000);
|
|
}
|
|
|
|
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
|
|
}
|
|
|
|
void FaceRenderer::updateDry(unsigned long now) {
|
|
if ((now - _lastBlinkMs) > 60000) {
|
|
_blinking = true;
|
|
_blinkUntilMs = now + 120;
|
|
_lastBlinkMs = now;
|
|
}
|
|
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
|
|
}
|
|
|
|
void FaceRenderer::updateTooWet(unsigned long now) {
|
|
if ((now - _lastBlinkMs) > 60000) {
|
|
_blinking = true;
|
|
_blinkUntilMs = now + 90;
|
|
_lastBlinkMs = now;
|
|
}
|
|
if (_blinking && (long)(now - _blinkUntilMs) >= 0) _blinking = false;
|
|
}
|
|
|
|
void FaceRenderer::renderDead(unsigned long now, const BatterySensor& battery) {
|
|
auto &d = _display->oled();
|
|
d.clearDisplay();
|
|
|
|
if ((long)(now - _nextDeadToggleMs) >= 0) {
|
|
_deadShowTombstone = !_deadShowTombstone;
|
|
_nextDeadToggleMs = now + 3000;
|
|
}
|
|
|
|
if (_deadShowTombstone) {
|
|
d.drawRoundRect(38, 12, 52, 40, 8, 1);
|
|
d.setCursor(52, 28);
|
|
d.setTextSize(2);
|
|
d.print("RIP");
|
|
} else {
|
|
drawXEye(40, 24);
|
|
drawXEye(88, 24);
|
|
drawMouthFlat();
|
|
}
|
|
|
|
// Draw battery icon in top-right corner
|
|
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink());
|
|
|
|
d.display();
|
|
}
|
|
|
|
void FaceRenderer::renderBatteryLow(unsigned long now, const BatterySensor& battery) {
|
|
auto &d = _display->oled();
|
|
d.clearDisplay();
|
|
|
|
// Nervous animation: eyes glance up at battery icon repeatedly
|
|
// Battery icon is at (110, 2) in top-right corner
|
|
// Eyes are centered at (40, 24) for left, (88, 24) for right
|
|
|
|
// Create a glancing pattern: look up at battery, then center, repeat
|
|
int glancePhase = (now / 800) % 3; // 3 phases: up, center, up
|
|
|
|
int pupilX = 0, pupilY = 0;
|
|
|
|
if (glancePhase == 0 || glancePhase == 2) {
|
|
// Look up toward battery icon (upper right)
|
|
pupilX = 3; // Look right
|
|
pupilY = -4; // Look up
|
|
} else {
|
|
// Brief center position between glances
|
|
pupilX = 0;
|
|
pupilY = -2; // Still slightly upward (worried/anxious)
|
|
}
|
|
|
|
// Draw eyes with pupils positioned
|
|
if (_blinking) {
|
|
drawEyesClosed();
|
|
} else {
|
|
drawEyesOpen(pupilX, pupilY);
|
|
}
|
|
|
|
// Worried eyebrows
|
|
drawBrowsWorried();
|
|
|
|
// Nervous mouth (wavy/uncertain)
|
|
drawMouthNervous();
|
|
|
|
// Draw battery icon in top-right corner (blinking)
|
|
_display->drawBatteryIcon(110, 2, battery.percent(), true);
|
|
|
|
d.display();
|
|
}
|
|
|
|
void FaceRenderer::renderNormal(unsigned long now, const BatterySensor& battery) {
|
|
auto &d = _display->oled();
|
|
d.clearDisplay();
|
|
|
|
if (_mood == HAPPY) {
|
|
if (_silly) drawSilly(_sillyVariant);
|
|
else if (_blinking) drawEyesClosed();
|
|
else drawEyesOpen(_gazeX, _gazeY);
|
|
drawMouthHappy();
|
|
drawSparkles(now);
|
|
}
|
|
else if (_mood == DRY) {
|
|
if (_blinking) drawEyesClosed();
|
|
else drawEyesSmallPupils(6, 0, 0);
|
|
drawBrowsVerySad();
|
|
drawMouthFrown();
|
|
drawBigWaterText(now);
|
|
}
|
|
else {
|
|
if (_blinking) drawEyesClosed();
|
|
else drawEyesOpen(0, 0);
|
|
drawMouthFlat();
|
|
drawBubbles((now / 100) % 12 - 6);
|
|
}
|
|
|
|
// Draw battery icon in top-right corner
|
|
_display->drawBatteryIcon(110, 2, battery.percent(), battery.shouldBlink());
|
|
|
|
d.display();
|
|
}
|
|
|
|
/* --- Drawing helpers --- */
|
|
|
|
void FaceRenderer::drawEyesOpen(int dx, int dy) {
|
|
auto &d = _display->oled();
|
|
d.fillCircle(40, 24, 12, 1);
|
|
d.fillCircle(88, 24, 12, 1);
|
|
d.fillCircle(40 + dx, 24 + dy, 4, 0);
|
|
d.fillCircle(88 + dx, 24 + dy, 4, 0);
|
|
}
|
|
|
|
void FaceRenderer::drawEyesClosed() {
|
|
auto &d = _display->oled();
|
|
d.drawLine(28, 24, 52, 24, 1);
|
|
d.drawLine(76, 24, 100, 24, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawEyesSmallPupils(int dy, int sx, int sy) {
|
|
auto &d = _display->oled();
|
|
d.fillCircle(40, 24, 12, 1);
|
|
d.fillCircle(88, 24, 12, 1);
|
|
d.fillCircle(40 + sx, 24 + dy, 2, 0);
|
|
d.fillCircle(88 + sx, 24 + dy, 2, 0);
|
|
}
|
|
|
|
void FaceRenderer::drawBrowsVerySad() {
|
|
auto &d = _display->oled();
|
|
d.drawLine(26, 12, 48, 18, 1);
|
|
d.drawLine(80, 18, 102, 12, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawBrowsWorried() {
|
|
auto &d = _display->oled();
|
|
// Raised/arched worried eyebrows
|
|
d.drawLine(26, 14, 36, 10, 1);
|
|
d.drawLine(36, 10, 48, 12, 1);
|
|
d.drawLine(80, 12, 92, 10, 1);
|
|
d.drawLine(92, 10, 102, 14, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawMouthHappy() {
|
|
auto &d = _display->oled();
|
|
for (int i = 0; i < 5; i++)
|
|
d.drawLine(54 + i, 46 + i / 2, 74 - i, 46 + i / 2, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawMouthFrown() {
|
|
auto &d = _display->oled();
|
|
for (int i = 0; i < 5; i++)
|
|
d.drawLine(54 - i, 48 + i / 2, 74 + i, 48 + i / 2, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawMouthFlat() {
|
|
auto &d = _display->oled();
|
|
d.drawLine(52, 48, 76, 48, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawMouthNervous() {
|
|
auto &d = _display->oled();
|
|
// Wavy/uncertain nervous mouth
|
|
d.drawLine(52, 48, 58, 47, 1);
|
|
d.drawLine(58, 47, 64, 48, 1);
|
|
d.drawLine(64, 48, 70, 47, 1);
|
|
d.drawLine(70, 47, 76, 48, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawBubbles(int off) {
|
|
auto &d = _display->oled();
|
|
d.drawCircle(10 + off, 12, 4, 1);
|
|
d.drawCircle(20 + off, 20, 3, 1);
|
|
d.drawCircle(30 + off, 14, 5, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawSparkles(unsigned long now) {
|
|
auto &d = _display->oled();
|
|
if ((now / 250) % 2 == 0) {
|
|
d.drawPixel(10, 10, 1);
|
|
d.drawPixel(118, 12, 1);
|
|
}
|
|
}
|
|
|
|
void FaceRenderer::drawSilly(uint8_t v) {
|
|
auto &d = _display->oled();
|
|
if (v == 0) {
|
|
drawEyesOpen(2, 2);
|
|
d.drawCircle(64, 48, 6, 1);
|
|
} else if (v == 1) {
|
|
drawEyesClosed();
|
|
d.drawRect(58, 44, 12, 8, 1);
|
|
} else {
|
|
drawEyesOpen(-2, 1);
|
|
d.drawLine(52, 48, 76, 48, 1);
|
|
d.drawPixel(64, 54, 1);
|
|
}
|
|
}
|
|
|
|
void FaceRenderer::drawXEye(int cx, int cy) {
|
|
auto &d = _display->oled();
|
|
d.drawLine(cx - 6, cy - 6, cx + 6, cy + 6, 1);
|
|
d.drawLine(cx - 6, cy + 6, cx + 6, cy - 6, 1);
|
|
}
|
|
|
|
void FaceRenderer::drawBigWaterText(unsigned long now) {
|
|
auto &d = _display->oled();
|
|
const char* msg = ((now / 2200UL) % 2UL == 0UL) ? "Need...water..." : "So...thirsty...";
|
|
d.setTextSize(1);
|
|
d.setTextColor(1);
|
|
|
|
int16_t x1 = 0, y1 = 0;
|
|
uint16_t w = 0, h = 0;
|
|
d.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
|
|
|
|
int16_t x = (int16_t)((128 - (int)w) / 2);
|
|
d.setCursor(x < 0 ? 0 : x, 56);
|
|
d.print(msg);
|
|
}
|