mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 11:11:10 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
-- Movement permission checks: tile passability (from generated collision
|
||||
-- data), map bounds, and entity occupancy.
|
||||
|
||||
local Collision = {}
|
||||
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
Collision.DELTA = DELTA
|
||||
|
||||
function Collision.target(cx, cy, dir)
|
||||
local d = DELTA[dir]
|
||||
return cx + d[1], cy + d[2]
|
||||
end
|
||||
|
||||
-- entities: array of anything with cellX/cellY (and optional targetX/targetY
|
||||
-- while mid-step, so nobody walks into a cell being entered).
|
||||
function Collision.occupied(entities, cx, cy, ignore)
|
||||
for _, e in ipairs(entities) do
|
||||
if e ~= ignore then
|
||||
if (e.cellX == cx and e.cellY == cy) or
|
||||
(e.targetX == cx and e.targetY == cy) then
|
||||
return e
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Tile-pair (elevation) collisions: certain tile pairs can't be crossed
|
||||
-- in a given tileset (cave/forest ledges). data set via Collision.load.
|
||||
local tilePairs = nil
|
||||
|
||||
function Collision.load(data)
|
||||
tilePairs = data.field and data.field.tilePairs or { land = {}, water = {} }
|
||||
end
|
||||
|
||||
local function pairBlocked(map, mover, sx, sy, tx, ty)
|
||||
if not tilePairs then return false end
|
||||
local list = mover.surfing and tilePairs.water or tilePairs.land
|
||||
if not list or #list == 0 then return false end
|
||||
local tileset = map.def.tileset
|
||||
local a = map:cellTile(sx, sy)
|
||||
local b = map:cellTile(tx, ty)
|
||||
for _, p in ipairs(list) do
|
||||
if p.tileset == tileset
|
||||
and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
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)
|
||||
if not map:inBounds(tx, ty) then
|
||||
return false, "bounds"
|
||||
end
|
||||
if not map:isWalkableCell(tx, ty) then
|
||||
-- surfers may ride water cells
|
||||
if not (mover.surfing and map:isWaterCell(tx, ty)) then
|
||||
return false, "tile"
|
||||
end
|
||||
end
|
||||
if pairBlocked(map, mover, mover.cellX, mover.cellY, tx, ty) then
|
||||
return false, "tile"
|
||||
end
|
||||
if Collision.occupied(entities, tx, ty, mover) then
|
||||
return false, "entity"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return Collision
|
||||
@@ -0,0 +1,95 @@
|
||||
-- ShakeElevator (pokered engine/overworld/elevator.asm), run after
|
||||
-- DisplayElevatorFloorMenu picks a floor (the elevator map script fires
|
||||
-- it via BIT_CUR_MAP_USED_ELEVATOR):
|
||||
--
|
||||
-- * lead-in: ShakeElevator's two ShakeElevatorRedrawRow calls (each
|
||||
-- ends in Delay3) plus its own Delay3 are 9 frames; the SilphCo /
|
||||
-- RocketHideout ...ShakeScripts prefix one more Delay3 (12 total)
|
||||
-- while Celadon's farjps straight in (9). The row redraws
|
||||
-- themselves are a VRAM patch with no port equivalent -- only their
|
||||
-- delays are kept.
|
||||
-- * SFX_STOP_ALL_MUSIC: the map theme cuts out for the ride.
|
||||
-- * 100 loop iterations, 2 frames each (`ld b, 100` / `ld c, 2` +
|
||||
-- DelayFrames): `e ^= $fe` flips e between $01 and $ff, so
|
||||
-- hSCY = rest + e alternates -1 / +1 around the resting scroll
|
||||
-- (first offset -1), and SFX_COLLISION plays every iteration.
|
||||
-- * hSCY restored, SFX_STOP_ALL_MUSIC again, then SFX_SAFARI_ZONE_PA
|
||||
-- plays and .musicLoop busy-waits on wChannelSoundIDs+CHAN5 until
|
||||
-- it ends.
|
||||
-- * UpdateSprites + PlayDefaultMusic: the map theme restarts.
|
||||
--
|
||||
-- SCY scrolls the BG layer only -- OAM sprites stay put -- so this
|
||||
-- state drives ow.bgShakeY, which OverworldState:drawWorld adds to the
|
||||
-- tile layers and not to the sprites. While it sits on the stack the
|
||||
-- overworld below neither updates nor takes input, like the original's
|
||||
-- blocking loop.
|
||||
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local ElevatorShake = {}
|
||||
ElevatorShake.__index = ElevatorShake
|
||||
|
||||
local CYCLES = 100 -- ld b, 100
|
||||
local FRAMES_PER_CYCLE = 2 -- ld c, 2 / call DelayFrames
|
||||
|
||||
-- opts.preFrames: lead-in delay frames (12 Silph/Rocket, 9 Celadon);
|
||||
-- opts.onDone: called once the ride is over (the floor warp)
|
||||
function ElevatorShake.new(game, ow, opts)
|
||||
opts = opts or {}
|
||||
return setmetatable({
|
||||
game = game,
|
||||
ow = ow,
|
||||
preFrames = opts.preFrames or 12,
|
||||
onDone = opts.onDone,
|
||||
phase = "pre",
|
||||
frames = 0,
|
||||
offset = 1, -- ld e, $1; the first `xor $fe` flips it to -1
|
||||
}, ElevatorShake)
|
||||
end
|
||||
|
||||
function ElevatorShake:update()
|
||||
if self.phase == "pre" then
|
||||
if self.frames < self.preFrames then
|
||||
self.frames = self.frames + 1
|
||||
return
|
||||
end
|
||||
-- SFX_STOP_ALL_MUSIC: the theme stops just before the first scroll
|
||||
-- write, in the same frame slice
|
||||
require("src.core.Music").stop()
|
||||
self.phase = "shake"
|
||||
self.frames = 0
|
||||
end
|
||||
if self.phase == "shake" then
|
||||
if self.frames % FRAMES_PER_CYCLE == 0 then
|
||||
-- one .shakeLoop iteration: flip the offset, write the scroll,
|
||||
-- retrigger SFX_COLLISION
|
||||
self.offset = -self.offset
|
||||
self.ow.bgShakeY = self.offset
|
||||
Sound.play(self.game.data, "Collision")
|
||||
end
|
||||
self.frames = self.frames + 1
|
||||
if self.frames >= CYCLES * FRAMES_PER_CYCLE then
|
||||
-- ld a, d / ldh [hSCY], a: back to the resting scroll, then the
|
||||
-- arrival chime
|
||||
self.ow.bgShakeY = 0
|
||||
if Sound.stop then Sound.stop("Collision") end -- SFX_STOP_ALL_MUSIC
|
||||
Sound.play(self.game.data, "Safari_Zone_PA")
|
||||
self.phase = "pa"
|
||||
end
|
||||
return
|
||||
end
|
||||
-- .musicLoop: hold until SFX_SAFARI_ZONE_PA finishes (headless the
|
||||
-- sound never starts, so this resolves on the next frame)
|
||||
if Sound.isPlaying and Sound.isPlaying("Safari_Zone_PA") then return end
|
||||
require("src.core.Music").restoreMap(self.game.data) -- PlayDefaultMusic
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
-- safety: never leave a scroll offset behind if popped early
|
||||
-- (e.g. Game:returnToTitle popping the whole stack)
|
||||
function ElevatorShake:exit()
|
||||
if self.ow then self.ow.bgShakeY = 0 end
|
||||
end
|
||||
|
||||
return ElevatorShake
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Wild encounters from generated encounter tables.
|
||||
-- Gen 1: on each step into a grass/water cell, a battle starts when
|
||||
-- rand(0..255) < map encounter rate; the slot is picked with the original
|
||||
-- probability buckets.
|
||||
|
||||
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 }
|
||||
|
||||
function Encounter.roll(encounterDef, rng)
|
||||
rng = rng or love.math.random
|
||||
if not encounterDef then return nil end
|
||||
local grass = encounterDef.grass
|
||||
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
|
||||
if pick < threshold then
|
||||
local slot = grass.slots[i]
|
||||
if slot then
|
||||
return { species = slot.species, level = slot.level }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return Encounter
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Runtime map built from generated data. All queries use "cells": the
|
||||
-- 16x16 walk grid (2x2 tiles). A map is width x height blocks; each block
|
||||
-- is 2x2 cells (4x4 tiles).
|
||||
--
|
||||
-- Collision follows the original engine: a cell is passable when the
|
||||
-- BOTTOM-LEFT 8x8 tile of the cell is in the tileset's walkable list
|
||||
-- (pokered checks the tile at the sprite's feet). Doors, warp tiles and
|
||||
-- grass use the same convention.
|
||||
|
||||
local Map = {}
|
||||
Map.__index = Map
|
||||
|
||||
function Map.new(def, tilesetDef)
|
||||
local self = setmetatable({}, Map)
|
||||
self.def = def
|
||||
self.tileset = tilesetDef
|
||||
self.id = def.id
|
||||
self.widthCells = def.width * 2
|
||||
self.heightCells = def.height * 2
|
||||
|
||||
self.walkable = {}
|
||||
for _, t in ipairs(tilesetDef.walkable) do self.walkable[t] = true end
|
||||
self.doorTiles = {}
|
||||
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
|
||||
|
||||
self.warpAt = {}
|
||||
for i, w in ipairs(def.warps) do
|
||||
self.warpAt[w.y * self.widthCells + w.x] = { index = i, def = w }
|
||||
end
|
||||
self.signAt = {}
|
||||
for _, s in ipairs(def.signs) do
|
||||
self.signAt[s.y * self.widthCells + s.x] = s
|
||||
end
|
||||
return self
|
||||
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
|
||||
end
|
||||
return self.def.blocks[by * self.def.width + bx + 1]
|
||||
end
|
||||
|
||||
-- tile id at tile coordinates (8px grid), border-extended
|
||||
function Map:tileAt(tx, ty)
|
||||
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
|
||||
local block = self.tileset.blocks[self:blockAt(bx, by) + 1]
|
||||
local ix = (ty % 4) * 4 + (tx % 4) + 1
|
||||
return block[ix]
|
||||
end
|
||||
|
||||
-- the collision tile of a cell: bottom-left 8x8 tile
|
||||
function Map:cellTile(cx, cy)
|
||||
return self:tileAt(cx * 2, cy * 2 + 1)
|
||||
end
|
||||
|
||||
function Map:inBounds(cx, cy)
|
||||
return cx >= 0 and cy >= 0 and cx < self.widthCells and cy < self.heightCells
|
||||
end
|
||||
|
||||
function Map:isWalkableCell(cx, cy)
|
||||
return self.walkable[self:cellTile(cx, cy)] or false
|
||||
end
|
||||
|
||||
function Map:isGrassCell(cx, cy)
|
||||
local grass = self.tileset.grassTile
|
||||
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.
|
||||
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
|
||||
end
|
||||
|
||||
-- Replace a block (Cut trees); the caller rebuilds the renderer.
|
||||
function Map:setBlock(bx, by, block)
|
||||
if bx < 0 or by < 0 or bx >= self.def.width or by >= self.def.height then
|
||||
return
|
||||
end
|
||||
self.def.blocks[by * self.def.width + bx + 1] = block
|
||||
end
|
||||
|
||||
-- true if the cell's collision tile is a door or warp-activating tile
|
||||
function Map:isWarpTileCell(cx, cy)
|
||||
local t = self:cellTile(cx, cy)
|
||||
return self.doorTiles[t] or self.warpTiles[t] or false
|
||||
end
|
||||
|
||||
-- counter tiles allow talking to NPCs across them (mart clerks, nurses)
|
||||
function Map:isCounterCell(cx, cy)
|
||||
local t = self:cellTile(cx, cy)
|
||||
for _, c in ipairs(self.tileset.counterTiles or {}) do
|
||||
if c == t then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Map:warpAtCell(cx, cy)
|
||||
return self.warpAt[cy * self.widthCells + cx]
|
||||
end
|
||||
|
||||
function Map:signAtCell(cx, cy)
|
||||
return self.signAt[cy * self.widthCells + cx]
|
||||
end
|
||||
|
||||
function Map:connection(dir)
|
||||
return self.def.connections and self.def.connections[dir]
|
||||
end
|
||||
|
||||
return Map
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Builds runtime Map objects (and their tile SpriteBatches) from generated
|
||||
-- data, cached by map id.
|
||||
|
||||
local Map = require("src.world.Map")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
|
||||
local MapLoader = {}
|
||||
|
||||
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))
|
||||
local tilesetDef = data.tilesets[def.tileset]
|
||||
assert(tilesetDef, "unknown tileset: " .. tostring(def.tileset))
|
||||
|
||||
-- warp tiles are stored per tileset macro name; the generated tilesets
|
||||
-- module carries them in the tileset entry itself
|
||||
local map = Map.new(def, tilesetDef)
|
||||
map.renderer = TileRenderer.new(map)
|
||||
cache[mapId] = map
|
||||
return map
|
||||
end
|
||||
|
||||
function MapLoader.clearCache()
|
||||
cache = {}
|
||||
end
|
||||
|
||||
return MapLoader
|
||||
@@ -0,0 +1,109 @@
|
||||
-- Map object (NPC/item) built from a generated object_event entry.
|
||||
-- STAY objects keep their facing; WALK objects wander randomly within the
|
||||
-- roam constraint (ANY_DIR / UP_DOWN / LEFT_RIGHT), like the original.
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local NPC = {}
|
||||
NPC.__index = NPC
|
||||
|
||||
local STEP_FRAMES = 16
|
||||
|
||||
local FACING_FROM_RANGE = {
|
||||
DOWN = "down", UP = "up", LEFT = "left", RIGHT = "right",
|
||||
}
|
||||
|
||||
local ROAM_DIRS = {
|
||||
ANY_DIR = { "up", "down", "left", "right" },
|
||||
UP_DOWN = { "up", "down" },
|
||||
LEFT_RIGHT = { "left", "right" },
|
||||
}
|
||||
|
||||
function NPC.new(data, mapId, objDef)
|
||||
local self = setmetatable({}, NPC)
|
||||
self.def = objDef
|
||||
self.id = string.format("%s_obj_%d", mapId, objDef.index)
|
||||
local spriteDef = data.sprites[objDef.sprite]
|
||||
assert(spriteDef, "unknown sprite " .. tostring(objDef.sprite))
|
||||
self.sprite = SpriteRenderer.new(spriteDef)
|
||||
-- object_event coordinates are already walk-grid cells
|
||||
self.cellX, self.cellY = objDef.x, objDef.y
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.facing = FACING_FROM_RANGE[objDef.range] or "down"
|
||||
self.moving = false
|
||||
self.progress = 0
|
||||
self.stepFlip = false
|
||||
self.frozen = false -- scripts freeze NPCs while talking
|
||||
self.wanders = objDef.movement == "WALK"
|
||||
self.roamDirs = ROAM_DIRS[objDef.range] or ROAM_DIRS.ANY_DIR
|
||||
self.timer = love.math.random(30, 120)
|
||||
return self
|
||||
end
|
||||
|
||||
function NPC:facePlayer(player)
|
||||
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:update(map, entities)
|
||||
if self.moving then
|
||||
self.progress = self.progress + 1
|
||||
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
|
||||
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
|
||||
-- pinned to the current cell while walkPhase() cycles.
|
||||
if self.marching then
|
||||
if self.progress >= STEP_FRAMES then
|
||||
self.progress = 0
|
||||
self.moving = false
|
||||
self.marching = false
|
||||
self.stepFlip = not self.stepFlip
|
||||
end
|
||||
return
|
||||
end
|
||||
local d = Collision.DELTA[self.facing]
|
||||
self.px = self.cellX * 16 + d[1] * self.progress
|
||||
self.py = self.cellY * 16 + d[2] * self.progress
|
||||
if self.progress >= STEP_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.stepFlip = not self.stepFlip
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.frozen or not self.wanders then return end
|
||||
self.timer = self.timer - 1
|
||||
if self.timer > 0 then return end
|
||||
self.timer = love.math.random(30, 180)
|
||||
local dir = self.roamDirs[love.math.random(#self.roamDirs)]
|
||||
self.facing = dir
|
||||
if love.math.random() < 0.5 then return end -- sometimes just turn
|
||||
-- never wander onto warps, so NPCs don't walk out of the map
|
||||
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
|
||||
if map:warpAtCell(tx, ty) then return end
|
||||
if Collision.canMove(map, entities, self, dir) then
|
||||
self.targetX, self.targetY = tx, ty
|
||||
self.moving = true
|
||||
self.progress = 0
|
||||
end
|
||||
end
|
||||
|
||||
function NPC:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
local p = self.progress % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
end
|
||||
|
||||
function NPC:draw(camX, camY)
|
||||
self.sprite:draw(self.px, self.py, camX, camY, self.facing,
|
||||
self:walkPhase(), self.stepFlip)
|
||||
end
|
||||
|
||||
return NPC
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,145 @@
|
||||
-- The player: tile-grid movement with pixel interpolation, faithful to the
|
||||
-- original's feel: facing changes on a short tap, movement is tile-by-tile
|
||||
-- at 1px per frame (16 frames per step), input locked while stepping.
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local Player = {}
|
||||
Player.__index = Player
|
||||
|
||||
local STEP_FRAMES = 16
|
||||
-- a turn in place holds for the ~2 frames the original spends on the
|
||||
-- extra OverworldLoop pass (home/overworld.asm .handleDirectionButtonPress
|
||||
-- returns to the loop without moving after a direction change)
|
||||
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)
|
||||
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)
|
||||
end
|
||||
-- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png,
|
||||
-- LedgeHoppingShadow, engine/overworld/ledges.asm)
|
||||
local fx = data.field and data.field.overworldFx
|
||||
if fx and fx.shadow then
|
||||
local ok, img = pcall(love.graphics.newImage, fx.shadow.path)
|
||||
self.shadowImg = ok and img or nil
|
||||
end
|
||||
self.cellX, self.cellY = cx, cy
|
||||
self.px, self.py = cx * 16, cy * 16
|
||||
self.facing = facing or "down"
|
||||
self.moving = false
|
||||
self.progress = 0
|
||||
self.stepFlip = false
|
||||
self.turnTimer = 0
|
||||
self.inputLocked = false
|
||||
return self
|
||||
end
|
||||
|
||||
function Player:position()
|
||||
return self.cellX, self.cellY
|
||||
end
|
||||
|
||||
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
|
||||
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
|
||||
return "turned"
|
||||
end
|
||||
if self.turnTimer > 0 then return nil end
|
||||
local ok, why = Collision.canMove(map, entities, self, dir)
|
||||
if not ok then
|
||||
return "blocked", why
|
||||
end
|
||||
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
|
||||
self.targetX, self.targetY = tx, ty
|
||||
self.moving = true
|
||||
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
|
||||
return "moved"
|
||||
end
|
||||
|
||||
-- Advance one fixed step; returns true when a step just completed.
|
||||
function Player:update()
|
||||
if self.turnTimer > 0 then
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
end
|
||||
if not self.moving then return false end
|
||||
local stepLen = self.stepFramesCur or STEP_FRAMES
|
||||
self.progress = self.progress + 1
|
||||
local d = Collision.DELTA[self.facing]
|
||||
local px = math.floor(self.progress * 16 / stepLen)
|
||||
self.px = self.cellX * 16 + d[1] * px
|
||||
self.py = self.cellY * 16 + d[2] * px
|
||||
if self.progress >= stepLen 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.stepFlip = not self.stepFlip
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Player:facingCell()
|
||||
return Collision.target(self.cellX, self.cellY, self.facing)
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
-- walk frame during the middle of the step
|
||||
local p = self.progress % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
end
|
||||
|
||||
local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
|
||||
function Player:draw(camX, camY)
|
||||
local py = self.py
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
|
||||
if self.hopFrames and self.hopFrames > 0 then
|
||||
self.hopFrames = self.hopFrames - 1
|
||||
local t = 1 - self.hopFrames / (self.hopTotal or 32)
|
||||
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
||||
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
|
||||
-- is 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
|
||||
-- LedgeHoppingShadowOAMBlock, engine/overworld/ledges.asm)
|
||||
if self.shadowImg then
|
||||
local sx = math.floor(self.px - camX)
|
||||
local sy = math.floor(self.py - camY) - 4 + 8
|
||||
love.graphics.draw(self.shadowImg, sx, sy)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
|
||||
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
||||
end
|
||||
elseif self.surfing then
|
||||
self.bobTimer = ((self.bobTimer or 0) + 1) % 32
|
||||
py = py + (self.bobTimer < 16 and 0 or 1)
|
||||
end
|
||||
local facing = self.facing
|
||||
if self.spinning then
|
||||
-- spinner tiles whirl the sprite (PlayerSpinningFacingOrder)
|
||||
self.spinTimer = (self.spinTimer or 0) + 1
|
||||
facing = SPIN_ORDER[math.floor(self.spinTimer / 4) % 4 + 1]
|
||||
end
|
||||
local sprite = (self.surfing and self.surfSprite)
|
||||
or (self.onBike and self.bikeSprite) or self.sprite
|
||||
sprite:draw(self.px, py, camX, camY, facing,
|
||||
self:walkPhase(), self.stepFlip)
|
||||
end
|
||||
|
||||
return Player
|
||||
@@ -0,0 +1,111 @@
|
||||
-- Warp resolution. A warp fires when:
|
||||
-- * the player finishes a step onto a warp cell whose collision tile is a
|
||||
-- door tile or warp tile (stairs, doors, mats, cave entrances), or
|
||||
-- * the player stands on a warp cell and tries to walk off the map edge
|
||||
-- (exit carpets at the bottom of interiors), or
|
||||
-- * the player stands on a warp cell and the "extra" check passes -- on
|
||||
-- arrival with the d-pad held, or on a blocked step (route-gate
|
||||
-- doorways, the Vermilion dock entrance, ...).
|
||||
-- This mirrors pokered's CheckWarpsNoCollision / CheckWarpsCollision /
|
||||
-- ExtraWarpCheck (home/overworld.asm).
|
||||
|
||||
local Warp = {}
|
||||
|
||||
-- Returns the warp entry to take when arriving at (cx,cy), or nil.
|
||||
function Warp.onArrive(map, cx, cy)
|
||||
local w = map:warpAtCell(cx, cy)
|
||||
if w and map:isWarpTileCell(cx, cy) then
|
||||
return w
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function inList(list, v)
|
||||
for _, x in ipairs(list) do
|
||||
if x == v then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ExtraWarpCheck: may the player standing at (cx,cy) facing dir warp
|
||||
-- without a door/warp tile underfoot? On the carpet maps/tilesets the
|
||||
-- tile in FRONT of the player must be a warp-carpet tile for the facing
|
||||
-- direction (IsWarpTileInFrontOfPlayer; SS_ANNE_BOW tests one hardcoded
|
||||
-- tile instead); everywhere else the player must face the map edge
|
||||
-- (IsPlayerFacingEdgeOfMap). carpets = field.warpCarpets.
|
||||
function Warp.extraCheck(map, carpets, cx, cy, dir)
|
||||
local Collision = require("src.world.Collision")
|
||||
local facingEdge =
|
||||
(dir == "up" and cy == 0)
|
||||
or (dir == "down" and cy == map.heightCells - 1)
|
||||
or (dir == "left" and cx == 0)
|
||||
or (dir == "right" and cx == map.widthCells - 1)
|
||||
if not carpets then return facingEdge end
|
||||
-- the map exceptions are tested before the tileset (ExtraWarpCheck)
|
||||
local useCarpet
|
||||
if inList(carpets.edgeMaps, map.id) then
|
||||
useCarpet = false
|
||||
elseif inList(carpets.function2Maps, map.id) then
|
||||
useCarpet = true
|
||||
else
|
||||
useCarpet = inList(carpets.function2Tilesets, map.def.tileset)
|
||||
end
|
||||
if not useCarpet then return facingEdge end
|
||||
local tx, ty = Collision.target(cx, cy, dir)
|
||||
local front = map:cellTile(tx, ty)
|
||||
if map.id == carpets.ssAnneBow.map then
|
||||
return front == carpets.ssAnneBow.tile
|
||||
end
|
||||
return inList(carpets.tiles[dir], front)
|
||||
end
|
||||
|
||||
-- Returns the warp entry when standing on (cx,cy) and the extra check
|
||||
-- passes toward dir (fired from a blocked step, or on arrival with the
|
||||
-- d-pad held).
|
||||
function Warp.onCollision(map, carpets, cx, cy, dir)
|
||||
local w = map:warpAtCell(cx, cy)
|
||||
if w and Warp.extraCheck(map, carpets, cx, cy, dir) then
|
||||
return w
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Returns the warp entry when standing on (cx,cy) and moving toward dir
|
||||
-- takes the player out of bounds.
|
||||
function Warp.onEdge(map, cx, cy, dir)
|
||||
local w = map:warpAtCell(cx, cy)
|
||||
if not w then return nil end
|
||||
local Collision = require("src.world.Collision")
|
||||
local tx, ty = Collision.target(cx, cy, dir)
|
||||
if not map:inBounds(tx, ty) then
|
||||
return w
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Resolve a warp's destination to map id + cell. LAST_MAP destinations
|
||||
-- (returning from an interior) resolve against the remembered outdoor
|
||||
-- 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 destMap = warpDef.destMap
|
||||
if destMap == "LAST_MAP" then
|
||||
assert(lastMap, "LAST_MAP warp with no remembered outdoor map")
|
||||
destMap = lastMap.id
|
||||
local destDef = data.maps[destMap]
|
||||
local dw = destDef and destDef.warps[warpDef.destWarp]
|
||||
if dw then
|
||||
return destMap, dw.x, dw.y
|
||||
end
|
||||
-- out-of-range data: fall back to where the player entered
|
||||
return destMap, lastMap.x, lastMap.y
|
||||
end
|
||||
local destDef = data.maps[destMap]
|
||||
assert(destDef, "warp to unknown map " .. tostring(destMap))
|
||||
local dw = destDef.warps[warpDef.destWarp]
|
||||
assert(dw, ("warp to %s#%d out of range"):format(destMap, warpDef.destWarp))
|
||||
return destMap, dw.x, dw.y
|
||||
end
|
||||
|
||||
return Warp
|
||||
Reference in New Issue
Block a user