mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 819150f50f |
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
||||
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
|
||||
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
|
||||
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
|
||||
-- true from onStep so wild encounters are skipped the same way).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local TOWER_5F_PURIFIED = {
|
||||
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
|
||||
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
|
||||
}
|
||||
|
||||
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
|
||||
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
|
||||
local TOWER_5F_HEAL = {
|
||||
{ "heal_party" },
|
||||
{ "fade", "out", "white" },
|
||||
{ "wait", 3 },
|
||||
{ "wait", 3 },
|
||||
{ "fade", "in", "white" },
|
||||
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
|
||||
}
|
||||
|
||||
M.POKEMON_TOWER_5F = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if not TOWER_5F_PURIFIED[x * 256 + y] then
|
||||
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
|
||||
return false
|
||||
end
|
||||
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
|
||||
return true
|
||||
end
|
||||
if ow.runner and ow.runner:isRunning() then return false end
|
||||
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
|
||||
if ow.runner then
|
||||
ow.runner:run(TOWER_5F_HEAL)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(TOWER_5F_HEAL)
|
||||
end
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
||||
-- (10,16) until defeated.
|
||||
|
||||
@@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
|
||||
|
||||
-- the hit blink + damage sound ride the queue behind the animation:
|
||||
-- on the move's anim row when one was announced, else on a bare hit
|
||||
-- row (thrash/rage continuations), placed BEFORE the drain rows the
|
||||
-- hits loop inserts so the blink precedes the bar drain
|
||||
local hitRow = battle.moveAnimRow
|
||||
if not hitRow then
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { hitRow = true }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
-- the hit blink + damage sound ride each animation row, placed BEFORE
|
||||
-- that hit's drain so the blink precedes the bar. Multi-hit moves
|
||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||
-- Thrash/rage continuations have no announcement anim -- a bare
|
||||
-- hitRow carries the blink instead.
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
local hitFx = { sfx = hitSfx,
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
for h = 1, hits do
|
||||
if target.mon.hp <= 0 then break end
|
||||
local hitRow
|
||||
if h == 1 then
|
||||
hitRow = battle.moveAnimRow
|
||||
if not hitRow then
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { hitRow = true }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
else
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
local hadSub = target.substituteHP ~= nil
|
||||
local dealt = battle:applyDamage(target, dmg)
|
||||
totalDealt = totalDealt + dealt
|
||||
landed = h
|
||||
if dealt > 0 then hitRow.hit = hitFx end
|
||||
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
end
|
||||
if Runtime.wants("battle.damage_dealt") then
|
||||
Runtime.emit("battle.damage_dealt", {
|
||||
battle = battle, user = user, target = target, move = move,
|
||||
@@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
end
|
||||
hits = landed > 0 and landed or hits
|
||||
if totalDealt > 0 then
|
||||
-- the original's per-hit sound: normal / super / not-very-effective
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
hitRow.hit = { sfx = hitSfx,
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
end
|
||||
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
|
||||
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
|
||||
-- .moveDidNotMiss); the multi-hit count follows the last hit
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
end
|
||||
if hits > 1 then
|
||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||
if user.isPlayer then
|
||||
|
||||
+6
-1
@@ -322,7 +322,9 @@ function Game:keypressed(key)
|
||||
return
|
||||
elseif key == "5" then
|
||||
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
|
||||
-- desktop. Mobile refuses the present shader (issue #136).
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if not GBCFX.isSupported() then return end
|
||||
self.save.options.gbcfx = GBCFX.cycle()
|
||||
self:writeOptions()
|
||||
return
|
||||
@@ -443,12 +445,15 @@ function Game:applyOptions(opts)
|
||||
if Sound.applyOptions then Sound.applyOptions(opts) end
|
||||
require("src.render.PaletteFX").applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
require("src.render.GBCFX").applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
Input:applyBindings(opts.bindings)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
|
||||
@@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = {
|
||||
z = "a", ["return"] = "a", space = "a",
|
||||
x = "b", backspace = "b",
|
||||
["kpenter"] = "start", escape = "start",
|
||||
-- Select: fight-menu move reorder + bag item reorder. Tab is the
|
||||
-- discoverable default (shown in CONTROLS); both shifts stay as
|
||||
-- aliases so Right-Shift muscle memory from older builds still works.
|
||||
tab = "select",
|
||||
rshift = "select",
|
||||
lshift = "select",
|
||||
}
|
||||
|
||||
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
|
||||
|
||||
@@ -48,7 +48,7 @@ local KEY = {
|
||||
a = "z",
|
||||
b = "x",
|
||||
start = "escape",
|
||||
select = "rshift",
|
||||
select = "tab",
|
||||
}
|
||||
|
||||
local function nowMs()
|
||||
|
||||
+29
-1
@@ -22,6 +22,15 @@ GBCFX.level = 0
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
|
||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||
function GBCFX.isSupported()
|
||||
if not love or not love.system or not love.system.getOS then return true end
|
||||
local osName = love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
end
|
||||
|
||||
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
|
||||
-- shadow blur are unrolled by hand).
|
||||
local SHADER_SRC = [[
|
||||
@@ -214,6 +223,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
|
||||
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
|
||||
|
||||
function GBCFX.shader()
|
||||
if not GBCFX.isSupported() then return nil end
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||
shader = ok and sh or false
|
||||
@@ -222,6 +232,10 @@ function GBCFX.shader()
|
||||
end
|
||||
|
||||
function GBCFX.setLevel(level)
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return
|
||||
end
|
||||
level = math.floor(tonumber(level) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
if level > 4 then level = 4 end
|
||||
@@ -230,12 +244,26 @@ end
|
||||
|
||||
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
|
||||
function GBCFX.cycle()
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return 0
|
||||
end
|
||||
GBCFX.setLevel((GBCFX.level + 1) % 5)
|
||||
return GBCFX.level
|
||||
end
|
||||
|
||||
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
|
||||
-- persisted non-zero value so boot recovers from a soft-brick. Returns
|
||||
-- true when opts was sanitized (caller should persist options.lua).
|
||||
function GBCFX.applyOptions(opts)
|
||||
if not GBCFX.isSupported() then
|
||||
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
|
||||
if opts then opts.gbcfx = 0 end
|
||||
GBCFX.level = 0
|
||||
return had and true or false
|
||||
end
|
||||
GBCFX.setLevel(opts and opts.gbcfx or 0)
|
||||
return false
|
||||
end
|
||||
|
||||
function GBCFX.levelLabel(level)
|
||||
@@ -243,7 +271,7 @@ function GBCFX.levelLabel(level)
|
||||
end
|
||||
|
||||
function GBCFX.active()
|
||||
return GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
end
|
||||
|
||||
-- Draw `canvas` fullscreen through the GBC FX shader into the current
|
||||
|
||||
@@ -353,18 +353,21 @@ local TILESET_GROUP_EXCEPTIONS = {
|
||||
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
|
||||
}
|
||||
|
||||
-- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top
|
||||
-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
|
||||
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
|
||||
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
|
||||
-- different palettes; the vanilla-derived blockset shares the one tile
|
||||
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
|
||||
-- is baked as a copy of `tile` in `group`'s colors, and the listed
|
||||
-- 0-based block cells draw the alias instead of the shared tile.
|
||||
-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84).
|
||||
local LOBBY_TABLE_TOP_ALIAS = {
|
||||
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
}
|
||||
PaletteFX.TILE_ALIASES = {
|
||||
CELADON_MART_ROOF = {
|
||||
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
},
|
||||
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
|
||||
CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
|
||||
}
|
||||
local ROOF_GROUP = 6
|
||||
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
|
||||
|
||||
@@ -55,8 +55,29 @@ local FLOWER_IMAGES = {
|
||||
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
|
||||
|
||||
local animFrame = 0
|
||||
function TileRenderer.tick()
|
||||
animFrame = animFrame + 1
|
||||
local animAccum = 0
|
||||
local ANIM_STEP = 1 / 60 -- Game Boy logic rate (matches FixedStep.STEP)
|
||||
|
||||
-- Advance water/flower/spinner tile animation. Called from the overworld
|
||||
-- draw path so it keeps running under dialogs (overworld update does not),
|
||||
-- but consumes wall-clock dt into 60Hz steps so high/low display refresh
|
||||
-- no longer speeds up or slows the cycle (issue #4).
|
||||
-- tick() / tick(nil) with no love.timer.getDelta (headless tests) still
|
||||
-- advances exactly one frame per call.
|
||||
function TileRenderer.tick(dt)
|
||||
if dt == nil and love and love.timer and love.timer.getDelta then
|
||||
dt = love.timer.getDelta()
|
||||
end
|
||||
if dt == nil then
|
||||
animFrame = animFrame + 1
|
||||
return
|
||||
end
|
||||
-- Cap catch-up so a long stall cannot jump many water/flower periods
|
||||
animAccum = math.min(animAccum + dt, 0.25)
|
||||
while animAccum >= ANIM_STEP do
|
||||
animAccum = animAccum - ANIM_STEP
|
||||
animFrame = animFrame + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
+22
-4
@@ -11,7 +11,9 @@ local Input = require("src.core.Input")
|
||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
|
||||
-- Input.lua's map, primary key first where several keys share a button
|
||||
-- Input.lua's map, primary key first where several keys share a button.
|
||||
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
|
||||
-- on the SELECT row so controller Back/View is discoverable (#73).
|
||||
local BUTTONS = {
|
||||
{ id = "up", label = "UP", key = "up" },
|
||||
{ id = "down", label = "DOWN", key = "down" },
|
||||
@@ -20,7 +22,7 @@ local BUTTONS = {
|
||||
{ id = "a", label = "A", key = "z" },
|
||||
{ id = "b", label = "B", key = "x" },
|
||||
{ id = "start", label = "START", key = "escape" },
|
||||
{ id = "select", label = "SELECT", key = "rshift" },
|
||||
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
|
||||
}
|
||||
|
||||
-- a binding is a plain key string or { key, pad }; absent = the fixed
|
||||
@@ -32,13 +34,29 @@ local function boundKey(overlay, def)
|
||||
return def.key
|
||||
end
|
||||
|
||||
local function boundPad(overlay, def)
|
||||
local b = overlay and overlay[def.id]
|
||||
if type(b) == "table" and b.pad then return b.pad end
|
||||
return def.pad
|
||||
end
|
||||
|
||||
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
|
||||
-- so controller Select/View is visible without opening a second legend.
|
||||
local function boundRight(overlay, def)
|
||||
local key = boundKey(overlay, def)
|
||||
if def.id ~= "select" then return key:upper() end
|
||||
local pad = boundPad(overlay, def)
|
||||
if pad then return (key .. "/" .. pad):upper() end
|
||||
return key:upper()
|
||||
end
|
||||
|
||||
function BindingsMenu.new(game)
|
||||
local overlay = game.save and game.save.options
|
||||
and game.save.options.bindings
|
||||
local items = {}
|
||||
for i, def in ipairs(BUTTONS) do
|
||||
items[i] = { label = def.label,
|
||||
right = boundKey(overlay, def):upper(), button = def }
|
||||
right = boundRight(overlay, def), button = def }
|
||||
end
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
@@ -78,7 +96,7 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
end
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundKey(opts.bindings, item.button):upper()
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
Input:applyBindings(opts.bindings)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
+10
-1
@@ -110,7 +110,7 @@ local function sameRows(_, rows) return rows end
|
||||
-- the vanilla rows as descriptors; each step body is the old per-index
|
||||
-- ladder's, so the save.options mutations are unchanged
|
||||
local function buildRows(game)
|
||||
return {
|
||||
local rows = {
|
||||
{ id = "textSpeed", label = "TEXT SPEED",
|
||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||
step = function(g)
|
||||
@@ -252,6 +252,15 @@ local function buildRows(game)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
end },
|
||||
}
|
||||
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
||||
if not GBCFX.isSupported() then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function OptionsMenu.new(game)
|
||||
|
||||
@@ -11,6 +11,8 @@ local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local PartyMenu = {}
|
||||
PartyMenu.__index = PartyMenu
|
||||
@@ -355,8 +357,12 @@ function PartyMenu:update(dt)
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
table.insert(items, { label = "FLY", action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
@@ -375,7 +381,7 @@ function PartyMenu:update(dt)
|
||||
table.insert(items, { label = "STRENGTH", action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
|
||||
@@ -2431,6 +2431,27 @@ function OverworldState:runVictoryHook()
|
||||
if hooks and hooks.onVictory then hooks.onVictory(Game, self) end
|
||||
end
|
||||
|
||||
-- pokered player sprite is fixed at screen ($40, $3c). TrainerEngage reads
|
||||
-- the NPC's 8-bit SPRITESTATEDATA1 X/Y pixels and CalcDifference; engage
|
||||
-- distance is stored as range<<4 (pixels). There is no tile LOS check for
|
||||
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
|
||||
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
|
||||
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
|
||||
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
|
||||
local function u8(n) return n % 256 end
|
||||
local function calcDiff(a, b)
|
||||
a, b = u8(a), u8(b)
|
||||
return a >= b and a - b or b - a
|
||||
end
|
||||
local function trainerSightPixelDist(npc, player, horizontal)
|
||||
if horizontal then
|
||||
return calcDiff(PLAYER_SCREEN_X,
|
||||
u8(PLAYER_SCREEN_X + (npc.cellX - player.cellX) * 16))
|
||||
end
|
||||
return calcDiff(PLAYER_SCREEN_Y,
|
||||
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
|
||||
end
|
||||
|
||||
-- STAY trainers with a facing spot the player crossing their line of
|
||||
-- sight (range from the extracted trainer headers), walk up and battle.
|
||||
function OverworldState:checkTrainerSight()
|
||||
@@ -2448,22 +2469,23 @@ function OverworldState:checkTrainerSight()
|
||||
local range = header and header.range or 0
|
||||
local vec = DIRVEC[npc.facing]
|
||||
if range > 0 and vec then
|
||||
local dist
|
||||
local dist, horizontal
|
||||
if vec[1] ~= 0 and npc.cellY == p.cellY then
|
||||
dist = (p.cellX - npc.cellX) * vec[1]
|
||||
horizontal = true
|
||||
elseif vec[2] ~= 0 and npc.cellX == p.cellX then
|
||||
dist = (p.cellY - npc.cellY) * vec[2]
|
||||
horizontal = false
|
||||
end
|
||||
-- pokered's TrainerEngage / CheckSpriteCanSeePlayer compares screen
|
||||
-- coordinates only (home/trainers.asm, engine/overworld/
|
||||
-- trainer_sight.asm) -- there is no line-of-sight obstruction check.
|
||||
-- An aligned trainer within range engages through interposed NPCs and
|
||||
-- unwalkable tiles, and the scripted walk-up below (scriptMove) also
|
||||
-- ignores collision, so the trainer simply walks/overlaps through
|
||||
-- anything on the line -- exactly as OAM sprites overlap on hardware.
|
||||
if dist and dist >= 1 and dist <= range then
|
||||
self:startTrainerApproach(npc, dist)
|
||||
return
|
||||
-- Screen-pixel range (CheckSpriteCanSeePlayer), not cell count:
|
||||
-- same facing-line rule as before, but the $fc Y quirk excludes the
|
||||
-- 4-tiles-north tile that cell math would still count as in range.
|
||||
if dist and dist >= 1 then
|
||||
local pixelDist = trainerSightPixelDist(npc, p, horizontal)
|
||||
if pixelDist > 0 and pixelDist <= range * 16 then
|
||||
self:startTrainerApproach(npc, dist)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3492,7 +3514,9 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
||||
end
|
||||
|
||||
function OverworldState:drawWorld()
|
||||
-- advance the water/flower tile animation (runs under dialogs too)
|
||||
-- advance the water/flower tile animation (runs under dialogs too).
|
||||
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
|
||||
-- does not speed or slow the cycle (issue #4).
|
||||
require("src.render.TileRenderer").tick()
|
||||
-- let the renderer know whether a spinner puzzle is currently sliding
|
||||
-- the player, so it can flicker the arrow tiles between the blur and
|
||||
|
||||
+20
-4
@@ -81,6 +81,14 @@ end
|
||||
|
||||
-- Advance one fixed step; returns true when a step just completed.
|
||||
function Player:update()
|
||||
-- land-frame walk pose lasts only through the draw after completion;
|
||||
-- the next update (idle or a chained step) clears it
|
||||
self.stepLanded = false
|
||||
-- Ledge-hop arc is cosmetic but must track the fixed 60Hz logic step,
|
||||
-- not love.draw's display refresh (issue #4: >59fps ended early).
|
||||
if self.hopFrames and self.hopFrames > 0 then
|
||||
self.hopFrames = self.hopFrames - 1
|
||||
end
|
||||
if self.turnTimer > 0 then
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
end
|
||||
@@ -109,6 +117,11 @@ function Player:update()
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.moving = false
|
||||
self.stepFlip = not self.stepFlip
|
||||
-- keep animClock's pose on this frame (issue #82): bike steps land
|
||||
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
|
||||
-- stand whenever moving cleared — a stand flash every tile on the
|
||||
-- bike, and sometimes after dismount when the clock is desynced
|
||||
self.stepLanded = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
@@ -119,7 +132,7 @@ function Player:facingCell()
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
if not self.moving and not self.stepLanded then return 0 end
|
||||
-- walk frame during the middle of each 16-frame animation cycle
|
||||
local p = (self.animClock or self.progress) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
@@ -129,10 +142,13 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
|
||||
function Player:draw(camX, camY)
|
||||
local py = self.py
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
|
||||
-- hopFrames counts down in Player:update (fixed step), never here.
|
||||
if self.hopFrames and self.hopFrames > 0 then
|
||||
self.hopFrames = self.hopFrames - 1
|
||||
local t = 1 - self.hopFrames / (self.hopTotal or 32)
|
||||
local total = self.hopTotal or 32
|
||||
-- update runs before draw, so remaining N means N steps already
|
||||
-- consumed this hop → t matches the old draw-side post-decrement phase
|
||||
local t = 1 - self.hopFrames / total
|
||||
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
||||
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
|
||||
|
||||
@@ -620,6 +620,22 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
|
||||
"RED++ still has ROUTE (aliased from VIRIDIAN)")
|
||||
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
|
||||
"RED++ passes zone colors through like GBC")
|
||||
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
|
||||
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
|
||||
do
|
||||
local aliases = PaletteFX.TILE_ALIASES
|
||||
local roof = aliases and aliases.CELADON_MART_ROOF
|
||||
local diner = aliases and aliases.CELADON_DINER
|
||||
check(roof ~= nil and diner ~= nil,
|
||||
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
|
||||
check(diner == roof,
|
||||
"diner reuses the same lobby table-top alias as the mart roof")
|
||||
local al = diner and diner[1]
|
||||
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
|
||||
and al.group == 5 and al.cells[5] and al.cells[6]
|
||||
and al.cells[9] and al.cells[10],
|
||||
"lobby table-top alias remaps block 29 cells 5/6/9/10")
|
||||
end
|
||||
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
|
||||
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
|
||||
do
|
||||
|
||||
@@ -295,7 +295,8 @@ check(bm.screenId == "BindingsMenu",
|
||||
check(#bm.items == 8, "one row per logical button")
|
||||
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
|
||||
and bm.items[5].label == "A" and bm.items[5].right == "Z"
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE",
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
|
||||
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
|
||||
"with no rebind the rows mirror the fixed map")
|
||||
check(cbGame.save.options.bindings == nil,
|
||||
"opening the screen alone writes nothing")
|
||||
|
||||
@@ -432,6 +432,50 @@ check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength,
|
||||
"fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu")
|
||||
popToOW()
|
||||
|
||||
-- ===========================================================================
|
||||
-- #83: FLY/TELEPORT use CheckIfInOutsideMap (OVERWORLD + PLATEAU), so the
|
||||
-- outdoor strip between Victory Road and the Elite Four / Indigo Plateau
|
||||
-- building allows Fly like any other outdoor overworld map.
|
||||
-- ===========================================================================
|
||||
Game.save.party = { mkMon("AERODACTYL", "FLY", "TELEPORT") }
|
||||
Game.save.inventory = { THUNDERBADGE = true }
|
||||
ow = pushOW("INDIGO_PLATEAU", 10, 5, "down")
|
||||
eq(ow.map.def.tileset, "PLATEAU", "Indigo Plateau outdoor uses PLATEAU tileset")
|
||||
local pmIndigo = PartyMenu.new(Game)
|
||||
Game.stack:push(pmIndigo)
|
||||
frame({ "a" })
|
||||
local actsIndigo = submenuActions(pmIndigo)
|
||||
check(actsIndigo.fly, "FLY listed on Indigo Plateau outdoor (PLATEAU)")
|
||||
check(actsIndigo.escape, "TELEPORT listed on Indigo Plateau outdoor (PLATEAU)")
|
||||
popToOW()
|
||||
|
||||
ow = pushOW("ROUTE_23", 10, 6, "down")
|
||||
eq(ow.map.def.tileset, "PLATEAU", "Route 23 uses PLATEAU tileset")
|
||||
local pmR23 = PartyMenu.new(Game)
|
||||
Game.stack:push(pmR23)
|
||||
frame({ "a" })
|
||||
check(submenuActions(pmR23).fly, "FLY listed on Route 23 (PLATEAU)")
|
||||
popToOW()
|
||||
|
||||
-- Indoors at the lobby still blocks FLY/TELEPORT (MART tileset)
|
||||
ow = pushOW("INDIGO_PLATEAU_LOBBY", 7, 8, "down")
|
||||
check(ow.map.def.tileset ~= "OVERWORLD" and ow.map.def.tileset ~= "PLATEAU",
|
||||
"Indigo lobby is not an outside tileset")
|
||||
local pmLobby = PartyMenu.new(Game)
|
||||
Game.stack:push(pmLobby)
|
||||
frame({ "a" })
|
||||
local actsLobby = submenuActions(pmLobby)
|
||||
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
|
||||
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
|
||||
popToOW()
|
||||
|
||||
-- restore fainted field-move mon for the STRENGTH/SURF cases below
|
||||
Game.save.party = { fainted }
|
||||
Game.save.inventory = {
|
||||
THUNDERBADGE = true, CASCADEBADGE = true,
|
||||
RAINBOWBADGE = true, SOULBADGE = true,
|
||||
}
|
||||
|
||||
-- STRENGTH activation from a fainted user (name text + strengthActive)
|
||||
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
|
||||
clearCaptured()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Regression: bike / desynced walk steps must not flash stand on land (issue #82).
|
||||
--
|
||||
-- walkPhase used to return 0 whenever moving was false. A step clears
|
||||
-- moving on its final FixedStep tick, so the draw after landing snapped
|
||||
-- to stand even when animClock was mid walk-cycle. Bike steps are 8
|
||||
-- frames, so they land at animClock % 16 == 8 (walk) every tile — always
|
||||
-- stuttery. Walking after a bike ride inherits a desynced animClock and
|
||||
-- hit the same stand flash "sometimes."
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_bike_walk_anim.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local Player = require("src.world.Player")
|
||||
local S = require("tests.harness").suite("parity bike walk anim")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local function makePlayer(onBike)
|
||||
local p = Player.new(Data, 5, 5, "down")
|
||||
p.onBike = onBike
|
||||
if onBike then
|
||||
p.stepFramesCur = p.bikeStepFrames or 8
|
||||
else
|
||||
p.stepFramesCur = p.stepFrames or 16
|
||||
end
|
||||
p.animClock = 0
|
||||
return p
|
||||
end
|
||||
|
||||
local function startStep(p)
|
||||
p.moving = true
|
||||
p.progress = 0
|
||||
p.targetX, p.targetY = p.cellX, p.cellY + 1
|
||||
end
|
||||
|
||||
-- --- bike: every land frame mid-cycle must stay walk ---
|
||||
local bike = makePlayer(true)
|
||||
local landPhases = {}
|
||||
for tile = 1, 4 do
|
||||
startStep(bike)
|
||||
local stepLen = bike.stepFramesCur
|
||||
for _ = 1, stepLen do
|
||||
local done = bike:update()
|
||||
if done then
|
||||
landPhases[#landPhases + 1] = bike:walkPhase()
|
||||
eq(bike.moving, false, "bike step clears moving on land")
|
||||
check(bike.stepLanded, "bike land latches stepLanded for draw")
|
||||
end
|
||||
end
|
||||
end
|
||||
-- lands at animClock 8, 16, 24, 32 → %16 = 8, 0, 8, 0
|
||||
-- walk band is 4..11, so lands at 8 must be phase 1; at 0 must be phase 0
|
||||
eq(landPhases[1], 1, "bike land at animClock%16==8 keeps walk pose")
|
||||
eq(landPhases[2], 0, "bike land at animClock%16==0 is stand (in-band)")
|
||||
eq(landPhases[3], 1, "bike land at animClock%16==8 keeps walk pose again")
|
||||
eq(landPhases[4], 0, "bike land at animClock%16==0 is stand again")
|
||||
|
||||
-- continuous bike phases across two tiles: no forced stand in the walk band
|
||||
bike = makePlayer(true)
|
||||
local phases = {}
|
||||
for _ = 1, 2 do
|
||||
startStep(bike)
|
||||
for _ = 1, bike.stepFramesCur do
|
||||
bike:update()
|
||||
phases[#phases + 1] = bike:walkPhase()
|
||||
end
|
||||
end
|
||||
-- frames 4..11 of animClock are walk; across 16 ticks that is indices 4..11
|
||||
for i = 4, 11 do
|
||||
eq(phases[i], 1, "bike continuous walk band has no stand flash at " .. i)
|
||||
end
|
||||
|
||||
-- idle after land: next update drops the latch → stand
|
||||
bike = makePlayer(true)
|
||||
startStep(bike)
|
||||
for _ = 1, bike.stepFramesCur do bike:update() end
|
||||
eq(bike:walkPhase(), 1, "latched land frame still walk")
|
||||
bike:update()
|
||||
eq(bike.stepLanded, false, "idle update clears stepLanded")
|
||||
eq(bike:walkPhase(), 0, "truly idle is stand")
|
||||
|
||||
-- --- walk after desynced animClock (post-bike): land must not force stand ---
|
||||
local walk = makePlayer(false)
|
||||
walk.animClock = 8 -- leftover from a bike half-cycle
|
||||
startStep(walk)
|
||||
local lastPhase
|
||||
for _ = 1, walk.stepFramesCur do
|
||||
walk:update()
|
||||
lastPhase = walk:walkPhase()
|
||||
end
|
||||
eq(walk.animClock % 16, 8, "desynced walk lands mid walk-cycle")
|
||||
eq(lastPhase, 1, "desynced walk land keeps walk pose (no post-bike stutter)")
|
||||
|
||||
S.finish()
|
||||
@@ -80,5 +80,46 @@ check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0,
|
||||
|
||||
GBCFX.setLevel(0)
|
||||
|
||||
-- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK)
|
||||
check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX")
|
||||
local prevSystem = love.system
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
check(not GBCFX.isSupported(), "Android reports GBC FX unsupported")
|
||||
GBCFX.setLevel(3)
|
||||
eq(GBCFX.level, 0, "setLevel forces OFF on Android")
|
||||
eq(GBCFX.cycle(), 0, "cycle stays OFF on Android")
|
||||
local opts = { gbcfx = 4 }
|
||||
check(GBCFX.applyOptions(opts) == true,
|
||||
"applyOptions reports a cleared persisted level on Android")
|
||||
eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android")
|
||||
eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android")
|
||||
check(not GBCFX.active(), "active() is false on Android")
|
||||
eq(GBCFX.shader(), nil, "shader() is nil on Android")
|
||||
love.system = { getOS = function() return "iOS" end }
|
||||
check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported")
|
||||
love.system = prevSystem
|
||||
check(GBCFX.isSupported(), "support restores when OS stub is removed")
|
||||
-- desktop path still applies a level after leaving the mobile gate
|
||||
GBCFX.applyOptions({ gbcfx = 2 })
|
||||
eq(GBCFX.level, 2, "applyOptions still sets levels on desktop")
|
||||
GBCFX.setLevel(0)
|
||||
|
||||
-- Options menu hides the GBC FX row on Android
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
local om = OptionsMenu.new({
|
||||
data = { rulesets = {}, constants = {} },
|
||||
save = { options = {} },
|
||||
stack = { pop = function() end },
|
||||
input = { wasPressed = function() return false end },
|
||||
modStatus = { available = {} },
|
||||
})
|
||||
local hasGbc = false
|
||||
for _, row in ipairs(om.rows) do
|
||||
if row.id == "gbcfx" then hasGbc = true end
|
||||
end
|
||||
check(not hasGbc, "Options menu omits GBC FX on Android")
|
||||
love.system = prevSystem
|
||||
|
||||
-- === summary ===
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves.
|
||||
--
|
||||
-- Select marks a slot, move the cursor, Select (or A) swaps. Defaults:
|
||||
-- Tab / either Shift / gamepad Back. Self-contained; also picked up by
|
||||
-- tests/run_tests.lua's parity_* glob.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Input = require("src.core.Input")
|
||||
local S = require("tests.harness").suite("parity move swap")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local function freshGame()
|
||||
local mon = Pokemon.new(Data, "NIDORAN_M", 8)
|
||||
mon.moves = {
|
||||
{ id = "TACKLE", pp = 35 },
|
||||
{ id = "LEER", pp = 30 },
|
||||
{ id = "HORN_ATTACK", pp = 25 },
|
||||
{ id = "POISON_STING", pp = 35 },
|
||||
}
|
||||
return {
|
||||
data = Data,
|
||||
input = Input,
|
||||
save = {
|
||||
party = { mon },
|
||||
player = { name = "RED" },
|
||||
inventory = {},
|
||||
options = {},
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
stack = { push = function() end, pop = function() end, top = function() end },
|
||||
}
|
||||
end
|
||||
|
||||
local function tapKey(battle, key)
|
||||
Input:keypressed(key)
|
||||
Input:step()
|
||||
battle:update(0)
|
||||
Input:keyreleased(key)
|
||||
end
|
||||
|
||||
local function tapPad(battle, button)
|
||||
Input:gamepadpressed(nil, button)
|
||||
Input:step()
|
||||
battle:update(0)
|
||||
Input:gamepadreleased(nil, button)
|
||||
end
|
||||
|
||||
-- Default Select sources all edge the logical select button.
|
||||
do
|
||||
Input:init()
|
||||
for _, key in ipairs({ "tab", "rshift", "lshift" }) do
|
||||
Input:reset()
|
||||
Input:keypressed(key)
|
||||
Input:step()
|
||||
check(Input:wasPressed("select"), key .. " maps to select")
|
||||
end
|
||||
Input:reset()
|
||||
Input:gamepadpressed(nil, "back")
|
||||
Input:step()
|
||||
check(Input:wasPressed("select"), "gamepad back maps to select")
|
||||
end
|
||||
|
||||
-- Fight menu: Select, move, Select swaps slots 1 and 2.
|
||||
do
|
||||
Input:init()
|
||||
local game = freshGame()
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.phase = "moveSelect"
|
||||
battle.moveIndex = 1
|
||||
battle.moveSwapIndex = nil
|
||||
local a = battle.player.curMoves[1].id
|
||||
local b = battle.player.curMoves[2].id
|
||||
tapKey(battle, "tab")
|
||||
eq(battle.moveSwapIndex, 1, "first Select marks the current slot")
|
||||
tapKey(battle, "down")
|
||||
eq(battle.moveIndex, 2, "cursor moved to slot 2")
|
||||
tapKey(battle, "tab")
|
||||
check(battle.moveSwapIndex == nil, "second Select clears the mark")
|
||||
eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move")
|
||||
eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move")
|
||||
eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync")
|
||||
end
|
||||
|
||||
-- Same reorder via gamepad Back (SDL "back" = controller Select/View).
|
||||
do
|
||||
Input:init()
|
||||
local game = freshGame()
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.phase = "moveSelect"
|
||||
battle.moveIndex = 1
|
||||
battle.moveSwapIndex = nil
|
||||
local a = battle.player.curMoves[1].id
|
||||
local b = battle.player.curMoves[2].id
|
||||
tapPad(battle, "back")
|
||||
tapPad(battle, "dpdown")
|
||||
tapPad(battle, "back")
|
||||
eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1")
|
||||
eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2")
|
||||
end
|
||||
|
||||
-- A confirms a pending swap (bag-style), without starting the turn.
|
||||
do
|
||||
Input:init()
|
||||
local game = freshGame()
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.phase = "moveSelect"
|
||||
battle.moveIndex = 1
|
||||
battle.moveSwapIndex = nil
|
||||
local a = battle.player.curMoves[1].id
|
||||
local b = battle.player.curMoves[2].id
|
||||
tapKey(battle, "tab")
|
||||
tapKey(battle, "down")
|
||||
tapKey(battle, "z") -- A
|
||||
eq(battle.phase, "moveSelect", "A completes a pending swap without attacking")
|
||||
eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1")
|
||||
eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Parity test: Pokemon Tower 5F purified-zone heal pad (#81).
|
||||
--
|
||||
-- pokered scripts/PokemonTower5F.asm PokemonTower5FDefaultScript:
|
||||
-- coords (10,8)/(11,8)/(10,9)/(11,9); CheckAndSetEvent
|
||||
-- EVENT_IN_PURIFIED_ZONE so the heal fires once until the player
|
||||
-- leaves; HealParty -> GBFadeOutToWhite -> Delay3 x2 ->
|
||||
-- GBFadeInFromWhite -> _PokemonTower5FPurifiedZoneText (no heal jingle).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_tower_heal_pad.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity tower heal pad")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local M = dofile("data/scripts/story3.lua")
|
||||
local tower = M.POKEMON_TOWER_5F
|
||||
check(tower ~= nil and type(tower.onStep) == "function",
|
||||
"POKEMON_TOWER_5F has an onStep heal-pad trigger")
|
||||
|
||||
local function cmds(rows)
|
||||
local out = {}
|
||||
for _, row in ipairs(rows) do out[#out + 1] = row[1] end
|
||||
return out
|
||||
end
|
||||
|
||||
local function gameWith(flags)
|
||||
return { save = { flags = flags or {} } }
|
||||
end
|
||||
|
||||
local function owWith()
|
||||
local ran = {}
|
||||
return {
|
||||
runner = {
|
||||
isRunning = function() return false end,
|
||||
run = function(_, rows) ran[#ran + 1] = rows end,
|
||||
},
|
||||
}, ran
|
||||
end
|
||||
|
||||
-- ---- 1. pad coords + leave clears latch --------------------------------
|
||||
do
|
||||
local game = gameWith()
|
||||
local ow, ran = owWith()
|
||||
check(not tower.onStep(game, ow, 9, 8), "off-pad X does not heal")
|
||||
check(not tower.onStep(game, ow, 10, 7), "off-pad Y does not heal")
|
||||
eq(#ran, 0, "no script off the pad")
|
||||
end
|
||||
|
||||
do
|
||||
local game = gameWith()
|
||||
local ow, ran = owWith()
|
||||
check(tower.onStep(game, ow, 10, 8), "first step on (10,8) heals")
|
||||
check(game.save.flags.EVENT_IN_PURIFIED_ZONE, "latches EVENT_IN_PURIFIED_ZONE")
|
||||
eq(#ran, 1, "heal script runs once")
|
||||
|
||||
check(tower.onStep(game, ow, 11, 9),
|
||||
"staying on the pad still consumes the step (BIT_NO_BATTLES)")
|
||||
eq(#ran, 1, "heal does not re-fire while still on the pad")
|
||||
|
||||
check(not tower.onStep(game, ow, 12, 9), "stepping off clears and returns false")
|
||||
check(not game.save.flags.EVENT_IN_PURIFIED_ZONE,
|
||||
"EVENT_IN_PURIFIED_ZONE resets off the pad")
|
||||
|
||||
check(tower.onStep(game, ow, 11, 8), "re-entering the pad heals again")
|
||||
eq(#ran, 2, "a fresh visit runs the heal script again")
|
||||
end
|
||||
|
||||
-- ---- 2. all four pad tiles trigger -------------------------------------
|
||||
for _, cell in ipairs({ { 10, 8 }, { 11, 8 }, { 10, 9 }, { 11, 9 } }) do
|
||||
local game = gameWith()
|
||||
local ow, ran = owWith()
|
||||
check(tower.onStep(game, ow, cell[1], cell[2]),
|
||||
("pad tile (%d,%d) heals"):format(cell[1], cell[2]))
|
||||
eq(#ran, 1, ("pad tile (%d,%d) queued a script"):format(cell[1], cell[2]))
|
||||
end
|
||||
|
||||
-- ---- 3. heal sequence matches pokered order ----------------------------
|
||||
do
|
||||
local game = gameWith()
|
||||
local ow, ran = owWith()
|
||||
tower.onStep(game, ow, 10, 9)
|
||||
local rows = ran[1]
|
||||
check(rows ~= nil, "heal rows captured")
|
||||
local sequence = table.concat(cmds(rows), ",")
|
||||
eq(sequence, "heal_party,fade,wait,wait,fade,show_text",
|
||||
"Tower pad sequence is heal → fade out → Delay3×2 → fade in → text")
|
||||
eq(rows[2][3], "white", "fades out to white")
|
||||
eq(rows[3][2], 3, "first Delay3 is 3 frames")
|
||||
eq(rows[4][2], 3, "second Delay3 is 3 frames")
|
||||
eq(rows[5][3], "white", "fades in from white")
|
||||
eq(rows[6][2], "_PokemonTower5FPurifiedZoneText",
|
||||
"shows the purified-zone text")
|
||||
for _, row in ipairs(rows) do
|
||||
check(row[1] ~= "play_once", "no Music_PkmnHealed on the Tower pad")
|
||||
end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -169,4 +169,53 @@ do
|
||||
"tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)")
|
||||
end
|
||||
|
||||
-- === (4) Route 9 Bug Catcher: 4-tiles-north screen Y $fc quirk ===
|
||||
-- Object 7 = SPRITE_YOUNGSTER at (22,2), STAY DOWN, OPP_BUG_CATCHER
|
||||
-- (data/maps/objects/Route9.asm); header range 4 (Route9TrainerHeader6).
|
||||
-- A ledge sits on (22,5); the walkable tile below it is (22,6) at cell
|
||||
-- distance 4. Cell math would engage, but pokered's unsigned screen-Y
|
||||
-- distance with SPRITESTATEDATA1_YPIXELS=$fc is $c0 > range<<4 ($40), so
|
||||
-- the trainer must not challenge through that ledge (GitHub #76).
|
||||
local ROUTE9_BUG_CATCHER = 7
|
||||
|
||||
local function freshRoute9(px, py, facing)
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
Input:init()
|
||||
OW.engaging = false
|
||||
OW.emote = nil
|
||||
Game.stack:push(OW, "ROUTE_9", px, py, facing)
|
||||
local ow = Game.stack:top()
|
||||
local trainer
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def.index == ROUTE9_BUG_CATCHER then trainer = npc end
|
||||
end
|
||||
return ow, trainer
|
||||
end
|
||||
|
||||
do
|
||||
local ow, trainer = freshRoute9(22, 6, "up")
|
||||
check(trainer ~= nil and trainer.cellX == 22 and trainer.cellY == 2,
|
||||
"Route 9 Bug Catcher stands at (22,2)")
|
||||
eq(trainer.facing, "down", "Bug Catcher faces down (STAY DOWN)")
|
||||
local header = Data:trainerHeader("Route9", ROUTE9_BUG_CATCHER)
|
||||
eq(header and header.range, 4, "Bug Catcher sight range is 4")
|
||||
|
||||
for _ = 1, 10 do frame(ow) end
|
||||
check(not ow.engaging,
|
||||
"distance 4 south of DOWN trainer: no engage (screen Y $fc quirk)")
|
||||
|
||||
-- same plateau, still in pixel range: distance 2 and 3 must engage
|
||||
local ow2, t2 = freshRoute9(22, 4, "up")
|
||||
local guard = 0
|
||||
while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end
|
||||
check(ow2.engaging and t2.cellY == 2,
|
||||
"distance 2 on the upper plateau still engages")
|
||||
|
||||
local ow3 = freshRoute9(22, 3, "up")
|
||||
guard = 0
|
||||
while not ow3.engaging and guard < 10 do guard = guard + 1; frame(ow3) end
|
||||
check(ow3.engaging, "distance 3 on the upper plateau still engages")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -1072,6 +1072,81 @@ do
|
||||
mh:performMove(mh.player, mh.enemy, { id = "DOUBLESLAP", pp = 10 })
|
||||
check(hasText(mh, "Hit the enemy\n5 times!"),
|
||||
"player multi-hit uses _MultiHitText")
|
||||
-- #85: multi-hit replays PlayMoveAnimation per strike (pokered
|
||||
-- GetPlayerAnimationType loop), interleaved with each HP drain
|
||||
do
|
||||
local anims, seq = 0, {}
|
||||
for _, r in ipairs(mh.queue) do
|
||||
if r.anim == "DOUBLESLAP" then
|
||||
anims = anims + 1
|
||||
seq[#seq + 1] = "anim"
|
||||
elseif r.drain then
|
||||
seq[#seq + 1] = "drain"
|
||||
end
|
||||
end
|
||||
eq(anims, 5, "multi-hit queues the move animation once per hit")
|
||||
eq(table.concat(seq, ","),
|
||||
"anim,drain,anim,drain,anim,drain,anim,drain,anim,drain",
|
||||
"multi-hit interleaves anim + drain per strike")
|
||||
for _, r in ipairs(mh.queue) do
|
||||
if r.anim == "DOUBLESLAP" then
|
||||
check(r.hit ~= nil, "each multi-hit anim carries hit blink/sfx")
|
||||
end
|
||||
end
|
||||
end
|
||||
-- #85: crit / effectiveness reprint each strike (.moveDidNotMiss
|
||||
-- before the GetPlayerAnimationType loop-back). DOUBLE_KICK is a
|
||||
-- fixed 2-hit Fighting move -- SE vs Normal SNORLAX.
|
||||
do
|
||||
local function countText(b, s)
|
||||
local n = 0
|
||||
for _, it in ipairs(b.queue) do
|
||||
if it.text and it.text:find(s, 1, true) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
|
||||
local mhk = BattleState.newWild(Game, "SNORLAX", 40)
|
||||
mhk.rng = mkseq({ 0, 255, 255 }) -- hit, no crit, max roll
|
||||
mhk:performMove(mhk.player, mhk.enemy, { id = "DOUBLE_KICK", pp = 10 })
|
||||
eq(countText(mhk, "It's super\neffective!"), 2,
|
||||
"multi-hit prints effectiveness once per strike")
|
||||
local seq = {}
|
||||
for _, r in ipairs(mhk.queue) do
|
||||
if r.anim == "DOUBLE_KICK" then seq[#seq + 1] = "anim"
|
||||
elseif r.drain then seq[#seq + 1] = "drain"
|
||||
elseif r.text == "It's super\neffective!" then seq[#seq + 1] = "se"
|
||||
elseif r.text and r.text:find("times!", 1, true) then seq[#seq + 1] = "count"
|
||||
end
|
||||
end
|
||||
eq(table.concat(seq, ","), "anim,drain,se,anim,drain,se,count",
|
||||
"multi-hit queues SE text between strikes, count after")
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Events = require("src.mods.Events")
|
||||
local savedE, savedH = Runtime.events, Runtime.hooks
|
||||
local hooks = Hooks.new()
|
||||
Runtime.install(Events.new(), hooks)
|
||||
local unsub = hooks:wrap("battle.crit", function() return true end)
|
||||
local mhc = BattleState.newWild(Game, "SNORLAX", 40)
|
||||
mhc.rng = mkseq({ 0, 255 }) -- acc, damage; crit from the hook
|
||||
mhc:performMove(mhc.player, mhc.enemy, { id = "DOUBLE_KICK", pp = 10 })
|
||||
eq(countText(mhc, "Critical hit!"), 2,
|
||||
"multi-hit prints Critical hit! once per strike")
|
||||
local cseq = {}
|
||||
for _, r in ipairs(mhc.queue) do
|
||||
if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim"
|
||||
elseif r.drain then cseq[#cseq + 1] = "drain"
|
||||
elseif r.text == "Critical hit!" then cseq[#cseq + 1] = "crit"
|
||||
elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se"
|
||||
end
|
||||
end
|
||||
eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,crit,se",
|
||||
"crit then effectiveness follow each multi-hit drain")
|
||||
unsub()
|
||||
Runtime.install(savedE, savedH)
|
||||
end
|
||||
Game.save.party = { Pokemon.new(Data, "SNORLAX", 30) }
|
||||
local mh2 = BattleState.newWild(Game, "RATTATA", 5)
|
||||
mh2.rng = mkseq({ 7, 0, 255, 255 })
|
||||
@@ -2611,6 +2686,43 @@ do
|
||||
tostring(c[3]), tostring(c[4])))
|
||||
end
|
||||
end
|
||||
|
||||
-- == issue #4: ledge hop countdown is fixed-step, not draw-rate ==
|
||||
-- hopFrames used to decrement inside Player:draw, so >59fps ended the
|
||||
-- arc early and <59fps stretched it. Countdown belongs in update().
|
||||
do
|
||||
local Player = require("src.world.Player")
|
||||
local p = Player.new(Data, 5, 6, "down")
|
||||
p.hopFrames, p.hopTotal = 32, 32
|
||||
p:draw(0, 0)
|
||||
p:draw(0, 0)
|
||||
p:draw(0, 0)
|
||||
eq(p.hopFrames, 32, "draw does not consume hopFrames")
|
||||
for _ = 1, 10 do p:update() end
|
||||
eq(p.hopFrames, 22, "ten fixed updates consume ten hopFrames")
|
||||
for _ = 1, 22 do p:update() end
|
||||
eq(p.hopFrames, 0, "hop expires after hopTotal fixed updates")
|
||||
p:update()
|
||||
eq(p.hopFrames, 0, "hopFrames stays at 0 once expired")
|
||||
end
|
||||
|
||||
-- == issue #4: tile anim tick accumulates wall-clock into 60Hz steps ==
|
||||
do
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.setSpinning(true)
|
||||
local before = TileRenderer.spinBlurActive()
|
||||
for _ = 1, 8 do TileRenderer.tick(1 / 60) end
|
||||
check(before ~= TileRenderer.spinBlurActive(),
|
||||
"tick(1/60) advances water/spinner clock at fixed 60Hz")
|
||||
local mid = TileRenderer.spinBlurActive()
|
||||
TileRenderer.tick(1 / 120)
|
||||
eq(TileRenderer.spinBlurActive(), mid,
|
||||
"sub-frame dt does not advance the tile anim clock")
|
||||
TileRenderer.tick(1 / 120)
|
||||
-- second half-frame completes one step; phase may or may not flip
|
||||
-- (8-tick blur period), but the clock must have accepted the step
|
||||
TileRenderer.setSpinning(false)
|
||||
end
|
||||
end
|
||||
|
||||
-- ================= BUGS.md batch: border-tree =================
|
||||
|
||||
Reference in New Issue
Block a user