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
@@ -179,6 +179,15 @@ See README.md for the overview.
|
|||||||
so it can be edited; the built-in one is the fallback, and deleting the copy resets it.
|
so it can be edited; the built-in one is the fallback, and deleting the copy resets it.
|
||||||
- Pages hold 16 elements (format 7). A `DynamicPage` is about 4 KB: allocate it with `new`, and reset
|
- Pages hold 16 elements (format 7). A `DynamicPage` is about 4 KB: allocate it with `new`, and reset
|
||||||
it with `clearPage()`, not `page = DynamicPage()` (a temporary on the 8 KB loop stack).
|
it with `clearPage()`, not `page = DynamicPage()` (a temporary on the 8 KB loop stack).
|
||||||
|
- Game of Life (`logic/life`, `display/life_player`): a page element without fields of its
|
||||||
|
own: it reuses `bar_width/bar_height` (area), `format` (edges), `input_unit` (start),
|
||||||
|
`text` (pattern / name start), `factor` (density), `scroll_speed`, `auto_unit`, `target`
|
||||||
|
(max generations), `bar_max` (hold s); the list is in `logic/page.h`. JSON adds `start`
|
||||||
|
and `generations`. One per page: `LifePlayer` keeps one board (about 7.5 KB) and ticks it
|
||||||
|
in `FlipdotFrames::dynamicPage()`; reloading a page with the same start
|
||||||
|
(`sameLifeStart()`) keeps it running. Rotating lists the icons only when
|
||||||
|
`iconListRevision()` changes. The pattern library (RLE) is in the web page; check new
|
||||||
|
patterns with a simulation before adding them.
|
||||||
- HA attributes may be paths into lists (`forecast.1.condition`, `net/ha_api`). The
|
- HA attributes may be paths into lists (`forecast.1.condition`, `net/ha_api`). The
|
||||||
forecast comes from the template sensor in `docs/home-assistant.md`.
|
forecast comes from the template sensor in `docs/home-assistant.md`.
|
||||||
- `examples/home-pages.json` is written by `tools/export_examples.py` from the display.
|
- `examples/home-pages.json` is written by `tools/export_examples.py` from the display.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 387 KiB After Width: | Height: | Size: 358 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 363 KiB |
@@ -23,7 +23,7 @@ too, and it boots again each time the relay switches on. Only the ESP32 runs all
|
|||||||
| Part | Folder | What it does |
|
| Part | Folder | What it does |
|
||||||
|------|--------|--------------|
|
|------|--------|--------------|
|
||||||
| **Arduino Nano** | [src/nano](src/nano/README.md) | Drives the panel's row and column electronics directly. Keeps a frame buffer and flips only the dots that changed. Takes simple text commands over serial. |
|
| **Arduino Nano** | [src/nano](src/nano/README.md) | Drives the panel's row and column electronics directly. Keeps a frame buffer and flips only the dots that changed. Takes simple text commands over serial. |
|
||||||
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows dynamic pages (a background per time of day with a clock, the date, Home Assistant values or text) or your own bitmaps, has a [web UI](docs/web-ui.md), exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
|
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows dynamic pages (a background per time of day with a clock, the date, Home Assistant values, text or Conway's Game of Life) or your own bitmaps, has a [web UI](docs/web-ui.md), exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
|
||||||
| **ESP32 recovery app** | [src/recovery](src/recovery/README.md) | Small second app: WiFi setup page and OTA, so the ESP32 stays reachable when the clock firmware fails. |
|
| **ESP32 recovery app** | [src/recovery](src/recovery/README.md) | Small second app: WiFi setup page and OTA, so the ESP32 stays reachable when the clock firmware fails. |
|
||||||
| **BROSE9323 driver** | [lib/BROSE9323](lib/BROSE9323/README.md) | Shared `Adafruit_GFX` display driver. On the Nano it toggles GPIO pins. On the ESP32 (`-D ESPSERIAL`) it turns every drawing call into a serial command for the Nano. |
|
| **BROSE9323 driver** | [lib/BROSE9323](lib/BROSE9323/README.md) | Shared `Adafruit_GFX` display driver. On the Nano it toggles GPIO pins. On the ESP32 (`-D ESPSERIAL`) it turns every drawing call into a serial command for the Nano. |
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
- **08:00–23:30:** the page: the chosen page (by default the built-in Clock page,
|
- **08:00–23:30:** the page: the chosen page (by default the built-in Clock page,
|
||||||
Pikachu with a thought bubble showing `HH:MM`, yawning from 17:00), or what the
|
Pikachu with a thought bubble showing `HH:MM`, yawning from 17:00), or what the
|
||||||
[schedule and rules](web-ui.md#schedule) pick.
|
[schedule and rules](web-ui.md#schedule) pick.
|
||||||
|
- **Game of Life** (a page element): the ESP32 runs the board (`logic/life`) and sends a
|
||||||
|
frame per generation with only the changed dots. Like scrolling text, the next frame
|
||||||
|
waits until the Nano has received and flipped the last one, so a busy board runs
|
||||||
|
slower than the chosen speed. Only the shown page runs; it pauses while the display is
|
||||||
|
off. Still or repeating boards are found by comparing a hash of the board with the
|
||||||
|
last 16 generations. With the hidden margin, gliders that leave the area are cleared
|
||||||
|
at the outer edge, and leftovers that stay put in the margin are cleared after 12
|
||||||
|
generations, so a glider gun keeps running cleanly.
|
||||||
- **Slow mode** (a switch in Home Assistant and the web UI, for less noise): the display
|
- **Slow mode** (a switch in Home Assistant and the web UI, for less noise): the display
|
||||||
only changes every 1, 2, 5, 10, 15, 20, 30 or 60 minutes, on the clock (15 minutes =
|
only changes every 1, 2, 5, 10, 15, 20, 30 or 60 minutes, on the clock (15 minutes =
|
||||||
:00, :15, :30 and :45). 30 seconds after each update the relay turns the display off;
|
:00, :15, :30 and :45). 30 seconds after each update the relay turns the display off;
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@
|
|||||||
[← Back to the README](../README.md)
|
[← Back to the README](../README.md)
|
||||||
|
|
||||||
The dynamic pages on the display, drawn by the ESP32 itself (`python
|
The dynamic pages on the display, drawn by the ESP32 itself (`python
|
||||||
tools/make_page_screenshots.py` makes these pictures again). The Clock page comes with the firmware (it can be changed, Reset brings back the original);
|
tools/make_page_screenshots.py` makes these pictures again). The Clock and Game of Life pages come with the firmware (they can be changed, Reset brings back the original);
|
||||||
the others are the example pages in [examples/home-pages.json](../examples/home-pages.json)
|
the others are the example pages in [examples/home-pages.json](../examples/home-pages.json)
|
||||||
with their icons (import them in the web UI, System tab). They use this home's Home
|
with their icons (import them in the web UI, System tab). They use this home's Home
|
||||||
Assistant entities: change them to yours in the page editor (`climate.home`,
|
Assistant entities: change them to yours in the page editor (`climate.home`,
|
||||||
@@ -14,6 +14,7 @@ replaces private parts of entity ids by these placeholders.
|
|||||||
| Page | |
|
| Page | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Clock** (from the firmware), day and from 17:00 |   |
|
| **Clock** (from the firmware), day and from 17:00 |   |
|
||||||
|
| **Game of Life** (from the firmware): the `life_` patterns of the Icons tab in random order on the whole display, hidden margin, 4 generations per second, a new pattern when it stops changing or after 600 generations; random dots without patterns |  |
|
||||||
| **Weer**: clock, weather icon by state, humidity, temperature |  |
|
| **Weer**: clock, weather icon by state, humidity, temperature |  |
|
||||||
| **Verwachting**: today (icon, temperature now, max/min) and the next 2 days (weekday, small icon, max/min); needs the [forecast sensor](home-assistant.md#weather-forecast) |  |
|
| **Verwachting**: today (icon, temperature now, max/min) and the next 2 days (weekday, small icon, max/min); needs the [forecast sensor](home-assistant.md#weather-forecast) |  |
|
||||||
| **Dag en nacht**: sun from 07:00, moon and stars from 19:00, date |   |
|
| **Dag en nacht**: sun from 07:00, moon and stars from 19:00, date |   |
|
||||||
|
|||||||
+12
-2
@@ -25,8 +25,9 @@ Bitmaps that pages or rules use are in their own subsection.
|
|||||||
Dynamic pages: a background per time of day, a bitmap or an [icon](#icons) (e.g. Pikachu
|
Dynamic pages: a background per time of day, a bitmap or an [icon](#icons) (e.g. Pikachu
|
||||||
from 00:00, yawning Pikachu from 17:00) with up to 16 elements on top. The preview is drawn by the ESP32, so it
|
from 00:00, yawning Pikachu from 17:00) with up to 16 elements on top. The preview is drawn by the ESP32, so it
|
||||||
is exactly what the display shows; drag the elements on it, or select one and use the
|
is exactly what the display shows; drag the elements on it, or select one and use the
|
||||||
arrow keys. The Clock page (Pikachu, the default) is a normal page that can be changed;
|
arrow keys. The Clock page (Pikachu, the default) and the Game of Life page are normal
|
||||||
its Reset button brings back the original. See the [pictures of the pages](pages.md).
|
pages that can be changed; their Reset button brings back the original. See the
|
||||||
|
[pictures of the pages](pages.md).
|
||||||
|
|
||||||
| Element | Options |
|
| Element | Options |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
@@ -38,6 +39,7 @@ its Reset button brings back the original. See the [pictures of the pages](pages
|
|||||||
| Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
|
| Stopwatch | `4:05`, `0:04`, `0:04:05`; started in the [Stopwatch](#stopwatch) tab or Home Assistant |
|
||||||
| Image | An icon (or a bitmap) at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the icon `weer cloudy`, `weer default` when there is none) |
|
| Image | An icon (or a bitmap) at a position, fixed or chosen by a Home Assistant state (`weer ` + `cloudy` = the icon `weer cloudy`, `weer default` when there is none) |
|
||||||
| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame. Optionally the percentage on top (e.g. 42%, rounded down, in the chosen font): its dots invert the bar, so it stays readable on the filled and the empty part; left out when it doesn't fit inside (Small needs a height of 7 with a frame) |
|
| Progress bar | Filled from the left by a Home Assistant number: value x multiplier / "full at" (a percentage: full at 100). Width, height, with or without a frame. Optionally the percentage on top (e.g. 42%, rounded down, in the chosen font): its dots invert the bar, so it stays readable on the filled and the empty part; left out when it doesn't fit inside (Small needs a height of 7 with a frame) |
|
||||||
|
| Game of Life | [Conway's Game of Life](https://conwaylife.com/wiki/Conway%27s_Game_of_Life) in an area (the whole display by default), one per page. Start: random dots (density %), one pattern, or the patterns whose names start with a text (e.g. `life_`) in turn or in random order, in the middle or at a random place. Edges: wrap around, dead, or a hidden margin (the world is 16 dots larger on every side, so gliders fly out of view and disappear). Speed in generations per second. It restarts when it stops changing (still, or repeating within 16 generations) and/or after a number of generations, after running on for a few seconds (an empty board at once) |
|
||||||
|
|
||||||
Every element has a position, alignment, font and overlay (on top, box that clears the
|
Every element has a position, alignment, font and overlay (on top, box that clears the
|
||||||
dots behind it, inverted). Entity ids can contain `{plex_user}`, replaced by the Plex user
|
dots behind it, inverted). Entity ids can contain `{plex_user}`, replaced by the Plex user
|
||||||
@@ -98,6 +100,14 @@ size), show it on the display. "Make bitmap" turns an icon into a full screen bi
|
|||||||
firmware: they are stored as icons at boot and can be edited, the firmware keeps the
|
firmware: they are stored as icons at boot and can be edited, the firmware keeps the
|
||||||
originals.
|
originals.
|
||||||
|
|
||||||
|
**Game of Life patterns**: 17 famous patterns from [LifeWiki](https://conwaylife.com/wiki/)
|
||||||
|
(glider, the three spaceships, a copperhead turned to fly sideways, the Gosper glider gun,
|
||||||
|
pulsar, pentadecathlon, queen bee shuttle, figure eight, Kok's galaxy, beacon and the
|
||||||
|
methuselahs R-pentomino, acorn, diehard, thunderbird and pi-heptomino). Add adds one as
|
||||||
|
an icon named `life_...`, Add all adds the missing ones. A Game of Life element starts with
|
||||||
|
one of them, or goes through all icons whose names start with `life_`, also the ones you
|
||||||
|
draw yourself. The patterns live in the web page (RLE text), not in the firmware.
|
||||||
|
|
||||||
Firmware 2.10.0 made icons of all uploaded bitmaps that only pages used (backgrounds and
|
Firmware 2.10.0 made icons of all uploaded bitmaps that only pages used (backgrounds and
|
||||||
images, not shown by the schedule, a rule, the chosen page or the off image), once, after
|
images, not shown by the schedule, a rule, the chosen page or the off image), once, after
|
||||||
the new firmware was marked as good. They keep their names and land on the same dots, so
|
the new firmware was marked as good. They keep their names and land on the same dots, so
|
||||||
|
|||||||
+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/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/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/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/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/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_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_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/page_selector.*` | Which page is shown now: rules, a manual pick, the rotating schedule (`PageSelector`) |
|
||||||
| `logic/stopwatch.h` | The stopwatch of the dynamic pages |
|
| `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/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
|
||||||
| `display/icon_store.*` | Icons (small images for pages) 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/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/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/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`) |
|
| `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/bitmap_store.h"
|
||||||
#include "display/font_store.h"
|
#include "display/font_store.h"
|
||||||
#include "display/icon_store.h"
|
#include "display/icon_store.h"
|
||||||
|
#include "display/life_player.h"
|
||||||
#include "display/page_resources.h"
|
#include "display/page_resources.h"
|
||||||
#include "display/page_store.h"
|
#include "display/page_store.h"
|
||||||
#include "logic/page_selector.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);
|
NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
|
||||||
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
|
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
|
||||||
FlipdotFrames frames(display, pageResources);
|
FlipdotFrames frames(display, pageResources, lifePlayer);
|
||||||
StatusScreen status(display, nano);
|
StatusScreen status(display, nano);
|
||||||
DisplayController controller(nano, frames);
|
DisplayController controller(nano, frames);
|
||||||
LoopStats loopStats;
|
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_CONNECT_TIMEOUT = 30000;
|
||||||
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
|
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)
|
// 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};
|
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "display/text.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)
|
uint32_t FlipdotFrames::dynamicPage(const LocalTime &time, uint32_t now, bool redraw)
|
||||||
{
|
{
|
||||||
uint8_t bitmap[BITMAP_BYTES];
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
_life.tick(now);
|
||||||
uint32_t wait = renderPage(_dynamic, time, now, _resources, bitmap);
|
uint32_t wait = renderPage(_dynamic, time, now, _resources, bitmap);
|
||||||
bool moving = wait != 0;
|
bool moving = wait != 0;
|
||||||
// The Nano has no acknowledgement: the next frame waits until it has received this one
|
// 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)
|
void FlipdotFrames::setDynamicPage(const DynamicPage &page)
|
||||||
{
|
{
|
||||||
_dynamic = page;
|
_dynamic = page;
|
||||||
|
_life.setPage(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
|
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "BROSE9323.h"
|
#include "BROSE9323.h"
|
||||||
|
#include "display/life_player.h"
|
||||||
#include "logic/bitmap_format.h"
|
#include "logic/bitmap_format.h"
|
||||||
#include "logic/display_controller.h"
|
#include "logic/display_controller.h"
|
||||||
#include "logic/page_render.h"
|
#include "logic/page_render.h"
|
||||||
@@ -10,9 +11,10 @@
|
|||||||
class FlipdotFrames : public Frames
|
class FlipdotFrames : public Frames
|
||||||
{
|
{
|
||||||
public:
|
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
|
// 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.
|
// 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;
|
uint32_t dynamicPage(const LocalTime &time, uint32_t now, bool redraw) override;
|
||||||
@@ -46,6 +48,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
BROSE9323 &_display;
|
BROSE9323 &_display;
|
||||||
PageResources &_resources;
|
PageResources &_resources;
|
||||||
|
LifePlayer &_life;
|
||||||
DynamicPage _dynamic;
|
DynamicPage _dynamic;
|
||||||
static const uint8_t EFFECT_UNKNOWN = 254;
|
static const uint8_t EFFECT_UNKNOWN = 254;
|
||||||
DriveSettings _drive;
|
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/bitmap_store.h"
|
||||||
#include "display/font_store.h"
|
#include "display/font_store.h"
|
||||||
#include "display/icon_store.h"
|
#include "display/icon_store.h"
|
||||||
|
#include "display/life_player.h"
|
||||||
#include "net/ha_api.h"
|
#include "net/ha_api.h"
|
||||||
|
|
||||||
Stopwatch stopwatch;
|
Stopwatch stopwatch;
|
||||||
@@ -40,3 +41,8 @@ bool StoredPageResources::stopwatchRunning()
|
|||||||
{
|
{
|
||||||
return _stopwatch.running();
|
return _stopwatch.running();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LifeRunner *StoredPageResources::life(const PageElement &element)
|
||||||
|
{
|
||||||
|
return lifePlayer.runner(element);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
#include "logic/stopwatch.h"
|
#include "logic/stopwatch.h"
|
||||||
|
|
||||||
// Bitmaps and icons from display/bitmap_store and display/icon_store, fonts from display/font_store, Home Assistant values
|
// 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
|
// web UI
|
||||||
class StoredPageResources : public PageResources
|
class StoredPageResources : public PageResources
|
||||||
{
|
{
|
||||||
@@ -16,6 +16,7 @@ public:
|
|||||||
const char *haValue(const PageElement &element) override;
|
const char *haValue(const PageElement &element) override;
|
||||||
uint32_t stopwatchMs() override;
|
uint32_t stopwatchMs() override;
|
||||||
bool stopwatchRunning() override;
|
bool stopwatchRunning() override;
|
||||||
|
const LifeRunner *life(const PageElement &element) override;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Stopwatch &_stopwatch;
|
Stopwatch &_stopwatch;
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include "logic/builtin_fonts.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()
|
int builtinPageCount()
|
||||||
{
|
{
|
||||||
@@ -42,6 +43,26 @@ static void clockPage(DynamicPage &page)
|
|||||||
strcpy(clock.font, FONT_NORMAL_NAME);
|
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)
|
bool builtinPage(const char *name, DynamicPage &page)
|
||||||
{
|
{
|
||||||
if (name && sameName(name, BUILTIN_PAGE_CLOCK))
|
if (name && sameName(name, BUILTIN_PAGE_CLOCK))
|
||||||
@@ -49,5 +70,10 @@ bool builtinPage(const char *name, DynamicPage &page)
|
|||||||
clockPage(page);
|
clockPage(page);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (name && sameName(name, BUILTIN_PAGE_LIFE))
|
||||||
|
{
|
||||||
|
lifePage(page);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,13 @@
|
|||||||
// The original dynamic pages. display/page_store stores a copy of each at boot that can be
|
// 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).
|
// 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
|
// Clock Pikachu with the time in a thought bubble, yawning from 17:00 (the clock
|
||||||
// the old firmware; also the default page)
|
// 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_CLOCK "Clock"
|
||||||
|
#define BUILTIN_PAGE_LIFE "Game of Life"
|
||||||
|
|
||||||
int builtinPageCount();
|
int builtinPageCount();
|
||||||
const char *builtinPageName(int index);
|
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 <string.h>
|
||||||
#include <strings.h>
|
#include <strings.h>
|
||||||
|
|
||||||
|
#include "logic/life.h"
|
||||||
|
|
||||||
static const uint16_t MINUTES_PER_DAY = 24 * 60;
|
static const uint16_t MINUTES_PER_DAY = 24 * 60;
|
||||||
static const uint32_t MAX_TARGET = 36525UL * MINUTES_PER_DAY; // 2100-01-01
|
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)
|
bool pageUsesImage(const DynamicPage &page, const char *name, bool by_state)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < page.slot_count; i++)
|
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++)
|
for (int i = 0; i < page.element_count; i++)
|
||||||
{
|
{
|
||||||
const PageElement &e = page.elements[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)
|
if (e.type != ElementType::Image)
|
||||||
continue;
|
continue;
|
||||||
if (!e.entity[0])
|
if (!e.entity[0])
|
||||||
@@ -152,11 +163,39 @@ static uint8_t formatCount(const PageElement &e)
|
|||||||
return STOPWATCH_FORMAT_COUNT;
|
return STOPWATCH_FORMAT_COUNT;
|
||||||
case ElementType::Bar:
|
case ElementType::Bar:
|
||||||
return BAR_FORMAT_COUNT;
|
return BAR_FORMAT_COUNT;
|
||||||
|
case ElementType::Life:
|
||||||
|
return LIFE_EDGES_COUNT;
|
||||||
default:
|
default:
|
||||||
return 255; // not used
|
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)
|
const char *validatePage(const DynamicPage &page)
|
||||||
{
|
{
|
||||||
if (page.slot_count > PAGE_SLOTS)
|
if (page.slot_count > PAGE_SLOTS)
|
||||||
@@ -194,7 +233,7 @@ const char *validatePage(const DynamicPage &page)
|
|||||||
return "Invalid entity or attribute";
|
return "Invalid entity or attribute";
|
||||||
if (e.decimals < DECIMALS_AS_IS || e.decimals > DECIMALS_MAX)
|
if (e.decimals < DECIMALS_AS_IS || e.decimals > DECIMALS_MAX)
|
||||||
return "Decimals must be 0-4, or as received";
|
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";
|
return "Unknown Home Assistant value setting";
|
||||||
if (!printable(e.map, sizeof(e.map)))
|
if (!printable(e.map, sizeof(e.map)))
|
||||||
return "The list of texts can only contain plain characters, up to 63";
|
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)
|
if (!isfinite(e.bar_max) || e.bar_max <= 0)
|
||||||
return "Progress bar: the full value must be a number above 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)
|
if (e.type == ElementType::Countdown && e.target >= MAX_TARGET)
|
||||||
return "The countdown date must be before 2100";
|
return "The countdown date must be before 2100";
|
||||||
if (e.type == ElementType::Image)
|
if (e.type == ElementType::Image)
|
||||||
@@ -768,6 +816,7 @@ void elementText(const PageElement &e, const LocalTime &t, const ElementValues &
|
|||||||
break;
|
break;
|
||||||
case ElementType::Image:
|
case ElementType::Image:
|
||||||
case ElementType::Bar:
|
case ElementType::Bar:
|
||||||
|
case ElementType::Life:
|
||||||
out[0] = '\0';
|
out[0] = '\0';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
+27
-2
@@ -8,8 +8,8 @@
|
|||||||
#include "logic/local_time.h"
|
#include "logic/local_time.h"
|
||||||
|
|
||||||
// Dynamic pages: a background bitmap that depends on the time of day, with elements
|
// 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)
|
// (clock, date, Home Assistant value, text, countdown, stopwatch, image, progress bar,
|
||||||
// drawn on top.
|
// Game of Life) drawn on top.
|
||||||
// Stored as a binary struct on LittleFS (display/page_store), edited in the web UI. Pure
|
// Stored as a binary struct on LittleFS (display/page_store), edited in the web UI. Pure
|
||||||
// logic, tested in test/test_esp32.
|
// 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)
|
Stopwatch, // the stopwatch (web UI and Home Assistant start, stop and reset it)
|
||||||
Image, // a bitmap, fixed or chosen by a Home Assistant state
|
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 %)
|
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,
|
Count,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,6 +138,25 @@ const uint8_t TIMESTAMP_FORMAT_COUNT = 4;
|
|||||||
const int8_t DECIMALS_AS_IS = -1;
|
const int8_t DECIMALS_AS_IS = -1;
|
||||||
const int8_t DECIMALS_MAX = 4;
|
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
|
// 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_MIN = 0.05f; // one dot every 20 s
|
||||||
const float SCROLL_SPEED_MAX = 30;
|
const float SCROLL_SPEED_MAX = 30;
|
||||||
@@ -162,6 +182,7 @@ struct PageElement
|
|||||||
int8_t decimals = DECIMALS_AS_IS;
|
int8_t decimals = DECIMALS_AS_IS;
|
||||||
// Text: the text; HaValue, Countdown, Stopwatch: shown before the value; Image: the
|
// 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)
|
// 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] = "";
|
char text[ELEMENT_TEXT_SIZE] = "";
|
||||||
// HaValue, Stopwatch: shown after the value; Countdown: shown when the time has passed
|
// HaValue, Stopwatch: shown after the value; Countdown: shown when the time has passed
|
||||||
char suffix[ELEMENT_SUFFIX_SIZE] = "";
|
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"
|
// 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);
|
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)
|
// The element shows a Home Assistant value (HaValue; Image, Bar with an entity)
|
||||||
bool elementUsesHa(const PageElement &element);
|
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);
|
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)
|
// 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,
|
static uint32_t drawTextElement(const PageElement &e, const char *text, uint32_t now_ms, PageResources &resources,
|
||||||
uint8_t *bitmap, ElementBox &box)
|
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);
|
drawImage(e, resources, bitmap, box);
|
||||||
else if (e.type == ElementType::Bar)
|
else if (e.type == ElementType::Bar)
|
||||||
drawBar(e, resources, bitmap, box);
|
drawBar(e, resources, bitmap, box);
|
||||||
|
else if (e.type == ElementType::Life)
|
||||||
|
next = sooner(next, drawLife(e, now_ms, resources, bitmap, box));
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ElementValues values;
|
ElementValues values;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
#include "logic/font.h"
|
#include "logic/font.h"
|
||||||
|
#include "logic/life.h"
|
||||||
#include "logic/local_time.h"
|
#include "logic/local_time.h"
|
||||||
#include "logic/page.h"
|
#include "logic/page.h"
|
||||||
|
|
||||||
@@ -21,6 +22,9 @@ public:
|
|||||||
virtual const char *haValue(const PageElement &element) = 0;
|
virtual const char *haValue(const PageElement &element) = 0;
|
||||||
virtual uint32_t stopwatchMs() { return 0; }
|
virtual uint32_t stopwatchMs() { return 0; }
|
||||||
virtual bool stopwatchRunning() { return false; }
|
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.
|
// 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
|
// now_ms: a running millisecond counter (millis()), for scrolling. boxes: nullptr, or room
|
||||||
// for page.element_count boxes.
|
// for page.element_count boxes.
|
||||||
// Returns the ms until the page looks different again (scrolling, seconds, a running
|
// 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).
|
// values, the stopwatch being started or stopped).
|
||||||
uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now_ms, PageResources &resources,
|
uint32_t renderPage(const DynamicPage &page, const LocalTime &time, uint32_t now_ms, PageResources &resources,
|
||||||
uint8_t *bitmap, ElementBox *boxes = nullptr);
|
uint8_t *bitmap, ElementBox *boxes = nullptr);
|
||||||
@@ -3,14 +3,17 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "logic/life.h"
|
||||||
#include "logic/settings.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 ALIGNS[] = {"left", "center", "right"};
|
||||||
static const char *const OVERLAYS[] = {"top", "box", "invert"};
|
static const char *const OVERLAYS[] = {"top", "box", "invert"};
|
||||||
static const char *const LANGUAGES[] = {"nl", "en"};
|
static const char *const LANGUAGES[] = {"nl", "en"};
|
||||||
static const char *const SHOWS[] = {"number", "duration", "time", "map"};
|
static const char *const SHOWS[] = {"number", "duration", "time", "map"};
|
||||||
static const char *const UNITS[] = {"s", "min", "h"};
|
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)
|
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);
|
formatTarget(e.target, target);
|
||||||
o["target"] = String(target);
|
o["target"] = String(target);
|
||||||
o["show"] = SHOWS[(int)e.show];
|
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["auto_unit"] = e.auto_unit;
|
||||||
o["absolute"] = e.absolute;
|
o["absolute"] = e.absolute;
|
||||||
o["map"] = withDegrees(e.map);
|
o["map"] = withDegrees(e.map);
|
||||||
@@ -148,6 +156,14 @@ const char *pageFromJson(JsonVariantConst in, DynamicPage &page)
|
|||||||
const char *target = o["target"] | "";
|
const char *target = o["target"] | "";
|
||||||
if (e.type == ElementType::Countdown && !parseTarget(target, e.target))
|
if (e.type == ElementType::Countdown && !parseTarget(target, e.target))
|
||||||
return "Countdown: enter a date and time";
|
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)) ||
|
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["suffix"], e.suffix, sizeof(e.suffix)) || !copyText(o["entity"], e.entity, sizeof(e.entity)) ||
|
||||||
!copyText(o["attribute"], e.attribute, sizeof(e.attribute)))
|
!copyText(o["attribute"], e.attribute, sizeof(e.attribute)))
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
// decimals (-1 = as received), text, suffix, entity, attribute, factor,
|
// decimals (-1 = as received), text, suffix, entity, attribute, factor,
|
||||||
// scroll_width, scroll_speed, target ("YYYY-MM-DDTHH:MM"),
|
// scroll_width, scroll_speed, target ("YYYY-MM-DDTHH:MM"),
|
||||||
// show: "number"|"duration"|"time"|"map", input_unit: "s"|"min"|"h",
|
// show: "number"|"duration"|"time"|"map", input_unit: "s"|"min"|"h",
|
||||||
// auto_unit, absolute, map}]}
|
// auto_unit, absolute, map, bar_width, bar_height, bar_max, bar_frame,
|
||||||
// type also "countdown", "stopwatch", "image". Texts use the UTF-8 degree sign.
|
// 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);
|
void pageToJson(const DynamicPage &page, JsonObject out);
|
||||||
// Returns nullptr or a message for the user. Validates the page (validatePage()).
|
// 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>
|
<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('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('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>
|
<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>
|
<div class="card"><h2>Fonts</h2><div class="form" id="fontlist"></div>
|
||||||
<h3>Add a font</h3>
|
<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>
|
<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>
|
<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>
|
<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>
|
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
|
||||||
<canvas id="editor" width="840" height="190"></canvas>
|
<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}
|
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)
|
// 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));
|
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}
|
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+')'}
|
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')]);
|
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:'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')));
|
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)}
|
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));
|
const nused=bitmapList.filter(b=>used.has(b.name.toLowerCase())).length;['pages_used','offpages_used','bitmaps_used'].forEach(id=>subsection(id,nused));
|
||||||
schedBuild();
|
schedBuild();
|
||||||
const fl=$('fontlist');fl.innerHTML='';
|
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 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){}}
|
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
|
// 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 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 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']];
|
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;
|
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 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:'',
|
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 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 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','')}
|
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()};
|
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)'));
|
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.'}))}
|
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 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 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']]));
|
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',
|
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)')}
|
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=='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);
|
else row(...pos,ovl);
|
||||||
box.append(...rows);d.append(box)})}
|
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 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 pePage(){const p=JSON.parse(JSON.stringify(pe.page));p.slots.sort(bySlot);return p}
|
||||||
function pePreview(){clearTimeout(peTimer);peTimer=setTimeout(peRender,120)}
|
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}}}
|
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}
|
return b}
|
||||||
function convert(){if(!srcImg)return;ed.set(imgBits(srcImg,W,H,+$('thr').value,$('inv').checked,$('dith').checked,$('fit').value));changed()}
|
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
|
// icon editor: its own size, drawn like the bitmap editor
|
||||||
let ic=null,icPaint=null,icImg=null;const icc=$('ic_canvas');
|
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'})}
|
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'})}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
#include "logic/builtin_pages.h"
|
#include "logic/builtin_pages.h"
|
||||||
#include "logic/font.h"
|
#include "logic/font.h"
|
||||||
#include "logic/icon_format.h"
|
#include "logic/icon_format.h"
|
||||||
|
#include "logic/life.h"
|
||||||
#include "logic/boot_policy.h"
|
#include "logic/boot_policy.h"
|
||||||
#include "logic/display_controller.h"
|
#include "logic/display_controller.h"
|
||||||
#include "logic/page.h"
|
#include "logic/page.h"
|
||||||
@@ -1325,6 +1326,22 @@ void test_render_unknown_font_uses_normal()
|
|||||||
TEST_ASSERT_EQUAL(29, box.width);
|
TEST_ASSERT_EQUAL(29, box.width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void test_builtin_life_page()
|
||||||
|
{
|
||||||
|
DynamicPage page;
|
||||||
|
TEST_ASSERT_TRUE(builtinPage("game of life", page));
|
||||||
|
TEST_ASSERT_NULL(validatePage(page));
|
||||||
|
TEST_ASSERT_EQUAL_STRING(BUILTIN_PAGE_LIFE, builtinPageName(1));
|
||||||
|
TEST_ASSERT_EQUAL_STRING(BUILTIN_PAGE_LIFE, builtinPageName("GAME OF LIFE"));
|
||||||
|
TEST_ASSERT_EQUAL(2, builtinPageCount());
|
||||||
|
const PageElement &e = page.elements[0];
|
||||||
|
TEST_ASSERT_EQUAL(ElementType::Life, e.type);
|
||||||
|
TEST_ASSERT_EQUAL(BITMAP_WIDTH, e.bar_width);
|
||||||
|
TEST_ASSERT_EQUAL(BITMAP_HEIGHT, e.bar_height);
|
||||||
|
TEST_ASSERT_EQUAL(LIFE_START_SHUFFLE, e.input_unit);
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "life_glider")); // patterns can be deleted
|
||||||
|
}
|
||||||
|
|
||||||
void test_builtin_clock_page_is_the_old_clock()
|
void test_builtin_clock_page_is_the_old_clock()
|
||||||
{
|
{
|
||||||
DynamicPage page;
|
DynamicPage page;
|
||||||
@@ -2661,6 +2678,466 @@ void test_plex_user_placeholder()
|
|||||||
TEST_ASSERT_EQUAL_STRING("ab_c", small);
|
TEST_ASSERT_EQUAL_STRING("ab_c", small);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Game of Life (logic/life)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void lifeGlider(LifeBoard &board, int x, int y)
|
||||||
|
{
|
||||||
|
// Flies down and to the right, one dot every 4 generations
|
||||||
|
board.setCell(x + 1, y, true);
|
||||||
|
board.setCell(x + 2, y + 1, true);
|
||||||
|
board.setCell(x, y + 2, true);
|
||||||
|
board.setCell(x + 1, y + 2, true);
|
||||||
|
board.setCell(x + 2, y + 2, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_blinker_repeats()
|
||||||
|
{
|
||||||
|
LifeBoard board;
|
||||||
|
board.begin(5, 5, LIFE_EDGES_DEAD);
|
||||||
|
for (int x = 1; x <= 3; x++)
|
||||||
|
board.setCell(x, 2, true);
|
||||||
|
board.step();
|
||||||
|
TEST_ASSERT_TRUE(board.cell(2, 1));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(2, 2));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(2, 3));
|
||||||
|
TEST_ASSERT_FALSE(board.cell(1, 2));
|
||||||
|
TEST_ASSERT_EQUAL(3, board.population());
|
||||||
|
TEST_ASSERT_FALSE(board.repeating());
|
||||||
|
board.step();
|
||||||
|
TEST_ASSERT_TRUE(board.cell(1, 2));
|
||||||
|
TEST_ASSERT_TRUE(board.repeating()); // the same as generation 0
|
||||||
|
TEST_ASSERT_EQUAL(2, board.generation());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_still_and_empty_repeat()
|
||||||
|
{
|
||||||
|
LifeBoard board;
|
||||||
|
board.begin(6, 6, LIFE_EDGES_DEAD);
|
||||||
|
board.setCell(2, 2, true); // block
|
||||||
|
board.setCell(3, 2, true);
|
||||||
|
board.setCell(2, 3, true);
|
||||||
|
board.setCell(3, 3, true);
|
||||||
|
board.step();
|
||||||
|
TEST_ASSERT_TRUE(board.repeating());
|
||||||
|
TEST_ASSERT_EQUAL(4, board.population());
|
||||||
|
|
||||||
|
board.clear();
|
||||||
|
board.setCell(0, 0, true); // dies at once
|
||||||
|
board.step();
|
||||||
|
TEST_ASSERT_EQUAL(0, board.population());
|
||||||
|
TEST_ASSERT_FALSE(board.repeating());
|
||||||
|
board.step();
|
||||||
|
TEST_ASSERT_TRUE(board.repeating());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_edges_wrap_or_dead()
|
||||||
|
{
|
||||||
|
// A blinker on the top row: its vertical phase reaches above the area
|
||||||
|
LifeBoard wrap;
|
||||||
|
wrap.begin(5, 5, LIFE_EDGES_WRAP);
|
||||||
|
LifeBoard dead;
|
||||||
|
dead.begin(5, 5, LIFE_EDGES_DEAD);
|
||||||
|
for (int x = 1; x <= 3; x++)
|
||||||
|
{
|
||||||
|
wrap.setCell(x, 0, true);
|
||||||
|
dead.setCell(x, 0, true);
|
||||||
|
}
|
||||||
|
wrap.step();
|
||||||
|
dead.step();
|
||||||
|
TEST_ASSERT_TRUE(wrap.cell(2, 4)); // came round at the bottom
|
||||||
|
TEST_ASSERT_EQUAL(3, wrap.population());
|
||||||
|
TEST_ASSERT_FALSE(dead.cell(2, 4));
|
||||||
|
TEST_ASSERT_EQUAL(2, dead.population());
|
||||||
|
TEST_ASSERT_FALSE(dead.cell(-1, 0)); // outside the area
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_glider_wraps_round_the_screen()
|
||||||
|
{
|
||||||
|
LifeBoard board;
|
||||||
|
board.begin(BITMAP_WIDTH, BITMAP_HEIGHT, LIFE_EDGES_WRAP);
|
||||||
|
lifeGlider(board, 10, 5);
|
||||||
|
// Back at its start after 4 x 84 x 19 generations (84 and 19 have no common divisor)
|
||||||
|
for (int i = 0; i < 4 * BITMAP_WIDTH * BITMAP_HEIGHT; i++)
|
||||||
|
{
|
||||||
|
board.step();
|
||||||
|
TEST_ASSERT_EQUAL(5, board.population());
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(board.cell(11, 5));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(12, 6));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(10, 7));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(11, 7));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(12, 7));
|
||||||
|
TEST_ASSERT_FALSE(board.repeating()); // the period is longer than LIFE_MAX_PERIOD
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_hidden_margin_lets_gliders_go()
|
||||||
|
{
|
||||||
|
LifeBoard board;
|
||||||
|
board.begin(20, 10, LIFE_EDGES_MARGIN);
|
||||||
|
lifeGlider(board, 2, 2);
|
||||||
|
bool left_the_area = false;
|
||||||
|
for (int i = 0; i < 400; i++)
|
||||||
|
{
|
||||||
|
board.step();
|
||||||
|
bool visible = false;
|
||||||
|
for (int y = 0; y < 10; y++)
|
||||||
|
for (int x = 0; x < 20; x++)
|
||||||
|
visible = visible || board.cell(x, y);
|
||||||
|
if (!visible)
|
||||||
|
left_the_area = true;
|
||||||
|
else
|
||||||
|
TEST_ASSERT_FALSE(left_the_area); // never comes back
|
||||||
|
}
|
||||||
|
TEST_ASSERT_TRUE(left_the_area);
|
||||||
|
TEST_ASSERT_EQUAL(0, board.population()); // gone, not a block at the edge
|
||||||
|
TEST_ASSERT_TRUE(board.repeating());
|
||||||
|
|
||||||
|
// Dead edges: it gets stuck in the corner instead
|
||||||
|
LifeBoard dead;
|
||||||
|
dead.begin(20, 10, LIFE_EDGES_DEAD);
|
||||||
|
lifeGlider(dead, 2, 2);
|
||||||
|
for (int i = 0; i < 400; i++)
|
||||||
|
dead.step();
|
||||||
|
TEST_ASSERT_TRUE(dead.population() > 0);
|
||||||
|
TEST_ASSERT_TRUE(dead.repeating());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_glider_gun_runs_clean_with_a_hidden_margin()
|
||||||
|
{
|
||||||
|
static const char *const gun[] = {
|
||||||
|
"........................O...........",
|
||||||
|
"......................O.O...........",
|
||||||
|
"............OO......OO............OO",
|
||||||
|
"...........O...O....OO............OO",
|
||||||
|
"OO........O.....O...OO..............",
|
||||||
|
"OO........O...O.OO....O.O...........",
|
||||||
|
"..........O.....O.......O...........",
|
||||||
|
"...........O...O....................",
|
||||||
|
"............OO......................",
|
||||||
|
};
|
||||||
|
uint8_t pattern[BITMAP_BYTES] = {};
|
||||||
|
for (int y = 0; y < 9; y++)
|
||||||
|
for (int x = 0; gun[y][x]; x++)
|
||||||
|
setBitmapPixel(pattern, x, y, gun[y][x] == 'O');
|
||||||
|
uint32_t random = 1;
|
||||||
|
LifeBoard board;
|
||||||
|
board.begin(BITMAP_WIDTH, BITMAP_HEIGHT, LIFE_EDGES_MARGIN);
|
||||||
|
TEST_ASSERT_TRUE(board.place(pattern, false, random));
|
||||||
|
for (int i = 0; i < 1500; i++)
|
||||||
|
{
|
||||||
|
board.step();
|
||||||
|
TEST_ASSERT_TRUE(board.population() < 200); // the gliders don't pile up
|
||||||
|
}
|
||||||
|
bool before[BITMAP_HEIGHT][BITMAP_WIDTH];
|
||||||
|
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
||||||
|
for (int x = 0; x < BITMAP_WIDTH; x++)
|
||||||
|
before[y][x] = board.cell(x, y);
|
||||||
|
for (int i = 0; i < 30; i++)
|
||||||
|
board.step();
|
||||||
|
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
||||||
|
for (int x = 0; x < BITMAP_WIDTH; x++)
|
||||||
|
TEST_ASSERT_EQUAL(before[y][x], board.cell(x, y)); // still the same gun
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_random_and_place()
|
||||||
|
{
|
||||||
|
uint32_t random = 12345;
|
||||||
|
LifeBoard board;
|
||||||
|
board.begin(BITMAP_WIDTH, BITMAP_HEIGHT, LIFE_EDGES_WRAP);
|
||||||
|
board.randomize(0, random);
|
||||||
|
TEST_ASSERT_EQUAL(0, board.population());
|
||||||
|
board.randomize(100, random);
|
||||||
|
TEST_ASSERT_EQUAL(BITMAP_WIDTH * BITMAP_HEIGHT, board.population());
|
||||||
|
board.randomize(30, random);
|
||||||
|
int population = board.population();
|
||||||
|
TEST_ASSERT_TRUE(population > 350 && population < 600); // about 479
|
||||||
|
|
||||||
|
// A 3 x 1 pattern anywhere in the bitmap, placed in the middle of an 11 x 5 area
|
||||||
|
uint8_t pattern[BITMAP_BYTES] = {};
|
||||||
|
for (int x = 5; x <= 7; x++)
|
||||||
|
setBitmapPixel(pattern, x, 9, true);
|
||||||
|
board.begin(11, 5, LIFE_EDGES_MARGIN);
|
||||||
|
TEST_ASSERT_TRUE(board.place(pattern, false, random));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(4, 2));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(5, 2));
|
||||||
|
TEST_ASSERT_TRUE(board.cell(6, 2));
|
||||||
|
TEST_ASSERT_EQUAL(3, board.population());
|
||||||
|
for (int i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
TEST_ASSERT_TRUE(board.place(pattern, true, random));
|
||||||
|
TEST_ASSERT_EQUAL(3, board.population()); // always fully inside the area
|
||||||
|
}
|
||||||
|
// Too big: the middle part is kept
|
||||||
|
board.begin(2, 2, LIFE_EDGES_DEAD);
|
||||||
|
TEST_ASSERT_TRUE(board.place(pattern, true, random));
|
||||||
|
TEST_ASSERT_EQUAL(2, board.population());
|
||||||
|
uint8_t empty[BITMAP_BYTES] = {};
|
||||||
|
TEST_ASSERT_FALSE(board.place(empty, false, random));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_runner_holds_then_restarts()
|
||||||
|
{
|
||||||
|
LifeRunner runner;
|
||||||
|
LifeTiming timing;
|
||||||
|
timing.speed = 10; // every 100 ms
|
||||||
|
timing.hold_ms = 1000;
|
||||||
|
runner.setTiming(timing);
|
||||||
|
runner.board.begin(5, 5, LIFE_EDGES_DEAD);
|
||||||
|
for (int x = 1; x <= 3; x++)
|
||||||
|
runner.board.setCell(x, 2, true);
|
||||||
|
runner.started(0);
|
||||||
|
TEST_ASSERT_EQUAL(100, runner.msUntilNext(0));
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Wait, runner.tick(50));
|
||||||
|
TEST_ASSERT_EQUAL(50, runner.msUntilNext(50));
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(100));
|
||||||
|
TEST_ASSERT_FALSE(runner.ending());
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(200)); // back to generation 0
|
||||||
|
TEST_ASSERT_TRUE(runner.ending());
|
||||||
|
// It keeps blinking during the hold time
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(300));
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(1100));
|
||||||
|
TEST_ASSERT_EQUAL(50, runner.msUntilNext(1150));
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Restart, runner.tick(1200));
|
||||||
|
runner.started(1200);
|
||||||
|
TEST_ASSERT_FALSE(runner.ending());
|
||||||
|
|
||||||
|
// Not on repeating, but after 3 generations, then at once
|
||||||
|
timing.restart_when_repeating = false;
|
||||||
|
timing.max_generations = 3;
|
||||||
|
timing.hold_ms = 0;
|
||||||
|
runner.setTiming(timing);
|
||||||
|
runner.board.clear();
|
||||||
|
runner.started(0);
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(100));
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(200));
|
||||||
|
TEST_ASSERT_FALSE(runner.ending()); // empty and repeating, but that is off
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(300));
|
||||||
|
TEST_ASSERT_TRUE(runner.ending());
|
||||||
|
TEST_ASSERT_EQUAL(1, runner.msUntilNext(300));
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Restart, runner.tick(300));
|
||||||
|
|
||||||
|
// Empty: no hold time
|
||||||
|
timing.restart_when_repeating = true;
|
||||||
|
timing.max_generations = 0;
|
||||||
|
timing.hold_ms = 5000;
|
||||||
|
runner.setTiming(timing);
|
||||||
|
runner.board.clear();
|
||||||
|
runner.board.setCell(2, 2, true); // dies at once
|
||||||
|
runner.started(0);
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(100));
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(200)); // empty twice: repeating
|
||||||
|
TEST_ASSERT_TRUE(runner.ending());
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Restart, runner.tick(201));
|
||||||
|
|
||||||
|
// Never ends
|
||||||
|
timing.restart_when_repeating = false;
|
||||||
|
timing.max_generations = 0;
|
||||||
|
timing.hold_ms = 0;
|
||||||
|
runner.setTiming(timing);
|
||||||
|
runner.started(0);
|
||||||
|
for (uint32_t t = 100; t < 10000; t += 100)
|
||||||
|
TEST_ASSERT_EQUAL(LifeTick::Stepped, runner.tick(t));
|
||||||
|
TEST_ASSERT_FALSE(runner.ending());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_next_pattern()
|
||||||
|
{
|
||||||
|
uint32_t random = 1;
|
||||||
|
TEST_ASSERT_EQUAL(-1, lifeNextPattern(LIFE_START_ROTATE, 0, -1, random));
|
||||||
|
TEST_ASSERT_EQUAL(0, lifeNextPattern(LIFE_START_ROTATE, 3, -1, random));
|
||||||
|
TEST_ASSERT_EQUAL(1, lifeNextPattern(LIFE_START_ROTATE, 3, 0, random));
|
||||||
|
TEST_ASSERT_EQUAL(0, lifeNextPattern(LIFE_START_ROTATE, 3, 2, random));
|
||||||
|
TEST_ASSERT_EQUAL(0, lifeNextPattern(LIFE_START_ROTATE, 3, 7, random)); // list got shorter
|
||||||
|
TEST_ASSERT_EQUAL(0, lifeNextPattern(LIFE_START_SHUFFLE, 1, 0, random));
|
||||||
|
int seen[4] = {};
|
||||||
|
int current = 2;
|
||||||
|
for (int i = 0; i < 200; i++)
|
||||||
|
{
|
||||||
|
int next = lifeNextPattern(LIFE_START_SHUFFLE, 4, current, random);
|
||||||
|
TEST_ASSERT_TRUE(next >= 0 && next < 4);
|
||||||
|
TEST_ASSERT_NOT_EQUAL(current, next); // never the same one twice
|
||||||
|
seen[next]++;
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 4; i++)
|
||||||
|
TEST_ASSERT_TRUE(seen[i] > 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DynamicPage lifePage()
|
||||||
|
{
|
||||||
|
DynamicPage page;
|
||||||
|
page.element_count = 1;
|
||||||
|
PageElement &e = page.elements[0];
|
||||||
|
e.type = ElementType::Life;
|
||||||
|
e.x = 10;
|
||||||
|
e.y = 2;
|
||||||
|
e.bar_width = 20;
|
||||||
|
e.bar_height = 10;
|
||||||
|
e.format = LIFE_EDGES_MARGIN;
|
||||||
|
e.input_unit = LIFE_START_PATTERN;
|
||||||
|
strcpy(e.text, "Day"); // one dot at 0, 0
|
||||||
|
e.factor = 30;
|
||||||
|
e.scroll_speed = 4;
|
||||||
|
e.auto_unit = true;
|
||||||
|
e.bar_max = 5;
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LifeResources : public FakeResources
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
const LifeRunner *runner = nullptr;
|
||||||
|
const LifeRunner *life(const PageElement &) override { return runner; }
|
||||||
|
};
|
||||||
|
|
||||||
|
void test_life_element_validation()
|
||||||
|
{
|
||||||
|
TEST_ASSERT_NULL(validatePage(lifePage()));
|
||||||
|
DynamicPage page = lifePage();
|
||||||
|
page.elements[1] = page.elements[0];
|
||||||
|
page.element_count = 2;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // one per page
|
||||||
|
|
||||||
|
page = lifePage();
|
||||||
|
page.elements[0].bar_width = 0;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page = lifePage();
|
||||||
|
page.elements[0].bar_height = 20;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page = lifePage();
|
||||||
|
page.elements[0].format = LIFE_EDGES_COUNT;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page = lifePage();
|
||||||
|
page.elements[0].input_unit = LIFE_START_COUNT;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page = lifePage();
|
||||||
|
page.elements[0].text[0] = '\0';
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page)); // a pattern is needed
|
||||||
|
page.elements[0].input_unit = LIFE_START_RANDOM;
|
||||||
|
TEST_ASSERT_NULL(validatePage(page));
|
||||||
|
page.elements[0].factor = 0;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page.elements[0].factor = 101;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page = lifePage();
|
||||||
|
page.elements[0].input_unit = LIFE_START_SHUFFLE; // shuffle needs input_unit 3
|
||||||
|
strcpy(page.elements[0].text, "life_");
|
||||||
|
TEST_ASSERT_NULL(validatePage(page));
|
||||||
|
page.elements[0].text[0] = '\0'; // all patterns
|
||||||
|
TEST_ASSERT_NULL(validatePage(page));
|
||||||
|
strcpy(page.elements[0].text, "life/");
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page = lifePage();
|
||||||
|
page.elements[0].scroll_speed = 0;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page.elements[0].scroll_speed = 21;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page = lifePage();
|
||||||
|
page.elements[0].bar_max = -1;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
page.elements[0].bar_max = 0; // restart at once
|
||||||
|
TEST_ASSERT_NULL(validatePage(page));
|
||||||
|
page.elements[0].target = LIFE_GENERATIONS_MAX + 1;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
|
||||||
|
// Other elements still can't use input_unit 3
|
||||||
|
page = barPage();
|
||||||
|
page.elements[0].input_unit = 3;
|
||||||
|
TEST_ASSERT_NOT_NULL(validatePage(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_element_render()
|
||||||
|
{
|
||||||
|
LifeResources res;
|
||||||
|
DynamicPage page = lifePage();
|
||||||
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
ElementBox boxes[PAGE_ELEMENTS];
|
||||||
|
|
||||||
|
// Not the shown page: the pattern in the middle of the area, nothing moves
|
||||||
|
TEST_ASSERT_EQUAL(0, renderPage(page, at(12, 0), 0, res, bitmap, boxes));
|
||||||
|
TEST_ASSERT_EQUAL(1, countOn(bitmap));
|
||||||
|
TEST_ASSERT_TRUE(bitmapPixel(bitmap, 10 + 9, 2 + 4));
|
||||||
|
TEST_ASSERT_EQUAL(10, boxes[0].x);
|
||||||
|
TEST_ASSERT_EQUAL(2, boxes[0].y);
|
||||||
|
TEST_ASSERT_EQUAL(20, boxes[0].width);
|
||||||
|
TEST_ASSERT_EQUAL(10, boxes[0].height);
|
||||||
|
// Random: the same fill every time
|
||||||
|
page.elements[0].input_unit = LIFE_START_RANDOM;
|
||||||
|
renderPage(page, at(12, 0), 0, res, bitmap);
|
||||||
|
int dots = countOn(bitmap);
|
||||||
|
TEST_ASSERT_TRUE(dots > 30 && dots < 90); // about 60 of 200
|
||||||
|
uint8_t again[BITMAP_BYTES];
|
||||||
|
renderPage(page, at(12, 0), 0, res, again);
|
||||||
|
TEST_ASSERT_EQUAL_MEMORY(bitmap, again, BITMAP_BYTES);
|
||||||
|
|
||||||
|
// The shown page: the running board
|
||||||
|
static LifeRunner runner;
|
||||||
|
LifeTiming timing;
|
||||||
|
timing.speed = 4;
|
||||||
|
runner.setTiming(timing);
|
||||||
|
runner.board.begin(20, 10, LIFE_EDGES_MARGIN);
|
||||||
|
runner.board.setCell(0, 0, true);
|
||||||
|
runner.board.setCell(19, 9, true);
|
||||||
|
runner.started(0);
|
||||||
|
res.runner = &runner;
|
||||||
|
TEST_ASSERT_EQUAL(150, renderPage(page, at(12, 0), 100, res, bitmap));
|
||||||
|
TEST_ASSERT_EQUAL(2, countOn(bitmap));
|
||||||
|
TEST_ASSERT_TRUE(bitmapPixel(bitmap, 10, 2));
|
||||||
|
TEST_ASSERT_TRUE(bitmapPixel(bitmap, 29, 11));
|
||||||
|
|
||||||
|
// Centred on x, flipping a full background
|
||||||
|
page.elements[0].align = Align::Center;
|
||||||
|
page.elements[0].x = 42;
|
||||||
|
page.elements[0].overlay = Overlay::Invert;
|
||||||
|
page.slot_count = 1;
|
||||||
|
strcpy(page.slots[0].bitmap, "Full");
|
||||||
|
renderPage(page, at(12, 0), 100, res, bitmap);
|
||||||
|
TEST_ASSERT_EQUAL(BITMAP_WIDTH * BITMAP_HEIGHT - 2, countOn(bitmap));
|
||||||
|
TEST_ASSERT_FALSE(bitmapPixel(bitmap, 32, 2));
|
||||||
|
TEST_ASSERT_FALSE(bitmapPixel(bitmap, 51, 11));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_pattern_is_in_use()
|
||||||
|
{
|
||||||
|
DynamicPage page = lifePage();
|
||||||
|
TEST_ASSERT_TRUE(pageUsesImage(page, "day"));
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "Evening"));
|
||||||
|
page.elements[0].input_unit = LIFE_START_ROTATE; // can be deleted from a rotation
|
||||||
|
TEST_ASSERT_FALSE(pageUsesImage(page, "Day"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_life_same_start()
|
||||||
|
{
|
||||||
|
PageElement a = lifePage().elements[0];
|
||||||
|
PageElement b = a;
|
||||||
|
TEST_ASSERT_TRUE(sameLifeStart(a, b));
|
||||||
|
b.scroll_speed = 10; // timing and position don't matter
|
||||||
|
b.bar_max = 0;
|
||||||
|
b.target = 100;
|
||||||
|
b.auto_unit = false;
|
||||||
|
b.x = 0;
|
||||||
|
b.overlay = Overlay::Invert;
|
||||||
|
strcpy(b.text, "DAY");
|
||||||
|
TEST_ASSERT_TRUE(sameLifeStart(a, b));
|
||||||
|
b = a;
|
||||||
|
b.bar_width = 21;
|
||||||
|
TEST_ASSERT_FALSE(sameLifeStart(a, b));
|
||||||
|
b = a;
|
||||||
|
b.format = LIFE_EDGES_WRAP;
|
||||||
|
TEST_ASSERT_FALSE(sameLifeStart(a, b));
|
||||||
|
b = a;
|
||||||
|
strcpy(b.text, "Evening");
|
||||||
|
TEST_ASSERT_FALSE(sameLifeStart(a, b));
|
||||||
|
b = a;
|
||||||
|
b.absolute = true;
|
||||||
|
TEST_ASSERT_FALSE(sameLifeStart(a, b));
|
||||||
|
b = a;
|
||||||
|
b.type = ElementType::Bar;
|
||||||
|
TEST_ASSERT_FALSE(sameLifeStart(a, b));
|
||||||
|
}
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
@@ -2766,6 +3243,7 @@ int main()
|
|||||||
RUN_TEST(test_render_alignment_and_boxes);
|
RUN_TEST(test_render_alignment_and_boxes);
|
||||||
RUN_TEST(test_render_unknown_font_uses_normal);
|
RUN_TEST(test_render_unknown_font_uses_normal);
|
||||||
RUN_TEST(test_builtin_clock_page_is_the_old_clock);
|
RUN_TEST(test_builtin_clock_page_is_the_old_clock);
|
||||||
|
RUN_TEST(test_builtin_life_page);
|
||||||
RUN_TEST(test_default_page_is_the_builtin_clock);
|
RUN_TEST(test_default_page_is_the_builtin_clock);
|
||||||
RUN_TEST(test_clock_with_seconds);
|
RUN_TEST(test_clock_with_seconds);
|
||||||
RUN_TEST(test_countdown_target_parse_and_format);
|
RUN_TEST(test_countdown_target_parse_and_format);
|
||||||
@@ -2813,6 +3291,19 @@ int main()
|
|||||||
RUN_TEST(test_attribute_paths_and_16_elements);
|
RUN_TEST(test_attribute_paths_and_16_elements);
|
||||||
RUN_TEST(test_plex_user_placeholder);
|
RUN_TEST(test_plex_user_placeholder);
|
||||||
RUN_TEST(test_slow_scrolling_below_one_dot_per_second);
|
RUN_TEST(test_slow_scrolling_below_one_dot_per_second);
|
||||||
|
RUN_TEST(test_life_blinker_repeats);
|
||||||
|
RUN_TEST(test_life_still_and_empty_repeat);
|
||||||
|
RUN_TEST(test_life_edges_wrap_or_dead);
|
||||||
|
RUN_TEST(test_life_glider_wraps_round_the_screen);
|
||||||
|
RUN_TEST(test_life_hidden_margin_lets_gliders_go);
|
||||||
|
RUN_TEST(test_life_glider_gun_runs_clean_with_a_hidden_margin);
|
||||||
|
RUN_TEST(test_life_random_and_place);
|
||||||
|
RUN_TEST(test_life_runner_holds_then_restarts);
|
||||||
|
RUN_TEST(test_life_next_pattern);
|
||||||
|
RUN_TEST(test_life_element_validation);
|
||||||
|
RUN_TEST(test_life_element_render);
|
||||||
|
RUN_TEST(test_life_pattern_is_in_use);
|
||||||
|
RUN_TEST(test_life_same_start);
|
||||||
|
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user