LOTS OF UPDATES

LOTS OF UPDATES - SEE ALL ISSUES THIS CLOSED
This commit is contained in:
bryanthaboi
2026-07-21 14:57:32 -04:00
committed by GitHub
42 changed files with 10142 additions and 211 deletions
File diff suppressed because it is too large Load Diff
+23 -18
View File
@@ -10,8 +10,8 @@
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
-- afterwards he gloats or sulks and marches out of the lab
-- (OaksLabRivalBattleEndScript).
-- afterwards HealParty + flag always, then he gloats or sulks and
-- marches out (OaksLabRivalEndBattleScript). A loss does not black out.
-- ball objects: CHARMANDER (6,3), SQUIRTLE (7,3), BULBASAUR (8,3);
-- rival = object 1 at (4,3). rivalBallX is the counter-pick's column.
@@ -107,9 +107,9 @@ return {
TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_false", 20 }, -- 3
{ "jump_if_false", 21 }, -- 3
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
{ "jump_if_true", 18 }, -- 5
{ "jump_if_true", 19 }, -- 5
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
@@ -120,18 +120,20 @@ return {
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 16
{ "jump", 21 }, -- 17
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 18
{ "jump", 25 }, -- 19
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 20
-- battle aftermath: on a win the rival sulks and marches out
-- (OaksLabRivalBattleEnd); on a loss the blackout already warped
-- us away, so the script just ends
{ "jump_if_false", 25 }, -- 21
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 22
{ "move_npc_to", 1, 4, 11 }, -- 23
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 24 (25 = end)
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
{ "heal_party" }, -- 16
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
{ "jump", 23 }, -- 18
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
{ "jump", "end" }, -- 20
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
{ "jump", "end" }, -- 22
-- win: sulk text then exit; loss: Rival1WinText already played in
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
{ "jump_if_false", 25 }, -- 23
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
{ "move_npc_to", 1, 4, 11 }, -- 25
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
},
},
@@ -178,9 +180,12 @@ return {
local party = flags.EVENT_CHOSE_BULBASAUR and 3
or flags.EVENT_CHOSE_SQUIRTLE and 2 or 1
table.insert(rows, { "start_battle", "trainer", "OPP_RIVAL1", party })
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- a loss blacks out to another map: end the script there
table.insert(rows, { "jump_if_false", base + 7 })
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
-- already shown in-battle via Rival1WinText)
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
+55 -1
View File
@@ -252,7 +252,17 @@ M.POKEMON_TOWER_7F = {
{ "show_object", "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI" }, -- 5
{ "hide_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET8" }, -- 6
{ "show_object", "SAFFRON_CITY", "SAFFRONCITY_ROCKET9" }, -- 7
{ "warp", "MR_FUJIS_HOUSE", 3, 3, "down" }, -- 8
-- pokered warps to wDestinationWarpID $1 (0-based) -- the house's
-- SECOND warp, the door mat at (3,7) -- facing UP
-- (PokemonTower7FWarpToMrFujiHouseScript: SPRITE_FACING_UP +
-- hWarpDestinationMap MR_FUJIS_HOUSE). Landing (3,3) instead put
-- the player at the Pokédex table, and the route's first waypoint
-- (3,7) then stepped onto a LIVE door mat and exited the house
-- before ever talking to Fuji -- so the POKE_FLUTE was never
-- collected and the Route 16 SNORLAX sealed the map. The arrival
-- mat itself is inert until stepped off (warpEntryCell), which is
-- what makes the vanilla coordinates safe.
{ "warp", "MR_FUJIS_HOUSE", 3, 7, "up" }, -- 8
},
},
}
@@ -387,6 +397,50 @@ M.WARDENS_HOUSE = {
-- -------------------------------------------------------------------
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
-- .PlayerCoordsArray -- (6,13) and (7,12) -- every frame while
-- EVENT_BEAT_SILPH_CO_GIOVANNI is unset: standing there shows his text,
-- walks him three tiles down (.GiovanniMovement), and starts the fight.
-- He also has no trainer-header entry, so sight engagement never fires
-- either. Without this hook he was a talk-only statue four tiles away
-- from anything the route (or a vanilla-faithful player walking the same
-- line) would touch, and the whole Silph ending -- the flag, the Master
-- Ball, the Saffron streets clearing -- silently never happened.
--
-- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and,
-- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss
-- sets nothing, so the trigger re-arms exactly as vanilla does.
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end
if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end
local gio
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end
end
if not gio or ow:trainerDefeated(gio) then return false end
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
end
end)
end)
return true
end,
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
end
end,
talk = {
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
{ "face_player" }, -- 1
+36 -2
View File
@@ -417,6 +417,26 @@ M.BIKE_SHOP = {
-- the Cinnabar lab (the wait is skipped).
-- -------------------------------------------------------------------
-- The Super Nerd (object index 1) claims both fossils and blocks the way
-- to them. In scripts/MtMoonB2F.asm he isn't a sight-line trainer: his
-- header carries no range, so MtMoonB2FDefaultScript force-triggers the
-- battle the instant the player steps onto (13,8) -- the chokepoint tile
-- to his right -- and reaching for a fossil intercepts you too.
local function superNerdBeaten(ow)
local nerd = ow:npcByIndex(1)
return not nerd or ow:trainerDefeated(nerd)
end
local function engageSuperNerd(game, ow, onDone)
local nerd = ow:npcByIndex(1)
if not nerd or ow:trainerDefeated(nerd) then
if onDone then onDone() end
return
end
nerd:facePlayer(ow.player)
ow:engageTrainer(nerd, onDone)
end
local function mtMoonFossil(itemId, otherName)
return function(game, ow, npc, done)
local TextBox = require("src.render.TextBox")
@@ -425,7 +445,13 @@ local function mtMoonFossil(itemId, otherName)
game.stack:push(TextBox.new(game, "You already took\na fossil.", done))
return
end
game.stack:push(TextBox.new(game, "You found a\nfossil! Take it?", function()
-- can't grab a fossil until the Super Nerd is beaten -- he intercepts
if not superNerdBeaten(ow) then
engageSuperNerd(game, ow, done)
return
end
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game, ("You want the\n%s?"):format(name), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then done() return end
game.save.inventory[itemId] = 1
@@ -434,7 +460,6 @@ local function mtMoonFossil(itemId, otherName)
local ctx = { save = game.save, overworld = ow, game = game }
Commands.hide_object(ctx, "MT_MOON_B2F", npc.def.name)
Commands.hide_object(ctx, "MT_MOON_B2F", otherName)
local name = game.data.items[itemId].name
game.stack:push(TextBox.new(game,
("%s got the\n%s!"):format(game.save.player.name, name), done))
end))
@@ -443,6 +468,15 @@ local function mtMoonFossil(itemId, otherName)
end
M.MT_MOON_B2F = {
-- MtMoonB2FDefaultScript forces the Super Nerd battle when the player
-- steps onto (13,8), the tile beside him guarding the fossils.
onStep = function(game, ow, x, y)
if x == 13 and y == 8 and not superNerdBeaten(ow) then
engageSuperNerd(game, ow, nil)
return true
end
return false
end,
talk = {
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil("DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL"),
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil("HELIX_FOSSIL", "MTMOONB2F_DOME_FOSSIL"),
+1 -1
View File
@@ -104,7 +104,7 @@ M.POKEMON_TOWER_6F = {
-- immediately re-fires.
ow:scriptMove(ow.player, "right", 1)
end
ow:afterBattle(result)
ow:afterBattle(result, battle)
end
game.stack:push(battle)
end))
+90
View File
@@ -633,4 +633,94 @@ M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
-- -------------------------------------------------------------------
-- Lance's room (scripts/LancesRoom.asm). Unlike the other three E4
-- rooms this one gates its ENTRANCE, not its exit: the .blk ships with
-- the arena doorway CLOSED (blocks $72/$73 at block (2,6)/(3,6), cells
-- (4-7,12-13)), and LanceShowOrHideEntranceBlocks OPENS it ($31/$32)
-- on every map load while EVENT_LANCES_ROOM_LOCK_DOOR is unset.
-- Without this script the doorway never opened, so the whole arena --
-- Lance AND both CHAMPIONS_ROOM warps at (5,0)/(6,0) -- was sealed off
-- from the entrance hall and the league dead-ended here ("goto (6,11)
-- unreachable on LANCES_ROOM").
--
-- LancesRoomDefaultScript's coordinate triggers, all inert once
-- EVENT_BEAT_LANCE is set:
-- (5,1)/(6,2) beside Lance -> his battle starts (a coordinate
-- trigger, not a talk; victories.lua OPP_LANCE#1 sets
-- EVENT_BEAT_LANCE on the win, so a loss re-arms)
-- (5,11)/(6,11) the doorway -> CheckAndSetEvent
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
-- the door behind the player with SFX_GO_INSIDE
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
-- (up 12, left 12, down 7, left 6) landing on (6,11).
-- It marches straight across the room's water decor:
-- pokered's CollisionCheckOnLand skips collision
-- entirely while simulated joypad states run, and our
-- scriptMove is collision-free the same way.
-- -------------------------------------------------------------------
local function lanceEntranceBlocks(game, ow)
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
ow:replaceBlock(2, 6, locked and 0x72 or 0x31)
ow:replaceBlock(3, 6, locked and 0x73 or 0x32)
end
local function lanceLockDoor(game, ow)
if game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR then return end
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true
require("src.core.Sound").play(game.data, "Go_Inside")
lanceEntranceBlocks(game, ow)
end
local function lanceWalkIn(game, ow)
ow:scriptMove(ow.player, "up", 12, function()
ow:scriptMove(ow.player, "left", 12, function()
ow:scriptMove(ow.player, "down", 7, function()
ow:scriptMove(ow.player, "left", 6, function()
-- the walk lands on (6,11); vanilla's per-frame coord poll
-- then locks the door at once. scriptMove landings do not
-- fire onStep, so lock here.
lanceLockDoor(game, ow)
end)
end)
end)
end)
end
M.LANCES_ROOM = {
onEnter = function(game, ow)
lanceEntranceBlocks(game, ow)
-- the warp arrival lands ON the staircase trigger, and onStep only
-- fires for completed steps -- start the walk-in here, the same
-- way Lorelei's auto walk-in runs from its onEnter
if not game.save.flags.EVENT_BEAT_LANCE
and ow.player.cellX == 24 and ow.player.cellY == 16 then
lanceWalkIn(game, ow)
end
end,
onStep = function(game, ow, x, y)
if game.save.flags.EVENT_BEAT_LANCE then return false end
if (x == 5 and y == 1) or (x == 6 and y == 2) then
local lance
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end
end
if not lance or ow:trainerDefeated(lance) then return false end
lance:facePlayer(ow.player)
ow:engageTrainer(lance, function() end)
return true
end
if (x == 5 or x == 6) and y == 11 then
lanceLockDoor(game, ow)
return false
end
if x == 24 and y == 16 then
lanceWalkIn(game, ow)
return true
end
return false
end,
}
return M
+32 -9
View File
@@ -5,24 +5,46 @@
-- Rocket Hideout Giovanni: his Silph Scope is an item ball next to him
-- (data/maps/objects/RocketHideoutB4F.asm), so no reward entry needed.
-- The TM each gym leader hands out afterwards is also ported.
--
-- `deactivate` lists the EVENT_BEAT_* flags each gym's victory script
-- sets to retire unfought non-leader trainers (PewterGym.asm
-- "; deactivate gym trainers" / SetEventRange in the other gyms, and
-- FightingDojo.asm SetEventRange EVENT_BEAT_KARATE_MASTER ..
-- EVENT_BEAT_FIGHTING_DOJO_TRAINER_3).
local function range(prefix, first, last)
local t = {}
for i = first, last do
t[#t + 1] = prefix .. i
end
return t
end
return {
["OPP_BROCK#1"] = { badge = "BOULDERBADGE", flag = "EVENT_BEAT_BROCK",
item = "TM_BIDE" },
item = "TM_BIDE",
deactivate = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" } },
["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY",
item = "TM_BUBBLEBEAM" },
item = "TM_BUBBLEBEAM",
deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1) },
["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE",
item = "TM_THUNDERBOLT" },
item = "TM_THUNDERBOLT",
deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2) },
["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA",
item = "TM_MEGA_DRAIN" },
item = "TM_MEGA_DRAIN",
deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6) },
["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA",
item = "TM_TOXIC" },
item = "TM_TOXIC",
deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5) },
["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA",
item = "TM_PSYWAVE" },
item = "TM_PSYWAVE",
deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6) },
["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE",
item = "TM_FIRE_BLAST" },
item = "TM_FIRE_BLAST",
deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6) },
["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI",
item = "TM_FISSURE" },
item = "TM_FISSURE",
deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7) },
-- Silph Co. Giovanni: unlocks the president's Master Ball gift
["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" },
@@ -31,7 +53,8 @@ return {
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
-- only him (data/maps/objects/FightingDojo.asm).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER" },
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
-- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable)
+2 -1
View File
@@ -132,6 +132,7 @@ migrated once into `options.lua` on load.
- Music / SFX volume
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- COLORS (GBC / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
- COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC), also hotkey `2`
(RED++ uses pokered-gbc SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
+3
View File
@@ -223,6 +223,9 @@ function love.quit()
if editorMode and EditorApp.quit then
return EditorApp.quit() -- return true to abort quit
end
pcall(function()
require("src.core.DiscordPresence").shutdown()
end)
end
function love.filedropped(file)
+120 -24
View File
@@ -106,19 +106,25 @@ end
Assets.register(BattleState.invalidate)
-- the species' SGB palette (data/pokemon/palettes.asm), or nil
-- the species' SGB palette (active COLORS pack), or nil
local function monPalette(data, species)
local p = data.palettes
local name = p and p.pokemon[species]
local colors = name and p.palettes[name]
return colors and { name = name, colors = colors } or nil
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.monPal(data, species)
if not colors then return nil end
local name = PaletteFX.monPalName(data, species)
-- prefix so GBC vs RED++ cache keys don't collide on shared names
if PaletteFX.usesGbcPack() then name = "redpp:" .. name end
return { name = name, colors = colors }
end
-- a named palette from data/generated/palettes.lua as a getImage pal
-- a named palette from the active COLORS pack as a getImage pal
local function namedPalette(data, name)
local p = data.palettes
local colors = p and p.palettes[name]
return colors and { name = name, colors = colors } or nil
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.pal(data, name)
if not colors then return nil end
local key = name
if PaletteFX.usesGbcPack() then key = "redpp:" .. name end
return { name = key, colors = colors }
end
-- The battle-BGP fade variant of a pic (AnimationFlashScreen and the
@@ -264,8 +270,10 @@ function BattleState:speciesSprite(species, isPlayerSide)
if not def then return nil end
local PaletteFX = require("src.render.PaletteFX")
local colors = PaletteFX.monPal(self.data, species, true)
local name = "GRAYMON"
if PaletteFX.usesGbcPack() then name = "redpp:GRAYMON" end
return getImage(isPlayerSide and def.spriteBack or def.spriteFront,
colors and { name = "GRAYMON", colors = colors } or nil,
colors and { name = name, colors = colors } or nil,
def.trueColor)
end
@@ -414,8 +422,17 @@ end
function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
-- instead of TrainerNames ("RIVAL1" etc.). Overlay so we don't mutate
-- the shared data table.
if oppClass == "OPP_RIVAL1" or oppClass == "OPP_RIVAL2"
or oppClass == "OPP_RIVAL3" then
local rivalName = (game.save.player and game.save.player.rival) or "BLUE"
self.trainer = setmetatable({ name = rivalName }, { __index = self.trainer })
end
self.enemyAIMods = self.trainer.aiMods
local partyDef = self.trainer.parties[partyIndex or 1]
assert(partyDef, ("trainer %s has no party %s"):format(oppClass, tostring(partyIndex)))
@@ -1547,6 +1564,25 @@ function BattleState:animationsOn()
return not o or o.animations ~= false
end
-- Drop the announcement-time move-anim row. Gen 1 queues PlayMoveAnimation
-- 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.
function BattleState:cancelMoveAnim()
local row = self.moveAnimRow
if not row then return end
self.moveAnimRow = nil
for i, item in ipairs(self.queue) do
if item == row then
table.remove(self.queue, i)
if self.nextInsert and i <= self.nextInsert then
self.nextInsert = self.nextInsert - 1
end
return
end
end
end
-- ------------------------------------------------------------------
-- special-effect (SE_*) implementations. Palette effects are BGP
-- shade maps ({[i] = shade color index i displays as}); on the SGB the
@@ -2151,6 +2187,20 @@ end
-- Decomposed into a staged pipeline over the merged move_effects record:
-- announcement -> callsMove -> charge -> perform -> primary run -> the
-- damaging pipeline (EffectRegistry.runDamaging).
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
-- after they land; these failure texts print with no animation.
local function primaryEffectFailed(msgs)
if not msgs or #msgs == 0 then return true end
local m = msgs[1]
if m == "But, it failed!" or m == "Nothing happened!" then return true end
if m:find("didn't affect", 1, true) then return true end
if m:find("is unaffected", 1, true) then return true end
if m:find("protected by MIST", 1, true) then return true end
if m:find("Already", 1, true) then return true end
return false
end
function BattleState:performMove(user, target, moveInst, isCalled)
local move = self:moveDef(moveInst)
if not move then
@@ -2198,6 +2248,11 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- already said its failure text
if record and record.callsMove then
local pick = record.callsMove(ctx)
-- Mirror Move never plays its own anim (MetronomePickMove does;
-- MirrorMoveCopyMove only reloads the copied move or prints fail)
if move.id == "MIRROR_MOVE" or not pick then
self:cancelMoveAnim()
end
if pick then
self:performMove(user, target, { id = pick, pp = 1 }, true)
end
@@ -2240,10 +2295,19 @@ function BattleState:performMove(user, target, moveInst, isCalled)
if record.accuracyChecked
and (target.invulnerable
or not self:accuracyRoll(move, user, target)) then
-- SleepEffect/PoisonEffect/... call PlayCurrentMoveAnimation only
-- after the effect lands; a miss skips it
self:cancelMoveAnim()
self:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
for _, m in ipairs(record.run(ctx)) do
local msgs = record.run(ctx)
-- Gen 1 status/stat effects animate only when they take effect
-- (AlreadyAsleep / NothingHappened / ButItFailed print with no anim)
if primaryEffectFailed(msgs) then
self:cancelMoveAnim()
end
for _, m in ipairs(msgs) do
self:sayNext(m)
end
self:drainNext() -- REST/RECOVER/SOFTBOILED move the user's bar
@@ -2251,6 +2315,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
end
if move.power == 0 and not (record and record.kind == "full") then
MoveEffects.warnUnknown(move.effect)
self:cancelMoveAnim()
self:sayNext("But, it failed!")
return
end
@@ -2290,6 +2355,7 @@ function BattleState:continueBide(user, target)
local dmg = (user.bideDamage or 0) * 2
user.bideTurns, user.bideDamage = nil, nil
if dmg <= 0 then
self:cancelMoveAnim()
self:sayNext("But, it failed!")
return
end
@@ -2558,6 +2624,22 @@ function BattleState:learnMove(mon, moveId)
end)
end
-- Map the battle was fought on (overworld wins; save.player.map is fallback).
function BattleState.currentMapId(self)
local game = self.game
local ow = game and game.overworld
if ow and ow.map then return ow.map.id end
local player = game and game.save and game.save.player
return player and player.map
end
-- pret HandlePlayerBlackOut: OPP_RIVAL1 in OAKS_LAB prints Rival1WinText
-- and returns without blacking out. OaksLabRivalEndBattleScript then heals.
function BattleState.isOaksLabStarterRival(self)
return self.oppClass == "OPP_RIVAL1"
and BattleState.currentMapId(self) == "OAKS_LAB"
end
function BattleState:playerMonFainted()
local nextMon = Party.firstHealthy(self.game.save.party)
-- Being out of useable POKéMON blacks you out even when the battle was
@@ -2570,9 +2652,20 @@ function BattleState:playerMonFainted()
-- aborts with "no healthy party"), and it is not reachable in pokered:
-- HandlePlayerMonFainted runs the player-side check on its own, so
-- losing your last mon always blacks you out whatever the enemy did.
-- Exception: the Oak's Lab starter rival (HandlePlayerBlackOut).
if not nextMon and self.result ~= "lose" then
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
if self.oppClass == "OPP_RIVAL1" then
local TextBox = require("src.render.TextBox")
local raw = (self.data.text and self.data.text._Rival1WinText)
or "{RIVAL}: Yeah! Am\nI great or what?"
self:sayNext(TextBox.substitute(self.game, raw))
end
-- Oak's Lab starter rival: Rival1WinText only (no blackout lines).
-- Any other wipe, including Route 22 RIVAL1, still blacks out.
if not BattleState.isOaksLabStarterRival(self) then
self:sayNext(("%s is out of\nuseable POKéMON!"):format(self.game.save.player.name))
self:sayNext(("%s blacked\nout!"):format(self.game.save.player.name))
end
self.result = "lose"
self.afterQueue = "finish"
return
@@ -3037,11 +3130,13 @@ function BattleState:finish()
return
end
-- Invariant: a battle can never hand the overworld a party with nothing
-- healthy in it. afterBattle only revives and warps to the heal point on
-- "lose", so any other result here strands the player at 0 HP with no way
-- back -- an unrecoverable state, not merely a wrong one. playerMonFainted
-- is the path that should have caught this; if we land here it did not, so
-- say so rather than silently papering over it.
-- healthy in it -- except the Oak's Lab starter rival, where pret skips
-- the blackout and OaksLabRivalEndBattleScript HealParty's immediately.
-- afterBattle only revives and warps to the heal point on a normal
-- "lose", so any other result here strands the player at 0 HP with no
-- way back -- an unrecoverable state, not merely a wrong one.
-- playerMonFainted is the path that should have caught this; if we land
-- here it did not, so say so rather than silently papering over it.
if self.result ~= "lose" and not Party.firstHealthy(self.game.save.party) then
Logger.warn("battle finished %s with no healthy party; forcing blackout",
tostring(self.result))
@@ -3364,9 +3459,10 @@ function BattleState:colorMode()
if self.colorFxReady == nil then
local ready = false
local g = love and love.graphics
local PaletteFX = require("src.render.PaletteFX")
if g and g.newCanvas and g.setScissor and g.setShader and g.getCanvas
and love.image and self.data.palettes
and require("src.render.PaletteFX").shader() then
and love.image and PaletteFX.pack(self.data)
and PaletteFX.shader() then
local ok1, bg = pcall(g.newCanvas, 160, 144)
local ok2, wv = pcall(g.newCanvas, 160, 144)
if ok1 and ok2 and bg and wv then
@@ -3385,9 +3481,10 @@ end
-- PAL_MEWMON (= MonsterPalettes[0]) while a side still shows its
-- trainer/back pic (the species bytes are 0 then).
function BattleState:sgbBattlePals()
local pals = self.data.palettes and self.data.palettes.palettes
if not pals then return nil end
local PaletteFX = require("src.render.PaletteFX")
local pack = PaletteFX.pack(self.data)
local pals = pack and pack.palettes
if not pals then return nil end
local function bar(b)
if not b then return pals.GREENBAR end
local hp = b.shownHP or b.mon.hp
@@ -3395,8 +3492,7 @@ function BattleState:sgbBattlePals()
end
local function mon(b, placeholder)
if placeholder or not b then return pals.MEWMON or pals.GREENBAR end
local name = self.data.palettes.pokemon[b.mon.species]
return pals[name] or pals.MEWMON
return PaletteFX.monPal(self.data, b.mon.species) or pals.MEWMON
end
return {
[0] = bar(self.player),
+10
View File
@@ -89,6 +89,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
-- SWIFT_EFFECT before the INVULNERABLE check)
if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
@@ -97,6 +99,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if record and record.gate then
local ok, failMsg = record.gate(ctx)
if not ok then
battle:cancelMoveAnim()
if failMsg then battle:sayNext(failMsg) end
return
end
@@ -108,6 +111,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not neverMiss then
if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
-- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
@@ -134,6 +139,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
end
if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim()
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
return
end
@@ -144,6 +150,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- failed with that text already chosen
local chosen, extra = record.chooseDamage(ctx)
if not chosen then
battle:cancelMoveAnim()
if extra then battle:sayNext(extra) end
return
end
@@ -154,12 +161,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end
if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("It doesn't affect\n%s!"):format(displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end
return
end
if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end
battle:sayNext(("%s's\nattack missed!"):format(displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end
return
+5
View File
@@ -616,6 +616,7 @@ MoveEffects.full = {
-- roll is below opponentLevel/4. Teleport's failure text is "But
-- it failed!", Roar/Whirlwind's is DidntAffectText; in trainer
-- battles Teleport fails and Roar/Whirlwind are "unaffected".
-- Fail paths DelayFrames then print -- no PlayCurrentMoveAnimation.
perform = function(ctx)
local battle, user, target, move = ctx.battle, ctx.user, ctx.target, ctx.move
if battle.kind == "wild" then
@@ -635,13 +636,17 @@ MoveEffects.full = {
battle.result = "run"
battle.afterQueue = "finish"
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
else
battle:cancelMoveAnim()
ctx.say(("It didn't affect\n%s!"):format(displayName(target)))
end
elseif move.id == "TELEPORT" then
battle:cancelMoveAnim()
ctx.say("But, it failed!")
else
battle:cancelMoveAnim()
ctx.say(("%s\nis unaffected!"):format(displayName(target)))
end
end,
+24
View File
@@ -97,6 +97,30 @@ function Data:seedDefaults()
-- the overworld's Kanto literals, same fill-if-absent contract; required
-- here rather than at the top so core keeps out of src/world at load time
require("src.world.FieldDefaults").seed(self)
-- Cinnabar's quiz trainers are text_asm (no def_trainers), so the
-- extractor never writes headers for them. Seed the EVENT_BEAT_* /
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
end
function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
local gym = {}
for i = 0, 6 do
local n = i + 1
-- object indices 2..8 are SUPER_NERD1..7; range 0 -- they only
-- engage via talk / wrong quiz answer, never sight lines
gym[i + 2] = {
event = "EVENT_BEAT_CINNABAR_GYM_TRAINER_" .. i,
range = 0,
battle = "_CinnabarGymSuperNerd" .. n .. "BattleText",
won = "_CinnabarGymSuperNerd" .. n .. "EndBattleText",
after = "_CinnabarGymSuperNerd" .. n .. "AfterBattleText",
}
end
headers.CinnabarGym = gym
end
-- POKEPORT_DATA_DIR points a test runner at another dataset root (the
+658
View File
@@ -0,0 +1,658 @@
-- Discord Rich Presence via the local Discord IPC pipe. Shows the player's
-- current map (and battle status) on their Discord profile. Desktop only;
-- no-ops on mobile, headless runs, or when Discord is not running.
--
-- Application ID is the only credential needed for presence. The Discord
-- Interactions public key is unused here (that is for verifying bot HTTP
-- payloads, not Rich Presence).
local Json = require("src.link.Json")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local DiscordPresence = {
APP_ID = "1529183141267374262",
}
local OP_HANDSHAKE, OP_FRAME, OP_CLOSE = 0, 1, 2
local MIN_UPDATE_INTERVAL = 2.0
local RECONNECT_INTERVAL = 30.0
local state = {
enabled = false,
connected = false,
socket = nil, -- Windows: file handle; Unix: ffi sockfd
isWindows = false,
ffi = nil,
ffiReady = false,
startedAt = 0,
dirty = false,
lastSentKey = nil,
lastSendAt = -1e9,
nextReconnectAt = 0,
location = nil, -- display name
mapId = nil,
activity = "menu", -- menu | exploring | battle
battleLabel = nil,
unsubs = {},
pid = nil,
loggedAbsent = false, -- one quiet note when Discord isn't running
}
local function disabledByEnv()
return os.getenv("POKEPORT_NO_DISCORD") == "1"
or os.getenv("POKEPORT_AUTOPILOT") ~= nil
or os.getenv("POKEPORT_DRIVER") ~= nil
end
local function isDesktop()
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "OS X" or osName == "Windows" or osName == "Linux"
end
local function packU32(n)
n = math.floor(n) % 0x100000000
local b1 = n % 256; n = math.floor(n / 256)
local b2 = n % 256; n = math.floor(n / 256)
local b3 = n % 256; n = math.floor(n / 256)
local b4 = n % 256
return string.char(b1, b2, b3, b4)
end
local function unpackU32(s)
local b1, b2, b3, b4 = s:byte(1, 4)
return b1 + b2 * 256 + b3 * 65536 + b4 * 16777216
end
local function nonce()
return string.format("%08x-%04x-4%03x-%04x-%04x%08x",
math.random(0, 0xffffffff),
math.random(0, 0xffff),
math.random(0, 0xfff),
math.random(0, 0x3fff) + 0x8000,
math.random(0, 0xffff),
math.random(0, 0xffffffff))
end
local function resolvePid()
if state.pid then return state.pid end
local ffi = state.ffi
if ffi then
if state.isWindows then
pcall(ffi.cdef, "unsigned long GetCurrentProcessId(void);")
local ok, p = pcall(function() return ffi.C.GetCurrentProcessId() end)
if ok and p and tonumber(p) and tonumber(p) > 0 then
state.pid = tonumber(p)
return state.pid
end
else
pcall(ffi.cdef, "int getpid(void);")
local ok, p = pcall(function() return ffi.C.getpid() end)
if ok and p and tonumber(p) and tonumber(p) > 0 then
state.pid = tonumber(p)
return state.pid
end
end
end
state.pid = 1000 + math.random(1, 8999)
return state.pid
end
local function ensureFfi()
if state.ffiReady then return state.ffi end
if state.ffi == false then return nil end
local ok, ffi = pcall(require, "ffi")
if not ok or not ffi then
state.ffi = false
return nil
end
state.ffi = ffi
if state.isWindows then
state.ffiReady = true
return ffi
end
-- Darwin sockaddr_un has sun_len + uint8_t family; Linux uses u16 family.
local osName = love.system.getOS()
local cdef
if osName == "OS X" then
cdef = [[
typedef unsigned int socklen_t;
typedef int ssize_t;
struct sockaddr_un {
uint8_t sun_len;
uint8_t sun_family;
char sun_path[104];
};
struct timeval { long tv_sec; int tv_usec; };
int socket(int domain, int type, int protocol);
int connect(int sockfd, const void *addr, socklen_t addrlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
int close(int fd);
]]
else
cdef = [[
typedef unsigned int socklen_t;
typedef int ssize_t;
struct sockaddr_un {
unsigned short sun_family;
char sun_path[108];
};
struct timeval { long tv_sec; long tv_usec; };
int socket(int domain, int type, int protocol);
int connect(int sockfd, const void *addr, socklen_t addrlen);
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
int close(int fd);
]]
end
-- cdef may already be registered from a prior boot; either way we need
-- sockaddr_un usable before claiming readiness
pcall(ffi.cdef, cdef)
local ready = pcall(function() return ffi.new("struct sockaddr_un") end)
if not ready then
state.ffi = false
state.ffiReady = false
return nil
end
state.ffiReady = true
return ffi
end
local function closeSocket()
local sock = state.socket
state.socket = nil
state.connected = false
if not sock then return end
if state.isWindows then
pcall(function() sock:close() end)
elseif state.ffi then
pcall(state.ffi.C.close, sock)
end
end
local function writeRaw(bytes)
if not state.socket then return false end
if state.isWindows then
local ok = pcall(function()
state.socket:seek("end")
local _, writeErr = state.socket:write(bytes)
state.socket:flush()
if writeErr then error(writeErr) end
end)
return ok
end
local ffi = state.ffi
if not ffi then return false end
local ok, sent = pcall(ffi.C.send, state.socket, bytes, #bytes, 0)
return ok and sent == #bytes
end
local function sendFrame(opcode, payload)
local body = payload or ""
local ok, frame = pcall(function()
return packU32(opcode) .. packU32(#body) .. body
end)
if not ok or not frame then return false end
return writeRaw(frame)
end
local function readExact(n)
if not state.socket or n <= 0 then return nil end
if state.isWindows then
local ok, data = pcall(function() return state.socket:read(n) end)
if not ok or not data or #data < n then return nil end
return data
end
local ffi = state.ffi
if not ffi then return nil end
local ok, data = pcall(function()
local buf = ffi.new("char[?]", n)
local got = 0
while got < n do
local nread = ffi.C.recv(state.socket, buf + got, n - got, 0)
if not nread or nread <= 0 then return nil end
got = got + tonumber(nread)
end
return ffi.string(buf, n)
end)
if not ok then return nil end
return data
end
local function receiveFrame()
local header = readExact(8)
if not header then return nil end
local ok, opcode, length = pcall(function()
return unpackU32(header:sub(1, 4)), unpackU32(header:sub(5, 8))
end)
if not ok or not opcode or not length or length < 0 or length > 65536 then
return nil
end
local data = length > 0 and readExact(length) or ""
if data == nil then return nil end
return opcode, data
end
local function unixSocketPaths()
local paths = {}
local envs = { "XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP" }
local bases = {}
for _, key in ipairs(envs) do
local v = os.getenv(key)
if v and v ~= "" then
if v:sub(-1) == "/" then v = v:sub(1, -2) end
bases[#bases + 1] = v
end
end
bases[#bases + 1] = "/tmp"
local suffixes = {
"",
"/app/com.discordapp.Discord",
"/app/com.discordapp.DiscordCanary",
"/app/com.discordapp.DiscordPTB",
"/snap.discord",
"/snap.discord-canary",
}
local seen = {}
for i = 0, 9 do
local name = "discord-ipc-" .. i
for _, base in ipairs(bases) do
for _, suffix in ipairs(suffixes) do
local path = base .. suffix .. "/" .. name
if not seen[path] then
seen[path] = true
paths[#paths + 1] = path
end
end
end
end
return paths
end
local function setSocketTimeout(fd, seconds)
local ffi = state.ffi
if not ffi or not fd then return end
pcall(function()
local tv = ffi.new("struct timeval")
tv.tv_sec = seconds
tv.tv_usec = 0
-- SOL_SOCKET / SO_RCVTIMEO / SO_SNDTIMEO differ by OS
local sol, rcv, snd
if love.system.getOS() == "OS X" then
sol, rcv, snd = 0xffff, 0x1006, 0x1005
else
sol, rcv, snd = 1, 20, 21
end
ffi.C.setsockopt(fd, sol, rcv, tv, ffi.sizeof(tv))
ffi.C.setsockopt(fd, sol, snd, tv, ffi.sizeof(tv))
end)
end
local function connectUnix()
local ffi = ensureFfi()
if not ffi then return false end
local darwin = love.system.getOS() == "OS X"
local maxPath = darwin and 103 or 107
-- AF_UNIX=1, SOCK_STREAM=1 on Linux/macOS
local pathsOk, paths = pcall(unixSocketPaths)
if not pathsOk or not paths then return false end
for _, path in ipairs(paths) do
if #path <= maxPath then
local ok, connected = pcall(function()
local fd = ffi.C.socket(1, 1, 0)
if not fd or fd < 0 then return false end
local addr = ffi.new("struct sockaddr_un")
addr.sun_family = 1
if darwin then
-- SUN_LEN: sizeof(sun_len+sun_family) + path + NUL
addr.sun_len = 2 + #path + 1
end
ffi.copy(addr.sun_path, path)
local rc = ffi.C.connect(fd, ffi.cast("void*", addr), ffi.sizeof(addr))
if rc == 0 then
setSocketTimeout(fd, 1) -- handshake must not freeze the game
state.socket = fd
return true
end
ffi.C.close(fd)
return false
end)
if ok and connected then return true end
end
end
return false
end
local function connectWindows()
for i = 0, 9 do
local path = "\\\\.\\pipe\\discord-ipc-" .. i
local ok, file = pcall(io.open, path, "r+")
if ok and file then
state.socket = file
return true
end
end
return false
end
local function handshake()
local ok, payload = pcall(Json.encode, { v = 1, client_id = DiscordPresence.APP_ID })
if not ok or not payload then return false end
if not sendFrame(OP_HANDSHAKE, payload) then return false end
-- Discord echoes READY as a FRAME. A timeout/missing client is a soft miss.
local opcode = receiveFrame()
if opcode == OP_FRAME then return true end
-- Windows named pipes have no easy read timeout: if the write succeeded
-- and Discord is present, proceed optimistically so boot never blocks.
if state.isWindows then return true end
return false
end
local function noteAbsent()
if state.loggedAbsent then return end
state.loggedAbsent = true
-- info, not warn: missing Discord is normal for many players
Logger.info("discord: rich presence unavailable (Discord not running)")
end
local function connect()
local ok, result = pcall(function()
closeSocket()
local linked
if state.isWindows then
linked = connectWindows()
else
linked = connectUnix()
end
if not linked then return false end
if not handshake() then
closeSocket()
return false
end
state.connected = true
state.dirty = true
state.lastSentKey = nil
state.loggedAbsent = false
Logger.info("discord: rich presence connected")
return true
end)
if not ok then
closeSocket()
return false
end
return result and true or false
end
local function locationName(game, mapId)
if not mapId then return nil end
local field = game and game.data and game.data.field
local townMap = field and field.townMap
local locations = townMap and (townMap.locations or townMap)
local entry = type(locations) == "table" and locations[mapId]
if type(entry) == "table" then
local name = entry.name or entry.label
if type(name) == "string" and name ~= "" then return name end
end
local def = game and game.data and game.data.maps and game.data.maps[mapId]
if def and type(def.label) == "string" and def.label ~= "" then
-- PalletTown -> Pallet Town
return def.label:gsub("(%l)(%u)", "%1 %2")
end
return tostring(mapId):gsub("_", " ")
end
local function speciesName(game, species)
if not species or not game or not game.data or not game.data.pokemon then
return nil
end
local def = game.data.pokemon[species]
return def and def.name or tostring(species):gsub("_", " ")
end
local function buildActivity()
local details, activityState
if state.activity == "battle" then
details = state.battleLabel or "In battle"
activityState = state.location and (state.location) or "Kanto"
elseif state.activity == "exploring" and state.location then
details = state.location
activityState = "Exploring the overworld"
else
details = state.location or "Kanto"
activityState = "At the title screen"
end
return {
details = details,
state = activityState,
timestamps = { start = state.startedAt },
assets = {
large_image = "logo",
large_text = "Pokemon Gen1Recomp",
},
}
end
local function activityKey(activity)
return (activity.details or "") .. "|" .. (activity.state or "")
end
local function flush(force)
if not state.enabled or not state.connected then return end
local ok = pcall(function()
local now = love.timer.getTime()
if not force and (now - state.lastSendAt) < MIN_UPDATE_INTERVAL then
state.dirty = true
return
end
local activity = buildActivity()
local key = activityKey(activity)
if not force and key == state.lastSentKey then
state.dirty = false
return
end
local payload = Json.encode({
cmd = "SET_ACTIVITY",
args = {
pid = resolvePid(),
activity = activity,
},
nonce = nonce(),
})
if not sendFrame(OP_FRAME, payload) then
closeSocket()
state.nextReconnectAt = now + RECONNECT_INTERVAL
return
end
state.lastSentKey = key
state.lastSendAt = now
state.dirty = false
end)
if not ok then
closeSocket()
local now = love and love.timer and love.timer.getTime and love.timer.getTime() or 0
state.nextReconnectAt = now + RECONNECT_INTERVAL
end
end
local function setPresence(fields)
-- event listeners must never throw into the overworld/battle path
pcall(function()
if fields.location ~= nil then state.location = fields.location end
if fields.mapId ~= nil then state.mapId = fields.mapId end
if fields.activity ~= nil then state.activity = fields.activity end
if fields.clearBattle then
state.battleLabel = nil
elseif fields.battleLabel ~= nil then
state.battleLabel = fields.battleLabel
end
state.dirty = true
flush(false)
end)
end
local function subscribe(game)
local events = Runtime.events
if not events or not events.on then return end
local function track(name, fn)
state.unsubs[#state.unsubs + 1] = events:on(name, fn, 0, "discord")
end
track("map.entered", function(ev)
local mapId = ev and ev.mapId
setPresence({
mapId = mapId,
location = locationName(game, mapId),
activity = "exploring",
clearBattle = true,
})
end)
track("battle.started", function(ev)
local battle = ev and ev.battle
local label = "In battle"
if ev and ev.kind == "wild" then
local name = speciesName(game, ev.species)
if name then
label = "Battling wild " .. name
if ev.level then label = label .. " Lv" .. tostring(ev.level) end
else
label = "Wild battle"
end
elseif ev and ev.kind == "trainer" then
local tname = battle and battle.trainer and battle.trainer.name
label = tname and ("Battling " .. tname) or "Trainer battle"
elseif ev and ev.kind == "link" then
label = "Link battle"
end
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
setPresence({
activity = "battle",
battleLabel = label,
location = locationName(game, mapId) or state.location,
mapId = mapId,
})
end)
track("battle.ended", function()
local mapId = state.mapId
or (game.save and game.save.player and game.save.player.map)
setPresence({
activity = mapId and "exploring" or "menu",
clearBattle = true,
location = locationName(game, mapId) or state.location,
mapId = mapId,
})
end)
track("screen.pushed", function(ev)
local s = ev and ev.state
local id = s and s.screenId
if not id and s and s.onNewGame then id = "TitleState" end
if id == "TitleState" or id == "IntroMovie" then
state.mapId = nil
setPresence({
activity = "menu",
location = id == "IntroMovie" and "Watching the intro" or "Title screen",
clearBattle = true,
})
end
end)
end
function DiscordPresence.init(game)
local ok, err = pcall(function()
DiscordPresence.shutdown()
if disabledByEnv() or not isDesktop() then return end
state.enabled = true
state.isWindows = love.system.getOS() == "Windows"
state.startedAt = os.time()
state.activity = "menu"
state.location = "Title screen"
state.mapId = nil
state.battleLabel = nil
state.dirty = true
state.nextReconnectAt = 0
state.loggedAbsent = false
if not state.isWindows and not ensureFfi() then
-- no FFI / broken cdef: stay disabled, never throw
state.enabled = false
return
end
subscribe(game)
if connect() then
flush(true)
else
noteAbsent()
local now = (love.timer and love.timer.getTime and love.timer.getTime()) or 0
state.nextReconnectAt = now + RECONNECT_INTERVAL
end
end)
if not ok then
closeSocket()
state.enabled = false
-- still soft: a broken presence stack must not abort boot
Logger.info("discord: rich presence disabled (%s)", tostring(err))
end
end
function DiscordPresence.update(_dt)
if not state.enabled then return end
pcall(function()
local now = love.timer.getTime()
if not state.connected then
if now >= state.nextReconnectAt then
state.nextReconnectAt = now + RECONNECT_INTERVAL
if connect() then
flush(true)
else
noteAbsent()
end
end
return
end
if state.dirty then flush(false) end
end)
end
function DiscordPresence.shutdown()
pcall(function()
for _, unsub in ipairs(state.unsubs) do
pcall(unsub)
end
state.unsubs = {}
if state.connected then
pcall(function()
-- omit activity to clear the presence
sendFrame(OP_FRAME, Json.encode({
cmd = "SET_ACTIVITY",
args = { pid = resolvePid() },
nonce = nonce(),
}))
sendFrame(OP_CLOSE, "{}")
end)
end
closeSocket()
end)
state.enabled = false
state.dirty = false
state.lastSentKey = nil
state.connected = false
state.socket = nil
end
-- test / debug helpers
DiscordPresence._state = state
DiscordPresence.locationName = locationName
return DiscordPresence
+8 -1
View File
@@ -12,8 +12,15 @@ function FixedStep:init(callback)
self.callback = callback
end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
-- steps), which silently throttled the high fast-forward levels: at 100X
-- a 60fps frame wants ~100 steps. Game:update raises this to fit the
-- current speed target; a stall still cannot snowball past one frame's
-- intended budget.
FixedStep.maxAccum = MAX_ACCUM
function FixedStep:update(dt)
self.accum = math.min(self.accum + dt, MAX_ACCUM)
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do
self.accum = self.accum - self.STEP
self.callback(self.STEP)
+14 -2
View File
@@ -68,6 +68,10 @@ function Game:load()
local OverworldState = require("src.world.OverworldController")
self.overworld = OverworldState
-- Discord Rich Presence: map name / battle status on the player's profile.
-- Soft-fail: missing Discord / IPC errors must never block boot.
pcall(function() require("src.core.DiscordPresence").init(self) end)
-- every service is up but nothing is on the stack yet; this payload is
-- the sanctioned way for a mod to obtain the Game object
ModRuntime.emit("game.ready", { game = self })
@@ -103,7 +107,7 @@ end
function Game:makeTitleState()
local OverworldState = require("src.world.OverworldController")
local factory = Screens.get(self, bootScreens(self).title or "TitleState")
return factory.new(self, {
local title = factory.new(self, {
onNewGame = function()
while self.stack:top() do self.stack:pop() end
-- New Game keeps the standalone options.lua preferences
@@ -126,6 +130,8 @@ function Game:makeTitleState()
end
end,
})
title.screenId = title.screenId or "TitleState"
return title
end
-- QUIT from the START menu: back to the title like a power-cycle,
@@ -170,7 +176,11 @@ function Game:update(dt)
-- deferred A and edge pulses land in Input's press queue for this step.
TouchInput:update(dt)
-- Fast-forward scales only the logic clock (see src/core/GameSpeed.lua).
FixedStep:update(dt * self:logicSpeed())
-- Give the accumulator room for one full frame at the current speed,
-- or the anti-spiral clamp quietly caps every level above ~15X.
local speed = self:logicSpeed()
FixedStep.maxAccum = math.max(0.25, speed * FixedStep.STEP * 1.5)
FixedStep:update(dt * speed)
-- Audio runs off real time at a fixed 60Hz regardless of game speed or
-- display refresh, so fades and chip synthesis keep their intended tempo
-- whether we are at 1X, 10X, or running with vsync disabled.
@@ -183,6 +193,7 @@ function Game:update(dt)
-- Overworld tilt toggle tween: presentational, so it runs on the real
-- frame dt (not the fixed logic step) for a smooth ~0.25s glide.
require("src.render.Tilt").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
end
-- render.zones' identity default: unhooked, the zone list reaches the blit
@@ -387,6 +398,7 @@ function Game:applyOptions(opts)
require("src.render.PaletteFX").applyOptions(opts)
require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts)
end
function Game:restoreSave(loaded, recovered)
+1 -1
View File
@@ -18,7 +18,7 @@ local GameSpeed = {}
-- attempt is long enough that the iteration loop, not the engine, is the
-- bottleneck. Vsync caps how much a real frame can do, so past 10X the
-- multiplier is increasingly a ceiling rather than a rate.
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75 }
GameSpeed.LEVELS = { 1, 2, 4, 10, 20, 30, 50, 75, 100,200 }
GameSpeed.DEFAULT = 1
function GameSpeed.levelLabel(v)
+2
View File
@@ -47,6 +47,8 @@ function SaveData.defaultOptions()
colors = "gbc",
tilt = 0,
gbcfx = 0,
-- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed",
-- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default.
mods = {},
+54
View File
@@ -0,0 +1,54 @@
-- Windowed vs borderless (desktop) fullscreen.
--
-- Persisted as save.options.videoMode. Applied from OptionsMenu and on
-- boot via Game:applyOptions. No-ops on mobile and in headless stubs
-- that lack love.window.
local VideoMode = {}
VideoMode.MODES = { "windowed", "borderless" }
VideoMode.DEFAULT = "windowed"
local LABELS = {
windowed = "WINDOWED",
borderless = "BORDERLESS",
}
function VideoMode.normalize(mode)
if mode == "borderless" then return "borderless" end
return VideoMode.DEFAULT
end
function VideoMode.modeLabel(mode)
return LABELS[VideoMode.normalize(mode)] or LABELS[VideoMode.DEFAULT]
end
function VideoMode.isMobile()
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "Android" or osName == "iOS"
end
-- Cycle windowed <-> borderless (dir ignored; two modes).
function VideoMode.cycle(mode, _dir)
return VideoMode.normalize(mode) == "borderless" and "windowed" or "borderless"
end
-- Push the mode into the live window. Safe when love.window is missing.
function VideoMode.apply(mode)
if VideoMode.isMobile() then return end
if not love or not love.window or not love.window.setFullscreen then return end
mode = VideoMode.normalize(mode)
if mode == "borderless" then
-- "desktop" = borderless fullscreen matching the display
love.window.setFullscreen(true, "desktop")
else
love.window.setFullscreen(false)
end
end
function VideoMode.applyOptions(opts)
VideoMode.apply(opts and opts.videoMode)
end
return VideoMode
+5 -4
View File
@@ -86,10 +86,11 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
end
local tint
local pals = data.palettes
if pals then
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local c = pals.palettes[name][3] -- GB color 2 is the fill shade
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
+200 -14
View File
@@ -5,17 +5,20 @@
-- then drawn once per zone through a shader that remaps the four DMG
-- shades to that zone's palette.
--
-- Port display option: COLORS (GBC / OG / OG INV / GBC INV / CLASSIC)
-- Port display option: COLORS (GBC / RED++ / OG / OG INV / GBC INV / CLASSIC)
-- transforms every zone's palette at send time via effectiveColors.
-- RED++ swaps the named-palette pack for pokered-gbc SuperPalettes
-- (data/palettes_gbc.lua), including per-species mon colors.
local PaletteFX = {}
local shader -- false = unavailable (headless / no shader support)
local gbcPack -- false = missing; nil = not loaded yet
-- Cycle order matches OptionsMenu / hotkey 2
PaletteFX.MODES = { "gbc", "og", "og_inv", "gbc_inv", "classic" }
PaletteFX.MODES = { "gbc", "redpp", "og", "og_inv", "gbc_inv", "classic" }
PaletteFX.MODE_LABELS = {
gbc = "GBC", og = "OG", og_inv = "OG INV",
gbc = "GBC", redpp = "RED++", og = "OG", og_inv = "OG INV",
gbc_inv = "GBC INV", classic = "CLASSIC",
}
PaletteFX.mode = "gbc"
@@ -124,22 +127,181 @@ function PaletteFX.whole(colors)
return PaletteFX.zone(colors, 0, 0, 19, 17)
end
-- named palette from data/generated/palettes.lua (nil on stale builds)
-- Red++ / pokered-gbc SuperPalette pack (committed; optional if absent).
function PaletteFX.gbcPack()
if gbcPack == nil then
local ok, pack = pcall(require, "data.palettes_gbc")
gbcPack = ok and pack or false
end
return gbcPack or nil
end
function PaletteFX.usesGbcPack(mode)
mode = mode or PaletteFX.mode
return mode == "redpp"
end
-- Active named-palette table for COLORS: RED++ uses data/palettes_gbc.lua,
-- everything else uses the ROM-imported data.palettes.
function PaletteFX.pack(data)
if PaletteFX.usesGbcPack() then
local g = PaletteFX.gbcPack()
if g then return g end
end
return data and data.palettes or nil
end
-- named palette from the active pack (nil on stale builds / missing name).
-- RED++ falls back to the ROM pack for names the gbc table omits (rare).
function PaletteFX.pal(data, name)
local p = data.palettes
return p and p.palettes[name] or nil
local p = PaletteFX.pack(data)
local c = p and p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
return data.palettes.palettes[name]
end
return nil
end
-- the species' palette (data/pokemon/palettes.asm), MEWMON for unknowns.
-- transformed forces PAL_GRAYMON (Ditto's palette) regardless of species
-- (engine/gfx/palettes.asm DeterminePaletteID: bit TRANSFORMED, a; a
-- Transformed mon's pic is tinted gray, not the copied species' own
-- SGB color).
-- SGB color). RED++ uses per-species pals from mon_palettes.asm.
function PaletteFX.monPal(data, species, transformed)
local p = data.palettes
local p = PaletteFX.pack(data)
if not p then return nil end
if transformed then return p.palettes.GRAYMON end
return p.palettes[p.pokemon[species] or "MEWMON"]
if transformed then
return p.palettes.GRAYMON
or (data and data.palettes and data.palettes.palettes.GRAYMON)
end
local name = p.pokemon[species] or "MEWMON"
local c = p.palettes[name]
if c then return c end
if PaletteFX.usesGbcPack() and data and data.palettes then
name = data.palettes.pokemon[species] or "MEWMON"
return data.palettes.palettes[name]
end
return nil
end
-- palette name a species currently resolves to (for image-cache keys)
function PaletteFX.monPalName(data, species, transformed)
if transformed then return "GRAYMON" end
local p = PaletteFX.pack(data)
if p and p.pokemon[species] then return p.pokemon[species] end
if data and data.palettes and data.palettes.pokemon[species] then
return data.palettes.pokemon[species]
end
return "MEWMON"
end
-- ------- true GBC overworld coloring (color/loadpalettes.asm,
-- color/data/*, color/sprites.asm ColorOverworldSprite) -------------------
--
-- RED++ pairs its named-palette battle/mon colors above with pokered-gbc's
-- real per-tile system: LoadTilesetPalette assigns one of 8 four-color BG
-- palettes to every tile GRAPHIC in a tileset (by tile id, not by map
-- position), and LoadTownPalette swaps just the ROOF slot (index 6) per
-- town/route. `data/palettes_gbc.lua`'s `world` table holds the extracted
-- data (tools/extract/palettes.py extract_gbc_world); these queries are
-- mode-independent (only check the pack exists) so TileRenderer can
-- precompute geometry once regardless of the active COLORS mode -- callers
-- that resolve to actual on-screen COLOR should gate on usesGbcPack()
-- themselves, the same way they already gate other RED++-only behavior.
--
-- LoadTilesetPalette's 3 hardcoded single-tile fixes (Celadon Mart) and
-- LoadTownPalette's Route 6/Saffron y<2 roof split are control flow, not
-- data, so they are not in the extracted pack -- they live here instead.
local TILE_GROUP_EXCEPTIONS = {
-- tile ids $4b-$4f -> BLUE (outside sky, seen through the mart's roof)
CELADON_MART_ROOF = { tiles = { [0x4b] = true, [0x4c] = true, [0x4d] = true,
[0x4e] = true, [0x4f] = true }, group = 3 },
-- tile $37 -> BROWN (counter miscoloration fix)
CELADON_MART_3F = { tiles = { [0x37] = true }, group = 5 },
-- tiles $07/$08/$17/$18 -> YELLOW (bench, blue by default)
CELADON_MART_1F = { tiles = { [0x07] = true, [0x08] = true,
[0x17] = true, [0x18] = true }, group = 4 },
}
local ROOF_GROUP = 6
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
-- whether the extracted pack has real per-tile GBC data for this tileset
-- (false for a mod tileset with no pokered-gbc counterpart, or when the
-- pack failed to load at all)
function PaletteFX.hasWorldTileset(tileset)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
return (w and w.tileGroups[tileset]) ~= nil
end
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
-- with the current map's tile-id exceptions (if any) applied first
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local groups = w and w.tileGroups[tileset]
if not groups then return nil end
local exc = TILE_GROUP_EXCEPTIONS[mapId]
if exc and exc.tiles[tileId] then return exc.group end
return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus)
end
-- this tileset's resolved 8-entry {r,g,b}x4 palette array, with the ROOF
-- slot swapped to the current town/route (Route 6's north end uses
-- Saffron's roof colors while the player stands in its top 2 cell rows,
-- like pokered's wYCoord check -- data is Game.data, for the map lookup)
function PaletteFX.worldGroupColors(data, tileset, mapId, playerCellY)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local base = w and w.groupColors[tileset]
if not base then return nil end
if not w.roofGroup[tileset] then return base end
local roofMapId = mapId
if mapId == ROUTE_6_SAFFRON.mapId and playerCellY
and playerCellY < ROUTE_6_SAFFRON.cellYBelow then
roofMapId = ROUTE_6_SAFFRON.useMapId
end
local roofMap = data and data.maps and data.maps[roofMapId]
local roof = roofMap and w.roofByMapIndex[roofMap.index]
if not roof then return base end
local out = {}
for i = 1, 8 do out[i] = base[i] end
-- LoadTownPalette only overwrites W2_BgPaletteData + $32, i.e. colors 1
-- and 2 (0-indexed) of the 4-color ROOF slot -- color 0 (background,
-- typically the sky-through-gaps white) and color 3 (outline black) keep
-- the tileset's own OUTDOOR_ROOF/INDOOR_ROOF base, only the roof
-- material's 2 middle shades are town-specific
local base4 = base[ROOF_GROUP + 1]
out[ROOF_GROUP + 1] = { base4[1], roof[1], roof[2], base4[4] }
return out
end
-- an overworld sprite's resolved 4-color OBJ palette (ColorOverworldSprite),
-- or nil when unassigned/unavailable, plus the resolved group index (for
-- callers that want a stable cache key without hashing the colors table).
-- spriteDef carries the ROM picture-id crosswalk in its `source` field
-- ("ROM:SpriteSheetPointerTable[N]"); seed (any stable per-instance value,
-- e.g. an NPC's `id`) resolves the "random" sentinel -- a deliberate
-- approximation of ColorOverworldSprite's per-OAM-slot pseudo-random pick
-- (`swap a; and 3` on the sprite's OAM offset, which has no equivalent
-- here): a stable hash instead, so the same NPC instance always shows the
-- same one of the 4 SPR_PAL_* colors.
function PaletteFX.spriteObp(spriteDef, seed)
local pack = PaletteFX.gbcPack()
local w = pack and pack.world
local src = spriteDef and spriteDef.source
if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]"))
local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end
if group == "random" then
local h = 0
seed = tostring(seed or "")
for i = 1, #seed do h = (h * 31 + seed:byte(i)) % 4294967296 end
group = h % 4
end
return w.spritePalettes[group], group
end
-- GetHealthBarColor (home/palettes.asm) on the standard 48px bar
@@ -173,13 +335,35 @@ function PaletteFX.permute(colors, map)
end
function PaletteFX.setMode(mode)
local prev = PaletteFX.mode
local ok = false
for _, m in ipairs(PaletteFX.MODES) do
if m == mode then
PaletteFX.mode = mode
return
ok = true
break
end
end
PaletteFX.mode = "gbc"
if not ok then PaletteFX.mode = "gbc" end
-- battle pics and overworld sprites bake the active pack into ImageData;
-- drop those caches when the pack (or any COLORS mode) changes so the
-- next draw re-tints
if prev ~= PaletteFX.mode then
pcall(function() require("src.battle.BattleState").invalidate() end)
pcall(function() require("src.render.SpriteRenderer").invalidate() end)
-- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once
-- per loaded map, so a mode toggle needs every cached Map/TileRenderer
-- dropped and the currently-visible one rebuilt in place -- otherwise
-- the on-screen map keeps its stale (wrong-mode) atlas until the next
-- map transition happens to reload it.
pcall(function()
require("src.world.MapLoader").invalidateAll()
local Game = require("src.core.Game")
if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then
Game.overworld:reloadMap(Game.overworld.map.id, "colors")
end
end)
end
end
function PaletteFX.cycleMode()
@@ -188,7 +372,7 @@ function PaletteFX.cycleMode()
for i, m in ipairs(PaletteFX.MODES) do
if m == cur then idx = i; break end
end
PaletteFX.mode = PaletteFX.MODES[idx % #PaletteFX.MODES + 1]
PaletteFX.setMode(PaletteFX.MODES[idx % #PaletteFX.MODES + 1])
return PaletteFX.mode
end
@@ -202,7 +386,7 @@ end
-- When a state exposes no SGB zones but COLORS needs a forced palette
-- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap
-- shader still runs. GBC / GBC INV leave nil alone (raw DMG canvas).
-- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas).
function PaletteFX.ensureZones(zones)
if zones and zones[1] then return zones end
local mode = PaletteFX.mode or "gbc"
@@ -213,6 +397,8 @@ function PaletteFX.ensureZones(zones)
end
-- Transform a 4-color palette for the active COLORS display mode.
-- GBC and RED++ pass the zone colors through (RED++ already swapped the
-- pack in pal/monPal); OG* / CLASSIC replace; GBC INV permutes shades.
function PaletteFX.effectiveColors(c)
if not c then return nil end
local mode = PaletteFX.mode or "gbc"
+60 -5
View File
@@ -18,10 +18,49 @@ local function getImage(path)
return imageCache[path]
end
-- RED++ overworld sprite OBJ-palette recolor (color/sprites.asm
-- ColorOverworldSprite), baked into an ImageData like BattleState's mon-pic
-- palette bake (src/battle/BattleState.lua getImage): CPU-remap the 4 DMG
-- shades to the resolved OBP colors, cached per (image path, group).
--
-- Sprite sheets carry no real alpha (every pixel, including the
-- background, is opaque -- confirmed by sampling the extracted PNGs): the
-- "transparent" look in every other draw path is a coincidence of the
-- whole-canvas shade-remap shader, where shade 0 (white) happens to map to
-- a similarly light color in whatever terrain zone the sprite stands over.
-- That coincidence breaks once terrain is colored per-tile instead of one
-- flat color per map (different tiles can have very different color-0s),
-- so shade 0 is keyed to alpha 0 here explicitly -- matching real GBC OBJ
-- hardware, where sprite palette index 0 is unconditionally transparent
-- (same rule TileRenderer's getColor0KeyShader documents for tall grass).
local obpCache = {}
local function getObpImage(path, colors, group)
local key = path .. "#obp" .. group
if not obpCache[key] then
local img
if love.image and love.image.newImageData then
local id = Assets.imageData(path)
id:mapPixel(function(_, _, r, g, b, a)
if a == 0 then return r, g, b, a end
if r > 0.83 then return r, g, b, 0 end -- OBJ color 0: always transparent
local col = r > 0.5 and colors[2] or r > 0.17 and colors[3] or colors[4]
return col[1] / 255, col[2] / 255, col[3] / 255, a
end)
img = love.graphics.newImage(id)
else
img = getImage(path) -- headless stub: no pixel access
end
obpCache[key] = img
end
return obpCache[key]
end
-- hot reload drops the sheets; live instances hold their own image, so
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
function SpriteRenderer.invalidate()
imageCache = {}
obpCache = {}
end
Assets.register(SpriteRenderer.invalidate)
@@ -29,9 +68,12 @@ Assets.register(SpriteRenderer.invalidate)
local STAND = { down = 0, up = 1, left = 2, right = 2 }
local WALK = { down = 3, up = 4, left = 5, right = 5 }
function SpriteRenderer.new(spriteDef)
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
function SpriteRenderer.new(spriteDef, seed)
local self = setmetatable({}, SpriteRenderer)
self.def = spriteDef
self.seed = seed
self.image = getImage(spriteDef.image)
local iw, ih = self.image:getDimensions()
self.frames = {}
@@ -46,13 +88,26 @@ end
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
local x = math.floor(px - camX)
local y = math.floor(py - camY) - 4
local image = self.image
-- full-color art claims its 16x16 cell out of the shade-remap pass
if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 16) end
if self.def.trueColor then
PaletteFX.markTrueColor(x, y, 16, 16)
elseif PaletteFX.usesGbcPack() then
-- RED++: the world canvas is already true-color (TileRenderer bakes
-- terrain, this bakes the sprite) and the world pass runs unshaded
-- (OverworldState.sgbWorldZones), so this draws like any normal sprite
-- -- opaque character pixels over a real-alpha-transparent background,
-- no trueColor rect needed (there is no shader left to exempt it from).
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then
image = getObpImage(self.def.image, colors, group)
end
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames
if self.def.frames <= 1 then
love.graphics.draw(self.image, self.frames[0], x, y)
love.graphics.draw(image, self.frames[0], x, y)
return
end
local frame = (self.def.walker and walkPhase == 1)
@@ -65,9 +120,9 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
end
local quad = self.frames[frame] or self.frames[0]
if flip then
love.graphics.draw(self.image, quad, x + 16, y, 0, -1, 1)
love.graphics.draw(image, quad, x + 16, y, 0, -1, 1)
else
love.graphics.draw(self.image, quad, x, y)
love.graphics.draw(image, quad, x, y)
end
end
+133 -12
View File
@@ -108,10 +108,23 @@ end
-- the water/flower branches did before they were data.
-- ------------------------------------------------------------------
-- the 8 shifted variants of one tile (built once per sheet + tile id)
-- shade 0-3 -> one of `colors`' 4 entries (same cutoffs PaletteFX's shader
-- uses), alpha passed through unchanged; nil colors leaves r,g,b as-is.
-- Shared by the whole-atlas bake (getGbcAtlas) and the animated-tile
-- variants below, so water/flowers/spinners match the static tiles around
-- them under RED++ instead of showing their un-recolored grayscale.
local function recolorSample(r, g, b, a, colors)
if not (colors and a > 0) then return r, g, b, a end
local col = r > 0.83 and colors[1] or r > 0.5 and colors[2]
or r > 0.17 and colors[3] or colors[4]
return col[1] / 255, col[2] / 255, col[3] / 255, a
end
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
local shiftVariants = {}
local function getShiftVariants(tilesetImagePath, perRow, tile)
local key = tilesetImagePath .. "#" .. tile
local function getShiftVariants(tilesetImagePath, perRow, tile, colors, gbcKey)
local key = tilesetImagePath .. "#" .. tile .. (gbcKey or "")
if shiftVariants[key] ~= nil then return shiftVariants[key] end
if not (love.image and love.image.newImageData) then
shiftVariants[key] = false
@@ -126,6 +139,7 @@ local function getShiftVariants(tilesetImagePath, perRow, tile)
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = id:getPixel(sx + x, sy + y)
r, g, b, a = recolorSample(r, g, b, a, colors)
v:setPixel((x + o) % 8, y, r, g, b, a)
end
end
@@ -136,12 +150,27 @@ local function getShiftVariants(tilesetImagePath, perRow, tile)
end
local frameImages = {}
local function getFrameImages(paths)
local key = table.concat(paths, "|")
local function getFrameImages(paths, colors, gbcKey)
local key = table.concat(paths, "|") .. (gbcKey or "")
if frameImages[key] ~= nil then return frameImages[key] end
local out = {}
for i, path in ipairs(paths) do
local ok, img = pcall(getImage, path)
local ok, img = pcall(function()
if not (colors and love.image and love.image.newImageData) then
return getImage(path)
end
local id = Assets.imageData(path)
local w, h = id:getDimensions()
local out2 = love.image.newImageData(w, h)
for y = 0, h - 1 do
for x = 0, w - 1 do
local r, g, b, a = id:getPixel(x, y)
r, g, b, a = recolorSample(r, g, b, a, colors)
out2:setPixel(x, y, r, g, b, a)
end
end
return love.graphics.newImage(out2)
end)
if not ok then
frameImages[key] = false
return false
@@ -237,17 +266,33 @@ end
-- one entry's runtime form: the tile ids it claims, the textures a step
-- picks from, and either a step sequence (hshift/frames) or a gate
-- (toggle). nil when the entry's pixels could not be built.
local function buildAnim(spec, tilesetImagePath, perRow, quads)
--
-- gbc, when present (RED++ with a baked atlas -- see getGbcAtlas), recolors
-- hshift/frames entries (water/flowers) the same way the atlas bakes their
-- static tile, so they match their surroundings instead of showing raw
-- grayscale over an otherwise fully-colored map. The "toggle" kind
-- (spinner puzzle blur, gfx/overworld/spinners.png) is a whole-atlas clone
-- built from the ORIGINAL grayscale atlas, not worth recoloring for a rare,
-- gameplay-gated blur -- it is skipped under gbc, same as the buildAnim
-- caller already does for a texture-build failure (the static, correctly-
-- colored tile shows through unanimated).
local function buildAnim(spec, tilesetImagePath, perRow, quads, gbc)
local tiles = spec.tiles
if not tiles then
if spec.tile == nil then return nil end
tiles = { spec.tile }
end
local period = spec.period or ANIM_PERIOD
local colors
if gbc then
local group = PaletteFX.worldGroupAt(gbc.tilesetId, gbc.mapId, tiles[1])
colors = group and gbc.groupColors[group + 1]
end
if spec.kind == "hshift" then
local offsets = spec.offsets
if not offsets or #offsets == 0 then return nil end
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1])
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1],
colors, gbc and gbc.key)
if not textures then return nil end
local sequence = {}
for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end
@@ -256,11 +301,12 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads)
elseif spec.kind == "frames" then
local sequence = spec.sequence
if not (spec.images and sequence and #sequence > 0) then return nil end
local textures = getFrameImages(spec.images)
local textures = getFrameImages(spec.images, colors, gbc and gbc.key)
if not textures then return nil end
return { tiles = tiles, textures = textures, sequence = sequence,
period = period }
elseif spec.kind == "toggle" then
if gbc then return nil end
local image = getToggleImage(spec, tilesetImagePath, perRow)
if not image then return nil end
-- the patch texture is a whole-atlas clone, so each cell needs the
@@ -271,10 +317,82 @@ local function buildAnim(spec, tilesetImagePath, perRow, quads)
return nil
end
function TileRenderer.new(map)
-- True GBC overworld coloring (COLORS=RED++): recolor the WHOLE tileset
-- atlas once, per (tileset image, map), rather than trying to retrofit the
-- SGB zone/shade-remap-shader post-process (built for a handful of coarse
-- screen regions) into per-tile precision -- pokered-gbc's real model is
-- "one of 8 four-color BG palettes baked per tile GRAPHIC"
-- (color/loadpalettes.asm LoadTilesetPalette), which is exactly a
-- recolored atlas, not a shader pass. Every existing draw path (batches,
-- quads, border fill) then just works unmodified, with no shader at
-- draw time; OverworldState.sgbWorldZones skips the shade-remap zone pass
-- entirely when this is active (re-running it over already-true-color
-- pixels would corrupt them), and SpriteRenderer's own OBP bake composites
-- on top with ordinary alpha blending -- no trueColor exemption needed,
-- because there is no shader left for it to be exempted from.
--
-- Only the ROOF group (index 6, OVERWORLD/PLATEAU only) varies by town
-- (LoadTownPalette); Route 6's mid-map Saffron-roof y<2 split is not
-- reproduced (it would need a rebuild on crossing the boundary for two
-- tile-rows of one route -- not worth the complexity), so it bakes with
-- the route's own default roof (Vermilion's) throughout.
local gbcAtlasCache = {}
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data)
local key = imagePath .. "#gbc:" .. mapId
if gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
local img = false
if love.image and love.image.newImageData then
local groupColors = PaletteFX.worldGroupColors(data, tilesetId, mapId, nil)
if groupColors then
local src = Assets.imageData(imagePath)
local iw, ih = src:getDimensions()
local out = love.image.newImageData(iw, ih)
local tileColors = {}
for t = 0, (iw / 8) * (ih / 8) - 1 do
local colors = tileColors[t]
if colors == nil then
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t)
colors = (group and groupColors[group + 1]) or false
tileColors[t] = colors
end
local ox, oy = (t % perRow) * 8, math.floor(t / perRow) * 8
for py = 0, 7 do
for px = 0, 7 do
local sx, sy = ox + px, oy + py
local r, g, b, a = src:getPixel(sx, sy)
r, g, b, a = recolorSample(r, g, b, a, colors)
out:setPixel(sx, sy, r, g, b, a)
end
end
end
img = love.graphics.newImage(out)
end
end
gbcAtlasCache[key] = img
return img or nil
end
-- data: Game.data (threaded through explicitly, not required lazily, so
-- headless tests that build a map from a plain local table still work)
function TileRenderer.new(map, data)
local self = setmetatable({}, TileRenderer)
self.map = map
self.data = data
self.image = getImage(map.tileset.image)
local gbcCtx
if data and PaletteFX.usesGbcPack() and PaletteFX.hasWorldTileset(map.tileset.id) then
local gbc = getGbcAtlas(map.tileset.image, map.tileset.id, map.id,
map.tileset.tilesPerRow, data)
if gbc then
self.image = gbc
self.gbcAtlas = true
-- also recolors the animated water/flower entries below, so they
-- match the atlas's static tiles instead of showing raw grayscale
gbcCtx = { tilesetId = map.tileset.id, mapId = map.id, key = "#gbc:" .. map.id,
groupColors = PaletteFX.worldGroupColors(data, map.tileset.id, map.id, nil) }
end
end
-- a full-color atlas colors everything it paints, ring and border fill
-- included, so every draw entry point claims its rect out of the pass
self.trueColor = map.tileset.trueColor or nil
@@ -301,7 +419,7 @@ function TileRenderer.new(map)
local declared = map.tileset.animatedTiles
or TileRenderer.defaultAnimatedTiles(map.tileset)
for _, spec in ipairs(declared) do
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads)
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads, gbcCtx)
if anim then
anim.cells = {}
anims[#anims + 1] = anim
@@ -487,7 +605,10 @@ end
-- rebuild after a block change (Cut trees)
function TileRenderer:rebuild()
local fresh = TileRenderer.new(self.map)
local fresh = TileRenderer.new(self.map, self.data)
self.image = fresh.image
self.gbcAtlas = fresh.gbcAtlas
self.quads = fresh.quads
self.ringBatch = fresh.ringBatch
self.mapBatch = fresh.mapBatch
self.anims = fresh.anims
+1 -1
View File
@@ -225,7 +225,7 @@ function Commands.start_battle(ctx, kind, a, b)
ctx.lastBattleResult = result
ctx.lastCheck = result == "win"
if ctx.overworld then
ctx.overworld:afterBattle(result)
ctx.overworld:afterBattle(result, battle)
end
runner:resume()
end
+12 -1
View File
@@ -3,7 +3,7 @@
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
-- quirks), plus the port's audio rows and display rows: music/SFX
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
-- GBC FX, and the MODS row that opens the mod manager.
-- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager.
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
-- add their own; CANCEL is appended after the hook and stays fixed on the
-- bottom line like pokered's.
@@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX")
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows")
@@ -191,6 +192,16 @@ local function buildRows(game)
GBCFX.setLevel(o.gbcfx)
return true
end },
{ id = "videoMode", label = "VIDEO MODE",
value = function(g)
return VideoMode.modeLabel(g.save.options.videoMode)
end,
step = function(g, dir)
local o = g.save.options
o.videoMode = VideoMode.cycle(o.videoMode, dir)
VideoMode.apply(o.videoMode)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = "GAME SPEED",
+6 -1
View File
@@ -1,7 +1,7 @@
-- Title screen (engine/movie/title.asm + engine/menus/main_menu.asm):
-- the logo (or a text fallback while the asset is missing), a cycling
-- Pokémon front sprite, the copyright line, and the CONTINUE / NEW GAME
-- / OPTION main menu on START or A.
-- / OPTION / EXIT GAME main menu on START or A.
local Font = require("src.render.Font")
local Music = require("src.core.Music")
@@ -165,6 +165,11 @@ function TitleState:openMenu()
table.insert(items, { label = "OPTION", onSelect = function()
require("src.ui.Screens").push(game, "OptionsMenu")
end })
table.insert(items, { label = "EXIT GAME", onSelect = function()
if love.event and love.event.quit then
love.event.quit()
end
end })
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 13, th = #items * 2 + 2 }))
end
+6
View File
@@ -92,6 +92,12 @@ local LAST_MAP_REWRITES = {
UNDERGROUND_PATH_ROUTE_6 = { rules = { { map = "ROUTE_6" } } },
UNDERGROUND_PATH_ROUTE_7 = { rules = { { map = "ROUTE_7" } } },
UNDERGROUND_PATH_ROUTE_8 = { rules = { { map = "ROUTE_8" } } },
-- DiglettsCaveRoute{2,11}_Script are the same pattern for the cave's two
-- entrance houses. Without these, crossing the cave and taking the far
-- house's LAST_MAP door warps you back to the route you ENTERED from --
-- the whole cave was a one-way trip that always returned to its start.
DIGLETTS_CAVE_ROUTE_2 = { rules = { { map = "ROUTE_2" } } },
DIGLETTS_CAVE_ROUTE_11 = { rules = { { map = "ROUTE_11" } } },
}
FieldDefaults.FIELD = {
+43
View File
@@ -29,6 +29,49 @@ local function hashSet(list, into)
return into
end
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
-- during an edge crossing. pokered's collision check reads the neighbor
-- strip's tile bytes, so a step off the map edge onto a solid tile of
-- the connected map bumps exactly like an in-map wall; the port needs
-- the same read without building the whole Map. Same math as cellTile
-- on the raw blocks, honoring the surf rule (water/shore passable only
-- while surfing, same fallbacks as Map.new).
function Map.defPassable(def, tilesetDef, cx, cy, surfing)
-- Fail closed: a missing tileset used to return true and re-open the
-- Pallet south-shore stranding (cross onto ROUTE_21 solids). No data
-- means we cannot prove the landing is safe, so the step bumps.
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
return false
end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return false end
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
if surfing then
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
if t == tile then return true end
end
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
shore = SHORE_TILES
end
for _, t in ipairs(shore or {}) do
if t == tile then return true end
end
end
return false
end
function Map.new(def, tilesetDef)
local self = setmetatable({}, Map)
self.def = def
+1 -1
View File
@@ -23,7 +23,7 @@ function MapLoader.load(data, mapId)
-- warp tiles are stored per tileset macro name; the generated tilesets
-- module carries them in the tileset entry itself
local map = Map.new(def, tilesetDef)
map.renderer = TileRenderer.new(map)
map.renderer = TileRenderer.new(map, data)
cache[mapId] = map
return map
end
+1 -1
View File
@@ -26,7 +26,7 @@ function NPC.new(data, mapId, objDef)
self.id = string.format("%s_obj_%d", mapId, objDef.index)
local spriteDef = data.sprites[objDef.sprite]
assert(spriteDef, "unknown sprite " .. tostring(objDef.sprite))
self.sprite = SpriteRenderer.new(spriteDef)
self.sprite = SpriteRenderer.new(spriteDef, self.id)
-- object_event coordinates are already walk-grid cells
self.cellX, self.cellY = objDef.x, objDef.y
self.px, self.py = self.cellX * 16, self.cellY * 16
+75 -9
View File
@@ -388,8 +388,25 @@ end
-- which recolored the whole screen per map -- see the survey zoom
-- entry in docs/known-differences.md). Border fill inherits the
-- current map's palette.
--
-- RED++ true overworld coloring does NOT go through this zone/shader
-- system at all: TileRenderer bakes real per-tile GBC colors straight into
-- a recolored tileset atlas (see TileRenderer's gbcAtlas), and
-- SpriteRenderer bakes sprites' OBP colors the same way, so the world
-- canvas is already final RGB by the time this runs. Returning an EMPTY
-- list here (when the current map has that baked atlas) skips the shader
-- entirely -- Renderer:endFrame's blit sees zoneList[1] == nil and falls
-- back to a plain, unshaded draw. Returning plain `nil` would NOT do this:
-- endFrame treats a nil worldZones as "no world-specific zones, reuse the
-- UI pass's zones" (sgbPalettes' whole-screen named-palette zone), which
-- would re-run the DMG shade-remap over already-true-color pixels using
-- an unrelated 4-color palette -- exactly the "colors are wrong" bug this
-- fixes.
function OverworldState:sgbWorldZones()
local PaletteFX = require("src.render.PaletteFX")
if PaletteFX.usesGbcPack() and self.map.renderer and self.map.renderer.gbcAtlas then
return {}
end
local base = PaletteFX.pal(Game.data, self:paletteNameFor(self.map))
if not base then return nil end
local vw, vh = Game.renderer:worldViewSize()
@@ -883,8 +900,7 @@ function OverworldState:checkEdgeExit(dir)
local conn = self.map:connection(COMPASS[dir])
if conn then
self:crossConnection(dir, conn)
return true
return self:crossConnection(dir, conn)
end
return false
end
@@ -898,7 +914,7 @@ function OverworldState:crossConnection(dir, conn)
local dest = Game.data.maps[conn.map]
if not dest then
Logger.warn("connection to unknown map %s", tostring(conn.map))
return
return false
end
local p = self.player
local x, y = p.cellX, p.cellY
@@ -914,6 +930,15 @@ function OverworldState:crossConnection(dir, conn)
end
x = math.max(0, math.min(destW - 1, x))
y = math.max(0, math.min(destH - 1, y))
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
-- stepping off the edge onto a solid tile of the connected map bumps
-- exactly like an in-map wall. Without this read, Pallet's south
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
-- collision tile -- stranding the player on a cell no walk can leave.
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
p.surfing) then
return false
end
self:setMap(conn.map, x, y, p.facing, { seamless = true })
-- place the player one cell before the seam (their old world spot,
-- which the neighbor strip renders identically) and start the step
@@ -931,6 +956,7 @@ function OverworldState:crossConnection(dir, conn)
p.stepFramesCur = Game.save.onBike
and (FieldDefaults.world(Game.data, "bikeStepFrames") or 8)
or (FieldDefaults.world(Game.data, "stepFrames") or 16)
return true
end
-- -------------------------------------------------------------------------
@@ -1022,7 +1048,7 @@ function OverworldState:goFishing(rod)
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
battle:makeSafari(Game.save.safari)
end
battle.onFinish = function(result) self:afterBattle(result) end
battle.onFinish = function(result) self:afterBattle(result, battle) end
self:pushBattle(battle)
end))
end))
@@ -1506,6 +1532,21 @@ function OverworldState:trySurf(fx, fy)
end
function OverworldState:tryCut(fx, fy)
-- UsedCut (engine/overworld/cut.asm) gates on the TILESET before
-- anything else: only OVERWORLD (tree tile $3d) and GYM (plant tile
-- $50) have cuttable anything. Matching raw block ids alone
-- false-positives on every other tileset -- block ids are only
-- meaningful within one tileset, so Route 23 (PLATEAU) had blocks
-- matching a swap's `before`, and applying it wrote a block id that
-- does not exist in PLATEAU's block table: the renderer indexed nil
-- and the game crashed. The same false match is what made the bot
-- chain-cut "ornamental bushes" around Saffron and Celadon.
local ts = self.map.def.tileset
local tile = self.map:cellTile(fx, fy)
if not ((ts == "OVERWORLD" and tile == 0x3d)
or (ts == "GYM" and tile == 0x50)) then
return false
end
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
local block = self.map:blockAt(bx, by)
local swap
@@ -1591,6 +1632,15 @@ function OverworldState:useCutFieldMove()
if not self:partyKnows("CUT") then return "no_badge" end
local fx, fy = self.player:facingCell()
if not self.map:inBounds(fx, fy) then return "nothing" end
-- same tileset/tile gate as tryCut (UsedCut, engine/overworld/cut.asm):
-- a tree BLOCK also contains fence/path cells, and facing those is
-- "nothing to cut" in vanilla
local ts = self.map.def.tileset
local tile = self.map:cellTile(fx, fy)
if not ((ts == "OVERWORLD" and tile == 0x3d)
or (ts == "GYM" and tile == 0x50)) then
return "nothing"
end
local bx, by = math.floor(fx / 2), math.floor(fy / 2)
local block = self.map:blockAt(bx, by)
local swap
@@ -1654,7 +1704,7 @@ function OverworldState:talkTo(npc)
if e == npc then table.remove(self.entities, i) break end
end
end
self:afterBattle(result)
self:afterBattle(result, battle)
unfreeze()
end
self:pushBattle(battle)
@@ -1972,7 +2022,7 @@ function OverworldState:engageTrainer(npc, onDone)
end
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
local after = function()
self:afterBattle(result)
self:afterBattle(result, battle)
if onDone then onDone() end
end
if wonText then
@@ -1981,7 +2031,7 @@ function OverworldState:engageTrainer(npc, onDone)
after()
end
else
self:afterBattle(result)
self:afterBattle(result, battle)
if onDone then onDone() end
end
end
@@ -1990,6 +2040,8 @@ function OverworldState:engageTrainer(npc, onDone)
end
-- Badges/items awarded after specific battles (data/scripts/victories.lua).
-- `deactivate` retires unfought gym/dojo trainers the way the originals'
-- SetEvent / SetEventRange do after the leader victory.
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
local victories = require("data.scripts.victories")
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
@@ -1998,6 +2050,11 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
if Game.save.flags[reward.flag] then return self:runVictoryHook() end
Game.save.flags[reward.flag] = true
end
if reward.deactivate then
for _, flag in ipairs(reward.deactivate) do
Game.save.flags[flag] = true
end
end
local lines = {}
if reward.badge then
Game.save.inventory[reward.badge] = 1
@@ -2348,7 +2405,7 @@ function OverworldState:onStepComplete()
if Game.save.safari and Map.inRegion(self.map.def, "SAFARI", "SAFARI_ZONE") then
battle:makeSafari(Game.save.safari)
end
battle.onFinish = function(result) self:afterBattle(result) end
battle.onFinish = function(result) self:afterBattle(result, battle) end
self:pushBattle(battle)
return
end
@@ -2681,7 +2738,9 @@ function OverworldState:safariGameOver(text)
end
-- Blackouts return to the last heal point; evolutions run after battles.
function OverworldState:afterBattle(result)
-- battle is optional; when given, Oak's Lab OPP_RIVAL1 losses skip the
-- blackout (pret HandlePlayerBlackOut) so the map script can HealParty.
function OverworldState:afterBattle(result, battle)
local lead = Game.save.party[1]
Logger.info("battle over: %s (lead %s %d/%d)", tostring(result),
lead and lead.species or "-", lead and lead.hp or 0,
@@ -2691,6 +2750,13 @@ function OverworldState:afterBattle(result)
Evolution.checkParty(Game)
end
if result == "lose" then
local oaksLabRival = battle and battle.oppClass == "OPP_RIVAL1"
and self.map and self.map.id == "OAKS_LAB"
if oaksLabRival then
-- stay in the lab; OaksLabRivalEndBattleScript heals and continues
evolutions()
return
end
-- blackout: revive the party at the last heal point; half the
-- money is lost (like the original)
local Pokemon = require("src.pokemon.Pokemon")
+3 -3
View File
@@ -26,12 +26,12 @@ function Player.new(data, cx, cy, facing)
local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk")
local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf")
local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike")
self.sprite = SpriteRenderer.new(data.sprites[walkId])
self.sprite = SpriteRenderer.new(data.sprites[walkId], "player")
if surfId and data.sprites[surfId] then
self.surfSprite = SpriteRenderer.new(data.sprites[surfId])
self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player")
end
if bikeId and data.sprites[bikeId] then
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId])
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player")
end
-- the ledge-hop shadow quarter-tile (gfx/overworld/shadow.png,
-- LedgeHoppingShadow, engine/overworld/ledges.asm)
+1921 -81
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -598,6 +598,23 @@ check(PaletteFX.monPal(palData, "TESTMON") == monPalette,
check(PaletteFX.monPal(palData, "UNKNOWN") == nil,
"an unmapped species falls through to MEWMON (absent here)")
-- RED++ pack: per-species SuperPalettes from data/palettes_gbc.lua
local prevMode = PaletteFX.mode
PaletteFX.setMode("redpp")
check(PaletteFX.usesGbcPack(), "redpp mode selects the gbc pack")
local gbc = PaletteFX.gbcPack()
check(gbc ~= nil and gbc.palettes.BULBASAUR ~= nil,
"data/palettes_gbc.lua ships per-species pals")
check(PaletteFX.monPalName({ palettes = nil }, "BULBASAUR") == "BULBASAUR",
"RED++ monPalName resolves to the species palette id")
check(PaletteFX.monPal({ palettes = nil }, "BULBASAUR") == gbc.palettes.BULBASAUR,
"RED++ monPal reads the species colors without a ROM pack")
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")
PaletteFX.setMode(prevMode)
-- ------- the transitions registry
local transitions = Registry.new("transitions", Schemas.REGISTRIES.transitions)
+3 -3
View File
@@ -205,7 +205,7 @@ end
local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "speed", "mods", "controls" }
"gbcfx", "videoMode", "speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id)
@@ -238,7 +238,7 @@ check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- the MODS row is the manager's discoverable home
local mgGame = optGame()
om = OptionsMenu.new(mgGame)
om.rows[12].activate(mgGame)
om.rows[13].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState",
@@ -248,7 +248,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame()
om = OptionsMenu.new(cbGame)
om.rows[13].activate(cbGame)
om.rows[14].activate(cbGame)
local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list")
+99
View File
@@ -263,4 +263,103 @@ do
package.loaded["src.render.TextBox"] = realTB
end
-- (7) gym / dojo leader victory deactivates unfought non-leader trainers
-- (PewterGym.asm "; deactivate gym trainers" / SetEventRange in the
-- other gyms; FightingDojo.asm SetEventRange through TRAINER_3).
do
local victories = require("data.scripts.victories")
local expected = {
["OPP_BROCK#1"] = { "EVENT_BEAT_PEWTER_GYM_TRAINER_0" },
["OPP_MISTY#1"] = { "EVENT_BEAT_CERULEAN_GYM_TRAINER_0",
"EVENT_BEAT_CERULEAN_GYM_TRAINER_1" },
["OPP_LT_SURGE#1"] = { "EVENT_BEAT_VERMILION_GYM_TRAINER_0",
"EVENT_BEAT_VERMILION_GYM_TRAINER_1",
"EVENT_BEAT_VERMILION_GYM_TRAINER_2" },
["OPP_ERIKA#1"] = 7,
["OPP_KOGA#1"] = 6,
["OPP_SABRINA#1"] = 7,
["OPP_BLAINE#1"] = 7,
["OPP_GIOVANNI#3"] = 8,
["OPP_BLACKBELT#1"] = 4,
}
for key, want in pairs(expected) do
local d = victories[key] and victories[key].deactivate
check(d ~= nil, key .. " lists trainers to deactivate")
if type(want) == "number" then
eq(#d, want, key .. " deactivates " .. want .. " trainers")
else
for i, flag in ipairs(want) do
eq(d[i], flag, key .. " deactivate[" .. i .. "]")
end
end
end
-- Cinnabar trainers have no extracted def_trainers; seeded headers let
-- EVENT_BEAT_CINNABAR_GYM_TRAINER_* satisfy trainerDefeated without
-- stamping defeatedTrainers (which would also open the quiz gates).
Data:seedCinnabarGymTrainerHeaders()
check(Data.trainer_headers.CinnabarGym ~= nil,
"CinnabarGym trainer headers seeded for deactivate flags")
local nerd1 = Data:trainerHeader("CinnabarGym", 2)
check(nerd1 and nerd1.event == "EVENT_BEAT_CINNABAR_GYM_TRAINER_0"
and nerd1.range == 0,
"Cinnabar SUPER_NERD1 header: event + no sight range")
require("src.render.Font").load(Data)
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
local realTB = package.loaded["src.render.TextBox"]
package.loaded["src.render.TextBox"] = {
new = function(game, text, done) return { text = text, onDone = done } end,
}
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
Game.save.inventory = {}
Game.save.defeatedTrainers = {}
Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up")
local ow = Game.stack:top()
ow:checkVictoryRewards("OPP_BROCK", 1)
check(Game.save.flags.EVENT_BEAT_BROCK, "Brock victory sets EVENT_BEAT_BROCK")
check(Game.save.inventory.BOULDERBADGE == 1, "Brock victory awards BOULDERBADGE")
check(Game.save.flags.EVENT_BEAT_PEWTER_GYM_TRAINER_0,
"Brock victory deactivates the Pewter Gym Cooltrainer")
local pewterNpc
for _, npc in ipairs(ow.npcs) do
if npc.def.index == 2 then pewterNpc = npc break end
end
check(pewterNpc and ow:trainerDefeated(pewterNpc),
"unfought Pewter gym trainer is defeated after badge")
while Game.stack:top() do Game.stack:pop() end
Game.save = SaveData.newGame()
Game.save.flags = {}
Game.save.inventory = {}
Game.save.defeatedTrainers = {}
Game.stack:push(OW, "CINNABAR_GYM", 16, 15, "up")
ow = Game.stack:top()
ow:checkVictoryRewards("OPP_BLAINE", 1)
check(Game.save.flags.EVENT_BEAT_CINNABAR_GYM_TRAINER_3,
"Blaine victory deactivates Cinnabar quiz trainers")
check(next(Game.save.defeatedTrainers) == nil,
"Blaine deactivate does not stamp defeatedTrainers (gates stay put)")
local cinnabarNpc
for _, npc in ipairs(ow.npcs) do
if npc.def.index == 5 then cinnabarNpc = npc break end
end
check(cinnabarNpc and ow:trainerDefeated(cinnabarNpc),
"unfought Cinnabar trainer is defeated via seeded header event")
while Game.stack:top() do Game.stack:pop() end
package.loaded["src.render.TextBox"] = realTB
end
S.finish()
+16 -3
View File
@@ -192,21 +192,34 @@ check(actsOn.cut and actsOn.surf and actsOn.strength,
"CUT/SURF/STRENGTH submenu entries appear once the badges are held")
-- ===========================================================================
-- I: CUT from the party menu (CERULEAN_CITY cut tree @18,28, stand @17,28)
-- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14)
-- spans two unwalkable cells: the fence half at (18,28) (tile $50) and the
-- tree itself at (19,28) (tile $3d). UsedCut (engine/overworld/cut.asm)
-- checks the facing TILE, so only the tree cell cuts -- stand above it at
-- (19,27) facing down, the way a vanilla player does.
-- ===========================================================================
Game.save.party = { mkMon("BULBASAUR", "CUT") }
Game.save.inventory = { CASCADEBADGE = true }
ow = pushOW("CERULEAN_CITY", 17, 28, "right")
ow = pushOW("CERULEAN_CITY", 19, 27, "down")
check(ow.map:blockAt(9, 14) == 50, "cut tree block (50) present before CUT")
eq(ow:useCutFieldMove(), "ok", "useCutFieldMove ok when facing a cut tree")
ow.player.facing = "up"
eq(ow:useCutFieldMove(), "nothing", "useCutFieldMove nothing when not facing a tree")
ow.player.facing = "right"
ow.player.facing = "down"
Game.save.inventory.CASCADEBADGE = nil
eq(ow:useCutFieldMove(), "no_badge", "useCutFieldMove no_badge without CASCADEBADGE")
Game.save.inventory.CASCADEBADGE = true
-- the fence half of the same tree block is NOT cuttable (tile $50 on
-- OVERWORLD, not the $3d tree tile)
popToOW()
ow = pushOW("CERULEAN_CITY", 17, 28, "right")
eq(ow:useCutFieldMove(), "nothing",
"useCutFieldMove nothing when facing the tree block's fence cell")
popToOW()
ow = pushOW("CERULEAN_CITY", 19, 27, "down")
-- success path: facing the tree -> _UsedCutText, menu closes, tree replaced
clearCaptured()
local pmCut = PartyMenu.new(Game)
+95
View File
@@ -0,0 +1,95 @@
-- Regression: map-edge crossings must read the NEIGHBOR map's collision.
--
-- Without that read, Pallet Town's south shore land spit (cells x=2,3 at
-- y=17) walked straight onto ROUTE_21 (2,0)/(3,0) -- solid tiles. From
-- (2,0) a further step south lands on walkable (2,1), after which the
-- solid edge cell blocks the only path back to the seam, so the player
-- cannot return north. pokered's CollisionCheckOnLand reads the neighbor
-- strip's tile bytes and bumps; the port must do the same via
-- Map.defPassable before crossConnection commits.
--
-- Self-contained; run via `luajit tests/parity_connection_collision.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 Game = require("src.core.Game")
local Input = require("src.core.Input")
local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity connection collision")
local check, eq = S.check, S.eq
local pallet = MapLoader.load(Data, "PALLET_TOWN")
local route21 = MapLoader.load(Data, "ROUTE_21")
local ts = Data.tilesets[route21.def.tileset]
-- ground truth: the spit is land, the Route 21 landings are solid, and
-- the water column next to it is surf-only
for _, x in ipairs({ 2, 3 }) do
check(pallet:isWalkableCell(x, 17),
("Pallet south shore (%d,17) is walkable land"):format(x))
check(not route21:isWalkableCell(x, 0),
("ROUTE_21 (%d,0) is a solid landing"):format(x))
check(not Map.defPassable(route21.def, ts, x, 0, false),
("defPassable refuses land->solid at ROUTE_21 (%d,0)"):format(x))
end
check(route21:isWaterCell(5, 0), "ROUTE_21 (5,0) is water")
check(not Map.defPassable(route21.def, ts, 5, 0, false),
"water landing without Surf is refused")
check(Map.defPassable(route21.def, ts, 5, 0, true),
"water landing with Surf is allowed")
check(not Map.defPassable(route21.def, nil, 2, 0, false),
"missing tileset fails closed (no permissive fallback)")
-- Live edge: standing on the spit and pressing into the seam must bump,
-- not swap maps. Pallet -> Route 1 north still crosses on grass.
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 2, 17, "down")
local ow = Game.stack:top()
eq(ow.map.id, "PALLET_TOWN", "start on Pallet south shore")
eq(ow.player.cellX, 2, "spit x")
eq(ow.player.cellY, 17, "spit y")
local conn = ow.map:connection("south")
check(conn and conn.map == "ROUTE_21", "Pallet south connects to ROUTE_21")
check(ow:crossConnection("down", conn) == false,
"crossConnection bumps on ROUTE_21 solid landing")
eq(ow.map.id, "PALLET_TOWN", "still on Pallet after the refused cross")
eq(ow.player.cellX, 2, "x unchanged after bump")
eq(ow.player.cellY, 17, "y unchanged after bump")
-- surfing the water column still crosses
ow.player.cellX, ow.player.cellY = 5, 17
ow.player.px, ow.player.py = 5 * 16, 17 * 16
ow.player.surfing = true
ow.player.moving = false
check(ow:crossConnection("down", conn) == true,
"surfing Pallet water still crosses onto ROUTE_21")
eq(ow.map.id, "ROUTE_21", "surf cross lands on ROUTE_21")
-- north seam onto Route 1 grass still works on foot
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "PALLET_TOWN", 10, 0, "up")
ow = Game.stack:top()
local north = ow.map:connection("north")
check(north and north.map == "ROUTE_1", "Pallet north connects to ROUTE_1")
check(ow:crossConnection("up", north) == true,
"Pallet -> Route 1 grass crossing still allowed")
eq(ow.map.id, "ROUTE_1", "north cross lands on ROUTE_1")
S.finish()
+67
View File
@@ -0,0 +1,67 @@
-- Parity: losing the Oak's Lab starter rival must not black out.
-- pret HandlePlayerBlackOut special-cases OPP_RIVAL1 on OAKS_LAB:
-- Rival1WinText only, no PlayerBlackedOutText, no warp / half-money.
-- OaksLabRivalEndBattleScript then HealParty and continues either way.
-- Self-contained; run via `luajit tests/parity_oaks_lab_rival_loss.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local BattleState = require("src.battle.BattleState")
local S = require("tests.harness").suite("parity oaks lab rival loss")
local check, eq = S.check, S.eq
local function saidBlackout(b)
for _, m in ipairs(b.said) do
if tostring(m):find("blacked") then return true end
end
return false
end
local function saidRivalTaunt(b)
for _, m in ipairs(b.said) do
if tostring(m):find("great or what") then return true end
end
return false
end
local function rivalBattle(mapId)
return {
kind = "trainer",
oppClass = "OPP_RIVAL1",
result = nil,
afterQueue = nil,
said = {},
data = {
text = { _Rival1WinText = "{RIVAL}: Yeah! Am\nI great or what?" },
},
game = {
save = {
party = { { species = "SQUIRTLE", hp = 0, stats = { hp = 20 } } },
player = { name = "RED", rival = "BLUE", map = mapId },
},
overworld = { map = { id = mapId } },
},
sayNext = function(self, m) self.said[#self.said + 1] = m end,
say = function(self, m) self.said[#self.said + 1] = m end,
}
end
do
local b = rivalBattle("OAKS_LAB")
BattleState.playerMonFainted(b)
eq(b.result, "lose", "lab rival wipe is still a loss")
check(saidRivalTaunt(b), "lab rival wipe shows Rival1WinText")
check(not saidBlackout(b), "lab rival wipe does not say blacked out")
check(BattleState.isOaksLabStarterRival(b), "oaks-lab starter-rival detector")
end
do
local b = rivalBattle("ROUTE_22")
BattleState.playerMonFainted(b)
eq(b.result, "lose", "route-22 rival wipe is a loss")
check(saidRivalTaunt(b), "route-22 rival still shows Rival1WinText")
check(saidBlackout(b), "route-22 rival still blacks out")
check(not BattleState.isOaksLabStarterRival(b),
"route-22 is not the oaks-lab special case")
end
S.finish()
+83 -9
View File
@@ -38,6 +38,19 @@ check(pallet:isWarpTileCell(5, 5), "Red's house door is a door tile")
local w = pallet:warpAtCell(5, 5)
eq(w.def.destMap, "REDS_HOUSE_1F", "door warp goes to Red's house")
-- Pallet south shore spit: land that faces ROUTE_21 solids. Crossing
-- without reading the neighbor tile stranded players (see
-- tests/parity_connection_collision.lua).
local Map = require("src.world.Map")
local route21Def = Data.maps.ROUTE_21
local route21Tileset = Data.tilesets[route21Def.tileset]
check(pallet:isWalkableCell(2, 17), "Pallet south shore spit (2,17) walkable")
check(pallet:isWalkableCell(3, 17), "Pallet south shore spit (3,17) walkable")
check(not Map.defPassable(route21Def, route21Tileset, 2, 0, false),
"ROUTE_21 (2,0) refuses a land edge cross")
check(not Map.defPassable(route21Def, route21Tileset, 3, 0, false),
"ROUTE_21 (3,0) refuses a land edge cross")
-- signs
local sign = pallet:signAtCell(13, 13)
eq(sign.text, "TEXT_PALLETTOWN_OAKSLAB_SIGN", "Oak's lab sign at (13,13)")
@@ -731,6 +744,9 @@ do
{ map = "UNDERGROUND_PATH_ROUTE_6", route = "ROUTE_6", x = 17, y = 13 },
{ map = "UNDERGROUND_PATH_ROUTE_7", route = "ROUTE_7", x = 5, y = 13 },
{ map = "UNDERGROUND_PATH_ROUTE_8", route = "ROUTE_8", x = 13, y = 3 },
-- DiglettsCaveRoute{2,11}_Script: same pattern for the cave's two ends
{ map = "DIGLETTS_CAVE_ROUTE_2", route = "ROUTE_2", x = 12, y = 9 },
{ map = "DIGLETTS_CAVE_ROUTE_11", route = "ROUTE_11", x = 4, y = 5 },
}
for _, c in ipairs(cases) do
local rewrite = rewrites[c.map]
@@ -851,6 +867,11 @@ do
ab.rng = mkseq({ 255 }) -- the 1/256 miss
ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 })
eq(ab.enemy.mon.status, nil, "THUNDER WAVE misses on the 255 roll")
local function hasAnim(b)
for _, r in ipairs(b.queue) do if r.anim then return true end end
return false
end
check(not hasAnim(ab), "a missed THUNDER WAVE plays no move animation")
ab.rng = mkseq({ 254 })
ab:performMove(ab.player, ab.enemy, { id = "THUNDER_WAVE", pp = 10 })
eq(ab.enemy.mon.status, "PAR", "THUNDER WAVE lands on the 254 roll")
@@ -861,6 +882,33 @@ do
eq(sbst.player.stages.attack, 1, "SHARPEN skips the accuracy roll")
end
-- HandleIfPlayerMoveMissed: skip PlayMoveAnimation on a miss
-- (unless EXPLODE_EFFECT)
do
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
local function hasAnim(b)
for _, r in ipairs(b.queue) do if r.anim then return true end end
return false
end
local function sawMiss(b)
for _, r in ipairs(b.queue) do
if r.text and r.text:find("attack missed!", 1, true) then return true end
end
return false
end
local mb = BattleState.newWild(Game, "RATTATA", 5)
mb.rng = function(a, b) return b end -- accuracy 255: miss
mb:performMove(mb.player, mb.enemy, { id = "TACKLE", pp = 10 })
check(sawMiss(mb), "TACKLE miss prints AttackMissedText")
check(not hasAnim(mb), "a missed TACKLE plays no move animation")
eq(mb.enemy.mon.hp, mb.enemy.mon.stats.hp, "a missed TACKLE deals no damage")
local hb = BattleState.newWild(Game, "RATTATA", 5)
hb.rng = function(a, b) return a end -- hit
hb:performMove(hb.player, hb.enemy, { id = "TACKLE", pp = 10 })
check(hasAnim(hb), "a landing TACKLE still queues its move animation")
end
-- #14: EXP.ALL second pass inherits the participant divisor and skips
-- fainted mons
do
@@ -1097,6 +1145,20 @@ do
tb.trainer.name)),
"trainer defeat uses '<PLAYER> defeated <TRAINER>!'")
-- pret GetTrainerName_: rival classes show wRivalName, not "RIVAL1"
do
local savedRival = Game.save.player.rival
Game.save.player.rival = "GARY"
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) }
local rb = BattleState.newTrainer(Game, "OPP_RIVAL1", 1)
eq(rb.trainer.name, "GARY", "rival battle uses saved rival name")
check(rb.introText:find("GARY", 1, true),
"rival intro wants-to-fight uses rival name")
check(Data.trainers.OPP_RIVAL1.name == "RIVAL1",
"shared trainer data keeps the RIVAL1 placeholder")
Game.save.player.rival = savedRival
end
-- PartyMenu onCancel fires when backing out without a pick
do
local PartyMenu = require("src.ui.PartyMenu")
@@ -1964,8 +2026,8 @@ do
-- option boxes (the port rows plus the MODS/CONTROLS entries) through a 4-box
-- viewport with a $EE ▼ marker; MUSIC VOL / SFX VOL clamp at 0..7 like
-- the text-speed cursor clamps at its ends (.pressedLeftInTextSpeed),
-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX cycle
-- their display modes.
-- MUSIC FILTER cycles OFF/1X/2X/3X, and COLORS / TILT / GBC FX / VIDEO MODE
-- cycle their display modes.
do
local OptionsMenu = require("src.ui.OptionsMenu")
local OInput = require("src.core.Input")
@@ -1973,6 +2035,7 @@ do
local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode")
local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite
SD.saveOptions(SD.defaultOptions())
@@ -1991,6 +2054,8 @@ do
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
eq(og.save.options.tilt, 0, "new saves default TILT to OFF")
eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF")
eq(og.save.options.videoMode, "windowed",
"new saves default VIDEO MODE to WINDOWED")
eq(om.scroll, 0, "options viewport starts at the top")
for _ = 1, 4 do press("down") end
eq(om.index, 5, "cursor reaches MUSIC VOL")
@@ -2030,7 +2095,15 @@ do
for _ = 1, 4 do press("a") end
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
press("down")
eq(om.index, 11, "cursor reaches GAME SPEED")
eq(om.index, 11, "cursor reaches VIDEO MODE")
press("a")
eq(og.save.options.videoMode, "borderless",
"A cycles VIDEO MODE to BORDERLESS")
press("a")
eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED")
press("down")
eq(om.index, 12, "cursor reaches GAME SPEED")
press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a
@@ -2039,25 +2112,26 @@ do
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down")
eq(om.index, 12, "cursor reaches MODS")
eq(om.index, 13, "cursor reaches MODS")
press("down")
eq(om.index, 13, "cursor reaches CONTROLS")
eq(om.index, 14, "cursor reaches CONTROLS")
press("down")
eq(om.index, 14, "CANCEL stays the fixed final row")
eq(om.scroll, 9, "CANCEL keeps the last option boxes on screen")
eq(om.index, 15, "CANCEL stays the fixed final row")
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub
press("a")
check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 14, "up from the top wraps to CANCEL")
eq(om2.scroll, 9, "wrapping to CANCEL scrolls to the tail")
eq(om2.index, 15, "up from the top wraps to CANCEL")
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options)
PaletteFX.applyOptions(og.save.options)
Tilt.applyOptions(og.save.options)
GBCFX.applyOptions(og.save.options)
VideoMode.applyOptions(og.save.options)
end
end
+475 -3
View File
@@ -1,6 +1,6 @@
"""Extract the Super Game Boy colorization palettes.
Sources:
Sources (vanilla pokered):
data/sgb/sgb_palettes.asm SuperPalettes: 4 colors per PAL_* entry,
5-bit RGB (IF DEF(_RED) variants are used;
this is a Red port)
@@ -8,17 +8,60 @@ Sources:
in Pokédex order with species names in the
line comments
Sources (pokered-gbc / Red++ SuperPalettes, optional COLORS mode):
data/super_palettes.asm multi-line RGB + ; 0xNN: PAL_NAME comments;
includes per-species pals under GEN_2_GRAPHICS
data/mon_palettes.asm MonsterPalettes: GEN_2 per-species PAL_* ids
Output: data/generated/palettes.lua
palettes[NAME] = { {r,g,b} x4 } with 8-bit components, color 0 first
pokemon[SPECIES] = NAME (PAL_ prefix stripped)
Output: data/palettes_gbc.lua (committed; not wiped by ROM import)
same shape, plus per-species palette entries (BULBASAUR, ), plus a
`world` table (below) for true overworld tile/roof/sprite coloring.
The HP bar fill is GB color 2 of PAL_GREENBAR / PAL_YELLOWBAR /
PAL_REDBAR; the thresholds live in home/palettes.asm GetHealthBarColor
(>= 27 pixels green, >= 10 yellow, else red).
Sources (pokered-gbc color/**, the real GBC overworld coloring engine):
color/data/map_palette_assignments.asm per tileset: 96 tile-graphic-id
-> palette-group (0-7) bytes
color/data/map_palette_sets.asm per tileset: 8 group -> palette-
constant bytes (positional)
color/data/map_palettes.asm palette-constant -> 4 RGB colors
color/data/roofpalettes.asm per pokered map ID (positional,
matches map.def.index): roof
2-color override + the labeled
RGB blocks it points at
color/data/spritepalettes.asm 8 four-color OBJ palettes
color/sprites.asm SpritePaletteAssignments: 1-based
picture ID -> OBJ palette 0-3, or
the "db 4" random-per-instance
sentinel (ColorOverworldSprite)
Output `world` table shape:
world.tileGroups[TILESET][tileId 0-95] = group index 0-7
world.groupColors[TILESET][group 0-7] = { {r,g,b} x4 }
world.roofGroup[TILESET] = the town-overridable group index (OVERWORLD/
PLATEAU only)
world.roofByMapIndex[mapIndex] = { {r,g,b} x2 } (matches map.def.index)
world.spritePalettes[0-7] = { {r,g,b} x4 }
world.spriteAssignment[pictureIndex 0-based] = group 0-3 or "random"
(pictureIndex matches the N in a sprite def's
`source = "ROM:SpriteSheetPointerTable[N]"`)
The 3 hardcoded Celadon Mart tile-group exceptions and the Route 6/Saffron
roof y-split (LoadTilesetPalette / LoadTownPalette control flow, not data)
are NOT extracted here -- they live as a small named table in
src/render/PaletteFX.lua next to the code that consumes `world`.
"""
import argparse
import os
import re
import sys
from . import util
@@ -28,6 +71,11 @@ def _scale5(v):
return round(int(v) * 255 / 31)
def _rgb8(nums12):
return [[_scale5(nums12[i]), _scale5(nums12[i + 1]), _scale5(nums12[i + 2])]
for i in range(0, 12, 3)]
def _read_raw(path):
"""Raw lines WITH comments (palette / species names live in them)."""
with open(path, encoding="utf-8") as f:
@@ -54,8 +102,7 @@ def extract(pokered, out_dir):
if len(nums) != 12:
util.die(f"sgb_palettes.asm:{lineno}: expected 12 components, got {len(nums)}")
name = m.group(2)
palettes[name] = [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])]
for i in range(0, 12, 3)]
palettes[name] = _rgb8(nums)
order.append(name)
# ---- MonsterPalettes -------------------------------------------------
@@ -91,3 +138,428 @@ def extract(pokered, out_dir):
header="SGB colorization: 4 8-bit RGB colors per palette (color 0\n"
"first) and the per-species palette assignment.")
return palettes, mon_pals
def _parse_gbc_super_palettes(path):
"""pokered-gbc data/super_palettes.asm: ; 0xNN: PAL_NAME then 4x RGB lines."""
palettes = {}
order = []
pending = None
buf = []
for lineno, line in _read_raw(path):
s = line.strip()
m = re.match(r";\s*0x[0-9a-fA-F]+:\s*PAL_(\w+)", s)
if m:
pending = m.group(1)
buf = []
continue
m = re.match(r"RGB\s+([\d,\s]+)", s)
if not m or not pending:
continue
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
buf.extend(nums)
if len(buf) < 12:
continue
if len(buf) != 12:
util.die(f"{path}:{lineno}: expected 12 components for PAL_{pending}, "
f"got {len(buf)}")
palettes[pending] = _rgb8(buf)
order.append(pending)
pending = None
buf = []
if pending:
util.die(f"{path}: incomplete palette PAL_{pending}")
return palettes, order
def _parse_gbc_monster_palettes(path):
"""GEN_2_GRAPHICS MonsterPalettes paired with ELSE-branch species comments."""
lines = _read_raw(path)
# species order from the vanilla ELSE comments (dex order + MISSINGNO)
species_order = []
in_else = False
for _, line in lines:
s = line.strip()
if s.startswith("ELSE"):
in_else = True
continue
if s.startswith("ENDC"):
break
if not in_else:
continue
m = re.match(r"db\s+PAL_\w+\s*;\s*(\w+)", s)
if m:
species_order.append(m.group(1))
gen2_pals = []
in_gen2 = False
for lineno, line in lines:
s = line.strip()
if s.startswith("MonsterPalettes:"):
continue
if s.startswith("IF GEN_2_GRAPHICS"):
in_gen2 = True
continue
if s.startswith("ELSE") or s.startswith("TrainerPalettes:"):
break
if not in_gen2:
continue
m = re.match(r"db\s+PAL_(\w+)", s)
if m:
gen2_pals.append(m.group(1))
if len(species_order) != len(gen2_pals):
util.die(f"{path}: GEN_2 MonsterPalettes ({len(gen2_pals)}) != "
f"ELSE species comments ({len(species_order)})")
mon_pals = {}
for species, pal in zip(species_order, gen2_pals):
if species != "MISSINGNO":
mon_pals[species] = pal
return mon_pals
# ---- color/** overworld attribute data ------------------------------------
#
# Two tiny fixed enums, hand-transcribed rather than parsed: both are
# `EQU`/`const` lists that never change (they are the hardware-shaped slot
# numbering, not game content), and each uses a different const-declaration
# style that isn't worth a bespoke parser for 4-8 entries.
# color/data/map_palette_constants.asm:69-78 (`const_value = 0` block)
_GROUP_NAMES = ["GRAY", "RED", "GREEN", "BLUE", "YELLOW", "BROWN", "ROOF", "TEXT"]
_GROUP_INDEX = {name: i for i, name in enumerate(_GROUP_NAMES)}
# color/sprites.asm:15-18 (`SPR_PAL_* EQU n`)
_SPR_PAL = {"SPR_PAL_ORANGE": 0, "SPR_PAL_BLUE": 1, "SPR_PAL_GREEN": 2, "SPR_PAL_BROWN": 3}
def _rgbN(nums):
return [[_scale5(nums[i]), _scale5(nums[i + 1]), _scale5(nums[i + 2])]
for i in range(0, len(nums), 3)]
def _parse_rgb_table(path, header_re, n_nums):
"""name -> n_nums/3 RGB colors, from blocks of `header_re` + RGB lines
(one or more `RGB r,g,b[,r,g,b...]` lines per block, however they're
wrapped -- matches both the 1-color-per-line and 4-colors-per-line
styles pokered-gbc uses in different files)."""
out = {}
order = []
pending = None
buf = []
for lineno, line in _read_raw(path):
s = line.strip()
m = header_re.match(s)
if m:
if pending:
util.die(f"{path}:{lineno}: incomplete RGB block for "
f"{pending} ({len(buf)}/{n_nums})")
pending = m.group(1)
buf = []
continue
m = re.match(r"RGB\s+([\d,\s]+)", s)
if not m or not pending:
continue
nums = [n for n in re.split(r"[,\s]+", m.group(1).strip()) if n]
buf.extend(nums)
if len(buf) < n_nums:
continue
if len(buf) != n_nums:
util.die(f"{path}:{lineno}: expected {n_nums} components for "
f"{pending}, got {len(buf)}")
out[pending] = _rgbN(buf)
order.append(pending)
pending = None
buf = []
if pending:
util.die(f"{path}: incomplete RGB block for {pending}")
return out, order
_TILESET_HEADER_RE = re.compile(r"^;\s*([A-Z][A-Z0-9_]*)\s*$")
def _parse_map_palette_assignments(path):
"""color/data/map_palette_assignments.asm: per tileset, 96 tile-graphic
palette-group bytes (0-7, symbolic), 16 per line / 6 lines."""
tile_groups = {}
order = []
current = None
vals = []
def flush(lineno):
if len(vals) != 96:
util.die(f"{path}:{lineno}: {current} has {len(vals)} tile "
f"groups (want 96)")
tile_groups[current] = vals[:]
order.append(current)
for lineno, line in _read_raw(path):
s = line.strip()
m = _TILESET_HEADER_RE.match(s)
if m:
if current:
flush(lineno)
current, vals = m.group(1), []
continue
m = re.match(r"db\s+(.*)$", s)
if not m or not current:
continue
for tok in m.group(1).split(","):
tok = tok.strip()
if tok not in _GROUP_INDEX:
util.die(f"{path}:{lineno}: unknown palette group {tok!r}")
vals.append(_GROUP_INDEX[tok])
if current:
flush("EOF")
return tile_groups, order
def _parse_map_palette_sets(path):
"""color/data/map_palette_sets.asm: per tileset, 8 palette-constant
names, positional (index = the palette-group 0-7 above)."""
sets = {}
order = []
current = None
vals = []
def flush(lineno):
if len(vals) != 8:
util.die(f"{path}:{lineno}: {current} has {len(vals)} palette "
f"sets (want 8)")
sets[current] = vals[:]
order.append(current)
for lineno, line in _read_raw(path):
s = line.strip()
m = _TILESET_HEADER_RE.match(s)
if m:
if current:
flush(lineno)
current, vals = m.group(1), []
continue
m = re.match(r"db\s+(\w+)", s)
if m and current:
vals.append(m.group(1))
if current:
flush("EOF")
return sets, order
def _parse_roof_order(path):
"""color/data/roofpalettes.asm's RoofPalettes: dw list, positional by
pokered map ID (matches map.def.index in the recomp project)."""
names = []
in_table = False
for lineno, line in _read_raw(path):
s = line.strip()
if s.startswith("RoofPalettes:"):
in_table = True
continue
if not in_table:
continue
m = re.match(r"dw\s+(\w+)", s)
if m:
names.append(m.group(1))
continue
if s == "":
continue
break # first non-dw, non-blank line (a Label:) ends the table
if not names:
util.die(f"{path}: RoofPalettes table not found or empty")
return names
def _parse_sprite_assignments(path):
"""color/sprites.asm SpritePaletteAssignments: 1-based picture ID (from
the '; 0xNN: SPRITE_NAME' comment) -> OBJ palette 0-3, or "random" for
the "db 4" per-instance sentinel (ColorOverworldSprite). Returned keyed
0-based (pictureId - 1), matching a recomp sprite def's
`source = "ROM:SpriteSheetPointerTable[N]"` index."""
assignments = {}
in_table = False
pending_id = None
header_re = re.compile(r"^;\s*0x([0-9a-fA-F]+):")
for lineno, line in _read_raw(path):
s = line.strip()
if s.startswith("SpritePaletteAssignments:"):
in_table = True
continue
if not in_table:
continue
m = header_re.match(s)
if m:
pending_id = int(m.group(1), 16)
continue
m = re.match(r"db\s+(\w+)", s)
if m and pending_id is not None:
tok = m.group(1)
if tok == "4":
assignments[pending_id - 1] = "random"
elif tok in _SPR_PAL:
assignments[pending_id - 1] = _SPR_PAL[tok]
else:
util.die(f"{path}:{lineno}: unknown sprite palette {tok!r}")
pending_id = None
continue
if s and not s.startswith(";"):
break # next label (AnimationTileset1Palettes:) ends the table
if not assignments:
util.die(f"{path}: SpritePaletteAssignments not found or empty")
return assignments
def extract_gbc_world(pokered_gbc):
"""pokered-gbc color/** -> the `world` table (see module docstring):
real per-tile GBC BG-palette groups, per-town roof overrides, and
overworld sprite OBJ palettes, for true overworld parity under
COLORS=RED++ (LoadTilesetPalette / LoadTownPalette / ColorOverworldSprite)."""
base = os.path.join(pokered_gbc, "color")
tile_groups, ts_order = _parse_map_palette_assignments(
os.path.join(base, "data/map_palette_assignments.asm"))
pal_sets, ts_order2 = _parse_map_palette_sets(
os.path.join(base, "data/map_palette_sets.asm"))
if set(ts_order) != set(ts_order2):
util.die("map_palette_assignments.asm / map_palette_sets.asm "
"tileset lists disagree")
if len(ts_order) < 20:
util.die(f"map_palette_assignments.asm: parsed only {len(ts_order)} "
f"tilesets (want ~24)")
map_palettes, _ = _parse_rgb_table(
os.path.join(base, "data/map_palettes.asm"),
re.compile(r"^;\s*0x[0-9a-fA-F]+:\s*(\w+)\s*$"), 12)
group_colors = {}
for ts in ts_order:
colors = []
for slot, const in enumerate(pal_sets[ts]):
if const not in map_palettes:
util.die(f"map_palette_sets.asm: {ts} slot {slot}: unknown "
f"palette constant {const}")
colors.append(map_palettes[const])
group_colors[ts] = colors
# Only these two tilesets are ever town-roof-swapped
# (LoadTilesetPalette: `cp 0` / `cp PLATEAU` before calling
# LoadTownPalette) -- the ROOF slot for every other tileset is static.
roof_group = {ts: _GROUP_INDEX["ROOF"]
for ts in ("OVERWORLD", "PLATEAU") if ts in group_colors}
roof_path = os.path.join(base, "data/roofpalettes.asm")
roof_colors, _ = _parse_rgb_table(
roof_path, re.compile(r"^(?!RoofPalettes)(\w+):\s*$"), 6)
roof_order = _parse_roof_order(roof_path)
roof_by_index = {}
for i, name in enumerate(roof_order):
if name not in roof_colors:
util.die(f"{roof_path}: RoofPalettes references unknown label "
f"{name}")
roof_by_index[i] = roof_colors[name]
sprite_path = os.path.join(base, "data/spritepalettes.asm")
raw_sprite_pals, _ = _parse_rgb_table(
sprite_path, re.compile(r"^;\s*(\d+)\s*$"), 12)
sprite_palettes = {int(k): v for k, v in raw_sprite_pals.items()}
if len(sprite_palettes) != 8:
util.die(f"{sprite_path}: parsed {len(sprite_palettes)} sprite "
f"palettes (want 8)")
sprite_assignment = _parse_sprite_assignments(
os.path.join(base, "sprites.asm"))
if len(sprite_assignment) != 72:
util.die(f"sprites.asm: parsed {len(sprite_assignment)} sprite "
f"palette assignments (want 72)")
tile_groups_out = {ts: {i: v for i, v in enumerate(vals)}
for ts, vals in tile_groups.items()}
return {
"tileGroups": tile_groups_out,
"groupColors": group_colors,
"roofGroup": roof_group,
"roofByMapIndex": roof_by_index,
"spritePalettes": sprite_palettes,
"spriteAssignment": sprite_assignment,
}
def extract_gbc(pokered_gbc, out_path):
"""Build the Red++ / pokered-gbc SuperPalette pack used by COLORS=RED++."""
super_path = os.path.join(pokered_gbc, "data/super_palettes.asm")
mon_path = os.path.join(pokered_gbc, "data/mon_palettes.asm")
if not os.path.isfile(super_path):
util.die(f"missing {super_path}")
if not os.path.isfile(mon_path):
util.die(f"missing {mon_path}")
palettes, order = _parse_gbc_super_palettes(super_path)
# pret spelling vs Red++ British spelling
if "GREYMON" in palettes and "GRAYMON" not in palettes:
palettes["GRAYMON"] = palettes["GREYMON"]
order.append("GRAYMON")
# Overworld still asks for ROUTE / PALLET; Red++ dropped those SGB
# entries (color/data handles overworld). Alias to closest town pals.
if "ROUTE" not in palettes and "VIRIDIAN" in palettes:
palettes["ROUTE"] = palettes["VIRIDIAN"]
order.append("ROUTE")
if "PALLET" not in palettes and "PEWTER" in palettes:
palettes["PALLET"] = palettes["PEWTER"]
order.append("PALLET")
mon_pals = _parse_gbc_monster_palettes(mon_path)
if len(mon_pals) != 151:
util.die(f"mon_palettes.asm: parsed {len(mon_pals)} species (want 151)")
for species, pal in mon_pals.items():
if pal not in palettes:
util.die(f"mon_palettes.asm: unknown palette PAL_{pal} for {species}")
for name in ("MEWMON", "GREENBAR", "YELLOWBAR", "REDBAR", "GRAYMON"):
if name not in palettes:
util.die(f"super_palettes.asm: PAL_{name} missing")
world = extract_gbc_world(pokered_gbc)
# write_lua stamps "Generated by tools/build_data.py"; keep that header
# but point the source field at the gbc tree.
util.write_lua(
out_path,
{"source": "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm"
" + color/**",
"palettes": palettes,
"order": order,
"pokemon": mon_pals,
"world": world},
header="Red++ / pokered-gbc SuperPalettes (COLORS=RED++): 4 8-bit RGB\n"
"colors per palette (color 0 first), including per-species\n"
"pals and the GEN_2 MonsterPalettes assignment, plus `world`\n"
"(true overworld tile/roof/sprite GBC coloring -- see this\n"
"file's module docstring for its shape).")
return palettes, mon_pals
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
sub = parser.add_subparsers(dest="cmd", required=True)
p_van = sub.add_parser("vanilla", help="extract pret/pokered SGB palettes")
p_van.add_argument("--pokered", required=True)
p_van.add_argument("--out-dir", default="data/generated")
p_gbc = sub.add_parser("gbc", help="extract pokered-gbc SuperPalettes")
p_gbc.add_argument("--pokered-gbc", required=True)
p_gbc.add_argument("--out", default="data/palettes_gbc.lua")
args = parser.parse_args(argv)
if args.cmd == "vanilla":
extract(args.pokered, args.out_dir)
else:
extract_gbc(args.pokered_gbc, args.out)
return 0
if __name__ == "__main__":
# python3 -m extract.palettes (from tools/) or python3 tools/extract/palettes.py
if __package__ is None:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
__package__ = "extract"
from extract import util as util # noqa: F811
raise SystemExit(main())
+6
View File
@@ -42219,6 +42219,12 @@
}
},
"MtMoonB2F": {
"1": {
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
"won": "_MtMoonB2fSuperNerdEachTakeOneText"
},
"2": {
"after": "_MtMoonB2FRocket1AfterBattleText",
"battle": "_MtMoonB2FRocket1BattleText",