mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
Merge pull request #1460 from bryanthaboi/dev
This commit is contained in:
@@ -6,18 +6,30 @@ function love.conf(t)
|
||||
|
||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
local companion = nil
|
||||
if arg then
|
||||
for _, a in ipairs(arg) do
|
||||
if a == "--editor" then editor = true end
|
||||
if a == "--developer" then developer = true end
|
||||
local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$")
|
||||
if port then companion = { port = tonumber(port), token = token } end
|
||||
end
|
||||
end
|
||||
-- main.lua runs in the same Lua state right after conf.lua; stash the
|
||||
-- decision in a global so it doesn't need to reparse `arg`.
|
||||
_G.POKEPORT_EDITOR_MODE = editor
|
||||
_G.POKEPORT_DEV_MODE = developer
|
||||
_G.POKEPORT_DISPLAY_COMPANION = companion
|
||||
|
||||
if editor then
|
||||
if companion then
|
||||
t.identity = "pokemon-love2d-companion"
|
||||
t.window.title = "gen1recomp Secondary Display"
|
||||
t.window.width = 640
|
||||
t.window.height = 576
|
||||
t.window.minwidth = 160
|
||||
t.window.minheight = 144
|
||||
t.window.resizable = true
|
||||
elseif editor then
|
||||
-- Same identity as the game, deliberately: the editor edits the game's
|
||||
-- saves and reads the game's ROM cache, both of which live under this
|
||||
-- folder. A private editor identity would point love.filesystem at an
|
||||
@@ -51,7 +63,8 @@ function love.conf(t)
|
||||
end
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
t.modules.joystick = true
|
||||
t.modules.audio = not companion
|
||||
t.modules.joystick = not companion
|
||||
t.modules.physics = false
|
||||
|
||||
-- love.system is not loaded during love.conf; love._os is set by the
|
||||
|
||||
@@ -554,7 +554,7 @@ gains a field instead of the name gaining a prefix.
|
||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||
`render.letterbox`, `render.hud`. Each sits
|
||||
`render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
|
||||
+61
-14
@@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched.
|
||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
||||
are valid choices. The list is empty while the world is busy, and omits an
|
||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
||||
contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
|
||||
eligible `sources`; each source contains its eligible `targets`. Fishing rows
|
||||
include the owned rods that are valid choices. The list is empty while the
|
||||
world is busy, and omits an action whenever its item, move, badge, terrain, or
|
||||
engine state forbids it.
|
||||
The optional second return is `"world is busy"` during transient input locks
|
||||
or `"no overworld"` before a playable world exists.
|
||||
|
||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||
and chooses automatically when only one rod is available. Invalid, stale, and
|
||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
||||
not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
and chooses automatically when only one rod is available. Red's `softboiled`
|
||||
accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
|
||||
record. Invalid, stale, and busy requests return `nil` plus a reason without
|
||||
changing game state. Mods do not need generation-specific badge, terrain,
|
||||
bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
Red exposes FLY separately because it requires a destination picker:
|
||||
`mod.world:canFly()` reports whether FLY is eligible at the current location,
|
||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||
Fly town list. Gold does not expose these two methods yet.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
@@ -278,10 +287,16 @@ The shared Red, Blue, Yellow, and Gold intents are:
|
||||
- `{ kind = "move", slot = 1..4 }`
|
||||
- `{ kind = "back" }` while the move menu is active
|
||||
|
||||
Red, Blue, and Yellow also expose their generation-specific choices:
|
||||
|
||||
- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the
|
||||
other accepted actions)
|
||||
- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index`
|
||||
|
||||
Menu choices and moves use the same engine methods as the native controls;
|
||||
`party` and `item` open the native screens rather than exposing or duplicating
|
||||
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
|
||||
states refuse these core intents. Use `mod.input` for ordinary text advance.
|
||||
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
||||
refuse core intents. Use `mod.input` for ordinary text advance.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
@@ -654,11 +669,14 @@ the wrapper is visible during that same fixed step. The callback receives
|
||||
|
||||
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
|
||||
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
|
||||
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
|
||||
is `{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure,
|
||||
button }`: `phase` is
|
||||
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
|
||||
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
|
||||
are LOVE window units, the same space `render.hud`'s viewport and the touch
|
||||
overlay lay out in. The on-screen touch controls keep first refusal: a
|
||||
`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
|
||||
active game viewport and `insideGame` says whether the pointer is inside it.
|
||||
Without a custom viewport both coordinate pairs are identical. The on-screen
|
||||
touch controls keep first refusal: a
|
||||
pointer that begins on a virtual control belongs to the pad for its whole
|
||||
lifecycle and never reaches the hook, while one that begins outside stays
|
||||
visible even if it later crosses a control. A real mouse reaches the hook
|
||||
@@ -691,6 +709,22 @@ composited and before touch controls draw. The window-space viewport contains
|
||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||
playfield or pushing an updating game state.
|
||||
|
||||
`render.viewport` lets a layout mod reserve the window-space rectangle in which
|
||||
the game renders. It receives `(next, ctx)` with the full window's `width`,
|
||||
`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
|
||||
returns `{ x, y, width, height }`. The engine clamps that rectangle to the
|
||||
window and makes game layout, safe-area calculations, and rendering use it as
|
||||
their display. Set `capture = true` to request a composition canvas even when
|
||||
the rectangle fills the window. With no subscriber, no canvas is allocated and
|
||||
the normal presentation path is unchanged.
|
||||
|
||||
When a viewport is active, `render.window` receives `(next, game, ctx)` after
|
||||
the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
|
||||
`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
|
||||
`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
|
||||
a wrapper may instead compose that canvas with its own UI. Touch controls remain
|
||||
full-size OS-window chrome and draw after this hook.
|
||||
|
||||
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||
the mod full control of the window, while calling `next` runs the engine's
|
||||
@@ -699,11 +733,24 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
`secondScreen` bridge (`available()` / `detected()` / `push(...)` /
|
||||
`pollTouch()` / `setEnabled`) for driving a second physical display.
|
||||
`detected()` reports a connected target even while its output is being created;
|
||||
`available()` means it can accept a frame now. `push(imageData, w, h)` retains
|
||||
the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
|
||||
arguments request an extended presentation; a preference ending in `:cover`
|
||||
fills and crops the target, while other values preserve the whole frame.
|
||||
Android also accepts `handheld` or `secondary` (with an optional `:cover`
|
||||
suffix) as routing hints; unsupported or unavailable targets fall back to the
|
||||
other connected display.
|
||||
`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame
|
||||
coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
On process-capable Windows, Linux and macOS hosts without a native display
|
||||
bridge, enabling this facade opens a second resizable app window instead. It
|
||||
uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled`
|
||||
contract, so a mod does not need a desktop-specific rendering path.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
-- opens the editor on that slot's file, and restores the launcher when
|
||||
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
||||
|
||||
if POKEPORT_DISPLAY_COMPANION then
|
||||
return require("src.render.DesktopCompanion").install(
|
||||
POKEPORT_DISPLAY_COMPANION)
|
||||
end
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
@@ -15,6 +20,7 @@ local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local NxDisplay = require("src.core.NxDisplay")
|
||||
local PlatformHooks = require("src.core.PlatformHooks")
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
@@ -503,24 +509,30 @@ end
|
||||
|
||||
function love.draw()
|
||||
if editorMode then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("editor", EditorApp)
|
||||
local result = EditorApp.draw()
|
||||
HostDisplay.endFrame("editor", EditorApp)
|
||||
return result
|
||||
end
|
||||
if TouchEditor then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("touch_editor", TouchEditor)
|
||||
local result = TouchEditor.draw()
|
||||
HostDisplay.endFrame("touch_editor", TouchEditor)
|
||||
return result
|
||||
end
|
||||
if Importer then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("launcher", Importer)
|
||||
local result = Importer:draw()
|
||||
HostDisplay.endFrame("launcher", Importer)
|
||||
return result
|
||||
end
|
||||
if not Game then return end
|
||||
if not Game then
|
||||
GameViewport.reset()
|
||||
return
|
||||
end
|
||||
|
||||
HostDisplay.beginFrame("game", Game)
|
||||
Game:draw()
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/love"
|
||||
android:label="${NAME}" >
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
android:value="true" />
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
android:exported="true"
|
||||
@@ -49,5 +52,15 @@
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="false"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
android:taskAffinity="${applicationId}.secondary"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent)
|
||||
{
|
||||
if (url == nullptr || body == nullptr || bodyLen < 0)
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
// Same resolution rule as httpDownload: the activity's own class via
|
||||
// SDL_AndroidGetActivity, never FindClass -- this bridge is called off
|
||||
// the main thread (love.thread workers), whose class loader cannot see
|
||||
// app classes.
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return false;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
// Old APK / new liblove skew: report "no transport" the same way a
|
||||
// missing curl does, instead of aborting on a missing method (#597).
|
||||
jmethodID method = env->GetStaticMethodID(activity, "httpPost",
|
||||
"(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jstring jurl = env->NewStringUTF(url);
|
||||
// raw bytes across the bridge: a log ring can carry arbitrary UTF-8,
|
||||
// and a jstring would run it through modified UTF-8
|
||||
jbyteArray jbody = env->NewByteArray(bodyLen);
|
||||
if (jbody != nullptr)
|
||||
env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body);
|
||||
jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr;
|
||||
jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr;
|
||||
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua);
|
||||
|
||||
env->DeleteLocalRef(jurl);
|
||||
if (jbody != nullptr)
|
||||
env->DeleteLocalRef(jbody);
|
||||
if (jct != nullptr)
|
||||
env->DeleteLocalRef(jct);
|
||||
if (jua != nullptr)
|
||||
env->DeleteLocalRef(jua);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||
@@ -1180,6 +1229,68 @@ void love_android_secondary_enable(int on)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
void love_android_secondary_target(int target)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity,
|
||||
"setSecondaryDisplayTarget", "(I)V");
|
||||
if (method)
|
||||
env->CallStaticVoidMethod(activity, method, target);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
int love_android_secondary_detected()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity,
|
||||
"hasSecondaryDisplayCandidate", "()Z");
|
||||
jboolean detected = JNI_FALSE;
|
||||
if (method)
|
||||
detected = env->CallStaticBooleanMethod(activity, method);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return detected ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
int love_android_present_secondary(const void *rgba, int width, int height,
|
||||
unsigned int background, int cover)
|
||||
{
|
||||
if (!rgba || width <= 0 || height <= 0)
|
||||
return 0;
|
||||
jlong size = (jlong) width * (jlong) height * 4;
|
||||
if (size <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame",
|
||||
"(Ljava/nio/ByteBuffer;IIIZ)Z");
|
||||
if (!method)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return 0;
|
||||
}
|
||||
jobject frame = env->NewDirectByteBuffer((void *) rgba, size);
|
||||
if (!frame)
|
||||
{
|
||||
env->DeleteLocalRef(activity);
|
||||
return 0;
|
||||
}
|
||||
jboolean shown = env->CallStaticBooleanMethod(activity, method, frame,
|
||||
width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE);
|
||||
env->DeleteLocalRef(frame);
|
||||
env->DeleteLocalRef(activity);
|
||||
return shown ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
const char *love_android_poll_secondary_touch()
|
||||
{
|
||||
|
||||
@@ -98,6 +98,14 @@ bool restartApp();
|
||||
**/
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The
|
||||
* mirror of httpDownload for mod.postLog log sends, which need POST and
|
||||
* have no curl on Android. contentType / userAgent may be null. Returns
|
||||
* whether the server accepted the send (2xx).
|
||||
**/
|
||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
||||
|
||||
/**
|
||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||
|
||||
@@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath,
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpPost(const char *url, const char *body, int bodyLen,
|
||||
const char *contentType, const char *userAgent) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::httpPost(url, body, bodyLen, contentType, userAgent);
|
||||
#else
|
||||
LOVE_UNUSED(url);
|
||||
LOVE_UNUSED(body);
|
||||
LOVE_UNUSED(bodyLen);
|
||||
LOVE_UNUSED(contentType);
|
||||
LOVE_UNUSED(userAgent);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsOpen(const char *host, int port) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -151,6 +151,14 @@ public:
|
||||
virtual bool httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent = nullptr, const char *accept = nullptr) const;
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST of a raw byte body (Android only; false
|
||||
* elsewhere). The mirror of httpDownload for mod.postLog log sends,
|
||||
* which need POST and have no curl on Android (#597).
|
||||
**/
|
||||
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
||||
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
||||
|
||||
/**
|
||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||
|
||||
@@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_httpPost(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
size_t bodyLen = 0;
|
||||
const char *body = luaL_checklstring(L, 2, &bodyLen);
|
||||
const char *ct = luaL_optstring(L, 3, nullptr);
|
||||
const char *ua = luaL_optstring(L, 4, nullptr);
|
||||
luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_hasBackgroundMusic(lua_State *L)
|
||||
{
|
||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||
@@ -233,6 +244,7 @@ static const luaL_Reg functions[] =
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpPost", w_httpPost },
|
||||
{ "tlsOpen", w_tlsOpen },
|
||||
{ "tlsStatus", w_tlsStatus },
|
||||
{ "tlsSend", w_tlsSend },
|
||||
|
||||
@@ -60,6 +60,7 @@ import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Vibrator;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.*;
|
||||
@@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity {
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
onHostResume();
|
||||
refreshDualScreenDisplayMode();
|
||||
if (secondaryEnabled) registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
// AYN's panel toggle emits virtual Right Shift, which SDL maps to a
|
||||
// gameplay button. The setting is absent on other Android devices.
|
||||
if (secondaryEnabled && dualScreenDisplayMode != -1
|
||||
&& event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT
|
||||
&& event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) {
|
||||
return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* SDL decides the activity's requested orientation at window creation
|
||||
* (SDLActivity.setOrientationBis). With a resizable window and no
|
||||
@@ -741,6 +755,76 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST, exposed as love.system.httpPost and used by
|
||||
* src/core/HostShell.lua for mod.postLog. The GET bridge above covers
|
||||
* downloads; log sends need POST, and Android ships no curl, so this is
|
||||
* the only POST transport the platform has. Strictly one-way, matching
|
||||
* the curl branch it mirrors: the response body is drained and
|
||||
* discarded, and only the 2xx verdict comes back.
|
||||
*
|
||||
* Same rules as httpDownload: https only, redirects followed by hand
|
||||
* (re-POSTing the body on each hop, the way curl -X POST behaves), and
|
||||
* the call is blocking on the Lua/worker thread -- never the UI thread.
|
||||
* The body arrives as raw bytes (a jbyteArray across the JNI) because a
|
||||
* log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8
|
||||
* corruption on characters outside the BMP.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) {
|
||||
if (url == null || body == null) return false;
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
String current = url;
|
||||
for (int hop = 0; hop < 5; hop++) {
|
||||
URL parsed = new URL(current);
|
||||
if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
|
||||
conn = (HttpURLConnection) parsed.openConnection();
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(60000);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("User-Agent",
|
||||
userAgent == null ? "gen1recomp" : userAgent);
|
||||
conn.setRequestProperty("Content-Type",
|
||||
contentType == null ? "text/plain" : contentType);
|
||||
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
|
||||
try {
|
||||
out.write(body);
|
||||
} finally {
|
||||
try { out.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
|
||||
String next = conn.getHeaderField("Location");
|
||||
conn.disconnect();
|
||||
conn = null;
|
||||
if (next == null) return false;
|
||||
current = new URL(parsed, next).toString();
|
||||
continue;
|
||||
}
|
||||
if (code < 200 || code > 299) return false;
|
||||
// drain and discard, so a slow server cannot wedge the
|
||||
// worker on a full socket buffer
|
||||
InputStream in = new BufferedInputStream(conn.getInputStream());
|
||||
try {
|
||||
byte[] buf = new byte[16384];
|
||||
while (in.read(buf) > 0) {}
|
||||
} finally {
|
||||
try { in.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "httpPost failed: " + e.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||
@@ -1406,9 +1490,38 @@ public class GameActivity extends SDLActivity {
|
||||
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
|
||||
// physical display. Driven from the engine through love_android_secondary_*
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static final int SECONDARY_TARGET_AUTO = 0;
|
||||
private static final int SECONDARY_TARGET_HANDHELD = 1;
|
||||
private static final int SECONDARY_TARGET_EXTERNAL = 2;
|
||||
// AYN keeps disabled panels registered as ON. This optional setting is the
|
||||
// usable-state signal: 0 = both, 1 = main only, 2 = second only.
|
||||
private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode";
|
||||
private static final String AYN_SECOND_SCREEN = "Screen-2";
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile SecondaryActivity secondaryActivity;
|
||||
private static volatile boolean secondaryActivityPending;
|
||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
private static volatile long secondaryRetryAfter;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||
private static volatile int dualScreenDisplayMode = -1;
|
||||
private static volatile byte[] secondaryFrame;
|
||||
private static volatile int secondaryFrameWidth;
|
||||
private static volatile int secondaryFrameHeight;
|
||||
private static volatile int secondaryBackground;
|
||||
private static volatile boolean secondaryFrameCover;
|
||||
private static final Object secondaryFrameLock = new Object();
|
||||
private static volatile long secondaryDetectionAt;
|
||||
private static volatile boolean secondaryDetected;
|
||||
private SecondaryDisplayMonitor secondaryDisplayMonitor;
|
||||
private boolean dualScreenModeObserverRegistered;
|
||||
private final android.database.ContentObserver dualScreenModeObserver =
|
||||
new android.database.ContentObserver(new Handler(Looper.getMainLooper())) {
|
||||
@Override public void onChange(boolean selfChange, Uri uri) {
|
||||
refreshDualScreenDisplayMode();
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
};
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
@@ -1421,86 +1534,206 @@ public class GameActivity extends SDLActivity {
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) {
|
||||
self.refreshDualScreenDisplayMode();
|
||||
self.registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
rebindSecondaryDisplay();
|
||||
} else {
|
||||
self.unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryDisplayTarget(int target) {
|
||||
int normalized = target == SECONDARY_TARGET_HANDHELD
|
||||
|| target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO;
|
||||
if (secondaryTarget == normalized) return;
|
||||
secondaryTarget = normalized;
|
||||
secondaryDetectionAt = 0;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
|
||||
private void refreshDualScreenDisplayMode() {
|
||||
int mode = Settings.System.getInt(
|
||||
getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1);
|
||||
if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0;
|
||||
dualScreenDisplayMode = mode;
|
||||
}
|
||||
|
||||
private void registerSecondaryDisplayListener() {
|
||||
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
|
||||
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
||||
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
||||
if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
|
||||
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
||||
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
||||
}
|
||||
if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) {
|
||||
getContentResolver().registerContentObserver(
|
||||
Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false,
|
||||
dualScreenModeObserver);
|
||||
dualScreenModeObserverRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterSecondaryDisplayListener() {
|
||||
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
|
||||
secondaryDisplayMonitor = null;
|
||||
if (monitor != null) monitor.unregister();
|
||||
if (dualScreenModeObserverRegistered) {
|
||||
getContentResolver().unregisterContentObserver(dualScreenModeObserver);
|
||||
dualScreenModeObserverRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void refreshSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled) return;
|
||||
SecondaryPresentation current = secondaryPresentation;
|
||||
Display display = current == null ? null : current.getDisplay();
|
||||
private static boolean secondaryOutputIsPreferred(GameActivity self) {
|
||||
Display preferred = findSecondaryDisplay(self, false);
|
||||
if (preferred == null) return false;
|
||||
SecondaryPresentation presentation = secondaryPresentation;
|
||||
Display display = presentation == null
|
||||
? null : presentation.getDisplay();
|
||||
if (display == null) {
|
||||
SecondaryActivity activity = secondaryActivity;
|
||||
display = activity == null ? null : getActivityDisplay(activity);
|
||||
}
|
||||
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
|
||||
if (current == null) {
|
||||
setupSecondaryDisplay();
|
||||
} else if (display == null || monitor == null
|
||||
|| !monitor.hasDisplay(display.getDisplayId())) {
|
||||
if (display == null || (monitor != null
|
||||
&& !monitor.hasDisplay(display.getDisplayId()))) return false;
|
||||
return display.getDisplayId() == preferred.getDisplayId();
|
||||
}
|
||||
|
||||
private static void rebindSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
self.runOnUiThread(() -> {
|
||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
||||
|| secondaryActivity != null || secondaryActivityPending
|
||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||
try {
|
||||
android.hardware.display.DisplayManager dm =
|
||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||
if (dm == null) return;
|
||||
Display chosen = null;
|
||||
for (Display d : dm.getDisplays()) {
|
||||
android.graphics.Point size = new android.graphics.Point();
|
||||
d.getRealSize(size);
|
||||
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
|
||||
+ " size=" + size.x + "x" + size.y);
|
||||
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
|
||||
chosen = d;
|
||||
}
|
||||
}
|
||||
if (chosen == null) {
|
||||
Display[] pres =
|
||||
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||
if (pres != null && pres.length > 0) chosen = pres[0];
|
||||
}
|
||||
Display chosen = findSecondaryDisplay(self, true);
|
||||
if (chosen == null) {
|
||||
Log.d("GameActivity", "no secondary display found");
|
||||
return;
|
||||
}
|
||||
if (!isPresentationDisplay(chosen)) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 29) return;
|
||||
secondaryActivityPending = true;
|
||||
secondaryActivityTarget = chosen.getDisplayId();
|
||||
Intent intent = new Intent(self, SecondaryActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
|
||||
android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic();
|
||||
options.setLaunchDisplayId(secondaryActivityTarget);
|
||||
self.startActivity(intent, options.toBundle());
|
||||
final int requestedDisplay = secondaryActivityTarget;
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
if (secondaryActivityPending
|
||||
&& secondaryActivityTarget == requestedDisplay) {
|
||||
secondaryActivityPending = false;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
}
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
|
||||
p.setOnDismissListener(dialog -> {
|
||||
if (secondaryPresentation == p) {
|
||||
secondaryPresentation = null;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
});
|
||||
p.show();
|
||||
secondaryPresentation = p;
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame != null) {
|
||||
p.setBackground(secondaryBackground);
|
||||
p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
|
||||
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
|
||||
}
|
||||
}
|
||||
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
|
||||
} catch (Throwable t) {
|
||||
Log.d("GameActivity", "secondary display setup failed: " + t);
|
||||
secondaryPresentation = null;
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
teardownSecondaryDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
|
||||
android.hardware.display.DisplayManager dm =
|
||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||
if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
|
||||
Display gameDisplay = getActivityDisplay(self);
|
||||
int gameDisplayId = gameDisplay == null
|
||||
? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId();
|
||||
Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY);
|
||||
boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29
|
||||
&& gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld);
|
||||
Display external = null;
|
||||
Display[] presentations = dm.getDisplays(
|
||||
android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||
for (Display d : presentations) {
|
||||
if (logDisplays) {
|
||||
android.graphics.Point size = new android.graphics.Point();
|
||||
d.getRealSize(size);
|
||||
Log.d("GameActivity", "display id=" + d.getDisplayId()
|
||||
+ " name=" + d.getName() + " size=" + size.x + "x" + size.y);
|
||||
}
|
||||
if (external == null && d.getDisplayId() != gameDisplayId
|
||||
&& isDisplayUsable(d)) external = d;
|
||||
}
|
||||
if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
|
||||
if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
|
||||
return handheldAvailable ? handheld : external;
|
||||
}
|
||||
|
||||
private static Display getActivityDisplay(android.app.Activity activity) {
|
||||
return android.os.Build.VERSION.SDK_INT >= 30
|
||||
? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay();
|
||||
}
|
||||
|
||||
private static boolean isPresentationDisplay(Display display) {
|
||||
if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false;
|
||||
return android.os.Build.VERSION.SDK_INT < 20
|
||||
|| (display.getFlags() & Display.FLAG_PRESENTATION) != 0;
|
||||
}
|
||||
|
||||
private static boolean isDisplayUsable(Display display) {
|
||||
if (display == null) return false;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 20
|
||||
&& display.getState() == Display.STATE_OFF) return false;
|
||||
if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) {
|
||||
return false;
|
||||
}
|
||||
return dualScreenDisplayMode != 2
|
||||
|| display.getDisplayId() != Display.DEFAULT_DISPLAY;
|
||||
}
|
||||
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
secondaryActivity = null;
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
if (a != null) {
|
||||
try { a.finish(); } catch (Throwable t) {}
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.TargetApi(17)
|
||||
@@ -1527,21 +1760,85 @@ public class GameActivity extends SDLActivity {
|
||||
return manager.getDisplay(displayId) != null;
|
||||
}
|
||||
|
||||
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); }
|
||||
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); }
|
||||
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); }
|
||||
private void changed() {
|
||||
secondaryDetectionAt = 0;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
|
||||
@Override public void onDisplayAdded(int displayId) { changed(); }
|
||||
@Override public void onDisplayRemoved(int displayId) { changed(); }
|
||||
@Override public void onDisplayChanged(int displayId) { changed(); }
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplay() {
|
||||
return secondaryPresentation != null;
|
||||
return secondaryPresentation != null || secondaryActivity != null;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplayCandidate() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (secondaryPresentation != null || secondaryActivity != null) return true;
|
||||
self.refreshDualScreenDisplayMode();
|
||||
long now = android.os.SystemClock.uptimeMillis();
|
||||
if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
|
||||
return secondaryDetected;
|
||||
}
|
||||
secondaryDetected = findSecondaryDisplay(self, false) != null;
|
||||
secondaryDetectionAt = now;
|
||||
return secondaryDetected;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean presentSecondaryFrame(
|
||||
java.nio.ByteBuffer rgba, int width, int height,
|
||||
int backgroundColor, boolean cover) {
|
||||
long bytes = (long) width * height * 4;
|
||||
if (rgba == null || width <= 0 || height <= 0
|
||||
|| bytes <= 0 || bytes > Integer.MAX_VALUE
|
||||
|| rgba.capacity() < bytes) return false;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame == null || secondaryFrame.length != (int) bytes) {
|
||||
secondaryFrame = new byte[(int) bytes];
|
||||
}
|
||||
rgba.rewind();
|
||||
rgba.get(secondaryFrame, 0, (int) bytes);
|
||||
rgba.rewind();
|
||||
secondaryFrameWidth = width;
|
||||
secondaryFrameHeight = height;
|
||||
secondaryBackground = backgroundColor;
|
||||
secondaryFrameCover = cover;
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
if (p == null && a == null) return false;
|
||||
try {
|
||||
if (p != null) {
|
||||
p.setBackground(backgroundColor);
|
||||
p.updateFrame(rgba, width, height, cover);
|
||||
} else {
|
||||
a.setBackground(backgroundColor);
|
||||
a.updateFrame(rgba, width, height, cover);
|
||||
}
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self != null) self.runOnUiThread(() -> {
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
if (p != null && buf != null && w > 0 && h > 0) {
|
||||
p.updateFrame(buf, w, h);
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
if ((p != null || a != null) && buf != null && w > 0 && h > 0) {
|
||||
if (p != null) p.updateFrame(buf, w, h);
|
||||
else a.updateFrame(buf, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1552,6 +1849,102 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private static void applySecondaryImmersive(android.view.Window w) {
|
||||
if (w == null) return;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||
w.setDecorFitsSystemWindows(false);
|
||||
android.view.WindowInsetsController c = w.getInsetsController();
|
||||
if (c != null) {
|
||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||
c.setSystemBarsBehavior(
|
||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
} else {
|
||||
w.getDecorView().setSystemUiVisibility(
|
||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
}
|
||||
}
|
||||
|
||||
public static class SecondaryActivity extends android.app.Activity {
|
||||
private FrameView frameView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Display display = getActivityDisplay(this);
|
||||
if (!secondaryEnabled || display == null
|
||||
|| display.getDisplayId() != secondaryActivityTarget) {
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
frameView = new FrameView(this);
|
||||
android.view.Window w = getWindow();
|
||||
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
|
||||
setContentView(frameView);
|
||||
applySecondaryImmersive(w);
|
||||
secondaryActivity = this;
|
||||
secondaryActivityPending = false;
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame != null) {
|
||||
setBackground(secondaryBackground);
|
||||
updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
|
||||
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (secondaryActivity == this) secondaryActivity = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) applySecondaryImmersive(getWindow());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchGenericMotionEvent(event)
|
||||
: super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
frameView.updateFrame(buf, w, h);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
frameView.updateFrame(buf, w, h, cover);
|
||||
}
|
||||
|
||||
void setBackground(int color) {
|
||||
frameView.setFrameBackground(color);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
@@ -1585,31 +1978,36 @@ public class GameActivity extends SDLActivity {
|
||||
if (hasFocus) applyImmersive();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchGenericMotionEvent(event)
|
||||
: super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
private void applyImmersive() {
|
||||
android.view.Window w = getWindow();
|
||||
if (w == null) return;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||
w.setDecorFitsSystemWindows(false);
|
||||
android.view.WindowInsetsController c = w.getInsetsController();
|
||||
if (c != null) {
|
||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||
c.setSystemBarsBehavior(
|
||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
} else {
|
||||
w.getDecorView().setSystemUiVisibility(
|
||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
}
|
||||
applySecondaryImmersive(getWindow());
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
frameView.updateFrame(buf, w, h);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
frameView.updateFrame(buf, w, h, cover);
|
||||
}
|
||||
|
||||
void setBackground(int color) {
|
||||
frameView.setFrameBackground(color);
|
||||
}
|
||||
}
|
||||
|
||||
private static class FrameView extends View {
|
||||
@@ -1618,7 +2016,9 @@ public class GameActivity extends SDLActivity {
|
||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||
private final Object lock = new Object();
|
||||
private int fw, fh;
|
||||
private int backgroundColor = 0xFF000000;
|
||||
private int activePointer = -1;
|
||||
private boolean cover;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
@@ -1628,7 +2028,12 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
updateFrame(buf, w, h, false);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
synchronized (lock) {
|
||||
this.cover = cover;
|
||||
if (bitmap == null || fw != w || fh != h) {
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
|
||||
@@ -1640,6 +2045,13 @@ public class GameActivity extends SDLActivity {
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
void setFrameBackground(int color) {
|
||||
synchronized (lock) {
|
||||
backgroundColor = 0xFF000000 | (color & 0x00FFFFFF);
|
||||
}
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private void enqueueTouch(String event) {
|
||||
synchronized (secondaryTouches) {
|
||||
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||
@@ -1691,12 +2103,15 @@ public class GameActivity extends SDLActivity {
|
||||
synchronized (lock) {
|
||||
if (bitmap == null || fw == 0 || fh == 0) return;
|
||||
int vw = getWidth(), vh = getHeight();
|
||||
int s = Math.min(vw / fw, vh / fh);
|
||||
if (s < 1) s = 1;
|
||||
int dw = fw * s, dh = fh * s;
|
||||
float fit = Math.min((float) vw / fw, (float) vh / fh);
|
||||
if (fit <= 0) return;
|
||||
float scale = cover
|
||||
? Math.max((float) vw / fw, (float) vh / fh)
|
||||
: fit >= 2f ? (float) Math.floor(fit) : fit;
|
||||
int dw = Math.round(fw * scale), dh = Math.round(fh * scale);
|
||||
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
|
||||
dst.set(dx, dy, dx + dw, dy + dh);
|
||||
canvas.drawColor(0xFF000000);
|
||||
canvas.drawColor(backgroundColor);
|
||||
canvas.drawBitmap(bitmap, null, dst, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,13 +218,20 @@ function BattleAPI:submit(intent)
|
||||
return nil, "stale battle context"
|
||||
end
|
||||
local kind = battle:battleKind()
|
||||
if kind == "oldman" or kind == "link" or kind == "safari" then
|
||||
if kind == "oldman" or kind == "link" then
|
||||
return nil, "battle kind is not controllable"
|
||||
end
|
||||
if top ~= battle then return nil, "battle menu is covered" end
|
||||
|
||||
local ok, err
|
||||
if intent.kind == "menu" then
|
||||
if intent.kind == "safari" then
|
||||
if kind ~= "safari" then return nil, "safari menu is not active" end
|
||||
ok, err = battle:chooseSafari(intent.action)
|
||||
elseif kind == "safari" then
|
||||
return nil, "battle kind is not controllable"
|
||||
elseif intent.kind == "mimic" then
|
||||
ok, err = battle:chooseMimic(intent.index)
|
||||
elseif intent.kind == "menu" then
|
||||
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if not MENU_CHOICES[intent.choice] then
|
||||
return nil, "unknown battle menu choice"
|
||||
|
||||
@@ -88,6 +88,33 @@ function BattleState:wantsFillScale()
|
||||
return options and options.battleFit == "fill" or false
|
||||
end
|
||||
|
||||
-- EXTENDED HUD configurations are admitted one at a time after their own
|
||||
-- placement and screenshot review. FIXED supports the three authored battle
|
||||
-- backgrounds; FILL uses one adaptive presentation stored as WHITE: stock
|
||||
-- battles retain the paper field required by Gen 1 back sprites, while arena
|
||||
-- providers may replace it with their own scene. Only the HUD moves to window
|
||||
-- space.
|
||||
function BattleState:extendedHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
local bg = options and options.battleBg
|
||||
return self:wideLayout()
|
||||
and options and options.battleHud == "extended"
|
||||
and ((options.battleFit == "fixed"
|
||||
and (bg == "world" or bg == "white" or bg == "black"))
|
||||
or (options.battleFit == "fill" and bg == "white"))
|
||||
end
|
||||
|
||||
function BattleState:extendedWorldHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
return self:extendedHUD() and options
|
||||
and options.battleFit == "fixed" and options.battleBg == "world"
|
||||
end
|
||||
|
||||
function BattleState:extendedBlackHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
return self:extendedHUD() and options and options.battleBg == "black"
|
||||
end
|
||||
|
||||
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
||||
-- that grow as the window gets bigger or the view is zoomed out. The battle
|
||||
-- screen itself is untouched: it keeps its white paper field in every mode.
|
||||
@@ -1103,6 +1130,15 @@ function BattleState:stepHPDrain()
|
||||
if not b.shownPx then b.shownPx = targetPx end
|
||||
if (b.drainHold or 0) > 0 then
|
||||
b.drainHold = b.drainHold - 1
|
||||
-- Once the count runs out with nothing left pending (bar and
|
||||
-- number already on the final total), the drain is over, not just
|
||||
-- between steps: leave the field at 0 and BattleSafety.inspect
|
||||
-- reads it as still mid-animation for the rest of the battle,
|
||||
-- since drainHold ~= nil is its settled-presentation gate.
|
||||
if b.drainHold <= 0 and b.shownPx == targetPx and b.shownHP == goal
|
||||
and not b.draining then
|
||||
b.drainHold = nil
|
||||
end
|
||||
busy = true
|
||||
elseif b.shownPx ~= targetPx then
|
||||
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
||||
@@ -2019,6 +2055,38 @@ function BattleState:cancelMove()
|
||||
return true
|
||||
end
|
||||
|
||||
local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 }
|
||||
|
||||
function BattleState:chooseSafari(action)
|
||||
if self.phase ~= "menu" or not self.safari then
|
||||
return nil, "safari menu is not active"
|
||||
end
|
||||
if self.safari.balls <= 0 then return nil, "no safari balls remain" end
|
||||
local index = SAFARI_ACTION_INDEX[action]
|
||||
if not index then return nil, "invalid safari action" end
|
||||
self.menuIndex = index
|
||||
self:safariAction(action)
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:chooseMimic(index)
|
||||
if self.phase ~= "mimicSelect" then
|
||||
return nil, "mimic menu is not active"
|
||||
end
|
||||
if type(index) ~= "number" or index % 1 ~= 0 then
|
||||
return nil, "invalid mimic slot"
|
||||
end
|
||||
local pick = self.mimicMoves and self.mimicMoves[index]
|
||||
local ctx = self.mimicCtx
|
||||
if not pick or not ctx then return nil, "invalid mimic slot" end
|
||||
self.mimicIndex = index
|
||||
self.mimicMoves, self.mimicCtx = nil, nil
|
||||
self.phase = "messages"
|
||||
self.nextInsert = 0 -- the copy's anim + text go to the queue head
|
||||
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:swapMoves(i, j)
|
||||
if i == j then return end
|
||||
local moves = self.player.curMoves
|
||||
@@ -2140,7 +2208,7 @@ function BattleState:update(dt)
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
||||
self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2248,12 +2316,7 @@ function BattleState:update(dt)
|
||||
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
local pick = moves[self.mimicIndex]
|
||||
local ctx = self.mimicCtx
|
||||
self.mimicMoves, self.mimicCtx = nil, nil
|
||||
self.phase = "messages"
|
||||
self.nextInsert = 0 -- the copy's anim + text go to the queue head
|
||||
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
|
||||
self:chooseMimic(self.mimicIndex)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -33,6 +33,15 @@ end
|
||||
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
||||
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
||||
|
||||
-- Strings.source, not Strings: harvested at require time so the catalog
|
||||
-- generator can see the literal, same pattern as MoveEffects.lua's
|
||||
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
|
||||
-- the harvester can't discover.
|
||||
local STAT_LABEL = {
|
||||
attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
|
||||
speed = Strings.source("SPEED"),
|
||||
}
|
||||
|
||||
-- The trainer's ai_classes record from the merged registry; the direct
|
||||
-- require covers battles built without a loader. A trainer record's
|
||||
-- aiClass field picks a record other than its own id.
|
||||
@@ -124,7 +133,7 @@ function TrainerAI.useItem(battle, item)
|
||||
elseif X_STAT[item] then
|
||||
local stat = X_STAT[item]
|
||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat])))
|
||||
elseif item == "GUARD_SPEC" then
|
||||
enemy.mist = true
|
||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
|
||||
|
||||
@@ -92,6 +92,26 @@ local function levelAt(battle, battler, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
local function battleIsTopState(battle)
|
||||
local stack = battle.game and battle.game.stack
|
||||
return not (stack and stack.top) or stack:top() == battle
|
||||
end
|
||||
|
||||
local function anchorHUD(battle, x, y, w, h, anchor)
|
||||
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
|
||||
local renderer = battle.game and battle.game.renderer
|
||||
if not (renderer and renderer.setBattleUIAnchor) then return end
|
||||
x = x + (battle.extendedHUDOffsetX or 0)
|
||||
y = y + (battle.extendedHUDOffsetY or 0)
|
||||
local x2 = math.min(WideBattle.WIDTH, x + w)
|
||||
local y2 = math.min(WideBattle.HEIGHT, y + h)
|
||||
x, y = math.max(0, x), math.max(0, y)
|
||||
w, h = x2 - x, y2 - y
|
||||
if w > 0 and h > 0 then
|
||||
renderer:setBattleUIAnchor(x, y, w, h, anchor)
|
||||
end
|
||||
end
|
||||
|
||||
-- One side's status box: name and level on the first line, a long HP bar
|
||||
-- under it, and the numeric HP on the player's box only (the foe's exact
|
||||
-- HP is never shown, like the original).
|
||||
@@ -114,6 +134,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||
x + tw * 8 - 64, y + 24)
|
||||
end
|
||||
anchorHUD(battle, x, y, tw * 8, th * 8, player and "bottom" or "top")
|
||||
end
|
||||
|
||||
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
|
||||
@@ -144,7 +165,6 @@ local function drawHUDs(battle, slide)
|
||||
and not battle.showPlayerBack and slide == 0 then
|
||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||
end
|
||||
drawIntroBalls(battle)
|
||||
end
|
||||
|
||||
local function drawMessageBox(battle)
|
||||
@@ -268,6 +288,8 @@ local function drawTextArea(battle)
|
||||
else
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
end
|
||||
anchorHUD(battle, 0, WideBattle.FIELD_BOTTOM,
|
||||
WideBattle.WIDTH, WideBattle.HEIGHT - WideBattle.FIELD_BOTTOM, "bottom")
|
||||
end
|
||||
|
||||
-- Battle animations are authored in the original 160px coordinate space.
|
||||
@@ -309,17 +331,23 @@ end
|
||||
-- The whole 304x144 composition for one frame.
|
||||
function WideBattle.draw(battle)
|
||||
local g = love.graphics
|
||||
local renderer = battle.game and battle.game.renderer
|
||||
local extendedHUD = battle:extendedHUD() and renderer
|
||||
and renderer.beginBattleHUDPass
|
||||
and renderer.endBattleHUDPass
|
||||
-- The field is the display mode's paper. Under a forced-mono mode the
|
||||
-- whole surface is remapped downstream (WideBattle.zones), so the field
|
||||
-- goes down as DMG white and comes out of that pass as the mode's paper;
|
||||
-- painting the resolved shade there would run it through the remap twice
|
||||
-- and land a shade off the letterbox the renderer fills around it.
|
||||
if monoMode() then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
else
|
||||
g.setColor(PaletteFX.paperShade(battle.data))
|
||||
if not (extendedHUD and battle:extendedWorldHUD()) then
|
||||
if monoMode() then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
else
|
||||
g.setColor(PaletteFX.paperShade(battle.data))
|
||||
end
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
end
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
-- AskName clears the field the same way the classic layout does
|
||||
if battle.blankForAskName then return end
|
||||
|
||||
@@ -346,6 +374,7 @@ function WideBattle.draw(battle)
|
||||
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||
battle.wideRegion = nil
|
||||
drawIntroBalls(battle)
|
||||
|
||||
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
|
||||
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
|
||||
@@ -359,12 +388,26 @@ function WideBattle.draw(battle)
|
||||
if sx == 0 and sy == 0 then return fn() end
|
||||
g.push()
|
||||
g.translate(sx, sy)
|
||||
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = sx, sy
|
||||
fn()
|
||||
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = nil, nil
|
||||
g.pop()
|
||||
end
|
||||
shaken(function() drawHUDs(battle, slide) end)
|
||||
drawAnimationLayer(battle)
|
||||
shaken(function() drawTextArea(battle) end)
|
||||
|
||||
if extendedHUD then
|
||||
local previous = renderer:beginBattleHUDPass()
|
||||
shaken(function() drawHUDs(battle, slide) end)
|
||||
shaken(function() drawTextArea(battle) end)
|
||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||
g.setColor(1, 1, 1, 0.85)
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
end
|
||||
renderer:endBattleHUDPass(previous)
|
||||
else
|
||||
shaken(function() drawHUDs(battle, slide) end)
|
||||
shaken(function() drawTextArea(battle) end)
|
||||
end
|
||||
|
||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||
g.setColor(1, 1, 1, 0.85)
|
||||
|
||||
@@ -2429,7 +2429,7 @@ Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker)
|
||||
if (side.lightScreen or 0) > 0 then return fail(self) end
|
||||
side.lightScreen = Battle.SCREEN_TURNS
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. "'s SPCL.DEF rose!" })
|
||||
text = Strings("%s's SPCL.DEF rose!", self:monName(attacker)) })
|
||||
end
|
||||
|
||||
Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
|
||||
@@ -2437,7 +2437,7 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
|
||||
if (side.reflect or 0) > 0 then return fail(self) end
|
||||
side.reflect = Battle.SCREEN_TURNS
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. "'s DEFENSE rose!" })
|
||||
text = Strings("%s's DEFENSE rose!", self:monName(attacker)) })
|
||||
end
|
||||
|
||||
-- engine/battle/move_effects/safeguard.asm:1
|
||||
|
||||
@@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data)
|
||||
if mon.dvs then
|
||||
mon.gender = Mon.gender(def, mon.dvs,
|
||||
{ species = mon.species, level = mon.level })
|
||||
mon.shiny = Mon.isShiny(mon.dvs,
|
||||
-- shiny is monotonic once true, the same as opts.shiny winning over
|
||||
-- shiny.roll at Mon.new: a forced shiny (the scripted-shiny path, DVs
|
||||
-- that do not themselves read as shiny) must not un-shiny the moment
|
||||
-- this runs again, and it runs on every SummaryMenu open via
|
||||
-- refreshStats. A mon not already shiny still promotes normally if
|
||||
-- its DVs justify it, e.g. after an edit.
|
||||
mon.shiny = mon.shiny or Mon.isShiny(mon.dvs,
|
||||
{ species = mon.species, def = def, level = mon.level })
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
|
||||
+51
-11
@@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep")
|
||||
local Input = require("src.core.Input")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
@@ -305,12 +306,32 @@ function Game.worldBgBattleDim(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.bgMode and state:bgMode() == "world" then
|
||||
-- The extended fixed HUD intentionally exposes the live world across
|
||||
-- the whole physical window. Keep this as a world-backed battle (zero
|
||||
-- is non-nil, so scaling and overlay holds remain active), but do not
|
||||
-- paint the standard dim veil around the native battle rectangle.
|
||||
if state.extendedWorldHUD and state:extendedWorldHUD() then
|
||||
return 0
|
||||
end
|
||||
return state.BG_WORLD_DIM or 0.55
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Does the stack contain the opt-in fixed Extended WORLD battle? Renderer
|
||||
-- uses this separately from battleDim: the world remains the surround, while
|
||||
-- the native-width battle field receives a paper backing from top to bottom.
|
||||
function Game.extendedWorldHUDInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.extendedWorldHUD and state:extendedWorldHUD() then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
|
||||
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
|
||||
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
|
||||
@@ -403,13 +424,13 @@ function Game.uiAnchorsHeldInStack(stack)
|
||||
end
|
||||
|
||||
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
|
||||
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
|
||||
-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
|
||||
-- the bag) becomes that base, cutting the overworld -- and with it the world
|
||||
-- pass -- out of the frame entirely. The backdrop the battle established
|
||||
-- then collapses to endFrame's flat black clear for as long as the menu is
|
||||
-- up. So a world-bg battle keeps the frame starting from underneath itself
|
||||
-- until it leaves the stack, the same hold uiFill and the dim already use.
|
||||
-- state (StateStack:visibleBase) -- but an opaque menu pushed over a WIDE
|
||||
-- battle must not prevent that battle from drawing. Native WIDE battles own
|
||||
-- the 304x144 surround around a centred classic menu, while external arena
|
||||
-- providers establish their window-sized scene from BattleState:draw. If the
|
||||
-- menu becomes the draw base, neither owner runs and the menu's white field
|
||||
-- replaces the whole presentation. BATTLE BG "world" additionally needs the
|
||||
-- overworld below the battle, as before.
|
||||
--
|
||||
-- Only the START of the draw moves. The clear stays keyed to the real
|
||||
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
|
||||
@@ -420,9 +441,13 @@ function Game.drawBaseInStack(stack, visibleBase)
|
||||
local states = stack and stack.states or {}
|
||||
for i = visibleBase - 1, 1, -1 do
|
||||
local state = states[i]
|
||||
if state and state.bgMode and state:bgMode() == "world" then
|
||||
local worldBattle = state and state.bgMode and state:bgMode() == "world"
|
||||
local wideBattle = state and state.isWideBattleLayout
|
||||
and state:isWideBattleLayout()
|
||||
if worldBattle or wideBattle then
|
||||
-- restart the search from under the battle: the highest opaque state at
|
||||
-- or below it (the overworld), not the menu sitting over it
|
||||
-- or below it (the battle itself for white/black WIDE, the overworld for
|
||||
-- a non-opaque world-backed battle), not the menu sitting over it
|
||||
for j = i, 1, -1 do
|
||||
if states[j].isOpaque then return j end
|
||||
end
|
||||
@@ -432,6 +457,14 @@ function Game.drawBaseInStack(stack, visibleBase)
|
||||
return visibleBase
|
||||
end
|
||||
|
||||
-- A classic overlay above the approved world-backed extended HUD paints only
|
||||
-- its centred area. Keep the wider owner surface transparent so its margins
|
||||
-- continue to reveal the world instead of becoming an opaque white sheet.
|
||||
function Game.uiCanvasTransparent(worldBelow, worldDrawn, wideBattle)
|
||||
return worldBelow or (worldDrawn and wideBattle ~= nil
|
||||
and wideBattle.extendedHUD and wideBattle:extendedHUD())
|
||||
end
|
||||
|
||||
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||
-- sprite and status zones move with the classic UI content.
|
||||
@@ -452,6 +485,7 @@ local function centerClassicZones(zones, offset)
|
||||
end
|
||||
|
||||
function Game:draw()
|
||||
GameViewport.begin(1)
|
||||
-- the UI canvas clears transparent when the overworld's world pass
|
||||
-- shows through beneath it; opaque full-screen states get the classic
|
||||
-- white clear
|
||||
@@ -485,6 +519,7 @@ function Game:draw()
|
||||
-- the stack for the same reason as uiFill above -- a prompt opened during
|
||||
-- the battle must not drop the dim for a frame.
|
||||
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
|
||||
Renderer.extendedWorldBand = Game.extendedWorldHUDInStack(self.stack)
|
||||
-- ...and for the same reason the UI's own scale has to know the world is
|
||||
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
||||
-- steps the UI down with the survey zoom only while a world is behind it,
|
||||
@@ -502,7 +537,8 @@ function Game:draw()
|
||||
-- menu to its top right, and the whole UI steps down with the zoom.
|
||||
Renderer.uiCentered = not Game.dynamicUI(self.save)
|
||||
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
|
||||
Renderer:beginFrame(worldBelow)
|
||||
Renderer:beginFrame(Game.uiCanvasTransparent(
|
||||
worldBelow, worldDrawn, wideBattle))
|
||||
for i = drawFrom, #self.stack.states do
|
||||
local state = self.stack.states[i]
|
||||
local wideState = state and state.isWideBattleLayout
|
||||
@@ -563,7 +599,9 @@ function Game:draw()
|
||||
if ModRuntime.wantsHook("render.hud") then
|
||||
ModRuntime.call("render.hud", function() end, self, viewport)
|
||||
end
|
||||
-- on-screen mobile controls: pure screen-space, over the finished frame
|
||||
GameViewport.finish(self)
|
||||
-- OS-window chrome: keep the pad full-size and above any composed companion
|
||||
-- view instead of capturing and shrinking it with the game viewport.
|
||||
TouchControls:draw()
|
||||
end
|
||||
|
||||
@@ -939,8 +977,10 @@ local function pointerUnclaimed() return false end
|
||||
-- coordinates are LOVE window units, the same space render.hud's viewport
|
||||
-- and the touch overlay lay out in
|
||||
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
|
||||
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
|
||||
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
||||
phase = phase, source = source, id = id, x = x, y = y,
|
||||
gameX = gameX, gameY = gameY, insideGame = insideGame,
|
||||
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
||||
})
|
||||
end
|
||||
|
||||
+28
-19
@@ -35,6 +35,7 @@ local World = require("src.world.gen2.World")
|
||||
-- The mod event/hook buses. Gold reaches them through Runtime like every
|
||||
-- other engine file, so a call site here is the same call site Gen 1 has.
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
-- Only for the mod-supplied save migrations and the mods-changed report, which
|
||||
-- are keyed off save.meta and know nothing about a generation; Gold's own save
|
||||
-- IO is src/core/gen2/Save.lua.
|
||||
@@ -73,8 +74,8 @@ end
|
||||
--
|
||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||
-- (the FixedStep callback in Game2:load), so none of it goes through
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
|
||||
-- hooks below never used to fire here; it is not a reason they should not. A
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
|
||||
-- below never used to fire here; it is not a reason they should not. A
|
||||
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
||||
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
||||
-- PAYLOAD, at the Gen 1 point in the order:
|
||||
@@ -86,6 +87,8 @@ end
|
||||
-- render.output* the normal composed frame (Renderer.lua:1063)
|
||||
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
||||
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
||||
-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
|
||||
-- render.window final OS-window composition (GameViewport.lua:145)
|
||||
--
|
||||
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
|
||||
-- world pass and the UI into ONE canvas, not two -- the call site says so and
|
||||
@@ -680,7 +683,10 @@ end
|
||||
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
|
||||
function Game2:usePartyItem(itemId)
|
||||
local ItemEffects = require("src.core.gen2.ItemEffects")
|
||||
local action = ItemEffects.partyAction(itemId)
|
||||
-- without the merged dataset this can only ever see RECORDS, the
|
||||
-- module's own built-ins, so a mod's field item resolves to no action
|
||||
-- at all and never gets past the .Oak refusal below
|
||||
local action = ItemEffects.partyAction(itemId, self.data)
|
||||
if not action then return end
|
||||
local party = (self.save and self.save.party) or {}
|
||||
if #party == 0 then
|
||||
@@ -1197,9 +1203,7 @@ function Game2:frameFit(w, h)
|
||||
dpi = tonumber(love.window.getDPIScale()) or 1
|
||||
end
|
||||
local pw, ph = w * dpi, h * dpi
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
pw, ph = GameViewport.pixelDimensions()
|
||||
return scale, ox, oy, dpi, pw, ph
|
||||
end
|
||||
|
||||
@@ -1217,18 +1221,15 @@ function Game2:viewport(w, h)
|
||||
}
|
||||
end
|
||||
|
||||
-- The screen-space layer, in the Gen 1 order: render.hud and then the
|
||||
-- on-screen pad (src/core/Game.lua:521 and :524, either side of
|
||||
-- Renderer:endFrame). Both are window-space, both sit over the finished
|
||||
-- frame -- post passes, letterbox and all -- and neither ever enters the game
|
||||
-- canvas. Every exit path of Game2:draw ends here, which is what makes that
|
||||
-- true of the composed frame a mod owns as well as of the plain one.
|
||||
-- The render.hud layer, in Gen 1's order over the finished game frame. The
|
||||
-- on-screen pad is drawn separately after GameViewport.finish, because it is
|
||||
-- OS-window chrome and must not be captured or scaled with this canvas.
|
||||
--
|
||||
-- render.hud: persistent tool status. The call is fenced with
|
||||
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
|
||||
-- mod render callback: a subscriber that returns cleanly but leaves a shader
|
||||
-- bound, the canvas redirected or the colour changed must not corrupt the next
|
||||
-- frame -- or, now, the pad drawn immediately after it.
|
||||
-- frame.
|
||||
function Game2:drawHud(w, h)
|
||||
if ModRuntime.wantsHook("render.hud") then
|
||||
local G = love.graphics
|
||||
@@ -1236,10 +1237,6 @@ function Game2:drawHud(w, h)
|
||||
ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
|
||||
G.pop()
|
||||
end
|
||||
-- The pad LAST, so a HUD mod cannot draw over the controls the player is
|
||||
-- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
|
||||
-- forces it, and nothing ever while a controller is in use.
|
||||
TouchControls:draw()
|
||||
end
|
||||
|
||||
-- render.letterbox: SGB borders and custom void art in the bars around the
|
||||
@@ -1363,9 +1360,9 @@ end
|
||||
-- is being shown on. Mod post-processes fold in between the two, where
|
||||
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
|
||||
-- is then drawn over, rather than something that smears the grid itself.
|
||||
function Game2:draw()
|
||||
function Game2:drawViewportFrame()
|
||||
local G = love.graphics
|
||||
local w, h = G.getDimensions()
|
||||
local w, h = GameViewport.dimensions()
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
@@ -1477,6 +1474,16 @@ function Game2:draw()
|
||||
self:drawHud(w, h)
|
||||
end
|
||||
|
||||
function Game2:draw()
|
||||
GameViewport.begin(2)
|
||||
GameViewport.setTarget()
|
||||
self:drawViewportFrame()
|
||||
GameViewport.finish(self)
|
||||
-- OS-window chrome: draw after companion composition so viewport layouts
|
||||
-- neither shrink nor cover the touch pad.
|
||||
TouchControls:draw()
|
||||
end
|
||||
|
||||
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from
|
||||
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
|
||||
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
|
||||
@@ -1803,8 +1810,10 @@ end
|
||||
|
||||
-- coordinates are LOVE window units, the same space render.hud's viewport is in
|
||||
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
|
||||
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
|
||||
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
||||
phase = phase, source = source, id = id, x = x, y = y,
|
||||
gameX = gameX, gameY = gameY, insideGame = insideGame,
|
||||
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
||||
})
|
||||
end
|
||||
|
||||
+47
-3
@@ -272,6 +272,38 @@ function HostShell.quote(s)
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- Launch another instance of this packaged app without waiting for it. The
|
||||
-- same path works on all process-capable desktop hosts; only the shell's
|
||||
-- background spelling differs. Source checkouts include their game folder,
|
||||
-- while fused releases and AppImages already carry it in the executable.
|
||||
function HostShell.spawnSelfDetached(args)
|
||||
if not require("src.core.Platform").canSpawnProcess() then return false end
|
||||
local fs = love and love.filesystem
|
||||
if not (fs and fs.getExecutablePath) then return false end
|
||||
local executable = os.getenv("APPIMAGE") or fs.getExecutablePath()
|
||||
if type(executable) ~= "string" or executable == "" then return false end
|
||||
|
||||
local argv = {}
|
||||
local fused = fs.isFused and fs.isFused()
|
||||
if not os.getenv("APPIMAGE") and not fused and fs.getSource then
|
||||
argv[#argv + 1] = fs.getSource()
|
||||
end
|
||||
for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end
|
||||
|
||||
local command = HostShell.quote(executable)
|
||||
for _, value in ipairs(argv) do
|
||||
command = command .. " " .. HostShell.quote(value)
|
||||
end
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
if osName == "Windows" then
|
||||
command = 'start "" /b ' .. command .. " >NUL 2>&1"
|
||||
else
|
||||
command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &"
|
||||
end
|
||||
local ok, _, code = os.execute(command)
|
||||
return ok == true or ok == 0 or code == 0
|
||||
end
|
||||
|
||||
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
|
||||
-- `curl --version` process on every single fetch -- twice for a GET through
|
||||
-- the Android-bridge fallback -- which doubled the number of spawns the lock
|
||||
@@ -420,9 +452,11 @@ end
|
||||
-- POST returning success/failure. Strictly one-way: the response body is
|
||||
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
||||
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
||||
-- large body never lands in the command line; the Android bridge has no POST
|
||||
-- transport, and httpPost reports that instead of half-working through
|
||||
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
|
||||
-- large body never lands in the command line; where curl is absent (Android
|
||||
-- and the other bridge-only platforms) the POST rides the JNI bridge --
|
||||
-- love.system.httpPost, the dedicated POST arm added beside httpDownload --
|
||||
-- instead of half-working through httpDownload (a GET round-trip to a POST
|
||||
-- endpoint would be a lie).
|
||||
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
if type(body) ~= "string" then return nil, "missing body" end
|
||||
@@ -498,6 +532,16 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
-- The GET bridge has no POST; the dedicated love.system.httpPost arm
|
||||
-- (GameActivity.httpPost) is the transport where curl is missing. A
|
||||
-- build without it reports the same "no POST transport" a missing curl
|
||||
-- would -- the old-APK skew path in the JNI bridge returns false.
|
||||
if love.system and type(love.system.httpPost) == "function" then
|
||||
local ok, sent = pcall(love.system.httpPost, url, body, contentType,
|
||||
userAgent)
|
||||
if ok and sent then return true end
|
||||
return nil, "log post rejected"
|
||||
end
|
||||
return nil, "no POST transport on this platform"
|
||||
end
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
local SafeArea = {}
|
||||
|
||||
function SafeArea.rect()
|
||||
function SafeArea.windowRect()
|
||||
local ww, wh = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
ww, wh = love.graphics.getDimensions()
|
||||
ww, wh = GameViewport.fullDimensions()
|
||||
end
|
||||
if ww <= 0 then ww = 1 end
|
||||
if wh <= 0 then wh = 1 end
|
||||
@@ -55,4 +57,8 @@ function SafeArea.rect()
|
||||
return x, y, w, h
|
||||
end
|
||||
|
||||
function SafeArea.rect()
|
||||
return GameViewport.localSafeRect(SafeArea.windowRect())
|
||||
end
|
||||
|
||||
return SafeArea
|
||||
|
||||
@@ -239,6 +239,11 @@ function SaveData.defaultOptions()
|
||||
-- scale the battle surface to the window so it fills vertically. See
|
||||
-- BattleState:wantsFillScale.
|
||||
battleFit = "fixed",
|
||||
-- BATTLE HUD: STANDARD keeps every wide-battle element inside the native
|
||||
-- 304x144 surface. EXTENDED is opt-in window-space placement for selected
|
||||
-- wide layouts; unsupported combinations deliberately fall back to the
|
||||
-- standard composition.
|
||||
battleHud = "standard",
|
||||
-- BATTLE BG: what fills the screen behind and around the battle.
|
||||
-- "white" = the display mode's paper shade (the classic look),
|
||||
-- "black" = plain black bars, "world" = the frozen overworld showing
|
||||
|
||||
@@ -339,7 +339,7 @@ end
|
||||
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
|
||||
-- which layout(), the editor chrome and the tests read.
|
||||
function TouchControls:currentBucket()
|
||||
local _, _, sw, sh = SafeArea.rect()
|
||||
local _, _, sw, sh = SafeArea.windowRect()
|
||||
local o = orientationFor(sw, sh)
|
||||
self.layouts = self.layouts or { portrait = {}, landscape = {} }
|
||||
local b = self.layouts[o]
|
||||
@@ -363,7 +363,7 @@ end
|
||||
-- while sizes stay derived from the short edge, times the orientation's
|
||||
-- size setting (#633).
|
||||
function TouchControls:layout()
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local ox, oy, sw, sh = SafeArea.windowRect()
|
||||
if self.layoutW == sw and self.layoutH == sh
|
||||
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||
return self.L
|
||||
@@ -397,7 +397,7 @@ end
|
||||
-- Move one control to a screen-space point and persist its normalized
|
||||
-- position within the safe rect. Used by the layout editor while dragging.
|
||||
function TouchControls:setControlCenter(name, cx, cy)
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local ox, oy, sw, sh = SafeArea.windowRect()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
@@ -608,10 +608,10 @@ local function drawIcon(img, zone, pressed, alphaMul)
|
||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||
end
|
||||
|
||||
-- Screen-space, called by Game:draw after Renderer:endFrame -- and by
|
||||
-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top
|
||||
-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher
|
||||
-- layout editor under preview mode.
|
||||
-- OS-window space, called after GameViewport.finish so the overlay rides on
|
||||
-- top of the game, companion composition and post-processing without being
|
||||
-- captured or scaled with any game viewport. Also used by the launcher layout
|
||||
-- editor under preview mode.
|
||||
function TouchControls:draw()
|
||||
if not self:visible() then return end
|
||||
local L = self:layout()
|
||||
|
||||
@@ -16,13 +16,56 @@
|
||||
-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek)
|
||||
-- and World only ever reads it.
|
||||
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Clock = {}
|
||||
|
||||
Clock.MINUTES_PER_DAY = 24 * 60
|
||||
Clock.DAYS = 7
|
||||
|
||||
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0, so
|
||||
-- index 1 is SUNDAY -- matching both InitClock's `self.day + 1` and
|
||||
-- `Clock.weekday(save) + 1`. Strings.source, not Strings: built at require
|
||||
-- time, before Strings.load has a catalog, so Clock.weekdayName looks each
|
||||
-- name up at display time instead (src/battle/MoveEffects.lua's STAT_LABEL
|
||||
-- is the same pattern). One shared table and one lookup function, so
|
||||
-- InitClock's screens, the main menu clock box and the Pokegear clock card
|
||||
-- cannot drift apart on what a weekday is called.
|
||||
Clock.DAY_NAMES = {
|
||||
Strings.source("SUNDAY"), Strings.source("MONDAY"), Strings.source("TUESDAY"),
|
||||
Strings.source("WEDNESDAY"), Strings.source("THURSDAY"), Strings.source("FRIDAY"),
|
||||
Strings.source("SATURDAY"),
|
||||
}
|
||||
|
||||
-- The translated name for a 1-based weekday (SUNDAY = 1), or nil if `day` is
|
||||
-- out of range.
|
||||
function Clock.weekdayName(day)
|
||||
local name = Clock.DAY_NAMES[day]
|
||||
return name and Strings(name)
|
||||
end
|
||||
|
||||
-- The three words Palettes.clockDaytime can hand back, translated (never
|
||||
-- DARK: that one only comes out of Palettes.daytimeFor, for a PALETTE_DARK
|
||||
-- map, and is never printed as text). Strings.source, not Strings:
|
||||
-- clockDaytime's return value is also an internal key every
|
||||
-- FORCED_DAYTIME/palette lookup in Palettes.lua compares against, so THAT
|
||||
-- stays untranslated -- only this table, and Clock.daytimeLabel below, look
|
||||
-- a word up, at the UI call sites that actually print it.
|
||||
local DAYTIME_LABEL = {
|
||||
MORN = Strings.source("MORN"), DAY = Strings.source("DAY"),
|
||||
NITE = Strings.source("NITE"),
|
||||
}
|
||||
|
||||
-- clockDaytime's word, translated -- the one InitClock's clock-setting
|
||||
-- screen and the Pokegear's clock card print (DisplayHourOClock /
|
||||
-- Pokegear_UpdateClock).
|
||||
function Clock.daytimeLabel(hour)
|
||||
local daytime = Palettes.clockDaytime(hour)
|
||||
return Strings(DAYTIME_LABEL[daytime] or daytime)
|
||||
end
|
||||
|
||||
-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute
|
||||
-- buffer left at the zero ByteFill put there.
|
||||
Clock.DEFAULT_HOUR = 10
|
||||
|
||||
@@ -138,15 +138,72 @@ local function coreRows(opts, hooks)
|
||||
ladder(opts, "battleStyle",
|
||||
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
|
||||
add(Strings("BATTLE LAYOUT"),
|
||||
ladder(opts, "battleLayout",
|
||||
{ { "og", "OG" }, { "wide", "WIDE" } }, "og"))
|
||||
function()
|
||||
return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
|
||||
end,
|
||||
function()
|
||||
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
|
||||
if opts.battleLayout ~= "wide" then
|
||||
opts.battleHud = "standard"
|
||||
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
|
||||
opts.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end)
|
||||
add(Strings("BATTLE SIZE"),
|
||||
ladder(opts, "battleFit",
|
||||
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
|
||||
function()
|
||||
return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED")
|
||||
end,
|
||||
function()
|
||||
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
|
||||
if opts.battleFit == "fill" and opts.battleLayout == "wide"
|
||||
and opts.battleHud == "extended" then
|
||||
opts.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end)
|
||||
add(Strings("BATTLE HUD"),
|
||||
function()
|
||||
return opts.battleLayout == "wide" and opts.battleHud == "extended"
|
||||
and Strings("EXTENDED")
|
||||
or Strings("STANDARD")
|
||||
end,
|
||||
function()
|
||||
if opts.battleLayout ~= "wide" then
|
||||
opts.battleHud = "standard"
|
||||
return false
|
||||
end
|
||||
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
|
||||
if opts.battleHud == "extended" and opts.battleFit == "fill" then
|
||||
opts.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end)
|
||||
add(Strings("BATTLE BG"),
|
||||
ladder(opts, "battleBg",
|
||||
{ { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } },
|
||||
"white"))
|
||||
function()
|
||||
if opts.battleLayout == "wide" and opts.battleFit == "fill"
|
||||
and opts.battleHud == "extended" then
|
||||
opts.battleBg = "white"
|
||||
return Strings("AUTO")
|
||||
end
|
||||
if opts.battleBg == "black" then return Strings("BLACK") end
|
||||
if opts.battleBg == "world" then return Strings("WORLD") end
|
||||
return Strings("WHITE")
|
||||
end,
|
||||
function(dir)
|
||||
if opts.battleLayout == "wide" and opts.battleFit == "fill"
|
||||
and opts.battleHud == "extended" then
|
||||
opts.battleBg = "white"
|
||||
return false
|
||||
end
|
||||
local order = { "white", "black", "world" }
|
||||
local cur = 1
|
||||
for i, mode in ipairs(order) do
|
||||
if opts.battleBg == mode then cur = i break end
|
||||
end
|
||||
opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1]
|
||||
return true
|
||||
end)
|
||||
add(Strings("UI LAYOUT"),
|
||||
ladder(opts, "uiLayout",
|
||||
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
|
||||
|
||||
@@ -979,7 +979,7 @@ function LauncherView._updateControl(imp)
|
||||
end
|
||||
-- idle / uptodate / error: offer a manual check, with no glow.
|
||||
return status, Strings("Check for updates"),
|
||||
function() pcall(imp.Check.start) end, false
|
||||
function() pcall(imp.Check.start, true) end, false
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ game panel
|
||||
|
||||
@@ -59,6 +59,16 @@ local STONES = {
|
||||
LEAF_STONE = true, MOON_STONE = true,
|
||||
}
|
||||
|
||||
-- Strings.source, not Strings: harvested at require time so the catalog
|
||||
-- generator can see the literal, same pattern as MoveEffects.lua's
|
||||
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
|
||||
-- the harvester can't discover.
|
||||
local STAT_LABEL = {
|
||||
hp = Strings.source("HP"), attack = Strings.source("ATTACK"),
|
||||
defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"),
|
||||
special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"),
|
||||
}
|
||||
|
||||
-- vitamins: stat-exp boosters (ItemUseVitamin)
|
||||
local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
|
||||
CARBOS = "speed", CALCIUM = "special" }
|
||||
@@ -294,7 +304,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
"Nothing happened!") }
|
||||
end
|
||||
b.stages[stat] = cur + 1
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
|
||||
end
|
||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||
-- the item, even when it is already active
|
||||
@@ -493,7 +503,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||
-- cannot be filled positionally; the engine wording stands
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||
Strings(STAT_LABEL[vitaminStat])) }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
@@ -515,7 +525,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
local ok = false
|
||||
for _, m in ipairs(speciesDef.tmhm) do
|
||||
-- a species record with no tmhm list at all is "teaches nothing", the
|
||||
-- same as one whose list just does not name this move -- not a reason
|
||||
-- to crash instead of refusing normally
|
||||
for _, m in ipairs(speciesDef.tmhm or {}) do
|
||||
if m == itemDef.machine.move then ok = true break end
|
||||
end
|
||||
if not ok then
|
||||
|
||||
@@ -1297,6 +1297,7 @@ function ManagerState:drawOverlay()
|
||||
love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(2, ty, 16, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, line in ipairs(lines) do
|
||||
drawTruncated(line, 4 * 8, (ty + i) * 8, 14)
|
||||
end
|
||||
@@ -1325,6 +1326,7 @@ function ManagerState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(0, 0, 20, 18)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
|
||||
if self.screen == "list" then
|
||||
self:drawList()
|
||||
|
||||
@@ -12,7 +12,7 @@ local Manifest = {}
|
||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
||||
engine_internals = true, steps = true,
|
||||
background = true }
|
||||
background = true, compute = true }
|
||||
|
||||
-- link-relevant registries; a mod that writes into one of these while
|
||||
-- declaring affects_link = false gets an attributed warning from the loader
|
||||
|
||||
+22
-4
@@ -28,8 +28,14 @@ local DENIED = {
|
||||
}
|
||||
|
||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||
-- love.thread starts a Lua state this sandbox has no say over.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
||||
-- love.thread starts a Lua state this sandbox has no say over. jit.util
|
||||
-- is the LuaJIT-specific equal of the debug library above -- funcbc,
|
||||
-- funck and friends read the bytecode and constants of any function a
|
||||
-- chunk can reach, which is enough to walk back to upvalues (the real
|
||||
-- _G, love, io) the rest of this file exists to keep out of reach. The
|
||||
-- bare `jit` table stays -- env.jit above hands it over directly for
|
||||
-- jit.on/off/flush -- so only the submodule require is denied.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true, ["jit"] = true }
|
||||
|
||||
-- The wire, which is what the network permission governs.
|
||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||
@@ -79,13 +85,25 @@ local BLOCKED_LOVE = {
|
||||
|
||||
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
||||
-- by that mod's own overlay and must not be shared.
|
||||
local function loveFacade(compat)
|
||||
local function loveFacade(compat, permissions)
|
||||
if not _G.love then return nil end
|
||||
local overrides = compat and compat.love
|
||||
return setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local override = overrides and overrides[key]
|
||||
if override ~= nil then return override end
|
||||
if key == "thread" then
|
||||
-- Threads open a fresh Lua state with a full standard library, so
|
||||
-- they stay blocked unless the mod declares the `compute`
|
||||
-- permission (the mod's own source runs in the worker, and the
|
||||
-- worker ships source-only like every other mod file). The mod
|
||||
-- must never receive arbitrary code from elsewhere: channels
|
||||
-- carry data only.
|
||||
if not (permissions or {}).compute then
|
||||
error('love.thread needs the "compute" permission in manifest.json', 2)
|
||||
end
|
||||
return _G.love.thread
|
||||
end
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
@@ -215,7 +233,7 @@ function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local compat = opts.compat
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade(compat)
|
||||
env.love = loveFacade(compat, opts.permissions)
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
||||
local loader = sandboxedLoad(env)
|
||||
env.load = loader
|
||||
|
||||
+62
-7
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
|
||||
desc = "{" .. table.concat(parts, ", ") .. "}" }
|
||||
end
|
||||
|
||||
-- An open record: the listed fields are typed (including f.id
|
||||
-- cross-references) and everything else on the value passes through
|
||||
-- unexamined, unlike f.rec's nested shapes, which reject any key they do
|
||||
-- not name. Map objects are why this exists -- NPCs, signs, items, warps
|
||||
-- and static encounters all share one array, and only a full union of
|
||||
-- every kind's shape could describe it as f.rec; that is a lot of surface
|
||||
-- to keep in sync with the loader for fields nothing here needs to check.
|
||||
-- f.partial types just the field that actually names another registry and
|
||||
-- leaves every kind-specific field around it alone.
|
||||
function f.partial(fields)
|
||||
local names = {}
|
||||
for name in pairs(fields) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
local parts = {}
|
||||
for _, name in ipairs(names) do
|
||||
local ft = fields[name]
|
||||
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
|
||||
end
|
||||
return { kind = "partial", fields = fields,
|
||||
desc = "{" .. table.concat(parts, ", ") .. ", ...}" }
|
||||
end
|
||||
|
||||
function f.union(alts)
|
||||
local parts = {}
|
||||
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
|
||||
@@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top)
|
||||
end
|
||||
return
|
||||
end
|
||||
if kind == "partial" then
|
||||
-- the open counterpart of "rec": listed fields are checked exactly
|
||||
-- like a rec's, and any key not listed is left alone rather than
|
||||
-- flagged, so a heterogeneous blob (map objects) can have one field
|
||||
-- typed without every other shape sharing the array being rejected
|
||||
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
|
||||
for key, ft in pairs(t.fields) do
|
||||
local sub = value[key]
|
||||
if sub ~= nil then
|
||||
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
|
||||
elseif ft.kind ~= "opt" and not patchMode then
|
||||
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
|
||||
:format(path, key, ft.desc)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
if kind == "union" then
|
||||
for _, alt in ipairs(t.alts) do
|
||||
local scratch = {}
|
||||
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
|
||||
for k, v in pairs(value) do
|
||||
collectRefs(t.value, v, path .. "." .. tostring(k), out)
|
||||
end
|
||||
elseif kind == "rec" and type(value) == "table" then
|
||||
elseif (kind == "rec" or kind == "partial") and type(value) == "table" then
|
||||
for key, ft in pairs(t.fields) do
|
||||
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
|
||||
end
|
||||
@@ -377,11 +416,19 @@ function Schemas.crossValidate(loader, data)
|
||||
and loader.content[ref.registry]
|
||||
-- A registry with no home in this generation has no id space to
|
||||
-- check against: its base view resolves to nothing, so EVERY
|
||||
-- reference into it would read as dangling. Gold's species carry a
|
||||
-- growthRate and an evolution method like Red's do; the ids are
|
||||
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods`
|
||||
-- namespaces that are not there to confirm them. Skipped for the
|
||||
-- same reason an undeclared registry is: unknown, not wrong.
|
||||
-- reference into it would read as dangling. `transitions` is the
|
||||
-- standing example -- Gold draws its own battle intro and never
|
||||
-- reads the merged table, so a mod's transition id there is
|
||||
-- unconfirmable, not wrong. `growth_rates` and `evolution_methods`
|
||||
-- used to sit in that category too, back when Gold had no id space
|
||||
-- for either. Both are routed now: `growth_rates` keeps its Gen 1
|
||||
-- path and is seeded from data.pokemon.growthRates (the extractor's
|
||||
-- Gold curves, src/battle/gen2/Mon.lua), and `evolution_methods`
|
||||
-- routes to gen2EvolutionMethods (src/core/gen2/Evolution.lua's
|
||||
-- literal EVOLVE_* ids, present with or without a ROM import). A
|
||||
-- Gold species' growthRate or evolution method is checked against
|
||||
-- real ids exactly like a Red one's, so a genuine typo is still
|
||||
-- caught here rather than waved through as "unknown, not wrong."
|
||||
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
|
||||
refRegistry = nil
|
||||
end
|
||||
@@ -887,7 +934,15 @@ R.maps = {
|
||||
destMap = f.str, destWarp = f.int(0),
|
||||
destGroup = f.opt(f.int(0)),
|
||||
destMapNum = f.opt(f.int(0)) })),
|
||||
objects = f.opt(f.list(f.any)),
|
||||
-- NPCs, signs, items, warps and static wild encounters all share this
|
||||
-- one array, with no field the loader could use to tell them apart
|
||||
-- ahead of time -- an f.rec strict enough to describe every kind would
|
||||
-- reject the others. f.partial types only `pokemon` (the static
|
||||
-- encounter's species, OverworldController.lua's `d.pokemon` ->
|
||||
-- BattleState.newWild) so a bad id is a load-time error, the same as
|
||||
-- an encounter slot's species, instead of the crash newWild has no
|
||||
-- guard against. Every other object field passes through untouched.
|
||||
objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })),
|
||||
signs = f.opt(f.list(f.any)),
|
||||
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Minimal second-window process for src/render/DesktopScreen.lua.
|
||||
|
||||
local DesktopCompanion = {}
|
||||
|
||||
function DesktopCompanion.install(config)
|
||||
local enet = require("enet")
|
||||
local host = assert(enet.host_create())
|
||||
local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2))
|
||||
local image, sourceW, sourceH, preference
|
||||
local background = { 0, 0, 0, 1 }
|
||||
local connected, commandedQuit = false, false
|
||||
local pointerDown = false
|
||||
local started = love.timer.getTime()
|
||||
local lastContact = started
|
||||
|
||||
local function send(kind, payload)
|
||||
if not connected then return end
|
||||
pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable")
|
||||
end
|
||||
|
||||
local function receiveFrame(data)
|
||||
local prefix = "F" .. config.token .. "\n"
|
||||
if data:sub(1, #prefix) ~= prefix then return end
|
||||
local split = data:find("\n", #prefix + 1, true)
|
||||
if not split then return end
|
||||
local w, h, rgb, mode = data:sub(#prefix + 1, split - 1)
|
||||
:match("^(%d+),(%d+),(%d+),([%w_:.-]+)$")
|
||||
w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb)
|
||||
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end
|
||||
local ok, raw = pcall(love.data.decompress, "string", "lz4",
|
||||
data:sub(split + 1))
|
||||
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end
|
||||
local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw)
|
||||
if not made then return end
|
||||
if not image or sourceW ~= w or sourceH ~= h then
|
||||
if image and image.release then image:release() end
|
||||
image = love.graphics.newImage(pixels)
|
||||
else
|
||||
image:replacePixels(pixels)
|
||||
end
|
||||
sourceW, sourceH, preference = w, h, mode
|
||||
image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest",
|
||||
mode:find("cover", 1, true) and "linear" or "nearest")
|
||||
background = {
|
||||
math.floor(rgb / 0x10000) % 0x100 / 255,
|
||||
math.floor(rgb / 0x100) % 0x100 / 255,
|
||||
rgb % 0x100 / 255, 1,
|
||||
}
|
||||
end
|
||||
|
||||
local function service()
|
||||
while true do
|
||||
local event = host:service(0)
|
||||
if not event then break end
|
||||
if event.type == "connect" then
|
||||
connected, lastContact = true, love.timer.getTime()
|
||||
send("H")
|
||||
elseif event.type == "receive" then
|
||||
lastContact = love.timer.getTime()
|
||||
if event.data == "Q" .. config.token then
|
||||
commandedQuit = true
|
||||
love.event.quit()
|
||||
elseif event.data ~= "P" .. config.token then
|
||||
receiveFrame(event.data)
|
||||
end
|
||||
elseif event.type == "disconnect" then
|
||||
love.event.quit()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function placement()
|
||||
if not image then return 0, 0, 1 end
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local cover = preference and preference:find("cover", 1, true)
|
||||
local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH)
|
||||
return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale
|
||||
end
|
||||
|
||||
local function input(action, x, y)
|
||||
if not image then return false end
|
||||
local dx, dy, scale = placement()
|
||||
local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale)
|
||||
if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end
|
||||
send("I", ("\n%s,%d,%d"):format(action, sx, sy))
|
||||
return true
|
||||
end
|
||||
|
||||
function love.update()
|
||||
service()
|
||||
local t = love.timer.getTime()
|
||||
if (not connected and t - started > 5) or t - lastContact > 5 then
|
||||
love.event.quit()
|
||||
end
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
love.graphics.clear(background[1], background[2], background[3], background[4])
|
||||
if not image then return end
|
||||
local x, y, scale = placement()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(image, x, y, 0, scale, scale)
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button)
|
||||
if button == 1 then pointerDown = input("down", x, y) end
|
||||
end
|
||||
function love.mousereleased(x, y, button)
|
||||
if button == 1 and pointerDown then
|
||||
if not input("up", x, y) then send("I", "\ncancel,0,0") end
|
||||
pointerDown = false
|
||||
end
|
||||
end
|
||||
function love.touchpressed(_, x, y) input("down", x, y) end
|
||||
function love.touchreleased(_, x, y) input("up", x, y) end
|
||||
function love.keypressed(key)
|
||||
if key == "escape" then love.event.quit() end
|
||||
end
|
||||
function love.quit()
|
||||
if not commandedQuit then send("C") end
|
||||
pcall(peer.disconnect_now, peer)
|
||||
end
|
||||
end
|
||||
|
||||
return DesktopCompanion
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Cross-platform desktop secondary display. LOVE owns one window, so a
|
||||
-- second minimal instance of this same app owns the companion window. ENet
|
||||
-- is bundled with LOVE; binding it to loopback keeps frames and input local.
|
||||
|
||||
local Platform = require("src.core.Platform")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local okEnet, enet = pcall(require, "enet")
|
||||
|
||||
local DesktopScreen = {}
|
||||
local state = {
|
||||
enabled = false, blocked = false, host = nil, peer = nil,
|
||||
token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0,
|
||||
}
|
||||
|
||||
local function now()
|
||||
return love and love.timer and love.timer.getTime and love.timer.getTime()
|
||||
or os.clock()
|
||||
end
|
||||
|
||||
local function destroy(sendQuit)
|
||||
if state.peer and sendQuit then
|
||||
pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable")
|
||||
end
|
||||
if state.peer then pcall(state.peer.disconnect_now, state.peer) end
|
||||
if state.host then pcall(state.host.destroy, state.host) end
|
||||
state.host, state.peer, state.token, state.port = nil, nil, nil, nil
|
||||
state.touches = {}
|
||||
end
|
||||
|
||||
local function token()
|
||||
local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":")
|
||||
local digest = love.data.hash("sha256", seed)
|
||||
return love.data.encode("string", "hex", digest):sub(1, 24)
|
||||
end
|
||||
|
||||
local function start()
|
||||
if state.host or state.blocked or now() < state.retryAt then
|
||||
return state.host ~= nil
|
||||
end
|
||||
local base = 49152 + math.floor(now() * 1000) % 12000
|
||||
for attempt = 0, 31 do
|
||||
local port = 49152 + (base - 49152 + attempt * 37) % 12000
|
||||
local ok, host = pcall(enet.host_create,
|
||||
("127.0.0.1:%d"):format(port), 1, 2)
|
||||
if ok and host then
|
||||
state.host, state.port, state.token = host, port, token()
|
||||
local launched = HostShell.spawnSelfDetached({
|
||||
("--display-companion=%d,%s"):format(port, state.token),
|
||||
})
|
||||
if launched then return true end
|
||||
destroy(false)
|
||||
break
|
||||
end
|
||||
end
|
||||
state.retryAt = now() + 1
|
||||
return false
|
||||
end
|
||||
|
||||
local function service()
|
||||
if not state.enabled or state.blocked then return end
|
||||
if not state.host and not start() then return end
|
||||
while state.host do
|
||||
local ok, event = pcall(state.host.service, state.host, 0)
|
||||
if not ok then
|
||||
destroy(false)
|
||||
state.retryAt = now() + 1
|
||||
return
|
||||
end
|
||||
if not event then break end
|
||||
if event.type == "receive" then
|
||||
local data = event.data or ""
|
||||
if data == "H" .. state.token then
|
||||
state.peer = event.peer
|
||||
elseif event.peer == state.peer
|
||||
and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then
|
||||
state.touches[#state.touches + 1] = data:sub(#state.token + 3)
|
||||
elseif event.peer == state.peer and data == "C" .. state.token then
|
||||
state.blocked = true
|
||||
destroy(false)
|
||||
return
|
||||
end
|
||||
elseif event.type == "disconnect" and event.peer == state.peer then
|
||||
destroy(false)
|
||||
state.retryAt = now() + 1
|
||||
return
|
||||
end
|
||||
end
|
||||
if state.peer and now() >= state.heartbeatAt then
|
||||
state.heartbeatAt = now() + 1
|
||||
pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable")
|
||||
end
|
||||
end
|
||||
|
||||
function DesktopScreen.usable()
|
||||
return okEnet and enet ~= nil and Platform.canSpawnProcess()
|
||||
end
|
||||
|
||||
function DesktopScreen.available()
|
||||
return DesktopScreen.detected()
|
||||
end
|
||||
|
||||
function DesktopScreen.detected()
|
||||
service()
|
||||
return state.peer ~= nil
|
||||
end
|
||||
|
||||
function DesktopScreen.push(imageData, w, h, background, preference)
|
||||
service()
|
||||
if not state.peer or not imageData or not imageData.getString then return false end
|
||||
w, h = tonumber(w), tonumber(h)
|
||||
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end
|
||||
local ok, raw = pcall(imageData.getString, imageData)
|
||||
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end
|
||||
local packed = love.data.compress("string", "lz4", raw, 1)
|
||||
local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h,
|
||||
tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", ""))
|
||||
local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable")
|
||||
return sent
|
||||
end
|
||||
|
||||
function DesktopScreen.pollTouch()
|
||||
service()
|
||||
return table.remove(state.touches, 1)
|
||||
end
|
||||
|
||||
function DesktopScreen.setEnabled(on)
|
||||
on = on == true
|
||||
if on == state.enabled then
|
||||
if on then service() end
|
||||
return
|
||||
end
|
||||
state.enabled = on
|
||||
state.blocked = false
|
||||
if on then start(); service() else destroy(true) end
|
||||
end
|
||||
|
||||
return DesktopScreen
|
||||
@@ -0,0 +1,185 @@
|
||||
-- Optional game viewport inside the OS window. A layout mod may reserve any
|
||||
-- window-space rectangle through render.viewport; the game then renders as if
|
||||
-- that rectangle were its whole display. With no subscriber this module is a
|
||||
-- pass-through and allocates no canvas.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Viewport = {
|
||||
rect = nil,
|
||||
full = nil,
|
||||
canvas = nil,
|
||||
generation = nil,
|
||||
frameActive = false,
|
||||
}
|
||||
|
||||
local function finite(value)
|
||||
return type(value) == "number" and value == value
|
||||
and value > -math.huge and value < math.huge
|
||||
end
|
||||
|
||||
local function realMetrics()
|
||||
local G = love.graphics
|
||||
local w, h = G.getDimensions()
|
||||
local pw, ph = w, h
|
||||
if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end
|
||||
local dpiX = w > 0 and pw / w or 1
|
||||
local dpiY = h > 0 and ph / h or 1
|
||||
if dpiX < 1e-6 then dpiX = 1 end
|
||||
if dpiY < 1e-6 then dpiY = 1 end
|
||||
return math.max(1, w), math.max(1, h),
|
||||
math.max(1, pw), math.max(1, ph), dpiX, dpiY
|
||||
end
|
||||
|
||||
local function clampRect(value, w, h)
|
||||
if type(value) ~= "table" then
|
||||
return { x = 0, y = 0, width = w, height = h }
|
||||
end
|
||||
local x = finite(value.x) and math.floor(value.x) or 0
|
||||
local y = finite(value.y) and math.floor(value.y) or 0
|
||||
local rw = finite(value.width) and math.floor(value.width) or w
|
||||
local rh = finite(value.height) and math.floor(value.height) or h
|
||||
x = math.max(0, math.min(x, w - 1))
|
||||
y = math.max(0, math.min(y, h - 1))
|
||||
rw = math.max(1, math.min(rw, w - x))
|
||||
rh = math.max(1, math.min(rh, h - y))
|
||||
return { x = x, y = y, width = rw, height = rh }
|
||||
end
|
||||
|
||||
local function sameSize(canvas, w, h)
|
||||
return canvas and canvas:getWidth() == w and canvas:getHeight() == h
|
||||
end
|
||||
|
||||
function Viewport.begin(generation)
|
||||
local w, h, pw, ph, dpiX, dpiY = realMetrics()
|
||||
local context = {
|
||||
width = w, height = h, pixelWidth = pw, pixelHeight = ph,
|
||||
dpiX = dpiX, dpiY = dpiY, generation = generation,
|
||||
}
|
||||
local requested
|
||||
if Runtime.wantsHook("render.viewport") then
|
||||
requested = Runtime.call("render.viewport", function(ctx)
|
||||
return { x = 0, y = 0, width = ctx.width, height = ctx.height }
|
||||
end, context)
|
||||
end
|
||||
local rect = clampRect(requested, w, h)
|
||||
Viewport.full = context
|
||||
Viewport.rect = rect
|
||||
Viewport.generation = generation
|
||||
local active = type(requested) == "table" and requested.capture == true
|
||||
or rect.x ~= 0 or rect.y ~= 0
|
||||
or rect.width ~= w or rect.height ~= h
|
||||
Viewport.frameActive = active
|
||||
if active then
|
||||
if not sameSize(Viewport.canvas, rect.width, rect.height) then
|
||||
if Viewport.canvas and Viewport.canvas.release then
|
||||
Viewport.canvas:release()
|
||||
end
|
||||
Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height)
|
||||
Viewport.canvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
else
|
||||
if Viewport.canvas and Viewport.canvas.release then
|
||||
Viewport.canvas:release()
|
||||
end
|
||||
Viewport.canvas = nil
|
||||
end
|
||||
return rect
|
||||
end
|
||||
|
||||
function Viewport.active()
|
||||
return Viewport.frameActive == true and Viewport.canvas ~= nil
|
||||
end
|
||||
|
||||
function Viewport.dimensions()
|
||||
if Viewport.active() then
|
||||
return Viewport.rect.width, Viewport.rect.height
|
||||
end
|
||||
return love.graphics.getDimensions()
|
||||
end
|
||||
|
||||
function Viewport.pixelDimensions()
|
||||
if Viewport.active() then
|
||||
if Viewport.canvas.getPixelDimensions then
|
||||
local w, h = Viewport.canvas:getPixelDimensions()
|
||||
return math.max(1, w), math.max(1, h)
|
||||
end
|
||||
return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)),
|
||||
math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY))
|
||||
end
|
||||
if love.graphics.getPixelDimensions then
|
||||
return love.graphics.getPixelDimensions()
|
||||
end
|
||||
return love.graphics.getDimensions()
|
||||
end
|
||||
|
||||
function Viewport.fullDimensions()
|
||||
if Viewport.full then return Viewport.full.width, Viewport.full.height end
|
||||
return love.graphics.getDimensions()
|
||||
end
|
||||
|
||||
function Viewport.target()
|
||||
return Viewport.canvas
|
||||
end
|
||||
|
||||
function Viewport.setTarget()
|
||||
love.graphics.setCanvas(Viewport.canvas)
|
||||
end
|
||||
|
||||
function Viewport.toLocal(x, y)
|
||||
local rect = Viewport.rect
|
||||
if not rect then return x, y, true end
|
||||
local lx, ly = x - rect.x, y - rect.y
|
||||
return lx, ly,
|
||||
lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height
|
||||
end
|
||||
|
||||
function Viewport.localSafeRect(x, y, w, h)
|
||||
local rect = Viewport.rect
|
||||
if not Viewport.active() or not rect then return x, y, w, h end
|
||||
local x1, y1 = math.max(x, rect.x), math.max(y, rect.y)
|
||||
local x2 = math.min(x + w, rect.x + rect.width)
|
||||
local y2 = math.min(y + h, rect.y + rect.height)
|
||||
if x2 <= x1 or y2 <= y1 then
|
||||
return 0, 0, rect.width, rect.height
|
||||
end
|
||||
return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1
|
||||
end
|
||||
|
||||
function Viewport.finish(game)
|
||||
if not Viewport.active() then return end
|
||||
local G = love.graphics
|
||||
local rect, full = Viewport.rect, Viewport.full
|
||||
G.setCanvas()
|
||||
G.push("all")
|
||||
G.origin()
|
||||
G.setScissor()
|
||||
G.setShader()
|
||||
G.setBlendMode("alpha")
|
||||
G.clear(0, 0, 0, 1)
|
||||
local context = {
|
||||
canvas = Viewport.canvas,
|
||||
x = rect.x, y = rect.y, width = rect.width, height = rect.height,
|
||||
windowWidth = full.width, windowHeight = full.height,
|
||||
dpiX = full.dpiX, dpiY = full.dpiY,
|
||||
generation = Viewport.generation,
|
||||
}
|
||||
Runtime.call("render.window", function(_, ctx)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(ctx.canvas, ctx.x, ctx.y)
|
||||
end, game, context)
|
||||
G.pop()
|
||||
end
|
||||
|
||||
function Viewport.reset()
|
||||
Viewport.frameActive = false
|
||||
Viewport.rect = nil
|
||||
Viewport.full = nil
|
||||
Viewport.generation = nil
|
||||
if Viewport.canvas and Viewport.canvas.release then
|
||||
Viewport.canvas:release()
|
||||
end
|
||||
Viewport.canvas = nil
|
||||
end
|
||||
|
||||
return Viewport
|
||||
+97
-12
@@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
|
||||
@@ -69,11 +70,9 @@ Renderer.UPRIGHT_MARGIN = 160
|
||||
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
||||
-- on BOTH axes (square).
|
||||
local function displayMetrics()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local ww, wh = GameViewport.dimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
pw, ph = GameViewport.pixelDimensions()
|
||||
local dpiX, dpiY = 1, 1
|
||||
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
||||
if wh > 0 and ph > 0 then dpiY = ph / wh end
|
||||
@@ -94,6 +93,7 @@ function Renderer:init()
|
||||
-- reason -- worldViewSize() already works in drawable pixels.
|
||||
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
||||
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
||||
self.battleHUDCanvas = nil
|
||||
self.worldCanvas = nil
|
||||
self.worldActive = false
|
||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||
@@ -202,10 +202,38 @@ function Renderer:setUISize(w, h)
|
||||
w, h = math.floor(w), math.floor(h)
|
||||
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
|
||||
if self.canvas and self.canvas.release then self.canvas:release() end
|
||||
if self.battleHUDCanvas and self.battleHUDCanvas.release then
|
||||
self.battleHUDCanvas:release()
|
||||
end
|
||||
self.battleHUDCanvas = nil
|
||||
self.uiWidth, self.uiHeight = w, h
|
||||
self.canvas = PixelCanvas.new(w, h, "nearest")
|
||||
end
|
||||
|
||||
-- Transparent native-pixel surface for an extended WIDE battle HUD. The
|
||||
-- battle scene remains in `canvas`; endFrame places registered HUD regions
|
||||
-- afterward in physical-window space.
|
||||
function Renderer:beginBattleHUDPass()
|
||||
local w, h = self:uiSize()
|
||||
if not self.battleHUDCanvas
|
||||
or self.battleHUDCanvas:getWidth() ~= w
|
||||
or self.battleHUDCanvas:getHeight() ~= h then
|
||||
if self.battleHUDCanvas and self.battleHUDCanvas.release then
|
||||
self.battleHUDCanvas:release()
|
||||
end
|
||||
self.battleHUDCanvas = PixelCanvas.new(w, h, "nearest")
|
||||
end
|
||||
local previous = love.graphics.getCanvas and love.graphics.getCanvas()
|
||||
or self.canvas
|
||||
love.graphics.setCanvas(self.battleHUDCanvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
return previous
|
||||
end
|
||||
|
||||
function Renderer:endBattleHUDPass(previous)
|
||||
love.graphics.setCanvas(previous or self.canvas)
|
||||
end
|
||||
|
||||
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
|
||||
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
|
||||
@@ -671,6 +699,17 @@ end
|
||||
-- centred letterbox. Declared during the element's own draw, in UI-canvas
|
||||
-- pixels, and consumed by endFrame this frame only.
|
||||
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
|
||||
local function addUIAnchor(renderer, x, y, w, h, anchor, windowClamped,
|
||||
canvas, extract)
|
||||
renderer.uiAnchors = renderer.uiAnchors or {}
|
||||
renderer.uiAnchors[#renderer.uiAnchors + 1] = {
|
||||
x = x, y = y, w = w, h = h, anchor = anchor,
|
||||
windowClamped = windowClamped and true or false,
|
||||
canvas = canvas,
|
||||
extract = extract ~= false,
|
||||
}
|
||||
end
|
||||
|
||||
function Renderer:setUIAnchor(x, y, w, h, anchor)
|
||||
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
|
||||
-- save.options.uiLayout): every element stays where it was drawn in the
|
||||
@@ -684,9 +723,16 @@ function Renderer:setUIAnchor(x, y, w, h, anchor)
|
||||
-- battle -- keeps every element inside it, so the box blits where it was
|
||||
-- drawn in the canvas instead of being pulled to the window edge.
|
||||
if self.uiAnchorHold then return end
|
||||
self.uiAnchors = self.uiAnchors or {}
|
||||
self.uiAnchors[#self.uiAnchors + 1] =
|
||||
{ x = x, y = y, w = w, h = h, anchor = anchor }
|
||||
addUIAnchor(self, x, y, w, h, anchor, false, self.canvas, true)
|
||||
end
|
||||
|
||||
-- Battle-owned window-space placement. Unlike ordinary UI anchors this is
|
||||
-- intentionally allowed while BattleState holds general dialogue/menu
|
||||
-- anchors inside the battle surface. Callers must gate it to an explicit
|
||||
-- battle HUD mode.
|
||||
function Renderer:setBattleUIAnchor(x, y, w, h, anchor)
|
||||
addUIAnchor(self, x, y, w, h, anchor, true,
|
||||
self.battleHUDCanvas or self.canvas, false)
|
||||
end
|
||||
|
||||
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||
@@ -698,7 +744,7 @@ end
|
||||
-- When GBC FX is active the composite is drawn into presentCanvas and
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas()
|
||||
GameViewport.setTarget()
|
||||
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
||||
-- Sp = integer framebuffer pixels per GB pixel;
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
@@ -799,6 +845,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
||||
-- a visibly brighter border.
|
||||
local clearR, clearG, clearB = 0, 0, 0
|
||||
local extendedBlackBand = false
|
||||
local bandR, bandG, bandB = 1, 1, 1
|
||||
if not self.worldActive then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local stack = ok and Game and Game.stack
|
||||
@@ -825,7 +873,15 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
|
||||
-- painted the whole phone white on New Game and in battle (#864), so
|
||||
-- the lock keeps the default black bars.
|
||||
if state and state.letterboxWhite
|
||||
if state and state.extendedBlackHUD and state:extendedBlackHUD()
|
||||
and not FaithfulRes.scaleCap() then
|
||||
-- Extended/Black keeps the author's black surround, but extends the
|
||||
-- fixed battle's paper field vertically through the physical window.
|
||||
-- The band uses the exact centred fixed-width composition bounds, so
|
||||
-- only vertical black bars remain at the sides.
|
||||
extendedBlackBand = true
|
||||
bandR, bandG, bandB = PaletteFX.paperShade(Game and Game.data)
|
||||
elseif state and state.letterboxWhite
|
||||
and not (state.bgMode and state:bgMode() == "black")
|
||||
and not FaithfulRes.scaleCap() then
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
@@ -833,6 +889,10 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
if extendedBlackBand then
|
||||
love.graphics.setColor(bandR, bandG, bandB, 1)
|
||||
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||
@@ -975,6 +1035,22 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- Extended/WORLD keeps the frozen world as the physical surround, but stock
|
||||
-- Gen 1 back sprites rely on the battle's paper shade for visible highlights.
|
||||
-- Back the exact fixed-width composition from physical top to bottom so only
|
||||
-- the left and right sides expose the world. The battle canvas and detached
|
||||
-- HUD remain transparent layers composited afterward.
|
||||
-- A worldOverride is an arena provider's completed scene (for example,
|
||||
-- StadiumBattleFX/Dramaless). It replaces the stock paper-backed battle
|
||||
-- field, so never cover it with the native back-sprite fallback.
|
||||
if self.extendedWorldBand and not self.worldOverride
|
||||
and not FaithfulRes.scaleCap() then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
|
||||
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- UI: anchored regions against their screen edges, the rest in the classic
|
||||
-- centred letterbox. With nothing anchored this is the single blit it has
|
||||
-- always been.
|
||||
@@ -997,14 +1073,23 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if a.anchor == "bottom" then
|
||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||
dy = wh - gapB - dh
|
||||
elseif a.anchor == "top" then
|
||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||
dy = a.y * Uy
|
||||
elseif a.anchor == "topright" then
|
||||
dx = ww - gapR - dw
|
||||
dy = a.y * Uy
|
||||
else -- unknown anchor: leave it where it is
|
||||
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
||||
end
|
||||
if a.windowClamped then
|
||||
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
|
||||
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
|
||||
end
|
||||
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
||||
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
|
||||
if a.extract then
|
||||
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
|
||||
end
|
||||
end
|
||||
for _, r in ipairs(rest) do
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
|
||||
@@ -1013,7 +1098,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
|
||||
-- The zone scissors are computed from the same origin, so an SGB
|
||||
-- region travels with the element instead of staying in the letterbox.
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy,
|
||||
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
|
||||
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
|
||||
end
|
||||
end
|
||||
@@ -1064,7 +1149,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
|
||||
if present then
|
||||
love.graphics.setCanvas()
|
||||
GameViewport.setTarget()
|
||||
-- Post-process pipelines run over the finished composite -- world, UI
|
||||
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
||||
-- LCD grid is then drawn over rather than something that smears the
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
-- Bridge to native secondary-display output (Android Presentation). The C
|
||||
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
|
||||
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
|
||||
-- this stays inert and the renderer keeps the in-window stacked layout.
|
||||
-- Shared secondary-display facade. Android uses its native Presentation
|
||||
-- bridge; process-capable desktop hosts fall back to a companion window.
|
||||
-- Everything is guarded, so unsupported hosts keep the in-window layout.
|
||||
|
||||
local SecondScreen = {}
|
||||
local C = nil
|
||||
local ffi = nil
|
||||
local desktop = nil
|
||||
local nativePresent = false
|
||||
local nativeTarget = false
|
||||
|
||||
local function log(msg)
|
||||
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
||||
@@ -21,6 +23,10 @@ do
|
||||
int love_android_secondary_ready();
|
||||
void love_android_push_secondary(const void *rgba, int w, int h);
|
||||
void love_android_secondary_enable(int on);
|
||||
int love_android_secondary_detected();
|
||||
int love_android_present_secondary(const void *rgba, int w, int h,
|
||||
unsigned int background, int cover);
|
||||
void love_android_secondary_target(int target);
|
||||
const char *love_android_poll_secondary_touch();
|
||||
]])
|
||||
local okLib, lib = pcall(ffi.load, "love")
|
||||
@@ -34,21 +40,74 @@ do
|
||||
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
|
||||
:format(tostring(okLib)))
|
||||
end
|
||||
if C then
|
||||
local okDetected, detected = pcall(function()
|
||||
return C.love_android_secondary_detected
|
||||
end)
|
||||
local okPresent, present = pcall(function()
|
||||
return C.love_android_present_secondary
|
||||
end)
|
||||
local okTarget, target = pcall(function()
|
||||
return C.love_android_secondary_target
|
||||
end)
|
||||
nativePresent = okDetected and detected ~= nil
|
||||
and okPresent and present ~= nil
|
||||
nativeTarget = okTarget and target ~= nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not C then
|
||||
local ok, backend = pcall(require, "src.render.DesktopScreen")
|
||||
if ok and backend and backend.usable and backend.usable() then
|
||||
desktop = backend
|
||||
log("desktop companion backend ready")
|
||||
end
|
||||
end
|
||||
|
||||
function SecondScreen.usable()
|
||||
return C ~= nil
|
||||
return C ~= nil or desktop ~= nil
|
||||
end
|
||||
|
||||
function SecondScreen.available()
|
||||
if desktop then return desktop.available() end
|
||||
if not C then return false end
|
||||
local ok, r = pcall(C.love_android_secondary_ready)
|
||||
return ok and r ~= 0
|
||||
end
|
||||
|
||||
function SecondScreen.push(imageData, w, h)
|
||||
-- A connected display is not necessarily the current Presentation yet. This
|
||||
-- distinction lets a companion retry its first frame after hotplug/re-target.
|
||||
function SecondScreen.detected()
|
||||
if desktop then return desktop.detected() end
|
||||
if nativePresent then
|
||||
local ok, r = pcall(C.love_android_secondary_detected)
|
||||
return ok and r ~= 0
|
||||
end
|
||||
return SecondScreen.available()
|
||||
end
|
||||
|
||||
function SecondScreen.push(imageData, w, h, background, preference)
|
||||
if desktop then
|
||||
return desktop.push(imageData, w, h, background, preference)
|
||||
end
|
||||
if not C or not imageData then return false end
|
||||
if nativePresent and (background ~= nil or preference ~= nil) then
|
||||
if nativeTarget then
|
||||
local target = 0
|
||||
if preference == "handheld" or preference == "handheld:cover" then
|
||||
target = 1
|
||||
elseif preference == "secondary" or preference == "secondary:cover" then
|
||||
target = 2
|
||||
end
|
||||
pcall(C.love_android_secondary_target, target)
|
||||
end
|
||||
local cover = type(preference) == "string"
|
||||
and preference:sub(-6) == ":cover"
|
||||
local ok, shown = pcall(C.love_android_present_secondary,
|
||||
imageData:getFFIPointer(), w, h, background or 0, cover and 1 or 0)
|
||||
return ok and shown ~= 0
|
||||
end
|
||||
return pcall(function()
|
||||
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
|
||||
end)
|
||||
@@ -57,6 +116,7 @@ end
|
||||
-- Returns the oldest queued secondary-display event as "action,x,y", where
|
||||
-- coordinates are in the submitted frame's pixel space.
|
||||
function SecondScreen.pollTouch()
|
||||
if desktop then return desktop.pollTouch() end
|
||||
if not C then return nil end
|
||||
local ok, event = pcall(function()
|
||||
return C.love_android_poll_secondary_touch()
|
||||
@@ -66,6 +126,7 @@ function SecondScreen.pollTouch()
|
||||
end
|
||||
|
||||
function SecondScreen.setEnabled(on)
|
||||
if desktop then return desktop.setEnabled(on) end
|
||||
if not C then return end
|
||||
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
||||
end
|
||||
|
||||
+16
-1
@@ -4,6 +4,7 @@
|
||||
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local BagMenu = {}
|
||||
@@ -46,7 +47,16 @@ end
|
||||
-- the stack, so every exit that prints has to close it afterwards. For
|
||||
-- every other item the picker popped itself first and closePicker's identity
|
||||
-- check makes it a no-op (#252).
|
||||
local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
--
|
||||
-- Every result string used to fall through to this one unconditional
|
||||
-- function with no seam around it: a mod could not suppress a message,
|
||||
-- delay it behind a screen of its own, or replace the outcome for one item
|
||||
-- id. The "item.use" hook wraps the whole dispatch (not a name per
|
||||
-- result -- a mod deciding what a Poké Doll or a stone does needs the
|
||||
-- SAME reach a vanilla `if result == ...` branch has, not a narrower one),
|
||||
-- the way "battle.overlay" and "ui.party.submenu" already wrap a
|
||||
-- screen's own default behavior elsewhere in src/ui.
|
||||
local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
|
||||
battle, moveIndex, game.overworld)
|
||||
local function closePicker()
|
||||
@@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
showMessages(game, payload, closePicker) -- failed
|
||||
end
|
||||
|
||||
local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return Runtime.call("item.use", vanillaUseOn,
|
||||
game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
local function pickTargetAndUse(game, battle, id, list)
|
||||
-- pick a target from the party
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
|
||||
+42
-1
@@ -180,6 +180,11 @@ local function buildRows(game)
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
||||
if o.battleLayout ~= "wide" then
|
||||
o.battleHud = "standard"
|
||||
elseif o.battleFit == "fill" and o.battleHud == "extended" then
|
||||
o.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end },
|
||||
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
|
||||
@@ -195,6 +200,31 @@ local function buildRows(game)
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
|
||||
if o.battleFit == "fill" and o.battleLayout == "wide"
|
||||
and o.battleHud == "extended" then
|
||||
o.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end },
|
||||
{ id = "battleHud", label = Strings("BATTLE HUD"),
|
||||
value = function(g)
|
||||
local o = g.save.options
|
||||
return o.battleLayout == "wide" and o.battleHud == "extended"
|
||||
and Strings("EXTENDED")
|
||||
or Strings("STANDARD")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
-- The extended HUD is a widescreen-only composition. Keep OG locked
|
||||
-- to the author's standard HUD even if an older save says otherwise.
|
||||
if o.battleLayout ~= "wide" then
|
||||
o.battleHud = "standard"
|
||||
return false
|
||||
end
|
||||
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
|
||||
if o.battleHud == "extended" and o.battleFit == "fill" then
|
||||
o.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end },
|
||||
-- What sits behind and around the battle. WHITE is the classic paper
|
||||
@@ -203,13 +233,24 @@ local function buildRows(game)
|
||||
-- shows through everywhere the battle does not paint).
|
||||
{ id = "battleBg", label = Strings("BATTLE BG"),
|
||||
value = function(g)
|
||||
local m = g.save.options.battleBg
|
||||
local o = g.save.options
|
||||
if o.battleLayout == "wide" and o.battleFit == "fill"
|
||||
and o.battleHud == "extended" then
|
||||
o.battleBg = "white"
|
||||
return Strings("AUTO")
|
||||
end
|
||||
local m = o.battleBg
|
||||
if m == "black" then return Strings("BLACK") end
|
||||
if m == "world" then return Strings("WORLD") end
|
||||
return Strings("WHITE")
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
if o.battleLayout == "wide" and o.battleFit == "fill"
|
||||
and o.battleHud == "extended" then
|
||||
o.battleBg = "white"
|
||||
return false
|
||||
end
|
||||
local order = { "white", "black", "world" }
|
||||
local cur = 1
|
||||
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
|
||||
|
||||
+2
-16
@@ -628,22 +628,8 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
if self.softboiledFrom then
|
||||
local user = party[self.softboiledFrom]
|
||||
local heal = math.floor(user.stats.hp / 5)
|
||||
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
|
||||
or user.hp <= heal then
|
||||
self.softboiledFrom = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
|
||||
else
|
||||
user.hp = user.hp - heal
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
self.softboiledFrom = nil
|
||||
require("src.core.Sound").play(self.game.data, "Heal_HP")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
|
||||
end
|
||||
self.softboiledFrom = nil
|
||||
self.game.overworld:useSoftboiledFieldMove(user, mon)
|
||||
elseif self.swapFrom then
|
||||
if self.swapFrom ~= self.index then
|
||||
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
|
||||
|
||||
@@ -3024,8 +3024,10 @@ function BattleState:useItem(itemId)
|
||||
-- Everything else the pack can spend on a party mon runs the same
|
||||
-- item_effects.asm routine the field pack runs: the potion line and the
|
||||
-- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and
|
||||
-- the ETHER / ELIXER family.
|
||||
local action = ItemEffects.partyAction(itemId)
|
||||
-- the ETHER / ELIXER family. Without the merged dataset this can only
|
||||
-- ever see RECORDS, the module's own built-ins, the same gap
|
||||
-- Game2:usePartyItem had for the field pack.
|
||||
local action = ItemEffects.partyAction(itemId, self.game and self.game.data)
|
||||
if action then
|
||||
return self:useOnPartyMon(itemId, action)
|
||||
end
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
-- covered by tests; the state at the bottom is the only part that draws.
|
||||
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
|
||||
@@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row)
|
||||
end
|
||||
|
||||
function BattleTransition:draw()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local w, h = GameViewport.dimensions()
|
||||
self:drawWidescreen(w, h)
|
||||
end
|
||||
|
||||
|
||||
@@ -60,10 +60,11 @@ InitClock.TEXT = TEXT
|
||||
-- same three and has always had them right.
|
||||
local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18
|
||||
|
||||
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0.
|
||||
local DAYS = {
|
||||
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
|
||||
}
|
||||
-- Clock.DAY_NAMES / Clock.weekdayName is the single translated home for this
|
||||
-- table: MainMenu's clock box and the Pokegear's clock card read the same
|
||||
-- weekday off the same save and must never disagree about what it is
|
||||
-- called.
|
||||
local DAYS = Clock.DAY_NAMES
|
||||
InitClock.DAYS = DAYS
|
||||
|
||||
function InitClock:wantsFillScale() return true end
|
||||
@@ -88,12 +89,15 @@ function InitClock.hourString(hour)
|
||||
local h = math.floor(hour or 0) % 24
|
||||
local display = h % 12
|
||||
if display == 0 then display = 12 end
|
||||
local word = require("src.world.gen2.Palettes").clockDaytime(h)
|
||||
-- Clock.daytimeLabel, not Palettes.clockDaytime: the printed word,
|
||||
-- translated -- this string reaches the player as-is, unlike the internal
|
||||
-- MORN/DAY/NITE key other palette code compares against.
|
||||
local word = Clock.daytimeLabel(h)
|
||||
return ("%s %d"):format(word, display)
|
||||
end
|
||||
|
||||
function InitClock.oclockString(hour)
|
||||
return InitClock.hourString(hour) .. " o'clock"
|
||||
return Strings("%s o'clock", InitClock.hourString(hour))
|
||||
end
|
||||
|
||||
function InitClock.timeString(hour, minute)
|
||||
@@ -187,7 +191,7 @@ function InitClock:question()
|
||||
return Strings(TEXT.whoaMinutes, self.minute)
|
||||
end
|
||||
if self.phase == "confirm-day" then
|
||||
return Strings(TEXT.confirmDay, DAYS[self.day + 1] or "?")
|
||||
return Strings(TEXT.confirmDay, Clock.weekdayName(self.day + 1) or "?")
|
||||
end
|
||||
if self.phase == "response" then
|
||||
return Strings(TEXT[InitClock.responseKey(self.hour)],
|
||||
@@ -196,11 +200,15 @@ function InitClock:question()
|
||||
return ""
|
||||
end
|
||||
|
||||
-- data/text/common_1.asm's "@MIN." suffix (DisplayMinutesWithMinString),
|
||||
-- separate from TEXT.whoaMinutes' own "%d min.?" confirmation line above.
|
||||
local MINUTES = Strings.source("%d min.")
|
||||
|
||||
-- The value the picker box shows, or nil while a page is up with no picker.
|
||||
function InitClock:display()
|
||||
if self.phase == "hour" then return InitClock.oclockString(self.hour) end
|
||||
if self.phase == "minute" then return ("%d min."):format(self.minute) end
|
||||
if self.phase == "day" then return DAYS[self.day + 1] or "?" end
|
||||
if self.phase == "minute" then return Strings(MINUTES, self.minute) end
|
||||
if self.phase == "day" then return Clock.weekdayName(self.day + 1) or "?" end
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
@@ -29,10 +29,11 @@ local MainMenu = {}
|
||||
MainMenu.__index = MainMenu
|
||||
MainMenu.isOpaque = true
|
||||
|
||||
-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings.
|
||||
local DAYS = {
|
||||
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
|
||||
}
|
||||
-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. Clock.DAY_NAMES
|
||||
-- / Clock.weekdayName is the single translated home for this table (see
|
||||
-- InitClock.lua's DAYS), so this screen's clock box cannot drift from the
|
||||
-- Pokegear's own.
|
||||
local DAYS = Clock.DAY_NAMES
|
||||
|
||||
-- MUSIC_MAIN_MENU; resolved by name so a cache without it just stays quiet.
|
||||
local MENU_MUSIC = "Music_MainMenu"
|
||||
@@ -164,7 +165,7 @@ function MainMenu:drawClockBox()
|
||||
-- Textbox at (0,12) with 4 interior rows and 13 interior columns.
|
||||
Chrome.textbox(0, 12, 13, 4)
|
||||
local hour, minute, weekday = self:clockParts()
|
||||
Chrome.print(DAYS[weekday] or "DAY", 1, 14)
|
||||
Chrome.print(Clock.weekdayName(weekday) or "DAY", 1, 14)
|
||||
-- PrintHour prints 1-12 with no leading zero, then ':' then two zero-padded
|
||||
-- minutes; the AM/PM half is drawn by PrintHour itself.
|
||||
local display = hour % 12
|
||||
|
||||
@@ -56,10 +56,6 @@ local CARDS = {
|
||||
-- card over. One row, so `#self.cards` stays 1 and nothing pages.
|
||||
local FLY_MAP_CARD = { id = "map", label = "FLY" }
|
||||
|
||||
local DAYS = {
|
||||
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------- the radio
|
||||
--
|
||||
-- engine/pokegear/radio.asm is not a text table: it is a jumptable of code.
|
||||
@@ -1878,7 +1874,7 @@ function Pokegear:drawClock()
|
||||
-- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and
|
||||
-- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at
|
||||
-- column 12.
|
||||
self:text(DAYS[weekday] or "", 6, 6)
|
||||
self:text(Clock.weekdayName(weekday) or "", 6, 6)
|
||||
local display = hour % 12
|
||||
if display == 0 then display = 12 end
|
||||
self:text(Chrome.number(display, 2), 6, 8)
|
||||
@@ -2203,13 +2199,13 @@ function Pokegear:drawPlain()
|
||||
if id == "clock" then
|
||||
local hour, minute, weekday = self:clockParts()
|
||||
Chrome.box(1, 5, 18, 7)
|
||||
Chrome.print(DAYS[weekday] or "DAY", 3, 7)
|
||||
Chrome.print(Clock.weekdayName(weekday) or "DAY", 3, 7)
|
||||
local display = hour % 12
|
||||
if display == 0 then display = 12 end
|
||||
Chrome.print(("%s:%s %s"):format(
|
||||
Chrome.number(display, 2), Chrome.number(minute, 2, true),
|
||||
hour < 12 and "AM" or "PM"), 5, 9)
|
||||
Chrome.print(Palettes.clockDaytime(hour), 5, 11)
|
||||
Chrome.print(Clock.daytimeLabel(hour), 5, 11)
|
||||
elseif id == "radio" then
|
||||
-- Without the gear sheet there is no dial art, so the frequencies go down
|
||||
-- the screen as a list. A frequency whose test failed still gets a row:
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local Theme = require("src.ui.kit.Theme")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
local Layout = {}
|
||||
|
||||
@@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
|
||||
function Layout.metrics(maxAppW)
|
||||
local W, H = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
W, H = love.graphics.getDimensions()
|
||||
W, H = GameViewport.dimensions()
|
||||
end
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local s = Kit.layout(sw, sh)
|
||||
|
||||
@@ -155,11 +155,12 @@ local function drain()
|
||||
end
|
||||
|
||||
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
|
||||
-- once a check is in flight or has reached a terminal state it is a no-op.
|
||||
function Check.start()
|
||||
-- once a check is in flight or has reached a terminal state it is a no-op unless
|
||||
-- force=true is passed (e.g. from an explicit button press).
|
||||
function Check.start(force)
|
||||
drain()
|
||||
if cache.status == "checking" or cache.status == "downloading" then return end
|
||||
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
|
||||
if not force and requested and cache.status ~= "error" and cache.status ~= "idle" then return end
|
||||
if not ensureWorker() then
|
||||
cache = { status = "error", error = "background threads unavailable" }
|
||||
return
|
||||
|
||||
@@ -16,6 +16,10 @@ PatchNotes.FILES = {
|
||||
"assets/PATCH_NOTES.md",
|
||||
}
|
||||
|
||||
PatchNotes.CACHE_FILES = {
|
||||
"updates/notes_cache.json",
|
||||
}
|
||||
|
||||
PatchNotes.REPO_FILES = {
|
||||
"mobile/ios/app-repo.json",
|
||||
}
|
||||
@@ -48,6 +52,29 @@ local function readPath(path)
|
||||
return nonempty(text) and text or nil
|
||||
end
|
||||
|
||||
function PatchNotes.fromCache(engine)
|
||||
for _, path in ipairs(PatchNotes.CACHE_FILES) do
|
||||
local text = readPath(path)
|
||||
if text then
|
||||
local ok, doc = pcall(Json.decode, text)
|
||||
if ok and type(doc) == "table" then
|
||||
if engine and engine ~= "0.0.0-dev" then
|
||||
if doc[engine] and nonempty(doc[engine]) then
|
||||
return doc[engine], engine
|
||||
end
|
||||
else
|
||||
for ver, notes in pairs(doc) do
|
||||
if nonempty(notes) then
|
||||
return notes, ver
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
function PatchNotes.fromFile()
|
||||
for _, path in ipairs(PatchNotes.FILES) do
|
||||
local text = readPath(path)
|
||||
@@ -88,6 +115,7 @@ function PatchNotes.fromRepo(engine)
|
||||
return row.notes, row.version
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
return list[1].notes, list[1].version
|
||||
end
|
||||
@@ -96,17 +124,24 @@ function PatchNotes.fromRepo(engine)
|
||||
end
|
||||
|
||||
function PatchNotes.body(Check)
|
||||
local notes, ver = PatchNotes.fromCheck(Check)
|
||||
if notes then return notes, ver end
|
||||
notes = PatchNotes.fromFile()
|
||||
if notes then return notes, ver end
|
||||
local Version = require("src.core.Version")
|
||||
local engine = (Version and Version.engine) or "?"
|
||||
|
||||
local notes, ver = PatchNotes.fromCheck(Check)
|
||||
if notes and (engine == "0.0.0-dev" or ver == engine or ver == nil) then
|
||||
return notes, ver or engine
|
||||
end
|
||||
|
||||
notes, ver = PatchNotes.fromCache(engine)
|
||||
if notes then return notes, ver end
|
||||
|
||||
notes = PatchNotes.fromFile()
|
||||
if notes then return notes, engine end
|
||||
|
||||
notes, ver = PatchNotes.fromRepo(engine)
|
||||
if notes then return notes, ver end
|
||||
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
|
||||
.. "They appear here after the launcher checks GitHub for the latest "
|
||||
.. "release.", engine
|
||||
|
||||
return "Unable to fetch patch notes.", engine
|
||||
end
|
||||
|
||||
return PatchNotes
|
||||
|
||||
@@ -151,6 +151,28 @@ local function gatePasses(rel)
|
||||
return not (info.minShell and info.minShell > shell)
|
||||
end
|
||||
|
||||
local function cacheNotes(ver, notes)
|
||||
if not (ver and type(notes) == "string" and notes ~= "" and Json) then return end
|
||||
if not (love and love.filesystem) then return end
|
||||
pcall(function()
|
||||
love.filesystem.createDirectory("updates")
|
||||
local cachePath = "updates/notes_cache.json"
|
||||
local existing = {}
|
||||
if love.filesystem.getInfo and love.filesystem.getInfo(cachePath) then
|
||||
local text = love.filesystem.read(cachePath)
|
||||
if text then
|
||||
local ok, doc = pcall(Json.decode, text)
|
||||
if ok and type(doc) == "table" then existing = doc end
|
||||
end
|
||||
end
|
||||
existing[ver] = notes
|
||||
local ok, encoded = pcall(Json.encode, existing)
|
||||
if ok and encoded then
|
||||
love.filesystem.write(cachePath, encoded)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- check
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -177,6 +199,9 @@ local function doCheck()
|
||||
return
|
||||
end
|
||||
pending = rel
|
||||
if rel.version and type(rel.notes) == "string" and rel.notes ~= "" then
|
||||
cacheNotes(rel.version, rel.notes)
|
||||
end
|
||||
|
||||
-- Unstamped dev build: the working tree always looks "newer", so never
|
||||
-- pester the developer with an update (contract item, Check design).
|
||||
|
||||
@@ -9,6 +9,7 @@ local Collision = require("src.world.Collision")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Map = require("src.world.Map")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
@@ -827,6 +828,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose)
|
||||
return true
|
||||
end
|
||||
|
||||
function OverworldState:useSoftboiledFieldMove(user, target)
|
||||
local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0
|
||||
if not user or not user.stats or not target or not target.stats
|
||||
or target == user or target.hp <= 0
|
||||
or target.hp >= target.stats.hp or user.hp <= heal then
|
||||
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect.")))
|
||||
return false
|
||||
end
|
||||
user.hp = user.hp - heal
|
||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||
require("src.core.Sound").play(Game.data, "Heal_HP")
|
||||
local def = Game.data.pokemon[target.species]
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s's HP\nwas restored!", target.nickname or def.name)))
|
||||
return true
|
||||
end
|
||||
|
||||
-- The battle transition's dungeon wipe uses the explicit map lists in
|
||||
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
|
||||
-- inclusive map-id ranges -- faithful to the original's omissions
|
||||
@@ -5131,7 +5149,7 @@ function OverworldState:drawWorld()
|
||||
-- point projects under the pipeline's own camera. That is the direct
|
||||
-- analogue of what :billboard does for tilt, and it keeps exactly one
|
||||
-- copy of every effect: the closures above are the ones that run.
|
||||
local pw, ph = love.graphics.getDimensions()
|
||||
local pw, ph = GameViewport.dimensions()
|
||||
local pscale = Zoom.scale(Game.renderer:fitScale())
|
||||
local ctx = {
|
||||
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
||||
|
||||
+91
-2
@@ -37,6 +37,57 @@ local function validPartySlot(party, slot)
|
||||
and party[slot] ~= nil
|
||||
end
|
||||
|
||||
local function outside(game, ow)
|
||||
return Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(game.data, "outsideTilesets"))
|
||||
end
|
||||
|
||||
local function knows(mon, moveId)
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == moveId then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function monInfo(game, mon, slot)
|
||||
local def = game.data.pokemon[mon.species] or {}
|
||||
return { slot = slot, species = mon.species,
|
||||
name = mon.nickname or def.name or mon.species, level = mon.level,
|
||||
hp = mon.hp, maxHp = mon.stats and mon.stats.hp or mon.hp }
|
||||
end
|
||||
|
||||
local function softboiledSources(game)
|
||||
local party, sources = game.save.party or {}, {}
|
||||
for sourceSlot, source in ipairs(party) do
|
||||
local heal = source.stats and math.floor(source.stats.hp / 5) or 0
|
||||
if knows(source, "SOFTBOILED") and source.hp > heal then
|
||||
local info = monInfo(game, source, sourceSlot)
|
||||
info.targets = {}
|
||||
for targetSlot, target in ipairs(party) do
|
||||
if target ~= source and target.hp > 0 and target.stats
|
||||
and target.hp < target.stats.hp then
|
||||
info.targets[#info.targets + 1] = monInfo(game, target, targetSlot)
|
||||
end
|
||||
end
|
||||
if #info.targets > 0 then sources[#sources + 1] = info end
|
||||
end
|
||||
end
|
||||
return sources
|
||||
end
|
||||
|
||||
local function flyDestinationAvailable(game, mapId)
|
||||
local field, save = game.data.field or {}, game.save
|
||||
for _, id in ipairs(field.flyOrder or {}) do
|
||||
if id == mapId then
|
||||
local def = game.data.maps and game.data.maps[id]
|
||||
return not not (save.visited and save.visited[id]
|
||||
and field.flyWarps and field.flyWarps[id]
|
||||
and def and Map.isFlyTown(def))
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
end
|
||||
@@ -141,10 +192,14 @@ function WorldAPI:availableFieldActions()
|
||||
and ow:partyKnows("DIG") then
|
||||
out[#out + 1] = { id = "dig", label = "DIG" }
|
||||
end
|
||||
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(game.data, "outsideTilesets")) then
|
||||
if ow:partyKnows("TELEPORT") and outside(game, ow) then
|
||||
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
|
||||
end
|
||||
local sources = softboiledSources(game)
|
||||
if #sources > 0 then
|
||||
out[#out + 1] = { id = "softboiled", label = "SOFTBOILED",
|
||||
sources = sources }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -187,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
elseif id == "dig" or id == "teleport" then
|
||||
ow:beginTeleportOut()
|
||||
return true
|
||||
elseif id == "softboiled" then
|
||||
local sourceSlot = opts and tonumber(opts.sourceSlot)
|
||||
local targetSlot = opts and tonumber(opts.targetSlot)
|
||||
local allowed
|
||||
for _, source in ipairs(found.sources or {}) do
|
||||
if source.slot == sourceSlot then
|
||||
for _, target in ipairs(source.targets or {}) do
|
||||
if target.slot == targetSlot then allowed = true break end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not allowed then return nil, "softboiled target unavailable" end
|
||||
if ow:useSoftboiledFieldMove(game.save.party[sourceSlot],
|
||||
game.save.party[targetSlot]) then return true end
|
||||
end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
|
||||
-- FLY needs a destination choice, so it is exposed separately from the
|
||||
-- immediate actions above. The request is still checked against the same
|
||||
-- visited-town list as the native Town Map picker before the world may warp.
|
||||
function WorldAPI:canFly()
|
||||
local game, ow = self.game, self:overworld()
|
||||
return not not (ow and ow.map and outside(game, ow) and ow:partyKnows("FLY"))
|
||||
end
|
||||
|
||||
function WorldAPI:flyTo(mapId)
|
||||
local game, ow = self.game, self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
if not self:canFly() then return nil, "fly unavailable" end
|
||||
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
|
||||
if not flyDestinationAvailable(game, mapId) then
|
||||
return nil, "destination unavailable"
|
||||
end
|
||||
ow:flyTo(mapId)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||
|
||||
+203
-279
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local OUT = os.getenv("SHOT_DIR") or "battle-hud-layout-lock"
|
||||
local BEFORE = OUT .. "/battle_hud_wide_extended.png"
|
||||
local AFTER = OUT .. "/battle_hud_og_locked_standard.png"
|
||||
|
||||
return function(game)
|
||||
os.remove(BEFORE)
|
||||
os.remove(AFTER)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleHud = "extended"
|
||||
|
||||
local menu = require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
local layoutRow, hudRow
|
||||
for _, row in ipairs(menu.rows) do
|
||||
if row.id == "battleLayout" then layoutRow = row end
|
||||
if row.id == "battleHud" then hudRow = row end
|
||||
end
|
||||
assert(layoutRow and hudRow, "battle layout/HUD rows are present")
|
||||
|
||||
menu.index = 6
|
||||
menu.scroll = 3
|
||||
assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED")
|
||||
assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written")
|
||||
|
||||
layoutRow.step(game, 1)
|
||||
assert(options.battleLayout == "og", "layout switched to OG")
|
||||
assert(options.battleHud == "standard", "OG normalized HUD to STANDARD")
|
||||
assert(hudRow.value(game) == "STANDARD", "OG displays STANDARD")
|
||||
assert(U.shot(game, AFTER), "OG/STANDARD screenshot was written")
|
||||
|
||||
print("[driver] BATTLE_HUD_LAYOUT_LOCK_PASS")
|
||||
game.driverDone = true
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Visual and behavioral acceptance for the adaptive BATTLE BG menu rule.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
love.window.setMode(1920, 1080, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "black"
|
||||
|
||||
local menu = require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
local fitRow, bgRow
|
||||
local bgIndex
|
||||
for i, row in ipairs(menu.rows) do
|
||||
if row.id == "battleFit" then fitRow = row end
|
||||
if row.id == "battleBg" then bgRow, bgIndex = row, i end
|
||||
end
|
||||
assert(fitRow and bgRow and bgIndex, "battle size/background rows are present")
|
||||
|
||||
fitRow.step(game, 1)
|
||||
assert(options.battleFit == "fill", "battle size switched to FILL")
|
||||
assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE")
|
||||
assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO")
|
||||
assert(bgRow.step(game, 1) == false, "AUTO background row is locked")
|
||||
assert(options.battleBg == "white", "locked AUTO retains the WHITE value")
|
||||
|
||||
menu.index = bgIndex
|
||||
menu.scroll = math.max(0, bgIndex - 5)
|
||||
U.wait(2)
|
||||
local autoPath = DIR .. "/fill_extended_auto_menu.png"
|
||||
os.remove(autoPath)
|
||||
local ok = U.shot(game, autoPath)
|
||||
|
||||
fitRow.step(game, -1)
|
||||
assert(options.battleFit == "fixed", "battle size switched back to FIXED")
|
||||
assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice")
|
||||
assert(bgRow.step(game, 1) == true and options.battleBg == "black",
|
||||
"FIXED can select BLACK")
|
||||
assert(bgRow.step(game, 1) == true and options.battleBg == "world",
|
||||
"FIXED can select WORLD")
|
||||
U.wait(2)
|
||||
local fixedPath = DIR .. "/fixed_extended_background_choices.png"
|
||||
os.remove(fixedPath)
|
||||
ok = U.shot(game, fixedPath) and ok
|
||||
|
||||
U.log(ok and "FILL_EXTENDED_AUTO_MENU_PASS"
|
||||
or "FILL_EXTENDED_AUTO_MENU_FAIL")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
-- Visual acceptance driver for WIDE + FILL + EXTENDED + WHITE.
|
||||
-- The full physical-window backing remains white while the four battle HUD
|
||||
-- panels move to their approved window anchors.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fill"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "white"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3,
|
||||
{ onFinish = function() end })
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(360)
|
||||
|
||||
battle.introSlide = 0
|
||||
battle.introBalls = nil
|
||||
battle.showEnemyTrainer = false
|
||||
battle.showPlayerBack = false
|
||||
battle.enemySendingOut = false
|
||||
battle.sendingOut = false
|
||||
battle.phase = "menu"
|
||||
battle.menuIndex = 1
|
||||
U.wait(2)
|
||||
|
||||
assert(battle:extendedHUD(), "FILL/WHITE activates the approved extended HUD")
|
||||
assert(not battle:extendedWorldHUD(), "FILL/WHITE does not use FIXED's paper band")
|
||||
|
||||
local path = DIR .. "/fill_extended_white_separate_layer.png"
|
||||
os.remove(path)
|
||||
local ok = U.shot(game, path)
|
||||
|
||||
love.window.setMode(960, 540, { resizable = true })
|
||||
U.wait(5)
|
||||
local smallPath = DIR .. "/fill_extended_white_small_16x9.png"
|
||||
os.remove(smallPath)
|
||||
ok = U.shot(game, smallPath) and ok
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(5)
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
game.stack:push(NamingScreen.new(game, {
|
||||
title = "NICKNAME?", maxLen = 10, onDone = function() end,
|
||||
}))
|
||||
U.wait(5)
|
||||
local overlayPath = DIR .. "/fill_extended_white_naming_overlay.png"
|
||||
os.remove(overlayPath)
|
||||
ok = U.shot(game, overlayPath) and ok
|
||||
|
||||
U.log(ok and "FILL_EXTENDED_WHITE_PASS" or "FILL_EXTENDED_WHITE_FAIL")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local OUT = os.getenv("SHOT_DIR") or "fixed-extended-black"
|
||||
local FULL = OUT .. "/fixed_extended_black_full.png"
|
||||
local SMALL = OUT .. "/fixed_extended_black_small_16x9.png"
|
||||
local OVERLAY = OUT .. "/fixed_extended_black_overlay.png"
|
||||
|
||||
return function(game)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
os.remove(FULL)
|
||||
os.remove(SMALL)
|
||||
os.remove(OVERLAY)
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "black"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3,
|
||||
{ onFinish = function() end })
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(360)
|
||||
battle.introSlide = 0
|
||||
battle.introBalls = nil
|
||||
battle.showEnemyTrainer = false
|
||||
battle.showPlayerBack = false
|
||||
battle.enemySendingOut = false
|
||||
battle.sendingOut = false
|
||||
battle.phase = "menu"
|
||||
battle.menuIndex = 1
|
||||
U.wait(2)
|
||||
|
||||
assert(battle:extendedHUD(), "BLACK activates the approved extended HUD")
|
||||
assert(battle:extendedBlackHUD(), "BLACK activates the white vertical battle band")
|
||||
assert(not battle:extendedWorldHUD(), "BLACK remains separate from WORLD")
|
||||
assert(U.shot(game, FULL), "full black-background screenshot was written")
|
||||
|
||||
love.window.setMode(960, 540, { resizable = true })
|
||||
U.wait(10)
|
||||
assert(U.shot(game, SMALL), "small black-background screenshot was written")
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(10)
|
||||
battle.blankForAskName = true
|
||||
local naming = require("src.ui.NamingScreen").new(game, {
|
||||
title = "NICKNAME?", maxLen = 10, onDone = function() end,
|
||||
})
|
||||
game.stack:push(naming)
|
||||
U.wait(10)
|
||||
assert(U.shot(game, OVERLAY), "black-background overlay screenshot was written")
|
||||
|
||||
print("[driver] FIXED_EXTENDED_BLACK_PASS")
|
||||
love.event.quit(0)
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local OUT = os.getenv("SHOT_DIR") or "fixed-extended-white"
|
||||
local FULL = OUT .. "/fixed_extended_white_full.png"
|
||||
local SMALL = OUT .. "/fixed_extended_white_small_16x9.png"
|
||||
local OVERLAY = OUT .. "/fixed_extended_white_overlay.png"
|
||||
|
||||
return function(game)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
os.remove(FULL)
|
||||
os.remove(SMALL)
|
||||
os.remove(OVERLAY)
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "white"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3,
|
||||
{ onFinish = function() end })
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(360)
|
||||
battle.introSlide = 0
|
||||
battle.introBalls = nil
|
||||
battle.showEnemyTrainer = false
|
||||
battle.showPlayerBack = false
|
||||
battle.enemySendingOut = false
|
||||
battle.sendingOut = false
|
||||
battle.phase = "menu"
|
||||
battle.menuIndex = 1
|
||||
U.wait(2)
|
||||
assert(battle:extendedHUD(), "WHITE activates the approved extended HUD")
|
||||
assert(not battle:extendedWorldHUD(), "WHITE keeps its opaque paper field")
|
||||
assert(U.shot(game, FULL), "full WHITE screenshot was written")
|
||||
|
||||
love.window.setMode(960, 540, { resizable = true })
|
||||
U.wait(10)
|
||||
assert(U.shot(game, SMALL), "small WHITE screenshot was written")
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(10)
|
||||
battle.blankForAskName = true
|
||||
local naming = require("src.ui.NamingScreen").new(game, {
|
||||
title = "NICKNAME?",
|
||||
maxLen = 10,
|
||||
initial = "",
|
||||
onDone = function() end,
|
||||
})
|
||||
game.stack:push(naming)
|
||||
U.wait(10)
|
||||
assert(U.shot(game, OVERLAY), "WHITE overlay screenshot was written")
|
||||
|
||||
print("[driver] FIXED_EXTENDED_WHITE_PASS")
|
||||
love.event.quit(0)
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Visual regression coverage for Professor Oak's scripted Yellow capture Bag
|
||||
-- over the WIDE + FIXED + EXTENDED + WORLD composition.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "world"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(30)
|
||||
|
||||
local demo = BattleState.newWild(game, "CHARMANDER", 5)
|
||||
demo:makeOldManDemo("PROF.OAK")
|
||||
demo.onFinish = function() end
|
||||
game.overworld:pushBattle(demo)
|
||||
|
||||
for _ = 1, 100 do
|
||||
if demo.phase == "menu" and (demo.demoTimer or 0) > 5 then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
for _ = 1, 180 do
|
||||
if game.stack:top() ~= demo then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(3)
|
||||
|
||||
local path = DIR .. "/fixed_extended_world_oak_charmander_bag.png"
|
||||
os.remove(path)
|
||||
local ok = game.stack:top() ~= demo and U.shot(game, path)
|
||||
U.log(ok and "FIXED_EXTENDED_WORLD_BAG_PASS"
|
||||
or "FIXED_EXTENDED_WORLD_BAG_FAIL")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Visual acceptance driver for the first EXTENDED HUD configuration only:
|
||||
-- WIDE + FIXED + EXTENDED + WORLD.
|
||||
-- POKEPORT_DRIVER=tests/drivers/fixed_extended_world_hud_test.lua \
|
||||
-- POKEPORT_IDENTITY=fixed-extended-world POKEPORT_TOUCH=0 \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
-- Match the 16:9 acceptance screenshot so the fixed 304x144 surface has
|
||||
-- measurable space above and below it.
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "world"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3,
|
||||
{ onFinish = function() end })
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(360)
|
||||
|
||||
battle.introSlide = 0
|
||||
battle.introBalls = nil
|
||||
battle.showEnemyTrainer = false
|
||||
battle.showPlayerBack = false
|
||||
battle.enemySendingOut = false
|
||||
battle.sendingOut = false
|
||||
battle.phase = "menu"
|
||||
battle.menuIndex = 1
|
||||
U.wait(2)
|
||||
|
||||
local path = DIR .. "/fixed_extended_world_separate_layer.png"
|
||||
os.remove(path)
|
||||
local ok = U.shot(game, path)
|
||||
|
||||
love.window.setMode(960, 540, { resizable = true })
|
||||
U.wait(5)
|
||||
local smallPath = DIR .. "/fixed_extended_world_small_16x9.png"
|
||||
os.remove(smallPath)
|
||||
ok = U.shot(game, smallPath) and ok
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(5)
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
game.stack:push(NamingScreen.new(game, {
|
||||
title = "NICKNAME?", maxLen = 10, onDone = function() end,
|
||||
}))
|
||||
U.wait(5)
|
||||
local overlayPath = DIR .. "/fixed_extended_world_naming_overlay.png"
|
||||
os.remove(overlayPath)
|
||||
ok = U.shot(game, overlayPath) and ok
|
||||
|
||||
U.log(ok and "FIXED_EXTENDED_WORLD_PASS" or "FIXED_EXTENDED_WORLD_FAIL")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
local function read(path)
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local source = file:read("*a")
|
||||
file:close()
|
||||
return source
|
||||
end
|
||||
|
||||
local function check(value, message)
|
||||
if not value then error(message, 2) end
|
||||
end
|
||||
|
||||
local java = read(
|
||||
"mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
|
||||
local manifest = read("mobile/android/app/src/main/AndroidManifest.xml")
|
||||
|
||||
check(manifest:find("android.allow_multiple_resumed_activities", 1, true)
|
||||
and manifest:find("GameActivity$SecondaryActivity", 1, true)
|
||||
and manifest:find('android:exported="false"', 1, true),
|
||||
"the private companion Activity opts into Android multi-display resume")
|
||||
check(java:find("android.os.Build.VERSION.SDK_INT < 29", 1, true)
|
||||
and java:find("options.setLaunchDisplayId", 1, true),
|
||||
"the primary-display fallback is restricted to Android 10+")
|
||||
check(java:find("SECONDARY_TARGET_HANDHELD", 1, true)
|
||||
and java:find("SECONDARY_TARGET_EXTERNAL", 1, true)
|
||||
and java:find("handheldAvailable ? handheld : external", 1, true),
|
||||
"routing hints retain a safe available-display fallback")
|
||||
check(java:find("dualScreenDisplayMode != %-1")
|
||||
and java:find("AYN_SECOND_SCREEN", 1, true)
|
||||
and java:find("dualScreenModeObserverRegistered", 1, true),
|
||||
"the optional AYN state is guarded and lifecycle-bound")
|
||||
check(java:find("activity.dispatchKeyEvent", 1, true)
|
||||
and java:find("activity.dispatchGenericMotionEvent", 1, true),
|
||||
"companion windows forward controller input to the game Activity")
|
||||
|
||||
print("android asymmetric display routing: ok")
|
||||
@@ -0,0 +1,37 @@
|
||||
local function read(path)
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local source = file:read("*a")
|
||||
file:close()
|
||||
return source
|
||||
end
|
||||
|
||||
local function check(value, message)
|
||||
if not value then error(message, 2) end
|
||||
end
|
||||
|
||||
local java = read(
|
||||
"mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
|
||||
local cpp = read("mobile/android/love/src/jni/love/src/common/android.cpp")
|
||||
|
||||
check(java:find("hasSecondaryDisplayCandidate", 1, true)
|
||||
and java:find("findSecondaryDisplay(self, false)", 1, true)
|
||||
and java:find("now %- secondaryDetectionAt < 500"),
|
||||
"Android exposes cached physical detection before Presentation is ready")
|
||||
check(java:find("presentSecondaryFrame", 1, true)
|
||||
and java:find("secondaryFrame = new byte", 1, true)
|
||||
and java:find("rgba.get(secondaryFrame", 1, true),
|
||||
"extended presentation reuses a retained frame buffer")
|
||||
check(java:find("java.nio.ByteBuffer.wrap(secondaryFrame)", 1, true),
|
||||
"a recreated Presentation receives the retained frame")
|
||||
check(java:find("0xFF000000 | (color & 0x00FFFFFF)", 1, true),
|
||||
"RGB companion backgrounds become opaque Android colors")
|
||||
check(java:find("Math.max((float) vw / fw, (float) vh / fh)", 1, true)
|
||||
and java:find("Math.floor(fit)", 1, true),
|
||||
"FrameView supports cover and pixel-friendly contain fits")
|
||||
check(cpp:find("love_android_secondary_detected", 1, true)
|
||||
and cpp:find("love_android_present_secondary", 1, true)
|
||||
and cpp:find("love_android_secondary_target", 1, true)
|
||||
and cpp:find('"(Ljava/nio/ByteBuffer;IIIZ)Z"', 1, true),
|
||||
"JNI exports the optional detected, routing, and presentation calls")
|
||||
|
||||
print("android secondary presentation: ok")
|
||||
@@ -131,4 +131,25 @@ T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "overworld",
|
||||
}, "settled overworld remains supported")
|
||||
|
||||
-- drainHold gates capture (see the refused() case above) exactly because it
|
||||
-- marks an HP bar mid-animation. Once stepHPDrain settles the bar it must
|
||||
-- let go of that gate too, or the very first drain of a battle leaves the
|
||||
-- checkpoint contract refused for everything after it.
|
||||
do
|
||||
local game3, _, battle3 = makeGame()
|
||||
battle3.enemy.mon.hp = battle3.enemy.mon.hp - 5
|
||||
local frames = 0
|
||||
while battle3:stepHPDrain() and frames < 10000 do
|
||||
frames = frames + 1
|
||||
end
|
||||
T.eq(battle3.enemy.shownHP, battle3.enemy.mon.hp,
|
||||
"the HP bar settles on the new total")
|
||||
T.eq(battle3.enemy.drainHold, nil,
|
||||
"drainHold releases the checkpoint gate once the bar finishes draining")
|
||||
local capability = Checkpoint.inspect(game3)
|
||||
T.check(capability.canCapture == true,
|
||||
"a checkpoint is capturable again after the drain settles: "
|
||||
.. tostring(capability.reason))
|
||||
end
|
||||
|
||||
T.finish()
|
||||
|
||||
@@ -92,6 +92,11 @@ local menu = { isOpaque = true } -- PartyMenu / ListMenu
|
||||
local whiteBattle = setmetatable(
|
||||
{ game = { save = { options = { battleBg = "white" } } } },
|
||||
{ __index = BattleState })
|
||||
local wideWhiteBattle = setmetatable(
|
||||
{ game = { save = { options = {
|
||||
battleBg = "white", battleLayout = "wide",
|
||||
} } } },
|
||||
{ __index = BattleState })
|
||||
local function stack(...) return { states = { ... },
|
||||
visibleBase = function(self)
|
||||
for i = #self.states, 1, -1 do
|
||||
@@ -111,7 +116,10 @@ T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld")
|
||||
T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does")
|
||||
local s3 = stack(overworld, whiteBattle, menu)
|
||||
T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3,
|
||||
"a white-bg battle has no map to hold, so nothing moves")
|
||||
"a classic white-bg battle has no presentation to hold, so nothing moves")
|
||||
local s3wide = stack(overworld, wideWhiteBattle, menu)
|
||||
T.eq(Game.drawBaseInStack(s3wide, s3wide:visibleBase()), 2,
|
||||
"an opaque WIDE battle still draws beneath its classic menu")
|
||||
local s4 = stack(overworld, menu)
|
||||
T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2,
|
||||
"and a menu outside a battle is untouched")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local clock, quit = 1, false
|
||||
local sent, queue, draws = {}, {}, 0
|
||||
local peer = {
|
||||
send = function(_, data) sent[#sent + 1] = data end,
|
||||
disconnect_now = function() end,
|
||||
}
|
||||
local host = {
|
||||
connect = function() return peer end,
|
||||
service = function()
|
||||
if #queue == 0 then return nil end
|
||||
return table.remove(queue, 1)
|
||||
end,
|
||||
}
|
||||
local rendered = {
|
||||
setFilter = function() end, replacePixels = function() end,
|
||||
release = function() end,
|
||||
}
|
||||
love = {
|
||||
timer = { getTime = function() return clock end },
|
||||
data = { decompress = function(_, _, value) return value end },
|
||||
image = { newImageData = function(w, h, format, raw)
|
||||
assert(w == 1 and h == 1 and format == "rgba8" and raw == "rgba")
|
||||
return {}
|
||||
end },
|
||||
graphics = {
|
||||
newImage = function() return rendered end,
|
||||
getDimensions = function() return 100, 100 end,
|
||||
clear = function() end, setColor = function() end,
|
||||
draw = function() draws = draws + 1 end,
|
||||
},
|
||||
event = { quit = function() quit = true end },
|
||||
}
|
||||
package.preload.enet = function()
|
||||
return { host_create = function() return host end }
|
||||
end
|
||||
|
||||
require("src.render.DesktopCompanion").install({ port = 50000, token = "token" })
|
||||
queue[#queue + 1] = { type = "connect" }
|
||||
love.update()
|
||||
assert(sent[#sent] == "Htoken", "companion authenticates after connecting")
|
||||
queue[#queue + 1] = {
|
||||
type = "receive", data = "Ftoken\n1,1,0,auto\nrgba",
|
||||
}
|
||||
love.update()
|
||||
love.draw()
|
||||
assert(draws == 1, "companion draws a received frame")
|
||||
love.mousepressed(50, 50, 1)
|
||||
love.mousereleased(50, 50, 1)
|
||||
assert(sent[#sent - 1] == "Itoken\ndown,0,0"
|
||||
and sent[#sent] == "Itoken\nup,0,0", "mouse input maps back to source pixels")
|
||||
queue[#queue + 1] = { type = "receive", data = "Qtoken" }
|
||||
love.update()
|
||||
assert(quit, "parent can close the companion")
|
||||
print("desktop companion: ok")
|
||||
@@ -0,0 +1,57 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local clock = 1
|
||||
love = {
|
||||
timer = { getTime = function() return clock end },
|
||||
data = {
|
||||
hash = function() return "digest" end,
|
||||
encode = function() return "0123456789abcdef0123456789abcdef" end,
|
||||
compress = function(_, _, value) return value end,
|
||||
},
|
||||
}
|
||||
|
||||
local sent, spawned, queue = {}, nil, {}
|
||||
local peer = {
|
||||
send = function(_, data, channel, flag)
|
||||
sent[#sent + 1] = { data = data, channel = channel, flag = flag }
|
||||
end,
|
||||
disconnect_now = function() end,
|
||||
}
|
||||
local host = {
|
||||
service = function()
|
||||
if #queue == 0 then return nil end
|
||||
return table.remove(queue, 1)
|
||||
end,
|
||||
destroy = function() end,
|
||||
}
|
||||
|
||||
package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end }
|
||||
package.loaded["src.core.HostShell"] = {
|
||||
spawnSelfDetached = function(args) spawned = args return true end,
|
||||
}
|
||||
package.preload.enet = function()
|
||||
return { host_create = function() return host end }
|
||||
end
|
||||
|
||||
local Screen = require("src.render.SecondScreen")
|
||||
assert(Screen.usable(), "the shared facade selects the desktop backend")
|
||||
Screen.setEnabled(true)
|
||||
assert(spawned and spawned[1]:match("^%-%-display%-companion=%d+,[%w]+$"),
|
||||
"enabling launches one companion of this app")
|
||||
local token = spawned[1]:match(",([%w]+)$")
|
||||
queue[#queue + 1] = { type = "receive", peer = peer, data = "H" .. token }
|
||||
assert(Screen.detected(), "a token-authenticated companion becomes detected")
|
||||
|
||||
local pixels = { getString = function() return "rgba" end }
|
||||
assert(Screen.push(pixels, 1, 1, 0x102030, "auto"),
|
||||
"a connected companion accepts a frame")
|
||||
assert(sent[#sent].data:find("^F" .. token .. "\n1,1,1056816,auto\nrgba"),
|
||||
"frame metadata and pixels stay in one loopback packet")
|
||||
|
||||
queue[#queue + 1] = {
|
||||
type = "receive", peer = peer, data = "I" .. token .. "\ndown,3,4",
|
||||
}
|
||||
assert(Screen.pollTouch() == "down,3,4", "companion input returns to the mod")
|
||||
Screen.setEnabled(false)
|
||||
assert(sent[#sent].data == "Q" .. token, "disabling closes the companion")
|
||||
print("desktop second screen: ok")
|
||||
@@ -468,6 +468,32 @@ do
|
||||
T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll")
|
||||
end)
|
||||
|
||||
-- Mon.syncIdentity (wired into refreshStats, which SummaryMenu.new calls
|
||||
-- on every menu open) used to recompute mon.shiny from DVs unconditionally,
|
||||
-- so opening the summary screen on a forced shiny -- one whose DVs do not
|
||||
-- happen to match the natural pattern -- un-shinied it the moment the menu
|
||||
-- opened. shiny is monotonic once true: a natural roll or a forced one
|
||||
-- both stay shiny through any later refresh, the way opts.shiny already
|
||||
-- wins at construction.
|
||||
do
|
||||
local forced = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs, shiny = true })
|
||||
T.eq(forced.shiny, true, "still shiny straight out of Mon.new")
|
||||
Mon.syncIdentity(forced, DATA)
|
||||
T.eq(forced.shiny, true, "syncIdentity does not clobber a forced shiny")
|
||||
Mon.refreshStats(forced, DATA)
|
||||
T.eq(forced.shiny, true,
|
||||
"refreshStats (SummaryMenu.new's call) does not either")
|
||||
|
||||
-- the natural cases are unaffected: DVs that read shiny stay shiny,
|
||||
-- DVs that do not stay plain
|
||||
local natural = Mon.new(DATA, "SEEDMON", 5, { dvs = shinyDvs })
|
||||
Mon.syncIdentity(natural, DATA)
|
||||
T.eq(natural.shiny, true, "a naturally shiny mon still reads shiny")
|
||||
local plain = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs })
|
||||
Mon.syncIdentity(plain, DATA)
|
||||
T.eq(plain.shiny, false, "a plain mon is not promoted to shiny")
|
||||
end
|
||||
|
||||
local genderCtx
|
||||
withHook("gender.roll", function(nextFn, ctx)
|
||||
genderCtx = ctx
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local osName, fused, command = "Windows", true, nil
|
||||
love = {
|
||||
system = { getOS = function() return osName end },
|
||||
filesystem = {
|
||||
getExecutablePath = function() return "C:\\Game\\gen1recomp.exe" end,
|
||||
getSource = function() return "C:\\Source\\gen1recomp" end,
|
||||
isFused = function() return fused end,
|
||||
},
|
||||
}
|
||||
package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end }
|
||||
local execute = os.execute
|
||||
os.execute = function(value) command = value return 0 end
|
||||
|
||||
local HostShell = require("src.core.HostShell")
|
||||
assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
|
||||
assert(command:find('start "" /b ', 1, true)
|
||||
and command:find('"C:\\Game\\gen1recomp.exe"', 1, true),
|
||||
"Windows launches the fused app detached")
|
||||
|
||||
osName, fused = "Linux", false
|
||||
assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
|
||||
assert(command:find("'C:\\Source\\gen1recomp'", 1, true)
|
||||
and command:sub(-1) == "&", "Linux source runs include the game folder")
|
||||
|
||||
osName, fused = "OS X", true
|
||||
assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
|
||||
assert(not command:find("start", 1, true) and command:sub(-1) == "&",
|
||||
"macOS uses the same detached POSIX path")
|
||||
os.execute = execute
|
||||
print("spawn self detached: ok")
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ItemEffects.use crashed instead of refusing when a species record carried
|
||||
-- no tmhm list at all (ipairs(nil)), which is a different situation from a
|
||||
-- species whose list simply does not name the move being taught -- that
|
||||
-- case already refuses cleanly with MonCannotLearnMachineMoveText. A mod
|
||||
-- species missing the field entirely took the whole game down on the very
|
||||
-- first TM/HM use rather than reaching that refusal.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.harness").suite("item effects tmhm nil")
|
||||
local Fixtures = require("tests.modkit.fixtures")
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
Data.pokemon.FIXMON_A.tmhm = nil
|
||||
|
||||
local mon = Pokemon.new(Data, "FIXMON_A", 10)
|
||||
local save = { player = { name = "RED" } }
|
||||
|
||||
local ok, result, payload = pcall(ItemEffects.use, Data, save, "FIX_TM", mon)
|
||||
T.check(ok, "using a TM on a species with no tmhm list does not crash: "
|
||||
.. tostring(result))
|
||||
if ok then
|
||||
T.eq(result, "failed", "the species refuses the move instead of crashing into it")
|
||||
T.check(type(payload) == "table" and payload[1] ~= nil,
|
||||
"a refusal message is still returned")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Public mod-API coverage for the "item.use" hook (src/ui/BagMenu.lua).
|
||||
--
|
||||
-- Before this hook existed, every result ItemEffects.use returned fell
|
||||
-- through to one unconditional call with nothing wrapped around it: a mod
|
||||
-- could not suppress a message, delay it behind a screen of its own, or
|
||||
-- replace what a specific item id does after the bag decides to use it.
|
||||
-- This exercises the seam end to end through the public mod API -- a real
|
||||
-- BagMenu list, a real USE selection -- rather than calling the hook
|
||||
-- machinery directly.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
|
||||
-- Real TextBoxes want a Font atlas; this only cares that useOn reaches the
|
||||
-- no-effect fallthrough, so the same stand-in tests/parity_rare_candy_menu.lua
|
||||
-- uses for a ROM-backed run works here too.
|
||||
local realTextBox = package.loaded["src.render.TextBox"]
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
}
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
local BagMenu = require("src.ui.BagMenu")
|
||||
|
||||
local FIXTURE = {
|
||||
["mods/item_hook_probe/manifest.json"] = [[{
|
||||
"id": "item_hook_probe",
|
||||
"name": "Item Hook Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/item_hook_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.hooks:wrap("item.use",
|
||||
function(vanilla, game, battle, id, target, list, moveIndex, picker)
|
||||
mod.exports.calls = (mod.exports.calls or 0) + 1
|
||||
mod.exports.id = id
|
||||
mod.exports.battle = battle
|
||||
mod.exports.target = target
|
||||
return vanilla(game, battle, id, target, list, moveIndex, picker)
|
||||
end)
|
||||
]],
|
||||
}
|
||||
|
||||
local function newStack()
|
||||
local stack = { states = {} }
|
||||
function stack:push(s) self.states[#self.states + 1] = s end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
return stack
|
||||
end
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/item_hook_probe" }, {
|
||||
fs = T.sdk.memfs(FIXTURE),
|
||||
})
|
||||
T.eq(#run.errors, 0,
|
||||
"the probe mod loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local game = {
|
||||
data = run.data,
|
||||
stack = newStack(),
|
||||
save = {
|
||||
player = { name = "RED" }, inventory = {}, money = 0,
|
||||
options = { battleStyle = "set", battleAnim = "on" },
|
||||
pokedex = { seen = {}, owned = {} }, flags = {},
|
||||
},
|
||||
}
|
||||
Bag.add(game.save, "FIX_POTION", 1)
|
||||
|
||||
local list = BagMenu.new(game, {})
|
||||
game.stack:push(list)
|
||||
local row
|
||||
for i, r in ipairs(list.items) do
|
||||
if r.value == "FIX_POTION" then row = i end
|
||||
end
|
||||
T.check(row ~= nil, "the fixture item is in the bag")
|
||||
list.index = row
|
||||
list.onChoose(list.items[row], list)
|
||||
|
||||
-- out of battle the bag offers USE / TOSS first (start_sub_menus.asm)
|
||||
local sub = game.stack:top()
|
||||
T.check(sub ~= nil and sub.items and sub.items[1] and sub.items[1].onSelect,
|
||||
"the USE/TOSS submenu opened")
|
||||
sub.items[1].onSelect()
|
||||
|
||||
local out = run.loader.exports.item_hook_probe or {}
|
||||
T.eq(out.calls, 1, "the hook fires exactly once for a bag item use")
|
||||
T.eq(out.id, "FIX_POTION", "the hook sees the item id")
|
||||
T.eq(out.battle, nil, "the hook sees the field-use battle argument (nil)")
|
||||
|
||||
local top = game.stack:top()
|
||||
T.check(type(top) == "table" and top.textBox == true,
|
||||
"vanilla still ran: the no-effect message box landed on the stack")
|
||||
|
||||
run.release()
|
||||
package.loaded["src.render.TextBox"] = realTextBox
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
|
||||
T.finish()
|
||||
@@ -68,6 +68,12 @@ do
|
||||
"stashed notes name a release version")
|
||||
end
|
||||
|
||||
do
|
||||
local notes, ver = PatchNotes.fromRepo("999.999.999")
|
||||
eq(notes, nil, "fromRepo returns nil when a specific engine version is missing")
|
||||
eq(ver, nil, "fromRepo version is nil when missing")
|
||||
end
|
||||
|
||||
do
|
||||
local f = assert(io.open("mobile/ios/app-repo.json", "rb"))
|
||||
local list = PatchNotes.parseRepo(f:read("*a"))
|
||||
@@ -78,6 +84,15 @@ do
|
||||
eq(notes, list[2].notes, "fromRepo returns that version's notes")
|
||||
end
|
||||
|
||||
do
|
||||
local oldVersion = package.loaded["src.core.Version"]
|
||||
package.loaded["src.core.Version"] = { engine = "999.999.999" }
|
||||
local body, ver = PatchNotes.body(nil)
|
||||
eq(body, "Unable to fetch patch notes.", "returns Unable to fetch patch notes when version is uncached and unlisted")
|
||||
eq(ver, "999.999.999", "returns the requested engine version")
|
||||
package.loaded["src.core.Version"] = oldVersion
|
||||
end
|
||||
|
||||
imp._appPatchNotes = true
|
||||
local modal = drawAndCapture(imp)
|
||||
check(modal:find("Patch notes", 1, true) ~= nil, "the modal titles itself")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Source file LuaJIT limits gate.
|
||||
-- Verifies that every game engine source file compiles cleanly under
|
||||
-- LuaJIT without exceeding LuaJIT's strict 200 local variables per-scope limit,
|
||||
-- 60 upvalue limit, or bytecode compiler limits.
|
||||
-- luajit tests/engine/luajit_source_limits_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
-- Find all .lua files in a directory recursively.
|
||||
local function findLuaFiles(dir, out)
|
||||
out = out or {}
|
||||
local p = io.popen("find " .. dir .. " -type f -name '*.lua'")
|
||||
if p then
|
||||
for line in p:lines() do
|
||||
out[#out + 1] = line
|
||||
end
|
||||
p:close()
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
local files = findLuaFiles("src")
|
||||
findLuaFiles("tools/save-editor", files)
|
||||
files[#files + 1] = "main.lua"
|
||||
files[#files + 1] = "conf.lua"
|
||||
|
||||
check(#files > 50, "discovered project source files (found " .. tostring(#files) .. ")")
|
||||
|
||||
for _, path in ipairs(files) do
|
||||
local f = assert(io.open(path, "rb"), "could not open " .. path)
|
||||
local source = f:read("*a")
|
||||
f:close()
|
||||
|
||||
-- Compile through LuaJIT loadstring: detects 'main function has more than 200 local variables'
|
||||
-- or 'function has more than 200 local variables' across any function scope in the file.
|
||||
local chunk, err = loadstring(source, "@" .. path)
|
||||
check(chunk ~= nil, path .. " compiles under LuaJIT: " .. tostring(err))
|
||||
end
|
||||
|
||||
-- Meta-test: prove that exceeding 200 locals fails the gate
|
||||
do
|
||||
local overflowLocals = {}
|
||||
for i = 1, 201 do overflowLocals[i] = "v" .. i end
|
||||
local badCode = "local " .. table.concat(overflowLocals, ", ")
|
||||
local chunk, err = loadstring(badCode, "@overflow_test.lua")
|
||||
check(chunk == nil, "LuaJIT strictly rejects chunks exceeding 200 locals")
|
||||
check(tostring(err):find("200 local variables", 1, true) ~= nil, "error message specifies 200 local variable limit")
|
||||
end
|
||||
|
||||
T.finish("luajit_source_limits")
|
||||
@@ -0,0 +1,89 @@
|
||||
-- A map object's `pokemon` field (the static wild encounter kind --
|
||||
-- OverworldController.lua's `d.pokemon`, handed straight to
|
||||
-- BattleState.newWild with no existence check of its own) used to go
|
||||
-- completely unchecked: R.maps.objects was f.opt(f.list(f.any)), so a
|
||||
-- typo'd species sat in a loaded mod and only surfaced as a crash the
|
||||
-- moment a player stepped up to that object. Every other kind sharing the
|
||||
-- objects array (NPCs, signs-as-objects, warps) has fields this schema
|
||||
-- still does not know about, which is what f.partial is for: it types only
|
||||
-- `pokemon` and leaves the rest of an object's shape alone.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local function manifest(id)
|
||||
return ([[{
|
||||
"id": "%s", "name": "%s", "version": "1.0.0",
|
||||
"entry": "main.lua", "api": 2
|
||||
}]]):format(id, id)
|
||||
end
|
||||
|
||||
-- ------- a bad species id is caught as a load error, not left to crash
|
||||
|
||||
local BAD = {
|
||||
["mods/bad_static_encounter/manifest.json"] = manifest("bad_static_encounter"),
|
||||
["mods/bad_static_encounter/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.maps:patch("FIX_ROUTE", {
|
||||
objects = {
|
||||
{ pokemon = "NOT_A_SPECIES", level = 30, text = "Gyaoo!" },
|
||||
},
|
||||
})
|
||||
]],
|
||||
}
|
||||
|
||||
do
|
||||
local run = T.sdk.loadMods({ "mods/bad_static_encounter" },
|
||||
{ fs = T.sdk.memfs(BAD) })
|
||||
local dangling = {}
|
||||
for _, message in ipairs(run.errors) do
|
||||
if message:match("unresolved reference") then
|
||||
dangling[#dangling + 1] = message
|
||||
end
|
||||
end
|
||||
T.eq(#dangling, 1,
|
||||
"a bad static-encounter species is reported once ("
|
||||
.. table.concat(dangling, "; ") .. ")")
|
||||
T.check(dangling[1] and dangling[1]:match("maps%.FIX_ROUTE%.objects")
|
||||
and dangling[1]:match("pokemon"),
|
||||
"the report names the map, the objects field and the pokemon registry: "
|
||||
.. tostring(dangling[1]))
|
||||
run.release()
|
||||
end
|
||||
|
||||
-- ------- a real species resolves, and an NPC-shaped object beside it (no
|
||||
-- pokemon field at all, and fields this schema never named -- sprite,
|
||||
-- movement, range) is untouched
|
||||
|
||||
local GOOD = {
|
||||
["mods/good_static_encounter/manifest.json"] = manifest("good_static_encounter"),
|
||||
["mods/good_static_encounter/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.content.maps:patch("FIX_ROUTE", {
|
||||
objects = {
|
||||
{ index = 1, name = "FIXROUTE_TRAINER", sprite = "SPRITE_FIX_NPC",
|
||||
movement = "STAY", range = "NONE", text = "TEXT_FIXROUTE_TRAINER",
|
||||
x = 5, y = 9 },
|
||||
{ pokemon = "FIXMON_A", level = 30, text = "Gyaoo!" },
|
||||
},
|
||||
})
|
||||
]],
|
||||
}
|
||||
|
||||
do
|
||||
local run = T.sdk.loadMods({ "mods/good_static_encounter" },
|
||||
{ fs = T.sdk.memfs(GOOD) })
|
||||
T.eq(#run.errors, 0,
|
||||
"a real species and an untyped NPC object both load clean ("
|
||||
.. tostring(run.errors[1]) .. ")")
|
||||
local objects = run.data.maps.FIX_ROUTE.objects
|
||||
T.eq(#objects, 2, "both objects landed on the map")
|
||||
T.eq(objects[1].sprite, "SPRITE_FIX_NPC",
|
||||
"the NPC object's untyped fields passed through unexamined")
|
||||
T.eq(objects[2].pokemon, "FIXMON_A",
|
||||
"the static encounter's species field passed through too")
|
||||
run.release()
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,101 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local savedHooks = Runtime.hooks
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
|
||||
local Viewport = require("src.render.GameViewport")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
Viewport.begin(1)
|
||||
assert(not Viewport.active(), "vanilla frame must not allocate a viewport")
|
||||
local w, h = Viewport.dimensions()
|
||||
assert(w == 640 and h == 576, "vanilla dimensions must stay unchanged")
|
||||
|
||||
hooks:wrap("render.viewport", function(next, ctx)
|
||||
local full = next(ctx)
|
||||
assert(full.width == 640 and full.height == 576,
|
||||
"viewport hook receives OS-independent window geometry")
|
||||
return { x = 320, y = 12, width = 320, height = 288 }
|
||||
end, 0, "fixture")
|
||||
|
||||
local presented
|
||||
hooks:wrap("render.window", function(next, game, ctx)
|
||||
presented = ctx
|
||||
return next(game, ctx)
|
||||
end, 0, "fixture")
|
||||
|
||||
Viewport.begin(2)
|
||||
assert(Viewport.active(), "a reserved rectangle creates a game target")
|
||||
w, h = Viewport.dimensions()
|
||||
assert(w == 320 and h == 288, "game renders against reserved dimensions")
|
||||
Viewport.target().getPixelDimensions = function() return 737, 664 end
|
||||
local pw, ph = Viewport.pixelDimensions()
|
||||
assert(pw == 737 and ph == 664,
|
||||
"captured rendering uses the target's real high-DPI pixel dimensions")
|
||||
local x, y, inside = Viewport.toLocal(400, 100)
|
||||
assert(x == 80 and y == 88 and inside,
|
||||
"window pointers expose viewport-local coordinates")
|
||||
local _, _, outside = Viewport.toLocal(20, 20)
|
||||
assert(not outside, "reserved companion space is outside the game viewport")
|
||||
local _, _, localW, localH = SafeArea.rect()
|
||||
local _, _, windowW, windowH = SafeArea.windowRect()
|
||||
assert(localW == 320 and localH == 288,
|
||||
"game chrome may still use viewport-local safe geometry")
|
||||
assert(windowW == 640 and windowH == 576,
|
||||
"OS chrome can retain the full-window safe geometry")
|
||||
TouchControls:init()
|
||||
local controls = TouchControls:layout()
|
||||
assert(controls.dpad.cx < 160 and controls.a.cx > 480,
|
||||
"touch controls stay laid out across the full OS window")
|
||||
local function source(path)
|
||||
local file = assert(io.open(path, "r"))
|
||||
local text = file:read("*a")
|
||||
file:close()
|
||||
return text
|
||||
end
|
||||
for _, path in ipairs({ "src/core/Game.lua", "src/core/Game2.lua" }) do
|
||||
local text = source(path)
|
||||
local finish = assert(text:find("GameViewport.finish(self)", 1, true))
|
||||
local controlsDraw = assert(text:find("TouchControls:draw()", finish, true))
|
||||
assert(controlsDraw > finish,
|
||||
path .. " draws touch controls after final window composition")
|
||||
end
|
||||
Viewport.setTarget()
|
||||
assert(love.graphics.getCanvas() == Viewport.target(),
|
||||
"game rendering is redirected into the viewport canvas")
|
||||
Viewport.finish({})
|
||||
assert(presented and presented.x == 320 and presented.y == 12
|
||||
and presented.width == 320 and presented.height == 288
|
||||
and presented.windowWidth == 640 and presented.windowHeight == 576
|
||||
and presented.generation == 2,
|
||||
"window composition receives game and host geometry")
|
||||
assert(love.graphics.getCanvas() == nil,
|
||||
"window composition restores the OS render target")
|
||||
Viewport.reset()
|
||||
assert(not Viewport.active(),
|
||||
"viewport geometry cannot leak into the launcher after presentation")
|
||||
|
||||
hooks.chains["render.viewport"] = nil
|
||||
hooks:wrap("render.viewport", function(next, ctx)
|
||||
local full = next(ctx)
|
||||
full.capture = true
|
||||
return full
|
||||
end, 0, "capture-fixture")
|
||||
Viewport.begin(1)
|
||||
assert(Viewport.active() and Viewport.dimensions() == 640,
|
||||
"a full-window capture allocates a final composition target")
|
||||
presented = nil
|
||||
Viewport.setTarget()
|
||||
Viewport.finish({})
|
||||
assert(presented and presented.width == 640 and presented.height == 576,
|
||||
"a full-window capture reaches final window composition")
|
||||
Viewport.reset()
|
||||
|
||||
Runtime.hooks = savedHooks
|
||||
print("render viewport: ok")
|
||||
@@ -0,0 +1,70 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local name = "src.render.SecondScreen"
|
||||
local oldModule = package.loaded[name]
|
||||
local oldFfi = package.loaded.ffi
|
||||
local oldPreload = package.preload.ffi
|
||||
local calls = {}
|
||||
local null = {}
|
||||
|
||||
local C = {
|
||||
love_android_secondary_ready = function() return 0 end,
|
||||
love_android_push_secondary = function(ptr, w, h)
|
||||
calls.push = { ptr, w, h }
|
||||
end,
|
||||
love_android_secondary_enable = function(on) calls.enabled = on end,
|
||||
love_android_secondary_target = function(target) calls.target = target end,
|
||||
love_android_secondary_detected = function() return 1 end,
|
||||
love_android_present_secondary = function(ptr, w, h, background, cover)
|
||||
calls.present = { ptr, w, h, background, cover }
|
||||
return 1
|
||||
end,
|
||||
love_android_poll_secondary_touch = function() return null end,
|
||||
}
|
||||
local fakeFfi = {
|
||||
C = C,
|
||||
NULL = null,
|
||||
cdef = function() end,
|
||||
load = function() return C end,
|
||||
string = function(value) return value end,
|
||||
}
|
||||
|
||||
package.loaded[name] = nil
|
||||
package.loaded.ffi = nil
|
||||
package.preload.ffi = function() return fakeFfi end
|
||||
|
||||
local SecondScreen = require(name)
|
||||
local image = { getFFIPointer = function() return "pixels" end }
|
||||
|
||||
T.eq(SecondScreen.available(), false,
|
||||
"an unbound presentation is not render-ready")
|
||||
T.eq(SecondScreen.detected(), true,
|
||||
"physical display detection is independent of presentation readiness")
|
||||
T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary:cover"), true,
|
||||
"extended Android presentation accepts frame metadata")
|
||||
T.same(calls.present, { "pixels", 160, 144, 0x112233, 1 },
|
||||
"cover and RGB background reach the native bridge")
|
||||
T.eq(calls.target, 2, "secondary routing reaches the optional native bridge")
|
||||
T.eq(SecondScreen.push(image, 160, 144, 0x112233, "handheld"), true,
|
||||
"handheld routing remains a contain presentation")
|
||||
T.eq(calls.target, 1, "handheld routing reaches the optional native bridge")
|
||||
T.eq(SecondScreen.push(image, 160, 144, 0x112233, "secondary"), true,
|
||||
"contain presentation remains available")
|
||||
T.same(calls.present, { "pixels", 160, 144, 0x112233, 0 },
|
||||
"contain is the default native fit")
|
||||
T.eq(SecondScreen.push(image, 160, 144, nil, "secondary:cover"), true,
|
||||
"a fit preference can request extended presentation by itself")
|
||||
T.same(calls.present, { "pixels", 160, 144, 0, 1 },
|
||||
"preference-only presentation defaults to a black background")
|
||||
T.eq(calls.target, 2, "a suffixed route keeps its target")
|
||||
T.eq(SecondScreen.push(image, 160, 144), true,
|
||||
"the original push ABI remains available")
|
||||
T.same(calls.push, { "pixels", 160, 144 },
|
||||
"legacy callers retain the original frame path")
|
||||
|
||||
package.loaded[name] = oldModule
|
||||
package.loaded.ffi = oldFfi
|
||||
package.preload.ffi = oldPreload
|
||||
|
||||
T.finish("Android secondary presentation facade")
|
||||
@@ -0,0 +1,141 @@
|
||||
-- The stat name substituted into X-item/vitamin "rose!" messages, and
|
||||
-- Gold's whole Light Screen / Reflect "rose!" messages, must reach a
|
||||
-- translation catalog, not just the surrounding sentence template (RBY:
|
||||
-- src/inventory/ItemEffects.lua, src/battle/TrainerAI.lua; Gold:
|
||||
-- src/battle/gen2/Battle.lua). With no catalog loaded the message stays
|
||||
-- English (the existing baseline); with one loaded that translates the
|
||||
-- relevant word(s), the substitution must change too -- that is the
|
||||
-- actual bug this suite guards against, which passing/failing sentences
|
||||
-- alone (as other suites already check) cannot tell apart from text that
|
||||
-- never reached Strings() at all.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local function withCatalog(catalog, fn)
|
||||
Strings.load({ strings = catalog })
|
||||
local ok, err = pcall(fn)
|
||||
Strings.load(nil)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------- player X-item
|
||||
|
||||
local save = SaveData.newGame()
|
||||
local player = { name = "FIXMON", stages = {} }
|
||||
local xBattle = { player = player, kind = "wild" }
|
||||
|
||||
local _, baseline = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
||||
T.check(baseline[1]:find("ATTACK", 1, true) ~= nil,
|
||||
"X ATTACK's rose! message names the stat in English with no catalog")
|
||||
|
||||
withCatalog({ ATTACK = "ATTAQUE" }, function()
|
||||
player.stages.attack = nil
|
||||
local _, msgs = ItemEffects.use(Data, save, "X_ATTACK", nil, xBattle)
|
||||
T.check(msgs[1]:find("ATTAQUE", 1, true) ~= nil,
|
||||
"a catalog translating ATTACK reaches the X ATTACK rose! message")
|
||||
T.check(msgs[1]:find("ATTACK", 1, true) == nil,
|
||||
"...and the untranslated English stat name is gone")
|
||||
end)
|
||||
|
||||
-- --------------------------------------------------------- player vitamin
|
||||
|
||||
local target = Pokemon.new(Data, "FIXMON_A", 10)
|
||||
withCatalog({ DEFENSE = "DEFENSE_FR" }, function()
|
||||
local _, msgs = ItemEffects.use(Data, save, "IRON", target)
|
||||
T.check(msgs[1]:find("DEFENSE_FR", 1, true) ~= nil,
|
||||
"a catalog translating DEFENSE reaches the IRON (vitamin) rose! message")
|
||||
end)
|
||||
|
||||
local hpTarget = Pokemon.new(Data, "FIXMON_A", 10)
|
||||
withCatalog({ HP = "PV" }, function()
|
||||
local _, msgs = ItemEffects.use(Data, save, "HP_UP", hpTarget)
|
||||
T.check(msgs[1]:find("PV", 1, true) ~= nil,
|
||||
"a catalog translating HP reaches the HP UP rose! message")
|
||||
end)
|
||||
|
||||
-- ------------------------------------------------------- AI trainer X-item
|
||||
|
||||
local enemy = { name = "FOE", stages = {} }
|
||||
local aiBattle = { enemy = enemy, trainer = { name = "TRAINER" }, data = Data }
|
||||
|
||||
withCatalog({ SPEED = "VITESSE" }, function()
|
||||
local msgs = TrainerAI.useItem(aiBattle, "X_SPEED")
|
||||
T.check(msgs[2]:find("VITESSE", 1, true) ~= nil,
|
||||
"a catalog translating SPEED reaches the AI trainer's X SPEED rose! message")
|
||||
end)
|
||||
|
||||
-- ------------------------------------------------------- Gold: Light Screen / Reflect
|
||||
|
||||
local Gen2Battle = require("src.battle.gen2.Battle")
|
||||
local Gen2Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local GEN2_DATA = {
|
||||
pokemon = {
|
||||
MACHOP = {
|
||||
id = "MACHOP", index = 66, name = "MACHOP",
|
||||
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
},
|
||||
moves = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
},
|
||||
type_chart = { types = { NORMAL = { id = "NORMAL", index = 0,
|
||||
category = "physical" } }, matchups = {} },
|
||||
items = {},
|
||||
}
|
||||
local perfectDvs = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfectDvs.hp = Gen2Mon.hpDV(perfectDvs)
|
||||
|
||||
local function newGen2Battle()
|
||||
local player = Gen2Mon.new(GEN2_DATA, "MACHOP", 15, { dvs = perfectDvs })
|
||||
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local wild = Gen2Mon.new(GEN2_DATA, "MACHOP", 15, { dvs = perfectDvs })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
return Gen2Battle.new({ data = GEN2_DATA, party = { player }, wild = wild })
|
||||
end
|
||||
|
||||
withCatalog({ ["%s's SPCL.DEF rose!"] = "%s voit sa DEF.SPÉ augmenter !" },
|
||||
function()
|
||||
local lsBattle = newGen2Battle()
|
||||
Gen2Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN(lsBattle, lsBattle.player)
|
||||
local events = lsBattle:takeEvents()
|
||||
local found = false
|
||||
for _, event in ipairs(events) do
|
||||
if event.kind == "message"
|
||||
and event.text:find("DEF.SPÉ augmenter", 1, true) then
|
||||
found = true
|
||||
end
|
||||
end
|
||||
T.check(found,
|
||||
"a catalog translating Light Screen's rose! message reaches it")
|
||||
end)
|
||||
|
||||
withCatalog({ ["%s's DEFENSE rose!"] = "%s voit sa DEFENSE augmenter !" },
|
||||
function()
|
||||
local refBattle = newGen2Battle()
|
||||
Gen2Battle.MOVE_EFFECTS.EFFECT_REFLECT(refBattle, refBattle.player)
|
||||
local events = refBattle:takeEvents()
|
||||
local found = false
|
||||
for _, event in ipairs(events) do
|
||||
if event.kind == "message"
|
||||
and event.text:find("DEFENSE augmenter", 1, true) then
|
||||
found = true
|
||||
end
|
||||
end
|
||||
T.check(found,
|
||||
"a catalog translating Reflect's rose! message reaches it")
|
||||
end)
|
||||
|
||||
T.finish("stat rise message translation")
|
||||
@@ -77,6 +77,7 @@ local function battleWith(fx, sprites)
|
||||
statusHUDVisible = function() return true end,
|
||||
bottomUIVisible = function() return true end,
|
||||
caughtMarkerVisible = function() return false end,
|
||||
extendedHUD = function() return false end,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -117,6 +117,27 @@ local DATA = {
|
||||
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE" },
|
||||
OLD_ROD = { id = "OLD_ROD", pocket = "KEY", name = "OLD ROD",
|
||||
fieldMenu = "ITEMMENU_CURRENT", battleMenu = "ITEMMENU_NOUSE" },
|
||||
-- a mod's own battle-pack item: its action lives only in
|
||||
-- gen2ItemEffects below, which ItemEffects.RECORDS (the module's
|
||||
-- built-in table) has never heard of (#8)
|
||||
MOD_ITEM = item("MOD_ITEM"),
|
||||
},
|
||||
gen2ItemEffects = {
|
||||
-- a status cure rather than an HP heal: HP is exposed to the wild
|
||||
-- mon's own reply once the item spends the turn, which would make a
|
||||
-- direct before/after HP check depend on incidental battle math this
|
||||
-- fix has nothing to do with. Status is not.
|
||||
MOD_ITEM = {
|
||||
action = "status", field = true, needsTarget = true,
|
||||
use = function(ctx)
|
||||
local mon = ctx.mon
|
||||
if mon.status ~= "poison" then
|
||||
return { used = false, text = "It won't have\nany effect." }
|
||||
end
|
||||
mon.status = nil
|
||||
return { used = true, text = "MOD ITEM used!" }
|
||||
end,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -236,6 +257,33 @@ do
|
||||
eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE untouched")
|
||||
end
|
||||
|
||||
-- ---- a mod's own battle-pack item (#8) -------------------------------------
|
||||
-- BattleState:useItem asked ItemEffects.partyAction for the item's family
|
||||
-- with no `data` argument, the same omission Game2:usePartyItem had for the
|
||||
-- field pack, so a mod item's action -- present only in the merged
|
||||
-- gen2ItemEffects table -- resolved to nil and the pack fell straight to
|
||||
-- "That isn't going to help here." instead of opening the party list.
|
||||
do
|
||||
local sick = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
|
||||
sick.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
sick.status = "poison"
|
||||
local screen, _, _, save, pushed = newScreen({
|
||||
player = sick, party = { sick }, inventory = { MOD_ITEM = 1 },
|
||||
})
|
||||
check(runToMenu(screen), "reached the menu")
|
||||
|
||||
screen:useItem("MOD_ITEM")
|
||||
eq(screen.phase, "submenu",
|
||||
"a mod's own gen2ItemEffects record opens UseItem_SelectMon")
|
||||
local picker = pushed[#pushed]
|
||||
eq(getmetatable(picker), PartyMenu, "and the pick is the party screen")
|
||||
if picker and picker.onChoose then
|
||||
picker.onChoose(1, sick)
|
||||
eq(sick.status, nil, "the mod item's own use() ran through the real screens")
|
||||
eq(save.inventory.MOD_ITEM, nil, "and the mod item was spent")
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- IsItemUsedOnConfusedMon: the battle-only arm --------------------------
|
||||
do
|
||||
local screen, battle, player, save, pushed = newScreen({
|
||||
|
||||
@@ -18,6 +18,7 @@ require("src.core.Logger").warn = function() end
|
||||
|
||||
local Clock = require("src.core.gen2.Clock")
|
||||
local InitClock = require("src.ui.gen2.InitClock")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
-- A stub input the screen drives off, the same shape Input:wasPressed has.
|
||||
local function fakeInput()
|
||||
@@ -254,4 +255,66 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- Clock.DAY_NAMES / Clock.weekdayName / Clock.daytimeLabel: the single home
|
||||
-- InitClock, MainMenu and the Pokegear clock card all share, so a weekday
|
||||
-- cannot be named one way on one screen and another way on the next.
|
||||
do
|
||||
eq(Clock.weekdayName(1), "SUNDAY", "1-based, SUNDAY first")
|
||||
eq(Clock.weekdayName(6), "FRIDAY", "and the rest in wCurDay's order")
|
||||
check(Clock.weekdayName(0) == nil, "day 0 is out of range")
|
||||
check(Clock.weekdayName(8) == nil, "and so is day 8")
|
||||
|
||||
eq(Clock.daytimeLabel(4), "MORN", "daytimeLabel matches clockDaytime's word")
|
||||
eq(Clock.daytimeLabel(10), "DAY", "for every hour band")
|
||||
eq(Clock.daytimeLabel(20), "NITE", "including the wrap back to NITE")
|
||||
|
||||
local MainMenu = require("src.ui.gen2.MainMenu")
|
||||
check(MainMenu.DAYS == Clock.DAY_NAMES,
|
||||
"MainMenu reuses the same table InitClock and the Pokegear do")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- a translation mod's turn
|
||||
--
|
||||
-- DAYS, the clockDaytime word and the "o'clock"/"min." suffixes used to
|
||||
-- bypass Strings entirely, so a translation mod's `strings` registry had no
|
||||
-- seam to catch them: the picker kept printing the English day name and
|
||||
-- "o'clock" no matter the catalog (reported from a real Gold build).
|
||||
do
|
||||
Strings.load({
|
||||
strings = {
|
||||
SUNDAY = "DIMANCHE",
|
||||
MORN = "MATIN",
|
||||
["%s o'clock"] = "%s heures",
|
||||
["%d min."] = "%d min",
|
||||
},
|
||||
})
|
||||
|
||||
local wheel = InitClock.new({ input = fakeInput() }, { mode = "day", save = {} })
|
||||
eq(wheel:display(), "DIMANCHE", "a translated catalog reaches the day wheel")
|
||||
|
||||
eq(InitClock.hourString(4), "MATIN 4",
|
||||
"and the clockDaytime word, through Clock.daytimeLabel")
|
||||
eq(InitClock.oclockString(4), "MATIN 4 heures",
|
||||
"and the o'clock suffix, template and all")
|
||||
|
||||
local minutePicker = InitClock.new({ input = fakeInput() }, { save = {} })
|
||||
minutePicker.phase = "minute"
|
||||
minutePicker.minute = 30
|
||||
eq(minutePicker:display(), "30 min", "and the minutes picker's own suffix")
|
||||
|
||||
-- Palettes.clockDaytime itself must stay untranslated even with a catalog
|
||||
-- loaded: FORCED_DAYTIME and the rest of Palettes.lua's own lookups
|
||||
-- compare against its return value as an internal key, not display text.
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
eq(Palettes.clockDaytime(4), "MORN",
|
||||
"the internal palette key is untouched by the loaded catalog")
|
||||
|
||||
-- Module state is process-global and tests/run_tests.lua runs every suite
|
||||
-- in one process (see tests/mod_strings_tests.lua's own note): leaving the
|
||||
-- catalog loaded would translate the day/hour of every suite after this
|
||||
-- one.
|
||||
Strings.load({})
|
||||
check(not Strings.active(), "the catalog is unloaded for the suites after this one")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -102,6 +102,24 @@ local DATA = {
|
||||
TM01 = { id = "TM01", name = "TM01", pocket = "TM_HM", index = 191,
|
||||
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE",
|
||||
teaches = "SWIFT" },
|
||||
-- a mod's own field item, whose action lives only in gen2ItemEffects
|
||||
-- below -- ItemEffects.RECORDS (the module's built-in table) has never
|
||||
-- heard of it, so resolving it at all requires the merged dataset (#8)
|
||||
MOD_ITEM = { id = "MOD_ITEM", name = "MOD ITEM", pocket = "ITEM",
|
||||
index = 250, fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_PARTY" },
|
||||
},
|
||||
gen2ItemEffects = {
|
||||
MOD_ITEM = {
|
||||
action = "heal", field = true, needsTarget = true,
|
||||
use = function(ctx)
|
||||
local mon = ctx.mon
|
||||
if mon.hp >= mon.maxHp then
|
||||
return { used = false, text = "It won't have\nany effect." }
|
||||
end
|
||||
mon.hp = math.min(mon.maxHp, mon.hp + 5)
|
||||
return { used = true, text = "MOD ITEM used!" }
|
||||
end,
|
||||
},
|
||||
},
|
||||
gen2MenuGfx = {},
|
||||
gen2Icons = {
|
||||
@@ -461,6 +479,24 @@ do
|
||||
eq(host.save.inventory.HP_UP, nil, "and the HP UP was spent")
|
||||
end
|
||||
|
||||
do
|
||||
-- #8 regression: Game2:usePartyItem asked ItemEffects.partyAction for the
|
||||
-- item's family with no `data` argument, so it could only ever see
|
||||
-- RECORDS -- the module's own built-ins. A mod's field item, whose
|
||||
-- action exists only in the merged gen2ItemEffects table, resolved to a
|
||||
-- nil action and fell straight through to the "isn't going to help here"
|
||||
-- refusal instead of opening the party list at all.
|
||||
local mon = fixtureMon(12, { hp = 10 })
|
||||
local host = newHost({ MOD_ITEM = 1 }, { mon })
|
||||
host:useFieldItem("MOD_ITEM")
|
||||
local party = host.stack:top()
|
||||
check(party ~= nil and party.prompt ~= nil,
|
||||
"a mod's own gen2ItemEffects record opens the party list")
|
||||
drive(host, function() return host.stack:top() ~= party end)
|
||||
eq(mon.hp, 15, "the mod item's own use() ran through the real menu")
|
||||
eq(host.save.inventory.MOD_ITEM, nil, "and the mod item was spent")
|
||||
end
|
||||
|
||||
do
|
||||
local mon = fixtureMon(12, { statExp = {
|
||||
hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } })
|
||||
|
||||
@@ -48,7 +48,7 @@ local battle = {
|
||||
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
|
||||
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
|
||||
}
|
||||
function battle:battleKind() return "wild" end
|
||||
function battle:battleKind() return self.kind or "wild" end
|
||||
function battle:effectRecord() return { accuracyChecked = true } end
|
||||
function battle:visibleText() return { "Wild TESTMON appeared!" } end
|
||||
function battle:menuLockedAction() return nil end
|
||||
@@ -63,6 +63,14 @@ function battle:chooseMove(slot)
|
||||
return true
|
||||
end
|
||||
function battle:cancelMove() self.phase = "menu" return true end
|
||||
function battle:chooseSafari(action)
|
||||
self.chosenSafari, self.phase = action, "messages"
|
||||
return true
|
||||
end
|
||||
function battle:chooseMimic(slot)
|
||||
self.chosenMimic, self.phase = slot, "messages"
|
||||
return true
|
||||
end
|
||||
function battle:catchChance(ball)
|
||||
return require("src.battle.Catching").chance(ball, self.enemy.mon,
|
||||
game.data.pokemon[self.enemy.mon.species])
|
||||
@@ -123,6 +131,18 @@ check(api:submit({ id = 3, revision = back.revision, kind = "back" }),
|
||||
"Gen 1 accepts move-menu back")
|
||||
eq(battle.phase, "menu", "Gen 1 back restores the command menu")
|
||||
|
||||
battle.kind, battle.safari = "safari", { balls = 30 }
|
||||
local safari = api:snapshot()
|
||||
check(api:submit({ id = 4, revision = safari.revision,
|
||||
kind = "safari", action = "rock" }), "Gen 1 accepts a Safari action")
|
||||
eq(battle.chosenSafari, "rock", "Gen 1 uses the semantic Safari path")
|
||||
battle.kind, battle.safari = "wild", nil
|
||||
battle.phase, battle.mimicMoves = "mimicSelect", { { slot = 1 } }
|
||||
local mimic = api:snapshot()
|
||||
check(api:submit({ id = 5, revision = mimic.revision,
|
||||
kind = "mimic", index = 1 }), "Gen 1 accepts a Mimic choice")
|
||||
eq(battle.chosenMimic, 1, "Gen 1 uses the semantic Mimic path")
|
||||
|
||||
local player2 = { species = "CHIKORITA", level = 5, hp = 20,
|
||||
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
|
||||
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
|
||||
@@ -228,6 +248,29 @@ do
|
||||
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
|
||||
end
|
||||
|
||||
do
|
||||
local state = setmetatable({ phase = "menu", safari = { balls = 30 },
|
||||
menuIndex = 1 }, { __index = Gen1BattleState })
|
||||
function state:safariAction(action) self.safariChoice = action end
|
||||
local ok, err = state:chooseSafari("missing")
|
||||
check(not ok and err == "invalid safari action",
|
||||
"native Safari rejects an unknown action")
|
||||
check(state:chooseSafari("rock"), "native Safari choice is accepted")
|
||||
eq(state.menuIndex, 3, "native Safari cursor follows the semantic choice")
|
||||
eq(state.safariChoice, "rock", "native Safari action uses the shared path")
|
||||
|
||||
state.phase = "mimicSelect"
|
||||
state.mimicMoves = { { slot = 4 } }
|
||||
state.mimicCtx = { user = {}, target = {}, moveInst = {} }
|
||||
function state:applyMimic(_, _, _, slot) self.mimicSlot = slot end
|
||||
ok, err = state:chooseMimic(2)
|
||||
check(not ok and err == "invalid mimic slot",
|
||||
"native Mimic rejects an unknown choice")
|
||||
check(state:chooseMimic(1), "native Mimic choice is accepted")
|
||||
eq(state.phase, "messages", "native Mimic choice resumes battle messages")
|
||||
eq(state.mimicSlot, 4, "native Mimic choice copies the selected move slot")
|
||||
end
|
||||
|
||||
local Loader = require("src.mods.Loader")
|
||||
local fs = { read = function() end, getInfo = function() end,
|
||||
getDirectoryItems = function() return {} end }
|
||||
|
||||
@@ -44,6 +44,11 @@ local PROBE = [[
|
||||
out.requireDebug = attempt(require, "debug")
|
||||
out.requirePackage = attempt(require, "package")
|
||||
out.requireFfi = attempt(require, "ffi")
|
||||
-- jit.util exposes bytecode/constant introspection over any function this
|
||||
-- chunk can reach -- the same class of escape the debug library is denied
|
||||
-- for -- so it must fail the same way require("debug") does rather than
|
||||
-- walking straight through under the bare "jit" global's cover.
|
||||
out.requireJitUtil = attempt(require, "jit.util")
|
||||
out.requireSocket = attempt(require, "socket")
|
||||
out.requireSemver = select(2, pcall(require, "src.mods.Semver"))
|
||||
|
||||
@@ -180,6 +185,8 @@ T.eq(out.getfenv, nil, "getfenv is still absent, so a mod cannot read the real _
|
||||
T.eq(out.debug, nil, "the debug library is still absent")
|
||||
T.check(out.loveThread ~= false, "love.thread is still refused: it opens a full Lua state")
|
||||
T.check(out.requireFfi ~= false, "require(\"ffi\") is still refused: it is arbitrary C")
|
||||
T.check(out.requireJitUtil ~= false,
|
||||
"require(\"jit.util\") is still refused: it is bytecode/constant introspection")
|
||||
T.check(out.requireDebug ~= false, "require(\"debug\") is still refused")
|
||||
T.check(out.requirePackage ~= false, "require(\"package\") is still refused")
|
||||
T.eq(out.popen, nil, "io.popen refuses rather than spawning a process")
|
||||
|
||||
@@ -24,7 +24,11 @@ local redWorld = {
|
||||
}
|
||||
local redGame = {
|
||||
data = { field = { outsideTilesets = { "OVERWORLD" } },
|
||||
items = { OLD_ROD = { name = "OLD ROD" } } },
|
||||
items = { OLD_ROD = { name = "OLD ROD" } },
|
||||
maps = { PALLET_TOWN = { index = 0, tileset = "OVERWORLD" },
|
||||
ROUTE_4 = { index = 11, tileset = "OVERWORLD" } },
|
||||
pokemon = { CHANSEY = { name = "CHANSEY" },
|
||||
PIKACHU = { name = "PIKACHU" } } },
|
||||
save = { player = { name = "RED" }, party = {},
|
||||
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
|
||||
stack = { states = { redWorld } },
|
||||
@@ -42,6 +46,7 @@ T.check(type(RedWorld.useBicycle) == "function"
|
||||
and type(RedWorld.useFishingRod) == "function"
|
||||
and type(RedWorld.useFlashFieldMove) == "function"
|
||||
and type(RedWorld.useStrengthFieldMove) == "function"
|
||||
and type(RedWorld.useSoftboiledFieldMove) == "function"
|
||||
and type(RedWorld.stopSurfing) == "function",
|
||||
"Red keeps field-action execution in its world")
|
||||
local actions = red:availableFieldActions()
|
||||
@@ -93,6 +98,40 @@ T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
|
||||
and redWorld.flashUsed and redWorld.teleportUsed,
|
||||
"Red delegates every move to its overworld path")
|
||||
|
||||
local source = { species = "CHANSEY", level = 30, hp = 80,
|
||||
stats = { hp = 100 }, moves = { { id = "SOFTBOILED" } } }
|
||||
local target = { species = "PIKACHU", level = 20, hp = 10,
|
||||
stats = { hp = 50 }, moves = {} }
|
||||
redGame.save.party = { source, target }
|
||||
redWorld.useSoftboiledFieldMove = function(self, user, recipient)
|
||||
self.softboiled = { user, recipient }
|
||||
return true
|
||||
end
|
||||
byId = {}
|
||||
for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
|
||||
T.check(byId.softboiled and byId.softboiled.sources[1].targets[1].slot == 2,
|
||||
"Red lists only valid SOFTBOILED targets")
|
||||
T.check(red:useFieldAction("softboiled", { sourceSlot = 1, targetSlot = 2 }),
|
||||
"Red accepts a listed SOFTBOILED transfer")
|
||||
T.check(redWorld.softboiled[1] == source and redWorld.softboiled[2] == target,
|
||||
"Red delegates SOFTBOILED to its overworld path")
|
||||
ok, err = red:useFieldAction("softboiled", { sourceSlot = 2, targetSlot = 1 })
|
||||
T.check(not ok and err == "softboiled target unavailable",
|
||||
"Red rejects an invalid SOFTBOILED source")
|
||||
|
||||
redGame.save.inventory.THUNDERBADGE = 1
|
||||
redGame.save.visited = { PALLET_TOWN = true, ROUTE_4 = true }
|
||||
redGame.data.field.flyOrder = { "PALLET_TOWN", "ROUTE_4" }
|
||||
redGame.data.field.flyWarps = { PALLET_TOWN = true, ROUTE_4 = true }
|
||||
redMoves.FLY = source
|
||||
redWorld.flyTo = function(self, mapId) self.flewTo = mapId end
|
||||
T.check(red:canFly(), "Red exposes FLY only in a valid outdoor context")
|
||||
T.check(red:flyTo("PALLET_TOWN") and redWorld.flewTo == "PALLET_TOWN",
|
||||
"Red validates and delegates a visited FLY destination")
|
||||
ok, err = red:flyTo("ROUTE_4")
|
||||
T.check(not ok and err == "destination unavailable",
|
||||
"Red rejects a fly warp that is not a native town destination")
|
||||
|
||||
redSurf = "dismount"
|
||||
redWorld.player.surfing = true
|
||||
redWorld.stopSurfing = function(self) self.dismounted = true end
|
||||
|
||||
@@ -324,6 +324,23 @@ do
|
||||
check(bound.gen2Tilesets == bound.tilesets, "bindGoldData aliases gen2Tilesets")
|
||||
check(Gen.tilesets({ gen2Tilesets = { TILESET_GYM = true } }).TILESET_GYM,
|
||||
"Gen.tilesets prefers gen2Tilesets")
|
||||
|
||||
-- bindGoldData bound gen2Palettes/gen2Icons/gen2Pokedex/gen2Landmarks/
|
||||
-- gen2Roofs/gen2Sprites through loadGen but never gen2Constants, so any
|
||||
-- mod reading mod.content.constants:get(...) under a save-editor Gold
|
||||
-- bootstrap saw an empty table where it expected the cart's ordered name
|
||||
-- lists. loadGen falls back to require("data.generated.constants") when
|
||||
-- the ROM cache has nothing active, which is what a checkout with no
|
||||
-- ROM imported hits too -- stub that module the same way to prove the
|
||||
-- wiring without needing a real Gold extraction.
|
||||
package.loaded["data.generated.constants"] = { badges = { "ZEPHYR" } }
|
||||
local withConstants = Gen.bindGoldData({})
|
||||
package.loaded["data.generated.constants"] = nil
|
||||
check(withConstants.gen2Constants ~= nil,
|
||||
"bindGoldData populates gen2Constants")
|
||||
check(withConstants.gen2Constants and withConstants.gen2Constants.badges
|
||||
and withConstants.gen2Constants.badges[1] == "ZEPHYR",
|
||||
"gen2Constants carries the extractor's own name lists")
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -95,6 +95,12 @@ function Gen.bindGoldData(data)
|
||||
end
|
||||
|
||||
data.gen2Palettes = data.gen2Palettes or loadGen("palettes")
|
||||
-- Namespaced AND differently shaped in Schemas.GEN2 (the cart's ordered
|
||||
-- name lists, not Gen 1's rule table), same as palettes/icons below --
|
||||
-- omitting it left mod.content.constants:get(...) reading an empty table
|
||||
-- under a Gold save-editor boot, which is what misreads "generation" and
|
||||
-- rejects every record a mod shapes off it.
|
||||
data.gen2Constants = data.gen2Constants or loadGen("constants")
|
||||
data.gen2Icons = data.gen2Icons or loadGen("icons")
|
||||
data.gen2Pokedex = data.gen2Pokedex or loadGen("pokedex")
|
||||
data.gen2Landmarks = data.gen2Landmarks or loadGen("landmarks")
|
||||
|
||||
@@ -430,7 +430,7 @@ function Ops.setDv(S, mon, key, value)
|
||||
end
|
||||
|
||||
function Ops.cycleMove(S, mon, slot)
|
||||
if not mon then return false end
|
||||
if not mon or not (S.cat and S.cat.moves and #S.cat.moves > 0) then return false end
|
||||
local moves = S.cat.moves
|
||||
local current = mon.moves and mon.moves[slot] and mon.moves[slot].id
|
||||
local idx = 0
|
||||
@@ -439,9 +439,14 @@ function Ops.cycleMove(S, mon, slot)
|
||||
if id == current then idx = i break end
|
||||
end
|
||||
end
|
||||
local nextId = moves[(idx % #moves) + 1]
|
||||
MonOps.setMove(S.data, mon, slot, nextId)
|
||||
return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
|
||||
for step = 1, #moves do
|
||||
local nextId = moves[((idx + step - 1) % #moves) + 1]
|
||||
if S.data and S.data.moves and S.data.moves[nextId] then
|
||||
MonOps.setMove(S.data, mon, slot, nextId)
|
||||
return Ops.mark(S, ("Move %d set to %s"):format(slot, nextId))
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Ops.clearMove(S, mon, slot)
|
||||
|
||||
Reference in New Issue
Block a user