feat: add pushable box blocks

This commit is contained in:
Martin Prokoph
2025-01-21 15:17:47 +01:00
parent 3c609340f2
commit 543a23ce0a
9 changed files with 240 additions and 26 deletions

View File

@@ -22,6 +22,9 @@ public:
bool isNegative() { bool isNegative() {
return x < 0 || y < 0; return x < 0 || y < 0;
} }
BlockPos add(int x, int y) {
return BlockPos(this->x + x, this->y + y);
}
BlockPos operator+(BlockPos offset) { BlockPos operator+(BlockPos offset) {
return BlockPos(this->getX() + offset.getX(), this->getY() + offset.getY()); return BlockPos(this->getX() + offset.getX(), this->getY() + offset.getY());
} }

View File

@@ -16,6 +16,7 @@ public:
Block GOAL = Block(Identifier("adventura", "goal"), 'O', Color::BRIGHT_GREEN, BlockSettingsBuilder().nonSolid().build()); 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 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 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() { BlockRegistry() {
registerBlock(AIR); registerBlock(AIR);
@@ -26,6 +27,7 @@ public:
registerBlock(GOAL); registerBlock(GOAL);
registerBlock(WALL); registerBlock(WALL);
registerBlock(SPIKE); registerBlock(SPIKE);
registerBlock(BOX);
} }
const Block getByEncoding(char encoding) { const Block getByEncoding(char encoding) {

View File

@@ -7,8 +7,11 @@ class BlockSettings {
bool hasCollision() { bool hasCollision() {
return hasCollision_; return hasCollision_;
} }
bool isMovable() { bool hasGravity() {
return isMovable_; return hasGravity_;
}
bool isPushable() {
return isPushable_;
} }
bool isLethal() { bool isLethal() {
return isLethal_; return isLethal_;
@@ -23,12 +26,15 @@ class BlockSettings {
void setSolid(bool isSolid) { void setSolid(bool isSolid) {
this->isSolid_ = isSolid; this->isSolid_ = isSolid;
} }
void setMovable(bool isMovable) { void setPushable(bool isMovable) {
this->isMovable_ = isMovable; this->isPushable_ = isMovable;
} }
void setCollision(bool hasCollision) { void setCollision(bool hasCollision) {
this->hasCollision_ = hasCollision; this->hasCollision_ = hasCollision;
} }
void setGravity(bool hasGravity) {
this->hasGravity_ = hasGravity;
}
void setLethal(bool isLethal) { void setLethal(bool isLethal) {
this->isLethal_ = isLethal; this->isLethal_ = isLethal;
} }
@@ -41,11 +47,12 @@ class BlockSettings {
private: private:
bool isSolid_ = true; bool isSolid_ = true;
bool isMovable_ = false; bool isPushable_ = false;
bool isClimbableFromTop_ = false; bool isClimbableFromTop_ = false;
bool isClimbableFromBottom_ = false; bool isClimbableFromBottom_ = false;
bool isLethal_ = false; bool isLethal_ = false;
bool hasCollision_ = false; bool hasCollision_ = false;
bool hasGravity_ = false;
}; };
class BlockSettingsBuilder { class BlockSettingsBuilder {
public: public:
@@ -53,14 +60,18 @@ class BlockSettingsBuilder {
blockSettings.setSolid(false); blockSettings.setSolid(false);
return *this; return *this;
} }
BlockSettingsBuilder movable() { BlockSettingsBuilder pushable() {
blockSettings.setMovable(true); blockSettings.setPushable(true);
return *this; return *this;
} }
BlockSettingsBuilder collidable() { BlockSettingsBuilder collidable() {
blockSettings.setCollision(true); blockSettings.setCollision(true);
return *this; return *this;
} }
BlockSettingsBuilder gravity() {
blockSettings.setGravity(true);
return *this;
}
BlockSettingsBuilder lethal() { BlockSettingsBuilder lethal() {
blockSettings.setLethal(true); blockSettings.setLethal(true);
return *this; return *this;

View File

@@ -1,6 +1,8 @@
#include <string> #include <string>
#include <iostream> #include <iostream>
#include <filesystem> #include <filesystem>
#include <algorithm>
#include "world.hpp" #include "world.hpp"
#include "player.hpp" #include "player.hpp"
#include "blockRegistry.hpp" #include "blockRegistry.hpp"
@@ -17,19 +19,40 @@ void jumpBackOneLine();
void printFile(string fileLocation, Color color); void printFile(string fileLocation, Color color);
bool startWorld(string worldFile); 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[]) { int main(int argc, char *argv[]) {
if (argc > 1) { if (argc > 1) {
if (!startWorld("./worlds/" + string(argv[1]))) return 0; if (!startWorld("./worlds/" + string(argv[1]))) return 0;
} }
else { else {
for (const auto & entry : fs::directory_iterator("./worlds")) vector<string> worlds;
if (!startWorld(entry.path())) return 0; 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); printFile("./screens/victory.txt", Color::BRIGHT_GREEN);
return 0; 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) { bool startWorld(string worldFile) {
BlockRegistry blockRegistry = BlockRegistry(); BlockRegistry blockRegistry = BlockRegistry();
World world = World(blockRegistry); World world = World(blockRegistry);
@@ -46,10 +69,25 @@ bool startWorld(string worldFile) {
if (!player.isAlive()) printFile("./screens/death.txt", Color::BRIGHT_RED); if (!player.isAlive()) printFile("./screens/death.txt", Color::BRIGHT_RED);
return player.hasReachedGoal(); return player.hasReachedGoal();
} }
/**
* Move the console cursor up by one line.
* Used to overwrite the previous line.
*/
void jumpBackOneLine() { void jumpBackOneLine() {
std::cout << "\033[1A"; 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) { void redraw(World &world, Player &player) {
//std::this_thread::sleep_for(std::chrono::seconds(1)); //std::this_thread::sleep_for(std::chrono::seconds(1));
for (unsigned int y = 0; y <= world.getMaxY()+1; y++) { for (unsigned int y = 0; y <= world.getMaxY()+1; y++) {
@@ -58,13 +96,19 @@ void redraw(World &world, Player &player) {
render(world, 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) { void render(World &world, Player &player) {
vector<vector<Block>> canvas = world.getFieldState(); vector<vector<Block>> canvas = world.getFieldState();
vector<vector<char>> playerTexture = player.mapToWorldspace(); vector<vector<char>> playerTexture = player.mapToWorldspace();
for (unsigned int y = 0; y <= world.getMaxY(); y++) { for (unsigned int y = 0; y <= world.getMaxY(); y++) {
for (unsigned int x = 0; x <= world.getMaxX(); x++) { 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); cout << Color::BRIGHT_YELLOW << playerTexture.at(y).at(x);
} }
else if (canvas.size() > y && canvas.at(y).size() > x) { else if (canvas.size() > y && canvas.at(y).size() > x) {
@@ -75,6 +119,14 @@ void render(World &world, Player &player) {
cout << endl; 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) { void printFile(string fileLocation, Color color) {
cout << color; cout << color;
vector<string> file = readFileAsVector(fileLocation); vector<string> file = readFileAsVector(fileLocation);

View File

@@ -5,6 +5,18 @@
bool tryWalk(World& world, Player& player, bool left); bool tryWalk(World& world, Player& player, bool left);
bool tryGoDown(World& world, Player& player); bool tryGoDown(World& world, Player& player);
bool tryGoUp(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) { bool onInput(char lastChar, World& world, Player& player) {
switch (lastChar) { switch (lastChar) {
@@ -28,19 +40,44 @@ bool onInput(char lastChar, World& world, Player& player) {
default: return false; 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) { bool tryWalk(World& world, Player& player, bool left) {
BlockPos neighbourPosTorso = player.getPos()+(left ? BlockPos(-1, 0) : BlockPos(1, 0)); BlockPos neighbourPosTorso = player.getPos()+(left ? BlockPos(-1, 0) : BlockPos(1, 0));
BlockPos neighbourPosFeet = player.getPos()+(left ? BlockPos(-1, 1) : BlockPos(1, 1)); BlockPos neighbourPosFeet = player.getPos()+(left ? BlockPos(-1, 1) : BlockPos(1, 1));
tryPushBlock(neighbourPosFeet, world, left);
if (!world.getBlockAt(neighbourPosFeet).getSettings().hasCollision()) { if (!world.getBlockAt(neighbourPosFeet).getSettings().hasCollision()) {
player.setPos(neighbourPosTorso); player.setPos(neighbourPosTorso);
return true; 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); left ? player.move(-1, -1) : player.move(1, -1);
return true; return true;
} }
return false; 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) { 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()) { if (world.getBlockAt(player.getPos()+BlockPos(0, 2)).getSettings().isClimbableFromTop() || world.getBlockAt(player.getPos()+BlockPos(0, 3)).getSettings().isClimbableFromTop()) {
player.move(0, 1); player.move(0, 1);
@@ -48,6 +85,17 @@ bool tryGoDown(World& world, Player& player) {
} }
return false; 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) { 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()) { if (world.getBlockAt(player.getPos()+BlockPos(0, 1)).getSettings().isClimbableFromBottom() || world.getBlockAt(player.getPos()+BlockPos(0, 2)).getSettings().isClimbableFromBottom()) {
player.move(0, -1); player.move(0, -1);
@@ -55,3 +103,17 @@ bool tryGoUp(World& world, Player& player) {
} }
return false; 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;
}

View File

@@ -11,7 +11,7 @@ public:
{' ', 'o', ' '}, {' ', 'o', ' '},
{'/', '|', '\\'}, {'/', '|', '\\'},
{'/', ' ', '\\'} {'/', ' ', '\\'}
}}; // Player pos is at the bottom center }}; // Player pos is at the center '|' char
} }
BlockPos getPos() { BlockPos getPos() {
@@ -32,19 +32,19 @@ public:
if (world.getBlockAt(pos) == world.getBlockRegistry().GOAL) reachedGoal = true; if (world.getBlockAt(pos) == world.getBlockRegistry().GOAL) reachedGoal = true;
if (world.getBlockAt(pos.add(0, 2)) == world.getBlockRegistry().WATER) fallLength = 0;
isFreeFalling = !world.getBlockAt(pos+BlockPos(0, 2)).getSettings().isSolid(); isFreeFalling = !world.getBlockAt(pos.add(0, 2)).getSettings().isSolid();
if (isFreeFalling) { if (isFreeFalling) {
fallLength += 1; fallLength += 1;
if (world.getBlockAt(pos+BlockPos(0, 2)) == world.getBlockRegistry().WATER) fallLength = 0; move(0, 1);
move(BlockPos(0, 1));
} }
else { else {
if (fallLength > 5) alive = false; if (fallLength > 5) alive = false;
fallLength = 0; 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() { bool isAlive() {
return alive; return alive;
@@ -75,7 +75,7 @@ public:
} }
private: private:
World world; World& world;
std::array<std::array<char, 3>, 3> playerTexture; std::array<std::array<char, 3>, 3> playerTexture;
BlockPos pos = BlockPos(0, 0); BlockPos pos = BlockPos(0, 0);
bool alive = true; bool alive = true;

View File

@@ -10,10 +10,23 @@ using std::vector;
class World { class World {
public: public:
/**
* Create a World object using the blocks defined in BlockRegistry.
*
* @param blockRegistry The BlockRegistry to use.
*/
World(BlockRegistry blockRegistry) { World(BlockRegistry blockRegistry) {
this->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) { void loadFromFile(string fileLocation) {
field = {}; field = {};
vector<string> file = readFileAsVector(fileLocation); vector<string> file = readFileAsVector(fileLocation);
@@ -27,14 +40,35 @@ public:
if (y > maxY) maxY = y; 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) { 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.size() <= pos.getUnsignedY()) field.push_back({});
while (field[pos.getUnsignedY()].size() <= pos.getUnsignedX()) field[pos.getUnsignedY()].push_back(blockRegistry.AIR); while (field[pos.getUnsignedY()].size() <= pos.getUnsignedX()) field[pos.getUnsignedY()].push_back(blockRegistry.AIR);
field[pos.getUnsignedY()][pos.getX()] = block; 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) { Block& getBlockAt(BlockPos pos) {
if (pos.getUnsignedY() < field.size() && pos.getUnsignedX() < field[pos.getUnsignedY()].size()) { if (pos.getUnsignedY() < field.size() && pos.getUnsignedX() < field[pos.getUnsignedY()].size()) {
return field[pos.getY()][pos.getX()]; return field[pos.getY()][pos.getX()];
@@ -42,21 +76,57 @@ public:
//cout << "Out of bounds: " << pos.getX() << ", " << pos.getY() << endl; //cout << "Out of bounds: " << pos.getX() << ", " << pos.getY() << endl;
return blockRegistry.AIR; 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) { bool containsPos(BlockPos pos) {
return !pos.isNegative() && pos.getUnsignedY() < field.size(); return !pos.isNegative() && pos.getUnsignedY() < field.size();
} }
/**
* @return The current state of the world as a 2D vector of blocks.
*/
vector<vector<Block>> getFieldState() { vector<vector<Block>> getFieldState() {
return field; return field;
} }
/**
* Get the block registry for the world.
*
* @return The block registry containing all registered blocks.
*/
BlockRegistry getBlockRegistry() { BlockRegistry getBlockRegistry() {
return blockRegistry; return blockRegistry;
} }
/**
* Get the biggest X (horizontal) value in the world.
*
* @return The maximum X value.
*/
unsigned int getMaxX() { unsigned int getMaxX() {
return maxX; return maxX;
} }
/**
* Get the biggest Y (vertical) value in the world.
*
* @return The maximum Y value.
*/
unsigned int getMaxY() { unsigned int getMaxY() {
return maxY; 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() { BlockPos getStartPos() {
return startPos; return startPos;
} }

View File

@@ -1,11 +1,11 @@
---------------------- ----------------------
00 <> 00
00 <++> 00
00 [+ +] 00
--------- [ ] ---------
0 o 00------ O [ 7 ] 0 o 00------ O
0 S /|\ 00 0 [ ] 0 S /|\ 00 0
0 / \ 00 0 --------------- 0 / \ 00 0 ---------------
---------------- 0 H 0 ---------------- 0 H 0
0 H 0 0 H 0

14
worlds/4.txt Normal file
View File

@@ -0,0 +1,14 @@
o
S /|\
/ \
---
0
0 x x
0 -------------
0 H
H H
H H
H H O
~~~H H
------------------^^^^^----------------