diff --git a/README.md b/README.md index 729d24e..865522e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ components/el512/ shared driver + ESPHome component (one copy of the code) still_alive.* Portal end-credits show, 5x7 font (el512_font.h) still_alive_data.h show content, generated by tools/import_still_alive.py cyberpunk.* animated Cyberpunk-style ESPHome pages + konosuba.* animated KonoSuba pages; konosuba_sprites.h from tools/ + el512_pages.h home data and helpers shared by the themed pages el512_display.* ESPHome display platform `el512` __init__.py, display.py ESPHome config schema lib/EL512/ Arduino library: Adafruit_GFX front end (EL512_GFX) @@ -90,7 +92,7 @@ src/still_alive/ Portal-style end-credits demo (env still_alive) src/test_images/ test sketch: cycles through 12 test images (env esp32dev) esphome/ el512-panel.yaml (Home Assistant display), el512-smoke.yaml home_assistant/packages/ el512_panel.yaml: helper sensors for the ESPHome pages -tools/ import_still_alive.py +tools/ import_still_alive.py, konosuba_sprites.py test/ unit tests (placeholder) docs/ datasheet and notes ``` @@ -109,9 +111,9 @@ pio device monitor ## ESPHome / Home Assistant [esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home -Assistant display with twelve pages: clock and weather, forecast, agenda, a sensor -dashboard with a message line, now playing, Plex, solar, Still Alive, and four -animated [Cyberpunk-style pages](#cyberpunk-pages). Pages +Assistant display with sixteen pages: clock and weather, forecast, agenda, a sensor +dashboard with a message line, now playing, Plex, solar, Still Alive, and eight +animated themed pages ([Cyberpunk](#cyberpunk-pages), [KonoSuba](#konosuba-pages)). Pages rotate on an interval; Home Assistant gets a page select, a pause switch, the interval, a message text, a "Play Still Alive" button, and Display / Low power mode switches (ENABLE, _LOWPWR). @@ -215,6 +217,38 @@ These pages are inspired by the look of Cyberpunk 2077 and use no assets from it Cyberpunk 2077 is a trademark of CD PROJEKT S.A.; this project is not affiliated with or endorsed by CD PROJEKT RED. +## KonoSuba pages + +Four animated pages around the KonoSuba party, drawn by +[components/el512/konosuba.cpp](components/el512/konosuba.cpp) with Cinzel, Nunito, +Press Start 2P, Noto Sans JP and Dela Gothic One (Google Fonts), all in the rotation: + +- **KonoSuba Explosion**: Megumin chants under a night sky while a magic circle + draws itself, then EXPLOSION (flash, shockwave, mushroom cloud, screen shake). She + ends up flat on the ground, Kazuma rates the explosion, and the day's count goes up. +- **KonoSuba Guild**: the Adventurers' Guild board: your next agenda events as quest + notes (rank, deadline, reward in Eris, NEW! for today), the weather as a hazard + notice and today's solar yield as the cabbage-harvest bounty, while cabbages fly by. +- **KonoSuba Card**: Megumin's adventurer card: level from today's solar yield, mana + from solar power, stamina from what the house load leaves, a daily luck roll, and the + explosions cast today. +- **KonoSuba Battle**: the party against a giant toad in a JRPG window; the battle log + uses live values (Kazuma steals the grid import, Megumin's explosion does the solar + watts in damage). + +The pixel art lives as text in [tools/konosuba_sprites.py](tools/konosuba_sprites.py), +which writes `components/el512/konosuba_sprites.h`; `--preview sprites.png` renders it +magnified for checking: + +```bash +python tools/konosuba_sprites.py --preview sprites.png +``` + +These pages are fan art: the pixel art and all texts are original, and no material +from the series is used. KonoSuba (*Kono Subarashii Sekai ni Shukufuku wo!*) is by +Natsume Akatsuki and Kurone Mishima, © its rights holders; this project is not +affiliated with or endorsed by them. + ## Using the driver ```cpp diff --git a/components/el512/cyberpunk.cpp b/components/el512/cyberpunk.cpp index a627750..c934aa2 100644 --- a/components/el512/cyberpunk.cpp +++ b/components/el512/cyberpunk.cpp @@ -23,42 +23,11 @@ constexpr int W = 512, H = 256; // --- Small helpers ----------------------------------------------------------- -// Integer hash for repeatable pseudo-random effects (lowbias32). -uint32_t hash(uint32_t x) { - x ^= x >> 16; - x *= 0x7feb352d; - x ^= x >> 15; - x *= 0x846ca68b; - x ^= x >> 16; - return x; -} -uint32_t hash(uint32_t a, uint32_t b) { return hash(a * 0x9e3779b9u ^ hash(b)); } - -std::string str(const char *fmt, ...) __attribute__((format(printf, 1, 2))); -std::string str(const char *fmt, ...) { - char buf[96]; - va_list args; - va_start(args, fmt); - vsnprintf(buf, sizeof(buf), fmt, args); - va_end(args); - return buf; -} - -std::string upper(std::string s) { - for (auto &c : s) - c = (char) toupper((unsigned char) c); - return s; -} +using namespace fx; std::string temp_text(float v, const char *unit = "°") { return std::isnan(v) ? "--" : str("%.1f%s", v, unit); } std::string watts_text(float v) { return std::isnan(v) ? "--" : watts(v); } -int text_width(Display &it, display::BaseFont *font, const char *text) { - int x, y, w, h; - it.get_text_bounds(0, 0, text, font, TextAlign::TOP_LEFT, &x, &y, &w, &h); - return w; -} - const char *const CODES[] = {"1C", "55", "BD", "E9", "7A", "FF"}; constexpr int CODE_COUNT = 6; diff --git a/components/el512/cyberpunk.h b/components/el512/cyberpunk.h index c05ac0b..e39c81c 100644 --- a/components/el512/cyberpunk.h +++ b/components/el512/cyberpunk.h @@ -8,9 +8,7 @@ // page_age_ms(), so every page starts its intro when it is shown. #pragma once -#include -#include - +#include "el512_pages.h" #include "esphome/components/display/display.h" #include "esphome/core/time.h" @@ -20,6 +18,9 @@ class EL512Display; namespace cyber { +using el512::HomeData; +using el512::Reading; + struct Fonts { display::BaseFont *title{nullptr}; // wide display face for headers (Orbitron) display::BaseFont *label{nullptr}; // condensed labels (Rajdhani) @@ -29,23 +30,6 @@ struct Fonts { display::BaseFont *code{nullptr}; // larger monospace for breach codes (Share Tech Mono) }; -struct Reading { - std::string name; - float value{NAN}; -}; - -// Home Assistant values shown on the pages; NAN or "" when unknown. -struct HomeData { - std::string weather; // condition, e.g. "partlycloudy" - float temp{NAN}; // °C - float humidity{NAN}; // % - float wind{NAN}; // km/h - float solar{NAN}; // W - float house{NAN}; // W - float grid{NAN}; // W, > 0 = import - Reading rooms[4]; -}; - void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); void breach_protocol(EL512Display &it, const Fonts &f); void optics_scan(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); diff --git a/components/el512/el512_display.cpp b/components/el512/el512_display.cpp index 73a0bb6..6d67e29 100644 --- a/components/el512/el512_display.cpp +++ b/components/el512/el512_display.cpp @@ -148,6 +148,24 @@ void EL512Display::invert_rect(int x, int y, int width, int height) { this->panel_->setPixel(px, py, !this->panel_->getPixel(px, py)); } +void EL512Display::dither_mask(int level) { + if (this->panel_ == nullptr || level >= 16) + return; + static const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}}; + uint8_t masks[4]; + for (int r = 0; r < 4; r++) { + masks[r] = 0; + for (int x = 0; x < 8; x++) + if (BAYER[r][x & 3] < level) + masks[r] |= 0x80 >> x; + } + uint8_t *fb = this->panel_->framebuffer(); + constexpr int ROW = EL512Panel::WIDTH / 8; + for (int y = 0; y < EL512Panel::HEIGHT; y++) + for (int i = 0; i < ROW; i++) + fb[y * ROW + i] &= masks[y & 3]; +} + void EL512Display::update() { if (this->panel_ == nullptr || !this->panel_->running()) return; diff --git a/components/el512/el512_display.h b/components/el512/el512_display.h index f8b9494..2e41892 100644 --- a/components/el512/el512_display.h +++ b/components/el512/el512_display.h @@ -4,6 +4,7 @@ #include "cyberpunk.h" #include "el512_helpers.h" +#include "konosuba.h" #include "el512_panel.h" #include "esphome/components/display/display_buffer.h" #include "esphome/core/component.h" @@ -66,6 +67,9 @@ class EL512Display : public display::DisplayBuffer { // y..y+height-1 right by dx pixels (wrapping around), invert a rectangle. void shift_rows(int y, int height, int dx); void invert_rect(int x, int y, int width, int height); + // Keep only the pixels whose 4x4 Bayer threshold is below level (0..16): + // 0 clears everything, 16 keeps everything. Steps of it dissolve a page in. + void dither_mask(int level); protected: void draw_absolute_pixel_internal(int x, int y, Color color) override; diff --git a/components/el512/el512_pages.h b/components/el512/el512_pages.h new file mode 100644 index 0000000..8ff679d --- /dev/null +++ b/components/el512/el512_pages.h @@ -0,0 +1,72 @@ +// Shared pieces for the themed ESPHome pages (cyberpunk.cpp, konosuba.cpp): the +// Home Assistant values they show, and small drawing helpers. +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "esphome/components/display/display.h" + +namespace esphome::el512 { + +struct Reading { + std::string name; + float value{NAN}; +}; + +// Home Assistant values shown on the pages; NAN or "" when unknown. Filled +// from YAML (see the cp_home global in el512-panel.yaml). +struct HomeData { + std::string weather; // condition, e.g. "partlycloudy" + float temp{NAN}; // °C + float humidity{NAN}; // % + float wind{NAN}; // km/h + float solar{NAN}; // W + float solar_today{NAN}; // Wh produced today + float house{NAN}; // W + float grid{NAN}; // W, > 0 = import + std::string agenda; // sensor.el512_agenda events: "day|time|title;..." + Reading rooms[4]; +}; + +namespace fx { + +// Integer hash for repeatable pseudo-random effects (lowbias32). +inline uint32_t hash(uint32_t x) { + x ^= x >> 16; + x *= 0x7feb352d; + x ^= x >> 15; + x *= 0x846ca68b; + x ^= x >> 16; + return x; +} +inline uint32_t hash(uint32_t a, uint32_t b) { return hash(a * 0x9e3779b9u ^ hash(b)); } + +inline std::string str(const char *fmt, ...) __attribute__((format(printf, 1, 2))); +inline std::string str(const char *fmt, ...) { + char buf[128]; + va_list args; + va_start(args, fmt); + vsnprintf(buf, sizeof(buf), fmt, args); + va_end(args); + return buf; +} + +inline std::string upper(std::string s) { + for (auto &c : s) + c = (char) toupper((unsigned char) c); + return s; +} + +inline int text_width(display::Display &it, display::BaseFont *font, const char *text) { + int x, y, w, h; + it.get_text_bounds(0, 0, text, font, display::TextAlign::TOP_LEFT, &x, &y, &w, &h); + return w; +} + +} // namespace fx +} // namespace esphome::el512 diff --git a/components/el512/konosuba.cpp b/components/el512/konosuba.cpp new file mode 100644 index 0000000..301d506 --- /dev/null +++ b/components/el512/konosuba.cpp @@ -0,0 +1,729 @@ +#include "konosuba.h" + +#include +#include +#include +#include + +#include "el512_display.h" +#include "esphome/core/hal.h" +#include "konosuba_sprites.h" + +namespace esphome::el512::konosuba { + +using display::COLOR_OFF; +using display::COLOR_ON; +using display::Display; +using display::TextAlign; + +namespace { + +using namespace fx; + +constexpr int W = 512, H = 256; +constexpr float DEG = 3.14159265f / 180.0f; +const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}}; + +// --- Drawing helpers ----------------------------------------------------------- + +// Draw a sprite (optionally only its first `rows` rows), scaled up in blocks. +void sprite(Display &it, const Sprite &s, int x, int y, int scale = 1, int rows = -1, Color color = COLOR_ON) { + const int h = rows < 0 ? s.h : std::min(rows, s.h); + for (int r = 0; r < h; r++) { + for (int c = 0; c < s.w; c++) { + if (s.rows[r][c] != '#') + continue; + if (scale == 1) + it.draw_pixel_at(x + c, y + r, color); + else + it.filled_rectangle(x + c * scale, y + r * scale, scale, scale, color); + } + } +} + +// Point on a circle; angle in degrees, 0 = up, clockwise. +void polar(int cx, int cy, float r, float a, int *x, int *y) { + *x = cx + (int) std::lround(r * std::sin(a * DEG)); + *y = cy - (int) std::lround(r * std::cos(a * DEG)); +} + +void arc(Display &it, int cx, int cy, float r, float a0, float a1) { + const int steps = std::max(2, (int) (std::fabs(a1 - a0) / 5)); + int px, py; + polar(cx, cy, r, a0, &px, &py); + for (int i = 1; i <= steps; i++) { + int nx, ny; + polar(cx, cy, r, a0 + (a1 - a0) * i / steps, &nx, &ny); + it.line(px, py, nx, ny); + px = nx; + py = ny; + } +} + +// Four-point twinkle. +void sparkle(Display &it, int x, int y, int r) { + it.horizontal_line(x - r, y, 2 * r + 1); + it.vertical_line(x, y - r, 2 * r + 1); + if (r >= 3) { + it.draw_pixel_at(x - 1, y - 1); + it.draw_pixel_at(x + 1, y - 1); + it.draw_pixel_at(x - 1, y + 1); + it.draw_pixel_at(x + 1, y + 1); + } +} + +void diamond(Display &it, int cx, int cy, int r) { + it.filled_triangle(cx - r, cy, cx + r, cy, cx, cy - r); + it.filled_triangle(cx - r, cy, cx + r, cy, cx, cy + r); +} + +// Disc filled with an ordered dither, level 0 (empty) .. 16 (solid). +void dither_disc(Display &it, int cx, int cy, int r, int level) { + for (int dy = -r; dy <= r; dy++) { + const int span = (int) std::sqrt((float) (r * r - dy * dy)); + const int y = cy + dy; + for (int x = cx - span; x <= cx + span; x++) + if (BAYER[y & 3][x & 3] < level) + it.draw_pixel_at(x, y); + } +} + +// Double frame with diamond studs in the corners. +void ornate_frame(Display &it, int x, int y, int w, int h) { + it.rectangle(x, y, w, h); + it.rectangle(x + 3, y + 3, w - 6, h - 6); + diamond(it, x + 1, y + 1, 5); + diamond(it, x + w - 2, y + 1, 5); + diamond(it, x + 1, y + h - 2, 5); + diamond(it, x + w - 2, y + h - 2, 5); +} + +// JRPG window: rounded outer line, inner line, dark inside. +void rpg_window(Display &it, int x, int y, int w, int h) { + it.filled_rectangle(x, y, w, h, COLOR_OFF); + it.horizontal_line(x + 2, y, w - 4); + it.horizontal_line(x + 2, y + h - 1, w - 4); + it.vertical_line(x, y + 2, h - 4); + it.vertical_line(x + w - 1, y + 2, h - 4); + it.draw_pixel_at(x + 1, y + 1); + it.draw_pixel_at(x + w - 2, y + 1); + it.draw_pixel_at(x + 1, y + h - 2); + it.draw_pixel_at(x + w - 2, y + h - 2); + it.rectangle(x + 3, y + 3, w - 6, h - 6); +} + +// Paper note with a pin on top and a folded bottom-right corner. +void note(Display &it, int x, int y, int w, int h) { + constexpr int FOLD = 10; + it.filled_rectangle(x, y, w, h, COLOR_OFF); + it.horizontal_line(x, y, w); + it.vertical_line(x, y, h); + it.horizontal_line(x, y + h - 1, w - FOLD); + it.vertical_line(x + w - 1, y, h - FOLD); + it.line(x + w - FOLD, y + h - 1, x + w - 1, y + h - FOLD); + it.vertical_line(x + w - FOLD, y + h - FOLD, FOLD); + it.horizontal_line(x + w - FOLD, y + h - FOLD, FOLD); + it.filled_circle(x + w / 2, y + 2, 4); + it.draw_pixel_at(x + w / 2 - 1, y + 1, COLOR_OFF); +} + +// Horizontal bar, lit up to frac. +void bar(Display &it, int x, int y, int w, int h, float frac) { + it.rectangle(x, y, w, h); + if (!std::isnan(frac)) + it.filled_rectangle(x + 2, y + 2, (int) ((w - 4) * std::clamp(frac, 0.0f, 1.0f)), h - 4); +} + +// Greedy word wrap into at most max_lines lines of `width` pixels. +std::vector wrap(Display &it, display::BaseFont *font, const std::string &text, int width, + size_t max_lines) { + std::vector lines; + std::string line; + size_t i = 0; + while (i <= text.size()) { + size_t j = text.find(' ', i); + if (j == std::string::npos) + j = text.size(); + const std::string word = text.substr(i, j - i); + const std::string joined = line.empty() ? word : line + " " + word; + if (!line.empty() && text_width(it, font, joined.c_str()) > width) { + lines.push_back(line); + line = word; + } else { + line = joined; + } + i = j + 1; + } + if (!line.empty()) + lines.push_back(line); + if (lines.size() > max_lines) { + lines.resize(max_lines); + lines.back() += "..."; + } + for (auto &l : lines) + l = fit(font, l, width); + return lines; +} + +// The first part of `s` that has been typed at `cps` characters per second. +std::string typed(const std::string &s, uint32_t t, int cps) { return s.substr(0, std::min(s.size(), t * cps / 1000)); } + +// Dissolve the page in over its first 400 ms. +void intro(EL512Display &it, uint32_t t) { + if (t < 400) + it.dither_mask((int) (t * 16 / 400)); +} + +std::string watts_text(float w) { return std::isnan(w) ? "--" : watts(w); } + +std::string thousands(int n) { + std::string s = std::to_string(n); + for (int i = (int) s.size() - 3; i > 0; i -= 3) + s.insert(i, ","); + return s; +} + +// --- Explosion counter (shared by the Explosion page and the card) --------------- + +struct Counter { + uint32_t count{0}; + int day{-1}; + uint32_t key{0}; +}; +Counter counter; + +void roll_day(ESPTime now) { + if (now.is_valid() && now.day_of_year != counter.day) { + counter.day = now.day_of_year; + counter.count = 0; + } +} + +} // namespace + +uint32_t explosions_today() { return counter.count; } + +// --- 1. Megumin's daily explosion --------------------------------------------------- + +void explosion(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { + if (f.big == nullptr) + return; + const uint32_t t = it.page_age_ms(); + constexpr uint32_t CHANT = 1600, BLAST = 6600, AFTER = 8800, RATE = 13400, CYCLE = 17000; + constexpr int GROUND = 226, CX = 360, CY = 116, R = 90; // magic circle + constexpr int BX = 360, BY = 178; // where it hits + constexpr int MX = 20, MY = GROUND - 84; // Megumin, 2x + constexpr int SX = MX + 52, SY = MY - 14; // staff, 2x + constexpr int ORB_X = SX + 9, ORB_Y = SY + 7; + const uint32_t cycle = t / CYCLE, c = t % CYCLE; + roll_day(now); + if (c >= BLAST) { + const uint32_t key = hash(it.page_shown_at(), cycle); + if (key != counter.key) { + counter.key = key; + counter.count++; + } + } + const bool blasting = c >= BLAST && c < AFTER; + + // Night sky, moon, ground. + for (int i = 0; i < 46; i++) { + const uint32_t r = hash(i, 77); + const int x = (int) (r % W), y = 60 + (int) ((r >> 9) % 150); + if (x < 110 && y > MY - 20) + continue; + const uint32_t tw = (t / 170 + (r >> 20)) % 13; + if (tw == 0) + sparkle(it, x, y, 2); + else if (tw < 10) + it.draw_pixel_at(x, y, COLOR_ON); + } + it.filled_circle(488, 70, 12); + it.filled_circle(482, 66, 11, COLOR_OFF); + it.horizontal_line(0, GROUND, W); + for (int i = 0; i < 22; i++) { + const int x = (int) (hash(i, 5) % W); + it.line(x, GROUND + 4, x - 2, GROUND + 1); + it.line(x, GROUND + 4, x + 2, GROUND + 1); + } + + // Title and clock (hidden while the sky is on fire). + if (!blasting) { + it.print(8, 4, f.jp_big, TextAlign::TOP_LEFT, "爆裂魔法"); + it.print(10, 52, f.pixel, TextAlign::TOP_LEFT, "EXPLOSION MAGIC"); + if (now.is_valid()) + it.strftime(504, 6, f.pixel_l, TextAlign::TOP_RIGHT, "%H:%M", now); + } + + std::string say; + if (c < AFTER) { + // Megumin on her feet, staff raised. + const bool blink = (t % 2900) < 140; + sprite(it, blink ? MEGUMIN_BLINK : MEGUMIN, MX, MY, 2); + sprite(it, STAFF, SX, SY, 2); + } + + if (c < CHANT) { + say = typed("Time for today's explosion!", c, 30); + } else if (c < BLAST) { + // The magic circle draws itself, ring by ring. + const uint32_t e = c - CHANT; + auto progress = [e](uint32_t from, uint32_t dur) { + return e <= from ? 0.0f : std::min(1.0f, (float) (e - from) / dur); + }; + const float rot = e / 22.0f; + if (progress(0, 1300) > 0) + arc(it, CX, CY, R, rot, rot + 360 * progress(0, 1300)); + if (progress(350, 1300) > 0) + arc(it, CX, CY, R - 14, -rot, -rot - 360 * progress(350, 1300)); + if (progress(1100, 1100) > 0) + arc(it, CX, CY, 48, rot * 2, rot * 2 + 360 * progress(1100, 1100)); + // Hexagram, one line after another. + const float hex = progress(1900, 1500) * 6; + for (int i = 0; i < 6 && hex > i; i++) { + const float a0 = (i % 2) * 60 + (i / 2) * 120 - rot, a1 = a0 + 120; + int x0, y0, x1, y1; + polar(CX, CY, 48, a0, &x0, &y0); + polar(CX, CY, 48, a1, &x1, &y1); + const float k = std::min(1.0f, hex - i); + it.line(x0, y0, x0 + (int) ((x1 - x0) * k), y0 + (int) ((y1 - y0) * k)); + } + // Runes between the outer rings. + if (e > 2400) { + const int shown = std::min(24, (e - 2400) / 40); + for (int i = 0; i < shown; i++) { + int x, y; + polar(CX, CY, R - 7, i * 15 + rot, &x, &y); + switch (hash(i, 3) % 3) { + case 0: + diamond(it, x, y, 2); + break; + case 1: + it.filled_rectangle(x - 1, y - 2, 2, 5); + break; + default: + it.draw_pixel_at(x - 2, y, COLOR_ON); + it.draw_pixel_at(x + 2, y, COLOR_ON); + it.draw_pixel_at(x, y - 2, COLOR_ON); + } + } + } + if (e > 3400) + sparkle(it, CX, CY, 3 + (int) ((e / 90) % 4)); + // Mana streams into the staff. + for (int i = 0; i < 12; i++) { + const uint32_t r = hash(i, 9); + const float k = (float) ((e + i * 173) % 900) / 900.0f; + const int x0 = (int) (r % 220), y0 = 70 + (int) ((r >> 10) % 140); + it.filled_rectangle(x0 + (int) ((ORB_X - x0) * k), y0 + (int) ((ORB_Y - y0) * k), 2, 2); + } + sparkle(it, ORB_X, ORB_Y, 4 + (int) ((e / 120) % 4)); + const char *chant[] = {"Crimson flame asleep within my staff...", "drink every last drop of my mana...", + "and paint the far hills into a crater!"}; + const int line = std::min(2, e / 1650); + say = typed(chant[line], e - line * 1650, 34); + } else if (c < AFTER) { + const uint32_t b = c - BLAST; + // Shockwave rings, fireball, mushroom cloud. + const int ring = (int) (b * 0.45f); + it.circle(BX, BY, ring); + it.circle(BX, BY, ring + 1); + if (ring > 30) + it.circle(BX, BY, ring - 26); + if (b < 700) { + it.filled_circle(BX, BY, std::min(84, 30 + b / 6)); + } else { + const uint32_t m = b - 700; + const int rise = std::min(70, m / 14); + dither_disc(it, BX, BY - rise, 70 - rise / 3, std::max(2, 16 - (int) (m / 110))); + it.filled_rectangle(BX - 14, BY - rise, 28, GROUND - (BY - rise), COLOR_ON); + dither_disc(it, BX, GROUND - 6, 60 - rise / 2, 10); + } + // EXPLOSION! over a dark band. + it.filled_rectangle(70, 2, 372, 52, COLOR_OFF); + it.print(256, 4, f.big, TextAlign::TOP_CENTER, "EXPLOSION!"); + if (b < 130) + it.invert_rect(0, 0, W, H); // the flash + if (b < 1500) { + const int amp = std::max(1, 7 - (int) (b / 220)); + it.shift_rows(0, H, ((b / 45) & 1) ? amp : -amp); + } + } else { + // Aftermath: smoke drifting off, crater, Megumin flat on the ground. + const uint32_t a = c - AFTER; + for (int i = 0; i < 3; i++) { + const int lvl = std::max(0, 9 - (int) (a / 500) - i); + if (lvl > 0) + dither_disc(it, BX - 50 + i * 50, BY - 30 - (int) (a / 120) - i * 12, 34 - i * 4, lvl); + } + it.horizontal_line(BX - 70, GROUND, 140, COLOR_OFF); + arc(it, BX, GROUND - 40, 78, 120, 240); + sprite(it, MEGUMIN_DOWN, MX - 8, GROUND - 24, 2); + if ((t / 400) % 2) + it.print(MX + 40, GROUND - 46, f.pixel, TextAlign::TOP_LEFT, "MP 0"); + if (c < RATE) { + say = typed("...I can't move. Somebody carry me home.", a, 26); + } else { + // Kazuma scores the explosion, as he does. + static const char *const COMMENTS[] = {"Crisp shockwave, great form.", "That smoke ring was pure art.", + "The ground shook just right.", "A little early, still lovely.", + "Best one this week!"}; + const uint32_t r = hash(cycle, it.page_shown_at()); + rpg_window(it, 150, 70, 250, 104); + it.print(275, 84, f.pixel, TextAlign::TOP_CENTER, "KAZUMA'S RATING"); + it.printf(275, 104, f.pixel_l, TextAlign::TOP_CENTER, "%u POINTS", (unsigned) (80 + r % 21)); + it.print(275, 136, f.text, TextAlign::TOP_CENTER, COMMENTS[(r >> 8) % 5]); + } + } + + // Speech line and the day's tally. + if (!say.empty()) + it.print(110, 242, f.text, TextAlign::CENTER_LEFT, say.c_str()); + if (c >= AFTER) + it.printf(504, 248, f.pixel, TextAlign::BASELINE_RIGHT, "TODAY: %u", (unsigned) counter.count); + intro(it, t); +} + +// --- 2. Adventurers' Guild quest board --------------------------------------------- + +void guild_board(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { + if (f.title == nullptr) + return; + const uint32_t t = it.page_age_ms(); + ornate_frame(it, 0, 0, W, H); + it.print(16, 9, f.title, TextAlign::TOP_LEFT, "ADVENTURERS' GUILD"); + it.print(496, 8, f.jp, TextAlign::TOP_RIGHT, "冒険者ギルド"); + it.horizontal_line(6, 38, W - 12); + it.horizontal_line(6, 40, W - 12); + + // Quests: the next agenda events, topped up with standing guild requests. + struct Quest { + std::string title, when; + bool from_agenda, today; + }; + std::vector quests; + const std::string today = now.is_valid() ? now.strftime("%a %d") : ""; + for (const auto &e : split(d.agenda, ';')) { + if (quests.size() == 3) + break; + const std::string day = field(e, 0), time = field(e, 1); + quests.push_back({field(e, 2), upper(day) + " " + (time.empty() ? "ALL DAY" : time), true, day == today}); + } + static const char *const STANDING[][2] = {{"Exterminate five giant toads", "BEFORE DUSK"}, + {"Escort a merchant cart to the next town", "THIS WEEK"}, + {"Bring in the cabbage harvest before it flies off", "SEASONAL"}}; + for (int i = 0; quests.size() < 3; i++) + quests.push_back({STANDING[i][0], STANDING[i][1], false, false}); + + for (int i = 0; i < 3; i++) { + const int dt = (int) t - i * 140; + if (dt < 0) + continue; + const int x = 10 + i * 124, y = 48 - (dt < 300 ? (300 - dt) * 40 / 300 : 0); + const Quest &q = quests[i]; + const uint32_t r = hash(std::hash{}(q.title)); + note(it, x, y, 118, 198); + it.print(x + 59, y + 12, f.pixel, TextAlign::TOP_CENTER, "QUEST"); + const int rank = 1 + (int) (r % 5); + for (int s = 0; s < rank; s++) + sparkle(it, x + 59 - (rank - 1) * 6 + s * 12, y + 30, 3); + int ly = y + 40; + for (const auto &l : wrap(it, f.text, q.title, 104, 5)) { + it.print(x + 7, ly, f.text, TextAlign::TOP_LEFT, l.c_str()); + ly += 18; + } + it.horizontal_line(x + 6, y + 136, 106); + it.print(x + 7, y + 142, f.pixel, TextAlign::TOP_LEFT, "DEADLINE"); + it.print(x + 7, y + 154, f.pixel, TextAlign::TOP_LEFT, fit(f.pixel, q.when, 104).c_str()); + it.print(x + 7, y + 168, f.pixel, TextAlign::TOP_LEFT, "REWARD"); + it.printf(x + 7, y + 180, f.pixel, TextAlign::TOP_LEFT, "%s ERIS", + thousands((1 + (int) ((r >> 8) % 40)) * 5000).c_str()); + if (q.today && (t / 500) % 2) { + it.filled_rectangle(x + 76, y + 6, 38, 14); + it.print(x + 95, y + 13, f.pixel, COLOR_OFF, TextAlign::CENTER, "NEW!"); + } + } + + // Hazard notice (weather) and the cabbage bounty (solar). + note(it, 386, 48, 116, 94); + it.print(444, 60, f.pixel, TextAlign::TOP_CENTER, "HAZARD"); + it.print(416, 102, f.weather, TextAlign::CENTER, weather_icon(d.weather)); + it.print(492, 94, f.text, TextAlign::BASELINE_RIGHT, std::isnan(d.temp) ? "--" : str("%.0f°", d.temp).c_str()); + it.print(444, 128, f.pixel, TextAlign::TOP_CENTER, fit(f.pixel, upper(weather_text(d.weather)), 100).c_str()); + + note(it, 386, 150, 116, 94); + it.print(444, 162, f.pixel, TextAlign::TOP_CENTER, "BOUNTY"); + it.print(444, 176, f.text, TextAlign::TOP_CENTER, "Cabbage harvest"); + it.print(444, 198, f.text, TextAlign::TOP_CENTER, + std::isnan(d.solar_today) ? "--" : str("%.1f kWh", d.solar_today / 1000.0f).c_str()); + it.printf(444, 222, f.pixel, TextAlign::TOP_CENTER, "NOW %s", upper(watts_text(d.solar)).c_str()); + + // Cabbages escaping across the board. + for (int i = 0; i < 3; i++) { + const int x = (int) ((t / 14 + i * 233) % 760) - 60; + const int y = 60 + i * 62 + (int) (12 * std::sin(t / 260.0f + i * 2)); + const Sprite &s = ((t / 140 + i) % 2) ? CABBAGE_A : CABBAGE_B; + it.filled_rectangle(x - 1, y - 1, s.w * 2 + 2, s.h * 2 + 2, COLOR_OFF); + sprite(it, s, x, y, 2); + } + intro(it, t); +} + +// --- 3. Megumin's adventurer card ---------------------------------------------------- + +void adventurer_card(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { + if (f.title == nullptr) + return; + const uint32_t t = it.page_age_ms(); + roll_day(now); + const int X = 14, Y = 6 + (t < 380 ? (int) (380 - t) * 140 / 380 : 0), CW = 484, CH = 244; + + // Card with cut corners and a header band. + constexpr int CUT = 12; + it.line(X + CUT, Y, X + CW - 1 - CUT, Y); + it.line(X + CW - 1 - CUT, Y, X + CW - 1, Y + CUT); + it.line(X + CW - 1, Y + CUT, X + CW - 1, Y + CH - 1 - CUT); + it.line(X + CW - 1, Y + CH - 1 - CUT, X + CW - 1 - CUT, Y + CH - 1); + it.line(X + CW - 1 - CUT, Y + CH - 1, X + CUT, Y + CH - 1); + it.line(X + CUT, Y + CH - 1, X, Y + CH - 1 - CUT); + it.line(X, Y + CH - 1 - CUT, X, Y + CUT); + it.line(X, Y + CUT, X + CUT, Y); + it.filled_rectangle(X + 8, Y + 6, CW - 16, 30); + it.print(X + 18, Y + 21, f.title, COLOR_OFF, TextAlign::CENTER_LEFT, "ADVENTURER CARD"); + it.print(X + CW - 18, Y + 20, f.jp, COLOR_OFF, TextAlign::CENTER_RIGHT, "冒険者カード"); + + // Portrait. + it.rectangle(X + 14, Y + 44, 98, 94); + it.rectangle(X + 16, Y + 46, 94, 90); + const bool blink = (t % 3300) < 150; + sprite(it, blink ? MEGUMIN_BLINK : MEGUMIN, X + 21, Y + 52, 3, 27); + it.print(X + 63, Y + 146, f.pixel, TextAlign::TOP_CENTER, "NO. 0429"); + it.print(X + 63, Y + 176, f.pixel, TextAlign::TOP_CENTER, "EXPLOSIONS"); + it.print(X + 63, Y + 188, f.pixel, TextAlign::TOP_CENTER, "TODAY"); + it.printf(X + 63, Y + 204, f.pixel_l, TextAlign::TOP_CENTER, "%u", (unsigned) counter.count); + + // Identity. + const int x0 = X + 130, xv = X + 210; + const int level = 10 + (std::isnan(d.solar_today) ? 0 : (int) (d.solar_today / 1000.0f)); + it.print(x0, Y + 50, f.pixel, TextAlign::TOP_LEFT, "NAME"); + it.print(xv, Y + 46, f.pixel_l, TextAlign::TOP_LEFT, "MEGUMIN"); + it.print(x0, Y + 74, f.pixel, TextAlign::TOP_LEFT, "CLASS"); + it.print(xv, Y + 70, f.pixel_l, TextAlign::TOP_LEFT, "ARCH WIZARD"); + it.print(x0, Y + 98, f.pixel, TextAlign::TOP_LEFT, "LEVEL"); + it.printf(xv, Y + 94, f.pixel_l, TextAlign::TOP_LEFT, "%d", level); + it.print(x0, Y + 118, f.pixel, TextAlign::TOP_LEFT, "SKILL"); + it.print(xv, Y + 118, f.pixel, TextAlign::TOP_LEFT, "EXPLOSION LV.MAX"); + + // Stats: mana is the sun, stamina what the house leaves over. + const int luck = now.is_valid() ? 1 + (int) (hash(now.day_of_year, now.year) % 99) : 50; + const float load = std::isnan(d.house) ? NAN : 1.0f - d.house / 5000.0f; + struct Stat { + const char *name; + float frac; + std::string value; + }; + const Stat stats[] = { + {"MANA", d.solar / 5000.0f, upper(watts_text(d.solar))}, + {"STAMINA", load, std::isnan(load) ? "--" : str("%d%%", (int) (std::clamp(load, 0.0f, 1.0f) * 100))}, + {"INT", 0.99f, "99"}, + {"LUCK", luck / 100.0f, str("%d", luck)}, + {"STR", 0.03f, "3"}, + }; + for (int i = 0; i < 5; i++) { + const int y = Y + 140 + i * 19; + it.print(x0, y, f.pixel, TextAlign::TOP_LEFT, stats[i].name); + bar(it, x0 + 70, y - 1, 200, 10, stats[i].frac); + it.print(X + CW - 16, y, f.pixel, TextAlign::TOP_RIGHT, stats[i].value.c_str()); + } + + // A glint sweeps over the card now and then; corners twinkle. + const uint32_t s = t % 5200; + if (s < 800 && t > 600) { + const int bx = X - 130 + (int) (s * (CW + 260) / 800); + for (int y = Y + 1; y < Y + CH - 1; y++) { + const int x = std::max(X + 1, bx - (y - Y) / 2); + const int x1 = std::min(X + CW - 1, bx - (y - Y) / 2 + 12); + if (x1 > x) + it.invert_rect(x, y, x1 - x, 1); + } + } + const int tw = (int) ((t / 220) % 12); + if (tw < 3) + sparkle(it, X + CW - 30, Y + 48, 2 + tw); + else if (tw >= 6 && tw < 9) + sparkle(it, X + 120, Y + CH - 14, tw - 4); +} + +// --- 4. Party battle against a giant toad -------------------------------------------- + +void party_battle(EL512Display &it, const Fonts &f, const HomeData &d) { + if (f.pixel == nullptr) + return; + const uint32_t t = it.page_age_ms(); + constexpr uint32_t TURN = 4200; + constexpr int TURNS = 5, GROUND = 132; + const uint32_t round = t / (TURN * TURNS); + const int turn = (int) ((t / TURN) % TURNS); + const uint32_t tt = t % TURN; + enum { KAZUMA_STEALS, AQUA_PURIFIES, TOAD_LICKS, TOAD_SWALLOWS, MEGUMIN_CASTS }; + + // Scenery. + it.print(8, 6, f.jp, TextAlign::TOP_LEFT, "戦闘"); + it.printf(56, 12, f.pixel, TextAlign::TOP_LEFT, "ROUND %u", (unsigned) (round + 1)); + arc(it, 110, GROUND + 70, 110, -52, 52); + arc(it, 300, GROUND + 90, 120, -40, 40); + it.horizontal_line(0, GROUND, W); + for (int i = 0; i < 18; i++) { + const int x = (int) (hash(i, 21) % W); + it.line(x, GROUND + 4, x - 2, GROUND + 1); + it.line(x, GROUND + 4, x + 2, GROUND + 1); + } + + // Who has fallen or been eaten at this moment. + const bool toad_down = turn == MEGUMIN_CASTS && tt > 1700; + const bool aqua_eaten = (turn == TOAD_SWALLOWS && tt > 1100) || (turn == MEGUMIN_CASTS && !toad_down); + const bool megumin_down = turn == MEGUMIN_CASTS && tt > 2300; + + // The party. + const Sprite *party[4] = {&KAZUMA, &AQUA, &MEGUMIN_S, &DARKNESS}; + const int actor = turn == KAZUMA_STEALS ? 0 : turn == AQUA_PURIFIES ? 1 : turn == MEGUMIN_CASTS ? 2 : -1; + for (int i = 0; i < 4; i++) { + int x = 22 + i * 46; + const Sprite &s = *party[i]; + if (i == 1 && aqua_eaten) + continue; + if (i == 3 && turn == TOAD_LICKS && tt > 1100 && tt < 2000) + x += ((tt / 60) & 1) ? 3 : -3; // Darkness takes the hit + if (i == 2 && megumin_down) { + sprite(it, MEGUMIN_DOWN, x - 16, GROUND - 12); + continue; + } + sprite(it, s, x, GROUND - s.h * 2, 2); + if (i == actor && (t / 300) % 2) + it.filled_triangle(x + 10, GROUND - s.h * 2 - 12, x + 22, GROUND - s.h * 2 - 12, x + 16, GROUND - s.h * 2 - 6); + } + + // The toad. + constexpr int TX = 336, TS = 3; + const int ty = GROUND - TOAD.h * TS - ((t / 450) % 2 ? 2 : 0); + const int target_x = turn == TOAD_LICKS ? 22 + 3 * 46 + 30 : 22 + 46 + 30; + const bool tongue = (turn == TOAD_LICKS && tt > 800 && tt < 1900) || (turn == TOAD_SWALLOWS && tt > 600 && tt < 1100); + if (!toad_down) { + sprite(it, tongue ? TOAD_TONGUE : TOAD, TX, ty, TS); + if (tongue) { + const int mx = TX + 5 * TS, my = ty + 12 * TS; + it.line(mx, my, target_x, GROUND - 20); + it.line(mx, my + 1, target_x, GROUND - 19); + it.filled_circle(target_x, GROUND - 20, 4); + } + float hp = turn == KAZUMA_STEALS && tt < 1200 ? 1.0f : 0.92f; + if (turn == MEGUMIN_CASTS && tt > 1300) + hp = 0.0f; + it.print(TX, 30, f.pixel, TextAlign::TOP_LEFT, "GIANT TOAD"); + bar(it, TX, 42, 96, 8, hp); + } else if (tt < 2600) { + dither_disc(it, TX + 48, GROUND - 26, 40, std::max(1, 14 - (int) ((tt - 1700) / 70))); + } + + // Turn effects. + if (turn == KAZUMA_STEALS && tt > 900 && tt < 1800) { + const float k = (tt - 900) / 900.0f; + const int x = TX + 20 + (int) ((22 + 16 - TX - 20) * k), y = GROUND - 60 - (int) (30 * std::sin(k * 3.14159f)); + it.filled_rectangle(x - 3, y - 3, 7, 7); + sparkle(it, x, y, 5); + } + if (turn == AQUA_PURIFIES && tt > 700 && tt < 2600) { + for (int i = 0; i < 7; i++) { + const int x = 22 + 46 + (int) (hash(i, 31) % 34); + const int y = GROUND - 10 - (int) (((tt - 700) / 12 + i * 23) % 60); + sparkle(it, x, y, 1 + (int) ((tt / 100 + i) % 3)); + } + } + if (turn == MEGUMIN_CASTS) { + if (tt > 300 && tt < 1300) { + const float rot = tt / 6.0f; + arc(it, TX + 48, 60, 34, rot, rot + 360 * std::min(1.0f, (tt - 300) / 500.0f)); + if (tt > 700) + arc(it, TX + 48, 60, 22, -rot, -rot + 360); + } + if (tt >= 1300 && tt < 1800) + it.filled_circle(TX + 48, GROUND - 30, 30 + (int) (tt - 1300) / 6); + if (tt >= 1300 && tt < 2000) + it.circle(TX + 48, GROUND - 30, (int) (tt - 1300) / 2); + if (tt > 2600 && (t / 250) % 2) + it.print(TX + 48, 70, f.pixel_l, TextAlign::TOP_CENTER, "VICTORY!"); + } + + // Party status window. + rpg_window(it, 0, 150, 300, 106); + struct Member { + const char *name; + float hp, mp; + }; + const bool cast = turn == MEGUMIN_CASTS && tt > 1300; + const bool licked = turn >= TOAD_LICKS && !(turn == TOAD_LICKS && tt < 1100); + const Member members[4] = { + {"KAZUMA", 0.8f, turn == KAZUMA_STEALS && tt < 900 ? 0.5f : 0.4f}, + {"AQUA", aqua_eaten || (turn == MEGUMIN_CASTS) ? 0.55f : 1.0f, turn >= AQUA_PURIFIES ? 0.85f : 1.0f}, + {"MEGUMIN", 0.7f, cast ? 0.0f : 1.0f}, + {"DARKNESS", licked ? 0.9f : 1.0f, 0.0f}, + }; + for (int i = 0; i < 4; i++) { + const int y = 160 + i * 23; + if (i == actor) + it.filled_triangle(9, y, 9, y + 8, 14, y + 4); + it.print(18, y, f.pixel, TextAlign::TOP_LEFT, members[i].name); + it.print(96, y, f.pixel, TextAlign::TOP_LEFT, "HP"); + bar(it, 116, y - 1, 64, 10, members[i].hp); + it.print(190, y, f.pixel, TextAlign::TOP_LEFT, "MP"); + bar(it, 210, y - 1, 78, 10, members[i].mp); + } + + // Battle log, typed out. + rpg_window(it, 304, 150, 208, 106); + std::string msg; + switch (turn) { + case KAZUMA_STEALS: + msg = std::isnan(d.grid) ? "Kazuma used STEAL! He got... a pair of socks." + : d.grid >= 0 ? "Kazuma used STEAL! He took " + watts(d.grid) + " from the grid." + : "Kazuma used STEAL! Wait, he gave " + watts(-d.grid) + " back to the grid?"; + break; + case AQUA_PURIFIES: { + const Reading &room = d.rooms[0]; + msg = std::isnan(room.value) + ? "Aqua used PURIFICATION! ...It had no effect." + : str("Aqua used PURIFICATION on the %s. It is now %.1fC. Not her doing.", room.name.c_str(), + room.value); + break; + } + case TOAD_LICKS: + msg = "The toad's tongue lashed out! Darkness took " + watts_text(d.house) + + " of house load and looks... delighted?"; + break; + case TOAD_SWALLOWS: + msg = "The giant toad swallowed Aqua whole! Muffled crying can be heard."; + break; + default: + if (tt < 2300) { + const int dmg = std::isnan(d.solar) || d.solar < 1 ? 9999 : (int) d.solar; + msg = "Megumin cast EXPLOSION! " + thousands(dmg) + " damage from the sun!"; + } else { + msg = "VICTORY! The toad is gone. Megumin can't move anymore."; + } + } + const uint32_t since = turn == MEGUMIN_CASTS && tt >= 2300 ? tt - 2300 : tt; + int ly = 162; + for (const auto &l : wrap(it, f.pixel, typed(msg, since, 40), 186, 7)) { + it.print(314, ly, f.pixel, TextAlign::TOP_LEFT, l.c_str()); + ly += 12; + } + + // Explosion flash and shake. + if (turn == MEGUMIN_CASTS && tt >= 1300 && tt < 1420) + it.invert_rect(0, 0, W, 150); + if (turn == MEGUMIN_CASTS && tt >= 1300 && tt < 2100) + it.shift_rows(0, 148, ((tt / 45) & 1) ? 4 : -4); + intro(it, t); +} + +} // namespace esphome::el512::konosuba diff --git a/components/el512/konosuba.h b/components/el512/konosuba.h new file mode 100644 index 0000000..ec22aa0 --- /dev/null +++ b/components/el512/konosuba.h @@ -0,0 +1,41 @@ +// KonoSuba-themed animated pages for the ESPHome display: Megumin's daily +// explosion, the Adventurers' Guild quest board, Megumin's adventurer card and +// a party battle against a giant toad. Fan art: the pixel art +// (konosuba_sprites.h, from tools/konosuba_sprites.py) and all texts are +// original; no material from the series is used. +// +// Each function draws a whole page. Call it from a page lambda and list that +// page under animated_pages; the animation clock is the display's page_age_ms(). +#pragma once + +#include "el512_pages.h" +#include "esphome/components/display/display.h" +#include "esphome/core/time.h" + +namespace esphome::el512 { + +class EL512Display; + +namespace konosuba { + +struct Fonts { + display::BaseFont *title{nullptr}; // fantasy serif headers (Cinzel) + display::BaseFont *big{nullptr}; // "EXPLOSION!" (Cinzel Decorative) + display::BaseFont *text{nullptr}; // rounded body text (Nunito) + display::BaseFont *pixel{nullptr}; // 8 px pixel font (Press Start 2P) + display::BaseFont *pixel_l{nullptr}; // 16 px pixel font + display::BaseFont *jp{nullptr}; // a few Japanese words (Noto Sans JP) + display::BaseFont *jp_big{nullptr}; // 爆裂魔法 (Dela Gothic One) + display::BaseFont *weather{nullptr}; // weather icons (Material Design Icons) +}; + +void explosion(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); +void guild_board(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); +void adventurer_card(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); +void party_battle(EL512Display &it, const Fonts &f, const HomeData &d); + +// Explosions cast on the Explosion page since midnight. +uint32_t explosions_today(); + +} // namespace konosuba +} // namespace esphome::el512 diff --git a/components/el512/konosuba_sprites.h b/components/el512/konosuba_sprites.h new file mode 100644 index 0000000..b792765 --- /dev/null +++ b/components/el512/konosuba_sprites.h @@ -0,0 +1,331 @@ +// Pixel art for the KonoSuba pages, generated by tools/konosuba_sprites.py; +// edit the art there. Original 1-bit fan art, '#' = lit pixel. +#pragma once + +#include + +namespace esphome::el512::konosuba { + +struct Sprite { + uint8_t w, h; + const char *const *rows; +}; + +static const char *const MEGUMIN_ROWS[] = { + "...............####.........", + "..............######........", + ".............###...##.......", + "............####.....#......", + "...........#####............", + "..........######............", + "..........#######...........", + ".........########...........", + ".........#########..........", + "........##########..........", + "........#.........#.........", + "........###########.........", + "##########################..", + ".########################...", + ".....###############........", + ".....##.#.##..#.#.##........", + ".....#.............#........", + ".....#..##....####.#........", + ".....#..##....####.#........", + ".....#.........#...#........", + ".....##.............#.......", + "......#.....##.....#........", + ".....###..........###.......", + "....####.##....##.####......", + "...######.######.######.....", + "..#######..####..#######....", + "..######...####...#######...", + ".#######...#..#...#######...", + ".######....#..#....######...", + ".######...#....#...######...", + "#######...#....#...#######..", + "######...#......#...######..", + "######..##########..######..", + "#####...#........#...#####..", + "#####..#..........#..#####..", + "####...############...####..", + "........#......#............", + "........#......#............", + "........#......#............", + "........#......#............", + ".......###....###...........", + ".......####...####..........", +}; +static constexpr Sprite MEGUMIN{28, 42, MEGUMIN_ROWS}; + +static const char *const STAFF_ROWS[] = { + "..#####..", + ".#.....#.", + "#..###..#", + "#.#####.#", + "#.#####.#", + "#..###..#", + ".#.....#.", + "..#...#..", + "...#.#...", + "....#....", + "....#....", + "...##....", + "....#....", + "....#....", + "....#....", + "....##...", + "....#....", + "....#....", + "....#....", + "....#....", + "...##....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....##...", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", +}; +static constexpr Sprite STAFF{9, 42, STAFF_ROWS}; + +static const char *const MEGUMIN_DOWN_ROWS[] = { + "....###......................................................", + "...#####.....................................................", + "..######.....................................................", + "..#######....................................................", + ".########.##.................................................", + ".#########..##.........#######...............................", + "###########...##.....###########.............................", + "#.........#.....#...##.#.#.#.#.##..#####################.....", + "###########......#..#.#.#.#.#.#.#.########################...", + "...................##############.###########.##########.###.", + "....................############..#####...........######...##", + "......................########....####.............####......", +}; +static constexpr Sprite MEGUMIN_DOWN{61, 12, MEGUMIN_DOWN_ROWS}; + +static const char *const KAZUMA_ROWS[] = { + "....######......", + "...########.....", + "..##########....", + "..#.#.##.#.#....", + "..#........#....", + "..#..#..#..#....", + "..#........#....", + "...#..##..#.....", + "....######......", + "..##########....", + ".###.####.###...", + ".##..#..#..##...", + ".##..#..#..##.#.", + ".##..####..##.#.", + ".##..#..#..##.#.", + "..#..#..#..#.###", + "....#....#....#.", + "....#....#......", + "...##....##.....", +}; +static constexpr Sprite KAZUMA{16, 19, KAZUMA_ROWS}; + +static const char *const AQUA_ROWS[] = { + ".....####.......", + "....#....#......", + ".....####.......", + "....######......", + "...########.....", + "..##########....", + "..#........#....", + "..#..#..#..#....", + "..#........#....", + "..##..##..##....", + "..###....###....", + ".####.##.####...", + ".##..####..##...", + ".##.#....#.##...", + ".#.#......#.#...", + ".#.#......#.#...", + "...########.....", + "....#....#......", + "....#....#......", + "...##....##.....", +}; +static constexpr Sprite AQUA{16, 20, AQUA_ROWS}; + +static const char *const MEGUMIN_S_ROWS[] = { + ".......##.......", + "......####......", + ".....######.....", + "....########....", + "....#......#....", + "################", + "...##########...", + "...#........#...", + "...#.#...##.#...", + "...#........#...", + "....#..##..#....", + ".....######.....", + "...##########...", + "..############..", + "..###.####.###..", + "..##..#..#..##..", + "..##..####..##..", + "..############..", + ".....#....#.....", + ".....#....#.....", + "....##....##....", +}; +static constexpr Sprite MEGUMIN_S{16, 21, MEGUMIN_S_ROWS}; + +static const char *const DARKNESS_ROWS[] = { + "....######......", + "...########.###.", + "..##########.##.", + "..#.##..##.#..#.", + "..#........#....", + "..#..#..#..#....", + "..#........#....", + "...#..##..#.....", + "....######......", + "############....", + "##.########.##..", + "#..#.#..#.#..#..", + "...#.####.#.....", + "...#.#..#.#.....", + "...#.####.#.....", + "...########.....", + "....#....#......", + "....##...##.....", + "...###...###....", +}; +static constexpr Sprite DARKNESS{16, 19, DARKNESS_ROWS}; + +static const char *const TOAD_ROWS[] = { + "......####............####......", + ".....#....#..........#....#.....", + "....#..##..#........#..##..#....", + "....#..##..##########..##..#....", + "...##.....#..........#.....##...", + "..#..........................#..", + ".#....#..........#.......#....#.", + "#..............................#", + "#.....##....#.........##...#...#", + "#..............................#", + "#..####################........#", + "#......................#.......#", + ".#........#.......#...........#.", + "..#..##..................##..#..", + "...##..#................#..##...", + "..#.....#..............#.....#..", + ".#..###..#............#..###..#.", + "##########............##########", +}; +static constexpr Sprite TOAD{32, 18, TOAD_ROWS}; + +static const char *const TOAD_TONGUE_ROWS[] = { + "......####............####......", + ".....#....#..........#....#.....", + "....#..##..#........#..##..#....", + "....#..##..##########..##..#....", + "...##.....#..........#.....##...", + "..#..........................#..", + ".#....#..........#.......#....#.", + "#..............................#", + "#.....##....#.........##...#...#", + "#..............................#", + "#..####################........#", + "#...#..................#.......#", + ".#...#....#.......#...........#.", + "..#...#..##..............##..#..", + "...##..#.##.............#..##...", + "..#.....####...........#.....#..", + ".#..###..##...........#..###..#.", + "##########............##########", +}; +static constexpr Sprite TOAD_TONGUE{32, 18, TOAD_TONGUE_ROWS}; + +static const char *const CABBAGE_A_ROWS[] = { + "#..............#", + "##...######...##", + ".##.#..#...#.##.", + "..##.#.#..#.##..", + "...#..###...#...", + "...#.#.#.#..#...", + "....#..#...#....", + ".....######.....", +}; +static constexpr Sprite CABBAGE_A{16, 8, CABBAGE_A_ROWS}; + +static const char *const CABBAGE_B_ROWS[] = { + "................", + ".....######.....", + "....#..#...#....", + "...#.#.#..#.#...", + "####..###...####", + "##.#.#.#.#..#.##", + "#...#..#...#...#", + ".....######.....", +}; +static constexpr Sprite CABBAGE_B{16, 8, CABBAGE_B_ROWS}; + +static const char *const MEGUMIN_BLINK_ROWS[] = { + "...............####.........", + "..............######........", + ".............###...##.......", + "............####.....#......", + "...........#####............", + "..........######............", + "..........#######...........", + ".........########...........", + ".........#########..........", + "........##########..........", + "........#.........#.........", + "........###########.........", + "##########################..", + ".########################...", + ".....###############........", + ".....##.#.##..#.#.##........", + ".....#.............#........", + ".....#........####.#........", + ".....#..##....####.#........", + ".....#.........#...#........", + ".....##.............#.......", + "......#.....##.....#........", + ".....###..........###.......", + "....####.##....##.####......", + "...######.######.######.....", + "..#######..####..#######....", + "..######...####...#######...", + ".#######...#..#...#######...", + ".######....#..#....######...", + ".######...#....#...######...", + "#######...#....#...#######..", + "######...#......#...######..", + "######..##########..######..", + "#####...#........#...#####..", + "#####..#..........#..#####..", + "####...############...####..", + "........#......#............", + "........#......#............", + "........#......#............", + "........#......#............", + ".......###....###...........", + ".......####...####..........", +}; +static constexpr Sprite MEGUMIN_BLINK{28, 42, MEGUMIN_BLINK_ROWS}; + +} // namespace esphome::el512::konosuba diff --git a/esphome/el512-panel.yaml b/esphome/el512-panel.yaml index 3b1fd5f..6ebd9a9 100644 --- a/esphome/el512-panel.yaml +++ b/esphome/el512-panel.yaml @@ -17,6 +17,8 @@ esphome: on_boot: - lambda: |- id(cp_fonts) = {id(f_cp_title), id(f_cp_label), id(f_cp_value), id(f_cp_huge), id(f_cp_mono), id(f_cp_code)}; + id(ks_fonts) = {id(f_ks_title), id(f_ks_big), id(f_ks_text), id(f_ks_pixel), id(f_ks_pixel_l), id(f_ks_jp), + id(f_ks_jp_big), id(icons_l)}; esp32: board: wemos_d1_mini32 @@ -59,11 +61,13 @@ globals: - id: np_updated type: uint32_t initial_value: "0" - # Cyberpunk pages: their fonts and the home data they show. + # Themed pages (Cyberpunk, KonoSuba): their fonts and the home data they show. - id: cp_fonts type: el512::cyber::Fonts + - id: ks_fonts + type: el512::konosuba::Fonts - id: cp_home - type: el512::cyber::HomeData + type: el512::HomeData # --- Fonts ------------------------------------------------------------------- font: @@ -159,6 +163,35 @@ font: id: f_cp_code size: 22 glyphs: " 0123456789ABCDEF." + # KonoSuba pages + - file: "gfonts://Cinzel@700" + id: f_ks_title + size: 22 + glyphs: " ABCDEFGHIJKLMNOPQRSTUVWXYZ'!" + - file: "gfonts://Cinzel Decorative@900" + id: f_ks_big + size: 44 + glyphs: "EXPLOSIN!" + - file: "gfonts://Nunito@700" + id: f_ks_text + size: 16 + glyphsets: [GF_Latin_Core] + - file: "gfonts://Press Start 2P" + id: f_ks_pixel + size: 8 + glyphsets: [GF_Latin_Core] + - file: "gfonts://Press Start 2P" + id: f_ks_pixel_l + size: 16 + glyphs: " 0123456789:!ABCDEFGHIJKLMNOPQRSTUVWXYZ" + - file: "gfonts://Noto Sans JP@700" + id: f_ks_jp + size: 20 + glyphs: "冒険者ギルドカー戦闘" # each character once + - file: "gfonts://Dela Gothic One" + id: f_ks_jp_big + size: 40 + glyphs: "爆裂魔法" # --- Data from Home Assistant ------------------------------------------------- text_sensor: @@ -350,6 +383,10 @@ select: - Cyber Breach - Cyber Scan - Cyber Split + - KonoSuba Explosion + - KonoSuba Guild + - KonoSuba Card + - KonoSuba Battle initial_option: Clock set_action: - lambda: |- @@ -366,6 +403,10 @@ select: else if (x == "Cyber Breach") p = id(page_cyber_breach); else if (x == "Cyber Scan") p = id(page_cyber_scan); else if (x == "Cyber Split") p = id(page_cyber_split); + else if (x == "KonoSuba Explosion") p = id(page_ks_explosion); + else if (x == "KonoSuba Guild") p = id(page_ks_guild); + else if (x == "KonoSuba Card") p = id(page_ks_card); + else if (x == "KonoSuba Battle") p = id(page_ks_battle); id(panel).show_page(p); id(panel).update(); @@ -386,7 +427,7 @@ interval: if (id(sw_pause).state || id(panel).is_still_alive_playing()) { id(rotation_elapsed) = 0; return; } if (++id(rotation_elapsed) < (int) id(page_interval).state) return; id(rotation_elapsed) = 0; - for (int i = 0; i < 12; i++) { + for (int i = 0; i < 16; i++) { id(panel).show_next_page(); const auto *p = id(panel).get_active_page(); const std::string &np = id(np_state).state; @@ -395,7 +436,7 @@ interval: break; } id(panel).update(); - # Home data for the Cyberpunk pages. + # Home data for the themed pages. - interval: 1s then: - lambda: |- @@ -405,6 +446,8 @@ interval: d.humidity = id(weather_hum).state; d.wind = id(weather_wind).state; d.solar = id(solar_power).state; + d.solar_today = id(solar_today).state; + d.agenda = id(agenda_events).state; d.house = id(house_power).state; d.grid = id(grid_import).state - id(grid_export).state; d.rooms[0] = {"Woonkamer", id(t_woonkamer).state}; @@ -421,7 +464,8 @@ display: update_interval: 1s still_alive_page: page_still_alive # Redrawn every animation_interval (~15 fps) while shown. - animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split] + animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split, + page_ks_explosion, page_ks_guild, page_ks_card, page_ks_battle] animation_interval: 66ms on_page_change: - then: @@ -438,6 +482,10 @@ display: else if (to == id(page_cyber_breach)) name = "Cyber Breach"; else if (to == id(page_cyber_scan)) name = "Cyber Scan"; else if (to == id(page_cyber_split)) name = "Cyber Split"; + else if (to == id(page_ks_explosion)) name = "KonoSuba Explosion"; + else if (to == id(page_ks_guild)) name = "KonoSuba Guild"; + else if (to == id(page_ks_card)) name = "KonoSuba Card"; + else if (to == id(page_ks_battle)) name = "KonoSuba Battle"; if (id(page_select).current_option() != name) id(page_select).publish_state(name); pages: # 1. Clock, date and current weather @@ -633,3 +681,17 @@ display: - id: page_cyber_split lambda: |- el512::cyber::split_hud(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now()); + + # 13-16. KonoSuba pages (components/el512/konosuba.cpp) + - id: page_ks_explosion + lambda: |- + el512::konosuba::explosion(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now()); + - id: page_ks_guild + lambda: |- + el512::konosuba::guild_board(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now()); + - id: page_ks_card + lambda: |- + el512::konosuba::adventurer_card(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now()); + - id: page_ks_battle + lambda: |- + el512::konosuba::party_battle(*id(panel), id(ks_fonts), id(cp_home)); diff --git a/tools/konosuba_sprites.py b/tools/konosuba_sprites.py new file mode 100644 index 0000000..4a00b55 --- /dev/null +++ b/tools/konosuba_sprites.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Pixel art for the KonoSuba pages (components/el512/konosuba.cpp). + +Original 1-bit fan art: '#' is a lit pixel, anything else is dark. Writes +components/el512/konosuba_sprites.h and, with --preview, a magnified PNG of all +sprites to check them without the panel. + + python tools/konosuba_sprites.py + python tools/konosuba_sprites.py --preview sprites.png +""" + +import argparse +from pathlib import Path + +SPRITES = { + # Megumin standing, staff in her right hand (viewer's right). + "MEGUMIN": [ + "...............####.........", + "..............######........", + ".............###...##.......", + "............####.....#......", + "...........#####............", + "..........######............", + "..........#######...........", + ".........########...........", + ".........#########..........", + "........##########..........", + "........#.........#.........", + "........###########.........", + "##########################..", + ".########################...", + ".....###############........", + ".....##.#.##..#.#.##........", + ".....#.............#........", + ".....#..##....####.#........", + ".....#..##....####.#........", + ".....#.........#...#........", + ".....##.............#.......", + "......#.....##.....#........", + ".....###..........###.......", + "....####.##....##.####......", + "...######.######.######.....", + "..#######..####..#######....", + "..######...####...#######...", + ".#######...#..#...#######...", + ".######....#..#....######...", + ".######...#....#...######...", + "#######...#....#...#######..", + "######...#......#...######..", + "######..##########..######..", + "#####...#........#...#####..", + "#####..#..........#..#####..", + "####...############...####..", + "........#......#............", + "........#......#............", + "........#......#............", + "........#......#............", + ".......###....###...........", + ".......####...####..........", + ], + # Her staff: a gnarled top around a gem. + "STAFF": [ + "..#####..", + ".#.....#.", + "#..###..#", + "#.#####.#", + "#.#####.#", + "#..###..#", + ".#.....#.", + "..#...#..", + "...#.#...", + "....#....", + "....#....", + "...##....", + "....#....", + "....#....", + "....#....", + "....##...", + "....#....", + "....#....", + "....#....", + "....#....", + "...##....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....##...", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + "....#....", + ], + # Megumin after the explosion: face down, hat fallen off, staff dropped. + "MEGUMIN_DOWN": [ + "....###.....................................................", + "...#####....................................................", + "..######....................................................", + "..#######...................................................", + ".########.##................................................", + ".#########..##.........#######..............................", + "###########...##.....###########............................", + "#.........#.....#...##.#.#.#.#.##..#####################....", + "###########......#..#.#.#.#.#.#.#.########################..", + "...................##############.###########.##########.###", + "....................############..#####...........######...##", + "......................########....####.............####.....", + ], + # Small party members for the battle window. + "KAZUMA": [ + "....######......", + "...########.....", + "..##########....", + "..#.#.##.#.#....", + "..#........#....", + "..#..#..#..#....", + "..#........#....", + "...#..##..#.....", + "....######......", + "..##########....", + ".###.####.###...", + ".##..#..#..##...", + ".##..#..#..##.#.", + ".##..####..##.#.", + ".##..#..#..##.#.", + "..#..#..#..#.###", + "....#....#....#.", + "....#....#......", + "...##....##.....", + ], + "AQUA": [ + ".....####.......", + "....#....#......", + ".....####.......", + "....######......", + "...########.....", + "..##########....", + "..#........#....", + "..#..#..#..#....", + "..#........#....", + "..##..##..##....", + "..###....###....", + ".####.##.####...", + ".##..####..##...", + ".##.#....#.##...", + ".#.#......#.#...", + ".#.#......#.#...", + "...########.....", + "....#....#......", + "....#....#......", + "...##....##.....", + ], + "MEGUMIN_S": [ + ".......##.......", + "......####......", + ".....######.....", + "....########....", + "....#......#....", + "################", + "...##########...", + "...#........#...", + "...#.#...##.#...", + "...#........#...", + "....#..##..#....", + ".....######.....", + "...##########...", + "..############..", + "..###.####.###..", + "..##..#..#..##..", + "..##..####..##..", + "..############..", + ".....#....#.....", + ".....#....#.....", + "....##....##....", + ], + "DARKNESS": [ + "....######......", + "...########.###.", + "..##########.##.", + "..#.##..##.#..#.", + "..#........#....", + "..#..#..#..#....", + "..#........#....", + "...#..##..#.....", + "....######......", + "############....", + "##.########.##..", + "#..#.#..#.#..#..", + "...#.####.#.....", + "...#.#..#.#.....", + "...#.####.#.....", + "...########.....", + "....#....#......", + "....##...##.....", + "...###...###....", + ], + # Giant toad, mouth closed and with its tongue out. + "TOAD": [ + "......####............####......", + ".....#....#..........#....#.....", + "....#..##..#........#..##..#....", + "....#..##..##########..##..#....", + "...##.....#..........#.....##...", + "..#..........................#..", + ".#....#..........#.......#....#.", + "#..............................#", + "#.....##....#.........##...#...#", + "#..............................#", + "#..####################........#", + "#......................#.......#", + ".#........#.......#...........#.", + "..#..##..................##..#..", + "...##..#................#..##...", + "..#.....#..............#.....#..", + ".#..###..#............#..###..#.", + "##########............##########", + ], + "TOAD_TONGUE": [ + "......####............####......", + ".....#....#..........#....#.....", + "....#..##..#........#..##..#....", + "....#..##..##########..##..#....", + "...##.....#..........#.....##...", + "..#..........................#..", + ".#....#..........#.......#....#.", + "#..............................#", + "#.....##....#.........##...#...#", + "#..............................#", + "#..####################........#", + "#...#..................#.......#", + ".#...#....#.......#...........#.", + "..#...#..##..............##..#..", + "...##..#.##.............#..##...", + "..#.....####...........#.....#..", + ".#..###..##...........#..###..#.", + "##########............##########", + ], + # Flying cabbage, wings up and down. + "CABBAGE_A": [ + "#..............#", + "##...######...##", + ".##.#..#...#.##.", + "..##.#.#..#.##..", + "...#..###...#...", + "...#.#.#.#..#...", + "....#..#...#....", + ".....######.....", + ], + "CABBAGE_B": [ + "................", + ".....######.....", + "....#..#...#....", + "...#.#.#..#.#...", + "####..###...####", + "##.#.#.#.#..#.##", + "#...#..#...#...#", + ".....######.....", + ], +} + + +def padded(rows): + """Rows padded with dark pixels to the widest one.""" + w = max(len(r) for r in rows) + return [r.ljust(w, ".") for r in rows] + + +for _name in SPRITES: + SPRITES[_name] = padded(SPRITES[_name]) + +# Megumin with her visible eye closed (the other is under the eyepatch). +SPRITES["MEGUMIN_BLINK"] = list(SPRITES["MEGUMIN"]) +SPRITES["MEGUMIN_BLINK"][17] = SPRITES["MEGUMIN"][17][:8] + ".." + SPRITES["MEGUMIN"][17][10:] + + +def write_header(path): + out = [ + "// Pixel art for the KonoSuba pages, generated by tools/konosuba_sprites.py;", + "// edit the art there. Original 1-bit fan art, '#' = lit pixel.", + "#pragma once", + "", + "#include ", + "", + "namespace esphome::el512::konosuba {", + "", + "struct Sprite {", + " uint8_t w, h;", + " const char *const *rows;", + "};", + "", + ] + for name, rows in SPRITES.items(): + out.append(f"static const char *const {name}_ROWS[] = {{") + out += [f' "{r}",' for r in rows] + out.append("};") + out.append(f"static constexpr Sprite {name}{{{len(rows[0])}, {len(rows)}, {name}_ROWS}};") + out.append("") + out.append("} // namespace esphome::el512::konosuba") + path.write_text("\n".join(out) + "\n", encoding="utf-8") + + +def write_preview(path, scale=6): + from PIL import Image, ImageDraw + + pad = 4 + names = list(SPRITES) + width = sum(len(SPRITES[n][0]) + pad for n in names) * scale + height = (max(len(r) for r in SPRITES.values()) + pad) * scale + 14 + img = Image.new("RGB", (width, height), (20, 12, 0)) + draw = ImageDraw.Draw(img) + x = 0 + for name in names: + rows = SPRITES[name] + draw.text((x + 2, 2), name, fill=(120, 80, 0)) + for y, row in enumerate(rows): + for cx, ch in enumerate(row): + if ch == "#": + x0, y0 = x + cx * scale, 14 + y * scale + draw.rectangle([x0, y0, x0 + scale - 2, y0 + scale - 2], fill=(255, 170, 30)) + x += (len(rows[0]) + pad) * scale + img.save(path) + + +def main(): + root = Path(__file__).resolve().parent.parent + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--out", type=Path, default=root / "components/el512/konosuba_sprites.h") + ap.add_argument("--preview", type=Path, help="also write a magnified PNG of all sprites") + args = ap.parse_args() + write_header(args.out) + print(f"wrote {args.out}") + if args.preview: + write_preview(args.preview) + print(f"wrote {args.preview}") + + +if __name__ == "__main__": + main()