中文
This commit is contained in:
parent
2b5cfc1538
commit
9529ecd0d5
18
.vscode/tasks.json
vendored
Normal file
18
.vscode/tasks.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
44
README.md
Normal file
44
README.md
Normal file
@ -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.
|
||||||
BIN
assets/icons_src/game.png
Normal file
BIN
assets/icons_src/game.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
assets/icons_src/timer.png
Normal file
BIN
assets/icons_src/timer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 734 KiB |
BIN
assets/icons_src/web.png
Normal file
BIN
assets/icons_src/web.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
data/icons/game.r565
Normal file
BIN
data/icons/game.r565
Normal file
Binary file not shown.
BIN
data/icons/timer.r565
Normal file
BIN
data/icons/timer.r565
Normal file
Binary file not shown.
BIN
data/icons/web.r565
Normal file
BIN
data/icons/web.r565
Normal file
Binary file not shown.
55
include/game.h
Normal file
55
include/game.h
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
#ifndef GAME_H
|
||||||
|
#define GAME_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#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
|
||||||
30
include/timer.h
Normal file
30
include/timer.h
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#ifndef TIMER_H
|
||||||
|
#define TIMER_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#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
|
||||||
191
include/ui.h
Normal file
191
include/ui.h
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
#ifndef UI_H
|
||||||
|
#define UI_H
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <Adafruit_ST7735.h>
|
||||||
|
#include <Adafruit_GFX.h>
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <U8g2_for_Adafruit_GFX.h>
|
||||||
|
#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
|
||||||
@ -23,6 +23,7 @@ lib_deps =
|
|||||||
adafruit/Adafruit ST7735 and ST7789 Library
|
adafruit/Adafruit ST7735 and ST7789 Library
|
||||||
adafruit/Adafruit GFX Library
|
adafruit/Adafruit GFX Library
|
||||||
bblanchon/ArduinoJson@^7.4.2
|
bblanchon/ArduinoJson@^7.4.2
|
||||||
|
olikraus/U8g2_for_Adafruit_GFX
|
||||||
upload_speed = 921600
|
upload_speed = 921600
|
||||||
monitor_speed = 115200
|
monitor_speed = 115200
|
||||||
build_flags = -std=gnu++2a
|
build_flags = -std=gnu++2a
|
||||||
|
|||||||
215
src/game.cpp
Normal file
215
src/game.cpp
Normal file
@ -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<ROWS && cx>=0 && cx<COLS){
|
||||||
|
g.board[cy][cx] = 1 + (g.current.type % 7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int clearLines(Tetris& g){
|
||||||
|
int cleared = 0;
|
||||||
|
for (int y=ROWS-1; y>=0; --y){
|
||||||
|
bool full = true;
|
||||||
|
for (int x=0; x<COLS; ++x){ if (g.board[y][x]==0){ full=false; break; } }
|
||||||
|
if (full){
|
||||||
|
++cleared;
|
||||||
|
for (int yy=y; yy>0; --yy){
|
||||||
|
for (int x=0;x<COLS;++x){ g.board[yy][x] = g.board[yy-1][x]; }
|
||||||
|
}
|
||||||
|
for (int x=0;x<COLS;++x) g.board[0][x]=0;
|
||||||
|
++y; // 重新检查此行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cleared>0){ 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<ROWS;++y) for (int x=0;x<COLS;++x) g.board[y][x]=0;
|
||||||
|
g.state = State::Playing;
|
||||||
|
g.score = 0;
|
||||||
|
g.dropInterval = 700;
|
||||||
|
g.lastDropMs = millis();
|
||||||
|
g.rng ^= millis(); if (g.rng==0) g.rng=1;
|
||||||
|
spawnPiece(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
void update(Tetris& g, uint32_t nowMs){
|
||||||
|
if (g.state != State::Playing) return;
|
||||||
|
if (nowMs - g.lastDropMs >= 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<ROWS; ++y){
|
||||||
|
for (int x=0; x<COLS; ++x){
|
||||||
|
uint8_t v = g.board[y][x];
|
||||||
|
if (v){
|
||||||
|
uint16_t c = g.colors[v % 8];
|
||||||
|
out.addFillRect(1000 + y*COLS + x, originX + x*cell, originY + y*cell, cell-1, cell-1, c);
|
||||||
|
} else {
|
||||||
|
// 画浅网格可选:注释掉减少绘制量
|
||||||
|
// out.addFillRect(2000 + y*COLS + x, originX + x*cell, originY + y*cell, cell-1, 1, 0x8410);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前方块
|
||||||
|
if (g.state == State::Playing){
|
||||||
|
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){
|
||||||
|
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
|
||||||
235
src/main.cpp
235
src/main.cpp
@ -1,9 +1,14 @@
|
|||||||
#include <Adafruit_GFX.h>
|
#include <Adafruit_GFX.h>
|
||||||
#include <Adafruit_ST7735.h>
|
#include <Adafruit_ST7735.h>
|
||||||
#include <SPI.h>
|
#include <SPI.h>
|
||||||
#include "utils.h"
|
#include <U8g2_for_Adafruit_GFX.h>
|
||||||
|
#include "game.h"
|
||||||
#include "init.h"
|
#include "init.h"
|
||||||
|
#include "timer.h"
|
||||||
|
#include "ui.h"
|
||||||
|
#include "utils.h"
|
||||||
|
|
||||||
|
U8G2_FOR_ADAFRUIT_GFX u8g2; // 给 Adafruit_GFX 套一层 U8g2 的“文字引擎”
|
||||||
using namespace esp32;
|
using namespace esp32;
|
||||||
|
|
||||||
#define TFT_MOSI 16
|
#define TFT_MOSI 16
|
||||||
@ -15,35 +20,102 @@ using namespace esp32;
|
|||||||
// 屏幕尺寸为 128x160
|
// 屏幕尺寸为 128x160
|
||||||
Adafruit_ST7735 tft(TFT_CS, TFT_DC, TFT_RST);
|
Adafruit_ST7735 tft(TFT_CS, TFT_DC, TFT_RST);
|
||||||
|
|
||||||
#define function auto
|
using namespace esp32::utils;
|
||||||
#define let auto
|
using namespace esp32::ui;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int BTN_LEFT = 12; // 左按钮
|
const int BTN_LEFT = 12; // 左按钮
|
||||||
const int BTN_RIGHT = 11; // 右按钮
|
const int BTN_RIGHT = 11; // 右按钮
|
||||||
const int BTN_UP = 10; // 上按钮
|
const int BTN_UP = 10; // 上按钮
|
||||||
|
|
||||||
#define BLACK rgbTo565(0x000000)
|
// 颜色宏兼容已由 utils.h 提供
|
||||||
#define WHITE rgbTo565(0xFFFFFF)
|
|
||||||
#define RED rgbTo565(0xFF0000)
|
// 应用状态(主屏轮播)
|
||||||
#define GREEN rgbTo565(0x00FF00)
|
struct AppState {
|
||||||
#define BLUE rgbTo565(0x0000FF)
|
// 轮播:当前索引与动画偏移(像素)
|
||||||
#define YELLOW rgbTo565(0xFFFF00)
|
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() {
|
void setup() {
|
||||||
// 按钮:内部下拉
|
// 按钮:内部下拉
|
||||||
@ -60,11 +132,38 @@ void setup() {
|
|||||||
tft.setRotation(0);
|
tft.setRotation(0);
|
||||||
tft.fillScreen(BLACK);
|
tft.fillScreen(BLACK);
|
||||||
tft.setCursor(0, 0);
|
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.setTextColor(WHITE);
|
||||||
tft.setTextSize(1);
|
tft.setTextSize(1);
|
||||||
|
|
||||||
// 初始化系统(读取配置并连接 WiFi)
|
// 初始化系统(读取配置并连接 WiFi)
|
||||||
init::initSystem(tft);
|
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() {
|
void loop() {
|
||||||
@ -72,7 +171,87 @@ void loop() {
|
|||||||
bool rightPressed = digitalRead(BTN_RIGHT) == HIGH;
|
bool rightPressed = digitalRead(BTN_RIGHT) == HIGH;
|
||||||
bool upPressed = digitalRead(BTN_UP) == HIGH;
|
bool upPressed = digitalRead(BTN_UP) == HIGH;
|
||||||
|
|
||||||
tft.fillRect(0, 30, 20, 20, leftPressed ? RED : BLACK);
|
if (app.scene == AppState::Scene::Home) {
|
||||||
tft.fillRect(108, 30, 20, 20, rightPressed ? RED : BLACK);
|
// 主页:左右滑动选择,Up 进入游戏
|
||||||
tft.fillRect(54, 0, 20, 20, upPressed ? RED : BLACK);
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
139
src/timer.cpp
Normal file
139
src/timer.cpp
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
#include "timer.h"
|
||||||
|
#include "utils.h"
|
||||||
|
#include <math.h>
|
||||||
|
|
||||||
|
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<s.lapCount;++i){
|
||||||
|
float lapDeg = s.lapAngles[i] - 90.0f;
|
||||||
|
float rad = deg2rad(lapDeg);
|
||||||
|
int16_t lx = cx + (int16_t)((radius - 4) * cosf(rad));
|
||||||
|
int16_t ly = cy + (int16_t)((radius - 4) * sinf(rad));
|
||||||
|
out.addCircle(6300 + i, lx, ly, 2, BLUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Laps 列表(右侧显示最近记录)
|
||||||
|
int16_t lapListX = screenW - 52;
|
||||||
|
int16_t lapListY = 4;
|
||||||
|
out.addText(6400, lapListX, lapListY, WHITE, "Laps");
|
||||||
|
int visible = s.lapCount;
|
||||||
|
if (visible > 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
|
||||||
267
src/ui.cpp
Normal file
267
src/ui.cpp
Normal file
@ -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
|
||||||
67
tools/convert_icons.py
Normal file
67
tools/convert_icons.py
Normal file
@ -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()
|
||||||
Loading…
x
Reference in New Issue
Block a user