grass sway during battles, fix south screen culling

This commit is contained in:
DramaticShape
2026-08-08 10:11:00 -04:00
parent 848c9cb29f
commit 41f1f07342
12 changed files with 833 additions and 51 deletions
+54
View File
@@ -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
+1
View File
@@ -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 |
+18
View File
@@ -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))
+131 -34
View File
@@ -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
+41 -7
View File
@@ -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
+23 -3
View File
@@ -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
+60
View File
@@ -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
+73 -1
View File
@@ -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
+6 -1
View File
@@ -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,
+16
View File
@@ -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
+128 -5
View File
@@ -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
+282
View File
@@ -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