mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 12:40:21 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -4,7 +4,16 @@ A native LÖVE2D recreation of Poke Red and Blue. The engine and map
|
|||||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||||
supplied by the player.
|
supplied by the player.
|
||||||
|
|
||||||
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
|
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||||
|
|
||||||
|
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
|
||||||
|
|
||||||
|
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a> </p>
|
||||||
|
|
||||||
|
### Watch the latest update video
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=TbHdJIrKJJU)
|
||||||
|
|
||||||
|
|
||||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||||
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
|
||||||
@@ -186,3 +195,5 @@ request with real detail is one that can actually get built.
|
|||||||
This project would not be possible without [pret](https://github.com/pret) >
|
This project would not be possible without [pret](https://github.com/pret) >
|
||||||
the pret band of decompiling maniacs > and their
|
the pret band of decompiling maniacs > and their
|
||||||
[pokered](https://github.com/pret/pokered) disassembly.
|
[pokered](https://github.com/pret/pokered) disassembly.
|
||||||
|
|
||||||
|
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
|
||||||
|
|||||||
@@ -38,15 +38,16 @@ BattleState.letterboxWhite = true
|
|||||||
|
|
||||||
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
|
||||||
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
|
||||||
-- differs; every battler, queue and animation below is shared. The wide
|
-- differs; every battler, queue and animation below is shared. Menus and
|
||||||
-- layout is live only while this battle is the state being drawn on top --
|
-- prompts pushed during a wide battle keep its wide canvas, while drawing
|
||||||
-- a party menu or bag pushed over it is a 160x144 screen, so the surface
|
-- their classic 160px UI centred within it (Game:draw).
|
||||||
-- goes back with it and the battle underneath is not drawn at all.
|
function BattleState:isWideBattleLayout()
|
||||||
function BattleState:wideLayout()
|
|
||||||
local options = self.game and self.game.save and self.game.save.options
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
if not options or options.battleLayout ~= "wide" then return false end
|
return options and options.battleLayout == "wide" or false
|
||||||
local stack = self.game.stack
|
end
|
||||||
return (stack and stack.top and stack:top()) == self
|
|
||||||
|
function BattleState:wideLayout()
|
||||||
|
return self:isWideBattleLayout()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Renderer:setUISize asks the top state for its surface before anything draws
|
-- Renderer:setUISize asks the top state for its surface before anything draws
|
||||||
|
|||||||
+65
-7
@@ -244,35 +244,93 @@ end
|
|||||||
-- exactly as the owning state computed it
|
-- exactly as the owning state computed it
|
||||||
local function sameZones(_, zones) return zones end
|
local function sameZones(_, zones) return zones end
|
||||||
|
|
||||||
|
-- A wide battle owns the surface until it leaves the stack. The party,
|
||||||
|
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||||
|
-- but the canvas must not snap to 160px between those states.
|
||||||
|
function Game.wideBattleInStack(stack)
|
||||||
|
for i = #(stack and stack.states or {}), 1, -1 do
|
||||||
|
local state = stack.states[i]
|
||||||
|
if state and state.isWideBattleLayout and state:isWideBattleLayout() then
|
||||||
|
return state
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||||
|
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||||
|
-- sprite and status zones move with the classic UI content.
|
||||||
|
local function centerClassicZones(zones, offset)
|
||||||
|
if not zones or offset == 0 then return zones end
|
||||||
|
local shifted = {}
|
||||||
|
for i, zone in ipairs(zones) do
|
||||||
|
local copy = {}
|
||||||
|
for key, value in pairs(zone) do copy[key] = value end
|
||||||
|
if copy.x == 0 and copy.w == Renderer.WIDTH then
|
||||||
|
copy.w = copy.w + offset * 2
|
||||||
|
else
|
||||||
|
copy.x = (copy.x or 0) + offset
|
||||||
|
end
|
||||||
|
shifted[i] = copy
|
||||||
|
end
|
||||||
|
return shifted
|
||||||
|
end
|
||||||
|
|
||||||
function Game:draw()
|
function Game:draw()
|
||||||
-- the UI canvas clears transparent when the overworld's world pass
|
-- the UI canvas clears transparent when the overworld's world pass
|
||||||
-- shows through beneath it; opaque full-screen states get the classic
|
-- shows through beneath it; opaque full-screen states get the classic
|
||||||
-- white clear
|
-- white clear
|
||||||
local base = self.stack:visibleBase()
|
local base = self.stack:visibleBase()
|
||||||
local worldBelow = self.stack.states[base] == self.overworld
|
local worldBelow = self.stack.states[base] == self.overworld
|
||||||
-- The UI surface is resolved once, before any state draws: the top state
|
-- A wide battle holds its 304px surface through every menu or prompt it
|
||||||
-- may want more than the Game Boy's 160x144 (the widescreen battle layout
|
-- opens. States that do not draw the wide battle composition are centred
|
||||||
-- asks for 304x144). Anything else keeps the classic surface, so a menu
|
-- in that surface below, so their classic coordinates and hit testing stay
|
||||||
-- pushed over a wide battle brings the screen straight back to 160x144.
|
-- unchanged. Outside a battle, including the title screen, the option is
|
||||||
|
-- intentionally inactive because it is a battle-layout setting.
|
||||||
local top = self.stack:top()
|
local top = self.stack:top()
|
||||||
if top and top.uiSize then
|
local wideBattle = Game.wideBattleInStack(self.stack)
|
||||||
|
local classicOffset = 0
|
||||||
|
if wideBattle and wideBattle.uiSize then
|
||||||
|
Renderer:setUISize(wideBattle:uiSize())
|
||||||
|
classicOffset = math.floor((select(1, Renderer:uiSize()) - Renderer.WIDTH) / 2)
|
||||||
|
elseif top and top.uiSize then
|
||||||
Renderer:setUISize(top:uiSize())
|
Renderer:setUISize(top:uiSize())
|
||||||
else
|
else
|
||||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||||
end
|
end
|
||||||
Renderer:beginFrame(worldBelow)
|
Renderer:beginFrame(worldBelow)
|
||||||
self.stack:draw()
|
for i = self.stack:visibleBase(), #self.stack.states do
|
||||||
|
local state = self.stack.states[i]
|
||||||
|
local wideState = state and state.isWideBattleLayout
|
||||||
|
and state:isWideBattleLayout()
|
||||||
|
if state and state.draw then
|
||||||
|
if classicOffset ~= 0 and not wideState then
|
||||||
|
love.graphics.push()
|
||||||
|
love.graphics.translate(classicOffset, 0)
|
||||||
|
state:draw()
|
||||||
|
love.graphics.pop()
|
||||||
|
else
|
||||||
|
state:draw()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
-- SGB colorization: the topmost state that knows its palette owns the
|
-- SGB colorization: the topmost state that knows its palette owns the
|
||||||
-- screen (overlays like text boxes inherit from what's beneath them);
|
-- screen (overlays like text boxes inherit from what's beneath them);
|
||||||
-- the overworld's world pass colors each visible map area separately
|
-- the overworld's world pass colors each visible map area separately
|
||||||
local zones, worldZones
|
local zones, worldZones, zoneOwner
|
||||||
for i = #self.stack.states, 1, -1 do
|
for i = #self.stack.states, 1, -1 do
|
||||||
local s = self.stack.states[i]
|
local s = self.stack.states[i]
|
||||||
if s.sgbPalettes then
|
if s.sgbPalettes then
|
||||||
zones = s:sgbPalettes(self)
|
zones = s:sgbPalettes(self)
|
||||||
|
zoneOwner = s
|
||||||
break
|
break
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if classicOffset ~= 0 and zoneOwner
|
||||||
|
and not (zoneOwner.isWideBattleLayout
|
||||||
|
and zoneOwner:isWideBattleLayout()) then
|
||||||
|
zones = centerClassicZones(zones, classicOffset)
|
||||||
|
end
|
||||||
-- 14's render.zones: weather/lighting overlays and custom colorization
|
-- 14's render.zones: weather/lighting overlays and custom colorization
|
||||||
-- recolor or add zones before the blit
|
-- recolor or add zones before the blit
|
||||||
if ModRuntime.wantsHook("render.zones") then
|
if ModRuntime.wantsHook("render.zones") then
|
||||||
|
|||||||
@@ -116,13 +116,12 @@ local function defaultsSave()
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
|
-- Merge a GenSave.decode() result over the new-game defaults, exactly the
|
||||||
-- way convert.lua did, then stamp the requested version. The 32768-byte
|
-- way convert.lua did, then stamp the requested version. Keep the imported
|
||||||
-- import template GenSave stashes as `rawImport` and the decode `warnings`
|
-- SRAM image with the slot: Pokémon Red restores its saved current-map cache
|
||||||
-- are dropped here: neither belongs in a serialized slot file (a fresh
|
-- before Continue, and an export needs that unmodeled data to remain bootable.
|
||||||
-- export always starts zero-filled -- see GenSave.lua's header).
|
-- Decode warnings are only import diagnostics and do not belong in the slot.
|
||||||
local function mergeDefaults(decoded, version)
|
local function mergeDefaults(decoded, version)
|
||||||
decoded.warnings = nil
|
decoded.warnings = nil
|
||||||
decoded.rawImport = nil
|
|
||||||
local save = defaultsSave()
|
local save = defaultsSave()
|
||||||
for k, v in pairs(decoded) do save[k] = v end
|
for k, v in pairs(decoded) do save[k] = v end
|
||||||
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
|
save.lastHeal = { map = save.player.map, x = save.player.x, y = save.player.y }
|
||||||
|
|||||||
@@ -96,7 +96,14 @@ local function syntheticSave(name)
|
|||||||
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
|
moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
|
||||||
nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45,
|
nickname = "SQ", ot = name, otId = seed.player.id, catchRate = 45,
|
||||||
} }
|
} }
|
||||||
return GenSave.encode(seed, data, nil)
|
-- The current-map view pointer is part of wMainData's map cache, not a
|
||||||
|
-- modeled save field. Pokémon Red restores that cache before Continue,
|
||||||
|
-- so it is a useful canary for the import -> slot -> export path.
|
||||||
|
local raw = GenSave.encode(seed, data, nil)
|
||||||
|
local cacheOff = OFF.mainData + 104
|
||||||
|
local cacheTemplate = raw:sub(1, cacheOff) .. string.char(0xA5)
|
||||||
|
.. raw:sub(cacheOff + 2)
|
||||||
|
return GenSave.encode(seed, data, cacheTemplate)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ---------------------------------------------- importToSlot -> listSlots
|
-- ---------------------------------------------- importToSlot -> listSlots
|
||||||
@@ -149,6 +156,8 @@ do
|
|||||||
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
|
eq(outBytes and #outBytes, GenSave.SAVE_SIZE, "the export is exactly 32768 bytes")
|
||||||
check(outBytes and mainChecksumValid(outBytes),
|
check(outBytes and mainChecksumValid(outBytes),
|
||||||
"the export carries a valid main-data checksum")
|
"the export carries a valid main-data checksum")
|
||||||
|
eq(outBytes and outBytes:byte(OFF.mainData + 105), 0xA5,
|
||||||
|
"the export keeps the saved current-map cache")
|
||||||
|
|
||||||
-- the export re-imports to an equivalent save
|
-- the export re-imports to an equivalent save
|
||||||
local re = SaveConvert.importSav(outBytes, "red")
|
local re = SaveConvert.importSav(outBytes, "red")
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
|
|||||||
local T = require("tests.modkit")
|
local T = require("tests.modkit")
|
||||||
local WideBattle = require("src.battle.WideBattle")
|
local WideBattle = require("src.battle.WideBattle")
|
||||||
local Renderer = require("src.render.Renderer")
|
local Renderer = require("src.render.Renderer")
|
||||||
|
local Game = require("src.core.Game")
|
||||||
|
|
||||||
T.eq(WideBattle.WIDTH, 304, "the wide layout runs on a 304px native surface")
|
T.eq(WideBattle.WIDTH, 304, "the wide layout runs on a 304px native surface")
|
||||||
T.eq(WideBattle.HEIGHT, 144, "the wide surface keeps the native height")
|
T.eq(WideBattle.HEIGHT, 144, "the wide surface keeps the native height")
|
||||||
T.eq(WideBattle.FIELD_BOTTOM, 104,
|
T.eq(WideBattle.FIELD_BOTTOM, 104,
|
||||||
"the lower 40 rows are the message / command windows")
|
"the lower 40 rows are the message / command windows")
|
||||||
|
|
||||||
|
local wide = { isWideBattleLayout = function() return true end }
|
||||||
|
local normal = { isWideBattleLayout = function() return false end }
|
||||||
|
T.eq(Game.wideBattleInStack({ states = { normal, wide, normal } }), wide,
|
||||||
|
"a wide battle remains the surface owner under a classic overlay")
|
||||||
|
T.eq(Game.wideBattleInStack({ states = { normal } }), nil,
|
||||||
|
"a classic stack keeps the normal surface")
|
||||||
|
|
||||||
-- move grid: slots are laid out 1 2 / 3 4
|
-- move grid: slots are laid out 1 2 / 3 4
|
||||||
T.eq(WideBattle.moveGridIndex(1, 4, "right"), 2, "RIGHT crosses the row")
|
T.eq(WideBattle.moveGridIndex(1, 4, "right"), 2, "RIGHT crosses the row")
|
||||||
T.eq(WideBattle.moveGridIndex(2, 4, "left"), 1, "LEFT crosses the row")
|
T.eq(WideBattle.moveGridIndex(2, 4, "left"), 1, "LEFT crosses the row")
|
||||||
|
|||||||
@@ -297,9 +297,10 @@ check(scSave and scSave.lastHeal and scSave.lastHeal.map == scSave.player.map,
|
|||||||
"SaveConvert.importSav: lastHeal derives from the decoded position")
|
"SaveConvert.importSav: lastHeal derives from the decoded position")
|
||||||
check(scSave and scSave.lastOutdoor and scSave.lastOutdoor.id ~= nil,
|
check(scSave and scSave.lastOutdoor and scSave.lastOutdoor.id ~= nil,
|
||||||
"SaveConvert.importSav: lastOutdoor is set")
|
"SaveConvert.importSav: lastOutdoor is set")
|
||||||
-- the import template + decode warnings never leak into the slot table
|
-- The original SRAM image carries the current-map cache that Red restores on
|
||||||
check(scSave and scSave.rawImport == nil and scSave.warnings == nil,
|
-- Continue, while decode warnings are only import diagnostics.
|
||||||
"SaveConvert.importSav: rawImport/warnings stripped from the returned table")
|
check(scSave and type(scSave.rawImport) == "string" and scSave.warnings == nil,
|
||||||
|
"SaveConvert.importSav: keeps the SRAM template but drops warnings")
|
||||||
|
|
||||||
-- size / type validation
|
-- size / type validation
|
||||||
local badSize, badSizeErr = SaveConvert.importSav("too short", 2)
|
local badSize, badSizeErr = SaveConvert.importSav("too short", 2)
|
||||||
|
|||||||
Reference in New Issue
Block a user