Public Access
694 lines
27 KiB
C++
694 lines
27 KiB
C++
#include "konosuba.h"
|
|||
|
|
|
||
|
|
#include <algorithm>
|
||
|
|
#include <cmath>
|
||
|
|
#include <functional>
|
||
|
|
#include <vector>
|
||
|
|
|
||
|
|
#include "el512_display.h"
|
||
|
|
#include "esphome/core/hal.h"
|
||
|
|
#include "konosuba_sprites.h"
|
||
|
|
|
||
|
|
namespace esphome::el512::konosuba {
|
||
|
|
|
||
|
|
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}};
|
||
|
|
|
||
|
|
// --- Drawing helpers -----------------------------------------------------------
|
||
|
|
|
||
|
|
// Four-point twinkle.
|
||
|
|
void sparkle(Display &it, int x, int y, int r) {
|
||
|
|
it.horizontal_line(x - r, y, 2 * r + 1);
|
||
|
|
it.vertical_line(x, y - r, 2 * r + 1);
|
||
|
|
if (r >= 3) {
|
||
|
|
it.draw_pixel_at(x - 1, y - 1);
|
||
|
|
it.draw_pixel_at(x + 1, y - 1);
|
||
|
|
it.draw_pixel_at(x - 1, y + 1);
|
||
|
|
it.draw_pixel_at(x + 1, y + 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void diamond(Display &it, int cx, int cy, int r) {
|
||
|
|
it.filled_triangle(cx - r, cy, cx + r, cy, cx, cy - r);
|
||
|
|
it.filled_triangle(cx - r, cy, cx + r, cy, cx, cy + r);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Disc filled with an ordered dither, level 0 (empty) .. 16 (solid).
|
||
|
|
void dither_disc(Display &it, int cx, int cy, int r, int level) {
|
||
|
|
for (int dy = -r; dy <= r; dy++) {
|
||
|
|
const int span = (int) std::sqrt((float) (r * r - dy * dy));
|
||
|
|
const int y = cy + dy;
|
||
|
|
for (int x = cx - span; x <= cx + span; x++)
|
||
|
|
if (BAYER[y & 3][x & 3] < level)
|
||
|
|
it.draw_pixel_at(x, y);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Double frame with diamond studs in the corners.
|
||
|
|
void ornate_frame(Display &it, int x, int y, int w, int h) {
|
||
|
|
it.rectangle(x, y, w, h);
|
||
|
|
it.rectangle(x + 3, y + 3, w - 6, h - 6);
|
||
|
|
diamond(it, x + 1, y + 1, 5);
|
||
|
|
diamond(it, x + w - 2, y + 1, 5);
|
||
|
|
diamond(it, x + 1, y + h - 2, 5);
|
||
|
|
diamond(it, x + w - 2, y + h - 2, 5);
|
||
|
|
}
|
||
|
|
|
||
|
|
// JRPG window: rounded outer line, inner line, dark inside.
|
||
|
|
void rpg_window(Display &it, int x, int y, int w, int h) {
|
||
|
|
it.filled_rectangle(x, y, w, h, COLOR_OFF);
|
||
|
|
it.horizontal_line(x + 2, y, w - 4);
|
||
|
|
it.horizontal_line(x + 2, y + h - 1, w - 4);
|
||
|
|
it.vertical_line(x, y + 2, h - 4);
|
||
|
|
it.vertical_line(x + w - 1, y + 2, h - 4);
|
||
|
|
it.draw_pixel_at(x + 1, y + 1);
|
||
|
|
it.draw_pixel_at(x + w - 2, y + 1);
|
||
|
|
it.draw_pixel_at(x + 1, y + h - 2);
|
||
|
|
it.draw_pixel_at(x + w - 2, y + h - 2);
|
||
|
|
it.rectangle(x + 3, y + 3, w - 6, h - 6);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Paper note with a pin on top and a folded bottom-right corner.
|
||
|
|
void note(Display &it, int x, int y, int w, int h) {
|
||
|
|
constexpr int FOLD = 10;
|
||
|
|
it.filled_rectangle(x, y, w, h, COLOR_OFF);
|
||
|
|
it.horizontal_line(x, y, w);
|
||
|
|
it.vertical_line(x, y, h);
|
||
|
|
it.horizontal_line(x, y + h - 1, w - FOLD);
|
||
|
|
it.vertical_line(x + w - 1, y, h - FOLD);
|
||
|
|
it.line(x + w - FOLD, y + h - 1, x + w - 1, y + h - FOLD);
|
||
|
|
it.vertical_line(x + w - FOLD, y + h - FOLD, FOLD);
|
||
|
|
it.horizontal_line(x + w - FOLD, y + h - FOLD, FOLD);
|
||
|
|
it.filled_circle(x + w / 2, y + 2, 4);
|
||
|
|
it.draw_pixel_at(x + w / 2 - 1, y + 1, COLOR_OFF);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Horizontal bar, lit up to frac.
|
||
|
|
void bar(Display &it, int x, int y, int w, int h, float frac) {
|
||
|
|
it.rectangle(x, y, w, h);
|
||
|
|
if (!std::isnan(frac))
|
||
|
|
it.filled_rectangle(x + 2, y + 2, (int) ((w - 4) * std::clamp(frac, 0.0f, 1.0f)), h - 4);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Greedy word wrap into at most max_lines lines of `width` pixels.
|
||
|
|
std::vector<std::string> wrap(Display &it, display::BaseFont *font, const std::string &text, int width,
|
||
|
|
size_t max_lines) {
|
||
|
|
std::vector<std::string> 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
// The first part of `s` that has been typed at `cps` characters per second.
|
||
|
|
std::string typed(const std::string &s, uint32_t t, int cps) { return s.substr(0, std::min<size_t>(s.size(), t * cps / 1000)); }
|
||
|
|
|
||
|
|
// Dissolve the page in over its first 400 ms.
|
||
|
|
void intro(EL512Display &it, uint32_t t) {
|
||
|
|
if (t < 400)
|
||
|
|
it.dither_mask((int) (t * 16 / 400));
|
||
|
|
}
|
||
|
|
|
||
|
|
std::string watts_text(float w) { return std::isnan(w) ? "--" : watts(w); }
|
||
|
|
|
||
|
|
std::string thousands(int n) {
|
||
|
|
std::string s = std::to_string(n);
|
||
|
|
for (int i = (int) s.size() - 3; i > 0; i -= 3)
|
||
|
|
s.insert(i, ",");
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Explosion counter (shared by the Explosion page and the card) ---------------
|
||
|
|
|
||
|
|
struct Counter {
|
||
|
|
uint32_t count{0};
|
||
|
|
int day{-1};
|
||
|
|
uint32_t key{0};
|
||
|
|
};
|
||
|
|
Counter counter;
|
||
|
|
|
||
|
|
void roll_day(ESPTime now) {
|
||
|
|
if (now.is_valid() && now.day_of_year != counter.day) {
|
||
|
|
counter.day = now.day_of_year;
|
||
|
|
counter.count = 0;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
} // namespace
|
||
|
|
|
||
|
|
uint32_t explosions_today() { return counter.count; }
|
||
|
|
|
||
|
|
// --- 1. Megumin's daily explosion ---------------------------------------------------
|
||
|
|
|
||
|
|
void explosion(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
|
||
|
|
if (f.big == nullptr)
|
||
|
|
return;
|
||
|
|
const uint32_t t = it.page_age_ms();
|
||
|
|
constexpr uint32_t CHANT = 1600, BLAST = 6600, AFTER = 8800, RATE = 13400, CYCLE = 17000;
|
||
|
|
constexpr int GROUND = 226, CX = 360, CY = 116, R = 90; // magic circle
|
||
|
|
constexpr int BX = 360, BY = 178; // where it hits
|
||
|
|
constexpr int MX = 20, MY = GROUND - 84; // Megumin, 2x
|
||
|
|
constexpr int SX = MX + 52, SY = MY - 14; // staff, 2x
|
||
|
|
constexpr int ORB_X = SX + 9, ORB_Y = SY + 7;
|
||
|
|
const uint32_t cycle = t / CYCLE, c = t % CYCLE;
|
||
|
|
roll_day(now);
|
||
|
|
if (c >= BLAST) {
|
||
|
|
const uint32_t key = hash(it.page_shown_at(), cycle);
|
||
|
|
if (key != counter.key) {
|
||
|
|
counter.key = key;
|
||
|
|
counter.count++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const bool blasting = c >= BLAST && c < AFTER;
|
||
|
|
|
||
|
|
// Night sky, moon, ground.
|
||
|
|
for (int i = 0; i < 46; i++) {
|
||
|
|
const uint32_t r = hash(i, 77);
|
||
|
|
const int x = (int) (r % W), y = 60 + (int) ((r >> 9) % 150);
|
||
|
|
if (x < 110 && y > MY - 20)
|
||
|
|
continue;
|
||
|
|
const uint32_t tw = (t / 170 + (r >> 20)) % 13;
|
||
|
|
if (tw == 0)
|
||
|
|
sparkle(it, x, y, 2);
|
||
|
|
else if (tw < 10)
|
||
|
|
it.draw_pixel_at(x, y, COLOR_ON);
|
||
|
|
}
|
||
|
|
it.filled_circle(488, 70, 12);
|
||
|
|
it.filled_circle(482, 66, 11, COLOR_OFF);
|
||
|
|
it.horizontal_line(0, GROUND, W);
|
||
|
|
for (int i = 0; i < 22; i++) {
|
||
|
|
const int x = (int) (hash(i, 5) % W);
|
||
|
|
it.line(x, GROUND + 4, x - 2, GROUND + 1);
|
||
|
|
it.line(x, GROUND + 4, x + 2, GROUND + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Title and clock (hidden while the sky is on fire).
|
||
|
|
if (!blasting) {
|
||
|
|
it.print(8, 4, f.jp_big, TextAlign::TOP_LEFT, "爆裂魔法");
|
||
|
|
it.print(10, 52, f.pixel, TextAlign::TOP_LEFT, "EXPLOSION MAGIC");
|
||
|
|
if (now.is_valid())
|
||
|
|
it.strftime(504, 6, f.pixel_l, TextAlign::TOP_RIGHT, "%H:%M", now);
|
||
|
|
}
|
||
|
|
|
||
|
|
std::string say;
|
||
|
|
if (c < AFTER) {
|
||
|
|
// Megumin on her feet, staff raised.
|
||
|
|
const bool blink = (t % 2900) < 140;
|
||
|
|
sprite(it, blink ? MEGUMIN_BLINK : MEGUMIN, MX, MY, 2);
|
||
|
|
sprite(it, STAFF, SX, SY, 2);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (c < CHANT) {
|
||
|
|
say = typed("Time for today's explosion!", c, 30);
|
||
|
|
} else if (c < BLAST) {
|
||
|
|
// The magic circle draws itself, ring by ring.
|
||
|
|
const uint32_t e = c - CHANT;
|
||
|
|
auto progress = [e](uint32_t from, uint32_t dur) {
|
||
|
|
return e <= from ? 0.0f : std::min(1.0f, (float) (e - from) / dur);
|
||
|
|
};
|
||
|
|
const float rot = e / 22.0f;
|
||
|
|
if (progress(0, 1300) > 0)
|
||
|
|
arc(it, CX, CY, R, rot, rot + 360 * progress(0, 1300));
|
||
|
|
if (progress(350, 1300) > 0)
|
||
|
|
arc(it, CX, CY, R - 14, -rot, -rot - 360 * progress(350, 1300));
|
||
|
|
if (progress(1100, 1100) > 0)
|
||
|
|
arc(it, CX, CY, 48, rot * 2, rot * 2 + 360 * progress(1100, 1100));
|
||
|
|
// Hexagram, one line after another.
|
||
|
|
const float hex = progress(1900, 1500) * 6;
|
||
|
|
for (int i = 0; i < 6 && hex > i; i++) {
|
||
|
|
const float a0 = (i % 2) * 60 + (i / 2) * 120 - rot, a1 = a0 + 120;
|
||
|
|
int x0, y0, x1, y1;
|
||
|
|
polar(CX, CY, 48, a0, &x0, &y0);
|
||
|
|
polar(CX, CY, 48, a1, &x1, &y1);
|
||
|
|
const float k = std::min(1.0f, hex - i);
|
||
|
|
it.line(x0, y0, x0 + (int) ((x1 - x0) * k), y0 + (int) ((y1 - y0) * k));
|
||
|
|
}
|
||
|
|
// Runes between the outer rings.
|
||
|
|
if (e > 2400) {
|
||
|
|
const int shown = std::min<int>(24, (e - 2400) / 40);
|
||
|
|
for (int i = 0; i < shown; i++) {
|
||
|
|
int x, y;
|
||
|
|
polar(CX, CY, R - 7, i * 15 + rot, &x, &y);
|
||
|
|
switch (hash(i, 3) % 3) {
|
||
|
|
case 0:
|
||
|
|
diamond(it, x, y, 2);
|
||
|
|
break;
|
||
|
|
case 1:
|
||
|
|
it.filled_rectangle(x - 1, y - 2, 2, 5);
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
it.draw_pixel_at(x - 2, y, COLOR_ON);
|
||
|
|
it.draw_pixel_at(x + 2, y, COLOR_ON);
|
||
|
|
it.draw_pixel_at(x, y - 2, COLOR_ON);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (e > 3400)
|
||
|
|
sparkle(it, CX, CY, 3 + (int) ((e / 90) % 4));
|
||
|
|
// Mana streams into the staff.
|
||
|
|
for (int i = 0; i < 12; i++) {
|
||
|
|
const uint32_t r = hash(i, 9);
|
||
|
|
const float k = (float) ((e + i * 173) % 900) / 900.0f;
|
||
|
|
const int x0 = (int) (r % 220), y0 = 70 + (int) ((r >> 10) % 140);
|
||
|
|
it.filled_rectangle(x0 + (int) ((ORB_X - x0) * k), y0 + (int) ((ORB_Y - y0) * k), 2, 2);
|
||
|
|
}
|
||
|
|
sparkle(it, ORB_X, ORB_Y, 4 + (int) ((e / 120) % 4));
|
||
|
|
const char *chant[] = {"Crimson flame asleep within my staff...", "drink every last drop of my mana...",
|
||
|
|
"and paint the far hills into a crater!"};
|
||
|
|
const int line = std::min<int>(2, e / 1650);
|
||
|
|
say = typed(chant[line], e - line * 1650, 34);
|
||
|
|
} else if (c < AFTER) {
|
||
|
|
const uint32_t b = c - BLAST;
|
||
|
|
// Shockwave rings, fireball, mushroom cloud.
|
||
|
|
const int ring = (int) (b * 0.45f);
|
||
|
|
it.circle(BX, BY, ring);
|
||
|
|
it.circle(BX, BY, ring + 1);
|
||
|
|
if (ring > 30)
|
||
|
|
it.circle(BX, BY, ring - 26);
|
||
|
|
if (b < 700) {
|
||
|
|
it.filled_circle(BX, BY, std::min<int>(84, 30 + b / 6));
|
||
|
|
} else {
|
||
|
|
const uint32_t m = b - 700;
|
||
|
|
const int rise = std::min<int>(70, m / 14);
|
||
|
|
dither_disc(it, BX, BY - rise, 70 - rise / 3, std::max(2, 16 - (int) (m / 110)));
|
||
|
|
it.filled_rectangle(BX - 14, BY - rise, 28, GROUND - (BY - rise), COLOR_ON);
|
||
|
|
dither_disc(it, BX, GROUND - 6, 60 - rise / 2, 10);
|
||
|
|
}
|
||
|
|
// EXPLOSION! over a dark band.
|
||
|
|
it.filled_rectangle(70, 2, 372, 52, COLOR_OFF);
|
||
|
|
it.print(256, 4, f.big, TextAlign::TOP_CENTER, "EXPLOSION!");
|
||
|
|
if (b < 130)
|
||
|
|
it.invert_rect(0, 0, W, H); // the flash
|
||
|
|
if (b < 1500) {
|
||
|
|
const int amp = std::max(1, 7 - (int) (b / 220));
|
||
|
|
it.shift_rows(0, H, ((b / 45) & 1) ? amp : -amp);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Aftermath: smoke drifting off, crater, Megumin flat on the ground.
|
||
|
|
const uint32_t a = c - AFTER;
|
||
|
|
for (int i = 0; i < 3; i++) {
|
||
|
|
const int lvl = std::max(0, 9 - (int) (a / 500) - i);
|
||
|
|
if (lvl > 0)
|
||
|
|
dither_disc(it, BX - 50 + i * 50, BY - 30 - (int) (a / 120) - i * 12, 34 - i * 4, lvl);
|
||
|
|
}
|
||
|
|
it.horizontal_line(BX - 70, GROUND, 140, COLOR_OFF);
|
||
|
|
arc(it, BX, GROUND - 40, 78, 120, 240);
|
||
|
|
sprite(it, MEGUMIN_DOWN, MX - 8, GROUND - 24, 2);
|
||
|
|
if ((t / 400) % 2)
|
||
|
|
it.print(MX + 40, GROUND - 46, f.pixel, TextAlign::TOP_LEFT, "MP 0");
|
||
|
|
if (c < RATE) {
|
||
|
|
say = typed("...I can't move. Somebody carry me home.", a, 26);
|
||
|
|
} else {
|
||
|
|
// Kazuma scores the explosion, as he does.
|
||
|
|
static const char *const COMMENTS[] = {"Crisp shockwave, great form.", "That smoke ring was pure art.",
|
||
|
|
"The ground shook just right.", "A little early, still lovely.",
|
||
|
|
"Best one this week!"};
|
||
|
|
const uint32_t r = hash(cycle, it.page_shown_at());
|
||
|
|
rpg_window(it, 150, 70, 250, 104);
|
||
|
|
it.print(275, 84, f.pixel, TextAlign::TOP_CENTER, "KAZUMA'S RATING");
|
||
|
|
it.printf(275, 104, f.pixel_l, TextAlign::TOP_CENTER, "%u POINTS", (unsigned) (80 + r % 21));
|
||
|
|
it.print(275, 136, f.text, TextAlign::TOP_CENTER, COMMENTS[(r >> 8) % 5]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Speech line and the day's tally.
|
||
|
|
if (!say.empty())
|
||
|
|
it.print(110, 242, f.text, TextAlign::CENTER_LEFT, say.c_str());
|
||
|
|
if (c >= AFTER)
|
||
|
|
it.printf(504, 248, f.pixel, TextAlign::BASELINE_RIGHT, "TODAY: %u", (unsigned) counter.count);
|
||
|
|
intro(it, t);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- 2. Adventurers' Guild quest board ---------------------------------------------
|
||
|
|
|
||
|
|
void guild_board(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
|
||
|
|
if (f.title == nullptr)
|
||
|
|
return;
|
||
|
|
const uint32_t t = it.page_age_ms();
|
||
|
|
ornate_frame(it, 0, 0, W, H);
|
||
|
|
it.print(16, 9, f.title, TextAlign::TOP_LEFT, "ADVENTURERS' GUILD");
|
||
|
|
it.print(496, 8, f.jp, TextAlign::TOP_RIGHT, "冒険者ギルド");
|
||
|
|
it.horizontal_line(6, 38, W - 12);
|
||
|
|
it.horizontal_line(6, 40, W - 12);
|
||
|
|
|
||
|
|
// Quests: the next agenda events, topped up with standing guild requests.
|
||
|
|
struct Quest {
|
||
|
|
std::string title, when;
|
||
|
|
bool from_agenda, today;
|
||
|
|
};
|
||
|
|
std::vector<Quest> quests;
|
||
|
|
const std::string today = now.is_valid() ? now.strftime("%a %d") : "";
|
||
|
|
for (const auto &e : split(d.agenda, ';')) {
|
||
|
|
if (quests.size() == 3)
|
||
|
|
break;
|
||
|
|
const std::string day = field(e, 0), time = field(e, 1);
|
||
|
|
quests.push_back({field(e, 2), upper(day) + " " + (time.empty() ? "ALL DAY" : time), true, day == today});
|
||
|
|
}
|
||
|
|
static const char *const STANDING[][2] = {{"Exterminate five giant toads", "BEFORE DUSK"},
|
||
|
|
{"Escort a merchant cart to the next town", "THIS WEEK"},
|
||
|
|
{"Bring in the cabbage harvest before it flies off", "SEASONAL"}};
|
||
|
|
for (int i = 0; quests.size() < 3; i++)
|
||
|
|
quests.push_back({STANDING[i][0], STANDING[i][1], false, false});
|
||
|
|
|
||
|
|
for (int i = 0; i < 3; i++) {
|
||
|
|
const int dt = (int) t - i * 140;
|
||
|
|
if (dt < 0)
|
||
|
|
continue;
|
||
|
|
const int x = 10 + i * 124, y = 48 - (dt < 300 ? (300 - dt) * 40 / 300 : 0);
|
||
|
|
const Quest &q = quests[i];
|
||
|
|
const uint32_t r = hash(std::hash<std::string>{}(q.title));
|
||
|
|
note(it, x, y, 118, 198);
|
||
|
|
it.print(x + 59, y + 12, f.pixel, TextAlign::TOP_CENTER, "QUEST");
|
||
|
|
const int rank = 1 + (int) (r % 5);
|
||
|
|
for (int s = 0; s < rank; s++)
|
||
|
|
sparkle(it, x + 59 - (rank - 1) * 6 + s * 12, y + 30, 3);
|
||
|
|
int ly = y + 40;
|
||
|
|
for (const auto &l : wrap(it, f.text, q.title, 104, 5)) {
|
||
|
|
it.print(x + 7, ly, f.text, TextAlign::TOP_LEFT, l.c_str());
|
||
|
|
ly += 18;
|
||
|
|
}
|
||
|
|
it.horizontal_line(x + 6, y + 136, 106);
|
||
|
|
it.print(x + 7, y + 142, f.pixel, TextAlign::TOP_LEFT, "DEADLINE");
|
||
|
|
it.print(x + 7, y + 154, f.pixel, TextAlign::TOP_LEFT, fit(f.pixel, q.when, 104).c_str());
|
||
|
|
it.print(x + 7, y + 168, f.pixel, TextAlign::TOP_LEFT, "REWARD");
|
||
|
|
it.printf(x + 7, y + 180, f.pixel, TextAlign::TOP_LEFT, "%s ERIS",
|
||
|
|
thousands((1 + (int) ((r >> 8) % 40)) * 5000).c_str());
|
||
|
|
if (q.today && (t / 500) % 2) {
|
||
|
|
it.filled_rectangle(x + 76, y + 6, 38, 14);
|
||
|
|
it.print(x + 95, y + 13, f.pixel, COLOR_OFF, TextAlign::CENTER, "NEW!");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Hazard notice (weather) and the cabbage bounty (solar).
|
||
|
|
note(it, 386, 48, 116, 94);
|
||
|
|
it.print(444, 60, f.pixel, TextAlign::TOP_CENTER, "HAZARD");
|
||
|
|
it.print(416, 102, f.weather, TextAlign::CENTER, weather_icon(d.weather));
|
||
|
|
it.print(492, 94, f.text, TextAlign::BASELINE_RIGHT, std::isnan(d.temp) ? "--" : str("%.0f°", d.temp).c_str());
|
||
|
|
it.print(444, 128, f.pixel, TextAlign::TOP_CENTER, fit(f.pixel, upper(weather_text(d.weather)), 100).c_str());
|
||
|
|
|
||
|
|
note(it, 386, 150, 116, 94);
|
||
|
|
it.print(444, 162, f.pixel, TextAlign::TOP_CENTER, "BOUNTY");
|
||
|
|
it.print(444, 176, f.text, TextAlign::TOP_CENTER, "Cabbage harvest");
|
||
|
|
it.print(444, 198, f.text, TextAlign::TOP_CENTER,
|
||
|
|
std::isnan(d.solar_today) ? "--" : str("%.1f kWh", d.solar_today / 1000.0f).c_str());
|
||
|
|
it.printf(444, 222, f.pixel, TextAlign::TOP_CENTER, "NOW %s", upper(watts_text(d.solar)).c_str());
|
||
|
|
|
||
|
|
// Cabbages escaping across the board.
|
||
|
|
for (int i = 0; i < 3; i++) {
|
||
|
|
const int x = (int) ((t / 14 + i * 233) % 760) - 60;
|
||
|
|
const int y = 60 + i * 62 + (int) (12 * std::sin(t / 260.0f + i * 2));
|
||
|
|
const Sprite &s = ((t / 140 + i) % 2) ? CABBAGE_A : CABBAGE_B;
|
||
|
|
it.filled_rectangle(x - 1, y - 1, s.w * 2 + 2, s.h * 2 + 2, COLOR_OFF);
|
||
|
|
sprite(it, s, x, y, 2);
|
||
|
|
}
|
||
|
|
intro(it, t);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- 3. Megumin's adventurer card ----------------------------------------------------
|
||
|
|
|
||
|
|
void adventurer_card(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now) {
|
||
|
|
if (f.title == nullptr)
|
||
|
|
return;
|
||
|
|
const uint32_t t = it.page_age_ms();
|
||
|
|
roll_day(now);
|
||
|
|
const int X = 14, Y = 6 + (t < 380 ? (int) (380 - t) * 140 / 380 : 0), CW = 484, CH = 244;
|
||
|
|
|
||
|
|
// Card with cut corners and a header band.
|
||
|
|
constexpr int CUT = 12;
|
||
|
|
it.line(X + CUT, Y, X + CW - 1 - CUT, Y);
|
||
|
|
it.line(X + CW - 1 - CUT, Y, X + CW - 1, Y + CUT);
|
||
|
|
it.line(X + CW - 1, Y + CUT, X + CW - 1, Y + CH - 1 - CUT);
|
||
|
|
it.line(X + CW - 1, Y + CH - 1 - CUT, X + CW - 1 - CUT, Y + CH - 1);
|
||
|
|
it.line(X + CW - 1 - CUT, Y + CH - 1, X + CUT, Y + CH - 1);
|
||
|
|
it.line(X + CUT, Y + CH - 1, X, Y + CH - 1 - CUT);
|
||
|
|
it.line(X, Y + CH - 1 - CUT, X, Y + CUT);
|
||
|
|
it.line(X, Y + CUT, X + CUT, Y);
|
||
|
|
it.filled_rectangle(X + 8, Y + 6, CW - 16, 30);
|
||
|
|
it.print(X + 18, Y + 21, f.title, COLOR_OFF, TextAlign::CENTER_LEFT, "ADVENTURER CARD");
|
||
|
|
it.print(X + CW - 18, Y + 20, f.jp, COLOR_OFF, TextAlign::CENTER_RIGHT, "冒険者カード");
|
||
|
|
|
||
|
|
// Portrait.
|
||
|
|
it.rectangle(X + 14, Y + 44, 98, 94);
|
||
|
|
it.rectangle(X + 16, Y + 46, 94, 90);
|
||
|
|
const bool blink = (t % 3300) < 150;
|
||
|
|
sprite(it, blink ? MEGUMIN_BLINK : MEGUMIN, X + 21, Y + 52, 3, 27);
|
||
|
|
it.print(X + 63, Y + 146, f.pixel, TextAlign::TOP_CENTER, "NO. 0429");
|
||
|
|
it.print(X + 63, Y + 176, f.pixel, TextAlign::TOP_CENTER, "EXPLOSIONS");
|
||
|
|
it.print(X + 63, Y + 188, f.pixel, TextAlign::TOP_CENTER, "TODAY");
|
||
|
|
it.printf(X + 63, Y + 204, f.pixel_l, TextAlign::TOP_CENTER, "%u", (unsigned) counter.count);
|
||
|
|
|
||
|
|
// Identity.
|
||
|
|
const int x0 = X + 130, xv = X + 210;
|
||
|
|
const int level = 10 + (std::isnan(d.solar_today) ? 0 : (int) (d.solar_today / 1000.0f));
|
||
|
|
it.print(x0, Y + 50, f.pixel, TextAlign::TOP_LEFT, "NAME");
|
||
|
|
it.print(xv, Y + 46, f.pixel_l, TextAlign::TOP_LEFT, "MEGUMIN");
|
||
|
|
it.print(x0, Y + 74, f.pixel, TextAlign::TOP_LEFT, "CLASS");
|
||
|
|
it.print(xv, Y + 70, f.pixel_l, TextAlign::TOP_LEFT, "ARCH WIZARD");
|
||
|
|
it.print(x0, Y + 98, f.pixel, TextAlign::TOP_LEFT, "LEVEL");
|
||
|
|
it.printf(xv, Y + 94, f.pixel_l, TextAlign::TOP_LEFT, "%d", level);
|
||
|
|
it.print(x0, Y + 118, f.pixel, TextAlign::TOP_LEFT, "SKILL");
|
||
|
|
it.print(xv, Y + 118, f.pixel, TextAlign::TOP_LEFT, "EXPLOSION LV.MAX");
|
||
|
|
|
||
|
|
// Stats: mana is the sun, stamina what the house leaves over.
|
||
|
|
const int luck = now.is_valid() ? 1 + (int) (hash(now.day_of_year, now.year) % 99) : 50;
|
||
|
|
const float load = std::isnan(d.house) ? NAN : 1.0f - d.house / 5000.0f;
|
||
|
|
struct Stat {
|
||
|
|
const char *name;
|
||
|
|
float frac;
|
||
|
|
std::string value;
|
||
|
|
};
|
||
|
|
const Stat stats[] = {
|
||
|
|
{"MANA", d.solar / 5000.0f, upper(watts_text(d.solar))},
|
||
|
|
{"STAMINA", load, std::isnan(load) ? "--" : str("%d%%", (int) (std::clamp(load, 0.0f, 1.0f) * 100))},
|
||
|
|
{"INT", 0.99f, "99"},
|
||
|
|
{"LUCK", luck / 100.0f, str("%d", luck)},
|
||
|
|
{"STR", 0.03f, "3"},
|
||
|
|
};
|
||
|
|
for (int i = 0; i < 5; i++) {
|
||
|
|
const int y = Y + 140 + i * 19;
|
||
|
|
it.print(x0, y, f.pixel, TextAlign::TOP_LEFT, stats[i].name);
|
||
|
|
bar(it, x0 + 70, y - 1, 200, 10, stats[i].frac);
|
||
|
|
it.print(X + CW - 16, y, f.pixel, TextAlign::TOP_RIGHT, stats[i].value.c_str());
|
||
|
|
}
|
||
|
|
|
||
|
|
// A glint sweeps over the card now and then; corners twinkle.
|
||
|
|
const uint32_t s = t % 5200;
|
||
|
|
if (s < 800 && t > 600) {
|
||
|
|
const int bx = X - 130 + (int) (s * (CW + 260) / 800);
|
||
|
|
for (int y = Y + 1; y < Y + CH - 1; y++) {
|
||
|
|
const int x = std::max(X + 1, bx - (y - Y) / 2);
|
||
|
|
const int x1 = std::min(X + CW - 1, bx - (y - Y) / 2 + 12);
|
||
|
|
if (x1 > x)
|
||
|
|
it.invert_rect(x, y, x1 - x, 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const int tw = (int) ((t / 220) % 12);
|
||
|
|
if (tw < 3)
|
||
|
|
sparkle(it, X + CW - 30, Y + 48, 2 + tw);
|
||
|
|
else if (tw >= 6 && tw < 9)
|
||
|
|
sparkle(it, X + 120, Y + CH - 14, tw - 4);
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- 4. Party battle against a giant toad --------------------------------------------
|
||
|
|
|
||
|
|
void party_battle(EL512Display &it, const Fonts &f, const HomeData &d) {
|
||
|
|
if (f.pixel == nullptr)
|
||
|
|
return;
|
||
|
|
const uint32_t t = it.page_age_ms();
|
||
|
|
constexpr uint32_t TURN = 4200;
|
||
|
|
constexpr int TURNS = 5, GROUND = 132;
|
||
|
|
const uint32_t round = t / (TURN * TURNS);
|
||
|
|
const int turn = (int) ((t / TURN) % TURNS);
|
||
|
|
const uint32_t tt = t % TURN;
|
||
|
|
enum { KAZUMA_STEALS, AQUA_PURIFIES, TOAD_LICKS, TOAD_SWALLOWS, MEGUMIN_CASTS };
|
||
|
|
|
||
|
|
// Scenery.
|
||
|
|
it.print(8, 6, f.jp, TextAlign::TOP_LEFT, "戦闘");
|
||
|
|
it.printf(56, 12, f.pixel, TextAlign::TOP_LEFT, "ROUND %u", (unsigned) (round + 1));
|
||
|
|
arc(it, 110, GROUND + 70, 110, -52, 52);
|
||
|
|
arc(it, 300, GROUND + 90, 120, -40, 40);
|
||
|
|
it.horizontal_line(0, GROUND, W);
|
||
|
|
for (int i = 0; i < 18; i++) {
|
||
|
|
const int x = (int) (hash(i, 21) % W);
|
||
|
|
it.line(x, GROUND + 4, x - 2, GROUND + 1);
|
||
|
|
it.line(x, GROUND + 4, x + 2, GROUND + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Who has fallen or been eaten at this moment.
|
||
|
|
const bool toad_down = turn == MEGUMIN_CASTS && tt > 1700;
|
||
|
|
const bool aqua_eaten = (turn == TOAD_SWALLOWS && tt > 1100) || (turn == MEGUMIN_CASTS && !toad_down);
|
||
|
|
const bool megumin_down = turn == MEGUMIN_CASTS && tt > 2300;
|
||
|
|
|
||
|
|
// The party.
|
||
|
|
const Sprite *party[4] = {&KAZUMA, &AQUA, &MEGUMIN_S, &DARKNESS};
|
||
|
|
const int actor = turn == KAZUMA_STEALS ? 0 : turn == AQUA_PURIFIES ? 1 : turn == MEGUMIN_CASTS ? 2 : -1;
|
||
|
|
for (int i = 0; i < 4; i++) {
|
||
|
|
int x = 22 + i * 46;
|
||
|
|
const Sprite &s = *party[i];
|
||
|
|
if (i == 1 && aqua_eaten)
|
||
|
|
continue;
|
||
|
|
if (i == 3 && turn == TOAD_LICKS && tt > 1100 && tt < 2000)
|
||
|
|
x += ((tt / 60) & 1) ? 3 : -3; // Darkness takes the hit
|
||
|
|
if (i == 2 && megumin_down) {
|
||
|
|
sprite(it, MEGUMIN_DOWN, x - 16, GROUND - 12);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
sprite(it, s, x, GROUND - s.h * 2, 2);
|
||
|
|
if (i == actor && (t / 300) % 2)
|
||
|
|
it.filled_triangle(x + 10, GROUND - s.h * 2 - 12, x + 22, GROUND - s.h * 2 - 12, x + 16, GROUND - s.h * 2 - 6);
|
||
|
|
}
|
||
|
|
|
||
|
|
// The toad.
|
||
|
|
constexpr int TX = 336, TS = 3;
|
||
|
|
const int ty = GROUND - TOAD.h * TS - ((t / 450) % 2 ? 2 : 0);
|
||
|
|
const int target_x = turn == TOAD_LICKS ? 22 + 3 * 46 + 30 : 22 + 46 + 30;
|
||
|
|
const bool tongue = (turn == TOAD_LICKS && tt > 800 && tt < 1900) || (turn == TOAD_SWALLOWS && tt > 600 && tt < 1100);
|
||
|
|
if (!toad_down) {
|
||
|
|
sprite(it, tongue ? TOAD_TONGUE : TOAD, TX, ty, TS);
|
||
|
|
if (tongue) {
|
||
|
|
const int mx = TX + 5 * TS, my = ty + 12 * TS;
|
||
|
|
it.line(mx, my, target_x, GROUND - 20);
|
||
|
|
it.line(mx, my + 1, target_x, GROUND - 19);
|
||
|
|
it.filled_circle(target_x, GROUND - 20, 4);
|
||
|
|
}
|
||
|
|
float hp = turn == KAZUMA_STEALS && tt < 1200 ? 1.0f : 0.92f;
|
||
|
|
if (turn == MEGUMIN_CASTS && tt > 1300)
|
||
|
|
hp = 0.0f;
|
||
|
|
it.print(TX, 30, f.pixel, TextAlign::TOP_LEFT, "GIANT TOAD");
|
||
|
|
bar(it, TX, 42, 96, 8, hp);
|
||
|
|
} else if (tt < 2600) {
|
||
|
|
dither_disc(it, TX + 48, GROUND - 26, 40, std::max(1, 14 - (int) ((tt - 1700) / 70)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Turn effects.
|
||
|
|
if (turn == KAZUMA_STEALS && tt > 900 && tt < 1800) {
|
||
|
|
const float k = (tt - 900) / 900.0f;
|
||
|
|
const int x = TX + 20 + (int) ((22 + 16 - TX - 20) * k), y = GROUND - 60 - (int) (30 * std::sin(k * 3.14159f));
|
||
|
|
it.filled_rectangle(x - 3, y - 3, 7, 7);
|
||
|
|
sparkle(it, x, y, 5);
|
||
|
|
}
|
||
|
|
if (turn == AQUA_PURIFIES && tt > 700 && tt < 2600) {
|
||
|
|
for (int i = 0; i < 7; i++) {
|
||
|
|
const int x = 22 + 46 + (int) (hash(i, 31) % 34);
|
||
|
|
const int y = GROUND - 10 - (int) (((tt - 700) / 12 + i * 23) % 60);
|
||
|
|
sparkle(it, x, y, 1 + (int) ((tt / 100 + i) % 3));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (turn == MEGUMIN_CASTS) {
|
||
|
|
if (tt > 300 && tt < 1300) {
|
||
|
|
const float rot = tt / 6.0f;
|
||
|
|
arc(it, TX + 48, 60, 34, rot, rot + 360 * std::min(1.0f, (tt - 300) / 500.0f));
|
||
|
|
if (tt > 700)
|
||
|
|
arc(it, TX + 48, 60, 22, -rot, -rot + 360);
|
||
|
|
}
|
||
|
|
if (tt >= 1300 && tt < 1800)
|
||
|
|
it.filled_circle(TX + 48, GROUND - 30, 30 + (int) (tt - 1300) / 6);
|
||
|
|
if (tt >= 1300 && tt < 2000)
|
||
|
|
it.circle(TX + 48, GROUND - 30, (int) (tt - 1300) / 2);
|
||
|
|
if (tt > 2600 && (t / 250) % 2)
|
||
|
|
it.print(TX + 48, 70, f.pixel_l, TextAlign::TOP_CENTER, "VICTORY!");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Party status window.
|
||
|
|
rpg_window(it, 0, 150, 300, 106);
|
||
|
|
struct Member {
|
||
|
|
const char *name;
|
||
|
|
float hp, mp;
|
||
|
|
};
|
||
|
|
const bool cast = turn == MEGUMIN_CASTS && tt > 1300;
|
||
|
|
const bool licked = turn >= TOAD_LICKS && !(turn == TOAD_LICKS && tt < 1100);
|
||
|
|
const Member members[4] = {
|
||
|
|
{"KAZUMA", 0.8f, turn == KAZUMA_STEALS && tt < 900 ? 0.5f : 0.4f},
|
||
|
|
{"AQUA", aqua_eaten || (turn == MEGUMIN_CASTS) ? 0.55f : 1.0f, turn >= AQUA_PURIFIES ? 0.85f : 1.0f},
|
||
|
|
{"MEGUMIN", 0.7f, cast ? 0.0f : 1.0f},
|
||
|
|
{"DARKNESS", licked ? 0.9f : 1.0f, 0.0f},
|
||
|
|
};
|
||
|
|
for (int i = 0; i < 4; i++) {
|
||
|
|
const int y = 160 + i * 23;
|
||
|
|
if (i == actor)
|
||
|
|
it.filled_triangle(9, y, 9, y + 8, 14, y + 4);
|
||
|
|
it.print(18, y, f.pixel, TextAlign::TOP_LEFT, members[i].name);
|
||
|
|
it.print(96, y, f.pixel, TextAlign::TOP_LEFT, "HP");
|
||
|
|
bar(it, 116, y - 1, 64, 10, members[i].hp);
|
||
|
|
it.print(190, y, f.pixel, TextAlign::TOP_LEFT, "MP");
|
||
|
|
bar(it, 210, y - 1, 78, 10, members[i].mp);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Battle log, typed out.
|
||
|
|
rpg_window(it, 304, 150, 208, 106);
|
||
|
|
std::string msg;
|
||
|
|
switch (turn) {
|
||
|
|
case KAZUMA_STEALS:
|
||
|
|
msg = std::isnan(d.grid) ? "Kazuma used STEAL! He got... a pair of socks."
|
||
|
|
: d.grid >= 0 ? "Kazuma used STEAL! He took " + watts(d.grid) + " from the grid."
|
||
|
|
: "Kazuma used STEAL! Wait, he gave " + watts(-d.grid) + " back to the grid?";
|
||
|
|
break;
|
||
|
|
case AQUA_PURIFIES: {
|
||
|
|
const Reading &room = d.rooms[0];
|
||
|
|
msg = std::isnan(room.value)
|
||
|
|
? "Aqua used PURIFICATION! ...It had no effect."
|
||
|
|
: str("Aqua used PURIFICATION on the %s. It is now %.1fC. Not her doing.", room.name.c_str(),
|
||
|
|
room.value);
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
case TOAD_LICKS:
|
||
|
|
msg = "The toad's tongue lashed out! Darkness took " + watts_text(d.house) +
|
||
|
|
" of house load and looks... delighted?";
|
||
|
|
break;
|
||
|
|
case TOAD_SWALLOWS:
|
||
|
|
msg = "The giant toad swallowed Aqua whole! Muffled crying can be heard.";
|
||
|
|
break;
|
||
|
|
default:
|
||
|
|
if (tt < 2300) {
|
||
|
|
const int dmg = std::isnan(d.solar) || d.solar < 1 ? 9999 : (int) d.solar;
|
||
|
|
msg = "Megumin cast EXPLOSION! " + thousands(dmg) + " damage from the sun!";
|
||
|
|
} else {
|
||
|
|
msg = "VICTORY! The toad is gone. Megumin can't move anymore.";
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const uint32_t since = turn == MEGUMIN_CASTS && tt >= 2300 ? tt - 2300 : tt;
|
||
|
|
int ly = 162;
|
||
|
|
for (const auto &l : wrap(it, f.pixel, typed(msg, since, 40), 186, 7)) {
|
||
|
|
it.print(314, ly, f.pixel, TextAlign::TOP_LEFT, l.c_str());
|
||
|
|
ly += 12;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Explosion flash and shake.
|
||
|
|
if (turn == MEGUMIN_CASTS && tt >= 1300 && tt < 1420)
|
||
|
|
it.invert_rect(0, 0, W, 150);
|
||
|
|
if (turn == MEGUMIN_CASTS && tt >= 1300 && tt < 2100)
|
||
|
|
it.shift_rows(0, 148, ((tt / 45) & 1) ? 4 : -4);
|
||
|
|
intro(it, t);
|
||
|
|
}
|
||
|
|
|
||
|
|
} // namespace esphome::el512::konosuba
|