engine: seams for mod-driven world actors and link sessions

Five small additions. None of them mention the mod that prompted them.

WorldAPI handles gain stepNow, canStep, placeAt, isMoving and setPassable.
scriptMove queues onto OverworldState.scriptMoves, and a non-empty
scriptMoves is how the overworld knows a cutscene is running, so it gates
handleInput -- an actor animated that way freezes the player's controls for
as long as it walks. Right for Oak marching to his lab, wrong for anything
moving on its own schedule. stepNow sets the same per-tile state without the
queue. It skips the collision check on purpose: the caller is replaying a
move that was decided somewhere else, and re-judging it here would let the
two copies disagree about where the actor is. canStep is there for callers
that do want the map's opinion.

OverworldController raises world.talk around the NPC talk path. An object
from spawnNpc has no TEXT_* id, so the vanilla path has nothing to say for
one; a mod that owns the object wraps this and does not call next.

LinkState.newFromSession adopts a transport that is already paired, so a
mode can tunnel a battle through a connection it already has rather than
opening a second one. The hello and fingerprint exchange still runs. When
the battle ends, link.battle_ended carries the result and both lockstep
party copies -- cable rules leave the real party alone, so that is the only
place the damage exists.

Game:startNewGame(opts) is the title's NEW GAME closure made callable, with
opts.intro=false to land straight in the world. CodeEntry.new takes an
optional {length=, charset=} so the same widget can enter an address or a
room code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-8SRFDDM\cam95
2026-08-22 21:21:02 -05:00
parent e88f2ef060
commit dd0f0e0892
5 changed files with 211 additions and 34 deletions
+20 -7
View File
@@ -147,13 +147,16 @@ function Game:bootConfig()
return boot
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
-- 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")
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()
@@ -167,9 +170,19 @@ function Game:makeTitleState()
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
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() 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 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
+7 -1
View File
@@ -1993,7 +1993,13 @@ 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", function(ow, target) ow:talkTo(target) end,
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