mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
G2 support
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user