/** * paradiso - Paradigmen der Softwareentwicklung * * (c) Copyright 2023-2025 Hartmut Seichter and Contributors * */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace TappyPlane { using SpriteName = std::tuple; using SpriteMap = std::unordered_map; static const unsigned int FRAME_RATE = 60; static const unsigned int WINDOW_WIDTH = 800; static const unsigned int WINDOW_HEIGHT = 480; static const unsigned int WINDOW_SCALE = 2; static const char ACTION_KEY = ' '; static constexpr float SCROLLING_SPEED_INC_FACTOR = 1.0002f; // x,y+h --- x+w,y+h // | | // | | // | | // x,y ----- x+w,y struct AABB { paradiso::Vector2 position{}; paradiso::Vector2 size{}; bool is_colliding_with(const AABB& other) { return position.x() < other.position.x() + other.size.x() && position.x() + size.x() > other.position.x() && position.y() < other.position.y() + other.size.y() && position.y() + size.y() > other.position.y(); } }; struct Background { static constexpr float INIT_SCROLLING_SPEED = 0.003f; float scrolling_speed = INIT_SCROLLING_SPEED; std::array sprites; paradiso::Renderer renderer{}; void init(SpriteMap& sprite_map) { sprites = {paradiso::Sprite{ .bitmap = sprite_map["background"], .pivot = {paradiso::Vector2::make(0.0f, 0.0f)}, .scale = {paradiso::Vector2::make(1.0f, 1.0f)}}, paradiso::Sprite{ .bitmap = sprite_map["background"], .pivot = {paradiso::Vector2::make(2.0f, 0.0f)}, .scale = {paradiso::Vector2::make(1.0f, 1.0f)}}}; } void reset() { scrolling_speed = INIT_SCROLLING_SPEED; sprites[0].pivot.x() = 0.0f; sprites[1].pivot.x() = 2.0f; } void update() { scrolling_speed *= SCROLLING_SPEED_INC_FACTOR; for (auto& sprite : sprites) { sprite.pivot.x() -= scrolling_speed; if (sprite.pivot.x() <= -2.0f) { sprite.pivot.x() += 4.0f; } } } void draw(const paradiso::Shader& shader) { for (const auto& sprite : sprites) { shader.set_uniform("pivot", sprite.pivot); shader.set_uniform("scale", sprite.scale); shader.set_uniform("rotation", sprite.rotation); renderer.draw(sprite, shader); } } }; struct Ground { static constexpr float INIT_SCROLLING_SPEED = 0.009f; float scrolling_speed = INIT_SCROLLING_SPEED; std::array sprites; paradiso::Renderer renderer{}; AABB aabb{}; void init(SpriteMap& sprite_map) { // The image's height is 71 px. float scale_y = 71.0f / WINDOW_HEIGHT; float pivot_y = -1.0f + scale_y; sprites = { paradiso::Sprite{ .bitmap = sprite_map["ground"], .pivot = {paradiso::Vector2::make(0.0f, pivot_y)}, .scale = {paradiso::Vector2::make(1.0f, scale_y)}}, paradiso::Sprite{ .bitmap = sprite_map["ground"], .pivot = {paradiso::Vector2::make(2.0f, pivot_y)}, .scale = {paradiso::Vector2::make(1.0f, scale_y)}}}; // We'll make the height half the height of the texture. // (If you want 1:1 size, you need to double the scale of the sprite.) aabb = AABB{.position = {paradiso::Vector2::make(-1.0f, -1.0f)}, .size = {paradiso::Vector2::make(2.0f, scale_y)}}; } void reset() { scrolling_speed = INIT_SCROLLING_SPEED; sprites[0].pivot.x() = 0.0f; sprites[1].pivot.x() = 2.0f; } void update() { scrolling_speed *= SCROLLING_SPEED_INC_FACTOR; for (auto& sprite : sprites) { sprite.pivot.x() -= scrolling_speed; if (sprite.pivot.x() <= -2.0f) { sprite.pivot.x() += 4.0f; } } } void draw(const paradiso::Shader& shader) { for (const auto& sprite : sprites) { shader.set_uniform("pivot", sprite.pivot); shader.set_uniform("scale", sprite.scale); shader.set_uniform("rotation", sprite.rotation); renderer.draw(sprite, shader); } } }; struct Rocks { static constexpr float INIT_SCROLLING_SPEED = 0.006f; static constexpr float GAP_SIZE = 1.5f; // The image's size is 108x239 px. static constexpr float SCALE_X = 108.0f / WINDOW_WIDTH; static constexpr float SCALE_Y = 239.0f / WINDOW_HEIGHT; static constexpr paradiso::Vector2 SCALE{ paradiso::Vector2::make(SCALE_X, SCALE_Y)}; float scrolling_speed = INIT_SCROLLING_SPEED; paradiso::Sprite top_sprite; paradiso::Sprite bottom_sprite; paradiso::Renderer top_renderer{}; paradiso::Renderer bottom_renderer{}; AABB top_aabb; AABB bottom_aabb; float init_pivot_x = 0.0f; auto get_random_top_position_y() -> float { return 1.0f - SCALE_Y + (std::rand() % 50) / 100.0f + 0.01f; } void init_aabbs() { // We'll make the width a quarter the width of the texture. // (If you want 1:1 size, you need to double the scale of the sprite.) auto size = paradiso::Vector2{ paradiso::Vector2::make(SCALE_X / 2, SCALE_Y * 2)}; auto top_position = paradiso::Vector2{paradiso::Vector2::make( top_sprite.pivot.x() - size.x() / 2, top_sprite.pivot.y() - size.y() / 2)}; auto bottom_position = paradiso::Vector2{paradiso::Vector2::make( bottom_sprite.pivot.x() - size.x() / 2, bottom_sprite.pivot.y() - size.y() / 2)}; top_aabb = AABB{top_position, size}; bottom_aabb = AABB{bottom_position, size}; } void init(SpriteMap& sprite_map, float pivot_x) { init_pivot_x = pivot_x; float top_position_y = get_random_top_position_y(); float bottom_position_y = top_position_y - GAP_SIZE; top_sprite = paradiso::Sprite{.bitmap = sprite_map["rocks.top"], .pivot = {paradiso::Vector2::make( init_pivot_x, top_position_y)}, .scale = SCALE}; bottom_sprite = paradiso::Sprite{.bitmap = sprite_map["rocks.bottom"], .pivot = {paradiso::Vector2::make( init_pivot_x, bottom_position_y)}, .scale = SCALE}; init_aabbs(); } void reset() { scrolling_speed = INIT_SCROLLING_SPEED; float top_position_y = get_random_top_position_y(); float bottom_position_y = top_position_y - GAP_SIZE; top_sprite.pivot = paradiso::Vector2{ paradiso::Vector2::make(init_pivot_x, top_position_y)}; bottom_sprite.pivot = paradiso::Vector2{ paradiso::Vector2::make(init_pivot_x, bottom_position_y)}; init_aabbs(); } void jump_back() { float top_position_y = get_random_top_position_y(); float bottom_position_y = top_position_y - GAP_SIZE; top_sprite.pivot = paradiso::Vector2{ paradiso::Vector2::make(1.2f, top_position_y)}; bottom_sprite.pivot = paradiso::Vector2{ paradiso::Vector2::make(1.2f, bottom_position_y)}; init_aabbs(); } void update() { scrolling_speed *= SCROLLING_SPEED_INC_FACTOR; top_sprite.pivot.x() -= scrolling_speed; bottom_sprite.pivot.x() -= scrolling_speed; // +----------+ // | | // | | // | # #-------- the x it is. // | \ | // | \ | // +-----\----+ // \ // *------ the x we need. // @Efficiency: We could just scroll the AABBs with the sprites. top_aabb.position.x() = top_sprite.pivot.x() - top_aabb.size.x() / 2; bottom_aabb.position.x() = bottom_sprite.pivot.x() - bottom_aabb.size.x() / 2; if (top_sprite.pivot.x() <= -1.2f) { // Reset. jump_back(); return; } } void draw(const paradiso::Shader& shader) { shader.set_uniform("pivot", top_sprite.pivot); shader.set_uniform("scale", top_sprite.scale); shader.set_uniform("rotation", top_sprite.rotation); top_renderer.draw(top_sprite, shader); shader.set_uniform("pivot", bottom_sprite.pivot); shader.set_uniform("scale", bottom_sprite.scale); shader.set_uniform("rotation", bottom_sprite.rotation); bottom_renderer.draw(bottom_sprite, shader); } }; struct Plane { static constexpr float GRAVITY = -0.001f; static constexpr float JUMP_VELOCITY = 0.03f; static constexpr float MAX_VELOCITY = 0.03f; static constexpr float INIT_POSITION_Y = 0.0f; static constexpr float INIT_VELOCITY_Y = 0.0f; static constexpr float INIT_ROTATION = 0.0f; static constexpr paradiso::Vector2 INIT_PIVOT{ paradiso::Vector2::make(0.0f, INIT_POSITION_Y)}; static const unsigned int ANIM_INTERVAL = 10; // The image's size is 88x73 px. static constexpr float SCALE_X = 88.0f / WINDOW_WIDTH; static constexpr float SCALE_Y = 73.0f / WINDOW_HEIGHT; static constexpr paradiso::Vector2 SCALE{ paradiso::Vector2::make(SCALE_X, SCALE_Y)}; // Animation sprites std::array sprites; std::array renderers; unsigned int anim_counter = 0; // The direction where 'current_sprite' should go because the animation // shouldn't loop. int anim_direction = 1; unsigned int current_sprite = 0; float position_y = INIT_POSITION_Y; float velocity_y = INIT_VELOCITY_Y; float rotation = INIT_ROTATION; AABB aabb; void init_aabb() { // We'll make the size half the size of the texture. // (If you want 1:1 size, you need to double the scale of the sprite.) // We could use 'SCALE' here, but we'll keep this for clarity. // (Although, now we no longer need 'SCALE' as a constant in this entire struct.) auto size = paradiso::Vector2{ paradiso::Vector2::make(SCALE_X, SCALE_Y)}; // Center it. auto position = paradiso::Vector2{paradiso::Vector2::make( INIT_PIVOT.x() - size.x() / 2, INIT_PIVOT.y() - size.y() / 2)}; aabb = AABB{position, size}; } void init(SpriteMap& sprite_map) { sprites = {paradiso::Sprite{.bitmap = sprite_map["plane.1"], .pivot = INIT_PIVOT, .scale = SCALE}, paradiso::Sprite{.bitmap = sprite_map["plane.2"], .pivot = INIT_PIVOT, .scale = SCALE}, paradiso::Sprite{.bitmap = sprite_map["plane.3"], .pivot = INIT_PIVOT, .scale = SCALE}}; init_aabb(); } void reset() { for (auto& sprite : sprites) { sprite.pivot = INIT_PIVOT; } position_y = INIT_POSITION_Y; velocity_y = INIT_VELOCITY_Y; rotation = INIT_ROTATION; init_aabb(); } void update_animation() { if (anim_counter < ANIM_INTERVAL) { anim_counter++; } else { anim_counter = 0; current_sprite += anim_direction; if (current_sprite >= sprites.size() - 1) { anim_direction = -1; } else if (current_sprite == 0) { anim_direction = 1; } } } void update(const paradiso::Window::KeyboardInputStack& input) { update_animation(); if (input.size() && input.top().key == ACTION_KEY && input.top().action == 1) { velocity_y += JUMP_VELOCITY; } else { velocity_y += GRAVITY; } velocity_y = std::clamp(velocity_y, -MAX_VELOCITY, MAX_VELOCITY); position_y += velocity_y; // +-------------+ // | | // | | // | | // | #--------- the y it is. // | | // | # | // | \ | // +--------\----+ // \ // *--- the y we need. // @Efficiency: We could just add the velocity to the AABB's y position. aabb.position.y() = position_y - aabb.size.y() / 2; rotation = velocity_y * 500.0f; } void draw(const paradiso::Shader& shader) { auto sprite = sprites[current_sprite]; sprite.rotation = rotation * (std::numbers::pi / 180.0f); sprite.pivot.y() = position_y; shader.set_uniform("pivot", sprite.pivot); shader.set_uniform("scale", sprite.scale); shader.set_uniform("rotation", sprite.rotation); renderers[current_sprite].draw(sprite, shader); } }; struct StartUI { static const unsigned int TAP_ANIM_INTERVAL = 40; // textGetReady.png, tapLeft.png, tapRight.png std::array base_sprites; std::array base_renderers; // tap.png, tapTick.png std::array tap_sprites; std::array tap_renderers; unsigned int tap_anim_counter = 0; unsigned int current_tap_sprite = 0; void init(SpriteMap& sprite_map) { base_sprites = { paradiso::Sprite{ .bitmap = sprite_map["start_ui.text"], .pivot = {paradiso::Vector2::make(0.0f, 0.5f)}, // The image's size is 400x73 px. .scale = {paradiso::Vector2::make( 400.0f / WINDOW_WIDTH, 73.0f / WINDOW_HEIGHT)}}, paradiso::Sprite{ .bitmap = sprite_map["tap_sign.left"], .pivot = {paradiso::Vector2::make(-0.5f, 0.0f)}, // The image's size is 85x42 px. .scale = {paradiso::Vector2::make( 85.0f / WINDOW_WIDTH, 42.0f / WINDOW_HEIGHT)}}, paradiso::Sprite{ .bitmap = sprite_map["tap_sign.right"], .pivot = {paradiso::Vector2::make(0.5f, 0.0f)}, // The image's size is 85x42 px. .scale = {paradiso::Vector2::make( 85.0f / WINDOW_WIDTH, 42.0f / WINDOW_HEIGHT)}}, }; tap_sprites = { paradiso::Sprite{ .bitmap = sprite_map["tap.normal"], .pivot = {paradiso::Vector2::make(0.0f, -0.5f)}, // The image's size is 59x59 px. .scale = {paradiso::Vector2::make( 59.0f / WINDOW_WIDTH, 59.0f / WINDOW_HEIGHT)}}, paradiso::Sprite{ .bitmap = sprite_map["tap.tick"], .pivot = {paradiso::Vector2::make(0.0f, -0.5f)}, // The image's size is 59x59 px. .scale = {paradiso::Vector2::make( 59.0f / WINDOW_WIDTH, 59.0f / WINDOW_HEIGHT)}}, }; } void update() { if (tap_anim_counter < TAP_ANIM_INTERVAL) { tap_anim_counter++; } else { tap_anim_counter = 0; current_tap_sprite = (current_tap_sprite + 1) % tap_sprites.size(); } } void draw(const paradiso::Shader& shader) { for (unsigned int i = 0; i < base_sprites.size(); i++) { auto sprite = base_sprites[i]; auto& renderer = base_renderers[i]; shader.set_uniform("pivot", sprite.pivot); shader.set_uniform("scale", sprite.scale); shader.set_uniform("rotation", sprite.rotation); renderer.draw(sprite, shader); } auto tap_sprite = tap_sprites[current_tap_sprite]; auto& tap_renderer = tap_renderers[current_tap_sprite]; shader.set_uniform("pivot", tap_sprite.pivot); shader.set_uniform("scale", tap_sprite.scale); shader.set_uniform("rotation", tap_sprite.rotation); tap_renderer.draw(tap_sprite, shader); } }; struct GameOverUI { paradiso::Sprite sprite; paradiso::Renderer renderer{}; void init(SpriteMap& sprite_map) { // The image's size is 412x78 px. float scale_x = 412.0f / WINDOW_WIDTH; float scale_y = 78.0f / WINDOW_HEIGHT; sprite = {.bitmap = sprite_map["game_over_ui.text"], .pivot = {paradiso::Vector2::make(0.0f, 0.0f)}, .scale = {paradiso::Vector2::make(scale_x, scale_y)}}; } void update() { // @ToDo: Make the sprite slide down. } void draw(const paradiso::Shader& shader) { shader.set_uniform("pivot", sprite.pivot); shader.set_uniform("scale", sprite.scale); shader.set_uniform("rotation", sprite.rotation); renderer.draw(sprite, shader); } }; enum class GameState { Start, Playing, GameOver }; struct App { SpriteMap sprites; enum class WorldType { Dirt, Grass, Ice, Rock, Snow }; enum class PlaneType { Blue, Green, Red, Yellow }; static auto create(WorldType world_type = WorldType::Grass, PlaneType plane_type = PlaneType::Red) -> App { auto app = App{}; paradiso::BitmapIO::get().set_path( paradiso::get_executable_path().parent_path().string() + "/assets"); // Yes, I'm lazy, how did you know? const char *ground_filename = nullptr; const char *rocks_top_filename = nullptr; const char *rocks_bottom_filename = nullptr; switch (world_type) { case WorldType::Dirt: ground_filename = "PNG/groundDirt.png"; rocks_top_filename = "PNG/rockDown.png"; rocks_bottom_filename = "PNG/rock.png"; break; case WorldType::Ice: ground_filename = "PNG/groundIce.png"; rocks_top_filename = "PNG/rockIceDown.png"; rocks_bottom_filename = "PNG/rockIce.png"; break; case WorldType::Rock: ground_filename = "PNG/groundRock.png"; rocks_top_filename = "PNG/rockDown.png"; rocks_bottom_filename = "PNG/rock.png"; break; case WorldType::Snow: ground_filename = "PNG/groundSnow.png"; rocks_top_filename = "PNG/rockSnowDown.png"; rocks_bottom_filename = "PNG/rockSnow.png"; break; default: case WorldType::Grass: ground_filename = "PNG/groundGrass.png"; rocks_top_filename = "PNG/rockGrassDown.png"; rocks_bottom_filename = "PNG/rockGrass.png"; break; } // Yes, I'm lazy, how did you know? const char *plane_1_filename = nullptr; const char *plane_2_filename = nullptr; const char *plane_3_filename = nullptr; switch (plane_type) { case PlaneType::Blue: plane_1_filename = "PNG/Planes/planeBlue1.png"; plane_2_filename = "PNG/Planes/planeBlue2.png"; plane_3_filename = "PNG/Planes/planeBlue3.png"; break; case PlaneType::Green: plane_1_filename = "PNG/Planes/planeGreen1.png"; plane_2_filename = "PNG/Planes/planeGreen2.png"; plane_3_filename = "PNG/Planes/planeGreen3.png"; break; case PlaneType::Yellow: plane_1_filename = "PNG/Planes/planeYellow1.png"; plane_2_filename = "PNG/Planes/planeYellow2.png"; plane_3_filename = "PNG/Planes/planeYellow3.png"; break; default: case PlaneType::Red: plane_1_filename = "PNG/Planes/planeRed1.png"; plane_2_filename = "PNG/Planes/planeRed2.png"; plane_3_filename = "PNG/Planes/planeRed3.png"; break; } auto assets = std::array{ SpriteName{"background", "PNG/background.png"}, SpriteName{"ground", ground_filename}, SpriteName{"rocks.top", rocks_top_filename}, SpriteName{"rocks.bottom", rocks_bottom_filename}, SpriteName{"plane.1", plane_1_filename}, SpriteName{"plane.2", plane_2_filename}, SpriteName{"plane.3", plane_3_filename}, SpriteName{"tap_sign.left", "PNG/UI/tapRight.png"}, SpriteName{"tap_sign.right", "PNG/UI/tapLeft.png"}, SpriteName{"tap.normal", "PNG/UI/tap.png"}, SpriteName{"tap.tick", "PNG/UI/tapTick.png"}, SpriteName{"start_ui.text", "PNG/UI/textGetReady.png"}, SpriteName{"game_over_ui.text", "PNG/UI/textGameOver.png"}, }; for (const auto& [name, filename] : assets) { std::print("{} : {} -> ", name, filename); auto bitmap = paradiso::BitmapIO::get().load(filename); app.sprites[name] = bitmap; } return app; } void run() { auto canvas_size = paradiso::Size{.width = WINDOW_WIDTH * WINDOW_SCALE, .height = WINDOW_HEIGHT * WINDOW_SCALE}; // Unser Fenster, auf das wir rendern. auto window = paradiso::Window(); window .set_size(canvas_size) // ... Größe .set_position(paradiso::Point{.x = 200, .y = 200}) // ... Position .set_title("ParadiSO.TappyPlane") // ... Titel .set_visible(true); // ... und jetzt anzeigen! // Der Fenster Context. auto context = paradiso::Context{}; // Ein Shader (Schattierungsprogramm). auto shader = paradiso::Shader{}; // Wir nutzen einen vorgefertigten Shader, der speziell für Sprites ist. shader.load_preset(paradiso::Shader::Preset::Sprite); // Ein Viewport stellt die Sicht der Kamera dar, d.h. bei quadratischen // Pixeln sollte hier auch eine dementsprechende Größe eingestellt // werden. context.set_viewport(paradiso::Rectangle{ .position = paradiso::Point{.x = 0, .y = 0}, .size = canvas_size}); // Conflower blue context.set_clearcolor(paradiso::RGBA::from_rgb(0x64, 0x95, 0xED)); // Wir initialisieren unsere Sachen. auto game_state = GameState::Start; auto background = Background{}; auto ground = Ground{}; auto rocks1 = Rocks{}; auto rocks2 = Rocks{}; auto plane = Plane{}; auto start_ui = StartUI{}; auto game_over_ui = GameOverUI{}; background.init(sprites); ground.init(sprites); rocks1.init(sprites, 1.2f); rocks2.init(sprites, 2.4f); plane.init(sprites); start_ui.init(sprites); game_over_ui.init(sprites); auto top_aabb = AABB{.position = {paradiso::Vector2::make(-1.0f, 1.0f)}, .size = {paradiso::Vector2::make(2.0f, 0.5f)}}; while (window.update([&](paradiso::Window& window) -> bool { auto t1 = std::chrono::high_resolution_clock::now(); if (window.keyboard_input().size() && window.keyboard_input().top().key == 'Q') { // Update-Loop beenden. return false; } if (game_state == GameState::Start) { if (window.keyboard_input().size() && window.keyboard_input().top().key == ACTION_KEY && window.keyboard_input().top().action == 1) { game_state = GameState::Playing; // Make the plane jump for the first time. // @ToDo: The plane jumps too high because there was no // gravity before. plane.update(window.keyboard_input()); } background.update(); plane.update_animation(); // Only the animation. ground.update(); start_ui.update(); } else if (game_state == GameState::Playing) { background.update(); ground.update(); rocks1.update(); rocks2.update(); plane.update(window.keyboard_input()); if (plane.aabb.is_colliding_with(rocks1.top_aabb) || plane.aabb.is_colliding_with(rocks1.bottom_aabb) || plane.aabb.is_colliding_with(rocks2.top_aabb) || plane.aabb.is_colliding_with(rocks2.bottom_aabb) || plane.aabb.is_colliding_with(top_aabb) || plane.aabb.is_colliding_with(ground.aabb)) { game_state = GameState::GameOver; } } else if (game_state == GameState::GameOver) { if (window.keyboard_input().size() && window.keyboard_input().top().key == ACTION_KEY) { // Reset everything. background.reset(); ground.reset(); rocks1.reset(); rocks2.reset(); plane.reset(); game_state = GameState::Start; } } context.set_viewport( paradiso::Rectangle{.position = paradiso::Point{.x = 0, .y = 0}, .size = canvas_size}); context.clear(); background.draw(shader); plane.draw(shader); rocks1.draw(shader); rocks2.draw(shader); ground.draw(shader); // Kinda dumb that we check the game state here the second time but // I like to seperate 'update' from 'draw'. if (game_state == GameState::Start) { start_ui.draw(shader); } else if (game_state == GameState::GameOver) { game_over_ui.draw(shader); } // Einen kurzen Moment warten, um auf 60 FPS zu kommen. auto t2 = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration_cast(t2 - t1); auto wait = std::chrono::microseconds(1000000 / FRAME_RATE) - duration; std::this_thread::sleep_for(wait); return true; })) { }; } }; } // namespace TappyPlane auto main() -> int { std::srand(std::time(nullptr)); auto world_type = TappyPlane::App::WorldType::Grass; auto plane_type = TappyPlane::App::PlaneType::Red; auto app = TappyPlane::App::create(world_type, plane_type); app.run(); return 0; }