feat: add Pocket Dungeon game prototype
Main / Build (BookPlayer) (push) Has been cancelled
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
Main / Build (BookPlayer) (push) Has been cancelled
Main / Build (Brainfuck) (push) Has been cancelled
Main / Build (Breakout) (push) Has been cancelled
Main / Build (Calculator) (push) Has been cancelled
Main / Build (Diceware) (push) Has been cancelled
Main / Build (EpubReader) (push) Has been cancelled
Main / Build (GPIO) (push) Has been cancelled
Main / Build (GraphicsDemo) (push) Has been cancelled
Main / Build (HelloWorld) (push) Has been cancelled
Main / Build (M5UnitTest) (push) Has been cancelled
Main / Build (Magic8Ball) (push) Has been cancelled
Main / Build (MediaKeys) (push) Has been cancelled
Main / Build (MystifyDemo) (push) Has been cancelled
Main / Build (SerialConsole) (push) Has been cancelled
Main / Build (Snake) (push) Has been cancelled
Main / Build (TamaTac) (push) Has been cancelled
Main / Build (TodoList) (push) Has been cancelled
Main / Build (TwoEleven) (push) Has been cancelled
Main / Bundle (push) Has been cancelled
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
)
|
||||
@@ -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<uint8_t>(2 + (nextRandom() % 5));
|
||||
auto y = static_cast<uint8_t>(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<int8_t>(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<int16_t>(state.playerPos.x + dx), static_cast<int16_t>(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
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <GameKitGrid.h>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,116 @@
|
||||
#include "DungeonRenderer.h"
|
||||
#include <cstdio>
|
||||
|
||||
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<uint16_t>((screenW - 22) / DUNGEON_COLS);
|
||||
const uint16_t maxCellH = static_cast<uint16_t>((screenH - 70) / DUNGEON_ROWS);
|
||||
const uint16_t cell = maxCellW < maxCellH ? maxCellW : maxCellH;
|
||||
grid = GameKit::GridSpec { DUNGEON_COLS, DUNGEON_ROWS, static_cast<uint16_t>(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
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "DungeonModel.h"
|
||||
#include <GameKit.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "PocketDungeon.h"
|
||||
#include <tt_lvgl_toolbar.h>
|
||||
|
||||
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<uint16_t>(prefs.getInt(PREF_BEST_FLOOR, 1)), static_cast<uint16_t>(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
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "DungeonModel.h"
|
||||
#include "DungeonRenderer.h"
|
||||
#include <GameKit.h>
|
||||
#include <SfxEngine.h>
|
||||
#include <TactilityCpp/App.h>
|
||||
#include <lvgl.h>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,13 @@
|
||||
#include "PocketDungeon.h"
|
||||
#include <TactilityCpp/App.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
(void) argc;
|
||||
(void) argv;
|
||||
registerApp<PocketDungeon::PocketDungeon>();
|
||||
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.pocketdungeon
|
||||
versionName=0.1.0
|
||||
versionCode=1
|
||||
name=Pocket Dungeon
|
||||
description=Tiny paper-inspired dungeon crawler
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "GameKitScene.h"
|
||||
#include "GameKitLoop.h"
|
||||
#include "GameKitGrid.h"
|
||||
#include "GameKitInput.h"
|
||||
#include "GameKitDraw.h"
|
||||
#include "GameKitPrefs.h"
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include "GameKitScene.h"
|
||||
#include <lvgl.h>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include "GameKitScene.h"
|
||||
#include <lvgl.h>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
#pragma once
|
||||
|
||||
#include <TactilityCpp/Preferences.h>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "GameKitGrid.h"
|
||||
|
||||
namespace GameKit {
|
||||
|
||||
lv_point_t gridToPx(const GridSpec& grid, GridPos pos) {
|
||||
return lv_point_t {
|
||||
static_cast<lv_coord_t>(grid.originX + pos.x * grid.cellPx),
|
||||
static_cast<lv_coord_t>(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
|
||||
@@ -0,0 +1,84 @@
|
||||
#include "GameKitInput.h"
|
||||
#include <tt_lvgl_keyboard.h>
|
||||
|
||||
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<Scene*>(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
|
||||
@@ -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<Loop*>(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
|
||||
Reference in New Issue
Block a user