mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
302 lines
13 KiB
Lua
302 lines
13 KiB
Lua
-- 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 FieldDefaults = require("src.world.FieldDefaults")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local SpriteRenderer = require("src.render.SpriteRenderer")
|
|
|
|
local Player = {}
|
|
Player.__index = Player
|
|
|
|
local STEP_FRAMES = 16
|
|
-- a turn in place blocks movement for the one extra OverworldLoop pass the
|
|
-- original spends after a direction change: .handleDirectionButtonPress ends
|
|
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
|
|
-- DelayFrame calls before the next JoypadOverworld, so the sample that can
|
|
-- commit to a step lands exactly 2 fixed steps after the turn -- the same
|
|
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above
|
|
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415)
|
|
local TURN_FRAMES = 2
|
|
|
|
function Player.new(data, cx, cy, facing)
|
|
local self = setmetatable({}, Player)
|
|
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], "player")
|
|
if surfId and data.sprites[surfId] then
|
|
self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player")
|
|
end
|
|
if bikeId and data.sprites[bikeId] then
|
|
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player")
|
|
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
|
|
-- FishingAnim (engine/overworld/player_animations.asm) patches tiles
|
|
-- $02/$06/$0a -- the bottom tile row of each standing frame -- with
|
|
-- RedFishingTiles before it parks the rod OAM, so the rod stroke meets a
|
|
-- pair of hands instead of ending in mid air (#384)
|
|
if fx then
|
|
local function posePath(name)
|
|
local def = fx[name]
|
|
return def and def.path or nil
|
|
end
|
|
local pose = { down = posePath("redFishFront"), up = posePath("redFishBack") }
|
|
pose.left = posePath("redFishSide")
|
|
pose.right = pose.left -- the side pose mirrors like the sprite (OAM_XFLIP)
|
|
if pose.down or pose.up or pose.left then self.fishTiles = pose end
|
|
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 = self.turnFrames or TURN_FRAMES
|
|
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
|
|
return "turned"
|
|
end
|
|
if self.turnTimer > 0 then return nil end
|
|
local ok, why = Collision.canMove(map, entities, self, dir)
|
|
if not ok then
|
|
-- Gen1: a blocked step still animates the player walking in place --
|
|
-- the collision path spends the step's worth of frames running
|
|
-- UpdateSprites before returning control, so the legs cycle without
|
|
-- the cell changing (home/overworld.asm collision handling; issue
|
|
-- #230). Re-armed every frame the direction is held into the wall;
|
|
-- Player:update ticks the walk clock while it counts down, so releasing
|
|
-- returns to the standing pose within a step's length.
|
|
self.bumpFrames = self.stepFrames or STEP_FRAMES
|
|
return "blocked", why
|
|
end
|
|
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
|
|
self.targetX, self.targetY = tx, ty
|
|
self.moving = true
|
|
self.bumpFrames = nil -- a real step supersedes any in-place bonk
|
|
self.progress = 0
|
|
-- the bicycle doubles walking speed (8 frames per step); movement.speed
|
|
-- lets a mod multiply or replace that (running shoes, dash, etc.)
|
|
local Game = require("src.core.Game")
|
|
local save = Game.save
|
|
local frames = (save and save.onBike) and self.bikeStepFrames
|
|
or self.stepFrames or STEP_FRAMES
|
|
if Runtime.wantsHook("movement.speed") then
|
|
frames = Runtime.call("movement.speed", function(f) return f end, frames, {
|
|
onBike = save and save.onBike or false,
|
|
surfing = self.surfing and true or false,
|
|
player = self,
|
|
input = Game.input,
|
|
save = save,
|
|
})
|
|
end
|
|
self.stepFramesCur = math.max(1, math.floor(tonumber(frames) or STEP_FRAMES))
|
|
return "moved"
|
|
end
|
|
|
|
-- Advance one fixed step; returns true when a step just completed.
|
|
function Player:update()
|
|
-- land-frame walk pose lasts only through the draw after completion;
|
|
-- the next update (idle or a chained step) clears it
|
|
self.stepLanded = false
|
|
-- Ledge-hop arc is cosmetic but must track the fixed 60Hz logic step,
|
|
-- not love.draw's display refresh (issue #4: >59fps ended early).
|
|
if self.hopFrames and self.hopFrames > 0 then
|
|
self.hopFrames = self.hopFrames - 1
|
|
end
|
|
if self.turnTimer > 0 then
|
|
self.turnTimer = self.turnTimer - 1
|
|
end
|
|
if self.spinFrames then
|
|
self.spinFrames = self.spinFrames - 1
|
|
if self.spinFrames <= 0 then
|
|
self.spinFrames = nil
|
|
self.spinDrop = nil
|
|
self.spinRise = nil -- teleport-out departure lift (#196)
|
|
self.spinning = false
|
|
end
|
|
end
|
|
-- wall-bonk walk-in-place (issue #230): while pushing into a wall the
|
|
-- collision path keeps the walk clock running without moving the cell,
|
|
-- so the sprite animates against the wall. Guarded on not-moving so a
|
|
-- real step (which clears bumpFrames and advances animClock itself
|
|
-- below) can never double-tick the leg cadence.
|
|
if not self.moving and self.bumpFrames and self.bumpFrames > 0 then
|
|
self.bumpFrames = self.bumpFrames - 1
|
|
self.animClock = (self.animClock or 0) + 1
|
|
end
|
|
if not self.moving then return false end
|
|
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
|
|
self.progress = self.progress + 1
|
|
-- the walk-cycle clock ticks once per real frame while moving, so the
|
|
-- leg cadence stays constant when the bike halves stepFramesCur (only
|
|
-- translation speed doubles, like UpdatePlayerSprite's frame counters)
|
|
self.animClock = (self.animClock or 0) + 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
|
|
-- keep animClock's pose on this frame (issue #82): bike steps land
|
|
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
|
|
-- stand whenever moving cleared -- a stand flash every tile on the
|
|
-- bike, and sometimes after dismount when the clock is desynced
|
|
self.stepLanded = true
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
function Player:facingCell()
|
|
return Collision.target(self.cellX, self.cellY, self.facing)
|
|
end
|
|
|
|
function Player:walkPhase()
|
|
-- moving, the land-frame after a completed step, or an active wall-bonk
|
|
-- (issue #230) animate; a standing sprite otherwise
|
|
if not self.moving and not self.stepLanded
|
|
and not (self.bumpFrames and self.bumpFrames > 0) then
|
|
return 0
|
|
end
|
|
-- walk frame during the middle of each 16-frame animation cycle
|
|
local p = (self.animClock or self.progress) % 16
|
|
return (p >= 4 and p < 12) and 1 or 0
|
|
end
|
|
|
|
local SPIN_ORDER = { "down", "left", "up", "right" }
|
|
|
|
-- What this frame renders to: the sheet, where it sits, which way it faces
|
|
-- and how far through a step it is. Shared by the 2D draw below and by a
|
|
-- render pipeline's own geometry (src/render/Pipelines.lua), so the two can
|
|
-- never disagree about which sprite or facing is current.
|
|
--
|
|
-- The last return says the player is mid-ledge-hop, which is what the 2D
|
|
-- path draws the ground shadow from and a 3D path turns into vertical lift.
|
|
--
|
|
-- This ADVANCES the surf-bob and spinner timers, so exactly one of pose()
|
|
-- and draw() may run per frame -- and draw() is written in terms of pose()
|
|
-- to keep that true by construction. (hopFrames counts down in
|
|
-- Player:update, on the fixed step, so it is safe to read here.)
|
|
function Player:pose()
|
|
local py = self.py
|
|
local hopping = false
|
|
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
|
|
if self.hopFrames and self.hopFrames > 0 then
|
|
local total = self.hopTotal or 32
|
|
-- update runs before draw, so remaining N means N steps already
|
|
-- consumed this hop → t matches the old draw-side post-decrement phase
|
|
local t = 1 - self.hopFrames / total
|
|
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
|
hopping = true
|
|
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
|
|
local phase = self:walkPhase()
|
|
-- alternate walk cycles mirror the up/down frame; derived from the
|
|
-- fixed-rate animation clock so the bike's shorter steps don't double
|
|
-- the leg cadence
|
|
local flip = math.floor((self.animClock or 0) / 16) % 2 == 1
|
|
if self.spinning then
|
|
-- spinner tiles whirl the sprite on its standing pose, one facing
|
|
-- per frame (LoadSpinnerArrowTiles runs every OverworldLoop frame)
|
|
self.spinTimer = (self.spinTimer or 0) + 1
|
|
facing = SPIN_ORDER[self.spinTimer % 4 + 1]
|
|
phase, flip = 0, false
|
|
-- teleport arrivals spin the sprite down into place
|
|
-- (EnterMapAnim PlayerSpinWhileMovingDown)
|
|
if self.spinFrames and self.spinDrop then
|
|
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
|
|
elseif self.spinFrames and self.spinRise then
|
|
-- Dig/Teleport/Escape-Rope departures spin the sprite UP out of the
|
|
-- map before the fade (LeaveMapAnim PlayerSpinWhileMovingUp) -- the
|
|
-- mirror of the arrival spin-down: the lift grows from 0 as spinFrames
|
|
-- counts down to 0 (#196), opposite sign to spinDrop above.
|
|
local total = self.spinTotal or 64
|
|
py = py - math.floor((total - self.spinFrames) * 24 / total)
|
|
end
|
|
end
|
|
-- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState
|
|
-- across FishingAnim, so casting from the water shows the on-foot sheet
|
|
local sprite = (self.fishing and self.sprite)
|
|
or (self.surfing and self.surfSprite)
|
|
or (self.onBike and self.bikeSprite) or self.sprite
|
|
return sprite, self.px, py, facing, phase, flip, hopping
|
|
end
|
|
|
|
function Player:draw(camX, camY)
|
|
local sprite, px, py, facing, phase, flip, hopping = self:pose()
|
|
-- the shadow stays on the ground under the jumper, mirrored out of the
|
|
-- single 8x8 tile the ROM stores -- but the two engines lay it out
|
|
-- differently, and their shadow.png tiles differ to match.
|
|
-- RED/BLUE: a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left sits
|
|
-- 8px below the sprite's standing top-left (LoadHoppingShadowOAM +
|
|
-- LedgeHoppingShadowOAMBlock at "lb bc, $54, $48",
|
|
-- engine/overworld/ledges.asm); its tile is blank above the bottom
|
|
-- four rows, so the four copies make one 16x16 ellipse.
|
|
-- YELLOW: a single 16x8 row 4px lower. Its LoadHoppingShadowOAM
|
|
-- copies only two entries (LedgeHoppingShadowOAM: dbsprite 9,11 and
|
|
-- dbsprite 10,11 OAM_XFLIP, raw OAM y=88 against RED's $54=84) and
|
|
-- parks sprites 38/39 offscreen at y=$a0, because its tile is a
|
|
-- full-height half-ellipse that already fills the row. Mirroring
|
|
-- that tile downward stacked a second blob under the first (#408).
|
|
if hopping and self.shadowImg then
|
|
local yellow = GameVersion.isYellow()
|
|
local sx = math.floor(self.px - camX)
|
|
local sy = math.floor(self.py - camY) - 4 + 8 + (yellow and 4 or 0)
|
|
love.graphics.draw(self.shadowImg, sx, sy)
|
|
love.graphics.draw(self.shadowImg, sx + 16, sy, 0, -1, 1)
|
|
if not yellow then
|
|
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
|
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 0, -1, -1)
|
|
end
|
|
end
|
|
-- Fishing pose: the standing frame with its bottom tile row swapped for
|
|
-- RedFishingTiles, which is where the hands and the near half of the rod
|
|
-- live; the far half is the rod OAM OverworldState draws (FishingRodOAM,
|
|
-- engine/overworld/player_animations.asm) -- #384
|
|
local fishTile = self.fishing and self.fishTiles and self.fishTiles[facing]
|
|
if fishTile then
|
|
sprite:draw(px, py, camX, camY, facing, 0, false, true)
|
|
sprite:drawTile(fishTile, math.floor(px - camX),
|
|
math.floor(py - camY) - 4 + 8, facing == "right")
|
|
return
|
|
end
|
|
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
|
end
|
|
|
|
return Player
|