diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f00542..242770b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,73 +1,132 @@ # Changelog -## 1.5.4 +## 1.5.5 ### Added -- **HORDE MODE.** Enter the konami code -- Up Up Down Down Left Right - Left Right B A -- standing in the overworld, and Kanto turns on you. - The sky drops to a starless violet night, the Lavender Town theme - comes up, the camera locks into the player's own head, a voxel - handgun with working iron sights appears in their right hand, and - waves of people walk out of the dark to kill them. The map's own NPCs - join in. Every mob that falls screams as a random Pokemon. Score goes - up per kill and per wave cleared; there is no pausing. When the health - runs out, a GAME OVER card offers the score and PRESS A -- and pressing - it puts the map, the cell, the facing, the camera rung, the hour, the - music and every NPC back exactly as they were. +- **A third-person camera: the 3RD rung.** The VOXEL ladder's eighth rung + (`3` / SELECT walk onto it after 1ST, and it is on the OPTIONS row) + stands the camera on a boom behind the player's shoulder. It is the + first-person rig with one number added, so everything 1ST already did + it does: the look steers on a mouse, the right stick or a touch drag, + and the grid walk is replaced by continuous camera-relative movement -- + push in any direction and you go there, at any angle, with collision, + warps, ledges, encounters and scripts still running through the + engine's own machinery. - The code is read off GAME BOY BUTTONS rather than off keys, so it - works on a keyboard, a pad, a phone's touch overlay and a pair of VR - controllers without knowing which one is in use. Fire on left click, - the pad's right trigger or B, a tap on a touch screen, or the right - trigger in VR; reload on R, the pad's X, or B on the right hand; aim - down the sights on right click or the left trigger (in VR you aim by - pointing, and the sights are real geometry to line up). + The boom **collides**: it marches back through the terrain height field + and the map's own walkability, so backing into a wall walks the camera + in to your shoulders instead of through it, and rounding a corner eases + it back out rather than snapping. Squeezed all the way into the head it + simply draws as 1ST until you step clear. It also carries a small + over-the-shoulder rail offset, which fades out as the boom shortens. - The crowd walks the same grid the game walks, chases along a flow - field swept out from the player -- so they come through doorways and - around buildings rather than into walls -- and they follow the player - through a door into the building on the far side of it. + Every sprite in the world **turns to face it** -- yours, the NPCs', the + figures drawn into the furniture -- and shows the frame it would look + like from where the camera actually stands, so walking behind someone + shows you their back. Your own character is drawn (with the + through-the-wall silhouette 1ST had no use for) and **turns to face + where they are walking** rather than where the camera looks, so a strafe + reads as one; standing still they come back round to the camera's + bearing, which is the one A talks along. -- **The horde's gun, on the Game Boy's own sound hardware.** The - gunshot, the dry fire, the magazine dropping out and seating, and the - slide coming home are all authored as channel programs and - synthesized by the game's emulated APU (`lib/HordeSfx.lua`). No audio - files ship with the mod. Three shot variants round-robin so a fast - trigger finger overlaps its own echoes instead of cutting them off. + Your card's frame is chosen from your body's **continuous** bearing + rather than from the compass direction the grid game stores. An NPC's + facing really is one of four directions, but yours is the angle the + camera itself is hung off, and quantising it before measuring it against + the eye leaves no margin for the shoulder rail's few degrees of offset: + in a band just short of each 45-degree boundary the pair read as 135 + degrees apart and picked the mirrored PROFILE frame. Standing still. + Spinning the camera swept four of those bands a revolution, which showed + up as the character flicking sideways for a split second. -### Changed + In VR the boom is declined outright and 3RD presents as 1ST does: a + headset that seats its wearer three cells behind their own body is a + well-known way to make people ill. The rung still changes the walk and + the sprites the same way. -- **SMOOTH TURN**, a new options row under VR (OFF by default), turns the - right stick into a continuous turn instead of the 45-degree snap. The - snap stays the default deliberately -- a software turn moves the world - past a head that did not move, which is the most reliable way to make - somebody ill in a headset -- but it costs continuity, so the choice is - the player's. The row only exists while VR is ON: a comfort setting for - a device that is not plugged in decides nothing. +- **Every camera zooms, on whatever the machine has.** The mouse wheel, + `Q`/`E`, a two-finger pinch and the pad's two stick clicks all reach + whichever camera is actually in front of you -- the third-person boom, + the staged battle's lens, or the engine's own survey zoom on an orbit + rung. One module (`lib/CamControl.lua`) answers "which camera is this + aimed at" so the four cameras never race each other for an event, and + forwards everything it does not claim. 1ST claims nothing: the eye is in + the player's head, and a pinch there would only wind the survey zoom for + whenever they stepped back out. -- **The wall bump is silent in first person**, on the flat screen and in - VR alike. On the grid a blocked step is a discrete event -- you pressed - a direction, the game refused, and the bump answers you once. The free - walk has no such moment: the body slides along whatever it grazes, so - walking a fence line or rounding a doorframe is blocked on one axis - continuously and the sound came out as a rattle rather than as an - answer. The grid walk keeps its own bump untouched. +- **The battle camera is yours to steer.** The right stick, a drag across + the screen or the mouse walks the staged shot around the arena and raises + the seat; the wheel, `Q`/`E`, a pinch or a stick click work the lens. -- **`FirstPerson.fovScale`** is a new seam on the first-person rig: a - plain multiplier on the field of view, for anything that wants to - narrow the lens without owning the camera. Horde mode's iron sights - ease it from 65 degrees to 40. It is folded into the orbit blend and - into the shadow signature, so a lens that narrows while the player - stands still still re-fits the sun's box. + Both axes stop where the composition does. LEFT stops at the shot the rig + was solved for, because there is nothing to the left of it. RIGHT ends + SIDE-ON -- the eye square to the arena's axis, both Pokemon at the same + distance instead of one behind the other -- computed from each rig's own + stance rather than written down. DOWN stops at the rig's low stance and + UP is 45 degrees above it, raised about the focus at a constant radius so + climbing never doubles as zooming. Input accumulates into a goal the eye + eases after, so a flick reads as the camera being pushed rather than + dragged. -- **VR gains an aim pose and a fire action** (`lib/VRXR.lua`). The fire - action is suggested ALONGSIDE START on the right trigger rather than - instead of it -- bindings are suggested once, before the session - attaches its action sets, so they cannot be swapped when a mode - starts, and OpenXR is happy for two actions to share an input. - Outside horde mode nothing reads it and the trigger is START exactly - as it was. + The lens **opens by exactly the amount the pair spreads**: the solved + shot looks along the arena's axis at a shallow angle, which foreshortens + the gap between the two mons to less than half its length, and swinging + round or climbing un-foreshortens it. Left alone that threw both Pokemon + off the edges of the frame at the far end of either range, which made the + whole far end unusable. + + Where you leave the camera is where the next battle opens. An angle and a + lens you chose are how you want to watch battles, not a fact about one + encounter. + +- **Move animations track the camera.** They already slid to follow the + pair's midpoint; now they follow its SEPARATION too. Both mons are + geometry standing on the map, so the camera sizes them -- and an effects + layer that kept the authored 106-pixel spacing through a zoom and a + 60-degree swing fired its beams into the air beside the Pokemon they were + aimed at. + +- **BACK SPRITES locks the battle camera.** That setting pins your own mon + to the GB's slot on the menu while the foe stands out on the map, and no + angle holds a composition that is half frame and half world. The steer, + the climb and the lens all stand down -- in the rig as well as at the + inputs, so an angle stored from before the row was switched on cannot + leave it steered anyway. The slow drift stays: it was always there under + BACK SPRITES and two degrees is not a composition problem. + +- **BATTLE BG is pinned to WHITE and its row comes off the menu.** The row + picks what fills the screen AROUND the battle, and this mode fills the + window with the map the fight is standing on -- there are no voids left + for it to be about. WORLD was actively wrong under it: it makes the + battle non-opaque so the engine draws a second, dimmed copy of the + overworld beneath the arena pass's own. Pinned rather than merely hidden, + so a save written before the mod was installed cannot carry a value the + menu can no longer reach. Uninstall and the row is back. + +### Fixed + +- **Grass and flowers are closed off at the sides.** Both stand as + per-pixel slabs built from runs of lit pixels, and only the front, the + back and a lid were ever emitted -- so from any angle off square you + looked straight in through the open end of every run and out the far + side. At the low cameras this release adds, that is most of the time. + Each run now wears end walls in the colour of the pixel they close off. + + Flowers needed more than that, because a flower SWAYS: the mesh spans the + union of every animation frame and each frame is cut back out in texture + space, so a pixel that drops out of a frame takes the union's wall with + it and leaves an interior boundary that never had one. The first cut of + this looked solid on the base frame and still had gaps on every other. + Every pixel of a flower now carries a cap on all four of its remaining + faces: enclosed and invisible while its neighbour is there, and already + in place the moment the animation takes that neighbour away. + +- **No more machine-gun bonking in 1ST and 3RD.** The grid walk's collision + sound marks a discrete event -- a direction pressed, a step refused. A + free walk has no such moment: the body slides along every wall it grazes, + continuously, so a corridor taken at a slight angle rang the bonk twice a + second from end to end. The wall stopping you is the feedback. ## 1.5.2 diff --git a/README.md b/README.md index 0efee17..4b2b42b 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ A mod for the [Pokémon Gen 1 Recompilation Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project). -The overworld as a voxelized 3D diorama. Also supports experimental first-person and VR. +The overworld as a voxelized 3D diorama. Also supports experimental +first-person, third-person and VR. ## Controls @@ -12,7 +13,7 @@ menu. | control | does | | --- | --- | -| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → OFF (camera pitch) | +| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → 3RD → OFF (camera pitch) | | `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row | | `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel | | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | @@ -23,17 +24,71 @@ menu. | the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | +## Free-roam cameras (1ST / 3RD) + +The last two rungs of the **VOXEL** ladder are experimental, and they are +the same camera: **1ST** stands it in the player's own eyes, **3RD** pulls +it back onto a boom behind their shoulder. Both steer, and on both the grid +walk is replaced by continuous camera-relative movement — push in any +direction and you go there, at any angle, not just along the four compass +lines. Collision, warps, ledges, encounters and scripts all still run +through the engine's own machinery. + +| control | does | +| --- | --- | +| mouse | look (the cursor is captured; left click is A, right click is B) | +| right stick | look | +| a touch drag off the overlay's controls | look | +| left stick / touch d-pad / arrow keys | walk, relative to where the camera looks | +| wheel, `Q` / `E`, pinch, or a stick click | **3RD only** — let the boom out and pull it in (`Q` and left stick click out, `E` and right stick click in) | + +On an **orbit rung** the same wheel, `Q`/`E` and pinch drive the engine's own +survey zoom. On **1ST** they do nothing at all: the eye is in your head, and +there is no distance to change. + +On **3RD** the boom shortens against whatever is behind you, so backing into +a wall walks the camera in to your shoulders rather than through it — squeeze +it all the way in and the view is 1ST until you step clear. The character +turns to face where they are walking, and every sprite in the world — yours, +the NPCs', the figures drawn into the furniture — turns to face the camera +and shows the frame it would look like from where the camera actually +stands, so walking behind someone shows you their back. + +## The battle camera + +A fight staged on the map (**3D-BTL**, on by default) is shot with a solved +over-the-shoulder rig — and you can steer it. + +| control | does | +| --- | --- | +| right stick, a touch drag, or the mouse | swing the shot around the arena (→) and raise the seat (↑) | +| wheel, `Q` / `E`, pinch, or a stick click | the lens (`Q` / left stick click out, `E` / right stick click in) | + +Both axes stop where the composition does. Left stops at the shot the rig was +solved for — there is nothing to the left of it. Right ends **side-on**: the +eye square to the arena's axis, both Pokémon at the same distance instead of +one behind the other. Down stops at the rig's own low stance; up is 45° above +it. The lens opens as you swing or climb, by exactly the amount the two +Pokémon spread apart, so they stay framed at every angle. Move animations +follow the pair's position *and* its separation, so a beam still lands on the +Pokémon it was aimed at. + +Where you leave the camera is where the next battle opens. + +**BACK SPRITES locks it.** That setting pins your own Pokémon to the GB's slot +on the menu while the foe stands out on the map, and no angle holds a +composition that is half frame and half world — so with it on, the shot holds +the one the rig was solved for. + ## VR The **VR** options row (OFF / ON, off by default) drives a PCVR headset through OpenXR on Windows — SteamVR, Oculus or WMR. -The **SMOOTH TURN** row appears under it while VR is ON (OFF by -default): ON turns the right stick into a continuous turn instead of the -45° snap. The snap is the default deliberately — a software turn moves -the world past a head that did not move, which is the most reliable way -to make somebody ill in a headset — but it costs continuity, so the -choice is yours. +Both free-roam rungs put the headset in the player's *head*: a boom that +seats its wearer three cells behind their own body is a reliable way to make +people ill, so **3RD** in VR is **1ST** in VR. The rung still changes the +walk and the sprites the same way. ### VR controls diff --git a/lib/BattleArena.lua b/lib/BattleArena.lua index 20f0e04..025fe8f 100644 --- a/lib/BattleArena.lua +++ b/lib/BattleArena.lua @@ -242,7 +242,10 @@ end -- Whether both mons would be in plain view from the battle camera. function BattleArena.clearance(map, arena) local BattleCam = V.require("BattleCam") - local ok, rig = pcall(BattleCam.rig, arena, 0) + -- the CANONICAL shot: whether a fight fits somewhere is a fact about the + -- ground, so it must not depend on the drift's phase or on where the + -- player last swung the camera (see BattleCam.rig's third argument) + local ok, rig = pcall(BattleCam.rig, arena, 0, true) if not (ok and rig and rig.eye) then return true end local eye = rig.eye local H = BattleArena.MON_H diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 09448a5..3bd3170 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -124,6 +124,83 @@ BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction BattleCam.DOLLY_PERIOD = 37 +-- ------- the player's own orbit +-- +-- The drift above is the shot breathing. THIS is the player steering it: +-- a right stick, a drag across the screen or the mouse walks the eye +-- around the arena's axis, and it stops at both ends. +-- +-- 0 is the shot the rig was solved for and the LEFT stop, because there is +-- nothing to the left of it -- the composition below is what the whole +-- module exists to land, and past it the two mons start swapping sides. +-- +-- 1 is SIDE-ON: the eye swung round until it is square to the arena's +-- north-south axis, where the two mons stand at the same distance instead +-- of one behind the other. That is as far as the picture stays a battle +-- rather than a diorama with two Pokemon in it, and it is a different angle +-- for each rig -- the tele lens starts 28 degrees off the axis and the wide +-- one 45 -- so the stop is COMPUTED from the rig rather than written down, +-- and retuning either moves its own stop with it. +-- +-- The input is deliberately not 1:1 with the pixels: it accumulates into +-- `orbitGoal` and the live angle eases after it, so a flick reads as the +-- camera being pushed rather than as the camera being dragged. +BattleCam.ORBIT_TIME = 0.22 -- seconds for the eye to catch its goal +BattleCam.ORBIT_DRAG = 1.15 -- fraction of the range per screen width +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: +-- BELOW one is zoomed in. It has to be the LENS rather than the distance, +-- because the rig derives its field of view from frameH and the distance +-- together -- so moving the eye alone changes the perspective and not the +-- framing, which is exactly what the dolly breath above is for. +BattleCam.ZOOM_MIN = 0.45 -- the pair filling the frame +BattleCam.ZOOM_MAX = 2.0 -- the fight in its own landscape +BattleCam.ZOOM_STEP = 1.15 +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 @@ -134,10 +211,170 @@ BattleCam.still = false BattleCam.t = 0 +-- 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 + +-- How far the eye may swing, in radians, before it is square to the arena's +-- axis. The rig's own stance decides it: `side` and `back` are the offset +-- it starts at, so the bearing it starts on is atan2(side, back) and what +-- is left to a quarter turn is the room the player has. +function BattleCam.orbitRange(arena) + local R = BattleCam.rigFor(arena) + return math.max(0, math.pi / 2 - math.atan2(R.side, R.back)) +end + +-- ------- what the player's inputs reach +-- +-- All four take a signed amount and clamp; positive is RIGHTWARD, toward +-- the side-on stop. Returning whether the goal actually moved lets a +-- caller tell "steered" from "already against the stop". + +-- 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 (orbit) or height (pitch). +function BattleCam.dragOrbit(fraction) + 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 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. +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) + 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, + was * (BattleCam.ZOOM_STEP ^ (notches or 0)))) + 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 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) + 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) + if now == goal then return goal end + local v = now + (goal - now) * math.min(1, (dt or 0) / time) + return (math.abs(goal - v) < 1e-4) and goal or v +end + -- Real frame time, like every other presentational tween in this mod: a -- fast-forwarded battle must not spin the camera. function BattleCam.update(dt) @@ -146,6 +383,14 @@ 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 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 local function phase(t, period) @@ -163,17 +408,35 @@ end -- -- `groundY` is the height of the arena floor, so a fight staged on a ledge -- or a raised walkway is shot from above THAT rather than from inside it. -function BattleCam.rig(arena, groundY) +-- `canonical` asks for the shot the rig was SOLVED for -- no drift, no +-- breath, no steer, no zoom -- from a caller that is reasoning about the +-- arena rather than drawing it. BattleArena's clearance test is the one +-- that needs it: whether a fight can be staged somewhere is a fact about +-- the ground, and answering it through whatever angle the player happened +-- to leave the last battle on would pick a different arena depending on +-- where they had swung the camera an hour ago. +function BattleCam.rig(arena, groundY, canonical) groundY = groundY or 0 local R = BattleCam.rigFor(arena) 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 - local yaw = BattleCam.still and 0 - or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD) + -- 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 = 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) -- the breath scales the whole offset, height included, so the eye moves -- along its own line to the arena and the pitch of the shot never changes - local k = BattleCam.still and 1 + local k = fixed and 1 or 1 + BattleCam.PAN_DOLLY * phase(BattleCam.t, BattleCam.DOLLY_PERIOD) local dx = (R.side * c - R.back * s) * k @@ -182,16 +445,45 @@ function BattleCam.rig(arena, groundY) 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] local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez)) local horiz = math.sqrt(ex * ex + ez * ez) + -- The lens carries the player's zoom: how much world the frame holds is + -- the one thing that actually changes the framing here, because the field + -- of view is DERIVED from that reach and the distance. Moving the eye + -- instead would leave the picture the same size and only change its + -- perspective -- which is what the dolly breath above is deliberately + -- for, and is not what "zoom" means to anyone holding a wheel. + local frameH = fixed and R.frameH or BattleCam.frameH(arena) return { eye = eye, focus = focus, - fov = 2 * math.atan((R.frameH / 2) / dist), + fov = 2 * math.atan((frameH / 2) / dist), -- the world curve is a free-roam flourish that bends the horizon away -- from the player; a fixed camera on a staged shot has no player to bend -- around, and the bend would tip the arena floor out from under the mons diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 19adad0..66f13ca 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -472,7 +472,10 @@ function BattleScene.render(state, arena, textures, token) local cx, cy = arena.mid[1], arena.mid[2] -- the world extents the sun frustum is fitted to; the camera itself is -- framed by cam.fov, so these only have to describe the ground in shot - local vh = BattleCam.rigFor(arena).frameH * ph / (BattleScene.GB_H * s) + -- the player's zoom is part of this: the sun's box is fitted to what the + -- frame holds, so a shot pulled wide has to light the ground it just + -- brought into view rather than the ground the rig alone would have + local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s) local vw = vh * pw / ph -- the cards need the camera's eye to face it, so the rig has to be live diff --git a/lib/CamControl.lua b/lib/CamControl.lua new file mode 100644 index 0000000..235958e --- /dev/null +++ b/lib/CamControl.lua @@ -0,0 +1,415 @@ +-- The player's own camera controls: zoom everywhere, and the battle's orbit. +-- +-- This mod has four cameras, and by the time a wheel notch arrives they all +-- want it. So one module owns the INPUTS and answers the only question that +-- matters -- which camera is this aimed at -- rather than each camera +-- growing its own wheel handler and racing the others for the event: +-- +-- a staged battle the camera the fight is shot with (BattleCam): the +-- wheel and Q/E work its lens, and the right stick, +-- a drag or the mouse walk it around the arena. +-- +-- the 3RD rung the boom behind the player's shoulder +-- (ThirdPerson): the wheel, Q/E and a pinch let it +-- out and pull it in. +-- +-- an orbit rung the engine's own survey zoom, which the wheel has +-- always driven -- so here the module mostly gets +-- out of the way, and only ADDS the two keys and the +-- pinch that the engine has no handler for. +-- +-- the 1ST rung nothing. The eye is in the player's head; there is +-- no distance to change, and a pinch there would +-- silently wind the survey zoom for whenever they +-- stepped back out. Inputs pass through untouched. +-- +-- Every claim is answered by a GATE rather than by a mode flag, and every +-- wrap forwards whatever it does not claim -- so with voxel mode off, and +-- on every screen that is not the overworld or a battle, each byte flows +-- exactly where it always did. +-- +-- Installed AFTER FirstPerson (see main.lua), which makes these wraps the +-- outer ones: a battle's controls get first refusal on the mouse and the +-- touch screen, which is right, because while a fight is staged the +-- free-roam look is not driving anyway. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Voxel = V.require("VoxelState") +local Voxel3D = V.require("Voxel3D") +local FirstPerson = V.require("FirstPerson") +local ThirdPerson = V.require("ThirdPerson") +local BattleCam = V.require("BattleCam") + +local CamControl = {} + +-- ------- tuning +-- +-- PINCH_SLACK is how far apart two fingers must travel, as a ratio of +-- their starting gap, before the gesture counts as a pinch at all -- below +-- it a two-finger tap wobbles rather than zooms. +-- +-- SURVEY_PINCH is how many of the engine's integer survey steps one +-- doubling of the finger gap is worth. The survey ladder is coarse (whole +-- pixels per world pixel), so a pinch has to be geared down or the first +-- centimetre of travel crosses the whole range. +CamControl.PINCH_SLACK = 0.02 +CamControl.SURVEY_PINCH = 2.2 + +-- ------- gates + +-- A fight staged on the map, drawn and on screen. Asked of the shot rather +-- than of the battle state, because the shot is exactly "there is a 3D +-- 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 BattleCam.steerable) and true or false +end + +CamControl.battleLive = battleLive + +-- The free-roam overworld, with the 3D pass carrying it: the gate every +-- zoom that is not a battle's answers to. +local function roaming() + return Voxel.active() and Voxel3D.available() and FirstPerson.onTop() +end + +-- Which camera a zoom is aimed at: "battle", "boom", "survey", or nil for +-- nothing that zooms (1ST, or a screen with no camera of ours behind it). +function CamControl.zoomTarget() + if battleLive() then return "battle" end + if not roaming() then return nil end + if Voxel.isThirdPerson(Voxel.level) then return "boom" end + if Voxel.isFirstPerson(Voxel.level) then return nil end + return "survey" +end + +-- ------- zoom +-- +-- `notches` is signed the way every zoom in this file is: POSITIVE pulls +-- the camera OUT. The engine's own survey step runs the other way, and is +-- negated at the one place it is called rather than everywhere else being +-- bent to match it. +-- +-- Returns true when the input was ours, which is what tells a wrap to stop +-- rather than forward. + +local function surveyStep(notches) + local ok = pcall(function() + local Game = require("src.core.Game") + Game:zoomStep(notches > 0 and -1 or 1) + end) + return ok +end + +function CamControl.zoomBy(notches) + if not notches or notches == 0 then return false end + local target = CamControl.zoomTarget() + if target == "battle" then + BattleCam.stepZoom(notches) + return true + elseif target == "boom" then + ThirdPerson.stepZoom(notches) + return true + elseif target == "survey" then + -- one call per notch: the engine's ladder is integer rungs, and a + -- wheel spun hard should climb them all rather than one + for _ = 1, math.min(8, math.abs(notches)) do surveyStep(notches) end + return true + end + return false +end + +-- A pinch's own scale: > 1 is fingers spreading, which means zoom IN +-- (pull the world closer), which is a NEGATIVE notch count. +function CamControl.pinchBy(factor) + if not (factor and factor > 0) then return false end + local target = CamControl.zoomTarget() + if target == "boom" then + return ThirdPerson.scaleZoom(1 / factor) + elseif target == "battle" then + -- battles take a pinch too: the wheel and the keys reach this camera + -- and a phone has neither, so without it the lens would be the one + -- control a touch screen could not work + return BattleCam.stepZoom(math.log(1 / factor) + / math.log(BattleCam.ZOOM_STEP)) + elseif target == "survey" then + CamControl.surveyAccum = (CamControl.surveyAccum or 0) + + math.log(factor) / math.log(2) * CamControl.SURVEY_PINCH + local moved = false + while CamControl.surveyAccum >= 1 do + CamControl.surveyAccum = CamControl.surveyAccum - 1 + surveyStep(-1) + moved = true + end + while CamControl.surveyAccum <= -1 do + CamControl.surveyAccum = CamControl.surveyAccum + 1 + surveyStep(1) + moved = true + end + return moved + end + return false +end + +CamControl.surveyAccum = 0 + +-- ------- the battle's orbit +-- +-- Only ever the battle's: the free-roam rungs already steer their own look +-- through FirstPerson, and these wraps sit outside it precisely so a fight +-- can borrow the same devices without either of them growing a mode check. + +-- The right stick, read as a rate off the axes FirstPerson's own wrap is +-- 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, 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 + +local installed = false + +function CamControl.install() + if installed then return end + installed = true + + local Game = require("src.core.Game") + + -- ------- the wheel + -- + -- The engine's own handler is the survey zoom, so the wrap only has to + -- take the notch away when some OTHER camera wants it; "survey" falls + -- through to exactly the code that always ran. + do + local inner = Game.wheelmoved + function Game:wheelmoved(dx, dy) + local target = CamControl.zoomTarget() + if (target == "battle" or target == "boom") and dy and dy ~= 0 then + CamControl.zoomBy(dy > 0 and -1 or 1) + return + end + return inner(self, dx, dy) + 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 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 + -- 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 + end + + -- ------- the touch screen + -- + -- Two gestures, told apart by how many fingers are down on OPEN screen + -- (the overlay's own d-pad and buttons are never either): + -- + -- one finger, in a battle drags the shot around the arena + -- two fingers pinch to zoom, wherever zooming means + -- something -- and while they are down the + -- free-roam look stands aside, so a pinch in + -- 3RD does not also spin the view + local TouchControls = require("src.core.TouchControls") + + local free = {} -- id -> {x, y} for every finger on open screen + local pinch = nil -- { a, b, gap } while two of them are pinching + + local function freeCount() + local n = 0 + for _ in pairs(free) do n = n + 1 end + return n + end + + local function gapOf(a, b) + local dx, dy = free[a].x - free[b].x, free[a].y - free[b].y + return math.sqrt(dx * dx + dy * dy) + end + + -- Two free fingers and a camera that zooms: start measuring. The look + -- drag is dropped for the duration -- FirstPerson never sees the moves + -- below -- and re-seated on whichever finger survives, so the view does + -- not jump by however far the pinch travelled. + local function startPinch() + if pinch or freeCount() < 2 then return end + local ids = {} + for id in pairs(free) do ids[#ids + 1] = id end + local gap = gapOf(ids[1], ids[2]) + if gap < 16 then return end + pinch = { a = ids[1], b = ids[2], gap = gap } + CamControl.surveyAccum = 0 + pcall(FirstPerson.dropLook) + end + + local function endPinch(lifted) + if not pinch then return end + local survivor = nil + for id in pairs(free) do + if id ~= lifted then survivor = id break end + end + pinch = nil + if survivor and free[survivor] then + pcall(FirstPerson.reseatLook, survivor, + free[survivor].x, free[survivor].y) + end + end + + local function onControl(x, y) + local hit = nil + pcall(function() hit = TouchControls:hitTest(x, y) end) + return hit + end + + -- Whether this module has any interest in touches at all this frame. + -- Kept deliberately wide -- a battle, or anything that zooms -- because + -- the wrap forwards everything it does not claim regardless. + local function wantsTouch() + return battleLive() or CamControl.zoomTarget() ~= nil + end + + do + local inner = Game.touchpressed + function Game:touchpressed(id, x, y) + if wantsTouch() and not onControl(x, y) then + free[id] = { x = x, y = y } + if CamControl.zoomTarget() then startPinch() end + -- forwarded even so: a single free finger is the free-roam look's + -- to claim (FirstPerson's wrap is inside this one), and in a + -- battle it is nobody's until it MOVES + end + return inner(self, id, x, y) + end + end + + do + local inner = Game.touchmoved + function Game:touchmoved(id, x, y) + local f = free[id] + if f then + local px, py = f.x, f.y + f.x, f.y = x, y + if pinch and (id == pinch.a or id == pinch.b) then + local gap = gapOf(pinch.a, pinch.b) + local factor = gap / math.max(1, pinch.gap) + if math.abs(factor - 1) > CamControl.PINCH_SLACK then + CamControl.pinchBy(factor) + pinch.gap = gap + end + return -- claimed: never a look drag too + end + if battleLive() and not pinch then + 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 + return inner(self, id, x, y) + end + end + + do + local inner = Game.touchreleased + function Game:touchreleased(id, x, y) + if free[id] then + if pinch and (id == pinch.a or id == pinch.b) then endPinch(id) end + free[id] = nil + end + return inner(self, id, x, y) + end + end + + -- a reset that drops held input state drops ours with it, exactly as the + -- free-roam look's does + do + local inner = Game.focus + function Game:focus(f) + free, pinch = {}, nil + CamControl.surveyAccum = 0 + return inner(self, f) + end + end +end + +return CamControl diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index 945fa50..1703032 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -1,4 +1,4 @@ --- Voxel world mode: the first-person camera -- the 1ST rung. +-- Voxel world mode: the free-roam camera -- the 1ST and 3RD rungs. -- -- Every other rung is the same camera at a different pitch: an orbit over -- the view centre, described by one number. 1ST is a different rig @@ -9,6 +9,13 @@ -- uniforms, project(), the sky's vanishing line, the water's lean -- reads -- eye and focus the same way it always has. -- +-- 3RD is that same rig with the eye pulled back onto a boom behind the +-- player's shoulder (lib/ThirdPerson.lua). Everything in this file is +-- already general over where the eye stands -- the attitude, the look +-- inputs, the move intent, the cards that turn to face the eye -- so the +-- third-person rung is one number applied at the very end of frame(), +-- rather than a second camera to keep in step with this one. +-- -- What this module owns: -- -- the ATTITUDE yaw and pitch, fed by whichever look input speaks: @@ -47,6 +54,7 @@ local Mat4 = V.require("Mat4") local Voxel = V.require("VoxelState") local Voxel3D = V.require("Voxel3D") local WorldCurve = V.require("WorldCurve") +local ThirdPerson = V.require("ThirdPerson") local FirstPerson = {} @@ -142,21 +150,62 @@ end -- ------- gates --- Whether the 1ST rung is selected and the 3D pass can carry it. +-- Whether a free-roam rung -- 1ST or 3RD -- is selected and the 3D pass can +-- carry it. Both stand the camera with the player, so both read the look +-- inputs, both walk free, and both turn the cards; how far behind the head +-- the eye ends up is ThirdPerson's business alone. function FirstPerson.engaged() - return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available() + return Voxel.isFreeCam(Voxel.level) and Voxel3D.available() +end + +-- Whether the overworld is what the player is looking at: nothing pushed +-- over it, so the buttons are free-roam's. Shared with everything else in +-- the mod that asks the same question of the same stack (CamControl's +-- zooms above all), rather than each restating the pcall. +function FirstPerson.onTop() + local ok, top, ow = pcall(function() + local Game = require("src.core.Game") + return Game.stack and Game.stack:top(), Game.overworld + end) + return ok and top ~= nil and top == ow end -- Whether first person should be READING the player's inputs right now: -- engaged, with the overworld on top of the stack (a menu, a dialog or a -- battle above it owns the buttons, exactly as it does for grid walking). function FirstPerson.driving() - if not FirstPerson.engaged() then return false end - local ok, top, ow = pcall(function() - local Game = require("src.core.Game") - return Game.stack and Game.stack:top(), Game.overworld - end) - return ok and top ~= nil and top == ow + return FirstPerson.engaged() and FirstPerson.onTop() +end + +-- The right stick's live X, for a camera that is not this one: while a +-- battle is staged the free-roam look is not driving, but the axes are +-- still arriving on the wrap below (which records whatever the rung), and +-- the battle's orbit wants them. Reading them here rather than wrapping +-- the same seam twice. +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 +-- the finger this module claimed as the look drag. Rather than have the +-- pinch fight for it, CamControl asks for it: dropLook while the gesture +-- runs, reseatLook on whichever finger survives it. Re-seating rather than +-- simply releasing is what stops the view snapping by however far the +-- pinch travelled -- the finger carries on as the look drag from where it +-- now is, which is what it looks like it should do. +function FirstPerson.dropLook() + lookTouch = nil +end + +function FirstPerson.reseatLook(id, x, y) + if id == nil then lookTouch = nil return end + lookTouch = { id = id, x = x, y = y } end -- The eased blend, 0 at the orbit and 1 in the head. @@ -187,7 +236,16 @@ end -- deep enough into the blend that the card would fill the lens from -- inside. The sun pass keeps drawing it either way -- a first-person -- player still throws a shadow on the ground ahead. +-- +-- Never while 3RD's boom is genuinely out, whatever the blend: the whole +-- point of a boom is that the character it is booming away from is on +-- screen. (Nor the silhouette that rides the same answer -- seeing your own +-- outline through the building you just walked behind is what a +-- third-person camera owes the player.) A boom SQUEEZED into the head by a +-- wall answers false there and the card comes out again, because at that +-- range it is the first-person problem word for word. function FirstPerson.hidePlayer() + if ThirdPerson.showsPlayer() then return false end return FirstPerson.cardBlend() > 0.9 end @@ -202,17 +260,79 @@ function FirstPerson.lookBy(dyaw, dpitch) FirstPerson.pitch + dpitch)) end --- The view direction's flat compass facing, for everything that still --- thinks in the grid's four directions: the cell A interacts with, the --- sprite the sun sees, the direction a blocked slide bonks in. -function FirstPerson.compassFacing() - local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) +-- A bearing as one of the grid's four directions -- the 45-degree +-- quantisation every facing in this file is made with, in one place so the +-- compass, the body and the card frames can never disagree about where a +-- boundary is. +local function facingOf(a) + local s, c = math.sin(a), math.cos(a) if math.abs(s) > math.abs(c) then return s > 0 and "right" or "left" end return c > 0 and "down" or "up" end +-- The view direction's flat compass facing, for everything that still +-- thinks in the grid's four directions: the cell A interacts with, the +-- sprite the sun sees, the direction a blocked slide bonks in. +function FirstPerson.compassFacing() + return facingOf(FirstPerson.yaw) +end + +-- Which way the BODY points, as a continuous world bearing, given the +-- world-space direction it is walking (0, 0 while standing). In the head, +-- the body is the head: you face what you look at. On the boom you can see +-- yourself, and a character sliding sideways while facing the lens reads as +-- a bug rather than as a strafe -- so a walking body turns to face its own +-- travel, and a standing one comes back round to the camera's bearing, +-- which is the one A talks along. +function FirstPerson.bodyBearing(wx, wz) + if ThirdPerson.extended() and wx and wz and (wx ~= 0 or wz ~= 0) then + return math.atan2(wx, wz) + end + return FirstPerson.yaw +end + +-- The same answer as one of the four facings, which is what the grid game +-- (and the sprite sheet) reasons in. +function FirstPerson.bodyFacing(wx, wz) + return facingOf(FirstPerson.bodyBearing(wx, wz)) +end + +-- ------- the body's live bearing +-- +-- The bearing the player's own body is actually pointing along RIGHT NOW, +-- or nil whenever the free walk is not the thing pointing it (a scripted +-- move, a cutscene, the grid walk with the rung off). FreeMove maintains +-- it; only the player's own card reads it. +-- +-- It exists because the card's frame is chosen by the angle BETWEEN the +-- body and the eye, and quantising the body to a compass direction first +-- throws away exactly the precision that choice needs. A standing body is +-- pointed along the camera's own yaw, so the true angle between them is a +-- flat 180 degrees and the card should show its back and nothing else -- +-- but snap the body to one of four directions on the game tick, then +-- measure it against an eye that has kept turning since, and the pair can +-- read as 135 degrees and pick the PROFILE frame instead. Spin the camera +-- fast and the character flicks to a mirrored side view for a frame or +-- two. Keeping the bearing continuous gives the measurement a full 45 +-- degrees of slack before it can cross a boundary, which no frame's worth +-- of turning comes close to spending. +FirstPerson.bodyYaw = nil + +-- Point the body along the direction it is walking (or, standing, along +-- the camera): records the continuous bearing and hands back the compass +-- facing the caller wants for p.facing. +function FirstPerson.pointBody(wx, wz) + FirstPerson.bodyYaw = FirstPerson.bodyBearing(wx, wz) + return facingOf(FirstPerson.bodyYaw) +end + +-- Hand the body back to whatever else is turning it. +function FirstPerson.releaseBody() + FirstPerson.bodyYaw = nil +end + -- The unit look direction, and its flat (ground-plane) part. local function lookDir() local cp = math.cos(FirstPerson.pitch) @@ -243,21 +363,43 @@ function FirstPerson.cardYaw(wx, wz) return math.atan2(dx, dz) end +-- Which of the four sprite frames a body at world bearing `phi` shows an +-- eye looking at (wx, wz): the bearing rotated into the viewer's own frame, +-- quantised. nil when there is no rig to be seen from. +local function frameFor(phi, wx, wz) + local eye = rig and rig.eye + if not (eye and phi) then return nil end + local dx, dz = eye[1] - wx, eye[3] - wz + if dx * dx + dz * dz < 1e-9 then return nil end + local rel = wrapPi(phi - math.atan2(dx, dz)) + local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4 + return FACING_ORDER[idx + 1] +end + -- Which of the four sprite frames an entity shows THIS eye: its facing -- rotated into the viewer's own frame, quantised. The flat game's frames -- are "how this pose looks from the south", so the apparent facing is the -- pose rotated by where the viewer actually stands -- walk behind an NPC -- and you see their back, circle to their flank and you see the profile, -- exactly as the four frames Gen 1 drew intend. +-- +-- An NPC's facing IS one of the four and nothing finer, so this is the +-- whole story for everyone in the world except the one body the camera is +-- attached to -- see playerFacing. function FirstPerson.apparentFacing(facing, wx, wz) - local eye = rig and rig.eye - local phi = FACING_ANGLE[facing] - if not (eye and phi) then return facing end - local dx, dz = eye[1] - wx, eye[3] - wz - if dx * dx + dz * dz < 1e-9 then return facing end - local rel = wrapPi(phi - math.atan2(dx, dz)) - local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4 - return FACING_ORDER[idx + 1] + return frameFor(FACING_ANGLE[facing], wx, wz) or facing +end + +-- The PLAYER's own card, which is the one case where the body's bearing is +-- known to better than a compass point (bodyYaw, above) -- and the one case +-- where it matters, because the eye is derived FROM that bearing rather +-- than independent of it. Measured continuously, a standing body reads as +-- a flat 180 degrees from its own camera and shows its back, steadily, +-- however fast the camera is spun. Falls back to the four-direction answer +-- whenever something other than the free walk is turning the body. +function FirstPerson.playerFacing(facing, wx, wz) + return frameFor(FirstPerson.bodyYaw, wx, wz) + or FirstPerson.apparentFacing(facing, wx, wz) end -- ------- the move intent @@ -353,6 +495,12 @@ function FirstPerson.update(dt) rig = nil end + -- the boom, on the same tick and for the same reason: it has to keep + -- easing after 3RD is left, and it needs the blend to know whether a + -- change of rung is a SLIDE (already inside the world, 1ST <-> 3RD) or + -- part of the dive in from the orbit, which carries the eye anyway + ThirdPerson.update(dt, FirstPerson.blend) + -- mouse capture follows engagement: captured whenever the rung is on and -- the window has focus, released the moment either ends. Checked against -- the live mode rather than toggled on edges, so a capture lost to the @@ -453,6 +601,12 @@ function FirstPerson.frame(me, cx, cy, vw, vh) head[2] + ly * FirstPerson.FOCUS_DIST, head[3] + lz * FirstPerson.FOCUS_DIST } + -- 3RD: the eye walks back off the head along the very direction it looks, + -- as far as the world allows. Fully in (1ST, and every frame of the + -- diorama) this hands back the head and the focus untouched, so the two + -- rungs are one rig with one number between them. + local camEye, camFocus = ThirdPerson.place(head, lx, ly, lz, fpFocus) + local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh) local function mix(p, q) return { p[1] + (q[1] - p[1]) * e, @@ -471,9 +625,9 @@ function FirstPerson.frame(me, cx, cy, vw, vh) local k = WorldCurve.k(vh) * (1 - e) rig = { - eye = mix(oEye, head), - focus = mix(oFocus, fpFocus), - fov = oFov + (FirstPerson.FOV * FirstPerson.fovScale - oFov) * e, + eye = mix(oEye, camEye), + focus = mix(oFocus, camFocus), + fov = oFov + (FirstPerson.FOV - oFov) * e, up = up, curve = k, } @@ -512,7 +666,9 @@ function FirstPerson.signature() math.floor(b * 64), math.floor(FirstPerson.yaw * 64), math.floor(FirstPerson.pitch * 64), - math.floor(FirstPerson.fovScale * 64), + -- and how far back the boom stands the eye: a wall shortening it moves + -- the camera the sun's box is fitted around, standing still or not + ThirdPerson.signature(), }, ",") end diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua index 712b583..bf5f9c4 100644 --- a/lib/FreeMove.lua +++ b/lib/FreeMove.lua @@ -1,11 +1,16 @@ --- Voxel world mode: free movement for the first-person rung. +-- Voxel world mode: free movement for the free-roam rungs. -- -- The engine walks a grid: sixteen frames per cell, four directions, --- input locked mid-step. Inside a first-person camera that gait reads as --- riding a rail, so while 1ST drives, this module replaces the WALK and --- nothing else: the player's position becomes continuous, steered by the --- camera's own yaw -- push forward and you go where you look, at any --- angle, sliding along whatever you graze. +-- input locked mid-step. Inside a camera that stands with the player that +-- gait reads as riding a rail, so while 1ST or 3RD drives, this module +-- replaces the WALK and nothing else: the player's position becomes +-- continuous, steered by the camera's own yaw -- push forward and you go +-- where you look, at any angle, sliding along whatever you graze. +-- +-- Both rungs walk identically: the boom behind the shoulder (3RD) changes +-- where the eye stands, not which way it points, and the walk was always +-- rotated by the YAW. The one thing it does change is which way the body +-- POINTS while it moves -- see bodyFacing in the tick. -- -- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact -- about cells -- what blocks, what warps, what rustles, what bites -- and @@ -73,6 +78,11 @@ end function FreeMove.drop() pos = nil + -- and the body with it: while something else is walking the player -- + -- a scripted move, a ledge hop, the grid walk off the rung -- the + -- engine's own four-direction facing is the whole truth about which way + -- they point, so the card must stop reading our finer one + FirstPerson.releaseBody() end -- named for the suite: the module's live position, nil while dropped @@ -208,6 +218,14 @@ local function pushSpecials(state, dir, why) return true 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 @@ -233,8 +251,12 @@ function FreeMove.tick(state) local input = Game.input -- the head is the facing: what A talks to, what the sun's card shows, - -- which way a bonk points - p.facing = FirstPerson.compassFacing() + -- which way a bonk points. (A body that is WALKING may turn along its + -- travel instead -- see below, once there is a travel to turn along; a + -- standing one always faces where the camera looks, which is what makes + -- A predictable.) pointBody rather than compassFacing, so the card also + -- gets the CONTINUOUS bearing behind that compass point. + p.facing = FirstPerson.pointBody(0, 0) -- HORDE MODE takes both of these away for as long as it runs: there is -- no pausing (START), and nobody stops to read a sign with the horde @@ -276,6 +298,18 @@ function FreeMove.tick(state) if not moving then return end + -- and once there IS a direction of travel, the body may point along it + -- rather than along the head: on the boom (3RD) you can see yourself, so + -- a strafe has to look like walking sideways. In the head it is the head + -- 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 or FreeMove.WALK local dx, dz = wx * speed, wz * speed @@ -315,8 +349,8 @@ function FreeMove.tick(state) FreeMove.drop() return end - -- the push handlers may have turned the facing; the head still rules - p.facing = FirstPerson.compassFacing() + -- the push handlers may have turned the facing; the walk still rules + p.facing = FirstPerson.pointBody(wx, wz) end end diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f5a1aae..2114264 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -249,6 +249,31 @@ OverworldBattle.TEXT_RECT = { mimic = { 0, 56, 128, 40 }, } +-- How far apart the two anchors are: the spacing every move animation was +-- authored against, and so the yardstick the live pair is measured with. +OverworldBattle.ANCHOR_SPAN = math.sqrt( + (OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2 + + (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2) + +-- The effects layer's scale for this shot: how far apart the two mons +-- actually are on screen, over how far apart the slots they were authored +-- for were. Clamped hard at both ends -- an effect is pixel art and a wild +-- factor is worse than a slightly wrong one -- and held at exactly 1 when +-- the marks coincide, which is a projection about to degenerate rather +-- than a pair that has genuinely closed up. +OverworldBattle.ANIM_SCALE_MIN = 0.5 +OverworldBattle.ANIM_SCALE_MAX = 2.0 + +function OverworldBattle.animScale(shot, px, py) + if not (shot and shot.enemy and px and py) then return 1 end + local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py + local span = math.sqrt(dx * dx + dy * dy) + if not (span > 1) then return 1 end + local k = span / OverworldBattle.ANCHOR_SPAN + return math.max(OverworldBattle.ANIM_SCALE_MIN, + math.min(OverworldBattle.ANIM_SCALE_MAX, k)) +end + function OverworldBattle.textRects(battle) if not battle or battle.blankForAskName then return {} end local r = OverworldBattle.TEXT_RECT @@ -478,6 +503,19 @@ 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 + -- turn it into travel (CamControl, which owns every one of those inputs) + pcall(V.require("CamControl").tick, dt) BattleCam.update(dt) -- the battle only exists once it has been pushed; a session opened at -- pushBattle time has it, one opened from battle.started was handed it @@ -1120,16 +1158,36 @@ function OverworldBattle.install() -- give them. They ride to where the PAIR went: the midpoint of the two -- mons' projected positions, less the midpoint of the slots they used to -- sit in. A hit still lands on the mon it is aimed at. + -- + -- And they ride the pair's SEPARATION as well, because the mons + -- themselves do. Both are geometry standing on the map, so the camera + -- sizes them: zoom in and they grow, swing round to side-on and the two + -- marks close up as the axis foreshortens. A layer that only slid would + -- have held the authored 106-pixel spacing through all of it -- a beam + -- fired between two mons that are no longer that far apart, ending in + -- the air beside the one it was aimed at. Scaling about the same + -- midpoint keeps every authored offset the same fraction of the gap it + -- was authored as. local a = OverworldBattle.ANCHOR -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side -- contributes no movement at all and the pair's centre has gone half as -- far as the foe's mark did. local px, py = shot.player[1], shot.player[2] if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end - local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2 - local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2 + local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2 + local ax = (a.enemy[1] + a.player[1]) / 2 + local ay = (a.enemy[2] + a.player[2]) / 2 love.graphics.push() - love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5)) + love.graphics.translate(cx - ax, cy - ay) + -- Clamped, and skipped outright if the marks ever coincide: a + -- degenerate projection must leave the effects the size they were + -- rather than collapse them to nothing or blow them across the screen. + local k = OverworldBattle.animScale(shot, px, py) + if k ~= 1 then + love.graphics.translate(ax, ay) + love.graphics.scale(k, k) + love.graphics.translate(-ax, -ay) + end local ok, err = pcall(innerAnim, self, colorized) love.graphics.pop() if not ok then error(err, 0) end diff --git a/lib/Structures.lua b/lib/Structures.lua index ad3d5ec..2c99c8d 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -3289,6 +3289,83 @@ 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. +-- `everyPixel` is for a standee whose silhouette ANIMATES. The mesh is +-- built once, over the UNION of every frame's mask, and each frame is cut +-- out again in texture space -- so a run that is six pixels wide in the +-- union may be two pixels wide in the frame on screen, and the four pixels +-- that dropped out took the union's end walls with them. What is left +-- exposed is an interior boundary, which had no wall because in the union +-- it was not a boundary at all. That is the gap that survived closing the +-- run ends: the first frame looked solid and every other frame did not. +-- +-- So an animated standee gets a wall on BOTH sides of EVERY pixel. A wall +-- between two lit pixels is enclosed by the front and back faces and never +-- seen; the moment its neighbour is keyed out it becomes the edge, already +-- in place and already wearing the right colour. Each is inset a hair into +-- its own pixel so the two that meet at a boundary are not coplanar -- the +-- voxel pass draws with culling off, and two quads in the same plane would +-- z-fight rather than politely take turns. +local SIDE_INSET = 0.03 + +local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF, + ax0, ay0, atlasW, atlasH, py, lit, everyPixel) + local function texel(px) + return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH + end + local function left(px, at) + local u, v = texel(px) + quads[#quads + 1] = { -- facing -X + { at, yBot, zB }, { at, yBot, zF }, + { at, yTop, zF }, { at, yTop, zB }, + uv = { { u, v }, { u, v }, { u, v }, { u, v } }, + shade = OBJ_SHADE.side, + } + end + local function right(px, at) + local u, v = texel(px) + quads[#quads + 1] = { -- facing +X + { at, yBot, zF }, { at, yBot, zB }, + { at, yTop, zB }, { at, yTop, zF }, + uv = { { u, v }, { u, v }, { u, v }, { u, v } }, + shade = OBJ_SHADE.side, + } + end + if everyPixel then + for px = ix, ix2 do + left(px, px + SIDE_INSET) + right(px, px + 1 - SIDE_INSET) + end + return + end + if not lit(ix - 1, py) then left(ix, ix) end + if not lit(ix2 + 1, py) then right(ix2, ix2 + 1) 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 +3436,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 @@ -3526,17 +3617,51 @@ local function flowerTemplate(map, data, tileId) uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, shade = OBJ_SHADE.back, } - -- petal tips: a top strip where the row above is clear. The - -- strip samples its own row's texel, so a tip that is not in - -- the current frame discards with the face beneath it - if not on(ix, py - 1) then - quads[#quads + 1] = { - { ix, yTop, zB }, { ix2 + 1, yTop, zB }, - { ix2 + 1, yTop, zF }, { ix, yTop, zF }, - uv = { { u0, v0 }, { u1, v0 }, { u1, v0 }, { u0, v0 } }, + -- ------- the shell, closed on all four remaining faces + -- + -- A flower SWAYS: the geometry spans the union of every animation + -- frame's mask and each frame is cut back out of it in texture + -- space (see the header). So "is there a pixel next door" has two + -- different answers -- one in the union this mesh was built from, + -- and one in the frame actually on screen -- and only the second + -- decides what is exposed. + -- + -- Closing the union's own edges is therefore not enough, and was + -- the bug the first cut of this shipped: the base frame looked + -- solid and every other frame still had gaps, because a pixel that + -- drops out of a frame takes the union's wall with it and leaves an + -- interior boundary that never had one. + -- + -- So every pixel gets a cap on all four of its remaining faces, + -- whatever its neighbours do. A cap between two lit pixels sits + -- inside the slab, enclosed by the front and back faces, and is + -- never seen; the moment its neighbour is keyed out it IS the edge, + -- already there and already wearing the right colour. Each samples + -- its own pixel's texel, so it appears and vanishes with the pixel + -- it belongs to rather than with the one it is closing off. + -- + -- Inset a hair into its own pixel, because the voxel pass draws + -- with culling off: the two caps that meet at a boundary would be + -- coplanar and z-fight rather than politely take turns. + for px = ix, ix2 do + local tu = (ax0 + px + 0.5) / atlasW + local tv = (ay0 + py + 0.5) / atlasH + local xa, xb = px, px + 1 + local yT = yTop - SIDE_INSET + local yB = yBot + SIDE_INSET + quads[#quads + 1] = { -- the pixel's own lid + { xa, yT, zB }, { xb, yT, zB }, { xb, yT, zF }, { xa, yT, zF }, + uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } }, shade = OBJ_SHADE.top, } + quads[#quads + 1] = { -- and its floor + { xa, yB, zF }, { xb, yB, zF }, { xb, yB, zB }, { xa, yB, zB }, + uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } }, + shade = OBJ_SHADE.bottom, + } end + sideQuads(quads, ix, ix2, yBot, yTop, zB, zF, + ax0, ay0, atlasW, atlasH, py, on, true) ix = ix2 + 1 else ix = ix + 1 diff --git a/lib/ThirdPerson.lua b/lib/ThirdPerson.lua new file mode 100644 index 0000000..2b0c69d --- /dev/null +++ b/lib/ThirdPerson.lua @@ -0,0 +1,407 @@ +-- Voxel world mode: the third-person camera -- the 3RD rung. +-- +-- 3RD is 1ST with the eye pulled off the back of the head. Everything that +-- makes the first-person rung work -- the steered attitude, the placed +-- camera on Voxel3D's seam, the cards that turn to face the eye, the +-- continuous camera-relative walk -- is already general over WHERE the eye +-- stands, so this module adds exactly one thing to it: a BOOM. +-- +-- What the boom owns: +-- +-- the LENGTH how far behind the pivot the eye sits, eased in and out +-- so stepping between 1ST and 3RD slides rather than cuts, +-- and clamped every frame by what the world will allow. +-- +-- the COLLISION a march back along the boom line through the terrain +-- height field and the map's own walkability, so backing +-- into a wall walks the camera in toward the player's +-- shoulders instead of through the wall into the void. +-- The recovery is deliberately slower than the intrusion: +-- a camera must never be a frame late leaving geometry, +-- and must never snap back out the instant a corner clears. +-- +-- the SHOULDER the small lateral rail offset that keeps the character +-- off dead centre, faded out with the boom so a camera +-- jammed against a wall does not also slide sideways into +-- it. +-- +-- Deliberately NOT here: the attitude, the look inputs, the blend, the +-- move intent (all lib/FirstPerson.lua, which drives this module and reads +-- its answer while building the frame's rig), and movement itself +-- (lib/FreeMove.lua, unchanged -- the walk is camera-relative either way, +-- and the camera's yaw is the same number on both rungs). +-- +-- Nothing here is required for the rung to draw: with no overworld to ask +-- (a headless run, the test suite) every query answers "clear" and the boom +-- extends to its full length over an empty world. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Voxel = V.require("VoxelState") + +local ThirdPerson = {} + +-- ------- the boom's numbers +-- +-- BOOM is world pixels behind the pivot at full extension. A cell is 16 and +-- a character card is 16 tall, so 48 stands the camera three cells back: +-- with the first-person lens (65 degrees vertical) that frames the player +-- at roughly a quarter of the frame height -- the modern action-game +-- middle ground, close enough to read the four-frame sprite and far enough +-- to see the cell you are about to walk into. +-- +-- PIVOT_LIFT raises the orbit point above the first-person eye, so the +-- boom looks slightly DOWN across the player's shoulder rather than +-- straight through the back of their head. +-- +-- SHOULDER is the lateral rail offset, in world pixels, positive to the +-- camera's right -- which puts the player left of centre, leaving the +-- larger half of the frame in front of them. +ThirdPerson.BOOM = 48 +ThirdPerson.PIVOT_LIFT = 4 +ThirdPerson.SHOULDER = 4 + +-- how long the eye takes to slide out to the boom (and back into the head +-- when 1ST is picked), in seconds -- the same order as FirstPerson's own +-- dive so stepping 75 -> 1ST -> 3RD reads as one continuous camera +ThirdPerson.BOOM_TIME = 0.35 + +-- ------- the player's own zoom +-- +-- A multiplier on BOOM, stepped by the wheel, Q/E or a pinch (see +-- CamControl, which owns every one of those and decides which camera a +-- given input is aimed at). The range is deliberately wider IN than OUT: +-- close is the shot people reach for, and far enough out the character is +-- a few pixels and the rung may as well be an orbit rung. +-- +-- Stepped in fractions rather than world pixels so a notch feels the same +-- at both ends -- the near end of a linear step would crawl and the far +-- end would leap. +ThirdPerson.ZOOM_MIN = 0.45 -- ~22px: over the shoulder, close +ThirdPerson.ZOOM_MAX = 2.4 -- ~115px: the character in a landscape +ThirdPerson.ZOOM_STEP = 1.18 -- one wheel notch / key press +ThirdPerson.ZOOM_TIME = 0.18 -- how fast the eye eases to a new one + +ThirdPerson.zoom = 1 -- eased, what place() actually uses +ThirdPerson.zoomGoal = 1 -- what the input asked for + +-- Step the zoom by `notches` (positive pulls the camera OUT). Returns true +-- when the goal actually moved, so a caller can tell "zoomed" from "already +-- at the stop" and let the input fall through. +function ThirdPerson.stepZoom(notches) + local was = ThirdPerson.zoomGoal + local goal = was * (ThirdPerson.ZOOM_STEP ^ (notches or 0)) + ThirdPerson.zoomGoal = math.max(ThirdPerson.ZOOM_MIN, + math.min(ThirdPerson.ZOOM_MAX, goal)) + return ThirdPerson.zoomGoal ~= was +end + +-- Scale the zoom by a continuous factor -- what a pinch hands over, where +-- the gesture's own scale IS the answer and there are no notches. +function ThirdPerson.scaleZoom(factor) + if not (factor and factor > 0) then return false end + return ThirdPerson.stepZoom(math.log(factor) / math.log(ThirdPerson.ZOOM_STEP)) +end + +-- ------- the collision's numbers +-- +-- STEP is how far apart the samples along the boom line are, in world +-- pixels, and REFINE how many bisections narrow the first blocked one -- +-- four halvings of a 4px step lands the eye within a quarter pixel of the +-- face, which is finer than the boom ever needs to be. +-- +-- PAD is the clearance kept between the eye and whatever stopped it. It +-- has to beat the placed camera's near plane (|eye - focus| * 0.05, which +-- at full extension is about 3.6 world pixels -- see Voxel3D) or the near +-- plane clips a hole in the very wall the boom stopped at. +-- +-- CLEAR is how high above a cell's ground the eye must be to pass OVER +-- something unwalkable rather than being stopped by it: a fence, a kerb or +-- a plant pot should not shove the camera in, a building should. Roughly +-- head height, so the eye clears the props and never the walls. +ThirdPerson.STEP = 4 +ThirdPerson.REFINE = 4 +ThirdPerson.PAD = 5 +ThirdPerson.CLEAR = 20 + +-- How fast the boom is allowed to grow BACK once whatever shortened it is +-- out of the way, in world pixels per second. Shortening is instant (a +-- camera inside a wall is a hole in the frame); lengthening is rationed, +-- so rounding a corner eases the eye back out instead of snapping it. +ThirdPerson.RETURN = 150 + +-- ------- state +-- +-- `out` is the eased extension, 0 in the head and 1 fully boomed -- the +-- number that carries 1ST into 3RD. `len` is the boom's actual length in +-- world pixels after the world has had its say, which is what place() +-- stands the eye at and update() eases back toward `want`. +ThirdPerson.out = 0 +ThirdPerson.len = 0 +ThirdPerson.want = 0 + +local function ease(t) + return t * t * (3 - 2 * t) +end + +-- ------- gates + +-- Whether the 3RD rung is the one selected. Not "is the boom out" -- that +-- is extended() below, which stays true through the ease after the rung is +-- left, the same way FirstPerson.blend outlives its own rung. +-- +-- A live headset declines the boom outright: VR builds its own eye cameras +-- from the tracked pose and never asks place() where to stand, and a +-- headset that seats its wearer three cells behind their own body is a +-- well-known way to make people ill. Answering false here is what keeps +-- everything ELSE the extension decides -- the player's own card, the body +-- that turns as it walks -- honest about the head VR actually puts you in. +-- Required lazily and guarded: VR reaches this module through FirstPerson, +-- and a headless run has no VR module worth loading at all. +local function headset() + local ok, on = pcall(function() return V.require("VR").active() end) + return ok and on or false +end + +function ThirdPerson.selected() + return Voxel.isThirdPerson(Voxel.level) and not headset() +end + +-- The eased extension, 0 at the head and 1 at the full boom. +function ThirdPerson.extension() + return ease(ThirdPerson.out) +end + +-- Whether the boom is out far enough to be a third-person camera at all -- +-- read off the TARGET extension rather than the live length, so it is +-- steady while the world shoves the eye about. What the body reads to +-- decide whether it turns along its own travel. +function ThirdPerson.extended() + return ThirdPerson.extension() > 0.5 +end + +-- How far back the eye must ACTUALLY be, in world pixels, for the player's +-- own card to be worth drawing: a shade under a cell, which is the point +-- where a 16-pixel card stops being a character and starts being a wall of +-- pixels across the lens. +ThirdPerson.SHOW_AT = 14 + +-- Whether the player's own card belongs in the frame. Not the same +-- question as extended(): back into a fence and the boom collapses into +-- the head whatever the rung says, and a card drawn there fills the lens +-- from inside exactly as it would in first person -- so it comes out, and +-- the rung reads as first person for as long as the world insists on it. +function ThirdPerson.showsPlayer() + return ThirdPerson.extension() > 0 and ThirdPerson.len >= ThirdPerson.SHOW_AT +end + +-- ------- the world the boom has to fit through +-- +-- Everything below asks the live overworld and pcall-guards the asking: +-- with no map (headless, the suite, a frame mid-warp) the boom simply +-- extends to its full length, which is the right answer for a world with +-- nothing in it. + +local function overworld() + local ok, ow = pcall(function() + return require("src.core.Game").overworld + end) + if not ok or not ow or not ow.map then return nil end + return ow +end + +-- Which map, and which of its cells, covers a world point. The player's own +-- map first, then the neighbours the scene streams in around it (same ox/oy +-- offsets VoxelScene draws them at) -- without that pass the boom would +-- shorten against "off the map" every time the player walked within three +-- cells of a route connection, which is most of the time. +-- +-- nil means no map covers it: genuinely off the world, where the border +-- ring is drawn and the camera has no business going. +local function cellAt(ow, wx, wz) + local map = ow.map + local cx, cy = math.floor(wx / 16), math.floor(wz / 16) + if map:inBounds(cx, cy) then return map, cx, cy end + for _, nb in ipairs(ow.neighbors or {}) do + if nb.map then + local nx = math.floor((wx - (nb.ox or 0)) / 16) + local ny = math.floor((wz - (nb.oy or 0)) / 16) + if nb.map:inBounds(nx, ny) then return nb.map, nx, ny end + end + end + return nil +end + +-- Whether the eye may not stand at this world point. Two refusals, and +-- they are different questions: +-- +-- the GROUND is the terrain height field the mesh is actually built from +-- (VoxelScene.groundAt -- the same answer a character stands on), so a +-- ledge, a raised bank or a cliff stops the boom exactly where it stops +-- the geometry, at any pitch. +-- +-- the WALKABILITY is the map's own, and stands in for everything built +-- ON the ground that the height field does not describe: house walls, +-- trees, signs, counters. Held to CLEAR above that cell's ground so the +-- short furniture of the world is passed over rather than bumped into. +local function occupied(ow, wx, y, wz) + local map, cx, cy = cellAt(ow, wx, wz) + if not map then return true end + local VoxelScene = V.require("VoxelScene") + local okG, gh = pcall(VoxelScene.groundAt, map, cx, cy) + gh = (okG and gh) or 0 + if y < gh + ThirdPerson.PAD then return true end + local okW, walkable = pcall(function() return map:isWalkableCell(cx, cy) end) + if okW and not walkable and y < gh + ThirdPerson.CLEAR then return true end + return false +end + +ThirdPerson._occupied = occupied -- named for the suite + +-- How far back along (bx, by, bz) from `pivot` the eye can stand, up to +-- `want`. March at STEP, and when a sample refuses, bisect back into the +-- gap between it and the last clear one -- so the answer is the face's own +-- position rather than the sampling grid's, and walking toward a wall +-- draws the camera in smoothly instead of in four-pixel jerks. PAD comes +-- off whatever survives. +function ThirdPerson.reach(ow, pivot, bx, by, bz, want) + if not ow or want <= 0 then return math.max(0, want) end + local function clear(t) + return not occupied(ow, pivot[1] + bx * t, pivot[2] + by * t, + pivot[3] + bz * t) + end + local lo = 0 + local steps = math.ceil(want / ThirdPerson.STEP) + local hi = nil + for i = 1, steps do + local t = math.min(want, i * ThirdPerson.STEP) + if clear(t) then + lo = t + else + hi = t + break + end + end + if not hi then return want end + for _ = 1, ThirdPerson.REFINE do + local mid = (lo + hi) / 2 + if clear(mid) then lo = mid else hi = mid end + end + return math.max(0, lo - ThirdPerson.PAD) +end + +-- ------- the tick +-- +-- Rides FirstPerson.update, which is itself on the pipeline's own update +-- hook, so this runs every frame whatever the rung -- the extension has to +-- keep easing back in after 3RD is left. `blend` is FirstPerson's dive into +-- the head: while it is fully out (the diorama), the extension SNAPS to its +-- target rather than easing, so picking 3RD from an orbit rung is one +-- motion (the dive) rather than two (a dive, then a slide backwards). +function ThirdPerson.update(dt, blend) + -- the player's own zoom FIRST, so everything below measures itself + -- against the boom length this frame actually wants. A step is a request + -- rather than a jump: three notches of wheel should read as one glide. + local zg = ThirdPerson.zoomGoal + if ThirdPerson.zoom ~= zg then + local k = math.min(1, dt / ThirdPerson.ZOOM_TIME) + local z = ThirdPerson.zoom + (zg - ThirdPerson.zoom) * k + ThirdPerson.zoom = (math.abs(zg - z) < 1e-4) and zg or z + end + + local target = ThirdPerson.selected() and 1 or 0 + if (blend or 0) <= 0 then + ThirdPerson.out = target + ThirdPerson.len = ThirdPerson.reachFor() * target + -- and the wanted length with it: place() is what normally maintains it + -- and it does not run at all while the rig is out of the frame, so a + -- stale want left here would have the recovery below creeping the boom + -- back out over a camera that is not on screen + ThirdPerson.want = ThirdPerson.len + else + local step = dt / ThirdPerson.BOOM_TIME + if ThirdPerson.out < target then + ThirdPerson.out = math.min(target, ThirdPerson.out + step) + elseif ThirdPerson.out > target then + ThirdPerson.out = math.max(target, ThirdPerson.out - step) + end + end + + -- the rationed recovery: place() already pulled `len` in to whatever the + -- world allowed this frame, and this is the only thing that lets it back + -- out again + if ThirdPerson.len < ThirdPerson.want then + ThirdPerson.len = math.min(ThirdPerson.want, + ThirdPerson.len + ThirdPerson.RETURN * dt) + end +end + +-- The boom's full length right now, before the world has its say: BOOM at +-- the player's own zoom. Named so the collision march and the shoulder +-- fade measure themselves against the same number. +function ThirdPerson.reachFor() + return ThirdPerson.BOOM * ThirdPerson.zoom +end + +-- ------- the eye +-- +-- Where the camera stands, given the pivot the first-person rig would have +-- put the eye at and the unit look direction it would have looked along. +-- Returns the eye and the focus: both slide by the shoulder offset, so the +-- view direction is untouched and only the frame's contents shift. +-- +-- With the boom fully in this is exactly the first-person answer, to the +-- pixel -- which is what makes 1ST and 3RD one rig with a number between +-- them rather than two cameras to keep in sync. +function ThirdPerson.place(pivot, lx, ly, lz, focus) + local e = ThirdPerson.extension() + if e <= 0 then + ThirdPerson.want, ThirdPerson.len = 0, 0 + return pivot, focus + end + + local up = ThirdPerson.PIVOT_LIFT * e + local orbit = { pivot[1], pivot[2] + up, pivot[3] } + + local want = ThirdPerson.reachFor() * e + ThirdPerson.want = want + local room = ThirdPerson.reach(overworld(), orbit, -lx, -ly, -lz, want) + -- in instantly, out only as fast as update() allows + ThirdPerson.len = math.min(ThirdPerson.len, room) + local len = ThirdPerson.len + + -- the rail offset, faded with how much boom actually survived: a camera + -- squeezed against a wall gives up its shoulder before it gives up its + -- distance. Right of the look, flat: cross(look, worldUp) normalized, + -- which for a look of (sin y, *, cos y) is (-cos y, 0, sin y) -- the same + -- right hand FirstPerson.moveWorld strafes along. + -- The rail rides the ZOOM as well, so it stays the same fraction of the + -- frame at every distance: a fixed four pixels would swamp the close shot + -- and vanish from the wide one. + local flat = math.sqrt(lx * lx + lz * lz) + local sx, sz = 0, 0 + if flat > 1e-6 then + local s = ThirdPerson.SHOULDER * ThirdPerson.zoom * e + * (len / math.max(want, 1e-6)) + sx, sz = -lz / flat * s, lx / flat * s + end + + local eye = { orbit[1] - lx * len + sx, + orbit[2] - ly * len, + orbit[3] - lz * len + sz } + local aim = focus and { focus[1] + sx, focus[2] + up, focus[3] + sz } + or nil + return eye, aim +end + +-- What a shadow signature has to include about the boom: the sun's box is +-- fitted around this camera, so sliding the eye back (or having a wall +-- shove it in) re-fits it even standing still. +function ThirdPerson.signature() + if ThirdPerson.extension() <= 0 then return "" end + return math.floor(ThirdPerson.len) .. "/" .. + math.floor(ThirdPerson.extension() * 64) +end + +return ThirdPerson diff --git a/lib/VR.lua b/lib/VR.lua index 5ea7f80..9ea5bb8 100644 --- a/lib/VR.lua +++ b/lib/VR.lua @@ -281,6 +281,11 @@ local function renderWorld(views, ctl) VoxelScene.spriteLean = math.rad(75) local pivot, anchor, scale, mountYaw + -- Either free-roam rung puts the headset in the player's head: 3RD's boom + -- is a FLAT-SCREEN framing device, and a headset that stands its wearer + -- three cells behind their own body is a well-known way to make people + -- ill. The rung still changes the walk and the cards the same way; only + -- the eye stays where a head belongs. local fp = FirstPerson.engaged() local battle, battleFloor if camMode == "battle" then battle, battleFloor = battleStage() end diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index eb2bbe1..0e76ef2 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -226,8 +226,16 @@ end -- reads its own shadowing with must describe the same frame, or the -- mirror-flip half of the pair asks the map about texels the sun filed -- under the other cheek. +-- The player's own card asks a different function for the same answer: +-- their body's bearing is what the camera is derived FROM, so it is known +-- continuously rather than as one of four directions, and measuring +-- against the compass point instead flicks the card to a profile for a +-- frame or two when the camera is spun fast (see playerFacing). local function viewFacing(p) if FirstPerson.cardBlend() > 0.5 then + if p.isPlayer then + return FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8) + end return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8) end return p.facing diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index 3e8bb45..e00e188 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -33,17 +33,18 @@ local Voxel = {} -- in the table is deliberate: the ladder is a list of what each rung LOOKS -- like, and two rungs may look the same while meaning different things. -- --- 1ST is the other rung that is more than an angle: the camera steps off its --- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua), --- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung --- it hands over from -- because the tween in and out of first person starts --- from whatever the orbit shows, and the lowest rung is the one a dive into --- a head should start from. Everything angle-derived (the sky's fade, the --- billboard lean the blend eases away) reads that 75 while the first-person --- rig owns the actual camera. -Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 } +-- 1ST and 3RD are the other rungs that are more than an angle: the camera +-- steps off its orbit entirely and stands with the player -- in their eyes +-- (lib/FirstPerson.lua), or on a boom behind their shoulder +-- (lib/ThirdPerson.lua) -- with free look and free movement on both. Their +-- ANGLE entries are 75 -- the orbit rung they hand over from -- because the +-- tween in and out starts from whatever the orbit shows, and the lowest rung +-- is the one a dive into a head should start from. Everything angle-derived +-- (the sky's fade, the billboard lean the blend eases away) reads that 75 +-- while the free-roam rig owns the actual camera. +Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75, 75 } Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", - "1ST (EXPERIMENTAL)" } + "1ST (EXPERIMENTAL)", "3RD (EXPERIMENTAL)" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 -- the rung FULL sits on, so nothing has to hunt for it by label @@ -60,6 +61,23 @@ function Voxel.isFirstPerson(level) return (level or Voxel.level) == Voxel.FP_LEVEL end +-- and the third-person one, which is the same rig with the eye boomed off +-- the back of the head (lib/ThirdPerson.lua) +Voxel.TP_LEVEL = 7 + +function Voxel.isThirdPerson(level) + return (level or Voxel.level) == Voxel.TP_LEVEL +end + +-- The two of them together: the rungs where the camera stands WITH the +-- player rather than orbiting the view centre, which is what decides that +-- the look inputs are read, the walk goes free and the cards turn to face +-- the eye. Everything that used to ask isFirstPerson for those asks this. +function Voxel.isFreeCam(level) + level = level or Voxel.level + return Voxel.isFirstPerson(level) or Voxel.isThirdPerson(level) +end + -- ------- what the hotkey walks -- -- The ANGLE rungs only, with FULL left out. The key is a display-mode @@ -69,11 +87,11 @@ end -- with no indication that a keypress had done so. FULL stays on the OPTIONS -- row, which is where a preset that changes other rows belongs. -- --- 1ST is on the path: it changes the camera and only the camera, which is --- exactly what the key promises -- and the key is also the way back OUT of --- first person on a keyboard, where the mouse is captured and the OPTIONS +-- 1ST and 3RD are on the path: they change the camera and only the camera, +-- which is exactly what the key promises -- and the key is also the way back +-- OUT of them on a keyboard, where the mouse is captured and the OPTIONS -- menu is a trip. -Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST +Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6, 7 } -- OFF,15,35,50,75,1ST,3RD -- The rung a press moves to from `level`. -- diff --git a/main.lua b/main.lua index d4abea1..cc062c3 100644 --- a/main.lua +++ b/main.lua @@ -24,10 +24,11 @@ -- below. This file declares; lib/ draws. -- -- Voxel mode is presentational: it changes what the world LOOKS like and --- nothing about what it IS. ONE rung is the deliberate exception. 1ST -- --- the first-person camera -- replaces the grid WALK with a free, --- camera-relative one while it is selected (lib/FreeMove.lua), because a --- head you can steer with a mouse demands feet that go where it looks. +-- nothing about what it IS. TWO rungs are the deliberate exception. 1ST +-- (the camera in the player's own eyes) and 3RD (the same rig, boomed back +-- behind their shoulder) replace the grid WALK with a free, +-- camera-relative one while either is selected (lib/FreeMove.lua), because +-- a camera you can steer with a mouse demands feet that go where it looks. -- Even there the game is untouched: the walk asks the engine's own -- collision the same questions a grid step asks, keeps the player's -- logical cell synced, and fires the engine's own landing pipeline per @@ -92,6 +93,7 @@ local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") +local CamControl = V.require("CamControl") local VR = V.require("VR") -- HORDE MODE: the konami code's minigame. Horde owns the state machine and -- every hook; the other four are the gun, the crowd, the readout and the @@ -582,6 +584,17 @@ do end local claim = HOTKEYS[key] local top = self.stack and self.stack:top() + -- Q and E work whichever camera is in front of the player -- the + -- battle's lens, the third-person boom, or the engine's own survey + -- zoom on an orbit rung. CamControl answers which, and answers "none" + -- for 1ST and for every screen with no camera of ours behind it, in + -- which case the key falls through untouched. Ahead of the hotkey + -- table because unlike those it is NOT free-roam only: a staged battle + -- is exactly where the zoom is most wanted. + if (key == "q" or key == "e") + and not (top and top.onKeyPressed) then + if CamControl.zoomBy(key == "q" and 1 or -1) then return end + end -- A screen with its own key handler gets the key first, exactly as the -- engine's first branch does: typing a nickname must not toggle a -- render mode. Only free-roam presses are ours to take. @@ -678,6 +691,25 @@ end -- -- Everything they did is still reachable: uninstall the mod and both rows are -- back, at whatever they were last set to. +-- BATTLE BG rides the same reasoning, and comes off for a reason of its own. +-- The row picks what fills the screen AROUND the battle's 160x144 field -- +-- WHITE paper, BLACK bars, or the frozen overworld dimmed behind it -- and +-- all three were answers to the same question: what to do with the voids, +-- given the battle is a small picture in the middle of a big window. +-- +-- This mod answers that question differently and permanently. A staged fight +-- fills the whole window with the map the fight is standing on, and the +-- flat battle screen it composites over it is drawn on the mode's own +-- surface; there are no voids left for the row to fill. WORLD is the worst +-- of the three under it -- it makes the battle non-opaque so the engine +-- draws the overworld underneath, which is a SECOND copy of the world drawn +-- under the one the arena pass already put there, dimmed and at a different +-- camera. BLACK bars over a diorama read as a letterboxed screenshot. +-- +-- So the value is pinned at WHITE, which is the one the mode was composed +-- against, and the row comes off the menu on the same reasoning as TILT and +-- GBC FX: a row that no longer decides anything is worse than no row. +-- Uninstall the mod and it is back, at whatever it was last set to. local function pinEngineFx(game) game = game or require("src.core.Game") local opts = game and game.save and game.save.options @@ -686,7 +718,9 @@ local function pinEngineFx(game) local changed = false if opts then changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0 + or (opts.battleBg or "white") ~= "white" opts.tilt, opts.gbcfx = 0, 0 + opts.battleBg = "white" end pcall(Tilt.setLevel, 0) pcall(GBCFX.setLevel, 0) @@ -704,6 +738,10 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) pinEngineFx(game) dropRow(out, "tilt") dropRow(out, "gbcfx") + -- and BATTLE BG with them: this mode fills the window with the map, so + -- the row's whole question -- what to put in the voids around the battle + -- -- no longer has voids to be about (see pinEngineFx) + dropRow(out, "battleBg") -- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes -- one away. While a fight can be staged on the map, OG is the only layout it -- can be composed in (OverworldBattle.forceOG), so the value is pinned there @@ -905,9 +943,11 @@ end -- so this file keeps naming every engine seam the mod touches. OverworldBattle.install() --- ------- the first-person rung's inputs and its walk +-- ------- the free-roam rungs' inputs and their walk -- --- 1ST needs two things no other rung does, and each is a named seam: +-- 1ST and 3RD need two things no other rung does, and each is a named seam. +-- Both rungs are one rig -- the boom behind the shoulder is a number inside +-- it (lib/ThirdPerson.lua) -- so both are installed by the same two calls: -- -- FirstPerson.install claims the LOOK inputs the engine ignores: the right -- stick's axes (Game:gamepadaxis passes them to Input, which returns early @@ -919,11 +959,11 @@ OverworldBattle.install() -- open screen is the look; the d-pad and buttons still go to -- TouchControls, whose own d-pad finger is also read back analog as the -- move vector). Every wrap forwards whatever it does not claim, and claims --- only while 1ST is actually driving. +-- only while one of the two rungs is actually driving. -- -- FreeMove.install wraps OverworldState:handleInput -- the one choke point -- where the grid walk reads the pad, and the same seam the engine's own --- Cycling Road pull lives behind. While 1ST drives, the walk is continuous +-- Cycling Road pull lives behind. While either drives, the walk is continuous -- and camera-relative; the player's logical cell stays synced and every -- per-cell consequence still runs through the engine's own machinery -- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The @@ -931,62 +971,16 @@ OverworldBattle.install() FirstPerson.install() FreeMove.install() --- ------- horde mode +-- ------- the zooms, and the battle camera the player can steer -- --- The sounds are registered before anything can ask for one; the hooks go --- in AFTER FreeMove (and, below, after the SELECT wrap) so the mode's own --- handleInput reasoning sits outside both of theirs, for the same reason --- the SELECT hook does. --- --- The GAME OVER card is a screens-registry record rather than a state --- this file pushes directly: that is the engine's own seam for a mod --- owning a screen, and it means the card is reachable by id from a driver --- or a test without going through a death. -HordeSfx.register(mod) - -mod.content.screens:register("HordeGameOver", { - new = function(game) return V.require("HordeGameOver").new(game) end, -}) - -mod.content.screens:register("HordeExitPrompt", { - new = function(game) return V.require("HordeExitPrompt").new(game) end, -}) - --- The gamepad's triggers, which nothing else in the engine or this mod --- claims: the RIGHT one fires and the LEFT one aims. Read as axes because --- that is what SDL calls them; the 0.5 crossing is the press. Installed --- beside the other input wraps, and inert with the mode off. -do - local Game = require("src.core.Game") - if not Game.dramaticShapeHordeTriggers then - local inner = Game.gamepadaxis - local rightDown = false - function Game:gamepadaxis(joystick, axis, value) - if Horde.playing() then - if axis == "triggerright" then - local down = (value or 0) > 0.5 - if down and not rightDown then HordeGun.fire() end - rightDown = down - elseif axis == "triggerleft" then - HordeGun.setAds((value or 0) > 0.35) - end - elseif rightDown then - rightDown = false - end - return inner(self, joystick, axis, value) - end - -- and X reloads, the only pad button the overworld does not already use - local innerBtn = Game.gamepadpressed - function Game:gamepadpressed(joystick, button) - if Horde.playing() and button == "x" then - HordeGun.reload() - return - end - return innerBtn(self, joystick, button) - end - Game.dramaticShapeHordeTriggers = true - end -end +-- CamControl claims the wheel, Q/E, the mouse and the touch screen for +-- whichever camera is actually in front of the player -- the staged +-- battle's, the third-person boom, or the engine's own survey zoom -- and +-- forwards everything else. Installed AFTER the two above deliberately: a +-- wrap installed later is the OUTER one, so a fight gets first refusal on +-- the mouse and the fingers, which is right, because while one is staged +-- the free-roam look is not driving. +CamControl.install() -- ------- SELECT walks the angle ladder -- @@ -1155,7 +1149,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.5.4" +mod.exports.version = "1.5.5" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index ca73c80..bddd212 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.5.4", + "version": "1.5.5", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/mod.card b/mod.card index 74fdc89..d3ac85c 100644 --- a/mod.card +++ b/mod.card @@ -17,15 +17,19 @@ return { "the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back", "hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded", "SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched", - "on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched", - "on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view", - "on the 1ST rung the wall-bump sound is silent -- the free walk slides along whatever it grazes rather than making a discrete blocked step, so the grid game's bonk came out as a rattle; the grid walk keeps its own", - "while HORDE MODE runs, the world is held still around it: no wild encounters, no trainers walking up, no talking to anyone, no START menu and no changing the camera rung -- doors and warps keep working, because the crowd follows the player through them", + "on the 1ST and 3RD rungs ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched", + "on the 1ST and 3RD rungs the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view", + "on the 3RD rung the character turns to face where they are walking rather than where the camera looks, so a strafe reads as one; standing still they come back round to the camera's bearing, which is the one A talks along", + "on 1ST and 3RD the wall-collision sound is gone: a free walk slides along every wall it grazes rather than refusing a discrete step, so the bonk rang twice a second for walking down a corridor", + "the BATTLE BG options row is taken OFF the menu and pinned to WHITE for as long as this mod is installed -- the mode fills the window with the map, so the row's own question (what to put in the voids around the battle) has no voids left to be about, and its WORLD setting drew a second dimmed copy of the overworld under the arena; uninstalling puts the row back", + "a staged battle's camera can be steered by the player -- right stick, touch drag or mouse to swing it around the arena and raise it, wheel / Q / E / pinch / stick click for the lens -- between the shot the rig was solved for and a side-on view of the arena, and it opens the lens as it goes so both Pokemon stay framed; the angle and lens carry into the next battle", + "with BACK SPRITES on the battle camera is held at the solved shot, because that setting pins your own Pokemon to the menu's slot while the foe stands on the map and no angle holds a half-framed, half-solid composition", }, added = { - "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)", + "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST / 3RD -- a first-person camera and a third-person one, both with free look and free movement)", "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "V-GRID on hotkey 5 and V-CURVE on hotkey 7", + "Q and E zoom whichever camera is in front of you -- the third-person boom, a staged battle's lens, or the engine's own survey zoom on an orbit rung -- alongside the mouse wheel, a two-finger pinch, and the pad's left and right stick clicks (out and in). 1ST claims none of them: the eye is in the player's head and there is no distance to change", "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", @@ -49,8 +53,10 @@ return { "the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass", "menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld", "terrain meshes are cached per map, so the first frame after entering a large map costs a build", - "1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge", - "in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land", + "1ST and 3RD need the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge", + "in 1ST and 3RD, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land", + "3RD's boom shortens against whatever stands behind the player, so backing into a wall walks the camera in to their shoulders; squeezed all the way in it draws as 1ST until they step clear", + "3RD in VR is 1ST in VR: a headset that seats its wearer three cells behind their own body is a well-known way to make people ill, so the boom is declined while a headset is live", "rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it", "VR is Windows x64 only (the shipped loader and the Win32 GL binding): on any other platform -- mobile above all -- the VR row is absent from the OPTIONS menu and the manager's page both, and a stored vr=true carried over in a save is ignored. On Windows it renders the scene once per eye (heavy with WATER FULL or AA up); pad/keyboard/mouse keep working alongside the XR controllers. The loader DLL is found wherever the mod was put -- the dev tree, an installed release's save directory, or an imported archive, from which it is copied once into the save directory so the FFI has a real disk path", "VR on and off are both the VR row's job (options menu or manager); no controller button does either", diff --git a/tests/cam_shots.lua b/tests/cam_shots.lua new file mode 100644 index 0000000..3bcbba3 --- /dev/null +++ b/tests/cam_shots.lua @@ -0,0 +1,211 @@ +-- Scratch driver: the cameras the player steers -- the third-person boom's +-- zoom, and the battle shot's orbit, climb and lens, including the far +-- stops (side-on, 45 degrees up) and what BACK SPRITES takes away. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cam_shots.lua \ +-- SHOT_DIR=.scratchpad/camshots lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/cam") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[cam] DRAMATIC_SHAPE mod not loaded") + return love.event.quit() + end + local V = handle.lib + local FirstPerson = V.require("FirstPerson") + local ThirdPerson = V.require("ThirdPerson") + local BattleCam = V.require("BattleCam") + local OverworldBattle = V.require("OverworldBattle") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local DayNight = V.require("DayNight") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + -- The flower's animation frame, pinned per shot rather than left to the + -- clock: the mesh spans the union of every frame and each one is cut back + -- out in texture space, so a gap that only opens on frame 2 is invisible + -- to a driver that always photographs frame 0 -- which is exactly how the + -- first cut of the closed sides shipped looking correct. + local ANIM = { frame = 0 } + TileRenderer.animFrame = function() return ANIM.frame end + DayNight.setting:sync("day") + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local shots = 0 + local function shot(name) + if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then + shots = shots + 1 + end + end + + -- ------- the boom's zoom + U.teleport(game, "PALLET_TOWN", 13, 14, "down") + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + Pipelines.setLevel("tiltshift", 0) + settle() + for _ = 1, 200 do + if FirstPerson.blend >= 1 then break end + U.wait(1) + end + FirstPerson.yaw = math.pi + U.wait(20) + for _, z in ipairs({ "min", "default", "max" }) do + ThirdPerson.zoomGoal = (z == "min" and ThirdPerson.ZOOM_MIN) + or (z == "max" and ThirdPerson.ZOOM_MAX) or 1 + for _ = 1, 90 do U.wait(1) end + print(("[cam] boom %-8s zoom %.2f len %.1f"):format(z, ThirdPerson.zoom, + ThirdPerson.len)) + shot("boom_" .. z) + end + ThirdPerson.zoomGoal = 1 + U.wait(60) + + -- ------- the standees' closed sides + -- + -- Pallet's flower beds and Route 1's tall grass, from a low camera close + -- in -- the angle that showed the slabs were open at the ends of every + -- run and let you see straight through them. + ThirdPerson.zoomGoal = ThirdPerson.ZOOM_MIN + for _, s in ipairs({ + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = math.rad(6), label = "flowers_low" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = 3 * math.pi / 4, + pitch = math.rad(30), label = "flowers_angled" }, + { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, + pitch = math.rad(8), label = "grass_low" }, + }) do + U.teleport(game, s.map, s.x, s.y, "down") + settle() + FirstPerson.yaw, FirstPerson.pitch = s.yaw, s.pitch + U.wait(60) + -- EVERY animation frame, because a gap the union closed and a frame + -- reopens is only visible on that frame + for f = 0, 3 do + ANIM.frame = f + U.wait(6) + shot(("%s_f%d"):format(s.label, f)) + end + ANIM.frame = 0 + end + ThirdPerson.zoomGoal = 1 + U.wait(60) + + -- ------- the battle shot + -- + -- Staged the way the game stages one, then steered to each stop with the + -- module's own entry points -- the same ones the wheel, the stick and a + -- drag reach. + Pipelines.setLevel("voxel", 4) + U.wait(60) + do + -- Somebody to fight WITH: a fresh driver save has an empty party, and + -- a trainer battle with nobody to send out ends on the frame it starts + -- -- which is what made every steered shot below sample a dead session. + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 40), + } + game.save.player.name = "RED" + local BattleState = require("src.battle.BattleState") + local class = next(game.data.trainers) + local battle = BattleState.newTrainer(game, class, 1) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + end + -- The wipe, then just enough of the send-out chatter to get both mons + -- standing on the arena -- and NOT one tap more. A is also FIGHT and then + -- the first move, so tapping past the intro starts an exchange and the + -- fight can be over before the camera has been steered anywhere. + U.wait(70) + for _ = 1, 40 do + if OverworldBattle.shot() then break end + U.tap(game, "a") + U.wait(10) + end + U.wait(60) + print(("[cam] staged: arena %s shot %s top %s") + :format(tostring(OverworldBattle.arena() ~= nil), + tostring(OverworldBattle.shot() ~= nil), + tostring(game.stack:top() ~= game.overworld))) + if not OverworldBattle.shot() then + print("[cam] no staged battle -- skipping the battle shots") + print(("[cam] %d shots into %s"):format(shots, ROOT)) + return love.event.quit() + end + + local function settleCam(label) + for _ = 1, 120 do U.wait(1) end + print(("[cam] battle %-20s orbit %.2f/%.2f pitch %.2f/%.2f " + .. "zoom %.2f/%.2f steerable %s live %s") + :format(label, BattleCam.orbit, BattleCam.orbitGoal, + BattleCam.pitch, BattleCam.pitchGoal, + BattleCam.zoom, BattleCam.zoomGoal, + tostring(BattleCam.steerable), + tostring(OverworldBattle.shot() ~= nil))) + shot("battle_" .. label) + end + + BattleCam.recentre() + settleCam("home") + + -- right to the stop: this must land SQUARE to the arena's axis + BattleCam.dragOrbit(10) + settleCam("side_on") + + -- and there is nothing to the left of home + BattleCam.recentre() + BattleCam.dragOrbit(-10) + settleCam("left_stop") + + -- up to the stop, and refusing to go below home + BattleCam.recentre() + BattleCam.dragPitch(10) + settleCam("high") + BattleCam.recentre() + BattleCam.dragPitch(-10) + settleCam("low_stop") + + -- the lens at both ends + BattleCam.recentre() + for _ = 1, 40 do BattleCam.stepZoom(-1) end + settleCam("zoom_in") + BattleCam.recentre() + for _ = 1, 40 do BattleCam.stepZoom(1) end + settleCam("zoom_out") + + -- everything at once, which is the shot a player would actually build + BattleCam.recentre() + BattleCam.dragOrbit(0.55) + BattleCam.dragPitch(0.5) + for _ = 1, 4 do BattleCam.stepZoom(-1) end + settleCam("steered") + + -- What BACK SPRITES takes away is NOT shot here: the flag it works + -- through is re-derived from the row every frame + -- (OverworldBattle.update), so a driver cannot hold it down for the + -- hundred-odd frames a settled shot needs, and a picture that claimed to + -- show a locked camera while the camera was in fact free would be worse + -- than no picture. The suite asserts it instead, on both axes and the + -- lens, and on the RIG as well as on the inputs. + BattleCam.recentre() + + print(("[cam] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 2b7cf7d..6c3c2ed 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -49,14 +49,16 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE", -- ------- the ladders the engine drives -T.eq(#defs.voxel.levels, 7, "voxel exposes a seven-rung ladder") +T.eq(#defs.voxel.levels, 8, "voxel exposes an eight-rung ladder") T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF") T.eq(defs.voxel.levels[2], "FULL", "FULL is the first rung after OFF -- the order those two get used in") T.eq(defs.voxel.levels[6], "75", "rung 5 is the 75-degree camera") T.eq(defs.voxel.levels[7], "1ST (EXPERIMENTAL)", - "the top rung is the first-person camera, labelled as the experiment it is") -T.eq(Pipelines.maxLevel("voxel"), 6, "the engine reads the ladder height") + "rung 6 is the first-person camera, labelled as the experiment it is") +T.eq(defs.voxel.levels[8], "3RD (EXPERIMENTAL)", + "and the top rung is the third-person one, labelled the same way") +T.eq(Pipelines.maxLevel("voxel"), 7, "the engine reads the ladder height") T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels") -- ------- gating: inert until switched on, and inert without a GPU @@ -1084,12 +1086,14 @@ local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve") -- with nothing on screen saying a keypress had done it. Pipelines.setLevel("voxel", 0) local walk = {} -for _ = 1, 7 do +for _ = 1, 8 do Game.keypressed(keyGame, "3") walk[#walk + 1] = Pipelines.levelLabel("voxel") end -T.eq(table.concat(walk, ","), "15,35,50,75,1ST (EXPERIMENTAL),OFF,15", - "3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST and wraps, never touching FULL") +T.eq(table.concat(walk, ","), + "15,35,50,75,1ST (EXPERIMENTAL),3RD (EXPERIMENTAL),OFF,15", + "3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST -> 3RD and wraps, never " + .. "touching FULL") -- FULL is 35 degrees, so a press from it goes ON to 50 rather than back to -- the rung that shows the same camera -- the key never appears to do nothing. @@ -1176,8 +1180,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer") -- TILT with or without us. Park the ladder on its top rung and turn both -- back on, so the single press under test is the one that wraps to OFF -- -- where nothing else is going to clear them. --- 6 is the "1ST" rung, the last one the key walks before it wraps to OFF -Pipelines.setLevel("voxel", 6) +-- 3RD is the last rung the key walks before it wraps to OFF +Pipelines.setLevel("voxel", VoxelState.TP_LEVEL) Tilt.setLevel(3) GBCFX.setLevel(4) keyGame.save.options.tilt = 3 @@ -3674,6 +3678,440 @@ FirstPerson.blend = 0 VoxelState.reset() end +-- ------- the third-person rung +-- +-- 3RD is 1ST with the eye on a boom, so what the suite has to hold still is +-- the boom: where it stands the eye behind a pivot, the march through the +-- world that shortens it when something is in the way, and the two things +-- its extension decides that 1ST decides the other way -- the player's own +-- card being drawn, and the body turning to face where it walks. + +do +local FirstPerson = + run.loader.exports.DRAMATIC_SHAPE.lib.require("FirstPerson") +local ThirdPerson = + run.loader.exports.DRAMATIC_SHAPE.lib.require("ThirdPerson") +local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") + +T.eq(VoxelState.TP_LEVEL, 7, "3RD is the eighth rung") +T.check(VoxelState.isThirdPerson(7), "and isThirdPerson answers for it") +T.check(not VoxelState.isThirdPerson(6), "but not for 1ST") +T.check(VoxelState.isFreeCam(6) and VoxelState.isFreeCam(7), + "both rungs that stand the camera with the player answer isFreeCam") +T.check(not VoxelState.isFreeCam(5), "the 75-degree orbit does not") +T.eq(VoxelState.ANGLE_LABELS[VoxelState.TP_LEVEL + 1], "3RD (EXPERIMENTAL)", + "the rung wears the experimental label") +T.eq(VoxelState.ANGLES_DEG[VoxelState.TP_LEVEL + 1], 75, + "and hands the blend the same 75-degree orbit 1ST does") +T.eq(VoxelState.HOTKEY_ORDER[#VoxelState.HOTKEY_ORDER], VoxelState.TP_LEVEL, + "the 3 key walks onto it, and off it back to OFF") + +-- the boom's own tween: picked from an orbit rung (blend fully out) the +-- extension SNAPS, so the dive into the world is one motion rather than a +-- dive followed by a slide; picked from inside the head it eases +VoxelState.setLevel(VoxelState.TP_LEVEL) +ThirdPerson.out, ThirdPerson.len = 0, 0 +ThirdPerson.update(1 / 60, 0) +T.eq(ThirdPerson.out, 1, "picked from the diorama, the boom starts extended") +ThirdPerson.out, ThirdPerson.len = 0, 0 +ThirdPerson.update(1 / 60, 1) +T.check(ThirdPerson.out > 0 and ThirdPerson.out < 1, + "picked from inside the head, it slides out over the boom's own time") + +-- ------- the world the march asks +-- +-- A stub map that refuses everything from x = 4 rightward, with no tileset +-- for the height field to read -- so the ground answers 0 (the lookup is +-- guarded for exactly this) and only the walkability speaks. +local wall = { map = { + inBounds = function(_, cx, cy) return cx >= 0 and cy >= 0 end, + isWalkableCell = function(_, cx) return cx < 4 end, + cellTile = function() return 0 end, +} } + +T.check(ThirdPerson._occupied(wall, 70, 10, 8), + "an unwalkable cell refuses the eye at head height") +T.check(not ThirdPerson._occupied(wall, 70, 30, 8), + "but not one the eye stands well above -- a fence is not a wall") +T.check(ThirdPerson._occupied(wall, 70, 2, 8), + "and the ground refuses it from below") +T.check(ThirdPerson._occupied(wall, -20, 30, 8), + "off the world entirely there is nowhere to stand: the border ring") + +-- the march itself: the wall's face is at x = 64, the pivot at x = 40, so +-- the eye may travel 24 east of it less the clearance pad -- the FACE's own +-- position rather than the four-pixel sampling grid's +local room = ThirdPerson.reach(wall, { 40, 10, 8 }, 1, 0, 0, ThirdPerson.BOOM) +T.check(math.abs(room - (24 - ThirdPerson.PAD)) < 0.5, + "the boom stops a pad short of the face that blocked it") +T.eq(ThirdPerson.reach(wall, { 40, 30, 8 }, 1, 0, 0, ThirdPerson.BOOM), + ThirdPerson.BOOM, "with nothing tall enough in the way it runs out full") +T.eq(ThirdPerson.reach(nil, { 40, 10, 8 }, 1, 0, 0, ThirdPerson.BOOM), + ThirdPerson.BOOM, "and with no world to ask at all -- headless, mid-warp") + +-- ------- the eye +-- +-- place() is pure arithmetic over the pivot and the look, given a world +-- with nothing in it: the suite lends the overworld away for the length of +-- the check so the march has nothing to shorten against. +local Game = require("src.core.Game") +local hadOw = Game.overworld +Game.overworld = nil + +ThirdPerson.out, ThirdPerson.len = 1, ThirdPerson.BOOM +local eye, aim = ThirdPerson.place({ 100, 20, 200 }, 0, 0, 1, + { 100, 20, 224 }) +T.check(math.abs(eye[3] - (200 - ThirdPerson.BOOM)) < 1e-6, + "looking south, the eye stands a full boom north of the pivot") +T.eq(eye[2], 20 + ThirdPerson.PIVOT_LIFT, + "raised to the orbit point above the head") +T.check(math.abs(eye[1] - (100 - ThirdPerson.SHOULDER)) < 1e-6, + "and slid along the rail to the camera's own right -- which is west " + .. "looking south, and puts the player left of centre") +T.check(math.abs(aim[1] - eye[1]) < 1e-6 + and math.abs(aim[3] - eye[3] - (ThirdPerson.BOOM + 24)) < 1e-6, + "the focus slides with it, so the rail moves the frame and not the look") + +ThirdPerson.out, ThirdPerson.len = 0, 0 +local head = { 100, 20, 200 } +local focus = { 100, 20, 224 } +T.eq(ThirdPerson.place(head, 0, 0, 1, focus), head, + "with the boom fully in, the eye IS the head -- 1ST to the pixel") + +-- ------- what the extension decides +-- +-- The rig is built through FirstPerson exactly as 1ST's is; the boom moves +-- the eye, and everything keyed to where the eye stands follows it. +ThirdPerson.out, ThirdPerson.len = 1, ThirdPerson.BOOM +FirstPerson.yaw, FirstPerson.pitch = 0, 0 +FirstPerson.blend = 1 +FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288) +T.check(FirstPerson.cardBlend() == 1, + "the boomed rig turns the cards to face it, exactly as the head does") +T.check(not FirstPerson.hidePlayer(), + "but the player's own card is DRAWN -- it is what the camera is watching") +T.eq(FirstPerson.apparentFacing("down", 108, 208), "up", + "and it shows the camera behind it its back") + +-- and a boom a wall has squeezed back into the head takes the card out +-- again: at that range it is the first-person problem word for word +ThirdPerson.len = ThirdPerson.SHOW_AT - 1 +T.check(FirstPerson.hidePlayer(), + "backed into a fence, the collapsed boom stops drawing the card") +ThirdPerson.len = ThirdPerson.SHOW_AT +T.check(not FirstPerson.hidePlayer(), "and draws it again the moment it clears") +ThirdPerson.len = ThirdPerson.BOOM + +T.eq(FirstPerson.bodyFacing(1, 0), "right", + "a body walking east turns east, whichever way the camera is pointed") +T.eq(FirstPerson.bodyFacing(0, -1), "up", "and north walking north") +T.eq(FirstPerson.bodyFacing(0, 0), FirstPerson.compassFacing(), + "standing still it comes back round to the camera's bearing, which is " + .. "the one A talks along") + +-- ------- the spin-flicker guard +-- +-- The player's card is the one whose body the camera is derived FROM: the +-- body is pointed along the camera's own yaw, so the angle between them is +-- a flat 180 degrees and the card should show its back and nothing else, +-- at every bearing. +-- +-- Quantise the body to a compass point first and that stops being true. +-- The shoulder rail stands the eye a few degrees off the exact rear axis, +-- and the round trip through four directions has no margin to spare for +-- it: in a band just short of each 45-degree boundary the pair measures as +-- 135 degrees and picks the mirrored PROFILE frame. Standing perfectly +-- still. Spin the camera and you sweep four of those bands a revolution -- +-- the character flicking sideways for a split second, which is the bug +-- this pair of checks exists to hold shut. +-- +-- 44 degrees is inside the first band. No lag anywhere: the body is +-- pointed exactly where the camera looks, which is what standing still IS. +FirstPerson.yaw, FirstPerson.pitch = math.rad(44), 0 +FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288) +FirstPerson.bodyYaw = FirstPerson.yaw +T.eq(FirstPerson.apparentFacing("down", 108, 208), "right", + "measured off the compass point, a standing body picks the profile -- " + .. "the flicker, reproduced with the camera perfectly still") +T.eq(FirstPerson.playerFacing("down", 108, 208), "up", + "measured off the body's own bearing, the card keeps its back turned") + +-- and the whole revolution, which is the assertion that actually matters: +-- there is no bearing at all where a standing body shows anything but its +-- back +;(function() + local wrong = {} + for deg = 0, 359 do + FirstPerson.yaw = math.rad(deg) + FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, + 500, 600, 320, 288) + FirstPerson.bodyYaw = FirstPerson.yaw + if FirstPerson.playerFacing(FirstPerson.compassFacing(), 108, 208) + ~= "up" then + wrong[#wrong + 1] = deg + end + end + T.eq(#wrong, 0, + "a standing body shows its back at every one of 360 bearings (bad: " + .. table.concat(wrong, ",") .. ")") +end)() + +-- and with nothing holding a bearing -- a scripted walk, a cutscene, the +-- grid walk -- the player falls back to the four-direction answer with +-- everybody else +FirstPerson.releaseBody() +T.eq(FirstPerson.bodyYaw, nil, "releasing the body drops the bearing") +T.eq(FirstPerson.playerFacing("down", 108, 208), + FirstPerson.apparentFacing("down", 108, 208), + "and the card reads exactly as an NPC's would") +T.eq(FirstPerson.pointBody(0, 0), FirstPerson.compassFacing(), + "pointing it again hands back the compass facing p.facing wants") +T.eq(FirstPerson.bodyYaw, FirstPerson.yaw, "and records the bearing behind it") +FirstPerson.yaw, FirstPerson.pitch = 0, 0 +FirstPerson.frame({ px = 100, py = 200, gh = 0, lift = 0 }, 500, 600, 320, 288) + +ThirdPerson.out, ThirdPerson.len = 0, 0 +T.eq(FirstPerson.bodyFacing(1, 0), "down", + "in the head the body is the head, whichever way it walks") +T.check(FirstPerson.hidePlayer(), + "and the card the camera stands inside is left out of the frame again") + +-- everything the section borrowed, put back +Game.overworld = hadOw +FirstPerson.blend = 0 +ThirdPerson.out, ThirdPerson.len, ThirdPerson.want = 0, 0, 0 +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, diff --git a/tests/tp_shots.lua b/tests/tp_shots.lua new file mode 100644 index 0000000..57e45c7 --- /dev/null +++ b/tests/tp_shots.lua @@ -0,0 +1,198 @@ +-- Scratch driver: shots of the 3RD (third-person) rung -- the eye boomed +-- off the back of the player's head, the player's own card drawn and turned +-- to face it, the boom shortening against walls indoors, and the body +-- turning to face where it walks. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tp_shots.lua \ +-- SHOT_DIR=.scratchpad/tpshots lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/tp") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[tp] DRAMATIC_SHAPE mod not loaded") + return love.event.quit() + end + local V = handle.lib + local FirstPerson = V.require("FirstPerson") + local ThirdPerson = V.require("ThirdPerson") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local DayNight = V.require("DayNight") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 0 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if FirstPerson.blend >= 1 and Voxel.ready + and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- the nearest WALKABLE cell (fp_shots' helper): a guessed coordinate + -- inside a building footprint buries the pivot in the geometry + local function place(mapId, x, y) + U.teleport(game, mapId, x, y, "down") + local ow = game.stack:top() + local map = ow and ow.map + if not map or map:isWalkableCell(x, y) then return end + for r = 1, 8 do + for dy = -r, r do + for dx = -r, r do + if math.max(math.abs(dx), math.abs(dy)) == r then + local cx, cy = x + dx, y + dy + if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then + U.teleport(game, mapId, cx, cy, "down") + print(("[tp] (%d,%d) not walkable; standing at (%d,%d)") + :format(x, y, cx, cy)) + return + end + end + end + end + end + end + + -- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west + local SCENES = { + -- Pallet Town, mid-street: the player's own card seen from behind, in + -- each compass direction plus a diagonal (the off-grid case, where a + -- south-facing card would be edge-on to any orbit camera) + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" }, + { map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" }, + { map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4, + label = "pallet_diag" }, + -- the shoreline: the boom over water, reflecting the player + { map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" }, + -- pitched down, the classic third-person framing; and up, where the + -- boom has to shorten rather than bury the eye in the ground + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = math.rad(35), label = "pallet_down" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = -math.rad(40), label = "pallet_skyward" }, + -- Route 1: grass and ledges, with the character in frame for scale + { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" }, + -- interiors: the one place a 48px boom cannot fit, so the collision + -- march is the whole shot + { map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi, + label = "center_north" }, + { map = "REDS_HOUSE_1F", x = 3, y = 4, yaw = math.pi, + label = "reds_house" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + place(s.map, s.x, s.y) + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + Pipelines.setLevel("tiltshift", 0) + settle() + FirstPerson.yaw = s.yaw + FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT + U.wait(20) + print(("[tp] %-16s boom %.1f of %.1f"):format(s.label, ThirdPerson.len, + ThirdPerson.BOOM)) + if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then + shots = shots + 1 + end + end + + -- the slide from 1ST to 3RD: the eye walks out of the head rather than + -- cutting, so the halfway frame is a real camera position + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + settle() + if U.shot(game, ROOT .. "/from_1st.png") then shots = shots + 1 end + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + for _ = 1, 300 do + if ThirdPerson.out >= 0.5 then break end + U.wait(1) + end + if U.shot(game, ROOT .. "/boom_mid.png") then shots = shots + 1 end + U.wait(60) + if U.shot(game, ROOT .. "/boom_out.png") then shots = shots + 1 end + + -- ------- the free walk, with the body turning + -- + -- Hold forward with the head yawed off-grid: the player must GLIDE (off + -- the 16px grid, which no grid step can do) and the body must end up + -- facing its own travel rather than the lens. + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + settle() + local ow = game.stack:top() + local p = ow.player + FirstPerson.yaw = 3 * math.pi / 4 -- northeast, deliberately off-grid + FirstPerson.pitch = FirstPerson.PITCH_DEFAULT + local x0, y0 = p.px, p.py + U.hold(game, "up", 90) + local moved = math.abs(p.px - x0) + math.abs(p.py - y0) + print(("[tp] walk moved %.1f px; off-grid: %s; body faces %s (camera %s)") + :format(moved, + tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0), + tostring(p.facing), tostring(FirstPerson.compassFacing()))) + if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end + + -- strafing: the one case the body facing exists for. Hold RIGHT with the + -- head due north and the character must walk east showing its flank. + place("PALLET_TOWN", 13, 14) + settle() + FirstPerson.yaw = math.pi + U.hold(game, "right", 40) + print(("[tp] strafe: body faces %s, camera looks %s") + :format(tostring(p.facing), tostring(FirstPerson.compassFacing()))) + if U.shot(game, ROOT .. "/strafe.png") then shots = shots + 1 end + + -- ------- the spin, sampled + -- + -- Stand still and turn the camera fast, sampling the frame the player's + -- own card actually draws every rendered frame. A standing body is + -- pointed along the camera's own yaw, so it must show its back the whole + -- way round; anything else is the sideways flick. Sampled through the + -- live rig, so this measures the real thing rather than the arithmetic. + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.TP_LEVEL) + settle() + ow = game.stack:top() + p = ow.player + local seen, frames = {}, 0 + for _ = 1, 240 do + FirstPerson.lookBy(math.rad(7), 0) -- ~420 deg/s, a hard flick + U.wait(1) + local f = FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8) + seen[f] = (seen[f] or 0) + 1 + frames = frames + 1 + end + local report = {} + for f, n in pairs(seen) do + report[#report + 1] = ("%s x%d"):format(f, n) + end + table.sort(report) + print(("[tp] spin: %d frames, card frames seen: %s") + :format(frames, table.concat(report, ", "))) + + print(("[tp] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end