From 543a23ce0a7cda9d0d89e02ca26d1a7c6875d026 Mon Sep 17 00:00:00 2001 From: Martin Prokoph Date: Tue, 21 Jan 2025 15:17:47 +0100 Subject: [PATCH] feat: add pushable box blocks --- src/blockPos.hpp | 3 ++ src/blockRegistry.hpp | 2 ++ src/blockSettings.hpp | 25 ++++++++++---- src/main.cpp | 58 +++++++++++++++++++++++++++++++-- src/movementHandler.hpp | 64 +++++++++++++++++++++++++++++++++++- src/player.hpp | 14 ++++---- src/world.hpp | 72 ++++++++++++++++++++++++++++++++++++++++- worlds/3.txt | 14 ++++---- worlds/4.txt | 14 ++++++++ 9 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 worlds/4.txt diff --git a/src/blockPos.hpp b/src/blockPos.hpp index 1fdf197..2b48075 100644 --- a/src/blockPos.hpp +++ b/src/blockPos.hpp @@ -22,6 +22,9 @@ public: bool isNegative() { return x < 0 || y < 0; } + BlockPos add(int x, int y) { + return BlockPos(this->x + x, this->y + y); + } BlockPos operator+(BlockPos offset) { return BlockPos(this->getX() + offset.getX(), this->getY() + offset.getY()); } diff --git a/src/blockRegistry.hpp b/src/blockRegistry.hpp index 4428aec..235797c 100644 --- a/src/blockRegistry.hpp +++ b/src/blockRegistry.hpp @@ -16,6 +16,7 @@ public: Block GOAL = Block(Identifier("adventura", "goal"), 'O', Color::BRIGHT_GREEN, BlockSettingsBuilder().nonSolid().build()); Block WALL = Block(Identifier("adventura", "wall"), '0', Color::BRIGHT_BLACK, BlockSettingsBuilder().collidable().build()); Block SPIKE = Block(Identifier("adventura", "spike"), '^', Color::BRIGHT_RED, BlockSettingsBuilder().lethal().build()); + Block BOX = Block(Identifier("adventura", "box"), 'x', Color::BRIGHT_CYAN, BlockSettingsBuilder().pushable().collidable().gravity().build()); BlockRegistry() { registerBlock(AIR); @@ -26,6 +27,7 @@ public: registerBlock(GOAL); registerBlock(WALL); registerBlock(SPIKE); + registerBlock(BOX); } const Block getByEncoding(char encoding) { diff --git a/src/blockSettings.hpp b/src/blockSettings.hpp index 1bbb58b..7ea2687 100644 --- a/src/blockSettings.hpp +++ b/src/blockSettings.hpp @@ -7,8 +7,11 @@ class BlockSettings { bool hasCollision() { return hasCollision_; } - bool isMovable() { - return isMovable_; + bool hasGravity() { + return hasGravity_; + } + bool isPushable() { + return isPushable_; } bool isLethal() { return isLethal_; @@ -23,12 +26,15 @@ class BlockSettings { void setSolid(bool isSolid) { this->isSolid_ = isSolid; } - void setMovable(bool isMovable) { - this->isMovable_ = isMovable; + void setPushable(bool isMovable) { + this->isPushable_ = isMovable; } void setCollision(bool hasCollision) { this->hasCollision_ = hasCollision; } + void setGravity(bool hasGravity) { + this->hasGravity_ = hasGravity; + } void setLethal(bool isLethal) { this->isLethal_ = isLethal; } @@ -41,11 +47,12 @@ class BlockSettings { private: bool isSolid_ = true; - bool isMovable_ = false; + bool isPushable_ = false; bool isClimbableFromTop_ = false; bool isClimbableFromBottom_ = false; bool isLethal_ = false; bool hasCollision_ = false; + bool hasGravity_ = false; }; class BlockSettingsBuilder { public: @@ -53,14 +60,18 @@ class BlockSettingsBuilder { blockSettings.setSolid(false); return *this; } - BlockSettingsBuilder movable() { - blockSettings.setMovable(true); + BlockSettingsBuilder pushable() { + blockSettings.setPushable(true); return *this; } BlockSettingsBuilder collidable() { blockSettings.setCollision(true); return *this; } + BlockSettingsBuilder gravity() { + blockSettings.setGravity(true); + return *this; + } BlockSettingsBuilder lethal() { blockSettings.setLethal(true); return *this; diff --git a/src/main.cpp b/src/main.cpp index a2265f6..2587762 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,8 @@ #include #include #include +#include + #include "world.hpp" #include "player.hpp" #include "blockRegistry.hpp" @@ -17,19 +19,40 @@ void jumpBackOneLine(); void printFile(string fileLocation, Color color); bool startWorld(string worldFile); +/** + * Entry point of the program. + * If a world file is provided as an argument, play through that world. + * Otherwise, play through all worlds in the worlds directory. + * In case the player dies during gameplay, exit without printing the victory screen. + * If the player reaches the goal of the final level, print the victory screen and exit. + */ int main(int argc, char *argv[]) { if (argc > 1) { if (!startWorld("./worlds/" + string(argv[1]))) return 0; } else { - for (const auto & entry : fs::directory_iterator("./worlds")) - if (!startWorld(entry.path())) return 0; + vector worlds; + for (auto & entry : fs::directory_iterator("./worlds")) { + worlds.push_back(entry.path()); + } + // We use this to sort the worlds alphabetically, so that the game progresses in the correct order. + std::sort( worlds.begin(), worlds.end(), [](string a, string b) { + return a < b; + }); + for (const auto & world : worlds) + if (!startWorld(world)) return 0; } printFile("./screens/victory.txt", Color::BRIGHT_GREEN); return 0; } +/** + * Start a new world defined in the file at worldFile. + * If the player reaches the goal, return true. + * In case they die, print the death screen and return false. + * @return true if the player reached the goal, false in case of death + */ bool startWorld(string worldFile) { BlockRegistry blockRegistry = BlockRegistry(); World world = World(blockRegistry); @@ -46,10 +69,25 @@ bool startWorld(string worldFile) { if (!player.isAlive()) printFile("./screens/death.txt", Color::BRIGHT_RED); return player.hasReachedGoal(); } +/** + * Move the console cursor up by one line. + * Used to overwrite the previous line. + */ void jumpBackOneLine() { std::cout << "\033[1A"; } +/** + * Redraws the game world and player state on the console. + * This function first moves the console cursor up by the number of lines + * equivalent to the world's height, effectively clearing previous output. + * It then calls the render function to display the current state of the world + * and the player. + * + * @param world Reference to the World object representing the game's world. + * @param player Reference to the Player object representing the player's state. + */ + void redraw(World &world, Player &player) { //std::this_thread::sleep_for(std::chrono::seconds(1)); for (unsigned int y = 0; y <= world.getMaxY()+1; y++) { @@ -58,13 +96,19 @@ void redraw(World &world, Player &player) { render(world, player); } +/** + * Renders the current state of the game world and player onto the console. + * It prints the world's blocks with their respective colors and encodings (characters). + * On positions that overlap with the player texture, the relevant character of the player's texture is printed instead. + */ void render(World &world, Player &player) { vector> canvas = world.getFieldState(); vector> playerTexture = player.mapToWorldspace(); for (unsigned int y = 0; y <= world.getMaxY(); y++) { for (unsigned int x = 0; x <= world.getMaxX(); x++) { - if (playerTexture.size() > y && playerTexture.at(y).size() > x && playerTexture.at(y).at(x) != ' ') { + if (!world.getBlockAt(BlockPos(x, y)).getSettings().isPushable() + && playerTexture.size() > y && playerTexture.at(y).size() > x && playerTexture.at(y).at(x) != ' ') { cout << Color::BRIGHT_YELLOW << playerTexture.at(y).at(x); } else if (canvas.size() > y && canvas.at(y).size() > x) { @@ -75,6 +119,14 @@ void render(World &world, Player &player) { cout << endl; } } +/** + * Prints the content of a file line by line onto the console, + * in the specified color. + * We use this to print our death and victory screens. + * + * @param fileLocation Path to the file to be printed. + * @param color Color to be used for the output. + */ void printFile(string fileLocation, Color color) { cout << color; vector file = readFileAsVector(fileLocation); diff --git a/src/movementHandler.hpp b/src/movementHandler.hpp index d3239e7..94b774c 100644 --- a/src/movementHandler.hpp +++ b/src/movementHandler.hpp @@ -5,6 +5,18 @@ bool tryWalk(World& world, Player& player, bool left); bool tryGoDown(World& world, Player& player); bool tryGoUp(World& world, Player& player); +bool tryPushBlock(BlockPos& blockPos, World& world, bool left); + +/** + * Processes the player's input and attempts to move the player in the game world + * based on the input character. Supports moving up, left, down, or right + * using the keys 'w', 'a', 's', 'd' as well as their upper-case equivalents (useful in case caps lock is pressed by accident). + * + * @param lastChar The character input representing the player's movement command. + * @param world Reference to the World object representing the game's world. + * @param player Reference to the Player object representing the player's state. + * @return true if the player's position was successfully updated, false otherwise. + */ bool onInput(char lastChar, World& world, Player& player) { switch (lastChar) { @@ -28,19 +40,44 @@ bool onInput(char lastChar, World& world, Player& player) { default: return false; } } +/** + * Attempts to move the player one block to the left or right. + * + * Checks if the neighbour block to the player's feet is not a solid block and + * if so, moves the player there. In case that block solid and the block above it + * is not solid, moves the player on top of that block. + * Otherwise, returns false. + * + * @param world Reference to the World object representing the game's world. + * @param player Reference to the Player object representing the player's state. + * @param left Whether to move left (true) or right (false). + * @return true if the player's position was successfully updated, false otherwise. + */ bool tryWalk(World& world, Player& player, bool left) { BlockPos neighbourPosTorso = player.getPos()+(left ? BlockPos(-1, 0) : BlockPos(1, 0)); BlockPos neighbourPosFeet = player.getPos()+(left ? BlockPos(-1, 1) : BlockPos(1, 1)); + tryPushBlock(neighbourPosFeet, world, left); if (!world.getBlockAt(neighbourPosFeet).getSettings().hasCollision()) { player.setPos(neighbourPosTorso); return true; } - else if (!left && world.getBlockAt(neighbourPosFeet).getSettings().hasCollision() && !world.getBlockAt(neighbourPosTorso).getSettings().isSolid()) { + else if (world.getBlockAt(neighbourPosFeet).getSettings().hasCollision() && !world.getBlockAt(neighbourPosTorso).getSettings().isSolid()) { left ? player.move(-1, -1) : player.move(1, -1); return true; } return false; } +/** + * Attempts to move the player one block downwards. + * + * Checks if the block above the player's torso or the block above the player's feet is + * climbable from the top and if so, moves the player there. + * Otherwise, returns false. + * + * @param world Reference to the World object representing the game's world. + * @param player Reference to the Player object representing the player's state. + * @return true if the player's position was successfully updated, false otherwise. + */ bool tryGoDown(World& world, Player& player) { if (world.getBlockAt(player.getPos()+BlockPos(0, 2)).getSettings().isClimbableFromTop() || world.getBlockAt(player.getPos()+BlockPos(0, 3)).getSettings().isClimbableFromTop()) { player.move(0, 1); @@ -48,10 +85,35 @@ bool tryGoDown(World& world, Player& player) { } return false; } +/** + * Attempts to move the player one block upwards. + * + * Checks if the block above the player's torso or the block above the player's head is + * climbable from the bottom and if so, moves the player there. + * Otherwise, returns false. + * + * @param world Reference to the World object representing the game's world. + * @param player Reference to the Player object representing the player's state. + * @return true if the player's position was successfully updated, false otherwise. + */ bool tryGoUp(World& world, Player& player) { if (world.getBlockAt(player.getPos()+BlockPos(0, 1)).getSettings().isClimbableFromBottom() || world.getBlockAt(player.getPos()+BlockPos(0, 2)).getSettings().isClimbableFromBottom()) { player.move(0, -1); return true; } return false; +} +bool tryPushBlock(BlockPos& blockPos, World& world, bool left) { + BlockPos neighbourBlockPos = blockPos+(left ? BlockPos(-1, 0) : BlockPos(1, 0)); + if (world.getBlockAt(blockPos).getSettings().isPushable()) { + if (world.getBlockAt(neighbourBlockPos).getSettings().isPushable()) { + tryPushBlock(neighbourBlockPos, world, left); + } + if (world.getBlockAt(neighbourBlockPos) == world.getBlockRegistry().AIR) { + world.setBlockAt(neighbourBlockPos, world.getBlockAt(blockPos)); + world.setBlockAt(blockPos, world.getBlockRegistry().AIR); + return true; + } + } + return false; } \ No newline at end of file diff --git a/src/player.hpp b/src/player.hpp index 804d166..401420a 100644 --- a/src/player.hpp +++ b/src/player.hpp @@ -11,7 +11,7 @@ public: {' ', 'o', ' '}, {'/', '|', '\\'}, {'/', ' ', '\\'} - }}; // Player pos is at the bottom center + }}; // Player pos is at the center '|' char } BlockPos getPos() { @@ -32,19 +32,19 @@ public: if (world.getBlockAt(pos) == world.getBlockRegistry().GOAL) reachedGoal = true; - - isFreeFalling = !world.getBlockAt(pos+BlockPos(0, 2)).getSettings().isSolid(); + if (world.getBlockAt(pos.add(0, 2)) == world.getBlockRegistry().WATER) fallLength = 0; + + isFreeFalling = !world.getBlockAt(pos.add(0, 2)).getSettings().isSolid(); if (isFreeFalling) { fallLength += 1; - if (world.getBlockAt(pos+BlockPos(0, 2)) == world.getBlockRegistry().WATER) fallLength = 0; - move(BlockPos(0, 1)); + move(0, 1); } else { if (fallLength > 5) alive = false; fallLength = 0; } - if (world.getBlockAt(pos+BlockPos(0, 2)).getSettings().isLethal()) alive = false; + if (world.getBlockAt(pos.add(0, 2)).getSettings().isLethal()) alive = false; } bool isAlive() { return alive; @@ -75,7 +75,7 @@ public: } private: - World world; + World& world; std::array, 3> playerTexture; BlockPos pos = BlockPos(0, 0); bool alive = true; diff --git a/src/world.hpp b/src/world.hpp index c459f57..f9e1e32 100644 --- a/src/world.hpp +++ b/src/world.hpp @@ -10,10 +10,23 @@ using std::vector; class World { public: + /** + * Create a World object using the blocks defined in BlockRegistry. + * + * @param blockRegistry The BlockRegistry to use. + */ World(BlockRegistry blockRegistry) { this->blockRegistry = blockRegistry; } + /** + * Load the world from the given text file. + * - The character '|' is the player's starting position. + * - The characters in the file are mapped to the corresponding blocks in the block registry. + * - All other characters are kept as purely visual decoration blocks. + * + * @param fileLocation The location of the file to load. + */ void loadFromFile(string fileLocation) { field = {}; vector file = readFileAsVector(fileLocation); @@ -27,14 +40,35 @@ public: if (y > maxY) maxY = y; } } + /** + * Sets the block at the given position in the world. + * + * In case the position is outside the current bounds of the world, the world will be automatically be expanded. + * If the position is negative, an error will be logged. + * + * @param pos The position to set the block at. + * @param block The block to set at that position. + */ void setBlockAt(BlockPos pos, Block block) { - if (pos.isNegative()) return; + if (pos.isNegative()) cout << "Tried to set block at negative position: (x: " << pos.getX() << ", y:" << pos.getY() << ")" << endl; while (field.size() <= pos.getUnsignedY()) field.push_back({}); while (field[pos.getUnsignedY()].size() <= pos.getUnsignedX()) field[pos.getUnsignedY()].push_back(blockRegistry.AIR); field[pos.getUnsignedY()][pos.getX()] = block; + if (block.getSettings().hasGravity() && containsPos(pos.add(0, 1)) && getBlockAt(pos.add(0, 1)) == blockRegistry.AIR) { + setBlockAt(pos.add(0, 1), block); + setBlockAt(pos, blockRegistry.AIR); + } } + /** + * Get the block at the given position in the world. + * + * In position is outside the current bounds of the world, the AIR block will be returned. + * + * @param pos The position to get the block at. + * @return The block at that position. + */ Block& getBlockAt(BlockPos pos) { if (pos.getUnsignedY() < field.size() && pos.getUnsignedX() < field[pos.getUnsignedY()].size()) { return field[pos.getY()][pos.getX()]; @@ -42,21 +76,57 @@ public: //cout << "Out of bounds: " << pos.getX() << ", " << pos.getY() << endl; return blockRegistry.AIR; } + + /** + * Checks whether the given position is within the bounds of the world, or not. + * + * @param pos The position to check. + * @return True if the position is non-negative and within the current bounds of the world, false otherwise. + */ bool containsPos(BlockPos pos) { return !pos.isNegative() && pos.getUnsignedY() < field.size(); } + + /** + * @return The current state of the world as a 2D vector of blocks. + */ vector> getFieldState() { return field; } + + /** + * Get the block registry for the world. + * + * @return The block registry containing all registered blocks. + */ BlockRegistry getBlockRegistry() { return blockRegistry; } + + /** + * Get the biggest X (horizontal) value in the world. + * + * @return The maximum X value. + */ unsigned int getMaxX() { return maxX; } + + /** + * Get the biggest Y (vertical) value in the world. + * + * @return The maximum Y value. + */ unsigned int getMaxY() { return maxY; } + + /** + * Get the starting position of the player in the world. + * + * @return The BlockPos representing the starting position in the current level. + */ + BlockPos getStartPos() { return startPos; } diff --git a/worlds/3.txt b/worlds/3.txt index ff349d4..9d0f384 100644 --- a/worlds/3.txt +++ b/worlds/3.txt @@ -1,15 +1,15 @@ ---------------------- - 00 <> - 00 <++> - 00 [+ +] ---------- [ ] -0 o 00------ O [ 7 ] -0 S /|\ 00 0 [ ] + 00 + 00 + 00 +--------- +0 o 00------ O +0 S /|\ 00 0 0 / \ 00 0 --------------- ---------------- 0 H 0 0 H 0 0 H 0 0 H 0 0~~~~~~0---------0 - 00000000 + 00000000 \ No newline at end of file diff --git a/worlds/4.txt b/worlds/4.txt new file mode 100644 index 0000000..d8ce5c9 --- /dev/null +++ b/worlds/4.txt @@ -0,0 +1,14 @@ + + o +S /|\ + / \ + --- + 0 + 0 x x + 0 ------------- + 0 H + H H + H H + H H O +~~~H H +------------------^^^^^---------------- \ No newline at end of file