grass sway during battles, fix south screen culling
DramaticShape · 2026-08-08 ·
parent 848c9cb29f
grass sway during battles, fix south screen culling
Changed files
CHANGELOG.md | 54 ++++++++++++
README.md | 1 +
lib/BattleScene.lua | 18 ++++
lib/CatchThrow.lua | 165 ++++++++++++++++++++++++++++--------
lib/LetsGo.lua | 48 +++++++++--
lib/ShadowMap.lua | 26 +++++-
lib/Shadows.lua | 60 +++++++++++++
lib/ViewBox.lua | 74 ++++++++++++++++-
lib/VoxelScene.lua | 7 +-
main.lua | 16 ++++
tests/dramatic_shape_test.lua | 133 +++++++++++++++++++++++++++--
tests/letsgo_empty.lua | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
12 files changed, 833 insertions(+), 51 deletions(-)
Diff
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