launcher: wizard bar follows the copied megabytes, saves table shows 4 rows at the minimum size, mods tab keeps focus and caret when it is rebuilt, take-over notice expires; geom_export previews in game space; apptest checks every menu step with longer holds, loads slot 2, retries an early crash once and records load
This commit is contained in:
1 parent
d030c9c6c7
commit
d6f84be125
19 files changed
+650
-67
No files matched your search
+4
-1
@@ -210,7 +210,10 @@ Implemented in `tools/geom_import.py` (OBJ → TRAK + MAP tile part, rest of the
|
|||||||
`geom_export`/`geom_import` = the old raw export space (z < 0).
|
`geom_export`/`geom_import` = the old raw export space (z < 0).
|
||||||
* `geom_export.py WAD OUT [--collision] [--objects] [--export-space]` → level.obj/mtl (UV, vertex colours, one
|
* `geom_export.py WAD OUT [--collision] [--objects] [--export-space]` → level.obj/mtl (UV, vertex colours, one
|
||||||
object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png
|
object per tile), page_NN.png, collision.obj (groups matNN_grpG), objects.obj, preview_top/persp.png
|
||||||
(textured software renderer).
|
(textured software renderer, right-handed camera, always in game space – the same way round as in the
|
||||||
|
game, checked against an in-game shot of t1l1m001: `img/geom_preview_handedness.jpg`; before 2026-11-07
|
||||||
|
the previews rendered export space and were mirrored). `tools/vorschau/render_level.py` (Blender) reads
|
||||||
|
file space and is not mirrored either (checked on the test world, 2026-11-07).
|
||||||
* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]`
|
* `geom_import.py BASE.wad MESH.obj NEW.wad [--coll-obj C.obj] [--keep-tiles] [--zones flat|keep] [--default-tex N] [--export-space]`
|
||||||
* `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads.
|
* `geom_demo.py BASE.wad demo.obj [--plaza]` – example geometry made of texture-tile quads.
|
||||||
* `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).
|
* `geomlib.py` – parser/builder (TRAK, STPC models, MAP tile part, TEXT table/pages, collision).
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
+32
-4
@@ -76,8 +76,14 @@ Config()` that never touched a file overwrites everything (installer, tests).
|
|||||||
`save` returns true when the merge pulled in values written elsewhere; `Config.refresh(file)` does
|
`save` returns true when the merge pulled in values written elsewhere; `Config.refresh(file)` does
|
||||||
the same merge without writing (unsaved changes of this instance are kept). The launcher refreshes
|
the same merge without writing (unsaved changes of this instance are kept). The launcher refreshes
|
||||||
when its window is activated and after a save that pulled something in, then rebuilds the Mods and
|
when its window is activated and after a save that pulled something in, then rebuilds the Mods and
|
||||||
Settings tabs (status "Took over changes made in another LlamaMod window."), so a stale window's
|
Settings tabs (status "Took over changes made in another LlamaMod window.", transient: after 6 s the
|
||||||
next click cannot undo another window's selection.
|
previous status comes back, `ui/StatusLine.flash`), so a stale window's next click cannot undo another
|
||||||
|
window's selection. The Mods tab rebuild keeps keyboard focus, caret and selection of the option the user
|
||||||
|
is typing in (`ui/FocusKeeper`: inputs are tagged `modId/optionKey`; FlatLaf's select-all on focus gain is
|
||||||
|
suppressed for the restored field, it would eat the next key).
|
||||||
|
Short windows: header 150 px from 860 px window height, 104 px below, 92 px below 700 px. The Saves tab
|
||||||
|
shows the table legend ("Coins: …") in the left column, so at the 820×640 minimum the chapter table
|
||||||
|
still shows 4 rows (7 at 720 px; `img/app/saves-640.jpg`).
|
||||||
Screenshots: `img/app/two-windows-before.jpg` / `-after.jpg` (right window ticked Test World, left
|
Screenshots: `img/app/two-windows-before.jpg` / `-after.jpg` (right window ticked Test World, left
|
||||||
window after a click into it), `img/app/interrupted-apply-notice.jpg`, `img/app/saves-min-width.jpg`,
|
window after a click into it), `img/app/interrupted-apply-notice.jpg`, `img/app/saves-min-width.jpg`,
|
||||||
`img/app/settings-min-width.jpg` (hints are `ui/WrapLabel`s: they wrap at the panel width, the
|
`img/app/settings-min-width.jpg` (hints are `ui/WrapLabel`s: they wrap at the panel width, the
|
||||||
@@ -92,6 +98,9 @@ looking for `…/Hd/groove.exe` next to `…/Cd/wads/`. After copying, `platform
|
|||||||
same checks as `deb-build/build.sh` (FESOUND.WAD length field, "OFNI" at offset 4 of every level
|
same checks as `deb-build/build.sh` (FESOUND.WAD length field, "OFNI" at offset 4 of every level
|
||||||
WAD, `music/english.cvs` must not end in 4 MiB of zeros); damaged copies are rejected.
|
WAD, `music/english.cvs` must not end in 4 MiB of zeros); damaged copies are rejected.
|
||||||
The ISO also contains three `(null)king*.ksv` leftovers; they are not copied.
|
The ISO also contains three `(null)king*.ksv` leftovers; they are not copied.
|
||||||
|
Wizard progress: the bar follows the copied bytes exactly (`Installer.COPY_SHARE` of the install progress
|
||||||
|
is the copy), so "Copying 553 of 568 MB" stands next to 97 %; the short steps after it (check, game folder,
|
||||||
|
re-applying mods) show a busy bar without a percentage (`img/app/wizard-copy-mb-fixed.jpg`).
|
||||||
|
|
||||||
## Registry, CD path and the exe patch
|
## Registry, CD path and the exe patch
|
||||||
|
|
||||||
@@ -356,14 +365,14 @@ and hard-linked into each session (~30 MB per session + ~200 MB Wine prefix on f
|
|||||||
T=tools/apptest.py
|
T=tools/apptest.py
|
||||||
$T run NAME --display 5 --mods fog,testworld --opt fog.strength=2 --level 1 1 \
|
$T run NAME --display 5 --mods fog,testworld --opt fog.strength=2 --level 1 1 \
|
||||||
--seq "Up:1.5 shot:walk a+x:0.5 sleep=1 shot:after" --out DIR [--keep]
|
--seq "Up:1.5 shot:walk a+x:0.5 sleep=1 shot:after" --out DIR [--keep]
|
||||||
# setup + apply + unlock slot 3 at world/chapter (cursor) + start + tolevel + keys + stop
|
# setup + apply + unlock slots 2 and 3 at world/chapter (cursor) + start + tolevel (slot 2) + keys + stop
|
||||||
# exit 0 ok, 3 crash, 4 timeout; DIR: report.json, 00_start.png, seq_NN_*.jpg, 99_end.jpg,
|
# exit 0 ok, 3 crash, 4 timeout; DIR: report.json, 00_start.png, seq_NN_*.jpg, 99_end.jpg,
|
||||||
# zz_failure.jpg, game.log, apply.log
|
# zz_failure.jpg, game.log, apply.log
|
||||||
$T setup NAME # install session (fast after the template exists)
|
$T setup NAME # install session (fast after the template exists)
|
||||||
$T apply NAME fog,mirror --opt fog.strength=0 --opt mirror.on=true # no ids = original
|
$T apply NAME fog,mirror --opt fog.strength=0 --opt mirror.on=true # no ids = original
|
||||||
$T unlock NAME 3 WORLD CHAPTER # slot 3, map cursor on WORLD/CHAPTER (do this before start)
|
$T unlock NAME 3 WORLD CHAPTER # slot 3, map cursor on WORLD/CHAPTER (do this before start)
|
||||||
$T start NAME --display 5 # Xvfb + private pulse + `--launch`; waits for window and audio
|
$T start NAME --display 5 # Xvfb + private pulse + `--launch`; waits for window and audio
|
||||||
$T tolevel NAME --world 1 # menu "Spiel starten" -> slot 3 -> map -> Enter -> skip fly-by
|
$T tolevel NAME --world 1 [--slot 3] # menu "Spiel starten" -> slot (default 3) -> map -> Enter -> skip fly-by
|
||||||
$T keys NAME Up:1.5 x a+x:0.5 sleep=2 shot:name dialog --out DIR # KEY[:hold s], default hold 0.2 s
|
$T keys NAME Up:1.5 x a+x:0.5 sleep=2 shot:name dialog --out DIR # KEY[:hold s], default hold 0.2 s
|
||||||
$T shot NAME out.png [--small] # 1920x1080 PNG, --small = 960x540 JPEG
|
$T shot NAME out.png [--small] # 1920x1080 PNG, --small = 960x540 JPEG
|
||||||
$T ocr NAME [WxH+X+Y] # German tesseract OCR
|
$T ocr NAME [WxH+X+Y] # German tesseract OCR
|
||||||
@@ -393,5 +402,24 @@ s.key("Up", 1.5); s.shot("a.png"); print(s.crashed()); s.stop()`.
|
|||||||
Controls are camera-relative (see testharness.md for movement measurements).
|
Controls are camera-relative (see testharness.md for movement measurements).
|
||||||
* Navigation timing (Wine 6, llvmpipe): menu ~6 s, slot ~14 s, gameplay ~25 s after start.
|
* Navigation timing (Wine 6, llvmpipe): menu ~6 s, slot ~14 s, gameplay ~25 s after start.
|
||||||
`tolevel(world=9)` waits for the beige world-9 map, other worlds for a stable picture.
|
`tolevel(world=9)` waits for the beige world-9 map, other worlds for a stable picture.
|
||||||
|
* Flakiness under load: with several agents' games running (load average 30 on 6 cores) the game
|
||||||
|
draws only a few frames per second and the slot screen ignores presses shorter than a frame
|
||||||
|
(seen 2026-11-07 at load ~30: the save slot carousel ignored Left/Right – 0.3 to 4 s holds – for
|
||||||
|
minutes while Return/Escape worked; root cause not found). `run` therefore loads **slot 2**, where
|
||||||
|
the carousel opens (no turning; slot 3 gets the same unlock for mods whose save-slot option is 3);
|
||||||
|
3 + 3 runs passed after that change. `tolevel` checks every menu step and repeats it with longer
|
||||||
|
holds (Return on menus 0.6 s first – 0.2 s presses were often missed under load, reported by the
|
||||||
|
test-world agent too): Return on the main menu until the slot screen shows (0.8/1.0/1.2 s), Right one step at a
|
||||||
|
time until OCR reads the wanted slot (the carousel wraps 3 → 1; a step that did not move is
|
||||||
|
repeated with 0.6/1.0/1.5 s; gives up after 240 s), Return on the slot until the slot screen is
|
||||||
|
gone, Return on the map again if the picture did not change after 8 s. Before opening the slot it
|
||||||
|
checks the slot text: an empty slot (would start a new game in the village) or another world's
|
||||||
|
name (`SLOT_WORLD_NAMES`, verified: 1 "dorf", 9 "testlevel") is an error instead of a wrong level.
|
||||||
|
`run` retries the start once when the game crashed before the main menu (Wine page fault at
|
||||||
|
start-up) or the slot screen never came: report.json `"retries": [{attempt, phase, error}]`,
|
||||||
|
`retry1_failure.jpg`/`retry1_game.log` in the out dir, result line "OK (after a retry: …)";
|
||||||
|
`--no-retry` turns it off. report.json also records `load_avg`/`cpus`. Your own key sequences
|
||||||
|
(`--seq`) are not retried: hold keys ≥0.5 s for menus when the machine is busy.
|
||||||
|
Offline tests of the retry/slot logic: `python3 -m unittest tools/test_apptest.py`.
|
||||||
* Session state (PIDs, display): `<root>/<name>/harness/state.json`; logs `xvfb.log`, `pulse.log`
|
* Session state (PIDs, display): `<root>/<name>/harness/state.json`; logs `xvfb.log`, `pulse.log`
|
||||||
there, game output in `data/logs/game.log`. Delete `<root>/<name>` to start from scratch.
|
there, game output in `data/logs/game.log`. Delete `<root>/<name>` to start from scratch.
|
||||||
@@ -53,7 +53,7 @@ public final class Installer {
|
|||||||
List<GameSource.SrcFile> cd = src.cdFiles();
|
List<GameSource.SrcFile> cd = src.cdFiles();
|
||||||
long total = totalSize(hd) + totalSize(cd);
|
long total = totalSize(hd) + totalSize(cd);
|
||||||
long[] done = {0};
|
long[] done = {0};
|
||||||
Progress copyP = p.sub(0, 0.85);
|
Progress copyP = p.sub(0, COPY_SHARE);
|
||||||
// files already copied are only trusted when an interrupted install of the same source
|
// files already copied are only trusted when an interrupted install of the same source
|
||||||
// resumes; a new setup (other or repaired image) copies everything again
|
// resumes; a new setup (other or repaired image) copies everything again
|
||||||
Path marker = paths.root().resolve("original").resolve(".install-source");
|
Path marker = paths.root().resolve("original").resolve(".install-source");
|
||||||
@@ -152,6 +152,9 @@ public final class Installer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Share of {@link #install}'s progress that is the file copy (byte-proportional); the rest is quick steps. */
|
||||||
|
public static final double COPY_SHARE = 0.85;
|
||||||
|
|
||||||
/** "Copying 123 of 568 MB (wads/t1l1m001.wad)". */
|
/** "Copying 123 of 568 MB (wads/t1l1m001.wad)". */
|
||||||
static String copyMessage(long done, long total, String file) {
|
static String copyMessage(long done, long total, String file) {
|
||||||
return String.format(Locale.ROOT, "Copying %d of %d MB (%s)", done >> 20, total >> 20, file);
|
return String.format(Locale.ROOT, "Copying %d of %d MB (%s)", done >> 20, total >> 20, file);
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.eliasmueller.llamamod.ui;
|
||||||
|
|
||||||
|
import java.awt.Component;
|
||||||
|
import java.awt.Container;
|
||||||
|
import java.awt.event.FocusAdapter;
|
||||||
|
import java.awt.event.FocusEvent;
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.JSpinner;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import javax.swing.text.JTextComponent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps keyboard focus (and caret/selection) across a rebuild that replaces components: inputs are tagged with a
|
||||||
|
* stable key, the focused one is remembered before the rebuild and its replacement focused afterwards.
|
||||||
|
*/
|
||||||
|
final class FocusKeeper {
|
||||||
|
private static final String KEY = "llamamod.focusKey";
|
||||||
|
private static final String SELECT_ALL_POLICY = "JTextField.selectAllOnFocusPolicy";
|
||||||
|
|
||||||
|
/** What had the focus: the tag of the input and, for text, caret and selection. */
|
||||||
|
record Snapshot(String key, int caret, int selStart, int selEnd) {}
|
||||||
|
|
||||||
|
private FocusKeeper() {}
|
||||||
|
|
||||||
|
static <T extends JComponent> T tag(T c, String key) {
|
||||||
|
c.putClientProperty(KEY, key);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tagged input that contains {@code focusOwner} (e.g. a spinner's text field), or null. */
|
||||||
|
static Snapshot capture(Component focusOwner, Container root) {
|
||||||
|
for (Component c = focusOwner; c != null && c != root; c = c.getParent()) {
|
||||||
|
if (c instanceof JComponent j && j.getClientProperty(KEY) instanceof String key) {
|
||||||
|
if (focusOwner instanceof JTextComponent t) {
|
||||||
|
return new Snapshot(key, t.getCaretPosition(), t.getSelectionStart(), t.getSelectionEnd());
|
||||||
|
}
|
||||||
|
return new Snapshot(key, -1, -1, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finds the replacement of the remembered input under {@code root}, restores caret/selection; null if gone. */
|
||||||
|
static Component restore(Snapshot s, Container root) {
|
||||||
|
if (s == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
JComponent c = find(root, s.key());
|
||||||
|
if (c == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Component target =
|
||||||
|
c instanceof JSpinner sp && sp.getEditor() instanceof JSpinner.DefaultEditor ed ? ed.getTextField() : c;
|
||||||
|
if (target instanceof JTextComponent t && s.caret() >= 0) {
|
||||||
|
int len = t.getDocument().getLength();
|
||||||
|
int a = Math.min(s.selStart(), len);
|
||||||
|
int b = Math.min(s.selEnd(), len);
|
||||||
|
if (a != b) {
|
||||||
|
// keep the direction: the caret is where the user left it
|
||||||
|
t.setCaretPosition(s.caret() == a ? b : a);
|
||||||
|
t.moveCaretPosition(Math.min(s.caret(), len));
|
||||||
|
} else {
|
||||||
|
t.setCaretPosition(Math.min(s.caret(), len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Focuses the restored input without FlatLaf's select-all on focus gain (it would eat the next key). */
|
||||||
|
static void focus(Component c) {
|
||||||
|
if (c instanceof JTextComponent t) {
|
||||||
|
t.putClientProperty(SELECT_ALL_POLICY, "never");
|
||||||
|
t.addFocusListener(new FocusAdapter() {
|
||||||
|
@Override
|
||||||
|
public void focusGained(FocusEvent e) {
|
||||||
|
t.removeFocusListener(this);
|
||||||
|
SwingUtilities.invokeLater(() -> t.putClientProperty(SELECT_ALL_POLICY, null));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
c.requestFocusInWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JComponent find(Container root, String key) {
|
||||||
|
for (Component c : root.getComponents()) {
|
||||||
|
if (c instanceof JComponent j && key.equals(j.getClientProperty(KEY))) {
|
||||||
|
return j;
|
||||||
|
}
|
||||||
|
if (c instanceof Container sub) {
|
||||||
|
JComponent f = find(sub, key);
|
||||||
|
if (f != null) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ final class MainFrame extends JFrame {
|
|||||||
private final HeaderPanel header = new HeaderPanel();
|
private final HeaderPanel header = new HeaderPanel();
|
||||||
private final JButton playButton = new JButton(Strings.t("play"));
|
private final JButton playButton = new JButton(Strings.t("play"));
|
||||||
private final JLabel statusLabel = new JLabel(" ");
|
private final JLabel statusLabel = new JLabel(" ");
|
||||||
|
private final StatusLine statusLine = new StatusLine(statusLabel, StatusLine.TRANSIENT_MS);
|
||||||
private final JProgressBar progress = new JProgressBar(0, 1000);
|
private final JProgressBar progress = new JProgressBar(0, 1000);
|
||||||
private final JButton cancelButton = new JButton(Strings.t("cancel"));
|
private final JButton cancelButton = new JButton(Strings.t("cancel"));
|
||||||
private final JTabbedPane tabs = new JTabbedPane();
|
private final JTabbedPane tabs = new JTabbedPane();
|
||||||
@@ -124,15 +125,19 @@ final class MainFrame extends JFrame {
|
|||||||
return l;
|
return l;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Short windows (laptops, 720p) get a lower header so the mod list keeps its room. */
|
/** Short windows (laptops, 720p) get a lower header so the mod list and the save table keep their room. */
|
||||||
private void fitHeader() {
|
private void fitHeader() {
|
||||||
int h = getHeight() < 860 ? 104 : 150;
|
int h = headerHeight(getHeight());
|
||||||
if (header.getPreferredSize().height != h) {
|
if (header.getPreferredSize().height != h) {
|
||||||
header.setPreferredSize(new Dimension(900, h));
|
header.setPreferredSize(new Dimension(900, h));
|
||||||
header.revalidate();
|
header.revalidate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int headerHeight(int windowHeight) {
|
||||||
|
return windowHeight < 700 ? 92 : windowHeight < 860 ? 104 : 150;
|
||||||
|
}
|
||||||
|
|
||||||
private void buildUi() {
|
private void buildUi() {
|
||||||
JPanel root = new JPanel(new BorderLayout());
|
JPanel root = new JPanel(new BorderLayout());
|
||||||
header.setLayout(new BorderLayout());
|
header.setLayout(new BorderLayout());
|
||||||
@@ -284,7 +289,7 @@ final class MainFrame extends JFrame {
|
|||||||
}
|
}
|
||||||
modsPanel.reload();
|
modsPanel.reload();
|
||||||
settingsPanel.reload();
|
settingsPanel.reload();
|
||||||
setStatus(Strings.t("status.configMerged"));
|
setTransientStatus(Strings.t("status.configMerged"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void onBusy(Consumer<Boolean> l) {
|
void onBusy(Consumer<Boolean> l) {
|
||||||
@@ -300,7 +305,11 @@ final class MainFrame extends JFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setStatus(String s) {
|
void setStatus(String s) {
|
||||||
statusLabel.setText(s);
|
statusLine.set(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTransientStatus(String s) {
|
||||||
|
statusLine.flash(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateGameRunning() {
|
private void updateGameRunning() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.eliasmueller.llamamod.mods.levels.LevelProjects;
|
|||||||
import com.eliasmueller.llamamod.util.SwingListeners;
|
import com.eliasmueller.llamamod.util.SwingListeners;
|
||||||
import java.awt.BorderLayout;
|
import java.awt.BorderLayout;
|
||||||
import java.awt.Color;
|
import java.awt.Color;
|
||||||
|
import java.awt.Component;
|
||||||
import java.awt.Cursor;
|
import java.awt.Cursor;
|
||||||
import java.awt.Dimension;
|
import java.awt.Dimension;
|
||||||
import java.awt.FlowLayout;
|
import java.awt.FlowLayout;
|
||||||
@@ -17,6 +18,7 @@ import java.awt.GridBagConstraints;
|
|||||||
import java.awt.GridBagLayout;
|
import java.awt.GridBagLayout;
|
||||||
import java.awt.GridLayout;
|
import java.awt.GridLayout;
|
||||||
import java.awt.Insets;
|
import java.awt.Insets;
|
||||||
|
import java.awt.KeyboardFocusManager;
|
||||||
import java.awt.Point;
|
import java.awt.Point;
|
||||||
import java.awt.event.MouseAdapter;
|
import java.awt.event.MouseAdapter;
|
||||||
import java.awt.event.MouseEvent;
|
import java.awt.event.MouseEvent;
|
||||||
@@ -116,6 +118,9 @@ final class ModsPanel extends JPanel {
|
|||||||
// rebuilding the cards must not move the list: new spinners and text fields scroll themselves into view
|
// rebuilding the cards must not move the list: new spinners and text fields scroll themselves into view
|
||||||
Point keep = firstReload ? new Point() : scroll.getViewport().getViewPosition();
|
Point keep = firstReload ? new Point() : scroll.getViewport().getViewPosition();
|
||||||
firstReload = false;
|
firstReload = false;
|
||||||
|
// e.g. a config merge while the user types in an option: the new field gets the focus and caret
|
||||||
|
FocusKeeper.Snapshot focus = FocusKeeper.capture(
|
||||||
|
KeyboardFocusManager.getCurrentKeyboardFocusManager().getFocusOwner(), list);
|
||||||
list.removeAll();
|
list.removeAll();
|
||||||
boxes.clear();
|
boxes.clear();
|
||||||
removeButtons.clear();
|
removeButtons.clear();
|
||||||
@@ -148,6 +153,10 @@ final class ModsPanel extends JPanel {
|
|||||||
updateState();
|
updateState();
|
||||||
list.revalidate();
|
list.revalidate();
|
||||||
list.repaint();
|
list.repaint();
|
||||||
|
Component refocus = FocusKeeper.restore(focus, list);
|
||||||
|
if (refocus != null) {
|
||||||
|
FocusKeeper.focus(refocus);
|
||||||
|
}
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
var viewport = scroll.getViewport();
|
var viewport = scroll.getViewport();
|
||||||
viewport.validate();
|
viewport.validate();
|
||||||
@@ -248,7 +257,7 @@ final class ModsPanel extends JPanel {
|
|||||||
outer.add(c, BorderLayout.CENTER);
|
outer.add(c, BorderLayout.CENTER);
|
||||||
JCheckBox cb = new JCheckBox();
|
JCheckBox cb = new JCheckBox();
|
||||||
cb.setSelected(frame.state.cfg.enabledMods.contains(m.id()));
|
cb.setSelected(frame.state.cfg.enabledMods.contains(m.id()));
|
||||||
boxes.put(m.id(), cb);
|
boxes.put(m.id(), FocusKeeper.tag(cb, m.id()));
|
||||||
GridBagConstraints g = cell(0, 0, 1, GridBagConstraints.NORTHWEST);
|
GridBagConstraints g = cell(0, 0, 1, GridBagConstraints.NORTHWEST);
|
||||||
g.gridheight = 2;
|
g.gridheight = 2;
|
||||||
g.insets = new Insets(0, 0, 0, 10);
|
g.insets = new Insets(0, 0, 0, 10);
|
||||||
@@ -451,7 +460,7 @@ final class ModsPanel extends JPanel {
|
|||||||
int row = 0;
|
int row = 0;
|
||||||
for (ModOption o : m.options()) {
|
for (ModOption o : m.options()) {
|
||||||
String v = values.get(o.key());
|
String v = values.get(o.key());
|
||||||
JComponent input = optionInput(m, o, v);
|
JComponent input = optionInput(m, o, v, m.id() + "/" + o.key());
|
||||||
String tip = tip(o);
|
String tip = tip(o);
|
||||||
GridBagConstraints g = new GridBagConstraints();
|
GridBagConstraints g = new GridBagConstraints();
|
||||||
g.gridy = row;
|
g.gridy = row;
|
||||||
@@ -513,12 +522,12 @@ final class ModsPanel extends JPanel {
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
private JComponent optionInput(Mod m, ModOption o, String value) {
|
private JComponent optionInput(Mod m, ModOption o, String value, String key) {
|
||||||
switch (o.type()) {
|
switch (o.type()) {
|
||||||
case BOOL -> {
|
case BOOL -> {
|
||||||
JCheckBox b = new JCheckBox(o.label(), Boolean.parseBoolean(value));
|
JCheckBox b = new JCheckBox(o.label(), Boolean.parseBoolean(value));
|
||||||
b.addActionListener(e -> setOption(m, o, Boolean.toString(b.isSelected()), b));
|
b.addActionListener(e -> setOption(m, o, Boolean.toString(b.isSelected()), b));
|
||||||
optionInputs.add(b);
|
optionInputs.add(FocusKeeper.tag(b, key));
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
case INT -> {
|
case INT -> {
|
||||||
@@ -527,7 +536,7 @@ final class ModsPanel extends JPanel {
|
|||||||
Long.valueOf(v), Long.valueOf(o.min()), Long.valueOf(o.max()), Long.valueOf(1)));
|
Long.valueOf(v), Long.valueOf(o.min()), Long.valueOf(o.max()), Long.valueOf(1)));
|
||||||
s.addChangeListener(e -> setOption(m, o, Long.toString(((Number) s.getValue()).longValue()), s));
|
s.addChangeListener(e -> setOption(m, o, Long.toString(((Number) s.getValue()).longValue()), s));
|
||||||
s.setPreferredSize(new Dimension(110, s.getPreferredSize().height));
|
s.setPreferredSize(new Dimension(110, s.getPreferredSize().height));
|
||||||
optionInputs.add(s);
|
optionInputs.add(FocusKeeper.tag(s, key));
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
case CHOICE -> {
|
case CHOICE -> {
|
||||||
@@ -540,7 +549,7 @@ final class ModsPanel extends JPanel {
|
|||||||
}
|
}
|
||||||
c.addActionListener(e ->
|
c.addActionListener(e ->
|
||||||
setOption(m, o, o.choices().get(c.getSelectedIndex()).value(), c));
|
setOption(m, o, o.choices().get(c.getSelectedIndex()).value(), c));
|
||||||
optionInputs.add(c);
|
optionInputs.add(FocusKeeper.tag(c, key));
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
case MULTI -> {
|
case MULTI -> {
|
||||||
@@ -563,16 +572,16 @@ final class ModsPanel extends JPanel {
|
|||||||
}
|
}
|
||||||
setOption(m, o, o.multiValue(chosen), p);
|
setOption(m, o, o.multiValue(chosen), p);
|
||||||
});
|
});
|
||||||
optionInputs.add(b);
|
optionInputs.add(FocusKeeper.tag(b, key + "#" + c.value()));
|
||||||
p.add(b);
|
p.add(b);
|
||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
case SEED -> {
|
case SEED -> {
|
||||||
JTextField t = textInput(m, o, value, 12);
|
JTextField t = textInput(m, o, value, 12, key);
|
||||||
JButton random = new JButton(Strings.t("mods.option.random"));
|
JButton random = new JButton(Strings.t("mods.option.random"));
|
||||||
random.addActionListener(e -> t.setText(ModOptions.randomSeed()));
|
random.addActionListener(e -> t.setText(ModOptions.randomSeed()));
|
||||||
optionInputs.add(random);
|
optionInputs.add(FocusKeeper.tag(random, key + "#random"));
|
||||||
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
JPanel p = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
|
||||||
p.setOpaque(false);
|
p.setOpaque(false);
|
||||||
p.add(t);
|
p.add(t);
|
||||||
@@ -581,15 +590,15 @@ final class ModsPanel extends JPanel {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
default -> {
|
default -> {
|
||||||
return textInput(m, o, value, 20);
|
return textInput(m, o, value, 20, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private JTextField textInput(Mod m, ModOption o, String value, int columns) {
|
private JTextField textInput(Mod m, ModOption o, String value, int columns, String key) {
|
||||||
JTextField t = new JTextField(value, columns);
|
JTextField t = new JTextField(value, columns);
|
||||||
SwingListeners.onTextChange(t, () -> setOption(m, o, t.getText(), t));
|
SwingListeners.onTextChange(t, () -> setOption(m, o, t.getText(), t));
|
||||||
optionInputs.add(t);
|
optionInputs.add(FocusKeeper.tag(t, key));
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,9 +78,14 @@ final class SavesPanel extends JPanel {
|
|||||||
JPanel left = new JPanel(new BorderLayout());
|
JPanel left = new JPanel(new BorderLayout());
|
||||||
JLabel hint = App.hintLabel(Strings.t("saves.hint"), 200, 11.5f);
|
JLabel hint = App.hintLabel(Strings.t("saves.hint"), 200, 11.5f);
|
||||||
hint.setBorder(BorderFactory.createEmptyBorder(12, 2, 0, 0));
|
hint.setBorder(BorderFactory.createEmptyBorder(12, 2, 0, 0));
|
||||||
|
// the table's legend lives here, where there is room: at the minimum window height every
|
||||||
|
// line under the table would cost a table row
|
||||||
|
JLabel coinsHint = App.hintLabel(Strings.t("saves.coinsHint"), 200, 11.5f);
|
||||||
|
coinsHint.setBorder(BorderFactory.createEmptyBorder(10, 2, 0, 0));
|
||||||
JPanel leftTop = new JPanel(new BorderLayout());
|
JPanel leftTop = new JPanel(new BorderLayout());
|
||||||
leftTop.add(slots, BorderLayout.NORTH);
|
leftTop.add(slots, BorderLayout.NORTH);
|
||||||
leftTop.add(hint, BorderLayout.CENTER);
|
leftTop.add(hint, BorderLayout.CENTER);
|
||||||
|
leftTop.add(coinsHint, BorderLayout.SOUTH);
|
||||||
left.add(leftTop, BorderLayout.NORTH);
|
left.add(leftTop, BorderLayout.NORTH);
|
||||||
add(left, BorderLayout.WEST);
|
add(left, BorderLayout.WEST);
|
||||||
|
|
||||||
@@ -94,7 +99,7 @@ final class SavesPanel extends JPanel {
|
|||||||
slotTitle.setFont(slotTitle.getFont().deriveFont(Font.BOLD, 17f));
|
slotTitle.setFont(slotTitle.getFont().deriveFont(Font.BOLD, 17f));
|
||||||
ed.add(slotTitle, g);
|
ed.add(slotTitle, g);
|
||||||
g.gridy++;
|
g.gridy++;
|
||||||
g.insets = new Insets(2, 0, 10, 0);
|
g.insets = new Insets(2, 0, 4, 0);
|
||||||
ed.add(slotInfo, g);
|
ed.add(slotInfo, g);
|
||||||
|
|
||||||
for (int w = 1; w <= Worlds.COUNT; w++) {
|
for (int w = 1; w <= Worlds.COUNT; w++) {
|
||||||
@@ -143,10 +148,6 @@ final class SavesPanel extends JPanel {
|
|||||||
JScrollPane sp = new JScrollPane(table);
|
JScrollPane sp = new JScrollPane(table);
|
||||||
sp.setPreferredSize(new Dimension(420, 180));
|
sp.setPreferredSize(new Dimension(420, 180));
|
||||||
ed.add(sp, g);
|
ed.add(sp, g);
|
||||||
g.gridy++;
|
|
||||||
g.weighty = 0;
|
|
||||||
g.fill = GridBagConstraints.HORIZONTAL;
|
|
||||||
ed.add(App.hintLabel(Strings.t("saves.coinsHint"), 2000, 11.5f), g);
|
|
||||||
add(ed, BorderLayout.CENTER);
|
add(ed, BorderLayout.CENTER);
|
||||||
|
|
||||||
JPanel buttons = new JPanel(new BorderLayout());
|
JPanel buttons = new JPanel(new BorderLayout());
|
||||||
|
|||||||
@@ -313,6 +313,24 @@ final class SetupWizard extends JDialog {
|
|||||||
dispose();
|
dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bar follows the copied megabytes exactly (so it matches the "Copying x of y MB" text); the short steps
|
||||||
|
* after the copy show a busy bar instead of a percentage.
|
||||||
|
*/
|
||||||
|
private void showProgress(double installFraction, String message) {
|
||||||
|
double copy = barFraction(installFraction);
|
||||||
|
boolean copying = copy < 1;
|
||||||
|
bar.setIndeterminate(!copying);
|
||||||
|
bar.setStringPainted(copying);
|
||||||
|
bar.setValue((int) Math.round(copy * 1000));
|
||||||
|
progressText.setText(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy share of the installer progress mapped onto 0..1. */
|
||||||
|
static double barFraction(double installFraction) {
|
||||||
|
return Math.max(0, Math.min(1, installFraction / Installer.COPY_SHARE));
|
||||||
|
}
|
||||||
|
|
||||||
private void startInstall() {
|
private void startInstall() {
|
||||||
if (!checkSource()) {
|
if (!checkSource()) {
|
||||||
frame.error(new IllegalArgumentException(Strings.t("wizard.noSource")));
|
frame.error(new IllegalArgumentException(Strings.t("wizard.noSource")));
|
||||||
@@ -331,10 +349,7 @@ final class SetupWizard extends JDialog {
|
|||||||
Progress p = new Progress() {
|
Progress p = new Progress() {
|
||||||
@Override
|
@Override
|
||||||
public void update(double f, String m) {
|
public void update(double f, String m) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> showProgress(f, m));
|
||||||
bar.setValue((int) (f * 1000));
|
|
||||||
progressText.setText(m);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -346,10 +361,10 @@ final class SetupWizard extends JDialog {
|
|||||||
@Override
|
@Override
|
||||||
protected Object doInBackground() throws Exception {
|
protected Object doInBackground() throws Exception {
|
||||||
try (GameSource gs = GameSource.open(src)) {
|
try (GameSource gs = GameSource.open(src)) {
|
||||||
new Installer(state.paths).install(gs, state.cfg, p.sub(0, 0.95));
|
new Installer(state.paths).install(gs, state.cfg, p);
|
||||||
}
|
}
|
||||||
// re-setup keeps the active mods
|
// re-setup keeps the active mods
|
||||||
state.modManager.apply(state.activeMods(), p.sub(0.95, 1));
|
state.modManager.apply(state.activeMods(), (f, m) -> p.update(1, m));
|
||||||
return state.saves.migrateNullSaves();
|
return state.saves.migrateNullSaves();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.eliasmueller.llamamod.ui;
|
||||||
|
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.Timer;
|
||||||
|
|
||||||
|
/** The status bar text: lasting messages, and transient ones that give way to the lasting one after a while. */
|
||||||
|
final class StatusLine {
|
||||||
|
static final int TRANSIENT_MS = 6000;
|
||||||
|
|
||||||
|
private final JLabel label;
|
||||||
|
private final Timer expire;
|
||||||
|
private String resting;
|
||||||
|
|
||||||
|
StatusLine(JLabel label, int transientMs) {
|
||||||
|
this.label = label;
|
||||||
|
this.resting = label.getText();
|
||||||
|
expire = new Timer(transientMs, e -> label.setText(resting));
|
||||||
|
expire.setRepeats(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set(String s) {
|
||||||
|
expire.stop();
|
||||||
|
resting = s;
|
||||||
|
label.setText(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown for a few seconds, then the last lasting message comes back (unless something newer replaced it). */
|
||||||
|
void flash(String s) {
|
||||||
|
label.setText(s);
|
||||||
|
expire.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean showingTransient() {
|
||||||
|
return expire.isRunning();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.eliasmueller.llamamod.ui;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
|
||||||
|
import javax.swing.JPanel;
|
||||||
|
import javax.swing.JSpinner;
|
||||||
|
import javax.swing.JTextField;
|
||||||
|
import javax.swing.SpinnerNumberModel;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class FocusKeeperTest {
|
||||||
|
private static JPanel form(String text) {
|
||||||
|
JPanel root = new JPanel();
|
||||||
|
JPanel card = new JPanel();
|
||||||
|
card.add(FocusKeeper.tag(new JTextField(text, 10), "mod/name"));
|
||||||
|
card.add(FocusKeeper.tag(new JSpinner(new SpinnerNumberModel(5, 0, 9, 1)), "mod/count"));
|
||||||
|
root.add(card);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JTextField field(JPanel root) {
|
||||||
|
return (JTextField) ((JPanel) root.getComponent(0)).getComponent(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void caretMovesToTheRebuiltField() {
|
||||||
|
JPanel before = form("llama");
|
||||||
|
JTextField old = field(before);
|
||||||
|
old.setCaretPosition(3);
|
||||||
|
FocusKeeper.Snapshot s = FocusKeeper.capture(old, before);
|
||||||
|
assertNotNull(s);
|
||||||
|
assertEquals("mod/name", s.key());
|
||||||
|
|
||||||
|
JPanel after = form("llama");
|
||||||
|
assertSame(field(after), FocusKeeper.restore(s, after));
|
||||||
|
assertEquals(3, field(after).getCaretPosition());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void selectionAndShorterTextAreHandled() {
|
||||||
|
JPanel before = form("llamas");
|
||||||
|
JTextField old = field(before);
|
||||||
|
old.setCaretPosition(1);
|
||||||
|
old.moveCaretPosition(5);
|
||||||
|
FocusKeeper.Snapshot s = FocusKeeper.capture(old, before);
|
||||||
|
|
||||||
|
JPanel after = form("llamas");
|
||||||
|
FocusKeeper.restore(s, after);
|
||||||
|
assertEquals(1, field(after).getSelectionStart());
|
||||||
|
assertEquals(5, field(after).getSelectionEnd());
|
||||||
|
assertEquals(5, field(after).getCaretPosition());
|
||||||
|
|
||||||
|
JPanel shorter = form("ll");
|
||||||
|
FocusKeeper.restore(s, shorter);
|
||||||
|
assertEquals(2, field(shorter).getCaretPosition());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void spinnerEditorMapsToTheSpinner() {
|
||||||
|
JPanel before = form("x");
|
||||||
|
JSpinner sp = (JSpinner) ((JPanel) before.getComponent(0)).getComponent(1);
|
||||||
|
JTextField editor = ((JSpinner.DefaultEditor) sp.getEditor()).getTextField();
|
||||||
|
FocusKeeper.Snapshot s = FocusKeeper.capture(editor, before);
|
||||||
|
assertEquals("mod/count", s.key());
|
||||||
|
|
||||||
|
JPanel after = form("x");
|
||||||
|
JSpinner sp2 = (JSpinner) ((JPanel) after.getComponent(0)).getComponent(1);
|
||||||
|
assertSame(((JSpinner.DefaultEditor) sp2.getEditor()).getTextField(), FocusKeeper.restore(s, after));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void untaggedOrMissingGivesNull() {
|
||||||
|
JPanel root = new JPanel();
|
||||||
|
JTextField plain = new JTextField();
|
||||||
|
root.add(plain);
|
||||||
|
assertNull(FocusKeeper.capture(plain, root));
|
||||||
|
assertNull(FocusKeeper.capture(null, root));
|
||||||
|
assertNull(FocusKeeper.restore(new FocusKeeper.Snapshot("gone", 0, 0, 0), root));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.eliasmueller.llamamod.ui;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class MainFrameLayoutTest {
|
||||||
|
@Test
|
||||||
|
void headerShrinksForShortWindows() {
|
||||||
|
assertEquals(150, MainFrame.headerHeight(900));
|
||||||
|
assertEquals(104, MainFrame.headerHeight(760));
|
||||||
|
assertEquals(104, MainFrame.headerHeight(720));
|
||||||
|
// the 640 px minimum: the Saves table keeps four rows
|
||||||
|
assertEquals(92, MainFrame.headerHeight(640));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.eliasmueller.llamamod.ui;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import com.eliasmueller.llamamod.platform.Installer;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class SetupWizardProgressTest {
|
||||||
|
@Test
|
||||||
|
void barFollowsTheCopiedMegabytes() {
|
||||||
|
// 553 of 568 MB copied: the bar must say 97 %, not the 78 % of the whole setup
|
||||||
|
double copied = 553.0 / 568.0;
|
||||||
|
assertEquals(copied, SetupWizard.barFraction(copied * Installer.COPY_SHARE), 1e-9);
|
||||||
|
assertEquals(0, SetupWizard.barFraction(0));
|
||||||
|
assertEquals(1, SetupWizard.barFraction(Installer.COPY_SHARE));
|
||||||
|
assertEquals(1, SetupWizard.barFraction(0.9)); // checking/creating: full (shown as busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.eliasmueller.llamamod.ui;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import javax.swing.JLabel;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class StatusLineTest {
|
||||||
|
private static void onEdt(Runnable r) throws Exception {
|
||||||
|
SwingUtilities.invokeAndWait(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transientMessageGivesWayToTheLastingOne() throws Exception {
|
||||||
|
JLabel l = new JLabel(" ");
|
||||||
|
StatusLine s = new StatusLine(l, 150);
|
||||||
|
onEdt(() -> {
|
||||||
|
s.set("Ready.");
|
||||||
|
s.flash("Took over changes made in another LlamaMod window.");
|
||||||
|
});
|
||||||
|
assertEquals("Took over changes made in another LlamaMod window.", l.getText());
|
||||||
|
assertTrue(s.showingTransient());
|
||||||
|
Thread.sleep(600);
|
||||||
|
onEdt(() -> {});
|
||||||
|
assertEquals("Ready.", l.getText());
|
||||||
|
assertFalse(s.showingTransient());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newerLastingMessageIsNotOverwritten() throws Exception {
|
||||||
|
JLabel l = new JLabel(" ");
|
||||||
|
StatusLine s = new StatusLine(l, 150);
|
||||||
|
onEdt(() -> {
|
||||||
|
s.set("Ready.");
|
||||||
|
s.flash("merged");
|
||||||
|
s.set("Applying mods");
|
||||||
|
});
|
||||||
|
Thread.sleep(600);
|
||||||
|
onEdt(() -> {});
|
||||||
|
assertEquals("Applying mods", l.getText());
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
-32
@@ -14,14 +14,17 @@ Commands (NAME first; --display only for 'start'/'run', remembered afterwards):
|
|||||||
apptest.py apply NAME [ids] [--opt id.k=v]... apply mods (none = original) via the app CLI
|
apptest.py apply NAME [ids] [--opt id.k=v]... apply mods (none = original) via the app CLI
|
||||||
apptest.py unlock NAME SLOT WORLD CHAPTER unlock up to WORLD/CHAPTER, map cursor there
|
apptest.py unlock NAME SLOT WORLD CHAPTER unlock up to WORLD/CHAPTER, map cursor there
|
||||||
apptest.py start NAME --display N Xvfb :N + private pulse + game (app --launch)
|
apptest.py start NAME --display N Xvfb :N + private pulse + game (app --launch)
|
||||||
apptest.py tolevel NAME [--world W] main menu -> slot 3 -> map -> level -> gameplay
|
apptest.py tolevel NAME [--world W] [--slot S] main menu -> slot S (3) -> map -> level -> gameplay
|
||||||
apptest.py keys NAME Up:1.5 x sleep=1 a+x:0.5 shot:name ... key sequence (hold s, default 0.2)
|
apptest.py keys NAME Up:1.5 x sleep=1 a+x:0.5 shot:name ... key sequence (hold s, default 0.2)
|
||||||
apptest.py shot NAME out.png [--small] screenshot (1920x1080 PNG, --small = 960x540 JPEG)
|
apptest.py shot NAME out.png [--small] screenshot (1920x1080 PNG, --small = 960x540 JPEG)
|
||||||
apptest.py ocr NAME [WxH+X+Y] German OCR of the screen (or a crop)
|
apptest.py ocr NAME [WxH+X+Y] German OCR of the screen (or a crop)
|
||||||
apptest.py status NAME game/window/crash/audio state (exit 3 = crashed)
|
apptest.py status NAME game/window/crash/audio state (exit 3 = crashed)
|
||||||
apptest.py stop NAME kill game (wineserver -k), Xvfb and pulse of NAME
|
apptest.py stop NAME kill game (wineserver -k), Xvfb and pulse of NAME
|
||||||
apptest.py run NAME --display N --mods a,b [--opt id.k=v]... --level W C [--seq "..."] --out DIR [--keep]
|
apptest.py run NAME --display N --mods a,b [--opt id.k=v]... --level W C [--seq "..."] --out DIR [--keep]
|
||||||
all in one; exit 0 ok, 3 crash, 4 timeout, 2 usage
|
[--no-retry] all in one; exit 0 ok, 3 crash, 4 timeout, 2 usage.
|
||||||
|
A crash before the main menu or a missed save slot screen
|
||||||
|
is retried once (report.json "retries", out/retry1_*);
|
||||||
|
--no-retry turns that off
|
||||||
|
|
||||||
Options of every command: --root DIR, --jar FILE (default: newest build/libs/*-all.jar),
|
Options of every command: --root DIR, --jar FILE (default: newest build/libs/*-all.jar),
|
||||||
--iso FILE (default ~/Downloads/GROOVE_DK.iso). Python: `from apptest import Session`.
|
--iso FILE (default ~/Downloads/GROOVE_DK.iso). Python: `from apptest import Session`.
|
||||||
@@ -34,6 +37,7 @@ Safety: never uses display :0, never touches ~/.wine-lama, kills only PIDs it st
|
|||||||
import fcntl
|
import fcntl
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -51,6 +55,10 @@ SLOT_CROP = "1600x280+160+740" # save-slot text "Gespeichertes Spiel N /
|
|||||||
MENU_CROP = "700x300+1150+650" # main menu items (bottom right)
|
MENU_CROP = "700x300+1150+650" # main menu items (bottom right)
|
||||||
|
|
||||||
|
|
||||||
|
# world -> word the save slot shows for it (OCR, lower case); only the verified ones
|
||||||
|
SLOT_WORLD_NAMES = {1: "dorf", 9: "testlevel"}
|
||||||
|
|
||||||
|
|
||||||
class Crash(Exception):
|
class Crash(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -517,8 +525,8 @@ class Session:
|
|||||||
raise Timeout("timeout (%d s) waiting for %s" % (timeout, what))
|
raise Timeout("timeout (%d s) waiting for %s" % (timeout, what))
|
||||||
|
|
||||||
# ------------------------------------------------------------ navigation
|
# ------------------------------------------------------------ navigation
|
||||||
def tolevel(self, world=None, skip_flyby=True):
|
def tolevel(self, world=None, skip_flyby=True, on_menu=None, slot=3):
|
||||||
"""Main menu -> "Spiel starten" -> slot 3 (rightmost) -> map (cursor from --unlock ... --cursor)
|
"""Main menu -> "Spiel starten" -> save slot (default 3) -> map (cursor from --unlock ... --cursor)
|
||||||
-> Enter -> skip the fly-by -> gameplay. Returns timings."""
|
-> Enter -> skip the fly-by -> gameplay. Returns timings."""
|
||||||
T = {}
|
T = {}
|
||||||
t0 = tx = time.time()
|
t0 = tx = time.time()
|
||||||
@@ -528,10 +536,14 @@ class Session:
|
|||||||
raise Crash(c)
|
raise Crash(c)
|
||||||
if fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")):
|
if fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6, scale="100%")):
|
||||||
log("main menu")
|
log("main menu")
|
||||||
|
if on_menu:
|
||||||
|
on_menu()
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
self.key("Return")
|
self.key("Return", 0.6)
|
||||||
break
|
break
|
||||||
if fuzzy_in("gespeichertes", self.slot_text()):
|
if fuzzy_in("gespeichertes", self.slot_text()):
|
||||||
|
if on_menu:
|
||||||
|
on_menu()
|
||||||
break
|
break
|
||||||
if time.time() - tx > 40:
|
if time.time() - tx > 40:
|
||||||
log("no menu yet -> Return (skips logo/demo)")
|
log("no menu yet -> Return (skips logo/demo)")
|
||||||
@@ -541,21 +553,15 @@ class Session:
|
|||||||
raise Timeout("main menu not reached")
|
raise Timeout("main menu not reached")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
T["menu"] = round(time.time() - t0, 1)
|
T["menu"] = round(time.time() - t0, 1)
|
||||||
self.wait_for(lambda: fuzzy_in("gespeichertes", self.slot_text()), 20, "save slot screen")
|
self._to_slot_screen()
|
||||||
time.sleep(0.4)
|
t = self._select_slot(slot)
|
||||||
for _ in range(2): # 3-slot slider without wrap: slot 3 is the rightmost
|
if not self._slot_ok(t):
|
||||||
self.key("Right", 0.3)
|
# an empty slot starts a new game in the village: never the level that was asked for
|
||||||
time.sleep(0.3)
|
raise Timeout("save slot %d is empty (%s) - unlock it first" % (slot, t))
|
||||||
for i in range(3):
|
name = SLOT_WORLD_NAMES.get(world)
|
||||||
time.sleep(0.6)
|
if name and not fuzzy_in(name, t):
|
||||||
t = self.slot_text()
|
raise Timeout("save slot %d holds another world (%s), expected %r" % (slot, t, name))
|
||||||
if fuzzy_in("kapitel", t) and not fuzzy_in("leer", t):
|
self._open_slot()
|
||||||
log("slot: %s" % t.split(" | ")[0])
|
|
||||||
break
|
|
||||||
self.key("Right", 0.3)
|
|
||||||
else:
|
|
||||||
log("WARNING: slot 3 text not recognised (%s)" % t)
|
|
||||||
self.key("Return")
|
|
||||||
T["slot"] = round(time.time() - t0, 1)
|
T["slot"] = round(time.time() - t0, 1)
|
||||||
if world == 9:
|
if world == 9:
|
||||||
self.wait_for(lambda: self.classify() == "map", 30, "world 9 map")
|
self.wait_for(lambda: self.classify() == "map", 30, "world 9 map")
|
||||||
@@ -563,10 +569,11 @@ class Session:
|
|||||||
else:
|
else:
|
||||||
self.wait_for(lambda: self.classify() not in ("black", "none"), 30, "map")
|
self.wait_for(lambda: self.classify() not in ("black", "none"), 30, "map")
|
||||||
self.wait_stable(still=1.5, timeout=20)
|
self.wait_stable(still=1.5, timeout=20)
|
||||||
self.key("Return")
|
map_thumb = self.thumb()
|
||||||
|
self.key("Return", 0.6)
|
||||||
t_load = time.time()
|
t_load = time.time()
|
||||||
T["map"] = round(t_load - t0, 1)
|
T["map"] = round(t_load - t0, 1)
|
||||||
skipped, good, states = False, 0, []
|
skipped, good, states, again = False, 0, [], 0
|
||||||
while time.time() - t_load < 150:
|
while time.time() - t_load < 150:
|
||||||
c = self.crashed()
|
c = self.crashed()
|
||||||
if c:
|
if c:
|
||||||
@@ -575,7 +582,12 @@ class Session:
|
|||||||
if not states or states[-1] != st:
|
if not states or states[-1] != st:
|
||||||
states.append(st)
|
states.append(st)
|
||||||
log("state: %s (%.1f s after level start)" % (st, time.time() - t_load))
|
log("state: %s (%.1f s after level start)" % (st, time.time() - t_load))
|
||||||
if st == "letterbox" and skip_flyby and not skipped:
|
if "black" not in states and "letterbox" not in states and time.time() - t_load > 8 + 8 * again \
|
||||||
|
and again < 2 and self.diff(self.thumb(), map_thumb) < 6:
|
||||||
|
again += 1 # still on the map: the Return was missed
|
||||||
|
log("level did not start -> Return again")
|
||||||
|
self.key("Return", 0.6 + 0.3 * again)
|
||||||
|
elif st == "letterbox" and skip_flyby and not skipped:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
self.key("Return")
|
self.key("Return")
|
||||||
skipped = True
|
skipped = True
|
||||||
@@ -596,6 +608,79 @@ class Session:
|
|||||||
log("gameplay after %.1f s, states %s" % (T["gameplay"], states))
|
log("gameplay after %.1f s, states %s" % (T["gameplay"], states))
|
||||||
return T
|
return T
|
||||||
|
|
||||||
|
def _to_slot_screen(self, timeout=45):
|
||||||
|
"""Waits for the save slot screen; a Return the menu missed (still fading in, or a key press shorter
|
||||||
|
than one frame on a loaded machine) is repeated, held a little longer each time."""
|
||||||
|
t0 = tr = time.time()
|
||||||
|
holds = [0.8, 1.0, 1.2]
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
c = self.crashed()
|
||||||
|
if c:
|
||||||
|
raise Crash(c)
|
||||||
|
if fuzzy_in("gespeichertes", self.slot_text()):
|
||||||
|
return
|
||||||
|
if time.time() - tr > 5 and fuzzy_in("spiel starten", self.ocr(MENU_CROP, threshold=75, psm=6,
|
||||||
|
scale="100%")):
|
||||||
|
hold = holds.pop(0) if holds else 1.2
|
||||||
|
log("still in the main menu -> Return again (held %.1f s)" % hold)
|
||||||
|
self.key("Return", hold)
|
||||||
|
tr = time.time()
|
||||||
|
time.sleep(0.7)
|
||||||
|
raise Timeout("timeout (%d s) waiting for save slot screen" % timeout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _slot_ok(text):
|
||||||
|
return fuzzy_in("kapitel", text) and not fuzzy_in("leer", text)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _slot_no(text):
|
||||||
|
"""Slot number from "gespeichertes spiel N" (OCR text), or None."""
|
||||||
|
m = re.search(r"spiel\s*([123])\b", text)
|
||||||
|
return int(m.group(1)) if m else None
|
||||||
|
|
||||||
|
def _select_slot(self, slot=3, timeout=240):
|
||||||
|
"""Turns the save slot carousel (it opens on slot 2) to `slot` and checks its text after every step.
|
||||||
|
The carousel wraps (3 -> 1) and ignores keys while it turns, so Right is pressed once per step and
|
||||||
|
only again when the text shows another slot. A step that did not move is repeated with a longer
|
||||||
|
hold (0.3/0.6/1.0/1.5 s). On a busy machine (load ~30, several games) the carousel ignored
|
||||||
|
Left/Right for minutes while Return/Escape worked: `run` therefore uses slot 2, which needs no
|
||||||
|
turning."""
|
||||||
|
self.wait_stable(still=0.8, timeout=6)
|
||||||
|
holds = [0.3, 0.6, 1.0, 1.5]
|
||||||
|
t0 = time.time()
|
||||||
|
t = self.slot_text()
|
||||||
|
while time.time() - t0 < timeout:
|
||||||
|
if self._slot_no(t) == slot:
|
||||||
|
log("slot: %s" % t.split(" | ")[0])
|
||||||
|
return t
|
||||||
|
if not fuzzy_in("gespeichertes", t):
|
||||||
|
raise Timeout("save slot screen lost while selecting slot %d (%s)" % (slot, t))
|
||||||
|
before = self._slot_no(t)
|
||||||
|
self.key("Right", holds[0])
|
||||||
|
t1 = time.time()
|
||||||
|
while time.time() - t1 < 8: # the carousel turns, then the new text shows
|
||||||
|
time.sleep(0.6)
|
||||||
|
t = self.slot_text()
|
||||||
|
if self._slot_no(t) not in (None, before):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if len(holds) > 1:
|
||||||
|
holds.pop(0)
|
||||||
|
log("slot carousel did not move (slot %s) -> Right again, held %.1f s" % (before, holds[0]))
|
||||||
|
raise Timeout("save slot %d not selected after %d s (%s)" % (slot, timeout, t))
|
||||||
|
|
||||||
|
def _open_slot(self):
|
||||||
|
"""Return on the slot until the slot screen is gone (a short press can be missed under load)."""
|
||||||
|
for hold in (0.6, 0.9, 1.2):
|
||||||
|
self.key("Return", hold)
|
||||||
|
t1 = time.time()
|
||||||
|
while time.time() - t1 < 6:
|
||||||
|
time.sleep(0.7)
|
||||||
|
if not fuzzy_in("gespeichertes", self.slot_text()):
|
||||||
|
return
|
||||||
|
log("still on the save slot screen -> Return again")
|
||||||
|
raise Timeout("save slot did not open")
|
||||||
|
|
||||||
def status(self):
|
def status(self):
|
||||||
s = {"display": self.display, "data": self.data, "game_pids": self.game_pids(),
|
s = {"display": self.display, "data": self.data, "game_pids": self.game_pids(),
|
||||||
"window": self.find_window() if self.display and self.state.get("xvfb_pid") else None,
|
"window": self.find_window() if self.display and self.state.get("xvfb_pid") else None,
|
||||||
@@ -606,18 +691,53 @@ class Session:
|
|||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
def run(sess, mods, opts, level, seq, out, keep=False):
|
def start_to_level(sess, level, out, report, retry=True):
|
||||||
|
"""start + tolevel; a crash before the main menu (Wine page faults at start-up, not caused by mods) or a
|
||||||
|
menu that was not recognised is retried once with a fresh game start. Each retry is listed in
|
||||||
|
report["retries"] and logged; the failed attempt's screenshot/log go to out/retry1_*."""
|
||||||
|
for attempt in (1, 2):
|
||||||
|
phase = "start"
|
||||||
|
try:
|
||||||
|
sess.start()
|
||||||
|
if level:
|
||||||
|
phase = "menu"
|
||||||
|
report["tolevel"] = sess.tolevel(world=level[0], slot=2,
|
||||||
|
on_menu=lambda: report.update(menu_reached=True))
|
||||||
|
return
|
||||||
|
except (Crash, Timeout) as e:
|
||||||
|
early = isinstance(e, Crash) and not report.get("menu_reached") or \
|
||||||
|
isinstance(e, Timeout) and "save slot" in str(e)
|
||||||
|
if not retry or attempt == 2 or not early:
|
||||||
|
raise
|
||||||
|
log("WARNING: %s during %s -> stopping and starting the game once more" % (e, phase))
|
||||||
|
try:
|
||||||
|
sess.shot(os.path.join(out, "retry1_failure.jpg"), small=True)
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
gl = os.path.join(sess.data, "logs", "game.log")
|
||||||
|
if os.path.exists(gl):
|
||||||
|
shutil.copy(gl, os.path.join(out, "retry1_game.log"))
|
||||||
|
report.setdefault("retries", []).append({"attempt": attempt, "phase": phase, "error": str(e)})
|
||||||
|
report.pop("menu_reached", None)
|
||||||
|
sess.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def run(sess, mods, opts, level, seq, out, keep=False, retry=True):
|
||||||
os.makedirs(out, exist_ok=True)
|
os.makedirs(out, exist_ok=True)
|
||||||
report = {"mods": mods, "opts": opts, "level": level}
|
# a busy machine (several agents' games) slows the game to a few frames per second: the usual cause of
|
||||||
|
# missed key presses; recorded to tell flaky runs apart
|
||||||
|
report = {"mods": mods, "opts": opts, "level": level, "load_avg": [round(x, 1) for x in os.getloadavg()],
|
||||||
|
"cpus": os.cpu_count()}
|
||||||
code = 0
|
code = 0
|
||||||
try:
|
try:
|
||||||
sess.setup()
|
sess.setup()
|
||||||
report["apply"] = sess.apply(mods, opts).strip().splitlines()[-3:]
|
report["apply"] = sess.apply(mods, opts).strip().splitlines()[-3:]
|
||||||
if level:
|
if level:
|
||||||
|
# slot 2 is where the carousel opens (no turning, see _select_slot); slot 3 gets the same
|
||||||
|
# progress for mods whose "save slot" option defaults to 3
|
||||||
|
sess.unlock(2, level[0], level[1])
|
||||||
sess.unlock(3, level[0], level[1])
|
sess.unlock(3, level[0], level[1])
|
||||||
sess.start()
|
start_to_level(sess, level, out, report, retry)
|
||||||
if level:
|
|
||||||
report["tolevel"] = sess.tolevel(world=level[0])
|
|
||||||
sess.shot(os.path.join(out, "00_start.png"))
|
sess.shot(os.path.join(out, "00_start.png"))
|
||||||
if seq:
|
if seq:
|
||||||
report["shots"] = sess.keys(seq, outdir=out)
|
report["shots"] = sess.keys(seq, outdir=out)
|
||||||
@@ -647,7 +767,8 @@ def run(sess, mods, opts, level, seq, out, keep=False):
|
|||||||
json.dump(report, open(os.path.join(out, "report.json"), "w"), indent=1)
|
json.dump(report, open(os.path.join(out, "report.json"), "w"), indent=1)
|
||||||
if not keep:
|
if not keep:
|
||||||
sess.stop()
|
sess.stop()
|
||||||
log("result: %s" % (report.get("error") or "OK"))
|
retried = "" if not report.get("retries") else " (after a retry: %s)" % report["retries"][0]["error"]
|
||||||
|
log("result: %s%s" % (report.get("error") or "OK", retried))
|
||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
@@ -660,7 +781,8 @@ def main(argv):
|
|||||||
i = 0
|
i = 0
|
||||||
while i < len(rest):
|
while i < len(rest):
|
||||||
a = rest[i]
|
a = rest[i]
|
||||||
if a in ("--display", "--root", "--jar", "--iso", "--mods", "--world", "--seq", "--out") and i + 1 < len(rest):
|
if a in ("--display", "--root", "--jar", "--iso", "--mods", "--world", "--slot", "--seq", "--out") \
|
||||||
|
and i + 1 < len(rest):
|
||||||
kw[a[2:]] = rest[i + 1]
|
kw[a[2:]] = rest[i + 1]
|
||||||
i += 2
|
i += 2
|
||||||
elif a == "--opt" and i + 1 < len(rest):
|
elif a == "--opt" and i + 1 < len(rest):
|
||||||
@@ -685,7 +807,8 @@ def main(argv):
|
|||||||
elif cmd == "start":
|
elif cmd == "start":
|
||||||
s.start()
|
s.start()
|
||||||
elif cmd == "tolevel":
|
elif cmd == "tolevel":
|
||||||
print(json.dumps(s.tolevel(world=int(kw["world"]) if "world" in kw else None)))
|
print(json.dumps(s.tolevel(world=int(kw["world"]) if "world" in kw else None,
|
||||||
|
slot=int(kw.get("slot", 3)))))
|
||||||
elif cmd == "keys":
|
elif cmd == "keys":
|
||||||
print(s.keys(pos, outdir=kw.get("out")))
|
print(s.keys(pos, outdir=kw.get("out")))
|
||||||
elif cmd == "shot":
|
elif cmd == "shot":
|
||||||
@@ -703,7 +826,8 @@ def main(argv):
|
|||||||
print("run needs --display N and --out DIR")
|
print("run needs --display N and --out DIR")
|
||||||
return 2
|
return 2
|
||||||
mods = [x for x in kw.get("mods", "").split(",") if x]
|
mods = [x for x in kw.get("mods", "").split(",") if x]
|
||||||
return run(s, mods, opts, kw.get("level"), kw.get("seq"), kw["out"], keep="--keep" in pos)
|
return run(s, mods, opts, kw.get("level"), kw.get("seq"), kw["out"], keep="--keep" in pos,
|
||||||
|
retry="--no-retry" not in pos)
|
||||||
else:
|
else:
|
||||||
print(__doc__)
|
print(__doc__)
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
+11
-2
@@ -9,7 +9,8 @@ Writes to outdir:
|
|||||||
page_NN.png texture pages (256x256, from TEXT)
|
page_NN.png texture pages (256x256, from TEXT)
|
||||||
collision.obj (--collision) collision polygons, grouped by material
|
collision.obj (--collision) collision polygons, grouped by material
|
||||||
objects.obj (--objects) static STPC object models side by side
|
objects.obj (--objects) static STPC object models side by side
|
||||||
preview_top.png, preview_persp.png textured software renderings for checking
|
preview_top.png, preview_persp.png textured software renderings for checking (game space,
|
||||||
|
the same way round as in the game)
|
||||||
|
|
||||||
Coordinates: OBJ file space = game space (x, y, z_game): right-handed, y up, z > 0 in the grid, as
|
Coordinates: OBJ file space = game space (x, y, z_game): right-handed, y up, z > 0 in the grid, as
|
||||||
Blender shows the level the same way round as the game (geomlib.to_file). --export-space writes
|
Blender shows the level the same way round as the game (geomlib.to_file). --export-space writes
|
||||||
@@ -101,6 +102,11 @@ def write_mtl(path, tris, texs, npages):
|
|||||||
|
|
||||||
# ------------------------------------------------------------ software renderer
|
# ------------------------------------------------------------ software renderer
|
||||||
|
|
||||||
|
def game_space_tris(tris):
|
||||||
|
"""world_tris (export space) -> game space for the right-handed preview camera."""
|
||||||
|
return [([G.to_file(p) for p in P], *rest) for P, *rest in tris]
|
||||||
|
|
||||||
|
|
||||||
def render(tris, pages, cam, size, path, texs):
|
def render(tris, pages, cam, size, path, texs):
|
||||||
Wd, Hd = size
|
Wd, Hd = size
|
||||||
eye, target, fov = cam
|
eye, target, fov = cam
|
||||||
@@ -237,10 +243,13 @@ def main():
|
|||||||
for k, im in enumerate(imgs):
|
for k, im in enumerate(imgs):
|
||||||
Image.fromarray(im).save(os.path.join(a.outdir, f'page_{k:02d}.png'))
|
Image.fromarray(im).save(os.path.join(a.outdir, f'page_{k:02d}.png'))
|
||||||
size = tuple(int(s) for s in a.size.split('x'))
|
size = tuple(int(s) for s in a.size.split('x'))
|
||||||
|
# the renderer's camera is right-handed: game space shows the level as the game does
|
||||||
|
# (export space would come out mirrored); also with --export-space
|
||||||
|
tris = game_space_tris(tris)
|
||||||
P = np.array([p for t in tris for p in t[0]])
|
P = np.array([p for t in tris for p in t[0]])
|
||||||
lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2
|
lo, hi = P.min(0), P.max(0); c = (lo + hi) / 2
|
||||||
ext = max(hi[0] - lo[0], hi[2] - lo[2])
|
ext = max(hi[0] - lo[0], hi[2] - lo[2])
|
||||||
# top view (almost vertical, north = -z at the top)
|
# top view (almost vertical, game -z at the top)
|
||||||
render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size,
|
render(tris, imgs, ((c[0], hi[1] + ext * 1.2, c[2] + 0.01), (c[0], lo[1], c[2]), 50), size,
|
||||||
os.path.join(a.outdir, 'preview_top.png'), texs)
|
os.path.join(a.outdir, 'preview_top.png'), texs)
|
||||||
render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size,
|
render(tris, imgs, ((c[0] + ext * 0.35, hi[1] + ext * 0.45, hi[2] + ext * 0.45), tuple(c), 60), size,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Offline tests of apptest's retry/slot logic (no game needed): python3 -m unittest tools/test_apptest.py"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import apptest as A # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSession:
|
||||||
|
def __init__(self, outcomes):
|
||||||
|
self.outcomes = list(outcomes) # per start: None ok, or exception raised by start/tolevel
|
||||||
|
self.data = tempfile.mkdtemp()
|
||||||
|
self.starts = self.stops = 0
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.starts += 1
|
||||||
|
o = self.outcomes.pop(0)
|
||||||
|
if isinstance(o, A.Crash) and "start" in str(o):
|
||||||
|
raise o
|
||||||
|
self._next = o
|
||||||
|
|
||||||
|
def tolevel(self, world=None, slot=3, on_menu=None):
|
||||||
|
o = self._next
|
||||||
|
if isinstance(o, A.Crash) and "level" in str(o):
|
||||||
|
on_menu()
|
||||||
|
raise o
|
||||||
|
if o:
|
||||||
|
raise o
|
||||||
|
on_menu()
|
||||||
|
return {"slot": slot}
|
||||||
|
|
||||||
|
def shot(self, path, small=False):
|
||||||
|
open(path, "w").close()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stops += 1
|
||||||
|
|
||||||
|
|
||||||
|
class StartRetryTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.out = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def test_early_crash_is_retried_once_and_reported(self):
|
||||||
|
s, rep = FakeSession([A.Crash("start: page fault"), None]), {}
|
||||||
|
A.start_to_level(s, (1, 1), self.out, rep)
|
||||||
|
self.assertEqual(2, s.starts)
|
||||||
|
self.assertEqual([{"attempt": 1, "phase": "start", "error": "start: page fault"}], rep["retries"])
|
||||||
|
self.assertEqual({"slot": 2}, rep["tolevel"])
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(self.out, "retry1_failure.jpg")))
|
||||||
|
|
||||||
|
def test_missed_slot_screen_is_retried(self):
|
||||||
|
s, rep = FakeSession([A.Timeout("timeout (45 s) waiting for save slot screen"), None]), {}
|
||||||
|
A.start_to_level(s, (1, 1), self.out, rep)
|
||||||
|
self.assertEqual("menu", rep["retries"][0]["phase"])
|
||||||
|
|
||||||
|
def test_crash_after_the_menu_is_not_retried(self):
|
||||||
|
s, rep = FakeSession([A.Crash("level: page fault"), None]), {}
|
||||||
|
with self.assertRaises(A.Crash):
|
||||||
|
A.start_to_level(s, (1, 1), self.out, rep)
|
||||||
|
self.assertEqual(1, s.starts)
|
||||||
|
self.assertNotIn("retries", rep)
|
||||||
|
|
||||||
|
def test_second_failure_and_no_retry_raise(self):
|
||||||
|
s = FakeSession([A.Crash("start: a"), A.Crash("start: b")])
|
||||||
|
with self.assertRaises(A.Crash):
|
||||||
|
A.start_to_level(s, None, self.out, {})
|
||||||
|
self.assertEqual(2, s.starts)
|
||||||
|
s = FakeSession([A.Crash("start: a"), None])
|
||||||
|
with self.assertRaises(A.Crash):
|
||||||
|
A.start_to_level(s, None, self.out, {}, retry=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SlotTextTest(unittest.TestCase):
|
||||||
|
def test_slot_number_and_content(self):
|
||||||
|
S = A.Session
|
||||||
|
self.assertEqual(2, S._slot_no(". gespeichertes spiel 2 u leer | gespeichertes spiel 2 u leer"))
|
||||||
|
self.assertEqual(3, S._slot_no("gespeichertes spiel 3 dorf kapitel 1"))
|
||||||
|
self.assertIsNone(S._slot_no("eingabe neues spiel starten"))
|
||||||
|
self.assertTrue(S._slot_ok("gespeichertes spiel 2 u testlevel kapitel 1 goldmünzen"))
|
||||||
|
self.assertFalse(S._slot_ok("gespeichertes spiel 2 u leer"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user