Public Access
Game of Life page element and built-in page
A new page element runs Conway's Game of Life in an area (the whole display by default), one per page. Start: random dots, one pattern, or the icons whose names start with a text in turn or in random order. Edges: wrap around, dead, or a hidden margin where gliders fly out of view (leftovers that stay put in the margin are cleared, so a glider gun runs cleanly). It restarts when the board is still or repeats within 16 generations and/or after a number of generations, after a hold time (an empty board at once). logic/life holds the board and timing (tested), display/life_player runs the board of the shown page, ticked before each frame; frames are paced like scrolling text. The element reuses existing fields, so the page format stays the same. The Icons tab has a library of 17 LifeWiki patterns (checked with a simulation), and the built-in page "Game of Life" shuffles through the life_ icons. About 12 KB flash, 8 KB RAM. Version 2.17.0, tested on the display over OTA. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
948087077d
commit
a91d2550d7
29 files changed
+1402
-31
No files matched your search
+4
-2
@@ -21,11 +21,12 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
|
||||
| `logic/schedule.*` | When the display is on (night, clock, standby), from the settings |
|
||||
| `logic/bitmap_format.*` | 84 x 19 bitmap format, hex encoding, names of bitmaps, pages and fonts |
|
||||
| `logic/icon_format.*` | Icons: small bitmaps of their own size, to and from full screen bitmaps |
|
||||
| `logic/page.*` | Dynamic pages: the struct, checks, time slots, texts of the elements (clock, date, Home Assistant value, text) |
|
||||
| `logic/page.*` | Dynamic pages: the struct, checks, time slots, texts of the elements (clock, date, Home Assistant value, text); which fields a Game of Life element uses |
|
||||
| `logic/page_render.*` | Draws a dynamic page into a bitmap (display and web previews) |
|
||||
| `logic/font.*` | Dot font format (built-in and uploaded fonts), text width, drawing |
|
||||
| `logic/builtin_fonts.*` | Small, Normal, Large, Full height; the `.cpp` is generated by `tools/make_builtin_fonts.py` |
|
||||
| `logic/builtin_pages.*` | The built-in Clock page (the clock of the old firmware) |
|
||||
| `logic/builtin_pages.*` | The built-in pages: Clock (the clock of the old firmware) and Game of Life |
|
||||
| `logic/life.*` | Conway's Game of Life: the board with its edges and cycle check (`LifeBoard`), timing and restarts (`LifeRunner`) |
|
||||
| `logic/page_schedule.*` | Schedule blocks and trigger rules: the structs, checks, rule conditions |
|
||||
| `logic/page_selector.*` | Which page is shown now: rules, a manual pick, the rotating schedule (`PageSelector`) |
|
||||
| `logic/stopwatch.h` | The stopwatch of the dynamic pages |
|
||||
@@ -43,6 +44,7 @@ debug output. Wiring is described in the [documentation](../../docs/wiring.md).
|
||||
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
|
||||
| `display/icon_store.*` | Icons (small images for pages) on LittleFS |
|
||||
| `display/page_store.*` | Built-in and stored dynamic pages (`/pages/<name>.pg`) |
|
||||
| `display/life_player.*` | Runs the Game of Life of the shown page: starts (random, a pattern, rotating through icons), ticks before each frame |
|
||||
| `display/font_store.*` | Built-in and uploaded fonts (`/fonts/<name>.fnt`) |
|
||||
| `display/page_resources.*` | Gives the page renderer its bitmaps, fonts and Home Assistant values |
|
||||
| `display/status_screen.*` | Two-line status messages at boot, after a crash and in the recovery app (`StatusScreen`) |
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@
|
||||
#include "display/bitmap_store.h"
|
||||
#include "display/font_store.h"
|
||||
#include "display/icon_store.h"
|
||||
#include "display/life_player.h"
|
||||
#include "display/page_resources.h"
|
||||
#include "display/page_store.h"
|
||||
#include "logic/page_selector.h"
|
||||
@@ -23,7 +24,7 @@ static void copyName(char *out, const char *name)
|
||||
|
||||
NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
|
||||
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
|
||||
FlipdotFrames frames(display, pageResources);
|
||||
FlipdotFrames frames(display, pageResources, lifePlayer);
|
||||
StatusScreen status(display, nano);
|
||||
DisplayController controller(nano, frames);
|
||||
LoopStats loopStats;
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
|
||||
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
|
||||
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
|
||||
|
||||
#define FIRMWARE_VERSION "2.16.0"
|
||||
#define FIRMWARE_VERSION "2.17.0"
|
||||
|
||||
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
|
||||
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
#include "config.h"
|
||||
#include "display/text.h"
|
||||
|
||||
FlipdotFrames::FlipdotFrames(BROSE9323 &display, PageResources &resources) : _display(display), _resources(resources)
|
||||
FlipdotFrames::FlipdotFrames(BROSE9323 &display, PageResources &resources, LifePlayer &life)
|
||||
: _display(display), _resources(resources), _life(life)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@ FlipdotFrames::FlipdotFrames(BROSE9323 &display, PageResources &resources) : _di
|
||||
uint32_t FlipdotFrames::dynamicPage(const LocalTime &time, uint32_t now, bool redraw)
|
||||
{
|
||||
uint8_t bitmap[BITMAP_BYTES];
|
||||
_life.tick(now);
|
||||
uint32_t wait = renderPage(_dynamic, time, now, _resources, bitmap);
|
||||
bool moving = wait != 0;
|
||||
// The Nano has no acknowledgement: the next frame waits until it has received this one
|
||||
@@ -84,6 +86,7 @@ void FlipdotFrames::off()
|
||||
void FlipdotFrames::setDynamicPage(const DynamicPage &page)
|
||||
{
|
||||
_dynamic = page;
|
||||
_life.setPage(page);
|
||||
}
|
||||
|
||||
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "BROSE9323.h"
|
||||
#include "display/life_player.h"
|
||||
#include "logic/bitmap_format.h"
|
||||
#include "logic/display_controller.h"
|
||||
#include "logic/page_render.h"
|
||||
@@ -10,9 +11,10 @@
|
||||
class FlipdotFrames : public Frames
|
||||
{
|
||||
public:
|
||||
FlipdotFrames(BROSE9323 &display, PageResources &resources);
|
||||
FlipdotFrames(BROSE9323 &display, PageResources &resources, LifePlayer &life);
|
||||
|
||||
// The page set with setDynamicPage(), drawn for this time. Only the changed dots are
|
||||
// The page set with setDynamicPage(), drawn for this time (its Game of Life is ticked
|
||||
// first). Only the changed dots are
|
||||
// sent (nothing when it looks the same), unless redraw. Moving pages are sent without
|
||||
// the refresh effect, and not faster than the Nano can receive and flip them.
|
||||
uint32_t dynamicPage(const LocalTime &time, uint32_t now, bool redraw) override;
|
||||
@@ -46,6 +48,7 @@ public:
|
||||
private:
|
||||
BROSE9323 &_display;
|
||||
PageResources &_resources;
|
||||
LifePlayer &_life;
|
||||
DynamicPage _dynamic;
|
||||
static const uint8_t EFFECT_UNKNOWN = 254;
|
||||
DriveSettings _drive;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#include "display/life_player.h"
|
||||
|
||||
#include <strings.h>
|
||||
|
||||
#include "display/icon_store.h"
|
||||
#include "display/page_resources.h"
|
||||
|
||||
LifePlayer lifePlayer;
|
||||
|
||||
// Random fill when no pattern can be loaded (rotating through no icons, a pattern deleted)
|
||||
static const int FALLBACK_DENSITY = 30;
|
||||
|
||||
static LifeTiming timingOf(const PageElement &e)
|
||||
{
|
||||
LifeTiming timing;
|
||||
timing.speed = e.scroll_speed;
|
||||
timing.restart_when_repeating = e.auto_unit;
|
||||
timing.max_generations = e.target;
|
||||
timing.hold_ms = (uint32_t)(e.bar_max * 1000);
|
||||
return timing;
|
||||
}
|
||||
|
||||
void LifePlayer::setPage(const DynamicPage &page)
|
||||
{
|
||||
const PageElement *life = nullptr;
|
||||
for (int i = 0; i < page.element_count && i < PAGE_ELEMENTS; i++)
|
||||
if (page.elements[i].type == ElementType::Life)
|
||||
life = &page.elements[i];
|
||||
if (!life)
|
||||
{
|
||||
_active = false;
|
||||
return;
|
||||
}
|
||||
_runner.setTiming(timingOf(*life));
|
||||
if (_active && sameLifeStart(_element, *life))
|
||||
{
|
||||
_element = *life;
|
||||
return;
|
||||
}
|
||||
_element = *life;
|
||||
_active = true;
|
||||
_start_needed = true;
|
||||
_pattern = -1;
|
||||
}
|
||||
|
||||
void LifePlayer::tick(uint32_t now)
|
||||
{
|
||||
if (!_active)
|
||||
return;
|
||||
if (_start_needed || _runner.tick(now) == LifeTick::Restart)
|
||||
_start(now);
|
||||
}
|
||||
|
||||
const LifeRunner *LifePlayer::runner(const PageElement &element) const
|
||||
{
|
||||
if (!_active || _start_needed || !sameLifeStart(_element, element))
|
||||
return nullptr;
|
||||
return &_runner;
|
||||
}
|
||||
|
||||
void LifePlayer::_start(uint32_t now)
|
||||
{
|
||||
_start_needed = false;
|
||||
if (_random == 0)
|
||||
_random = esp_random() | 1;
|
||||
const PageElement &e = _element;
|
||||
_runner.board.begin(e.bar_width, e.bar_height, e.format);
|
||||
|
||||
bool placed = false;
|
||||
if (e.input_unit == LIFE_START_PATTERN)
|
||||
placed = _place(e.text);
|
||||
else if (e.input_unit == LIFE_START_ROTATE || e.input_unit == LIFE_START_SHUFFLE)
|
||||
{
|
||||
_loadPatterns();
|
||||
// Skips patterns that can't be loaded (or have no dots), each one tried once
|
||||
for (size_t tries = 0; !placed && tries < _patterns.size(); tries++)
|
||||
{
|
||||
_pattern = lifeNextPattern(e.input_unit, _patterns.size(), _pattern, _random);
|
||||
placed = _place(_patterns[_pattern].c_str());
|
||||
}
|
||||
}
|
||||
if (!placed)
|
||||
{
|
||||
int density = e.input_unit == LIFE_START_RANDOM ? (int)e.factor : FALLBACK_DENSITY;
|
||||
_runner.board.randomize(density, _random);
|
||||
if (e.input_unit != LIFE_START_RANDOM)
|
||||
Serial.printf("Life: no pattern \"%s\", random start\n", e.text);
|
||||
}
|
||||
_runner.started(now);
|
||||
}
|
||||
|
||||
bool LifePlayer::_place(const char *name)
|
||||
{
|
||||
uint8_t pattern[BITMAP_BYTES];
|
||||
if (!pageResources.background(name, pattern))
|
||||
return false;
|
||||
if (!_runner.board.place(pattern, _element.absolute, _random))
|
||||
return false;
|
||||
Serial.printf("Life: %s\n", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Listing the icons takes about a second: only when they or the name start changed
|
||||
void LifePlayer::_loadPatterns()
|
||||
{
|
||||
if (_patterns_loaded && _patterns_revision == iconListRevision() && _patterns_start.equalsIgnoreCase(_element.text))
|
||||
return;
|
||||
_patterns.clear();
|
||||
size_t start = strlen(_element.text);
|
||||
for (const String &name : listIcons())
|
||||
if (strncasecmp(name.c_str(), _element.text, start) == 0)
|
||||
_patterns.push_back(name);
|
||||
_patterns_loaded = true;
|
||||
_patterns_revision = iconListRevision();
|
||||
_patterns_start = _element.text;
|
||||
_pattern = -1;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <vector>
|
||||
|
||||
#include "logic/life.h"
|
||||
#include "logic/page.h"
|
||||
|
||||
// Runs the Game of Life element of the shown page (logic/life): FlipdotFrames sets the page
|
||||
// and ticks it before each frame, StoredPageResources gives the board to the renderer.
|
||||
// Patterns are bitmaps or icons (loaded through pageResources); for rotating, the icons
|
||||
// whose names start with the element's text, listed once per change of the icons.
|
||||
class LifePlayer
|
||||
{
|
||||
public:
|
||||
// The shown page changed or was loaded again. A Life element that starts the same way
|
||||
// (sameLifeStart) keeps running with the new timing; otherwise it starts again.
|
||||
void setPage(const DynamicPage &page);
|
||||
// Starts a run when needed, steps when a generation is due, restarts after the hold time
|
||||
void tick(uint32_t now);
|
||||
// The running board when the element starts like the running one, otherwise nullptr
|
||||
const LifeRunner *runner(const PageElement &element) const;
|
||||
|
||||
private:
|
||||
LifeRunner _runner;
|
||||
PageElement _element;
|
||||
bool _active = false;
|
||||
bool _start_needed = false;
|
||||
uint32_t _random = 0;
|
||||
int _pattern = -1; // index in _patterns of the running one
|
||||
std::vector<String> _patterns;
|
||||
bool _patterns_loaded = false;
|
||||
uint32_t _patterns_revision = 0;
|
||||
String _patterns_start;
|
||||
|
||||
void _start(uint32_t now);
|
||||
bool _place(const char *name);
|
||||
void _loadPatterns();
|
||||
};
|
||||
|
||||
extern LifePlayer lifePlayer;
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "display/bitmap_store.h"
|
||||
#include "display/font_store.h"
|
||||
#include "display/icon_store.h"
|
||||
#include "display/life_player.h"
|
||||
#include "net/ha_api.h"
|
||||
|
||||
Stopwatch stopwatch;
|
||||
@@ -40,3 +41,8 @@ bool StoredPageResources::stopwatchRunning()
|
||||
{
|
||||
return _stopwatch.running();
|
||||
}
|
||||
|
||||
const LifeRunner *StoredPageResources::life(const PageElement &element)
|
||||
{
|
||||
return lifePlayer.runner(element);
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
#include "logic/stopwatch.h"
|
||||
|
||||
// Bitmaps and icons from display/bitmap_store and display/icon_store, fonts from display/font_store, Home Assistant values
|
||||
// from net/ha_api and the stopwatch, for drawing dynamic pages on the display and in the
|
||||
// from net/ha_api, the stopwatch and the running Game of Life (display/life_player), for drawing dynamic pages on the display and in the
|
||||
// web UI
|
||||
class StoredPageResources : public PageResources
|
||||
{
|
||||
@@ -16,6 +16,7 @@ public:
|
||||
const char *haValue(const PageElement &element) override;
|
||||
uint32_t stopwatchMs() override;
|
||||
bool stopwatchRunning() override;
|
||||
const LifeRunner *life(const PageElement &element) override;
|
||||
|
||||
private:
|
||||
Stopwatch &_stopwatch;
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "logic/builtin_fonts.h"
|
||||
#include "logic/life.h"
|
||||
|
||||
static const char *const NAMES[] = {BUILTIN_PAGE_CLOCK};
|
||||
static const char *const NAMES[] = {BUILTIN_PAGE_CLOCK, BUILTIN_PAGE_LIFE};
|
||||
|
||||
int builtinPageCount()
|
||||
{
|
||||
@@ -42,6 +43,26 @@ static void clockPage(DynamicPage &page)
|
||||
strcpy(clock.font, FONT_NORMAL_NAME);
|
||||
}
|
||||
|
||||
// The whole display, the gliders of the glider gun fly out of view. The gun never repeats
|
||||
// (its period is 30), so a run ends after 600 generations (2.5 minutes).
|
||||
static void lifePage(DynamicPage &page)
|
||||
{
|
||||
clearPage(page);
|
||||
page.element_count = 1;
|
||||
PageElement &life = page.elements[0];
|
||||
life.type = ElementType::Life;
|
||||
life.bar_width = BITMAP_WIDTH;
|
||||
life.bar_height = BITMAP_HEIGHT;
|
||||
life.format = LIFE_EDGES_MARGIN;
|
||||
life.input_unit = LIFE_START_SHUFFLE;
|
||||
strcpy(life.text, "life_");
|
||||
life.factor = 30; // random dots, when there are no patterns
|
||||
life.scroll_speed = 4;
|
||||
life.auto_unit = true;
|
||||
life.target = 600;
|
||||
life.bar_max = 5;
|
||||
}
|
||||
|
||||
bool builtinPage(const char *name, DynamicPage &page)
|
||||
{
|
||||
if (name && sameName(name, BUILTIN_PAGE_CLOCK))
|
||||
@@ -49,5 +70,10 @@ bool builtinPage(const char *name, DynamicPage &page)
|
||||
clockPage(page);
|
||||
return true;
|
||||
}
|
||||
if (name && sameName(name, BUILTIN_PAGE_LIFE))
|
||||
{
|
||||
lifePage(page);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -5,10 +5,13 @@
|
||||
// The original dynamic pages. display/page_store stores a copy of each at boot that can be
|
||||
// edited; the original is the fallback (deleting the copy resets the page).
|
||||
//
|
||||
// Clock Pikachu with the time in a thought bubble, yawning from 17:00 (the clock of
|
||||
// the old firmware; also the default page)
|
||||
// Clock Pikachu with the time in a thought bubble, yawning from 17:00 (the clock
|
||||
// of the old firmware; also the default page)
|
||||
// Game of Life Conway's Game of Life on the whole display: the life_ icons (the
|
||||
// patterns of the Icons tab) in random order, random dots without them
|
||||
|
||||
#define BUILTIN_PAGE_CLOCK "Clock"
|
||||
#define BUILTIN_PAGE_LIFE "Game of Life"
|
||||
|
||||
int builtinPageCount();
|
||||
const char *builtinPageName(int index);
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
#include "logic/life.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
uint32_t lifeRandom(uint32_t &state)
|
||||
{
|
||||
if (state == 0)
|
||||
state = 0x9E3779B9;
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
return state;
|
||||
}
|
||||
|
||||
void LifeBoard::begin(int width, int height, uint8_t edges)
|
||||
{
|
||||
if (width < 1)
|
||||
width = 1;
|
||||
if (width > BITMAP_WIDTH)
|
||||
width = BITMAP_WIDTH;
|
||||
if (height < 1)
|
||||
height = 1;
|
||||
if (height > BITMAP_HEIGHT)
|
||||
height = BITMAP_HEIGHT;
|
||||
_edges = edges < LIFE_EDGES_COUNT ? edges : LIFE_EDGES_WRAP;
|
||||
_width = width;
|
||||
_height = height;
|
||||
_margin = _edges == LIFE_EDGES_MARGIN ? LIFE_MARGIN : 0;
|
||||
_world_width = width + 2 * _margin;
|
||||
_world_height = height + 2 * _margin;
|
||||
clear();
|
||||
}
|
||||
|
||||
void LifeBoard::clear()
|
||||
{
|
||||
memset(_cells, 0, sizeof(_cells));
|
||||
memset(_age, 0, sizeof(_age));
|
||||
_generation = 0;
|
||||
_startHistory();
|
||||
}
|
||||
|
||||
bool LifeBoard::cell(int x, int y) const
|
||||
{
|
||||
if (x < 0 || y < 0 || x >= _width || y >= _height)
|
||||
return false;
|
||||
return _world(x + _margin, y + _margin);
|
||||
}
|
||||
|
||||
void LifeBoard::setCell(int x, int y, bool alive)
|
||||
{
|
||||
if (x < 0 || y < 0 || x >= _width || y >= _height)
|
||||
return;
|
||||
_setWorld(x + _margin, y + _margin, alive);
|
||||
_startHistory();
|
||||
}
|
||||
|
||||
void LifeBoard::randomize(int percent, uint32_t &random)
|
||||
{
|
||||
clear();
|
||||
for (int y = 0; y < _height; y++)
|
||||
for (int x = 0; x < _width; x++)
|
||||
if ((int)(lifeRandom(random) % 100) < percent)
|
||||
_setWorld(x + _margin, y + _margin, true);
|
||||
_startHistory();
|
||||
}
|
||||
|
||||
bool lifePlacement(const uint8_t *pattern, int width, int height, bool random_place, uint32_t &random, int &dx,
|
||||
int &dy)
|
||||
{
|
||||
int left = BITMAP_WIDTH, top = BITMAP_HEIGHT, right = -1, bottom = -1;
|
||||
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
||||
for (int x = 0; x < BITMAP_WIDTH; x++)
|
||||
if (bitmapPixel(pattern, x, y))
|
||||
{
|
||||
if (x < left)
|
||||
left = x;
|
||||
if (x > right)
|
||||
right = x;
|
||||
if (y < top)
|
||||
top = y;
|
||||
if (y > bottom)
|
||||
bottom = y;
|
||||
}
|
||||
if (right < 0)
|
||||
return false;
|
||||
int pattern_width = right - left + 1;
|
||||
int pattern_height = bottom - top + 1;
|
||||
int x0 = (width - pattern_width) / 2;
|
||||
int y0 = (height - pattern_height) / 2;
|
||||
if (random_place)
|
||||
{
|
||||
if (pattern_width <= width)
|
||||
x0 = lifeRandom(random) % (width - pattern_width + 1);
|
||||
if (pattern_height <= height)
|
||||
y0 = lifeRandom(random) % (height - pattern_height + 1);
|
||||
}
|
||||
dx = x0 - left;
|
||||
dy = y0 - top;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool LifeBoard::place(const uint8_t *pattern, bool random_place, uint32_t &random)
|
||||
{
|
||||
clear();
|
||||
int dx, dy;
|
||||
if (!lifePlacement(pattern, _width, _height, random_place, random, dx, dy))
|
||||
return false;
|
||||
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
||||
for (int x = 0; x < BITMAP_WIDTH; x++)
|
||||
if (bitmapPixel(pattern, x, y))
|
||||
{
|
||||
int bx = x + dx, by = y + dy;
|
||||
if (bx >= 0 && by >= 0 && bx < _width && by < _height)
|
||||
_setWorld(bx + _margin, by + _margin, true);
|
||||
}
|
||||
_startHistory();
|
||||
return true;
|
||||
}
|
||||
|
||||
void LifeBoard::step()
|
||||
{
|
||||
memset(_next, 0, sizeof(_next));
|
||||
for (int y = 0; y < _world_height; y++)
|
||||
for (int x = 0; x < _world_width; x++)
|
||||
{
|
||||
int neighbours = 0;
|
||||
for (int dy = -1; dy <= 1; dy++)
|
||||
for (int dx = -1; dx <= 1; dx++)
|
||||
if ((dx || dy) && _world(x + dx, y + dy))
|
||||
neighbours++;
|
||||
if (neighbours == 3 || (neighbours == 2 && _world(x, y)))
|
||||
_next[y][x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
memcpy(_cells, _next, sizeof(_cells));
|
||||
if (_margin > 0)
|
||||
_clearMargin();
|
||||
_generation++;
|
||||
|
||||
uint32_t hash = _hash();
|
||||
_repeating = false;
|
||||
for (int i = 0; i < _history_count; i++)
|
||||
if (_history[i] == hash)
|
||||
_repeating = true;
|
||||
_history[_history_next] = hash;
|
||||
_history_next = (_history_next + 1) % LIFE_MAX_PERIOD;
|
||||
if (_history_count < LIFE_MAX_PERIOD)
|
||||
_history_count++;
|
||||
}
|
||||
|
||||
int LifeBoard::population() const
|
||||
{
|
||||
int count = 0;
|
||||
for (int y = 0; y < _world_height; y++)
|
||||
for (int x = 0; x < _world_width; x++)
|
||||
count += _world(x, y);
|
||||
return count;
|
||||
}
|
||||
|
||||
bool LifeBoard::_world(int x, int y) const
|
||||
{
|
||||
if (_edges == LIFE_EDGES_WRAP)
|
||||
{
|
||||
x = (x + _world_width) % _world_width;
|
||||
y = (y + _world_height) % _world_height;
|
||||
}
|
||||
else if (x < 0 || y < 0 || x >= _world_width || y >= _world_height)
|
||||
return false;
|
||||
return _cells[y][x / 8] & (0x80 >> (x % 8));
|
||||
}
|
||||
|
||||
void LifeBoard::_setWorld(int x, int y, bool alive)
|
||||
{
|
||||
if (alive)
|
||||
_cells[y][x / 8] |= 0x80 >> (x % 8);
|
||||
else
|
||||
_cells[y][x / 8] &= ~(0x80 >> (x % 8));
|
||||
}
|
||||
|
||||
void LifeBoard::_clearMargin()
|
||||
{
|
||||
for (int y = 0; y < _world_height; y++)
|
||||
for (int x = 0; x < _world_width; x++)
|
||||
{
|
||||
if (!_world(x, y))
|
||||
{
|
||||
_age[y][x] = 0;
|
||||
continue;
|
||||
}
|
||||
if (_age[y][x] < 255)
|
||||
_age[y][x]++;
|
||||
bool outer = x < LIFE_MARGIN_KILL || y < LIFE_MARGIN_KILL || x >= _world_width - LIFE_MARGIN_KILL ||
|
||||
y >= _world_height - LIFE_MARGIN_KILL;
|
||||
if (outer)
|
||||
{
|
||||
_setWorld(x, y, false);
|
||||
_age[y][x] = 0;
|
||||
}
|
||||
else if (_age[y][x] >= LIFE_MARGIN_STILL && _far(x, y))
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
{
|
||||
int nx = x + dx, ny = y + dy;
|
||||
if (nx >= 0 && ny >= 0 && nx < _world_width && ny < _world_height && _far(nx, ny))
|
||||
{
|
||||
_setWorld(nx, ny, false);
|
||||
_age[ny][nx] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool LifeBoard::_far(int x, int y) const
|
||||
{
|
||||
return x < _margin - LIFE_MARGIN_KEEP || y < _margin - LIFE_MARGIN_KEEP ||
|
||||
x >= _margin + _width + LIFE_MARGIN_KEEP || y >= _margin + _height + LIFE_MARGIN_KEEP;
|
||||
}
|
||||
|
||||
// FNV-1a over the used part of the world
|
||||
uint32_t LifeBoard::_hash() const
|
||||
{
|
||||
uint32_t hash = 2166136261u;
|
||||
int row_bytes = (_world_width + 7) / 8;
|
||||
for (int y = 0; y < _world_height; y++)
|
||||
for (int i = 0; i < row_bytes; i++)
|
||||
{
|
||||
hash ^= _cells[y][i];
|
||||
hash *= 16777619u;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
void LifeBoard::_startHistory()
|
||||
{
|
||||
_history[0] = _hash();
|
||||
_history_count = 1;
|
||||
_history_next = 1;
|
||||
_repeating = false;
|
||||
}
|
||||
|
||||
void LifeRunner::started(uint32_t now)
|
||||
{
|
||||
_last_step = now;
|
||||
_ending = false;
|
||||
}
|
||||
|
||||
LifeTick LifeRunner::tick(uint32_t now)
|
||||
{
|
||||
// An empty board shows nothing: no hold time
|
||||
if (_ending && (now - _ended_at >= _timing.hold_ms || board.population() == 0))
|
||||
return LifeTick::Restart;
|
||||
if (now - _last_step < _interval())
|
||||
return LifeTick::Wait;
|
||||
_last_step = now;
|
||||
board.step();
|
||||
if (!_ending && ((_timing.restart_when_repeating && board.repeating()) ||
|
||||
(_timing.max_generations > 0 && board.generation() >= _timing.max_generations)))
|
||||
{
|
||||
_ending = true;
|
||||
_ended_at = now;
|
||||
}
|
||||
return LifeTick::Stepped;
|
||||
}
|
||||
|
||||
uint32_t LifeRunner::msUntilNext(uint32_t now) const
|
||||
{
|
||||
uint32_t passed = now - _last_step;
|
||||
uint32_t interval = _interval();
|
||||
uint32_t wait = passed < interval ? interval - passed : 1;
|
||||
if (_ending)
|
||||
{
|
||||
uint32_t held = now - _ended_at;
|
||||
uint32_t hold_left = held < _timing.hold_ms ? _timing.hold_ms - held : 1;
|
||||
if (hold_left < wait)
|
||||
wait = hold_left;
|
||||
}
|
||||
return wait > 0 ? wait : 1;
|
||||
}
|
||||
|
||||
uint32_t LifeRunner::_interval() const
|
||||
{
|
||||
if (!(_timing.speed > 0))
|
||||
return 1000;
|
||||
float ms = 1000 / _timing.speed;
|
||||
return ms < 1 ? 1 : (uint32_t)ms;
|
||||
}
|
||||
|
||||
int lifeNextPattern(uint8_t start, int count, int current, uint32_t &random)
|
||||
{
|
||||
if (count <= 0)
|
||||
return -1;
|
||||
if (start == LIFE_START_SHUFFLE)
|
||||
{
|
||||
if (current < 0 || current >= count)
|
||||
return lifeRandom(random) % count;
|
||||
if (count == 1)
|
||||
return 0;
|
||||
int next = lifeRandom(random) % (count - 1); // any other than current
|
||||
return next >= current ? next + 1 : next;
|
||||
}
|
||||
if (start == LIFE_START_ROTATE)
|
||||
return current < 0 || current >= count - 1 ? 0 : current + 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "logic/bitmap_format.h"
|
||||
|
||||
// Conway's Game of Life for the Life page element: a board with its rules (LifeBoard) and
|
||||
// the timing on top of it (LifeRunner). Pure logic, tested in test/test_esp32.
|
||||
//
|
||||
// Rules: a live cell with 2 or 3 live neighbours stays alive, a dead cell with exactly 3
|
||||
// becomes alive, everything else dies.
|
||||
|
||||
// What happens at the edges of the area
|
||||
const uint8_t LIFE_EDGES_WRAP = 0; // what leaves on one side comes back on the other
|
||||
const uint8_t LIFE_EDGES_DEAD = 1; // outside the area everything is dead
|
||||
const uint8_t LIFE_EDGES_MARGIN = 2; // a hidden margin around the area; gliders fly out of view
|
||||
const uint8_t LIFE_EDGES_COUNT = 3;
|
||||
|
||||
// Hidden margin: dots on every side; its outer LIFE_MARGIN_KILL rings are cleared after
|
||||
// every step, so what flies out disappears. Gliders that hit those rings leave a block or a
|
||||
// blinker behind; cells of the margin (further than LIFE_MARGIN_KEEP from the area) that
|
||||
// stay alive LIFE_MARGIN_STILL generations in a row are cleared too, with the cells around
|
||||
// them (one at a time, a block grows back). Moving things never keep a cell alive that long.
|
||||
const int LIFE_MARGIN = 16;
|
||||
const int LIFE_MARGIN_KILL = 2;
|
||||
const int LIFE_MARGIN_KEEP = 3;
|
||||
const uint8_t LIFE_MARGIN_STILL = 12;
|
||||
const int LIFE_MAX_WIDTH = BITMAP_WIDTH + 2 * LIFE_MARGIN;
|
||||
const int LIFE_MAX_HEIGHT = BITMAP_HEIGHT + 2 * LIFE_MARGIN;
|
||||
const int LIFE_MAX_ROW_BYTES = (LIFE_MAX_WIDTH + 7) / 8;
|
||||
|
||||
// Cycles up to this period count as "ended" (still lifes, blinkers, pulsars: 3,
|
||||
// pentadecathlons: 15). A glider gun (30) keeps running.
|
||||
const int LIFE_MAX_PERIOD = 16;
|
||||
|
||||
// Simple random numbers (xorshift32); the state must not be 0
|
||||
uint32_t lifeRandom(uint32_t &state);
|
||||
|
||||
// Where a pattern (drawn anywhere in a full screen bitmap) goes in an area of width x
|
||||
// height: its dot x, y goes to x + dx, y + dy. In the middle, or at a random place where it
|
||||
// fits (in the middle in a direction it doesn't fit). False when it has no dots.
|
||||
bool lifePlacement(const uint8_t *pattern, int width, int height, bool random_place, uint32_t &random, int &dx,
|
||||
int &dy);
|
||||
|
||||
class LifeBoard
|
||||
{
|
||||
public:
|
||||
// An empty board for an area of width x height dots (1-84 x 1-19)
|
||||
void begin(int width, int height, uint8_t edges);
|
||||
void clear();
|
||||
|
||||
int width() const { return _width; }
|
||||
int height() const { return _height; }
|
||||
uint8_t edges() const { return _edges; }
|
||||
|
||||
// Cells of the area (0, 0 is its top left); outside it false
|
||||
bool cell(int x, int y) const;
|
||||
void setCell(int x, int y, bool alive);
|
||||
|
||||
// Every cell of the area alive with this chance (0-100 %)
|
||||
void randomize(int percent, uint32_t &random);
|
||||
// Places a pattern drawn at the top left of a full screen bitmap (an icon expanded
|
||||
// with iconToBitmap): in the middle of the area, or at a random place in it. Parts
|
||||
// that don't fit are left out. The board is cleared first. False without dots.
|
||||
bool place(const uint8_t *pattern, bool random_place, uint32_t &random);
|
||||
|
||||
// One generation
|
||||
void step();
|
||||
uint32_t generation() const { return _generation; }
|
||||
// Live cells in the area and the hidden margin
|
||||
int population() const;
|
||||
// The board looks like one of the last LIFE_MAX_PERIOD generations (still, or
|
||||
// repeating with a short period); also when it is empty
|
||||
bool repeating() const { return _repeating; }
|
||||
|
||||
private:
|
||||
uint8_t _edges = LIFE_EDGES_WRAP;
|
||||
int _width = 0; // the area
|
||||
int _height = 0;
|
||||
int _margin = 0; // LIFE_MARGIN with hidden margin
|
||||
int _world_width = 0;
|
||||
int _world_height = 0;
|
||||
uint32_t _generation = 0;
|
||||
bool _repeating = false;
|
||||
uint32_t _history[LIFE_MAX_PERIOD] = {};
|
||||
int _history_count = 0;
|
||||
int _history_next = 0;
|
||||
uint8_t _cells[LIFE_MAX_HEIGHT][LIFE_MAX_ROW_BYTES] = {};
|
||||
uint8_t _next[LIFE_MAX_HEIGHT][LIFE_MAX_ROW_BYTES] = {};
|
||||
uint8_t _age[LIFE_MAX_HEIGHT][LIFE_MAX_WIDTH] = {}; // hidden margin: generations alive in a row
|
||||
|
||||
bool _world(int x, int y) const; // world coordinates, with the edge rule
|
||||
void _setWorld(int x, int y, bool alive);
|
||||
void _clearMargin();
|
||||
bool _far(int x, int y) const; // in the margin, further than LIFE_MARGIN_KEEP from the area
|
||||
uint32_t _hash() const;
|
||||
void _startHistory();
|
||||
};
|
||||
|
||||
// When a run ends and how fast it goes
|
||||
struct LifeTiming
|
||||
{
|
||||
float speed = 4; // generations per second
|
||||
bool restart_when_repeating = true;
|
||||
uint32_t max_generations = 0; // 0: no limit
|
||||
uint32_t hold_ms = 5000; // after the end it keeps running this long, then restarts (empty: at once)
|
||||
};
|
||||
|
||||
enum class LifeTick : uint8_t
|
||||
{
|
||||
Wait, // nothing changed
|
||||
Stepped, // a new generation
|
||||
Restart, // the run is over: start a new one (then call started())
|
||||
};
|
||||
|
||||
class LifeRunner
|
||||
{
|
||||
public:
|
||||
LifeBoard board;
|
||||
|
||||
void setTiming(const LifeTiming &timing) { _timing = timing; }
|
||||
// A new run was put on the board at this time (generation 0 is shown first)
|
||||
void started(uint32_t now);
|
||||
// Steps when a generation is due; at most one step per call
|
||||
LifeTick tick(uint32_t now);
|
||||
// The ms until the next tick() does something (at least 1)
|
||||
uint32_t msUntilNext(uint32_t now) const;
|
||||
// The run ended (repeating or max generations) and is in its hold time
|
||||
bool ending() const { return _ending; }
|
||||
|
||||
private:
|
||||
LifeTiming _timing;
|
||||
uint32_t _last_step = 0;
|
||||
bool _ending = false;
|
||||
uint32_t _ended_at = 0;
|
||||
|
||||
uint32_t _interval() const;
|
||||
};
|
||||
|
||||
// Start modes of the Life element
|
||||
const uint8_t LIFE_START_RANDOM = 0; // random cells (density %)
|
||||
const uint8_t LIFE_START_PATTERN = 1; // one pattern (an icon)
|
||||
const uint8_t LIFE_START_ROTATE = 2; // the patterns whose names start with the text, in order
|
||||
const uint8_t LIFE_START_SHUFFLE = 3; // the same, in random order
|
||||
const uint8_t LIFE_START_COUNT = 4;
|
||||
|
||||
// The pattern after `current` (-1: none yet) of `count` patterns: the next one (ROTATE) or
|
||||
// a random other one (SHUFFLE). -1 when count is 0.
|
||||
int lifeNextPattern(uint8_t start, int count, int current, uint32_t &random);
|
||||
@@ -7,6 +7,8 @@
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
|
||||
#include "logic/life.h"
|
||||
|
||||
static const uint16_t MINUTES_PER_DAY = 24 * 60;
|
||||
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01
|
||||
|
||||
@@ -71,6 +73,13 @@ bool elementUsesHa(const PageElement &element)
|
||||
}
|
||||
}
|
||||
|
||||
bool sameLifeStart(const PageElement &a, const PageElement &b)
|
||||
{
|
||||
return a.type == ElementType::Life && b.type == ElementType::Life && a.bar_width == b.bar_width &&
|
||||
a.bar_height == b.bar_height && a.format == b.format && a.input_unit == b.input_unit &&
|
||||
strcasecmp(a.text, b.text) == 0 && a.factor == b.factor && a.absolute == b.absolute;
|
||||
}
|
||||
|
||||
bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state)
|
||||
{
|
||||
for (int i = 0; i < page.slot_count; i++)
|
||||
@@ -79,6 +88,8 @@ bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state)
|
||||
for (int i = 0; i < page.element_count; i++)
|
||||
{
|
||||
const PageElement &e = page.elements[i];
|
||||
if (e.type == ElementType::Life && e.input_unit == LIFE_START_PATTERN && sameName(e.text, name))
|
||||
return true; // patterns to rotate through can be deleted (the list gets shorter)
|
||||
if (e.type != ElementType::Image)
|
||||
continue;
|
||||
if (!e.entity[0])
|
||||
@@ -152,11 +163,39 @@ static uint8_t formatCount(const PageElement &e)
|
||||
return STOPWATCH_FORMAT_COUNT;
|
||||
case ElementType::Bar:
|
||||
return BAR_FORMAT_COUNT;
|
||||
case ElementType::Life:
|
||||
return LIFE_EDGES_COUNT;
|
||||
default:
|
||||
return 255; // not used
|
||||
}
|
||||
}
|
||||
|
||||
static const char *validateLife(const PageElement &e)
|
||||
{
|
||||
if (e.bar_width < 1 || e.bar_width > BITMAP_WIDTH || e.bar_height < 1 || e.bar_height > BITMAP_HEIGHT)
|
||||
return "Game of Life: width 1-84, height 1-19 dots";
|
||||
if (e.input_unit >= LIFE_START_COUNT)
|
||||
return "Game of Life: unknown start";
|
||||
if (e.input_unit == LIFE_START_RANDOM && !(e.factor >= 1 && e.factor <= 100))
|
||||
return "Game of Life: the density must be 1-100 %";
|
||||
if (e.input_unit == LIFE_START_PATTERN && !validBitmapName(e.text))
|
||||
return "Game of Life: choose a pattern";
|
||||
if ((e.input_unit == LIFE_START_ROTATE || e.input_unit == LIFE_START_SHUFFLE) && e.text[0])
|
||||
{
|
||||
char name[ELEMENT_TEXT_SIZE + 1];
|
||||
snprintf(name, sizeof(name), "%sx", e.text); // the start of a valid name
|
||||
if (e.text[0] == ' ' || !validBitmapName(name))
|
||||
return "Game of Life: invalid start of the pattern names";
|
||||
}
|
||||
if (!(e.scroll_speed >= LIFE_SPEED_MIN && e.scroll_speed <= LIFE_SPEED_MAX))
|
||||
return "Game of Life: speed 0.1-20 generations per second";
|
||||
if (e.target > LIFE_GENERATIONS_MAX)
|
||||
return "Game of Life: at most 1000000 generations";
|
||||
if (!(e.bar_max >= 0 && e.bar_max <= LIFE_HOLD_MAX))
|
||||
return "Game of Life: hold 0-3600 seconds";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const char *validatePage(const DynamicPage &page)
|
||||
{
|
||||
if (page.slot_count > PAGE_SLOTS)
|
||||
@@ -194,7 +233,7 @@ const char *validatePage(const DynamicPage &page)
|
||||
return "Invalid entity or attribute";
|
||||
if (e.decimals < DECIMALS_AS_IS || e.decimals > DECIMALS_MAX)
|
||||
return "Decimals must be 0-4, or as received";
|
||||
if (e.show >= HaShow::Count || e.input_unit >= UNIT_COUNT)
|
||||
if (e.show >= HaShow::Count || (e.type != ElementType::Life && e.input_unit >= UNIT_COUNT))
|
||||
return "Unknown Home Assistant value setting";
|
||||
if (!printable(e.map, sizeof(e.map)))
|
||||
return "The list of texts can only contain plain characters, up to 63";
|
||||
@@ -218,6 +257,15 @@ const char *validatePage(const DynamicPage &page)
|
||||
if (!isfinite(e.bar_max) || e.bar_max <= 0)
|
||||
return "Progress bar: the full value must be a number above 0";
|
||||
}
|
||||
if (e.type == ElementType::Life)
|
||||
{
|
||||
const char *message = validateLife(e);
|
||||
if (message)
|
||||
return message;
|
||||
for (int j = 0; j < i; j++)
|
||||
if (page.elements[j].type == ElementType::Life)
|
||||
return "A page can have one Game of Life";
|
||||
}
|
||||
if (e.type == ElementType::Countdown && e.target >= MAX_TARGET)
|
||||
return "The countdown date must be before 2100";
|
||||
if (e.type == ElementType::Image)
|
||||
@@ -768,6 +816,7 @@ void elementText(const PageElement &e, const LocalTime &t, const ElementValues &
|
||||
break;
|
||||
case ElementType::Image:
|
||||
case ElementType::Bar:
|
||||
case ElementType::Life:
|
||||
out[0] = '\0';
|
||||
break;
|
||||
default:
|
||||
|
||||
+27
-2
@@ -8,8 +8,8 @@
|
||||
#include "logic/local_time.h"
|
||||
|
||||
// Dynamic pages: a background bitmap that depends on the time of day, with elements
|
||||
// (clock, date, Home Assistant value, text, countdown, stopwatch, image, progress bar)
|
||||
// drawn on top.
|
||||
// (clock, date, Home Assistant value, text, countdown, stopwatch, image, progress bar,
|
||||
// Game of Life) drawn on top.
|
||||
// Stored as a binary struct on LittleFS (display/page_store), edited in the web UI. Pure
|
||||
// logic, tested in test/test_esp32.
|
||||
//
|
||||
@@ -38,6 +38,7 @@ enum class ElementType : uint8_t
|
||||
Stopwatch, // the stopwatch (web UI and Home Assistant start, stop and reset it)
|
||||
Image, // a bitmap, fixed or chosen by a Home Assistant state
|
||||
Bar, // a progress bar filled by a Home Assistant number (e.g. 0-100 %)
|
||||
Life, // Conway's Game of Life in an area (logic/life), at most one per page
|
||||
Count,
|
||||
};
|
||||
|
||||
@@ -137,6 +138,25 @@ const uint8_t TIMESTAMP_FORMAT_COUNT = 4;
|
||||
const int8_t DECIMALS_AS_IS = -1;
|
||||
const int8_t DECIMALS_MAX = 4;
|
||||
|
||||
// Game of Life elements use these fields (no fields of their own, so the page format
|
||||
// stayed the same):
|
||||
// x, y, align the area, like a progress bar (x is its left, middle or right)
|
||||
// bar_width/bar_height the size of the area
|
||||
// format LIFE_EDGES_* (logic/life.h)
|
||||
// input_unit LIFE_START_*: random, one pattern, or rotate/shuffle through patterns
|
||||
// text the pattern (an icon or bitmap), or the start of the names to rotate
|
||||
// through (e.g. "life_"); not used for random
|
||||
// factor random: the density in % (1-100)
|
||||
// absolute patterns at a random place in the area (otherwise in the middle)
|
||||
// scroll_speed generations per second (LIFE_SPEED_MIN-LIFE_SPEED_MAX)
|
||||
// auto_unit restart when it is still or repeating
|
||||
// target restart after this many generations (0: no limit)
|
||||
// bar_max after the end it keeps running this many seconds, then restarts
|
||||
const float LIFE_SPEED_MIN = 0.1f;
|
||||
const float LIFE_SPEED_MAX = 20;
|
||||
const uint32_t LIFE_GENERATIONS_MAX = 1000000;
|
||||
const float LIFE_HOLD_MAX = 3600;
|
||||
|
||||
// Scrolling: dots per second, and the empty dots between the end and the next start
|
||||
const float SCROLL_SPEED_MIN = 0.05f; // one dot every 20 s
|
||||
const float SCROLL_SPEED_MAX = 30;
|
||||
@@ -162,6 +182,7 @@ struct PageElement
|
||||
int8_t decimals = DECIMALS_AS_IS;
|
||||
// Text: the text; HaValue, Countdown, Stopwatch: shown before the value; Image: the
|
||||
// bitmap name, or with an entity the start of it (the state is added)
|
||||
// Life: the pattern, or the start of the pattern names
|
||||
char text[ELEMENT_TEXT_SIZE] = "";
|
||||
// HaValue, Stopwatch: shown after the value; Countdown: shown when the time has passed
|
||||
char suffix[ELEMENT_SUFFIX_SIZE] = "";
|
||||
@@ -246,6 +267,10 @@ bool expandEntity(const char *entity, const char *user, char *out, size_t out_si
|
||||
// A user name the way Home Assistant puts it in entity ids: "Manga Valk" -> "manga_valk"
|
||||
void plexUserSlug(const char *name, char *out, size_t out_size);
|
||||
|
||||
// Two Game of Life elements start the same way (area, edges, start, pattern, density,
|
||||
// place); the timing settings may differ. A running board can then go on.
|
||||
bool sameLifeStart(const PageElement &a, const PageElement &b);
|
||||
|
||||
// The element shows a Home Assistant value (HaValue; Image, Bar with an entity)
|
||||
bool elementUsesHa(const PageElement &element);
|
||||
|
||||
|
||||
@@ -156,6 +156,62 @@ static void drawBar(const PageElement &e, PageResources &resources, uint8_t *bit
|
||||
drawBarPercent(e, resources, bitmap, left + inner, e.y + inner, w - 2 * inner, h - 2 * inner);
|
||||
}
|
||||
|
||||
// Life without a running board (a preview of a page that is not shown): the start, the
|
||||
// pattern in the middle or a random fill that is always the same, in area coordinates
|
||||
static void lifeStart(const PageElement &e, int w, int h, PageResources &resources, uint8_t *start)
|
||||
{
|
||||
memset(start, 0, BITMAP_BYTES);
|
||||
uint32_t random = 1;
|
||||
uint8_t pattern[BITMAP_BYTES];
|
||||
int dx, dy;
|
||||
if (e.input_unit == LIFE_START_PATTERN)
|
||||
{
|
||||
if (!resources.background(e.text, pattern) || !lifePlacement(pattern, w, h, false, random, dx, dy))
|
||||
return;
|
||||
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
||||
for (int x = 0; x < BITMAP_WIDTH; x++)
|
||||
if (bitmapPixel(pattern, x, y))
|
||||
setBitmapPixel(start, x + dx, y + dy, true);
|
||||
return;
|
||||
}
|
||||
int percent = e.input_unit == LIFE_START_RANDOM ? (int)e.factor : 30;
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
setBitmapPixel(start, x, y, (int)(lifeRandom(random) % 100) < percent);
|
||||
}
|
||||
|
||||
// Life: the running board of the shown page; returns the ms until its next generation
|
||||
static uint32_t drawLife(const PageElement &e, uint32_t now_ms, PageResources &resources, uint8_t *bitmap,
|
||||
ElementBox &box)
|
||||
{
|
||||
int w = e.bar_width, h = e.bar_height;
|
||||
int left = e.x;
|
||||
if (e.align == Align::Center)
|
||||
left -= w / 2;
|
||||
else if (e.align == Align::Right)
|
||||
left -= w;
|
||||
box.x = left;
|
||||
box.y = e.y;
|
||||
box.width = w;
|
||||
box.height = h;
|
||||
if (e.overlay == Overlay::Box)
|
||||
clearBox(bitmap, left - 1, e.y - 1, w + 2, h + 2);
|
||||
|
||||
const LifeRunner *runner = resources.life(e);
|
||||
uint8_t start[BITMAP_BYTES];
|
||||
if (!runner)
|
||||
lifeStart(e, w, h, resources, start);
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
{
|
||||
if (!(runner ? runner->board.cell(x, y) : bitmapPixel(start, x, y)))
|
||||
continue;
|
||||
int bx = left + x, by = e.y + y;
|
||||
setBitmapPixel(bitmap, bx, by, e.overlay == Overlay::Invert ? !bitmapPixel(bitmap, bx, by) : true);
|
||||
}
|
||||
return runner ? runner->msUntilNext(now_ms) : 0;
|
||||
}
|
||||
|
||||
// A text element; returns the ms until it scrolls on (0: it doesn't scroll)
|
||||
static uint32_t drawTextElement(const PageElement &e, const char *text, uint32_t now_ms, PageResources &resources,
|
||||
uint8_t *bitmap, ElementBox &box)
|
||||
@@ -223,6 +279,8 @@ uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now
|
||||
drawImage(e, resources, bitmap, box);
|
||||
else if (e.type == ElementType::Bar)
|
||||
drawBar(e, resources, bitmap, box);
|
||||
else if (e.type == ElementType::Life)
|
||||
next = sooner(next, drawLife(e, now_ms, resources, bitmap, box));
|
||||
else
|
||||
{
|
||||
ElementValues values;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <stdint.h>
|
||||
|
||||
#include "logic/font.h"
|
||||
#include "logic/life.h"
|
||||
#include "logic/local_time.h"
|
||||
#include "logic/page.h"
|
||||
|
||||
@@ -21,6 +22,9 @@ public:
|
||||
virtual const char *haValue(const PageElement &element) = 0;
|
||||
virtual uint32_t stopwatchMs() { return 0; }
|
||||
virtual bool stopwatchRunning() { return false; }
|
||||
// The running Game of Life of the shown page when this element starts like it
|
||||
// (sameLifeStart); nullptr otherwise (the start is drawn then)
|
||||
virtual const LifeRunner *life(const PageElement &) { return nullptr; }
|
||||
};
|
||||
|
||||
// The area an element covers on the screen (for the editor), may be partly off screen.
|
||||
@@ -36,7 +40,7 @@ struct ElementBox
|
||||
// now_ms: a running millisecond counter (millis()), for scrolling. boxes: nullptr, or room
|
||||
// for page.element_count boxes.
|
||||
// Returns the ms until the page looks different again (scrolling, seconds, a running
|
||||
// stopwatch), or 0 when it only changes with the minute or its content (Home Assistant
|
||||
// stopwatch, the next generation of a Game of Life), or 0 when it only changes with the minute or its content (Home Assistant
|
||||
// values, the stopwatch being started or stopped).
|
||||
uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now_ms, PageResources &resources,
|
||||
uint8_t *bitmap, ElementBox *boxes = nullptr);
|
||||
@@ -3,14 +3,17 @@
|
||||
#include <Arduino.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "logic/life.h"
|
||||
#include "logic/settings.h"
|
||||
|
||||
static const char *const TYPES[] = {"clock", "date", "ha", "text", "countdown", "stopwatch", "image", "bar"};
|
||||
static const char *const TYPES[] = {"clock", "date", "ha", "text", "countdown", "stopwatch", "image", "bar", "life"};
|
||||
static const char *const ALIGNS[] = {"left", "center", "right"};
|
||||
static const char *const OVERLAYS[] = {"top", "box", "invert"};
|
||||
static const char *const LANGUAGES[] = {"nl", "en"};
|
||||
static const char *const SHOWS[] = {"number", "duration", "time", "map"};
|
||||
static const char *const UNITS[] = {"s", "min", "h"};
|
||||
// Game of Life (in input_unit, see logic/page.h)
|
||||
static const char *const LIFE_STARTS[] = {"random", "pattern", "rotate", "shuffle"};
|
||||
|
||||
template <size_t N> static int indexOf(const char *const (&names)[N], const char *name)
|
||||
{
|
||||
@@ -66,7 +69,12 @@ void pageToJson(const DynamicPage &page, JsonObject out)
|
||||
formatTarget(e.target, target);
|
||||
o["target"] = String(target);
|
||||
o["show"] = SHOWS[(int)e.show];
|
||||
o["input_unit"] = UNITS[e.input_unit];
|
||||
o["input_unit"] = UNITS[e.input_unit < UNIT_COUNT ? e.input_unit : 0];
|
||||
if (e.type == ElementType::Life)
|
||||
{
|
||||
o["start"] = LIFE_STARTS[e.input_unit < LIFE_START_COUNT ? e.input_unit : 0];
|
||||
o["generations"] = e.target;
|
||||
}
|
||||
o["auto_unit"] = e.auto_unit;
|
||||
o["absolute"] = e.absolute;
|
||||
o["map"] = withDegrees(e.map);
|
||||
@@ -148,6 +156,14 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
|
||||
const char *target = o["target"] | "";
|
||||
if (e.type == ElementType::Countdown && !parseTarget(target, e.target))
|
||||
return "Countdown: enter a date and time";
|
||||
if (e.type == ElementType::Life)
|
||||
{
|
||||
int start = indexOf(LIFE_STARTS, o["start"] | "random");
|
||||
if (start < 0)
|
||||
return "Game of Life: unknown start";
|
||||
e.input_unit = start;
|
||||
e.target = constrain(o["generations"] | 0.0, 0.0, (double)LIFE_GENERATIONS_MAX + 1);
|
||||
}
|
||||
if (!copyText(o["font"], e.font, sizeof(e.font)) || !copyText(o["text"], e.text, sizeof(e.text)) ||
|
||||
!copyText(o["suffix"], e.suffix, sizeof(e.suffix)) || !copyText(o["entity"], e.entity, sizeof(e.entity)) ||
|
||||
!copyText(o["attribute"], e.attribute, sizeof(e.attribute)))
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
// decimals (-1 = as received), text, suffix, entity, attribute, factor,
|
||||
// scroll_width, scroll_speed, target ("YYYY-MM-DDTHH:MM"),
|
||||
// show: "number"|"duration"|"time"|"map", input_unit: "s"|"min"|"h",
|
||||
// auto_unit, absolute, map}]}
|
||||
// type also "countdown", "stopwatch", "image". Texts use the UTF-8 degree sign.
|
||||
// auto_unit, absolute, map, bar_width, bar_height, bar_max, bar_frame,
|
||||
// day_offset}]}
|
||||
// type also "countdown", "stopwatch", "image", "bar", "life". Game of Life elements also
|
||||
// have start: "random"|"pattern"|"rotate"|"shuffle" and generations (0: no limit); the
|
||||
// other fields mean what logic/page.h says. Texts use the UTF-8 degree sign.
|
||||
|
||||
void pageToJson(const DynamicPage &page, JsonObject out);
|
||||
// Returns nullptr or a message for the user. Validates the page (validatePage()).
|
||||
|
||||
@@ -79,7 +79,7 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
||||
<div class="row"><button class="s" onclick="peAdd('clock')">+ Clock</button><button class="s" onclick="peAdd('date')">+ Date</button>
|
||||
<button class="s" onclick="peAdd('ha')">+ Home Assistant value</button><button class="s" onclick="peAdd('text')">+ Text</button>
|
||||
<button class="s" onclick="peAdd('countdown')">+ Countdown</button><button class="s" onclick="peAdd('stopwatch')">+ Stopwatch</button>
|
||||
<button class="s" onclick="peAdd('image')">+ Image</button><button class="s" onclick="peAdd('bar')">+ Progress bar</button></div>
|
||||
<button class="s" onclick="peAdd('image')">+ Image</button><button class="s" onclick="peAdd('bar')">+ Progress bar</button><button class="s" onclick="peAdd('life')">+ Game of Life</button></div>
|
||||
<p class="hint">Drag elements on the preview, or select one and use the arrow keys. X is the left edge, the middle or the right edge (Align). Box clears the dots behind the element, Inverted flips the dots below it. Home Assistant values need the URL and token in the Home Assistant tab. Type ° for degrees. Entity ids can contain {plex_user} (Home Assistant tab). A text wider than its scroll window scrolls; moving pages (scrolling, seconds) are shown without the refresh effect, as fast as the dots can flip.</p></div>
|
||||
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
|
||||
<h3>Add a font</h3>
|
||||
@@ -130,7 +130,10 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
||||
<div class="row" id="ic_imgopts" style="display:none"><label>Threshold <input type="range" id="ic_thr" min="1" max="254" value="128"></label>
|
||||
<label><input type="checkbox" id="ic_inv"> Invert</label><label><input type="checkbox" id="ic_dith"> Dither</label></div>
|
||||
<canvas id="ic_canvas" width="840" height="190"></canvas>
|
||||
<p class="hint">Click or drag to draw; starting on a yellow dot erases. A new size keeps the dots at the top left. Crop removes the empty rows and columns around the dots; on a page the icon is drawn from the element's X, Y (its top left corner), so cropping moves the dots. Images are scaled to the icon size.</p></div></section>
|
||||
<p class="hint">Click or drag to draw; starting on a yellow dot erases. A new size keeps the dots at the top left. Crop removes the empty rows and columns around the dots; on a page the icon is drawn from the element's X, Y (its top left corner), so cropping moves the dots. Images are scaled to the icon size.</p></div>
|
||||
<div class="card"><h2>Game of Life patterns</h2><div class="grid" id="lifepats"></div>
|
||||
<div class="row"><button class="b" onclick="lifeAdd(LIFEPATS)">Add all</button></div>
|
||||
<p class="hint">Famous patterns from LifeWiki (conwaylife.com), added as icons named life_... A Game of Life element on a page can start with one of them, or go through all icons whose names start with life_ (also the ones you draw yourself).</p></div></section>
|
||||
|
||||
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
|
||||
<canvas id="editor" width="840" height="190"></canvas>
|
||||
@@ -265,7 +268,9 @@ function usedBitmaps(){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());
|
||||
if(sched)(sched.rules||[]).forEach(r=>add(r.page));return u}
|
||||
// The bitmaps and icons a page shows (backgrounds, fixed images, the names of images by state)
|
||||
function pageImages(page){const u=new Set(),add=n=>n&&u.add(n.toLowerCase());(page.slots||[]).forEach(s=>add(s.bitmap));
|
||||
(page.elements||[]).forEach(e=>{if(e.type!='image')return;if(!e.entity)add(e.text);
|
||||
(page.elements||[]).forEach(e=>{if(e.type=='life'&&e.start=='pattern')add(e.text);
|
||||
if(e.type=='life'&&(e.start=='rotate'||e.start=='shuffle'))iconList.forEach(b=>{if(b.name.toLowerCase().startsWith((e.text||'').toLowerCase()))add(b.name)});
|
||||
if(e.type!='image')return;if(!e.entity)add(e.text);
|
||||
else if(e.text)bitmapList.concat(iconList).forEach(b=>{if(b.name.toLowerCase().startsWith(e.text.toLowerCase()))add(b.name)})});return u}
|
||||
function subsection(id,n){$(id+'_box').style.display=n?'':'none';$(id+'_box').querySelector('summary').textContent='Used by pages and rules ('+n+')'}
|
||||
async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/bitmaps','bitmaps'),getList('/api/pages','pages'),getList('/api/fonts','fonts'),getList('/api/schedule','blocks'),getList('/api/icons','icons')]);
|
||||
@@ -292,7 +297,7 @@ async function loadLists(){const[b,p,f,sc,ic]=await Promise.all([getList('/api/b
|
||||
mk('div',{className:'tag',textContent:x.width+' x '+x.height+', '+(by.length?'on '+by.join(', '):'not used'),title:by.join(', ')}),
|
||||
mk('div',{className:'row'},btn('Edit',()=>icOpen(x)),btn('Make bitmap',()=>toBitmap(x.name)),btn('Export',()=>exportItem('icon',x.name)),btn('Delete',()=>delIcon(x.name),'d')));
|
||||
ics.appendChild(d)}
|
||||
if(!iconList.length)ics.append(mk('p',{className:'hint',textContent:'No icons yet.'}));
|
||||
if(!iconList.length)ics.append(mk('p',{className:'hint',textContent:'No icons yet.'}));lifeLib();
|
||||
const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused));
|
||||
schedBuild();
|
||||
const fl=$('fontlist');fl.innerHTML='';
|
||||
@@ -360,15 +365,18 @@ async function scSave(){sched.blocks.sort(bySlot);sched=await post('/api/schedul
|
||||
async function backToSchedule(){await post('/api/schedule/back');msg('Back to the schedule');setTimeout(pollStatusOnce,500)}
|
||||
async function pollStatusOnce(){try{status=await api('/api/status');$('sc_now').textContent='Showing: '+status.shown+' ('+status.shown_status+')';$('shownnow').textContent=$('sc_now').textContent}catch(e){}}
|
||||
// page editor: the ESP32 draws the preview and returns the element boxes
|
||||
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image',bar:'Progress bar'};
|
||||
const ETYPES={clock:'Clock',date:'Date',ha:'Home Assistant value',text:'Text',countdown:'Countdown',stopwatch:'Stopwatch',image:'Image',bar:'Progress bar',life:'Game of Life'};
|
||||
const TEXTTYPES=['clock','date','ha','text','countdown','stopwatch'];
|
||||
const CDF=[['smart: 12 dagen, 5 uur, 34 min, 0:45','smart: 12 days, 5 hours, 34 min, 0:45'],['days: 12 dagen','days: 12 days'],['12d 04:05','12d 04:05'],['12d 04:05:33 (every second)','12d 04:05:33 (every second)']];
|
||||
const DATEF=[['do 15 okt','Thu 15 Oct'],['15 okt','15 Oct'],['15 oktober','15 October'],['15-10','15-10'],['15-10-2026','15-10-2026'],['donderdag','Thursday'],['do','Thu']];
|
||||
let pe=null,peTimer=null,peSeq=0,peData=null,peDrag=null;
|
||||
function tomorrow(){const d=new Date(Date.now()+864e5),p=n=>String(n).padStart(2,'0');return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate())+'T12:00'}
|
||||
function newEl(t){return{type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
|
||||
function newEl(t){const e={type:t,x:t=='clock'?27:0,y:t=='clock'?5:0,align:'left',overlay:'top',font:'Normal',format:0,language:'nl',decimals:-1,
|
||||
text:t=='text'?'Hallo':'',suffix:'',entity:'',attribute:'',factor:1,scroll_width:0,scroll_speed:8,target:tomorrow(),show:'number',input_unit:'s',auto_unit:false,absolute:false,map:'',
|
||||
bar_width:40,bar_height:3,bar_max:100,bar_frame:true,day_offset:0}}
|
||||
bar_width:40,bar_height:3,bar_max:100,bar_frame:true,day_offset:0};
|
||||
// Game of Life (src/esp32/logic/page.h): format = edges, factor = density, scroll_speed = generations/s, bar_max = hold s
|
||||
if(t=='life')Object.assign(e,{bar_width:84,bar_height:19,format:2,start:'random',factor:30,scroll_speed:4,auto_unit:true,generations:0,bar_max:5,text:'life_'});
|
||||
return e}
|
||||
function peShow(title,name){$('pe_title').textContent=title;$('pe_name').value=name;$('pe').style.display='block';peData=null;peBuild();pePreview();$('pe').scrollIntoView({behavior:'smooth'})}
|
||||
function peOpen(pg,copy){pe={orig:copy?'':pg.name,page:JSON.parse(JSON.stringify(pg.page)),sel:-1,boxes:[],pos:[]};peShow(copy?'Copy of '+pg.name:'Edit '+pg.name,copy?'':pg.name)}
|
||||
function peNew(){pe={orig:'',page:{slots:[{start:'00:00',bitmap:''}],elements:[newEl('clock')]},sel:-1,boxes:[],pos:[]};peShow('New page','')}
|
||||
@@ -432,6 +440,18 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
|
||||
const pc=mk('input',{type:'checkbox',checked:e.format==1});pc.onchange=()=>{e.format=pc.checked?1:0;if(pc.checked&&same(e.font,'Normal'))e.font='Small';peElems();pePreview()};
|
||||
row(mk('label',{},pc,' percentage on top (inverts the bar below it; only shown when it fits inside, Small needs a height of 7 with frame)'));
|
||||
rows.push(mk('p',{className:'hint',textContent:'Filled from the left: value x multiplier / full at (e.g. a percentage: full at 100; seconds of a duration: use an entity with the percentage). Unknown values show an empty bar.'}))}
|
||||
if(e.type=='life'){const rot=e.start=='rotate'||e.start=='shuffle',pat=inp(e,'text','text',{size:rot?10:20,maxLength:rot?23:24,placeholder:rot?'life_':'life_glider'});
|
||||
if(!rot)pat.setAttribute('list','bmnames');const n=iconList.filter(x=>x.name.toLowerCase().startsWith((e.text||'').toLowerCase())).length;
|
||||
row(field('Start',sel(e,'start',[['random','random dots'],['pattern','one pattern'],['rotate','patterns in turn'],['shuffle','patterns in random order']],peElems)),
|
||||
...(e.start=='random'?[field('Density',inp(e,'factor','number',{style:'width:4em',min:1,max:100})),'%']:[]),
|
||||
...(e.start=='pattern'?[field('Pattern',pat)]:[]),...(rot?[field('Icons whose names start with',pat),mk('span',{className:'hint',textContent:'('+n+' now)'})]:[]),
|
||||
...(e.start!='random'?[chk(e,'absolute','at a random place (otherwise in the middle)')]:[]));
|
||||
row(field('Edges',sel(e,'format',[[0,'wrap around'],[1,'dead (nothing outside)'],[2,'hidden margin (gliders fly away)']])),
|
||||
field('Speed',inpf(e,'scroll_speed',{style:'width:5em',min:0.1,max:20})),'generations per second',
|
||||
field('Width',inp(e,'bar_width','number',{style:'width:4em',min:1,max:84})),field('Height',inp(e,'bar_height','number',{style:'width:4em',min:1,max:19})));
|
||||
row(chk(e,'auto_unit','restart when it stops changing (still or repeating)'),field('or after',inp(e,'generations','number',{style:'width:6em',min:0})),'generations (0: never);',
|
||||
field('first keep running',inp(e,'bar_max','number',{style:'width:4em',min:0,max:3600})),'s');
|
||||
rows.push(mk('p',{className:'hint',textContent:'Patterns are icons: add famous ones in the Icons tab (Game of Life patterns) or draw your own. Wrap around: what leaves one side comes back on the other. Hidden margin: the world is larger than the area, so gliders fly out of view. A new generation is sent once the dots of the last one have flipped, so a busy board can be slower than the speed. The preview shows the running board while this page is shown, otherwise its start.'}))}
|
||||
const fonts=fontList.map(f=>[f.name,f.name+' ('+f.height+')']);if(!fontList.some(f=>same(f.name,e.font)))fonts.push([e.font,e.font+' (gone)']);
|
||||
const pos=[field('X',inp(e,'x','number',{style:'width:5em'})),field('Y',inp(e,'y','number',{style:'width:5em'}))];
|
||||
const ovl=field('Overlay',sel(e,'overlay',[['top','on top'],['box','box (clear behind)'],['invert','inverted']]));
|
||||
@@ -440,9 +460,11 @@ function peElems(){const d=$('pe_elems');d.innerHTML='';d.append(mk('datalist',{
|
||||
row(field('Scroll in a window of',inp(e,'scroll_width','number',{style:'width:5em',min:0,max:168})),'dots (0: off), at',
|
||||
inpf(e,'scroll_speed',{style:'width:5em',min:0.05,max:30}),'dots per second (0.25: one every 4 s)')}
|
||||
else if(e.type=='bar')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),...(e.format==1?[field('Font',sel(e,'font',fonts))]:[]),ovl);
|
||||
else if(e.type=='life')row(...pos,field('Align',sel(e,'align',[['left','left'],['center','center'],['right','right']])),ovl);
|
||||
else row(...pos,ovl);
|
||||
box.append(...rows);d.append(box)})}
|
||||
function peAdd(t){if(pe.page.elements.length>=16){msg('16 elements at most',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
|
||||
function peAdd(t){if(pe.page.elements.length>=16){msg('16 elements at most',true);return}
|
||||
if(t=='life'&&pe.page.elements.some(e=>e.type=='life')){msg('One Game of Life per page',true);return}pe.page.elements.push(newEl(t));pe.sel=pe.page.elements.length-1;peElems();pePreview()}
|
||||
function peMark(){document.querySelectorAll('#pe_elems .el').forEach((d,i)=>d.classList.toggle('sel',i==pe.sel));peDraw()}
|
||||
function pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p}
|
||||
function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)}
|
||||
@@ -608,6 +630,26 @@ function imgBits(img,w,h,thr,inv,dith,fit){const c=document.createElement('canva
|
||||
if(dith){const e=l[i]-o;if(x+1<w)l[i+1]+=e*7/16;if(y+1<h){if(x>0)l[i+w-1]+=e*3/16;l[i+w]+=e*5/16;if(x+1<w)l[i+w+1]+=e/16}}}
|
||||
return b}
|
||||
function convert(){if(!srcImg)return;ed.set(imgBits(srcImg,W,H,+$('thr').value,$('inv').checked,$('dith').checked,$('fit').value));changed()}
|
||||
// Game of Life patterns from LifeWiki (checked with a simulation): icon name, what it does, RLE
|
||||
const LIFEPATS=[['life_glider','flies diagonally','bo$2bo$3o!'],['life_lwss','lightweight spaceship','bo2bo$o4b$o3bo$4o!'],
|
||||
['life_mwss','middleweight spaceship','3bo2b$bo3bo$o5b$o4bo$5o!'],['life_hwss','heavyweight spaceship','3b2o2b$bo4bo$o6b$o5bo$6o!'],
|
||||
['life_copperhead','slow spaceship (c/10)','3b2obo$o6bo$o2bo3b2o$b2o5bob2o$b2o5bob2o$o2bo3b2o$o6bo$3b2obo!'],
|
||||
['life_gosper_gun','glider gun (period 30)','24bo$22bobo$12b2o6b2o12b2o$11bo3bo4b2o12b2o$2o8bo5bo3b2o$2o8bo3bob2o4bobo$10bo5bo7bo$11bo3bo$12b2o!'],
|
||||
['life_pulsar','oscillator (period 3)','2b3o3b3o2$o4bobo4bo$o4bobo4bo$o4bobo4bo$2b3o3b3o2$2b3o3b3o$o4bobo4bo$o4bobo4bo$o4bobo4bo2$2b3o3b3o!'],
|
||||
['life_pentadecathlon','oscillator (period 15)','2bo4bo$2ob4ob2o$2bo4bo!'],['life_queen_bee','shuttle (period 30)','9bo$7bobo$6bobo$2o3bo2bo11b2o$2o4bobo11b2o$7bobo$9bo!'],
|
||||
['life_figure_eight','oscillator (period 8)','3o$3o$3o$3b3o$3b3o$3b3o!'],['life_kok_galaxy','oscillator (period 8)','6ob2o$6ob2o$7b2o$2o5b2o$2o5b2o$2o5b2o$2o$2ob6o$2ob6o!'],
|
||||
['life_beacon','oscillator (period 2)','2o$o$3bo$2b2o!'],['life_r_pentomino','chaos for 1103 generations','b2o$2o$bo!'],
|
||||
['life_acorn','chaos for 5206 generations','bo$3bo$2o2b3o!'],['life_diehard','vanishes after 130 generations','6bo$2o$bo3b3o!'],
|
||||
['life_thunderbird','settles after 243 generations','3o2$bo$bo$bo!'],['life_pi_heptomino','settles after 173 generations','3o$obo$obo!']];
|
||||
function rleBits(rle){const p=[];let x=0,y=0,w=1,n='';for(const c of rle){if(c>='0'&&c<='9'){n+=c;continue}const k=+n||1;n='';
|
||||
if(c=='b')x+=k;else if(c=='o'){for(let i=0;i<k;i++)p.push([x+i,y]);x+=k;w=Math.max(w,x)}else if(c=='$'){y+=k;x=0}else if(c=='!')break}
|
||||
const h=y+1,b=new Uint8Array(w*h);p.forEach(([a,c])=>b[c*w+a]=1);return{w,h,bits:b}}
|
||||
function lifeLib(){const d=$('lifepats');d.innerHTML='';for(const l of LIFEPATS){const p=rleBits(l[2]),have=iconList.some(x=>same(x.name,l[0]));
|
||||
d.append(mk('div',{className:'item'},icCanvas(p.bits,p.w,p.h),mk('div',{className:'name',textContent:l[0]}),mk('div',{className:'tag',textContent:p.w+' x '+p.h+', '+l[1]}),
|
||||
mk('div',{className:'row'},have?mk('span',{className:'hint',textContent:'added'}):btn('Add',()=>lifeAdd([l])))))}}
|
||||
async function lifeAdd(list){let k=0,bad=0;for(const[n,,r]of list){if(iconList.some(x=>same(x.name,n)))continue;const p=rleBits(r);
|
||||
try{await post('/api/icon',{name:n,width:p.w,height:p.h,data:icToHex(p.bits,p.w,p.h)});k++}catch(e){bad++}}
|
||||
if(!bad)msg(k?'Added '+k+' pattern'+(k>1?'s':''):'All patterns are there');loadLists()}
|
||||
// icon editor: its own size, drawn like the bitmap editor
|
||||
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas');
|
||||
function icShow(title){icImg=null;$('ic_imgopts').style.display='none';$('ic_file').value='';$('ic_title').textContent=title;$('ic_name').value=ic.orig;$('ic_w').value=ic.w;$('ic_h').value=ic.h;$('ic').style.display='block';icDraw();$('ic').scrollIntoView({behavior:'smooth'})}
|
||||
|
||||
Reference in new issue
Block a user