mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
big ass modding update
This commit is contained in:
+26
-5
@@ -1,6 +1,8 @@
|
||||
-- Movement permission checks: tile passability (from generated collision
|
||||
-- data), map bounds, and entity occupancy.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Collision = {}
|
||||
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
@@ -49,11 +51,7 @@ local function pairBlocked(map, mover, sx, sy, tx, ty)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Returns true when the mover may step from (cx,cy) toward dir.
|
||||
-- Out-of-bounds is blocked here; the OverworldController handles map
|
||||
-- connections and edge warps before asking.
|
||||
function Collision.canMove(map, entities, mover, dir)
|
||||
local tx, ty = Collision.target(mover.cellX, mover.cellY, dir)
|
||||
local function verdict(map, entities, mover, dir, tx, ty)
|
||||
if not map:inBounds(tx, ty) then
|
||||
return false, "bounds"
|
||||
end
|
||||
@@ -72,4 +70,27 @@ function Collision.canMove(map, entities, mover, dir)
|
||||
return true
|
||||
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), so the hook stays a single-value middleware
|
||||
local function passthrough(allowed) return allowed end
|
||||
|
||||
-- Returns true when the mover may step from (cx,cy) toward dir.
|
||||
-- Out-of-bounds is blocked here; the OverworldController handles map
|
||||
-- connections and edge warps before asking. Per-step hot path: with an
|
||||
-- empty chain this costs one table lookup and no ctx allocation.
|
||||
function Collision.canMove(map, entities, mover, dir)
|
||||
local tx, ty = Collision.target(mover.cellX, mover.cellY, dir)
|
||||
local allowed, why = verdict(map, entities, mover, dir, tx, ty)
|
||||
if Runtime.wantsHook("movement.collision") then
|
||||
local ctx = { map = map, mover = mover, dir = dir,
|
||||
fromX = mover.cellX, fromY = mover.cellY,
|
||||
toX = tx, toY = ty, reason = why }
|
||||
allowed = Runtime.call("movement.collision", passthrough, allowed, ctx)
|
||||
why = ctx.reason
|
||||
end
|
||||
if allowed then return true end
|
||||
return false, why
|
||||
end
|
||||
|
||||
return Collision
|
||||
|
||||
+14
-3
@@ -3,10 +3,21 @@
|
||||
-- rand(0..255) < map encounter rate; the slot is picked with the original
|
||||
-- probability buckets.
|
||||
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
|
||||
local Encounter = {}
|
||||
|
||||
-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm)
|
||||
local SLOT_BUCKETS = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 }
|
||||
-- Cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm),
|
||||
-- now constants.encounterBuckets. An encounter def may also carry its own
|
||||
-- `buckets` of any length, as long as the last entry is 256 and there are
|
||||
-- as many slots as buckets.
|
||||
local buckets = FieldDefaults.CONSTANTS.encounterBuckets
|
||||
|
||||
-- Collision.load's idiom: the overworld hands the dataset over on entry so
|
||||
-- the pure roll stays free of a Data reference.
|
||||
function Encounter.load(data)
|
||||
buckets = FieldDefaults.constant(data, "encounterBuckets")
|
||||
end
|
||||
|
||||
function Encounter.roll(encounterDef, rng)
|
||||
rng = rng or love.math.random
|
||||
@@ -15,7 +26,7 @@ function Encounter.roll(encounterDef, rng)
|
||||
if not grass or grass.rate == 0 then return nil end
|
||||
if rng(0, 255) >= grass.rate then return nil end
|
||||
local pick = rng(0, 255)
|
||||
for i, threshold in ipairs(SLOT_BUCKETS) do
|
||||
for i, threshold in ipairs(grass.buckets or buckets) do
|
||||
if pick < threshold then
|
||||
local slot = grass.slots[i]
|
||||
if slot then
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
-- Vanilla values for the data.field / Data.constants keys this milestone
|
||||
-- lifts out of src/world/ literals. The importer does not stamp them yet,
|
||||
-- so every read site folds its own table over these and behaves exactly as
|
||||
-- the literal did on a stale cache; seed() fills the gaps in Data before
|
||||
-- the mod merge so a mod's patch has a vanilla base to merge over instead
|
||||
-- of replacing Kanto wholesale.
|
||||
-- Pure Lua, no love.*, so the headless loader and offline tools can require it.
|
||||
|
||||
local FieldDefaults = {}
|
||||
|
||||
-- ------- data.field
|
||||
|
||||
-- SGB overworld palette (engine/gfx/palettes.asm SetPal_Overworld): towns
|
||||
-- own theirs, routes PAL_ROUTE, interiors inherit the last outdoor map,
|
||||
-- with the Pokemon Tower / cave tileset and Elite Four cases on top.
|
||||
local PALETTES = {
|
||||
byMap = {
|
||||
PALLET_TOWN = "PALLET", VIRIDIAN_CITY = "VIRIDIAN",
|
||||
PEWTER_CITY = "PEWTER", CERULEAN_CITY = "CERULEAN",
|
||||
LAVENDER_TOWN = "LAVENDER", VERMILION_CITY = "VERMILION",
|
||||
CELADON_CITY = "CELADON", FUCHSIA_CITY = "FUCHSIA",
|
||||
CINNABAR_ISLAND = "CINNABAR", INDIGO_PLATEAU = "INDIGO",
|
||||
SAFFRON_CITY = "SAFFRON",
|
||||
LORELEIS_ROOM = "PALLET", BRUNOS_ROOM = "CAVE",
|
||||
},
|
||||
-- Pokemon Tower / Agatha, then the caves
|
||||
byTileset = { CEMETERY = "GRAYMON", CAVERN = "CAVE" },
|
||||
byPrefix = { { prefix = "ROUTE_", palette = "ROUTE" } },
|
||||
default = "ROUTE",
|
||||
}
|
||||
|
||||
-- data/tilesets/bookshelf_tile_ids.asm: tileset id + collision tile ->
|
||||
-- what facing up into it prints. `kind` names an engine flavor (the
|
||||
-- vanilla five); mods author `text` (a data.text key) or `screen`
|
||||
-- (a screens-registry id) instead.
|
||||
local BOOKSHELVES = {
|
||||
PLATEAU = { [0x30] = { kind = "statues" } },
|
||||
HOUSE = { [0x3D] = { screen = "TownMap" }, [0x1E] = { kind = "books" } },
|
||||
MANSION = { [0x32] = { kind = "books" } },
|
||||
REDS_HOUSE_1 = { [0x32] = { kind = "books" } },
|
||||
LAB = { [0x28] = { kind = "books" } },
|
||||
LOBBY = { [0x16] = { kind = "elevator" }, [0x50] = { kind = "stuff" },
|
||||
[0x52] = { kind = "stuff" } },
|
||||
GYM = { [0x1D] = { kind = "books" } },
|
||||
DOJO = { [0x1D] = { kind = "books" } },
|
||||
GATE = { [0x22] = { kind = "books" } },
|
||||
MART = { [0x54] = { kind = "stuff" }, [0x55] = { kind = "stuff" } },
|
||||
POKECENTER = { [0x54] = { kind = "stuff" }, [0x55] = { kind = "stuff" } },
|
||||
SHIP = { [0x36] = { kind = "books" } },
|
||||
}
|
||||
|
||||
-- Rod tables (item_effects.asm ItemUseOldRod/GoodRod, data/wild/good_rod.asm).
|
||||
-- The rejection-loop odds stay engine behavior: they are Gen-1 mechanics,
|
||||
-- not content. perMap names the field key holding the per-map groups.
|
||||
local FISHING = {
|
||||
OLD_ROD = { always = { species = "MAGIKARP", level = 5 } },
|
||||
GOOD_ROD = { pool = { { species = "GOLDEEN", level = 10 },
|
||||
{ species = "POLIWAG", level = 10 } } },
|
||||
SUPER_ROD = { perMap = "superRod" },
|
||||
}
|
||||
|
||||
-- The step counter gates on EVENT_IN_SAFARI_ZONE, not the map, so every
|
||||
-- interior counts and the gate itself never does (home/overworld.asm).
|
||||
local SAFARI = {
|
||||
stepMaps = {
|
||||
"SAFARI_ZONE_CENTER", "SAFARI_ZONE_EAST",
|
||||
"SAFARI_ZONE_NORTH", "SAFARI_ZONE_WEST",
|
||||
"SAFARI_ZONE_CENTER_REST_HOUSE", "SAFARI_ZONE_EAST_REST_HOUSE",
|
||||
"SAFARI_ZONE_NORTH_REST_HOUSE", "SAFARI_ZONE_WEST_REST_HOUSE",
|
||||
"SAFARI_ZONE_SECRET_HOUSE",
|
||||
},
|
||||
exitWarp = { map = "SAFARI_ZONE_GATE", x = 4, y = 3, facing = "down" },
|
||||
}
|
||||
|
||||
-- home/overworld.asm LoadPlayerSpriteGraphics / LoadSurfingPlayerSprite-
|
||||
-- Graphics / player_animations.asm LoadBirdSpriteGraphics
|
||||
local PLAYER_SPRITES = {
|
||||
walk = "SPRITE_RED", surf = "SPRITE_SEEL",
|
||||
bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD",
|
||||
}
|
||||
|
||||
-- Route22Gate_Script rewrites wLastMap from the player's Y every frame, so
|
||||
-- the north exit leaves onto Route 23 and the south onto Route 22. Rules
|
||||
-- are ordered, first match wins, the last row is the default.
|
||||
local LAST_MAP_REWRITES = {
|
||||
ROUTE_22_GATE = { axis = "y", rules = { { below = 4, map = "ROUTE_23" },
|
||||
{ map = "ROUTE_22" } } },
|
||||
}
|
||||
|
||||
FieldDefaults.FIELD = {
|
||||
palettes = PALETTES,
|
||||
bookshelves = BOOKSHELVES,
|
||||
fishing = FISHING,
|
||||
safari = SAFARI,
|
||||
playerSprites = PLAYER_SPRITES,
|
||||
lastMapRewrites = LAST_MAP_REWRITES,
|
||||
-- CheckIfInOutsideMap: what counts as "outside" for the wLastMap memory
|
||||
outsideTilesets = { "OVERWORLD", "PLATEAU" },
|
||||
-- the Route 16/18 gate scripts `res BIT_ALWAYS_ON_BIKE` every frame
|
||||
forcedMovement = { clearMaps = { "ROUTE_16_GATE_1F", "ROUTE_18_GATE_1F" } },
|
||||
-- the one-shot flag the gate's pass text is gated on; a gate a mod adds
|
||||
-- gets "PASSED_<mapId>" instead of this pre-v2 spelling
|
||||
badgeGates = { ROUTE_22_GATE = { passedFlag = "PASSED_ROUTE22_GATE" } },
|
||||
-- VermilionGymSetDoorTile opens the motorized door once both locks are hit
|
||||
hiddenExtras = {
|
||||
trashCans = { map = "VERMILION_GYM",
|
||||
doorBlock = { bx = 2, by = 2, block = 5 } },
|
||||
},
|
||||
-- IsSurfingAllowed refuses SURF on the B4F stairs square until both
|
||||
-- plug boulders are down (engine/overworld/field_move_messages.asm)
|
||||
seafoam = {
|
||||
SEAFOAM_ISLANDS_B4F = {
|
||||
surfBlocked = { { x = 7, y = 11, untilEvents = {
|
||||
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
|
||||
"EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE" } } },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- ------- Data.constants
|
||||
|
||||
FieldDefaults.CONSTANTS = {
|
||||
world = {
|
||||
poisonStepInterval = 4, -- ApplyOutOfBattlePoisonDamage
|
||||
poisonDamage = 1,
|
||||
blackoutMoneyDivisor = 2,
|
||||
daycareExpPerStep = 1,
|
||||
neighborHops = 2, -- connection hops drawn around the current map
|
||||
stepFrames = 16, -- 1px per frame, 16 frames per tile
|
||||
bikeStepFrames = 8, -- the bicycle doubles walking speed
|
||||
turnFrames = 2, -- the extra OverworldLoop pass after a turn
|
||||
},
|
||||
-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm)
|
||||
encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 },
|
||||
-- the badge each HM's field move is gated on; distinct from
|
||||
-- constants.hmMoves, which is the forget-gate move set
|
||||
hmBadges = {
|
||||
CUT = { badge = "CASCADEBADGE" }, SURF = { badge = "SOULBADGE" },
|
||||
STRENGTH = { badge = "RAINBOWBADGE" }, FLY = { badge = "THUNDERBADGE" },
|
||||
FLASH = { badge = "BOULDERBADGE" },
|
||||
},
|
||||
}
|
||||
|
||||
-- ------- accessors
|
||||
|
||||
-- data.field[key] with the vanilla table as the stale-cache fallback
|
||||
function FieldDefaults.field(data, key)
|
||||
local field = data and data.field
|
||||
local value = field and field[key]
|
||||
if value ~= nil then return value end
|
||||
return FieldDefaults.FIELD[key]
|
||||
end
|
||||
|
||||
local function walk(node, n, ...)
|
||||
for i = 1, n do
|
||||
if type(node) ~= "table" then return nil end
|
||||
node = node[(select(i, ...))]
|
||||
end
|
||||
return node
|
||||
end
|
||||
|
||||
-- one leaf inside a field sub-table, falling back per path so a cache that
|
||||
-- stamps the record but not this key still resolves the vanilla value
|
||||
function FieldDefaults.fieldValue(data, key, ...)
|
||||
local n = select("#", ...)
|
||||
local value = walk(data and data.field and data.field[key], n, ...)
|
||||
if value ~= nil then return value end
|
||||
return walk(FieldDefaults.FIELD[key], n, ...)
|
||||
end
|
||||
|
||||
function FieldDefaults.constant(data, key)
|
||||
local constants = data and data.constants
|
||||
local value = constants and constants[key]
|
||||
if value ~= nil then return value end
|
||||
return FieldDefaults.CONSTANTS[key]
|
||||
end
|
||||
|
||||
-- one world constant, falling back per key so a cache that stamps half of
|
||||
-- constants.world still resolves the other half
|
||||
function FieldDefaults.world(data, key)
|
||||
local world = data and data.constants and data.constants.world
|
||||
local value = world and world[key]
|
||||
if value ~= nil then return value end
|
||||
return FieldDefaults.CONSTANTS.world[key]
|
||||
end
|
||||
|
||||
-- ------- seeding
|
||||
|
||||
-- fill-if-absent, never overwrite: an importer that learns to stamp one of
|
||||
-- these silently takes over, and re-running is a no-op. Lists are leaves.
|
||||
local function fill(dst, src)
|
||||
for key, value in pairs(src) do
|
||||
if dst[key] == nil then
|
||||
if type(value) == "table" then
|
||||
local copy = {}
|
||||
fill(copy, value)
|
||||
dst[key] = copy
|
||||
else
|
||||
dst[key] = value
|
||||
end
|
||||
elseif type(value) == "table" and type(dst[key]) == "table"
|
||||
and #value == 0 then
|
||||
fill(dst[key], value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Called before the mod merge (Data:seedDefaults): puts the vanilla values
|
||||
-- in data.field / data.constants so mod.content.field:patch("palettes", ...)
|
||||
-- deep-merges over Kanto instead of replacing it.
|
||||
function FieldDefaults.seed(data)
|
||||
data.field = data.field or {}
|
||||
data.constants = data.constants or {}
|
||||
fill(data.field, FieldDefaults.FIELD)
|
||||
fill(data.constants, FieldDefaults.CONSTANTS)
|
||||
return data
|
||||
end
|
||||
|
||||
return FieldDefaults
|
||||
+72
-11
@@ -10,6 +10,25 @@
|
||||
local Map = {}
|
||||
Map.__index = Map
|
||||
|
||||
-- Stale-cache fallbacks for the tileset properties the importer does not
|
||||
-- stamp yet (item_effects.asm IsNextTileShoreOrWater, home/overworld.asm
|
||||
-- CollisionCheckOnWater): $14 is water everywhere; the shore tiles $32 and
|
||||
-- $48 (Safari Zone) everywhere EXCEPT SHIP_PORT, where $32 is the dock's
|
||||
-- boarding platform -- a land tile. A tileset record that carries
|
||||
-- waterTiles/shoreTiles wins outright, which is how a new tileset gets
|
||||
-- surfable water without naming Kanto's.
|
||||
local WATER_TILES = { 0x14 }
|
||||
local SHORE_TILES = { 0x32, 0x48 }
|
||||
local NO_SHORE_TILESETS = { SHIP_PORT = true }
|
||||
|
||||
-- what counts as "outside" for the wLastMap memory (CheckIfInOutsideMap)
|
||||
local OUTSIDE_TILESETS = { "OVERWORLD", "PLATEAU" }
|
||||
|
||||
local function hashSet(list, into)
|
||||
for _, t in ipairs(list) do into[t] = true end
|
||||
return into
|
||||
end
|
||||
|
||||
function Map.new(def, tilesetDef)
|
||||
local self = setmetatable({}, Map)
|
||||
self.def = def
|
||||
@@ -24,18 +43,65 @@ function Map.new(def, tilesetDef)
|
||||
for _, t in ipairs(tilesetDef.doorTiles or {}) do self.doorTiles[t] = true end
|
||||
self.warpTiles = {}
|
||||
for _, t in ipairs(tilesetDef.warpTiles or {}) do self.warpTiles[t] = true end
|
||||
-- water and shore share one lookup: both are surfable, only the caller's
|
||||
-- water_tilesets.asm membership check separates them
|
||||
self.waterTiles = hashSet(tilesetDef.waterTiles or WATER_TILES, {})
|
||||
local shore = tilesetDef.shoreTiles
|
||||
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end
|
||||
hashSet(shore or {}, self.waterTiles)
|
||||
|
||||
self.warpAt = {}
|
||||
for i, w in ipairs(def.warps) do
|
||||
for i, w in ipairs(def.warps or {}) do
|
||||
self.warpAt[w.y * self.widthCells + w.x] = { index = i, def = w }
|
||||
end
|
||||
self.signAt = {}
|
||||
for _, s in ipairs(def.signs) do
|
||||
for _, s in ipairs(def.signs or {}) do
|
||||
self.signAt[s.y * self.widthCells + s.x] = s
|
||||
end
|
||||
return self
|
||||
end
|
||||
|
||||
-- ------- map record properties (authored maps set them; vanilla falls back)
|
||||
|
||||
-- town/route surface: door SFX, the walk-out step, the Fly menu and the
|
||||
-- town map all mean this one
|
||||
function Map.isOutdoor(def)
|
||||
if def.outdoor ~= nil then return def.outdoor end
|
||||
return def.tileset == "OVERWORLD"
|
||||
end
|
||||
|
||||
-- CheckIfInOutsideMap, a strictly wider set: Route 23 / Indigo Plateau are
|
||||
-- outside for the wLastMap memory without being outdoor for the door SFX
|
||||
function Map.isOutside(def, tilesets)
|
||||
if Map.isOutdoor(def) then return true end
|
||||
for _, ts in ipairs(tilesets or OUTSIDE_TILESETS) do
|
||||
if ts == def.tileset then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- region groups maps a rule applies to without naming them; the id prefix
|
||||
-- is the fallback for caches that predate the property
|
||||
function Map.inRegion(def, region, prefix)
|
||||
if def.region ~= nil then return def.region == region end
|
||||
return prefix ~= nil and def.id:find(prefix, 1, true) == 1
|
||||
end
|
||||
|
||||
-- unidentifiable wild battles on this map unless the player holds an item
|
||||
function Map.ghostBattles(def)
|
||||
if def.ghostBattles ~= nil then return def.ghostBattles end
|
||||
if def.id:find("POKEMON_TOWER", 1, true) == 1 then
|
||||
return { unlessItem = "SILPH_SCOPE" }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- strength-pushable map objects (engine/overworld/push_boulder.asm)
|
||||
function Map.isPushable(objDef)
|
||||
if objDef.pushable ~= nil then return objDef.pushable end
|
||||
return objDef.sprite == "SPRITE_BOULDER"
|
||||
end
|
||||
|
||||
function Map:blockAt(bx, by)
|
||||
if bx < 0 or by < 0 or bx >= self.def.width or by >= self.def.height then
|
||||
return self.def.borderBlock
|
||||
@@ -69,16 +135,11 @@ function Map:isGrassCell(cx, cy)
|
||||
return grass ~= nil and self:cellTile(cx, cy) == grass
|
||||
end
|
||||
|
||||
-- Water and eastern-shore tiles (item_effects.asm IsNextTileShoreOrWater,
|
||||
-- home/overworld.asm CollisionCheckOnWater): $14 everywhere; the shore
|
||||
-- tiles $32 and $48 (Safari Zone) everywhere EXCEPT the SHIP_PORT
|
||||
-- tileset, where $32 is the dock's boarding platform (a land tile).
|
||||
-- Tileset membership in water_tilesets.asm is checked by the caller.
|
||||
-- Water and eastern-shore tiles, from the tileset's waterTiles/shoreTiles
|
||||
-- (hash sets built in Map.new). Tileset membership in water_tilesets.asm
|
||||
-- is checked by the caller.
|
||||
function Map:isWaterCell(cx, cy)
|
||||
local t = self:cellTile(cx, cy)
|
||||
if t == 0x14 then return true end
|
||||
if self.def.tileset == "SHIP_PORT" then return false end
|
||||
return t == 0x32 or t == 0x48
|
||||
return self.waterTiles[self:cellTile(cx, cy)] or false
|
||||
end
|
||||
|
||||
-- Replace a block (Cut trees); the caller rebuilds the renderer.
|
||||
|
||||
+32
-4
@@ -1,6 +1,9 @@
|
||||
-- Builds runtime Map objects (and their tile SpriteBatches) from generated
|
||||
-- data, cached by map id.
|
||||
-- data, cached by map id. The cache is keyed so a mod that patches one
|
||||
-- map record after boot (or the dev-mode hot reload) can drop just that
|
||||
-- entry instead of every map's SpriteBatches.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Map = require("src.world.Map")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
|
||||
@@ -11,9 +14,11 @@ local cache = {}
|
||||
function MapLoader.load(data, mapId)
|
||||
if cache[mapId] then return cache[mapId] end
|
||||
local def = data.maps[mapId]
|
||||
assert(def, "unknown map: " .. tostring(mapId))
|
||||
assert(def, "unknown map: " .. tostring(mapId) ..
|
||||
" (not in the maps registry)")
|
||||
local tilesetDef = data.tilesets[def.tileset]
|
||||
assert(tilesetDef, "unknown tileset: " .. tostring(def.tileset))
|
||||
assert(tilesetDef, ("map %s wants unknown tileset: %s (not in the " ..
|
||||
"tilesets registry)"):format(tostring(mapId), tostring(def.tileset)))
|
||||
|
||||
-- warp tiles are stored per tileset macro name; the generated tilesets
|
||||
-- module carries them in the tileset entry itself
|
||||
@@ -23,8 +28,31 @@ function MapLoader.load(data, mapId)
|
||||
return map
|
||||
end
|
||||
|
||||
function MapLoader.clearCache()
|
||||
-- the live instance for a map id, or nil when it has not been loaded;
|
||||
-- callers that must not build a map (invalidation, tests) use this
|
||||
function MapLoader.cached(mapId)
|
||||
return cache[mapId]
|
||||
end
|
||||
|
||||
-- drop one map so the next load re-reads its record and rebuilds its
|
||||
-- renderer. Callers holding the old instance keep it -- OverworldState
|
||||
-- re-points self.map itself (WorldAPI:invalidateMap).
|
||||
function MapLoader.invalidate(mapId)
|
||||
local had = cache[mapId] ~= nil
|
||||
cache[mapId] = nil
|
||||
return had
|
||||
end
|
||||
|
||||
function MapLoader.invalidateAll()
|
||||
cache = {}
|
||||
end
|
||||
|
||||
-- kept as the pre-v2 name
|
||||
MapLoader.clearCache = MapLoader.invalidateAll
|
||||
|
||||
-- the cached Map objects own the per-map TileRenderer instances, so a flush
|
||||
-- that skipped this one would leave live SpriteBatches built from the old
|
||||
-- search path (14 cache-invalidation contract, rows 1 and 3)
|
||||
Assets.register(MapLoader.invalidateAll)
|
||||
|
||||
return MapLoader
|
||||
|
||||
+557
-174
File diff suppressed because it is too large
Load Diff
+19
-11
@@ -3,6 +3,7 @@
|
||||
-- at 1px per frame (16 frames per step), input locked while stepping.
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local Player = {}
|
||||
@@ -16,15 +17,21 @@ local TURN_FRAMES = 2
|
||||
|
||||
function Player.new(data, cx, cy, facing)
|
||||
local self = setmetatable({}, Player)
|
||||
self.sprite = SpriteRenderer.new(data.sprites.SPRITE_RED)
|
||||
-- the original surfs on the Seel sprite
|
||||
-- (LoadSurfingPlayerSpriteGraphics, home/overworld.asm)
|
||||
if data.sprites.SPRITE_SEEL then
|
||||
self.surfSprite = SpriteRenderer.new(data.sprites.SPRITE_SEEL)
|
||||
self.stepFrames = FieldDefaults.world(data, "stepFrames") or STEP_FRAMES
|
||||
self.bikeStepFrames = FieldDefaults.world(data, "bikeStepFrames")
|
||||
self.turnFrames = FieldDefaults.world(data, "turnFrames") or TURN_FRAMES
|
||||
-- field.playerSprites: which sprite ids the player wears on foot, on the
|
||||
-- water and on the bicycle (LoadPlayerSpriteGraphics /
|
||||
-- LoadSurfingPlayerSpriteGraphics, home/overworld.asm)
|
||||
local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk")
|
||||
local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf")
|
||||
local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike")
|
||||
self.sprite = SpriteRenderer.new(data.sprites[walkId])
|
||||
if surfId and data.sprites[surfId] then
|
||||
self.surfSprite = SpriteRenderer.new(data.sprites[surfId])
|
||||
end
|
||||
-- and cycles on the red_bike sheet (LoadPlayerSpriteGraphics)
|
||||
if data.sprites.SPRITE_RED_BIKE then
|
||||
self.bikeSprite = SpriteRenderer.new(data.sprites.SPRITE_RED_BIKE)
|
||||
if bikeId and data.sprites[bikeId] then
|
||||
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId])
|
||||
end
|
||||
-- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png,
|
||||
-- LedgeHoppingShadow, engine/overworld/ledges.asm)
|
||||
@@ -53,7 +60,7 @@ function Player:tryMove(dir, map, entities)
|
||||
if self.moving or self.inputLocked then return nil end
|
||||
if self.facing ~= dir then
|
||||
self.facing = dir
|
||||
self.turnTimer = TURN_FRAMES
|
||||
self.turnTimer = self.turnFrames or TURN_FRAMES
|
||||
return "turned"
|
||||
end
|
||||
if self.turnTimer > 0 then return nil end
|
||||
@@ -67,7 +74,8 @@ function Player:tryMove(dir, map, entities)
|
||||
self.progress = 0
|
||||
-- the bicycle doubles walking speed (8 frames per step)
|
||||
local save = require("src.core.Game").save
|
||||
self.stepFramesCur = (save and save.onBike) and 8 or STEP_FRAMES
|
||||
self.stepFramesCur = (save and save.onBike) and self.bikeStepFrames
|
||||
or self.stepFrames or STEP_FRAMES
|
||||
return "moved"
|
||||
end
|
||||
|
||||
@@ -77,7 +85,7 @@ function Player:update()
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
end
|
||||
if not self.moving then return false end
|
||||
local stepLen = self.stepFramesCur or STEP_FRAMES
|
||||
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
|
||||
self.progress = self.progress + 1
|
||||
local d = Collision.DELTA[self.facing]
|
||||
local px = math.floor(self.progress * 16 / stepLen)
|
||||
|
||||
+15
-1
@@ -9,6 +9,8 @@
|
||||
-- This mirrors pokered's CheckWarpsNoCollision / CheckWarpsCollision /
|
||||
-- ExtraWarpCheck (home/overworld.asm).
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Warp = {}
|
||||
|
||||
-- Returns the warp entry to take when arriving at (cx,cy), or nil.
|
||||
@@ -88,7 +90,7 @@ end
|
||||
-- map; the landing cell is that map's warp entry named by the warp id
|
||||
-- (wDestinationWarpID placement -- two-sided route gates land you on
|
||||
-- the side you exit, not where you entered).
|
||||
function Warp.destination(data, warpDef, lastMap)
|
||||
local function resolve(data, warpDef, lastMap)
|
||||
local destMap = warpDef.destMap
|
||||
if destMap == "LAST_MAP" then
|
||||
assert(lastMap, "LAST_MAP warp with no remembered outdoor map")
|
||||
@@ -108,4 +110,16 @@ function Warp.destination(data, warpDef, lastMap)
|
||||
return destMap, dw.x, dw.y
|
||||
end
|
||||
|
||||
-- the resolved destination passes through warp.destination, so a mod can
|
||||
-- reroute one door without owning the warp table (ctx carries the warp
|
||||
-- record and the remembered outdoor side the resolution used)
|
||||
local function warped(mapId, x, y) return mapId, x, y end
|
||||
|
||||
function Warp.destination(data, warpDef, lastMap)
|
||||
local destMap, x, y = resolve(data, warpDef, lastMap)
|
||||
if not Runtime.wantsHook("warp.destination") then return destMap, x, y end
|
||||
return Runtime.call("warp.destination", warped, destMap, x, y,
|
||||
{ warp = warpDef, lastMap = lastMap, data = data })
|
||||
end
|
||||
|
||||
return Warp
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
-- mod.world: the supported way for mod code to act on the running
|
||||
-- overworld. Every method resolves the live OverworldState by scanning
|
||||
-- the state stack for the isOverworld marker and returns nil, "no
|
||||
-- overworld" when none is up -- called from the title screen this is a
|
||||
-- quiet no-op, never a crash. Reaching into OverworldState internals
|
||||
-- stays unsupported; anything a mod legitimately needs belongs here.
|
||||
|
||||
local Logger = require("src.core.Logger")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
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 overworld, or nil. Game.overworld is the fast path; the stack
|
||||
-- scan is the authority, so a state pushed over the world (a battle, a
|
||||
-- menu) still resolves to the world underneath it.
|
||||
function WorldAPI:overworld()
|
||||
local game = self.game
|
||||
local stack = game and game.stack
|
||||
local states = stack and stack.states
|
||||
if states then
|
||||
for i = #states, 1, -1 do
|
||||
if states[i].isOverworld then return states[i] end
|
||||
end
|
||||
end
|
||||
local ow = game and game.overworld
|
||||
if ow and ow.isOverworld and ow.map then return ow end
|
||||
return nil
|
||||
end
|
||||
|
||||
function WorldAPI:current()
|
||||
local ow = self:overworld()
|
||||
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||
local p = ow.player
|
||||
return { mapId = ow.map.id, x = p and p.cellX, y = p and p.cellY,
|
||||
facing = p and p.facing }
|
||||
end
|
||||
|
||||
-- opts.arrive = "fly" | "teleport" picks the arrival FX; anything else
|
||||
-- lands the player without one, like a scripted warp.
|
||||
function WorldAPI:warpTo(mapId, x, y, facing, opts)
|
||||
local ow = self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
if not self.game.data.maps[mapId] then
|
||||
return nil, "unknown map: " .. tostring(mapId)
|
||||
end
|
||||
if opts and (opts.arrive == "fly" or opts.arrive == "teleport") then
|
||||
ow.arriveWarp = opts.arrive
|
||||
end
|
||||
ow:startWarpTo(mapId, x, y, facing or "down", opts and opts.onDone,
|
||||
{ via = "warp", keepMusic = opts and opts.keepMusic })
|
||||
return true
|
||||
end
|
||||
|
||||
-- save.objectToggles is the same store the spawn filter reads, so a toggle
|
||||
-- on an inactive map takes effect the next time it is entered.
|
||||
function WorldAPI:toggleObject(mapId, objName, visible)
|
||||
local save = self.game and self.game.save
|
||||
if not save then return nil, "no save" end
|
||||
save.objectToggles = save.objectToggles or {}
|
||||
save.objectToggles[mapId] = save.objectToggles[mapId] or {}
|
||||
save.objectToggles[mapId][objName] = visible and true or false
|
||||
Runtime.emit("world.object_toggled",
|
||||
{ mapId = mapId, objName = objName, visible = visible and true or false })
|
||||
local ow = self:overworld()
|
||||
if ow and ow.map and ow.map.id == mapId then
|
||||
ow:setMap(mapId, ow.player.cellX, ow.player.cellY, ow.player.facing,
|
||||
{ seamless = true, via = "reload", keepMusic = true })
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function WorldAPI:setFlag(name, value)
|
||||
local save = self.game and self.game.save
|
||||
if not save or not save.flags then return nil, "no save" end
|
||||
save.flags[name] = value
|
||||
return true
|
||||
end
|
||||
|
||||
function WorldAPI:getFlag(name)
|
||||
local save = self.game and self.game.save
|
||||
return save and save.flags and save.flags[name]
|
||||
end
|
||||
|
||||
-- active map only: this mutates the runtime Map and rebuilds the renderer.
|
||||
-- A layout change that must survive a reload belongs in a maps patch.
|
||||
function WorldAPI:replaceBlock(bx, by, block)
|
||||
local ow = self:overworld()
|
||||
if not ow or not ow.map then return nil, NO_OVERWORLD end
|
||||
ow:replaceBlock(bx, by, block)
|
||||
return true
|
||||
end
|
||||
|
||||
-- objDef uses the same shape as maps[].objects. Runtime objects are not
|
||||
-- serialized: a permanent NPC belongs in a maps patch, this is for
|
||||
-- scripted and dynamic actors the mod re-spawns on map.entered.
|
||||
function WorldAPI:spawnNpc(mapId, objDef)
|
||||
local ow = self:overworld()
|
||||
if not ow 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 ow:addRuntimeObject(mapId, copy, self.modId)
|
||||
end
|
||||
|
||||
function WorldAPI:removeNpc(npcId)
|
||||
local ow = self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
return ow:removeRuntimeObject(npcId, self.modId)
|
||||
end
|
||||
|
||||
-- a handle onto a live NPC: scriptMove / marchInPlace / face, which is
|
||||
-- everything the scripted-movement queue exposes
|
||||
local Handle = {}
|
||||
Handle.__index = Handle
|
||||
|
||||
function Handle:scriptMove(dir, tiles, onDone)
|
||||
self.ow:scriptMove(self.npc, dir, tiles or 1, onDone)
|
||||
return true
|
||||
end
|
||||
|
||||
function Handle:marchInPlace(onDone)
|
||||
self.ow:marchInPlace(self.npc, onDone)
|
||||
return true
|
||||
end
|
||||
|
||||
function Handle:face(dir)
|
||||
self.npc.facing = dir
|
||||
return true
|
||||
end
|
||||
|
||||
function Handle:position()
|
||||
return self.npc.cellX, self.npc.cellY
|
||||
end
|
||||
|
||||
function WorldAPI:npc(mapId, indexOrName)
|
||||
local ow = self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
if ow.map and ow.map.id ~= mapId then return nil, "map is not active" end
|
||||
for _, npc in ipairs(ow.npcs or {}) do
|
||||
if npc.def.index == indexOrName or npc.def.name == indexOrName
|
||||
or npc.id == indexOrName then
|
||||
return setmetatable({ ow = ow, npc = npc, id = npc.id }, Handle)
|
||||
end
|
||||
end
|
||||
return nil, "no such object: " .. tostring(indexOrName)
|
||||
end
|
||||
|
||||
-- FIFO queueing is owned by the script runner; until it lands this runs
|
||||
-- the rows when nothing else is running and refuses otherwise, so a mod
|
||||
-- never silently loses a script.
|
||||
function WorldAPI:queueScript(rows, extra)
|
||||
local ow = self:overworld()
|
||||
if not ow or not ow.runner then return nil, NO_OVERWORLD end
|
||||
if ow.runner:isRunning() then return nil, "a script is already running" end
|
||||
ow.runner:run(rows, extra)
|
||||
return true
|
||||
end
|
||||
|
||||
-- drop a map's cached instance so the next load re-reads its record; when
|
||||
-- it is the active map the world reloads around the player in place
|
||||
function WorldAPI:invalidateMap(mapId)
|
||||
local ow = self:overworld()
|
||||
if not ow then
|
||||
local had = MapLoader.invalidate(mapId)
|
||||
Runtime.emit("map.reloaded", { mapId = mapId, reason = "invalidate" })
|
||||
return had
|
||||
end
|
||||
local ok, err = pcall(ow.reloadMap, ow, mapId, "invalidate")
|
||||
if not ok then
|
||||
Logger.warn("[%s] invalidateMap %s failed: %s", tostring(self.modId),
|
||||
tostring(mapId), tostring(err))
|
||||
return nil, tostring(err)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return WorldAPI
|
||||
Reference in New Issue
Block a user