diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1a20b..63ea377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,20 @@ EXCELLENT, which multiplies the engine's own Gen 1 catch roll; the shakes the roll answers are the rocks the ball plays on the ground. + **Running out, and staying out of the way.** Under FULL an empty bag + does not hand the fight back to the classic menu -- there is no fight to + hand back, since a Let's Go wild has no Pokemon of yours in it and a foe + that never takes a turn, so that menu would offer a FIGHT that cannot + happen. The encounter keeps its own screen: the seat holds, the Pokemon + stands there, the readout says NO BALLS LEFT, and RUN is the way out. + Throwing your last ball lands in the same place rather than ending the + session. And the scripted catch tutorials -- the VIRIDIAN CITY old man, + and Yellow's PROF.OAK catching the PIKACHU -- are left alone at every + rung: they are cutscenes wearing a battle's clothes, where the cursor, + the bag and the throw are all scripted and nobody keeps the Pokemon, so + they play exactly as the original does with no capture screen, no held + camera and no experience. + **What it stands on.** The outcome is exactly a Gen 1 ball throw: same catch math (status, HP and ball factors intact), same outcome texts, same caught flow -- dex page, nickname, box overflow -- and a missed @@ -50,6 +64,46 @@ depth-capable driver, no headset); anywhere it cannot stand, balls quietly take the engine's classic toss. +- **SHADOWS: a row that stands the sun's pass down.** Cast shadows are the + most expensive thing the mode draws after the geometry -- the whole world + rendered a second time from the light, every time the view or anybody in + it moves -- and on a phone or an old laptop that is the difference between + the diorama running and the diorama stuttering. ON by default, because a + world where a building throws nothing reads as flat however many voxels it + is made of. OFF means off rather than "fall back": the flat decal drop + shadows are the stand-in for a machine that WANTED shadows and could not + have them, so they stay down too, and the forest's light shafts go with + them (the beams are lit by the sun's own map). FULL neither sets the row + nor takes it away, on the same reasoning as AA -- what the look costs is + the player's question, not a preset's. + +### Fixed + +- **The grass moves during a staged battle.** The wind is switched on around + the free-roam pass's grass draws and off again after them, and the battle + pass -- which draws the same tufts, on the same map, from its own camera -- + never switched it on: the uniform sat at the per-frame default, which means + no wind, so a field that was moving one frame before the encounter went dead + still for the whole fight and started again when it ended. A fight is staged + on the MAP, in that place's own weather and light; a frozen field was the one + thing reading as a photograph of it rather than the place. No walker-contact + push comes with it -- that is somebody stepping through the grass, and the + two mons stand still on their own tiles. + +- **The bottom of the frame no longer bites a row out of the scenery.** + RENDER DIST cut the world to where the frame's rays land on the GROUND, + and the ground is not what the picture is made of: a tree at the bottom of + the screen has its feet south of the row its top is seen on, because the + bottom edge's ray is still coming down as it passes them. The cut is by + column -- deliberately, so it never takes the tops off trees -- so a tree + whose base fell one pixel outside lost its whole height at once, and the + last row of forest along the bottom of the frame was cut through with the + ground behind it showing. The south edge is now walked back down that same + ray by the tallest thing that can stand on it (about a tile and a half at + 35 degrees, four tiles at 50, eleven at 75), plus a tile of slack so a hard + edge is never decided by a rounding. FIT carries it too: it is a correction + to the honest answer, not margin around it. + ## 1.7.1 ### Added diff --git a/README.md b/README.md index 5acd476..359cd04 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ menu. | `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models | | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | +| the **SHADOWS** options row | ON / OFF — real cast shadows, thrown by rendering the whole scene a second time from the sun, so buildings, trees, ledges and people shadow whatever they land on: walls, roofs, each other. The most expensive pass in the mode after the geometry itself, and the first thing to turn off on a phone or an old machine. **OFF** is no shadow at all — the flat drop shadows under characters included — and the forest's light shafts go with it, since the beams are lit by the sun's own map. **FULL** leaves it alone | | 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 | diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index ed4b6d4..a6ad975 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -736,11 +736,29 @@ function BattleScene.render(state, arena, textures, token) -- pull is also what keeps a tuft from z-fighting the floor it stands on local pull = VoxelScene.pull(math.max(pitch, 0.05)) if not discs then + -- and the WIND blowing through it, exactly as the free-roam pass + -- switches on around its own grass draws (VoxelScene). Without this + -- the uniform sits at the per-frame default beginScene sends -- zero, + -- meaning "no wind" -- and the tall grass a fight is standing in goes + -- dead still for the length of the battle while the same tufts one + -- frame earlier, and one frame after, were moving. A staged fight is + -- shot on the MAP, in that place's own weather and light; a frozen + -- field is the one thing that reads as a photograph of it rather than + -- the place itself. + -- + -- No contact point goes with it (grassWind's px/pz are left nil, which + -- sends the far-away sentinel): that push is a WALKER parting the grass + -- they are stepping through, and there is nobody walking here -- the + -- two mons stand still on their own tiles for the whole shot. + Voxel3D.grassWind(true) Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull) for _, nb in ipairs(neighbors) do Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy), pull) end + -- off again before the flowers, which are not grass and have no sway + -- of their own -- the same order the free-roam pass draws them in + Voxel3D.grassWind(false) local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05))) Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull, ShadowMap.snug(nil)) diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua index b573115..47bc7c5 100644 --- a/lib/CatchThrow.lua +++ b/lib/CatchThrow.lua @@ -913,6 +913,19 @@ local function drawGB(battle) local g = love.graphics g.push("all") + -- The empty hand: no ring (nothing will be graded) and no ball readout + -- (there is no ball) -- just what happened and the way out. On the two + -- rows the aim HUD already uses, NOT side by side on one: at the GB font + -- these two strings are ~110 and ~56 pixels wide in a 160 pixel frame, + -- so a single row draws the second plate straight over the end of the + -- first ("OUT OF BALL|A/B:RUN"). + if S.phase == "aim" and S.empty then + label("NO BALLS LEFT", 8, 119) + label("A/B:RUN", 158, 131, "right") + g.pop() + return + end + if S.phase == "aim" or S.phase == "flight" then local cx, cy, outer = ringGeometry(shot) local rho = ringRho(S.ringT) @@ -945,7 +958,10 @@ local function drawGB(battle) -- enough at the GB font to run under the B:BACK plate opposite ballGlyph(8, 137, S.ballId) label(("%s x%d"):format(name:gsub("%s*BALL%s*", ""), count), 15, 131) - label("B:BACK", 158, 131, "right") + -- and what B does, which is not the same thing in both modes: under + -- FULL there is no classic menu behind the throw to back out TO, so B + -- is the encounter's RUN and the label has to say so + label(S.fullWild and "B:RUN" or "B:BACK", 158, 131, "right") if S.canSwitch then label("L/R:BALL", 15, 119) end -- the wind-up readout: sparks orbit the ball as it charges, dim and @@ -1042,10 +1058,12 @@ end -- opts: consumed (the bag already took the ball), safari (safari flow), -- canSwitch (aim-time ball cycling), declinable (B backs out / runs), --- fullWild (Let's Go rules: no foe turns, stay in throw mode, B flees) +-- fullWild (Let's Go rules: no foe turns, stay in throw mode, B flees), +-- empty (ballId is nil -- the out-of-balls hand, see rearm below) function CatchThrow.begin(battle, ballId, opts) if S then return false end opts = opts or {} + local empty = opts.empty or ballId == nil local ok = pcall(function() local arena, groundY = OB().arenaInfo() local shot = OB().shot() @@ -1056,8 +1074,9 @@ function CatchThrow.begin(battle, ballId, opts) ballId = ballId, safari = opts.safari or false, fullWild = opts.fullWild or false, + empty = empty, consumed = opts.consumed or false, - canSwitch = opts.canSwitch or false, + canSwitch = (opts.canSwitch and not empty) or false, declinable = opts.declinable ~= false, phase = "aim", clock = 0, t = 0, ringT = 0, @@ -1072,6 +1091,7 @@ function CatchThrow.begin(battle, ballId, opts) handGB = { 80, 110 }, } ball.pos = handPos() + ball.visible = not empty S.body = { r = 10, yOff = 8, hh = 8 } -- until a measurement lands measureBody() end) @@ -1080,7 +1100,7 @@ function CatchThrow.begin(battle, ballId, opts) return false end battle.phase = CatchThrow.PHASE - CatchThrow.lastBall = ballId + if ballId then CatchThrow.lastBall = ballId end -- the capture seat, HELD: still drops the drift, the steer and the lift -- inside the rig itself, and OverworldBattle's per-frame steerable gate -- closes every input that could move it. The player's own steered angle @@ -1089,13 +1109,27 @@ function CatchThrow.begin(battle, ballId, opts) return true end --- the next ball into the hand, without tearing the seat down: FULL's --- "always in throw mode" between one throw and the next +-- The next ball into the hand, without tearing the seat down: FULL's +-- "always in throw mode" between one throw and the next. +-- +-- A NIL ball is not a failure to rearm -- it is the empty hand, and it is +-- still capture mode. Running out mid-encounter does not hand the fight +-- back to the classic menu (there is no fight: a Let's Go wild has no +-- player Pokemon in it and the foe never takes a turn, so the menu would +-- offer a FIGHT that cannot happen against a foe that cannot answer). +-- The seat holds, the Pokemon stands there, and the readout says so with +-- the one move that is left. local function rearm(ballId) S.ballId = ballId - CatchThrow.lastBall = ballId - S.ballInst = Pokeball.new(ballId) - S.ballInst.pos = handPos() + S.empty = ballId == nil + if ballId then + CatchThrow.lastBall = ballId + S.ballInst = Pokeball.new(ballId) + S.ballInst.pos = handPos() + else + S.ballInst.visible = false + S.canSwitch = false + end S.battle.phase = CatchThrow.PHASE -- park the engine's menu again S.phase = "aim" S.clock = 0 @@ -1135,8 +1169,12 @@ local function winToGB(x, y) return (px - shot.lx) / shot.scale, (py - shot.ly) / shot.scale end +-- An EMPTY hand is not aimable: with no ball there is nothing to drag, so +-- the pointer seams stand down entirely and the mouse/finger goes back to +-- whatever owned it -- rather than the player wrestling an invisible ball +-- around a screen that can never throw it. local function aiming() - return S ~= nil and S.phase == "aim" + return S ~= nil and S.phase == "aim" and not S.empty end local function pointer(kind, x, y, id) @@ -1154,12 +1192,78 @@ local function pointer(kind, x, y, id) end end +-- ------- the BUTTONS, read on the LOGIC STEP rather than the frame +-- +-- Everything else in this file is presentational and rides the render +-- frame (Pipelines.update). Button EDGES cannot: Input:step rebuilds +-- `pressed` from scratch once per FIXED step, and Game:update runs the +-- fixed steps for the frame FIRST and Pipelines.update after -- so a +-- frame that runs two steps has already thrown the first step's edges +-- away by the time anything on the render clock looks at them. +-- +-- Which is not a rare race. It is every frame below 60fps: the key is +-- queued between frames, the frame's FIRST step promotes it, the SECOND +-- wipes it, and the poll never sees it at all. A 3D battle is exactly +-- where the frame rate goes under, so B-to-run, A-to-throw and the L/R +-- ball switch were all reliably dead on the machines that most needed +-- them and fine on a 144Hz one (where most frames run no step at all and +-- the edge lingers). Read from the pressQueue on the engine's own +-- input.step seam instead: it fires once per logic step, never skipped, +-- with this step's presses still in the queue. +-- +-- Taken rather than peeked, so a press the capture consumed does not also +-- page the message it just queued. +local function take(g, btn) + local q = g and g.input and g.input.pressQueue + if not q then return false end + local hit = false + for i = #q, 1, -1 do + if q[i] == btn then + table.remove(q, i) + hit = true + end + end + return hit +end + +function CatchThrow.buttons(g) + if not (S and S.phase == "aim") then return end + -- the same beat of deafness the drag has: the A that picked the ball out + -- of the bag menu is still this step's edge, and must not become a throw + if S.clock < 0.25 then return end + if S.empty then + -- A as well as B: nothing here can be confirmed, so the confirm button + -- should get the player out rather than do nothing at all + local b, a = take(g, "b"), take(g, "a") + if b or a then CatchThrow.cancel(true) end + return + end + if S.declinable and take(g, "b") then + CatchThrow.cancel(true) + return + end + if take(g, "a") then tapThrow() end + if take(g, "left") then switchBall(-1) end + if take(g, "right") then switchBall(1) end +end + function CatchThrow.installInput() if installed then return end installed = true local Game = require("src.core.Game") + -- the logic-step seam for the buttons (see above). next_ first, so a + -- tool mod injecting presses on this hook is read like a controller. + local mod = V.mod + if mod and mod.hooks then + mod.hooks:wrap("input.step", function(next_, g, dt) + local r = next_(g, dt) + pcall(CatchThrow.buttons, g) + return r + end) + end + -- ------- mouse -- -- Installed after CamControl's wraps (see main.lua's install order), so @@ -1340,6 +1444,18 @@ function CatchThrow.update(dt) end if S.phase == "aim" then + -- ------- the empty hand + -- + -- No ball, so no drag, no wind-up, no throw and no ring: the seat and + -- the Pokemon are all that is left, and the only input that means + -- anything is leaving. A runs as well as B on purpose -- there is no + -- second choice for it to be confused with, and a player mashing the + -- confirm button at a screen that cannot confirm anything should get + -- out rather than get stuck. + if S.empty then + if BattleScene.capture then BattleScene.capture.hideTextBox = true end + return -- A/B are read on the logic step, below + end -- the foe's body, if no measurement landed at begin (the texture pass -- and the session race on the entry frame; a model needs its pack -- warm): retried while aiming, briefly -- the default torso covers a @@ -1407,36 +1523,17 @@ function CatchThrow.update(dt) if BattleScene.capture then BattleScene.capture.hideTextBox = true end stickInput(dt) - - -- a beat of deafness on entry: the A that picked the ball in the bag - -- menu is still this frame's edge, and it must not become the throw - if S.clock < 0.25 then return end - local input = game().input - if input and input.wasPressed then - if S.declinable and input:wasPressed("b") then - CatchThrow.cancel(true) - return - end - if input:wasPressed("a") then tapThrow() end - if input:wasPressed("left") then switchBall(-1) end - if input:wasPressed("right") then switchBall(1) end - end + -- B, A and the L/R ball switch are NOT read here: button edges do not + -- survive the render clock (CatchThrow.buttons, on the logic step) return end -- FULL, between throws: the miss text is playing, the seat is held, and -- the moment the engine offers the menu the next ball is in the hand - -- instead. Out of balls, the hold releases and the classic menu takes - -- over -- there is nothing left to be in throw mode WITH. + -- instead -- or, when that was the last ball, the empty hand is (rearm + -- takes nil for exactly this). Either way the capture screen stays up. if S.phase == "await" then - if S.battle.phase == "menu" then - local ball = CatchThrow.pickBall() - if ball then - rearm(ball) - else - endSession() - end - end + if S.battle.phase == "menu" then rearm(CatchThrow.pickBall()) end return end diff --git a/lib/LetsGo.lua b/lib/LetsGo.lua index b62ad2f..e2075ef 100644 --- a/lib/LetsGo.lua +++ b/lib/LetsGo.lua @@ -104,10 +104,25 @@ local function vrOn() return ok and vr and vr.enabled and vr.enabled() or false end +-- ------- the battles that are cutscenes wearing a battle's clothes +-- +-- The catch tutorials -- the VIRIDIAN CITY old man, and Yellow's PROF.OAK +-- catching the PIKACHU (both BattleState:makeOldManDemo, which is why one +-- flag covers both) -- are scripted from the first frame: the cursor moves +-- itself, the bag opens itself, the ball is thrown by someone who is not +-- the player, and the throw always catches a Pokemon nobody keeps. There +-- is no decision in them to hand a minigame, and the story beat is the +-- point, so LET'S GO stays out of them entirely at whatever rung: no +-- capture screen, no FULL treatment, no experience. +function LetsGo.scripted(battle) + return battle and (battle.demo or battle.oakDemo) and true or false +end + function LetsGo.wantsMinigame(battle) if not LetsGo.mode() then return false end if not battle or battle.kind ~= "wild" then return false end - if battle.demo or battle.ghost or battle.noCatch then return false end + if LetsGo.scripted(battle) then return false end + if battle.ghost or battle.noCatch then return false end if not Voxel3D.available() or vrOn() then return false end -- the staged shot must actually be standing: this is "there is a 3D -- battle on screen right now", which the throw is aimed into @@ -123,8 +138,8 @@ end -- from the wipe to the last message. function LetsGo.fullWild(battle) return LetsGo.mode() == "full" and battle and battle.kind == "wild" - and not (battle.safari or battle.demo or battle.ghost - or battle.noCatch) + and not LetsGo.scripted(battle) + and not (battle.safari or battle.ghost or battle.noCatch) and true or false end @@ -237,9 +252,18 @@ local function autoEnter() if battle.dramaticShapeDeclined then return end if not LetsGo.wantsMinigame(battle) then return end local ball = CatchThrow.pickBall() - if not ball then return end + local full = LetsGo.fullWild(battle) + -- An empty bag does NOT fall back to the classic menu under FULL. A + -- Let's Go wild has no player Pokemon in it and a foe that never takes a + -- turn, so the menu it would fall back to offers a FIGHT that cannot + -- happen -- the encounter has to keep its own screen and its own exit. + -- The capture screen opens empty-handed instead: the foe stands there, + -- the readout says there is nothing to throw, and RUN is the way out. + -- At CATCH ONLY there is no auto-entry to speak of and the bag is the + -- only route in, so no balls simply means no throw, as it always did. + if not (ball or full) then return end CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true, - fullWild = LetsGo.fullWild(battle) }) + fullWild = full }) end -- ------- per frame, from the voxel pipeline's update hook @@ -299,9 +323,17 @@ function LetsGo.install() end -- a catch pays experience under FULL, exactly as a knockout would + -- Never for a scripted demo: the old man's catch is a cutscene, nobody + -- keeps the Pokemon, and the party it would pay may not exist yet + -- (Yellow's Pallet intro runs before the lab gift). The engine's own + -- flow does not reach either hook for a demo today -- oldManThrow ends + -- the battle without storeCaughtMon or awardExp -- so this guards the + -- INVARIANT rather than a live bug: a demo pays nothing, whatever route + -- some later engine takes to get there. mod.hooks:wrap("battle.catch_exp", function(next_, ctx) if LetsGo.mode() == "full" and ctx and ctx.battle - and ctx.battle.kind == "wild" then + and ctx.battle.kind == "wild" + and not LetsGo.scripted(ctx.battle) then return true end return next_(ctx) @@ -358,6 +390,7 @@ function LetsGo.install() end mod.hooks:wrap("battle.exp_award", function(next_, ctx) + if ctx and LetsGo.scripted(ctx.battle) then return next_(ctx) end local cc = expCtx if cc and ctx and ctx.battle == cc.battle then expCtx = nil @@ -398,7 +431,8 @@ function LetsGo.install() local b = payload and payload.battle if not (b and LetsGo.fullWild(b)) then return end if not (Voxel3D.available() and not vrOn()) then return end - if not CatchThrow.pickBall() then return end + -- deliberately NOT gated on owning a ball: an empty bag still gets the + -- Let's Go encounter (see autoEnter), so the send-out still has to go local q = b.queue local n = q and #q or 0 if n >= 6 and type(q[n]) == "table" and q[n].fn diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 8997a49..6d914fd 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -217,10 +217,27 @@ local function getBlank() return blank or nil end +-- Whether the player asked for shadows at all (the SHADOWS row, see +-- lib/Shadows). Asked through a pcall because this module is loaded by +-- probes and by the suite with no mod namespace around it, where the answer +-- is simply yes. +-- +-- ONE gate for both halves of the module -- can the pass run, and is there +-- a map to read -- because they must never disagree: available() alone +-- would leave the LAST map standing (`ready` is still true), and every +-- surface would go on wearing shadows frozen in the pose the row was +-- switched off in. +function ShadowMap.wanted() + local ok, on = pcall(function() return V.require("Shadows").enabled() end) + return (not ok) or on +end + -- Whether the sun pass can run at all. False headless, without shaders, or -- where the canvas cannot be made -- VoxelScene then keeps the flat decal --- shadows, which need nothing but a quad. +-- shadows, which need nothing but a quad -- and false with the row off, +-- where nothing stands in (see lib/Shadows). function ShadowMap.available() + if not ShadowMap.wanted() then return false end if love.system and love.system.getOS and love.system.getOS() == "iOS" then return false end @@ -241,9 +258,12 @@ function ShadowMap.texture() return getBlank() end --- True while the map holds a frame the main pass can read. +-- True while the map holds a frame the main pass can read. The row's OFF +-- lands here as well as on available(): a map drawn a frame ago is still in +-- the canvas, and every reader (the scene shader's sunDark, the water's, +-- the forest's beams) hangs off this one answer. function ShadowMap.active() - return ready and canvas ~= nil and canvas ~= false + return ready and canvas ~= nil and canvas ~= false and ShadowMap.wanted() end -- The direction the light TRAVELS, normalized. The shear is the shadow a diff --git a/lib/Shadows.lua b/lib/Shadows.lua new file mode 100644 index 0000000..e9eb5ac --- /dev/null +++ b/lib/Shadows.lua @@ -0,0 +1,60 @@ +-- Voxel world mode: whether the sun casts at all. +-- +-- lib/ShadowMap renders the whole scene a second time from the light every +-- frame the view or a pose changes, at up to 2048 squared, and every +-- surface in the main pass then takes four taps at it. That is the single +-- most expensive thing this mode does after the geometry itself -- and on a +-- phone, or an old laptop, it is the difference between the diorama running +-- and the diorama stuttering. So it gets a row. +-- +-- OFF means OFF, not "fall back": VoxelScene keeps flat decal shadows for a +-- driver that cannot make the map (see Voxel3D.beginShadows), and those are +-- a stand-in for a machine that wanted shadows and could not have them. +-- A player who has just switched them off wants no shadow under anybody, +-- which is what this row gives -- see ShadowMap.wanted, the one gate both +-- halves hang off. +-- +-- This file owns the toggle rather than the drawing: the value, where it +-- persists, and the row the player finds it on -- exactly as VoxelGrid does +-- for the wireframe. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") + +local Shadows = {} + +-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in +-- OPTIONS and the mod manager's own settings page for this mod +Shadows.KEY = "shadows" +Shadows.LABEL = "SHADOWS" + +-- ON is values[1] and so the default: cast shadows are what the mode is +-- for as much as the geometry is -- a world where a building throws +-- nothing reads as flat however many voxels it is made of. The row is for +-- the machine that cannot carry them, not a look anybody is choosing. +Shadows.setting = ModSetting.new(Shadows.KEY, Shadows.LABEL, + { true, false }, { "ON", "OFF" }) + +function Shadows.enabled() + return Shadows.setting:get() and true or false +end + +function Shadows.set(enabled, game) + return Shadows.setting:setIndex(enabled and 1 or 2, game) +end + +function Shadows.toggle(game) + return Shadows.setting:cycle(game) +end + +function Shadows.sync(value) + Shadows.setting:sync(value and true or false) +end + +function Shadows.row() + return Shadows.setting:row() +end + +return Shadows diff --git a/lib/ViewBox.lua b/lib/ViewBox.lua index f745071..200ef02 100644 --- a/lib/ViewBox.lua +++ b/lib/ViewBox.lua @@ -26,6 +26,15 @@ -- in closed form -- see footprint() for the derivation, which is three -- lines of algebra and no tuning at all. -- +-- AND THE GROUND IS NOT THE PICTURE. The trapezoid is where the frame's +-- rays LAND; what is drawn is what stands on it, and a tree at the bottom +-- of the screen has its feet south of the row its top is seen on. Cut to +-- the trapezoid alone, the box takes that tree away whole -- the cut is by +-- column, so a base one pixel outside loses the whole height -- and the +-- bottom of the frame reads as a bite taken out of the scenery. So the +-- south edge is walked back down the bottom ray by the tallest thing that +-- can stand there; see lift(). +-- -- The CUT is still a rectangle (the shader's box kind), so what is stored -- is the trapezoid's bounding rect: never narrower than the picture, so it -- can never take a bite out of it. It is off-centre in z, because the @@ -105,6 +114,63 @@ ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL, -- the row, so a player who can see the seam can push it away. ViewBox.MAX_REACH = 6 +-- ------- the geometry standing on the ground it frames +-- +-- The footprint is where the frame's rays hit the GROUND, and the ground is +-- not what the picture is made of. A tree is most of a hundred world pixels +-- tall, and a point that high up on the BOTTOM edge's own ray sits south of +-- where that ray lands -- nearer the eye, because the ray is coming down. So +-- the bottom of the screen is full of things whose feet are outside the +-- ground trapezoid, and a box cut to the trapezoid alone takes them away +-- whole: the shader cuts a fragment by the column it stands in (Voxel3D's +-- dioramaCull is unbounded upward, deliberately, so a cut never takes the +-- tops off trees), so a tree one pixel south of the edge loses its whole +-- height at once. That is a bite along the bottom of the picture -- a row of +-- trees cut through by the frame's own edge, with the ground behind them +-- showing. +-- +-- HEIGHT is the tallest thing standing on that ground, and it is the sun +-- pass's own figure for the same reason it needs one: how far outside the +-- ground it fits can something still reach the picture? Kept here rather +-- than read across so this file's cut does not move when the light's +-- frustum is retuned; they answer to the same world either way. +ViewBox.HEIGHT = 160 + +-- And a tile of slack on top, at every pitch. The cut's south edge would +-- otherwise land on the frame's own bottom row at the rungs where the term +-- below is zero, which is a hard edge (see FADE_FRAC) balanced on the +-- pixel it is drawn at -- a supersampled frame (lib/AntiAlias) resolves +-- half of it. One tile is cheap and no cut this file makes should be +-- decided by a rounding. +ViewBox.SOUTH_PAD = 16 + +-- How much further south than the ground it lands on the bottom edge of the +-- frame can still show, in world pixels. +-- +-- At sy = -1 the ray direction (see footprint) is +-- +-- d = (0, -(cos a + tanY sin a), -(sin a - tanY cos a)) +-- +-- in (x, y, z) with y up and -z north, so climbing it costs +-- +-- (sin a - tanY cos a) / (cos a + tanY sin a) +-- +-- of south per world pixel of height. Zero at and below atan(tanY) -- about +-- 26 degrees with FOCAL 1, where the ray is shallower than the frame's own +-- half-angle and a RAISED point lands north of the ground hit, which no cut +-- can lose -- a tile and a half at 35, four at 50, and a good eleven at 75, +-- where the eye is nearly level and a tree is nearly all of what is under +-- the bottom of the frame. +function ViewBox.lift(a) + local Voxel = V.require("VoxelState") + local tanY = 1 / (2 * (Voxel.FOCAL or 1)) + local ca = math.max(math.cos(a or 0), 1e-3) + local sa = math.max(math.sin(a or 0), 0) + local rise = (sa - tanY * ca) / (ca + tanY * sa) + if rise <= 0 then return 0 end + return ViewBox.HEIGHT * rise +end + -- The rim under V-CURVE, as a fraction of the shorter half-extent, and for -- the reason Diorama.FADE_FRAC exists: a bent world has no straight sides, -- so a hard edge across one is a lie about what is being looked at. Flat, @@ -247,8 +313,14 @@ function ViewBox.frame(cx, cy, vw, vh, level) if left <= 0.001 then return nil end frac = frac / left local Voxel = V.require("VoxelState") + local angle = Voxel.angle or 0 local north, south, side = ViewBox.footprint( - Voxel.angle or 0, vw, vh, ViewBox.MAX_REACH * (vh or 288)) + angle, vw, vh, ViewBox.MAX_REACH * (vh or 288)) + -- the ground the bottom edge lands on is not the southernmost thing under + -- it: what STANDS there reaches into the frame from further south (see + -- lift). Added before the row's multiplier, so FIT carries it too -- it is + -- a correction to the honest answer, not margin around it. + south = south + ViewBox.lift(angle) + ViewBox.SOUTH_PAD north, south, side = north * frac, south * frac, side * frac -- The rectangle around it. Off-centre in z, because the trapezoid is: -- the camera looks NORTH from south of its focus, so there is far more diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index e90c45a..6239deb 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -15,6 +15,7 @@ local V = ... local Mat4 = V.require("Mat4") local Voxel3D = V.require("Voxel3D") local ShadowMap = V.require("ShadowMap") +local Shadows = V.require("Shadows") local ChunkMesher = V.require("ChunkMesher") local SpriteBillboards = V.require("SpriteBillboards") local TileShape = V.require("TileShape") @@ -1050,7 +1051,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- against the terrain just drawn (a shadow behind a building stays -- hidden) but never depth-writing, so the grass pass at the end of the -- frame still wins its feet-overdraw fights. - if not Voxel3D.shadowsActive() then + -- + -- Not with the SHADOWS row off, though: that is a player saying no + -- shadows, and standing the fallback in would answer a machine that + -- cannot have them (see lib/Shadows). + if Shadows.enabled() and not Voxel3D.shadowsActive() then Voxel3D.beginShadows() for _, p in ipairs(posed) do drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh, diff --git a/main.lua b/main.lua index 04b6ba0..810c023 100644 --- a/main.lua +++ b/main.lua @@ -92,6 +92,7 @@ local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") local ForestAtmos = V.require("ForestAtmos") +local Shadows = V.require("Shadows") local AntiAlias = V.require("AntiAlias") local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") @@ -539,6 +540,21 @@ local SETTINGS = { .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " .. "shadows, the sky and the light following -- or SYNC it to the " .. "clock on the wall, so Kanto's evening falls when yours does." }, + -- `full` on AA's reasoning below, and for the same reason: the sun's pass + -- is the most expensive thing in the frame after the geometry, so this is + -- a question about the machine rather than a knob on the diorama, and it + -- has to stay reachable from inside FULL -- which never sets it either. + { Shadows.setting, + "Real cast shadows: the scene rendered a second time from the sun, so " + .. "buildings, trees, ledges and people throw shadows that climb walls, " + .. "drape over roofs and slide across each other, following the hour on " + .. "the DAYTIME row. It is the most expensive pass in the mode after the " + .. "geometry itself -- a whole extra draw of the world every time the " + .. "view or anybody in it moves -- so OFF is the first thing to try on a " + .. "phone or an old machine. OFF is no shadow at all, the flat drop " + .. "shadows under characters included, and the forest's light shafts go " + .. "with it: the beams are lit by the sun's own map.", + full = true }, -- Marked `full` for the opposite reason the battle rows are: this is not a -- knob on the look at all, it is what the look COSTS. FULL is a preset for -- the diorama, not a licence to spend four times the fill rate on the diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 9cb33c4..1a3524d 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -153,6 +153,11 @@ T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it") -- and AA, for the opposite reason: it is not a knob on the look at all, it is -- what the look COSTS, and only the player knows what their machine can carry T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns") +-- SHADOWS on the same reasoning: the sun's pass is what the look costs, and +-- a preset for the diorama does not get to spend a second draw of the whole +-- world on a machine that cannot carry one +T.check(fullIds["DRAMATIC_SHAPE:shadows"], + "and SHADOWS, the other row that is a question about the hardware") -- VR survives FULL on AA's reasoning: whether a headset is on the desk is -- not the diorama's to decide T.check(fullIds["DRAMATIC_SHAPE:vr"], "and VR, likewise the hardware's question") @@ -406,14 +411,14 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, -- (nothing to store, nothing for the mod manager to persist) and is offered -- on every platform, saying WHERE? rather than IMPORT where there is no file -- dialog to open -T.eq(#hookedRows, 13, "the options hook added a row per setting, plus the " +T.eq(#hookedRows, 14, "the options hook added a row per setting, plus the " .. "STADIUM ROM action row") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5] local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10] -- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO --- hookedRows[9] and AA hookedRows[11]; all four are read where they are --- used rather than named here, because this chunk is one main function and --- has 200 local slots to spend +-- hookedRows[9], SHADOWS hookedRows[11] and AA hookedRows[12]; all five are +-- read where they are used rather than named here, because this chunk is one +-- main function and has 200 local slots to spend T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label") T.eq(hookedRows[4].value(), "FIT", "and defaults to FIT -- the cut IS the window the flat game already " @@ -527,7 +532,7 @@ do local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local aaGame = { save = { options = {} }, mods = { modOptions = {} } } -local aa = hookedRows[11] +local aa = hookedRows[12] T.eq(aa.label, "AA", "the anti-aliasing row carries its label") T.eq(aa.value(), "OFF", "and starts off -- supersampling is a cost knob, and a mod must not spend " @@ -567,6 +572,49 @@ AntiAlias.expand(320, 200) T.eq(AntiAlias.factor(), 1, "leaving nothing behind for the next pass") end +-- ------- SHADOWS: the sun's own pass, and the row that stands it down +-- +-- The most expensive thing in the frame after the geometry -- the whole world +-- drawn a second time from the light, every time anything in it moves -- so +-- the row exists for the machine that cannot carry it. What it must do is +-- switch shadows OFF rather than down: both halves of ShadowMap (can the pass +-- run, and is there a map to read) hang off the one gate, because the map +-- from the last frame is still in the canvas and a row that only stopped the +-- pass would leave every shadow frozen in the pose it was switched off in. +-- +-- In an anonymous scope, like every section that wants more than a name or +-- two: this file is one chunk and a chunk has 200 local slots, which the +-- main function is already sitting on. +;(function() +local Shadows = run.loader.exports.DRAMATIC_SHAPE.lib.require("Shadows") +local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") +local shadowRow = hookedRows[11] +local shGame = { save = { options = {} }, mods = { modOptions = {} } } +T.eq(shadowRow.label, "SHADOWS", "the shadow row carries its label") +T.eq(shadowRow.value(), "ON", + "and starts ON -- cast shadows are as much of the mode as the geometry, " + .. "and the row is for the machine that cannot have them") +T.eq(Shadows.enabled(), true, "which is what the pass reads") +T.eq(ShadowMap.wanted(), true, "and the gate both halves of the sun pass ask") + +shadowRow.step(shGame, 1) +T.eq(shadowRow.value(), "OFF", "stepping the row stands the sun pass down") +T.eq(shGame.save.options.modOptions.DRAMATIC_SHAPE.shadows, false, + "the choice persists beside the other settings, not over them") +T.eq(Shadows.enabled(), false, "the pass sees it") +T.eq(ShadowMap.wanted(), false, "through the one gate") +T.eq(ShadowMap.available(), false, + "so the sun pass never opens -- the frame's second draw of the world is " + .. "the whole thing being saved") +T.eq(ShadowMap.active(), false, + "and nothing reads the map either: a pass stopped WITHOUT this would leave " + .. "the last frame's shadows standing forever") + +shadowRow.step(shGame, 1) +T.eq(shadowRow.value(), "ON", "and the row is a toggle -- one step back on") +T.eq(ShadowMap.wanted(), true, "with the sun asked for again") +end)() + -- ------- the animated terrain atlas survives an engine without its seams -- -- Regression: cycling palette modes with voxel mode on eventually killed @@ -6187,6 +6235,10 @@ T.check(box.z < 200, .. "the view centre is the bug that cut the top off") do local n, s, x = VB.footprint(math.rad(35), 320, 288, REACH) + -- the south edge is the ground the bottom ray lands on PLUS what stands + -- there: a tree's feet are south of the row its top is seen on, and the + -- cut is by column (see lift) + s = s + VB.lift(math.rad(35)) + VB.SOUTH_PAD T.check(math.abs(box.rz - (n + s) * 0.5) < 1e-6, "the half-depth spans the footprint from its south edge to its north") T.check(math.abs(box.z - (200 - (n - s) * 0.5)) < 1e-6, @@ -6196,6 +6248,49 @@ do "both bigger than the window at every tilted rung, so the cut can " .. "never take a bite out of the picture") end + +-- ------- and the geometry standing on the ground it frames +-- +-- The bug: the box was cut to where the frame's rays hit the GROUND, and +-- the ground is not what the picture is made of. At every rung past the +-- frame's own half-angle the bottom of the screen shows things whose FEET +-- are south of that line, and the shader cuts by column -- so a tree one +-- pixel outside lost its whole height and the bottom of the frame wore a +-- bite out of the scenery, the ground behind showing through. +do + local tanY = 1 / (2 * VS.FOCAL) + T.eq(VB.lift(0), 0, + "straight down nothing is lost: a raised point lands NORTH of where its " + .. "own ray hits the ground, which no cut can take") + T.eq(VB.lift(math.atan(tanY)), 0, + "and up to atan(tanY) -- the frame's own half-angle -- it still is") + T.check(VB.lift(math.rad(35)) > 0 and VB.lift(math.rad(50)) > VB.lift(math.rad(35)) + and VB.lift(math.rad(75)) > VB.lift(math.rad(50)), + "past it the lift opens with the tilt: the flatter the eye, the more of " + .. "a tree is what sits under the bottom of the frame") + -- against the ray it is derived from: a point HEIGHT up on the bottom + -- edge's own ray, dropped to the ground, is exactly this far south of + -- where that ray lands + for _, deg in ipairs({ 35, 50, 75 }) do + local a = math.rad(deg) + local dy = -(math.cos(a) + tanY * math.sin(a)) + local dz = -(math.sin(a) - tanY * math.cos(a)) + T.check(math.abs(VB.lift(a) - VB.HEIGHT * (-dz / -dy)) < 1e-9, + ("%d degrees: the lift is that ray walked back up by HEIGHT") + :format(deg)) + end + -- and it is in the CUT, at FIT, which is the rung the artefact was seen on + VS.angle = math.rad(35) + local fit = VB.frame(0, 0, 320, 288, 3) + local n, s = VB.footprint(math.rad(35), 320, 288, REACH) + T.check(fit.z + fit.rz >= s + VB.lift(math.rad(35)) - 1e-6, + "so the box's south edge clears the feet of everything the bottom of " + .. "the frame can show, tree tops included") + T.check(fit.z + fit.rz > s and fit.z - fit.rz < -n + 1e-6, + "without moving the north edge, which the reach already decides") +end +VS.angle = math.rad(35) +box = VB.frame(100, 200, 320, 288, 3) T.check(box.rx ~= box.rz, "which is the whole reason the box kind carries two half-extents: a " .. "square cut on a wide frame either loses the sides or overshoots") @@ -6337,6 +6432,34 @@ end)() -- the row answers the mode, and OFF answers false T.eq(LetsGo.mode(), false, "LET'S GO defaults to OFF") + -- ------- the scripted catch tutorials are none of LET'S GO's business + -- + -- The VIRIDIAN CITY old man and Yellow's PROF.OAK / PIKACHU intro are + -- the same makeOldManDemo cutscene: the cursor, the bag and the throw + -- are all scripted, and nobody keeps the Pokemon. Every rung has to + -- leave them exactly as the engine plays them. + T.eq(LetsGo.scripted({ demo = true }), true, + "the old man's tutorial is a scripted battle") + T.eq(LetsGo.scripted({ demo = true, oakDemo = true }), true, + "and so is PROF.OAK catching the PIKACHU") + T.eq(LetsGo.scripted({ kind = "wild" }), false, + "an ordinary wild encounter is not") + T.eq(LetsGo.scripted(nil), false, "and no battle at all is not either") + do + -- the predicates that gate every FULL behaviour, read at FULL + local realMode = LetsGo.mode + LetsGo.mode = function() return "full" end + T.eq(LetsGo.fullWild({ kind = "wild" }), true, + "FULL owns an ordinary wild encounter") + T.eq(LetsGo.fullWild({ kind = "wild", demo = true }), false, + "but never the old man's demo, whatever the row says") + T.eq(LetsGo.fullWild({ kind = "wild", oakDemo = true }), false, + "nor PROF.OAK's") + T.eq(LetsGo.wantsMinigame({ kind = "wild", demo = true }), false, + "and a scripted throw is never handed the minigame") + LetsGo.mode = realMode + end + -- the ball: a full open-drop-rock-click life without a GPU T.eq(Mat4.rotateZ ~= nil, true, "Mat4 grew the roll the wobble rocks on") do diff --git a/tests/letsgo_empty.lua b/tests/letsgo_empty.lua new file mode 100644 index 0000000..a1c3342 --- /dev/null +++ b/tests/letsgo_empty.lua @@ -0,0 +1,282 @@ +-- Driver: the two edges the capture mode has to hold on to. +-- +-- A FULL with an EMPTY BAG. The encounter must still be a Let's Go +-- encounter -- head-on seat, no player Pokemon, no classic menu -- +-- with the readout saying there is nothing to throw and RUN as the +-- way out. The failure this catches is the old behaviour: falling +-- back to the battle menu, which under FULL offers a FIGHT against a +-- foe that never takes a turn. +-- +-- B FULL, running DRY MID-ENCOUNTER. One ball, thrown weakly enough to +-- fall short: the miss must land in the empty hand rather than +-- tearing the session down. +-- +-- C The SCRIPTED catch tutorial (the VIRIDIAN CITY old man; PROF.OAK +-- and the PIKACHU are the same makeOldManDemo). LET'S GO must not +-- touch it at any rung -- no session, no held camera, no veil -- and +-- it must play its scripted throw through to its own ending. +-- +-- SHOT_DIR=.scratchpad/letsgo \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_empty.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Bag = require("src.inventory.Bag") + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local LetsGo = lib.require("LetsGo") + local CatchThrow = lib.require("CatchThrow") + local BattleScene = lib.require("BattleScene") + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 10), + } + game.save.player.name = "RED" + LetsGo.setting:setValue("full", game) + if os.getenv("DS_RUNG") == "cards" then + lib.require("OverworldBattle").setting:setValue(true, game) + U.log("3D-BTL forced to 2D-3D A") + end + U.log("LET'S GO mode: " .. tostring(LetsGo.mode())) + + -- every ball out of the bag, whatever the save arrived with + local BALLS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL" } + local function emptyBag() + for _, id in ipairs(BALLS) do + local n = game.save.inventory[id] or 0 + if n > 0 then pcall(Bag.remove, game.save, id, n) end + game.save.inventory[id] = nil + end + end + local function ballCount() + local n = 0 + for _, id in ipairs(BALLS) do n = n + (game.save.inventory[id] or 0) end + return n + end + + -- A REAL key event, not U.tap's synthetic pressQueue inject. The bug + -- this driver has to be able to see (button edges read on the render + -- clock instead of the logic step) lives between love.keypressed and + -- whoever polls the edge, so a driver that writes the queue itself + -- jumps straight over it. "b" is the default binding for the B button. + local function key(name) + love.keypressed(name, name, false) + U.wait(2) + love.keyreleased(name, name) + U.wait(2) + end + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(90) + + -- drive the intro chatter until the menu would open (which under FULL is + -- when capture mode takes over instead) + local function toCapture(battle) + U.wait(70) + for _ = 1, 80 do + if battle.phase == "menu" or CatchThrow.session() then break end + U.tap(game, "a") + U.wait(6) + end + U.wait(30) + end + + local function describe(tag, battle) + local s = CatchThrow.session() + U.log(("%s: session=%s empty=%s phase=%s battle=%s veil=%s hidePlayer=%s") + :format(tag, s and "yes" or "NO", s and tostring(s.empty) or "-", + s and s.phase or "-", tostring(battle.phase), + BattleScene.capture and "up" or "down", + tostring(BattleScene.capture + and BattleScene.capture.hidePlayer))) + return s + end + + -- ------- A: an empty bag still gets the capture screen + + emptyBag() + U.log("== A: FULL with " .. ballCount() .. " balls") + local a = BattleState.newWild(game, "PIDGEY", 5) + a.onFinish = function() end + game.overworld:pushBattle(a) + toCapture(a) + + local s = describe("A", a) + U.log(("A: hand is empty -> %s (want yes)"):format( + (s and s.empty) and "yes" or "NO")) + U.shot(game, DIR .. "/empty_1_aim.png") + + -- and B is the way out: a Let's Go wild always escapes + key("b") + for _ = 1, 120 do + U.wait(2) + if a.result then break end + U.tap(game, "a") + end + U.log(("A: after B -- result=%s (want run)"):format(tostring(a.result))) + -- the session and the held camera are swept by battle.ended, which is the + -- teardown BELOW this, not the moment `result` is written -- so the sweep + -- is only worth asserting once the battle has actually left the stack + for _ = 1, 40 do U.tap(game, "a"); U.wait(4) end + U.wait(60) + U.log(("A: after teardown -- session=%s veil=%s (want gone/down)") + :format(CatchThrow.session() and "still up" or "gone", + BattleScene.capture and "still up" or "down")) + + -- ------- B: the LAST ball, thrown short + + Bag.add(game.save, "POKE_BALL", 1, game.data) + CatchThrow.lastBall = "POKE_BALL" + U.log("== B: FULL with " .. ballCount() .. " ball") + local b = BattleState.newWild(game, "PIDGEY", 5) + b.onFinish = function() end + game.overworld:pushBattle(b) + toCapture(b) + describe("B", b) + + -- a deliberately feeble swipe: it must fall short of the Pokemon, so + -- the miss is the outcome under test rather than a lucky catch + local aim = CatchThrow._aimInfo() + if aim then + local uw, uh = love.graphics.getDimensions() + local function toWin(gx, gy) + return (aim.lx + gx * aim.scale) * uw / aim.pw, + (aim.ly + gy * aim.scale) * uh / aim.ph + end + local hx, hy = aim.hand[1], aim.hand[2] + local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy + local d = math.sqrt(dx * dx + dy * dy) + dx, dy = dx / d, dy / d + local step = 120 / 60 + local px, py = toWin(hx, hy) + love.mousepressed(px, py, 1, false, 1) + for i = 1, 8 do + local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i) + love.mousemoved(wx, wy, 0, 0, false) + U.wait(1) + end + local wx, wy = toWin(hx + dx * step * 8, hy + dy * step * 8) + love.mousereleased(wx, wy, 1, false, 1) + else + U.log("B: NO AIM INFO -- capture mode did not open") + end + + -- ride the throw out, then keep tapping through the miss text until the + -- hand is refilled -- which, with the bag now empty, means the EMPTY hand + for _ = 1, 400 do + U.wait(2) + local q = CatchThrow.session() + if q and q.phase == "aim" and q.empty then break end + if not q then break end + if q.phase ~= "aim" and q.phase ~= "flight" then U.tap(game, "a") end + end + local sb = describe("B", b) + U.log(("B: last ball thrown, balls=%d -> %s (want an empty hand)") + :format(ballCount(), + (sb and sb.empty) and "empty hand" or + (sb and ("still holding " .. tostring(sb.ballId))) + or "SESSION GONE")) + U.shot(game, DIR .. "/empty_2_ranout.png") + key("b") + for _ = 1, 120 do + U.wait(2) + if b.result then break end + U.tap(game, "a") + end + U.log("B: after B -- result=" .. tostring(b.result) .. " (want run)") + for _ = 1, 40 do U.tap(game, "a"); U.wait(4) end + U.wait(60) + + -- ------- C: the scripted tutorial, untouched + + Bag.add(game.save, "POKE_BALL", 10, game.data) + U.log("== C: the old man's demo, at FULL, with " .. ballCount() .. " balls") + local om = game.data.field.oldManBattle or { species = "WEEDLE", level = 5 } + local c = BattleState.newWild(game, om.species, om.level) + c:makeOldManDemo() + c.onFinish = function() end + U.log("C: LetsGo.scripted -> " .. tostring(LetsGo.scripted(c)) + .. " fullWild -> " .. tostring(LetsGo.fullWild(c)) + .. " wantsMinigame -> " .. tostring(LetsGo.wantsMinigame(c))) + game.overworld:pushBattle(c) + + -- the demo drives ITSELF: the cursor, the bag and the throw are all + -- scripted, so this only watches. Any session or veil appearing here is + -- the failure. + local sawSession, sawVeil = false, false + local shotDemo = false + for i = 1, 900 do + if CatchThrow.session() then sawSession = true end + if BattleScene.capture then sawVeil = true end + if not shotDemo and i > 200 then + shotDemo = true + U.shot(game, DIR .. "/empty_3_oldman.png") + end + if c.result then break end + U.wait(2) + -- the scripted beats want A only to page the text along + if i % 3 == 0 then U.tap(game, "a") end + end + U.log(("C: session ever opened=%s (want no) veil ever up=%s (want no)") + :format(tostring(sawSession), tostring(sawVeil))) + U.log(("C: result=%s balls=%d (want 10 -- the demo consumes none)") + :format(tostring(c.result), ballCount())) + U.log(("C: party still %d, first is %s") + :format(#game.save.party, + game.data.pokemon[game.save.party[1].species].name)) + + -- ------- D: B RUNS, with a ball in hand, on a frame that runs several + -- logic steps + -- + -- The reported failure, reproduced rather than reasoned about. Input:step + -- rebuilds the edge table once per FIXED step; Game:update runs the + -- frame's steps first and the render-clock hooks after, so any frame + -- carrying more than one step has already discarded the earlier steps' + -- edges. Below 60fps -- which is where a 3D battle lives -- that is + -- every press. speedOverride multiplies the logic clock, so it packs + -- several steps into each frame on demand and turns "sometimes, on a + -- slow machine" into "every time, here". + for _, speed in ipairs({ 1, 4 }) do + Bag.add(game.save, "POKE_BALL", 5, game.data) + CatchThrow.lastBall = "POKE_BALL" + game.speedOverride = speed > 1 and speed or nil + U.log(("== D: FULL, ball in hand, B to run at %dX logic speed"):format(speed)) + local d = BattleState.newWild(game, "PIDGEY", 5) + d.onFinish = function() end + game.overworld:pushBattle(d) + toCapture(d) + local sd = describe("D" .. speed, d) + if sd and not sd.empty then + key("b") + local ran = false + for _ = 1, 60 do + U.wait(2) + if d.result then ran = true break end + end + U.log(("D%dX: after a REAL B -- result=%s (want run) %s") + :format(speed, tostring(d.result), + ran and "RAN" or "*** B DID NOTHING ***")) + else + U.log("D" .. speed .. ": no armed session to test") + end + -- escape hatch: a B that did nothing leaves the battle parked in the + -- capture phase forever, and the rest of the run must still report + if not d.result then + pcall(CatchThrow.onBattleEnded) + d.result, d.phase, d.afterQueue = "run", "messages", "finish" + end + for _ = 1, 80 do U.tap(game, "a"); U.wait(4) end + U.wait(60) + game.speedOverride = nil + end + U.log("done -- " .. DIR) +end