mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 03:35:56 +02:00
Bugs and stuff (#144)
* main menu scrollable when over 8 items * buggies * more buggies * Lorelei, Bruno, and Agatha now push their AfterBattle text right after a win
This commit is contained in:
+215
-49
@@ -52,6 +52,10 @@ local imageCache = {}
|
||||
-- fully transparent rows below a pic's content (the extracted 32x32 back
|
||||
-- pics carry baked-in padding); used to sit the pic flush on the text box
|
||||
local imagePadBottom = {}
|
||||
-- fully transparent columns left of a pic's content; at 2x (back pics)
|
||||
-- this is subtracted from hlcoord 1,5 so opaque pixels match hardware,
|
||||
-- where those columns were white-on-white rather than shifted content
|
||||
local imagePadLeft = {}
|
||||
-- image -> { path, pal } so palette-fade variants (see fadeImage) can be
|
||||
-- rebuilt for any battle pic, whatever code loaded it
|
||||
local imageMeta = {}
|
||||
@@ -63,7 +67,7 @@ local function getImage(path, pal, trueColor)
|
||||
if trueColor then pal = nil end
|
||||
local key = pal and (path .. "#" .. pal.name) or path
|
||||
if not imageCache[key] then
|
||||
local img, pad = nil, 0
|
||||
local img, pad, padL = nil, 0, 0
|
||||
if love.image and love.image.newImageData then
|
||||
local id = Assets.imageData(path)
|
||||
if pal then
|
||||
@@ -86,13 +90,25 @@ local function getImage(path, pal, trueColor)
|
||||
if opaque then break end
|
||||
bottom = bottom - 1
|
||||
end
|
||||
local left = 0
|
||||
while left < w do
|
||||
local opaque = false
|
||||
for y = 0, h - 1 do
|
||||
local _, _, _, a = id:getPixel(left, y)
|
||||
if a > 0 then opaque = true break end
|
||||
end
|
||||
if opaque then break end
|
||||
left = left + 1
|
||||
end
|
||||
img = love.graphics.newImage(id)
|
||||
pad = h - 1 - bottom
|
||||
padL = left
|
||||
else
|
||||
img = Assets.image(path) -- headless stub: no pixel access
|
||||
end
|
||||
imageCache[key] = img
|
||||
imagePadBottom[img] = pad
|
||||
imagePadLeft[img] = padL
|
||||
imageMeta[img] = { path = path, pal = pal, trueColor = trueColor or nil }
|
||||
end
|
||||
return imageCache[key]
|
||||
@@ -101,7 +117,7 @@ end
|
||||
-- hot reload: the next getImage re-resolves every pic through the asset
|
||||
-- search path and re-measures its ground padding
|
||||
function BattleState.invalidate()
|
||||
imageCache, imagePadBottom, imageMeta = {}, {}, {}
|
||||
imageCache, imagePadBottom, imagePadLeft, imageMeta = {}, {}, {}, {}
|
||||
end
|
||||
|
||||
Assets.register(BattleState.invalidate)
|
||||
@@ -258,6 +274,10 @@ local function makeBattler(data, mon, isPlayer, save)
|
||||
}
|
||||
end
|
||||
|
||||
-- LinkBattle builds clamped copies with save=nil (no badge boosts); wild
|
||||
-- and trainer constructors pass the live save for the player side.
|
||||
BattleState.makeBattler = makeBattler
|
||||
|
||||
-- The battle pic for `species` on the given side (back pic for the
|
||||
-- player side, front pic for the enemy side), tinted PAL_GRAYMON --
|
||||
-- the same path makeBattler uses, but forced gray -- since this is only
|
||||
@@ -832,7 +852,7 @@ function BattleState:computeMusicKind()
|
||||
return "final"
|
||||
elseif isBoss or (self.trainer and self.trainer.id == "OPP_LANCE") then
|
||||
return "gym"
|
||||
elseif self.kind == "trainer" then
|
||||
elseif self.kind == "trainer" or self.kind == "link" then
|
||||
return "trainer"
|
||||
end
|
||||
return "wild"
|
||||
@@ -894,6 +914,17 @@ function BattleState:enter()
|
||||
self.showEnemyTrainer = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
elseif self.kind == "link" then
|
||||
-- Colosseum has no foe trainer pic, but the enemy mon still grows
|
||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||
-- intro that LinkBattle previously inherited from newWild).
|
||||
self.enemySendingOut = true
|
||||
self:say(("%s sent\nout %s!"):format(self.opponentName or "FOE",
|
||||
self.enemy.name))
|
||||
self:act(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
end)
|
||||
end
|
||||
if not self.ghost then
|
||||
-- the enemy's cry plays as it appears (data/pokemon/cries.asm)
|
||||
@@ -950,20 +981,37 @@ function BattleState:exit()
|
||||
end
|
||||
end
|
||||
|
||||
-- An action the battler is locked into (bypasses the menu), or nil.
|
||||
function BattleState:lockedAction(battler)
|
||||
-- End a trapping sequence (USING_TRAPPING_MOVE). SendOutMon clears the
|
||||
-- foe's bit (core.asm:1761-1762); EnemySendOutFirstMon clears the
|
||||
-- player's (core.asm:1314-1315). Any switch frees the other side.
|
||||
local function clearTrapping(battler)
|
||||
if not battler then return end
|
||||
battler.trappingTurns = nil
|
||||
battler.trapMove = nil
|
||||
battler.trapDamage = nil
|
||||
end
|
||||
|
||||
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
|
||||
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
|
||||
-- skip the menu -- the player can still item/switch (and must press
|
||||
-- FIGHT to continue a trapping sequence).
|
||||
function BattleState:menuLockedAction(battler)
|
||||
if battler.mustRecharge then return { special = "recharge" } end
|
||||
if battler.charging then return battler.charging end
|
||||
if battler.thrashTurns and battler.thrashTurns > 0 then return battler.thrashMove end
|
||||
if battler.rageMove then return battler.rageMove end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- After FIGHT: skip MoveSelectionMenu (core.asm:320-329). Own
|
||||
-- trapping/Bide continues; foe trapping forces CANNOT_MOVE ($ff).
|
||||
function BattleState:fightLockedAction(battler)
|
||||
if battler.trappingTurns and battler.trappingTurns > 0 then
|
||||
return { special = "trapping" }
|
||||
end
|
||||
if battler.bideTurns then return { special = "bide" } end
|
||||
if battler.rageMove then return battler.rageMove end
|
||||
-- held in place while the OPPONENT's trapping move is running
|
||||
-- (core.asm:316-322 reads the live USING_TRAPPING_MOVE bit, so a
|
||||
-- trap ended early by paralysis/faint frees the victim immediately);
|
||||
-- boundTurns is a mirror kept for Status.beforeMove's held check
|
||||
-- held while the OPPONENT's trapping bit is set (live mirror so a
|
||||
-- trap ended early by paralysis/faint frees the victim immediately)
|
||||
local opp = battler.isPlayer and self.enemy or self.player
|
||||
battler.boundTurns = opp and opp.trappingTurns
|
||||
and math.max(1, opp.trappingTurns) or nil
|
||||
@@ -973,6 +1021,11 @@ function BattleState:lockedAction(battler)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Full lock for AI / callers that need any forced action.
|
||||
function BattleState:lockedAction(battler)
|
||||
return self:menuLockedAction(battler) or self:fightLockedAction(battler)
|
||||
end
|
||||
|
||||
function BattleState:playerHasPP()
|
||||
for i, mv in ipairs(self.player.curMoves) do
|
||||
if mv.pp > 0 and self.player.disabledSlot ~= i then return true end
|
||||
@@ -1079,8 +1132,9 @@ function BattleState:update(dt)
|
||||
if not (self.player.mustRecharge or self.player.rageMove) then
|
||||
self.player.flinched, self.enemy.flinched = false, false
|
||||
end
|
||||
-- locked multi-turn actions skip the menu entirely
|
||||
local locked = self:lockedAction(self.player)
|
||||
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
|
||||
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
|
||||
local locked = self:menuLockedAction(self.player)
|
||||
if locked then
|
||||
self:resolveTurn(locked)
|
||||
return
|
||||
@@ -1104,6 +1158,13 @@ function BattleState:update(dt)
|
||||
end)
|
||||
self:act(function() self:endOfTurn() end)
|
||||
elseif choice == "fight" then
|
||||
-- After the menu: own trapping/Bide or foe Wrap skips the move
|
||||
-- list and forces the locked action (core.asm:320-329)
|
||||
local fightLock = self:fightLockedAction(self.player)
|
||||
if fightLock then
|
||||
self:resolveTurn(fightLock)
|
||||
return
|
||||
end
|
||||
if not self:playerHasPP() then
|
||||
-- _NoMovesLeftText, then Struggle engages
|
||||
self:say(("%s has no\nmoves left!"):format(self.player.name))
|
||||
@@ -1514,6 +1575,9 @@ function BattleState:resolveSwitch(newMon)
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
|
||||
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
@@ -1619,10 +1683,19 @@ end
|
||||
-- only after MoveHitTest / the effect lands (HandleIfPlayerMoveMissed skips
|
||||
-- it on a miss unless EXPLODE_EFFECT); we insert early for blink attachment
|
||||
-- and peel it back on miss/fail paths.
|
||||
-- Dig/Fly charge leaves the user pic hidden (SLIDE_DOWN / TELEPORT); the
|
||||
-- second-turn DIG/FLY anim restores it via SE_SLIDE_MON_UP / SE_SHOW_MON_PIC.
|
||||
-- Cancelling that row on miss/immune would otherwise leave the digger
|
||||
-- invisible until another anim's resetPicFx (#100).
|
||||
function BattleState:cancelMoveAnim()
|
||||
local row = self.moveAnimRow
|
||||
if not row then return end
|
||||
self.moveAnimRow = nil
|
||||
if row.anim == "DIG" or row.anim == "FLY" then
|
||||
local user = row.attackerIsPlayer and self.player or self.enemy
|
||||
local pf = user and self.picFx and self.picFx[user]
|
||||
if pf then pf.hidden = nil end
|
||||
end
|
||||
for i, item in ipairs(self.queue) do
|
||||
if item == row then
|
||||
table.remove(self.queue, i)
|
||||
@@ -1686,14 +1759,23 @@ end
|
||||
|
||||
-- transient pic effects reset when a new animation row starts (each
|
||||
-- PlayAnimation redraws from a clean slate); `minimized` survives --
|
||||
-- the minimize sprite replaces the pic DATA, so redraws keep it until
|
||||
-- the pic is reloaded (switch/Transform/ChangeMonPic)
|
||||
-- the minimize sprite replaces the pic DATA until reload. Dig/Fly's
|
||||
-- charge hide is a cleared tilemap that must survive until SE_SHOW_* /
|
||||
-- SE_SLIDE_MON_UP (or cancelMoveAnim on a missed Dig/Fly release):
|
||||
-- clearing it here made Dig pop in before emerge and wrap/bounce (#100).
|
||||
-- Other hides (Acid Armor, etc.) still clear so the next anim restores.
|
||||
function BattleState:resetPicFx()
|
||||
if not self.picFx then return end
|
||||
for _, pf in pairs(self.picFx) do
|
||||
local digFly = self.animName == "DIG" or self.animName == "FLY"
|
||||
local digFlyUser = digFly and (self.animAttackerIsPlayer
|
||||
and self.player or self.enemy) or nil
|
||||
for battler, pf in pairs(self.picFx) do
|
||||
pf.kind, pf.t = nil, nil
|
||||
pf.ox, pf.oy = 0, 0
|
||||
pf.hidden = nil
|
||||
local keepHide = battler.invulnerable or battler == digFlyUser
|
||||
if not keepHide then
|
||||
pf.hidden = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2094,6 +2176,8 @@ function BattleState:executeAction(user, target, action)
|
||||
local oldName = self.enemy.name
|
||||
self.enemyIndex = action.index
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[action.index], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
@@ -2144,6 +2228,43 @@ function BattleState:executeAction(user, target, action)
|
||||
self:performMove(user, target, action, false)
|
||||
end
|
||||
|
||||
-- Sleep / confusion onomatopoeia from Check*StatusConditions
|
||||
-- (core.asm): side-specific SLP_*/CONF_* anims, not the Rest/Amnesia
|
||||
-- move rows. Player sleep plays the anim before FastAsleepText;
|
||||
-- enemy sleep and both confusion sides print the text first.
|
||||
function BattleState:statusOnomatopoeia(user, kind)
|
||||
local isPlayer = user.isPlayer
|
||||
local anim
|
||||
if kind == "sleep" then
|
||||
anim = isPlayer and "SLP_PLAYER_ANIM" or "SLP_ANIM"
|
||||
else
|
||||
anim = isPlayer and "CONF_PLAYER_ANIM" or "CONF_ANIM"
|
||||
end
|
||||
local text = kind == "sleep"
|
||||
and (displayName(user) .. "\nis fast asleep!")
|
||||
or (displayName(user) .. "\nis confused!")
|
||||
if kind == "sleep" and isPlayer then
|
||||
self:animNext(anim, isPlayer)
|
||||
self:sayNext(text)
|
||||
else
|
||||
self:sayNext(text)
|
||||
self:animNext(anim, isPlayer)
|
||||
end
|
||||
end
|
||||
|
||||
-- Queue status text (+ sleep/confusion FX when the line matches).
|
||||
-- Wake / snap-out / flinch / etc. stay text-only.
|
||||
function BattleState:sayStatusMsg(user, msg)
|
||||
local text = prefixEnemy(msg, user)
|
||||
if msg:find("is fast asleep!", 1, true) then
|
||||
self:statusOnomatopoeia(user, "sleep")
|
||||
elseif msg:find("is confused!", 1, true) then
|
||||
self:statusOnomatopoeia(user, "confused")
|
||||
else
|
||||
self:sayNext(text)
|
||||
end
|
||||
end
|
||||
|
||||
-- The pre-recharge slice of CheckPlayerStatusConditions (core.asm:
|
||||
-- 3328-3382): sleep -> freeze -> held-in-place -> flinch, each losing
|
||||
-- the turn WITHOUT consuming the recharge flag. The disable/confusion/
|
||||
@@ -2162,7 +2283,7 @@ function BattleState:preRechargeChecks(user, target)
|
||||
mon.status = nil
|
||||
self:sayNext(displayName(user) .. "\nwoke up!")
|
||||
else
|
||||
self:sayNext(displayName(user) .. "\nis fast asleep!")
|
||||
self:statusOnomatopoeia(user, "sleep")
|
||||
end
|
||||
return true
|
||||
end
|
||||
@@ -2189,7 +2310,7 @@ end
|
||||
-- returns true when the user's action is interrupted.
|
||||
function BattleState:statusInterrupt(user, target)
|
||||
local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self)
|
||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, user)) end
|
||||
for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end
|
||||
if selfHit then
|
||||
-- confusion self-hit (core.asm:3428-3434): clears everything in
|
||||
-- status1 except CONFUSED, then HandleSelfConfusionDamage deals a
|
||||
@@ -2266,11 +2387,16 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
user.charging, user.chargeReady, user.invulnerable = nil, nil, nil
|
||||
end
|
||||
|
||||
-- PP: not for continuations, struggle, or called moves
|
||||
-- PP: not for continuations, struggle, called moves, or (under
|
||||
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
|
||||
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
|
||||
local isContinuation = releasing
|
||||
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
|
||||
or moveInst == user.rageMove
|
||||
if not isContinuation and not moveInst.struggle and not isCalled then
|
||||
local enemyUnlimited = not user.isPlayer
|
||||
and self.ruleset and self.ruleset.enemyUnlimitedPP
|
||||
if not isContinuation and not moveInst.struggle and not isCalled
|
||||
and not enemyUnlimited then
|
||||
moveInst.pp = math.max(0, moveInst.pp - 1)
|
||||
end
|
||||
|
||||
@@ -2574,8 +2700,20 @@ function BattleState:enemyMonFainted()
|
||||
self.participants = {}
|
||||
|
||||
if self.kind == "trainer" then
|
||||
if self.enemyIndex < #self.enemyParty then
|
||||
self.enemyIndex = self.enemyIndex + 1
|
||||
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
|
||||
-- the whole enemy party for the first mon with HP left. Blindly
|
||||
-- doing enemyIndex+1 softlocks after an AI switch (Agatha): a later
|
||||
-- slot can already be fainted, so the empty-HP mon comes out, the
|
||||
-- FIGHT menu returns, and executeAction no-ops on target.hp <= 0.
|
||||
local nextIndex
|
||||
for i, mon in ipairs(self.enemyParty) do
|
||||
if mon.hp > 0 then
|
||||
nextIndex = i
|
||||
break
|
||||
end
|
||||
end
|
||||
if nextIndex then
|
||||
self.enemyIndex = nextIndex
|
||||
-- SHIFT battle style (the default): announce the next mon and
|
||||
-- offer a free switch (SET skips the prompt)
|
||||
local nextMon = self.enemyParty[self.enemyIndex]
|
||||
@@ -2599,6 +2737,7 @@ function BattleState:enemyMonFainted()
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, game.save)
|
||||
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
@@ -2624,6 +2763,8 @@ function BattleState:enemyMonFainted()
|
||||
self:act(function()
|
||||
local previous = self.enemy
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[2], battler = self.enemy,
|
||||
@@ -2777,6 +2918,7 @@ function BattleState:openReplacementMenu()
|
||||
self:restoreMimicked(self.player)
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, game.save)
|
||||
clearTrapping(self.enemy) -- SendOutMon clears foe trap
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
@@ -3474,17 +3616,16 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
|
||||
love.graphics.draw(img, quad, x + ox, y + oy, 0, scale, scale)
|
||||
end
|
||||
elseif k == "slideUp" then
|
||||
-- AnimationSlideMonUp: cyclic upward wrap, one row per 2 frames
|
||||
local scroll = 8 * math.min(7, math.floor(t / 2) + 1)
|
||||
local src = math.floor(scroll / scale) % h
|
||||
if src == 0 then
|
||||
love.graphics.draw(img, x + ox, y, 0, scale, scale)
|
||||
else
|
||||
local top = love.graphics.newQuad(0, src, w, h - src, w, h)
|
||||
love.graphics.draw(img, top, x + ox, y, 0, scale, scale)
|
||||
local bottom = love.graphics.newQuad(0, 0, w, src, w, h)
|
||||
love.graphics.draw(img, bottom, x + ox, y + (h - src) * scale,
|
||||
0, scale, scale)
|
||||
-- AnimationSlideMonUp (animations.asm): 7 row steps x 2f. After Dig's
|
||||
-- SLIDE_DOWN the tilemap is blank; each step fills the next bottom
|
||||
-- row so the mon emerges from underground. A cyclic wrap of a full
|
||||
-- pic looked like a bounce at Dig's end (#100).
|
||||
local step = math.min(7, math.floor((t - 1) / 2) + 1)
|
||||
local visible = math.floor(h * step / 7)
|
||||
if visible > 0 then
|
||||
local quad = love.graphics.newQuad(0, h - visible, w, visible, w, h)
|
||||
love.graphics.draw(img, quad, x + ox,
|
||||
y + (h - visible) * scale, 0, scale, scale)
|
||||
end
|
||||
elseif xscale < 1 then
|
||||
-- AnimationSquishMonPic: columns collapse toward the middle
|
||||
@@ -3696,6 +3837,20 @@ function BattleState:drawAnimLayer(colorized)
|
||||
end
|
||||
end
|
||||
|
||||
-- Front/trainer pics: LoadUncompressedSpriteData centers the sprite in
|
||||
-- a 7x7 tile buffer, then CopyUncompressedPicToTilemap places that
|
||||
-- buffer at hlcoord 12,0. Horizontal pad is floor((8-w)/2) tiles;
|
||||
-- vertical pad is (7-h) -- bottom-aligned inside the 7x7.
|
||||
local function enemyPicXY(img, slide, sx, sy)
|
||||
local tw = math.floor(img:getWidth() / 8)
|
||||
local th = math.floor(img:getHeight() / 8)
|
||||
if tw < 1 then tw = 1 elseif tw > 7 then tw = 7 end
|
||||
if th < 1 then th = 1 elseif th > 7 then th = 7 end
|
||||
local hPad = math.floor((8 - tw) / 2)
|
||||
local vPad = 7 - th
|
||||
return 96 + 8 * hPad - slide + sx, 8 * vPad + sy
|
||||
end
|
||||
|
||||
-- the two mon pics (or the trainer/back pics), offset by the window
|
||||
-- shake -- on the GB the pics are BG tiles, so they move with it
|
||||
function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
@@ -3713,19 +3868,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
g.intersectScissor(0, 0, 160, clipY)
|
||||
clipped = true
|
||||
end
|
||||
-- Enemy: front sprite top-right (GB: pic at hlcoord 12,0).
|
||||
-- Enemy: front sprite in the 7x7 slot at hlcoord 12,0.
|
||||
if self.showEnemyTrainer and self.trainerPic then
|
||||
-- the enemy trainer pic holds the mon slot until the send-out
|
||||
local img = self:picImage(self.trainerPic)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 160 - 8 - img:getWidth() - slide + sx,
|
||||
math.max(0, 48 - img:getHeight()) + sy)
|
||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||
love.graphics.draw(img, ex, ey)
|
||||
elseif self.enemy and self.enemy.sprite and not self.enemyHidden
|
||||
and not self.enemySendingOut and not self:fxHidden(self.enemy) then
|
||||
local img = self:picImage(self.enemy.sprite)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local ex = 160 - 8 - img:getWidth() - slide + sx
|
||||
local ey = math.max(0, 48 - img:getHeight()) + sy
|
||||
local ex, ey = enemyPicXY(img, slide, sx, sy)
|
||||
local gs = self:growInScale(self.enemy)
|
||||
if gs then
|
||||
-- AnimateSendingOutMon: the downscaled pic keeps its bottom edge
|
||||
@@ -3739,15 +3893,18 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
end
|
||||
end
|
||||
|
||||
-- Player: back sprite bottom-left (2x like the GB, feet near y=100).
|
||||
-- Player: back sprite at hlcoord 1,5 (x=8), 2x like the GB, feet at y=96.
|
||||
-- Left transparent columns (matted white) are pulled back so opaque
|
||||
-- pixels land where hardware's white-on-white columns left them.
|
||||
local hidePlayer = self.safari or self.demo
|
||||
if self.showPlayerBack and self.playerBackPic then
|
||||
-- Red's (or the old man's) back pic until "Go!"; it stays up for
|
||||
-- the whole safari / catch-demo battle like the original
|
||||
local img = self:picImage(self.playerBackPic)
|
||||
local pad = imagePadBottom[self.playerBackPic] or 0
|
||||
local padL = imagePadLeft[self.playerBackPic] or 0
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 16 + slide + sx,
|
||||
love.graphics.draw(img, 8 - padL * 2 + slide + sx,
|
||||
96 - (img:getHeight() - pad) * 2 + sy, 0, 2, 2)
|
||||
elseif self.player and self.player.sprite and not hidePlayer
|
||||
and not self.sendingOut and not self:fxHidden(self.player) then
|
||||
@@ -3755,17 +3912,19 @@ function BattleState:drawPicsLayer(slide, sx, sy)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- feet flush on the text box top (y=96), ignoring baked-in padding
|
||||
local pad = imagePadBottom[self.player.sprite] or 0
|
||||
local padL = imagePadLeft[self.player.sprite] or 0
|
||||
local px = 8 - padL * 2 + sx
|
||||
local gs = self:growInScale(self.player)
|
||||
if gs then
|
||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=16+w
|
||||
-- core.asm:1757-1762): feet pinned at y=96, center at x=8+w
|
||||
if gs > 0 then
|
||||
love.graphics.draw(img, 16 + img:getWidth() * (1 - gs) + sx,
|
||||
love.graphics.draw(img, px + img:getWidth() * (1 - gs),
|
||||
96 - (img:getHeight() - pad) * 2 * gs + sy,
|
||||
0, 2 * gs, 2 * gs)
|
||||
end
|
||||
else
|
||||
self:drawBattlerPic(self.player, 16 + sx,
|
||||
self:drawBattlerPic(self.player, px,
|
||||
96 - (img:getHeight() - pad) * 2 + sy, 2)
|
||||
end
|
||||
end
|
||||
@@ -3820,16 +3979,22 @@ function BattleState:drawHUDs(slide)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72)
|
||||
end
|
||||
-- trainer-battle party pokeball rows during the intro
|
||||
-- trainer/link party pokeball rows during the intro
|
||||
-- (SetupPlayerAndEnemyPokeballs, draw_hud_pokeball_gfx.asm)
|
||||
if self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack)
|
||||
and slide == 0 then
|
||||
local showIntroBalls = slide == 0 and (
|
||||
(self.kind == "trainer" and (self.showEnemyTrainer or self.showPlayerBack))
|
||||
or (self.kind == "link" and (self.showPlayerBack or self.enemySendingOut))
|
||||
)
|
||||
if showIntroBalls then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.showEnemyTrainer and self.enemyParty then
|
||||
if self.enemyParty and (
|
||||
(self.kind == "trainer" and self.showEnemyTrainer)
|
||||
or (self.kind == "link" and self.enemySendingOut)
|
||||
) then
|
||||
self:drawBallRow(self.enemyParty, 64, 16, -8)
|
||||
end
|
||||
if self.showPlayerBack then
|
||||
self:drawBallRow(self.game.save.party, 88, 80, 8)
|
||||
self:drawBallRow(self.playerParty or self.game.save.party, 88, 80, 8)
|
||||
end
|
||||
end
|
||||
local hidePlayer = self.safari or self.demo
|
||||
@@ -3863,7 +4028,8 @@ function BattleState:drawTextArea()
|
||||
if self.phase == "messages" and self.current then
|
||||
local shown = 0
|
||||
for li, codes in ipairs(self.lines) do
|
||||
local y = 104 + li * 8
|
||||
-- battle text uses every other tile row (hlcoord *,14 / *,16)
|
||||
local y = 112 + (li - 1) * 16
|
||||
for i = 1, #codes do
|
||||
if shown >= self.charIndex then break end
|
||||
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
-- (super-effective) or adds 1 (not-effective when a better move exists);
|
||||
-- the MINIMUM-scored move is chosen, ties broken uniformly among the
|
||||
-- tied minima (core.asm:2971-3002). A non-minimal move is never
|
||||
-- selectable. Respects PP, Disable and Transform/Mimic move overrides.
|
||||
-- selectable. Respects Disable (and PP only when the ruleset depletes
|
||||
-- enemy PP — Gen 1 AI never reads wEnemyMonPP).
|
||||
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
@@ -212,9 +213,13 @@ end
|
||||
|
||||
function TrainerAI.chooseMove(battler, rng, battle)
|
||||
rng = rng or love.math.random
|
||||
-- Gen 1: SelectEnemyMove never consults wEnemyMonPP; Struggle only when
|
||||
-- every move slot is missing/disabled (core.asm:2957-2999). modern_clean
|
||||
-- depletes enemy PP and falls back to Struggle when none remain.
|
||||
local unlimited = battle and battle.ruleset and battle.ruleset.enemyUnlimitedPP
|
||||
local usable = {}
|
||||
for i, mv in ipairs(battler.curMoves) do
|
||||
if mv.pp > 0 and battler.disabledSlot ~= i then
|
||||
if battler.disabledSlot ~= i and (unlimited or mv.pp > 0) then
|
||||
table.insert(usable, mv)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,4 +13,7 @@ return {
|
||||
randMax = 255,
|
||||
-- Focus Energy famously QUARTERS the crit rate instead of x4
|
||||
focusEnergyBug = true,
|
||||
-- Wild/trainer enemies never spend PP (DecrementPP only touches the
|
||||
-- player side in pokered). They therefore never Struggle from empty PP.
|
||||
enemyUnlimitedPP = true,
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ return {
|
||||
randMin = 217,
|
||||
randMax = 255,
|
||||
focusEnergyBug = false,
|
||||
-- Gen 2+ style: AI opponents deplete PP and can Struggle when empty.
|
||||
enemyUnlimitedPP = false,
|
||||
}
|
||||
|
||||
@@ -27,4 +27,12 @@ function FixedStep:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop any pending catch-up steps. A hitch inside one logic step (map
|
||||
-- seam setMap / song start) makes the next real-time dt huge; without this
|
||||
-- the while-loop above would advance many walk frames before the next
|
||||
-- draw, which looks like a slide with no leg animation (issue #93).
|
||||
function FixedStep:discardCatchup()
|
||||
self.accum = 0
|
||||
end
|
||||
|
||||
return FixedStep
|
||||
|
||||
@@ -478,6 +478,25 @@ SaveData.addCoreMigration(2, function(save)
|
||||
end
|
||||
end)
|
||||
|
||||
-- #131 / follow-up to #50: Game Corner poster grunt used to stay on the
|
||||
-- floor after defeat (defeatedTrainers only). The #50 script now hides
|
||||
-- him via objectToggles, but saves that already beat him never got the
|
||||
-- toggle -- he still blocks the hideout switch. from=3 so every pre-4
|
||||
-- save reconciles once, then is skipped after re-stamp.
|
||||
SaveData.addCoreMigration(3, function(save)
|
||||
local defeated = save.defeatedTrainers
|
||||
if not defeated or not defeated["GAME_CORNER_obj_11"] then return end
|
||||
save.objectToggles = save.objectToggles or {}
|
||||
local mapToggles = save.objectToggles.GAME_CORNER
|
||||
if not mapToggles then
|
||||
mapToggles = {}
|
||||
save.objectToggles.GAME_CORNER = mapToggles
|
||||
end
|
||||
if mapToggles.GAMECORNER_ROCKET ~= false then
|
||||
mapToggles.GAMECORNER_ROCKET = false
|
||||
end
|
||||
end)
|
||||
|
||||
-- ------- write
|
||||
|
||||
-- Game progress only; options are written separately via saveOptions.
|
||||
|
||||
@@ -7,7 +7,7 @@ local Version = {
|
||||
engine = "1.0.0", -- game/engine release (semver triple)
|
||||
modApi = 2, -- mod API major (manifest `api`)
|
||||
linkProtocol = 2, -- link handshake wire version (Handshake.PROTOCOL)
|
||||
saveFormat = 3, -- save.meta.format
|
||||
saveFormat = 4, -- save.meta.format
|
||||
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
|
||||
}
|
||||
|
||||
|
||||
+49
-27
@@ -36,18 +36,13 @@ local function makeRng(seed)
|
||||
end
|
||||
end
|
||||
|
||||
-- battler builder shared by both sides -- NO badge boosts, so both
|
||||
-- machines compute identical stats
|
||||
-- Battlers go through BattleState.makeBattler so pics get the same
|
||||
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
|
||||
-- battles. save=nil skips badge boosts so both machines keep identical
|
||||
-- stats (Gen 1 cable battles famously kept badges; we still diverge).
|
||||
local function mkBattler(data, mon, isPlayer)
|
||||
local def = data.pokemon[mon.species]
|
||||
local ok, img = pcall(love.graphics.newImage,
|
||||
isPlayer and def.spriteBack or def.spriteFront)
|
||||
return {
|
||||
mon = mon, def = def, isPlayer = isPlayer, stages = {},
|
||||
name = mon.nickname or def.name,
|
||||
curStats = mon.stats, curTypes = def.types, curMoves = mon.moves,
|
||||
sprite = ok and img or nil,
|
||||
}
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
return BattleState.makeBattler(data, mon, isPlayer, nil)
|
||||
end
|
||||
|
||||
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
|
||||
@@ -213,6 +208,8 @@ function LinkBattle.new(game, net, opts)
|
||||
self.player = mkBattler(game.data, myParty[1], true)
|
||||
self.enemy = mkBattler(game.data, theirParty[1], false)
|
||||
self.enemyParty = theirParty
|
||||
self.playerParty = myParty -- intro ball row uses the clamped copies
|
||||
self.opponentName = theirName
|
||||
self.introText = ("%s wants\nto battle!"):format(theirName)
|
||||
self.remoteHashes = {}
|
||||
self.localHashes = {}
|
||||
@@ -235,6 +232,43 @@ function LinkBattle.new(game, net, opts)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- player-side SendOutMon (poof + grow-in + cry); mirrors BattleState
|
||||
local function sendOutPlayer(s, mon)
|
||||
local previous = s.player
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[1], battler = s.player, previous = previous,
|
||||
})
|
||||
s.sendingOut = true
|
||||
s:sayNext(s:sendOutText(s.player.name))
|
||||
s:animNext("POOF_ANIM", false)
|
||||
s:actNext(function()
|
||||
s.sendingOut = false
|
||||
s:startGrowIn(s.player)
|
||||
require("src.core.Sound").playCry(s.data, s.player.mon.species)
|
||||
end)
|
||||
end
|
||||
|
||||
-- enemy-side EnemySendOut (grow-in + cry; no poof)
|
||||
local function sendOutEnemy(s, mon)
|
||||
local previous = s.enemy
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
s:actNext(function()
|
||||
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
-- decode a remote action message against the enemy battler
|
||||
local function decodeTheirAction(s, msg)
|
||||
if msg.kind == "move" then
|
||||
@@ -306,17 +340,11 @@ function LinkBattle.new(game, net, opts)
|
||||
-- switches happen before attacks (both may switch)
|
||||
if myMsg.kind == "switch" then
|
||||
local idx = myMsg.index
|
||||
s:act(function()
|
||||
s.player = mkBattler(game.data, myParty[idx], true)
|
||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
||||
end)
|
||||
s:act(function() sendOutPlayer(s, myParty[idx]) end)
|
||||
myAction = nil
|
||||
end
|
||||
if theirSwitch then
|
||||
s:act(function()
|
||||
s.enemy = mkBattler(game.data, theirParty[theirSwitch], false)
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
end)
|
||||
s:act(function() sendOutEnemy(s, theirParty[theirSwitch]) end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
@@ -452,10 +480,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(myParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function()
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:sayNext(("Go! %s!"):format(s.player.name))
|
||||
end)
|
||||
s:act(function() sendOutPlayer(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
@@ -468,10 +493,7 @@ function LinkBattle.new(game, net, opts)
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(theirParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function()
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:sayNext(("%s sent\nout %s!"):format(theirName, s.enemy.name))
|
||||
end)
|
||||
s:act(function() sendOutEnemy(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,6 +18,12 @@ ManagerState.isOpaque = true
|
||||
-- Game:keypressed recognizes a directly-pushed instance
|
||||
ManagerState.screenId = "ManagerState"
|
||||
|
||||
-- Same as OptionsMenu: without this, title LOGO zones leak through when
|
||||
-- MODS is opened from the title-screen options path.
|
||||
function ManagerState:sgbPalettes(game)
|
||||
return require("src.render.PaletteFX").wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
-- the charmap has no * ~ + < > glyphs, so the status gutter uses what it
|
||||
-- does have: staged-awaiting-restart, disabled, errored, dep-unhealthy
|
||||
local GLYPH = { staged = ".", disabled = "-", errored = "!", blocked = "?" }
|
||||
|
||||
+49
-15
@@ -23,6 +23,29 @@ Renderer.HEIGHT = 144
|
||||
-- endFrame composites the padded canvas back with a matching offset.
|
||||
Renderer.UPRIGHT_MARGIN = 160
|
||||
|
||||
-- LOVE units + framebuffer pixels + the live unit→pixel ratio.
|
||||
-- Android's DisplayMetrics.density (love.graphics.getDPIScale) is often
|
||||
-- non-integer (1.5, 2.75, …). Integer scaling in units then maps each GB
|
||||
-- pixel to a fractional number of framebuffer pixels → shimmer, uneven /
|
||||
-- non-square "pixels", and movement judder (issue #87). Always derive the
|
||||
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
|
||||
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
||||
local function displayMetrics()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
local dpi = 1
|
||||
if ww > 0 and pw > 0 then
|
||||
dpi = pw / ww
|
||||
elseif love.graphics.getDPIScale then
|
||||
dpi = love.graphics.getDPIScale()
|
||||
end
|
||||
if not dpi or dpi < 1e-6 then dpi = 1 end
|
||||
return ww, wh, pw, ph, dpi
|
||||
end
|
||||
|
||||
function Renderer:init()
|
||||
self.canvas = love.graphics.newCanvas(self.WIDTH, self.HEIGHT)
|
||||
self.canvas:setFilter("nearest", "nearest")
|
||||
@@ -36,10 +59,12 @@ function Renderer:init()
|
||||
self.uprightActive = false
|
||||
end
|
||||
|
||||
-- integer scale that fits the GB UI viewport in the window
|
||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
||||
-- units via / dpi when drawing.
|
||||
function Renderer:fitScale()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
return math.max(1, math.floor(math.min(ww / self.WIDTH, wh / self.HEIGHT)))
|
||||
local _, _, pw, ph = displayMetrics()
|
||||
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
|
||||
end
|
||||
|
||||
-- world-pass canvas size in world pixels: enough to fill the window at s'.
|
||||
@@ -48,9 +73,13 @@ end
|
||||
-- background peeking at the receded top/bottom corners; flat mode returns
|
||||
-- exactly today's size (growth factor is 1 when tilt is inactive).
|
||||
function Renderer:worldViewSize()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local s = Zoom.scale(self:fitScale())
|
||||
local ww, wh, _, _, dpi = displayMetrics()
|
||||
local s = Zoom.scale(self:fitScale()) / dpi
|
||||
local vw, vh = Zoom.fillViewSize(s, ww, wh)
|
||||
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
||||
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
||||
if vw % 2 ~= 0 then vw = vw + 1 end
|
||||
if vh % 2 ~= 0 then vh = vh + 1 end
|
||||
if Tilt.active() then
|
||||
local g = Tilt.viewGrowth()
|
||||
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
||||
@@ -289,11 +318,14 @@ end
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local S = self:fitScale()
|
||||
local ww, wh, pw, ph, dpi = displayMetrics()
|
||||
-- Sp = integer framebuffer pixels per GB pixel; S = LOVE-unit draw scale.
|
||||
local Sp = self:fitScale()
|
||||
local S = Sp / dpi
|
||||
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
||||
local ox = math.floor((ww - vpw) / 2)
|
||||
local oy = math.floor((wh - vph) / 2)
|
||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||
local ox = math.floor((pw - self.WIDTH * Sp) / 2) / dpi
|
||||
local oy = math.floor((ph - self.HEIGHT * Sp) / 2) / dpi
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
@@ -321,9 +353,9 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
-- blit `canvas` at integer `scale` into origin (bx, by), scissored to
|
||||
-- blit `canvas` at `scale` (LOVE units) into origin (bx, by), scissored to
|
||||
-- the (boxX, boxY, boxW, boxH) screen rect. zoneScale converts zone
|
||||
-- coords (canvas-space) into screen pixels.
|
||||
-- coords (canvas-space) into screen units.
|
||||
local function blit(canvas, scale, zoneList, zoneScale, bx, by, boxX, boxY, boxW, boxH)
|
||||
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if not shader then
|
||||
@@ -355,11 +387,12 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
|
||||
if self.worldActive then
|
||||
local s = Zoom.scale(S)
|
||||
local sp = Zoom.scale(Sp)
|
||||
local s = sp / dpi
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
local wox = math.floor((ww - wvw * s) / 2)
|
||||
local woy = math.floor((wh - wvh * s) / 2)
|
||||
local wox = math.floor((pw - wvw * sp) / 2) / dpi
|
||||
local woy = math.floor((ph - wvh * sp) / 2) / dpi
|
||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||
@@ -433,7 +466,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
|
||||
if present then
|
||||
love.graphics.setCanvas()
|
||||
GBCFX.present(present, S)
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(present, Sp)
|
||||
end
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
|
||||
+49
-2
@@ -484,11 +484,51 @@ function Commands.heal_party(ctx)
|
||||
end
|
||||
end
|
||||
|
||||
-- AskName (engine/menus/naming_screen.asm): yes/no then NamingScreen.
|
||||
-- AddPartyMon only offers this for party mons (box deposits skip it).
|
||||
-- Preserves ctx.lastCheck so GivePokemon's carry is not clobbered by the
|
||||
-- yes/no result (scripts jump_if_false on give failure afterwards).
|
||||
local function askNickname(ctx, mon)
|
||||
local runner = ctx.runner
|
||||
if not runner then return end
|
||||
local success = ctx.lastCheck
|
||||
local name = ctx.game.stringBuffer
|
||||
or (ctx.game.data.pokemon[mon.species]
|
||||
and ctx.game.data.pokemon[mon.species].name)
|
||||
or mon.species
|
||||
-- Prefer the real text label; fall back to the BattleState wording.
|
||||
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
|
||||
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name })
|
||||
else
|
||||
Commands.show_text(ctx, ("Do you want to\ngive a nickname\nto %s?"):format(name))
|
||||
end
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes)
|
||||
if not yes then
|
||||
ctx.lastCheck = success
|
||||
runner:resume()
|
||||
return
|
||||
end
|
||||
Screens.push(ctx.game, "NamingScreen", {
|
||||
title = "NICKNAME?", maxLen = 10,
|
||||
onDone = function(nick)
|
||||
if nick and #nick > 0 then mon.nickname = nick end
|
||||
ctx.lastCheck = success
|
||||
runner:resume()
|
||||
end,
|
||||
})
|
||||
end))
|
||||
runner:yield()
|
||||
ctx.lastCheck = success
|
||||
end
|
||||
|
||||
-- give_pokemon <species> <level>: _GivePokemon (engine/events/
|
||||
-- give_pokemon.asm) -- party first, then the box. ctx.lastCheck gets
|
||||
-- the asm's carry: true when the mon was given, false when both the
|
||||
-- party and every box are full (that .boxFull path leaves the giver's
|
||||
-- script able to offer again later, e.g. the Celadon Eevee ball).
|
||||
-- Party adds run AskName (AddPartyMon) when a script runner is present;
|
||||
-- mods that pre-set gift.nickname skip the prompt.
|
||||
function Commands.give_pokemon(ctx, species, level)
|
||||
-- Native mods can transform a gift before the Pokémon object is created.
|
||||
-- This is intentionally an event rather than a special-case starter hook:
|
||||
@@ -505,7 +545,8 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
ctx.game.stringBuffer = ctx.game.data.pokemon[species].name or species
|
||||
ctx.pendingPokemonName = species
|
||||
require("src.battle.BattleState").stampOT(ctx.save, mon)
|
||||
if not Party.add(ctx.save.party, mon) then
|
||||
local addedToParty = Party.add(ctx.save.party, mon)
|
||||
if not addedToParty then
|
||||
if not require("src.pokemon.Boxes").deposit(ctx.save, mon) then
|
||||
ctx.lastCheck = false
|
||||
return
|
||||
@@ -517,6 +558,11 @@ function Commands.give_pokemon(ctx, species, level)
|
||||
dex.owned[species] = true
|
||||
end
|
||||
ctx.lastCheck = true
|
||||
-- AddPartyMon AskName: party only; skip box deposits, mod-set nicknames,
|
||||
-- and callback-style callers that have no script runner to yield on.
|
||||
if addedToParty and not gift.nickname and ctx.runner then
|
||||
askNickname(ctx, mon)
|
||||
end
|
||||
end
|
||||
|
||||
function Commands.give_money(ctx, amount)
|
||||
@@ -1066,7 +1112,8 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
end
|
||||
for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp",
|
||||
"open_mart", "trade", "push_screen", "record_hall_of_fame",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item", "wait",
|
||||
"old_man_demo", "static_battle", "rival_battle", "give_item",
|
||||
"give_pokemon", "wait",
|
||||
"wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc",
|
||||
"emote", "fade", "pan_camera", "play_once" }) do
|
||||
local meta = Commands.meta[verb] or {}
|
||||
|
||||
+3
-1
@@ -216,7 +216,9 @@ local function useOn(game, battle, id, target, list, moveIndex)
|
||||
consume(game, id)
|
||||
require("src.core.Sound").play(game.data, "Teleport_Exit1")
|
||||
ow.player.surfing = false
|
||||
ow:warpToHealPoint()
|
||||
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
|
||||
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
else
|
||||
showMessages(game, { "OAK: " .. game.save.player.name
|
||||
.. "!\nThis isn't the\ntime to use that!" })
|
||||
|
||||
+204
-79
@@ -1,14 +1,16 @@
|
||||
-- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party
|
||||
-- member's front sprite scrolls onto the screen (HoFShowMonOrPlayer's
|
||||
-- .ScrollPic), then its name/level shows and its cry plays
|
||||
-- (HoFDisplayAndRecordMonInfo). After the last mon, HoFDisplayPlayerStats
|
||||
-- shows the trainer name, play time, money and Prof. Oak's dex rating.
|
||||
-- Plays Music_HallOfFame when the audio data has it. Calls onDone() after
|
||||
-- popping itself.
|
||||
-- member's front sprite scrolls onto the right side of the screen
|
||||
-- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the
|
||||
-- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom
|
||||
-- "HALL OF FAME" text box before fading to the next mon. After the
|
||||
-- party, the player pic scrolls in and HoFDisplayPlayerStats shows the
|
||||
-- name/time/money boxes plus the dex rating. Plays Music_HallOfFame
|
||||
-- when the audio data has it. Calls onDone() after popping itself.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
|
||||
local HallOfFame = {}
|
||||
HallOfFame.__index = HallOfFame
|
||||
@@ -17,6 +19,10 @@ HallOfFame.isOpaque = true
|
||||
-- SGB: SetPal_PokemonWholeScreen for the mon on display
|
||||
function HallOfFame:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
if self.phase == "player" or self.phase == "player_stats"
|
||||
or self.phase == "player_dex" or self.phase == "player_rating" then
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
local mon = game.save.party[self.index or 0]
|
||||
if mon then
|
||||
local c = P.monPal(game.data, mon.species)
|
||||
@@ -26,17 +32,20 @@ function HallOfFame:sgbPalettes(game)
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
local MON_FRAMES = 150 -- ~2.5s per inductee (A advances early)
|
||||
|
||||
-- HoFShowMonOrPlayer's .ScrollPic: hSCX is nudged by e = 4px per
|
||||
-- DelayFrame (doubled on SGB) until it settles. The back pic (an
|
||||
-- enlarged, blurred 2x scale of the back sprite) sweeps right-to-left
|
||||
-- and off the left edge first; tracing the actual hSCX/hSCY math shows
|
||||
-- the real front pic that follows enters from the *left* edge and
|
||||
-- slides *right* into its resting tile, at that same 4px/frame rate --
|
||||
-- that's the half we port here (the back-pic wipe is a VRAM/scroll-
|
||||
-- register trick with no equivalent in this sprite-based renderer).
|
||||
-- DelayFrame (doubled on SGB) until it settles. The front pic rests at
|
||||
-- hlcoord 12,5 (engine/movie/hall_of_fame.asm HoFLoadMonPlayerPicTileIDs).
|
||||
local SCROLL_SPEED = 4 -- px/frame @ 60fps
|
||||
local PIC_X, PIC_Y = 12 * 8, 5 * 8
|
||||
|
||||
-- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom
|
||||
-- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite.
|
||||
local INFO_HOLD = 80
|
||||
local HOF_HOLD = 180
|
||||
local FADE_FRAMES = 20
|
||||
|
||||
-- HoFPrintTextAndDelay after each dex line
|
||||
local DEX_HOLD = 120
|
||||
|
||||
local function tryImage(path)
|
||||
if not path then return nil end
|
||||
@@ -57,7 +66,7 @@ local function drawTextBlock(text, x, y, maxY)
|
||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
if maxY and y > maxY then break end
|
||||
Font.draw(line, x, y)
|
||||
y = y + 10
|
||||
y = y + 8
|
||||
end
|
||||
return y
|
||||
end
|
||||
@@ -70,6 +79,10 @@ function HallOfFame.new(game, onDone)
|
||||
self.timer = 0
|
||||
self.phase = "mons"
|
||||
self.sprites = {} -- species -> image or false
|
||||
self.playerPic = tryImage("assets/generated/trainer_card/red.png")
|
||||
self.scrollX = PIC_X
|
||||
self.showHofBanner = false
|
||||
self.fade = 0
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -84,16 +97,21 @@ end
|
||||
function HallOfFame:nextMon()
|
||||
self.index = self.index + 1
|
||||
local mon = self.game.save.party[self.index]
|
||||
self.showHofBanner = false
|
||||
self.fade = 0
|
||||
if mon then
|
||||
self.timer = MON_FRAMES
|
||||
self.phase = "mons"
|
||||
self.timer = INFO_HOLD
|
||||
Sound.playCry(self.game.data, mon.species)
|
||||
-- scroll the new inductee's pic in from the left (see SCROLL_SPEED)
|
||||
local sprite = self:spriteFor(mon.species)
|
||||
local w = sprite and sprite:getWidth() or 0
|
||||
self.scrollRestX = math.floor((160 - w) / 2)
|
||||
local w = sprite and sprite:getWidth() or 56
|
||||
self.scrollX = -w
|
||||
else
|
||||
self.phase = "congrats"
|
||||
-- HoFShowMonOrPlayer with wHoFMonOrPlayer = player
|
||||
self.phase = "player"
|
||||
self.timer = 0
|
||||
local w = self.playerPic and self.playerPic:getWidth() or 56
|
||||
self.scrollX = -w
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,77 +135,184 @@ function HallOfFame:dexSeenOwned()
|
||||
return seen, owned
|
||||
end
|
||||
|
||||
function HallOfFame:update(dt)
|
||||
local input = self.game.input
|
||||
if self.phase == "mons" then
|
||||
if self.scrollX and self.scrollX < self.scrollRestX then
|
||||
self.scrollX = math.min(self.scrollRestX, self.scrollX + SCROLL_SPEED)
|
||||
end
|
||||
self.timer = self.timer - 1
|
||||
if input:wasPressed("a") or self.timer <= 0 then
|
||||
self:nextMon()
|
||||
end
|
||||
elseif input:wasPressed("a") then
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
function HallOfFame:advanceMonPhase()
|
||||
if self.phase == "mons" and not self.showHofBanner then
|
||||
-- 80-frame info hold done: TextBoxBorder at (2,13) + "HALL OF FAME"
|
||||
self.showHofBanner = true
|
||||
self.timer = HOF_HOLD
|
||||
elseif self.phase == "mons" then
|
||||
self.phase = "fade"
|
||||
self.timer = FADE_FRAMES
|
||||
self.fade = 0
|
||||
elseif self.phase == "fade" then
|
||||
self:nextMon()
|
||||
end
|
||||
end
|
||||
|
||||
function HallOfFame:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.phase == "mons" then
|
||||
Font.draw("HALL OF FAME", (160 - 12 * 8) / 2, 8)
|
||||
local mon = self.game.save.party[self.index]
|
||||
if mon then
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local sprite = self:spriteFor(mon.species)
|
||||
if sprite then
|
||||
local w, h = sprite:getDimensions()
|
||||
love.graphics.draw(sprite, self.scrollX or math.floor((160 - w) / 2), 96 - h)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local name = mon.nickname or (def and def.name) or mon.species
|
||||
Font.draw(name, 32, 108)
|
||||
Font.draw((":L%d"):format(mon.level), 112, 108)
|
||||
end
|
||||
else
|
||||
-- HoFDisplayPlayerStats (no "HALL OF FAME" banner here -- the real
|
||||
-- screen is a fresh ClearScreen): trainer name, play time, money,
|
||||
-- then the POKéDEX seen/owned tally and Prof. Oak's rating text,
|
||||
-- using the same real save-data fields as TrainerCard.lua
|
||||
-- (save.player.name/playTime/money) and PokedexMenu.lua/
|
||||
-- OverworldController:dexRating (save.pokedex.seen/owned).
|
||||
local save = self.game.save
|
||||
local text = self.game.data.text or {}
|
||||
local y = 8
|
||||
Font.draw(save.player.name or "RED", 8, y)
|
||||
y = y + 16
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw(("PLAY TIME %3d:%02d"):format(math.floor(t / 3600),
|
||||
math.floor(t / 60) % 60), 8, y)
|
||||
y = y + 12
|
||||
Font.draw(("MONEY ¥%d"):format(save.money or 0), 8, y)
|
||||
y = y + 16
|
||||
function HallOfFame:update(dt)
|
||||
local input = self.game.input
|
||||
local skip = input:wasPressed("a")
|
||||
|
||||
if self.phase == "mons" or self.phase == "fade" then
|
||||
if self.phase == "mons" and self.scrollX < PIC_X then
|
||||
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
|
||||
return
|
||||
end
|
||||
if self.phase == "fade" then
|
||||
self.timer = self.timer - 1
|
||||
self.fade = 1 - math.max(0, self.timer) / FADE_FRAMES
|
||||
if self.timer <= 0 or skip then self:advanceMonPhase() end
|
||||
return
|
||||
end
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self:advanceMonPhase()
|
||||
end
|
||||
elseif self.phase == "player" then
|
||||
if self.scrollX < PIC_X then
|
||||
self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED)
|
||||
return
|
||||
end
|
||||
self.phase = "player_stats"
|
||||
self.timer = DEX_HOLD
|
||||
elseif self.phase == "player_stats" then
|
||||
-- name / play time / money boxes are up; then DexSeenOwnedText
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self.phase = "player_dex"
|
||||
self.timer = DEX_HOLD
|
||||
end
|
||||
elseif self.phase == "player_dex" then
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
self.phase = "player_rating"
|
||||
self.timer = DEX_HOLD
|
||||
end
|
||||
elseif self.phase == "player_rating" then
|
||||
self.timer = self.timer - 1
|
||||
if skip or self.timer <= 0 then
|
||||
-- HoFFadeOutScreenAndMusic -> Credits lead-in (no A wait here)
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- HoFDisplayMonInfo: TextBoxBorder (0,2) b=9,c=10 + LEVEL/TYPE labels
|
||||
function HallOfFame:drawMonInfo(mon)
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
Font.drawBox(0, 2, 12, 11)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local name = mon.nickname or (def and def.name) or mon.species
|
||||
Font.draw(name, 1 * 8, 4 * 8)
|
||||
Font.draw("LEVEL/", 2 * 8, 6 * 8)
|
||||
Font.draw("TYPE1/", 2 * 8, 7 * 8)
|
||||
local t1 = def and def.types and def.types[1]
|
||||
local t2 = def and def.types and def.types[2]
|
||||
local dual = t2 and t2 ~= t1
|
||||
if dual then
|
||||
Font.draw("TYPE2/", 2 * 8, 8 * 8)
|
||||
end
|
||||
-- PrintLevelCommon at (8,7): bare level digits (no <LV> tile here)
|
||||
Font.draw(tostring(mon.level), 8 * 8, 7 * 8)
|
||||
-- PrintMonType at (3,9) / +2 rows for type 2
|
||||
if t1 then
|
||||
Font.draw(TypeChart.displayName(t1), 3 * 8, 9 * 8)
|
||||
end
|
||||
if dual then
|
||||
Font.draw(TypeChart.displayName(t2), 3 * 8, 11 * 8)
|
||||
end
|
||||
end
|
||||
|
||||
-- Bottom HALL OF FAME banner: TextBoxBorder (2,13) b=3,c=14
|
||||
function HallOfFame:drawHofBanner()
|
||||
Font.drawBox(2, 13, 16, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("HALL OF FAME", 4 * 8, 15 * 8)
|
||||
end
|
||||
|
||||
function HallOfFame:drawPic(img)
|
||||
if not img then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y)
|
||||
end
|
||||
|
||||
-- HoFDisplayPlayerStats boxes + labels (player pic already on the right)
|
||||
function HallOfFame:drawPlayerStats()
|
||||
local save = self.game.save
|
||||
-- name box: TextBoxBorder (5,0) b=2,c=9 → drawBox(5,0,11,4)
|
||||
Font.drawBox(5, 0, 11, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(save.player.name or "RED", 7 * 8, 2 * 8)
|
||||
|
||||
-- play time / money box: TextBoxBorder (0,4) b=6,c=10 → drawBox(0,4,12,8)
|
||||
Font.drawBox(0, 4, 12, 8)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("PLAY TIME", 1 * 8, 6 * 8)
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600), math.floor(t / 60) % 60),
|
||||
5 * 8, 7 * 8)
|
||||
Font.draw("MONEY", 1 * 8, 9 * 8)
|
||||
-- PrintBCDNumber with MONEY_SIGN; port uses ¥ like TrainerCard
|
||||
Font.draw(("¥%d"):format(save.money or 0), 4 * 8, 10 * 8)
|
||||
end
|
||||
|
||||
function HallOfFame:drawDexBox(kind)
|
||||
local save = self.game.save
|
||||
local text = self.game.data.text or {}
|
||||
Font.drawBox(0, 12, 20, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if kind == "seen" then
|
||||
local seen, owned = self:dexSeenOwned()
|
||||
local seenOwned = text._DexSeenOwnedText
|
||||
or "POKéDEX Seen:{NUM:wDexRatingNumMonsSeen, 1, 3}\n Owned:{NUM:wDexRatingNumMonsOwned, 1, 3}"
|
||||
seenOwned = seenOwned
|
||||
:gsub("{NUM:wDexRatingNumMonsSeen[^}]*}", tostring(seen))
|
||||
:gsub("{NUM:wDexRatingNumMonsOwned[^}]*}", tostring(owned))
|
||||
y = drawTextBlock(seenOwned, 8, y) + 6
|
||||
|
||||
drawTextBlock(seenOwned, 1 * 8, 14 * 8, 17 * 8)
|
||||
else
|
||||
local _, owned = self:dexSeenOwned()
|
||||
local ratingHeader = (text._DexRatingText or "POKéDEX Rating{COLON}"):gsub("{COLON}", ":")
|
||||
Font.draw(ratingHeader, 8, y)
|
||||
y = y + 12
|
||||
|
||||
Font.draw(ratingHeader, 1 * 8, 14 * 8)
|
||||
local rating = text[dexRatingKey(owned)] or "Keep it up!"
|
||||
drawTextBlock(rating, 8, y, 136)
|
||||
drawTextBlock(rating, 1 * 8, 15 * 8, 17 * 8)
|
||||
end
|
||||
end
|
||||
|
||||
function HallOfFame:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
|
||||
if self.phase == "mons" or self.phase == "fade" then
|
||||
local mon = self.game.save.party[self.index]
|
||||
if mon then
|
||||
self:drawPic(self:spriteFor(mon.species))
|
||||
if self.scrollX >= PIC_X then
|
||||
self:drawMonInfo(mon)
|
||||
if self.showHofBanner then
|
||||
self:drawHofBanner()
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.phase == "fade" and self.fade > 0 then
|
||||
love.graphics.setColor(1, 1, 1, self.fade)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
end
|
||||
elseif self.phase == "player" then
|
||||
self:drawPic(self.playerPic)
|
||||
elseif self.phase == "player_stats" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
elseif self.phase == "player_dex" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
self:drawDexBox("seen")
|
||||
elseif self.phase == "player_rating" then
|
||||
self:drawPic(self.playerPic)
|
||||
self:drawPlayerStats()
|
||||
self:drawDexBox("rating")
|
||||
end
|
||||
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+43
-4
@@ -19,7 +19,16 @@ function Menu.new(game, items, opts)
|
||||
self.tx = opts.tx or 10
|
||||
self.ty = opts.ty or 0
|
||||
self.tw = opts.tw or 10
|
||||
self.th = opts.th or (#items * 2 + 2)
|
||||
self.rowStep = opts.rowStep or 2
|
||||
-- maxVisible: cap the box to this many rows and scroll the rest instead
|
||||
-- of growing past it (e.g. the start menu, whose row count varies with
|
||||
-- save state and mod hooks); nil/unset keeps every caller's old
|
||||
-- behavior of sizing the box to fit all items.
|
||||
self.maxVisible = opts.maxVisible
|
||||
self.scroll = 0
|
||||
local visible = (self.maxVisible and math.min(self.maxVisible, #items))
|
||||
or #items
|
||||
self.th = opts.th or (visible * self.rowStep + 2)
|
||||
self.cancelable = opts.cancelable ~= false
|
||||
-- Whether START closes the menu. In pokered a menu responds only to the
|
||||
-- keys in its wMenuWatchedKeys mask; the common PAD_A | PAD_B (and the
|
||||
@@ -31,9 +40,25 @@ function Menu.new(game, items, opts)
|
||||
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
|
||||
-- menus silent (home/window.asm HandleMenuInput_)
|
||||
self.noSound = opts.noSound or false
|
||||
self:clampScroll()
|
||||
return self
|
||||
end
|
||||
|
||||
-- keeps self.index inside the visible [scroll+1, scroll+maxVisible] window;
|
||||
-- callers that move self.index directly (e.g. restoring a saved cursor
|
||||
-- position) should call this afterwards to scroll it into view
|
||||
function Menu:clampScroll()
|
||||
if not (self.maxVisible and #self.items > self.maxVisible) then
|
||||
self.scroll = 0
|
||||
return
|
||||
end
|
||||
if self.index - self.scroll > self.maxVisible then
|
||||
self.scroll = self.index - self.maxVisible
|
||||
elseif self.index - self.scroll < 1 then
|
||||
self.scroll = self.index - 1
|
||||
end
|
||||
end
|
||||
|
||||
function Menu:update(dt)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("up") then
|
||||
@@ -61,15 +86,29 @@ function Menu:update(dt)
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
end
|
||||
self:clampScroll()
|
||||
end
|
||||
|
||||
function Menu:draw()
|
||||
Font.drawBox(self.tx, self.ty, self.tw, self.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, item in ipairs(self.items) do
|
||||
Font.draw(item.label, (self.tx + 2) * 8, (self.ty + i * 2 - 1) * 8)
|
||||
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
|
||||
or #self.items
|
||||
for row = 1, visible do
|
||||
local item = self.items[self.scroll + row]
|
||||
if not item then break end
|
||||
Font.draw(item.label, (self.tx + 2) * 8,
|
||||
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
end
|
||||
local cursorRow = self.index - self.scroll
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
|
||||
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8)
|
||||
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
|
||||
-- use, sat on the bottom border like TextBox's page-advance cursor
|
||||
if self.maxVisible and self.scroll + self.maxVisible < #self.items then
|
||||
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
|
||||
(self.ty + self.th - 2) * 8)
|
||||
end
|
||||
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, (self.ty + self.index * 2 - 1) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ local OptionsMenu = {}
|
||||
OptionsMenu.__index = OptionsMenu
|
||||
OptionsMenu.isOpaque = true
|
||||
|
||||
-- Opaque full-screen menu: own MEWMON so opening OPTION from the title
|
||||
-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that
|
||||
-- zone covers UI rows 8-9, which is the third options box label line
|
||||
-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}).
|
||||
function OptionsMenu:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
-- TextSpeedOptionData frame delays with the original labels
|
||||
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
|
||||
-- no-loader fallback for the ruleset row, same pair BattleState keeps
|
||||
|
||||
@@ -294,7 +294,9 @@ function PartyMenu:update(dt)
|
||||
if ow and heal then
|
||||
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
|
||||
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
|
||||
ow:warpToHealPoint()
|
||||
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
|
||||
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
|
||||
ow:warpToHealPoint(nil, { arrive = "teleport" })
|
||||
end))
|
||||
end
|
||||
return
|
||||
|
||||
+11
-1
@@ -7,6 +7,7 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
@@ -117,11 +118,20 @@ function StartMenu.new(game)
|
||||
-- the start menu's mask is PAD_DOWN | PAD_UP | PAD_START | PAD_B | PAD_A
|
||||
-- (engine/menus/draw_start_menu.asm), so START closes it back to the
|
||||
-- overworld -- unlike most menus, whose masks omit PAD_START.
|
||||
--
|
||||
-- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state,
|
||||
-- and mods can append their own rows through the hook above, so the
|
||||
-- double-spaced box (the original's style) can grow past the 18-tile
|
||||
-- canvas. Cap it at however many rows actually fit and scroll the rest,
|
||||
-- with Menu's moreArrow showing while there's more below.
|
||||
local rowStep = 2
|
||||
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
|
||||
local menu = Menu.new(game, items,
|
||||
{ tx = 9, ty = 0, tw = 11, th = #items * 2 + 2, startCloses = true })
|
||||
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true })
|
||||
-- the cursor position survives closing the menu
|
||||
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
|
||||
menu.index = math.min(game.save.startMenuIndex or 1, #items)
|
||||
menu:clampScroll()
|
||||
local baseUpdate = menu.update
|
||||
menu.update = function(self, dt)
|
||||
baseUpdate(self, dt)
|
||||
|
||||
+33
-5
@@ -13,13 +13,35 @@ TitleState.isOpaque = true
|
||||
|
||||
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
|
||||
-- the version-ribbon band LOGO1, the rest MEWMON.
|
||||
--
|
||||
-- The CONTINUE / NEW GAME menu and the continue-info box sit inside those
|
||||
-- LOGO bands. pokered's MainMenu clears the title and runs
|
||||
-- RunDefaultPaletteCommand so black UI ink stays black; this port keeps the
|
||||
-- title art visible underneath, so without an overlay those boxes inherit
|
||||
-- LOGO2 blue / LOGO1 red (issue #133). A trailing trueColor zone leaves the
|
||||
-- overlay's DMG black unshaded while the logo and title mon keep title pals.
|
||||
--
|
||||
-- ROM SuperPal whites are often {255,239,255}. Under RED++, LOGO2/MEWMON
|
||||
-- come from the GBC pack (pure white) while Blue's LOGO1 stays on the ROM
|
||||
-- pack (#128), so the version-ribbon row reads as a pink band. Force that
|
||||
-- slot to pure white; ink colors (Blue/Red "Version" text) stay intact.
|
||||
local function withPureWhite(pal)
|
||||
if not pal then return nil end
|
||||
return { { 255, 255, 255 }, pal[2], pal[3], pal[4] }
|
||||
end
|
||||
|
||||
function TitleState:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local z = {
|
||||
P.zone(P.pal(game.data, "LOGO2"), 0, 0, 19, 7),
|
||||
P.zone(P.pal(game.data, "LOGO1"), 0, 8, 19, 9),
|
||||
P.zone(withPureWhite(P.pal(game.data, "LOGO1")), 0, 8, 19, 9),
|
||||
P.zone(P.pal(game.data, "MEWMON"), 0, 10, 19, 17),
|
||||
}
|
||||
local top = game.stack and game.stack:top()
|
||||
local box = top and top.titleUiBox
|
||||
if box then
|
||||
z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4])
|
||||
end
|
||||
return z[3] and z or nil
|
||||
end
|
||||
|
||||
@@ -119,8 +141,11 @@ local ContinueInfo = {}
|
||||
ContinueInfo.__index = ContinueInfo
|
||||
|
||||
function ContinueInfo.new(title, save)
|
||||
return setmetatable({ title = title, game = title.game, save = save },
|
||||
ContinueInfo)
|
||||
-- box at (4,7), 16x10 tiles -- see ContinueInfo:draw / DisplayContinueGameInfo
|
||||
return setmetatable({
|
||||
title = title, game = title.game, save = save,
|
||||
titleUiBox = { 4, 7, 19, 16 },
|
||||
}, ContinueInfo)
|
||||
end
|
||||
|
||||
function ContinueInfo:update(dt)
|
||||
@@ -184,8 +209,11 @@ function TitleState:openMenu()
|
||||
love.event.quit()
|
||||
end
|
||||
end })
|
||||
game.stack:push(Menu.new(game, items,
|
||||
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
|
||||
local th = #items * 2 + 2
|
||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes
|
||||
menu.titleUiBox = { 0, 0, 12, th - 1 }
|
||||
game.stack:push(menu)
|
||||
end
|
||||
|
||||
function TitleState:update(dt)
|
||||
|
||||
@@ -120,8 +120,11 @@ FieldDefaults.FIELD = {
|
||||
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)
|
||||
-- plug boulders are down (engine/overworld/field_move_messages.asm).
|
||||
-- B3F currents set BIT_FORCED_WARP so the south-edge water warps fire
|
||||
-- without a held d-pad (scripts/SeafoamIslandsB3F.asm).
|
||||
seafoam = {
|
||||
SEAFOAM_ISLANDS_B3F = { setsForcedWarp = true },
|
||||
SEAFOAM_ISLANDS_B4F = {
|
||||
surfBlocked = { { x = 7, y = 11, untilEvents = {
|
||||
"EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
|
||||
|
||||
@@ -303,6 +303,9 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
else
|
||||
self.player = Player.new(Game.data, x, y, facing)
|
||||
end
|
||||
-- crossConnection re-arms this after setMap; clear so a warp/reload
|
||||
-- cannot leave a stale deferred PlayMapMusic pending
|
||||
self.pendingSeamMusic = nil
|
||||
self.entities = { self.player }
|
||||
for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end
|
||||
|
||||
@@ -322,6 +325,12 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- 16/18 gate exits), and the scripted door-mat walkout that follows
|
||||
-- suppresses onStepComplete, so waiting for a plain step never mounts
|
||||
self:checkForcedMovement()
|
||||
-- Seafoam B4F's map script pushes off the B3F stair warps every frame
|
||||
-- while the upper plugs are out (SeafoamIslandsB4FDefaultScript); the
|
||||
-- B3F/B4F force-surf mouths also arm their MOVE_OBJECT current scripts
|
||||
-- from CheckForceBikeOrSurf. Re-check here so a warp-in does not sit
|
||||
-- idle on those cells waiting for a player step.
|
||||
self:checkSeafoamCurrent()
|
||||
|
||||
-- snap the camera immediately: the overworld doesn't update while a
|
||||
-- Transition is on top, so a stale camera would show the new map at
|
||||
@@ -802,6 +811,15 @@ function OverworldState:update(dt)
|
||||
if entry and (self.player.cellX ~= entry.x or self.player.cellY ~= entry.y) then
|
||||
self.warpEntryCell = nil
|
||||
end
|
||||
-- deferred PlayMapMusic from crossConnection (issue #93)
|
||||
if stepped and self.pendingSeamMusic then
|
||||
local mapId = self.pendingSeamMusic
|
||||
self.pendingSeamMusic = nil
|
||||
if mapId == self.map.id then
|
||||
require("src.core.Music").playMap(Game.data, mapId, Game.save.onBike,
|
||||
self.player.surfing)
|
||||
end
|
||||
end
|
||||
if stepped and not scripted then
|
||||
self:onStepComplete()
|
||||
end
|
||||
@@ -1050,7 +1068,15 @@ function OverworldState:crossConnection(dir, conn)
|
||||
if not Map.defPassable(dest, ts, x, y, p.surfing) then
|
||||
return false
|
||||
end
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true })
|
||||
-- keepMusic: defer PlayMapMusic until the seam step lands. Starting a
|
||||
-- new chip song inside setMap used to hitch the render thread (~200ms)
|
||||
-- so FixedStep catch-up ate the walk frames (issue #93). Threaded synth
|
||||
-- removed most of that hitch; discarding catch-up + deferring the song
|
||||
-- still protects the visible step when neighbor rebuild or the sync
|
||||
-- fallback stalls, and avoids the rare one-frame volume spike from a
|
||||
-- song swap mid-step.
|
||||
self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true })
|
||||
self.pendingSeamMusic = conn.map
|
||||
-- place the player one cell before the seam (their old world spot,
|
||||
-- which the neighbor strip renders identically) and start the step
|
||||
-- into the new map RIGHT NOW so there is no one-frame stall at the
|
||||
@@ -1064,9 +1090,13 @@ function OverworldState:crossConnection(dir, conn)
|
||||
p.targetX, p.targetY = x, y
|
||||
p.moving = true
|
||||
p.progress = 0
|
||||
-- fresh walk-cycle clock so the seam step always shows leg frames
|
||||
-- (mid-cycle stand phase would otherwise look like a slide)
|
||||
p.animClock = 0
|
||||
p.stepFramesCur = Game.save.onBike
|
||||
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
|
||||
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
|
||||
require("src.core.FixedStep"):discardCatchup()
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -2669,8 +2699,11 @@ function OverworldState:onStepComplete()
|
||||
elseif entry then
|
||||
-- still standing on the warp we arrived through; do not re-trigger it
|
||||
else
|
||||
-- CheckWarpsNoCollision: door/warp tiles fire immediately; otherwise
|
||||
-- ExtraWarpCheck must pass AND either a d-pad is held or BIT_FORCED_WARP
|
||||
-- is set (Seafoam B3F currents — home/overworld.asm).
|
||||
local w = Warp.onArrive(self.map, p.cellX, p.cellY)
|
||||
if not w and self:dirHeld() then
|
||||
if not w and (self:dirHeld() or self.forcedWarp) then
|
||||
w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
|
||||
p.cellX, p.cellY, p.facing)
|
||||
end
|
||||
@@ -2751,9 +2784,11 @@ function OverworldState:runSpinnerMoves(moves, i)
|
||||
local mv = moves[i]
|
||||
if not mv then
|
||||
self.player.spinning = false
|
||||
if not self:checkSpinner() and self.player.surfing then
|
||||
self:checkSeafoamCurrent()
|
||||
end
|
||||
-- Scripted steps skip onStepComplete while they run; once the RLE
|
||||
-- finishes, re-enter the normal landing pipeline so chained spinners,
|
||||
-- Seafoam currents, and CheckWarpsNoCollision (incl. BIT_FORCED_WARP)
|
||||
-- see the tile we stopped on — same as pokered after simulated joypad.
|
||||
self:onStepComplete()
|
||||
return
|
||||
end
|
||||
self.player.spinning = true -- spin the sprite while sliding
|
||||
@@ -2926,6 +2961,9 @@ function OverworldState:checkSeafoamCurrent()
|
||||
if sf.forcedExit and p.surfing and not allSet(sf.forcedExit.activeUntilEvents) then
|
||||
for _, c in ipairs(sf.forcedExit.coords) do
|
||||
if p.cellX == c.x and p.cellY == c.y then
|
||||
-- SeafoamIslandsB4FDefaultScript: res BIT_FORCED_WARP before the
|
||||
-- push so the B3F stair warps underfoot cannot bounce you back.
|
||||
self.forcedWarp = false
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
|
||||
return true
|
||||
@@ -2947,6 +2985,12 @@ function OverworldState:checkSeafoamCurrent()
|
||||
end
|
||||
for _, c in ipairs(active) do
|
||||
if p.cellX == c.x and p.cellY == c.y then
|
||||
-- SeafoamIslandsB3F.asm sets BIT_FORCED_WARP before DecodeRLEList so
|
||||
-- the south-edge water stairs auto-warp when the current ends.
|
||||
if FieldDefaults.fieldValue(Game.data, "seafoam", self.map.id,
|
||||
"setsForcedWarp") then
|
||||
self.forcedWarp = true
|
||||
end
|
||||
self:runSpinnerMoves(c.moves, 1)
|
||||
return true
|
||||
end
|
||||
@@ -3155,16 +3199,20 @@ end
|
||||
-- Warp to the last heal point (blackout, ESCAPE ROPE, DIG/TELEPORT).
|
||||
-- The heal point is usually an interior, so LAST_MAP exits are re-pointed
|
||||
-- at its remembered town door rather than wherever the player left from.
|
||||
function OverworldState:warpToHealPoint(onDone)
|
||||
--
|
||||
-- opts.arrive = "teleport" for Dig/Teleport/Escape Rope (LeaveMapAnim /
|
||||
-- EnterMapAnim). Blackouts omit it: pret HandleBlackOut only
|
||||
-- GBFadeOutToBlack + PrepareForSpecialWarp + SpecialEnterMap, and never
|
||||
-- sets BIT_FLY_WARP / BIT_DUNGEON_WARP, so EnterMap never runs EnterMapAnim.
|
||||
function OverworldState:warpToHealPoint(onDone, opts)
|
||||
local heal = self:healPoint()
|
||||
self.player.surfing = false
|
||||
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
|
||||
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
|
||||
Game.save.forcedBike = nil
|
||||
-- rematerializing plays the teleport-in poof (EnterMapAnim in
|
||||
-- engine/overworld/player_animations.asm: SFX_TELEPORT_ENTER_1, then
|
||||
-- ENTER_2 after the spin-down); blackouts take this path too
|
||||
self.arriveWarp = "teleport"
|
||||
if opts and opts.arrive == "teleport" then
|
||||
self.arriveWarp = "teleport"
|
||||
end
|
||||
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
|
||||
if heal.outdoor then
|
||||
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
|
||||
@@ -3202,9 +3250,9 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
-- one-step justWarped guard, which only skipped the very next frame's
|
||||
-- check and so let a mon walked back onto the pad re-trigger it.
|
||||
self.warpEntryCell = { x = x, y = y }
|
||||
-- Fly/Teleport/Dig/Escape-Rope/blackout landings poof the player
|
||||
-- back in (player_animations.asm EnterMapAnim); ordinary door
|
||||
-- warps never take this branch
|
||||
-- Fly/Teleport/Dig/Escape-Rope landings poof the player back in
|
||||
-- (player_animations.asm EnterMapAnim). Blackouts and ordinary
|
||||
-- door warps never take this branch.
|
||||
if arriveWarp == "fly" then
|
||||
require("src.core.Sound").play(Game.data, "Fly")
|
||||
elseif arriveWarp == "teleport" then
|
||||
|
||||
Reference in New Issue
Block a user