Public Access
Netrunner HUD, self-solving breach protocol, optics boot-and-scan and a split HUD with a mini breach matrix, all in the page rotation. The el512 display gains animated_pages / animation_interval (redraw ~15 fps while shown), page_age_ms() as animation clock, and shift_rows() / invert_rect() for glitch effects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
688 lines
25 KiB
C++
688 lines
25 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 -----------------------------------------------------------
|
|
|
|
// Integer hash for repeatable pseudo-random effects (lowbias32).
|
|
uint32_t hash(uint32_t x) {
|
|
x ^= x >> 16;
|
|
x *= 0x7feb352d;
|
|
x ^= x >> 15;
|
|
x *= 0x846ca68b;
|
|
x ^= x >> 16;
|
|
return x;
|
|
}
|
|
uint32_t hash(uint32_t a, uint32_t b) { return hash(a * 0x9e3779b9u ^ hash(b)); }
|
|
|
|
std::string str(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
|
std::string str(const char *fmt, ...) {
|
|
char buf[96];
|
|
va_list args;
|
|
va_start(args, fmt);
|
|
vsnprintf(buf, sizeof(buf), fmt, args);
|
|
va_end(args);
|
|
return buf;
|
|
}
|
|
|
|
std::string upper(std::string s) {
|
|
for (auto &c : s)
|
|
c = (char) toupper((unsigned char) c);
|
|
return s;
|
|
}
|
|
|
|
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); }
|
|
|
|
int text_width(Display &it, display::BaseFont *font, const char *text) {
|
|
int x, y, w, h;
|
|
it.get_text_bounds(0, 0, text, font, TextAlign::TOP_LEFT, &x, &y, &w, &h);
|
|
return w;
|
|
}
|
|
|
|
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++) {
|
|
const int x = 264 + (i % 2) * 124, y = 192 + (i / 2) * 34;
|
|
it.print(x, y, f.label, TextAlign::BASELINE_LEFT, el512::fit(f.label, d.rooms[i].name, 62).c_str());
|
|
it.print(x + 116, y, f.value, TextAlign::BASELINE_RIGHT, 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, "BREACH 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, 54, f.mono, TextAlign::BASELINE_LEFT, "CODE MATRIX");
|
|
draw_matrix(it, {10, 62, 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
|