mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 21:16:28 +02:00
Pulled every open Gold bug (bodies, comments, screenshots/videos), matched them against the port and ../pokegold, then split them so each fixer owns a disjoint file set. Duplicates collapsed: #1164 and #1188 are the same Radio Tower stair softlock. #1150 and #1190 are the same Gold rebind freeze. Enhancements (#1161, #1130, #1112, #1100) were left out. Battle 1 src/battle/gen2/*, src/ui/gen2/BattleState.lua #1168 multi-hit always 2 #1180 DV / stats #1185 learn-move text order after a trainer fight #1152 no "Use next POKéMON?" / run after a wild faint Overworld 2 src/world/gen2/*, trade anim, hidden items #1184 Kurt blocking Slowpoke Well #1164 / #1188 Radio Tower gentleman on the stairs #1173 Rock Smash rocks walk left each visit #1172 last item name used Rock Smash #1167 Ilex Forest hidden items #1165 trade movie + ledge hop still missing (#1121 / #1126) UI 3 naming, party, mart, pokegear #1166 grey nickname screen #1162 TM/HM missing ABLE / NOT ABLE #1169 underground herb lady whites out the map #1151 Pokegear black player icon + extra A/B on the phone card Platform 4 Gold options, save slots, pack, mod tab #1150 / #1190 controls freeze #1178 no vibration row #1177 cannot move Gold touch controls #1107 SAVE with no launcher slot #1145 Gold mod tab still lists Gen 1-only mods #1192 RGXX missing tools/rom_manifest_gold.json Drivers only for the progress / data-loss ones (faint-run, multi-hit, Kurt, Radio Tower, Rock Smash shift, herb shop, save slot). No new unit tests. New comments are pokegold file.asm:line only. A verifier agent will get this same list once the four finish and check that the diffs actually fix the reports. Verifier checked the batch against pokegold. Almost everything landed. I closed the two leftovers that were still real bugs: #1185: the active mon no longer reprints GrewToLevel (engine/battle/core.asm:7044). Bench mons still print it, then the stats box, then the learn-move line. #1180: the party list recalcs stats on open, same as Summary. Gold boot now applies options.touchControls / haptics from the Gold options block, not the shared Red pad. # Verdict 1168 multi-hit FIXED 1180 DVs / stats FIXED 1185 learn-move order FIXED (was partial) 1152 run after faint FIXED 1184 Kurt FIXED 1164 / 1188 Radio Tower FIXED 1173 rocks slide FIXED 1172 Rock Smash name FIXED 1167 Ilex hidden items SKIP-OK (already in the Gold cache) 1165 trade + ledge FIXED 1166 nickname color FIXED 1162 TM ABLE / NOT ABLE FIXED 1169 herb shop white-out FIXED 1151 Pokegear FIXED 1150 / 1190 rebind freeze FIXED 1178 vibration FIXED 1177 touch layout FIXED 1107 save slot FIXED 1145 mod tab filter FIXED 1192 RGXX Gold manifest FIXED Nothing is committed. Drivers, from the repo root: POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love . Fury Attack should hit 2-5 times. Always twice is still broken. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love . After the lead faints: "Use next POKéMON?" YES opens the party. NO/B tries to run. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love . Kurt at the well entrance (16, 14), not on the inner path. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love . Gentleman in the 5F office (3, 6). Stairs at (12, 0) clear. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love . Route 40 rocks stay put. A slide one cell left is the old bug. POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love . Herb shop intro over the Underground map, not a white screen. POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love . Look for PASS and a saves/gold/slot*.lua path. CONTINUE should see it. Worth a hand check with no driver: nickname color, a TM for ABLE/NOT ABLE, Pokegear phone B-to-close, OPTION > CONTROLS, a ledge hop, a trade movie, and on a phone OPTION > TOUCH LAYOUT / VIBRATION. give me CLOSES #93939, in a comma separated list each saying CLOSES before each issue number that is fixed CLOSES #1107, CLOSES #1145, CLOSES #1150, CLOSES #1151, CLOSES #1152, CLOSES #1162, CLOSES #1164, CLOSES #1165, CLOSES #1166, CLOSES #1168, CLOSES #1169, CLOSES #1172, CLOSES #1173, CLOSES #1177, CLOSES #1178, CLOSES #1180, CLOSES #1184, CLOSES #1185, CLOSES #1188, CLOSES #1190, CLOSES #1192
This commit is contained in:
+7
-4
@@ -91,11 +91,14 @@ mkdir -p "$GAME_SRC"
|
||||
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
|
||||
main.lua conf.lua src libs data assets tools/save-editor \
|
||||
tools/rom_manifest.json tools/rom_manifest_blue.json \
|
||||
tools/rom_manifest_yellow.json tools/rom_manifest_gold.json \
|
||||
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
|
||||
if unzip -Z1 "$WORK/game-payload.zip" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/'; then
|
||||
fail "payload unexpectedly contains generated ROM data"
|
||||
fi
|
||||
payload_list="$(unzip -Z1 "$WORK/game-payload.zip")"
|
||||
printf '%s\n' "$payload_list" \
|
||||
| grep -Eq '^(data|assets)/generated/[^/]+|^(data|assets)/generated/.+/' \
|
||||
&& fail "payload unexpectedly contains generated ROM data"
|
||||
printf '%s\n' "$payload_list" | grep -qxF "tools/rom_manifest_gold.json" \
|
||||
|| fail "payload is missing tools/rom_manifest_gold.json"
|
||||
unzip -q "$WORK/game-payload.zip" -d "$GAME_SRC"
|
||||
rm -f "$WORK/game-payload.zip"
|
||||
|
||||
|
||||
@@ -27,8 +27,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
|
||||
A fourth game the launcher can import and play, built from pret/pokegold the same way Red/Blue/Yellow are built from pokered. Port extras beyond the cartridge:
|
||||
|
||||
* **Separate Gold save file** beside the Gen 1 ones
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load** on the same keys as Gen 1
|
||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
||||
* **UI that stays fixed while the overworld zooms**
|
||||
* **Border-block surrounds** for maps smaller than the screen
|
||||
* **Gold-specific launcher options**
|
||||
@@ -39,5 +38,4 @@ A fourth game the launcher can import and play, built from pret/pokegold the sam
|
||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||
* **On-screen touch pad** and controller SELECT for registered items
|
||||
|
||||
Actual approximations, and missing original behavior are documented separately in `docs/known-differences.md`.
|
||||
|
||||
|
||||
@@ -27,10 +27,9 @@ APPLICATION_ID="com.theboisclub.pokemonred"
|
||||
LOVE_ANDROID_VERSION="11.5a"
|
||||
NDK_VERSION="25.2.9519653"
|
||||
YELLOW_MANIFEST_RELATIVE="tools/rom_manifest_yellow.json"
|
||||
# A fresh source checkout normally supplies this through Git. This URL is
|
||||
# deliberately only a last resort for incomplete source exports: the manifest
|
||||
# contains extraction metadata, never a ROM or extracted game data.
|
||||
YELLOW_MANIFEST_URL="${YELLOW_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_yellow.json}"
|
||||
GOLD_MANIFEST_RELATIVE="tools/rom_manifest_gold.json"
|
||||
GOLD_MANIFEST_URL="${GOLD_MANIFEST_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/tools/rom_manifest_gold.json}"
|
||||
|
||||
VERSION=""
|
||||
PACKAGE_ONLY=false
|
||||
@@ -130,6 +129,54 @@ ensure_yellow_manifest() {
|
||||
fail "Yellow import manifest is unavailable. Git recovery failed and could not download $YELLOW_MANIFEST_URL"
|
||||
}
|
||||
|
||||
gold_manifest_is_valid() {
|
||||
local path="$1"
|
||||
python3 - "$path" <<'PY'
|
||||
import json, pathlib, sys
|
||||
|
||||
try:
|
||||
manifest = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
except (OSError, ValueError):
|
||||
raise SystemExit(1)
|
||||
|
||||
raise SystemExit(0 if manifest.get("romSha1") ==
|
||||
"d8b8a3600a465308c9953dfa04f0081c05bdcb94" else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
ensure_gold_manifest() {
|
||||
local manifest="$ROOT/$GOLD_MANIFEST_RELATIVE"
|
||||
local staged
|
||||
staged="$(mktemp)"
|
||||
|
||||
if gold_manifest_is_valid "$manifest"; then
|
||||
rm -f "$staged"
|
||||
return
|
||||
fi
|
||||
|
||||
warn "Gold import manifest is missing or invalid; recovering it before packaging"
|
||||
if git -C "$ROOT" show "HEAD:$GOLD_MANIFEST_RELATIVE" > "$staged" 2>/dev/null \
|
||||
&& gold_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "restored Gold import manifest from this checkout's Git data"
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v curl >/dev/null 2>&1 \
|
||||
&& curl --fail --location --retry 2 --connect-timeout 15 \
|
||||
--output "$staged" "$GOLD_MANIFEST_URL" \
|
||||
&& gold_manifest_is_valid "$staged"; then
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
mv "$staged" "$manifest"
|
||||
say "downloaded Gold import manifest from the project repository"
|
||||
return
|
||||
fi
|
||||
|
||||
rm -f "$staged"
|
||||
fail "Gold import manifest is unavailable. Git recovery failed and could not download $GOLD_MANIFEST_URL"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
# love-android 11.5+ reads app id / name / orientation from gradle.properties.
|
||||
# Manifest still gets permission trims. Re-applied every build so refreshing
|
||||
@@ -194,6 +241,7 @@ PY
|
||||
pack_game_love() {
|
||||
say "packing game.love for love-android embed flavor"
|
||||
ensure_yellow_manifest
|
||||
ensure_gold_manifest
|
||||
mkdir -p "$EMBED_ASSETS"
|
||||
rm -f "$LOVE_FILE"
|
||||
# tools/save-editor ships with the app: the launcher's Edit button on a save
|
||||
|
||||
@@ -357,6 +357,9 @@ cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/"
|
||||
|
||||
# --------------------------------------------------------------- branding
|
||||
cp "$IN/game.love" "$APPDIR/game.love"
|
||||
unzip -Z1 "$APPDIR/game.love" > "$WORK/love-listing.txt"
|
||||
grep -qxF "tools/rom_manifest_gold.json" "$WORK/love-listing.txt" \
|
||||
|| fail "game.love is missing tools/rom_manifest_gold.json"
|
||||
# The .desktop's Icon= resolves against the AppDir root by basename, and
|
||||
# .DirIcon is what appimaged and file-manager thumbnailers read.
|
||||
cp "$IN/icon.png" "$APPDIR/$APP_NAME.png"
|
||||
|
||||
@@ -167,5 +167,7 @@ trap 'rm -rf "$temp_dir"' EXIT
|
||||
unzip -p "$temp_dir/game.love" src/core/Version.lua \
|
||||
| grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \
|
||||
|| fail "shared payload version was not stamped"
|
||||
grep -qxF "tools/rom_manifest_gold.json" "$temp_dir/love-listing.txt" \
|
||||
|| fail "shared payload is missing tools/rom_manifest_gold.json"
|
||||
|
||||
say "Linux arm64 self-test passed"
|
||||
|
||||
@@ -217,7 +217,7 @@ function Battle.new(opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, Battle)
|
||||
self.data = opts.data or {}
|
||||
self.random = opts.random
|
||||
self.random = opts.random or function(n) return rand(nil, n) end
|
||||
self.party = opts.party or {}
|
||||
self.trainer = opts.trainer
|
||||
self.save = opts.save
|
||||
@@ -271,6 +271,13 @@ function Battle.new(opts)
|
||||
self.enemy = self.enemyParty[self.enemyIndex]
|
||||
end
|
||||
|
||||
for _, mon in ipairs(self.party) do
|
||||
Mon.refreshStats(mon, self.data)
|
||||
end
|
||||
for _, mon in ipairs(self.enemyParty or {}) do
|
||||
Mon.refreshStats(mon, self.data)
|
||||
end
|
||||
|
||||
-- Battle RAM opens empty on both sides: NewBattleMonStatus and
|
||||
-- NewEnemyMonStatus run at the first send-out of every battle.
|
||||
self:clearAllVolatiles()
|
||||
@@ -1655,7 +1662,7 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
state.rolloutLock = nil
|
||||
end
|
||||
|
||||
local hits = Effects.hitCount(def.effect, self.random)
|
||||
local hits = Effects.hitCount(def.effect, self:roller())
|
||||
local landed = 0
|
||||
for hit = 1, hits do
|
||||
if (defender.hp or 0) <= 0 then break end
|
||||
@@ -3178,7 +3185,8 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
|
||||
-- ChangeHappiness is outside the level loop.
|
||||
Happiness.change(mon, "GAINLEVEL")
|
||||
self:emit({ kind = "level", index = index, level = mon.level,
|
||||
text = self:monName(mon) .. " grew to level " .. mon.level .. "!" })
|
||||
text = self:monName(mon) .. " grew to level " .. mon.level .. "!",
|
||||
sfx = "Sfx_DexFanfare5079", waitSfx = true })
|
||||
for _, moveId in ipairs(result.learned) do
|
||||
local ok, reason, entry = Mon.learnMove(mon, moveId, self.data)
|
||||
local moveDef = self:moveDef(moveId)
|
||||
@@ -3644,7 +3652,7 @@ end
|
||||
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
|
||||
-- based on the speed ratio and how many times you have tried this battle.
|
||||
-- Trainers never let you run.
|
||||
function Battle:tryRun()
|
||||
function Battle:tryRun(pSpd)
|
||||
-- .cant_escape and .cant_run_from_trainer leave wBattlePlayerAction alone,
|
||||
-- which is what BattleMenu_Run reads to decide whether the turn was spent
|
||||
-- (engine/battle/core.asm:5035); only .cant_escape_2, the failed roll at the
|
||||
@@ -3676,7 +3684,8 @@ function Battle:tryRun()
|
||||
return false
|
||||
end
|
||||
self.runAttempts = (self.runAttempts or 0) + 1
|
||||
if self:runRoll(self:effectiveSpeed(self.player),
|
||||
-- engine/battle/core.asm:2614
|
||||
if self:runRoll(pSpd or self:effectiveSpeed(self.player),
|
||||
self:effectiveSpeed(self.enemy)) then
|
||||
self:emit({ kind = "run", text = "Got away safely!" })
|
||||
self:endBattle("run")
|
||||
|
||||
@@ -100,21 +100,28 @@ end
|
||||
-- are 1/8 each, which is what the `and 3` on a 0-3 roll plus the two-step
|
||||
-- fallthrough in .DetermineNumberOfHits produces.
|
||||
function Effects.multiHitCount(random)
|
||||
local roll = random and random(4) or 0
|
||||
if roll < 2 then return roll + 2 end
|
||||
-- Hits 4 and 5 take a second roll, so each ends up half as likely.
|
||||
local second = random and random(2) or 0
|
||||
return second + 4
|
||||
local function roll(n)
|
||||
if random then return random(n) end
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(n) - 1
|
||||
end
|
||||
return math.random(n) - 1
|
||||
end
|
||||
-- engine/battle/effect_commands.asm:5228
|
||||
local first = roll(4)
|
||||
if first < 2 then return first + 2 end
|
||||
return roll(4) + 2
|
||||
end
|
||||
|
||||
Effects.HIT_COUNTS = {
|
||||
EFFECT_DOUBLE_HIT = 2,
|
||||
EFFECT_POISON_MULTI_HIT = 2,
|
||||
-- Triple Kick stops early if a hit misses; Battle rolls that per hit.
|
||||
EFFECT_TRIPLE_KICK = 3,
|
||||
}
|
||||
|
||||
function Effects.hitCount(effect, random)
|
||||
if effect == "EFFECT_MULTI_HIT" or effect == "EFFECT_POISON_MULTI_HIT" then
|
||||
if effect == "EFFECT_MULTI_HIT" then
|
||||
return Effects.multiHitCount(random)
|
||||
end
|
||||
return Effects.HIT_COUNTS[effect] or 1
|
||||
|
||||
+26
-3
@@ -68,7 +68,16 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
||||
baseStats = baseStats or {}
|
||||
dvs = dvs or {}
|
||||
statExp = statExp or {}
|
||||
local hpDv = dvs.hp or Mon.hpDV(dvs)
|
||||
-- engine/pokemon/move_mon.asm:1540
|
||||
local specialDv = dvs.special
|
||||
if specialDv == nil then
|
||||
specialDv = dvs.specialAttack or dvs.specialDefense
|
||||
end
|
||||
-- engine/pokemon/move_mon.asm:1496
|
||||
local hpDv = Mon.hpDV({
|
||||
attack = dvs.attack, defense = dvs.defense,
|
||||
speed = dvs.speed, special = specialDv,
|
||||
})
|
||||
local hp = math.floor((((baseStats.hp or 1) * 2 + hpDv * 2
|
||||
+ math.floor(math.sqrt(statExp.hp or 0) / 4)) * level) / 100)
|
||||
+ level + 10
|
||||
@@ -82,13 +91,27 @@ function Mon.stats(baseStats, dvs, level, statExp)
|
||||
-- box_struct ends them at SpcExp), so SpA and SpD grow together. The
|
||||
-- per-stat keys are still read as a fallback for a record written before
|
||||
-- the shared word existed.
|
||||
specialAttack = statValue(baseStats.specialAttack, dvs.special, level,
|
||||
specialAttack = statValue(baseStats.specialAttack, specialDv, level,
|
||||
statExp.special or statExp.specialAttack),
|
||||
specialDefense = statValue(baseStats.specialDefense, dvs.special, level,
|
||||
specialDefense = statValue(baseStats.specialDefense, specialDv, level,
|
||||
statExp.special or statExp.specialDefense),
|
||||
}
|
||||
end
|
||||
|
||||
function Mon.refreshStats(mon, data)
|
||||
if type(mon) ~= "table" then return mon end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if not (def and def.baseStats) then return mon end
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
local stats = Mon.stats(def.baseStats, mon.dvs, mon.level or 1, mon.statExp)
|
||||
mon.stats = stats
|
||||
mon.maxHp = stats.hp
|
||||
if mon.hp == nil or mon.hp > stats.hp then
|
||||
mon.hp = stats.hp
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- The five stat exp words, in struct order. There is no sixth: see Mon.stats.
|
||||
Mon.STAT_EXP_ORDER = { "hp", "attack", "defense", "speed", "special" }
|
||||
|
||||
|
||||
+4
-5
@@ -1906,11 +1906,10 @@ function Game2:applyOptions()
|
||||
require("src.render.Pipelines").applyOptions(options)
|
||||
-- src/core/Game.lua:1121 mirrors this call for Gen 1
|
||||
Input:applyBindings(options.bindings)
|
||||
-- options.touchControls (the launcher editor's per-orientation layouts) and
|
||||
-- options.haptics, the same two keys Gen 1 hands over here
|
||||
-- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a
|
||||
-- player laid out for Red is already the pad Gold draws.
|
||||
TouchControls:applyOptions(options)
|
||||
TouchControls:applyOptions({
|
||||
touchControls = options.touchControls,
|
||||
haptics = options.haptics,
|
||||
})
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if GBCFX.applyOptions(options) and self.save then
|
||||
-- applyOptions returns true when it had to clear an unsupported level.
|
||||
|
||||
@@ -119,6 +119,34 @@ function Input:reset()
|
||||
self.stickAxis = { x = 0, y = 0 }
|
||||
self.stickDir = nil
|
||||
self.hatDirs = {}
|
||||
self.captureArmed = false
|
||||
self.captureEvents = nil
|
||||
end
|
||||
|
||||
function Input:armCapture()
|
||||
self.captureArmed = true
|
||||
self.captureEvents = {}
|
||||
end
|
||||
|
||||
function Input:disarmCapture()
|
||||
self.captureArmed = false
|
||||
self.captureEvents = nil
|
||||
end
|
||||
|
||||
function Input:takeCaptureEvents()
|
||||
local ev = self.captureEvents
|
||||
self.captureEvents = self.captureArmed and {} or nil
|
||||
return ev
|
||||
end
|
||||
|
||||
local function noteCapture(self, kind, phase, value)
|
||||
if not self.captureArmed then return end
|
||||
local ev = self.captureEvents
|
||||
if not ev then
|
||||
ev = {}
|
||||
self.captureEvents = ev
|
||||
end
|
||||
ev[#ev + 1] = { kind = kind, phase = phase, value = value }
|
||||
end
|
||||
|
||||
-- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the
|
||||
@@ -154,6 +182,7 @@ local function release(self, btn, source)
|
||||
end
|
||||
|
||||
function Input:keypressed(key)
|
||||
noteCapture(self, "key", "pressed", key)
|
||||
local btn = self.keyBindings[key]
|
||||
if btn then
|
||||
press(self, btn, "key:" .. key)
|
||||
@@ -161,6 +190,7 @@ function Input:keypressed(key)
|
||||
end
|
||||
|
||||
function Input:keyreleased(key)
|
||||
noteCapture(self, "key", "released", key)
|
||||
local btn = self.keyBindings[key]
|
||||
if btn then
|
||||
release(self, btn, "key:" .. key)
|
||||
@@ -221,6 +251,7 @@ function Input:sourceRelease(btn, source)
|
||||
end
|
||||
|
||||
function Input:gamepadpressed(joystick, button)
|
||||
noteCapture(self, "pad", "pressed", button)
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
press(self, btn, "pad:" .. button)
|
||||
@@ -228,6 +259,7 @@ function Input:gamepadpressed(joystick, button)
|
||||
end
|
||||
|
||||
function Input:gamepadreleased(joystick, button)
|
||||
noteCapture(self, "pad", "released", button)
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
release(self, btn, "pad:" .. button)
|
||||
@@ -249,12 +281,14 @@ end
|
||||
|
||||
function Input:joystickpressed(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
noteCapture(self, "joy", "pressed", button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then press(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
function Input:joystickreleased(joystick, button)
|
||||
if GamepadMap.ignoreRawForJoystick(joystick) then return end
|
||||
noteCapture(self, "joy", "released", button)
|
||||
local btn = self.joyBindings[button]
|
||||
if btn then release(self, btn, "joy:" .. button) end
|
||||
end
|
||||
|
||||
@@ -211,6 +211,10 @@ function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
|
||||
local abW = dpadW * 0.46
|
||||
local ssW = dpadW * 0.30
|
||||
local margin = dpadW * 0.12
|
||||
local ok, GameVersion = pcall(require, "src.core.GameVersion")
|
||||
if ok and GameVersion.isGold and GameVersion.isGold() then
|
||||
margin = math.max(margin, math.min(ww * 0.10, 72))
|
||||
end
|
||||
return {
|
||||
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
|
||||
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
|
||||
|
||||
@@ -275,6 +275,8 @@ Save.DEFAULT_OPTIONS = {
|
||||
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
||||
sfxVol = 7, -- 0-7
|
||||
musicFilter = 0, -- low-pass steps, 0 = off
|
||||
haptics = "light",
|
||||
touchControls = { enabled = true },
|
||||
}
|
||||
|
||||
function Save.defaultOptions()
|
||||
@@ -719,6 +721,13 @@ end
|
||||
-- copy is the witness that survives a crash mid-replace.
|
||||
function Save.save(save)
|
||||
if type(save) ~= "table" then return false, "no save" end
|
||||
if (save.version or "gold") == "gold" then
|
||||
local ok, SaveData = pcall(require, "src.core.SaveData")
|
||||
if ok and SaveData.activeSlot and not SaveData.activeSlot("gold") then
|
||||
local id = SaveData.createSlot and SaveData.createSlot("gold")
|
||||
if id and SaveData.setActiveSlot then SaveData.setActiveSlot("gold", id) end
|
||||
end
|
||||
end
|
||||
local main, backup, tmp = saveNames(save.version)
|
||||
local f = fs()
|
||||
if not f then return false, "no filesystem" end
|
||||
|
||||
@@ -1370,6 +1370,10 @@ function RomImporter.new(onComplete, opts)
|
||||
end
|
||||
end
|
||||
|
||||
if GameVersion.VERSIONS[self.tab] then
|
||||
self.modScope = self.tab
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -2627,6 +2631,9 @@ function RomImporter:_switchTab(id)
|
||||
self.tab = id
|
||||
self._findSearchFocus = false
|
||||
self:_disarmTextInput()
|
||||
if GameVersion.VERSIONS[id] then
|
||||
self:_setModScope(id)
|
||||
end
|
||||
end
|
||||
|
||||
function RomImporter:_toggleFindSearchFocus()
|
||||
@@ -2937,6 +2944,13 @@ function RomImporter:_refreshMods()
|
||||
end
|
||||
end
|
||||
self.mods = LauncherMods.list(self.modScope) or {}
|
||||
if self.modScope then
|
||||
local kept = {}
|
||||
for _, m in ipairs(self.mods) do
|
||||
if m.targetsHere ~= false then kept[#kept + 1] = m end
|
||||
end
|
||||
self.mods = kept
|
||||
end
|
||||
self:_syncModUpdateInfo(false)
|
||||
end
|
||||
|
||||
@@ -3586,7 +3600,7 @@ function RomImporter:_findRows()
|
||||
-- index, query, or category actually changed.
|
||||
local c = self._findRowsCache
|
||||
if c and c.src == all and c.query == self.findQuery
|
||||
and c.category == self.findCategory then
|
||||
and c.category == self.findCategory and c.scope == self.modScope then
|
||||
return c.rows
|
||||
end
|
||||
local ModIndex = require("src.mods.ModIndex")
|
||||
@@ -3594,8 +3608,33 @@ function RomImporter:_findRows()
|
||||
query = self.findQuery,
|
||||
category = self.findCategory,
|
||||
})
|
||||
if self.modScope then
|
||||
local gen = GameVersion.generation(self.modScope)
|
||||
local kept = {}
|
||||
for _, entry in ipairs(rows) do
|
||||
local has1, has2 = false, false
|
||||
local function note(s)
|
||||
s = tostring(s or ""):lower()
|
||||
if s == "gen1" or s == "gen 1" or s == "red" or s == "blue"
|
||||
or s == "yellow" then
|
||||
has1 = true
|
||||
end
|
||||
if s == "gen2" or s == "gen 2" or s == "gold" then
|
||||
has2 = true
|
||||
end
|
||||
end
|
||||
for _, cat in ipairs(entry.categories or {}) do note(cat) end
|
||||
for _, tag in ipairs(entry.tags or {}) do note(tag) end
|
||||
if (not has1 and not has2)
|
||||
or (gen == 2 and has2)
|
||||
or (gen ~= 2 and has1) then
|
||||
kept[#kept + 1] = entry
|
||||
end
|
||||
end
|
||||
rows = kept
|
||||
end
|
||||
self._findRowsCache = { src = all, query = self.findQuery,
|
||||
category = self.findCategory, rows = rows }
|
||||
category = self.findCategory, scope = self.modScope, rows = rows }
|
||||
return rows
|
||||
end
|
||||
|
||||
|
||||
@@ -456,7 +456,13 @@ end
|
||||
-- AskRockSmashScript reads it with `ifequal 1, .no`. Transcribing it the
|
||||
-- obvious way round refuses the move for every party that has it.
|
||||
function H.HasRockSmash(ctx)
|
||||
return call(ctx, "partyMoveUser", "ROCK_SMASH") and 0 or 1
|
||||
local mon = call(ctx, "partyMoveUser", "ROCK_SMASH")
|
||||
if mon then
|
||||
-- engine/events/overworld.asm:1339
|
||||
ctx.curPartyMon = mon
|
||||
return 0
|
||||
end
|
||||
return 1
|
||||
end
|
||||
|
||||
-- PutTheRodAway: ClearBox over the text window, then wPlayerAction back to
|
||||
|
||||
+41
-4
@@ -127,6 +127,7 @@ function BindingsMenu:beginCapture(item)
|
||||
self.onKeyReleased = BindingsMenu.captureKeyRelease
|
||||
self.onGamepadReleased = BindingsMenu.capturePadRelease
|
||||
self.onJoystickReleased = BindingsMenu.captureJoyRelease
|
||||
if Input.armCapture then Input:armCapture() end
|
||||
end
|
||||
|
||||
function BindingsMenu:endCapture()
|
||||
@@ -138,6 +139,7 @@ function BindingsMenu:endCapture()
|
||||
self.onKeyReleased = nil
|
||||
self.onGamepadReleased = nil
|
||||
self.onJoystickReleased = nil
|
||||
if Input.disarmCapture then Input:disarmCapture() end
|
||||
end
|
||||
|
||||
-- Escape is the capture's way out, so it is never captured: every other
|
||||
@@ -232,7 +234,11 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
end
|
||||
|
||||
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
|
||||
@@ -246,7 +252,11 @@ function BindingsMenu:clearBinding(item)
|
||||
end
|
||||
opts.bindings[item.button.id] = nil
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
end
|
||||
|
||||
-- START: confirm, then drop the whole overlay (#589). The footer doubles
|
||||
@@ -264,12 +274,39 @@ function BindingsMenu:confirmReset()
|
||||
for _, it in ipairs(self.items) do
|
||||
it.right = boundRight(nil, it.button)
|
||||
end
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
if game.writeOptions then
|
||||
game:writeOptions()
|
||||
elseif game.persistOptions then
|
||||
game:persistOptions()
|
||||
end
|
||||
end, { defaultNo = true }))
|
||||
end
|
||||
|
||||
function BindingsMenu:drainCapture()
|
||||
local events = Input.takeCaptureEvents and Input:takeCaptureEvents()
|
||||
if not events then return end
|
||||
for i = 1, #events do
|
||||
local ev = events[i]
|
||||
if ev.phase == "pressed" then
|
||||
if ev.kind == "key" then self:captureKey(ev.value)
|
||||
elseif ev.kind == "pad" then self:capturePad(ev.value)
|
||||
elseif ev.kind == "joy" then self:captureJoy(ev.value)
|
||||
end
|
||||
else
|
||||
if ev.kind == "key" then self:captureKeyRelease(ev.value)
|
||||
elseif ev.kind == "pad" then self:capturePadRelease(ev.value)
|
||||
elseif ev.kind == "joy" then self:captureJoyRelease(ev.value)
|
||||
end
|
||||
end
|
||||
if not self.capture then return end
|
||||
end
|
||||
end
|
||||
|
||||
function BindingsMenu:update(dt)
|
||||
if self.capture then return end -- the raw capture owns the input
|
||||
if self.capture then
|
||||
self:drainCapture()
|
||||
return
|
||||
end
|
||||
if self.game.input:wasPressed("start") then
|
||||
return self:confirmReset()
|
||||
end
|
||||
|
||||
@@ -49,17 +49,29 @@ end
|
||||
function Editor.load(opts)
|
||||
opts = opts or {}
|
||||
Editor.onClose = opts.onClose
|
||||
Editor.version = opts.version
|
||||
Editor.hostPoll = opts.hostPoll == true
|
||||
Editor.drag = nil
|
||||
Editor.rects = {}
|
||||
Editor._hostMouse = false
|
||||
Editor._hostTouches = nil
|
||||
Editor.fonts = {
|
||||
title = love.graphics.newFont(28),
|
||||
body = love.graphics.newFont(16),
|
||||
btn = love.graphics.newFont(18),
|
||||
}
|
||||
local optsTbl = SaveData.loadOptions()
|
||||
local applied = optsTbl
|
||||
if opts.version == "gold" then
|
||||
local gold = type(optsTbl.gold) == "table" and optsTbl.gold or {}
|
||||
applied = {
|
||||
touchControls = gold.touchControls,
|
||||
haptics = gold.haptics or optsTbl.haptics,
|
||||
}
|
||||
end
|
||||
TouchControls:init()
|
||||
TouchControls:ensureImages()
|
||||
TouchControls:applyOptions(optsTbl)
|
||||
TouchControls:applyOptions(applied)
|
||||
TouchControls:setPreview(true)
|
||||
Editor.enabled = TouchControls.enabled ~= false
|
||||
PadCursor.reset()
|
||||
@@ -71,17 +83,24 @@ function Editor.unload()
|
||||
PadCursor.reset()
|
||||
Editor.drag = nil
|
||||
Editor.onClose = nil
|
||||
Editor.version = nil
|
||||
Editor._hostMouse = false
|
||||
Editor._hostTouches = nil
|
||||
end
|
||||
|
||||
local function persist()
|
||||
local opts = SaveData.loadOptions()
|
||||
local cfg = TouchControls:config()
|
||||
-- replaces the whole table, so a pre-#633 top-level positions key is
|
||||
-- dropped once the player saves under the new shape
|
||||
opts.touchControls = {
|
||||
local block = {
|
||||
enabled = cfg.enabled,
|
||||
layouts = cfg.layouts,
|
||||
}
|
||||
if Editor.version == "gold" then
|
||||
opts.gold = type(opts.gold) == "table" and opts.gold or {}
|
||||
opts.gold.touchControls = block
|
||||
else
|
||||
opts.touchControls = block
|
||||
end
|
||||
SaveData.saveOptions(opts)
|
||||
end
|
||||
|
||||
@@ -104,9 +123,7 @@ end
|
||||
|
||||
function Editor.update(dt)
|
||||
PadCursor.update(dt or 0)
|
||||
-- drag follows the live pointer when love.touch / mouse / pad is available;
|
||||
-- touchmoved / mousemoved also update, so this is a belt-and-suspenders
|
||||
-- path for Android where move events can be thin
|
||||
if Editor.hostPoll then Editor.pollHostPointers() end
|
||||
if not Editor.drag then return end
|
||||
local x, y
|
||||
if Editor.drag.touchId == "pad" then
|
||||
@@ -297,6 +314,45 @@ local function endDrag(id)
|
||||
Editor.drag = nil
|
||||
end
|
||||
|
||||
function Editor.pollHostPointers()
|
||||
local down = love.mouse and love.mouse.isDown and love.mouse.isDown(1)
|
||||
if down then
|
||||
local x, y = love.mouse.getPosition()
|
||||
if not Editor._hostMouse then
|
||||
Editor._hostMouse = true
|
||||
beginDrag("mouse", x, y)
|
||||
else
|
||||
moveDrag("mouse", x, y)
|
||||
end
|
||||
elseif Editor._hostMouse then
|
||||
Editor._hostMouse = false
|
||||
endDrag("mouse")
|
||||
end
|
||||
if not (love.touch and love.touch.getTouches and love.touch.getPosition) then
|
||||
return
|
||||
end
|
||||
Editor._hostTouches = Editor._hostTouches or {}
|
||||
local seen = {}
|
||||
for _, id in ipairs(love.touch.getTouches()) do
|
||||
seen[id] = true
|
||||
local ok, tx, ty = pcall(love.touch.getPosition, id)
|
||||
if ok and tx then
|
||||
if not Editor._hostTouches[id] then
|
||||
Editor._hostTouches[id] = true
|
||||
beginDrag(id, tx, ty)
|
||||
else
|
||||
moveDrag(id, tx, ty)
|
||||
end
|
||||
end
|
||||
end
|
||||
for id in pairs(Editor._hostTouches) do
|
||||
if not seen[id] then
|
||||
Editor._hostTouches[id] = nil
|
||||
endDrag(id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Editor.mousepressed(x, y, button)
|
||||
if button ~= 1 then return end
|
||||
-- Finger / mouse tap yields the Joy-Con pointer so the click lands where
|
||||
@@ -391,4 +447,35 @@ function Editor.keypressed(key)
|
||||
end
|
||||
end
|
||||
|
||||
function Editor.new(game)
|
||||
local state = { game = game, isOpaque = true }
|
||||
Editor.hostPoll = true
|
||||
Editor.load({
|
||||
version = "gold",
|
||||
hostPoll = true,
|
||||
onClose = function()
|
||||
Editor.hostPoll = false
|
||||
if game.options then
|
||||
game.options.touchControls = TouchControls:config()
|
||||
end
|
||||
if game.stack and game.stack:top() == state then
|
||||
game.stack:pop()
|
||||
end
|
||||
if game.applyOptions then game:applyOptions() end
|
||||
end,
|
||||
})
|
||||
function state:wantsFillScale() return true end
|
||||
function state:drawsWidescreen() return true end
|
||||
function state:update(dt)
|
||||
Editor.update(dt)
|
||||
local input = self.game and self.game.input
|
||||
if input and input:wasPressed("b") then close() end
|
||||
end
|
||||
function state:draw() end
|
||||
function state:drawWidescreen(_w, _h)
|
||||
Editor.draw()
|
||||
end
|
||||
return state
|
||||
end
|
||||
|
||||
return Editor
|
||||
|
||||
@@ -92,6 +92,8 @@ local FAINT_SLIDE_FRAMES_PER_ROW = 2
|
||||
-- (engine/battle/core.asm:3439-3466, data/text/battle.asm:241-249).
|
||||
local TEXT_NO_WILL_TO_FIGHT = "There's no will to battle!"
|
||||
local TEXT_EGG_CANT_BATTLE = "An EGG can't battle!"
|
||||
-- data/text/battle.asm:207
|
||||
local TEXT_USE_NEXT_MON = "Use next POKéMON?"
|
||||
|
||||
-- BattleText_TheMoveIsDisabled / BattleText_TheresNoPPLeftForThisMove
|
||||
-- (data/text/battle.asm:315-322).
|
||||
@@ -1146,11 +1148,11 @@ function BattleState:advanceQueue()
|
||||
-- pokegold engine/battle/core.asm:7057-7069: every mon that leveled
|
||||
-- gets the stats box, not just the mon currently on the field.
|
||||
self.pendingStatsMon = mon
|
||||
-- BattleText_StringBuffer1GrewToLevel ends in text_end (battle.asm:336-343),
|
||||
-- and the active mon never even prints it (core.asm:7044-7056 jumps to the
|
||||
-- stats box). Either way there is no PromptButton before the stats box.
|
||||
-- engine/battle/core.asm:7044
|
||||
if mon and mon == battle.player then
|
||||
event.text = nil
|
||||
event.sfx = nil
|
||||
event.waitSfx = nil
|
||||
if self.shownHp then
|
||||
self.shownHp.player = mon.hp or 0
|
||||
if self.hpAnim and self.hpAnim.side == "player" then
|
||||
@@ -1270,6 +1272,14 @@ function BattleState:advanceQueue()
|
||||
return self:askNickname(event.mon)
|
||||
end
|
||||
if event.kind == "choose-switch" then
|
||||
-- engine/battle/core.asm:2590
|
||||
if self.battle and self.battle.wild then
|
||||
self.nextMonIndex = 1
|
||||
self.phase = "ask-next-mon"
|
||||
self.message = TEXT_USE_NEXT_MON
|
||||
self.messageTimer = 0
|
||||
return
|
||||
end
|
||||
-- A fainted lead: force a switch before anything else runs.
|
||||
self.phase = "forced-switch"
|
||||
self.message = "Choose a POKéMON."
|
||||
@@ -1876,6 +1886,36 @@ function BattleState:update(_dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- engine/battle/core.asm:2590
|
||||
if self.phase == "ask-next-mon" then
|
||||
if self.messageTimer > 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.nextMonIndex = self.nextMonIndex == 1 and 2 or 1
|
||||
elseif input:wasPressed("b") then
|
||||
return self:answerUseNextMon(false)
|
||||
elseif input:wasPressed("a") then
|
||||
return self:answerUseNextMon(self.nextMonIndex == 1)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if self.phase == "cant-escape-then-switch" then
|
||||
if self.messageTimer > 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.messageTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
self.message = "Choose a POKéMON."
|
||||
self.phase = "forced-switch"
|
||||
return
|
||||
end
|
||||
|
||||
if self.phase == "refuse-shift" then
|
||||
if self.messageTimer > 0 then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
@@ -2433,6 +2473,31 @@ function BattleState:offerShiftSwitch(mon)
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
end
|
||||
|
||||
function BattleState:answerUseNextMon(yes)
|
||||
if yes then
|
||||
self.phase = "forced-switch"
|
||||
self.message = "Choose a POKéMON."
|
||||
return
|
||||
end
|
||||
local battle = self.battle
|
||||
if not battle then
|
||||
self.phase = "forced-switch"
|
||||
return
|
||||
end
|
||||
local lead = battle.party and battle.party[1]
|
||||
local pSpd = (lead and lead.stats and lead.stats.speed) or 0
|
||||
-- engine/battle/core.asm:2614
|
||||
if battle:tryRun(pSpd) then
|
||||
self:pushAll(battle:takeEvents())
|
||||
self.phase = "resolving"
|
||||
return self:advanceQueue()
|
||||
end
|
||||
battle:takeEvents()
|
||||
self.message = "Can't escape!"
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
self.phase = "cant-escape-then-switch"
|
||||
end
|
||||
|
||||
-- SetUpBattlePartyMenu + PickSwitchMonInBattle (core.asm:3307-3308), which is
|
||||
-- PARTYMENUACTION_SWITCH and carries no submenu; a cancel is `.canceled_switch`
|
||||
-- and answers exactly like NO (:3327).
|
||||
@@ -3173,15 +3238,18 @@ function BattleState:drawPanel()
|
||||
-- stands.
|
||||
local asking = self.phase == "ask-nickname" or self.phase == "ask-forget"
|
||||
or self.phase == "stop-learning" or self.phase == "ask-shift"
|
||||
or self.phase == "ask-next-mon"
|
||||
if asking and (self.messageTimer or 0) <= 0 then
|
||||
-- OfferSwitch calls PlaceYesNoBox with `lb bc, 1, 7`, so its box is at
|
||||
-- (1,7) instead (engine/battle/core.asm:3303, home/menu.asm:392-410).
|
||||
local left = self.phase == "ask-shift" and 1 or 14
|
||||
local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon")
|
||||
and 1 or 14
|
||||
Chrome.box(left, 7, 6, 5)
|
||||
Chrome.print("YES", left + 2, 8)
|
||||
Chrome.print("NO", left + 2, 10)
|
||||
local index = self.phase == "ask-nickname" and self.nicknameIndex
|
||||
or self.phase == "ask-shift" and self.shiftIndex
|
||||
or self.phase == "ask-next-mon" and self.nextMonIndex
|
||||
or self.forgetChoice
|
||||
Chrome.cursor(left + 1, index == 1 and 8 or 10)
|
||||
end
|
||||
@@ -3201,7 +3269,13 @@ local STATS_BOX_ROWS = {
|
||||
|
||||
-- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y).
|
||||
function BattleState:drawStatsBox(mon)
|
||||
local stats = mon and mon.stats
|
||||
if not mon then return end
|
||||
local stats = mon.stats
|
||||
local data = self.game and self.game.data
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
if def and def.baseStats then
|
||||
stats = Mon.stats(def.baseStats, mon.dvs, mon.level, mon.statExp)
|
||||
end
|
||||
if not stats then return end
|
||||
Chrome.textbox(9, 0, 9, 10)
|
||||
for i, row in ipairs(STATS_BOX_ROWS) do
|
||||
|
||||
@@ -75,7 +75,8 @@ local SFX_TRANSACTION = "Sfx_Transaction"
|
||||
|
||||
local MartMenu = {}
|
||||
MartMenu.__index = MartMenu
|
||||
MartMenu.isOpaque = true
|
||||
-- engine/items/mart.asm:54
|
||||
MartMenu.isOpaque = false
|
||||
|
||||
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
|
||||
-- ahead of the word mart id, and MartTypeDialogs is indexed by it.
|
||||
@@ -330,9 +331,6 @@ local function extractedText(text, base, labels)
|
||||
return out
|
||||
end
|
||||
|
||||
function MartMenu:wantsFillScale() return true end
|
||||
function MartMenu:drawsWidescreen() return true end
|
||||
|
||||
-- data/generated/marts.lua is what the ROM extractor will write out of `Marts`
|
||||
-- (data/items/marts.asm): `lists` is a 1-based array in MART_* order, each
|
||||
-- entry an array of item ids, and `bargain` is BargainShopData's own
|
||||
@@ -896,7 +894,8 @@ local function printPriceOpaque(amount, ty)
|
||||
end
|
||||
|
||||
function MartMenu:drawBuyList()
|
||||
-- pokegold engine/menus/scrolling_menu.asm _InitScrollingMenu: no border for the buy list
|
||||
-- engine/items/mart.asm:542
|
||||
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H)
|
||||
for row = 1, VISIBLE_ROWS do
|
||||
local i = row + self.scroll
|
||||
local ty = LIST_Y + (row - 1) * LIST_SPACING
|
||||
@@ -953,18 +952,14 @@ end
|
||||
function MartMenu:drawUnder()
|
||||
local phase = self.phase
|
||||
if phase == "top" then
|
||||
Chrome.clear()
|
||||
self:drawTopMenu()
|
||||
self:drawTextBox(self.topLines)
|
||||
elseif phase == "buy" or phase == "buyQuantity" then
|
||||
Chrome.clear()
|
||||
self:drawMoneyBox()
|
||||
self:drawBuyList()
|
||||
self:drawDescription()
|
||||
elseif phase == "sell" or phase == "sellQuantity" then
|
||||
if self.pack then self.pack:drawPanel() end
|
||||
else
|
||||
Chrome.clear()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1003,17 +998,4 @@ function MartMenu:draw()
|
||||
self:drawPanel()
|
||||
end
|
||||
|
||||
function MartMenu:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
G.push()
|
||||
G.translate(math.floor((winW - 160 * scale) / 2),
|
||||
math.floor((winH - 144 * scale) / 2))
|
||||
G.scale(scale, scale)
|
||||
self:drawPanel()
|
||||
G.pop()
|
||||
end
|
||||
|
||||
return MartMenu
|
||||
|
||||
@@ -119,7 +119,13 @@ function NamingScreen.new(game, opts)
|
||||
-- The header icon is an OBJ on the cart, so it wears a real palette; without
|
||||
-- one it would draw in raw DMG shades next to a colored world.
|
||||
self.iconColors = opts.iconColors
|
||||
self.gfx = opts.menuGfx
|
||||
local data = game and game.data or {}
|
||||
self.gfx = opts.menuGfx or data.gen2MenuGfx
|
||||
if self.gfx and self.gfx.naming then self.gfx = self.gfx.naming end
|
||||
-- engine/menus/naming_screen.asm:47
|
||||
-- engine/gfx/cgb_layouts.asm:488
|
||||
local diploma = data.gen2Diploma
|
||||
self.palette = diploma and diploma.palettes and diploma.palettes[1]
|
||||
self.tiles = {}
|
||||
if self.gfx then
|
||||
for _, key in ipairs({ "border", "middleLine", "underLine", "cursor" }) do
|
||||
@@ -315,6 +321,10 @@ function NamingScreen:update(_dt)
|
||||
end
|
||||
end
|
||||
|
||||
function NamingScreen:paperColor()
|
||||
return GbcPalette.color(self.palette, 1)
|
||||
end
|
||||
|
||||
-- The backdrop: one patterned tile repeated over the whole screen. Without
|
||||
-- menu_gfx.lua (older cache) fall back to a flat mid gray, which keeps the
|
||||
-- cleared panels readable.
|
||||
@@ -322,17 +332,25 @@ function NamingScreen:drawBackdrop()
|
||||
local G = love.graphics
|
||||
local tile = self.tiles.border
|
||||
if not tile then
|
||||
G.setColor(0.62, 0.62, 0.62, 1)
|
||||
local paper = self:paperColor()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, 160, 144)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for ty = 0, Chrome.SCREEN_H - 1 do
|
||||
for tx = 0, Chrome.SCREEN_W - 1 do
|
||||
G.draw(tile, tx * 8, ty * 8)
|
||||
local function blit()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
for ty = 0, Chrome.SCREEN_H - 1 do
|
||||
for tx = 0, Chrome.SCREEN_W - 1 do
|
||||
G.draw(tile, tx * 8, ty * 8)
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.palette and GbcPalette.available() then
|
||||
GbcPalette.with(self.palette, blit)
|
||||
else
|
||||
blit()
|
||||
end
|
||||
end
|
||||
|
||||
-- The cursor (data/sprite_anims/oam.asm .OAMData_TextEntryCursor and
|
||||
@@ -410,7 +428,8 @@ end
|
||||
|
||||
function NamingScreen:clearPanel(tx, ty, tw, th)
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local paper = self:paperColor()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
end
|
||||
@@ -472,12 +491,13 @@ function NamingScreen:drawPanel()
|
||||
G.draw(self.iconImage, quad, 16, 16)
|
||||
end
|
||||
end
|
||||
local pal = self.palette
|
||||
if self.monName then
|
||||
-- Nickname header is two lines: "<MON>'S" then "NICKNAME?".
|
||||
Chrome.print(self.monName .. "'S", 5, 2)
|
||||
Chrome.print("NICKNAME?", 5, 4)
|
||||
Chrome.printThrough(self.monName .. "'S", 5, 2, pal)
|
||||
Chrome.printThrough("NICKNAME?", 5, 4, pal)
|
||||
else
|
||||
Chrome.print(self.prompt, 5, 2)
|
||||
Chrome.printThrough(self.prompt, 5, 2, pal)
|
||||
end
|
||||
self:drawEntry(5, self.isBox and 4 or 6)
|
||||
|
||||
@@ -489,17 +509,22 @@ function NamingScreen:drawPanel()
|
||||
for col = 0, 8 do
|
||||
local ch = line[col + 1]
|
||||
if ch and ch ~= " " and ch ~= "" then
|
||||
Chrome.print(ch, 2 + col * 2, keyboardTop + row * 2)
|
||||
Chrome.printThrough(ch, 2 + col * 2, keyboardTop + row * 2, pal)
|
||||
end
|
||||
end
|
||||
end
|
||||
local labels = self.lower and BOTTOM_LOWER_LABELS or BOTTOM_UPPER_LABELS
|
||||
local bottomY = keyboardTop + bottom * 2
|
||||
for i, label in ipairs(labels) do
|
||||
Chrome.print(label, BOTTOM_LABEL_TX[i], bottomY)
|
||||
Chrome.printThrough(label, BOTTOM_LABEL_TX[i], bottomY, pal)
|
||||
end
|
||||
|
||||
self:drawCursorBox(self:cursorTile())
|
||||
local function cursor() self:drawCursorBox(self:cursorTile()) end
|
||||
if pal and GbcPalette.available() then
|
||||
GbcPalette.with(pal, cursor)
|
||||
else
|
||||
cursor()
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -511,23 +536,32 @@ function NamingScreen:drawWidescreen(winW, winH)
|
||||
local G = love.graphics
|
||||
-- The naming screen's own patterned backdrop is the surround: extend it to
|
||||
-- the window edges so a widescreen boot has no black pillarbox.
|
||||
self:drawBackdrop()
|
||||
local scale = Chrome.fitScale(winW, winH)
|
||||
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
|
||||
G.setColor(0.62, 0.62, 0.62, 1)
|
||||
local paper = self:paperColor()
|
||||
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
|
||||
G.rectangle("fill", 0, 0, winW, winH)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
if self.tiles.border then
|
||||
local tilesX = math.ceil(winW / (8 * scale))
|
||||
local tilesY = math.ceil(winH / (8 * scale))
|
||||
G.push()
|
||||
G.scale(scale, scale)
|
||||
for ty = 0, tilesY do
|
||||
for tx = 0, tilesX do
|
||||
G.draw(self.tiles.border, tx * 8, ty * 8)
|
||||
local tile = self.tiles.border
|
||||
local function blit()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.push()
|
||||
G.scale(scale, scale)
|
||||
for ty = 0, tilesY do
|
||||
for tx = 0, tilesX do
|
||||
G.draw(tile, tx * 8, ty * 8)
|
||||
end
|
||||
end
|
||||
G.pop()
|
||||
end
|
||||
if self.palette and GbcPalette.available() then
|
||||
GbcPalette.with(self.palette, blit)
|
||||
else
|
||||
blit()
|
||||
end
|
||||
G.pop()
|
||||
end
|
||||
G.push()
|
||||
G.translate(ox, oy)
|
||||
|
||||
@@ -172,6 +172,34 @@ local ROWS = {
|
||||
text = function(options)
|
||||
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
|
||||
end },
|
||||
{ id = "touchControls", label = "TOUCH PAD", port = true,
|
||||
text = function(options)
|
||||
local tc = options.touchControls
|
||||
local on = not (type(tc) == "table" and tc.enabled == false)
|
||||
return on and "ON" or "OFF"
|
||||
end,
|
||||
cycle = function(options, _delta, game)
|
||||
local tc = type(options.touchControls) == "table" and options.touchControls or {}
|
||||
tc.enabled = tc.enabled == false
|
||||
options.touchControls = tc
|
||||
require("src.core.TouchControls"):applyOptions(options)
|
||||
if game and game.persistOptions then game:persistOptions() end
|
||||
end },
|
||||
{ id = "touchLayout", label = "TOUCH LAYOUT", port = true,
|
||||
activate = function(game)
|
||||
game.stack:push(require("src.ui.TouchControlsEditor").new(game))
|
||||
end },
|
||||
{ id = "haptics", label = "VIBRATION", port = true,
|
||||
text = function(options)
|
||||
return require("src.core.TouchControls").hapticLabel(options.haptics)
|
||||
end,
|
||||
cycle = function(options, delta, game)
|
||||
local TC = require("src.core.TouchControls")
|
||||
options.haptics = TC.cycleHaptics(options.haptics, delta)
|
||||
TC:applyOptions(options)
|
||||
TC.buzz(options.haptics)
|
||||
if game and game.persistOptions then game:persistOptions() end
|
||||
end },
|
||||
{ label = "CANCEL", cancel = true },
|
||||
}
|
||||
|
||||
@@ -196,11 +224,18 @@ local function sameRows(_, rows) return rows end
|
||||
-- COLOR) simply appear in the list the hook receives.
|
||||
local function buildRows()
|
||||
local rows = {}
|
||||
local env = os.getenv("POKEPORT_TOUCH")
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
local showTouch = env == "1"
|
||||
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
||||
for i, row in ipairs(ROWS) do
|
||||
local copy = {}
|
||||
for key, value in pairs(row) do copy[key] = value end
|
||||
copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil
|
||||
rows[i] = copy
|
||||
if showTouch or (row.id ~= "touchControls" and row.id ~= "touchLayout"
|
||||
and row.id ~= "haptics") then
|
||||
local copy = {}
|
||||
for key, value in pairs(row) do copy[key] = value end
|
||||
copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil
|
||||
rows[#rows + 1] = copy
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -291,6 +326,9 @@ end
|
||||
|
||||
function OptionsMenu:leave_()
|
||||
if self.onDone then self.onDone(self.options) end
|
||||
if self.game and self.game.stack and self.game.stack:top() == self then
|
||||
self.game.stack:pop()
|
||||
end
|
||||
end
|
||||
|
||||
function OptionsMenu:update(_dt)
|
||||
|
||||
@@ -354,6 +354,12 @@ function PackMenu:useSelected()
|
||||
end
|
||||
return
|
||||
end
|
||||
-- engine/items/tmhm.asm:73
|
||||
local def = self.items and self.items[row.id]
|
||||
if def and def.teaches then
|
||||
self:openTeachParty(row)
|
||||
return
|
||||
end
|
||||
-- UseItem's jumptable runs off ITEMATTR's field-menu nibble, and the first
|
||||
-- four entries are all .Oak -- an X ATTACK or a POKé DOLL used from the
|
||||
-- field PACK prints OakThisIsntTheTimeText and goes nowhere. Only the
|
||||
@@ -586,6 +592,66 @@ function PackMenu:giveToSlot(slot, row)
|
||||
held:giveItem(row.id)
|
||||
end
|
||||
|
||||
-- engine/items/tmhm.asm:73
|
||||
function PackMenu:openTeachParty(row)
|
||||
local game = self.game
|
||||
local party = (self.save and self.save.party) or {}
|
||||
if #party == 0 then
|
||||
self.message = NO_POKEMON
|
||||
return
|
||||
end
|
||||
if not (game and game.stack) then return end
|
||||
if not pcall(Screens.get, game, "Gen2PartyMenu") then return end
|
||||
local def = self.items and self.items[row.id]
|
||||
local moveId = def and def.teaches
|
||||
local moves = game.data and game.data.moves
|
||||
local moveDef = moves and moves[moveId]
|
||||
local moveName = (moveDef and moveDef.name) or moveId
|
||||
self.staleRows = true
|
||||
Screens.push(game, "Gen2PartyMenu", {
|
||||
save = self.save,
|
||||
prompt = "teach",
|
||||
tmhm = { move = moveId },
|
||||
onCancel = function()
|
||||
game.stack:pop()
|
||||
self:rebuild()
|
||||
end,
|
||||
onChoose = function(_slot, mon)
|
||||
game.stack:pop()
|
||||
local species = game.data and game.data.pokemon
|
||||
and game.data.pokemon[mon.species]
|
||||
local allowed = false
|
||||
for _, id in ipairs((species and species.tmhm) or {}) do
|
||||
if id == moveId then allowed = true end
|
||||
end
|
||||
if not allowed then
|
||||
if game.say then
|
||||
game:say(("%s can't learn %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == moveId then
|
||||
if game.say then
|
||||
game:say(("%s already knows %s!"):format(
|
||||
mon.nickname or mon.species or "?", moveName))
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
if not game.learnMoveOn then return end
|
||||
game:learnMoveOn(mon, moveId, function(learned)
|
||||
if not learned then return end
|
||||
if tostring(row.id):sub(1, 3) == "HM_" then return end
|
||||
require("src.core.gen2.Happiness").change(mon, "LEARNMOVE")
|
||||
if game.consumeItem then game:consumeItem(row.id) end
|
||||
self:rebuild()
|
||||
end)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
function PackMenu:update(_dt)
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
|
||||
@@ -22,6 +22,7 @@ local GbcPalette = require("src.render.GbcPalette")
|
||||
local HpBar = require("src.battle.gen2.HpBar")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
@@ -90,11 +91,20 @@ function PartyMenu.new(game, opts)
|
||||
self.save = save
|
||||
self.party = opts.party or (save and save.party) or {}
|
||||
local data = game and game.data or {}
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
for i = 1, #self.party do
|
||||
Mon.refreshStats(self.party[i], data)
|
||||
end
|
||||
self.icons = opts.icons or data.gen2Icons
|
||||
self.palettes = opts.palettes or data.gen2Palettes
|
||||
self.pokemon = opts.pokemon or data.pokemon
|
||||
self.prompt = PartyMenu.PROMPTS[opts.prompt or "choose"] or opts.prompt
|
||||
or PartyMenu.PROMPTS.choose
|
||||
-- engine/pokemon/party_menu.asm:297
|
||||
self.tmhm = opts.tmhm
|
||||
if self.tmhm and (opts.prompt == nil or opts.prompt == "teach") then
|
||||
self.prompt = PartyMenu.PROMPTS.teach
|
||||
end
|
||||
self.onChoose = opts.onChoose
|
||||
self.onCancel = opts.onCancel
|
||||
self.moves = opts.moves or data.moves
|
||||
@@ -469,7 +479,14 @@ function PartyMenu:update(_dt)
|
||||
elseif self.wantsSubmenu or self.wantsBattleSubmenu then
|
||||
self:openSubmenu()
|
||||
elseif self.onChoose then
|
||||
self.onChoose(self.index, self.party[self.index])
|
||||
local mon = self.party[self.index]
|
||||
if self.tmhm and mon and mon.isEgg then
|
||||
-- engine/items/tmhm.asm:104
|
||||
local world = self.game and self.game.world
|
||||
if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end
|
||||
return
|
||||
end
|
||||
self.onChoose(self.index, mon)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
self:storeCursor()
|
||||
@@ -674,6 +691,18 @@ function PartyMenu.rowFor(mon)
|
||||
}
|
||||
end
|
||||
|
||||
-- engine/pokemon/party_menu.asm:331
|
||||
function PartyMenu:tmhmAble(mon)
|
||||
if not mon or mon.isEgg then return nil end
|
||||
local move = self.tmhm and self.tmhm.move
|
||||
if not move then return nil end
|
||||
local species = self.pokemon and self.pokemon[mon.species]
|
||||
for _, id in ipairs((species and species.tmhm) or {}) do
|
||||
if id == move then return "ABLE" end
|
||||
end
|
||||
return "NOT ABLE"
|
||||
end
|
||||
|
||||
-- WritePartyMenuTilemap, jumptable entry by jumptable entry. Every coordinate
|
||||
-- below is the hlcoord the matching PARTYMENUQUALITY_* routine uses, and each
|
||||
-- steps 2 * SCREEN_WIDTH per mon:
|
||||
@@ -712,10 +741,15 @@ function PartyMenu:drawPanel()
|
||||
self:drawIcon(mon, self:iconX(i), 4 + (i - 1) * 16 + self:iconBob(i))
|
||||
local row = PartyMenu.rowFor(mon)
|
||||
Chrome.print(row.name, 3, nameY)
|
||||
if row.hp then Chrome.print(row.hp, 13, nameY) end
|
||||
if self.tmhm then
|
||||
local able = self:tmhmAble(mon)
|
||||
if able then Chrome.print(able, 12, dataY) end
|
||||
else
|
||||
if row.hp then Chrome.print(row.hp, 13, nameY) end
|
||||
if row.hp then self:drawHpBar(mon, 11, dataY) end
|
||||
end
|
||||
if row.status then Chrome.print(row.status, 5, dataY) end
|
||||
if row.level then Chrome.print(row.level, 8, dataY) end
|
||||
if row.hp then self:drawHpBar(mon, 11, dataY) end
|
||||
end
|
||||
|
||||
-- .end does `dec hl` twice from the row past the last nickname, so CANCEL
|
||||
|
||||
@@ -1084,16 +1084,20 @@ function Pokegear:update(_dt)
|
||||
-- answers -1 rather than backing out to the strip.
|
||||
if self.fly then return self:updateFlyMap(input) end
|
||||
if self.mode == "strip" then
|
||||
if input:wasPressed("left") then
|
||||
self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards
|
||||
elseif input:wasPressed("right") then
|
||||
self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.mode = "card"
|
||||
elseif input:wasPressed("b") then
|
||||
if self.onClose then self.onClose() end
|
||||
local stripCard = self:card()
|
||||
if not (stripCard and stripCard.id == "phone") then
|
||||
if input:wasPressed("left") then
|
||||
self.cardIndex = self.cardIndex > 1 and self.cardIndex - 1 or #self.cards
|
||||
elseif input:wasPressed("right") then
|
||||
self.cardIndex = self.cardIndex < #self.cards and self.cardIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.mode = "card"
|
||||
elseif input:wasPressed("b") then
|
||||
if self.onClose then self.onClose() end
|
||||
end
|
||||
return
|
||||
end
|
||||
return
|
||||
self.mode = "card"
|
||||
end
|
||||
-- Inside a card.
|
||||
local card = self:card()
|
||||
@@ -1105,6 +1109,19 @@ function Pokegear:update(_dt)
|
||||
-- submenu is waiting for.
|
||||
local phoneBusy = card and card.id == "phone"
|
||||
and (self.call ~= nil or self.phoneSubmenu ~= nil)
|
||||
-- engine/pokegear/pokegear.asm:799
|
||||
if card and card.id == "phone" and not phoneBusy then
|
||||
if input:wasPressed("b") then
|
||||
if self.onClose then self.onClose() end
|
||||
return
|
||||
elseif input:wasPressed("left") then
|
||||
self:switchCard("map", "clock")
|
||||
return
|
||||
elseif input:wasPressed("right") then
|
||||
self:switchCard("radio")
|
||||
return
|
||||
end
|
||||
end
|
||||
if not phoneBusy and input:wasPressed("b") then
|
||||
self.mode = "strip"
|
||||
self:stopRadio()
|
||||
@@ -2057,6 +2074,7 @@ function Pokegear:drawPlayerIcon(x, y)
|
||||
local beat = math.floor((self.iconTimer or 0) / 8)
|
||||
-- .OAMData_RedWalk (data/sprite_anims/oam.asm:314-319) hangs its four tiles
|
||||
-- at -8,-8; camY of -4 undoes the world's sprite lift.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.playerIcon:draw(x - 8, y - 8, 0, -4, "down",
|
||||
beat % 2, beat == 3)
|
||||
return true
|
||||
|
||||
@@ -261,6 +261,8 @@ function SummaryMenu.new(game, opts)
|
||||
math.min(opts.index or 1, math.max(1, #self.party)))
|
||||
self.mon = self.party[self.index]
|
||||
end
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
Mon.refreshStats(self.mon, data)
|
||||
self.page = opts.page or PINK_PAGE
|
||||
-- ManagePokemonMoves opens straight onto MoveScreenLoop's screen; SELECT off
|
||||
-- the green page reaches the same view with the stats pages still behind it.
|
||||
@@ -678,6 +680,8 @@ function SummaryMenu:switchMon(delta)
|
||||
if next_ < 1 or next_ > #self.party then return false end
|
||||
self.index = next_
|
||||
self.mon = self.party[next_]
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
Mon.refreshStats(self.mon, self.game and self.game.data)
|
||||
self.moveIndex = 1
|
||||
self:playCry()
|
||||
return true
|
||||
@@ -694,6 +698,8 @@ function SummaryMenu:switchMonPastEggs(delta)
|
||||
if next_ < 1 or next_ > #self.party then return false end
|
||||
self.index = next_
|
||||
self.mon = self.party[next_]
|
||||
-- engine/pokemon/move_mon.asm:1402
|
||||
Mon.refreshStats(self.mon, self.game and self.game.data)
|
||||
self.moveIndex = 1
|
||||
self:playCry()
|
||||
return true
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
local Anim = require("src.core.gen2.TradeAnim")
|
||||
local Assets = require("src.render.Assets")
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Music = require("src.core.Music")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
@@ -202,6 +203,15 @@ local TEMPLATE_ROWS = {
|
||||
{ row = 6, text = "<ID>№." },
|
||||
}
|
||||
|
||||
-- gfx/sgb/predef.pal:29
|
||||
local function scale5(value) return math.floor(value * 255 / 31 + 0.5) end
|
||||
local TRADE_TUBE_PAL = {
|
||||
{ scale5(31), scale5(31), scale5(31) },
|
||||
{ scale5(18), scale5(20), scale5(27) },
|
||||
{ scale5(11), scale5(15), scale5(23) },
|
||||
{ 0, 0, 0 },
|
||||
}
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- Construction
|
||||
--------------------------------------------------------------------------
|
||||
@@ -367,8 +377,22 @@ end
|
||||
-- reading through.
|
||||
-- `index` is a GB shade, 0 (white) to 3 (black); GbcPalette.color counts from
|
||||
-- 1, as the palettes themselves do.
|
||||
function TradeAnimView:bgColors()
|
||||
local id = (self.beat or {}).id or ""
|
||||
local pan = id:find("_pan_", 1, true)
|
||||
if not pan then
|
||||
return Palettes.textColors(self.palettes)
|
||||
end
|
||||
local colors = TRADE_TUBE_PAL
|
||||
-- engine/movie/trade_animation.asm:1271
|
||||
if math.floor((self.frame or 0) / 8) % 2 == 1 then
|
||||
colors = { colors[1], colors[3], colors[2], colors[4] }
|
||||
end
|
||||
return colors
|
||||
end
|
||||
|
||||
function TradeAnimView:shade(index)
|
||||
local colors = Palettes.textColors(self.palettes)
|
||||
local colors = self:bgColors()
|
||||
local rgb = GbcPalette.color(colors, index + 1)
|
||||
or ({ { 255, 255, 255 }, { 168, 168, 168 }, { 96, 96, 96 },
|
||||
{ 0, 0, 0 } })[index + 1]
|
||||
@@ -384,7 +408,7 @@ end
|
||||
-- and GbcPalette maps them. A driver with no shader draws the greys, which
|
||||
-- is the DMG ramp and not a black frame.
|
||||
function TradeAnimView:through(body)
|
||||
local colors = Palettes.textColors(self.palettes)
|
||||
local colors = self:bgColors()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
@@ -543,7 +567,7 @@ function TradeAnimView:drawStats(record, offset)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", (PANEL_X + 1) * 8, PANEL_Y * 8, 9 * 8, 8)
|
||||
for _, row in ipairs(TEMPLATE_ROWS) do
|
||||
Chrome.print(row.text, PANEL_X + 1, row.row)
|
||||
Chrome.print(Strings(row.text), PANEL_X + 1, row.row)
|
||||
end
|
||||
Chrome.print(Chrome.number(record.dex or 0, 3, true), PANEL_X + 7, 0)
|
||||
Chrome.print(record.name, PANEL_X + 1, 2)
|
||||
@@ -767,6 +791,8 @@ end
|
||||
function TradeAnimView:drawPanel()
|
||||
local id = (self.beat or {}).id
|
||||
local t = self.offset or 0
|
||||
-- engine/movie/trade_animation.asm:151
|
||||
local wasBattle = Font.useBattleExtra(true)
|
||||
Chrome.clear()
|
||||
|
||||
if GIVE_BEATS[id] then
|
||||
@@ -794,6 +820,7 @@ function TradeAnimView:drawPanel()
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.useBattleExtra(wasBattle)
|
||||
end
|
||||
|
||||
function TradeAnimView:drawTubeBeat(id, t)
|
||||
|
||||
@@ -442,6 +442,27 @@ function NPC:updateTreeShake()
|
||||
return true
|
||||
end
|
||||
|
||||
function NPC:scriptRockSmash(frames)
|
||||
-- engine/overworld/map_objects.asm:1462
|
||||
self.rockSmash = {
|
||||
frame = 0,
|
||||
frames = frames or 10,
|
||||
}
|
||||
self.frozen = true
|
||||
return true
|
||||
end
|
||||
|
||||
function NPC:updateRockSmash()
|
||||
local st = self.rockSmash
|
||||
if not st then return false end
|
||||
st.frame = st.frame + 1
|
||||
if st.frame >= st.frames then
|
||||
self.rockSmash = nil
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- `passable` is the follower's escape (src/world/gen2/Follower.lua), the same
|
||||
-- name and meaning src/world/Collision.lua:20 gives it under Gen 1.
|
||||
local function occupied(entities, tx, ty, self)
|
||||
@@ -514,6 +535,10 @@ function NPC:update(map, entities)
|
||||
self:updateTreeShake()
|
||||
return
|
||||
end
|
||||
if self.rockSmash then
|
||||
self:updateRockSmash()
|
||||
return
|
||||
end
|
||||
-- NPC_CHANGE_FACING (src/world/NPC.lua:71): one walk cycle in place, no
|
||||
-- translation. Above the moving arm because it has no targetX to reach,
|
||||
-- and the arm below would assign cellX = nil a frame later.
|
||||
@@ -726,6 +751,15 @@ function NPC:draw(ox, oy, scale)
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0,
|
||||
facing, 0, false, false, q == 3)
|
||||
elseif self.rockSmash then
|
||||
-- engine/overworld/map_objects.asm:1462
|
||||
if (self.rockSmash.frame % 2) == 0 then
|
||||
G.pop()
|
||||
return
|
||||
end
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0,
|
||||
self.facing, self:walkPhase(), self.stepFlip)
|
||||
else
|
||||
self.sprite:draw(
|
||||
self.px, self.py + yOffset, 0, 0,
|
||||
|
||||
@@ -17,6 +17,12 @@ local TURN_FRAMES = 4
|
||||
-- at the walking rate, which is what stops a bike step flickering the legs.
|
||||
Player.STEP_FRAMES = STEP_FRAMES
|
||||
|
||||
-- engine/overworld/map_objects.asm:1815
|
||||
local JUMP_Y = {
|
||||
-4, -6, -8, -10, -11, -12, -12, -12,
|
||||
-11, -10, -9, -8, -6, -4, 0, 0,
|
||||
}
|
||||
|
||||
function Player.new(cx, cy, facing, spriteDef)
|
||||
local self = setmetatable({
|
||||
cellX = cx, cellY = cy,
|
||||
@@ -170,9 +176,11 @@ function Player:update()
|
||||
self.px = self.cellX * 16 + dx * adv
|
||||
self.py = self.cellY * 16 + dy * adv
|
||||
if self.jumping then
|
||||
-- pokegold engine/overworld/map_objects.asm: UpdateJumpPosition's
|
||||
-- y_offsets table peaks at -12.
|
||||
self.py = self.py - math.floor(12 * math.sin(math.pi * self.progress / frames))
|
||||
-- engine/overworld/map_objects.asm:1815
|
||||
local idx = math.floor((self.progress - 1) / 2) + 1
|
||||
if idx < 1 then idx = 1 end
|
||||
if idx > #JUMP_Y then idx = #JUMP_Y end
|
||||
self.spriteYOffset = JUMP_Y[idx]
|
||||
end
|
||||
if self.progress >= frames then
|
||||
self.cellX, self.cellY = self.targetX, self.targetY
|
||||
@@ -180,6 +188,7 @@ function Player:update()
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.moving = false
|
||||
self.jumping = nil
|
||||
self.spriteYOffset = 0
|
||||
self.stepFlip = not self.stepFlip
|
||||
return true
|
||||
end
|
||||
@@ -193,6 +202,15 @@ function Player:draw(ox, oy, scale)
|
||||
-- standing on. StepFunction_GotBite's `xor 1` rod bob and the fly take-off
|
||||
-- lift both ride this one byte.
|
||||
local yOffset = self.spriteYOffset or 0
|
||||
if self.jumping then
|
||||
-- engine/overworld/map_objects.asm:1995
|
||||
local gx = ox + self.px * scale
|
||||
local gy = oy + self.py * scale
|
||||
local s = 16 * scale
|
||||
G.setColor(0, 0, 0, 0.4)
|
||||
G.ellipse("fill", gx + s * 0.5, gy + s * 0.85, s * 0.35, s * 0.12)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.sprite then
|
||||
G.push()
|
||||
G.translate(ox, oy)
|
||||
|
||||
@@ -621,6 +621,8 @@ function World.new(game)
|
||||
-- which is why a cut tree is back the next time you walk in. Restoring
|
||||
-- these at the top of setMap is that refill.
|
||||
blockEdits = {},
|
||||
-- engine/overworld/map_setup.asm:78
|
||||
objectSpawns = {},
|
||||
-- A field move that is mid-flow (the used-X text, then its effect).
|
||||
fieldMove = nil,
|
||||
-- ---- state the script VM owns ------------------------------------------
|
||||
@@ -1819,6 +1821,15 @@ function World:moveObject(objectId, cellX, cellY)
|
||||
local def = self.map and self.map.def
|
||||
local obj = def and def.objects and def.objects[index]
|
||||
if not (obj and cellX and cellY) then return end
|
||||
local mapId = self.map and self.map.id
|
||||
local key = obj.index or index
|
||||
if mapId then
|
||||
self.objectSpawns = self.objectSpawns or {}
|
||||
self.objectSpawns[mapId] = self.objectSpawns[mapId] or {}
|
||||
if not self.objectSpawns[mapId][key] then
|
||||
self.objectSpawns[mapId][key] = { obj.x, obj.y }
|
||||
end
|
||||
end
|
||||
obj.x, obj.y = cellX, cellY
|
||||
local npc = self:objectEntity(objectId)
|
||||
if npc and npc ~= self.player then
|
||||
@@ -3715,6 +3726,16 @@ function World:updateMovement()
|
||||
while st.i <= #st.bytes do
|
||||
local b = st.bytes[st.i]
|
||||
st.i = st.i + 1
|
||||
-- engine/overworld/movement.asm:163
|
||||
if b == 0x57 then
|
||||
local duration = st.bytes[st.i] or 0
|
||||
st.i = st.i + 1
|
||||
if ent.scriptRockSmash then
|
||||
ent:scriptRockSmash(duration)
|
||||
end
|
||||
st.sleep = duration
|
||||
return
|
||||
end
|
||||
local act = Movement.decodeByte(b)
|
||||
if act.kind == "end" then
|
||||
-- SLIDING_F is an object flag, not a stream one, so a stream that never
|
||||
@@ -5237,6 +5258,38 @@ function World:restoreBlocks()
|
||||
return any
|
||||
end
|
||||
|
||||
function World:restoreObjectSpawns()
|
||||
-- engine/overworld/map_setup.asm:78
|
||||
local spawns = self.objectSpawns
|
||||
if not spawns then return end
|
||||
for mapId, byIndex in pairs(spawns) do
|
||||
local def = self.maps and self.maps[mapId]
|
||||
local objects = def and def.objects
|
||||
if objects then
|
||||
for key, xy in pairs(byIndex) do
|
||||
local obj
|
||||
for _, row in ipairs(objects) do
|
||||
if (row.index or 0) == key then obj = row break end
|
||||
end
|
||||
if obj then
|
||||
obj.x, obj.y = xy[1], xy[2]
|
||||
end
|
||||
local npc = self.npcPool
|
||||
and self.npcPool[string.format("%s_obj_%d", mapId, key)]
|
||||
if npc then
|
||||
npc.cellX, npc.cellY = xy[1], xy[2]
|
||||
npc.px, npc.py = xy[1] * 16, xy[2] * 16
|
||||
npc.homeX, npc.homeY = xy[1], xy[2]
|
||||
npc.moving = false
|
||||
npc.progress = 0
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
end
|
||||
end
|
||||
end
|
||||
spawns[mapId] = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop the loaded map's baked canvases and bake again. Same shape as what
|
||||
-- pollTimeOfDay does when the clock rolls the palette over; a block edit
|
||||
-- invalidates the bake for the same reason a palette change does. A world with
|
||||
@@ -5360,8 +5413,10 @@ end
|
||||
-- picked, and TextBox reads it back off game.stringBuffer.
|
||||
function World:setNickname(mon)
|
||||
if not self.game then return end
|
||||
self.game.stringBuffer =
|
||||
(mon and (mon.nickname or mon.name or mon.species)) or ""
|
||||
local name = (mon and (mon.nickname or mon.name or mon.species)) or ""
|
||||
self.game.stringBuffer = name
|
||||
-- engine/events/overworld.asm:1339
|
||||
if self.vm then self.vm.stringBuffer = name end
|
||||
end
|
||||
|
||||
function World:playMonCry(mon)
|
||||
@@ -8304,6 +8359,7 @@ function World:setMap(mapId, cx, cy, facing, opts)
|
||||
-- and WHIRLPOOL swapped out goes back: a cut tree is standing again the next
|
||||
-- time the map is loaded, and this has to happen before Map.new reads them.
|
||||
self:restoreBlocks()
|
||||
self:restoreObjectSpawns()
|
||||
-- HandleNewMap (home/map.asm:216-228) runs ResetMapBufferEventFlags before
|
||||
-- anything else that touches state: event flags 0-7
|
||||
-- (EVENT_TEMPORARY_UNTIL_MAP_RELOAD) die on every map load, which is what
|
||||
@@ -8784,7 +8840,8 @@ function World:tryLedgeJump(dir)
|
||||
-- ShakeGrass (engine/overworld/movement.asm:741-770).
|
||||
p.inGrass, p.grassShake = false, nil
|
||||
p.progress = 0
|
||||
p.stepFrames = Player.STEP_FRAMES
|
||||
-- engine/overworld/map_objects.asm:1163
|
||||
p.stepFrames = Player.STEP_FRAMES * 2
|
||||
self:playSfxNamed("Sfx_JumpOverLedge", SFX_JUMP_OVER_LEDGE)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- In-game Gold SAVE with no launcher slot must leave a file CONTINUE can see.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_IDENTITY=gold-bug1107 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_save_slot_bug1107_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
local before = SaveData.listSlots("gold")
|
||||
local hadFile = false
|
||||
for _, slot in ipairs(before) do
|
||||
if slot.exists then hadFile = true break end
|
||||
end
|
||||
if not hadFile then
|
||||
local opts = SaveData.loadOptions()
|
||||
opts.saveSlots = opts.saveSlots or {}
|
||||
opts.saveSlots.gold = nil
|
||||
SaveData.saveOptions(opts)
|
||||
SaveData.resetSlotState()
|
||||
end
|
||||
|
||||
local ok, err = game:writeSave()
|
||||
if not ok then
|
||||
U.log("FAIL gold writeSave:", tostring(err))
|
||||
else
|
||||
local after = SaveData.listSlots("gold")
|
||||
local found, path
|
||||
for _, slot in ipairs(after) do
|
||||
if slot.exists then
|
||||
found = slot.id
|
||||
path = SaveData.slotDiskPath("gold", slot.id)
|
||||
break
|
||||
end
|
||||
end
|
||||
if found then
|
||||
U.log("PASS gold save is launcher-visible:", found, path or "")
|
||||
else
|
||||
U.log("FAIL gold save wrote but listSlots has no file")
|
||||
end
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Herb shop intro over the Goldenrod Underground map (#1169).
|
||||
-- pokegold engine/items/mart.asm:54 HerbShop, maps/GoldenrodUnderground.asm:158
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/herbshop_bug1169_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("SHOT_DIR") or os.getenv("POKEPORT_SHOT_DIR")
|
||||
or "/tmp/bug1169"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
-- maps/GoldenrodUnderground.asm:52
|
||||
world.clockDay = 0
|
||||
world.mapScenes = world.mapScenes or {}
|
||||
-- maps/GoldenrodUnderground.asm:679
|
||||
assert(world:setMap("GOLDENROD_UNDERGROUND", 6, 21, "right"),
|
||||
"setMap GOLDENROD_UNDERGROUND failed")
|
||||
U.wait(8)
|
||||
|
||||
local granny
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_GRANNY" then granny = npc break end
|
||||
end
|
||||
if not granny then
|
||||
U.log("FAIL granny not on the map (need Sunday)")
|
||||
end
|
||||
|
||||
local MartMenu = require("src.ui.gen2.MartMenu")
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
for _ = 1, 90 do
|
||||
if getmetatable(game.stack:top()) == MartMenu then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) ~= MartMenu then
|
||||
U.log("FAIL mart did not open")
|
||||
elseif top.isOpaque then
|
||||
U.log("FAIL mart is opaque")
|
||||
else
|
||||
U.log("herb shop intro over the map")
|
||||
end
|
||||
U.shot(game, out .. "/herbshop_intro.png")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Kurt stays at moveobject (11, 6) for the visit, then ROM spawn (16, 14)
|
||||
-- on the next load.
|
||||
-- #1184
|
||||
-- maps/SlowpokeWellB1F.asm:48 / engine/overworld/map_setup.asm:78
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/kurt_well_bug1184_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local KURT_ID = 8
|
||||
local ROM_X, ROM_Y = 16, 14
|
||||
local MOVE_X, MOVE_Y = 11, 6
|
||||
local FLAG_KURT = 1856
|
||||
local FLAG_ROCKETS = 1788
|
||||
|
||||
local function findKurt(world)
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_KURT" then return npc end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function kurtDef(world)
|
||||
local objects = world.map and world.map.def and world.map.def.objects
|
||||
return objects and objects[KURT_ID - 1]
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
world.mapScenes = world.mapScenes or {}
|
||||
world.mapScenes.PLAYERS_HOUSE_1F = 1
|
||||
world.mapScenes.NEW_BARK_TOWN = 1
|
||||
world.events:set(FLAG_KURT, false)
|
||||
world.events:set(FLAG_ROCKETS, true)
|
||||
|
||||
assert(world:setMap("SLOWPOKE_WELL_B1F", 15, 14, "right"),
|
||||
"SLOWPOKE_WELL_B1F did not load")
|
||||
U.wait(4)
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function claim(ok, text)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", text)
|
||||
end
|
||||
|
||||
local kurt = findKurt(world)
|
||||
claim(kurt ~= nil, "Kurt is on the well map")
|
||||
claim(kurt and kurt.cellX == ROM_X and kurt.cellY == ROM_Y,
|
||||
("Kurt ROM spawn is (%d, %d), saw (%s, %s)")
|
||||
:format(ROM_X, ROM_Y,
|
||||
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
|
||||
|
||||
world:moveObject(KURT_ID, MOVE_X, MOVE_Y)
|
||||
world:appearObject(KURT_ID)
|
||||
U.wait(4)
|
||||
|
||||
kurt = findKurt(world)
|
||||
claim(kurt and kurt.cellX == MOVE_X and kurt.cellY == MOVE_Y,
|
||||
("after moveobject Kurt is at (%d, %d), saw (%s, %s)")
|
||||
:format(MOVE_X, MOVE_Y,
|
||||
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
|
||||
|
||||
assert(world:setMap("PLAYERS_HOUSE_1F", 3, 3, "down"),
|
||||
"PLAYERS_HOUSE_1F did not load")
|
||||
U.wait(2)
|
||||
world.events:set(FLAG_KURT, false)
|
||||
assert(world:setMap("SLOWPOKE_WELL_B1F", 15, 14, "right"),
|
||||
"SLOWPOKE_WELL_B1F did not reload")
|
||||
U.wait(4)
|
||||
|
||||
local def = kurtDef(world)
|
||||
kurt = findKurt(world)
|
||||
claim(def and def.x == ROM_X and def.y == ROM_Y,
|
||||
("reload restored Kurt def to (%d, %d), saw (%s, %s)")
|
||||
:format(ROM_X, ROM_Y,
|
||||
tostring(def and def.x), tostring(def and def.y)))
|
||||
claim(kurt and kurt.cellX == ROM_X and kurt.cellY == ROM_Y,
|
||||
("reload put Kurt at (%d, %d), not the blocking cell (%d, %d); saw (%s, %s)")
|
||||
:format(ROM_X, ROM_Y, MOVE_X, MOVE_Y,
|
||||
tostring(kurt and kurt.cellX), tostring(kurt and kurt.cellY)))
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
U.log("Kurt should be at the well entrance, not on the inner path.")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Fury Attack / Barrage hit 2-5 times, Twineedle stays at 2. Issue #1168.
|
||||
-- pokegold engine/battle/effect_commands.asm:5228 (BattleCommand_EndLoop).
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/multihit_bug1168_test.lua love .
|
||||
-- Do not add POKEPORT_SPEED: the per-strike HP steps are what you are judging.
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Effects = require("src.battle.gen2.Effects")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local function claim(ok, text)
|
||||
print((ok and "[multihit] PASS " or "[multihit] FAIL ") .. text)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
if not (world and world.map) then
|
||||
print("[multihit] FAIL gold world did not boot")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local fury = game.data.moves and game.data.moves.FURY_ATTACK
|
||||
local twine = game.data.moves and game.data.moves.TWINEEDLE
|
||||
claim(fury ~= nil, "FURY_ATTACK is in the move table")
|
||||
claim(fury and fury.effect == "EFFECT_MULTI_HIT",
|
||||
"FURY_ATTACK is EFFECT_MULTI_HIT")
|
||||
claim(twine ~= nil, "TWINEEDLE is in the move table")
|
||||
claim(twine and twine.effect == "EFFECT_POISON_MULTI_HIT",
|
||||
"TWINEEDLE is EFFECT_POISON_MULTI_HIT")
|
||||
claim(Effects.hitCount("EFFECT_DOUBLE_HIT") == 2, "DOUBLE_HIT is 2")
|
||||
claim(Effects.hitCount("EFFECT_POISON_MULTI_HIT") == 2,
|
||||
"POISON_MULTI_HIT (Twineedle) is 2")
|
||||
|
||||
local seq, si = { 0, 1, 2, 0, 3, 3 }, 0
|
||||
local function scripted()
|
||||
si = si + 1
|
||||
return seq[si] or 0
|
||||
end
|
||||
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 2, "roll 0 -> 2 hits")
|
||||
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 3, "roll 1 -> 3 hits")
|
||||
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 2,
|
||||
"roll 2 then 0 -> 2 hits")
|
||||
claim(Effects.hitCount("EFFECT_MULTI_HIT", scripted) == 5,
|
||||
"roll 3 then 3 -> 5 hits")
|
||||
|
||||
local function fiveHitRandom(n)
|
||||
if n == 4 then return 3 end
|
||||
return 0
|
||||
end
|
||||
|
||||
local headP = Mon.new(game.data, "CYNDAQUIL", 20)
|
||||
local headW = Mon.new(game.data, "SNORLAX", 20)
|
||||
if headP and headW then
|
||||
headP.moves = { { id = "FURY_ATTACK", pp = 20, maxPp = 20 } }
|
||||
local b = Battle.new({
|
||||
data = game.data, party = { headP }, wild = headW,
|
||||
random = fiveHitRandom,
|
||||
})
|
||||
local landed, hitLine = 0, nil
|
||||
for _, ev in ipairs(b:takeTurn({ kind = "move", move = "FURY_ATTACK" })) do
|
||||
if ev.kind == "damage" and ev.side == "enemy" then
|
||||
landed = landed + 1
|
||||
end
|
||||
if ev.kind == "message" and ev.text then
|
||||
local n = ev.text:match("Hit (%d+) time")
|
||||
if n then hitLine, landed = ev.text, tonumber(n) end
|
||||
end
|
||||
end
|
||||
claim(landed == 5,
|
||||
("scripted Fury Attack hit %d times (want 5, not 2)"):format(landed))
|
||||
if hitLine then print("[multihit] " .. hitLine) end
|
||||
else
|
||||
claim(false, "could not build a headless Fury Attack pair")
|
||||
end
|
||||
|
||||
local player = Mon.new(game.data, "CYNDAQUIL", 20)
|
||||
if not player then
|
||||
print("[multihit] FAIL could not build CYNDAQUIL")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
player.moves = { { id = "FURY_ATTACK", pp = 20, maxPp = 20 } }
|
||||
game.save.party = { player }
|
||||
|
||||
local wild = Mon.new(game.data, "SNORLAX", 20)
|
||||
if not wild then
|
||||
print("[multihit] FAIL could not build SNORLAX")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
if not world:startBattle({ wild = wild }) then
|
||||
print("[multihit] FAIL startBattle failed")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local screen
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then screen = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not (screen and screen.battle) then
|
||||
print("[multihit] FAIL battle screen never came up")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local left = 2
|
||||
screen.battle.random = function(n)
|
||||
if n == 4 and left > 0 then
|
||||
left = left - 1
|
||||
return 3
|
||||
end
|
||||
if left > 0 then return 0 end
|
||||
if love and love.math and love.math.random then
|
||||
return love.math.random(n) - 1
|
||||
end
|
||||
return math.random(n) - 1
|
||||
end
|
||||
|
||||
for _ = 1, 200 do
|
||||
if screen.phase == "menu" then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
if screen.phase ~= "menu" then
|
||||
print("[multihit] FAIL never reached the battle menu")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
tap("a")
|
||||
U.wait(6)
|
||||
tap("a")
|
||||
print("[multihit] Fury Attack should strike five times on this turn.")
|
||||
print("[multihit] later turns are 2-5. Twineedle would stay at 2.")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
-- Radio Tower 5F director returns to the office after a downstairs/upstairs
|
||||
-- reload, not on the stair warp at (12, 0).
|
||||
-- #1164 / #1188
|
||||
-- maps/RadioTower5F.asm:115 / engine/overworld/map_setup.asm:78
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/radiotower_softlock_bug1164_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local DIRECTOR_ID = 2
|
||||
local OFFICE_X, OFFICE_Y = 3, 6
|
||||
local STAIR_X, STAIR_Y = 12, 0
|
||||
local FLAG_ROCKETS = 1742
|
||||
local FLAG_CIVILIANS = 1744
|
||||
|
||||
local function findDirector(world)
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_GENTLEMAN" then return npc end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function directorDef(world)
|
||||
local objects = world.map and world.map.def and world.map.def.objects
|
||||
return objects and objects[DIRECTOR_ID - 1]
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
world.mapScenes = world.mapScenes or {}
|
||||
world.mapScenes.RADIO_TOWER_5F = 2
|
||||
world.mapScenes.RADIO_TOWER_4F = 0
|
||||
world.events:set(FLAG_ROCKETS, true)
|
||||
world.events:set(FLAG_CIVILIANS, false)
|
||||
|
||||
assert(world:setMap("RADIO_TOWER_5F", 10, 4, "down"),
|
||||
"RADIO_TOWER_5F did not load")
|
||||
U.wait(4)
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function claim(ok, text)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", text)
|
||||
end
|
||||
|
||||
local director = findDirector(world)
|
||||
claim(director ~= nil, "director is on 5F")
|
||||
claim(director and director.cellX == OFFICE_X and director.cellY == OFFICE_Y,
|
||||
("director office spawn is (%d, %d), saw (%s, %s)")
|
||||
:format(OFFICE_X, OFFICE_Y,
|
||||
tostring(director and director.cellX),
|
||||
tostring(director and director.cellY)))
|
||||
|
||||
world:moveObject(DIRECTOR_ID, STAIR_X, STAIR_Y)
|
||||
world:appearObject(DIRECTOR_ID)
|
||||
U.wait(4)
|
||||
|
||||
director = findDirector(world)
|
||||
claim(director and director.cellX == STAIR_X and director.cellY == STAIR_Y,
|
||||
("after moveobject director is at the stairs (%d, %d), saw (%s, %s)")
|
||||
:format(STAIR_X, STAIR_Y,
|
||||
tostring(director and director.cellX),
|
||||
tostring(director and director.cellY)))
|
||||
|
||||
assert(world:setMap("RADIO_TOWER_4F", 12, 4, "down"),
|
||||
"RADIO_TOWER_4F did not load")
|
||||
U.wait(2)
|
||||
assert(world:setMap("RADIO_TOWER_5F", 10, 4, "down"),
|
||||
"RADIO_TOWER_5F did not reload")
|
||||
U.wait(4)
|
||||
|
||||
local def = directorDef(world)
|
||||
director = findDirector(world)
|
||||
claim(def and def.x == OFFICE_X and def.y == OFFICE_Y,
|
||||
("reload restored director def to (%d, %d), saw (%s, %s)")
|
||||
:format(OFFICE_X, OFFICE_Y,
|
||||
tostring(def and def.x), tostring(def and def.y)))
|
||||
claim(not (director and director.cellX == STAIR_X and director.cellY == STAIR_Y),
|
||||
"director is not standing on the stair warp")
|
||||
claim(director and director.cellX == OFFICE_X and director.cellY == OFFICE_Y,
|
||||
("reload put director in the office (%d, %d); saw (%s, %s)")
|
||||
:format(OFFICE_X, OFFICE_Y,
|
||||
tostring(director and director.cellX),
|
||||
tostring(director and director.cellY)))
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
U.log("The gentleman should be in the office. The stairs must be clear.")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Route 40 smashable rocks stay on their cell through rock_smash, then a
|
||||
-- seamless Olivine round trip.
|
||||
-- #1173
|
||||
-- engine/overworld/movement.asm:163 / maps/Route40.asm:291
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/rocksmash_shift_bug1173_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local ROCK_ID = 6
|
||||
local ROCK_X, ROCK_Y = 12, 8
|
||||
local SMASH = { 0x57, 10, 0x47 }
|
||||
|
||||
local function findRock(world, x, y)
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_ROCK"
|
||||
and npc.cellX == x and npc.cellY == y then
|
||||
return npc
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function rockAt(world, x, y)
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.sprite == "SPRITE_ROCK"
|
||||
and npc.cellX == x and npc.cellY == y then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
world.mapScenes = world.mapScenes or {}
|
||||
world.mapScenes.ROUTE_40 = 0
|
||||
world.mapScenes.OLIVINE_CITY = 0
|
||||
|
||||
assert(world:setMap("ROUTE_40", 12, 9, "up"), "ROUTE_40 did not load")
|
||||
U.wait(4)
|
||||
|
||||
local pass, fail = 0, 0
|
||||
local function claim(ok, text)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", text)
|
||||
end
|
||||
|
||||
local rock = findRock(world, ROCK_X, ROCK_Y)
|
||||
claim(rock ~= nil, ("rock is at (%d, %d)"):format(ROCK_X, ROCK_Y))
|
||||
|
||||
world:beginMovement(ROCK_ID, SMASH)
|
||||
for _ = 1, 40 do
|
||||
if not world.moveState then break end
|
||||
U.wait(1)
|
||||
end
|
||||
claim(world.moveState == nil, "rock_smash stream finished")
|
||||
|
||||
rock = findRock(world, ROCK_X, ROCK_Y)
|
||||
claim(rock ~= nil,
|
||||
("after rock_smash the rock is still at (%d, %d), not one cell left")
|
||||
:format(ROCK_X, ROCK_Y))
|
||||
claim(not rockAt(world, ROCK_X - 1, ROCK_Y),
|
||||
"no smashable rock slid one cell left")
|
||||
|
||||
assert(world:setMap("OLIVINE_CITY", 18, 15, "down", { seamless = true }),
|
||||
"OLIVINE_CITY did not load")
|
||||
U.wait(2)
|
||||
assert(world:setMap("ROUTE_40", 12, 9, "up", { seamless = true }),
|
||||
"ROUTE_40 did not reload")
|
||||
U.wait(4)
|
||||
|
||||
claim(rockAt(world, ROCK_X, ROCK_Y),
|
||||
("after Olivine round trip the rock is still at (%d, %d)")
|
||||
:format(ROCK_X, ROCK_Y))
|
||||
claim(not rockAt(world, ROCK_X - 1, ROCK_Y),
|
||||
"seamless reload did not shift the rock left")
|
||||
|
||||
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
||||
U.log("The three rocks should sit on (12, 8), (11, 7), (13, 6).")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
-- After a wild faint, "Use next POKéMON?" yes/no; NO tries to run. Issue #1152.
|
||||
-- pokegold engine/battle/core.asm:2590 (AskUseNextPokemon).
|
||||
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/use_next_mon_bug1152_test.lua love .
|
||||
-- Do not add POKEPORT_SPEED: you need the yes/no box to sit there.
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local function claim(ok, text)
|
||||
print((ok and "[use-next] PASS " or "[use-next] FAIL ") .. text)
|
||||
return ok
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
if not (world and world.map) then
|
||||
print("[use-next] FAIL gold world did not boot")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local lead = Mon.new(game.data, "SENTRET", 5)
|
||||
local backup = Mon.new(game.data, "CYNDAQUIL", 12)
|
||||
if not (lead and backup) then
|
||||
print("[use-next] FAIL could not build the party")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
lead.hp = 1
|
||||
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
backup.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
game.save.party = { lead, backup }
|
||||
|
||||
local wild = Mon.new(game.data, "PIDGEY", 20)
|
||||
if not wild then
|
||||
print("[use-next] FAIL could not build a wild PIDGEY")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
if not world:startBattle({ wild = wild }) then
|
||||
print("[use-next] FAIL startBattle failed")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
local screen
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then screen = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not (screen and screen.battle) then
|
||||
print("[use-next] FAIL battle screen never came up")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
for _ = 1, 200 do
|
||||
if screen.phase == "menu" then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
if screen.phase ~= "menu" then
|
||||
print("[use-next] FAIL never reached the battle menu")
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
|
||||
tap("a")
|
||||
U.wait(6)
|
||||
tap("a")
|
||||
|
||||
local sawAsk, sawForced = false, false
|
||||
for _ = 1, 400 do
|
||||
if screen.phase == "ask-next-mon" then sawAsk = true break end
|
||||
if screen.phase == "forced-switch" or screen.phase == "submenu" then
|
||||
sawForced = true
|
||||
break
|
||||
end
|
||||
if screen.battle.over then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
|
||||
claim(sawAsk, "wild faint opened Use next POKéMON?")
|
||||
claim(not sawForced, "wild faint did not skip to the party list")
|
||||
claim(screen.phase == "ask-next-mon",
|
||||
"phase is ask-next-mon (YES switches, NO/B tries to run)")
|
||||
print("[use-next] YES sends you to the party. NO or B tries to run.")
|
||||
print("[use-next] a failed run still opens the party. trainers skip this.")
|
||||
|
||||
while true do U.wait(60) end
|
||||
end
|
||||
Reference in New Issue
Block a user