diff --git a/docs/PC.md b/docs/PC.md index e45ced1ce..9cefd30d6 100644 --- a/docs/PC.md +++ b/docs/PC.md @@ -125,7 +125,7 @@ key is optional; these are the defaults: "sound": "stereo" // stereo or mono }, "input": { - "mouse_sensitivity": 0.1, // right-stick deflection (1 = full) per pixel moved in one tick + "mouse_sensitivity": 0.2, // degrees the camera turns per count of mouse motion "stick_sensitivity": 1.33, // gamepad stick scale before the game's dead zone (PCSX2's default) "stick_invert_x": false, // the gamepad's camera stick, flipped left to right "stick_invert_y": false, // ...and up and down @@ -135,12 +135,14 @@ key is optional; these are the defaults: "gyro_invert_y": false, "mouse_invert_y": false, "mouse_capture": true, // SDL relative mouse mode while the window has focus + "mouse_zoom": false, // optional third-person wheel zoom; middle click resets by default "mouse_release": ["Escape"], // keys that give the cursor back in a window ([]: none) "vibration": true, // the gamepad's rumble "bindings": { "cross": ["Mouse1", "Space"], // an action: its keys and mouse buttons; replaces the defaults - "ry": "-MouseY", // lx ly rx ry take MouseX or MouseY, with a sign and a scale (MouseX*0.5) - "fps_toggle": "F3" // the FPS counter on and off + "ry": "-MouseY*0.5", // lx ly rx ry take MouseX or MouseY, with a sign and a scale: the mouse as that stick + "fps_toggle": "F3", // the FPS counter on and off + "zoom_reset": "Mouse3" // optional zoom: restore the camera's normal distance } }, "discord": { @@ -227,7 +229,7 @@ dungeon's `PadInput_OK` is cross and `PadInput_NO` circle, | Input | Pad | What the game does with it | |---|---|---| | WASD | left stick | walk (`dun/gameloop.cpp:3006`, the town's `EdMoveChara`); menus with `MenuModeOn` turn it into the d-pad; d-pad as well where nothing reads the stick (below) | -| mouse motion | right stick | camera: `AddAngle(0.04 * -GetRXf())`, `AddHeight(-GetRYf())` (`dun/gameloop.cpp:4327`; the town's `MoveCamera`, `editloop.cpp:3793`) | +| mouse motion | none | the camera, directly (see "Mouse look") | | left click, Space | cross | attack, open, talk (`dun/gameloop.cpp:3585`, `editloop.cpp:3885`); confirm | | right click, X | R1 | held while locked on: guard (`dun/gameloop.cpp:3347`, `guard_mode = 5` at :3374); unlocked, turns the camera (:4338) | | F | circle | lock on to the nearest enemy or let go; with none in range, swing the camera behind the character (`dun/gameloop.cpp:3243`); back in menus | @@ -247,12 +249,12 @@ dungeon's `PadInput_OK` is cross and `PadInput_NO` circle, | gamepad L5 (`paddle4`) | none | held, the gyroscope turns the camera (`gyro` set to `held`) | The square button is not a guard in this game: the guard is R1 held while -locked on, so right click is R1. The camera turns the way the view moves on -PC: the follow camera sits at `follow + distance * (sin a, cos a)` -(`camerafollow.cpp`) and the stick's right is the screen's right -(`move_x = lx cos a + ly sin a`), so a positive RX, which lowers `a`, turns -the view right; a positive RY lowers the camera, which looks up, so the -default `ry = -MouseY` makes mouse up look up and `mouse_invert_y` flips it. +locked on, so right click is R1. The follow camera sits at +`follow + distance * (sin a, cos a)` (`camerafollow.cpp`) and the stick's +right is the screen's right (`move_x = lx cos a + ly sin a`), so a positive +RX, which lowers `a`, turns the view right, and a positive RY lowers the +camera, which looks up. Mouse right turns the view right and mouse up looks +up; `mouse_invert_y` flips the latter. The actions are `up down left right cross circle square triangle l1 r1 l2 r2 l3 r3 start select lx- lx+ ly- ly+ rx- rx+ ry- ry+ lx ly rx ry`, and the @@ -283,18 +285,25 @@ Each key-down of a toggle's key counts once, however briefly it is held. is ignored as drift. `gyro_invert_x`/`_y` flip it, and `stick_invert_x`/`_y` flip the camera stick the same way. - **First-person view.** R2's view, in a dungeon or outdoors in a town, reads - only the left stick, so while it is on, the right stick, the mouse and the gyro drive the left stick whenever + only the left stick, so while it is on, the right stick and the gyro drive the left stick whenever the left stick itself is centred. -- **Mouse.** The right stick at each pad read is the motion since the - previous read, divided by the ticks between them, times - `mouse_sensitivity` (0.1: ten pixels in one tick is full deflection, - 0.04 radians of dungeon camera per frame). A mouse that stops reads - centred at the next read; motion during a load does not land at once. +- **Mouse.** The mouse turns the camera itself ("Mouse look"), not the + right stick. Binding `MouseX` or `MouseY` to `lx ly rx ry` makes it a + stick again on that axis and takes that axis from the mouse look: the + stick reads the motion since the previous read divided by the ticks + between them, at the deflection that would turn the dungeon's camera + (0.04 radians a tick at full deflection) as far as the mouse look does, + times the binding's scale, and full deflection at most. - **Capture.** With `mouse_capture`, SDL relative mode holds the cursor - while the window has focus. Losing focus releases it, and so does + while the window has focus. Fullscreen startup captures when focus arrives, + and focus regain restores capture. Losing focus releases it, and so does `mouse_release` (Escape) when the window is not fullscreen; a click in the window captures again and does nothing else. While released, motion - and clicks do not reach the game. Without capture they always do. + and clicks do not reach the game. Without capture they always do, and + the motion is the cursor's, after the system's pointer acceleration; + captured, it is the mouse's raw counts (SDL's relative mode with + `SDL_HINT_MOUSE_RELATIVE_SYSTEM_SCALE` off, unless the environment sets + `SDL_MOUSE_RELATIVE_SYSTEM_SCALE`, which wins). - **WASD on the d-pad.** The developer menu (`MenuLoop`) and the dungeon loader read only the d-pad and never call `MenuModeOn`; the dungeon reads the d-pad to pick the active item while WASD walks. So the movement keys @@ -309,13 +318,111 @@ Each key-down of a toggle's key counts once, however briefly it is held. turns a full WASD deflection (128) into the d-pad; a diagonal (91 per axis) stays under their threshold, as a gamepad's does. +### Mouse look + +The mouse turns each camera the right stick turns, by the angle it moved: +`mouse_sensitivity` degrees per count, with no dead zone, no steps and no +full deflection to cap it (`port/src/camera_port.cpp`). The dungeon's, +the interior's and the georama view's cameras and the first-person views +read it in the stick's units, added to the stick's reading, so their own +conditions see both alike; the town's walk camera takes it apart (below). A +gamepad's stick is unchanged. + +A follow camera eases towards its angle and its eye towards its place +(`CCameraFollow::Step`, `CCamera::Step`): a turn shows over a few tenths of +a second, and one past half a turn would go the short way round, backwards. +That suits the stick's small steady steps, not the mouse, so the mouse's +share of a turn is applied at once: when the camera's next `AddAngle` in the +same pad read is exactly the delta of the reading the mouse went into, the +port's replacement (`port/src/camerafollow.cpp`) turns the angle and the eye +about the point it looks at by that share immediately. Any other `AddAngle` +on that camera, or the next pad read, ends the reading unused; mouse and +stick that cancel turn nothing. The stick's share, the following of the +character and the game's own camera moves still ease. + +The town's walk camera (`EdMoveChara`, walking and fishing) tests the walls +from where the eye is before the stick turns it, which allows for a stick's +small step but not for a mouse's whole turn: taken the same way, a fast turn +put the eye past a wall, and the camera then swung round behind it. So the +town's RX stays the stick's, and `EdGetRXf` records the mouse's turn in a +request `EditLoop` opens around the walk. After the camera's step, if the +same camera is still shown on the same ground, map and mode in the same pad +read, `EditLoop` applies the safe prefix of the mouse turn immediately. A +continuous clearance bound covers the look-ray fan and the eye's movement, +including pending positions and the remaining follow-camera easing corridor. +Intervals whose bound touches geometry are subdivided; clear endpoint samples +alone never authorize a turn. The eye keeps 10 units of clearance and the +floor query keeps 18 below it. An initially invalid corridor rejects the +mouse turn until the game repairs it; refused motion is dropped. + +The port collector allocates a proven upper bound before calling each frame's +collector and includes all grid cells crossed by the sweep. Unknown collision +types, invalid dimensions, or work-budget exhaustion reject unvalidated motion. +Collection is limited to 32,768 polygons and 4,096 frame visits; each turn has +at most 4,096 interval checks and one million polygon checks. The camera can +stop earlier than the stick, +which may slide along a wall the town finds beside the eye. As RX no longer +carries the mouse, the drift behind a walking character and R1 and L1, which +wait for RX to rest, see only the stick: the camera can swing behind while +the mouse turns it. + +Live motion is taken whole at each read of pad 1 (`InputLatchPad`, once per +pass of the main loop), so each count reaches the camera at one read +whatever the display's frames do between ticks; a pause in the reads longer +than a quarter of a second (a load) drops what came during it. A script's +`mouse:DX,DY` is the motion of each read. + +Third-person mouse pitch changes the follow camera's height while it keeps +looking at the player. It does not rotate the view independently of the player. +Height requests stay inside the gameplay limits, and the return toward the +baseline is one fifth of its retail rate after mouse pitch input. Floor and +wall correction and the automatic horizontal swing behind a walking player +retain their retail behavior. + +Optional **Mouse Wheel Zoom**, under Options > Controls, changes the distance +from the player: wheel up moves closer, wheel down farther. **Reset Zoom** +defaults to middle mouse; Controls offers middle mouse, either side button, +Home or Disabled. Other bindings can be set with `input.bindings.zoom_reset`. +With zoom enabled its reset binding takes priority over conflicting pad +bindings; disabling zoom restores normal input behavior. Reset restores the +town's normal distance or the dungeon camera's starting distance, as far as +geometry allows. Zoom is limited to 30–140 world units, follows normal camera +easing and validates the entire pending distance corridor. Dungeon zoom retains +ten-unit wall clearance and five-unit floor clearance for its normally lower +eye. Collision can limit the requested distance and restore it when clear. +Menus, first-person views, Georama, fishing, lock-on and scripted cameras do +not consume wheel zoom input. The feature is off by default. + +| View | Horizontal | Vertical | +|---|---|---| +| dungeon (`DunMoveChara`) | `AddAngle(0.04 * -turn)` | camera height, `AddHeight(-ry)`, at most 30 | +| dungeon, first person (R2, `EyeCamera`) | heading, beside the left stick | pitch, inside retail's -1 to 0.65 | +| town (`EdMoveChara`, `EdGetRXf`/`EdGetRYf`, `EditLoop`) | the eye turned after the step, as far as it stays clear of walls | camera height while under 30, the mouse raising it to 30 at most | +| town, first person (R2, `EyeCamera`) | heading, beside the left stick | pitch, inside -1 to 0.65 | +| interior (`MoveCamera`, `edit_in.cpp`) | `AddAngle(0.04 * -horizontal)` | camera height, at most 30 | +| interior, first person (`EyeCamera`) | the character's heading, which the view follows, beside both sticks | pitch, inside -1 to 0.65 | +| georama (`MoveCamera`, `editloop.cpp`) | `AddAngle(0.03 * -horizontal)` | none: the view sets its height each frame | + +Vertical motion on a follow camera, which has a height rather than a pitch, +changes the height by what tilts the line from the eye to the point it +circles by the mouse's angle (`MouseLookTiltHeight`). It changes only the +height the camera is going to, as the stick does, and eases: the game's +corrections bound that height afterwards (the dungeon at least 1.6, and 25 +over the floor under the eye; the town 18 over it where the floor is level +enough), and the mouse raises it to 30 at most, the player's limit. It is not +a free pitch: looking up from the default view moves the camera little, and +the dungeon moves its height on its own (`autoCamTrial`), so a raised dungeon +camera drifts back. A first-person view takes the mouse only while it is +shown, and its heading stays within half a turn. The developer cameras (`EdDMoveCamera`, +the item viewer) and the event script's `GET_APAD` read the stick alone. + ### Scripted input `--input FILE` (`port/src/platform/input_script.cpp`) replaces the pads with a script, through `InputSetOverride`. Each line is ``` - [pad1|pad2] [button ...] [key:NAME ...] [mouseN ...] [mouse:DX,DY] [lx ly rx ry] + [pad1|pad2] [button ...] [key:NAME ...] [mouseN ...] [mouse:DX,DY] [wheel:NOTCHES] [lx ly rx ry] ``` and holds the named buttons (`cross circle square triangle start select l1 @@ -323,9 +430,9 @@ r1 l2 r2 l3 r3 up down left right`, any case) and the four stick bytes (0-255, centred at 128 when left out) on that pad, pad 1 unless the line says `pad2`, from that frame until the pad's next line. On pad 1 lines, `key:NAME` (an SDL key name, `_` for a space), `mouse1` to `mouse5` and -`mouse:DX,DY` (pixels per tick) go through the keyboard and mouse bindings -as live input does, d-pad rule included, and reach `fps_toggle` too: a -line that starts holding `key:F3` is one press of it. A line with no +`mouse:DX,DY` (counts per tick) go through the keyboard and mouse bindings +and the mouse look as live input does, d-pad rule included, and reach +`fps_toggle` too: a line that starts holding `key:F3` is one press of it. A line with no button releases everything. Frames count the game's main loop as `--frames` does; frame 0 also covers the 60-tick warm-up and the loading screens before the first frame. `#` starts a comment; a pad's frames must diff --git a/docs/port/truncations.md b/docs/port/truncations.md index ab333002b..f3176b3a0 100644 --- a/docs/port/truncations.md +++ b/docs/port/truncations.md @@ -61,7 +61,7 @@ recovered, as `CCharacter::ClothStep` does with `PortImagePointer`) fixes the ro | site | function | state | class | expression | origin | use | |---|---|---|---|---|---|---| | port/src/dataread.cpp:184 | LoadFileBG | port | low-bits | `reinterpret_cast < std :: uintptr_t >(buffer)` | parameter | test | -| port/src/edit_in.cpp:1757 | LoadData | port | low-bits | `(intptr_t)func_point[i].parts` | field | index: InteriorParts[(int)(intptr_t)func_point[i].parts] | +| port/src/edit_in.cpp:1758 | LoadData | port | low-bits | `(intptr_t)func_point[i].parts` | field | index: InteriorParts[(int)(intptr_t)func_point[i].parts] | | port/src/editloop_init.cpp:405 | EditInit | port | low-bits | `reinterpret_cast < std :: intptr_t >(EdNPCReadBuffer)` | global pointer | store: read_misalign | | port/src/texture_buffer.cpp:14 | CTextureManager::SetBuffer | port | low-bits | `reinterpret_cast < std :: uintptr_t >(this->buffer)` | field | store: misalignment | | ps2/src/dataset.cpp:381 | LoadMDSFile | retail | low-bits | `(int)data` | parameter | test | diff --git a/port/include/stubs/ebattle.hpp b/port/include/stubs/ebattle.hpp new file mode 100644 index 000000000..060db40c2 --- /dev/null +++ b/port/include/stubs/ebattle.hpp @@ -0,0 +1,11 @@ +#pragma once + +// The port's EdGetRXf, EdGetRYf and EyeCamera (port/src/ebattle.cpp) ask this unit's static +// check_key_mode whether the editor's input mode owns the pad, through a global forwarder; that also +// keeps clang from dropping the static as unused. + +static int check_key_mode(int mode); + +int PortEdCheckKeyMode(int mode) { + return check_key_mode(mode); +} diff --git a/port/src/camera_port.cpp b/port/src/camera_port.cpp new file mode 100644 index 000000000..89be758df --- /dev/null +++ b/port/src/camera_port.cpp @@ -0,0 +1,258 @@ +#include "camera_port.hpp" + +#include + +#include +#include +#include +#include +#include + +#include "camerafollow.hpp" +#include "collision.hpp" +#include "editground.hpp" +#include "gameutil.hpp" +#include "mouse_collision.hpp" +#include "camera_zoom.hpp" +#include "platform/input.hpp" + +namespace { + +// About 86 degrees: the tangent stays finite. +constexpr float kMaxTilt = 1.5f; + +constexpr float kEyeDownLimit = 0.65f; +constexpr float kEyeUpLimit = -1.0f; + +// What a reading left with its camera: the AddAngle delta it makes, the stick's share of that +// delta, and the pad read it belongs to. +struct Share { + CCameraFollow *camera = nullptr; + float delta = 0.0f; + float stick_delta = 0.0f; + std::uint64_t read = 0; +}; + +Share g_turn; + +// Only a gameplay height read grants ownership for this pad read. Scripted height changes, +// floor clearance and controller-only cameras retain their retail behavior. +struct HeightControl { + CCameraFollow *camera = nullptr; + std::uint64_t read = 0; + float input_delta = 0.0f; + bool input_pending = false; + bool native = false; + bool moving = false; + bool stick = false; +}; +HeightControl g_height; + +struct TownRequest { + bool open = false; + CCameraFollow *camera = nullptr; + CEditGround *ground = nullptr; + int map = 0; + int mode = 0; + std::uint64_t read = 0; + float yaw = 0.0f; + bool fishing = false; +}; + +TownRequest g_town; + +// Reused only by the town request; collection proves bounds before writing. +std::vector g_town_polys; + +CBoxVu0 CameraBox(CCameraFollow &camera) { + CBoxVu0 box{}; + float radius = std::max({std::hypot(camera.pos[0] - camera.ref[0], camera.pos[2] - camera.ref[2]), + std::hypot(camera.next_pos[0] - camera.next_ref[0], camera.next_pos[2] - camera.next_ref[2]), + std::fabs(camera.distance)}) + + 20.0f; + for (int i = 0; i < 3; ++i) { + float low = std::min({camera.ref[i], camera.next_ref[i], camera.follow[i], camera.pos[i], camera.next_pos[i]}); + float high = std::max({camera.ref[i], camera.next_ref[i], camera.follow[i], camera.pos[i], camera.next_pos[i]}); + box.min[i] = low - radius - std::fabs(camera.height); + box.max[i] = high + radius + std::fabs(camera.height); + } + return box; +} + +void Orbit(float *point, const float *centre, float turn) { + float c = std::cos(turn); + float s = std::sin(turn); + float x = point[0] - centre[0]; + float z = point[2] - centre[2]; + point[0] = centre[0] + x * c + z * s; + point[2] = centre[2] + z * c - x * s; +} + +} // namespace + +float MouseLookTurn(CCameraFollow *camera, float radians, float stick) { + const InputMouseLook &look = InputGetMouseLook(); + g_turn = {}; + if (look.yaw == 0.0f) { + return stick; + } + float reading = stick + look.yaw / radians; + // Mouse and stick that cancel turn nothing, as in the town, which then makes no AddAngle. + if (reading != 0.0f) { + g_turn = {camera, radians * -reading, radians * -stick, look.read}; + } + return reading; +} + +float MouseLookRise(CCameraFollow *camera, float stick, float ceiling, float floor) { + const InputMouseLook &look = InputGetMouseLook(); + float pitch = look.pitch; + bool native = g_height.camera == camera && g_height.native; + if (!camera->follow_on || CCamera::StopCamera) { + g_height = {}; + return stick; + } + float height = camera->GetHeight(); + float target = MouseLookTiltHeight(height, camera->GetDistance(), pitch); + if (pitch < 0.0f) { + target = std::min(target, std::max(height, ceiling)); + } else if (pitch > 0.0f) { + target = std::max(target, std::min(height, floor)); + } + float reading = stick - (target - height); + g_height = {camera, look.read, -reading, true, native || pitch != 0.0f, pitch != 0.0f, stick != 0.0f}; + return reading; +} + +float MouseLookHeightDelta(CCameraFollow *camera, float delta) { + if (g_height.camera != camera || g_height.read != InputGetMouseLook().read || + !camera->follow_on || CCamera::StopCamera) { + return delta; + } + if (g_height.input_pending && delta == g_height.input_delta) { + g_height.input_pending = false; + return delta; + } + if (!g_height.native || g_height.stick) { + return delta; + } + // EdMoveChara and autoCamTrial use this exact descent toward their baseline. + // Recognize that operation rather than slowing arbitrary camera or collision changes. + for (float baseline : {5.0f, 35.0f}) { + if (camera->height > 5.0f) { + float drop = std::clamp((camera->height - baseline) * 0.05f, 0.15f, 0.5f); + if (delta == -drop) { + return g_height.moving ? 0.0f : delta * 0.2f; + } + } + } + return delta; +} + +float MouseLookTakeTurn(CCameraFollow *camera, float delta) { + if (g_turn.camera != camera) { + return 0.0f; + } + // A reading lasts until its camera's next turn within the same pad read: a turn the camera + // refused is not carried into a later one. + Share share = g_turn; + g_turn = {}; + if (share.read != InputGetMouseLook().read || delta != share.delta) { + return 0.0f; + } + return delta - share.stick_delta; +} + +float MouseLookTiltHeight(float height, float distance, float pitch) { + if (pitch == 0.0f || !(distance > 0.0f)) { + return height; + } + float tilt = std::atan2(height, distance); + float tilted = tilt - pitch; + // Only the way the mouse moves is held back: an eye already past the limit stays where it is. + if (pitch < 0.0f) { + tilted = std::min(tilted, std::max(tilt, kMaxTilt)); + } else { + tilted = std::max(tilted, std::min(tilt, -kMaxTilt)); + } + return distance * std::tan(tilted); +} + +float MouseLookEyeAngleV(float angle) { + float pitch = InputGetMouseLook().pitch; + float tilted = angle - pitch; + if (pitch > 0.0f) { + return std::max(tilted, std::min(angle, kEyeUpLimit)); + } + if (pitch < 0.0f) { + return std::min(tilted, std::max(angle, kEyeDownLimit)); + } + return angle; +} + +float MouseLookEyeAngleH(float angle) { + constexpr float kPi = std::numbers::pi_v; + float yaw = InputGetMouseLook().yaw; + if (yaw == 0.0f) { + return angle; + } + return std::remainder(angle - yaw, 2.0f * kPi); +} + +float MouseLookClampOrbit(const float *eye, const float *look, float turn, const MouseLookPolys &polys) { + float distance = std::hypot(eye[0] - look[0], eye[2] - look[2]); + float angle = std::atan2(eye[0] - look[0], eye[2] - look[2]); + CCameraFollow camera(distance, eye[1] - look[1], angle, 8.0f); + sceVu0CopyVector(camera.pos, const_cast(eye)); + sceVu0CopyVector(camera.next_pos, const_cast(eye)); + sceVu0CopyVector(camera.ref, const_cast(look)); + sceVu0CopyVector(camera.next_ref, const_cast(look)); + sceVu0CopyVector(camera.follow, const_cast(look)); + CBoxVu0 box = CameraBox(camera); + CCPoly *walls = nullptr; + int count = polys(box, &walls); + return MouseCameraClamp(camera, turn, walls, count); +} + +void MouseLookTurnOrbit(CCameraFollow *camera, float turn) { + constexpr float kTurn = 2.0f * std::numbers::pi_v; + Orbit(camera->pos, camera->ref, turn); + Orbit(camera->next_pos, camera->next_ref, turn); + camera->angle = std::remainder(camera->angle + turn, kTurn); + camera->next_angle = std::remainder(camera->next_angle + turn, kTurn); + // The view's own angles, which a step works out from the eye, without stepping it. + camera->CCamera::Step(0); +} + +void TownMouseBegin(CCameraFollow *camera, CEditGround *ground, int map, int mode) { + g_town = {true, camera, ground, map, mode, InputGetMouseLook().read}; +} + +void TownMouseRecord(CCameraFollow *camera, bool fishing) { + if (g_town.open && g_town.camera == camera && g_town.read == InputGetMouseLook().read && g_town.yaw == 0.0f) { + g_town.yaw = InputGetMouseLook().yaw; + g_town.fishing = fishing; + if (!fishing) TownZoomRead(camera); + } +} + +void TownMouseApply(CCamera *shown, CEditGround *ground, int map, int mode) { + TownRequest request = g_town; + g_town = {}; + CCameraFollow *camera = request.camera; + if (!request.open || request.yaw == 0.0f || static_cast(camera) != shown || request.ground != ground || + request.map != map || request.mode != mode || request.read != InputGetMouseLook().read || + !camera->follow_on || CCamera::StopCamera) { + return; + } + if (!ground) { + return; + } + int mask = request.fishing ? 1 : 0xFFFF; + CBoxVu0 box = CameraBox(*camera); + int count = MouseCameraPolys(*ground, box, mask, g_town_polys); + float turn = MouseCameraClamp(*camera, -request.yaw, g_town_polys.data(), count); + if (turn != 0.0f) { + MouseLookTurnOrbit(camera, turn); + } +} diff --git a/port/src/camera_port.hpp b/port/src/camera_port.hpp new file mode 100644 index 000000000..432b6864c --- /dev/null +++ b/port/src/camera_port.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include + +class CBoxVu0; +class CCamera; +class CCameraFollow; +class CCPoly; +class CEditGround; + +// The mouse turns the game's cameras where the right stick does, by the angle it moved +// (InputGetMouseLook) rather than through a stick's dead zone, steps and full deflection. A camera +// reads it in its stick's units, added to the stick's reading, so its own conditions (R1 and L1 +// only while the stick rests) see both alike; the town's walk camera takes it apart (below). +// +// A follow camera eases towards its angle and its eye towards its place (CCameraFollow::Step, +// CCamera::Step), which suits a stick's small steady steps but trails the mouse and takes the +// shorter way round, backwards, once a step passes half a turn. So a turning reading also leaves +// the mouse's share with the camera, and the camera's next AddAngle within the same pad read, if +// it is exactly that reading's delta, turns the camera by the share at once +// (port/src/camerafollow.cpp); the stick's share and everything else the camera does still ease. +// Height is not taken at once: the floor and ceiling limits the game puts on it afterwards bound +// only where the eye is going. + +// The stick's reading plus the mouse's turn this tick, for a camera that turns by +// AddAngle(radians * -reading): right turns right. +float MouseLookTurn(CCameraFollow *camera, float radians, float stick); + +// The stick's reading plus the mouse's tilt this tick, for a follow camera that lowers its eye by +// AddHeight(-reading): the change of height that tilts the line from the eye to the point it +// circles by the mouse's angle, mouse up looking up. The mouse raises the eye no higher than +// ceiling, and never requests a height below floor. The eye continues looking at the follow +// target; height changes keep the retail easing and collision correction. +float MouseLookRise(CCameraFollow *camera, float stick, float ceiling, float floor = 1.6f); + +// Preserves manual input and floor clearance, but slows the retail baseline descent to one fifth +// after native mouse use. Only an active gameplay height read owns this operation. +float MouseLookHeightDelta(CCameraFollow *camera, float delta); + +// The mouse's share of an AddAngle delta on camera: nonzero only for the delta of the reading +// MouseLookTurn last gave for it in the current pad read. Any AddAngle on that camera ends the +// reading. +float MouseLookTakeTurn(CCameraFollow *camera, float delta); + +// The eye's height over the point it circles from `distance` away once the view tilts up by `pitch` +// radians, short of straight up or down. +float MouseLookTiltHeight(float height, float distance, float pitch); + +// A first-person view's vertical angle (above zero looks down) once the mouse tilts it, inside the +// game's limits (EyeCamera, dun/gameloop.cpp:9598): the stick stops past 0.65 down and -1 up. +float MouseLookEyeAngleV(float angle); + +// A first-person view's heading once the mouse turns it, within half a turn either way. +float MouseLookEyeAngleH(float angle); + +// The town's walk camera (EdMoveChara) decides its wall tests from where the eye is before the +// stick's turn, which allows for a stick's small step, not for the mouse's whole turn at once. So +// the town takes the mouse's turn apart from the stick's: EdGetRXf records it into a request +// EditLoop opens around the walk, and EditLoop applies it after the camera's step, as far round as +// the eye stays clear of walls, the rest dropped. + +// The camera polygons in box (CEditGround::PickUpCameraPoly), as a count written to *polys. +using MouseLookPolys = std::function; + +// The signed safe prefix of a continuous turn. Conservative bounds cover the swept look ray, +// eye clearance and floor query; uncertain intervals subdivide within a fixed work budget. +// An initially invalid pose rejects the turn. This helper has no pending camera state; +// TownMouseApply validates the full follow-camera corridor through MouseCameraClamp. +float MouseLookClampOrbit(const float *eye, const float *look, float turn, const MouseLookPolys &polys); + +// Turns a follow camera's angle, the angle it is turning to, its eye and the eye it is easing to by +// turn radians at once. +void MouseLookTurnOrbit(CCameraFollow *camera, float turn); + +// Opens the request for this pad read's walk, replacing any other. +void TownMouseBegin(CCameraFollow *camera, CEditGround *ground, int map, int mode); + +// From EdGetRXf: the mouse's turn this read for camera, if a request for it is open. +void TownMouseRecord(CCameraFollow *camera, bool fishing); + +// Applies and closes the request, if the camera shown, the ground, the map and the mode are still +// the ones it was opened for, in the same pad read. +void TownMouseApply(CCamera *shown, CEditGround *ground, int map, int mode); diff --git a/port/src/camera_zoom.cpp b/port/src/camera_zoom.cpp new file mode 100644 index 000000000..6fe6bf3f1 --- /dev/null +++ b/port/src/camera_zoom.cpp @@ -0,0 +1,153 @@ +#include "camera_zoom.hpp" + +#include +#include +#include +#include +#include + +#include "camerafollow.hpp" +#include "collision.hpp" +#include "dungeonmap.hpp" +#include "mouse_collision.hpp" +#include "platform/config.hpp" +#include "platform/input.hpp" + +extern float camera_near_dist; +extern float camera_far_dist; + +namespace { +constexpr float kMinDistance = 30.0f; +constexpr float kMaxDistance = 140.0f; +constexpr float kWheelStep = 8.0f; + +struct Zoom { + CCameraFollow *camera = nullptr; + const void *scene = nullptr; + int key = 0; + float requested = 0.0f; + std::uint64_t read = std::numeric_limits::max(); +}; + +Zoom g_town, g_dungeon; +bool g_town_scope = false; +float g_near = 0.0f, g_far = 0.0f; +std::vector g_polys; + +void Prepare(Zoom &zoom, CCameraFollow *camera, const void *scene, int key, float normal) { + if (zoom.camera != camera || zoom.scene != scene || zoom.key != key) { + zoom = {camera, scene, key, normal}; + } +} + +void Read(Zoom &zoom, float normal) { + const auto &input = InputGetMouseLook(); + if (zoom.read == input.read) { + return; + } + zoom.read = input.read; + if (input.zoom_reset) { + zoom.requested = normal; + } else if (std::isfinite(input.zoom)) { + zoom.requested = std::clamp(zoom.requested - input.zoom * kWheelStep, kMinDistance, kMaxDistance); + } +} + +CBoxVu0 Box(const CCameraFollow &camera, float requested) { + CBoxVu0 box{}; + float radius = std::max({std::fabs(camera.distance), requested, + std::hypot(camera.pos[0] - camera.ref[0], camera.pos[2] - camera.ref[2]), + std::hypot(camera.next_pos[0] - camera.next_ref[0], camera.next_pos[2] - camera.next_ref[2])}) + + 20.0f; + for (int i = 0; i < 3; ++i) { + box.min[i] = std::min({camera.pos[i], camera.next_pos[i], camera.ref[i], camera.next_ref[i], camera.follow[i]}) - radius - std::fabs(camera.height); + box.max[i] = std::max({camera.pos[i], camera.next_pos[i], camera.ref[i], camera.next_ref[i], camera.follow[i]}) + radius + std::fabs(camera.height); + } + return box; +} + +void ApplyTown() { + auto *camera = g_town.camera; + auto *ground = const_cast(static_cast(g_town.scene)); + CBoxVu0 box = Box(*camera, g_town.requested); + int count = MouseCameraPolys(*ground, box, 0xFFFF, g_polys); + float accepted = MouseCameraClampDistance(*camera, g_town.requested, g_polys.data(), count); + camera->SetDistance(accepted); + // The retail clear-camera branch otherwise assigns 70 every frame before Step. + // Its wall/floor rules run normally using this validated distance for the current update. + camera_near_dist = accepted; + camera_far_dist = accepted + (g_far - g_near); +} +} // namespace + +void TownZoomBegin(CCameraFollow *camera, CEditGround *ground, int map) { + TownZoomEnd(); + if (!ConfigGet().mouse_zoom) { + g_town = {}; + return; + } + if (!camera || !ground || !camera->follow_on || CCamera::StopCamera) { + return; + } + g_near = camera_near_dist; + g_far = camera_far_dist; + Prepare(g_town, camera, ground, map, g_near); + g_town_scope = true; + ApplyTown(); +} + +void TownZoomRead(CCameraFollow *camera) { + if (!g_town_scope || g_town.camera != camera || !camera->follow_on || CCamera::StopCamera) { + return; + } + Read(g_town, g_near); + ApplyTown(); +} + +void TownZoomEnd() { + if (g_town_scope) { + camera_near_dist = g_near; + camera_far_dist = g_far; + g_town_scope = false; + } +} + +void DungeonZoomApply(CCameraFollow *camera, CDungeonMap *map, bool lock_on, float previous_distance) { + if (!ConfigGet().mouse_zoom) { + if (g_dungeon.camera == camera && camera && camera->follow_on && !lock_on && !CCamera::StopCamera) { + // Turning the option off returns to the dungeon camera's normal starting distance. + g_dungeon.requested = 60.0f; + } else { + g_dungeon = {}; + return; + } + } + if (!camera || !map || !camera->follow_on || CCamera::StopCamera || lock_on) { + return; + } + // Retail's distance relaxation can expand beyond the last validated zoom destination. + // Keep its collision contraction; validate every expansion ourselves before Step uses it. + camera->SetDistance(std::min(camera->distance, previous_distance)); + Prepare(g_dungeon, camera, map, map->map_seed, 60.0f); + if (ConfigGet().mouse_zoom) { + Read(g_dungeon, 60.0f); + } + CBoxVu0 box = Box(*camera, g_dungeon.requested); + int count = MouseCameraPolys(*map, box, g_polys); + // Dungeon's default eye can be only 7.6 units above a floor (6-unit follow point + 1.6 + // height). Keep that valid: walls retain ten-unit clearance and floors retain five. + camera->SetDistance(MouseCameraClampDistance(*camera, g_dungeon.requested, g_polys.data(), count, 5.0f)); + if (!ConfigGet().mouse_zoom) { + g_dungeon = {}; + } +} + +float DungeonZoomNearDistance(CCameraFollow *camera, float normal, bool lock_on) { + if (!camera || !ConfigGet().mouse_zoom || lock_on || g_dungeon.camera != camera || + !camera->follow_on || CCamera::StopCamera) { + return normal; + } + // Retail lifts the eye whenever it comes within 60 units of the player. With an + // intentionally closer zoom that would keep lifting it into a steep overhead view. + return std::min(normal, g_dungeon.requested); +} diff --git a/port/src/camera_zoom.hpp b/port/src/camera_zoom.hpp new file mode 100644 index 000000000..1852cf3ad --- /dev/null +++ b/port/src/camera_zoom.hpp @@ -0,0 +1,14 @@ +#pragma once + +class CCameraFollow; +class CEditGround; +class CDungeonMap; + +// Town distance rules are scoped to this walk update; collision correction still owns the eye. +void TownZoomBegin(CCameraFollow *camera, CEditGround *ground, int map); +void TownZoomRead(CCameraFollow *camera); +void TownZoomEnd(); + +// Only called after the normal dungeon gameplay camera checks, never by scripted/item cameras. +void DungeonZoomApply(CCameraFollow *camera, CDungeonMap *map, bool lock_on, float previous_distance); +float DungeonZoomNearDistance(CCameraFollow *camera, float normal, bool lock_on); diff --git a/port/src/camerafollow.cpp b/port/src/camerafollow.cpp new file mode 100644 index 000000000..7ff366458 --- /dev/null +++ b/port/src/camerafollow.cpp @@ -0,0 +1,36 @@ +#include "camerafollow.hpp" + +#include +#include + +#include "camera_port.hpp" + +PC_OVERRIDE void CCameraFollow::AddHeight(float delta) { + this->height += MouseLookHeightDelta(this, delta); +} + +// Retail's AddAngle, which sets only where the camera is turning to, plus the mouse's share of the +// delta (camera_port.hpp) applied to where it is as well: the angle and the eye turn about the point +// the eye looks at at once. The angle stays within half a turn, so Step's interpolation still sees +// only the stick's share as the way left to go. + +PC_OVERRIDE void CCameraFollow::AddAngle(float delta) { + float mouse = MouseLookTakeTurn(this, delta); + + if (mouse == 0.0f || !this->follow_on || CCamera::StopCamera) { + this->next_angle += delta; + return; + } + + constexpr float kTurn = 2.0f * std::numbers::pi_v; + float turn = std::remainder(mouse, kTurn); + float c = std::cos(turn); + float s = std::sin(turn); + float x = this->pos[0] - this->ref[0]; + float z = this->pos[2] - this->ref[2]; + + this->next_angle += delta - mouse + turn; + this->angle = std::remainder(this->angle + turn, kTurn); + this->pos[0] = this->ref[0] + x * c + z * s; + this->pos[2] = this->ref[2] + z * c - x * s; +} diff --git a/port/src/dun/movechara.cpp b/port/src/dun/movechara.cpp index d18d52bda..3d3bb0e1f 100644 --- a/port/src/dun/movechara.cpp +++ b/port/src/dun/movechara.cpp @@ -14,6 +14,8 @@ #include "btmisc.hpp" #include "btsysscript.hpp" #include "camera.hpp" +#include "camera_port.hpp" +#include "camera_zoom.hpp" #include "camerafollow.hpp" #include "character.hpp" #include "clothread.hpp" @@ -194,7 +196,9 @@ extern s32 viewMode__2; // The dungeon's MoveChara (renamed DunMoveChara by port/include/stubs/dun/gameloop.hpp, as the PS2 // link does) and BtCheckDamageProc, retail's, with the addresses of NowDngMap's parts and of the -// active item counters formed on the whole pointer instead of an unsigned int. +// active item counters formed on the whole pointer instead of an unsigned int, and the mouse +// turning the camera beside the right stick (camera_port.hpp). EyeCamera, the first-person view, +// is retail's with the mouse turning it beside the left stick. namespace { @@ -1561,9 +1565,9 @@ PC_OVERRIDE void DunMoveChara() { } HealingWater(); - float turn = GamePad.GetRXf(); + float turn = MouseLookTurn(NowCamera__3, 0.04f, GamePad.GetRXf()); - NowCamera__3->AddHeight(-GamePad.GetRYf()); + NowCamera__3->AddHeight(-MouseLookRise(NowCamera__3, GamePad.GetRYf(), 30.0f)); if (NowCamera__3->GetHeight() >= 30.0f) { NowCamera__3->SetHeight(30.0f); @@ -1651,7 +1655,13 @@ PC_OVERRIDE void DunMoveChara() { NowCamera__3->SetFollow(pos[0] + to_target[0], BtActStatus.camera_shake_offset + (to_target[1] + (6.0f + pos[1] + reference[1])), pos[2] + to_target[2]); } + extern float camera_up_near_dist; + float normal_near = camera_up_near_dist; + float zoom_start_distance = NowCamera__3->GetDistance(); + camera_up_near_dist = DungeonZoomNearDistance(NowCamera__3, normal_near, lockOnTargetFlag != 0); autoCamTrial(); + camera_up_near_dist = normal_near; + DungeonZoomApply(NowCamera__3, NowDngMap, lockOnTargetFlag != 0, zoom_start_distance); } } } @@ -3385,3 +3395,76 @@ PC_OVERRIDE int BtCheckDamageProc() { return taken; } + +PC_OVERRIDE void EyeCamera() { + sceVu0FVECTOR pos; + sceVu0FVECTOR ref; + sceVu0FMATRIX rotation; + sceVu0FMATRIX unit; + float stick_x; + float stick_y; + + stick_x = GamePad.GetLXf(); + stick_y = -GamePad.GetLYf(); + + if (stick_x > 0.0f) { + float rate = 0.02f; + + viewAngleH__2 -= stick_x * rate; + + if (viewAngleH__2 < -PI) { + viewAngleH__2 += TWO_PI; + } + } + + if (stick_x < -0.0f) { + float rate = 0.02f; + + viewAngleH__2 -= stick_x * rate; + + if (viewAngleH__2 > PI) { + viewAngleH__2 -= TWO_PI; + } + } + + if (stick_y > 0.0f) { + if (viewAngleV__2 < 0.65f) { + float rate = 0.02f; + + viewAngleV__2 += stick_y * rate; + } + } + + if (stick_y < -0.0f) { + if (viewAngleV__2 > -1.0f) { + float rate = 0.02f; + + viewAngleV__2 += stick_y * rate; + } + } + + viewAngleH__2 = MouseLookEyeAngleH(viewAngleH__2); + viewAngleV__2 = MouseLookEyeAngleV(viewAngleV__2); + + ref[0] = 0.0f; + ref[1] = 0.0f; + ref[2] = 10.0f; + ref[3] = 0.0f; + + sceVu0UnitMatrix(unit); + sceVu0RotMatrixX(rotation, unit, viewAngleV__2); + sceVu0RotMatrixY(rotation, rotation, viewAngleH__2); + sceVu0ApplyMatrix(ref, rotation, ref); + sceVu0CopyVector(pos, CharaMain.pos); + + CUserStatus *status = UserStatus; + float height = CharaHeight(status); + + pos[1] += height - 1.5f; + ref[0] += pos[0]; + ref[1] += pos[1]; + ref[2] += pos[2]; + + NowCamera__3->SetPos(pos); + NowCamera__3->SetRef(ref); +} diff --git a/port/src/ebattle.cpp b/port/src/ebattle.cpp index f48a29c62..2ec0d6e9a 100644 --- a/port/src/ebattle.cpp +++ b/port/src/ebattle.cpp @@ -1,5 +1,7 @@ #include "ebattle.hpp" +#include + #include "character.hpp" #include "dataread.hpp" #include "editloop.hpp" @@ -8,6 +10,7 @@ #include "snd.hpp" #include "platform/config.hpp" +#include "platform/input.hpp" struct EB_KEY { int frame; @@ -187,3 +190,118 @@ PC_OVERRIDE int EBLoop() { eb_count++; return 0; } + +#include +#include "camera.hpp" +#include "camera_port.hpp" +#include "camerafollow.hpp" +#include "character.hpp" +#include "edit.hpp" + +// Defined by ps2/src/ebattle.cpp without a header declaration. +extern int viewMode; +extern int chara_mode; +extern float viewAngleH; +extern float viewAngleV; + +int PortEdCheckKeyMode(int mode); + +// The right stick of the town's walk: EdMoveChara turns its camera by RX (0.03 a tick, unless a +// wall is on that side) and raises it by RY while it is under 30. RX stays the stick's: the mouse's +// turn goes to EditLoop's request (camera_port.hpp), which checks it against the walls itself; so +// the drift behind a walking character and R1 and L1, which wait for RX to rest, see only the stick. +// RY takes the mouse as camera_port.hpp describes. In an interior's first-person view EdMoveChara +// turns the character by RX only while the left stick rests, so the mouse turns it in EyeCamera. + +PC_OVERRIDE float EdGetRXf(int mode) { + if (PortEdCheckKeyMode(mode)) { + CCameraFollow *camera = EdMoveCharaInfo.camera; + if (viewMode == 0 && !EdMoveCharaInfo.interior && camera != NULL) { + TownMouseRecord(camera, EdMoveCharaInfo.fishing != 0); + } + return GamePad.GetRXf(); + } + + return 0.0f; +} + +// EdMoveChara reads it for the town's camera only; EyeCamera below reads the stick itself. +PC_OVERRIDE float EdGetRYf(int mode) { + if (PortEdCheckKeyMode(mode)) { + CCameraFollow *camera = EdMoveCharaInfo.camera; + float stick = GamePad.GetRYf(); + if (camera == NULL) { + return stick; + } + float reading = MouseLookRise(camera, stick, EdDebugCameraFlag == 0 ? 30.0f : INFINITY, 5.0f); + // Retail ignores RY at the ceiling, including a request to lower the camera. Let a + // native mouse reversal leave that ceiling; the subsequent floor checks still apply. + if (EdDebugCameraFlag == 0 && camera->GetHeight() >= 30.0f && InputGetMouseLook().pitch > 0.0f) { + camera->AddHeight(-reading); + return 0.0f; + } + return reading; + } + + return 0.0f; +} + +// Retail's, with the mouse turning the view while it is the one shown. In an interior the view +// takes its heading from the character's (EdMoveChara), so the mouse turns the character too. +PC_OVERRIDE void EyeCamera(CCamera *camera, CCharacter *character, int right_stick) { + float stick_x; + float stick_y; + bool unlocked = (chara_mode & 3) == 0; + + if (right_stick != 0) { + stick_x = 0.0f; + stick_y = unlocked && PortEdCheckKeyMode(1) ? -GamePad.GetRYf() : 0.0f; + } else { + stick_x = unlocked ? EdGetLXf(1) : 0.0f; + stick_y = unlocked ? -EdGetLYf(1) : 0.0f; + } + + if (stick_x > 0.0f) { + float rate = 0.02f; + viewAngleH -= stick_x * rate; + + if (viewAngleH < -PI) { + viewAngleH += TWO_PI; + } + } + + if (stick_x < -0.0f) { + float rate = 0.02f; + viewAngleH -= stick_x * rate; + + if (viewAngleH > PI) { + viewAngleH -= TWO_PI; + } + } + + if (stick_y > 0.0f && viewAngleV < 0.65f) { + float rate = 0.02f; + viewAngleV += stick_y * rate; + } + + if (stick_y < -0.0f && viewAngleV > -1.0f) { + float rate = 0.02f; + viewAngleV += stick_y * rate; + } + + if (viewMode != 0 && unlocked && PortEdCheckKeyMode(1)) { + float heading = MouseLookEyeAngleH(viewAngleH); + + if (right_stick != 0 && heading != viewAngleH) { + sceVu0FVECTOR rotation; + character->GetRotation(rotation); + rotation[1] = heading; + character->SetRotation(rotation); + } + + viewAngleH = heading; + viewAngleV = MouseLookEyeAngleV(viewAngleV); + } + + EdEyeCamera(camera, character); +} diff --git a/port/src/edit_in.cpp b/port/src/edit_in.cpp index 5006622a8..1248815c0 100644 --- a/port/src/edit_in.cpp +++ b/port/src/edit_in.cpp @@ -11,6 +11,7 @@ #include "battlemenu.hpp" #include "boxvu0.hpp" #include "camera.hpp" +#include "camera_port.hpp" #include "camerafollow.hpp" #include "character.hpp" #include "clsmes.hpp" @@ -1259,7 +1260,7 @@ static void MoveCharacter() { } /** - * Applies the right stick to the interior camera, holding its height and distance in + * Applies the right stick and the mouse to the interior camera, holding its height and distance in * range. * * @mangled MoveCamera__FP13CCameraFollow__2 @@ -1270,8 +1271,8 @@ static void MoveCharacter() { static void MoveCamera(CCameraFollow *camera) { static float camera_distance[3] = {20.0f, 60.0f, 100.0f}; - float horizontal = GamePad.GetRXf(); - camera->AddHeight(-GamePad.GetRYf()); + float horizontal = MouseLookTurn(camera, 0.04f, GamePad.GetRXf()); + camera->AddHeight(-MouseLookRise(camera, GamePad.GetRYf(), 30.0f)); if (!(camera->GetHeight() <= 30.0f)) { camera->SetHeight(30.0f); diff --git a/port/src/editloop.cpp b/port/src/editloop.cpp index 02a1330ba..7f6baaa4c 100644 --- a/port/src/editloop.cpp +++ b/port/src/editloop.cpp @@ -12,6 +12,8 @@ #include "boxvu0.hpp" #include "camera.hpp" +#include "camera_port.hpp" +#include "camera_zoom.hpp" #include "camerafollow.hpp" #include "character.hpp" #include "clsmes.hpp" @@ -392,6 +394,7 @@ PC_OVERRIDE int EditLoop() { switch (mode) { case ED_MODE_FISHING: draw_clock = 0; + TownMouseBegin(&MainCamera, pEditGround, MapNo, GameMode); MainMode(); MainEditMode(); @@ -408,6 +411,8 @@ PC_OVERRIDE int EditLoop() { break; case ED_MODE_WALK: if (loop_counter > 0) { + TownMouseBegin(&MainCamera, pEditGround, MapNo, GameMode); + if (EdCheckViewMode() == 0) TownZoomBegin(&MainCamera, pEditGround, MapNo); MainMode(); } @@ -674,6 +679,9 @@ PC_OVERRIDE int EditLoop() { break; } + TownZoomEnd(); + TownMouseApply(NowCamera, pEditGround, MapNo, GameMode); + sceVu0FMATRIX view; sceVu0FVECTOR eye; @@ -1534,6 +1542,28 @@ static void EditMainDraw() { } +// Retail's MoveCamera, the georama view's, with the mouse turning it beside the right stick. +PC_OVERRIDE void MoveCamera(CCameraFollow *camera) { + float horizontal = MouseLookTurn(camera, 0.03f, GamePad.GetRXf()); + camera->AddHeight(-GamePad.GetRYf()); + + if (EdDebugCameraFlag == 0 && !(camera->GetHeight() <= 30.0f)) { + camera->SetHeight(30.0f); + } + + camera->AddAngle(0.03f * -horizontal); + + if (horizontal == 0.0f) { + if (GamePad.On(PAD_R1) != 0) { + camera->AddAngle(-DEG_TO_RAD); + } + + if (GamePad.On(PAD_L1) != 0) { + camera->AddAngle(DEG_TO_RAD); + } + } +} + // Retail's EdDrawClock, kept in the window's top right corner. PC_OVERRIDE void EdDrawClock(int x, int y) { if (draw_clock != 0 && EditMapInfo->time_stop == 0) { diff --git a/port/src/menu_option.cpp b/port/src/menu_option.cpp index 5da0f43cf..4f7caf529 100644 --- a/port/src/menu_option.cpp +++ b/port/src/menu_option.cpp @@ -71,6 +71,7 @@ constexpr int kValueRight = 554; constexpr int kBarX = 580; constexpr int kExitX = 136; constexpr int kExitY = 352; +constexpr int kExitHeight = 31; // A help window's frame round its middle (MenuHelpWinDraw): 24 pixels a side and 22 above and below. constexpr int kPlateSide = 24; constexpr int kPlateHeight = 44; @@ -368,6 +369,72 @@ void RestoreMouseSensitivity(Config &config, const Config &defaults) { config.mouse_sensitivity = defaults.mouse_sensitivity; } +constexpr const char *kZoomResetBindings[] = {"Mouse3", "Mouse4", "Mouse5", "Home", ""}; +constexpr const char *kZoomResetNames[] = {"Middle Mouse", "Mouse4", "Mouse5", "Home", "Disabled"}; + +const std::vector &ZoomResetBindings(const Config &config) { + static const std::vector defaults = {"Mouse3"}; + auto binding = std::ranges::find(config.key_bindings, "zoom_reset", &ConfigKeyBinding::action); + return binding == config.key_bindings.end() ? defaults : binding->keys; +} + +int ZoomResetChoice(const Config &config) { + const auto &keys = ZoomResetBindings(config); + if (keys.empty()) { + return 4; + } + for (int choice = 0; choice < 4; ++choice) { + if (keys.size() == 1 && keys.front() == kZoomResetBindings[choice]) { + return choice; + } + } + return 5; // The file's custom binding remains a selectable choice until explicitly changed. +} + +int ZoomResetCount(const Config &config) { return ZoomResetChoice(config) == 5 ? 6 : 5; } + +void SetZoomReset(Config &config, int choice) { + if (choice < 0 || choice >= 5) { + return; + } + auto binding = std::ranges::find(config.key_bindings, "zoom_reset", &ConfigKeyBinding::action); + std::vector keys; + if (choice < 4) { + keys.push_back(kZoomResetBindings[choice]); + } + if (binding == config.key_bindings.end()) { + config.key_bindings.push_back({"zoom_reset", std::move(keys)}); + } else { + binding->keys = std::move(keys); + } +} + +std::string ZoomResetText(const Config &config) { + int choice = ZoomResetChoice(config); + if (choice < 5) { + return kZoomResetNames[choice]; + } + std::string text; + for (const auto &key : ZoomResetBindings(config)) { + if (!text.empty()) { + text += ", "; + } + text += key; + } + return text; +} + +void RestoreZoomReset(Config &config, const Config &defaults) { + auto binding = std::ranges::find(config.key_bindings, "zoom_reset", &ConfigKeyBinding::action); + if (binding != config.key_bindings.end()) { + config.key_bindings.erase(binding); + } + auto default_binding = std::ranges::find(defaults.key_bindings, "zoom_reset", &ConfigKeyBinding::action); + if (default_binding != defaults.key_bindings.end()) { + config.key_bindings.push_back(*default_binding); + } +} + // 0.50 to 2.50 in twentieths. int StickSensitivityCount(const Config &) { return 41; @@ -474,6 +541,10 @@ const Row kControlRows[] = { RestoreMouseSensitivity}, OnOffRow<&Config::mouse_invert_y>("input.mouse_invert_y", "Invert Mouse Y", "\"Invert Mouse Y\"\nMoving the mouse up\nlooks down."), + OnOffRow<&Config::mouse_zoom>("input.mouse_zoom", "Mouse Wheel Zoom", + "\"Mouse Wheel Zoom\"\nScroll to move closer\nor farther from your\ncharacter."), + Row{"input.bindings.zoom_reset", "Reset Zoom", "\"Reset Zoom\"\nRestores the normal\ncamera distance.", + -1, ZoomResetCount, ZoomResetChoice, SetZoomReset, ZoomResetText, nullptr, RestoreZoomReset}, Row{"input.stick_sensitivity", "Stick Sensitivity", "\"Stick Sensitivity\"\nHow far a gamepad's\nstick has to tilt.", -1, StickSensitivityCount, StickSensitivityChoice, SetStickSensitivity, StickSensitivityText, nullptr, RestoreStickSensitivity}, @@ -882,7 +953,7 @@ void RunMouse() { over_row = r; } } - if (Inside(x, y, kExitX, kExitY, kExitX + 60, kExitY + 29)) { + if (Inside(x, y, kExitX, kExitY, kExitX + 60, kExitY + kExitHeight)) { over_row = rows; } for (int t = 0; t < kTabCount; ++t) { @@ -1098,6 +1169,16 @@ void WriteOptions(CSaveData &save, const ConfigGameOptions &options) { } // namespace +int OptionZoomResetChoice(const Config &config) { return ZoomResetChoice(config); } + +int OptionZoomResetCount(const Config &config) { return ZoomResetCount(config); } + +std::string OptionZoomResetText(const Config &config) { return ZoomResetText(config); } + +void OptionSetZoomReset(Config &config, int choice) { SetZoomReset(config, choice); } + +void OptionRestoreZoomReset(Config &config, const Config &defaults) { RestoreZoomReset(config, defaults); } + std::vector OptionResolutionList(std::span modes, int configured_width, int configured_height, bool display_known, int display_width, int display_height) { @@ -1316,7 +1397,7 @@ PC_OVERRIDE void DrawMenuOption() { DrawTabPlate(alpha); DrawScrollBar(alpha); MenuTextureReload(g_screen.block_no); - DrawSprite(kExitX, kExitY, 452, 224, 60, 29, alpha); + DrawSprite(kExitX, kExitY, 452, 224, 60, kExitHeight, alpha); if (g_screen.step == OPTION_STEP_RUN) { // The brackets go round whatever a click would act on. diff --git a/port/src/menu_option.hpp b/port/src/menu_option.hpp index 92555c348..05be778fb 100644 --- a/port/src/menu_option.hpp +++ b/port/src/menu_option.hpp @@ -38,3 +38,11 @@ struct OptionResolution { std::vector OptionResolutionList(std::span modes, int configured_width, int configured_height, bool display_known, int display_width, int display_height); + +// The Controls row's reset presets: Middle Mouse, Mouse4, Mouse5, Home and Disabled, followed by +// the configured custom binding when present. Selecting a preset changes only zoom_reset. +int OptionZoomResetChoice(const Config &config); +int OptionZoomResetCount(const Config &config); +std::string OptionZoomResetText(const Config &config); +void OptionSetZoomReset(Config &config, int choice); +void OptionRestoreZoomReset(Config &config, const Config &defaults); diff --git a/port/src/mouse_collision.cpp b/port/src/mouse_collision.cpp new file mode 100644 index 000000000..abbed9f1b --- /dev/null +++ b/port/src/mouse_collision.cpp @@ -0,0 +1,403 @@ +#include "mouse_collision.hpp" + +#include +#include +#include +#include +#include +#include + +#include "camerafollow.hpp" +#include "editarea.hpp" +#include "editground.hpp" +#include "dungeonmap.hpp" +#include "frame.hpp" +#include "rect.hpp" + +namespace { + +constexpr int kPolyBudget = 32768; +constexpr int kFrameBudget = 4096; +constexpr float kStep = 0.03f; +constexpr float kMargin = 0.001f; + +struct Vec { + double x, y, z; + + Vec operator+(Vec b) const { return {x + b.x, y + b.y, z + b.z}; } + + Vec operator-(Vec b) const { return {x - b.x, y - b.y, z - b.z}; } + + Vec operator*(double s) const { return {x * s, y * s, z * s}; } +}; + +Vec V(const float *p) { return {p[0], p[1], p[2]}; } + +double Dot(Vec a, Vec b) { return a.x * b.x + a.y * b.y + a.z * b.z; } + +Vec Cross(Vec a, Vec b) { return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; } + +double Length(Vec a) { return std::sqrt(Dot(a, a)); } + +bool Finite(Vec a) { return std::isfinite(a.x) && std::isfinite(a.y) && std::isfinite(a.z); } + +double PointSegment(Vec p, Vec a, Vec b) { + Vec ab = b - a; + double d = Dot(ab, ab); + double t = d > 0 ? std::clamp(Dot(p - a, ab) / d, 0.0, 1.0) : 0; + return Length(p - (a + ab * t)); +} + +double SegmentSegment(Vec a, Vec b, Vec c, Vec d) { + Vec u = b - a, v = d - c, w = a - c; + double aa = Dot(u, u), bb = Dot(u, v), cc = Dot(v, v), dd = Dot(u, w), ee = Dot(v, w); + double result = std::min({PointSegment(a, c, d), PointSegment(b, c, d), + PointSegment(c, a, b), PointSegment(d, a, b)}); + double det = aa * cc - bb * bb; + if (det > 1e-20) { + double s = (bb * ee - cc * dd) / det, t = (aa * ee - bb * dd) / det; + if (s >= 0 && s <= 1 && t >= 0 && t <= 1) { + result = std::min(result, Length(w + u * s - v * t)); + } + } + return result; +} + +bool Inside(Vec p, Vec a, Vec b, Vec c, Vec normal) { + double s0 = Dot(Cross(b - a, p - a), normal), s1 = Dot(Cross(c - b, p - b), normal), + s2 = Dot(Cross(a - c, p - c), normal); + return s0 >= -1e-12 && s1 >= -1e-12 && s2 >= -1e-12; +} + +double PointTriangle(Vec p, Vec a, Vec b, Vec c) { + Vec n = Cross(b - a, c - a); + double nn = Dot(n, n); + double result = std::min({PointSegment(p, a, b), PointSegment(p, b, c), PointSegment(p, c, a)}); + if (nn > 1e-20) { + double side = Dot(p - a, n); + Vec projected = p - n * (side / nn); + if (Inside(projected, a, b, c, n)) { + result = std::min(result, std::fabs(side) / std::sqrt(nn)); + } + } + return result; +} + +double SegmentTriangle(Vec from, Vec to, const CCPoly &poly) { + Vec a = V(poly.vertex[0]), b = V(poly.vertex[1]), c = V(poly.vertex[2]); + Vec n = Cross(b - a, c - a); + double denom = Dot(to - from, n); + if (std::fabs(denom) > 1e-20) { + double t = Dot(a - from, n) / denom; + if (t >= 0 && t <= 1 && Inside(from + (to - from) * t, a, b, c, n)) { + return 0; + } + } + return std::min({PointTriangle(from, a, b, c), PointTriangle(to, a, b, c), + SegmentSegment(from, to, a, b), SegmentSegment(from, to, b, c), + SegmentSegment(from, to, c, a)}); +} + +// The frame routine can write at most every triangle in its eligible subtree. Prove that +// bound before it sees an output pointer; unknown collision implementations fail closed. +int FrameBound(CFrame *frame, int &visits, int depth = 0) { + if (!frame) { + return 0; + } + if (++visits > kFrameBudget || depth > 64) { + return -1; + } + if (frame->flags == 4) { + return 0; + } + int count = 0; + if (frame->collision && (frame->flags & 1)) { + auto *mesh = dynamic_cast(frame->collision); + if (!mesh || typeid(*frame->collision) != typeid(CCollisionMDT) || + mesh->mesh_count < 0 || mesh->mesh_count > kPolyBudget || + (mesh->mesh_count && !mesh->mesh)) { + return -1; + } + count = mesh->mesh_count; + } + if (!(frame->flags & 2) && !(frame->flags & 4)) { + for (CFrame *child = frame->child; child; child = child->brother) { + int n = FrameBound(child, visits, depth + 1); + if (n < 0 || n > kPolyBudget - count) { + return -1; + } + count += n; + } + } + return count; +} + +Vec Orbit(Vec p, Vec ref, double angle) { + Vec d = p - ref; + double c = std::cos(angle), s = std::sin(angle); + return {ref.x + d.x * c + d.z * s, p.y, ref.z + d.z * c - d.x * s}; +} + +struct Bound { + Vec min{INFINITY, INFINITY, INFINITY}, max{-INFINITY, -INFINITY, -INFINITY}; + + void Add(Vec p, double radius = 0) { + min = {std::min(min.x, p.x - radius), std::min(min.y, p.y), std::min(min.z, p.z - radius)}; + max = {std::max(max.x, p.x + radius), std::max(max.y, p.y), std::max(max.z, p.z + radius)}; + } + + Vec Centre() const { return (min + max) * 0.5; } + + double Radius() const { return Length(max - min) * 0.5; } +}; + +bool CorridorClear(CCameraFollow &camera, double middle, double half, CCPoly *polys, int count, + float floor_clearance = 18.0f) { + constexpr double turn = 2 * std::numbers::pi; + double gap = std::remainder(double(camera.next_angle) - camera.angle, turn); + Vec ref = V(camera.ref), next_ref = V(camera.next_ref), follow = V(camera.follow); + Vec eye = V(camera.pos), next_eye = V(camera.next_pos); + if (!Finite(ref) || !Finite(next_ref) || !Finite(follow) || !Finite(eye) || !Finite(next_eye)) { + return false; + } + Bound eyes, refs; + // Rotation moves each point by at most this chord distance from the interval midpoint. + auto add = [&](Vec p, Vec r) { + double radius = std::hypot(p.x - r.x, p.z - r.z); + eyes.Add(Orbit(p, r, middle), 2 * radius * std::sin(half / 2)); + }; + add(eye, ref); + add(next_eye, next_ref); + double a = camera.angle + middle + gap / 2; + Vec target{follow.x + camera.distance * std::sin(a), follow.y + camera.height, + follow.z + camera.distance * std::cos(a)}; + double reach = 2 * std::fabs(camera.distance) * std::sin(std::min(std::numbers::pi, std::fabs(gap) / 2 + half) / 2); + eyes.Add(target, reach); + refs.Add(ref); + refs.Add(next_ref); + refs.Add(follow); + Vec e = eyes.Centre(), r = refs.Centre(); + float ep[4] = {float(e.x), float(e.y), float(e.z), 1}, rp[4] = {float(r.x), float(r.y), float(r.z), 1}; + // CCamera eases coordinates separately (and may snap/limit a coordinate), so bounding each + // coordinate encloses its subsequent states, including the generated follow destinations. + return MouseCameraClear(ep, rp, float(eyes.Radius()), float(refs.Radius()), polys, count, + float((eyes.max.y - eyes.min.y) / 2), floor_clearance); +} + +double ClearPrefix(CCameraFollow &camera, double from, double to, CCPoly *polys, int count, + int &budget, int depth = 0) { + if (--budget < 0) { + return from; + } + if (CorridorClear(camera, (from + to) / 2, std::fabs(to - from) / 2, polys, count)) { + return to; + } + // A conservative envelope can touch a wall while the true arc remains clear. Subdivide + // that envelope before stopping; every accepted interval still has a clearance proof. + if (depth == 12) { + return from; + } + double middle = (from + to) / 2; + double first = ClearPrefix(camera, from, middle, polys, count, budget, depth + 1); + if (first != middle) { + return first; + } + return ClearPrefix(camera, middle, to, polys, count, budget, depth + 1); +} + +} // namespace + +int MouseCameraPolys(CEditGround &ground, CBoxVu0 &box, int mask, std::vector &polys) { + polys.clear(); + int visits = 0; + auto part = [&](CMapParts &part) { + if (part.handle < 0 || !part.camera_frame || !part.CheckBox(&box)) { + return true; + } + CFrame *frame = part.camera_frame; + int bound = FrameBound(frame, visits); + if (bound < 0 || bound > kPolyBudget - int(polys.size())) { + return false; + } + if (!bound) { + return true; + } + frame->SetPosition(part.pos[0], part.pos[1], part.pos[2]); + frame->SetRotation(part.rotation.x, part.rotation.y, part.rotation.z); + auto start = polys.size(); + polys.resize(start + bound); + int found = frame->PickUpNearPoly(polys.data() + start, box); + if (found < 0 || found > bound) { + return false; + } + polys.resize(start + found); + return true; + }; + if (mask & 2) { + for (auto &p : ground.parts) { + if (!part(p)) { + return -1; + } + } + } + if (mask & 1) { + for (auto &p : ground.fixed_parts) { + if (!part(p)) { + return -1; + } + } + } + // Whole grid areas cover the entire sweep, rather than only the four cells by its centre. + // Each cell writes at most two triangles; dimensions also protect the retail grid indexing. + for (CEditArea *area : ground.areas) { + if (!area) { + continue; + } + if (area->width < 0 || area->width > 16 || area->height < 0 || area->height > 16 || + area->map_no < 0 || area->map_no > 4) { + return -1; + } + int bound = 2 * area->width * area->height; + if (bound > kPolyBudget - int(polys.size())) { + return -1; + } + auto start = polys.size(); + polys.resize(start + bound); + if (bound) { + CRect_i_ rect(0, 0, area->width, area->height); + int found = area->PickUpPoly(polys.data() + start, rect); + if (found < 0 || found > bound) { + return -1; + } + polys.resize(start + found); + } + } + return int(polys.size()); +} + +int MouseCameraPolys(CDungeonMap &map, CBoxVu0 &box, std::vector &polys) { + polys.clear(); + int visits = 0; + auto append = [&](CFrame *frame, float x, float y, float z, int turn) { + if (!frame) { + return true; + } + int bound = FrameBound(frame, visits); + if (bound < 0 || bound > kPolyBudget - int(polys.size()) || turn < 0 || turn > 6) { + return false; + } + // Match the retail transform, without relying on camera_dist's old draw visibility. + if (turn > 3) turn -= 3; + if (turn == 3) turn = -1; + frame->SetRotation(0.0f, std::numbers::pi_v * (-90.0f * turn) / 180.0f, 0.0f); + frame->SetPosition(x, y, z); + if (!bound) return true; + auto start = polys.size(); + polys.resize(start + bound); + int found = frame->PickUpNearPoly(polys.data() + start, box); + if (found < 0 || found > bound) return false; + polys.resize(start + found); + return true; + }; + if (map.map_type != 1) { + for (auto &part : map.parts) { + if (!part.frame[0]) break; + if (!append(part.camera_collision, part.frame_offset[0][0], part.frame_offset[0][1], + part.frame_offset[0][2], int(part.frame_turn[0]) + part.camera_collision_turn)) return -1; + } + } else { + for (int j = 0; j < 20; ++j) { + for (int i = 0; i < 20; ++i) { + auto &cell = map.cells[i + j * 20]; + if (cell.parts_no == -1) continue; + if (cell.parts_no < 0 || cell.parts_no >= 72 || cell.direction < 0 || cell.direction > 3) return -1; + auto &part = map.parts[cell.parts_no]; + if (!append(part.camera_collision, 160.0f * i, 0.0f, 160.0f * j, + cell.direction + part.camera_collision_turn)) return -1; + } + } + } + return int(polys.size()); +} + +bool MouseCameraClear(const float *eye, const float *look, float eye_radius, float look_radius, + CCPoly *polys, int count, float vertical_radius, float floor_clearance) { + Vec e = V(eye), r = V(look); + if (count < 0 || (count && !polys) || !Finite(e) || !Finite(r) || + !std::isfinite(eye_radius) || !std::isfinite(look_radius) || !std::isfinite(vertical_radius) || + eye_radius < 0 || look_radius < 0 || vertical_radius < 0 || + !std::isfinite(floor_clearance) || floor_clearance <= 0) { + return false; + } + double ray_margin = std::max(eye_radius, look_radius) + kMargin; + Vec down = e + Vec{0, -floor_clearance, 0}, up = e + Vec{0, 15, 0}; + for (int i = 0; i < count; ++i) { + const CCPoly &p = polys[i]; + if (!Finite(V(p.vertex[0])) || !Finite(V(p.vertex[1])) || !Finite(V(p.vertex[2]))) { + return false; + } + Vec normal = Cross(V(p.vertex[1]) - V(p.vertex[0]), V(p.vertex[2]) - V(p.vertex[0])); + bool horizontal = std::fabs(normal.y) > 0.5 * Length(normal); + double clearance = horizontal ? std::min(10.0f, floor_clearance) : 10.0; + if (SegmentTriangle(r, e, p) <= ray_margin || PointTriangle(e, V(p.vertex[0]), V(p.vertex[1]), V(p.vertex[2])) <= clearance + eye_radius + kMargin) { + return false; + } + double floor_min = std::min({double(p.vertex[0][1]), double(p.vertex[1][1]), double(p.vertex[2][1])}); + double floor_max = std::max({double(p.vertex[0][1]), double(p.vertex[1][1]), double(p.vertex[2][1])}); + if (floor_max < down.y - vertical_radius - kMargin || floor_min > up.y + vertical_radius + kMargin) { + continue; + } + if (std::fabs(normal.y) > 0.5 * Length(normal) && SegmentTriangle(down, up, p) <= eye_radius + kMargin) { + return false; + } + } + return true; +} + +float MouseCameraClamp(CCameraFollow &camera, float turn, CCPoly *polys, int count) { + if (!std::isfinite(turn) || !std::isfinite(camera.angle) || !std::isfinite(camera.next_angle) || + !std::isfinite(camera.distance) || !std::isfinite(camera.height) || + !std::isfinite(camera.speed) || camera.speed < 1.0f || + !CorridorClear(camera, 0, 0, polys, count)) { + return 0; + } + // A complete revolution covers any further revolutions; keep work bounded for huge deltas. + double check = std::copysign(std::min(std::fabs(double(turn)), 2 * std::numbers::pi), turn); + int steps = std::max(1, int(std::ceil(std::fabs(check) / kStep))); + double accepted = 0; + // Bound both interval subdivision and total geometry work. Exhaustion drops the remainder + // of this read rather than queuing a delayed turn. + int budget = std::min(4096, 1000000 / std::max(count, 1)); + for (int i = 0; i < steps; ++i) { + double end = check * (i + 1) / steps; + double prefix = ClearPrefix(camera, accepted, end, polys, count, budget); + if (prefix != end) { + return std::nextafter(float(prefix), 0.0f); + } + accepted = end; + } + return turn; +} + +float MouseCameraClampDistance(CCameraFollow &camera, float distance, CCPoly *polys, int count, + float floor_clearance) { + if (!std::isfinite(distance) || distance <= 0.0f || !std::isfinite(camera.distance) || + !std::isfinite(camera.angle) || !std::isfinite(camera.next_angle) || + !std::isfinite(camera.height) || !std::isfinite(camera.speed) || camera.speed < 1.0f || + !CorridorClear(camera, 0, 0, polys, count, floor_clearance)) { + return camera.distance; + } + CCameraFollow candidate(camera); + candidate.distance = distance; + if (CorridorClear(candidate, 0, 0, polys, count, floor_clearance)) return distance; + float accepted = camera.distance; + float refused = distance; + int checks = std::min(16, 1000000 / std::max(count, 1)); + for (int i = 0; i < checks; ++i) { + float middle = (accepted + refused) * 0.5f; + candidate.distance = middle; + if (CorridorClear(candidate, 0, 0, polys, count, floor_clearance)) accepted = middle; + else refused = middle; + } + return accepted; +} diff --git a/port/src/mouse_collision.hpp b/port/src/mouse_collision.hpp new file mode 100644 index 000000000..311738e10 --- /dev/null +++ b/port/src/mouse_collision.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "collision.hpp" + +class CEditGround; +class CCameraFollow; +class CDungeonMap; + +// Collects camera geometry with a proven allocation bound before calling retail collectors. +// A negative result means the geometry could not be validated within the work budget. +int MouseCameraPolys(CEditGround &ground, CBoxVu0 &box, int mask, std::vector &polys); +int MouseCameraPolys(CDungeonMap &map, CBoxVu0 &box, std::vector &polys); + +// A continuous, conservative clearance test for the eye, its look ray and floor clearance. +bool MouseCameraClear(const float *eye, const float *look, float eye_radius, float look_radius, + CCPoly *polys, int count, float vertical_radius = 0.0f, float floor_clearance = 18.0f); + +// Includes pending positions and the remaining follow-camera easing corridor. +float MouseCameraClamp(CCameraFollow &camera, float turn, CCPoly *polys, int count); + +// Validates the entire current/pending/follow corridor for a new horizontal orbit distance. +float MouseCameraClampDistance(CCameraFollow &camera, float distance, CCPoly *polys, int count, + float floor_clearance = 18.0f); diff --git a/port/src/platform/config.cpp b/port/src/platform/config.cpp index 5274d7d91..094e670f7 100644 --- a/port/src/platform/config.cpp +++ b/port/src/platform/config.cpp @@ -271,6 +271,9 @@ bool Apply(Config &config, std::string_view name, const Json &value) { if (name == "input.mouse_capture") { return ReadBool(value, config.mouse_capture); } + if (name == "input.mouse_zoom") { + return ReadBool(value, config.mouse_zoom); + } if (name == "input.mouse_release") { return ReadList(value, config.mouse_release_keys); } @@ -474,6 +477,7 @@ std::string ConfigSerialize(const Config &config) { root["input"]["gyro_invert_y"] = config.gyro_invert_y; root["input"]["mouse_invert_y"] = config.mouse_invert_y; root["input"]["mouse_capture"] = config.mouse_capture; + root["input"]["mouse_zoom"] = config.mouse_zoom; root["input"]["mouse_release"] = config.mouse_release_keys; root["input"]["vibration"] = options.vibration; root["input"]["bindings"] = std::move(bindings); diff --git a/port/src/platform/config.hpp b/port/src/platform/config.hpp index 6279c9523..7b785ecbe 100644 --- a/port/src/platform/config.hpp +++ b/port/src/platform/config.hpp @@ -69,7 +69,7 @@ struct Config { float shadow_distance = 0.0f; float master_volume = 1.0f; std::vector key_bindings; - float mouse_sensitivity = 0.1f; + float mouse_sensitivity = 0.2f; float stick_sensitivity = 1.33f; bool stick_invert_x = false; bool stick_invert_y = false; @@ -79,6 +79,7 @@ struct Config { bool gyro_invert_y = false; bool mouse_invert_y = false; bool mouse_capture = true; + bool mouse_zoom = false; std::vector mouse_release_keys = {"Escape"}; ConfigGameOptions options; bool discord_rich_presence = true; diff --git a/port/src/platform/input.cpp b/port/src/platform/input.cpp index 03df51957..ba88249b2 100644 --- a/port/src/platform/input.cpp +++ b/port/src/platform/input.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include "clock.hpp" @@ -72,13 +73,13 @@ constexpr Action kActions[] = { {"ry+", ActionKind::HalfAxis, 0, kAxisRightY, 1, "K"}, {"lx", ActionKind::Axis, 0, kAxisLeftX, 0, ""}, {"ly", ActionKind::Axis, 0, kAxisLeftY, 0, ""}, - {"rx", ActionKind::Axis, 0, kAxisRightX, 0, "MouseX"}, - // Mouse up looks up: a negative ry lowers the follow camera (AddHeight(-GetRYf())). - {"ry", ActionKind::Axis, 0, kAxisRightY, 0, "-MouseY"}, + {"rx", ActionKind::Axis, 0, kAxisRightX, 0, ""}, + {"ry", ActionKind::Axis, 0, kAxisRightY, 0, ""}, {"fps_toggle", ActionKind::Host, 0, kAxisLeftX, 0, "F3"}, {"developer_menu", ActionKind::Host, 0, kAxisLeftX, 0, "Gamepad:paddle2"}, {"debug_menu", ActionKind::Host, 0, kAxisLeftX, 0, "Gamepad:paddle1"}, {"gyro_hold", ActionKind::Host, 0, kAxisLeftX, 0, "Gamepad:paddle4"}, + {"zoom_reset", ActionKind::Host, 0, kAxisLeftX, 0, "Mouse3"}, }; struct ButtonMap { @@ -105,11 +106,12 @@ const ButtonMap kGamepadButtons[] = { // clang-format on constexpr std::size_t kActionCount = std::size(kActions); -constexpr std::size_t kFirstHostAction = kActionCount - 4; +constexpr std::size_t kFirstHostAction = kActionCount - 5; constexpr std::size_t kHostActionCount = kActionCount - kFirstHostAction; static_assert(kActions[kFirstHostAction].name == "fps_toggle"); static_assert(static_cast(InputHostAction::FpsToggle) == 0); -static_assert(static_cast(InputHostAction::GyroHold) == kHostActionCount - 1); +static_assert(static_cast(InputHostAction::ZoomReset) == kHostActionCount - 1); +constexpr std::size_t kZoomResetHost = static_cast(InputHostAction::ZoomReset); // AxisCalibration (ps2/src/gamepad.cpp): a byte within 49 above or 50 below the centre reads as // zero, and the remaining 78 steps each side span the game's +-128. @@ -129,6 +131,12 @@ constexpr Uint32 kRumbleMilliseconds = 500; constexpr int kMouseButtonCount = 5; constexpr float kGyroDeadband = 0.03f; +// A stick bound to the mouse takes the deflection that turns the dungeon's camera (0.04 radians a +// tick at full deflection, dun/gameloop.cpp:4336) as far as the mouse look would. +constexpr float kMouseStickRadians = 0.04f; + +// Longer between two reads of pad 0 and the game was loading, not looking. +constexpr double kMouseLookMaxGapSeconds = 0.25; struct Source { enum Kind { @@ -161,6 +169,9 @@ std::array, kInputPadCount> g_override; std::array g_keys{}; float g_mouse_dx = 0.0f; float g_mouse_dy = 0.0f; +InputMouseLook g_mouse_look; +bool g_mouse_zoom = false; +float g_scripted_wheel = 0.0f; std::int64_t g_last_latch_tick = -1; std::int64_t g_latch_serial = 0; std::int64_t g_stick_read_serial = -1; @@ -271,6 +282,7 @@ void EnsureBindings() { ParseSources(names, kActions[i].kind, g_bindings[i]); } g_mouse = InputMouseSettings{}; + g_mouse_zoom = false; g_mouse.release_scancodes = {SDL_SCANCODE_ESCAPE}; MouseConfigure(g_mouse.capture, g_mouse.release_scancodes); g_bindings_ready = true; @@ -286,7 +298,7 @@ std::uint8_t StickToByte(Sint16 value) { // which needs over 90% of a modern stick's travel), so the deflection is scaled by // input.stick_sensitivity (1.33 by default, PCSX2's analog sensitivity) and the circle is stretched // onto the square, keeping the direction. -float g_stick_sensitivity = 1.33f; +float g_stick_sensitivity = 1.33f; float g_gyro_sensitivity = 0.5f; ConfigGyro g_gyro = ConfigGyro::Off; bool g_gyro_invert_x = false; @@ -360,7 +372,7 @@ void ReadGamepad(SDL_Gamepad *gamepad, InputPadState &state) { SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY), SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX), SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTY)}; - auto invert = [&](int x) { + auto invert = [&](int x) { if (g_stick_invert_x) { axes[x] = Flip(axes[x]); } @@ -498,9 +510,44 @@ bool HostPolledHeld(std::size_t host) { return false; } +bool MouseAxisBound(int code) { + return std::ranges::any_of(g_bindings, [&](const std::vector &sources) { + return std::ranges::any_of(sources, [&](const Source &source) { + return source.kind == Source::MouseAxis && source.code == code; + }); + }); +} + +// A zoom reset binding owns its physical sources while zoom is enabled. Keyboard alternatives +// of the same game action and actual controller buttons still work normally. +bool ZoomReserved(const Source &source) { + return g_mouse_zoom && !g_menu_mouse && + std::ranges::any_of(g_bindings[kFirstHostAction + kZoomResetHost], [&](const Source &reset) { + return reset.kind == source.kind && reset.code == source.code; + }); +} + +float MouseRadiansPerCount() { return g_mouse.sensitivity * std::numbers::pi_v / 180.0f; } + +InputMouseLook MouseLookFromMotion(float dx, float dy) { + InputMouseLook look; + if (!MouseAxisBound(0)) { + look.yaw = dx * MouseRadiansPerCount(); + } + if (!MouseAxisBound(1)) { + look.pitch = (g_mouse.invert_y ? dy : -dy) * MouseRadiansPerCount(); + } + return look; +} + void PollHostActions() { for (std::size_t host = 0; host < kHostActionCount; ++host) { bool held = HostPolledHeld(host); + if (host == kZoomResetHost && (!g_mouse_zoom || g_menu_mouse)) { + g_host_presses[host] = 0; + g_host_polled[host] = held; + continue; + } if (held && !g_host_polled[host]) { ++g_host_presses[host]; } @@ -557,6 +604,13 @@ void InputApplyConfig(const Config &config) { mouse.release_scancodes.push_back(scancode); } InputSetMouseSettings(mouse); + g_mouse_zoom = config.mouse_zoom; + g_mouse_look.zoom = 0.0f; + g_mouse_look.zoom_reset = false; + g_scripted_wheel = 0.0f; + MouseTakeWheel(); + g_host_presses[kZoomResetHost] = 0; + g_host_polled[kZoomResetHost] = HostPolledHeld(kZoomResetHost); } void InputShutdown() { @@ -564,8 +618,10 @@ void InputShutdown() { g_menu_mouse = false; g_menu_dx = 0.0f; g_menu_dy = 0.0f; + g_mouse_look = {0.0f, 0.0f, g_mouse_look.read}; g_keys.fill(false); g_scripted = {}; + g_scripted_wheel = 0.0f; g_host_presses.fill(0); g_host_polled.fill(false); g_gyro = ConfigGyro::Off; @@ -612,13 +668,38 @@ void InputHandleEvent(const SDL_Event &event) { EnsureBindings(); for (std::size_t host = 0; host < kHostActionCount; ++host) { if (KeyBound(host, event.key.scancode)) { + if (host == kZoomResetHost && (!g_mouse_zoom || g_menu_mouse)) { + continue; + } ++g_host_presses[host]; } } } break; + case SDL_EVENT_MOUSE_BUTTON_DOWN: + // Preserve a click released before the next poll; don't count its poll edge twice. + EnsureBindings(); + if (g_mouse_zoom && !g_menu_mouse && + std::ranges::any_of(g_bindings[kFirstHostAction + kZoomResetHost], [&](const Source &source) { + // SDL numbers middle 2/right 3; bindings use right Mouse2/middle Mouse3. + int button = event.button.button == SDL_BUTTON_MIDDLE ? 3 : event.button.button == SDL_BUTTON_RIGHT ? 2 + : event.button.button; + return source.kind == Source::MouseButton && source.code == button; + })) { + ++g_host_presses[kZoomResetHost]; + } + g_host_polled[kZoomResetHost] = HostPolledHeld(kZoomResetHost); + break; + case SDL_EVENT_MOUSE_BUTTON_UP: + g_host_polled[kZoomResetHost] = HostPolledHeld(kZoomResetHost); + break; case SDL_EVENT_WINDOW_FOCUS_LOST: g_keys.fill(false); + g_mouse_look.yaw = g_mouse_look.pitch = 0.0f; + g_mouse_look.zoom = 0.0f; + g_mouse_look.zoom_reset = false; + g_scripted_wheel = 0.0f; + g_host_presses[kZoomResetHost] = 0; g_menu_dx = 0.0f; g_menu_dy = 0.0f; break; @@ -650,6 +731,28 @@ void InputLatchPad(int pad) { } g_mouse_dx = dx / static_cast(ticks); g_mouse_dy = dy / static_cast(ticks); + // The look takes the whole motion rather than a per-tick speed, so the camera turns by what the + // mouse moved, once, however the reads fall against the display's frames. + if (g_override[0] && !g_menu_mouse) { + dx = g_scripted.mouse_dx; + dy = g_scripted.mouse_dy; + } else if (static_cast(ticks) > kMouseLookMaxGapSeconds * ClockTickRate()) { + dx = 0.0f; + dy = 0.0f; + } + std::uint64_t read = g_mouse_look.read + 1; + g_mouse_look = MouseLookFromMotion(dx, dy); + g_mouse_look.read = read; + // Menus take the live wheel themselves. Everything else drains it each pad read, including + // disabled zoom and loads, so it cannot be played back when gameplay next owns the camera. + float wheel = g_menu_mouse ? 0.0f : MouseTakeWheel() + std::exchange(g_scripted_wheel, 0.0f); + bool reset = g_host_presses[kZoomResetHost] != 0; + g_host_presses[kZoomResetHost] = 0; + if (g_mouse_zoom && !g_menu_mouse && + static_cast(ticks) <= kMouseLookMaxGapSeconds * ClockTickRate()) { + g_mouse_look.zoom = std::isfinite(wheel) ? wheel : 0.0f; + g_mouse_look.zoom_reset = reset; + } Compose(0); } @@ -662,6 +765,12 @@ void InputSetMenuMouse(bool on) { MouseTakeMotion(dx, dy); g_mouse_dx = 0.0f; g_mouse_dy = 0.0f; + g_mouse_look.yaw = g_mouse_look.pitch = 0.0f; + g_mouse_look.zoom = 0.0f; + g_mouse_look.zoom_reset = false; + g_scripted_wheel = 0.0f; + g_host_presses[kZoomResetHost] = 0; + g_host_polled[kZoomResetHost] = HostPolledHeld(kZoomResetHost); MouseTakeWheel(); Compose(0); } @@ -674,13 +783,15 @@ InputMenuMouse InputTakeMenuMouse() { // A script's motion is per tick, and a menu takes once a tick. mouse.dx = g_menu_dx + dx + g_scripted.mouse_dx; mouse.dy = g_menu_dy + dy + g_scripted.mouse_dy; - mouse.wheel = MouseTakeWheel(); + mouse.wheel = MouseTakeWheel() + std::exchange(g_scripted_wheel, 0.0f); mouse.buttons = MouseButtons() | g_scripted.mouse_buttons; g_menu_dx = 0.0f; g_menu_dy = 0.0f; return mouse; } +const InputMouseLook &InputGetMouseLook() { return g_mouse_look; } + void InputNoteLeftStickRead() { g_stick_read_serial = g_latch_serial; } bool InputLeftStickLive() { return g_stick_live; } @@ -736,6 +847,9 @@ InputPadState InputApplyKeyboardMouse(InputPadState base, const InputKeyboardMou for (std::size_t i = 0; i < kActionCount; ++i) { bool down = false; for (const Source &source : g_bindings[i]) { + if (kActions[i].kind != ActionKind::Host && ZoomReserved(source)) { + continue; + } switch (source.kind) { case Source::Key: down |= std::ranges::find(held.keys, source.code) != held.keys.end(); @@ -747,8 +861,8 @@ InputPadState InputApplyKeyboardMouse(InputPadState base, const InputKeyboardMou if (g_menu_mouse) { break; } - mouse[kActions[i].axis] += - source.scale * g_mouse.sensitivity * (source.code == 0 ? held.mouse_dx : mouse_y); + mouse[kActions[i].axis] += source.scale * MouseRadiansPerCount() / kMouseStickRadians * + (source.code == 0 ? held.mouse_dx : mouse_y); break; case Source::GamepadButton: break; @@ -884,5 +998,6 @@ bool InputHostPressed(InputHostAction action) { void InputSetScriptedDevices(const InputKeyboardMouse &held) { EnsureBindings(); g_scripted = held; + g_scripted_wheel = held.mouse_wheel; PollHostActions(); } diff --git a/port/src/platform/input.hpp b/port/src/platform/input.hpp index db1c516b4..1247127d2 100644 --- a/port/src/platform/input.hpp +++ b/port/src/platform/input.hpp @@ -55,11 +55,12 @@ struct InputKeyboardMouse { std::uint32_t mouse_buttons = 0; float mouse_dx = 0.0f; float mouse_dy = 0.0f; + float mouse_wheel = 0.0f; }; struct InputMouseSettings { - // Stick deflection (1 is full) per pixel of motion in one tick. - float sensitivity = 0.1f; + // Degrees of camera turn per count of mouse motion. + float sensitivity = 0.2f; bool invert_y = false; bool capture = true; std::vector release_scancodes; @@ -86,11 +87,28 @@ void InputPoll(); // The window event hook InputInit installs: keys, mouse buttons and motion, focus. void InputHandleEvent(const SDL_Event &event); -// The game is about to read pad: the mouse motion since the previous read becomes the stick -// deflection for this read, and whether the game read the left stick since then decides whether -// stick_dpad applies to it. +// The game is about to read pad: the mouse motion since the previous read becomes this read's mouse +// look (and the deflection of a stick bound to the mouse), and whether the game read the left stick +// since then decides whether stick_dpad applies to it. void InputLatchPad(int pad); +// How far the mouse turns the camera over one tick, in radians: the motion between the game's last +// two reads of pad 0 at input.mouse_sensitivity, the script's motion while one drives the pad. yaw +// above zero turns the view right, pitch above zero looks up. An axis a stick binding takes the +// mouse for (MouseX, MouseY) reads zero. Motion during a pause in the reads longer than a quarter +// of a second, a load, is dropped. +struct InputMouseLook { + float yaw = 0.0f; + float pitch = 0.0f; + // Which read of pad 0 this is, counting from 1. + std::uint64_t read = 0; + // Wheel notches, positive zooms in. Only emitted with input.mouse_zoom enabled outside menus. + float zoom = 0.0f; + bool zoom_reset = false; +}; + +const InputMouseLook &InputGetMouseLook(); + // The game read pad 0's left stick (CGamePad::GetLX/GetLY, port/src/gamepad.cpp). void InputNoteLeftStickRead(); @@ -120,8 +138,8 @@ std::uint8_t InputStickByte(float deflection); // Rebinds one action ("cross", "up", "lx-", "rx", ...) to the names given: SDL key names, Mouse1 to // Mouse5 (left, right, middle, X1, X2), and for the whole-axis actions lx ly rx ry MouseX or MouseY -// with an optional sign and scale (-MouseY, MouseX*0.5). Returns false if the action or a name is -// unknown or does not fit the action. +// with an optional sign and scale (-MouseY, MouseX*0.5), which make the mouse a stick instead of +// the camera's own. Returns false if the action or a name is unknown or does not fit the action. bool InputBindKeys(std::string_view action, std::span keys); // Restores the default bindings and mouse settings. @@ -158,6 +176,7 @@ enum class InputHostAction { DeveloperMenu, DebugMenu, GyroHold, + ZoomReset, }; // Whether a key, mouse button or gamepad button bound to the action is held, live or scripted. diff --git a/port/src/platform/input_script.cpp b/port/src/platform/input_script.cpp index e7d456d32..dd178be0a 100644 --- a/port/src/platform/input_script.cpp +++ b/port/src/platform/input_script.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -16,10 +17,22 @@ struct ButtonName { }; constexpr ButtonName kButtonNames[] = { - {"cross", kInputCross}, {"circle", kInputCircle}, {"square", kInputSquare}, {"triangle", kInputTriangle}, - {"start", kInputStart}, {"select", kInputSelect}, {"l1", kInputL1}, {"r1", kInputR1}, - {"l2", kInputL2}, {"r2", kInputR2}, {"l3", kInputL3}, {"r3", kInputR3}, - {"up", kInputUp}, {"down", kInputDown}, {"left", kInputLeft}, {"right", kInputRight}, + {"cross", kInputCross }, + {"circle", kInputCircle }, + {"square", kInputSquare }, + {"triangle", kInputTriangle}, + {"start", kInputStart }, + {"select", kInputSelect }, + {"l1", kInputL1 }, + {"r1", kInputR1 }, + {"l2", kInputL2 }, + {"r2", kInputR2 }, + {"l3", kInputL3 }, + {"r3", kInputR3 }, + {"up", kInputUp }, + {"down", kInputDown }, + {"left", kInputLeft }, + {"right", kInputRight }, }; InputScript g_script; @@ -77,6 +90,13 @@ bool ParseDeviceToken(std::string_view token, std::string_view lower, InputScrip } return true; } + if (lower.starts_with("wheel:")) { + if (!ParseNumber(lower.substr(6), step.devices.mouse_wheel) || !std::isfinite(step.devices.mouse_wheel)) { + why = "mouse wheel is wheel:number (finite notches)"; + return false; + } + return true; + } if (lower.size() == 6 && lower.starts_with("mouse") && lower[5] >= '1' && lower[5] <= '5') { step.devices.mouse_buttons |= 1u << (lower[5] - '1'); return true; diff --git a/port/src/platform/input_script.hpp b/port/src/platform/input_script.hpp index a1e2e0381..790be4474 100644 --- a/port/src/platform/input_script.hpp +++ b/port/src/platform/input_script.hpp @@ -10,14 +10,14 @@ // Scripted input for headless runs. Each line is // -// [pad1|pad2] [button ...] [key: ...] [mouseN ...] [mouse:dx,dy] [lx ly rx ry] +// [pad1|pad2] [button ...] [key: ...] [mouseN ...] [mouse:dx,dy] [wheel:notches] [lx ly rx ry] // // and holds those buttons (and sticks, centred when omitted) on that pad (pad 1 when omitted) from // that frame of the game's main loop until the pad's next line. Keys (SDL names), mouse buttons and -// mouse motion (pixels per tick) go through the keyboard and mouse bindings as live input does, on -// pad 1 only. Frame 0 covers the start-up -// warm-up too. `#` starts a comment. Pad 1 is held released before its first line; pad 2 is left to -// its device unless a line names it. +// mouse motion (counts per tick) go through the keyboard and mouse bindings and the mouse look as +// live input does, on pad 1 only. Wheel notches are per tick, positive zooms in; use a released line +// on the next frame for one wheel step. Frame 0 covers the start-up warm-up too. `#` starts a comment. +// Pad 1 is held released before its first line; pad 2 is left to its device unless a line names it. struct InputScriptStep { std::int64_t frame = 0; diff --git a/port/src/platform/mouse.cpp b/port/src/platform/mouse.cpp index 7b97f7803..e03f408ce 100644 --- a/port/src/platform/mouse.cpp +++ b/port/src/platform/mouse.cpp @@ -12,6 +12,7 @@ namespace { bool g_capture_enabled = true; std::vector g_release_keys = {SDL_SCANCODE_ESCAPE}; bool g_captured = false; +bool g_released = false; std::uint32_t g_buttons = 0; // The click that recaptures is swallowed, and so is its release. std::uint32_t g_swallowed = 0; @@ -20,10 +21,13 @@ float g_dy = 0.0f; float g_wheel = 0.0f; void SetCaptured(bool captured) { - g_captured = captured; if (SDL_Window *window = WindowHandle()) { - SDL_SetWindowRelativeMouseMode(window, captured); + if (!SDL_SetWindowRelativeMouseMode(window, captured)) { + g_captured = SDL_GetWindowRelativeMouseMode(window); + return; + } } + g_captured = captured; } bool Live() { return g_captured || !g_capture_enabled; } @@ -54,14 +58,24 @@ std::uint32_t ButtonBit(Uint8 button) { } // namespace void MouseConfigure(bool capture, std::span release_scancodes) { + bool enabling = capture && !g_capture_enabled; g_capture_enabled = capture; g_release_keys.assign(release_scancodes.begin(), release_scancodes.end()); if (!capture && g_captured) { SetCaptured(false); } + if (enabling) { + g_released = false; + SDL_Window *window = WindowHandle(); + if (window && (SDL_GetWindowFlags(window) & SDL_WINDOW_INPUT_FOCUS)) SetCaptured(true); + } } void MouseStart() { + // The look is linear in the mouse's counts, so relative mode must not apply the system's + // pointer acceleration. That is SDL's default; an environment variable may still ask for it. + SDL_SetHint(SDL_HINT_MOUSE_RELATIVE_SYSTEM_SCALE, "0"); + g_released = false; SDL_Window *window = WindowHandle(); if (g_capture_enabled && window != nullptr && (SDL_GetWindowFlags(window) & SDL_WINDOW_INPUT_FOCUS) != 0) { @@ -75,6 +89,7 @@ void MouseStop() { } g_buttons = 0; g_swallowed = 0; + g_released = false; g_dx = 0.0f; g_dy = 0.0f; g_wheel = 0.0f; @@ -82,6 +97,18 @@ void MouseStop() { bool MouseHandleEvent(const SDL_Event &event) { switch (event.type) { + case SDL_EVENT_WINDOW_FOCUS_GAINED: + case SDL_EVENT_WINDOW_ENTER_FULLSCREEN: + // Fullscreen startup can finish and gain focus after MouseStart. SDL releases + // relative mode on focus loss; restore it when the game becomes active again. + if (g_capture_enabled && (!g_released || Fullscreen())) { + SDL_Window *window = WindowHandle(); + if (window && (event.window.windowID == 0 || event.window.windowID == SDL_GetWindowID(window)) && + (SDL_GetWindowFlags(window) & SDL_WINDOW_INPUT_FOCUS)) { + SetCaptured(true); + } + } + return false; case SDL_EVENT_WINDOW_FOCUS_LOST: if (g_captured) { SetCaptured(false); @@ -106,6 +133,7 @@ bool MouseHandleEvent(const SDL_Event &event) { case SDL_EVENT_MOUSE_BUTTON_DOWN: if (!Live()) { SetCaptured(true); + g_released = false; g_swallowed |= ButtonBit(event.button.button); return true; } @@ -122,6 +150,7 @@ bool MouseHandleEvent(const SDL_Event &event) { if (g_captured && !event.key.repeat && !Fullscreen() && std::ranges::contains(g_release_keys, static_cast(event.key.scancode))) { SetCaptured(false); + g_released = true; g_buttons = 0; g_dx = 0.0f; g_dy = 0.0f; diff --git a/port/src/platform/mouse.hpp b/port/src/platform/mouse.hpp index 58cce8ccf..bb4016eb7 100644 --- a/port/src/platform/mouse.hpp +++ b/port/src/platform/mouse.hpp @@ -5,9 +5,10 @@ union SDL_Event; -// The mouse as the camera: SDL relative mode while the window has focus. Focus loss and, in a -// window, a release key hand the cursor back; a click in the window takes it again, and that click -// reaches nothing else. Without capture (or with it switched off) motion and buttons always count. +// The mouse as the camera: SDL relative mode, which reports raw counts, while the window has focus. +// Focus loss and, in a window, a release key hand the cursor back; a click in the window takes it +// again, and that click reaches nothing else. Without capture (or with it switched off) motion and +// buttons always count, motion as the cursor moves. void MouseConfigure(bool capture, std::span release_scancodes); @@ -24,7 +25,8 @@ bool MouseCaptured(); // Bit n-1 for Mouse n while held: 1 left, 2 right, 3 middle, 4 and 5 the side buttons. std::uint32_t MouseButtons(); -// The motion in pixels since the previous call; y grows downward. +// The motion since the previous call, in counts when captured (pixels of cursor travel otherwise); +// y grows downward. void MouseTakeMotion(float &dx, float &dy); // The wheel's turn since the previous call, in notches; positive away from the user. diff --git a/port/src/tests/camera_zoom_test.cpp b/port/src/tests/camera_zoom_test.cpp new file mode 100644 index 000000000..5b28c45a3 --- /dev/null +++ b/port/src/tests/camera_zoom_test.cpp @@ -0,0 +1,106 @@ +#include + +#include +#include +#include + +#include "../camera_zoom.hpp" +#include "../platform/clock.hpp" +#include "../platform/config.hpp" +#include "../platform/input.hpp" +#include "../platform/paths.hpp" +#include "camerafollow.hpp" +#include "collision.hpp" +#include "dungeonmap.hpp" +#include "frame.hpp" +#include "platform_fixture.hpp" + +TEST(CameraZoom, RetailRelaxationCannotExpandPastAWallAndResetReturnsToNormal) { + auto root = std::filesystem::temp_directory_path() / + ("chronicle-zoom-test-" + std::to_string(dc::test::ProcessId())); + + struct Cleanup { + std::filesystem::path root; + + ~Cleanup() { + std::error_code error; + std::filesystem::remove(root / "config.json", error); + std::filesystem::remove(root, error); + } + } cleanup{root}; + + PathsSetSaveRoot(root); + Config config; + config.mouse_zoom = true; + config.mouse_capture = false; + ASSERT_TRUE(ConfigChange(config)); + InputApplyConfig(config); + ClockSetUnbounded(true); + ClockReset(); + + auto map = std::make_unique(); + map->map_type = 0; + CFrame frame; + CCollisionMDT collision; + CCPolyBox mesh{}; + float points[3][3] = { + {-200, -100, 90}, + {200, -100, 90}, + {0, 200, 90} + }; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + mesh.poly.vertex[i][j] = points[i][j]; + } + } + for (int j = 0; j < 3; ++j) { + mesh.box.min[j] = collision.min[j] = std::min({points[0][j], points[1][j], points[2][j]}); + mesh.box.max[j] = collision.max[j] = std::max({points[0][j], points[1][j], points[2][j]}); + } + collision.mesh = &mesh; + collision.mesh_count = 1; + frame.flags = 1; + frame.collision = &collision; + map->parts[0].frame[0] = &frame; + map->parts[0].camera_collision = &frame; + + CCameraFollow camera(60.0f, 30.0f, 0.0f, 8.0f); + camera.Step(-1); + InputKeyboardMouse input; + input.mouse_wheel = -5.0f; + InputSetScriptedDevices(input); + InputLatchPad(0); + camera.SetDistance(62.0f); // Retail begins restoring toward 80 before the zoom hook. + DungeonZoomApply(&camera, map.get(), false, 60.0f); + ASSERT_GT(camera.distance, 60.0f); + ASSERT_LT(camera.distance, 80.0f); + InputSetScriptedDevices({}); + for (int step = 0; step < 100; ++step) { + ClockPump(); + InputLatchPad(0); + float previous = camera.distance; + camera.AddDistance((80.0f - previous) / 10.0f); + DungeonZoomApply(&camera, map.get(), false, previous); + camera.Step(1); + ASSERT_LT(camera.pos[2], 80.0f); + ASSERT_LT(camera.next_pos[2], 80.0f); + } + input = {}; + input.mouse_buttons = 1u << 2; // Logical Mouse3, the default reset binding. + InputSetScriptedDevices(input); + ClockPump(); + InputLatchPad(0); + ASSERT_TRUE(InputGetMouseLook().zoom_reset); + DungeonZoomApply(&camera, map.get(), false, camera.distance); + ASSERT_FLOAT_EQ(camera.distance, 60.0f); + input = {}; + input.mouse_wheel = 3.0f; + InputSetScriptedDevices(input); + ClockPump(); + InputLatchPad(0); + DungeonZoomApply(&camera, map.get(), false, 60.0f); + ASSERT_FLOAT_EQ(camera.distance, 36.0f); + ASSERT_FLOAT_EQ(DungeonZoomNearDistance(&camera, 60.0f, false), 36.0f); + ASSERT_FLOAT_EQ(DungeonZoomNearDistance(&camera, 60.0f, true), 60.0f); + ASSERT_FLOAT_EQ(DungeonZoomNearDistance(nullptr, 60.0f, false), 60.0f); +} diff --git a/port/src/tests/menu_option_test.cpp b/port/src/tests/menu_option_test.cpp index d6fd27f38..7ed383fdd 100644 --- a/port/src/tests/menu_option_test.cpp +++ b/port/src/tests/menu_option_test.cpp @@ -128,3 +128,47 @@ TEST(MenuOption, ResolutionListKeepsConfiguredSize) { ASSERT_TRUE(Has(list, 3840, 2160)); ASSERT_EQ(list.front().width, 0); } + +TEST(MenuOption, ZoomResetPresetsPreserveOtherBindingsAndRestoreDefault) { + Config config; + config.key_bindings.push_back({ + "cross", {"Space", "Z"} + }); + ASSERT_EQ(OptionZoomResetChoice(config), 0); + ASSERT_EQ(OptionZoomResetText(config), "Middle Mouse"); + ASSERT_EQ(OptionZoomResetCount(config), 5); + OptionSetZoomReset(config, 2); + ASSERT_EQ(OptionZoomResetChoice(config), 2); + ASSERT_EQ(OptionZoomResetText(config), "Mouse5"); + ASSERT_EQ(config.key_bindings.front(), (ConfigKeyBinding{ + "cross", {"Space", "Z"} + })); + Config restored = ConfigParse(ConfigSerialize(config)); + ASSERT_EQ(OptionZoomResetText(restored), "Mouse5"); + OptionSetZoomReset(config, 4); + ASSERT_EQ(OptionZoomResetText(config), "Disabled"); + OptionRestoreZoomReset(config, Config{}); + ASSERT_EQ(OptionZoomResetText(config), "Middle Mouse"); + ASSERT_EQ(config.key_bindings.size(), 1u); + ASSERT_EQ(config.key_bindings.front().action, "cross"); +} + +TEST(MenuOption, CustomZoomResetBindingIsShownAndKeptUntilChanged) { + Config config; + config.key_bindings = { + {"zoom_reset", {"F12", "Mouse4"}}, + {"r3", {"B"} } + }; + ASSERT_EQ(OptionZoomResetChoice(config), 5); + ASSERT_EQ(OptionZoomResetCount(config), 6); + ASSERT_EQ(OptionZoomResetText(config), "F12, Mouse4"); + Config before = config; + OptionSetZoomReset(config, 5); + ASSERT_EQ(config, before); + OptionSetZoomReset(config, 3); + ASSERT_EQ(OptionZoomResetText(config), "Home"); + ASSERT_EQ(config.key_bindings[1], before.key_bindings[1]); + OptionRestoreZoomReset(config, before); + ASSERT_EQ(OptionZoomResetText(config), "F12, Mouse4"); + ASSERT_EQ(config.key_bindings.front(), before.key_bindings[1]); +} diff --git a/port/src/tests/platform_config_test.cpp b/port/src/tests/platform_config_test.cpp index df0cfc19d..bc7b51a83 100644 --- a/port/src/tests/platform_config_test.cpp +++ b/port/src/tests/platform_config_test.cpp @@ -41,6 +41,19 @@ TEST(PlatformConfig, ShadowDistance) { ASSERT_TRUE(ConfigParse(ConfigSerialize(config)).shadow_distance == 320.0f); } +TEST(PlatformConfig, OptionalMouseZoomRoundTripsAndKeepsResetBindings) { + ASSERT_FALSE(ConfigParse("").mouse_zoom); + ASSERT_FALSE(ConfigParse(R"({"input":{"mouse_zoom":"on"}})").mouse_zoom); + Config config = ConfigParse(R"({"input":{"mouse_zoom":true,"bindings":{ + "zoom_reset":["Mouse5","Home"],"cross":["Space","Z"]}}})"); + ASSERT_TRUE(config.mouse_zoom); + Config restored = ConfigParse(ConfigSerialize(config)); + ASSERT_EQ(restored, config); + ASSERT_EQ(restored.key_bindings.size(), 2u); + ASSERT_FALSE(ConfigAppliesOnRestart("input.mouse_zoom")); + ASSERT_FALSE(ConfigAppliesOnRestart("input.bindings.zoom_reset")); +} + TEST(PlatformConfig, ParsesJson) { Config config = ConfigParse(R"({ // comment @@ -93,7 +106,7 @@ TEST(PlatformConfig, LoadsFromSaveRoot) { TEST(PlatformConfig, ParsesMouseSettingsAndBindings) { Config defaults = ConfigParse(""); - ASSERT_TRUE(defaults.mouse_sensitivity == 0.1f && !defaults.mouse_invert_y && defaults.mouse_capture); + ASSERT_TRUE(defaults.mouse_sensitivity == 0.2f && !defaults.mouse_invert_y && defaults.mouse_capture); ASSERT_TRUE(defaults.stick_sensitivity == 1.33f && defaults.gyro_sensitivity == 0.5f); ASSERT_TRUE(defaults.gyro == ConfigGyro::Held && !defaults.gyro_invert_x && !defaults.stick_invert_y); ASSERT_TRUE(ConfigParse(R"({"input": {"gyro": "first_person"}})").gyro == ConfigGyro::FirstPerson); @@ -124,7 +137,7 @@ TEST(PlatformConfig, ParsesMouseSettingsAndBindings) { ASSERT_TRUE(config.key_bindings[2].keys.size() == 2 && config.key_bindings[2].keys[0] == "Mouse2"); Config bad = ConfigParse(R"({"input": {"mouse_sensitivity": -1, "stick_sensitivity": 0, "mouse_capture": "maybe"}})"); - ASSERT_TRUE(bad.mouse_sensitivity == 0.1f && bad.stick_sensitivity == 1.33f && bad.mouse_capture); + ASSERT_TRUE(bad.mouse_sensitivity == 0.2f && bad.stick_sensitivity == 1.33f && bad.mouse_capture); ASSERT_TRUE(ConfigParse(R"({"input": {"mouse_release": []}})").mouse_release_keys.empty()); } diff --git a/port/src/tests/platform_mouse_capture_test.cpp b/port/src/tests/platform_mouse_capture_test.cpp new file mode 100644 index 000000000..866726108 --- /dev/null +++ b/port/src/tests/platform_mouse_capture_test.cpp @@ -0,0 +1,101 @@ +#include +#include + +#include "../platform/mouse.hpp" +#include "../platform/window.hpp" + +namespace { +void Pump() { + SDL_Event event; + while (SDL_PollEvent(&event)) { + MouseHandleEvent(event); + } +} + +class MouseCapture : public testing::Test { +protected: + void SetUp() override { + // WindowInit exits on video initialization failure. A CI runner may have no + // desktop driver at all, so establish that prerequisite before calling it. + if (!SDL_InitSubSystem(SDL_INIT_VIDEO)) { + GTEST_SKIP() << "Capture needs a desktop video driver: " << SDL_GetError(); + } + const char *driver = SDL_GetCurrentVideoDriver(); + if (driver && (SDL_strcmp(driver, "offscreen") == 0 || SDL_strcmp(driver, "dummy") == 0)) { + GTEST_SKIP() << "Capture needs a focused desktop window"; + } + WindowConfig config; + config.width = 320; + config.height = 240; + config.vulkan = false; + WindowInit(config); + MouseConfigure(true, {}); + } + + void TearDown() override { + MouseStop(); + WindowShutdown(); + } + + bool Focus() { + SDL_ShowWindow(WindowHandle()); + SDL_RaiseWindow(WindowHandle()); + Uint64 end = SDL_GetTicks() + 1000; + do { + Pump(); + if (SDL_GetWindowFlags(WindowHandle()) & SDL_WINDOW_INPUT_FOCUS) { + return true; + } + SDL_Delay(10); + } while (SDL_GetTicks() < end); + return false; + } +}; +} // namespace + +TEST_F(MouseCapture, CapturesWhenFullscreenStartupGainsFocusLater) { + SDL_HideWindow(WindowHandle()); + Pump(); + MouseStart(); + ASSERT_FALSE(MouseCaptured()); + ASSERT_TRUE(SDL_SetWindowFullscreen(WindowHandle(), true)); + if (!Focus()) { + GTEST_SKIP() << "Desktop did not grant input focus"; + } + Pump(); + ASSERT_TRUE(MouseCaptured()); + ASSERT_TRUE(SDL_GetWindowRelativeMouseMode(WindowHandle())); + SDL_Event lost{}; + lost.type = SDL_EVENT_WINDOW_FOCUS_LOST; + MouseHandleEvent(lost); + ASSERT_FALSE(MouseCaptured()); + SDL_Event gained{}; + gained.type = SDL_EVENT_WINDOW_FOCUS_GAINED; + gained.window.windowID = SDL_GetWindowID(WindowHandle()); + MouseHandleEvent(gained); + ASSERT_TRUE(MouseCaptured()); + ASSERT_TRUE(SDL_GetWindowRelativeMouseMode(WindowHandle())); +} + +TEST_F(MouseCapture, ExplicitWindowedReleaseSurvivesRefocus) { + int escape = SDL_SCANCODE_ESCAPE; + MouseConfigure(true, std::span(&escape, 1)); + if (!Focus()) { + GTEST_SKIP() << "Desktop did not grant input focus"; + } + MouseStart(); + ASSERT_TRUE(MouseCaptured()); + SDL_Event event{}; + event.type = SDL_EVENT_KEY_DOWN; + event.key.scancode = SDL_SCANCODE_ESCAPE; + ASSERT_TRUE(MouseHandleEvent(event)); + ASSERT_FALSE(MouseCaptured()); + event.type = SDL_EVENT_WINDOW_FOCUS_LOST; + MouseHandleEvent(event); + event.type = SDL_EVENT_WINDOW_FOCUS_GAINED; + MouseHandleEvent(event); + ASSERT_FALSE(MouseCaptured()); + MouseConfigure(false, {}); + MouseHandleEvent(event); + ASSERT_FALSE(MouseCaptured()); +} diff --git a/port/src/tests/platform_mouse_look_test.cpp b/port/src/tests/platform_mouse_look_test.cpp new file mode 100644 index 000000000..7487def95 --- /dev/null +++ b/port/src/tests/platform_mouse_look_test.cpp @@ -0,0 +1,586 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../camera_port.hpp" +#include "../mouse_collision.hpp" +#include "../platform/clock.hpp" +#include "../platform/input.hpp" +#include "camera.hpp" +#include "camerafollow.hpp" +#include "character.hpp" +#include "collision.hpp" +#include "ebattle.hpp" +#include "edit.hpp" +#include "editarea.hpp" +#include "editground.hpp" +#include "dungeonmap.hpp" +#include "frame.hpp" +#include "gamepad.hpp" + +extern int viewMode; +extern float viewAngleH; +void EyeCamera(CCamera *camera, CCharacter *character, int right_stick); + +namespace { + +constexpr float kDegree = std::numbers::pi_v / 180.0f; + +// Without capture the window's motion always counts, so no window is needed. +void Settings(float sensitivity, bool invert_y) { + InputResetBindings(); + InputMouseSettings settings; + settings.sensitivity = sensitivity; + settings.invert_y = invert_y; + settings.capture = false; + InputSetMouseSettings(settings); + ClockSetUnbounded(true); + ClockReset(); +} + +void Move(float dx, float dy) { + SDL_Event event{}; + event.type = SDL_EVENT_MOUSE_MOTION; + event.motion.xrel = dx; + event.motion.yrel = dy; + InputHandleEvent(event); +} + +// The angle between two headings, folded into half a turn either way. +float Gap(float a, float b) { return std::remainder(a - b, 2.0f * std::numbers::pi_v); } + +} // namespace + +TEST(PlatformMouseLook, TakesEachCountOnceAtTheSensitivity) { + Settings(0.5f, false); + Move(7.0f, -3.0f); + Move(13.0f, -1.0f); + InputLatchPad(0); + ASSERT_NEAR(InputGetMouseLook().yaw, 10.0f * kDegree, 1e-6f); + ASSERT_NEAR(InputGetMouseLook().pitch, 2.0f * kDegree, 1e-6f); + + // Far past a stick's full deflection, and nothing is held over to the next read. + Move(400.0f, 0.0f); + ClockPump(); + InputLatchPad(0); + ASSERT_NEAR(InputGetMouseLook().yaw, 200.0f * kDegree, 1e-5f); + ClockPump(); + InputLatchPad(0); + ASSERT_TRUE(InputGetMouseLook().yaw == 0.0f && InputGetMouseLook().pitch == 0.0f); + + // Pad 2's reads leave the look alone. + Move(4.0f, 0.0f); + InputLatchPad(1); + ASSERT_TRUE(InputGetMouseLook().yaw == 0.0f); + InputLatchPad(0); + ASSERT_NEAR(InputGetMouseLook().yaw, 2.0f * kDegree, 1e-6f); +} + +TEST(PlatformMouseLook, InvertAndStickBindings) { + Settings(1.0f, true); + Move(0.0f, -5.0f); + InputLatchPad(0); + ASSERT_NEAR(InputGetMouseLook().pitch, -5.0f * kDegree, 1e-6f); + + // An axis bound to a stick is the stick's alone. + std::string_view mouse_x[] = {"MouseX*0.5"}; + ASSERT_TRUE(InputBindKeys("rx", mouse_x)); + Move(10.0f, 10.0f); + InputLatchPad(0); + ASSERT_TRUE(InputGetMouseLook().yaw == 0.0f); + ASSERT_NEAR(InputGetMouseLook().pitch, 10.0f * kDegree, 1e-6f); +} + +TEST(PlatformMouseLook, DropsMotionAcrossALoad) { + Settings(1.0f, false); + InputLatchPad(0); + Move(30.0f, 0.0f); + for (int tick = 0; tick < 60; ++tick) { + ClockPump(); + } + InputLatchPad(0); + ASSERT_TRUE(InputGetMouseLook().yaw == 0.0f); +} + +TEST(PlatformMouseLook, TiltHeight) { + ASSERT_TRUE(MouseLookTiltHeight(5.0f, 60.0f, 0.0f) == 5.0f); + // Level, then 45 degrees down: the eye rises to the distance. + ASSERT_NEAR(MouseLookTiltHeight(0.0f, 60.0f, -45.0f * kDegree), 60.0f, 1e-3f); + ASSERT_NEAR(MouseLookTiltHeight(60.0f, 60.0f, 45.0f * kDegree), 0.0f, 1e-3f); + // Short of straight down, and an eye already past that is not pulled back. + ASSERT_TRUE(std::isfinite(MouseLookTiltHeight(0.0f, 60.0f, -3.0f))); + ASSERT_TRUE(MouseLookTiltHeight(0.0f, 60.0f, -3.0f) < 60.0f * 20.0f); + ASSERT_NEAR(MouseLookTiltHeight(40.0f, 0.5f, -0.2f), 40.0f, 1e-2f); +} + +TEST(PlatformMouseLook, FollowCameraTurnsAtOnce) { + Settings(0.2f, false); + CCameraFollow camera(60.0f, 5.0f, 0.0f, 8.0f); + camera.Step(-1); + + // Past half a turn the camera's easing would take the short way round, backwards; 20 degrees + // would show a fraction of itself. The eye is where the whole turn puts it after one step. + for (float counts : {1000.0f, 100.0f}) { + float start = camera.GetAngle(); + Move(counts, 0.0f); + InputLatchPad(0); + camera.AddAngle(0.04f * -MouseLookTurn(&camera, 0.04f, 0.0f)); + camera.Step(1); + float want = start - counts * 0.2f * kDegree; + ASSERT_NEAR(Gap(camera.GetAngle(), want), 0.0f, 1e-4f); + ASSERT_NEAR(Gap(std::atan2(camera.pos[0], camera.pos[2]), want), 0.0f, 1e-4f); + } + + // The stick's own turn still eases. + InputLatchPad(0); + float start = camera.GetAngle(); + camera.AddAngle(-0.04f); + camera.Step(1); + ASSERT_TRUE(Gap(camera.GetAngle(), start) > -0.04f && Gap(camera.GetAngle(), start) < 0.0f); + + // A reading the camera did not turn by is gone at the next pad read: the same delta from + // anything else then only sets where the camera is going. + Move(100.0f, 0.0f); + InputLatchPad(0); + float delta = 0.04f * -MouseLookTurn(&camera, 0.04f, 0.0f); + InputLatchPad(0); + float eye_x = camera.pos[0]; + start = camera.GetAngle(); + camera.AddAngle(delta); + ASSERT_TRUE(camera.GetAngle() == start && camera.pos[0] == eye_x); +} + +TEST(PlatformMouseLook, FollowCameraHeightStaysInBounds) { + Settings(0.2f, false); + CCameraFollow camera(60.0f, 5.0f, 0.0f, 8.0f); + camera.Step(-1); + + // Looking up again and again lowers only where the eye is going, which the game's own minimum + // (the dungeon's 1.6) puts back each time, so the eye never passes under it. + for (int read = 0; read < 10; ++read) { + Move(0.0f, -100.0f); + InputLatchPad(0); + camera.AddHeight(-MouseLookRise(&camera, 0.0f, 30.0f)); + if (camera.GetHeight() <= 1.6f) { + camera.SetHeight(1.6f); + } + camera.Step(1); + ASSERT_TRUE(camera.pos[1] >= 1.6f - 1e-4f); + } + + // The mouse raises the eye no higher than the ceiling the camera keeps. + camera.SetHeight(29.0f); + Move(0.0f, 100.0f); + InputLatchPad(0); + ASSERT_NEAR(MouseLookRise(&camera, 0.0f, 30.0f), -1.0f, 1e-4f); +} + +TEST(PlatformMouseLook, ThirdPersonHeightKeepsTheFollowTargetAndSoftensDescent) { + Settings(0.2f, false); + CCameraFollow camera(60.0f, 5.0f, 0.0f, 8.0f); + camera.SetFollow(12.0f, 14.0f, 20.0f); + camera.Step(-1); + Move(0.0f, 1000.0f); + InputLatchPad(0); + camera.AddHeight(-MouseLookRise(&camera, 0.0f, 30.0f, 5.0f)); + ASSERT_FLOAT_EQ(camera.height, 30.0f); + camera.AddHeight(-0.5f); // The retail descent must not fight this mouse read. + ASSERT_FLOAT_EQ(camera.height, 30.0f); + for (int frame = 0; frame < 60; ++frame) { + ClockPump(); + InputLatchPad(0); + camera.AddHeight(-MouseLookRise(&camera, 0.0f, 30.0f, 5.0f)); + camera.AddHeight(-std::clamp((camera.height - 5.0f) * 0.05f, 0.15f, 0.5f)); + camera.Step(1); + ASSERT_FLOAT_EQ(camera.ref[0], 12.0f); + ASSERT_FLOAT_EQ(camera.ref[1], 14.0f); + ASSERT_FLOAT_EQ(camera.ref[2], 20.0f); + ASSERT_NEAR(std::hypot(camera.pos[0] - camera.ref[0], camera.pos[2] - camera.ref[2]), 60.0f, 1e-4f); + ASSERT_GT(camera.pos[1], camera.ref[1]); + } + // Retail would already be nearly back to baseline after one second. + ASSERT_NEAR(camera.height, 24.0f, 1e-3f); + // Collision raises remain immediate, even during mouse ownership. + camera.AddHeight(7.0f); + ASSERT_NEAR(camera.height, 31.0f, 1e-3f); + // No gameplay height read in this pad read: a scripted descent stays retail. + ClockPump(); + InputLatchPad(0); + camera.AddHeight(-0.5f); + ASSERT_NEAR(camera.height, 30.5f, 1e-3f); +} + +TEST(PlatformMouseLook, ThirdPersonHeightBoundsReverseAndLeaveControllerUnchanged) { + Settings(0.2f, false); + CCameraFollow camera(60.0f, 30.0f, 0.0f, 8.0f); + camera.Step(-1); + Move(0.0f, -1000.0f); + InputLatchPad(0); + camera.AddHeight(-MouseLookRise(&camera, 0.0f, 30.0f, 5.0f)); + ASSERT_FLOAT_EQ(camera.height, 5.0f); + Move(0.0f, 10.0f); + InputLatchPad(0); + camera.AddHeight(-MouseLookRise(&camera, 0.0f, 30.0f, 5.0f)); + ASSERT_GT(camera.height, 5.0f); + // Explicit stick input retains both its height delta and retail descent. + camera.SetHeight(20.0f); + InputLatchPad(0); + camera.AddHeight(-MouseLookRise(&camera, 0.5f, 30.0f, 5.0f)); + ASSERT_FLOAT_EQ(camera.height, 19.5f); + camera.AddHeight(-0.5f); + ASSERT_FLOAT_EQ(camera.height, 19.0f); + camera.FollowOff(); + MouseLookRise(&camera, 0.0f, 30.0f, 5.0f); + camera.FollowOn(); + camera.AddHeight(-0.5f); + ASSERT_FLOAT_EQ(camera.height, 18.5f); +} + +TEST(PlatformMouseLook, TownMouseCanLowerTheCameraAtItsCeiling) { + Settings(0.2f, false); + CCameraFollow camera(60.0f, 30.0f, 0.0f, 8.0f); + camera.Step(-1); + EdMoveCharaInfo.camera = &camera; + EdMoveCharaInfo.interior = 0; + EdDebugCameraFlag = 0; + Move(0.0f, -10.0f); + InputLatchPad(0); + ASSERT_FLOAT_EQ(EdGetRYf(1), 0.0f); + ASSERT_LT(camera.height, 30.0f); + ASSERT_GT(camera.height, 5.0f); +} + +TEST(PlatformMouseLook, InteriorEyeTurnsTheCharacter) { + static unsigned char dma[2][1024]; + ASSERT_TRUE(scePadInit(0) == 1 && scePadPortOpen(0, 0, dma[0]) == 1 && scePadPortOpen(1, 0, dma[1]) == 1); + Settings(0.2f, false); + viewMode = 1; + CCamera eye(1.0f); + CCharacter character; + + // The left stick held or at rest: the mouse turns the character's heading, which the view takes, + // once, and never through RX, which EdMoveChara reads only while the left stick rests. + for (std::uint8_t lx : {std::uint8_t{255}, kInputAxisCentre}) { + InputPadState pad; + pad.connected = true; + pad.left_x = lx; + InputSetOverride(0, &pad); + for (int i = 0; i < 4; ++i) { + GamePad.UpDate(); + } + ASSERT_TRUE((GamePad.GetLXf() != 0.0f) == (lx != kInputAxisCentre)); + + character.SetRotation(0.0f, 0.5f, 0.0f); + viewAngleH = 0.5f; + InputKeyboardMouse mouse; + mouse.mouse_dx = 100.0f; + InputSetScriptedDevices(mouse); + GamePad.UpDate(); + ASSERT_TRUE(EdGetRXf(1) == GamePad.GetRXf()); + EyeCamera(&eye, &character, 1); + ASSERT_NEAR(viewAngleH, 0.5f - 20.0f * kDegree, 1e-5f); + ASSERT_TRUE(character.GetRotation()->y == viewAngleH); + } + + // Many turns in one read still leave a heading within half a turn. + InputKeyboardMouse mouse; + mouse.mouse_dx = 4000.0f; + InputSetScriptedDevices(mouse); + GamePad.UpDate(); + EyeCamera(&eye, &character, 1); + ASSERT_NEAR(Gap(viewAngleH, 0.5f - 820.0f * kDegree), 0.0f, 1e-4f); + ASSERT_TRUE(std::fabs(viewAngleH) <= std::numbers::pi_v); +} + +TEST(PlatformMouseLook, TownTurnStopsAtAWall) { + // A wall in the plane x = 30 facing the eye, which circles look at 60 from +z. + CCPoly wall{}; + float corners[3][4] = { + {30.0f, -100.0f, -300.0f, 1.0f}, + {30.0f, -100.0f, 300.0f, 1.0f}, + {30.0f, 300.0f, 0.0f, 1.0f} + }; + for (int i = 0; i < 3; ++i) { + sceVu0CopyVector(wall.vertex[i], corners[i]); + } + wall.normal[0] = -1.0f; + MouseLookPolys walled = [&](CBoxVu0 &, CCPoly **polys) { + *polys = &wall; + return 1; + }; + MouseLookPolys open = [&](CBoxVu0 &, CCPoly **polys) { + *polys = &wall; + return 0; + }; + float look[4] = {0.0f, 10.0f, 0.0f, 1.0f}; + float eye[4] = {0.0f, 15.0f, 60.0f, 1.0f}; + float whole = 200.0f * kDegree; + + // Towards the wall the turn stops short of it (10 away); away from it, or in the open past half a + // turn, it is whole. + float stopped = MouseLookClampOrbit(eye, look, whole, walled); + ASSERT_GT(stopped, 0.0f); + ASSERT_LT(stopped, std::asin(20.0f / 60.0f)); + ASSERT_NEAR(MouseLookClampOrbit(eye, look, -100.0f * kDegree, walled), -100.0f * kDegree, 1e-4f); + ASSERT_NEAR(MouseLookClampOrbit(eye, look, whole, open), whole, 1e-4f); + + // What was stopped is not turned later. + CCameraFollow camera(60.0f, 5.0f, 0.0f, 8.0f); + camera.Step(-1); + MouseLookTurnOrbit(&camera, stopped); + for (int i = 0; i < 10; ++i) { + camera.Step(1); + } + ASSERT_NEAR(Gap(camera.GetAngle(), stopped), 0.0f, 1e-4f); + + // The town's RX is the stick's alone, so an AddAngle of what the mouse's reading would have been + // turns nothing at once, and a request outliving its pad read applies nothing. + Settings(0.2f, false); + viewMode = 0; + EdMoveCharaInfo.camera = &camera; + EdMoveCharaInfo.interior = 0; + Move(100.0f, 0.0f); + InputLatchPad(0); + TownMouseBegin(&camera, nullptr, 0, 0); + ASSERT_TRUE(EdGetRXf(1) == GamePad.GetRXf()); + float start = camera.GetAngle(); + camera.AddAngle(0.03f * -(20.0f * kDegree / 0.03f)); + ASSERT_TRUE(camera.GetAngle() == start); + InputLatchPad(0); + TownMouseApply(&camera, nullptr, 0, 0); + ASSERT_TRUE(camera.GetAngle() == start); +} + +namespace { +CCPoly Triangle(std::array a, std::array b, std::array c) { + CCPoly p{}; + std::array, 3> points{a, b, c}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + p.vertex[i][j] = points[i][j]; + } + } + return p; +} +} // namespace + +TEST(PlatformMouseLook, ThinWallStopsTheContinuousRayFan) { + CCPoly thin = Triangle({.25f, -100, 20}, {.35f, -100, 20}, {.3f, 100, 20}); + MouseLookPolys walls = [&](CBoxVu0 &, CCPoly **out) { *out=&thin; return 1; }; + float eye[4] = {0, 15, 60, 1}, look[4] = {0, 10, 0, 1}; + // Both former endpoint samples (0 and .03) miss this triangle, but the ray at .015 hits. + float accepted = MouseLookClampOrbit(eye, look, .06f, walls); + ASSERT_GE(accepted, 0); + ASSERT_LT(accepted, .015f); +} + +TEST(PlatformMouseLook, ZoomDistanceCannotCrossAWallOrLeaveAnUnsafePendingTarget) { + CCameraFollow camera(60.0f, 30.0f, 0.0f, 8.0f); + camera.Step(-1); + CCPoly wall = Triangle({-200, -100, 90}, {200, -100, 90}, {0, 200, 90}); + float accepted = MouseCameraClampDistance(camera, 140.0f, &wall, 1); + ASSERT_GT(accepted, 60.0f); + ASSERT_LT(accepted, 80.0f); + camera.SetDistance(accepted); + for (int step = 0; step < 100; ++step) { + camera.Step(1); + ASSERT_LT(camera.pos[2], 80.0f); + ASSERT_LT(camera.next_pos[2], 80.0f); + ASSERT_FLOAT_EQ(camera.ref[2], 0.0f); + } + ASSERT_FLOAT_EQ(MouseCameraClampDistance(camera, 40.0f, &wall, 1), 40.0f); + ASSERT_FLOAT_EQ(MouseCameraClampDistance(camera, 100.0f, nullptr, -1), camera.distance); + camera.next_pos[2] = 100.0f; + ASSERT_FLOAT_EQ(MouseCameraClampDistance(camera, 40.0f, &wall, 1), camera.distance); +} + +TEST(PlatformMouseLook, DungeonZoomCollectorRejectsInvalidCellsAndOversizedMeshes) { + auto map = std::make_unique(); + map->map_type = 1; + for (auto &cell : map->cells) cell.parts_no = -1; + CBoxVu0 box{}; + for (int i = 0; i < 3; ++i) { + box.min[i] = -1000.0f; + box.max[i] = 1000.0f; + } + std::vector output; + ASSERT_EQ(MouseCameraPolys(*map, box, output), 0); + map->cells[0].parts_no = 72; + ASSERT_EQ(MouseCameraPolys(*map, box, output), -1); + map->cells[0].parts_no = 0; + map->cells[0].direction = 0; + CFrame frame; + CCollisionMDT collision; + frame.flags = 1; + frame.collision = &collision; + collision.mesh_count = 32769; + map->parts[0].camera_collision = &frame; + ASSERT_EQ(MouseCameraPolys(*map, box, output), -1); + ASSERT_TRUE(output.empty()); +} + +TEST(PlatformMouseLook, LowDungeonCameraZoomRetainsFloorAndWallClearance) { + CCameraFollow camera(60.0f, 1.6f, 0.0f, 8.0f); + camera.SetFollow(0.0f, 6.0f, 0.0f); + camera.Step(-1); + CCPoly floor = Triangle({-1000, 0, -1000}, {1000, 0, -1000}, {0, 0, 1000}); + for (int step = 0; step < 240; ++step) { + float goal = step < 120 ? 100.0f : 60.0f; + camera.SetDistance(MouseCameraClampDistance(camera, goal, &floor, 1, 5.0f)); + camera.Step(1); + ASSERT_GT(camera.pos[1], 5.0f); + if (step == 119) ASSERT_NEAR(camera.distance, 100.0f, 1e-3f); + } + ASSERT_NEAR(camera.distance, 60.0f, 1e-3f); + CCPoly wall = Triangle({-200, -100, 90}, {200, -100, 90}, {0, 200, 90}); + ASSERT_LT(MouseCameraClampDistance(camera, 140.0f, &wall, 1, 5.0f), 80.0f); +} + +TEST(PlatformMouseLook, InvalidFloorDoesNotDisableClearance) { + CCPoly floor = Triangle({-100, 0, -100}, {100, 0, -100}, {0, 0, 300}); + MouseLookPolys walls = [&](CBoxVu0 &, CCPoly **out) { *out=&floor; return 1; }; + float eye[4] = {0, 17, 60, 1}, look[4] = {0, 10, 0, 1}; + ASSERT_EQ(MouseLookClampOrbit(eye, look, 45 * kDegree, walls), 0); +} + +TEST(PlatformMouseLook, ClearFloorDoesNotLimitAnInstantFlick) { + CCPoly floor = Triangle({-1000, 0, -1000}, {1000, 0, -1000}, {0, 0, 3000}); + MouseLookPolys walls = [&](CBoxVu0 &, CCPoly **out) { *out=&floor; return 1; }; + float eye[4] = {0, 19, 70, 1}, look[4] = {0, 14, 0, 1}; + ASSERT_FLOAT_EQ(MouseLookClampOrbit(eye, look, 200 * kDegree, walls), 200 * kDegree); + ASSERT_FLOAT_EQ(MouseLookClampOrbit(eye, look, -200 * kDegree, walls), -200 * kDegree); +} + +TEST(PlatformMouseLook, ContractedEyeDoesNotLeavePendingTargetAcrossWall) { + CCPoly wall = Triangle({30, -100, -300}, {30, -100, 300}, {30, 300, 0}); + CCameraFollow camera(60, 5, 0, 8); + camera.SetFollow(0, 10, 0); + camera.Step(-1); + camera.SetPos(nullptr, 0, 15, 20); + camera.Step(1); + float accepted = MouseCameraClamp(camera, 45 * kDegree, &wall, 1); + ASSERT_GE(accepted, 0); + ASSERT_LT(accepted, std::asin(20.0f / 60)); + MouseLookTurnOrbit(&camera, accepted); + for (int i = 0; i < 100; ++i) { + camera.Step(1); + ASSERT_LT(camera.pos[0], 20.0f); + ASSERT_LT(camera.next_pos[0], 20.0f); + } +} + +TEST(PlatformMouseLook, CollectorHandlesMoreThanRetailScratchAndStopsBeforeBudget) { + CEditGround ground{}; + ground.Initialize(); + CFrame frame; + CCollisionMDT collision; + std::vector mesh(401); + for (auto &p : mesh) { + p.poly = Triangle({.25f, -100, 20}, {.35f, -100, 20}, {.3f, 100, 20}); + for (int j = 0; j < 3; ++j) { + p.box.min[j] = std::min({p.poly.vertex[0][j], p.poly.vertex[1][j], p.poly.vertex[2][j]}); + p.box.max[j] = std::max({p.poly.vertex[0][j], p.poly.vertex[1][j], p.poly.vertex[2][j]}); + } + } + collision.mesh = mesh.data(); + collision.mesh_count = int(mesh.size()); + frame.flags = 1; + frame.collision = &collision; + ground.parts[0].handle = 0; + ground.parts[0].camera_frame = &frame; + CBoxVu0 box{}; + for (int j = 0; j < 3; ++j) { + collision.min[j] = mesh[0].box.min[j]; + collision.max[j] = mesh[0].box.max[j]; + ground.parts[0].bound.min[j] = -100; + ground.parts[0].bound.max[j] = 100; + box.min[j] = -200; + box.max[j] = 200; + } + std::vector output; + ASSERT_EQ(MouseCameraPolys(ground, box, 0xFFFF, output), 401); + ASSERT_EQ(output.size(), 401u); + // No collector call can run on this deliberately undersized fixture when its claimed + // mesh count exceeds the allocation budget. + collision.mesh_count = 32769; + ASSERT_EQ(MouseCameraPolys(ground, box, 0xFFFF, output), -1); + ASSERT_TRUE(output.empty()); +} + +TEST(PlatformMouseLook, CollectorAppendsEveryGridAreaAndRejectsInvalidMetadata) { + CEditGround ground{}; + ground.Initialize(); + CEditArea first{}, second{}; + first.SetSize(16, 16, 20, 1); + second.SetSize(16, 16, 20, 1); + second.offset_y = 100; + ground.areas[0] = &first; + ground.areas[1] = &second; + CBoxVu0 box{}; + std::vector output; + EXPECT_EQ(MouseCameraPolys(ground, box, 0xffff, output), 1024); + ASSERT_EQ(output.size(), 1024u); + EXPECT_EQ(output[0].vertex[0][1], 0); + EXPECT_EQ(output[512].vertex[0][1], 100); + second.width = 17; + EXPECT_EQ(MouseCameraPolys(ground, box, 0xffff, output), -1); + second.width = 16; + second.map_no = 5; + EXPECT_EQ(MouseCameraPolys(ground, box, 0xffff, output), -1); + ground.areas[0] = ground.areas[1] = nullptr; +} + +TEST(PlatformMouseLook, CollectorBoundsVisitsEvenForDisabledSiblingCycles) { + CEditGround ground{}; + ground.Initialize(); + CFrame parent, child; + parent.flags = 0; + child.flags = 4; + parent.child = &child; + child.brother = &child; + ground.parts[0].handle = 0; + ground.parts[0].camera_frame = &parent; + CBoxVu0 box{}; + for (int i = 0; i < 3; ++i) { + ground.parts[0].bound.min[i] = -100; + ground.parts[0].bound.max[i] = 100; + box.min[i] = -200; + box.max[i] = 200; + } + std::vector output; + int result = MouseCameraPolys(ground, box, 0xffff, output); + parent.child = child.brother = nullptr; + EXPECT_EQ(result, -1); + EXPECT_TRUE(output.empty()); +} + +TEST(PlatformMouseLook, MenuMotionAndFocusLossCannotReachTheCamera) { + Settings(0.2f, false); + Move(100, 0); + InputLatchPad(0); + ASSERT_NE(InputGetMouseLook().yaw, 0); + InputSetMenuMouse(true); + ASSERT_EQ(InputGetMouseLook().yaw, 0); + Move(100, 0); + InputLatchPad(0); + ASSERT_EQ(InputGetMouseLook().yaw, 0); + InputSetMenuMouse(false); + InputLatchPad(0); + ASSERT_EQ(InputGetMouseLook().yaw, 0); + Move(100, 0); + InputLatchPad(0); + ASSERT_NE(InputGetMouseLook().yaw, 0); + SDL_Event event{}; + event.type = SDL_EVENT_WINDOW_FOCUS_LOST; + InputHandleEvent(event); + ASSERT_EQ(InputGetMouseLook().yaw, 0); +} diff --git a/port/src/tests/platform_pad_test.cpp b/port/src/tests/platform_pad_test.cpp index da88f070d..73be84a75 100644 --- a/port/src/tests/platform_pad_test.cpp +++ b/port/src/tests/platform_pad_test.cpp @@ -3,7 +3,10 @@ #include #include "../gameloop.hpp" +#include "../platform/clock.hpp" +#include "../platform/config.hpp" #include "../platform/input.hpp" +#include "../platform/input_script.hpp" #include "gamepad.hpp" #include "mainselect.hpp" @@ -369,3 +372,160 @@ TEST(PlatformPad, OptionsMouseOwnsMotionButtonsAndWheel) { ASSERT_NE(InputApplyKeyboardMouse(base, held).buttons, kInputStart); InputShutdown(); } + +namespace { +void ZoomSettings(bool enabled = true) { + InputShutdown(); + Config config; + config.mouse_zoom = enabled; + config.mouse_capture = false; + InputApplyConfig(config); + ClockSetUnbounded(true); + ClockReset(); + InputLatchPad(0); +} + +void Wheel(float notches) { + SDL_Event event{}; + event.type = SDL_EVENT_MOUSE_WHEEL; + event.wheel.y = notches; + InputHandleEvent(event); +} + +void MiddleClick(bool down) { + SDL_Event event{}; + event.type = down ? SDL_EVENT_MOUSE_BUTTON_DOWN : SDL_EVENT_MOUSE_BUTTON_UP; + event.button.button = SDL_BUTTON_MIDDLE; + InputHandleEvent(event); +} +} // namespace + +TEST(PlatformPad, MouseZoomWheelAndQuickResetAreTakenOnce) { + ZoomSettings(); + Wheel(2.5f); + MiddleClick(true); + MiddleClick(false); // Neither poll nor pad read has happened during the click. + InputPoll(); + InputLatchPad(1); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 2.5f); + ASSERT_TRUE(InputGetMouseLook().zoom_reset); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + // Polling a held click does not repeat its reset. + MiddleClick(true); + InputPoll(); + InputLatchPad(0); + ASSERT_TRUE(InputGetMouseLook().zoom_reset); + InputPoll(); + InputLatchPad(0); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + InputShutdown(); +} + +TEST(PlatformPad, MouseZoomResetReservesOnlyItsBoundSources) { + ZoomSettings(); + InputKeyboardMouse held; + held.mouse_buttons = 1u << 2; // Logical Mouse3; SDL's middle mask has a different bit. + ASSERT_EQ(InputApplyKeyboardMouse({}, held).buttons & kInputR3, 0); + held.keys = {SDL_SCANCODE_B}; + ASSERT_NE(InputApplyKeyboardMouse({}, held).buttons & kInputR3, 0); + // A configured keyboard reset also owns its binding without disturbing other shortcuts. + std::string_view reset[] = {"Home"}; + std::string_view cross[] = {"Home", "Space"}; + ASSERT_TRUE(InputBindKeys("zoom_reset", reset)); + ASSERT_TRUE(InputBindKeys("cross", cross)); + held = {.keys = {SDL_SCANCODE_HOME}}; + ASSERT_EQ(InputApplyKeyboardMouse({}, held).buttons, 0); + InputSetScriptedDevices(held); + InputLatchPad(0); + ASSERT_TRUE(InputGetMouseLook().zoom_reset); + InputLatchPad(0); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + held.keys = {SDL_SCANCODE_SPACE}; + ASSERT_NE(InputApplyKeyboardMouse({}, held).buttons & kInputCross, 0); + ZoomSettings(false); + held = {.mouse_buttons = 1u << 2}; + ASSERT_NE(InputApplyKeyboardMouse({}, held).buttons & kInputR3, 0); + Wheel(3.0f); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + InputShutdown(); +} + +TEST(PlatformPad, MouseZoomDropsLoadsFocusAndMenuTransitions) { + ZoomSettings(); + Wheel(1.0f); + MiddleClick(true); + MiddleClick(false); + for (int tick = 0; tick < 60; ++tick) { + ClockPump(); + } + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + Wheel(1.0f); + MiddleClick(true); + SDL_Event event{}; + event.type = SDL_EVENT_WINDOW_FOCUS_LOST; + InputHandleEvent(event); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + InputSetMenuMouse(true); + Wheel(2.0f); + MiddleClick(true); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + ASSERT_FLOAT_EQ(InputTakeMenuMouse().wheel, 2.0f); + ASSERT_FLOAT_EQ(InputTakeMenuMouse().wheel, 0.0f); + Wheel(4.0f); + InputSetMenuMouse(false); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + InputShutdown(); +} + +TEST(PlatformPad, InputScriptParsesZoomWheelAndRejectsInvalidNotches) { + InputScript script; + std::string error; + ASSERT_TRUE(InputScriptParse("0\n10 wheel:1.5 mouse3\n11\n20 wheel:-2\n21\n", script, error)) << error; + ASSERT_FLOAT_EQ(InputScriptDevicesAt(script, 10).mouse_wheel, 1.5f); + ASSERT_EQ(InputScriptDevicesAt(script, 10).mouse_buttons, 1u << 2); + ASSERT_FLOAT_EQ(InputScriptDevicesAt(script, 11).mouse_wheel, 0.0f); + ASSERT_FLOAT_EQ(InputScriptDevicesAt(script, 20).mouse_wheel, -2.0f); + ASSERT_FALSE(InputScriptParse("0 wheel:nan\n", script, error)); + ASSERT_FALSE(InputScriptParse("0 wheel:inf\n", script, error)); + ASSERT_FALSE(InputScriptParse("0 wheel:1x\n", script, error)); + ASSERT_FALSE(InputScriptParse("0 pad2 wheel:1\n", script, error)); +} + +TEST(PlatformPad, ScriptedZoomWheelIsTakenOnceAndMenusOwnIt) { + ZoomSettings(); + InputKeyboardMouse scripted; + scripted.mouse_wheel = -1.5f; + scripted.mouse_buttons = 1u << 2; + InputSetScriptedDevices(scripted); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, -1.5f); + ASSERT_TRUE(InputGetMouseLook().zoom_reset); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + InputSetMenuMouse(true); + InputSetScriptedDevices(scripted); + InputLatchPad(0); + ASSERT_FLOAT_EQ(InputGetMouseLook().zoom, 0.0f); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + ASSERT_FLOAT_EQ(InputTakeMenuMouse().wheel, -1.5f); + ASSERT_FLOAT_EQ(InputTakeMenuMouse().wheel, 0.0f); + InputSetMenuMouse(false); + InputLatchPad(0); + ASSERT_FALSE(InputGetMouseLook().zoom_reset); + InputShutdown(); +}