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
This commit is contained in:
Shadowtrance
2026-02-07 08:35:13 +10:00
committed by GitHub
parent d31b6b48a4
commit 46cf00d92e
41 changed files with 4025 additions and 131 deletions
+316
View File
@@ -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;
}
}
+49
View File
@@ -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;
};
+109
View File
@@ -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*/
+321
View File
@@ -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;
}
+97
View File
@@ -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*/
+499
View File
@@ -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;
}
+64
View File
@@ -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*/
+11
View File
@@ -0,0 +1,11 @@
#include "Snake.h"
#include <TactilityCpp/App.h>
extern "C" {
int main(int argc, char* argv[]) {
registerApp<Snake>();
return 0;
}
}