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 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-18 23:58:34 +02:00
1 parent e9b91c5628
commit 1e8a9a5958
11 files changed
+1056 -75

No files matched your search

+33 -5
View File
@@ -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
+1
View File
@@ -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"
+70
View File
@@ -2,6 +2,7 @@
// Home Assistant values they show, and small drawing helpers.
#pragma once
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdarg>
@@ -10,6 +11,7 @@
#include <string>
#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<int>(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
-35
View File
@@ -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<int>(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);
+1 -6
View File
@@ -2,15 +2,10 @@
// edit the art there. Original 1-bit fan art, '#' = lit pixel.
#pragma once
#include <stdint.h>
#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[] = {
"...............####.........",
"..............######........",
+497
View File
@@ -0,0 +1,497 @@
#include "steins.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <vector>
#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<std::string> wrap(Display &it, display::BaseFont *font, const std::string &text, int width,
size_t max_lines) {
std::vector<std::string> 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<size_t>(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<size_t>(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
+36
View File
@@ -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
+164
View File
@@ -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
+70 -8
View File
@@ -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();
static std::vector<display::DisplayPage *> 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;
if (p == id(page_still_alive)) continue;
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());
+17 -21
View File
@@ -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 <stdint.h>",
'#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}")
+167
View File
@@ -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__)