Files
planar-EL512.256-H/components/el512/cyberpunk.cpp
T
mangavalkandClaude Opus 5.5 d2e81a5a36 Add a PC renderer for the themed pages; fix overlapping layouts
tools/render_pages.py compiles the page code natively with g++ against
ESPHome's own display and font code and the font bitmaps of the last
`esphome compile`, and draws pages to PNG at chosen moments with sample
data (normal, empty, alert, busy), as a contact sheet, or as one example
per page in docs/images/pages (now shown in the README).

Fixes found with it: Cyber HUD zone names ran into the temperatures,
the breach header label hit the tag and the column marker the matrix
label; KonoSuba subtitle, guild weather icon, cabbage text and all-day
deadline, and hills through the party; Steins;Gate D-mail recipient, the
microwave display, the gadget card value and world line labels; LCARS
room rows, readout bars and data cascade, warp core readouts, and the
captain's log weather wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:46:31 +02:00

658 lines
24 KiB
C++

#include "cyberpunk.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <cstdlib>
#include "el512_display.h"
#include "esphome/core/hal.h"
namespace esphome::el512::cyber {
using display::COLOR_OFF;
using display::COLOR_ON;
using display::Display;
using display::TextAlign;
namespace {
constexpr int W = 512, H = 256;
// --- Small helpers -----------------------------------------------------------
using namespace fx;
std::string temp_text(float v, const char *unit = "°") { return std::isnan(v) ? "--" : str("%.1f%s", v, unit); }
std::string watts_text(float v) { return std::isnan(v) ? "--" : watts(v); }
const char *const CODES[] = {"1C", "55", "BD", "E9", "7A", "FF"};
constexpr int CODE_COUNT = 6;
std::string hex_noise(uint32_t seed, int pairs) {
std::string s;
for (int i = 0; i < pairs; i++) {
if (i > 0)
s += ' ';
s += CODES[hash(seed, i) % CODE_COUNT];
}
return s;
}
// --- Graphic vocabulary -------------------------------------------------------
// Frame with the top-left and bottom-right corners cut off at 45 degrees.
void cut_frame(Display &it, int x, int y, int w, int h, int cut = 8) {
const int r = x + w - 1, b = y + h - 1;
it.line(x + cut, y, r, y);
it.line(r, y, r, b - cut);
it.line(r, b - cut, r - cut, b);
it.line(r - cut, b, x, b);
it.line(x, b, x, y + cut);
it.line(x, y + cut, x + cut, y);
}
// Lit label with a slanted right end and the text cut out of it; returns the
// width including the slant.
int tag(Display &it, display::BaseFont *font, int x, int y, int h, const char *text) {
const int w = text_width(it, font, text) + 10;
it.filled_rectangle(x, y, w, h);
it.filled_triangle(x + w, y, x + w, y + h - 1, x + w + h - 1, y);
it.print(x + 5, y + h / 2, font, COLOR_OFF, TextAlign::CENTER_LEFT, text);
return w + h;
}
// Corner brackets around a rectangle.
void brackets(Display &it, int x, int y, int w, int h, int len) {
const int r = x + w - 1, b = y + h - 1;
it.horizontal_line(x, y, len);
it.vertical_line(x, y, len);
it.horizontal_line(r - len + 1, y, len);
it.vertical_line(r, y, len);
it.horizontal_line(x, b, len);
it.vertical_line(x, b - len + 1, len);
it.horizontal_line(r - len + 1, b, len);
it.vertical_line(r, b - len + 1, len);
}
// Segmented bar lit up to frac (0..1); unlit segments are a tick on the base.
void seg_bar(Display &it, int x, int y, int w, int h, float frac, int seg = 6) {
const int n = w / seg;
const int lit = std::isnan(frac) ? 0 : (int) std::lround(std::clamp(frac, 0.0f, 1.0f) * n);
for (int i = 0; i < n; i++) {
if (i < lit)
it.filled_rectangle(x + i * seg, y, seg - 2, h);
else
it.horizontal_line(x + i * seg, y + h - 1, seg - 2);
}
}
// Diagonal warning stripes that crawl sideways with phase.
void hatch(Display &it, int x, int y, int w, int h, int phase) {
for (int py = 0; py < h; py++)
for (int px = 0; px < w; px++)
if (((px + py + phase) & 7) < 3)
it.draw_pixel_at(x + px, y + py, COLOR_ON);
}
// Text scrolling right to left between x0 and x1, 40 px/s.
void ticker(Display &it, display::BaseFont *font, int x0, int x1, int baseline, uint32_t t, const std::string &text) {
const int span = text_width(it, font, text.c_str()) + (x1 - x0);
const int x = x1 - (int) ((t / 25) % (uint32_t) span);
it.start_clipping(x0, 0, x1, H);
it.print(x, baseline, font, TextAlign::BASELINE_LEFT, text.c_str());
it.end_clipping();
}
// Page intro: the drawing is revealed left to right behind a bright scan line.
void wipe_in(EL512Display &it, uint32_t t, uint32_t dur = 450) {
if (t >= dur)
return;
const int x = (int) (W * t / dur);
it.filled_rectangle(x, 0, W - x, H, COLOR_OFF);
it.vertical_line(x, 0, H);
it.vertical_line(x + 3, 0, H);
}
// Glitch: right after the page appears, then a short burst every few seconds
// where random bands of rows jump sideways and thin slivers flash inverted.
void glitch(EL512Display &it, uint32_t t) {
const uint32_t burst = t / 3700, in = t % 3700;
int bands = 0;
if (t < 350)
bands = 6;
else if (in < 200 && hash(burst, 7) % 3 != 0)
bands = 1 + (int) (hash(burst, 1) % 3);
const uint32_t frame = t / 50;
for (int i = 0; i < bands; i++) {
const uint32_t r = hash(frame, i);
const int y = (int) (r % H), h = 2 + (int) ((r >> 9) % 14), dx = (int) ((r >> 16) % 49) - 24;
it.shift_rows(y, h, dx);
if ((r >> 25) % 3 == 0)
it.invert_rect((int) ((r >> 4) % 400), y, 40 + (int) ((r >> 12) % 120), 2);
}
}
// Random flickering blocks, used while a page "powers up".
void noise_blocks(Display &it, uint32_t t, int count) {
const uint32_t frame = t / 40;
for (int i = 0; i < count; i++) {
const uint32_t r = hash(frame, 100 + i);
it.filled_rectangle((int) (r % W), (int) ((r >> 9) % H), 4 + (int) ((r >> 18) % 60), 1 + (int) ((r >> 24) % 6));
}
}
// --- Breach protocol -----------------------------------------------------------
//
// A random code matrix with a solution path built in: pick 0 is in the top
// row, then picks alternate between the column and the row of the previous
// one, as in the game. The daemons are runs of codes along that path, so the
// path uploads all of them. Everything is a function of the time since the
// puzzle started, so nothing has to be stored between frames.
constexpr int PATH = 6; // picks, and buffer size
constexpr int MAX_N = 6; // matrix size
constexpr uint32_t INTRO = 1200; // codes settle into the matrix
constexpr uint32_t PICK = 1000; // per pick: cursor moves, then selects
constexpr uint32_t SELECT_AT = 620;
constexpr uint32_t RESULT = 3200; // "upload complete"
constexpr uint32_t CYCLE = INTRO + PATH * PICK + RESULT;
constexpr float TIME_LIMIT = 30.0f; // s on the breach timer
struct Daemon {
const char *name;
uint8_t start, len; // run of path codes
};
constexpr Daemon DAEMONS[] = {{"DATAMINE_V1", 0, 2}, {"ICEPICK", 1, 3}, {"MASS_VULNERABILITY", 3, 3}};
constexpr int DAEMON_COUNT = 3;
struct Puzzle {
int n{MAX_N};
uint8_t grid[MAX_N][MAX_N]{};
uint8_t pr[PATH]{}, pc[PATH]{}; // solution path
uint8_t code(int k) const { return this->grid[this->pr[k]][this->pc[k]]; }
// Index of (r, c) on the path among the first `count` picks, or -1.
int path_index(int r, int c, int count) const {
for (int k = 0; k < count; k++)
if (this->pr[k] == r && this->pc[k] == c)
return k;
return -1;
}
void generate(uint32_t seed, int size) {
this->n = size;
uint32_t s = seed;
auto rnd = [&s](int m) {
s = hash(s + 0x6d2b79f5u);
return (int) (s % (uint32_t) m);
};
for (int r = 0; r < size; r++)
for (int c = 0; c < size; c++)
this->grid[r][c] = (uint8_t) rnd(CODE_COUNT);
for (bool ok = false; !ok;) {
this->pr[0] = 0;
this->pc[0] = (uint8_t) rnd(size);
ok = true;
for (int k = 1; k < PATH && ok; k++) {
ok = false;
for (int attempt = 0; attempt < 24 && !ok; attempt++) {
int r = this->pr[k - 1], c = this->pc[k - 1];
if (k & 1)
r = rnd(size); // odd picks move along the column
else
c = rnd(size); // even picks move along the row
if (this->path_index(r, c, k) >= 0)
continue;
this->pr[k] = (uint8_t) r;
this->pc[k] = (uint8_t) c;
ok = true;
}
}
}
}
};
// What the breach looks like at time `local` into the puzzle.
struct BreachView {
uint32_t local{0};
int pick{0}; // pick in progress, PATH when solved
int sel{0}; // codes in the buffer
int hover{-1}; // cursor index along the active line, -1 = none
bool flash{false}; // the cell just selected is blinking
bool row_line{true};
int line{0}; // active row or column
float remain{TIME_LIMIT};
bool settled(int col) const { return this->local >= 250 + (uint32_t) col * 150; }
bool done() const { return this->pick >= PATH; }
};
BreachView breach_view(const Puzzle &p, uint32_t local) {
BreachView v;
v.local = local;
if (local < INTRO)
return v;
const uint32_t u = local - INTRO;
const uint32_t solve = std::min<uint32_t>(u, PATH * PICK);
if (solve > SELECT_AT)
v.remain = std::max(0.0f, TIME_LIMIT - (solve - SELECT_AT) / 1000.0f);
v.pick = std::min<int>(u / PICK, PATH);
if (v.done()) {
v.sel = PATH;
return v;
}
const int k = v.pick;
const uint32_t w = u % PICK;
v.sel = k + (w >= SELECT_AT ? 1 : 0);
v.row_line = (k & 1) == 0;
v.line = v.row_line ? (k == 0 ? 0 : p.pr[k - 1]) : p.pc[k - 1];
const int target = v.row_line ? p.pc[k] : p.pr[k];
const int from = k == 0 ? 0 : (v.row_line ? p.pc[k - 1] : p.pr[k - 1]);
const int steps = (int) (w / 110);
if (w >= SELECT_AT) {
v.hover = target;
v.flash = w < SELECT_AT + 300 && ((w / 50) & 1);
} else if (target == from) {
// Already on it: look at the neighbour first, then come back.
const int alt = from + 1 < p.n ? from + 1 : from - 1;
v.hover = (steps == 1 || steps == 2) ? alt : from;
} else {
const int d = target - from;
v.hover = from + (d > 0 ? 1 : -1) * std::min(steps, std::abs(d));
}
return v;
}
struct MatrixLayout {
int x, y; // top-left of the first cell
int cw, ch; // cell size
display::BaseFont *font;
};
void draw_matrix(Display &it, const MatrixLayout &L, const Puzzle &p, const BreachView &v) {
const int n = p.n;
// Active row or column.
if (v.local >= INTRO && !v.done()) {
if (v.row_line) {
const int y = L.y + v.line * L.ch;
it.rectangle(L.x - 3, y, n * L.cw + 6, L.ch);
it.filled_triangle(L.x - 9, y + L.ch / 2 - 4, L.x - 9, y + L.ch / 2 + 4, L.x - 5, y + L.ch / 2);
} else {
const int x = L.x + v.line * L.cw;
it.rectangle(x, L.y - 3, L.cw, n * L.ch + 6);
it.filled_triangle(x + L.cw / 2 - 4, L.y - 9, x + L.cw / 2 + 4, L.y - 9, x + L.cw / 2, L.y - 5);
}
}
const uint32_t frame = v.local / 60;
for (int r = 0; r < n; r++) {
for (int c = 0; c < n; c++) {
const int x = L.x + c * L.cw, y = L.y + r * L.ch;
const int cx = x + L.cw / 2, cy = y + L.ch / 2;
const bool on_line = v.local >= INTRO && !v.done() && (v.row_line ? r == v.line : c == v.line);
const bool hovered = on_line && v.hover == (v.row_line ? c : r);
const int idx = p.path_index(r, c, v.sel);
const bool used = idx >= 0;
const char *code = CODES[v.settled(c) ? p.grid[r][c] : hash(frame, r * MAX_N + c) % CODE_COUNT];
if (v.local < INTRO && !v.settled(c) && hash(frame, 50 + r * MAX_N + c) % 3 == 0)
continue; // still flickering in
if (hovered && !(used && v.flash)) {
it.filled_rectangle(x + 3, y + 2, L.cw - 6, L.ch - 4);
if (!used || idx == v.sel - 1)
it.print(cx, cy, L.font, COLOR_OFF, TextAlign::CENTER, code);
} else if (used) {
brackets(it, cx - 8, cy - 7, 16, 14, 4);
} else {
it.print(cx, cy, L.font, TextAlign::CENTER, code);
}
}
}
}
// Buffer slots: the codes picked so far.
void draw_buffer(Display &it, display::BaseFont *font, int x, int y, int slot, int h, const Puzzle &p, int sel) {
for (int i = 0; i < PATH; i++) {
const int sx = x + i * (slot + 4);
if (i < sel) {
it.rectangle(sx, y, slot, h);
it.print(sx + slot / 2, y + h / 2, font, TextAlign::CENTER, CODES[p.code(i)]);
} else {
// Dashed empty slot.
for (int d = 0; d < slot; d += 4) {
it.horizontal_line(sx + d, y, 2);
it.horizontal_line(sx + d, y + h - 1, 2);
}
for (int d = 0; d < h; d += 4) {
it.vertical_line(sx, y + d, 2);
it.vertical_line(sx + slot - 1, y + d, 2);
}
}
}
}
int daemon_progress(const Daemon &dm, int sel) { return std::clamp(sel - (int) dm.start, 0, (int) dm.len); }
// A different puzzle every cycle and every time the page is shown.
uint32_t breach_seed(EL512Display &it, uint32_t t, uint32_t salt) {
return hash(t / CYCLE, salt ^ it.page_shown_at());
}
// --- Shared HUD pieces -----------------------------------------------------------
// Big clock, seconds bar and date line in the top-left corner.
void clock_block(Display &it, const Fonts &f, ESPTime now, int width) {
if (!now.is_valid()) {
it.print(4, 104, f.value, TextAlign::BASELINE_LEFT, "--:--");
return;
}
it.strftime(2, 110, f.huge, TextAlign::BASELINE_LEFT, "%H:%M", now);
seg_bar(it, 4, 120, width - 4, 6, now.second / 59.0f, 8);
it.print(4, 144, f.mono, TextAlign::BASELINE_LEFT, upper(now.strftime("%a %d.%m.%Y // WK %V")).c_str());
}
const char *grid_word(float grid) { return std::isnan(grid) ? "GRID" : (grid > 0 ? "GRID IMPORT" : "GRID EXPORT"); }
} // namespace
// --- 1. Netrunner HUD: live home data in Cyberpunk dress ------------------------
void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.huge == nullptr)
return;
const uint32_t t = it.page_age_ms();
// Top bar: tag, stepped rule, status line.
const int tw = tag(it, f.label, 0, 0, 18, "NETRUNNER // HUD");
it.horizontal_line(tw + 2, 17, 330 - tw - 2);
it.line(330, 17, 340, 7);
it.horizontal_line(340, 7, 172);
const uint32_t up = millis() / 1000;
const std::string status =
str("LINK UP %02u:%02u:%02u", (unsigned) (up / 3600 % 100), (unsigned) (up / 60 % 60), (unsigned) (up % 60));
it.print(508, 21, f.mono, TextAlign::BASELINE_RIGHT, status.c_str());
if ((t / 500) % 2)
it.filled_rectangle(508 - text_width(it, f.mono, status.c_str()) + text_width(it, f.mono, "LINK "), 12, 8, 8);
// Left: clock, environment.
clock_block(it, f, now, 244);
cut_frame(it, 0, 154, 244, 82);
tag(it, f.mono, 0, 154, 14, "ENV");
it.print(12, 202, f.value, TextAlign::BASELINE_LEFT, temp_text(d.temp, "°C").c_str());
it.print(12, 226, f.label, TextAlign::BASELINE_LEFT, upper(weather_text(d.weather)).c_str());
if (!std::isnan(d.humidity))
it.printf(234, 186, f.mono, TextAlign::BASELINE_RIGHT, "HUM %.0f%%", d.humidity);
if (!std::isnan(d.wind))
it.printf(234, 204, f.mono, TextAlign::BASELINE_RIGHT, "WND %.0f km/h", d.wind);
// Right top: power.
cut_frame(it, 254, 26, 258, 118);
tag(it, f.mono, 254, 26, 14, "POWER GRID");
struct Row {
const char *name;
float w;
};
const Row rows[3] = {{"SOLAR", d.solar}, {"HOUSE", d.house}, {grid_word(d.grid), std::fabs(d.grid)}};
for (int i = 0; i < 3; i++) {
const int y = 60 + i * 30;
it.print(264, y, f.label, TextAlign::BASELINE_LEFT, rows[i].name);
it.print(502, y, f.label, TextAlign::BASELINE_RIGHT, watts_text(rows[i].w).c_str());
seg_bar(it, 264, y + 4, 240, 7, rows[i].w / 5000.0f);
}
// Right bottom: zones.
cut_frame(it, 254, 150, 258, 86);
tag(it, f.mono, 254, 150, 14, "ZONES");
for (int i = 0; i < 4; i++) {
// Name on top, temperature below it.
const int x = 264 + (i % 2) * 124, y = 164 + (i / 2) * 36;
it.print(x, y + 11, f.mono, TextAlign::BASELINE_LEFT, el512::fit(f.mono, upper(d.rooms[i].name), 114).c_str());
it.print(x, y + 33, f.value, TextAlign::BASELINE_LEFT, temp_text(d.rooms[i].value).c_str());
}
// Bottom: ticker.
tag(it, f.mono, 0, 241, 14, ">>");
const std::string feed = "CONNECTION STABLE // SOLAR " + watts_text(d.solar) + " // LOAD " + watts_text(d.house) +
" // OUTSIDE " + temp_text(d.temp, " C") + " // ICE NOMINAL // " +
hex_noise(t / 8000, 10) + " // ";
ticker(it, f.mono, 34, W, 253, t, feed);
wipe_in(it, t);
glitch(it, t);
}
// --- 2. Breach protocol: a code matrix that solves itself ------------------------
void breach_protocol(EL512Display &it, const Fonts &f) {
if (f.code == nullptr)
return;
const uint32_t t = it.page_age_ms();
Puzzle p;
p.generate(breach_seed(it, t, 1), 6);
const BreachView v = breach_view(p, t % CYCLE);
// Header and timer.
tag(it, f.title, 0, 0, 24, "BREACH PROTOCOL");
it.print(420, 18, f.label, TextAlign::BASELINE_RIGHT, "TIME REMAINING");
cut_frame(it, 428, 0, 84, 26, 6);
const bool low = v.remain < 10.0f && (t / 250) % 2;
if (!low)
it.printf(470, 13, f.code, TextAlign::CENTER, "%05.2f", v.remain);
it.filled_rectangle(0, 30, (int) (W * v.remain / TIME_LIMIT), 3);
// Code matrix.
cut_frame(it, 0, 40, 296, 216);
it.print(10, 52, f.mono, TextAlign::BASELINE_LEFT, "CODE MATRIX");
draw_matrix(it, {10, 64, 46, 31, f.code}, p, v);
// Buffer.
it.print(304, 54, f.mono, TextAlign::BASELINE_LEFT, "BUFFER");
draw_buffer(it, f.code, 304, 60, 30, 28, p, v.sel);
// Daemons to upload.
it.print(304, 110, f.mono, TextAlign::BASELINE_LEFT, "SEQUENCES TO UPLOAD");
for (int i = 0; i < DAEMON_COUNT; i++) {
const Daemon &dm = DAEMONS[i];
const int y = 118 + i * 44;
const int got = daemon_progress(dm, v.sel);
for (int j = 0; j < dm.len; j++) {
const int x = 304 + j * 30;
const char *code = CODES[p.code(dm.start + j)];
if (j < got) {
it.filled_rectangle(x, y, 26, 18);
it.print(x + 13, y + 9, f.mono, COLOR_OFF, TextAlign::CENTER, code);
} else {
it.rectangle(x, y, 26, 18);
it.print(x + 13, y + 9, f.mono, TextAlign::CENTER, code);
}
}
if (got == dm.len) {
tag(it, f.mono, 304, y + 22, 15, dm.name);
it.print(508, y + 13, f.mono, TextAlign::BASELINE_RIGHT, "INSTALLED");
} else {
it.print(304, y + 34, f.label, TextAlign::BASELINE_LEFT, dm.name);
}
}
// Result.
if (v.done()) {
const uint32_t r = v.local - INTRO - PATH * PICK;
if (r > 150 || (r / 50) % 2) {
it.filled_rectangle(16, 116, 264, 60, COLOR_OFF);
it.filled_rectangle(20, 120, 256, 52);
it.print(148, 140, f.title, COLOR_OFF, TextAlign::CENTER, "UPLOAD COMPLETE");
it.printf(148, 162, f.mono, COLOR_OFF, TextAlign::CENTER, "%d/%d DAEMONS INSTALLED", DAEMON_COUNT,
DAEMON_COUNT);
}
}
wipe_in(it, t);
glitch(it, t);
}
// --- 3. Optics: boot log, calibration, then a scan of the house -------------------
void optics_scan(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.huge == nullptr)
return;
const uint32_t t = it.page_age_ms();
constexpr uint32_t BOOT = 350, CALIB = 3900, SCAN = 5000;
if (t < BOOT) {
noise_blocks(it, t, 16);
glitch(it, t);
return;
}
if (t < CALIB) {
// Boot log, typed at ~110 characters per second.
tag(it, f.title, 0, 0, 22, "OPTICS // BOOT");
const std::string lines[] = {
"BIOS CHECKSUM ............. OK",
"NEURAL LINK HANDSHAKE ..... OK",
"ICE SUBSYSTEM ............. ARMED",
"OPTICAL ZOOM 1X-8X ........ OK",
"AMBIENT TEMP .............. " + temp_text(d.temp, " C"),
"SOLAR TAP ................. " + watts_text(d.solar),
"LOCAL TIME ................ " + (now.is_valid() ? now.strftime("%H:%M:%S") : std::string("SYNC")),
"> ALL SYSTEMS NOMINAL",
};
size_t budget = (t - BOOT) * 110 / 1000;
int y = 46;
for (const auto &line : lines) {
const size_t n = std::min(budget, line.size());
it.print(8, y, f.mono, TextAlign::BASELINE_LEFT, line.substr(0, n).c_str());
if (n < line.size()) {
if ((t / 200) % 2)
it.filled_rectangle(8 + text_width(it, f.mono, line.substr(0, n).c_str()) + 1, y - 10, 7, 12);
break;
}
budget -= n;
y += 24;
}
// Load bars.
const char *bars[] = {"MEM", "CPU", "SYNC"};
for (int i = 0; i < 3; i++) {
const float frac = std::clamp(((float) t - BOOT - i * 500) / (2600.0f - i * 300), 0.0f, 1.0f);
const int y0 = 60 + i * 56;
it.print(330, y0, f.label, TextAlign::BASELINE_LEFT, bars[i]);
it.printf(508, y0, f.value, TextAlign::BASELINE_RIGHT, "%d%%", (int) (frac * 100));
seg_bar(it, 330, y0 + 8, 180, 10, frac);
}
it.vertical_line(316, 40, 200);
hatch(it, 330, 232, 180, 12, (int) (t / 60));
glitch(it, t);
return;
}
// Reticle: brackets shrink from the full screen onto the target.
const int cx = 140, cy = 134;
const int bx = 26, by = 24, bw = 228, bh = 224;
if (t < SCAN) {
const float k = (float) (t - CALIB) / (SCAN - CALIB);
const float e = 1 - (1 - k) * (1 - k);
brackets(it, (int) (bx * e), (int) (by * e), (int) (W + (bw - W) * e), (int) (H + (bh - H) * e), 24);
if ((t / 150) % 2)
it.print(256, 128, f.title, TextAlign::CENTER, "CALIBRATING");
glitch(it, t);
return;
}
const uint32_t s = t - SCAN;
brackets(it, bx, by, bw, bh, 24);
tag(it, f.mono, bx, by - 18, 15, "TARGET LOCK");
it.circle(cx, cy, 92);
it.circle(cx, cy, 64);
const int rot = (int) (s / 30) % 360;
for (int a = 0; a < 360; a += 30)
it.line_at_angle(cx, cy, a + rot, 86, 98);
for (int a = 0; a < 360; a += 90)
it.line_at_angle(cx, cy, a - rot / 2, 64, 72);
it.line_at_angle(cx, cy, (int) (s / 8) % 360, 0, 62); // radar sweep
it.horizontal_line(cx - 112, cy, 16);
it.horizontal_line(cx + 96, cy, 16);
it.vertical_line(cx, cy - 110, 14);
it.vertical_line(cx, cy + 96, 14);
if (now.is_valid()) {
it.filled_rectangle(cx - 52, cy - 18, 104, 30, COLOR_OFF);
it.strftime(cx, cy + 9, f.value, TextAlign::BASELINE_CENTER, "%H:%M", now);
it.strftime(cx, cy + 28, f.mono, TextAlign::BASELINE_CENTER, ":%S", now);
}
// Scan results, typed in row by row.
cut_frame(it, 272, 24, 240, 214);
tag(it, f.mono, 272, 24, 15, "SCAN // HOME");
const bool busy = !std::isnan(d.house) && d.house > 3000;
const std::string rows[][2] = {
{"CLASS", "RESIDENTIAL"},
{"THREAT", busy ? "MODERATE" : "LOW"},
{"TEMP", temp_text(d.temp, " C")},
{"WEATHER", upper(weather_text(d.weather))},
{"HUMIDITY", std::isnan(d.humidity) ? "--" : str("%.0f %%", d.humidity)},
{"SOLAR", watts_text(d.solar)},
{"LOAD", watts_text(d.house)},
{"GRID", std::isnan(d.grid) ? "--" : (d.grid > 0 ? "+" : "-") + watts(std::fabs(d.grid))},
};
const int shown = std::min<int>(8, (int) (s / 220));
for (int i = 0; i < shown; i++) {
const int y = 62 + i * 20;
it.print(282, y, f.mono, TextAlign::BASELINE_LEFT, rows[i][0].c_str());
it.print(376, y, f.mono, TextAlign::BASELINE_LEFT, el512::fit(f.mono, rows[i][1], 128).c_str());
}
if (shown < 8 && (t / 120) % 2)
it.filled_rectangle(282, 52 + shown * 20, 7, 12);
if (shown == 8) {
hatch(it, 282, 220, 100, 10, (int) (t / 60));
it.print(500, 230, f.mono, TextAlign::BASELINE_RIGHT, (s / 600) % 2 ? "SCAN COMPLETE" : "");
}
glitch(it, t);
}
// --- 4. Split: compact HUD on the left, a small breach on the right ----------------
void split_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
if (f.huge == nullptr)
return;
const uint32_t t = it.page_age_ms();
// Left: clock and four readouts.
clock_block(it, f, now, 270);
const std::string vals[4] = {temp_text(d.temp, "°C"), watts_text(d.solar), watts_text(d.house),
watts_text(std::fabs(d.grid))};
const char *names[4] = {"OUTSIDE", "SOLAR", "LOAD", grid_word(d.grid)};
for (int i = 0; i < 4; i++) {
const int x = 4 + (i % 2) * 140, y = 166 + (i / 2) * 46;
it.print(x, y, f.mono, TextAlign::BASELINE_LEFT, names[i]);
it.print(x, y + 28, f.value, TextAlign::BASELINE_LEFT, vals[i].c_str());
}
// Divider with a notch.
it.vertical_line(284, 0, 110);
it.line(284, 110, 292, 118);
it.vertical_line(292, 118, 138);
// Right: small breach.
Puzzle p;
p.generate(breach_seed(it, t, 2), 5);
const BreachView v = breach_view(p, t % CYCLE);
tag(it, f.mono, 300, 0, 15, "BREACH");
it.printf(508, 12, f.mono, TextAlign::BASELINE_RIGHT, "%05.2f", v.remain);
it.filled_rectangle(300, 18, (int) (208 * v.remain / TIME_LIMIT), 2);
draw_matrix(it, {310, 36, 40, 30, f.mono}, p, v);
draw_buffer(it, f.mono, 304, 194, 30, 22, p, v.sel);
int installed = 0;
for (const auto &dm : DAEMONS)
installed += daemon_progress(dm, v.sel) == dm.len;
it.print(304, 246, f.mono, TextAlign::BASELINE_LEFT, v.done() ? "UPLOAD COMPLETE" : "DAEMONS");
for (int i = 0; i < DAEMON_COUNT; i++) {
const int x = 470 + i * 14;
if (i < installed)
it.filled_rectangle(x, 236, 10, 10);
else
it.rectangle(x, 236, 10, 10);
}
wipe_in(it, t);
glitch(it, t);
}
} // namespace esphome::el512::cyber