feat(pocketdungeon): intro GOAL+CONTROLS left icons right + 2x2 tutorial levels
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

- Intro redesign QVGA: left scrollable column 2 cards GOAL+CONTROLS only,
  right 44px rail small icons Play 38 accent + Exit 30 muted (fixes cramped)
- Tutorial by play: floor0 TUTORIAL_ROWS=2 player+stairs, floor1 bat+gold+stairs,
  real game from floor2 infinite with displayFloor mapping
- Model: renderRows, isTutorial(), tutorialId(), clearAllTiles(), generateTutorialFloor
- Renderer: respects renderRows HIDDEN, board height 2 rows centered, TUT messages
- Game: reset tutorials=true, skip moveMonsters in tutorial, bests only real floors
- Keep audio-stream migration: SDK 0.8.0-dev IDF5.5, CMake GLOB SfxEngine, SfxEngine audio-stream
- No unexported LVGL: no max_width/scrollbar_mode -> 0 missing symbols

Installed 192.168.68.134 verified screenshot 6.1K airy left+right
This commit is contained in:
Adolfo
2026-07-19 23:05:43 -04:00
parent b2e9046438
commit 2a45c98fb3
9 changed files with 457 additions and 130 deletions
+1 -1
View File
@@ -10,5 +10,5 @@ idf_component_register(
../../../Libraries/TactilityCpp/Include
../../../Libraries/GameKit/Include
../../../Libraries/SfxEngine/Include
REQUIRES TactilitySDK esp_driver_i2s esp_driver_gpio
REQUIRES TactilitySDK
)
+51 -32
View File
@@ -1,9 +1,7 @@
#include "DungeonModel.h"
#ifndef LV_ABS
#define LV_ABS(x) ((x) > 0 ? (x) : -(x))
#endif
namespace PocketDungeon {
uint32_t DungeonModel::nextRandom() {
@@ -11,48 +9,75 @@ uint32_t DungeonModel::nextRandom() {
return (seed >> 16u) & 0x7FFFu;
}
void DungeonModel::reset(uint32_t seedValue) {
void DungeonModel::reset(uint32_t seedValue, bool tutorials) {
seed = seedValue;
state = DungeonState {};
state.floor = 1;
state.floor = tutorials ? 0 : 2;
state.hp = 5;
state.gold = 0;
state.gameOver = false;
generateFloor();
}
void DungeonModel::clearAllTiles() {
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y)
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
state.tiles[y][x] = Tile::Floor;
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) { state.tiles[0][x] = Tile::Wall; state.tiles[DUNGEON_ROWS-1][x] = Tile::Wall; }
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) { state.tiles[y][0] = Tile::Wall; state.tiles[y][DUNGEON_COLS-1] = Tile::Wall; }
}
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::generateTutorialFloor(uint8_t tId) {
clearAllTiles();
state.entityCount = 0;
state.renderRows = TUTORIAL_ROWS;
for (uint8_t y = TUTORIAL_ROWS + 1; y < DUNGEON_ROWS; ++y)
for (uint8_t x = 0; x < DUNGEON_COLS; ++x)
state.tiles[y][x] = Tile::Wall;
state.playerPos = {1, 1};
if (tId == 0) {
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
} else {
state.tiles[1][3] = Tile::Treasure;
state.tiles[1][DUNGEON_COLS - 2] = Tile::Stairs;
addEnemy(EntityType::Bat, {5, 1}, 1);
}
}
void DungeonModel::generateFloor() {
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
if (state.floor < 2) { generateTutorialFloor(static_cast<uint8_t>(state.floor)); return; }
state.renderRows = DUNGEON_ROWS;
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) {
if (state.floor > 2) {
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));
uint16_t realFloor = state.floor - 1;
addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast<int8_t>(realFloor / 3));
addEnemy(EntityType::Bat, {3, 5}, 1);
if (state.floor >= 3) addEnemy(EntityType::Slime, {7, 4}, 2);
if (realFloor >= 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;
const auto& e = state.entities[i];
if (e.alive && e.pos == pos) return i;
}
return -1;
}
@@ -70,11 +95,8 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
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 (enemy.hp <= 0) { enemy.alive = false; state.gold += enemy.type == EntityType::Bat ? 2 : 1; }
if (!isTutorial()) moveMonsters();
if (state.hp <= 0) { state.gameOver = true; return MoveResult::Dead; }
return MoveResult::Attacked;
}
@@ -87,15 +109,19 @@ MoveResult DungeonModel::movePlayer(int dx, int dy) {
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;
if (state.floor >= 2) {
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
if (state.gold > bestGold) bestGold = state.gold;
}
generateFloor();
return MoveResult::Stairs;
}
moveMonsters();
if (!isTutorial()) 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;
if (state.floor >= 2) {
if (state.floor - 1 > bestFloor) bestFloor = state.floor - 1;
if (state.gold > bestGold) bestGold = state.gold;
}
return result;
}
@@ -105,26 +131,19 @@ void DungeonModel::moveMonsters() {
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;
}
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];
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;
}
if (target == state.playerPos) state.hp -= 1;
else if (isWalkable(target) && findEnemyAt(target) < 0) enemy.pos = target;
}
}
@@ -4,9 +4,9 @@
#include <cstdint>
namespace PocketDungeon {
static constexpr uint8_t DUNGEON_COLS = 9;
static constexpr uint8_t DUNGEON_ROWS = 7;
static constexpr uint8_t TUTORIAL_ROWS = 2;
static constexpr uint8_t MAX_ENTITIES = 8;
enum class Tile : uint8_t { Floor, Wall, Stairs, Treasure };
@@ -25,10 +25,11 @@ struct DungeonState {
Entity entities[MAX_ENTITIES] {};
uint8_t entityCount = 0;
GameKit::GridPos playerPos {1, 1};
uint16_t floor = 1;
uint16_t floor = 0; // 0,1 tutorial, 2+ real
uint16_t gold = 0;
int8_t hp = 5;
bool gameOver = false;
uint8_t renderRows = DUNGEON_ROWS;
};
class DungeonModel {
@@ -42,9 +43,11 @@ class DungeonModel {
int findEnemyAt(GameKit::GridPos pos) const;
bool isWalkable(GameKit::GridPos pos) const;
void moveMonsters();
void generateTutorialFloor(uint8_t tId);
void clearAllTiles();
public:
void reset(uint32_t seedValue = 0xC0FFEE);
void reset(uint32_t seedValue = 0xC0FFEE, bool tutorials = true);
void generateFloor();
MoveResult movePlayer(int dx, int dy);
@@ -52,6 +55,9 @@ public:
uint16_t getBestFloor() const { return bestFloor; }
uint16_t getBestGold() const { return bestGold; }
void setBests(uint16_t floor, uint16_t gold) { bestFloor = floor; bestGold = gold; }
bool isTutorial() const { return state.floor < 2; }
uint8_t tutorialId() const { return static_cast<uint8_t>(state.floor); }
uint16_t displayFloor() const { return state.floor < 2 ? 1 : state.floor - 1; }
};
} // namespace PocketDungeon
@@ -19,7 +19,7 @@ void DungeonRenderer::create(lv_obj_t* parent) {
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);
lv_obj_align(status, LV_ALIGN_TOP_MID, 0, 6);
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);
@@ -48,8 +48,7 @@ lv_color_t DungeonRenderer::tileColor(Tile 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);
case Tile::Floor: default: return lv_color_hex(COLOR_FLOOR);
}
}
@@ -65,48 +64,64 @@ 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);
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 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());
if (model.isTutorial()) {
std::snprintf(buf, sizeof(buf), "TUTORIAL %u/2 HP:%d Gold:%u", model.tutorialId()+1, state.hp, state.gold);
} else {
std::snprintf(buf, sizeof(buf), "F%u HP:%d Gold:%u Best:%u/%u", model.displayFloor(), state.hp, state.gold, model.getBestFloor(), model.getBestGold());
}
lv_label_set_text(status, buf);
clearEntityLabels();
for (uint8_t y = 0; y < DUNGEON_ROWS; ++y) {
bool visible = y < state.renderRows;
if (!visible) {
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) lv_obj_add_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
continue;
}
for (uint8_t x = 0; x < DUNGEON_COLS; ++x) {
lv_obj_clear_flag(cells[y][x], LV_OBJ_FLAG_HIDDEN);
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));
}
}
if (state.renderRows == TUTORIAL_ROWS) {
lv_obj_set_height(board, grid.cellPx * TUTORIAL_ROWS);
lv_obj_align(board, LV_ALIGN_CENTER, 0, -10);
} else {
lv_obj_set_height(board, grid.rows * grid.cellPx);
lv_obj_align(board, LV_ALIGN_CENTER, 0, 8);
}
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);
const Entity& e = state.entities[i];
if (!e.alive) continue;
const char* sym = e.type == EntityType::Bat ? "b" : "s";
lv_label_set_text(labels[e.pos.y][e.pos.x], sym);
lv_obj_set_style_text_color(labels[e.pos.y][e.pos.x], entityColor(e.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";
const char* text = model.isTutorial() ? (model.tutorialId()==0 ? "TUT1: @ to >" : "TUT2: Kill b, $ , >") : "Arrows/Swipe/Tap";
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::Blocked: text = "Wall blocks"; break;
case MoveResult::Attacked: text = "You strike!"; break;
case MoveResult::Treasure: text = "Treasure! +5"; break;
case MoveResult::Stairs: text = model.isTutorial() ? "Tutorial done!" : "Down..."; break;
case MoveResult::Dead: text = "Game over - tap to restart"; break;
default: break;
}
+281 -34
View File
@@ -1,5 +1,8 @@
#include "PocketDungeon.h"
#include <tt_lvgl_toolbar.h>
#include <cstdio>
extern "C" {
#include <tt_app.h>
}
namespace PocketDungeon {
@@ -7,19 +10,26 @@ static constexpr uint32_t TICK_MS = 200;
static constexpr const char* PREF_BEST_FLOOR = "bestFloor";
static constexpr const char* PREF_BEST_GOLD = "bestGold";
static constexpr uint32_t COLOR_BG = 0x10111A;
static constexpr uint32_t COLOR_PANEL = 0x1D2030;
static constexpr uint32_t COLOR_ACCENT = 0x365CF5;
static constexpr uint32_t COLOR_OVERLAY = 0x000000;
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);
lv_obj_set_style_bg_color(parent, lv_color_hex(COLOR_BG), 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());
model.reset(lv_tick_get(), true);
if (sfx == nullptr) {
sfx = new SfxEngine();
if (sfx->start()) sfx->applyVolumePreset(SfxEngine::VolumePreset::Quiet);
}
phase = AppPhase::Intro;
onEnter(parent);
loop.start(TICK_MS, this);
(void) app;
}
void PocketDungeon::onHide(AppHandle app) {
@@ -27,63 +37,300 @@ void PocketDungeon::onHide(AppHandle app) {
loop.stop();
onExit();
saveBests();
if (sfx != nullptr) {
sfx->stop();
delete sfx;
sfx = nullptr;
}
if (sfx) { sfx->stop(); delete sfx; sfx = nullptr; }
root = nullptr;
}
void PocketDungeon::onEnter(lv_obj_t* sceneRoot) {
renderer.create(sceneRoot);
GameKit::attachInput(sceneRoot, this);
root = sceneRoot;
GameKit::attachInput(root, this);
showIntro();
lastResult = MoveResult::None;
}
void PocketDungeon::onExit() { clearIntro(); clearGame(); }
void PocketDungeon::attachInputToCurrent() {
if (root) GameKit::attachInput(root, this);
if (introContainer) {
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
GameKit::attachInput(introContainer, this);
}
if (gameContainer) {
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
GameKit::attachInput(gameContainer, this);
lv_obj_add_event_cb(gameContainer, onGameContainerLongPress, LV_EVENT_LONG_PRESSED, this);
}
if (pauseOverlay) {
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
GameKit::attachInput(pauseOverlay, this);
}
}
void PocketDungeon::clearIntro() { if (introContainer) { lv_obj_delete(introContainer); introContainer = nullptr; } }
void PocketDungeon::clearGame() {
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
if (gameContainer) { lv_obj_delete(gameContainer); gameContainer = nullptr; }
}
void PocketDungeon::showIntro() {
clearGame(); clearIntro();
phase = AppPhase::Intro;
introContainer = lv_obj_create(root);
lv_obj_set_size(introContainer, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(introContainer, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(introContainer, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(introContainer, lv_color_hex(COLOR_BG), LV_PART_MAIN);
lv_obj_remove_flag(introContainer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(introContainer, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(introContainer, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(introContainer, 6, LV_PART_MAIN);
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(introContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
renderIntro();
attachInputToCurrent();
}
void PocketDungeon::showGame() {
clearIntro(); clearGame();
phase = AppPhase::Playing;
gameContainer = lv_obj_create(root);
lv_obj_set_size(gameContainer, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(gameContainer, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(gameContainer, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(gameContainer, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_remove_flag(gameContainer, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(gameContainer, LV_OBJ_FLAG_GESTURE_BUBBLE);
renderer.create(gameContainer);
lv_obj_t* pauseBtn = lv_btn_create(gameContainer);
lv_obj_set_size(pauseBtn, 32, 26);
lv_obj_set_style_bg_color(pauseBtn, lv_color_hex(0x2A2D40), LV_PART_MAIN);
lv_obj_set_style_radius(pauseBtn, 6, LV_PART_MAIN);
lv_obj_set_style_pad_all(pauseBtn, 0, LV_PART_MAIN);
lv_obj_align(pauseBtn, LV_ALIGN_TOP_RIGHT, -6, 4);
lv_obj_add_event_cb(pauseBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
lv_obj_t* pauseLbl = lv_label_create(pauseBtn);
lv_label_set_text(pauseLbl, LV_SYMBOL_CLOSE);
lv_obj_center(pauseLbl);
lastResult = MoveResult::None;
attachInputToCurrent();
render();
if (sfx) sfx->play(SfxId::Warp);
}
void PocketDungeon::showPause() {
if (pauseOverlay) return;
phase = AppPhase::Paused;
pauseOverlay = lv_obj_create(root);
lv_obj_set_size(pauseOverlay, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_pad_all(pauseOverlay, 12, LV_PART_MAIN);
lv_obj_set_style_border_width(pauseOverlay, 0, LV_PART_MAIN);
lv_obj_set_style_bg_color(pauseOverlay, lv_color_hex(COLOR_OVERLAY), LV_PART_MAIN);
lv_obj_set_style_bg_opa(pauseOverlay, LV_OPA_70, LV_PART_MAIN);
lv_obj_remove_flag(pauseOverlay, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(pauseOverlay, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(pauseOverlay, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(pauseOverlay, 10, LV_PART_MAIN);
lv_obj_add_flag(pauseOverlay, LV_OBJ_FLAG_CLICKABLE);
lv_obj_t* box = lv_obj_create(pauseOverlay);
lv_obj_set_width(box, LV_PCT(80));
lv_obj_set_height(box, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(box, 14, LV_PART_MAIN);
lv_obj_set_style_bg_color(box, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_border_width(box, 0, LV_PART_MAIN);
lv_obj_set_style_radius(box, 12, LV_PART_MAIN);
lv_obj_set_flex_flow(box, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(box, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(box, 10, LV_PART_MAIN);
lv_obj_remove_flag(box, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* t = lv_label_create(box);
lv_label_set_text(t, "Paused");
lv_obj_set_style_text_color(t, lv_color_white(), LV_PART_MAIN);
lv_obj_t* resumeBtn = lv_btn_create(box);
lv_obj_set_width(resumeBtn, LV_PCT(100));
lv_obj_set_height(resumeBtn, 38);
lv_obj_set_style_bg_color(resumeBtn, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
lv_obj_set_style_radius(resumeBtn, 10, LV_PART_MAIN);
lv_obj_add_event_cb(resumeBtn, onResumeButtonClicked, LV_EVENT_CLICKED, this);
lv_obj_t* rl = lv_label_create(resumeBtn);
lv_label_set_text(rl, "Resume");
lv_obj_center(rl);
lv_obj_t* exitBtn = lv_btn_create(box);
lv_obj_set_width(exitBtn, LV_PCT(100));
lv_obj_set_height(exitBtn, 36);
lv_obj_set_style_bg_color(exitBtn, lv_color_hex(0x2E303F), LV_PART_MAIN);
lv_obj_set_style_radius(exitBtn, 10, LV_PART_MAIN);
lv_obj_add_event_cb(exitBtn, onExitButtonClicked, LV_EVENT_CLICKED, this);
lv_obj_t* el = lv_label_create(exitBtn);
lv_label_set_text(el, "Exit Game");
lv_obj_center(el);
if (sfx) sfx->play(SfxId::MenuOpen);
attachInputToCurrent();
}
void PocketDungeon::hidePause() {
if (pauseOverlay) { lv_obj_delete(pauseOverlay); pauseOverlay = nullptr; }
phase = AppPhase::Playing;
if (sfx) sfx->play(SfxId::MenuClose);
attachInputToCurrent();
render();
}
void PocketDungeon::onExit() {}
void PocketDungeon::exitGame() {
saveBests();
if (sfx) sfx->play(SfxId::Cancel);
tt_app_stop();
}
void PocketDungeon::renderIntro() {
if (!introContainer) return;
lv_obj_t* titleRow = lv_obj_create(introContainer);
lv_obj_set_width(titleRow, LV_PCT(100));
lv_obj_set_height(titleRow, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(titleRow, 6, LV_PART_MAIN);
lv_obj_set_style_border_width(titleRow, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(titleRow, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_remove_flag(titleRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(titleRow, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(titleRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(titleRow, 2, LV_PART_MAIN);
lv_obj_t* title = lv_label_create(titleRow);
lv_label_set_text(title, "POCKET DUNGEON");
lv_obj_set_style_text_color(title, lv_color_hex(0xF8F3E6), LV_PART_MAIN);
lv_obj_t* subtitle = lv_label_create(titleRow);
lv_label_set_text(subtitle, "Tiny paper roguelike");
lv_obj_set_style_text_color(subtitle, lv_color_hex(0x8A9FBF), LV_PART_MAIN);
char bestBuf[64];
std::snprintf(bestBuf, sizeof(bestBuf), "Best F%u G%u", model.getBestFloor(), model.getBestGold());
lv_obj_t* best = lv_label_create(titleRow);
lv_label_set_text(best, bestBuf);
lv_obj_set_style_text_color(best, lv_color_hex(0x7A8196), LV_PART_MAIN);
lv_obj_t* mainRow = lv_obj_create(introContainer);
lv_obj_set_width(mainRow, LV_PCT(100));
lv_obj_set_flex_grow(mainRow, 1);
lv_obj_set_style_pad_all(mainRow, 4, LV_PART_MAIN);
lv_obj_set_style_pad_column(mainRow, 8, LV_PART_MAIN);
lv_obj_set_style_pad_bottom(mainRow, 8, LV_PART_MAIN);
lv_obj_set_style_border_width(mainRow, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(mainRow, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(mainRow, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(mainRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_remove_flag(mainRow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* leftCol = lv_obj_create(mainRow);
lv_obj_set_flex_grow(leftCol, 1);
lv_obj_set_height(leftCol, LV_PCT(100));
lv_obj_set_style_pad_all(leftCol, 0, LV_PART_MAIN);
lv_obj_set_style_pad_right(leftCol, 4, LV_PART_MAIN);
lv_obj_set_style_pad_bottom(leftCol, 6, LV_PART_MAIN);
lv_obj_set_style_border_width(leftCol, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(leftCol, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(leftCol, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(leftCol, 8, LV_PART_MAIN);
lv_obj_add_flag(leftCol, LV_OBJ_FLAG_SCROLLABLE);
auto makeCard = [](lv_obj_t* parent, const char* tTitle, const char* body, uint32_t col) {
lv_obj_t* card = lv_obj_create(parent);
lv_obj_set_width(card, LV_PCT(100));
lv_obj_set_height(card, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(card, 9, LV_PART_MAIN);
lv_obj_set_style_pad_row(card, 3, LV_PART_MAIN);
lv_obj_set_style_bg_color(card, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_border_width(card, 0, LV_PART_MAIN);
lv_obj_set_style_radius(card, 10, LV_PART_MAIN);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* t = lv_label_create(card);
lv_label_set_text(t, tTitle);
lv_obj_set_style_text_color(t, lv_color_hex(col), LV_PART_MAIN);
lv_obj_t* b = lv_label_create(card);
lv_label_set_text(b, body);
lv_obj_set_style_text_color(b, lv_color_hex(0xD9DDE8), LV_PART_MAIN);
lv_label_set_long_mode(b, LV_LABEL_LONG_WRAP);
lv_obj_set_width(b, LV_PCT(100));
return card;
};
makeCard(leftCol, "GOAL", "Reach deepest floor.\nCollect gold, survive.", 0xD99B23);
makeCard(leftCol, "CONTROLS", "Arrows / WASD / Swipe\nTap quadrant = move\nQ / ESC = exit", 0x6D8CFF);
lv_obj_t* rightRail = lv_obj_create(mainRow);
lv_obj_set_width(rightRail, 44);
lv_obj_set_height(rightRail, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(rightRail, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(rightRail, 10, LV_PART_MAIN);
lv_obj_set_style_border_width(rightRail, 0, LV_PART_MAIN);
lv_obj_set_style_bg_opa(rightRail, LV_OPA_TRANSP, LV_PART_MAIN);
lv_obj_set_flex_flow(rightRail, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(rightRail, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_remove_flag(rightRail, LV_OBJ_FLAG_SCROLLABLE);
auto makeIconBtn = [](lv_obj_t* parent, const char* symbol, uint32_t bg, lv_event_cb_t cb, void* user, int s) -> lv_obj_t* {
lv_obj_t* btn = lv_btn_create(parent);
lv_obj_set_size(btn, s, s);
lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN);
lv_obj_set_style_radius(btn, s / 3, LV_PART_MAIN);
lv_obj_set_style_pad_all(btn, 0, LV_PART_MAIN);
lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user);
lv_obj_t* lbl = lv_label_create(btn);
lv_label_set_text(lbl, symbol);
lv_obj_center(lbl);
lv_obj_set_style_text_color(lbl, lv_color_white(), LV_PART_MAIN);
return btn;
};
makeIconBtn(rightRail, LV_SYMBOL_PLAY, COLOR_ACCENT, onStartButtonClicked, this, 38);
makeIconBtn(rightRail, LV_SYMBOL_CLOSE, 0x252836, onExitButtonClicked, this, 30);
}
void PocketDungeon::onStartButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->showGame(); lv_event_stop_bubbling(e); }
void PocketDungeon::onResumeButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->hidePause(); lv_event_stop_bubbling(e); }
void PocketDungeon::onExitButtonClicked(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s) s->exitGame(); lv_event_stop_bubbling(e); }
void PocketDungeon::onGameContainerLongPress(lv_event_t* e) { auto* s = static_cast<PocketDungeon*>(lv_event_get_user_data(e)); if (s && s->phase==AppPhase::Playing) s->showPause(); }
void PocketDungeon::onInput(const GameKit::InputEvent& input) {
int dx = 0;
int dy = 0;
if (input.kind == GameKit::InputKind::Cancel || input.kind == GameKit::InputKind::Menu) { exitGame(); return; }
if (phase == AppPhase::Intro) { if (input.kind != GameKit::InputKind::Unknown) showGame(); return; }
if (phase == AppPhase::Paused) { if (input.kind != GameKit::InputKind::Unknown) hidePause(); return; }
int dx=0, 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;
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();
if (dx!=0||dy!=0||input.kind==GameKit::InputKind::Confirm||input.kind==GameKit::InputKind::Touch) {
model.reset(lv_tick_get(), true); lastResult=MoveResult::None; render();
}
return;
}
if (dx != 0 || dy != 0) {
lastResult = model.movePlayer(dx, dy);
if (dx!=0||dy!=0) {
lastResult=model.movePlayer(dx,dy);
playResultSound(lastResult);
if (lastResult == MoveResult::Dead) saveBests();
if (lastResult==MoveResult::Dead) saveBests();
render();
}
}
void PocketDungeon::onTick(const GameKit::Tick& tick) {
(void) tick;
}
void PocketDungeon::render() {
void PocketDungeon::onTick(const GameKit::Tick& tick){ (void)tick; }
void PocketDungeon::render(){
if (phase==AppPhase::Intro||phase==AppPhase::Paused) return;
if (!gameContainer) return;
renderer.render(model, lastResult);
}
void PocketDungeon::saveBests() {
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) {
void PocketDungeon::playResultSound(MoveResult r){
if (!sfx) return;
switch(r){
case MoveResult::Attacked: sfx->play(SfxId::Hurt); break;
case MoveResult::Treasure: sfx->play(SfxId::Coin); break;
case MoveResult::Stairs: sfx->play(SfxId::Warp); break;
@@ -9,8 +9,15 @@
namespace PocketDungeon {
enum class AppPhase : uint8_t { Intro, Playing, Paused };
class PocketDungeon final : public App, public GameKit::Scene {
lv_obj_t* root = nullptr;
lv_obj_t* introContainer = nullptr;
lv_obj_t* gameContainer = nullptr;
lv_obj_t* pauseOverlay = nullptr;
AppPhase phase = AppPhase::Intro;
GameKit::Loop loop;
GameKit::Prefs prefs {"PocketDungeon"};
DungeonModel model;
@@ -20,6 +27,20 @@ class PocketDungeon final : public App, public GameKit::Scene {
void playResultSound(MoveResult result);
void saveBests();
void showIntro();
void showGame();
void showPause();
void hidePause();
void clearIntro();
void clearGame();
void renderIntro();
void attachInputToCurrent();
void exitGame();
static void onStartButtonClicked(lv_event_t* e);
static void onResumeButtonClicked(lv_event_t* e);
static void onExitButtonClicked(lv_event_t* e);
static void onGameContainerLongPress(lv_event_t* e);
public:
void onShow(AppHandle app, lv_obj_t* parent) override;