diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/AppGui.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/AppGui.java index d4a0a73..c385ec1 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/AppGui.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/AppGui.java @@ -14,6 +14,7 @@ import javax.swing.SwingUtilities; @Slf4j public final class AppGui { + /** Utility class; not instantiable. */ private AppGui() { } @@ -29,8 +30,13 @@ public final class AppGui { try { App.run(io); } catch (RuntimeException e) { + // The worker is the only consumer of the input queue, so once + // it dies nothing can service the window again. Tell the player + // what happened and close down rather than leaving a live-looking + // window that silently ignores every command. log.error("Fatal error during game startup", e); - io.write("Fatal error: " + e.getMessage()); + io.fatal("Fatal error: " + e.getMessage() + + "\n\nThe game cannot continue and will close."); } }, "game-loop"); worker.setDaemon(true); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java index eeea0be..303ff18 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/OggMusicBackend.java @@ -7,6 +7,7 @@ import javax.sound.sampled.AudioInputStream; import javax.sound.sampled.AudioSystem; import javax.sound.sampled.FloatControl; import javax.sound.sampled.SourceDataLine; +import java.util.concurrent.atomic.AtomicBoolean; import java.io.BufferedInputStream; import java.io.InputStream; @@ -20,17 +21,46 @@ import java.io.InputStream; @Slf4j public final class OggMusicBackend implements MusicBackend { + /** Classpath directory the bundled tracks are looked up in. */ private static final String MUSIC_DIR = "/music/"; + + /** Duration of a fade-in or fade-out, in milliseconds. */ private static final int FADE_MS = 400; + + /** Number of gain steps a fade is split into. */ private static final int FADE_STEPS = 20; + + /** Size of the PCM transfer buffer, in bytes. */ private static final int BUFFER = 4096; + + /** Gain a fade starts from and ends at, in decibels. */ private static final float FLOOR_DB = -40f; // near-silence for fades + /** The currently playing thread, or null when silent. */ private volatile Thread playThread; - private volatile boolean stopping; + + /** + * Stop token of the current play thread. Each playback owns its own token + * rather than sharing one flag: if {@link #stopCurrent()} times out waiting + * for a thread, that thread's token stays set, so it still winds down and + * can never be revived by the next {@link #fadeTo}. + */ + private volatile AtomicBoolean playStop; + + /** Output line of the current playback, used by {@link #setGainDb}. */ private volatile SourceDataLine line; + + /** Gain most recently requested by the player, in decibels. */ private volatile float targetGainDb = MusicLevel.MEDIUM.gainDb(); + /** + * Stops the current track and starts {@code track} on a fresh daemon thread, + * fading in to {@code gainDb}. A track that is not on the classpath is logged + * and leaves playback silent. + * + * @param track file name below {@code /music/} + * @param gainDb target MASTER_GAIN in decibels + */ @Override public synchronized void fadeTo(String track, float gainDb) { stopCurrent(); @@ -40,17 +70,24 @@ public final class OggMusicBackend implements MusicBackend { log.warn("Music resource not found, staying silent: {}", resource); return; } - stopping = false; - playThread = new Thread(() -> playLoop(resource, gainDb), "music"); + AtomicBoolean stop = new AtomicBoolean(false); + playStop = stop; + playThread = new Thread(() -> playLoop(resource, gainDb, stop), "music"); playThread.setDaemon(true); playThread.start(); } + /** Fades the current track out and stops the play thread. */ @Override public synchronized void fadeOut() { stopCurrent(); } + /** + * Applies a new gain to the running playback immediately, without a fade. + * + * @param gainDb target MASTER_GAIN in decibels + */ @Override public synchronized void setGainDb(float gainDb) { targetGainDb = gainDb; @@ -60,30 +97,51 @@ public final class OggMusicBackend implements MusicBackend { } } + /** Stops playback and releases the audio line. */ @Override public synchronized void shutdown() { stopCurrent(); } - /** Signals the play thread to stop and waits for its fade-out + cleanup. */ + /** + * Signals the play thread to stop and waits for its fade-out + cleanup. + * The thread's stop token stays set even if the join times out, so a slow + * thread still terminates on its own instead of playing on forever. + */ private void stopCurrent() { Thread t = playThread; + AtomicBoolean stop = playStop; if (t == null) { return; } - stopping = true; + if (stop != null) { + stop.set(true); + } try { t.join(FADE_MS + 600L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + if (t.isAlive()) { + log.warn("Music thread did not stop within the join budget; it will wind down on its own"); + } playThread = null; + playStop = null; } - private void playLoop(String resource, float gainDb) { + /** + * Body of the play thread: decodes the resource to PCM and writes it to the + * output line, restarting at the end of the stream until {@code stop} is set. + * Decoding failures are logged and end playback. Runs a fade-out on exit. + * + * @param resource classpath path of the OGG file + * @param gainDb gain to fade in to + * @param stop this playback's stop token + */ + private void playLoop(String resource, float gainDb, AtomicBoolean stop) { SourceDataLine l = null; try { - while (!stopping) { + while (!stop.get()) { // Reopen the bundled resource each loop; BufferedInputStream gives // the SPI the mark/reset support it needs to read the OGG header. try (InputStream res = getClass().getResourceAsStream(resource); @@ -97,12 +155,15 @@ public final class OggMusicBackend implements MusicBackend { l = AudioSystem.getSourceDataLine(pcm); l.open(pcm); l.start(); - line = l; - ramp(l, FLOOR_DB, gainDb); // fade in once + // Only publish the line if this playback is still current. + if (playStop == stop) { + line = l; + } + ramp(l, FLOOR_DB, gainDb, stop); // fade in once } byte[] buf = new byte[BUFFER]; int n; - while (!stopping && (n = din.read(buf, 0, buf.length)) != -1) { + while (!stop.get() && (n = din.read(buf, 0, buf.length)) != -1) { l.write(buf, 0, n); } } @@ -113,24 +174,44 @@ public final class OggMusicBackend implements MusicBackend { } finally { if (l != null) { try { - ramp(l, currentGain(l), FLOOR_DB); // fade out + // Fade-out must run to completion, so it gets a fresh token. + ramp(l, currentGain(l), FLOOR_DB, new AtomicBoolean(false)); } catch (RuntimeException ignored) { // best-effort fade } l.stop(); l.close(); } - line = null; + // Only clear the shared line if it still belongs to this playback. + if (playStop == stop) { + line = null; + } } } - private void ramp(SourceDataLine l, float fromDb, float toDb) { + /** + * Ramps the gain, aborting early if this playback has been stopped. + * + * @param l the line to ramp + * @param fromDb starting gain in decibels + * @param toDb final gain in decibels + * @param stop stop token checked between steps + */ + private void ramp(SourceDataLine l, float fromDb, float toDb, AtomicBoolean stop) { for (int i = 0; i <= FADE_STEPS; i++) { + if (stop.get()) { + return; + } applyGain(l, fromDb + (toDb - fromDb) * i / FADE_STEPS); sleep(FADE_MS / FADE_STEPS); } } + /** + * @param l the line to read + * @return the line's current gain, or {@link #targetGainDb} if the platform + * offers no master-gain control + */ private float currentGain(SourceDataLine l) { try { return ((FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN)).getValue(); @@ -139,6 +220,13 @@ public final class OggMusicBackend implements MusicBackend { } } + /** + * Sets the line's gain, clamped to the range it supports. Lines without a + * master-gain control are left untouched. + * + * @param l the line to adjust + * @param db requested gain in decibels + */ private void applyGain(SourceDataLine l, float db) { try { FloatControl gain = (FloatControl) l.getControl(FloatControl.Type.MASTER_GAIN); @@ -148,6 +236,11 @@ public final class OggMusicBackend implements MusicBackend { } } + /** + * Sleeps, restoring the interrupt flag instead of propagating. + * + * @param ms milliseconds to sleep + */ private void sleep(long ms) { try { Thread.sleep(ms); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java index 65184df..7df4d1d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -15,6 +15,7 @@ import javax.swing.BoxLayout; import javax.swing.JButton; import javax.swing.JComponent; import javax.swing.JFrame; +import javax.swing.JOptionPane; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; @@ -71,20 +72,50 @@ public class SwingIO implements GameIO { /** Side-panel width as a fraction of the window width, with a lower bound only. */ private static final double SIDE_RATIO = 0.34; + + /** Lower bound for the side-panel width, in unscaled pixels. */ private static final int SIDE_MIN = 220; + /** Background music of the current room. */ private final MusicController music = new MusicController(new OggMusicBackend()); + + /** + * Lines typed into the input field, handed from the Swing EDT to the game + * loop, which blocks on {@link #readLine()}. + */ private final LinkedBlockingQueue inputs = new LinkedBlockingQueue<>(); + + /** The game window. */ private final JFrame frame; + + /** Centre region: the scrolling transcript of styled output. */ private final JTextPane output; + + /** Document behind {@link #output}; all text is appended to it. */ private final StyledDocument doc; + + /** Bottom region: the command line the player types into. */ private final JTextField input; + + /** Top region: the single-line status bar. */ private final JLabel hud; + + /** Right region (upper): the drawn map. */ private final MapPanel map; + + /** Scroll pane wrapping {@link #map}. */ private final JScrollPane sideScroll; + + /** Right region (lower): the quest log. */ private final QuestPanel quests; + + /** Scroll pane wrapping {@link #quests}. */ private final JScrollPane questScroll; + + /** Right region: container holding {@link #sideScroll} and {@link #questScroll}. */ private final JPanel sidePanel; + + /** Font all component fonts are derived from; bundled TTF or a monospaced fallback. */ private final Font baseFont; /** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */ @@ -93,6 +124,12 @@ public class SwingIO implements GameIO { /** User zoom multiplier, adjusted live via keyboard. */ private float zoom = 1f; + /** + * Builds the window, wires the input field to the input queue, installs the + * zoom keys, and shows the frame. + * + * @param title window title + */ public SwingIO(String title) { baseFont = loadFont(); uiScale = detectScale(); @@ -156,7 +193,11 @@ public class SwingIO implements GameIO { input.requestFocusInWindow(); } - /** Loads the bundled font if present, else falls back to a logical monospaced font. */ + /** + * Loads the bundled font if present, else falls back to a logical monospaced font. + * + * @return the font all component fonts are derived from + */ private Font loadFont() { try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) { if (in != null) { @@ -170,7 +211,11 @@ public class SwingIO implements GameIO { return new Font(Font.MONOSPACED, Font.PLAIN, 12); } - /** Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. */ + /** + * Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. + * + * @return the display scale, at least 1.0 + */ private float detectScale() { try { GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment() @@ -207,6 +252,8 @@ public class SwingIO implements GameIO { * Auto-scale derived from the window size relative to the design size * (900x600). At the initial size this equals {@link #uiScale}; growing the * window grows the text, mirroring how the map fits its panel. + * + * @return the auto-scale, clamped to 0.6–2.6 */ private float frameScale() { double s = Math.min(frame.getWidth() / 900.0, frame.getHeight() / 600.0); @@ -242,10 +289,23 @@ public class SwingIO implements GameIO { root.getActionMap().put("zoomReset", action(e -> setZoom(1f))); } + /** + * Maps a key stroke to an action name on the component's window-scoped input map. + * + * @param c the component whose input map is extended + * @param ks the key stroke to bind + * @param name the action-map key it triggers + */ private void bind(JComponent c, KeyStroke ks, String name) { c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name); } + /** + * Adapts a lambda to a Swing action. + * + * @param body code run when the action fires + * @return the action + */ private AbstractAction action(java.util.function.Consumer body) { return new AbstractAction() { @Override @@ -255,11 +315,20 @@ public class SwingIO implements GameIO { }; } + /** + * Sets the zoom, clamped to 0.6–3.0, and re-applies the fonts. + * + * @param z the requested zoom multiplier + */ private void setZoom(float z) { zoom = Math.max(0.6f, Math.min(3.0f, z)); applyFonts(); } + /** + * @param s the semantic role + * @return the colour this frontend paints that role in + */ private Color colorFor(Style s) { return switch (s) { case HEADING -> new Color(0xc9, 0x8a, 0xe0); @@ -272,6 +341,12 @@ public class SwingIO implements GameIO { }; } + /** + * Appends the spans to the output pane and scrolls to the end. Runs on the + * Swing EDT; safe to call from the game loop. + * + * @param spans the styled runs to append + */ private void appendSpans(List spans) { SwingUtilities.invokeLater(() -> { try { @@ -357,6 +432,23 @@ public class SwingIO implements GameIO { }); } + /** + * Reports an unrecoverable error and closes the GUI. The game loop runs on + * the only thread that consumes player input, so if it dies the window would + * otherwise stay open and silently swallow every command. Shows a modal + * dialog, then exits. + * + * @param message text shown to the player + */ + public void fatal(String message) { + music.shutdown(); + SwingUtilities.invokeLater(() -> { + JOptionPane.showMessageDialog(frame, message, "Haunted Manor", JOptionPane.ERROR_MESSAGE); + frame.dispose(); + System.exit(1); + }); + } + @Override public void setMap(MapView view) { map.show(view); @@ -405,7 +497,11 @@ public class SwingIO implements GameIO { /** Current menu being shown, so a window resize can rebuild it at the new scale. */ private String menuTitle; + + /** Options of the menu currently shown, or null when no menu is visible. */ private List menuOptions; + + /** Queue the current menu's chosen index is offered to. */ private LinkedBlockingQueue menuPicked; /** Rebuilds the visible menu overlay at the current window scale (called on resize). */ @@ -415,7 +511,15 @@ public class SwingIO implements GameIO { } } - /** Installs the menu as the frame's glass pane (covers everything) and shows it. */ + /** + * Installs the menu as the frame's glass pane (covers everything) and shows it. + * Disables the input field while the menu is up. A button click offers that + * button's index to {@code picked}; Escape offers the last option. + * + * @param title heading shown above the options + * @param options option labels, one button each + * @param picked queue the chosen index is offered to + */ private void showMenuOverlay(String title, List options, LinkedBlockingQueue picked) { menuTitle = title; menuOptions = options; @@ -463,24 +567,55 @@ public class SwingIO implements GameIO { glass.add(card); // single child → centered by GridBagLayout frame.setGlassPane(glass); glass.setVisible(true); + // The glass pane swallows mouse events but cannot take keyboard focus, + // so the input field would stay live behind the menu and queue commands + // that get replayed once the menu closes. Disable it for the duration. + input.setEnabled(false); glass.requestFocusInWindow(); } + /** + * Hides the menu overlay, discards anything typed while it was up, and + * returns focus to the input field. + */ private void hideMenuOverlay() { menuOptions = null; // stop resize rebuilds once the menu is dismissed frame.getGlassPane().setVisible(false); + // Drop anything typed before the field was disabled, so a stale line + // cannot be replayed as a game command. + inputs.clear(); + input.setEnabled(true); + input.requestFocusInWindow(); } /** Palette for the themed menu overlay, matching the game window's dark look. */ private static final Color MENU_BG = new Color(0x0b, 0x0e, 0x13); + + /** Colour of the menu heading. */ private static final Color MENU_TITLE = new Color(0xc9, 0x8a, 0xe0); + + /** Menu-button background at rest. */ private static final Color BTN_BG = new Color(0x16, 0x1b, 0x24); + + /** Menu-button background under the mouse. */ private static final Color BTN_BG_HOVER = new Color(0x22, 0x2a, 0x37); + + /** Menu-button label colour. */ private static final Color BTN_FG = new Color(0xcf, 0xd6, 0xe0); + + /** Menu-button outline at rest. */ private static final Color BTN_LINE = new Color(0x2a, 0x2f, 0x3a); + + /** Menu-button outline under the mouse. */ private static final Color BTN_LINE_HOVER = new Color(0x6f, 0xcf, 0x73); - /** A flat, dark, fixed-width menu button matching the game palette, with hover feedback. */ + /** + * A flat, dark, fixed-width menu button matching the game palette, with hover feedback. + * + * @param text the button label + * @param scale the scale its size and font are derived from + * @return the button + */ private JButton menuButton(String text, float scale) { int padV = (int) (11 * scale); int padH = (int) (18 * scale); @@ -514,12 +649,25 @@ public class SwingIO implements GameIO { return b; } + /** + * @param line outline colour + * @param padV vertical inner padding, in pixels + * @param padH horizontal inner padding, in pixels + * @return a one-pixel outline with the given inner padding + */ private static javax.swing.border.Border menuButtonBorder(Color line, int padV, int padH) { return BorderFactory.createCompoundBorder( BorderFactory.createLineBorder(line, 1), BorderFactory.createEmptyBorder(padV, padH, padV, padH)); } + /** + * Appends the entries as comma-separated spans of the given style. + * + * @param spans target list the spans are added to + * @param xs the entries + * @param st style applied to each entry + */ private void addList(List spans, List xs, Style st) { for (int i = 0; i < xs.size(); i++) { if (i > 0) { diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java index f812d4f..135ba18 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/save/SaveService.java @@ -23,9 +23,13 @@ import java.util.stream.Stream; @Slf4j public class SaveService { + /** Name of the settings file, which shares the directory but is not a save slot. */ private static final String SETTINGS_FILENAME = "settings.json"; + /** Directory holding one JSON file per slot. */ private final Path dir; + + /** Jackson mapper used for both directions. */ private final ObjectMapper mapper = new ObjectMapper(); /** Uses the default {@code ./saves} directory. */ @@ -33,21 +37,47 @@ public class SaveService { this(Path.of("saves")); } + /** + * Uses a custom save directory; it is created on the first write. + * + * @param dir directory the slot files are read from and written to + */ public SaveService(Path dir) { this.dir = dir; } - /** Slug used for the on-disk filename of a slot name. */ + /** + * Slug used for the on-disk filename of a slot name. + * + *

The mapping must be injective: two different slot names may never + * produce the same filename, or one save would silently overwrite the + * other. Sanitising is lossy ({@code "Bedroom Run"} and {@code "Bedroom_Run"} + * both reduce to {@code bedroom_run}), so whenever a character actually had + * to be replaced, a short hash of the original name is appended. Names that + * survive sanitising unchanged keep their clean filename. + * + * @param slotName the player-visible save name + * @return a filesystem-safe, collision-free slug + */ public static String slug(String slotName) { - String s = slotName.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]+", "_"); + String raw = slotName.trim().toLowerCase(Locale.ROOT); + String s = raw.replaceAll("[^a-z0-9_-]+", "_"); if (s.isBlank()) { return "save"; } + if (!s.equals(raw)) { + s = s + "_" + Integer.toHexString(raw.hashCode() & 0xffff); + } // Never let a user-named save collide with the settings file. return s.equals("settings") ? "settings_" : s; } - /** Writes the session to {@code .json} atomically. */ + /** + * Writes the session to {@code .json} atomically. + * + * @param session the session to persist; its slot name determines the filename + * @throws SaveException if the file could not be written + */ public void save(GameSession session) { SaveData data = SaveCodec.capture(session); try { @@ -70,7 +100,11 @@ public class SaveService { } } - /** Lists all readable slots, newest first; skips unreadable files. */ + /** + * Lists all readable slots, newest first; skips unreadable files. + * + * @return slot metadata sorted by save time, newest first; empty if the directory does not exist + */ public List list() { if (!Files.isDirectory(dir)) { return List.of(); @@ -97,7 +131,13 @@ public class SaveService { return out; } - /** Loads a slot by slug: read JSON, reload world, overlay snapshot. */ + /** + * Loads a slot by slug: read JSON, reload world, overlay snapshot. + * + * @param slug filename stem of the slot, as reported by {@link SaveSlotInfo#slug()} + * @return a session with the world freshly loaded and the snapshot applied + * @throws SaveException if the file is unreadable or its schema version is not supported + */ public GameSession load(String slug) { Path file = dir.resolve(slug + ".json"); SaveData data; diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java index f5aba96..e3b2367 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/save/SaveServiceTest.java @@ -45,10 +45,33 @@ class SaveServiceTest { List slots = svc.list(); assertThat(slots).hasSize(1); assertThat(slots.getFirst().slotName()).isEqualTo("Alpha Run"); - assertThat(slots.getFirst().slug()).isEqualTo("alpha_run"); + // Sanitising the space is lossy, so the slug carries a disambiguating hash. + assertThat(slots.getFirst().slug()).startsWith("alpha_run_"); assertThat(slots.getFirst().turn()).isEqualTo(3); } + @Test + void slug_forNameNeedingNoSanitising_staysClean() { + assertThat(SaveService.slug("Manor")).isEqualTo("manor"); + } + + @Test + void slug_forNamesDifferingOnlyInSeparators_doesNotCollide() { + // "Bedroom Run" and "Bedroom_Run" both sanitise to "bedroom_run"; without + // disambiguation the second save would silently overwrite the first. + assertThat(SaveService.slug("Bedroom Run")) + .isNotEqualTo(SaveService.slug("Bedroom_Run")); + } + + @Test + void save_withNamesDifferingOnlyInSeparators_keepsBothSlots(@TempDir Path dir) { + SaveService svc = new SaveService(dir); + svc.save(freshSession("Bedroom Run")); + svc.save(freshSession("Bedroom_Run")); + + assertThat(svc.list()).hasSize(2); + } + @Test void listSkipsCorruptFiles(@TempDir Path dir) throws Exception { Files.writeString(dir.resolve("broken.json"), "{ not json");