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
+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