Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 152 additions & 6 deletions lib/FirstPerson.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -706,15 +843,19 @@ 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)
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)
FirstPerson.steer(-(x - lookTouch.x) * per,
(y - lookTouch.y) * per)
end
lookTouch.x, lookTouch.y = x, y
return
Expand All @@ -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
Expand Down
10 changes: 8 additions & 2 deletions lib/ModSetting.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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." },
Expand Down