From 2a45c98fb3e0b519e4c9b40f8ffedffe7cb02fdb Mon Sep 17 00:00:00 2001 From: Adolfo Date: Sun, 19 Jul 2026 23:05:43 -0400 Subject: [PATCH] feat(pocketdungeon): intro GOAL+CONTROLS left icons right + 2x2 tutorial levels - 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 --- Apps/PocketDungeon/main/CMakeLists.txt | 2 +- .../main/Source/DungeonModel.cpp | 83 +++-- Apps/PocketDungeon/main/Source/DungeonModel.h | 12 +- .../main/Source/DungeonRenderer.cpp | 51 ++- .../main/Source/PocketDungeon.cpp | 315 ++++++++++++++++-- .../PocketDungeon/main/Source/PocketDungeon.h | 21 ++ Apps/PocketDungeon/manifest.properties | 10 +- Libraries/SfxEngine/Include/SfxEngine.h | 4 +- Libraries/SfxEngine/Source/SfxEngine.cpp | 89 +++-- 9 files changed, 457 insertions(+), 130 deletions(-) diff --git a/Apps/PocketDungeon/main/CMakeLists.txt b/Apps/PocketDungeon/main/CMakeLists.txt index d2db898..0c2fc8f 100644 --- a/Apps/PocketDungeon/main/CMakeLists.txt +++ b/Apps/PocketDungeon/main/CMakeLists.txt @@ -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 ) diff --git a/Apps/PocketDungeon/main/Source/DungeonModel.cpp b/Apps/PocketDungeon/main/Source/DungeonModel.cpp index 3b95e00..1aefc6e 100644 --- a/Apps/PocketDungeon/main/Source/DungeonModel.cpp +++ b/Apps/PocketDungeon/main/Source/DungeonModel.cpp @@ -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(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(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)); + uint16_t realFloor = state.floor - 1; + addEnemy(EntityType::Slime, {5, 2}, 1 + static_cast(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; } } diff --git a/Apps/PocketDungeon/main/Source/DungeonModel.h b/Apps/PocketDungeon/main/Source/DungeonModel.h index b89d51f..c2473af 100644 --- a/Apps/PocketDungeon/main/Source/DungeonModel.h +++ b/Apps/PocketDungeon/main/Source/DungeonModel.h @@ -4,9 +4,9 @@ #include 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(state.floor); } + uint16_t displayFloor() const { return state.floor < 2 ? 1 : state.floor - 1; } }; } // namespace PocketDungeon diff --git a/Apps/PocketDungeon/main/Source/DungeonRenderer.cpp b/Apps/PocketDungeon/main/Source/DungeonRenderer.cpp index bdfb0c8..2f98412 100644 --- a/Apps/PocketDungeon/main/Source/DungeonRenderer.cpp +++ b/Apps/PocketDungeon/main/Source/DungeonRenderer.cpp @@ -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((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; } diff --git a/Apps/PocketDungeon/main/Source/PocketDungeon.cpp b/Apps/PocketDungeon/main/Source/PocketDungeon.cpp index 7bb24d2..9262c0f 100644 --- a/Apps/PocketDungeon/main/Source/PocketDungeon.cpp +++ b/Apps/PocketDungeon/main/Source/PocketDungeon.cpp @@ -1,5 +1,8 @@ #include "PocketDungeon.h" -#include +#include +extern "C" { +#include +} 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(prefs.getInt(PREF_BEST_FLOOR, 1)), static_cast(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(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(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(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(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; diff --git a/Apps/PocketDungeon/main/Source/PocketDungeon.h b/Apps/PocketDungeon/main/Source/PocketDungeon.h index a40e4ad..7deb0b9 100644 --- a/Apps/PocketDungeon/main/Source/PocketDungeon.h +++ b/Apps/PocketDungeon/main/Source/PocketDungeon.h @@ -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; diff --git a/Apps/PocketDungeon/manifest.properties b/Apps/PocketDungeon/manifest.properties index e5b32fe..4b86b8f 100644 --- a/Apps/PocketDungeon/manifest.properties +++ b/Apps/PocketDungeon/manifest.properties @@ -1,11 +1,11 @@ [manifest] version=0.1 [target] -sdk=0.7.0-dev -platforms=esp32,esp32s3,esp32c6,esp32p4 +sdk=0.8.0-dev +platforms=esp32s3,esp32p4 [app] id=one.tactility.pocketdungeon -versionName=0.1.0 -versionCode=1 +versionName=0.1.1 +versionCode=2 name=Pocket Dungeon -description=Tiny paper-inspired dungeon crawler +description=Tiny paper-inspired dungeon crawler - audio-stream migrated diff --git a/Libraries/SfxEngine/Include/SfxEngine.h b/Libraries/SfxEngine/Include/SfxEngine.h index 4e96e52..a6ac338 100644 --- a/Libraries/SfxEngine/Include/SfxEngine.h +++ b/Libraries/SfxEngine/Include/SfxEngine.h @@ -26,6 +26,7 @@ #include #include +#include #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "freertos/queue.h" @@ -277,7 +278,8 @@ private: // State //-------------------------------------------------------------------------- - Device* i2sDevice_ = nullptr; + Device* streamDevice_ = nullptr; + AudioStreamHandle streamHandle_ = nullptr; TaskHandle_t task_ = nullptr; SemaphoreHandle_t stopSemaphore_ = nullptr; // Signaled when audio task exits QueueHandle_t msgQueue_ = nullptr; diff --git a/Libraries/SfxEngine/Source/SfxEngine.cpp b/Libraries/SfxEngine/Source/SfxEngine.cpp index a6fd942..917918d 100644 --- a/Libraries/SfxEngine/Source/SfxEngine.cpp +++ b/Libraries/SfxEngine/Source/SfxEngine.cpp @@ -9,7 +9,8 @@ #include "SfxEngine.h" #include "SfxDefinitions.h" -#include +#include +#include #include #include #include "esp_log.h" @@ -424,7 +425,7 @@ void SfxEngine::audioTaskFunc(void* param) { size_t written; QueueMsg msg; - ESP_LOGI(TAG, "Audio task started"); + ESP_LOGI(TAG, "Audio task started (audio-stream)"); while (self->running_) { // Process queued messages (non-blocking) @@ -463,19 +464,26 @@ void SfxEngine::audioTaskFunc(void* param) { // Fill audio buffer (member buffer to avoid stack pressure) self->fillStereoBuffer(self->audioBuffer_, BUFFER_SAMPLES); - // Write to I2S - error_t error = i2s_controller_write(self->i2sDevice_, self->audioBuffer_, - sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(100)); + // Write via audio-stream (resampled to native codec rate, e.g. 44100) + if (self->streamHandle_ == nullptr) { + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + error_t error = audio_stream_write(self->streamHandle_, self->audioBuffer_, + sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(200)); if (error != ERROR_NONE) { - ESP_LOGE(TAG, "I2S write error"); + ESP_LOGE(TAG, "audio_stream_write error %d", error); self->running_ = false; break; } } - // Flush silence - memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_)); - i2s_controller_write(self->i2sDevice_, self->audioBuffer_, sizeof(self->audioBuffer_), &written, pdMS_TO_TICKS(50)); + // Flush silence to avoid pop + if (self->streamHandle_ != nullptr) { + memset(self->audioBuffer_, 0, sizeof(self->audioBuffer_)); + size_t dummy = 0; + audio_stream_write(self->streamHandle_, self->audioBuffer_, sizeof(self->audioBuffer_), &dummy, pdMS_TO_TICKS(100)); + } ESP_LOGI(TAG, "Audio task exiting"); @@ -494,33 +502,38 @@ void SfxEngine::audioTaskFunc(void* param) { bool SfxEngine::start() { if (running_) return true; - // Find I2S device - i2sDevice_ = nullptr; - device_for_each_of_type(&I2S_CONTROLLER_TYPE, &i2sDevice_, [](Device* device, void* context) { - if (!device_is_ready(device)) return true; - Device** devicePtr = static_cast(context); - *devicePtr = device; - return false; - }); + // Find audio-stream device (new audio provision) + streamDevice_ = nullptr; + streamHandle_ = nullptr; - if (i2sDevice_ == nullptr) { - ESP_LOGW(TAG, "No I2S device found"); + streamDevice_ = device_find_by_name("audio-stream"); + if (streamDevice_ == nullptr) { + // Fallback: find first device of AUDIO_STREAM_TYPE + device_for_each_of_type(&AUDIO_STREAM_TYPE, &streamDevice_, [](Device* device, void* context) { + if (!device_is_ready(device)) return true; + Device** devPtr = static_cast(context); + *devPtr = device; + return false; + }); + } + + if (streamDevice_ == nullptr) { + ESP_LOGW(TAG, "No audio-stream device found - audio provision missing"); return false; } - // Configure I2S - I2sConfig config = { - .communication_format = I2S_FORMAT_STAND_I2S, + // Open output stream with resampling (app wants 16k stereo, codec native is 44100) + struct AudioStreamConfig cfg = { .sample_rate = SAMPLE_RATE, .bits_per_sample = 16, - .channel_left = 0, - .channel_right = 0 + .channels = 2 }; - error_t error = i2s_controller_set_config(i2sDevice_, &config); + error_t error = audio_stream_open_output(streamDevice_, &cfg, &streamHandle_); if (error != ERROR_NONE) { - ESP_LOGE(TAG, "Failed to configure I2S: %s", error_to_string(error)); - i2sDevice_ = nullptr; + ESP_LOGE(TAG, "Failed to open audio-stream: %s (%d)", error_to_string(error), error); + streamDevice_ = nullptr; + streamHandle_ = nullptr; return false; } @@ -528,12 +541,13 @@ bool SfxEngine::start() { msgQueue_ = xQueueCreate(8, sizeof(QueueMsg)); if (msgQueue_ == nullptr) { ESP_LOGE(TAG, "Failed to create message queue"); - i2s_controller_reset(i2sDevice_); - i2sDevice_ = nullptr; + audio_stream_close(streamHandle_); + streamHandle_ = nullptr; + streamDevice_ = nullptr; return false; } - // Start audio task + // Start audio task (needs slightly larger stack for audio_stream write path) running_ = true; BaseType_t result = xTaskCreate(audioTaskFunc, "sfxeng", 4096, this, 5, &task_); if (result != pdPASS) { @@ -541,12 +555,13 @@ bool SfxEngine::start() { running_ = false; vQueueDelete(msgQueue_); msgQueue_ = nullptr; - i2s_controller_reset(i2sDevice_); - i2sDevice_ = nullptr; + audio_stream_close(streamHandle_); + streamHandle_ = nullptr; + streamDevice_ = nullptr; return false; } - ESP_LOGI(TAG, "SfxEngine started (voices=%d, sampleRate=%d)", NUM_VOICES, SAMPLE_RATE); + ESP_LOGI(TAG, "SfxEngine started via audio-stream (voices=%d, sampleRate=%d -> resampled to native)", NUM_VOICES, SAMPLE_RATE); return true; } @@ -575,11 +590,13 @@ void SfxEngine::stop() { msgQueue_ = nullptr; } - if (i2sDevice_ != nullptr) { - i2s_controller_reset(i2sDevice_); - i2sDevice_ = nullptr; + if (streamHandle_ != nullptr) { + audio_stream_close(streamHandle_); + streamHandle_ = nullptr; } + streamDevice_ = nullptr; + ESP_LOGI(TAG, "SfxEngine stopped"); }