mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 16:21:30 +02:00
CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417
This commit is contained in:
+81
-1
@@ -25,6 +25,29 @@ local SAMPLE_RATE = ChipSynth.SAMPLE_RATE
|
||||
local MUSIC_BUFFER_SAMPLES = ChipSynth.MUSIC_BUFFER_SAMPLES
|
||||
local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Per-channel mix (edit these)
|
||||
-- Applied on load and whenever this file hot-reloads.
|
||||
-- Runtime: ChipAudio.setChannelVolume / setChannelPitch.
|
||||
-- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums
|
||||
-- Volume: 1 = authentic, 0 = mute, >1 boosts
|
||||
-- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave
|
||||
-- ---------------------------------------------------------------------------
|
||||
local CHANNEL_VOLUME = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 0.25, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
local CHANNEL_PITCH = {
|
||||
[1] = 1, -- pulse 1
|
||||
[2] = 1, -- pulse 2
|
||||
[3] = 0.5, -- wave
|
||||
[4] = 1, -- noise / drums
|
||||
}
|
||||
ChipSynth.setChannelVolumes(CHANNEL_VOLUME)
|
||||
ChipSynth.setChannelPitches(CHANNEL_PITCH)
|
||||
|
||||
-- currentMusic: { source, gen, threaded, started, finished, engine }
|
||||
-- threaded songs stream from the worker (engine is nil here);
|
||||
-- the fallback path owns a local engine and fills the source itself.
|
||||
@@ -152,13 +175,23 @@ function ChipAudio.playMusic(data, header, allowLoops)
|
||||
musicGen = musicGen + 1
|
||||
local gen = musicGen
|
||||
cmdCh:push({ cmd = "play", gen = gen, header = header,
|
||||
allowLoops = allowLoops, audio = slimAudio(data) })
|
||||
allowLoops = allowLoops, audio = slimAudio(data),
|
||||
channelVolumes = ChipSynth.getChannelVolumes(),
|
||||
channelPitches = ChipSynth.getChannelPitches() })
|
||||
currentMusic = { source = source, gen = gen, threaded = true,
|
||||
started = false, finished = false }
|
||||
-- playback starts in update() once the first buffer arrives (~1 frame)
|
||||
return source
|
||||
end
|
||||
|
||||
local function pushChannelMix()
|
||||
if workerReady and cmdCh then
|
||||
cmdCh:push({ cmd = "channelMix",
|
||||
volumes = ChipSynth.getChannelVolumes(),
|
||||
pitches = ChipSynth.getChannelPitches() })
|
||||
end
|
||||
end
|
||||
|
||||
-- move finished buffers from the worker into the Source; start playback once
|
||||
-- the first one lands
|
||||
local function updateThreaded()
|
||||
@@ -270,6 +303,53 @@ function ChipAudio.invalidate()
|
||||
if workerReady and cmdCh then cmdCh:push({ cmd = "invalidate" }) end
|
||||
end
|
||||
|
||||
-- Runtime mix for one hardware channel (1..4). Takes effect on the next
|
||||
-- synthesized buffer (live music) and on any SFX/cry rendered after the call.
|
||||
function ChipAudio.setChannelVolume(hw, scale)
|
||||
ChipSynth.setChannelVolume(hw, scale)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelVolume(hw)
|
||||
return ChipSynth.getChannelVolume(hw)
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelVolumes(volumes)
|
||||
ChipSynth.setChannelVolumes(volumes)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelVolumes()
|
||||
return ChipSynth.getChannelVolumes()
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelPitch(hw, scale)
|
||||
ChipSynth.setChannelPitch(hw, scale)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelPitch(hw)
|
||||
return ChipSynth.getChannelPitch(hw)
|
||||
end
|
||||
|
||||
function ChipAudio.setChannelPitches(pitches)
|
||||
ChipSynth.setChannelPitches(pitches)
|
||||
pushChannelMix()
|
||||
end
|
||||
|
||||
function ChipAudio.getChannelPitches()
|
||||
return ChipSynth.getChannelPitches()
|
||||
end
|
||||
|
||||
-- aliases for channel 4 (noise / drums)
|
||||
function ChipAudio.setNoiseVolume(scale)
|
||||
ChipAudio.setChannelVolume(4, scale)
|
||||
end
|
||||
|
||||
function ChipAudio.getNoiseVolume()
|
||||
return ChipAudio.getChannelVolume(4)
|
||||
end
|
||||
|
||||
-- a stale song must not keep sounding past the flush that replaced its
|
||||
-- program (20 §2 cache contract, chip music row)
|
||||
Assets.register(ChipAudio.invalidate)
|
||||
|
||||
+77
-7
@@ -29,6 +29,71 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE
|
||||
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
|
||||
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
|
||||
|
||||
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
|
||||
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
|
||||
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
|
||||
-- buffer on both the sync path and the worker (via ChipAudio).
|
||||
local channelVolume = { 1, 1, 1, 1 }
|
||||
local channelPitch = { 1, 1, 1, 1 }
|
||||
|
||||
local function clampScale(scale)
|
||||
return math.max(0, tonumber(scale) or 0)
|
||||
end
|
||||
|
||||
local function setChannelTable(table, hw, scale)
|
||||
hw = tonumber(hw)
|
||||
if not hw or hw < 1 or hw > 4 then return end
|
||||
table[hw] = clampScale(scale)
|
||||
end
|
||||
|
||||
local function setChannelTables(table, values)
|
||||
if type(values) ~= "table" then return end
|
||||
for hw = 1, 4 do
|
||||
if values[hw] ~= nil then table[hw] = clampScale(values[hw]) end
|
||||
end
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelVolume(hw, scale)
|
||||
setChannelTable(channelVolume, hw, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelVolume(hw)
|
||||
return channelVolume[tonumber(hw) or 0] or 1
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelVolumes(volumes)
|
||||
setChannelTables(channelVolume, volumes)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelVolumes()
|
||||
return { channelVolume[1], channelVolume[2], channelVolume[3], channelVolume[4] }
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelPitch(hw, scale)
|
||||
setChannelTable(channelPitch, hw, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelPitch(hw)
|
||||
return channelPitch[tonumber(hw) or 0] or 1
|
||||
end
|
||||
|
||||
function ChipSynth.setChannelPitches(pitches)
|
||||
setChannelTables(channelPitch, pitches)
|
||||
end
|
||||
|
||||
function ChipSynth.getChannelPitches()
|
||||
return { channelPitch[1], channelPitch[2], channelPitch[3], channelPitch[4] }
|
||||
end
|
||||
|
||||
-- aliases for the noise/drum layer
|
||||
function ChipSynth.setNoiseVolume(scale)
|
||||
ChipSynth.setChannelVolume(4, scale)
|
||||
end
|
||||
|
||||
function ChipSynth.getNoiseVolume()
|
||||
return ChipSynth.getChannelVolume(4)
|
||||
end
|
||||
|
||||
local PITCHES = {
|
||||
0xF82C, 0xF89D, 0xF907, 0xF96B, 0xF9CA, 0xFA23,
|
||||
0xFA77, 0xFAC7, 0xFB12, 0xFB58, 0xFB9B, 0xFBDA,
|
||||
@@ -430,7 +495,8 @@ function Channel:sampleNoise(parameter)
|
||||
local divisor = NOISE_DIVISORS[bit.band(parameter, 7)]
|
||||
local shift = bit.rshift(parameter, 4)
|
||||
if shift < 14 then
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE
|
||||
local pitch = channelPitch[self.hardware] or 1
|
||||
local cycles = GB_CLOCK / divisor / (2 ^ shift) / SAMPLE_RATE * pitch
|
||||
local width7 = bit.band(parameter, 8) ~= 0
|
||||
local remaining = cycles
|
||||
while remaining > 0 do
|
||||
@@ -500,11 +566,14 @@ function Channel:sample()
|
||||
event.sample = sampleIndex + 1
|
||||
if event.silence then return 0 end
|
||||
|
||||
if event.drum then return self:sampleDrum(event, sampleIndex) end
|
||||
local gain = channelVolume[self.hardware] or 1
|
||||
if event.drum then
|
||||
return self:sampleDrum(event, sampleIndex) * gain
|
||||
end
|
||||
local volume = envelopeVolume(
|
||||
event.volume or 0, event.fade or 0, event.elapsed)
|
||||
if event.noise then
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15
|
||||
return self:sampleNoise(event.noiseParameter) * volume / 15 * gain
|
||||
end
|
||||
|
||||
local register = event.register
|
||||
@@ -529,7 +598,8 @@ function Channel:sample()
|
||||
end
|
||||
end
|
||||
end
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047))
|
||||
local pitch = channelPitch[self.hardware] or 1
|
||||
local frequency = 131072 / (2048 - math.min(register, 2047)) * pitch
|
||||
if event.wave then frequency = frequency * 0.5 end
|
||||
local phase = self.phase
|
||||
self.phase = (phase + frequency / SAMPLE_RATE) % 1
|
||||
@@ -539,7 +609,7 @@ function Channel:sample()
|
||||
-- a def-local program may omit its wave table entirely
|
||||
if not wave then return 0 end
|
||||
local index = math.min(32, math.floor(phase * 32) + 1)
|
||||
return wave[index] * event.waveLevel
|
||||
return wave[index] * event.waveLevel * gain
|
||||
end
|
||||
local duty = event.duty
|
||||
if type(duty) == "table" then
|
||||
@@ -548,9 +618,9 @@ function Channel:sample()
|
||||
local pattern = WAVE_PATTERN_TABLES[duty or 2] or WAVE_PATTERN_TABLES[2]
|
||||
local step = math.floor(phase * 8) % 8
|
||||
if pattern[step + 1] == 0 then
|
||||
return -volume / 15
|
||||
return -volume / 15 * gain
|
||||
end
|
||||
return volume / 15
|
||||
return volume / 15 * gain
|
||||
end
|
||||
|
||||
local Engine = {}
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
--
|
||||
-- Protocol -- main thread pushes command tables onto the "chipaudio_cmd"
|
||||
-- channel and drains produced buffers off "chipaudio_out":
|
||||
-- cmd = "play" { gen, header, allowLoops, audio } start a song
|
||||
-- cmd = "play" { gen, header, allowLoops, audio,
|
||||
-- channelVolumes?, channelPitches? }
|
||||
-- cmd = "stop" halt production
|
||||
-- cmd = "channelMix" { volumes, pitches } per-hw volume/pitch
|
||||
-- cmd = "invalidate" drop the bank cache
|
||||
-- cmd = "quit" end the thread
|
||||
-- out buffers are tagged with the play's `gen` so the main thread can
|
||||
@@ -45,6 +47,12 @@ local function handle(cmd)
|
||||
engine = nil
|
||||
outCh:clear() -- drop any buffers left from the previous song
|
||||
data = { audio = cmd.audio }
|
||||
if cmd.channelVolumes ~= nil then
|
||||
ChipSynth.setChannelVolumes(cmd.channelVolumes)
|
||||
end
|
||||
if cmd.channelPitches ~= nil then
|
||||
ChipSynth.setChannelPitches(cmd.channelPitches)
|
||||
end
|
||||
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
|
||||
{ allowLoops = cmd.allowLoops })
|
||||
if ok then
|
||||
@@ -58,6 +66,9 @@ local function handle(cmd)
|
||||
engine = nil
|
||||
finished = false
|
||||
outCh:clear()
|
||||
elseif cmd.cmd == "channelMix" then
|
||||
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
|
||||
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
|
||||
elseif cmd.cmd == "invalidate" then
|
||||
ChipSynth.invalidateBanks()
|
||||
elseif cmd.cmd == "quit" then
|
||||
|
||||
@@ -18,7 +18,8 @@ local function samePath(path) return path end
|
||||
-- side: "front" | "back"
|
||||
-- opts.mon: the live mon when available (per-instance skins)
|
||||
-- opts.kind: "battle" | "summary" | "dex" | "evolution" | "hof" | "trade"
|
||||
-- | "title" | "oak" | "credits" (informational for wrappers)
|
||||
-- | "title" | "oak" | "credits" | "overworld" (informational
|
||||
-- for wrappers)
|
||||
-- Returns path, trueColor.
|
||||
function Sprites.path(data, species, side, opts)
|
||||
opts = opts or {}
|
||||
|
||||
+20
-4
@@ -52,13 +52,24 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
function NPC:update(map, entities)
|
||||
-- self.stepFrames overrides the shared 16-frame walk for an object whose
|
||||
-- step has to stay in phase with something else: Yellow's follower
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
|
||||
-- command: two cells of travel inside one step's frames
|
||||
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
|
||||
-- pixel span doubles while the frame count does not. Nothing else sets
|
||||
-- either field, so every other object keeps the constant (#410, #409).
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local span = self.hopStep and 2 or 1
|
||||
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
|
||||
if self.progress >= stepLen then
|
||||
self.progress = 0
|
||||
self.moving = false
|
||||
self.marching = false
|
||||
@@ -67,13 +78,18 @@ function NPC:update(map, entities)
|
||||
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
|
||||
-- 1px per frame at the default length; a shortened step scales instead,
|
||||
-- so the cell still lands on a 16px boundary (Player:update does the
|
||||
-- same for the bicycle)
|
||||
local moved = math.floor(self.progress * 16 * span / stepLen)
|
||||
self.px = self.cellX * 16 + d[1] * moved
|
||||
self.py = self.cellY * 16 + d[2] * moved
|
||||
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.hopStep = nil
|
||||
self.stepFlip = not self.stepFlip
|
||||
end
|
||||
return
|
||||
|
||||
@@ -795,6 +795,12 @@ function OverworldState:update(dt)
|
||||
if ca.onDone then ca.onDone() end
|
||||
end
|
||||
end
|
||||
-- Yellow's companion hopping up onto the Poke Center counter owns the
|
||||
-- world for its arc, the same way the heal machine below does (#417)
|
||||
if self.pikaHop then
|
||||
require("src.world.PikachuFollower").updateHop(self)
|
||||
return
|
||||
end
|
||||
if self.healAnim then
|
||||
local ha = self.healAnim
|
||||
local ev = OverworldState.stepHealAnim(ha)
|
||||
@@ -1497,13 +1503,18 @@ function OverworldState:interact()
|
||||
npc = self:npcAtCell(fx2, fy2)
|
||||
end
|
||||
if npc then
|
||||
if not npc.moving then
|
||||
if npc.pikachuFollower then
|
||||
-- the companion answers directly (TalkToPikachu), no map text id
|
||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||
else
|
||||
self:talkTo(npc)
|
||||
end
|
||||
if npc.pikachuFollower then
|
||||
-- the companion answers directly (TalkToPikachu), no map text id --
|
||||
-- and it answers mid-step too. pikachu_follow.asm walks the follower
|
||||
-- on the player's own step clock, so the original never has it
|
||||
-- mid-tile while the player stands; this port's follow is a frame
|
||||
-- late (the npc loop runs before Player:update lands the step), so
|
||||
-- the not-moving gate used to eat the A press in the frames right
|
||||
-- after landing -- exactly when you turn round to face it (#407).
|
||||
-- talk() lands the follower on its cell first.
|
||||
require("src.world.PikachuFollower").talk(Game, self, npc)
|
||||
elseif not npc.moving then
|
||||
self:talkTo(npc)
|
||||
end
|
||||
interacted(self, fx, fy, "npc", npc)
|
||||
return
|
||||
@@ -2509,41 +2520,56 @@ function OverworldState:nurseHeal(onDone, npc)
|
||||
hello = hello .. "\f"
|
||||
.. (t._ShallWeHealYourPokemonText or Strings("Shall we heal your\nPOKéMON?"))
|
||||
end
|
||||
-- Yellow's companion has its own beat threaded through this sequence
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
|
||||
if not yes then
|
||||
Game.stack:push(TextBox.new(Game, bye, onDone))
|
||||
return
|
||||
end
|
||||
local need = t._NeedYourPokemonText or Strings("OK. We'll need\nyour POKéMON.")
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
if npc then npc.facing = "left" end
|
||||
require("src.core.Music").stop()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
Game.save.lastHeal = { -- SetLastBlackoutMap
|
||||
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
||||
-- the town door of this interior, for LAST_MAP exits after a
|
||||
-- blackout/ESCAPE ROPE warp here
|
||||
outdoor = self.lastOutdoor
|
||||
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
||||
or nil,
|
||||
}
|
||||
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
||||
visible = true,
|
||||
-- map anchor: the player's cell when healing
|
||||
-- began (the GB's fixed screen coords assume it
|
||||
-- BG-aligned at (64,64))
|
||||
px = self.player.cellX * 16,
|
||||
py = self.player.cellY * 16 }
|
||||
self.healAnim.onDone = function()
|
||||
if npc then npc:facePlayer(self.player) end
|
||||
self:finishNurseHeal(bye, onDone)
|
||||
end
|
||||
end))
|
||||
-- accepting the heal sends the companion up onto the counter to Nurse
|
||||
-- Joy first: pokecenter.asm runs `callfar PikachuWalksToNurseJoy`
|
||||
-- between SetLastBlackoutMap and NeedYourPokemonText, and the hop has
|
||||
-- to finish before the text box goes up because only the top state
|
||||
-- updates. No follower (or not Yellow) calls straight through (#417).
|
||||
Follower.hopToCounter(self, function()
|
||||
Game.stack:push(TextBox.new(Game, need, function()
|
||||
-- the nurse turns to the machine, the map music stops, and the
|
||||
-- party heals before the machine runs (predef HealParty)
|
||||
if npc then npc.facing = "left" end
|
||||
-- DisablePikachuOverworldSpriteDrawing: Pikachu goes behind the
|
||||
-- counter with the party for the machine animation
|
||||
Follower.setVisible(self, false)
|
||||
require("src.core.Music").stop()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(Game.save.party) do
|
||||
Pokemon.heal(mon)
|
||||
end
|
||||
Game.save.lastHeal = { -- SetLastBlackoutMap
|
||||
map = self.map.id, x = self.player.cellX, y = self.player.cellY,
|
||||
-- the town door of this interior, for LAST_MAP exits after a
|
||||
-- blackout/ESCAPE ROPE warp here
|
||||
outdoor = self.lastOutdoor
|
||||
and { id = self.lastOutdoor.id, x = self.lastOutdoor.x, y = self.lastOutdoor.y }
|
||||
or nil,
|
||||
}
|
||||
self.healAnim = { balls = #Game.save.party, lit = 0, timer = 0,
|
||||
visible = true,
|
||||
-- map anchor: the player's cell when healing
|
||||
-- began (the GB's fixed screen coords assume it
|
||||
-- BG-aligned at (64,64))
|
||||
px = self.player.cellX * 16,
|
||||
py = self.player.cellY * 16 }
|
||||
self.healAnim.onDone = function()
|
||||
-- EnablePikachuOverworldSpriteDrawing, before the fighting-fit
|
||||
-- line: it comes back on the counter facing the player
|
||||
Follower.setVisible(self, true)
|
||||
if npc then npc:facePlayer(self.player) end
|
||||
self:finishNurseHeal(bye, onDone)
|
||||
end
|
||||
end))
|
||||
end)
|
||||
end }))
|
||||
end
|
||||
|
||||
@@ -4410,6 +4436,30 @@ end
|
||||
|
||||
-- screen-space overlays: drawn to the UI canvas at normal scale
|
||||
function OverworldState:drawUI()
|
||||
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
|
||||
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
|
||||
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so
|
||||
-- the front pic holds for the whole beat while the cry and any emote
|
||||
-- bubble play over the world below (#407).
|
||||
if self.emote and self.emote.pikaPic then
|
||||
require("src.render.Font").drawBox(6, 5, 7, 7)
|
||||
-- one image per path, cached: this draws every frame of the hold, and
|
||||
-- a mod skin can move the path between talks
|
||||
if self.pikaPicPath ~= self.emote.pikaPic then
|
||||
local ok, loaded = pcall(love.graphics.newImage, self.emote.pikaPic)
|
||||
self.pikaPicImg = ok and loaded or nil
|
||||
self.pikaPicPath = self.emote.pikaPic
|
||||
end
|
||||
local img = self.pikaPicImg
|
||||
if img then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local w, h = img:getDimensions()
|
||||
love.graphics.draw(img, math.floor(56 + (40 - w) / 2),
|
||||
math.floor(48 + (40 - h) / 2))
|
||||
end
|
||||
end
|
||||
|
||||
-- poison step flicker (ChangeBGPalColor0_4Frames: dark for two
|
||||
-- 4-frame pulses)
|
||||
if self.poisonFlash and self.poisonFlash > 0 then
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
-- UpdatePikachuHappinessAndMood (256-step coin-flip WALKING bump, mood
|
||||
-- converging by 1 per step toward 128).
|
||||
|
||||
local Collision = require("src.world.Collision")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local PikachuFollower = {}
|
||||
@@ -140,6 +141,14 @@ local function makeFollower(game, ow, x, y, facing)
|
||||
npc.pikachuFollower = true
|
||||
npc.passable = true -- never blocks a step (Collision.occupied)
|
||||
npc.facing = facing or "down"
|
||||
-- the idle animations below pose the walk cycle with no step under it,
|
||||
-- which NPC:walkPhase (moving-only) cannot express. An instance field
|
||||
-- shadows the class method, so NPC:pose keeps working unchanged (#411).
|
||||
npc.walkPhase = function(self)
|
||||
local idle = self.idle
|
||||
if idle and idle.phase then return idle.phase % 2 end
|
||||
return NPC.walkPhase(self)
|
||||
end
|
||||
return npc
|
||||
end
|
||||
|
||||
@@ -183,9 +192,157 @@ function PikachuFollower.onMapEntered(game, ow)
|
||||
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Idle behavior (pikachu_follow.asm Func_fc803 and the Func_fc842 roll it
|
||||
-- hands off to). Standing still, the follower burns down a frame
|
||||
-- counter; at zero it either looks in a random direction (Random & $c,
|
||||
-- another $20 frames later) or, when the buffered follow command puts it
|
||||
-- two or more cells off the player (ComputePikachuFollowCommand's 5-8
|
||||
-- band), rolls one of four in-place animations: a bounce, the walk cycle
|
||||
-- on the spot, a two frame shuffle, or a clockwise spin. Func_fc82e
|
||||
-- drops whichever is running the moment the player takes a step. Nothing
|
||||
-- here plays a bubble or a cry -- those are TalkToPikachu's alone (#411).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local IDLE_LOOK = 0x20 -- Func_fc803's pause between random glances
|
||||
local IDLE_REST = 0x10 -- Func_fc835's pause after an animation ends
|
||||
local IDLE_FRAME = 8 -- frames per sprite frame in Func_fc8f8/92b/95d
|
||||
|
||||
local FACINGS = { "down", "up", "left", "right" }
|
||||
-- Func_fc95d .Facings, the order the spin turns through
|
||||
local CLOCKWISE = { down = "left", left = "up", up = "right", right = "down" }
|
||||
|
||||
-- Pointer_fc8d6, transposed to (dx, dy): the asm stores (y, x) and walks
|
||||
-- the table backwards as the $11 counter runs down, so entry N here is
|
||||
-- what counter N draws. A sway four pixels right then four left with the
|
||||
-- body bobbing up twice, netting zero displacement.
|
||||
local BOUNCE = {
|
||||
{ 0, 0 }, { -1, -2 }, { -2, -4 }, { -3, -2 }, { -4, 0 },
|
||||
{ -3, -2 }, { -2, -4 }, { -1, -2 }, { 0, 0 }, { 1, -2 },
|
||||
{ 2, -4 }, { 3, -2 }, { 4, 0 }, { 3, -2 }, { 2, -4 },
|
||||
{ 1, -2 }, { 0, 0 },
|
||||
}
|
||||
|
||||
local function randomInt(a, b)
|
||||
local rand = love and love.math and love.math.random or math.random
|
||||
return rand(a, b)
|
||||
end
|
||||
|
||||
-- back onto the cell's own pixels: while the follower stands, nothing else
|
||||
-- writes px/py, so the bounce offset has to be undone from here
|
||||
local function idleReset(npc)
|
||||
npc.idle = nil
|
||||
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
|
||||
end
|
||||
|
||||
-- ComputePikachuFollowCommand: the command the idle state reads back is
|
||||
-- 1-4 while the follower sits within a cell of the player and 5-8 once it
|
||||
-- is two or more off, Y deciding whenever the rows differ. Returns the
|
||||
-- facing those 5-8 encode (Func_fc862 turns that way before it bounces),
|
||||
-- or nil for the near band, which only ever glances.
|
||||
local function strandedFacing(ow, npc)
|
||||
local p = ow.player
|
||||
local dy = p.cellY - npc.cellY
|
||||
if dy ~= 0 then
|
||||
if dy > -2 and dy < 2 then return nil end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
local dx = p.cellX - npc.cellX
|
||||
if dx > -2 and dx < 2 then return nil end
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
|
||||
-- Func_fc842: an even roll over the four PointerTable_fc85a entries
|
||||
local function startIdleAnim(npc, facing)
|
||||
local roll = randomInt(0, 3)
|
||||
if roll == 0 then
|
||||
-- Func_fc862 turns toward the player, then asm_fc87f bounces
|
||||
npc.facing = facing or npc.facing
|
||||
npc.idle = { kind = "bounce", frames = 0x11 }
|
||||
elseif roll == 1 then
|
||||
npc.idle = { kind = "walk", frames = 0x30, tick = 0, phase = 0 }
|
||||
elseif roll == 2 then
|
||||
npc.idle = { kind = "shuffle", frames = 0x20, tick = 0, phase = 0 }
|
||||
else
|
||||
npc.idle = { kind = "spin", frames = 0x20, tick = 0 }
|
||||
end
|
||||
end
|
||||
|
||||
local function idleTick(ow, npc)
|
||||
-- Func_fc82e: a step in progress ends the idle state outright
|
||||
if ow.player.moving then idleReset(npc) return end
|
||||
local idle = npc.idle
|
||||
if not idle then
|
||||
idle = { kind = "wait", frames = IDLE_LOOK }
|
||||
npc.idle = idle
|
||||
end
|
||||
if idle.kind == "wait" then
|
||||
idle.frames = idle.frames - 1
|
||||
if idle.frames > 0 then return end
|
||||
local facing = strandedFacing(ow, npc)
|
||||
if facing then
|
||||
startIdleAnim(npc, facing)
|
||||
else
|
||||
npc.facing = FACINGS[randomInt(1, 4)]
|
||||
idle.frames = IDLE_LOOK
|
||||
end
|
||||
return
|
||||
end
|
||||
if idle.kind == "bounce" then
|
||||
local o = BOUNCE[idle.frames] or BOUNCE[1]
|
||||
npc.px = npc.cellX * 16 + o[1]
|
||||
npc.py = npc.cellY * 16 + o[2]
|
||||
else
|
||||
idle.tick = idle.tick + 1
|
||||
if idle.tick >= IDLE_FRAME then
|
||||
idle.tick = 0
|
||||
if idle.kind == "walk" then
|
||||
-- Func_fc8f8 runs the anim counter through all four frames; the
|
||||
-- top bit is the mirrored foot, which is our stepFlip
|
||||
idle.phase = (idle.phase + 1) % 4
|
||||
npc.stepFlip = idle.phase >= 2
|
||||
elseif idle.kind == "shuffle" then
|
||||
idle.phase = idle.phase == 0 and 1 or 0 -- Func_fc92b's xor $1
|
||||
else
|
||||
npc.facing = CLOCKWISE[npc.facing] or "down"
|
||||
end
|
||||
end
|
||||
end
|
||||
idle.frames = idle.frames - 1
|
||||
if idle.frames <= 0 then
|
||||
-- Func_fc835: a $10 frame rest, then the idle counter again
|
||||
idleReset(npc)
|
||||
npc.idle = { kind = "wait", frames = IDLE_REST }
|
||||
end
|
||||
end
|
||||
|
||||
-- The cell ahead is a ledge the player just hopped (data/tilesets/
|
||||
-- ledge_tiles.asm, the same row match OverworldState:checkLedgeHop makes).
|
||||
-- The follower only ever retraces cells the player stood on, so a ledge
|
||||
-- tile in the trail means the player jumped it (#409).
|
||||
local function ledgeStep(game, ow, cx, cy, dir)
|
||||
local map = ow.map
|
||||
local d = Collision.DELTA[dir]
|
||||
local fx, fy = cx + d[1], cy + d[2]
|
||||
local lx, ly = cx + d[1] * 2, cy + d[2] * 2
|
||||
if not (map:inBounds(fx, fy) and map:inBounds(lx, ly)) then return false end
|
||||
local tileset = map.def.tileset
|
||||
local standing = map:cellTile(cx, cy)
|
||||
local front = map:cellTile(fx, fy)
|
||||
for _, ledge in ipairs(game.data.field.ledges or {}) do
|
||||
if (ledge.tileset or "OVERWORLD") == tileset
|
||||
and ledge.facing == dir and ledge.input == dir
|
||||
and ledge.standingTile == standing and ledge.ledgeTile == front then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- one follow step per frame: chase the cell the player last vacated
|
||||
-- (pikachu_follow.asm keeps it one walk step behind)
|
||||
function PikachuFollower.update(game, ow)
|
||||
if ow.pikaHop then return end -- the counter hop owns the follower (#417)
|
||||
local npc = findFollower(ow)
|
||||
if not npc then
|
||||
if shouldSpawn(game, ow) then PikachuFollower.onMapEntered(game, ow) end
|
||||
@@ -201,15 +358,31 @@ function PikachuFollower.update(game, ow)
|
||||
trail = { x = p.cellX, y = p.cellY }
|
||||
ow.pikachuTrail = trail
|
||||
end
|
||||
-- the player left the trailing cell: it becomes Pikachu's next goal
|
||||
if p.cellX ~= trail.x or p.cellY ~= trail.y then
|
||||
-- The follow command is queued the frame the player COMMITS a step, not
|
||||
-- the frame it lands: home/overworld.asm .noCollision sets wWalkCounter
|
||||
-- and calls Func_fcc08 (pikachu_follow.asm Func_fcc42 reads the direction
|
||||
-- of the step just started) before AdvancePlayerSprite, so Pikachu walks
|
||||
-- into the cell the player is vacating during that same step and rests
|
||||
-- exactly one cell behind. Waiting for p.cellX to change put a whole
|
||||
-- extra step between them -- the two-tile gap of issue #410. targetX/Y
|
||||
-- is the committed destination while a step is in flight and nil when
|
||||
-- standing, so a warp or teleport still registers here (and the far > 6
|
||||
-- snap below still catches it).
|
||||
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 = p.cellX, p.cellY
|
||||
trail.x, trail.y = destX, destY
|
||||
end
|
||||
if npc.moving or not npc.goalX then return end
|
||||
-- standing still with nothing to chase is the idle state (Func_fc803);
|
||||
-- once a step is under way NPC:update owns px/py, so only the idle
|
||||
-- record is dropped here -- never the interpolated pixels
|
||||
if npc.moving then npc.idle = nil return end
|
||||
if not npc.goalX then idleTick(ow, npc) return end
|
||||
local gx, gy = npc.goalX, npc.goalY
|
||||
if npc.cellX == gx and npc.cellY == gy then
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
idleTick(ow, npc)
|
||||
return
|
||||
end
|
||||
-- fell more than a screen behind (forced movement, warp math): snap
|
||||
@@ -218,8 +391,10 @@ function PikachuFollower.update(game, ow)
|
||||
npc.cellX, npc.cellY = gx, gy
|
||||
npc.px, npc.py = gx * 16, gy * 16
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
npc.idle = nil -- the snap already rewrote px/py
|
||||
return
|
||||
end
|
||||
idleReset(npc) -- a real step overrides whatever the idle pose was
|
||||
local dir
|
||||
if npc.cellX < gx then dir = "right"
|
||||
elseif npc.cellX > gx then dir = "left"
|
||||
@@ -228,15 +403,44 @@ function PikachuFollower.update(game, ow)
|
||||
npc.facing = dir
|
||||
npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0)
|
||||
npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0)
|
||||
-- the cell ahead is the ledge the player hopped: clear both cells in one
|
||||
-- step instead of stopping on the ledge (#409). pikachu_follow.asm
|
||||
-- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING
|
||||
-- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over
|
||||
-- one normal step's frames -- no arc and no shadow, the hop command only
|
||||
-- doubles the step vector (NPC:update's hopStep span).
|
||||
if ledgeStep(game, ow, npc.cellX, npc.cellY, dir) then
|
||||
local d = Collision.DELTA[dir]
|
||||
npc.targetX, npc.targetY = npc.cellX + d[1] * 2, npc.cellY + d[2] * 2
|
||||
npc.goalX, npc.goalY = npc.targetX, npc.targetY
|
||||
npc.hopStep = true
|
||||
end
|
||||
-- walk at the player's own step length (the bicycle is moot: shouldSpawn
|
||||
-- hides the follower on a bike, ShouldPikachuSpawn's wWalkBikeSurfState
|
||||
-- check), and halve it while more than one cell behind -- that is
|
||||
-- FastPikachuFollow, which pikachu_follow.asm picks whenever two or more
|
||||
-- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer:
|
||||
-- walk counter $4 instead of NormalPikachuFollow's $8).
|
||||
local stepLen = p.stepFramesCur or 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
|
||||
-- this frame's npc:update loop already ran (OverworldState:update walks
|
||||
-- self.npcs, then calls here), so burn the step's first frame now.
|
||||
-- Without it the step costs a frame more than the player's and Pikachu
|
||||
-- trails a pixel further every tile.
|
||||
npc:update(ow.map, ow.entities)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- TalkToPikachu (engine/pikachu/pikachu_emotions.asm + data/pikachu/
|
||||
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
|
||||
-- and voiced PCM clip. The face-pic animation half of each emotion
|
||||
-- (pikaemotion_pikapic) has no port; the bubble + clip carry the beat.
|
||||
-- and voiced PCM clip, and raise the framed Pikachu picture the original
|
||||
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
|
||||
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The
|
||||
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted,
|
||||
-- so the front pic stands in for all twenty of them (#407).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
|
||||
@@ -340,6 +544,20 @@ local function bubbleIndex(game, name)
|
||||
end
|
||||
|
||||
function PikachuFollower.talk(game, ow, npc, done)
|
||||
-- pikachu_follow.asm steps the follower on the player's own walk clock,
|
||||
-- so it is never mid-tile while the player stands and can always be
|
||||
-- addressed; this port's follow is a frame late, so land the step here
|
||||
-- rather than answer from between two cells (#407). The emote hold
|
||||
-- returns before the npc update loop, so a follower left mid-step would
|
||||
-- freeze between cells for the whole beat.
|
||||
if npc.moving then
|
||||
npc.cellX, npc.cellY = npc.targetX or npc.cellX, npc.targetY or npc.cellY
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
npc.moving = false
|
||||
npc.progress = 0
|
||||
npc.hopStep = nil
|
||||
end
|
||||
idleReset(npc) -- the bubble anchor reads px/py, and the hold freezes it
|
||||
npc:facePlayer(ow.player)
|
||||
ow.player.facing = OPPOSITE[npc.facing] or ow.player.facing
|
||||
local save = game.save
|
||||
@@ -357,12 +575,97 @@ function PikachuFollower.talk(game, ow, npc, done)
|
||||
-- caches built before the Yellow bubble sheet only carry the three
|
||||
-- shared bubbles; a missing crop degrades to a silent hold
|
||||
local bi = e.bubble and bubbleIndex(game, e.bubble)
|
||||
-- pikaemotion_pikapic: every entry in data/pikachu/pikachu_emotions.asm
|
||||
-- ends with one, and its box is the only thing most of them put on
|
||||
-- screen (emotion 5, the fresh-save cell, has no bubble at all). The
|
||||
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
|
||||
-- Sprites.path keeps a mod's replacement skin in play. The scripts'
|
||||
-- 32-58 frame durations bracket the hold below, so it stays at 50.
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local pic = Sprites.path(game.data, "PIKACHU", "front",
|
||||
{ kind = "overworld" })
|
||||
ow.emote = {
|
||||
npc = npc, frames = 50, bubble = bi or false,
|
||||
npc = npc, frames = 50, bubble = bi or false, pikaPic = pic,
|
||||
onDone = done,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by
|
||||
-- engine/events/pokecenter.asm once the heal is accepted): the companion
|
||||
-- looks up ($36) and hops onto the Poke Center counter. The original
|
||||
-- picks one of three movement scripts by where it stands -- below the
|
||||
-- player (.PikaMovementData1: walk up left, hop up right), left of it
|
||||
-- (.PikaMovementData2: hop up right) or right of it (.PikaMovementData3:
|
||||
-- hop up left) -- and all three land on the counter tile directly in
|
||||
-- front of the player, so the port animates that one hop. Pikachu
|
||||
-- already above the player yields zero movement bytes: no beat (#417).
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local HOP_FRAMES = 32 -- the port's ledge-hop arc (Player:pose hopTotal)
|
||||
|
||||
function PikachuFollower.hopToCounter(ow, done)
|
||||
local npc = GameVersion.isYellow() and findFollower(ow) or nil
|
||||
local p = ow.player
|
||||
local cx, cy = p:facingCell()
|
||||
-- the nurse is talked to across a counter tile (OverworldState:interact);
|
||||
-- anything else is the .pikachu_above_player no-op path
|
||||
if not npc or p.facing ~= "up" or not ow.map:isCounterCell(cx, cy) then
|
||||
if done then done() end
|
||||
return
|
||||
end
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
npc.moving, npc.progress, npc.hopStep = false, 0, nil
|
||||
npc.idle = nil
|
||||
npc.facing = "up" -- $36, look up
|
||||
ow.pikaHop = {
|
||||
npc = npc, frames = 0, cellX = cx, cellY = cy, onDone = done,
|
||||
fromX = npc.px, fromY = npc.py, toX = cx * 16, toY = cy * 16,
|
||||
}
|
||||
end
|
||||
|
||||
-- One frame of that hop. OverworldState:update holds the world for it the
|
||||
-- way it holds for the heal machine (only the top state updates, so this
|
||||
-- has to sit between the two text boxes); the arc matches Player:pose's
|
||||
-- ledge hop -- a 10px sine over 32 frames.
|
||||
function PikachuFollower.updateHop(ow)
|
||||
local h = ow.pikaHop
|
||||
if not h then return end
|
||||
h.frames = h.frames + 1
|
||||
local t = math.min(1, h.frames / HOP_FRAMES)
|
||||
h.npc.px = h.fromX + (h.toX - h.fromX) * t
|
||||
h.npc.py = h.fromY + (h.toY - h.fromY) * t
|
||||
- math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
if h.frames < HOP_FRAMES then return end
|
||||
h.npc.cellX, h.npc.cellY = h.cellX, h.cellY
|
||||
h.npc.px, h.npc.py = h.toX, h.toY
|
||||
ow.pikaHop = nil
|
||||
-- the player has not moved, so the trail restarts under his feet and the
|
||||
-- follower only steps back off the counter once he walks away
|
||||
ow.pikachuTrail = { x = ow.player.cellX, y = ow.player.cellY }
|
||||
if h.onDone then h.onDone() end
|
||||
end
|
||||
|
||||
-- Disable/EnablePikachuOverworldSpriteDrawing around the healing machine
|
||||
-- (engine/events/pokecenter.asm): Pikachu goes behind the counter with the
|
||||
-- party and comes back standing on it, facing the player -- the respawn is
|
||||
-- wPikachuSpawnState = 5, which is .above_player in pikachu_follow.asm,
|
||||
-- followed by `lb bc, 15, 0` (sprite struct 15 is Pikachu, image index 0
|
||||
-- is facing down). ow.entities is the draw list and ow.npcs the update
|
||||
-- list, so dropping it from entities alone hides it in place (#417).
|
||||
function PikachuFollower.setVisible(ow, visible)
|
||||
local npc = findFollower(ow)
|
||||
if not npc then return end
|
||||
for i, e in ipairs(ow.entities or {}) do
|
||||
if e == npc then table.remove(ow.entities, i) break end
|
||||
end
|
||||
if visible then
|
||||
npc.facing = "down"
|
||||
table.insert(ow.entities, npc)
|
||||
end
|
||||
end
|
||||
|
||||
-- npc the player is facing, when it is the follower (interact hook)
|
||||
function PikachuFollower.at(ow, cx, cy)
|
||||
local npc = findFollower(ow)
|
||||
|
||||
+21
-7
@@ -4,6 +4,7 @@
|
||||
|
||||
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")
|
||||
|
||||
@@ -237,17 +238,30 @@ 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: 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)
|
||||
-- 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
|
||||
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)
|
||||
love.graphics.draw(self.shadowImg, sx, sy + 16, 0, 1, -1)
|
||||
love.graphics.draw(self.shadowImg, sx + 16, sy + 16, 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
|
||||
sprite:draw(px, py, camX, camY, facing, phase, flip)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user