diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 51ea4b3..3bd3170 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -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] diff --git a/lib/CamControl.lua b/lib/CamControl.lua index 9061645..235958e 100644 --- a/lib/CamControl.lua +++ b/lib/CamControl.lua @@ -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 diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index 9409332..5511d99 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -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 diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua index 3c7182a..cb9f2e8 100644 --- a/lib/FreeMove.lua +++ b/lib/FreeMove.lua @@ -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 diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f4055dd..2114264 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -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 diff --git a/lib/Structures.lua b/lib/Structures.lua index ad3d5ec..be4aac2 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -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 diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 5bf4401..9ccce89 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -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,