Public Access
Add four animated KonoSuba ESPHome pages
Megumin's daily explosion, the Adventurers' Guild quest board (agenda, weather, solar), Megumin's adventurer card and a party battle against a giant toad with a live-data battle log. Original 1-bit pixel art lives in tools/konosuba_sprites.py, which generates konosuba_sprites.h and can render a preview PNG. Home data and drawing helpers shared by the themed pages move to el512_pages.h; the display gains dither_mask() for a dissolve-in intro. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
50ee17b3f6
commit
e9b91c5628
11 files changed
+1655
-61
No files matched your search
@@ -83,6 +83,8 @@ components/el512/ shared driver + ESPHome component (one copy of the code)
|
|||||||
still_alive.* Portal end-credits show, 5x7 font (el512_font.h)
|
still_alive.* Portal end-credits show, 5x7 font (el512_font.h)
|
||||||
still_alive_data.h show content, generated by tools/import_still_alive.py
|
still_alive_data.h show content, generated by tools/import_still_alive.py
|
||||||
cyberpunk.* animated Cyberpunk-style ESPHome pages
|
cyberpunk.* animated Cyberpunk-style ESPHome pages
|
||||||
|
konosuba.* animated KonoSuba pages; konosuba_sprites.h from tools/
|
||||||
|
el512_pages.h home data and helpers shared by the themed pages
|
||||||
el512_display.* ESPHome display platform `el512`
|
el512_display.* ESPHome display platform `el512`
|
||||||
__init__.py, display.py ESPHome config schema
|
__init__.py, display.py ESPHome config schema
|
||||||
lib/EL512/ Arduino library: Adafruit_GFX front end (EL512_GFX)
|
lib/EL512/ Arduino library: Adafruit_GFX front end (EL512_GFX)
|
||||||
@@ -90,7 +92,7 @@ src/still_alive/ Portal-style end-credits demo (env still_alive)
|
|||||||
src/test_images/ test sketch: cycles through 12 test images (env esp32dev)
|
src/test_images/ test sketch: cycles through 12 test images (env esp32dev)
|
||||||
esphome/ el512-panel.yaml (Home Assistant display), el512-smoke.yaml
|
esphome/ el512-panel.yaml (Home Assistant display), el512-smoke.yaml
|
||||||
home_assistant/packages/ el512_panel.yaml: helper sensors for the ESPHome pages
|
home_assistant/packages/ el512_panel.yaml: helper sensors for the ESPHome pages
|
||||||
tools/ import_still_alive.py
|
tools/ import_still_alive.py, konosuba_sprites.py
|
||||||
test/ unit tests (placeholder)
|
test/ unit tests (placeholder)
|
||||||
docs/ datasheet and notes
|
docs/ datasheet and notes
|
||||||
```
|
```
|
||||||
@@ -109,9 +111,9 @@ pio device monitor
|
|||||||
## ESPHome / Home Assistant
|
## ESPHome / Home Assistant
|
||||||
|
|
||||||
[esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home
|
[esphome/el512-panel.yaml](esphome/el512-panel.yaml) turns the panel into a Home
|
||||||
Assistant display with twelve pages: clock and weather, forecast, agenda, a sensor
|
Assistant display with sixteen pages: clock and weather, forecast, agenda, a sensor
|
||||||
dashboard with a message line, now playing, Plex, solar, Still Alive, and four
|
dashboard with a message line, now playing, Plex, solar, Still Alive, and eight
|
||||||
animated [Cyberpunk-style pages](#cyberpunk-pages). Pages
|
animated themed pages ([Cyberpunk](#cyberpunk-pages), [KonoSuba](#konosuba-pages)). Pages
|
||||||
rotate on an interval; Home Assistant gets a page select, a pause switch, the
|
rotate on an interval; Home Assistant gets a page select, a pause switch, the
|
||||||
interval, a message text, a "Play Still Alive" button, and Display / Low power mode
|
interval, a message text, a "Play Still Alive" button, and Display / Low power mode
|
||||||
switches (ENABLE, _LOWPWR).
|
switches (ENABLE, _LOWPWR).
|
||||||
@@ -215,6 +217,38 @@ These pages are inspired by the look of Cyberpunk 2077 and use no assets from it
|
|||||||
Cyberpunk 2077 is a trademark of CD PROJEKT S.A.; this project is not affiliated
|
Cyberpunk 2077 is a trademark of CD PROJEKT S.A.; this project is not affiliated
|
||||||
with or endorsed by CD PROJEKT RED.
|
with or endorsed by CD PROJEKT RED.
|
||||||
|
|
||||||
|
## KonoSuba pages
|
||||||
|
|
||||||
|
Four animated pages around the KonoSuba party, drawn by
|
||||||
|
[components/el512/konosuba.cpp](components/el512/konosuba.cpp) with Cinzel, Nunito,
|
||||||
|
Press Start 2P, Noto Sans JP and Dela Gothic One (Google Fonts), all in the rotation:
|
||||||
|
|
||||||
|
- **KonoSuba Explosion**: Megumin chants under a night sky while a magic circle
|
||||||
|
draws itself, then EXPLOSION (flash, shockwave, mushroom cloud, screen shake). She
|
||||||
|
ends up flat on the ground, Kazuma rates the explosion, and the day's count goes up.
|
||||||
|
- **KonoSuba Guild**: the Adventurers' Guild board: your next agenda events as quest
|
||||||
|
notes (rank, deadline, reward in Eris, NEW! for today), the weather as a hazard
|
||||||
|
notice and today's solar yield as the cabbage-harvest bounty, while cabbages fly by.
|
||||||
|
- **KonoSuba Card**: Megumin's adventurer card: level from today's solar yield, mana
|
||||||
|
from solar power, stamina from what the house load leaves, a daily luck roll, and the
|
||||||
|
explosions cast today.
|
||||||
|
- **KonoSuba Battle**: the party against a giant toad in a JRPG window; the battle log
|
||||||
|
uses live values (Kazuma steals the grid import, Megumin's explosion does the solar
|
||||||
|
watts in damage).
|
||||||
|
|
||||||
|
The pixel art lives as text in [tools/konosuba_sprites.py](tools/konosuba_sprites.py),
|
||||||
|
which writes `components/el512/konosuba_sprites.h`; `--preview sprites.png` renders it
|
||||||
|
magnified for checking:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/konosuba_sprites.py --preview sprites.png
|
||||||
|
```
|
||||||
|
|
||||||
|
These pages are fan art: the pixel art and all texts are original, and no material
|
||||||
|
from the series is used. KonoSuba (*Kono Subarashii Sekai ni Shukufuku wo!*) is by
|
||||||
|
Natsume Akatsuki and Kurone Mishima, © its rights holders; this project is not
|
||||||
|
affiliated with or endorsed by them.
|
||||||
|
|
||||||
## Using the driver
|
## Using the driver
|
||||||
|
|
||||||
```cpp
|
```cpp
|
||||||
|
|||||||
@@ -23,42 +23,11 @@ constexpr int W = 512, H = 256;
|
|||||||
|
|
||||||
// --- Small helpers -----------------------------------------------------------
|
// --- Small helpers -----------------------------------------------------------
|
||||||
|
|
||||||
// Integer hash for repeatable pseudo-random effects (lowbias32).
|
using namespace fx;
|
||||||
uint32_t hash(uint32_t x) {
|
|
||||||
x ^= x >> 16;
|
|
||||||
x *= 0x7feb352d;
|
|
||||||
x ^= x >> 15;
|
|
||||||
x *= 0x846ca68b;
|
|
||||||
x ^= x >> 16;
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
uint32_t hash(uint32_t a, uint32_t b) { return hash(a * 0x9e3779b9u ^ hash(b)); }
|
|
||||||
|
|
||||||
std::string str(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
|
||||||
std::string str(const char *fmt, ...) {
|
|
||||||
char buf[96];
|
|
||||||
va_list args;
|
|
||||||
va_start(args, fmt);
|
|
||||||
vsnprintf(buf, sizeof(buf), fmt, args);
|
|
||||||
va_end(args);
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string upper(std::string s) {
|
|
||||||
for (auto &c : s)
|
|
||||||
c = (char) toupper((unsigned char) c);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string temp_text(float v, const char *unit = "°") { return std::isnan(v) ? "--" : str("%.1f%s", v, unit); }
|
std::string temp_text(float v, const char *unit = "°") { return std::isnan(v) ? "--" : str("%.1f%s", v, unit); }
|
||||||
std::string watts_text(float v) { return std::isnan(v) ? "--" : watts(v); }
|
std::string watts_text(float v) { return std::isnan(v) ? "--" : watts(v); }
|
||||||
|
|
||||||
int text_width(Display &it, display::BaseFont *font, const char *text) {
|
|
||||||
int x, y, w, h;
|
|
||||||
it.get_text_bounds(0, 0, text, font, TextAlign::TOP_LEFT, &x, &y, &w, &h);
|
|
||||||
return w;
|
|
||||||
}
|
|
||||||
|
|
||||||
const char *const CODES[] = {"1C", "55", "BD", "E9", "7A", "FF"};
|
const char *const CODES[] = {"1C", "55", "BD", "E9", "7A", "FF"};
|
||||||
constexpr int CODE_COUNT = 6;
|
constexpr int CODE_COUNT = 6;
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,7 @@
|
|||||||
// page_age_ms(), so every page starts its intro when it is shown.
|
// page_age_ms(), so every page starts its intro when it is shown.
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cmath>
|
#include "el512_pages.h"
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "esphome/components/display/display.h"
|
#include "esphome/components/display/display.h"
|
||||||
#include "esphome/core/time.h"
|
#include "esphome/core/time.h"
|
||||||
|
|
||||||
@@ -20,6 +18,9 @@ class EL512Display;
|
|||||||
|
|
||||||
namespace cyber {
|
namespace cyber {
|
||||||
|
|
||||||
|
using el512::HomeData;
|
||||||
|
using el512::Reading;
|
||||||
|
|
||||||
struct Fonts {
|
struct Fonts {
|
||||||
display::BaseFont *title{nullptr}; // wide display face for headers (Orbitron)
|
display::BaseFont *title{nullptr}; // wide display face for headers (Orbitron)
|
||||||
display::BaseFont *label{nullptr}; // condensed labels (Rajdhani)
|
display::BaseFont *label{nullptr}; // condensed labels (Rajdhani)
|
||||||
@@ -29,23 +30,6 @@ struct Fonts {
|
|||||||
display::BaseFont *code{nullptr}; // larger monospace for breach codes (Share Tech Mono)
|
display::BaseFont *code{nullptr}; // larger monospace for breach codes (Share Tech Mono)
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Reading {
|
|
||||||
std::string name;
|
|
||||||
float value{NAN};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Home Assistant values shown on the pages; NAN or "" when unknown.
|
|
||||||
struct HomeData {
|
|
||||||
std::string weather; // condition, e.g. "partlycloudy"
|
|
||||||
float temp{NAN}; // °C
|
|
||||||
float humidity{NAN}; // %
|
|
||||||
float wind{NAN}; // km/h
|
|
||||||
float solar{NAN}; // W
|
|
||||||
float house{NAN}; // W
|
|
||||||
float grid{NAN}; // W, > 0 = import
|
|
||||||
Reading rooms[4];
|
|
||||||
};
|
|
||||||
|
|
||||||
void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
|
void netrunner_hud(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
|
||||||
void breach_protocol(EL512Display &it, const Fonts &f);
|
void breach_protocol(EL512Display &it, const Fonts &f);
|
||||||
void optics_scan(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
|
void optics_scan(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
|
||||||
|
|||||||
@@ -148,6 +148,24 @@ void EL512Display::invert_rect(int x, int y, int width, int height) {
|
|||||||
this->panel_->setPixel(px, py, !this->panel_->getPixel(px, py));
|
this->panel_->setPixel(px, py, !this->panel_->getPixel(px, py));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void EL512Display::dither_mask(int level) {
|
||||||
|
if (this->panel_ == nullptr || level >= 16)
|
||||||
|
return;
|
||||||
|
static const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
|
||||||
|
uint8_t masks[4];
|
||||||
|
for (int r = 0; r < 4; r++) {
|
||||||
|
masks[r] = 0;
|
||||||
|
for (int x = 0; x < 8; x++)
|
||||||
|
if (BAYER[r][x & 3] < level)
|
||||||
|
masks[r] |= 0x80 >> x;
|
||||||
|
}
|
||||||
|
uint8_t *fb = this->panel_->framebuffer();
|
||||||
|
constexpr int ROW = EL512Panel::WIDTH / 8;
|
||||||
|
for (int y = 0; y < EL512Panel::HEIGHT; y++)
|
||||||
|
for (int i = 0; i < ROW; i++)
|
||||||
|
fb[y * ROW + i] &= masks[y & 3];
|
||||||
|
}
|
||||||
|
|
||||||
void EL512Display::update() {
|
void EL512Display::update() {
|
||||||
if (this->panel_ == nullptr || !this->panel_->running())
|
if (this->panel_ == nullptr || !this->panel_->running())
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#include "cyberpunk.h"
|
#include "cyberpunk.h"
|
||||||
#include "el512_helpers.h"
|
#include "el512_helpers.h"
|
||||||
|
#include "konosuba.h"
|
||||||
#include "el512_panel.h"
|
#include "el512_panel.h"
|
||||||
#include "esphome/components/display/display_buffer.h"
|
#include "esphome/components/display/display_buffer.h"
|
||||||
#include "esphome/core/component.h"
|
#include "esphome/core/component.h"
|
||||||
@@ -66,6 +67,9 @@ class EL512Display : public display::DisplayBuffer {
|
|||||||
// y..y+height-1 right by dx pixels (wrapping around), invert a rectangle.
|
// y..y+height-1 right by dx pixels (wrapping around), invert a rectangle.
|
||||||
void shift_rows(int y, int height, int dx);
|
void shift_rows(int y, int height, int dx);
|
||||||
void invert_rect(int x, int y, int width, int height);
|
void invert_rect(int x, int y, int width, int height);
|
||||||
|
// Keep only the pixels whose 4x4 Bayer threshold is below level (0..16):
|
||||||
|
// 0 clears everything, 16 keeps everything. Steps of it dissolve a page in.
|
||||||
|
void dither_mask(int level);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void draw_absolute_pixel_internal(int x, int y, Color color) override;
|
void draw_absolute_pixel_internal(int x, int y, Color color) override;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Shared pieces for the themed ESPHome pages (cyberpunk.cpp, konosuba.cpp): the
|
||||||
|
// Home Assistant values they show, and small drawing helpers.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "esphome/components/display/display.h"
|
||||||
|
|
||||||
|
namespace esphome::el512 {
|
||||||
|
|
||||||
|
struct Reading {
|
||||||
|
std::string name;
|
||||||
|
float value{NAN};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Home Assistant values shown on the pages; NAN or "" when unknown. Filled
|
||||||
|
// from YAML (see the cp_home global in el512-panel.yaml).
|
||||||
|
struct HomeData {
|
||||||
|
std::string weather; // condition, e.g. "partlycloudy"
|
||||||
|
float temp{NAN}; // °C
|
||||||
|
float humidity{NAN}; // %
|
||||||
|
float wind{NAN}; // km/h
|
||||||
|
float solar{NAN}; // W
|
||||||
|
float solar_today{NAN}; // Wh produced today
|
||||||
|
float house{NAN}; // W
|
||||||
|
float grid{NAN}; // W, > 0 = import
|
||||||
|
std::string agenda; // sensor.el512_agenda events: "day|time|title;..."
|
||||||
|
Reading rooms[4];
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace fx {
|
||||||
|
|
||||||
|
// Integer hash for repeatable pseudo-random effects (lowbias32).
|
||||||
|
inline uint32_t hash(uint32_t x) {
|
||||||
|
x ^= x >> 16;
|
||||||
|
x *= 0x7feb352d;
|
||||||
|
x ^= x >> 15;
|
||||||
|
x *= 0x846ca68b;
|
||||||
|
x ^= x >> 16;
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
inline uint32_t hash(uint32_t a, uint32_t b) { return hash(a * 0x9e3779b9u ^ hash(b)); }
|
||||||
|
|
||||||
|
inline std::string str(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
|
||||||
|
inline std::string str(const char *fmt, ...) {
|
||||||
|
char buf[128];
|
||||||
|
va_list args;
|
||||||
|
va_start(args, fmt);
|
||||||
|
vsnprintf(buf, sizeof(buf), fmt, args);
|
||||||
|
va_end(args);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline std::string upper(std::string s) {
|
||||||
|
for (auto &c : s)
|
||||||
|
c = (char) toupper((unsigned char) c);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
inline int text_width(display::Display &it, display::BaseFont *font, const char *text) {
|
||||||
|
int x, y, w, h;
|
||||||
|
it.get_text_bounds(0, 0, text, font, display::TextAlign::TOP_LEFT, &x, &y, &w, &h);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace fx
|
||||||
|
} // namespace esphome::el512
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
#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;
|
||||||
|
constexpr float DEG = 3.14159265f / 180.0f;
|
||||||
|
const uint8_t BAYER[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
|
||||||
|
|
||||||
|
// --- Drawing helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
// Draw a sprite (optionally only its first `rows` rows), scaled up in blocks.
|
||||||
|
void sprite(Display &it, const Sprite &s, int x, int y, int scale = 1, int rows = -1, Color color = COLOR_ON) {
|
||||||
|
const int h = rows < 0 ? s.h : std::min<int>(rows, s.h);
|
||||||
|
for (int r = 0; r < h; r++) {
|
||||||
|
for (int c = 0; c < s.w; c++) {
|
||||||
|
if (s.rows[r][c] != '#')
|
||||||
|
continue;
|
||||||
|
if (scale == 1)
|
||||||
|
it.draw_pixel_at(x + c, y + r, color);
|
||||||
|
else
|
||||||
|
it.filled_rectangle(x + c * scale, y + r * scale, scale, scale, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point on a circle; angle in degrees, 0 = up, clockwise.
|
||||||
|
void polar(int cx, int cy, float r, float a, int *x, int *y) {
|
||||||
|
*x = cx + (int) std::lround(r * std::sin(a * DEG));
|
||||||
|
*y = cy - (int) std::lround(r * std::cos(a * DEG));
|
||||||
|
}
|
||||||
|
|
||||||
|
void arc(Display &it, int cx, int cy, float r, float a0, float a1) {
|
||||||
|
const int steps = std::max(2, (int) (std::fabs(a1 - a0) / 5));
|
||||||
|
int px, py;
|
||||||
|
polar(cx, cy, r, a0, &px, &py);
|
||||||
|
for (int i = 1; i <= steps; i++) {
|
||||||
|
int nx, ny;
|
||||||
|
polar(cx, cy, r, a0 + (a1 - a0) * i / steps, &nx, &ny);
|
||||||
|
it.line(px, py, nx, ny);
|
||||||
|
px = nx;
|
||||||
|
py = ny;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// KonoSuba-themed animated pages for the ESPHome display: Megumin's daily
|
||||||
|
// explosion, the Adventurers' Guild quest board, Megumin's adventurer card and
|
||||||
|
// a party battle against a giant toad. Fan art: the pixel art
|
||||||
|
// (konosuba_sprites.h, from tools/konosuba_sprites.py) and all texts are
|
||||||
|
// original; no material from the series is used.
|
||||||
|
//
|
||||||
|
// Each function draws a whole page. Call it from a page lambda and list that
|
||||||
|
// page under animated_pages; the animation clock is the display's page_age_ms().
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "el512_pages.h"
|
||||||
|
#include "esphome/components/display/display.h"
|
||||||
|
#include "esphome/core/time.h"
|
||||||
|
|
||||||
|
namespace esphome::el512 {
|
||||||
|
|
||||||
|
class EL512Display;
|
||||||
|
|
||||||
|
namespace konosuba {
|
||||||
|
|
||||||
|
struct Fonts {
|
||||||
|
display::BaseFont *title{nullptr}; // fantasy serif headers (Cinzel)
|
||||||
|
display::BaseFont *big{nullptr}; // "EXPLOSION!" (Cinzel Decorative)
|
||||||
|
display::BaseFont *text{nullptr}; // rounded body text (Nunito)
|
||||||
|
display::BaseFont *pixel{nullptr}; // 8 px pixel font (Press Start 2P)
|
||||||
|
display::BaseFont *pixel_l{nullptr}; // 16 px pixel font
|
||||||
|
display::BaseFont *jp{nullptr}; // a few Japanese words (Noto Sans JP)
|
||||||
|
display::BaseFont *jp_big{nullptr}; // 爆裂魔法 (Dela Gothic One)
|
||||||
|
display::BaseFont *weather{nullptr}; // weather icons (Material Design Icons)
|
||||||
|
};
|
||||||
|
|
||||||
|
void explosion(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
|
||||||
|
void guild_board(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
|
||||||
|
void adventurer_card(EL512Display &it, const Fonts &f, const HomeData &d, ESPTime now);
|
||||||
|
void party_battle(EL512Display &it, const Fonts &f, const HomeData &d);
|
||||||
|
|
||||||
|
// Explosions cast on the Explosion page since midnight.
|
||||||
|
uint32_t explosions_today();
|
||||||
|
|
||||||
|
} // namespace konosuba
|
||||||
|
} // namespace esphome::el512
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// Pixel art for the KonoSuba pages, generated by tools/konosuba_sprites.py;
|
||||||
|
// edit the art there. Original 1-bit fan art, '#' = lit pixel.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
namespace esphome::el512::konosuba {
|
||||||
|
|
||||||
|
struct Sprite {
|
||||||
|
uint8_t w, h;
|
||||||
|
const char *const *rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
static const char *const MEGUMIN_ROWS[] = {
|
||||||
|
"...............####.........",
|
||||||
|
"..............######........",
|
||||||
|
".............###...##.......",
|
||||||
|
"............####.....#......",
|
||||||
|
"...........#####............",
|
||||||
|
"..........######............",
|
||||||
|
"..........#######...........",
|
||||||
|
".........########...........",
|
||||||
|
".........#########..........",
|
||||||
|
"........##########..........",
|
||||||
|
"........#.........#.........",
|
||||||
|
"........###########.........",
|
||||||
|
"##########################..",
|
||||||
|
".########################...",
|
||||||
|
".....###############........",
|
||||||
|
".....##.#.##..#.#.##........",
|
||||||
|
".....#.............#........",
|
||||||
|
".....#..##....####.#........",
|
||||||
|
".....#..##....####.#........",
|
||||||
|
".....#.........#...#........",
|
||||||
|
".....##.............#.......",
|
||||||
|
"......#.....##.....#........",
|
||||||
|
".....###..........###.......",
|
||||||
|
"....####.##....##.####......",
|
||||||
|
"...######.######.######.....",
|
||||||
|
"..#######..####..#######....",
|
||||||
|
"..######...####...#######...",
|
||||||
|
".#######...#..#...#######...",
|
||||||
|
".######....#..#....######...",
|
||||||
|
".######...#....#...######...",
|
||||||
|
"#######...#....#...#######..",
|
||||||
|
"######...#......#...######..",
|
||||||
|
"######..##########..######..",
|
||||||
|
"#####...#........#...#####..",
|
||||||
|
"#####..#..........#..#####..",
|
||||||
|
"####...############...####..",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
".......###....###...........",
|
||||||
|
".......####...####..........",
|
||||||
|
};
|
||||||
|
static constexpr Sprite MEGUMIN{28, 42, MEGUMIN_ROWS};
|
||||||
|
|
||||||
|
static const char *const STAFF_ROWS[] = {
|
||||||
|
"..#####..",
|
||||||
|
".#.....#.",
|
||||||
|
"#..###..#",
|
||||||
|
"#.#####.#",
|
||||||
|
"#.#####.#",
|
||||||
|
"#..###..#",
|
||||||
|
".#.....#.",
|
||||||
|
"..#...#..",
|
||||||
|
"...#.#...",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"...##....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....##...",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"...##....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....##...",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
};
|
||||||
|
static constexpr Sprite STAFF{9, 42, STAFF_ROWS};
|
||||||
|
|
||||||
|
static const char *const MEGUMIN_DOWN_ROWS[] = {
|
||||||
|
"....###......................................................",
|
||||||
|
"...#####.....................................................",
|
||||||
|
"..######.....................................................",
|
||||||
|
"..#######....................................................",
|
||||||
|
".########.##.................................................",
|
||||||
|
".#########..##.........#######...............................",
|
||||||
|
"###########...##.....###########.............................",
|
||||||
|
"#.........#.....#...##.#.#.#.#.##..#####################.....",
|
||||||
|
"###########......#..#.#.#.#.#.#.#.########################...",
|
||||||
|
"...................##############.###########.##########.###.",
|
||||||
|
"....................############..#####...........######...##",
|
||||||
|
"......................########....####.............####......",
|
||||||
|
};
|
||||||
|
static constexpr Sprite MEGUMIN_DOWN{61, 12, MEGUMIN_DOWN_ROWS};
|
||||||
|
|
||||||
|
static const char *const KAZUMA_ROWS[] = {
|
||||||
|
"....######......",
|
||||||
|
"...########.....",
|
||||||
|
"..##########....",
|
||||||
|
"..#.#.##.#.#....",
|
||||||
|
"..#........#....",
|
||||||
|
"..#..#..#..#....",
|
||||||
|
"..#........#....",
|
||||||
|
"...#..##..#.....",
|
||||||
|
"....######......",
|
||||||
|
"..##########....",
|
||||||
|
".###.####.###...",
|
||||||
|
".##..#..#..##...",
|
||||||
|
".##..#..#..##.#.",
|
||||||
|
".##..####..##.#.",
|
||||||
|
".##..#..#..##.#.",
|
||||||
|
"..#..#..#..#.###",
|
||||||
|
"....#....#....#.",
|
||||||
|
"....#....#......",
|
||||||
|
"...##....##.....",
|
||||||
|
};
|
||||||
|
static constexpr Sprite KAZUMA{16, 19, KAZUMA_ROWS};
|
||||||
|
|
||||||
|
static const char *const AQUA_ROWS[] = {
|
||||||
|
".....####.......",
|
||||||
|
"....#....#......",
|
||||||
|
".....####.......",
|
||||||
|
"....######......",
|
||||||
|
"...########.....",
|
||||||
|
"..##########....",
|
||||||
|
"..#........#....",
|
||||||
|
"..#..#..#..#....",
|
||||||
|
"..#........#....",
|
||||||
|
"..##..##..##....",
|
||||||
|
"..###....###....",
|
||||||
|
".####.##.####...",
|
||||||
|
".##..####..##...",
|
||||||
|
".##.#....#.##...",
|
||||||
|
".#.#......#.#...",
|
||||||
|
".#.#......#.#...",
|
||||||
|
"...########.....",
|
||||||
|
"....#....#......",
|
||||||
|
"....#....#......",
|
||||||
|
"...##....##.....",
|
||||||
|
};
|
||||||
|
static constexpr Sprite AQUA{16, 20, AQUA_ROWS};
|
||||||
|
|
||||||
|
static const char *const MEGUMIN_S_ROWS[] = {
|
||||||
|
".......##.......",
|
||||||
|
"......####......",
|
||||||
|
".....######.....",
|
||||||
|
"....########....",
|
||||||
|
"....#......#....",
|
||||||
|
"################",
|
||||||
|
"...##########...",
|
||||||
|
"...#........#...",
|
||||||
|
"...#.#...##.#...",
|
||||||
|
"...#........#...",
|
||||||
|
"....#..##..#....",
|
||||||
|
".....######.....",
|
||||||
|
"...##########...",
|
||||||
|
"..############..",
|
||||||
|
"..###.####.###..",
|
||||||
|
"..##..#..#..##..",
|
||||||
|
"..##..####..##..",
|
||||||
|
"..############..",
|
||||||
|
".....#....#.....",
|
||||||
|
".....#....#.....",
|
||||||
|
"....##....##....",
|
||||||
|
};
|
||||||
|
static constexpr Sprite MEGUMIN_S{16, 21, MEGUMIN_S_ROWS};
|
||||||
|
|
||||||
|
static const char *const DARKNESS_ROWS[] = {
|
||||||
|
"....######......",
|
||||||
|
"...########.###.",
|
||||||
|
"..##########.##.",
|
||||||
|
"..#.##..##.#..#.",
|
||||||
|
"..#........#....",
|
||||||
|
"..#..#..#..#....",
|
||||||
|
"..#........#....",
|
||||||
|
"...#..##..#.....",
|
||||||
|
"....######......",
|
||||||
|
"############....",
|
||||||
|
"##.########.##..",
|
||||||
|
"#..#.#..#.#..#..",
|
||||||
|
"...#.####.#.....",
|
||||||
|
"...#.#..#.#.....",
|
||||||
|
"...#.####.#.....",
|
||||||
|
"...########.....",
|
||||||
|
"....#....#......",
|
||||||
|
"....##...##.....",
|
||||||
|
"...###...###....",
|
||||||
|
};
|
||||||
|
static constexpr Sprite DARKNESS{16, 19, DARKNESS_ROWS};
|
||||||
|
|
||||||
|
static const char *const TOAD_ROWS[] = {
|
||||||
|
"......####............####......",
|
||||||
|
".....#....#..........#....#.....",
|
||||||
|
"....#..##..#........#..##..#....",
|
||||||
|
"....#..##..##########..##..#....",
|
||||||
|
"...##.....#..........#.....##...",
|
||||||
|
"..#..........................#..",
|
||||||
|
".#....#..........#.......#....#.",
|
||||||
|
"#..............................#",
|
||||||
|
"#.....##....#.........##...#...#",
|
||||||
|
"#..............................#",
|
||||||
|
"#..####################........#",
|
||||||
|
"#......................#.......#",
|
||||||
|
".#........#.......#...........#.",
|
||||||
|
"..#..##..................##..#..",
|
||||||
|
"...##..#................#..##...",
|
||||||
|
"..#.....#..............#.....#..",
|
||||||
|
".#..###..#............#..###..#.",
|
||||||
|
"##########............##########",
|
||||||
|
};
|
||||||
|
static constexpr Sprite TOAD{32, 18, TOAD_ROWS};
|
||||||
|
|
||||||
|
static const char *const TOAD_TONGUE_ROWS[] = {
|
||||||
|
"......####............####......",
|
||||||
|
".....#....#..........#....#.....",
|
||||||
|
"....#..##..#........#..##..#....",
|
||||||
|
"....#..##..##########..##..#....",
|
||||||
|
"...##.....#..........#.....##...",
|
||||||
|
"..#..........................#..",
|
||||||
|
".#....#..........#.......#....#.",
|
||||||
|
"#..............................#",
|
||||||
|
"#.....##....#.........##...#...#",
|
||||||
|
"#..............................#",
|
||||||
|
"#..####################........#",
|
||||||
|
"#...#..................#.......#",
|
||||||
|
".#...#....#.......#...........#.",
|
||||||
|
"..#...#..##..............##..#..",
|
||||||
|
"...##..#.##.............#..##...",
|
||||||
|
"..#.....####...........#.....#..",
|
||||||
|
".#..###..##...........#..###..#.",
|
||||||
|
"##########............##########",
|
||||||
|
};
|
||||||
|
static constexpr Sprite TOAD_TONGUE{32, 18, TOAD_TONGUE_ROWS};
|
||||||
|
|
||||||
|
static const char *const CABBAGE_A_ROWS[] = {
|
||||||
|
"#..............#",
|
||||||
|
"##...######...##",
|
||||||
|
".##.#..#...#.##.",
|
||||||
|
"..##.#.#..#.##..",
|
||||||
|
"...#..###...#...",
|
||||||
|
"...#.#.#.#..#...",
|
||||||
|
"....#..#...#....",
|
||||||
|
".....######.....",
|
||||||
|
};
|
||||||
|
static constexpr Sprite CABBAGE_A{16, 8, CABBAGE_A_ROWS};
|
||||||
|
|
||||||
|
static const char *const CABBAGE_B_ROWS[] = {
|
||||||
|
"................",
|
||||||
|
".....######.....",
|
||||||
|
"....#..#...#....",
|
||||||
|
"...#.#.#..#.#...",
|
||||||
|
"####..###...####",
|
||||||
|
"##.#.#.#.#..#.##",
|
||||||
|
"#...#..#...#...#",
|
||||||
|
".....######.....",
|
||||||
|
};
|
||||||
|
static constexpr Sprite CABBAGE_B{16, 8, CABBAGE_B_ROWS};
|
||||||
|
|
||||||
|
static const char *const MEGUMIN_BLINK_ROWS[] = {
|
||||||
|
"...............####.........",
|
||||||
|
"..............######........",
|
||||||
|
".............###...##.......",
|
||||||
|
"............####.....#......",
|
||||||
|
"...........#####............",
|
||||||
|
"..........######............",
|
||||||
|
"..........#######...........",
|
||||||
|
".........########...........",
|
||||||
|
".........#########..........",
|
||||||
|
"........##########..........",
|
||||||
|
"........#.........#.........",
|
||||||
|
"........###########.........",
|
||||||
|
"##########################..",
|
||||||
|
".########################...",
|
||||||
|
".....###############........",
|
||||||
|
".....##.#.##..#.#.##........",
|
||||||
|
".....#.............#........",
|
||||||
|
".....#........####.#........",
|
||||||
|
".....#..##....####.#........",
|
||||||
|
".....#.........#...#........",
|
||||||
|
".....##.............#.......",
|
||||||
|
"......#.....##.....#........",
|
||||||
|
".....###..........###.......",
|
||||||
|
"....####.##....##.####......",
|
||||||
|
"...######.######.######.....",
|
||||||
|
"..#######..####..#######....",
|
||||||
|
"..######...####...#######...",
|
||||||
|
".#######...#..#...#######...",
|
||||||
|
".######....#..#....######...",
|
||||||
|
".######...#....#...######...",
|
||||||
|
"#######...#....#...#######..",
|
||||||
|
"######...#......#...######..",
|
||||||
|
"######..##########..######..",
|
||||||
|
"#####...#........#...#####..",
|
||||||
|
"#####..#..........#..#####..",
|
||||||
|
"####...############...####..",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
".......###....###...........",
|
||||||
|
".......####...####..........",
|
||||||
|
};
|
||||||
|
static constexpr Sprite MEGUMIN_BLINK{28, 42, MEGUMIN_BLINK_ROWS};
|
||||||
|
|
||||||
|
} // namespace esphome::el512::konosuba
|
||||||
@@ -17,6 +17,8 @@ esphome:
|
|||||||
on_boot:
|
on_boot:
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
id(cp_fonts) = {id(f_cp_title), id(f_cp_label), id(f_cp_value), id(f_cp_huge), id(f_cp_mono), id(f_cp_code)};
|
id(cp_fonts) = {id(f_cp_title), id(f_cp_label), id(f_cp_value), id(f_cp_huge), id(f_cp_mono), id(f_cp_code)};
|
||||||
|
id(ks_fonts) = {id(f_ks_title), id(f_ks_big), id(f_ks_text), id(f_ks_pixel), id(f_ks_pixel_l), id(f_ks_jp),
|
||||||
|
id(f_ks_jp_big), id(icons_l)};
|
||||||
|
|
||||||
esp32:
|
esp32:
|
||||||
board: wemos_d1_mini32
|
board: wemos_d1_mini32
|
||||||
@@ -59,11 +61,13 @@ globals:
|
|||||||
- id: np_updated
|
- id: np_updated
|
||||||
type: uint32_t
|
type: uint32_t
|
||||||
initial_value: "0"
|
initial_value: "0"
|
||||||
# Cyberpunk pages: their fonts and the home data they show.
|
# Themed pages (Cyberpunk, KonoSuba): their fonts and the home data they show.
|
||||||
- id: cp_fonts
|
- id: cp_fonts
|
||||||
type: el512::cyber::Fonts
|
type: el512::cyber::Fonts
|
||||||
|
- id: ks_fonts
|
||||||
|
type: el512::konosuba::Fonts
|
||||||
- id: cp_home
|
- id: cp_home
|
||||||
type: el512::cyber::HomeData
|
type: el512::HomeData
|
||||||
|
|
||||||
# --- Fonts -------------------------------------------------------------------
|
# --- Fonts -------------------------------------------------------------------
|
||||||
font:
|
font:
|
||||||
@@ -159,6 +163,35 @@ font:
|
|||||||
id: f_cp_code
|
id: f_cp_code
|
||||||
size: 22
|
size: 22
|
||||||
glyphs: " 0123456789ABCDEF."
|
glyphs: " 0123456789ABCDEF."
|
||||||
|
# KonoSuba pages
|
||||||
|
- file: "gfonts://Cinzel@700"
|
||||||
|
id: f_ks_title
|
||||||
|
size: 22
|
||||||
|
glyphs: " ABCDEFGHIJKLMNOPQRSTUVWXYZ'!"
|
||||||
|
- file: "gfonts://Cinzel Decorative@900"
|
||||||
|
id: f_ks_big
|
||||||
|
size: 44
|
||||||
|
glyphs: "EXPLOSIN!"
|
||||||
|
- file: "gfonts://Nunito@700"
|
||||||
|
id: f_ks_text
|
||||||
|
size: 16
|
||||||
|
glyphsets: [GF_Latin_Core]
|
||||||
|
- file: "gfonts://Press Start 2P"
|
||||||
|
id: f_ks_pixel
|
||||||
|
size: 8
|
||||||
|
glyphsets: [GF_Latin_Core]
|
||||||
|
- file: "gfonts://Press Start 2P"
|
||||||
|
id: f_ks_pixel_l
|
||||||
|
size: 16
|
||||||
|
glyphs: " 0123456789:!ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
- file: "gfonts://Noto Sans JP@700"
|
||||||
|
id: f_ks_jp
|
||||||
|
size: 20
|
||||||
|
glyphs: "冒険者ギルドカー戦闘" # each character once
|
||||||
|
- file: "gfonts://Dela Gothic One"
|
||||||
|
id: f_ks_jp_big
|
||||||
|
size: 40
|
||||||
|
glyphs: "爆裂魔法"
|
||||||
|
|
||||||
# --- Data from Home Assistant -------------------------------------------------
|
# --- Data from Home Assistant -------------------------------------------------
|
||||||
text_sensor:
|
text_sensor:
|
||||||
@@ -350,6 +383,10 @@ select:
|
|||||||
- Cyber Breach
|
- Cyber Breach
|
||||||
- Cyber Scan
|
- Cyber Scan
|
||||||
- Cyber Split
|
- Cyber Split
|
||||||
|
- KonoSuba Explosion
|
||||||
|
- KonoSuba Guild
|
||||||
|
- KonoSuba Card
|
||||||
|
- KonoSuba Battle
|
||||||
initial_option: Clock
|
initial_option: Clock
|
||||||
set_action:
|
set_action:
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
@@ -366,6 +403,10 @@ select:
|
|||||||
else if (x == "Cyber Breach") p = id(page_cyber_breach);
|
else if (x == "Cyber Breach") p = id(page_cyber_breach);
|
||||||
else if (x == "Cyber Scan") p = id(page_cyber_scan);
|
else if (x == "Cyber Scan") p = id(page_cyber_scan);
|
||||||
else if (x == "Cyber Split") p = id(page_cyber_split);
|
else if (x == "Cyber Split") p = id(page_cyber_split);
|
||||||
|
else if (x == "KonoSuba Explosion") p = id(page_ks_explosion);
|
||||||
|
else if (x == "KonoSuba Guild") p = id(page_ks_guild);
|
||||||
|
else if (x == "KonoSuba Card") p = id(page_ks_card);
|
||||||
|
else if (x == "KonoSuba Battle") p = id(page_ks_battle);
|
||||||
id(panel).show_page(p);
|
id(panel).show_page(p);
|
||||||
id(panel).update();
|
id(panel).update();
|
||||||
|
|
||||||
@@ -386,7 +427,7 @@ interval:
|
|||||||
if (id(sw_pause).state || id(panel).is_still_alive_playing()) { id(rotation_elapsed) = 0; return; }
|
if (id(sw_pause).state || id(panel).is_still_alive_playing()) { id(rotation_elapsed) = 0; return; }
|
||||||
if (++id(rotation_elapsed) < (int) id(page_interval).state) return;
|
if (++id(rotation_elapsed) < (int) id(page_interval).state) return;
|
||||||
id(rotation_elapsed) = 0;
|
id(rotation_elapsed) = 0;
|
||||||
for (int i = 0; i < 12; i++) {
|
for (int i = 0; i < 16; i++) {
|
||||||
id(panel).show_next_page();
|
id(panel).show_next_page();
|
||||||
const auto *p = id(panel).get_active_page();
|
const auto *p = id(panel).get_active_page();
|
||||||
const std::string &np = id(np_state).state;
|
const std::string &np = id(np_state).state;
|
||||||
@@ -395,7 +436,7 @@ interval:
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
id(panel).update();
|
id(panel).update();
|
||||||
# Home data for the Cyberpunk pages.
|
# Home data for the themed pages.
|
||||||
- interval: 1s
|
- interval: 1s
|
||||||
then:
|
then:
|
||||||
- lambda: |-
|
- lambda: |-
|
||||||
@@ -405,6 +446,8 @@ interval:
|
|||||||
d.humidity = id(weather_hum).state;
|
d.humidity = id(weather_hum).state;
|
||||||
d.wind = id(weather_wind).state;
|
d.wind = id(weather_wind).state;
|
||||||
d.solar = id(solar_power).state;
|
d.solar = id(solar_power).state;
|
||||||
|
d.solar_today = id(solar_today).state;
|
||||||
|
d.agenda = id(agenda_events).state;
|
||||||
d.house = id(house_power).state;
|
d.house = id(house_power).state;
|
||||||
d.grid = id(grid_import).state - id(grid_export).state;
|
d.grid = id(grid_import).state - id(grid_export).state;
|
||||||
d.rooms[0] = {"Woonkamer", id(t_woonkamer).state};
|
d.rooms[0] = {"Woonkamer", id(t_woonkamer).state};
|
||||||
@@ -421,7 +464,8 @@ display:
|
|||||||
update_interval: 1s
|
update_interval: 1s
|
||||||
still_alive_page: page_still_alive
|
still_alive_page: page_still_alive
|
||||||
# Redrawn every animation_interval (~15 fps) while shown.
|
# Redrawn every animation_interval (~15 fps) while shown.
|
||||||
animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split]
|
animated_pages: [page_cyber_hud, page_cyber_breach, page_cyber_scan, page_cyber_split,
|
||||||
|
page_ks_explosion, page_ks_guild, page_ks_card, page_ks_battle]
|
||||||
animation_interval: 66ms
|
animation_interval: 66ms
|
||||||
on_page_change:
|
on_page_change:
|
||||||
- then:
|
- then:
|
||||||
@@ -438,6 +482,10 @@ display:
|
|||||||
else if (to == id(page_cyber_breach)) name = "Cyber Breach";
|
else if (to == id(page_cyber_breach)) name = "Cyber Breach";
|
||||||
else if (to == id(page_cyber_scan)) name = "Cyber Scan";
|
else if (to == id(page_cyber_scan)) name = "Cyber Scan";
|
||||||
else if (to == id(page_cyber_split)) name = "Cyber Split";
|
else if (to == id(page_cyber_split)) name = "Cyber Split";
|
||||||
|
else if (to == id(page_ks_explosion)) name = "KonoSuba Explosion";
|
||||||
|
else if (to == id(page_ks_guild)) name = "KonoSuba Guild";
|
||||||
|
else if (to == id(page_ks_card)) name = "KonoSuba Card";
|
||||||
|
else if (to == id(page_ks_battle)) name = "KonoSuba Battle";
|
||||||
if (id(page_select).current_option() != name) id(page_select).publish_state(name);
|
if (id(page_select).current_option() != name) id(page_select).publish_state(name);
|
||||||
pages:
|
pages:
|
||||||
# 1. Clock, date and current weather
|
# 1. Clock, date and current weather
|
||||||
@@ -633,3 +681,17 @@ display:
|
|||||||
- id: page_cyber_split
|
- id: page_cyber_split
|
||||||
lambda: |-
|
lambda: |-
|
||||||
el512::cyber::split_hud(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now());
|
el512::cyber::split_hud(*id(panel), id(cp_fonts), id(cp_home), id(ha_time).now());
|
||||||
|
|
||||||
|
# 13-16. KonoSuba pages (components/el512/konosuba.cpp)
|
||||||
|
- id: page_ks_explosion
|
||||||
|
lambda: |-
|
||||||
|
el512::konosuba::explosion(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now());
|
||||||
|
- id: page_ks_guild
|
||||||
|
lambda: |-
|
||||||
|
el512::konosuba::guild_board(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now());
|
||||||
|
- id: page_ks_card
|
||||||
|
lambda: |-
|
||||||
|
el512::konosuba::adventurer_card(*id(panel), id(ks_fonts), id(cp_home), id(ha_time).now());
|
||||||
|
- id: page_ks_battle
|
||||||
|
lambda: |-
|
||||||
|
el512::konosuba::party_battle(*id(panel), id(ks_fonts), id(cp_home));
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pixel art for the KonoSuba pages (components/el512/konosuba.cpp).
|
||||||
|
|
||||||
|
Original 1-bit fan art: '#' is a lit pixel, anything else is dark. Writes
|
||||||
|
components/el512/konosuba_sprites.h and, with --preview, a magnified PNG of all
|
||||||
|
sprites to check them without the panel.
|
||||||
|
|
||||||
|
python tools/konosuba_sprites.py
|
||||||
|
python tools/konosuba_sprites.py --preview sprites.png
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SPRITES = {
|
||||||
|
# Megumin standing, staff in her right hand (viewer's right).
|
||||||
|
"MEGUMIN": [
|
||||||
|
"...............####.........",
|
||||||
|
"..............######........",
|
||||||
|
".............###...##.......",
|
||||||
|
"............####.....#......",
|
||||||
|
"...........#####............",
|
||||||
|
"..........######............",
|
||||||
|
"..........#######...........",
|
||||||
|
".........########...........",
|
||||||
|
".........#########..........",
|
||||||
|
"........##########..........",
|
||||||
|
"........#.........#.........",
|
||||||
|
"........###########.........",
|
||||||
|
"##########################..",
|
||||||
|
".########################...",
|
||||||
|
".....###############........",
|
||||||
|
".....##.#.##..#.#.##........",
|
||||||
|
".....#.............#........",
|
||||||
|
".....#..##....####.#........",
|
||||||
|
".....#..##....####.#........",
|
||||||
|
".....#.........#...#........",
|
||||||
|
".....##.............#.......",
|
||||||
|
"......#.....##.....#........",
|
||||||
|
".....###..........###.......",
|
||||||
|
"....####.##....##.####......",
|
||||||
|
"...######.######.######.....",
|
||||||
|
"..#######..####..#######....",
|
||||||
|
"..######...####...#######...",
|
||||||
|
".#######...#..#...#######...",
|
||||||
|
".######....#..#....######...",
|
||||||
|
".######...#....#...######...",
|
||||||
|
"#######...#....#...#######..",
|
||||||
|
"######...#......#...######..",
|
||||||
|
"######..##########..######..",
|
||||||
|
"#####...#........#...#####..",
|
||||||
|
"#####..#..........#..#####..",
|
||||||
|
"####...############...####..",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
"........#......#............",
|
||||||
|
".......###....###...........",
|
||||||
|
".......####...####..........",
|
||||||
|
],
|
||||||
|
# Her staff: a gnarled top around a gem.
|
||||||
|
"STAFF": [
|
||||||
|
"..#####..",
|
||||||
|
".#.....#.",
|
||||||
|
"#..###..#",
|
||||||
|
"#.#####.#",
|
||||||
|
"#.#####.#",
|
||||||
|
"#..###..#",
|
||||||
|
".#.....#.",
|
||||||
|
"..#...#..",
|
||||||
|
"...#.#...",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"...##....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....##...",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"...##....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....##...",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
"....#....",
|
||||||
|
],
|
||||||
|
# Megumin after the explosion: face down, hat fallen off, staff dropped.
|
||||||
|
"MEGUMIN_DOWN": [
|
||||||
|
"....###.....................................................",
|
||||||
|
"...#####....................................................",
|
||||||
|
"..######....................................................",
|
||||||
|
"..#######...................................................",
|
||||||
|
".########.##................................................",
|
||||||
|
".#########..##.........#######..............................",
|
||||||
|
"###########...##.....###########............................",
|
||||||
|
"#.........#.....#...##.#.#.#.#.##..#####################....",
|
||||||
|
"###########......#..#.#.#.#.#.#.#.########################..",
|
||||||
|
"...................##############.###########.##########.###",
|
||||||
|
"....................############..#####...........######...##",
|
||||||
|
"......................########....####.............####.....",
|
||||||
|
],
|
||||||
|
# Small party members for the battle window.
|
||||||
|
"KAZUMA": [
|
||||||
|
"....######......",
|
||||||
|
"...########.....",
|
||||||
|
"..##########....",
|
||||||
|
"..#.#.##.#.#....",
|
||||||
|
"..#........#....",
|
||||||
|
"..#..#..#..#....",
|
||||||
|
"..#........#....",
|
||||||
|
"...#..##..#.....",
|
||||||
|
"....######......",
|
||||||
|
"..##########....",
|
||||||
|
".###.####.###...",
|
||||||
|
".##..#..#..##...",
|
||||||
|
".##..#..#..##.#.",
|
||||||
|
".##..####..##.#.",
|
||||||
|
".##..#..#..##.#.",
|
||||||
|
"..#..#..#..#.###",
|
||||||
|
"....#....#....#.",
|
||||||
|
"....#....#......",
|
||||||
|
"...##....##.....",
|
||||||
|
],
|
||||||
|
"AQUA": [
|
||||||
|
".....####.......",
|
||||||
|
"....#....#......",
|
||||||
|
".....####.......",
|
||||||
|
"....######......",
|
||||||
|
"...########.....",
|
||||||
|
"..##########....",
|
||||||
|
"..#........#....",
|
||||||
|
"..#..#..#..#....",
|
||||||
|
"..#........#....",
|
||||||
|
"..##..##..##....",
|
||||||
|
"..###....###....",
|
||||||
|
".####.##.####...",
|
||||||
|
".##..####..##...",
|
||||||
|
".##.#....#.##...",
|
||||||
|
".#.#......#.#...",
|
||||||
|
".#.#......#.#...",
|
||||||
|
"...########.....",
|
||||||
|
"....#....#......",
|
||||||
|
"....#....#......",
|
||||||
|
"...##....##.....",
|
||||||
|
],
|
||||||
|
"MEGUMIN_S": [
|
||||||
|
".......##.......",
|
||||||
|
"......####......",
|
||||||
|
".....######.....",
|
||||||
|
"....########....",
|
||||||
|
"....#......#....",
|
||||||
|
"################",
|
||||||
|
"...##########...",
|
||||||
|
"...#........#...",
|
||||||
|
"...#.#...##.#...",
|
||||||
|
"...#........#...",
|
||||||
|
"....#..##..#....",
|
||||||
|
".....######.....",
|
||||||
|
"...##########...",
|
||||||
|
"..############..",
|
||||||
|
"..###.####.###..",
|
||||||
|
"..##..#..#..##..",
|
||||||
|
"..##..####..##..",
|
||||||
|
"..############..",
|
||||||
|
".....#....#.....",
|
||||||
|
".....#....#.....",
|
||||||
|
"....##....##....",
|
||||||
|
],
|
||||||
|
"DARKNESS": [
|
||||||
|
"....######......",
|
||||||
|
"...########.###.",
|
||||||
|
"..##########.##.",
|
||||||
|
"..#.##..##.#..#.",
|
||||||
|
"..#........#....",
|
||||||
|
"..#..#..#..#....",
|
||||||
|
"..#........#....",
|
||||||
|
"...#..##..#.....",
|
||||||
|
"....######......",
|
||||||
|
"############....",
|
||||||
|
"##.########.##..",
|
||||||
|
"#..#.#..#.#..#..",
|
||||||
|
"...#.####.#.....",
|
||||||
|
"...#.#..#.#.....",
|
||||||
|
"...#.####.#.....",
|
||||||
|
"...########.....",
|
||||||
|
"....#....#......",
|
||||||
|
"....##...##.....",
|
||||||
|
"...###...###....",
|
||||||
|
],
|
||||||
|
# Giant toad, mouth closed and with its tongue out.
|
||||||
|
"TOAD": [
|
||||||
|
"......####............####......",
|
||||||
|
".....#....#..........#....#.....",
|
||||||
|
"....#..##..#........#..##..#....",
|
||||||
|
"....#..##..##########..##..#....",
|
||||||
|
"...##.....#..........#.....##...",
|
||||||
|
"..#..........................#..",
|
||||||
|
".#....#..........#.......#....#.",
|
||||||
|
"#..............................#",
|
||||||
|
"#.....##....#.........##...#...#",
|
||||||
|
"#..............................#",
|
||||||
|
"#..####################........#",
|
||||||
|
"#......................#.......#",
|
||||||
|
".#........#.......#...........#.",
|
||||||
|
"..#..##..................##..#..",
|
||||||
|
"...##..#................#..##...",
|
||||||
|
"..#.....#..............#.....#..",
|
||||||
|
".#..###..#............#..###..#.",
|
||||||
|
"##########............##########",
|
||||||
|
],
|
||||||
|
"TOAD_TONGUE": [
|
||||||
|
"......####............####......",
|
||||||
|
".....#....#..........#....#.....",
|
||||||
|
"....#..##..#........#..##..#....",
|
||||||
|
"....#..##..##########..##..#....",
|
||||||
|
"...##.....#..........#.....##...",
|
||||||
|
"..#..........................#..",
|
||||||
|
".#....#..........#.......#....#.",
|
||||||
|
"#..............................#",
|
||||||
|
"#.....##....#.........##...#...#",
|
||||||
|
"#..............................#",
|
||||||
|
"#..####################........#",
|
||||||
|
"#...#..................#.......#",
|
||||||
|
".#...#....#.......#...........#.",
|
||||||
|
"..#...#..##..............##..#..",
|
||||||
|
"...##..#.##.............#..##...",
|
||||||
|
"..#.....####...........#.....#..",
|
||||||
|
".#..###..##...........#..###..#.",
|
||||||
|
"##########............##########",
|
||||||
|
],
|
||||||
|
# Flying cabbage, wings up and down.
|
||||||
|
"CABBAGE_A": [
|
||||||
|
"#..............#",
|
||||||
|
"##...######...##",
|
||||||
|
".##.#..#...#.##.",
|
||||||
|
"..##.#.#..#.##..",
|
||||||
|
"...#..###...#...",
|
||||||
|
"...#.#.#.#..#...",
|
||||||
|
"....#..#...#....",
|
||||||
|
".....######.....",
|
||||||
|
],
|
||||||
|
"CABBAGE_B": [
|
||||||
|
"................",
|
||||||
|
".....######.....",
|
||||||
|
"....#..#...#....",
|
||||||
|
"...#.#.#..#.#...",
|
||||||
|
"####..###...####",
|
||||||
|
"##.#.#.#.#..#.##",
|
||||||
|
"#...#..#...#...#",
|
||||||
|
".....######.....",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def padded(rows):
|
||||||
|
"""Rows padded with dark pixels to the widest one."""
|
||||||
|
w = max(len(r) for r in rows)
|
||||||
|
return [r.ljust(w, ".") for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
for _name in SPRITES:
|
||||||
|
SPRITES[_name] = padded(SPRITES[_name])
|
||||||
|
|
||||||
|
# Megumin with her visible eye closed (the other is under the eyepatch).
|
||||||
|
SPRITES["MEGUMIN_BLINK"] = list(SPRITES["MEGUMIN"])
|
||||||
|
SPRITES["MEGUMIN_BLINK"][17] = SPRITES["MEGUMIN"][17][:8] + ".." + SPRITES["MEGUMIN"][17][10:]
|
||||||
|
|
||||||
|
|
||||||
|
def write_header(path):
|
||||||
|
out = [
|
||||||
|
"// Pixel art for the KonoSuba pages, generated by tools/konosuba_sprites.py;",
|
||||||
|
"// edit the art there. Original 1-bit fan art, '#' = lit pixel.",
|
||||||
|
"#pragma once",
|
||||||
|
"",
|
||||||
|
"#include <stdint.h>",
|
||||||
|
"",
|
||||||
|
"namespace esphome::el512::konosuba {",
|
||||||
|
"",
|
||||||
|
"struct Sprite {",
|
||||||
|
" uint8_t w, h;",
|
||||||
|
" const char *const *rows;",
|
||||||
|
"};",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for name, rows in SPRITES.items():
|
||||||
|
out.append(f"static const char *const {name}_ROWS[] = {{")
|
||||||
|
out += [f' "{r}",' for r in rows]
|
||||||
|
out.append("};")
|
||||||
|
out.append(f"static constexpr Sprite {name}{{{len(rows[0])}, {len(rows)}, {name}_ROWS}};")
|
||||||
|
out.append("")
|
||||||
|
out.append("} // namespace esphome::el512::konosuba")
|
||||||
|
path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def write_preview(path, scale=6):
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
pad = 4
|
||||||
|
names = list(SPRITES)
|
||||||
|
width = sum(len(SPRITES[n][0]) + pad for n in names) * scale
|
||||||
|
height = (max(len(r) for r in SPRITES.values()) + pad) * scale + 14
|
||||||
|
img = Image.new("RGB", (width, height), (20, 12, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
x = 0
|
||||||
|
for name in names:
|
||||||
|
rows = SPRITES[name]
|
||||||
|
draw.text((x + 2, 2), name, fill=(120, 80, 0))
|
||||||
|
for y, row in enumerate(rows):
|
||||||
|
for cx, ch in enumerate(row):
|
||||||
|
if ch == "#":
|
||||||
|
x0, y0 = x + cx * scale, 14 + y * scale
|
||||||
|
draw.rectangle([x0, y0, x0 + scale - 2, y0 + scale - 2], fill=(255, 170, 30))
|
||||||
|
x += (len(rows[0]) + pad) * scale
|
||||||
|
img.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--out", type=Path, default=root / "components/el512/konosuba_sprites.h")
|
||||||
|
ap.add_argument("--preview", type=Path, help="also write a magnified PNG of all sprites")
|
||||||
|
args = ap.parse_args()
|
||||||
|
write_header(args.out)
|
||||||
|
print(f"wrote {args.out}")
|
||||||
|
if args.preview:
|
||||||
|
write_preview(args.preview)
|
||||||
|
print(f"wrote {args.preview}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user