Merge pull request #1746 from campavao/engine-mod-seams

engine: generic seams for mod-driven multiplayer modes
This commit is contained in:
bryanthaboi
2026-08-24 08:34:33 -04:00
committed by GitHub
9 changed files with 649 additions and 34 deletions
+30 -17
View File
@@ -147,29 +147,42 @@ function Game:bootConfig()
return boot
end
-- NEW GAME, as a call: a fresh skeleton (through save.new_game, so a mod
-- can reshape it), the overworld at the skeleton's spawn, and the intro
-- screen on top. The title menu's NEW GAME row is this; a mod that starts a
-- game on its own terms -- a match, a challenge mode -- calls it directly.
--
-- opts.intro = false skips the newGame screen (Oak's speech) so the player
-- lands straight in the world; the skeleton then has to carry a name and a
-- party, which is the caller's job via save.new_game.
function Game:startNewGame(opts)
local OverworldState = require("src.world.OverworldController")
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing,
{ via = "boot", freshBoot = true })
if not (opts and opts.intro == false) then
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
local OverworldState = require("src.world.OverworldController")
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
local title = factory.new(self, {
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
self.sessionStartedAt = os.time()
self.save = SaveData.newGame(self:bootConfig())
-- no bucket carry-over: mod state from an abandoned session must
-- not leak into a fresh slot; mods seed via save.created instead
self:adoptSave(self.save)
ModRuntime.emit("save.created", { save = self.save })
self:applyOptions(self.save.options)
self.stack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y,
self.save.player.facing,
{ via = "boot", freshBoot = true })
Screens.push(self, bootScreens(self).newGame or "OakSpeech",
function() end)
end,
onNewGame = function() self:startNewGame() end,
onContinue = function()
local loaded, recovered = SaveData.load()
if loaded then
+54 -15
View File
@@ -1,28 +1,63 @@
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the
-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn
-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read
-- aloud or handwritten never has to be checked twice).
-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's
-- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32
-- style alphabet pokeserver room/tournament codes are drawn from
-- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or
-- handwritten never has to be checked twice).
--
-- The Gen 1 naming grid cannot stand in for this: it has no digits at all
-- (data/text/alphabets.asm is letters and punctuation), so a room code or
-- an address typed there would be unenterable. That is what this exists
-- for.
--
-- new() takes an optional {length=, charset=} so the same interaction can
-- carry something other than a room code -- a dotted IP over "0123456789.",
-- say. Both default to the room-code shape, so existing callers are
-- unaffected and CodeEntry.LENGTH / CodeEntry.CHARSET still describe them.
local CodeEntry = {}
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
CodeEntry.LENGTH = 6
function CodeEntry.new()
-- state carries its own length/charset so a caller holding two widgets of
-- different shapes cannot have one read the other's alphabet
function CodeEntry.new(opts)
local charset = (opts and opts.charset) or CodeEntry.CHARSET
local length = (opts and opts.length) or CodeEntry.LENGTH
local chars = {}
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
return { chars = chars, pos = 1 }
for i = 1, length do chars[i] = 1 end -- index into charset, 1-based
return { chars = chars, pos = 1, charset = charset, length = length }
end
local N = #CodeEntry.CHARSET
-- Seed the slots from an existing string: prefilling the LAN address means
-- the player scrubs the last octet instead of all twelve digits. Anything
-- not in the charset lands on slot 1's character.
-- Slots past the end of the seed -- and any character the charset does not
-- carry -- land on the charset's blank where it has one, so seeding a
-- 15-slot address widget with "192.168.1.40" reads back as that address and
-- not as "192.168.1.40000". A charset with no blank (the room code's) has
-- nowhere to put one, so those fall back to the first character as before.
function CodeEntry.fromText(text, opts)
local state = CodeEntry.new(opts)
local blank = state.charset:find(" ", 1, true) or 1
for i = 1, state.length do
local ch = tostring(text or ""):sub(i, i)
state.chars[i] = (ch ~= "" and state.charset:find(ch, 1, true)) or blank
end
return state
end
local function charsetOf(state) return state.charset or CodeEntry.CHARSET end
local function lengthOf(state) return state.length or CodeEntry.LENGTH end
function CodeEntry.up(state)
state.chars[state.pos] = state.chars[state.pos] % N + 1
local n = #charsetOf(state)
state.chars[state.pos] = state.chars[state.pos] % n + 1
end
function CodeEntry.down(state)
state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1
local n = #charsetOf(state)
state.chars[state.pos] = (state.chars[state.pos] - 2) % n + 1
end
function CodeEntry.left(state)
@@ -30,14 +65,18 @@ function CodeEntry.left(state)
end
function CodeEntry.right(state)
state.pos = math.min(CodeEntry.LENGTH, state.pos + 1)
state.pos = math.min(lengthOf(state), state.pos + 1)
end
-- the character in slot i, which is what a draw loop wants
function CodeEntry.charAt(state, i)
local charset = charsetOf(state)
return charset:sub(state.chars[i], state.chars[i])
end
function CodeEntry.text(state)
local out = {}
for i = 1, CodeEntry.LENGTH do
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
end
for i = 1, lengthOf(state) do out[i] = CodeEntry.charAt(state, i) end
return table.concat(out)
end
+53 -1
View File
@@ -112,6 +112,32 @@ function LinkState.newJoinOnline(game, code)
return self
end
-- Adopt a transport that is ALREADY paired and skip the connect UI: an
-- overworld multiplayer session handing one pair of players off to a battle
-- or a trade. The caller has settled which mode and which side hosts, so
-- all that is left is the hello exchange every link session runs before it
-- commits -- the fingerprint/mod compatibility check still gets its say,
-- exactly as it would have on the LAN or ONLINE path.
--
-- `transport` is anything Session accepts (update/poll/send/close plus the
-- .paired/.closed/.error fields), which is what lets a mod route a battle
-- over a channel of its own. Ownership transfers with it: exitWith closes
-- the session, so the caller's transport is done once this state unwinds.
--
-- opts.forceLevel the level rule, normally chosen on the battleOptions
-- screen; an adopted session has no menu to pick it on
function LinkState.newFromSession(game, transport, mode, isHost, opts)
local self = LinkState.new(game)
self.net = Session.new(transport, { role = isHost and "host" or "guest",
kind = "link" })
self.adopted = true
self.adoptedMode, self.adoptedHost = mode, isHost and true or false
self.forceLevel = opts and opts.forceLevel or nil
self.stage = "adopted"
self:sendHello(isHost and mode or nil)
return self
end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
@@ -203,8 +229,9 @@ function LinkState:decideCompat(mode, isHost)
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
})
if self.verdict == "full" or self.verdict == "vanilla_peer" then
if isHost and mode == "battle" then
if isHost and mode == "battle" and not self.adopted then
-- host picks the level rule now, before the parties are exchanged
-- (an adopted session had it passed in; see newFromSession)
self.stage = "battleOptions"
else
self:startMode(mode, isHost)
@@ -243,6 +270,16 @@ function LinkState:update(dt)
end
end
-- handed over from an already-paired session (LinkState.newFromSession):
-- both hellos are in flight, and the first one to land settles compat and
-- drops us straight into the agreed mode
if self.stage == "adopted" then
if self:pollHello() then
self:decideCompat(self.adoptedMode, self.adoptedHost)
end
return
end
if self.stage == "menu" then
if input:wasPressed("down") then
self.index = self.index % 3 + 1
@@ -497,11 +534,26 @@ function LinkState:update(dt)
return
end
self.game.stack:push(battle)
self.battle = battle
self.stage = "battleRunning"
end
elseif self.stage == "battleRunning" then
if self.game.stack:top() == self then
-- the lockstep copies carry the damage the real party never takes
-- (cable rules), so a mode that wants it -- a tournament ladder, a
-- battle royale -- reads it from here before the state unwinds
local battle = self.battle
if battle and Runtime.wants("link.battle_ended") then
Runtime.emit("link.battle_ended", {
result = battle.result or "ended",
myParty = battle.playerParty,
theirParty = battle.enemyParty,
peerName = self.peerName,
role = self.isHost and "host" or "guest",
})
end
self.battle = nil
self:exitWith(nil) -- battle finished
end
end
+10 -1
View File
@@ -1969,6 +1969,10 @@ function OverworldState:pushableAtCell(cx, cy)
return nil
end
-- world.talk's fallthrough, hoisted so the A press does not build a closure
-- on every press just to have one to hand a hook nobody may have wrapped
local function vanillaTalk(ow, target) ow:talkTo(target) end
-- what the A press resolved to, for world.interacted's listeners
local function interacted(self, fx, fy, kind, target)
Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy,
@@ -1998,7 +2002,12 @@ function OverworldState:interact()
-- talk() lands the follower on its cell first.
require("src.world.PikachuFollower").talk(Game, self, npc)
elseif not npc.moving then
self:talkTo(npc)
-- world.talk: the A press on an object, before the map's text tables
-- get it. A runtime object a mod spawned (WorldAPI:spawnNpc) carries
-- no TEXT_* id, so the vanilla path has nothing to say for it; a mod
-- that owns the object wraps this and simply does not call next().
-- Everything else falls straight through to talkTo as before.
Runtime.call("world.talk", vanillaTalk, self, npc)
end
interacted(self, fx, fy, "npc", npc)
return
+67
View File
@@ -5,6 +5,7 @@
-- quiet no-op, never a crash. Reaching into OverworldState internals
-- stays unsupported; anything a mod legitimately needs belongs here.
local Collision = require("src.world.Collision")
local Logger = require("src.core.Logger")
local FieldDefaults = require("src.world.FieldDefaults")
local Map = require("src.world.Map")
@@ -407,6 +408,72 @@ function Handle:position()
return self.npc.cellX, self.npc.cellY
end
-- Walk one tile starting NOW, outside the scripted-movement queue.
--
-- scriptMove queues onto OverworldState.scriptMoves, and a non-empty
-- scriptMoves is how the overworld knows a cutscene is running -- it gates
-- handleInput (OverworldController "local scripted = ... #self.scriptMoves >
-- 0"), so an actor animated that way freezes the player's controls for as
-- long as it walks. That is right for Oak marching to his lab and wrong for
-- an actor that moves on its own schedule: a networked player's ghost, an
-- ambient walker. This is the same per-tile state scriptMove sets, minus
-- the queue and therefore minus the lockout.
--
-- Collision is deliberately not checked. The caller is replaying a move
-- that was already decided somewhere else (validated on the peer's machine,
-- or authored), and re-judging it here would let the two copies disagree
-- about where the actor is. Use canStep first if you want the check.
function Handle:stepNow(dir)
local npc = self.npc
if not Collision.DELTA[dir] then return nil, "bad direction: " .. tostring(dir) end
if npc.moving then return nil, "already moving" end
npc.facing = dir
npc.targetX, npc.targetY = Collision.target(npc.cellX, npc.cellY, dir)
npc.moving = true
npc.progress = 0
return true
end
-- Would stepNow land somewhere legal? Exposed separately so a caller that
-- does want the map's opinion can ask for it without giving up the "replay
-- verbatim" default above.
function Handle:canStep(dir)
local ow = self.ow
if not (ow and ow.map) then return false end
return Collision.canMove(ow.map, ow.entities, self.npc, dir) and true or false
end
-- Snap to a cell with no animation: a warp arrival, or a resync that has
-- drifted too far to walk off. Clears any step in flight so the entity
-- cannot land on its old target a frame later.
function Handle:placeAt(x, y, facing)
local npc = self.npc
npc.moving = false
npc.marching = false
npc.targetX, npc.targetY = nil, nil
npc.progress = 0
npc.cellX, npc.cellY = x, y
npc.px, npc.py = x * 16, y * 16
if facing then npc.facing = facing end
return true
end
-- True while a step is still animating, so a driver can pace itself rather
-- than stomping a move in flight.
function Handle:isMoving()
return self.npc.moving and true or false
end
-- Whether the player may walk through this object (Collision.occupied skips
-- passable entities -- Yellow's companion Pikachu is the engine's own user
-- of the flag). It still draws and can still be talked to; it just stops
-- being an obstacle, which is what a dynamic actor wants when standing in a
-- doorway would otherwise wall someone in.
function Handle:setPassable(passable)
self.npc.passable = passable and true or false
return true
end
function WorldAPI:npc(mapId, indexOrName)
local ow = self:overworld()
if not ow then return nil, NO_OVERWORLD end