#include "steins.h" #include #include #include #include #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 wrap(Display &it, display::BaseFont *font, const std::string &text, int width, size_t max_lines) { std::vector 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(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) { // Odd width so the dome (radius R, 2R + 1 pixels across) meets both walls. constexpr int TW = 57, 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; kept // inside the glass. it.start_clipping(x + 2, y + 2, x + TW - 2, y + TH - 14); 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); it.end_clipping(); } } // 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"); 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(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 { // The microwave's little display: "850W" or "12.0kW". const std::string lcd = std::isnan(d.house) ? "--" : d.house >= 1000 ? str("%.1fkW", d.house / 1000.0f) : str("%.0fW", d.house); it.print(MX + 209, MY + 23, f.mono, TextAlign::CENTER, lcd.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; // Number, name, value, then the description. it.rectangle(340, 38, 170, 140); it.rectangle(342, 40, 166, 136); it.printf(350, 44, f.title, TextAlign::TOP_LEFT, "NO.%s", g.no.c_str()); int y = 68; 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.print(350, y + 2, f.title, TextAlign::TOP_LEFT, g.value.c_str()); y += 26; it.horizontal_line(350, y, 150); y += 5; 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, h == 24 ? TextAlign::TOP_RIGHT : TextAlign::TOP_CENTER, "%02d:00", h); } // 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); // Labels on the left, where the chart stays empty until sunrise: Beta's // above its line, the others below theirs (Steins Gate and Beta are only a // few pixels apart). if (fl.div == 1.048596f) it.horizontal_line(PX0 + 1, y, PX1 - PX0); else dashed_hline(it, PX0 + 1, y, PX1 - PX0, 4, 4, phase); const bool above = fl.div > 1.1f; it.print(PX0 + 6, above ? y - 3 : y + 3, f.pixel, above ? TextAlign::BOTTOM_LEFT : TextAlign::TOP_LEFT, 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 - 40, PY1 - 10, 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