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();
+}