-- Voxel world mode: the first-person camera -- the 1ST rung. -- -- Every other rung is the same camera at a different pitch: an orbit over -- the view centre, described by one number. 1ST is a different rig -- entirely: the eye stands in the player's own head, the view direction is -- the player's to steer -- mouse, right stick or a touch drag -- and the -- rig rides the placed-camera seam (Voxel3D.camera) that the staged battle -- already proved out. Everything downstream of that seam -- the shader -- uniforms, project(), the sky's vanishing line, the water's lean -- reads -- eye and focus the same way it always has. -- -- What this module owns: -- -- the ATTITUDE yaw and pitch, fed by whichever look input speaks: -- relative mouse motion, the right stick's rate, or a -- touch dragged across open screen. All three drive the -- same two numbers, so they compose instead of fighting. -- -- the BLEND easing between the orbit and the head. Stepping onto -- the rung dives the camera from wherever the orbit was -- into the player's eyes over half a second; stepping off -- flies it back out. Mid-blend the rig is a straight lerp -- of the two cameras -- eye, focus, fov, up -- through -- the same placed-camera record. -- -- the MOVE INTENT the analog vector FreeMove walks the player by, -- gathered here because it is made of the same devices: -- the left stick's raw axes, the touch d-pad's true -- deflection, or the held keys, rotated by this camera's -- yaw so "forward" means "where I am looking". -- -- Deliberately NOT here: movement itself (lib/FreeMove.lua, which owns the -- collision walk and the grid the game logic still lives on), and the -- billboard math that faces cards at this eye (VoxelScene, which owns -- every other card matrix too). -- -- Everything the module reaches -- the mouse's relative mode, the wrapped -- love handlers, the touch overlay's hit test -- is pcall-guarded the same -- way the 3D pass is: headless runs and drivers without a mouse simply -- never see the input, and the rung falls back to holding the 75-degree -- orbit. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local Mat4 = V.require("Mat4") local Voxel = V.require("VoxelState") local Voxel3D = V.require("Voxel3D") local WorldCurve = V.require("WorldCurve") local FirstPerson = {} -- ------- the rig's numbers -- -- EYE_HEIGHT stands the eye near the top of the 16px sprite -- the head, -- not the hat tip -- above the same ground-plus-lift the character card -- stands on, so surfing bobs and ledge hops carry the view with them. -- -- FOV is wider than the diorama's ~53 degrees: inside the world, the -- diorama's lens reads as a keyhole. 65 vertical is the modern-shooter -- middle ground. -- -- FOCUS_DIST is short on purpose: the placed-camera branch derives its -- near plane from |eye - focus| (dist * 0.05), and the eye walks within -- 2-3 world pixels of a wall face when sliding along it -- a far focus -- would push the near plane through the wall and clip a hole in it. FirstPerson.EYE_HEIGHT = 13 FirstPerson.FOV = math.rad(65) FirstPerson.FOCUS_DIST = 24 -- Pitch limits, in radians below horizontal (positive looks DOWN). The -- world has no ceiling and the sky's bands sit low, so looking far up -- shows the void above the gradient; the up-range is clamped tighter than -- the down-range for that reason, not a technical one. FirstPerson.PITCH_DOWN = math.rad(70) FirstPerson.PITCH_UP = -math.rad(50) FirstPerson.PITCH_DEFAULT = math.rad(10) -- how long the dive into (and out of) the head takes, in seconds FirstPerson.BLEND_TIME = 0.45 -- ------- look input tuning -- -- MOUSE_SENS is radians per relative-mode count -- about 0.18 degrees per -- count, the conventional shooter default. STICK rates are radians per -- second at full deflection, with a squared response curve so small -- deflections aim and full ones turn. TOUCH_TURN is what one full screen -- width of drag turns, mobile-shooter convention. FirstPerson.MOUSE_SENS = 0.0032 FirstPerson.STICK_YAW = 3.5 FirstPerson.STICK_PITCH = 2.4 FirstPerson.STICK_DEAD = 0.18 FirstPerson.TOUCH_TURN = 2.2 * math.pi FirstPerson.MOVE_DEAD = 0.25 -- ------- state -- -- Yaw is a world bearing: 0 faces south (+Z, the way a resting sprite -- faces), pi/2 east -- the same convention VoxelScene.YAW uses, so a -- facing converts to a yaw by table lookup. FirstPerson.yaw = 0 FirstPerson.pitch = FirstPerson.PITCH_DEFAULT FirstPerson.blend = 0 local wasEngaged = false local stick = { x = 0, y = 0 } -- right stick, latest event values local mouseDX, mouseDY = 0, 0 -- relative counts since last update local lookTouch = nil -- { id, x, y } of the claimed finger local touchMove = nil -- the touch d-pad's analog deflection local captured = false -- mouse relative mode engaged by us -- the placed-camera record this module last handed to Voxel3D, so passes -- that key behaviour off "is the first-person rig the one drawing" (the -- billboard yaw, the frame remap) can ask by identity rather than by mode -- -- the battle's own placed camera must never read as first person local rig = nil local FACING_ANGLE = { down = 0, right = math.pi / 2, up = math.pi, left = -math.pi / 2, } local FACING_ORDER = { "down", "right", "up", "left" } local function wrapPi(a) return (a + math.pi) % (2 * math.pi) - math.pi end local function ease(t) return t * t * (3 - 2 * t) end -- ------- gates -- Whether the 1ST rung is selected and the 3D pass can carry it. function FirstPerson.engaged() return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available() end -- Whether first person should be READING the player's inputs right now: -- engaged, with the overworld on top of the stack (a menu, a dialog or a -- battle above it owns the buttons, exactly as it does for grid walking). function FirstPerson.driving() if not FirstPerson.engaged() then return false end local ok, top, ow = pcall(function() local Game = require("src.core.Game") return Game.stack and Game.stack:top(), Game.overworld end) return ok and top ~= nil and top == ow end -- The eased blend, 0 at the orbit and 1 in the head. function FirstPerson.blendEased() return ease(FirstPerson.blend) end -- The blend, but only while the free-roam pass's own rig is the placed -- camera. The battle scene places a camera of its own through the same -- seam, and its cards must keep their stage lean rather than yawing at a -- first-person eye that is not looking at them. function FirstPerson.cardBlend() if not rig or Voxel3D.camera ~= rig then return 0 end return ease(FirstPerson.blend) end -- A VR eye stepping into the rig's shoes: the VR pass builds its own -- placed cameras (one per eye) and hands each one here as it draws, so -- everything keyed to "the first-person rig is drawing" -- the billboard -- yaw, the frame remap, the hidden player card -- answers for that eye. -- In the diorama (blend 0) adoption is inert: cardBlend still reports -- zero and the cards keep their lean. function FirstPerson.adoptVReye(record) rig = record end -- Whether the player's own card should be left out of the camera draw: -- deep enough into the blend that the card would fill the lens from -- inside. The sun pass keeps drawing it either way -- a first-person -- player still throws a shadow on the ground ahead. function FirstPerson.hidePlayer() return FirstPerson.cardBlend() > 0.9 end -- ------- attitude -- Apply a look delta, in radians. Everything that turns the head funnels -- through here, so the clamps live once. function FirstPerson.lookBy(dyaw, dpitch) FirstPerson.yaw = wrapPi(FirstPerson.yaw + dyaw) FirstPerson.pitch = math.max(FirstPerson.PITCH_UP, math.min(FirstPerson.PITCH_DOWN, FirstPerson.pitch + dpitch)) end -- The view direction's flat compass facing, for everything that still -- thinks in the grid's four directions: the cell A interacts with, the -- sprite the sun sees, the direction a blocked slide bonks in. function FirstPerson.compassFacing() local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) if math.abs(s) > math.abs(c) then return s > 0 and "right" or "left" end return c > 0 and "down" or "up" end -- The unit look direction, and its flat (ground-plane) part. local function lookDir() local cp = math.cos(FirstPerson.pitch) return math.sin(FirstPerson.yaw) * cp, -math.sin(FirstPerson.pitch), math.cos(FirstPerson.yaw) * cp end function FirstPerson.lookFlat() return math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) end -- ------- billboards seen from inside the world -- -- The diorama's cards face south and lean back by the camera's pitch -- -- correct for a camera that always stands south. An eye that can stand -- ANYWHERE sees a south-facing card edge-on from the east, so in first -- person every card yaws about its feet to face the eye (cylindrical -- billboarding: upright, never tipping). VoxelScene blends its matrices -- between the two by cardBlend. -- The yaw that turns a card's south-facing normal toward the eye. function FirstPerson.cardYaw(wx, wz) local eye = rig and rig.eye if not eye then return 0 end local dx, dz = eye[1] - wx, eye[3] - wz if dx * dx + dz * dz < 1e-9 then return 0 end return math.atan2(dx, dz) end -- Which of the four sprite frames an entity shows THIS eye: its facing -- rotated into the viewer's own frame, quantised. The flat game's frames -- are "how this pose looks from the south", so the apparent facing is the -- pose rotated by where the viewer actually stands -- walk behind an NPC -- and you see their back, circle to their flank and you see the profile, -- exactly as the four frames Gen 1 drew intend. function FirstPerson.apparentFacing(facing, wx, wz) local eye = rig and rig.eye local phi = FACING_ANGLE[facing] if not (eye and phi) then return facing end local dx, dz = eye[1] - wx, eye[3] - wz if dx * dx + dz * dz < 1e-9 then return facing end local rel = wrapPi(phi - math.atan2(dx, dz)) local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4 return FACING_ORDER[idx + 1] end -- ------- the move intent -- -- The analog vector FreeMove walks by, in CAMERA space: mx strafes (+ -- right), mz advances (+ forward). Whichever device is actually deflected -- answers -- the left stick's raw axes first (the engine quantises them to -- a d-pad; the raw pair is the analog truth), then a touch d-pad finger, -- then the held keys. Magnitude caps at 1. function FirstPerson.moveVector() local ok, Game = pcall(require, "src.core.Game") local input = ok and Game.input or nil local ax = input and input.stickAxis or nil if ax then local mag = math.sqrt(ax.x * ax.x + ax.y * ax.y) if mag > FirstPerson.MOVE_DEAD then local t = math.min(1, (mag - FirstPerson.MOVE_DEAD) / (1 - FirstPerson.MOVE_DEAD)) return ax.x / mag * t, -ax.y / mag * t end end if touchMove then local mag = math.sqrt(touchMove.x * touchMove.x + touchMove.y * touchMove.y) if mag > FirstPerson.MOVE_DEAD then local t = math.min(1, mag) return touchMove.x / mag * t, -touchMove.y / mag * t end end if input then local mx = (input:isDown("right") and 1 or 0) - (input:isDown("left") and 1 or 0) local mz = (input:isDown("up") and 1 or 0) - (input:isDown("down") and 1 or 0) if mx ~= 0 or mz ~= 0 then local mag = math.sqrt(mx * mx + mz * mz) return mx / mag, mz / mag end end return 0, 0 end -- Rotate a camera-space move into world space: forward is the flat look -- direction, strafe-right is its right hand. (cross(forward, up) with -- forward = (sin y, 0, cos y) and up = +Y lands right on (-cos y, 0, -- sin y): face south and your right hand points west.) function FirstPerson.moveWorld(mx, mz) local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) return -c * mx + s * mz, s * mx + c * mz end -- ------- the tick -- Runs from the pipeline's update hook, every frame whatever the level -- -- the same tick VoxelState eases the orbit on. Owns the blend, the mouse -- capture lifecycle, and the frame's stick-rate look. function FirstPerson.update(dt) local engagedNow = FirstPerson.engaged() -- entering the rung: the head starts looking the way the sprite faces, -- pitched gently down -- the reading pose of the flat game if engagedNow and not wasEngaged then local ok, facing = pcall(function() local Game = require("src.core.Game") return Game.overworld and Game.overworld.player and Game.overworld.player.facing end) FirstPerson.yaw = (ok and FACING_ANGLE[facing]) or 0 FirstPerson.pitch = FirstPerson.PITCH_DEFAULT end wasEngaged = engagedNow -- the blend, held at flat until there is terrain to dive into -- the -- same wait Voxel.update keeps for the orbit tween, for the same reason local target = engagedNow and 1 or 0 if target > FirstPerson.blend and FirstPerson.blend == 0 and not Voxel.ready then target = 0 end local step = dt / FirstPerson.BLEND_TIME if FirstPerson.blend < target then FirstPerson.blend = math.min(target, FirstPerson.blend + step) elseif FirstPerson.blend > target then FirstPerson.blend = math.max(target, FirstPerson.blend - step) end if FirstPerson.blend <= 0 and rig then -- fully out: let go of the placed camera (unless a battle already -- swapped its own in, which is not ours to clear) if Voxel3D.camera == rig then Voxel3D.camera = nil end rig = nil end -- mouse capture follows engagement: captured whenever the rung is on and -- the window has focus, released the moment either ends. Checked against -- the live mode rather than toggled on edges, so a capture lost to the -- OS (alt-tab) re-arms itself on the next focused frame. local wantCapture = engagedNow if wantCapture and love.window and love.window.hasFocus then local okF, focus = pcall(love.window.hasFocus) wantCapture = okF and focus or false end if love.mouse and love.mouse.setRelativeMode then local okM, isRel = pcall(love.mouse.getRelativeMode) if okM and isRel ~= wantCapture then pcall(love.mouse.setRelativeMode, wantCapture) end captured = wantCapture end local driving = FirstPerson.driving() -- The mouse's counts, accumulated by the wrapped handler since the last -- tick; dropped unread while something else owns the screen. -- -- The yaw sign is NEGATED, here and in every look input below: yaw grows -- south -> east -> north (the world runs +X east, +Z south, and the -- direction is (sin yaw, cos yaw)), which seen from behind the eye is a -- LEFT turn -- so "move the mouse right, look right" means subtracting. local dx, dy = mouseDX, mouseDY mouseDX, mouseDY = 0, 0 if driving and (dx ~= 0 or dy ~= 0) then FirstPerson.lookBy(-dx * FirstPerson.MOUSE_SENS, dy * FirstPerson.MOUSE_SENS) end -- the right stick is a rate: radians per second, squared response so -- the first half of the throw aims and the rest turns if driving then local rx, ry = stick.x, stick.y local function curve(v) local a = math.abs(v) if a < FirstPerson.STICK_DEAD then return 0 end a = (a - FirstPerson.STICK_DEAD) / (1 - FirstPerson.STICK_DEAD) return (v < 0 and -1 or 1) * a * a end local cy, cp = curve(rx), curve(ry) if cy ~= 0 or cp ~= 0 then -- negated yaw for the same reason as the mouse above FirstPerson.lookBy(-cy * FirstPerson.STICK_YAW * dt, cp * FirstPerson.STICK_PITCH * dt) end end end -- ------- the rig itself -- The orbit camera's eye/focus/fov/up for the frame's centre -- the same -- arithmetic Voxel3D.viewProjection runs, restated here because the blend -- needs both ends as DATA. Kept textually tiny so the two cannot drift: -- focus on the centre, eye FOCAL*vh away at the pitch, up perpendicular -- in the YZ plane. local function orbitRig(cx, cy, vh) local a = Voxel.angle local dist = Voxel.FOCAL * vh return { cx, dist * math.cos(a), cy + dist * math.sin(a) }, { cx, 0, cy }, 2 * math.atan(1 / (2 * Voxel.FOCAL)), { 0, math.sin(a), -math.cos(a) } end local lastEye = nil -- frozen head pose for player-less frames -- Build this frame's placed camera and hand it to Voxel3D, plus the scene -- centre the curve and the depth reference should use. `me` is the -- player's posed entry (px, py, gh, lift) or nil (a Fly animation), and -- (cx, cy) the orbit's own view centre. -- -- Returns nil with the blend fully out, which is the caller's signal to -- leave the orbit in charge. function FirstPerson.frame(me, cx, cy, vw, vh) local b = FirstPerson.blend if b <= 0 then if rig and Voxel3D.camera == rig then Voxel3D.camera = nil end rig = nil return nil end local e = ease(b) local head if me then head = { me.px + 8, (me.gh or 0) + (me.lift or 0) + FirstPerson.EYE_HEIGHT, me.py + 8 } lastEye = head else head = lastEye or { cx, FirstPerson.EYE_HEIGHT, cy } end local lx, ly, lz = lookDir() local fpFocus = { head[1] + lx * FirstPerson.FOCUS_DIST, head[2] + ly * FirstPerson.FOCUS_DIST, head[3] + lz * FirstPerson.FOCUS_DIST } local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh) local function mix(p, q) return { p[1] + (q[1] - p[1]) * e, p[2] + (q[2] - p[2]) * e, p[3] + (q[3] - p[3]) * e } end local up = mix(oUp, { 0, 1, 0 }) local ul = math.sqrt(up[1] * up[1] + up[2] * up[2] + up[3] * up[3]) if ul > 1e-6 then up[1], up[2], up[3] = up[1] / ul, up[2] / ul, up[3] / ul else up = { 0, 1, 0 } end -- the world curve eases out with the blend: standing inside the world, -- the bend that sells the diorama reads as the ground falling away. A -- true zero (curve declined) needs the field present -- nil would let -- Voxel3D fall back to the setting local k = WorldCurve.k(vh) * (1 - e) rig = { eye = mix(oEye, head), focus = mix(oFocus, fpFocus), fov = oFov + (FirstPerson.FOV - oFov) * e, up = up, curve = k, } Voxel3D.camera = rig -- the scene centre walks from the orbit's view centre to the head, so -- the curve's focus, the depth reference and the glint's travel follow -- the camera that is actually in charge local sx = cx + (head[1] - cx) * e local sy = cy + (head[3] - cy) * e return rig, sx, sy end -- Where the shadow pass should centre its box: pushed along the flat look -- so the fitted frustum -- built for an orbit that always looks north -- -- covers the ground THIS camera sees. The push is strongest looking -- south (the direction the orbit's box barely reaches) and scales with -- the blend. function FirstPerson.shadowCenter(sx, sy, vh) local e = FirstPerson.cardBlend() if e <= 0 then return sx, sy end local fx, fz = FirstPerson.lookFlat() local ShadowMap = V.require("ShadowMap") local cap = (ShadowMap.FAR_CAP or 2.5) * vh return sx + fx * 0.6 * vh * e, sy + fz * (fz > 0 and (cap - vh * 0.5) or vh * 0.4) * e end -- The first-person facts a shadow signature has to include: the sun's -- box is fitted around this camera, so turning the head or walking the -- blend has to re-fit it even standing still. function FirstPerson.signature() local b = FirstPerson.blend if b <= 0 then return "" end return table.concat({ math.floor(b * 64), math.floor(FirstPerson.yaw * 64), math.floor(FirstPerson.pitch * 64), }, ",") end -- ------- input capture -- -- The seams: relative mouse motion has no Game handler at all (the -- engine's love.mousemoved only feeds the mouse-as-touch debug path), the -- right stick's axes are explicitly ignored by Input, and a touch -- anywhere off the overlay's controls dies in TouchControls. Each wrap -- forwards everything it does not claim, and claims only while first -- person is actually driving -- so with the rung off, every byte flows -- exactly where it always did. local installed = false function FirstPerson.install() if installed then return end installed = true local Game = require("src.core.Game") -- ------- right stick do local inner = Game.gamepadaxis function Game:gamepadaxis(joystick, axis, value) if axis == "rightx" then stick.x = value elseif axis == "righty" then stick.y = value end return inner(self, joystick, axis, value) end end -- generic (non-gamepad) sticks: axes 1/2 are the left stick by SDL -- convention and Input already claims them; 3/4 are the usual right -- pair on the same class of device. Real gamepads are excluded -- they -- already spoke through the mapped rightx/righty above, and their RAW -- axis 3 is as likely a trigger as a stick. -- -- Two more exclusions, both learned the hard way on Android, where this -- wrap runs BEFORE the engine's own generic-joystick guards: -- -- the accelerometer arrives as a joystick named for what it is, with -- gravity pinning an axis well past any deadzone -- the same device -- Game:joystickaxis refuses for movement (#459), refused here by the -- same name test, or the view spins on its own the moment 1ST opens. -- -- and a raw axis is only BELIEVED after it has been seen near centre -- once. A stick at rest sits at zero, so a real one earns trust with -- its first touch; a gravity-pinned sensor axis or a trigger resting -- at an extreme never centres and so never steers the look. local function isAccelerometer(joystick) local ok, name = pcall(function() return joystick:getName() end) return ok and type(name) == "string" and name:lower():find("accelerometer", 1, true) ~= nil end local rawCentred = {} do local inner = Game.joystickaxis function Game:joystickaxis(joystick, axis, value) local mapped = joystick and joystick.isGamepad and joystick:isGamepad() if not mapped and (axis == 3 or axis == 4) and not isAccelerometer(joystick) then if math.abs(value) < 0.3 then rawCentred[axis] = true end if rawCentred[axis] then if axis == 3 then stick.x = value else stick.y = value end end end return inner(self, joystick, axis, value) end end -- ------- mouse -- -- love.mousemoved rather than a Game method, because the engine has no -- Game:mousemoved to wrap -- the callback in the project's main.lua is -- the one place relative counts arrive. Claimed only while captured; -- pass-through otherwise, including the mouse-as-touch path. do local inner = love.mousemoved love.mousemoved = function(x, y, dx, dy, istouch) if captured and not istouch then mouseDX = mouseDX + (dx or 0) mouseDY = mouseDY + (dy or 0) return end if inner then return inner(x, y, dx, dy, istouch) end end end -- While the mouse is captured there is no cursor to click UI with, so -- the buttons become GB buttons: left is A, right is B -- through the -- overlay's own press path, which a rebind can never detach. What WE -- pressed is remembered per button, so the release always reaches the -- overlay even if the capture ended while the button was down -- -- otherwise a click that outlives the rung strands A held forever. local mouseHeld = {} local MOUSE_BTN = { [1] = "a", [2] = "b" } do local inner = love.mousepressed love.mousepressed = function(x, y, button, istouch, presses) if captured and not istouch and MOUSE_BTN[button] then local Input = require("src.core.Input") mouseHeld[button] = true Input:overlayPressed(MOUSE_BTN[button]) return end if inner then return inner(x, y, button, istouch, presses) end end end do local inner = love.mousereleased love.mousereleased = function(x, y, button, istouch, presses) if mouseHeld[button] then local Input = require("src.core.Input") mouseHeld[button] = nil Input:overlayReleased(MOUSE_BTN[button]) return end if inner then return inner(x, y, button, istouch, presses) end end end -- ------- touch -- -- A finger on open screen -- not on the overlay's d-pad or buttons -- -- becomes the look drag. One finger owns the look at a time; every -- other touch flows to TouchControls untouched, so a thumb can drag the -- view while the other walks the d-pad. That d-pad finger is also read -- back ANALOG here: TouchControls quantises it to four directions for -- the grid game, but the deflection it quantised is exactly the move -- vector a free walk wants. local TouchControls = require("src.core.TouchControls") local function dpadVector(x, y) local ok, v = pcall(function() local L = TouchControls:layout() local dz = L.dpad local half = dz.w * 0.65 return { x = math.max(-1, math.min(1, (x - dz.cx) / half)), y = math.max(-1, math.min(1, (y - dz.cy) / half)) } end) return ok and v or nil end do local inner = Game.touchpressed function Game:touchpressed(id, x, y) if FirstPerson.driving() then local onControl = nil pcall(function() onControl = TouchControls:hitTest(x, y) end) if not onControl and not lookTouch then lookTouch = { id = id, x = x, y = y } return end inner(self, id, x, y) if onControl == "dpad" and TouchControls.dpadTouch == id then touchMove = dpadVector(x, y) end return end return inner(self, id, x, y) end end do local inner = Game.touchmoved function Game:touchmoved(id, x, y) if lookTouch and lookTouch.id == id then local w = 1280 pcall(function() w = love.graphics.getWidth() end) local per = FirstPerson.TOUCH_TURN / math.max(320, w) if FirstPerson.driving() then -- negated yaw for the same reason as the mouse (see update): -- drag right, look right, the mobile-shooter convention FirstPerson.lookBy(-(x - lookTouch.x) * per, (y - lookTouch.y) * per) end lookTouch.x, lookTouch.y = x, y return end if touchMove and TouchControls.dpadTouch == id then touchMove = dpadVector(x, y) or touchMove end return inner(self, id, x, y) end end do local inner = Game.touchreleased function Game:touchreleased(id, x, y) if lookTouch and lookTouch.id == id then lookTouch = nil return end if TouchControls.dpadTouch == id then touchMove = nil end return inner(self, id, x, y) end end -- a reset that drops held input state drops ours with it do local inner = Game.focus function Game:focus(f) lookTouch, touchMove = nil, nil stick.x, stick.y = 0, 0 mouseDX, mouseDY = 0, 0 return inner(self, f) end end -- a disconnected controller cannot send the centering event for whatever -- its stick last held -- the engine drops all input state here, and the -- look rate (plus the raw axes' earned trust) goes with it do local inner = Game.joystickremoved function Game:joystickremoved(joystick) stick.x, stick.y = 0, 0 rawCentred[3], rawCentred[4] = nil, nil return inner(self, joystick) end end end return FirstPerson