diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..b5dd0ef --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Convert & Upload Icons", + "type": "shell", + "command": "pwsh", + "args": [ + "-NoProfile", + "-Command", + "python tools/convert_icons.py --src assets/icons_src --dst data/icons --size 48 48; pio run -t uploadfs" + ], + "isBackground": false, + "problemMatcher": [], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..94aa710 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# ESP32 ST7735 UI Framework (Lightweight VDOM + Tween) + +This project now includes a tiny UI layer that renders a display list to the ST7735 display and supports non-linear animations via cubic-bezier easing. + +## Files +- `include/ui.h`, `src/ui.cpp`: UI framework (DisplayList, Tween, Icon loader) +- `tools/convert_icons.py`: Convert PNG icons to RGB565 raw files with a 4-byte header + +## Icons pipeline +1. Put PNG source icons at `assets/icons_src/`: + - `timer.png` + - `web.png` + - `game.png` +2. Convert to RGB565 raw files and write into LittleFS data folder: + +```pwsh +# Install pillow once (Windows PowerShell) +python -m pip install pillow + +# Convert icons (48x48 by default) +python tools/convert_icons.py --src assets/icons_src --dst data/icons --size 48 48 + +# Upload LittleFS +pio run -t uploadfs +``` + +The output files are: +- `data/icons/timer.r565` +- `data/icons/web.r565` +- `data/icons/game.r565` + +File format: little-endian header `[uint16 width][uint16 height]` followed by `width*height*2` bytes of RGB565 pixels. + +## Main screen carousel +- Three icons (Timer, Web, Game) +- Left/Right buttons slide between icons with an ease-in-out tween + +You can tune layout in `src/main.cpp`: +- `ICON_W`, `ICON_H`, `SPACING`, `CENTER_X`, `BASE_Y` + +## Notes & next steps +- Current renderer clears the screen per frame. If you see flicker, we can upgrade to a dirty-rectangle renderer. +- Icon draw uses `drawPixel` per pixel. For speed, we can add a windowed bulk draw. +- Tween API is minimal (one-shot). We can add callbacks and yoyo/repeat later. diff --git a/assets/icons_src/game.png b/assets/icons_src/game.png new file mode 100644 index 0000000..6381c53 Binary files /dev/null and b/assets/icons_src/game.png differ diff --git a/assets/icons_src/timer.png b/assets/icons_src/timer.png new file mode 100644 index 0000000..db1616f Binary files /dev/null and b/assets/icons_src/timer.png differ diff --git a/assets/icons_src/web.png b/assets/icons_src/web.png new file mode 100644 index 0000000..abae501 Binary files /dev/null and b/assets/icons_src/web.png differ diff --git a/data/icons/game.r565 b/data/icons/game.r565 new file mode 100644 index 0000000..893e5e2 Binary files /dev/null and b/data/icons/game.r565 differ diff --git a/data/icons/timer.r565 b/data/icons/timer.r565 new file mode 100644 index 0000000..866e09a Binary files /dev/null and b/data/icons/timer.r565 differ diff --git a/data/icons/web.r565 b/data/icons/web.r565 new file mode 100644 index 0000000..e5bccf1 Binary files /dev/null and b/data/icons/web.r565 differ diff --git a/include/game.h b/include/game.h new file mode 100644 index 0000000..20a05b3 --- /dev/null +++ b/include/game.h @@ -0,0 +1,55 @@ +#ifndef GAME_H +#define GAME_H + +#include +#include "ui.h" // for DisplayList and colors + +namespace esp32 { +namespace game { + +using namespace esp32::ui; + +// 棋盘尺寸(经典 10x20) +static const int COLS = 10; +static const int ROWS = 20; + +enum class State : uint8_t { Playing = 0, GameOver = 1 }; + +struct Piece { + uint8_t type; // 0..6 + int8_t x; // 左上角/参考点 + int8_t y; + // 仅使用一个朝向(简化:不提供旋转) + int8_t blocks[4][2]; // 4 个方块偏移 + uint16_t color; +}; + +struct Tetris { + uint8_t board[ROWS][COLS]; // 0=空,其它=颜色索引或标记 + uint16_t colors[8]; // 颜色表(index->RGB565) + Piece current; + uint32_t lastDropMs = 0; + uint32_t dropInterval = 700; // ms + State state = State::Playing; + uint32_t score = 0; + uint32_t rng = 1; +}; + +void init(Tetris& g); +void reset(Tetris& g); +void update(Tetris& g, uint32_t nowMs); + +// 输入 +void moveLeft(Tetris& g); +void moveRight(Tetris& g); +void hardDrop(Tetris& g); + +// UI 构建 +void buildScene(const Tetris& g, DisplayList& out, + int16_t screenW, int16_t screenH, + int16_t cell, int16_t originX, int16_t originY); + +} // namespace game +} // namespace esp32 + +#endif // GAME_H diff --git a/include/timer.h b/include/timer.h new file mode 100644 index 0000000..16f7eeb --- /dev/null +++ b/include/timer.h @@ -0,0 +1,30 @@ +#ifndef TIMER_H +#define TIMER_H + +#include +#include "ui.h" + +namespace esp32 { +namespace timer { + +struct ClockState { + uint32_t startMs = 0; // 起始时间 + uint32_t elapsedMs = 0; // 当前经过时间 + static const int MAX_LAPS = 12; + uint16_t lapCount = 0; + float lapAngles[MAX_LAPS]; // 存储圈记录的角度(秒针位置) + uint32_t lapMs[MAX_LAPS]; // 每次记录的时间戳 (ms) + bool running = true; +}; + +void init(ClockState& s); +void reset(ClockState& s); +void update(ClockState& s, uint32_t nowMs); +void addLap(ClockState& s); +void buildScene(const ClockState& s, esp32::ui::DisplayList& out, + int16_t screenW, int16_t screenH); + +} // namespace timer +} // namespace esp32 + +#endif // TIMER_H diff --git a/include/ui.h b/include/ui.h new file mode 100644 index 0000000..046821f --- /dev/null +++ b/include/ui.h @@ -0,0 +1,191 @@ +#ifndef UI_H +#define UI_H + +#include +#include +#include +#include +#include +#include "utils.h" + +namespace esp32 { +namespace ui { + +// 简易图标:RGB565 像素数组,内存持有 +struct Icon { + uint16_t width = 0; + uint16_t height = 0; + uint16_t* pixels = nullptr; // width*height 大小的 RGB565 数组 +}; + +// 显示操作类型 +enum class OpType : uint8_t { FillRect = 0, DrawText = 1, DrawIcon = 2, Line = 3, Circle = 4 }; + +// 显示项(Display List 条目) +struct DisplayItem { + OpType op; + uint16_t key; // 用于跨帧识别(简化版,当前全量重放) + int16_t x; + int16_t y; + int16_t w; // FillRect 使用 或 Line/Circle 半径/终点 x + int16_t h; // FillRect 使用 或 Line 终点 y + uint16_t color; // FillRect/DrawText 使用 + const char* text; // 指向 textBuf 或字符串字面量 + const Icon* icon; // DrawIcon 使用 + char textBuf[64]; // 小型文本缓冲,存放动态生成的字符串(例如时间、lap) +}; + +// 固定容量的显示列表 +class DisplayList { + public: + static const int MAX_ITEMS = 256; + DisplayList() : _count(0) {} + + void clear() { _count = 0; } + int size() const { return _count; } + const DisplayItem& at(int i) const { return _items[i]; } + + bool addFillRect(uint16_t key, int16_t x, int16_t y, int16_t w, int16_t h, uint16_t color) { + if (_count >= MAX_ITEMS) return false; + auto& it = _items[_count++]; + it.op = OpType::FillRect; + it.key = key; + it.x = x; it.y = y; it.w = w; it.h = h; it.color = color; + it.text = nullptr; it.icon = nullptr; + return true; + } + + bool addText(uint16_t key, int16_t x, int16_t y, uint16_t color, const char* text, bool centered = false) { + if (_count >= MAX_ITEMS) return false; + auto& it = _items[_count++]; + it.op = OpType::DrawText; + it.key = key; + it.x = x; it.y = y; + it.w = centered ? 1 : 0; // w=1 表示需要居中 + it.h = 0; + it.color = color; + // 复制文本到缓冲,保证作用域结束后仍可用 + if (text) { + strncpy(it.textBuf, text, sizeof(it.textBuf) - 1); + it.textBuf[sizeof(it.textBuf) - 1] = '\0'; + it.text = it.textBuf; + } else { + it.textBuf[0] = '\0'; + it.text = it.textBuf; + } + it.icon = nullptr; + return true; + } + + bool addIcon(uint16_t key, int16_t x, int16_t y, const Icon* icon) { + if (_count >= MAX_ITEMS) return false; + auto& it = _items[_count++]; + it.op = OpType::DrawIcon; + it.key = key; + it.x = x; it.y = y; it.w = 0; it.h = 0; it.color = 0; + it.text = nullptr; it.icon = icon; + return true; + } + + bool addLine(uint16_t key, int16_t x1, int16_t y1, int16_t x2, int16_t y2, uint16_t color) { + if (_count >= MAX_ITEMS) return false; + auto& it = _items[_count++]; + it.op = OpType::Line; + it.key = key; + it.x = x1; it.y = y1; it.w = x2; it.h = y2; it.color = color; + it.text = nullptr; it.icon = nullptr; + return true; + } + + bool addCircle(uint16_t key, int16_t cx, int16_t cy, int16_t radius, uint16_t color) { + if (_count >= MAX_ITEMS) return false; + auto& it = _items[_count++]; + it.op = OpType::Circle; + it.key = key; + it.x = cx; it.y = cy; it.w = radius; it.h = 0; it.color = color; + it.text = nullptr; it.icon = nullptr; + return true; + } + + private: + int _count; + DisplayItem _items[MAX_ITEMS]; +}; + +// Tween(补间动画) +struct Tween { + float* target = nullptr; + float from = 0.0f; + float to = 0.0f; + uint32_t startMs = 0; + uint32_t durationMs = 0; + float x1 = 0.42f, y1 = 0.0f, x2 = 0.58f, y2 = 1.0f; // 贝塞尔参数 + bool active = false; +}; + +// UI 框架主体 +class UI { + public: + UI() : _tft(nullptr), _u8g2(nullptr), _builder(nullptr), _builderCtx(nullptr) {} + + void begin(Adafruit_ST7735* tft, U8G2_FOR_ADAFRUIT_GFX* u8g2 = nullptr) { + _tft = tft; + _u8g2 = u8g2; + } + + // builder: 负责把状态转为显示列表 + using BuilderFn = void (*)(void* ctx, DisplayList& out); + void setScene(BuilderFn builder, void* ctx) { + _builder = builder; + _builderCtx = ctx; + } + + // 每帧调用:更新动画 + 生成显示列表 + 渲染 + void tick(uint32_t nowMs); + + // 添加一个 float 补间(从当前值到目标值) + int addTweenFloat(float* target, float to, uint32_t durationMs, + float x1 = 0.42f, float y1 = 0.0f, float x2 = 0.58f, float y2 = 1.0f); + + bool hasActiveTweens() const; + bool isTweenActive(int id) const; + + // 图标加载:文件格式 (little-endian): [2字节宽][2字节高][像素: width*height*2] + static bool loadIconFromFS(const char* path, Icon& out); + static void freeIcon(Icon& icon); + + // 启用内存帧缓冲(双缓冲),第一次调用会分配两块 16 位色的画布 + bool enableBackBuffer(uint16_t width, uint16_t height); + bool isBackBufferEnabled() const { return _useBackBuffer; } + + private: + void _updateTweens(uint32_t nowMs); + void _render(const DisplayList& list); + void _drawIcon(int16_t x, int16_t y, const Icon& icon); + void _renderToCanvas(const DisplayList& list); // 使用内存画布绘制 + void _flushCanvas(); // 将当前画布推送到屏幕 + void _swapCanvas(); // 交换前后缓冲 + + Adafruit_ST7735* _tft; + U8G2_FOR_ADAFRUIT_GFX* _u8g2; + BuilderFn _builder; + void* _builderCtx; + DisplayList _list; + + static const int MAX_TWEENS = 8; + Tween _tweens[MAX_TWEENS]; + + // 双缓冲相关 + bool _useBackBuffer = false; + GFXcanvas16* _canvasA = nullptr; // 当前绘制目标 + GFXcanvas16* _canvasB = nullptr; // 上一帧 + GFXcanvas16* _canvasCurrent = nullptr; + GFXcanvas16* _canvasPrev = nullptr; + uint16_t _bufW = 0; + uint16_t _bufH = 0; +}; + +} // namespace ui +} // namespace esp32 + +#endif // UI_H diff --git a/platformio.ini b/platformio.ini index fc8bfa4..9508649 100644 --- a/platformio.ini +++ b/platformio.ini @@ -23,6 +23,7 @@ lib_deps = adafruit/Adafruit ST7735 and ST7789 Library adafruit/Adafruit GFX Library bblanchon/ArduinoJson@^7.4.2 + olikraus/U8g2_for_Adafruit_GFX upload_speed = 921600 monitor_speed = 115200 build_flags = -std=gnu++2a diff --git a/src/game.cpp b/src/game.cpp new file mode 100644 index 0000000..6d9062a --- /dev/null +++ b/src/game.cpp @@ -0,0 +1,215 @@ +#include "game.h" + +namespace esp32 { +namespace game { + +using namespace esp32::ui; +using namespace esp32::utils; + +static uint32_t xorshift32(uint32_t& s){ + uint32_t x = s; + x ^= x << 13; x ^= x >> 17; x ^= x << 5; s = x; return x; +} + +static uint8_t rand7(uint32_t& s){ return (uint8_t)(xorshift32(s) % 7); } + +static void setPieceShape(Piece& p){ + // 定义 7 种方块在默认朝向下的 4 个格子偏移 + // 坐标以 (0,0) 为参考,落点左上角附近 + switch (p.type){ + case 0: // I 形: 水平 + p.blocks[0][0] = -1; p.blocks[0][1] = 0; + p.blocks[1][0] = 0; p.blocks[1][1] = 0; + p.blocks[2][0] = 1; p.blocks[2][1] = 0; + p.blocks[3][0] = 2; p.blocks[3][1] = 0; break; + case 1: // O 方块 + p.blocks[0][0] = 0; p.blocks[0][1] = 0; + p.blocks[1][0] = 1; p.blocks[1][1] = 0; + p.blocks[2][0] = 0; p.blocks[2][1] = 1; + p.blocks[3][0] = 1; p.blocks[3][1] = 1; break; + case 2: // T + p.blocks[0][0] = -1; p.blocks[0][1] = 0; + p.blocks[1][0] = 0; p.blocks[1][1] = 0; + p.blocks[2][0] = 1; p.blocks[2][1] = 0; + p.blocks[3][0] = 0; p.blocks[3][1] = 1; break; + case 3: // L + p.blocks[0][0] = -1; p.blocks[0][1] = 0; + p.blocks[1][0] = 0; p.blocks[1][1] = 0; + p.blocks[2][0] = 1; p.blocks[2][1] = 0; + p.blocks[3][0] = 1; p.blocks[3][1] = 1; break; + case 4: // J + p.blocks[0][0] = -1; p.blocks[0][1] = 0; + p.blocks[1][0] = 0; p.blocks[1][1] = 0; + p.blocks[2][0] = 1; p.blocks[2][1] = 0; + p.blocks[3][0] = -1; p.blocks[3][1] = 1; break; + case 5: // S + p.blocks[0][0] = 0; p.blocks[0][1] = 0; + p.blocks[1][0] = 1; p.blocks[1][1] = 0; + p.blocks[2][0] = -1; p.blocks[2][1] = 1; + p.blocks[3][0] = 0; p.blocks[3][1] = 1; break; + case 6: // Z + p.blocks[0][0] = -1; p.blocks[0][1] = 0; + p.blocks[1][0] = 0; p.blocks[1][1] = 0; + p.blocks[2][0] = 0; p.blocks[2][1] = 1; + p.blocks[3][0] = 1; p.blocks[3][1] = 1; break; + } +} + +static bool collide(const Tetris& g, const Piece& p, int nx, int ny){ + for (int i=0;i<4;++i){ + int cx = nx + p.blocks[i][0]; + int cy = ny + p.blocks[i][1]; + if (cx < 0 || cx >= COLS || cy >= ROWS) return true; + if (cy >= 0 && g.board[cy][cx] != 0) return true; + } + return false; +} + +static void spawnPiece(Tetris& g){ + g.current.type = rand7(g.rng); + g.current.x = COLS/2 - 1; + g.current.y = -1; // 允许出屏生成 + setPieceShape(g.current); + // 颜色:从表里取 1..7 + g.current.color = g.colors[1 + (g.current.type % 7)]; + if (collide(g, g.current, g.current.x, g.current.y+1)){ + g.state = State::GameOver; + } +} + +static void lockPiece(Tetris& g){ + for (int i=0;i<4;++i){ + int cx = g.current.x + g.current.blocks[i][0]; + int cy = g.current.y + g.current.blocks[i][1]; + if (cy>=0 && cy=0 && cx=0; --y){ + bool full = true; + for (int x=0; x0; --yy){ + for (int x=0;x0){ g.score += cleared * 100; } + return cleared; +} + +void init(Tetris& g){ + // 初始化颜色表 + g.colors[0] = 0; // 未用 + g.colors[1] = WHITE; + g.colors[2] = rgbTo565(0x00FFFF); // CYAN + g.colors[3] = YELLOW; + g.colors[4] = GREEN; + g.colors[5] = BLUE; + g.colors[6] = RED; + g.colors[7] = rgbTo565(0xFF00FF); // MAGENTA + reset(g); +} + +void reset(Tetris& g){ + for (int y=0;y= g.dropInterval){ + g.lastDropMs = nowMs; + if (!collide(g, g.current, g.current.x, g.current.y+1)){ + g.current.y += 1; + } else { + lockPiece(g); + clearLines(g); + spawnPiece(g); + } + } +} + +void moveLeft(Tetris& g){ + if (g.state != State::Playing) return; + if (!collide(g, g.current, g.current.x-1, g.current.y)){ + g.current.x -= 1; + } +} + +void moveRight(Tetris& g){ + if (g.state != State::Playing) return; + if (!collide(g, g.current, g.current.x+1, g.current.y)){ + g.current.x += 1; + } +} + +void hardDrop(Tetris& g){ + if (g.state != State::Playing) return; + while (!collide(g, g.current, g.current.x, g.current.y+1)){ + g.current.y += 1; + } + lockPiece(g); + clearLines(g); + spawnPiece(g); +} + +void buildScene(const Tetris& g, DisplayList& out, + int16_t screenW, int16_t screenH, + int16_t cell, int16_t originX, int16_t originY){ + // 边框 + out.addFillRect(900, originX-2, originY-2, COLS*cell+4, ROWS*cell+4, WHITE); + out.addFillRect(901, originX-1, originY-1, COLS*cell+2, ROWS*cell+2, BLACK); + + // 棋盘格子 + for (int y=0; y=0){ + out.addFillRect(3000 + i, originX + cx*cell, originY + cy*cell, cell-1, cell-1, g.current.color); + } + } + } + + // 顶部文字:Score + char buf[24]; + snprintf(buf, sizeof(buf), "Score: %lu", (unsigned long)g.score); + out.addText(8000, 4, 4, WHITE, buf); + + if (g.state == State::GameOver){ + out.addText(8001, 8, screenH - 24, YELLOW, "Game Over"); + out.addText(8002, 8, screenH - 14, WHITE, "Left: Home Right: Retry"); + } else { + out.addText(8003, 8, screenH - 14, WHITE, "Up: Drop Left/Right: Move"); + } +} + +} // namespace game +} // namespace esp32 diff --git a/src/main.cpp b/src/main.cpp index 554e706..c0f7e4a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,14 @@ #include #include #include -#include "utils.h" +#include +#include "game.h" #include "init.h" +#include "timer.h" +#include "ui.h" +#include "utils.h" +U8G2_FOR_ADAFRUIT_GFX u8g2; // 给 Adafruit_GFX 套一层 U8g2 的“文字引擎” using namespace esp32; #define TFT_MOSI 16 @@ -15,35 +20,102 @@ using namespace esp32; // 屏幕尺寸为 128x160 Adafruit_ST7735 tft(TFT_CS, TFT_DC, TFT_RST); -#define function auto -#define let auto -function rgbTo565(uint32_t rgbColor) -> uint16_t { - uint8_t r = (rgbColor >> 16) & 0xFF; - uint8_t g = (rgbColor >> 8) & 0xFF; - uint8_t b = rgbColor & 0xFF; - - // 交换 r 和 b - uint8_t tmp = r; - r = b; - b = tmp; - - uint16_t r5 = (r * 31 / 255) << 11; - uint16_t g6 = (g * 63 / 255) << 5; - uint16_t b5 = (b * 31 / 255); - - return r5 | g6 | b5; -} +using namespace esp32::utils; +using namespace esp32::ui; const int BTN_LEFT = 12; // 左按钮 const int BTN_RIGHT = 11; // 右按钮 const int BTN_UP = 10; // 上按钮 -#define BLACK rgbTo565(0x000000) -#define WHITE rgbTo565(0xFFFFFF) -#define RED rgbTo565(0xFF0000) -#define GREEN rgbTo565(0x00FF00) -#define BLUE rgbTo565(0x0000FF) -#define YELLOW rgbTo565(0xFFFF00) +// 颜色宏兼容已由 utils.h 提供 + +// 应用状态(主屏轮播) +struct AppState { + // 轮播:当前索引与动画偏移(像素) + int currentIndex = 0; // 0: Timer, 1: Web, 2: Game + float carouselOffset = 0.0f; // 动画帧间偏移,完成后归零 + int targetIndex = -1; // 动画结束时要切换到的索引(无动画=-1) + + // 图标资源 + Icon iconTimer; + Icon iconWeb; + Icon iconGame; + + // 场景管理 + enum class Scene : uint8_t { Home = 0, Timer = 1, Game = 2 }; + Scene scene = Scene::Home; + game::Tetris tetris; + timer::ClockState clock; +}; + +static UI appUI; +static AppState app; +static bool prevLeft = false; +static bool prevRight = false; +static bool prevUp = false; + +// 布局参数 +static const int SCREEN_W = 128; +static const int SCREEN_H = 160; +static const int ICON_W = 48; // 图标宽度 +static const int ICON_H = 48; // 图标高度 +static const int SPACING = 72; // 图标中心间距(略大于宽度,保证边缘留白) +static const int CENTER_X = (SCREEN_W - ICON_W) / 2; // 中心图标左上角 X +static const int BASE_Y = 40; // 图标顶端 Y(上方留空间给标题) +static const int LABEL_MARGIN = 12; // 图标与文字竖向间距 + +static const int CHAR_W = 14; +static const int CHAR_H = 14; + +// 根据状态输出显示列表 +static void buildScene(void* ctx, DisplayList& out) { + AppState* s = (AppState*)ctx; + if (s->scene == AppState::Scene::Home) { + // 顶部标题居中 - 支持中文 + const char* title = "主菜单"; // 中文标题 + out.addText(100, SCREEN_W / 2, 8, WHITE, title, true); // 使用居中 + + // 图标与文字 + for (int i = 0; i < 3; ++i) { + int rel = i - s->currentIndex; // 相对索引 + int x = CENTER_X + rel * SPACING + (int)(s->carouselOffset); + if (x + ICON_W < 0 || x > SCREEN_W) + continue; + const Icon* ic = nullptr; + const char* label = ""; + switch (i) { + case 0: + ic = &s->iconTimer; + label = "计时器"; // 中文标签 + break; + case 1: + ic = &s->iconWeb; + label = "网页"; // 中文标签 + break; + case 2: + ic = &s->iconGame; + label = "游戏"; // 中文标签 + break; + } + out.addIcon(10 + i, x, BASE_Y, ic); + // 标签居中显示 + int labelX = x + ICON_W / 2; // 图标中心点 + int labelY = BASE_Y + ICON_H + LABEL_MARGIN; + out.addText(200 + i, labelX, labelY, WHITE, label, true); // 使用居中 + } + } else if (s->scene == AppState::Scene::Game) { + // 游戏画面:棋盘居中 + const int cell = 6; + int boardW = game::COLS * cell; + int boardH = game::ROWS * cell; + int originX = (SCREEN_W - boardW) / 2; + int originY = 16; + game::buildScene(s->tetris, out, SCREEN_W, SCREEN_H, cell, originX, + originY); + } else if (s->scene == AppState::Scene::Timer) { + timer::buildScene(s->clock, out, SCREEN_W, SCREEN_H); + } +} void setup() { // 按钮:内部下拉 @@ -60,11 +132,38 @@ void setup() { tft.setRotation(0); tft.fillScreen(BLACK); tft.setCursor(0, 0); + // ★ 关键:把 u8g2 挂到 tft 上 + u8g2.begin(tft); + // 连接到 Adafruit_GFX :contentReference[oaicite:2]{index=2} + u8g2.setFontMode(1); // 透明背景(默认也是 1) + u8g2.setFontDirection(0); // 正常从左到右 + u8g2.setForegroundColor(WHITE); // 文字颜色 + + // 使用更小的中文字体 + // u8g2_font_wqy12_t_chinese1: 12像素,常用汉字(约2500字) + // u8g2_font_wqy12_t_chinese2: 12像素,更多汉字(约6000字) + // u8g2_font_wqy12_t_chinese3: 12像素,完整汉字(约9000字) + u8g2.setFont(u8g2_font_wqy12_t_chinese3); // 12像素字体,比 unifont 小 + tft.setTextColor(WHITE); tft.setTextSize(1); // 初始化系统(读取配置并连接 WiFi) init::initSystem(tft); + + // UI 初始化,传入 u8g2 以支持中文 + appUI.begin(&tft, &u8g2); + // 启用内存帧缓冲,避免闪烁(若内存不足会返回 false) + appUI.enableBackBuffer(SCREEN_W, SCREEN_H); + + // 加载图标(LittleFS: /icons/*.r565,格式: [w][h][pixels]) + // 若不存在,将在渲染时以蓝色占位矩形显示 + UI::loadIconFromFS("/icons/timer.r565", app.iconTimer); + UI::loadIconFromFS("/icons/web.r565", app.iconWeb); + UI::loadIconFromFS("/icons/game.r565", app.iconGame); + + // 绑定场景构建器 + appUI.setScene(buildScene, &app); } void loop() { @@ -72,7 +171,87 @@ void loop() { bool rightPressed = digitalRead(BTN_RIGHT) == HIGH; bool upPressed = digitalRead(BTN_UP) == HIGH; - tft.fillRect(0, 30, 20, 20, leftPressed ? RED : BLACK); - tft.fillRect(108, 30, 20, 20, rightPressed ? RED : BLACK); - tft.fillRect(54, 0, 20, 20, upPressed ? RED : BLACK); + if (app.scene == AppState::Scene::Home) { + // 主页:左右滑动选择,Up 进入游戏 + if (leftPressed && !prevLeft && !appUI.hasActiveTweens()) { + if (app.currentIndex > 0 && app.targetIndex == -1) { + app.targetIndex = app.currentIndex - 1; + app.carouselOffset = 0.0f; + appUI.addTweenFloat(&app.carouselOffset, (float)SPACING, 300); + } + } + if (rightPressed && !prevRight && !appUI.hasActiveTweens()) { + if (app.currentIndex < 2 && app.targetIndex == -1) { + app.targetIndex = app.currentIndex + 1; + app.carouselOffset = 0.0f; + appUI.addTweenFloat(&app.carouselOffset, -(float)SPACING, 300); + } + } + // 进入计时器:index==0 + if (upPressed && !prevUp && app.currentIndex == 0) { + timer::init(app.clock); + app.scene = AppState::Scene::Timer; + } + // 进入游戏:index==2 + if (upPressed && !prevUp && app.currentIndex == 2) { + game::init(app.tetris); + app.scene = AppState::Scene::Game; + } + } else { + if (app.scene == AppState::Scene::Game) { + // 游戏内:左右移动,Up 硬降;GameOver 时 左=返回主页,右=重玩 + if (leftPressed && !prevLeft) { + if (app.tetris.state == game::State::GameOver) { + app.scene = AppState::Scene::Home; + app.targetIndex = -1; + } else { + game::moveLeft(app.tetris); + } + } + if (rightPressed && !prevRight) { + if (app.tetris.state == game::State::GameOver) { + game::reset(app.tetris); + } else { + game::moveRight(app.tetris); + } + } + if (upPressed && !prevUp) { + if (app.tetris.state == game::State::Playing) { + game::hardDrop(app.tetris); + } + } + game::update(app.tetris, millis()); + } else if (app.scene == AppState::Scene::Timer) { + // 计时器:Left 返回主页,Right 重置,Up 增加 lap 标记 + if (leftPressed && !prevLeft) { + app.scene = AppState::Scene::Home; + app.targetIndex = -1; + } + if (rightPressed && !prevRight) { + timer::reset(app.clock); + } + if (upPressed && !prevUp) { + timer::addLap(app.clock); + } + timer::update(app.clock, millis()); + } + } + + prevLeft = leftPressed; + prevRight = rightPressed; + prevUp = upPressed; + + // 驱动 UI 帧刷新 + appUI.tick(millis()); + + // 动画结束:如果没有活动 tween 且存在 targetIndex,则完成索引切换并重置偏移 + if (app.scene == AppState::Scene::Home) { + if (!appUI.hasActiveTweens() && app.targetIndex != -1) { + app.currentIndex = app.targetIndex; + app.targetIndex = -1; + app.carouselOffset = 0.0f; + } + } + + delay(16); // 简单节流 ~60FPS } diff --git a/src/timer.cpp b/src/timer.cpp new file mode 100644 index 0000000..3e4168d --- /dev/null +++ b/src/timer.cpp @@ -0,0 +1,139 @@ +#include "timer.h" +#include "utils.h" +#include + +namespace esp32 { +namespace timer { + +using namespace esp32::ui; +using namespace esp32::utils; + +static float deg2rad(float d){ return d * 3.1415926f / 180.0f; } +static const int CHAR_W = 6; +static const int CHAR_H = 8; +static const uint16_t CYAN_COLOR = rgbTo565(0x00FFFF); + +void init(ClockState& s){ + s.startMs = millis(); + s.elapsedMs = 0; + s.lapCount = 0; + s.running = true; +} + +void reset(ClockState& s){ + s.startMs = millis(); + s.elapsedMs = 0; + s.lapCount = 0; + s.running = true; +} + +void update(ClockState& s, uint32_t nowMs){ + if (s.running){ + s.elapsedMs = nowMs - s.startMs; + } +} + +void addLap(ClockState& s){ + if (s.lapCount >= ClockState::MAX_LAPS) return; + // 记录当前秒针角度(0-60秒 -> 0-360度) + uint32_t sec = (s.elapsedMs / 1000) % 60; + float angleDeg = (sec / 60.0f) * 360.0f; + s.lapAngles[s.lapCount] = angleDeg; + s.lapMs[s.lapCount] = s.elapsedMs; + s.lapCount++; +} + +void buildScene(const ClockState& s, DisplayList& out, + int16_t screenW, int16_t screenH){ + // 标题 + out.addText(6000, 4, 4, WHITE, "Timer"); + // 时间文本 mm:ss.cc (cc=厘秒) + uint32_t totalCs = s.elapsedMs / 10; + uint32_t totalSec = totalCs / 100; + uint32_t mm = totalSec / 60; + uint32_t ss = totalSec % 60; + uint32_t cs = totalCs % 100; + char buf[16]; + snprintf(buf, sizeof(buf), "%02lu:%02lu.%02lu", (unsigned long)mm, (unsigned long)ss, (unsigned long)cs); + // 背景擦除该区域避免残影 + // out.addFillRect(5999, 0, 14, 72, 9, BLACK); + out.addText(6001, 4, 14, YELLOW, buf); + + // 钟表圆心与半径 + int16_t radius = 54; // 缩小一点,右侧留空间展示 laps + int16_t cx = screenW / 2 ; // 向左偏移为右侧列表腾位置 + int16_t cy = screenH / 2 + 5; // 稍微偏下 + + // 外圈 + out.addCircle(6002, cx, cy, radius, WHITE); + + // 刻度 (12 个, 每 30°),使用短线 + for (int i=0;i<12;++i){ + float ang = deg2rad(i * 30.0f - 90.0f); // 使 0 点在顶端 + int16_t x1 = cx + (int16_t)((radius - 8) * cosf(ang)); + int16_t y1 = cy + (int16_t)((radius - 8) * sinf(ang)); + int16_t x2 = cx + (int16_t)((radius - 2) * cosf(ang)); + int16_t y2 = cy + (int16_t)((radius - 2) * sinf(ang)); + out.addLine(6100 + i, x1, y1, x2, y2, WHITE); + } + + // 时针、分针、秒针角度 + float totalMinutes = s.elapsedMs / 60000.0f; + float hours = fmodf(totalMinutes / 60.0f, 12.0f); + float minutes = fmodf(totalMinutes, 60.0f); + float seconds = fmodf(s.elapsedMs / 1000.0f, 60.0f); + + float hourDeg = (hours / 12.0f) * 360.0f - 90.0f; + float minuteDeg = (minutes / 60.0f) * 360.0f - 90.0f; + float secondDeg = (seconds / 60.0f) * 360.0f - 90.0f; + + // 指针长度 + int16_t hourLen = (int16_t)(radius * 0.5f); + int16_t minuteLen = (int16_t)(radius * 0.7f); + int16_t secondLen = (int16_t)(radius * 0.85f); + + // 计算并添加指针 + auto addHand = [&](uint16_t key, float deg, int16_t len, uint16_t color){ + float rad = deg2rad(deg); + int16_t x2 = cx + (int16_t)(len * cosf(rad)); + int16_t y2 = cy + (int16_t)(len * sinf(rad)); + out.addLine(key, cx, cy, x2, y2, color); + }; + addHand(6200, hourDeg, hourLen, WHITE); + addHand(6201, minuteDeg, minuteLen, GREEN); + addHand(6202, secondDeg, secondLen, RED); + + // Laps:在圆周上标记记录点(用小线段或小圆点) + for (uint16_t i=0;i 6) visible = 6; // 最多显示 6 条 + // 显示最近的 visible 条(倒序) + for (int i = 0; i < visible; ++i) { + int idx = s.lapCount - 1 - i; + uint32_t lapCs = s.lapMs[idx] / 10; + uint32_t lapSec = lapCs / 100; + uint32_t lmm = lapSec / 60; + uint32_t lss = lapSec % 60; + uint32_t lcs = lapCs % 100; + char lapBuf[16]; + snprintf(lapBuf, sizeof(lapBuf), "%02lu:%02lu.%02lu", (unsigned long)lmm, (unsigned long)lss, (unsigned long)lcs); + out.addText(6401 + i, lapListX, lapListY + (i + 1) * CHAR_H + 2, CYAN_COLOR, lapBuf); + } + + // 底部操作提示 + out.addText(6450, 2, screenH - 12, WHITE, "Up: Lap Right: Reset Left: Home"); +} + +} // namespace timer +} // namespace esp32 diff --git a/src/ui.cpp b/src/ui.cpp new file mode 100644 index 0000000..397f233 --- /dev/null +++ b/src/ui.cpp @@ -0,0 +1,267 @@ +#include "ui.h" +#include "utils.h" + +namespace esp32 { +namespace ui { + +using namespace esp32::utils; + +bool UI::loadIconFromFS(const char* path, Icon& out) { + File f = LittleFS.open(path, "r"); + if (!f) { + return false; + } + if (f.size() < 4) { + f.close(); + return false; + } + uint8_t hdr[4]; + if (f.read(hdr, 4) != 4) { + f.close(); + return false; + } + uint16_t w = hdr[0] | (hdr[1] << 8); + uint16_t h = hdr[2] | (hdr[3] << 8); + size_t count = (size_t)w * (size_t)h; + size_t bytes = count * 2; + if (f.size() < 4 + bytes) { + f.close(); + return false; + } + uint16_t* buf = (uint16_t*)malloc(bytes); + if (!buf) { + f.close(); + return false; + } + // 文件内为 RGB565,小端,每像素2字节 + size_t read = f.read((uint8_t*)buf, bytes); + f.close(); + if (read != bytes) { + free(buf); + return false; + } + out.width = w; + out.height = h; + out.pixels = buf; + return true; +} + +void UI::freeIcon(Icon& icon) { + if (icon.pixels) { + free(icon.pixels); + icon.pixels = nullptr; + } + icon.width = icon.height = 0; +} + +int UI::addTweenFloat(float* target, float to, uint32_t durationMs, + float x1, float y1, float x2, float y2) { + for (int i = 0; i < MAX_TWEENS; ++i) { + if (!_tweens[i].active) { + _tweens[i].target = target; + _tweens[i].from = *target; + _tweens[i].to = to; + _tweens[i].startMs = millis(); + _tweens[i].durationMs = durationMs; + _tweens[i].x1 = x1; _tweens[i].y1 = y1; _tweens[i].x2 = x2; _tweens[i].y2 = y2; + _tweens[i].active = true; + return i; + } + } + return -1; +} + +bool UI::hasActiveTweens() const { + for (int i = 0; i < MAX_TWEENS; ++i) { + if (_tweens[i].active) return true; + } + return false; +} + +bool UI::isTweenActive(int id) const { + if (id < 0 || id >= MAX_TWEENS) return false; + return _tweens[id].active; +} + +void UI::_updateTweens(uint32_t nowMs) { + for (int i = 0; i < MAX_TWEENS; ++i) { + Tween& t = _tweens[i]; + if (!t.active) continue; + uint32_t elapsed = nowMs - t.startMs; + float p = 0.0f; + if (elapsed >= t.durationMs) { + p = 1.0f; + } else { + p = (float)elapsed / (float)t.durationMs; + } + float eased = linear2CubicBezier(p, t.x1, t.y1, t.x2, t.y2); + *t.target = t.from + (t.to - t.from) * eased; + if (p >= 1.0f) { + *t.target = t.to; + t.active = false; + } + } +} + +void UI::_drawIcon(int16_t x, int16_t y, const Icon& icon) { + if (!_tft || !icon.pixels) return; + // 使用 Adafruit_GFX 的批量位图绘制,显著快于逐像素 + _tft->drawRGBBitmap(x, y, icon.pixels, icon.width, icon.height); +} + +void UI::_render(const DisplayList& list) { + if (!_tft) return; + if (_useBackBuffer) { + _renderToCanvas(list); + _flushCanvas(); + _swapCanvas(); + return; + } + // 无缓冲模式:清屏 + 重放 + _tft->fillScreen(BLACK); + _tft->setTextWrap(false); + for (int i = 0; i < list.size(); ++i) { + const auto& it = list.at(i); + switch (it.op) { + case OpType::FillRect: + _tft->fillRect(it.x, it.y, it.w, it.h, it.color); + break; + case OpType::DrawText: + if (it.text) { + int16_t x = it.x; + int16_t y = it.y; + // 使用 U8g2 渲染中文(如果可用) + if (_u8g2) { + // 如果需要居中 (w=1),计算实际文本宽度 + if (it.w == 1) { + int16_t textWidth = _u8g2->getUTF8Width(it.text); + x = x - textWidth / 2; + } + // U8g2 使用基线坐标系,需要加上字体上升高度 + int16_t ascent = _u8g2->getFontAscent(); + _u8g2->setForegroundColor(it.color); + _u8g2->setCursor(x, y + ascent); + _u8g2->print(it.text); + } else { + // 降级到 Adafruit GFX + if (x >= 0 && x < _tft->width()) { + _tft->setCursor(x, y); + _tft->setTextColor(it.color); + _tft->print(it.text); + } + } + } + break; + case OpType::DrawIcon: + if (it.icon) _drawIcon(it.x, it.y, *it.icon); + break; + case OpType::Line: + _tft->drawLine(it.x, it.y, it.w, it.h, it.color); + break; + case OpType::Circle: + _tft->drawCircle(it.x, it.y, it.w, it.color); + break; + } + } +} + +bool UI::enableBackBuffer(uint16_t width, uint16_t height) { + if (_useBackBuffer) return true; // already enabled + _bufW = width; + _bufH = height; + _canvasA = new GFXcanvas16(width, height); + _canvasB = new GFXcanvas16(width, height); + if (!_canvasA || !_canvasB) { + delete _canvasA; _canvasA = nullptr; + delete _canvasB; _canvasB = nullptr; + return false; + } + _canvasA->fillScreen(BLACK); + _canvasB->fillScreen(BLACK); + _canvasCurrent = _canvasA; + _canvasPrev = _canvasB; + _useBackBuffer = true; + return true; +} + +void UI::_renderToCanvas(const DisplayList& list) { + if (!_canvasCurrent) return; + _canvasCurrent->fillScreen(BLACK); + _canvasCurrent->setTextWrap(false); + for (int i = 0; i < list.size(); ++i) { + const auto& it = list.at(i); + switch (it.op) { + case OpType::FillRect: + _canvasCurrent->fillRect(it.x, it.y, it.w, it.h, it.color); + break; + case OpType::DrawText: + if (it.text) { + int16_t x = it.x; + int16_t y = it.y; + // 使用 U8g2 渲染中文(如果可用) + // 注意:U8g2 需要先临时切换到画布,渲染后再切回屏幕 + if (_u8g2) { + // 暂时将 U8g2 指向画布 + _u8g2->begin(*_canvasCurrent); + // 如果需要居中 (w=1),计算实际文本宽度 + if (it.w == 1) { + int16_t textWidth = _u8g2->getUTF8Width(it.text); + x = x - textWidth / 2; + } + // U8g2 使用基线坐标系,需要加上字体上升高度 + int16_t ascent = _u8g2->getFontAscent(); + _u8g2->setForegroundColor(it.color); + _u8g2->setCursor(x, y + ascent); + _u8g2->print(it.text); + // 恢复到屏幕 + _u8g2->begin(*_tft); + } else { + // 降级到 Adafruit GFX + if (x >= 0 && x < _bufW) { + _canvasCurrent->setCursor(x, y); + _canvasCurrent->setTextColor(it.color); + _canvasCurrent->print(it.text); + } + } + } + break; + case OpType::DrawIcon: + if (it.icon && it.icon->pixels) { + // 在画布上绘制位图 + _canvasCurrent->drawRGBBitmap(it.x, it.y, it.icon->pixels, it.icon->width, it.icon->height); + } + break; + case OpType::Line: + _canvasCurrent->drawLine(it.x, it.y, it.w, it.h, it.color); + break; + case OpType::Circle: + _canvasCurrent->drawCircle(it.x, it.y, it.w, it.color); + break; + } + } +} + +void UI::_flushCanvas() { + if (!_tft || !_canvasCurrent) return; + // 初版:整帧推送,避免闪烁;后续可做 diff 行段优化 + _tft->drawRGBBitmap(0, 0, _canvasCurrent->getBuffer(), _bufW, _bufH); +} + +void UI::_swapCanvas() { + // 交换当前与前一帧(为后续行 diff 做准备) + GFXcanvas16* tmp = _canvasPrev; + _canvasPrev = _canvasCurrent; + _canvasCurrent = tmp; +} + +void UI::tick(uint32_t nowMs) { + _updateTweens(nowMs); + _list.clear(); + if (_builder) { + _builder(_builderCtx, _list); + } + _render(_list); +} + +} // namespace ui +} // namespace esp32 diff --git a/tools/convert_icons.py b/tools/convert_icons.py new file mode 100644 index 0000000..28c883d --- /dev/null +++ b/tools/convert_icons.py @@ -0,0 +1,67 @@ +# Convert PNG/JPG icons to custom RGB565 raw format with header +# Output file format (little-endian): [uint16 width][uint16 height][pixels: width*height*2 bytes] +# Usage: python tools/convert_icons.py --src assets/icons_src --dst data/icons --size 48 48 + +import argparse +import os +from PIL import Image + + +def rgb888_to_rgb565(r, g, b): + # Swap r/b to match display byte order used in utils::rgbTo565 + r, b = b, r + r5 = (r * 31) // 255 + g6 = (g * 63) // 255 + b5 = (b * 31) // 255 + return (r5 << 11) | (g6 << 5) | b5 + + +def convert_one(src_path, dst_path, out_w, out_h): + img = Image.open(src_path).convert('RGBA') + img = img.resize((out_w, out_h), Image.LANCZOS) + + # Premultiply against black background for simple alpha handling + bg = Image.new('RGBA', (out_w, out_h), (0, 0, 0, 255)) + img = Image.alpha_composite(bg, img) + + # Write header + pixels + os.makedirs(os.path.dirname(dst_path), exist_ok=True) + with open(dst_path, 'wb') as f: + f.write(bytes([out_w & 0xFF, (out_w >> 8) & 0xFF, out_h & 0xFF, (out_h >> 8) & 0xFF])) + px = img.load() + for y in range(out_h): + for x in range(out_w): + r, g, b, a = px[x, y] + c = rgb888_to_rgb565(r, g, b) + f.write(bytes([c & 0xFF, (c >> 8) & 0xFF])) + print(f"Converted: {src_path} -> {dst_path}") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--src', default='assets/icons_src', help='Source icon folder') + parser.add_argument('--dst', default='data/icons', help='Destination folder in LittleFS data') + parser.add_argument('--size', nargs=2, type=int, default=[48, 48], help='Output icon size WxH') + args = parser.parse_args() + + out_w, out_h = args.size + + mapping = { + 'timer': 'timer.r565', + 'web': 'web.r565', + 'game': 'game.r565', + } + + for name, out_file in mapping.items(): + src_file = os.path.join(args.src, f'{name}.png') + if not os.path.exists(src_file): + print(f"WARN: missing {src_file}, skip") + continue + dst_file = os.path.join(args.dst, out_file) + convert_one(src_file, dst_file, out_w, out_h) + + print('Done. You can now upload FS: pio run -t uploadfs') + + +if __name__ == '__main__': + main()