Mystify Demo, TwoEleven updates & Snake (#21)
* **New Features** * Added Mystify screensaver demo with animated polygons and trails * Added Snake game with multiple difficulties, high-score persistence, and multi-input (touch/keyboard) support * Added CLI tool for building, packaging, and deploying apps (end-to-end build/install/run workflow) * Per-grid-size high-score persistence added to 2048 app; expanded keyboard controls (WASD and device-specific mappings) * **Documentation** * Added Snake README with gameplay, controls, and usage instructions
@@ -12,7 +12,7 @@ jobs:
|
|||||||
Build:
|
Build:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
app_name: [Calculator, Diceware, GPIO, GraphicsDemo, HelloWorld, SerialConsole, TwoEleven]
|
app_name: [Calculator, Diceware, GPIO, GraphicsDemo, HelloWorld, SerialConsole, TwoEleven, MystifyDemo, Snake]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(Mystify)
|
||||||
|
tactility_project(Mystify)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRC_DIRS "Source"
|
||||||
|
# Library headers must be included directly,
|
||||||
|
# because all regular dependencies get stripped by elf_loader's cmake script
|
||||||
|
INCLUDE_DIRS "Include" "../../../Libraries/TactilityCpp/Include"
|
||||||
|
REQUIRES TactilitySDK
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "drivers/DisplayDriver.h"
|
||||||
|
#include "drivers/TouchDriver.h"
|
||||||
|
|
||||||
|
void runApplication(DisplayDriver* display, TouchDriver* touch);
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "PixelBuffer.h"
|
||||||
|
#include "drivers/DisplayDriver.h"
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <esp_random.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mystify Screensaver Demo
|
||||||
|
*
|
||||||
|
* Classic Windows-style mystify screensaver with bouncing polygons and trailing edges.
|
||||||
|
* Adapted to work with DisplayDriver and PixelBuffer abstractions.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* MystifyDemo mystify;
|
||||||
|
* mystify.init(display);
|
||||||
|
*
|
||||||
|
* while (!shouldExit) {
|
||||||
|
* mystify.update();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
class MystifyDemo {
|
||||||
|
public:
|
||||||
|
static constexpr int NUM_POLYGONS = 2;
|
||||||
|
static constexpr int NUM_VERTICES = 4;
|
||||||
|
static constexpr int TRAIL_LENGTH = 8;
|
||||||
|
static constexpr int COLOR_CHANGE_INTERVAL = 200; // Frames between color changes
|
||||||
|
static constexpr int STRIP_HEIGHT = 16; // Draw in strips to avoid SPI buffer overflow
|
||||||
|
|
||||||
|
MystifyDemo() = default;
|
||||||
|
~MystifyDemo() { deinit(); }
|
||||||
|
|
||||||
|
// Non-copyable, non-movable (owns PixelBuffer)
|
||||||
|
MystifyDemo(const MystifyDemo&) = delete;
|
||||||
|
MystifyDemo& operator=(const MystifyDemo&) = delete;
|
||||||
|
MystifyDemo(MystifyDemo&&) = delete;
|
||||||
|
MystifyDemo& operator=(MystifyDemo&&) = delete;
|
||||||
|
|
||||||
|
bool init(DisplayDriver* display) {
|
||||||
|
if (!display) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
display_ = display;
|
||||||
|
width_ = display->getWidth();
|
||||||
|
height_ = display->getHeight();
|
||||||
|
|
||||||
|
if (width_ <= 0 || height_ <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed random generator with hardware entropy
|
||||||
|
srand(static_cast<unsigned>(esp_random()));
|
||||||
|
|
||||||
|
// Allocate full-screen framebuffer
|
||||||
|
void* mem = malloc(sizeof(PixelBuffer));
|
||||||
|
if (!mem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
framebuffer_ = new(mem) PixelBuffer(width_, height_, display->getColorFormat());
|
||||||
|
|
||||||
|
initPolygons();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void deinit() {
|
||||||
|
if (framebuffer_) {
|
||||||
|
framebuffer_->~PixelBuffer();
|
||||||
|
free(framebuffer_);
|
||||||
|
framebuffer_ = nullptr;
|
||||||
|
}
|
||||||
|
display_ = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void update() {
|
||||||
|
if (!framebuffer_ || !display_) return;
|
||||||
|
|
||||||
|
// Clear framebuffer to black
|
||||||
|
framebuffer_->clear();
|
||||||
|
|
||||||
|
// Update and draw each polygon
|
||||||
|
for (int p = 0; p < NUM_POLYGONS; p++) {
|
||||||
|
updatePolygon(polygons_[p]);
|
||||||
|
drawPolygon(polygons_[p]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send framebuffer to display in strips (full screen is too large for single SPI transaction)
|
||||||
|
display_->lock();
|
||||||
|
for (int y = 0; y < height_; y += STRIP_HEIGHT) {
|
||||||
|
int stripEnd = (y + STRIP_HEIGHT > height_) ? height_ : y + STRIP_HEIGHT;
|
||||||
|
display_->drawBitmap(0, y, width_, stripEnd, framebuffer_->getDataAtRow(y));
|
||||||
|
}
|
||||||
|
display_->unlock();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Smooth sub-pixel movement with floats
|
||||||
|
struct Vertex {
|
||||||
|
float x = 0;
|
||||||
|
float y = 0;
|
||||||
|
float dx = 0;
|
||||||
|
float dy = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Polygon {
|
||||||
|
Vertex vertices[NUM_VERTICES];
|
||||||
|
// History: [trail_index][vertex_index] = {x, y}
|
||||||
|
int16_t historyX[TRAIL_LENGTH][NUM_VERTICES];
|
||||||
|
int16_t historyY[TRAIL_LENGTH][NUM_VERTICES];
|
||||||
|
uint8_t colorIndex;
|
||||||
|
int historyHead = 0;
|
||||||
|
bool historyFull = false;
|
||||||
|
int colorChangeCounter = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vibrant colors as RGB888 for format-agnostic rendering
|
||||||
|
struct Color {
|
||||||
|
uint8_t r, g, b;
|
||||||
|
};
|
||||||
|
|
||||||
|
static constexpr Color COLOR_POOL[] = {
|
||||||
|
{255, 0, 255}, // Magenta
|
||||||
|
{0, 255, 255}, // Cyan
|
||||||
|
{255, 255, 0}, // Yellow
|
||||||
|
{255, 128, 0}, // Orange
|
||||||
|
{0, 255, 128}, // Spring green
|
||||||
|
{128, 0, 255}, // Purple
|
||||||
|
{255, 64, 128}, // Hot pink
|
||||||
|
{128, 255, 0}, // Lime
|
||||||
|
};
|
||||||
|
static constexpr int COLOR_POOL_SIZE = sizeof(COLOR_POOL) / sizeof(COLOR_POOL[0]);
|
||||||
|
|
||||||
|
DisplayDriver* display_ = nullptr;
|
||||||
|
PixelBuffer* framebuffer_ = nullptr;
|
||||||
|
uint16_t width_ = 0;
|
||||||
|
uint16_t height_ = 0;
|
||||||
|
Polygon polygons_[NUM_POLYGONS];
|
||||||
|
|
||||||
|
static float randomFloat(float min, float max) {
|
||||||
|
return min + (max - min) * (static_cast<float>(rand()) / static_cast<float>(RAND_MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
void initPolygons() {
|
||||||
|
for (int p = 0; p < NUM_POLYGONS; p++) {
|
||||||
|
Polygon& polygon = polygons_[p];
|
||||||
|
|
||||||
|
// Pick random color from pool
|
||||||
|
polygon.colorIndex = rand() % COLOR_POOL_SIZE;
|
||||||
|
polygon.historyHead = 0;
|
||||||
|
polygon.historyFull = false;
|
||||||
|
// Stagger color changes so polygons don't change simultaneously
|
||||||
|
polygon.colorChangeCounter = rand() % COLOR_CHANGE_INTERVAL;
|
||||||
|
|
||||||
|
// Initialize vertices with random positions and velocities
|
||||||
|
for (int v = 0; v < NUM_VERTICES; v++) {
|
||||||
|
Vertex& vertex = polygon.vertices[v];
|
||||||
|
vertex.x = static_cast<float>(rand() % width_);
|
||||||
|
vertex.y = static_cast<float>(rand() % height_);
|
||||||
|
|
||||||
|
// Speed range for smooth movement
|
||||||
|
vertex.dx = randomFloat(0.8f, 2.0f);
|
||||||
|
vertex.dy = randomFloat(0.8f, 2.0f);
|
||||||
|
if (rand() % 2) vertex.dx = -vertex.dx;
|
||||||
|
if (rand() % 2) vertex.dy = -vertex.dy;
|
||||||
|
|
||||||
|
// Ensure dx != dy for more interesting movement patterns
|
||||||
|
if (std::fabs(vertex.dx - vertex.dy) < 0.3f) {
|
||||||
|
vertex.dy += (vertex.dy > 0 ? 0.5f : -0.5f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize history with current positions
|
||||||
|
for (int t = 0; t < TRAIL_LENGTH; t++) {
|
||||||
|
for (int v = 0; v < NUM_VERTICES; v++) {
|
||||||
|
polygon.historyX[t][v] = static_cast<int16_t>(polygon.vertices[v].x);
|
||||||
|
polygon.historyY[t][v] = static_cast<int16_t>(polygon.vertices[v].y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updatePolygon(Polygon& polygon) {
|
||||||
|
constexpr float minSpeed = 0.5f;
|
||||||
|
constexpr float maxSpeed = 2.5f;
|
||||||
|
|
||||||
|
// Periodic color change
|
||||||
|
polygon.colorChangeCounter++;
|
||||||
|
if (polygon.colorChangeCounter >= COLOR_CHANGE_INTERVAL) {
|
||||||
|
polygon.colorChangeCounter = 0;
|
||||||
|
// Pick a different color
|
||||||
|
uint8_t newColor;
|
||||||
|
do {
|
||||||
|
newColor = rand() % COLOR_POOL_SIZE;
|
||||||
|
} while (newColor == polygon.colorIndex && COLOR_POOL_SIZE > 1);
|
||||||
|
polygon.colorIndex = newColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move vertices
|
||||||
|
for (int v = 0; v < NUM_VERTICES; v++) {
|
||||||
|
Vertex& vertex = polygon.vertices[v];
|
||||||
|
vertex.x += vertex.dx;
|
||||||
|
vertex.y += vertex.dy;
|
||||||
|
|
||||||
|
// Bounce off edges with slight angle variation for organic movement
|
||||||
|
if (vertex.x <= 0) {
|
||||||
|
vertex.x = 0;
|
||||||
|
vertex.dx = std::fabs(vertex.dx);
|
||||||
|
vertex.dy *= (1.0f + randomFloat(-0.1f, 0.1f));
|
||||||
|
} else if (vertex.x >= width_ - 1) {
|
||||||
|
vertex.x = static_cast<float>(width_ - 1);
|
||||||
|
vertex.dx = -std::fabs(vertex.dx);
|
||||||
|
vertex.dy *= (1.0f + randomFloat(-0.1f, 0.1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vertex.y <= 0) {
|
||||||
|
vertex.y = 0;
|
||||||
|
vertex.dy = std::fabs(vertex.dy);
|
||||||
|
vertex.dx *= (1.0f + randomFloat(-0.1f, 0.1f));
|
||||||
|
} else if (vertex.y >= height_ - 1) {
|
||||||
|
vertex.y = static_cast<float>(height_ - 1);
|
||||||
|
vertex.dy = -std::fabs(vertex.dy);
|
||||||
|
vertex.dx *= (1.0f + randomFloat(-0.1f, 0.1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp speeds to prevent runaway acceleration or stalling
|
||||||
|
auto clampSpeed = [minSpeed, maxSpeed](float& speed) {
|
||||||
|
float sign = (speed >= 0) ? 1.0f : -1.0f;
|
||||||
|
float absSpeed = std::fabs(speed);
|
||||||
|
if (absSpeed < minSpeed) absSpeed = minSpeed;
|
||||||
|
if (absSpeed > maxSpeed) absSpeed = maxSpeed;
|
||||||
|
speed = sign * absSpeed;
|
||||||
|
};
|
||||||
|
clampSpeed(vertex.dx);
|
||||||
|
clampSpeed(vertex.dy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance history ring buffer
|
||||||
|
polygon.historyHead = (polygon.historyHead + 1) % TRAIL_LENGTH;
|
||||||
|
if (polygon.historyHead == 0) {
|
||||||
|
polygon.historyFull = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store current positions
|
||||||
|
for (int v = 0; v < NUM_VERTICES; v++) {
|
||||||
|
polygon.historyX[polygon.historyHead][v] = static_cast<int16_t>(polygon.vertices[v].x);
|
||||||
|
polygon.historyY[polygon.historyHead][v] = static_cast<int16_t>(polygon.vertices[v].y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawPolygon(const Polygon& polygon) {
|
||||||
|
const Color& baseColor = COLOR_POOL[polygon.colorIndex];
|
||||||
|
|
||||||
|
// Draw trail from oldest to newest (so newest is on top)
|
||||||
|
for (int t = TRAIL_LENGTH - 1; t >= 0; t--) {
|
||||||
|
int histIndex = polygon.historyHead - t;
|
||||||
|
if (histIndex < 0) histIndex += TRAIL_LENGTH;
|
||||||
|
|
||||||
|
// Skip if we don't have enough history yet
|
||||||
|
if (!polygon.historyFull && histIndex > polygon.historyHead) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate brightness for this trail frame (older = dimmer)
|
||||||
|
int brightness = 255 - (t * 230 / TRAIL_LENGTH);
|
||||||
|
if (brightness < 25) brightness = 25;
|
||||||
|
|
||||||
|
// Scale color by brightness
|
||||||
|
uint8_t r = (baseColor.r * brightness) / 255;
|
||||||
|
uint8_t g = (baseColor.g * brightness) / 255;
|
||||||
|
uint8_t b = (baseColor.b * brightness) / 255;
|
||||||
|
|
||||||
|
// Draw edges connecting vertices
|
||||||
|
for (int e = 0; e < NUM_VERTICES; e++) {
|
||||||
|
int nextVertex = (e + 1) % NUM_VERTICES;
|
||||||
|
|
||||||
|
int x0 = polygon.historyX[histIndex][e];
|
||||||
|
int y0 = polygon.historyY[histIndex][e];
|
||||||
|
int x1 = polygon.historyX[histIndex][nextVertex];
|
||||||
|
int y1 = polygon.historyY[histIndex][nextVertex];
|
||||||
|
|
||||||
|
drawLine(x0, y0, x1, y1, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bresenham's line algorithm
|
||||||
|
void drawLine(int x0, int y0, int x1, int y1, uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
int dx = std::abs(x1 - x0);
|
||||||
|
int dy = std::abs(y1 - y0);
|
||||||
|
int sx = (x0 < x1) ? 1 : -1;
|
||||||
|
int sy = (y0 < y1) ? 1 : -1;
|
||||||
|
int err = dx - dy;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
setPixel(x0, y0, r, g, b);
|
||||||
|
|
||||||
|
if (x0 == x1 && y0 == y1) break;
|
||||||
|
|
||||||
|
int e2 = 2 * err;
|
||||||
|
if (e2 > -dy) {
|
||||||
|
err -= dy;
|
||||||
|
x0 += sx;
|
||||||
|
}
|
||||||
|
if (e2 < dx) {
|
||||||
|
err += dx;
|
||||||
|
y0 += sy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPixel(int x, int y, uint8_t r, uint8_t g, uint8_t b) {
|
||||||
|
if (x >= 0 && x < width_ && y >= 0 && y < height_) {
|
||||||
|
framebuffer_->setPixel(x, y, r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <esp_log.h>
|
||||||
|
#include "drivers/Colors.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <tt_hal_display.h>
|
||||||
|
|
||||||
|
class PixelBuffer {
|
||||||
|
uint16_t pixelWidth;
|
||||||
|
uint16_t pixelHeight;
|
||||||
|
ColorFormat colorFormat;
|
||||||
|
uint8_t* data;
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
|
PixelBuffer(uint16_t pixelWidth, uint16_t pixelHeight, ColorFormat colorFormat) :
|
||||||
|
pixelWidth(pixelWidth),
|
||||||
|
pixelHeight(pixelHeight),
|
||||||
|
colorFormat(colorFormat)
|
||||||
|
{
|
||||||
|
data = static_cast<uint8_t*>(malloc(pixelWidth * pixelHeight * getPixelSize()));
|
||||||
|
assert(data != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
~PixelBuffer() {
|
||||||
|
free(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t getPixelWidth() const {
|
||||||
|
return pixelWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t getPixelHeight() const {
|
||||||
|
return pixelHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorFormat getColorFormat() const {
|
||||||
|
return colorFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
void* getData() const {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t getDataSize() const {
|
||||||
|
return pixelWidth * pixelHeight * getPixelSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
void* getDataAtRow(uint16_t row) const {
|
||||||
|
auto address = reinterpret_cast<uint32_t>(data) + (row * getRowDataSize());
|
||||||
|
return reinterpret_cast<void*>(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t getRowDataSize() const {
|
||||||
|
return pixelWidth * getPixelSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t getPixelSize() const {
|
||||||
|
switch (colorFormat) {
|
||||||
|
case COLOR_FORMAT_MONOCHROME:
|
||||||
|
return 1;
|
||||||
|
case COLOR_FORMAT_BGR565:
|
||||||
|
case COLOR_FORMAT_BGR565_SWAPPED:
|
||||||
|
case COLOR_FORMAT_RGB565:
|
||||||
|
case COLOR_FORMAT_RGB565_SWAPPED:
|
||||||
|
return 2;
|
||||||
|
case COLOR_FORMAT_RGB888:
|
||||||
|
return 3;
|
||||||
|
default:
|
||||||
|
// TODO: Crash with error
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8_t* getPixelAddress(uint16_t x, uint16_t y) const {
|
||||||
|
uint32_t offset = ((y * getPixelWidth()) + x) * getPixelSize();
|
||||||
|
uint32_t address = reinterpret_cast<uint32_t>(data) + offset;
|
||||||
|
return reinterpret_cast<uint8_t*>(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setPixel(uint16_t x, uint16_t y, uint8_t r, uint8_t g, uint8_t b) const {
|
||||||
|
auto address = getPixelAddress(x, y);
|
||||||
|
switch (colorFormat) {
|
||||||
|
case COLOR_FORMAT_MONOCHROME:
|
||||||
|
*address = (uint8_t)((uint16_t)r + (uint16_t)g + (uint16_t)b / 3);
|
||||||
|
break;
|
||||||
|
case COLOR_FORMAT_BGR565:
|
||||||
|
Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address));
|
||||||
|
break;
|
||||||
|
case COLOR_FORMAT_BGR565_SWAPPED: {
|
||||||
|
// TODO: Make proper conversion function
|
||||||
|
Colors::rgb888ToBgr565(r, g, b, reinterpret_cast<uint16_t*>(address));
|
||||||
|
uint8_t temp = *address;
|
||||||
|
*address = *(address + 1);
|
||||||
|
*(address + 1) = temp;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case COLOR_FORMAT_RGB565: {
|
||||||
|
Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case COLOR_FORMAT_RGB565_SWAPPED: {
|
||||||
|
// TODO: Make proper conversion function
|
||||||
|
Colors::rgb888ToRgb565(r, g, b, reinterpret_cast<uint16_t*>(address));
|
||||||
|
uint8_t temp = *address;
|
||||||
|
*address = *(address + 1);
|
||||||
|
*(address + 1) = temp;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case COLOR_FORMAT_RGB888: {
|
||||||
|
uint8_t pixel[3] = { r, g, b };
|
||||||
|
memcpy(address, pixel, 3);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// NO-OP
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear(int value = 0) const {
|
||||||
|
memset(data, value, getDataSize());
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
class Colors {
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
|
static void rgb888ToRgb565(uint8_t red, uint8_t green, uint8_t blue, uint16_t* rgb565) {
|
||||||
|
uint16_t _rgb565 = (red >> 3);
|
||||||
|
_rgb565 = (_rgb565 << 6) | (green >> 2);
|
||||||
|
_rgb565 = (_rgb565 << 5) | (blue >> 3);
|
||||||
|
*rgb565 = _rgb565;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rgb888ToBgr565(uint8_t red, uint8_t green, uint8_t blue, uint16_t* bgr565) {
|
||||||
|
uint16_t _bgr565 = (blue >> 3);
|
||||||
|
_bgr565 = (_bgr565 << 6) | (green >> 2);
|
||||||
|
_bgr565 = (_bgr565 << 5) | (red >> 3);
|
||||||
|
*bgr565 = _bgr565;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void rgb565ToRgb888(uint16_t rgb565, uint32_t* rgb888) {
|
||||||
|
uint32_t _rgb565 = rgb565;
|
||||||
|
uint8_t b = (_rgb565 >> 8) & 0xF8;
|
||||||
|
uint8_t g = (_rgb565 >> 3) & 0xFC;
|
||||||
|
uint8_t r = (_rgb565 << 3) & 0xF8;
|
||||||
|
|
||||||
|
uint8_t* r8p = reinterpret_cast<uint8_t*>(rgb888);
|
||||||
|
uint8_t* g8p = r8p + 1;
|
||||||
|
uint8_t* b8p = r8p + 2;
|
||||||
|
|
||||||
|
*r8p = r | ((r >> 3) & 0x7);
|
||||||
|
*g8p = g | ((g >> 2) & 0x3);
|
||||||
|
*b8p = b | ((b >> 3) & 0x7);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <tt_hal_display.h>
|
||||||
|
#include <Tactility/kernel/Kernel.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for tt_hal_display_driver_*
|
||||||
|
*/
|
||||||
|
class DisplayDriver {
|
||||||
|
|
||||||
|
DisplayDriverHandle handle = nullptr;
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
|
explicit DisplayDriver(DeviceId id) {
|
||||||
|
assert(tt_hal_display_driver_supported(id));
|
||||||
|
handle = tt_hal_display_driver_alloc(id);
|
||||||
|
assert(handle != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
~DisplayDriver() {
|
||||||
|
tt_hal_display_driver_free(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool lock(TickType_t timeout = tt::kernel::MAX_TICKS) const {
|
||||||
|
return tt_hal_display_driver_lock(handle, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void unlock() const {
|
||||||
|
tt_hal_display_driver_unlock(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t getWidth() const {
|
||||||
|
return tt_hal_display_driver_get_pixel_width(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t getHeight() const {
|
||||||
|
return tt_hal_display_driver_get_pixel_height(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
ColorFormat getColorFormat() const {
|
||||||
|
return tt_hal_display_driver_get_colorformat(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawBitmap(int xStart, int yStart, int xEnd, int yEnd, const void* pixelData) const {
|
||||||
|
tt_hal_display_driver_draw_bitmap(handle, xStart, yStart, xEnd, yEnd, pixelData);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <tt_hal_touch.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper for tt_hal_touch_driver_*
|
||||||
|
*/
|
||||||
|
class TouchDriver {
|
||||||
|
|
||||||
|
TouchDriverHandle handle = nullptr;
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
|
explicit TouchDriver(DeviceId id) {
|
||||||
|
assert(tt_hal_touch_driver_supported(id));
|
||||||
|
handle = tt_hal_touch_driver_alloc(id);
|
||||||
|
assert(handle != nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
~TouchDriver() {
|
||||||
|
tt_hal_touch_driver_free(handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool getTouchedPoints(uint16_t* x, uint16_t* y, uint16_t* strength, uint8_t* count, uint8_t maxCount) const {
|
||||||
|
return tt_hal_touch_driver_get_touched_points(handle, x, y, strength, count, maxCount);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#include "Application.h"
|
||||||
|
#include "MystifyDemo.h"
|
||||||
|
#include "PixelBuffer.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
|
||||||
|
#include <Tactility/kernel/Kernel.h>
|
||||||
|
|
||||||
|
constexpr auto TAG = "Application";
|
||||||
|
constexpr int MYSTIFY_FRAME_DELAY_MS = 50; // ~20 FPS for smooth animation
|
||||||
|
|
||||||
|
static bool isTouched(TouchDriver* touch) {
|
||||||
|
uint16_t x, y, strength;
|
||||||
|
uint8_t pointCount = 0;
|
||||||
|
return touch->getTouchedPoints(&x, &y, &strength, &pointCount, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void runApplication(DisplayDriver* display, TouchDriver* touch) {
|
||||||
|
// Run the Mystify screensaver demo
|
||||||
|
MystifyDemo mystify;
|
||||||
|
if (!mystify.init(display)) {
|
||||||
|
ESP_LOGE(TAG, "Failed to initialize MystifyDemo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Starting Mystify demo - touch to exit");
|
||||||
|
|
||||||
|
do {
|
||||||
|
mystify.update();
|
||||||
|
|
||||||
|
// Frame rate limiter - ~20 FPS for smooth animation
|
||||||
|
tt::kernel::delayTicks(tt::kernel::millisToTicks(MYSTIFY_FRAME_DELAY_MS));
|
||||||
|
} while (!isTouched(touch));
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Mystify demo ended");
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#include "Application.h"
|
||||||
|
#include "drivers/DisplayDriver.h"
|
||||||
|
#include "drivers/TouchDriver.h"
|
||||||
|
|
||||||
|
#include <esp_log.h>
|
||||||
|
|
||||||
|
#include <tt_app.h>
|
||||||
|
#include <tt_app_alertdialog.h>
|
||||||
|
#include <tt_lvgl.h>
|
||||||
|
|
||||||
|
constexpr auto TAG = "Main";
|
||||||
|
|
||||||
|
/** Find a DisplayDevice that supports the DisplayDriver interface */
|
||||||
|
static bool findUsableDisplay(DeviceId& deviceId) {
|
||||||
|
uint16_t display_count = 0;
|
||||||
|
if (!tt_hal_device_find(DEVICE_TYPE_DISPLAY, &deviceId, &display_count, 1)) {
|
||||||
|
ESP_LOGE(TAG, "No display device found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tt_hal_display_driver_supported(deviceId)) {
|
||||||
|
ESP_LOGE(TAG, "Display doesn't support driver mode");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a TouchDevice that supports the TouchDriver interface */
|
||||||
|
static bool findUsableTouch(DeviceId& deviceId) {
|
||||||
|
uint16_t touch_count = 0;
|
||||||
|
if (!tt_hal_device_find(DEVICE_TYPE_TOUCH, &deviceId, &touch_count, 1)) {
|
||||||
|
ESP_LOGE(TAG, "No touch device found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tt_hal_touch_driver_supported(deviceId)) {
|
||||||
|
ESP_LOGE(TAG, "Touch doesn't support driver mode");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onCreate(AppHandle appHandle, void* data) {
|
||||||
|
DeviceId display_id;
|
||||||
|
if (!findUsableDisplay(display_id)) {
|
||||||
|
tt_app_stop();
|
||||||
|
tt_app_alertdialog_start("Error", "The display doesn't support the required features.", nullptr, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceId touch_id;
|
||||||
|
if (!findUsableTouch(touch_id)) {
|
||||||
|
tt_app_stop();
|
||||||
|
tt_app_alertdialog_start("Error", "The touch driver doesn't support the required features.", nullptr, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop LVGL first (because it's currently using the drivers we want to use)
|
||||||
|
tt_lvgl_stop();
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Creating display driver");
|
||||||
|
auto display = new DisplayDriver(display_id);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Creating touch driver");
|
||||||
|
auto touch = new TouchDriver(touch_id);
|
||||||
|
|
||||||
|
// Run the main logic
|
||||||
|
ESP_LOGI(TAG, "Running application");
|
||||||
|
runApplication(display, touch);
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Cleanup display driver");
|
||||||
|
delete display;
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Cleanup touch driver");
|
||||||
|
delete touch;
|
||||||
|
|
||||||
|
ESP_LOGI(TAG, "Stopping application");
|
||||||
|
tt_app_stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void onDestroy(AppHandle appHandle, void* data) {
|
||||||
|
// Restart LVGL to resume rendering of regular apps
|
||||||
|
if (!tt_lvgl_is_started()) {
|
||||||
|
ESP_LOGI(TAG, "Restarting LVGL");
|
||||||
|
tt_lvgl_start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
tt_app_register((AppRegistration) {
|
||||||
|
.onCreate = onCreate,
|
||||||
|
.onDestroy = onDestroy
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[manifest]
|
||||||
|
version=0.1
|
||||||
|
[target]
|
||||||
|
sdk=0.7.0-dev
|
||||||
|
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
|
[app]
|
||||||
|
id=one.tactility.mystifydemo
|
||||||
|
versionName=0.3.0
|
||||||
|
versionCode=3
|
||||||
|
name=Mystify Demo
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
import configparser
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
import requests
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
ttbuild_path = ".tactility"
|
||||||
|
ttbuild_version = "3.2.0"
|
||||||
|
ttbuild_cdn = "https://cdn.tactilityproject.org"
|
||||||
|
ttbuild_sdk_json_validity = 3600 # seconds
|
||||||
|
ttport = 6666
|
||||||
|
verbose = False
|
||||||
|
use_local_sdk = False
|
||||||
|
local_base_path = None
|
||||||
|
|
||||||
|
shell_color_red = "\033[91m"
|
||||||
|
shell_color_orange = "\033[93m"
|
||||||
|
shell_color_green = "\033[32m"
|
||||||
|
shell_color_purple = "\033[35m"
|
||||||
|
shell_color_cyan = "\033[36m"
|
||||||
|
shell_color_reset = "\033[m"
|
||||||
|
|
||||||
|
def print_help():
|
||||||
|
print("Usage: python tactility.py [action] [options]")
|
||||||
|
print("")
|
||||||
|
print("Actions:")
|
||||||
|
print(" build [esp32,esp32s3] Build the app. Optionally specify a platform.")
|
||||||
|
print(" esp32: ESP32")
|
||||||
|
print(" esp32s3: ESP32 S3")
|
||||||
|
print(" clean Clean the build folders")
|
||||||
|
print(" clearcache Clear the SDK cache")
|
||||||
|
print(" updateself Update this tool")
|
||||||
|
print(" run [ip] Run the application")
|
||||||
|
print(" install [ip] Install the application")
|
||||||
|
print(" uninstall [ip] Uninstall the application")
|
||||||
|
print(" bir [ip] [esp32,esp32s3] Build, install then run. Optionally specify a platform.")
|
||||||
|
print(" brrr [ip] [esp32,esp32s3] Functionally the same as \"bir\", but \"app goes brrr\" meme variant.")
|
||||||
|
print("")
|
||||||
|
print("Options:")
|
||||||
|
print(" --help Show this commandline info")
|
||||||
|
print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.")
|
||||||
|
print(" --skip-build Run everything except the idf.py/CMake commands")
|
||||||
|
print(" --verbose Show extra console output")
|
||||||
|
|
||||||
|
# region Core
|
||||||
|
|
||||||
|
def download_file(url, filepath):
|
||||||
|
global verbose
|
||||||
|
if verbose:
|
||||||
|
print(f"Downloading from {url} to {filepath}")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=None,
|
||||||
|
headers={
|
||||||
|
"User-Agent": f"Tactility Build Tool {ttbuild_version}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(request)
|
||||||
|
file = open(filepath, mode="wb")
|
||||||
|
file.write(response.read())
|
||||||
|
file.close()
|
||||||
|
return True
|
||||||
|
except OSError as error:
|
||||||
|
if verbose:
|
||||||
|
print_error(f"Failed to fetch URL {url}\n{error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def print_warning(message):
|
||||||
|
print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}")
|
||||||
|
|
||||||
|
def print_error(message):
|
||||||
|
print(f"{shell_color_red}ERROR: {message}{shell_color_reset}")
|
||||||
|
|
||||||
|
def print_status_busy(status):
|
||||||
|
sys.stdout.write(f"⌛ {status}\r")
|
||||||
|
|
||||||
|
def print_status_success(status):
|
||||||
|
# Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text
|
||||||
|
print(f"✅ {shell_color_green}{status}{shell_color_reset} ")
|
||||||
|
|
||||||
|
def print_status_error(status):
|
||||||
|
# Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text
|
||||||
|
print(f"❌ {shell_color_red}{status}{shell_color_reset} ")
|
||||||
|
|
||||||
|
def exit_with_error(message):
|
||||||
|
print_error(message)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def get_url(ip, path):
|
||||||
|
return f"http://{ip}:{ttport}{path}"
|
||||||
|
|
||||||
|
def read_properties_file(path):
|
||||||
|
config = configparser.RawConfigParser()
|
||||||
|
config.read(path)
|
||||||
|
return config
|
||||||
|
|
||||||
|
#endregion Core
|
||||||
|
|
||||||
|
#region SDK helpers
|
||||||
|
|
||||||
|
def read_sdk_json():
|
||||||
|
json_file_path = os.path.join(ttbuild_path, "tool.json")
|
||||||
|
with open(json_file_path) as json_file:
|
||||||
|
return json.load(json_file)
|
||||||
|
|
||||||
|
def get_sdk_dir(version, platform):
|
||||||
|
global use_local_sdk, local_base_path
|
||||||
|
if use_local_sdk:
|
||||||
|
base_path = local_base_path
|
||||||
|
if base_path is None:
|
||||||
|
exit_with_error("TACTILITY_SDK_PATH environment variable is not set")
|
||||||
|
sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}")
|
||||||
|
sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK")
|
||||||
|
if not os.path.isdir(sdk_dir):
|
||||||
|
exit_with_error(f"Local SDK folder not found for platform {platform}: {sdk_dir}")
|
||||||
|
return sdk_dir
|
||||||
|
else:
|
||||||
|
return os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK")
|
||||||
|
|
||||||
|
def validate_local_sdks(platforms, version):
|
||||||
|
if not use_local_sdk:
|
||||||
|
return
|
||||||
|
global local_base_path
|
||||||
|
base_path = local_base_path
|
||||||
|
for platform in platforms:
|
||||||
|
sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}")
|
||||||
|
sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK")
|
||||||
|
if not os.path.isdir(sdk_dir):
|
||||||
|
exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}")
|
||||||
|
|
||||||
|
def get_sdk_root_dir(version, platform):
|
||||||
|
global ttbuild_cdn
|
||||||
|
return os.path.join(ttbuild_path, f"{version}-{platform}")
|
||||||
|
|
||||||
|
def get_sdk_url(version, file):
|
||||||
|
global ttbuild_cdn
|
||||||
|
return f"{ttbuild_cdn}/sdk/{version}/{file}"
|
||||||
|
|
||||||
|
def sdk_exists(version, platform):
|
||||||
|
sdk_dir = get_sdk_dir(version, platform)
|
||||||
|
return os.path.isdir(sdk_dir)
|
||||||
|
|
||||||
|
def should_update_tool_json():
|
||||||
|
global ttbuild_cdn
|
||||||
|
json_filepath = os.path.join(ttbuild_path, "tool.json")
|
||||||
|
if os.path.exists(json_filepath):
|
||||||
|
json_modification_time = os.path.getmtime(json_filepath)
|
||||||
|
now = time.time()
|
||||||
|
global ttbuild_sdk_json_validity
|
||||||
|
minimum_seconds_difference = ttbuild_sdk_json_validity
|
||||||
|
return (now - json_modification_time) > minimum_seconds_difference
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_tool_json():
|
||||||
|
global ttbuild_cdn, ttbuild_path
|
||||||
|
json_url = f"{ttbuild_cdn}/sdk/tool.json"
|
||||||
|
json_filepath = os.path.join(ttbuild_path, "tool.json")
|
||||||
|
return download_file(json_url, json_filepath)
|
||||||
|
|
||||||
|
def should_fetch_sdkconfig_files(platform_targets):
|
||||||
|
for platform in platform_targets:
|
||||||
|
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
||||||
|
if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fetch_sdkconfig_files(platform_targets):
|
||||||
|
for platform in platform_targets:
|
||||||
|
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
||||||
|
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
|
||||||
|
if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path):
|
||||||
|
exit_with_error(f"Failed to download sdkconfig file for {platform}")
|
||||||
|
|
||||||
|
#endregion SDK helpers
|
||||||
|
|
||||||
|
#region Validation
|
||||||
|
|
||||||
|
def validate_environment():
|
||||||
|
if os.environ.get("IDF_PATH") is None:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1")
|
||||||
|
else:
|
||||||
|
exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh")
|
||||||
|
if not os.path.exists("manifest.properties"):
|
||||||
|
exit_with_error("manifest.properties not found")
|
||||||
|
if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None:
|
||||||
|
print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.")
|
||||||
|
print_warning("If you want to use it, use the '--local-sdk' parameter")
|
||||||
|
elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None:
|
||||||
|
exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.")
|
||||||
|
|
||||||
|
def validate_self(sdk_json):
|
||||||
|
if not "toolVersion" in sdk_json:
|
||||||
|
exit_with_error("Server returned invalid SDK data format (toolVersion not found)")
|
||||||
|
if not "toolCompatibility" in sdk_json:
|
||||||
|
exit_with_error("Server returned invalid SDK data format (toolCompatibility not found)")
|
||||||
|
if not "toolDownloadUrl" in sdk_json:
|
||||||
|
exit_with_error("Server returned invalid SDK data format (toolDownloadUrl not found)")
|
||||||
|
tool_version = sdk_json["toolVersion"]
|
||||||
|
tool_compatibility = sdk_json["toolCompatibility"]
|
||||||
|
if tool_version != ttbuild_version:
|
||||||
|
print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})")
|
||||||
|
print_warning(f"Run 'tactility.py updateself' to update.")
|
||||||
|
if re.search(tool_compatibility, ttbuild_version) is None:
|
||||||
|
print_error("The tool is not compatible anymore.")
|
||||||
|
print_error("Run 'tactility.py updateself' to update.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
#endregion Validation
|
||||||
|
|
||||||
|
#region Manifest
|
||||||
|
|
||||||
|
def read_manifest():
|
||||||
|
return read_properties_file("manifest.properties")
|
||||||
|
|
||||||
|
def validate_manifest(manifest):
|
||||||
|
# [manifest]
|
||||||
|
if not "manifest" in manifest:
|
||||||
|
exit_with_error("Invalid manifest format: [manifest] not found")
|
||||||
|
if not "version" in manifest["manifest"]:
|
||||||
|
exit_with_error("Invalid manifest format: [manifest] version not found")
|
||||||
|
# [target]
|
||||||
|
if not "target" in manifest:
|
||||||
|
exit_with_error("Invalid manifest format: [target] not found")
|
||||||
|
if not "sdk" in manifest["target"]:
|
||||||
|
exit_with_error("Invalid manifest format: [target] sdk not found")
|
||||||
|
if not "platforms" in manifest["target"]:
|
||||||
|
exit_with_error("Invalid manifest format: [target] platforms not found")
|
||||||
|
# [app]
|
||||||
|
if not "app" in manifest:
|
||||||
|
exit_with_error("Invalid manifest format: [app] not found")
|
||||||
|
if not "id" in manifest["app"]:
|
||||||
|
exit_with_error("Invalid manifest format: [app] id not found")
|
||||||
|
if not "versionName" in manifest["app"]:
|
||||||
|
exit_with_error("Invalid manifest format: [app] versionName not found")
|
||||||
|
if not "versionCode" in manifest["app"]:
|
||||||
|
exit_with_error("Invalid manifest format: [app] versionCode not found")
|
||||||
|
if not "name" in manifest["app"]:
|
||||||
|
exit_with_error("Invalid manifest format: [app] name not found")
|
||||||
|
|
||||||
|
def is_valid_manifest_platform(manifest, platform):
|
||||||
|
manifest_platforms = manifest["target"]["platforms"].split(",")
|
||||||
|
return platform in manifest_platforms
|
||||||
|
|
||||||
|
def validate_manifest_platform(manifest, platform):
|
||||||
|
if not is_valid_manifest_platform(manifest, platform):
|
||||||
|
exit_with_error(f"Platform {platform} is not available in the manifest.")
|
||||||
|
|
||||||
|
def get_manifest_target_platforms(manifest, requested_platform):
|
||||||
|
if requested_platform == "" or requested_platform is None:
|
||||||
|
return manifest["target"]["platforms"].split(",")
|
||||||
|
else:
|
||||||
|
validate_manifest_platform(manifest, requested_platform)
|
||||||
|
return [requested_platform]
|
||||||
|
|
||||||
|
#endregion Manifest
|
||||||
|
|
||||||
|
#region SDK download
|
||||||
|
|
||||||
|
def sdk_download(version, platform):
|
||||||
|
sdk_root_dir = get_sdk_root_dir(version, platform)
|
||||||
|
os.makedirs(sdk_root_dir, exist_ok=True)
|
||||||
|
sdk_index_url = get_sdk_url(version, "index.json")
|
||||||
|
print(f"Downloading SDK version {version} for {platform}")
|
||||||
|
sdk_index_filepath = os.path.join(sdk_root_dir, "index.json")
|
||||||
|
if verbose:
|
||||||
|
print(f"Downloading {sdk_index_url} to {sdk_index_filepath}")
|
||||||
|
if not download_file(sdk_index_url, sdk_index_filepath):
|
||||||
|
# TODO: 404 check, print a more accurate error
|
||||||
|
print_error(f"Failed to download SDK version {version}. Check your internet connection and make sure this release exists.")
|
||||||
|
return False
|
||||||
|
with open(sdk_index_filepath) as sdk_index_json_file:
|
||||||
|
sdk_index_json = json.load(sdk_index_json_file)
|
||||||
|
sdk_platforms = sdk_index_json["platforms"]
|
||||||
|
if platform not in sdk_platforms:
|
||||||
|
print_error(f"Platform {platform} not found in {sdk_platforms} for version {version}")
|
||||||
|
return False
|
||||||
|
sdk_platform_file = sdk_platforms[platform]
|
||||||
|
sdk_zip_source_url = get_sdk_url(version, sdk_platform_file)
|
||||||
|
sdk_zip_target_filepath = os.path.join(sdk_root_dir, f"{version}-{platform}.zip")
|
||||||
|
if verbose:
|
||||||
|
print(f"Downloading {sdk_zip_source_url} to {sdk_zip_target_filepath}")
|
||||||
|
if not download_file(sdk_zip_source_url, sdk_zip_target_filepath):
|
||||||
|
print_error(f"Failed to download {sdk_zip_source_url} to {sdk_zip_target_filepath}")
|
||||||
|
return False
|
||||||
|
with zipfile.ZipFile(sdk_zip_target_filepath, "r") as zip_ref:
|
||||||
|
zip_ref.extractall(os.path.join(sdk_root_dir, "TactilitySDK"))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def sdk_download_all(version, platforms):
|
||||||
|
for platform in platforms:
|
||||||
|
if not sdk_exists(version, platform):
|
||||||
|
if not sdk_download(version, platform):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if verbose:
|
||||||
|
print(f"Using cached download for SDK version {version} and platform {platform}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
#endregion SDK download
|
||||||
|
|
||||||
|
#region Building
|
||||||
|
|
||||||
|
def get_cmake_path(platform):
|
||||||
|
return os.path.join("build", f"cmake-build-{platform}")
|
||||||
|
|
||||||
|
def find_elf_file(platform):
|
||||||
|
cmake_dir = get_cmake_path(platform)
|
||||||
|
if os.path.exists(cmake_dir):
|
||||||
|
for file in os.listdir(cmake_dir):
|
||||||
|
if file.endswith(".app.elf"):
|
||||||
|
return os.path.join(cmake_dir, file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_all(version, platforms, skip_build):
|
||||||
|
for platform in platforms:
|
||||||
|
# First build command must be "idf.py build", otherwise it fails to execute "idf.py elf"
|
||||||
|
# We check if the ELF file exists and run the correct command
|
||||||
|
# This can lead to code caching issues, so sometimes a clean build is required
|
||||||
|
if find_elf_file(platform) is None:
|
||||||
|
if not build_first(version, platform, skip_build):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if not build_consecutively(version, platform, skip_build):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def wait_for_process(process):
|
||||||
|
buffer = []
|
||||||
|
if sys.platform != "win32":
|
||||||
|
os.set_blocking(process.stdout.fileno(), False)
|
||||||
|
while process.poll() is None:
|
||||||
|
while True:
|
||||||
|
line = process.stdout.readline()
|
||||||
|
if line:
|
||||||
|
decoded_line = line.decode("UTF-8")
|
||||||
|
if decoded_line != "":
|
||||||
|
buffer.append(decoded_line)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
# Read any remaining output
|
||||||
|
for line in process.stdout:
|
||||||
|
decoded_line = line.decode("UTF-8")
|
||||||
|
if decoded_line:
|
||||||
|
buffer.append(decoded_line)
|
||||||
|
return buffer
|
||||||
|
|
||||||
|
# The first build must call "idf.py build" and consecutive builds must call "idf.py elf" as it finishes faster.
|
||||||
|
# The problem is that the "idf.py build" always results in an error, even though the elf file is created.
|
||||||
|
# The solution is to suppress the error if we find that the elf file was created.
|
||||||
|
def build_first(version, platform, skip_build):
|
||||||
|
sdk_dir = get_sdk_dir(version, platform)
|
||||||
|
if verbose:
|
||||||
|
print(f"Using SDK at {sdk_dir}")
|
||||||
|
os.environ["TACTILITY_SDK_PATH"] = sdk_dir
|
||||||
|
sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}")
|
||||||
|
shutil.copy(sdkconfig_path, "sdkconfig")
|
||||||
|
elf_path = find_elf_file(platform)
|
||||||
|
# Remove previous elf file: re-creation of the file is used to measure if the build succeeded,
|
||||||
|
# as the actual build job will always fail due to technical issues with the elf cmake script
|
||||||
|
if elf_path is not None:
|
||||||
|
os.remove(elf_path)
|
||||||
|
if skip_build:
|
||||||
|
return True
|
||||||
|
print(f"Building first {platform} build")
|
||||||
|
cmake_path = get_cmake_path(platform)
|
||||||
|
print_status_busy(f"Building {platform} ELF")
|
||||||
|
shell_needed = sys.platform == "win32"
|
||||||
|
build_command = ["idf.py", "-B", cmake_path, "build"]
|
||||||
|
if verbose:
|
||||||
|
print(f"Running command: {" ".join(build_command)}")
|
||||||
|
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process:
|
||||||
|
build_output = wait_for_process(process)
|
||||||
|
# The return code is never expected to be 0 due to a bug in the elf cmake script, but we keep it just in case
|
||||||
|
if process.returncode == 0:
|
||||||
|
print(f"{shell_color_green}Building for {platform} ✅{shell_color_reset}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if find_elf_file(platform) is None:
|
||||||
|
for line in build_output:
|
||||||
|
print(line, end="")
|
||||||
|
print_status_error(f"Building {platform} ELF")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print_status_success(f"Building {platform} ELF")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def build_consecutively(version, platform, skip_build):
|
||||||
|
sdk_dir = get_sdk_dir(version, platform)
|
||||||
|
if verbose:
|
||||||
|
print(f"Using SDK at {sdk_dir}")
|
||||||
|
os.environ["TACTILITY_SDK_PATH"] = sdk_dir
|
||||||
|
sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}")
|
||||||
|
shutil.copy(sdkconfig_path, "sdkconfig")
|
||||||
|
if skip_build:
|
||||||
|
return True
|
||||||
|
cmake_path = get_cmake_path(platform)
|
||||||
|
print_status_busy(f"Building {platform} ELF")
|
||||||
|
shell_needed = sys.platform == "win32"
|
||||||
|
build_command = ["idf.py", "-B", cmake_path, "elf"]
|
||||||
|
if verbose:
|
||||||
|
print(f"Running command: {" ".join(build_command)}")
|
||||||
|
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process:
|
||||||
|
build_output = wait_for_process(process)
|
||||||
|
if process.returncode == 0:
|
||||||
|
print_status_success(f"Building {platform} ELF")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
for line in build_output:
|
||||||
|
print(line, end="")
|
||||||
|
print_status_error(f"Building {platform} ELF")
|
||||||
|
return False
|
||||||
|
|
||||||
|
#endregion Building
|
||||||
|
|
||||||
|
#region Packaging
|
||||||
|
|
||||||
|
def package_intermediate_manifest(target_path):
|
||||||
|
if not os.path.isfile("manifest.properties"):
|
||||||
|
print_error("manifest.properties not found")
|
||||||
|
return
|
||||||
|
shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties"))
|
||||||
|
|
||||||
|
def package_intermediate_binaries(target_path, platforms):
|
||||||
|
elf_dir = os.path.join(target_path, "elf")
|
||||||
|
os.makedirs(elf_dir, exist_ok=True)
|
||||||
|
for platform in platforms:
|
||||||
|
elf_path = find_elf_file(platform)
|
||||||
|
if elf_path is None:
|
||||||
|
print_error(f"ELF file not found at {elf_path}")
|
||||||
|
return
|
||||||
|
shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}.elf"))
|
||||||
|
|
||||||
|
def package_intermediate_assets(target_path):
|
||||||
|
if os.path.isdir("assets"):
|
||||||
|
shutil.copytree("assets", os.path.join(target_path, "assets"), dirs_exist_ok=True)
|
||||||
|
|
||||||
|
def package_intermediate(platforms):
|
||||||
|
target_path = os.path.join("build", "package-intermediate")
|
||||||
|
if os.path.isdir(target_path):
|
||||||
|
shutil.rmtree(target_path)
|
||||||
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
package_intermediate_manifest(target_path)
|
||||||
|
package_intermediate_binaries(target_path, platforms)
|
||||||
|
package_intermediate_assets(target_path)
|
||||||
|
|
||||||
|
def package_name(platforms):
|
||||||
|
elf_path = find_elf_file(platforms[0])
|
||||||
|
elf_base_name = os.path.basename(elf_path).removesuffix(".app.elf")
|
||||||
|
return os.path.join("build", f"{elf_base_name}.app")
|
||||||
|
|
||||||
|
|
||||||
|
def package_all(platforms):
|
||||||
|
status = f"Building package with {platforms}"
|
||||||
|
print_status_busy(status)
|
||||||
|
package_intermediate(platforms)
|
||||||
|
# Create build/something.app
|
||||||
|
try:
|
||||||
|
tar_path = package_name(platforms)
|
||||||
|
tar = tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT)
|
||||||
|
tar.add(os.path.join("build", "package-intermediate"), arcname="")
|
||||||
|
tar.close()
|
||||||
|
print_status_success(status)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print_status_error(f"Building package failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
#endregion Packaging
|
||||||
|
|
||||||
|
def setup_environment():
|
||||||
|
global ttbuild_path
|
||||||
|
os.makedirs(ttbuild_path, exist_ok=True)
|
||||||
|
|
||||||
|
def build_action(manifest, platform_arg):
|
||||||
|
# Environment validation
|
||||||
|
validate_environment()
|
||||||
|
platforms_to_build = get_manifest_target_platforms(manifest, platform_arg)
|
||||||
|
|
||||||
|
if use_local_sdk:
|
||||||
|
global local_base_path
|
||||||
|
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
|
||||||
|
validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
|
||||||
|
|
||||||
|
if should_fetch_sdkconfig_files(platforms_to_build):
|
||||||
|
fetch_sdkconfig_files(platforms_to_build)
|
||||||
|
|
||||||
|
if not use_local_sdk:
|
||||||
|
sdk_json = read_sdk_json()
|
||||||
|
validate_self(sdk_json)
|
||||||
|
# Build
|
||||||
|
sdk_version = manifest["target"]["sdk"]
|
||||||
|
if not use_local_sdk:
|
||||||
|
if not sdk_download_all(sdk_version, platforms_to_build):
|
||||||
|
exit_with_error("Failed to download one or more SDKs")
|
||||||
|
if not build_all(sdk_version, platforms_to_build, skip_build): # Environment validation
|
||||||
|
return False
|
||||||
|
if not skip_build:
|
||||||
|
package_all(platforms_to_build)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clean_action():
|
||||||
|
if os.path.exists("build"):
|
||||||
|
print_status_busy("Removing build/")
|
||||||
|
shutil.rmtree("build")
|
||||||
|
print_status_success("Removed build/")
|
||||||
|
else:
|
||||||
|
print("Nothing to clean")
|
||||||
|
|
||||||
|
def clear_cache_action():
|
||||||
|
if os.path.exists(ttbuild_path):
|
||||||
|
print_status_busy(f"Removing {ttbuild_path}/")
|
||||||
|
shutil.rmtree(ttbuild_path)
|
||||||
|
print_status_success(f"Removed {ttbuild_path}/")
|
||||||
|
else:
|
||||||
|
print("Nothing to clear")
|
||||||
|
|
||||||
|
def update_self_action():
|
||||||
|
sdk_json = read_sdk_json()
|
||||||
|
tool_download_url = sdk_json["toolDownloadUrl"]
|
||||||
|
if download_file(tool_download_url, "tactility.py"):
|
||||||
|
print("Updated")
|
||||||
|
else:
|
||||||
|
exit_with_error("Update failed")
|
||||||
|
|
||||||
|
def get_device_info(ip):
|
||||||
|
print_status_busy(f"Requesting device info")
|
||||||
|
url = get_url(ip, "/info")
|
||||||
|
try:
|
||||||
|
response = requests.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print_error("Run failed")
|
||||||
|
else:
|
||||||
|
print_status_success(f"Received device info:")
|
||||||
|
print(response.json())
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print_status_error(f"Device info request failed: {e}")
|
||||||
|
|
||||||
|
def run_action(manifest, ip):
|
||||||
|
app_id = manifest["app"]["id"]
|
||||||
|
print_status_busy("Running")
|
||||||
|
url = get_url(ip, "/app/run")
|
||||||
|
params = {'id': app_id}
|
||||||
|
try:
|
||||||
|
response = requests.post(url, params=params)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print_error("Run failed")
|
||||||
|
else:
|
||||||
|
print_status_success("Running")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print_status_error(f"Running request failed: {e}")
|
||||||
|
|
||||||
|
def install_action(ip, platforms):
|
||||||
|
print_status_busy("Installing")
|
||||||
|
for platform in platforms:
|
||||||
|
elf_path = find_elf_file(platform)
|
||||||
|
if elf_path is None:
|
||||||
|
print_status_error(f"ELF file not built for {platform}")
|
||||||
|
return False
|
||||||
|
package_path = package_name(platforms)
|
||||||
|
# print(f"Installing {package_path} to {ip}")
|
||||||
|
url = get_url(ip, "/app/install")
|
||||||
|
try:
|
||||||
|
# Prepare multipart form data
|
||||||
|
with open(package_path, 'rb') as file:
|
||||||
|
files = {
|
||||||
|
'elf': file
|
||||||
|
}
|
||||||
|
response = requests.put(url, files=files)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print_status_error("Install failed")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print_status_success("Installing")
|
||||||
|
return True
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print_status_error(f"Install request failed: {e}")
|
||||||
|
return False
|
||||||
|
except IOError as e:
|
||||||
|
print_status_error(f"Install file error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def uninstall_action(manifest, ip):
|
||||||
|
app_id = manifest["app"]["id"]
|
||||||
|
print_status_busy("Uninstalling")
|
||||||
|
url = get_url(ip, "/app/uninstall")
|
||||||
|
params = {'id': app_id}
|
||||||
|
try:
|
||||||
|
response = requests.put(url, params=params)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print_status_error("Server responded that uninstall failed")
|
||||||
|
else:
|
||||||
|
print_status_success("Uninstalled")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print_status_error(f"Uninstall request failed: {e}")
|
||||||
|
|
||||||
|
#region Main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"Tactility Build System v{ttbuild_version}")
|
||||||
|
if "--help" in sys.argv:
|
||||||
|
print_help()
|
||||||
|
sys.exit()
|
||||||
|
# Argument validation
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
if "--verbose" in sys.argv:
|
||||||
|
verbose = True
|
||||||
|
sys.argv.remove("--verbose")
|
||||||
|
skip_build = False
|
||||||
|
if "--skip-build" in sys.argv:
|
||||||
|
skip_build = True
|
||||||
|
sys.argv.remove("--skip-build")
|
||||||
|
if "--local-sdk" in sys.argv:
|
||||||
|
use_local_sdk = True
|
||||||
|
sys.argv.remove("--local-sdk")
|
||||||
|
action_arg = sys.argv[1]
|
||||||
|
|
||||||
|
# Environment setup
|
||||||
|
setup_environment()
|
||||||
|
if not os.path.isfile("manifest.properties"):
|
||||||
|
exit_with_error("manifest.properties not found")
|
||||||
|
manifest = read_manifest()
|
||||||
|
validate_manifest(manifest)
|
||||||
|
all_platform_targets = manifest["target"]["platforms"].split(",")
|
||||||
|
# Update SDK cache (tool.json)
|
||||||
|
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
|
||||||
|
exit_with_error("Failed to retrieve SDK info")
|
||||||
|
# Actions
|
||||||
|
if action_arg == "build":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
platform = None
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
platform = sys.argv[2]
|
||||||
|
if not build_action(manifest, platform):
|
||||||
|
sys.exit(1)
|
||||||
|
elif action_arg == "clean":
|
||||||
|
clean_action()
|
||||||
|
elif action_arg == "clearcache":
|
||||||
|
clear_cache_action()
|
||||||
|
elif action_arg == "updateself":
|
||||||
|
update_self_action()
|
||||||
|
elif action_arg == "run":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
run_action(manifest, sys.argv[2])
|
||||||
|
elif action_arg == "install":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
platform = None
|
||||||
|
platforms_to_install = all_platform_targets
|
||||||
|
if len(sys.argv) >= 4:
|
||||||
|
platform = sys.argv[3]
|
||||||
|
platforms_to_install = [platform]
|
||||||
|
install_action(sys.argv[2], platforms_to_install)
|
||||||
|
elif action_arg == "uninstall":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
uninstall_action(manifest, sys.argv[2])
|
||||||
|
elif action_arg == "bir" or action_arg == "brrr":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
platform = None
|
||||||
|
platforms_to_install = all_platform_targets
|
||||||
|
if len(sys.argv) >= 4:
|
||||||
|
platform = sys.argv[3]
|
||||||
|
platforms_to_install = [platform]
|
||||||
|
if build_action(manifest, platform):
|
||||||
|
if install_action(sys.argv[2], platforms_to_install):
|
||||||
|
run_action(manifest, sys.argv[2])
|
||||||
|
else:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Unknown commandline parameter")
|
||||||
|
|
||||||
|
#endregion Main
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
|
||||||
|
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||||
|
|
||||||
|
if (DEFINED ENV{TACTILITY_SDK_PATH})
|
||||||
|
set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH})
|
||||||
|
else()
|
||||||
|
set(TACTILITY_SDK_PATH "../../release/TactilitySDK")
|
||||||
|
message(WARNING "⚠️ TACTILITY_SDK_PATH environment variable is not set, defaulting to ${TACTILITY_SDK_PATH}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
|
||||||
|
set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH})
|
||||||
|
|
||||||
|
project(Snake)
|
||||||
|
tactility_project(Snake)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Snake
|
||||||
|
|
||||||
|
The classic Snake game for Tactility.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Snake is a faithful implementation of the classic arcade game where you control a snake that grows longer as it eats food. Navigate carefully to avoid hitting walls or your own tail!
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Four Difficulty Levels**: Easy, Medium, Hard, and Hell - with wall collision toggle for the ultimate challenge.
|
||||||
|
- **Multiple Input Methods**: Touch gestures and keyboard support.
|
||||||
|
- **Visual Feedback**: Color-coded snake head and body with smooth movement.
|
||||||
|
- **Score Tracking**: Real-time score display with game over detection.
|
||||||
|
- **High Score Persistence**: Saves best scores for each difficulty level.
|
||||||
|
- **Progressive Speed**: Game speeds up as your snake grows longer.
|
||||||
|
- **Responsive UI**: Automatically adapts grid size to available screen space.
|
||||||
|
- **Non-Square Grids**: Takes advantage of the full display area.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Screenshots taken directly from my Lilygo T-Deck Plus.
|
||||||
|
Tested on Lilygo T-Deck Plus and M5Stack Cardputer.
|
||||||
|
|
||||||
|
  
|
||||||
|

|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Tactility
|
||||||
|
- Touchscreen or keyboard
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Launch the Snake app.
|
||||||
|
2. Optionally select "How to Play" to learn the controls.
|
||||||
|
3. Select your preferred difficulty (Easy, Medium, Hard, or Hell).
|
||||||
|
4. Control the snake to eat food and grow longer.
|
||||||
|
5. Avoid hitting yourself (and walls in Hell mode)!
|
||||||
|
6. Game ends when you collide - try to get the highest score!
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- **Touchscreen**: Swipe up, down, left, or right to change direction.
|
||||||
|
- **Keyboard (Arrow Keys)**: Use arrow keys (Up, Down, Left, Right) for movement.
|
||||||
|
- **Keyboard (WASD)**: Use W, A, S, D keys for movement.
|
||||||
|
- **Keyboard (Cardputer)**: Use semicolon (;), comma (,), period (.), slash (/) for up, left, down, right.
|
||||||
|
|
||||||
|
## Game Rules
|
||||||
|
|
||||||
|
- Snake starts in the center moving right.
|
||||||
|
- Eat food (red dot) to grow longer and increase score.
|
||||||
|
- Each food eaten adds one segment to your snake.
|
||||||
|
- Cannot reverse direction (no 180-degree turns).
|
||||||
|
- In Easy/Medium/Hard: Snake wraps around screen edges.
|
||||||
|
- In Hell mode: Hitting walls = instant death!
|
||||||
|
- Fill the entire grid to win (if you're that good)!
|
||||||
|
|
||||||
|
## Difficulty Levels
|
||||||
|
|
||||||
|
- **Easy**: Large cells (16px) - fewer cells, slower pace, wrap-around walls.
|
||||||
|
- **Medium**: Medium cells (12px) - balanced challenge, wrap-around walls.
|
||||||
|
- **Hard**: Small cells (8px) - many cells, requires quick reflexes, wrap-around walls.
|
||||||
|
- **Hell**: Small cells (8px) + wall collision - hitting walls means game over!
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
file(GLOB_RECURSE SOURCE_FILES
|
||||||
|
Source/*.c*
|
||||||
|
)
|
||||||
|
|
||||||
|
idf_component_register(
|
||||||
|
SRCS ${SOURCE_FILES}
|
||||||
|
# Library headers must be included directly,
|
||||||
|
# because all regular dependencies get stripped by elf_loader's cmake script
|
||||||
|
INCLUDE_DIRS ../../../Libraries/TactilityCpp/Include
|
||||||
|
REQUIRES TactilitySDK
|
||||||
|
)
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* @file Snake.cpp
|
||||||
|
* @brief Snake game app implementation for Tactility
|
||||||
|
*/
|
||||||
|
#include "Snake.h"
|
||||||
|
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <tt_hal.h>
|
||||||
|
#include <tt_lvgl_toolbar.h>
|
||||||
|
#include <tt_app_alertdialog.h>
|
||||||
|
#include <tt_app_selectiondialog.h>
|
||||||
|
#include <tt_preferences.h>
|
||||||
|
#include <TactilityCpp/LvglLock.h>
|
||||||
|
|
||||||
|
constexpr auto* TAG = "Snake";
|
||||||
|
|
||||||
|
// Preferences keys for high scores (one per difficulty)
|
||||||
|
static constexpr const char* PREF_NAMESPACE = "Snake";
|
||||||
|
static constexpr const char* PREF_HIGH_EASY = "high_easy";
|
||||||
|
static constexpr const char* PREF_HIGH_MED = "high_med";
|
||||||
|
static constexpr const char* PREF_HIGH_HARD = "high_hard";
|
||||||
|
static constexpr const char* PREF_HIGH_HELL = "high_hell";
|
||||||
|
|
||||||
|
// High scores for each difficulty (loaded from preferences)
|
||||||
|
static int32_t highScoreEasy = 0;
|
||||||
|
static int32_t highScoreMedium = 0;
|
||||||
|
static int32_t highScoreHard = 0;
|
||||||
|
static int32_t highScoreHell = 0;
|
||||||
|
|
||||||
|
static constexpr size_t DIFFICULTY_COUNT = 4;
|
||||||
|
|
||||||
|
// Selection dialog indices (0 = How to Play, 1-4 = difficulties)
|
||||||
|
static constexpr int32_t SELECTION_HOW_TO_PLAY = 0;
|
||||||
|
static constexpr int32_t SELECTION_EASY = 1;
|
||||||
|
static constexpr int32_t SELECTION_MEDIUM = 2;
|
||||||
|
static constexpr int32_t SELECTION_HARD = 3;
|
||||||
|
static constexpr int32_t SELECTION_HELL = 4;
|
||||||
|
|
||||||
|
// Difficulty options (cell sizes - larger = easier)
|
||||||
|
// Hell uses same size as Hard but with wall collision enabled
|
||||||
|
static const uint16_t difficultySizes[DIFFICULTY_COUNT] = { SNAKE_CELL_LARGE, SNAKE_CELL_MEDIUM, SNAKE_CELL_SMALL, SNAKE_CELL_SMALL };
|
||||||
|
|
||||||
|
static int getToolbarHeight(UiScale uiScale) {
|
||||||
|
if (uiScale == UiScale::UiScaleSmallest) {
|
||||||
|
return 22;
|
||||||
|
} else {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void loadHighScores() {
|
||||||
|
PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE);
|
||||||
|
if (prefs) {
|
||||||
|
tt_preferences_opt_int32(prefs, PREF_HIGH_EASY, &highScoreEasy);
|
||||||
|
tt_preferences_opt_int32(prefs, PREF_HIGH_MED, &highScoreMedium);
|
||||||
|
tt_preferences_opt_int32(prefs, PREF_HIGH_HARD, &highScoreHard);
|
||||||
|
tt_preferences_opt_int32(prefs, PREF_HIGH_HELL, &highScoreHell);
|
||||||
|
tt_preferences_free(prefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void saveHighScore(int32_t difficulty, int32_t score) {
|
||||||
|
PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE);
|
||||||
|
if (prefs) {
|
||||||
|
switch (difficulty) {
|
||||||
|
case SELECTION_EASY:
|
||||||
|
highScoreEasy = score;
|
||||||
|
tt_preferences_put_int32(prefs, PREF_HIGH_EASY, score);
|
||||||
|
break;
|
||||||
|
case SELECTION_MEDIUM:
|
||||||
|
highScoreMedium = score;
|
||||||
|
tt_preferences_put_int32(prefs, PREF_HIGH_MED, score);
|
||||||
|
break;
|
||||||
|
case SELECTION_HARD:
|
||||||
|
highScoreHard = score;
|
||||||
|
tt_preferences_put_int32(prefs, PREF_HIGH_HARD, score);
|
||||||
|
break;
|
||||||
|
case SELECTION_HELL:
|
||||||
|
highScoreHell = score;
|
||||||
|
tt_preferences_put_int32(prefs, PREF_HIGH_HELL, score);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tt_preferences_free(prefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int32_t getHighScore(int32_t difficulty) {
|
||||||
|
switch (difficulty) {
|
||||||
|
case SELECTION_EASY: return highScoreEasy;
|
||||||
|
case SELECTION_MEDIUM: return highScoreMedium;
|
||||||
|
case SELECTION_HARD: return highScoreHard;
|
||||||
|
case SELECTION_HELL: return highScoreHell;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Snake::showHelpDialog() {
|
||||||
|
const char* buttons[] = { "OK" };
|
||||||
|
helpDialogId = tt_app_alertdialog_start(
|
||||||
|
"How to Play",
|
||||||
|
"Swipe or use arrow keys to change direction.\n"
|
||||||
|
"Eat food to grow longer.\n"
|
||||||
|
"Don't hit yourself!",
|
||||||
|
buttons, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Snake::showSelectionDialog() {
|
||||||
|
const char* items[] = { "How to Play", "Easy", "Medium", "Hard", "Hell" };
|
||||||
|
selectionDialogId = tt_app_selectiondialog_start("Snake", 5, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Snake::snakeEventCb(lv_event_t* e) {
|
||||||
|
Snake* self = (Snake*)lv_event_get_user_data(e);
|
||||||
|
lv_obj_t* target = lv_event_get_target_obj(e);
|
||||||
|
lv_event_code_t code = lv_event_get_code(e);
|
||||||
|
|
||||||
|
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||||
|
if (snake_get_game_over(target)) {
|
||||||
|
int32_t score = snake_get_score(target);
|
||||||
|
int32_t length = snake_get_length(target);
|
||||||
|
int32_t prevHighScore = getHighScore(self->currentDifficulty);
|
||||||
|
bool isNewHighScore = score > prevHighScore;
|
||||||
|
|
||||||
|
// Save high score if it's a new record
|
||||||
|
if (isNewHighScore) {
|
||||||
|
saveHighScore(self->currentDifficulty, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* alertDialogLabels[] = { "OK" };
|
||||||
|
char message[120];
|
||||||
|
if (isNewHighScore && score > 0) {
|
||||||
|
snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32 "\nLENGTH: %" PRId32,
|
||||||
|
score, length);
|
||||||
|
} else {
|
||||||
|
snprintf(message, sizeof(message), "GAME OVER!\n\nSCORE: %" PRId32 "\nLENGTH: %" PRId32 "\nBEST: %" PRId32,
|
||||||
|
score, length, getHighScore(self->currentDifficulty));
|
||||||
|
}
|
||||||
|
self->gameOverDialogId = tt_app_alertdialog_start(
|
||||||
|
isNewHighScore && score > 0 ? "NEW HIGH SCORE!" : "GAME OVER!",
|
||||||
|
message, alertDialogLabels, 1);
|
||||||
|
} else {
|
||||||
|
// Update score display
|
||||||
|
lv_label_set_text_fmt(self->scoreLabel, "SCORE: %u", snake_get_score(self->gameObject));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Snake::newGameBtnEvent(lv_event_t* e) {
|
||||||
|
Snake* self = (Snake*)lv_event_get_user_data(e);
|
||||||
|
if (self == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
snake_set_new_game(self->gameObject);
|
||||||
|
// Update score label
|
||||||
|
if (self->scoreLabel) {
|
||||||
|
lv_label_set_text_fmt(self->scoreLabel, "SCORE: %u", snake_get_score(self->gameObject));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Snake::createGame(lv_obj_t* parent, uint16_t cell_size, bool wallCollision, lv_obj_t* tb) {
|
||||||
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
|
||||||
|
// Create game widget
|
||||||
|
gameObject = snake_create(parent, cell_size, wallCollision);
|
||||||
|
if (!gameObject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lv_obj_set_size(gameObject, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(gameObject, 1);
|
||||||
|
|
||||||
|
// Create score wrapper in toolbar
|
||||||
|
scoreWrapper = lv_obj_create(tb);
|
||||||
|
lv_obj_set_size(scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100));
|
||||||
|
lv_obj_set_style_pad_top(scoreWrapper, 4, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_pad_bottom(scoreWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_pad_left(scoreWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_pad_right(scoreWrapper, 10, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_pad_row(scoreWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_pad_column(scoreWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_border_width(scoreWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_bg_opa(scoreWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_remove_flag(scoreWrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
// Create score label
|
||||||
|
scoreLabel = lv_label_create(scoreWrapper);
|
||||||
|
lv_label_set_text_fmt(scoreLabel, "SCORE: %u", snake_get_score(gameObject));
|
||||||
|
lv_obj_set_style_text_align(scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_align(scoreLabel, LV_ALIGN_CENTER, 0, 0);
|
||||||
|
lv_obj_set_size(scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_style_text_font(scoreLabel, lv_font_get_default(), 0);
|
||||||
|
lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_GREEN), LV_PART_MAIN);
|
||||||
|
lv_obj_add_event_cb(gameObject, snakeEventCb, LV_EVENT_VALUE_CHANGED, this);
|
||||||
|
|
||||||
|
// Create new game button wrapper
|
||||||
|
newGameWrapper = lv_obj_create(tb);
|
||||||
|
lv_obj_set_width(newGameWrapper, LV_SIZE_CONTENT);
|
||||||
|
lv_obj_set_flex_flow(newGameWrapper, LV_FLEX_FLOW_ROW);
|
||||||
|
lv_obj_set_style_pad_all(newGameWrapper, 2, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_border_width(newGameWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_set_style_bg_opa(newGameWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
|
// Create new game button
|
||||||
|
auto ui_scale = tt_hal_configuration_get_ui_scale();
|
||||||
|
auto toolbar_height = getToolbarHeight(ui_scale);
|
||||||
|
lv_obj_t* newGameBtn = lv_btn_create(newGameWrapper);
|
||||||
|
if (ui_scale == UiScale::UiScaleSmallest) {
|
||||||
|
lv_obj_set_size(newGameBtn, toolbar_height - 8, toolbar_height - 8);
|
||||||
|
} else {
|
||||||
|
lv_obj_set_size(newGameBtn, toolbar_height - 6, toolbar_height - 6);
|
||||||
|
}
|
||||||
|
lv_obj_set_style_pad_all(newGameBtn, 0, LV_STATE_DEFAULT);
|
||||||
|
lv_obj_align(newGameBtn, LV_ALIGN_CENTER, 0, 0);
|
||||||
|
lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, this);
|
||||||
|
|
||||||
|
lv_obj_t* btnIcon = lv_image_create(newGameBtn);
|
||||||
|
lv_image_set_src(btnIcon, LV_SYMBOL_REFRESH);
|
||||||
|
lv_obj_align(btnIcon, LV_ALIGN_CENTER, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Snake::onHide(AppHandle appHandle) {
|
||||||
|
scoreLabel = nullptr;
|
||||||
|
scoreWrapper = nullptr;
|
||||||
|
toolbar = nullptr;
|
||||||
|
mainWrapper = nullptr;
|
||||||
|
newGameWrapper = nullptr;
|
||||||
|
gameObject = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Snake::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
||||||
|
// Check if we should exit (user closed selection dialog)
|
||||||
|
if (shouldExit) {
|
||||||
|
shouldExit = false;
|
||||||
|
tt_app_stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
|
||||||
|
// Create toolbar
|
||||||
|
toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
|
||||||
|
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
|
||||||
|
// Create main wrapper
|
||||||
|
mainWrapper = lv_obj_create(parent);
|
||||||
|
lv_obj_set_width(mainWrapper, LV_PCT(100));
|
||||||
|
lv_obj_set_flex_grow(mainWrapper, 1);
|
||||||
|
lv_obj_set_style_pad_all(mainWrapper, 2, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_row(mainWrapper, 2, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_column(mainWrapper, 2, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(mainWrapper, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
|
// Load high scores on first show
|
||||||
|
if (!highScoresLoaded) {
|
||||||
|
loadHighScores();
|
||||||
|
highScoresLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we need to show the help dialog
|
||||||
|
if (showHelpOnShow) {
|
||||||
|
showHelpOnShow = false;
|
||||||
|
showHelpDialog();
|
||||||
|
// Check if we have a pending difficulty selection from onResult
|
||||||
|
} else if (pendingSelection >= SELECTION_EASY && pendingSelection <= SELECTION_HELL) {
|
||||||
|
// Force layout update before creating game so dimensions are computed
|
||||||
|
lv_obj_update_layout(parent);
|
||||||
|
// Track which difficulty we're playing for high score saving
|
||||||
|
currentDifficulty = pendingSelection;
|
||||||
|
// Start game with selected difficulty (convert selection index to difficulty index)
|
||||||
|
int32_t difficultyIndex = pendingSelection - SELECTION_EASY;
|
||||||
|
// Hell mode enables wall collision (hitting walls = game over)
|
||||||
|
bool wallCollision = (pendingSelection == SELECTION_HELL);
|
||||||
|
createGame(mainWrapper, difficultySizes[difficultyIndex], wallCollision, toolbar);
|
||||||
|
pendingSelection = -1;
|
||||||
|
} else {
|
||||||
|
// Show selection dialog
|
||||||
|
showSelectionDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Snake::onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) {
|
||||||
|
// Don't manipulate LVGL objects here - they may be invalid
|
||||||
|
// Just store state for onShow to handle
|
||||||
|
|
||||||
|
if (launchId == selectionDialogId && selectionDialogId != 0) {
|
||||||
|
selectionDialogId = 0;
|
||||||
|
|
||||||
|
int32_t selection = -1;
|
||||||
|
if (resultData != nullptr) {
|
||||||
|
selection = tt_app_selectiondialog_get_result_index(resultData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection == SELECTION_HOW_TO_PLAY) {
|
||||||
|
// Mark to show help dialog in onShow
|
||||||
|
showHelpOnShow = true;
|
||||||
|
} else if (selection >= SELECTION_EASY && selection <= SELECTION_HELL) {
|
||||||
|
// Store selection for onShow to handle
|
||||||
|
pendingSelection = selection;
|
||||||
|
} else {
|
||||||
|
// User closed dialog without selecting - mark for exit
|
||||||
|
shouldExit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (launchId == helpDialogId && helpDialogId != 0) {
|
||||||
|
helpDialogId = 0;
|
||||||
|
// Return to selection dialog
|
||||||
|
pendingSelection = -1;
|
||||||
|
|
||||||
|
} else if (launchId == gameOverDialogId && gameOverDialogId != 0) {
|
||||||
|
gameOverDialogId = 0;
|
||||||
|
// Mark to show selection dialog in onShow
|
||||||
|
pendingSelection = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @file Snake.h
|
||||||
|
* @brief Snake game app class for Tactility
|
||||||
|
*/
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <tt_app.h>
|
||||||
|
#include <lvgl.h>
|
||||||
|
#include <TactilityCpp/App.h>
|
||||||
|
|
||||||
|
#include "SnakeUi.h"
|
||||||
|
#include "SnakeLogic.h"
|
||||||
|
#include "SnakeHelpers.h"
|
||||||
|
|
||||||
|
class Snake final : public App {
|
||||||
|
|
||||||
|
private:
|
||||||
|
// UI element pointers (invalidated on hide, recreated on show)
|
||||||
|
lv_obj_t* scoreLabel = nullptr;
|
||||||
|
lv_obj_t* scoreWrapper = nullptr;
|
||||||
|
lv_obj_t* toolbar = nullptr;
|
||||||
|
lv_obj_t* mainWrapper = nullptr;
|
||||||
|
lv_obj_t* newGameWrapper = nullptr;
|
||||||
|
lv_obj_t* gameObject = nullptr;
|
||||||
|
|
||||||
|
// State tracking (persists across hide/show cycles)
|
||||||
|
int32_t pendingSelection = -1; // -1 = show selection, 1-3 = start game with difficulty
|
||||||
|
bool shouldExit = false;
|
||||||
|
bool showHelpOnShow = false; // Show help dialog when onShow is called
|
||||||
|
bool highScoresLoaded = false;
|
||||||
|
int32_t currentDifficulty = -1; // Track which difficulty is being played
|
||||||
|
|
||||||
|
// Dialog launch IDs for tracking which dialog returned (only accessible to member functions)
|
||||||
|
AppLaunchId selectionDialogId = 0;
|
||||||
|
AppLaunchId gameOverDialogId = 0;
|
||||||
|
AppLaunchId helpDialogId = 0;
|
||||||
|
|
||||||
|
static void snakeEventCb(lv_event_t* e);
|
||||||
|
static void newGameBtnEvent(lv_event_t* e);
|
||||||
|
void createGame(lv_obj_t* parent, uint16_t cell_size, bool wallCollision, lv_obj_t* tb);
|
||||||
|
void showSelectionDialog();
|
||||||
|
void showHelpDialog();
|
||||||
|
|
||||||
|
public:
|
||||||
|
|
||||||
|
void onShow(AppHandle context, lv_obj_t* parent) override;
|
||||||
|
void onHide(AppHandle context) override;
|
||||||
|
void onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
|
||||||
|
};
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* @file SnakeHelpers.h
|
||||||
|
* @brief Data structures and constants for the Snake game
|
||||||
|
*/
|
||||||
|
#ifndef SNAKE_HELPERS_H
|
||||||
|
#define SNAKE_HELPERS_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*********************
|
||||||
|
* INCLUDES
|
||||||
|
*********************/
|
||||||
|
#include "lvgl.h"
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/*********************
|
||||||
|
* DEFINES
|
||||||
|
*********************/
|
||||||
|
|
||||||
|
// Cell sizes in pixels (larger = easier, smaller = harder)
|
||||||
|
#define SNAKE_CELL_LARGE 16 // Easy - bigger cells, fewer cells fit
|
||||||
|
#define SNAKE_CELL_MEDIUM 12 // Medium
|
||||||
|
#define SNAKE_CELL_SMALL 8 // Hard - smaller cells, more cells fit
|
||||||
|
|
||||||
|
// Game timing
|
||||||
|
#define SNAKE_GAME_SPEED_MS 150 // Initial timer interval in milliseconds
|
||||||
|
#define SNAKE_MIN_SPEED_MS 60 // Minimum (fastest) timer interval
|
||||||
|
#define SNAKE_SPEED_DECREASE_MS 5 // Speed increase per food eaten (ms reduction)
|
||||||
|
|
||||||
|
// Visual settings
|
||||||
|
#define SNAKE_INITIAL_LENGTH 3
|
||||||
|
#define SNAKE_CELL_RADIUS 2
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
#define SNAKE_HEAD_COLOR lv_color_hex(0x4CAF50) // Green
|
||||||
|
#define SNAKE_BODY_COLOR lv_color_hex(0x81C784) // Light green
|
||||||
|
#define SNAKE_FOOD_COLOR lv_color_hex(0xF44336) // Red
|
||||||
|
#define SNAKE_BG_COLOR lv_color_hex(0x212121) // Dark background
|
||||||
|
#define SNAKE_GRID_COLOR lv_color_hex(0x424242) // Grid lines
|
||||||
|
|
||||||
|
/**********************
|
||||||
|
* TYPEDEFS
|
||||||
|
**********************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Snake movement direction
|
||||||
|
*/
|
||||||
|
typedef enum {
|
||||||
|
SNAKE_DIR_UP = 0,
|
||||||
|
SNAKE_DIR_DOWN,
|
||||||
|
SNAKE_DIR_LEFT,
|
||||||
|
SNAKE_DIR_RIGHT
|
||||||
|
} snake_direction_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Snake body segment (doubly-linked list node)
|
||||||
|
*/
|
||||||
|
typedef struct snake_segment {
|
||||||
|
int16_t x; // Grid x position
|
||||||
|
int16_t y; // Grid y position
|
||||||
|
lv_obj_t* obj; // LVGL object for this segment
|
||||||
|
struct snake_segment* prior; // Previous segment (towards tail)
|
||||||
|
struct snake_segment* next; // Next segment (towards head)
|
||||||
|
} snake_segment_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Complete game state
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
// Snake data
|
||||||
|
snake_segment_t* head; // Snake head (linked list)
|
||||||
|
|
||||||
|
// UI elements
|
||||||
|
lv_obj_t* widget; // Parent widget (for events)
|
||||||
|
lv_obj_t* container; // Main game container
|
||||||
|
lv_obj_t* food; // Food object
|
||||||
|
lv_timer_t* timer; // Game timer
|
||||||
|
|
||||||
|
// Game state
|
||||||
|
snake_direction_t direction; // Current movement direction
|
||||||
|
snake_direction_t next_direction;// Buffered direction (prevents 180° reversal)
|
||||||
|
|
||||||
|
// Grid settings (supports non-square)
|
||||||
|
uint16_t grid_width; // Grid width in cells
|
||||||
|
uint16_t grid_height; // Grid height in cells
|
||||||
|
uint16_t cell_size; // Pixel size per cell
|
||||||
|
|
||||||
|
// Score tracking
|
||||||
|
uint16_t score;
|
||||||
|
uint16_t length;
|
||||||
|
|
||||||
|
// Food position
|
||||||
|
int16_t food_x;
|
||||||
|
int16_t food_y;
|
||||||
|
|
||||||
|
// State flags
|
||||||
|
bool game_over;
|
||||||
|
bool paused;
|
||||||
|
bool wall_collision_enabled; // If true, hitting walls = game over
|
||||||
|
} snake_game_t;
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /*extern "C"*/
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /*SNAKE_HELPERS_H*/
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* @file SnakeLogic.c
|
||||||
|
* @brief Pure game logic for the Snake game
|
||||||
|
*/
|
||||||
|
#include "SnakeLogic.h"
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initialize the snake body as a linked list
|
||||||
|
*/
|
||||||
|
snake_segment_t* snake_init_body(uint16_t length, int16_t start_x, int16_t start_y) {
|
||||||
|
if (length == 0) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create head segment
|
||||||
|
snake_segment_t* head = (snake_segment_t*)lv_malloc(sizeof(snake_segment_t));
|
||||||
|
if (!head) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
head->x = start_x;
|
||||||
|
head->y = start_y;
|
||||||
|
head->obj = NULL;
|
||||||
|
head->prior = NULL;
|
||||||
|
head->next = NULL;
|
||||||
|
|
||||||
|
// Create remaining segments (body extends to the left of head)
|
||||||
|
snake_segment_t* current = head;
|
||||||
|
for (uint16_t i = 1; i < length; i++) {
|
||||||
|
snake_segment_t* segment = (snake_segment_t*)lv_malloc(sizeof(snake_segment_t));
|
||||||
|
if (!segment) {
|
||||||
|
// Clean up already allocated segments
|
||||||
|
snake_free_body(head);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
segment->x = start_x - i; // Body extends left from head
|
||||||
|
segment->y = start_y;
|
||||||
|
segment->obj = NULL;
|
||||||
|
segment->prior = current;
|
||||||
|
segment->next = NULL;
|
||||||
|
|
||||||
|
current->next = segment;
|
||||||
|
current = segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Free all memory used by the snake body
|
||||||
|
*/
|
||||||
|
void snake_free_body(snake_segment_t* head) {
|
||||||
|
snake_segment_t* current = head;
|
||||||
|
while (current != NULL) {
|
||||||
|
snake_segment_t* next = current->next;
|
||||||
|
// Note: LVGL objects must be deleted separately by the UI layer
|
||||||
|
lv_free(current);
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Add a new segment to the tail of the snake
|
||||||
|
*/
|
||||||
|
bool snake_grow(snake_segment_t* head) {
|
||||||
|
if (!head) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the tail
|
||||||
|
snake_segment_t* tail = head;
|
||||||
|
while (tail->next != NULL) {
|
||||||
|
tail = tail->next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new segment at tail position (will be updated on next move)
|
||||||
|
snake_segment_t* new_segment = (snake_segment_t*)lv_malloc(sizeof(snake_segment_t));
|
||||||
|
if (!new_segment) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
new_segment->x = tail->x;
|
||||||
|
new_segment->y = tail->y;
|
||||||
|
new_segment->obj = NULL;
|
||||||
|
new_segment->prior = tail;
|
||||||
|
new_segment->next = NULL;
|
||||||
|
|
||||||
|
tail->next = new_segment;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Move the snake one step in the current direction
|
||||||
|
*/
|
||||||
|
bool snake_move(snake_game_t* game) {
|
||||||
|
if (!game || !game->head || game->game_over) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply buffered direction
|
||||||
|
game->direction = game->next_direction;
|
||||||
|
|
||||||
|
// Calculate new head position
|
||||||
|
int16_t new_x = game->head->x;
|
||||||
|
int16_t new_y = game->head->y;
|
||||||
|
|
||||||
|
switch (game->direction) {
|
||||||
|
case SNAKE_DIR_UP:
|
||||||
|
new_y--;
|
||||||
|
break;
|
||||||
|
case SNAKE_DIR_DOWN:
|
||||||
|
new_y++;
|
||||||
|
break;
|
||||||
|
case SNAKE_DIR_LEFT:
|
||||||
|
new_x--;
|
||||||
|
break;
|
||||||
|
case SNAKE_DIR_RIGHT:
|
||||||
|
new_x++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wall collision or wrap-around
|
||||||
|
if (game->wall_collision_enabled) {
|
||||||
|
// Wall collision mode - hitting walls = game over
|
||||||
|
if (new_x < 0 || new_x >= game->grid_width ||
|
||||||
|
new_y < 0 || new_y >= game->grid_height) {
|
||||||
|
game->game_over = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Wrap around walls
|
||||||
|
if (new_x < 0) new_x = game->grid_width - 1;
|
||||||
|
else if (new_x >= game->grid_width) new_x = 0;
|
||||||
|
if (new_y < 0) new_y = game->grid_height - 1;
|
||||||
|
else if (new_y >= game->grid_height) new_y = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for self collision BEFORE moving (so tail hasn't vacated yet)
|
||||||
|
// Skip the tail segment since it will move out of the way
|
||||||
|
snake_segment_t* segment = game->head->next;
|
||||||
|
while (segment != NULL && segment->next != NULL) { // Stop before tail
|
||||||
|
if (segment->x == new_x && segment->y == new_y) {
|
||||||
|
game->game_over = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
segment = segment->next;
|
||||||
|
}
|
||||||
|
// Also check the tail - it will move, so new head CAN go there
|
||||||
|
// (This is intentional - allows snake to "chase its tail")
|
||||||
|
|
||||||
|
// Move body segments (from tail to head, each takes position of previous)
|
||||||
|
snake_segment_t* tail = game->head;
|
||||||
|
while (tail->next != NULL) {
|
||||||
|
tail = tail->next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move from tail towards head
|
||||||
|
while (tail->prior != NULL) {
|
||||||
|
tail->x = tail->prior->x;
|
||||||
|
tail->y = tail->prior->y;
|
||||||
|
tail = tail->prior;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move head to new position
|
||||||
|
game->head->x = new_x;
|
||||||
|
game->head->y = new_y;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Set the snake's next direction (with 180° reversal prevention)
|
||||||
|
*/
|
||||||
|
bool snake_set_direction(snake_game_t* game, snake_direction_t dir) {
|
||||||
|
if (!game) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent 180° reversal - check against BUFFERED direction to handle rapid inputs
|
||||||
|
snake_direction_t current = game->next_direction;
|
||||||
|
|
||||||
|
if ((current == SNAKE_DIR_UP && dir == SNAKE_DIR_DOWN) ||
|
||||||
|
(current == SNAKE_DIR_DOWN && dir == SNAKE_DIR_UP) ||
|
||||||
|
(current == SNAKE_DIR_LEFT && dir == SNAKE_DIR_RIGHT) ||
|
||||||
|
(current == SNAKE_DIR_RIGHT && dir == SNAKE_DIR_LEFT)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
game->next_direction = dir;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if a position is occupied by the snake body
|
||||||
|
*/
|
||||||
|
static bool is_position_on_snake(snake_segment_t* head, int16_t x, int16_t y) {
|
||||||
|
snake_segment_t* current = head;
|
||||||
|
while (current != NULL) {
|
||||||
|
if (current->x == x && current->y == y) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current->next;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Spawn food at a random location not occupied by snake
|
||||||
|
* @return true if food was placed, false if grid is full (win condition)
|
||||||
|
*/
|
||||||
|
bool snake_spawn_food(snake_game_t* game) {
|
||||||
|
if (!game || game->grid_width == 0 || game->grid_height == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int16_t x, y;
|
||||||
|
const int32_t grid_size = (int32_t)game->grid_width * game->grid_height;
|
||||||
|
uint16_t snake_len = snake_count_segments(game->head);
|
||||||
|
|
||||||
|
// Use random sampling for sparse grids, deterministic search for dense grids
|
||||||
|
if (snake_len < (grid_size * 3 / 4)) {
|
||||||
|
// Random sampling - efficient for sparse grids
|
||||||
|
int attempts = 0;
|
||||||
|
do {
|
||||||
|
x = rand() % game->grid_width;
|
||||||
|
y = rand() % game->grid_height;
|
||||||
|
attempts++;
|
||||||
|
} while (is_position_on_snake(game->head, x, y) && attempts < grid_size);
|
||||||
|
|
||||||
|
if (!is_position_on_snake(game->head, x, y)) {
|
||||||
|
game->food_x = x;
|
||||||
|
game->food_y = y;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic search - guaranteed to find free cell if one exists
|
||||||
|
int16_t start_y = rand() % game->grid_height;
|
||||||
|
int16_t start_x = rand() % game->grid_width;
|
||||||
|
for (int16_t i = 0; i < game->grid_height; i++) {
|
||||||
|
int16_t gy = (start_y + i) % game->grid_height;
|
||||||
|
for (int16_t j = 0; j < game->grid_width; j++) {
|
||||||
|
int16_t gx = (start_x + j) % game->grid_width;
|
||||||
|
if (!is_position_on_snake(game->head, gx, gy)) {
|
||||||
|
game->food_x = gx;
|
||||||
|
game->food_y = gy;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid is truly full - win condition
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if snake head collides with walls (unused - wrap-around enabled)
|
||||||
|
*/
|
||||||
|
bool snake_check_wall_collision(snake_game_t* game) {
|
||||||
|
if (!game || !game->head) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int16_t x = game->head->x;
|
||||||
|
int16_t y = game->head->y;
|
||||||
|
|
||||||
|
return (x < 0 || x >= game->grid_width || y < 0 || y >= game->grid_height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if snake head collides with its own body
|
||||||
|
*/
|
||||||
|
bool snake_check_self_collision(snake_game_t* game) {
|
||||||
|
if (!game || !game->head) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int16_t head_x = game->head->x;
|
||||||
|
int16_t head_y = game->head->y;
|
||||||
|
|
||||||
|
// Check collision with body segments (skip head itself)
|
||||||
|
snake_segment_t* current = game->head->next;
|
||||||
|
while (current != NULL) {
|
||||||
|
if (current->x == head_x && current->y == head_y) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current->next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if snake head collides with food
|
||||||
|
*/
|
||||||
|
bool snake_check_food_collision(snake_game_t* game) {
|
||||||
|
if (!game || !game->head) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (game->head->x == game->food_x && game->head->y == game->food_y);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Count the number of segments in the snake
|
||||||
|
*/
|
||||||
|
uint16_t snake_count_segments(snake_segment_t* head) {
|
||||||
|
uint16_t length = 0;
|
||||||
|
snake_segment_t* current = head;
|
||||||
|
|
||||||
|
while (current != NULL) {
|
||||||
|
length++;
|
||||||
|
current = current->next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return length;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* @file SnakeLogic.h
|
||||||
|
* @brief Pure game logic for the Snake game (no UI dependencies)
|
||||||
|
*/
|
||||||
|
#ifndef SNAKE_LOGIC_H
|
||||||
|
#define SNAKE_LOGIC_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*********************
|
||||||
|
* INCLUDES
|
||||||
|
*********************/
|
||||||
|
#include "SnakeHelpers.h"
|
||||||
|
|
||||||
|
/***********************
|
||||||
|
* FUNCTION PROTOTYPES
|
||||||
|
**********************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Initialize the snake body as a linked list
|
||||||
|
* @param length Initial length of the snake
|
||||||
|
* @param start_x Starting x position (grid coordinate)
|
||||||
|
* @param start_y Starting y position (grid coordinate)
|
||||||
|
* @return Pointer to the head segment, or NULL on failure
|
||||||
|
*/
|
||||||
|
snake_segment_t* snake_init_body(uint16_t length, int16_t start_x, int16_t start_y);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Free all memory used by the snake body
|
||||||
|
* @param head Pointer to the head segment
|
||||||
|
*/
|
||||||
|
void snake_free_body(snake_segment_t* head);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Add a new segment to the tail of the snake
|
||||||
|
* @param head Pointer to the head segment
|
||||||
|
* @return true on success, false on allocation failure
|
||||||
|
*/
|
||||||
|
bool snake_grow(snake_segment_t* head);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Move the snake one step in the current direction
|
||||||
|
* @param game Game state containing snake and direction
|
||||||
|
* @return true if move was successful, false if collision occurred
|
||||||
|
*/
|
||||||
|
bool snake_move(snake_game_t* game);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Set the snake's next direction (with 180° reversal prevention)
|
||||||
|
* @param game Game state
|
||||||
|
* @param dir New direction
|
||||||
|
* @return true if direction was set, false if it would cause 180° reversal
|
||||||
|
*/
|
||||||
|
bool snake_set_direction(snake_game_t* game, snake_direction_t dir);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Spawn food at a random location not occupied by snake
|
||||||
|
* @param game Game state
|
||||||
|
* @return true if food was placed, false if grid is full (win condition)
|
||||||
|
*/
|
||||||
|
bool snake_spawn_food(snake_game_t* game);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if snake head collides with walls
|
||||||
|
* @param game Game state
|
||||||
|
* @return true if collision detected
|
||||||
|
*/
|
||||||
|
bool snake_check_wall_collision(snake_game_t* game);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if snake head collides with its own body
|
||||||
|
* @param game Game state
|
||||||
|
* @return true if collision detected
|
||||||
|
*/
|
||||||
|
bool snake_check_self_collision(snake_game_t* game);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if snake head collides with food
|
||||||
|
* @param game Game state
|
||||||
|
* @return true if collision detected
|
||||||
|
*/
|
||||||
|
bool snake_check_food_collision(snake_game_t* game);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Count the number of segments in the snake
|
||||||
|
* @param head Pointer to the head segment
|
||||||
|
* @return Number of segments
|
||||||
|
*/
|
||||||
|
uint16_t snake_count_segments(snake_segment_t* head);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /*extern "C"*/
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /*SNAKE_LOGIC_H*/
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
/**
|
||||||
|
* @file SnakeUi.c
|
||||||
|
* @brief LVGL widget implementation for the Snake game
|
||||||
|
*/
|
||||||
|
#include "SnakeUi.h"
|
||||||
|
#include "SnakeLogic.h"
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <tt_lvgl_keyboard.h>
|
||||||
|
|
||||||
|
// Forward declarations
|
||||||
|
static void game_play_event(lv_event_t* e);
|
||||||
|
static void snake_timer_cb(lv_timer_t* timer);
|
||||||
|
static void delete_event(lv_event_t* e);
|
||||||
|
static void focus_event(lv_event_t* e);
|
||||||
|
static void snake_draw(snake_game_t* game);
|
||||||
|
static void snake_create_segment_objects(snake_game_t* game);
|
||||||
|
static void snake_delete_segment_objects(snake_game_t* game);
|
||||||
|
|
||||||
|
// Static flag to ensure srand is only called once
|
||||||
|
static bool srand_initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Free all resources for the snake game object
|
||||||
|
*/
|
||||||
|
static void delete_event(lv_event_t* e) {
|
||||||
|
lv_obj_t* obj = lv_event_get_target_obj(e);
|
||||||
|
snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj);
|
||||||
|
|
||||||
|
if (game) {
|
||||||
|
// Stop timer first
|
||||||
|
if (game->timer) {
|
||||||
|
lv_timer_delete(game->timer);
|
||||||
|
game->timer = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore edit mode to false before cleanup
|
||||||
|
if (tt_lvgl_hardware_keyboard_is_available()) {
|
||||||
|
lv_group_t* group = lv_group_get_default();
|
||||||
|
if (group) {
|
||||||
|
lv_group_set_editing(group, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete LVGL objects for snake segments
|
||||||
|
snake_delete_segment_objects(game);
|
||||||
|
|
||||||
|
// Free snake body linked list
|
||||||
|
snake_free_body(game->head);
|
||||||
|
game->head = NULL;
|
||||||
|
|
||||||
|
// Food object is a child of container, will be deleted automatically
|
||||||
|
// Container is a child of obj, will be deleted automatically
|
||||||
|
|
||||||
|
lv_free(game);
|
||||||
|
lv_obj_set_user_data(obj, NULL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Handle focus/defocus to manage edit mode for keyboard input
|
||||||
|
*/
|
||||||
|
static void focus_event(lv_event_t* e) {
|
||||||
|
lv_event_code_t code = lv_event_get_code(e);
|
||||||
|
lv_group_t* group = lv_group_get_default();
|
||||||
|
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
if (code == LV_EVENT_FOCUSED) {
|
||||||
|
// Enable edit mode so arrow keys control the game
|
||||||
|
lv_group_set_editing(group, true);
|
||||||
|
} else if (code == LV_EVENT_DEFOCUSED) {
|
||||||
|
// Restore normal focus navigation
|
||||||
|
lv_group_set_editing(group, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Delete LVGL objects for all snake segments
|
||||||
|
*/
|
||||||
|
static void snake_delete_segment_objects(snake_game_t* game) {
|
||||||
|
if (!game || !game->head) return;
|
||||||
|
|
||||||
|
snake_segment_t* segment = game->head;
|
||||||
|
while (segment != NULL) {
|
||||||
|
if (segment->obj) {
|
||||||
|
lv_obj_delete(segment->obj);
|
||||||
|
segment->obj = NULL;
|
||||||
|
}
|
||||||
|
segment = segment->next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Create LVGL objects for all snake segments
|
||||||
|
*/
|
||||||
|
static void snake_create_segment_objects(snake_game_t* game) {
|
||||||
|
if (!game || !game->head || !game->container) return;
|
||||||
|
|
||||||
|
snake_segment_t* segment = game->head;
|
||||||
|
bool is_head = true;
|
||||||
|
|
||||||
|
while (segment != NULL) {
|
||||||
|
segment->obj = lv_obj_create(game->container);
|
||||||
|
lv_obj_set_size(segment->obj, game->cell_size - 2, game->cell_size - 2);
|
||||||
|
lv_obj_set_style_radius(segment->obj, SNAKE_CELL_RADIUS, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(segment->obj, 0, LV_PART_MAIN);
|
||||||
|
|
||||||
|
if (is_head) {
|
||||||
|
lv_obj_set_style_bg_color(segment->obj, SNAKE_HEAD_COLOR, LV_PART_MAIN);
|
||||||
|
is_head = false;
|
||||||
|
} else {
|
||||||
|
lv_obj_set_style_bg_color(segment->obj, SNAKE_BODY_COLOR, LV_PART_MAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position will be set by snake_draw()
|
||||||
|
segment = segment->next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Update LVGL object positions based on game state
|
||||||
|
*/
|
||||||
|
static void snake_draw(snake_game_t* game) {
|
||||||
|
if (!game || !game->container) return;
|
||||||
|
|
||||||
|
// Update snake segment positions
|
||||||
|
snake_segment_t* segment = game->head;
|
||||||
|
while (segment != NULL) {
|
||||||
|
if (segment->obj) {
|
||||||
|
lv_coord_t px = segment->x * game->cell_size + 1;
|
||||||
|
lv_coord_t py = segment->y * game->cell_size + 1;
|
||||||
|
lv_obj_set_pos(segment->obj, px, py);
|
||||||
|
}
|
||||||
|
segment = segment->next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update food position
|
||||||
|
if (game->food) {
|
||||||
|
lv_coord_t fx = game->food_x * game->cell_size + 1;
|
||||||
|
lv_coord_t fy = game->food_y * game->cell_size + 1;
|
||||||
|
lv_obj_set_pos(game->food, fx, fy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Timer callback - moves snake and updates display
|
||||||
|
*/
|
||||||
|
static void snake_timer_cb(lv_timer_t* timer) {
|
||||||
|
snake_game_t* game = (snake_game_t*)lv_timer_get_user_data(timer);
|
||||||
|
if (!game || game->game_over || game->paused) return;
|
||||||
|
|
||||||
|
// Move snake
|
||||||
|
bool move_ok = snake_move(game);
|
||||||
|
|
||||||
|
if (!move_ok) {
|
||||||
|
// Collision occurred, game over
|
||||||
|
game->game_over = true;
|
||||||
|
lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check food collision
|
||||||
|
if (snake_check_food_collision(game)) {
|
||||||
|
// Grow snake and update score
|
||||||
|
if (!snake_grow(game->head)) {
|
||||||
|
// Memory allocation failed - treat as game over
|
||||||
|
game->game_over = true;
|
||||||
|
lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
game->score++;
|
||||||
|
game->length++;
|
||||||
|
|
||||||
|
// Create LVGL object for new segment
|
||||||
|
snake_segment_t* tail = game->head;
|
||||||
|
while (tail->next != NULL) {
|
||||||
|
tail = tail->next;
|
||||||
|
}
|
||||||
|
if (tail && !tail->obj && game->container) {
|
||||||
|
tail->obj = lv_obj_create(game->container);
|
||||||
|
if (!tail->obj) {
|
||||||
|
game->game_over = true;
|
||||||
|
lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lv_obj_set_size(tail->obj, game->cell_size - 2, game->cell_size - 2);
|
||||||
|
lv_obj_set_style_radius(tail->obj, SNAKE_CELL_RADIUS, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(tail->obj, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(tail->obj, SNAKE_BODY_COLOR, LV_PART_MAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn new food - if fails, grid is full (win!)
|
||||||
|
if (!snake_spawn_food(game)) {
|
||||||
|
// Hide food since there's no valid position
|
||||||
|
if (game->food) {
|
||||||
|
lv_obj_add_flag(game->food, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
// Player won - end the game
|
||||||
|
game->game_over = true;
|
||||||
|
lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increase speed (decrease timer period) as snake grows
|
||||||
|
uint32_t foods_eaten = game->length - SNAKE_INITIAL_LENGTH;
|
||||||
|
uint32_t speed_reduction = foods_eaten * SNAKE_SPEED_DECREASE_MS;
|
||||||
|
uint32_t new_period = SNAKE_GAME_SPEED_MS;
|
||||||
|
if (speed_reduction < (SNAKE_GAME_SPEED_MS - SNAKE_MIN_SPEED_MS)) {
|
||||||
|
new_period = SNAKE_GAME_SPEED_MS - speed_reduction;
|
||||||
|
} else {
|
||||||
|
new_period = SNAKE_MIN_SPEED_MS;
|
||||||
|
}
|
||||||
|
lv_timer_set_period(game->timer, new_period);
|
||||||
|
|
||||||
|
// Notify score change
|
||||||
|
lv_obj_send_event(game->widget, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update display
|
||||||
|
snake_draw(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Event callback for game play (gesture/key)
|
||||||
|
*/
|
||||||
|
static void game_play_event(lv_event_t* e) {
|
||||||
|
lv_event_code_t code = lv_event_get_code(e);
|
||||||
|
lv_obj_t* obj = (lv_obj_t*)lv_event_get_user_data(e);
|
||||||
|
snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj);
|
||||||
|
|
||||||
|
if (!game || game->game_over) return;
|
||||||
|
|
||||||
|
if (code == LV_EVENT_GESTURE) {
|
||||||
|
lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_active());
|
||||||
|
switch (dir) {
|
||||||
|
case LV_DIR_TOP:
|
||||||
|
snake_set_direction(game, SNAKE_DIR_UP);
|
||||||
|
break;
|
||||||
|
case LV_DIR_BOTTOM:
|
||||||
|
snake_set_direction(game, SNAKE_DIR_DOWN);
|
||||||
|
break;
|
||||||
|
case LV_DIR_LEFT:
|
||||||
|
snake_set_direction(game, SNAKE_DIR_LEFT);
|
||||||
|
break;
|
||||||
|
case LV_DIR_RIGHT:
|
||||||
|
snake_set_direction(game, SNAKE_DIR_RIGHT);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (code == LV_EVENT_KEY) {
|
||||||
|
uint32_t key = lv_event_get_key(e);
|
||||||
|
// Arrow keys, WASD, and punctuation keys for cardputer
|
||||||
|
switch (key) {
|
||||||
|
case LV_KEY_UP:
|
||||||
|
case 'w':
|
||||||
|
case 'W':
|
||||||
|
case ';':
|
||||||
|
snake_set_direction(game, SNAKE_DIR_UP);
|
||||||
|
break;
|
||||||
|
case LV_KEY_DOWN:
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
case '.':
|
||||||
|
snake_set_direction(game, SNAKE_DIR_DOWN);
|
||||||
|
break;
|
||||||
|
case LV_KEY_LEFT:
|
||||||
|
case 'a':
|
||||||
|
case 'A':
|
||||||
|
case ',':
|
||||||
|
snake_set_direction(game, SNAKE_DIR_LEFT);
|
||||||
|
break;
|
||||||
|
case LV_KEY_RIGHT:
|
||||||
|
case 'd':
|
||||||
|
case 'D':
|
||||||
|
case '/':
|
||||||
|
snake_set_direction(game, SNAKE_DIR_RIGHT);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Create a new Snake game widget
|
||||||
|
*/
|
||||||
|
lv_obj_t* snake_create(lv_obj_t* parent, uint16_t cell_size, bool wall_collision) {
|
||||||
|
// Create main object
|
||||||
|
lv_obj_t* obj = lv_obj_create(parent);
|
||||||
|
if (!obj) return NULL;
|
||||||
|
|
||||||
|
// Allocate game state
|
||||||
|
snake_game_t* game = (snake_game_t*)lv_malloc(sizeof(snake_game_t));
|
||||||
|
if (!game) {
|
||||||
|
lv_obj_delete(obj);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
memset(game, 0, sizeof(snake_game_t));
|
||||||
|
lv_obj_set_user_data(obj, game);
|
||||||
|
|
||||||
|
// Store widget reference for events
|
||||||
|
game->widget = obj;
|
||||||
|
|
||||||
|
// Initialize game state
|
||||||
|
game->cell_size = cell_size;
|
||||||
|
game->score = 0;
|
||||||
|
game->length = SNAKE_INITIAL_LENGTH;
|
||||||
|
game->direction = SNAKE_DIR_RIGHT;
|
||||||
|
game->next_direction = SNAKE_DIR_RIGHT;
|
||||||
|
game->game_over = false;
|
||||||
|
game->paused = false;
|
||||||
|
game->wall_collision_enabled = wall_collision;
|
||||||
|
|
||||||
|
// Set up main object
|
||||||
|
lv_obj_set_size(obj, LV_PCT(100), LV_PCT(100));
|
||||||
|
lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, LV_PART_MAIN);
|
||||||
|
|
||||||
|
// Create game container (the actual playing field)
|
||||||
|
game->container = lv_obj_create(obj);
|
||||||
|
lv_obj_remove_flag(game->container, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
lv_obj_set_style_bg_color(game->container, SNAKE_BG_COLOR, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(game->container, 1, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_color(game->container, SNAKE_GRID_COLOR, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_pad_all(game->container, 0, LV_PART_MAIN);
|
||||||
|
lv_group_remove_obj(game->container);
|
||||||
|
lv_obj_remove_flag(game->container, LV_OBJ_FLAG_GESTURE_BUBBLE);
|
||||||
|
|
||||||
|
// Calculate grid dimensions based on available space (non-square)
|
||||||
|
lv_coord_t available_w = lv_obj_get_content_width(parent) - 6;
|
||||||
|
lv_coord_t available_h = lv_obj_get_content_height(parent) - 6;
|
||||||
|
|
||||||
|
if (available_w < 50) available_w = 50;
|
||||||
|
if (available_h < 50) available_h = 50;
|
||||||
|
|
||||||
|
// Calculate how many cells fit in each dimension
|
||||||
|
game->grid_width = available_w / cell_size;
|
||||||
|
game->grid_height = available_h / cell_size;
|
||||||
|
|
||||||
|
// Ensure minimum grid size (3x3 minimum for playable game)
|
||||||
|
if (game->grid_width < 3) game->grid_width = 3;
|
||||||
|
if (game->grid_height < 3) game->grid_height = 3;
|
||||||
|
|
||||||
|
// Calculate actual field size
|
||||||
|
lv_coord_t field_w = game->cell_size * game->grid_width;
|
||||||
|
lv_coord_t field_h = game->cell_size * game->grid_height;
|
||||||
|
|
||||||
|
lv_obj_set_size(game->container, field_w, field_h);
|
||||||
|
lv_obj_center(game->container);
|
||||||
|
|
||||||
|
// Initialize snake body at center
|
||||||
|
int16_t start_x = game->grid_width / 2;
|
||||||
|
int16_t start_y = game->grid_height / 2;
|
||||||
|
game->head = snake_init_body(SNAKE_INITIAL_LENGTH, start_x, start_y);
|
||||||
|
if (!game->head) {
|
||||||
|
lv_obj_set_user_data(obj, NULL);
|
||||||
|
lv_free(game);
|
||||||
|
lv_obj_delete(obj);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create LVGL objects for snake segments
|
||||||
|
snake_create_segment_objects(game);
|
||||||
|
|
||||||
|
// Create food object
|
||||||
|
game->food = lv_obj_create(game->container);
|
||||||
|
lv_obj_set_size(game->food, game->cell_size - 2, game->cell_size - 2);
|
||||||
|
lv_obj_set_style_radius(game->food, game->cell_size / 2, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_border_width(game->food, 0, LV_PART_MAIN);
|
||||||
|
lv_obj_set_style_bg_color(game->food, SNAKE_FOOD_COLOR, LV_PART_MAIN);
|
||||||
|
|
||||||
|
// Initialize random seed only once
|
||||||
|
if (!srand_initialized) {
|
||||||
|
srand((unsigned int)time(NULL));
|
||||||
|
srand_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn initial food
|
||||||
|
snake_spawn_food(game);
|
||||||
|
|
||||||
|
// Initial draw
|
||||||
|
snake_draw(game);
|
||||||
|
|
||||||
|
// Add event callbacks for touch gestures and keyboard
|
||||||
|
lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_GESTURE, obj);
|
||||||
|
lv_obj_add_event_cb(game->container, game_play_event, LV_EVENT_KEY, obj);
|
||||||
|
lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL);
|
||||||
|
|
||||||
|
// Set up keyboard focus if available
|
||||||
|
if (tt_lvgl_hardware_keyboard_is_available()) {
|
||||||
|
lv_group_t* group = lv_group_get_default();
|
||||||
|
if (group) {
|
||||||
|
lv_group_add_obj(group, game->container);
|
||||||
|
// Register focus handlers to manage edit mode lifecycle
|
||||||
|
lv_obj_add_event_cb(game->container, focus_event, LV_EVENT_FOCUSED, NULL);
|
||||||
|
lv_obj_add_event_cb(game->container, focus_event, LV_EVENT_DEFOCUSED, NULL);
|
||||||
|
// Focus the container (will trigger FOCUSED event and enable edit mode)
|
||||||
|
lv_group_focus_obj(game->container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start game timer
|
||||||
|
game->timer = lv_timer_create(snake_timer_cb, SNAKE_GAME_SPEED_MS, game);
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Reset the game to start a new game
|
||||||
|
*/
|
||||||
|
void snake_set_new_game(lv_obj_t* obj) {
|
||||||
|
snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj);
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
// Stop timer during reset
|
||||||
|
if (game->timer) {
|
||||||
|
lv_timer_pause(game->timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old snake segment objects
|
||||||
|
snake_delete_segment_objects(game);
|
||||||
|
|
||||||
|
// Free old snake body
|
||||||
|
snake_free_body(game->head);
|
||||||
|
game->head = NULL;
|
||||||
|
|
||||||
|
// Reset game state
|
||||||
|
game->score = 0;
|
||||||
|
game->length = SNAKE_INITIAL_LENGTH;
|
||||||
|
game->direction = SNAKE_DIR_RIGHT;
|
||||||
|
game->next_direction = SNAKE_DIR_RIGHT;
|
||||||
|
game->game_over = false;
|
||||||
|
game->paused = false;
|
||||||
|
|
||||||
|
// Create new snake body at center
|
||||||
|
int16_t start_x = game->grid_width / 2;
|
||||||
|
int16_t start_y = game->grid_height / 2;
|
||||||
|
game->head = snake_init_body(SNAKE_INITIAL_LENGTH, start_x, start_y);
|
||||||
|
|
||||||
|
if (!game->head) {
|
||||||
|
// Memory allocation failed - keep game in over state
|
||||||
|
game->game_over = true;
|
||||||
|
lv_obj_send_event(obj, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new segment objects
|
||||||
|
snake_create_segment_objects(game);
|
||||||
|
|
||||||
|
// Spawn new food and unhide it
|
||||||
|
if (snake_spawn_food(game)) {
|
||||||
|
if (game->food) {
|
||||||
|
lv_obj_remove_flag(game->food, LV_OBJ_FLAG_HIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw
|
||||||
|
snake_draw(game);
|
||||||
|
|
||||||
|
// Resume timer
|
||||||
|
if (game->timer) {
|
||||||
|
lv_timer_resume(game->timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify change
|
||||||
|
lv_obj_send_event(obj, LV_EVENT_VALUE_CHANGED, NULL);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the current score
|
||||||
|
*/
|
||||||
|
uint16_t snake_get_score(lv_obj_t* obj) {
|
||||||
|
snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj);
|
||||||
|
if (!game) return 0;
|
||||||
|
return game->score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the current snake length
|
||||||
|
*/
|
||||||
|
uint16_t snake_get_length(lv_obj_t* obj) {
|
||||||
|
snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj);
|
||||||
|
if (!game) return 0;
|
||||||
|
return game->length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if game is over
|
||||||
|
*/
|
||||||
|
bool snake_get_game_over(lv_obj_t* obj) {
|
||||||
|
snake_game_t* game = (snake_game_t*)lv_obj_get_user_data(obj);
|
||||||
|
if (!game) return true;
|
||||||
|
return game->game_over;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @file SnakeUi.h
|
||||||
|
* @brief LVGL widget interface for the Snake game
|
||||||
|
*/
|
||||||
|
#ifndef SNAKE_UI_H
|
||||||
|
#define SNAKE_UI_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*********************
|
||||||
|
* INCLUDES
|
||||||
|
*********************/
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include "SnakeHelpers.h"
|
||||||
|
#include "lvgl.h"
|
||||||
|
|
||||||
|
/***********************
|
||||||
|
* FUNCTION PROTOTYPES
|
||||||
|
**********************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Create a new Snake game widget
|
||||||
|
* @param parent Parent LVGL object
|
||||||
|
* @param cell_size Size of each cell in pixels (SNAKE_CELL_SMALL, MEDIUM, or LARGE)
|
||||||
|
* @param wall_collision If true, hitting walls = game over; if false, snake wraps around
|
||||||
|
* @return Pointer to the created LVGL object, or NULL on failure
|
||||||
|
*/
|
||||||
|
lv_obj_t* snake_create(lv_obj_t* parent, uint16_t cell_size, bool wall_collision);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Reset the game to start a new game
|
||||||
|
* @param obj Snake game LVGL object
|
||||||
|
*/
|
||||||
|
void snake_set_new_game(lv_obj_t* obj);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the current score
|
||||||
|
* @param obj Snake game LVGL object
|
||||||
|
* @return Current score
|
||||||
|
*/
|
||||||
|
uint16_t snake_get_score(lv_obj_t* obj);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Get the current snake length
|
||||||
|
* @param obj Snake game LVGL object
|
||||||
|
* @return Current length
|
||||||
|
*/
|
||||||
|
uint16_t snake_get_length(lv_obj_t* obj);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Check if game is over
|
||||||
|
* @param obj Snake game LVGL object
|
||||||
|
* @return true if game is over
|
||||||
|
*/
|
||||||
|
bool snake_get_game_over(lv_obj_t* obj);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} /*extern "C"*/
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /*SNAKE_UI_H*/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#include "Snake.h"
|
||||||
|
#include <TactilityCpp/App.h>
|
||||||
|
|
||||||
|
extern "C" {
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
registerApp<Snake>();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[manifest]
|
||||||
|
version=0.1
|
||||||
|
[target]
|
||||||
|
sdk=0.7.0-dev
|
||||||
|
platforms=esp32,esp32s3,esp32c6,esp32p4
|
||||||
|
[app]
|
||||||
|
id=one.tactility.snake
|
||||||
|
versionName=0.3.0
|
||||||
|
versionCode=3
|
||||||
|
name=Snake
|
||||||
|
description=Classic Snake game
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
import configparser
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
import requests
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
ttbuild_path = ".tactility"
|
||||||
|
ttbuild_version = "3.2.0"
|
||||||
|
ttbuild_cdn = "https://cdn.tactilityproject.org"
|
||||||
|
ttbuild_sdk_json_validity = 3600 # seconds
|
||||||
|
ttport = 6666
|
||||||
|
verbose = False
|
||||||
|
use_local_sdk = False
|
||||||
|
local_base_path = None
|
||||||
|
|
||||||
|
shell_color_red = "\033[91m"
|
||||||
|
shell_color_orange = "\033[93m"
|
||||||
|
shell_color_green = "\033[32m"
|
||||||
|
shell_color_purple = "\033[35m"
|
||||||
|
shell_color_cyan = "\033[36m"
|
||||||
|
shell_color_reset = "\033[m"
|
||||||
|
|
||||||
|
def print_help():
|
||||||
|
print("Usage: python tactility.py [action] [options]")
|
||||||
|
print("")
|
||||||
|
print("Actions:")
|
||||||
|
print(" build [esp32,esp32s3] Build the app. Optionally specify a platform.")
|
||||||
|
print(" esp32: ESP32")
|
||||||
|
print(" esp32s3: ESP32 S3")
|
||||||
|
print(" clean Clean the build folders")
|
||||||
|
print(" clearcache Clear the SDK cache")
|
||||||
|
print(" updateself Update this tool")
|
||||||
|
print(" run [ip] Run the application")
|
||||||
|
print(" install [ip] Install the application")
|
||||||
|
print(" uninstall [ip] Uninstall the application")
|
||||||
|
print(" bir [ip] [esp32,esp32s3] Build, install then run. Optionally specify a platform.")
|
||||||
|
print(" brrr [ip] [esp32,esp32s3] Functionally the same as \"bir\", but \"app goes brrr\" meme variant.")
|
||||||
|
print("")
|
||||||
|
print("Options:")
|
||||||
|
print(" --help Show this commandline info")
|
||||||
|
print(" --local-sdk Use SDK specified by environment variable TACTILITY_SDK_PATH with platform subfolders matching target platforms.")
|
||||||
|
print(" --skip-build Run everything except the idf.py/CMake commands")
|
||||||
|
print(" --verbose Show extra console output")
|
||||||
|
|
||||||
|
# region Core
|
||||||
|
|
||||||
|
def download_file(url, filepath):
|
||||||
|
global verbose
|
||||||
|
if verbose:
|
||||||
|
print(f"Downloading from {url} to {filepath}")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=None,
|
||||||
|
headers={
|
||||||
|
"User-Agent": f"Tactility Build Tool {ttbuild_version}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(request)
|
||||||
|
file = open(filepath, mode="wb")
|
||||||
|
file.write(response.read())
|
||||||
|
file.close()
|
||||||
|
return True
|
||||||
|
except OSError as error:
|
||||||
|
if verbose:
|
||||||
|
print_error(f"Failed to fetch URL {url}\n{error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def print_warning(message):
|
||||||
|
print(f"{shell_color_orange}WARNING: {message}{shell_color_reset}")
|
||||||
|
|
||||||
|
def print_error(message):
|
||||||
|
print(f"{shell_color_red}ERROR: {message}{shell_color_reset}")
|
||||||
|
|
||||||
|
def print_status_busy(status):
|
||||||
|
sys.stdout.write(f"⌛ {status}\r")
|
||||||
|
|
||||||
|
def print_status_success(status):
|
||||||
|
# Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text
|
||||||
|
print(f"✅ {shell_color_green}{status}{shell_color_reset} ")
|
||||||
|
|
||||||
|
def print_status_error(status):
|
||||||
|
# Trailing spaces are to overwrite previously written characters by a potentially shorter print_status_busy() text
|
||||||
|
print(f"❌ {shell_color_red}{status}{shell_color_reset} ")
|
||||||
|
|
||||||
|
def exit_with_error(message):
|
||||||
|
print_error(message)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def get_url(ip, path):
|
||||||
|
return f"http://{ip}:{ttport}{path}"
|
||||||
|
|
||||||
|
def read_properties_file(path):
|
||||||
|
config = configparser.RawConfigParser()
|
||||||
|
config.read(path)
|
||||||
|
return config
|
||||||
|
|
||||||
|
#endregion Core
|
||||||
|
|
||||||
|
#region SDK helpers
|
||||||
|
|
||||||
|
def read_sdk_json():
|
||||||
|
json_file_path = os.path.join(ttbuild_path, "tool.json")
|
||||||
|
with open(json_file_path) as json_file:
|
||||||
|
return json.load(json_file)
|
||||||
|
|
||||||
|
def get_sdk_dir(version, platform):
|
||||||
|
global use_local_sdk, local_base_path
|
||||||
|
if use_local_sdk:
|
||||||
|
base_path = local_base_path
|
||||||
|
if base_path is None:
|
||||||
|
exit_with_error("TACTILITY_SDK_PATH environment variable is not set")
|
||||||
|
sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}")
|
||||||
|
sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK")
|
||||||
|
if not os.path.isdir(sdk_dir):
|
||||||
|
exit_with_error(f"Local SDK folder not found for platform {platform}: {sdk_dir}")
|
||||||
|
return sdk_dir
|
||||||
|
else:
|
||||||
|
return os.path.join(ttbuild_path, f"{version}-{platform}", "TactilitySDK")
|
||||||
|
|
||||||
|
def validate_local_sdks(platforms, version):
|
||||||
|
if not use_local_sdk:
|
||||||
|
return
|
||||||
|
global local_base_path
|
||||||
|
base_path = local_base_path
|
||||||
|
for platform in platforms:
|
||||||
|
sdk_parent_dir = os.path.join(base_path, f"{version}-{platform}")
|
||||||
|
sdk_dir = os.path.join(sdk_parent_dir, "TactilitySDK")
|
||||||
|
if not os.path.isdir(sdk_dir):
|
||||||
|
exit_with_error(f"Local SDK folder missing for {platform}: {sdk_dir}")
|
||||||
|
|
||||||
|
def get_sdk_root_dir(version, platform):
|
||||||
|
global ttbuild_cdn
|
||||||
|
return os.path.join(ttbuild_path, f"{version}-{platform}")
|
||||||
|
|
||||||
|
def get_sdk_url(version, file):
|
||||||
|
global ttbuild_cdn
|
||||||
|
return f"{ttbuild_cdn}/sdk/{version}/{file}"
|
||||||
|
|
||||||
|
def sdk_exists(version, platform):
|
||||||
|
sdk_dir = get_sdk_dir(version, platform)
|
||||||
|
return os.path.isdir(sdk_dir)
|
||||||
|
|
||||||
|
def should_update_tool_json():
|
||||||
|
global ttbuild_cdn
|
||||||
|
json_filepath = os.path.join(ttbuild_path, "tool.json")
|
||||||
|
if os.path.exists(json_filepath):
|
||||||
|
json_modification_time = os.path.getmtime(json_filepath)
|
||||||
|
now = time.time()
|
||||||
|
global ttbuild_sdk_json_validity
|
||||||
|
minimum_seconds_difference = ttbuild_sdk_json_validity
|
||||||
|
return (now - json_modification_time) > minimum_seconds_difference
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_tool_json():
|
||||||
|
global ttbuild_cdn, ttbuild_path
|
||||||
|
json_url = f"{ttbuild_cdn}/sdk/tool.json"
|
||||||
|
json_filepath = os.path.join(ttbuild_path, "tool.json")
|
||||||
|
return download_file(json_url, json_filepath)
|
||||||
|
|
||||||
|
def should_fetch_sdkconfig_files(platform_targets):
|
||||||
|
for platform in platform_targets:
|
||||||
|
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
||||||
|
if not os.path.exists(os.path.join(ttbuild_path, sdkconfig_filename)):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fetch_sdkconfig_files(platform_targets):
|
||||||
|
for platform in platform_targets:
|
||||||
|
sdkconfig_filename = f"sdkconfig.app.{platform}"
|
||||||
|
target_path = os.path.join(ttbuild_path, sdkconfig_filename)
|
||||||
|
if not download_file(f"{ttbuild_cdn}/{sdkconfig_filename}", target_path):
|
||||||
|
exit_with_error(f"Failed to download sdkconfig file for {platform}")
|
||||||
|
|
||||||
|
#endregion SDK helpers
|
||||||
|
|
||||||
|
#region Validation
|
||||||
|
|
||||||
|
def validate_environment():
|
||||||
|
if os.environ.get("IDF_PATH") is None:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via %IDF_PATH%\\export.ps1")
|
||||||
|
else:
|
||||||
|
exit_with_error("Cannot find the Espressif IDF SDK. Ensure it is installed and that it is activated via $PATH_TO_IDF_SDK/export.sh")
|
||||||
|
if not os.path.exists("manifest.properties"):
|
||||||
|
exit_with_error("manifest.properties not found")
|
||||||
|
if use_local_sdk == False and os.environ.get("TACTILITY_SDK_PATH") is not None:
|
||||||
|
print_warning("TACTILITY_SDK_PATH is set, but will be ignored by this command.")
|
||||||
|
print_warning("If you want to use it, use the '--local-sdk' parameter")
|
||||||
|
elif use_local_sdk == True and os.environ.get("TACTILITY_SDK_PATH") is None:
|
||||||
|
exit_with_error("local build was requested, but TACTILITY_SDK_PATH environment variable is not set.")
|
||||||
|
|
||||||
|
def validate_self(sdk_json):
|
||||||
|
if not "toolVersion" in sdk_json:
|
||||||
|
exit_with_error("Server returned invalid SDK data format (toolVersion not found)")
|
||||||
|
if not "toolCompatibility" in sdk_json:
|
||||||
|
exit_with_error("Server returned invalid SDK data format (toolCompatibility not found)")
|
||||||
|
if not "toolDownloadUrl" in sdk_json:
|
||||||
|
exit_with_error("Server returned invalid SDK data format (toolDownloadUrl not found)")
|
||||||
|
tool_version = sdk_json["toolVersion"]
|
||||||
|
tool_compatibility = sdk_json["toolCompatibility"]
|
||||||
|
if tool_version != ttbuild_version:
|
||||||
|
print_warning(f"New version available: {tool_version} (currently using {ttbuild_version})")
|
||||||
|
print_warning(f"Run 'tactility.py updateself' to update.")
|
||||||
|
if re.search(tool_compatibility, ttbuild_version) is None:
|
||||||
|
print_error("The tool is not compatible anymore.")
|
||||||
|
print_error("Run 'tactility.py updateself' to update.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
#endregion Validation
|
||||||
|
|
||||||
|
#region Manifest
|
||||||
|
|
||||||
|
def read_manifest():
|
||||||
|
return read_properties_file("manifest.properties")
|
||||||
|
|
||||||
|
def validate_manifest(manifest):
|
||||||
|
# [manifest]
|
||||||
|
if not "manifest" in manifest:
|
||||||
|
exit_with_error("Invalid manifest format: [manifest] not found")
|
||||||
|
if not "version" in manifest["manifest"]:
|
||||||
|
exit_with_error("Invalid manifest format: [manifest] version not found")
|
||||||
|
# [target]
|
||||||
|
if not "target" in manifest:
|
||||||
|
exit_with_error("Invalid manifest format: [target] not found")
|
||||||
|
if not "sdk" in manifest["target"]:
|
||||||
|
exit_with_error("Invalid manifest format: [target] sdk not found")
|
||||||
|
if not "platforms" in manifest["target"]:
|
||||||
|
exit_with_error("Invalid manifest format: [target] platforms not found")
|
||||||
|
# [app]
|
||||||
|
if not "app" in manifest:
|
||||||
|
exit_with_error("Invalid manifest format: [app] not found")
|
||||||
|
if not "id" in manifest["app"]:
|
||||||
|
exit_with_error("Invalid manifest format: [app] id not found")
|
||||||
|
if not "versionName" in manifest["app"]:
|
||||||
|
exit_with_error("Invalid manifest format: [app] versionName not found")
|
||||||
|
if not "versionCode" in manifest["app"]:
|
||||||
|
exit_with_error("Invalid manifest format: [app] versionCode not found")
|
||||||
|
if not "name" in manifest["app"]:
|
||||||
|
exit_with_error("Invalid manifest format: [app] name not found")
|
||||||
|
|
||||||
|
def is_valid_manifest_platform(manifest, platform):
|
||||||
|
manifest_platforms = manifest["target"]["platforms"].split(",")
|
||||||
|
return platform in manifest_platforms
|
||||||
|
|
||||||
|
def validate_manifest_platform(manifest, platform):
|
||||||
|
if not is_valid_manifest_platform(manifest, platform):
|
||||||
|
exit_with_error(f"Platform {platform} is not available in the manifest.")
|
||||||
|
|
||||||
|
def get_manifest_target_platforms(manifest, requested_platform):
|
||||||
|
if requested_platform == "" or requested_platform is None:
|
||||||
|
return manifest["target"]["platforms"].split(",")
|
||||||
|
else:
|
||||||
|
validate_manifest_platform(manifest, requested_platform)
|
||||||
|
return [requested_platform]
|
||||||
|
|
||||||
|
#endregion Manifest
|
||||||
|
|
||||||
|
#region SDK download
|
||||||
|
|
||||||
|
def sdk_download(version, platform):
|
||||||
|
sdk_root_dir = get_sdk_root_dir(version, platform)
|
||||||
|
os.makedirs(sdk_root_dir, exist_ok=True)
|
||||||
|
sdk_index_url = get_sdk_url(version, "index.json")
|
||||||
|
print(f"Downloading SDK version {version} for {platform}")
|
||||||
|
sdk_index_filepath = os.path.join(sdk_root_dir, "index.json")
|
||||||
|
if verbose:
|
||||||
|
print(f"Downloading {sdk_index_url} to {sdk_index_filepath}")
|
||||||
|
if not download_file(sdk_index_url, sdk_index_filepath):
|
||||||
|
# TODO: 404 check, print a more accurate error
|
||||||
|
print_error(f"Failed to download SDK version {version}. Check your internet connection and make sure this release exists.")
|
||||||
|
return False
|
||||||
|
with open(sdk_index_filepath) as sdk_index_json_file:
|
||||||
|
sdk_index_json = json.load(sdk_index_json_file)
|
||||||
|
sdk_platforms = sdk_index_json["platforms"]
|
||||||
|
if platform not in sdk_platforms:
|
||||||
|
print_error(f"Platform {platform} not found in {sdk_platforms} for version {version}")
|
||||||
|
return False
|
||||||
|
sdk_platform_file = sdk_platforms[platform]
|
||||||
|
sdk_zip_source_url = get_sdk_url(version, sdk_platform_file)
|
||||||
|
sdk_zip_target_filepath = os.path.join(sdk_root_dir, f"{version}-{platform}.zip")
|
||||||
|
if verbose:
|
||||||
|
print(f"Downloading {sdk_zip_source_url} to {sdk_zip_target_filepath}")
|
||||||
|
if not download_file(sdk_zip_source_url, sdk_zip_target_filepath):
|
||||||
|
print_error(f"Failed to download {sdk_zip_source_url} to {sdk_zip_target_filepath}")
|
||||||
|
return False
|
||||||
|
with zipfile.ZipFile(sdk_zip_target_filepath, "r") as zip_ref:
|
||||||
|
zip_ref.extractall(os.path.join(sdk_root_dir, "TactilitySDK"))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def sdk_download_all(version, platforms):
|
||||||
|
for platform in platforms:
|
||||||
|
if not sdk_exists(version, platform):
|
||||||
|
if not sdk_download(version, platform):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if verbose:
|
||||||
|
print(f"Using cached download for SDK version {version} and platform {platform}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
#endregion SDK download
|
||||||
|
|
||||||
|
#region Building
|
||||||
|
|
||||||
|
def get_cmake_path(platform):
|
||||||
|
return os.path.join("build", f"cmake-build-{platform}")
|
||||||
|
|
||||||
|
def find_elf_file(platform):
|
||||||
|
cmake_dir = get_cmake_path(platform)
|
||||||
|
if os.path.exists(cmake_dir):
|
||||||
|
for file in os.listdir(cmake_dir):
|
||||||
|
if file.endswith(".app.elf"):
|
||||||
|
return os.path.join(cmake_dir, file)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def build_all(version, platforms, skip_build):
|
||||||
|
for platform in platforms:
|
||||||
|
# First build command must be "idf.py build", otherwise it fails to execute "idf.py elf"
|
||||||
|
# We check if the ELF file exists and run the correct command
|
||||||
|
# This can lead to code caching issues, so sometimes a clean build is required
|
||||||
|
if find_elf_file(platform) is None:
|
||||||
|
if not build_first(version, platform, skip_build):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
if not build_consecutively(version, platform, skip_build):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def wait_for_process(process):
|
||||||
|
buffer = []
|
||||||
|
if sys.platform != "win32":
|
||||||
|
os.set_blocking(process.stdout.fileno(), False)
|
||||||
|
while process.poll() is None:
|
||||||
|
while True:
|
||||||
|
line = process.stdout.readline()
|
||||||
|
if line:
|
||||||
|
decoded_line = line.decode("UTF-8")
|
||||||
|
if decoded_line != "":
|
||||||
|
buffer.append(decoded_line)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
# Read any remaining output
|
||||||
|
for line in process.stdout:
|
||||||
|
decoded_line = line.decode("UTF-8")
|
||||||
|
if decoded_line:
|
||||||
|
buffer.append(decoded_line)
|
||||||
|
return buffer
|
||||||
|
|
||||||
|
# The first build must call "idf.py build" and consecutive builds must call "idf.py elf" as it finishes faster.
|
||||||
|
# The problem is that the "idf.py build" always results in an error, even though the elf file is created.
|
||||||
|
# The solution is to suppress the error if we find that the elf file was created.
|
||||||
|
def build_first(version, platform, skip_build):
|
||||||
|
sdk_dir = get_sdk_dir(version, platform)
|
||||||
|
if verbose:
|
||||||
|
print(f"Using SDK at {sdk_dir}")
|
||||||
|
os.environ["TACTILITY_SDK_PATH"] = sdk_dir
|
||||||
|
sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}")
|
||||||
|
shutil.copy(sdkconfig_path, "sdkconfig")
|
||||||
|
elf_path = find_elf_file(platform)
|
||||||
|
# Remove previous elf file: re-creation of the file is used to measure if the build succeeded,
|
||||||
|
# as the actual build job will always fail due to technical issues with the elf cmake script
|
||||||
|
if elf_path is not None:
|
||||||
|
os.remove(elf_path)
|
||||||
|
if skip_build:
|
||||||
|
return True
|
||||||
|
print(f"Building first {platform} build")
|
||||||
|
cmake_path = get_cmake_path(platform)
|
||||||
|
print_status_busy(f"Building {platform} ELF")
|
||||||
|
shell_needed = sys.platform == "win32"
|
||||||
|
build_command = ["idf.py", "-B", cmake_path, "build"]
|
||||||
|
if verbose:
|
||||||
|
print(f"Running command: {" ".join(build_command)}")
|
||||||
|
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process:
|
||||||
|
build_output = wait_for_process(process)
|
||||||
|
# The return code is never expected to be 0 due to a bug in the elf cmake script, but we keep it just in case
|
||||||
|
if process.returncode == 0:
|
||||||
|
print(f"{shell_color_green}Building for {platform} ✅{shell_color_reset}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if find_elf_file(platform) is None:
|
||||||
|
for line in build_output:
|
||||||
|
print(line, end="")
|
||||||
|
print_status_error(f"Building {platform} ELF")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print_status_success(f"Building {platform} ELF")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def build_consecutively(version, platform, skip_build):
|
||||||
|
sdk_dir = get_sdk_dir(version, platform)
|
||||||
|
if verbose:
|
||||||
|
print(f"Using SDK at {sdk_dir}")
|
||||||
|
os.environ["TACTILITY_SDK_PATH"] = sdk_dir
|
||||||
|
sdkconfig_path = os.path.join(ttbuild_path, f"sdkconfig.app.{platform}")
|
||||||
|
shutil.copy(sdkconfig_path, "sdkconfig")
|
||||||
|
if skip_build:
|
||||||
|
return True
|
||||||
|
cmake_path = get_cmake_path(platform)
|
||||||
|
print_status_busy(f"Building {platform} ELF")
|
||||||
|
shell_needed = sys.platform == "win32"
|
||||||
|
build_command = ["idf.py", "-B", cmake_path, "elf"]
|
||||||
|
if verbose:
|
||||||
|
print(f"Running command: {" ".join(build_command)}")
|
||||||
|
with subprocess.Popen(build_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, shell=shell_needed) as process:
|
||||||
|
build_output = wait_for_process(process)
|
||||||
|
if process.returncode == 0:
|
||||||
|
print_status_success(f"Building {platform} ELF")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
for line in build_output:
|
||||||
|
print(line, end="")
|
||||||
|
print_status_error(f"Building {platform} ELF")
|
||||||
|
return False
|
||||||
|
|
||||||
|
#endregion Building
|
||||||
|
|
||||||
|
#region Packaging
|
||||||
|
|
||||||
|
def package_intermediate_manifest(target_path):
|
||||||
|
if not os.path.isfile("manifest.properties"):
|
||||||
|
print_error("manifest.properties not found")
|
||||||
|
return
|
||||||
|
shutil.copy("manifest.properties", os.path.join(target_path, "manifest.properties"))
|
||||||
|
|
||||||
|
def package_intermediate_binaries(target_path, platforms):
|
||||||
|
elf_dir = os.path.join(target_path, "elf")
|
||||||
|
os.makedirs(elf_dir, exist_ok=True)
|
||||||
|
for platform in platforms:
|
||||||
|
elf_path = find_elf_file(platform)
|
||||||
|
if elf_path is None:
|
||||||
|
print_error(f"ELF file not found at {elf_path}")
|
||||||
|
return
|
||||||
|
shutil.copy(elf_path, os.path.join(elf_dir, f"{platform}.elf"))
|
||||||
|
|
||||||
|
def package_intermediate_assets(target_path):
|
||||||
|
if os.path.isdir("assets"):
|
||||||
|
shutil.copytree("assets", os.path.join(target_path, "assets"), dirs_exist_ok=True)
|
||||||
|
|
||||||
|
def package_intermediate(platforms):
|
||||||
|
target_path = os.path.join("build", "package-intermediate")
|
||||||
|
if os.path.isdir(target_path):
|
||||||
|
shutil.rmtree(target_path)
|
||||||
|
os.makedirs(target_path, exist_ok=True)
|
||||||
|
package_intermediate_manifest(target_path)
|
||||||
|
package_intermediate_binaries(target_path, platforms)
|
||||||
|
package_intermediate_assets(target_path)
|
||||||
|
|
||||||
|
def package_name(platforms):
|
||||||
|
elf_path = find_elf_file(platforms[0])
|
||||||
|
elf_base_name = os.path.basename(elf_path).removesuffix(".app.elf")
|
||||||
|
return os.path.join("build", f"{elf_base_name}.app")
|
||||||
|
|
||||||
|
|
||||||
|
def package_all(platforms):
|
||||||
|
status = f"Building package with {platforms}"
|
||||||
|
print_status_busy(status)
|
||||||
|
package_intermediate(platforms)
|
||||||
|
# Create build/something.app
|
||||||
|
try:
|
||||||
|
tar_path = package_name(platforms)
|
||||||
|
tar = tarfile.open(tar_path, mode="w", format=tarfile.USTAR_FORMAT)
|
||||||
|
tar.add(os.path.join("build", "package-intermediate"), arcname="")
|
||||||
|
tar.close()
|
||||||
|
print_status_success(status)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print_status_error(f"Building package failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
#endregion Packaging
|
||||||
|
|
||||||
|
def setup_environment():
|
||||||
|
global ttbuild_path
|
||||||
|
os.makedirs(ttbuild_path, exist_ok=True)
|
||||||
|
|
||||||
|
def build_action(manifest, platform_arg):
|
||||||
|
# Environment validation
|
||||||
|
validate_environment()
|
||||||
|
platforms_to_build = get_manifest_target_platforms(manifest, platform_arg)
|
||||||
|
|
||||||
|
if use_local_sdk:
|
||||||
|
global local_base_path
|
||||||
|
local_base_path = os.environ.get("TACTILITY_SDK_PATH")
|
||||||
|
validate_local_sdks(platforms_to_build, manifest["target"]["sdk"])
|
||||||
|
|
||||||
|
if should_fetch_sdkconfig_files(platforms_to_build):
|
||||||
|
fetch_sdkconfig_files(platforms_to_build)
|
||||||
|
|
||||||
|
if not use_local_sdk:
|
||||||
|
sdk_json = read_sdk_json()
|
||||||
|
validate_self(sdk_json)
|
||||||
|
# Build
|
||||||
|
sdk_version = manifest["target"]["sdk"]
|
||||||
|
if not use_local_sdk:
|
||||||
|
if not sdk_download_all(sdk_version, platforms_to_build):
|
||||||
|
exit_with_error("Failed to download one or more SDKs")
|
||||||
|
if not build_all(sdk_version, platforms_to_build, skip_build): # Environment validation
|
||||||
|
return False
|
||||||
|
if not skip_build:
|
||||||
|
package_all(platforms_to_build)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clean_action():
|
||||||
|
if os.path.exists("build"):
|
||||||
|
print_status_busy("Removing build/")
|
||||||
|
shutil.rmtree("build")
|
||||||
|
print_status_success("Removed build/")
|
||||||
|
else:
|
||||||
|
print("Nothing to clean")
|
||||||
|
|
||||||
|
def clear_cache_action():
|
||||||
|
if os.path.exists(ttbuild_path):
|
||||||
|
print_status_busy(f"Removing {ttbuild_path}/")
|
||||||
|
shutil.rmtree(ttbuild_path)
|
||||||
|
print_status_success(f"Removed {ttbuild_path}/")
|
||||||
|
else:
|
||||||
|
print("Nothing to clear")
|
||||||
|
|
||||||
|
def update_self_action():
|
||||||
|
sdk_json = read_sdk_json()
|
||||||
|
tool_download_url = sdk_json["toolDownloadUrl"]
|
||||||
|
if download_file(tool_download_url, "tactility.py"):
|
||||||
|
print("Updated")
|
||||||
|
else:
|
||||||
|
exit_with_error("Update failed")
|
||||||
|
|
||||||
|
def get_device_info(ip):
|
||||||
|
print_status_busy(f"Requesting device info")
|
||||||
|
url = get_url(ip, "/info")
|
||||||
|
try:
|
||||||
|
response = requests.get(url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print_error("Run failed")
|
||||||
|
else:
|
||||||
|
print_status_success(f"Received device info:")
|
||||||
|
print(response.json())
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print_status_error(f"Device info request failed: {e}")
|
||||||
|
|
||||||
|
def run_action(manifest, ip):
|
||||||
|
app_id = manifest["app"]["id"]
|
||||||
|
print_status_busy("Running")
|
||||||
|
url = get_url(ip, "/app/run")
|
||||||
|
params = {'id': app_id}
|
||||||
|
try:
|
||||||
|
response = requests.post(url, params=params)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print_error("Run failed")
|
||||||
|
else:
|
||||||
|
print_status_success("Running")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print_status_error(f"Running request failed: {e}")
|
||||||
|
|
||||||
|
def install_action(ip, platforms):
|
||||||
|
print_status_busy("Installing")
|
||||||
|
for platform in platforms:
|
||||||
|
elf_path = find_elf_file(platform)
|
||||||
|
if elf_path is None:
|
||||||
|
print_status_error(f"ELF file not built for {platform}")
|
||||||
|
return False
|
||||||
|
package_path = package_name(platforms)
|
||||||
|
# print(f"Installing {package_path} to {ip}")
|
||||||
|
url = get_url(ip, "/app/install")
|
||||||
|
try:
|
||||||
|
# Prepare multipart form data
|
||||||
|
with open(package_path, 'rb') as file:
|
||||||
|
files = {
|
||||||
|
'elf': file
|
||||||
|
}
|
||||||
|
response = requests.put(url, files=files)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print_status_error("Install failed")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print_status_success("Installing")
|
||||||
|
return True
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print_status_error(f"Install request failed: {e}")
|
||||||
|
return False
|
||||||
|
except IOError as e:
|
||||||
|
print_status_error(f"Install file error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def uninstall_action(manifest, ip):
|
||||||
|
app_id = manifest["app"]["id"]
|
||||||
|
print_status_busy("Uninstalling")
|
||||||
|
url = get_url(ip, "/app/uninstall")
|
||||||
|
params = {'id': app_id}
|
||||||
|
try:
|
||||||
|
response = requests.put(url, params=params)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print_status_error("Server responded that uninstall failed")
|
||||||
|
else:
|
||||||
|
print_status_success("Uninstalled")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
print_status_error(f"Uninstall request failed: {e}")
|
||||||
|
|
||||||
|
#region Main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"Tactility Build System v{ttbuild_version}")
|
||||||
|
if "--help" in sys.argv:
|
||||||
|
print_help()
|
||||||
|
sys.exit()
|
||||||
|
# Argument validation
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
if "--verbose" in sys.argv:
|
||||||
|
verbose = True
|
||||||
|
sys.argv.remove("--verbose")
|
||||||
|
skip_build = False
|
||||||
|
if "--skip-build" in sys.argv:
|
||||||
|
skip_build = True
|
||||||
|
sys.argv.remove("--skip-build")
|
||||||
|
if "--local-sdk" in sys.argv:
|
||||||
|
use_local_sdk = True
|
||||||
|
sys.argv.remove("--local-sdk")
|
||||||
|
action_arg = sys.argv[1]
|
||||||
|
|
||||||
|
# Environment setup
|
||||||
|
setup_environment()
|
||||||
|
if not os.path.isfile("manifest.properties"):
|
||||||
|
exit_with_error("manifest.properties not found")
|
||||||
|
manifest = read_manifest()
|
||||||
|
validate_manifest(manifest)
|
||||||
|
all_platform_targets = manifest["target"]["platforms"].split(",")
|
||||||
|
# Update SDK cache (tool.json)
|
||||||
|
if not use_local_sdk and should_update_tool_json() and not update_tool_json():
|
||||||
|
exit_with_error("Failed to retrieve SDK info")
|
||||||
|
# Actions
|
||||||
|
if action_arg == "build":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
platform = None
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
platform = sys.argv[2]
|
||||||
|
if not build_action(manifest, platform):
|
||||||
|
sys.exit(1)
|
||||||
|
elif action_arg == "clean":
|
||||||
|
clean_action()
|
||||||
|
elif action_arg == "clearcache":
|
||||||
|
clear_cache_action()
|
||||||
|
elif action_arg == "updateself":
|
||||||
|
update_self_action()
|
||||||
|
elif action_arg == "run":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
run_action(manifest, sys.argv[2])
|
||||||
|
elif action_arg == "install":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
platform = None
|
||||||
|
platforms_to_install = all_platform_targets
|
||||||
|
if len(sys.argv) >= 4:
|
||||||
|
platform = sys.argv[3]
|
||||||
|
platforms_to_install = [platform]
|
||||||
|
install_action(sys.argv[2], platforms_to_install)
|
||||||
|
elif action_arg == "uninstall":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
uninstall_action(manifest, sys.argv[2])
|
||||||
|
elif action_arg == "bir" or action_arg == "brrr":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Commandline parameter missing")
|
||||||
|
platform = None
|
||||||
|
platforms_to_install = all_platform_targets
|
||||||
|
if len(sys.argv) >= 4:
|
||||||
|
platform = sys.argv[3]
|
||||||
|
platforms_to_install = [platform]
|
||||||
|
if build_action(manifest, platform):
|
||||||
|
if install_action(sys.argv[2], platforms_to_install):
|
||||||
|
run_action(manifest, sys.argv[2])
|
||||||
|
else:
|
||||||
|
print_help()
|
||||||
|
exit_with_error("Unknown commandline parameter")
|
||||||
|
|
||||||
|
#endregion Main
|
||||||
@@ -13,13 +13,14 @@ TwoEleven is a faithful implementation of the popular 2048 game, where you slide
|
|||||||
- **Intuitive Controls**: Swipe gestures on touchscreens or use arrow keys for keyboard input.
|
- **Intuitive Controls**: Swipe gestures on touchscreens or use arrow keys for keyboard input.
|
||||||
- **Visual Feedback**: Color-coded tiles with smooth animations.
|
- **Visual Feedback**: Color-coded tiles with smooth animations.
|
||||||
- **Score Tracking**: Real-time score display with win/lose detection.
|
- **Score Tracking**: Real-time score display with win/lose detection.
|
||||||
|
- **High Score Persistence**: Saves best scores for each grid size.
|
||||||
- **Responsive UI**: Optimized for small screens with clean, modern design.
|
- **Responsive UI**: Optimized for small screens with clean, modern design.
|
||||||
- **Thread-Safe**: Proper handling of UI updates to prevent crashes.
|
- **Thread-Safe**: Proper handling of UI updates to prevent crashes.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
Screenshots taken directly from my Lilygo T-Deck Plus.
|
Screenshots taken directly from my Lilygo T-Deck Plus.
|
||||||
Which is also the only device it has been tested on so far.
|
Tested on Lilygo T-Deck Plus and M5Stack Cardputer.
|
||||||
|
|
||||||
  
|
  
|
||||||
 
|
 
|
||||||
@@ -31,7 +32,7 @@ Which is also the only device it has been tested on so far.
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. Launch the TwoEleven app.
|
1. Launch the 2048 app.
|
||||||
2. Select your preferred grid size (3x3 to 6x6).
|
2. Select your preferred grid size (3x3 to 6x6).
|
||||||
3. Swipe tiles in any direction to move and combine them.
|
3. Swipe tiles in any direction to move and combine them.
|
||||||
4. Reach the 2048 tile to win, or get stuck to lose.
|
4. Reach the 2048 tile to win, or get stuck to lose.
|
||||||
@@ -40,8 +41,10 @@ Which is also the only device it has been tested on so far.
|
|||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
- **Touchscreen**: Swipe up, down, left, or right to move tiles.
|
- **Touchscreen**: Swipe up, down, left, or right to move tiles.
|
||||||
- **Keyboard**: Use arrow keys (↑, ↓, ←, →) for movement.
|
- **Keyboard (Arrow Keys)**: Use arrow keys (Up, Down, Left, Right) for movement.
|
||||||
- **New Game**: Press the "New" button to reset the board.
|
- **Keyboard (WASD)**: Use W, A, S, D keys for movement.
|
||||||
|
- **Keyboard (Cardputer)**: Use semicolon (;), comma (,), period (.), slash (/) for up, left, down, right.
|
||||||
|
- **New Game**: Press the refresh button in the toolbar to reset the board.
|
||||||
|
|
||||||
## Game Rules
|
## Game Rules
|
||||||
|
|
||||||
@@ -51,7 +54,3 @@ Which is also the only device it has been tested on so far.
|
|||||||
- New tiles appear after each move.
|
- New tiles appear after each move.
|
||||||
- Game ends when you reach 2048 (win) or no moves are possible (lose).
|
- Game ends when you reach 2048 (win) or no moves are possible (lose).
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
- Maybe trackball one day?
|
|
||||||
- Maybe other keys rather being limited to arrow directions. (why so limited lvgl?)
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.8 KiB |
@@ -1,58 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* @file TwoEleven.cpp
|
||||||
|
* @brief 2048 game app implementation for Tactility
|
||||||
|
*/
|
||||||
#include "TwoEleven.h"
|
#include "TwoEleven.h"
|
||||||
|
|
||||||
|
#include <inttypes.h>
|
||||||
|
#include <tt_hal.h>
|
||||||
#include <tt_lvgl_toolbar.h>
|
#include <tt_lvgl_toolbar.h>
|
||||||
#include <tt_app_alertdialog.h>
|
#include <tt_app_alertdialog.h>
|
||||||
|
#include <tt_app_selectiondialog.h>
|
||||||
|
#include <tt_preferences.h>
|
||||||
#include <TactilityCpp/LvglLock.h>
|
#include <TactilityCpp/LvglLock.h>
|
||||||
|
|
||||||
constexpr auto* TAG = "TwoEleven";
|
constexpr auto* TAG = "TwoEleven";
|
||||||
|
|
||||||
static lv_obj_t* scoreLabel = nullptr;
|
// Preferences keys for high scores (one per grid size)
|
||||||
static lv_obj_t* scoreWrapper = nullptr;
|
static constexpr const char* PREF_NAMESPACE = "TwoEleven";
|
||||||
static lv_obj_t* toolbar = nullptr;
|
static constexpr const char* PREF_HIGH_3X3 = "high_3x3";
|
||||||
static lv_obj_t* mainWrapper = nullptr;
|
static constexpr const char* PREF_HIGH_4X4 = "high_4x4";
|
||||||
static lv_obj_t* newGameWrapper = nullptr;
|
static constexpr const char* PREF_HIGH_5X5 = "high_5x5";
|
||||||
static lv_obj_t* gameObject = nullptr;
|
static constexpr const char* PREF_HIGH_6X6 = "high_6x6";
|
||||||
|
|
||||||
static uint16_t selectedSize = 4;
|
// High scores for each grid size (loaded from preferences)
|
||||||
|
static int32_t highScore3x3 = 0;
|
||||||
|
static int32_t highScore4x4 = 0;
|
||||||
|
static int32_t highScore5x5 = 0;
|
||||||
|
static int32_t highScore6x6 = 0;
|
||||||
|
|
||||||
|
static constexpr size_t SIZE_COUNT = 4;
|
||||||
|
|
||||||
|
// Selection dialog indices (0 = How to Play, 1-4 = grid sizes)
|
||||||
|
static constexpr int32_t SELECTION_HOW_TO_PLAY = 0;
|
||||||
|
static constexpr int32_t SELECTION_3X3 = 1;
|
||||||
|
static constexpr int32_t SELECTION_4X4 = 2;
|
||||||
|
static constexpr int32_t SELECTION_5X5 = 3;
|
||||||
|
static constexpr int32_t SELECTION_6X6 = 4;
|
||||||
|
|
||||||
|
// Grid size options (index matches selection - 1)
|
||||||
|
static const uint16_t gridSizes[SIZE_COUNT] = { 3, 4, 5, 6 };
|
||||||
|
|
||||||
|
static int getToolbarHeight(UiScale uiScale) {
|
||||||
|
if (uiScale == UiScale::UiScaleSmallest) {
|
||||||
|
return 22;
|
||||||
|
} else {
|
||||||
|
return 40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void loadHighScores() {
|
||||||
|
PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE);
|
||||||
|
if (prefs) {
|
||||||
|
tt_preferences_opt_int32(prefs, PREF_HIGH_3X3, &highScore3x3);
|
||||||
|
tt_preferences_opt_int32(prefs, PREF_HIGH_4X4, &highScore4x4);
|
||||||
|
tt_preferences_opt_int32(prefs, PREF_HIGH_5X5, &highScore5x5);
|
||||||
|
tt_preferences_opt_int32(prefs, PREF_HIGH_6X6, &highScore6x6);
|
||||||
|
tt_preferences_free(prefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void saveHighScore(int32_t gridSize, int32_t score) {
|
||||||
|
PreferencesHandle prefs = tt_preferences_alloc(PREF_NAMESPACE);
|
||||||
|
if (prefs) {
|
||||||
|
switch (gridSize) {
|
||||||
|
case SELECTION_3X3:
|
||||||
|
highScore3x3 = score;
|
||||||
|
tt_preferences_put_int32(prefs, PREF_HIGH_3X3, score);
|
||||||
|
break;
|
||||||
|
case SELECTION_4X4:
|
||||||
|
highScore4x4 = score;
|
||||||
|
tt_preferences_put_int32(prefs, PREF_HIGH_4X4, score);
|
||||||
|
break;
|
||||||
|
case SELECTION_5X5:
|
||||||
|
highScore5x5 = score;
|
||||||
|
tt_preferences_put_int32(prefs, PREF_HIGH_5X5, score);
|
||||||
|
break;
|
||||||
|
case SELECTION_6X6:
|
||||||
|
highScore6x6 = score;
|
||||||
|
tt_preferences_put_int32(prefs, PREF_HIGH_6X6, score);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tt_preferences_free(prefs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int32_t getHighScore(int32_t gridSize) {
|
||||||
|
switch (gridSize) {
|
||||||
|
case SELECTION_3X3: return highScore3x3;
|
||||||
|
case SELECTION_4X4: return highScore4x4;
|
||||||
|
case SELECTION_5X5: return highScore5x5;
|
||||||
|
case SELECTION_6X6: return highScore6x6;
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TwoEleven::showSelectionDialog() {
|
||||||
|
const char* items[] = { "How to Play", "3x3", "4x4", "5x5", "6x6" };
|
||||||
|
selectionDialogId = tt_app_selectiondialog_start("2048", 5, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TwoEleven::showHelpDialog() {
|
||||||
|
const char* buttons[] = { "OK" };
|
||||||
|
helpDialogId = tt_app_alertdialog_start(
|
||||||
|
"How to Play",
|
||||||
|
"Swipe or use arrow keys to move tiles.\n"
|
||||||
|
"Tiles with the same number merge.\n"
|
||||||
|
"Reach 2048 to win!",
|
||||||
|
buttons, 1);
|
||||||
|
}
|
||||||
|
|
||||||
void TwoEleven::twoElevenEventCb(lv_event_t* e) {
|
void TwoEleven::twoElevenEventCb(lv_event_t* e) {
|
||||||
|
TwoEleven* self = (TwoEleven*)lv_event_get_user_data(e);
|
||||||
|
if (self == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
lv_event_code_t code = lv_event_get_code(e);
|
lv_event_code_t code = lv_event_get_code(e);
|
||||||
lv_obj_t* obj_2048 = lv_event_get_target_obj(e);
|
|
||||||
lv_obj_t* scoreLabel = (lv_obj_t *)lv_event_get_user_data(e);
|
|
||||||
|
|
||||||
const char* alertDialogLabels[] = { "OK" };
|
|
||||||
|
|
||||||
if (code == LV_EVENT_VALUE_CHANGED) {
|
if (code == LV_EVENT_VALUE_CHANGED) {
|
||||||
if (twoeleven_get_best_tile(obj_2048) >= 2048) {
|
int32_t score = twoeleven_get_score(self->gameObject);
|
||||||
char message[64];
|
|
||||||
sprintf(message, "YOU WIN!\n\nSCORE: %d", twoeleven_get_score(obj_2048));
|
if (self->gameOverDialogId == 0 && twoeleven_get_best_tile(self->gameObject) >= 2048) {
|
||||||
tt_app_alertdialog_start("YOU WIN!", message, alertDialogLabels, 1);
|
int32_t prevHighScore = getHighScore(self->currentGridSize);
|
||||||
} else if (twoeleven_get_status(obj_2048)) {
|
bool isNewHighScore = score > prevHighScore;
|
||||||
char message[64];
|
|
||||||
sprintf(message, "GAME OVER!\n\nSCORE: %d", twoeleven_get_score(obj_2048));
|
// Save high score if it's a new record
|
||||||
tt_app_alertdialog_start("GAME OVER!", message, alertDialogLabels, 1);
|
if (isNewHighScore) {
|
||||||
|
saveHighScore(self->currentGridSize, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* alertDialogLabels[] = { "OK" };
|
||||||
|
char message[100];
|
||||||
|
if (isNewHighScore) {
|
||||||
|
snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32, score);
|
||||||
|
self->gameOverDialogId = tt_app_alertdialog_start("YOU WIN!", message, alertDialogLabels, 1);
|
||||||
|
} else {
|
||||||
|
snprintf(message, sizeof(message), "YOU WIN!\n\nSCORE: %" PRId32 "\nBEST: %" PRId32, score, getHighScore(self->currentGridSize));
|
||||||
|
self->gameOverDialogId = tt_app_alertdialog_start("YOU WIN!", message, alertDialogLabels, 1);
|
||||||
|
}
|
||||||
|
} else if (self->gameOverDialogId == 0 && twoeleven_get_status(self->gameObject)) {
|
||||||
|
int32_t prevHighScore = getHighScore(self->currentGridSize);
|
||||||
|
bool isNewHighScore = score > prevHighScore;
|
||||||
|
|
||||||
|
// Save high score if it's a new record
|
||||||
|
if (isNewHighScore) {
|
||||||
|
saveHighScore(self->currentGridSize, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* alertDialogLabels[] = { "OK" };
|
||||||
|
char message[100];
|
||||||
|
if (isNewHighScore && score > 0) {
|
||||||
|
snprintf(message, sizeof(message), "NEW HIGH SCORE!\n\nSCORE: %" PRId32, score);
|
||||||
|
self->gameOverDialogId = tt_app_alertdialog_start("NEW HIGH SCORE!", message, alertDialogLabels, 1);
|
||||||
|
} else {
|
||||||
|
snprintf(message, sizeof(message), "GAME OVER!\n\nSCORE: %" PRId32 "\nBEST: %" PRId32, score, getHighScore(self->currentGridSize));
|
||||||
|
self->gameOverDialogId = tt_app_alertdialog_start("GAME OVER!", message, alertDialogLabels, 1);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
lv_label_set_text_fmt(scoreLabel, "SCORE: %d", twoeleven_get_score(obj_2048));
|
// Update score display
|
||||||
|
lv_label_set_text_fmt(self->scoreLabel, "SCORE: %" PRId32, score);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwoEleven::newGameBtnEvent(lv_event_t* e) {
|
void TwoEleven::newGameBtnEvent(lv_event_t* e) {
|
||||||
lv_obj_t* obj_2048 = (lv_obj_t *)lv_event_get_user_data(e);
|
TwoEleven* self = (TwoEleven*)lv_event_get_user_data(e);
|
||||||
twoeleven_set_new_game(obj_2048);
|
if (self == nullptr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
twoeleven_set_new_game(self->gameObject);
|
||||||
|
// Update score label
|
||||||
|
if (self->scoreLabel) {
|
||||||
|
lv_label_set_text_fmt(self->scoreLabel, "SCORE: %" PRId32, twoeleven_get_score(self->gameObject));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwoEleven::create_game(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar) {
|
void TwoEleven::createGame(lv_obj_t* parent, uint16_t size, lv_obj_t* tb) {
|
||||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
|
||||||
//game...
|
// Create game widget
|
||||||
gameObject = twoeleven_create(parent, size);
|
gameObject = twoeleven_create(parent, size);
|
||||||
lv_obj_set_style_text_font(gameObject, lv_font_get_default(), 0);
|
lv_obj_set_style_text_font(gameObject, lv_font_get_default(), 0);
|
||||||
lv_obj_set_size(gameObject, LV_PCT(100), LV_PCT(100));
|
lv_obj_set_size(gameObject, LV_PCT(100), LV_PCT(100));
|
||||||
lv_obj_set_flex_grow(gameObject, 1);
|
lv_obj_set_flex_grow(gameObject, 1);
|
||||||
|
|
||||||
scoreWrapper = lv_obj_create(toolbar);
|
// Create score wrapper in toolbar
|
||||||
|
scoreWrapper = lv_obj_create(tb);
|
||||||
lv_obj_set_size(scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100));
|
lv_obj_set_size(scoreWrapper, LV_SIZE_CONTENT, LV_PCT(100));
|
||||||
lv_obj_set_style_pad_top(scoreWrapper, 4, LV_STATE_DEFAULT);
|
lv_obj_set_style_pad_top(scoreWrapper, 4, LV_STATE_DEFAULT);
|
||||||
lv_obj_set_style_pad_bottom(scoreWrapper, 0, LV_STATE_DEFAULT);
|
lv_obj_set_style_pad_bottom(scoreWrapper, 0, LV_STATE_DEFAULT);
|
||||||
@@ -64,119 +196,69 @@ void TwoEleven::create_game(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar)
|
|||||||
lv_obj_set_style_bg_opa(scoreWrapper, 0, LV_STATE_DEFAULT);
|
lv_obj_set_style_bg_opa(scoreWrapper, 0, LV_STATE_DEFAULT);
|
||||||
lv_obj_remove_flag(scoreWrapper, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(scoreWrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
//toolbar new score
|
// Create score label
|
||||||
scoreLabel = lv_label_create(scoreWrapper);
|
scoreLabel = lv_label_create(scoreWrapper);
|
||||||
lv_label_set_text_fmt(scoreLabel, "SCORE: %d", twoeleven_get_score(gameObject));
|
lv_label_set_text_fmt(scoreLabel, "SCORE: %" PRId32, twoeleven_get_score(gameObject));
|
||||||
lv_obj_set_style_text_align(scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT);
|
lv_obj_set_style_text_align(scoreLabel, LV_TEXT_ALIGN_LEFT, LV_STATE_DEFAULT);
|
||||||
lv_obj_align(scoreLabel, LV_ALIGN_CENTER, 0, 0);
|
lv_obj_align(scoreLabel, LV_ALIGN_CENTER, 0, 0);
|
||||||
lv_obj_set_size(scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
lv_obj_set_size(scoreLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
||||||
lv_obj_set_style_text_font(scoreLabel, lv_font_get_default(), 0);
|
lv_obj_set_style_text_font(scoreLabel, lv_font_get_default(), 0);
|
||||||
lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_AMBER), LV_PART_MAIN);
|
lv_obj_set_style_text_color(scoreLabel, lv_palette_main(LV_PALETTE_AMBER), LV_PART_MAIN);
|
||||||
lv_obj_add_event_cb(gameObject, twoElevenEventCb, LV_EVENT_ALL, scoreLabel);
|
lv_obj_add_event_cb(gameObject, twoElevenEventCb, LV_EVENT_VALUE_CHANGED, this);
|
||||||
|
|
||||||
newGameWrapper = lv_obj_create(toolbar);
|
// Create new game button wrapper
|
||||||
|
newGameWrapper = lv_obj_create(tb);
|
||||||
lv_obj_set_width(newGameWrapper, LV_SIZE_CONTENT);
|
lv_obj_set_width(newGameWrapper, LV_SIZE_CONTENT);
|
||||||
lv_obj_set_flex_flow(newGameWrapper, LV_FLEX_FLOW_ROW);
|
lv_obj_set_flex_flow(newGameWrapper, LV_FLEX_FLOW_ROW);
|
||||||
lv_obj_set_style_pad_all(newGameWrapper, 2, LV_STATE_DEFAULT);
|
lv_obj_set_style_pad_all(newGameWrapper, 2, LV_STATE_DEFAULT);
|
||||||
lv_obj_set_style_border_width(newGameWrapper, 0, LV_STATE_DEFAULT);
|
lv_obj_set_style_border_width(newGameWrapper, 0, LV_STATE_DEFAULT);
|
||||||
lv_obj_set_style_bg_opa(newGameWrapper, 0, LV_STATE_DEFAULT);
|
lv_obj_set_style_bg_opa(newGameWrapper, 0, LV_STATE_DEFAULT);
|
||||||
|
|
||||||
//toolbar reset
|
// Create new game button
|
||||||
|
auto ui_scale = tt_hal_configuration_get_ui_scale();
|
||||||
|
auto toolbar_height = getToolbarHeight(ui_scale);
|
||||||
lv_obj_t* newGameBtn = lv_btn_create(newGameWrapper);
|
lv_obj_t* newGameBtn = lv_btn_create(newGameWrapper);
|
||||||
lv_obj_set_size(newGameBtn, 34, 34);
|
if (ui_scale == UiScale::UiScaleSmallest) {
|
||||||
|
lv_obj_set_size(newGameBtn, toolbar_height - 8, toolbar_height - 8);
|
||||||
|
} else {
|
||||||
|
lv_obj_set_size(newGameBtn, toolbar_height - 6, toolbar_height - 6);
|
||||||
|
}
|
||||||
lv_obj_set_style_pad_all(newGameBtn, 0, LV_STATE_DEFAULT);
|
lv_obj_set_style_pad_all(newGameBtn, 0, LV_STATE_DEFAULT);
|
||||||
lv_obj_align(newGameBtn, LV_ALIGN_CENTER, 0, 0);
|
lv_obj_align(newGameBtn, LV_ALIGN_CENTER, 0, 0);
|
||||||
lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, gameObject);
|
lv_obj_add_event_cb(newGameBtn, newGameBtnEvent, LV_EVENT_CLICKED, this);
|
||||||
|
|
||||||
lv_obj_t* btnLabel = lv_image_create(newGameBtn);
|
lv_obj_t* btnIcon = lv_image_create(newGameBtn);
|
||||||
lv_image_set_src(btnLabel, LV_SYMBOL_REFRESH);
|
lv_image_set_src(btnIcon, LV_SYMBOL_REFRESH);
|
||||||
lv_obj_align(btnLabel, LV_ALIGN_CENTER, 0, 0);
|
lv_obj_align(btnIcon, LV_ALIGN_CENTER, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwoEleven::create_selection(lv_obj_t* parent, lv_obj_t* toolbar) {
|
void TwoEleven::onHide(AppHandle appHandle) {
|
||||||
lv_obj_t* selection = lv_obj_create(parent);
|
|
||||||
lv_obj_set_size(selection, LV_PCT(100), LV_PCT(100));
|
|
||||||
lv_obj_set_flex_flow(selection, LV_FLEX_FLOW_COLUMN);
|
|
||||||
lv_obj_set_flex_grow(selection, 1);
|
|
||||||
lv_obj_remove_flag(selection, LV_OBJ_FLAG_SCROLLABLE);
|
|
||||||
lv_obj_set_style_pad_all(selection, 0, LV_PART_MAIN);
|
|
||||||
lv_obj_set_style_border_width(selection, 0, LV_STATE_DEFAULT);
|
|
||||||
|
|
||||||
lv_obj_t* titleWrapper = lv_obj_create(selection);
|
|
||||||
lv_obj_set_size(titleWrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
|
||||||
lv_obj_set_style_pad_all(titleWrapper, 0, LV_STATE_DEFAULT);
|
|
||||||
lv_obj_set_style_border_width(titleWrapper, 0, LV_STATE_DEFAULT);
|
|
||||||
lv_obj_set_style_bg_opa(titleWrapper, 0, LV_STATE_DEFAULT);
|
|
||||||
lv_obj_set_flex_flow(titleWrapper, LV_FLEX_FLOW_COLUMN);
|
|
||||||
lv_obj_set_flex_align(titleWrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
||||||
lv_obj_remove_flag(titleWrapper, LV_OBJ_FLAG_SCROLLABLE);
|
|
||||||
|
|
||||||
lv_obj_t* titleLabel = lv_label_create(titleWrapper);
|
|
||||||
lv_label_set_text(titleLabel, "Select Matrix Size");
|
|
||||||
lv_obj_align(titleLabel, LV_ALIGN_CENTER, 0, 0);
|
|
||||||
lv_obj_set_size(titleLabel, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
|
||||||
|
|
||||||
lv_obj_t* controlsWrapper = lv_obj_create(titleWrapper);
|
|
||||||
lv_obj_set_size(controlsWrapper, LV_PCT(100), LV_SIZE_CONTENT);
|
|
||||||
lv_obj_set_style_pad_all(controlsWrapper, 0, LV_STATE_DEFAULT);
|
|
||||||
lv_obj_set_style_border_width(controlsWrapper, 0, LV_STATE_DEFAULT);
|
|
||||||
lv_obj_set_style_bg_opa(controlsWrapper, 0, LV_STATE_DEFAULT);
|
|
||||||
lv_obj_set_flex_flow(controlsWrapper, LV_FLEX_FLOW_COLUMN);
|
|
||||||
lv_obj_set_flex_align(controlsWrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
||||||
lv_obj_remove_flag(controlsWrapper, LV_OBJ_FLAG_SCROLLABLE);
|
|
||||||
|
|
||||||
lv_obj_t* touchControlsLabel = lv_label_create(controlsWrapper);
|
|
||||||
lv_label_set_text(touchControlsLabel, "Touchscreen:\nSwipe up, down, left, right to move tiles.");
|
|
||||||
lv_obj_set_style_text_font(touchControlsLabel, lv_font_get_default(), 0);
|
|
||||||
lv_obj_set_style_text_align(touchControlsLabel, LV_TEXT_ALIGN_CENTER, 0);
|
|
||||||
|
|
||||||
lv_obj_t* keyControlsLabel = lv_label_create(controlsWrapper);
|
|
||||||
lv_label_set_text_fmt(keyControlsLabel, "Keyboard:\nUse arrow keys (%s, %s, %s, %s) to move tiles.", LV_SYMBOL_UP, LV_SYMBOL_DOWN, LV_SYMBOL_LEFT, LV_SYMBOL_RIGHT);
|
|
||||||
lv_obj_set_style_text_font(keyControlsLabel, lv_font_get_default(), 0);
|
|
||||||
lv_obj_set_style_text_align(keyControlsLabel, LV_TEXT_ALIGN_CENTER, 0);
|
|
||||||
|
|
||||||
lv_obj_t* buttonContainer = lv_obj_create(selection);
|
|
||||||
lv_obj_set_flex_flow(buttonContainer, LV_FLEX_FLOW_ROW);
|
|
||||||
lv_obj_set_flex_align(buttonContainer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
||||||
lv_obj_remove_flag(buttonContainer, LV_OBJ_FLAG_SCROLLABLE);
|
|
||||||
lv_obj_set_size(buttonContainer, LV_PCT(100), LV_SIZE_CONTENT);
|
|
||||||
lv_obj_set_style_bg_opa(buttonContainer, 0, LV_STATE_DEFAULT);
|
|
||||||
lv_obj_set_style_border_width(buttonContainer, 0, LV_STATE_DEFAULT);
|
|
||||||
|
|
||||||
for(int s = 3; s <= 6; s++) {
|
|
||||||
lv_obj_t* btn = lv_btn_create(buttonContainer);
|
|
||||||
lv_obj_set_size(btn, 60, 40);
|
|
||||||
lv_obj_t* lbl = lv_label_create(btn);
|
|
||||||
char txt[10];
|
|
||||||
sprintf(txt, "%dx%d", s, s);
|
|
||||||
lv_label_set_text(lbl, txt);
|
|
||||||
lv_obj_center(lbl);
|
|
||||||
lv_obj_add_event_cb(btn, size_select_cb, LV_EVENT_CLICKED, (void*)s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void TwoEleven::size_select_cb(lv_event_t* e) {
|
|
||||||
selectedSize = (uint16_t)(uintptr_t)lv_event_get_user_data(e);
|
|
||||||
lv_obj_t* selection = lv_obj_get_parent(lv_event_get_target_obj(e));
|
|
||||||
lv_obj_t* selectionWrapper = lv_obj_get_parent(selection);
|
|
||||||
lv_obj_clean(selectionWrapper);
|
|
||||||
scoreLabel = nullptr;
|
scoreLabel = nullptr;
|
||||||
scoreWrapper = nullptr;
|
scoreWrapper = nullptr;
|
||||||
|
toolbar = nullptr;
|
||||||
|
mainWrapper = nullptr;
|
||||||
newGameWrapper = nullptr;
|
newGameWrapper = nullptr;
|
||||||
gameObject = nullptr;
|
gameObject = nullptr;
|
||||||
create_game(selectionWrapper, selectedSize, toolbar);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
||||||
|
// Check if we should exit (user closed selection dialog)
|
||||||
|
if (shouldExit) {
|
||||||
|
shouldExit = false;
|
||||||
|
tt_app_stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
|
||||||
|
|
||||||
|
// Create toolbar
|
||||||
toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
|
toolbar = tt_lvgl_toolbar_create_for_app(parent, appHandle);
|
||||||
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
|
||||||
|
// Create main wrapper
|
||||||
mainWrapper = lv_obj_create(parent);
|
mainWrapper = lv_obj_create(parent);
|
||||||
lv_obj_set_width(mainWrapper, LV_PCT(100));
|
lv_obj_set_width(mainWrapper, LV_PCT(100));
|
||||||
lv_obj_set_height(mainWrapper, LV_PCT(100));
|
|
||||||
lv_obj_set_flex_grow(mainWrapper, 1);
|
lv_obj_set_flex_grow(mainWrapper, 1);
|
||||||
lv_obj_set_style_pad_all(mainWrapper, 2, LV_PART_MAIN);
|
lv_obj_set_style_pad_all(mainWrapper, 2, LV_PART_MAIN);
|
||||||
lv_obj_set_style_pad_row(mainWrapper, 2, LV_PART_MAIN);
|
lv_obj_set_style_pad_row(mainWrapper, 2, LV_PART_MAIN);
|
||||||
@@ -184,19 +266,68 @@ void TwoEleven::onShow(AppHandle appHandle, lv_obj_t* parent) {
|
|||||||
lv_obj_set_style_border_width(mainWrapper, 0, LV_PART_MAIN);
|
lv_obj_set_style_border_width(mainWrapper, 0, LV_PART_MAIN);
|
||||||
lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE);
|
lv_obj_remove_flag(mainWrapper, LV_OBJ_FLAG_SCROLLABLE);
|
||||||
|
|
||||||
create_selection(mainWrapper, toolbar);
|
// Load high scores on first show
|
||||||
|
if (!highScoresLoaded) {
|
||||||
|
loadHighScores();
|
||||||
|
highScoresLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we need to show the help dialog
|
||||||
|
if (showHelpOnShow) {
|
||||||
|
showHelpOnShow = false;
|
||||||
|
showHelpDialog();
|
||||||
|
// Check if we have a pending size selection from onResult
|
||||||
|
} else if (pendingSelection >= SELECTION_3X3 && pendingSelection <= SELECTION_6X6) {
|
||||||
|
// Force layout update before creating game so dimensions are computed
|
||||||
|
lv_obj_update_layout(parent);
|
||||||
|
// Track which grid size we're playing for high score saving
|
||||||
|
currentGridSize = pendingSelection;
|
||||||
|
// Start game with selected size (convert selection index to size index)
|
||||||
|
int32_t sizeIndex = pendingSelection - SELECTION_3X3;
|
||||||
|
createGame(mainWrapper, gridSizes[sizeIndex], toolbar);
|
||||||
|
pendingSelection = -1;
|
||||||
|
} else {
|
||||||
|
// Show selection dialog
|
||||||
|
showSelectionDialog();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TwoEleven::onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) {
|
void TwoEleven::onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) {
|
||||||
if (result == APP_RESULT_OK && resultData != nullptr) {
|
// Don't manipulate LVGL objects here - they may be invalid
|
||||||
// Dialog closed with OK, go back to selection
|
// Just store state for onShow to handle
|
||||||
tt_lvgl_lock(TT_LVGL_DEFAULT_LOCK_TIME);
|
|
||||||
lv_obj_clean(mainWrapper);
|
if (launchId == selectionDialogId && selectionDialogId != 0) {
|
||||||
scoreLabel = nullptr;
|
selectionDialogId = 0;
|
||||||
scoreWrapper = nullptr;
|
|
||||||
newGameWrapper = nullptr;
|
int32_t selection = -1;
|
||||||
gameObject = nullptr;
|
if (resultData != nullptr) {
|
||||||
create_selection(mainWrapper, toolbar);
|
selection = tt_app_selectiondialog_get_result_index(resultData);
|
||||||
tt_lvgl_unlock();
|
}
|
||||||
|
|
||||||
|
if (selection == SELECTION_HOW_TO_PLAY) {
|
||||||
|
// Mark to show help dialog in onShow
|
||||||
|
showHelpOnShow = true;
|
||||||
|
} else if (selection >= SELECTION_3X3 && selection <= SELECTION_6X6) {
|
||||||
|
// Store selection for onShow to handle
|
||||||
|
pendingSelection = selection;
|
||||||
|
} else {
|
||||||
|
// User closed dialog without selecting - mark for exit
|
||||||
|
shouldExit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (launchId == helpDialogId && helpDialogId != 0) {
|
||||||
|
helpDialogId = 0;
|
||||||
|
// Return to selection dialog
|
||||||
|
pendingSelection = -1;
|
||||||
|
|
||||||
|
} else if (launchId == gameOverDialogId && gameOverDialogId != 0) {
|
||||||
|
gameOverDialogId = 0;
|
||||||
|
// Mark to show selection dialog in onShow
|
||||||
|
pendingSelection = -1;
|
||||||
|
|
||||||
|
} else if (launchId == winDialogId && winDialogId != 0) {
|
||||||
|
winDialogId = 0;
|
||||||
|
// Mark to show selection dialog in onShow
|
||||||
|
pendingSelection = -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,37 @@
|
|||||||
|
|
||||||
class TwoEleven final : public App {
|
class TwoEleven final : public App {
|
||||||
|
|
||||||
|
private:
|
||||||
|
// UI element pointers (invalidated on hide, recreated on show)
|
||||||
|
lv_obj_t* scoreLabel = nullptr;
|
||||||
|
lv_obj_t* scoreWrapper = nullptr;
|
||||||
|
lv_obj_t* toolbar = nullptr;
|
||||||
|
lv_obj_t* mainWrapper = nullptr;
|
||||||
|
lv_obj_t* newGameWrapper = nullptr;
|
||||||
|
lv_obj_t* gameObject = nullptr;
|
||||||
|
|
||||||
|
// State tracking (persists across hide/show cycles)
|
||||||
|
int32_t pendingSelection = -1;
|
||||||
|
bool shouldExit = false;
|
||||||
|
bool showHelpOnShow = false;
|
||||||
|
int32_t currentGridSize = -1;
|
||||||
|
bool highScoresLoaded = false;
|
||||||
|
|
||||||
|
// Dialog launch IDs
|
||||||
|
AppLaunchId selectionDialogId = 0;
|
||||||
|
AppLaunchId gameOverDialogId = 0;
|
||||||
|
AppLaunchId winDialogId = 0;
|
||||||
|
AppLaunchId helpDialogId = 0;
|
||||||
|
|
||||||
static void twoElevenEventCb(lv_event_t* e);
|
static void twoElevenEventCb(lv_event_t* e);
|
||||||
static void newGameBtnEvent(lv_event_t* e);
|
static void newGameBtnEvent(lv_event_t* e);
|
||||||
static void create_game(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar);
|
void createGame(lv_obj_t* parent, uint16_t size, lv_obj_t* toolbar);
|
||||||
static void create_selection(lv_obj_t* parent, lv_obj_t* toolbar);
|
void showSelectionDialog();
|
||||||
static void size_select_cb(lv_event_t* e);
|
void showHelpDialog();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
|
|
||||||
void onShow(AppHandle context, lv_obj_t* parent) override;
|
void onShow(AppHandle context, lv_obj_t* parent) override;
|
||||||
|
void onHide(AppHandle context) override;
|
||||||
void onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
|
void onResult(AppHandle appHandle, void* _Nullable data, AppLaunchId launchId, AppResult result, BundleHandle resultData) override;
|
||||||
};
|
};
|
||||||
@@ -126,7 +126,7 @@ bool game_over(uint16_t matrix_size, const uint16_t **matrix) {
|
|||||||
/**
|
/**
|
||||||
* @brief Get the current score
|
* @brief Get the current score
|
||||||
*/
|
*/
|
||||||
uint16_t twoeleven_get_score(lv_obj_t * obj)
|
uint32_t twoeleven_get_score(lv_obj_t * obj)
|
||||||
{
|
{
|
||||||
const twoeleven_t * game_2048 = (const twoeleven_t *)lv_obj_get_user_data(obj);
|
const twoeleven_t * game_2048 = (const twoeleven_t *)lv_obj_get_user_data(obj);
|
||||||
if (!game_2048) return 0;
|
if (!game_2048) return 0;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ bool game_over(uint16_t matrix_size, const uint16_t **matrix);
|
|||||||
/**
|
/**
|
||||||
* @brief Get the current score
|
* @brief Get the current score
|
||||||
*/
|
*/
|
||||||
uint16_t twoeleven_get_score(lv_obj_t * obj);
|
uint32_t twoeleven_get_score(lv_obj_t * obj);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get the game over status
|
* @brief Get the game over status
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#include "TwoElevenHelpers.h"
|
#include "TwoElevenHelpers.h"
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <tt_lvgl_keyboard.h>
|
||||||
|
|
||||||
static void game_play_event(lv_event_t * e);
|
static void game_play_event(lv_event_t * e);
|
||||||
static void btnm_event_cb(lv_event_t * e);
|
static void btnm_event_cb(lv_event_t * e);
|
||||||
|
static void focus_event(lv_event_t* e);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Free all resources for the 2048 game object
|
* @brief Free all resources for the 2048 game object
|
||||||
@@ -15,6 +17,13 @@ static void delete_event(lv_event_t * e)
|
|||||||
lv_obj_t * obj = lv_event_get_target_obj(e);
|
lv_obj_t * obj = lv_event_get_target_obj(e);
|
||||||
twoeleven_t * game_2048 = (twoeleven_t *)lv_obj_get_user_data(obj);
|
twoeleven_t * game_2048 = (twoeleven_t *)lv_obj_get_user_data(obj);
|
||||||
if (game_2048) {
|
if (game_2048) {
|
||||||
|
// Reset group editing mode if we enabled it
|
||||||
|
if (tt_lvgl_hardware_keyboard_is_available()) {
|
||||||
|
lv_group_t* group = lv_group_get_default();
|
||||||
|
if (group) {
|
||||||
|
lv_group_set_editing(group, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
for (uint16_t index = 0; index < game_2048->map_count; index++) {
|
for (uint16_t index = 0; index < game_2048->map_count; index++) {
|
||||||
if (game_2048->btnm_map[index]) {
|
if (game_2048->btnm_map[index]) {
|
||||||
lv_free(game_2048->btnm_map[index]);
|
lv_free(game_2048->btnm_map[index]);
|
||||||
@@ -32,6 +41,24 @@ static void delete_event(lv_event_t * e)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Handle focus/defocus to manage edit mode for keyboard input
|
||||||
|
*/
|
||||||
|
static void focus_event(lv_event_t* e) {
|
||||||
|
lv_event_code_t code = lv_event_get_code(e);
|
||||||
|
lv_group_t* group = lv_group_get_default();
|
||||||
|
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
if (code == LV_EVENT_FOCUSED) {
|
||||||
|
// Enable edit mode so arrow keys control the game
|
||||||
|
lv_group_set_editing(group, true);
|
||||||
|
} else if (code == LV_EVENT_DEFOCUSED) {
|
||||||
|
// Restore normal focus navigation
|
||||||
|
lv_group_set_editing(group, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Create a new 2048 game object
|
* @brief Create a new 2048 game object
|
||||||
*/
|
*/
|
||||||
@@ -113,10 +140,23 @@ lv_obj_t * twoeleven_create(lv_obj_t * parent, uint16_t matrix_size)
|
|||||||
lv_btnmatrix_set_map(game_2048->btnm, (const char **)game_2048->btnm_map);
|
lv_btnmatrix_set_map(game_2048->btnm, (const char **)game_2048->btnm_map);
|
||||||
lv_btnmatrix_set_btn_ctrl_all(game_2048->btnm, LV_BTNMATRIX_CTRL_DISABLED);
|
lv_btnmatrix_set_btn_ctrl_all(game_2048->btnm, LV_BTNMATRIX_CTRL_DISABLED);
|
||||||
|
|
||||||
lv_obj_add_event_cb(game_2048->btnm, game_play_event, LV_EVENT_ALL, obj);
|
lv_obj_add_event_cb(game_2048->btnm, game_play_event, LV_EVENT_GESTURE, obj);
|
||||||
|
lv_obj_add_event_cb(game_2048->btnm, game_play_event, LV_EVENT_KEY, obj);
|
||||||
lv_obj_add_event_cb(game_2048->btnm, btnm_event_cb, LV_EVENT_DRAW_TASK_ADDED, NULL);
|
lv_obj_add_event_cb(game_2048->btnm, btnm_event_cb, LV_EVENT_DRAW_TASK_ADDED, NULL);
|
||||||
lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL);
|
lv_obj_add_event_cb(obj, delete_event, LV_EVENT_DELETE, NULL);
|
||||||
|
|
||||||
|
if (tt_lvgl_hardware_keyboard_is_available()) {
|
||||||
|
lv_group_t* group = lv_group_get_default();
|
||||||
|
if (group) {
|
||||||
|
lv_group_add_obj(group, game_2048->btnm);
|
||||||
|
// Register focus handlers to manage edit mode lifecycle
|
||||||
|
lv_obj_add_event_cb(game_2048->btnm, focus_event, LV_EVENT_FOCUSED, NULL);
|
||||||
|
lv_obj_add_event_cb(game_2048->btnm, focus_event, LV_EVENT_DEFOCUSED, NULL);
|
||||||
|
// Focus the container (will trigger FOCUSED event and enable edit mode)
|
||||||
|
lv_group_focus_obj(game_2048->btnm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,17 +215,31 @@ static void game_play_event(lv_event_t * e)
|
|||||||
} else if (code == LV_EVENT_KEY) {
|
} else if (code == LV_EVENT_KEY) {
|
||||||
game_2048->game_over = game_over(game_2048->matrix_size, (const uint16_t **)game_2048->matrix);
|
game_2048->game_over = game_over(game_2048->matrix_size, (const uint16_t **)game_2048->matrix);
|
||||||
if (!game_2048->game_over) {
|
if (!game_2048->game_over) {
|
||||||
switch (*((const uint8_t *) lv_event_get_param(e))) {
|
uint32_t key = lv_event_get_key(e);
|
||||||
|
switch (key) {
|
||||||
|
// Arrow keys, WASD, and punctuation keys for cardputer
|
||||||
case LV_KEY_UP:
|
case LV_KEY_UP:
|
||||||
|
case 'w':
|
||||||
|
case 'W':
|
||||||
|
case ';':
|
||||||
success = move_right(&(game_2048->score), game_2048->matrix_size, game_2048->matrix);
|
success = move_right(&(game_2048->score), game_2048->matrix_size, game_2048->matrix);
|
||||||
break;
|
break;
|
||||||
case LV_KEY_DOWN:
|
case LV_KEY_DOWN:
|
||||||
|
case 's':
|
||||||
|
case 'S':
|
||||||
|
case '.':
|
||||||
success = move_left(&(game_2048->score), game_2048->matrix_size, game_2048->matrix);
|
success = move_left(&(game_2048->score), game_2048->matrix_size, game_2048->matrix);
|
||||||
break;
|
break;
|
||||||
case LV_KEY_LEFT:
|
case LV_KEY_LEFT:
|
||||||
|
case 'a':
|
||||||
|
case 'A':
|
||||||
|
case ',':
|
||||||
success = move_up(&(game_2048->score), game_2048->matrix_size, game_2048->matrix);
|
success = move_up(&(game_2048->score), game_2048->matrix_size, game_2048->matrix);
|
||||||
break;
|
break;
|
||||||
case LV_KEY_RIGHT:
|
case LV_KEY_RIGHT:
|
||||||
|
case 'd':
|
||||||
|
case 'D':
|
||||||
|
case '/':
|
||||||
success = move_down(&(game_2048->score), game_2048->matrix_size, game_2048->matrix);
|
success = move_down(&(game_2048->score), game_2048->matrix_size, game_2048->matrix);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||