diff --git a/docs.md b/docs.md new file mode 100644 index 0000000..55bd4cd --- /dev/null +++ b/docs.md @@ -0,0 +1,624 @@ +# Game Math/C++/Calculating The Future + +Sara Gerretsen + +https://git.saragerretsen.nl/Sara/kernmodule-cpp + +# Game Style + +Speed racer (with full 2d motion). + +# Math + +Other than all the basic binary vector operations, my `Vecf` 2d vector class implements the following: + +Only thing particularly of-note is the SDL_FPoint conversion operator. Used to simplify interaction with the `SDL_Render` API. Most of this was copied from my [previous 2d vector implementation]([cutes/vmath.h at main - cutes - Sara's Gitea](https://git.saragerretsen.nl/Sara/cutes/src/branch/main/vmath.h), with some minor modifications and updates as well as translating it to be C++ rather than C. + +```cpp +struct Vecf { + float x{0.f}, y{0.f}; + + operator SDL_FPoint(); + //! squared distance between two vectors as points. Use for comparing distances efficiently. + static float sqr_distance(Vecf const &from, Vecf const &to); + //! distance between two vectors as points + static float distance(Vecf const &from, Vecf const &to); + //! compare within an epsilon + static bool equals_approximate(Vecf const &lhs, Vecf const &rhs); + //! scalar member-wise multiplication product + static float dot(Vecf const &lhs, Vecf const &rhs); + //! unsigned angle difference in radians between lhs and rhs + static float angle_between(Vecf const &lhs, Vecf const &rhs); + //! interpolate linearly between two points + static Vecf lerp(Vecf const &from, Vecf const &to, float t); + //! move towards a point by a set unit distance + static Vecf move_towards(Vecf const &from, Vecf const &to, float delta); + //! calculate the outgoing velocity of an object reflecting against a surface + static Vecf reflect(Vecf const &in, Vecf const &normal); + //! magnitude (a.k.a length or absolute) of this vector + float magnitude() const; + //! square of the magnitude, use for comparing lengths of vectors efficiently + float sqr_magnitude() const; + //! vector perpendicular to this one + Vecf perpendicular() const; + //! rotate vector by t Radians + Vecf rotated(float t) const; + //! vector pointing in the same direction with a length of 1. Or, vector divided by it's magnitude + Vecf normalized() const; + //! reverse scale of vector + Vecf reciprocal() const; + //! returns true if either the x or y element is NaN + bool is_nan() const; + + Vecf clamp_magnitude(float max) const; + //! scale vector member-wise + void scale(float x, float y); + //! scale vector member-wise + void scale(Vecf const &factors); + Vecf scaled(Vecf const &factor) const; + + static Vecf const RIGHT; + static Vecf const UP; + static Vecf const ONE; + static Vecf const POSITIVE_INFINITY; + static Vecf const ZERO; +}; + +``` + +# General Structure: + +The core systems of the project make extensive use of C++ smart pointers for memory management. There are no *owning* raw pointers. Though many raw pointers are used to access smart-pointer-managed memory. Basically everything in the `core/` directory will be part of the `ce` namespace, short for `CanvasEngine`. + +I decided to try writing purely C++20 as standardised. (Hence no `#pragma`, oops) + +## CanvasEngine + +Named after the (now-discontinued) "engine" used for teaching C# basics at GLU. + +Represents the main application state and provides an interface to the engine subsystems. + +```cpp +void CanvasEngine::run(std::unique_ptr &level) { + if(!stay_open) + return; + assert(CanvasEngine::singleton_instance == nullptr && "Engine singleton instance already assigned, starting another instance is invalid"); + // register as singleton + CanvasEngine::singleton_instance = this; + // take ownership of and instantiate level + this->level = std::move(level); + if(!this->level->is_instantiated()) + this->level->instantiate(); + // start tracking time + this->frame_start_time = this->last_frame_start_time = SDL_GetTicks64(); + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "CANVAS: starting"); + // main application loop + while(stay_open) { + // track frame time + this->frame_start_time = SDL_GetTicks64(); + this->delta_time = double(this->frame_start_time - this->last_frame_start_time) / 1000.f; + // process events + this->process_events(); + // update application implementation + if(this->delta_time > this->target_delta_time) { + this->tick(this->delta_time); + this->last_frame_start_time = this->frame_start_time; + this->draw(render); + SDL_RenderPresent(this->render); + } else { + SDL_Delay(2); + } + if(this->next_level) { + this->level = std::move(this->next_level); + this->level->instantiate(); + } + } + assert(CanvasEngine::singleton_instance == this && "Engine singleton instance changed while game was running"); + CanvasEngine::singleton_instance = nullptr; +} + +``` + + + +## Hierarchy + +The world is made up of instances of, or inheriting from, `Node`. `Node` defines the interface required for gameplay functionality and rendering, without implementing it. + +The majority of actual functionality in `Node` is in the `propagate_` functions. As they're responsible for pushing notifications like _tick and _added to child objects. + +```cpp +class Node { +public: +friend class Level; +typedef std::unique_ptr OwnedPtr; +typedef std::vector> ChildrenVector; +private: + Node *parent{nullptr}; + bool inside_tree{false}; + bool request_deletion{false}; + bool has_ticked{false}; + std::string name{}; + ChildrenVector children{}; + bool visible{true}; + bool tick{true}; + ce::Level *level{nullptr}; +public: + Signal<> destroyed{}; //!< Signal invoked by the destructor + Signal child_removed{}; //!< Signal invoked when a child is removed. + Signal child_added{}; //!< Signal invoked when a child is added. +public: + Node(std::string name); + virtual ~Node(); +public: + virtual void _added() {} //!< called the moment after the object is added as a child to another node + virtual void _first_tick() {} //!< called the first frame this object is active + virtual void _tick(double delta_time [[maybe_unused]]) {} //!< called every frame + virtual void _removed() {} //!< called the moment before the object is removed as a child to another node + virtual void _draw(SDL_Renderer *render [[maybe_unused]], ce::Transform const &view_transform [[maybe_unused]]) {} + virtual void _draw_ui(SDL_Renderer *render [[maybe_unused]], ce::Transform const &ui_transform [[maybe_unused]]) {} + virtual void _update_transform() {} +public: + template TNode *get_child(std::string const &name); //!< get a non-owning pointer to a child + template TNode *create_child(Args... cargs); + void add_child(Node::OwnedPtr &child); //!< add a child, the caller must own the pointer + void set_parent(Node *new_parent); + Node *get_parent() const; //!< get the parent. + std::string const &get_name() const; //!< get the name of this node. + void set_name(std::string const &name); //!< change the name of this node. + void flag_for_deletion(); //!< request deletion at the end of the frame. + bool requests_deletion() const; //!< returns whether or not the node is flagged for deletion. + void set_visible(bool value); //!< enable or disable _draw for this object and all children + bool is_visible() const; + void set_tick(bool value); + bool is_ticking() const; + ce::Level *get_level() const; + bool is_inside_tree() const; + ChildrenVector &get_children(); +private: + void set_level(ce::Level *level); + void set_is_inside_tree(bool value); + std::optional remove_child(Node *child); //!< remove a child, the caller now owns the pointer + void propagate_tick(double delta_time); + void propagate_post_tick(); + void propagate_added(); + void propagate_removed(); + void propagate_draw(SDL_Renderer *render, ce::Transform const &view_transform); + void propagate_draw_ui(SDL_Renderer *renderer, ce::Transform const &ui_transform); + bool rename_child(std::string const &old_name, std::string const &new_name); +}; +``` + + `Node2D` provides transformation and transform hierarchy through `Transform`. `Transform` is a simple struct with a `position`, `rotation` and `scale`. As SDL_render (the media library i used instead of SFML) doesn't support using matrix transformations. + +core/node2d.cpp: + +```cpp +void Node2D::_update_transform() { + if(this->parent_node2d != nullptr) + this->global_transform = this->transform * this->parent_node2d->get_global_transform(); + else + this->global_transform = this->transform; + for(ChildrenVector::value_type &pair : this->get_children()) + pair.second->_update_transform(); +} + +void Node2D::set_transform(Transform const &transform) { + this->transform = transform; + this->_update_transform(); +} + +... + +void Node2D::set_global_transform(Transform transform) { + this->global_transform = transform; + if(this->parent_node2d) { + Transform parent = this->parent_node2d->get_global_transform(); + transform.position -= parent.position.rotated(-parent.rotation); + transform.scale_by(parent.scale.reciprocal()); + assert(transform.scale.x != 0.f || transform.scale.y != 0.f); + transform.rotation -= parent.rotation; + } + this->transform = transform; + this->_update_transform(); +} + +``` + +## Collision Subsystem + +Accessed through the `CollisionWorld` object, available on the `CanvasEngine` singleton through `ce::CanvasEngine::get_singleton()->get_collision_world()`. The collision world is a simple list of all currently active `CollidableNodes`. It loops over every pair, every tick. And notifies them if they overlapped. `RigidBody` inherits from `CollidableNode` and implements very rudimentary collision solving. As well as velocity, forces, and drag. + +core/colldiable_node.hpp + +```cpp +class CollidableNode : public Node2D { + CollisionMask mask{~0x0u /* all layers by default */}; + CollisionMask layers{0x1u /* only the first layer is enabled by default */}; +public: + Signal overlap_enter{}; +public: + CollidableNode(std::string const &name, CollisionMask layers, CollisionMask mask); + void add_overlap(CollisionShape *shape, CollisionShape *other); + + void set_mask(CollisionMask mask); + CollisionMask get_mask() const; + void set_layers(CollisionMask layers); + CollisionMask get_layers() const; +}; +``` + +`CollidableNode`s on their own cannot collide with anything. They need one or more shapes as their children to function. `CollisionShape`s are also nodes that will automatically register as part of a parent `CollidableNode`. `CollisionShape` is just a circle with a transform. Of the transform, only position applies, as rotation doesn't matter, and scaling would require implementing actually complicated overlap and escape vector detection. + +```cpp +class CollisionShape : public Node2D { + CollidableNode *owner{nullptr}; + float radius; + CollisionWorld &world; + bool is_registered{false}; + float bounce{0.5f}; +public: + CollisionShape(std::string const &name, float radius, float bounce); + + virtual void _added() override; + virtual void _removed() override; + virtual void _draw(SDL_Renderer *render, ce::Transform const &view_transform) override; + + // return true if either of these objects could receive a notification for colliding with the other + static bool can_collide(CollisionShape const *lhs, CollisionShape const *rhs); + static bool shapes_overlap(CollisionShape const *lhs, CollisionShape const *rhs); + + // get the motion lhs will have to make to fully disconnect from rhs. + static Vecf get_escape_vector(CollisionShape const *lhs, CollisionShape const *rhs); + + + CollidableNode *get_owner() const; + float get_radius() const; + CollisionMask get_layers() const; + CollisionMask get_mask() const; + float get_bounce() const; +private: + void register_with_world(); + void deregister_with_world(); +}; + +``` + +core/rigidbody.cpp: + +```cpp +void RigidBody::_tick(double delta) { + Vecf next_velocity{ + this->linear_velocity * (1.f - drag * delta) + + this->acceleration * delta + + this->impulse + }; + this->set_global_transform(this->get_global_transform() + .translated((next_velocity + this->linear_velocity) * 0.5f * delta + this->motion) + ); + this->linear_velocity = next_velocity; + this->motion = this->acceleration = this->impulse = Vecf::ZERO; +} + +void RigidBody::on_collision(CollisionShape *shape, CollidableNode *other, CollisionShape *other_shape) { + Transform const shape_transform{shape->get_global_transform()}; + Transform const other_transform{other_shape->get_global_transform()}; + Vecf escape_vector{CollisionShape::get_escape_vector(shape, other_shape)}; + // we do this because there's no collision object to maintain a static physics state for different body's resolution steps. + // (if we were to directly modify the transform here, the escape vector for the other body would be completely off) + this->motion = escape_vector; + float const bounce{shape->get_bounce() * other_shape->get_bounce()}; + if(RigidBody *other_rb{dynamic_cast(other)}) { + Vecf const relative_velocity{other_rb->linear_velocity - this->linear_velocity}; + float const relative_mass{other_rb->mass / (other_rb->mass + this->mass)}; + this->add_impulse(escape_vector.normalized() * relative_velocity.magnitude() * bounce * relative_mass); + } else { + this->add_impulse(Vecf::reflect(this->linear_velocity, escape_vector.normalized()) * bounce); + } +} +``` + +core/collision.hpp + +```cpp +void CollisionWorld::check_collisions() { + for(int i{0}; i < this->shapes.size(); ++i) { + CollisionShape *shape{this->shapes[i]}; + this->check_collisions_for(shape, i); + } +} + +void CollisionWorld::check_collisions_for(CollisionShape *shape, size_t begin) { + // check all shapes *after* this one in the shapes list. + // (As the shapes *before* have already been checked, guaranteeing that each pair will only be checked once) + for(size_t i{begin+1}; i < this->shapes.size(); ++i) { + CollisionShape *other{this->shapes[i]}; + if(other != shape && other->get_owner() != shape->get_owner() && CollisionShape::shapes_overlap(shape, other)) { + if((shape->get_mask() & other->get_layers()) != 0x0u) + shape->get_owner()->add_overlap(shape, other); + if((shape->get_layers() & other->get_mask()) != 0x0u) + other->get_owner()->add_overlap(other, shape); + } + } +} + +``` + +Collision detection uses masks to minimize checks. Though I didn't end up using those in the final version. It uses sqr_magnitude instead of magnitude to avoid a sqrt. Though it's a minor optimization that isn't really needed for a project this size. + +```cpp +bool CollisionShape::can_collide(CollisionShape const *lhs, CollisionShape const *rhs) { + return lhs->owner != nullptr && rhs->owner != nullptr + && lhs->owner != rhs->owner + && ((lhs->get_mask() & rhs->get_layers()) != 0x0u + || (rhs->get_mask() & lhs->get_layers()) != 0x0u); +} + +bool CollisionShape::shapes_overlap(CollisionShape const *lhs, CollisionShape const *rhs) { + float const rad_sum{lhs->radius + rhs->radius}; + return (lhs->get_global_transform().position - rhs->get_global_transform().position).sqr_magnitude() < rad_sum * rad_sum; +} + +Vecf CollisionShape::get_escape_vector(CollisionShape const *lhs, CollisionShape const *rhs) { + Vecf const difference{lhs->get_global_transform().position - rhs->get_global_transform().position}; + float const diff_mag{difference.magnitude()}; + return diff_mag < (lhs->radius + rhs->radius) + ? (difference / diff_mag) * (lhs->radius + rhs->radius - diff_mag) + : Vecf::ZERO; +} +``` + +# Signals + +Notifications are carried between objects using Signals. An implementation of the Observer pattern intended to provide generic, typesafe, anonymous callbacks. + +core/signal.hpp: + +```cpp + +/*! Observer-listener implementation + */ +template +class Signal { + std::vector> listeners{}; +public: + void connect(Callable callable); + void disconnect(Callable callable); + void invoke(Args...); +}; + +template void Signal::connect(Callable callable) { + this->listeners.push_back(callable); +} + +template void Signal::disconnect(Callable callable) { + std::erase_if(this->listeners, + [&callable](Callable &listener) -> bool { + return listener == callable; + }); +} + +template void Signal::invoke(Args... args) { + for(Callable &listener : this->listeners) { + listener.call(args...); + } +} + +#ifdef DEBUG +static inline +void TEST_signals() { + struct A {inline void f(int val) { + std::printf("A: %d\n", val); + }}; + struct B { inline void f(int val) { + std::printf("B: %d\n", val); + }}; + + A a_object; + B b_object; + + Signal signal; + + Callable a_callable{Callable::make(&a_object, &A::f)}; + Callable b_callable{Callable::make(&b_object, &B::f)}; + + signal.connect(a_callable); + signal.invoke(5); + signal.connect(b_callable); + signal.invoke(12); + signal.disconnect(a_callable); + signal.invoke(10); +} +#endif +``` + +This is achieved using the custom Callable template class. It's made up of an end-programmer-facing `Callable` wrapper. And a `HiddenCallable` to enable the end-programmer to use a very simple stack-allocated interface, while behind the scenes it can do polymorphism as required to make `Callable` anonymous (so not requiring the caller to know the type of the target). + +core/callable.hpp: + +```cpp +/* Base interface template for a member function pointer object + */ +template +struct HiddenCallableBase { + virtual ~HiddenCallableBase() = default; + virtual Return call(Args... args) const = 0; + virtual bool equals(HiddenCallableBase const &other) const; +}; + +template bool HiddenCallableBase::equals(HiddenCallableBase const &other) const { + return &other == this; +} + +/* Hidden component of a Callable, specialized for a specific target object + */ +template +struct HiddenCallable : public HiddenCallableBase { +typedef Return (Target::*Signature)(Args...); + HiddenCallable(Target *target, Signature function); + Target *target{nullptr}; + Signature function{nullptr}; + virtual void call(Args...) const override; + virtual bool equals(HiddenCallableBase const &other) const override; +}; + +template bool HiddenCallable::equals(HiddenCallableBase const &other) const { + HiddenCallable const *cast{dynamic_cast const *>(&other)}; + return cast != nullptr && &other == this && cast->target == this->target && cast->function == this->function; +} + +template HiddenCallable::HiddenCallable(Target *target, Signature function) : target{target}, function{function} {} +template void HiddenCallable::call(Args... args) const { + std::invoke(this->function, this->target, args...); +} + +/* Class for referring to a callable pointer to a member function. + */ +template +class Callable { +public: + std::shared_ptr const> hidden; + template static Callable make(Target *target, Return (Target::*function)(Args...)); + Return call(Args... args); +}; + +template template Callable +Callable::make(Target *target, Return (Target::*function)(Args...)) { + Callable callable; + callable.hidden = std::make_unique>(target, function); + return callable; +}; + +template Return Callable::call(Args... args) { + return this->hidden->call(args...); +} + +template bool operator==(Callable const &lhs, Callable const &rhs) { + return lhs.hidden->equals(*rhs.hidden.get()); +} +``` + +The general concept of this is based on Godot's concepts of the same names (the exposed/hidden layers and function pointers). Though heavily modified, as I don't need to support a custom scripting language. Which then allowed me to use compile-time type checking. Rather than having to do that using a variant type at runtime. It also lets me avoid `void*` sorcery. However much I may love myself a `void*`, it's best to avoid them where needed. + +These are then exposed in classes as such: + +core/collidable_node.hpp: + +```cpp +class CollidableNode : public Node2D { +... +public: + Signal overlap_enter{}; +... +}; +``` + +and listened to like this: + +```cpp +Player::Player() +... + this->overlap_enter.connect(ce::Callable::make( + this, &Player::_on_overlap_enter + )); +... +} +``` + +# Input + +The input subsystem is rather simple to use. It has three concepts. The `InputMap` being the simplest. + +```cpp +/*! Map input bindings to functions. + */ +class InputMap { + std::map bindings{}; +public: + InputAction &bind_input(std::string const &name, std::vector &&binds); + InputAction &get_action(std::string const &action_id); + void process_event(SDL_Event const &evt); +}; +``` + +The input map can be acquired from the `CanvasEngine`. Input actions are then registered. Which expose a `changed` `Signal`. That can be listened for as described in the relevant section. + +An input Action is built of `InputEffect`s. Which are objects that receive SDL events, filter them, and transform them, to produce a signal whenever they want. Containing whatever data they want. + +For example a `KeyboardKey` effect will wait for a specific key to be pressed. And mark itself as changed. So that the `InputAction` will notify it's listeners. + +More complex effects are also possible. For example a `ButtonAxis` combines two `InputEffects` and maps one to -1 and one to +1. Giving an easy 2-directional axis. + +```cpp +/*! Represents a collection of bindings. + */ +class InputAction { +private: + std::vector effects{}; //!< list of all effects used to produce final result +public: + Signal changed{}; + InputAction() = default; + InputAction(InputEffect::Ptr &effect); + InputAction(std::vector &effects); + + void process_event(SDL_Event const &evt); //!< process an incoming OS event + + template Effect *get_effect_of_type(); //!< get the first effect from the effects list that is of type Effect +}; + +``` + +The part of the `Player` constructor that sets up input looks like this: + +```cpp + ce::InputMap &map{ce::CanvasEngine::get_singleton()->get_input_map()}; + map.bind_input("horizontal", { + new ce::ButtonAxis( + new ce::KeyboardScancode(SDL_SCANCODE_A), + new ce::KeyboardScancode(SDL_SCANCODE_D) + ) + }).changed.connect(ce::Callable::make( + this, &Player::_input_horizontal_movement) + ); + map.bind_input("vertical", { + new ce::ButtonAxis( + new ce::KeyboardScancode(SDL_SCANCODE_W), + new ce::KeyboardScancode(SDL_SCANCODE_S) + ) + }).changed.connect(ce::Callable::make( + this, &Player::_input_vertical_movement) + ); + +``` + +It uses a `ButtonAxis` to combine two scancodes into an axis with a range of `-1` to `1`. Making input tracking slightly easier. + +# Levels + +Levels are relatively tiny objects. Only really existing to have somewhere to construct the node hierarchy. Though also useful for storing level-local data. + +```cpp +class Level1 : public ce::Level { +public: + unsigned score{0}; + unsigned lives{3}; + ce::Signal score_added{}; + ce::Signal life_lost{}; +public: + void add_score(); + void lose_life(); + virtual ce::Node::OwnedPtr construct() override; +}; +``` + +Although they can be constructed anywhere. Only the `CanvasEngine` should call `instantiate` to actually construct and instantiate the scene when the level becomes the new primary level. + +# External resources used: + +Game Engine Architecture by Jason Gregory + +Game Physics by David H. Eberly + +Many hours on [Desmos.com](desmos.com) to check my math. + +So so so much [cppreference.com](cppreference.com) (though i downloaded it so i could check it on the train). diff --git a/premake5.lua b/premake5.lua index 88faef4..07b4707 100644 --- a/premake5.lua +++ b/premake5.lua @@ -12,7 +12,7 @@ project "game" postbuildcommands "{COPYDIR} ../resources %{cfg.targetdir}" files { "src/**.cpp" } includedirs { "src/" } - links { "SDL2", "SDL2_image", "m" } + links { "SDL2", "SDL2_image", "m", "SDL2_ttf" } targetdir "bin/" exceptionhandling "Off" filter "configurations:debug" diff --git a/src/core/collidable_node.hpp b/src/core/collidable_node.hpp index a5695a9..a64df88 100644 --- a/src/core/collidable_node.hpp +++ b/src/core/collidable_node.hpp @@ -14,7 +14,6 @@ class CollidableNode : public Node2D { CollisionMask mask{~0x0u /* all layers by default */}; CollisionMask layers{0x1u /* only the first layer is enabled by default */}; public: - // collision(local_shape, other_node, other_shape) Signal overlap_enter{}; public: CollidableNode(std::string const &name, CollisionMask layers, CollisionMask mask); diff --git a/src/core/collision_shape.cpp b/src/core/collision_shape.cpp index 0ff62bb..06fd929 100644 --- a/src/core/collision_shape.cpp +++ b/src/core/collision_shape.cpp @@ -40,6 +40,7 @@ void CollisionShape::_removed() { } void CollisionShape::_draw(SDL_Renderer *render, ce::Transform const &view_transform) { +#if 0 static const size_t points_c{17}; SDL_FPoint points[points_c]; Vecf point{this->radius, 0.f}; @@ -53,6 +54,7 @@ void CollisionShape::_draw(SDL_Renderer *render, ce::Transform const &view_trans } points[points_c-1] = points[0]; SDL_RenderDrawLinesF(render, points, points_c); +#endif } bool CollisionShape::can_collide(CollisionShape const *lhs, CollisionShape const *rhs) { diff --git a/src/core/collision_shape.hpp b/src/core/collision_shape.hpp index 8dd25c2..a5cabae 100644 --- a/src/core/collision_shape.hpp +++ b/src/core/collision_shape.hpp @@ -21,9 +21,11 @@ public: virtual void _removed() override; virtual void _draw(SDL_Renderer *render, ce::Transform const &view_transform) override; + // return true if either of these objects could receive a notification for colliding with the other static bool can_collide(CollisionShape const *lhs, CollisionShape const *rhs); static bool shapes_overlap(CollisionShape const *lhs, CollisionShape const *rhs); + // get the motion lhs will have to make to fully disconnect from rhs. static Vecf get_escape_vector(CollisionShape const *lhs, CollisionShape const *rhs);