mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 08:11:11 +02:00
cam control rotation in battle
This commit is contained in:
+171
-22
@@ -151,6 +151,24 @@ BattleCam.ORBIT_STICK = 0.9 -- fraction of the range per second, full tilt
|
||||
BattleCam.ORBIT_MOUSE = 0.0011 -- fraction of the range per mouse count
|
||||
BattleCam.STICK_DEAD = 0.2
|
||||
|
||||
-- ------- and the height it is watched from
|
||||
--
|
||||
-- The same steering on the other axis, with the same shape of stop at each
|
||||
-- end: 0 is the rig's own stance -- the low, near-floor seat the whole
|
||||
-- composition is solved around, and the DOWN stop, because below it the
|
||||
-- camera starts looking up the arena's nose -- and 1 is 45 degrees above
|
||||
-- it, which is high enough to read the ground the fight is standing on
|
||||
-- without becoming the diorama's own top-down.
|
||||
--
|
||||
-- Raised about the FOCUS rather than about the eye, so the aim stays on
|
||||
-- the two mons and only the seat climbs; and at a constant radius, so
|
||||
-- climbing never changes how big anything is -- that is the zoom's job.
|
||||
BattleCam.PITCH_RANGE = math.rad(45)
|
||||
BattleCam.PITCH_TIME = 0.22
|
||||
BattleCam.PITCH_DRAG = 1.6 -- fraction of the range per screen HEIGHT
|
||||
BattleCam.PITCH_STICK = 0.9
|
||||
BattleCam.PITCH_MOUSE = 0.0016
|
||||
|
||||
-- ------- and the player's own zoom
|
||||
--
|
||||
-- How much world the frame holds, as a multiple of the rig's own frameH:
|
||||
@@ -165,9 +183,24 @@ BattleCam.ZOOM_TIME = 0.18
|
||||
|
||||
BattleCam.orbit = 0
|
||||
BattleCam.orbitGoal = 0
|
||||
BattleCam.pitch = 0
|
||||
BattleCam.pitchGoal = 0
|
||||
BattleCam.zoom = 1
|
||||
BattleCam.zoomGoal = 1
|
||||
|
||||
-- Whether the player may steer at all. BACK SPRITES clears it: that
|
||||
-- setting pins the player's own mon to the GB's own slot on the menu
|
||||
-- (OverworldBattle.backPinned) instead of standing it out on the map, so
|
||||
-- half the picture is nailed to the frame and half of it is geometry. Swing
|
||||
-- the camera under that and the two halves come apart -- the foe walks
|
||||
-- around an arena its opponent is not standing in, and the move animations
|
||||
-- that reach between them stretch across the gap. There is no angle that
|
||||
-- composition survives, so the answer is not to allow one.
|
||||
--
|
||||
-- Only the STEER is withheld: the slow drift stays, because it was always
|
||||
-- there under BACK SPRITES and two degrees is not a composition problem.
|
||||
BattleCam.steerable = true
|
||||
|
||||
-- Hold the rig perfectly still (VR sets this while a session runs). The
|
||||
-- drift exists to give a FLAT screen the depth cue the picture cannot
|
||||
-- have; a headset gets real parallax from the player's own head, and a
|
||||
@@ -178,13 +211,21 @@ BattleCam.still = false
|
||||
|
||||
BattleCam.t = 0
|
||||
|
||||
-- Every fight opens on the shot the rig was solved for: the orbit and the
|
||||
-- zoom are a way of LOOKING at this battle, not a preference carried into
|
||||
-- the next one, and a player who left the camera side-on an hour ago should
|
||||
-- not have the next encounter open there.
|
||||
-- Only the DRIFT's phase, so every fight opens on the same breath. Where
|
||||
-- the player last put the camera is deliberately NOT reset: an angle and a
|
||||
-- lens they chose are how they want to watch battles, not a thing about
|
||||
-- this battle, and having to re-find them every encounter would make them
|
||||
-- not worth setting. They are session state -- a fresh run opens on the
|
||||
-- rig's own shot, which is the one the composition is solved for.
|
||||
function BattleCam.reset()
|
||||
BattleCam.t = 0
|
||||
end
|
||||
|
||||
-- Back to the solved shot, for anything that wants the composition as
|
||||
-- authored rather than as steered.
|
||||
function BattleCam.recentre()
|
||||
BattleCam.orbit, BattleCam.orbitGoal = 0, 0
|
||||
BattleCam.pitch, BattleCam.pitchGoal = 0, 0
|
||||
BattleCam.zoom, BattleCam.zoomGoal = 1, 1
|
||||
end
|
||||
|
||||
@@ -203,36 +244,64 @@ end
|
||||
-- the side-on stop. Returning whether the goal actually moved lets a
|
||||
-- caller tell "steered" from "already against the stop".
|
||||
|
||||
local function setOrbit(goal)
|
||||
local was = BattleCam.orbitGoal
|
||||
BattleCam.orbitGoal = math.max(0, math.min(1, goal))
|
||||
return BattleCam.orbitGoal ~= was
|
||||
-- Both axes go through here, so the "nothing while BACK SPRITES holds the
|
||||
-- composition" rule and the two stops live in one place each.
|
||||
local function setAxis(key, goal)
|
||||
if not BattleCam.steerable then return false end
|
||||
local was = BattleCam[key]
|
||||
BattleCam[key] = math.max(0, math.min(1, goal))
|
||||
return BattleCam[key] ~= was
|
||||
end
|
||||
|
||||
-- A drag, in fractions of the screen's width.
|
||||
-- A drag, in fractions of the screen's width (orbit) or height (pitch).
|
||||
function BattleCam.dragOrbit(fraction)
|
||||
return setOrbit(BattleCam.orbitGoal + (fraction or 0) * BattleCam.ORBIT_DRAG)
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + (fraction or 0) * BattleCam.ORBIT_DRAG)
|
||||
end
|
||||
|
||||
function BattleCam.dragPitch(fraction)
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + (fraction or 0) * BattleCam.PITCH_DRAG)
|
||||
end
|
||||
|
||||
-- Relative mouse motion, in counts.
|
||||
function BattleCam.mouseOrbit(dx)
|
||||
return setOrbit(BattleCam.orbitGoal + (dx or 0) * BattleCam.ORBIT_MOUSE)
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + (dx or 0) * BattleCam.ORBIT_MOUSE)
|
||||
end
|
||||
|
||||
function BattleCam.mousePitch(dy)
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + (dy or 0) * BattleCam.PITCH_MOUSE)
|
||||
end
|
||||
|
||||
-- A stick held for `dt` seconds, as a rate with a squared response -- the
|
||||
-- first half of the throw aims and the rest travels, the same curve the
|
||||
-- free-roam look uses.
|
||||
function BattleCam.stickOrbit(x, dt)
|
||||
local a = math.abs(x or 0)
|
||||
if a < BattleCam.STICK_DEAD then return false end
|
||||
local function curve(v)
|
||||
local a = math.abs(v or 0)
|
||||
if a < BattleCam.STICK_DEAD then return 0 end
|
||||
a = (a - BattleCam.STICK_DEAD) / (1 - BattleCam.STICK_DEAD)
|
||||
local v = ((x < 0) and -1 or 1) * a * a
|
||||
return setOrbit(BattleCam.orbitGoal
|
||||
+ v * BattleCam.ORBIT_STICK * (dt or 0))
|
||||
return ((v < 0) and -1 or 1) * a * a
|
||||
end
|
||||
|
||||
function BattleCam.stickOrbit(x, dt)
|
||||
local v = curve(x)
|
||||
if v == 0 then return false end
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + v * BattleCam.ORBIT_STICK * (dt or 0))
|
||||
end
|
||||
|
||||
function BattleCam.stickPitch(y, dt)
|
||||
local v = curve(y)
|
||||
if v == 0 then return false end
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + v * BattleCam.PITCH_STICK * (dt or 0))
|
||||
end
|
||||
|
||||
-- The zoom, in notches (positive pulls OUT, like every other zoom here).
|
||||
function BattleCam.stepZoom(notches)
|
||||
if not BattleCam.steerable then return false end
|
||||
local was = BattleCam.zoomGoal
|
||||
BattleCam.zoomGoal = math.max(BattleCam.ZOOM_MIN,
|
||||
math.min(BattleCam.ZOOM_MAX,
|
||||
@@ -240,11 +309,64 @@ function BattleCam.stepZoom(notches)
|
||||
return BattleCam.zoomGoal ~= was
|
||||
end
|
||||
|
||||
-- How far apart the two mons READ from the current orbit, as a multiple of
|
||||
-- how far apart they read from the solved shot.
|
||||
--
|
||||
-- The arena's axis runs from one mon to the other, and the solved shot
|
||||
-- looks along it at a shallow 28 degrees, which foreshortens that gap to
|
||||
-- less than half its length. Swing round to square-on and the
|
||||
-- foreshortening is gone: the same two cells now read at their full
|
||||
-- separation, better than twice as wide. Left alone, that threw the pair
|
||||
-- out to the edges of the frame -- half of each mon off-screen at the
|
||||
-- side-on stop, which made the whole far end of the range unusable.
|
||||
--
|
||||
-- Climbing does the same thing on the other axis -- a raised camera looks
|
||||
-- less along the ground and more across it, which un-foreshortens the gap
|
||||
-- again -- so the correction has to answer to both.
|
||||
--
|
||||
-- What it measures is how much of the arena's axis survives projection:
|
||||
-- the axis runs due north-south, the view line points back at the arena at
|
||||
-- plan bearing `beta` and elevation `elev`, and the part of a unit axis
|
||||
-- that lands across the frame rather than along the view is the sine of
|
||||
-- the angle between them. The ratio of that to the solved shot's own is
|
||||
-- the factor the lens opens by -- 1 at the solved shot by construction,
|
||||
-- about 1.9 at side-on, about 1.7 fully raised.
|
||||
--
|
||||
-- Analytic rather than measured off the built rig, so nothing has to
|
||||
-- reason about a camera to ask the question, and so the sun's box (which
|
||||
-- asks through frameH) gets the identical number the lens does.
|
||||
--
|
||||
-- Measured off the STEER alone, deliberately: the drift's own two degrees
|
||||
-- moved this before and must keep moving it by exactly as much, or every
|
||||
-- battle shot that has ever been taken shifts.
|
||||
local function axisSpan(beta, elev)
|
||||
local c = math.cos(elev)
|
||||
local s = math.sin(beta) * c
|
||||
local v = math.sin(elev)
|
||||
return math.sqrt(s * s + v * v)
|
||||
end
|
||||
|
||||
function BattleCam.spread(arena)
|
||||
local R = BattleCam.rigFor(arena)
|
||||
local beta = math.atan2(R.side, R.back)
|
||||
local elev = math.atan2(R.height - R.lookY,
|
||||
math.sqrt((R.side - R.lookX) ^ 2 + R.back ^ 2))
|
||||
local home = axisSpan(beta, elev)
|
||||
if home < 1e-6 then return 1 end
|
||||
return axisSpan(beta + BattleCam.orbit * BattleCam.orbitRange(arena),
|
||||
elev + BattleCam.pitch * BattleCam.PITCH_RANGE) / home
|
||||
end
|
||||
|
||||
-- How much world the frame holds right now: the rig's own reach at the
|
||||
-- player's zoom. The sun's box is fitted to this too, so a zoomed shot
|
||||
-- lights exactly the ground it shows.
|
||||
-- player's zoom and at whatever the orbit has done to the pair's spacing,
|
||||
-- or the rig's own alone whenever both are being withheld (VR's fixed
|
||||
-- seat, BACK SPRITES' pinned composition). The sun's box is fitted to this
|
||||
-- too, so a zoomed shot lights exactly the ground it shows -- which is why
|
||||
-- BattleScene asks this rather than multiplying for itself.
|
||||
function BattleCam.frameH(arena)
|
||||
return BattleCam.rigFor(arena).frameH * BattleCam.zoom
|
||||
local base = BattleCam.rigFor(arena).frameH
|
||||
if BattleCam.still or not BattleCam.steerable then return base end
|
||||
return base * BattleCam.zoom * BattleCam.spread(arena)
|
||||
end
|
||||
|
||||
local function chase(now, goal, dt, time)
|
||||
@@ -261,10 +383,12 @@ function BattleCam.update(dt)
|
||||
-- float precision in the sines below
|
||||
local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD
|
||||
if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end
|
||||
-- and the steered pair easing after whatever the player last asked for,
|
||||
-- and the steered three easing after whatever the player last asked for,
|
||||
-- which is what keeps a flick of the stick from being a cut
|
||||
BattleCam.orbit = chase(BattleCam.orbit, BattleCam.orbitGoal, dt,
|
||||
BattleCam.ORBIT_TIME)
|
||||
BattleCam.pitch = chase(BattleCam.pitch, BattleCam.pitchGoal, dt,
|
||||
BattleCam.PITCH_TIME)
|
||||
BattleCam.zoom = chase(BattleCam.zoom, BattleCam.zoomGoal, dt,
|
||||
BattleCam.ZOOM_TIME)
|
||||
end
|
||||
@@ -297,13 +421,16 @@ function BattleCam.rig(arena, groundY, canonical)
|
||||
local mx, mz = arena.mid[1], arena.mid[2]
|
||||
-- VR asks for the same stillness for its own reason (see BattleCam.still)
|
||||
local fixed = BattleCam.still or canonical
|
||||
-- and the steer is withheld a second way, on its own: BACK SPRITES holds
|
||||
-- the composition and the DRIFT still runs under it (see steerable)
|
||||
local steered = (not fixed) and BattleCam.steerable
|
||||
|
||||
-- The drift, plus wherever the player has steered to. The steer is
|
||||
-- NEGATIVE because the rotation below runs the other way from the bearing
|
||||
-- it turns: rotating (side, back) by +yaw carries the eye back toward the
|
||||
-- arena's own axis, and the room the player has is all on the far side of
|
||||
-- that -- out toward square-on. (orbitRange measures exactly that room.)
|
||||
local steer = fixed and 0 or -BattleCam.orbit * BattleCam.orbitRange(arena)
|
||||
local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
|
||||
local yaw = steer + (fixed and 0
|
||||
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD))
|
||||
local c, s = math.cos(yaw), math.sin(yaw)
|
||||
@@ -318,6 +445,28 @@ function BattleCam.rig(arena, groundY, canonical)
|
||||
local eye = { mx + dx, groundY + R.height * k, mz + dz }
|
||||
local focus = { mx + R.lookX, groundY + R.lookY, mz }
|
||||
|
||||
-- and the climb: the eye swung UP about the focus, at a constant radius.
|
||||
-- About the focus so the aim stays nailed to the two mons and only the
|
||||
-- seat moves, and at a constant radius so climbing never changes how big
|
||||
-- anything is -- that is the lens's job below, and a rig that did both at
|
||||
-- once would have no way to do either on purpose.
|
||||
local lift = steered and BattleCam.pitch * BattleCam.PITCH_RANGE or 0
|
||||
if lift > 0 then
|
||||
local vx, vy, vz = eye[1] - focus[1], eye[2] - focus[2], eye[3] - focus[3]
|
||||
local flat = math.sqrt(vx * vx + vz * vz)
|
||||
local r = math.sqrt(flat * flat + vy * vy)
|
||||
if flat > 1e-6 and r > 1e-6 then
|
||||
local a = math.atan2(vy, flat) + lift
|
||||
-- short of straight down, always: the placed camera's up vector is
|
||||
-- world up, which degenerates against a view looking exactly along it
|
||||
a = math.min(a, math.rad(85))
|
||||
local nf = r * math.cos(a)
|
||||
eye[1] = focus[1] + vx / flat * nf
|
||||
eye[3] = focus[3] + vz / flat * nf
|
||||
eye[2] = focus[2] + r * math.sin(a)
|
||||
end
|
||||
end
|
||||
|
||||
local ex = eye[1] - focus[1]
|
||||
local ey = eye[2] - focus[2]
|
||||
local ez = eye[3] - focus[3]
|
||||
|
||||
+69
-7
@@ -64,11 +64,17 @@ CamControl.SURVEY_PINCH = 2.2
|
||||
-- battle in front of the player right now" -- with 3D-BTL off, or on a map
|
||||
-- with no arena, the engine's own flat battle screen is up and its camera
|
||||
-- is not ours to steer.
|
||||
-- BACK SPRITES also closes it, through BattleCam.steerable: that setting
|
||||
-- nails the player's own mon to the GB's slot on the menu while the foe
|
||||
-- stands out on the map, and no camera angle holds a composition that is
|
||||
-- half frame and half world (see BattleCam.steerable, which is where the
|
||||
-- reasoning lives and which the RIG answers to as well -- so a stored
|
||||
-- angle from before the setting was switched on stands down with it).
|
||||
local function battleLive()
|
||||
local ok, shot = pcall(function()
|
||||
return V.require("OverworldBattle").shot()
|
||||
end)
|
||||
return (ok and shot) and true or false
|
||||
return (ok and shot and BattleCam.steerable) and true or false
|
||||
end
|
||||
|
||||
CamControl.battleLive = battleLive
|
||||
@@ -169,10 +175,16 @@ CamControl.surveyAccum = 0
|
||||
-- already recording (it records whatever the rung, so a battle can read
|
||||
-- them without a second wrap on the same seam). Ticked from
|
||||
-- OverworldBattle.update, which runs whatever is on top of the stack.
|
||||
--
|
||||
-- X walks the shot round the arena, Y raises the seat. The Y is NEGATED:
|
||||
-- a stick pushed forward reads as negative on SDL's axis, and pushing
|
||||
-- forward should send the camera UP and over -- the same "push the camera
|
||||
-- where you want it" the drag and the mouse below use.
|
||||
function CamControl.tick(dt)
|
||||
if not battleLive() then return end
|
||||
local x = FirstPerson.stickX()
|
||||
local x, y = FirstPerson.stickX(), FirstPerson.stickY()
|
||||
if x ~= 0 then BattleCam.stickOrbit(x, dt) end
|
||||
if y ~= 0 then BattleCam.stickPitch(-y, dt) end
|
||||
end
|
||||
|
||||
-- ------- the wraps
|
||||
@@ -202,18 +214,63 @@ function CamControl.install()
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the stick clicks
|
||||
--
|
||||
-- Q and E, on the pad: the left stick's click pulls the camera out and the
|
||||
-- right stick's pulls it in. A controller has no wheel and no number row,
|
||||
-- and the two clicks are the only buttons a Gen 1 pad layout leaves free
|
||||
-- (SELECT already walks the angle ladder).
|
||||
--
|
||||
-- Claimed for the two cameras a pad player can actually be looking at
|
||||
-- while pressing them -- the third-person boom and a staged battle's lens
|
||||
-- -- and forwarded untouched everywhere else, so a player who has rebound
|
||||
-- either click keeps it on every other screen, a rebind capture included.
|
||||
-- Not on the orbit rungs: the survey zoom has the OPTIONS row and the
|
||||
-- wheel already, and taking a pad button for it would be taking one from
|
||||
-- a player who never asked.
|
||||
local CLICK_ZOOMS = { boom = true, battle = true }
|
||||
do
|
||||
local inner = Game.gamepadpressed
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
if (button == "leftstick" or button == "rightstick")
|
||||
and CLICK_ZOOMS[CamControl.zoomTarget() or ""] then
|
||||
CamControl.zoomBy(button == "leftstick" and 1 or -1)
|
||||
return
|
||||
end
|
||||
return inner(self, joystick, button)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the mouse
|
||||
--
|
||||
-- Battle only. The free-roam look already owns relative motion through
|
||||
-- FirstPerson's own wrap (this one is outside it, so what is claimed here
|
||||
-- never reaches it) and a fight is exactly when that look is not driving.
|
||||
--
|
||||
-- Bare motion, no button held: moving the mouse moves the shot.
|
||||
--
|
||||
-- Each event's contribution is CLAMPED, though, because not every motion
|
||||
-- event is a hand moving. The pointer entering the window, a warp back to
|
||||
-- centre, an alt-tab -- each arrives as ONE event carrying the whole
|
||||
-- distance from wherever the cursor was last seen, and in testing that
|
||||
-- was a couple of hundred counts: enough to swing the shot a quarter of
|
||||
-- the way to side-on before the player had touched anything. A real hand
|
||||
-- delivers its travel as a stream of small events and is unaffected; a
|
||||
-- teleport delivers it as one and is cut down to the size of a flick.
|
||||
local MOUSE_STEP = 40
|
||||
local function clamp(v)
|
||||
return math.max(-MOUSE_STEP, math.min(MOUSE_STEP, v or 0))
|
||||
end
|
||||
do
|
||||
local inner = love.mousemoved
|
||||
love.mousemoved = function(x, y, dx, dy, istouch)
|
||||
if battleLive() and not istouch and dx and dx ~= 0 then
|
||||
BattleCam.mouseOrbit(dx)
|
||||
if battleLive() and not istouch then
|
||||
-- dy is NEGATED for the same reason the stick's is: moving the
|
||||
-- mouse away from you sends the camera up and over
|
||||
if dx and dx ~= 0 then BattleCam.mouseOrbit(clamp(dx)) end
|
||||
if dy and dy ~= 0 then BattleCam.mousePitch(-clamp(dy)) end
|
||||
-- forwarded anyway: the cursor still has UI to point at, and the
|
||||
-- orbit is a read of the motion rather than a claim on it
|
||||
-- steer is a read of the motion rather than a claim on it
|
||||
end
|
||||
if inner then return inner(x, y, dx, dy, istouch) end
|
||||
end
|
||||
@@ -317,9 +374,14 @@ function CamControl.install()
|
||||
return -- claimed: never a look drag too
|
||||
end
|
||||
if battleLive() and not pinch then
|
||||
local w = 1280
|
||||
pcall(function() w = love.graphics.getWidth() end)
|
||||
local w, h = 1280, 720
|
||||
pcall(function()
|
||||
w, h = love.graphics.getWidth(), love.graphics.getHeight()
|
||||
end)
|
||||
BattleCam.dragOrbit((x - px) / math.max(320, w))
|
||||
-- dragged UP sends the camera up and over, the same way the
|
||||
-- stick and the mouse do
|
||||
BattleCam.dragPitch(-(y - py) / math.max(240, h))
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
@@ -177,6 +177,10 @@ function FirstPerson.stickX()
|
||||
return stick.x or 0
|
||||
end
|
||||
|
||||
function FirstPerson.stickY()
|
||||
return stick.y or 0
|
||||
end
|
||||
|
||||
-- ------- lending the look finger out
|
||||
--
|
||||
-- A pinch needs both fingers on the screen, and one of them is very likely
|
||||
|
||||
+12
-7
@@ -209,13 +209,14 @@ local function pushSpecials(state, dir, why)
|
||||
return true
|
||||
end
|
||||
end
|
||||
if why ~= "entity" then
|
||||
if (state.bumpCooldown or 0) <= 0 then
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
state.bumpCooldown = 16
|
||||
end
|
||||
end
|
||||
-- and NO bonk. The grid walk's collision sound marks a discrete event:
|
||||
-- you pressed a direction, the step was refused, nothing happened. A
|
||||
-- free walk has no such moment -- the body slides along every wall it
|
||||
-- grazes, continuously, and a corridor taken at a slight angle is a
|
||||
-- steady graze from end to end. Rate-limited or not, that came out as a
|
||||
-- machine-gun of bonks for walking normally down a hallway. The wall
|
||||
-- stopping you is the feedback; the sound only ever said so twice a
|
||||
-- second whether or not anything had changed.
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -286,6 +287,10 @@ function FreeMove.tick(state)
|
||||
-- either way -- bodyBearing says so.
|
||||
p.facing = FirstPerson.pointBody(wx, wz)
|
||||
|
||||
-- the engine's own bonk clock, kept draining while the free walk has the
|
||||
-- wheel: nothing here rings it (see pushSpecials), but stepping back onto
|
||||
-- the grid must not inherit a cooldown frozen at whatever it held when
|
||||
-- the rung was picked
|
||||
state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
|
||||
|
||||
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
|
||||
|
||||
@@ -503,6 +503,14 @@ function OverworldBattle.update(dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- Whether the shot is the player's to steer at all. BACK SPRITES pins
|
||||
-- their own mon to the GB's slot on the menu while the foe stands out on
|
||||
-- the map, and there is no angle that half-framed, half-solid
|
||||
-- composition survives -- so under it the camera holds the shot the rig
|
||||
-- was solved for (the slow drift aside, which was always there). Polled
|
||||
-- per frame rather than latched at battle start: the row is reachable
|
||||
-- from the mod manager's page mid-session.
|
||||
BattleCam.steerable = not OverworldBattle.backPinned()
|
||||
-- the right stick, read as a rate before the rig is built from it: the
|
||||
-- wheel, the keys, the mouse and a drag all arrive as events and have
|
||||
-- already landed, but a stick is a HELD position and only a tick can
|
||||
|
||||
@@ -3289,6 +3289,56 @@ end
|
||||
|
||||
-- ---- tall grass ----
|
||||
|
||||
-- ---- closing a standee's sides ----
|
||||
--
|
||||
-- The grass tufts and the flowers are both built the same way: each row of
|
||||
-- the 8x8 drawing becomes a horizontal RUN of lit pixels, stood up as a
|
||||
-- front face and a back face one voxel apart, with a lid on top. What that
|
||||
-- leaves open is the two ENDS of every run -- so the slab was a pair of
|
||||
-- billboards rather than a solid, and from any angle off square you looked
|
||||
-- in through the edge and straight out the other side. At the low cameras
|
||||
-- this mod has grown (1ST, 3RD, the battle's floor-level seat) that is
|
||||
-- most of the time.
|
||||
--
|
||||
-- A wall goes on an end only where the pixel beyond it is actually clear,
|
||||
-- which for a run's end it is by construction -- except where two runs on
|
||||
-- the same row meet across a gap of nothing, which cannot happen, and at
|
||||
-- the tile's border, where the neighbouring tile's own standee may or may
|
||||
-- not continue the shape. The border is closed anyway: tufts sit on their
|
||||
-- own half-cells with a gap between them, so an open border edge is a hole
|
||||
-- in the open, not a seam with anything.
|
||||
--
|
||||
-- Each wall samples ONE texel at its centre -- the end pixel it is closing
|
||||
-- off -- so it wears that pixel's own colour, which is the nearest coloured
|
||||
-- pixel to the surface being filled. Sampling a single texel is also what
|
||||
-- carries the animation: when a frame keys that pixel out, the wall's own
|
||||
-- fragments discard with the faces either side of it, so a swaying tuft
|
||||
-- never leaves a wall standing where its blade no longer is.
|
||||
local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
||||
ax0, ay0, atlasW, atlasH, py, lit)
|
||||
local function texel(px)
|
||||
return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH
|
||||
end
|
||||
if not lit(ix - 1, py) then
|
||||
local u, v = texel(ix)
|
||||
quads[#quads + 1] = { -- the run's left wall, facing -X
|
||||
{ ix, yBot, zB }, { ix, yBot, zF },
|
||||
{ ix, yTop, zF }, { ix, yTop, zB },
|
||||
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
|
||||
shade = OBJ_SHADE.side,
|
||||
}
|
||||
end
|
||||
if not lit(ix2 + 1, py) then
|
||||
local u, v = texel(ix2)
|
||||
quads[#quads + 1] = { -- and its right wall, facing +X
|
||||
{ ix2 + 1, yBot, zF }, { ix2 + 1, yBot, zB },
|
||||
{ ix2 + 1, yTop, zB }, { ix2 + 1, yTop, zF },
|
||||
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
|
||||
shade = OBJ_SHADE.side,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
|
||||
-- whole clump of grass. Each tile stands as its own thin per-pixel slab
|
||||
-- at ITS OWN depth -- the cell's north tile row in the north half of the
|
||||
@@ -3359,6 +3409,20 @@ local function grassTemplate(map, data, tileId)
|
||||
shade = 1,
|
||||
}
|
||||
end
|
||||
-- and underneath, where a blade ends in mid-air over the ground
|
||||
if not opaque(ix, iy + 1) then
|
||||
quads[#quads + 1] = {
|
||||
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
|
||||
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
|
||||
uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
|
||||
shade = OBJ_SHADE.bottom,
|
||||
}
|
||||
end
|
||||
-- and the run's two end walls, which is what makes a blade a solid
|
||||
-- thing rather than two billboards you can see between (sideQuads
|
||||
-- above argues it, and why each wall wears its end pixel's colour)
|
||||
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
||||
ax0, ay0, atlasW, atlasH, iy, opaque)
|
||||
ix = ix2 + 1
|
||||
else
|
||||
ix = ix + 1
|
||||
@@ -3537,6 +3601,30 @@ local function flowerTemplate(map, data, tileId)
|
||||
shade = OBJ_SHADE.top,
|
||||
}
|
||||
end
|
||||
-- and the same strip on the bottom, where the row below is clear:
|
||||
-- a petal that ends mid-air is a solid thing seen from underneath,
|
||||
-- and a low camera (1ST, 3RD, the battle's floor-level seat) is
|
||||
-- looking straight up at it
|
||||
if not on(ix, py + 1) then
|
||||
quads[#quads + 1] = {
|
||||
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
|
||||
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
|
||||
uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
|
||||
shade = OBJ_SHADE.bottom,
|
||||
}
|
||||
end
|
||||
-- The ENDS of the run, which close the slab off sideways. Without
|
||||
-- them the flower is two faces and a lid: from anywhere but square
|
||||
-- on you look straight through its open edge and out the far side,
|
||||
-- which is what stopped it reading as a solid thing.
|
||||
--
|
||||
-- Each wall samples ONE texel -- the run's own end pixel, at its
|
||||
-- centre -- so the side wears the colour of the pixel it is closing
|
||||
-- off (the nearest coloured pixel there is) rather than a keyed
|
||||
-- hole, and discards with that pixel when the animation frame it
|
||||
-- belongs to is not the one on screen.
|
||||
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
||||
ax0, ay0, atlasW, atlasH, py, on)
|
||||
ix = ix2 + 1
|
||||
else
|
||||
ix = ix + 1
|
||||
|
||||
@@ -3885,6 +3885,233 @@ Voxel3D.camera = nil
|
||||
VoxelState.reset()
|
||||
end
|
||||
|
||||
-- ------- the cameras the player steers
|
||||
--
|
||||
-- Three cameras take the same four inputs -- a wheel, Q/E, a pinch, a
|
||||
-- stick -- and the whole of CamControl is the answer to "which one is this
|
||||
-- aimed at". So the suite pins that routing table, then each camera's own
|
||||
-- stops: the boom's zoom, and the battle's orbit, climb and lens.
|
||||
|
||||
do
|
||||
local CamControl = run.loader.exports.DRAMATIC_SHAPE.lib.require("CamControl")
|
||||
local ThirdPerson =
|
||||
run.loader.exports.DRAMATIC_SHAPE.lib.require("ThirdPerson")
|
||||
local BattleCam = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleCam")
|
||||
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
|
||||
local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
|
||||
|
||||
-- ------- the boom's own zoom
|
||||
ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
|
||||
T.eq(ThirdPerson.reachFor(), ThirdPerson.BOOM,
|
||||
"at zoom 1 the boom reaches exactly its own length")
|
||||
T.check(ThirdPerson.stepZoom(1), "a notch out moves the goal")
|
||||
T.check(ThirdPerson.zoomGoal > 1, "outward, which is what positive means")
|
||||
T.check(ThirdPerson.stepZoom(-2), "and back in past where it started")
|
||||
T.check(ThirdPerson.zoomGoal < 1, "inward")
|
||||
for _ = 1, 40 do ThirdPerson.stepZoom(-1) end
|
||||
T.eq(ThirdPerson.zoomGoal, ThirdPerson.ZOOM_MIN, "it stops coming in")
|
||||
T.check(not ThirdPerson.stepZoom(-1),
|
||||
"and says so, so the input can fall through instead of being eaten")
|
||||
for _ = 1, 60 do ThirdPerson.stepZoom(1) end
|
||||
T.eq(ThirdPerson.zoomGoal, ThirdPerson.ZOOM_MAX, "and stops going out")
|
||||
|
||||
-- the ease: a step is a request, and the eye takes ZOOM_TIME to answer it
|
||||
ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
|
||||
ThirdPerson.stepZoom(2)
|
||||
ThirdPerson.update(1 / 60, 1)
|
||||
T.check(ThirdPerson.zoom > 1 and ThirdPerson.zoom < ThirdPerson.zoomGoal,
|
||||
"one frame later the eye is on its way but not there")
|
||||
for _ = 1, 120 do ThirdPerson.update(1 / 60, 1) end
|
||||
T.eq(ThirdPerson.zoom, ThirdPerson.zoomGoal, "and it arrives")
|
||||
T.check(math.abs(ThirdPerson.reachFor()
|
||||
- ThirdPerson.BOOM * ThirdPerson.zoom) < 1e-9,
|
||||
"the boom it reaches for is the length at that zoom")
|
||||
ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
|
||||
|
||||
-- the same control with no notches, for a gesture whose own scale IS the
|
||||
-- answer. It scales the BOOM, so the inversion a pinch needs (spread the
|
||||
-- fingers, pull the camera in) belongs to the gesture, not to this
|
||||
T.check(ThirdPerson.scaleZoom(2), "a continuous factor moves it too")
|
||||
T.check(math.abs(ThirdPerson.zoomGoal - 2) < 1e-6,
|
||||
"and scales the boom by exactly that factor")
|
||||
ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
|
||||
|
||||
-- ------- which camera an input is aimed at
|
||||
--
|
||||
-- Needs a 3D pass and a free-roam stack, neither of which a headless run
|
||||
-- has; both are lent for the length of the check and handed back.
|
||||
;(function()
|
||||
local Game = require("src.core.Game")
|
||||
local hadAvail = Voxel3D.available
|
||||
local hadStack, hadOw = Game.stack, Game.overworld
|
||||
local ow = {}
|
||||
Voxel3D.available = function() return true end
|
||||
Game.overworld = ow
|
||||
Game.stack = { top = function() return ow end }
|
||||
|
||||
VoxelState.setLevel(0)
|
||||
T.eq(CamControl.zoomTarget(), nil, "with the mode off, no camera of ours")
|
||||
VoxelState.setLevel(3)
|
||||
T.eq(CamControl.zoomTarget(), "survey",
|
||||
"on an orbit rung a zoom is the engine's own survey zoom")
|
||||
VoxelState.setLevel(VoxelState.FP_LEVEL)
|
||||
T.eq(CamControl.zoomTarget(), nil,
|
||||
"in 1ST nothing zooms -- the eye is in the player's head")
|
||||
VoxelState.setLevel(VoxelState.TP_LEVEL)
|
||||
T.eq(CamControl.zoomTarget(), "boom", "and in 3RD it is the boom")
|
||||
|
||||
ThirdPerson.zoomGoal = 1
|
||||
T.check(CamControl.zoomBy(1) and ThirdPerson.zoomGoal > 1,
|
||||
"so a wheel notch on that rung lets the boom out")
|
||||
ThirdPerson.zoomGoal = 1
|
||||
T.check(CamControl.pinchBy(2) and ThirdPerson.zoomGoal < 1,
|
||||
"and spreading two fingers pulls it IN -- the gesture is the inversion")
|
||||
ThirdPerson.zoomGoal = 1
|
||||
T.check(CamControl.pinchBy(0.5) and ThirdPerson.zoomGoal > 1,
|
||||
"pinching them together pushes it out again")
|
||||
ThirdPerson.zoom, ThirdPerson.zoomGoal = 1, 1
|
||||
|
||||
-- 1ST is the rung that deliberately swallows nothing: a pinch there
|
||||
-- would silently wind the survey zoom for whenever the player stepped
|
||||
-- back out to an orbit rung
|
||||
VoxelState.setLevel(VoxelState.FP_LEVEL)
|
||||
T.check(not CamControl.zoomBy(1), "1ST claims no wheel notch")
|
||||
T.check(not CamControl.pinchBy(2), "and no pinch")
|
||||
VoxelState.setLevel(VoxelState.TP_LEVEL)
|
||||
|
||||
-- a screen over the overworld takes every one of them back
|
||||
Game.stack = { top = function() return {} end }
|
||||
T.eq(CamControl.zoomTarget(), nil,
|
||||
"with anything pushed over the overworld, nothing is ours to zoom")
|
||||
|
||||
Voxel3D.available = hadAvail
|
||||
Game.stack, Game.overworld = hadStack, hadOw
|
||||
VoxelState.reset()
|
||||
end)()
|
||||
|
||||
-- ------- the battle's orbit
|
||||
--
|
||||
-- The stop that matters is the far one: swung fully right, the eye must be
|
||||
-- SQUARE to the arena's axis -- the side-on shot -- and not a degree past
|
||||
-- it. Measured off the rig rather than off the constant, because the
|
||||
-- constant is computed from the rig's own stance.
|
||||
;(function()
|
||||
local arena = { mid = { 100, 200 }, player = { 100, 216 },
|
||||
enemy = { 100, 184 } }
|
||||
local function bearing()
|
||||
local rig = BattleCam.rig(arena, 0)
|
||||
return math.atan2(rig.eye[1] - arena.mid[1], rig.eye[3] - arena.mid[2])
|
||||
end
|
||||
BattleCam.recentre()
|
||||
BattleCam.reset()
|
||||
BattleCam.steerable = true
|
||||
|
||||
local home = bearing()
|
||||
T.check(home > 0.4 and home < 0.6,
|
||||
"the solved shot stands about 28 degrees off the arena's axis")
|
||||
BattleCam.orbit = 1
|
||||
T.check(math.abs(bearing() - math.pi / 2) < 1e-9,
|
||||
"swung fully right, the eye is exactly square to the axis: side-on")
|
||||
T.check(math.abs(BattleCam.orbitRange(arena) - (math.pi / 2 - home)) < 1e-9,
|
||||
"which is precisely the room orbitRange said it had")
|
||||
|
||||
-- and the near one: there is nothing to the left of the solved shot
|
||||
BattleCam.recentre()
|
||||
T.check(not BattleCam.dragOrbit(-1), "a drag left of home does nothing")
|
||||
T.eq(BattleCam.orbitGoal, 0, "the shot the composition was solved for IS "
|
||||
.. "the left stop")
|
||||
T.check(BattleCam.dragOrbit(0.2), "a drag right steers")
|
||||
BattleCam.dragOrbit(10)
|
||||
T.eq(BattleCam.orbitGoal, 1, "and stops at side-on however hard it is pushed")
|
||||
|
||||
-- ------- the climb
|
||||
BattleCam.recentre()
|
||||
local function elevation()
|
||||
local rig = BattleCam.rig(arena, 0)
|
||||
local vx = rig.eye[1] - rig.focus[1]
|
||||
local vy = rig.eye[2] - rig.focus[2]
|
||||
local vz = rig.eye[3] - rig.focus[3]
|
||||
return math.atan2(vy, math.sqrt(vx * vx + vz * vz)),
|
||||
math.sqrt(vx * vx + vy * vy + vz * vz)
|
||||
end
|
||||
local low, radius = elevation()
|
||||
BattleCam.pitch = 1
|
||||
local high, radius2 = elevation()
|
||||
T.check(math.abs((high - low) - BattleCam.PITCH_RANGE) < 1e-6,
|
||||
"raised fully, the seat is exactly 45 degrees above the solved one")
|
||||
T.check(math.abs(radius2 - radius) < 1e-6,
|
||||
"at the same distance -- climbing is not zooming")
|
||||
BattleCam.recentre()
|
||||
T.check(not BattleCam.dragPitch(-1), "and it will not tilt below home")
|
||||
T.eq(BattleCam.pitchGoal, 0, "the rig's own low stance is the down stop")
|
||||
BattleCam.dragPitch(10)
|
||||
T.eq(BattleCam.pitchGoal, 1, "45 degrees is the up stop")
|
||||
|
||||
-- ------- the lens opening to keep the pair framed
|
||||
--
|
||||
-- Swinging round or climbing un-foreshortens the arena's axis, so the two
|
||||
-- mons read further apart; left alone that threw them off the edges of
|
||||
-- the frame at the far end of both ranges.
|
||||
BattleCam.recentre()
|
||||
T.check(math.abs(BattleCam.spread(arena) - 1) < 1e-9,
|
||||
"at the solved shot the lens is the rig's own, exactly")
|
||||
BattleCam.orbit = 1
|
||||
T.check(BattleCam.spread(arena) > 1.8,
|
||||
"side-on the pair reads nearly twice as far apart, and the lens opens "
|
||||
.. "by the same amount")
|
||||
BattleCam.orbit = 0
|
||||
BattleCam.pitch = 1
|
||||
T.check(BattleCam.spread(arena) > 1.5,
|
||||
"and climbing spreads them too, on the other axis")
|
||||
BattleCam.recentre()
|
||||
|
||||
local wide = BattleCam.rig(arena, 0).fov
|
||||
T.check(BattleCam.stepZoom(-3), "three notches in")
|
||||
BattleCam.zoom = BattleCam.zoomGoal
|
||||
T.check(BattleCam.rig(arena, 0).fov < wide,
|
||||
"and the lens is longer -- zoom is the FRAME, not the distance")
|
||||
T.check(math.abs(BattleCam.frameH(arena)
|
||||
- BattleCam.rigFor(arena).frameH * BattleCam.zoom) < 1e-9,
|
||||
"which is what the sun's box is fitted to as well")
|
||||
for _ = 1, 40 do BattleCam.stepZoom(-1) end
|
||||
T.eq(BattleCam.zoomGoal, BattleCam.ZOOM_MIN, "the lens has a near stop")
|
||||
for _ = 1, 60 do BattleCam.stepZoom(1) end
|
||||
T.eq(BattleCam.zoomGoal, BattleCam.ZOOM_MAX, "and a far one")
|
||||
|
||||
-- ------- what BACK SPRITES takes away
|
||||
--
|
||||
-- Not just the input: the RIG stands down too, so an angle stored from
|
||||
-- before the row was switched on cannot leave the pinned composition
|
||||
-- steered anyway.
|
||||
BattleCam.recentre()
|
||||
BattleCam.orbit, BattleCam.orbitGoal = 1, 1
|
||||
BattleCam.pitch, BattleCam.pitchGoal = 1, 1
|
||||
BattleCam.zoom, BattleCam.zoomGoal = 0.5, 0.5
|
||||
BattleCam.steerable = false
|
||||
T.check(math.abs(bearing() - home) < 1e-9,
|
||||
"with the player's mon pinned to the menu, the shot holds its own angle")
|
||||
T.eq(BattleCam.frameH(arena), BattleCam.rigFor(arena).frameH,
|
||||
"and its own lens")
|
||||
T.check(not BattleCam.dragOrbit(0.5), "and refuses to be steered")
|
||||
T.check(not BattleCam.dragPitch(0.5), "on either axis")
|
||||
T.check(not BattleCam.stepZoom(-1), "or zoomed")
|
||||
BattleCam.steerable = true
|
||||
|
||||
-- ------- and what a new battle remembers
|
||||
BattleCam.recentre()
|
||||
BattleCam.dragOrbit(0.5)
|
||||
BattleCam.dragPitch(0.5)
|
||||
BattleCam.stepZoom(-1)
|
||||
BattleCam.reset()
|
||||
T.check(BattleCam.orbitGoal > 0 and BattleCam.pitchGoal > 0
|
||||
and BattleCam.zoomGoal < 1,
|
||||
"a new fight opens where the player left the camera, not where the rig "
|
||||
.. "was solved -- an angle they chose is how they watch battles")
|
||||
T.eq(BattleCam.t, 0, "only the drift's own phase starts over")
|
||||
BattleCam.recentre()
|
||||
end)()
|
||||
end
|
||||
|
||||
-- ------- the VR rig's arithmetic
|
||||
--
|
||||
-- VRRig is the deliberately pure half of the VR stack: headset poses in,
|
||||
|
||||
Reference in New Issue
Block a user