diff --git a/docs/manual/10-waypoints.md b/docs/manual/10-waypoints.md index ccd3765..5f1159b 100644 --- a/docs/manual/10-waypoints.md +++ b/docs/manual/10-waypoints.md @@ -83,6 +83,8 @@ that Ctrl+click therefore also inverts snapping). * **Backspace** (while the tool is active) or **Delete** / **Edit → Delete** removes the selected nodes and **relinks** the chain: the previous node now leads to the following one. The next node becomes selected, so you can delete several in a row. +* **Esc** clears the waypoint selection; pressing it again (or **Q**) leaves the Waypoint tool + and returns to Select. * Moving the owner object with the Place tool moves the whole chain along (option *Move waypoints along*). diff --git a/docs/manual/15-build-play.md b/docs/manual/15-build-play.md index 715213c..5d3bc7e 100644 --- a/docs/manual/15-build-play.md +++ b/docs/manual/15-build-play.md @@ -47,15 +47,21 @@ status bar keeps the result of the last build; click it to open the Problems pan **Level → Play** (**F5**, toolbar **Play**) does everything in one go: 1. builds the level, -2. installs it in LlamaMod's `levels` folder as a level mod (next to the mods you have switched - on), +2. installs a **temporary copy** of it as a level mod (next to the mods you have switched on), 3. prepares the play-test save slot (unlocked up to your level), 4. starts the game **directly in the level** – no logos, no menus, no map. ![The game started straight in the level](img/play_boot.jpg) In the game, press **Escape** to quit straight back to the editor. When the game has ended, -LlamaMod switches your normal mod selection back on; the level itself stays installed. +LlamaMod switches your normal mod selection back on and deletes the temporary copy – play-tests +do not fill LlamaMod's mod list. While the game runs, the copy shows up in LlamaMod as +"*name* (play-test)" with a blue **temporary play-test** badge. + +If the editor is closed before the game, the clean-up happens the next time the editor starts. +To keep a level in LlamaMod, use **Level → Add to LlamaMod…** (see +[Playing without the editor](#playing-without-the-editor)) or switch on **Keep play-tested level +in LlamaMod**. ### Play from the camera position @@ -75,6 +81,7 @@ dropping the llama into the void. The original level's opening fly-by is skipped |---|---|---| | **Play boots straight into the level** | on | Off: the game starts at the main menu with the map cursor on your level (choose *Spiel starten* → slot → Enter on the map); groove.exe is not changed. | | **Escape quits the play-test** | on | In a play-test the pause key ends the game at once. Off: Escape opens the normal pause menu. | +| **Keep play-tested level in LlamaMod** | off | On: Play installs the level as a normal level mod in the `levels` folder (as **Add to LlamaMod** does) and it stays switched on after the game. | | Play-test save slot (**Edit → Preferences**) | 3 | The slot the game plays with; it is backed up before it is changed. | > **Note:** Booting into the level uses the **Boot to level** mod, which adds code to LlamaMod's @@ -95,6 +102,12 @@ More in [Troubleshooting and FAQ](20-troubleshooting.md). ## Playing without the editor -The level is now a mod in LlamaMod's **Levels** category. To play it the normal way, tick it in -LlamaMod, click **Apply** and **Play**. New levels are on the world-9 (TestLevel) map – unlock +**Level → Add to LlamaMod…** copies the current state of the level into LlamaMod's `levels` +folder as a normal level mod – on the TestLevel chapter chosen in the +[Level settings](14-level-settings.md), or replacing its original chapter. Adding the same +project again updates that mod. To play it the normal way, tick it in LlamaMod's **Levels** +category, click **Apply** and **Play**. New levels are on the world-9 (TestLevel) map – unlock it in **Save games → Unlock TestLevel (world 9)**; edited original levels replace their chapter. + +A level you no longer want in LlamaMod is removed with **Remove…** on its card +(see [Mods](18-mods.md#level-mods)). diff --git a/docs/manual/17-sharing.md b/docs/manual/17-sharing.md index 873403c..a87058d 100644 --- a/docs/manual/17-sharing.md +++ b/docs/manual/17-sharing.md @@ -23,11 +23,12 @@ Any of these works: * **Copy the file into the `levels` folder** of LlamaMod's data folder (**Settings → Data folder → Open folder**, then `levels`) and restart LlamaMod. The level appears in the **Levels** category of the **Mods** tab. -* **Open it in the editor** (**File → Open project**) and press **F5**: the play-test installs it - in the `levels` folder and starts it. +* **Open it in the editor** (**File → Open project**) and choose **Level → Add to LlamaMod…**. + (**F5** only play-tests it: the copy is removed again when the game ends.) * From the command line: `LlamaMod --import-level `. -Then tick the level in LlamaMod, click **Apply** and **▶ Play**. +Then tick the level in LlamaMod, click **Apply** and **▶ Play**. To get rid of it again, click +**Remove…** on its card – the file is moved to the backups, see [Mods](18-mods.md#level-mods). ## Where the level appears in the game diff --git a/docs/manual/18-mods.md b/docs/manual/18-mods.md index d7f73a4..f14b8aa 100644 --- a/docs/manual/18-mods.md +++ b/docs/manual/18-mods.md @@ -39,6 +39,22 @@ option: Edited original levels have no options – they replace their chapter. See [Sharing levels](17-sharing.md). +**Removing a level:** click **Remove…** on its card. After you confirm, the project file is moved +to `backups/levels//` in the data folder – nothing is deleted, and you can copy it back +into `levels` to get the level again. This is refused while the game runs. If the level was +active, it is taken out of the selection and LlamaMod asks you to click **Apply**. Careful: the +editor saves projects in the `levels` folder by default, so the file may also be your editor +project – it is then in the backup folder. + +**Open levels folder** (in the **Levels** header) shows the folder in your file manager. + +Levels you play-test in the editor are only installed for the play-test and disappear when the +game ends (while it runs they show a **temporary play-test** badge). Older LlamaMod versions +kept every play-tested level; those files are ordinary level mods now – remove the ones you do +not want with **Remove…**. + +![Removing a level](img/levels_remove.jpg) + ## Command line LlamaMod can be scripted: @@ -48,6 +64,7 @@ LlamaMod --mods list all mods with their optio LlamaMod --apply fog,mirror --opt fog.distance=400 apply mods with options LlamaMod --apply restore the original game LlamaMod --launch start the game +LlamaMod --remove-level level-my-level move a level mod's file to backups/levels/ ``` Option values are checked and saved like in the window. diff --git a/docs/manual/19-shortcuts.md b/docs/manual/19-shortcuts.md index 26de492..d8eac34 100644 --- a/docs/manual/19-shortcuts.md +++ b/docs/manual/19-shortcuts.md @@ -219,10 +219,12 @@ Every menu command with its shortcut and a short description. | Level settings… | | Name, chapter, grid size, death plane, feature flags | | Check for problems | | List problems that would break the level (missing start, exits …) | | Build | **Ctrl+B** | Validate the level and build its WAD (in the background) | -| Play | **F5** | Build the level, install it as a mod and start the game directly in it (no menus; your mod selection is restored when the game exits) | +| Play | **F5** | Build the level, install a temporary copy and start the game directly in it (no menus; when the game exits, the copy is removed and your mod selection restored) | | Play from camera position | **Shift+F5** | Play-test with the llama standing on the ground point in the middle of the view, facing the camera direction | | Play boots straight into the level (on/off) | | Off: Play starts at the main menu with the map cursor on the level (no change to groove.exe) | | Escape quits the play-test (on/off) | | In a play-test the pause key (Escape) ends the game at once, back to the editor | +| Keep play-tested level in LlamaMod (on/off) | | Off: Play installs a temporary copy that is removed when the game exits. On: the level stays in LlamaMod's mod list (Mods → Levels) after testing | +| Add to LlamaMod… | | Copy the level into LlamaMod's level folder as a normal level mod (on its TestLevel chapter or original slot), to play it from LlamaMod or share the mod list | | Bake lighting… | | | | Environment zones | | | | Visibility regions | | | diff --git a/docs/manual/img/levels_remove.jpg b/docs/manual/img/levels_remove.jpg new file mode 100644 index 0000000..0e33897 Binary files /dev/null and b/docs/manual/img/levels_remove.jpg differ diff --git a/notes/editor-playtest.md b/notes/editor-playtest.md index 39c3643..b52fbdf 100644 --- a/notes/editor-playtest.md +++ b/notes/editor-playtest.md @@ -15,17 +15,26 @@ seconds after the game window opens, and **Escape** quits back to the editor. | Play boots straight into the level | Level menu (check) | off = old behaviour (main menu, map cursor) | | Escape quits the play-test | Level menu (check) | boot mod option `quitOnEscape` | -* Run: `PlayTest.build` (built-in, background) → the project snapshot is written to - `/levels/.llamalevel` → the user's **active** mods (others on the same WAD and Test +* Run: `PlayTest.build` (built-in, background) → the project snapshot is written to the + **temporary** `/levels/.playtest/.llamalevel` (mod id `playtest-`; with + "Keep play-tested level in LlamaMod" on: `/levels/.llamalevel` as before) → the user's **active** mods (others on the same WAD and Test World on chapter 1 turned off) + the level mod become `enabledMods`; `boot-to-level` is applied **in addition** (not stored in `enabledMods`) with world/chapter of the project, slot (Preferences, default 3), `logos=true`, `quitOnEscape`, `start` → save slot unlocked up to the level → `GameLauncher.launch`. * **Restore**: a daemon thread polls `GameLauncher.isRunning()` every 2 s; when the game has - ended it re-applies the user's selection without the boot mod (cached outputs, < 1 s) and - restores the boot mod's own options → LlamaMod's normal Play starts at the menu again - (`RestoreCheck`: active `[level-…, boot-to-level]` → `[level-…]`, exe without mod patches). - If the editor is closed before the game, LlamaMod shows the selection as "apply needed". + ended it re-applies the user's **previous** active mods and enabled list (stored in the + session: `mods`, `enabled`, `bootOptions`), restores the boot mod's own options and deletes + `levels/.playtest/` plus `playtest-*` entries in `enabledMods`/`modOptions` + (`LevelProjects.clearPlaytest`). With "keep" the level stays enabled/active (old behaviour). + The session file `/editor/playtest-session.json` lets the next editor start finish it; + without a session, the next editor start deletes leftover copies if no game runs + (`clearStale`). The non-boot Play (menu, map cursor) uses the same install/restore path now. +* **Level → Add to LlamaMod…**: writes the snapshot to `/levels/` (`PlayTest.installTarget`: + the same project's file is reused, file names compared case-insensitively) and sets the mod's + `chapter` option to the project's TestLevel chapter. +* The launcher hides `playtest-*` mods unless enabled/active (then: "(play-test)" + blue badge + "temporary play-test", no Remove button). * **Play from camera position**: ground point = depth-buffer hit at the viewport centre (export space → game x, y, −z), heading = camera yaw (`atan2(f.x, −f.z)`, 0 = +z). `FloorFinder` then looks for **collision**: highest floor polygon (|n.y| ≥ 0.5) at or below @@ -102,7 +111,17 @@ hardware is untested. ## Tests -`BootToLevelModTest` (hooks at both frame handlers, logo patch, exe re-parses; Escape hook only +`PlaytestLifecycleTest` (temporary copy listed as `playtest-…` → restore removes it and puts +the enabled list back; keep → stays; stale copies cleared; Remove → backups, refused while the +game runs; regression: `My-level`/`my-level` get distinct ids), `BootToLevelModTest` (hooks at both frame handlers, logo patch, exe re-parses; Escape hook only when asked; start parsing; original village: only the llama moves, file size unchanged; editor-style chain moves and turns with the llama), `RestoreCheck` (manual), `EdTour` + `PlayTourSteps` (scripted editor runs on an X display: camera placement, play-from-here). + +## Temporary play-test copies – verified in game (apptest lvlrm, game on :5, editor on :31, 2026-11-07) + +EdTour `run:level.play` on `/tmp/…/mytest.llamalevel` (real launch, private pulse): during the +game `levels/.playtest/mytest.llamalevel`, enabled `[playtest-mytest]`, active +`[playtest-mytest, boot-to-level]`, Testwelt plaza in game (`img/mods/levels_playtest_temporary.jpg`); +Escape → game gone → `.playtest` deleted, enabled `[]` and active `[level-old-village-test]` as +before the test, session file removed. Launcher badge: `img/mods/levels_playtest_badge.jpg`. diff --git a/notes/img/mods/levels_playtest_badge.jpg b/notes/img/mods/levels_playtest_badge.jpg new file mode 100644 index 0000000..3868c68 Binary files /dev/null and b/notes/img/mods/levels_playtest_badge.jpg differ diff --git a/notes/img/mods/levels_playtest_temporary.jpg b/notes/img/mods/levels_playtest_temporary.jpg new file mode 100644 index 0000000..cc67799 Binary files /dev/null and b/notes/img/mods/levels_playtest_temporary.jpg differ diff --git a/notes/img/mods/levels_remove_dialog.jpg b/notes/img/mods/levels_remove_dialog.jpg new file mode 100644 index 0000000..0e33897 Binary files /dev/null and b/notes/img/mods/levels_remove_dialog.jpg differ diff --git a/notes/img/mods/levels_removed.jpg b/notes/img/mods/levels_removed.jpg new file mode 100644 index 0000000..104ef36 Binary files /dev/null and b/notes/img/mods/levels_removed.jpg differ diff --git a/notes/llamamod-app.md b/notes/llamamod-app.md index 12946df..56e8659 100644 --- a/notes/llamamod-app.md +++ b/notes/llamamod-app.md @@ -39,6 +39,7 @@ Runtime modules: java.base, java.desktop, java.prefs, java.logging, jdk.charsets llamamod --version --install --mods --launch [--wait] llamamod --apply [id,id] [--opt id.key=value ...] llamamod --registry --saves --unlock [--cursor] +llamamod --import-level --remove-level ``` `--mods` lists every mod with category and its options (current value, type, range, default). `--opt` values are validated and saved in config.json like the GUI does. Errors (e.g. "The game is @@ -60,6 +61,8 @@ different folder chosen in the wizard is remembered via `java.util.prefs`. | `cd-active/` | what the game sees as its CD: hard links to originals + mod outputs | | `cache///` | mod outputs + `manifest.json` | | `backups/saves//` | save game backups (taken before every write) | +| `levels/` | level projects = level mods (`.playtest/`: temporary editor play-test copies) | +| `backups/levels//` | level mods removed with "Remove…" / `--remove-level` | | `wine/` | own Wine prefix (Linux) | | `logs/` | game.log, wineboot.log, registry.log | diff --git a/notes/mod-levels.md b/notes/mod-levels.md index bf88b3e..bd7e7dc 100644 --- a/notes/mod-levels.md +++ b/notes/mod-levels.md @@ -6,7 +6,7 @@ Code: `mods/levels/LevelProjectSource` (a `ModSource`), `LevelProjectMod`, `Leve | | | |---|---| -| id | `level-` (lower case, non-alphanumerics → `-`) | +| id | `level-` (lower case, non-alphanumerics → `-`); names that collide (`My-level` / `my-level`) get `-2`, `-3`… (first in name order keeps the plain id, a suffix never takes an id another file has) | | name / description | level name, description and author from the project | | version | first 16 hex digits of the file's SHA-256 (any change of the file rebuilds) | | order | 60: after Test World (50, chapter 1), before Mirror (75) and the debug view (100), so those also apply to the level | @@ -24,6 +24,18 @@ the lowest provided chapter fills all missing chapters 1..49 (the map crashes on chapter WAD), loading pictures as before. To play chapter n: unlock it in the save editor (or `--unlock 9 --cursor`), then select it on the world-9 map. +**Temporary play-test copies**: `levels/.playtest/*.llamalevel` are listed too, as +`playtest-` (`LevelProjectMod.playtest()`, name "… (play-test)", chapter = the project's +own, not counted for other levels' defaults). The editor deletes them after the play-test +(`LevelProjects.clearPlaytest`), see editor-playtest.md. Files from older play-tests in `levels/` +cannot be told apart from saved levels and are left alone (users remove them). + +**Removing** (`LevelProjects.remove`, launcher card "Remove…", CLI `--remove-level `): +moves the file to `/backups/levels//`, takes the id out of `enabledMods` +(`activeMods` stays → the launcher shows "Apply" pending), refused while the game runs and for +play-test copies. The Levels header has "Open levels folder". Screenshots: +`img/mods/levels_remove_dialog.jpg`, `levels_removed.jpg`. + CLI (`EditorCli`): `--editor-import`, `--editor-new`, `--editor-build`, `--import-level`, see editor-format.md. The registry reads the folder when it is created (restart the app / create a new `ModRegistry` after importing). diff --git a/src/main/java/com/eliasmueller/llamamod/Main.java b/src/main/java/com/eliasmueller/llamamod/Main.java index 892315e..de57455 100644 --- a/src/main/java/com/eliasmueller/llamamod/Main.java +++ b/src/main/java/com/eliasmueller/llamamod/Main.java @@ -7,6 +7,8 @@ import com.eliasmueller.llamamod.mods.ModOption; import com.eliasmueller.llamamod.mods.ModOptions; import com.eliasmueller.llamamod.mods.ModRegistry; import com.eliasmueller.llamamod.mods.levels.EditorCli; +import com.eliasmueller.llamamod.mods.levels.LevelProjectMod; +import com.eliasmueller.llamamod.mods.levels.LevelProjects; import com.eliasmueller.llamamod.platform.AppPaths; import com.eliasmueller.llamamod.platform.Config; import com.eliasmueller.llamamod.platform.GameLauncher; @@ -124,6 +126,7 @@ public final class Main { llamamod --editor-import [--cd ] llamamod --editor-new [base level] [--name ] [--cd ] llamamod --import-level (into the level folder, shown as a mod) + llamamod --remove-level move a level mod's file to backups/levels/ Data folder: -Dllamamod.data= or LLAMAMOD_DATA (default %s) """ .formatted(BuildInfo.display(), AppPaths.defaultRoot())); @@ -214,6 +217,20 @@ public final class Main { Path b = st.save(slot, k); System.out.println("saved" + (b != null ? ", backup: " + b : "")); } + case "--remove-level" -> { + if (rest.size() != 1) { + throw new UsageException("--remove-level expects one level mod id or file name"); + } + LevelProjectMod lm = LevelProjects.find(new ModRegistry(), rest.get(0)); + if (lm == null) { + throw new UsageException("no level mod \"" + rest.get(0) + "\" (see --mods, ids level-…)"); + } + LevelProjects.Removed r = LevelProjects.remove( + paths, cfg, lm, cfg.installed() && new GameLauncher(paths, cfg).isRunning()); + cfg.save(paths.config()); + System.out.println("removed " + lm.id() + ": " + lm.file().getFileName() + " moved to " + r.backup() + + (r.wasActive() ? "\nit was active – run --apply to update the game" : "")); + } case "--editor-build", "--editor-import", "--editor-new", "--import-level" -> { if (!args[0].equals("--import-level") && !Edition.editorEnabled()) { throw new UsageException("This LlamaMod build has no level editor."); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PathTool.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PathTool.java index ecee32e..e1ddc57 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PathTool.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/objects/PathTool.java @@ -246,6 +246,15 @@ public final class PathTool implements Tool { deleteSelected(ctx); return true; } + if (k == KeyEvent.VK_ESCAPE) { + if (!ctx.selection().of(PathNode.class).isEmpty()) { + ctx.selection().clear(ctx); + } else { + ctx.setTool("select"); + ctx.status("Waypoint editing finished"); + } + return true; + } return false; } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestEditorPlugin.java index 373269e..95698dc 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestEditorPlugin.java @@ -16,7 +16,8 @@ import org.joml.Vector3f; /** * Play-test straight into the level: replaces the built-in Play (F5) with a run that boots the * game directly into the level ({@link com.eliasmueller.llamamod.mods.boottolevel.BootToLevelMod}), - * adds "Play from camera position" (Shift+F5) and a switch back to the menu-based Play. + * adds "Play from camera position" (Shift+F5), a switch back to the menu-based Play, "Keep + * play-tested level in LlamaMod" and "Add to LlamaMod…". */ public final class PlaytestEditorPlugin implements EditorPlugin { @Override @@ -36,8 +37,8 @@ public final class PlaytestEditorPlugin implements EditorPlugin { }); x.action(ActionSpec.of("level.play", "Level", 20, 30, "Play", "play", "F5", c -> PlaytestRunner.play(c, null)) .enabledWhen(PlaytestEditorPlugin::idle) - .tip("Build the level, install it as a mod and start the game directly in it" - + " (no menus; your mod selection is restored when the game exits)")); + .tip("Build the level, install a temporary copy and start the game directly in it" + + " (no menus; when the game exits, the copy is removed and your mod selection restored)")); x.action(ActionSpec.of( "level.playHere", "Level", @@ -74,6 +75,27 @@ public final class PlaytestEditorPlugin implements EditorPlugin { .checked(c -> c.prefs().getBool(PlaytestRunner.PREF_QUIT, true)) .enabledWhen(c -> c.prefs().getBool(PlaytestRunner.PREF_BOOT, true)) .tip("In a play-test the pause key (Escape) ends the game at once, back to the editor")); + x.action(ActionSpec.of( + "level.playKeep", "Level", 20, 34, "Keep play-tested level in LlamaMod", null, null, c -> { + c.prefs() + .put(PlaytestRunner.PREF_KEEP, !c.prefs().getBool(PlaytestRunner.PREF_KEEP, false)); + c.prefs().save(); + }) + .checked(c -> c.prefs().getBool(PlaytestRunner.PREF_KEEP, false)) + .tip("Off: Play installs a temporary copy that is removed when the game exits. On: the level" + + " stays in LlamaMod's mod list (Mods → Levels) after testing")); + x.action(ActionSpec.of( + "level.addToLlamaMod", + "Level", + 20, + 35, + "Add to LlamaMod…", + null, + null, + PlaytestRunner::addToLlamaMod) + .enabledWhen(PlaytestEditorPlugin::idle) + .tip("Copy the level into LlamaMod's level folder as a normal level mod (on its TestLevel" + + " chapter or original slot), to play it from LlamaMod or share the mod list")); x.toolbarAction("level.playHere", "build"); x.onOpen(PlaytestRunner::resumeRestore); } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java index 931f812..400edde 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestRunner.java @@ -1,5 +1,6 @@ package com.eliasmueller.llamamod.editor.tools.playtest; +import com.eliasmueller.llamamod.editor.io.ProjectIO; import com.eliasmueller.llamamod.editor.ui.Document; import com.eliasmueller.llamamod.editor.ui.EditorContext; import com.eliasmueller.llamamod.editor.ui.EditorEnv; @@ -11,6 +12,7 @@ import com.eliasmueller.llamamod.mods.ModOptions; import com.eliasmueller.llamamod.mods.ModRegistry; import com.eliasmueller.llamamod.mods.boottolevel.BootToLevelMod; import com.eliasmueller.llamamod.mods.levels.LevelProjectMod; +import com.eliasmueller.llamamod.mods.levels.LevelProjects; import com.eliasmueller.llamamod.platform.Config; import com.eliasmueller.llamamod.platform.GameLauncher; import com.eliasmueller.llamamod.platform.Json; @@ -30,15 +32,16 @@ import javax.swing.JOptionPane; import javax.swing.SwingUtilities; /** - * Play-test with "boot to level": build → install the level mod (as the built-in Play does) → - * apply the user's mods plus {@link BootToLevelMod} (world/chapter of the level, optional player - * start) → start the game, which loads the level without menus. Once the game has exited, the - * user's own selection is applied again (without the boot mod), so LlamaMod's normal Play starts - * at the main menu as before. + * Play-test: build → install a temporary copy of the level in {@code levels/.playtest/} (or, with + * "Keep in LlamaMod", as a normal level mod) → apply the user's mods plus the level and, with + * "boot to level", {@link BootToLevelMod} (world/chapter of the level, optional player start) → + * start the game. Once the game has exited, the user's own selection is applied again and the + * temporary copy is deleted, so LlamaMod looks exactly as before. */ public final class PlaytestRunner { static final String PREF_BOOT = "playtest.boot"; static final String PREF_QUIT = "playtest.quitOnEscape"; + static final String PREF_KEEP = "playtest.keep"; private static volatile Thread watcher; @@ -67,10 +70,6 @@ public final class PlaytestRunner { return; } boolean boot = start != null || ctx.prefs().getBool(PREF_BOOT, true); - if (!boot) { - w.playTest().play(); - return; - } EditorEnv env = ctx.env(); if (!env.config().installed()) { message(ctx, "The game is not installed. Set it up in LlamaMod first.", JOptionPane.WARNING_MESSAGE); @@ -88,7 +87,7 @@ public final class PlaytestRunner { if (!quitRunningGame(ctx, env)) { return; } - w.playTest().build(r -> install(w, d, tgt, start)); + w.playTest().build(r -> install(w, d, tgt, start, boot)); } /** @@ -141,12 +140,34 @@ public final class PlaytestRunner { JOptionPane.showMessageDialog(ctx.frame(), text, "Play-test", type); } - /** What the play-test changed, to undo it after the game. */ - record Session(List userMods, Map bootOptions) {} + /** + * What the play-test changed, to undo it after the game: the mods to apply again, the boot mod's + * options and the enabled mods to put back (null = keep the play-test's selection, "Keep in + * LlamaMod"). + */ + record Session(List userMods, Map bootOptions, List enabled) { + Session(List userMods, Map bootOptions) { + this(userMods, bootOptions, null); + } + } - private static void install(EditorWindow w, Document d, int[] tgt, double[] start) { + /** Where the play-test copy goes: a normal level mod (keep) or a temporary file in levels/.playtest/. */ + static PlayTest.Install installFor(EditorContext ctx, Document d, boolean keep) { + PlayTest.Install perm = PlayTest.install(ctx, d); + if (perm == null || keep) { + return perm; + } + String stem = d.file() != null + ? d.file().getFileName().toString().replaceFirst("(?i)\\.llamalevel$", "") + : PlayTest.slug(d.title()); + Path t = LevelProjects.playtestDir(ctx.env().levelsDir()).resolve(stem + ProjectIO.EXTENSION); + return new PlayTest.Install(perm.snapshot(), t, false); + } + + private static void install(EditorWindow w, Document d, int[] tgt, double[] start, boolean boot) { EditorEnv env = w.env(); - PlayTest.Install inst = PlayTest.install(w, d); + boolean keep = w.prefs().getBool(PREF_KEEP, false); + PlayTest.Install inst = installFor(w, d, keep); if (inst == null) { return; } @@ -158,24 +179,45 @@ public final class PlaytestRunner { w.runTask( "Play-test " + d.title(), p -> { - p.update(0.05, "Installing the level as a mod"); - inst.write(); + p.update(0.05, keep ? "Installing the level as a mod" : "Installing a temporary copy of the level"); Config cfg = env.config(); + // leftovers of an earlier play-test whose restore did not run + LevelProjects.clearPlaytest(env.levelsDir(), cfg); + List prevActive = new ArrayList<>(cfg.activeMods); + prevActive.removeIf(LevelProjects::isPlaytestId); + List prevEnabled = new ArrayList<>(cfg.enabledMods); + inst.write(); ModRegistry reg = new ModRegistry(); LevelProjectMod mine = PlayTest.levelMod(reg, cfg, target, tgt); List disabled = new ArrayList<>(); boolean userBoot = cfg.enabledMods.contains(BootToLevelMod.ID); - List mods = PlayTest.selectionWith(cfg, reg, mine, tgt, disabled, BootToLevelMod.ID); + List mods = + PlayTest.selectionWith(cfg, reg, mine, tgt, disabled, boot ? BootToLevelMod.ID : null); Mod bootMod = reg.get(BootToLevelMod.ID); if (bootMod == null) { bootMod = new BootToLevelMod(); } - if (userBoot) { + if (boot && userBoot) { mods.add(bootMod); } cfg.enabledMods = new ArrayList<>(mods.stream().map(Mod::id).toList()); Map before = cfg.modOptions.get(BootToLevelMod.ID); Map saved = before == null ? null : new LinkedHashMap<>(before); + Session session = + keep ? new Session(mods, saved) : new Session(resolve(reg, prevActive), saved, prevEnabled); + if (!boot) { + p.update(0.15, "Applying mods"); + env.modManager().apply(mods, p.sub(0.15, 0.85)); + env.saveConfig(); + p.update(0.86, "Preparing save slot " + slot); + PlayTest.prepareSaveSlot(env, slot, tgt); + p.update(0.9, "Starting the game"); + if (!noLaunch) { + writeSession(env, session); + new GameLauncher(env.paths(), cfg).launch(p.sub(0.9, 1)); + } + return new Object[] {session, disabled}; + } ModOptions.set(cfg, bootMod, "world", String.valueOf(tgt[0])); ModOptions.set(cfg, bootMod, "chapter", String.valueOf(tgt[1])); ModOptions.set(cfg, bootMod, "slot", String.valueOf(slot)); @@ -194,7 +236,6 @@ public final class PlaytestRunner { env.saveConfig(); p.update(0.86, "Preparing save slot " + slot); PlayTest.prepareSaveSlot(env, slot, tgt); - Session session = new Session(mods, saved); p.update(0.9, "Starting the game"); // tests start the game through their own harness (private display and sound) if (!noLaunch) { @@ -212,16 +253,48 @@ public final class PlaytestRunner { d.history().markSaved(); } env.modsChanged(); - w.status("Game started – it loads " + levelName + " directly" - + (start != null ? " (player at the camera position)" : "") - + (disabled.isEmpty() ? "" : " · turned off: " + String.join(", ", disabled))); + String off = disabled.isEmpty() ? "" : " · turned off: " + String.join(", ", disabled); + if (boot) { + w.status("Game started – it loads " + levelName + " directly" + + (start != null ? " (player at the camera position)" : "") + + off); + } else { + w.status("Game started – " + levelName + " is selected on the map of save slot " + slot + off); + JOptionPane.showMessageDialog( + w, + "The game is starting.

" + + "In the main menu choose Spiel starten and save slot " + slot + + " – the map cursor is already on " + esc(levelName) + + "; press Enter to play.

" + + (keep + ? "The level stays installed in LlamaMod (Mods → Levels)." + : "The level is installed only for this play-test; use Level → Add to" + + " LlamaMod to keep it.") + + "", + "Play-test", + JOptionPane.INFORMATION_MESSAGE); + } if (!noLaunch) { watch(w, env, session, true); } }); } - /** The user's active mods (other mods on the same WAD and Test World on chapter 1 turned off) + the level. */ + private static String esc(String s) { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } + + private static List resolve(ModRegistry reg, List ids) { + List out = new ArrayList<>(); + for (String id : ids) { + Mod m = reg.get(id); + if (m != null) { + out.add(m); + } + } + return out; + } + /** Waits until the game has exited, then applies the user's selection again (without the boot mod). */ private static void watch(EditorWindow w, EditorEnv env, Session s, boolean launching) { Thread t = new Thread( @@ -268,8 +341,12 @@ public final class PlaytestRunner { } else { cfg.modOptions.put(BootToLevelMod.ID, new LinkedHashMap<>(s.bootOptions())); } + if (s.enabled() != null) { + cfg.enabledMods = new ArrayList<>(s.enabled()); + } ModManager mm = env.modManager(); mm.apply(s.userMods(), Progress.NONE); + LevelProjects.clearPlaytest(env.levelsDir(), cfg); env.saveConfig(); Files.deleteIfExists(sessionFile(env)); } @@ -287,6 +364,7 @@ public final class PlaytestRunner { Map m = new LinkedHashMap<>(); m.put("mods", new ArrayList(s.userMods().stream().map(Mod::id).toList())); m.put("bootOptions", s.bootOptions() == null ? null : new LinkedHashMap(s.bootOptions())); + m.put("enabled", s.enabled() == null ? null : new ArrayList(s.enabled())); Path f = sessionFile(env); Files.createDirectories(f.getParent()); Files.writeString(f, Json.write(m)); @@ -302,13 +380,10 @@ public final class PlaytestRunner { if (!(Json.parse(Files.readString(f)) instanceof Map m) || !(m.get("mods") instanceof List ids)) { return null; } - List mods = new ArrayList<>(); - for (Object id : ids) { - Mod mod = reg.get(String.valueOf(id)); - if (mod != null) { - mods.add(mod); - } - } + List mods = resolve(reg, ids.stream().map(String::valueOf).toList()); + List enabled = m.get("enabled") instanceof List en + ? new ArrayList<>(en.stream().map(String::valueOf).toList()) + : null; Map boot = null; if (m.get("bootOptions") instanceof Map b) { boot = new LinkedHashMap<>(); @@ -316,7 +391,7 @@ public final class PlaytestRunner { boot.put(String.valueOf(e.getKey()), String.valueOf(e.getValue())); } } - return new Session(mods, boot); + return new Session(mods, boot, enabled); } catch (IOException | RuntimeException e) { return null; } @@ -329,6 +404,7 @@ public final class PlaytestRunner { } EditorEnv env = ctx.env(); if (!Files.isRegularFile(sessionFile(env))) { + clearStale(env); return; } Session s = readSession(env, new ModRegistry()); @@ -342,4 +418,63 @@ public final class PlaytestRunner { } watch(w, env, s, false); } + + /** Removes play-test copies left behind without a pending session (editor closed before the game started). */ + static void clearStale(EditorEnv env) { + if (!Files.isDirectory(LevelProjects.playtestDir(env.levelsDir())) + || new GameLauncher(env.paths(), env.config()).isRunning()) { + return; + } + try { + synchronized (PlaytestRunner.class) { + LevelProjects.clearPlaytest(env.levelsDir(), env.config()); + env.saveConfig(); + } + } catch (IOException e) { + // tried again next start + } + } + + /** + * Level → Add to LlamaMod: writes the current state into the levels folder as a normal level mod + * (reusing the file of the same project) with its TestLevel chapter as the mod's option. + */ + static void addToLlamaMod(EditorContext ctx) { + Document d = ctx.document(); + if (d == null || !(ctx instanceof EditorWindow w)) { + return; + } + PlayTest.Install inst = PlayTest.install(ctx, d); + if (inst == null) { + return; + } + EditorEnv env = ctx.env(); + int[] tgt = target(d); + w.runTask( + "Add " + d.title() + " to LlamaMod", + p -> { + inst.write(); + ModRegistry reg = new ModRegistry(); + LevelProjectMod m = tgt == null ? null : PlayTest.levelMod(reg, env.config(), inst.target(), tgt); + env.saveConfig(); + return m == null ? d.title() : m.name(); + }, + res -> { + if (inst.ownFile()) { + d.history().markSaved(); + } + env.modsChanged(); + String where = tgt == null ? "" : " on " + PlayTest.levelName(tgt); + w.status("Added to LlamaMod: " + inst.target().getFileName() + where); + JOptionPane.showMessageDialog( + w, + "" + esc(String.valueOf(res)) + + " is now a level mod in LlamaMod" + esc(where) + ".

File: " + + esc(inst.target().toString()) + + "

Turn it on under Mods → Levels and click Apply. Remove it there with" + + " “Remove…”.", + "Add to LlamaMod", + JOptionPane.INFORMATION_MESSAGE); + }); + } } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/ui/PlayTest.java b/src/main/java/com/eliasmueller/llamamod/editor/ui/PlayTest.java index 4c7fb00..c8cda68 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/ui/PlayTest.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/ui/PlayTest.java @@ -10,6 +10,7 @@ import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModOptions; import com.eliasmueller.llamamod.mods.ModRegistry; import com.eliasmueller.llamamod.mods.levels.LevelProjectMod; +import com.eliasmueller.llamamod.mods.levels.LevelProjects; import com.eliasmueller.llamamod.platform.Config; import com.eliasmueller.llamamod.platform.GameLauncher; import com.eliasmueller.llamamod.save.Ksv; @@ -198,7 +199,8 @@ public final class PlayTest { var st = d.scene().settings(); Path own = d.file() == null ? null : d.file().toAbsolutePath().normalize(); for (int i = 1; ; i++) { - Path t = levelsDir.resolve((i == 1 ? stem : stem + "-" + i) + ProjectIO.EXTENSION); + // an existing file whose name differs only in case is the same target (Windows sees one file) + Path t = LevelProjects.resolveIgnoreCase(levelsDir, (i == 1 ? stem : stem + "-" + i) + ProjectIO.EXTENSION); if (!Files.exists(t) || t.toAbsolutePath().normalize().equals(own)) { return t; } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectMod.java b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectMod.java index 8196132..fdd349d 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectMod.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectMod.java @@ -31,16 +31,23 @@ public final class LevelProjectMod implements Mod { private final int defaultChapter; private final String id; + private final boolean playtest; - LevelProjectMod(Path file, byte[] data, ProjectInfo info, int defaultChapter) { + LevelProjectMod(Path file, byte[] data, ProjectInfo info, int defaultChapter, String id, boolean playtest) { this.file = file; this.info = info; this.hash = ProjectIO.sha256(data).substring(0, 16); this.stamp = stamp(file); this.defaultChapter = defaultChapter; + this.id = id; + this.playtest = playtest; + } + + /** {@code level-} ({@code playtest-} for temporary play-test copies), lower case. */ + static String baseId(Path file, boolean playtest) { String fn = file.getFileName().toString(); String stem = fn.substring(0, fn.length() - ProjectIO.EXTENSION.length()); - this.id = "level-" + return (playtest ? "playtest-" : "level-") + stem.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", ""); } @@ -48,6 +55,11 @@ public final class LevelProjectMod implements Mod { return file; } + /** A temporary copy installed by an editor play-test (removed when the play-test ends). */ + public boolean playtest() { + return playtest; + } + public ProjectInfo info() { return info; } @@ -59,7 +71,8 @@ public final class LevelProjectMod implements Mod { @Override public String name() { - return info.name().isBlank() ? file.getFileName().toString() : info.name(); + String n = info.name().isBlank() ? file.getFileName().toString() : info.name(); + return playtest ? n + " (play-test)" : n; } @Override @@ -69,7 +82,9 @@ public final class LevelProjectMod implements Mod { : "Changes the original level " + info.base() + "."; String d = info.description().isBlank() ? "" : info.description() + " "; String by = info.author().isBlank() ? "" : " By " + info.author() + "."; - return d + where + by + " Project: " + file.getFileName(); + return playtest + ? "Temporary play-test copy from the level editor – removed when the play-test ends. " + where + : d + where + by + " Project: " + file.getFileName(); } @Override diff --git a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectSource.java b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectSource.java index 961c973..d46a51f 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectSource.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjectSource.java @@ -16,7 +16,8 @@ import java.util.Set; /** * One mod per {@code .llamalevel} file in {@code /levels/} (category "levels"). New levels * get a default TestLevel chapter: their stored target, or the next free one if another project - * already uses it. + * already uses it. Temporary play-test copies in {@code levels/.playtest/} are listed too, with + * ids {@code playtest-…}. */ public final class LevelProjectSource implements ModSource { private final Path dir; @@ -33,32 +34,55 @@ public final class LevelProjectSource implements ModSource { public List mods() { List out = new ArrayList<>(); Set used = new HashSet<>(); + List levels = LevelProjects.list(dir); + List tests = LevelProjects.list(LevelProjects.playtestDir(dir)); + Set bases = new HashSet<>(); + levels.forEach(p -> bases.add(LevelProjectMod.baseId(p, false))); + tests.forEach(p -> bases.add(LevelProjectMod.baseId(p, true))); Set ids = new HashSet<>(); - for (Path p : LevelProjects.list(dir)) { - byte[] d; - ProjectInfo info; - try { - d = Files.readAllBytes(p); - info = ProjectIO.info(d); - } catch (IOException | RuntimeException e) { - continue; - } - int chapter = 0; - if (info.isNewLevel()) { - chapter = info.targetChapter(); - if (chapter < 1 || chapter > 49 || used.contains(chapter)) { - chapter = 2; - while (used.contains(chapter) && chapter < 49) { - chapter++; - } - } - used.add(chapter); - } - LevelProjectMod m = new LevelProjectMod(p, d, info, chapter); - if (ids.add(m.id())) { - out.add(m); - } + for (Path p : levels) { + add(p, false, used, bases, ids, out); + } + for (Path p : tests) { + add(p, true, used, bases, ids, out); } return out; } + + private static void add( + Path p, boolean playtest, Set used, Set bases, Set ids, List out) { + byte[] d; + ProjectInfo info; + try { + d = Files.readAllBytes(p); + info = ProjectIO.info(d); + } catch (IOException | RuntimeException e) { + return; + } + int chapter = 0; + if (info.isNewLevel() && playtest) { + chapter = Math.max(1, Math.min(49, info.targetChapter())); + } else if (info.isNewLevel()) { + chapter = info.targetChapter(); + if (chapter < 1 || chapter > 49 || used.contains(chapter)) { + chapter = 2; + while (used.contains(chapter) && chapter < 49) { + chapter++; + } + } + used.add(chapter); + } + // names differing only in case or punctuation share an id: later ones get a suffix no other file has + String base = LevelProjectMod.baseId(p, playtest); + String id = base; + if (!ids.add(id)) { + int i = 2; + while (bases.contains(base + "-" + i) || ids.contains(base + "-" + i)) { + i++; + } + id = base + "-" + i; + ids.add(id); + } + out.add(new LevelProjectMod(p, d, info, chapter, id, playtest)); + } } diff --git a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjects.java b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjects.java index 5d8923e..fa9c9e5 100644 --- a/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjects.java +++ b/src/main/java/com/eliasmueller/llamamod/mods/levels/LevelProjects.java @@ -2,19 +2,30 @@ package com.eliasmueller.llamamod.mods.levels; import com.eliasmueller.llamamod.editor.io.ProjectIO; import com.eliasmueller.llamamod.editor.io.ProjectInfo; +import com.eliasmueller.llamamod.mods.Mod; +import com.eliasmueller.llamamod.mods.ModRegistry; import com.eliasmueller.llamamod.platform.AppPaths; +import com.eliasmueller.llamamod.platform.Config; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.stream.Stream; -/** The level project folder {@code /levels/} and importing project files into it. */ +/** + * The level project folder {@code /levels/}: importing project files into it, removing them + * (into {@code /backups/levels//}) and the temporary play-test copies in {@code + * levels/.playtest/}. + */ public final class LevelProjects { public static final String DIR = "levels"; + public static final String PLAYTEST_DIR = ".playtest"; + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); private LevelProjects() {} @@ -22,6 +33,111 @@ public final class LevelProjects { return paths.root().resolve(DIR); } + /** Folder of the temporary play-test copies inside a levels folder. */ + public static Path playtestDir(Path levelsDir) { + return levelsDir.resolve(PLAYTEST_DIR); + } + + /** The file in dir whose name equals name ignoring case (the first in name order), else dir/name. */ + public static Path resolveIgnoreCase(Path dir, String name) { + Path exact = dir.resolve(name); + if (Files.exists(exact)) { + return exact; + } + for (Path p : list(dir)) { + if (p.getFileName().toString().equalsIgnoreCase(name)) { + return p; + } + } + return exact; + } + + /** + * Deletes the play-test copies and their entries in the config (enabled mods, options). Active + * mods are left alone: they describe what is applied. + */ + public static void clearPlaytest(Path levelsDir, Config cfg) throws IOException { + Path dir = playtestDir(levelsDir); + if (Files.isDirectory(dir)) { + try (Stream s = Files.list(dir)) { + for (Path p : s.toList()) { + if (Files.isRegularFile(p)) { + Files.deleteIfExists(p); + } + } + } + try { + Files.deleteIfExists(dir); + } catch (IOException e) { + // not empty (foreign files): keep it + } + } + cfg.enabledMods.removeIf(LevelProjects::isPlaytestId); + cfg.modOptions.keySet().removeIf(LevelProjects::isPlaytestId); + } + + public static boolean isPlaytestId(String id) { + return id.startsWith("playtest-"); + } + + /** A permanent level mod by id or by file (name in the levels folder, or a path). */ + public static LevelProjectMod find(ModRegistry reg, String idOrFile) { + Mod byId = reg.get(idOrFile); + if (byId instanceof LevelProjectMod lm && !lm.playtest()) { + return lm; + } + for (Mod m : reg.all()) { + if (m instanceof LevelProjectMod lm && !lm.playtest()) { + String fn = lm.file().getFileName().toString(); + if (fn.equals(idOrFile) + || fn.equals(idOrFile + ProjectIO.EXTENSION) + || lm.file() + .toAbsolutePath() + .normalize() + .equals(Path.of(idOrFile).toAbsolutePath().normalize())) { + return lm; + } + } + } + return null; + } + + /** Outcome of {@link #remove}: where the file went and whether the mod was applied. */ + public record Removed(Path backup, boolean wasActive) {} + + /** + * Moves a level mod's file to {@code /backups/levels//} and takes it out of the + * enabled mods. Refused while the game runs. If it was applied, the selection is pending until + * the next apply. + */ + public static Removed remove(AppPaths paths, Config cfg, LevelProjectMod mod, boolean gameRunning) + throws IOException { + if (gameRunning) { + throw new IOException("The game is running – quit it before removing a level."); + } + if (mod.playtest()) { + throw new IOException( + mod.name() + " is a temporary play-test copy; it is removed when the play-test ends."); + } + Path f = mod.file(); + if (!Files.isRegularFile(f)) { + throw new IOException(f + " does not exist (any more)"); + } + Path dir = paths.backups().resolve(DIR).resolve(LocalDateTime.now().format(TS)); + Files.createDirectories(dir); + Path out = dir.resolve(f.getFileName()); + for (int i = 2; Files.exists(out); i++) { + out = dir.resolve(i + "-" + f.getFileName()); + } + try { + Files.move(f, out, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + Files.move(f, out); + } + cfg.enabledMods.remove(mod.id()); + return new Removed(out, cfg.activeMods.contains(mod.id())); + } + /** Project files in the folder, sorted by name. */ public static List list(Path dir) { List out = new ArrayList<>(); diff --git a/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java b/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java index f425999..4814dc2 100644 --- a/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/ui/ModsPanel.java @@ -4,6 +4,8 @@ import com.eliasmueller.llamamod.mods.Mod; import com.eliasmueller.llamamod.mods.ModManager; import com.eliasmueller.llamamod.mods.ModOption; import com.eliasmueller.llamamod.mods.ModOptions; +import com.eliasmueller.llamamod.mods.levels.LevelProjectMod; +import com.eliasmueller.llamamod.mods.levels.LevelProjects; import com.eliasmueller.llamamod.util.SwingListeners; import java.awt.BorderLayout; import java.awt.Color; @@ -18,6 +20,8 @@ import java.awt.Point; import java.awt.Rectangle; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; +import java.io.IOException; +import java.nio.file.Files; import java.util.ArrayList; import java.util.HashSet; import java.util.LinkedHashMap; @@ -33,6 +37,7 @@ import javax.swing.JCheckBox; import javax.swing.JComboBox; import javax.swing.JComponent; import javax.swing.JLabel; +import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSpinner; @@ -51,6 +56,7 @@ final class ModsPanel extends JPanel { private final MainFrame frame; private final JPanel list = new JPanel(); private final Map boxes = new LinkedHashMap<>(); + private final List removeButtons = new ArrayList<>(); private final List optionInputs = new ArrayList<>(); private final Map groupCounts = new LinkedHashMap<>(); private final Set collapsed = new HashSet<>(); @@ -135,6 +141,7 @@ final class ModsPanel extends JPanel { applyButton.setEnabled(on); restoreButton.setEnabled(on); boxes.values().forEach(cb -> cb.setEnabled(on)); + removeButtons.forEach(b -> b.setEnabled(on)); optionInputs.forEach(c -> c.setEnabled(on)); } @@ -144,9 +151,10 @@ final class ModsPanel extends JPanel { firstReload = false; list.removeAll(); boxes.clear(); + removeButtons.clear(); optionInputs.clear(); groupCounts.clear(); - List mods = frame.state.registry.all(); + List mods = mods(); if (mods.isEmpty()) { JLabel l = new JLabel(Strings.t("mods.none")); l.setBorder(BorderFactory.createEmptyBorder(20, 4, 20, 4)); @@ -181,6 +189,19 @@ final class ModsPanel extends JPanel { }); } + /** + * The mods shown: temporary play-test copies of the level editor only while a play-test uses + * them (they are deleted when it ends). + */ + private List mods() { + var cfg = frame.state.cfg; + return frame.state.registry.all().stream() + .filter(m -> !(m instanceof LevelProjectMod lm && lm.playtest()) + || cfg.enabledMods.contains(m.id()) + || cfg.activeMods.contains(m.id())) + .toList(); + } + private static Map> groups(List mods) { Map> byCat = new TreeMap<>((a, b) -> { int ia = CATEGORY_ORDER.indexOf(a); @@ -214,7 +235,18 @@ final class ModsPanel extends JPanel { count.setForeground(UIManager.getColor("Label.disabledForeground")); groupCounts.put(category, count); h.add(title, BorderLayout.WEST); - h.add(count, BorderLayout.EAST); + if (category.equals(Mod.LEVELS)) { + JButton open = linkButton(Strings.t("mods.openLevels")); + open.setToolTipText(Strings.t("mods.openLevelsTip")); + open.addActionListener(e -> openLevelsFolder()); + JPanel east = new JPanel(new FlowLayout(FlowLayout.RIGHT, 12, 0)); + east.setOpaque(false); + east.add(open); + east.add(count); + h.add(east, BorderLayout.EAST); + } else { + h.add(count, BorderLayout.EAST); + } h.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); h.addMouseListener(new MouseAdapter() { @Override @@ -272,6 +304,13 @@ final class ModsPanel extends JPanel { title.add(Box.createHorizontalStrut(10)); title.add(exeBadge()); } + boolean playtest = m instanceof LevelProjectMod lm && lm.playtest(); + if (playtest) { + title.add(Box.createHorizontalStrut(10)); + JLabel b = badge(Strings.t("mods.playtestBadge"), "#3A7BD5"); + b.setToolTipText(Strings.t("mods.playtestBadgeTip")); + title.add(b); + } g = cell(1, 0, 1, GridBagConstraints.WEST); g.weightx = 1; c.add(title, g); @@ -279,7 +318,19 @@ final class ModsPanel extends JPanel { ver.setToolTipText(Strings.t("mods.idTip", m.id())); ver.setForeground(UIManager.getColor("Label.disabledForeground")); ver.setFont(ver.getFont().deriveFont(11.5f)); - c.add(ver, cell(2, 0, 1, GridBagConstraints.EAST)); + if (m instanceof LevelProjectMod lm && !playtest) { + JButton remove = linkButton(Strings.t("mods.remove")); + remove.setToolTipText(Strings.t("mods.removeTip", lm.file().getFileName())); + remove.addActionListener(e -> remove(lm)); + removeButtons.add(remove); + JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0)); + right.setOpaque(false); + right.add(remove); + right.add(ver); + c.add(right, cell(2, 0, 1, GridBagConstraints.EAST)); + } else { + c.add(ver, cell(2, 0, 1, GridBagConstraints.EAST)); + } JTextArea desc = new JTextArea(m.description()); desc.setLineWrap(true); desc.setWrapStyleWord(true); @@ -347,6 +398,83 @@ final class ModsPanel extends JPanel { return b; } + private static JLabel badge(String text, String color) { + JLabel b = new JLabel(text); + b.setFont(b.getFont().deriveFont(Font.BOLD, 11f)); + b.setOpaque(true); + b.putClientProperty( + "FlatLaf.style", + "arc: 999; background: fade(" + color + ",18%); foreground: " + + (com.formdev.flatlaf.FlatLaf.isLafDark() ? "lighten(" + color + ",20%)" : color)); + b.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8)); + return b; + } + + /** Small text button without a frame (secondary actions on cards and headers). */ + private static JButton linkButton(String text) { + JButton b = new JButton(text); + b.putClientProperty("JButton.buttonType", "toolBarButton"); + b.setFont(b.getFont().deriveFont(12f)); + b.setForeground(UIManager.getColor("Component.accentColor")); + b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + b.setFocusable(false); + return b; + } + + private void openLevelsFolder() { + try { + var dir = LevelProjects.dir(frame.state.paths); + Files.createDirectories(dir); + SettingsPanel.openFolder(dir); + } catch (IOException e) { + frame.error(e); + } + } + + /** "Remove…" on a level card: moves the project file to the backups after a confirmation. */ + private void remove(LevelProjectMod m) { + if (frame.gameRunning()) { + frame.error(new IllegalStateException(Strings.t("mods.gameRunning"))); + return; + } + Object[] options = {Strings.t("mods.removeYes"), Strings.t("mods.removeCancel")}; + int r = JOptionPane.showOptionDialog( + frame, + MainFrame.html( + Strings.t( + "mods.removeAsk", + MainFrame.escape(m.name()), + MainFrame.escape(m.file().toString()), + MainFrame.escape(frame.state + .paths + .backups() + .resolve(LevelProjects.DIR) + .toString())), + 420), + Strings.t("mods.removeTitle"), + JOptionPane.DEFAULT_OPTION, + JOptionPane.WARNING_MESSAGE, + null, + options, + options[1]); + if (r != 0) { + return; + } + try { + LevelProjects.Removed res = + LevelProjects.remove(frame.state.paths, frame.state.cfg, m, frame.state.launcher.isRunning()); + frame.state.saveConfig(); + frame.state.reloadMods(); + reload(); + frame.setStatus(Strings.t( + res.wasActive() ? "mods.removedActive" : "mods.removed", + m.name(), + res.backup().getParent())); + } catch (IOException e) { + frame.error(e); + } + } + // ---------------------------------------------------------------- options private JPanel optionsPanel(Mod m) { @@ -500,7 +628,7 @@ final class ModsPanel extends JPanel { /** In apply order. */ private List selected() { - return frame.state.registry.all().stream().filter(this::isSelected).toList(); + return mods().stream().filter(this::isSelected).toList(); } boolean hasPendingChanges() { @@ -514,7 +642,7 @@ final class ModsPanel extends JPanel { } private void updateState() { - for (Map.Entry> g : groups(frame.state.registry.all()).entrySet()) { + for (Map.Entry> g : groups(mods()).entrySet()) { JLabel l = groupCounts.get(g.getKey()); if (l != null) { long on = g.getValue().stream().filter(this::isSelected).count(); diff --git a/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties b/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties index efe28b9..e82aaa4 100644 --- a/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties +++ b/src/main/resources/com/eliasmueller/llamamod/ui/messages.properties @@ -57,6 +57,18 @@ mods.category.cheats=Cheats & debug mods.category.other=Other mods.exeBadge=modifies groove.exe mods.exeBadgeTip=This mod changes the game program (groove.exe) in LlamaMod's copy. On Windows, some antivirus programs may wrongly report the changed file. +mods.playtestBadge=temporary play-test +mods.playtestBadgeTip=A copy installed by a play-test in the level editor. It is removed automatically when the play-test ends; use “Add to LlamaMod” in the editor to keep a level. +mods.remove=Remove… +mods.removeTip=Remove this level from LlamaMod (the file {0} is moved to the backups) +mods.removeTitle=Remove level +mods.removeAsk=Remove the level “{0}” from LlamaMod?

The file
{1}
is moved to a new folder in
{2}
(nothing is deleted). If this is also your editor project, open it from there to work on it again. +mods.removeYes=Remove +mods.removeCancel=Cancel +mods.removed=Removed “{0}” – the file is in {1}. +mods.removedActive=Removed “{0}” – the file is in {1}. It was active: click “Apply” to update the game. +mods.openLevels=Open levels folder +mods.openLevelsTip=The folder with the level projects shown here (every .llamalevel file in it is a level mod) av.noticeTitle=Note about antivirus software av.notice=The selected mods ({0}) add extra code to groove.exe in LlamaMod''s private game copy.

Some antivirus programs (e.g. Windows Defender) may report this changed file as a threat and quarantine it. This is a false positive: LlamaMod only patches its own copy, your original game files are never touched.

If that happens, add the LlamaMod data folder as an exclusion in your antivirus program: diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestLifecycleTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestLifecycleTest.java new file mode 100644 index 0000000..4372807 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/playtest/PlaytestLifecycleTest.java @@ -0,0 +1,187 @@ +package com.eliasmueller.llamamod.editor.tools.playtest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.editor.io.Bases; +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.io.ProjectIO; +import com.eliasmueller.llamamod.editor.ui.EditorEnv; +import com.eliasmueller.llamamod.mods.GameData; +import com.eliasmueller.llamamod.mods.Mod; +import com.eliasmueller.llamamod.mods.levels.LevelProjectMod; +import com.eliasmueller.llamamod.mods.levels.LevelProjectSource; +import com.eliasmueller.llamamod.mods.levels.LevelProjects; +import com.eliasmueller.llamamod.platform.AppPaths; +import com.eliasmueller.llamamod.platform.Config; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Play-test copies are temporary, "keep" copies stay, and the launcher's Remove moves files to the backups. */ +class PlaytestLifecycleTest { + @TempDir + Path tmp; + + private AppPaths paths; + private Config cfg; + private EditorEnv env; + private Path levels; + private byte[] project; + + @BeforeEach + void setUp() throws IOException { + Bases cd = Bases.fromCd(GameData.cd()); + Project p = Project.create(cd, "new", "t1l1m001"); + p.scene().settings().prop("targetChapter").setRaw(p.scene().settings(), 4); + project = ProjectIO.write(p, cd); + paths = new AppPaths(tmp); + // a minimal installed CD: applying the user's (empty) selection needs no game data + Files.createDirectories(paths.originalCd().resolve("wads")); + Files.write(paths.originalCd().resolve("wads/t1l1m001.wad"), new byte[] {1}); + cfg = new Config(); + env = new EditorEnv(paths, cfg, null, GameData.cd(), true); + levels = LevelProjects.dir(paths); + } + + private Path put(Path dir, String name) throws IOException { + Files.createDirectories(dir); + return Files.write(dir.resolve(name), project); + } + + private LevelProjectMod mod(String id) { + for (Mod m : new LevelProjectSource(levels).mods()) { + if (m.id().equals(id)) { + return (LevelProjectMod) m; + } + } + return null; + } + + @Test + void playtestCopyIsRemovedWhenThePlaytestEnds() throws IOException { + put(levels, "mine.llamalevel"); + Path copy = put(LevelProjects.playtestDir(levels), "mine.llamalevel"); + LevelProjectMod t = mod("playtest-mine"); + assertNotNull(t); + assertTrue(t.playtest()); + assertTrue(t.name().endsWith("(play-test)")); + assertEquals("4", t.options().getFirst().defaultValue(), "own chapter, not shifted by the saved level"); + assertFalse(mod("level-mine").playtest()); + + cfg.enabledMods = new ArrayList<>(List.of("fog", "playtest-mine")); + cfg.modOptions.put("playtest-mine", Map.of("chapter", "4")); + PlaytestRunner.Session s = new PlaytestRunner.Session(List.of(), null, List.of("fog", "level-mine")); + PlaytestRunner.writeSession(env, s); + PlaytestRunner.restore(env, s); + + assertFalse(Files.exists(copy)); + assertFalse(Files.exists(LevelProjects.playtestDir(levels))); + assertEquals(List.of("fog", "level-mine"), cfg.enabledMods); + assertFalse(cfg.modOptions.containsKey("playtest-mine")); + assertFalse(Files.exists(PlaytestRunner.sessionFile(env))); + assertNull(mod("playtest-mine")); + assertNotNull(mod("level-mine"), "the saved level stays"); + assertEquals(List.of("fog", "level-mine"), Config.load(paths.config()).enabledMods); + } + + @Test + void pendingSessionKeepsTheSelectionToRestore() throws IOException { + PlaytestRunner.Session s = new PlaytestRunner.Session(List.of(), null, List.of("a", "b")); + PlaytestRunner.writeSession(env, s); + assertEquals( + List.of("a", "b"), + PlaytestRunner.readSession(env, new com.eliasmueller.llamamod.mods.ModRegistry()) + .enabled()); + } + + @Test + void staleCopiesAreClearedOnEditorStart() throws IOException { + Path copy = put(LevelProjects.playtestDir(levels), "old.llamalevel"); + PlaytestRunner.clearStale(env); + assertFalse(Files.exists(copy)); + } + + @Test + void keptLevelStays() throws IOException { + Path f = put(levels, "kept.llamalevel"); + cfg.enabledMods = new ArrayList<>(List.of("level-kept")); + PlaytestRunner.restore(env, new PlaytestRunner.Session(List.of(), null)); + assertTrue(Files.exists(f)); + assertEquals(List.of("level-kept"), cfg.enabledMods); + assertNotNull(mod("level-kept")); + } + + @Test + void removeMovesTheFileToTheBackups() throws IOException { + Path f = put(levels, "gone.llamalevel"); + LevelProjectMod m = mod("level-gone"); + cfg.enabledMods = new ArrayList<>(List.of("level-gone", "fog")); + cfg.activeMods = new ArrayList<>(List.of("level-gone", "fog")); + LevelProjects.Removed r = LevelProjects.remove(paths, cfg, m, false); + assertFalse(Files.exists(f)); + assertTrue(r.wasActive()); + assertEquals(paths.backups().resolve("levels"), r.backup().getParent().getParent()); + assertTrue(Files.isRegularFile(r.backup())); + assertEquals("gone.llamalevel", r.backup().getFileName().toString()); + assertEquals(List.of("fog"), cfg.enabledMods); + assertEquals(List.of("level-gone", "fog"), cfg.activeMods, "applied state changes only with Apply"); + assertNull(mod("level-gone")); + } + + @Test + void removeIsRefusedWhileTheGameRuns() throws IOException { + Path f = put(levels, "busy.llamalevel"); + cfg.enabledMods = new ArrayList<>(List.of("level-busy")); + IOException e = + assertThrows(IOException.class, () -> LevelProjects.remove(paths, cfg, mod("level-busy"), true)); + assertTrue(e.getMessage().contains("game is running"), e.getMessage()); + assertTrue(Files.exists(f)); + assertEquals(List.of("level-busy"), cfg.enabledMods); + put(LevelProjects.playtestDir(levels), "t.llamalevel"); + LevelProjectMod t = mod("playtest-t"); + assertThrows( + IOException.class, + () -> LevelProjects.remove(paths, cfg, t, false), + "play-test copies go by themselves"); + } + + @Test + void findByIdOrFileName() throws IOException { + put(levels, "Some Level.llamalevel"); + put(LevelProjects.playtestDir(levels), "some-level.llamalevel"); + var reg = new java.util.LinkedHashMap(); + new LevelProjectSource(levels).mods().forEach(m -> reg.put(m.id(), m)); + assertEquals(List.of("level-some-level", "playtest-some-level"), List.copyOf(reg.keySet())); + } + + /** Regression: "My-level" (old play-test) and "my-level" (new slug) both had the id level-my-level. */ + @Test + void namesDifferingInCaseGetUniqueIds() throws IOException { + put(levels, "My-level.llamalevel"); + put(levels, "my-level.llamalevel"); + put(levels, "my-level-2.llamalevel"); + List mods = new LevelProjectSource(levels).mods(); + assertEquals(3, mods.size()); + Map byFile = new java.util.TreeMap<>(); + for (Mod m : mods) { + byFile.put(((LevelProjectMod) m).file().getFileName().toString(), m.id()); + } + assertEquals("level-my-level", byFile.get("My-level.llamalevel")); + assertEquals("level-my-level-2", byFile.get("my-level-2.llamalevel"), "an existing id stays stable"); + assertEquals("level-my-level-3", byFile.get("my-level.llamalevel")); + assertEquals( + levels.resolve("My-level.llamalevel"), LevelProjects.resolveIgnoreCase(levels, "MY-LEVEL.llamalevel")); + assertEquals(levels.resolve("new.llamalevel"), LevelProjects.resolveIgnoreCase(levels, "new.llamalevel")); + } +}