mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user