G2 support

This commit is contained in:
bryanthaboi
2026-08-11 11:52:56 -04:00
parent 79ed37699e
commit ae6cac89e1
489 changed files with 226677 additions and 1798 deletions
+233
View File
@@ -0,0 +1,233 @@
-- The BICYCLE: BikeFunction (engine/events/overworld.asm), the PLAYER_BIKE
-- half of DoPlayerMovement (engine/overworld/player_movement.asm), the map
-- load's CheckUpdatePlayerSprite (engine/overworld/map_setup.asm) and the
-- bike shop's phone call (maps/GoldenrodBikeShop.asm ->
-- engine/overworld/events.asm DoBikeStep).
--
-- DoBikeStep was already written and running in src/world/gen2/StepEvents.lua
-- and could never fire, because nothing put the player in PLAYER_BIKE and
-- nothing set the flag the clerk sets. Both ends live here.
--
-- love-free: every routine takes the map environment, the collision under the
-- player and the current wPlayerState as plain values, so the whole decision
-- tree is testable without a world.
--
-- The three scripts BikeFunction queues are built here too, in the shape
-- src/world/gen2/HiddenItems.lua uses: a `hiddenitem` pickup and a BICYCLE
-- mount are both hand-assembled command lists for the same VM.
local FieldMoves = require("src.world.gen2.FieldMoves")
local Permissions = require("src.world.gen2.Permissions")
local Strings = require("src.core.Strings")
local Bike = {}
-- constants/engine_flags.asm. wBikeFlags' three bits and wStatusFlags2's
-- BIKE_SHOP_CALL bit are all reachable from a script as ENGINE_* ids, which is
-- the namespace Vm's setflag / clearflag writes onto save.engineFlags.
Bike.ENGINE_BIKE_SHOP_CALL_ENABLED = 19
Bike.ENGINE_STRENGTH_ACTIVE = 23
Bike.ENGINE_ALWAYS_ON_BIKE = 24
Bike.ENGINE_DOWNHILL = 25
-- engine/overworld/variables.asm .VarActionTable, and wPlayerState as
-- VAR_MOVEMENT writes it raw. Script_GetOnBike is `loadvar VAR_MOVEMENT,
-- PLAYER_BIKE`, so the mount is literally one variable write plus a sprite
-- reload.
Bike.VAR_MOVEMENT = 0x08
Bike.PLAYER_NORMAL_ID = 0
Bike.PLAYER_BIKE_ID = 1
-- MUSIC_BICYCLE. .GetOnBike does not use the outdoor-song override machinery
-- Gen 1 has: it fades the current song out, plays this one and writes it into
-- wMapMusic, so the bike theme IS the map's music until the map changes or the
-- player gets off (`special PlayMapMusic`).
Bike.MUSIC_BICYCLE = "Music_Bicycle"
-- StepVectors (engine/overworld/map_objects.asm): a normal step is 8 frames of
-- 2 pixels and a fast step 4 frames of 4, so the bike is exactly half the
-- duration of a walk. The port walks a 16-pixel cell in 16 frames, so the
-- ratio is what carries over rather than the count.
function Bike.stepFramesFor(walkFrames)
return math.max(1, math.floor((walkFrames or 16) / 2))
end
-- .CheckEnvironment's first half: CheckOutdoorMap (ROUTE or TOWN), plus CAVE
-- and GATE by name. INDOOR, ENVIRONMENT_5 and DUNGEON are the three that
-- refuse -- which is why you cannot ride inside a Gym but can ride through
-- Union Cave and the Route 32 gatehouse.
local RIDEABLE_ENVIRONMENT = {
TOWN = true, ROUTE = true, CAVE = true, GATE = true,
}
function Bike.environmentAllows(environment)
return RIDEABLE_ENVIRONMENT[environment] == true
end
-- .CheckEnvironment in full: the environment, then GetPlayerTilePermission
-- `and $f` -- the tile the player is STANDING on has to be a plain LAND_TILE.
-- CollisionPermissionTable calls doors and stairs LAND, so the tiles this
-- actually rejects are water and walls: it is the gate that stops a bike being
-- got on mid-surf.
function Bike.canUseHere(environment, collision)
if not Bike.environmentAllows(environment) then return false end
return Permissions.isLand(collision)
end
-- .TryBike. Three answers plus a nil, in the cart's own order:
--
-- nil .CannotUseBike -- `ld a, $0`, so wFieldMoveSucceeded is 0
-- and the PACK prints OakThisIsntTheTimeText and stays open.
-- "mount" PLAYER_NORMAL and the environment allows it.
-- "dismount" PLAYER_BIKE, and wBikeFlags' ALWAYS_ON_BIKE is clear.
-- "cant_get_off" PLAYER_BIKE on a forced stretch (the Cycling Road's
-- ENGINE_ALWAYS_ON_BIKE). Still returns 1 on the cart, so
-- the PACK closes and the refusal prints in the overworld.
--
-- A surfing player falls through every `cp` and lands on .CannotUseBike, which
-- is why nil covers PLAYER_SURF without a test of its own.
function Bike.tryBike(ctx)
ctx = ctx or {}
if not Bike.canUseHere(ctx.environment, ctx.collision) then return nil end
local state = ctx.state or FieldMoves.PLAYER_NORMAL
if state == FieldMoves.PLAYER_NORMAL then return "mount" end
if state == FieldMoves.PLAYER_BIKE then
if ctx.alwaysOnBike then return "cant_get_off" end
return "dismount"
end
return nil
end
-- CheckUpdatePlayerSprite (engine/overworld/map_setup.asm), run on every map
-- load, in the cart's own order:
--
-- .CheckForcedBiking ALWAYS_ON_BIKE puts the player ON the bike,
-- whatever they walked in as, and wins outright.
-- .CheckSurfing CheckOnWater reads the tile the player is
-- STANDING on: a load that lands on water is a
-- surfing load, and one that already was keeps
-- the sprite it had.
-- .ResetSurfingOrBikingState the two ways the state is taken away: surfing
-- and NOT on water (the arm above has already
-- failed by the time this one runs), or riding
-- into an INDOOR, ENVIRONMENT_5 or DUNGEON map.
--
-- `onWater` is CheckOnWater's answer. nil means the caller could not read the
-- tile at all -- a world with no map up yet -- and the two surf arms are
-- skipped rather than guessed at, because guessing wrong either strands a
-- player on land aboard a Lapras or drops them into the sea on foot.
function Bike.mapSetupState(state, environment, alwaysOnBike, onWater)
if alwaysOnBike then return FieldMoves.PLAYER_BIKE end
if onWater ~= nil then
if onWater then
if FieldMoves.isSurfing(state) then return state end
return FieldMoves.PLAYER_SURF
end
if FieldMoves.isSurfing(state) then return FieldMoves.PLAYER_NORMAL end
end
if state ~= FieldMoves.PLAYER_BIKE then return state end
if environment == "INDOOR" or environment == "ENVIRONMENT_5"
or environment == "DUNGEON" then
return FieldMoves.PLAYER_NORMAL
end
return state
end
-- .GetDPad: on a DOWNHILL map (the Cycling Road), a frame with no direction
-- held is a frame moving DOWN -- the bike rolls on its own. A held direction,
-- any held direction, wins.
function Bike.forcedDirection(dir, downhill)
if dir then return dir end
if downhill then return "down" end
return nil
end
-- .DoStep's pick between STEP_BIKE and STEP_WALK. .BikeCheck is what makes it
-- a bike step at all, and the DOWNHILL exception is the cart's own: coasting
-- across a slope is SLOWER than coasting down it, so every direction but DOWN
-- gets the walking duration back.
function Bike.stepFrames(state, dir, downhill, walkFrames)
walkFrames = walkFrames or 16
if state ~= FieldMoves.PLAYER_BIKE then return walkFrames end
if downhill and dir ~= "down" then return walkFrames end
return Bike.stepFramesFor(walkFrames)
end
-- ------------------------------------------------------------ the scripts
--
-- data/text/common_2.asm. None of the three hangs off a script pointer -- they
-- are `text_far` targets inside engine/events/overworld.asm -- so the extractor
-- never saw them and there is no text.lua key to name them by. Strings.source
-- declares them, Strings() resolves them at the call: the split a module-level
-- table has to use.
--
-- {STRBUF} is wStringBuffer2, which _DoItemEffect filled with the item's name
-- before it ever reached BikeFunction; `getitemname` is that same fill here.
local TEXT_GOT_ON_BIKE = Strings.source("{PLAYER} got on the\n{STRBUF}.")
local TEXT_GOT_OFF_BIKE = Strings.source("{PLAYER} got off\nthe {STRBUF}.")
local TEXT_CANT_GET_OFF = Strings.source("You can't get off\nhere!")
-- Script_GetOnBike and Script_GetOffBike, which are the same six commands with
-- a different PLAYER_* byte and a `special PlayMapMusic` on the way out (the
-- bike theme was written into wMapMusic on the way in, so only the dismount
-- has to put the map's own song back).
--
-- The cart's `refreshmap` and `special UpdateTimePals` are dropped for the same
-- reason HiddenItems.itemfinderScript drops them: both repair the tilemap and
-- the palettes the PACK overwrote, and the port draws the PACK as a state over
-- an untouched world. The `opentext` in front is the mirror of that: the cart
-- inherits the PACK's open text box and this port's queued script does not, so
-- it opens one of its own, exactly as the itemfinder script does.
--
-- `specialId(name)` resolves a special by LABEL through the cache's own
-- specialOrder; a nil answer just leaves that line out rather than dispatching
-- some other special by a counted index.
local function stateScript(item, varValue, text, specialId, restoreMusic, silent)
local script = {}
if not silent then
-- .CheckIfRegistered: with wUsingItemWithSelect set, the cart swaps in
-- Script_GetOnBike_Register / Script_GetOffBike_Register, which are the
-- same state change with the line and the box taken out -- a SELECT press
-- gets on the bike with no text at all.
script[#script + 1] = { op = "opentext" }
script[#script + 1] = { op = "getitemname", item = item }
end
script[#script + 1] = { op = "loadvar", args = { Bike.VAR_MOVEMENT, varValue } }
if not silent then
script[#script + 1] = { op = "rawtext", text = text }
script[#script + 1] = { op = "waitbutton" }
script[#script + 1] = { op = "closetext" }
end
local update = specialId and specialId("UpdatePlayerSprite")
if update then script[#script + 1] = { op = "special", id = update } end
if restoreMusic then
local play = specialId and specialId("PlayMapMusic")
if play then script[#script + 1] = { op = "special", id = play } end
end
script[#script + 1] = { op = "end" }
return script
end
function Bike.mountScript(item, specialId, silent)
return stateScript(item, Bike.PLAYER_BIKE_ID, TEXT_GOT_ON_BIKE,
specialId, false, silent)
end
function Bike.dismountScript(item, specialId, silent)
return stateScript(item, Bike.PLAYER_NORMAL_ID, TEXT_GOT_OFF_BIKE,
specialId, true, silent)
end
-- Script_CantGetOffBike: no loadvar at all, so wPlayerState is left exactly as
-- it was and the player is still riding when the box closes.
function Bike.cantGetOffScript()
return {
{ op = "opentext" },
{ op = "rawtext", text = TEXT_CANT_GET_OFF },
{ op = "waitbutton" },
{ op = "closetext" },
{ op = "end" },
}
end
return Bike
+213
View File
@@ -0,0 +1,213 @@
-- The border block that surrounds a map.
--
-- home/map.asm LoadBlockData byte-fills wOverworldMapBlocks with 0 before
-- ChangeMap copies the map's own blocks into the middle of it, and then
-- LoadMetatiles resolves every block it reads:
--
-- ; If the current map block is a border block, load the border block.
-- ld a, [de]
-- and a
-- jr nz, .ok
-- ld a, [wMapBorderBlock]
--
-- So block id 0 is not "tileset block 0": it is a stand-in for the map
-- header's border block, both in the margin ChangeMap never wrote to and
-- anywhere inside the map's own block list. A map smaller than the 20x18
-- viewport (GOLDENROD_DEPT_STORE_ELEVATOR is 2x2 blocks) is almost all
-- margin, which is why it showed as black around a postage stamp instead of
-- the wall block the cart tiles across the whole screen.
--
-- The fill is one 32x32 bake wrap-tiled over the view, drawn under the map
-- and the connection strips, so it costs a single quad however small the map
-- is. Gen 1 already does this in src/render/TileRenderer.lua (its border
-- block comes straight from the map header and block 0 means nothing there),
-- so this is the Gen 2 half rather than a change to the shared renderer.
local GbcPalette = require("src.render.GbcPalette")
local PixelCanvas = require("src.render.PixelCanvas")
local BorderFill = {}
-- One block, in pixels: 4x4 tiles of 8.
BorderFill.SIZE = 32
-- LoadMetatiles' `and a / jr nz` in one place: id 0 (or a hole in the block
-- list) reads as the map header's border block.
function BorderFill.blockFor(blockId, borderBlock)
if blockId == nil or blockId == 0 then return borderBlock or 0 end
return blockId
end
-- Bakes are cached alongside the map canvases and share their key, so the
-- daytime rollover, the COLOR option and the cave flicker all invalidate the
-- border with the map it belongs to. The suffix keeps World:dropMapImages'
-- "<mapId>|" prefix sweep working on it.
function BorderFill.cacheKey(mapKey)
return tostring(mapKey) .. "|border"
end
-- Where to put the wrap-tiled quad for a camera at (camX, camY) world pixels
-- filling a w x h screen at scale s. The source origin is floored so the
-- 32x32 texture is sampled on whole texels (a fractional offset would soften
-- the wall against the map's own pixels), and the draw position takes the
-- fraction back so the tiling still meshes with the map canvas next to it.
-- The extra block of width/height covers that shift.
function BorderFill.viewport(camX, camY, w, h, s)
s = (s and s > 0) and s or 1
local ix, iy = math.floor(camX), math.floor(camY)
local vw = math.ceil(w / s) + BorderFill.SIZE
local vh = math.ceil(h / s) + BorderFill.SIZE
local sx = math.floor((ix - camX) * s)
local sy = math.floor((iy - camY) * s)
return ix, iy, vw, vh, sx, sy
end
-- Bake `blockId` of `tileset` into a 32x32 repeat-wrapped image.
--
-- Same palette pass as World:bakeMapImage: a tile's four colors come from its
-- PalMap slot inside the eight BG palettes of `bgSet`, so the walk is by slot
-- and not by tile. 32x32 real pixels through PixelCanvas -- a DPI-scaled
-- canvas would bake the block at a fractional texel size and the repeat wrap
-- would then tile at non-square pixels (#208).
--
-- `waterFrame` is the optional { image, row, tile, slot } descriptor of this
-- frame's AnimateWaterTile graphic (engine/tilesets/tileset_anims.asm:167).
-- The fill is a wrap-tiled 32x32 texture and cannot be overlaid, so a border
-- block made of water is re-baked per frame instead.
function BorderFill.bake(atlas, tileset, blockId, bgSet, waterFrame)
if not (atlas and tileset and love and love.graphics) then return nil end
local block = tileset.blocks and tileset.blocks[(blockId or 0) + 1]
if not block then return nil end
local tilesPerRow = tileset.tilesPerRow or 16
local tilePalettes = tileset.tilePalettes
local colored = bgSet and tilePalettes and GbcPalette.available()
local aw, ah = atlas:getDimensions()
local quads = {}
local function quadFor(tile)
local q = quads[tile]
if q then return q end
q = love.graphics.newQuad((tile % tilesPerRow) * 8,
math.floor(tile / tilesPerRow) * 8, 8, 8, aw, ah)
quads[tile] = q
return q
end
local function drawTiles(slot)
for i = 0, 15 do
local tile = block[i + 1] or 0
-- tilePalettes is 1-based over the sheet tiles; anything past it takes
-- slot 1, exactly as the map bake does.
local tileSlot = tilePalettes and tilePalettes[tile + 1] or 1
if not slot or tileSlot == slot then
love.graphics.draw(atlas, quadFor(tile),
(i % 4) * 8, math.floor(i / 4) * 8)
end
end
end
local canvas = PixelCanvas.new(BorderFill.SIZE, BorderFill.SIZE, "nearest")
if not canvas then return nil end
love.graphics.push("all")
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
-- A LOVE canvas does not reset the transform, and this bake is reachable
-- from inside World:draw (the first frame after a COLOR change), so the
-- renderer's world transform would otherwise be baked in and then cached.
love.graphics.push()
love.graphics.origin()
if colored then
for slot = 1, 8 do
GbcPalette.with(bgSet[slot], function() drawTiles(slot) end)
end
else
drawTiles(nil)
end
if waterFrame and waterFrame.image and waterFrame.tile then
local quad = love.graphics.newQuad(0, ((waterFrame.row or 1) - 1) * 8,
8, 8, 8, 32)
local function frames()
love.graphics.setColor(1, 1, 1, 1)
for i = 0, 15 do
if (block[i + 1] or 0) == waterFrame.tile then
love.graphics.draw(waterFrame.image, quad,
(i % 4) * 8, math.floor(i / 4) * 8)
end
end
end
local set = colored and bgSet[waterFrame.slot or 1]
if set then GbcPalette.with(set, frames) else frames() end
end
love.graphics.pop()
love.graphics.setCanvas()
love.graphics.pop()
local img = canvas
if canvas.newImageData then
local ok, data = pcall(canvas.newImageData, canvas)
if ok and data then
local okImg, made = pcall(love.graphics.newImage, data)
if okImg and made then img = made end
end
end
img:setWrap("repeat", "repeat")
img:setFilter("nearest", "nearest")
return img
end
-- Each map header carries its OWN border block, so crossing a boundary swaps
-- the whole void from one block to another: Cherrygrove's water becomes Route
-- 30's trees between one frame and the next. On a 20x18 viewport that is a few
-- pixels at the screen edge and nobody sees it; under survey zoom the void is
-- most of the window, and the swap reads as the background popping.
--
-- So the swap is dissolved rather than cut. `key` is the map the image belongs
-- to, not the image itself: the same block gets re-baked by the daytime
-- rollover, the COLOR option and the two-frame cave flicker, and a dissolve on
-- any of those would smear the flicker into mush.
BorderFill.CROSSFADE_FRAMES = 20
-- The bookkeeping half, love-free so it can be checked without a canvas.
-- Returns the image to draw underneath (nil on the first fill and once the
-- dissolve is over) and the alpha the incoming image draws at.
function BorderFill.crossfade(owner, image, key)
if not owner or key == nil then return nil, 1 end
if owner.borderKey ~= key then
-- Nothing to dissolve from on the first map of a session.
owner.borderFrom = (owner.borderKey ~= nil) and owner.borderLast or nil
owner.borderKey = key
owner.borderFade = owner.borderFrom and 0 or nil
end
owner.borderLast = image
if not owner.borderFade then return nil, 1 end
owner.borderFade = owner.borderFade + 1
if owner.borderFade >= BorderFill.CROSSFADE_FRAMES then
owner.borderFade, owner.borderFrom = nil, nil
return nil, 1
end
return owner.borderFrom, owner.borderFade / BorderFill.CROSSFADE_FRAMES
end
-- Tile `image` across the whole view, world-aligned so it meshes with the map
-- canvas drawn over it. One reused Quad per caller table: this runs every
-- overworld frame, and a fresh Quad here churns the GC.
function BorderFill.draw(owner, image, camX, camY, w, h, s, key)
if not (image and love and love.graphics) then return false end
local ix, iy, vw, vh, sx, sy = BorderFill.viewport(camX, camY, w, h, s)
local q = owner and owner.borderQuad
if q then
q:setViewport(ix, iy, vw, vh, BorderFill.SIZE, BorderFill.SIZE)
else
q = love.graphics.newQuad(ix, iy, vw, vh,
BorderFill.SIZE, BorderFill.SIZE)
if owner then owner.borderQuad = q end
end
local from, alpha = BorderFill.crossfade(owner, image, key)
if from then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(from, q, sx, sy, 0, s, s)
end
love.graphics.setColor(1, 1, 1, alpha)
love.graphics.draw(image, q, sx, sy, 0, s, s)
love.graphics.setColor(1, 1, 1, 1)
return true
end
return BorderFill
+263
View File
@@ -0,0 +1,263 @@
-- wCmdQueue: engine/overworld/cmd_queue.asm and home/stone_queue.asm.
--
-- Four five-byte slots, polled once a frame by HandleCmdQueue, written by the
-- `writecmdqueue` script command and cleared by `delcmdqueue`. The port had
-- neither: both commands were explicit no-ops, and `delcmdqueue` answering TRUE
-- was correct only because the queue it reported on was permanently empty.
--
-- Only one of the five queue types does anything a player can see, and it is
-- the one that matters most: CMDQUEUE_STONETABLE is what makes a boulder pushed
-- onto a hole fall through it. Two maps use it -- Ice Path B1F and Blackthorn
-- Gym 2F -- and Ice Path gates Blackthorn, so without this the eighth badge is
-- unreachable.
--
-- CmdQueue_Null ret
-- CmdQueue_Type1 SetXYCompareFlags
-- CmdQueue_StoneTable the boulder check below
-- CmdQueue_Type3 ret
-- CmdQueue_Type4 an hSCY shake, unreferenced by any map
--
-- love-free: the caller supplies the objects, the warps and a collision lookup.
local Strings = require("src.core.Strings")
local CmdQueue = {}
CmdQueue.CAPACITY = 4
-- HandleQueuedCommand.Jumptable order (constants/script_constants.asm).
CmdQueue.NULL = 0
CmdQueue.TYPE1 = 1
CmdQueue.STONETABLE = 2
CmdQueue.TYPE3 = 3
CmdQueue.TYPE4 = 4
CmdQueue.NUM_TYPES = 5
-- CheckPitTile (home/map_objects.asm): COLL_PIT and COLL_PIT_68.
local PIT = { [0x60] = true, [0x68] = true }
-- SPRITEMOVEDATA_STRENGTH_BOULDER. The check is on the MOVEMENT type, not on
-- SPRITE_BOULDER: Blackthorn Gym 2F has six boulders and only three of them are
-- in its stone table, but all six carry this movedata.
CmdQueue.BOULDER_MOVEDATA = 0x19
function CmdQueue.new()
return {}
end
-- ClearCmdQueue: every slot's TYPE byte zeroed. Called on a map load, which is
-- why a queue never survives a warp and every map that needs one writes it back
-- from a MAPCALLBACK_CMDQUEUE callback.
function CmdQueue.clear(queue)
for i = 1, CmdQueue.CAPACITY do queue[i] = nil end
return queue
end
-- WriteCmdQueue -> .GetNextEmptyEntry. A full queue sets carry and the write is
-- simply DROPPED; there is no error path and no overwrite.
function CmdQueue.write(queue, entry)
if type(entry) ~= "table" or not entry.kind then return nil end
for i = 1, CmdQueue.CAPACITY do
if queue[i] == nil then
queue[i] = entry
return i
end
end
return nil
end
-- DelCmdQueue. Answers whether it FOUND and deleted an entry of that type --
-- which is the opposite of what `delcmdqueue` writes to wScriptVar, because
-- Script_delcmdqueue's `ret c` returns on the delete with wScriptVar still 0
-- and only falls through to TRUE when the loop ran off the end.
function CmdQueue.delete(queue, kind)
for i = 1, CmdQueue.CAPACITY do
local entry = queue[i]
if entry and entry.kind == kind then
queue[i] = nil
return true
end
end
return false
end
function CmdQueue.count(queue)
local n = 0
for i = 1, CmdQueue.CAPACITY do
if queue[i] then n = n + 1 end
end
return n
end
-- .IsObjectOnWarp's `.check_on_warp`: a linear walk of the map's warp_events
-- for one at the object's cell, answering the warp NUMBER rather than a
-- boolean. The number is 1-based (`ld a, [wCurMapWarpEventCount] / sub d /
-- inc a`), which is the same numbering `stonetable`'s first byte uses.
--
-- The cart subtracts 4 from the object's stored coordinates because
-- OBJECT_MAP_X / _Y carry the map border's offset; the port stores plain map
-- cells, so there is nothing to subtract.
function CmdQueue.warpNumberAt(warps, x, y)
for index, warp in ipairs(warps or {}) do
if warp.x == x and warp.y == y then return index end
end
return nil
end
-- .IsObjectInStoneTable: walk `db warp, object / dw script` rows until $ff.
-- BOTH bytes have to match, which is what keeps a boulder pushed onto the wrong
-- hole from falling through it.
function CmdQueue.stoneRow(rows, warpNumber, objectId)
for _, row in ipairs(rows or {}) do
if row.warp == warpNumber and row.object == objectId then return row end
end
return nil
end
-- CmdQueue_StoneTable. Four gates on the object before HandleStoneQueue is
-- even called, and they are all load bearing:
--
-- OBJECT_SPRITE non-zero -- a disappeared boulder has no struct left
-- OBJECT_MOVEMENT_TYPE -- SPRITEMOVEDATA_STRENGTH_BOULDER
-- CheckPitTile -- the tile UNDER the boulder is a hole
-- OBJECT_WALKING STANDING -- not mid-push, or it would fall a step early
--
-- The loop returns on the FIRST boulder that falls (`jr c, .fall_down_hole`
-- pops and rets), so two boulders never drop on the same frame.
function CmdQueue.stoneFall(entry, ctx)
local rows = entry and entry.rows
if not rows then return nil end
for _, obj in ipairs((ctx and ctx.objects) or {}) do
if obj.visible ~= false
and obj.movement == CmdQueue.BOULDER_MOVEDATA
and not obj.moving
and PIT[ctx.collisionAt(obj.cellX, obj.cellY)] then
local warp = CmdQueue.warpNumberAt(ctx.warps, obj.cellX, obj.cellY)
local row = warp and CmdQueue.stoneRow(rows, warp, obj.id)
if row then return row, obj end
end
end
return nil
end
-- HandleCmdQueue: every slot, in order, once a frame. Only STONETABLE produces
-- anything for the caller to act on; the other four are the cart's own `ret`s
-- and its unreferenced hSCY shake, written out so the jumptable is complete
-- rather than implied.
function CmdQueue.poll(queue, ctx)
for i = 1, CmdQueue.CAPACITY do
local entry = queue[i]
if entry and entry.kind == CmdQueue.STONETABLE then
local row, obj = CmdQueue.stoneFall(entry, ctx)
if row then return row, obj, i end
end
end
return nil
end
--------------------------------------------------------------------------
-- The two stone tables
--------------------------------------------------------------------------
--
-- These are DATA the extractor cannot reach yet. A stone table hangs off a
-- MAPCALLBACK_CMDQUEUE callback, maps.lua carries no callbacks at all, and the
-- per-boulder scripts are reachable only through the table -- so none of it is
-- in scripts.lua. There are exactly two of them in the whole game and both are
-- eight lines of pokegold, so they are hand-ported here with their source
-- cited, the same standing arrangement the Pokegear's radio lines have.
--
-- When the extractor grows map callbacks these become the fallback rather than
-- the source: World:writeCmdQueue prefers an extracted entry.
--
-- Object ids are the cart's own (`object_const_def` is `const_def 2`, so the
-- first object_event of a map is id 2), which is the numbering `disappear`
-- already speaks. Warp numbers are 1-based into the map's warp_events.
--
-- Event flags are the numbers this cache assigns:
-- EVENT_BOULDER_IN_ICE_PATH_1..4 1801..1804 (the B1F boulders themselves)
-- EVENT_BOULDER_IN_ICE_PATH_1A..4A 1805..1808 (their twins one floor down,
-- on ICE_PATH_B2F_MAHOGANY_SIDE -- clearing one is what makes the fallen
-- boulder appear down there)
-- They are consecutive `const`s in constants/event_flags.asm, and
-- tests/gen2_world_test.lua pins the four the cache actually emits.
local ICE_PATH_BOULDER_EVENT = { 1805, 1806, 1807, 1808 }
-- maps/IcePathB1F.asm .FinishBoulder, shared by all four rows:
-- pause 30 / scall .BoulderFallsThrough / opentext / writetext / waitbutton /
-- closetext / end, where .BoulderFallsThrough is playsound SFX_STRENGTH +
-- earthquake 80 (two pixels for sixteen frames -- one byte, two numbers).
local function boulderScript(objectId, clearEvent, text)
local script = {
{ op = "disappear", object = objectId },
}
if clearEvent then
script[#script + 1] = { op = "clearevent", event = clearEvent }
end
script[#script + 1] = { op = "pause", frames = 30 }
script[#script + 1] = { op = "playsound", id = 27 } -- SFX_STRENGTH
script[#script + 1] = { op = "earthquake", param = 80 }
script[#script + 1] = { op = "opentext" }
-- `rawtext` is the port's own command, not the cart's: `writetext` names a
-- key into text.lua and this string was never extracted (see the note above).
script[#script + 1] = { op = "rawtext", text = text }
script[#script + 1] = { op = "waitbutton" }
script[#script + 1] = { op = "closetext" }
script[#script + 1] = { op = "end" }
return script
end
local ICE_PATH_TEXT = Strings.source("The boulder fell\nthrough.")
local BLACKTHORN_TEXT = Strings.source("The boulder fell\nthrough!")
CmdQueue.STONE_TABLES = {
-- maps/IcePathB1F.asm IcePathB1FSetUpStoneTableCallback.
ICE_PATH_B1F = {
{ warp = 3, object = 2,
script = boulderScript(2, ICE_PATH_BOULDER_EVENT[1], ICE_PATH_TEXT) },
{ warp = 4, object = 3,
script = boulderScript(3, ICE_PATH_BOULDER_EVENT[2], ICE_PATH_TEXT) },
{ warp = 5, object = 4,
script = boulderScript(4, ICE_PATH_BOULDER_EVENT[3], ICE_PATH_TEXT) },
{ warp = 6, object = 5,
script = boulderScript(5, ICE_PATH_BOULDER_EVENT[4], ICE_PATH_TEXT) },
},
-- maps/BlackthornGym2F.asm. Note the warp order: BOULDER1 goes to warp 5,
-- BOULDER2 to warp 3 and BOULDER3 to warp 4, which is not the order the rows
-- are written in and is transcribed rather than tidied. These three clear no
-- event: nothing appears on the floor below, the boulder is simply gone.
BLACKTHORN_GYM_2F = {
{ warp = 5, object = 4, script = boulderScript(4, nil, BLACKTHORN_TEXT) },
{ warp = 3, object = 5, script = boulderScript(5, nil, BLACKTHORN_TEXT) },
{ warp = 4, object = 6, script = boulderScript(6, nil, BLACKTHORN_TEXT) },
},
}
-- MAPCALLBACK_CMDQUEUE's whole job on both maps: `writecmdqueue .CommandQueue`
-- where the entry is `cmdqueue CMDQUEUE_STONETABLE, .StoneTable`.
function CmdQueue.mapEntry(mapId)
local rows = CmdQueue.STONE_TABLES[mapId]
if not rows then return nil end
return { kind = CmdQueue.STONETABLE, rows = rows, mapId = mapId }
end
-- The same entry taken from the cache instead of from the table above: the
-- extractor now follows `writecmdqueue`'s operand through the cmdqueue struct
-- into the stonetable, so a row arrives naming a scripts.lua key rather than
-- carrying an inlined command list. Answers nil for a cache that predates
-- that, or for any of the four queue types nothing acts on, so the caller
-- falls back to STONE_TABLES rather than writing an entry with no rows.
function CmdQueue.fromExtracted(entry, mapId)
if type(entry) ~= "table" then return nil end
if entry.type ~= CmdQueue.STONETABLE then return nil end
local rows = {}
for _, row in ipairs(entry.rows or {}) do
if row.warp and row.object and row.scriptKey then
rows[#rows + 1] =
{ warp = row.warp, object = row.object, script = row.scriptKey }
end
end
if #rows == 0 then return nil end
return { kind = CmdQueue.STONETABLE, rows = rows, mapId = mapId,
extracted = true }
end
return CmdQueue
+102
View File
@@ -0,0 +1,102 @@
-- Gen 2 event-flag bitfield (wEventFlags). Flag SET → object with that
-- eventFlag is hidden (CheckObjectFlag in map_objects_2.asm).
local Runtime = require("src.mods.Runtime")
local Events = {}
Events.__index = Events
local FLAGS_PER_BYTE = 8
function Events.new(initial)
local self = setmetatable({ flags = {} }, Events)
if type(initial) == "table" then
for _, id in ipairs(initial) do self:set(id, true) end
end
return self
end
function Events:get(id)
if not id or id < 0 then return false end
local byte = math.floor(id / FLAGS_PER_BYTE)
local bitn = id % FLAGS_PER_BYTE
local row = self.flags[byte] or 0
return math.floor(row / (2 ^ bitn)) % 2 == 1
end
-- flag.changed carries the SAME name and the same two payload keys Gen 1's
-- src/script/Flags.lua emits, and fires on the same condition: only a real
-- transition, so a redundant set of an already-set flag is silent.
--
-- One difference, and it is a difference in the data rather than in the API:
-- `name` holds a NUMBER here. Gen 2 flags are bits in wEventFlags and the
-- cart's EVENT_* constants are indices into that bitfield, where Gen 1 flags
-- are string keys in save.flags. src/world/gen2/WorldAPI.lua's setFlag tells
-- a mod the same thing from the other side, so a dual-generation listener
-- branches on type(payload.name) rather than on the generation.
--
-- Events:restore and Events:resetMapBuffer deliberately do NOT come through
-- here: a save load and HandleNewMap's one-byte wipe are not script writes,
-- and Gen 1 does not emit for its own save load either.
function Events:set(id, value)
if not id or id < 0 then return end
local watched = Runtime.wants("flag.changed")
local before = watched and self:get(id) or false
local byte = math.floor(id / FLAGS_PER_BYTE)
local bitn = id % FLAGS_PER_BYTE
local mask = 2 ^ bitn
local row = self.flags[byte] or 0
if value then
self.flags[byte] = row + (math.floor(row / mask) % 2 == 0 and mask or 0)
else
if math.floor(row / mask) % 2 == 1 then
self.flags[byte] = row - mask
end
end
if watched then
local after = value and true or false
if before ~= after then
Runtime.emit("flag.changed", { name = id, value = after })
end
end
end
-- ResetMapBufferEventFlags (home/flag.asm): `xor a / ld hl, wEventFlags /
-- ld [hli], a` zeroes exactly ONE byte -- flags 0-7, the
-- EVENT_TEMPORARY_UNTIL_MAP_RELOAD block -- and HandleNewMap runs it on every
-- map load. This is what re-arms every "once per visit" script latch: Bill's
-- grandpa hands out one stone per house entry because his script sets flag 0
-- and checks it at the top, and Kurt's house, the ports, Dragon's Den B1F and
-- the National Park gate all lean on the same byte the same way.
function Events:resetMapBuffer()
self.flags[0] = nil
end
function Events:objectVisible(eventFlag)
-- Extracted maps may keep 0xFFFF instead of nil for "always appear".
if eventFlag == nil or eventFlag == 0xFFFF then return true end
return not self:get(eventFlag)
end
-- The bitfield as a plain table for the save file, and back. Stored as
-- byte -> value rather than a flag list because that is what the cart's SRAM
-- holds, and because a sparse map keeps a save small (most bytes are 0).
function Events:serialize()
local out = {}
for byte, value in pairs(self.flags) do
if value ~= 0 then out[byte] = value end
end
return out
end
function Events:restore(bytes)
if type(bytes) ~= "table" then return self end
self.flags = {}
for byte, value in pairs(bytes) do
local index = tonumber(byte)
if index then self.flags[index] = value end
end
return self
end
return Events
+810
View File
@@ -0,0 +1,810 @@
-- The seven HM field moves, as the love-free half of
-- engine/events/overworld.asm: "may this move be used from here", "what does
-- it do to the tile", and "which line does it print when it can't".
--
-- Every routine in that file comes in two flavours and the port needs both,
-- because they are not the same routine with a different caller:
--
-- *Function / Try*FromMenu the PACK / party-submenu path. Checks the
-- BADGE first (CheckBadge, which prints
-- "Sorry! A new BADGE is required." itself), and
-- assumes the mon is already chosen, because the
-- party list is what chose it.
-- Try*OW the A-press path out of TryTileCollisionEvent.
-- Checks the MOVE first (CheckPartyMove), then
-- the badge through CheckEngineFlag -- which is
-- CheckBadge with the text stripped off, so a
-- badgeless A press is silent, not a refusal.
--
-- The two orders are the reason a tree you cannot cut says "This tree can be
-- CUT!" and stops, while CUT off the menu with no HIVEBADGE says "Sorry! A new
-- BADGE is required." Keep them apart.
--
-- Nothing here touches love, the save writer, or the map: a routine is handed
-- a context table (World:fieldContext builds it) and hands back a result the
-- caller acts on. That is what lets tests drive all seven with a bare table.
--
-- Result shape:
-- { ok = false } the event is declined outright and
-- NOTHING is printed (TryHeadbuttOW's
-- `ret nc`, TrySurfOW's `.quit`)
-- { ok = false, text = ..., badge = } a refusal with a line, `badge` set
-- when it was CheckBadge that refused
-- { ok = true, ask = ..., ... } a yesorno first, then the action
-- { ok = true, action = "cut", ... } run it now
--
-- `action` names the World method that carries it out; everything else in the
-- table is that action's argument.
local Permissions = require("src.world.gen2.Permissions")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local FieldMoves = {}
-- ---------------------------------------------------------------- text
--
-- data/text/common_1.asm and common_2.asm, in the port's own TextBox markers:
-- \n is the text box's second line (`line`), \f is a page break (`para`), and
-- {STRBUF} is the shared wStringBuffer2 token that `text_ram wStringBuffer2`
-- expands to -- GetPartyNickname is what fills it, so it is always the
-- nickname of the mon CheckPartyMove picked.
--
-- These are transcribed rather than looked up for the same reason the headbutt
-- lines in World.lua are: engine/events/overworld.asm names each label
-- directly, the extractor only walks reachable SCRIPT pointers, and so
-- data/generated/text.lua has no key for any of them. `#` is the four-tile
-- POKé compression byte.
-- Each one is wrapped in Strings.source so the catalog generator harvests it:
-- this table is built at require time, long before Strings.load has a catalog,
-- so the lookup itself happens at the World call sites through Strings(...).
FieldMoves.TEXT = {
BADGE_REQUIRED = Strings.source("Sorry! A new BADGE\nis required."),
CANT_USE_HERE = Strings.source("Can't use that\nhere."),
USE_CUT = Strings.source("{STRBUF} used\nCUT!"),
CUT_NOTHING = Strings.source("There's nothing to\nCUT here."),
ASK_CUT = Strings.source("This tree can be\nCUT!"
.. "\fWant to use CUT?"),
CAN_CUT = Strings.source("This tree can be\nCUT!"),
BLINDING_FLASH = Strings.source("A blinding FLASH\nlights the area!"),
USED_SURF = Strings.source("{STRBUF} used\nSURF!"),
CANT_SURF = Strings.source("You can't SURF\nhere."),
ALREADY_SURFING = Strings.source("You're already\nSURFING."),
ASK_SURF = Strings.source("The water is calm.\nWant to SURF?"),
USE_WATERFALL = Strings.source("{STRBUF} used\nWATERFALL!"),
HUGE_WATERFALL = Strings.source("Wow, it's a huge\nwaterfall."),
ASK_WATERFALL = Strings.source("Do you want to use\nWATERFALL?"),
USE_STRENGTH = Strings.source("{STRBUF} used\nSTRENGTH!"),
MOVE_BOULDER = Strings.source("{STRBUF} can\nmove boulders."),
ASK_STRENGTH = Strings.source("A #MON may be\nable to move this."
.. "\fWant to use\nSTRENGTH?"),
BOULDERS_MOVE = Strings.source("Boulders may now\nbe moved!"),
BOULDERS_MAY_MOVE = Strings.source("A #MON may be\nable to move this."),
-- EscapeRopeOrDig's three lines (engine/events/overworld.asm): _UseDigText
-- and _UseEscapeRopeText open the shared warp, _CantUseDigText is DIG's own
-- refusal (the rope's .FailDig arm prints nothing and leaves the PACK to
-- its .Oak line).
USE_DIG = Strings.source("{STRBUF} used\nDIG!"),
USE_ESCAPE_ROPE = Strings.source("{PLAYER} used an\nESCAPE ROPE."),
-- TeleportFunction: _TeleportReturnText on the way out, _CantUseTeleportText
-- indoors.
TELEPORT_RETURN = Strings.source("Return to the last\n#MON CENTER."),
USE_WHIRLPOOL = Strings.source("{STRBUF} used\nWHIRLPOOL!"),
MAY_PASS_WHIRLPOOL = Strings.source("It's a vicious\nwhirlpool!"
.. "\fA #MON may be\nable to pass it."),
ASK_WHIRLPOOL = Strings.source("A whirlpool is in\nthe way."
.. "\fWant to use\nWHIRLPOOL?"),
-- Not a cart line: the stand-in destination prompt World:askFlyPoint uses
-- until the POKeGEAR's MAP card grows _FlyMap's cursor mode.
ASK_FLY_TO = Strings.source("Fly to %s?"),
}
-- ---------------------------------------------------------------- badges
--
-- The ENGINE_*BADGE each Function passes to CheckBadge. There is no pattern
-- to it -- CUT is the HIVEBADGE, SURF is the FOGBADGE, FLASH is the very first
-- badge in the game -- so it is a table, not a formula.
FieldMoves.BADGE = {
CUT = "HIVE", -- CutFunction.CheckAble
FLASH = "ZEPHYR", -- FlashFunction.CheckUseFlash
SURF = "FOG", -- SurfFunction.TrySurf / TrySurfOW
FLY = "STORM", -- FlyFunction.TryFly
STRENGTH = "PLAIN", -- StrengthFunction.TryStrength / TryStrengthOW
WHIRLPOOL = "GLACIER", -- WhirlpoolFunction.TryWhirlpool / TryWhirlpoolOW
WATERFALL = "RISING", -- WaterfallFunction.TryWaterfall / TryWaterfallOW
}
-- wJohtoBadges bit order, which is also the order src/ui/gen2/TrainerCard.lua
-- lists them in. A save may key `player.badges` by name or by that position,
-- and the trainer card already reads it both ways; this is the same read, so
-- the two screens can never disagree about who owns what.
-- NOTE the order: MINERAL is bit 4 and STORM bit 5, which is NOT the order a
-- player earns them (Chuck's STORMBADGE comes before Jasmine's MINERALBADGE).
-- constants/engine_flags.asm:38-45 is the authority and this follows it; the
-- two used to be swapped here, which silently mapped SURF's gate onto the wrong
-- bit.
FieldMoves.JOHTO_BADGES = {
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
}
FieldMoves.KANTO_BADGES = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
-- ENGINE_* id -> which badge store and which name.
--
-- On the cart these are not two things: ENGINE_ZEPHYRBADGE *is* bit 0 of
-- wJohtoBadges (constants/engine_flags.asm's "; wJohtoBadges" block), so
-- `setflag ENGINE_ZEPHYRBADGE` and "the player owns the Zephyr Badge" are the
-- same write. The port had split them -- scripts wrote save.engineFlags while
-- hasBadge, VAR_BADGES, the trainer card and the save summary all read
-- save.player.badges, which nothing ever wrote. The visible effect was that no
-- field move could EVER be used: Cut, Surf, Strength and Fly all refused with
-- "Sorry! A new BADGE is required" no matter how many gyms were cleared.
-- World:setEngineFlag / World:engineFlag route badge ids here so there is one
-- store again, the same way ENGINE_BUG_CONTEST_TIMER is routed to
-- save.bugContest rather than kept as a second copy.
FieldMoves.BADGE_FLAG = {}
for index, name in ipairs(FieldMoves.JOHTO_BADGES) do
FieldMoves.BADGE_FLAG[25 + index] = { store = "badges", name = name }
end
for index, name in ipairs(FieldMoves.KANTO_BADGES) do
FieldMoves.BADGE_FLAG[33 + index] = { store = "kantoBadges", name = name }
end
function FieldMoves.hasBadge(save, badge)
if not badge then return true end
local owned = save and save.player and save.player.badges
if type(owned) ~= "table" then return false end
if owned[badge] then return true end
for index, name in ipairs(FieldMoves.JOHTO_BADGES) do
if name == badge then return owned[index] == true end
end
return false
end
-- CheckPartyMove (engine/events/overworld.asm): the first party slot holding
-- move `moveId`. The cart leaves that slot in wCurPartyMon and every caller
-- reads it back through GetPartyNickname, so the mon comes back with its
-- index rather than a bare yes/no.
--
-- EGG slots are skipped by the cart's `.next`; the port has no egg state, so
-- there is nothing to skip yet and a mon with an `egg` field is refused here
-- for whenever there is.
--
-- fieldmove.eligibility wraps it, exactly as it wraps OverworldState:partyKnows
-- under Gen 1: the vanilla link runs first, so a mod that unlocks a field move
-- another way (an HM in the bag, a rental mon) still loses to a party that
-- really knows it. The chain is (moveId, ctx) -> mon, the Gen 1 signature; the
-- second return (the party slot) survives an empty chain but a wrapper that
-- returns one value drops it, which is why every caller here reads only `mon`.
--
-- ctx keeps Gen 1's `save` and `data` keys and adds the two this arm has that
-- Gen 1's does not: `party` (this module is love-free and takes its state as
-- arguments, so the list is not reachable from a Game) and `moveId`.
local function findMoveUser(party, moveId)
for index, mon in ipairs(party or {}) do
if not mon.egg then
for _, move in ipairs(mon.moves or {}) do
local id = type(move) == "table" and move.id or move
if id == moveId then return mon, index end
end
end
end
return nil
end
local function partyMoveUserVanilla(moveId, ctx)
return findMoveUser(ctx and ctx.party, moveId)
end
-- `fieldCtx` is World:fieldContext's table when the caller has one; it is only
-- read for the hook's ctx, so the two-argument Gen 2 callers keep working.
function FieldMoves.partyMoveUser(party, moveId, fieldCtx)
if not Runtime.wantsHook("fieldmove.eligibility") then
return findMoveUser(party, moveId)
end
return Runtime.call("fieldmove.eligibility", partyMoveUserVanilla, moveId, {
save = fieldCtx and fieldCtx.save,
data = fieldCtx and fieldCtx.data,
party = party,
moveId = moveId,
})
end
-- --------------------------------------------------------- encounter gate
--
-- CanEncounterWildMon (engine/overworld/events.asm). The branch that matters
-- is the one in the middle: a CAVE or DUNGEON map jumps STRAIGHT to the ice
-- check, skipping CheckGrassCollision entirely, which is why every walkable
-- tile of Dark Cave and Union Cave is an encounter tile and why the port --
-- which only ever asked Permissions.isGrass -- gave those maps none at all.
--
-- `noWildEncounters` is STATUSFLAGS_NO_WILD_ENCOUNTERS_F, the flag the
-- `wildoff` / `wildon` script commands drive.
function FieldMoves.canEncounterWildMon(environment, playerColl, noWild)
if noWild then return false end
if environment ~= "CAVE" and environment ~= "DUNGEON" then
if not Permissions.isEncounterCollision(playerColl) then return false end
end
-- .ice_check: shared by both arms, so an ice floor in a cave is as free of
-- encounters as an ice floor on a route.
if Permissions.isIce(playerColl) then return false end
return true
end
-- ChooseWildEncounter picks its table off CheckOnWater, i.e. the PERMISSION of
-- the tile the player stands on, not off the grass array that let the roll
-- happen. Standing in a cave rolls the grass list; surfing rolls the water
-- one, on a route and in a cave alike.
function FieldMoves.encounterTable(playerColl)
return Permissions.isWater(playerColl) and "water" or "grass"
end
-- ------------------------------------------------------------ cut blocks
--
-- data/collision/field_move_blocks.asm, verbatim. A row is
-- { facing block, replacement block, animation type }: CUT and WHIRLPOOL do
-- not edit tiles, they swap the whole 32x32 BLOCK the facing tile belongs to
-- for another block out of the same tileset, which is why one swing clears a
-- 2x2 patch of grass.
--
-- Animation type 1 is the grass swirl, 0 the falling tree
-- (OWCutAnimation reads it out of wCutWhirlpoolAnimationType).
FieldMoves.CUT_BLOCKS = {
TILESET_JOHTO = {
[0x03] = { 0x02, 1 }, -- grass
[0x5b] = { 0x3c, 0 }, -- tree
[0x5f] = { 0x3d, 0 }, -- tree
[0x63] = { 0x3f, 0 }, -- tree
[0x67] = { 0x3e, 0 }, -- tree
},
TILESET_JOHTO_MODERN = {
[0x03] = { 0x02, 1 }, -- grass
},
TILESET_KANTO = {
[0x0b] = { 0x0a, 1 }, -- grass
[0x32] = { 0x6d, 0 }, -- tree
[0x33] = { 0x6c, 0 }, -- tree
[0x34] = { 0x6f, 0 }, -- tree
[0x35] = { 0x4c, 0 }, -- tree
[0x60] = { 0x6e, 0 }, -- tree
},
TILESET_PARK = {
[0x13] = { 0x03, 1 }, -- grass
[0x03] = { 0x04, 1 }, -- grass
},
TILESET_FOREST = {
[0x0f] = { 0x17, 0 },
},
}
FieldMoves.WHIRLPOOL_BLOCKS = {
TILESET_JOHTO = {
[0x07] = { 0x36, 0 },
},
}
-- CheckOverworldTileArrays: the tileset has to be in the dictionary AND the
-- facing block has to be in that tileset's list, or the whole thing fails
-- (both `.nope` arms clear carry). Returns replacement block, animation.
function FieldMoves.blockReplacement(table_, tileset, blockId)
local rows = table_ and tileset and table_[tileset]
local row = rows and blockId and rows[blockId]
if not row then return nil end
return row[1], row[2]
end
-- CheckMapForSomethingToCut: the facing tile's collision has to be cuttable
-- AND the block it sits in has to have a replacement. Both halves are needed
-- -- a COLL_CUT_TREE in a tileset with no CutTreeBlockPointers row is the
-- cart's own "nothing to cut".
function FieldMoves.somethingToCut(ctx)
if not Permissions.isCuttable(ctx.facingColl) then return nil end
return FieldMoves.blockReplacement(
FieldMoves.CUT_BLOCKS, ctx.tileset, ctx.facingBlock)
end
-- TryWhirlpoolMenu, which is CheckMapForSomethingToCut with CheckWhirlpoolTile
-- in place of CheckCutCollision.
function FieldMoves.somethingToWhirlpool(ctx)
if not Permissions.isWhirlpool(ctx.facingColl) then return nil end
return FieldMoves.blockReplacement(
FieldMoves.WHIRLPOOL_BLOCKS, ctx.tileset, ctx.facingBlock)
end
-- CheckMapCanWaterfall: facing UP, and the tile ABOVE the player (wTileUp, not
-- the facing tile the A press found) is a waterfall. Those are the same cell
-- while the player faces up, which is exactly why the routine gets away with
-- reading wTileUp -- but the menu path has no facing tile at all, so it must
-- be wTileUp there too.
function FieldMoves.canWaterfall(ctx)
if ctx.facing ~= "up" then return false end
return Permissions.isWaterfall(ctx.upColl)
end
-- .CheckContinueWaterfall: the climb keeps applying turn_waterfall UP for as
-- long as the tile the player is STANDING on is still a waterfall tile.
function FieldMoves.waterfallContinues(playerColl)
return Permissions.isWaterfall(playerColl)
end
-- ------------------------------------------------------- player state
--
-- constants/ram_constants.asm wPlayerState. Held as strings so a save that
-- round-trips one is readable, and mapped to ChrisStateSprites
-- (data/sprites/player_sprites.asm) on the way to the renderer.
--
-- PLAYER_SKATE (2) is the one wPlayerState value with no string here: nothing
-- in Gold ever writes it, and ChrisStateSprites has no row for it either.
FieldMoves.PLAYER_NORMAL = "normal"
FieldMoves.PLAYER_BIKE = "bike"
FieldMoves.PLAYER_SURF = "surf"
FieldMoves.PLAYER_SURF_PIKA = "surf_pika"
FieldMoves.STATE_SPRITE = {
normal = "SPRITE_CHRIS",
bike = "SPRITE_CHRIS_BIKE",
surf = "SPRITE_SURF",
surf_pika = "SPRITE_SURFING_PIKACHU",
}
function FieldMoves.isBiking(state)
return state == FieldMoves.PLAYER_BIKE
end
function FieldMoves.isSurfing(state)
return state == FieldMoves.PLAYER_SURF
or state == FieldMoves.PLAYER_SURF_PIKA
end
-- GetSurfType: the mon CheckPartyMove picked decides the sprite, and PIKACHU
-- is the one species that rides its own.
function FieldMoves.surfType(mon)
local species = mon and (mon.species or mon.id)
if species == "PIKACHU" then return FieldMoves.PLAYER_SURF_PIKA end
return FieldMoves.PLAYER_SURF
end
-- CheckDirection: refuse to start surfing when the tile permissions already
-- block a step in the direction the player faces. wTilePermissions is the
-- four-way "can I leave this tile" mask built by GetMovementPermissions, and
-- the port has no such mask -- but the thing it is guarding against is
-- surfing off a ledge or through a side wall, so the check is the same
-- question asked of the tile under the player.
local BLOCKED_BY = {
-- COLL_RIGHT_WALL / LEFT / UP, the HI_NYBBLE_SIDE_WALLS rows that are
-- actually used, plus the unused remainder of the block for completeness.
[0xb0] = { right = true },
[0xb1] = { left = true },
[0xb2] = { up = true },
[0xb3] = { down = true },
[0xb4] = { down = true, right = true },
[0xb5] = { down = true, left = true },
[0xb6] = { up = true, right = true },
[0xb7] = { up = true, left = true },
}
function FieldMoves.directionBlocked(playerColl, facing)
local row = playerColl and BLOCKED_BY[playerColl % 256]
return (row and row[facing]) == true
end
-- ------------------------------------------------------------------- fly
--
-- data/maps/flypoints.asm, verbatim and in order: FlyMap walks this table by
-- index, so the order is the order the picker scrolls in. Nothing in the ROM
-- points at it as script data, so it is not in landmarks.lua and is
-- transcribed here; FieldMoves.flyPoints reads landmarks.lua for the index and
-- the printed name, and simply drops any row the cache has no landmark for.
--
-- `flag` is the row's ENGINE_FLYPOINT_* id, constants/engine_flags.asm's
-- const_def count (0-based, ENGINE_RADIO_CARD is 0): the byte a town's own
-- MAPCALLBACK_NEWMAP callback sets with `setflag` the first time you walk in,
-- and what FieldMoves.hasVisitedSpawn below actually reads.
FieldMoves.FLYPOINTS = {
-- Johto
{ landmark = "LANDMARK_NEW_BARK_TOWN", spawn = "SPAWN_NEW_BARK", flag = 64 },
{ landmark = "LANDMARK_CHERRYGROVE_CITY", spawn = "SPAWN_CHERRYGROVE", flag = 65 },
{ landmark = "LANDMARK_VIOLET_CITY", spawn = "SPAWN_VIOLET", flag = 66 },
{ landmark = "LANDMARK_AZALEA_TOWN", spawn = "SPAWN_AZALEA", flag = 67 },
{ landmark = "LANDMARK_GOLDENROD_CITY", spawn = "SPAWN_GOLDENROD", flag = 69 },
{ landmark = "LANDMARK_ECRUTEAK_CITY", spawn = "SPAWN_ECRUTEAK", flag = 71 },
{ landmark = "LANDMARK_OLIVINE_CITY", spawn = "SPAWN_OLIVINE", flag = 70 },
{ landmark = "LANDMARK_CIANWOOD_CITY", spawn = "SPAWN_CIANWOOD", flag = 68 },
{ landmark = "LANDMARK_MAHOGANY_TOWN", spawn = "SPAWN_MAHOGANY", flag = 72 },
{ landmark = "LANDMARK_LAKE_OF_RAGE", spawn = "SPAWN_LAKE_OF_RAGE", flag = 73 },
{ landmark = "LANDMARK_BLACKTHORN_CITY", spawn = "SPAWN_BLACKTHORN", flag = 74 },
{ landmark = "LANDMARK_SILVER_CAVE", spawn = "SPAWN_MT_SILVER", flag = 75 },
-- Kanto
{ landmark = "LANDMARK_PALLET_TOWN", spawn = "SPAWN_PALLET", flag = 52 },
{ landmark = "LANDMARK_VIRIDIAN_CITY", spawn = "SPAWN_VIRIDIAN", flag = 53 },
{ landmark = "LANDMARK_PEWTER_CITY", spawn = "SPAWN_PEWTER", flag = 54 },
{ landmark = "LANDMARK_CERULEAN_CITY", spawn = "SPAWN_CERULEAN", flag = 55 },
{ landmark = "LANDMARK_VERMILION_CITY", spawn = "SPAWN_VERMILION", flag = 57 },
{ landmark = "LANDMARK_ROCK_TUNNEL", spawn = "SPAWN_ROCK_TUNNEL", flag = 56 },
{ landmark = "LANDMARK_LAVENDER_TOWN", spawn = "SPAWN_LAVENDER", flag = 58 },
{ landmark = "LANDMARK_CELADON_CITY", spawn = "SPAWN_CELADON", flag = 60 },
{ landmark = "LANDMARK_SAFFRON_CITY", spawn = "SPAWN_SAFFRON", flag = 59 },
{ landmark = "LANDMARK_FUCHSIA_CITY", spawn = "SPAWN_FUCHSIA", flag = 61 },
{ landmark = "LANDMARK_CINNABAR_ISLAND", spawn = "SPAWN_CINNABAR", flag = 62 },
{ landmark = "LANDMARK_INDIGO_PLATEAU", spawn = "SPAWN_INDIGO", flag = 63 },
}
-- spawn -> row, built once, so hasVisitedSpawn below does not walk the whole
-- table on every call.
local FLYPOINT_BY_SPAWN = {}
for _, row in ipairs(FieldMoves.FLYPOINTS) do
FLYPOINT_BY_SPAWN[row.spawn] = row
end
-- KANTO_FLYPOINT: the first Kanto row, 1-based here. FlyMap splits the table
-- at it and shows one region's half or the other, never both.
FieldMoves.KANTO_FLYPOINT = 13
-- HasVisitedSpawn is a bit in wVisitedSpawns, which the ENGINE_FLYPOINT_*
-- engine flags drive: a town's own MAPCALLBACK_NEWMAP callback runs `setflag
-- ENGINE_FLYPOINT_<X>` the first time the map loads, and Script_setflag
-- (Vm.lua) lands that on save.engineFlags[id] the same way ENGINE_ZEPHYRBADGE
-- and the rest of the namespace do -- see FieldMoves.FLYPOINTS' `flag`
-- column for the id.
--
-- A save from before this read the engine flags is missing that entry
-- entirely (engineFlags[id] == nil, not false), so the old bookkeeping --
-- save.visitedSpawns, a plain spawn-name set World used to write by hand --
-- is kept as the fallback for exactly that case. A save that has both
-- trusts the engine flag; a fresh save never touches visitedSpawns again.
function FieldMoves.hasVisitedSpawn(save, spawn)
if not (save and spawn) then return false end
local row = FLYPOINT_BY_SPAWN[spawn]
local engine = save.engineFlags
if row and type(engine) == "table" then
local set = engine[row.flag]
if set ~= nil then return set == true end
end
return (save.visitedSpawns or {})[spawn] == true
end
-- The rows FlyMap would actually let the cursor stop on: this region's half of
-- the table, minus every spawn CheckIfVisitedFlypoint rejects.
--
-- The Kanto half is withheld until SPAWN_INDIGO is visited (.KantoFlyMap's
-- HasVisitedSpawn gate), because with no Kanto flypoint enabled the cart's own
-- picker crashes; standing in Kanto before that shows the Johto map.
function FieldMoves.flyPoints(save, landmarks, region)
local first, last = 1, FieldMoves.KANTO_FLYPOINT - 1
if region == "kanto"
and FieldMoves.hasVisitedSpawn(save, "SPAWN_INDIGO") then
first, last = FieldMoves.KANTO_FLYPOINT, #FieldMoves.FLYPOINTS
end
local out = {}
local table_ = landmarks and landmarks.landmarks
for i = first, last do
local row = FieldMoves.FLYPOINTS[i]
if FieldMoves.hasVisitedSpawn(save, row.spawn) then
local entry = table_ and table_[row.landmark]
out[#out + 1] = {
landmark = row.landmark,
spawn = row.spawn,
index = entry and entry.index or nil,
name = entry and entry.name or row.landmark,
}
end
end
return out
end
-- ------------------------------------------------------------ menu paths
--
-- The *Function routines, each in its own jumptable order. A menu use has
-- already picked the mon, so `ctx.mon` is that mon and CheckPartyMove is not
-- run again; what these decide is the badge and the situation.
local function badgeGate(ctx, move)
local badge = FieldMoves.BADGE[move]
if FieldMoves.hasBadge(ctx.save, badge) then return nil end
-- CheckBadge queues .BadgeRequiredText itself and every caller then exits
-- the jumptable, so this is a refusal WITH a line even from the OW paths
-- that use CheckEngineFlag -- those call the flag check, not this.
return { ok = false, badge = badge, text = FieldMoves.TEXT.BADGE_REQUIRED }
end
-- CutFunction: .CheckAble (badge, then CheckMapForSomethingToCut), .DoCut,
-- .FailCut.
function FieldMoves.cutFromMenu(ctx)
local refused = badgeGate(ctx, "CUT")
if refused then return refused end
local replacement, animation = FieldMoves.somethingToCut(ctx)
if not replacement then
return { ok = false, text = FieldMoves.TEXT.CUT_NOTHING }
end
return {
ok = true, action = "cut",
replacement = replacement, animation = animation,
text = FieldMoves.TEXT.USE_CUT,
}
end
-- FlashFunction.CheckUseFlash: badge, then wTimeOfDayPalset == DARKNESS_PALSET
-- -- so FLASH is refused in a lit cave and on a route alike, and the refusal
-- is FieldMoveFailed's generic "Can't use that here."
function FieldMoves.flashFromMenu(ctx)
local refused = badgeGate(ctx, "FLASH")
if refused then return refused end
if not ctx.dark then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "flash", text = FieldMoves.TEXT.BLINDING_FLASH }
end
-- SurfFunction: .TrySurf, .DoSurf, .FailSurf, .AlreadySurfing. Note the
-- order -- already-surfing is checked BEFORE the facing tile, which is why
-- surfing up to a shore and pressing SURF says "You're already SURFING."
-- rather than "You can't SURF here."
function FieldMoves.surfFromMenu(ctx)
local refused = badgeGate(ctx, "SURF")
if refused then return refused end
if ctx.alwaysOnBike then
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
end
if FieldMoves.isSurfing(ctx.playerState) then
return { ok = false, text = FieldMoves.TEXT.ALREADY_SURFING }
end
if not Permissions.isWater(ctx.facingColl)
or FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then
return { ok = false, text = FieldMoves.TEXT.CANT_SURF }
end
return {
ok = true, action = "surf",
state = FieldMoves.surfType(ctx.mon),
text = FieldMoves.TEXT.USED_SURF,
}
end
-- FlyFunction.TryFly: badge, then CheckOutdoorMap -- ROUTE or TOWN and nothing
-- else, so a Pokecenter counts as indoors. The picker itself is the caller's
-- job; this only says whether it may open.
function FieldMoves.flyFromMenu(ctx)
local refused = badgeGate(ctx, "FLY")
if refused then return refused end
if ctx.environment ~= "ROUTE" and ctx.environment ~= "TOWN" then
-- .indoors falls to .FailFly, which is FieldMoveFailed.
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "fly" }
end
-- StrengthFunction.TryStrength is the shortest of the seven: the badge, and
-- nothing else. STRENGTH from the menu always succeeds once the PLAINBADGE is
-- in, wherever the player is standing, because all it does is set
-- BIKEFLAGS_STRENGTH_ACTIVE and say so.
function FieldMoves.strengthFromMenu(ctx)
local refused = badgeGate(ctx, "STRENGTH")
if refused then return refused end
return {
ok = true, action = "strength",
text = FieldMoves.TEXT.USE_STRENGTH,
after = FieldMoves.TEXT.MOVE_BOULDER,
}
end
-- WaterfallFunction.TryWaterfall: badge, then CheckMapCanWaterfall.
function FieldMoves.waterfallFromMenu(ctx)
local refused = badgeGate(ctx, "WATERFALL")
if refused then return refused end
if not FieldMoves.canWaterfall(ctx) then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return {
ok = true, action = "waterfall", text = FieldMoves.TEXT.USE_WATERFALL,
}
end
-- WhirlpoolFunction: .TryWhirlpool (badge, TryWhirlpoolMenu), .DoWhirlpool,
-- .FailWhirlpool.
function FieldMoves.whirlpoolFromMenu(ctx)
local refused = badgeGate(ctx, "WHIRLPOOL")
if refused then return refused end
local replacement, animation = FieldMoves.somethingToWhirlpool(ctx)
if not replacement then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return {
ok = true, action = "whirlpool",
replacement = replacement, animation = animation,
text = FieldMoves.TEXT.USE_WHIRLPOOL,
}
end
-- TryHeadbuttFromMenu: no badge at all (HEADBUTT is a TM, not an HM), just the
-- facing tile.
function FieldMoves.headbuttFromMenu(ctx)
if not Permissions.isHeadbuttTree(ctx.facingColl) then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "headbutt" }
end
-- SweetScentFromMenu (engine/events/sweet_scent.asm): QueueScript then an
-- unconditional `ld a, $1 / ld [wFieldMoveSucceeded], a` -- no badge, no
-- tile test, nothing that can refuse the press. Whether anything actually
-- turns up is answered later, by the queued script itself
-- (World:sweetScentEncounter), the same way a failed HEADBUTT still shakes
-- the tree before coming up empty.
function FieldMoves.sweetScentFromMenu(_ctx)
return { ok = true, action = "sweetscent" }
end
-- DigFunction (engine/events/overworld.asm EscapeRopeOrDig): no badge -- DIG
-- is a TM -- just .CheckCanDig's CAVE / DUNGEON environment and a live dig
-- triple, which the world hands in as ctx.canEscapeRope. .FailDig prints
-- _CantUseDigText for the move (the rope shares the check but fails silent).
function FieldMoves.digFromMenu(ctx)
local env = ctx.environment
if (env ~= "CAVE" and env ~= "DUNGEON") or not ctx.canEscapeRope then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return { ok = true, action = "dig", text = FieldMoves.TEXT.USE_DIG }
end
-- TeleportFunction .TryTeleport: CheckOutdoorMap (TOWN or ROUTE), then the
-- last spawn pair; World:warpToSpawn already resolves that pair with the
-- bedroom fallback the port boots with, so the outdoor test is the whole
-- refusal here.
function FieldMoves.teleportFromMenu(ctx)
local env = ctx.environment
if env ~= "TOWN" and env ~= "ROUTE" then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return {
ok = true, action = "teleport", text = FieldMoves.TEXT.TELEPORT_RETURN,
}
end
FieldMoves.FROM_MENU = {
CUT = FieldMoves.cutFromMenu,
FLASH = FieldMoves.flashFromMenu,
SURF = FieldMoves.surfFromMenu,
FLY = FieldMoves.flyFromMenu,
STRENGTH = FieldMoves.strengthFromMenu,
WATERFALL = FieldMoves.waterfallFromMenu,
WHIRLPOOL = FieldMoves.whirlpoolFromMenu,
HEADBUTT = FieldMoves.headbuttFromMenu,
SWEET_SCENT = FieldMoves.sweetScentFromMenu,
DIG = FieldMoves.digFromMenu,
TELEPORT = FieldMoves.teleportFromMenu,
}
-- The party submenu's field-move row. Anything the port has no routine for
-- (SOFTBOILED, ROCK_SMASH, MILK_DRINK) lands on FieldMoveFailed's line, which
-- is what the cart's own unimplemented-here branches print.
function FieldMoves.fromMenu(moveId, ctx)
local fn = FieldMoves.FROM_MENU[moveId]
if not fn then
return { ok = false, text = FieldMoves.TEXT.CANT_USE_HERE }
end
return fn(ctx)
end
-- -------------------------------------------------------------- OW paths
--
-- Try*OW, reached from TryTileCollisionEvent. The tile has already been
-- matched by the caller (that is what picked which of these to run), so these
-- start at CheckPartyMove and the badge is CheckEngineFlag -- silent.
-- TryCutOW: no mon or no badge is NOT silent here, it is CantCutScript, so an
-- uncuttable tree still tells you it can be cut. The map check happens after
-- the YES, inside AskCutScript's `callasm .CheckMap`, which is why answering
-- YES to a tree in a tileset with no replacement block simply closes the box.
function FieldMoves.tryCutOW(ctx)
local mon = FieldMoves.partyMoveUser(ctx.party, "CUT", ctx)
if not mon or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.CUT) then
return { ok = false, text = FieldMoves.TEXT.CAN_CUT, took = true }
end
local replacement, animation = FieldMoves.somethingToCut(ctx)
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_CUT,
action = replacement and "cut" or nil,
replacement = replacement, animation = animation,
text = FieldMoves.TEXT.USE_CUT,
}
end
-- TryWhirlpoolOW. Unlike CUT, TryWhirlpoolMenu runs BEFORE the ask, so a
-- whirlpool in a tileset with no replacement block gets the refusal line.
function FieldMoves.tryWhirlpoolOW(ctx)
local mon = FieldMoves.partyMoveUser(ctx.party, "WHIRLPOOL", ctx)
local replacement, animation = FieldMoves.somethingToWhirlpool(ctx)
if not mon
or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.WHIRLPOOL)
or not replacement then
return {
ok = false, took = true, text = FieldMoves.TEXT.MAY_PASS_WHIRLPOOL,
}
end
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_WHIRLPOOL,
action = "whirlpool",
replacement = replacement, animation = animation,
text = FieldMoves.TEXT.USE_WHIRLPOOL,
}
end
-- TryWaterfallOW.
function FieldMoves.tryWaterfallOW(ctx)
local mon = FieldMoves.partyMoveUser(ctx.party, "WATERFALL", ctx)
if not mon
or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.WATERFALL)
or not FieldMoves.canWaterfall(ctx) then
return { ok = false, took = true, text = FieldMoves.TEXT.HUGE_WATERFALL }
end
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_WATERFALL,
action = "waterfall", text = FieldMoves.TEXT.USE_WATERFALL,
}
end
-- TrySurfOW. Every failure arm is `.quit` -- `xor a`, no script, no text --
-- so a shore with no SURF mon is a dead A press, not a refusal. It is also
-- the LAST thing TryTileCollisionEvent tries, so it can afford to be silent.
function FieldMoves.trySurfOW(ctx)
if FieldMoves.isSurfing(ctx.playerState) then return { ok = false } end
if not Permissions.isWater(ctx.facingColl) then return { ok = false } end
if FieldMoves.directionBlocked(ctx.playerColl, ctx.facing) then
return { ok = false }
end
if not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.SURF) then
return { ok = false }
end
local mon = FieldMoves.partyMoveUser(ctx.party, "SURF", ctx)
if not mon then return { ok = false } end
if ctx.alwaysOnBike then return { ok = false } end
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_SURF,
action = "surf", state = FieldMoves.surfType(mon),
text = FieldMoves.TEXT.USED_SURF,
}
end
-- TryStrengthOW, which is a callasm inside AskStrengthScript rather than a
-- tile event: walking into a boulder runs the boulder's own script, and that
-- script asks this which of its three lines to print. The three wScriptVar
-- values are transcribed as strings:
--
-- 0 "already" STRENGTH is already active -> BouldersMoveText
-- 1 "nope" no mon / no PLAINBADGE -> BouldersMayMoveText
-- 2 "ask" may be turned on right now -> AskStrengthScript
--
-- Note the inversion in the cart: `bit BIKEFLAGS_STRENGTH_ACTIVE_F` jumps to
-- .already_using when the bit is CLEAR, so 2 is the not-yet case and 0 the
-- already-on one. Reading that backwards swaps the two lines.
function FieldMoves.tryStrengthOW(ctx)
local mon = FieldMoves.partyMoveUser(ctx.party, "STRENGTH", ctx)
if not mon or not FieldMoves.hasBadge(ctx.save, FieldMoves.BADGE.STRENGTH) then
return { ok = false, took = true, text = FieldMoves.TEXT.BOULDERS_MAY_MOVE }
end
if ctx.strengthActive then
return { ok = false, took = true, text = FieldMoves.TEXT.BOULDERS_MOVE }
end
return {
ok = true, took = true, mon = mon,
ask = FieldMoves.TEXT.ASK_STRENGTH,
action = "strength",
text = FieldMoves.TEXT.USE_STRENGTH,
after = FieldMoves.TEXT.MOVE_BOULDER,
}
end
return FieldMoves
+248
View File
@@ -0,0 +1,248 @@
-- Gen 2 party follower: the entity and trail loop Gold's cart has no
-- counterpart for, shaped like src/world/PikachuFollower.lua because that is
-- the surface Gen 1 follower mods drive (docs/mod-api-gen2-compat.md).
local Logger = require("src.core.Logger")
local Map = require("src.world.gen2.Map")
local NPC = require("src.world.gen2.Npc")
local Follower = {}
-- above every extracted object_event index, so the `<map>_obj_<n>` id
-- src/world/gen2/Npc.lua:165 seeds from can never collide with a map object
local INDEX = 250
-- Gold ships no such record: a mod patches the `sprites` registry (routed to
-- data.gen2Sprites, src/mods/Schemas.lua:475) before shouldSpawn says yes.
Follower.SPRITE = "SPRITE_PIKACHU"
local warnedSprite = false
-- Gold has no companion, so vanilla answers no. A local VARIABLE, not a
-- function: setShouldSpawn writes the same cell debug.setupvalue reaches.
local shouldSpawn
shouldSpawn = function(_game, _world)
return false
end
function Follower.setShouldSpawn(fn)
local previous = shouldSpawn
shouldSpawn = fn or previous
return previous
end
local function spriteDefFor(world)
local sprites = world.sprites or {}
local def = sprites[Follower.SPRITE]
if def then return def end
-- Loud, then the player's sheet: a mod overwrites npc.sprite the line after
-- NPC.new, so a missing record must not decide whether the entity exists.
if not warnedSprite then
warnedSprite = true
Logger.warn("gen2 follower: no %s sprite record; using the player sheet",
Follower.SPRITE)
end
return world.player and world.player.spriteDef
end
local function makeFollower(_game, world, x, y, facing)
local def = spriteDefFor(world)
if not def then return nil end
local npc = NPC.new(world.map.id, {
-- STANDING_DOWN, not STILL: STILL carries FIXED_FACING
-- (src/world/gen2/Npc.lua:61) and a follower has to turn.
index = INDEX, name = "FOLLOWER", sprite = Follower.SPRITE,
movement = NPC.MOVE.STANDING_DOWN, x = x, y = y,
}, def)
npc.follower = true
-- the Gen 1 name a mod tests when it hunts the stock companion
-- (src/world/PikachuFollower.lua:141)
npc.pikachuFollower = true
npc.passable = true -- never blocks a step (src/world/gen2/Player.lua verdict)
npc.facing = facing or "down"
return npc
end
local function findFollower(world)
for i, npc in ipairs(world.npcs or {}) do
if npc.pikachuFollower then return npc, i end
end
return nil
end
local function remove(world)
local npc, i = findFollower(world)
if not npc then return end
table.remove(world.npcs, i)
for j, e in ipairs(world.entities or {}) do
if e == npc then table.remove(world.entities, j) break end
end
world.follower = nil
end
-- behind the player's facing when walkable, else his own cell: it trails out
-- on the next step (src/world/PikachuFollower.lua:169)
local function spawnCell(world)
local p = world.player
local d = Map.DELTA[p.facing] or Map.DELTA.down
local bx, by = p.cellX - d[1], p.cellY - d[2]
if world.map:inBounds(bx, by) and world.map:isWalkableCell(bx, by) then
return bx, by
end
return p.cellX, p.cellY
end
function Follower.current(world)
return (findFollower(world))
end
function Follower.onMapEntered(game, world, opts, viaMapLoad)
if not (world and world.map and world.player) then return end
remove(world)
if not shouldSpawn(game, world) then return end
-- keepPikachu is Gen 1's spelling of the same opt (src/world/PikachuFollower
-- .lua:191); a mod passing it must not get a fresh spawn at every seam.
local keep = opts and (opts.keepFollower or opts.keepPikachu)
if keep then
table.insert(world.npcs, keep)
table.insert(world.entities, keep)
world.follower = keep
return
end
local x, y = spawnCell(world)
-- a fresh load parks it under the player and it walks out as the trail
-- opens; a mid-map respawn keeps the behind-the-facing cell (#863)
if viaMapLoad then x, y = world.player.cellX, world.player.cellY end
local npc = makeFollower(game, world, x, y, world.player.facing)
if not npc then return end
table.insert(world.npcs, npc)
table.insert(world.entities, npc)
world.follower = npc
world.followerTrail = { x = world.player.cellX, y = world.player.cellY }
-- Gen 1's name for the same table, by reference: rebase mutates it in
-- place, so a mod that resets ow.pikachuTrail still moves the live trail.
world.pikachuTrail = world.followerTrail
end
-- One follow step per logic frame, called from World:step after
-- World:updatePeople -- src/world/OverworldController.lua:1039's position.
function Follower.update(game, world)
if not (world and world.map and world.player) then return end
local npc = findFollower(world)
if not npc then
if shouldSpawn(game, world) then Follower.onMapEntered(game, world) end
return
end
if not shouldSpawn(game, world) then
remove(world)
return
end
world.follower = npc
local p = world.player
local trail = world.followerTrail
if not trail then
trail = { x = p.cellX, y = p.cellY }
world.followerTrail = trail
world.pikachuTrail = trail
end
-- the commit, not the landing: targetX/Y is the live destination, which is
-- what keeps the gap at one cell (src/world/PikachuFollower.lua:429, #410)
local destX = p.targetX or p.cellX
local destY = p.targetY or p.cellY
if destX ~= trail.x or destY ~= trail.y then
npc.goalX, npc.goalY = trail.x, trail.y
trail.x, trail.y = destX, destY
end
if npc.moving then return end
if not npc.goalX then return end
local gx, gy = npc.goalX, npc.goalY
if npc.cellX == gx and npc.cellY == gy then
npc.goalX, npc.goalY = nil, nil
return
end
-- more than a screen behind (a warp, a scripted move): snap, do not walk
local far = math.abs(npc.cellX - gx) + math.abs(npc.cellY - gy)
if far > 6 then
npc.cellX, npc.cellY = gx, gy
npc.px, npc.py = gx * 16, gy * 16
npc.goalX, npc.goalY = nil, nil
return
end
local dir
if npc.cellX < gx then dir = "right"
elseif npc.cellX > gx then dir = "left"
elseif npc.cellY < gy then dir = "down"
else dir = "up" end
npc.facing = dir
npc.stepDir = dir
local d = Map.DELTA[dir]
npc.targetX, npc.targetY = npc.cellX + d[1], npc.cellY + d[2]
-- the player's own step length, halved while more than a cell behind:
-- FastPikachuFollow (src/world/PikachuFollower.lua:509)
local stepLen = p.stepFrames or 16
if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end
npc.stepFrames = stepLen
npc.moving = true
npc.progress = 0
-- World:updatePeople already ran, so burn the first frame here or the
-- follower loses a pixel a tile (src/world/PikachuFollower.lua:520)
npc:update(world.map, world.entities)
end
-- The two Gen 1 members a follower mod replaces outright. Gold has neither a
-- companion to talk to nor a walking starter, so both answer honestly nil.
function Follower.talk(_game, _world, _npc, _done)
return false
end
function Follower.starterInParty(_save, _needHealthy)
return nil
end
-- Drop the follower from the DRAW list while leaving it in the UPDATE list,
-- so it hides in place and keeps trailing (src/world/PikachuFollower.lua:952).
-- Re-adding faces it down, as the Gen 1 arm does.
function Follower.setVisible(world, visible)
local npc = findFollower(world)
if not npc then return end
local entities = world.entities or {}
for i, e in ipairs(entities) do
if e == npc then
if visible then return end
table.remove(entities, i)
return
end
end
if not visible then return end
npc.facing = "down"
table.insert(entities, npc)
end
-- The follower when it is STANDING on that cell, which is the test an
-- interact hook wants: mid-step it is between two (src/world/PikachuFollower
-- .lua:965).
function Follower.at(world, cx, cy)
local npc = findFollower(world)
if not npc or npc.moving then return nil end
if npc.cellX == cx and npc.cellY == cy then return npc end
return nil
end
-- slide into a connected map's frame by the seam's delta, the way
-- src/world/PikachuFollower.lua:386 rebases the Gen 1 arm
function Follower.rebase(world, dx, dy)
local npc = findFollower(world)
if npc then
npc.cellX, npc.cellY = npc.cellX + dx, npc.cellY + dy
npc.px, npc.py = npc.px + dx * 16, npc.py + dy * 16
if npc.targetX then npc.targetX = npc.targetX + dx end
if npc.targetY then npc.targetY = npc.targetY + dy end
if npc.goalX then npc.goalX = npc.goalX + dx end
if npc.goalY then npc.goalY = npc.goalY + dy end
end
local trail = world.followerTrail
if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end
end
return Follower
+289
View File
@@ -0,0 +1,289 @@
-- Hidden items: engine/events/checkforhiddenitems.asm (the ITEMFINDER sweep)
-- and the BGEVENT_ITEM arm of the bg event dispatch (`.itemifset` in
-- engine/overworld/events.asm, reached from home/map.asm
-- CheckIfFacingTileCoordIsBGEvent), whose body is HiddenItemScript
-- (engine/events/hidden_item.asm).
--
-- A `bg_event x, y, BGEVENT_ITEM, Label` does NOT name a script. Its operand
-- points at `hiddenitem item, flag`, three bytes laid down by `dwb flag, item`
-- (macros/scripts/maps.asm), and the extractor now carries those two numbers on
-- the bg event row as `hiddenItem = { item, event }` instead of disassembling
-- them. Eighty-seven of them exist; nothing in the port could reach one,
-- because World:bgEventAt only ever answered for BGEVENT_READ.
--
-- love-free: the caller supplies the map def, the player cell, the flag store
-- and a name-to-id sfx resolver, and gets back a command list for the VM.
local Strings = require("src.core.Strings")
local HiddenItems = {}
-- constants/script_constants.asm BGEVENT_*.
HiddenItems.BGEVENT_ITEM = 7
-- constants/hardware.inc: the screen is 20x18 TILES, and a walk cell is two
-- tiles on a side, so SCREEN_WIDTH / 4 and SCREEN_HEIGHT / 4 are half a screen
-- in the cell units bg_event coordinates and wXCoord/wYCoord both use. RGBDS
-- divides integers, so 18 / 4 is 4 and 18 / 2 is 9: the sweep box is NOT
-- symmetric about the player and transcribing it as one loses a row.
local HALF_SCREEN_X, HALF_SCREEN_Y = 5, 4
local SCREEN_CELLS_X, SCREEN_CELLS_Y = 10, 9
-- constants/sfx_constants.asm, resolved by LABEL at call time; the ids here are
-- only the fallback for a cache whose sfx table sits somewhere else.
local SFX_SECOND_PART_OF_ITEMFINDER = { "Sfx_SecondPartOfItemfinder", 18 }
local SFX_TRANSACTION = { "Sfx_Transaction", 34 }
local SFX_ITEM = { "Sfx_Item", 1 }
-- data/text/common_1.asm and common_2.asm. None of these four is reachable
-- from a script pointer -- the itemfinder's two hang off `text_far` inside
-- engine/items/itemfinder.asm and the pickup's two off HiddenItemScript's own
-- ASM -- so the extractor never saw them and there is no text.lua key to name
-- them by. Strings.source declares them here and Strings() resolves them at
-- the call, which is the split a module-level table has to use.
local TEXT_PLAYER_FOUND = Strings.source("{PLAYER} found\n{STRBUF}.")
local TEXT_BUT_NO_SPACE = Strings.source("But {PLAYER} has\nno space left…")
local TEXT_ITEMFINDER_NEARBY = Strings.source(
"Yes! ITEMFINDER\nindicates there's\nan item nearby.")
local TEXT_ITEMFINDER_NOPE = Strings.source(
"Nope! ITEMFINDER\nisn't responding.")
-- The ITEMBALL pair is NOT the hidden item's pair, and the two read almost the
-- same, which is exactly why they get confused. FindItemInBallScript writes
-- _FoundItemText and _CantCarryItemText (data/text/common_2.asm:199 and :206);
-- the found line ends on "!" where the hidden item's _PlayerFoundItemText ends
-- on "." (common_1.asm:998), and the full-pocket line is three lines of "But
-- {PLAYER} can't / carry any more / items!" where _ButNoSpaceText is two. The
-- `\v` is the `cont` in that third line, the same scroll the extracted text
-- uses. Declared here for the same reason as the four above: no script pointer
-- reaches them, so the extractor never saw them and there is no text.lua key.
local TEXT_FOUND_ITEM = Strings.source("{PLAYER} found\n{STRBUF}!")
local TEXT_CANT_CARRY = Strings.source(
"But {PLAYER} can't\ncarry any more\vitems!")
-- The `hiddenitem` pair on a bg event row, or nil when the row is not one.
function HiddenItems.dataOf(bgEvent)
if type(bgEvent) ~= "table" then return nil end
if bgEvent.kind ~= HiddenItems.BGEVENT_ITEM then return nil end
local data = bgEvent.hiddenItem
if type(data) ~= "table" or not data.item then return nil end
return data
end
-- Every still-unfound hidden item on a map, in bg_event order. `events` is the
-- wEventFlags store (src/world/gen2/Events.lua); a nil one means "nothing found
-- yet", which is what a test harness without a save wants.
function HiddenItems.unfound(mapDef, events)
local out = {}
for _, ev in ipairs((mapDef and mapDef.bgEvents) or {}) do
local data = HiddenItems.dataOf(ev)
if data and not (events and events:get(data.event)) then
out[#out + 1] = { x = ev.x, y = ev.y, item = data.item, event = data.event }
end
end
return out
end
-- CheckForHiddenItems, spelled out because the box is easy to get wrong.
--
-- The cart takes the BOTTOM RIGHT corner of the screen (player + half a screen
-- on each axis) and, for each bg event, computes corner minus event coordinate
-- as an unsigned byte. Carry -- the event is past the corner -- skips it, and
-- so does a difference of a whole screen or more. So the surviving box is
--
-- x in [player - 4 .. player + 5] (10 cells, the player left of centre)
-- y in [player - 4 .. player + 4] (9 cells, the player centred)
--
-- which is the visible screen, not a radius: this is the same "is it on
-- screen" test the object engine uses, and the ITEMFINDER really does answer
-- for an item the player can see but has walked past.
function HiddenItems.onScreen(px, py, ex, ey)
local dx = (px + HALF_SCREEN_X) - ex
local dy = (py + HALF_SCREEN_Y) - ey
if dx < 0 or dx >= SCREEN_CELLS_X then return false end
if dy < 0 or dy >= SCREEN_CELLS_Y then return false end
return true
end
-- The whole of CheckForHiddenItems: the first unfound hidden item on screen, or
-- nil. The cart returns a bare carry; the row itself is returned here because
-- nothing else needs the coordinates and a caller that wants the boolean can
-- test for nil.
function HiddenItems.nearby(mapDef, px, py, events)
if not (mapDef and px and py) then return nil end
for _, row in ipairs(HiddenItems.unfound(mapDef, events)) do
if HiddenItems.onScreen(px, py, row.x, row.y) then return row end
end
return nil
end
-- The hidden item at a cell, if the player is facing one that is still unfound.
-- `.itemifset` checks the flag FIRST and jumps to `.dontread` when it is set,
-- which is why an already-taken hidden item does not eat the A press: the
-- press falls through to TryTileCollisionEvent exactly as if the bg event were
-- not there at all.
function HiddenItems.at(mapDef, cx, cy, events)
for _, ev in ipairs((mapDef and mapDef.bgEvents) or {}) do
if ev.x == cx and ev.y == cy then
local data = HiddenItems.dataOf(ev)
if data and not (events and events:get(data.event)) then
return { x = ev.x, y = ev.y, item = data.item, event = data.event }
end
return nil
end
end
return nil
end
-- HiddenItemScript (engine/events/hidden_item.asm), command for command:
--
-- opentext / readmem wHiddenItemID / getitemname STRING_BUFFER_3,
-- USE_SCRIPT_VAR / writetext .PlayerFoundItemText / giveitem ITEM_FROM_MEM /
-- iffalse .bag_full / callasm SetMemEvent / specialsound / itemnotify /
-- sjump .finish
--
-- The wHiddenItemData copy `.itemifset` makes before it calls the script is
-- what the readmem and the ITEM_FROM_MEM both read; here the item is baked into
-- the list instead, because the list is built per pickup. `callasm
-- SetMemEvent` is the flag write, and it lands only on the arm where the item
-- was really taken -- a full pack leaves the item where it is, findable again.
--
-- `rawtext` is the port's own command: `writetext` names a key into text.lua
-- and these two lines were never extracted (see the note on the strings above).
function HiddenItems.pickupScript(item, event)
local bagFull = {
{ op = "promptbutton" },
{ op = "rawtext", text = TEXT_BUT_NO_SPACE },
{ op = "waitbutton" },
{ op = "closetext" },
{ op = "end" },
}
return {
{ op = "opentext" },
{ op = "getitemname", item = item },
{ op = "rawtext", text = TEXT_PLAYER_FOUND },
{ op = "giveitem", item = item, quantity = 1 },
{ op = "iffalse", script = bagFull },
{ op = "setevent", event = event },
{ op = "specialsound" },
{ op = "itemnotify" },
{ op = "closetext" },
{ op = "end" },
}
end
-- FindItemInBallScript (engine/events/misc_scripts.asm:9), command for command:
--
-- callasm .TryReceiveItem / iffalse .no_room / disappear LAST_TALKED /
-- opentext / writetext .FoundItemText / playsound SFX_ITEM / pause 60 /
-- itemnotify / closetext / end
--
-- .no_room: opentext / writetext .FoundItemText / waitbutton /
-- writetext .CantCarryItemText / waitbutton / closetext / end
--
-- The A-press arm for an OBJECTTYPE_ITEMBALL object. The pointer under such an
-- object is two raw bytes -- item, quantity -- so there is no bytecode for the
-- VM to start; the extractor read the pair into `def.itemball` and this list is
-- the script the cart would have run.
--
-- It is NOT HiddenItemScript with a `disappear` swapped in for the flag write,
-- which is how it read before. `.TryReceiveItem` (misc_scripts.asm:38) does
-- BOTH the GetItemName into wStringBuffer3 and the ReceiveItem, in one callasm,
-- before a single box is drawn -- so the getitemname and the give both come
-- first here, the full-pocket branch is taken with nothing yet on screen, and
-- its arm prints the found line, waits, and only then prints the "can't carry
-- any more" line. Three further things the hidden-item shape got wrong: the
-- sound is an unconditional `playsound SFX_ITEM`, not `specialsound` (which
-- would ring SFX_GET_TM for a TM, scripting.asm:476); `disappear` lands BEFORE
-- the text, not after the give; and the success arm holds on `pause 60` under
-- the found line before itemnotify, which is the freeze a pickup is supposed to
-- have and which was missing entirely.
--
-- `disappear` is what stands in for the hidden item's flag write:
-- World:disappearObject sets the object's own event flag
-- (EVENT_GOT_HM07_WATERFALL on the Ice Path ball is the one a run cannot do
-- without), which is what keeps the ball gone across a reload and what a route
-- row's `expect` reads. The `.no_room` arm never reaches it, so a full pocket
-- leaves the ball findable.
--
-- The pause operand is the cart's literal 60, the same reading every other
-- transcription in the port uses (CmdQueue's `pause 30`, the fishing `pause 40`
-- in World). Script_pause loops `ld c, 2 / call DelayFrames` per unit, so the
-- hardware holds twice the operand; that factor lives on Vm:pauseFrames, where
-- it fixes every pause at once, rather than being pre-multiplied here.
function HiddenItems.ballPickupScript(item, quantity, objectId, sfxId)
local noRoom = {
{ op = "opentext" },
{ op = "rawtext", text = TEXT_FOUND_ITEM },
{ op = "waitbutton" },
{ op = "rawtext", text = TEXT_CANT_CARRY },
{ op = "waitbutton" },
{ op = "closetext" },
{ op = "end" },
}
return {
{ op = "getitemname", item = item },
{ op = "giveitem", item = item, quantity = quantity or 1 },
{ op = "iffalse", script = noRoom },
{ op = "disappear", object = objectId },
{ op = "opentext" },
-- `playsound` leads the text rather than trailing it, and the `pause 60`
-- rides the text row as `hold`, because of the one thing this port's box
-- does that a MapTextbox does not: it takes its own button and pops on it.
-- The cart prints the found line and the itemnotify line into the SAME box
-- with `playsound SFX_ITEM / pause 60` between them and nothing that takes
-- a box down (misc_scripts.asm:13-17). Written straight, the port popped
-- the found box on the press, spent the pause with an EMPTY state stack --
-- 120 frames of bare overworld inside a single cart textbox, with Game2's
-- play clock (only paused while a state is on the stack) counting every one
-- of them -- and then built a second box. `stay` + `hold` keeps the one
-- box up for the jingle and the pause; World:showText hands it straight
-- over to the itemnotify page in the frame the hold drains.
{ op = "playsound",
id = sfxId and sfxId(SFX_ITEM[1], SFX_ITEM[2]) or SFX_ITEM[2] },
{ op = "rawtext", text = TEXT_FOUND_ITEM, stay = true, hold = 60 },
{ op = "itemnotify" },
{ op = "closetext" },
{ op = "end" },
}
end
-- ItemFinder's two queued scripts (engine/items/itemfinder.asm).
--
-- `sfxId(label, fallback)` resolves a pokegold sfx label against this cache's
-- own table. .ItemfinderSound is `ld c, 4` around WaitPlaySFX
-- SFX_SECOND_PART_OF_ITEMFINDER then WaitPlaySFX SFX_TRANSACTION, and
-- WaitPlaySFX waits BEFORE it plays, so the wait leads each of the eight
-- sounds rather than trailing it -- the last one is deliberately left ringing
-- under the text box.
--
-- The cart's `refreshmap` and `special UpdateTimePals` are dropped: both repair
-- the tilemap and the palettes the PACK overwrote, and the port draws the PACK
-- as a state over an untouched world. Running the port's `refreshmap` here
-- would be a real map reload, which is a much bigger thing than the cart is
-- doing.
function HiddenItems.itemfinderScript(found, sfxId)
local script = {}
if found then
for _ = 1, 4 do
for _, sfx in ipairs({ SFX_SECOND_PART_OF_ITEMFINDER, SFX_TRANSACTION }) do
script[#script + 1] = { op = "waitsfx" }
script[#script + 1] = {
op = "playsound",
id = sfxId and sfxId(sfx[1], sfx[2]) or sfx[2],
}
end
end
end
script[#script + 1] = { op = "opentext" }
script[#script + 1] = {
op = "rawtext",
text = found and TEXT_ITEMFINDER_NEARBY or TEXT_ITEMFINDER_NOPE,
}
script[#script + 1] = { op = "waitbutton" }
script[#script + 1] = { op = "closetext" }
script[#script + 1] = { op = "end" }
return script
end
return HiddenItems
+304
View File
@@ -0,0 +1,304 @@
-- Gen 2 runtime map: block grid + COLL_* quads (not Gen 1 walkable lists).
-- Coordinates are unpadded cells (extract stores width×height blocks as-is;
-- WRAM's 3-block border is not mirrored here).
local Permissions = require("src.world.gen2.Permissions")
local Map = {}
Map.__index = Map
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
Map.DELTA = DELTA
function Map.new(def, tileset)
local self = setmetatable({}, Map)
self.def = def
self.id = def.id
self.tileset = tileset
self.width = def.width
self.height = def.height
self.widthCells = def.width * 2
self.heightCells = def.height * 2
self.blocks = def.blocks
self.borderBlock = def.borderBlock or 0
self.collision = tileset.collision
self.warps = def.warps or {}
self.connections = def.connections or {}
-- A Gen 1 mod reads conn.map; a Gold extraction may only carry conn.mapId,
-- which is why World.computeNeighbors:514 reads both. Normalise here so
-- one read answers on either cache.
for _, conn in pairs(self.connections) do
if type(conn) == "table" and conn.map == nil then conn.map = conn.mapId end
end
-- Warp lookup by cell.
self._warpAt = {}
for i, w in ipairs(self.warps) do
self._warpAt[w.y * 1024 + w.x] = { index = i, def = w }
end
return self
end
function Map:inBounds(cx, cy)
return cx >= 0 and cy >= 0
and cx < self.widthCells and cy < self.heightCells
end
function Map:blockId(bx, by)
if bx < 0 or by < 0 or bx >= self.width or by >= self.height then
return self.borderBlock
end
return self.blocks[by * self.width + bx + 1] or 0
end
-- COLL_* byte for cell (cx, cy). Block id 0 is impassable sentinel
-- (GetCoordTileCollision .nope → $ff).
function Map:cellCollision(cx, cy)
local bx, by = math.floor(cx / 2), math.floor(cy / 2)
local id = self:blockId(bx, by)
if id == 0 then return 0xff end
local quad = self.collision and self.collision[id + 1]
if not quad then return 0xff end
local lx, ly = cx % 2, cy % 2
return quad[ly * 2 + lx + 1] or 0xff
end
function Map:isWalkable(cx, cy)
if not self:inBounds(cx, cy) then return false end
return Permissions.isWalkable(self:cellCollision(cx, cy))
end
-- ------- the shared cell vocabulary a mod binds to
--
-- Same four names src/world/Map.lua answers, so one mod's placement and
-- behaviour code reads either generation's map (mod.world hands this object
-- out, and a mod that guards on `map.isWalkableCell and ...` otherwise
-- silently concludes every cell is walkable, dry and grassless). Gen 1
-- answers from tile ids and a per-tileset set; Gold answers from the COLL_*
-- byte, which is the same question asked of a different grid.
-- The Gen 1 name for "the byte that decides this cell". Gold's is the
-- collision quad entry, not a tile id, so the ids are NOT comparable across
-- generations: use the predicates, not the number.
local warnedCellTile = false
function Map:cellTile(cx, cy)
-- Loud once, because the call SUCCEEDS and the number is plausible: a mod
-- comparing it to 0x52 (Gen 1 grass) or 0x14 (water) gets a wrong answer
-- with nothing to show for it. No Gen 2 caller reaches this name.
if not warnedCellTile then
warnedCellTile = true
require("src.core.Logger").warn(
"Map:cellTile on Gold returns a COLL_* byte, not a Gen 1 tile id; the "
.. "two number spaces are unrelated -- use the cell predicates")
end
return self:cellCollision(cx, cy)
end
function Map:isWalkableCell(cx, cy)
return self:isWalkable(cx, cy)
end
function Map:isWaterCell(cx, cy)
if not self:inBounds(cx, cy) then return false end
return Permissions.isWater(self:cellCollision(cx, cy))
end
-- Off-map cells never count as grass, for the reason Gen 1 guards the same
-- way (src/world/Map.lua:224): the border block is filler scenery.
function Map:isGrassCell(cx, cy)
if not self:inBounds(cx, cy) then return false end
return Permissions.isGrass(self:cellCollision(cx, cy))
end
function Map:warpAt(cx, cy)
return self._warpAt[cy * 1024 + cx]
end
-- Three more Gen 1 spellings, for the reason the four above exist: a mod
-- guarding on `map.isCounterCell and ...` otherwise sees no counter anywhere.
function Map:warpAtCell(cx, cy)
return self:warpAt(cx, cy)
end
function Map:isCounterCell(cx, cy)
if not self:inBounds(cx, cy) then return false end
return Permissions.isCounter(self:cellCollision(cx, cy))
end
-- Gen 1 asks this of a def plus the outdoor tileset set; Gold's header says
-- so outright, so the second argument is ignored.
function Map.isOutside(def, _tilesets)
local env = def and def.environment
return env == "TOWN" or env == "ROUTE"
end
-- Gen 1 keeps isOutdoor NARROWER than isOutside (src/world/Map.lua:148, :155);
-- Gold decides both from the header's environment byte, so they collapse.
function Map.isOutdoor(def)
if def and def.outdoor ~= nil then return def.outdoor end
return Map.isOutside(def)
end
-- src/world/Map.lua:176 verbatim: def.region, else the id prefix. Gold's
-- defs carry no region, so the prefix arm is the live one.
function Map.inRegion(def, region, prefix)
if not def then return false end
if def.region ~= nil then return def.region == region end
return prefix ~= nil and def.id ~= nil and def.id:find(prefix, 1, true) == 1
end
-- Gen 1 asks the SPRITE NAME (src/world/Map.lua:191); on Gold a boulder is
-- the STRENGTH_BOULDER movement byte (src/world/gen2/Npc.lua:56), so a
-- sprite-name test here would answer false for every real boulder.
function Map.isPushable(objDef)
if not objDef then return false end
if objDef.pushable ~= nil then return objDef.pushable end
return objDef.movement == 0x19
end
-- ------- the Gen 1 spellings that read a raw def
--
-- Gen 1 answers these from tile ids and the tileset's walkable list; Gold
-- answers from the COLL_* quad, which lives on the TILESET either way, so an
-- unloaded neighbour (a connection crossing) can be asked without a Map.
-- The cell's COLL_* byte off a raw def. NOT a tile id: the number space is
-- unrelated to Gen 1's, so compare with the predicates below, never a literal.
function Map.defCellTile(def, tilesetDef, cx, cy)
if not (def and tilesetDef and tilesetDef.collision and def.blocks) then
return nil
end
local bx, by = math.floor(cx / 2), math.floor(cy / 2)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock or 0
else
id = def.blocks[by * def.width + bx + 1] or 0
end
if id == 0 then return 0xff end
local quad = tilesetDef.collision[id + 1]
if not quad then return 0xff end
return quad[(cy % 2) * 2 + (cx % 2) + 1] or 0xff
end
function Map.defIsWalkableCell(def, tilesetDef, cx, cy)
local coll = Map.defCellTile(def, tilesetDef, cx, cy)
if coll == nil then return false end
return Permissions.isWalkable(coll)
end
function Map.defIsWaterCell(def, tilesetDef, cx, cy)
local coll = Map.defCellTile(def, tilesetDef, cx, cy)
if coll == nil then return false end
return Permissions.isWater(coll)
end
-- Fails CLOSED on missing data, for the reason src/world/Map.lua:104 does:
-- no data means we cannot prove the landing is safe, so the step bumps.
function Map.defPassable(def, tilesetDef, cx, cy, surfing)
if not (def and tilesetDef and tilesetDef.collision and def.blocks) then
return false
end
if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end
if surfing then
local coll = Map.defCellTile(def, tilesetDef, cx, cy)
return coll ~= nil and Permissions.surfable(coll) ~= nil
end
return false
end
-- ------- the Gen 1 instance spellings a mod calls on world.map
-- Gen 1's name for blockId, same border extension (src/world/Map.lua:196).
function Map:blockAt(bx, by)
return self:blockId(bx, by)
end
-- Writes the block grid in place and nothing else, exactly as
-- src/world/Map.lua:247 does. The VISIBLE edit is World:changeBlock, which
-- also records the blockEdits undo a map reload restores from -- a bare write
-- here leaves a Cut tree gone forever.
function Map:setBlock(bx, by, block)
if bx < 0 or by < 0 or bx >= self.width or by >= self.height then return end
self.blocks[by * self.width + bx + 1] = block
end
-- Graphics tile id on the 8px grid, border-extended: the same math
-- World:bgTileAt (src/world/gen2/World.lua:7728) does in pixels. Gen 2
-- blocks are 4x4 tiles too, so src/world/Map.lua:204's index math ports
-- unchanged. Border cells read the border block, not BorderFill.blockFor.
function Map:tileAt(tx, ty)
local blocks = self.tileset and self.tileset.blocks
if not blocks then return nil end
local id = self:blockId(math.floor(tx / 4), math.floor(ty / 4))
local block = blocks[id + 1]
if not block then return nil end
return block[(ty % 4) * 4 + (tx % 4) + 1]
end
-- Gold has no door TILE set: a door is a warp collision kind. This is the
-- narrow arm (a door walked INTO), so a floor mat does not answer true.
function Map:isDoorTileCell(cx, cy)
if not self:inBounds(cx, cy) then return false end
return Permissions.isImmediateWarp(self:cellCollision(cx, cy))
end
-- Gen 1 spells this as two sets (doors OR warp-activating tiles); Gold's
-- warp collision kinds are that union already.
function Map:isWarpTileCell(cx, cy)
if not self:inBounds(cx, cy) then return false end
return Permissions.isWarpCollision(self:cellCollision(cx, cy))
end
-- Coordinate-only, so it cannot apply the facing and event filters
-- World:bgEventAt (src/world/gen2/World.lua:7084) applies: it reports bg
-- events the engine would not read. The record is a bgEvent, not a Gen 1
-- sign -- sign.text is nil on Gold.
function Map:signAtCell(cx, cy)
for _, ev in ipairs(self.def and self.def.bgEvents or {}) do
if ev.x == cx and ev.y == cy then return ev end
end
return nil
end
-- GetMovementPermissions (home/map.asm): may a step `dir` LEAVE (cx, cy)?
--
-- Two refusals, both invisible to a plain walkable test. The STANDING tile's
-- side-wall kind blocks its own directions (on an UP_WALL you cannot move up).
-- And Gold's four neighbour arms each set the FACE_DOWN bit when the adjacent
-- tile is their wall kind, so a matching neighbour -- on real maps, an UP_WALL
-- below -- forbids the DOWN step. This is what ends an Ice Path slide on the
-- last ice cell above the $b2 strip instead of gliding onto it, and that rest
-- chain is the only route to HM07 WATERFALL. See Permissions.sideBlocks /
-- neighborBlocksDown for the cart derivation.
function Map:stepPermitted(cx, cy, dir)
return Permissions.stepPermitted(
function(x, y) return self:cellCollision(x, y) end, cx, cy, dir)
end
function Map:connection(dir)
return self.connections[dir]
end
-- Destination cell after stepping off this edge onto a connected map.
-- Same strip math as Gen 1 (offset is in blocks). Returns nil if no conn.
function Map.connectionLanding(def, conn, dir, fromCx, fromCy)
if not (def and conn) then return nil end
local destW, destH = def.width * 2, def.height * 2
local offset = conn.offset or 0
local x, y
if dir == "up" then
x, y = fromCx - offset * 2, destH - 1
elseif dir == "down" then
x, y = fromCx - offset * 2, 0
elseif dir == "left" then
x, y = destW - 1, fromCy - offset * 2
else
x, y = 0, fromCy - offset * 2
end
x = math.max(0, math.min(destW - 1, x))
y = math.max(0, math.min(destH - 1, y))
return x, y
end
return Map
+740
View File
@@ -0,0 +1,740 @@
-- Gen 2 map object: walks / spins from SPRITEMOVEDATA_* + object_event
-- radius (not Gen 1's WALK/STAY + range strings). Drawn via SpriteRenderer.
local Logger = require("src.core.Logger")
local Map = require("src.world.gen2.Map")
local Movement = require("src.script.gen2.Movement")
local Permissions = require("src.world.gen2.Permissions")
local Runtime = require("src.mods.Runtime")
local SpriteRenderer = require("src.render.SpriteRenderer")
local NPC = {}
NPC.__index = NPC
local STEP_FRAMES = 16
-- OBJECT_ACTION_SPIN's own cadence: the spin frameset turns the sprite a
-- quarter every four frames, which is what makes a teleporting object read as
-- spinning rather than as facing one way while it rises.
local SPIN_FACINGS = { "down", "left", "up", "right" }
local SPIN_FRAMES_PER_FACING = 4
-- constants/map_object_constants.asm
local MOVE = {
STILL = 1,
WANDER = 2,
SPINRANDOM_SLOW = 3,
WALK_UP_DOWN = 4,
WALK_LEFT_RIGHT = 5,
STANDING_DOWN = 6,
STANDING_UP = 7,
STANDING_LEFT = 8,
STANDING_RIGHT = 9,
SPINRANDOM_FAST = 10,
SPINCOUNTERCLOCKWISE = 0x1e,
SPINCLOCKWISE = 0x1f,
-- The three rows whose palette-flags byte is `STRENGTH_BOULDER | BIG_OBJECT`
-- (data/sprites/map_objects.asm). BIG_OBJECT is the bit IsNPCAtCoord tests
-- before handing the coordinate to WillObjectIntersectBigObject -- so the
-- object is TWO cells wide and two tall for collision and for an A press
-- alike. Gold puts exactly two of them on maps: the sleeping Snorlax
-- outside Vermilion ($15) and PLAYERS_HOUSE_2F's big doll decoration ($21).
BIGDOLLSYM = 0x15,
BIGDOLLASYM = 0x20,
BIGDOLL = 0x21,
SWIM_WANDER = 0x24,
}
local BIG_OBJECT = {
[MOVE.BIGDOLLSYM] = true,
[MOVE.BIGDOLLASYM] = true,
[MOVE.BIGDOLL] = true,
}
-- Every SPRITEMOVEDATA row whose flags1 byte carries FIXED_FACING
-- (data/sprites/map_objects.asm): STILL $01, BIGDOLLSYM $15, POKEMON $16,
-- SUDOWOODO $17, SMASHABLE_ROCK $18, STRENGTH_BOULDER $19, SHADOW $1b,
-- EMOTE $1c, BIGDOLLASYM $20, BIGDOLL $21, BOULDERDUST $22, GRASS $23.
-- CopySpriteMovementData rewrites OBJECT_FLAGS1 out of the row on every
-- spawn, so this is re-seeded in NPC.new rather than latched: a scripted
-- `fix_facing` correctly dies at the next rebuildPeople, exactly as a
-- respawned object loses it on the cart.
local FIXED_FACING_MOVE = {
[0x01] = true, [0x15] = true, [0x16] = true, [0x17] = true,
[0x18] = true, [0x19] = true, [0x1b] = true, [0x1c] = true,
[0x20] = true, [0x21] = true, [0x22] = true, [0x23] = true,
}
-- SetFacingBigDoll (engine/overworld/map_object_action.asm): $15 always draws
-- through FacingBigDollSymmetric and $20 always through
-- FacingBigDollAsymmetric, but $21 reads wVariableSprites[SPRITE_BIG_DOLL] and
-- takes the symmetric table only for SPRITE_BIG_SNORLAX and SPRITE_BIG_LAPRAS.
-- SPRITE_BIG_ONIX is the doll a mirrored left half would draw wrong.
local BIG_DOLL_SYM_SPRITES = {
SPRITE_BIG_SNORLAX = true,
SPRITE_BIG_LAPRAS = true,
}
function NPC.bigFacing(movement, spriteId)
if movement == MOVE.BIGDOLLSYM then return "sym" end
if movement == MOVE.BIGDOLLASYM then return "asym" end
if movement ~= MOVE.BIGDOLL then return nil end
return BIG_DOLL_SYM_SPRITES[spriteId] and "sym" or "asym"
end
local FACING_FROM_MOVE = {
[MOVE.STILL] = "down",
[MOVE.WANDER] = "down",
[MOVE.SPINRANDOM_SLOW] = "down",
[MOVE.WALK_UP_DOWN] = "down",
[MOVE.WALK_LEFT_RIGHT] = "left",
[MOVE.STANDING_DOWN] = "down",
[MOVE.STANDING_UP] = "up",
[MOVE.STANDING_LEFT] = "left",
[MOVE.STANDING_RIGHT] = "right",
[MOVE.SPINRANDOM_FAST] = "down",
[MOVE.SWIM_WANDER] = "down",
-- The two spin rows are the only ones in the table that do NOT start facing
-- down: `db LEFT ; facing` and `db RIGHT ; facing` (data/sprites/map_objects
-- .asm:245-256). They are the first quarter of their own cycle.
[MOVE.SPINCOUNTERCLOCKWISE] = "left",
[MOVE.SPINCLOCKWISE] = "right",
}
local DIRS_Y = { "up", "down" }
local DIRS_X = { "left", "right" }
local DIRS_ANY = { "up", "down", "left", "right" }
-- _MovementSpinTurnRight / _MovementSpinTurnLeft's two facing tables
-- (engine/overworld/map_objects.asm:826-843), read as "from this facing, next
-- this one". The cart indexes them by OBJECT_DIRECTION >> 2, i.e. the OW_DOWN
-- / OW_UP / OW_LEFT / OW_RIGHT order; this is the same four rows by name.
--
-- Unlike the two RANDOM spins these are DETERMINISTIC quarter turns -- which is
-- the whole point of them, because a spinner whose facing is the puzzle (the
-- Team Rocket base's guard patterns) has to be predictable.
local SPIN_NEXT = {
clockwise = { down = "left", up = "right", left = "up", right = "down" },
counterclockwise = { down = "right", up = "left", left = "down", right = "up" },
}
-- `ld a, $10 / ld [OBJECT_STEP_DURATION]` then STEP_TYPE_SLEEP
-- (_MovementSpinRepeat, map_objects.asm:809-823): a fixed sixteen frames on
-- each quarter, no Random anywhere in the loop.
local SPIN_TURN_FRAMES = 16
local function rand(a, b)
if love and love.math and love.math.random then
return love.math.random(a, b)
end
return math.random(a, b)
end
local function randf()
if love and love.math and love.math.random then
return love.math.random()
end
return math.random()
end
local function patternFor(movement)
if movement == MOVE.WALK_UP_DOWN then
return "walk", DIRS_Y
elseif movement == MOVE.WALK_LEFT_RIGHT then
return "walk", DIRS_X
elseif movement == MOVE.WANDER or movement == MOVE.SWIM_WANDER then
return "walk", DIRS_ANY
elseif movement == MOVE.SPINRANDOM_SLOW then
return "spin", DIRS_ANY, 60, 180
elseif movement == MOVE.SPINRANDOM_FAST then
return "spin", DIRS_ANY, 20, 60
elseif movement == MOVE.SPINCLOCKWISE then
return "turn", SPIN_NEXT.clockwise, SPIN_TURN_FRAMES, SPIN_TURN_FRAMES
elseif movement == MOVE.SPINCOUNTERCLOCKWISE then
return "turn", SPIN_NEXT.counterclockwise,
SPIN_TURN_FRAMES, SPIN_TURN_FRAMES
end
return "stand", nil
end
-- Gen 1's two behaviour strings onto the cart's SPRITEMOVEDATA byte. STAY is
-- STANDING_*, never STILL: STILL carries FIXED_FACING (above) and an object
-- that cannot turn is not a trailer. WALK gets a radius default because Gen 2
-- refuses every step outside it and radius 0 would freeze the object.
local GEN1_STAY = {
UP = MOVE.STANDING_UP, DOWN = MOVE.STANDING_DOWN,
LEFT = MOVE.STANDING_LEFT, RIGHT = MOVE.STANDING_RIGHT,
}
-- The sheet a Gen 1 NPC.new falls back to when the SPRITE_* id it names is
-- not in Gold's table. src/mods/Gen2Compat.lua points this at the live
-- player's; a mod overwrites npc.sprite the line after the call, so a missing
-- record must not decide whether the entity exists.
NPC.fallbackSpriteDef = nil
local warnedGen1Sprite = {}
-- src/world/NPC.lua:23's shape: (data, mapId, objDef). Sniffed rather than
-- given its own name so `getmetatable(npc) == require("src.world.NPC")` holds
-- for a gen2compat mod -- the facade IS this table (src/mods/Gen2Compat.lua),
-- and a constructor that lived beside it would hand back objects carrying a
-- different metatable than the module the mod holds.
local function fromGen1(data, mapId, objDef)
local movement, range = objDef.movement, objDef.range
local mv, radius
if type(movement) == "number" then
mv, radius = movement, objDef.radius
elseif movement == "WALK" then
if range == "UP_DOWN" then mv = MOVE.WALK_UP_DOWN
elseif range == "LEFT_RIGHT" then mv = MOVE.WALK_LEFT_RIGHT
else mv = MOVE.WANDER end
radius = objDef.radius or { x = 3, y = 3 }
else
mv = GEN1_STAY[range] or MOVE.STANDING_DOWN
end
local sprites = data and (rawget(data, "gen2Sprites") or data.sprites)
local def = sprites and objDef.sprite and sprites[objDef.sprite]
if not def then
local key = tostring(objDef.sprite)
if not warnedGen1Sprite[key] then
warnedGen1Sprite[key] = true
Logger.warn("src.world.NPC: no %s in Gold's sprite table; using the "
.. "player sheet", key)
end
local fallback = NPC.fallbackSpriteDef
def = type(fallback) == "function" and fallback() or fallback
end
if not def then
error("src.world.NPC: no sprite record for " .. tostring(objDef.sprite), 0)
end
local npc = NPC.new(mapId, {
index = objDef.index, name = objDef.name, sprite = objDef.sprite,
movement = mv, radius = radius, x = objDef.x, y = objDef.y,
}, def)
-- the Gen 1 SPRITE_* id the caller passed in, which Gold's objDef does not
-- keep once the sheet is resolved
npc.spriteId = objDef.sprite
return npc
end
function NPC.new(mapId, objDef, spriteDef)
-- Gen 1 passes the DATA table first; Gold's first argument is always the
-- map id string.
if type(mapId) == "table" then return fromGen1(mapId, objDef, spriteDef) end
local movement = objDef.movement or MOVE.STILL
local kind, dirs, spinLo, spinHi = patternFor(movement)
local radius = objDef.radius or {}
local self = setmetatable({
def = objDef,
id = string.format("%s_obj_%d", mapId, objDef.index or 0),
mapId = mapId,
cellX = objDef.x,
cellY = objDef.y,
homeX = objDef.x,
homeY = objDef.y,
px = objDef.x * 16,
py = objDef.y * 16,
facing = FACING_FROM_MOVE[movement] or "down",
moving = false,
progress = 0,
stepFlip = false,
-- OBJECT_FLAGS2's IN_GRASS_F (engine/overworld/map_objects.asm:247), set
-- from the spawn tile by the STEP_TYPE_RESET latch in NPC:update.
inGrass = false,
spawnLatched = false,
frozen = false,
kind = kind,
roamDirs = dirs,
radiusX = radius.x or 0,
radiusY = radius.y or 0,
spinLo = spinLo,
spinHi = spinHi,
bigObject = BIG_OBJECT[movement] == true,
bigFacing = NPC.bigFacing(movement, spriteDef and spriteDef.id),
fixedFacing = FIXED_FACING_MOVE[movement] or nil,
timer = rand(30, 120),
sprite = SpriteRenderer.new(spriteDef, string.format("%s_obj_%d", mapId, objDef.index or 0)),
-- The sheet is grayscale and carries no alpha; PAL_OW_* crossed with the
-- time of day decides the real colors AND which pixels are transparent.
-- World:applyPalettes pushes them into the SpriteRenderer and refreshes
-- them when the clock rolls over, so a pooled NPC never keeps yesterday's.
spriteDef = spriteDef,
}, NPC)
return self
end
-- `variablesprite` on a slot this object reads through: the sheet changes and
-- NOTHING else does. Script_variablesprite writes one byte of wVariableSprites
-- (engine/overworld/scripting.asm:869) and the `special LoadUsedSpritesGFX`
-- beside it reloads the tiles -- the object STRUCT is never touched, so its
-- coordinates, its facing, its FROZEN_F and its identity as wLastTalked all
-- survive. Two map scripts depend on that: LassAliceScript's
-- `applymovement ... Movement_NinjaSpin / faceplayer / variablesprite / special
-- LoadUsedSpritesGFX / faceplayer` (maps/FuchsiaGym.asm:61-66, and the same
-- shape for Linda, Cindy and Barry) and CopycatsHouse2F.asm:23-48.
--
-- So this repaints in place rather than the World retiring the NPC and letting
-- rebuildPeople make a new one: a new table would strand World.talkNpc,
-- .trainerNpc, .followState and any running moveState on an object that is no
-- longer on the map, and drop the ninja back to her map-def cell and default
-- facing halfway through unmasking.
function NPC:setSpriteDef(spriteDef)
if not spriteDef or spriteDef == self.spriteDef then return false end
self.spriteDef = spriteDef
self.sprite = SpriteRenderer.new(spriteDef, self.id)
-- bigFacing is derived from the SHEET (NPC.bigFacing keys off spriteDef.id),
-- so it is the one cached field that has to be recomputed with it.
self.bigFacing = NPC.bigFacing(self.def and self.def.movement, spriteDef.id)
return true
end
function NPC:inRadius(tx, ty)
return math.abs(tx - self.homeX) <= self.radiusX
and math.abs(ty - self.homeY) <= self.radiusY
end
-- WillObjectIntersectBigObject (engine/overworld/npc_movement.asm): the object's
-- own coordinates are the TOP LEFT of the blob, and a cell belongs to it when
-- both `coord - object` land in 0..1 (`sub [hl] / jr c, .nope / cp 2 / jr nc`).
-- Every other object is the one cell it stands on, which is what the fall
-- through to a plain compare says.
function NPC:covers(cx, cy)
if not self.bigObject then
return self.cellX == cx and self.cellY == cy
end
local dx, dy = cx - self.cellX, cy - self.cellY
return dx >= 0 and dx < 2 and dy >= 0 and dy < 2
end
function NPC:facePlayer(player)
-- ApplyObjectFacing (engine/overworld/scripting.asm:856) refuses a
-- fixed-facing object outright, and _DoesSpriteHaveFacings
-- (engine/overworld/overworld.asm:343) returns carry for a STILL_SPRITE,
-- whose sheet has only the one pose to turn to.
if self.fixedFacing then return end
if self.spriteDef and (self.spriteDef.frames or 0) <= 1 then return end
local dx = player.cellX - self.cellX
local dy = player.cellY - self.cellY
if math.abs(dx) > math.abs(dy) then
self.facing = dx > 0 and "right" or "left"
else
self.facing = dy > 0 and "down" or "up"
end
end
function NPC:scriptFace(dir)
if self.fixedFacing then return end
if dir then self.facing = dir end
end
-- The direction the object MOVES in and the direction it is DRAWN facing are
-- two different bytes. InitStep skips the OBJECT_DIRECTION write while
-- FIXED_FACING_F is set (engine/overworld/map_objects.asm:284-294) and
-- SetFacingStepAction bails to SetFacingCurrent while SLIDING_F is set
-- (engine/overworld/map_object_action.asm:48), so either flag walks the object
-- across the map without turning it.
function NPC:scriptStep(dir)
if self.moving then return false end
self.stepDir = dir or self.facing
if not self.fixedFacing and not self.sliding then
self.facing = self.stepDir
end
local d = Map.DELTA[self.stepDir]
if not d then
self.stepDir = nil
return false
end
self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2]
self.moving = true
self.progress = 0
self.frozen = true
return true
end
-- StepFunction_TeleportFrom / _TeleportTo (engine/overworld/map_objects.asm).
-- `from` is sixteen frames of OBJECT_ACTION_SPIN on the spot and then sixteen
-- more spinning while OBJECT_JUMP_HEIGHT walks OBJECT_SPRITE_Y_OFFSET up a
-- sine, so the object lifts off its tile before `disappear` takes it away.
-- `to` is the same three beats in reverse: a still wait, a spinning descent
-- from the same curve, and one last spin once it has landed.
--
-- The step type owns the object until it is done, which is why this sets
-- `frozen`: World:beginMovement's own sleep counter is what waits it out.
function NPC:scriptTeleport(mode, frames)
local beat = Movement.TELEPORT_BEAT_FRAMES
self.teleport = {
mode = mode == "to" and "to" or "from",
frame = 0,
frames = frames or ((mode == "to") and 3 * beat or 2 * beat),
}
self.frozen = true
self.spriteYOffset = 0
return true
end
-- One frame of that step type. Returns false once the last beat is over, so
-- the caller can drop the state.
function NPC:updateTeleport()
local st = self.teleport
if not st then return false end
local beat = Movement.TELEPORT_BEAT_FRAMES
st.frame = st.frame + 1
local spinning = true
if st.mode == "from" then
if st.frame <= beat then
-- .DoSpin: the object is still on its tile for the first beat.
self.spriteYOffset = 0
else
-- .DoSpinRise: OBJECT_JUMP_HEIGHT starts at $10 and is incremented once
-- a frame, so the sine walks the sprite off the top of the tile.
self.spriteYOffset = Movement.teleportYOffset(
Movement.TELEPORT_RISE_HEIGHT + (st.frame - beat))
end
elseif st.frame <= beat then
-- .DoWait holds OBJECT_ACTION_00, so nothing spins: the object simply
-- waits at the far end of the descent curve.
spinning = false
self.spriteYOffset = Movement.teleportYOffset(
Movement.TELEPORT_FALL_HEIGHT)
elseif st.frame <= 2 * beat then
-- .DoDescent, the rise's curve read backwards down to the tile.
self.spriteYOffset = Movement.teleportYOffset(st.frame - beat)
else
-- .DoFinalSpin, back on the ground.
self.spriteYOffset = 0
end
if spinning then
self.facing = SPIN_FACINGS[
(math.floor(st.frame / SPIN_FRAMES_PER_FACING) % #SPIN_FACINGS) + 1]
end
if st.frame >= st.frames then
self.teleport = nil
self.spriteYOffset = 0
return false
end
return true
end
-- Movement_tree_shake (engine/overworld/movement.asm:334): OBJECT_ACTION is
-- set to OBJECT_ACTION_WEIRD_TREE and the object is parked on
-- STEP_TYPE_SLEEP for 24 frames. Same lifetime as scriptTeleport above --
-- the step type owns the object, and World:beginMovement waits it out on the
-- sleep counter -- so it sets `frozen` the same way.
function NPC:scriptTreeShake(frames)
self.treeShake = {
frame = 0,
frames = frames or Movement.TREE_SHAKE_FRAMES,
}
self.frozen = true
return true
end
-- One frame of it. Returns false on the last beat so the caller can drop the
-- state, matching NPC:updateTeleport.
function NPC:updateTreeShake()
local st = self.treeShake
if not st then return false end
st.frame = st.frame + 1
if st.frame >= st.frames then
self.treeShake = nil
return false
end
return true
end
-- `passable` is the follower's escape (src/world/gen2/Follower.lua), the same
-- name and meaning src/world/Collision.lua:20 gives it under Gen 1.
local function occupied(entities, tx, ty, self)
if not entities then return false end
for _, e in ipairs(entities) do
if e ~= self and not e.passable then
if e.cellX == tx and e.cellY == ty then return true end
if e.moving and e.targetX == tx and e.targetY == ty then return true end
end
end
return false
end
-- the movement.collision chain's vanilla link and the verdict it wraps, both
-- hoisted so an empty chain allocates nothing (src/world/gen2/Player.lua and
-- src/world/Collision.lua are the same shape)
local function passthrough(allowed) return allowed end
local function wanderVerdict(self, map, entities, tx, ty)
if not self:inRadius(tx, ty) then return false, "radius" end
if not map:isWalkable(tx, ty) then return false, "tile" end
-- don't walk out through doors
if map:warpAt(tx, ty) then return false, "warp" end
if occupied(entities, tx, ty, self) then return false, "entity" end
return true
end
function NPC:walkPhase()
-- SetFacingStepAction bails to SetFacingCurrent BEFORE it increments
-- OBJECT_STEP_FRAME (engine/overworld/map_object_action.asm:48), so a
-- sliding object holds its step frame as well as its facing: it glides.
if self.sliding then return 0 end
if not self.moving then return 0 end
local frames = self.stepFrames or STEP_FRAMES
local p = self.progress % frames
return (p >= frames / 4 and p < frames * 3 / 4) and 1 or 0
end
-- Gen 1's seven-value entity pose (src/world/NPC.lua:124), on the class so a
-- mod poses the object it is FOLLOWING, not only one it built itself.
function NPC:pose()
return self.sprite, self.px, self.py + (self.spriteYOffset or 0),
self.facing, self:walkPhase(), self.stepFlip, false
end
-- SetTallGrassFlags' test (engine/overworld/map_objects.asm:247).
function NPC.grassAt(map, cx, cy)
if not (map and map.cellCollision and cx and cy) then return false end
local coll = map:cellCollision(cx, cy)
return Permissions.isSuperTallGrass(coll) or Permissions.isGrass(coll)
end
function NPC:update(map, entities)
-- STEP_TYPE_RESET's StepFunction_Reset reads the object's OWN tile into
-- SetTallGrassFlags (engine/overworld/map_objects.asm:498-511, :196-208).
if not self.spawnLatched and map then
self.spawnLatched = true
self.inGrass = NPC.grassAt(map, self.cellX, self.cellY)
end
-- The teleport step type owns the object outright (it replaces
-- STEP_TYPE_FROM_MOVEMENT until its last beat), so it runs above the frozen
-- gate the way the walk interpolation does.
if self.teleport then
self:updateTeleport()
return
end
-- STEP_TYPE_SLEEP with OBJECT_ACTION_WEIRD_TREE owns the object the same
-- way the teleport step type does, so it sits in the same position.
if self.treeShake then
self:updateTreeShake()
return
end
-- NPC_CHANGE_FACING (src/world/NPC.lua:71): one walk cycle in place, no
-- translation. Above the moving arm because it has no targetX to reach,
-- and the arm below would assign cellX = nil a frame later.
if self.marching then
self.moving = true
self.progress = self.progress + 1
if self.progress >= (self.stepFrames or STEP_FRAMES) then
self.progress = 0
self.moving = false
self.marching = false
self.stepFlip = not self.stepFlip
end
return
end
if self.moving then
-- NormalStep's begin-of-step grass work (engine/overworld/movement.asm:657-674);
-- UpdateTallGrassFlags only RE-tests while IN_GRASS is set (map_objects.asm:226).
if self.progress == 0 and map then
local grass = NPC.grassAt(map, self.targetX, self.targetY)
if self.inGrass then self.inGrass = grass end
self.grassShake = grass or nil
end
self.progress = self.progress + 1
-- Toward the TARGET, not one cell along stepDir: a follower's ledge hop is
-- a two-cell move over one step (src/world/gen2/Player.lua:150 does the
-- same), and `stepFrames` is what lets it keep pace with a bike.
local frames = self.stepFrames or STEP_FRAMES
local moved = math.floor(self.progress * 16 / frames)
local dx = (self.targetX or self.cellX) - self.cellX
local dy = (self.targetY or self.cellY) - self.cellY
self.px = self.cellX * 16 + dx * moved
self.py = self.cellY * 16 + dy * moved
if self.progress >= frames then
self.cellX, self.cellY = self.targetX, self.targetY
self.targetX, self.targetY = nil, nil
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
self.stepDir = nil
self.stepFlip = not self.stepFlip
-- CopyCoordsTileToLastCoordsTile -> SetTallGrassFlags at the step's end
-- (map_objects.asm:196-208, :247).
if map then
self.inGrass = NPC.grassAt(map, self.cellX, self.cellY)
end
end
return
end
if self.frozen or self.kind == "stand" then return end
self.timer = self.timer - 1
if self.timer > 0 then return end
if self.kind == "spin" then
self.timer = rand(self.spinLo or 60, self.spinHi or 180)
self.facing = self.roamDirs[rand(1, #self.roamDirs)]
return
end
-- SPINCLOCKWISE / SPINCOUNTERCLOCKWISE: one quarter turn in a FIXED order
-- every sixteen frames, never a re-roll (see SPIN_NEXT). Route 32's
-- Youngster Gordon, Route 35's Firebreather Walt, RadioTower4F's GruntM10 and
-- the Route 40/41 swimmers all carry one of these two rows and used to fall
-- through to "stand", so none of them turned at all.
if self.kind == "turn" then
self.timer = self.spinLo or SPIN_TURN_FRAMES
self.facing = self.roamDirs[self.facing] or self.facing
return
end
-- walk
self.timer = rand(30, 180)
local dir = self.roamDirs[rand(1, #self.roamDirs)]
self.facing = dir
if randf() < 0.5 then return end -- sometimes just turn, like Gen 1
local d = Map.DELTA[dir]
local tx, ty = self.cellX + d[1], self.cellY + d[2]
local allowed, why = wanderVerdict(self, map, entities, tx, ty)
-- movement.collision serves every mover, not just the player: Gen 1 runs the
-- NPC wander through the same src/world/Collision.lua canMove the player
-- uses, so a mod that widens or narrows movement sees both here too. Guarded
-- like the player's site; a mod-free boot pays one table lookup. The two
-- extra reasons ("radius", "warp") are Gen 2's own refusals -- an object_event
-- may not leave its radius and never walks out through a door -- and are
-- additions to Gen 1's bounds / tile / entity, never renames of them.
if Runtime.wantsHook("movement.collision") then
local ctx = { map = map, mover = self, dir = dir,
fromX = self.cellX, fromY = self.cellY,
toX = tx, toY = ty, reason = why }
allowed = Runtime.call("movement.collision", passthrough, allowed, ctx)
end
if not allowed then return end
self.targetX, self.targetY = tx, ty
self.moving = true
self.progress = 0
end
-- FacingBigDollSymmetric (data/sprites/facings.asm): sixteen OAM entries over a
-- 32x32 square, and the right half is the left half X-FLIPPED -- the sheet only
-- carries the eight tiles of one side. Those eight are the sheet's first two
-- 16x16 frames stacked, so the doll is frame 0 over frame 1, mirrored across
-- the middle. Drawn from the sprite's resolved (palette-baked) image so it
-- wears the same OBJ palette every other Gen 2 sprite does.
--
-- The 4px lift is the one SpriteRenderer:draw applies to every overworld
-- sprite; the object's own cell is the doll's top left, so the square lands on
-- the 2x2 blob NPC:covers describes.
function NPC:drawBig()
local image = self.sprite.resolveImage and self.sprite:resolveImage()
if not image then return end
local G = love.graphics
local x, y = math.floor(self.px), math.floor(self.py) - 4
for half = 0, 1 do
local quad = self.sprite.frames and self.sprite.frames[half]
if quad then
G.draw(image, quad, x, y + half * 16)
G.draw(image, quad, x + 32, y + half * 16, 0, -1, 1)
end
end
end
-- FacingBigDollAsymmetric (data/sprites/facings.asm), transcribed as its own
-- `db y, x, attributes, tile index` rows: fourteen 8x8 tiles over the same
-- 32x32 square, with the lower left two cells left empty and two tiles reused
-- X-flipped. A doll with no mirror line (SPRITE_BIG_ONIX) cannot be drawn by
-- doubling one half the way drawBig does.
local BIG_DOLL_ASYM = {
{ 0, 0, false, 0x00 },
{ 0, 8, false, 0x01 },
{ 8, 0, false, 0x04 },
{ 8, 8, false, 0x05 },
{ 16, 8, false, 0x07 },
{ 24, 8, false, 0x0a },
{ 0, 24, false, 0x03 },
{ 0, 16, false, 0x02 },
{ 8, 24, true, 0x02 },
{ 8, 16, false, 0x06 },
{ 16, 24, false, 0x09 },
{ 16, 16, false, 0x08 },
{ 24, 24, true, 0x04 },
{ 24, 16, false, 0x0b },
}
NPC.BIG_DOLL_ASYM = BIG_DOLL_ASYM
-- A tile index is four to a 16x16 sheet frame, row major, the way
-- FacingStepDown0's $00..$03 read off the standing-down frame -- so tile t
-- sits at ((t % 2) * 8, (t // 4) * 16 + ((t % 4) // 2) * 8) in the sheet.
function NPC.bigDollTileRect(tile)
return (tile % 2) * 8,
math.floor(tile / 4) * 16 + math.floor((tile % 4) / 2) * 8
end
function NPC:bigDollQuads(image)
if self.bigDollQuadCache then return self.bigDollQuadCache end
local iw, ih = image:getDimensions()
local quads = {}
for tile = 0, 11 do
local sx, sy = NPC.bigDollTileRect(tile)
quads[tile] = love.graphics.newQuad(sx, sy, 8, 8, iw, ih)
end
self.bigDollQuadCache = quads
return quads
end
function NPC:drawBigAsym()
local image = self.sprite.resolveImage and self.sprite:resolveImage()
if not image then return end
local G = love.graphics
local x, y = math.floor(self.px), math.floor(self.py) - 4
local quads = self:bigDollQuads(image)
for _, row in ipairs(BIG_DOLL_ASYM) do
local quad = quads[row[4]]
if quad then
if row[3] then
G.draw(image, quad, x + row[2] + 8, y + row[1], 0, -1, 1)
else
G.draw(image, quad, x + row[2], y + row[1])
end
end
end
end
function NPC:draw(ox, oy, scale)
-- Gen 1 spells this draw(camX, camY) and SpriteRenderer subtracts them
-- (src/world/NPC.lua:129). Two arguments means that call, not a missing
-- scale: G.scale(nil, nil) would either raise or draw unscaled at an
-- offset, which is the silent wrong answer.
if scale == nil then return self:draw(-(ox or 0), -(oy or 0), 1) end
local G = love.graphics
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
-- OBJECT_SPRITE_Y_OFFSET is added to the OBJ's y when it is written to OAM,
-- so it moves the sprite without moving the object off its tile.
local yOffset = self.spriteYOffset or 0
if self.bigObject then
G.push()
G.translate(0, yOffset)
if self.bigFacing == "asym" then self:drawBigAsym() else self:drawBig() end
G.pop()
elseif self.treeShake then
-- SetFacingWeirdTree cycles four quarters off FacingWeirdTree0-3
-- (data/sprites/facings.asm:46-52, :185-190, :192-197): quarters 0 and 2
-- are FacingStepDown0's tiles $00-$03, quarter 1 is $04-$07 (the "up"
-- frame's tiles) and quarter 3 is that same frame mirrored. So the tree
-- rocks right, upright, left, upright rather than turning to face.
local q = Movement.treeShakeIndex(self.treeShake.frame)
local facing = (q == 1 or q == 3) and "up" or "down"
self.sprite:draw(
self.px, self.py + yOffset, 0, 0,
facing, 0, false, false, q == 3)
else
self.sprite:draw(
self.px, self.py + yOffset, 0, 0,
self.facing, self:walkPhase(), self.stepFlip)
end
G.pop()
end
NPC.MOVE = MOVE
NPC.patternFor = patternFor
return NPC
+275
View File
@@ -0,0 +1,275 @@
-- Gen 2 GBC palette resolution: which four colors each tile, sprite and pic
-- draws with right now. Pure table math over data/generated/palettes.lua --
-- no love calls -- so tests and tools can ask the same questions the renderer
-- does. src/render/GbcPalette.lua turns the answers into draw calls.
--
-- Ported from engine/gfx/color.asm LoadMapPals and
-- engine/tilesets/timeofday_pals.asm ReplaceTimeOfDayPals:
--
-- real clock hour -> wTimeOfDay (GetTimeOfDay, engine/rtc/rtc.asm)
-- + map header -> wMapTimeOfDay (PALETTE_* override)
-- = wTimeOfDayPal -> the daytime whose colors actually load
--
-- and then, for that daytime:
--
-- EnvironmentColorsPointers[environment][daytime] -> 8 TilesetBGPalette ids
-- RoofPals[mapGroup] -> PAL_BG_ROOF colors 1-2
-- MapObjectPals[daytime] -> the 8 OBJ palettes
--
-- A tile's slot within that 8-palette set comes from its tileset's PalMap
-- (tilesets.lua `tilePalettes`, 1-based); a sprite's comes from
-- sprites.lua `paletteId` (PAL_OW_*).
local Palettes = {}
-- wTimeOfDay order (the GetTimePalette jumptable): MORN_F, DAY_F, NITE_F,
-- DARKNESS_F. bg_tiles.pal, npc_sprites.pal and every environment_colors row
-- are laid out in this same order, which is why one index works for all three.
Palettes.DAYTIMES = { "MORN", "DAY", "NITE", "DARK" }
Palettes.DAYTIME_ID = { MORN = 1, DAY = 2, NITE = 3, DARK = 4 }
-- engine/rtc/rtc.asm TimesOfDay: 0400-0959 morn, 1000-1759 day, 1800-0359
-- nite. The table is a run of "hour < N -> this daytime" rows, so the last
-- row wrapping back to NITE is what makes midnight-to-4am night.
local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18
-- PAL_OW_* (constants/sprite_data_constants.asm), 1-based for Lua indexing.
Palettes.OW_PALETTE_ID = {
PAL_OW_RED = 1, PAL_OW_BLUE = 2, PAL_OW_GREEN = 3, PAL_OW_BROWN = 4,
PAL_OW_PINK = 5, PAL_OW_EMOTE = 6, PAL_OW_TREE = 7, PAL_OW_ROCK = 8,
}
-- Roofs are only recolored outdoors; LoadMapPals returns early for anything
-- that is not TOWN or ROUTE, so an indoor map keeps the pool's roof palette.
local ROOF_ENVIRONMENTS = { TOWN = true, ROUTE = true }
-- ReplaceTimeOfDayPals.BrightnessLevels, read out as a plain lookup:
-- the map's PALETTE_* either follows the clock (AUTO) or pins one daytime.
local FORCED_DAYTIME = {
PALETTE_AUTO = nil,
PALETTE_DAY = "DAY",
PALETTE_NITE = "NITE",
PALETTE_MORN = "MORN",
PALETTE_DARK = "DARK",
}
-- White and black bracket the two colors a mon/trainer pic actually ships
-- (data/pokemon/palettes.asm: "only the middle two colors are included").
local WHITE = { 255, 255, 255 }
local BLACK = { 0, 0, 0 }
-- The daytime the game clock is in, ignoring any map override.
--
-- `hour` is hHours, the value UpdateTime leaves after FixTime has added the
-- save's wStartHour base -- World:hour, or the World.clockHour pin a driver
-- sets. Every caller that can reach a world or a save must pass it: the
-- no-argument fallback is the raw host clock, which is the cart's RTC WITHOUT
-- the base, so a save whose owner answered Oak reads a different hour there
-- than the rest of the game does.
function Palettes.clockDaytime(hour)
if not hour then
hour = tonumber(os.date("%H")) or 12
end
hour = math.floor(hour) % 24
if hour < MORN_HOUR then return "NITE" end
if hour < DAY_HOUR then return "MORN" end
if hour < NITE_HOUR then return "DAY" end
return "NITE"
end
-- The daytime whose colors load on this map: the clock, unless the map header
-- pins one. PALETTE_DARK maps are pitch black until FLASH is used, at which
-- point they read as night (ReplaceTimeOfDayPals.UsedFlash).
function Palettes.daytimeFor(mapDef, hour, flashUsed)
local clock = Palettes.clockDaytime(hour)
local forced = mapDef and FORCED_DAYTIME[mapDef.palette]
if forced == "DARK" then
return flashUsed and "NITE" or "DARK"
end
return forced or clock
end
-- True when this map is lit by DARKNESS_PALSET right now, i.e. it is a
-- PALETTE_DARK map and FLASH has not been used. That is the exact condition
-- FlashFunction.CheckUseFlash tests (`ld a, [wTimeOfDayPalset] / cp
-- DARKNESS_PALSET`), so FLASH is allowed here and refused everywhere else --
-- including inside a cave that is merely PALETTE_NITE, like Union Cave.
function Palettes.isDarkness(mapDef, hour, flashUsed)
return Palettes.daytimeFor(mapDef, hour, flashUsed) == "DARK"
end
-- There is no vision mask in Gen 2. A dark map is dark because the `dark`
-- rows of gfx/tilesets/bg_tiles.pal are colors 1-3 black on a color 0 of
-- RGB 01,01,02, and every environment's dark row ($18-$1f) points at them --
-- so the whole screen resolves to near-black through the ordinary bake, with
-- no second pass and nothing masked out. The one exception is what actually
-- makes a dark cave navigable:
--
-- FlickeringCaveEntrancePalette (engine/tilesets/tileset_anims.asm) runs every
-- VBlank while wTimeOfDayPalset is DARKNESS_PALSET and rewrites PAL_BG_YELLOW
-- color 0 from either its own color 0 or its color 1, picked by bit 1 of
-- hVBlankCounter. In the dark row those two are RGB 30,30,11 and black, so
-- the cave entrance blinks yellow-black-yellow on a four-frame cycle while
-- everything else stays flat. That blink is the "where is the way out"
-- signal, and it is the only light in the map.
Palettes.PAL_BG_YELLOW = 5 -- 1-based slot, PAL_BG_YELLOW is $04
-- `and %10`: two frames on, two frames off.
Palettes.FLICKER_PERIOD = 4
-- Which of PAL_BG_YELLOW's own colors is copied into its color 0 this frame.
-- 1 is "leave color 0 alone", 2 is "use color 1", both 1-based.
function Palettes.caveFlickerSource(frame)
return (math.floor((frame or 0) / 2) % 2 == 1) and 2 or 1
end
-- A copy of `set` with that copy already made. `sourceIndex` is 1 or 2, the
-- value caveFlickerSource returns. Only the yellow slot is rebuilt, so the
-- other seven palettes stay shared with the caller's set.
function Palettes.withCaveFlicker(set, sourceIndex)
if not set then return set end
local slot = set[Palettes.PAL_BG_YELLOW]
if not slot then return set end
local source = slot[sourceIndex == 2 and 2 or 1]
if not source then return set end
local out = {}
for i = 1, 8 do out[i] = set[i] end
local yellow = {}
for i = 1, 4 do
local c = slot[i]
yellow[i] = c and { c[1], c[2], c[3] } or nil
end
yellow[1] = { source[1], source[2], source[3] }
out[Palettes.PAL_BG_YELLOW] = yellow
return out
end
-- The eight BG palettes loaded for this map, each { {r,g,b} x4 }, with the
-- roof override already folded in. Index with a tileset's tilePalettes value.
function Palettes.bgSet(data, mapDef, daytime)
if not (data and data.bg and data.environments) then return nil end
local env = mapDef and mapDef.environment
local row = env and data.environments[env]
-- ENVIRONMENT_5 and friends fall back to the outdoor table, same as the
-- pointer table's duplicate entries do.
row = row or data.environments.TOWN
if not row then return nil end
local indices = row[daytime] or row.DAY
if not indices then return nil end
local set = {}
for slot = 1, 8 do
local pool = data.bg[indices[slot]]
-- Copy: the roof override below mutates one slot, and the pool entry is
-- shared by every map that picks it.
local colors = {}
for i = 1, 4 do
local c = pool and pool[i] or BLACK
colors[i] = { c[1], c[2], c[3] }
end
set[slot] = colors
end
local roofSlot = data.roofSlot or 7
if mapDef and ROOF_ENVIRONMENTS[env] and data.roofs then
local roof = data.roofs[mapDef.group]
if roof then
-- Colors 1 and 2 only; the pool keeps the roof palette's 0 and 3.
local pair = (daytime == "MORN" or daytime == "DAY")
and roof.mornDay or roof.nite
if pair and pair[1] and pair[2] then
set[roofSlot][2] = { pair[1][1], pair[1][2], pair[1][3] }
set[roofSlot][3] = { pair[2][1], pair[2][2], pair[2][3] }
end
end
end
return set
end
-- The eight OBJ palettes for OW sprites at this time of day.
function Palettes.objectSet(data, daytime)
if not (data and data.objects) then return nil end
return data.objects[daytime] or data.objects.DAY
end
-- The object_event palette byte's own OBJ palette slot, or nil for "use the
-- sprite's default". AddMapObject's tail (engine/overworld/player_object.asm
-- :187-194) is `ld hl, MAPOBJECT_PALETTE / add hl, bc / ld a, [hl] / and
-- MAPOBJECT_PALETTE_MASK / jr z, .skip_color_override / swap a / and
-- OAM_PALETTE`: a NON-ZERO field wins over whatever GetSpritePalette answered,
-- and only its low three bits reach OAM.
--
-- The PAL_NPC_* block is `const_def 1 << 3` over the same eight names as
-- PAL_OW_* (constants/sprite_data_constants.asm:15-38) -- bit 3 is nothing but
-- the "not the default" marker, which is why `and OAM_PALETTE` drops it and
-- PAL_NPC_BLUE lands on the same colors as PAL_OW_BLUE. The extractor stores
-- the field already unswapped, as the plain constant.
function Palettes.objectPaletteId(objDef)
local p = objDef and objDef.palette
if not p or p == 0 then return nil end
return p % 8
end
-- A sprite definition's OBJ palette (sprites.lua stores both the PAL_OW_*
-- name and the raw id; either resolves), unless the object_event standing on
-- the map overrode it -- see Palettes.objectPaletteId. The override is what
-- makes the three legendary beasts three different animals: BurnedTowerB1F's
-- Raikou, Entei and Suicune are all SPRITE_GROWLITHE, whose own palette is
-- PAL_OW_RED, and only PAL_NPC_BROWN / PAL_NPC_RED / PAL_NPC_BLUE on the three
-- object_events tell them apart (maps/BurnedTowerB1F.asm:152-154).
function Palettes.spritePalette(data, daytime, spriteDef, objDef)
local set = Palettes.objectSet(data, daytime)
if not set then return nil end
local id = Palettes.objectPaletteId(objDef)
if not id then
id = spriteDef and spriteDef.paletteId
if not id then
local name = spriteDef and spriteDef.palette
id = name and (Palettes.OW_PALETTE_ID[name] or
Palettes.OW_PALETTE_ID["PAL_OW_" .. tostring(name)])
id = id and id - 1 or 0
end
end
return set[(id or 0) + 1] or set[1]
end
-- A battle pic's four colors: white, the two shipped middle colors, black.
function Palettes.monColors(data, speciesId, shiny)
local entry = data and data.pokemon and data.pokemon[speciesId]
if not entry then return nil end
local pair = (shiny and entry.shiny) or entry.normal
if not (pair and pair[1] and pair[2]) then return nil end
return {
{ WHITE[1], WHITE[2], WHITE[3] },
{ pair[1][1], pair[1][2], pair[1][3] },
{ pair[2][1], pair[2][2], pair[2][3] },
{ BLACK[1], BLACK[2], BLACK[3] },
}
end
-- Trainer pics work the same way; row 0 is PLAYER (Chris shares Cal's colors).
function Palettes.trainerColors(data, className)
local pair = data and data.trainers and data.trainers[className or "PLAYER"]
if not (pair and pair[1] and pair[2]) then return nil end
return {
{ WHITE[1], WHITE[2], WHITE[3] },
{ pair[1][1], pair[1][2], pair[1][3] },
{ pair[2][1], pair[2][2], pair[2][3] },
{ BLACK[1], BLACK[2], BLACK[3] },
}
end
-- PAL_BG_TEXT is white/white/white/black in every set, which is what makes
-- text boxes readable at night; menus and the naming screen draw with it.
function Palettes.textColors(data)
if not (data and data.bg) then return nil end
local pool = data.bg[8] -- $07, the morn "text" row
if not pool then return nil end
return {
{ pool[1][1], pool[1][2], pool[1][3] },
{ pool[2][1], pool[2][2], pool[2][3] },
{ pool[3][1], pool[3][2], pool[3][3] },
{ pool[4][1], pool[4][2], pool[4][3] },
}
end
return Palettes
+348
View File
@@ -0,0 +1,348 @@
-- Gen 2 COLL_* → permission (pokegold CollisionPermissionTable lo-nybble,
-- home/map_objects.asm GetTilePermission). LAND=0, WATER=1, WALL=0x0f.
local Permissions = {}
Permissions.LAND = 0x00
Permissions.WATER = 0x01
Permissions.WALL = 0x0f
-- CollisionPermissionTable, lo nybble only (256 entries).
local TABLE = {
0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15,
0, 0, 15, 0, 0, 15, 0, 0, 0, 0, 15, 0, 0, 15, 0, 0,
1, 1, 1, 0, 1, 1, 1, 15, 1, 1, 1, 0, 1, 1, 1, 15,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 15, 0, 0, 0, 0, 0, 0, 0, 15, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
15, 15, 15, 15, 15, 0, 0, 0, 15, 15, 15, 15, 15, 0, 0, 0,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 15,
}
function Permissions.of(coll)
if coll == nil or coll < 0 then return Permissions.WALL end
return TABLE[(coll % 256) + 1] or Permissions.WALL
end
function Permissions.isLand(coll)
return Permissions.of(coll) == Permissions.LAND
end
function Permissions.isWater(coll)
return Permissions.of(coll) == Permissions.WATER
end
function Permissions.isWall(coll)
return Permissions.of(coll) == Permissions.WALL
end
-- Walkable on foot: DoPlayerMovement's .CheckWalkable, which is nothing more
-- than "the permission is LAND_TILE".
function Permissions.isWalkable(coll)
return Permissions.of(coll) == Permissions.LAND
end
-- .CheckSurfable (engine/overworld/player_movement.asm): the same test a
-- surfing player's step runs, and the reason it is three-valued rather than a
-- yes/no is that the LAND answer is what ends the surf.
--
-- "water" the permission is WATER_TILE, keep surfing
-- "land" the permission is LAND_TILE, this step is .ExitWater
-- nil neither, the step bumps
function Permissions.surfable(coll)
local perm = Permissions.of(coll)
if perm == Permissions.WATER then return "water" end
if perm == Permissions.LAND then return "land" end
return nil
end
-- Tall / long grass, the collisions a step can roll a wild encounter on
-- (constants/collision_constants.asm; the _10 and _1C aliases are unused on
-- the cart but land in the same permission).
local GRASS = {
[0x10] = true, -- COLL_TALL_GRASS_10 (unused)
[0x14] = true, -- COLL_LONG_GRASS
[0x18] = true, -- COLL_TALL_GRASS
[0x1c] = true, -- COLL_LONG_GRASS_1C (unused)
}
function Permissions.isGrass(coll)
if coll == nil then return false end
return GRASS[coll % 256] == true
end
-- CheckSuperTallGrassTile (home/map_objects.asm): the LONG grass pair only, not
-- the tall grass one. It is what doubles the Bug Contest encounter rate (40
-- percent against 20) and what makes the grass rustle animation play.
local SUPER_TALL_GRASS = {
[0x14] = true, -- COLL_LONG_GRASS
[0x1c] = true, -- COLL_LONG_GRASS_1C (unused)
}
function Permissions.isSuperTallGrass(coll)
if coll == nil then return false end
return SUPER_TALL_GRASS[coll % 256] == true
end
-- CheckGrassCollision (engine/overworld/tile_events.asm) -- NOT the same list
-- as GRASS above, and the difference is load bearing:
--
-- * COLL_WATER is in it, which is what lets a surfing step roll an
-- encounter at all (CanEncounterWildMon runs this, then
-- ChooseWildEncounter picks the water table off CheckOnWater);
-- * the unused $10 / $1c grass aliases are NOT in it, so a tile the graphics
-- call tall grass but the array does not is a free step.
--
-- The garbage rows ($08, $28, $48-$4c) are in the cart's array verbatim and
-- are kept here for the same reason: a romhack tileset that uses one gets the
-- cart's behaviour rather than the tidy one.
local ENCOUNTER_COLLISION = {
[0x08] = true, -- COLL_CUT_08
[0x18] = true, -- COLL_TALL_GRASS
[0x14] = true, -- COLL_LONG_GRASS
[0x28] = true, -- COLL_CUT_28
[0x29] = true, -- COLL_WATER
[0x48] = true, -- COLL_GRASS_48
[0x49] = true, -- COLL_GRASS_49
[0x4a] = true, -- COLL_GRASS_4A
[0x4b] = true, -- COLL_GRASS_4B
[0x4c] = true, -- COLL_GRASS_4C
}
function Permissions.isEncounterCollision(coll)
if coll == nil then return false end
return ENCOUNTER_COLLISION[coll % 256] == true
end
-- The single-collision predicates out of home/map_objects.asm. Every one of
-- them is a `cp COLL_x / ret z` pair over a real constant and its unused
-- alias, so they are pairs here too.
local ICE = { [0x23] = true, [0x2b] = true } -- CheckIceTile
local WHIRLPOOL = { [0x24] = true, [0x2c] = true } -- CheckWhirlpoolTile
local CUT_TREE = { [0x12] = true, [0x1a] = true } -- CheckCutTreeTile
local HEADBUTT_TREE = { [0x15] = true, [0x1d] = true } -- CheckHeadbuttTreeTile
-- CheckWaterfallTile pairs COLL_WATERFALL with COLL_CURRENT_DOWN, not with a
-- $3x alias of itself: the downward current is the tile at the TOP of a
-- waterfall, and the climb has to keep going while the player is on one.
local WATERFALL = { [0x33] = true, [0x3b] = true }
-- CheckCounterTile. The counter is not walkable, so it never shows up in a
-- movement test -- its whole job is in CheckFacingObject, which DOUBLES the
-- reach of an A press over one so the player can talk to the nurse or the
-- clerk standing on the far side. See World:interact.
local COUNTER = { [0x90] = true, [0x98] = true }
local function member(set, coll)
if coll == nil or coll < 0 then return false end
return set[coll % 256] == true
end
function Permissions.isIce(coll) return member(ICE, coll) end
function Permissions.isWhirlpool(coll) return member(WHIRLPOOL, coll) end
function Permissions.isCutTree(coll) return member(CUT_TREE, coll) end
function Permissions.isHeadbuttTree(coll) return member(HEADBUTT_TREE, coll) end
function Permissions.isWaterfall(coll) return member(WATERFALL, coll) end
function Permissions.isCounter(coll) return member(COUNTER, coll) end
-- DoPlayerMovement's .CheckTile, HI_NYBBLE_CURRENT arm
-- (engine/overworld/player_movement.asm). Every $3x collision is a CURRENT
-- tile, and the direction it forces is its LOW TWO BITS indexed into
-- .water_table (`maskbits NUM_DIRECTIONS`), so COLL_WATERFALL $33 and
-- COLL_CURRENT_DOWN $3b both come out DOWN.
--
-- The arm runs ABOVE .CheckTurning and .TryStep and returns
-- PLAYERMOVEMENT_CONTINUE, which is the whole mechanic: a current OVERRIDES
-- the d-pad rather than being refused by it. That is what makes the plunge
-- down a waterfall automatic and what makes a waterfall column unclimbable by
-- walking -- HM07's scripted climb (World:runWaterfall) moves the player with
-- its own steps, and it runs under World:busy where this never gets a look in.
--
-- COLL_WHIRLPOOL is tested before the nybble and takes PLAYERMOVEMENT_FORCE_TURN
-- instead, so it is not one of these.
local CURRENT_DIR = { [0] = "right", "left", "up", "down" }
function Permissions.currentDirection(coll)
if coll == nil or coll < 0 then return nil end
coll = coll % 256
if coll - (coll % 16) ~= 0x30 then return nil end
return CURRENT_DIR[coll % 4]
end
-- CheckCutCollision (engine/overworld/tile_events.asm): the collisions CUT is
-- allowed to swing at. Both grasses are in it, which is why CUT mows a patch
-- of tall grass down to bare ground and not only trees.
local CUTTABLE = {
[0x12] = true, -- COLL_CUT_TREE
[0x1a] = true, -- COLL_CUT_TREE_1A
[0x10] = true, -- COLL_TALL_GRASS_10
[0x18] = true, -- COLL_TALL_GRASS
[0x14] = true, -- COLL_LONG_GRASS
[0x1c] = true, -- COLL_LONG_GRASS_1C
}
function Permissions.isCuttable(coll) return member(CUTTABLE, coll) end
-- Ledges (HI_NYBBLE_LEDGES, $a0-$a7) and the facings that hop them.
--
-- `.TryJump` (engine/overworld/player_movement.asm) runs after `.TryStep`
-- fails: standing ON a ledge tile, facing a direction its `.ledge_table` row
-- allows, the refused step becomes a STEP_LEDGE -- a two-cell jump. The tile
-- itself is LAND in the permission table (walk onto it from any side); the
-- one-way-ness comes from the tile past it being refused, which on every cart
-- map it is (a wall or a side-wall tile). Gold's direction order differs from
-- Crystal's: $a0 is HOP_RIGHT here, not HOP_DOWN.
local LEDGE_FACINGS = {
[0x0] = { right = true }, -- COLL_HOP_RIGHT
[0x1] = { left = true }, -- COLL_HOP_LEFT
[0x2] = { up = true }, -- COLL_HOP_UP (unused)
[0x3] = { down = true }, -- COLL_HOP_DOWN
[0x4] = { down = true, right = true }, -- COLL_HOP_DOWN_RIGHT
[0x5] = { down = true, left = true }, -- COLL_HOP_DOWN_LEFT
[0x6] = { up = true, right = true }, -- COLL_HOP_UP_RIGHT (unused)
[0x7] = { up = true, left = true }, -- COLL_HOP_UP_LEFT (unused)
}
function Permissions.isLedge(coll)
if coll == nil or coll < 0 then return false end
return math.floor((coll % 256) / 16) == 0xa
end
-- The facings that jump this ledge, or nil for a non-ledge.
function Permissions.ledgeFacings(coll)
if not Permissions.isLedge(coll) then return nil end
return LEDGE_FACINGS[coll % 8]
end
-- One-way walls (HI_NYBBLE_SIDE_WALLS $b0, HI_NYBBLE_SIDE_BUOYS $c0).
--
-- GetMovementPermissions (home/map.asm) builds wTilePermissions from two
-- sources, and `.CheckLandPerms` / `.CheckSurfPerms` refuse a step whose
-- facing bit is set:
--
-- * the STANDING tile: `.MovementPermissionsData[coll & 7]`. The stored
-- masks are DOWN/UP/LEFT/RIGHT_MASK (1/2/4/8) but they are compared
-- against FACE_* bits (FACE_DOWN=8, FACE_UP=4, FACE_LEFT=2, FACE_RIGHT=1),
-- so the row that reads DOWN_MASK blocks FACE_RIGHT -- which lands each
-- COLL_x_WALL on blocking exactly direction x. Standing on an UP_WALL,
-- you cannot leave upward.
-- * the four NEIGHBOUR tiles. Each arm matches its own wall kinds (below:
-- UP/UP_RIGHT/UP_LEFT; above: DOWN/DOWN_*; right: LEFT/*_LEFT; left:
-- RIGHT/*_RIGHT) -- but on GOLD all four `.ok_*` arms are `set RIGHT, [hl]`
-- (bit 3 = FACE_DOWN), so every match forbids stepping DOWN. Only
-- COLL_UP_WALL below the player occurs on real maps, where the quirk and
-- the intent agree: you cannot step DOWN onto an UP_WALL. The other three
-- arms are transcribed faithfully anyway.
local SIDE_BLOCKS = {
[0x0] = { right = true }, -- COLL_RIGHT_WALL / RIGHT_BUOY
[0x1] = { left = true }, -- COLL_LEFT_WALL / LEFT_BUOY
[0x2] = { up = true }, -- COLL_UP_WALL / UP_BUOY
[0x3] = { down = true }, -- COLL_DOWN_WALL (unused)
[0x4] = { down = true, right = true }, -- COLL_DOWN_RIGHT_WALL (unused)
[0x5] = { down = true, left = true }, -- COLL_DOWN_LEFT_WALL (unused)
[0x6] = { up = true, right = true }, -- COLL_UP_RIGHT_WALL (unused)
[0x7] = { up = true, left = true }, -- COLL_UP_LEFT_WALL (unused)
}
function Permissions.isSideWall(coll)
if coll == nil or coll < 0 then return false end
local hi = math.floor((coll % 256) / 16)
return hi == 0xb or hi == 0xc
end
-- Directions a player STANDING on this tile may not move, or nil.
function Permissions.sideBlocks(coll)
if not Permissions.isSideWall(coll) then return nil end
return SIDE_BLOCKS[coll % 8]
end
-- The neighbour arms of GetMovementPermissions. `neighborDir` names where
-- the tile sits relative to the player ("down" = the tile below). A true
-- answer forbids the player's DOWN step -- the Gold `set RIGHT` quirk above --
-- whichever arm matched.
local NEIGHBOR_ARM = {
down = { [0x2] = true, [0x6] = true, [0x7] = true }, -- UP_WALL kinds below
up = { [0x3] = true, [0x4] = true, [0x5] = true }, -- DOWN_WALL kinds above
right = { [0x1] = true, [0x5] = true, [0x7] = true }, -- LEFT_WALL kinds right
left = { [0x0] = true, [0x4] = true, [0x6] = true }, -- RIGHT_WALL kinds left
}
function Permissions.neighborBlocksDown(neighborDir, coll)
if not Permissions.isSideWall(coll) then return false end
local arm = NEIGHBOR_ARM[neighborDir]
return (arm and arm[coll % 8]) == true
end
-- The whole GetMovementPermissions verdict: may a step `dir` leave (cx, cy)?
-- `collOf(x, y)` answers the collision byte, so the same rule serves the live
-- Map, the bot's planner and the offline graph without three copies drifting.
local NEIGHBOR_DELTA = {
up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 },
}
function Permissions.stepPermitted(collOf, cx, cy, dir)
local standing = Permissions.sideBlocks(collOf(cx, cy))
if standing and standing[dir] then return false end
if dir == "down" then
for nd, d in pairs(NEIGHBOR_DELTA) do
if Permissions.neighborBlocksDown(nd, collOf(cx + d[1], cy + d[2])) then
return false
end
end
end
return true
end
function Permissions.isWarpCollision(coll)
if coll == nil or coll < 0 then return false end
-- COLL_PIT / COLL_PIT_68 plus high-nybble $7 (CheckWarpCollision /
-- HI_NYBBLE_WARPS in engine/overworld/tile_events.asm).
if coll == 0x60 or coll == 0x68 then return true end
return math.floor(coll / 16) == 7
end
-- Carpet warps need a press in their direction (CheckDirectionalWarp).
local CARPET_DIR = {
[0x70] = "down", -- COLL_WARP_CARPET_DOWN
[0x76] = "left", -- COLL_WARP_CARPET_LEFT
[0x78] = "up", -- COLL_WARP_CARPET_UP
[0x7e] = "right", -- COLL_WARP_CARPET_RIGHT
}
function Permissions.carpetDirection(coll)
return CARPET_DIR[coll]
end
-- CheckWarpFacingDown (engine/overworld/tile_events.asm). RefreshPlayerSprite
-- runs this against the tile the player ARRIVES on and calls SpawnInFacingDown
-- when it hits; every other arrival tile keeps the facing they walked in with.
-- The four `; unused` rows are transcribed rather than dropped -- the array is
-- what the cart tests against, and a tileset that ever emitted $73 would find
-- them.
local WARP_FACING_DOWN = {
[0x71] = true, -- COLL_DOOR
[0x79] = true, -- COLL_DOOR_79 (unused)
[0x7a] = true, -- COLL_STAIRCASE
[0x73] = true, -- COLL_STAIRCASE_73 (unused)
[0x7b] = true, -- COLL_CAVE
[0x74] = true, -- COLL_CAVE_74 (unused)
[0x7c] = true, -- COLL_WARP_PANEL
[0x75] = true, -- COLL_DOOR_75 (unused)
[0x7d] = true, -- COLL_DOOR_7D (unused)
}
function Permissions.warpFacesDown(coll) return member(WARP_FACING_DOWN, coll) end
-- Immediate warp on landing (doors, stairs, caves, panels) vs carpet.
function Permissions.isImmediateWarp(coll)
if not Permissions.isWarpCollision(coll) then return false end
return CARPET_DIR[coll] == nil
end
return Permissions
+225
View File
@@ -0,0 +1,225 @@
-- Minimal Gen 2 overworld player: tile-grid steps at 16 frames/cell.
-- Draws via shared SpriteRenderer (same 16x96 facing layout as Gen 1).
local Map = require("src.world.gen2.Map")
local Runtime = require("src.mods.Runtime")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Player = {}
Player.__index = Player
local STEP_FRAMES = 16
local TURN_FRAMES = 4
-- The walking duration, exported so World can halve it for a bike step
-- (.DoStep's STEP_BIKE arm, engine/overworld/player_movement.asm). The leg
-- cadence below deliberately does NOT scale with it: animClock keeps counting
-- at the walking rate, which is what stops a bike step flickering the legs.
Player.STEP_FRAMES = STEP_FRAMES
function Player.new(cx, cy, facing, spriteDef)
local self = setmetatable({
cellX = cx, cellY = cy,
px = cx * 16, py = cy * 16,
facing = facing or "down",
moving = false,
progress = 0,
turnTimer = 0,
turnArmed = true,
stepFlip = false,
-- OBJECT_FLAGS2's IN_GRASS_F (engine/overworld/map_objects.asm:247).
inGrass = false,
animClock = 0,
-- Frames this cell takes; World rewrites it per step from the STEP_* the
-- player's state picks, and a step already under way keeps the one it
-- started with.
stepFrames = STEP_FRAMES,
sprite = nil,
spriteDef = spriteDef,
}, Player)
if spriteDef then
self.sprite = SpriteRenderer.new(spriteDef, "player")
end
return self
end
function Player:setSprite(spriteDef)
if spriteDef then
self.spriteDef = spriteDef
self.sprite = SpriteRenderer.new(spriteDef, "player")
end
end
-- the movement.collision chain sees the boolean; a wrapper that flips it
-- rewrites ctx.reason to say why (the engine's own reasons are bounds / tile /
-- entity, the same three src/world/Collision.lua names under Gen 1), so the
-- hook stays a single-value middleware.
local function passthrough(allowed) return allowed end
-- The verdict on one step, hoisted so the hooked and unhooked paths cannot
-- drift. World:movePlayer has already vetoed the direction by handing us a
-- refusingMap when GetMovementPermissions says no, so a side-wall veto arrives
-- here as "tile" exactly like a wall does.
local function verdict(self, map, entities, tx, ty)
if not map:inBounds(tx, ty) then return false, "bounds" end
if not map:isWalkable(tx, ty) then return false, "tile" end
if entities then
for _, e in ipairs(entities) do
-- `passable` is the follower's escape, src/world/Collision.lua:20's
-- name and meaning: the player walks straight through it.
if e ~= self and not e.passable then
if e.cellX == tx and e.cellY == ty then return false, "entity" end
if e.moving and e.targetX == tx and e.targetY == ty then
return false, "entity"
end
end
end
end
return true
end
function Player:tryMove(dir, map, entities)
if self.moving then return nil end
if self.facing ~= dir then
self.facing = dir
if self.turnArmed then
self.turnArmed = false
self.turnTimer = TURN_FRAMES
return "turned"
end
end
if self.turnTimer > 0 then return nil end
local d = Map.DELTA[dir]
local tx, ty = self.cellX + d[1], self.cellY + d[2]
local allowed, why = verdict(self, map, entities, tx, ty)
-- Per-step hot path, guarded the way src/world/Collision.lua's canMove is:
-- with an empty chain this costs one table lookup and no ctx allocation.
if Runtime.wantsHook("movement.collision") then
local ctx = { map = map, mover = self, dir = dir,
fromX = self.cellX, fromY = self.cellY,
toX = tx, toY = ty, reason = why }
allowed = Runtime.call("movement.collision", passthrough, allowed, ctx)
why = ctx.reason
end
if not allowed then
-- World:movePlayer tells the two refusals apart: "edge" is what asks the
-- connection table for the neighbouring map, "blocked" is a bump.
return why == "bounds" and "edge" or "blocked"
end
self.targetX, self.targetY = tx, ty
self.moving = true
self.progress = 0
return "moved"
end
-- Cutscene step: ignores collision so Elm walk-up / after-pick paths play.
function Player:scriptFace(dir)
if dir then self.facing = dir end
end
function Player:scriptStep(dir)
if self.moving then return false end
-- A scripted step names its own STEP_* on the cart (SurfStartStep is a slow
-- step), so it never inherits the bike's shorter one.
self.stepFrames = STEP_FRAMES
self.facing = dir or self.facing
local d = Map.DELTA[self.facing]
if not d then return false end
self.targetX, self.targetY = self.cellX + d[1], self.cellY + d[2]
self.moving = true
self.progress = 0
return true
end
-- Gen 1's name for the cell being faced (src/world/Player.lua), so a mod that
-- wraps World:interact asks one question of either generation.
function Player:facingCell()
local d = Map.DELTA[self.facing] or Map.DELTA.down
return self.cellX + d[1], self.cellY + d[2]
end
function Player:walkPhase()
if not self.moving then return 0 end
local p = self.animClock % STEP_FRAMES
return (p >= 4 and p < 12) and 1 or 0
end
function Player:update()
if self.turnTimer > 0 then
self.turnTimer = self.turnTimer - 1
end
if not self.moving then
-- Re-arm turn-in-place once a poll finds no held direction (caller
-- clears this while a dir is held; we only set it from idle).
return false
end
self.progress = self.progress + 1
self.animClock = self.animClock + 1
-- Interpolate toward the TARGET cell rather than one cell along the facing:
-- a ledge hop (World:tryLedgeJump, the cart's STEP_LEDGE) is a two-cell move
-- and the facing-delta math walked only half of it, leaving the sprite a
-- cell behind where the grid said the player was.
local frames = self.stepFrames or STEP_FRAMES
local adv = math.floor(self.progress * 16 / frames)
local dx = (self.targetX or self.cellX) - self.cellX
local dy = (self.targetY or self.cellY) - self.cellY
self.px = self.cellX * 16 + dx * adv
self.py = self.cellY * 16 + dy * adv
if self.jumping then
-- The hop arc. Cosmetic: the grid position is the straight-line
-- interpolation above, only the drawn pixels rise.
self.py = self.py - math.floor(6 * math.sin(math.pi * self.progress / frames))
end
if self.progress >= frames then
self.cellX, self.cellY = self.targetX, self.targetY
self.targetX, self.targetY = nil, nil
self.px, self.py = self.cellX * 16, self.cellY * 16
self.moving = false
self.jumping = nil
self.stepFlip = not self.stepFlip
return true
end
return false
end
function Player:draw(ox, oy, scale)
local G = love.graphics
-- OBJECT_SPRITE_Y_OFFSET: added to the OBJ's y as it is written to OAM, so
-- it moves the sprite without moving the player off the tile they are
-- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off
-- lift both ride this one byte.
local yOffset = self.spriteYOffset or 0
if self.sprite then
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
-- Chris is PAL_OW_RED; World:applyPalettes keeps the SpriteRenderer's
-- OBJ palette current.
self.sprite:draw(
self.px, self.py + yOffset, 0, 0,
self.facing, self:walkPhase(), self.stepFlip)
G.pop()
return
end
-- Fallback rectangle if sprites.lua is missing from an old cache.
local x = ox + self.px * scale
local y = oy + (self.py + yOffset) * scale
local s = 16 * scale
G.setColor(0.95, 0.35, 0.25, 1)
G.rectangle("fill", x + s * 0.15, y + s * 0.1, s * 0.7, s * 0.85, 2, 2)
G.setColor(1, 0.9, 0.55, 1)
local notch = s * 0.22
if self.facing == "up" then
G.rectangle("fill", x + s * 0.5 - notch / 2, y + s * 0.05, notch, notch)
elseif self.facing == "down" then
G.rectangle("fill", x + s * 0.5 - notch / 2, y + s * 0.7, notch, notch)
elseif self.facing == "left" then
G.rectangle("fill", x + s * 0.05, y + s * 0.4, notch, notch)
else
G.rectangle("fill", x + s * 0.75, y + s * 0.4, notch, notch)
end
G.setColor(1, 1, 1, 1)
end
return Player
+200
View File
@@ -0,0 +1,200 @@
-- CountStep (engine/overworld/events.asm), the block that runs on every
-- overworld footfall, and the four routines it calls that had no port at all.
--
-- This is the chain the world was missing entirely: `World` kept no step count,
-- so `Happiness.step` and `Breeding.step` were written and tested and nothing
-- ever called either. Eggs never hatched and scripted phone calls never fired,
-- both main quest.
--
-- CheckTileEvent runs it between the coord events and the wild encounter roll,
-- and a CARRY out of it means a player event is queued -- which is why a step
-- that hatches an egg or drops a poisoned mon never also starts a battle.
--
-- CountStep:
-- ret if wLinkMode ; not modelled: no link overworld
-- CheckSpecialPhoneCall -> c: .doscript
-- DoRepelStep -> c: .doscript
-- inc wPoisonStepCount
-- inc wStepCount -> z (the wrap): StepHappiness
-- wStepCount == $80 : DoEggStep -> nz: .hatch
-- DayCareStep
-- wPoisonStepCount >= 4 : reset, DoPoisonStep -> c: .doscript
-- DoBikeStep
--
-- Everything here is love-free and takes its state as arguments so the whole
-- chain is testable without a world.
local Bike = require("src.world.gen2.Bike")
local Breeding = require("src.core.gen2.Breeding")
local Happiness = require("src.core.gen2.Happiness")
local Phone = require("src.core.gen2.Phone")
local StepEvents = {}
-- constants/pokemon_data_constants.asm: `1 << PSN`. The port stores a status
-- as a lowercase name on the mon; the battle writes "poison"/"toxic"
-- (Battle.STATUS_EFFECTS), older saves may carry "psn"/"tox".
local function isPoisoned(mon)
local status = mon and mon.status
return status == "psn" or status == "tox" or status == "poison"
or status == "toxic"
end
-- .DamageMonIfPoisoned's two answers, kept as the cart's own bit pair so the
-- "someone fainted beats someone hurt" test below reads like `and %10`.
StepEvents.POISON_HURT = 1
StepEvents.POISON_FAINTED = 2
-- Every 4 steps (wPoisonStepCount `cp 4 / jr c`).
StepEvents.POISON_PERIOD = 4
-- DoBikeStep's threshold is `cp HIGH(1024)` on the counter's HIGH byte, so it
-- is 1024 steps and the counter saturates at $ffff rather than wrapping.
StepEvents.BIKE_CALL_STEPS = 1024
StepEvents.BIKE_STEP_MAX = 0xffff
-- DoPoisonStep. One HP off every poisoned mon that is still standing, and the
-- mon that runs out has its status CLEARED on the way down -- so a party wiped
-- by poison walks into the Pokemon Center with no status left to cure.
--
-- The two flags are collected across the WHOLE party before either branch is
-- taken (wPoisonStepFlagSum), which is why one faint anywhere in the party
-- outranks five mons merely taking damage.
function StepEvents.poisonStep(party)
party = party or {}
local hurt, fainted = {}, {}
for index, mon in ipairs(party) do
if isPoisoned(mon) and (mon.hp or 0) > 0 then
mon.hp = mon.hp - 1
if mon.hp <= 0 then
mon.hp = 0
mon.status = nil
fainted[#fainted + 1] = index
else
hurt[#hurt + 1] = index
end
end
end
if #fainted > 0 then
return { kind = "poisonFaint", fainted = fainted, hurt = hurt, blocks = true }
end
if #hurt > 0 then
-- .PlayPoisonSFX and the two-frame red flash, then `xor a`: no carry, so
-- the step still counts and the wild roll still happens.
return { kind = "poisonHurt", hurt = hurt, blocks = false }
end
return nil
end
-- .CheckWhitedOut's tail: `predef CheckPlayerPartyForFitMon`, whose answer is
-- what decides between closing the text box and jumping to
-- OverworldWhiteoutScript. An egg is not a fit mon (DayCare_GiveEgg zeroes its
-- HP), which Breeding.healthyCount already says out loud.
function StepEvents.whitedOut(party)
return Breeding.healthyCount(party) == 0
end
-- DoRepelStep. `dec a / ret nz`: the wear-off lands on the step that takes the
-- counter to zero, and that step is NOT counted -- so the last repel step never
-- ticks the egg or the day care.
function StepEvents.repelStep(save)
local left = save.repelSteps or 0
if left <= 0 then return false end
save.repelSteps = left - 1
return save.repelSteps == 0
end
-- DoBikeStep. Four gates before the counter even moves, and then a quirk worth
-- keeping: `scf` at the end is thrown away by CountStep's `.done`, which does
-- `xor a / ret`. So queueing the bike shop's call does NOT stop the step being
-- counted and does NOT produce a player event -- the call goes out on the NEXT
-- footfall, through CheckSpecialPhoneCall at the top of this same block.
--
-- wStatusFlags2's BIKE_SHOP_CALL bit is not a byte nobody else reads: the
-- Goldenrod bike shop clerk's own `setflag ENGINE_BIKE_SHOP_CALL_ENABLED`
-- (maps/GoldenrodBikeShop.asm) is what turns it on, and Vm's setflag lands
-- that on save.engineFlags under the ENGINE_* id. save.bikeShopCall is kept
-- as the fallback for a save written before that was wired up, and is cleared
-- alongside the flag so the two can never disagree.
local function bikeShopCallEnabled(save)
local flags = save.engineFlags
if type(flags) == "table" then
local set = flags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED]
if set ~= nil then return set == true end
end
return save.bikeShopCall == true
end
function StepEvents.bikeStep(save, opts)
opts = opts or {}
if not bikeShopCallEnabled(save) then return false end
if opts.playerState ~= "bike" then return false end
if opts.phoneService == false then return false end
local steps = math.min((save.bikeStep or 0) + 1, StepEvents.BIKE_STEP_MAX)
save.bikeStep = steps
if steps < StepEvents.BIKE_CALL_STEPS then return false end
-- "If a call has already been queued, don't overwrite that call."
if Phone.hasSpecialCall(save) then return false end
Phone.queueSpecialCall(save, Phone.SPECIALCALL.SPECIALCALL_BIKESHOP)
-- `res STATUSFLAGS2_BIKE_SHOP_CALL_F`: one call, ever.
if type(save.engineFlags) == "table" then
save.engineFlags[Bike.ENGINE_BIKE_SHOP_CALL_ENABLED] = nil
end
save.bikeShopCall = false
return true
end
-- The whole block, in the cart's order.
--
-- `ctx` carries what the routines need from outside the save: `data` for the
-- day care's species lookups, `rng` for its egg roll, `phone` for
-- CheckSpecialPhoneCall's map/time context, `playerState` and `phoneService`
-- for DoBikeStep.
--
-- Returns an event table or nil, plus whether the step was COUNTED. The
-- event's `blocks` field is CountStep's CARRY: the caller owes the matching
-- player-event script and must not roll a wild encounter on that step. Only
-- `poisonHurt` reports an event without one -- DoPoisonStep's .PlayPoisonSFX
-- arm ends `xor a`, so a party that merely takes damage still walks into grass.
function StepEvents.count(save, ctx)
ctx = ctx or {}
if type(save) ~= "table" then return nil, false end
if ctx.linkMode then return nil, false end
-- Neither of the next two counts the step.
local call = Phone.checkSpecialCall(save, ctx.phone)
if call then return { kind = "phoneCall", call = call, blocks = true }, false end
if StepEvents.repelStep(save) then
return { kind = "repel", blocks = true }, false
end
save.poisonStepCount = ((save.poisonStepCount or 0) + 1) % 256
-- Breeding.step owns wStepCount: it increments, ticks the eggs at $80 and
-- runs DayCareStep, all in the cart's order. StepHappiness sits between the
-- increment and the egg tick on the cart and is called after both here, which
-- is safe rather than sloppy: the wrap ($00) and the egg phase ($80) can
-- never be the same step, so the two never run on the same footfall at all.
local bred = Breeding.step(ctx.data, save, ctx.rng)
Happiness.step(save)
if bred == "hatch" then return { kind = "hatch", blocks = true }, true end
if save.poisonStepCount >= StepEvents.POISON_PERIOD then
save.poisonStepCount = 0
local poison = StepEvents.poisonStep(save.party)
if poison and poison.kind == "poisonFaint" then
poison.whiteout = StepEvents.whitedOut(save.party)
return poison, true
end
if poison then
-- .PlayPoisonSFX only: no carry, so the caller plays the sound and the
-- step carries on into the wild roll.
StepEvents.bikeStep(save, ctx)
return poison, true
end
end
StepEvents.bikeStep(save, ctx)
return nil, true
end
return StepEvents
+122
View File
@@ -0,0 +1,122 @@
-- The Viridian Trainer House: the one battle a day against CAL in the
-- basement's TRAINING HALL (maps/TrainerHouseB1F.asm).
--
-- The conversation itself is script bytecode and stays in the cache: the
-- coord_event on the doorway cell runs TrainerHouseReceptionistScript, which
-- checks ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY, asks `special TrainerHouse`
-- whose opponent it is, walks the player into the room and starts the battle.
-- What that script needs from the port is the three compiled routines behind
-- it, and all three are about the SAME question: whether a Mystery Gift trade
-- has left a custom trainer in SRAM.
--
-- TrainerHouse engine/events/specials.asm -- reads
-- sMysteryGiftTrainerHouseFlag into wScriptVar, so
-- the script picks CAL2 (the visitor) or CAL3 (the
-- house's own trainer).
-- ReadTrainerParty engine/battle/read_trainer_party.asm -- CAL2 is
-- the ONLY trainer in the game whose party does not
-- come from data/trainers/parties.asm. Its `.cal2`
-- arm reads sMysteryGiftTrainer as a
-- TRAINERTYPE_MOVES party instead.
-- GetTrainerName same file -- and CAL is the only class whose name
-- is not read from the parties table either: with
-- the flag set it is copied out of
-- sMysteryGiftPartnerName.
--
-- MYSTERY GIFT IS OUT OF SCOPE (it is one of the six peripheral stubs in
-- src/script/gen2/Specials.lua: the trade rides the Game Boy's infrared port
-- into a second cartridge, and there is no second cartridge here). So the
-- flag is permanently clear, which is exactly the state of a cartridge that
-- has never been linked, and every one of the three routines above takes its
-- own no-custom-data arm. That fallback is what this module ports:
--
-- * the script asks once before the walk-in and once at the battle, gets
-- FALSE both times, and fights CAL3 -- MEGANIUM, TYPHLOSION and FERALIGATR
-- at level 50, the strongest of the three CAL rows;
-- * a CAL2 lookup that reaches here anyway is answered with CAL3 rather than
-- with parties.asm's CAL (2) row, because that row is DEAD DATA on the
-- cart: `ReadTrainerParty` branches to SRAM before it ever indexes the
-- table, so handing back BAYLEEF/QUILAVA/CROCONAW at level 30 would be a
-- team no cartridge ever fields;
-- * the name is the parties table's own "CAL".
--
-- The once-a-day gate is not here: ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY is a
-- wDailyFlags1 bit like Kurt's, so the script's own setflag is the whole of
-- the write and src/core/gen2/Apricorns.lua's daily reset is the whole of the
-- clear. The id below is for readers and for the test that pins the pair.
local Trainers = require("src.world.gen2.Trainers")
local TrainerHouse = {}
-- constants/trainer_constants.asm: the CAL class and its three members. CAL1
-- is the Route 27 battle, CAL2 the Mystery Gift visitor, CAL3 the house's own.
TrainerHouse.CAL = 12
TrainerHouse.CAL1, TrainerHouse.CAL2, TrainerHouse.CAL3 = 1, 2, 3
-- constants/engine_flags.asm index 86, wDailyFlags1 bit
-- DAILYFLAGS1_FOUGHT_IN_TRAINER_HALL_TODAY. Cleared by
-- Apricorns.dailyReset, which wipes both daily bytes whole.
TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY = 86
-- sMysteryGiftTrainerHouseFlag (ram/sram.asm), the byte a completed Mystery
-- Gift trade leaves behind. STUB, and a deliberate one: nothing in this port
-- can set it, because nothing in this port can run the infrared trade that
-- writes it (engine/link/mystery_gift.asm). Kept as a function rather than as
-- a constant `false` so the day Mystery Gift lands there is one place to teach
-- about save.mysteryGift, and so the two readers below cannot drift apart.
function TrainerHouse.hasCustomTrainer(save)
local gift = type(save) == "table" and save.mysteryGift or nil
return (gift and gift.trainerHouse) and true or false
end
-- ReadTrainerParty's `cp CAL / cp CAL2` pair, as the question a caller with a
-- class and a member can ask: which member should actually be loaded. Only
-- CAL2 is ever redirected, and only when there is no custom trainer to redirect
-- it to -- with one in SRAM the cart reads the party out of SRAM and this
-- would have nothing to say about it either.
function TrainerHouse.resolveMember(save, class, member)
if class == TrainerHouse.CAL and member == TrainerHouse.CAL2
and not TrainerHouse.hasCustomTrainer(save) then
return TrainerHouse.CAL3
end
return member
end
-- GetTrainerName's CAL arm. Returns the name the SRAM copy would have
-- supplied, or nil for "fall through to the parties table", which is what
-- `.not_cal2` does. nil rather than "CAL" on purpose: the caller already has
-- the table, and inventing the answer here would hide a lookup that failed.
function TrainerHouse.customName(save, class)
if class ~= TrainerHouse.CAL then return nil end
if not TrainerHouse.hasCustomTrainer(save) then return nil end
local gift = save and save.mysteryGift
return gift and gift.partnerName or nil
end
-- The pair of lookups the World hands to the VM, with the CAL2 redirect
-- applied. `trainerData` is the cache's trainers table.
function TrainerHouse.lookup(trainerData, save, class, member)
return Trainers.lookup(trainerData, class,
TrainerHouse.resolveMember(save, class, member))
end
function TrainerHouse.name(trainerData, save, class, member)
local custom = TrainerHouse.customName(save, class)
if custom then return custom end
local entry = TrainerHouse.lookup(trainerData, save, class, member)
return entry and entry.name or nil
end
-- The daily gate, for a reader holding nothing but a save file. The script
-- owns both sides of it in game (checkflag / setflag), so neither of these has
-- a call site in the engine: they exist so the test can state the rule, and so
-- a future rematch feature has one name for the bit rather than the number 86
-- written out again.
function TrainerHouse.foughtToday(save)
local flags = type(save) == "table" and save.engineFlags or nil
return (flags and flags[TrainerHouse.ENGINE_FOUGHT_IN_TRAINER_HALL_TODAY])
== true
end
return TrainerHouse
+130
View File
@@ -0,0 +1,130 @@
-- Gen 2 overworld trainers: the `trainer` struct an object_event points at
-- (macros/scripts/maps.asm), the eyesight test from home/trainers.asm, and the
-- party build that turns trainers.lua's level/species rows into battle mons.
--
-- The extractor already has every class's members; this is the layer between
-- that table and a battle, so nothing here reads the ROM.
local Mon = require("src.battle.gen2.Mon")
local Map = require("src.world.gen2.Map")
local Trainers = {}
-- constants/script_constants.asm
Trainers.TEXT_SEEN, Trainers.TEXT_WIN, Trainers.TEXT_LOSS = 0, 1, 2
-- constants/misc_constants.asm
Trainers.RESET_FLAG, Trainers.SET_FLAG, Trainers.CHECK_FLAG = 0, 1, 2
-- trainers.lua keys classes by name; the `trainer` struct and `loadtrainer`
-- both carry the class's numeric constant, so index once per data table.
local function classIndex(trainerData)
if not (trainerData and trainerData.classes) then return {} end
local cache = rawget(trainerData, "_byIndex")
if cache then return cache end
cache = {}
for id, class in pairs(trainerData.classes) do
if type(class) == "table" and class.index then
cache[class.index] = class
class.id = class.id or id
end
end
rawset(trainerData, "_byIndex", cache)
return cache
end
Trainers.classIndex = classIndex
-- class constant + member number -> a flat record the battle screen can use.
-- `name` is the trainer's own name (JOEY), `className` the class's display
-- name (YOUNGSTER) -- the HUD wants "YOUNGSTER JOEY".
function Trainers.lookup(trainerData, class, member)
local entry = classIndex(trainerData)[class]
if not entry then return nil end
local row = entry.trainers and entry.trainers[member]
if not row then return nil end
return {
class = class,
classId = entry.id,
className = entry.name,
member = member,
id = row.id,
name = row.name,
trainerType = row.trainerType,
roster = row.party or {},
attributes = entry.attributes,
-- TRNATTR_ITEM1/ITEM2: what AI_TryItem may reach for. A copy, so using
-- one up in a battle does not empty the class record for the next
-- trainer of that class.
items = (function()
local out = {}
for _, id in ipairs(entry.items or {}) do out[#out + 1] = id end
return out
end)(),
baseMoney = entry.baseMoney,
}
end
-- Build the battle party. TRAINERTYPE_MOVES / _ITEM_MOVES rows carry an
-- explicit move list; the rest take whatever the species knows at that level,
-- which is what MakeTrainerPartyMon does via LearnLevelMoves.
function Trainers.party(data, entry)
local party = {}
for _, row in ipairs((entry and entry.roster) or {}) do
local moves = nil
if row.moves and #row.moves > 0 then
moves = {}
for _, id in ipairs(row.moves) do
local def = data and data.moves and data.moves[id]
moves[#moves + 1] = { id = id, pp = def and def.pp or 0,
maxPp = def and def.pp or 0 }
end
end
-- Trainer mons roll no DVs: the cart gives every one of them 9/8/8/8/8
-- (wEnemyMonDVs is fixed in MakeTrainerPartyMon), which is why a trainer's
-- Rattata is always the same Rattata.
local mon = Mon.new(data, row.species, row.level, {
moves = moves,
item = row.item,
dvs = { attack = 9, defense = 8, speed = 8, special = 8 },
})
if mon then party[#party + 1] = mon end
end
return party
end
-- FacingPlayerDistance (home/trainers.asm): the trainer must share a row or
-- column with the player, be facing along it, and the gap must be at least 1
-- and no more than its sight range. Returns distance, direction.
function Trainers.sees(npc, player, sight)
if not (npc and player) or (sight or 0) <= 0 then return nil end
if npc.cellX == player.cellX then
local d = player.cellY - npc.cellY
if d == 0 then return nil end
local dir = d > 0 and "down" or "up"
d = math.abs(d)
if npc.facing ~= dir or d > sight then return nil end
return d, dir
elseif npc.cellY == player.cellY then
local d = player.cellX - npc.cellX
if d == 0 then return nil end
local dir = d > 0 and "right" or "left"
d = math.abs(d)
if npc.facing ~= dir or d > sight then return nil end
return d, dir
end
return nil
end
-- TrainerWalkToPlayer: the trainer closes to one cell short of the player,
-- so a distance of 1 means it never moves.
function Trainers.approach(distance, dir)
local steps = {}
for _ = 1, math.max(0, (distance or 1) - 1) do
steps[#steps + 1] = dir
end
return steps
end
Trainers.DELTA = Map.DELTA
return Trainers
File diff suppressed because it is too large Load Diff
+329
View File
@@ -0,0 +1,329 @@
-- mod.world for Gen 2 (Gold): the same facade src/world/WorldAPI.lua gives a
-- mod under Gen 1, resolved against src/world/gen2/World.lua instead of the
-- Gen 1 overworld state. One name, one method set, two arms -- a mod that
-- declares gen2compat calls mod.world:current() and does not care which game
-- it is running on.
--
-- Two structural differences show through, and both are reported rather than
-- faked:
--
-- * Gold's World is not a stack state. It hangs off the service owner as
-- game.world for the whole run, so there is no stack scan here; a menu or
-- a battle pushed over the world does not hide it.
--
-- * Gen 2 event flags are NUMBERS (wEventFlags is a bitfield, and the cart's
-- EVENT_* constants are indices into it), where Gen 1 flags are string
-- keys in save.flags. setFlag/getFlag therefore take a numeric id here
-- and say so when handed a string, rather than silently writing a key the
-- bitfield cannot hold.
--
-- Anything Gen 2 has no equivalent for at all returns nil plus a reason, the
-- same shape the Gen 1 arm uses for "no overworld". A dual-generation mod
-- checks the second return and degrades; it never crashes and never gets a
-- silent no-op.
local Logger = require("src.core.Logger")
local Movement = require("src.script.gen2.Movement")
local Runtime = require("src.mods.Runtime")
local WorldAPI = {}
WorldAPI.__index = WorldAPI
local NO_OVERWORLD = "no overworld"
function WorldAPI.new(game, modId)
return setmetatable({ game = game, modId = modId }, WorldAPI)
end
-- the live World, or nil while the boot cinema is still up
function WorldAPI:overworld()
local world = self.game and self.game.world
if world and world.map then return world end
return nil
end
function WorldAPI:current()
local world = self:overworld()
if not world or not world.map then return nil, NO_OVERWORLD end
local p = world.player
return { mapId = world.map.id, x = p and p.cellX, y = p and p.cellY,
facing = p and p.facing }
end
-- opts is accepted for signature parity with the Gen 1 arm; Gold's arrival FX
-- come from the map setup method, so opts.arrive has nothing to select yet.
function WorldAPI:warpTo(mapId, x, y, facing, opts)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not (world.maps and world.maps[mapId]) then
return nil, "unknown map: " .. tostring(mapId)
end
if not (x and y) then return nil, "warpTo needs x and y" end
local ok = world:warpToMapId(mapId, x, y,
facing or (world.player and world.player.facing) or "down")
if not ok then return nil, world.status or "warp failed" end
return true
end
-- Gen 2 has no save.objectToggles: an object's visibility IS its
-- MAPOBJECT_EVENT_FLAG, which lives in the event bitfield and is therefore
-- already persistent and already re-read by the next LoadObjectMasks. Setting
-- the flag is the whole operation; appear/disappear additionally take it off
-- the live map when the map is the active one.
--
-- objRef is the object's 1-based index in the map's object list, or its name
-- when the extracted map carries one.
function WorldAPI:toggleObject(mapId, objRef, visible)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not (world.map and world.map.id == mapId) then
-- the flag is per object, and the object list only resolves for a loaded
-- map, so an off-map toggle has nothing to name
return nil, "map is not active: " .. tostring(mapId)
end
local def = world.map.def
local objects = def and def.objects
if not objects then return nil, "map has no objects" end
local index
for i, obj in ipairs(objects) do
if i == objRef or obj.name == objRef then
-- def.objects is keyed by the object's own index, and World:objectEntity
-- reads it back as objectId - 1, so the id is that key plus one
index = obj.index or i
break
end
end
if not index then
return nil, "no such object: " .. tostring(objRef)
end
local objectId = index + 1
if visible then world:appearObject(objectId) else world:disappearObject(objectId) end
Runtime.emit("world.object_toggled",
{ mapId = mapId, objName = objRef, visible = visible and true or false })
return true
end
-- id is a numeric EVENT_* index into wEventFlags. A string is the Gen 1
-- habit and cannot work here, so it is refused with the reason rather than
-- stored somewhere the engine never looks.
local function flagId(id)
if type(id) == "number" then return id end
return nil, ("Gen 2 event flags are numeric ids, got %s (%s)")
:format(type(id), tostring(id))
end
function WorldAPI:setFlag(id, value)
local world = self:overworld()
if not world or not world.events then return nil, NO_OVERWORLD end
local numeric, err = flagId(id)
if not numeric then return nil, err end
world.events:set(numeric, value and true or false)
return true
end
function WorldAPI:getFlag(id)
local world = self:overworld()
if not world or not world.events then return nil, NO_OVERWORLD end
local numeric, err = flagId(id)
if not numeric then return nil, err end
return world.events:get(numeric)
end
-- active map only, same contract as the Gen 1 arm: this mutates the loaded
-- block data and rebuilds the view. `block` is a block id.
function WorldAPI:replaceBlock(bx, by, block)
local world = self:overworld()
if not world or not world.map then return nil, NO_OVERWORLD end
world:changeBlock(bx, by, block)
return true
end
local UNSUPPORTED = "not supported in Gen 2 yet"
-- objDef uses the same shape as an extracted map's objects list (sprite, x, y,
-- movement, hours, ...), which is the Gen 1 arm's contract too. Runtime
-- objects are not serialized; a mod respawns them on map.entered.
function WorldAPI:spawnNpc(mapId, objDef)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if type(objDef) ~= "table" then return nil, "objDef must be a table" end
local copy = {}
for k, v in pairs(objDef) do copy[k] = v end
return world:addRuntimeObject(mapId, copy, self.modId)
end
function WorldAPI:removeNpc(npcId)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
return world:removeRuntimeObject(npcId, self.modId)
end
-- a handle onto a live NPC. Scripted movement does carry over: it compiles to
-- the cart's own movement stream and rides World:beginMovement, the same path
-- an `applymovement` in a map script takes -- so a mod's walk is frozen,
-- stepped and released exactly like a scripted one. Only spawning does not.
local Handle = {}
Handle.__index = Handle
-- One movement stream at a time is the engine's own limit (World.moveState is
-- a single slot), so a second call while one is running is refused rather than
-- silently replacing the first and stranding its onDone.
function Handle:scriptMove(dir, tiles, onDone)
local world = self.world
if world.moveState then return nil, "a movement is already running" end
local step = Movement.stepByte(dir)
if not step then return nil, "unknown direction: " .. tostring(dir) end
local bytes = {}
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
bytes[#bytes + 1] = Movement.STEP_END
world:beginMovement(self.objectId, bytes, onDone)
return true
end
-- Gen 1's marchInPlace is step_sleep-with-animation; the Gen 2 stream has no
-- single byte for it, and faking one out of turn bytes would march the wrong
-- way. Left explicit rather than approximated.
function Handle:marchInPlace()
return nil, UNSUPPORTED
end
function Handle:face(dir)
self.npc:scriptFace(dir)
return true
end
function Handle:position()
return self.npc.cellX, self.npc.cellY
end
function WorldAPI:npc(mapId, indexOrName)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if world.map and world.map.id ~= mapId then return nil, "map is not active" end
for _, npc in ipairs(world.npcs or {}) do
local def = npc.def
if def and (def.index == indexOrName or def.name == indexOrName) then
return setmetatable(
{ world = world, npc = npc, objectId = (def.index or 0) + 1 }, Handle)
end
end
return nil, "no such object: " .. tostring(indexOrName)
end
-- The Gen 2 VM runs the cart's own bytecode out of data/generated/scripts.lua,
-- not the Gen 1 runner's `{ "command", ... }` rows, so there is no row list to
-- hand it. What a mod actually reaches for out of that vocabulary is a small
-- set of verbs that Gold has its own entry points for, so those are driven
-- directly here, one row at a time, and anything else is refused BY NAME
-- before the first row runs -- a half-run queue is the failure mode this
-- facade exists to avoid. The full list is src/script/Commands.lua; these
-- five are the ones with a Gen 2 home.
local VERBS = {}
-- start_battle "wild" species level. Gold's own grass step ends in
-- World:startBattle with a Mon (src/world/gen2/World.lua:4021), so this is
-- that call with the mon built from the mod's species and level. The trainer
-- arm needs a party out of the extracted trainer table and an OPP_CLASS the
-- mod cannot name, so only the wild arm is served.
function VERBS.start_battle(api, row, resume)
local world = api:overworld()
local kind = row[2]
if kind ~= "wild" then
return nil, "only start_battle \"wild\" is supported in Gen 2"
end
local game = world.game
local mon = require("src.battle.gen2.Mon").new(
game and game.data, row[3], tonumber(row[4]) or 5)
if not mon then return nil, "unknown species: " .. tostring(row[3]) end
local save = game and game.save
if save then
save.pokedex = save.pokedex or { seen = {}, caught = {} }
save.pokedex.seen[mon.species] = true
end
world:startBattle({ wild = mon }, function() resume() end)
return true
end
function VERBS.warp(api, row, resume)
local ok, err = api:warpTo(row[2], row[3], row[4], row[5])
if not ok then return nil, err end
resume()
return true
end
function VERBS.text(api, row, resume)
local world = api:overworld()
world:showText(tostring(row[2] or ""), function() resume() end)
return true
end
function VERBS.setflag(api, row, resume)
local ok, err = api:setFlag(row[2], true)
if not ok then return nil, err end
resume()
return true
end
function VERBS.clearflag(api, row, resume)
local ok, err = api:setFlag(row[2], false)
if not ok then return nil, err end
resume()
return true
end
-- Rows run in order, each one resuming the next from its own completion
-- callback, so a battle or a text box blocks the queue the way it blocks the
-- Gen 1 runner's coroutine. One queue at a time, for the reason
-- Handle:scriptMove refuses a second movement.
function WorldAPI:queueScript(rows, extra)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if type(rows) ~= "table" then return nil, "queueScript wants a row list" end
if self.queue then return nil, "a script is already running" end
for i, row in ipairs(rows) do
local name = type(row) == "table" and row[1]
if not VERBS[name] then
return nil, ("unsupported script command in Gen 2: %s (row %d)")
:format(tostring(name), i)
end
end
self.queue = true
local pc = 0
local step
local function finish(err)
self.queue = nil
if err then
Logger.warn("[%s] queueScript stopped: %s", tostring(self.modId), err)
end
if extra and extra.onDone then extra.onDone(err == nil) end
end
step = function()
pc = pc + 1
local row = rows[pc]
if not row then return finish(nil) end
local ok, err = VERBS[row[1]](self, row, function() step() end)
if not ok then finish(err or "row failed") end
end
step()
return true
end
-- Gold's maps come from one table loaded at World:load, so there is no
-- per-map instance cache to drop. Reloading the active map is the part that
-- carries meaning, and reloadMapBadWarp is exactly the cart's own
-- "load this map again where you stand" (MAPSETUP_BADWARP).
function WorldAPI:invalidateMap(mapId)
local world = self:overworld()
if not world then return nil, NO_OVERWORLD end
if not (world.map and world.map.id == mapId) then return true end
local ok, err = pcall(world.reloadMapBadWarp, world)
if not ok then
Logger.warn("[%s] invalidateMap %s failed: %s", tostring(self.modId),
tostring(mapId), tostring(err))
return nil, tostring(err)
end
Runtime.emit("map.reloaded", { mapId = mapId, reason = "invalidate" })
return true
end
return WorldAPI