mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
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:
+30
-17
@@ -147,29 +147,42 @@ function Game:bootConfig()
|
|||||||
return boot
|
return boot
|
||||||
end
|
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
|
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
|
||||||
-- and by the START-menu QUIT confirmation
|
-- and by the START-menu QUIT confirmation
|
||||||
function Game:makeTitleState()
|
function Game:makeTitleState()
|
||||||
local OverworldState = require("src.world.OverworldController")
|
local OverworldState = require("src.world.OverworldController")
|
||||||
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
|
||||||
local title = factory.new(self, {
|
local title = factory.new(self, {
|
||||||
onNewGame = function()
|
onNewGame = function() self:startNewGame() end,
|
||||||
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,
|
|
||||||
onContinue = function()
|
onContinue = function()
|
||||||
local loaded, recovered = SaveData.load()
|
local loaded, recovered = SaveData.load()
|
||||||
if loaded then
|
if loaded then
|
||||||
|
|||||||
+54
-15
@@ -1,28 +1,63 @@
|
|||||||
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
|
-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's
|
||||||
-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the
|
-- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32
|
||||||
-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn
|
-- style alphabet pokeserver room/tournament codes are drawn from
|
||||||
-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read
|
-- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or
|
||||||
-- aloud or handwritten never has to be checked twice).
|
-- 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 = {}
|
local CodeEntry = {}
|
||||||
|
|
||||||
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||||
CodeEntry.LENGTH = 6
|
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 = {}
|
local chars = {}
|
||||||
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
|
for i = 1, length do chars[i] = 1 end -- index into charset, 1-based
|
||||||
return { chars = chars, pos = 1 }
|
return { chars = chars, pos = 1, charset = charset, length = length }
|
||||||
end
|
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)
|
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
|
end
|
||||||
|
|
||||||
function CodeEntry.down(state)
|
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
|
end
|
||||||
|
|
||||||
function CodeEntry.left(state)
|
function CodeEntry.left(state)
|
||||||
@@ -30,14 +65,18 @@ function CodeEntry.left(state)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function CodeEntry.right(state)
|
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
|
end
|
||||||
|
|
||||||
function CodeEntry.text(state)
|
function CodeEntry.text(state)
|
||||||
local out = {}
|
local out = {}
|
||||||
for i = 1, CodeEntry.LENGTH do
|
for i = 1, lengthOf(state) do out[i] = CodeEntry.charAt(state, i) end
|
||||||
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
|
|
||||||
end
|
|
||||||
return table.concat(out)
|
return table.concat(out)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+53
-1
@@ -112,6 +112,32 @@ function LinkState.newJoinOnline(game, code)
|
|||||||
return self
|
return self
|
||||||
end
|
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)
|
function LinkState:exitWith(message, reason)
|
||||||
DiscordPresence.setJoinCode(nil)
|
DiscordPresence.setJoinCode(nil)
|
||||||
self.game.linkSession = nil -- back to the player's own GAME SPEED
|
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 },
|
mods = peer and peer.mods, fingerprint = peer and peer.fingerprint },
|
||||||
})
|
})
|
||||||
if self.verdict == "full" or self.verdict == "vanilla_peer" then
|
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
|
-- host picks the level rule now, before the parties are exchanged
|
||||||
|
-- (an adopted session had it passed in; see newFromSession)
|
||||||
self.stage = "battleOptions"
|
self.stage = "battleOptions"
|
||||||
else
|
else
|
||||||
self:startMode(mode, isHost)
|
self:startMode(mode, isHost)
|
||||||
@@ -243,6 +270,16 @@ function LinkState:update(dt)
|
|||||||
end
|
end
|
||||||
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 self.stage == "menu" then
|
||||||
if input:wasPressed("down") then
|
if input:wasPressed("down") then
|
||||||
self.index = self.index % 3 + 1
|
self.index = self.index % 3 + 1
|
||||||
@@ -497,11 +534,26 @@ function LinkState:update(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.game.stack:push(battle)
|
self.game.stack:push(battle)
|
||||||
|
self.battle = battle
|
||||||
self.stage = "battleRunning"
|
self.stage = "battleRunning"
|
||||||
end
|
end
|
||||||
|
|
||||||
elseif self.stage == "battleRunning" then
|
elseif self.stage == "battleRunning" then
|
||||||
if self.game.stack:top() == self 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
|
self:exitWith(nil) -- battle finished
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1993,7 +1993,13 @@ function OverworldState:interact()
|
|||||||
-- talk() lands the follower on its cell first.
|
-- talk() lands the follower on its cell first.
|
||||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||||
elseif not npc.moving then
|
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
|
end
|
||||||
interacted(self, fx, fy, "npc", npc)
|
interacted(self, fx, fy, "npc", npc)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
-- quiet no-op, never a crash. Reaching into OverworldState internals
|
-- quiet no-op, never a crash. Reaching into OverworldState internals
|
||||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||||
|
|
||||||
|
local Collision = require("src.world.Collision")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local FieldDefaults = require("src.world.FieldDefaults")
|
local FieldDefaults = require("src.world.FieldDefaults")
|
||||||
local Map = require("src.world.Map")
|
local Map = require("src.world.Map")
|
||||||
@@ -407,6 +408,72 @@ function Handle:position()
|
|||||||
return self.npc.cellX, self.npc.cellY
|
return self.npc.cellX, self.npc.cellY
|
||||||
end
|
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)
|
function WorldAPI:npc(mapId, indexOrName)
|
||||||
local ow = self:overworld()
|
local ow = self:overworld()
|
||||||
if not ow then return nil, NO_OVERWORLD end
|
if not ow then return nil, NO_OVERWORLD end
|
||||||
|
|||||||
Reference in New Issue
Block a user