From dd0f0e08921e035cecf762787ecc115ffb52c511 Mon Sep 17 00:00:00 2001 From: "DESKTOP-8SRFDDM\\cam95" Date: Sat, 22 Aug 2026 21:21:02 -0500 Subject: [PATCH] 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 --- src/core/Game.lua | 47 +++++++++++++-------- src/link/CodeEntry.lua | 69 ++++++++++++++++++++++++------- src/link/LinkState.lua | 54 +++++++++++++++++++++++- src/world/OverworldController.lua | 8 +++- src/world/WorldAPI.lua | 67 ++++++++++++++++++++++++++++++ 5 files changed, 211 insertions(+), 34 deletions(-) diff --git a/src/core/Game.lua b/src/core/Game.lua index b1b730b9..bbe9c9eb 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/link/CodeEntry.lua b/src/link/CodeEntry.lua index f0335662..23c74241 100644 --- a/src/link/CodeEntry.lua +++ b/src/link/CodeEntry.lua @@ -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 diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index c2464beb..44ae1841 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e0391b3f..47dda17e 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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 diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 69b9eea2..d4f3d800 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -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