diff --git a/Apps/PocketDungeon/CMakeLists.txt b/Apps/PocketDungeon/CMakeLists.txt new file mode 100644 index 0000000..3bebde5 --- /dev/null +++ b/Apps/PocketDungeon/CMakeLists.txt @@ -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(PocketDungeon) +tactility_project(PocketDungeon) diff --git a/Apps/PocketDungeon/README.md b/Apps/PocketDungeon/README.md new file mode 100644 index 0000000..5eb411d --- /dev/null +++ b/Apps/PocketDungeon/README.md @@ -0,0 +1,19 @@ +# Pocket Dungeon + +Paper Apps-inspired tiny roguelike for Tactility ESP32 devices. + +MVP behavior: +- 9x7 board with walls, treasure, stairs, slimes, bats, and the player. +- One input = one turn. +- Move with keyboard arrows/WASD, swipe gestures, or touch quadrants. +- Bump monsters to attack. +- Monsters chase/attack after each player turn. +- Treasure and defeated monsters add gold. +- Stairs generate the next floor. +- Best floor and gold persist via preferences. + +Build from repo root: + +```bash +python3 tactility.py Apps/PocketDungeon build esp32s3 --verbose +``` diff --git a/Apps/PocketDungeon/main/CMakeLists.txt b/Apps/PocketDungeon/main/CMakeLists.txt new file mode 100644 index 0000000..d2db898 --- /dev/null +++ b/Apps/PocketDungeon/main/CMakeLists.txt @@ -0,0 +1,14 @@ +file(GLOB_RECURSE SOURCE_FILES Source/*.c*) +file(GLOB_RECURSE GAMEKIT_FILES ../../../Libraries/GameKit/Source/*.c*) +file(GLOB_RECURSE SFX_ENGINE_FILES ../../../Libraries/SfxEngine/Source/*.c*) + +idf_component_register( + SRCS ${SOURCE_FILES} ${GAMEKIT_FILES} ${SFX_ENGINE_FILES} + # Library headers must be included directly, + # because all regular dependencies get stripped by elf_loader's cmake script + INCLUDE_DIRS + ../../../Libraries/TactilityCpp/Include + ../../../Libraries/GameKit/Include + ../../../Libraries/SfxEngine/Include + REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio +) diff --git a/Apps/PocketDungeon/main/Source/DungeonModel.cpp b/Apps/PocketDungeon/main/Source/DungeonModel.cpp new file mode 100644 index 0000000..3b95e00 --- /dev/null +++ b/Apps/PocketDungeon/main/Source/DungeonModel.cpp @@ -0,0 +1,131 @@ +#include "DungeonModel.h" + +#ifndef LV_ABS +#define LV_ABS(x) ((x) > 0 ? (x) : -(x)) +#endif + +namespace PocketDungeon { + +uint32_t DungeonModel::nextRandom() { + seed = seed * 1103515245u + 12345u; + return (seed >> 16u) & 0x7FFFu; +} + +void DungeonModel::reset(uint32_t seedValue) { + seed = seedValue; + state = DungeonState {}; + state.floor = 1; + state.hp = 5; + state.gold = 0; + state.gameOver = false; + generateFloor(); +} + +void DungeonModel::addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp) { + if (state.entityCount >= MAX_ENTITIES) return; + state.entities[state.entityCount++] = Entity { type, pos, hp, true }; +} + +void DungeonModel::generateFloor() { + for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { + for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { + const bool edge = (x == 0 || y == 0 || x == DUNGEON_COLS - 1 || y == DUNGEON_ROWS - 1); + state.tiles[y][x] = edge ? Tile::Wall : Tile::Floor; + } + } + state.playerPos = {1, 1}; + state.entityCount = 0; + state.tiles[2][4] = Tile::Wall; + state.tiles[3][4] = Tile::Wall; + state.tiles[5][2 + (state.floor % 2)] = Tile::Treasure; + state.tiles[5][7] = Tile::Stairs; + if (state.floor > 1) { + auto x = static_cast(2 + (nextRandom() % 5)); + auto y = static_cast(1 + (nextRandom() % 5)); + if (!(x == 1 && y == 1) && !(x == 7 && y == 5)) state.tiles[y][x] = Tile::Wall; + } + addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast(state.floor / 3)); + addEnemy(EntityType::Bat, {3, 5}, 1); + if (state.floor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2); +} + +int DungeonModel::findEnemyAt(GameKit::GridPos pos) const { + for (uint8_t i = 0; i < state.entityCount; ++i) { + const auto& entity = state.entities[i]; + if (entity.alive && entity.pos == pos) return i; + } + return -1; +} + +bool DungeonModel::isWalkable(GameKit::GridPos pos) const { + if (pos.x < 0 || pos.y < 0 || pos.x >= DUNGEON_COLS || pos.y >= DUNGEON_ROWS) return false; + return state.tiles[pos.y][pos.x] != Tile::Wall; +} + +MoveResult DungeonModel::movePlayer(int dx, int dy) { + if (state.gameOver) return MoveResult::Dead; + GameKit::GridPos target { static_cast(state.playerPos.x + dx), static_cast(state.playerPos.y + dy) }; + if (!isWalkable(target)) return MoveResult::Blocked; + const int enemyIdx = findEnemyAt(target); + if (enemyIdx >= 0) { + Entity& enemy = state.entities[enemyIdx]; + enemy.hp -= 1; + if (enemy.hp <= 0) { + enemy.alive = false; + state.gold += enemy.type == EntityType::Bat ? 2 : 1; + } + moveMonsters(); + if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; } + return MoveResult::Attacked; + } + state.playerPos = target; + MoveResult result = MoveResult::Moved; + Tile& tile = state.tiles[target.y][target.x]; + if (tile == Tile::Treasure) { + state.gold += 5; + tile = Tile::Floor; + result = MoveResult::Treasure; + } else if (tile == Tile::Stairs) { + state.floor += 1; + if (state.floor > bestFloor) bestFloor = state.floor; + if (state.gold > bestGold) bestGold = state.gold; + generateFloor(); + return MoveResult::Stairs; + } + moveMonsters(); + if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; } + if (state.floor > bestFloor) bestFloor = state.floor; + if (state.gold > bestGold) bestGold = state.gold; + return result; +} + +void DungeonModel::moveMonsters() { + for (uint8_t i = 0; i < state.entityCount; ++i) { + Entity& enemy = state.entities[i]; + if (!enemy.alive) continue; + const int16_t dx = state.playerPos.x - enemy.pos.x; + const int16_t dy = state.playerPos.y - enemy.pos.y; + if ((dx == 0 && (dy == 1 || dy == -1)) || (dy == 0 && (dx == 1 || dx == -1))) { + state.hp -= 1; + continue; + } + GameKit::GridPos target = enemy.pos; + if (enemy.type == EntityType::Bat && (nextRandom() % 3 == 0)) { + const int dirs[4][2] = {{1,0},{-1,0},{0,1},{0,-1}}; + const int* dir = dirs[nextRandom() % 4]; + target.x += dir[0]; + target.y += dir[1]; + } else if (LV_ABS(dx) > LV_ABS(dy)) { + target.x += dx > 0 ? 1 : -1; + } else if (dy != 0) { + target.y += dy > 0 ? 1 : -1; + } + if (target == state.playerPos) { + state.hp -= 1; + } else if (isWalkable(target) && findEnemyAt(target) < 0) { + enemy.pos = target; + } + } +} + +} // namespace PocketDungeon diff --git a/Apps/PocketDungeon/main/Source/DungeonModel.h b/Apps/PocketDungeon/main/Source/DungeonModel.h new file mode 100644 index 0000000..b89d51f --- /dev/null +++ b/Apps/PocketDungeon/main/Source/DungeonModel.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include + +namespace PocketDungeon { + +static constexpr uint8_t DUNGEON_COLS = 9; +static constexpr uint8_t DUNGEON_ROWS = 7; +static constexpr uint8_t MAX_ENTITIES = 8; + +enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure }; +enum class EntityType : uint8_t { Player, Slime, Bat }; +enum class MoveResult : uint8_t { None, Moved, Blocked, Attacked, Treasure, Stairs, Dead }; + +struct Entity { + EntityType type = EntityType::Slime; + GameKit::GridPos pos {}; + int8_t hp = 1; + bool alive = false; +}; + +struct DungeonState { + Tile tiles[DUNGEON_ROWS][DUNGEON_COLS] {}; + Entity entities[MAX_ENTITIES] {}; + uint8_t entityCount = 0; + GameKit::GridPos playerPos {1, 1}; + uint16_t floor = 1; + uint16_t gold = 0; + int8_t hp = 5; + bool gameOver = false; +}; + +class DungeonModel { + DungeonState state {}; + uint32_t seed = 0xC0FFEE; + uint16_t bestFloor = 1; + uint16_t bestGold = 0; + + uint32_t nextRandom(); + void addEnemy(EntityType type, GameKit::GridPos pos, int8_t hp); + int findEnemyAt(GameKit::GridPos pos) const; + bool isWalkable(GameKit::GridPos pos) const; + void moveMonsters(); + +public: + void reset(uint32_t seedValue = 0xC0FFEE); + void generateFloor(); + MoveResult movePlayer(int dx, int dy); + + const DungeonState& getState() const { return state; } + uint16_t getBestFloor() const { return bestFloor; } + uint16_t getBestGold() const { return bestGold; } + void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; } +}; + +} // namespace PocketDungeon diff --git a/Apps/PocketDungeon/main/Source/DungeonRenderer.cpp b/Apps/PocketDungeon/main/Source/DungeonRenderer.cpp new file mode 100644 index 0000000..bdfb0c8 --- /dev/null +++ b/Apps/PocketDungeon/main/Source/DungeonRenderer.cpp @@ -0,0 +1,116 @@ +#include "DungeonRenderer.h" +#include + +namespace PocketDungeon { + +static constexpr uint32_t COLOR_BG = 0x10111A; +static constexpr uint32_t COLOR_PANEL = 0x1D2030; +static constexpr uint32_t COLOR_FLOOR = 0xD8C7A1; +static constexpr uint32_t COLOR_WALL = 0x5D574C; +static constexpr uint32_t COLOR_STAIRS = 0x6D4CC2; +static constexpr uint32_t COLOR_TREASURE = 0xD99B23; +static constexpr uint32_t COLOR_PLAYER = 0x365CF5; +static constexpr uint32_t COLOR_SLIME = 0x3B9D58; +static constexpr uint32_t COLOR_BAT = 0x953B9D; + +void DungeonRenderer::create(lv_obj_t* parent) { + root = parent; + lv_obj_set_style_bg_color(root, lv_color_hex(COLOR_BG), LV_PART_MAIN); + lv_obj_remove_flag(root, LV_OBJ_FLAG_SCROLLABLE); + status = lv_label_create(root); + lv_obj_set_style_text_color(status, lv_color_hex(0xF2E8D0), LV_PART_MAIN); + lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 8); + const lv_coord_t screenW = lv_display_get_horizontal_resolution(nullptr); + const lv_coord_t screenH = lv_display_get_vertical_resolution(nullptr); + const uint16_t maxCellW = static_cast((screenW - 22) / DUNGEON_COLS); + const uint16_t maxCellH = static_cast((screenH - 70) / DUNGEON_ROWS); + const uint16_t cell = maxCellW < maxCellH ? maxCellW : maxCellH; + grid = GameKit::GridSpec { DUNGEON_COLS, DUNGEON_ROWS, static_cast(cell > 10 ? cell : 10), 0, 0 }; + board = GameKit::makePanel(root, lv_color_hex(COLOR_PANEL), 10); + lv_obj_set_size(board, grid.cols * grid.cellPx, grid.rows * grid.cellPx); + lv_obj_align(board, LV_ALIGN_CENTER, 0, 8); + for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { + for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { + cells[y][x] = GameKit::makeCell(board, grid.cellPx - 2, lv_color_hex(COLOR_FLOOR), 4); + GameKit::setCell(cells[y][x], x, y, grid.cellPx); + labels[y][x] = lv_label_create(cells[y][x]); + lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN); + lv_obj_center(labels[y][x]); + } + } + message = lv_label_create(root); + lv_obj_set_style_text_color(message, lv_color_hex(0xB8BCD8), LV_PART_MAIN); + lv_obj_align(message, LV_ALIGN_BOTTOM_MID, 0, -8); +} + +lv_color_t DungeonRenderer::tileColor(Tile tile) { + switch (tile) { + case Tile::Wall: return lv_color_hex(COLOR_WALL); + case Tile::Stairs: return lv_color_hex(COLOR_STAIRS); + case Tile::Treasure: return lv_color_hex(COLOR_TREASURE); + case Tile::Floor: + default: return lv_color_hex(COLOR_FLOOR); + } +} + +const char* DungeonRenderer::tileText(Tile tile) { + switch (tile) { + case Tile::Stairs: return ">"; + case Tile::Treasure: return "$"; + default: return ""; + } +} + +lv_color_t DungeonRenderer::entityColor(EntityType type) { + switch (type) { + case EntityType::Player: return lv_color_hex(COLOR_PLAYER); + case EntityType::Bat: return lv_color_hex(COLOR_BAT); + case EntityType::Slime: + default: return lv_color_hex(COLOR_SLIME); + } +} + +void DungeonRenderer::clearEntityLabels() { + for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { + for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { + lv_label_set_text(labels[y][x], ""); + lv_obj_set_style_text_color(labels[y][x], lv_color_white(), LV_PART_MAIN); + } + } +} + +void DungeonRenderer::render(const DungeonModel& model, MoveResult lastResult) { + const DungeonState& state = model.getState(); + char buf[96]; + std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", state.floor, state.hp, state.gold, model.getBestFloor(), model.getBestGold()); + lv_label_set_text(status, buf); + clearEntityLabels(); + for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { + for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { + const Tile tile = state.tiles[y][x]; + lv_obj_set_style_bg_color(cells[y][x], tileColor(tile), LV_PART_MAIN); + lv_label_set_text(labels[y][x], tileText(tile)); + } + } + for (uint8_t i = 0; i < state.entityCount; ++i) { + const Entity& entity = state.entities[i]; + if (!entity.alive) continue; + const char* symbol = entity.type == EntityType::Bat ? "b" : "s"; + lv_label_set_text(labels[entity.pos.y][entity.pos.x], symbol); + lv_obj_set_style_text_color(labels[entity.pos.y][entity.pos.x], entityColor(entity.type), LV_PART_MAIN); + } + lv_label_set_text(labels[state.playerPos.y][state.playerPos.x], "@"); + lv_obj_set_style_text_color(labels[state.playerPos.y][state.playerPos.x], lv_color_hex(COLOR_PLAYER), LV_PART_MAIN); + const char* text = "Move with arrows, swipe, or tap a direction"; + switch (lastResult) { + case MoveResult::Blocked: text = "A wall blocks the way"; break; + case MoveResult::Attacked: text = "You strike the monster"; break; + case MoveResult::Treasure: text = "Treasure found!"; break; + case MoveResult::Stairs: text = "Down the stairs..."; break; + case MoveResult::Dead: text = "Game over - tap to restart"; break; + default: break; + } + lv_label_set_text(message, text); +} + +} // namespace PocketDungeon diff --git a/Apps/PocketDungeon/main/Source/DungeonRenderer.h b/Apps/PocketDungeon/main/Source/DungeonRenderer.h new file mode 100644 index 0000000..702482c --- /dev/null +++ b/Apps/PocketDungeon/main/Source/DungeonRenderer.h @@ -0,0 +1,28 @@ +#pragma once + +#include "DungeonModel.h" +#include +#include + +namespace PocketDungeon { + +class DungeonRenderer { + lv_obj_t* root = nullptr; + lv_obj_t* board = nullptr; + lv_obj_t* cells[DUNGEON_ROWS][DUNGEON_COLS] {}; + lv_obj_t* labels[DUNGEON_ROWS][DUNGEON_COLS] {}; + lv_obj_t* status = nullptr; + lv_obj_t* message = nullptr; + GameKit::GridSpec grid {}; + + static lv_color_t tileColor(Tile tile); + static const char* tileText(Tile tile); + static lv_color_t entityColor(EntityType type); + void clearEntityLabels(); + +public: + void create(lv_obj_t* parent); + void render(const DungeonModel& model, MoveResult lastResult); +}; + +} // namespace PocketDungeon diff --git a/Apps/PocketDungeon/main/Source/PocketDungeon.cpp b/Apps/PocketDungeon/main/Source/PocketDungeon.cpp new file mode 100644 index 0000000..7bb24d2 --- /dev/null +++ b/Apps/PocketDungeon/main/Source/PocketDungeon.cpp @@ -0,0 +1,97 @@ +#include "PocketDungeon.h" +#include + +namespace PocketDungeon { + +static constexpr uint32_t TICK_MS = 200; +static constexpr const char* PREF_BEST_FLOOR = "bestFloor"; +static constexpr const char* PREF_BEST_GOLD = "bestGold"; + +void PocketDungeon::onShow(AppHandle app, lv_obj_t* parent) { + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN); + root = parent; + (void) tt_lvgl_toolbar_create_for_app(parent, app); + model.setBests(static_cast(prefs.getInt(PREF_BEST_FLOOR, 1)), static_cast(prefs.getInt(PREF_BEST_GOLD, 0))); + model.reset(lv_tick_get()); + if (sfx == nullptr) { + sfx = new SfxEngine(); + if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet); + } + onEnter(parent); + loop.start(TICK_MS, this); +} + +void PocketDungeon::onHide(AppHandle app) { + (void) app; + loop.stop(); + onExit(); + saveBests(); + if (sfx != nullptr) { + sfx->stop(); + delete sfx; + sfx = nullptr; + } + root = nullptr; +} + +void PocketDungeon::onEnter(lv_obj_t* sceneRoot) { + renderer.create(sceneRoot); + GameKit::attachInput(sceneRoot, this); + lastResult = MoveResult::None; + render(); +} + +void PocketDungeon::onExit() {} + +void PocketDungeon::onInput(const GameKit::InputEvent& input) { + int dx = 0; + int dy = 0; + switch (input.kind) { + case GameKit::InputKind::Up: dy = -1; break; + case GameKit::InputKind::Down: dy = 1; break; + case GameKit::InputKind::Left: dx = -1; break; + case GameKit::InputKind::Right: dx = 1; break; + default: break; + } + if (model.getState().gameOver) { + model.reset(lv_tick_get()); + lastResult = MoveResult::None; + render(); + return; + } + if (dx != 0 || dy != 0) { + lastResult = model.movePlayer(dx, dy); + playResultSound(lastResult); + if (lastResult == MoveResult::Dead) saveBests(); + render(); + } +} + +void PocketDungeon::onTick(const GameKit::Tick& tick) { + (void) tick; +} + +void PocketDungeon::render() { + renderer.render(model, lastResult); +} + +void PocketDungeon::saveBests() { + prefs.putInt(PREF_BEST_FLOOR, model.getBestFloor()); + prefs.putInt(PREF_BEST_GOLD, model.getBestGold()); +} + +void PocketDungeon::playResultSound(MoveResult result) { + if (sfx == nullptr) return; + switch (result) { + case MoveResult::Attacked: sfx->play(SfxId::Hurt); break; + case MoveResult::Treasure: sfx->play(SfxId::Coin); break; + case MoveResult::Stairs: sfx->play(SfxId::Warp); break; + case MoveResult::Dead: sfx->play(SfxId::GameOver); break; + case MoveResult::Moved: sfx->play(SfxId::Blip); break; + case MoveResult::Blocked: sfx->play(SfxId::Error); break; + default: break; + } +} + +} // namespace PocketDungeon diff --git a/Apps/PocketDungeon/main/Source/PocketDungeon.h b/Apps/PocketDungeon/main/Source/PocketDungeon.h new file mode 100644 index 0000000..a40e4ad --- /dev/null +++ b/Apps/PocketDungeon/main/Source/PocketDungeon.h @@ -0,0 +1,34 @@ +#pragma once + +#include "DungeonModel.h" +#include "DungeonRenderer.h" +#include +#include +#include +#include + +namespace PocketDungeon { + +class PocketDungeon final : public App, public GameKit::Scene { + lv_obj_t* root = nullptr; + GameKit::Loop loop; + GameKit::Prefs prefs {"PocketDungeon"}; + DungeonModel model; + DungeonRenderer renderer; + SfxEngine* sfx = nullptr; + MoveResult lastResult = MoveResult::None; + + void playResultSound(MoveResult result); + void saveBests(); + +public: + void onShow(AppHandle app, lv_obj_t* parent) override; + void onHide(AppHandle app) override; + void onEnter(lv_obj_t* root) override; + void onExit() override; + void onInput(const GameKit::InputEvent& input) override; + void onTick(const GameKit::Tick& tick) override; + void render() override; +}; + +} // namespace PocketDungeon diff --git a/Apps/PocketDungeon/main/Source/main.cpp b/Apps/PocketDungeon/main/Source/main.cpp new file mode 100644 index 0000000..3536439 --- /dev/null +++ b/Apps/PocketDungeon/main/Source/main.cpp @@ -0,0 +1,13 @@ +#include "PocketDungeon.h" +#include + +extern "C" { + +int main(int argc, char* argv[]) { + (void) argc; + (void) argv; + registerApp(); + return 0; +} + +} diff --git a/Apps/PocketDungeon/manifest.properties b/Apps/PocketDungeon/manifest.properties new file mode 100644 index 0000000..e5b32fe --- /dev/null +++ b/Apps/PocketDungeon/manifest.properties @@ -0,0 +1,11 @@ +[manifest] +version=0.1 +[target] +sdk=0.7.0-dev +platforms=esp32,esp32s3,esp32c6,esp32p4 +[app] +id=one.tactility.pocketdungeon +versionName=0.1.0 +versionCode=1 +name=Pocket Dungeon +description=Tiny paper-inspired dungeon crawler diff --git a/Libraries/GameKit/Include/GameKit.h b/Libraries/GameKit/Include/GameKit.h new file mode 100644 index 0000000..e3d713f --- /dev/null +++ b/Libraries/GameKit/Include/GameKit.h @@ -0,0 +1,8 @@ +#pragma once + +#include "GameKitScene.h" +#include "GameKitLoop.h" +#include "GameKitGrid.h" +#include "GameKitInput.h" +#include "GameKitDraw.h" +#include "GameKitPrefs.h" diff --git a/Libraries/GameKit/Include/GameKitDraw.h b/Libraries/GameKit/Include/GameKitDraw.h new file mode 100644 index 0000000..3e01b6f --- /dev/null +++ b/Libraries/GameKit/Include/GameKitDraw.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace GameKit { + +lv_obj_t* makePanel(lv_obj_t* parent, lv_color_t color, uint8_t radius = 8); +lv_obj_t* makeCell(lv_obj_t* parent, uint16_t size, lv_color_t color, uint8_t radius = 4); +lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, lv_color_t color); +void setCell(lv_obj_t* obj, int x, int y, uint16_t cellPx); + +} // namespace GameKit diff --git a/Libraries/GameKit/Include/GameKitGrid.h b/Libraries/GameKit/Include/GameKitGrid.h new file mode 100644 index 0000000..e30a8bb --- /dev/null +++ b/Libraries/GameKit/Include/GameKitGrid.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace GameKit { + +struct GridPos { + int16_t x = 0; + int16_t y = 0; +}; + +inline bool operator==(GridPos a, GridPos b) { return a.x == b.x && a.y == b.y; } +inline bool operator!=(GridPos a, GridPos b) { return !(a == b); } + +struct GridSpec { + uint8_t cols = 0; + uint8_t rows = 0; + uint16_t cellPx = 0; + uint16_t originX = 0; + uint16_t originY = 0; +}; + +lv_point_t gridToPx(const GridSpec& grid, GridPos pos); +bool gridContains(const GridSpec& grid, GridPos pos); + +} // namespace GameKit diff --git a/Libraries/GameKit/Include/GameKitInput.h b/Libraries/GameKit/Include/GameKitInput.h new file mode 100644 index 0000000..66c57b8 --- /dev/null +++ b/Libraries/GameKit/Include/GameKitInput.h @@ -0,0 +1,13 @@ +#pragma once + +#include "GameKitScene.h" +#include + +namespace GameKit { + +InputKind keyToInput(uint32_t key); +InputKind gestureToInput(lv_dir_t dir); +InputKind pointToQuadrant(lv_obj_t* obj, lv_point_t point); +void attachInput(lv_obj_t* target, Scene* scene); + +} // namespace GameKit diff --git a/Libraries/GameKit/Include/GameKitLoop.h b/Libraries/GameKit/Include/GameKitLoop.h new file mode 100644 index 0000000..30ecdc6 --- /dev/null +++ b/Libraries/GameKit/Include/GameKitLoop.h @@ -0,0 +1,24 @@ +#pragma once + +#include "GameKitScene.h" +#include +#include + +namespace GameKit { + +class Loop { + lv_timer_t* timer = nullptr; + Scene* scene = nullptr; + uint32_t periodMs = 0; + uint32_t lastTickMs = 0; + + static void onTimer(lv_timer_t* timer); + +public: + ~Loop(); + bool start(uint32_t tickMs, Scene* targetScene); + void stop(); + bool isRunning() const { return timer != nullptr; } +}; + +} // namespace GameKit diff --git a/Libraries/GameKit/Include/GameKitPrefs.h b/Libraries/GameKit/Include/GameKitPrefs.h new file mode 100644 index 0000000..75fd4b0 --- /dev/null +++ b/Libraries/GameKit/Include/GameKitPrefs.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include + +namespace GameKit { + +class Prefs { + Preferences prefs; + +public: + explicit Prefs(const char* ns) : prefs(ns) {} + int32_t getInt(const char* key, int32_t fallback = 0) const { return prefs.getInt32(key, fallback); } + void putInt(const char* key, int32_t value) const { prefs.putInt32(key, value); } +}; + +} // namespace GameKit diff --git a/Libraries/GameKit/Include/GameKitScene.h b/Libraries/GameKit/Include/GameKitScene.h new file mode 100644 index 0000000..55e394f --- /dev/null +++ b/Libraries/GameKit/Include/GameKitScene.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +namespace GameKit { + +struct Tick { + uint32_t dtMs = 0; + uint32_t nowMs = 0; +}; + +enum class InputKind : uint8_t { + Up, + Down, + Left, + Right, + Confirm, + Cancel, + Menu, + Touch, + Swipe, + Unknown, +}; + +struct InputEvent { + InputKind kind = InputKind::Unknown; + lv_point_t point {0, 0}; +}; + +class Scene { +public: + virtual ~Scene() = default; + virtual void onEnter(lv_obj_t* root) = 0; + virtual void onExit() = 0; + virtual void onInput(const InputEvent& input) = 0; + virtual void onTick(const Tick& tick) = 0; + virtual void render() = 0; +}; + +} // namespace GameKit diff --git a/Libraries/GameKit/README.md b/Libraries/GameKit/README.md new file mode 100644 index 0000000..d97acc3 --- /dev/null +++ b/Libraries/GameKit/README.md @@ -0,0 +1,13 @@ +# GameKit + +Small LVGL-first helper library for Tactility games. + +Current scope: +- `Loop`: `lv_timer` lifecycle wrapper. +- `Scene`: common enter/exit/input/tick/render interface. +- `Input`: normalize keyboard arrows/WASD, gestures, and touch-quadrant clicks. +- `Grid`: tiny tile-grid coordinate helpers. +- `Draw`: lightweight panel/cell/label helpers. +- `Prefs`: small wrapper around Tactility preferences. + +This intentionally avoids a heavy sprite engine. First consumers should use LVGL rectangles/labels, then add canvas/sprite helpers only when a game proves it needs them. diff --git a/Libraries/GameKit/Source/GameKitDraw.cpp b/Libraries/GameKit/Source/GameKitDraw.cpp new file mode 100644 index 0000000..fb8c9f0 --- /dev/null +++ b/Libraries/GameKit/Source/GameKitDraw.cpp @@ -0,0 +1,32 @@ +#include "GameKitDraw.h" + +namespace GameKit { + +lv_obj_t* makePanel(lv_obj_t* parent, lv_color_t color, uint8_t radius) { + lv_obj_t* obj = lv_obj_create(parent); + lv_obj_set_style_bg_color(obj, color, LV_PART_MAIN); + lv_obj_set_style_border_width(obj, 0, LV_PART_MAIN); + lv_obj_set_style_radius(obj, radius, LV_PART_MAIN); + lv_obj_set_style_pad_all(obj, 0, LV_PART_MAIN); + lv_obj_remove_flag(obj, LV_OBJ_FLAG_SCROLLABLE); + return obj; +} + +lv_obj_t* makeCell(lv_obj_t* parent, uint16_t size, lv_color_t color, uint8_t radius) { + lv_obj_t* obj = makePanel(parent, color, radius); + lv_obj_set_size(obj, size, size); + return obj; +} + +lv_obj_t* makeLabel(lv_obj_t* parent, const char* text, lv_color_t color) { + lv_obj_t* label = lv_label_create(parent); + lv_label_set_text(label, text); + lv_obj_set_style_text_color(label, color, LV_PART_MAIN); + return label; +} + +void setCell(lv_obj_t* obj, int x, int y, uint16_t cellPx) { + lv_obj_set_pos(obj, x * cellPx, y * cellPx); +} + +} // namespace GameKit diff --git a/Libraries/GameKit/Source/GameKitGrid.cpp b/Libraries/GameKit/Source/GameKitGrid.cpp new file mode 100644 index 0000000..ebd2cd4 --- /dev/null +++ b/Libraries/GameKit/Source/GameKitGrid.cpp @@ -0,0 +1,16 @@ +#include "GameKitGrid.h" + +namespace GameKit { + +lv_point_t gridToPx(const GridSpec& grid, GridPos pos) { + return lv_point_t { + static_cast(grid.originX + pos.x * grid.cellPx), + static_cast(grid.originY + pos.y * grid.cellPx) + }; +} + +bool gridContains(const GridSpec& grid, GridPos pos) { + return pos.x >= 0 && pos.y >= 0 && pos.x < grid.cols && pos.y < grid.rows; +} + +} // namespace GameKit diff --git a/Libraries/GameKit/Source/GameKitInput.cpp b/Libraries/GameKit/Source/GameKitInput.cpp new file mode 100644 index 0000000..2393a0a --- /dev/null +++ b/Libraries/GameKit/Source/GameKitInput.cpp @@ -0,0 +1,84 @@ +#include "GameKitInput.h" +#include + +namespace GameKit { + +InputKind keyToInput(uint32_t key) { + switch (key) { + case LV_KEY_UP: return InputKind::Up; + case LV_KEY_DOWN: return InputKind::Down; + case LV_KEY_LEFT: return InputKind::Left; + case LV_KEY_RIGHT: return InputKind::Right; + case LV_KEY_ENTER: return InputKind::Confirm; + case LV_KEY_ESC: return InputKind::Cancel; + default: break; + } + if (key == 'w' || key == 'W') return InputKind::Up; + if (key == 's' || key == 'S') return InputKind::Down; + if (key == 'a' || key == 'A') return InputKind::Left; + if (key == 'd' || key == 'D') return InputKind::Right; + if (key == 'q' || key == 'Q') return InputKind::Cancel; + return InputKind::Unknown; +} + +InputKind gestureToInput(lv_dir_t dir) { + if (dir & LV_DIR_TOP) return InputKind::Up; + if (dir & LV_DIR_BOTTOM) return InputKind::Down; + if (dir & LV_DIR_LEFT) return InputKind::Left; + if (dir & LV_DIR_RIGHT) return InputKind::Right; + return InputKind::Unknown; +} + +InputKind pointToQuadrant(lv_obj_t* obj, lv_point_t point) { + const lv_coord_t w = lv_obj_get_width(obj); + const lv_coord_t h = lv_obj_get_height(obj); + const lv_coord_t cx = w / 2; + const lv_coord_t cy = h / 2; + const lv_coord_t dx = point.x - cx; + const lv_coord_t dy = point.y - cy; + if (LV_ABS(dx) > LV_ABS(dy)) return dx < 0 ? InputKind::Left : InputKind::Right; + return dy < 0 ? InputKind::Up : InputKind::Down; +} + +static void inputEvent(lv_event_t* e) { + auto* scene = static_cast(lv_event_get_user_data(e)); + if (scene == nullptr) return; + InputEvent input; + const lv_event_code_t code = lv_event_get_code(e); + lv_obj_t* target = lv_event_get_target_obj(e); + if (code == LV_EVENT_KEY) { + input.kind = keyToInput(lv_event_get_key(e)); + } else if (code == LV_EVENT_GESTURE) { + input.kind = gestureToInput(lv_indev_get_gesture_dir(lv_indev_active())); + } else if (code == LV_EVENT_CLICKED) { + lv_indev_t* indev = lv_indev_active(); + if (indev) { + lv_indev_get_point(indev, &input.point); + lv_area_t coords; + lv_obj_get_coords(target, &coords); + input.point.x -= coords.x1; + input.point.y -= coords.y1; + input.kind = pointToQuadrant(target, input.point); + } else { + input.kind = InputKind::Confirm; + } + } + if (input.kind != InputKind::Unknown) scene->onInput(input); +} + +void attachInput(lv_obj_t* target, Scene* scene) { + lv_obj_add_flag(target, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(target, inputEvent, LV_EVENT_KEY, scene); + lv_obj_add_event_cb(target, inputEvent, LV_EVENT_GESTURE, scene); + lv_obj_add_event_cb(target, inputEvent, LV_EVENT_CLICKED, scene); + if (tt_lvgl_hardware_keyboard_is_available()) { + lv_group_t* group = lv_group_get_default(); + if (group) { + lv_group_add_obj(group, target); + lv_group_focus_obj(target); + lv_group_set_editing(group, true); + } + } +} + +} // namespace GameKit diff --git a/Libraries/GameKit/Source/GameKitLoop.cpp b/Libraries/GameKit/Source/GameKitLoop.cpp new file mode 100644 index 0000000..ea7694d --- /dev/null +++ b/Libraries/GameKit/Source/GameKitLoop.cpp @@ -0,0 +1,38 @@ +#include "GameKitLoop.h" + +namespace GameKit { + +Loop::~Loop() { stop(); } + +bool Loop::start(uint32_t tickMs, Scene* targetScene) { + stop(); + if (targetScene == nullptr || tickMs == 0) return false; + scene = targetScene; + periodMs = tickMs; + lastTickMs = lv_tick_get(); + timer = lv_timer_create(onTimer, tickMs, this); + return timer != nullptr; +} + +void Loop::stop() { + if (timer != nullptr) { + lv_timer_delete(timer); + timer = nullptr; + } + scene = nullptr; + periodMs = 0; + lastTickMs = 0; +} + +void Loop::onTimer(lv_timer_t* timer) { + auto* loop = static_cast(lv_timer_get_user_data(timer)); + if (loop == nullptr || loop->scene == nullptr) return; + const uint32_t now = lv_tick_get(); + uint32_t dt = loop->periodMs; + if (loop->lastTickMs != 0) dt = now - loop->lastTickMs; + loop->lastTickMs = now; + loop->scene->onTick(Tick { dt, now }); + loop->scene->render(); +} + +} // namespace GameKit