This commit is contained in:
bryanthaboi
2026-08-01 08:10:22 -04:00
38 changed files with 1145 additions and 165 deletions
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---|
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes
- Mods may patch `constants.bagSize` through the public content registry. The
native `save.lua` format keeps every existing item when the configured
limit changes; exporting to a cartridge `.sav` still writes only the first
20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room.
+13
View File
@@ -187,5 +187,18 @@ 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.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
normal single-window composite so the mod can decorate around it. `ctx` carries
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)` / `setEnabled`)
for driving a second physical display. 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.
Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set.
+2 -1
View File
@@ -380,7 +380,8 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
with no slot cap, and the eight badges as toggle chips.
- **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid.
+23
View File
@@ -50,6 +50,29 @@ chosen file under the app save directory as `picked_rom.gb`,
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache).
### Step bridge (Pokéwalker mod)
`love.system.syncHealthSteps()``GameActivity.syncHealthSteps` (same
JNI route as the picker: `common/android.cpp`
`modules/system/System.cpp``wrap_System.cpp`). The Java side does a
one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
since boot, counted by the OS whether or not any app runs), anchors the
reading in `SharedPreferences` so a walk is never credited twice
(a reading below the anchor means the phone rebooted → re-anchor without
crediting), and stages the delta as `steps_pending.json` in the save
identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
the base game calls it; the consumer is the
[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
off).
Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
of the build script's permission trim). The first
`syncHealthSteps()` call shows the system prompt; on grant the sensor
read runs immediately (`onRequestPermissionsResult`,
`STEP_PERMISSION_REQUEST_CODE`).
### SDK / NDK
love-android 11.5a expects:
@@ -8,6 +8,11 @@
the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC
STEPS option); scripts/build_android.sh must not strip it. -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" />
<!-- Touchscreen support -->
@@ -219,6 +219,18 @@ bool showCreateDocument(const char *suggestedName)
return result;
}
bool syncHealthSteps()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
/*
* Helper functions for the filesystem module
*/
@@ -851,4 +863,51 @@ const char *getArg0()
} // android
} // love
extern "C" __attribute__((visibility("default")))
int love_android_secondary_ready()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
jboolean ready = JNI_FALSE;
if (m)
ready = env->CallStaticBooleanMethod(activity, m);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
return ready ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
void love_android_push_secondary(const void *rgba, int w, int h)
{
if (!rgba || w <= 0 || h <= 0)
return;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
if (m)
{
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
env->CallStaticVoidMethod(activity, m, buf, w, h);
env->DeleteLocalRef(buf);
}
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
void love_android_secondary_enable(int on)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
if (m)
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
#endif // LOVE_ANDROID
@@ -74,6 +74,13 @@ bool showFilePicker(const char *destFilename = nullptr);
**/
bool showCreateDocument(const char *suggestedName = nullptr);
/**
* Pokéwalker step bridge: asks GameActivity to read the hardware step
* counter and stage steps_pending.json in the save identity dir (see
* GameActivity.syncHealthSteps). Returns whether a sync could start.
*/
bool syncHealthSteps();
/*
* Helper functions for the filesystem module
*/
@@ -212,6 +212,15 @@ bool System::createFile(const char *suggestedName) const
#endif
}
bool System::syncHealthSteps() const
{
#ifdef LOVE_ANDROID
return love::android::syncHealthSteps();
#else
return false;
#endif
}
bool System::hasBackgroundMusic() const
{
#if defined(LOVE_ANDROID)
@@ -127,6 +127,12 @@ public:
**/
virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Pokéwalker: stage pending real-world steps (steps_pending.json in the
* save dir) from the platform step source. Android-only; false elsewhere.
*/
virtual bool syncHealthSteps() const;
/**
* Gets if the user is playing music on background.
* Throws an exception on unsupported platforms.
@@ -109,6 +109,12 @@ int w_createFile(lua_State *L)
return 1;
}
int w_syncHealthSteps(lua_State *L)
{
luax_pushboolean(L, instance()->syncHealthSteps());
return 1;
}
int w_hasBackgroundMusic(lua_State *L)
{
lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -126,6 +132,7 @@ static const luaL_Reg functions[] =
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 }
};
@@ -41,13 +41,20 @@ import android.app.AlertDialog;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator;
import android.util.Log;
import android.util.DisplayMetrics;
@@ -67,6 +74,7 @@ public class GameActivity extends SDLActivity {
public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5;
public static final int STEP_PERMISSION_REQUEST_CODE = 6;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
@@ -83,6 +91,17 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
// (cumulative since boot, counted by the OS whether or not any app is
// running), anchored in SharedPreferences so a walk is never credited
// twice.
private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
private static final String STEP_PREFS = "pokewalker_steps";
private static final String STEP_PREF_ANCHOR = "anchor";
private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
private static final long STEP_MAX_PER_SYNC = 50000;
// Destination basename for the in-flight SAF pick (set by showFilePicker).
// Saved/restored across instance state: the picker is a separate activity
// and Android may destroy this one while it is up (memory pressure, or
@@ -312,12 +331,14 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel();
}
teardownSecondaryDisplay();
super.onPause();
}
@Override
public void onResume() {
super.onResume();
setupSecondaryDisplay();
}
/**
@@ -532,6 +553,160 @@ public class GameActivity extends SDLActivity {
}
}
/**
* Step sync, called from Lua as love.system.syncHealthSteps()
* (see modules/system/wrap_System.cpp). Asynchronous like the picker:
* returns whether a sync could be started; the result lands later as
* steps_pending.json in the save identity dir, where the Pokéwalker
* mod's poll consumes it.
*
* Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
* runtime permission; the first call shows the system prompt and a later
* sync (the mod retries on save load / option change) delivers.
*/
@Keep
public static boolean syncHealthSteps() {
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT >= 29
&& ActivityCompat.checkSelfPermission(self,
Manifest.permission.ACTIVITY_RECOGNITION)
!= PackageManager.PERMISSION_GRANTED) {
self.runOnUiThread(new Runnable() {
@Override
public void run() {
ActivityCompat.requestPermissions(self,
new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
STEP_PERMISSION_REQUEST_CODE);
}
});
return true;
}
self.startStepSensorRead();
return true;
}
/**
* One-shot read of the cumulative hardware step counter. The sensor
* usually reports its cached value moments after registration; some
* devices hold the event until the next physical step, so the listener
* is given 20 seconds before being torn down (the next sync retries).
*/
private void startStepSensorRead() {
final SensorManager manager =
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
if (manager == null) return;
Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
if (counter == null) {
Log.d("GameActivity", "no step counter sensor on this device");
return;
}
final SensorEventListener listener = new SensorEventListener() {
private boolean delivered = false;
@Override
public void onSensorChanged(SensorEvent event) {
if (delivered || event.values.length == 0) return;
delivered = true;
manager.unregisterListener(this);
deliverSteps((long) event.values[0]);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
};
if (!manager.registerListener(listener, counter,
SensorManager.SENSOR_DELAY_NORMAL)) {
Log.d("GameActivity", "step counter listener registration failed");
return;
}
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
// No-op if the listener already delivered and unregistered.
manager.unregisterListener(listener);
}
}, 20000);
}
/**
* Convert a cumulative counter reading into pending steps. The counter
* resets to zero on reboot: a reading below the stored anchor re-anchors
* without crediting (steps walked between the reboot and this sync are
* lost, which errs on the honest side).
*/
private void deliverSteps(long counterNow) {
SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
long now = System.currentTimeMillis();
if (anchor < 0 || counterNow < anchor) {
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
Log.d("GameActivity", "step anchor set at " + counterNow);
return;
}
long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
if (steps <= 0) return;
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
File dir = saveIdentityDir();
if (!dir.isDirectory() && !dir.mkdirs()) {
Log.d("GameActivity", "cannot create save dir for steps: " + dir);
return;
}
File pending = new File(dir, PENDING_STEPS_FILENAME);
long total = steps;
// Merge with an unconsumed earlier delivery so steps are never lost
// (same contract as the iOS bridge).
if (pending.isFile()) {
try {
byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
FileInputStream in = new FileInputStream(pending);
int read = in.read(raw);
in.close();
if (read > 0) {
org.json.JSONObject old =
new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
total += Math.max(0, old.optLong("steps", 0));
}
} catch (Exception e) {
Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
}
}
try {
org.json.JSONObject payload = new org.json.JSONObject();
payload.put("steps", total);
payload.put("from", isoTime(fromWalltime));
payload.put("to", isoTime(now));
File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
FileOutputStream out = new FileOutputStream(tmp);
out.write(payload.toString().getBytes("UTF-8"));
out.close();
if (!tmp.renameTo(pending)) {
tmp.delete();
Log.d("GameActivity", "could not publish pending steps");
return;
}
Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
} catch (Exception e) {
Log.d("GameActivity", "could not write pending steps: " + e);
}
}
private static String isoTime(long millis) {
java.text.SimpleDateFormat format =
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
return format.format(new java.util.Date(millis));
}
private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null;
OutputStream out = null;
@@ -762,6 +937,17 @@ public class GameActivity extends SDLActivity {
}
break;
}
case STEP_PERMISSION_REQUEST_CODE: {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d("GameActivity", "Step permission granted");
// Deliver right away so the sync the player just
// opted into doesn't wait for the next launch.
startStepSensorRead();
} else {
Log.d("GameActivity", "Did not get step permission.");
}
break;
}
default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
}
@@ -944,4 +1130,182 @@ public class GameActivity extends SDLActivity {
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
}
}
// 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 volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
@Keep
public static void setSecondaryEnabled(final boolean on) {
secondaryEnabled = on;
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
}
});
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) 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];
}
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.show();
secondaryPresentation = p;
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null;
}
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
}
@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);
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
SecondaryPresentation(Context context, Display display) {
super(context, display);
frameView = new FrameView(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
android.view.Window w = getWindow();
if (w != null) {
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT);
}
setContentView(frameView);
applyImmersive();
frameView.post(new Runnable() {
@Override public void run() { applyImmersive(); }
});
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applyImmersive();
}
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);
}
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
}
private static class FrameView extends View {
private android.graphics.Bitmap bitmap;
private final android.graphics.Rect dst = new android.graphics.Rect();
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
FrameView(Context context) {
super(context);
paint.setFilterBitmap(false);
paint.setAntiAlias(false);
setBackgroundColor(0xFF000000);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
synchronized (lock) {
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);
fw = w; fh = h;
}
buf.rewind();
bitmap.copyPixelsFromBuffer(buf);
}
postInvalidate();
}
@Override
protected void onDraw(android.graphics.Canvas canvas) {
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;
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
}
}
+1 -1
View File
@@ -156,7 +156,7 @@ return function(mod)
caughtAreas()[areaKey(self.game, self)] = "DUPES_LOST"
mod.save:set("caught_areas", caughtAreas())
end
Bag.add(self.game.save, ball, 1)
Bag.add(self.game.save, ball, 1, self.game.data)
self:say(reason == "area" and "This area already\nhas a captured POKéMON!"
or "You already have\nthis POKéMON family!")
return
+29 -3
View File
@@ -198,6 +198,10 @@ pack_game_love() {
rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh).
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
# APK, so the mod manager's Delete can't remove it and it reappears every
# launch. Pokewalker ships as an importable .zip instead, which gives it
# a real install/upgrade/delete lifecycle.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
@@ -290,10 +294,32 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle
run_gradle() {
local task="assembleEmbedNoRecordDebug"
say "building APK ($task)"
local build_dir="$ANDROID_DIR"
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
# shadow the android tree to a space-free location and build there; the
# shadow persists across runs so gradle/ndk builds stay incremental.
case "$ANDROID_DIR" in
*" "*)
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
say "path contains spaces (ndk-build cannot handle them);"
say "shadow-building in: $build_dir"
mkdir -p "$build_dir"
rsync -a --delete \
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
--exclude="local.properties" \
"$ANDROID_DIR/" "$build_dir/"
if [ -f "$ANDROID_DIR/local.properties" ]; then
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
fi
;;
esac
say "building APK ($task)"
if ! (
cd "$ANDROID_DIR"
cd "$build_dir"
./gradlew --no-daemon "$task"
); then
fail "gradle $task failed.
@@ -302,7 +328,7 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug"
local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then
say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \;
+61 -21
View File
@@ -2764,10 +2764,24 @@ function BattleState:updateFx()
-- so a sounding siren rides out the next hit's HP drain instead of
-- dropping out mid-announcement (#293)
self.lowHealthAlarmOn = self:lowHealthAlarmActive()
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
-- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
-- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
-- ctx.on false after some budget) before letting vanilla act on it.
if Runtime.wantsHook("battle.low_health_alarm") then
Runtime.call("battle.low_health_alarm", function(ctx)
if ctx.on then
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end, { on = self.lowHealthAlarmOn, battle = self })
else
Sound.stopLoop("Low_Health_Alarm")
if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end
end
@@ -3303,7 +3317,10 @@ function BattleState:onFaint(battler)
end
end
function BattleState:enemyMonFainted()
-- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
-- and, when a mod's battle.catch_exp hook says so, the catch path
-- (storeCaughtMon).
function BattleState:awardExp()
-- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp
@@ -3381,27 +3398,44 @@ function BattleState:enemyMonFainted()
end
end
end
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(alive) do
applyShare(mon, participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll")
-- battle.exp_award: the participant/EXP.ALL split, factored out so a
-- mod can replace it wholesale (e.g. a flat undivided share to every
-- non-fainted party mon) without re-deriving participants/alive.
-- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
local function vanillaExpAward(ctx)
-- with EXP.ALL, participants split half the exp and the other half
-- is divided among the whole party (engine/battle/experience.asm)
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
for _, mon in ipairs(ctx.alive) do
ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
end
if expAll then
-- the second GainExperience pass sets the gain flags for the WHOLE
-- party, so DivideExpDataByNumMonsGainingExp divides the already
-- halved-and-participant-divided exp again by the party count, and
-- .partyMonLoop still skips fainted mons (core.asm:818-858 +
-- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
end
end
end
end
local awardCtx = { battle = self, participants = participants, alive = alive,
applyShare = applyShare }
if Runtime.wantsHook("battle.exp_award") then
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
else
vanillaExpAward(awardCtx)
end
self.participants = {}
end
function BattleState:enemyMonFainted()
self:awardExp()
if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
@@ -3974,6 +4008,12 @@ function BattleState:storeCaughtMon()
-- (item_effects.asm:472-501), regenerating its move list from the
-- base data -- a Mimic'd slot never leaves the battle with it
self:restoreMimicked(self.enemy)
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
-- this to true to pay out the same award a faint would have.
if Runtime.wantsHook("battle.catch_exp")
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
self:awardExp()
end
local game = self.game
local dex = game.save.pokedex
local species = self.enemy.mon.species
+5 -5
View File
@@ -14,12 +14,12 @@ local MODULES = {
-- Optional for compatibility with developer and stale caches.
local OPTIONAL = { "audio", "palettes", "icons" }
-- The rules the engine still carries as literals. The constants registry
-- deep-merges over these, so a value has to exist before a mod can patch
-- it; each one is the number the engine hard-codes today, so seeding them
-- changes nothing on a mod-free boot.
-- Vanilla defaults for rules exposed through the constants registry. A
-- value has to exist before a mod can patch it; each one matches the
-- engine's no-mod behavior, so seeding them changes nothing on a vanilla
-- boot.
local CONSTANT_DEFAULTS = {
bagSize = 20, -- BAG_ITEM_CAPACITY (src/inventory/Bag.lua)
bagSize = 20, -- BAG_ITEM_CAPACITY (Bag.capacity fallback)
partyMax = 6, -- PARTY_LENGTH (src/pokemon/Party.lua)
boxCount = 12, boxSize = 20, -- Bill's PC (src/pokemon/Boxes.lua)
moveMax = 4,
+40
View File
@@ -0,0 +1,40 @@
-- Usable window rect for mobile chrome (notch / Dynamic Island / home
-- indicator / Android display cutouts). Wraps love.window.getSafeArea when
-- the engine provides it; otherwise the full graphics window.
--
-- Desktop and headless stubs return the full window, so callers can always
-- layout against this rect without platform branches. Interactive chrome
-- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion.
local SafeArea = {}
function SafeArea.rect()
local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions()
end
if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end
if not (love and love.window and love.window.getSafeArea) then
return 0, 0, ww, wh
end
local x, y, w, h = love.window.getSafeArea()
if type(x) ~= "number" or type(y) ~= "number"
or type(w) ~= "number" or type(h) ~= "number"
or w <= 0 or h <= 0 then
return 0, 0, ww, wh
end
-- Clamp to the drawable window so a bad / mid-rotation backend cannot
-- push layout outside the surface.
x = math.max(0, math.min(x, ww))
y = math.max(0, math.min(y, wh))
w = math.max(1, math.min(w, ww - x))
h = math.max(1, math.min(h, wh - y))
return x, y, w, h
end
return SafeArea
+1 -1
View File
@@ -1058,7 +1058,7 @@ local function reclaim(save, data, report)
if type(entry) == "table" and known(data.items, entry.id) then
table.remove(orphaned.items, i)
if entry.from == "pcItems" or type(save.inventory) ~= "table"
or not Bag.add(save, entry.id, entry.count or 1) then
or not Bag.add(save, entry.id, entry.count or 1, data) then
save.pcItems = save.pcItems or {}
save.pcItems[entry.id] = (save.pcItems[entry.id] or 0) + (entry.count or 1)
end
+42 -26
View File
@@ -23,6 +23,7 @@
-- and a player rebind can never detach the overlay.
local Input = require("src.core.Input")
local SafeArea = require("src.core.SafeArea")
local TouchControls = {}
@@ -92,20 +93,23 @@ function TouchControls.normalizeConfig(tc)
return out
end
-- Pure default layout in LOVE units for a given window size. Shared by
-- layout() and the editor's Reset path so defaults stay in one place.
function TouchControls.defaultLayout(ww, wh)
-- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- origin (ox, oy). Shared by layout() and the editor's Reset path so
-- defaults stay in one place. ox/oy default to 0 for the headless tests
-- and for callers that already pass a full-window size.
function TouchControls.defaultLayout(ww, wh, ox, oy)
ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34)
local abW = dpadW * 0.46
local ssW = dpadW * 0.30
local margin = dpadW * 0.12
return {
dpad = { cx = margin + dpadW / 2, cy = wh - margin - dpadW / 2, w = dpadW },
a = { cx = ww - margin - abW * 0.55, cy = wh - margin - abW * 1.75, w = abW },
b = { cx = ww - margin - abW * 1.60, cy = wh - margin - abW * 0.55, w = abW },
start = { cx = ww / 2 + ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
select = { cx = ww / 2 - ssW * 0.60, cy = wh - margin - ssW * 0.95, w = ssW },
dpad = { cx = ox + margin + dpadW / 2, cy = oy + wh - margin - dpadW / 2, w = dpadW },
a = { cx = ox + ww - margin - abW * 0.55, cy = oy + wh - margin - abW * 1.75, w = abW },
b = { cx = ox + ww - margin - abW * 1.60, cy = oy + wh - margin - abW * 0.55, w = abW },
start = { cx = ox + ww / 2 + ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
select = { cx = ox + ww / 2 - ssW * 0.60, cy = oy + wh - margin - ssW * 0.95, w = ssW },
}
end
@@ -155,6 +159,7 @@ function TouchControls:applyOptions(opts)
self.enabled = cfg.enabled
self.positions = cfg.positions
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
if not self.enabled then
self.controllerHidden = false
self:reset()
@@ -185,30 +190,37 @@ function TouchControls:visible()
and not self.controllerHidden
end
local function clampZone(zone, ww, wh)
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
local function clampZone(zone, x0, y0, x1, y1)
local half = zone.w * 0.5
zone.cx = math.max(half, math.min(ww - half, zone.cx))
zone.cy = math.max(half, math.min(wh - half, zone.cy))
zone.cx = math.max(x0 + half, math.min(x1 - half, zone.cx))
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end
-- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window size changes (rotation, resize). Default: d-pad bottom-left,
-- B/A bottom-right with A above B (the Game Boy diagonal), START/SELECT
-- flanking the bottom center. Custom positions (normalized 0..1) override
-- centers while sizes stay derived from the short edge.
-- the window or safe area changes (rotation, resize, notch insets).
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- diagonal), START/SELECT flanking the bottom center -- all inside the
-- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers
-- while sizes stay derived from the short edge.
function TouchControls:layout()
local ww, wh = love.graphics.getDimensions()
if self.layoutW == ww and self.layoutH == wh and self.L then return self.L end
self.layoutW, self.layoutH = ww, wh
self.L = TouchControls.defaultLayout(ww, wh)
local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L
end
self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy
self.L = TouchControls.defaultLayout(sw, sh, ox, oy)
if self.positions then
for _, name in ipairs(CONTROLS) do
local p = self.positions[name]
local zone = self.L[name]
if p and zone then
zone.cx = p.x * ww
zone.cy = p.y * wh
clampZone(zone, ww, wh)
zone.cx = ox + p.x * sw
zone.cy = oy + p.y * sh
clampZone(zone, ox, oy, ox + sw, oy + sh)
end
end
end
@@ -222,21 +234,25 @@ function TouchControls:layout()
end
-- Move one control to a screen-space point and persist its normalized
-- position. Used by the layout editor while dragging.
-- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy)
local ww, wh = love.graphics.getDimensions()
local ox, oy, sw, sh = SafeArea.rect()
local L = self:layout()
local zone = L[name]
if not zone then return end
zone.cx, zone.cy = cx, cy
clampZone(zone, ww, wh)
clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {}
self.positions[name] = { x = zone.cx / ww, y = zone.cy / wh }
self.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0,
}
end
function TouchControls:clearPositions()
self.positions = nil
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
end
local function inCircle(zone, x, y, slop)
+1 -1
View File
@@ -214,7 +214,7 @@ function VERBS.give(self, rest)
end
elseif game.data.items and game.data.items[id] then
local n = tonumber(count) or 1
if require("src.inventory.Bag").add(save, id, n) then
if require("src.inventory.Bag").add(save, id, n, game.data) then
self:print(("%s x%d added"):format(id, n))
else
self:print("bag full")
+42 -35
View File
@@ -1,6 +1,7 @@
local GameVersion = require("src.core.GameVersion")
local Strings = require("src.core.Strings")
local HostShell = require("src.core.HostShell")
local SafeArea = require("src.core.SafeArea")
local RomImporter = {}
RomImporter.__index = RomImporter
@@ -1302,10 +1303,10 @@ local PAD_DPAD_SPEED = 420
function RomImporter:_activatePadCursor()
if self._padCursorActive then return end
local w, h = love.graphics.getDimensions()
local ox, oy, w, h = SafeArea.rect()
if not self._padInited then
self._padCursor.x = w * 0.5
self._padCursor.y = h * 0.45
self._padCursor.x = ox + w * 0.5
self._padCursor.y = oy + h * 0.45
self._padInited = true
end
self._padCursorActive = true
@@ -1351,11 +1352,11 @@ function RomImporter:_updatePadCursor(dt)
if mag > 1 then dx, dy = dx / mag, dy / mag end
local speed = (math.abs(ax) > PAD_DEAD or math.abs(ay) > PAD_DEAD)
and PAD_SPEED or PAD_DPAD_SPEED
local w, h = love.graphics.getDimensions()
local ox, oy, w, h = SafeArea.rect()
local nx = self._padCursor.x + dx * speed * dt
local ny = self._padCursor.y + dy * speed * dt
self._padCursor.x = math.max(0, math.min(w, nx))
self._padCursor.y = math.max(0, math.min(h, ny))
self._padCursor.x = math.max(ox, math.min(ox + w, nx))
self._padCursor.y = math.max(oy, math.min(oy + h, ny))
end
-- Right stick scrolls the active list (save slots or mods), or the whole page
@@ -1733,7 +1734,10 @@ function RomImporter:_resetFrameRects()
end
function RomImporter:draw()
local width, height = love.graphics.getDimensions()
-- Full window for immersive backdrop; safe rect for interactive chrome so
-- notch / Dynamic Island / home indicator / Android cutouts are respected.
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, width, height = SafeArea.rect()
local s = clamp(height / 768, 0.7, 1.6)
local pulse = self.pulse
self._s = s
@@ -1752,8 +1756,9 @@ function RomImporter:draw()
self._anyHover = false
self:_resetFrameRects()
-- Fonts + size-dependent scenery, rebuilt only when the window size changes.
local fontKey = ("%dx%d"):format(width, height)
-- Fonts + size-dependent scenery, rebuilt only when the window / safe
-- area changes (rotation, resize, inset changes).
local fontKey = ("%dx%d@%d,%d"):format(fullW, fullH, ox, oy)
if self.fontKey ~= fontKey then
self.fontKey = fontKey
local function f(px) return love.graphics.newFont(math.max(8, math.floor(px + 0.5))) end
@@ -1777,10 +1782,10 @@ function RomImporter:draw()
-- Background: a radial gradient (bright navy at top-centre -> near black).
-- A triangle fan from the top-centre gives the radial falloff; the screen
-- is cleared to the outer colour first so the corners it does not reach
-- match seamlessly.
-- match seamlessly. Sized to the full window so unsafe edges stay filled.
do
local cx, cy = width / 2, 0
local rx, ry = width * 1.3, height * 1.08
local cx, cy = fullW / 2, 0
local rx, ry = fullW * 1.3, fullH * 1.08
local n = 72
local verts = { { cx, cy, 0, 0,
PAL.bgTop[1] / 255, PAL.bgTop[2] / 255, PAL.bgTop[3] / 255, 1 } }
@@ -1794,8 +1799,8 @@ function RomImporter:draw()
-- CRT vignette: a gentle edge darkening, centred slightly above the middle.
do
local cx, cy = width / 2, height * 0.45
local rx, ry = width * 0.78, height * 0.78
local cx, cy = fullW / 2, fullH * 0.45
local rx, ry = fullW * 0.78, fullH * 0.78
local n = 72
local verts = { { cx, cy, 0, 0, 0, 0, 0, 0 } }
for i = 0, n do
@@ -1817,7 +1822,7 @@ function RomImporter:draw()
self.scanlineImage:setWrap("repeat", "repeat")
self.scanlineImage:setFilter("nearest", "nearest")
end
self.scanlineQuad = love.graphics.newQuad(0, 0, width, height, 1, 3)
self.scanlineQuad = love.graphics.newQuad(0, 0, fullW, fullH, 1, 3)
end
-- Invert shader: the Boi's Club Games mark is dark ink; on this dark panel it
@@ -1843,21 +1848,23 @@ function RomImporter:draw()
}
]])
-- background
-- background (full window — unsafe edges stay painted)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.bgMesh)
-- Centered content container (max ~1440 scaled units on very wide windows)
-- with a responsive side gutter; every column below derives from these.
-- Origin is the safe-area top-left so chrome clears device insets.
local appW = math.min(width, 1440 * s)
local appX = (width - appW) / 2
local appX = ox + (width - appW) / 2
local padH = clamp(appW * 0.03, 12 * s, 26 * s)
local third = appW / 3
-- tricolor strip (Red | Blue | Yellow), 6px tall, with a soft downward bloom
local stripH = math.max(4, 6 * s)
local stripY = oy
local segs = {
{ PAL.red, appX, third },
{ PAL.blue, appX + third, third },
@@ -1865,11 +1872,11 @@ function RomImporter:draw()
}
love.graphics.setBlendMode("add")
for _, seg in ipairs(segs) do
fillGrad(seg[2], stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
fillGrad(seg[2], stripY + stripH, seg[3], stripH * 3.6, seg[1], seg[1], 0.30, 0.0)
end
love.graphics.setBlendMode("alpha")
for _, seg in ipairs(segs) do
col(seg[1]); love.graphics.rectangle("fill", seg[2], 0, seg[3], stripH)
col(seg[1]); love.graphics.rectangle("fill", seg[2], stripY, seg[3], stripH)
end
-- Footer (Boi's Club Games logo + trust warning), measured first so the
@@ -1891,7 +1898,7 @@ function RomImporter:draw()
math.min(330 * s, appW - 32 * s))
local logoScale = math.min(logoTargetW / logoW, height * 0.15 / logoH)
local logoDW, logoDH = logoW * logoScale, logoH * logoScale
local logoY = stripH + 14 * s
local logoY = stripY + stripH + 14 * s
-- Tab bar: R/B/Y/divider/MODS chips (label + underline on the active one),
-- with "N of 3 ready" right-aligned.
@@ -1921,7 +1928,7 @@ function RomImporter:draw()
local bannerBand = bannerActive and (bannerH + 20 * s) or 6 * s
local cX = appX + padH
local cW = appW - 2 * padH
local contentBottom = height - footerH - bannerBand
local contentBottom = oy + height - footerH - bannerBand
local cH = math.max(0, contentBottom - contentTop)
-- Page scroll. Everything under the tab bar -- panel, updater banner and
@@ -1932,14 +1939,14 @@ function RomImporter:draw()
-- the previous frame's measurement, the same one-frame settle the slot and
-- mod lists already rely on. While the page fits, `paged` is false and every
-- measurement below is what it always was.
local viewportH = math.max(0, height - contentTop)
local viewportH = math.max(0, oy + height - contentTop)
self._panelNaturalH = self._panelNaturalH or {}
local naturalH = (self._panelNaturalH[self.tab] or 0) + bannerBand + footerH
local paged, pageScroll, maxPage =
RomImporter.pageScrollFor(naturalH, viewportH, self.pageScroll)
self.pageScroll, self._pageMax = pageScroll, maxPage
-- read by the hit tests; a scrolled control is live only inside the viewport
pageBand = paged and { contentTop, height } or nil
pageBand = paged and { contentTop, oy + height } or nil
-- tab bar (rebuilds self.tabRects). Pinned: it is the launcher's navigation,
-- and it sits above the scrolling viewport.
@@ -2110,10 +2117,10 @@ function RomImporter:draw()
-- logo, over the split, with a gentle bob + gold glow + sweeping shine
local bob = math.sin(pulse * (2 * math.pi / 4)) * 6 * s
local lx, ly = (width - logoDW) / 2, logoY + bob
local lx, ly = ox + (width - logoDW) / 2, logoY + bob
love.graphics.setBlendMode("add")
love.graphics.setColor(1, 0.85, 0.2, 0.16 + 0.12 * (0.5 + 0.5 * math.sin(pulse * 1.6)))
love.graphics.draw(self.logo, (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
love.graphics.draw(self.logo, ox + (width - logoDW * 1.05) / 2, ly - logoDH * 0.025, 0,
logoScale * 1.05, logoScale * 1.05)
love.graphics.setBlendMode("alpha")
local shineW = 0.16
@@ -2146,11 +2153,11 @@ function RomImporter:draw()
-- save-slot rename modal (#205), drawn over everything
if self._rename then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 420 * s)
local dh = 128 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.green, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.85, 0.85)
@@ -2192,11 +2199,11 @@ function RomImporter:draw()
-- it is typed.
if self._indexPrompt then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 520 * s)
local dh = 168 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
neonGlow(dx, dy, dw, dh, rr, PAL.modDot, 0.4)
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.9, 0.9)
@@ -2248,7 +2255,7 @@ function RomImporter:draw()
if self._modConfirm or self._modVersions or self._modReleaseNotes
or self._findDetails then
col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, width, height)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
end
if self._modConfirm then
local c = self._modConfirm
@@ -2256,7 +2263,7 @@ function RomImporter:draw()
local lineH = self.hintFont:getHeight() + 4 * s
local dh = 36 * s + (#c.lines) * lineH + 56 * s
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2298,7 +2305,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 480 * s)
local dh = math.min(height - 48 * s, 360 * s)
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.92, 0.92)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2343,7 +2350,7 @@ function RomImporter:draw()
local dw = math.min(appW - 32 * s, 520 * s)
local dh = math.min(height - 48 * s, 420 * s)
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.94, 0.94)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
@@ -2400,7 +2407,7 @@ function RomImporter:draw()
listH = listN * rowH
dh = headerH + listH + footerH
local dx = appX + (appW - dw) / 2
local dy = (height - dh) / 2
local dy = oy + (height - dh) / 2
local rr = 12 * s
fillGradRounded(dx, dy, dw, dh, rr, PAL.slotBg, PAL.slotBg, 0.96, 0.96)
love.graphics.setLineWidth(math.max(1, 1.2 * s))
+23 -7
View File
@@ -1,11 +1,26 @@
-- The 20-slot bag (BAG_ITEM_CAPACITY, constants/menu_constants.asm):
-- a distinct item id occupies one slot regardless of quantity; badges
-- live in the inventory table but are not bag items. save.bagOrder
-- keeps acquisition order like wBagItems (SELECT can reorder it).
-- The bag defaults to 20 slots (BAG_ITEM_CAPACITY,
-- constants/menu_constants.asm), but mods may replace that limit through
-- Data.constants.bagSize. A distinct item id occupies one slot regardless
-- of quantity; badges live in the inventory table but are not bag items.
-- save.bagOrder keeps acquisition order like wBagItems (SELECT can reorder
-- it).
local Bag = {}
Bag.CAPACITY = 20
local DEFAULT_CAPACITY = 20
-- `data` is injectable for the save editor and headless mod tests. Normal
-- gameplay may omit it because the loader merges mods into the Data
-- singleton before any item can be added. The fallback keeps old/stale
-- generated caches and isolated callers at the vanilla limit.
function Bag.capacity(data)
data = data or require("src.core.Data")
local configured = data and data.constants and data.constants.bagSize
if type(configured) == "number" and configured >= 1 then
return math.floor(configured)
end
return DEFAULT_CAPACITY
end
local function isBadge(id)
return id:find("BADGE", 1, true) ~= nil
@@ -55,9 +70,10 @@ end
-- Add qty of an item; returns false (and adds nothing) when a new slot
-- is needed and the bag is full, or when the stack would pass 99
-- (AddItemToInventory's per-slot quantity cap).
function Bag.add(save, id, qty)
function Bag.add(save, id, qty, data)
local inv = save.inventory
if not inv[id] and not isBadge(id) and Bag.slots(save) >= Bag.CAPACITY then
if not inv[id] and not isBadge(id)
and Bag.slots(save) >= Bag.capacity(data) then
return false
end
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
+69 -30
View File
@@ -474,6 +474,41 @@ local function withTrueColor(zoneList, pass)
return merged
end
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
-- render.compose mod can composite the world/UI canvases into its own layout.
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
end
-- zones: optional list of SGB palette regions (see PaletteFX) in
-- 160x144 UI space, applied to the UI pass. worldZones: optional
-- regions in world-canvas pixels (overworld survey zoom colors each
@@ -506,6 +541,36 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world")
-- render.compose: hand a mod the finished world + UI canvases (and their
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
-- stacked Game Boy screens, or driving one onto a second physical display.
-- The mod returns true to take over the whole window; anything else (or no
-- mod wrapping the hook) falls through to the normal composite below.
if Runtime.wantsHook("render.compose") then
local ctx = {
renderer = self,
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
worldOverride = self.worldOverride,
worldActive = self.worldActive and true or false,
zones = zones, worldZones = worldZones,
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
secondScreen = require("src.render.SecondScreen"),
}
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh, gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
}
end
end
-- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen
@@ -560,38 +625,12 @@ function Renderer:endFrame(zones, worldZones)
})
end
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by),
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy
-- convert zone coords (canvas-space) into screen units.
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
-- call sites below stay unchanged.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
end
if self.worldOverride then
+59
View File
@@ -0,0 +1,59 @@
-- 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.
local SecondScreen = {}
local C = nil
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
end
do
local ok, ffi = pcall(require, "ffi")
if not (ok and ffi) then
log("ffi unavailable (not LuaJIT); second display disabled")
else
pcall(ffi.cdef, [[
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
]])
local okLib, lib = pcall(ffi.load, "love")
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
C = lib
log("bridge linked via ffi.load('love')")
elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
C = ffi.C
log("bridge linked via default namespace")
else
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib)))
end
end
end
function SecondScreen.usable()
return C ~= nil
end
function SecondScreen.available()
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)
if not C or not imageData then return false end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end)
end
function SecondScreen.setEnabled(on)
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
return SecondScreen
+3 -2
View File
@@ -210,11 +210,12 @@ end
-- text (label or literal; {RAM:wStringBuffer} becomes the item name);
-- pass false when the script shows its own received-text row.
function Commands.give_item(ctx, itemId, count, gotText)
-- the 20-slot bag can refuse (BAG_ITEM_CAPACITY): say so and halt
-- the bag can refuse at its configured capacity (20 in vanilla): halt
-- the script, so later set_flag rows don't burn the gift -- make
-- room and talk again, like the original (pokered's `jr nc, .bag_full`
-- skips the received text entirely when AddItemToInventory refuses)
if not require("src.inventory.Bag").add(ctx.save, itemId, count or 1) then
if not require("src.inventory.Bag").add(
ctx.save, itemId, count or 1, ctx.game.data) then
Commands.show_text(ctx, ctx.game.data.text
and ctx.game.data.text._BagFullText or Strings("You can't carry\nany more items!"))
return math.huge
+1 -1
View File
@@ -77,7 +77,7 @@ local function withdraw(game)
onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag")
if not Bag.add(game.save, item.value, qty) then
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = Strings("You can't carry\nany more items.")
return
end
+1 -1
View File
@@ -67,7 +67,7 @@ local function buy(game, stock)
list.footer = notEnough
return
end
if not Bag.add(game.save, item.value, qty) then
if not Bag.add(game.save, item.value, qty, game.data) then
list.footer = txt(game, "_PokemartItemBagFullText",
Strings("You can't carry\nany more items."))
return
+13 -11
View File
@@ -109,34 +109,36 @@ function Editor.update(_dt)
end
function Editor.draw()
local ww, wh = love.graphics.getDimensions()
local SafeArea = require("src.core.SafeArea")
local fullW, fullH = love.graphics.getDimensions()
local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768))
Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere)
col(PAL.bgBot)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.rectangle("fill", 0, 0, fullW, fullH)
col(PAL.bgTop, 0.85)
love.graphics.circle("fill", ww * 0.5, wh * 0.15, math.max(ww, wh) * 0.55)
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
local pad = 18 * s
local barH = 56 * s
local btnH = 40 * s
local btnW = 100 * s
-- top bar
-- top bar (inside the safe area so it clears the notch / status bar)
col(PAL.card, 0.92)
love.graphics.rectangle("fill", 0, 0, ww, barH + pad)
love.graphics.rectangle("fill", 0, 0, fullW, oy + barH + pad)
col(PAL.stroke, 0.35)
love.graphics.setLineWidth(1)
love.graphics.line(0, barH + pad, ww, barH + pad)
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
love.graphics.setFont(Editor.fonts.title)
col(PAL.white)
love.graphics.print("Touch Controls", pad, pad + 4 * s)
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
-- Done / Reset
local done = { x = ww - pad - btnW, y = pad + (barH - btnH) / 2,
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
w = btnW, h = btnH }
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
Editor.rects.done, Editor.rects.reset = done, reset
@@ -156,9 +158,9 @@ function Editor.draw()
chromeBtn(done, "Done", PAL.green)
-- enable toggle card
local cardY = barH + pad + 14 * s
local cardY = oy + barH + pad + 14 * s
local cardH = 64 * s
local cardX, cardW = pad, ww - 2 * pad
local cardX, cardW = ox + pad, ww - 2 * pad
col(PAL.card, 0.88)
roundRect("fill", cardX, cardY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
@@ -190,7 +192,7 @@ function Editor.draw()
local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout."
love.graphics.printf(hint, pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw()
+2 -2
View File
@@ -1810,7 +1810,7 @@ function OverworldState:tryHiddenObject(fx, fy)
if h.x == fx and h.y == fy then
save.hiddenTaken = save.hiddenTaken or {}
if save.hiddenTaken[key] then return false end
if not require("src.inventory.Bag").add(save, h.item, 1) then
if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return true
end
@@ -2441,7 +2441,7 @@ function OverworldState:talkTo(npc)
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
-- the string "0" as truthy, so screen it out and fall through to text.
if d.item and d.item ~= "0" and d.item ~= 0 then
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then
Game.stack:push(TextBox.new(Game, Strings("You can't carry\nany more items!")))
return
end
+6 -4
View File
@@ -2414,14 +2414,15 @@ local function sellItem(id)
return sold
end
-- Free up bag slots so `needed` NEW item kinds fit (Bag.CAPACITY is 20
-- slots; a stack of an item already held costs nothing). This is why
-- Free up bag slots so `needed` NEW item kinds fit (20 slots without a
-- capacity mod; a stack of an item already held costs nothing). This is why
-- every restock had been reporting "HYPER_POTION x0": the buy list
-- opened, the quantity was set, the engine said "no room" -- and the run
-- walked into the Mansion with FULL_HEALs but not one HP restore.
local function freeBagSlots(needed, where)
local used = #(G.save.bagOrder or {})
local free = 20 - used
local capacity = require("src.inventory.Bag").capacity(G.data)
local free = capacity - used
for _, id in ipairs(SELLABLE_JUNK) do
if free >= needed then break end
if ((G.save.inventory or {})[id] or 0) > 0 then
@@ -2632,7 +2633,8 @@ function ops.shop(s, where)
end
end
local used = #(G.save.bagOrder or {})
if newKinds > 0 and 20 - used < newKinds then
local capacity = require("src.inventory.Bag").capacity(G.data)
if newKinds > 0 and capacity - used < newKinds then
freeBagSlots(newKinds, where)
end
end
+62
View File
@@ -0,0 +1,62 @@
-- Unit coverage for the render.compose seam (D14: a public-API test names
-- the hook, gate_hooks supplies the no-mod parity, docs/modding.md documents
-- it). render.compose lets a mod take over window composition: a wrap that
-- returns true without calling next owns the whole window; a wrap that calls
-- next lets the engine's normal single-window composite run and decorates it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Runtime = require("src.mods.Runtime")
local Hooks = require("src.mods.Hooks")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local bus = Hooks.new()
Runtime.hooks = bus
-- the shape Renderer:endFrame hands the hook
local function fakeCtx()
return {
renderer = {}, worldCanvas = {}, uiCanvas = {},
worldActive = true, zones = {}, worldZones = nil,
ww = 480, wh = 432, ox = 0, oy = 12, scale = 3,
dpiX = 1, dpiY = 1, secondScreen = {},
}
end
-- takeover: a mod drawing its own layout returns true and never calls next,
-- so the engine's vanilla composite is skipped entirely
do
local vanillaRan, gotCtx = false, nil
bus:wrap("render.compose", function(next, renderer, ctx)
gotCtx = ctx
return true
end, 0, "ds-mod")
local handled = Runtime.call("render.compose",
function() vanillaRan = true; return false end,
{ tag = "renderer" }, fakeCtx())
T.eq(handled, true, "a mod returning true signals full window takeover")
T.eq(vanillaRan, false, "takeover skips the engine composite (vanilla not run)")
T.check(gotCtx ~= nil and gotCtx.ww == 480 and gotCtx.secondScreen ~= nil,
"the hook receives the frame ctx (metrics, canvases, secondScreen)")
bus.chains["render.compose"] = nil
end
-- decorate: a mod calling next lets the engine composite run, and the
-- engine's not-handled return (false) flows back through the chain
do
local vanillaRan = false
bus:wrap("render.compose", function(next, renderer, ctx)
return next()
end, 0, "ds-mod")
local handled = Runtime.call("render.compose",
function() vanillaRan = true; return false end,
{ tag = "renderer" }, fakeCtx())
T.eq(vanillaRan, true, "calling next runs the engine composite")
T.eq(handled, false, "the engine's not-handled return flows back through next")
bus.chains["render.compose"] = nil
end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
T.finish("render_compose_seam")
+8
View File
@@ -203,4 +203,12 @@ stub.mouse = {
stub.timer = { getTime = function() return 0 end }
-- Desktop / headless: full-window safe area (matches LÖVE's fallback).
stub.window = {
getSafeArea = function()
local ww, wh = stub.graphics.getDimensions()
return 0, 0, ww, wh
end,
}
return stub
+62
View File
@@ -716,6 +716,68 @@ do
check(actBattle:enemyAction().hooked == true,
"battle.enemy_action hook rewrites the choice")
unsub()
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip that
unsub = hooks:wrap("battle.catch_exp", function() return true end)
local catchExpParty = { Pokemon.new(Data, "BULBASAUR", 10) }
local catchExpGame = makeGame(catchExpParty)
local catchExpBattle = BattleState.newWild(catchExpGame, "RATTATA", 3)
catchExpBattle.enemy.mon = Pokemon.new(Data, "RATTATA", 3)
local expBeforeCatch = catchExpParty[1].exp
catchExpBattle:storeCaughtMon()
check(catchExpParty[1].exp > expBeforeCatch,
"battle.catch_exp hook pays out exp on a catch")
unsub()
-- battle.exp_award: a mod can replace the participant/EXP.ALL split
-- wholesale via ctx.applyShare
unsub = hooks:wrap("battle.exp_award", function(nextFn, ctx)
ctx.applyShare(ctx.alive[1], 999, "flatShare")
end)
local awardParty = { Pokemon.new(Data, "BULBASAUR", 10) }
local awardGame = makeGame(awardParty)
local awardBattle = BattleState.newWild(awardGame, "RATTATA", 3)
local expBeforeAward = awardParty[1].exp
awardBattle:awardExp()
check(awardParty[1].exp > expBeforeAward,
"battle.exp_award hook replaces the award split")
unsub()
end
-- ------- battle.low_health_alarm hook: mirrors the siren toggle
do
local Sound = require("src.core.Sound")
local calls = {}
local origStart, origStop = Sound.startLoop, Sound.stopLoop
Sound.startLoop = function(data, name) calls[#calls + 1] = { "start", name } end
Sound.stopLoop = function(name) calls[#calls + 1] = { "stop", name } end
local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 20) })
local battle = BattleState.newWild(game, "RATTATA", 5)
battle.player.mon.hp = 1
battle.player.shownHP = 1
local seenOn = nil
local unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
seenOn = ctx.on
return nextFn(ctx)
end)
battle:updateFx()
check(seenOn == true, "battle.low_health_alarm hook sees the alarm toggle on")
check(calls[#calls][1] == "start" and calls[#calls][2] == "Low_Health_Alarm",
"an unmodified hook still starts the siren loop")
unsub()
unsub = hooks:wrap("battle.low_health_alarm", function(nextFn, ctx)
ctx.on = false
return nextFn(ctx)
end)
battle:updateFx()
check(calls[#calls][1] == "stop", "a mod can force the alarm off before vanilla acts")
unsub()
Sound.startLoop, Sound.stopLoop = origStart, origStop
end
-- ------- battle events: the scripted sequence
+69
View File
@@ -0,0 +1,69 @@
-- T4: constants.bagSize controls the bag through the public mod API while
-- vanilla and existing-save behavior remain unchanged.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Bag = require("src.inventory.Bag")
local CAPACITY_MOD = {
["mods/fix_small_bag/manifest.json"] = [[{
"id": "fix_small_bag",
"name": "Fixture Small Bag",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/fix_small_bag/main.lua"] = [[
local mod = ...
mod.content.constants:patch("bagSize", 2)
]],
}
-- No-mod parity: the new lookup keeps the cartridge's 20-slot limit.
do
local data = T.fixtures.fresh()
local run = T.sdk.loadNone({ data = data })
T.eq(#run.errors, 0, "the no-mod baseline loads cleanly")
T.eq(Bag.capacity(data), 20, "the vanilla bag still has 20 slots")
T.eq(Bag.capacity({}), 20, "a stale dataset without bagSize falls back to 20")
run.release()
end
-- The public constants registry changes both the reported and enforced cap.
do
local data = T.fixtures.fresh()
local run = T.sdk.loadMods({ "mods/fix_small_bag" },
{ data = data, fs = T.sdk.memfs(CAPACITY_MOD) })
T.eq(#run.errors, 0, "the capacity mod loads cleanly")
T.eq(Bag.capacity(data), 2, "Bag.capacity reads merged constants.bagSize")
local save = { inventory = {} }
T.check(Bag.add(save, "FIX_POTION", 1, data), "the first item fits")
T.check(Bag.add(save, "FIX_BALL", 1, data), "the second item fits")
T.check(not Bag.add(save, "FIX_TM", 1, data),
"a new item is refused at the modded limit")
T.eq(Bag.slots(save), 2, "a refused add does not change the bag")
run.release()
end
-- Saves are dictionaries, not fixed arrays: lowering the active cap never
-- truncates an older or modded save. Existing stacks remain usable while a
-- new item waits until the player makes enough room.
do
local data = T.fixtures.fresh()
data.constants.bagSize = 1
local save = {
inventory = { FIX_POTION = 1, FIX_BALL = 1 },
bagOrder = { "FIX_POTION", "FIX_BALL" },
}
T.eq(Bag.slots(save), 2, "an over-cap save keeps all existing slots")
T.check(Bag.add(save, "FIX_POTION", 1, data),
"an over-cap save can still add to an existing stack")
T.eq(save.inventory.FIX_POTION, 2, "the existing stack is updated")
T.check(not Bag.add(save, "FIX_TM", 1, data),
"an over-cap save cannot add another item kind")
T.eq(Bag.slots(save), 2, "the compatibility path never drops items")
end
T.finish("bag_capacity")
+29 -1
View File
@@ -1696,6 +1696,7 @@ end
-- ---------------------------------------------------------------- touch controls layout (#327)
do
local TC = require("src.core.TouchControls")
local SafeArea = require("src.core.SafeArea")
local cfg = TC.normalizeConfig(nil)
eq(cfg.enabled, true, "touchControls default enabled")
check(cfg.positions == nil, "touchControls default positions nil")
@@ -1721,6 +1722,12 @@ do
check(L.a.cx > 200, "default A on right half")
check(L.dpad.cy > 400, "default d-pad in bottom half")
-- safe-area origin shifts defaults without changing relative layout
local Ls = TC.defaultLayout(400, 800, 20, 30)
eq(Ls.dpad.cx, L.dpad.cx + 20, "defaultLayout ox shifts controls")
eq(Ls.dpad.cy, L.dpad.cy + 30, "defaultLayout oy shifts controls")
eq(Ls.a.cx, L.a.cx + 20, "defaultLayout ox shifts A")
-- applyOptions + visible gate (no real images needed for the gate)
TC.enabled = true
TC.active = true
@@ -1743,16 +1750,37 @@ do
-- custom position applied through layout()
local g = love.graphics
local oldDim, oldFont = g.getDimensions, g.newFont
local oldSafe = love.window and love.window.getSafeArea
g.getDimensions = function() return 400, 800 end
g.newFont = function() return { getWidth = function() return 10 end,
getHeight = function() return 10 end } end
TC.layoutW, TC.layoutH, TC.L = nil, nil, nil
love.window = love.window or {}
love.window.getSafeArea = function() return 0, 0, 400, 800 end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
local lay = TC:layout()
eq(lay.dpad.cx, 100, "custom dpad cx = nx * ww")
eq(lay.dpad.cy, 600, "custom dpad cy = ny * wh")
-- inset safe area: custom positions stay inside the usable rect
love.window.getSafeArea = function() return 10, 40, 380, 720 end
TC.layoutW, TC.layoutH, TC.layoutOx, TC.layoutOy, TC.L = nil, nil, nil, nil, nil
lay = TC:layout()
eq(lay.dpad.cx, 10 + 0.25 * 380, "safe-area custom dpad cx")
eq(lay.dpad.cy, 40 + 0.75 * 720, "safe-area custom dpad cy")
check(lay.dpad.cy <= 40 + 720 - lay.dpad.w * 0.5 + 1e-6,
"safe-area dpad clears bottom inset")
local x, y, w, h = SafeArea.rect()
eq(x, 10, "SafeArea.rect x")
eq(y, 40, "SafeArea.rect y")
eq(w, 380, "SafeArea.rect w")
eq(h, 720, "SafeArea.rect h")
TC:clearPositions()
check(TC.positions == nil, "clearPositions wipes overrides")
g.getDimensions, g.newFont = oldDim, oldFont
if oldSafe then love.window.getSafeArea = oldSafe
else love.window.getSafeArea = nil end
end
-- ---------------------------------------------------------------- crit thresholds (CriticalHitTest)
+3 -2
View File
@@ -181,12 +181,13 @@ end
do
-- the bag has a hard slot cap; the picker must refuse past it
local S = newState()
local capacity = Bag.capacity(S.data)
local added = 0
for _, id in ipairs(S.cat.items) do
if not Ops.isBadgeId(id) and Ops.addToBag(S, id) then added = added + 1 end
if added >= Bag.CAPACITY then break end
if added >= capacity then break end
end
eq(Bag.slots(S.save), Bag.CAPACITY, "the bag filled to its cap")
eq(Bag.slots(S.save), capacity, "the bag filled to its cap")
S.dirty = false
local spare
for _, id in ipairs(S.cat.items) do
+1 -1
View File
@@ -443,7 +443,7 @@ local function tabCount(id)
return tostring(n)
elseif id == "items" then
local Bag = require("src.inventory.Bag")
return ("%d/%d"):format(Bag.slots(S.save), Bag.CAPACITY)
return ("%d/%d"):format(Bag.slots(S.save), Bag.capacity(S.data))
elseif id == "events" then
local n = 0
for _ in pairs(S.save.flags or {}) do n = n + 1 end
+8 -5
View File
@@ -8,7 +8,8 @@
--
-- Clamps mirror the running game, not the UI: level 1-100, DV 0-15, party 6
-- (src/pokemon/Party), box 20 x 12 (src/pokemon/Boxes), money 0-999999,
-- item stack 99 and 20 bag slots (src/inventory/Bag).
-- item stack 99 and the configured bag capacity (20 by default;
-- src/inventory/Bag).
local Pokemon = require("src.pokemon.Pokemon")
local PartyMod = require("src.pokemon.Party")
@@ -441,11 +442,13 @@ end
function Ops.addToBag(S, id)
if not id then return Ops.say(S, "Pick an item first") end
if Bag.add(S.save, id, 1) then
local capacity = Bag.capacity(S.data)
if Bag.add(S.save, id, 1, S.data) then
return Ops.mark(S, ("Added %s to the bag (%d/%d slots)")
:format(id, Bag.slots(S.save), Bag.CAPACITY))
:format(id, Bag.slots(S.save), capacity))
end
return Ops.say(S, ("Bag is full (%d/%d slots)"):format(Bag.slots(S.save), Bag.CAPACITY))
return Ops.say(S, ("Bag is full (%d/%d slots)")
:format(Bag.slots(S.save), capacity))
end
function Ops.bagAdjust(S, id, delta)
@@ -455,7 +458,7 @@ function Ops.bagAdjust(S, id, delta)
if have >= Ops.STACK_MAX then
return Ops.say(S, ("%s is already at x%d"):format(id, Ops.STACK_MAX))
end
Bag.add(S.save, id, delta)
Bag.add(S.save, id, delta, S.data)
else
Bag.remove(S.save, id, -delta)
if not S.save.inventory[id] then
+4 -3
View File
@@ -1,4 +1,4 @@
-- Items panel: money, the shared item picker, badges, the 20-slot bag
-- Items panel: money, the shared item picker, badges, the configurable bag
-- (Bag.add/remove, ordered by Bag.order) and PC item storage (a plain
-- S.save.pcItems dict with no slot cap).
--
@@ -178,12 +178,13 @@ function M.draw(S, Kit, x, y, w, h)
-- --------------------------------------------------------------- bag
local order = Bag.order(S.save)
local capacity = Bag.capacity(S.data)
Kit.card(bagX, y, listW, h)
Kit.caption(bagX + pad, y + pad, "BAG")
Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), Bag.CAPACITY),
Kit.textRight("mono", ("%d/%d slots"):format(Bag.slots(S.save), capacity),
bagX + listW - pad, y + pad, PAL.caption)
local barY = y + pad + Kit.textHeight("caption") + 8 * s
local slotFrac = Bag.slots(S.save) / Bag.CAPACITY
local slotFrac = Bag.slots(S.save) / capacity
Kit.meter(bagX + pad, barY, listW - 2 * pad, 5 * s, slotFrac * 100,
slotFrac >= 1 and PAL.yellow or PAL.blue)