#include "cyberpunk.h" #include #include #include #include #include #include #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(u, PATH * PICK); if (solve > SELECT_AT) v.remain = std::max(0.0f, TIME_LIMIT - (solve - SELECT_AT) / 1000.0f); v.pick = std::min(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(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