Files
planar-EL512.256-H/components/el512/trek.cpp
T
mangavalkandClaude Opus 5.5 8f0662e939 Red alert: keep the warning text clear of the frame and each other
The top pill now stops before the wider ALERT CONDITION RED title, the
printer lines sit higher with room between them and the bottom pill, and
the sweeping rectangles are clipped to the area above those lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:03:04 +02:00

405 lines
17 KiB
C++

#include "trek.h"
#include <algorithm>
#include <cmath>
#include <vector>
#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::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());
}
const char *title = alert ? "ALERT CONDITION RED" : "SHIP STATUS";
// The top pill stops short of the title, which is wider in red alert.
const int pill_w = std::min(50, 508 - text_width(it, f.medium, title) - 8 - 306);
if (pill_w >= 18)
pill(it, 306, 0, pill_w, 18);
pill(it, 306, 238, 70, 18);
it.print(508, 22, f.medium, TextAlign::BASELINE_RIGHT, title);
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.
// The sweep stays between the frame, the title and the lines below it.
const int cx = 296, cy = 98;
const int phase = (int) (t % 900) * 120 / 900;
it.start_clipping(84, 30, 508, 164);
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));
}
it.end_clipping();
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, 170, f.medium, TextAlign::TOP_CENTER, d.printer == "error" ? "3D PRINTER ERROR" : "3D PRINTER PAUSED");
it.print(cx, 204, 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, 80, 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 = 148 + (i / 2) * 44;
it.print(x, y, f.small, TextAlign::TOP_LEFT, fit(f.small, upper(d.rooms[i].name), 104).c_str());
it.print(x, y + 14, 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 = 456 + col * 30, 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(196, y, f.small, TextAlign::TOP_RIGHT, rows[i].value.c_str());
readout(it, 0, y + 22, 196, 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<uint32_t>(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 " + lower(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