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:
mangavalkandClaude Opus 5.5 committed 2026-10-20 17:52:25 +02:00
1 parent 948087077d
commit a91d2550d7
29 files changed
+1402 -31

No files matched your search

+491
View File
@@ -19,6 +19,7 @@
#include "logic/builtin_pages.h"
#include "logic/font.h"
#include "logic/icon_format.h"
#include "logic/life.h"
#include "logic/boot_policy.h"
#include "logic/display_controller.h"
#include "logic/page.h"
@@ -1325,6 +1326,22 @@ void test_render_unknown_font_uses_normal()
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()
{
DynamicPage page;
@@ -2661,6 +2678,466 @@ void test_plex_user_placeholder()
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()
{
UNITY_BEGIN();
@@ -2766,6 +3243,7 @@ int main()
RUN_TEST(test_render_alignment_and_boxes);
RUN_TEST(test_render_unknown_font_uses_normal);
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_clock_with_seconds);
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_plex_user_placeholder);
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();
}