diff --git a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp index cde8df2162..048781f53e 100644 --- a/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp +++ b/SerialPrograms/Source/ComputerPrograms/ComputerPrograms_UnitTestRunner.cpp @@ -62,6 +62,7 @@ UnitTestDatabase make_UNIT_TESTS_ALL(){ OCR::add_tests(ret); Kernels::add_tests(ret); Pokemon::add_tests_LegPlanner(ret); + Pokemon::add_tests_LegWalker(ret); NintendoSwitch::add_tests_CheckOnlineDetector(ret); NintendoSwitch::add_tests_FailedToConnectDetector(ret); NintendoSwitch::add_tests_UpdatePopupDetector(ret); diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp new file mode 100644 index 0000000000..40f082e2f8 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.cpp @@ -0,0 +1,70 @@ +/* Leg Walker + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Pokemon_LegWalker.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +std::string to_string(const LegSpec& leg){ + return std::string(direction_name(leg.direction)) + " " + std::to_string(leg.tiles) + + (leg.to_wall ? " (to wall)" : ""); +} + + +Milliseconds leg_hold(const LegSpec& leg, int tiles){ + if (leg.to_wall){ + return leg.tile_time * (tiles + 2); + } + return leg.tile_time * (tiles - 1) + leg.tile_time / 2; +} + +int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay, int remaining){ + const Milliseconds since_first_step = elapsed - detection_delay + leg.tile_time / 2; + if (since_first_step.count() <= 0){ + return 0; + } + return std::clamp((int)(since_first_step / leg.tile_time), 0, remaining); +} + + +LegResult walk_leg_with(const LegSpec& leg, const LegCalls& calls, size_t max_interruptions){ + LegResult result; + int remaining = leg.tiles; + while (true){ + const std::optional outcome = calls.hold_watching(leg_hold(leg, remaining)); + if (!outcome){ + result.tiles = leg.tiles; + return result; + } + result.interruptions++; + if (result.interruptions > max_interruptions){ + calls.fail("Interrupted " + std::to_string(result.interruptions) + " times on: " + to_string(leg)); + } + calls.clear(outcome->interrupt); + const std::string seen = calls.name(outcome->interrupt) + " after " + std::to_string(outcome->elapsed.count()) + "ms"; + if (leg.to_wall){ + // The wall stops the player on the leg's last tile however far + // they got, so the leg is simply held again. + calls.log(seen + ", walking to the wall again."); + continue; + } + const int walked = tiles_before_interrupt(leg, outcome->elapsed, calls.detection_delay(outcome->interrupt), remaining); + remaining -= walked; + result.exact = false; + calls.log(seen + ", about " + std::to_string(walked) + " tiles walked, " + std::to_string(remaining) + " to go."); + if (remaining <= 0){ + result.tiles = leg.tiles; + return result; + } + } +} + + + +} +} diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h new file mode 100644 index 0000000000..2d471b3eb9 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker.h @@ -0,0 +1,212 @@ +/* Leg Walker + * + * From: https://github.com/PokemonAutomation/ + * + * Walks one leg of a plan: holds a direction for the leg's tiles while + * watching for whatever can stop the player part-way (a wild battle, a + * message, a trainer). Each of those is a LegInterrupt the game supplies. + * When one shows, the hold stops, the game clears it, and the rest of the + * leg is walked: a leg to a wall is simply held again, since the wall stops + * the player on its tile; for an open leg, how far the player got is worked + * out from when the interrupt was seen. + * + * How a direction is held (D-pad or stick) and how long a tile takes are + * the game's too, through LegMover. Templated on the console and the + * controller context, like run_until(), so this stays free of any one + * console's types. + * + */ + +#ifndef PokemonAutomation_Pokemon_LegWalker_H +#define PokemonAutomation_Pokemon_LegWalker_H + +#include +#include +#include +#include +#include +#include +#include "Common/Cpp/Time.h" +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" +#include "Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +// Something that stops the player part-way through a leg. +template +class LegInterrupt{ +public: + virtual ~LegInterrupt() = default; + + virtual std::string name() const = 0; + + // A new watcher for the screen the interruption brings up. + virtual std::unique_ptr make_watcher() const = 0; + + // How long after the step that set it off the watcher fires. The games + // only interrupt between steps, so from this the walker works out how + // many tiles of an open leg were walked. + virtual Milliseconds detection_delay() const = 0; + + // Clears it, leaving the player standing on the overworld, on the same + // tile and facing the same way. + virtual void clear(Console& console, Context& context) const = 0; +}; + +template +using LegInterrupts = std::vector>>; + + +// How the game moves the player. +template +class LegMover{ +public: + virtual ~LegMover() = default; + // Holds `direction` for `duration`, then lets go. + virtual void hold(Context& context, Direction direction, Milliseconds duration) const = 0; +}; + + +// A straight leg, for a player standing still and facing its way. +struct LegSpec{ + Direction direction = Direction::NORTH; + int tiles = 0; + // A wall stops the player after the last tile. + bool to_wall = false; + // How long one tile takes, for this leg's direction and way of moving. + Milliseconds tile_time{0}; +}; +std::string to_string(const LegSpec& leg); + +struct LegResult{ + // Tiles walked, which is `tiles` unless the leg gave up. + int tiles = 0; + // Whether `tiles` is certain: an interrupted open leg is an estimate. + bool exact = true; + size_t interruptions = 0; +}; + + +// How long to hold for `tiles` tiles: an open leg lets go half a tile before +// the last step would end, so the step under way finishes and no other +// starts; a leg to a wall is held two tiles longer than it takes. +Milliseconds leg_hold(const LegSpec& leg, int tiles); + +// How many tiles an open leg had walked when an interrupt was seen +// `elapsed` into its hold, given the interrupt's detection delay. Steps end +// a whole number of tiles into the hold, so this rounds to the nearest. +int tiles_before_interrupt(const LegSpec& leg, Milliseconds elapsed, Milliseconds detection_delay, int remaining); + + +// What one hold reported: nothing if it ran out, or which interrupt showed +// and how long into the hold. +struct HoldOutcome{ + size_t interrupt = 0; + Milliseconds elapsed{0}; +}; + +// The leg's bookkeeping, with the console side passed in, so it can be +// tested with scripted outcomes. `hold_watching` holds for the duration and +// reports the first interrupt seen; `clear` clears interrupt i; +// `detection_delay` is interrupt i's delay; `log` reports progress; `fail` +// throws, and is called after more than `max_interruptions`. +struct LegCalls{ + std::function(Milliseconds hold)> hold_watching; + std::function clear; + std::function detection_delay; + std::function name; + std::function log; + std::function fail; +}; +LegResult walk_leg_with(const LegSpec& leg, const LegCalls& calls, size_t max_interruptions); + + +// Walks `leg`. Throws OperationFailedException if it is interrupted more +// than `max_interruptions` times. +template +LegResult walk_leg( + Console& console, Context& context, + const LegMover& mover, + const LegInterrupts& interrupts, + const LegSpec& leg, + size_t max_interruptions = 5 +){ + auto hold_watching = [&](Milliseconds hold) -> std::optional{ + std::vector> watchers; + std::vector callbacks; + for (const auto& interrupt : interrupts){ + watchers.emplace_back(interrupt->make_watcher()); + callbacks.emplace_back(*watchers.back()); + } + context.wait_for_all_requests(); + WallClock start = current_time(); + const int ret = run_until( + console, context, + [&](Context& context){ + start = current_time(); + mover.hold(context, leg.direction, hold); + }, + callbacks + ); + if (ret < 0){ + return std::nullopt; + } + return HoldOutcome{(size_t)ret, std::chrono::duration_cast(current_time() - start)}; + }; + LegCalls calls; + calls.hold_watching = hold_watching; + calls.clear = [&](size_t index){ interrupts[index]->clear(console, context); }; + calls.detection_delay = [&](size_t index){ return interrupts[index]->detection_delay(); }; + calls.name = [&](size_t index){ return interrupts[index]->name(); }; + calls.log = [&](const std::string& message){ console.log("Leg: " + message, COLOR_ORANGE); }; + calls.fail = [&](const std::string& message){ + OperationFailedExceptionWithScreenshot::fire(ErrorReportMode::SEND_ERROR_REPORT, message, console); + }; + return walk_leg_with(leg, calls, max_interruptions); +} + + +// After a leg: waits `watch` for an interrupt the last step set off, which +// shows after the hold has ended, and clears it. Throws +// OperationFailedException after `max_interruptions` in a row. +template +void clear_late_interrupts( + Console& console, Context& context, + const LegInterrupts& interrupts, + Milliseconds watch, + size_t max_interruptions = 5 +){ + for (size_t count = 0; ; count++){ + std::vector> watchers; + std::vector callbacks; + for (const auto& interrupt : interrupts){ + watchers.emplace_back(interrupt->make_watcher()); + callbacks.emplace_back(*watchers.back()); + } + context.wait_for_all_requests(); + const int ret = wait_until(console, context, watch, callbacks); + if (ret < 0){ + return; + } + if (count >= max_interruptions){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "Interrupted " + std::to_string(count) + " times in a row after a leg.", + console + ); + } + console.log("Leg: " + interrupts[ret]->name() + " at the end of the leg.", COLOR_ORANGE); + interrupts[ret]->clear(console, context); + } +} + + + + +} +} +#endif diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp new file mode 100644 index 0000000000..c0ef205267 --- /dev/null +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp @@ -0,0 +1,176 @@ +/* Leg Walker Tests + * + * From: https://github.com/PokemonAutomation/ + * + * The leg walker's bookkeeping, with holds that report scripted + * interruptions instead of a console. + * + */ + +#include +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "Pokemon_LegWalker.h" +#include "Pokemon_Navigation_Tests.h" + +namespace PokemonAutomation{ +namespace Pokemon{ + + +namespace{ + +using namespace std::chrono_literals; + +constexpr Milliseconds TILE = 267ms; +constexpr Milliseconds DELAY = 300ms; + +// Holds that report the scripted outcomes in order, then run out. +struct ScriptedLeg{ + std::deque> outcomes; + std::vector holds; + std::vector cleared; + + LegCalls calls(){ + LegCalls calls; + calls.hold_watching = [this](Milliseconds hold) -> std::optional{ + holds.push_back(hold); + if (outcomes.empty()){ + return std::nullopt; + } + std::optional outcome = outcomes.front(); + outcomes.pop_front(); + return outcome; + }; + calls.clear = [this](size_t index){ cleared.push_back(index); }; + calls.detection_delay = [](size_t){ return DELAY; }; + calls.name = [](size_t){ return std::string("a battle"); }; + calls.log = [](const std::string&){}; + calls.fail = [](const std::string& message){ throw std::runtime_error(message); }; + return calls; + } +}; + +// An interrupt set off by step `step`, seen DELAY after it ends. +HoldOutcome after_step(int step){ + return HoldOutcome{0, TILE * step + DELAY}; +} + + +UnitTestResult test_hold_times(){ + const LegSpec open{Direction::EAST, 4, false, TILE}; + const LegSpec wall{Direction::EAST, 4, true, TILE}; + if (leg_hold(open, 4) != TILE * 3 + TILE / 2){ + return "Open leg: " + std::to_string(leg_hold(open, 4).count()) + "ms"; + } + if (leg_hold(wall, 4) != TILE * 6){ + return "Wall leg: " + std::to_string(leg_hold(wall, 4).count()) + "ms"; + } + return true; +} + +UnitTestResult test_tiles_before_interrupt(){ + const LegSpec leg{Direction::EAST, 8, false, TILE}; + for (int step = 0; step <= 8; step++){ + for (Milliseconds jitter : {-100ms, 0ms, 100ms}){ + const int walked = tiles_before_interrupt(leg, TILE * step + DELAY + jitter, DELAY, 8); + if (walked != step){ + return "Step " + std::to_string(step) + " seen " + std::to_string(jitter.count()) + "ms off read as " + std::to_string(walked); + } + } + } + if (tiles_before_interrupt(leg, 50ms, DELAY, 8) != 0){ + return "An interrupt from before the leg should count no tiles."; + } + if (tiles_before_interrupt(leg, TILE * 20, DELAY, 3) != 3){ + return "Should not count more tiles than remain."; + } + return true; +} + +UnitTestResult test_wall_leg_walked_again(){ + ScriptedLeg script; + script.outcomes = {after_step(2)}; + const LegSpec leg{Direction::NORTH, 6, true, TILE}; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 2 || script.holds[1] != leg_hold(leg, 6)){ + return "Expected the whole leg held again."; + } + if (!result.exact || result.interruptions != 1 || script.cleared.size() != 1){ + return "Expected an exact result after one interruption."; + } + return true; +} + +UnitTestResult test_open_leg_rest_walked(){ + ScriptedLeg script; + script.outcomes = {after_step(2)}; + const LegSpec leg{Direction::NORTH, 6, false, TILE}; + const LegResult result = walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 2 || script.holds[1] != leg_hold(leg, 4)){ + return "Expected the remaining 4 tiles held."; + } + if (result.exact){ + return "An interrupted open leg is an estimate."; + } + return true; +} + +UnitTestResult test_open_leg_interrupted_at_end(){ + ScriptedLeg script; + script.outcomes = {after_step(6)}; + const LegSpec leg{Direction::NORTH, 6, false, TILE}; + walk_leg_with(leg, script.calls(), 5); + if (script.holds.size() != 1){ + return "Nothing was left to walk after the last step."; + } + return true; +} + +UnitTestResult test_too_many_interruptions(){ + ScriptedLeg script; + for (int c = 0; c < 4; c++){ + script.outcomes.push_back(after_step(0)); + } + const LegSpec leg{Direction::NORTH, 6, true, TILE}; + try{ + walk_leg_with(leg, script.calls(), 3); + }catch (std::runtime_error&){ + return true; + } + return "Expected to give up after 3 interruptions."; +} + + +class Test_LegWalker : public UnitTest{ +public: + Test_LegWalker(std::string name, std::function body) + : UnitTest("Pokemon::LegWalker - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + return m_body(); + } + +private: + std::function m_body; +}; + +} + + +void add_tests_LegWalker(UnitTestDatabase& database){ + database.add("hold times", test_hold_times); + database.add("tiles before an interrupt", test_tiles_before_interrupt); + database.add("a wall leg is walked again", test_wall_leg_walked_again); + database.add("an open leg walks the rest", test_open_leg_rest_walked); + database.add("an open leg interrupted at its end", test_open_leg_interrupted_at_end); + database.add("gives up after too many interruptions", test_too_many_interruptions); +} + + + +} +} diff --git a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h index 5919dd56b2..fa7fd8e612 100644 --- a/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h +++ b/SerialPrograms/Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h @@ -14,6 +14,7 @@ namespace Pokemon{ void add_tests_LegPlanner(UnitTestDatabase& database); +void add_tests_LegWalker(UnitTestDatabase& database); diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_BagDetector.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_BagDetector.cpp index efd44bf464..46f2e5facc 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_BagDetector.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_BagDetector.cpp @@ -23,7 +23,9 @@ namespace PokemonFRLG{ BagDetector::BagDetector(Color color) : m_bag_shadow_box(0.131250, 0.608173, 0.068269, 0.001442) , m_pocket_underline_box(0.043269, 0.137259, 0.279808, 0.005769) - , m_menu_top_box(0.029808, 0.029086, 0.947115, 0.003606) + // Stops short of the red arrow drawn over the frame once the list is + // scrolled down. + , m_menu_top_box(0.029808, 0.029086, 0.550000, 0.003606) , m_menu_right_box(0.977404, 0.034134, 0.005288, 0.597837) {} void BagDetector::make_overlays(VideoOverlaySet& items) const{ diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.cpp new file mode 100644 index 0000000000..f7174de9b8 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.cpp @@ -0,0 +1,76 @@ +/* Battle Flash Watcher + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTools/ImageStats.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "CommonFramework/VideoPipeline/VideoOverlayScopes.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" +#include "PokemonFRLG_BattleFlashWatcher.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace std::chrono_literals; + + +namespace{ + +// On the console a flash took the screen from 145 to 98 (two thirds) and +// back within 80ms, twice in about 300ms. +constexpr std::chrono::milliseconds FLASH_WINDOW = 400ms; +constexpr double FLASH_DARKER = 0.8; // the dip is below this much of the brightness before it +constexpr double FLASH_FLOOR = 0.4; // and above this much: lower is a fade to black +constexpr double FLASH_RECOVERY = 1.1; // then the screen comes back to this much of the dip + +} + + +BattleFlashWatcher::BattleFlashWatcher(Color color) + : VisualInferenceCallback("BattleFlashWatcher") + , m_color(color) +{} + +void BattleFlashWatcher::make_overlays(VideoOverlaySet& items) const{ + items.add(m_color, GameSettings::instance().GAME_BOX); +} + +bool BattleFlashWatcher::process_frame(const ImageViewRGB32& frame, WallClock timestamp){ + const ImageViewRGB32 game_screen = extract_box_reference(frame, GameSettings::instance().GAME_BOX); + const double brightness = image_stats(game_screen).average.sum(); + while (!m_history.empty() && timestamp - m_history.front().first > FLASH_WINDOW){ + m_history.pop_front(); + } + + // The brightest frame before the screen first dips, and the darkest + // frame since. + double before = 0; + double dip = -1; + for (const auto& item : m_history){ + if (dip < 0){ + if (item.second < before * FLASH_DARKER){ + dip = item.second; + }else{ + before = std::max(before, item.second); + } + }else{ + dip = std::min(dip, item.second); + } + } + m_history.emplace_back(timestamp, brightness); + + return dip >= 0 + && dip > before * FLASH_FLOOR + && brightness > dip * FLASH_RECOVERY; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h new file mode 100644 index 0000000000..a705d3473c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h @@ -0,0 +1,46 @@ +/* Battle Flash Watcher + * + * From: https://github.com/PokemonAutomation/ + * + * Every wild battle starts with the overworld flashing dark twice, within + * half a second of the step that set it off, before the battle's own wipe. + * The wipe and the battle screen differ by map and come seconds later, so + * the flash is the earliest and the same sign of a battle everywhere. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_BattleFlashWatcher_H +#define PokemonAutomation_PokemonFRLG_BattleFlashWatcher_H + +#include +#include +#include "Common/Cpp/Color.h" +#include "CommonTools/InferenceCallbacks/VisualInferenceCallback.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Fires when the game screen darkens by a fifth or more and starts to come +// back within a moment. A warp's fade, or the Switch dimming its screen, +// only darkens and so doesn't count. +class BattleFlashWatcher : public VisualInferenceCallback{ +public: + BattleFlashWatcher(Color color); + + virtual void make_overlays(VideoOverlaySet& items) const override; + virtual bool process_frame(const ImageViewRGB32& frame, WallClock timestamp) override; + +private: + Color m_color; + // Recent brightness of the game screen. + std::deque> m_history; +}; + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp new file mode 100644 index 0000000000..8ad357066c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp @@ -0,0 +1,220 @@ +/* Overworld Map + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "Common/Cpp/Exceptions.h" +#include "PokemonFRLG_OverworldMap.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +std::optional tile_from_char(char c){ + switch (c){ + case '#': return Tile::WALL; + case 'J': return Tile::LEDGE; + case '.': return Tile::FLOOR; + case '"': return Tile::TALL_GRASS; + case '~': return Tile::WATER; + case 'D': return Tile::DOOR; + case 'C': return Tile::CAVE_ENTRANCE; + case 'L': return Tile::LADDER; + case '^': return Tile::MAT_NORTH; + case 'v': return Tile::MAT_SOUTH; + case '<': return Tile::MAT_WEST; + case '>': return Tile::MAT_EAST; + case 'n': return Tile::CLOSED_NORTH; + case 's': return Tile::CLOSED_SOUTH; + case 'e': return Tile::CLOSED_EAST; + case 'w': return Tile::CLOSED_WEST; + default: return std::nullopt; + } +} + + +bool in_bounds(const MapData& map, const TilePos& point){ + return map.tiles.contains(point); +} +Tile tile_at(const MapData& map, const TilePos& point){ + return in_bounds(map, point) ? map.tiles.at(point) : Tile::WALL; +} +uint8_t elevation_at(const MapData& map, const TilePos& point){ + return map.elevation.contains(point) ? map.elevation.at(point) : ELEVATION_IGNORED; +} + +bool is_wall(const MapData& map, const TilePos& point){ + const Tile tile = tile_at(map, point); + return tile == Tile::WALL || tile == Tile::DOOR || tile == Tile::LEDGE; +} +bool is_ledge(const MapData& map, const TilePos& point){ + return tile_at(map, point) == Tile::LEDGE; +} +bool is_water(const MapData& map, const TilePos& point){ + return tile_at(map, point) == Tile::WATER; +} +bool is_tall_grass(const MapData& map, const TilePos& point){ + return tile_at(map, point) == Tile::TALL_GRASS; +} + +namespace{ + +// The tile closed on its `side` edge. +Tile closed_side(Direction side){ + switch (side){ + case Direction::NORTH: + return Tile::CLOSED_NORTH; + case Direction::SOUTH: + return Tile::CLOSED_SOUTH; + case Direction::EAST: + return Tile::CLOSED_EAST; + case Direction::WEST: + return Tile::CLOSED_WEST; + } + return Tile::WALL; +} +Direction opposite(Direction direction){ + switch (direction){ + case Direction::NORTH: + return Direction::SOUTH; + case Direction::SOUTH: + return Direction::NORTH; + case Direction::EAST: + return Direction::WEST; + case Direction::WEST: + return Direction::EAST; + } + return direction; +} + +} + +bool blocks_entering(const MapData& map, const TilePos& point, Direction direction){ + // Moving south enters by the north edge. + return tile_at(map, point) == closed_side(opposite(direction)); +} +bool blocks_leaving(const MapData& map, const TilePos& point, Direction direction){ + return tile_at(map, point) == closed_side(direction); +} + +bool warps_on_step(const MapData& map, const TilePos& point){ + const Tile tile = tile_at(map, point); + return tile == Tile::CAVE_ENTRANCE || tile == Tile::LADDER; +} +bool is_door(const MapData& map, const TilePos& point){ + return tile_at(map, point) == Tile::DOOR; +} +std::optional exit_mat(const MapData& map, const TilePos& point){ + switch (tile_at(map, point)){ + case Tile::MAT_NORTH: + return Direction::NORTH; + case Tile::MAT_SOUTH: + return Direction::SOUTH; + case Tile::MAT_WEST: + return Direction::WEST; + case Tile::MAT_EAST: + return Direction::EAST; + default: + return std::nullopt; + } +} +bool walks_out_on_arrival(const MapData& map, const TilePos& point){ + const Tile tile = tile_at(map, point); + return tile == Tile::DOOR || tile == Tile::CAVE_ENTRANCE; +} + +void hide_objects(MapData& map, const std::set& flags){ + std::vector shown; + for (const MapObject& object : map.objects){ + if (object.flag.empty() || flags.count(object.flag) == 0){ + shown.push_back(object); + } + } + map.objects = std::move(shown); +} + +const MapObject* object_at(const MapData& map, const TilePos& point){ + for (const MapObject& object : map.objects){ + if (std::abs(object.x - point.x) <= object.wander_x && std::abs(object.y - point.y) <= object.wander_y){ + return &object; + } + } + return nullptr; +} +const MapWarp* warp_at(const MapData& map, const TilePos& point){ + for (const MapWarp& warp : map.warps){ + if (warp.x == point.x && warp.y == point.y){ + return &warp; + } + } + return nullptr; +} + + + +StepKind classify_step(const MapData& map, const PlayerState& state, Direction direction){ + return classify_step_onto(map, state, direction, map, state.position.next(direction)); +} + +StepKind classify_step_onto( + const MapData& from, const PlayerState& state, Direction direction, + const MapData& to, const TilePos& destination +){ + if (is_wall(to, destination) + || blocks_leaving(from, state.position, direction) + || blocks_entering(to, destination, direction) + ){ + return StepKind::BLOCKED; + } + + const uint8_t level = elevation_at(to, destination); + const bool mismatch = state.elevation != ELEVATION_ANY + && level != ELEVATION_ANY && level != ELEVATION_IGNORED + && level != state.elevation; + if (mismatch){ + // CanStopSurfing() and IsPlayerFacingSurfableFishableWater(). + if (state.surfing && level == ELEVATION_GROUND && object_at(to, destination) == nullptr){ + return StepKind::STOP_SURFING; + } + if (!state.surfing && state.elevation == ELEVATION_GROUND && is_water(to, destination)){ + return StepKind::START_SURF; + } + return StepKind::BLOCKED; + } + + if (object_at(to, destination) != nullptr){ + return StepKind::BLOCKED; + } + return StepKind::WALK; +} + +PlayerState after_step(const MapData& map, const PlayerState& state, Direction direction, StepKind kind){ + return after_step_onto(map, state, map, state.position.next(direction), kind); +} + +PlayerState after_step_onto( + const MapData& from, const PlayerState& state, + const MapData& to, const TilePos& destination, StepKind kind +){ + PlayerState next = state; + next.position = destination; + const uint8_t level = elevation_at(to, destination); + if (level != ELEVATION_IGNORED && elevation_at(from, state.position) != ELEVATION_IGNORED){ + next.elevation = level; + } + if (kind == StepKind::START_SURF){ + next.surfing = true; + }else if (kind == StepKind::STOP_SURFING){ + next.surfing = false; + } + return next; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h new file mode 100644 index 0000000000..83ea60b342 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h @@ -0,0 +1,213 @@ +/* Overworld Map + * + * From: https://github.com/PokemonAutomation/ + * + * An FRLG map as the game sees it: per tile collision, elevation and the + * behaviors that matter for walking (water, one-way edges), plus its warps + * and the objects standing on it. The map data is generated from the + * pret/pokefirered decompilation, and classify_step() follows the game's own + * collision checks (GetCollisionAtCoords() in src/event_object_movement.c), + * so a route planned on these maps is one the game allows. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldMap_H +#define PokemonAutomation_PokemonFRLG_OverworldMap_H + +#include +#include +#include +#include +#include +#include +#include "Pokemon/Navigation/Pokemon_Grid.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +using Pokemon::Direction; +using Pokemon::TilePos; + + +// A warp from (x, y) to warp `dest_warp` of `dest_map`. Whether and how the +// player takes it depends on the tile there (see Tile). An empty +// `dest_map` leads to a map that isn't loaded. +struct MapWarp{ + int x = 0; + int y = 0; + std::string dest_map; + int dest_warp = 0; +}; + +// An object standing on a tile (person, item ball, rock, Pokemon), which +// blocks it. `kind` is its graphics, e.g. "nurse" or "mewtwo". The game +// hides it once its event flag is set, e.g. a guard who leaves after the +// Hall of Fame; an empty flag means it is always there. A person who wanders +// may stand anywhere up to `wander_x` and `wander_y` tiles from (x, y), so +// all of those tiles count as blocked. +struct MapObject{ + int x = 0; + int y = 0; + std::string kind; + std::string flag; + int wander_x = 0; + int wander_y = 0; +}; + +// Walking off the map's `direction` edge continues on `map`, whose tiles are +// shifted by `offset` along that edge (the decompilation's map connections). +struct MapConnection{ + Direction direction = Direction::NORTH; + int offset = 0; + std::string map; +}; + +// Elevations with a meaning of their own in the game's rules +// (IsElevationMismatchAt() and ObjectEventUpdateElevation() in +// pokefirered). +// Stairs and ladders: they join every elevation. +constexpr uint8_t ELEVATION_ANY = 0; +// Bridges and the like: they don't change the player's elevation. +constexpr uint8_t ELEVATION_IGNORED = 15; +// Ordinary ground: most walkable tiles in the decompilation's map layouts. +// Surf starts and ends here (CanStopSurfing(), and the Surf field move +// checks the player's elevation is 3). +constexpr uint8_t ELEVATION_GROUND = 3; + + +// What a tile is, for walking. The map files write each as one character, +// shown here. +enum class Tile : uint8_t{ + WALL, // '#' collision (wall, cliff) + LEDGE, // 'J' a wall, except that walking into it the way it + // faces hops the player down (not modelled, so the + // planner avoids it) + FLOOR, // '.' + TALL_GRASS, // '"' floor with wild encounters + WATER, // '~' entered only by starting to Surf + DOOR, // 'D' a wall that warps the player who walks north into it + CAVE_ENTRANCE, // 'C' floor that warps the player who steps onto it + LADDER, // 'L' ladder or escalator: floor that warps the player + // who steps onto it + MAT_NORTH, // '^' exit mat, or stairs at the side of a room: floor + MAT_SOUTH, // 'v' that warps the player standing on it who walks + // in the arrow's direction + MAT_WEST, // '<' + MAT_EAST, // '>' + CLOSED_NORTH, // 'n' can't be entered moving south or left moving north + CLOSED_SOUTH, // 's' can't be entered moving north or left moving south + CLOSED_EAST, // 'e' can't be entered moving west or left moving east + CLOSED_WEST, // 'w' can't be entered moving east or left moving west +}; + +// The tile a map file's character stands for, or nothing if it isn't one. +std::optional tile_from_char(char c); + + +// One value per tile of a map, row by row from the top. +template +struct Grid{ + int width = 0; + int height = 0; + std::vector cells; + + bool contains(const TilePos& point) const{ + return 0 <= point.x && point.x < width && 0 <= point.y && point.y < height; + } + // `point` must be inside the grid. + const Cell& at(const TilePos& point) const{ + return cells[(size_t)point.y * width + point.x]; + } +}; + + +// A map as generated from the decompilation (see PokemonFRLG_OverworldMaps.h +// for its JSON form). +struct MapData{ + std::string name; + Grid tiles; + // Walking keeps to one elevation; tiles of elevation 0 (stairs, + // ladders) join elevations, and 15 ignores them. The same size as + // `tiles`. + Grid elevation; + std::vector warps; + std::vector objects; + std::vector connections; + // Places on the map that routes start or end at, by name, e.g. "pc". + std::map points; + // Event flags that are set together, by name, e.g. + // "after_hall_of_fame". + std::map> flag_sets; +}; + +bool in_bounds(const MapData& map, const TilePos& point); +// Out-of-bounds tiles are walls. +Tile tile_at(const MapData& map, const TilePos& point); +uint8_t elevation_at(const MapData& map, const TilePos& point); + +bool is_wall(const MapData& map, const TilePos& point); +bool is_water(const MapData& map, const TilePos& point); +bool is_tall_grass(const MapData& map, const TilePos& point); +bool is_ledge(const MapData& map, const TilePos& point); +// Whether the tile itself refuses a step in `direction`: entering it, or +// leaving it. +bool blocks_entering(const MapData& map, const TilePos& point, Direction direction); +bool blocks_leaving(const MapData& map, const TilePos& point, Direction direction); + +// The warp the game takes on stepping onto this tile (CAVE_ENTRANCE, LADDER). +bool warps_on_step(const MapData& map, const TilePos& point); +// A door, entered by walking north into it. +bool is_door(const MapData& map, const TilePos& point); +// The direction that takes the exit mat on this tile, if it is one. +std::optional exit_mat(const MapData& map, const TilePos& point); +// Arriving on a door or cave entrance, the game walks the player one +// tile south, out of it. +bool walks_out_on_arrival(const MapData& map, const TilePos& point); + +const MapObject* object_at(const MapData& map, const TilePos& point); +const MapWarp* warp_at(const MapData& map, const TilePos& point); + +// Removes the objects the game hides once one of `flags` is set. +void hide_objects(MapData& map, const std::set& flags); + + +// What decides where the player's next step can go. +struct PlayerState{ + TilePos position; + uint8_t elevation = ELEVATION_GROUND; + bool surfing = false; +}; + +enum class StepKind{ + BLOCKED, + WALK, // moves one tile (on land, or on water while surfing) + STOP_SURFING, // moves from water onto land and stops surfing + START_SURF, // blocked, but facing water the player can Surf onto +}; + +// What pressing `direction` once does to the player in `state`. +StepKind classify_step(const MapData& map, const PlayerState& state, Direction direction); + +// The same for a step that crosses into another map: from `state` on `from` +// onto `destination` on `to`. +StepKind classify_step_onto( + const MapData& from, const PlayerState& state, Direction direction, + const MapData& to, const TilePos& destination +); + +// The player after a step of kind WALK, STOP_SURFING or START_SURF (the +// Surf action moves the player onto the water). +PlayerState after_step(const MapData& map, const PlayerState& state, Direction direction, StepKind kind); +PlayerState after_step_onto( + const MapData& from, const PlayerState& state, + const MapData& to, const TilePos& destination, StepKind kind +); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp new file mode 100644 index 0000000000..e64a9e6a29 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp @@ -0,0 +1,300 @@ +/* Overworld Maps + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/Json/JsonArray.h" +#include "Common/Cpp/Json/JsonObject.h" +#include "Common/Cpp/Json/JsonValue.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "PokemonFRLG_OverworldMaps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +const char MAP_FOLDER[] = "PokemonFRLG/Maps/"; + +const char* const MAP_NAMES[] = { + // From a new game to Brock. + "PalletTown", + "PalletTown_PlayersHouse_1F", + "PalletTown_PlayersHouse_2F", + "PalletTown_RivalsHouse", + "PalletTown_ProfessorOaksLab", + "Route1", + "ViridianCity", + "ViridianCity_Mart", + "ViridianCity_PokemonCenter_1F", + "Route22", + "Route2", + "Route2_ViridianForest_SouthEntrance", + "ViridianForest", + "Route2_ViridianForest_NorthEntrance", + "PewterCity", + "PewterCity_PokemonCenter_1F", + "PewterCity_Mart", + "PewterCity_Museum_1F", + "PewterCity_Gym", + // Cerulean Cave, for Mewtwo. + "CeruleanCity_PokemonCenter_1F", + "CeruleanCity", + "Route24", + "CeruleanCave_1F", + "CeruleanCave_2F", + "CeruleanCave_B1F", +}; + + +// The readers below take `where`, the source and the path to the object +// being read (e.g. "PokemonFRLG/Maps/CeruleanCity.json: warps[2]."), and +// name the field in what they throw. + +const JsonObject& as_object(const JsonValue& value, const std::string& where){ + const JsonObject* object = value.to_object(); + if (object == nullptr){ + throw ParseException(where + " is not a JSON object."); + } + return *object; +} + +const JsonValue& read_field(const JsonObject& object, const std::string& key, const std::string& where){ + const JsonValue* value = object.get_value(key); + if (value == nullptr){ + throw ParseException(where + key + " is missing."); + } + return *value; +} + +int read_int(const JsonObject& object, const std::string& key, const std::string& where){ + const JsonValue& value = read_field(object, key, where); + if (!value.is_integer()){ + throw ParseException(where + key + " is not an integer."); + } + return (int)value.to_integer_default(); +} + +std::string read_string(const JsonObject& object, const std::string& key, const std::string& where){ + const std::string* text = read_field(object, key, where).to_string(); + if (text == nullptr){ + throw ParseException(where + key + " is not a string."); + } + return *text; +} + +const JsonArray& read_array(const JsonObject& object, const std::string& key, const std::string& where){ + const JsonArray* array = read_field(object, key, where).to_array(); + if (array == nullptr){ + throw ParseException(where + key + " is not an array."); + } + return *array; +} + +// The grid `key`: `height` strings of `width` characters, each read by +// `read_cell`, which gives nothing for a character it doesn't know. +template +Grid read_grid( + const JsonObject& object, const std::string& key, const std::string& where, + int width, int height, std::optional (*read_cell)(char c) +){ + const JsonArray& rows = read_array(object, key, where); + if ((int)rows.size() != height){ + throw ParseException(where + key + " has " + std::to_string(rows.size()) + " rows, not " + std::to_string(height) + "."); + } + Grid grid{width, height, {}}; + for (int y = 0; y < height; y++){ + const std::string row_where = where + key + "[" + std::to_string(y) + "]"; + const std::string* row = rows[y].to_string(); + if (row == nullptr || (int)row->size() != width){ + throw ParseException(row_where + " is not a string of " + std::to_string(width) + " tiles."); + } + for (int x = 0; x < width; x++){ + const std::optional cell = read_cell((*row)[x]); + if (!cell){ + throw ParseException( + row_where + " has unknown tile '" + std::string(1, (*row)[x]) + "' at " + to_string(TilePos{x, y}) + "." + ); + } + grid.cells.push_back(*cell); + } + } + return grid; +} + +// An elevation digit's level. +std::optional elevation_from_char(char digit){ + if ('0' <= digit && digit <= '9'){ + return (uint8_t)(digit - '0'); + } + if ('a' <= digit && digit <= 'f'){ + return (uint8_t)(digit - 'a' + 10); + } + return std::nullopt; +} + +MapWarp read_warp(const JsonObject& object, const std::string& where){ + MapWarp warp; + warp.x = read_int(object, "x", where); + warp.y = read_int(object, "y", where); + warp.dest_map = read_string(object, "dest_map", where); + warp.dest_warp = read_int(object, "dest_warp", where); + return warp; +} + +MapObject read_map_object(const JsonObject& object, const std::string& where){ + MapObject map_object; + map_object.x = read_int(object, "x", where); + map_object.y = read_int(object, "y", where); + map_object.kind = read_string(object, "kind", where); + map_object.flag = read_string(object, "flag", where); + map_object.wander_x = read_int(object, "wander_x", where); + map_object.wander_y = read_int(object, "wander_y", where); + return map_object; +} + +MapConnection read_connection(const JsonObject& object, const std::string& where){ + MapConnection connection; + const std::string direction = read_string(object, "direction", where); + bool known = false; + for (Direction candidate : Pokemon::ALL_DIRECTIONS){ + if (direction == Pokemon::direction_name(candidate)){ + connection.direction = candidate; + known = true; + } + } + if (!known){ + throw ParseException(where + "direction \"" + direction + "\" is not north, east, south or west."); + } + connection.offset = read_int(object, "offset", where); + connection.map = read_string(object, "map", where); + return connection; +} + +std::pair read_point(const JsonObject& object, const std::string& where){ + const std::string name = read_string(object, "name", where); + return {name, TilePos{read_int(object, "x", where), read_int(object, "y", where)}}; +} + +std::pair> read_flag_set(const JsonObject& object, const std::string& where){ + const std::string name = read_string(object, "name", where); + std::set flags; + // Named, since GCC warns of a reference returned for a temporary key. + const std::string key = "flags"; + const JsonArray& array = read_array(object, key, where); + for (size_t c = 0; c < array.size(); c++){ + const std::string* flag = array[c].to_string(); + if (flag == nullptr){ + throw ParseException(where + key + "[" + std::to_string(c) + "] is not a string."); + } + flags.insert(*flag); + } + return {name, std::move(flags)}; +} + +template +std::vector read_list( + const JsonObject& object, const std::string& key, const std::string& where, + Item (*read_item)(const JsonObject& object, const std::string& where) +){ + std::vector items; + const JsonArray& array = read_array(object, key, where); + for (size_t c = 0; c < array.size(); c++){ + const std::string item_where = where + key + "[" + std::to_string(c) + "]"; + items.push_back(read_item(as_object(array[c], item_where), item_where + ".")); + } + return items; +} + +MapData read_map(const JsonValue& json, const std::string& source){ + const JsonObject& root = as_object(json, source); + const std::string where = source + ": "; + MapData map; + map.name = read_string(root, "name", where); + const int width = read_int(root, "width", where); + const int height = read_int(root, "height", where); + if (width <= 0 || height <= 0){ + throw ParseException(where + "width and height must be positive."); + } + map.tiles = read_grid(root, "tiles", where, width, height, tile_from_char); + map.elevation = read_grid(root, "elevation", where, width, height, elevation_from_char); + map.warps = read_list(root, "warps", where, read_warp); + map.objects = read_list(root, "objects", where, read_map_object); + map.connections = read_list(root, "connections", where, read_connection); + for (auto& point : read_list>(root, "points", where, read_point)){ + map.points.insert(std::move(point)); + } + for (auto& flag_set : read_list>>(root, "flag_sets", where, read_flag_set)){ + map.flag_sets.insert(std::move(flag_set)); + } + return map; +} + +const std::vector& all_maps(){ + static const std::vector MAPS = []{ + std::vector maps; + for (const char* name : MAP_NAMES){ + maps.push_back(load_map(name)); + } + return maps; + }(); + return MAPS; +} + +} + + +MapData parse_map(const std::string& json, const std::string& source){ + return read_map(parse_json(json), source); +} + +MapData load_map(const std::string& name){ + const std::string source = std::string(MAP_FOLDER) + name + ".json"; + MapData map = read_map(load_json_file(RESOURCE_PATH() + source), source); + if (map.name != name){ + throw ParseException(source + ": name is \"" + map.name + "\", not \"" + name + "\"."); + } + return map; +} + +std::vector overworld_maps(const std::set& set_flags){ + std::vector maps = all_maps(); + for (MapData& map : maps){ + hide_objects(map, set_flags); + } + return maps; +} + +TilePos map_point(const std::vector& maps, const std::string& map, const std::string& point){ + for (const MapData& data : maps){ + if (data.name != map){ + continue; + } + const auto iter = data.points.find(point); + if (iter != data.points.end()){ + return iter->second; + } + } + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "No point \"" + point + "\" on map " + map + "."); +} + +std::set map_flag_set(const std::vector& maps, const std::string& name){ + std::set flags; + for (const MapData& map : maps){ + const auto iter = map.flag_sets.find(name); + if (iter != map.flag_sets.end()){ + flags.insert(iter->second.begin(), iter->second.end()); + } + } + return flags; +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h new file mode 100644 index 0000000000..77d30dae4c --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h @@ -0,0 +1,74 @@ +/* Overworld Maps + * + * From: https://github.com/PokemonAutomation/ + * + * The FRLG maps the overworld routes know about so far: every map from a + * new game in Pallet Town through Viridian City, Route 2 and Viridian + * Forest to Brock's gym in Pewter City; and Cerulean City, its Pokemon + * Center, Route 24 to its north, and the three floors of Cerulean Cave, + * where Mewtwo waits on B1F. + * + * Each map is a JSON file, Resources/PokemonFRLG/Maps/.json in + * Packages, generated by devtools/maps/frlg_maps.py. The schema (see the + * README.md next to the files) is MapData's: + * + * { + * "name": "CeruleanCity", "width": 48, "height": 40, + * "tiles": ["####~~~...", ...], // `height` rows of `width` tiles + * "elevation": ["0033333...", ...], // the same, one hex digit per tile + * "warps": [{"x": 22, "y": 19, "dest_map": "...", "dest_warp": 1}, ...], + * "objects": [{"x": 1, "y": 13, "kind": "cooltrainer_m", + * "flag": "FLAG_...", "wander_x": 0, "wander_y": 0}, ...], + * "connections": [{"direction": "north", "offset": 12, "map": "Route24"}, ...], + * "points": [{"name": "pc", "x": 11, "y": 2}, ...], + * "flag_sets": [{"name": "after_hall_of_fame", "flags": ["FLAG_...", ...]}, ...] + * } + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldMaps_H +#define PokemonAutomation_PokemonFRLG_OverworldMaps_H + +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldMap.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Reads a map from its JSON text. `source` names it in errors. Throws +// ParseException, naming the map and the field, if a field is missing or +// doesn't fit the schema. +MapData parse_map(const std::string& json, const std::string& source); + +// Loads Resources/PokemonFRLG/Maps/.json. Throws FileException if +// the file is missing, and ParseException as parse_map(). +MapData load_map(const std::string& name); + +// Every map, by its decompilation name ("CeruleanCity", "CeruleanCave_B1F", ...), +// with the objects the given event flags hide removed. The files are read +// once, on the first call. +std::vector overworld_maps(const std::set& set_flags = {}); + +// Point `point` of map `map` among `maps`, e.g. ("CeruleanCave_B1F", +// "mewtwo"). Throws InternalProgramError if there is none. +TilePos map_point(const std::vector& maps, const std::string& map, const std::string& point); + +// The flags in flag set `name` of every map among `maps`, e.g. +// "after_hall_of_fame": the flags the Hall of Fame sets, which clear the way +// to Cerulean Cave. +std::set map_flag_set(const std::vector& maps, const std::string& name); + + +void add_tests_OverworldMaps(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp new file mode 100644 index 0000000000..dc369de97b --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp @@ -0,0 +1,163 @@ +/* Overworld Maps Tests + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "PokemonFRLG_OverworldMaps.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +class Test_OverworldMaps : public UnitTest{ +public: + Test_OverworldMaps(std::string name, std::function body) + : UnitTest("PokemonFRLG::OverworldMaps - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + + +const char* const VALID_MAP = R"({ + "name": "Test", "width": 3, "height": 2, + "tiles": ["#.~", "\"Dv"], + "elevation": ["031", "3f3"], + "warps": [{"x": 1, "y": 1, "dest_map": "Other", "dest_warp": 2}], + "objects": [{"x": 0, "y": 1, "kind": "nurse", "flag": "FLAG_X", "wander_x": 1, "wander_y": 0}], + "connections": [{"direction": "south", "offset": -4, "map": "Other"}], + "points": [{"name": "door", "x": 1, "y": 1}], + "flag_sets": [{"name": "later", "flags": ["FLAG_X", "FLAG_Y"]}] +})"; + +// The ParseException that parsing `json` throws, or empty if it doesn't. +std::string parse_error(const std::string& json){ + try{ + parse_map(json, "Test.json"); + }catch (ParseException& e){ + return e.message(); + } + return ""; +} + +// Whether `text` contains every one of `parts`. +bool mentions(const std::string& text, std::initializer_list parts){ + for (const char* part : parts){ + if (text.find(part) == std::string::npos){ + return false; + } + } + return true; +} + +UnitTestResult test_valid_map(){ + const MapData map = parse_map(VALID_MAP, "Test.json"); + if (map.name != "Test" || map.tiles.width != 3 || map.tiles.height != 2 + || map.tiles.at({0, 1}) != Tile::TALL_GRASS || map.tiles.at({2, 1}) != Tile::MAT_SOUTH + ){ + return "Wrong name, size or tiles."; + } + if (elevation_at(map, {2, 0}) != 1 || elevation_at(map, {1, 1}) != 15){ + return "Wrong elevation."; + } + if (map.warps.size() != 1 || map.warps[0].dest_map != "Other" || map.warps[0].dest_warp != 2){ + return "Wrong warps."; + } + const MapObject* nurse = object_at(map, {1, 1}); + if (map.objects.size() != 1 || nurse == nullptr || nurse->kind != "nurse" || nurse->flag != "FLAG_X"){ + return "Wrong objects."; + } + const MapConnection& connection = map.connections.at(0); + if (connection.direction != Direction::SOUTH || connection.offset != -4 || connection.map != "Other"){ + return "Wrong connections."; + } + if (map.points.at("door") != TilePos{1, 1} || map.flag_sets.at("later") != std::set{"FLAG_X", "FLAG_Y"}){ + return "Wrong points or flag sets."; + } + return true; +} + +UnitTestResult test_missing_field(){ + std::string json = VALID_MAP; + json.replace(json.find("\"elevation\""), 11, "\"heights\""); + const std::string error = parse_error(json); + if (!mentions(error, {"Test.json", "elevation", "missing"})){ + return "Expected an error naming the missing elevation, got: \"" + error + "\""; + } + return true; +} + +UnitTestResult test_bad_field_type(){ + std::string json = VALID_MAP; + json.replace(json.find("\"dest_warp\": 2"), 14, "\"dest_warp\": \"2\""); + const std::string error = parse_error(json); + if (!mentions(error, {"Test.json", "warps[0]", "dest_warp", "integer"})){ + return "Expected an error naming warps[0].dest_warp, got: \"" + error + "\""; + } + return true; +} + +UnitTestResult test_bad_row_length(){ + std::string json = VALID_MAP; + json.replace(json.find("\"#.~\""), 5, "\"#.\""); + const std::string error = parse_error(json); + if (!mentions(error, {"Test.json", "tiles[0]", "string of 3 tiles"})){ + return "Expected an error naming the short row, got: \"" + error + "\""; + } + return true; +} + +UnitTestResult test_unknown_tile(){ + std::string json = VALID_MAP; + json.replace(json.find("\"#.~\""), 5, "\"#.?\""); + const std::string error = parse_error(json); + if (!mentions(error, {"Test.json", "tiles[0]", "unknown tile '?'", "(2, 0)"})){ + return "Expected an error naming the unknown tile, got: \"" + error + "\""; + } + return true; +} + +UnitTestResult test_not_json(){ + const std::string error = parse_error("{\"name\": "); + if (!mentions(error, {"Test.json", "not a JSON object"})){ + return "Expected an error for broken JSON, got: \"" + error + "\""; + } + return true; +} + +} + + +void add_tests_OverworldMaps(UnitTestDatabase& database){ + database.add("valid map", test_valid_map); + database.add("missing field", test_missing_field); + database.add("field of the wrong type", test_bad_field_type); + database.add("row of the wrong length", test_bad_row_length); + database.add("unknown tile", test_unknown_tile); + database.add("not JSON", test_not_json); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp new file mode 100644 index 0000000000..6fdd13c6ce --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp @@ -0,0 +1,711 @@ +/* Overworld Route + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "Pokemon/Navigation/Pokemon_LegPlanner.h" +#include "PokemonFRLG_OverworldMaps.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +std::string to_string(const MapPosition& position){ + return position.map + " " + to_string(position.point); +} + +std::string to_string(const RouteStep& step){ + std::string text; + switch (step.action){ + case RouteAction::WALK: + text = "walk " + std::string(direction_name(step.direction)) + " " + std::to_string(step.tiles); + if (step.warps){ + text += " (warp)"; + } + if (step.to_wall){ + text += " (to wall)"; + } + break; + case RouteAction::SURF: + text = "surf " + std::string(direction_name(step.direction)); + break; + case RouteAction::FACE: + text = "face " + std::string(direction_name(step.direction)); + break; + } + return text + " -> " + to_string(step.end); +} + + + +const RouteCosts& default_route_costs(){ + static const RouteCosts COSTS{ + {RouteCost::TILE, 1}, + {RouteCost::TURN, 2}, + {RouteCost::WARP, 30}, + {RouteCost::SURF, 40}, + {RouteCost::GRASS_TILE, 20}, + {RouteCost::OPEN_STOP, 8}, + }; + return COSTS; +} + +int route_cost(const RouteCosts& costs, RouteCost kind){ + auto iter = costs.find(kind); + if (iter == costs.end()){ + iter = default_route_costs().find(kind); + } + return iter->second; +} + + + +namespace{ + + +int find_map(const std::vector& maps, const std::string& name){ + for (size_t c = 0; c < maps.size(); c++){ + if (maps[c].name == name){ + return (int)c; + } + } + return -1; +} + +// The player on one of the loaded maps. +struct Located{ + int map = 0; + PlayerState state; + + bool operator==(const Located& other) const{ + return map == other.map + && state.position == other.state.position + && state.elevation == other.state.elevation + && state.surfing == other.state.surfing; + } +}; +struct LocatedHash{ + size_t operator()(const Located& player) const{ + return (((std::hash()(player.state.position) * 31 + player.map) * 16 + + player.state.elevation) * 2) + (player.state.surfing ? 1 : 0); + } +}; + +// What pressing a direction once does to the player. +struct Transition{ + Located next; + RouteAction action = RouteAction::WALK; + bool warped = false; + bool walked_out = false; // the warp ended with the game walking the player out + bool dismounted = false; // stepped off the water onto land +}; + +// The player taking `warp`, or nothing if it leads off the loaded maps. +std::optional take_warp(const std::vector& maps, const MapWarp* warp){ + if (warp == nullptr){ + return std::nullopt; + } + const int destination = find_map(maps, warp->dest_map); + if (destination < 0 || warp->dest_warp < 0 || warp->dest_warp >= (int)maps[destination].warps.size()){ + return std::nullopt; + } + const MapData& map = maps[destination]; + const MapWarp& arrival = map.warps[warp->dest_warp]; + TilePos position{arrival.x, arrival.y}; + Transition transition; + if (walks_out_on_arrival(map, position)){ + position = position.next(Direction::SOUTH); + transition.walked_out = true; + } + transition.next.map = destination; + transition.next.state = PlayerState{position, elevation_at(map, position), false}; + transition.warped = true; + return transition; +} + +// Walking off the map's edge onto the map connected there, if it is loaded. +std::optional cross_edge(const std::vector& maps, const Located& player, Direction direction){ + const MapData& map = maps[player.map]; + for (const MapConnection& connection : map.connections){ + if (connection.direction != direction){ + continue; + } + const int destination = find_map(maps, connection.map); + if (destination < 0){ + return std::nullopt; + } + const MapData& to = maps[destination]; + const TilePos here = player.state.position; + TilePos point; + switch (direction){ + case Direction::NORTH: + point = {here.x - connection.offset, to.tiles.height - 1}; + break; + case Direction::SOUTH: + point = {here.x - connection.offset, 0}; + break; + case Direction::WEST: + point = {to.tiles.width - 1, here.y - connection.offset}; + break; + case Direction::EAST: + point = {0, here.y - connection.offset}; + break; + } + const StepKind kind = classify_step_onto(map, player.state, direction, to, point); + if (kind == StepKind::BLOCKED){ + return std::nullopt; + } + Transition transition; + transition.next.map = destination; + transition.next.state = after_step_onto(map, player.state, to, point, kind); + transition.action = kind == StepKind::START_SURF ? RouteAction::SURF : RouteAction::WALK; + transition.dismounted = kind == StepKind::STOP_SURFING; + return transition; + } + return std::nullopt; +} + +// Pressing `direction` once (field_control_avatar.c): an exit mat or a door +// warps without the player moving, a step onto a ladder or cave entrance +// warps after it, and anything else is a step by the collision rules. +std::optional press(const std::vector& maps, const Located& player, Direction direction){ + const MapData& map = maps[player.map]; + const TilePos here = player.state.position; + if (exit_mat(map, here) == direction){ + return take_warp(maps, warp_at(map, here)); + } + const TilePos ahead = here.next(direction); + if (direction == Direction::NORTH && is_door(map, ahead) && !player.state.surfing){ + return take_warp(maps, warp_at(map, ahead)); + } + + if (!in_bounds(map, ahead)){ + return cross_edge(maps, player, direction); + } + + const StepKind kind = classify_step(map, player.state, direction); + if (kind == StepKind::BLOCKED){ + return std::nullopt; + } + Transition transition; + transition.next = player; + transition.next.state = after_step(map, player.state, direction, kind); + transition.action = kind == StepKind::START_SURF ? RouteAction::SURF : RouteAction::WALK; + transition.dismounted = kind == StepKind::STOP_SURFING; + const TilePos landed = transition.next.state.position; + if (transition.action == RouteAction::WALK && warps_on_step(map, landed) && warp_at(map, landed) != nullptr){ + return take_warp(maps, warp_at(map, landed)); + } + return transition; +} + +// Whether holding `direction` stops the player where they are: a wall, a +// ledge, or water they'd need to Surf onto. A leg that ends there can be +// held for longer than it takes, and walked again after an interruption. A +// person in the way doesn't count, since they may have wandered off, and +// neither does an edge to a map that isn't loaded. +bool stops_at(const std::vector& maps, const Located& player, Direction direction){ + const std::optional transition = press(maps, player, direction); + if (transition){ + return transition->action == RouteAction::SURF; + } + const MapData& map = maps[player.map]; + const TilePos ahead = player.state.position.next(direction); + if (in_bounds(map, ahead)){ + return object_at(map, ahead) == nullptr; + } + for (const MapConnection& connection : map.connections){ + if (connection.direction == direction){ + return false; + } + } + return true; +} + +// The search state: a player, the way they face (4 stands for "not known +// yet", before the first step), and whether they are in the middle of a leg +// that could go on, as opposed to just stopped by a warp or Surf. +// The game's step rules for the shared leg planner. +class StepModel{ +public: + using State = Located; + using StateHash = LocatedHash; + + StepModel(const std::vector& maps, const RouteCosts& costs) + : m_maps(maps) + , m_costs(costs) + {} + + std::optional> press(const State& state, Direction direction) const{ + const std::optional transition = PokemonFRLG::press(m_maps, state, direction); + if (!transition){ + return std::nullopt; + } + Pokemon::Step step; + step.next = transition->next; + step.action = transition->action == RouteAction::SURF ? ACTION_SURF : 0; + // Leaving the water: the player walks at half the speed from here. + step.starts_leg = transition->dismounted; + step.ends_leg = transition->warped || transition->dismounted; + step.warped = transition->warped; + step.facing = transition->walked_out ? Direction::SOUTH : direction; + if (transition->action == RouteAction::SURF){ + step.extra_cost += route_cost(m_costs, RouteCost::SURF); + } + if (transition->warped){ + step.extra_cost += route_cost(m_costs, RouteCost::WARP); + } + if (is_tall_grass(m_maps[step.next.map], step.next.state.position)){ + step.extra_cost += route_cost(m_costs, RouteCost::GRASS_TILE); + } + return step; + } + bool held_at(const State& state, Direction direction) const{ + return stops_at(m_maps, state, direction); + } + + static constexpr int ACTION_SURF = 1; + +private: + const std::vector& m_maps; + const RouteCosts& m_costs; +}; + +Pokemon::LegCosts to_leg_costs(const RouteCosts& costs){ + Pokemon::LegCosts leg_costs; + leg_costs.per_tile = route_cost(costs, RouteCost::TILE); + leg_costs.per_turn = route_cost(costs, RouteCost::TURN); + leg_costs.per_open_stop = route_cost(costs, RouteCost::OPEN_STOP); + return leg_costs; +} + +RouteStep to_route_step(const std::vector& maps, const Pokemon::Leg& leg){ + RouteStep step; + switch (leg.kind){ + case Pokemon::LegKind::WALK: + step.action = RouteAction::WALK; + break; + case Pokemon::LegKind::ACTION: + step.action = RouteAction::SURF; + break; + case Pokemon::LegKind::FACE: + step.action = RouteAction::FACE; + break; + } + step.direction = leg.direction; + step.tiles = leg.kind == Pokemon::LegKind::FACE ? 0 : leg.tiles; + step.warps = leg.warps; + step.surfing = leg.start.state.surfing; + step.to_wall = leg.to_wall; + step.facing_after = leg.facing_after; + step.end = MapPosition{maps[leg.end.map].name, leg.end.state.position}; + return step; +} + +} + + + +std::optional> plan_overworld_route( + const std::vector& maps, + const MapPosition& start, + const RouteGoal& goal, + const RouteCosts& costs, + std::optional start_facing +){ + const int start_map = find_map(maps, start.map); + const int goal_map = find_map(maps, goal.position.map); + if (start_map < 0 || goal_map < 0){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Route between maps that aren't loaded."); + } + Located first; + first.map = start_map; + first.state.position = start.point; + first.state.elevation = elevation_at(maps[start_map], start.point); + first.state.surfing = is_water(maps[start_map], start.point); + + const StepModel model(maps, costs); + const auto legs = Pokemon::plan_legs( + model, first, start_facing, + std::function([&](const Located& player){ + return player.map == goal_map && player.state.position == goal.position.point; + }), + goal.facing, to_leg_costs(costs) + ); + if (!legs){ + return std::nullopt; + } + + std::vector route; + for (const Pokemon::Leg& leg : *legs){ + route.push_back(to_route_step(maps, leg)); + } + return route; +} + + +MapPosition follow_overworld_route( + const std::vector& maps, + const MapPosition& start, + const std::vector& route +){ + Located player; + player.map = find_map(maps, start.map); + if (player.map < 0){ + throw InternalProgramError(nullptr, PA_CURRENT_FUNCTION, "Route from a map that isn't loaded: " + start.map); + } + player.state.position = start.point; + player.state.elevation = elevation_at(maps[player.map], start.point); + player.state.surfing = is_water(maps[player.map], start.point); + + for (const RouteStep& step : route){ + if (step.action == RouteAction::FACE){ + continue; + } + for (int c = 0; c < step.tiles; c++){ + const std::optional transition = press(maps, player, step.direction); + if (!transition || transition->action != step.action){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Step refused at " + to_string(MapPosition{maps[player.map].name, player.state.position}) + ": " + to_string(step) + ); + } + player = transition->next; + } + const MapPosition position{maps[player.map].name, player.state.position}; + if (!(position == step.end)){ + throw InternalProgramError( + nullptr, PA_CURRENT_FUNCTION, + "Ended at " + to_string(position) + " instead of " + to_string(step.end) + ); + } + } + return MapPosition{maps[player.map].name, player.state.position}; +} + + + +namespace{ + +class Test_OverworldRoute : public UnitTest{ +public: + Test_OverworldRoute(std::string name, std::function body) + : UnitTest("PokemonFRLG::OverworldRoute - " + std::move(name)) + , m_body(std::move(body)) + {} + virtual UnitTestResult run(Logger&, CancellableScope&) const override{ + try{ + return m_body(); + }catch (FileException& e){ + // CI runs with upstream Packages, which may not have the maps yet. + return UnitTestResult(UnitTestResult::SKIPPED, "Map files not found: " + e.message()); + }catch (Exception& e){ + return std::string(e.name()) + ": " + e.message(); + } + } + +private: + std::function m_body; +}; + +std::string describe(const std::vector& route){ + std::string text; + for (const RouteStep& step : route){ + text += "\n " + to_string(step); + } + return text; +} + +// A 5x4 map for the step rules: a pond, a raised ledge reached by a stair +// tile, and a tile that can't be entered moving south. +const MapData& small_map(){ + static const MapData MAP = parse_map(R"({ + "name": "Small", "width": 5, "height": 4, + "tiles": ["#####", "#.n.#", "#.~.#", "#####"], + "elevation": ["33333", "33403", "33133", "33333"], + "warps": [], "objects": [], "connections": [], "points": [], "flag_sets": [] + })", "Small"); + return MAP; +} + +UnitTestResult test_step_rules(){ + const MapData& map = small_map(); + PlayerState state{{1, 1}, 3, false}; + if (classify_step(map, state, Direction::EAST) != StepKind::BLOCKED){ + return "Walked from elevation 3 onto elevation 4."; + } + if (classify_step(map, PlayerState{{2, 0}, 3, false}, Direction::SOUTH) != StepKind::BLOCKED){ + return "Entered a north-blocked tile moving south."; + } + state = PlayerState{{1, 2}, 3, false}; + if (classify_step(map, state, Direction::EAST) != StepKind::START_SURF){ + return "Can't start surfing onto the pond."; + } + state = after_step(map, state, Direction::EAST, StepKind::START_SURF); + if (!state.surfing || state.elevation != 1){ + return "Surfing didn't put the player on the water."; + } + if (classify_step(map, state, Direction::EAST) != StepKind::STOP_SURFING){ + return "Can't land from the pond."; + } + if (classify_step(map, PlayerState{{3, 2}, 3, false}, Direction::NORTH) != StepKind::WALK){ + return "Can't step from elevation 3 onto the elevation-0 stair."; + } + return true; +} + +// The maps after the Hall of Fame, which clears the way to Cerulean Cave. +std::vector post_game_maps(){ + return overworld_maps(map_flag_set(overworld_maps(), "after_hall_of_fame")); +} + +MapPosition point_on(const std::vector& maps, const std::string& map, const std::string& point){ + return MapPosition{map, map_point(maps, map, point)}; +} + +const MapData* map_named(const std::vector& maps, const std::string& name){ + const int index = find_map(maps, name); + return index < 0 ? nullptr : &maps[index]; +} + +UnitTestResult test_maps(){ + const std::vector maps = post_game_maps(); + const MapData* b1f = map_named(maps, "CeruleanCave_B1F"); + const MapData* city = map_named(maps, "CeruleanCity"); + if (b1f == nullptr || city == nullptr || map_named(maps, "CeruleanCity_PokemonCenter_1F") == nullptr){ + return "Missing a map."; + } + if (b1f->tiles.width != 40 || b1f->tiles.height != 23 || city->tiles.width != 48 || city->tiles.height != 40){ + return "Unexpected map size."; + } + const TilePos mewtwo_tile = map_point(maps, "CeruleanCave_B1F", "mewtwo"); + const MapObject* mewtwo = object_at(*b1f, mewtwo_tile); + if (mewtwo == nullptr || mewtwo->kind != "mewtwo"){ + return "Mewtwo is not on B1F at " + to_string(mewtwo_tile) + "."; + } + if (object_at(*city, {1, 13}) != nullptr){ + return "The cave guard is still there after the Hall of Fame."; + } + if (object_at(*map_named(overworld_maps(), "CeruleanCity"), {1, 13}) == nullptr){ + return "The cave guard is missing before the Hall of Fame."; + } + return true; +} + +// Plans `start` to `goal`, replays it with the step rules and checks where it ends. +UnitTestResult check_route( + const std::vector& maps, + const MapPosition& start, const RouteGoal& goal, + std::vector& route +){ + const std::optional> planned = plan_overworld_route(maps, start, goal); + if (!planned){ + return "No route from " + to_string(start) + " to " + to_string(goal.position) + "."; + } + route = *planned; + const MapPosition end = follow_overworld_route(maps, start, route); + if (!(end == goal.position)){ + return "Route ends at " + to_string(end) + ":" + describe(route); + } + if (goal.facing && route.back().direction != *goal.facing){ + return "Doesn't end facing " + std::string(direction_name(*goal.facing)) + ":" + describe(route); + } + return true; +} + +// Below Mewtwo, facing it, to talk to it. +RouteGoal facing_mewtwo(const std::vector& maps){ + const TilePos mewtwo = map_point(maps, "CeruleanCave_B1F", "mewtwo"); + return RouteGoal{{"CeruleanCave_B1F", mewtwo.next(Direction::SOUTH)}, Direction::NORTH}; +} + +UnitTestResult test_entrance_to_mewtwo(){ + const std::vector maps = post_game_maps(); + std::vector route; + UnitTestResult result = check_route(maps, point_on(maps, "CeruleanCave_1F", "entrance"), facing_mewtwo(maps), route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + bool surfs = false; + bool reaches_b1f = false; + for (const RouteStep& step : route){ + surfs |= step.action == RouteAction::SURF; + reaches_b1f |= step.warps && step.end.map == "CeruleanCave_B1F"; + } + if (!surfs || !reaches_b1f){ + return "Expected to take the ladder to B1F and to surf:" + describe(route); + } + return true; +} + +UnitTestResult test_pokemon_center_to_mewtwo(){ + const std::vector maps = post_game_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps), route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + // Out through the exit mat, onto the city's step below the door, and + // into the cave by its entrance. + std::vector maps_in_order; + for (const RouteStep& step : route){ + if (step.warps){ + maps_in_order.push_back(step.end.map); + } + } + if (maps_in_order.size() < 2 || maps_in_order[0] != "CeruleanCity" || maps_in_order[1] != "CeruleanCave_1F"){ + return "Expected to leave by the mat and enter the cave next:" + describe(route); + } + if (!(route[0].end.map == "CeruleanCity_PokemonCenter_1F" || route[0].warps)){ + return "Unexpected first step:" + describe(route); + } + return true; +} + +UnitTestResult test_cave_guard_blocks(){ + // Before the Hall of Fame the guard stands in the cave entrance. + const std::vector maps = overworld_maps(); + if (plan_overworld_route(maps, point_on(maps, "CeruleanCity_PokemonCenter_1F", "pc"), facing_mewtwo(maps))){ + return "Planned past the cave guard."; + } + return true; +} + +UnitTestResult test_mewtwo_tile_unreachable(){ + const std::vector maps = post_game_maps(); + const std::optional> route = plan_overworld_route( + maps, + point_on(maps, "CeruleanCave_1F", "entrance"), + {point_on(maps, "CeruleanCave_B1F", "mewtwo"), std::nullopt} + ); + if (route){ + return "Planned a route onto Mewtwo itself:" + describe(*route); + } + return true; +} + +UnitTestResult test_story_maps(){ + const std::vector maps = overworld_maps(); + for (const char* name : { + "PalletTown_PlayersHouse_2F", "PalletTown_ProfessorOaksLab", "Route1", "ViridianCity_Mart", + "Route22", "Route2", "ViridianForest", "PewterCity", "PewterCity_Museum_1F", "PewterCity_Gym", + }){ + if (map_named(maps, name) == nullptr){ + return std::string("Missing map ") + name + "."; + } + } + return true; +} + +// Which maps `route` warps into, in order. +std::vector maps_warped_into(const std::vector& route){ + std::vector maps; + for (const RouteStep& step : route){ + if (step.warps){ + maps.push_back(step.end.map); + } + } + return maps; +} + +UnitTestResult test_bedroom_to_oaks_lab(){ + const std::vector maps = overworld_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "PalletTown_PlayersHouse_2F", "start"), + {point_on(maps, "PalletTown", "oaks_lab_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + const std::vector warped = maps_warped_into(route); + if (warped != std::vector{"PalletTown_PlayersHouse_1F", "PalletTown"}){ + return "Expected to take the stairs and leave by the front door:" + describe(route); + } + return true; +} + +UnitTestResult test_viridian_mart_to_pewter_gym(){ + const std::vector maps = overworld_maps(); + std::vector route; + UnitTestResult result = check_route( + maps, point_on(maps, "ViridianCity_Mart", "clerk_front"), + {point_on(maps, "PewterCity", "gym_front"), Direction::NORTH}, route + ); + if (result.result != UnitTestResult::PASSED){ + return result; + } + const std::vector warped = maps_warped_into(route); + if (std::find(warped.begin(), warped.end(), "ViridianForest") == warped.end()){ + return "Expected to go through Viridian Forest:" + describe(route); + } + return true; +} + +// Two small maps that don't need the map files: a room left by the exit mat +// at its bottom, onto the door of a yard, out of which the game walks the +// player. +const char* const TEST_ROOM_JSON = R"({ + "name": "Room", "width": 5, "height": 4, + "tiles": ["#####", "#...#", "#...#", "##v##"], + "elevation": ["33333", "33333", "33333", "33333"], + "warps": [{"x": 2, "y": 3, "dest_map": "Yard", "dest_warp": 0}], + "objects": [], + "connections": [], "points": [], "flag_sets": [] +})"; +const char* const TEST_YARD_JSON = R"({ + "name": "Yard", "width": 5, "height": 4, + "tiles": ["#####", "##D##", "#...#", "#####"], + "elevation": ["33333", "33333", "33333", "33333"], + "warps": [{"x": 2, "y": 1, "dest_map": "Room", "dest_warp": 0}], + "objects": [], + "connections": [], "points": [], "flag_sets": [] +})"; + +UnitTestResult test_route_through_warp(){ + const std::vector maps{ + parse_map(TEST_ROOM_JSON, "Room"), + parse_map(TEST_YARD_JSON, "Yard"), + }; + std::vector route; + UnitTestResult result = check_route(maps, {"Room", {2, 1}}, {{"Yard", {1, 2}}, std::nullopt}, route); + if (result.result != UnitTestResult::PASSED){ + return result; + } + for (const RouteStep& step : route){ + if (step.warps && step.end.map == "Yard"){ + return true; + } + } + return "Expected to warp into the yard:" + describe(route); +} + +} + +void add_tests_OverworldRoute(UnitTestDatabase& database){ + database.add("route through a warp", test_route_through_warp); + database.add("maps to Brock", test_story_maps); + database.add("bedroom to Oak's Lab", test_bedroom_to_oaks_lab); + database.add("Viridian Mart to Pewter Gym", test_viridian_mart_to_pewter_gym); + database.add("step rules", test_step_rules); + database.add("maps", test_maps); + database.add("Cerulean Cave entrance to Mewtwo", test_entrance_to_mewtwo); + database.add("Cerulean Pokemon Center to Mewtwo", test_pokemon_center_to_mewtwo); + database.add("cave guard blocks the way", test_cave_guard_blocks); + database.add("Mewtwo's tile is blocked", test_mewtwo_tile_unreachable); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h new file mode 100644 index 0000000000..35afa7a1e4 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h @@ -0,0 +1,119 @@ +/* Overworld Route + * + * From: https://github.com/PokemonAutomation/ + * + * Plans a route over one or more FRLG maps joined by warps: the D-pad legs + * to walk, where to start surfing, and which way to face at the end. FRLG + * moves exactly one tile per step, so a planned route can be walked blind. + * Legs end against walls where that costs little: such a leg can be held + * for longer than it takes, and walked again if something interrupts it. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldRoute_H +#define PokemonAutomation_PokemonFRLG_OverworldRoute_H + +#include +#include +#include +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "PokemonFRLG_OverworldMap.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +struct MapPosition{ + std::string map; + TilePos point; + + bool operator==(const MapPosition& other) const = default; +}; +std::string to_string(const MapPosition& position); + + +enum class RouteAction{ + WALK, // hold `direction` for `tiles` tiles + SURF, // face `direction`, use Surf, and land on the water there + FACE, // turn to `direction` without moving +}; + +struct RouteStep{ + RouteAction action = RouteAction::WALK; + Direction direction = Direction::NORTH; + int tiles = 0; + // WALK: the last tile is a warp (ladder, stairs), so the screen fades + // and the player arrives on another map. + bool warps = false; + // WALK: on the water, where the player moves twice as fast. + bool surfing = false; + // WALK: a wall stops the player at the end, so the leg can be held for + // longer than it takes and still ends on its tile. + bool to_wall = false; + // Which way the player faces after the step. Arriving at a door or a + // cave mouth, the game walks the player a tile out, facing south. + Direction facing_after = Direction::NORTH; + // Where the player is after the step. + MapPosition end; +}; +std::string to_string(const RouteStep& step); + + +struct RouteGoal{ + MapPosition position; + // Which way to face on arrival, e.g. towards a Pokemon to talk to. + std::optional facing; +}; + +// What the planner weighs a route by. Each is a cost per occurrence. +enum class RouteCost{ + TILE, + TURN, // extra for a leg in a new direction + WARP, // the fade in and out + SURF, // the dialog and the animation + GRASS_TILE, // extra for tall grass, where wild Pokemon appear + // Extra for a leg that ends where the player could keep walking: it has + // to be timed, and if a message or a battle stops it part-way, where + // the player is can only be estimated. A leg that ends at a wall is + // walked again instead. + OPEN_STOP, +}; +using RouteCosts = std::map; + +// TILE 1, TURN 2, WARP 30, SURF 40, GRASS_TILE 20, OPEN_STOP 8. +const RouteCosts& default_route_costs(); + +// `costs`'s cost for `kind`, or the default if it has none. +int route_cost(const RouteCosts& costs, RouteCost kind); + +// The cheapest route from `start`, surfing if it is on water, to +// `goal`, or nothing if there is none. `maps` must hold every map the route +// may pass through; warps to other maps are not taken. Planned with +// Pokemon::plan_legs() on the game's step rules. +std::optional> plan_overworld_route( + const std::vector& maps, + const MapPosition& start, + const RouteGoal& goal, + const RouteCosts& costs = default_route_costs(), + std::optional start_facing = std::nullopt +); + +// Replays `route` from `start` with the game's step rules and returns where +// it ends. Throws InternalProgramError if a step can't be taken as planned. +MapPosition follow_overworld_route( + const std::vector& maps, + const MapPosition& start, + const std::vector& route +); + + +void add_tests_OverworldRoute(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp new file mode 100644 index 0000000000..8ac35e5140 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp @@ -0,0 +1,313 @@ +/* Overworld Walk + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include "CommonFramework/Exceptions/OperationFailedExceptionWithScreenshot.h" +#include "CommonTools/Async/InferenceRoutines.h" +#include "NintendoSwitch/Commands/NintendoSwitch_Commands_PushButtons.h" +#include "CommonFramework/VideoPipeline/VideoFeed.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_BattleDialogs.h" +#include "VideoGames/PokemonFRLG/Inference/Dialogs/PokemonFRLG_DialogDetector.h" +#include "VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h" +#include "PokemonFRLG_PlayerFacingReader.h" +#include "PokemonFRLG_OverworldWalk.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + +using namespace std::chrono_literals; + + +namespace{ + +constexpr Milliseconds TILE_TIME = 267ms; // 16 frames at 60 fps, measured exact over 15 tiles +constexpr Milliseconds SURF_TILE_TIME = 133ms; // the game moves a surfing player at running speed +constexpr Milliseconds TURN_TAP = 40ms; // turns a player facing another way, without a step +constexpr Milliseconds TURN_SETTLE = 300ms; +constexpr Milliseconds FACE_HOLD = 40ms; // short enough to turn without stepping +constexpr Milliseconds FACE_SETTLE = 300ms; // the turn animation, before A reaches what is ahead +constexpr Milliseconds STEP_RELEASE = 100ms; +// From the end of the hold, through the fade out and in. +constexpr Milliseconds WARP_TIMEOUT = 8000ms; +constexpr Milliseconds SURF_PROMPT_TIMEOUT = 5000ms; +constexpr Milliseconds SURF_ANIMATION = 3000ms; +// After a warp's fade: leaving by a door the game walks the player a tile out +// on its own, and a leg started before that ends loses tiles (on the console a +// 600ms wait cost 2 tiles). +constexpr Milliseconds AFTER_WARP = 1500ms; +// A message set off by a leg's last step shows after the hold ends. Caught +// here, before the next leg's turn tap, which the message would swallow. +constexpr Milliseconds AFTER_LEG_WATCH = 500ms; +constexpr size_t MAX_INTERRUPTIONS_PER_LEG = 5; + + +// A wild battle, seen by the flashes it starts with. +class WildBattleInterrupt : public OverworldInterrupt{ +public: + virtual std::string name() const override{ + return "a wild battle"; + } + virtual std::unique_ptr make_watcher() const override{ + return std::make_unique(COLOR_RED); + } + // The first flash shows about 130ms after the step on the console, and + // the watcher needs it to start fading back. Not calibrated yet. + virtual Milliseconds detection_delay() const override{ + return 300ms; + } + virtual void clear(ConsoleHandle& console, ProControllerContext& context) const override{ + flee_battle(console, context); + } +}; + +// A wild battle whose flashes were missed, seen once "Wild +// appeared!" shows. Where an open leg stopped is then only a rough guess. +class LateWildBattleInterrupt : public WildBattleInterrupt{ +public: + virtual std::string name() const override{ + return "a wild battle (seen late)"; + } + virtual std::unique_ptr make_watcher() const override{ + return std::make_unique(COLOR_RED); + } + // The flashes, the wipe and the opening animation. Not calibrated yet. + virtual Milliseconds detection_delay() const override{ + return 2000ms; + } +}; + +class MessageInterrupt : public OverworldInterrupt{ +public: + virtual std::string name() const override{ + return "a message"; + } + virtual std::unique_ptr make_watcher() const override{ + return std::make_unique(COLOR_RED); + } + // The box shows as the step ends; then video lag and the watcher's + // 250ms. Not calibrated yet. + virtual Milliseconds detection_delay() const override{ + return 400ms; + } + virtual void clear(ConsoleHandle& console, ProControllerContext& context) const override{ + WhiteDialogOverWatcher closed(COLOR_RED); + context.wait_for_all_requests(); + const int ret = run_until( + console, context, + [](ProControllerContext& context){ + for (int c = 0; c < 5; c++){ + pbf_press_button(context, BUTTON_B, 160ms, 840ms); + } + }, + {closed} + ); + if (ret < 0){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "walk_overworld_route(): a message on the overworld didn't close.", + console + ); + } + } +}; + +// A message, taken to be "REPEL's effect wore off...", after which another +// Max Repel is used from the Items pocket. +class RepelWoreOffInterrupt : public MessageInterrupt{ +public: + explicit RepelWoreOffInterrupt(int max_repel_slot) + : m_max_repel_slot(max_repel_slot) + {} + virtual std::string name() const override{ + return "a message (Repel wore off?)"; + } + virtual void clear(ConsoleHandle& console, ProControllerContext& context) const override{ + MessageInterrupt::clear(console, context); + use_max_repel_from_bag(console, context, m_max_repel_slot); + } + +private: + int m_max_repel_slot; +}; + + +DpadPosition dpad(Direction direction){ + switch (direction){ + case Direction::NORTH: + return DPAD_UP; + case Direction::EAST: + return DPAD_RIGHT; + case Direction::SOUTH: + return DPAD_DOWN; + case Direction::WEST: + return DPAD_LEFT; + } + return DPAD_NONE; +} + +// The leg mover: holds the D-pad. FRLG moves exactly a tile per 16 frames. +class DpadMover : public Pokemon::LegMover{ +public: + virtual void hold(ProControllerContext& context, Direction direction, Milliseconds duration) const override{ + pbf_press_dpad(context, dpad(direction), duration, STEP_RELEASE); + } +}; + +Pokemon::LegSpec leg_spec(const RouteStep& step){ + return Pokemon::LegSpec{step.direction, step.tiles, step.to_wall, step.surfing ? SURF_TILE_TIME : TILE_TIME}; +} + +// One watcher for the whole fade: a ladder's black is short enough to be +// over before a second watcher, started once the fade is seen, looks. +void wait_out_warp(ConsoleHandle& console, ProControllerContext& context, const RouteStep& step){ + BlackScreenOverWatcher arrival(COLOR_RED); + context.wait_for_all_requests(); + if (wait_until(console, context, WARP_TIMEOUT, {arrival}) < 0){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "walk_overworld_route(): no fade in and out after walking onto the warp to " + step.end.map + ".", + console + ); + } +} + +void start_surf(ConsoleHandle& console, ProControllerContext& context, Direction direction, bool facing_water){ + // "The water is a deep blue... Would you like to SURF?", with YES selected. + // An A pressed while the player is still turning is lost. + if (!facing_water){ + pbf_press_dpad(context, dpad(direction), FACE_HOLD, FACE_SETTLE); + } + pbf_press_button(context, BUTTON_A, 160ms, 0ms); + SelectionDialogWatcher prompt(COLOR_RED); + context.wait_for_all_requests(); + if (wait_until(console, context, SURF_PROMPT_TIMEOUT, {prompt}) < 0){ + OperationFailedExceptionWithScreenshot::fire( + ErrorReportMode::SEND_ERROR_REPORT, + "walk_overworld_route(): no Surf prompt facing the water.", + console + ); + } + pbf_press_button(context, BUTTON_A, 160ms, 1000ms); + + // " used SURF!" waits for A, then the player jumps onto the water. + for (int c = 0; c < 5; c++){ + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + if (!screen || !WhiteDialogDetector(COLOR_RED).detect(screen)){ + break; + } + pbf_press_button(context, BUTTON_A, 160ms, 1000ms); + } + pbf_wait(context, SURF_ANIMATION); +} + +} + + + +OverworldInterrupts overworld_interrupts_with_repel(int max_repel_slot){ + OverworldInterrupts ret; + ret.emplace_back(std::make_unique()); + ret.emplace_back(std::make_unique()); + ret.emplace_back(std::make_unique(max_repel_slot)); + return ret; +} + +void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& context, int slot){ + console.log("Using a Max Repel (Items pocket, slot " + std::to_string(slot + 1) + ")."); + open_bag_from_overworld(console, context); + // The bag is seen while it still slides in, and ignores presses until + // it is done. + pbf_wait(context, 1000ms); + // To the Items pocket, then to the top of it. + for (int c = 0; c < 3; c++){ + pbf_press_dpad(context, DPAD_LEFT, 100ms, 600ms); + } + for (int c = 0; c < 20; c++){ + pbf_press_dpad(context, DPAD_UP, 100ms, 100ms); + } + for (int c = 0; c < slot; c++){ + pbf_press_dpad(context, DPAD_DOWN, 100ms, 300ms); + } + // USE, " used the MAX REPEL.", back to the bag, then out of the + // bag and the start menu. A third A here would open the item's menu again. + pbf_press_button(context, BUTTON_A, 160ms, 800ms); + pbf_press_button(context, BUTTON_A, 160ms, 1500ms); + pbf_press_button(context, BUTTON_A, 160ms, 1500ms); + pbf_press_button(context, BUTTON_B, 160ms, 2000ms); + pbf_press_button(context, BUTTON_B, 160ms, 1000ms); + context.wait_for_all_requests(); +} + +const OverworldInterrupts& default_overworld_interrupts(){ + // Battles first: their screens are checked before the message box's. + static const OverworldInterrupts interrupts = []{ + OverworldInterrupts ret; + ret.emplace_back(std::make_unique()); + ret.emplace_back(std::make_unique()); + ret.emplace_back(std::make_unique()); + return ret; + }(); + return interrupts; +} + + +void walk_overworld_route( + ConsoleHandle& console, ProControllerContext& context, + const std::vector& route, + std::optional facing, + const OverworldInterrupts& interrupts +){ + // Each leg starts with a turn tap unless it goes the way the player faces. + // A tap while already facing that way takes a step, so a facing not + // given is read from the screen. If that fails too it taps anyway. + if (!facing){ + context.wait_for_all_requests(); + VideoSnapshot screen = console.video().snapshot(); + if (screen){ + facing = read_player_facing(screen); + } + console.log(std::string("Route: the player faces ") + (facing ? Pokemon::direction_name(*facing) : "an unknown way") + "."); + } + const DpadMover mover; + for (const RouteStep& step : route){ + console.log("Route: " + to_string(step)); + switch (step.action){ + case RouteAction::WALK: + // Turning first, with a tap, makes the hold start from a standstill + // facing the right way: holding through a turn lost a tile on the + // console, while holds from a standstill were exact over 15 tiles. + if (facing != step.direction){ + pbf_press_dpad(context, dpad(step.direction), TURN_TAP, TURN_SETTLE); + } + Pokemon::walk_leg(console, context, mover, interrupts, leg_spec(step), MAX_INTERRUPTIONS_PER_LEG); + if (step.warps){ + wait_out_warp(console, context, step); + pbf_wait(context, AFTER_WARP); + } + break; + case RouteAction::SURF: + start_surf(console, context, step.direction, facing == step.direction); + break; + case RouteAction::FACE: + pbf_press_dpad(context, dpad(step.direction), FACE_HOLD, STEP_RELEASE); + break; + } + if (step.action != RouteAction::FACE){ + Pokemon::clear_late_interrupts(console, context, interrupts, AFTER_LEG_WATCH, MAX_INTERRUPTIONS_PER_LEG); + } + facing = step.facing_after; + } + context.wait_for_all_requests(); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h new file mode 100644 index 0000000000..402826fd7a --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h @@ -0,0 +1,69 @@ +/* Overworld Walk + * + * From: https://github.com/PokemonAutomation/ + * + * Walks a planned overworld route with the D-pad. FRLG moves one tile per + * 16 frames and finishes a step once it starts, so a leg is one hold timed + * to land mid-way between the last tile and one more. Warps are waited out + * by their fade; Surf goes through its dialog. + * + * A message or a wild battle can stop the player part-way through a leg. + * Each kind is an OverworldInterrupt: the walker watches for all of them + * while it holds the D-pad, and when one shows it stops, has it cleared and + * walks the rest of the leg. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_OverworldWalk_H +#define PokemonAutomation_PokemonFRLG_OverworldWalk_H + +#include +#include +#include +#include "Pokemon/Navigation/Pokemon_LegWalker.h" +#include "NintendoSwitch/Controllers/Procon/NintendoSwitch_ProController.h" +#include "NintendoSwitch/NintendoSwitch_ConsoleHandle.h" +#include "PokemonFRLG_OverworldRoute.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// Something that stops the player part-way through a leg: a wild battle, a +// message. See Pokemon::LegInterrupt. +using OverworldInterrupt = Pokemon::LegInterrupt; +using OverworldInterrupts = Pokemon::LegInterrupts; + +// A wild battle, run from, and a message (e.g. "REPEL's effect wore off..."), +// closed with B. +const OverworldInterrupts& default_overworld_interrupts(); + +// The same, except that after a message another Max Repel is used, from +// slot `max_repel_slot` (from 0) of the Items pocket. Any message on the +// overworld is taken to be the one saying the last Repel wore off. +OverworldInterrupts overworld_interrupts_with_repel(int max_repel_slot); + +// From the overworld, uses the Max Repel in `slot` (from 0) of the Items +// pocket and returns to the overworld. +void use_max_repel_from_bag(ConsoleHandle& console, ProControllerContext& context, int slot); + + +// Walks `route` from where it was planned. The player must be standing still +// on the overworld, not running and not on the bike. `facing` is the way the +// player faces at the start; if not given, it is read from the screen. Throws OperationFailedException if a +// warp's fade or the Surf prompt doesn't show, or a leg is interrupted too +// many times. +void walk_overworld_route( + ConsoleHandle& console, ProControllerContext& context, + const std::vector& route, + std::optional facing = std::nullopt, + const OverworldInterrupts& interrupts = default_overworld_interrupts() +); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.cpp new file mode 100644 index 0000000000..601dd21086 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.cpp @@ -0,0 +1,252 @@ +/* Player Facing Reader + * + * From: https://github.com/PokemonAutomation/ + * + */ + +#include +#include +#include +#include "Common/Cpp/Color.h" +#include "Common/Cpp/Exceptions.h" +#include "Common/Cpp/TestRunners/UnitTest.h" +#include "CommonFramework/GlobalAutoPaths.h" +#include "CommonFramework/ImageTools/ImageBoxes.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "VideoGames/PokemonFRLG/PokemonFRLG_Settings.h" +#include "PokemonFRLG_PlayerFacingReader.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +namespace{ + +// The sprite sheet's frames are 16x32, and the standing ones are 0 (south), +// 1 (north) and 2 (west). Rows 12 to 30 are drawn; the rest is empty. +const char PLAYER_SPRITE_SHEET[] = "PokemonFRLG/PlayerSprites/red_normal.png"; +constexpr int SHEET_FRAME_WIDTH = 16; +constexpr int SHEET_FIRST_ROW = 12; + +constexpr int SPRITE_WIDTH = 16; +constexpr int SPRITE_ROWS = 19; +// Where the sprite's first drawn row sits on the GBA's 240x160 screen while +// the camera follows the player. Measured on the console. +constexpr int SPRITE_X = 112; +constexpr int SPRITE_Y = 56 + 12; +constexpr int GBA_WIDTH = 240; +constexpr int GBA_HEIGHT = 160; + +// Average difference per channel over the sprite's pixels. On the console a +// match scored about 12 and the next best sprite over 50. +constexpr double MATCH_MAX = 25; +constexpr double MATCH_MARGIN = 2.0; + + +ImageRGB32 sheet_frame(const ImageViewRGB32& sheet, int frame){ + return sheet.sub_image(frame * SHEET_FRAME_WIDTH, SHEET_FIRST_ROW, SPRITE_WIDTH, SPRITE_ROWS).copy(); +} + +// How far the screen differs from `sprite`, drawn `dy` GBA pixels lower. +double difference(const ImageViewRGB32& game, const ImageViewRGB32& sprite, bool mirror, int dy){ + const double scale_x = (double)game.width() / GBA_WIDTH; + const double scale_y = (double)game.height() / GBA_HEIGHT; + long total = 0; + int pixels = 0; + for (int row = 0; row < SPRITE_ROWS; row++){ + for (int col = 0; col < SPRITE_WIDTH; col++){ + const Color expected(sprite.pixel(mirror ? SPRITE_WIDTH - 1 - col : col, row)); + if (expected.alpha() == 0){ + continue; + } + const size_t x = (size_t)((SPRITE_X + col + 0.5) * scale_x); + const size_t y = (size_t)((SPRITE_Y + dy + row + 0.5) * scale_y); + if (x >= game.width() || y >= game.height()){ + return std::numeric_limits::max(); + } + const Color pixel(game.pixel(x, y)); + total += std::abs((int)pixel.red() - expected.red()); + total += std::abs((int)pixel.green() - expected.green()); + total += std::abs((int)pixel.blue() - expected.blue()); + pixels++; + } + } + return pixels == 0 ? std::numeric_limits::max() : (double)total / (3 * pixels); +} + +} + + +const PlayerSprites& player_sprites(){ + static const PlayerSprites SPRITES = []{ + const ImageRGB32 sheet(RESOURCE_PATH() + PLAYER_SPRITE_SHEET); + return PlayerSprites{sheet_frame(sheet, 0), sheet_frame(sheet, 1), sheet_frame(sheet, 2)}; + }(); + return SPRITES; +} + + +std::optional read_player_facing(const ImageViewRGB32& screen){ + const ImageViewRGB32 game = extract_box_reference(screen, GameSettings::instance().GAME_BOX); + return match_player_facing(game, player_sprites()); +} + +std::optional match_player_facing(const ImageViewRGB32& game, const PlayerSprites& sprites){ + struct Candidate{ + Direction direction; + const ImageViewRGB32& sprite; + bool mirror; + }; + const Candidate candidates[] = { + {Direction::SOUTH, sprites.south, false}, + {Direction::NORTH, sprites.north, false}, + {Direction::WEST, sprites.west, false}, + {Direction::EAST, sprites.west, true}, + }; + + double best = std::numeric_limits::max(); + double second = best; + Direction facing = Direction::SOUTH; + for (const Candidate& candidate : candidates){ + // A pixel either way, for a box that is slightly off. + double score = std::numeric_limits::max(); + for (int dy = -1; dy <= 1; dy++){ + score = std::min(score, difference(game, candidate.sprite, candidate.mirror, dy)); + } + if (score < best){ + second = best; + best = score; + facing = candidate.direction; + }else{ + second = std::min(second, score); + } + } + if (best > MATCH_MAX || second < best * MATCH_MARGIN){ + return std::nullopt; + } + return facing; +} + + + +namespace{ + +class Test_PlayerFacingReader : public UnitTest{ +public: + Test_PlayerFacingReader(const std::string& image, std::optional expected) + : UnitTest("PokemonFRLG::PlayerFacingReader - " + image) + , m_image(UNIT_TEST_RESOURCE_PATH() + image) + , m_expected(expected) + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope& scope) const override{ + std::optional facing; + try{ + facing = read_player_facing(ImageRGB32(m_image)); + }catch (FileException& e){ + // The screenshots and the sprite sheet aren't in CI's resources yet. + return UnitTestResult(UnitTestResult::SKIPPED, "File not found: " + e.message()); + } + if (facing == m_expected){ + return true; + } + return std::string("Read ") + (facing ? direction_name(*facing) : "nothing"); + } + +private: + std::string m_image; + std::optional m_expected; +}; + +// Made-up sprites, so the matching is tested without the sprite sheet: +// solid red facing south, solid blue facing north, and green on the left +// half facing west. +PlayerSprites fixture_sprites(){ + PlayerSprites sprites{ + ImageRGB32(SPRITE_WIDTH, SPRITE_ROWS), + ImageRGB32(SPRITE_WIDTH, SPRITE_ROWS), + ImageRGB32(SPRITE_WIDTH, SPRITE_ROWS), + }; + sprites.south.fill(0xffff0000); + sprites.north.fill(0xff0000ff); + sprites.west.fill(0x00000000); + for (int row = 0; row < SPRITE_ROWS; row++){ + for (int col = 0; col < SPRITE_WIDTH / 2; col++){ + sprites.west.pixel(col, row) = 0xff00ff00; + } + } + return sprites; +} + +// A grey GBA screen at twice its size, with `sprite` drawn where the +// player stands. +ImageRGB32 fixture_screen(const ImageViewRGB32* sprite, bool mirror){ + ImageRGB32 game(2 * GBA_WIDTH, 2 * GBA_HEIGHT); + game.fill(0xff808080); + for (int row = 0; sprite != nullptr && row < SPRITE_ROWS; row++){ + for (int col = 0; col < SPRITE_WIDTH; col++){ + const uint32_t pixel = sprite->pixel(mirror ? SPRITE_WIDTH - 1 - col : col, row); + if (Color(pixel).alpha() == 0){ + continue; + } + for (int dy = 0; dy < 2; dy++){ + for (int dx = 0; dx < 2; dx++){ + game.pixel(2 * (SPRITE_X + col) + dx, 2 * (SPRITE_Y + row) + dy) = pixel; + } + } + } + } + return game; +} + +class Test_PlayerFacingFixture : public UnitTest{ +public: + Test_PlayerFacingFixture() + : UnitTest("PokemonFRLG::PlayerFacingReader - made-up sprites") + {} + + virtual UnitTestResult run(Logger& logger, CancellableScope& scope) const override{ + const PlayerSprites sprites = fixture_sprites(); + struct Case{ + const ImageViewRGB32* sprite; + bool mirror; + std::optional expected; + }; + const Case cases[] = { + {&sprites.south, false, Direction::SOUTH}, + {&sprites.north, false, Direction::NORTH}, + {&sprites.west, false, Direction::WEST}, + {&sprites.west, true, Direction::EAST}, + {nullptr, false, std::nullopt}, + }; + for (const Case& test : cases){ + const std::optional facing = match_player_facing(fixture_screen(test.sprite, test.mirror), sprites); + if (facing != test.expected){ + return std::string("Read ") + (facing ? direction_name(*facing) : "nothing") + + " instead of " + (test.expected ? direction_name(*test.expected) : "nothing"); + } + } + return true; + } +}; + +} + +void add_tests_PlayerFacingReader(UnitTestDatabase& database){ + database.add(); + // LeafGreen on the Switch, captured with a 1080p card. + database.add("PokemonFRLG/PlayerFacingReader/South-Route24.png", Direction::SOUTH); + database.add("PokemonFRLG/PlayerFacingReader/North-Route24.png", Direction::NORTH); + database.add("PokemonFRLG/PlayerFacingReader/South-CeruleanCave.png", Direction::SOUTH); + database.add("PokemonFRLG/PlayerFacingReader/North-CeruleanCave.png", Direction::NORTH); + database.add("PokemonFRLG/PlayerFacingReader/Surfing.png", std::nullopt); + database.add("PokemonFRLG/PlayerFacingReader/Bag.png", std::nullopt); +} + + + +} +} +} diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.h b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.h new file mode 100644 index 0000000000..fb682fde18 --- /dev/null +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.h @@ -0,0 +1,58 @@ +/* Player Facing Reader + * + * From: https://github.com/PokemonAutomation/ + * + * Which way the player faces, read from the overworld. The camera keeps the + * player on the same spot of the screen, so the reader compares that spot + * with the player's standing sprites from pret/pokefirered, on the sprite's + * own pixels only, so the ground behind doesn't matter. The sprites come + * from Packages: Resources/PokemonFRLG/PlayerSprites/red_normal.png is the + * decompilation's graphics/object_events/pics/people/red_normal.png. + * + * Only the walking player is known: surfing and biking use other sprites. + * + */ + +#ifndef PokemonAutomation_PokemonFRLG_PlayerFacingReader_H +#define PokemonAutomation_PokemonFRLG_PlayerFacingReader_H + +#include +#include "Common/Cpp/TestRunners/UnitTestDatabase.h" +#include "CommonFramework/ImageTypes/ImageRGB32.h" +#include "CommonFramework/ImageTypes/ImageViewRGB32.h" +#include "PokemonFRLG_OverworldMap.h" + +namespace PokemonAutomation{ +namespace NintendoSwitch{ +namespace PokemonFRLG{ + + +// The standing player's frames, as the game draws them, with transparent +// pixels at alpha 0. Facing east is facing west mirrored. +struct PlayerSprites{ + ImageRGB32 south; + ImageRGB32 north; + ImageRGB32 west; +}; + +// Loads the frames once. Throws FileException if the file is missing. +const PlayerSprites& player_sprites(); + + +// The way the standing player faces, or nothing if no standing sprite +// matches clearly (surfing, on the bike, in a menu, mid-step). +std::optional read_player_facing(const ImageViewRGB32& screen); + +// The same on `game`, the GBA's 240x160 screen at any scale, already cut +// out of the console's. +std::optional match_player_facing(const ImageViewRGB32& game, const PlayerSprites& sprites); + + +void add_tests_PlayerFacingReader(UnitTestDatabase& database); + + + +} +} +} +#endif diff --git a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp index d51e585590..d5ba6f59bb 100644 --- a/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp +++ b/SerialPrograms/Source/VideoGames/PokemonFRLG/PokemonFRLG_Tests.cpp @@ -23,6 +23,9 @@ #include "Inference/PokemonFRLG_StatsReader.h" #include "Inference/PokemonFRLG_TrainerIdReader.h" #include "Inference/PokemonFRLG_WildEncounterReader.h" +#include "Overworld/PokemonFRLG_OverworldMaps.h" +#include "Overworld/PokemonFRLG_OverworldRoute.h" +#include "Overworld/PokemonFRLG_PlayerFacingReader.h" #include "PokemonFRLG_Navigation.h" #include "PokemonFRLG_Tests.h" @@ -278,6 +281,9 @@ void add_tests(UnitTestDatabase& database){ add_tests_HallOfFameSavingDetector(database); add_tests_BattleMoveSelectionArrow(database); add_tests_ExitWildBattle(database); + add_tests_OverworldMaps(database); + add_tests_OverworldRoute(database); + add_tests_PlayerFacingReader(database); } diff --git a/SerialPrograms/cmake/SourceFiles.cmake b/SerialPrograms/cmake/SourceFiles.cmake index c650d0494d..5d32b4d1ed 100644 --- a/SerialPrograms/cmake/SourceFiles.cmake +++ b/SerialPrograms/cmake/SourceFiles.cmake @@ -1438,6 +1438,9 @@ file(GLOB LIBRARY_SOURCES Source/Pokemon/Navigation/Pokemon_Grid.h Source/Pokemon/Navigation/Pokemon_LegPlanner.h Source/Pokemon/Navigation/Pokemon_LegPlanner_Tests.cpp + Source/Pokemon/Navigation/Pokemon_LegWalker.cpp + Source/Pokemon/Navigation/Pokemon_LegWalker.h + Source/Pokemon/Navigation/Pokemon_LegWalker_Tests.cpp Source/Pokemon/Navigation/Pokemon_Navigation_Tests.h Source/Pokemon/Options/Pokemon_BoxSortingTable.cpp Source/Pokemon/Options/Pokemon_BoxSortingTable.h @@ -1702,6 +1705,8 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Inference/Menus/PokemonFRLG_TrainerCardDetector.h Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleLevelUpReader.cpp Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleLevelUpReader.h + Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.cpp + Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleFlashWatcher.h Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattlePokemonDetector.cpp Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattlePokemonDetector.h Source/VideoGames/PokemonFRLG/Inference/PokemonFRLG_BattleSelectionArrowDetector.cpp @@ -1732,6 +1737,17 @@ file(GLOB LIBRARY_SOURCES Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_CatchFanfareDetector.h Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_ShinySoundDetector.cpp Source/VideoGames/PokemonFRLG/Inference/Sounds/PokemonFRLG_ShinySoundDetector.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMap.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldMaps_Tests.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldRoute.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_OverworldWalk.h + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.cpp + Source/VideoGames/PokemonFRLG/Overworld/PokemonFRLG_PlayerFacingReader.h Source/VideoGames/PokemonFRLG/PokemonFRLG_Navigation.cpp Source/VideoGames/PokemonFRLG/PokemonFRLG_Navigation.h Source/VideoGames/PokemonFRLG/PokemonFRLG_Panels.cpp diff --git a/devtools/maps/frlg_maps.py b/devtools/maps/frlg_maps.py new file mode 100644 index 0000000000..07fe744d0f --- /dev/null +++ b/devtools/maps/frlg_maps.py @@ -0,0 +1,203 @@ +"""Writes FRLG overworld maps as JSON, from the pret/pokefirered decompilation. + +Usage: + python3 devtools/maps/frlg_maps.py --out Packages/Resources/PokemonFRLG/Maps \ + CeruleanCity_PokemonCenter_1F CeruleanCity Route24 \ + CeruleanCave_1F CeruleanCave_2F CeruleanCave_B1F + +Downloads each map's data/maps//map.json, its layout's map.bin and its +tilesets' metatile_attributes.bin at --commit into --cache, then writes +/.json in the schema of Packages/Resources/PokemonFRLG/Maps/README.md. +Warps and connections lead only to maps in the same run: others get an empty +dest_map, or are left out. Named points and flag sets come from POINTS and +FLAG_SETS below. +""" +import argparse +import json +import os +import re +import struct +import subprocess + +COMMIT = 'ce6406d8c2eaf46d543c8a2606de0c3cb1951982' + +# Behaviors with their own tile character; see the README for the legend. +TERRAIN = { + 'MB_TALL_GRASS': '"', 'MB_WARP_DOOR': 'D', 'MB_CAVE_DOOR': 'C', + 'MB_LADDER': 'L', 'MB_UP_ESCALATOR': 'L', 'MB_DOWN_ESCALATOR': 'L', + 'MB_SOUTH_ARROW_WARP': 'v', 'MB_NORTH_ARROW_WARP': '^', + 'MB_EAST_ARROW_WARP': '>', 'MB_WEST_ARROW_WARP': '<', + # Stairs at the side of a room warp like an exit mat, the way the stairs + # go up or down (IsDirectionalStairWarpMetatileBehavior()). + 'MB_UP_RIGHT_STAIR_WARP': '>', 'MB_DOWN_RIGHT_STAIR_WARP': '>', + 'MB_UP_LEFT_STAIR_WARP': '<', 'MB_DOWN_LEFT_STAIR_WARP': '<', + 'MB_IMPASSABLE_NORTH': 'n', 'MB_IMPASSABLE_SOUTH': 's', + 'MB_IMPASSABLE_EAST': 'e', 'MB_IMPASSABLE_WEST': 'w', +} +FLOOR = ('MB_NORMAL', 'MB_CAVE', 'MB_ROCK_STAIRS', 'MB_TALL_GRASS') +DIRECTIONS = {'up': 'north', 'down': 'south', 'left': 'west', 'right': 'east'} + +# Places and flag sets the decompilation doesn't name, written into the map +# they belong to. +# A `*_front` point is where the player stands to talk to someone across a +# counter, or to walk north through a door. +POINTS = { + # Where a new game starts (WarpToPlayersRoom() in src/new_game.c). + 'PalletTown_PlayersHouse_2F': {'start': (6, 6)}, + 'PalletTown': {'oaks_lab_front': (16, 14)}, + # The clerk is across the counter to the west. + 'ViridianCity_Mart': {'clerk_front': (4, 3)}, + 'ViridianCity_PokemonCenter_1F': {'nurse_front': (7, 4)}, + 'PewterCity': {'gym_front': (15, 17)}, + 'PewterCity_PokemonCenter_1F': {'nurse_front': (7, 4)}, + 'PewterCity_Gym': {'brock_front': (6, 6)}, + # The tile in front of the PC. + 'CeruleanCity_PokemonCenter_1F': {'pc': (11, 2), 'nurse_front': (7, 4)}, + # Where the player stands after entering from Cerulean City. + 'CeruleanCave_1F': {'entrance': (33, 21)}, + # Mewtwo's tile. Its battle starts by talking to it from the south. + 'CeruleanCave_B1F': {'mewtwo': (7, 12)}, +} +FLAG_SETS = { + # Set once the player has entered the Hall of Fame. They clear the way + # to Cerulean Cave: its guard, the rival and the Rocket. + 'CeruleanCity': {'after_hall_of_fame': [ + 'FLAG_HIDE_CERULEAN_CAVE_GUARD', + 'FLAG_HIDE_CERULEAN_RIVAL', + 'FLAG_HIDE_CERULEAN_ROCKET', + ]}, +} + + +class Decomp: + def __init__(self, commit, cache): + self.raw = 'https://raw.githubusercontent.com/pret/pokefirered/' + commit + self.cache = os.path.join(cache, commit) + + def path(self, rel): + local = os.path.join(self.cache, rel) + if not os.path.exists(local): + os.makedirs(os.path.dirname(local), exist_ok=True) + subprocess.run(['curl', '-sfL', f'{self.raw}/{rel}', '-o', local], check=True) + return local + + def read(self, rel): + with open(self.path(rel), 'rb') as file: + return file.read() + + +def behaviors(decomp): + names = {} + for line in decomp.read('include/constants/metatile_behaviors.h').decode().splitlines(): + match = re.match(r'#define (MB_\w+)\s+(0x[0-9A-Fa-f]+|\d+)', line) + if match: + names[int(match.group(2), 0)] = match.group(1) + return names + + +def tileset_attributes(decomp, symbol, kind): + name = re.sub(r'([a-z])([A-Z0-9])', r'\1_\2', symbol.replace('gTileset_', '')).lower() + data = decomp.read(f'data/tilesets/{kind}/{name}/metatile_attributes.bin') + return [struct.unpack_from('> 10) & 3, value >> 12 + attributes = primary[metatile] if metatile < 640 else secondary[metatile - 640] + behavior = names.get(attributes & 0x1ff, hex(attributes & 0x1ff)) + row += tile_character(behavior, collision, unhandled) + levels += '%x' % level + tiles.append(row) + elevation.append(levels) + return width, height, tiles, elevation + + +def wander(obj): + movement = obj.get('movement_type', '') + if 'WANDER' not in movement: + return 0, 0 + x = 0 if 'UP_AND_DOWN' in movement else int(obj.get('movement_range_x') or 0) + y = 0 if 'LEFT_AND_RIGHT' in movement else int(obj.get('movement_range_y') or 0) + return x, y + + +def map_json(decomp, name, layouts, names, loaded, unhandled): + source = json.loads(decomp.read(f'data/maps/{name}/map.json')) + width, height, tiles, elevation = grid(decomp, layouts[source['layout']], names, unhandled) + objects = [] + for obj in source.get('object_events', []): + flag = obj.get('flag') or '' + wander_x, wander_y = wander(obj) + objects.append({ + 'x': obj['x'], 'y': obj['y'], + 'kind': obj['graphics_id'].replace('OBJ_EVENT_GFX_', '').lower(), + 'flag': '' if flag == '0' else flag, + 'wander_x': wander_x, 'wander_y': wander_y, + }) + return { + 'name': name, 'width': width, 'height': height, + 'tiles': tiles, 'elevation': elevation, + 'warps': [ + {'x': w['x'], 'y': w['y'], 'dest_map': loaded.get(w['dest_map'], ''), 'dest_warp': int(w['dest_warp_id'])} + for w in source.get('warp_events', []) + ], + 'objects': objects, + 'connections': [ + {'direction': DIRECTIONS[c['direction']], 'offset': c['offset'], 'map': loaded[c['map']]} + for c in (source.get('connections') or []) if c['map'] in loaded + ], + 'points': [{'name': n, 'x': x, 'y': y} for n, (x, y) in POINTS.get(name, {}).items()], + 'flag_sets': [{'name': n, 'flags': flags} for n, flags in FLAG_SETS.get(name, {}).items()], + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--commit', default=COMMIT) + parser.add_argument('--cache', default=os.path.expanduser('~/.cache/pokefirered')) + parser.add_argument('--out', required=True) + parser.add_argument('maps', nargs='+') + args = parser.parse_args() + + decomp = Decomp(args.commit, args.cache) + names = behaviors(decomp) + layouts = {l['id']: l for l in json.loads(decomp.read('data/layouts/layouts.json'))['layouts'] if 'id' in l} + loaded = {json.loads(decomp.read(f'data/maps/{m}/map.json'))['id']: m for m in args.maps} + unhandled = {} + os.makedirs(args.out, exist_ok=True) + for name in args.maps: + data = map_json(decomp, name, layouts, names, loaded, unhandled) + with open(os.path.join(args.out, name + '.json'), 'w', newline='\n') as out: + json.dump(data, out, indent=4) + out.write('\n') + if unhandled: + print(f'Non-collision behaviors written as floor: {unhandled}') + + +if __name__ == '__main__': + main()