mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
401 lines
13 KiB
Lua
401 lines
13 KiB
Lua
-- Native LÖVE2D port of Pokemon Red. A packaged build creates its private
|
|
-- game-data cache from a user-provided ROM on first boot.
|
|
--
|
|
-- Set POKEPORT_EDITOR=1 or pass `--editor` to `love .` to boot the save
|
|
-- editor tool (tools/save-editor/) instead of the game.
|
|
|
|
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
|
|
|
local Game, EditorApp, Importer
|
|
|
|
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
|
|
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
|
|
-- coroutine that receives `Game` and yields once per
|
|
-- frame; used headless (xvfb) for scripted screenshots
|
|
|
|
-- --speed N / POKEPORT_SPEED=N: run the logic clock N times faster without
|
|
-- touching audio (src/core/GameSpeed.lua). Overrides the saved option so a
|
|
-- bot or screenshot run is not at the mercy of the player's last choice.
|
|
local speedOverride = tonumber(os.getenv("POKEPORT_SPEED"))
|
|
|
|
-- POKEPORT_TOUCH=1 forces the mobile on-screen controls on and lets the
|
|
-- mouse stand in for a finger, so the overlay can be exercised on desktop
|
|
-- (see src/core/TouchControls.lua).
|
|
local mouseTouch = os.getenv("POKEPORT_TOUCH") == "1"
|
|
|
|
-- How many times to run a scripted act+step loop per rendered frame. Only
|
|
-- scripted runs use this; interactive play fast-forwards through
|
|
-- Game.speedOverride / the GAME SPEED option instead.
|
|
local function scriptedIterations()
|
|
if not (autopilot or driverCo) then return 1 end
|
|
return math.max(1, math.floor(require("src.core.GameSpeed").clamp(speedOverride)))
|
|
end
|
|
|
|
local function bootGame(version)
|
|
-- The launcher hands us the chosen game (Red / Blue); scripted and headless
|
|
-- runs fall back to POKEPORT_VERSION, then Red. Set the active version and
|
|
-- overlay its extracted cache BEFORE anything requires generated data, so
|
|
-- data/generated + assets/generated resolve to that version's files.
|
|
local GameVersion = require("src.core.GameVersion")
|
|
GameVersion.set(version or os.getenv("POKEPORT_VERSION") or "red")
|
|
require("src.import.CacheFs").mountVersion(GameVersion.get())
|
|
if love.window and love.window.setTitle then
|
|
local Version = require("src.core.Version")
|
|
love.window.setTitle(Version.title(
|
|
GameVersion.info().displayName .. " (Gen 1 Recompilation Project)"))
|
|
end
|
|
Game = require("src.core.Game")
|
|
Game:load()
|
|
if os.getenv("POKEPORT_AUTOPILOT") then
|
|
autopilot = require("tests.autopilot")
|
|
end
|
|
local driverPath = os.getenv("POKEPORT_DRIVER")
|
|
if driverPath then
|
|
local fn = assert(loadfile(driverPath))()
|
|
driverCo = coroutine.create(fn)
|
|
end
|
|
-- After the two above are known: a scripted run drives the multiplier
|
|
-- from love.update's loop, so the in-engine one must stay at 1 or the
|
|
-- two would compound (10x10 = 100 steps per observation).
|
|
Game.speedOverride = (autopilot or driverCo) and 1 or speedOverride
|
|
end
|
|
|
|
function love.load(args)
|
|
-- Self-updater boot shell: a fused build may mount and chainload a newer
|
|
-- downloaded payload here. True means it took over, so we must stop. A
|
|
-- dev / source checkout no-ops (see src/update/Boot.lua).
|
|
local Boot = require("src.update.Boot")
|
|
if Boot.run(args) then return end
|
|
|
|
local savePath
|
|
for i, a in ipairs(args or {}) do
|
|
if a == "--editor" then
|
|
editorMode = true
|
|
elseif a == "--developer" then
|
|
_G.POKEPORT_DEV_MODE = true
|
|
elseif a == "--save" and args[i + 1] and args[i + 1] ~= "" then
|
|
savePath = args[i + 1]
|
|
elseif a == "--speed" and tonumber(args[i + 1]) then
|
|
speedOverride = tonumber(args[i + 1])
|
|
end
|
|
end
|
|
love.graphics.setDefaultFilter("nearest", "nearest")
|
|
|
|
if editorMode then
|
|
package.path = love.filesystem.getSource() .. "/tools/save-editor/?.lua;"
|
|
.. love.filesystem.getSource() .. "/tools/save-editor/panels/?.lua;"
|
|
.. package.path
|
|
EditorApp = require("App")
|
|
EditorApp.load(savePath)
|
|
return
|
|
end
|
|
|
|
local RomImporter = require("src.import.RomImporter")
|
|
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
|
|
local importPath = os.getenv("POKEPORT_IMPORT_ROM")
|
|
-- Scripted / headless runs pick their game from POKEPORT_VERSION (default
|
|
-- Red); the launcher's per-column choice does not apply to them.
|
|
local scriptedVersion = os.getenv("POKEPORT_VERSION") or "red"
|
|
local ready = RomImporter.isReady(scriptedVersion)
|
|
-- Scripted / headless runs have to reach the game with no human pressing
|
|
-- Play: an autopilot, a frame driver, an import-only build step, or an
|
|
-- explicit ROM path all bypass the interactive launcher and keep today's
|
|
-- import-then-boot (or boot-straight-in) behavior.
|
|
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
|
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
|
|
|
|
if scripted then
|
|
if forceImport or not ready then
|
|
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
|
|
-- passes it to onComplete; boot that version.
|
|
Importer = RomImporter.new(function(version)
|
|
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then
|
|
love.event.quit()
|
|
return
|
|
end
|
|
Importer = nil
|
|
bootGame(version or scriptedVersion)
|
|
end)
|
|
if importPath then Importer:startPath(importPath) end
|
|
return
|
|
end
|
|
bootGame(scriptedVersion)
|
|
return
|
|
end
|
|
|
|
-- Interactive: the launcher always runs. Red and Blue are each live: a
|
|
-- column shows Play when that game's ROM is already imported, or Choose ROM
|
|
-- / drag-drop when it is not (Yellow is still a placeholder). Any dropped
|
|
-- .gb is routed to Red or Blue by its SHA-1; pressing Play boots that game.
|
|
Importer = RomImporter.new(function(version)
|
|
Importer = nil
|
|
bootGame(version)
|
|
end, { launcher = true, forceImport = forceImport })
|
|
end
|
|
|
|
function love.update(dt)
|
|
if editorMode then return EditorApp.update(dt) end
|
|
if Importer then return Importer:update(dt) end
|
|
|
|
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
|
-- once per Game:update, so they must keep a 1:1 relationship with the
|
|
-- logic step. Fast-forwarding them by scaling the step inside
|
|
-- Game:update would run N steps per observation: a held direction walks
|
|
-- through all N, the player slides past the waypoint, and the script
|
|
-- re-plans from an overshot cell. So iterate the whole act+step loop
|
|
-- instead -- same script, just more of it per rendered frame.
|
|
local iterations = scriptedIterations()
|
|
|
|
if autopilot then
|
|
for _ = 1, iterations do
|
|
autopilot.update()
|
|
Game:update(1 / 60) -- deterministic stepping for the autopilot
|
|
end
|
|
return
|
|
end
|
|
if driverCo then
|
|
for _ = 1, iterations do
|
|
local ok, err = coroutine.resume(driverCo, Game)
|
|
if not ok then
|
|
print("driver error: " .. tostring(err))
|
|
love.event.quit(1)
|
|
return
|
|
end
|
|
if coroutine.status(driverCo) == "dead" then
|
|
love.event.quit()
|
|
return
|
|
end
|
|
Game:update(1 / 60)
|
|
end
|
|
return
|
|
end
|
|
Game:update(dt)
|
|
end
|
|
|
|
function love.draw()
|
|
if editorMode then return EditorApp.draw() end
|
|
if Importer then return Importer:draw() end
|
|
|
|
Game:draw()
|
|
-- frame capture requested by a driver
|
|
if Game.capturePath then
|
|
local path = Game.capturePath
|
|
Game.capturePath = nil
|
|
love.graphics.captureScreenshot(function(imagedata)
|
|
local fd = imagedata:encode("png")
|
|
local f = io.open(path, "wb")
|
|
if f then
|
|
f:write(fd:getString())
|
|
f:close()
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
function love.keypressed(key, scancode, isrepeat)
|
|
if editorMode then return EditorApp.keypressed(key) end
|
|
if Importer then return Importer:keypressed(key) end
|
|
Game:keypressed(key)
|
|
end
|
|
|
|
function love.keyreleased(key)
|
|
if editorMode then return end
|
|
if Importer then return end
|
|
Game:keyreleased(key)
|
|
end
|
|
|
|
function love.gamepadpressed(joystick, button)
|
|
if editorMode then return end
|
|
if Importer then return end
|
|
Game:gamepadpressed(joystick, button)
|
|
end
|
|
|
|
function love.gamepadreleased(joystick, button)
|
|
if editorMode then return end
|
|
if Importer then return end
|
|
Game:gamepadreleased(joystick, button)
|
|
end
|
|
|
|
function love.gamepadaxis(joystick, axis, value)
|
|
if editorMode then return end
|
|
if Importer then return end
|
|
Game:gamepadaxis(joystick, axis, value)
|
|
end
|
|
|
|
function love.joystickremoved(joystick)
|
|
if editorMode then return end
|
|
if Importer then return end
|
|
Game:joystickremoved(joystick)
|
|
end
|
|
|
|
-- f is true on focus gained, false on focus lost (e.g. alt-tab). A held
|
|
-- direction's key-up can be delivered to the OS instead of the game while
|
|
-- unfocused, so reset input on either transition rather than trust it.
|
|
function love.focus(f)
|
|
if editorMode then return end
|
|
if Importer then
|
|
if Importer.focus then Importer:focus(f) end
|
|
return
|
|
end
|
|
Game:focus(f)
|
|
end
|
|
|
|
-- v is true when the window becomes visible again, false on minimize.
|
|
function love.visible(v)
|
|
if editorMode then return end
|
|
if Importer then return end
|
|
Game:visible(v)
|
|
end
|
|
|
|
function love.touchpressed(id, x, y, dx, dy, pressure)
|
|
if editorMode then return end
|
|
if Importer then return Importer:mousepressed(x, y, 1) end
|
|
Game:touchpressed(id, x, y)
|
|
end
|
|
|
|
function love.touchmoved(id, x, y, dx, dy, pressure)
|
|
if editorMode then return end
|
|
if Importer then return end
|
|
Game:touchmoved(id, x, y)
|
|
end
|
|
|
|
function love.touchreleased(id, x, y, dx, dy, pressure)
|
|
if editorMode then return end
|
|
if Importer then return end
|
|
Game:touchreleased(id, x, y)
|
|
end
|
|
|
|
function love.wheelmoved(x, y)
|
|
if editorMode then
|
|
if EditorApp.wheelmoved then return EditorApp.wheelmoved(x, y) end
|
|
return
|
|
end
|
|
if Importer then return end
|
|
Game:wheelmoved(x, y)
|
|
end
|
|
|
|
function love.mousepressed(x, y, button)
|
|
if Importer then return Importer:mousepressed(x, y, button) end
|
|
if editorMode and EditorApp.mousepressed then
|
|
return EditorApp.mousepressed(x, y, button)
|
|
end
|
|
if mouseTouch and Game and button == 1 then
|
|
Game:touchpressed("mouse", x, y)
|
|
end
|
|
end
|
|
|
|
function love.mousereleased(x, y, button)
|
|
if Importer then return end
|
|
if editorMode and EditorApp.mousereleased then
|
|
return EditorApp.mousereleased(x, y, button)
|
|
end
|
|
if mouseTouch and Game and button == 1 then
|
|
Game:touchreleased("mouse", x, y)
|
|
end
|
|
end
|
|
|
|
function love.mousemoved(x, y)
|
|
if editorMode or Importer then return end
|
|
if mouseTouch and Game and love.mouse.isDown(1) then
|
|
Game:touchmoved("mouse", x, y)
|
|
end
|
|
end
|
|
|
|
function love.textinput(text)
|
|
if Importer then return end
|
|
if editorMode and EditorApp.textinput then
|
|
return EditorApp.textinput(text)
|
|
end
|
|
end
|
|
|
|
function love.quit()
|
|
if editorMode and EditorApp.quit then
|
|
return EditorApp.quit() -- return true to abort quit
|
|
end
|
|
pcall(function()
|
|
require("src.core.DiscordPresence").shutdown()
|
|
end)
|
|
end
|
|
|
|
function love.filedropped(file)
|
|
if editorMode and EditorApp and EditorApp.filedropped then
|
|
return EditorApp.filedropped(file)
|
|
end
|
|
if Importer then Importer:filedropped(file) end
|
|
end
|
|
|
|
local function pacingEnabled()
|
|
if os.getenv("POKEPORT_AUTOPILOT") then return false end
|
|
if os.getenv("POKEPORT_DRIVER") then return false end
|
|
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
|
|
return true
|
|
end
|
|
|
|
function love.run()
|
|
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
|
|
|
|
-- don't let love.load's cost land in the first frame's dt
|
|
if love.timer then love.timer.step() end
|
|
|
|
local FrameCap = require("src.core.FrameCap")
|
|
local paced = pacingEnabled()
|
|
-- The deadline the next present() should not beat. Carried forward one
|
|
-- budget per frame so pacing stays even instead of drifting with the
|
|
-- per-frame sleep-granularity jitter.
|
|
local nextFrame = love.timer and love.timer.getTime() or 0
|
|
local dt = 0
|
|
|
|
return function()
|
|
-- process events
|
|
if love.event then
|
|
love.event.pump()
|
|
for name, a, b, c, d, e, f in love.event.poll() do
|
|
if name == "quit" then
|
|
if not love.quit or not love.quit() then
|
|
return a or 0
|
|
end
|
|
end
|
|
love.handlers[name](a, b, c, d, e, f)
|
|
end
|
|
end
|
|
|
|
-- update dt
|
|
if love.timer then dt = love.timer.step() end
|
|
|
|
-- call update and draw
|
|
if love.update then love.update(dt) end
|
|
|
|
if love.graphics and love.graphics.isActive() then
|
|
love.graphics.origin()
|
|
love.graphics.clear(love.graphics.getBackgroundColor())
|
|
if love.draw then love.draw() end
|
|
love.graphics.present()
|
|
end
|
|
|
|
if love.timer then
|
|
if paced then
|
|
-- Sleep out the remainder of the frame budget, measured from the
|
|
-- carried deadline, in small chunks so the OS timer stays
|
|
-- responsive. vsync is untouched: when it already paces slower
|
|
-- than the cap the remainder is <= 0 and this rounds to a no-op.
|
|
local budget = 1 / FrameCap.current
|
|
nextFrame = nextFrame + budget
|
|
local now = love.timer.getTime()
|
|
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
|
|
-- deadline more than a full budget in the past; re-anchor to now so
|
|
-- we pace the next frame rather than burst uncapped to catch up.
|
|
if now - nextFrame > budget then
|
|
nextFrame = now
|
|
end
|
|
while true do
|
|
local remaining = nextFrame - love.timer.getTime()
|
|
if remaining <= 0 then break end
|
|
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
|
|
end
|
|
else
|
|
love.timer.sleep(0.001)
|
|
end
|
|
end
|
|
end
|
|
end
|