From 22f4f41f872f828337895dec8429f7961cab2a53 Mon Sep 17 00:00:00 2001 From: hernan Date: Mon, 3 Aug 2026 09:06:18 -0400 Subject: [PATCH] Add an on-screen look stick for first person First person on a touch device has the d-pad for walking and a drag-anywhere gesture for looking, and no visible affordance for the second one -- a player who does not already know to drag has no way to turn around. Adds a thumbstick above A/B, drawn only while 1ST is driving. It writes into the same stick table the physical right stick writes to, so it inherits the rate-based response already implemented for a gamepad: a held deflection keeps turning, with the squared curve, rather than running out of travel at the edge of the glass the way a drag does. It centres on release. Drag-anywhere is untouched underneath: the stick is an affordance for the thumb that is already over there, not a replacement for a flick with whatever finger is free. Three rows come with it, gated to the 1ST rung so they are invisible elsewhere: LOOK STICK hides it for controller players (and stops it claiming touches, or it would be an invisible control), LOOK SPEED, and INVERT Y. The preferences apply in a steer() layer wrapping the three device inputs rather than inside lookBy, so the mouse, the stick and a drag stay in step while lookBy remains the exact primitive the rig and the tests drive -- a 2*pi turn has to stay one turn whatever the sensitivity. ModSetting gains an optional default index, because LOOK SPEED reads SLOW/NORMAL/FAST in that order but should start at NORMAL, and rung 1 was the only default available. Co-Authored-By: Claude Opus 5 --- lib/FirstPerson.lua | 158 ++++++++++++++++++++++++++++++++++++++++++-- lib/ModSetting.lua | 10 ++- main.lua | 14 ++++ 3 files changed, 174 insertions(+), 8 deletions(-) diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index 945fa50..23b0d4d 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -47,6 +47,7 @@ local Mat4 = V.require("Mat4") local Voxel = V.require("VoxelState") local Voxel3D = V.require("Voxel3D") local WorldCurve = V.require("WorldCurve") +local ModSetting = V.require("ModSetting") local FirstPerson = {} @@ -93,6 +94,34 @@ FirstPerson.STICK_DEAD = 0.18 FirstPerson.TOUCH_TURN = 2.2 * math.pi FirstPerson.MOVE_DEAD = 0.25 +-- ------- the player's own look preferences +-- +-- SPEED multiplies every look input rather than any one of them, so the +-- stick, the mouse and a drag all move together -- a player who finds the +-- view too twitchy means the view, not the device they happened to use. +-- +-- INVERT Y is not a nicety: a substantial minority of people fly a camera +-- pull-back-to-look-up and cannot use one that does the opposite. It +-- negates pitch only, which is the axis that convention actually splits on. +-- +-- STICK hides the on-screen stick for anyone playing with a controller, or +-- who prefers the drag-anywhere look and would rather have the glass back. +-- Hiding it also stops it claiming touches, or it would be an invisible +-- control swallowing thumbs. +FirstPerson.stickSetting = ModSetting.new("lookstick", "LOOK STICK", + { true, false }, { "ON", "OFF" }) +FirstPerson.speedSetting = ModSetting.new("lookspeed", "LOOK SPEED", + { 0.6, 1.0, 1.6 }, + { "SLOW", "NORMAL", "FAST" }, 2) +FirstPerson.invertSetting = ModSetting.new("lookinvert", "INVERT Y", + { false, true }, { "OFF", "ON" }) + +function FirstPerson.stickShown() + local ok, v = pcall(function() return FirstPerson.stickSetting:get() end) + if not ok or v == nil then return true end + return v and true or false +end + -- ------- state -- -- Yaw is a world bearing: 0 faces south (+Z, the way a resting sprite @@ -113,6 +142,7 @@ FirstPerson.fovScale = 1 local wasEngaged = false local stick = { x = 0, y = 0 } -- right stick, latest event values +local stickTouch = nil -- the finger holding the on-screen look stick 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 @@ -202,6 +232,23 @@ function FirstPerson.lookBy(dyaw, dpitch) FirstPerson.pitch + dpitch)) end +-- Every look input goes through here rather than calling lookBy directly, +-- so the mouse, the stick and a drag cannot drift apart -- and a fourth +-- input added later inherits both preferences without knowing they exist. +-- +-- Deliberately NOT inside lookBy: that is the primitive the rig and the +-- tests drive, and a turn of exactly 2*pi has to come out as exactly one +-- turn whatever the player has the sensitivity set to. +function FirstPerson.steer(dyaw, dpitch) + local okS, mul = pcall(function() return FirstPerson.speedSetting:get() end) + if okS and type(mul) == "number" then + dyaw, dpitch = dyaw * mul, dpitch * mul + end + local okI, inv = pcall(function() return FirstPerson.invertSetting:get() end) + if okI and inv then dpitch = -dpitch end + return FirstPerson.lookBy(dyaw, 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. @@ -382,8 +429,8 @@ function FirstPerson.update(dt) 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) + FirstPerson.steer(-dx * FirstPerson.MOUSE_SENS, + dy * FirstPerson.MOUSE_SENS) end -- the right stick is a rate: radians per second, squared response so @@ -399,8 +446,8 @@ function FirstPerson.update(dt) 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) + FirstPerson.steer(-cy * FirstPerson.STICK_YAW * dt, + cp * FirstPerson.STICK_PITCH * dt) end end end @@ -526,6 +573,51 @@ end -- person is actually driving -- so with the rung off, every byte flows -- exactly where it always did. +-- ------- drawing the look stick +-- +-- Called from the flat overlay pass, which draws into the world canvas in +-- SUPERSAMPLED canvas pixels -- hence the scale, the same one HordeHud is +-- handed. The engine's own pad is composited over the top of this canvas +-- afterwards, which is exactly why the stick sits ABOVE A/B rather than +-- under them: anything overlapping a button would be drawn beneath it. +-- +-- Nothing here is load-bearing. A failure to work out where the pad is +-- (headless, no overlay) simply draws nothing, and drag-anywhere still +-- steers the view. +function FirstPerson.drawLookStick(rw, rh) + if not FirstPerson.driving() then return end + if not FirstPerson.stickShown() then return end + local z = FirstPerson.lookStick and FirstPerson.lookStick() + if not z then return end + local TouchControls = require("src.core.TouchControls") + if not TouchControls:visible() then return end + -- The pad's layout is in LOVE UNITS; this canvas is in framebuffer pixels + -- (times the AA factor). The ratio between the canvas we were handed and + -- the window's unit width is the exact conversion -- and it stays exact + -- whatever the density or the supersampling, which is why it is measured + -- rather than assumed. Passing ctx.scale here (the world-pixel scale the + -- FX closures use) put the stick hundreds of pixels off-screen. + local ww = love.graphics.getWidth() + if not (ww and ww > 0 and rw and rw > 0) then return end + local s = rw / ww + local cx, cy, r = z.cx * s, z.cy * s, z.r * s + local held = stickTouch ~= nil + love.graphics.push("all") + -- the well: dark ring, faint fill, matching the pad's own restraint so it + -- reads as part of the overlay rather than as HUD + love.graphics.setColor(0, 0, 0, held and 0.30 or 0.20) + love.graphics.circle("fill", cx, cy, r) + love.graphics.setColor(1, 1, 1, held and 0.45 or 0.28) + love.graphics.setLineWidth(math.max(1, 2 * s)) + love.graphics.circle("line", cx, cy, r) + -- the knob, sitting where the thumb has pushed it + local kx = cx + (stick.x or 0) * r * 0.62 + local ky = cy + (stick.y or 0) * r * 0.62 + love.graphics.setColor(1, 1, 1, held and 0.55 or 0.34) + love.graphics.circle("fill", kx, ky, r * 0.38) + love.graphics.pop() +end + local installed = false function FirstPerson.install() @@ -676,10 +768,55 @@ function FirstPerson.install() return ok and v or nil end + -- ------- the on-screen look stick + -- + -- A thumbstick above A/B, drawn only while 1ST PERSON is driving. It + -- writes into the SAME `stick` table the physical right stick writes to, + -- so it inherits the rate-based response already implemented for a + -- gamepad: a held deflection keeps turning, and the squared curve means + -- small pushes aim while full ones spin. A drag would only turn by how far + -- the finger travelled, which runs out at the edge of the glass. + -- + -- Drag-anywhere is deliberately left in place underneath. The stick is an + -- affordance for the thumb that is already over there, not a replacement + -- for a flick with whatever finger is free. + local function lookStick() + local ok, z = pcall(function() + local L = TouchControls:layout() + local a, b = L.a, L.b + if not (a and b) then return nil end + -- centred on the A/B cluster horizontally, sitting above the higher + -- of the two so it never overlaps a button the engine draws on top + local r = a.w * 1.15 + return { cx = (a.cx + b.cx) / 2, + cy = math.min(a.cy, b.cy) - a.w * 0.75 - r, + r = r } + end) + return ok and z or nil + end + FirstPerson.lookStick = lookStick + + -- deflection of a point within the stick, clamped to the rim, or nil + local function stickDeflect(z, x, y) + local dx, dy = (x - z.cx) / z.r, (y - z.cy) / z.r + local m = math.sqrt(dx * dx + dy * dy) + if m > 1 then dx, dy = dx / m, dy / m end + return dx, dy + end + do local inner = Game.touchpressed function Game:touchpressed(id, x, y) if FirstPerson.driving() then + local z = FirstPerson.stickShown() and lookStick() or nil + if z and not stickTouch then + local dx, dy = (x - z.cx) / z.r, (y - z.cy) / z.r + if dx * dx + dy * dy <= 1 then + stickTouch = { id = id, z = z } + stick.x, stick.y = stickDeflect(z, x, y) + return + end + end local onControl = nil pcall(function() onControl = TouchControls:hitTest(x, y) end) if not onControl and not lookTouch then @@ -706,6 +843,10 @@ function FirstPerson.install() do local inner = Game.touchmoved function Game:touchmoved(id, x, y) + if stickTouch and stickTouch.id == id then + stick.x, stick.y = stickDeflect(stickTouch.z, x, y) + return + end if lookTouch and lookTouch.id == id then local w = 1280 pcall(function() w = love.graphics.getWidth() end) @@ -713,8 +854,8 @@ function FirstPerson.install() 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) + FirstPerson.steer(-(x - lookTouch.x) * per, + (y - lookTouch.y) * per) end lookTouch.x, lookTouch.y = x, y return @@ -728,6 +869,11 @@ function FirstPerson.install() do local inner = Game.touchreleased function Game:touchreleased(id, x, y) + if stickTouch and stickTouch.id == id then + stickTouch = nil + stick.x, stick.y = 0, 0 + return + end if lookTouch and lookTouch.id == id then lookTouch = nil return diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua index 2bed605..1a79742 100644 --- a/lib/ModSetting.lua +++ b/lib/ModSetting.lua @@ -33,9 +33,10 @@ end -- `values` are the stored values in ladder order and `labels` what the row -- shows for each; values[1] is the default, and the one an unreadable or -- unrecognised stored value falls back to. -function ModSetting.new(key, label, values, labels) +function ModSetting.new(key, label, values, labels, default) return setmetatable({ key = key, label = label, values = values, labels = labels, + default = default, index = nil, -- nil = not yet read back from the persisted options }, ModSetting) end @@ -44,7 +45,12 @@ local function indexOf(self, value) for i, v in ipairs(self.values) do if v == value then return i end end - return 1 + -- Nothing persisted yet. Rung 1 is the right default for a ladder that + -- starts at OFF, and the wrong one for a ladder whose neutral sits in the + -- middle -- LOOK SPEED reads SLOW/NORMAL/FAST in that order because that + -- is the order a player expects to step through, not because slow is the + -- default. `default` lets those two facts differ. + return self.default or 1 end -- What the player left it at last session. Read lazily rather than at load diff --git a/main.lua b/main.lua index d4abea1..d2de689 100644 --- a/main.lua +++ b/main.lua @@ -275,6 +275,7 @@ mod.content.render_pipelines:register("voxel", { -- headset never reaches this line (drawWorld returns the mirror -- above) -- lib/VR draws the same HUD onto each eye instead. HordeHud.drawFlat(rw, rh, ctx.scale * AntiAlias.factor()) + FirstPerson.drawLookStick(rw, rh) Voxel3D.endOverlay() end -- and back to the window's own size, which is what the engine composites @@ -404,6 +405,19 @@ local function stagedBattles() end local SETTINGS = { + -- First-person look preferences. `when` keeps them off the menu on every + -- other rung: three rows about a stick that is not on screen are noise. + { FirstPerson.stickSetting, + "Show the on-screen look stick above A/B. Off leaves the drag-anywhere " + .. "look, which is what a controller or a spare thumb uses anyway.", + when = function() return FirstPerson.engaged() end }, + { FirstPerson.speedSetting, + "How fast the view turns. Applies to the stick, a controller and a " + .. "drag alike.", + when = function() return FirstPerson.engaged() end }, + { FirstPerson.invertSetting, + "Push down to look up, the flight-stick convention.", + when = function() return FirstPerson.engaged() end }, { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style." },