From 1e8a9a59581e517f38ba72efac3ef6a16c863629 Mon Sep 17 00:00:00 2001 From: MangaValk Date: Sun, 18 Oct 2026 23:58:34 +0200 Subject: [PATCH] Add four Steins;Gate pages; shuffle the page rotation hourly Divergence meter (Nixie tubes, world lines and clock), a D-mail sent through the Phone Microwave using the panel's Message text, the Future Gadget Lab inventory with live sensors, and a world line chart of today's solar power. Original pixel art in tools/steins_sprites.py; the sprite tool is shared between themes and Sprite, sprite(), polar() and arc() move to el512_pages.h. HomeData gains the message and a 24 h solar history in 15-minute slots. The rotation now walks all pages in a random order that is reshuffled every hour (Still Alive stays out, Now playing is skipped when idle). Co-Authored-By: Claude Opus 5.5 --- README.md | 38 ++- components/el512/el512_display.h | 1 + components/el512/el512_pages.h | 70 ++++ components/el512/konosuba.cpp | 35 -- components/el512/konosuba_sprites.h | 7 +- components/el512/steins.cpp | 497 ++++++++++++++++++++++++++++ components/el512/steins.h | 36 ++ components/el512/steins_sprites.h | 164 +++++++++ esphome/el512-panel.yaml | 80 ++++- tools/konosuba_sprites.py | 38 +-- tools/steins_sprites.py | 167 ++++++++++ 11 files changed, 1057 insertions(+), 76 deletions(-) create mode 100644 components/el512/steins.cpp create mode 100644 components/el512/steins.h create mode 100644 components/el512/steins_sprites.h create mode 100644 tools/steins_sprites.py diff --git a/README.md b/README.md index 865522e..7b51e64 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ components/el512/ shared driver + ESPHome component (one copy of the code) 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/ + steins.* animated Steins;Gate pages; steins_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 @@ -92,7 +93,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, konosuba_sprites.py +tools/ import_still_alive.py, konosuba_sprites.py, steins_sprites.py test/ unit tests (placeholder) docs/ datasheet and notes ``` @@ -111,10 +112,11 @@ pio device monitor ## ESPHome / Home Assistant [esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home -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 +Assistant display with twenty pages: clock and weather, forecast, agenda, a sensor +dashboard with a message line, now playing, Plex, solar, Still Alive, and twelve +animated themed pages ([Cyberpunk](#cyberpunk-pages), [KonoSuba](#konosuba-pages), +[Steins;Gate](#steinsgate-pages)). Pages +rotate on an interval, in a random order that is reshuffled every hour; 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). @@ -249,6 +251,32 @@ from the series is used. KonoSuba (*Kono Subarashii Sekai ni Shukufuku wo!*) is Natsume Akatsuki and Kurone Mishima, © its rights holders; this project is not affiliated with or endorsed by them. +## Steins;Gate pages + +Four animated pages from the Future Gadget Lab, drawn by +[components/el512/steins.cpp](components/el512/steins.cpp) with Nixie One, Special +Elite and Noto Sans JP (plus fonts shared with the other themed pages), all in the +rotation: + +- **Divergence Meter**: eight Nixie tubes roll and lock onto a world line + (Steins Gate first), then show the time; every change ripples like a Reading + Steiner. +- **D-Mail**: a flip phone types the panel's Message text (36 characters, or a + default) as a D-mail, the Phone Microwave (name subject to change) spins a banana and + discharges, and the world line shifts. Upa sits on top; Kurisu keeps watch. +- **Gadget Lab**: the lab inventory, where every gadget is a live sensor (#008, the + Phone Microwave, draws the house load), with a detail card and Okabe in his lab coat. +- **World Lines**: the alpha, beta and Steins Gate attractor fields with today's solar + power plotted as the world line, futures branching off "you are here". The history + is kept on the device in 15-minute slots (`HomeData::record()`), so it fills during + the day and starts over at midnight or after a restart. + +The pixel art lives in [tools/steins_sprites.py](tools/steins_sprites.py) (same +`--preview` option as the KonoSuba sprites). + +These pages are fan art: the pixel art and all texts are original, and no material +from the series is used. Steins;Gate is © MAGES. and its other rights holders; this project is not affiliated with or endorsed by them. + ## Using the driver ```cpp diff --git a/components/el512/el512_display.h b/components/el512/el512_display.h index 2e41892..5021cc0 100644 --- a/components/el512/el512_display.h +++ b/components/el512/el512_display.h @@ -5,6 +5,7 @@ #include "cyberpunk.h" #include "el512_helpers.h" #include "konosuba.h" +#include "steins.h" #include "el512_panel.h" #include "esphome/components/display/display_buffer.h" #include "esphome/core/component.h" diff --git a/components/el512/el512_pages.h b/components/el512/el512_pages.h index 8ff679d..9fe2c56 100644 --- a/components/el512/el512_pages.h +++ b/components/el512/el512_pages.h @@ -2,6 +2,7 @@ // Home Assistant values they show, and small drawing helpers. #pragma once +#include #include #include #include @@ -10,6 +11,7 @@ #include #include "esphome/components/display/display.h" +#include "esphome/core/time.h" namespace esphome::el512 { @@ -30,7 +32,38 @@ struct HomeData { float house{NAN}; // W float grid{NAN}; // W, > 0 = import std::string agenda; // sensor.el512_agenda events: "day|time|title;..." + std::string message; // the panel's Message text Reading rooms[4]; + + // Today's solar power in 15-minute slots (highest value seen per slot; -1 = + // no data yet). Fed by record(), once a second from YAML. + static constexpr int SLOTS = 96; + float solar_slots[SLOTS]; + int slots_day{-1}; + + HomeData() { + for (auto &s : this->solar_slots) + s = -1; + } + void record(ESPTime now) { + if (!now.is_valid()) + return; + if (now.day_of_year != this->slots_day) { + this->slots_day = now.day_of_year; + for (auto &s : this->solar_slots) + s = -1; + } + float &slot = this->solar_slots[now.hour * 4 + now.minute / 15]; + if (!std::isnan(this->solar)) + slot = std::max(slot, this->solar); + } +}; + +// 1-bit sprite: rows of '#' (lit) and anything else (dark). Generated by the +// tools/*_sprites.py files. +struct Sprite { + uint8_t w, h; + const char *const *rows; }; namespace fx { @@ -68,5 +101,42 @@ inline int text_width(display::Display &it, display::BaseFont *font, const char return w; } +// Draw a sprite (optionally only its first `rows` rows), scaled up in blocks. +inline void sprite(display::Display &it, const Sprite &s, int x, int y, int scale = 1, int rows = -1, + Color color = display::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. +inline void polar(int cx, int cy, float r, float a, int *x, int *y) { + constexpr float DEG = 3.14159265f / 180.0f; + *x = cx + (int) std::lround(r * std::sin(a * DEG)); + *y = cy - (int) std::lround(r * std::cos(a * DEG)); +} + +// Circular arc from angle a0 to a1 (degrees, as polar()). +inline void arc(display::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; + } +} + } // namespace fx } // namespace esphome::el512 diff --git a/components/el512/konosuba.cpp b/components/el512/konosuba.cpp index 301d506..49ccb30 100644 --- a/components/el512/konosuba.cpp +++ b/components/el512/konosuba.cpp @@ -21,45 +21,10 @@ 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); diff --git a/components/el512/konosuba_sprites.h b/components/el512/konosuba_sprites.h index b792765..72a50a0 100644 --- a/components/el512/konosuba_sprites.h +++ b/components/el512/konosuba_sprites.h @@ -2,15 +2,10 @@ // edit the art there. Original 1-bit fan art, '#' = lit pixel. #pragma once -#include +#include "el512_pages.h" // Sprite namespace esphome::el512::konosuba { -struct Sprite { - uint8_t w, h; - const char *const *rows; -}; - static const char *const MEGUMIN_ROWS[] = { "...............####.........", "..............######........", diff --git a/components/el512/steins.cpp b/components/el512/steins.cpp new file mode 100644 index 0000000..70a6116 --- /dev/null +++ b/components/el512/steins.cpp @@ -0,0 +1,497 @@ +#include "steins.h" + +#include +#include +#include +#include + +#include "el512_display.h" +#include "esphome/core/hal.h" +#include "steins_sprites.h" + +namespace esphome::el512::steins { + +using display::COLOR_OFF; +using display::COLOR_ON; +using display::Display; +using display::TextAlign; + +namespace { + +using namespace fx; + +constexpr int W = 512, H = 256; +const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}}; + +// --- Helpers ----------------------------------------------------------------------- + +void round_rect(Display &it, int x, int y, int w, int h, int r) { + it.horizontal_line(x + r, y, w - 2 * r); + it.horizontal_line(x + r, y + h - 1, w - 2 * r); + it.vertical_line(x, y + r, h - 2 * r); + it.vertical_line(x + w - 1, y + r, h - 2 * r); + arc(it, x + r, y + r, r, 270, 360); + arc(it, x + w - 1 - r, y + r, r, 0, 90); + arc(it, x + w - 1 - r, y + h - 1 - r, r, 90, 180); + arc(it, x + r, y + h - 1 - r, r, 180, 270); +} + +void dashed_hline(Display &it, int x, int y, int w, int on, int off, int phase = 0) { + for (int i = 0; i < w; i++) + if ((i + phase) % (on + off) < on) + it.draw_pixel_at(x + i, y); +} + +// Reading Steiner: the picture ripples sideways, fading out over `dur` ms. +void ripple(EL512Display &it, uint32_t age, uint32_t dur, float amp) { + if (age >= dur) + return; + const float a = amp * (1.0f - (float) age / dur); + for (int y = 0; y < H; y++) + it.shift_rows(y, 1, (int) std::lround(a * std::sin(y * 0.11f + age * 0.025f))); +} + +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; +} + +std::string typed(const std::string &s, uint32_t t, int cps) { return s.substr(0, std::min(s.size(), t * cps / 1000)); } + +std::string watts_text(float w) { return std::isnan(w) ? "--" : watts(w); } +std::string temp_text(float v) { return std::isnan(v) ? "--" : str("%.1f C", v); } + +struct WorldLine { + const char *value; // 8 characters, as on the meter + const char *field; +}; +const WorldLine WORLD_LINES[] = { + {"1.048596", "STEINS GATE"}, {"0.571024", "ALPHA"}, {"0.523299", "ALPHA"}, {"0.456903", "ALPHA"}, + {"0.409431", "ALPHA"}, {"0.337187", "ALPHA"}, {"1.130205", "BETA"}, {"1.129848", "BETA"}, +}; +constexpr int WORLD_LINE_COUNT = sizeof(WORLD_LINES) / sizeof(WORLD_LINES[0]); + +// One Nixie tube: glass outline, anode mesh, socket, and the lit digit. +void tube(Display &it, const Fonts &f, int x, int y, char digit) { + constexpr int TW = 56, TH = 176, R = 28; + const int cx = x + TW / 2; + arc(it, cx, y + R, R, -90, 90); + it.vertical_line(x, y + R, TH - R - 14); + it.vertical_line(x + TW - 1, y + R, TH - R - 14); + it.filled_rectangle(x - 2, y + TH - 14, TW + 4, 10); + for (int i = 0; i < 5; i++) + it.vertical_line(x + 8 + i * 10, y + TH - 4, 6); + for (int my = y + 24; my < y + TH - 24; my += 6) + for (int mx = x + 7 + ((my / 6) % 2) * 3; mx < x + TW - 6; mx += 6) + it.draw_pixel_at(mx, my); + if (digit != ' ') { + const char s[2] = {digit, 0}; + // Twice, one pixel apart, so the thin filament reads as glowing. + it.print(cx, y + TH / 2 - 8, f.nixie, TextAlign::CENTER, s); + it.print(cx + 1, y + TH / 2 - 8, f.nixie, TextAlign::CENTER, s); + } +} + +// Divergence for a solar power, for the chart and the D-mail: 0.3 .. 1.3. +float divergence(float solar) { return 0.3f + std::clamp(std::isnan(solar) ? 0.0f : solar, 0.0f, 6000.0f) / 6000.0f; } + +} // namespace + +// --- 1. Divergence meter -------------------------------------------------------------- + +void divergence_meter(EL512Display &it, const Fonts &f, ESPTime now) { + if (f.nixie == nullptr) + return; + const uint32_t t = it.page_age_ms(); + constexpr uint32_t HALF = 8000, CYCLE = 2 * HALF; + const uint32_t cycle = t / CYCLE, c = t % CYCLE; + const bool clock = c >= HALF; + const uint32_t p = clock ? c - HALF : c; + // Steins Gate first, then wherever the world line wanders. + const WorldLine &wl = cycle == 0 ? WORLD_LINES[0] : WORLD_LINES[hash(cycle, it.page_shown_at()) % WORLD_LINE_COUNT]; + + char target[9]; + if (clock && now.is_valid()) + snprintf(target, sizeof(target), "%02d.%02d.%02d", now.hour % 24, now.minute % 60, now.second % 60); + else if (clock) + strcpy(target, " . . "); + else + memcpy(target, wl.value, sizeof(target)); + + for (int i = 0; i < 8; i++) { + char ch = target[i]; + // Digits roll, then lock in from left to right. + if (p < 250 + (uint32_t) i * 180 && ch != '.') + ch = (char) ('0' + hash(p / 45, i * 7 + cycle) % 10); + if (hash(t / 70, i + 100) % 157 == 0) + ch = ' '; // a tube flickers + tube(it, f, 6 + i * 63, 22, ch); + } + + it.print(6, 2, f.mono, TextAlign::TOP_LEFT, "DIVERGENCE METER"); + it.print(506, 0, f.jp, TextAlign::TOP_RIGHT, "世界線"); + it.horizontal_line(0, 214, W); + if (clock) { + it.print(6, 236, f.mono, TextAlign::CENTER_LEFT, "LAB TIME"); + if (now.is_valid()) + it.print(506, 236, f.title, TextAlign::CENTER_RIGHT, upper(now.strftime("%A %d %B")).c_str()); + } else { + it.print(6, 236, f.mono, TextAlign::CENTER_LEFT, "WORLD LINE"); + it.print(96, 236, f.title, TextAlign::CENTER_LEFT, wl.field); + it.printf(506, 236, f.mono, TextAlign::CENTER_RIGHT, "DIVERGENCE %s%%", wl.value); + } + ripple(it, p, 650, 12); +} + +// --- 2. D-mail through the Phone Microwave ------------------------------------------------ + +void d_mail(EL512Display &it, const Fonts &f, const HomeData &d) { + if (f.pixel == nullptr) + return; + const uint32_t t = it.page_age_ms(); + constexpr uint32_t CYCLE = 19000, TYPE_AT = 900, CPS = 9; + const uint32_t cycle = t / CYCLE, c = t % CYCLE; + std::string msg = d.message.empty() ? "Don't open the fridge on Friday. -O" : d.message; + if (msg.size() > 36) + msg.resize(36); // a D-mail holds 36 characters + const uint32_t typed_end = TYPE_AT + msg.size() * 1000 / CPS; + const uint32_t SEND = typed_end + 400, SENT = SEND + 1000, ZAP = SENT + 2800, DONE = ZAP + 800; + + // Flip phone: screen half, hinge, keypad half. + round_rect(it, 18, 4, 170, 128, 10); + it.rectangle(30, 16, 146, 104); + it.filled_rectangle(60, 132, 86, 6); + round_rect(it, 18, 138, 170, 114, 10); + it.rectangle(36, 146, 30, 10); + it.rectangle(140, 146, 30, 10); + it.circle(103, 151, 7); + static const char *const KEYS = "123456789*0#"; + // Which key the thumb is on (multi-tap layout; space is 0). + int pressed = -1; + if (c >= TYPE_AT && c < typed_end) { + const char ch = (char) tolower((unsigned char) msg[(c - TYPE_AT) * CPS / 1000]); + static const char *const LETTERS[] = {"", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}; + pressed = 0; + if (ch == ' ') + pressed = 10; + for (int k = 1; k <= 8; k++) + if (ch != 0 && strchr(LETTERS[k], ch) != nullptr) + pressed = k; + if ((c / 55) % 3 == 0) + pressed = -1; // between taps + } + for (int k = 0; k < 12; k++) { + const int x = 32 + (k % 3) * 50, y = 166 + (k / 3) * 20; + const char label[2] = {KEYS[k], 0}; + if (k == pressed) { + it.filled_rectangle(x, y, 44, 17); + it.print(x + 22, y + 9, f.pixel, COLOR_OFF, TextAlign::CENTER, label); + } else { + it.rectangle(x, y, 44, 17); + it.print(x + 22, y + 9, f.pixel, TextAlign::CENTER, label); + } + } + + // Phone screen. + it.filled_rectangle(31, 17, 144, 12); + it.print(35, 19, f.pixel, COLOR_OFF, TextAlign::TOP_LEFT, "D-MAIL"); + for (int b = 0; b < 4; b++) + it.filled_rectangle(146 + b * 6, 26 - b * 2 - 2, 4, b * 2 + 2, COLOR_OFF); + if (c < SEND) { + it.print(35, 34, f.pixel, TextAlign::TOP_LEFT, "TO: ME, 1 WEEK AGO"); + it.horizontal_line(34, 45, 138); + std::string body = c < TYPE_AT ? "" : typed(msg, c - TYPE_AT, CPS); + if ((t / 300) % 2) + body += "_"; + int y = 50; + for (const auto &l : wrap(it, f.pixel, body, 136, 5)) { + it.print(35, y, f.pixel, TextAlign::TOP_LEFT, l.c_str()); + y += 11; + } + it.printf(172, 108, f.pixel, TextAlign::TOP_RIGHT, "%u/36", + (unsigned) std::min(msg.size(), c < TYPE_AT ? 0 : (c - TYPE_AT) * CPS / 1000)); + } else if (c < SENT) { + it.print(103, 56, f.pixel, TextAlign::TOP_CENTER, "SENDING..."); + it.rectangle(40, 74, 126, 10); + it.filled_rectangle(42, 76, (int) (122 * (c - SEND) / (SENT - SEND)), 6); + } else { + it.print(103, 50, f.pixel, TextAlign::TOP_CENTER, "D-MAIL SENT"); + it.print(103, 66, f.pixel, TextAlign::TOP_CENTER, c >= DONE ? "DELIVERED TO" : "IN TRANSIT..."); + if (c >= DONE) + it.print(103, 78, f.pixel, TextAlign::TOP_CENTER, "THE PAST"); + } + + // The Phone Microwave (name subject to change). + constexpr int MX = 212, MY = 62, MW = 244, MH = 128; + constexpr int WX = MX + 12, WY = MY + 12, WW = 150, WH = MH - 24; + const bool running = c >= SENT && c < ZAP + 300; + round_rect(it, MX, MY, MW, MH, 6); + round_rect(it, WX, WY, WW, WH, 6); + it.vertical_line(MX + 174, MY + 4, MH - 8); + it.rectangle(MX + 182, MY + 12, 54, 22); + for (int b = 0; b < 6; b++) + it.circle(MX + 194 + (b % 3) * 15, MY + 50 + (b / 3) * 16, 4); + it.filled_rectangle(MX + 184, MY + 92, 50, 16); + it.print(MX + 209, MY + 100, f.pixel, COLOR_OFF, TextAlign::CENTER, "START"); + const int cx = WX + WW / 2, cy = WY + WH - 26; + if (running) { + const int level = 6 + (int) (3 * std::sin(c / 90.0f)); + for (int y = WY + 4; y < WY + WH - 4; y++) + for (int x = WX + 4; x < WX + WW - 4; x++) + if (BAYER[y & 3][x & 3] < level && BAYER[(y + 1) & 3][(x + 2) & 3] < 6) + it.draw_pixel_at(x, y, COLOR_ON); + it.printf(MX + 209, MY + 23, f.mono, TextAlign::CENTER, "0:%02u", (unsigned) ((ZAP + 300 - c) / 1000)); + } else { + it.print(MX + 209, MY + 23, f.mono, TextAlign::CENTER, upper(watts_text(d.house)).c_str()); + } + // Turntable with a banana on it. + for (int a = 0; a < 360; a += 15) { + const float r0 = a * 3.14159f / 180, r1 = (a + 15) * 3.14159f / 180; + it.line(cx + (int) (52 * std::sin(r0)), cy + (int) (11 * std::cos(r0)), cx + (int) (52 * std::sin(r1)), + cy + (int) (11 * std::cos(r1))); + } + const float ba = running ? c / 400.0f : 0.6f; + const int bx = cx + (int) (34 * std::sin(ba)), by = cy + (int) (7 * std::cos(ba)) - 6; + it.filled_rectangle(bx - 8, by, 16, 4, COLOR_OFF); + arc(it, bx, by - 8, 10, 120, 240); + arc(it, bx, by - 9, 10, 125, 235); + // Discharge while it runs past its limit. + if (running && c > SENT + 1200) { + for (int s = 0; s < 3; s++) { + const uint32_t r = hash(c / 60, s); + int x = MX + (int) (r % MW), y = (r >> 8) % 2 ? MY : MY + MH; + const int dir = y == MY ? -1 : 1; + for (int seg = 0; seg < 3; seg++) { + const int nx = x + (int) ((r >> (12 + seg * 4)) % 17) - 8, ny = y + dir * (6 + (int) ((r >> (20 + seg)) % 8)); + it.line(x, y, nx, ny); + x = nx; + y = ny; + } + } + } + // Upa on top, Kurisu keeping an eye on things. + const bool hop = (t / 380) % 2; + sprite(it, hop ? UPA_A : UPA_B, MX + 28, MY - 18 - (hop ? 4 : 0), 2); + sprite(it, KURISU, 472, 134, 2); + + // Label, or the world line change once it went through. + if (c < DONE) { + it.print(MX + MW / 2, MY + MH + 8, f.mono, TextAlign::TOP_CENTER, "PHONE MICROWAVE"); + it.print(MX + MW / 2, MY + MH + 24, f.mono, TextAlign::TOP_CENTER, "(NAME SUBJECT TO CHANGE)"); + } else { + const float from = divergence(d.solar) - 0.05f, to = from + ((hash(cycle, 9) % 2) ? 0.0471f : -0.0472f); + it.print(MX, MY + MH + 8, f.mono, TextAlign::TOP_LEFT, "WORLD LINE SHIFTED"); + const std::string a = str("%.6f", from), b = str("%.6f", to); + it.print(MX, MY + MH + 26, f.code, TextAlign::TOP_LEFT, a.c_str()); + const int ax = MX + text_width(it, f.code, a.c_str()) + 6, ay = MY + MH + 37; + it.horizontal_line(ax, ay, 18); + it.filled_triangle(ax + 18, ay - 4, ax + 18, ay + 4, ax + 24, ay); + it.print(ax + 30, MY + MH + 26, f.code, TextAlign::TOP_LEFT, b.c_str()); + } + + if (c >= ZAP && c < ZAP + 120) + it.invert_rect(0, 0, W, H); + if (c >= ZAP) + ripple(it, c - ZAP, 900, 14); +} + +// --- 3. Future Gadget Lab inventory --------------------------------------------------------- + +void gadget_lab(EL512Display &it, const Fonts &f, const HomeData &d) { + if (f.title == nullptr) + return; + const uint32_t t = it.page_age_ms(); + struct Gadget { + std::string no, name, desc, value; + }; + const std::string room0 = d.rooms[0].name.empty() ? "ROOM" : upper(d.rooms[0].name); + const std::string room1 = d.rooms[1].name.empty() ? "ROOM" : upper(d.rooms[1].name); + const float kwh = d.solar_today / 1000.0f; + const Gadget gadgets[] = { + {"001", "SOLAR RAY COLLECTOR", "Turns sunlight into watts. Do not stare into it.", watts_text(d.solar)}, + {"002", "GRID SIPHON", + std::isnan(d.grid) || d.grid >= 0 ? "Borrowing power from the grid. They are watching." + : "Feeding power back into the grid. How generous.", + std::isnan(d.grid) ? "--" : (d.grid >= 0 ? "+" : "-") + watts(std::fabs(d.grid))}, + {"003", "THERMAL ORACLE", "Foretells the weather by measuring it.", temp_text(d.temp)}, + {"004", "DAMPNESS DIVINER", "Reads how damp this world line is.", + std::isnan(d.humidity) ? "--" : str("%.0f %%", d.humidity)}, + {"005", room0 + " PROBE", "A sensor left in the " + d.rooms[0].name + ". For science.", temp_text(d.rooms[0].value)}, + {"006", room1 + " PROBE", "Its twin, hidden in the " + d.rooms[1].name + ".", temp_text(d.rooms[1].value)}, + {"008", "PHONE MICROWAVE (NAME SUBJECT TO CHANGE)", "Turns bananas into gel. Draws the whole house load.", + watts_text(d.house)}, + {"009", "SOLAR YIELD LEDGER", "Everything the sun gave the lab today.", + std::isnan(kwh) ? "--" : str("%.2f kWh", kwh)}, + }; + constexpr int N = 8; + constexpr uint32_t STEP = 2600; + const int sel = (int) ((t / STEP) % N); + + it.print(8, 4, f.title, TextAlign::TOP_LEFT, "FUTURE GADGET LAB"); + it.print(506, 4, f.jp, TextAlign::TOP_RIGHT, "未来ガジェット研究所"); + it.horizontal_line(0, 32, W); + + // Inventory list. + for (int i = 0; i < N; i++) { + const int y = 38 + i * 22; + const Gadget &g = gadgets[i]; + const Color fg = i == sel ? COLOR_OFF : COLOR_ON; + if (i == sel) + it.filled_rectangle(2, y, 330, 20); + it.printf(8, y + 10, f.mono, fg, TextAlign::CENTER_LEFT, "No.%s", g.no.c_str()); + it.print(64, y + 10, f.mono, fg, TextAlign::CENTER_LEFT, fit(f.mono, g.name, 186).c_str()); + it.print(328, y + 10, f.mono, fg, TextAlign::CENTER_RIGHT, g.value.c_str()); + } + it.print(8, 222, f.mono, TextAlign::TOP_LEFT, "LAB STATUS: ALL GADGETS NOMINAL"); + if ((t / 500) % 2) + it.filled_rectangle(270, 225, 8, 8); + + // Detail card for the selected gadget. + const Gadget &g = gadgets[sel]; + const uint32_t since = t % STEP; + it.rectangle(340, 38, 170, 128); + it.rectangle(342, 40, 166, 124); + it.printf(350, 46, f.title, TextAlign::TOP_LEFT, "NO.%s", g.no.c_str()); + it.print(502, 46, f.title, TextAlign::TOP_RIGHT, g.value.c_str()); + int y = 74; + for (const auto &l : wrap(it, f.mono, g.name, 150, 2)) { + it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str()); + y += 15; + } + it.horizontal_line(350, y + 2, 150); + y += 8; + for (const auto &l : wrap(it, f.mono, typed(g.desc, since, 40), 150, 3)) { + it.print(350, y, f.mono, TextAlign::TOP_LEFT, l.c_str()); + y += 15; + } + + // The lab's founder, coat flaring in a wind only he can feel. + const bool flare = (t / 700) % 3 == 2; + sprite(it, flare ? OKABE_B : OKABE_A, 462, 182, 2); + const uint32_t talk = t % 9000; + if (talk > 5000) { + const std::string line = typed("El Psy Kongroo.", talk - 5000, 12); + it.print(456, 196, f.mono, TextAlign::TOP_RIGHT, line.c_str()); + } else { + it.print(456, 196, f.mono, TextAlign::TOP_RIGHT, "LAB MEM 001"); + } + ripple(it, t % 7000, 350, 6); +} + +// --- 4. World line chart of today's solar power --------------------------------------------- + +void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { + if (f.title == nullptr) + return; + const uint32_t t = it.page_age_ms(); + constexpr int PX0 = 44, PX1 = 500, PY0 = 40, PY1 = 214; + constexpr float DMAX = 1.4f; + auto ymap = [](float div) { return PY1 - (int) (div / DMAX * (PY1 - PY0)); }; + auto xmap = [](float hours) { return PX0 + (int) (hours / 24.0f * (PX1 - PX0)); }; + + it.print(8, 4, f.title, TextAlign::TOP_LEFT, "WORLD LINE CHART"); + it.print(222, 2, f.jp, TextAlign::TOP_LEFT, "世界線"); + const float cur = divergence(d.solar); + it.rectangle(370, 2, 140, 30); + it.printf(440, 17, f.code, TextAlign::CENTER, "%.6f%%", cur); + + // Axes. + it.vertical_line(PX0, PY0, PY1 - PY0 + 1); + it.horizontal_line(PX0, PY1, PX1 - PX0); + for (float v : {0.0f, 0.5f, 1.0f}) { + it.printf(PX0 - 6, ymap(v), f.pixel, TextAlign::CENTER_RIGHT, "%.1f", v); + it.horizontal_line(PX0 - 3, ymap(v), 3); + } + for (int h = 0; h <= 24; h += 6) { + it.vertical_line(xmap(h), PY1, 4); + it.printf(xmap(h), PY1 + 8, f.pixel, TextAlign::TOP_CENTER, "%02d:00", h % 24); + } + + // Attractor fields. + struct Field { + float div; + const char *name; + }; + const Field fields[] = {{0.571024f, "ALPHA 0.571024"}, {1.048596f, "STEINS GATE 1.048596"}, {1.130205f, "BETA 1.130205"}}; + const int phase = (int) (t / 80); + for (const auto &fl : fields) { + const int y = ymap(fl.div); + if (fl.div == 1.048596f) { + it.horizontal_line(PX0 + 1, y, PX1 - PX0); + it.print(PX1, y - 3, f.pixel, TextAlign::BOTTOM_RIGHT, fl.name); + } else { + dashed_hline(it, PX0 + 1, y, PX1 - PX0, 4, 4, phase); + it.print(PX1, fl.div > 1.1f ? y - 3 : y + 3, f.pixel, fl.div > 1.1f ? TextAlign::BOTTOM_RIGHT : TextAlign::TOP_RIGHT, + fl.name); + } + } + + // Today's world line, drawn in over the first 1.5 s. + const int reveal = t < 1500 ? PX0 + (int) ((PX1 - PX0) * t / 1500) : PX1; + int px = -1, py = -1, points = 0; + for (int s = 0; s < HomeData::SLOTS; s++) { + const float v = d.solar_slots[s]; + if (v < 0) { + px = -1; + continue; + } + const int x = xmap((s + 0.5f) / 4.0f), y = ymap(divergence(v)); + if (x > reveal) + break; + if (px >= 0) { + it.line(px, py, x, y); + it.line(px, py - 1, x, y - 1); + } + px = x; + py = y; + points++; + } + if (points == 0 && (t / 600) % 2) + it.print((PX0 + PX1) / 2, (PY0 + PY1) / 2 + 30, f.mono, TextAlign::CENTER, "COLLECTING WORLD LINE DATA..."); + + // Where we are now, and the futures branching off it. + if (now.is_valid() && t >= 1500) { + const int x = xmap(now.hour + now.minute / 60.0f), y = ymap(cur); + for (int b = 0; b < 3; b++) { + const int dy = (int) (hash(b, now.hour) % 60) - 30 + (b - 1) * 22; + const int steps = std::max(1, (PX1 - x) / 4); + for (int i = 0; i < steps; i++) + if ((i + phase) % 3 != 0) + it.draw_pixel_at(x + i * 4, y + dy * i / steps, COLOR_ON); + } + const int r = 3 + (int) ((t / 150) % 3); + it.circle(x, y, r + 2); + if ((t / 400) % 2) + it.filled_circle(x, y, 2); + const bool left = x > 360; + it.print(left ? x - 10 : x + 10, y - 14, f.pixel, left ? TextAlign::TOP_RIGHT : TextAlign::TOP_LEFT, "YOU ARE HERE"); + } + ripple(it, (t + 3000) % 8000, 500, 8); +} + +} // namespace esphome::el512::steins diff --git a/components/el512/steins.h b/components/el512/steins.h new file mode 100644 index 0000000..37ff819 --- /dev/null +++ b/components/el512/steins.h @@ -0,0 +1,36 @@ +// Steins;Gate-themed animated pages for the ESPHome display: a Nixie-tube +// divergence meter, a D-mail sent from a flip phone through the Phone +// Microwave, the Future Gadget Lab inventory with live sensors, and a world +// line chart of today's solar power. Fan art: the pixel art +// (steins_sprites.h, from tools/steins_sprites.py) and all texts are original. +// +// 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 steins { + +struct Fonts { + display::BaseFont *nixie{nullptr}; // Nixie tube digits (Nixie One) + display::BaseFont *title{nullptr}; // typewriter headers (Special Elite) + display::BaseFont *mono{nullptr}; // small monospace (Share Tech Mono 14) + display::BaseFont *code{nullptr}; // larger monospace (Share Tech Mono 22) + display::BaseFont *pixel{nullptr}; // phone LCD (Press Start 2P 8) + display::BaseFont *jp{nullptr}; // a few Japanese words (Noto Sans JP) +}; + +void divergence_meter(EL512Display &it, const Fonts &f, ESPTime now); +void d_mail(EL512Display &it, const Fonts &f, const HomeData &d); +void gadget_lab(EL512Display &it, const Fonts &f, const HomeData &d); +void world_lines(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now); + +} // namespace steins +} // namespace esphome::el512 diff --git a/components/el512/steins_sprites.h b/components/el512/steins_sprites.h new file mode 100644 index 0000000..a468016 --- /dev/null +++ b/components/el512/steins_sprites.h @@ -0,0 +1,164 @@ +// Pixel art for the Steins;Gate pages, generated by tools/steins_sprites.py; +// edit the art there. Original 1-bit fan art, '#' = lit pixel. +#pragma once + +#include "el512_pages.h" // Sprite + +namespace esphome::el512::steins { + +static const char *const OKABE_A_ROWS[] = { + "......#######.........", + "....##########.#......", + "...############.......", + "..#############.#.....", + "..##.#.#.##.####......", + "..#.........###.......", + "..#..##..##..#........", + "..#...........#.......", + "..#.....#.....#.......", + "...#..#####..#........", + "....#.......#.........", + ".....#######..........", + "...###.....###........", + "..##..#...#..##.......", + ".##....#.#....##......", + ".#.#....#....#.#......", + ".#.#....#....#.#......", + ".#..#...#...#..#......", + "#...#...#...#...#.....", + "#...#...#...#...#.....", + "#..#....#....#..#.....", + "#..#....#....#..#.....", + "##.#....#....#.##.....", + "##.#....#....#.##.....", + "...#....#....#........", + "...#...###...#........", + "...#...#.#...#........", + "..#....#.#....#.......", + "..#....#.#....#.......", + ".#.....#.#.....#......", + ".#.....#.#.....#......", + "#......#.#......#.....", + "########.########.....", + ".......#.#............", + ".......#.#............", + "......##.##...........", +}; +static constexpr Sprite OKABE_A{22, 36, OKABE_A_ROWS}; + +static const char *const OKABE_B_ROWS[] = { + "......#######.........", + "....##########.#......", + "...############.......", + "..#############.#.....", + "..##.#.#.##.####......", + "..#.........###.......", + "..#..##..##..#........", + "..#...........#.......", + "..#.....#.....#.......", + "...#..#####..#........", + "....#.......#.........", + ".....#######..........", + "...###.....###........", + "..##..#...#..##.......", + ".##....#.#....##......", + ".#.#....#....#.##.....", + ".#.#....#....#.#.#....", + ".#..#...#...#..#..#...", + "#...#...#...#...#..#..", + "#...#...#...#...#...#.", + "#..#....#....#..#....#", + "#..#....#....#..#....#", + "##.#....#....#.##....#", + "##.#....#....#......#.", + "...#....#....#......#.", + "...#...###...#.....#..", + "...#...#.#...#.....#..", + "..#....#.#....#...#...", + "..#....#.#....#...#...", + ".#.....#.#.....#.#....", + ".#.....#.#.....##.....", + "#......#.#......#.....", + "########.########.....", + ".......#.#............", + ".......#.#............", + "......##.##...........", +}; +static constexpr Sprite OKABE_B{22, 36, OKABE_B_ROWS}; + +static const char *const KURISU_ROWS[] = { + ".....######.......", + "...##########.....", + "..############....", + "..##.#.##.#.##....", + "..#..........#....", + "..#..##..##..#....", + "..#..........#....", + "..#....#.....#....", + "..##..###...##....", + "..###......###....", + "..#.##....##.#....", + "..#..######..#....", + "..#.#..#..#..#....", + "..##..###..#.#....", + "..#....#....##....", + ".##...###...###...", + ".#.#...#...#..#...", + ".#.#.......#..#...", + ".#..#.....#...#...", + ".#..#######...#...", + "....#.....#.......", + "....#.....#.......", + "....##...##.......", + ".....#...#........", + ".....#...#........", + ".....#...#........", + ".....#...#........", + "....##...##.......", +}; +static constexpr Sprite KURISU{18, 28, KURISU_ROWS}; + +static const char *const UPA_A_ROWS[] = { + "..#........#..", + "..##......##..", + "..#.######.#..", + ".#..........#.", + "#...#....#...#", + "#............#", + "#.....##.....#", + ".#..........#.", + "..##########..", +}; +static constexpr Sprite UPA_A{14, 9, UPA_A_ROWS}; + +static const char *const UPA_B_ROWS[] = { + "..............", + "...#......#...", + "..##.####.##..", + ".#..........#.", + "#...#....#...#", + "#.....##.....#", + "##..........##", + ".############.", + "..............", +}; +static constexpr Sprite UPA_B{14, 9, UPA_B_ROWS}; + +static const char *const CAN_ROWS[] = { + "..######..", + ".#......#.", + "##########", + "#........#", + "#.######.#", + "#.#....#.#", + "#.#.##.#.#", + "#.#....#.#", + "#.######.#", + "#........#", + "#........#", + "##########", + ".########.", +}; +static constexpr Sprite CAN{10, 13, CAN_ROWS}; + +} // namespace esphome::el512::steins diff --git a/esphome/el512-panel.yaml b/esphome/el512-panel.yaml index 6ebd9a9..35a1c89 100644 --- a/esphome/el512-panel.yaml +++ b/esphome/el512-panel.yaml @@ -19,6 +19,7 @@ esphome: 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)}; + id(sg_fonts) = {id(f_sg_nixie), id(f_sg_title), id(f_cp_mono), id(f_cp_code), id(f_ks_pixel), id(f_sg_jp)}; esp32: board: wemos_d1_mini32 @@ -61,11 +62,13 @@ globals: - id: np_updated type: uint32_t initial_value: "0" - # Themed pages (Cyberpunk, KonoSuba): their fonts and the home data they show. + # Themed pages (Cyberpunk, KonoSuba, Steins;Gate): their fonts and the home data they show. - id: cp_fonts type: el512::cyber::Fonts - id: ks_fonts type: el512::konosuba::Fonts + - id: sg_fonts + type: el512::steins::Fonts - id: cp_home type: el512::HomeData @@ -162,7 +165,7 @@ font: - file: "gfonts://Share Tech Mono" id: f_cp_code size: 22 - glyphs: " 0123456789ABCDEF." + glyphs: " 0123456789ABCDEF.%" # KonoSuba pages - file: "gfonts://Cinzel@700" id: f_ks_title @@ -192,6 +195,19 @@ font: id: f_ks_jp_big size: 40 glyphs: "爆裂魔法" + # Steins;Gate pages (also use f_cp_mono, f_cp_code and f_ks_pixel) + - file: "gfonts://Nixie One" + id: f_sg_nixie + size: 110 + glyphs: "0123456789." + - file: "gfonts://Special Elite" + id: f_sg_title + size: 20 + glyphsets: [GF_Latin_Core] + - file: "gfonts://Noto Sans JP@700" + id: f_sg_jp + size: 18 + glyphs: "未来ガジェット研究所世界線" # --- Data from Home Assistant ------------------------------------------------- text_sensor: @@ -387,6 +403,10 @@ select: - KonoSuba Guild - KonoSuba Card - KonoSuba Battle + - Divergence Meter + - D-Mail + - Gadget Lab + - World Lines initial_option: Clock set_action: - lambda: |- @@ -407,6 +427,10 @@ select: 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); + else if (x == "Divergence Meter") p = id(page_sg_meter); + else if (x == "D-Mail") p = id(page_sg_dmail); + else if (x == "Gadget Lab") p = id(page_sg_lab); + else if (x == "World Lines") p = id(page_sg_world); id(panel).show_page(p); id(panel).update(); @@ -419,7 +443,8 @@ button: id(panel).play_still_alive(); id(panel).update(); -# Rotate pages; skip Still Alive (start it from HA) and Now playing when idle. +# Rotate pages in a random order, reshuffled every hour; Still Alive is left out +# (start it from HA) and Now playing is skipped when idle. interval: - interval: 1s then: @@ -427,12 +452,28 @@ 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 < 16; i++) { - id(panel).show_next_page(); - const auto *p = id(panel).get_active_page(); - const std::string &np = id(np_state).state; - if (p == id(page_still_alive)) continue; + static std::vector order; + static size_t pos = 0; + static int shuffled_hour = -1; + auto now = id(ha_time).now(); + const int hour = now.is_valid() ? now.hour : (int) (millis() / 3600000UL); + if (order.empty() || hour != shuffled_hour) { + shuffled_hour = hour; + order = {id(page_clock), id(page_forecast), id(page_agenda), id(page_dashboard), id(page_media), + id(page_plex), id(page_solar), id(page_cyber_hud), id(page_cyber_breach), id(page_cyber_scan), + id(page_cyber_split), id(page_ks_explosion), id(page_ks_guild), id(page_ks_card), + id(page_ks_battle), id(page_sg_meter), id(page_sg_dmail), id(page_sg_lab), id(page_sg_world)}; + for (size_t i = order.size() - 1; i > 0; i--) + std::swap(order[i], order[random_uint32() % (i + 1)]); + pos = order.size() - 1; // the next step starts at the front + } + const std::string &np = id(np_state).state; + for (size_t n = 0; n < order.size(); n++) { + pos = (pos + 1) % order.size(); + display::DisplayPage *p = order[pos]; if (p == id(page_media) && (np == "idle" || np.empty())) continue; + if (p == id(panel).get_active_page()) continue; // not twice in a row across a reshuffle + id(panel).show_page(p); break; } id(panel).update(); @@ -448,12 +489,14 @@ interval: d.solar = id(solar_power).state; d.solar_today = id(solar_today).state; d.agenda = id(agenda_events).state; + d.message = id(panel_message).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}; d.rooms[1] = {"Voorkamer", id(t_voorkamer).state}; d.rooms[2] = {"Patrick", id(t_patrick).state}; d.rooms[3] = {"Badkamer", id(t_badkamer).state}; + d.record(id(ha_time).now()); # --- The panel and its pages ---------------------------------------------------- display: @@ -465,7 +508,8 @@ display: 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, - page_ks_explosion, page_ks_guild, page_ks_card, page_ks_battle] + page_ks_explosion, page_ks_guild, page_ks_card, page_ks_battle, + page_sg_meter, page_sg_dmail, page_sg_lab, page_sg_world] animation_interval: 66ms on_page_change: - then: @@ -486,6 +530,10 @@ display: 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"; + else if (to == id(page_sg_meter)) name = "Divergence Meter"; + else if (to == id(page_sg_dmail)) name = "D-Mail"; + else if (to == id(page_sg_lab)) name = "Gadget Lab"; + else if (to == id(page_sg_world)) name = "World Lines"; if (id(page_select).current_option() != name) id(page_select).publish_state(name); pages: # 1. Clock, date and current weather @@ -695,3 +743,17 @@ display: - id: page_ks_battle lambda: |- el512::konosuba::party_battle(*id(panel), id(ks_fonts), id(cp_home)); + + # 17-20. Steins;Gate pages (components/el512/steins.cpp) + - id: page_sg_meter + lambda: |- + el512::steins::divergence_meter(*id(panel), id(sg_fonts), id(ha_time).now()); + - id: page_sg_dmail + lambda: |- + el512::steins::d_mail(*id(panel), id(sg_fonts), id(cp_home)); + - id: page_sg_lab + lambda: |- + el512::steins::gadget_lab(*id(panel), id(sg_fonts), id(cp_home)); + - id: page_sg_world + lambda: |- + el512::steins::world_lines(*id(panel), id(sg_fonts), id(cp_home), id(ha_time).now()); diff --git a/tools/konosuba_sprites.py b/tools/konosuba_sprites.py index 4a00b55..961289b 100644 --- a/tools/konosuba_sprites.py +++ b/tools/konosuba_sprites.py @@ -285,44 +285,39 @@ SPRITES["MEGUMIN_BLINK"] = list(SPRITES["MEGUMIN"]) SPRITES["MEGUMIN_BLINK"][17] = SPRITES["MEGUMIN"][17][:8] + ".." + SPRITES["MEGUMIN"][17][10:] -def write_header(path): +def write_header(path, sprites=SPRITES, title="KonoSuba", tool="konosuba_sprites.py", namespace="konosuba"): out = [ - "// Pixel art for the KonoSuba pages, generated by tools/konosuba_sprites.py;", + f"// Pixel art for the {title} pages, generated by tools/{tool};", "// edit the art there. Original 1-bit fan art, '#' = lit pixel.", "#pragma once", "", - "#include ", + '#include "el512_pages.h" // Sprite', "", - "namespace esphome::el512::konosuba {", - "", - "struct Sprite {", - " uint8_t w, h;", - " const char *const *rows;", - "};", + f"namespace esphome::el512::{namespace} {{", "", ] - for name, rows in SPRITES.items(): + 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") + out.append(f"}} // namespace esphome::el512::{namespace}") path.write_text("\n".join(out) + "\n", encoding="utf-8") -def write_preview(path, scale=6): +def write_preview(path, sprites=SPRITES, 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 + 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] + 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): @@ -333,16 +328,17 @@ def write_preview(path, scale=6): img.save(path) -def main(): +def main(sprites=SPRITES, title="KonoSuba", tool="konosuba_sprites.py", namespace="konosuba", doc=__doc__): + """Command line for a sprite file: writes the header, optionally a preview.""" 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 = argparse.ArgumentParser(description=doc, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--out", type=Path, default=root / f"components/el512/{namespace}_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) + write_header(args.out, sprites, title, tool, namespace) print(f"wrote {args.out}") if args.preview: - write_preview(args.preview) + write_preview(args.preview, sprites) print(f"wrote {args.preview}") diff --git a/tools/steins_sprites.py b/tools/steins_sprites.py new file mode 100644 index 0000000..41fcdd7 --- /dev/null +++ b/tools/steins_sprites.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Pixel art for the Steins;Gate pages (components/el512/steins.cpp). + +Original 1-bit fan art: '#' is a lit pixel. Writes +components/el512/steins_sprites.h and, with --preview, a magnified PNG. + + python tools/steins_sprites.py + python tools/steins_sprites.py --preview sprites.png +""" + +from konosuba_sprites import main, padded + +SPRITES = { + # Okabe in his lab coat, and with the coat flaring out. + "OKABE_A": [ + "......#######.........", + "....##########.#......", + "...############.......", + "..#############.#.....", + "..##.#.#.##.####......", + "..#.........###.......", + "..#..##..##..#........", + "..#...........#.......", + "..#.....#.....#.......", + "...#..#####..#........", + "....#.......#.........", + ".....#######..........", + "...###.....###........", + "..##..#...#..##.......", + ".##....#.#....##......", + ".#.#....#....#.#......", + ".#.#....#....#.#......", + ".#..#...#...#..#......", + "#...#...#...#...#.....", + "#...#...#...#...#.....", + "#..#....#....#..#.....", + "#..#....#....#..#.....", + "##.#....#....#.##.....", + "##.#....#....#.##.....", + "...#....#....#........", + "...#...###...#........", + "...#...#.#...#........", + "..#....#.#....#.......", + "..#....#.#....#.......", + ".#.....#.#.....#......", + ".#.....#.#.....#......", + "#......#.#......#.....", + "########.########.....", + ".......#.#............", + ".......#.#............", + "......##.##...........", + ], + "OKABE_B": [ + "......#######.........", + "....##########.#......", + "...############.......", + "..#############.#.....", + "..##.#.#.##.####......", + "..#.........###.......", + "..#..##..##..#........", + "..#...........#.......", + "..#.....#.....#.......", + "...#..#####..#........", + "....#.......#.........", + ".....#######..........", + "...###.....###........", + "..##..#...#..##.......", + ".##....#.#....##......", + ".#.#....#....#.##.....", + ".#.#....#....#.#.#....", + ".#..#...#...#..#..#...", + "#...#...#...#...#..#..", + "#...#...#...#...#...#.", + "#..#....#....#..#....#", + "#..#....#....#..#....#", + "##.#....#....#.##....#", + "##.#....#....#......#.", + "...#....#....#......#.", + "...#...###...#.....#..", + "...#...#.#...#.....#..", + "..#....#.#....#...#...", + "..#....#.#....#...#...", + ".#.....#.#.....#.#....", + ".#.....#.#.....##.....", + "#......#.#......#.....", + "########.########.....", + ".......#.#............", + ".......#.#............", + "......##.##...........", + ], + # Kurisu: long hair, shirt and tie, belt. + "KURISU": [ + ".....######.......", + "...##########.....", + "..############....", + "..##.#.##.#.##....", + "..#..........#....", + "..#..##..##..#....", + "..#..........#....", + "..#....#.....#....", + "..##..###...##....", + "..###......###....", + "..#.##....##.#....", + "..#..######..#....", + "..#.#..#..#..#....", + "..##..###..#.#....", + "..#....#....##....", + ".##...###...###...", + ".#.#...#...#..#...", + ".#.#.......#..#...", + ".#..#.....#...#...", + ".#..#######...#...", + "....#.....#.......", + "....#.....#.......", + "....##...##.......", + ".....#...#........", + ".....#...#........", + ".....#...#........", + ".....#...#........", + "....##...##.......", + ], + # Upa, upright and squashed mid-bounce. + "UPA_A": [ + "..#........#..", + "..##......##..", + "..#.######.#..", + ".#..........#.", + "#...#....#...#", + "#............#", + "#.....##.....#", + ".#..........#.", + "..##########..", + ], + "UPA_B": [ + "..............", + "...#......#...", + "..##.####.##..", + ".#..........#.", + "#...#....#...#", + "#.....##.....#", + "##..........##", + ".############.", + "..............", + ], + # The lab's favourite fizzy drink. + "CAN": [ + "..######..", + ".#......#.", + "##########", + "#........#", + "#.######.#", + "#.#....#.#", + "#.#.##.#.#", + "#.#....#.#", + "#.######.#", + "#........#", + "#........#", + "##########", + ".########.", + ], +} + +for _name in SPRITES: + SPRITES[_name] = padded(SPRITES[_name]) + +if __name__ == "__main__": + main(SPRITES, "Steins;Gate", "steins_sprites.py", "steins", __doc__)