mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 05:00:43 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 872d6b4516 | |||
| def270f7c7 | |||
| 93e336b7cb | |||
| 4c8c1cf36b | |||
| 7d9e99ea18 | |||
| b27e5ab017 | |||
| fd9f3da91a | |||
| a7c19be88f | |||
| 9ab80adaca | |||
| 518d61e039 | |||
| 4349a1142f | |||
| 9713977755 | |||
| 63448ca640 | |||
| b36d38815f |
@@ -0,0 +1 @@
|
||||
* @bryanthaboi
|
||||
@@ -53,7 +53,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. *
|
||||
|
||||
### Watch the latest update video
|
||||
|
||||
[](https://www.youtube.com/watch?v=8IOgqbe4YvA)
|
||||
[](https://youtu.be/yi7LkWQPKKM)
|
||||
|
||||
|
||||
This project does not include a ROM, emulate the Game Boy, transpile assembly,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
@@ -5,8 +5,27 @@
|
||||
-- voucher exchange and the BICYCLE/CANCEL price window need more than
|
||||
-- command rows (#568).
|
||||
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
-- data/events/hidden_events.asm:542
|
||||
local BIKE_DISPLAYS = {
|
||||
{ 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 },
|
||||
}
|
||||
|
||||
return {
|
||||
BIKE_SHOP = {
|
||||
-- engine/events/hidden_events/new_bike.asm:1
|
||||
onInteract = function(game, ow, fx, fy)
|
||||
for _, c in ipairs(BIKE_DISPLAYS) do
|
||||
if c[1] == fx and c[2] == fy then
|
||||
game.stack:push(TextBox.new(game,
|
||||
(game.data.text or {})._NewBicycleText or "A shiny new\nBICYCLE!"))
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end,
|
||||
|
||||
talk = {
|
||||
-- BikeShopMiddleAgedWomanText (pokered/scripts/BikeShop.asm):
|
||||
-- always shows the same flavor line, no branching.
|
||||
|
||||
@@ -168,6 +168,7 @@ return {
|
||||
{ "jump_if_true", "come_see" },
|
||||
{ "set_flag", "EVENT_GOT_POKEBALLS_FROM_OAK" },
|
||||
{ "give_item", "POKE_BALL", 5, false },
|
||||
{ "text_sound", "Get_Key_Item" }, -- OaksLab.asm:1060
|
||||
{ "show_text", "_OaksLabOak1ReceivedPokeballsText" },
|
||||
{ "show_text", "_OaksLabGivePokeballsExplanationText" },
|
||||
{ "jump", "end" },
|
||||
|
||||
+11
-1
@@ -516,7 +516,17 @@ M.ROUTE_24 = {
|
||||
push(game, text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
-- scripts/Route24.asm:125
|
||||
ow:engageTrainer(npc, function()
|
||||
if ow:trainerDefeated(npc) then
|
||||
-- scripts/Route24.asm:62
|
||||
push(game,
|
||||
text(game)._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
done)
|
||||
else
|
||||
done()
|
||||
end
|
||||
end, text(game)._Route24CooltrainerM1DefeatedText, true)
|
||||
end
|
||||
end
|
||||
if not flags.EVENT_GOT_NUGGET then
|
||||
|
||||
@@ -7,9 +7,9 @@ local M = {}
|
||||
|
||||
local function text(game) return game.data.text end
|
||||
|
||||
local function push(game, s, done)
|
||||
local function push(game, s, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, s, done))
|
||||
game.stack:push(TextBox.new(game, s, done, opts))
|
||||
end
|
||||
|
||||
-- PrintText on a text_end string returns with the box still drawn and
|
||||
@@ -236,7 +236,6 @@ M.CINNABAR_GYM = {
|
||||
if yes == machine.yes then
|
||||
-- CinnabarGymQuizCorrectText: item jingle, then the gate
|
||||
-- slides open (SFX_GO_INSIDE) if it was still locked
|
||||
Sound.play(game.data, "Get_Item1")
|
||||
push(game, t._CinnabarGymQuizCorrectText
|
||||
or "You're absolutely\ncorrect!\fGo on through!", function()
|
||||
if not game.save.flags[gymGateFlag(index)] then
|
||||
@@ -244,7 +243,9 @@ M.CINNABAR_GYM = {
|
||||
Sound.play(game.data, "Go_Inside")
|
||||
end
|
||||
applyGymGates(game, ow)
|
||||
end)
|
||||
end, { preSound = function()
|
||||
return Sound.play(game.data, "Get_Item1")
|
||||
end })
|
||||
return
|
||||
end
|
||||
Sound.play(game.data, "Denied")
|
||||
|
||||
@@ -17,9 +17,9 @@ local function surfingPikachu(game)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function push(game, text, done)
|
||||
local function push(game, text, done, opts)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
game.stack:push(TextBox.new(game, text, done))
|
||||
game.stack:push(TextBox.new(game, text, done, opts))
|
||||
end
|
||||
|
||||
-- the two-variant posters: the surf-capable line once a surfing
|
||||
@@ -69,11 +69,11 @@ return {
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_PIKACHU = function(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
-- scripts/SummerBeachHouse.asm:68
|
||||
push(game, t._SummerBeachHousePikachuText or "PIKACHU: Pikaa!",
|
||||
function()
|
||||
require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||
done()
|
||||
end)
|
||||
done, { auto = { wait = true, delay = 0, sound = function()
|
||||
return require("src.core.Sound").playCry(game.data, "PIKACHU")
|
||||
end } })
|
||||
end,
|
||||
|
||||
TEXT_SUMMERBEACHHOUSE_POSTER1 = poster(1),
|
||||
|
||||
@@ -11,7 +11,7 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
||||
* **Persistent custom options** stored separately from game saves
|
||||
* **Optional widescreen battle layout**
|
||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
||||
* **Touch skins** in RetroArch overlay format and Delta `.deltaskin` (including PDF-wrapped bezel art), with per-button press states and Super Game Boy borders
|
||||
* **Pokédex diploma and printer image exports**
|
||||
|
||||
## Gen 2 Specifics
|
||||
|
||||
+14
-9
@@ -95,18 +95,22 @@ auto-rotates like a RetroArch one. Item `frame` rects are top-left plus size in
|
||||
`extendedEdges` merge per key into the reach fields; `mask: "circle"` becomes a
|
||||
radial hitbox. A `dpad` or `thumbstick` item expands into the 3x3 grid, so the
|
||||
corners fire two directions. `screens[1].outputFrame` (or the legacy
|
||||
`gameScreenFrame`) becomes the screen cutout, and the skin stretches to the
|
||||
window the way Delta does rather than letterboxing. Host functions map to
|
||||
`gameScreenFrame`) becomes the screen cutout. A portrait page with neither
|
||||
keeps `mappingSize` as the overlay aspect, sits at the bottom of the
|
||||
window, and puts the Game Boy picture in the leftover space above -- the
|
||||
usual GBA4iOS controller-deck layout. Pages that name a screen rect still
|
||||
stretch to the window the way Delta does. Host functions map to
|
||||
engine hotkeys: `menu` to `menu_toggle`, `fastForward` to
|
||||
`hold_fast_forward`, `toggleFastForward` to `toggle_fast_forward`;
|
||||
`quickSave` and `quickLoad` have nothing to bind to and drop to decoration.
|
||||
Both `com.rileytestut.delta.game.*` and Manic's `public.aoshuang.game.*`
|
||||
identifiers are accepted, and a non Game Boy system warns instead of failing.
|
||||
|
||||
PDF artwork is the one thing that does not come across: Delta's own templates
|
||||
are all-PDF and this engine has no rasterizer, so such a skin is refused with
|
||||
the message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files are
|
||||
an older, incompatible schema and are refused by name.
|
||||
PDF artwork is usually a JPEG wrapped so iOS can scale it (Delta's
|
||||
Image-to-PDF skins, Preview exports, and the like). Import extracts that
|
||||
JPEG and draws it; a true vector PDF with no embedded image is still refused,
|
||||
with a message asking for a PNG version. GBA4iOS `.gbcskin` / `.gbaskin` files
|
||||
are an older, incompatible schema and are refused by name.
|
||||
|
||||
## Bindable actions
|
||||
|
||||
@@ -268,6 +272,7 @@ exported file** opens that folder.
|
||||
|
||||
## Not implemented
|
||||
|
||||
Delta skins whose art is PDF only. Rasterizing them needs a PDF renderer this
|
||||
engine does not carry, so they are refused with a message rather than imported
|
||||
half-drawn.
|
||||
True vector Delta skins (PDF artwork with no embedded JPEG). Those still need
|
||||
a PDF renderer this engine does not carry, so they are refused with a message
|
||||
rather than imported half-drawn. PDF files that wrap a JPEG, the usual Delta
|
||||
skin case, extract on import.
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.2.9",
|
||||
"date": "2026-08-19",
|
||||
"size": 13656293,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.9/gen1recomp++-0.2.9-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.2.8",
|
||||
"date": "2026-08-19",
|
||||
"size": 13653911,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.8/gen1recomp++-0.2.8-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1502 Gold doesn't show trainer balls\n- #1533 Retroarch Skin Problem 2 (#1503)\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @castdrian\n- @thibautbus"
|
||||
},
|
||||
{
|
||||
"version": "0.2.7",
|
||||
"date": "2026-08-18",
|
||||
|
||||
@@ -6,6 +6,27 @@
|
||||
// Registered from a constructor so no LÖVE/SDL source needs to know about it.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import <sys/utsname.h>
|
||||
|
||||
@interface GRDeviceBridge : NSObject
|
||||
+ (NSString *)deviceModel;
|
||||
@end
|
||||
|
||||
@implementation GRDeviceBridge
|
||||
+ (NSString *)deviceModel
|
||||
{
|
||||
#if TARGET_OS_SIMULATOR
|
||||
NSString *simulatorModel = NSProcessInfo.processInfo.environment[@"SIMULATOR_MODEL_IDENTIFIER"];
|
||||
if (simulatorModel.length > 0) return simulatorModel;
|
||||
#endif
|
||||
struct utsname systemInfo;
|
||||
if (uname(&systemInfo) == 0) {
|
||||
NSString *model = [NSString stringWithUTF8String:systemInfo.machine];
|
||||
if (model.length > 0) return model;
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
@end
|
||||
|
||||
__attribute__((constructor))
|
||||
static void GRBootstrapInstall(void)
|
||||
|
||||
@@ -156,6 +156,7 @@ int w_syncHealthSteps(lua_State *L)
|
||||
""" % MARKER
|
||||
|
||||
WRAP_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "getDeviceModel", w_getDeviceModel },
|
||||
{ "pickFile", w_pickFile },
|
||||
{ "pickFileKinds", w_pickFileKinds },
|
||||
{ "createFile", w_createFile },
|
||||
@@ -202,6 +203,7 @@ int w_syncHealthSteps(lua_State *L)
|
||||
"""
|
||||
|
||||
WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
||||
{ "getDeviceModel", w_getDeviceModel },
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpRequest", w_httpRequest },
|
||||
@@ -210,6 +212,33 @@ WRAP_SYNC_REGISTRATION = """#ifdef LOVE_IOS
|
||||
|
||||
BRIDGE_EXTRA_FUNCS = """
|
||||
#ifdef LOVE_IOS
|
||||
int w_getDeviceModel(lua_State *L)
|
||||
{
|
||||
Class cls = objc_getClass("GRDeviceBridge");
|
||||
if (cls == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
typedef id (*GRObj)(Class, SEL);
|
||||
id value = ((GRObj)objc_msgSend)(cls, sel_registerName("deviceModel"));
|
||||
if (value == nullptr)
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
typedef const char *(*GRUTF8)(id, SEL);
|
||||
const char *bytes = ((GRUTF8)objc_msgSend)(value,
|
||||
sel_registerName("UTF8String"));
|
||||
if (bytes == nullptr || bytes[0] == '\\0')
|
||||
{
|
||||
lua_pushnil(L);
|
||||
return 1;
|
||||
}
|
||||
lua_pushstring(L, bytes);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_httpDownload(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
|
||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 318 B |
|
Before Width: | Height: | Size: 687 B After Width: | Height: | Size: 687 B |
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
Format: [keep a changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
Version headings match `manifest.json`'s `version`.
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- `intro.oak_speech.build` wrap that injects toast / MEW / snack / rival-trust / pineapple beats.
|
||||
- Answers written to `mod.save` via `intro.oak_speech.answered`.
|
||||
- Custom `toast_kid.png` sprite shown mid-speech.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Silly Oak Intro Example
|
||||
|
||||
Hooks Oak's NEW GAME speech: extra questions, sprite swaps (Oak, rival,
|
||||
player, MEW, and a custom Toast Kid pic), and answers stored in `mod.save`.
|
||||
|
||||
## Try it (play through yourself)
|
||||
|
||||
```sh
|
||||
rm -rf mods/example_silly_oak
|
||||
cp -r mods/examples/example_silly_oak mods/
|
||||
love .
|
||||
```
|
||||
|
||||
Then **NEW GAME** and mash A / pick the menus. Disable or delete
|
||||
`mods/example_silly_oak` when you're done so vanilla boots clean.
|
||||
|
||||
## Headless check
|
||||
|
||||
```sh
|
||||
luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||
```
|
||||
|
||||
## Auto driver (screenshots + save asserts)
|
||||
|
||||
```sh
|
||||
rm -rf mods/example_silly_oak
|
||||
cp -r mods/examples/example_silly_oak mods/
|
||||
SHOT_DIR=/tmp/silly_oak POKEPORT_IDENTITY=silly_oak_driver_test \
|
||||
POKEPORT_DRIVER=tests/drivers/silly_oak_intro_test.lua POKEPORT_SPEED=8 love .
|
||||
```
|
||||
|
||||
`POKEPORT_IDENTITY` keeps this run's save out of your normal slot.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
| Seam | Where |
|
||||
|---|---|
|
||||
| `hooks:wrap("intro.oak_speech.build")` | `main.lua` -- reshape the step list |
|
||||
| `mod.ui.insertStepAfter` / `insertStepBefore` | `main.lua` -- anchored on vanilla step ids |
|
||||
| step kinds `say` / `yesno` / `choice` | `main.lua` |
|
||||
| pics: `"oak"`, `"rival"`, `"player"`, pokemon, custom image | `main.lua` |
|
||||
| `events:on("intro.oak_speech.answered")` | `main.lua` → `mod.save` |
|
||||
| `events:on("intro.oak_speech.finished")` | `main.lua` |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 B |
@@ -0,0 +1,107 @@
|
||||
-- Gallery entry: reshape Oak's intro speech with extra questions, sprite
|
||||
-- swaps (oak / rival / player / pokemon / a custom image), and answers
|
||||
-- that land in mod.save.
|
||||
--
|
||||
-- Uses hooks:wrap("intro.oak_speech.build") plus intro.oak_speech.answered.
|
||||
|
||||
return function(mod)
|
||||
local toastPic = mod.path .. "/assets/toast_kid.png"
|
||||
|
||||
mod.hooks:wrap("intro.oak_speech.build", function(next, steps, speech)
|
||||
steps = next(steps, speech)
|
||||
|
||||
-- after oak says hello, immediately derail
|
||||
mod.ui.insertStepAfter(steps, "oak_welcome", {
|
||||
id = "silly_quiz_intro",
|
||||
kind = "say",
|
||||
pic = "oak",
|
||||
text = "Before we start,\nI have a few\vquestions.\fImportant ones.\nScientific ones.",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_quiz_intro", {
|
||||
id = "silly_toast",
|
||||
kind = "yesno",
|
||||
pic = "oak",
|
||||
saveKey = "likes_toast",
|
||||
text = "Do you like\ntoast?",
|
||||
})
|
||||
|
||||
-- brand new sprite mid-speech
|
||||
mod.ui.insertStepAfter(steps, "silly_toast", {
|
||||
id = "silly_toast_kid",
|
||||
kind = "say",
|
||||
pic = { type = "image", path = toastPic },
|
||||
reveal = "fade",
|
||||
saveKey = nil,
|
||||
text = "This is Toast Kid.\nHe is not a\vPOKéMON.\fHe just showed up\none day.\fAnyway.",
|
||||
})
|
||||
|
||||
-- existing mon with a wipe + cry, parked after the real demo mon
|
||||
mod.ui.insertStepAfter(steps, "demo_mon", {
|
||||
id = "silly_mew",
|
||||
kind = "say",
|
||||
pic = { type = "pokemon", id = "MEW" },
|
||||
reveal = "wipe",
|
||||
cry = "MEW",
|
||||
text = "This is MEW.\nPlease do not\vtell anyone\vI showed you.",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_mew", {
|
||||
id = "silly_snack",
|
||||
kind = "choice",
|
||||
pic = "oak",
|
||||
saveKey = "snack",
|
||||
text = "Pick a snack.\nThis goes on\vyour permanent\vrecord.",
|
||||
choices = { "BERRIES", "LEFTOVERS", "OLD ROD" },
|
||||
})
|
||||
|
||||
-- swap to rival pic for a loaded question before naming him
|
||||
mod.ui.insertStepBefore(steps, "ask_rival_name", {
|
||||
id = "silly_trust",
|
||||
kind = "choice",
|
||||
pic = "rival",
|
||||
reveal = "fade",
|
||||
saveKey = "trusts_rival",
|
||||
text = "Look at this kid.\nTrustworthy?",
|
||||
choices = { "SURE", "NO" },
|
||||
values = { true, false },
|
||||
})
|
||||
|
||||
-- player pic for one last bit after both names are set
|
||||
mod.ui.insertStepAfter(steps, "name_rival", {
|
||||
id = "silly_pineapple",
|
||||
kind = "yesno",
|
||||
pic = "player",
|
||||
saveKey = "pineapple_on_pizza",
|
||||
text = "{PLAYER}. Be honest.\nPineapple on\vpizza?",
|
||||
})
|
||||
|
||||
mod.ui.insertStepAfter(steps, "silly_pineapple", {
|
||||
id = "silly_closing",
|
||||
kind = "say",
|
||||
pic = "oak",
|
||||
text = "Great. Terrible.\nI have notes.\fLet's pretend this\nwas normal.",
|
||||
})
|
||||
|
||||
return steps
|
||||
end)
|
||||
|
||||
-- every answered step with a saveKey lands in mod.save (and therefore
|
||||
-- save.modData[mod.id] once the slot is written)
|
||||
mod.events:on("intro.oak_speech.answered", function(ev)
|
||||
if not ev.saveKey then return end
|
||||
mod.save:set(ev.saveKey, ev.value)
|
||||
mod.log:info("intro answer %s = %s", tostring(ev.saveKey), tostring(ev.value))
|
||||
end)
|
||||
|
||||
mod.events:on("intro.oak_speech.finished", function(ev)
|
||||
local answers = ev.answers or {}
|
||||
for key, value in pairs(answers) do
|
||||
if mod.save:get(key) == nil then
|
||||
mod.save:set(key, value)
|
||||
end
|
||||
end
|
||||
mod.save:set("quiz_done", true)
|
||||
mod.log:info("silly oak quiz done")
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "example_silly_oak",
|
||||
"name": "Silly Oak Intro Example",
|
||||
"version": "1.0.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
"category": "UI",
|
||||
"game_version": ">=0.0.0-0 <2.0.0",
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"description": "Reshapes Oak's intro speech with extra questions, sprite swaps, and answers saved to mod.save."
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Sharing metadata for the manager detail pane.
|
||||
return {
|
||||
summary = "Oak asks dumb questions during the intro and remembers your answers.",
|
||||
author = "Pokemon Gen 1 Recompilation Project",
|
||||
contact = "https://github.com/bryanthaboi/gen1recomp",
|
||||
tags = { "intro", "ui", "oak", "hooks" },
|
||||
differences = {
|
||||
changed = {
|
||||
"Oak's NEW GAME speech gains extra questions and sprite beats",
|
||||
},
|
||||
added = {
|
||||
"mod.save keys: likes_toast, snack, trusts_rival, pineapple_on_pizza, quiz_done",
|
||||
"Custom Toast Kid pic mid-intro",
|
||||
},
|
||||
known = { "vanilla naming and the shrink-away still run" },
|
||||
},
|
||||
credits = {
|
||||
{ who = "Pokemon Gen 1 Recompilation Project", for_ = "the intro.oak_speech hooks" },
|
||||
},
|
||||
compat = { engine = ">=0.0.0 <2.0.0", modApi = 2 },
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
-- Standalone: luajit mods/examples/example_silly_oak/tests/example_silly_oak_test.lua
|
||||
-- Covers the intro.oak_speech build hook, step helpers, sprite descriptors,
|
||||
-- and answers landing in mod.save.
|
||||
--
|
||||
-- Needs an imported ROM dataset (data/generated/). Headless CI and a
|
||||
-- fresh checkout without a ROM skip cleanly -- the gallery is also
|
||||
-- covered by tests/mod_examples_tests.lua when generated data is present.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local function hasGenerated()
|
||||
local handle = io.open("data/generated/constants.lua", "r")
|
||||
if handle then handle:close() return true end
|
||||
return false
|
||||
end
|
||||
if not hasGenerated() then
|
||||
print("example_silly_oak_test skipped (needs data/generated/)")
|
||||
os.exit(0)
|
||||
end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local Data = require("src.core.Data")
|
||||
Data:load()
|
||||
|
||||
local run = T.sdk.loadMod("mods/examples/example_silly_oak", { data = Data })
|
||||
T.eq(#run.errors, 0, "loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local mod = run.mod
|
||||
T.check(mod ~= nil and mod.state == "loaded", "mod reached the loaded state")
|
||||
local ModUI = require("src.ui.ModUI")
|
||||
local bucket = function()
|
||||
return run.loader.modSave.example_silly_oak or {}
|
||||
end
|
||||
local toastPath = (mod.path or "mods/examples/example_silly_oak")
|
||||
.. "/assets/toast_kid.png"
|
||||
|
||||
-- ------- build hook injects every silly beat around vanilla anchors
|
||||
|
||||
local speech = OakSpeech.new({
|
||||
data = Data,
|
||||
save = { player = { name = "RED", rival = "BLUE" } },
|
||||
stack = { push = function() end, pop = function() end },
|
||||
}, nil)
|
||||
local steps = speech:buildSteps()
|
||||
|
||||
local ids = {}
|
||||
for _, step in ipairs(steps) do ids[#ids + 1] = step.id end
|
||||
local function has(id)
|
||||
for _, x in ipairs(ids) do if x == id then return true end end
|
||||
return false
|
||||
end
|
||||
|
||||
T.check(has("oak_welcome") and has("name_player") and has("shrink"),
|
||||
"vanilla anchors still present")
|
||||
T.check(has("silly_quiz_intro") and has("silly_toast") and has("silly_toast_kid"),
|
||||
"toast quiz beats injected")
|
||||
T.check(has("silly_mew") and has("silly_snack"),
|
||||
"MEW reveal and snack choice injected")
|
||||
T.check(has("silly_trust") and has("silly_pineapple") and has("silly_closing"),
|
||||
"rival trust + pineapple beats injected")
|
||||
|
||||
-- order: toast kid before demo_mon, mew after demo_mon, trust before rival ask
|
||||
local function indexOf(id)
|
||||
for i, x in ipairs(ids) do if x == id then return i end end
|
||||
return 0
|
||||
end
|
||||
T.check(indexOf("silly_toast_kid") < indexOf("demo_mon"),
|
||||
"Toast Kid shows before the demo mon")
|
||||
T.check(indexOf("demo_mon") < indexOf("silly_mew"),
|
||||
"MEW shows after the demo mon")
|
||||
T.check(indexOf("silly_trust") < indexOf("ask_rival_name"),
|
||||
"trust question is before rival naming")
|
||||
T.check(indexOf("name_rival") < indexOf("silly_pineapple")
|
||||
and indexOf("silly_pineapple") < indexOf("legend"),
|
||||
"pineapple lands between rival name and the legend beat")
|
||||
|
||||
-- ------- step shapes cover choice / yesno / custom image / pokemon
|
||||
|
||||
local byId = {}
|
||||
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||
|
||||
T.eq(byId.silly_toast.kind, "yesno", "toast is a yes/no")
|
||||
T.eq(byId.silly_toast.saveKey, "likes_toast", "toast writes likes_toast")
|
||||
T.eq(byId.silly_snack.kind, "choice", "snack is a multi choice")
|
||||
T.eq(#byId.silly_snack.choices, 3, "snack has three options")
|
||||
T.check(byId.silly_toast_kid.pic and byId.silly_toast_kid.pic.type == "image",
|
||||
"Toast Kid uses a custom image pic")
|
||||
T.check(byId.silly_mew.pic and byId.silly_mew.pic.type == "pokemon"
|
||||
and byId.silly_mew.pic.id == "MEW" and byId.silly_mew.cry == "MEW",
|
||||
"MEW beat uses pokemon pic + cry")
|
||||
T.eq(byId.silly_trust.pic, "rival", "trust question shows the rival pic")
|
||||
|
||||
-- ------- resolvePic covers trainer / pokemon / player / image shorthand
|
||||
|
||||
local oakImg = OakSpeech.resolvePic({ data = Data }, "oak", speech)
|
||||
local rivalImg = OakSpeech.resolvePic({ data = Data }, "rival", speech)
|
||||
local playerImg = OakSpeech.resolvePic({ data = Data }, "player", speech)
|
||||
local mewImg, mewFlip = OakSpeech.resolvePic({ data = Data },
|
||||
{ type = "pokemon", id = "MEW", flip = true }, speech)
|
||||
local customImg = OakSpeech.resolvePic({ data = Data },
|
||||
{ type = "image", path = toastPath }, speech)
|
||||
-- headless love stub may return nil images; the call itself must not throw
|
||||
T.check(oakImg == speech.oakPic or oakImg == nil or type(oakImg) == "userdata"
|
||||
or type(oakImg) == "table",
|
||||
"oak shorthand resolves without error")
|
||||
T.check(rivalImg == speech.rivalPic or rivalImg == nil or type(rivalImg) == "userdata"
|
||||
or type(rivalImg) == "table",
|
||||
"rival shorthand resolves without error")
|
||||
T.check(playerImg == speech.playerPic or playerImg == nil
|
||||
or type(playerImg) == "userdata" or type(playerImg) == "table",
|
||||
"player shorthand resolves without error")
|
||||
T.check(mewFlip == true, "pokemon flip flag is honored")
|
||||
T.check(customImg ~= nil or true, "custom image path is accepted")
|
||||
|
||||
-- ------- answered event writes mod.save (loader.modSave bucket)
|
||||
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "likes_toast", value = true, label = "YES", index = 1,
|
||||
step = byId.silly_toast, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "snack", value = "OLD ROD", label = "OLD ROD", index = 3,
|
||||
step = byId.silly_snack, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "trusts_rival", value = false, label = "NO", index = 2,
|
||||
step = byId.silly_trust, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.answered", {
|
||||
saveKey = "pineapple_on_pizza", value = true, label = "YES", index = 1,
|
||||
step = byId.silly_pineapple, speech = speech,
|
||||
})
|
||||
Runtime.emit("intro.oak_speech.finished", {
|
||||
speech = speech, answers = speech.answers,
|
||||
})
|
||||
|
||||
local saved = bucket()
|
||||
T.eq(saved.likes_toast, true, "likes_toast saved")
|
||||
T.eq(saved.snack, "OLD ROD", "snack saved")
|
||||
T.eq(saved.trusts_rival, false, "trusts_rival saved")
|
||||
T.eq(saved.pineapple_on_pizza, true, "pineapple_on_pizza saved")
|
||||
T.eq(saved.quiz_done, true, "quiz_done stamped on finish")
|
||||
|
||||
-- ------- ModUI step helpers (public surface)
|
||||
|
||||
local tiny = {
|
||||
{ id = "a", kind = "say" },
|
||||
{ id = "b", kind = "say" },
|
||||
}
|
||||
ModUI.insertStepAfter(tiny, "a", { id = "mid", kind = "choice" })
|
||||
T.eq(tiny[2].id, "mid", "insertStepAfter lands behind the anchor")
|
||||
ModUI.insertStepBefore(tiny, "b", { id = "pre_b", kind = "yesno" })
|
||||
T.eq(tiny[3].id, "pre_b", "insertStepBefore lands ahead of the anchor")
|
||||
ModUI.removeStep(tiny, "mid")
|
||||
T.check(tiny[2].id ~= "mid", "removeStep drops by id")
|
||||
|
||||
run.release()
|
||||
T.finish("example_silly_oak")
|
||||
+126
-53
@@ -1008,9 +1008,23 @@ end
|
||||
-- flickers the OBJ palette, DoBallTossSpecialEffects)
|
||||
function BattleState:animNext(name, isPlayer, shakes, ball)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert,
|
||||
{ anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
|
||||
ball = ball })
|
||||
local row = { anim = name, attackerIsPlayer = isPlayer, shakes = shakes,
|
||||
ball = ball }
|
||||
table.insert(self.queue, self.nextInsert, row)
|
||||
return row
|
||||
end
|
||||
|
||||
-- an animation row ahead of the move's own, with PlayBattleAnimation2's
|
||||
-- applying-animation shake (engine/battle/effects.asm:1461-1471)
|
||||
function BattleState:animBeforeMove(name, isPlayer)
|
||||
local at
|
||||
for i, item in ipairs(self.queue) do
|
||||
if item == self.moveAnimRow then at = i break end
|
||||
end
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, at or self.nextInsert,
|
||||
{ anim = name, attackerIsPlayer = isPlayer, animDelayed = true,
|
||||
hit = { animType = isPlayer and 6 or 3 } })
|
||||
end
|
||||
|
||||
-- insert an act right after the current queue item
|
||||
@@ -1554,8 +1568,13 @@ end
|
||||
function BattleState:sendOutText(name)
|
||||
local e = self.enemy and self.enemy.mon
|
||||
local pct = 100
|
||||
if e and e.hp > 0 and math.floor(e.stats.hp / 4) > 0 then
|
||||
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
||||
if e and e.hp > 0 then
|
||||
-- the same routine stamps wLastSwitchInEnemyMonHP
|
||||
-- (engine/battle/common_text.asm:105-110)
|
||||
self.lastSwitchInEnemyHP = e.hp
|
||||
if math.floor(e.stats.hp / 4) > 0 then
|
||||
pct = math.floor(e.hp * 25 / math.floor(e.stats.hp / 4))
|
||||
end
|
||||
end
|
||||
if pct >= 70 then return Strings("Go! %s!", name) end
|
||||
if pct >= 40 then return Strings("Do it! %s!", name) end
|
||||
@@ -1563,6 +1582,27 @@ function BattleState:sendOutText(name)
|
||||
return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name)
|
||||
end
|
||||
|
||||
-- RetreatMon / PlayerMon2Text (engine/battle/common_text.asm:167-243): the
|
||||
-- adjective reads the enemy HP lost since this mon switched in
|
||||
function BattleState:withdrawText(name)
|
||||
local e = self.enemy and self.enemy.mon
|
||||
local drop = 0
|
||||
if e and self.lastSwitchInEnemyHP and math.floor(e.stats.hp / 4) > 0 then
|
||||
drop = math.floor((self.lastSwitchInEnemyHP - e.hp) * 25
|
||||
/ math.floor(e.stats.hp / 4))
|
||||
end
|
||||
local word = ""
|
||||
if drop <= 0 then
|
||||
word = self:romText("_EnoughText", "enough!")
|
||||
elseif drop >= 70 then
|
||||
word = self:romText("_GoodText", "good!")
|
||||
elseif drop >= 30 then
|
||||
word = self:romText("_OKExclamationText", "OK!")
|
||||
end
|
||||
return self:romText("_PlayerMon2Text", "%s ", name) .. word
|
||||
.. self:romText("_ComeBackText", "\nCome back!")
|
||||
end
|
||||
|
||||
-- The cry a mon makes as it takes the field. Yellow does not run its
|
||||
-- starter Pikachu through PlayCry at all: SendOutMon branches to
|
||||
-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM
|
||||
@@ -1816,7 +1856,8 @@ function BattleState:enter()
|
||||
self.enemySendingOut = true
|
||||
self:slidePic("foe")
|
||||
end)
|
||||
self:say(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||
-- _TrainerSentOutText ends `done`, not `prompt` (data/text/text_2.asm:923)
|
||||
self:sayAuto(Strings("%s sent\nout %s!", foeName, self.enemy.name))
|
||||
self:act(function()
|
||||
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
|
||||
-- pic grows out of the ball (AnimateSendingOutMon), then the cry
|
||||
@@ -1845,7 +1886,8 @@ function BattleState:enter()
|
||||
self.sendingOut = true
|
||||
self:slidePic("back")
|
||||
end)
|
||||
self:say(self:sendOutText(self.player.name))
|
||||
-- _GoText.._PlayerMon1Text carry no prompt (data/text/text_2.asm:1274-1294)
|
||||
self:sayAuto(self:sendOutText(self.player.name))
|
||||
-- then the POOF plays and the mon appears with its cry
|
||||
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||
self:queueSendOutAnim(true)
|
||||
@@ -2659,22 +2701,28 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
|
||||
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
previous = previous,
|
||||
})
|
||||
self:markParticipant()
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||
-- outgoing pic and holds 50 frames before the mon is recalled
|
||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:actNext(function()
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, newMon, true, self.game.save)
|
||||
-- SendOutMon (core.asm:1761-1762): player's send-out clears the
|
||||
-- foe's USING_TRAPPING_MOVE -- Wrap/Bind/etc. ends on any switch
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1], battler = self.player,
|
||||
previous = previous,
|
||||
})
|
||||
self:markParticipant()
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
end)
|
||||
end)
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
@@ -2703,7 +2751,12 @@ function BattleState:residualFor(b, opp)
|
||||
if b.residualDone then return end
|
||||
b.residualDone = true
|
||||
local msgs = Status.residual(b, opp, self)
|
||||
local rec = Status.recordFor(self.data and self.data.statuses, b.mon.status)
|
||||
for _, m in ipairs(msgs) do self:sayNext(prefixEnemy(m, b)) end
|
||||
-- engine/battle/core.asm:490-493
|
||||
if rec and rec.residual then
|
||||
self:animNext("BURN_PSN_ANIM", b.isPlayer)
|
||||
end
|
||||
if b.leechSeeded and b.mon.hp > 0 then
|
||||
-- the drain plays the ABSORB animation from the healing side
|
||||
-- (core.asm:506-517 flips hWhoseTurn before PlayMoveAnimation)
|
||||
@@ -3587,7 +3640,17 @@ function BattleState:executeAction(user, target, action)
|
||||
markSeen(self.game, self.enemy.mon.species)
|
||||
-- _AIBattleWithdrawText: "X with-/drew Y!"
|
||||
self:sayNext(Strings("%s with-\ndrew %s!", self.trainer.name, oldName))
|
||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
-- EnemySendOut falls into EnemySendOutFirstMon: TrainerSentOutText,
|
||||
-- then AnimateSendingOutMon and PlayCry (core.asm:1276-1434)
|
||||
self.enemySendingOut = true
|
||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:actNext(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
self:actNext(function()
|
||||
self:waitSfxNext(self:playEntranceCry(self.enemy))
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -4277,6 +4340,9 @@ function BattleState:enemyMonFainted()
|
||||
self:act(function()
|
||||
local previous = self.enemy
|
||||
self.enemy = makeBattler(self.data, self.enemyParty[self.enemyIndex], false)
|
||||
-- EnemySendOutFirstMon (core.asm:1359-1363): the fresh foe's HP is the
|
||||
-- new wLastSwitchInEnemyMonHP baseline RetreatMon measures from
|
||||
self.lastSwitchInEnemyHP = self.enemy.mon.hp
|
||||
-- EnemySendOutFirstMon (core.asm:1314-1315): clears player's trap
|
||||
clearTrapping(self.player)
|
||||
self:syncSides()
|
||||
@@ -4296,7 +4362,7 @@ function BattleState:enemyMonFainted()
|
||||
-- (AnimateSendingOutMon) with the cry; no POOF -- that animation
|
||||
-- belongs to the player-side SendOutMon (core.asm:1757-1762)
|
||||
self.enemySendingOut = true
|
||||
self:sayNext(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:sayNextAuto(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
|
||||
self:actNext(function()
|
||||
self.enemySendingOut = false
|
||||
self:startGrowIn(self.enemy)
|
||||
@@ -4309,34 +4375,41 @@ function BattleState:enemyMonFainted()
|
||||
self:act(function()
|
||||
local mon = shiftSwitchMon
|
||||
if not mon then return end
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
battler = self.player, previous = previous,
|
||||
})
|
||||
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
|
||||
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
|
||||
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
|
||||
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
|
||||
-- the reset the mon that was out when the enemy fainted -- marked by
|
||||
-- the send-out act above, which mirrors EnemySendOut's own re-flag
|
||||
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
|
||||
-- enemyMonFainted counted two mons and the switch-in earned half the
|
||||
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
|
||||
-- replacements (openReplacementMenu) must NOT do this: pokered's
|
||||
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
|
||||
-- the deliberate exp-share, and a fainted mon is already dropped by
|
||||
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||
-- hold, then the recall and the send-out
|
||||
self.nextInsert = 0
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:actNext(function()
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||
clearTrapping(self.enemy)
|
||||
self:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = self, side = self.sides[1],
|
||||
battler = self.player, previous = previous,
|
||||
})
|
||||
-- Taking the SHIFT offer ZEROES wPartyGainExpFlags and
|
||||
-- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon
|
||||
-- (EnemySendOutFirstMon tail, core.asm:1436-1443), and SwitchPlayerMon
|
||||
-- then FLAG_SETs only the mon coming in (core.asm:2424-2433). Without
|
||||
-- the reset the mon that was out when the enemy fainted -- marked by
|
||||
-- the send-out act above, which mirrors EnemySendOut's own re-flag
|
||||
-- (core.asm:1276-1289) -- stayed a participant, so the exp divisor in
|
||||
-- enemyMonFainted counted two mons and the switch-in earned half the
|
||||
-- next KO (#275). Voluntary switches (resolveSwitch) and post-faint
|
||||
-- replacements (openReplacementMenu) must NOT do this: pokered's
|
||||
-- party-menu SwitchPlayerMon keeps the outgoing mon flagged, which is
|
||||
-- the deliberate exp-share, and a fainted mon is already dropped by
|
||||
-- onFaint mirroring RemoveFaintedPlayerMon (core.asm:1002-1007).
|
||||
self.participants = {}
|
||||
self:markParticipant()
|
||||
self.nextInsert = 0
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
end)
|
||||
end)
|
||||
return
|
||||
end
|
||||
@@ -4526,7 +4599,7 @@ function BattleState:openReplacementMenu()
|
||||
self.nextInsert = 0
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:sayNextAuto(self:sendOutText(self.player.name))
|
||||
self:queueSendOutAnim(false)
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -92,6 +92,20 @@ local function hitCount(ctx, record)
|
||||
return dist[r + 1]
|
||||
end
|
||||
|
||||
-- engine/battle/effects.asm:119-151 (poison), :194-255 (burn/freeze/paralyze)
|
||||
local FBP_SIDE_STATUS = { BRN = true, FRZ = true, PAR = true }
|
||||
|
||||
local function secondaryStatusFx(battle, user, status)
|
||||
if status == "PSN" then
|
||||
local row = battle:animNext(user.isPlayer and "ENEMY_HUD_SHAKE_ANIM"
|
||||
or "SHAKE_SCREEN_ANIM", user.isPlayer)
|
||||
row.animDelayed = true
|
||||
row.hit = { animType = user.isPlayer and 6 or 3 }
|
||||
elseif FBP_SIDE_STATUS[status] and user.isPlayer then
|
||||
battle:animNext("ENEMY_HUD_SHAKE_ANIM", true).animDelayed = true
|
||||
end
|
||||
end
|
||||
|
||||
-- The damaging pipeline, extracted from the performMove monolith: every
|
||||
-- stage keeps the original's exact check order and rng consumption
|
||||
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
|
||||
@@ -318,7 +332,12 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- secondary side effects (blocked by fainting)
|
||||
if record and record.run and record.kind ~= "primary"
|
||||
and target.mon.hp > 0 and totalDealt > 0 then
|
||||
for _, m in ipairs(record.run(ctx)) do
|
||||
local hadStatus = target.mon.status
|
||||
local msgs = record.run(ctx)
|
||||
if target.mon.status and target.mon.status ~= hadStatus then
|
||||
secondaryStatusFx(battle, user, target.mon.status)
|
||||
end
|
||||
for _, m in ipairs(msgs) do
|
||||
battle:sayNext(m)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -581,6 +581,15 @@ MoveEffects.full = {
|
||||
end,
|
||||
},
|
||||
THRASH_PETAL_DANCE_EFFECT = {
|
||||
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||
-- (data/battle/special_effects.asm:22) and animates the setup turn
|
||||
beforeAccuracy = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||
end
|
||||
end,
|
||||
afterDamage = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
|
||||
+21
-6
@@ -116,11 +116,13 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
||||
if type(assets) ~= "table" then return nil end
|
||||
pdfFiles = pdfFiles or {}
|
||||
local raster = {}
|
||||
local pdfName
|
||||
for _, key in ipairs(DeltaSkin.ASSET_LADDER) do
|
||||
local name = pick(assets, key)
|
||||
if key == "medium" and type(name) ~= "string" then name = pick(assets, "normal") end
|
||||
if type(name) == "string" and name ~= "" then
|
||||
if name:lower():match("%.pdf$") then
|
||||
pdfName = name
|
||||
pdfFiles[#pdfFiles + 1] = name
|
||||
else
|
||||
raster[#raster + 1] = { key = key, name = name }
|
||||
@@ -130,12 +132,14 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
||||
local resizable = pick(assets, "resizable")
|
||||
if type(resizable) == "string" and resizable ~= "" then
|
||||
if resizable:lower():match("%.pdf$") then
|
||||
pdfName = resizable
|
||||
pdfFiles[#pdfFiles + 1] = resizable
|
||||
else
|
||||
raster[#raster + 1] = { key = "large", name = resizable }
|
||||
end
|
||||
end
|
||||
if #raster == 0 then return nil end
|
||||
local pdfPath = pdfName and DeltaSkin.resolveName(pdfName, opts) or nil
|
||||
if #raster == 0 then return nil, pdfPath end
|
||||
|
||||
local target = numOr(opts and opts.targetWidth, DeltaSkin.DEFAULT_TARGET_WIDTH)
|
||||
local chosen
|
||||
@@ -145,7 +149,7 @@ function DeltaSkin.pickAsset(assets, opts, pdfFiles)
|
||||
end
|
||||
end
|
||||
if not chosen then chosen = raster[#raster].name end
|
||||
return DeltaSkin.resolveName(chosen, opts)
|
||||
return DeltaSkin.resolveName(chosen, opts), nil
|
||||
end
|
||||
|
||||
function DeltaSkin.mergeEdges(base, item)
|
||||
@@ -288,10 +292,12 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
||||
addWarning(warnings, orient .. " has no mappingSize; assuming 320x240")
|
||||
end
|
||||
|
||||
local imagePath, pdfPath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles)
|
||||
local page = {
|
||||
name = orient,
|
||||
orient = orient,
|
||||
imagePath = DeltaSkin.pickAsset(pick(obj, "assets"), opts, pdfFiles),
|
||||
imagePath = imagePath,
|
||||
pdfPath = pdfPath,
|
||||
fullScreen = true,
|
||||
normalized = true,
|
||||
pixelCoords = false,
|
||||
@@ -309,6 +315,14 @@ function DeltaSkin.buildPage(obj, orient, opts, warnings, pdfFiles)
|
||||
if screen then
|
||||
page.viewport = screen
|
||||
page.viewportFill = false
|
||||
else
|
||||
-- mappingSize is the overlay, not the device. Portrait controller
|
||||
-- skins (GBA4iOS-era 320x240 decks, this Pikachu skin, etc.) keep
|
||||
-- that aspect, sit at the bottom, and leave the leftover for the
|
||||
-- Game Boy picture. A screens/gameScreenFrame rect still fills.
|
||||
page.aspectFromCfg = true
|
||||
page.screenFit = "remainder"
|
||||
if orient == "portrait" then page.anchor = "bottom" end
|
||||
end
|
||||
|
||||
local baseEdges = pick(obj, "extendedEdges")
|
||||
@@ -368,9 +382,6 @@ function DeltaSkin.parse(text, opts)
|
||||
end
|
||||
end
|
||||
if #pages == 0 then return nil, "info.json has no usable representation" end
|
||||
if #pdfFiles > 0 then
|
||||
addWarning(warnings, "PDF artwork cannot be imported yet")
|
||||
end
|
||||
|
||||
return {
|
||||
pages = pages,
|
||||
@@ -390,6 +401,10 @@ function DeltaSkin.needsConversion(skin)
|
||||
local files = skin.pdfFiles
|
||||
if type(files) ~= "table" or #files == 0 then return nil end
|
||||
for _, page in ipairs(skin.pages or {}) do
|
||||
-- A raster asset, or a JPEG recovered from the PDF at load, means the
|
||||
-- skin can draw. Parse-only callers still see pdfOnly because they
|
||||
-- have not run extract yet.
|
||||
if page.rasterData then return nil end
|
||||
if page.imagePath then return nil end
|
||||
end
|
||||
return { pdfOnly = true, files = files }
|
||||
|
||||
+86
-13
@@ -6,6 +6,49 @@ local IssueReport = {}
|
||||
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
|
||||
local TEMPLATE = "bug_report.yml"
|
||||
|
||||
local APPLE_MODELS = {
|
||||
["iPhone14,2"] = "iPhone 13 Pro",
|
||||
["iPhone14,3"] = "iPhone 13 Pro Max",
|
||||
["iPhone14,4"] = "iPhone 13 mini",
|
||||
["iPhone14,5"] = "iPhone 13",
|
||||
["iPhone14,7"] = "iPhone 14",
|
||||
["iPhone14,8"] = "iPhone 14 Plus",
|
||||
["iPhone15,2"] = "iPhone 14 Pro",
|
||||
["iPhone15,3"] = "iPhone 14 Pro Max",
|
||||
["iPhone15,4"] = "iPhone 15",
|
||||
["iPhone15,5"] = "iPhone 15 Plus",
|
||||
["iPhone16,1"] = "iPhone 15 Pro",
|
||||
["iPhone16,2"] = "iPhone 15 Pro Max",
|
||||
["iPhone17,1"] = "iPhone 16 Pro",
|
||||
["iPhone17,2"] = "iPhone 16 Pro Max",
|
||||
["iPhone17,3"] = "iPhone 16",
|
||||
["iPhone17,4"] = "iPhone 16 Plus",
|
||||
["iPhone17,5"] = "iPhone 16e",
|
||||
["Mac14,2"] = "MacBook Air (13-inch, M2)",
|
||||
["Mac14,3"] = "Mac mini (M2)",
|
||||
["Mac14,5"] = "MacBook Pro (14-inch, M2 Max)",
|
||||
["Mac14,6"] = "MacBook Pro (16-inch, M2 Max)",
|
||||
["Mac14,7"] = "MacBook Pro (13-inch, M2)",
|
||||
["Mac14,9"] = "MacBook Pro (14-inch, M2 Pro)",
|
||||
["Mac14,10"] = "MacBook Pro (16-inch, M2 Pro)",
|
||||
["Mac14,12"] = "Mac mini (M2 Pro)",
|
||||
["Mac14,13"] = "Mac Studio (M2 Max)",
|
||||
["Mac14,14"] = "Mac Studio (M2 Ultra)",
|
||||
["Mac14,15"] = "MacBook Air (15-inch, M2)",
|
||||
["Mac15,3"] = "MacBook Pro (14-inch, M3)",
|
||||
["Mac15,6"] = "MacBook Pro (14-inch, M3 Pro)",
|
||||
["Mac15,7"] = "MacBook Pro (16-inch, M3 Pro)",
|
||||
["Mac15,12"] = "MacBook Air (13-inch, M3)",
|
||||
["Mac15,13"] = "MacBook Air (15-inch, M3)",
|
||||
["Mac16,1"] = "MacBook Pro (14-inch, M4)",
|
||||
["Mac16,5"] = "MacBook Pro (16-inch, M4 Max)",
|
||||
["Mac16,6"] = "MacBook Pro (14-inch, M4 Max)",
|
||||
["Mac16,7"] = "MacBook Pro (16-inch, M4 Pro)",
|
||||
["Mac16,8"] = "MacBook Pro (14-inch, M4 Pro)",
|
||||
["Mac16,10"] = "Mac mini (M4)",
|
||||
["Mac16,11"] = "Mac mini (M4 Pro)",
|
||||
}
|
||||
|
||||
local function clean(value)
|
||||
if value == nil then return nil end
|
||||
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
@@ -36,6 +79,16 @@ local function commandValue(command)
|
||||
return clean(value)
|
||||
end
|
||||
|
||||
local function commandText(command)
|
||||
if not io or type(io.popen) ~= "function" then return nil end
|
||||
local ok, pipe = pcall(io.popen, command, "r")
|
||||
if not ok or not pipe then return nil end
|
||||
local readOK, value = pcall(pipe.read, pipe, "*a")
|
||||
pcall(pipe.close, pipe)
|
||||
if not readOK then return nil end
|
||||
return clean(value)
|
||||
end
|
||||
|
||||
local function percentEncode(value)
|
||||
local text = tostring(value or "")
|
||||
return (text:gsub("([^%w%-_%.~])", function(char)
|
||||
@@ -66,6 +119,25 @@ local function loveVersion()
|
||||
return result
|
||||
end
|
||||
|
||||
local function friendlyModel(identifier)
|
||||
identifier = clean(identifier)
|
||||
if not identifier then return nil end
|
||||
return APPLE_MODELS[identifier] or identifier
|
||||
end
|
||||
|
||||
local function macModel()
|
||||
local details = commandText("system_profiler SPHardwareDataType 2>/dev/null")
|
||||
if details then
|
||||
local name = clean(details:match("Model Name:%s*([^\r\n]+)"))
|
||||
local chip = clean(details:match("Chip:%s*([^\r\n]+)"))
|
||||
if name and chip and not name:find(chip, 1, true) then
|
||||
return name .. " (" .. chip .. ")"
|
||||
end
|
||||
if name then return name end
|
||||
end
|
||||
return friendlyModel(commandValue("sysctl -n hw.model 2>/dev/null"))
|
||||
end
|
||||
|
||||
local function appVersion()
|
||||
local version = clean(Version.engine)
|
||||
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
|
||||
@@ -73,20 +145,25 @@ local function appVersion()
|
||||
end
|
||||
|
||||
local function deviceModel(rawOS, system)
|
||||
local model = clean(call(system.getModel))
|
||||
if model then return model end
|
||||
local nativeModel = clean(call(system.getDeviceModel))
|
||||
if nativeModel then return friendlyModel(nativeModel) end
|
||||
if rawOS == "OS X" or rawOS == "macOS" then
|
||||
return commandValue("sysctl -n hw.model 2>/dev/null")
|
||||
return macModel()
|
||||
end
|
||||
local model = clean(call(system.getModel))
|
||||
if model and not model:lower():find("gpu", 1, true)
|
||||
and not model:lower():find("renderer", 1, true) then
|
||||
return friendlyModel(model)
|
||||
end
|
||||
if rawOS == "Windows" then
|
||||
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
|
||||
return friendlyModel(commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL"))
|
||||
end
|
||||
if rawOS == "Linux" then
|
||||
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
||||
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
|
||||
return friendlyModel(commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
|
||||
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null"))
|
||||
end
|
||||
if rawOS == "Android" then
|
||||
return commandValue("getprop ro.product.model 2>/dev/null")
|
||||
return friendlyModel(commandValue("getprop ro.product.model 2>/dev/null"))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
@@ -121,7 +198,7 @@ local function metadata(options, context)
|
||||
local window = love and love.window or {}
|
||||
local rawOS = clean(call(system.getOS))
|
||||
local model = deviceModel(rawOS, system)
|
||||
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
|
||||
local renderer, rendererVersion = call(graphics.getRendererInfo)
|
||||
local width, height = call(graphics.getDimensions)
|
||||
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
|
||||
local modeWidth, modeHeight, flags = call(window.getMode)
|
||||
@@ -134,11 +211,7 @@ local function metadata(options, context)
|
||||
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
|
||||
end
|
||||
add("Platform", formOS(rawOS))
|
||||
local hardware = model
|
||||
if rendererDevice and rendererDevice ~= model then
|
||||
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
|
||||
end
|
||||
add("Device", hardware)
|
||||
add("Device", model)
|
||||
local rendererDetails = clean(renderer)
|
||||
if rendererDetails and clean(rendererVersion) then
|
||||
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
-- Recover a raster from a PDF that is really a wrapped JPEG. Delta skins
|
||||
-- ship artwork that way so iOS can scale it; LOVE has no PDF renderer, so
|
||||
-- import pulls the embedded image out instead of refusing the skin. True
|
||||
-- vector PDFs (no Image XObject, no JPEG) still fail.
|
||||
|
||||
local PdfImage = {}
|
||||
|
||||
local function isPdf(bytes)
|
||||
return type(bytes) == "string" and bytes:sub(1, 5) == "%PDF-"
|
||||
end
|
||||
|
||||
-- After the `stream` keyword the spec allows \n or \r\n before the bytes.
|
||||
-- `endstream` also contains the letters "stream", so skip that match.
|
||||
local function streamDataStart(bytes, from)
|
||||
local s, e = bytes:find("stream", from, true)
|
||||
while s do
|
||||
if s == 1 or bytes:sub(s - 3, s - 1) ~= "end" then
|
||||
local p = e + 1
|
||||
if bytes:sub(p, p) == "\r" then p = p + 1 end
|
||||
if bytes:sub(p, p) == "\n" then p = p + 1 end
|
||||
return p, s
|
||||
end
|
||||
s, e = bytes:find("stream", e + 1, true)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function dictWindow(bytes, imageAt)
|
||||
local from = imageAt > 400 and (imageAt - 400) or 1
|
||||
local to = math.min(#bytes, imageAt + 800)
|
||||
return bytes:sub(from, to)
|
||||
end
|
||||
|
||||
local function dictNumber(window, key)
|
||||
-- Prefer an indirect ref so `/Length 5 0 R` is not read as length 5.
|
||||
if window:find("/" .. key .. "%s+%d+%s+%d+%s+R") then return nil end
|
||||
return tonumber(window:match("/" .. key .. "%s+(%d+)"))
|
||||
end
|
||||
|
||||
local function dictFilter(window)
|
||||
local named = window:match("/Filter%s*/(%w+)")
|
||||
if named then return named end
|
||||
return window:match("/Filter%s*%[%s*/(%w+)")
|
||||
end
|
||||
|
||||
local function jpegIn(bytes, from, to)
|
||||
if from < 1 then from = 1 end
|
||||
if not to or to > #bytes then to = #bytes end
|
||||
if to < from then return nil end
|
||||
local region = bytes:sub(from, to)
|
||||
local soi = region:find("\255\216\255", 1, true)
|
||||
if not soi then return nil end
|
||||
local eoi = region:find("\255\217", soi + 3, true)
|
||||
if not eoi then return nil end
|
||||
return region:sub(soi, eoi + 1)
|
||||
end
|
||||
|
||||
local function candidate(data, width, height, ext)
|
||||
if not data or data == "" then return nil end
|
||||
return {
|
||||
data = data,
|
||||
ext = ext or "jpg",
|
||||
width = width or 0,
|
||||
height = height or 0,
|
||||
}
|
||||
end
|
||||
|
||||
local function bigger(a, b)
|
||||
if not a then return b end
|
||||
if not b then return a end
|
||||
local as = (a.width or 0) * (a.height or 0)
|
||||
local bs = (b.width or 0) * (b.height or 0)
|
||||
if bs ~= as then return bs > as and b or a end
|
||||
return #b.data > #a.data and b or a
|
||||
end
|
||||
|
||||
-- Walk Image XObjects and take the largest DCTDecode (JPEG) stream.
|
||||
local function fromImageXObjects(bytes)
|
||||
local best
|
||||
local i = 1
|
||||
while true do
|
||||
local s, e = bytes:find("/Subtype%s*/Image", i)
|
||||
if not s then break end
|
||||
local window = dictWindow(bytes, s)
|
||||
local filter = dictFilter(window)
|
||||
local width = dictNumber(window, "Width")
|
||||
local height = dictNumber(window, "Height")
|
||||
local dataStart = streamDataStart(bytes, e)
|
||||
i = e + 1
|
||||
if dataStart and filter == "DCTDecode" then
|
||||
local es = bytes:find("endstream", dataStart, true)
|
||||
local jpeg = jpegIn(bytes, dataStart, es and (es - 1) or nil)
|
||||
best = bigger(best, candidate(jpeg, width, height, "jpg"))
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- Image-to-PDF converters (3-Heights, Preview, etc.) leave a single JPEG
|
||||
-- body even when /Length is an indirect object we do not resolve.
|
||||
local function fromBareJpeg(bytes)
|
||||
local jpeg = jpegIn(bytes, 1, #bytes)
|
||||
if not jpeg then return nil end
|
||||
return candidate(jpeg, 0, 0, "jpg")
|
||||
end
|
||||
|
||||
function PdfImage.extract(bytes)
|
||||
if not isPdf(bytes) then return nil, "not a pdf" end
|
||||
local best = fromImageXObjects(bytes)
|
||||
if not best then best = fromBareJpeg(bytes) end
|
||||
if not best then return nil, "no extractable image" end
|
||||
return best
|
||||
end
|
||||
|
||||
return PdfImage
|
||||
+105
-11
@@ -432,6 +432,10 @@ function TouchSkin.parseNative(text)
|
||||
aspectFromCfg = raw.fitAspect == true,
|
||||
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
||||
or raw.orient == "any") and raw.orient or nil,
|
||||
screenFit = raw.screenFit == "remainder" and "remainder" or nil,
|
||||
anchor = (raw.anchor == "top" or raw.anchor == "bottom"
|
||||
or raw.anchor == "left" or raw.anchor == "right")
|
||||
and raw.anchor or nil,
|
||||
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||
controls = {},
|
||||
}
|
||||
@@ -507,6 +511,8 @@ function TouchSkin.toNative(skin)
|
||||
alphaMod = page.alphaMod,
|
||||
aspect = page.aspect,
|
||||
fitAspect = page.aspectFromCfg or nil,
|
||||
screenFit = page.screenFit == "remainder" and "remainder" or nil,
|
||||
anchor = page.anchor,
|
||||
orient = (page.orient == "portrait" or page.orient == "landscape"
|
||||
or page.orient == "any") and page.orient or nil,
|
||||
controls = {},
|
||||
@@ -570,6 +576,40 @@ local function loadImage(path)
|
||||
return img
|
||||
end
|
||||
|
||||
-- FileData so LOVE sniffs JPEG/PNG from the name, not a path inside the zip.
|
||||
local function loadImageFromBytes(bytes, name)
|
||||
if not bytes or bytes == "" then return nil end
|
||||
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||
if not (love.filesystem and love.filesystem.newFileData) then return nil end
|
||||
local key = "bytes:" .. tostring(name) .. ":" .. tostring(#bytes)
|
||||
local cached = imageCache[key]
|
||||
if cached then return cached end
|
||||
local okFd, fd = pcall(love.filesystem.newFileData, bytes, name or "bezel.jpg")
|
||||
if not okFd or not fd then return nil end
|
||||
local ok, img = pcall(love.graphics.newImage, fd)
|
||||
if not ok or not img then
|
||||
if love.image and love.image.newImageData then
|
||||
local okData, data = pcall(love.image.newImageData, fd)
|
||||
if okData and data then ok, img = pcall(love.graphics.newImage, data) end
|
||||
end
|
||||
end
|
||||
if not ok or not img then return nil end
|
||||
if img.setFilter then img:setFilter("linear", "linear") end
|
||||
imageCache[key] = img
|
||||
return img
|
||||
end
|
||||
|
||||
local function rasterizePdfPage(page, root)
|
||||
if not page or page.image or not page.pdfPath then return end
|
||||
local pdf = readFile(joinPath(root, page.pdfPath))
|
||||
local raster = require("src.core.PdfImage").extract(pdf)
|
||||
if not raster then return end
|
||||
local name = tostring(page.pdfPath):gsub("%.[Pp][Dd][Ff]$", "") .. "." .. raster.ext
|
||||
page.rasterData = raster.data
|
||||
page.rasterName = name:match("([^/]+)$") or name
|
||||
page.image = loadImageFromBytes(raster.data, page.rasterName)
|
||||
end
|
||||
|
||||
local function pixelScalePending(page)
|
||||
if page.pixelCoords then return true end
|
||||
for _, ctl in ipairs(page.controls or {}) do
|
||||
@@ -622,6 +662,8 @@ function TouchSkin.load(root, id)
|
||||
for _, page in ipairs(skin.pages) do
|
||||
if page.imagePath then
|
||||
page.image = loadImage(joinPath(root, page.imagePath))
|
||||
elseif page.pdfPath then
|
||||
rasterizePdfPage(page, root)
|
||||
end
|
||||
if not applyPixelScale(page) then
|
||||
return nil, "could not read " .. tostring(page.imagePath)
|
||||
@@ -646,7 +688,7 @@ end
|
||||
TouchSkin.ARCHIVE_EXTS = { zip = true, deltaskin = true }
|
||||
TouchSkin.LEGACY_EXTS = { gbcskin = true, gbaskin = true, gbskin = true }
|
||||
TouchSkin.PDF_ONLY_MESSAGE =
|
||||
"This skin uses PDF artwork, which cannot be imported yet. "
|
||||
"This skin uses PDF artwork with no extractable image. "
|
||||
.. "Ask the author for a PNG version."
|
||||
|
||||
function TouchSkin.archiveId(name)
|
||||
@@ -1246,12 +1288,27 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
and page.aspect and page.aspect > 0 and h > 0
|
||||
if fit then
|
||||
local displayAspect = w / h
|
||||
local anchor = page.anchor
|
||||
if displayAspect > page.aspect then
|
||||
bw = h * page.aspect
|
||||
bx = ox + (w - bw) * 0.5
|
||||
local extra = w - bw
|
||||
if anchor == "right" then
|
||||
bx = ox + extra
|
||||
elseif anchor == "left" then
|
||||
bx = ox
|
||||
else
|
||||
bx = ox + extra * 0.5
|
||||
end
|
||||
else
|
||||
bh = w / page.aspect
|
||||
by = oy + (h - bh) * 0.5
|
||||
local extra = h - bh
|
||||
if anchor == "bottom" then
|
||||
by = oy + extra
|
||||
elseif anchor == "top" then
|
||||
by = oy
|
||||
else
|
||||
by = oy + extra * 0.5
|
||||
end
|
||||
end
|
||||
end
|
||||
local r = page.rect
|
||||
@@ -1308,18 +1365,55 @@ end
|
||||
|
||||
function TouchSkin.hasViewport()
|
||||
local page = TouchSkin.page()
|
||||
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
|
||||
if not page or not TouchSkin.drawable() then return false end
|
||||
return page.viewport ~= nil or page.screenFit == "remainder"
|
||||
end
|
||||
|
||||
-- Largest strip of (ox,oy,w,h) that does not overlap the overlay box.
|
||||
local function remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||
local right, bottom = ox + w, oy + h
|
||||
local cand = {
|
||||
{ ox, oy, w, by - oy },
|
||||
{ ox, by + bh, w, bottom - (by + bh) },
|
||||
{ ox, oy, bx - ox, h },
|
||||
{ bx + bw, oy, right - (bx + bw), h },
|
||||
}
|
||||
local best, bestArea
|
||||
for _, r in ipairs(cand) do
|
||||
if r[3] > 1 and r[4] > 1 then
|
||||
local area = r[3] * r[4]
|
||||
if not best or area > bestArea then
|
||||
best, bestArea = r, area
|
||||
end
|
||||
end
|
||||
end
|
||||
if not best then return nil end
|
||||
return best[1], best[2], best[3], best[4]
|
||||
end
|
||||
|
||||
function TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
if not page then return nil end
|
||||
ox, oy = ox or 0, oy or 0
|
||||
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
if page.viewport then
|
||||
local v = page.viewport
|
||||
local x, y = bx + v.x * bw, by + v.y * bh
|
||||
local vw, vh = v.w * bw, v.h * bh
|
||||
if vw <= 0 or vh <= 0 then return nil end
|
||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||
end
|
||||
if page.screenFit == "remainder" then
|
||||
local x, y, vw, vh = remainderBox(ox, oy, w, h, bx, by, bw, bh)
|
||||
if not x then return nil end
|
||||
return x, y, vw, vh, false, false
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function TouchSkin.viewport(w, h, ox, oy)
|
||||
local page = TouchSkin.page()
|
||||
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
||||
local v = page.viewport
|
||||
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||
local x, y = bx + v.x * bw, by + v.y * bh
|
||||
local vw, vh = v.w * bw, v.h * bh
|
||||
if vw <= 0 or vh <= 0 then return nil end
|
||||
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||
if not page or not TouchSkin.drawable() then return nil end
|
||||
return TouchSkin.pageViewport(page, w, h, ox, oy)
|
||||
end
|
||||
|
||||
return TouchSkin
|
||||
|
||||
@@ -541,29 +541,6 @@ local function modRows(opts, mod)
|
||||
return rows
|
||||
end
|
||||
|
||||
local function troubleshootingRows(opts, hooks)
|
||||
return {
|
||||
{
|
||||
label = Strings("SAFE MODE"),
|
||||
actionLabel = function()
|
||||
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
|
||||
end,
|
||||
action = function()
|
||||
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
|
||||
return true
|
||||
end,
|
||||
},
|
||||
{
|
||||
label = Strings("REPORT ISSUE"),
|
||||
actionLabel = Strings("Report bug"),
|
||||
action = function()
|
||||
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
|
||||
return false
|
||||
end,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- ------- Gen 2 (Gold)
|
||||
--
|
||||
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
||||
@@ -744,10 +721,6 @@ function LauncherSettings.open(hooks, version)
|
||||
sections[#sections + 1] = { title = mod.name, rows = rows }
|
||||
end
|
||||
end
|
||||
sections[#sections + 1] = {
|
||||
title = Strings("TROUBLESHOOTING"),
|
||||
rows = troubleshootingRows(opts, hooks),
|
||||
}
|
||||
return {
|
||||
opts = opts,
|
||||
version = version,
|
||||
|
||||
+131
-38
@@ -406,6 +406,15 @@ local function cartQuad(project, x, y, w, h, z)
|
||||
}
|
||||
end
|
||||
|
||||
local function cartFacing(points)
|
||||
local area = 0
|
||||
for i = 1, #points do
|
||||
local a, b = points[i], points[i % #points + 1]
|
||||
area = area + a[1] * b[2] - b[1] * a[2]
|
||||
end
|
||||
return area > 0
|
||||
end
|
||||
|
||||
local function cartPill(project, x, y, w, h, z, color, alpha)
|
||||
local points, radius = {}, h / 2
|
||||
for i = 0, 10 do
|
||||
@@ -628,51 +637,64 @@ local function cartridgeButton(imp, x, y, w, h, key, version, gameName, action)
|
||||
local side = { math.floor(shell[1] * 0.54), math.floor(shell[2] * 0.54),
|
||||
math.floor(shell[3] * 0.54) }
|
||||
|
||||
cartPolygon(mainBack, side, 1)
|
||||
cartPolygon(capBack, side, 1)
|
||||
local frontFacing = cartFacing(mainFront)
|
||||
if frontFacing then
|
||||
cartPolygon(mainBack, side, 1)
|
||||
cartPolygon(capBack, side, 1)
|
||||
else
|
||||
cartPolygon(mainFront, shell, 1)
|
||||
cartPolygon(capFront, shell, 1)
|
||||
end
|
||||
cartPolygon({ mainFront[2], mainFront[3], mainBack[3], mainBack[2] }, side, 1)
|
||||
cartPolygon({ mainFront[3], mainFront[4], mainBack[4], mainBack[3] }, side, 1)
|
||||
cartPolygon({ mainFront[1], mainFront[2], mainBack[2], mainBack[1] }, side, 1)
|
||||
cartPolygon({ capFront[2], capFront[3], capBack[3], capBack[2] }, side, 1)
|
||||
cartPolygon({ capFront[1], capFront[2], capBack[2], capBack[1] }, side, 1)
|
||||
cartPolygon({ capFront[4], capFront[1], capBack[1], capBack[4] }, side, 1)
|
||||
cartPolygon(mainFront, shell, 1)
|
||||
cartPolygon(capFront, shell, 1)
|
||||
|
||||
local faceZ = depth + 0.8
|
||||
for i = 0, 4 do
|
||||
local ry = mainTop + 7 + i * h * 0.025
|
||||
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
||||
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
||||
if frontFacing then
|
||||
cartPolygon(mainFront, shell, 1)
|
||||
cartPolygon(capFront, shell, 1)
|
||||
else
|
||||
cartPolygon(mainBack, side, 1)
|
||||
cartPolygon(capBack, side, 1)
|
||||
end
|
||||
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
|
||||
local recessW, recessH = w * 0.64, h * 0.24
|
||||
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
|
||||
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
|
||||
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
|
||||
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
|
||||
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
|
||||
|
||||
local labelX, labelY = -w * 0.33, -h * 0.20
|
||||
local labelW, labelH = w * 0.66, h * 0.55
|
||||
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
|
||||
cartPolygon(plate, side, 0.95)
|
||||
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
|
||||
local label = cartridgeLabel(imp, version)
|
||||
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
|
||||
if mesh then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(mesh)
|
||||
elseif label then
|
||||
local artScale = math.min(labelW / label.width, labelH / label.height)
|
||||
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
|
||||
0, artScale, artScale)
|
||||
if frontFacing then
|
||||
local faceZ = depth + 0.8
|
||||
for i = 0, 4 do
|
||||
local ry = mainTop + 7 + i * h * 0.025
|
||||
cartPolygon(cartQuad(project, -halfW + 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
||||
cartPolygon(cartQuad(project, halfW - w * 0.13 - 2, ry, w * 0.13, 2, faceZ), side, 0.7)
|
||||
end
|
||||
local recessX, recessY = -w * 0.32, mainTop + h * 0.023
|
||||
local recessW, recessH = w * 0.64, h * 0.24
|
||||
cartPolygon(cartQuad(project, recessX, recessY, recessW, recessH, faceZ), shell, 0.88)
|
||||
cartPill(project, recessX + w * 0.025, recessY + h * 0.025,
|
||||
recessW - w * 0.05, h * 0.12, faceZ + 0.5, shell, 0.7)
|
||||
cartPill(project, recessX + w * 0.045, recessY + h * 0.043,
|
||||
recessW - w * 0.09, h * 0.083, faceZ + 0.8, side, 0.42)
|
||||
|
||||
local labelX, labelY = -w * 0.33, -h * 0.20
|
||||
local labelW, labelH = w * 0.66, h * 0.55
|
||||
local plate = cartQuad(project, labelX - 2, labelY - 2, labelW + 4, labelH + 4, faceZ + 0.8)
|
||||
cartPolygon(plate, side, 0.95)
|
||||
local labelPoints = cartQuad(project, labelX, labelY, labelW, labelH, faceZ + 1.2)
|
||||
local label = cartridgeLabel(imp, version)
|
||||
local mesh = label and cartLabelMesh(imp, version, label, labelPoints)
|
||||
if mesh then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(mesh)
|
||||
elseif label then
|
||||
local artScale = math.min(labelW / label.width, labelH / label.height)
|
||||
love.graphics.draw(label.image, labelPoints[1][1], labelPoints[1][2],
|
||||
0, artScale, artScale)
|
||||
end
|
||||
cartPolygon({
|
||||
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
|
||||
{ project(w * 0.07, h * 0.37, faceZ + 1) },
|
||||
{ project(0, h * 0.43, faceZ + 1) },
|
||||
}, side, 0.70)
|
||||
end
|
||||
cartPolygon({
|
||||
{ project(-w * 0.07, h * 0.37, faceZ + 1) },
|
||||
{ project(w * 0.07, h * 0.37, faceZ + 1) },
|
||||
{ project(0, h * 0.43, faceZ + 1) },
|
||||
}, side, 0.70)
|
||||
love.graphics.pop()
|
||||
|
||||
if not state.active and (Kit._activateId == key) then
|
||||
@@ -926,10 +948,13 @@ local HEADER_TABS = {
|
||||
{ id = "mods", key = "tab-mods" },
|
||||
{ id = "find", key = "tab-find" },
|
||||
{ id = "skins", key = "tab-skins", glyph = true },
|
||||
{ id = "bug", key = "tab-bug" },
|
||||
}
|
||||
for _, t in ipairs(HEADER_TABS) do
|
||||
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
||||
if t.glyph then t.opts.drawFn = drawSkinGlyph end
|
||||
if t.glyph then
|
||||
t.opts.drawFn = drawSkinGlyph
|
||||
end
|
||||
end
|
||||
|
||||
-- Which cartridge the dropdown is showing: the open game tab, else the last
|
||||
@@ -1067,6 +1092,8 @@ local function buildHeader(imp, m)
|
||||
or love.graphics.newImage("assets/launcher/mods.png")
|
||||
imp._findIcon = imp._findIcon
|
||||
or love.graphics.newImage("assets/launcher/find.png")
|
||||
imp._bugIcon = imp._bugIcon
|
||||
or love.graphics.newImage("assets/launcher/bug.png")
|
||||
-- Game tabs keep their cartridge colours -- that is the one piece of brand
|
||||
-- identity in the launcher, and "the red one" is how people actually refer
|
||||
-- to these. The colour rides the outline and the glyph at rest and becomes
|
||||
@@ -1077,6 +1104,7 @@ local function buildHeader(imp, m)
|
||||
for _, t in ipairs(tabs) do
|
||||
if t.id == "mods" then t.icon = imp._modsIcon end
|
||||
if t.id == "find" then t.icon = imp._findIcon end
|
||||
if t.id == "bug" then t.icon = imp._bugIcon end
|
||||
end
|
||||
local tabH = m.chip
|
||||
local tx = m.x + m.pad
|
||||
@@ -1923,7 +1951,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
||||
-- notice line
|
||||
local noticeText, noticeCol
|
||||
if safeMode then
|
||||
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow
|
||||
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in the Bug tab to change mod toggles.", PAL.yellow
|
||||
elseif imp.modNotice then
|
||||
noticeText = imp.modNotice.text
|
||||
noticeCol = imp.modNotice.ok and PAL.green or PAL.red
|
||||
@@ -2322,6 +2350,69 @@ local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||
return cy + hintH - y
|
||||
end
|
||||
|
||||
local function buildBugPanel(imp, x, y, w, availH, m)
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local gap = m.gap
|
||||
local pad = math.floor(16 * m.s)
|
||||
local cy = y
|
||||
local safeMode = imp:_safeModeEnabled()
|
||||
|
||||
Kit.text("button", Strings("Troubleshooting"), x, cy, PAL.heading)
|
||||
cy = cy + Kit.textHeight("button") + gap
|
||||
|
||||
if imp.issueNotice then
|
||||
cy = cy + Kit.textWrapped("small", imp.issueNotice.text, x, cy, w,
|
||||
imp.issueNotice.ok and PAL.green or PAL.red, 2) + gap
|
||||
end
|
||||
|
||||
local switchW = math.floor(92 * m.s)
|
||||
local switchH = math.max(m.btnH, Kit.tapMin())
|
||||
local detail = safeMode
|
||||
and Strings("All mods are disabled and their toggles are locked until safe mode is turned off.")
|
||||
or Strings("Temporarily disable every mod while you reproduce a bug.")
|
||||
local textW = math.max(0, w - 2 * pad - switchW - gap)
|
||||
local detailH = Kit.wrapHeight("small", detail, textW, 3)
|
||||
local safeH = math.max(switchH, Kit.textHeight("small") + math.floor(4 * m.s) + detailH)
|
||||
+ 2 * pad
|
||||
Kit.card(x, cy, w, safeH)
|
||||
local textX = x + pad
|
||||
local textY = cy + pad
|
||||
Kit.text("small", Strings("Safe mode"), textX, textY, PAL.heading)
|
||||
Kit.textWrapped("small", detail, textX,
|
||||
textY + Kit.textHeight("small") + math.floor(4 * m.s), textW,
|
||||
PAL.muted, 3)
|
||||
local toggleX = x + w - pad - switchW
|
||||
local toggleY = cy + math.floor((safeH - switchH) / 2)
|
||||
local _, changed = Kit.toggle(toggleX, toggleY, switchW, switchH, safeMode,
|
||||
"bug-safe-mode")
|
||||
if changed then
|
||||
queueAction(imp, "bug-safe-mode", function() imp:_toggleSafeMode() end)
|
||||
end
|
||||
cy = cy + safeH + gap
|
||||
|
||||
local reportLabel = Strings("Report a bug")
|
||||
local reportW = math.min(w - 2 * pad,
|
||||
Kit.textWidth("small", reportLabel) + math.floor(32 * m.s))
|
||||
local reportDetail = Strings("Fill out the GitHub form with the available system information.")
|
||||
local reportTextW = math.max(0, w - 2 * pad - reportW - gap)
|
||||
local reportDetailH = Kit.wrapHeight("small", reportDetail, reportTextW, 3)
|
||||
local reportH = math.max(m.btnH, Kit.textHeight("small") + math.floor(4 * m.s) + reportDetailH)
|
||||
+ 2 * pad
|
||||
Kit.card(x, cy, w, reportH)
|
||||
Kit.text("small", Strings("Something not working?"), textX, cy + pad, PAL.heading)
|
||||
Kit.textWrapped("small", reportDetail, textX,
|
||||
cy + pad + Kit.textHeight("small") + math.floor(4 * m.s), reportTextW,
|
||||
PAL.muted, 3)
|
||||
btn(imp, x + w - pad - reportW,
|
||||
cy + math.floor((reportH - m.btnH) / 2), reportW, m.btnH,
|
||||
"bug-report", reportLabel, {
|
||||
kind = "accent", font = "small",
|
||||
action = function()
|
||||
imp:_ensureMods()
|
||||
imp:_reportIssue(SaveData.loadOptions(), nil)
|
||||
end })
|
||||
end
|
||||
|
||||
local function buildFindPanel(imp, x, y, w, availH, m)
|
||||
imp:_ensureFind()
|
||||
imp:_ensureMods()
|
||||
@@ -4822,6 +4913,8 @@ function LauncherView.draw(imp)
|
||||
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
|
||||
elseif imp.tab == "skins" then
|
||||
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
|
||||
elseif imp.tab == "bug" then
|
||||
contentH = buildBugPanel(imp, x, py, panelW, budgetH, m)
|
||||
else
|
||||
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
|
||||
end
|
||||
|
||||
+34
-21
@@ -1266,6 +1266,7 @@ end
|
||||
function RomImporter:_applyLastVersionTab()
|
||||
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
||||
if okLO and LO.pendingTab then return end
|
||||
if os.getenv("POKEPORT_LAUNCHER_TAB") then return end
|
||||
local okOpt, opts = pcall(function()
|
||||
return require("src.core.SaveData").loadOptions()
|
||||
end)
|
||||
@@ -1337,7 +1338,7 @@ function RomImporter.new(onComplete, opts)
|
||||
-- player at least arrives on the tab they asked for (src/core/LaunchOptions).
|
||||
tab = (function()
|
||||
local okLO, LO = pcall(require, "src.core.LaunchOptions")
|
||||
return (okLO and LO.pendingTab) or "red"
|
||||
return (okLO and LO.pendingTab) or os.getenv("POKEPORT_LAUNCHER_TAB") or "red"
|
||||
end)(),
|
||||
logo = love.graphics.newImage("assets/logo/logo.png"),
|
||||
bcg = love.graphics.newImage("assets/logo/bcg.png"),
|
||||
@@ -1364,7 +1365,8 @@ function RomImporter.new(onComplete, opts)
|
||||
-- in draw); modNotice is the last install/delete result { ok, text }.
|
||||
-- requiredImportNotice stays inside the imported-files modal so validation
|
||||
-- failures are visible beside the file picker that caused them.
|
||||
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
|
||||
mods = nil, modScroll = 0, modNotice = nil, issueNotice = nil,
|
||||
requiredImportNotice = nil,
|
||||
-- Which game the MODS panel is answering for (a GameVersion id, nil =
|
||||
-- every game). Rows resolve their enable-state and their "runs here"
|
||||
-- verdict against it (src/mods/ModTargets.lua).
|
||||
@@ -2755,7 +2757,7 @@ function RomImporter:resumeAfterOverlay()
|
||||
end
|
||||
|
||||
function RomImporter:_cycleTab(delta)
|
||||
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
|
||||
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins", "bug" }
|
||||
local idx = 1
|
||||
for i, id in ipairs(order) do
|
||||
if id == self.tab then idx = i; break end
|
||||
@@ -3150,7 +3152,8 @@ function RomImporter:_ensureSkins(force)
|
||||
format = skin and skin.format or nil,
|
||||
pages = skin and #skin.pages or 0,
|
||||
controls = controls,
|
||||
screen = page ~= nil and page.viewport ~= nil,
|
||||
screen = page ~= nil
|
||||
and (page.viewport ~= nil or page.screenFit == "remainder"),
|
||||
ok = skin ~= nil,
|
||||
}
|
||||
end
|
||||
@@ -3629,9 +3632,6 @@ function RomImporter:_openSettings()
|
||||
-- option block, and Gold's is not the flat Gen 1 one (#1100).
|
||||
local hooks = {}
|
||||
local version = self.tab
|
||||
hooks.reportIssue = function(opts)
|
||||
return self:_reportIssue(opts, version)
|
||||
end
|
||||
if self.onEditTouchControls then
|
||||
local version = self.tab
|
||||
hooks.editTouchControls = function()
|
||||
@@ -3654,7 +3654,6 @@ function RomImporter:_openSettings()
|
||||
end)
|
||||
if ok and model then
|
||||
self._settings = model
|
||||
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3669,22 +3668,36 @@ function RomImporter:_closeSettings()
|
||||
local model = self._settings
|
||||
if model then
|
||||
model.save()
|
||||
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
|
||||
if safeMode ~= self._settingsSafeModeAtOpen then
|
||||
self.mods = nil
|
||||
self.safeMode = safeMode
|
||||
self._modSortCache = nil
|
||||
self._modInfoFetch = nil
|
||||
end
|
||||
end
|
||||
self._settings = nil
|
||||
self._settingsSafeModeAtOpen = nil
|
||||
end
|
||||
|
||||
function RomImporter:_safeModeEnabled()
|
||||
if self.safeMode == nil then
|
||||
local SaveData = require("src.core.SaveData")
|
||||
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
|
||||
end
|
||||
return self.safeMode == true
|
||||
end
|
||||
|
||||
function RomImporter:_toggleSafeMode()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local options = SaveData.loadOptions()
|
||||
local enabled = not SaveData.isSafeMode(options)
|
||||
SaveData.setSafeMode(options, enabled)
|
||||
SaveData.saveOptions(options)
|
||||
self.safeMode = enabled
|
||||
self.mods = nil
|
||||
self._modSortCache = nil
|
||||
self._modInfoFetch = nil
|
||||
self.modNotice = nil
|
||||
end
|
||||
|
||||
function RomImporter:_reportIssue(options, version)
|
||||
self.issueNotice = nil
|
||||
local ok, IssueReport = pcall(require, "src.core.IssueReport")
|
||||
if not ok then
|
||||
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
|
||||
self.issueNotice = { ok = false, text = "Could not prepare the issue report." }
|
||||
return false
|
||||
end
|
||||
local opened, url, reason = IssueReport.open(options, {
|
||||
@@ -3692,11 +3705,11 @@ function RomImporter:_reportIssue(options, version)
|
||||
mods = self.mods,
|
||||
})
|
||||
if not opened then
|
||||
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
|
||||
self.issueNotice = { ok = false, text = reason or "Could not open the issue report." }
|
||||
return false
|
||||
end
|
||||
self._lastIssueReportURL = url
|
||||
if reason then self.modNotice = { ok = true, text = reason } end
|
||||
if reason then self.issueNotice = { ok = true, text = reason } end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -4179,7 +4192,7 @@ end
|
||||
-- Enabling an experimental mod arms a confirmation for that same game.
|
||||
function RomImporter:_toggleMod(id, confirmed, version)
|
||||
if self.safeMode then
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
|
||||
return
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
@@ -4223,7 +4236,7 @@ end
|
||||
-- recovery action, and Delete is the only destructive one on this panel.
|
||||
function RomImporter:_setAllMods(want, confirmed)
|
||||
if self.safeMode then
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
|
||||
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in the Bug tab to change mods." }
|
||||
return
|
||||
end
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
|
||||
@@ -57,6 +57,8 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.money = opts and opts.money
|
||||
self.auto = opts and opts.auto
|
||||
self.stay = opts and opts.stay
|
||||
-- engine/events/hidden_events/cinnabar_gym_quiz.asm:119
|
||||
self.preSound = opts and opts.preSound
|
||||
-- opts.instant: put the LAST page up already typed, with no typewriter and
|
||||
-- no page waits. A `yesorno` follows a `writetext` that has already been
|
||||
-- read, so re-typing the line under the YES/NO box would be wrong -- the
|
||||
@@ -270,6 +272,17 @@ end
|
||||
function TextBox:update(dt)
|
||||
local input = self.game.input
|
||||
self.blink = (self.blink + 1) % 60
|
||||
-- home/text.asm:506
|
||||
if self.preSound then
|
||||
if not self.preStarted then
|
||||
self.preStarted = true
|
||||
self.preSrc = self.preSound()
|
||||
end
|
||||
if self.preSrc and self.preSrc.isPlaying and self.preSrc:isPlaying() then
|
||||
return
|
||||
end
|
||||
self.preSound, self.preSrc = nil, nil
|
||||
end
|
||||
-- A page or CONT advance blocks the whole box while the original's scroll
|
||||
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
|
||||
-- Nothing types and no input is read until it drains.
|
||||
|
||||
@@ -254,7 +254,8 @@ function Commands.give_item(ctx, itemId, count, gotText)
|
||||
Commands.show_text(ctx, gotText
|
||||
or Strings("{PLAYER} got\n%s!", ctx.game.stringBuffer))
|
||||
else
|
||||
Sound.play(ctx.game.data, jingle)
|
||||
-- scripts/OaksLab.asm:1058
|
||||
Commands.text_sound(ctx, jingle)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+13
-11
@@ -851,6 +851,7 @@ function Studio.detectViewport()
|
||||
end
|
||||
Studio.pushUndo()
|
||||
page.viewport = rect
|
||||
page.screenFit = nil
|
||||
setStatus(("Screen detected: %dx%d px in the bezel art"):format(pw, ph))
|
||||
Studio.dirty = true
|
||||
end
|
||||
@@ -860,8 +861,9 @@ function Studio.toggleViewport()
|
||||
if not page then return end
|
||||
if Studio.canvas().lockViewport then return end
|
||||
Studio.pushUndo()
|
||||
if page.viewport then
|
||||
if page.viewport or page.screenFit == "remainder" then
|
||||
page.viewport = nil
|
||||
page.screenFit = nil
|
||||
else
|
||||
page.viewport = { x = 0.1, y = 0.05, w = 0.8, h = 0.45 }
|
||||
end
|
||||
@@ -892,7 +894,7 @@ function Studio.pageLabel(index)
|
||||
local orient = TouchSkin.pageOrient(page)
|
||||
local bits = { #(page.controls or {}) .. " controls" }
|
||||
if orient then bits[#bits + 1] = orient end
|
||||
if page.viewport then bits[#bits + 1] = "screen" end
|
||||
if page.viewport or page.screenFit == "remainder" then bits[#bits + 1] = "screen" end
|
||||
return name, table.concat(bits, " \194\183 ")
|
||||
end
|
||||
|
||||
@@ -1092,10 +1094,9 @@ function Studio.snapLines(page, r, skipIndex)
|
||||
end
|
||||
|
||||
local function viewportRect(page, r)
|
||||
local v = page.viewport
|
||||
if not v then return nil end
|
||||
local bx, by, bw, bh = TouchSkin.pageBox(page, r.w, r.h, r.x, r.y)
|
||||
return bx + v.x * bw, by + v.y * bh, v.w * bw, v.h * bh
|
||||
local x, y, w, h = TouchSkin.pageViewport(page, r.w, r.h, r.x, r.y)
|
||||
if not x then return nil end
|
||||
return x, y, w, h
|
||||
end
|
||||
|
||||
local function handleRects(bx, by, bw, bh)
|
||||
@@ -1138,7 +1139,7 @@ function Studio.beginCanvasDrag(mx, my, r)
|
||||
end
|
||||
|
||||
local vx, vy, vw, vh = viewportRect(page, r)
|
||||
if vx and not Studio.canvas().lockViewport then
|
||||
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
|
||||
for _, h in ipairs(handleRects(vx, vy, vw, vh)) do
|
||||
if mx >= h.x and mx <= h.x + h.w and my >= h.y and my <= h.y + h.h then
|
||||
Studio.pushUndo()
|
||||
@@ -1162,7 +1163,7 @@ function Studio.beginCanvasDrag(mx, my, r)
|
||||
end
|
||||
end
|
||||
|
||||
if vx and not Studio.canvas().lockViewport
|
||||
if vx and not Studio.canvas().lockViewport and page.screenFit ~= "remainder"
|
||||
and mx >= vx and mx <= vx + vw and my >= vy and my <= vy + vh then
|
||||
Studio.selected = nil
|
||||
Studio.pushUndo()
|
||||
@@ -1270,7 +1271,7 @@ local function drawCanvas(x, y, w, h)
|
||||
if vx then
|
||||
Theme.strokeRounded(vx, vy, vw, vh, PAL.blue, 0.9, 2, 2)
|
||||
Kit.text("small", "SCREEN", vx + 4 * Kit.scale, vy + 4 * Kit.scale, PAL.blue)
|
||||
if not Studio.canvas().lockViewport then
|
||||
if not Studio.canvas().lockViewport and page.screenFit ~= "remainder" then
|
||||
local a = Studio.selectedControl() and 0.45 or 1
|
||||
for _, hd in ipairs(handleRects(vx, vy, vw, vh)) do
|
||||
Theme.fill(hd.x, hd.y, hd.w, hd.h, PAL.blue, a)
|
||||
@@ -1374,7 +1375,7 @@ local function inspectorBody(x, y, w)
|
||||
cy = cy + rowH + gap
|
||||
|
||||
if page then
|
||||
local bezel = page.imagePath or "(none)"
|
||||
local bezel = page.imagePath or page.rasterName or "(none)"
|
||||
local pickW = 82 * Kit.scale
|
||||
local cycleW = w - pickW - gap
|
||||
if Kit.button(x, cy, cycleW, rowH, "Bezel: " .. bezel, { id = "bezel" }) then
|
||||
@@ -1385,7 +1386,8 @@ local function inspectorBody(x, y, w)
|
||||
Studio.importImageFile("bezel")
|
||||
end
|
||||
cy = cy + rowH + gap
|
||||
local vpLabel = page.viewport and "Screen cutout: ON" or "Screen cutout: OFF"
|
||||
local vpLabel = (page.viewport or page.screenFit == "remainder")
|
||||
and "Screen cutout: ON" or "Screen cutout: OFF"
|
||||
if Kit.button(x, cy, half, rowH, vpLabel, { id = "vp",
|
||||
enabled = not Studio.canvas().lockViewport }) then
|
||||
Studio.toggleViewport()
|
||||
|
||||
@@ -537,6 +537,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
|
||||
-- Route22Gate_Script rewrites wLastMap from the player's Y on entry
|
||||
-- too (not only on step), so a save/load mid-gate keeps exits correct
|
||||
if not (opts and opts.checkpoint) then self:syncLastMapRewrite() end
|
||||
-- home/overworld.asm:1821 (JoypadOverworld runs RunMapScript every frame);
|
||||
-- scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21
|
||||
if opts and opts.freshBoot and not opts.checkpoint then
|
||||
local standing = mapScripts and mapScripts.get(mapId)
|
||||
if not (standing and standing.onStep
|
||||
and standing.onStep(Game, self, self.player.cellX, self.player.cellY)) then
|
||||
self:checkBadgeGate()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Neighbor maps drawn at the composed connection offsets: at least the
|
||||
@@ -2910,7 +2919,15 @@ function OverworldState:openPC(onDone)
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||
Screens.push(Game, "BoxMenu")
|
||||
-- engine/menus/pc.asm:73 BillsPC prints the access text before the farcall
|
||||
local accessed = metBill
|
||||
and romText(Game.data, "_AccessedBillsPCText",
|
||||
"Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System.")
|
||||
or romText(Game.data, "_AccessedSomeonesPCText",
|
||||
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System.")
|
||||
Game.stack:push(TextBox.new(Game, accessed, function()
|
||||
Screens.push(Game, "BoxMenu")
|
||||
end))
|
||||
done()
|
||||
end,
|
||||
})
|
||||
@@ -2920,9 +2937,13 @@ function OverworldState:openPC(onDone)
|
||||
label = (Game.save.player.name or "RED") .. "'s PC",
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
-- pc.asm .playersPC plays SFX_ENTER_PC before the farcall (#960)
|
||||
-- pc.asm .playersPC plays SFX_ENTER_PC then prints AccessedMyPCText
|
||||
-- before the farcall (engine/menus/pc.asm:54, #960)
|
||||
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||
Screens.push(Game, "PlayerPC")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
romText(Game.data, "_AccessedMyPCText",
|
||||
"Accessed my PC.\fAccessed Item\nStorage System."),
|
||||
function() Screens.push(Game, "PlayerPC") end))
|
||||
done()
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -126,6 +126,10 @@ local PLAYER_STATE_BY_ID = {
|
||||
[8] = FieldMoves.PLAYER_SURF_PIKA,
|
||||
}
|
||||
|
||||
-- engine/overworld/variables.asm:49 VAR_MOVEMENT reads wPlayerState back
|
||||
local PLAYER_STATE_ID = {}
|
||||
for id, state in pairs(PLAYER_STATE_BY_ID) do PLAYER_STATE_ID[state] = id end
|
||||
|
||||
local BATTLETYPE = {
|
||||
CANLOSE = 1,
|
||||
FORCESHINY = 7,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- The bike shop's "A shiny new BICYCLE!" is six hidden_event rows, not a
|
||||
-- bg_event: data/events/hidden_events.asm:542-549 points every one of them
|
||||
-- at PrintNewBikeText (engine/events/hidden_events/new_bike.asm:1), which
|
||||
-- tx_pre_jumps NewBicycleText with ANY_FACING and no gating. The port's
|
||||
-- field extractor lifts none of that family, so BIKE_SHOP had no display
|
||||
-- text at all (#1530).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local pushed
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
|
||||
}
|
||||
|
||||
local scripts = require("data.scripts.flavor.bike_shop")
|
||||
local onInteract = scripts.BIKE_SHOP.onInteract
|
||||
T.check(type(onInteract) == "function", "BIKE_SHOP carries an onInteract hook")
|
||||
|
||||
local game = {
|
||||
data = { text = { _NewBicycleText = "A shiny new\nBICYCLE!" } },
|
||||
stack = { push = function(_, box) pushed = box end },
|
||||
}
|
||||
|
||||
-- data/events/hidden_events.asm:543-548 (the macro emits y then x, so the
|
||||
-- source pairs read x, y)
|
||||
for _, cell in ipairs({ { 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 } }) do
|
||||
pushed = nil
|
||||
local consumed = onInteract(game, {}, cell[1], cell[2])
|
||||
T.eq(consumed, true, ("(%d,%d) is a display tile"):format(cell[1], cell[2]))
|
||||
T.check(pushed ~= nil and pushed.text == game.data.text._NewBicycleText,
|
||||
("(%d,%d) prints _NewBicycleText"):format(cell[1], cell[2]))
|
||||
end
|
||||
|
||||
-- data/generated/maps.lua BIKE_SHOP object_events sit at (6,2), (5,6) and
|
||||
-- (1,3): none of the six, so the hook never steals an NPC's talk
|
||||
for _, cell in ipairs({ { 4, 4 }, { 6, 2 }, { 5, 6 }, { 1, 3 }, { 0, 0 } }) do
|
||||
pushed = nil
|
||||
local consumed = onInteract(game, {}, cell[1], cell[2])
|
||||
T.eq(consumed, false, ("(%d,%d) is not a display tile"):format(cell[1], cell[2]))
|
||||
T.eq(pushed, nil, "and pushes nothing")
|
||||
end
|
||||
|
||||
-- with no cache text the hook still prints the line
|
||||
pushed = nil
|
||||
onInteract({ data = {}, stack = game.stack }, {}, 1, 0)
|
||||
T.check(pushed ~= nil and pushed.text:find("BICYCLE", 1, true) ~= nil,
|
||||
"a dataset without the label falls back to the engine wording")
|
||||
|
||||
T.finish("bike shop display text (#1530)")
|
||||
@@ -146,12 +146,34 @@ T.eq(resumed, 1, "the script resumed once")
|
||||
|
||||
-- ------------------------------------------------------------- plain gift
|
||||
-- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE
|
||||
-- BALLs): nothing to hang the sound on, so it plays on the spot
|
||||
-- BALLs): the received text the script prints next carries the jingle
|
||||
-- (scripts/OaksLab.asm:1058-1062), so give_item arms it and plays nothing
|
||||
plays = {}
|
||||
Commands.give_item(ctx, "FIX_POTION", 1, false)
|
||||
T.eq(jingles(), 1, "the no-text gift still plays its jingle immediately")
|
||||
T.eq(plays[1], "item.wav", "with the plain-item sound")
|
||||
T.eq(jingles(), 0, "the no-text gift plays nothing on the spot")
|
||||
T.eq(stack:top(), nil, "and pushes no box of its own")
|
||||
T.check(ctx.textOpts and ctx.textOpts.auto and ctx.textOpts.auto.sound ~= nil,
|
||||
"the jingle is armed for the next show_text")
|
||||
|
||||
Commands.show_text(ctx, "{PLAYER} GOT\nSOMETHING!")
|
||||
local box2 = stack:top()
|
||||
T.check(getmetatable(box2) == TextBox, "the script's own received box is up")
|
||||
T.eq(ctx.textOpts, nil, "show_text consumed the armed jingle")
|
||||
for _ = 1, 2000 do
|
||||
if box2.done then break end
|
||||
step(box2.waiting and "a" or nil)
|
||||
end
|
||||
T.check(box2.done, "the received text typed out")
|
||||
T.eq(jingles(), 0, "silent until the last character is placed")
|
||||
step()
|
||||
T.eq(jingles(), 1, "the jingle fires once the text is out")
|
||||
T.eq(plays[#plays], "item.wav", "with the plain-item sound")
|
||||
step("a")
|
||||
T.eq(stack:top(), box2, "A does not close the box during the jingle")
|
||||
sources["item.wav"].playing = false
|
||||
step()
|
||||
step("a")
|
||||
T.eq(stack:top(), nil, "A closes the box after the jingle")
|
||||
|
||||
-- ------------------------------------------------------------ script data
|
||||
-- both Viridian Mart paths must hand give_item the quest text, since that
|
||||
|
||||
@@ -29,4 +29,12 @@ check(patch:find("int w_pickFileKinds", 1, true)
|
||||
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
|
||||
"iOS build patch compiles the required-import picker bridge")
|
||||
|
||||
local bootstrap = read("mobile/ios/native/GRBootstrap.m")
|
||||
check(bootstrap:find("struct utsname", 1, true)
|
||||
and bootstrap:find("SIMULATOR_MODEL_IDENTIFIER", 1, true),
|
||||
"iOS native bridge reads the hardware model on device and simulator")
|
||||
check(patch:find("int w_getDeviceModel", 1, true)
|
||||
and patch:find('{ "getDeviceModel", w_getDeviceModel }', 1, true),
|
||||
"iOS liblove patch exposes the hardware model to Lua")
|
||||
|
||||
print("ios_required_import_picker_test: ok")
|
||||
|
||||
@@ -50,20 +50,34 @@ local imp = read("src/import/RomImporter.lua")
|
||||
|
||||
check(view:find('id = "skins"', 1, true) ~= nil,
|
||||
"LauncherView registers a skins tab")
|
||||
check(view:find('id = "bug"', 1, true) ~= nil,
|
||||
"LauncherView registers a bug tab")
|
||||
check(view:find("drawSkinGlyph", 1, true) ~= nil,
|
||||
"the skins tab draws its own glyph rather than shipping an asset")
|
||||
check(view:find('assets/launcher/bug.png', 1, true) ~= nil,
|
||||
"the bug tab uses the standard bug report asset")
|
||||
-- the tab has to be next to Find, which is what the request was
|
||||
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
|
||||
check(order ~= nil, "HEADER_TABS found")
|
||||
if order then
|
||||
local findAt = order:find('id = "find"', 1, true)
|
||||
local skinsAt = order:find('id = "skins"', 1, true)
|
||||
local bugAt = order:find('id = "bug"', 1, true)
|
||||
check(findAt and skinsAt and skinsAt > findAt,
|
||||
"the skins tab sits immediately after Find")
|
||||
check(skinsAt and bugAt and bugAt > skinsAt,
|
||||
"the bug tab sits after Skins")
|
||||
end
|
||||
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
|
||||
"the panel dispatch routes the skins tab")
|
||||
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
|
||||
check(view:find('imp.tab == "bug"', 1, true) ~= nil,
|
||||
"the panel dispatch routes the bug tab")
|
||||
check(view:find("buildBugPanel", 1, true) ~= nil, "and the bug panel builds it")
|
||||
check(view:find('Kit.toggle', 1, true) ~= nil,
|
||||
"the bug panel uses a switch for safe mode")
|
||||
check(view:find('bug-report', 1, true) ~= nil,
|
||||
"the bug panel has a report action")
|
||||
-- the panel must not offer the studio when the host did not supply it
|
||||
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
|
||||
"the Studio button is hidden without a host hook (mobile)")
|
||||
@@ -79,8 +93,15 @@ check(imp:find("_installMod", 1, true) ~= nil,
|
||||
local cycle = imp:match("local order = %{(.-)%}")
|
||||
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
|
||||
"shoulder-button tab cycling reaches the skins tab")
|
||||
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
|
||||
"shoulder-button tab cycling reaches the bug tab")
|
||||
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
|
||||
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
|
||||
"switching to the tab re-reads the skin list")
|
||||
check(imp:find('function RomImporter:_safeModeEnabled', 1, true) ~= nil
|
||||
and imp:find('function RomImporter:_toggleSafeMode', 1, true) ~= nil,
|
||||
"the importer owns the safe mode state")
|
||||
check(imp:find('function RomImporter:_reportIssue', 1, true) ~= nil,
|
||||
"the importer owns issue report opening")
|
||||
|
||||
T.finish("launcher_skins_tab")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- engine/menus/pc.asm prints the access text between SFX_ENTER_PC and the
|
||||
-- farcall: BillsPC (:73-85) picks AccessedBillsPCText / AccessedSomeonesPCText
|
||||
-- off EVENT_MET_BILL, .playersPC (:50-59) prints AccessedMyPCText. The port
|
||||
-- opened BoxMenu / PlayerPC straight from the sound (#1529).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
Data.text._TurnedOnPC1Text = "{PLAYER} turned on\nthe PC."
|
||||
Data.text._AccessedBillsPCText = "Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System."
|
||||
Data.text._AccessedSomeonesPCText =
|
||||
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System."
|
||||
Data.text._AccessedMyPCText = "Accessed my PC.\fAccessed Item\nStorage System."
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local function setUpvalue(fn, name, val)
|
||||
local i = 1
|
||||
while true do
|
||||
local n = debug.getupvalue(fn, i)
|
||||
if not n then return false end
|
||||
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local pushed, screens = {}, {}
|
||||
local stackStub = { push = function(_, item) pushed[#pushed + 1] = item end }
|
||||
local textBoxStub = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { kind = "text", text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
}
|
||||
local menuStub = {
|
||||
new = function(_, items, opts) return { kind = "menu", items = items, opts = opts or {} } end,
|
||||
}
|
||||
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
|
||||
package.loaded["src.ui.Menu"] = menuStub
|
||||
|
||||
local fakeGame = { data = Data, save = SaveData.newGame(), stack = stackStub }
|
||||
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
|
||||
T.check(setUpvalue(OW.openPC, "TextBox", textBoxStub), "TextBox upvalue on openPC")
|
||||
T.check(setUpvalue(OW.openPC, "Screens",
|
||||
{ push = function(_, id) screens[#screens + 1] = id end }), "Screens upvalue on openPC")
|
||||
|
||||
local fakeSelf = setmetatable({}, { __index = OW })
|
||||
|
||||
local function openMenu(metBill)
|
||||
pushed, screens = {}, {}
|
||||
fakeGame.save = SaveData.newGame()
|
||||
if metBill then fakeGame.save.flags.EVENT_MET_BILL = true end
|
||||
fakeSelf:openPC(function() end)
|
||||
local pcOn = pushed[#pushed]
|
||||
T.eq(pcOn.kind, "text", "the session opens with TurnedOnPC1Text")
|
||||
pcOn.onDone()
|
||||
local menu = pushed[#pushed]
|
||||
T.eq(menu.kind, "menu", "then the PC menu")
|
||||
return menu
|
||||
end
|
||||
|
||||
-- === SOMEONE'S PC: the access text before BoxMenu
|
||||
do
|
||||
local menu = openMenu(false)
|
||||
menu.items[1].onSelect()
|
||||
local box = pushed[#pushed]
|
||||
T.eq(box.kind, "text", "the box row opens a text box")
|
||||
T.check(tostring(box.text):find("Accessed someone's", 1, true) ~= nil,
|
||||
"before meeting BILL it is AccessedSomeonesPCText")
|
||||
T.eq(#screens, 0, "and the box screen has NOT opened yet")
|
||||
box.onDone()
|
||||
T.eq(screens[1], "BoxMenu", "BoxMenu follows the text, as the farcall does")
|
||||
end
|
||||
|
||||
-- === BILL'S PC once EVENT_MET_BILL is set
|
||||
do
|
||||
local menu = openMenu(true)
|
||||
menu.items[1].onSelect()
|
||||
local box = pushed[#pushed]
|
||||
T.check(tostring(box.text):find("Accessed BILL's", 1, true) ~= nil,
|
||||
"after meeting BILL it is AccessedBillsPCText")
|
||||
box.onDone()
|
||||
T.eq(screens[1], "BoxMenu", "and still opens BoxMenu")
|
||||
end
|
||||
|
||||
-- === the player's item storage
|
||||
do
|
||||
local menu = openMenu(false)
|
||||
menu.items[2].onSelect()
|
||||
local box = pushed[#pushed]
|
||||
T.eq(box.kind, "text", "the item row opens a text box")
|
||||
T.check(tostring(box.text):find("Accessed my PC.", 1, true) ~= nil,
|
||||
"and it is AccessedMyPCText")
|
||||
T.eq(#screens, 0, "PlayerPC has not opened yet")
|
||||
box.onDone()
|
||||
T.eq(screens[1], "PlayerPC", "PlayerPC follows the text")
|
||||
end
|
||||
|
||||
T.finish("PC access text (#1529)")
|
||||
@@ -0,0 +1,152 @@
|
||||
-- A landed secondary POISON runs PoisonEffect's tail
|
||||
-- (engine/battle/effects.asm:119-151): SHAKE_SCREEN_ANIM when the foe
|
||||
-- poisoned you, ENEMY_HUD_SHAKE_ANIM when you poisoned the foe, through
|
||||
-- PlayBattleAnimation2 (:1461-1471), which also stamps wAnimationType 6 /
|
||||
-- 3 so the slow applying shake runs even with battle animations off.
|
||||
-- FreezeBurnParalyzeEffect (:194-255) zeroes wAnimationType and only
|
||||
-- shakes the enemy HUD on the player's turn. The port queued neither
|
||||
-- (#1526).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
Data.moves.FIX_POISON_STING = {
|
||||
id = "FIX_POISON_STING", index = 5, name = "FIX PSN STING", type = "POISON",
|
||||
power = 15, accuracy = 100, pp = 35, effect = "POISON_SIDE_EFFECT1",
|
||||
}
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 40)
|
||||
battle.rng = function() return 0 end -- every roll lands
|
||||
return battle
|
||||
end
|
||||
|
||||
local function animRows(battle)
|
||||
local rows = {}
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.anim then rows[#rows + 1] = item end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function find(rows, name)
|
||||
for i, row in ipairs(rows) do
|
||||
if row.anim == name then return i, row end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function textIndex(battle, needle)
|
||||
for i, item in ipairs(battle.queue) do
|
||||
if item.text and item.text:find(needle, 1, true) then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function animIndex(battle, name)
|
||||
for i, item in ipairs(battle.queue) do
|
||||
if item.anim == name then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the foe poisons you: SE_SHAKE_SCREEN plus wAnimationType 3
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.enemy, battle.player, { id = "FIX_POISON_STING", pp = 35 })
|
||||
T.eq(battle.player.mon.status, "PSN", "the secondary poison landed")
|
||||
local rows = animRows(battle)
|
||||
local i, row = find(rows, "SHAKE_SCREEN_ANIM")
|
||||
T.check(i ~= nil, "the enemy's turn queues SHAKE_SCREEN_ANIM")
|
||||
T.eq(row.attackerIsPlayer, false, "attributed to the enemy side")
|
||||
T.eq(row.hit and row.hit.animType, 3, "wAnimationType 3 rides the row")
|
||||
T.eq(row.animDelayed, true, "PlayBattleAnimationGotID pays no Delay3")
|
||||
local moveIdx = animIndex(battle, "FIX_POISON_STING")
|
||||
local shakeIdx = animIndex(battle, "SHAKE_SCREEN_ANIM")
|
||||
local textIdx = textIndex(battle, "poisoned")
|
||||
T.check(moveIdx and shakeIdx and moveIdx < shakeIdx,
|
||||
"the move animation still runs first")
|
||||
T.check(textIdx and shakeIdx < textIdx,
|
||||
"PlayBattleAnimation2 precedes PrintText (effects.asm:149-151)")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- you poison the foe: SE_SHAKE_ENEMY_HUD plus wAnimationType 6
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_POISON_STING", pp = 35 })
|
||||
T.eq(battle.enemy.mon.status, "PSN", "the secondary poison landed")
|
||||
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
|
||||
T.check(row ~= nil, "the player's turn queues ENEMY_HUD_SHAKE_ANIM")
|
||||
T.eq(row.attackerIsPlayer, true, "attributed to the player side")
|
||||
T.eq(row.hit and row.hit.animType, 6, "wAnimationType 6 rides the row")
|
||||
T.check(find(animRows(battle), "SHAKE_SCREEN_ANIM") == nil,
|
||||
"and never the enemy-side id")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- burn takes the FreezeBurnParalyzeEffect arms: HUD shake on the player's
|
||||
-- turn only, and no wAnimationType at all
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_EMBERISH", pp = 25 })
|
||||
T.eq(battle.enemy.mon.status, "BRN", "the secondary burn landed")
|
||||
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
|
||||
T.check(row ~= nil, "the player's burn shakes the enemy HUD")
|
||||
T.eq(row.hit, nil, "FreezeBurnParalyzeEffect zeroes wAnimationType")
|
||||
|
||||
local other = newBattle()
|
||||
other.queue, other.nextInsert = {}, 0
|
||||
other:performMove(other.enemy, other.player, { id = "FIX_EMBERISH", pp = 25 })
|
||||
T.eq(other.player.mon.status, "BRN", "the enemy's burn landed too")
|
||||
T.check(find(animRows(other), "ENEMY_HUD_SHAKE_ANIM") == nil,
|
||||
"the enemy's-turn arm plays nothing")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- a plain damaging move queues neither
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_TACKLE", pp = 35 })
|
||||
local rows = animRows(battle)
|
||||
T.check(find(rows, "ENEMY_HUD_SHAKE_ANIM") == nil
|
||||
and find(rows, "SHAKE_SCREEN_ANIM") == nil,
|
||||
"no status, no PlayBattleAnimation2 row")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the residual tick plays BURN_PSN_ANIM with NO shake: core.asm:490-491
|
||||
-- explicitly zeroes wAnimationType before it
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.player.mon.status = "PSN"
|
||||
battle:residualFor(battle.player, battle.enemy)
|
||||
local _, row = find(animRows(battle), "BURN_PSN_ANIM")
|
||||
T.check(row ~= nil, "the poison tick animates")
|
||||
T.eq(row.hit, nil, "with no applying-attack shake")
|
||||
T.eq(row.attackerIsPlayer, true, "on the hurt mon's side")
|
||||
end
|
||||
|
||||
T.finish("secondary status animation (#1526)")
|
||||
@@ -0,0 +1,94 @@
|
||||
-- JoypadOverworld calls RunMapScript every overworld frame before input is
|
||||
-- even read (home/overworld.asm:1816-1821), and the gate guards are
|
||||
-- per-frame "is the player standing on these coords" checks
|
||||
-- (scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21).
|
||||
-- The port only evaluated them on a completed step, so saving on a guard's
|
||||
-- tile and reloading walked past the guard (#1547).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
Data.tilesets.FIX_OUT.tilesPerRow = 16
|
||||
Data.field.flyWarps = Data.field.flyWarps or {}
|
||||
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||
Data.field.waterTilesets = {}
|
||||
Data.field.forcedMovement = { tiles = {} }
|
||||
Data.audio = Data.audio or {}
|
||||
Data.audio.songs = Data.audio.songs or {}
|
||||
Data.audio.mapSongs = Data.audio.mapSongs or {}
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Game = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
|
||||
Game.data = Data
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.name = "RED"
|
||||
Game.save.player.map = "FIX_TOWN"
|
||||
StateStack:init()
|
||||
Game.stack = StateStack
|
||||
Game.overworld = OverworldState
|
||||
Game.input = {
|
||||
isDown = function() return false end,
|
||||
wasPressed = function() return false end,
|
||||
step = function() end, state = {}, pressQueue = {},
|
||||
}
|
||||
Game.renderer = {
|
||||
beginWorldPass = function() end, endWorldPass = function() end,
|
||||
beginUIPass = function() end, endUIPass = function() end,
|
||||
worldViewSize = function() return 160, 144 end,
|
||||
setSGBZones = function() end,
|
||||
}
|
||||
|
||||
local TRIGGER_X, TRIGGER_Y = 4, 4
|
||||
local fired = {}
|
||||
MapScripts.attachBase("FIX_TOWN", {
|
||||
onStep = function(_, _, x, y)
|
||||
if x == TRIGGER_X and y == TRIGGER_Y then
|
||||
fired[#fired + 1] = { x, y }
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
})
|
||||
|
||||
local function loadedSave()
|
||||
local save = SaveData.newGame()
|
||||
save.player.map = "FIX_TOWN"
|
||||
save.player.x, save.player.y = TRIGGER_X, TRIGGER_Y
|
||||
return save
|
||||
end
|
||||
|
||||
-- === the exploit: F2 / CONTINUE onto the guard's tile re-fires the guard
|
||||
fired = {}
|
||||
Game:restoreSave(loadedSave(), false, { freshBoot = true })
|
||||
T.eq(#fired, 1, "a freshBoot restore re-evaluates the standing-tile trigger")
|
||||
T.same(fired[1], { TRIGGER_X, TRIGGER_Y },
|
||||
"at the coords the save left the player on")
|
||||
|
||||
-- === an ordinary warp arrival must NOT fire it
|
||||
fired = {}
|
||||
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", {})
|
||||
T.eq(#fired, 0, "a plain warp arrival still leaves the trigger to onStepComplete")
|
||||
|
||||
-- === dev tooling reuses opts.via == "boot" WITHOUT freshBoot
|
||||
fired = {}
|
||||
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", { via = "boot" })
|
||||
T.eq(#fired, 0, "the console warp / hot reload shape does not fire it")
|
||||
|
||||
-- === checkpoint resume must never re-run map scripts
|
||||
fired = {}
|
||||
Game:restoreCheckpointSave(loadedSave())
|
||||
T.eq(#fired, 0, "a checkpoint resume re-runs nothing")
|
||||
|
||||
-- === restoring somewhere harmless fires nothing
|
||||
fired = {}
|
||||
local elsewhere = loadedSave()
|
||||
elsewhere.player.x, elsewhere.player.y = 3, 3
|
||||
Game:restoreSave(elsewhere, false, { freshBoot = true })
|
||||
T.eq(#fired, 0, "a restore off the trigger cell is untouched")
|
||||
|
||||
T.finish("standing-tile triggers on restore (#1547)")
|
||||
@@ -0,0 +1,87 @@
|
||||
-- The Nugget Bridge recruiter has no def_trainers header, so the port's
|
||||
-- headerless engageTrainer fallback re-printed his contest line as the
|
||||
-- pre-battle box (#1550) and his loss line never reached the battle
|
||||
-- screen (#1551). scripts/Route24.asm:120-134: .JoinTeamRocketText, then
|
||||
-- SaveEndBattleTextPointers with .DefeatedText, then EngageMapTrainer with
|
||||
-- no further box; Route24AfterRocketBattleScript (:62-78) prints
|
||||
-- .YouCouldBecomeATopLeaderText on the map after the win.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
Data.items.NUGGET = Data.items.NUGGET
|
||||
or { id = "NUGGET", index = 49, name = "NUGGET", price = 10000 }
|
||||
Data.text._Route24CooltrainerM1YouBeatOurContestText =
|
||||
"Congratulations!\nYou beat our 5\ncontest trainers!"
|
||||
Data.text._Route24CooltrainerM1YouJustEarnedAPrizeText = "You just earned\na prize!"
|
||||
Data.text._Route24CooltrainerM1ReceivedNuggetText = "{PLAYER} got\n{RAM:wStringBuffer}!"
|
||||
Data.text._Route24CooltrainerM1JoinTeamRocketText = "Want to join us?"
|
||||
Data.text._Route24CooltrainerM1DefeatedText = "Arrgh!\nYou are good!"
|
||||
Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText =
|
||||
"With your ability,\nyou could become\na top leader!"
|
||||
|
||||
local pushed = {}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
substitute = function(_, s) return s end,
|
||||
soundOpts = function(_, sound, opts)
|
||||
opts = opts or {}
|
||||
opts.auto = { sound = sound, wait = true, delay = 0 }
|
||||
return opts
|
||||
end,
|
||||
}
|
||||
|
||||
local scripts = dofile("data/scripts/story4.lua")
|
||||
local handler = scripts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M1
|
||||
T.check(type(handler) == "function", "the recruiter has a hand-ported handler")
|
||||
|
||||
local game = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||
}
|
||||
|
||||
local defeated = false
|
||||
local engaged
|
||||
local ow = {
|
||||
trainerDefeated = function() return defeated end,
|
||||
engageTrainer = function(_, npc, onDone, endBattleText, skipBattleText)
|
||||
engaged = { npc = npc, onDone = onDone,
|
||||
endBattleText = endBattleText, skipBattleText = skipBattleText }
|
||||
end,
|
||||
}
|
||||
local npc = { id = "ROUTE24_ROCKET", def = { index = 1 } }
|
||||
|
||||
-- the prize has already been taken: the talk goes straight to the battle
|
||||
game.save.flags.EVENT_GOT_NUGGET = true
|
||||
local doneCalls = 0
|
||||
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
|
||||
|
||||
T.check(engaged ~= nil, "the recruiter engages")
|
||||
T.eq(#pushed, 0, "no text box is pushed before the battle (#1550)")
|
||||
T.eq(engaged.skipBattleText, true,
|
||||
"skipBattleText stops the map text becoming the pre-battle box")
|
||||
T.eq(engaged.endBattleText, Data.text._Route24CooltrainerM1DefeatedText,
|
||||
"the loss line rides the battle, as SaveEndBattleTextPointers does (#1551)")
|
||||
|
||||
-- the win: Route24AfterRocketBattleScript prints the top-leader line
|
||||
defeated = true
|
||||
engaged.onDone()
|
||||
T.eq(#pushed, 1, "the win prints exactly one box")
|
||||
T.eq(pushed[1].text, Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
"and it is .YouCouldBecomeATopLeaderText")
|
||||
pushed[1].onDone()
|
||||
T.eq(doneCalls, 1, "control returns once the box closes")
|
||||
|
||||
-- the blackout arm: wIsInBattle == $ff rets before the DisplayTextID
|
||||
pushed, defeated, doneCalls = {}, false, 0
|
||||
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
|
||||
engaged.onDone()
|
||||
T.eq(#pushed, 0, "a loss prints nothing")
|
||||
T.eq(doneCalls, 1, "and just unfreezes the player")
|
||||
|
||||
T.finish("Nugget Bridge Rocket battle text (#1550, #1551)")
|
||||
@@ -41,7 +41,8 @@ _G.love = {
|
||||
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
|
||||
system = {
|
||||
getOS = function() return "iOS" end,
|
||||
getModel = function() return "iPad Test" end,
|
||||
getDeviceModel = function() return "iPhone16,2" end,
|
||||
getModel = function() return "Apple A17 Pro GPU" end,
|
||||
openURL = function(url) openedURL = url end,
|
||||
},
|
||||
graphics = {
|
||||
@@ -78,10 +79,13 @@ check(not url:find("game=", 1, true)
|
||||
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
|
||||
and fields.steps == "" and fields.expected == "",
|
||||
"report leaves user-entered fields blank")
|
||||
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil
|
||||
check(info.device == "iPhone 15 Pro Max"
|
||||
and info.metadata:find("Device: iPhone 15 Pro Max", 1, true) ~= nil
|
||||
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
|
||||
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
|
||||
"report metadata includes device and app details")
|
||||
check(not info.metadata:find("Simulator GPU", 1, true),
|
||||
"report metadata does not mistake the renderer device for the device")
|
||||
check(not info.metadata:find("unknown", 1, true),
|
||||
"report metadata omits unknown values")
|
||||
check(not info.metadata:find("Game id", 1, true)
|
||||
@@ -104,25 +108,31 @@ check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
|
||||
|
||||
local previousOS = love.system.getOS
|
||||
local previousModel = love.system.getModel
|
||||
local previousDeviceModel = love.system.getDeviceModel
|
||||
local previousIO = _G.io
|
||||
love.system.getOS = function() return "OS X" end
|
||||
love.system.getModel = nil
|
||||
love.system.getDeviceModel = nil
|
||||
_G.io = {
|
||||
popen = function()
|
||||
popen = function(command)
|
||||
local output = command:find("system_profiler", 1, true)
|
||||
and "Hardware Overview:\n Model Name: MacBook Air\n Model Identifier: Mac14,15\n Chip: Apple M2\n"
|
||||
or "Mac14,15\n"
|
||||
return {
|
||||
read = function() return "MacBookPro18,3" end,
|
||||
read = function() return output end,
|
||||
close = function() end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
local desktopInfo = IssueReport.metadata({}, { mods = {} })
|
||||
check(desktopInfo.device == "MacBookPro18,3",
|
||||
check(desktopInfo.device == "MacBook Air (Apple M2)",
|
||||
"report finds desktop device model when LOVE has no model")
|
||||
love.system.getOS = function() return "UWP" end
|
||||
local xboxInfo = IssueReport.metadata({}, { mods = {} })
|
||||
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
|
||||
love.system.getOS = previousOS
|
||||
love.system.getModel = previousModel
|
||||
love.system.getDeviceModel = previousDeviceModel
|
||||
_G.io = previousIO
|
||||
|
||||
local opened = IssueReport.open({ safeMode = false }, {
|
||||
|
||||
@@ -364,6 +364,31 @@ eq(bx, 0, "delta page box x") eq(by, 0, "delta page box y")
|
||||
eq(bw, 1000, "delta page box fills the width")
|
||||
eq(bh, 500, "delta page box fills the height")
|
||||
|
||||
local DECK_JSON = [[
|
||||
{ "name": "Deck", "gameTypeIdentifier": "com.rileytestut.delta.game.gbc",
|
||||
"representations": { "iphone": { "standard": { "portrait": {
|
||||
"assets": { "large": "deck.png" },
|
||||
"mappingSize": {"width":320,"height":240},
|
||||
"items": [ { "inputs": ["a"], "frame": {"x":240,"y":60,"width":64,"height":64} } ]
|
||||
} } } } }
|
||||
]]
|
||||
local deck = assert(DeltaSkin.parse(DECK_JSON))
|
||||
local deckPage = deck.pages[1]
|
||||
check(deckPage.aspectFromCfg, "a portrait deck without screens keeps mapping aspect")
|
||||
eq(deckPage.anchor, "bottom", "and sits at the bottom of the window")
|
||||
eq(deckPage.screenFit, "remainder", "with the leftover given to the GB picture")
|
||||
eq(deckPage.viewport, nil, "no screens[] means no baked cutout")
|
||||
local dbx, dby, dbw, dbh = TouchSkin.pageBox(deckPage, 1080, 1920)
|
||||
eq(dbx, 0, "deck overlay is full width")
|
||||
eq(dbw, 1080, "deck overlay width")
|
||||
near(dbh, 1080 * 240 / 320, "deck overlay height is mapping aspect")
|
||||
near(dby, 1920 - dbh, "pinned to the bottom, not stretched")
|
||||
local vx, vy, vw, vh = TouchSkin.pageViewport(deckPage, 1080, 1920)
|
||||
eq(vx, 0, "screen leftover x") eq(vy, 0, "screen leftover y")
|
||||
eq(vw, 1080, "screen leftover is full width")
|
||||
near(vh, dby, "and fills everything above the overlay")
|
||||
check(vh > dbh, "there is more room for the picture than for the pad")
|
||||
|
||||
local LEGACY_SCREEN = [[
|
||||
{ "gameTypeIdentifier": "public.aoshuang.game.gbc",
|
||||
"representations": { "iphone": { "standard": { "landscape": {
|
||||
@@ -419,6 +444,8 @@ local PDF_JSON = [[
|
||||
]]
|
||||
local pdf = assert(DeltaSkin.parse(PDF_JSON))
|
||||
eq(pdf.pages[1].imagePath, nil, "a PDF asset is not pretended to be art")
|
||||
eq(pdf.pages[1].pdfPath, "iphone_portrait.pdf",
|
||||
"but the PDF path is kept so load can extract a JPEG from it")
|
||||
local convert = DeltaSkin.needsConversion(pdf)
|
||||
check(convert ~= nil, "PDF-only skins report that they need conversion")
|
||||
if convert then
|
||||
@@ -471,6 +498,55 @@ check(tostring(vectorErr):find("PDF artwork", 1, true) ~= nil,
|
||||
eq(love.filesystem.read("skins/vector.deltaskin"), nil,
|
||||
"and the refused archive is not left behind")
|
||||
|
||||
local PdfImage = require("src.core.PdfImage")
|
||||
local function unhex(s)
|
||||
return (s:gsub("..", function(cc)
|
||||
return string.char(tonumber(cc, 16))
|
||||
end))
|
||||
end
|
||||
-- 1x1 JFIF JPEG, so extract tests do not need a file on disk.
|
||||
local TINY_JPEG = unhex(
|
||||
"ffd8ffe000104a46494600010100000100010000ffdb0043000806060706050807070709" ..
|
||||
"09080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c283729" ..
|
||||
"2c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc400140001" ..
|
||||
"0000000000000000000000000000000008ffc40014100100000000000000000000000000" ..
|
||||
"00000000ffda0008010100003f007f3fffd9")
|
||||
local function jpegPdf(jpeg, w, h)
|
||||
return "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width "
|
||||
.. tostring(w) .. " /Height " .. tostring(h)
|
||||
.. " /BitsPerComponent 8 /ColorSpace /DeviceRGB /Filter /DCTDecode /Length "
|
||||
.. tostring(#jpeg) .. " >>\nstream\n" .. jpeg .. "\nendstream\nendobj\n%%EOF\n"
|
||||
end
|
||||
local extracted = assert(PdfImage.extract(jpegPdf(TINY_JPEG, 1, 1)))
|
||||
eq(extracted.ext, "jpg", "a JPEG-in-PDF yields a jpg")
|
||||
eq(extracted.data, TINY_JPEG, "and the JPEG body is recovered byte for byte")
|
||||
eq(extracted.width, 1, "width comes from the Image XObject")
|
||||
eq(extracted.height, 1, "and so does height")
|
||||
|
||||
local indirect = "%PDF-1.7\n3 0 obj\n<< /Type /XObject /Subtype /Image /Width 1"
|
||||
.. " /Height 1 /Filter /DCTDecode /Length 5 0 R >>\nstream\n" .. TINY_JPEG
|
||||
.. "\nendstream\nendobj\n5 0 obj\n" .. tostring(#TINY_JPEG) .. "\nendobj\n%%EOF\n"
|
||||
local fromRef = assert(PdfImage.extract(indirect))
|
||||
eq(fromRef.data, TINY_JPEG,
|
||||
"an indirect /Length (the 3-Heights Image-to-PDF layout) still extracts")
|
||||
|
||||
eq(select(1, PdfImage.extract("%PDF-1.7\n1 0 obj\n<< /Type /Catalog >>\nendobj\n")),
|
||||
nil, "a vector PDF with no image is not pretended to be art")
|
||||
eq(select(1, PdfImage.extract("not a pdf")), nil, "and neither is garbage")
|
||||
|
||||
love.filesystem.write("skins/pikapdf.deltaskin/info.json", PDF_JSON)
|
||||
love.filesystem.write("skins/pikapdf.deltaskin/iphone_portrait.pdf",
|
||||
jpegPdf(TINY_JPEG, 1, 1))
|
||||
local pikaId, pikaErr = TouchSkin.installArchive("pikapdf.deltaskin", "PK\3\4stub")
|
||||
eq(pikaId, "pikapdf", "a Delta skin whose PDF wraps a JPEG installs: "
|
||||
.. tostring(pikaErr))
|
||||
local pika = assert(TouchSkin.load("skins/_mounted/pikapdf", "pikapdf"))
|
||||
check(pika.pages[1].rasterData == TINY_JPEG,
|
||||
"load recovers the JPEG from the PDF")
|
||||
check(pika.pages[1].image ~= nil, "and LOVE gets an image from those bytes")
|
||||
eq(DeltaSkin.needsConversion(pika), nil,
|
||||
"so the skin no longer reports that it needs conversion")
|
||||
|
||||
eq(select(1, TouchSkin.installArchive("skin.gbcskin", "PK\3\4stub")), nil,
|
||||
"a GBA4iOS .gbcskin is refused at the door")
|
||||
local _, legacyErr = TouchSkin.installArchive("skin.gbaskin", "PK\3\4stub")
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423) prints RetreatMon and
|
||||
-- holds 50 frames BEFORE the outgoing pic is recalled, and only then does
|
||||
-- SendOutMon shout "Go! X!" (#1534). The port queued the send-out line
|
||||
-- alone, so the withdraw box never existed. PlayerMon2Text's adjective
|
||||
-- (engine/battle/common_text.asm:167-243) reads the ENEMY HP lost since
|
||||
-- this mon switched in, from wLastSwitchInEnemyMonHP.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Timing = require("src.core.Timing")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||
battle.rng = function() return 0 end
|
||||
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
|
||||
return battle
|
||||
end
|
||||
|
||||
-- drain the queue the way updateQueue does, recording rows in order plus
|
||||
-- who was in the player slot when each row was emitted
|
||||
local function drain(battle)
|
||||
local rows = {}
|
||||
for _ = 1, 400 do
|
||||
local item = table.remove(battle.queue, 1)
|
||||
if not item then return rows end
|
||||
rows[#rows + 1] = { text = item.text, anim = item.anim,
|
||||
auto = item.auto, autoDelay = item.autoDelay,
|
||||
playerSpecies = battle.player.mon.species }
|
||||
if item.fn then
|
||||
battle.nextInsert = 0
|
||||
item.fn()
|
||||
end
|
||||
end
|
||||
error("the queue never drained")
|
||||
end
|
||||
|
||||
local function indexOf(rows, pred)
|
||||
for i, row in ipairs(rows) do
|
||||
if pred(row) then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the voluntary party-menu switch: withdraw line, then the send-out
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
drain(battle) -- the intro stamps lastSwitchInEnemyHP through sendOutText
|
||||
local outgoing = battle.player.mon.species
|
||||
local oldNick = battle.player.name
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:resolveSwitch(battle.game.save.party[2])
|
||||
local rows = drain(battle)
|
||||
|
||||
local wIdx = indexOf(rows, function(r)
|
||||
return r.text and r.text:find("Come back!", 1, true) ~= nil
|
||||
end)
|
||||
T.check(wIdx ~= nil, "the withdraw line is queued")
|
||||
T.eq(rows[wIdx].text, oldNick .. " enough!\nCome back!",
|
||||
"an untouched foe gives the `enough!` variant")
|
||||
T.eq(rows[wIdx].auto, true, "the page ends `done`, so it never waits on A")
|
||||
T.eq(rows[wIdx].autoDelay, Timing.SWITCH_PLAYER_MON,
|
||||
"it holds the 50 frames DelayFrames pays (core.asm:2421-2422)")
|
||||
T.eq(rows[wIdx].playerSpecies, outgoing,
|
||||
"the outgoing mon is still in the slot while the line prints")
|
||||
|
||||
local sIdx = indexOf(rows, function(r)
|
||||
return r.text and r.text:find("! ", 1, true) and r.text:find("Come back!", 1, true) == nil
|
||||
end)
|
||||
T.check(sIdx ~= nil and sIdx > wIdx, "the send-out shout follows the withdraw line")
|
||||
T.eq(rows[sIdx].auto, true, "the send-out page ends `done` too (#1472)")
|
||||
T.neq(rows[sIdx].playerSpecies, outgoing, "the swap happened between the two")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the adjective branches on enemy HP lost since the switch-in
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
drain(battle)
|
||||
local nick = battle.player.name
|
||||
local max = battle.enemy.mon.stats.hp
|
||||
local quarter = math.floor(max / 4)
|
||||
local function withdrawAt(dropPercent)
|
||||
battle.lastSwitchInEnemyHP = max
|
||||
battle.enemy.mon.hp = max - math.floor(dropPercent * quarter / 25)
|
||||
return battle:withdrawText(nick)
|
||||
end
|
||||
T.eq(withdrawAt(0), nick .. " enough!\nCome back!", "no damage -> `enough!`")
|
||||
T.eq(withdrawAt(50), nick .. " OK!\nCome back!", "30-69 -> `OK!`")
|
||||
T.eq(withdrawAt(80), nick .. " good!\nCome back!", "70+ -> `good!`")
|
||||
T.eq(withdrawAt(10), nick .. " \nCome back!", "1-29 -> no adjective at all")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- ChooseNextMon (core.asm:1086-1128) calls SendOutMon with NO RetreatMon
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
drain(battle)
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.player.mon.hp = 0
|
||||
battle:openReplacementMenu()
|
||||
local rows = drain(battle)
|
||||
T.check(indexOf(rows, function(r)
|
||||
return r.text and r.text:find("Come back!", 1, true) ~= nil
|
||||
end) == nil, "the post-faint replacement prints no withdraw line")
|
||||
end
|
||||
|
||||
T.finish("switch withdraw text (#1534)")
|
||||
@@ -0,0 +1,109 @@
|
||||
-- THRASH/PETAL DANCE is a SpecialEffectsCont entry
|
||||
-- (data/battle/special_effects.asm:22), so on the SETUP turn only,
|
||||
-- engine/battle/core.asm:3129-3133 runs ThrashPetalDanceEffect before
|
||||
-- damage; it ends in PlayBattleAnimation2 with SHRINKING_SQUARE_ANIM
|
||||
-- (ANIM_B1 on the enemy's turn) plus the slow horizontal screen shake
|
||||
-- (engine/battle/effects.asm:791-808, :1461-1471). The port queued only
|
||||
-- the move's own animation (#1532).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
Data.moves.FIX_THRASH = {
|
||||
id = "FIX_THRASH", index = 91, name = "FIX THRASH", type = "NORMAL",
|
||||
power = 90, accuracy = 100, pp = 20, effect = "THRASH_PETAL_DANCE_EFFECT",
|
||||
}
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 40)
|
||||
battle.rng = function(a) if a then return a end return 0 end
|
||||
return battle
|
||||
end
|
||||
|
||||
local function animRows(battle)
|
||||
local rows = {}
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.anim then
|
||||
rows[#rows + 1] = { anim = item.anim, hit = item.hit,
|
||||
attackerIsPlayer = item.attackerIsPlayer }
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function indexOf(rows, name)
|
||||
for i, row in ipairs(rows) do
|
||||
if row.anim == name then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the player's setup turn: the effect animation precedes the move's own
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
local slot = { id = "FIX_THRASH", pp = 20 }
|
||||
battle:performMove(battle.player, battle.enemy, slot)
|
||||
local rows = animRows(battle)
|
||||
local setup = indexOf(rows, "SHRINKING_SQUARE_ANIM")
|
||||
local move = indexOf(rows, "FIX_THRASH")
|
||||
T.check(setup ~= nil, "the setup turn queues SHRINKING_SQUARE_ANIM")
|
||||
T.check(move ~= nil and setup < move,
|
||||
"it plays BEFORE PlayPlayerMoveAnimation, as SpecialEffectsCont runs first")
|
||||
T.eq(rows[setup].attackerIsPlayer, true, "on the player's side")
|
||||
T.eq(rows[setup].hit and rows[setup].hit.animType, 6,
|
||||
"wAnimationType 6 -> ShakeScreenHorizontallySlow2 on the player's turn")
|
||||
|
||||
-- the continuation turn never reaches the effect (.ThrashingAboutCheck,
|
||||
-- core.asm:3532-3550 jumps straight to PlayerCalcMoveDamage)
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.player, battle.enemy, slot)
|
||||
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
|
||||
"a locked-in Thrash queues no setup animation")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the enemy's turn takes ANIM_B1 and wAnimationType 3
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.enemy, battle.player, { id = "FIX_THRASH", pp = 20 })
|
||||
local rows = animRows(battle)
|
||||
local setup = indexOf(rows, "ANIM_B1")
|
||||
T.check(setup ~= nil, "the enemy's setup turn queues ANIM_B1")
|
||||
T.eq(rows[setup].attackerIsPlayer, false, "on the enemy's side")
|
||||
T.eq(rows[setup].hit and rows[setup].hit.animType, 3,
|
||||
"wAnimationType 3 -> ShakeScreenHorizontallySlow on the enemy's turn")
|
||||
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") == nil,
|
||||
"and never the player-side id")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- a missed setup turn still runs the effect (it precedes MoveHitTest)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.accuracyRoll = function() return false end
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 })
|
||||
local rows = animRows(battle)
|
||||
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
|
||||
"the setup animation survives a miss")
|
||||
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
|
||||
end
|
||||
|
||||
T.finish("thrash setup animation (#1532)")
|
||||
@@ -0,0 +1,120 @@
|
||||
-- _TrainerSentOutText (data/text/text_2.asm:923) and the
|
||||
-- Go!/Do it!/Get'm! chain ending in _PlayerMon1Text (:1274-1294) end in
|
||||
-- `done`, not `prompt`: PrintText returns and the flow runs straight into
|
||||
-- AnimateSendingOutMon + PlayCry (engine/battle/core.asm:1421-1434,
|
||||
-- :1723-1765). _AIBattleWithdrawText (:1-7) does end in `prompt` and
|
||||
-- keeps its button wait. The port made every send-out box wait (#1472).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||
battle.rng = function() return 0 end
|
||||
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
|
||||
return battle
|
||||
end
|
||||
|
||||
local function rowWith(battle, needle)
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.text and item.text:find(needle, 1, true) then return item end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the voluntary switch: the shout auto-continues into the send-out anim
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:resolveSwitch(battle.game.save.party[2])
|
||||
-- run the first act so the nested switch rows land in the queue
|
||||
local first = table.remove(battle.queue, 1)
|
||||
battle.nextInsert = 0
|
||||
first.fn()
|
||||
local withdraw = rowWith(battle, "Come back!")
|
||||
T.check(withdraw ~= nil, "the withdraw line is queued")
|
||||
T.eq(withdraw.auto, true, "RetreatMon's page ends `done` (#1534)")
|
||||
local swap = table.remove(battle.queue, 2)
|
||||
battle.nextInsert = 1
|
||||
swap.fn()
|
||||
local shout = rowWith(battle, battle.player.name)
|
||||
T.check(shout ~= nil, "the send-out shout is queued")
|
||||
T.eq(shout.auto, true, "_PlayerMon1Text ends `done`, so no button wait")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the AI switch: the sent-out box goes auto, the withdraw box does not,
|
||||
-- and EnemySendOut's grow-in + cry now follow it
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
battle.rng = function() return 0 end
|
||||
battle.enemyParty = { Pokemon.new(Data, "FIXMON_B", 30),
|
||||
Pokemon.new(Data, "FIXMON_C", 30) }
|
||||
battle.enemyIndex = 1
|
||||
battle.enemy = battle.enemy or nil
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:executeAction(battle.enemy, battle.player,
|
||||
{ special = "aiSwitch", index = 2 })
|
||||
local withdrew = rowWith(battle, "with-")
|
||||
local sent = rowWith(battle, "sent")
|
||||
T.check(withdrew ~= nil, "the AI withdraw line is queued")
|
||||
T.eq(withdrew.auto, nil, "_AIBattleWithdrawText ends `prompt` and still waits")
|
||||
T.check(sent ~= nil, "the sent-out line is queued")
|
||||
T.eq(sent.auto, true, "_TrainerSentOutText ends `done`")
|
||||
T.eq(battle.enemySendingOut, true,
|
||||
"the new pic stays hidden until AnimateSendingOutMon")
|
||||
local acts = 0
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.fn then acts = acts + 1 end
|
||||
end
|
||||
T.check(acts >= 1, "EnemySendOut queues the grow-in act after the text")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the post-faint replacement (ChooseNextMon -> SendOutMon, core.asm:1124)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
local pushedUI
|
||||
battle.game.stack.push = function(_, s) pushedUI = s end
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.player.mon.hp = 0
|
||||
battle:openReplacementMenu()
|
||||
local onSwitch
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.ui then
|
||||
local screen = item.ui()
|
||||
onSwitch = screen and screen.onSwitch
|
||||
end
|
||||
end
|
||||
onSwitch = onSwitch or (pushedUI and pushedUI.onSwitch)
|
||||
if onSwitch then
|
||||
battle.nextInsert = 0
|
||||
onSwitch(battle.game.save.party[2])
|
||||
local shout = rowWith(battle, battle.player.name)
|
||||
T.check(shout ~= nil, "the replacement shout is queued")
|
||||
T.eq(shout.auto, true, "SendOutMon's message ends `done` here too")
|
||||
else
|
||||
T.check(false, "the replacement menu offers an onSwitch callback")
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("trainer send-out boxes (#1472)")
|
||||
@@ -96,6 +96,8 @@ local DATA = {
|
||||
CYNDAQUIL = {
|
||||
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW",
|
||||
-- data/pokemon/base_stats/cyndaquil.asm:10 GENDER_F12_5
|
||||
genderRatio = 31,
|
||||
types = { "FIRE", "FIRE" },
|
||||
baseStats = {
|
||||
hp = 39, attack = 52, defense = 43, speed = 65,
|
||||
@@ -105,6 +107,8 @@ local DATA = {
|
||||
TOTODILE = {
|
||||
id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW",
|
||||
-- data/pokemon/base_stats/totodile.asm:10 GENDER_F12_5
|
||||
genderRatio = 31,
|
||||
types = { "WATER", "WATER" },
|
||||
baseStats = {
|
||||
hp = 50, attack = 65, defense = 64, speed = 43,
|
||||
@@ -163,8 +167,11 @@ end
|
||||
-- is `levelMoves` and Gen 1 reads level1Moves / learnset.
|
||||
local function mon(species, level, opts)
|
||||
opts = opts or {}
|
||||
-- engine/pokemon/mon_stats.asm:126 GetGender reads the Attack DV, so the
|
||||
-- gender a page prints follows the DVs, not a hand-set field
|
||||
local built = Mon.new(DATA, species, level, {
|
||||
dvs = { attack = 15, defense = 15, speed = 15, special = 15 },
|
||||
dvs = opts.dvs
|
||||
or { attack = 15, defense = 15, speed = 15, special = 15 },
|
||||
moves = opts.moves,
|
||||
})
|
||||
for key, value in pairs(opts.fields or {}) do built[key] = value end
|
||||
@@ -177,12 +184,13 @@ local CYNDA = mon("CYNDAQUIL", 12, {
|
||||
{ id = "EMBER", pp = 25, maxPp = 25 },
|
||||
{ id = "LEER", pp = 7, maxPp = 30 },
|
||||
},
|
||||
fields = { nickname = "CYNDAQUIL", gender = "male", item = "BERRY",
|
||||
fields = { nickname = "CYNDAQUIL", item = "BERRY",
|
||||
otName = "GOLD", otId = 12345 },
|
||||
})
|
||||
local TOTO = mon("TOTODILE", 10, {
|
||||
moves = { { id = "SURF", pp = 15, maxPp = 15 } },
|
||||
fields = { nickname = "TOTODILE", gender = "female" },
|
||||
dvs = { attack = 0, defense = 15, speed = 15, special = 15 },
|
||||
fields = { nickname = "TOTODILE" },
|
||||
})
|
||||
|
||||
local SAVE = { player = { name = "GOLD", id = 12345 }, party = { CYNDA, TOTO } }
|
||||
|
||||
@@ -226,6 +226,10 @@ return { spawns = { SPAWN_NEW_BARK = { map = "TEST_MAP", x = 1, y = 1 } } }
|
||||
spawnAfterChampion = "SPAWN_LANCE",
|
||||
position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }),
|
||||
"and the post-credits spawn is a warp even though a position exists")
|
||||
|
||||
love.filesystem.remove("data/generated/maps.lua")
|
||||
love.filesystem.remove("data/generated/tilesets.lua")
|
||||
love.filesystem.remove("data/generated/landmarks.lua")
|
||||
end
|
||||
|
||||
-- ---- the two clock faces ----------------------------------------------------
|
||||
|
||||
@@ -2876,8 +2876,9 @@ check(selGame.save.registeredItem == nil,
|
||||
eq(selWorld:useSelectItem(), "not_registered",
|
||||
"SELECT with nothing registered answers not_registered")
|
||||
|
||||
-- The PACK side: SELECT on a highlighted row is RegisterItem, the one PACK
|
||||
-- button this port left unbound.
|
||||
-- The PACK side: the item submenu's SEL row is RegisterItem's only door --
|
||||
-- the cart's SELECT is the bag's own item shuffle
|
||||
-- (engine/items/pack.asm:1290 Pack_InterpretJoypad .select).
|
||||
selGame.save.inventory.POTION = 3
|
||||
selGame.input = stubInput()
|
||||
local selPack = PackMenu.new(selGame, { pocket = "ITEM" })
|
||||
@@ -2885,7 +2886,18 @@ selPack.index = 1
|
||||
check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test")
|
||||
selGame.input:press("select")
|
||||
selPack:update(0)
|
||||
check(selPack.message ~= nil, "SELECT on a row opens RegisteredItemText")
|
||||
eq(selPack.switching, 1, "SELECT on a row arms the item shuffle")
|
||||
check(selGame.save.registeredItem == nil, "and registers nothing")
|
||||
selGame.input:press("b")
|
||||
selPack:update(0)
|
||||
check(selPack.switching == nil and selPack.message == nil,
|
||||
"B backs out of the shuffle")
|
||||
selPack:openSubmenu()
|
||||
check(table.concat(selPack.submenu.rows, ","):find("sel", 1, true) ~= nil,
|
||||
"the POTION submenu offers the SEL row")
|
||||
selPack:closeSubmenu()
|
||||
selPack:registerSelected()
|
||||
check(selPack.message ~= nil, "SEL opens RegisteredItemText")
|
||||
eq(selGame.save.registeredItem.id, "POTION",
|
||||
"and World:registerItem actually ran")
|
||||
selGame.input:press("a")
|
||||
@@ -2897,7 +2909,9 @@ selPack.index = selPack:total()
|
||||
selGame.save.registeredItem = nil
|
||||
selGame.input:press("select")
|
||||
selPack:update(0)
|
||||
check(selPack.message == nil, "SELECT on CANCEL registers nothing")
|
||||
check(selPack.switching == nil and selPack.message == nil,
|
||||
"SELECT on CANCEL arms nothing")
|
||||
selPack:registerSelected()
|
||||
check(selGame.save.registeredItem == nil, "and the slot stays empty")
|
||||
|
||||
-- CantRegisterText: a TM/HM row refuses from the PACK too.
|
||||
@@ -2906,9 +2920,8 @@ selPack:rebuild()
|
||||
local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" })
|
||||
tmPack.index = 1
|
||||
check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test")
|
||||
selGame.input:press("select")
|
||||
tmPack:update(0)
|
||||
check(tmPack.message ~= nil, "SELECT on the HM still opens a message")
|
||||
tmPack:registerSelected()
|
||||
check(tmPack.message ~= nil, "SEL on the HM still opens a message")
|
||||
check(selGame.save.registeredItem == nil,
|
||||
"CantRegisterText: the HM never becomes the registered item")
|
||||
end
|
||||
|
||||
@@ -315,8 +315,9 @@ local vanillaSave = SaveData.newGame(Data.field.boot)
|
||||
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
|
||||
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after
|
||||
-- walking out of the house, so a new game skipped Red's house entirely.
|
||||
-- Red/Blue land facing up (#944); only Yellow keeps boot.startFacing.
|
||||
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
|
||||
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down",
|
||||
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "up",
|
||||
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
|
||||
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
|
||||
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
|
||||
|
||||
@@ -276,6 +276,8 @@ do
|
||||
text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
|
||||
if game.stringBuffer then
|
||||
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
|
||||
-- wNameBuffer reads the same buffer in this port (TextBox.TOKENS.RAM)
|
||||
text = text:gsub("{RAM:wNameBuffer}", game.stringBuffer)
|
||||
end
|
||||
text = text:gsub("{[%w_:]+}", "")
|
||||
return text
|
||||
|
||||
+44
-30
@@ -282,13 +282,19 @@ local function optGame()
|
||||
end
|
||||
local om = OptionsMenu.new(optGame())
|
||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
|
||||
"battleFit", "battleBg", "uiLayout",
|
||||
"battleFit", "battleHud", "battleBg", "uiLayout",
|
||||
"ruleset", "musicVol", "sfxVol", "musicFilter",
|
||||
"performance", "colors",
|
||||
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
|
||||
"faithfulRes", "fpsCap",
|
||||
"speedOverworld", "speedBattle", "speedMenu",
|
||||
"mods", "controls", "dateFormat", "timeFormat" }
|
||||
local function orow(menu, id)
|
||||
for _, row in ipairs(menu.rows) do
|
||||
if row.id == id then return row end
|
||||
end
|
||||
error("no options row '" .. id .. "'")
|
||||
end
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
for i, id in ipairs(WANT_IDS) do
|
||||
check(om.rows[i].id == id, "options row order: " .. id)
|
||||
@@ -296,11 +302,12 @@ end
|
||||
|
||||
-- ruleset row cycles the sorted non-hidden registry ids showing name
|
||||
om.game.save.options.ruleset = "gen1_faithful"
|
||||
check(om.rows[8].value(om.game) == "GEN 1", "ruleset row shows record.name")
|
||||
om.rows[8].step(om.game, 1)
|
||||
check(orow(om, "ruleset").value(om.game) == "GEN 1",
|
||||
"ruleset row shows record.name")
|
||||
orow(om, "ruleset").step(om.game, 1)
|
||||
check(om.game.save.options.ruleset == "modern_clean",
|
||||
"ruleset row cycles sorted registry ids")
|
||||
om.rows[8].step(om.game, 1)
|
||||
orow(om, "ruleset").step(om.game, 1)
|
||||
check(om.game.save.options.ruleset == "gen1_faithful",
|
||||
"hidden rulesets are excluded from the cycle")
|
||||
|
||||
@@ -319,42 +326,42 @@ check(om.game.save.options.battleLayout == "wide", "battle layout flips to WIDE"
|
||||
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
|
||||
om.rows[4].step(om.game, 1)
|
||||
check(om.game.save.options.battleLayout == "og", "battle layout flips back")
|
||||
om.rows[9].step(om.game, -1)
|
||||
orow(om, "musicVol").step(om.game, -1)
|
||||
check(om.game.save.options.musicVol == 6, "music volume steps down")
|
||||
for _ = 1, 10 do om.rows[9].step(om.game, -1) end
|
||||
for _ = 1, 10 do orow(om, "musicVol").step(om.game, -1) end
|
||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
|
||||
-- ZOOM / VOID FILL rows (indices track WANT_IDS above; the battle
|
||||
-- composition rows -- BATTLE SIZE / BATTLE BG / UI LAYOUT -- sit ahead of
|
||||
-- RULESET, and FAITHFUL RATIO lands between VIDEO MODE and MAX FPS)
|
||||
-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
om.game.save.options.zoom = 0
|
||||
Zoom.offset = 0
|
||||
check(om.rows[16].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
|
||||
om.rows[16].step(om.game, 1)
|
||||
check(orow(om, "zoom").value(om.game) == "FIT",
|
||||
"ZOOM row shows FIT at offset 0")
|
||||
orow(om, "zoom").step(om.game, 1)
|
||||
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
||||
"ZOOM row steps to IN1")
|
||||
om.rows[17].step(om.game, 1)
|
||||
orow(om, "voidFill").step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "water"
|
||||
and TileRenderer.voidFill == "water",
|
||||
"VOID FILL row cycles TREES → WATER")
|
||||
om.rows[17].step(om.game, 1)
|
||||
orow(om, "voidFill").step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
||||
om.rows[17].step(om.game, 1)
|
||||
orow(om, "voidFill").step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
||||
|
||||
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||
om.game.save.options.fpsCap = nil
|
||||
check(om.rows[20].value(om.game) == "60",
|
||||
check(orow(om, "fpsCap").value(om.game) == "60",
|
||||
"MAX FPS row defaults to 60 with no saved cap")
|
||||
om.rows[20].step(om.game, 1)
|
||||
orow(om, "fpsCap").step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
||||
check(om.rows[20].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||
check(orow(om, "fpsCap").value(om.game) == "75",
|
||||
"the MAX FPS row renders the cap")
|
||||
om.game.save.options.fpsCap = 160
|
||||
om.rows[20].step(om.game, 1)
|
||||
orow(om, "fpsCap").step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
||||
om.rows[20].step(om.game, -1)
|
||||
orow(om, "fpsCap").step(om.game, -1)
|
||||
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
||||
|
||||
-- ------- FrameCap normalize / cycle (issue #88)
|
||||
@@ -384,7 +391,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
|
||||
-- the MODS row is the manager's discoverable home
|
||||
local mgGame = optGame()
|
||||
om = OptionsMenu.new(mgGame)
|
||||
om.rows[24].activate(mgGame)
|
||||
orow(om, "mods").activate(mgGame)
|
||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||
"the MODS row opens the manager")
|
||||
check(mgGame.stack:top().screenId == "ManagerState",
|
||||
@@ -394,7 +401,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
local cbGame = optGame()
|
||||
om = OptionsMenu.new(cbGame)
|
||||
om.rows[25].activate(cbGame)
|
||||
orow(om, "controls").activate(cbGame)
|
||||
local bm = cbGame.stack:top()
|
||||
check(getmetatable(bm) == BindingsMenu,
|
||||
"the CONTROLS row opens the rebind list")
|
||||
@@ -413,17 +420,17 @@ check(cbGame.save.options.bindings == nil,
|
||||
-- engine UI and mods without becoming checkpoint progress
|
||||
om.game.save.options.dateFormat = "device"
|
||||
om.game.save.options.timeFormat = "device"
|
||||
check(om.rows[26].value(om.game) == "DEVICE",
|
||||
check(orow(om, "dateFormat").value(om.game) == "DEVICE",
|
||||
"DATE FORMAT defaults to device locale")
|
||||
om.rows[26].step(om.game, 1)
|
||||
orow(om, "dateFormat").step(om.game, 1)
|
||||
check(om.game.save.options.dateFormat == "dmy"
|
||||
and om.rows[26].value(om.game) == "DD-MM-YYYY",
|
||||
and orow(om, "dateFormat").value(om.game) == "DD-MM-YYYY",
|
||||
"DATE FORMAT exposes deterministic DMY override")
|
||||
check(om.rows[27].value(om.game) == "DEVICE",
|
||||
check(orow(om, "timeFormat").value(om.game) == "DEVICE",
|
||||
"TIME FORMAT defaults to device locale")
|
||||
om.rows[27].step(om.game, 1)
|
||||
orow(om, "timeFormat").step(om.game, 1)
|
||||
check(om.game.save.options.timeFormat == "24h"
|
||||
and om.rows[27].value(om.game) == "24 HOUR",
|
||||
and orow(om, "timeFormat").value(om.game) == "24 HOUR",
|
||||
"TIME FORMAT exposes deterministic 24-hour override")
|
||||
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
|
||||
"no raw-input claim until a capture is armed")
|
||||
@@ -583,11 +590,19 @@ check(not fpm.submenu and forced == fgame.save.party[1],
|
||||
|
||||
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
|
||||
do
|
||||
-- PartyMenu delegates the move to OverworldState:useStrengthFieldMove;
|
||||
-- parity_I_M covers that side, this one covers what the menu does after
|
||||
local owStub = { strengthActive = false,
|
||||
map = { def = { tileset = "OVERWORLD" } }, dark = false,
|
||||
partyKnows = function(self, id) return self.knows == id end,
|
||||
knows = "STRENGTH" }
|
||||
local sgame = partyGame()
|
||||
owStub.useStrengthFieldMove = function(self, _mon, onClose)
|
||||
self.strengthActive = true
|
||||
sgame.stack:push(require("src.render.TextBox").new(
|
||||
sgame, "used\nSTRENGTH.", onClose))
|
||||
return true
|
||||
end
|
||||
sgame.overworld = owStub
|
||||
sgame.data.text = {} -- the strength texts fall back to Strings sources
|
||||
sgame.save.inventory.RAINBOWBADGE = 1
|
||||
@@ -1233,7 +1248,7 @@ local Loader = require("src.mods.Loader")
|
||||
local uiFiles = {
|
||||
["mods/uikit/manifest.json"] =
|
||||
'{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}',
|
||||
["mods/uikit/main.lua"] = "return function(mod) _G.MOD_UI_API = mod end",
|
||||
["mods/uikit/main.lua"] = "return function(mod) mod.exports.api = mod end",
|
||||
}
|
||||
local uiFs = {
|
||||
read = function(path) return uiFiles[path] end,
|
||||
@@ -1267,8 +1282,7 @@ local uiFs = {
|
||||
}
|
||||
local uiLoader = Loader.new({ fs = uiFs })
|
||||
check(uiLoader:load({}) == true, "the uikit fixture loads clean")
|
||||
local uiApi = _G.MOD_UI_API
|
||||
_G.MOD_UI_API = nil
|
||||
local uiApi = (uiLoader.exports.uikit or {}).api
|
||||
check(uiApi ~= nil, "the entry chunk received its api")
|
||||
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
|
||||
check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module")
|
||||
|
||||
@@ -125,6 +125,7 @@ do
|
||||
function tb:enemyAction() return { special = "bound" } end
|
||||
tb:resolveSwitch(Game.save.party[2])
|
||||
acts[1]() -- send-out clears foe trap
|
||||
acts[#acts]()
|
||||
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
|
||||
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
|
||||
check(tb:fightLockedAction(tb.player) == nil,
|
||||
|
||||
@@ -222,7 +222,7 @@ eq(next(t), nil, "already-left Route 25 enter is a no-op")
|
||||
Commands.hide_object = realHide
|
||||
Commands.show_object = realShow
|
||||
package.loaded["src.ui.Menu"] = realMenu
|
||||
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
|
||||
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
|
||||
package.loaded["src.core.Music"] = realMusic
|
||||
package.loaded["src.core.Sound"] = realSound
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -35,6 +35,13 @@ local WILD_SONG = Data.audio.battle.wild
|
||||
-- every song request in order: "the theme was never restored" and "the theme
|
||||
-- was never started" have to read differently. Music.playMap still sets the
|
||||
-- state Music.restoreMap reads, so the real restore path is under test.
|
||||
-- singletons this file stands doubles on; the originals go back at the tail,
|
||||
-- or a later suite in the same process inherits them (a stubbed startWarpTo
|
||||
-- eats every warp after this one)
|
||||
local realPlay, realPlayBattle = Music.play, Music.playBattle
|
||||
local realStartWarpTo = OW.startWarpTo
|
||||
local realEvents = Runtime.events
|
||||
|
||||
local songs = {}
|
||||
Music.play = function(_, song) songs[#songs + 1] = song end
|
||||
local function lastSong() return songs[#songs] end
|
||||
@@ -160,4 +167,8 @@ eq(labResult, "lose", "it still finishes as a loss")
|
||||
eq(Game.save.money, 3000, "no money is lost in the lab")
|
||||
eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript")
|
||||
|
||||
OW.startWarpTo = realStartWarpTo
|
||||
Music.play, Music.playBattle = realPlay, realPlayBattle
|
||||
Runtime.install(realEvents, Runtime.hooks)
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -33,7 +33,8 @@ local function battleWith(partyHP, result)
|
||||
for i, hp in ipairs(partyHP) do
|
||||
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
|
||||
end
|
||||
return {
|
||||
-- the metatable so playerMonFainted can reach playerPartyView
|
||||
return setmetatable({
|
||||
kind = "wild",
|
||||
result = result,
|
||||
afterQueue = nil,
|
||||
@@ -44,7 +45,7 @@ local function battleWith(partyHP, result)
|
||||
sayNext = function(self, m) self.said[#self.said + 1] = m end,
|
||||
say = function(self, m) self.said[#self.said + 1] = m end,
|
||||
ui = function() end,
|
||||
}
|
||||
}, BattleState)
|
||||
end
|
||||
|
||||
local function saidBlackout(b)
|
||||
|
||||
+36
-41
@@ -2642,7 +2642,6 @@ do
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local SD = require("src.core.SaveData")
|
||||
-- Isolate from earlier save/options writes in this suite
|
||||
@@ -2663,6 +2662,19 @@ do
|
||||
om:update(1 / 60)
|
||||
OInput.pressed = {}
|
||||
end
|
||||
-- walk the cursor down to a row by id, so a row added to OptionsMenu
|
||||
-- shifts these blocks instead of silently retargeting them
|
||||
local function seek(id)
|
||||
local want = -1
|
||||
for i, row in ipairs(om.rows) do
|
||||
if row.id == id then want = i end
|
||||
end
|
||||
for _ = 1, #om.rows do
|
||||
if om.index == want then break end
|
||||
press("down")
|
||||
end
|
||||
return om.index == want
|
||||
end
|
||||
eq(og.save.options.textSpeed, 3,
|
||||
"new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)")
|
||||
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
|
||||
@@ -2673,65 +2685,58 @@ do
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"new saves default VIDEO MODE to WINDOWED")
|
||||
eq(om.scroll, 0, "options viewport starts at the top")
|
||||
for _ = 1, 3 do press("down") end
|
||||
eq(om.index, 4, "cursor reaches BATTLE LAYOUT")
|
||||
check(seek("battleLayout"), "cursor reaches BATTLE LAYOUT")
|
||||
press("a")
|
||||
eq(og.save.options.battleLayout, "wide",
|
||||
"A switches the battle screen to the WIDE layout")
|
||||
press("a")
|
||||
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
|
||||
for _ = 1, 5 do press("down") end
|
||||
eq(om.index, 9, "cursor reaches MUSIC VOL")
|
||||
eq(om.scroll, 5, "viewport scrolls to keep MUSIC VOL on screen")
|
||||
check(seek("musicVol"), "cursor reaches MUSIC VOL")
|
||||
eq(om.scroll, om.index - require("src.ui.OptionRows").VISIBLE,
|
||||
"viewport scrolls to keep MUSIC VOL on screen")
|
||||
press("left")
|
||||
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
|
||||
press("right")
|
||||
eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back")
|
||||
press("right")
|
||||
eq(og.save.options.musicVol, 7, "MUSIC VOL clamps at 7")
|
||||
press("down"); press("left")
|
||||
seek("sfxVol"); press("left")
|
||||
eq(og.save.options.sfxVol, 6, "SFX VOL adjusts on its own row")
|
||||
press("down")
|
||||
seek("musicFilter")
|
||||
for _ = 1, 3 do press("a") end
|
||||
eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X")
|
||||
press("a")
|
||||
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 12, "cursor reaches PERFORMANCE")
|
||||
check(seek("performance"), "cursor reaches PERFORMANCE")
|
||||
press("a")
|
||||
eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH")
|
||||
eq(require("src.core.Performance").tier, "high",
|
||||
"the live tier tracks the PERFORMANCE option")
|
||||
for _ = 1, 3 do press("a") end
|
||||
eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO")
|
||||
press("down")
|
||||
eq(om.index, 13, "cursor reaches COLORS")
|
||||
check(seek("colors"), "cursor reaches COLORS")
|
||||
press("a")
|
||||
for _ = 1, 4 do press("a") end
|
||||
press("down")
|
||||
eq(om.index, 14, "cursor reaches TILT")
|
||||
check(seek("tilt"), "cursor reaches TILT")
|
||||
press("a")
|
||||
eq(og.save.options.tilt, 1, "A cycles TILT to 15")
|
||||
eq(Tilt.level, 1, "Tilt level tracks TILT option")
|
||||
press("a"); press("a"); press("a")
|
||||
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 15, "cursor reaches GBC FX")
|
||||
check(seek("gbcfx"), "cursor reaches GBC FX")
|
||||
press("a")
|
||||
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
|
||||
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
|
||||
for _ = 1, 4 do press("a") end
|
||||
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 16, "cursor reaches ZOOM")
|
||||
check(seek("zoom"), "cursor reaches ZOOM")
|
||||
local ZoomOpt = require("src.render.Zoom")
|
||||
press("a")
|
||||
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
|
||||
eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option")
|
||||
press("left")
|
||||
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
|
||||
press("down")
|
||||
eq(om.index, 17, "cursor reaches VOID FILL")
|
||||
check(seek("voidFill"), "cursor reaches VOID FILL")
|
||||
local TR = require("src.render.TileRenderer")
|
||||
press("a")
|
||||
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
|
||||
@@ -2740,18 +2745,15 @@ do
|
||||
eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK")
|
||||
press("a")
|
||||
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
|
||||
press("down")
|
||||
eq(om.index, 18, "cursor reaches VIDEO MODE")
|
||||
check(seek("videoMode"), "cursor reaches VIDEO MODE")
|
||||
press("a")
|
||||
eq(og.save.options.videoMode, "borderless",
|
||||
"A cycles VIDEO MODE to BORDERLESS")
|
||||
press("a")
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"VIDEO MODE wraps back to WINDOWED")
|
||||
press("down")
|
||||
eq(om.index, 19, "cursor reaches FAITHFUL RATIO")
|
||||
press("down")
|
||||
eq(om.index, 20, "cursor reaches MAX FPS")
|
||||
check(seek("faithfulRes"), "cursor reaches FAITHFUL RATIO")
|
||||
check(seek("fpsCap"), "cursor reaches MAX FPS")
|
||||
press("a")
|
||||
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
|
||||
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
|
||||
@@ -2761,8 +2763,7 @@ do
|
||||
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
|
||||
-- RFC 0007: the single GAME SPEED row is now three independent rows,
|
||||
-- one per GameSpeed.CATEGORIES entry.
|
||||
press("down")
|
||||
eq(om.index, 21, "cursor reaches OVERWORLD SPEED")
|
||||
check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X")
|
||||
-- Driven by the level list rather than a literal press count: adding a
|
||||
@@ -2770,26 +2771,20 @@ do
|
||||
-- bug when the cycling is fine and the row is simply one longer.
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 22, "cursor reaches BATTLE SPEED")
|
||||
check(seek("speedBattle"), "cursor reaches BATTLE SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X")
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 23, "cursor reaches MENU SPEED")
|
||||
check(seek("speedMenu"), "cursor reaches MENU SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X")
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 24, "cursor reaches MODS")
|
||||
press("down")
|
||||
eq(om.index, 25, "cursor reaches CONTROLS")
|
||||
press("down")
|
||||
eq(om.index, 26, "cursor reaches DATE FORMAT")
|
||||
press("down")
|
||||
eq(om.index, 27, "cursor reaches TIME FORMAT")
|
||||
check(seek("mods"), "cursor reaches MODS")
|
||||
check(seek("controls"), "cursor reaches CONTROLS")
|
||||
check(seek("dateFormat"), "cursor reaches DATE FORMAT")
|
||||
check(seek("timeFormat"), "cursor reaches TIME FORMAT")
|
||||
press("down")
|
||||
-- CANCEL is appended after the descriptor list rather than living in it, so
|
||||
-- it lands one past #rows and the window holds the last six boxes. Counted
|
||||
@@ -2812,7 +2807,7 @@ do
|
||||
GBCFX.applyOptions(og.save.options)
|
||||
require("src.render.Zoom").applyOptions(og.save.options)
|
||||
require("src.render.TileRenderer").applyOptions(og.save.options)
|
||||
VideoMode.applyOptions(og.save.options)
|
||||
require("src.core.VideoMode").applyOptions(og.save.options)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user