mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
15029d811f
# Closed issues CLOSES #17: Incorrect Character Visuals (Only with GBC filter) CLOSES #23: Could you allow the player to change the order of the moves CLOSES #24: Evolution music not playing during evolution CLOSES #26: Standing on door glitch CLOSES #27: Battle Intro text automatically continues CLOSES #29: Visual bug when zoomed out CLOSES #32: Team Rocket recruiter doesn't battle with you unless you speak with him first CLOSES #33: Developer/Debug Console CLOSES #35: Professor Oak's introduction Inaccuracies CLOSES #36: Missing Pokemon Dex entries when picking starter + Rival Pathing issues CLOSES #39: Guy who stops player from skipping brock doesn't bring you to brock's gym + doesn't leave once you've beaten brock CLOSES #40: Bill cutscene is broken CLOSES #41: Ticket guy failing to be a Ticket guy CLOSES #42: S.S. anne odd behavior + Missing sailing away animation CLOSES #43: Dig Attack animation appears to be glitched CLOSES #44: Pokeball flashing doesn't appear to be accurate CLOSES #45: Inaccurate Cut Animation CLOSES #46: Dugtrio i caught in diglett cave has two of the same move CLOSES #47: Rival ignores player in Lavender Tower CLOSES #48: Healing pad in lavender tower does not function CLOSES #49: Incorrect dialogue with parched security guard CLOSES #50: (Game Breaking!) Rocket grunt guarding poster refuses to move CLOSES #51: Badges showing up as items I can deposit in PC CLOSES #52: Visual bug on Celadon Department Store roof (Red Filter) CLOSES #54: Visual issue on route 15 + Fuchsia City CLOSES #56: Running animation missing CLOSES #57: Safari Zone does not display steps while you are inside of it CLOSES #58: (Game Breaking!) Softlock at cycling road gate CLOSES #59: Bike visual issues CLOSES #60: Cycling road not forcing you to get on your bike CLOSES #61: No keycard doors in Silph Co. CLOSES #63: Missing teleporter animation CLOSES #64: Inaccurate spinning CLOSES #65: Reimplement unused Silph Co. Chief and Professor Oak trainer battles
425 lines
17 KiB
Lua
425 lines
17 KiB
Lua
-- Two-pass renderer. The UI pass is the classic 160x144 Game Boy canvas
|
|
-- drawn at the integer window fit scale S, letterboxed in the window.
|
|
-- The world pass (overworld survey zoom) is a variable-size canvas that
|
|
-- fills the *entire* window at the effective integer scale s', so black
|
|
-- letterbox voids become more map, not empty bars. Both use nearest-
|
|
-- neighbor filtering.
|
|
-- Spec: docs/new-features.md (survey zoom)
|
|
|
|
local Zoom = require("src.render.Zoom")
|
|
local Tilt = require("src.render.Tilt")
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
|
|
local Renderer = {}
|
|
|
|
Renderer.WIDTH = 160
|
|
Renderer.HEIGHT = 144
|
|
|
|
-- Tilt mode: the upright billboard canvas is grown by this many world
|
|
-- pixels on every side beyond the ground world view, so a structure or
|
|
-- sprite standing near a view edge still draws in full instead of being
|
|
-- clipped where the ground canvas ends (a receding tree wall at the top of
|
|
-- the view rises above row 0; a fence at the bottom-left drops below/left).
|
|
-- endFrame composites the padded canvas back with a matching offset.
|
|
Renderer.UPRIGHT_MARGIN = 160
|
|
|
|
function Renderer:init()
|
|
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
|
self.canvas:setFilter("nearest", "nearest")
|
|
self.worldCanvas = nil
|
|
self.worldActive = false
|
|
-- tilt mode only: a transparent overlay canvas the size of the world
|
|
-- canvas that receives the upright billboard pass (sprites + standing
|
|
-- FX, drawn at their projected ground anchors). It composites flat over
|
|
-- the projected ground in endFrame; never touched while tilt is off.
|
|
self.uprightCanvas = nil
|
|
self.uprightActive = false
|
|
end
|
|
|
|
-- integer scale that fits the GB UI viewport in the window
|
|
function Renderer:fitScale()
|
|
local ww, wh = love.graphics.getDimensions()
|
|
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
|
|
end
|
|
|
|
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
|
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
|
|
-- the projected ground plane still covers the whole window with no
|
|
-- background peeking at the receded top/bottom corners; flat mode returns
|
|
-- exactly today's size (growth factor is 1 when tilt is inactive).
|
|
function Renderer:worldViewSize()
|
|
local ww, wh = love.graphics.getDimensions()
|
|
local s = Zoom.scale(self:fitScale())
|
|
local vw, vh = Zoom.fillViewSize(s, ww, wh)
|
|
if Tilt.active() then
|
|
local g = Tilt.viewGrowth()
|
|
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
|
end
|
|
return vw, vh
|
|
end
|
|
|
|
-- transparent: the world pass shows through (UI pass draws overlays only)
|
|
function Renderer:beginFrame(transparent)
|
|
self.worldActive = false
|
|
self.uprightActive = false
|
|
-- last frame's trueColor rects and sprite redraws go before anything
|
|
-- draws this one
|
|
PaletteFX.clearTrueColor()
|
|
PaletteFX.clearSpriteRedraws()
|
|
PaletteFX.setPass("ui")
|
|
love.graphics.setCanvas(self.canvas)
|
|
if transparent then
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
else
|
|
love.graphics.clear(1, 1, 1, 1)
|
|
end
|
|
end
|
|
|
|
function Renderer:beginWorldPass()
|
|
local vw, vh = self:worldViewSize()
|
|
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
|
or self.worldCanvas:getHeight() ~= vh then
|
|
self.worldCanvas = love.graphics.newCanvas(vw, vh)
|
|
self.worldCanvas:setFilter("nearest", "nearest")
|
|
end
|
|
self.worldActive = true
|
|
PaletteFX.setPass("world")
|
|
love.graphics.setCanvas(self.worldCanvas)
|
|
love.graphics.clear(1, 1, 1, 1)
|
|
end
|
|
|
|
function Renderer:endWorldPass()
|
|
PaletteFX.setPass("ui")
|
|
love.graphics.setCanvas(self.canvas)
|
|
end
|
|
|
|
-- Tilt mode's upright pass: standing things (sprites, tall-grass feet
|
|
-- overdraw, screen-anchored FX) draw here instead of into the ground
|
|
-- world canvas, each already projected to its ground anchor and colorized
|
|
-- with its map's SGB palette (see OverworldController:billboard). The
|
|
-- canvas is transparent so the projected ground shows through the gaps;
|
|
-- endFrame blits it flat over the projected ground. Sized/filtered like
|
|
-- the world canvas but kept separate so the ground can be projected as a
|
|
-- plane while these stay upright. Only entered while Tilt.active().
|
|
function Renderer:beginUprightPass()
|
|
local vw, vh = self:worldViewSize()
|
|
local M = self.UPRIGHT_MARGIN
|
|
local cw, ch = vw + 2 * M, vh + 2 * M
|
|
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
|
or self.uprightCanvas:getHeight() ~= ch then
|
|
self.uprightCanvas = love.graphics.newCanvas(cw, ch)
|
|
self.uprightCanvas:setFilter("nearest", "nearest")
|
|
end
|
|
self.uprightActive = true
|
|
PaletteFX.setPass(nil)
|
|
love.graphics.setCanvas(self.uprightCanvas)
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
-- shift the whole pass into the padded canvas so billboards keep drawing
|
|
-- in flat world-canvas coordinates (0..vw, 0..vh) while the margin catches
|
|
-- anything that overhangs an edge; endFrame undoes it with the same offset
|
|
love.graphics.push()
|
|
love.graphics.translate(M, M)
|
|
end
|
|
|
|
-- return to the ground world canvas (the world pass owns it until draw()
|
|
-- calls endWorldPass)
|
|
function Renderer:endUprightPass()
|
|
PaletteFX.setPass("world")
|
|
love.graphics.pop()
|
|
love.graphics.setCanvas(self.worldCanvas)
|
|
end
|
|
|
|
-- Perspective mesh shader for tilt mode. The mesh already carries CPU-
|
|
-- projected 2D corner positions (from Tilt.groundPoint), so the vertex
|
|
-- stage does no projection; instead it passes each corner's depthScale as
|
|
-- the per-vertex "q" and pre-multiplies the texture coords by it. The
|
|
-- fragment divides back, which reconstructs perspective-correct texture
|
|
-- interpolation across the whole quad (no affine-warp seams) using the
|
|
-- exact same projection the billboards will anchor to. false = headless /
|
|
-- no shader support, in which case the renderer stays on the flat blit.
|
|
local TILT_SHADER = [[
|
|
varying float vScale;
|
|
#ifdef VERTEX
|
|
attribute float VertexScale;
|
|
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
|
vScale = VertexScale;
|
|
VaryingTexCoord = vec4(VertexTexCoord.xy * VertexScale, 0.0, 1.0);
|
|
return transform_projection * vertex_position;
|
|
}
|
|
#endif
|
|
#ifdef PIXEL
|
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
|
return Texel(tex, tc / vScale) * color;
|
|
}
|
|
#endif
|
|
]]
|
|
|
|
function Renderer:tiltShader()
|
|
if self._tiltShader == nil then
|
|
local ok, sh = pcall(love.graphics.newShader, TILT_SHADER)
|
|
self._tiltShader = ok and sh or false
|
|
end
|
|
return self._tiltShader or nil
|
|
end
|
|
|
|
-- Dynamic 4-vertex ground quad; positions/depthScale are refreshed each
|
|
-- frame from Tilt.meshCorners. The custom VertexScale attribute rides the
|
|
-- perspective "q" through to the shader above.
|
|
function Renderer:tiltMesh()
|
|
if self._tiltMesh == nil then
|
|
local format = {
|
|
{ "VertexPosition", "float", 2 },
|
|
{ "VertexTexCoord", "float", 2 },
|
|
{ "VertexScale", "float", 1 },
|
|
}
|
|
local ok, mesh = pcall(love.graphics.newMesh, format, 4, "fan", "dynamic")
|
|
self._tiltMesh = ok and mesh or false
|
|
end
|
|
return self._tiltMesh or nil
|
|
end
|
|
|
|
-- Draw the world pass through the tilt projection. Two steps: (1) a
|
|
-- canvas-to-canvas palette pre-pass that bakes the SGB world zones into a
|
|
-- colorized ground canvas in flat space (a perspective transform breaks
|
|
-- the rectangular scissors endFrame normally uses), then (2) project that
|
|
-- canvas onto the tilted plane via the perspective mesh, scaled/centred
|
|
-- exactly like the flat world blit. `target` is the canvas to project
|
|
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
|
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
|
-- back to the flat blit unchanged.
|
|
function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
|
local shader = self:tiltShader()
|
|
local mesh = self:tiltMesh()
|
|
if not (shader and mesh) then return false end
|
|
local wvw = self.worldCanvas:getWidth()
|
|
local wvh = self.worldCanvas:getHeight()
|
|
|
|
-- colorized ground canvas, resized to match the world canvas. Linear
|
|
-- sampling softens the pixel shimmer the perspective warp would cause
|
|
-- (the flat path keeps nearest). TODO(tilt): optionally render this at
|
|
-- 2x for extra crispness.
|
|
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
|
or self.tiltCanvas:getHeight() ~= wvh then
|
|
self.tiltCanvas = love.graphics.newCanvas(wvw, wvh)
|
|
self.tiltCanvas:setFilter("linear", "linear")
|
|
end
|
|
|
|
love.graphics.setCanvas(self.tiltCanvas)
|
|
love.graphics.clear(1, 1, 1, 1)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
|
if zoneShader then
|
|
love.graphics.setShader(zoneShader)
|
|
-- same trueColor sentinel the flat blit honors (14 §trueColor)
|
|
local bare = false
|
|
for _, z in ipairs(zoneList) do
|
|
local plain = z.colors == false
|
|
if plain ~= bare then
|
|
bare = plain
|
|
love.graphics.setShader(not plain and zoneShader or nil)
|
|
end
|
|
if not plain then PaletteFX.sendColors(zoneShader, z.colors) end
|
|
local x, y = math.max(0, z.x), math.max(0, z.y)
|
|
local x2, y2 = math.min(wvw, z.x + z.w), math.min(wvh, z.y + z.h)
|
|
if x2 > x and y2 > y then
|
|
love.graphics.setScissor(x, y, x2 - x, y2 - y)
|
|
love.graphics.draw(self.worldCanvas, 0, 0)
|
|
end
|
|
end
|
|
love.graphics.setScissor()
|
|
love.graphics.setShader()
|
|
else
|
|
love.graphics.draw(self.worldCanvas, 0, 0)
|
|
end
|
|
|
|
-- project onto the tilted plane into the present target (or screen)
|
|
love.graphics.setCanvas(target)
|
|
mesh:setTexture(self.tiltCanvas)
|
|
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
|
love.graphics.push()
|
|
love.graphics.translate(wox, woy)
|
|
love.graphics.scale(s, s)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setShader(shader)
|
|
love.graphics.draw(mesh)
|
|
love.graphics.setShader()
|
|
love.graphics.pop()
|
|
return true
|
|
end
|
|
|
|
-- clamp a scissor rect to the viewport box
|
|
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph)
|
|
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
|
|
x, y = math.max(x, ox), math.max(y, oy)
|
|
if x2 <= x or y2 <= y then return false end
|
|
love.graphics.setScissor(x, y, x2 - x, y2 - y)
|
|
return true
|
|
end
|
|
|
|
-- Splice the pass's trueColor rects (reported by the renderers that drew
|
|
-- a record carrying the flag) onto the end of its zone list, so each one
|
|
-- re-blits its region with no shader over the colorized pass. An absent
|
|
-- or empty zone list is left alone: that already draws the whole canvas
|
|
-- unshaded, which is what the rects were asking for.
|
|
local function withTrueColor(zoneList, pass)
|
|
local rects = PaletteFX.trueColorRects(pass)
|
|
if not (rects[1] and zoneList and zoneList[1]) then return zoneList end
|
|
local merged = {}
|
|
for i = 1, #zoneList do merged[i] = zoneList[i] end
|
|
for i = 1, #rects do merged[#merged + 1] = rects[i] end
|
|
return merged
|
|
end
|
|
|
|
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
|
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
|
-- regions in world-canvas pixels (overworld survey zoom colors each
|
|
-- visible map area separately), applied to the world pass; the world
|
|
-- pass falls back to the UI zones when absent. Each zone is drawn
|
|
-- scissored through the shade-remap shader, later zones on top.
|
|
-- When GBC FX is active the composite is drawn into presentCanvas and
|
|
-- presented through the GBC FX shader as a final pass.
|
|
function Renderer:endFrame(zones, worldZones)
|
|
love.graphics.setCanvas()
|
|
local ww, wh = love.graphics.getDimensions()
|
|
local S = self:fitScale()
|
|
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
|
local ox = math.floor((ww - vpw) / 2)
|
|
local oy = math.floor((wh - vph) / 2)
|
|
local GBCFX = require("src.render.GBCFX")
|
|
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
|
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
|
zones = PaletteFX.ensureZones(zones)
|
|
if worldZones then worldZones = PaletteFX.ensureZones(worldZones) end
|
|
-- the UI rects are in 160x144 canvas space and the world rects in world-
|
|
-- canvas pixels, matching the zone list each is appended to. A world
|
|
-- pass with no world zones falls back to the UI list, whose coordinate
|
|
-- space the world rects are not in, so they are dropped there.
|
|
zones = withTrueColor(zones, "ui")
|
|
worldZones = withTrueColor(worldZones, "world")
|
|
|
|
local needPresent = GBCFX.active()
|
|
local present = nil
|
|
if needPresent then
|
|
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
|
or self.presentCanvas:getHeight() ~= wh then
|
|
self.presentCanvas = love.graphics.newCanvas(ww, wh)
|
|
self.presentCanvas:setFilter("linear", "linear")
|
|
end
|
|
present = self.presentCanvas
|
|
love.graphics.setCanvas(present)
|
|
end
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
|
|
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to
|
|
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
|
|
-- coords (canvas-space) into screen pixels.
|
|
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
|
|
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
|
if not shader then
|
|
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
|
love.graphics.draw(canvas, bx, by, 0, scale, scale)
|
|
love.graphics.setScissor()
|
|
return
|
|
end
|
|
love.graphics.setShader(shader)
|
|
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
|
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
|
-- list never toggles and issues exactly the calls it always did.
|
|
local bare = false
|
|
for _, z in ipairs(zoneList) do
|
|
local plain = z.colors == false
|
|
if plain ~= bare then
|
|
bare = plain
|
|
love.graphics.setShader(not plain and shader or nil)
|
|
end
|
|
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
|
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale,
|
|
z.w * zoneScale, z.h * zoneScale,
|
|
boxX, boxY, boxW, boxH) then
|
|
love.graphics.draw(canvas, bx, by, 0, scale, scale)
|
|
end
|
|
end
|
|
love.graphics.setScissor()
|
|
love.graphics.setShader()
|
|
end
|
|
|
|
if self.worldActive then
|
|
local s = Zoom.scale(S)
|
|
local wvw = self.worldCanvas:getWidth()
|
|
local wvh = self.worldCanvas:getHeight()
|
|
local wox = math.floor((ww - wvw * s) / 2)
|
|
local woy = math.floor((wh - wvh * s) / 2)
|
|
-- Tilt mode projects the ground world pass through the perspective mesh
|
|
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
|
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
|
-- projection is unavailable (headless / no shader); then the ground
|
|
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
|
-- identical to today.
|
|
local projected =
|
|
Tilt.active() and self:drawTiltedWorld(worldZones or zones, s, wox, woy, present)
|
|
if not projected then
|
|
if worldZones then
|
|
blit(self.worldCanvas, s, worldZones, s, wox, woy, 0, 0, ww, wh)
|
|
else
|
|
blit(self.worldCanvas, s, zones, S, wox, woy, 0, 0, ww, wh)
|
|
end
|
|
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
|
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
|
-- feet-overdraw entries carry `colors` and re-colorize through the
|
|
-- color-0-keyed shade-remap shader so they keep hiding sprite feet.
|
|
local redraws = PaletteFX.spriteRedraws()
|
|
if redraws[1] then
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
local activeShader = nil
|
|
for _, r in ipairs(redraws) do
|
|
local wanted = r.colors
|
|
and (r.keyed and PaletteFX.keyedShader() or PaletteFX.shader())
|
|
or nil
|
|
if wanted ~= activeShader then
|
|
activeShader = wanted
|
|
love.graphics.setShader(wanted)
|
|
end
|
|
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
|
if r.quad then
|
|
love.graphics.draw(r.image, r.quad, wox + r.x * s, woy + r.y * s,
|
|
0, s * r.sx, s)
|
|
else
|
|
love.graphics.draw(r.image, wox + r.x * s, woy + r.y * s,
|
|
0, s * r.sx, s)
|
|
end
|
|
end
|
|
if activeShader then love.graphics.setShader() end
|
|
love.graphics.setScissor()
|
|
end
|
|
end
|
|
-- Composite the tilt upright pass over the ground (projected or, in the
|
|
-- rare no-shader fallback, flat). It already carries its billboards'
|
|
-- projected positions and per-sprite SGB colorization on a transparent
|
|
-- canvas, so it just needs the same centred integer-scale blit the flat
|
|
-- world pass uses -- no zone scissoring. uprightActive is only ever
|
|
-- set in tilt mode, so flat frames skip this and stay identical.
|
|
if self.uprightActive then
|
|
local M = self.UPRIGHT_MARGIN
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
|
|
love.graphics.setScissor()
|
|
end
|
|
end
|
|
-- UI stays in the classic centered GB letterbox
|
|
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
|
|
|
if present then
|
|
love.graphics.setCanvas()
|
|
GBCFX.present(present, S)
|
|
end
|
|
self.worldActive = false
|
|
self.uprightActive = false
|
|
PaletteFX.setPass(nil)
|
|
end
|
|
|
|
return Renderer
|