mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 07:10:51 +02:00
grass sway during battles, fix south screen culling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user