initial commit

This commit is contained in:
bryanthaboi
2026-07-17 20:30:02 -04:00
commit a5d2e77e7d
298 changed files with 100561 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
-- The into-battle transition (engine/battle/battle_transitions.asm):
-- one of the original's eight wipes selected by three bits, trainer
-- battle (bit 0), enemy at least 3 levels above the lead (bit 1),
-- dungeon map (bit 2):
-- %000 DoubleCircle %001 Spiral(in) %010 Circle %011 Spiral(out)
-- %100 HStripes %101 Shrink %110 VStripes %111 Split
-- Only the two circle wipes flash the screen first (only they call
-- BattleTransition_FlashScreen); the spiral runs inward unless the
-- enemy is stronger (wBattleTransitionSpiralDirection).
-- Pushed above the overworld; pops itself and runs onDone at the end.
local BattleTransition = {}
BattleTransition.__index = BattleTransition
BattleTransition.isOpaque = false -- draws over the frozen overworld
-- BattleTransition_FlashScreenPalettes: fade to black and back, then to
-- white and back; each palette held 2 frames, whole sequence played 3
-- times. Positive = black overlay strength, negative = white.
local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0,
-1 / 3, -2 / 3, -1, -2 / 3, -1 / 3, 0 }
local FLASH_HOLD = 2 -- frames per palette step
local FLASH_CYCLES = 3
local TILE = 8
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
-- outward spiral (%011): BattleTransition_OutwardSpiral_ walks from
-- (10,10) counterclockwise (right/up/left/down), turning whenever the
-- tile on its outer side is unfilled; 120 frames x 3 fills = 360 fills
-- on linear tilemap memory. At the screen edges the walk reads (and
-- fills) adjacent WRAM, so the left column and part of the top row stay
-- unfilled until the final blackout, reproduced here by tracking those
-- cells but not drawing them.
local function outwardSpiralOrder()
local order, filled = {}, {}
local addr = 10 * COLS + 10 -- hlcoord 10,10
local dir = 3 -- 0 up / 1 left / 2 down / 3 right
local checkOff = { [0] = -1, [1] = COLS, [2] = 1, [3] = -COLS }
local moveOff = { [0] = -COLS, [1] = -1, [2] = COLS, [3] = 1 }
for _ = 1, COLS * ROWS do
local checked = addr + checkOff[dir]
if not filled[checked] then
addr = checked
dir = (dir + 1) % 4
else
addr = addr + moveOff[dir]
end
if not filled[addr] then
filled[addr] = true
if addr >= 0 and addr < COLS * ROWS then
order[#order + 1] = { addr % COLS, math.floor(addr / COLS) }
end
end
end
return order
end
-- inward spiral (%001): BattleTransition_InwardSpiral starts at (0,0)
-- and walks the perimeter counterclockwise, down the left edge, right
-- along the bottom, up the right edge, left along the top, spiraling
-- in; 359 fills, the center tile is left for the final blackout
local function inwardSpiralOrder()
local order = {}
local x, y = 0, 0
local function run(dx, dy, n)
for _ = 1, n do
order[#order + 1] = { x, y }
x, y = x + dx, y + dy
end
end
run(0, 1, 17) -- SCREEN_HEIGHT - 1
local c = 18
while true do
c = c + 1
run(1, 0, c) -- right
c = c - 2
run(0, -1, c) -- up
c = c + 1
run(-1, 0, c) -- left
c = c - 2
if c == 0 then break end
run(0, 1, c) -- down
end
return order
end
-- sweep order (the Circle wipes): tiles sorted by angle from the center.
-- pokered sweeps counterclockwise starting at the right edge middle
-- (BattleTransition_HalfCircle1 runs (18,6) up over the top to (1,6);
-- HalfCircle2 continues (1,11) down under the bottom back to (18,11)).
-- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves
-- at once, so opposite arms)
local function sweepOrder(arms)
local cx, cy = COLS / 2, ROWS / 2
local tiles = {}
for y = 0, ROWS - 1 do
for x = 0, COLS - 1 do
local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx)
if a < 0 then a = a + 2 * math.pi end
if arms == 2 then a = a % math.pi end
tiles[#tiles + 1] = { x, y, a }
end
end
table.sort(tiles, function(p, q) return p[3] < q[3] end)
return tiles
end
local ORDERS = {} -- cached per style
local function orderFor(style)
if not ORDERS[style] then
if style == "spiralout" then
ORDERS[style] = outwardSpiralOrder()
elseif style == "spiralin" then
ORDERS[style] = inwardSpiralOrder()
elseif style == "circle" then
ORDERS[style] = sweepOrder(1)
elseif style == "doublecircle" then
ORDERS[style] = sweepOrder(2)
end
end
return ORDERS[style]
end
-- opts: trainer (bool), stronger (bool), dungeon (bool)
function BattleTransition.new(game, onDone, opts)
local self = setmetatable({}, BattleTransition)
self.game = game
self.onDone = onDone
self.t = 0
opts = opts or {}
local bits = (opts.trainer and 1 or 0) + (opts.stronger and 2 or 0)
+ (opts.dungeon and 4 or 0)
self.style = ({ [0] = "doublecircle", "spiralin", "circle", "spiralout",
"hstripes", "shrink", "vstripes", "split" })[bits]
-- only the circle wipes flash first (battle_transitions.asm:585,628)
self.phase = (self.style == "circle" or self.style == "doublecircle")
and "flash" or "wipe"
self.wipeLen = (self.style == "spiralin" or self.style == "spiralout"
or self.style == "circle"
or self.style == "doublecircle") and 40 or 24
return self
end
function BattleTransition:update(dt)
self.t = self.t + 1
if self.phase == "flash" then
if self.t >= FLASH_CYCLES * #FLASH_STEPS * FLASH_HOLD then
self.phase = "wipe"
self.t = 0
end
else
if self.t >= self.wipeLen + 6 then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
end
function BattleTransition:draw()
if self.phase == "flash" then
local step = math.floor(self.t / FLASH_HOLD) % #FLASH_STEPS + 1
local v = FLASH_STEPS[step]
if v ~= 0 then
local shade = v > 0 and 0 or 1
love.graphics.setColor(shade, shade, shade, math.abs(v))
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
return
end
love.graphics.setColor(0, 0, 0, 1)
local prog = math.min(1, self.t / self.wipeLen)
local style = self.style
local order = orderFor(style)
if order then
-- tile-order wipes: spiral / circle sweeps
local n = math.floor(#order * prog)
for i = 1, n do
local c = order[i]
love.graphics.rectangle("fill", c[1] * TILE, c[2] * TILE, TILE, TILE)
end
elseif style == "hstripes" then
-- interlaced rows wipe from alternating sides
local w = math.floor(160 * prog)
for row = 0, ROWS - 1 do
local y = row * TILE
if row % 2 == 0 then
love.graphics.rectangle("fill", 0, y, w, TILE)
else
love.graphics.rectangle("fill", 160 - w, y, w, TILE)
end
end
elseif style == "vstripes" then
-- interlaced columns wipe from alternating ends
local h = math.floor(144 * prog)
for col = 0, COLS - 1 do
local x = col * TILE
if col % 2 == 0 then
love.graphics.rectangle("fill", x, 0, TILE, h)
else
love.graphics.rectangle("fill", x, 144 - h, TILE, h)
end
end
elseif style == "shrink" then
-- the image squashes toward the middle: the asm shifts rows and
-- columns inward in the same loop, so bars close from all four
-- edges at once
local h = math.floor(72 * prog)
local w = math.floor(80 * prog)
love.graphics.rectangle("fill", 0, 0, 160, h)
love.graphics.rectangle("fill", 0, 144 - h, 160, h)
love.graphics.rectangle("fill", 0, 0, w, 144)
love.graphics.rectangle("fill", 160 - w, 0, w, 144)
else -- split: the quarters tear apart from the middle; the asm shifts
-- rows and columns outward each loop, so a black cross grows from
-- the center in both axes at once
local h = math.floor(72 * prog)
local w = math.floor(80 * prog)
love.graphics.rectangle("fill", 0, 72 - h, 160, h * 2)
love.graphics.rectangle("fill", 80 - w, 0, w * 2, 144)
end
if prog >= 1 then
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
love.graphics.setColor(1, 1, 1, 1)
end
return BattleTransition
+20
View File
@@ -0,0 +1,20 @@
-- Camera centered on the player. At the default 160x144 view this is
-- the original framing (player sprite at screen tile (8,8) -> pixel
-- (64, 60) after the -4px sprite offset); wider/taller world-pass views
-- (window-filling survey on phones, wheel zoom-out) keep the player at
-- the same relative center.
local Camera = {}
Camera.__index = Camera
function Camera.new()
return setmetatable({ x = 0, y = 0 }, Camera)
end
function Camera:follow(px, py, viewW, viewH)
viewW, viewH = viewW or 160, viewH or 144
self.x = px - (viewW / 2 - 16)
self.y = py - (viewH / 2 - 8)
end
return Camera
+116
View File
@@ -0,0 +1,116 @@
-- Text renderer using the real extracted font sheets and charmap.
-- font.png holds glyph codes $80-$FF, font_extra.png $60-$7F (borders etc).
-- The charmap is matched greedily (longest sequence first) so multi-byte
-- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs.
local Font = {}
local state
function Font.load(data)
local def = data.font
local main = love.graphics.newImage(def.image)
local extra = love.graphics.newImage(def.imageExtra)
state = {
def = def,
main = main,
extra = extra,
mainQuads = {},
extraQuads = {},
byFirstByte = {},
}
local function buildQuads(img, quads)
local iw, ih = img:getDimensions()
local perRow = iw / 8
for i = 0, perRow * (ih / 8) - 1 do
quads[i] = love.graphics.newQuad((i % perRow) * 8,
math.floor(i / perRow) * 8, 8, 8, iw, ih)
end
end
buildQuads(main, state.mainQuads)
buildQuads(extra, state.extraQuads)
-- charmap comes sorted longest-first from the extractor; bucket by first
-- byte for fast greedy matching
for _, entry in ipairs(def.charmap) do
local b = entry.seq:byte(1)
state.byFirstByte[b] = state.byFirstByte[b] or {}
table.insert(state.byFirstByte[b], entry)
end
end
-- Convert a text string into a list of glyph codes. Unknown characters
-- render as space (and are reported once).
local reported = {}
function Font.encode(text)
local codes = {}
local i = 1
while i <= #text do
local candidates = state.byFirstByte[text:byte(i)]
local matched = false
if candidates then
for _, entry in ipairs(candidates) do
local n = #entry.seq
if text:sub(i, i + n - 1) == entry.seq then
codes[#codes + 1] = entry.code
i = i + n
matched = true
break
end
end
end
if not matched then
local ch = text:sub(i, i)
if not reported[ch] and ch:byte() >= 32 then
reported[ch] = true
require("src.core.Logger").warn("font: no glyph for %q", ch)
end
codes[#codes + 1] = 0x7F -- space
i = i + 1
end
end
return codes
end
function Font.drawCode(code, x, y)
local def = state.def
if code >= def.mainBase then
love.graphics.draw(state.main, state.mainQuads[code - def.mainBase], x, y)
elseif code >= def.extraBase then
love.graphics.draw(state.extra, state.extraQuads[code - def.extraBase], x, y)
end
end
-- Draw a plain single-line string at pixel (x, y).
function Font.draw(text, x, y)
local codes = Font.encode(text)
for i, code in ipairs(codes) do
Font.drawCode(code, x + (i - 1) * 8, y)
end
return #codes * 8
end
-- Border glyph codes (font_extra.png, from charmap.asm $79-$7E)
Font.BORDER = {
tl = 0x79, h = 0x7A, tr = 0x7B, v = 0x7C, bl = 0x7D, br = 0x7E,
}
-- Draw a Game Boy style bordered box in tile coordinates.
function Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
local B = Font.BORDER
Font.drawCode(B.tl, tx * 8, ty * 8)
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
Font.drawCode(B.bl, tx * 8, (ty + th - 1) * 8)
Font.drawCode(B.br, (tx + tw - 1) * 8, (ty + th - 1) * 8)
for i = 1, tw - 2 do
Font.drawCode(B.h, (tx + i) * 8, ty * 8)
Font.drawCode(B.h, (tx + i) * 8, (ty + th - 1) * 8)
end
for j = 1, th - 2 do
Font.drawCode(B.v, tx * 8, (ty + j) * 8)
Font.drawCode(B.v, (tx + tw - 1) * 8, (ty + j) * 8)
end
end
return Font
+273
View File
@@ -0,0 +1,273 @@
-- GBC Effects post-process ("Pixel Transparency" style, see
-- github.com/mattakins/Pixel_Transparency). A cumulative 4-level ladder
-- applied after palette colorization and before the CRT pass:
-- 1 reflective screen: bright pixels blend toward a procedurally
-- grained warm backing (the unlit-GBC "transparent whites" look)
-- 2 + LCD subpixel grid
-- 3 + drop shadows (dark pixels float above the backing)
-- 4 + sunlight: specular glare + rainbow QWP shimmer with a slowly
-- drifting light source
-- Levels OFF/1/2/3/4 persist as save.options.gbcfx; hotkey 5 cycles.
-- Spec: docs/new-features.md (Custom Options / GBC FX)
--
-- One shader for all levels: features are gated by the `level` uniform
-- (float comparisons), so cycling never recompiles. All spatial effects
-- key off `pixelScale` (screen pixels per GB pixel) so grid pitch,
-- shadow offsets and grain stay window-size independent.
local GBCFX = {}
GBCFX.LABELS = { "OFF", "1", "2", "3", "4" }
GBCFX.level = 0
local shader -- false = unavailable (headless / no shader support)
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
-- shadow blur are unrolled by hand).
local SHADER_SRC = [[
extern number level;
extern number time;
extern number pixelScale; // screen pixels per GB pixel (integer fit scale)
#define PI 3.14159265359
// ---- level thresholds (cumulative ladder) ----
#define L_GRID 1.5
#define L_SHADOW 2.5
#define L_SUN 3.5
// ---- level 1: reflective backing ----
#define BACK_BRIGHTNESS 0.48
#define GRAIN_INTENSITY 0.065
// #A6AC84 "Pocket" backing tint, normalized to unit mean brightness
#define POCKET_TINT vec3(1.0596, 1.0979, 0.8424)
#define BASE_ALPHA 0.20
#define WHITE_EXTRA 0.75
// front polarizer film tint
#define POLARIZER vec3(0.94, 1.0, 0.865)
// ---- level 2: LCD grid (lcd1x style) ----
#define BRIGHTEN_SCANLINES 16.0
#define BRIGHTEN_LCD 4.0
// ---- level 3: drop shadow ----
#define SHADOW_OFFSET 3.0
#define SHADOW_OPACITY 0.5
// ---- level 4: sunlight ----
#define GLARE_INTENSITY 0.15
#define GLARE_SIGMA 0.25
#define SHIMMER_INTENSITY 0.25
// chroma amplification so the bands read on the already-desaturated,
// backing-blended image (reference applies 0.25 to raw film reflectance)
#define SHIMMER_CHROMA_GAIN 3.0
#define SHIMMER_SPREAD 1.8
#define LIGHT_RANGE 0.6
#define FILM_NOISE_AMOUNT 0.5
#define REFLECT_FLOOR 0.03
float hash21(vec2 p)
{
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
// smooth value noise, ~[0,1]
float vnoise(vec2 p)
{
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float luma(vec3 c)
{
return dot(c, vec3(0.2126, 0.7152, 0.0722));
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
{
vec4 src = Texel(tex, tc);
if (level < 0.5) {
return src * color;
}
float ps = max(pixelScale, 1.0);
vec2 gbPix = pc / ps; // GB-pixel coordinates
vec2 texel = 1.0 / love_ScreenSize.xy; // one screen pixel in tc
vec2 gbTexel = texel * ps; // one GB pixel in tc
// Drifting light position (normalized screen coords, upper area).
// Computed unconditionally: level 3 borrows it for shadow drift.
vec2 lightPos = vec2(0.5 + 0.35 * sin(time * 0.13),
0.3 + 0.2 * sin(time * 0.07));
// ---- level 1: procedural backing material ----
// flat gray + 3-octave paper grain, tinted warm
float grain = vnoise(gbPix * 0.9) * 0.5
+ vnoise(gbPix * 2.1 + vec2(17.0, 5.0)) * 0.3
+ vnoise(gbPix * 4.3 + vec2(3.0, 29.0)) * 0.2;
float backLum = BACK_BRIGHTNESS + (grain - 0.5) * (GRAIN_INTENSITY * 2.0);
vec3 back = backLum * POCKET_TINT;
// ---- level 3: dark pixels cast a soft shadow onto the backing ----
if (level >= L_SHADOW) {
vec2 shOff = vec2(SHADOW_OFFSET);
if (level >= L_SUN) {
// subtle drift opposite the light's wander
shOff += vec2((0.5 - lightPos.x) * 3.0, (0.3 - lightPos.y) * 3.0);
}
vec2 so = tc - shOff * gbTexel;
vec2 e = gbTexel;
// 9-tap gaussian blur of the offset sample's brightness (unrolled)
float s = 0.0;
s += luma(Texel(tex, so).rgb) * 4.0;
s += luma(Texel(tex, so + vec2( e.x, 0.0)).rgb) * 2.0;
s += luma(Texel(tex, so + vec2(-e.x, 0.0)).rgb) * 2.0;
s += luma(Texel(tex, so + vec2(0.0, e.y)).rgb) * 2.0;
s += luma(Texel(tex, so + vec2(0.0, -e.y)).rgb) * 2.0;
s += luma(Texel(tex, so + vec2( e.x, e.y)).rgb) * 1.0;
s += luma(Texel(tex, so + vec2( e.x, -e.y)).rgb) * 1.0;
s += luma(Texel(tex, so + vec2(-e.x, e.y)).rgb) * 1.0;
s += luma(Texel(tex, so + vec2(-e.x, -e.y)).rgb) * 1.0;
s /= 16.0;
float dark = 1.0 - s;
// deadzone: near-white pixels (dark ~ 0) cast no shadow at all
float shadow = dark * smoothstep(0.08, 0.30, dark) * SHADOW_OPACITY;
back = mix(back, back * 0.2, shadow);
}
// ---- level 2: LCD subpixel grid on the lit image only ----
vec3 lit = src.rgb;
if (level >= L_GRID) {
vec2 angle = 2.0 * PI * (gbPix - 0.25);
float yfac = (BRIGHTEN_SCANLINES + sin(angle.y))
/ (BRIGHTEN_SCANLINES + 1.0);
float xfac = (BRIGHTEN_LCD + sin(angle.x)) / (BRIGHTEN_LCD + 1.0);
lit *= yfac * xfac;
}
// ---- level 1: brightness-proportional pixel transparency ----
float lum = luma(src.rgb);
float a = BASE_ALPHA * lum;
// near-white pixels (luma > 0.90 AND min channel > 0.81) are nearly
// fully transparent -- narrow smoothsteps stand in for the hard AND
float mn = min(src.r, min(src.g, src.b));
a += WHITE_EXTRA * smoothstep(0.88, 0.92, lum) * smoothstep(0.79, 0.83, mn);
vec3 col = mix(lit, back, clamp(a, 0.0, 1.0));
// ---- level 4: sunlight (glare + rainbow QWP shimmer) ----
float glare = 0.0;
if (level >= L_SUN) {
float aspect = love_ScreenSize.x / love_ScreenSize.y;
vec2 p = vec2(tc.x * aspect, tc.y);
vec2 lp = vec2(lightPos.x * aspect, lightPos.y);
float d = distance(p, lp);
// specular gaussian hotspot (added after the polarizer tint:
// it reflects off the front glass, not the LCD)
glare = GLARE_INTENSITY * exp(-d * d / (2.0 * GLARE_SIGMA * GLARE_SIGMA));
// quarter-wave-plate film: effective retardance (nm) grows with
// distance from the light point -> concentric interference bands;
// smooth "film thickness" noise makes the bands splotchy
float film = vnoise(gbPix * 0.06 + vec2(7.3, 2.9) + time * 0.01);
float gammaEff = (260.0 + 620.0 * SHIMMER_SPREAD * (d / LIGHT_RANGE))
* (1.0 + FILM_NOISE_AMOUNT * (film - 0.5));
float ph = 4.0 * PI * gammaEff;
// 7 wavelength samples 400..700nm with approximate spectral RGB,
// unrolled (no const arrays in GLSL 1.20)
vec3 rb = vec3(0.0);
float cw;
cw = cos(ph / 400.0); rb += cw * cw * vec3(0.15, 0.00, 0.50);
cw = cos(ph / 450.0); rb += cw * cw * vec3(0.00, 0.10, 1.00);
cw = cos(ph / 500.0); rb += cw * cw * vec3(0.00, 0.80, 0.40);
cw = cos(ph / 550.0); rb += cw * cw * vec3(0.20, 1.00, 0.00);
cw = cos(ph / 600.0); rb += cw * cw * vec3(1.00, 0.60, 0.00);
cw = cos(ph / 650.0); rb += cw * cw * vec3(1.00, 0.10, 0.00);
cw = cos(ph / 700.0); rb += cw * cw * vec3(0.70, 0.00, 0.00);
rb /= vec3(3.05, 2.60, 1.90); // per-channel weight sums -> peak 1.0
// fade with distance from the light, kill on dark pixels,
// weight by pixel color squared
float att = 1.0 - smoothstep(0.0, LIGHT_RANGE, d);
float refl = max(lum, REFLECT_FLOOR);
// luminance-preserving tint: add only the chroma of the rainbow
vec3 shimmer = (rb - vec3(luma(rb))) * SHIMMER_CHROMA_GAIN
* src.rgb * src.rgb * refl * att;
col += shimmer * SHIMMER_INTENSITY;
}
// front polarizer tint, then front-surface glare on top
col *= POLARIZER;
col += vec3(glare);
return vec4(col, src.a) * color;
}
]]
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
function GBCFX.shader()
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
shader = ok and sh or false
end
return shader or nil
end
function GBCFX.setLevel(level)
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
if level > 4 then level = 4 end
GBCFX.level = level
end
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
function GBCFX.cycle()
GBCFX.setLevel((GBCFX.level + 1) % 5)
return GBCFX.level
end
function GBCFX.applyOptions(opts)
GBCFX.setLevel(opts and opts.gbcfx or 0)
end
function GBCFX.levelLabel(level)
return GBCFX.LABELS[(level or GBCFX.level) + 1] or "OFF"
end
function GBCFX.active()
return GBCFX.level > 0 and GBCFX.shader() ~= nil
end
-- Draw `canvas` fullscreen through the GBC FX shader into the current
-- render target (or plain if the shader is unavailable). pixelScale is
-- the integer screen-pixels-per-GB-pixel scale so grid/shadow offsets
-- stay window-size independent.
function GBCFX.present(canvas, pixelScale)
local sh = GBCFX.shader()
if not sh or GBCFX.level <= 0 then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(canvas, 0, 0)
return
end
local t = 0
if love.timer and love.timer.getTime then
t = love.timer.getTime()
end
sh:send("level", GBCFX.level)
sh:send("time", t)
sh:send("pixelScale", math.max(1, math.floor(tonumber(pixelScale) or 1)))
love.graphics.setShader(sh)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(canvas, 0, 0)
love.graphics.setShader()
end
return GBCFX
+77
View File
@@ -0,0 +1,77 @@
-- In-battle HUD tiles, shared by the battle screen and the status
-- screen: pokered overlays the $62-$7F font area with the HP bar /
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
local HudTiles = {}
local tiles
function HudTiles.tile(code, x, y, tint)
if not tiles then
tiles = {}
local function add(path, base)
local ok, img = pcall(love.graphics.newImage, path)
if not ok then return end
local iw, ih = img:getDimensions()
local per = iw / 8
for i = 0, per * (ih / 8) - 1 do
tiles[base + i] = {
img = img,
quad = love.graphics.newQuad((i % per) * 8,
math.floor(i / per) * 8, 8, 8, iw, ih),
}
end
end
add("assets/generated/battle/font_battle_extra.png", 0x62)
add("assets/generated/battle/battle_hud_1.png", 0x6D) -- overrides
add("assets/generated/battle/battle_hud_2.png", 0x73)
add("assets/generated/battle/battle_hud_3.png", 0x76)
end
local t = tiles[code]
if not t then return end
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(tint or { 1, 1, 1, 1 })
love.graphics.draw(t.img, t.quad, x, y)
love.graphics.setColor(r, g, b, a)
end
-- The bar's right-end tile follows wHPBarType (DrawHPBar's "Right"
-- branch): only type 1 -- the player's in-battle bar and the status
-- screen -- gets the double-bar $6D; the enemy bar (0) and the party
-- menu (2) close with the near-blank $6C nub.
function HudTiles.capTile(barType)
return barType == 1 and 0x6D or 0x6C
end
-- Tile HP bar (home/pokemon.asm DrawHPBar): "HP" ($71) + ":[" ($62),
-- six 8px segments ($63 empty, +n partial, $6B full), then the
-- wHPBarType right cap. A nonzero HP always shows at least a
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red).
function HudTiles.drawHPBar(data, tx, ty, mon, barType)
local x, y = tx * 8, ty * 8
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
local px = 0
if mon.stats.hp > 0 and mon.hp > 0 then
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
end
local tint
local pals = data.palettes
if pals then
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local c = pals.palettes[name][3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 }
end
for i = 0, 5 do
local seg = math.min(8, math.max(0, px - i * 8))
HudTiles.tile(seg >= 8 and 0x6B or 0x63 + seg, x + 16 + i * 8, y, tint)
end
HudTiles.tile(HudTiles.capTile(barType), x + 64, y)
end
return HudTiles
+198
View File
@@ -0,0 +1,198 @@
-- SGB-style colorization post-pass. The Super Game Boy colored the DMG
-- picture by assigning 4-color palettes to rectangular screen regions
-- (ATTR_BLK packets, data/sgb/sgb_packets.asm). States expose
-- sgbPalettes() returning a list of zones; the finished 160x144 frame is
-- then drawn once per zone through a shader that remaps the four DMG
-- shades to that zone's palette.
--
-- Port display option: COLORS (GBC / OG / OG INV / GBC INV / CLASSIC)
-- transforms every zone's palette at send time via effectiveColors.
local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
-- Cycle order matches OptionsMenu / hotkey 2
PaletteFX.MODES = { "gbc", "og", "og_inv", "gbc_inv", "classic" }
PaletteFX.MODE_LABELS = {
gbc = "GBC", og = "OG", og_inv = "OG INV",
gbc_inv = "GBC INV", classic = "CLASSIC",
}
PaletteFX.mode = "gbc"
-- Classic DMG pea-soup greens (#9BBC0F / #8BAC0F / #306230 / #0F380F)
PaletteFX.CLASSIC = {
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
}
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, [[
extern vec3 c0; extern vec3 c1; extern vec3 c2; extern vec3 c3;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
vec3 mapped = p.r > 0.83 ? c0 : (p.r > 0.5 ? c1 : (p.r > 0.17 ? c2 : c3));
return vec4(mapped, p.a);
}
]])
shader = ok and sh or false
end
return shader or nil
end
-- Shade-remap variant that also keys shade 0 (DMG white / lightest gray)
-- to transparent -- the GB OBJ-to-BG priority trick. Tilt mode's upright
-- pass uses it for tall-grass feet overdraw: the patch must be colorized
-- to match the ground grass it hides, yet let the sprite show through the
-- grass tile's white gaps. The flat path gets this from TileRenderer's
-- color-0 key plus the whole-canvas zone colorization at blit time; the
-- upright canvas is composited with no zone pass, so the two are fused
-- into one shader here. Same c0..c3 uniforms as shader(), so sendColors
-- feeds it identically.
local keyedShader -- false = unavailable (headless / no shader support)
function PaletteFX.keyedShader()
if keyedShader == nil then
local ok, sh = pcall(love.graphics.newShader, [[
extern vec3 c0; extern vec3 c1; extern vec3 c2; extern vec3 c3;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
vec3 mapped = p.r > 0.83 ? c0 : (p.r > 0.5 ? c1 : (p.r > 0.17 ? c2 : c3));
float a = (p.r > 0.83 && p.g > 0.83 && p.b > 0.83) ? 0.0 : p.a;
return vec4(mapped, a);
}
]])
keyedShader = ok and sh or false
end
return keyedShader or nil
end
-- ATTR_BLK inclusive tile rect -> pixel-space zone
function PaletteFX.zone(colors, tx1, ty1, tx2, ty2)
if not colors then return nil end
return { colors = colors, x = tx1 * 8, y = ty1 * 8,
w = (tx2 - tx1 + 1) * 8, h = (ty2 - ty1 + 1) * 8 }
end
function PaletteFX.whole(colors)
return PaletteFX.zone(colors, 0, 0, 19, 17)
end
-- named palette from data/generated/palettes.lua (nil on stale builds)
function PaletteFX.pal(data, name)
local p = data.palettes
return p and p.palettes[name] or nil
end
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
-- Transformed mon's pic is tinted gray, not the copied species' own
-- SGB color).
function PaletteFX.monPal(data, species, transformed)
local p = data.palettes
if not p then return nil end
if transformed then return p.palettes.GRAYMON end
return p.palettes[p.pokemon[species] or "MEWMON"]
end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
function PaletteFX.barPalName(hp, maxHp)
local px = maxHp > 0 and math.floor(hp * 48 / maxHp) or 0
if hp > 0 and px < 1 then px = 1 end
return px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
end
-- convenience: a single whole-screen zone for a named palette
function PaletteFX.wholeNamed(data, name)
local c = PaletteFX.pal(data, name)
return c and { PaletteFX.whole(c) } or nil
end
-- The four DMG grays the extracted art uses (255/170/85/0), as a
-- palette-shaped table -- shade index 0 (lightest) first, like the SGB
-- palettes in data/generated/palettes.lua.
PaletteFX.GRAYS = { { 255, 255, 255 }, { 170, 170, 170 },
{ 85, 85, 85 }, { 0, 0, 0 } }
-- Permute a 4-color palette through a BGP-style shade map
-- (map[i] = the shade color index i displays as, i = 0..3). Emulates
-- pokered's SetAnimationBGPalette / AnimationFlashScreen* writes to
-- rBGP composed with the SGB colorization: the SGB colors the remapped
-- DMG shade, so a screen region shows palette[map[shade]].
function PaletteFX.permute(colors, map)
if not map then return colors end
return { colors[map[0] + 1], colors[map[1] + 1],
colors[map[2] + 1], colors[map[3] + 1] }
end
function PaletteFX.setMode(mode)
for _, m in ipairs(PaletteFX.MODES) do
if m == mode then
PaletteFX.mode = mode
return
end
end
PaletteFX.mode = "gbc"
end
function PaletteFX.cycleMode()
local cur = PaletteFX.mode or "gbc"
local idx = 1
for i, m in ipairs(PaletteFX.MODES) do
if m == cur then idx = i; break end
end
PaletteFX.mode = PaletteFX.MODES[idx % #PaletteFX.MODES + 1]
return PaletteFX.mode
end
function PaletteFX.applyOptions(opts)
PaletteFX.setMode(opts and opts.colors or "gbc")
end
function PaletteFX.modeLabel(mode)
return PaletteFX.MODE_LABELS[mode or PaletteFX.mode] or "GBC"
end
-- When a state exposes no SGB zones but COLORS needs a forced palette
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
-- shader still runs. GBC / GBC INV leave nil alone (raw DMG canvas).
function PaletteFX.ensureZones(zones)
if zones and zones[1] then return zones end
local mode = PaletteFX.mode or "gbc"
if mode == "og" or mode == "og_inv" or mode == "classic" then
return { PaletteFX.whole(PaletteFX.GRAYS) }
end
return zones
end
-- Transform a 4-color palette for the active COLORS display mode.
function PaletteFX.effectiveColors(c)
if not c then return nil end
local mode = PaletteFX.mode or "gbc"
if mode == "og" then
return PaletteFX.GRAYS
elseif mode == "og_inv" then
return PaletteFX.permute(PaletteFX.GRAYS, INV_MAP)
elseif mode == "classic" then
return PaletteFX.CLASSIC
elseif mode == "gbc_inv" then
return PaletteFX.permute(c, INV_MAP)
end
return c
end
-- send a 4-color (0-255 RGB) palette to the shade-remap shader, after
-- applying the active COLORS display mode
function PaletteFX.sendColors(shader, c)
c = PaletteFX.effectiveColors(c)
if not c then return end
shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 })
shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 })
shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 })
shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 })
end
return PaletteFX
+350
View File
@@ -0,0 +1,350 @@
-- 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 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
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
love.graphics.setCanvas(self.worldCanvas)
love.graphics.clear(1, 1, 1, 1)
end
function Renderer:endWorldPass()
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
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()
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 PaletteFX = require("src.render.PaletteFX")
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)
for _, z in ipairs(zoneList) do
PaletteFX.sendColors(zoneShader, z.colors)
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
-- 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 PaletteFX = require("src.render.PaletteFX")
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
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)
for _, z in ipairs(zoneList) do
PaletteFX.sendColors(shader, z.colors)
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
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
end
return Renderer
+61
View File
@@ -0,0 +1,61 @@
-- Overworld character sprites. A 12-tile sheet (16x96 PNG) holds 6 16x16
-- frames: stand down/up/left, walk down/up/left (data/sprites/facings.asm).
-- Right-facing frames are horizontal flips of the left frames.
-- Sprites draw 4px above their cell, like the GB engine.
local SpriteRenderer = {}
SpriteRenderer.__index = SpriteRenderer
local imageCache = {}
local function getImage(path)
if not imageCache[path] then
imageCache[path] = love.graphics.newImage(path)
end
return imageCache[path]
end
local STAND = { down = 0, up = 1, left = 2, right = 2 }
local WALK = { down = 3, up = 4, left = 5, right = 5 }
function SpriteRenderer.new(spriteDef)
local self = setmetatable({}, SpriteRenderer)
self.def = spriteDef
self.image = getImage(spriteDef.image)
local iw, ih = self.image:getDimensions()
self.frames = {}
for f = 0, spriteDef.frames - 1 do
self.frames[f] = love.graphics.newQuad(0, f * 16, 16, 16, iw, ih)
end
return self
end
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then
love.graphics.draw(self.image, self.frames[0], x, y)
return
end
local frame = (self.def.walker and walkPhase == 1)
and WALK[facing] or STAND[facing]
local flip = false
if facing == "right" then
flip = true
elseif (facing == "down" or facing == "up") and walkPhase == 1 and stepFlip then
flip = true
end
local quad = self.frames[frame] or self.frames[0]
if flip then
love.graphics.draw(self.image, quad, x + 16, y, 0, -1, 1)
else
love.graphics.draw(self.image, quad, x, y)
end
end
return SpriteRenderer
+220
View File
@@ -0,0 +1,220 @@
-- The lower dialogue box: bordered 20x6-tile window, typewriter effect,
-- two visible text lines, A to advance.
--
-- Text markers (from the extractor): \n = second line, \v = scroll one
-- line up, \f = page break (wait for A, clear). {PLAYER}/{RIVAL} etc. are
-- substituted before display. Pushed on the state stack; pops itself when
-- the text is exhausted and A is pressed, then calls onDone.
local Font = require("src.render.Font")
local TextBox = {}
TextBox.__index = TextBox
local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6
local LINE1_Y, LINE2_Y = (BOX_TY + 2) * 8, (BOX_TY + 4) * 8
local TEXT_X = 8
local MAX_COLS = 18
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
-- the box then closes and choice(yes) runs instead of onDone.
-- opts.defaultNo starts the cursor on NO.
-- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like
-- _UsedStrengthText) never wait for a button: once the last page has
-- typed out, auto.sound() runs (returning an audio source blocks like
-- WaitForSoundToFinish; nil headless), then auto.delay frames pass
-- (default 3, Delay3) and the box pops itself + calls onDone. No
-- blinking cursor, no Press_AB beep.
function TextBox.new(game, text, onDone, opts)
local self = setmetatable({}, TextBox)
self.game = game
self.onDone = onDone
self.choice = opts and opts.choice
self.defaultNo = opts and opts.defaultNo
self.auto = opts and opts.auto
text = TextBox.substitute(game, text)
self.pages = TextBox.paginate(text)
self.pageIndex = 1
self.lineIndex = 1
self.charIndex = 0
self.shown = {} -- visible lines (max 2), each a list of glyph codes
self.waiting = false
self.done = false
self.blink = 0
self:beginLine()
return self
end
function TextBox.substitute(game, text)
local save = game.save
text = text:gsub("{PLAYER}", save.player.name or "RED")
text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
-- wStringBuffer: give_item copies the item name here, like GiveItem ->
-- CopyToStringBuffer (home/give.asm); "received item!" texts read it
-- (staying set afterwards mirrors pokered's stale-buffer semantics)
if game.stringBuffer then
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
end
text = text:gsub("{[%w_:]+}", "") -- other runtime tokens: drop visibly-empty
return text
end
-- Split marked-up text into pages of lines. \v-scrolled lines become
-- additional lines on the same page (the box scrolls them).
function TextBox.paginate(text)
local pages = {}
for pageText in (text .. "\f"):gmatch("(.-)\f") do
if pageText ~= "" then
local lines = {}
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
local line = chunk
-- wrap long lines defensively (the source rarely needs it)
while #line > MAX_COLS do
local cut = MAX_COLS
for i = MAX_COLS, 1, -1 do
if line:sub(i, i) == " " then cut = i break end
end
table.insert(lines, line:sub(1, cut))
line = line:sub(cut + 1)
end
table.insert(lines, line)
end
-- drop trailing empty line from the final gmatch round
if lines[#lines] == "" then table.remove(lines) end
if #lines > 0 then table.insert(pages, lines) end
end
end
if #pages == 0 then pages = { { "" } } end
return pages
end
function TextBox:currentLine()
return self.pages[self.pageIndex][self.lineIndex]
end
function TextBox:beginLine()
self.charIndex = 0
self.codes = Font.encode(self:currentLine())
if #self.shown >= 2 then
table.remove(self.shown, 1)
self.scrollPx = 8 -- pixel scroll-up (ScrollTextUpOneLine)
end
table.insert(self.shown, {})
end
function TextBox:update(dt)
local input = self.game.input
self.blink = (self.blink + 1) % 60
if self.done then
if self.auto then
if not self.autoStarted then
self.autoStarted = true
self.autoSrc = self.auto.sound and self.auto.sound() or nil
self.autoTimer = 0
end
if self.autoSrc and self.autoSrc.isPlaying and self.autoSrc:isPlaying() then
return -- the cry is still sounding (WaitForSoundToFinish)
end
self.autoTimer = self.autoTimer + 1
local delay = self.auto.delay or 3
-- auto.onOverlap: fired once when the delay elapses but before the
-- box closes, so an overlay (the Pallet "!" bubble) can appear
-- while the box is still on screen; the box then lingers
-- auto.overlap more frames before popping (scripts/PalletTown.asm
-- PalletTownOakText: DelayFrames 10 then EmotionBubble over the
-- still-shown "Hey! Wait!" box).
if self.auto.onOverlap and not self.overlapFired
and self.autoTimer >= delay then
self.overlapFired = true
self.auto.onOverlap()
end
if self.autoTimer >= delay + (self.auto.overlap or 0) then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
return
end
if self.choice then
if not self.choicePushed then
self.choicePushed = true
local ChoiceBox = require("src.ui.ChoiceBox")
self.game.stack:push(ChoiceBox.new(self.game, function(yes)
self.game.stack:pop() -- this text box, under the choice
self.choice(yes)
end, { defaultNo = self.defaultNo }))
end
return
end
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone() end
end
return
end
if self.waiting then
if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false
self.shown = {}
self.pageIndex = self.pageIndex + 1
self.lineIndex = 1
self:beginLine()
end
return
end
-- typewriter cadence: one character every N frames, N = the OPTION
-- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B
-- prints every frame like the original's held-button fast path
local delay = (self.game.save.options and self.game.save.options.textSpeed) or 3
if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
if input:isDown("a") or input:isDown("b") then delay = 1 end
self.charTimer = (self.charTimer or 0) + 1
while self.charTimer >= delay do
self.charTimer = self.charTimer - delay
if self.charIndex < #self.codes then
self.charIndex = self.charIndex + 1
local line = self.shown[#self.shown]
line[#line + 1] = self.codes[self.charIndex]
else
-- line finished
local page = self.pages[self.pageIndex]
if self.lineIndex < #page then
self.lineIndex = self.lineIndex + 1
self:beginLine()
elseif self.pageIndex < #self.pages then
self.waiting = true
else
self.done = true
end
break
end
end
end
function TextBox:draw()
Font.drawBox(BOX_TX, BOX_TY, BOX_TW, BOX_TH)
love.graphics.setColor(0, 0, 0, 1)
if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
end
local off = self.scrollPx or 0
local ys = { LINE1_Y, LINE2_Y }
for i, line in ipairs(self.shown) do
local y = (ys[i] or LINE2_Y) + off
for j, code in ipairs(line) do
Font.drawCode(code, TEXT_X + (j - 1) * 8, y)
end
end
if (self.waiting or (self.done and not self.choice and not self.auto))
and self.blink < 30 then
-- page-advance cursor: glyph $EE, the blinking down arrow the original
-- prints via `ld a, "▼"` (home/text.asm)
Font.drawCode(0xEE, 18 * 8, (BOX_TY + 5) * 8 - 4)
end
love.graphics.setColor(1, 1, 1, 1)
end
return TextBox
+407
View File
@@ -0,0 +1,407 @@
-- Draws a map's tile layer: one texture atlas per tileset, 8x8 quads,
-- a single static SpriteBatch covering the map plus a border-block ring
-- (the ring plays the role of the GB border blocks around small maps).
local TileRenderer = {}
TileRenderer.__index = TileRenderer
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
-- OVERWORLD maps fill beyond-edge space with the solid tree wall
-- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51,
-- the border block of ViridianCity/CeruleanCity/CeladonCity et al.),
-- not each map's own border_block, which can be grass ($0B, the
-- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other
-- tilesets keep their designated border (interiors stay black/void)
local TREE_WALL_BLOCK = 0x0F
local function borderBlockFor(map)
if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end
return map.def.borderBlock
end
TileRenderer.borderBlockFor = borderBlockFor
local imageCache = {}
local function getImage(path)
if not imageCache[path] then
imageCache[path] = love.graphics.newImage(path)
end
return imageCache[path]
end
-- ------------------------------------------------------------------
-- Tile animation (home/vcopy.asm): tilesets with TILEANIM_WATER[_FLOWER]
-- rotate water tile $14 one pixel every 20 frames (4 steps right, 4
-- left) and cycle flower tile $03 through 3 frames.
-- ------------------------------------------------------------------
local WATER_TILE, FLOWER_TILE = 0x14, 0x03
-- cumulative pixel offset per animation step (the rrca/rlca sequence)
local WATER_OFFSETS = { 1, 2, 3, 2, 1, 0, 7, 0 }
-- flower frame per step (wMovingBGTilesCounter2 & 3: <2 -> 1, 2, 3)
local FLOWER_FRAMES = { 1, 2, 3, 1, 1, 2, 3, 1 }
local animFrame = 0
function TileRenderer.tick()
animFrame = animFrame + 1
end
-- ------------------------------------------------------------------
-- Spinner arrow tiles (engine/overworld/spinners.asm LoadSpinnerArrowTiles):
-- a wholly separate, contextually-triggered VRAM patch layered on top of
-- the ambient water/flower cycle above -- while wMovementFlags.BIT_SPINNING
-- is set (Gym/Rocket Hideout spinner puzzles), each forced-movement step
-- farcalls LoadSpinnerArrowTiles, which flips 4 fixed destination tile IDs
-- per tileset between the shared 'blur' graphic (gfx/overworld/spinners.2bpp,
-- SpinnerArrowAnimTiles) and the tileset's own static graphic (restore).
-- Only 2 distinct frames exist -- no continuous multi-frame cycle.
-- ------------------------------------------------------------------
-- data/tilesets/spinner_tiles.asm: dest tile IDs patched per tileset
TileRenderer.SPINNER_ARROW_TILES = {
GYM = { 0x3c, 0x3d, 0x4c, 0x4d },
FACILITY = { 0x20, 0x21, 0x30, 0x31 },
}
-- dest tile id -> offset (in 8x8 tiles) into the SpinnerArrowAnimTiles strip,
-- taken verbatim from the `spinner SpinnerArrowAnimTiles, <offset>, <dest>`
-- rows of data/tilesets/spinner_tiles.asm
local SPINNER_STRIP_OFFSET = {
GYM = { [0x3c] = 1, [0x3d] = 3, [0x4c] = 0, [0x4d] = 2 },
FACILITY = { [0x20] = 0, [0x21] = 1, [0x30] = 2, [0x31] = 3 },
}
local spinning = false
function TileRenderer.setSpinning(active)
spinning = active
end
-- true while the spinner arrow tiles should show the 'blur' graphic; false
-- means draw nothing extra (the static mapBatch/ringBatch tile shows
-- through, matching the asm's restore-to-original behavior). The 8-tick
-- half-period approximates one GB movement step (2px/frame); this is a
-- deliberate approximation of wSimulatedJoypadStatesIndex bit-0 parity, not
-- a cycle-accurate replication -- the port's tweened scriptMove has no
-- direct equivalent discrete step counter.
function TileRenderer.spinBlurActive()
return spinning and (math.floor(animFrame / 8) % 2 == 0)
end
-- the 8 shifted variants of a tileset's water tile (built once per sheet)
local waterVariants = {}
local function getWaterVariants(tilesetImagePath, perRow)
if waterVariants[tilesetImagePath] ~= nil then
return waterVariants[tilesetImagePath]
end
if not (love.image and love.image.newImageData) then
waterVariants[tilesetImagePath] = false
return false
end
local id = love.image.newImageData(tilesetImagePath)
local sx = (WATER_TILE % perRow) * 8
local sy = math.floor(WATER_TILE / perRow) * 8
local out = {}
for o = 0, 7 do
local v = love.image.newImageData(8, 8)
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = id:getPixel(sx + x, sy + y)
v:setPixel((x + o) % 8, y, r, g, b, a)
end
end
out[o + 1] = love.graphics.newImage(v)
end
waterVariants[tilesetImagePath] = out
return out
end
local flowerFrames
local function getFlowerFrames()
if flowerFrames ~= nil then return flowerFrames end
flowerFrames = {}
for i = 1, 3 do
local ok, img = pcall(love.graphics.newImage,
("assets/generated/tilesets/flower%d.png"):format(i))
if not ok then flowerFrames = false return false end
flowerFrames[i] = img
end
return flowerFrames
end
-- the tileset's own atlas ImageData with the 4 spinner-tile slots blitted
-- over with the shared blur strip (assets/generated/tilesets/spinners.png,
-- extracted from gfx/overworld/spinners.png); cached per tileset image path
local spinnerBlurImages = {}
local spinnerStripData
local function getSpinnerBlurImage(tilesetId, tilesetImagePath, perRow)
if spinnerBlurImages[tilesetImagePath] ~= nil then
return spinnerBlurImages[tilesetImagePath]
end
if not (love.image and love.image.newImageData) then
spinnerBlurImages[tilesetImagePath] = false
return false
end
local destTiles = TileRenderer.SPINNER_ARROW_TILES[tilesetId]
local offsets = SPINNER_STRIP_OFFSET[tilesetId]
if not (destTiles and offsets) then
spinnerBlurImages[tilesetImagePath] = false
return false
end
if spinnerStripData == nil then
local ok, id = pcall(love.image.newImageData,
"assets/generated/tilesets/spinners.png")
spinnerStripData = ok and id or false
end
if not spinnerStripData then
spinnerBlurImages[tilesetImagePath] = false
return false
end
local atlas = love.image.newImageData(tilesetImagePath)
local clone = love.image.newImageData(atlas:getWidth(), atlas:getHeight())
clone:paste(atlas, 0, 0, 0, 0, atlas:getWidth(), atlas:getHeight())
for _, id in ipairs(destTiles) do
local sx = offsets[id] * 8
local dx = (id % perRow) * 8
local dy = math.floor(id / perRow) * 8
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = spinnerStripData:getPixel(sx + x, y)
clone:setPixel(dx + x, dy + y, r, g, b, a)
end
end
end
local img = love.graphics.newImage(clone)
spinnerBlurImages[tilesetImagePath] = img
return img
end
function TileRenderer.new(map)
local self = setmetatable({}, TileRenderer)
self.map = map
self.image = getImage(map.tileset.image)
local iw, ih = self.image:getDimensions()
self.quads = {}
local perRow = map.tileset.tilesPerRow
for t = 0, (iw / 8) * (ih / 8) - 1 do
self.quads[t] = love.graphics.newQuad((t % perRow) * 8,
math.floor(t / perRow) * 8, 8, 8, iw, ih)
end
local def = map.def
local wB, hB = def.width, def.height
-- two batches: the border-block ring around the map, and the map body.
-- Connected-map strips draw body-only on top of this map's ring.
local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16
self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static")
self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static")
-- animated tiles overdraw the static batches each frame
local anim = map.tileset.animation
local animWater = anim == "TILEANIM_WATER" or anim == "TILEANIM_WATER_FLOWER"
local variants = animWater and getWaterVariants(map.tileset.image, perRow)
local flowers = anim == "TILEANIM_WATER_FLOWER" and getFlowerFrames()
-- Gym/Rocket-Hideout spinner-arrow tiles (see SPINNER_ARROW_TILES above);
-- only GYM/FACILITY tilesets carry these dest tile ids
local spinnerIds = TileRenderer.SPINNER_ARROW_TILES[map.tileset.id]
local spinnerSet
if spinnerIds then
spinnerSet = {}
for _, id in ipairs(spinnerIds) do spinnerSet[id] = true end
end
local water, flower, spinner = {}, {}, {}
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
local inside = bx >= 0 and by >= 0 and bx < wB and by < hB
local batch = inside and self.mapBatch or self.ringBatch
local block = map.tileset.blocks[map:blockAt(bx, by) + 1]
for ty = 0, 3 do
for tx = 0, 3 do
local tile = block[ty * 4 + tx + 1]
local quad = self.quads[tile]
if quad then
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
end
if variants and tile == WATER_TILE then
table.insert(water, { bx * 32 + tx * 8, by * 32 + ty * 8, inside })
elseif flowers and tile == FLOWER_TILE then
table.insert(flower, { bx * 32 + tx * 8, by * 32 + ty * 8, inside })
elseif spinnerSet and spinnerSet[tile] then
table.insert(spinner, { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile })
end
end
end
end
end
-- animated overdraw batches: the full set (ring + body) for the
-- current map, and a body-only set for connected-map drawing --
-- a neighbor's water ring must never overdraw this map's tiles.
-- `quadFor`, when given, looks up a per-entry quad (used by the spinner
-- batch, whose texture is a full tileset-atlas clone rather than a
-- single-tile image like the water/flower variants).
local function animBatches(entries, image, quadFor)
if #entries == 0 then return nil, nil end
local all = love.graphics.newSpriteBatch(image, #entries, "static")
local body
for _, c in ipairs(entries) do
if quadFor then all:add(quadFor(c[4]), c[1], c[2]) else all:add(c[1], c[2]) end
if c[3] then
body = body or love.graphics.newSpriteBatch(image, #entries, "static")
if quadFor then body:add(quadFor(c[4]), c[1], c[2]) else body:add(c[1], c[2]) end
end
end
return all, body
end
if variants then
self.waterBatch, self.waterBodyBatch = animBatches(water, variants[1])
self.waterVariants = self.waterBatch and variants or nil
end
if flowers then
self.flowerBatch, self.flowerBodyBatch = animBatches(flower, flowers[1])
self.flowerFrames = self.flowerBatch and flowers or nil
end
if spinnerSet then
local blurImage = getSpinnerBlurImage(map.tileset.id, map.tileset.image, perRow)
if blurImage then
local quads = self.quads
self.spinnerBatch, self.spinnerBodyBatch =
animBatches(spinner, blurImage, function(tile) return quads[tile] end)
self.spinnerBlurImage = self.spinnerBatch and blurImage or nil
end
end
-- a repeating 32x32 image of the border block, tiled behind
-- everything the 3-block ring doesn't cover (the survey zoom sees
-- far past the ring; interiors keep their black border this way)
pcall(function()
local border = map.tileset.blocks[borderBlockFor(map) + 1]
if not border then return end
local canvas = love.graphics.newCanvas(32, 32)
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.clear(1, 1, 1, 1)
for ty = 0, 3 do
for tx = 0, 3 do
local quad = self.quads[border[ty * 4 + tx + 1]]
if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end
end
end
love.graphics.setCanvas()
love.graphics.pop()
local img = love.graphics.newImage(canvas:newImageData())
img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest")
self.borderFill = img
end)
return self
end
-- tile the border block across the whole view (world-aligned so it
-- meshes seamlessly with the ring batch)
function TileRenderer:drawBorderFill(camX, camY, vw, vh)
if not self.borderFill then return end
local x, y = math.floor(camX), math.floor(camY)
local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32)
love.graphics.draw(self.borderFill, quad, 0, 0)
end
-- GB OBJ-to-BG priority: sprites show through BG color 0 and hide under
-- colors 1-3. Tall-grass overdraw needs the same rule, otherwise the
-- tile's white gaps paint opaque boxes over the sprite's feet.
local color0KeyShader -- false = unavailable
local function getColor0KeyShader()
if color0KeyShader ~= nil then return color0KeyShader or nil end
local ok, sh = pcall(love.graphics.newShader, [[
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc) * color;
// same shade-0 cutoff PaletteFX uses (DMG white / lightest gray)
if (p.r > 0.83 && p.g > 0.83 && p.b > 0.83) p.a = 0.0;
return p;
}
]])
color0KeyShader = ok and sh or false
return color0KeyShader or nil
end
-- draw a cell's bottom tile row without touching the shader (the caller
-- owns it). drawCellBottom wraps this with the color-0 key; tilt mode's
-- upright pass wraps it with a color-0-keyed palette shader instead
-- (PaletteFX.keyedShader) so the feet patch is colorized like the ground.
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
local ty = cy * 2 + 1
for i = 0, 1 do
local tx = cx * 2 + i
local quad = self.quads[self.map:tileAt(tx, ty)]
if quad then
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
end
end
end
-- redraw a cell's bottom tile row (tall grass hides the lower half of
-- sprites standing in it, like the GB sprite-priority trick)
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
local shader = getColor0KeyShader()
if shader then love.graphics.setShader(shader) end
self:drawCellBottomRaw(cx, cy, camX, camY)
if shader then love.graphics.setShader() end
end
-- water/flower overdraw at the current animation step; bodyOnly skips
-- the ring positions (connected maps draw body-only)
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
local waterBatch = bodyOnly and self.waterBodyBatch or self.waterBatch
local flowerBatch = bodyOnly and self.flowerBodyBatch or self.flowerBatch
local spinnerBatch = bodyOnly and self.spinnerBodyBatch or self.spinnerBatch
if not (waterBatch or flowerBatch or spinnerBatch) then return end
local i = (math.floor(animFrame / 20) % 8) + 1
local x, y = -math.floor(camX), -math.floor(camY)
if waterBatch then
waterBatch:setTexture(self.waterVariants[WATER_OFFSETS[i] + 1])
love.graphics.draw(waterBatch, x, y)
end
if flowerBatch then
flowerBatch:setTexture(self.flowerFrames[FLOWER_FRAMES[i]])
love.graphics.draw(flowerBatch, x, y)
end
-- spinner arrow tiles (engine/overworld/spinners.asm): only 2 frames
-- (blur / restore-to-static), gated on spinBlurActive() rather than the
-- free-running water/flower cycle above -- when false, draw nothing so
-- the already-static mapBatch/ringBatch tile shows through unchanged
if spinnerBatch and TileRenderer.spinBlurActive() then
love.graphics.draw(spinnerBatch, x, y)
end
end
function TileRenderer:draw(camX, camY)
love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY))
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
self:drawAnimated(camX, camY)
end
-- body only, for connected-map strips
function TileRenderer:drawMapOnly(camX, camY)
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
self:drawAnimated(camX, camY, true)
end
-- rebuild after a block change (Cut trees)
function TileRenderer:rebuild()
local fresh = TileRenderer.new(self.map)
self.ringBatch = fresh.ringBatch
self.mapBatch = fresh.mapBatch
self.waterBatch = fresh.waterBatch
self.waterBodyBatch = fresh.waterBodyBatch
self.waterVariants = fresh.waterVariants
self.flowerBatch = fresh.flowerBatch
self.flowerBodyBatch = fresh.flowerBodyBatch
self.flowerFrames = fresh.flowerFrames
self.spinnerBatch = fresh.spinnerBatch
self.spinnerBodyBatch = fresh.spinnerBodyBatch
self.spinnerBlurImage = fresh.spinnerBlurImage
self.borderFill = fresh.borderFill
end
return TileRenderer
+155
View File
@@ -0,0 +1,155 @@
-- Overworld tilt mode: a cycleable, purely presentational perspective
-- tilt for the free-roam overworld. The flat world canvas is treated as
-- a ground plane, rotated about the horizontal axis through the viewport
-- centre and viewed through a perspective camera, so rows above centre
-- recede/shrink and rows below come closer (the HD-2D "diorama" look).
-- Like survey zoom this lives entirely in the draw path -- zero effect
-- on collision, movement, triggers, scripts -- and is persisted via
-- save.options.tilt (OFF / 15 / 35 / 50).
--
-- Spec: docs/new-features.md (tilt mode)
local Zoom = require("src.render.Zoom")
local Tilt = {}
-- Discrete tilt angles in degrees (index 0 is off). Cycle: off→15→35→50→off.
Tilt.ANGLES_DEG = { 0, 15, 35, 50 }
Tilt.ANGLE_LABELS = { "OFF", "15", "35", "50" }
-- Runtime state. `level` is the discrete option (0=off .. 3=50°);
-- `angle` is the live tweened tilt in radians; `from`/`goal`/`t` drive
-- the ease between any two levels (including off).
Tilt.level = 0
Tilt.angle = 0
Tilt.from = 0
Tilt.goal = 0
Tilt.t = 1
-- Compatibility: TARGET_ANGLE is the current goal; enabled mirrors level > 0.
Tilt.TARGET_ANGLE = 0
Tilt.enabled = false
Tilt.TWEEN_TIME = 0.25
Tilt.FOCAL = 1.0
Tilt.VIEW_MARGIN = 0.35
local function ease(t)
return t * t * (3 - 2 * t)
end
local function goalFor(level)
return math.rad(Tilt.ANGLES_DEG[level + 1] or 0)
end
function Tilt.setLevel(level)
level = math.floor(tonumber(level) or 0)
if level < 0 then level = 0 end
if level > 3 then level = 3 end
local goal = goalFor(level)
if goal ~= Tilt.goal or level ~= Tilt.level then
Tilt.from = Tilt.angle
Tilt.goal = goal
Tilt.t = 0
end
Tilt.level = level
Tilt.TARGET_ANGLE = goal
Tilt.enabled = level > 0
end
-- Advance OFF → 15 → 35 → 50 → OFF. Returns the new level.
function Tilt.cycle()
Tilt.setLevel((Tilt.level + 1) % 4)
return Tilt.level
end
-- Legacy name: one cycle step (same as cycle).
function Tilt.toggle()
return Tilt.cycle()
end
function Tilt.reset()
Tilt.level = 0
Tilt.angle = 0
Tilt.from = 0
Tilt.goal = 0
Tilt.t = 1
Tilt.TARGET_ANGLE = 0
Tilt.enabled = false
end
function Tilt.applyOptions(opts)
local level = math.floor(tonumber(opts and opts.tilt) or 0)
if level < 0 then level = 0 end
if level > 3 then level = 3 end
Tilt.level = level
Tilt.goal = goalFor(level)
Tilt.from = Tilt.goal
Tilt.angle = Tilt.goal
Tilt.t = 1
Tilt.TARGET_ANGLE = Tilt.goal
Tilt.enabled = level > 0
end
function Tilt.levelLabel(level)
return Tilt.ANGLE_LABELS[(level or Tilt.level) + 1] or "OFF"
end
-- Ease angle from `from` toward `goal` over TWEEN_TIME.
function Tilt.update(dt)
if Tilt.t < 1 then
Tilt.t = math.min(1, Tilt.t + dt / Tilt.TWEEN_TIME)
local e = ease(Tilt.t)
Tilt.angle = Tilt.from + (Tilt.goal - Tilt.from) * e
else
Tilt.angle = Tilt.goal
end
Tilt.TARGET_ANGLE = Tilt.goal
Tilt.enabled = Tilt.level > 0
end
-- true while tilt is on *or* still tweening -- i.e. whenever the renderer
-- must take the perspective path rather than the flat blit
function Tilt.active()
return Tilt.level > 0 or Tilt.angle > 0
end
function Tilt.gateOK(top, overworld)
return Zoom.gateOK(top, overworld)
end
function Tilt.groundPoint(cx, cy, vw, vh)
local a = Tilt.angle
if a <= 0 then return cx, cy, 1 end
local u = cx - vw * 0.5
local w = cy - vh * 0.5
local d = Tilt.FOCAL * vh
local scale = d / (d - w * math.sin(a))
local sx = vw * 0.5 + u * scale
local sy = vh * 0.5 + w * math.cos(a) * scale
return sx, sy, scale
end
function Tilt.viewGrowth()
local a = Tilt.angle
if a <= 0 then return 1 end
local topScale = 1 / (1 + 0.5 * math.sin(a) / Tilt.FOCAL)
local base = 1 / (math.cos(a) * topScale)
return base + Tilt.VIEW_MARGIN * (base - 1)
end
function Tilt.meshCorners(vw, vh)
local corners = {
{ 0, 0, 0, 0 },
{ vw, 0, 1, 0 },
{ vw, vh, 1, 1 },
{ 0, vh, 0, 1 },
}
local out = {}
for i, c in ipairs(corners) do
local sx, sy, scale = Tilt.groundPoint(c[1], c[2], vw, vh)
out[i] = { sx, sy, c[3], c[4], scale }
end
return out
end
return Tilt
+68
View File
@@ -0,0 +1,68 @@
-- Screen fade used for warps: fade out, run a callback (map switch), fade in.
-- Pushed on the state stack above the overworld.
local Transition = {}
Transition.__index = Transition
local FRAMES = 12
function Transition.new(game, onMidpoint, onDone)
local self = setmetatable({}, Transition)
self.game = game
self.onMidpoint = onMidpoint
self.onDone = onDone
self.t = 0
self.phase = "out"
return self
end
function Transition:update(dt)
self.t = self.t + 1
if self.t >= FRAMES then
self.t = 0
if self.phase == "out" then
self.phase = "in"
if self.onMidpoint then self.onMidpoint() end
else
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
end
function Transition:draw()
local alpha = self.t / FRAMES
if self.phase == "in" then alpha = 1 - alpha end
love.graphics.setColor(0, 0, 0, alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
-- GBPalWhiteOutWithDelay3 (home/palettes.asm): the field moves that close
-- the party menu (start_sub_menus.asm .goBackToMap paths) white out the
-- palettes, and they stay white through Delay3 + the screen-tile restore
-- until CloseTextDisplay's LoadGBPal -- a ~7-frame solid-white blink.
-- Instant white, hold, instant restore (a palette write, not a fade).
local WhiteFlash = {}
WhiteFlash.__index = WhiteFlash
WhiteFlash.isOpaque = true
function Transition.whiteFlash(game, frames, onDone)
return setmetatable({ game = game, frames = frames or 7,
onDone = onDone, t = 0 }, WhiteFlash)
end
function WhiteFlash:update(dt)
self.t = self.t + 1
if self.t >= self.frames then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
function WhiteFlash:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
end
return Transition
+48
View File
@@ -0,0 +1,48 @@
-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
-- by the mouse wheel. Stored as an offset from the window fit scale S
-- so a resize keeps the relative zoom. Session-only; never saved.
-- Spec: docs/new-features.md (survey zoom)
local Zoom = {}
Zoom.offset = 0
-- effective integer scale s' in [1, 2*S]
function Zoom.scale(S)
return math.max(1, math.min(2 * S, S + Zoom.offset))
end
function Zoom.step(delta, S)
Zoom.offset = Zoom.offset + delta
if S + Zoom.offset < 1 then Zoom.offset = 1 - S end
if S + Zoom.offset > 2 * S then Zoom.offset = S end
end
function Zoom.reset()
Zoom.offset = 0
end
-- world pixels covered by a w x h letterbox viewport at fit scale S
-- (legacy GB-framed size; prefer fillViewSize for the live world pass)
function Zoom.viewSize(S, w, h)
local s = Zoom.scale(S)
return math.ceil(w * S / s), math.ceil(h * S / s)
end
-- world pixels needed to fill a ww x wh window at the current zoom scale
-- (fills letterbox "black voids" with more map, phones, tall windows)
function Zoom.fillViewSize(s, ww, wh)
return math.ceil(ww / s), math.ceil(wh / s)
end
-- zoom input is honored only while free-roaming the overworld
function Zoom.gateOK(top, overworld)
if top == nil or top ~= overworld then return false end
if top.transitioning then return false end
if top.runner and top.runner.isRunning and top.runner:isRunning() then
return false
end
return true
end
return Zoom