mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 14:51:03 +02:00
748 lines
33 KiB
Lua
748 lines
33 KiB
Lua
-- Overworld battles: fights that happen on the map you were standing on.
|
|
--
|
|
-- The engine's battle is a screen: a white field with two pics on it, pushed
|
|
-- over a frozen overworld that stops drawing. This turns that white field
|
|
-- into the world -- the same terrain the free-roam mode extrudes, shot from
|
|
-- a placed over-the-shoulder camera at a clear patch of ground nearby --
|
|
-- while leaving the battle ITSELF alone. Every pic, HUD, HP bar, move
|
|
-- animation, faint slide and text box is the engine's own, drawn in the
|
|
-- engine's own order. What changes is what is behind them, and where the two
|
|
-- pics stand.
|
|
--
|
|
-- The sequence, from the moment something picks a fight:
|
|
--
|
|
-- 1. the overworld cast is culled -- every NPC vanishes, so the wipe
|
|
-- plays over an empty map and no bystander is left standing in the
|
|
-- arena shot
|
|
-- 2. the engine's own transition wipes the screen (untouched: it is the
|
|
-- right wipe, picked by the right three bits)
|
|
-- 3. the battle draws over a live, window-resolution render of the arena,
|
|
-- with each mon PINNED to the cell it is standing on, the camera
|
|
-- drifting slowly enough to read as parallax, and a depth-of-field pass
|
|
-- holding the slab of world the two of them occupy sharp
|
|
-- 4. the battle ends, the cast comes back, and the player is exactly
|
|
-- where they were standing
|
|
--
|
|
-- WHAT DOES NOT MOVE. The arena is where the CAMERA goes, not where the
|
|
-- player goes: nothing here writes a cell, a facing, a flag or a warp. A
|
|
-- real warp would have to survive trainer sight-lines, post-battle
|
|
-- dialogue, the blackout path and every script that assumes the player is
|
|
-- where it left them -- and it would have to put them back afterwards.
|
|
-- Moving the camera buys the whole shot and owes nothing back.
|
|
--
|
|
-- The feature declines cleanly rather than half-working: no depth support,
|
|
-- no open ground on the map, the row switched off, or a mesh still building
|
|
-- all end at the same place, which is the battle screen the engine has
|
|
-- always drawn.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
local ModSetting = V.require("ModSetting")
|
|
local BattleArena = V.require("BattleArena")
|
|
local BattleCam = V.require("BattleCam")
|
|
local BattleScene = V.require("BattleScene")
|
|
local BattleDOF = V.require("BattleDOF")
|
|
local BattleHud = V.require("BattleHud")
|
|
local BattlePics = V.require("BattlePics")
|
|
local Voxel3D = V.require("Voxel3D")
|
|
local ChunkMesher = V.require("ChunkMesher")
|
|
|
|
local OverworldBattle = {}
|
|
|
|
-- DS_BATTLE_DEBUG=1 logs what the HUD's brightness probe is reading, once a
|
|
-- second, which is how the glyph flip is checked from a shot run. Read
|
|
-- through pcall: the loader's sandbox does not hand a mod `os`, and a
|
|
-- diagnostic must never be the reason the mod fails to load.
|
|
local DEBUG = select(2, pcall(function() return os.getenv("DS_BATTLE_DEBUG") end))
|
|
if DEBUG == nil or DEBUG == false then DEBUG = nil end
|
|
|
|
OverworldBattle.KEY = "battles"
|
|
OverworldBattle.LABEL = "3D-BTL"
|
|
|
|
-- On by default: a mod whose headline is "the world in 3D" should not need
|
|
-- the player to go and find the switch before the world shows up in a
|
|
-- battle. ON is first, so it is also what an unreadable stored value falls
|
|
-- back to.
|
|
OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
|
|
OverworldBattle.LABEL,
|
|
{ true, false }, { "ON", "OFF" })
|
|
|
|
function OverworldBattle.enabled()
|
|
return OverworldBattle.setting:get() and true or false
|
|
end
|
|
|
|
-- ------- both mons face you
|
|
--
|
|
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
|
-- wrong twice over: it is turned away from the camera that is looking at it,
|
|
-- and the back pics are a different, smaller drawing made for a slot the
|
|
-- player never really sees. So the player's side asks for the FRONT pic too,
|
|
-- through the engine's own pokemon.sprite hook -- the seam that exists for
|
|
-- exactly this, so no battle code has to be touched to get it.
|
|
--
|
|
-- Answered BEFORE a battle exists, because the battler is built before the
|
|
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
|
|
-- whether one on this map WOULD be -- the row is on, the 3D pass is
|
|
-- available, and the map has an arena -- which is the same question with the
|
|
-- same answer a moment later. Cached per map, because the arena search walks
|
|
-- the whole grid and this runs once per battler.
|
|
local staged = { mapId = nil, ok = false }
|
|
|
|
function OverworldBattle.wantsFront()
|
|
if not OverworldBattle.enabled() then return false end
|
|
if not Voxel3D.available() then return false end
|
|
-- required here rather than through the file's own helper: this runs
|
|
-- while a battler is being built, which is before that helper is defined
|
|
local g = require("src.core.Game")
|
|
local ow = g and g.overworld
|
|
if not (ow and ow.map and ow.player) then return false end
|
|
if staged.mapId ~= ow.map.id then
|
|
local ok, arena = pcall(BattleArena.find, ow.map,
|
|
ow.player.cellX, ow.player.cellY,
|
|
ow.player.surfing)
|
|
staged = { mapId = ow.map.id, ok = (ok and arena) and true or false }
|
|
end
|
|
return staged.ok
|
|
end
|
|
|
|
-- ------- where the engine's own pics stand
|
|
--
|
|
-- The GB draws the player's back pic with its feet on the text box at row 96
|
|
-- and its 7x7-tile slot centred on x=40, and the enemy's front pic
|
|
-- bottom-aligned in a 7x7 slot centred on x=124 ending at row 56. Those two
|
|
-- points are the pics' FEET, they hold for every species at every scale (the
|
|
-- engine's placement helpers pin the bottom edge and the centre), and they
|
|
-- are what BattleCam is solved to put the two arena cells under.
|
|
--
|
|
-- Which makes the pin a subtraction: whatever the drift has done to the
|
|
-- camera this frame, each pic moves by its own cell's projected position
|
|
-- minus its anchor. At the middle of the drift that is zero.
|
|
OverworldBattle.ANCHOR = {
|
|
player = { 26, 96 },
|
|
enemy = { 124, 56 },
|
|
}
|
|
|
|
-- ------- how big a mon is
|
|
--
|
|
-- Not a decision made here. A pic is drawn at its own integer scale -- 1x for
|
|
-- a 56px front pic, 2x for a 32px back one -- because that is the only way it
|
|
-- keeps every pixel the artist drew, and the CAMERA is solved so that one
|
|
-- overworld square is that big on screen (see BattleCam). The mon fits its
|
|
-- tile because the tile was sized to the mon, not the other way round.
|
|
OverworldBattle.SLOT_W = { front = 56, back = 32 }
|
|
|
|
-- The two HUD blocks, as the pixel spans DrawEnemyHUDAndHPBar and
|
|
-- DrawPlayerHUDAndHPBar actually reach. Neither overlaps its side's pic at
|
|
-- the anchors above.
|
|
OverworldBattle.HUD_RECT = {
|
|
enemy = { 8, 0, 80, 32 },
|
|
player = { 72, 56, 88, 40 },
|
|
}
|
|
|
|
-- ------- the live battle
|
|
--
|
|
-- nil when no overworld battle is running. Never more than one: battles do
|
|
-- not nest.
|
|
local session = nil
|
|
|
|
local function game()
|
|
return require("src.core.Game")
|
|
end
|
|
|
|
-- Put the map's cast back. Both lists are handed back by identity, so
|
|
-- anything that captured one before the battle still sees the same table.
|
|
local function restoreCast()
|
|
if not (session and session.state) then return end
|
|
if session.entities then session.state.entities = session.entities end
|
|
if session.ghosts then session.state.ghosts = session.ghosts end
|
|
session.entities, session.ghosts = nil, nil
|
|
end
|
|
|
|
-- Cull them. The player stays -- they are not an NPC, they are who the
|
|
-- battle belongs to, and Fly/surf animations and the save's own capture read
|
|
-- state.player through this list.
|
|
--
|
|
-- Only the DRAW lists are touched, and only while the overworld is frozen
|
|
-- underneath a battle: StateStack updates the top state alone, so nothing
|
|
-- walks, wanders, triggers or collides against a list that is short for
|
|
-- these frames. The originals go back at battle.ended.
|
|
local function cullCast(state)
|
|
session.entities = state.entities
|
|
session.ghosts = state.ghosts
|
|
state.entities = { state.player }
|
|
state.ghosts = {}
|
|
end
|
|
|
|
-- Stage a battle triggered from `state`, if this mode can. Returns true when
|
|
-- a session started -- which is also the only case where anything visible
|
|
-- changes, so a map with no room for an arena plays exactly the vanilla
|
|
-- battle it always did, cast and all.
|
|
function OverworldBattle.begin(state, battle)
|
|
OverworldBattle.finish()
|
|
if not OverworldBattle.enabled() then return false end
|
|
if not (state and state.map and state.player) then return false end
|
|
if not Voxel3D.available() then return false end
|
|
|
|
local ok, arena = pcall(BattleArena.find, state.map,
|
|
state.player.cellX, state.player.cellY,
|
|
state.player.surfing)
|
|
if not (ok and arena) then return false end
|
|
|
|
session = { state = state, arena = arena, battle = battle, shot = nil,
|
|
armed = false, token = 0 }
|
|
cullCast(state)
|
|
BattleCam.reset()
|
|
return true
|
|
end
|
|
|
|
-- The fallback entry point: a battle that arrived without going through the
|
|
-- overworld's own pushBattle (a link battle, a script pushing a BattleState
|
|
-- directly). Nothing visible depends on the cull for those -- the wipe has
|
|
-- already been and gone -- but the arena still has to be picked.
|
|
function OverworldBattle.ensure(battle)
|
|
if session then
|
|
-- a battle pushed through the overworld reaches begin() before it is
|
|
-- built far enough to draw; battle.started is where it is finished
|
|
if battle and not session.battle then session.battle = battle end
|
|
return
|
|
end
|
|
local g = game()
|
|
local ow = g and g.overworld
|
|
if ow and ow.map then OverworldBattle.begin(ow, battle) end
|
|
end
|
|
|
|
-- The arena this battle is staged on, or nil. Read by the shot driver so a
|
|
-- screenshot can be labelled with the ground it was taken on.
|
|
function OverworldBattle.arena()
|
|
return session and session.arena or nil
|
|
end
|
|
|
|
function OverworldBattle.finish()
|
|
if not session then return end
|
|
restoreCast()
|
|
session = nil
|
|
Voxel3D.camera = nil
|
|
end
|
|
|
|
-- ------- per-frame
|
|
--
|
|
-- Driven from the voxel pipeline's update hook, which the engine ticks every
|
|
-- frame regardless of which state is on top -- including the frames the
|
|
-- transition wipe covers, which is what gets the arena's meshes built before
|
|
-- the first battle frame needs them.
|
|
--
|
|
-- The scene is rendered HERE rather than inside the battle's draw, because
|
|
-- update runs with no canvas bound: a 3D pass that binds a depth target and
|
|
-- unbinds to the screen when it is done cannot do that in the middle of
|
|
-- someone else's frame without putting the frame back itself.
|
|
function OverworldBattle.update(dt)
|
|
if not session then return end
|
|
|
|
local g = game()
|
|
local top = g and g.stack and g.stack:top()
|
|
local ow = g and g.overworld
|
|
-- A battle that ended without saying so (a script tearing the state down,
|
|
-- a path that never emits battle.ended) would otherwise leave the cast
|
|
-- culled for good. Armed only once something has actually covered the
|
|
-- overworld, because begin() runs while the overworld is still on top.
|
|
if top ~= nil and top ~= ow then
|
|
session.armed = true
|
|
elseif session.armed then
|
|
OverworldBattle.finish()
|
|
return
|
|
end
|
|
|
|
BattleCam.update(dt)
|
|
-- the battle only exists once it has been pushed; a session opened at
|
|
-- pushBattle time has it, one opened from battle.started was handed it
|
|
session.battle = session.battle or (top ~= ow and top or nil)
|
|
-- the world pass is hidden behind the battle, so mesh builds get the wide
|
|
-- slice: nothing visible can hitch on them
|
|
ChunkMesher.pump(true)
|
|
|
|
-- The mons' textures are rendered HERE, with no canvas bound, for the same
|
|
-- reason the scene is: the pics layer binds its own targets, and doing that
|
|
-- inside somebody else's frame means putting the frame back afterwards.
|
|
local okTex, textures = pcall(OverworldBattle.textures, session.battle)
|
|
if not okTex then textures = nil end
|
|
session.token = (session.token or 0) + 1
|
|
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
|
|
textures, session.token)
|
|
if not ok then
|
|
-- One failure retires the arena for THIS battle and nothing else: the
|
|
-- battle screen carries on as the engine's own, the free-roam pipeline
|
|
-- this runs inside keeps rendering the overworld, and the next battle
|
|
-- tries again. Rethrowing would hand the whole voxel mode to Pipelines'
|
|
-- guard, which retires a pipeline for the session.
|
|
session.shot = nil
|
|
session.broken = true
|
|
V.mod.log:warn("overworld battle scene failed: %s -- this battle draws "
|
|
.. "on the plain battle background", tostring(shot))
|
|
return
|
|
end
|
|
if shot and shot.canvas then
|
|
-- the depth of field is measured off the two marks: the slab in focus is
|
|
-- the one the mons are standing in, at whatever the drift has done to
|
|
-- where that lands
|
|
local y1 = shot.ly + shot.player[2] * shot.scale
|
|
local y2 = shot.ly + shot.enemy[2] * shot.scale
|
|
local focusY, band, range = BattleDOF.bandFor(y1, y2, shot.ph)
|
|
local okDof, blurred = pcall(BattleDOF.apply, shot.canvas,
|
|
focusY, band, range)
|
|
if okDof and blurred then shot.canvas = blurred end
|
|
-- the frosted glass the HUDs sit on is built from the FINISHED backdrop,
|
|
-- so a panel over a blurred far field is frosted from what is actually
|
|
-- behind it
|
|
pcall(BattleHud.build, shot.canvas)
|
|
end
|
|
session.shot = shot
|
|
end
|
|
|
|
-- The finished shot for this frame, or nil when there is none and the battle
|
|
-- should draw the way it always did.
|
|
function OverworldBattle.shot()
|
|
if not session or session.broken then return nil end
|
|
local s = session.shot
|
|
if s and s.canvas then return s end
|
|
return nil
|
|
end
|
|
|
|
function OverworldBattle.invalidate()
|
|
BattleDOF.invalidate()
|
|
BattleHud.invalidate()
|
|
BattlePics.invalidate()
|
|
end
|
|
|
|
-- ------- the battle screen's background
|
|
--
|
|
-- BattleState opens by filling 160x144 white -- that fill IS the battle's
|
|
-- background, and in the colorized pipeline it is also the BG canvas's clear
|
|
-- (nothing else clears it, so skipping it outright would ghost last frame).
|
|
-- So for the length of one draw, that one call is intercepted: on the two
|
|
-- offscreen canvases it becomes a transparent clear, so the shade-remap pass
|
|
-- composites the HUD and the text box over the arena and leaves the empty
|
|
-- field showing it; on the screen it is simply dropped, because the UI canvas
|
|
-- has already been cleared transparent for the world to show through.
|
|
--
|
|
-- Matched exactly -- fill, the full frame, at the origin, in opaque white --
|
|
-- so the text box (a 20x6 box lower down), a mon pic, an HP bar and the
|
|
-- move-animation flash (which is white at 0.85) all pass through untouched.
|
|
--
|
|
-- This is a shim over love.graphics and it is the one invasive thing here,
|
|
-- so it is scoped as tightly as it can be: installed around a single call,
|
|
-- removed on the way out including on error, and never live outside a battle
|
|
-- frame this mode is drawing.
|
|
local function withoutBackgroundFill(battle, fn)
|
|
local g = love.graphics
|
|
local rectangle = g.rectangle
|
|
g.rectangle = function(mode, x, y, w, h, ...)
|
|
if mode == "fill" and x == 0 and y == 0
|
|
and w == BattleScene.GB_W and h == BattleScene.GB_H then
|
|
local r, gr, b, a = g.getColor()
|
|
if r > 0.99 and gr > 0.99 and b > 0.99 then
|
|
-- Two different full-frame whites, both replaced rather than drawn.
|
|
--
|
|
-- OPAQUE is the battle's background, and on the offscreen canvases it
|
|
-- doubles as their clear, so there it becomes a transparent one.
|
|
--
|
|
-- TRANSLUCENT is the hit flash. Over a white field that reads as a
|
|
-- flash; over a world it whites out the map, the HUD and the text box
|
|
-- together. BattleScene puts it back on the mons alone.
|
|
if a > 0.99 then
|
|
local target = g.getCanvas()
|
|
if target ~= nil
|
|
and (target == battle.bgCanvas or target == battle.waveCanvas) then
|
|
g.clear(0, 0, 0, 0)
|
|
end
|
|
end
|
|
return
|
|
end
|
|
end
|
|
return rectangle(mode, x, y, w, h, ...)
|
|
end
|
|
local ok, err = pcall(fn, battle)
|
|
g.rectangle = rectangle
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
-- ------- the mons, as textures for the 3D pass
|
|
--
|
|
-- The two Pokemon are not composited over the world any more: they are quads
|
|
-- standing in it (see BattleBillboard). What that needs from the battle
|
|
-- screen is a TEXTURE per side -- and the honest way to get one is to let the
|
|
-- engine draw its own pics layer, unchanged, into a canvas.
|
|
--
|
|
-- So the layer is rendered twice, once per side, with the other side
|
|
-- falsified out of existence by nulling exactly the fields its branches
|
|
-- test. Everything the engine does to a pic comes along for free that way:
|
|
-- the trainer pic before the send-out, the grow-out-of-the-ball scale, the
|
|
-- faint slide, the damage blink, the squish, every SE displacement. None of
|
|
-- it is reimplemented and none of it can drift.
|
|
--
|
|
-- Two things are forced during that render. The scale, to 1, so the texture
|
|
-- carries the artwork's own pixels and the BILLBOARD does the sizing; and the
|
|
-- placement, so the pic lands centred on a known column with its feet on a
|
|
-- known row. That known point is what the quad is then hung from.
|
|
local TEX_AX, TEX_AY = 80, 96 -- forced pic centre and baseline
|
|
local TRAINER_AX, TRAINER_AY = 124, 56 -- the intro trainer pic's own slot
|
|
|
|
OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
|
|
|
|
-- Which side is being rendered, or nil. The placement wrappers read it.
|
|
local texturing = nil
|
|
|
|
local texCanvas = {}
|
|
local innerPics = nil -- captured by install()
|
|
|
|
local function texCanvasFor(side)
|
|
local c = texCanvas[side]
|
|
if c then return c end
|
|
local ok, made = pcall(love.graphics.newCanvas, BattleScene.GB_W,
|
|
BattleScene.GB_H, { dpiscale = 1 })
|
|
if not ok then return nil end
|
|
made:setFilter("nearest", "nearest")
|
|
texCanvas[side] = made
|
|
return made
|
|
end
|
|
|
|
-- Whether this side has anything to draw at all. Mirrors drawPicsLayer's own
|
|
-- guards, so an empty canvas is never hung on a quad: a fainted, hidden or
|
|
-- not-yet-sent-out mon simply has no billboard this frame.
|
|
local function sideVisible(battle, side)
|
|
if side == "enemy" then
|
|
if battle.showEnemyTrainer and battle.trainerPic then return true end
|
|
return (battle.enemy and battle.enemy.sprite and not battle.enemyHidden
|
|
and not battle.enemySendingOut
|
|
and not battle:fxHidden(battle.enemy)) and true or false
|
|
end
|
|
if battle.showPlayerBack and battle.playerBackPic then return true end
|
|
local hide = battle.safari or battle.demo
|
|
return (battle.player and battle.player.sprite and not hide
|
|
and not battle.sendingOut
|
|
and not battle:fxHidden(battle.player)) and true or false
|
|
end
|
|
|
|
local OFF = {
|
|
enemy = { player = false, showPlayerBack = false },
|
|
player = { enemy = false, showEnemyTrainer = false },
|
|
}
|
|
|
|
-- Render one side's pics layer into its canvas and report where the pic's
|
|
-- feet ended up, in canvas coordinates.
|
|
function OverworldBattle.sideTexture(battle, side)
|
|
if not (innerPics and battle) then return nil end
|
|
if not sideVisible(battle, side) then return nil end
|
|
local canvas = texCanvasFor(side)
|
|
if not canvas then return nil end
|
|
|
|
local g = love.graphics
|
|
local prevCanvas = g.getCanvas()
|
|
local prevBlend, prevAlpha = g.getBlendMode()
|
|
-- The pic-window scissors are in the battle screen's fixed coordinates and
|
|
-- would clip a pic that has been moved to the middle of its own canvas.
|
|
-- There is nothing here for them to protect -- no HUD, no text box, just
|
|
-- the one pic -- so they are switched off for the render.
|
|
local setScissor, intersectScissor = g.setScissor, g.intersectScissor
|
|
local getScissor = g.getScissor
|
|
g.setScissor = function() end
|
|
g.intersectScissor = function() end
|
|
g.getScissor = function() return nil end
|
|
|
|
local saved = {}
|
|
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
|
|
texturing = side
|
|
|
|
local ok, err = pcall(function()
|
|
g.setCanvas(canvas)
|
|
g.clear(0, 0, 0, 0)
|
|
g.setBlendMode("alpha")
|
|
g.setColor(1, 1, 1, 1)
|
|
innerPics(battle, 0, 0, 0)
|
|
end)
|
|
|
|
texturing = nil
|
|
for k in pairs(OFF[side]) do battle[k] = saved[k] end
|
|
g.setScissor, g.intersectScissor, g.getScissor =
|
|
setScissor, intersectScissor, getScissor
|
|
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
|
|
g.setBlendMode(prevBlend or "alpha", prevAlpha)
|
|
if not ok then error(err, 0) end
|
|
|
|
local ax, ay = TEX_AX, TEX_AY
|
|
local trainer = false
|
|
-- The intro trainer pic draws itself straight into its own 7x7 slot rather
|
|
-- than through the placement helpers, so it is hung from that slot instead.
|
|
if side == "enemy" and battle.showEnemyTrainer and battle.trainerPic then
|
|
ax, ay, trainer = TRAINER_AX, TRAINER_AY, true
|
|
elseif side == "player" and battle.showPlayerBack and battle.playerBackPic then
|
|
trainer = true
|
|
end
|
|
return { canvas = canvas, ax = ax, ay = ay, trainer = trainer }
|
|
end
|
|
|
|
-- Whether the hit flash is showing this frame.
|
|
--
|
|
-- Mirrors BattleState:draw's own test, because the flash is a DRAW-time
|
|
-- decision there (a counter plus the frame parity that makes it flicker) and
|
|
-- there is no seam that reports it. Read-only, so the worst a future engine
|
|
-- change can do is flash on a frame the engine would not have.
|
|
function OverworldBattle.flashing(battle)
|
|
local fx = battle and battle.fx
|
|
if not (fx and fx.flash and fx.flash > 0) then return false end
|
|
return (battle.frame or 0) % 4 < 2
|
|
end
|
|
|
|
-- Both sides, or nil when neither has anything to show.
|
|
function OverworldBattle.textures(battle)
|
|
if not battle then return nil end
|
|
local out = {}
|
|
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
|
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
|
out.enemy = okE and enemy or nil
|
|
out.player = okP and player or nil
|
|
if not (out.enemy or out.player) then return nil end
|
|
out.flash = OverworldBattle.flashing(battle)
|
|
return out
|
|
end
|
|
|
|
-- ------- engine seams
|
|
--
|
|
-- Four wraps, each idempotent so a hot reload cannot stack them.
|
|
|
|
function OverworldBattle.install()
|
|
local OverworldState = require("src.world.OverworldController")
|
|
if not OverworldState.dramaticShapeBattleHook then
|
|
local inner = OverworldState.pushBattle
|
|
-- The one place the overworld starts a battle, and it runs BEFORE the
|
|
-- transition is pushed -- which is what lets the cull happen off-screen
|
|
-- and the wipe play over a map with nobody on it.
|
|
function OverworldState:pushBattle(battle)
|
|
pcall(OverworldBattle.begin, self, battle)
|
|
return inner(self, battle)
|
|
end
|
|
OverworldState.dramaticShapeBattleHook = true
|
|
end
|
|
|
|
local BattleState = require("src.battle.BattleState")
|
|
if BattleState.dramaticShapeBattleHook then return end
|
|
|
|
-- Integer scales only. The camera is solved to make one overworld square
|
|
-- exactly big enough for a pic at its own integer scale (see BattleCam), so
|
|
-- the fit never has to come out of the pixels -- and a species override or
|
|
-- a battle_sprite_scales entry that asks for 1.7x would undo that and
|
|
-- resample the sprite into mush. Rounded rather than refused, so such a mod
|
|
-- still gets the bigger or smaller mon it asked for, on the pixel grid.
|
|
local innerScale = BattleState.resolveBattleScale
|
|
function BattleState.resolveBattleScale(data, side, path, species)
|
|
local base = innerScale(data, side, path, species)
|
|
-- 1:1 into the billboard texture: the artwork's own pixels, with the
|
|
-- quad's world size doing every bit of the scaling. Anything else would
|
|
-- resample the sprite twice -- once into the texture and again on the way
|
|
-- to the screen -- and a twice-resampled Gen 1 pic is mush.
|
|
if texturing then return 1 end
|
|
if not OverworldBattle.shot() then return base end
|
|
return math.max(1, math.floor((tonumber(base) or 1) + 0.5))
|
|
end
|
|
|
|
-- Keyed-out whites inside a pic used to be filled by the white field
|
|
-- behind it. There is a world back there now, so they are filled here
|
|
-- instead -- see BattlePics, which puts the paper back without touching
|
|
-- the silhouette.
|
|
local innerPic = BattleState.picImage
|
|
function BattleState:picImage(img)
|
|
local out = innerPic(self, img)
|
|
if not OverworldBattle.shot() then return out end
|
|
return BattlePics.filled(out)
|
|
end
|
|
|
|
-- While a billboard texture is being rendered both pics are put in the same
|
|
-- known place -- centred on TEX_AX with their feet on TEX_AY -- so the quad
|
|
-- has one anchor to hang from whichever side and whichever species it is
|
|
-- carrying. Outside that render both helpers answer exactly as they always
|
|
-- did.
|
|
local innerBack = BattleState.backPlacement
|
|
function BattleState.backPlacement(w, h, pad, padL, scale)
|
|
local x, y, s = innerBack(w, h, pad, padL, scale)
|
|
if not texturing then return x, y, s end
|
|
return TEX_AX - w * scale / 2, TEX_AY - (h - pad) * scale, s
|
|
end
|
|
|
|
local innerFront = BattleState.frontPlacement
|
|
function BattleState.frontPlacement(ex, ey, w, h, scale)
|
|
local x, y, s = innerFront(ex, ey, w, h, scale)
|
|
if not texturing then return x, y, s end
|
|
return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
|
|
end
|
|
|
|
local innerDraw = BattleState.draw
|
|
function BattleState:draw()
|
|
local shot = OverworldBattle.shot()
|
|
-- AskName blanks the field on purpose (the nickname prompt is meant to
|
|
-- sit on nothing); leave that one alone.
|
|
if not shot or self.blankForAskName then
|
|
-- nil, not false: the class default is inherited again, so a battle
|
|
-- that loses its arena mid-fight goes back to white voids
|
|
self.letterboxWhite = nil
|
|
self.dramaticShapeShot = nil
|
|
return innerDraw(self)
|
|
end
|
|
self.dramaticShapeShot = shot
|
|
-- The world reaches the screen through the seam a render pipeline's
|
|
-- finished world image already uses: one window-resolution canvas,
|
|
-- blitted a pixel to a pixel, with the 160x144 UI canvas composited over
|
|
-- it in the classic letterbox afterwards. That is what makes the backdrop
|
|
-- as crisp as the free-roam diorama while the pics and text stay GB art.
|
|
local renderer = game().renderer
|
|
if renderer and renderer.setWorldOverride then
|
|
renderer:setWorldOverride(shot.canvas)
|
|
end
|
|
-- beginFrame clears the UI canvas white for an opaque state; the world is
|
|
-- under it now, so clear it back to nothing and let it through. Safe to
|
|
-- do here: an opaque battle is the lowest state drawn, so nothing has
|
|
-- drawn into this canvas yet.
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
-- the white letterbox exists so the window matches the white battle
|
|
-- canvas; there is a world out to the window edges now
|
|
self.letterboxWhite = false
|
|
OverworldBattle.drawHudPanels(self)
|
|
withoutBackgroundFill(self, innerDraw)
|
|
end
|
|
|
|
-- The mons are geometry standing on the map now, drawn in the 3D pass
|
|
-- before this screen is composited at all, so the flat pics layer has
|
|
-- nothing left to do here. Skipped rather than left to draw underneath, or
|
|
-- every Pokemon would appear twice: once on its tile and once in its slot.
|
|
innerPics = BattleState.drawPicsLayer
|
|
function BattleState:drawPicsLayer(slide, sx, sy)
|
|
if self.dramaticShapeShot then return end
|
|
return innerPics(self, slide, sx, sy)
|
|
end
|
|
|
|
-- Move animations are authored against the pics' fixed slots, and a single
|
|
-- animation reaches across both sides, so there is no per-side offset to
|
|
-- give them. They ride the average, which is where the pair's centre went
|
|
-- -- a few pixels at most, and it keeps a hit landing on the mon it is
|
|
-- aimed at instead of drifting off it.
|
|
local innerAnim = BattleState.drawAnimLayer
|
|
function BattleState:drawAnimLayer(colorized)
|
|
local shot = self.dramaticShapeShot
|
|
if not shot then return innerAnim(self, colorized) end
|
|
-- Move animations are authored against the pics' old fixed slots, and one
|
|
-- animation reaches across both sides, so there is no per-side offset to
|
|
-- give them. They ride to where the PAIR went: the midpoint of the two
|
|
-- mons' projected positions, less the midpoint of the slots they used to
|
|
-- sit in. A hit still lands on the mon it is aimed at.
|
|
local a = OverworldBattle.ANCHOR
|
|
local dx = (shot.enemy[1] + shot.player[1]) / 2
|
|
- (a.enemy[1] + a.player[1]) / 2
|
|
local dy = (shot.enemy[2] + shot.player[2]) / 2
|
|
- (a.enemy[2] + a.player[2]) / 2
|
|
love.graphics.push()
|
|
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
|
local ok, err = pcall(innerAnim, self, colorized)
|
|
love.graphics.pop()
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
-- The engine's flash has a SECOND half, and it is the one that reaches the
|
|
-- menu. Beside the white rectangle (dropped above) the flash moves are
|
|
-- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the
|
|
-- colorized pipeline applies in drawZonePass to the WHOLE background
|
|
-- canvas. That canvas carries the HUD glyphs and the text box, so a fade
|
|
-- meant for the two mons washed the menu out with them.
|
|
--
|
|
-- The fade is left switched on for the pics, which read it through
|
|
-- picImage, and switched off for the zone pass alone. So the mons flash
|
|
-- and the furniture around them does not.
|
|
--
|
|
-- The zone pass has a SECOND thing it paints, and this is the one that
|
|
-- reads as the menu box flashing. A screen shake makes it fill every zone
|
|
-- with the zone's own color 0 before it draws the offset copy -- the
|
|
-- hardware showing empty BG in the strip the shake vacated. On a white
|
|
-- battle field that fill is invisible; over a world it is an opaque white
|
|
-- sheet across the whole frame, and since a shake program alternates
|
|
-- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it
|
|
-- switches on and off a few times a second. It is dropped: the background
|
|
-- here is the map, so what the shake vacates should show the map.
|
|
local innerZone = BattleState.drawZonePass
|
|
function BattleState:drawZonePass(src, sx, sy)
|
|
if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end
|
|
-- shadow the method on the instance for this call only; putting the
|
|
-- field back to whatever it was (normally nil) lets the class method be
|
|
-- found again
|
|
local had = rawget(self, "activeBgp")
|
|
self.activeBgp = function() return nil end
|
|
local g = love.graphics
|
|
local rectangle = g.rectangle
|
|
g.rectangle = function(mode, ...)
|
|
-- the pass draws no other rectangle; the shake still shifts the copy
|
|
if mode == "fill" then return end
|
|
return rectangle(mode, ...)
|
|
end
|
|
local ok, err = pcall(innerZone, self, src, sx, sy)
|
|
g.rectangle = rectangle
|
|
self.activeBgp = had
|
|
if not ok then error(err, 0) end
|
|
end
|
|
|
|
-- Black glyphs on grass are not readable; over a frosted panel measured
|
|
-- dark they are not readable either, so they go white. Mapped rather than
|
|
-- rewritten: the HUD sets pure black for its text and nothing else, and in
|
|
-- the colorized pipeline this lands in the grayscale BG canvas, where
|
|
-- white IS shade 0 and the zone pass then colours it like every other
|
|
-- lightest-shade surface. One rule, both pipelines.
|
|
--
|
|
-- The HP bar is untouched: it is drawn in its own greens and reds, and
|
|
-- only an exactly-black set is remapped.
|
|
local innerHUDs = BattleState.drawHUDs
|
|
function BattleState:drawHUDs(slide)
|
|
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
|
|
return innerHUDs(self, slide)
|
|
end
|
|
local battle = self
|
|
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
|
|
innerHUDs(battle, slide)
|
|
end)
|
|
end
|
|
|
|
BattleState.dramaticShapeBattleHook = true
|
|
end
|
|
|
|
-- Whether each HUD block is on screen this frame.
|
|
--
|
|
-- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam
|
|
-- that reports "the enemy HUD is up". A panel under a HUD that is not there
|
|
-- would be a frosted slab floating in the arena, so it is worth mirroring;
|
|
-- the worst a future engine change can do is show an empty one for a frame,
|
|
-- never break a battle.
|
|
function OverworldBattle.hudLive(battle, slide)
|
|
local enemy = battle.enemy and not battle.showEnemyTrainer
|
|
and not battle.enemySendingOut
|
|
and not battle:growInScale(battle.enemy) and slide == 0
|
|
and not battle.enemy.fainted
|
|
local player = battle.player and not (battle.safari or battle.demo)
|
|
and not battle.showPlayerBack and slide == 0
|
|
return enemy and true or false, player and true or false
|
|
end
|
|
|
|
-- Lay the frosted glass down under whichever HUD is about to draw, and
|
|
-- record which way the glyphs have to flip.
|
|
function OverworldBattle.drawHudPanels(battle)
|
|
local shot = battle.dramaticShapeShot
|
|
battle.dramaticShapeDark = nil
|
|
if not shot then return end
|
|
local slide = (battle.introSlide or 0) * 4
|
|
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
|
if not (enemy or player) then return end
|
|
local rect = OverworldBattle.HUD_RECT
|
|
local live = {}
|
|
if enemy then live.enemy = rect.enemy end
|
|
if player then live.player = rect.player end
|
|
local dark = BattleHud.verdict(live, shot)
|
|
battle.dramaticShapeDark = dark
|
|
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
|
|
end
|
|
|
|
return OverworldBattle
|