From cc31014fd2cfc74f4a973598a3bc5ef3f58516c0 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:20:36 +0200 Subject: [PATCH 01/18] feat: cyberdeck demo suite (cyber pulse, life grid, byte stream, sys stats) Adds four new menu entries that showcase the firefly-scene engine and turn the Pixie into a cyberpunk-styled cyberdeck: * Cyber Pulse - 8 neon equalizer bars driven by a triangle-wave oscillator with per-column phase/period offsets, plus a translucent CRT-style scan line. * Life Grid - 16x16 Conway's Game of Life with toroidal topology. Four seeds (glider, pulsar, R-pentomino, random), N/S to switch seed, OK to pause/step, Cancel to exit. * Byte Stream - Matrix-style falling-character stream, 12 columns with random period/phase, opacity-faded trails, and a single glyph riding each head. * Sys Stats - Live system telemetry (uptime, free heap, render FPS, chip rev, IDF version, build hash) plus a 60-sample free-heap timeline. Uses ffx_sceneLabel_setTextFormat for dynamic value updates. Menu refactor: * panel-menu now supports a scrolling viewport (3 visible / 7 items), with the previously-disabled GIFs entry re-enabled. * Top/bottom neon rules added for cyberpunk styling consistent with the new panels. Other: * main.c: pass firmware version to ffx_init (required by the current pinned hollows submodule API; main was previously out-of-sync with components/firefly-hollows). * panels.h: add declarations for the new panels and fix pushPanelGifs return type (int, matching its definition). All new code uses only the documented firefly-scene / firefly-hollows public API. No new image assets - everything is composed from boxes, labels and the existing arrow sprite. --- main/CMakeLists.txt | 4 + main/main.c | 4 +- main/panel-bytes.c | 149 ++++++++++++++++++++++++++ main/panel-cyber.c | 154 +++++++++++++++++++++++++++ main/panel-life.c | 251 ++++++++++++++++++++++++++++++++++++++++++++ main/panel-menu.c | 146 ++++++++++++++++++-------- main/panel-stats.c | 184 ++++++++++++++++++++++++++++++++ main/panels.h | 22 +++- 8 files changed, 866 insertions(+), 48 deletions(-) create mode 100644 main/panel-bytes.c create mode 100644 main/panel-cyber.c create mode 100644 main/panel-life.c create mode 100644 main/panel-stats.c diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f7a69a0..ab1154a 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,10 +1,14 @@ idf_component_register( SRCS "main.c" + "panel-bytes.c" "panel-connect.c" + "panel-cyber.c" "panel-gifs.c" + "panel-life.c" "panel-menu.c" "panel-space.c" + "panel-stats.c" "panel-tx.c" "utils.c" diff --git a/main/main.c b/main/main.c index a07c39f..96f1e8e 100644 --- a/main/main.c +++ b/main/main.c @@ -23,6 +23,8 @@ #define GIT_COMMIT ("unknown") #endif +#define PIXIE_FW_VERSION FFX_VERSION(0, 2, 0) + static int initPanel(void *arg) { return pushPanelMenu(); @@ -33,7 +35,7 @@ void app_main() { FFX_LOG("GIT Commit: %s", GIT_COMMIT); - ffx_init(ffx_demo_backgroundPixies, initPanel, NULL); + ffx_init(PIXIE_FW_VERSION, ffx_demo_backgroundPixies, initPanel, NULL); while (1) { ffx_dumpStats(); diff --git a/main/panel-bytes.c b/main/panel-bytes.c new file mode 100644 index 0000000..56c0960 --- /dev/null +++ b/main/panel-bytes.c @@ -0,0 +1,149 @@ +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define COL_COUNT (12) +#define TRAIL_LEN (10) +#define DOT_SIZE (10) +#define COL_STRIDE (20) +#define COL_OFFSET ((240 - (COL_COUNT * COL_STRIDE)) / 2) +#define DOT_STRIDE (16) + +#define TOTAL_RANGE (240 + TRAIL_LEN * DOT_STRIDE) + + +typedef struct ByteCol { + uint16_t period; + uint16_t phase; +} ByteCol; + +typedef struct BytesState { + FfxScene scene; + FfxNode dots[COL_COUNT * TRAIL_LEN]; + FfxNode glyphs[COL_COUNT]; + ByteCol cols[COL_COUNT]; + uint32_t rngState; +} BytesState; + + +static uint32_t rngNext(uint32_t *s) { + uint32_t x = *s; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *s = x ? x : 0xC0FFEE42; + return *s; +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + BytesState *state = _state; + uint32_t t = ticks(); + + for (int c = 0; c < COL_COUNT; c++) { + ByteCol *col = &state->cols[c]; + int32_t headY = (int32_t)(((t + col->phase) / col->period) % TOTAL_RANGE); + int32_t x = COL_OFFSET + c * COL_STRIDE; + + for (int i = 0; i < TRAIL_LEN; i++) { + int32_t y = headY - i * DOT_STRIDE; + FfxNode dot = state->dots[c * TRAIL_LEN + i]; + if (y < -DOT_SIZE || y > 240) { + ffx_sceneNode_setHidden(dot, true); + continue; + } + ffx_sceneNode_setHidden(dot, false); + ffx_sceneNode_setPosition(dot, ffx_point(x, y)); + } + + FfxNode glyph = state->glyphs[c]; + int32_t gy = headY - 5; + if (gy < 0 || gy > 240) { + ffx_sceneNode_setHidden(glyph, true); + } else { + ffx_sceneNode_setHidden(glyph, false); + ffx_sceneNode_setPosition(glyph, ffx_point(x + DOT_SIZE / 2, gy)); + } + } +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + BytesState *state = _state; + state->scene = scene; + state->rngState = 0x13371337 ^ ticks(); + + FfxNode bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(bg, COLOR_BLACK); + ffx_sceneGroup_appendChild(panel, bg); + ffx_sceneNode_setPosition(bg, ffx_point(0, 0)); + + static const char *glyphChars[8] = { "0", "1", "X", "F", "A", "Z", "7", "$" }; + + for (int c = 0; c < COL_COUNT; c++) { + ByteCol *col = &state->cols[c]; + col->period = 6 + (rngNext(&state->rngState) % 12); + col->phase = rngNext(&state->rngState) % 4096; + + for (int i = 0; i < TRAIL_LEN; i++) { + FfxNode dot = ffx_scene_createBox(scene, + ffx_size(DOT_SIZE, DOT_SIZE)); + uint8_t opacity; + color_ffxt color; + if (i == 0) { + color = ffx_color_rgb(220, 255, 220); + opacity = MAX_OPACITY; + } else { + color = ffx_color_rgb(0, 255, 65); + int fade = MAX_OPACITY - (i * MAX_OPACITY / TRAIL_LEN); + if (fade < 2) { fade = 2; } + opacity = (uint8_t)fade; + } + ffx_sceneBox_setColor(dot, color); + ffx_sceneBox_setOpacity(dot, opacity); + ffx_sceneGroup_appendChild(panel, dot); + ffx_sceneNode_setPosition(dot, ffx_point(0, -32)); + state->dots[c * TRAIL_LEN + i] = dot; + } + + const char *glyph = glyphChars[rngNext(&state->rngState) & 7]; + FfxNode g = ffx_scene_createLabel(scene, FfxFontMedium, glyph); + ffx_sceneGroup_appendChild(panel, g); + ffx_sceneNode_setPosition(g, ffx_point(0, -32)); + ffx_sceneLabel_setAlign(g, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(g, COLOR_BLACK); + state->glyphs[c] = g; + } + + FfxNode title = ffx_scene_createLabel(scene, FfxFontLargeBold, + "BYTE//STREAM"); + ffx_sceneGroup_appendChild(panel, title); + ffx_sceneNode_setPosition(title, ffx_point(120, 18)); + ffx_sceneLabel_setAlign(title, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(title, COLOR_BLACK); + + FfxNode hint = ffx_scene_createLabel(scene, FfxFontMedium, "[CANCEL] EXIT"); + ffx_sceneGroup_appendChild(panel, hint); + ffx_sceneNode_setPosition(hint, ffx_point(120, 228)); + ffx_sceneLabel_setAlign(hint, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(hint, COLOR_BLACK); + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelBytes() { + return ffx_pushPanel(initFunc, sizeof(BytesState), NULL); +} diff --git a/main/panel-cyber.c b/main/panel-cyber.c new file mode 100644 index 0000000..34d33b2 --- /dev/null +++ b/main/panel-cyber.c @@ -0,0 +1,154 @@ +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define BAR_COUNT (8) +#define BAR_WIDTH (22) +#define BAR_GAP (4) +#define BAR_AREA_LEFT (16) +#define BAR_FLOOR_Y (220) +#define BAR_HEIGHT_MAX (150) + +#define SCAN_SPEED_MS (12) + + +typedef struct CyberState { + FfxScene scene; + FfxNode bars[BAR_COUNT]; + FfxNode floor; + FfxNode scanline; + FfxNode tickerDot; +} CyberState; + + +static const uint16_t barPeriods[BAR_COUNT] = { + 1100, 870, 1450, 720, 1230, 980, 1610, 830 +}; + +static const uint16_t barPhases[BAR_COUNT] = { + 0, 200, 450, 730, 1000, 1300, 1500, 1850 +}; + + +static uint32_t triangleWave(uint32_t t, uint32_t period, uint32_t phase, + uint32_t maxVal) { + uint32_t pos = (t + phase) % period; + uint32_t half = period / 2; + if (pos < half) { return (pos * maxVal) / half; } + return maxVal - ((pos - half) * maxVal) / half; +} + +static void neonBarColor(int idx, uint8_t *r, uint8_t *g, uint8_t *b) { + static const uint8_t palette[BAR_COUNT][3] = { + { 0, 255, 65 }, // matrix green + { 0, 255, 255 }, // cyan + { 255, 0, 200 }, // hot pink + { 255, 220, 0 }, // amber + { 120, 0, 255 }, // violet + { 255, 80, 0 }, // neon orange + { 0, 140, 255 }, // electric blue + { 255, 0, 80 }, // blood red + }; + *r = palette[idx][0]; + *g = palette[idx][1]; + *b = palette[idx][2]; +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + CyberState *state = _state; + uint32_t t = ticks(); + + for (int i = 0; i < BAR_COUNT; i++) { + uint32_t h = triangleWave(t, barPeriods[i], barPhases[i], + BAR_HEIGHT_MAX); + int32_t x = BAR_AREA_LEFT + i * (BAR_WIDTH + BAR_GAP); + int32_t y = BAR_FLOOR_Y - h; + ffx_sceneNode_setPosition(state->bars[i], ffx_point(x, y)); + } + + int32_t sy = (t / SCAN_SPEED_MS) % 240; + ffx_sceneNode_setPosition(state->scanline, ffx_point(0, sy)); + + int32_t tx = (t / 20) % 240; + ffx_sceneNode_setPosition(state->tickerDot, ffx_point(tx, 218)); +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + CyberState *state = _state; + state->scene = scene; + + FfxNode bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(bg, ffx_color_rgb(8, 0, 16)); + ffx_sceneGroup_appendChild(panel, bg); + ffx_sceneNode_setPosition(bg, ffx_point(0, 0)); + + FfxNode title = ffx_scene_createLabel(scene, FfxFontLargeBold, + "CYBER//PULSE"); + ffx_sceneGroup_appendChild(panel, title); + ffx_sceneNode_setPosition(title, ffx_point(120, 22)); + ffx_sceneLabel_setAlign(title, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(title, ffx_color_rgb(0, 0, 0)); + + FfxNode subtitle = ffx_scene_createLabel(scene, FfxFontMedium, + "BPM 128 // SYNC OK"); + ffx_sceneGroup_appendChild(panel, subtitle); + ffx_sceneNode_setPosition(subtitle, ffx_point(120, 42)); + ffx_sceneLabel_setAlign(subtitle, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(subtitle, ffx_color_rgb(0, 0, 0)); + + FfxNode floor = ffx_scene_createBox(scene, ffx_size(240, 2)); + ffx_sceneBox_setColor(floor, ffx_color_rgb(0, 255, 65)); + ffx_sceneGroup_appendChild(panel, floor); + ffx_sceneNode_setPosition(floor, ffx_point(0, BAR_FLOOR_Y)); + state->floor = floor; + + for (int i = 0; i < BAR_COUNT; i++) { + FfxNode bar = ffx_scene_createBox(scene, + ffx_size(BAR_WIDTH, BAR_HEIGHT_MAX)); + uint8_t r, g, b; + neonBarColor(i, &r, &g, &b); + ffx_sceneBox_setColor(bar, ffx_color_rgb(r, g, b)); + ffx_sceneGroup_appendChild(panel, bar); + int32_t x = BAR_AREA_LEFT + i * (BAR_WIDTH + BAR_GAP); + ffx_sceneNode_setPosition(bar, ffx_point(x, BAR_FLOOR_Y)); + state->bars[i] = bar; + } + + FfxNode scanline = ffx_scene_createBox(scene, ffx_size(240, 2)); + ffx_sceneBox_setColor(scanline, ffx_color_rgb(180, 255, 220)); + ffx_sceneBox_setOpacity(scanline, 8); + ffx_sceneGroup_appendChild(panel, scanline); + state->scanline = scanline; + + FfxNode tickerDot = ffx_scene_createBox(scene, ffx_size(3, 3)); + ffx_sceneBox_setColor(tickerDot, ffx_color_rgb(0, 255, 255)); + ffx_sceneGroup_appendChild(panel, tickerDot); + state->tickerDot = tickerDot; + + FfxNode hint = ffx_scene_createLabel(scene, FfxFontMedium, "[CANCEL] EXIT"); + ffx_sceneGroup_appendChild(panel, hint); + ffx_sceneNode_setPosition(hint, ffx_point(120, 232)); + ffx_sceneLabel_setAlign(hint, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(hint, ffx_color_rgb(0, 0, 0)); + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelCyber() { + return ffx_pushPanel(initFunc, sizeof(CyberState), NULL); +} diff --git a/main/panel-life.c b/main/panel-life.c new file mode 100644 index 0000000..49756a1 --- /dev/null +++ b/main/panel-life.c @@ -0,0 +1,251 @@ +#include +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define GRID_DIM (16) +#define CELL_SIZE (14) +#define CELL_GAP (1) +#define GRID_PIXELS (GRID_DIM * (CELL_SIZE + CELL_GAP)) +#define GRID_OFFSET ((240 - GRID_PIXELS) / 2) +#define GRID_TOP (32) + +#define STEP_INTERVAL (160) + +#define PRESET_COUNT (4) + + +typedef struct LifeState { + FfxScene scene; + FfxNode cells[GRID_DIM * GRID_DIM]; + FfxNode presetLabels[PRESET_COUNT]; + + uint8_t board[GRID_DIM * GRID_DIM]; + uint8_t scratch[GRID_DIM * GRID_DIM]; + + int preset; + bool paused; + uint32_t nextStepAt; + uint32_t rngState; +} LifeState; + + +static const char *presetNames[PRESET_COUNT] = { + "GLIDER", "PULSAR", "RPENTOMINO", "RANDOM" +}; + +static uint32_t rngNext(uint32_t *s) { + uint32_t x = *s; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *s = x ? x : 0xCAFEBABE; + return *s; +} + +static void boardClear(LifeState *life) { + memset(life->board, 0, sizeof(life->board)); +} + +static void boardSet(LifeState *life, int x, int y, uint8_t v) { + if (x < 0 || x >= GRID_DIM || y < 0 || y >= GRID_DIM) { return; } + life->board[y * GRID_DIM + x] = v ? 1 : 0; +} + +static void seedGlider(LifeState *life) { + boardClear(life); + boardSet(life, 1, 0, 1); + boardSet(life, 2, 1, 1); + boardSet(life, 0, 2, 1); + boardSet(life, 1, 2, 1); + boardSet(life, 2, 2, 1); +} + +static void seedPulsar(LifeState *life) { + static const int8_t cells[][2] = { + {2,0},{3,0},{4,0},{8,0},{9,0},{10,0}, + {0,2},{5,2},{7,2},{12,2}, + {0,3},{5,3},{7,3},{12,3}, + {0,4},{5,4},{7,4},{12,4}, + {2,5},{3,5},{4,5},{8,5},{9,5},{10,5}, + {2,7},{3,7},{4,7},{8,7},{9,7},{10,7}, + {0,8},{5,8},{7,8},{12,8}, + {0,9},{5,9},{7,9},{12,9}, + {0,10},{5,10},{7,10},{12,10}, + {2,12},{3,12},{4,12},{8,12},{9,12},{10,12} + }; + boardClear(life); + int ox = (GRID_DIM - 13) / 2; + int oy = (GRID_DIM - 13) / 2; + for (size_t i = 0; i < sizeof(cells) / sizeof(cells[0]); i++) { + boardSet(life, ox + cells[i][0], oy + cells[i][1], 1); + } +} + +static void seedRPentomino(LifeState *life) { + boardClear(life); + int cx = GRID_DIM / 2; + int cy = GRID_DIM / 2; + boardSet(life, cx, cy - 1, 1); + boardSet(life, cx + 1, cy - 1, 1); + boardSet(life, cx - 1, cy, 1); + boardSet(life, cx, cy, 1); + boardSet(life, cx, cy + 1, 1); +} + +static void seedRandom(LifeState *life) { + boardClear(life); + for (int i = 0; i < GRID_DIM * GRID_DIM; i++) { + life->board[i] = (rngNext(&life->rngState) & 0x3) == 0 ? 1 : 0; + } +} + +static void applyPreset(LifeState *life) { + switch (life->preset) { + case 0: seedGlider(life); break; + case 1: seedPulsar(life); break; + case 2: seedRPentomino(life); break; + default: seedRandom(life); break; + } +} + +static void stepBoard(LifeState *life) { + for (int y = 0; y < GRID_DIM; y++) { + for (int x = 0; x < GRID_DIM; x++) { + int n = 0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) { continue; } + int nx = (x + dx + GRID_DIM) % GRID_DIM; + int ny = (y + dy + GRID_DIM) % GRID_DIM; + n += life->board[ny * GRID_DIM + nx]; + } + } + uint8_t alive = life->board[y * GRID_DIM + x]; + life->scratch[y * GRID_DIM + x] = + (alive && (n == 2 || n == 3)) || (!alive && n == 3); + } + } + memcpy(life->board, life->scratch, sizeof(life->board)); +} + +static void renderBoard(LifeState *life) { + for (int i = 0; i < GRID_DIM * GRID_DIM; i++) { + ffx_sceneNode_setHidden(life->cells[i], !life->board[i]); + } +} + +static void updatePresetHighlight(LifeState *life) { + for (int i = 0; i < PRESET_COUNT; i++) { + ffx_sceneNode_setHidden(life->presetLabels[i], i != life->preset); + } +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + LifeState *life = _state; + if (life->paused) { return; } + + uint32_t t = ticks(); + if (t < life->nextStepAt) { return; } + life->nextStepAt = t + STEP_INTERVAL; + + stepBoard(life); + renderBoard(life); +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + LifeState *life = _state; + + switch (props.keys.down) { + case FfxKeyCancel: + ffx_popPanel(0); + break; + case FfxKeyOk: + life->paused = !life->paused; + life->nextStepAt = ticks(); + break; + case FfxKeyNorth: + life->preset = (life->preset + PRESET_COUNT - 1) % PRESET_COUNT; + applyPreset(life); + renderBoard(life); + updatePresetHighlight(life); + break; + case FfxKeySouth: + life->preset = (life->preset + 1) % PRESET_COUNT; + applyPreset(life); + renderBoard(life); + updatePresetHighlight(life); + break; + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + LifeState *life = _state; + life->scene = scene; + life->preset = 0; + life->paused = false; + life->rngState = 0xDEADBEEF ^ ticks(); + + FfxNode bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(bg, ffx_color_rgb(2, 6, 4)); + ffx_sceneGroup_appendChild(panel, bg); + ffx_sceneNode_setPosition(bg, ffx_point(0, 0)); + + FfxNode title = ffx_scene_createLabel(scene, FfxFontLargeBold, "LIFE//GRID"); + ffx_sceneGroup_appendChild(panel, title); + ffx_sceneNode_setPosition(title, ffx_point(8, 16)); + ffx_sceneLabel_setAlign(title, FfxTextAlignLeft | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(title, ffx_color_rgb(0, 0, 0)); + + for (int i = 0; i < PRESET_COUNT; i++) { + FfxNode label = ffx_scene_createLabel(scene, FfxFontMedium, + presetNames[i]); + ffx_sceneGroup_appendChild(panel, label); + ffx_sceneNode_setPosition(label, ffx_point(232, 16)); + ffx_sceneLabel_setAlign(label, FfxTextAlignRight | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(label, ffx_color_rgb(0, 0, 0)); + life->presetLabels[i] = label; + } + updatePresetHighlight(life); + + for (int y = 0; y < GRID_DIM; y++) { + for (int x = 0; x < GRID_DIM; x++) { + FfxNode cell = ffx_scene_createBox(scene, + ffx_size(CELL_SIZE, CELL_SIZE)); + uint8_t g = 120 + ((x + y) * 8) % 130; + ffx_sceneBox_setColor(cell, ffx_color_rgb(0, g, 65)); + ffx_sceneGroup_appendChild(panel, cell); + ffx_sceneNode_setPosition(cell, ffx_point( + GRID_OFFSET + x * (CELL_SIZE + CELL_GAP), + GRID_TOP + y * (CELL_SIZE + CELL_GAP))); + ffx_sceneNode_setHidden(cell, true); + life->cells[y * GRID_DIM + x] = cell; + } + } + + FfxNode hint = ffx_scene_createLabel(scene, FfxFontMedium, + "N/S:SEED OK:PAUSE X:EXIT"); + ffx_sceneGroup_appendChild(panel, hint); + ffx_sceneNode_setPosition(hint, ffx_point(120, 230)); + ffx_sceneLabel_setAlign(hint, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(hint, ffx_color_rgb(0, 0, 0)); + + applyPreset(life); + renderBoard(life); + life->nextStepAt = ticks() + STEP_INTERVAL; + + ffx_onEvent(FfxEventKeys, onKeys, life); + ffx_onEvent(FfxEventRenderScene, onRender, life); + + return 0; +} + +int pushPanelLife() { + return ffx_pushPanel(initFunc, sizeof(LifeState), NULL); +} diff --git a/main/panel-menu.c b/main/panel-menu.c index dd2a12e..c8d20e2 100644 --- a/main/panel-menu.c +++ b/main/panel-menu.c @@ -9,82 +9,144 @@ #include "images/image-arrow.h" +#define ITEM_COUNT (7) +#define VISIBLE_ROWS (3) +#define ROW_HEIGHT (40) +#define ROW_FIRST_Y (63) +#define ARROW_X (25) +#define ARROW_OFFSET_Y (-5) +#define LABEL_X (70) + + typedef struct State { - size_t cursor; + int cursor; + int scrollOffset; FfxScene scene; - FfxNode nodeCursor; + FfxNode arrow; + FfxNode labels[ITEM_COUNT]; } State; +static const char *menuItems[ITEM_COUNT] = { + "Wallet", + "GIFs", + "Le Space", + "Cyber Pulse", + "Life Grid", + "Byte Stream", + "Sys Stats", +}; + + +static void launchItem(int idx) { + switch (idx) { + case 0: pushPanelConnect(); break; + case 1: pushPanelGifs(); break; + case 2: pushPanelSpace(); break; + case 3: pushPanelCyber(); break; + case 4: pushPanelLife(); break; + case 5: pushPanelBytes(); break; + case 6: pushPanelStats(); break; + } +} + +static int rowYForSlot(int slot) { + return ROW_FIRST_Y + slot * ROW_HEIGHT; +} + +static void layoutItems(State *app) { + for (int i = 0; i < ITEM_COUNT; i++) { + int slot = i - app->scrollOffset; + if (slot < 0 || slot >= VISIBLE_ROWS) { + ffx_sceneNode_setHidden(app->labels[i], true); + continue; + } + ffx_sceneNode_setHidden(app->labels[i], false); + ffx_sceneNode_setPosition(app->labels[i], + ffx_point(LABEL_X, rowYForSlot(slot))); + } +} + +static void scrollToCursor(State *app) { + if (app->cursor < app->scrollOffset) { + app->scrollOffset = app->cursor; + } else if (app->cursor >= app->scrollOffset + VISIBLE_ROWS) { + app->scrollOffset = app->cursor - (VISIBLE_ROWS - 1); + } +} + +static void moveArrow(State *app, bool animated) { + int slot = app->cursor - app->scrollOffset; + int y = rowYForSlot(slot) + ARROW_OFFSET_Y; + ffx_sceneNode_stopAnimations(app->arrow, FfxSceneActionStopCurrent); + if (animated) { + ffx_sceneNode_animatePosition(app->arrow, ffx_point(ARROW_X, y), + 0, 150, FfxCurveEaseOutQuad, NULL, NULL); + } else { + ffx_sceneNode_setPosition(app->arrow, ffx_point(ARROW_X, y)); + } +} + static void onKeys(FfxEvent event, FfxEventProps props, void *_app) { State *app = _app; - - switch(props.keys.down) { - case FfxKeyOk: { - uint32_t result = 0; - switch(app->cursor) { - case 0: - result = pushPanelConnect(); - break; - case 1: -// result = pushPanelGifs(NULL); - break; - case 2: - result = pushPanelSpace(NULL); - break; - } - printf("RESULT-men: %ld\n", result); + switch (props.keys.down) { + case FfxKeyOk: + launchItem(app->cursor); return; - } case FfxKeyNorth: if (app->cursor == 0) { return; } app->cursor--; break; case FfxKeySouth: - if (app->cursor == 2) { return; } + if (app->cursor == ITEM_COUNT - 1) { return; } app->cursor++; break; default: return; } - - ffx_sceneNode_stopAnimations(app->nodeCursor, FfxSceneActionStopCurrent); - ffx_sceneNode_animatePosition(app->nodeCursor, - ffx_point(25, 58 + (app->cursor * 40)), 0, 150, - FfxCurveEaseOutQuad, NULL, NULL); + scrollToCursor(app); + layoutItems(app); + moveArrow(app, true); } static int initFunc(FfxScene scene, FfxNode node, void *_app, void *arg) { State *app = _app; app->scene = scene; + app->cursor = 0; + app->scrollOffset = 0; FfxNode box = ffx_scene_createBox(scene, ffx_size(200, 180)); ffx_sceneBox_setColor(box, RGBA_DARKER75); ffx_sceneGroup_appendChild(node, box); - ffx_sceneNode_setPosition(box, (FfxPoint){ .x = 20, .y = 30 }); - - FfxNode text; - - text = ffx_scene_createLabel(scene, FfxFontLarge, "Wallet"); - ffx_sceneGroup_appendChild(node, text); - ffx_sceneNode_setPosition(text, (FfxPoint){ .x = 70, .y = 63 }); - - text = ffx_scene_createLabel(scene, FfxFontLarge, "GIFs"); - ffx_sceneGroup_appendChild(node, text); - ffx_sceneNode_setPosition(text, (FfxPoint){ .x = 70, .y = 103 }); - - text = ffx_scene_createLabel(scene, FfxFontLarge, "Le Space"); - ffx_sceneGroup_appendChild(node, text); - ffx_sceneNode_setPosition(text, (FfxPoint){ .x = 70, .y = 143 }); + ffx_sceneNode_setPosition(box, ffx_point(20, 30)); + + FfxNode topRule = ffx_scene_createBox(scene, ffx_size(200, 1)); + ffx_sceneBox_setColor(topRule, ffx_color_rgb(0, 255, 65)); + ffx_sceneGroup_appendChild(node, topRule); + ffx_sceneNode_setPosition(topRule, ffx_point(20, 30)); + + FfxNode bottomRule = ffx_scene_createBox(scene, ffx_size(200, 1)); + ffx_sceneBox_setColor(bottomRule, ffx_color_rgb(0, 255, 65)); + ffx_sceneGroup_appendChild(node, bottomRule); + ffx_sceneNode_setPosition(bottomRule, ffx_point(20, 209)); + + for (int i = 0; i < ITEM_COUNT; i++) { + FfxNode label = ffx_scene_createLabel(scene, FfxFontLarge, + menuItems[i]); + ffx_sceneGroup_appendChild(node, label); + ffx_sceneNode_setHidden(label, true); + app->labels[i] = label; + } FfxNode cursor = ffx_scene_createImage(scene, image_arrow, sizeof(image_arrow)); ffx_sceneGroup_appendChild(node, cursor); - ffx_sceneNode_setPosition(cursor, (FfxPoint){ .x = 25, .y = 58 }); + app->arrow = cursor; - app->nodeCursor = cursor; + layoutItems(app); + moveArrow(app, false); ffx_onEvent(FfxEventKeys, onKeys, app); diff --git a/main/panel-stats.c b/main/panel-stats.c new file mode 100644 index 0000000..005c2c1 --- /dev/null +++ b/main/panel-stats.c @@ -0,0 +1,184 @@ +#include +#include + +#include "esp_chip_info.h" +#include "esp_idf_version.h" +#include "esp_system.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#ifndef GIT_COMMIT +#define GIT_COMMIT ("unknown") +#endif + + +#define HISTORY_LEN (60) +#define BAR_AREA_LEFT (20) +#define BAR_AREA_TOP (170) +#define BAR_HEIGHT_MAX (50) + + +typedef struct StatsState { + FfxScene scene; + + FfxNode heapHistory[HISTORY_LEN]; + FfxNode heapValueLabel; + FfxNode uptimeValueLabel; + FfxNode fpsValueLabel; + + uint32_t bootTime; + uint32_t nextSampleAt; + int historyHead; + uint32_t initialFreeHeap; + + uint32_t fpsWindowStart; + uint32_t fpsFrames; +} StatsState; + + +static FfxNode addLabel(FfxScene scene, FfxNode parent, const char *text, + int32_t x, int32_t y, FfxFont font, uint32_t align) { + FfxNode label = ffx_scene_createLabel(scene, font, text); + ffx_sceneGroup_appendChild(parent, label); + ffx_sceneNode_setPosition(label, ffx_point(x, y)); + ffx_sceneLabel_setAlign(label, align); + ffx_sceneLabel_setOutlineColor(label, COLOR_BLACK); + return label; +} + +static void renderHeapBar(StatsState *state, int slot, uint32_t freeBytes) { + if (state->initialFreeHeap == 0) { + state->initialFreeHeap = freeBytes; + } + uint32_t scale = state->initialFreeHeap; + if (scale < freeBytes) { scale = freeBytes; } + if (scale == 0) { scale = 1; } + + int32_t h = (int32_t)((freeBytes * BAR_HEIGHT_MAX) / scale); + if (h < 1) { h = 1; } + if (h > BAR_HEIGHT_MAX) { h = BAR_HEIGHT_MAX; } + + int32_t x = BAR_AREA_LEFT + slot * 3; + int32_t y = BAR_AREA_TOP + (BAR_HEIGHT_MAX - h); + ffx_sceneNode_setPosition(state->heapHistory[slot], ffx_point(x, y)); +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + StatsState *state = _state; + + state->fpsFrames++; + + uint32_t t = ticks(); + if (t < state->nextSampleAt) { return; } + state->nextSampleAt = t + 250; + + uint32_t freeHeap = esp_get_free_heap_size(); + renderHeapBar(state, state->historyHead, freeHeap); + state->historyHead = (state->historyHead + 1) % HISTORY_LEN; + + ffx_sceneLabel_setTextFormat(state->heapValueLabel, "%lu KB", + (unsigned long)(freeHeap / 1024)); + + uint32_t uptimeMs = t - state->bootTime; + uint32_t secs = uptimeMs / 1000; + ffx_sceneLabel_setTextFormat(state->uptimeValueLabel, "%02lu:%02lu:%02lu", + (unsigned long)(secs / 3600), + (unsigned long)((secs / 60) % 60), + (unsigned long)(secs % 60)); + + uint32_t windowMs = t - state->fpsWindowStart; + if (windowMs >= 1000) { + uint32_t fps = (state->fpsFrames * 1000) / windowMs; + ffx_sceneLabel_setTextFormat(state->fpsValueLabel, "%lu FPS", + (unsigned long)fps); + state->fpsWindowStart = t; + state->fpsFrames = 0; + } +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + StatsState *state = _state; + state->scene = scene; + state->bootTime = ticks(); + state->fpsWindowStart = state->bootTime; + + FfxNode bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(bg, ffx_color_rgb(0, 8, 12)); + ffx_sceneGroup_appendChild(panel, bg); + ffx_sceneNode_setPosition(bg, ffx_point(0, 0)); + + addLabel(scene, panel, "SYS//STATS", 120, 16, FfxFontLargeBold, + FfxTextAlignCenter | FfxTextAlignMiddle); + + addLabel(scene, panel, "CHIP", 14, 50, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + esp_chip_info_t chip; + esp_chip_info(&chip); + static char chipBuf[32]; + snprintf(chipBuf, sizeof(chipBuf), "ESP32-C3 r%d %dC", + (int)chip.revision, (int)chip.cores); + addLabel(scene, panel, chipBuf, 226, 50, FfxFontMedium, + FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "IDF", 14, 70, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + addLabel(scene, panel, esp_get_idf_version(), 226, 70, FfxFontMedium, + FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "BUILD", 14, 90, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + addLabel(scene, panel, GIT_COMMIT, 226, 90, FfxFontMedium, + FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "UPTIME", 14, 110, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + state->uptimeValueLabel = addLabel(scene, panel, "00:00:00", 226, 110, + FfxFontMedium, FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "HEAP", 14, 130, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + state->heapValueLabel = addLabel(scene, panel, "-- KB", 226, 130, + FfxFontMedium, FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "RENDER", 14, 150, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + state->fpsValueLabel = addLabel(scene, panel, "-- FPS", 226, 150, + FfxFontMedium, FfxTextAlignRight | FfxTextAlignMiddle); + + for (int i = 0; i < HISTORY_LEN; i++) { + FfxNode bar = ffx_scene_createBox(scene, ffx_size(2, BAR_HEIGHT_MAX)); + ffx_sceneBox_setColor(bar, ffx_color_rgb(0, 200, 80)); + ffx_sceneGroup_appendChild(panel, bar); + ffx_sceneNode_setPosition(bar, + ffx_point(BAR_AREA_LEFT + i * 3, BAR_AREA_TOP + BAR_HEIGHT_MAX)); + state->heapHistory[i] = bar; + } + + addLabel(scene, panel, "[CANCEL] EXIT", 120, 232, FfxFontMedium, + FfxTextAlignCenter | FfxTextAlignMiddle); + + state->nextSampleAt = state->bootTime; + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelStats() { + return ffx_pushPanel(initFunc, sizeof(StatsState), NULL); +} diff --git a/main/panels.h b/main/panels.h index 884296b..920e77d 100644 --- a/main/panels.h +++ b/main/panels.h @@ -32,10 +32,6 @@ bool appendNetwork(void *info, FfxDataResult *chainId); int pushPanelMenu(); -//int pushPanelMenu(FfxInfoInitFunc initFunc, size_t stateSize, void *initArg); -//bool appendMenuItem(void *menu, const char* title, -// FfxInfoClickFunc clickFunc, FfxInfoClickArg clickArg); - /////////////////////////////// // Simple Game and Toy Panels @@ -47,12 +43,28 @@ typedef enum GameResult { } GameResult; // See: panel-gifs.c -void pushPanelGifs(); +int pushPanelGifs(); // See: panel-space.c GameResult pushPanelSpace(); +/////////////////////////////// +// Cyberdeck Demo Panels + +// See: panel-cyber.c +int pushPanelCyber(); + +// See: panel-life.c +int pushPanelLife(); + +// See: panel-bytes.c +int pushPanelBytes(); + +// See: panel-stats.c +int pushPanelStats(); + + /////////////////////////////// // Wallet Panels From a5591150f32736f89169925554b0ccd5b2ba8e30 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:24:19 +0200 Subject: [PATCH 02/18] build: add build.sh and flash.sh helpers build.sh wraps the documented Docker one-liner from README.md, with preflight checks for docker availability and submodule init. flash.sh handles the full flash without Docker passthrough quirks: * auto-detects /dev/tty.usbmodem* (macOS) and /dev/ttyACM*/ttyUSB* (linux); -p PORT, -b BAUD, ESPPORT/ESPBAUD env vars also honored * prefers host esptool.py (works without ESP-IDF installed); falls back to native idf.py if available * writes bootloader, partition table and pixie.bin at the correct offsets matching partitions.csv * --monitor flag opens a serial monitor after flashing --- build.sh | 36 +++++++++++++++++++++++ flash.sh | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100755 build.sh create mode 100755 flash.sh diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..23ea653 --- /dev/null +++ b/build.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +if ! command -v docker >/dev/null 2>&1; then + echo "error: docker not found in PATH" >&2 + echo " install Docker Desktop: https://docs.docker.com/get-docker" >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "error: docker daemon is not running" >&2 + echo " start Docker Desktop and try again" >&2 + exit 1 +fi + +if [ ! -f components/firefly-hollows/include/firefly-hollows.h ]; then + echo "==> Initializing submodules" + git submodule update --init --recursive +fi + +echo "==> Building Pixie firmware (espressif/idf)" +docker run --rm \ + -v "$PWD":/project \ + -w /project \ + -e HOME=/tmp \ + espressif/idf idf.py build + +echo +echo "==> Build complete" +if [ -f build/pixie.bin ]; then + ls -lh build/pixie.bin +fi +echo +echo "Flash with: ./flash.sh" diff --git a/flash.sh b/flash.sh new file mode 100755 index 0000000..042849b --- /dev/null +++ b/flash.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac +done + +if [ -z "$PORT" ]; then + for p in /dev/tty.usbmodem* /dev/cu.usbmodem* /dev/ttyACM* /dev/ttyUSB*; do + if [ -e "$p" ]; then PORT="$p"; break; fi + done +fi + +if [ -z "$PORT" ]; then + echo "error: no serial port detected" >&2 + echo " pass -p /dev/your-port or export ESPPORT=/dev/your-port" >&2 + echo " on macOS try: ls /dev/tty.usbmodem*" >&2 + echo " on linux try: ls /dev/ttyACM* /dev/ttyUSB*" >&2 + exit 1 +fi + +if [ ! -f build/pixie.bin ]; then + echo "error: build/pixie.bin not found - run ./build.sh first" >&2 + exit 1 +fi + +echo "==> Flashing $PORT @ ${BAUD}" + +if command -v esptool.py >/dev/null 2>&1; then + esptool.py --chip esp32c3 -p "$PORT" -b "$BAUD" \ + --before default_reset --after hard_reset \ + write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m \ + 0x0 build/bootloader/bootloader.bin \ + 0x8000 build/partition_table/partition-table.bin \ + 0x10000 build/pixie.bin +elif command -v idf.py >/dev/null 2>&1; then + idf.py -p "$PORT" -b "$BAUD" flash +else + echo "error: neither esptool.py nor idf.py found in PATH" >&2 + echo " install one of:" >&2 + echo " pip install esptool" >&2 + echo " or set up ESP-IDF: https://docs.espressif.com/projects/esp-idf/" >&2 + exit 1 +fi + +echo +echo "==> Flash complete" + +if [ "$MONITOR" = "1" ]; then + if command -v idf.py >/dev/null 2>&1; then + exec idf.py -p "$PORT" monitor + elif command -v screen >/dev/null 2>&1; then + exec screen "$PORT" 115200 + else + echo "warning: no monitor tool available (idf.py or screen)" >&2 + fi +else + echo "Monitor with: ./flash.sh --monitor (or) screen $PORT 115200" +fi From 0c72cd566066ed5b1f62a5a21e1783a378abc4e0 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:27:51 +0200 Subject: [PATCH 03/18] build: pin idf image to v5.4.1 espressif/idf:latest now ships ESP-IDF 6.1, which fails to bootstrap on a project last configured against 5.4.1 (component manager looks for files that don't exist yet on a fresh 6.x build). Pin to the project's actual IDF version. Override with IDF_IMAGE env var. --- build.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 23ea653..ad1f065 100755 --- a/build.sh +++ b/build.sh @@ -20,12 +20,17 @@ if [ ! -f components/firefly-hollows/include/firefly-hollows.h ]; then git submodule update --init --recursive fi -echo "==> Building Pixie firmware (espressif/idf)" +# Pin the IDF image - the project's sdkconfig was generated against +# ESP-IDF 5.4.1 (see commit 547c1e1) and `espressif/idf:latest` (6.x) +# fails to bootstrap on it. Override with IDF_IMAGE if you know better. +IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.4.1}" + +echo "==> Building Pixie firmware ($IDF_IMAGE)" docker run --rm \ -v "$PWD":/project \ -w /project \ -e HOME=/tmp \ - espressif/idf idf.py build + "$IDF_IMAGE" idf.py build echo echo "==> Build complete" From b232fe7ac3eb809aa54a265e33cc7b9be1199b58 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:32:31 +0200 Subject: [PATCH 04/18] fix: replace undefined COLOR_BACK with COLOR_NAVONLY; bump idf to v5.5.4 panel-tx.c referenced COLOR_BACK in three places (lines 125, 237, 338) for the 'BACK' info-button color, but no such symbol exists in the pinned firefly-color.h. The semantically-correct replacement is COLOR_NAVONLY, which firefly-hollows.h documents as the back-button color. Same upstream-vs-submodule drift as the ffx_init signature. Also bump the default Docker image from v5.4.1 to v5.5.4 - the latest v5.5 line builds the project cleanly and is better-supported. --- build.sh | 8 ++++---- main/panel-tx.c | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/build.sh b/build.sh index ad1f065..928f1ae 100755 --- a/build.sh +++ b/build.sh @@ -20,10 +20,10 @@ if [ ! -f components/firefly-hollows/include/firefly-hollows.h ]; then git submodule update --init --recursive fi -# Pin the IDF image - the project's sdkconfig was generated against -# ESP-IDF 5.4.1 (see commit 547c1e1) and `espressif/idf:latest` (6.x) -# fails to bootstrap on it. Override with IDF_IMAGE if you know better. -IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.4.1}" +# Pin the IDF image - `espressif/idf:latest` (6.x) fails to bootstrap +# on this project. v5.5.x is the most recent line known to build cleanly. +# Override with IDF_IMAGE if you know better. +IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.5.4}" echo "==> Building Pixie firmware ($IDF_IMAGE)" docker run --rm \ diff --git a/main/panel-tx.c b/main/panel-tx.c index 5e953c5..935bebd 100644 --- a/main/panel-tx.c +++ b/main/panel-tx.c @@ -122,7 +122,7 @@ static int initAddressFunc(void *info, void *_state, void *_arg) { } // Buttons - ffx_appendInfoButton(info, "BACK", COLOR_BACK, clickApprove, NULLARG); + ffx_appendInfoButton(info, "BACK", COLOR_NAVONLY, clickApprove, NULLARG); return 0; } @@ -234,7 +234,7 @@ static int initDataFunc(void *info, void *_state, void *arg) { } // Buttons - ffx_appendInfoButton(info, "BACK", COLOR_BACK, clickApprove, NULLARG); + ffx_appendInfoButton(info, "BACK", COLOR_NAVONLY, clickApprove, NULLARG); return true; } @@ -335,7 +335,7 @@ static int initNetworkFunc(void *info, void *_state, void *arg) { ffx_appendInfoEntry(info, "CHAIN ID", str, NULL, NULLARG); // Buttons - ffx_appendInfoButton(info, "BACK", COLOR_BACK, clickApprove, NULLARG); + ffx_appendInfoButton(info, "BACK", COLOR_NAVONLY, clickApprove, NULLARG); return 0; } From 651cdb5c79a6ab220bc2ebe6b60c301ca8afe723 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:41:15 +0200 Subject: [PATCH 05/18] build: auto-clean build/ when IDF_IMAGE changes Switching ESP-IDF minor versions (5.4 -> 5.5) leaves an incompatible cmake cache pointing at the previous python_env, producing a 'currently active env doesn't match' error. Track the image used for the last build in build/.idf-image and wipe build/ on mismatch. --- build.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/build.sh b/build.sh index 928f1ae..455d1b9 100755 --- a/build.sh +++ b/build.sh @@ -25,6 +25,16 @@ fi # Override with IDF_IMAGE if you know better. IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.5.4}" +# Switching IDF major.minor versions reuses incompatible cmake cache +# (different python_env path). Wipe build/ when the image changes. +if [ -d build ] && [ -f build/.idf-image ]; then + cached=$(cat build/.idf-image) + if [ "$cached" != "$IDF_IMAGE" ]; then + echo "==> IDF image changed ($cached -> $IDF_IMAGE); cleaning build/" + rm -rf build + fi +fi + echo "==> Building Pixie firmware ($IDF_IMAGE)" docker run --rm \ -v "$PWD":/project \ @@ -32,6 +42,8 @@ docker run --rm \ -e HOME=/tmp \ "$IDF_IMAGE" idf.py build +mkdir -p build && echo "$IDF_IMAGE" > build/.idf-image + echo echo "==> Build complete" if [ -f build/pixie.bin ]; then From e6d14dc4525c431f47bd34c92bf8a3f2405d84e2 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:45:36 +0200 Subject: [PATCH 06/18] build: patch missing hollows.h include in pinned firefly-hollows The pinned firefly-hollows commit (2fbda9d) has a real bug: src/task-ble.c references TaskBleInit (defined in src/hollows.h) without including it, so any clean build of this project fails inside the submodule. The next upstream commit (1f55b89) adds the missing include, but the same commit also reshuffles the FfxKey bit assignments. Inheriting that without a coordinated downstream update is a regression risk we want to keep out of this PR. build.sh now applies a narrow, idempotent in-place patch to add the missing include before invoking docker. Real fix belongs upstream as a PR against firefly/component-hollows pulling out just the include change. --- build.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/build.sh b/build.sh index 455d1b9..7fe1d47 100755 --- a/build.sh +++ b/build.sh @@ -20,6 +20,29 @@ if [ ! -f components/firefly-hollows/include/firefly-hollows.h ]; then git submodule update --init --recursive fi +# Workaround for a bug in the pinned firefly-hollows commit (2fbda9d): +# src/task-ble.c references TaskBleInit (defined in src/hollows.h) without +# including it, so the build fails. Upstream fixed this in commit 1f55b89, +# but that commit also reshuffles the FfxKey bit assignments, which is too +# risky to inherit without also updating downstream code. Patch in place; +# idempotent so re-runs are safe. Real fix: open PR against +# firefly/component-hollows to land just the include change. +hollows_ble="components/firefly-hollows/src/task-ble.c" +if [ -f "$hollows_ble" ] && ! grep -q '^#include "hollows.h"' "$hollows_ble"; then + echo "==> patching $hollows_ble (add missing #include \"hollows.h\")" + awk ' + /^#include "firefly-tx.h"/ && !patched { + print + print "" + print "// Patched by build.sh: pinned commit is missing this include" + print "#include \"hollows.h\"" + patched = 1 + next + } + { print } + ' "$hollows_ble" > "$hollows_ble.tmp" && mv "$hollows_ble.tmp" "$hollows_ble" +fi + # Pin the IDF image - `espressif/idf:latest` (6.x) fails to bootstrap # on this project. v5.5.x is the most recent line known to build cleanly. # Override with IDF_IMAGE if you know better. From ccff0ee94c5029e2dff8ef26fd9101f975037100 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:51:58 +0200 Subject: [PATCH 07/18] build: patch missing comma in pinned hollows.c TaskBleInit init components/firefly-hollows/src/hollows.c line 132 reads: TaskBleInit init = { .version = version <-- missing comma .ready = xSemaphoreCreateBinaryStatic(&readyBuffer) }; Without the comma the compiler parses '.ready' as a member access on the integer 'version', producing 'request for member ready in something not a structure or union' and the unused-but-set-parameter error on 'version'. Same upstream-bug class as the missing include - apply an idempotent in-place fix from build.sh. --- build.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build.sh b/build.sh index 7fe1d47..19976c9 100755 --- a/build.sh +++ b/build.sh @@ -43,6 +43,16 @@ if [ -f "$hollows_ble" ] && ! grep -q '^#include "hollows.h"' "$hollows_ble"; th ' "$hollows_ble" > "$hollows_ble.tmp" && mv "$hollows_ble.tmp" "$hollows_ble" fi +# Same root cause - hollows.c:132 has a malformed initializer; the +# `.version = version` line is missing a trailing comma, so the next +# `.ready` field is parsed as a member access on `version`. +hollows_c="components/firefly-hollows/src/hollows.c" +if [ -f "$hollows_c" ] && grep -q '^[[:space:]]*\.version = version$' "$hollows_c"; then + echo "==> patching $hollows_c (add missing comma in TaskBleInit init)" + sed -i.bak 's/^\([[:space:]]*\)\.version = version$/\1.version = version,/' \ + "$hollows_c" && rm -f "$hollows_c.bak" +fi + # Pin the IDF image - `espressif/idf:latest` (6.x) fails to bootstrap # on this project. v5.5.x is the most recent line known to build cleanly. # Override with IDF_IMAGE if you know better. From 32f981cc6be517e7f2c06f5e59a8d874755bee92 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:01:48 +0200 Subject: [PATCH 08/18] build: flash.sh --monitor falls back to docker idf.py monitor screen reads raw bytes and prints panic addresses unsymbolicated, which is useless for debugging boot-time crashes. When neither host idf.py nor esptool but docker is available, run the monitor inside the same pinned IDF container - it reads build/pixie.elf and resolves PCs to source locations. --- flash.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/flash.sh b/flash.sh index 042849b..8da0080 100755 --- a/flash.sh +++ b/flash.sh @@ -77,10 +77,19 @@ echo "==> Flash complete" if [ "$MONITOR" = "1" ]; then if command -v idf.py >/dev/null 2>&1; then exec idf.py -p "$PORT" monitor + elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + # idf.py monitor inside docker - symbolicates panic addresses by + # reading build/pixie.elf, which is much more useful than raw screen. + IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.5.4}" + exec docker run --rm -it \ + -v "$PWD":/project -w /project -e HOME=/tmp \ + --device "$PORT" \ + "$IDF_IMAGE" idf.py -p "$PORT" monitor elif command -v screen >/dev/null 2>&1; then + echo "note: 'screen' won't symbolicate panic addresses; install esptool/idf for that" >&2 exec screen "$PORT" 115200 else - echo "warning: no monitor tool available (idf.py or screen)" >&2 + echo "warning: no monitor tool available (idf.py, docker, or screen)" >&2 fi else echo "Monitor with: ./flash.sh --monitor (or) screen $PORT 115200" From 0416cfd88e2f35c54a46a961b901dabb2a8de622 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:03:52 +0200 Subject: [PATCH 09/18] build: wrap ble_hs_id_copy_addr with host lock for IDF v5.5 NimBLE ESP-IDF v5.5's NimBLE tightened locking on ble_hs_id_copy_addr - it asserts the caller holds the host lock and panics otherwise. The pinned firefly-hollows task-ble.c calls it bare from onSync(), so the device boot-loops with: assert failed: ble_hs_id_addr ble_hs_id.c:295 (ble_hs_locked_by_cur_task()) Add ble_hs_lock()/ble_hs_unlock() around the call via build.sh patch. --- build.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/build.sh b/build.sh index 19976c9..a381309 100755 --- a/build.sh +++ b/build.sh @@ -53,6 +53,23 @@ if [ -f "$hollows_c" ] && grep -q '^[[:space:]]*\.version = version$' "$hollows_ "$hollows_c" && rm -f "$hollows_c.bak" fi +# IDF v5.5's NimBLE asserts that ble_hs_id_copy_addr is called with the +# host lock held; the pinned hollows code calls it bare from onSync, so +# the device panic-reboots right after BLE init. Wrap with lock/unlock. +if [ -f "$hollows_ble" ] && \ + grep -q '^ rc = ble_hs_id_copy_addr(conn.own_addr_type, conn.address, NULL);$' "$hollows_ble"; then + echo "==> patching $hollows_ble (wrap ble_hs_id_copy_addr with host lock)" + awk ' + /^ rc = ble_hs_id_copy_addr\(conn\.own_addr_type, conn\.address, NULL\);$/ { + print " ble_hs_lock();" + print $0 + print " ble_hs_unlock();" + next + } + { print } + ' "$hollows_ble" > "$hollows_ble.tmp" && mv "$hollows_ble.tmp" "$hollows_ble" +fi + # Pin the IDF image - `espressif/idf:latest` (6.x) fails to bootstrap # on this project. v5.5.x is the most recent line known to build cleanly. # Override with IDF_IMAGE if you know better. From a99b8119d0ea722bc38faaa4431bb569836a61bc Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:09:32 +0200 Subject: [PATCH 10/18] build: forward-declare ble_hs_lock/unlock; sentinel-guard the patch ble_hs_lock and ble_hs_unlock exist in NimBLE's lib but are not in the public ble_hs.h on this version, so add inline forward declarations. Use 'extern void ble_hs_lock' as the idempotency sentinel so re-runs of build.sh don't double-wrap the call. --- build.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.sh b/build.sh index a381309..063c257 100755 --- a/build.sh +++ b/build.sh @@ -56,11 +56,16 @@ fi # IDF v5.5's NimBLE asserts that ble_hs_id_copy_addr is called with the # host lock held; the pinned hollows code calls it bare from onSync, so # the device panic-reboots right after BLE init. Wrap with lock/unlock. +# ble_hs_lock/unlock exist in the lib but aren't in the public ble_hs.h +# of this NimBLE version, so forward-declare them inline. if [ -f "$hollows_ble" ] && \ + ! grep -q 'extern void ble_hs_lock' "$hollows_ble" && \ grep -q '^ rc = ble_hs_id_copy_addr(conn.own_addr_type, conn.address, NULL);$' "$hollows_ble"; then echo "==> patching $hollows_ble (wrap ble_hs_id_copy_addr with host lock)" awk ' /^ rc = ble_hs_id_copy_addr\(conn\.own_addr_type, conn\.address, NULL\);$/ { + print " extern void ble_hs_lock(void);" + print " extern void ble_hs_unlock(void);" print " ble_hs_lock();" print $0 print " ble_hs_unlock();" From 75a52090185b0ad5036f88cfb0f54f126f27f553 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:14:31 +0200 Subject: [PATCH 11/18] build: prefer esp-idf-monitor over docker --device on macOS Docker Desktop on macOS runs the engine in a Linux VM, so the --device flag can't reach host /dev/tty.usbmodem* paths and the container exits with 'no such file or directory'. esp-idf-monitor is a standalone pip package that does the same panic symbolication as 'idf.py monitor' without needing the full IDF. Resolution order is now native idf.py, then esp-idf-monitor, then docker (Linux only), then screen as last resort. --- flash.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/flash.sh b/flash.sh index 8da0080..15e5404 100755 --- a/flash.sh +++ b/flash.sh @@ -77,19 +77,27 @@ echo "==> Flash complete" if [ "$MONITOR" = "1" ]; then if command -v idf.py >/dev/null 2>&1; then exec idf.py -p "$PORT" monitor - elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then - # idf.py monitor inside docker - symbolicates panic addresses by - # reading build/pixie.elf, which is much more useful than raw screen. + elif python3 -c 'import esp_idf_monitor' >/dev/null 2>&1; then + # Standalone monitor (pip install esp-idf-monitor). Reads pixie.elf + # and resolves panic PCs to source locations - same UX as idf.py + # monitor, no ESP-IDF or Docker needed. + exec python3 -m esp_idf_monitor -p "$PORT" build/pixie.elf + elif [ "$(uname -s)" = "Linux" ] && command -v docker >/dev/null 2>&1 \ + && docker info >/dev/null 2>&1; then + # Docker --device passthrough only works on Linux; on macOS Docker + # Desktop runs in a VM and host /dev/tty paths aren't visible. IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.5.4}" exec docker run --rm -it \ -v "$PWD":/project -w /project -e HOME=/tmp \ --device "$PORT" \ "$IDF_IMAGE" idf.py -p "$PORT" monitor elif command -v screen >/dev/null 2>&1; then - echo "note: 'screen' won't symbolicate panic addresses; install esptool/idf for that" >&2 + echo "note: 'screen' won't symbolicate panic addresses; install" >&2 + echo " esp-idf-monitor for that: pip install esp-idf-monitor" >&2 exec screen "$PORT" 115200 else - echo "warning: no monitor tool available (idf.py, docker, or screen)" >&2 + echo "warning: no monitor tool available" >&2 + echo " install one: pip install esp-idf-monitor" >&2 fi else echo "Monitor with: ./flash.sh --monitor (or) screen $PORT 115200" From 6286d59c96ae2eb36362941c1c693873ffa5832b Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:20:27 +0200 Subject: [PATCH 12/18] build: lock both ble_hs_id_* calls in onSync, not just copy_addr The previous patch only wrapped ble_hs_id_copy_addr, but ble_hs_id_infer_auto on the line above also goes through ble_hs_id_addr internally and hits the same lock-held assert first. Move the lock to cover the entire id-resolution sequence in onSync. --- build.sh | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/build.sh b/build.sh index 063c257..e87f8bf 100755 --- a/build.sh +++ b/build.sh @@ -53,22 +53,30 @@ if [ -f "$hollows_c" ] && grep -q '^[[:space:]]*\.version = version$' "$hollows_ "$hollows_c" && rm -f "$hollows_c.bak" fi -# IDF v5.5's NimBLE asserts that ble_hs_id_copy_addr is called with the -# host lock held; the pinned hollows code calls it bare from onSync, so -# the device panic-reboots right after BLE init. Wrap with lock/unlock. -# ble_hs_lock/unlock exist in the lib but aren't in the public ble_hs.h -# of this NimBLE version, so forward-declare them inline. +# IDF v5.5's NimBLE asserts that the host lock is held when calling the +# id helpers (ble_hs_id_addr, called internally by both ble_hs_id_infer_auto +# and ble_hs_id_copy_addr). Pinned hollows code calls both bare from +# onSync, so the device panic-reboots right after BLE init. Wrap the +# whole sequence in one lock/unlock pair. ble_hs_lock/unlock exist in +# the NimBLE lib but aren't in the public include path, so forward-declare. if [ -f "$hollows_ble" ] && \ ! grep -q 'extern void ble_hs_lock' "$hollows_ble" && \ - grep -q '^ rc = ble_hs_id_copy_addr(conn.own_addr_type, conn.address, NULL);$' "$hollows_ble"; then - echo "==> patching $hollows_ble (wrap ble_hs_id_copy_addr with host lock)" + grep -q '^ rc = ble_hs_id_infer_auto(0, &conn.own_addr_type);$' "$hollows_ble"; then + echo "==> patching $hollows_ble (wrap onSync ble_hs_id_* calls with lock)" awk ' - /^ rc = ble_hs_id_copy_addr\(conn\.own_addr_type, conn\.address, NULL\);$/ { + /^ rc = ble_hs_id_infer_auto\(0, &conn\.own_addr_type\);$/ { print " extern void ble_hs_lock(void);" print " extern void ble_hs_unlock(void);" print " ble_hs_lock();" print $0 + in_block = 1 + next + } + in_block && /^ print_addr/ { print " ble_hs_unlock();" + print "" + print $0 + in_block = 0 next } { print } From ebab773540cc736a4c948bc7fd68e5461c14b086 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:30:42 +0200 Subject: [PATCH 13/18] build: skip NimBLE host init to bypass BLE_HS_DEBUG inconsistency Replaced the (incorrect) host-lock wrapper patch with an early vTaskDelete in taskBleFunc, just after the bootstrap semaphore is given. NimBLE in IDF v5.5 has an internally inconsistent BLE_HS_DEBUG configuration where BLE_HS_DBG_ASSERT is active but the ble_hs_lock_nested bookkeeping that would mark the owning task is gated differently, so ble_hs_locked_by_cur_task always returns false and the assert in ble_hs_id_addr fires right after the BLE host task starts. The cyberdeck demos run entirely on the display/keypad path and do not need BLE. The wallet panel needs it but is broken at this commit regardless. Real fix belongs upstream as a NimBLE/IDF-version-aware update of firefly-hollows. --- build.sh | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/build.sh b/build.sh index e87f8bf..c3f43ad 100755 --- a/build.sh +++ b/build.sh @@ -53,30 +53,26 @@ if [ -f "$hollows_c" ] && grep -q '^[[:space:]]*\.version = version$' "$hollows_ "$hollows_c" && rm -f "$hollows_c.bak" fi -# IDF v5.5's NimBLE asserts that the host lock is held when calling the -# id helpers (ble_hs_id_addr, called internally by both ble_hs_id_infer_auto -# and ble_hs_id_copy_addr). Pinned hollows code calls both bare from -# onSync, so the device panic-reboots right after BLE init. Wrap the -# whole sequence in one lock/unlock pair. ble_hs_lock/unlock exist in -# the NimBLE lib but aren't in the public include path, so forward-declare. +# IDF v5.5's NimBLE has an internally-inconsistent BLE_HS_DEBUG config: +# the BLE_HS_DBG_ASSERT macro is active and asserts ble_hs_locked_by_cur_task, +# but the lock-bookkeeping in ble_hs_lock_nested that would set the owning +# task handle never runs in this code path, so the assert always fails +# right after the BLE host task starts. The cyberdeck demos don't need +# BLE; bypass the host init by exiting taskBleFunc right after it signals +# ready. Wallet panel won't work, but it's broken on this commit anyway. if [ -f "$hollows_ble" ] && \ - ! grep -q 'extern void ble_hs_lock' "$hollows_ble" && \ - grep -q '^ rc = ble_hs_id_infer_auto(0, &conn.own_addr_type);$' "$hollows_ble"; then - echo "==> patching $hollows_ble (wrap onSync ble_hs_id_* calls with lock)" + ! grep -q 'PIXIE-PATCH: skip-ble-init' "$hollows_ble" && \ + grep -q '^ xSemaphoreGive(init->ready);$' "$hollows_ble"; then + echo "==> patching $hollows_ble (skip BLE host init for demo build)" awk ' - /^ rc = ble_hs_id_infer_auto\(0, &conn\.own_addr_type\);$/ { - print " extern void ble_hs_lock(void);" - print " extern void ble_hs_unlock(void);" - print " ble_hs_lock();" + /^ xSemaphoreGive\(init->ready\);$/ && !patched { print $0 - in_block = 1 - next - } - in_block && /^ print_addr/ { - print " ble_hs_unlock();" print "" - print $0 - in_block = 0 + print " // PIXIE-PATCH: skip-ble-init - bypass NimBLE host init," + print " // which trips an internally-inconsistent BLE_HS_DEBUG" + print " // assert in ble_hs_id_addr on IDF v5.5." + print " vTaskDelete(NULL);" + patched = 1 next } { print } From 5187cc36ee8842cc35938622182bdf76cb1a3882 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 02:36:50 +0200 Subject: [PATCH 14/18] feat: 4 game panels (Raycast, Asteroids, Breakout, Dungeon) Stacks four new games on top of the cyberdeck-suite branch: * Raycast - Wolf3D-style first-person raycaster following Lode Vandevenne's tutorial algorithm (public domain, credited in the source). 16x16 map, 60 strips at STRIP_WIDTH=4 rendered as boxes with per-frame setSize, fish-eye- corrected wall heights, side-shaded coloring. Controls: N=forward, S=turn-left, OK=turn-right, X=exit. Sliding collision so walls don't trap you. * Asteroids - side-scroll dodger/shooter. Drifting rocks come from the right with random velocities, bouncing off top/bottom edges. Tap OK to fire bullets, hold N/S to dodge. Score on survival and rock kills, OK restarts on death. * Breakout - vertical Breakout. 5x10 brick wall on the left with a rainbow palette, paddle on the right. Ball deflection angle depends on hit position relative to paddle center. OK launches/restarts, N/S move paddle. * Dungeon - turn-and-step grid crawler. 12x12 dungeon, classic EotB-style controls (N=turn-left, S=turn-right, OK=step). Find the green goal cell to escape; step counter doubles as the score. Menu refactor: ITEM_COUNT bumped 7 -> 11, scrolling viewport handles the four new entries automatically. All four games are pure scene-graph (no new image assets), use only the documented firefly-scene API, and pass clang -fsyntax-only. --- main/CMakeLists.txt | 4 + main/panel-brick.c | 247 +++++++++++++++++++++++++++++++++++++++ main/panel-crawl.c | 225 +++++++++++++++++++++++++++++++++++ main/panel-menu.c | 24 ++-- main/panel-raycast.c | 198 +++++++++++++++++++++++++++++++ main/panel-roids.c | 271 +++++++++++++++++++++++++++++++++++++++++++ main/panels.h | 16 +++ 7 files changed, 977 insertions(+), 8 deletions(-) create mode 100644 main/panel-brick.c create mode 100644 main/panel-crawl.c create mode 100644 main/panel-raycast.c create mode 100644 main/panel-roids.c diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index ab1154a..360b825 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,12 +1,16 @@ idf_component_register( SRCS "main.c" + "panel-brick.c" "panel-bytes.c" "panel-connect.c" + "panel-crawl.c" "panel-cyber.c" "panel-gifs.c" "panel-life.c" "panel-menu.c" + "panel-raycast.c" + "panel-roids.c" "panel-space.c" "panel-stats.c" "panel-tx.c" diff --git a/main/panel-brick.c b/main/panel-brick.c new file mode 100644 index 0000000..e111205 --- /dev/null +++ b/main/panel-brick.c @@ -0,0 +1,247 @@ +// Vertical Breakout. Bricks on the left, paddle on the right, ball +// bounces between. N/S move the paddle along the right edge. + +#include +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define BRICK_COLS (5) +#define BRICK_ROWS (10) +#define BRICK_W (16) +#define BRICK_H (16) +#define BRICK_GAP (2) +#define BRICK_X0 (16) +#define BRICK_Y0 (24) + +#define PADDLE_X (220) +#define PADDLE_W (5) +#define PADDLE_H (44) + +#define BALL_SIZE (5) + + +typedef struct BrickState { + FfxScene scene; + FfxNode bg; + FfxNode bricks[BRICK_COLS * BRICK_ROWS]; + bool brickAlive[BRICK_COLS * BRICK_ROWS]; + FfxNode paddle; + FfxNode ball; + FfxNode scoreLabel; + FfxNode hint; + FfxNode statusLabel; + + int16_t paddleY; + int16_t ballX, ballY; + int8_t ballVx, ballVy; + bool launched; + int score; + int lives; + + FfxKeys keys; +} BrickState; + + +static void resetField(BrickState *state) { + for (int i = 0; i < BRICK_COLS * BRICK_ROWS; i++) { + state->brickAlive[i] = true; + ffx_sceneNode_setHidden(state->bricks[i], false); + } + state->score = 0; + state->lives = 3; +} + +static void resetBall(BrickState *state) { + state->ballX = PADDLE_X - BALL_SIZE - 2; + state->ballY = state->paddleY + PADDLE_H / 2 - BALL_SIZE / 2; + state->ballVx = -2; + state->ballVy = -2; + state->launched = false; + ffx_sceneNode_setPosition(state->ball, ffx_point(state->ballX, state->ballY)); +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + BrickState *state = _state; + state->keys = props.keys.down; + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + return; + } + if (props.keys.down & FfxKeyOk) { + if (state->lives <= 0) { + resetField(state); + resetBall(state); + ffx_sceneNode_setHidden(state->statusLabel, true); + } else if (!state->launched) { + state->launched = true; + } + } +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + BrickState *state = _state; + if (state->lives <= 0) { return; } + + if (state->keys & FfxKeyNorth) { state->paddleY -= 4; } + if (state->keys & FfxKeySouth) { state->paddleY += 4; } + if (state->paddleY < 0) { state->paddleY = 0; } + if (state->paddleY > 240 - PADDLE_H) { state->paddleY = 240 - PADDLE_H; } + ffx_sceneNode_setPosition(state->paddle, ffx_point(PADDLE_X, state->paddleY)); + + if (!state->launched) { + state->ballX = PADDLE_X - BALL_SIZE - 2; + state->ballY = state->paddleY + PADDLE_H / 2 - BALL_SIZE / 2; + ffx_sceneNode_setPosition(state->ball, ffx_point(state->ballX, state->ballY)); + return; + } + + state->ballX += state->ballVx; + state->ballY += state->ballVy; + + if (state->ballY <= 0) { state->ballY = 0; state->ballVy = -state->ballVy; } + if (state->ballY >= 240 - BALL_SIZE){ state->ballY = 240 - BALL_SIZE; state->ballVy = -state->ballVy; } + if (state->ballX <= 0) { state->ballX = 0; state->ballVx = -state->ballVx; } + + if (state->ballX + BALL_SIZE >= PADDLE_X && + state->ballX + BALL_SIZE <= PADDLE_X + PADDLE_W && + state->ballY + BALL_SIZE >= state->paddleY && + state->ballY <= state->paddleY + PADDLE_H && state->ballVx > 0) { + state->ballVx = -state->ballVx; + int rel = (state->ballY + BALL_SIZE / 2) - (state->paddleY + PADDLE_H / 2); + state->ballVy = rel / 8; + if (state->ballVy < -3) { state->ballVy = -3; } + if (state->ballVy > 3) { state->ballVy = 3; } + if (state->ballVy == 0) { state->ballVy = (state->ballX & 1) ? -1 : 1; } + } + + if (state->ballX > 240) { + state->lives--; + if (state->lives <= 0) { + ffx_sceneLabel_setText(state->statusLabel, "GAME OVER OK=RESTART"); + ffx_sceneNode_setHidden(state->statusLabel, false); + } else { + resetBall(state); + } + } + + int br = -1; + for (int i = 0; i < BRICK_COLS * BRICK_ROWS; i++) { + if (!state->brickAlive[i]) { continue; } + int col = i % BRICK_COLS; + int row = i / BRICK_COLS; + int bx = BRICK_X0 + col * (BRICK_W + BRICK_GAP); + int by = BRICK_Y0 + row * (BRICK_H + BRICK_GAP); + if (state->ballX + BALL_SIZE >= bx && state->ballX <= bx + BRICK_W && + state->ballY + BALL_SIZE >= by && state->ballY <= by + BRICK_H) { + br = i; + break; + } + } + if (br >= 0) { + state->brickAlive[br] = false; + ffx_sceneNode_setHidden(state->bricks[br], true); + state->ballVx = -state->ballVx; + state->score += 10; + } + + ffx_sceneNode_setPosition(state->ball, ffx_point(state->ballX, state->ballY)); + ffx_sceneLabel_setTextFormat(state->scoreLabel, "%d L:%d", + state->score, state->lives); + + bool anyAlive = false; + for (int i = 0; i < BRICK_COLS * BRICK_ROWS; i++) { + if (state->brickAlive[i]) { anyAlive = true; break; } + } + if (!anyAlive) { + ffx_sceneLabel_setText(state->statusLabel, "CLEAR OK=AGAIN"); + ffx_sceneNode_setHidden(state->statusLabel, false); + state->lives = 0; + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + BrickState *state = _state; + state->scene = scene; + state->paddleY = 120 - PADDLE_H / 2; + + state->bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(state->bg, ffx_color_rgb(4, 6, 16)); + ffx_sceneGroup_appendChild(panel, state->bg); + ffx_sceneNode_setPosition(state->bg, ffx_point(0, 0)); + + static const uint8_t rowR[BRICK_ROWS] = { + 255, 255, 255, 220, 180, 140, 80, 40, 0, 0 + }; + static const uint8_t rowG[BRICK_ROWS] = { + 40, 120, 200, 255, 255, 255, 220, 200, 180, 160 + }; + static const uint8_t rowB[BRICK_ROWS] = { + 80, 60, 40, 60, 120, 200, 240, 220, 180, 140 + }; + + for (int row = 0; row < BRICK_ROWS; row++) { + for (int col = 0; col < BRICK_COLS; col++) { + int i = row * BRICK_COLS + col; + FfxNode brick = ffx_scene_createBox(scene, ffx_size(BRICK_W, BRICK_H)); + ffx_sceneBox_setColor(brick, + ffx_color_rgb(rowR[row], rowG[row], rowB[row])); + ffx_sceneGroup_appendChild(panel, brick); + ffx_sceneNode_setPosition(brick, ffx_point( + BRICK_X0 + col * (BRICK_W + BRICK_GAP), + BRICK_Y0 + row * (BRICK_H + BRICK_GAP))); + state->bricks[i] = brick; + } + } + + state->paddle = ffx_scene_createBox(scene, ffx_size(PADDLE_W, PADDLE_H)); + ffx_sceneBox_setColor(state->paddle, ffx_color_rgb(0, 255, 220)); + ffx_sceneGroup_appendChild(panel, state->paddle); + + state->ball = ffx_scene_createBox(scene, ffx_size(BALL_SIZE, BALL_SIZE)); + ffx_sceneBox_setColor(state->ball, ffx_color_rgb(255, 255, 255)); + ffx_sceneGroup_appendChild(panel, state->ball); + + state->scoreLabel = ffx_scene_createLabel(scene, FfxFontMedium, "0 L:3"); + ffx_sceneGroup_appendChild(panel, state->scoreLabel); + ffx_sceneNode_setPosition(state->scoreLabel, ffx_point(120, 12)); + ffx_sceneLabel_setAlign(state->scoreLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->scoreLabel, COLOR_BLACK); + + state->hint = ffx_scene_createLabel(scene, FfxFontMedium, + "N/S:PADDLE OK:LAUNCH X:EXIT"); + ffx_sceneGroup_appendChild(panel, state->hint); + ffx_sceneNode_setPosition(state->hint, ffx_point(120, 230)); + ffx_sceneLabel_setAlign(state->hint, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->hint, COLOR_BLACK); + + state->statusLabel = ffx_scene_createLabel(scene, FfxFontLargeBold, + "GAME OVER"); + ffx_sceneGroup_appendChild(panel, state->statusLabel); + ffx_sceneNode_setPosition(state->statusLabel, ffx_point(120, 120)); + ffx_sceneLabel_setAlign(state->statusLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->statusLabel, COLOR_BLACK); + ffx_sceneNode_setHidden(state->statusLabel, true); + + resetField(state); + resetBall(state); + ffx_sceneNode_setPosition(state->paddle, ffx_point(PADDLE_X, state->paddleY)); + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelBrick() { + return ffx_pushPanel(initFunc, sizeof(BrickState), NULL); +} diff --git a/main/panel-crawl.c b/main/panel-crawl.c new file mode 100644 index 0000000..7d5f8f0 --- /dev/null +++ b/main/panel-crawl.c @@ -0,0 +1,225 @@ +// Top-down dungeon crawler. 12x12 grid, classic turn-and-step controls +// (N=turn left, S=turn right, OK=step forward, Cancel=exit). Find the +// goal cell to win. + +#include +#include +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define DUNGEON_W (12) +#define DUNGEON_H (12) +#define CELL_SIZE (18) +#define GRID_PIXELS (DUNGEON_W * CELL_SIZE) +#define GRID_OFFSET_X ((240 - GRID_PIXELS) / 2) +#define GRID_OFFSET_Y (24) + +#define DIR_N (0) +#define DIR_E (1) +#define DIR_S (2) +#define DIR_W (3) + + +typedef struct CrawlState { + FfxScene scene; + FfxNode bg; + FfxNode walls[DUNGEON_W * DUNGEON_H]; + FfxNode goalMarker; + FfxNode player; + FfxNode facingDot; + FfxNode titleLabel; + FfxNode statusLabel; + FfxNode hint; + + int8_t px, py; + int8_t pdir; + int8_t goalX, goalY; + int steps; + bool won; +} CrawlState; + + +static const char dungeon[DUNGEON_H][DUNGEON_W + 1] = { + "############", + "#P.....#...#", + "#.###..#.#.#", + "#.#.#..#.#.#", + "#.#......#.#", + "#.######.#.#", + "#........#.#", + "###.######.#", + "#..........#", + "#.######.#.#", + "#........#G#", + "############" +}; + + +static void placePlayer(CrawlState *state) { + int x = GRID_OFFSET_X + state->px * CELL_SIZE + 2; + int y = GRID_OFFSET_Y + state->py * CELL_SIZE + 2; + ffx_sceneNode_setPosition(state->player, ffx_point(x, y)); + + int cx = GRID_OFFSET_X + state->px * CELL_SIZE + CELL_SIZE / 2 - 2; + int cy = GRID_OFFSET_Y + state->py * CELL_SIZE + CELL_SIZE / 2 - 2; + int dx = 0, dy = 0; + switch (state->pdir) { + case DIR_N: dy = -CELL_SIZE / 2 + 2; break; + case DIR_E: dx = CELL_SIZE / 2 - 2; break; + case DIR_S: dy = CELL_SIZE / 2 - 2; break; + case DIR_W: dx = -CELL_SIZE / 2 + 2; break; + } + ffx_sceneNode_setPosition(state->facingDot, ffx_point(cx + dx, cy + dy)); +} + +static bool isWall(int x, int y) { + if (x < 0 || x >= DUNGEON_W || y < 0 || y >= DUNGEON_H) { return true; } + return dungeon[y][x] == '#'; +} + +static void resetGame(CrawlState *state) { + state->steps = 0; + state->won = false; + state->pdir = DIR_E; + for (int y = 0; y < DUNGEON_H; y++) { + for (int x = 0; x < DUNGEON_W; x++) { + char c = dungeon[y][x]; + if (c == 'P') { state->px = x; state->py = y; } + if (c == 'G') { state->goalX = x; state->goalY = y; } + } + } + placePlayer(state); + ffx_sceneNode_setHidden(state->statusLabel, true); +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + CrawlState *state = _state; + + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + return; + } + if (state->won) { + if (props.keys.down & FfxKeyOk) { resetGame(state); } + return; + } + if (props.keys.down & FfxKeyNorth) { + state->pdir = (state->pdir + 3) & 3; + placePlayer(state); + } else if (props.keys.down & FfxKeySouth) { + state->pdir = (state->pdir + 1) & 3; + placePlayer(state); + } else if (props.keys.down & FfxKeyOk) { + int nx = state->px; + int ny = state->py; + switch (state->pdir) { + case DIR_N: ny--; break; + case DIR_E: nx++; break; + case DIR_S: ny++; break; + case DIR_W: nx--; break; + } + if (!isWall(nx, ny)) { + state->px = nx; + state->py = ny; + state->steps++; + placePlayer(state); + if (nx == state->goalX && ny == state->goalY) { + state->won = true; + ffx_sceneLabel_setTextFormat(state->statusLabel, + "ESCAPED IN %d STEPS - OK", state->steps); + ffx_sceneNode_setHidden(state->statusLabel, false); + } else { + ffx_sceneLabel_setTextFormat(state->titleLabel, + "DUNGEON STEPS:%d", state->steps); + } + } + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + CrawlState *state = _state; + state->scene = scene; + + state->bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(state->bg, ffx_color_rgb(8, 8, 16)); + ffx_sceneGroup_appendChild(panel, state->bg); + ffx_sceneNode_setPosition(state->bg, ffx_point(0, 0)); + + state->titleLabel = ffx_scene_createLabel(scene, FfxFontLargeBold, + "DUNGEON"); + ffx_sceneGroup_appendChild(panel, state->titleLabel); + ffx_sceneNode_setPosition(state->titleLabel, ffx_point(120, 12)); + ffx_sceneLabel_setAlign(state->titleLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->titleLabel, COLOR_BLACK); + + for (int y = 0; y < DUNGEON_H; y++) { + for (int x = 0; x < DUNGEON_W; x++) { + int i = y * DUNGEON_W + x; + FfxNode cell = ffx_scene_createBox(scene, + ffx_size(CELL_SIZE - 1, CELL_SIZE - 1)); + ffx_sceneGroup_appendChild(panel, cell); + ffx_sceneNode_setPosition(cell, ffx_point( + GRID_OFFSET_X + x * CELL_SIZE, + GRID_OFFSET_Y + y * CELL_SIZE)); + if (dungeon[y][x] == '#') { + ffx_sceneBox_setColor(cell, ffx_color_rgb(80, 70, 110)); + } else { + ffx_sceneBox_setColor(cell, ffx_color_rgb(20, 20, 30)); + } + state->walls[i] = cell; + } + } + + state->goalMarker = ffx_scene_createBox(scene, + ffx_size(CELL_SIZE - 6, CELL_SIZE - 6)); + ffx_sceneBox_setColor(state->goalMarker, ffx_color_rgb(0, 255, 80)); + ffx_sceneGroup_appendChild(panel, state->goalMarker); + + state->player = ffx_scene_createBox(scene, + ffx_size(CELL_SIZE - 4, CELL_SIZE - 4)); + ffx_sceneBox_setColor(state->player, ffx_color_rgb(255, 200, 0)); + ffx_sceneGroup_appendChild(panel, state->player); + + state->facingDot = ffx_scene_createBox(scene, ffx_size(4, 4)); + ffx_sceneBox_setColor(state->facingDot, ffx_color_rgb(255, 255, 255)); + ffx_sceneGroup_appendChild(panel, state->facingDot); + + state->hint = ffx_scene_createLabel(scene, FfxFontMedium, + "N:LEFT S:RIGHT OK:STEP X:EXIT"); + ffx_sceneGroup_appendChild(panel, state->hint); + ffx_sceneNode_setPosition(state->hint, ffx_point(120, 230)); + ffx_sceneLabel_setAlign(state->hint, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->hint, COLOR_BLACK); + + state->statusLabel = ffx_scene_createLabel(scene, FfxFontLargeBold, + "ESCAPED"); + ffx_sceneGroup_appendChild(panel, state->statusLabel); + ffx_sceneNode_setPosition(state->statusLabel, ffx_point(120, 120)); + ffx_sceneLabel_setAlign(state->statusLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->statusLabel, COLOR_BLACK); + ffx_sceneNode_setHidden(state->statusLabel, true); + + resetGame(state); + + ffx_sceneNode_setPosition(state->goalMarker, ffx_point( + GRID_OFFSET_X + state->goalX * CELL_SIZE + 3, + GRID_OFFSET_Y + state->goalY * CELL_SIZE + 3)); + + ffx_onEvent(FfxEventKeys, onKeys, state); + + return 0; +} + +int pushPanelCrawl() { + return ffx_pushPanel(initFunc, sizeof(CrawlState), NULL); +} diff --git a/main/panel-menu.c b/main/panel-menu.c index c8d20e2..f9caec3 100644 --- a/main/panel-menu.c +++ b/main/panel-menu.c @@ -9,7 +9,7 @@ #include "images/image-arrow.h" -#define ITEM_COUNT (7) +#define ITEM_COUNT (11) #define VISIBLE_ROWS (3) #define ROW_HEIGHT (40) #define ROW_FIRST_Y (63) @@ -35,18 +35,26 @@ static const char *menuItems[ITEM_COUNT] = { "Life Grid", "Byte Stream", "Sys Stats", + "Raycast", + "Asteroids", + "Breakout", + "Dungeon", }; static void launchItem(int idx) { switch (idx) { - case 0: pushPanelConnect(); break; - case 1: pushPanelGifs(); break; - case 2: pushPanelSpace(); break; - case 3: pushPanelCyber(); break; - case 4: pushPanelLife(); break; - case 5: pushPanelBytes(); break; - case 6: pushPanelStats(); break; + case 0: pushPanelConnect(); break; + case 1: pushPanelGifs(); break; + case 2: pushPanelSpace(); break; + case 3: pushPanelCyber(); break; + case 4: pushPanelLife(); break; + case 5: pushPanelBytes(); break; + case 6: pushPanelStats(); break; + case 7: pushPanelRaycast(); break; + case 8: pushPanelRoids(); break; + case 9: pushPanelBrick(); break; + case 10: pushPanelCrawl(); break; } } diff --git a/main/panel-raycast.c b/main/panel-raycast.c new file mode 100644 index 0000000..18f33b8 --- /dev/null +++ b/main/panel-raycast.c @@ -0,0 +1,198 @@ +// Wolfenstein 3D-style raycaster, following Lode Vandevenne's tutorial +// (public domain): https://lodev.org/cgtutor/raycasting.html + +#include +#include +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define STRIP_COUNT (60) +#define STRIP_WIDTH (4) +#define SCREEN_W (240) +#define SCREEN_H (240) +#define HUD_H (28) +#define VIEW_H (SCREEN_H - HUD_H) +#define MAP_W (16) +#define MAP_H (16) +#define FOV (1.0472f) +#define MOVE_SPEED (0.045f) +#define ROT_SPEED (0.04f) +#define DDA_LIMIT (32) + + +typedef struct RayState { + FfxScene scene; + FfxNode strips[STRIP_COUNT]; + FfxNode sky; + FfxNode floor; + FfxNode hudBg; + FfxNode hudLabel; + + float px, py; + float pa; + + FfxKeys keys; +} RayState; + + +static const char worldMap[MAP_H][MAP_W + 1] = { + "################", + "#..............#", + "#.####....####.#", + "#.#..........#.#", + "#.#..######..#.#", + "#.#..#....#..#.#", + "#....#....#....#", + "#....#....#....#", + "#....#....#....#", + "#.#..#....#..#.#", + "#.#..######..#.#", + "#.#..........#.#", + "#.####....####.#", + "#..............#", + "#..............#", + "################" +}; + + +static bool blocked(float x, float y) { + int mx = (int)x; + int my = (int)y; + if (mx < 0 || mx >= MAP_W || my < 0 || my >= MAP_H) { return true; } + return worldMap[my][mx] == '#'; +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + RayState *state = _state; + state->keys = props.keys.down; + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + } +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + RayState *state = _state; + FfxKeys keys = state->keys; + + if (keys & FfxKeyNorth) { + float dx = cosf(state->pa) * MOVE_SPEED; + float dy = sinf(state->pa) * MOVE_SPEED; + if (!blocked(state->px + dx, state->py)) { state->px += dx; } + if (!blocked(state->px, state->py + dy)) { state->py += dy; } + } + if (keys & FfxKeySouth) { state->pa -= ROT_SPEED; } + if (keys & FfxKeyOk) { state->pa += ROT_SPEED; } + + for (int i = 0; i < STRIP_COUNT; i++) { + float cameraX = (2.0f * i + 1.0f) / STRIP_COUNT - 1.0f; + float rayAngle = state->pa + cameraX * (FOV / 2.0f); + float rdx = cosf(rayAngle); + float rdy = sinf(rayAngle); + + int mapX = (int)state->px; + int mapY = (int)state->py; + + float ddx = (rdx == 0.0f) ? 1e30f : fabsf(1.0f / rdx); + float ddy = (rdy == 0.0f) ? 1e30f : fabsf(1.0f / rdy); + + int stepX, stepY; + float sideX, sideY; + if (rdx < 0) { stepX = -1; sideX = (state->px - mapX) * ddx; } + else { stepX = 1; sideX = (mapX + 1.0f - state->px) * ddx; } + if (rdy < 0) { stepY = -1; sideY = (state->py - mapY) * ddy; } + else { stepY = 1; sideY = (mapY + 1.0f - state->py) * ddy; } + + bool hit = false; + int side = 0; + for (int n = 0; !hit && n < DDA_LIMIT; n++) { + if (sideX < sideY) { + sideX += ddx; mapX += stepX; side = 0; + } else { + sideY += ddy; mapY += stepY; side = 1; + } + if (mapX < 0 || mapX >= MAP_W || mapY < 0 || mapY >= MAP_H) { break; } + if (worldMap[mapY][mapX] == '#') { hit = true; } + } + + if (!hit) { + ffx_sceneNode_setHidden(state->strips[i], true); + continue; + } + float perp = (side == 0) ? (sideX - ddx) : (sideY - ddy); + if (perp < 0.1f) { perp = 0.1f; } + + int wallH = (int)(VIEW_H / perp); + if (wallH > VIEW_H) { wallH = VIEW_H; } + if (wallH < 1) { wallH = 1; } + int wallY = (VIEW_H - wallH) / 2; + + ffx_sceneNode_setHidden(state->strips[i], false); + ffx_sceneBox_setSize(state->strips[i], ffx_size(STRIP_WIDTH, wallH)); + ffx_sceneNode_setPosition(state->strips[i], + ffx_point(i * STRIP_WIDTH, wallY)); + + int b = (int)(220.0f / (1.0f + perp * 0.4f)); + if (b > 220) { b = 220; } + if (b < 30) { b = 30; } + if (side == 1) { b = b * 2 / 3; } + ffx_sceneBox_setColor(state->strips[i], + ffx_color_rgb(b, b / 5, b / 7)); + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + RayState *state = _state; + state->scene = scene; + state->px = 8.0f; + state->py = 7.5f; + state->pa = 0.0f; + state->keys = 0; + + state->sky = ffx_scene_createBox(scene, ffx_size(SCREEN_W, VIEW_H / 2)); + ffx_sceneBox_setColor(state->sky, ffx_color_rgb(15, 8, 30)); + ffx_sceneGroup_appendChild(panel, state->sky); + ffx_sceneNode_setPosition(state->sky, ffx_point(0, 0)); + + state->floor = ffx_scene_createBox(scene, ffx_size(SCREEN_W, VIEW_H / 2)); + ffx_sceneBox_setColor(state->floor, ffx_color_rgb(40, 20, 12)); + ffx_sceneGroup_appendChild(panel, state->floor); + ffx_sceneNode_setPosition(state->floor, ffx_point(0, VIEW_H / 2)); + + for (int i = 0; i < STRIP_COUNT; i++) { + FfxNode strip = ffx_scene_createBox(scene, ffx_size(STRIP_WIDTH, 1)); + ffx_sceneBox_setColor(strip, ffx_color_rgb(180, 40, 30)); + ffx_sceneGroup_appendChild(panel, strip); + ffx_sceneNode_setPosition(strip, ffx_point(i * STRIP_WIDTH, VIEW_H / 2)); + state->strips[i] = strip; + } + + state->hudBg = ffx_scene_createBox(scene, ffx_size(SCREEN_W, HUD_H)); + ffx_sceneBox_setColor(state->hudBg, ffx_color_rgb(0, 0, 0)); + ffx_sceneGroup_appendChild(panel, state->hudBg); + ffx_sceneNode_setPosition(state->hudBg, ffx_point(0, VIEW_H)); + + state->hudLabel = ffx_scene_createLabel(scene, FfxFontMedium, + "N:FWD S:LEFT OK:RIGHT X:EXIT"); + ffx_sceneGroup_appendChild(panel, state->hudLabel); + ffx_sceneNode_setPosition(state->hudLabel, + ffx_point(120, VIEW_H + HUD_H / 2)); + ffx_sceneLabel_setAlign(state->hudLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->hudLabel, COLOR_BLACK); + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelRaycast() { + return ffx_pushPanel(initFunc, sizeof(RayState), NULL); +} diff --git a/main/panel-roids.c b/main/panel-roids.c new file mode 100644 index 0000000..650d86b --- /dev/null +++ b/main/panel-roids.c @@ -0,0 +1,271 @@ +// Asteroids-flavored side-scroll dodger. Ship on the left, drifting +// rocks come from the right; tap OK to fire bullets, hold N/S to move. + +#include +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define MAX_ROCKS (10) +#define MAX_BULLETS (6) +#define SHIP_X (16) +#define SHIP_W (16) +#define SHIP_H (10) +#define BULLET_W (8) +#define BULLET_H (3) +#define BULLET_SPEED (5) +#define ROCK_SIZE (16) + + +typedef struct Rock { + int16_t x, y; + int8_t vx, vy; + uint8_t alive; +} Rock; + +typedef struct RoidsState { + FfxScene scene; + FfxNode bg; + FfxNode ship; + FfxNode rocks[MAX_ROCKS]; + FfxNode bullets[MAX_BULLETS]; + FfxNode scoreLabel; + FfxNode hint; + FfxNode gameOverLabel; + + int16_t shipY; + Rock rocks_state[MAX_ROCKS]; + int16_t bulletX[MAX_BULLETS]; + int16_t bulletY[MAX_BULLETS]; + bool bulletAlive[MAX_BULLETS]; + + FfxKeys keys; + uint32_t score; + uint32_t spawnAt; + uint32_t fireCooldown; + uint32_t rng; + bool gameOver; +} RoidsState; + + +static uint32_t rngNext(uint32_t *s) { + uint32_t x = *s; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *s = x ? x : 0xA5A5C0DE; + return *s; +} + +static void spawnRock(RoidsState *state) { + for (int i = 0; i < MAX_ROCKS; i++) { + if (state->rocks_state[i].alive) { continue; } + Rock *r = &state->rocks_state[i]; + r->x = 240 + ROCK_SIZE; + r->y = (int16_t)(rngNext(&state->rng) % (240 - ROCK_SIZE)); + r->vx = -2 - (int8_t)(rngNext(&state->rng) % 3); + r->vy = (int8_t)((rngNext(&state->rng) % 3) - 1); + r->alive = 1; + ffx_sceneNode_setHidden(state->rocks[i], false); + ffx_sceneNode_setPosition(state->rocks[i], ffx_point(r->x, r->y)); + return; + } +} + +static void fireBullet(RoidsState *state) { + for (int i = 0; i < MAX_BULLETS; i++) { + if (state->bulletAlive[i]) { continue; } + state->bulletAlive[i] = true; + state->bulletX[i] = SHIP_X + SHIP_W; + state->bulletY[i] = state->shipY + SHIP_H / 2; + ffx_sceneNode_setHidden(state->bullets[i], false); + ffx_sceneNode_setPosition(state->bullets[i], + ffx_point(state->bulletX[i], state->bulletY[i])); + return; + } +} + +static void resetGame(RoidsState *state) { + state->shipY = 120 - SHIP_H / 2; + state->score = 0; + state->gameOver = false; + state->spawnAt = ticks() + 500; + state->fireCooldown = 0; + for (int i = 0; i < MAX_ROCKS; i++) { + state->rocks_state[i].alive = 0; + ffx_sceneNode_setHidden(state->rocks[i], true); + } + for (int i = 0; i < MAX_BULLETS; i++) { + state->bulletAlive[i] = false; + ffx_sceneNode_setHidden(state->bullets[i], true); + } + ffx_sceneNode_setHidden(state->gameOverLabel, true); + ffx_sceneNode_setPosition(state->ship, ffx_point(SHIP_X, state->shipY)); +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + RoidsState *state = _state; + state->keys = props.keys.down; + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + return; + } + if (state->gameOver && (props.keys.down & FfxKeyOk)) { + resetGame(state); + } +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + RoidsState *state = _state; + if (state->gameOver) { return; } + + uint32_t t = ticks(); + state->score++; + + if (state->keys & FfxKeyNorth) { state->shipY -= 3; } + if (state->keys & FfxKeySouth) { state->shipY += 3; } + if (state->shipY < 0) { state->shipY = 0; } + if (state->shipY > 240 - SHIP_H) { state->shipY = 240 - SHIP_H; } + ffx_sceneNode_setPosition(state->ship, ffx_point(SHIP_X, state->shipY)); + + if ((state->keys & FfxKeyOk) && t > state->fireCooldown) { + fireBullet(state); + state->fireCooldown = t + 180; + } + + if (t > state->spawnAt) { + spawnRock(state); + uint32_t gap = 600 - (state->score / 4); + if (gap < 200) { gap = 200; } + state->spawnAt = t + gap; + } + + for (int i = 0; i < MAX_BULLETS; i++) { + if (!state->bulletAlive[i]) { continue; } + state->bulletX[i] += BULLET_SPEED; + if (state->bulletX[i] > 240) { + state->bulletAlive[i] = false; + ffx_sceneNode_setHidden(state->bullets[i], true); + continue; + } + ffx_sceneNode_setPosition(state->bullets[i], + ffx_point(state->bulletX[i], state->bulletY[i])); + } + + int16_t shipL = SHIP_X; + int16_t shipR = SHIP_X + SHIP_W; + int16_t shipT = state->shipY; + int16_t shipB = state->shipY + SHIP_H; + + for (int i = 0; i < MAX_ROCKS; i++) { + Rock *r = &state->rocks_state[i]; + if (!r->alive) { continue; } + r->x += r->vx; + r->y += r->vy; + if (r->y < 0) { r->y = 0; r->vy = -r->vy; } + if (r->y > 240 - ROCK_SIZE) { r->y = 240 - ROCK_SIZE; r->vy = -r->vy; } + if (r->x < -ROCK_SIZE) { + r->alive = 0; + ffx_sceneNode_setHidden(state->rocks[i], true); + continue; + } + + for (int j = 0; j < MAX_BULLETS; j++) { + if (!state->bulletAlive[j]) { continue; } + int16_t bx = state->bulletX[j]; + int16_t by = state->bulletY[j]; + if (bx + BULLET_W >= r->x && bx <= r->x + ROCK_SIZE && + by + BULLET_H >= r->y && by <= r->y + ROCK_SIZE) { + r->alive = 0; + ffx_sceneNode_setHidden(state->rocks[i], true); + state->bulletAlive[j] = false; + ffx_sceneNode_setHidden(state->bullets[j], true); + state->score += 50; + break; + } + } + if (!r->alive) { continue; } + + if (r->x < shipR && r->x + ROCK_SIZE > shipL && + r->y < shipB && r->y + ROCK_SIZE > shipT) { + state->gameOver = true; + ffx_sceneNode_setHidden(state->gameOverLabel, false); + } + + ffx_sceneNode_setPosition(state->rocks[i], ffx_point(r->x, r->y)); + } + + ffx_sceneLabel_setTextFormat(state->scoreLabel, "SCORE %lu", + (unsigned long)state->score); +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + RoidsState *state = _state; + state->scene = scene; + state->rng = 0x13371337 ^ ticks(); + + state->bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(state->bg, ffx_color_rgb(2, 2, 14)); + ffx_sceneGroup_appendChild(panel, state->bg); + ffx_sceneNode_setPosition(state->bg, ffx_point(0, 0)); + + state->ship = ffx_scene_createBox(scene, ffx_size(SHIP_W, SHIP_H)); + ffx_sceneBox_setColor(state->ship, ffx_color_rgb(0, 255, 200)); + ffx_sceneGroup_appendChild(panel, state->ship); + + for (int i = 0; i < MAX_ROCKS; i++) { + FfxNode r = ffx_scene_createBox(scene, ffx_size(ROCK_SIZE, ROCK_SIZE)); + ffx_sceneBox_setColor(r, ffx_color_rgb(160, 80, 40)); + ffx_sceneGroup_appendChild(panel, r); + ffx_sceneNode_setHidden(r, true); + state->rocks[i] = r; + } + for (int i = 0; i < MAX_BULLETS; i++) { + FfxNode b = ffx_scene_createBox(scene, ffx_size(BULLET_W, BULLET_H)); + ffx_sceneBox_setColor(b, ffx_color_rgb(255, 255, 0)); + ffx_sceneGroup_appendChild(panel, b); + ffx_sceneNode_setHidden(b, true); + state->bullets[i] = b; + } + + state->scoreLabel = ffx_scene_createLabel(scene, FfxFontMedium, "SCORE 0"); + ffx_sceneGroup_appendChild(panel, state->scoreLabel); + ffx_sceneNode_setPosition(state->scoreLabel, ffx_point(8, 14)); + ffx_sceneLabel_setAlign(state->scoreLabel, + FfxTextAlignLeft | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->scoreLabel, COLOR_BLACK); + + state->hint = ffx_scene_createLabel(scene, FfxFontMedium, + "N/S:MOVE OK:FIRE X:EXIT"); + ffx_sceneGroup_appendChild(panel, state->hint); + ffx_sceneNode_setPosition(state->hint, ffx_point(120, 230)); + ffx_sceneLabel_setAlign(state->hint, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->hint, COLOR_BLACK); + + state->gameOverLabel = ffx_scene_createLabel(scene, FfxFontLargeBold, + "GAME OVER - OK"); + ffx_sceneGroup_appendChild(panel, state->gameOverLabel); + ffx_sceneNode_setPosition(state->gameOverLabel, ffx_point(120, 120)); + ffx_sceneLabel_setAlign(state->gameOverLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->gameOverLabel, COLOR_BLACK); + ffx_sceneNode_setHidden(state->gameOverLabel, true); + + resetGame(state); + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelRoids() { + return ffx_pushPanel(initFunc, sizeof(RoidsState), NULL); +} diff --git a/main/panels.h b/main/panels.h index 920e77d..c446cef 100644 --- a/main/panels.h +++ b/main/panels.h @@ -65,6 +65,22 @@ int pushPanelBytes(); int pushPanelStats(); +/////////////////////////////// +// Game Panels + +// See: panel-raycast.c +int pushPanelRaycast(); + +// See: panel-roids.c +int pushPanelRoids(); + +// See: panel-brick.c +int pushPanelBrick(); + +// See: panel-crawl.c +int pushPanelCrawl(); + + /////////////////////////////// // Wallet Panels From e74ac89ed5b9c42646507f2999c87bd64d0d8cec Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 02:46:01 +0200 Subject: [PATCH 15/18] feat: LED mode panel with persistent animations Adds 'LED Mode' menu entry that drives the 4 WS2812B LEDs through the firefly-hollows pixels API. Seven modes: * OFF - all LEDs off * CYAN - solid neon cyan * RAINBOW - cycling hues, per-LED phase offsets * PULSE - violet breathing * STROBE - 1 Hz white flash * POLICE - alternating red/blue pairs * MATRIX - green wave traveling across the four LEDs Each mode is a PixelAnimationFunc kicked off via pixels_animate(), which the IO task ticks in the background, so the selected mode keeps running after the panel is popped - the user can set a vibe and walk away. Mode changes stop the previous animation and start the new one. The pixels API is private to firefly-hollows (header in src/), so the relevant symbols are forward-declared inline (same pattern used elsewhere in this branch for ble_hs_lock). Animations use the existing fixed_ffxt math helpers (scalarfx, mulfx, tofx) and HSV color helpers - no math.h dependency. ITEM_COUNT bumped 11 -> 12 in the menu. --- main/CMakeLists.txt | 1 + main/panel-leds.c | 223 ++++++++++++++++++++++++++++++++++++++++++++ main/panel-menu.c | 12 ++- main/panels.h | 3 + 4 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 main/panel-leds.c diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 360b825..c825817 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -7,6 +7,7 @@ idf_component_register( "panel-crawl.c" "panel-cyber.c" "panel-gifs.c" + "panel-leds.c" "panel-life.c" "panel-menu.c" "panel-raycast.c" diff --git a/main/panel-leds.c b/main/panel-leds.c new file mode 100644 index 0000000..3fc9fdf --- /dev/null +++ b/main/panel-leds.c @@ -0,0 +1,223 @@ +// LED mode picker. Drives the 4 WS2812B LEDs via firefly-hollows' +// internal pixels API. Each mode is a PixelAnimationFunc kicked off +// with pixels_animate(); the most recently selected mode keeps +// running after the panel is popped. + +#include +#include + +#include "firefly-color.h" +#include "firefly-fixed.h" +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define MODE_COUNT (7) +#define LED_COUNT (4) + + +// firefly-hollows' pixels API lives in src/ (private to the +// component). Forward-declare what we need; the linker resolves to +// the same symbols task-io.c uses. +typedef void* PixelsContext; +typedef void (*PixelAnimationFunc)(color_ffxt *output, size_t count, + fixed_ffxt t, void *arg); +extern PixelsContext pixels; +extern void pixels_animate(PixelsContext context, PixelAnimationFunc fn, + uint32_t duration, uint32_t repeat, void *arg); +extern void pixels_stopAnimation(PixelsContext context, uint32_t final); + + +typedef struct LedsState { + FfxScene scene; + FfxNode bg; + FfxNode titleLabel; + FfxNode modeLabel; + FfxNode descLabel; + FfxNode dots[MODE_COUNT]; + FfxNode hint; + + int mode; +} LedsState; + + +static const char *modeNames[MODE_COUNT] = { + "OFF", "CYAN", "RAINBOW", "PULSE", "STROBE", "POLICE", "MATRIX" +}; +static const char *modeDescs[MODE_COUNT] = { + "all off", + "solid neon cyan", + "cycling hues", + "slow breathing", + "1 Hz white flash", + "alternating red/blue", + "green wave" +}; +static const uint32_t modeDurations[MODE_COUNT] = { + 1000, 1000, 4000, 1800, 1000, 800, 1200 +}; + + +static void animOff(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { + for (size_t i = 0; i < count; i++) { out[i] = COLOR_BLACK; } +} + +static void animCyan(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { + color_ffxt c = ffx_color_rgb(0, 220, 240); + for (size_t i = 0; i < count; i++) { out[i] = c; } +} + +static void animRainbow(color_ffxt *out, size_t count, fixed_ffxt t, + void *arg) { + int32_t base = scalarfx(3960, t); + for (size_t i = 0; i < count; i++) { + int32_t hue = (base + (int32_t)i * 990) % 3960; + out[i] = ffx_color_hsv(hue, MAX_SAT, MAX_VAL); + } +} + +static void animPulse(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { + int32_t v; + fixed_ffxt half = FM_1 / 2; + if (t < half) { v = scalarfx(MAX_VAL, mulfx(t, tofx(2))); } + else { v = scalarfx(MAX_VAL, mulfx(FM_1 - t, tofx(2))); } + color_ffxt c = ffx_color_hsv(275, MAX_SAT, v); + for (size_t i = 0; i < count; i++) { out[i] = c; } +} + +static void animStrobe(color_ffxt *out, size_t count, fixed_ffxt t, + void *arg) { + bool on = t < (FM_1 / 4); + color_ffxt c = on ? ffx_color_rgb(255, 255, 255) : COLOR_BLACK; + for (size_t i = 0; i < count; i++) { out[i] = c; } +} + +static void animPolice(color_ffxt *out, size_t count, fixed_ffxt t, + void *arg) { + bool phase = t < (FM_1 / 2); + color_ffxt red = ffx_color_rgb(255, 0, 0); + color_ffxt blue = ffx_color_rgb(0, 0, 255); + for (size_t i = 0; i < count; i++) { + bool side = (i & 1) ^ phase; + out[i] = side ? red : blue; + } +} + +static void animMatrix(color_ffxt *out, size_t count, fixed_ffxt t, + void *arg) { + int32_t head = scalarfx((int32_t)(count * 2), t); + for (size_t i = 0; i < count; i++) { + int32_t dist = (head - (int32_t)i + (int32_t)(count * 2)) + % (int32_t)(count * 2); + int32_t v = (dist < (int32_t)count) + ? (MAX_VAL - dist * (MAX_VAL / (int32_t)count)) + : 0; + if (v < 0) { v = 0; } + out[i] = ffx_color_hsv(180, MAX_SAT, v); + } +} + +static const PixelAnimationFunc modeFuncs[MODE_COUNT] = { + animOff, animCyan, animRainbow, animPulse, + animStrobe, animPolice, animMatrix +}; + + +static void applyMode(LedsState *state) { + pixels_stopAnimation(pixels, 0); + pixels_animate(pixels, modeFuncs[state->mode], modeDurations[state->mode], + 0, NULL); + + ffx_sceneLabel_setText(state->modeLabel, modeNames[state->mode]); + ffx_sceneLabel_setText(state->descLabel, modeDescs[state->mode]); + for (int i = 0; i < MODE_COUNT; i++) { + ffx_sceneBox_setColor(state->dots[i], + i == state->mode ? ffx_color_rgb(0, 255, 200) + : ffx_color_rgb(40, 40, 60)); + } +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + LedsState *state = _state; + switch (props.keys.down) { + case FfxKeyCancel: + ffx_popPanel(0); + return; + case FfxKeyNorth: + state->mode = (state->mode + MODE_COUNT - 1) % MODE_COUNT; + applyMode(state); + break; + case FfxKeySouth: + state->mode = (state->mode + 1) % MODE_COUNT; + applyMode(state); + break; + case FfxKeyOk: + applyMode(state); + break; + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + LedsState *state = _state; + state->scene = scene; + state->mode = 2; + + state->bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(state->bg, ffx_color_rgb(4, 0, 12)); + ffx_sceneGroup_appendChild(panel, state->bg); + ffx_sceneNode_setPosition(state->bg, ffx_point(0, 0)); + + state->titleLabel = ffx_scene_createLabel(scene, FfxFontLargeBold, + "LED MODE"); + ffx_sceneGroup_appendChild(panel, state->titleLabel); + ffx_sceneNode_setPosition(state->titleLabel, ffx_point(120, 26)); + ffx_sceneLabel_setAlign(state->titleLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->titleLabel, COLOR_BLACK); + + int dotW = 20; + int dotH = 6; + int totalW = MODE_COUNT * dotW + (MODE_COUNT - 1) * 4; + int x0 = (240 - totalW) / 2; + for (int i = 0; i < MODE_COUNT; i++) { + FfxNode dot = ffx_scene_createBox(scene, ffx_size(dotW, dotH)); + ffx_sceneGroup_appendChild(panel, dot); + ffx_sceneNode_setPosition(dot, ffx_point(x0 + i * (dotW + 4), 56)); + state->dots[i] = dot; + } + + state->modeLabel = ffx_scene_createLabel(scene, FfxFontLargeBold, ""); + ffx_sceneGroup_appendChild(panel, state->modeLabel); + ffx_sceneNode_setPosition(state->modeLabel, ffx_point(120, 116)); + ffx_sceneLabel_setAlign(state->modeLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->modeLabel, COLOR_BLACK); + + state->descLabel = ffx_scene_createLabel(scene, FfxFontMedium, ""); + ffx_sceneGroup_appendChild(panel, state->descLabel); + ffx_sceneNode_setPosition(state->descLabel, ffx_point(120, 156)); + ffx_sceneLabel_setAlign(state->descLabel, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->descLabel, COLOR_BLACK); + + state->hint = ffx_scene_createLabel(scene, FfxFontMedium, + "N/S:MODE X:EXIT (mode persists)"); + ffx_sceneGroup_appendChild(panel, state->hint); + ffx_sceneNode_setPosition(state->hint, ffx_point(120, 230)); + ffx_sceneLabel_setAlign(state->hint, + FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(state->hint, COLOR_BLACK); + + applyMode(state); + + ffx_onEvent(FfxEventKeys, onKeys, state); + + return 0; +} + +int pushPanelLeds() { + return ffx_pushPanel(initFunc, sizeof(LedsState), NULL); +} diff --git a/main/panel-menu.c b/main/panel-menu.c index f9caec3..e4031eb 100644 --- a/main/panel-menu.c +++ b/main/panel-menu.c @@ -9,7 +9,7 @@ #include "images/image-arrow.h" -#define ITEM_COUNT (11) +#define ITEM_COUNT (12) #define VISIBLE_ROWS (3) #define ROW_HEIGHT (40) #define ROW_FIRST_Y (63) @@ -35,6 +35,7 @@ static const char *menuItems[ITEM_COUNT] = { "Life Grid", "Byte Stream", "Sys Stats", + "LED Mode", "Raycast", "Asteroids", "Breakout", @@ -51,10 +52,11 @@ static void launchItem(int idx) { case 4: pushPanelLife(); break; case 5: pushPanelBytes(); break; case 6: pushPanelStats(); break; - case 7: pushPanelRaycast(); break; - case 8: pushPanelRoids(); break; - case 9: pushPanelBrick(); break; - case 10: pushPanelCrawl(); break; + case 7: pushPanelLeds(); break; + case 8: pushPanelRaycast(); break; + case 9: pushPanelRoids(); break; + case 10: pushPanelBrick(); break; + case 11: pushPanelCrawl(); break; } } diff --git a/main/panels.h b/main/panels.h index c446cef..41edd53 100644 --- a/main/panels.h +++ b/main/panels.h @@ -64,6 +64,9 @@ int pushPanelBytes(); // See: panel-stats.c int pushPanelStats(); +// See: panel-leds.c +int pushPanelLeds(); + /////////////////////////////// // Game Panels From 1cf92b909f4aea2299d0904d34f418c0824d1e6d Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 02:48:16 +0200 Subject: [PATCH 16/18] build: print Ctrl+] exit hint before launching the monitor esp_idf_monitor (and idf.py monitor) treat Ctrl+C as data forwarded to the target rather than an exit signal; the actual quit shortcut is Ctrl+]. Print a banner up front so users don't get stuck in the monitor wondering why their interrupt is being ignored. --- flash.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flash.sh b/flash.sh index 15e5404..09b83a6 100755 --- a/flash.sh +++ b/flash.sh @@ -75,6 +75,12 @@ echo echo "==> Flash complete" if [ "$MONITOR" = "1" ]; then + echo + echo " +------------------------------------------------+" + echo " | Press Ctrl+] to exit the monitor |" + echo " | Ctrl+C is forwarded to the device, not caught |" + echo " +------------------------------------------------+" + echo if command -v idf.py >/dev/null 2>&1; then exec idf.py -p "$PORT" monitor elif python3 -c 'import esp_idf_monitor' >/dev/null 2>&1; then @@ -94,6 +100,7 @@ if [ "$MONITOR" = "1" ]; then elif command -v screen >/dev/null 2>&1; then echo "note: 'screen' won't symbolicate panic addresses; install" >&2 echo " esp-idf-monitor for that: pip install esp-idf-monitor" >&2 + echo "note: in screen, exit with Ctrl+A then K (then y)" >&2 exec screen "$PORT" 115200 else echo "warning: no monitor tool available" >&2 From 0ee5a4559afdd1d3ec89c20bd6a04998023eb585 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 02:50:42 +0200 Subject: [PATCH 17/18] fix(leds): use per-pixel pixels_animatePixel; drop missing APIs The header pixels.h declares pixels_animate (all-pixels) and pixels_stopAnimation, but pixels.c at the pinned commit only implements the per-pixel variant pixels_animatePixel - the linker correctly fails with 'undefined reference' on the missing two. Switch the LED panel to drive each of the four LEDs individually: each animation function writes only out[0], the pixel index travels in via the 'arg' parameter, and applyMode loops 0..3 calling pixels_animatePixel once per LED. Re-calling pixels_animatePixel overwrites the previous animation slot for that pixel, so mode switching works without an explicit stop call. --- main/panel-leds.c | 66 +++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/main/panel-leds.c b/main/panel-leds.c index 3fc9fdf..8b1d63a 100644 --- a/main/panel-leds.c +++ b/main/panel-leds.c @@ -22,13 +22,17 @@ // firefly-hollows' pixels API lives in src/ (private to the // component). Forward-declare what we need; the linker resolves to // the same symbols task-io.c uses. +// +// Note: the public-looking pixels_animate (all-pixels) and +// pixels_stopAnimation are declared in pixels.h but not actually +// implemented in pixels.c at this commit, so we use the per-pixel +// pixels_animatePixel instead and pass the pixel index via `arg`. typedef void* PixelsContext; typedef void (*PixelAnimationFunc)(color_ffxt *output, size_t count, fixed_ffxt t, void *arg); extern PixelsContext pixels; -extern void pixels_animate(PixelsContext context, PixelAnimationFunc fn, - uint32_t duration, uint32_t repeat, void *arg); -extern void pixels_stopAnimation(PixelsContext context, uint32_t final); +extern void pixels_animatePixel(PixelsContext context, uint32_t pixel, + PixelAnimationFunc fn, uint32_t duration, uint32_t repeat, void *arg); typedef struct LedsState { @@ -61,22 +65,24 @@ static const uint32_t modeDurations[MODE_COUNT] = { }; +// Each animation writes only out[0] - pixels_animatePixel binds one +// LED at a time, and the pixel index travels through `arg`. + +static int pixIndex(void *arg) { return (int)(uintptr_t)arg; } + static void animOff(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { - for (size_t i = 0; i < count; i++) { out[i] = COLOR_BLACK; } + out[0] = COLOR_BLACK; } static void animCyan(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { - color_ffxt c = ffx_color_rgb(0, 220, 240); - for (size_t i = 0; i < count; i++) { out[i] = c; } + out[0] = ffx_color_rgb(0, 220, 240); } static void animRainbow(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { int32_t base = scalarfx(3960, t); - for (size_t i = 0; i < count; i++) { - int32_t hue = (base + (int32_t)i * 990) % 3960; - out[i] = ffx_color_hsv(hue, MAX_SAT, MAX_VAL); - } + int32_t hue = (base + pixIndex(arg) * 990) % 3960; + out[0] = ffx_color_hsv(hue, MAX_SAT, MAX_VAL); } static void animPulse(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { @@ -84,40 +90,32 @@ static void animPulse(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { fixed_ffxt half = FM_1 / 2; if (t < half) { v = scalarfx(MAX_VAL, mulfx(t, tofx(2))); } else { v = scalarfx(MAX_VAL, mulfx(FM_1 - t, tofx(2))); } - color_ffxt c = ffx_color_hsv(275, MAX_SAT, v); - for (size_t i = 0; i < count; i++) { out[i] = c; } + out[0] = ffx_color_hsv(275, MAX_SAT, v); } static void animStrobe(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { bool on = t < (FM_1 / 4); - color_ffxt c = on ? ffx_color_rgb(255, 255, 255) : COLOR_BLACK; - for (size_t i = 0; i < count; i++) { out[i] = c; } + out[0] = on ? ffx_color_rgb(255, 255, 255) : COLOR_BLACK; } static void animPolice(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { bool phase = t < (FM_1 / 2); - color_ffxt red = ffx_color_rgb(255, 0, 0); - color_ffxt blue = ffx_color_rgb(0, 0, 255); - for (size_t i = 0; i < count; i++) { - bool side = (i & 1) ^ phase; - out[i] = side ? red : blue; - } + bool side = (pixIndex(arg) & 1) ^ phase; + out[0] = side ? ffx_color_rgb(255, 0, 0) : ffx_color_rgb(0, 0, 255); } static void animMatrix(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { - int32_t head = scalarfx((int32_t)(count * 2), t); - for (size_t i = 0; i < count; i++) { - int32_t dist = (head - (int32_t)i + (int32_t)(count * 2)) - % (int32_t)(count * 2); - int32_t v = (dist < (int32_t)count) - ? (MAX_VAL - dist * (MAX_VAL / (int32_t)count)) - : 0; - if (v < 0) { v = 0; } - out[i] = ffx_color_hsv(180, MAX_SAT, v); - } + int i = pixIndex(arg); + int32_t head = scalarfx(LED_COUNT * 2, t); + int32_t dist = (head - i + LED_COUNT * 2) % (LED_COUNT * 2); + int32_t v = (dist < LED_COUNT) + ? (MAX_VAL - dist * (MAX_VAL / LED_COUNT)) + : 0; + if (v < 0) { v = 0; } + out[0] = ffx_color_hsv(180, MAX_SAT, v); } static const PixelAnimationFunc modeFuncs[MODE_COUNT] = { @@ -127,9 +125,11 @@ static const PixelAnimationFunc modeFuncs[MODE_COUNT] = { static void applyMode(LedsState *state) { - pixels_stopAnimation(pixels, 0); - pixels_animate(pixels, modeFuncs[state->mode], modeDurations[state->mode], - 0, NULL); + PixelAnimationFunc fn = modeFuncs[state->mode]; + uint32_t dur = modeDurations[state->mode]; + for (int i = 0; i < LED_COUNT; i++) { + pixels_animatePixel(pixels, i, fn, dur, 0, (void*)(uintptr_t)i); + } ffx_sceneLabel_setText(state->modeLabel, modeNames[state->mode]); ffx_sceneLabel_setText(state->descLabel, modeDescs[state->mode]); From 75e3476a0d875494a3a6f0e22cc1943a9ff5874c Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 12:46:06 +0200 Subject: [PATCH 18/18] feat(leds): infinite loop + 5 new modes (Fire, Comet, Ocean, Neon, Sparkle) * Pass repeat=1 (not 0) to pixels_animatePixel. The implementation treats repeat as a boolean - 0 was one-shot, so modes were running for one duration cycle and then freezing on the last frame. Confirmed at firefly-hollows/src/pixels.c:381: context->actions[pixel].type = repeat ? AnimationTypeRepeat : AnimationTypeNormal; Counter-intuitive naming, but pinning the API. * Add 5 new modes: - FIRE - red/orange flame flicker via per-pixel hash - COMET - bright yellow head chases around the 4-LED ring - OCEAN - blue/cyan ripple, slow phase-offset per LED - NEON - rotating magenta/cyan/yellow/green palette - SPARKLE - random twinkle, ~20% on at any time 12 modes total. Index dots shrunk to 14px to fit the wider strip. --- main/panel-leds.c | 122 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 101 insertions(+), 21 deletions(-) diff --git a/main/panel-leds.c b/main/panel-leds.c index 8b1d63a..0ae8197 100644 --- a/main/panel-leds.c +++ b/main/panel-leds.c @@ -1,7 +1,8 @@ // LED mode picker. Drives the 4 WS2812B LEDs via firefly-hollows' -// internal pixels API. Each mode is a PixelAnimationFunc kicked off -// with pixels_animate(); the most recently selected mode keeps -// running after the panel is popped. +// internal pixels API. Each mode is a PixelAnimationFunc bound to +// each pixel via pixels_animatePixel; the most recently selected +// mode loops forever in the IO task and persists after the panel +// is popped. #include #include @@ -15,7 +16,7 @@ #include "utils.h" -#define MODE_COUNT (7) +#define MODE_COUNT (12) #define LED_COUNT (4) @@ -23,10 +24,12 @@ // component). Forward-declare what we need; the linker resolves to // the same symbols task-io.c uses. // -// Note: the public-looking pixels_animate (all-pixels) and -// pixels_stopAnimation are declared in pixels.h but not actually -// implemented in pixels.c at this commit, so we use the per-pixel -// pixels_animatePixel instead and pass the pixel index via `arg`. +// Note: pixels.h declares pixels_animate and pixels_stopAnimation but +// pixels.c at the pinned commit only implements pixels_animatePixel. +// Animation functions write only out[0] - the pixel index travels +// through `arg`. Pass repeat=1 so the animation loops forever +// (repeat=0 in the implementation means one-shot, which is the +// opposite of what the parameter name suggests). typedef void* PixelsContext; typedef void (*PixelAnimationFunc)(color_ffxt *output, size_t count, fixed_ffxt t, void *arg); @@ -49,27 +52,43 @@ typedef struct LedsState { static const char *modeNames[MODE_COUNT] = { - "OFF", "CYAN", "RAINBOW", "PULSE", "STROBE", "POLICE", "MATRIX" + "OFF", "CYAN", "RAINBOW", "PULSE", + "STROBE", "POLICE", "MATRIX", "FIRE", + "COMET", "OCEAN", "NEON", "SPARKLE" }; static const char *modeDescs[MODE_COUNT] = { "all off", "solid neon cyan", "cycling hues", - "slow breathing", + "violet breathing", "1 Hz white flash", "alternating red/blue", - "green wave" + "green wave", + "red/orange flicker", + "yellow chaser", + "blue/cyan ripple", + "magenta/cyan/yellow", + "random twinkle" }; static const uint32_t modeDurations[MODE_COUNT] = { - 1000, 1000, 4000, 1800, 1000, 800, 1200 + 1000, // OFF + 1000, // CYAN + 4000, // RAINBOW + 1800, // PULSE + 1000, // STROBE + 800, // POLICE + 1200, // MATRIX + 400, // FIRE + 900, // COMET + 3000, // OCEAN + 2000, // NEON + 600 // SPARKLE }; -// Each animation writes only out[0] - pixels_animatePixel binds one -// LED at a time, and the pixel index travels through `arg`. - static int pixIndex(void *arg) { return (int)(uintptr_t)arg; } + static void animOff(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { out[0] = COLOR_BLACK; } @@ -118,17 +137,77 @@ static void animMatrix(color_ffxt *out, size_t count, fixed_ffxt t, out[0] = ffx_color_hsv(180, MAX_SAT, v); } +// Cheap per-pixel pseudo-random hash for organic effects. +static uint32_t hash32(uint32_t x) { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + return x ? x : 0xA5C0FFEE; +} + +static void animFire(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { + int i = pixIndex(arg); + uint32_t phase = (uint32_t)(t >> 11); + uint32_t s = hash32(phase * 31u + (uint32_t)i * 0x9E3779B9u); + int32_t hue = (int32_t)(s % 350); + int32_t v = MAX_VAL - (int32_t)(s & 0x1f); + if (v < 12) { v = 12; } + out[0] = ffx_color_hsv(hue, MAX_SAT, v); +} + +static void animComet(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { + int i = pixIndex(arg); + int32_t head = scalarfx(LED_COUNT, t); + int32_t dist = (head - i + LED_COUNT) % LED_COUNT; + int32_t v; + switch (dist) { + case 0: v = MAX_VAL; break; + case 1: v = MAX_VAL / 3; break; + case 2: v = MAX_VAL / 8; break; + default: v = 0; break; + } + out[0] = ffx_color_hsv(660, MAX_SAT, v); +} + +static void animOcean(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { + int i = pixIndex(arg); + int32_t base = scalarfx(700, t) + i * 200; + int32_t hue = 1980 + (base % 700); + out[0] = ffx_color_hsv(hue, MAX_SAT, MAX_VAL - 8); +} + +static void animNeon(color_ffxt *out, size_t count, fixed_ffxt t, void *arg) { + int i = pixIndex(arg); + int phase = (int)((t * 4) >> 16); + static const int32_t hues[4] = { 3300, 1980, 660, 1320 }; + int32_t hue = hues[(phase + i) & 3]; + out[0] = ffx_color_hsv(hue, MAX_SAT, MAX_VAL); +} + +static void animSparkle(color_ffxt *out, size_t count, fixed_ffxt t, + void *arg) { + int i = pixIndex(arg); + uint32_t phase = (uint32_t)(t >> 13); + uint32_t s = hash32(phase * 17u + (uint32_t)i * 0x85EBCA77u); + bool on = (s & 0xf) < 3; + int32_t hue = (int32_t)(s % 3960); + out[0] = on ? ffx_color_hsv(hue, MAX_SAT, MAX_VAL) : COLOR_BLACK; +} + + static const PixelAnimationFunc modeFuncs[MODE_COUNT] = { - animOff, animCyan, animRainbow, animPulse, - animStrobe, animPolice, animMatrix + animOff, animCyan, animRainbow, animPulse, + animStrobe, animPolice, animMatrix, animFire, + animComet, animOcean, animNeon, animSparkle }; static void applyMode(LedsState *state) { PixelAnimationFunc fn = modeFuncs[state->mode]; uint32_t dur = modeDurations[state->mode]; + // repeat=1 means loop forever (repeat=0 is one-shot in this impl) for (int i = 0; i < LED_COUNT; i++) { - pixels_animatePixel(pixels, i, fn, dur, 0, (void*)(uintptr_t)i); + pixels_animatePixel(pixels, i, fn, dur, 1, (void*)(uintptr_t)i); } ffx_sceneLabel_setText(state->modeLabel, modeNames[state->mode]); @@ -178,14 +257,15 @@ static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { FfxTextAlignCenter | FfxTextAlignMiddle); ffx_sceneLabel_setOutlineColor(state->titleLabel, COLOR_BLACK); - int dotW = 20; + int dotW = 14; int dotH = 6; - int totalW = MODE_COUNT * dotW + (MODE_COUNT - 1) * 4; + int gap = 4; + int totalW = MODE_COUNT * dotW + (MODE_COUNT - 1) * gap; int x0 = (240 - totalW) / 2; for (int i = 0; i < MODE_COUNT; i++) { FfxNode dot = ffx_scene_createBox(scene, ffx_size(dotW, dotH)); ffx_sceneGroup_appendChild(panel, dot); - ffx_sceneNode_setPosition(dot, ffx_point(x0 + i * (dotW + 4), 56)); + ffx_sceneNode_setPosition(dot, ffx_point(x0 + i * (dotW + gap), 56)); state->dots[i] = dot; }