From d1c9268ffc8ea81400f1a97ac7524c8e9326f0ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:18:58 +0000 Subject: [PATCH] Add camera follow and shake, and gamepad input Batch 4 of ROADMAP.md. - CameraFollow component: follows its own entity, or another by id, with an offset, exponential smoothing that doesn't depend on frame rate, a dead zone, and bounds that keep the view inside them (or centre it when it doesn't fit). It snaps on start and when the target changes, and is serialised. The logic is in apply(camera, target, dt), so it is tested without a window. - Camera::shake(intensity, duration) moves the view by a random offset that fades out quadratically, keeping the stronger of overlapping shakes. Only getView changes, so the camera's position, following and mouse world coordinates are unaffected. A paused scene holds the offset. - GamepadListener: polled each frame after glfwPollEvents. Buttons have down, begin down and end down (compared with the previous poll, and a disconnect releases everything). Axes have a configurable dead zone, applied radially to sticks. Stick y points up, and triggers go from 0 to 1. setState lets tests and tools fake a pad. - Console commands shake and gamepads. - The demo shakes the camera on hard impacts, and a gamepad's left stick pans and A drops a box. Tests: new unit test test_camera for shake, follow (snap, frame-rate independent smoothing, dead zone, offset, bounds, json) and gamepad state, and engine test test_camera_engine for following through the scene, changing and losing the target, shake from the console, and per-frame polling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015cNAkNqoDWA5gFReD1AsqV --- CLAUDE.md | 2 + README.md | 21 +- ROADMAP.md | 6 +- examples/demo.cpp | 9 + include/pancake/components/camerafollow.hpp | 59 ++++++ include/pancake/core/camera.hpp | 12 ++ include/pancake/core/listener.hpp | 27 +++ include/pancake/pancake.hpp | 1 + src/components/camerafollow.cpp | 224 ++++++++++++++++++++ src/core/camera.cpp | 43 +++- src/core/console.cpp | 23 ++ src/core/listener.cpp | 97 +++++++++ src/core/window.cpp | 1 + tests/CMakeLists.txt | 2 + tests/test_camera.cpp | 179 ++++++++++++++++ tests/test_camera_engine.cpp | 116 ++++++++++ 16 files changed, 815 insertions(+), 7 deletions(-) create mode 100644 include/pancake/components/camerafollow.hpp create mode 100644 src/components/camerafollow.cpp create mode 100644 tests/test_camera.cpp create mode 100644 tests/test_camera_engine.cpp 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; +}