Files
planar-EL512.256-H/components/el512/konosuba.cpp
T

694 lines
27 KiB
C++
Raw Normal View History

2026-10-18 23:46:24 +02:00
#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