Public Access
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:
1 parent
e9b91c5628
commit
1e8a9a5958
11 files changed
+1057
-76
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = {
|
||||
"...............####.........",
|
||||
"..............######........",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user