diff --git a/CLAUDE.md b/CLAUDE.md index 97edf1b..856ff0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ There is no external test framework. `tests/test.hpp` provides `TEST("name") {.. **Engine/Window loop** (`core/engine.cpp`, `core/window.cpp`). `Pancake::*` free functions are a thin facade over the `Window` namespace, which holds all global state (GLFW window, current `Scene*`, shaders, entity-picking framebuffer). - Calls such as `load`, `save`, `reset`, `title`, and `size` only **set flags**, which the main loop applies at the top of the next frame. `load(fn)` runs the init function against the current scene, and `reset()` replaces the scene with an empty one. - A component that resets the scene deletes itself, so tests and demos must `load` a new driver in the same frame. +- Gamepads are polled after `glfwPollEvents` each frame (`GamepadListener::poll`). Begin and end down compare against the previous poll, so they need no `endFrame`, and `setState` lets tests fake a pad. - Each frame does `scene->update(dt)`, where `dt` is the frame time × time scale, zero while paused, or exactly one physics step for each `step()` while paused. Components keep updating while paused. Then the frame renders twice: once into an RGB32F framebuffer with the entity shader (sprite entity IDs for `Window::readPixel`) and once to the screen. Then `DebugDraw::render()` runs, followed by ImGui (`Console::render`, `Inspector::render`). - Shutdown deletes the scene, then the GL resources, then ImGui. @@ -43,6 +44,7 @@ There is no external test framework. `tests/test.hpp` provides `TEST("name") {.. - `Scene::after`/`every` timers run after entity updates, advance with the scene `dt`, and are cancelled when their owner entity dies. They aren't serialised. `Tween` (`components/tween.hpp`) is a non-serialised component that kills itself when done, and reads its start value when it begins, after its delay. - `kill()` marks a component dead and calls `end()`. The owning update loop deletes dead entities and components afterwards. - `Scene::~Scene` kills every entity before deleting any systems, so components can unregister while the systems still exist. +- `Camera::shake` offsets the view only (`getView`). `getPosition` stays the logical position, which `CameraFollow` and mouse world coordinates use. `CameraFollow` (`components/camerafollow.hpp`) is serialised; its logic is in `apply(camera, target, dt)` so it can be tested without a window. - `TransformableComponent` adds a position/size/rotation offset relative to the entity. **Serialisation / reflection** (`core/factory.hpp`). `REGISTER(Base, Derived)` registers a default constructor under the class name. `FACTORY(Component).create(type)` is used by `Entity::load`, and `FACTORY(Collider)` by `Rigidbody::load`. Every serialisable component needs a default constructor, a `serialise()`/`load(json)` pair that chains to its base class, and a `REGISTER` line. New fields added to existing components should be optional on load, so older save files keep loading (see `gravityScale`). `Scene::serialise` also writes gravity and the asset pools (fonts, spritesheets, sprites, audio). diff --git a/README.md b/README.md index e7369a0..9b7bbbe 100644 --- a/README.md +++ b/README.md @@ -140,12 +140,13 @@ The engine provides the following features: - Collision listeners with enter and exit events, sensors, collision layers and masks, and raycasts. - Spatial queries to find entities in an area, near a point or under the mouse. - Audio playback and management using SoLoud. -- Input management for keyboard and mouse, with pressed, held and released events. +- Input management for keyboard, mouse and gamepads, with pressed, held and released events. +- A camera that can follow an entity, with smoothing, a dead zone and bounds, and shake. - Entity-component system for managing game objects, with typed component access and named entities. - Resource loading from file system. - JSON serialization and deserialization for game data, including custom components. - Time scale, pause and single stepping, with timers (`scene->after`, `scene->every`) and tweens with easing that follow them. -- An in-game console with built-in commands (`help`, `save`, `load`, `reset`, `debug`, `gravity`, `entities`, `inspector`, `pause`, `step`, `timescale`, `spawn`, `kill`, `tp`, `fps`, `vsync`, `fullscreen`) and support for your own. +- An in-game console with built-in commands (`help`, `save`, `load`, `reset`, `debug`, `gravity`, `entities`, `inspector`, `pause`, `step`, `timescale`, `spawn`, `kill`, `tp`, `fps`, `vsync`, `fullscreen`, `shake`, `gamepads`) and support for your own. - An entity inspector: click an entity to view and edit its components. - Debug drawing for collision shapes and other debugging purposes. @@ -189,6 +190,22 @@ coin->setCollisionLayer(PICKUP)->setCollisionMask(PLAYER)->setSensor(true); RaycastResult hit = scene->getPhysics()->raycast(Ray(origin, direction), 10.0f, ALL_LAYERS & ~PICKUP); ``` +## Camera and Gamepads +```c++ +// Follow the player. Smoothing is in seconds, the dead zone and bounds in world units. It is saved with the scene. +player->addComponent() + ->setSmoothing(0.2f) + ->setDeadZone(vec2(1.0f, 0.5f)) + ->setBounds(vec2(-50.0f, -10.0f), vec2(50.0f, 30.0f)); + +scene->getCamera()->shake(0.3f, 0.4f); // Up to 0.3 units, fading over 0.4 seconds. Only the view moves. + +// Gamepad 0 by default. Sticks have the dead zone removed and up is positive y. Triggers go from 0 to 1. +vec2 move = GamepadListener::getLeftStick(); +if (GamepadListener::isButtonBeginDown(GLFW_GAMEPAD_BUTTON_A)) {} +float throttle = GamepadListener::getAxis(GLFW_GAMEPAD_AXIS_RIGHT_TRIGGER); +``` + ## Time, Timers and Tweens ```c++ Pancake::timeScale(0.25f); // Slow motion. Components get the scaled delta time. diff --git a/ROADMAP.md b/ROADMAP.md index 76448a3..d120ef0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,9 +23,9 @@ Tick an item off in the same branch that implements it. ## Batch 4: camera and input devices -- [ ] `CameraFollow` component: target entity, smoothing, dead zone, bounds clamp. -- [ ] Camera shake: `shake(intensity, duration)`. -- [ ] Gamepad input through `glfwGetGamepadState`, with begin, down and end semantics. +- [x] `CameraFollow` component: target entity, smoothing, dead zone, bounds clamp, offset. +- [x] Camera shake: `shake(intensity, duration)`, and a `shake` console command. +- [x] Gamepad input through `glfwGetGamepadState`, with begin, down and end semantics, dead zones, and a `gamepads` console command. ## Batch 5: editor tooling diff --git a/examples/demo.cpp b/examples/demo.cpp index d2af9a5..b4a8424 100644 --- a/examples/demo.cpp +++ b/examples/demo.cpp @@ -11,6 +11,7 @@ // N step one physics frame (pauses) // F toggle the frame rate overlay // R reset the sandbox +// Gamepad left stick pans, A drops a box // Escape quit // // Run with --frames N to quit automatically after N frames. @@ -55,6 +56,7 @@ class ImpactFlash : public Component, public CollisionListener { void collisionEnter(Entity* with, CollisionManifold manifold) override { if (this->speed > 6.0f) {this->flash = 0.15f;} + if (this->speed > 12.0f) {Pancake::getScene()->getCamera()->shake(0.15f, 0.25f);} } void update(float dt) override { @@ -171,6 +173,13 @@ class Sandbox : public Component { } if (KeyListener::isKeyBeginDown(GLFW_KEY_GRAVE_ACCENT)) {Pancake::console();} + // Gamepad: the left stick pans, even while paused, and A drops a box at the centre of the view. + Camera* view = Pancake::getScene()->getCamera(); + view->addPosition(GamepadListener::getLeftStick() * 10.0f * Pancake::getUnscaledDeltaTime()); + if (GamepadListener::isButtonBeginDown(GLFW_GAMEPAD_BUTTON_A)) { + spawnBox(Pancake::getScene(), view->getPosition(), vec2(0.8f, 0.8f), 1.0f, randomColour()); + } + if (ImGui::GetIO().WantCaptureMouse) {return;} Camera* camera = Pancake::getScene()->getCamera(); vec2 mouse = vec2(MouseListener::getWorldX(), MouseListener::getWorldY()); diff --git a/include/pancake/components/camerafollow.hpp b/include/pancake/components/camerafollow.hpp new file mode 100644 index 0000000..da5bd84 --- /dev/null +++ b/include/pancake/components/camerafollow.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include "pancake/core/component.hpp" +#include "pancake/core/camera.hpp" + +namespace Pancake { + + // Moves the scene camera to follow an entity: its own entity by default, or another by id. + // Pausing the scene stops smoothed movement, as it advances with the scene's delta time. + class CameraFollow : public Component { + + private: + + int target; // The entity to follow, or -1 for this component's entity. + glm::vec2 offset; // Added to the target's position. + float smoothing; // Seconds for the camera to close most of the gap (about 63%). Zero snaps. + glm::vec2 deadZone; // Half size of a box around the camera centre the target can move in freely. + bool bounded; + glm::vec2 boundsMin; // The view is kept inside these bounds, or centred on them if it is larger. + glm::vec2 boundsMax; + bool snapNext; // Ignore smoothing on the next update, so the camera starts on the target. + + public: + + CameraFollow(); + + void start() override; + void update(float dt) override; + json serialise() override; + bool load(json j) override; + void imgui() override; + + // Move the camera towards the target. Called by update with the scene camera and the resolved target. + void apply(Camera* camera, Entity* target, float dt); + + int getTarget(); + glm::vec2 getOffset(); + float getSmoothing(); + glm::vec2 getDeadZone(); + bool hasBounds(); + glm::vec2 getBoundsMin(); + glm::vec2 getBoundsMax(); + + CameraFollow* setTarget(Entity* entity); // nullptr follows this component's entity. + CameraFollow* setTarget(int entityId); + CameraFollow* setOffset(glm::vec2 offset); + CameraFollow* setSmoothing(float seconds); + CameraFollow* setDeadZone(glm::vec2 halfSize); + CameraFollow* setBounds(glm::vec2 min, glm::vec2 max); + CameraFollow* clearBounds(); + CameraFollow* snap(); // Jump straight to the target on the next update. + + }; + + REGISTER(Component, CameraFollow); + +} diff --git a/include/pancake/core/camera.hpp b/include/pancake/core/camera.hpp index 69f5ca8..d12a16a 100644 --- a/include/pancake/core/camera.hpp +++ b/include/pancake/core/camera.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -31,6 +32,13 @@ namespace Pancake { float timeHalf; float distance; float maxVelocity; + + // Shake moves the view, not the camera's position, so following and picking aren't disturbed. + float shakeIntensity; + float shakeDuration; + float shakeTime; + vec2 shakeOffset; + std::minstd_rand shakeRandom; public: @@ -38,6 +46,10 @@ namespace Pancake { void adjustProjection(); void update(float dt); void move(vec2 to, float t); + void shake(float intensity, float duration); // Up to intensity world units, fading out over duration seconds. + void stopShake(); + bool isShaking(); + vec2 getShakeOffset(); json serialise(); void load(json j); diff --git a/include/pancake/core/listener.hpp b/include/pancake/core/listener.hpp index 79e67e6..0c40d85 100644 --- a/include/pancake/core/listener.hpp +++ b/include/pancake/core/listener.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include namespace Pancake { @@ -38,4 +40,29 @@ namespace Pancake { void endFrame(); } + // Gamepads with a standard mapping, polled once a frame. Buttons and axes use GLFW's names, such as + // GLFW_GAMEPAD_BUTTON_A and GLFW_GAMEPAD_AXIS_LEFT_X. Gamepads are numbered 0 to GLFW_JOYSTICK_LAST. + namespace GamepadListener { + + void poll(); + + // Replace a gamepad's state, as poll does. Begin and end down compare it with the previous state. + void setState(int gamepad, bool connected, const GLFWgamepadstate& state, std::string name = ""); + + bool isConnected(int gamepad = 0); + std::string getName(int gamepad = 0); + bool isButtonDown(int button, int gamepad = 0); + bool isButtonBeginDown(int button, int gamepad = 0); + bool isButtonEndDown(int button, int gamepad = 0); + + // Stick axes are in [-1, 1] with the dead zone removed. Triggers are in [0, 1], at rest at 0. + float getAxis(int axis, int gamepad = 0); + glm::vec2 getLeftStick(int gamepad = 0); // Up is positive y, unlike GLFW's raw axes. + glm::vec2 getRightStick(int gamepad = 0); + + void setDeadZone(float deadZone); + float getDeadZone(); + + } + } \ No newline at end of file diff --git a/include/pancake/pancake.hpp b/include/pancake/pancake.hpp index 11665f2..c5922bb 100644 --- a/include/pancake/pancake.hpp +++ b/include/pancake/pancake.hpp @@ -11,6 +11,7 @@ #include "pancake/audio/audiowave.hpp" #include "pancake/components/animation.hpp" +#include "pancake/components/camerafollow.hpp" #include "pancake/components/transition.hpp" #include "pancake/components/tween.hpp" diff --git a/src/components/camerafollow.cpp b/src/components/camerafollow.cpp new file mode 100644 index 0000000..9e04939 --- /dev/null +++ b/src/components/camerafollow.cpp @@ -0,0 +1,224 @@ +#include +#include + +#include "pancake/components/camerafollow.hpp" +#include "pancake/core/window.hpp" + +namespace Pancake { + + namespace { + + bool readVec2(json& j, const char* key, glm::vec2* out) { + if (!j.contains(key) || !j[key].is_array() || j[key].size() != 2) {return false;} + if (!j[key][0].is_number() || !j[key][1].is_number()) {return false;} + *out = glm::vec2(j[key][0], j[key][1]); + return true; + } + + json writeVec2(glm::vec2 v) { + return json::array({v.x, v.y}); + } + + // Moves value just enough to keep target within halfSize of it. + float applyDeadZone(float value, float target, float halfSize) { + float d = target - value; + if (d > halfSize) {return target - halfSize;} + if (d < -halfSize) {return target + halfSize;} + return value; + } + + // Keeps a view of the given half size inside [min, max], centring it when it doesn't fit. + float clampView(float centre, float halfView, float min, float max) { + if (max - min <= 2.0f * halfView) {return 0.5f * (min + max);} + return std::fmin(std::fmax(centre, min + halfView), max - halfView); + } + + } + + CameraFollow::CameraFollow() : Component("CameraFollow") { + this->target = -1; + this->offset = glm::vec2(0.0f, 0.0f); + this->smoothing = 0.0f; + this->deadZone = glm::vec2(0.0f, 0.0f); + this->bounded = false; + this->boundsMin = glm::vec2(0.0f, 0.0f); + this->boundsMax = glm::vec2(0.0f, 0.0f); + this->snapNext = true; + } + + void CameraFollow::start() { + this->snapNext = true; + } + + void CameraFollow::update(float dt) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return;} + Entity* followed = this->target < 0 ? this->getEntity() : scene->getEntity(this->target); + if (followed == nullptr || followed->isDead()) {return;} + this->apply(scene->getCamera(), followed, dt); + } + + void CameraFollow::apply(Camera* camera, Entity* followed, float dt) { + + glm::vec2 position = camera->getPosition(); + glm::vec2 goal = followed->getPosition() + this->offset; + + // Only move when the target leaves the dead zone, and then only as far as its edge. + glm::vec2 desired; + desired.x = applyDeadZone(position.x, goal.x, this->deadZone.x); + desired.y = applyDeadZone(position.y, goal.y, this->deadZone.y); + + // Close the gap exponentially, so the result doesn't depend on the frame rate. + if (this->snapNext || this->smoothing <= 0.0f) {position = desired;} + else {position += (desired - position) * (1.0f - std::exp(-dt / this->smoothing));} + this->snapNext = false; + + if (this->bounded) { + glm::vec2 halfView = 0.5f * camera->getProjectionSize() / camera->getZoom(); + position.x = clampView(position.x, halfView.x, this->boundsMin.x, this->boundsMax.x); + position.y = clampView(position.y, halfView.y, this->boundsMin.y, this->boundsMax.y); + } + + camera->setPosition(position); + + } + + json CameraFollow::serialise() { + json j = this->Component::serialise(); + j.emplace("target", this->target); + j.emplace("offset", writeVec2(this->offset)); + j.emplace("smoothing", this->smoothing); + j.emplace("deadZone", writeVec2(this->deadZone)); + j.emplace("bounded", this->bounded); + j.emplace("boundsMin", writeVec2(this->boundsMin)); + j.emplace("boundsMax", writeVec2(this->boundsMax)); + return j; + } + + bool CameraFollow::load(json j) { + + if (!this->Component::load(j)) {return false;} + if (!j.contains("target") || !j["target"].is_number_integer()) {return false;} + if (!j.contains("smoothing") || !j["smoothing"].is_number()) {return false;} + if (!j.contains("bounded") || !j["bounded"].is_boolean()) {return false;} + + glm::vec2 offset, deadZone, boundsMin, boundsMax; + if (!readVec2(j, "offset", &offset)) {return false;} + if (!readVec2(j, "deadZone", &deadZone)) {return false;} + if (!readVec2(j, "boundsMin", &boundsMin)) {return false;} + if (!readVec2(j, "boundsMax", &boundsMax)) {return false;} + + this->target = j["target"]; + this->offset = offset; + this->smoothing = j["smoothing"]; + this->deadZone = deadZone; + this->bounded = j["bounded"]; + this->boundsMin = boundsMin; + this->boundsMax = boundsMax; + return true; + + } + + void CameraFollow::imgui() { + + ImGui::Text("Target: "); + ImGui::SameLine(); + ImGui::InputInt("##Target", &this->target); + + ImGui::Text("Offset: "); + ImGui::SameLine(); + ImGui::DragFloat2("##Offset", &this->offset[0], 0.05f); + + ImGui::Text("Smoothing: "); + ImGui::SameLine(); + ImGui::DragFloat("##Smoothing", &this->smoothing, 0.01f, 0.0f, 10.0f); + + ImGui::Text("Dead Zone: "); + ImGui::SameLine(); + if (ImGui::DragFloat2("##DeadZone", &this->deadZone[0], 0.05f)) {this->setDeadZone(this->deadZone);} + + ImGui::Checkbox("Bounded", &this->bounded); + if (this->bounded) { + ImGui::Text("Bounds Min: "); + ImGui::SameLine(); + ImGui::DragFloat2("##BoundsMin", &this->boundsMin[0], 0.1f); + ImGui::Text("Bounds Max: "); + ImGui::SameLine(); + ImGui::DragFloat2("##BoundsMax", &this->boundsMax[0], 0.1f); + } + + } + + int CameraFollow::getTarget() { + return this->target; + } + + glm::vec2 CameraFollow::getOffset() { + return this->offset; + } + + float CameraFollow::getSmoothing() { + return this->smoothing; + } + + glm::vec2 CameraFollow::getDeadZone() { + return this->deadZone; + } + + bool CameraFollow::hasBounds() { + return this->bounded; + } + + glm::vec2 CameraFollow::getBoundsMin() { + return this->boundsMin; + } + + glm::vec2 CameraFollow::getBoundsMax() { + return this->boundsMax; + } + + CameraFollow* CameraFollow::setTarget(Entity* entity) { + this->target = entity == nullptr ? -1 : entity->getId(); + this->snapNext = true; + return this; + } + + CameraFollow* CameraFollow::setTarget(int entityId) { + this->target = entityId < 0 ? -1 : entityId; + this->snapNext = true; + return this; + } + + CameraFollow* CameraFollow::setOffset(glm::vec2 offset) { + this->offset = offset; + return this; + } + + CameraFollow* CameraFollow::setSmoothing(float seconds) { + this->smoothing = std::fmax(seconds, 0.0f); + return this; + } + + CameraFollow* CameraFollow::setDeadZone(glm::vec2 halfSize) { + this->deadZone = glm::max(halfSize, glm::vec2(0.0f, 0.0f)); + return this; + } + + CameraFollow* CameraFollow::setBounds(glm::vec2 min, glm::vec2 max) { + this->bounded = true; + this->boundsMin = glm::min(min, max); + this->boundsMax = glm::max(min, max); + return this; + } + + CameraFollow* CameraFollow::clearBounds() { + this->bounded = false; + return this; + } + + CameraFollow* CameraFollow::snap() { + this->snapNext = true; + return this; + } + +} diff --git a/src/core/camera.cpp b/src/core/camera.cpp index 00e6b1f..c8a7df2 100644 --- a/src/core/camera.cpp +++ b/src/core/camera.cpp @@ -1,5 +1,6 @@ #include "pancake/core/camera.hpp" #include "pancake/core/window.hpp" +#include #include namespace Pancake { @@ -19,6 +20,11 @@ namespace Pancake { this->timeHalf = 0.0f; this->distance = 0.0f; this->maxVelocity = 0.0f; + + this->shakeIntensity = 0.0f; + this->shakeDuration = 0.0f; + this->shakeTime = 0.0f; + this->shakeOffset = glm::vec2(0.0f, 0.0f); this->adjustProjection(); @@ -56,6 +62,38 @@ namespace Pancake { this->timeCurrent += dt; } + // Shake with a random offset, fading out quadratically. Paused scenes (dt of zero) hold the offset. + if (this->shakeTime > 0.0f && dt > 0.0f) { + this->shakeTime = std::max(this->shakeTime - dt, 0.0f); + float fade = this->shakeTime / this->shakeDuration; + float amplitude = this->shakeIntensity * fade * fade; + std::uniform_real_distribution unit(-1.0f, 1.0f); + this->shakeOffset = amplitude * glm::vec2(unit(this->shakeRandom), unit(this->shakeRandom)); + } + + } + + void Camera::shake(float intensity, float duration) { + if (intensity <= 0.0f || duration <= 0.0f) {return;} + // Keep the current shake if it is still stronger than the new one. + float fade = this->shakeTime > 0.0f ? this->shakeTime / this->shakeDuration : 0.0f; + if (this->shakeIntensity * fade * fade > intensity) {return;} + this->shakeIntensity = intensity; + this->shakeDuration = duration; + this->shakeTime = duration; + } + + void Camera::stopShake() { + this->shakeTime = 0.0f; + this->shakeOffset = glm::vec2(0.0f, 0.0f); + } + + bool Camera::isShaking() { + return this->shakeTime > 0.0f; + } + + vec2 Camera::getShakeOffset() { + return this->shakeOffset; } void Camera::move(vec2 to, float t) { @@ -188,8 +226,9 @@ namespace Pancake { mat4 Camera::getView() { - glm::vec3 cameraPosition(this->position.x - this->projectionSize.x / (2 * this->zoom), this->position.y - this->projectionSize.y / (2 * this->zoom), 20.0f); - glm::vec3 cameraFront(this->position.x - this->projectionSize.x / (2 * this->zoom), this->position.y - this->projectionSize.y / (2 * this->zoom), -1.0f); + vec2 centre = this->position + this->shakeOffset; + glm::vec3 cameraPosition(centre.x - this->projectionSize.x / (2 * this->zoom), centre.y - this->projectionSize.y / (2 * this->zoom), 20.0f); + glm::vec3 cameraFront(centre.x - this->projectionSize.x / (2 * this->zoom), centre.y - this->projectionSize.y / (2 * this->zoom), -1.0f); glm::vec3 cameraUp(0.0f, 1.0f, 0.0f); this->view = glm::lookAt(cameraPosition, cameraFront, cameraUp); diff --git a/src/core/console.cpp b/src/core/console.cpp index fffe9aa..695645b 100644 --- a/src/core/console.cpp +++ b/src/core/console.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -6,6 +7,7 @@ #include "pancake/core/console.hpp" #include "pancake/core/inspector.hpp" #include "pancake/core/window.hpp" +#include "pancake/core/listener.hpp" #include "pancake/graphics/debugdraw.hpp" #include "pancake/graphics/spriterenderer.hpp" #include "pancake/physics/rigidbody.hpp" @@ -218,6 +220,27 @@ namespace Pancake { Console::print(std::string("Vsync ") + (state ? "on" : "off")); }); + Console::registerCommand("shake", "shake [duration]: Shake the camera.", [](std::vector args) { + Scene* scene = Window::getScene(); + if (scene == nullptr) {return;} + float intensity; + float duration = 0.5f; + bool valid = (args.size() == 1 || args.size() == 2) && parseFloat(args[0], &intensity); + if (valid && args.size() == 2) {valid = parseFloat(args[1], &duration);} + if (!valid) {Console::print("Usage: shake [duration]"); return;} + scene->getCamera()->shake(intensity, duration); + }); + + Console::registerCommand("gamepads", "List the connected gamepads.", [](std::vector args) { + int count = 0; + for (int i = 0; i <= GLFW_JOYSTICK_LAST; i++) { + if (!GamepadListener::isConnected(i)) {continue;} + Console::print(std::to_string(i) + ": " + GamepadListener::getName(i)); + count++; + } + if (count == 0) {Console::print("No gamepads connected");} + }); + Console::registerCommand("fullscreen", "fullscreen [on|off]: Fill the primary monitor.", [](std::vector args) { Window::setFullscreen(parseState(args, Window::isFullscreen())); }); diff --git a/src/core/listener.cpp b/src/core/listener.cpp index ca702cb..d1e224e 100644 --- a/src/core/listener.cpp +++ b/src/core/listener.cpp @@ -1,3 +1,4 @@ +#include #include #include "pancake/core/listener.hpp" #include "pancake/core/camera.hpp" @@ -25,6 +26,38 @@ namespace { bool mouseEndDown[9] = {0}; int mouseDownCount = 0; bool mouseDragging = 0; + + struct Gamepad { + bool connected = false; + std::string name; + GLFWgamepadstate state = {}; + GLFWgamepadstate previous = {}; + }; + + Gamepad gamepads[GLFW_JOYSTICK_LAST + 1]; + float gamepadDeadZone = 0.15f; + + bool validGamepad(int gamepad) { + return gamepad >= 0 && gamepad <= GLFW_JOYSTICK_LAST; + } + + // Removes the dead zone and rescales, so values start at 0 just outside it and still reach 1. + float removeDeadZone(float value) { + float magnitude = std::fabs(value); + if (magnitude <= gamepadDeadZone) {return 0.0f;} + float scaled = std::fmin((magnitude - gamepadDeadZone) / (1.0f - gamepadDeadZone), 1.0f); + return value < 0.0f ? -scaled : scaled; + } + + // Applies the dead zone to the stick's length, so diagonals aren't snapped to the axes. + glm::vec2 stick(int gamepad, int xAxis, int yAxis) { + if (!validGamepad(gamepad) || !gamepads[gamepad].connected) {return glm::vec2(0.0f, 0.0f);} + glm::vec2 v = glm::vec2(gamepads[gamepad].state.axes[xAxis], -gamepads[gamepad].state.axes[yAxis]); + float length = glm::length(v); + if (length <= gamepadDeadZone) {return glm::vec2(0.0f, 0.0f);} + float scaled = std::fmin((length - gamepadDeadZone) / (1.0f - gamepadDeadZone), 1.0f); + return v * (scaled / length); + } } @@ -203,6 +236,70 @@ namespace Pancake { return scrollY; } + void GamepadListener::poll() { + for (int i = 0; i <= GLFW_JOYSTICK_LAST; i++) { + GLFWgamepadstate state = {}; + bool connected = glfwJoystickIsGamepad(i) && glfwGetGamepadState(i, &state); + const char* name = connected ? glfwGetGamepadName(i) : nullptr; + setState(i, connected, state, name == nullptr ? "" : name); + } + } + + void GamepadListener::setState(int gamepad, bool connected, const GLFWgamepadstate& state, std::string name) { + if (!validGamepad(gamepad)) {return;} + Gamepad& g = gamepads[gamepad]; + g.previous = g.state; + g.state = connected ? state : GLFWgamepadstate {}; // A disconnected pad releases every button. + g.connected = connected; + g.name = connected ? name : ""; + } + + bool GamepadListener::isConnected(int gamepad) { + return validGamepad(gamepad) && gamepads[gamepad].connected; + } + + std::string GamepadListener::getName(int gamepad) { + return validGamepad(gamepad) ? gamepads[gamepad].name : ""; + } + + bool GamepadListener::isButtonDown(int button, int gamepad) { + if (!validGamepad(gamepad) || button < 0 || button > GLFW_GAMEPAD_BUTTON_LAST) {return false;} + return gamepads[gamepad].state.buttons[button] == GLFW_PRESS; + } + + bool GamepadListener::isButtonBeginDown(int button, int gamepad) { + if (!validGamepad(gamepad) || button < 0 || button > GLFW_GAMEPAD_BUTTON_LAST) {return false;} + return gamepads[gamepad].state.buttons[button] == GLFW_PRESS && gamepads[gamepad].previous.buttons[button] != GLFW_PRESS; + } + + bool GamepadListener::isButtonEndDown(int button, int gamepad) { + if (!validGamepad(gamepad) || button < 0 || button > GLFW_GAMEPAD_BUTTON_LAST) {return false;} + return gamepads[gamepad].state.buttons[button] != GLFW_PRESS && gamepads[gamepad].previous.buttons[button] == GLFW_PRESS; + } + + float GamepadListener::getAxis(int axis, int gamepad) { + if (!validGamepad(gamepad) || !gamepads[gamepad].connected || axis < 0 || axis > GLFW_GAMEPAD_AXIS_LAST) {return 0.0f;} + float value = gamepads[gamepad].state.axes[axis]; + if (axis == GLFW_GAMEPAD_AXIS_LEFT_TRIGGER || axis == GLFW_GAMEPAD_AXIS_RIGHT_TRIGGER) {return 0.5f * (value + 1.0f);} + return removeDeadZone(value); + } + + glm::vec2 GamepadListener::getLeftStick(int gamepad) { + return stick(gamepad, GLFW_GAMEPAD_AXIS_LEFT_X, GLFW_GAMEPAD_AXIS_LEFT_Y); + } + + glm::vec2 GamepadListener::getRightStick(int gamepad) { + return stick(gamepad, GLFW_GAMEPAD_AXIS_RIGHT_X, GLFW_GAMEPAD_AXIS_RIGHT_Y); + } + + void GamepadListener::setDeadZone(float deadZone) { + gamepadDeadZone = std::fmin(std::fmax(deadZone, 0.0f), 0.95f); + } + + float GamepadListener::getDeadZone() { + return gamepadDeadZone; + } + void MouseListener::endFrame() { for (int i = 0; i < 9; i++) { diff --git a/src/core/window.cpp b/src/core/window.cpp index 1a6f144..ad1223e 100644 --- a/src/core/window.cpp +++ b/src/core/window.cpp @@ -299,6 +299,7 @@ namespace Pancake { } glfwPollEvents(); + GamepadListener::poll(); if (saveFlag) { scene->save(saveFilename); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8bcbaab..014d67d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,5 +35,7 @@ pancake_test(test_serialise) pancake_test(test_entity) pancake_test(test_time) pancake_test(test_collision_events) +pancake_test(test_camera) pancake_engine_test(test_engine) pancake_engine_test(test_time_engine) +pancake_engine_test(test_camera_engine) diff --git a/tests/test_camera.cpp b/tests/test_camera.cpp new file mode 100644 index 0000000..681f8eb --- /dev/null +++ b/tests/test_camera.cpp @@ -0,0 +1,179 @@ +// Camera shake, CameraFollow and gamepad input, with no window. + +#include +#include "test.hpp" +#include "pancake/pancake.hpp" + +using namespace Pancake; + +TEST("shake moves the view within its intensity, then fades to nothing") { + Camera camera(glm::vec2(1.0f, 2.0f), glm::vec2(12.0f, 12.0f), 1.0f); + glm::mat4 still = camera.getView(); + camera.shake(0.5f, 1.0f); + CHECK(camera.isShaking()); + + bool moved = false; + for (int i = 0; i < 30; i++) { + camera.update(1.0f / 60.0f); + glm::vec2 offset = camera.getShakeOffset(); + CHECK(std::fabs(offset.x) <= 0.5f && std::fabs(offset.y) <= 0.5f); + moved = moved || offset != glm::vec2(0.0f); + } + CHECK(moved); + CHECK(camera.getPosition() == glm::vec2(1.0f, 2.0f)); // The logical position never moves. + CHECK(camera.getView() != still); + + // A weaker shake doesn't cut a stronger one short. + camera.shake(0.01f, 5.0f); + for (int i = 0; i < 40; i++) {camera.update(1.0f / 60.0f);} + CHECK(!camera.isShaking()); + CHECK(camera.getShakeOffset() == glm::vec2(0.0f)); + CHECK(camera.getView() == still); +} + +TEST("shake holds still while paused and can be stopped") { + Camera camera(glm::vec2(0.0f), glm::vec2(12.0f, 12.0f), 1.0f); + camera.shake(1.0f, 1.0f); + camera.update(0.1f); + glm::vec2 offset = camera.getShakeOffset(); + camera.update(0.0f); + CHECK(camera.getShakeOffset() == offset); + camera.stopShake(); + CHECK(!camera.isShaking()); + CHECK(camera.getShakeOffset() == glm::vec2(0.0f)); +} + +TEST("follow snaps first, then smooths independently of frame rate") { + Entity target(glm::vec2(5.0f, 5.0f)); + CameraFollow follow; + follow.setSmoothing(0.5f); + Camera camera(glm::vec2(0.0f), glm::vec2(12.0f, 12.0f), 1.0f); + + follow.apply(&camera, &target, 1.0f / 60.0f); + CHECK_NEAR(camera.getPosition().x, 5.0, 1e-5); + + // Half a second at 60 or 30 frames per second closes the same fraction of the gap. + Camera fast(glm::vec2(5.0f, 5.0f), glm::vec2(12.0f, 12.0f), 1.0f); + Camera slow(glm::vec2(5.0f, 5.0f), glm::vec2(12.0f, 12.0f), 1.0f); + target.setPosition(glm::vec2(15.0f, 5.0f)); + for (int i = 0; i < 30; i++) {follow.apply(&fast, &target, 1.0f / 60.0f);} + for (int i = 0; i < 15; i++) {follow.apply(&slow, &target, 1.0f / 30.0f);} + double expected = 15.0 - 10.0 * std::exp(-1.0); + CHECK_NEAR(fast.getPosition().x, expected, 1e-3); + CHECK_NEAR(slow.getPosition().x, expected, 1e-3); + + // A paused scene doesn't move a smoothed camera. + follow.apply(&fast, &target, 0.0f); + CHECK_NEAR(fast.getPosition().x, expected, 1e-3); +} + +TEST("follow ignores movement inside the dead zone and adds the offset") { + Entity target(glm::vec2(0.0f, 0.0f)); + CameraFollow follow; + follow.setDeadZone(glm::vec2(2.0f, 1.0f))->setOffset(glm::vec2(0.0f, 1.0f)); + Camera camera(glm::vec2(0.0f, 1.0f), glm::vec2(12.0f, 12.0f), 1.0f); + follow.apply(&camera, &target, 0.1f); + CHECK(camera.getPosition() == glm::vec2(0.0f, 1.0f)); + + target.setPosition(glm::vec2(1.5f, -0.5f)); // Still inside. + follow.apply(&camera, &target, 0.1f); + CHECK(camera.getPosition() == glm::vec2(0.0f, 1.0f)); + + target.setPosition(glm::vec2(5.0f, 0.0f)); // Out to the right, so the camera moves until it is on the edge. + follow.apply(&camera, &target, 0.1f); + CHECK_NEAR(camera.getPosition().x, 3.0, 1e-5); + CHECK_NEAR(camera.getPosition().y, 1.0, 1e-5); +} + +TEST("follow keeps the view inside its bounds, centring it when it doesn't fit") { + Entity target(glm::vec2(0.0f, 0.0f)); + CameraFollow follow; + Camera camera(glm::vec2(0.0f), glm::vec2(12.0f, 12.0f), 1.0f); // A 12 by 12 view, as the test window is square. + follow.setBounds(glm::vec2(-20.0f, -3.0f), glm::vec2(20.0f, 3.0f)); + + target.setPosition(glm::vec2(-100.0f, 0.0f)); + follow.apply(&camera, &target, 0.1f); + CHECK_NEAR(camera.getPosition().x, -14.0, 1e-4); // Left edge of the view on the bound. + CHECK_NEAR(camera.getPosition().y, 0.0, 1e-4); // Taller than the bounds, so centred. + + camera.setZoom(2.0f); // Half the view, so it fits vertically now. + target.setPosition(glm::vec2(0.0f, 50.0f)); + follow.apply(&camera, &target, 0.1f); + CHECK_NEAR(camera.getPosition().y, 0.0, 1e-4); + CHECK_NEAR(camera.getPosition().x, 0.0, 1e-4); +} + +TEST("follow settings round trip through json") { + CameraFollow original; + original.setTarget(42)->setOffset(glm::vec2(1.0f, 2.0f))->setSmoothing(0.25f)->setDeadZone(glm::vec2(1.0f, 0.5f)); + original.setBounds(glm::vec2(5.0f, 5.0f), glm::vec2(-5.0f, -5.0f)); + Component* c = FACTORY(Component).create("CameraFollow"); + CHECK(c != nullptr); + if (c == nullptr) {return;} + CHECK(c->load(original.serialise())); + CameraFollow* loaded = dynamic_cast(c); + CHECK(loaded->getTarget() == 42); + CHECK(loaded->getOffset() == glm::vec2(1.0f, 2.0f)); + CHECK_NEAR(loaded->getSmoothing(), 0.25, 1e-6); + CHECK(loaded->hasBounds()); + CHECK(loaded->getBoundsMin() == glm::vec2(-5.0f, -5.0f)); // Corners are ordered. + CHECK(loaded->serialise() == original.serialise()); + nlohmann::json broken = original.serialise(); + broken.erase("deadZone"); + CHECK(!loaded->load(broken)); + delete c; +} + +TEST("gamepad buttons begin and end down for one poll") { + GLFWgamepadstate state = {}; + CHECK(!GamepadListener::isConnected(0)); + GamepadListener::setState(0, true, state, "Test Pad"); + CHECK(GamepadListener::isConnected(0)); + CHECK(GamepadListener::getName(0) == "Test Pad"); + + state.buttons[GLFW_GAMEPAD_BUTTON_A] = GLFW_PRESS; + GamepadListener::setState(0, true, state); + CHECK(GamepadListener::isButtonDown(GLFW_GAMEPAD_BUTTON_A)); + CHECK(GamepadListener::isButtonBeginDown(GLFW_GAMEPAD_BUTTON_A)); + GamepadListener::setState(0, true, state); + CHECK(GamepadListener::isButtonDown(GLFW_GAMEPAD_BUTTON_A)); + CHECK(!GamepadListener::isButtonBeginDown(GLFW_GAMEPAD_BUTTON_A)); + + // Disconnecting releases every button. + GamepadListener::setState(0, false, state); + CHECK(!GamepadListener::isConnected(0)); + CHECK(!GamepadListener::isButtonDown(GLFW_GAMEPAD_BUTTON_A)); + CHECK(GamepadListener::isButtonEndDown(GLFW_GAMEPAD_BUTTON_A)); + GamepadListener::setState(0, false, state); + CHECK(!GamepadListener::isButtonEndDown(GLFW_GAMEPAD_BUTTON_A)); + + CHECK(!GamepadListener::isButtonDown(GLFW_GAMEPAD_BUTTON_A, 99)); + CHECK(!GamepadListener::isButtonDown(99)); +} + +TEST("gamepad axes remove the dead zone, flip stick y, and map triggers to 0 to 1") { + GamepadListener::setDeadZone(0.2f); + GLFWgamepadstate state = {}; + state.axes[GLFW_GAMEPAD_AXIS_LEFT_X] = 0.1f; + state.axes[GLFW_GAMEPAD_AXIS_LEFT_Y] = -1.0f; // GLFW reports up as negative. + state.axes[GLFW_GAMEPAD_AXIS_RIGHT_X] = 0.6f; + state.axes[GLFW_GAMEPAD_AXIS_LEFT_TRIGGER] = -1.0f; + state.axes[GLFW_GAMEPAD_AXIS_RIGHT_TRIGGER] = 1.0f; + GamepadListener::setState(1, true, state); + + CHECK_NEAR(GamepadListener::getAxis(GLFW_GAMEPAD_AXIS_LEFT_X, 1), 0.0, 1e-6); + CHECK_NEAR(GamepadListener::getAxis(GLFW_GAMEPAD_AXIS_RIGHT_X, 1), 0.5, 1e-6); + CHECK_NEAR(GamepadListener::getAxis(GLFW_GAMEPAD_AXIS_LEFT_TRIGGER, 1), 0.0, 1e-6); + CHECK_NEAR(GamepadListener::getAxis(GLFW_GAMEPAD_AXIS_RIGHT_TRIGGER, 1), 1.0, 1e-6); + + glm::vec2 left = GamepadListener::getLeftStick(1); + CHECK(left.y > 0.99f); + CHECK(left.x > 0.0f && left.x < 0.2f); // The dead zone applies to the stick's length, not each axis. + CHECK_NEAR(GamepadListener::getRightStick(1).x, 0.5, 1e-6); + + GamepadListener::setState(1, false, state); + CHECK(GamepadListener::getLeftStick(1) == glm::vec2(0.0f)); + CHECK_NEAR(GamepadListener::getAxis(GLFW_GAMEPAD_AXIS_RIGHT_TRIGGER, 1), 0.0, 1e-6); +} + +RUN_TESTS() diff --git a/tests/test_camera_engine.cpp b/tests/test_camera_engine.cpp new file mode 100644 index 0000000..f8bdc48 --- /dev/null +++ b/tests/test_camera_engine.cpp @@ -0,0 +1,116 @@ +// Runs the real engine loop to check CameraFollow finding its target through the scene, camera shake from +// the console, and gamepads being polled every frame. + +#include +#include "test.hpp" +#include "pancake/pancake.hpp" + +using namespace Pancake; + +namespace { + + int frame = 0; + int shakeStarted = -1; + int faded = -1; + + void log(const char* message) { + std::printf("frame %d: %s\n", frame, message); + std::fflush(stdout); + } + + Camera* camera() { + return Pancake::getScene()->getCamera(); + } + +} + +class Driver : public Component { + + public: + + Driver() : Component("Driver") {} + + void update(float dt) override { + + frame++; + Scene* scene = Pancake::getScene(); + Entity* player = scene->findEntity("Player"); + Entity* other = scene->findEntity("Other"); + + if (frame == 3) { + log("the camera starts on the player"); + CHECK(camera()->getPosition() == vec2(5.0f, 5.0f)); + player->setPosition(vec2(8.0f, 5.0f)); + } + + if (frame == 5) { + log("it follows the player, then another target"); + CHECK(camera()->getPosition() == vec2(8.0f, 5.0f)); + player->getComponent()->setTarget(other); + } + + if (frame == 7) { + CHECK(camera()->getPosition() == vec2(-5.0f, 0.0f)); + other->kill(); // The camera stays put once its target is gone. + } + + if (frame == 9) { + CHECK(camera()->getPosition() == vec2(-5.0f, 0.0f)); + log("shake from the console"); + Console::execute("shake 1 0.2"); + CHECK(camera()->isShaking()); + shakeStarted = frame; + } + + if (shakeStarted > 0 && frame > shakeStarted && !camera()->isShaking()) { + log("the shake has faded"); + CHECK(camera()->getShakeOffset() == vec2(0.0f)); + CHECK(camera()->getPosition() == vec2(-5.0f, 0.0f)); + shakeStarted = -1; + faded = frame; + + // A fake pad is replaced by what the next poll finds, which is nothing. + GLFWgamepadstate state = {}; + state.buttons[GLFW_GAMEPAD_BUTTON_A] = GLFW_PRESS; + GamepadListener::setState(0, true, state, "Fake"); + CHECK(GamepadListener::isButtonDown(GLFW_GAMEPAD_BUTTON_A)); + } + + if (shakeStarted > 0 && frame - shakeStarted > 5000) {log("the shake never faded"); CHECK(false); Pancake::stop();} + + if (faded > 0 && frame == faded + 1) { + log("gamepads are polled every frame"); + CHECK(!GamepadListener::isConnected(0)); + CHECK(!GamepadListener::isButtonDown(GLFW_GAMEPAD_BUTTON_A)); + CHECK(GamepadListener::isButtonEndDown(GLFW_GAMEPAD_BUTTON_A)); + Console::execute("gamepads"); + } + + if (faded > 0 && frame == faded + 3) {Pancake::stop();} + + } + +}; + +void init(Scene* scene) { + Entity* player = new Entity(vec2(5.0f, 5.0f)); + player->setName("Player"); + player->addComponent(); + scene->addEntity(player); + + Entity* other = new Entity(vec2(-5.0f, 0.0f)); + other->setName("Other"); + scene->addEntity(other); + + Entity* d = new Entity(); + d->addComponent(new Driver()); + scene->addEntity(d); +} + +int main() { + Pancake::load(init); + Pancake::start(); + CHECK(faded > 0 && frame == faded + 3); + std::printf("engine ran %d frames, %d failed checks\n", frame, PancakeTest::failures()); + return PancakeTest::failures() == 0 ? 0 : 1; +}