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

This commit is contained in:
aeroreyna
2026-07-16 22:31:55 -04:00
parent 214287193e
commit d976595879
23 changed files with 862 additions and 0 deletions
@@ -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
+13
View File
@@ -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;
}
}