Public Access
Add Portal-style Still Alive demo and split build envs
- src/still_alive: timed console text, typed scrolling credits and ASCII art, laid out for 512x256 (41x31 console, 41x9 credits, 41x20 picture). Plays placeholder content unless imported.h exists. - tools/import_still_alive.py: generates the git-ignored imported.h (text, timing, art, credits) from SDSkyKlouD/still-alive-web. - platformio.ini: envs still_alive (default) and esp32dev (test images, moved to src/test_images). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ff80c9e4ac
commit
329f97a5ec
11 files changed
+680
-11
No files matched your search
@@ -24,3 +24,6 @@ $RECYCLE.BIN/
|
||||
# Editors
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Imported still_alive text (tools/import_still_alive.py)
|
||||
src/still_alive/imported.h
|
||||
@@ -66,12 +66,14 @@ truth; if anything here disagrees with it, the datasheet wins.
|
||||
## Layout
|
||||
|
||||
```
|
||||
platformio.ini build config (esp32dev, Arduino, Adafruit GFX)
|
||||
platformio.ini build config: envs still_alive (default) and esp32dev
|
||||
include/pins.h pin map
|
||||
src/main.cpp test sketch: cycles through test images
|
||||
src/still_alive/ Portal-style end-credits demo (env still_alive)
|
||||
src/test_images/ test sketch: cycles through 12 test images (env esp32dev)
|
||||
lib/EL512/ local library
|
||||
src/EL512Panel.* framebuffer, row encoder, I2S DMA refresh
|
||||
src/EL512_GFX.* Adafruit_GFX subclass
|
||||
tools/ import_still_alive.py
|
||||
test/ unit tests (placeholder)
|
||||
docs/ datasheet and notes
|
||||
```
|
||||
@@ -79,19 +81,29 @@ docs/ datasheet and notes
|
||||
## Build and flash
|
||||
|
||||
Requires [PlatformIO](https://platformio.org/) (CLI or the VS Code extension).
|
||||
The upload and monitor port is set to COM20 in `platformio.ini`.
|
||||
|
||||
```bash
|
||||
pio run
|
||||
```
|
||||
|
||||
```bash
|
||||
pio run -t upload
|
||||
```
|
||||
|
||||
```bash
|
||||
pio run -e still_alive -t upload # end-credits demo
|
||||
pio run -e esp32dev -t upload # test images
|
||||
pio device monitor
|
||||
```
|
||||
|
||||
## Still Alive demo
|
||||
|
||||
A recreation of Portal's ending: timed song text typed into a console on the
|
||||
left, credits scrolling top right, ASCII art bottom right. Without extra data it
|
||||
plays placeholder text. To import the original text, timing, art and credits from
|
||||
[SDSkyKlouD/still-alive-web](https://github.com/SDSkyKlouD/still-alive-web):
|
||||
|
||||
```bash
|
||||
python tools/import_still_alive.py
|
||||
pio run -e still_alive -t upload
|
||||
```
|
||||
|
||||
This writes `src/still_alive/imported.h`, which is git-ignored. Reset the board and
|
||||
start the song when the serial log says (7.75 s after reset with the imported data).
|
||||
|
||||
## Using the driver
|
||||
|
||||
```cpp
|
||||
|
||||
+12
-1
@@ -1,7 +1,10 @@
|
||||
; Planar EL512.256-H electroluminescent panel driver for ESP32 (ESP-WROOM-32)
|
||||
; https://docs.platformio.org/page/projectconf.html
|
||||
|
||||
[env:esp32dev]
|
||||
[platformio]
|
||||
default_envs = still_alive
|
||||
|
||||
[env]
|
||||
platform = espressif32@6.10.0
|
||||
board = esp32dev
|
||||
framework = arduino
|
||||
@@ -12,3 +15,11 @@ lib_deps =
|
||||
adafruit/Adafruit GFX Library
|
||||
build_flags =
|
||||
-Wall
|
||||
|
||||
; Test images: cycles through 12 labelled test patterns.
|
||||
[env:esp32dev]
|
||||
build_src_filter = +<test_images/>
|
||||
|
||||
; Portal-style end-credits console.
|
||||
[env:still_alive]
|
||||
build_src_filter = +<still_alive/>
|
||||
@@ -0,0 +1,76 @@
|
||||
// ASCII pictures for the bottom-right box (41 columns x 20 rows fit).
|
||||
// Each picture is centred in the box. Used when there is no imported.h.
|
||||
#pragma once
|
||||
|
||||
#include "show_data.h"
|
||||
|
||||
static const char* const ART_CAKE_LINES[] = {
|
||||
" ) ) )",
|
||||
" (_) (_) (_)",
|
||||
" | | |",
|
||||
" .---|-----|-----|---.",
|
||||
" |~~~~~~~~~~~~~~~~~~~|",
|
||||
" | o o o o |",
|
||||
" |~~~~~~~~~~~~~~~~~~~|",
|
||||
" | |",
|
||||
" '-------------------'",
|
||||
};
|
||||
|
||||
static const char* const ART_HEART_LINES[] = {
|
||||
" .-\"\"\"-. .-\"\"\"-.",
|
||||
" / \\ / \\",
|
||||
" | V |",
|
||||
" \\ /",
|
||||
" '. .'",
|
||||
" '. .'",
|
||||
" '. .'",
|
||||
" '. .'",
|
||||
" V",
|
||||
};
|
||||
|
||||
static const char* const ART_CHECK_LINES[] = {
|
||||
" ##",
|
||||
" ###",
|
||||
" ###",
|
||||
" ## ###",
|
||||
" ### ###",
|
||||
" ### ###",
|
||||
" ###",
|
||||
"",
|
||||
" TEST PASSED",
|
||||
};
|
||||
|
||||
static const char* const ART_PANEL_LINES[] = {
|
||||
" .--------------------------.",
|
||||
" | EL512.256-H 512 x 256 |",
|
||||
" | |",
|
||||
" | VCLK 5.0 MHz |",
|
||||
" | HS 256 + 16 clk |",
|
||||
" | VS 70.4 Hz |",
|
||||
" | |",
|
||||
" '--------------------------'",
|
||||
" | | | | |",
|
||||
" ESP32 I2S0 DMA",
|
||||
};
|
||||
|
||||
#define EL_ART(a) {a, sizeof(a) / sizeof(a[0])}
|
||||
|
||||
// Index with the ART_* ids below.
|
||||
static const Art ARTS[] = {
|
||||
{nullptr, 0},
|
||||
EL_ART(ART_CAKE_LINES),
|
||||
EL_ART(ART_HEART_LINES),
|
||||
EL_ART(ART_CHECK_LINES),
|
||||
EL_ART(ART_PANEL_LINES),
|
||||
};
|
||||
|
||||
#undef EL_ART
|
||||
|
||||
static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]);
|
||||
|
||||
enum : int8_t {
|
||||
ART_CAKE = 1,
|
||||
ART_HEART,
|
||||
ART_CHECK,
|
||||
ART_PANEL,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
// Placeholder credits, used when there is no imported.h. Typed into the
|
||||
// top-right box one line at a time, scrolling up, looping.
|
||||
// The box is 41 columns wide; longer lines wrap.
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
static constexpr uint32_t CREDITS_START_MS = 0;
|
||||
static constexpr uint32_t CREDIT_CHAR_US = 68624; // Portal's credits speed
|
||||
static constexpr bool CREDITS_LOOP = true;
|
||||
|
||||
static const char* const CREDITS[] = {
|
||||
"",
|
||||
"PLANAR EL512.256-H",
|
||||
"512 x 256 electroluminescent",
|
||||
"",
|
||||
"Driven by ......... ESP32",
|
||||
"Refresh ........... I2S0 + DMA",
|
||||
"Pixel clock ....... 5.0 MHz",
|
||||
"Frame rate ........ 70.4 Hz",
|
||||
"Stream ............ 142 KB",
|
||||
"Graphics .......... Adafruit GFX",
|
||||
"",
|
||||
"Bugs found and fixed:",
|
||||
" BCK at half the byte rate",
|
||||
" VCLK on the wrong edge",
|
||||
" one very loose wire",
|
||||
"",
|
||||
"Known issues:",
|
||||
" row stripes go whoosh",
|
||||
" some burn-in, it is old",
|
||||
"",
|
||||
"No panels were harmed",
|
||||
"in the making of this demo.",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
};
|
||||
|
||||
static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]);
|
||||
@@ -0,0 +1,31 @@
|
||||
// Placeholder console text, used when there is no imported.h.
|
||||
//
|
||||
// To play the real song text with its original timing, run
|
||||
// python tools/import_still_alive.py
|
||||
// which writes imported.h next to this file (see the script for details).
|
||||
//
|
||||
// Or write your own steps here: each one starts at startMs (from the start of
|
||||
// the show), is typed out evenly over durMs, and can start a new line after it
|
||||
// (STEP_NEWLINE), clear the console first (STEP_CLEAR) and switch the picture.
|
||||
// The console is 41 columns x 31 rows; longer lines wrap.
|
||||
#pragma once
|
||||
|
||||
#include "art.h"
|
||||
#include "show_data.h"
|
||||
|
||||
static constexpr uint32_t SONG_START_MS = 0; // when to start the music
|
||||
|
||||
static const LyricStep LYRICS[] = {
|
||||
{0, 2600, STEP_NEWLINE, ART_PANEL, "Forms EL-512256: Panel Bring-up Report"},
|
||||
{3000, 0, STEP_NEWLINE, ART_KEEP, ""},
|
||||
{3500, 1400, STEP_NEWLINE, ART_KEEP, "Your lyrics go here."},
|
||||
{5500, 2100, STEP_NEWLINE, ART_KEEP, "Run tools/import_still_alive.py"},
|
||||
{8000, 2600, STEP_NEWLINE, ART_CHECK, "to import them with the song's timing,"},
|
||||
{11000, 2300, STEP_NEWLINE, ART_KEEP, "or edit src/still_alive/lyrics.h."},
|
||||
{15000, 0, STEP_CLEAR, ART_KEEP, ""},
|
||||
{15500, 2300, STEP_NEWLINE, ART_HEART, "Each step starts at its startMs and"},
|
||||
{18000, 1500, STEP_NEWLINE, ART_KEEP, "is typed out over durMs."},
|
||||
{21000, 2100, STEP_NEWLINE, ART_CAKE, "Reset the board to start over."},
|
||||
};
|
||||
|
||||
static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]);
|
||||
@@ -0,0 +1,192 @@
|
||||
// Portal-style end-credits console on the EL512.256-H:
|
||||
// timed lyrics on the left, typed credits top right, ASCII art bottom right.
|
||||
//
|
||||
// Content: imported.h if present (generated by tools/import_still_alive.py),
|
||||
// otherwise the placeholder lyrics.h + art.h + credits.h.
|
||||
#include <Arduino.h>
|
||||
#include <EL512.h>
|
||||
#include <string.h>
|
||||
|
||||
#if __has_include("imported.h")
|
||||
#include "imported.h"
|
||||
#else
|
||||
#include "art.h"
|
||||
#include "credits.h"
|
||||
#include "lyrics.h"
|
||||
#endif
|
||||
#include "pins.h"
|
||||
#include "text_box.h"
|
||||
|
||||
static EL512Panel panel({
|
||||
el512pins::VCLK,
|
||||
el512pins::VID,
|
||||
el512pins::TVID,
|
||||
el512pins::HS,
|
||||
el512pins::VS,
|
||||
el512pins::ENABLE,
|
||||
el512pins::LOWPWR_N,
|
||||
});
|
||||
static EL512_GFX display(panel);
|
||||
|
||||
static constexpr uint32_t START_DELAY_MS = 1000; // after boot or restart
|
||||
static constexpr uint32_t CURSOR_BLINK_MS = 300;
|
||||
static constexpr uint32_t RESTART_MS = 10000; // after the last step
|
||||
|
||||
// 41 x 31 lyrics console | 41 x 9 credits over a 41 x 20 picture.
|
||||
static TextBox lyricsBox(0, 0, 254, 256);
|
||||
static TextBox creditsBox(256, 0, 256, 86);
|
||||
static TextBox artBox(256, 88, 256, 168);
|
||||
|
||||
static uint32_t showStart = 0;
|
||||
static size_t step = 0;
|
||||
static size_t charIdx = 0;
|
||||
static bool stepStarted = false;
|
||||
static uint32_t finishedAt = 0;
|
||||
|
||||
static size_t creditIdx = 0;
|
||||
static size_t creditChar = 0;
|
||||
static bool creditLineOpen = false;
|
||||
static bool creditsDone = false;
|
||||
static uint32_t creditLineAt = 0; // show time the current credits line started
|
||||
|
||||
static bool due(uint32_t now, uint32_t at) { return (int32_t)(now - at) >= 0; }
|
||||
|
||||
static void showArt(int8_t id) {
|
||||
artBox.clear();
|
||||
if (id <= ART_NONE || (size_t)id >= ART_COUNT) return;
|
||||
const Art& art = ARTS[id];
|
||||
size_t width = 0;
|
||||
for (uint8_t i = 0; i < art.count; i++) width = std::max(width, strlen(art.lines[i]));
|
||||
const uint8_t top = art.count < artBox.rows() ? (artBox.rows() - art.count) / 2 : 0;
|
||||
const uint8_t left = width < artBox.cols() ? (artBox.cols() - width) / 2 : 0;
|
||||
for (uint8_t r = 0; r < top; r++) artBox.put('\n');
|
||||
for (uint8_t i = 0; i < art.count && top + i < artBox.rows(); i++) {
|
||||
if (i > 0) artBox.put('\n');
|
||||
for (uint8_t c = 0; c < left; c++) artBox.put(' ');
|
||||
// Truncate instead of wrapping so a wide picture keeps its shape.
|
||||
for (const char* p = art.lines[i]; *p && left + (p - art.lines[i]) < artBox.cols(); p++)
|
||||
artBox.put(*p);
|
||||
}
|
||||
}
|
||||
|
||||
static void startShow(uint32_t now) {
|
||||
lyricsBox.clear();
|
||||
creditsBox.clear();
|
||||
showArt(ART_NONE);
|
||||
showStart = now + START_DELAY_MS;
|
||||
step = 0;
|
||||
charIdx = 0;
|
||||
stepStarted = false;
|
||||
finishedAt = 0;
|
||||
creditIdx = 0;
|
||||
creditChar = 0;
|
||||
creditLineOpen = false;
|
||||
creditsDone = false;
|
||||
creditLineAt = CREDITS_START_MS;
|
||||
// Credits are typed on the bottom row and scroll up, as in the game.
|
||||
for (uint8_t r = 1; r < creditsBox.rows(); r++) creditsBox.put('\n');
|
||||
}
|
||||
|
||||
// Type whatever the clock says should be on screen by now; true if anything changed.
|
||||
static bool updateLyrics(uint32_t now) {
|
||||
if (!due(now, showStart)) return false;
|
||||
const uint32_t t = now - showStart;
|
||||
bool changed = false;
|
||||
while (step < LYRIC_COUNT && t >= LYRICS[step].startMs) {
|
||||
const LyricStep& s = LYRICS[step];
|
||||
if (!stepStarted) {
|
||||
stepStarted = true;
|
||||
if (s.flags & STEP_CLEAR) lyricsBox.clear();
|
||||
if (s.art != ART_KEEP) showArt(s.art);
|
||||
changed = true;
|
||||
}
|
||||
const size_t len = strlen(s.text);
|
||||
const uint32_t elapsed = t - s.startMs;
|
||||
const size_t want = (s.durMs == 0 || elapsed >= s.durMs) ? len : len * elapsed / s.durMs;
|
||||
while (charIdx < want) {
|
||||
lyricsBox.put(s.text[charIdx++]);
|
||||
changed = true;
|
||||
}
|
||||
if (charIdx < len) break; // still typing this step
|
||||
if (s.flags & STEP_NEWLINE) lyricsBox.put('\n');
|
||||
step++;
|
||||
charIdx = 0;
|
||||
stepStarted = false;
|
||||
changed = true;
|
||||
if (step == LYRIC_COUNT) finishedAt = now;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// Credits: each line takes CREDIT_CHAR_US per character (an empty line counts as
|
||||
// one), typed evenly, then the next line starts on a fresh bottom row.
|
||||
static bool updateCredits(uint32_t now) {
|
||||
if (creditsDone || !due(now, showStart)) return false;
|
||||
const uint32_t t = now - showStart;
|
||||
if (t < CREDITS_START_MS) return false;
|
||||
bool changed = false;
|
||||
while (true) {
|
||||
const char* line = CREDITS[creditIdx];
|
||||
const size_t len = strlen(line);
|
||||
if (!creditLineOpen) {
|
||||
creditLineOpen = true;
|
||||
creditsBox.put('\n');
|
||||
changed = true;
|
||||
}
|
||||
const uint32_t dur = (uint32_t)((uint64_t)CREDIT_CHAR_US * (len ? len : 1) / 1000);
|
||||
const uint32_t elapsed = t - creditLineAt;
|
||||
const size_t want = elapsed >= dur ? len : len * elapsed / dur;
|
||||
while (creditChar < want) {
|
||||
creditsBox.put(line[creditChar++]);
|
||||
changed = true;
|
||||
}
|
||||
if (elapsed < dur) break;
|
||||
creditLineAt += dur;
|
||||
creditChar = 0;
|
||||
creditLineOpen = false;
|
||||
if (++creditIdx == CREDIT_COUNT) {
|
||||
creditIdx = 0;
|
||||
if (!CREDITS_LOOP) {
|
||||
creditsDone = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
Serial.println("EL512.256-H still alive");
|
||||
if (!display.begin()) {
|
||||
Serial.println("panel init failed (out of DMA memory?)");
|
||||
while (true) delay(1000);
|
||||
}
|
||||
Serial.printf("%u lyric steps, %u pictures, %u credit lines\n", (unsigned)LYRIC_COUNT,
|
||||
(unsigned)ART_COUNT, (unsigned)CREDIT_COUNT);
|
||||
Serial.printf("start the song %lu ms after reset\n",
|
||||
(unsigned long)(START_DELAY_MS + SONG_START_MS));
|
||||
startShow(millis());
|
||||
}
|
||||
|
||||
void loop() {
|
||||
static bool cursorWas = false;
|
||||
const uint32_t now = millis();
|
||||
|
||||
if (step == LYRIC_COUNT && due(now, finishedAt + RESTART_MS)) startShow(now);
|
||||
|
||||
bool dirty = updateLyrics(now);
|
||||
dirty |= updateCredits(now);
|
||||
const bool cursor = (now / CURSOR_BLINK_MS) & 1;
|
||||
dirty |= cursor != cursorWas;
|
||||
cursorWas = cursor;
|
||||
|
||||
if (dirty) {
|
||||
display.fillScreen(0);
|
||||
lyricsBox.draw(display, cursor);
|
||||
creditsBox.draw(display, cursor && !creditsDone && due(now, showStart + CREDITS_START_MS));
|
||||
artBox.draw(display, false, false); // no frame around the picture, as in the game
|
||||
display.display();
|
||||
}
|
||||
delay(2);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Data types shared by the hand-written show (lyrics.h + art.h) and the imported
|
||||
// one (imported.h, generated by tools/import_still_alive.py).
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
enum : uint8_t {
|
||||
STEP_NEWLINE = 1 << 0, // start a new line after the text is typed
|
||||
STEP_CLEAR = 1 << 1, // clear the console before typing
|
||||
};
|
||||
|
||||
// One timed piece of console text. Steps are sorted by startMs.
|
||||
struct LyricStep {
|
||||
uint32_t startMs; // from the start of the show
|
||||
uint16_t durMs; // the text is typed out evenly over this time
|
||||
uint8_t flags;
|
||||
int8_t art; // picture to show (index into ARTS), or ART_KEEP
|
||||
const char* text;
|
||||
};
|
||||
|
||||
struct Art {
|
||||
const char* const* lines;
|
||||
uint8_t count;
|
||||
};
|
||||
|
||||
static constexpr int8_t ART_KEEP = -1; // leave the current picture
|
||||
static constexpr int8_t ART_NONE = 0; // ARTS[0] is always the empty picture
|
||||
@@ -0,0 +1,89 @@
|
||||
// Fixed-size character console in the GFX default 6x8 font, inside a dashed frame.
|
||||
// Text wraps at the right edge and scrolls up when it runs past the bottom row.
|
||||
#pragma once
|
||||
|
||||
#include <Adafruit_GFX.h>
|
||||
#include <string.h>
|
||||
|
||||
class TextBox {
|
||||
public:
|
||||
static constexpr uint8_t MAX_COLS = 48;
|
||||
static constexpr uint8_t MAX_ROWS = 32;
|
||||
static constexpr int16_t PAD = 4;
|
||||
static constexpr int16_t CHAR_W = 6;
|
||||
static constexpr int16_t CHAR_H = 8;
|
||||
|
||||
TextBox(int16_t x, int16_t y, int16_t w, int16_t h)
|
||||
: _x(x), _y(y), _w(w), _h(h), _cols(fit(w, CHAR_W, MAX_COLS)), _rows(fit(h, CHAR_H, MAX_ROWS)) {
|
||||
clear();
|
||||
}
|
||||
|
||||
uint8_t cols() const { return _cols; }
|
||||
uint8_t rows() const { return _rows; }
|
||||
|
||||
void clear() {
|
||||
memset(_text, 0, sizeof(_text));
|
||||
_row = 0;
|
||||
_col = 0;
|
||||
}
|
||||
|
||||
void put(char c) {
|
||||
if (c == '\n') {
|
||||
newLine();
|
||||
return;
|
||||
}
|
||||
if (_col >= _cols) newLine();
|
||||
_text[_row][_col++] = c;
|
||||
}
|
||||
|
||||
void print(const char* s) {
|
||||
while (*s) put(*s++);
|
||||
}
|
||||
|
||||
void draw(Adafruit_GFX& g, bool cursor, bool frame = true) const {
|
||||
if (frame) drawFrame(g);
|
||||
g.setTextSize(1);
|
||||
g.setTextColor(1);
|
||||
g.setTextWrap(false);
|
||||
for (uint8_t r = 0; r < _rows; r++) {
|
||||
g.setCursor(_x + PAD, _y + PAD + r * CHAR_H);
|
||||
g.print(_text[r]);
|
||||
}
|
||||
if (cursor) g.fillRect(_x + PAD + _col * CHAR_W, _y + PAD + _row * CHAR_H + 6, 5, 2, 1);
|
||||
}
|
||||
|
||||
private:
|
||||
static uint8_t fit(int16_t px, int16_t cell, uint8_t cap) {
|
||||
const int n = (px - 2 * PAD) / cell;
|
||||
return n > cap ? cap : n;
|
||||
}
|
||||
|
||||
void newLine() {
|
||||
_col = 0;
|
||||
if (_row + 1 < _rows) {
|
||||
_row++;
|
||||
return;
|
||||
}
|
||||
memmove(_text[0], _text[1], (size_t)(_rows - 1) * sizeof(_text[0]));
|
||||
memset(_text[_rows - 1], 0, sizeof(_text[0]));
|
||||
}
|
||||
|
||||
// Dashed outline, 4 px on / 2 px off.
|
||||
void drawFrame(Adafruit_GFX& g) const {
|
||||
for (int16_t i = 0; i < _w; i += 6) {
|
||||
const int16_t n = std::min<int16_t>(4, _w - i);
|
||||
g.drawFastHLine(_x + i, _y, n, 1);
|
||||
g.drawFastHLine(_x + i, _y + _h - 1, n, 1);
|
||||
}
|
||||
for (int16_t i = 0; i < _h; i += 6) {
|
||||
const int16_t n = std::min<int16_t>(4, _h - i);
|
||||
g.drawFastVLine(_x, _y + i, n, 1);
|
||||
g.drawFastVLine(_x + _w - 1, _y + i, n, 1);
|
||||
}
|
||||
}
|
||||
|
||||
int16_t _x, _y, _w, _h;
|
||||
uint8_t _cols, _rows;
|
||||
uint8_t _row = 0, _col = 0;
|
||||
char _text[MAX_ROWS][MAX_COLS + 1];
|
||||
};
|
||||
File renamed without changes.
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import the console text, credits, timing and ASCII art for the still_alive demo.
|
||||
|
||||
Reads index.html and res/stillalive.js from
|
||||
https://github.com/SDSkyKlouD/still-alive-web (downloaded, or local copies) and
|
||||
writes src/still_alive/imported.h, which the firmware uses instead of the
|
||||
placeholder lyrics.h / art.h / credits.h when it exists.
|
||||
|
||||
python tools/import_still_alive.py # download from GitHub
|
||||
python tools/import_still_alive.py --html index.html --js stillalive.js
|
||||
python tools/import_still_alive.py --out somewhere/imported.h
|
||||
|
||||
imported.h is listed in .gitignore, so the imported text stays on your machine.
|
||||
|
||||
Source format: each <div class="container_lyrics" data-start=ms> is one screen
|
||||
(the console clears when it starts). Inside it, <span data-start data-dur
|
||||
data-append-br data-text data-asciiart> are typed lines with times relative to
|
||||
the screen, and <br data-show-offset=ms> are extra blank lines. ASCII art comes
|
||||
from <pre class="asciiart_NAME"> blocks. The credits are the CREDIT_DATA array in
|
||||
stillalive.js; the page starts typing them 9 s in and starts the song 6.75 s in.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
SOURCE_BASE = "https://raw.githubusercontent.com/SDSkyKlouD/still-alive-web/master/"
|
||||
OUT = Path(__file__).resolve().parent.parent / "src" / "still_alive" / "imported.h"
|
||||
|
||||
# Timing from stillalive.js.
|
||||
SONG_START_MS = 6750
|
||||
CREDITS_START_MS = 9000
|
||||
CREDIT_CHAR_US = 68624 # 68.623562 ms per character
|
||||
|
||||
CONSOLE_COLS = 41
|
||||
CONSOLE_ROWS = 31
|
||||
ART_COLS = 41
|
||||
ART_ROWS = 20
|
||||
|
||||
# The panel font is plain ASCII; fold the few typographic characters.
|
||||
FOLD = {"‘": "'", "’": "'", "“": '"', "”": '"',
|
||||
"–": "-", "—": "-", "…": "...", " ": " "}
|
||||
|
||||
|
||||
def to_ascii(s):
|
||||
s = "".join(FOLD.get(c, c) for c in s)
|
||||
return "".join(c if 32 <= ord(c) < 127 else "?" for c in s)
|
||||
|
||||
|
||||
def c_string(s):
|
||||
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def attr(tag, name):
|
||||
m = re.search(r'\b%s="([^"]*)"' % re.escape(name), tag)
|
||||
return html.unescape(m.group(1)) if m else None
|
||||
|
||||
|
||||
def load(path, remote):
|
||||
if path:
|
||||
return Path(path).read_text(encoding="utf-8")
|
||||
with urllib.request.urlopen(SOURCE_BASE + remote) as r:
|
||||
return r.read().decode("utf-8")
|
||||
|
||||
|
||||
def parse_credits(script):
|
||||
m = re.search(r"const CREDIT_DATA = \[(.*?)\n\];", script, re.S)
|
||||
if not m:
|
||||
return []
|
||||
# Each entry is a double-quoted JS string; undo its escapes.
|
||||
return [to_ascii(re.sub(r"\\(.)", r"\1", s))
|
||||
for s in re.findall(r'"((?:[^"\\]|\\.)*)"', m.group(1))]
|
||||
|
||||
|
||||
def parse_art(page):
|
||||
arts = {}
|
||||
for m in re.finditer(r'<pre class="asciiart_(\w+)">(.*?)</pre>', page, re.S):
|
||||
lines = [to_ascii(l.rstrip()) for l in html.unescape(m.group(2)).split("\n")]
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
arts[m.group(1)] = lines
|
||||
return arts
|
||||
|
||||
|
||||
def parse_steps(page, art_ids):
|
||||
# (start, order, dur, flags, art, text); order keeps source order on equal times.
|
||||
steps = []
|
||||
screens = list(re.finditer(r'<div class="container_lyrics[^"]*" data-start="(\d+)">', page))
|
||||
for i, screen in enumerate(screens):
|
||||
base = int(screen.group(1))
|
||||
end = screens[i + 1].start() if i + 1 < len(screens) else len(page)
|
||||
body = page[screen.end():end]
|
||||
steps.append((base, len(steps), 0, "STEP_CLEAR", "ART_KEEP", ""))
|
||||
for tag in re.finditer(r"<(span|br)\b[^>]*>", body):
|
||||
t = tag.group(0)
|
||||
if tag.group(1) == "br":
|
||||
offset = attr(t, "data-show-offset")
|
||||
if offset is not None:
|
||||
steps.append((base + int(offset), len(steps), 0, "STEP_NEWLINE", "ART_KEEP", ""))
|
||||
continue
|
||||
text = attr(t, "data-text")
|
||||
if text is None or attr(t, "data-start") is None:
|
||||
continue
|
||||
flags = "STEP_NEWLINE" if attr(t, "data-append-br") == "true" else "0"
|
||||
art = attr(t, "data-asciiart")
|
||||
art_id = "ART_KEEP" if art is None else ("ART_NONE" if art == "clear" else str(art_ids.get(art, 0)))
|
||||
steps.append((base + int(attr(t, "data-start")), len(steps),
|
||||
min(int(attr(t, "data-dur") or 0), 65535), flags, art_id, to_ascii(text)))
|
||||
steps.sort(key=lambda s: (s[0], s[1]))
|
||||
return steps
|
||||
|
||||
|
||||
def check(steps, arts):
|
||||
"""Warn about anything that will not fit the panel layout."""
|
||||
rows = 0
|
||||
col = 0
|
||||
for start, _, _, flags, _, text in steps:
|
||||
if flags == "STEP_CLEAR":
|
||||
rows, col = 0, 0
|
||||
col += len(text)
|
||||
if len(text) > CONSOLE_COLS:
|
||||
print(f"warning: line at {start} ms is {len(text)} chars, wraps", file=sys.stderr)
|
||||
if flags == "STEP_NEWLINE":
|
||||
rows += 1
|
||||
col = 0
|
||||
if rows == CONSOLE_ROWS:
|
||||
print(f"warning: screen overflows {CONSOLE_ROWS} rows at {start} ms, scrolls",
|
||||
file=sys.stderr)
|
||||
for name, lines in arts.items():
|
||||
if len(lines) > ART_ROWS or max(map(len, lines), default=0) > ART_COLS:
|
||||
print(f"warning: art '{name}' is larger than {ART_COLS}x{ART_ROWS}, gets cut",
|
||||
file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
ap.add_argument("--html", help="local index.html (default: download)")
|
||||
ap.add_argument("--js", help="local res/stillalive.js (default: download)")
|
||||
ap.add_argument("--out", type=Path, default=OUT, help="output file (default: %(default)s)")
|
||||
args = ap.parse_args()
|
||||
|
||||
page = load(args.html, "index.html")
|
||||
arts = parse_art(page)
|
||||
art_ids = {name: i + 1 for i, name in enumerate(arts)}
|
||||
steps = parse_steps(page, art_ids)
|
||||
credits = parse_credits(load(args.js, "res/stillalive.js"))
|
||||
if not steps or not arts or not credits:
|
||||
sys.exit("error: no lyrics, art or credits found; has the source changed?")
|
||||
check(steps, arts)
|
||||
|
||||
out = ["// Generated by tools/import_still_alive.py from",
|
||||
"// https://github.com/SDSkyKlouD/still-alive-web - do not edit.",
|
||||
"#pragma once", "", '#include "show_data.h"', ""]
|
||||
for name, lines in arts.items():
|
||||
out.append(f"static const char* const ART_{name.upper()}_LINES[] = {{")
|
||||
out += [f" {c_string(l)}," for l in lines]
|
||||
out += ["};", ""]
|
||||
out.append("static const Art ARTS[] = {")
|
||||
out.append(" {nullptr, 0},")
|
||||
out += [f" {{ART_{n.upper()}_LINES, {len(l)}}}, // {art_ids[n]}" for n, l in arts.items()]
|
||||
out += ["};", "static constexpr size_t ART_COUNT = sizeof(ARTS) / sizeof(ARTS[0]);", ""]
|
||||
out.append("static const LyricStep LYRICS[] = {")
|
||||
out += [f" {{{s}, {d}, {f}, {a}, {c_string(t)}}}," for s, _, d, f, a, t in steps]
|
||||
out += ["};", "static constexpr size_t LYRIC_COUNT = sizeof(LYRICS) / sizeof(LYRICS[0]);", ""]
|
||||
|
||||
out.append("static const char* const CREDITS[] = {")
|
||||
out += [f" {c_string(c)}," for c in credits]
|
||||
out += ["};", "static constexpr size_t CREDIT_COUNT = sizeof(CREDITS) / sizeof(CREDITS[0]);", ""]
|
||||
out += [f"static constexpr uint32_t SONG_START_MS = {SONG_START_MS};",
|
||||
f"static constexpr uint32_t CREDITS_START_MS = {CREDITS_START_MS};",
|
||||
f"static constexpr uint32_t CREDIT_CHAR_US = {CREDIT_CHAR_US};",
|
||||
"static constexpr bool CREDITS_LOOP = false;", ""]
|
||||
|
||||
args.out.write_text("\n".join(out), encoding="utf-8", newline="\n")
|
||||
screens = sum(1 for s in steps if s[3] == "STEP_CLEAR")
|
||||
print(f"wrote {args.out}: {len(steps)} steps on {screens} screens, {len(arts)} pictures, "
|
||||
f"{len(credits)} credit lines, {steps[-1][0] / 1000:.0f} s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user