#include "trek.h" #include #include #include #include "el512_display.h" #include "esphome/core/hal.h" #include "trek_sprites.h" namespace esphome::el512::trek { 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}}; // --- LCARS shapes -------------------------------------------------------------------- // Bar with round ends. void pill(Display &it, int x, int y, int w, int h, Color c = COLOR_ON) { const int r = h / 2; it.filled_rectangle(x + r, y, w - 2 * r, h, c); it.filled_circle(x + r, y + r, r, c); it.filled_circle(x + w - 1 - r, y + r, r, c); } // Lit block with a dark label at its bottom right, as LCARS buttons have. void block(Display &it, display::BaseFont *font, int x, int y, int w, int h, const char *label) { it.filled_rectangle(x, y, w, h); if (label != nullptr) it.print(x + w - 4, y + h - 1, font, COLOR_OFF, TextAlign::BOTTOM_RIGHT, label); } // LCARS elbow: a vertical arm (vw wide) joined to a horizontal arm (hh thick) // by a large outer curve; the horizontal arm runs along the top (top = true) // or the bottom of the w x h box. void elbow(Display &it, int x, int y, int w, int h, int vw, int hh, bool top) { const int ro = vw * 3 / 4, ri = hh; const int by = top ? y : y + h - hh; // horizontal arm it.filled_rectangle(x, by, w, hh); it.filled_rectangle(x, y, vw, h); // Round the outer corner. const int oy = top ? y : y + h - ro; it.filled_rectangle(x, oy, ro, ro, COLOR_OFF); it.start_clipping(x, oy, x + ro, oy + ro); it.filled_circle(x + ro, top ? y + ro : y + h - 1 - ro, ro); it.end_clipping(); // Fillet the inner corner. const int iy = top ? by + hh : by - ri; it.filled_rectangle(x + vw, iy, ri, ri); it.start_clipping(x + vw, iy, x + vw + ri, iy + ri); it.filled_circle(x + vw + ri, top ? iy + ri : iy - 1, ri, COLOR_OFF); it.end_clipping(); } // Horizontal readout bar: dim track and a lit pill up to frac. void readout(Display &it, int x, int y, int w, int h, float frac) { it.horizontal_line(x, y + h / 2, w); if (!std::isnan(frac) && frac > 0) pill(it, x, y, std::max(h, (int) (w * std::clamp(frac, 0.0f, 1.0f))), h); } 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 kw(float w) { return std::isnan(w) ? "--" : upper(watts(w)); } std::string deg(float v) { return std::isnan(v) ? "--" : str("%.1f", v); } float stardate(ESPTime now) { if (!now.is_valid()) return 58000.0f; return 58000.0f + (now.year - 2026) * 1000.0f + (now.day_of_year - 1 + now.hour / 24.0f) * 1000.0f / 365.25f; } // LCARS-style serial numbers for blocks and the data cascade. std::string serial(uint32_t seed) { return str("%02u-%04u", (unsigned) (seed % 100), (unsigned) ((seed >> 8) % 10000)); } } // namespace bool red_alert(const HomeData &d) { return d.printer == "paused" || d.printer == "error"; } // --- 1. LCARS ship status (red alert when the printer needs a look) ---------------------- void lcars_status(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { if (f.large == nullptr) return; const uint32_t t = it.page_age_ms(); const bool alert = red_alert(d); const bool blink = (t / 500) % 2; // Frame: two elbows, sidebar blocks between them, bars along top and bottom. if (!alert || blink) { elbow(it, 0, 0, 300, 100, 72, 18, true); elbow(it, 0, 156, 300, 100, 72, 18, false); } else { elbow(it, 0, 0, 300, 100, 72, 6, true); elbow(it, 0, 156, 300, 100, 72, 6, false); } for (int i = 0; i < 2; i++) { const uint32_t r = hash(i, (t / 1700) * (i + 1)); if (!(alert ? blink : r % 7 == 0)) block(it, f.pixel, 0, 104 + i * 26, 72, 22, serial(hash(i, 4)).c_str()); } pill(it, 306, 0, 50, 18); pill(it, 306, 238, 70, 18); it.print(508, 22, f.medium, TextAlign::BASELINE_RIGHT, alert ? "ALERT CONDITION RED" : "SHIP STATUS"); it.printf(508, 256, f.small, TextAlign::BASELINE_RIGHT, "STARDATE %.1f", stardate(now)); if (alert) { // Red alert: bars sweep outward from the centre around the warning. const int cx = 292, cy = 128; const int phase = (int) (t % 900) * 120 / 900; for (int k = 0; k < 3; k++) { const int s = (phase + k * 40) % 120; it.rectangle(cx - 70 - s, cy - 30 - s / 3, 140 + 2 * s, 60 + 2 * (s / 3)); } if ((t % 1000) < 700) { it.filled_rectangle(cx - 110, cy - 34, 220, 68, COLOR_OFF); it.print(cx, cy + 4, f.large, TextAlign::CENTER, "RED ALERT"); } it.print(cx, 196, f.medium, TextAlign::TOP_CENTER, d.printer == "error" ? "3D PRINTER ERROR" : "3D PRINTER PAUSED"); it.print(cx, 222, f.small, TextAlign::TOP_CENTER, "REPORT TO THE FABRICATION BAY"); return; } // Power distribution. it.print(84, 24, f.small, TextAlign::TOP_LEFT, "POWER DISTRIBUTION"); const float grid = d.grid; struct Row { const char *name; float w; }; const Row rows[3] = {{"SOLAR", d.solar}, {"HOUSE", d.house}, {std::isnan(grid) || grid >= 0 ? "GRID IN" : "GRID OUT", std::fabs(grid)}}; for (int i = 0; i < 3; i++) { const int y = 48 + i * 24; block(it, f.small, 84, y, 66, 18, rows[i].name); readout(it, 156, y + 4, 90, 10, rows[i].w / 5000.0f); it.print(298, y + 18, f.small, TextAlign::BOTTOM_RIGHT, kw(rows[i].w).c_str()); } // Environmental. it.print(84, 128, f.small, TextAlign::TOP_LEFT, "ENVIRONMENTAL DECKS 1-4"); for (int i = 0; i < 4; i++) { const int x = 84 + (i % 2) * 110, y = 150 + (i / 2) * 40; it.print(x, y, f.small, TextAlign::TOP_LEFT, fit(f.small, upper(d.rooms[i].name), 104).c_str()); it.print(x, y + 16, f.medium, TextAlign::TOP_LEFT, deg(d.rooms[i].value).c_str()); } // Clock, sensors, fabrication bay. it.vertical_line(306, 26, 206); if (now.is_valid()) it.strftime(316, 22, f.large, TextAlign::TOP_LEFT, "%H:%M", now); it.print(316, 96, f.small, TextAlign::TOP_LEFT, "LONG RANGE SENSORS"); it.printf(316, 114, f.small, TextAlign::TOP_LEFT, "%s %s", deg(d.temp).c_str(), upper(weather_text(d.weather)).c_str()); if (!std::isnan(d.humidity)) it.printf(316, 132, f.small, TextAlign::TOP_LEFT, "HUMIDITY %.0f%%", d.humidity); it.print(316, 160, f.small, TextAlign::TOP_LEFT, "FABRICATION BAY"); it.print(316, 178, f.small, TextAlign::TOP_LEFT, d.printer.empty() ? "NO SIGNAL" : upper(d.printer).c_str()); // Data cascade: columns of numbers rolling over. for (int col = 0; col < 2; col++) { for (int row = 0; row < 9; row++) { const uint32_t r = hash(col * 31 + row, (t / 180) + row * 3); const int x = 446 + col * 32, y = 96 + row * 12; if (r % 5 == 0) continue; it.printf(x, y, f.pixel, TextAlign::TOP_LEFT, "%03u", (unsigned) (r % 1000)); } } } // --- 2. Lower Decks duty roster --------------------------------------------------------- void duty_roster(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { if (f.text == nullptr) return; const uint32_t t = it.page_age_ms(); pill(it, 0, 0, 40, 18); it.filled_rectangle(44, 0, 160, 18); it.print(508, 22, f.medium, TextAlign::BASELINE_RIGHT, "U.S.S. CERRITOS DUTY ROSTER"); struct Ensign { const char *name; const Sprite *sprite; const char *chore; }; static const Ensign ENSIGNS[4] = { {"MARINER", &MARINER, "Sneak off to the holodeck before anyone notices"}, {"BOIMLER", &BOIMLER, "Reorganize the cargo bay alphabetically, again"}, {"TENDI", &TENDI, "Run a full diagnostic of the science lab"}, {"RUTHERFORD", &RUTHERFORD, "Scrub the plasma conduits on deck 12"}, }; // The next agenda events go to the ensigns, in order; the rest get chores. const auto events = split(d.agenda, ';'); const int alarmed = (int) ((t / 5000) % 4); // who gets the "!" this time for (int i = 0; i < 4; i++) { const int x = i * 128, cx = x + 64; const Ensign &e = ENSIGNS[i]; const int bob = ((t / 350 + i) % 2) ? 1 : 0; sprite(it, *e.sprite, cx - 16, 28 - bob, 2); if (i == alarmed && (t % 5000) > 3000) { it.filled_rectangle(cx + 18, 26, 12, 18); it.print(cx + 24, 35, f.medium, COLOR_OFF, TextAlign::CENTER, "!"); } pill(it, cx - 54, 80, 108, 18); it.print(cx, 89, f.small, COLOR_OFF, TextAlign::CENTER, e.name); std::string title = e.chore, when = "BETA SHIFT"; if ((size_t) i < events.size()) { title = field(events[i], 2); const std::string day = field(events[i], 0), time = field(events[i], 1); when = upper(day) + " " + (time.empty() ? "ALL DAY" : time); } if (i > 0) it.vertical_line(x, 104, 126); it.print(x + 8, 106, f.pixel, TextAlign::TOP_LEFT, "ASSIGNMENT"); int y = 120; for (const auto &l : wrap(it, f.text, title, 112, 4)) { it.print(x + 8, y, f.text, TextAlign::TOP_LEFT, l.c_str()); y += 18; } it.print(x + 8, 206, f.pixel, TextAlign::TOP_LEFT, "REPORT"); it.print(x + 8, 218, f.pixel, TextAlign::TOP_LEFT, fit(f.pixel, when, 112).c_str()); } // Bottom bar with a scrolling notice. pill(it, 0, 238, 40, 18); const std::string notice = "ALL ENSIGNS REPORT TO YOUR STATIONS // SENIOR STAFF MEETING IN CONFERENCE ROOM 1 // " "REMINDER: THE HOLODECK IS NOT A NAP ROOM // "; const int span = text_width(it, f.small, notice.c_str()); const int nx = 46 - (int) ((t / 25) % (uint32_t) span); it.start_clipping(46, 236, W, H); it.print(nx, 256, f.small, TextAlign::BASELINE_LEFT, notice.c_str()); it.print(nx + span, 256, f.small, TextAlign::BASELINE_LEFT, notice.c_str()); it.end_clipping(); } // --- 3. Warp core and engineering --------------------------------------------------------- void warp_core(EL512Display &it, const Fonts &f, const HomeData &d) { if (f.large == nullptr) return; const uint32_t t = it.page_age_ms(); const float solar = std::isnan(d.solar) ? 0 : std::max(0.0f, d.solar); const bool breach = !std::isnan(d.grid) && d.grid > 3000; const float speed = 40 + std::min(solar, 5000.0f) / 5000.0f * 160 + (breach ? 120 : 0); // px/s // The core: a column of rings with plasma pulses rushing into the chamber. constexpr int X0 = 224, X1 = 288, CH0 = 104, CH1 = 152; it.vertical_line(X0, 0, H); it.vertical_line(X1, 0, H); for (int y = 4; y < H; y += 16) { if (y > CH0 - 8 && y < CH1 + 8) continue; it.horizontal_line(X0 - 6, y, X1 - X0 + 13); it.horizontal_line(X0 + 6, y + 6, X1 - X0 - 11); } const uint32_t travel = (uint32_t) (t * speed / 1000); for (int k = 0; k < 4; k++) { const int p = (int) ((travel + k * 26) % CH0); it.filled_rectangle(X0 + 4, p - 3, X1 - X0 - 7, 6); // from the top it.filled_rectangle(X0 + 4, H - p - 3, X1 - X0 - 7, 6); // from the bottom } // Reaction chamber. it.filled_rectangle(X0 - 16, CH0, X1 - X0 + 33, CH1 - CH0, COLOR_OFF); it.rectangle(X0 - 16, CH0, X1 - X0 + 33, CH1 - CH0); const int level = 7 + (int) (6 * std::sin(t * speed / 9000.0f)); for (int y = CH0 + 3; y < CH1 - 3; y++) for (int x = X0 - 13; x < X1 + 14; x++) if (BAYER[y & 3][x & 3] < level) it.draw_pixel_at(x, y, COLOR_ON); // Engineering readouts on the left. it.print(0, 0, f.medium, TextAlign::TOP_LEFT, "ENGINEERING"); const float containment = std::isnan(d.house) ? NAN : 1.0f - std::min(d.house, 5000.0f) / 5000.0f; struct Row { const char *name; std::string value; float frac; }; const Row rows[] = { {"WARP CORE OUTPUT", kw(solar), solar / 5000.0f}, {"CONTAINMENT FIELD", std::isnan(containment) ? "--" : str("%.0f%%", containment * 100), containment}, {"PLASMA INJECTORS", kw(std::fabs(d.grid)), std::fabs(d.grid) / 5000.0f}, }; for (int i = 0; i < 3; i++) { const int y = 36 + i * 44; it.print(0, y, f.small, TextAlign::TOP_LEFT, rows[i].name); it.print(204, y, f.small, TextAlign::TOP_RIGHT, rows[i].value.c_str()); readout(it, 0, y + 22, 200, 10, rows[i].frac); } it.print(0, 172, f.small, TextAlign::TOP_LEFT, "MATTER / ANTIMATTER 1:1"); it.print(0, 192, f.small, TextAlign::TOP_LEFT, "DILITHIUM MATRIX STABLE"); it.printf(0, 212, f.small, TextAlign::TOP_LEFT, "PULSE RATE %.0f", speed); // Rutherford with advice. sprite(it, RUTHERFORD, 382, 158 - ((t / 400) % 2), 3); const std::string tips[] = { "Core's purring! The sun's giving us " + kw(solar) + ".", "Containment's holding. Don't touch the blue button.", "If it glows brighter, that's normal. Probably.", "House is pulling " + kw(d.house) + ". Totally manageable.", }; const std::string &tip = breach ? "Uh... that's a LOT of grid draw. Is the oven on?" : tips[(t / 5000) % 4]; it.rectangle(306, 6, 204, 104); it.filled_triangle(400, 109, 420, 109, 410, 128); it.filled_triangle(402, 108, 418, 108, 410, 124, COLOR_OFF); int y = 14; for (const auto &l : wrap(it, f.text, typed(tip, t % 5000, 30), 188, 5)) { it.print(314, y, f.text, TextAlign::TOP_LEFT, l.c_str()); y += 18; } if (breach && (t / 300) % 2) { it.filled_rectangle(0, 236, 300, 20); it.print(150, 246, f.small, COLOR_OFF, TextAlign::CENTER, "CORE BREACH IMMINENT (IT'S THE OVEN)"); } } // --- 4. Captain's log under the viewscreen ----------------------------------------------- void captains_log(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) { if (f.text == nullptr) return; const uint32_t t = it.page_age_ms(); constexpr uint32_t CYCLE = 18000, DROP = 11000; const uint32_t c = t % CYCLE; const bool warp = c < DROP; constexpr int VX = 4, VY = 2, VW = 504, VH = 146, CX = 256, CY = 74; // Viewscreen: stars streak past at warp, then drift once we drop out. it.start_clipping(VX + 2, VY + 2, VX + VW - 2, VY + VH - 2); for (int i = 0; i < 70; i++) { const uint32_t r = hash(i, 1701); const float a = (r % 3600) / 10.0f; if (warp) { const float dmod = std::fmod((r >> 12) % 300 + c * (0.12f + (r >> 20) % 10 * 0.02f), 300.0f); int x0, y0, x1, y1; polar(CX, CY, dmod, a, &x0, &y0); polar(CX, CY, dmod + 4 + dmod / 5, a, &x1, &y1); it.line(x0, y0, x1, y1); } else { int x, y; polar(CX, CY, (float) ((r >> 12) % 300) + (c - DROP) * 0.004f, a, &x, &y); it.draw_pixel_at(x, y, COLOR_ON); } } if (!warp) { // A planet slides in after dropping out of warp. const int px = 600 - (int) std::min(c - DROP, 4000) / 25; it.filled_circle(px, 170, 110, COLOR_OFF); it.circle(px, 170, 110); for (int y = 60; y < VY + VH; y++) for (int x = px - 110; x < px; x++) if ((x - px) * (x - px) + (y - 170) * (y - 170) < 108 * 108 && BAYER[y & 3][x & 3] < 3) it.draw_pixel_at(x, y, COLOR_ON); } it.end_clipping(); const int bob = (t / 600) % 2; it.filled_rectangle(CX - 50, CY - 13 + bob, 100, 26, COLOR_OFF); sprite(it, SHIP, CX - 48, CY - 12 + bob, 2); it.rectangle(VX, VY, VW, VH); it.rectangle(VX + 1, VY + 1, VW - 2, VH - 2); if (c >= DROP && c < DROP + 120) it.invert_rect(VX, VY, VW, VH); // dropping out of warp it.print(VX + VW - 8, VY + VH - 6, f.pixel, TextAlign::BOTTOM_RIGHT, warp ? "WARP 6" : "FULL STOP"); // Captain's log, written from today's data. pill(it, 0, 154, 30, 18); it.printf(36, 154, f.small, TextAlign::TOP_LEFT, "CAPTAIN'S LOG, STARDATE %.1f", stardate(now)); std::string entry = "We are holding position at home."; if (!std::isnan(d.temp)) entry += str(" Outside it is %.0f degrees", d.temp) + (d.weather.empty() ? std::string(".") : " and " + weather_text(d.weather) + "."); if (!std::isnan(d.solar)) entry += " The solar collectors deliver " + watts(d.solar) + (std::isnan(d.house) ? std::string(".") : " while the replicators draw " + watts(d.house) + "."); const auto events = split(d.agenda, ';'); entry += events.empty() ? " No away missions are scheduled." : " Next mission: " + field(events[0], 2) + ", " + field(events[0], 0) + (field(events[0], 1).empty() ? "" : " " + field(events[0], 1)) + "."; if (d.printer == "printing") entry += " The fabrication bay is busy printing."; int y = 178; for (const auto &l : wrap(it, f.text, typed(entry, c, 34), 500, 4)) { it.print(6, y, f.text, TextAlign::TOP_LEFT, l.c_str()); y += 19; } } } // namespace esphome::el512::trek