diff --git a/Semesterprojekt/.gitignore b/Semesterprojekt/.gitignore index a110e81..b55fdef 100644 --- a/Semesterprojekt/.gitignore +++ b/Semesterprojekt/.gitignore @@ -36,4 +36,7 @@ build/ .vscode/ ### Mac OS ### -.DS_Store.superpowers/ +.DS_Store + +### Brainstorming visual companion ### +.superpowers/ diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index 432d408..ee69844 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -17,7 +17,13 @@ alle Phasen 1–7 grün. Ziel: deutlich mehr Tiefe und Politur. ## Themenblöcke -### 1. Reichere Präsentation / Ausgabe-Schicht +### 1. Reichere Präsentation / Ausgabe-Schicht ✅ umgesetzt (Branch `feature/presentation-layer`) + +> Umgesetzt: semantisches Modell (`io.text`), `ConsoleIO` (ANSI + Box-Drawing, +> Color/Glyph-Modi), `SwingIO` (4 Regionen, `JTextPane`, gebündelter Font, +> HiDPI-Skalierung + Zoom `Strg +/-/0`), HUD pro Zug, Welcome-Banner. 79 Tests grün. +> Spec/Plan unter `docs/superpowers/`. Karte/Menü/Save/Musik weiterhin offen. + - Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner, Hervorhebungen für Items/NPCs/Richtungen). diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index 8b74bfb..8aa08a3 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -19,6 +19,7 @@ import thb.jeanluc.adventure.game.Game; import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.GameIO; +import thb.jeanluc.adventure.io.text.Banner; import thb.jeanluc.adventure.loader.WorldLoader; /** @@ -77,7 +78,7 @@ public final class App { Game game = new Game(ctx, registry, new CommandParser()); quit.bind(game); - io.write(loaded.world().getTitle()); + io.print(Banner.welcome(loaded.world().getTitle())); io.write(loaded.world().getWelcomeMessage()); new LookCommand().execute(ctx, java.util.List.of()); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java index ecc918d..2a1fb5b 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java @@ -2,49 +2,27 @@ package thb.jeanluc.adventure.command.impl; import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.game.GameContext; +import thb.jeanluc.adventure.io.text.RoomView; import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.item.Item; import java.util.List; -import java.util.stream.Collectors; /** - * Prints a full description of the room the player is currently in: - * name, description, visible items, visible NPCs, and available exits. - * Usage: {@code look}. + * Describes the current room by emitting a {@link RoomView}; the active + * frontend decides how to render it. Usage: {@code look}. */ public class LookCommand implements Command { @Override public void execute(GameContext ctx, List args) { Room room = ctx.getPlayer().getCurrentRoom(); - StringBuilder sb = new StringBuilder(); - sb.append("== ").append(room.getName()).append(" ==\n"); - sb.append(room.getDescription().stripTrailing()); - - if (!room.getItems().isEmpty()) { - String items = room.getItems().values().stream() - .map(Item::getName) - .collect(Collectors.joining(", ")); - sb.append("\nYou see: ").append(items).append('.'); - } - if (!room.getNpcs().isEmpty()) { - String npcs = room.getNpcs().values().stream() - .map(Npc::getName) - .collect(Collectors.joining(", ")); - sb.append("\nHere is: ").append(npcs).append('.'); - } - if (room.getExits().isEmpty()) { - sb.append("\nThere are no obvious exits."); - } else { - String exits = room.getExits().keySet().stream() - .map(Direction::getLabel) - .collect(Collectors.joining(", ")); - sb.append("\nExits: ").append(exits).append('.'); - } - ctx.getIo().write(sb.toString()); + List items = room.getItems().values().stream().map(Item::getName).toList(); + List npcs = room.getNpcs().values().stream().map(Npc::getName).toList(); + List exits = room.getExits().keySet().stream().map(Direction::getLabel).toList(); + ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits)); } @Override diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java index 046c3d6..fa5c3bf 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -5,6 +5,7 @@ import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.CommandParser; import thb.jeanluc.adventure.command.CommandRegistry; import thb.jeanluc.adventure.command.ParsedCommand; +import thb.jeanluc.adventure.io.text.Hud; import java.util.Optional; @@ -28,10 +29,14 @@ public class Game { /** Loop flag. Flipped to false by {@link #stop()}. */ private boolean running = true; + /** Number of commands processed this session; shown in the HUD. */ + private int turn = 0; + /** * Runs the loop until {@link #stop()} is called or input is exhausted. */ public void run() { + publishHud(); while (running) { String input = ctx.getIo().readLine(); if (input == null) { @@ -46,10 +51,20 @@ public class Game { ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); } else { cmd.get().execute(ctx, parsed.args()); + turn++; } + publishHud(); } } + private void publishHud() { + ctx.getIo().setHud(new Hud( + ctx.getPlayer().getCurrentRoom().getName(), + ctx.getPlayer().getGold(), + turn, + false)); + } + /** * Signals the loop to terminate after the current iteration. */ diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java index bce6c4d..d88cb7c 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java @@ -1,41 +1,57 @@ package thb.jeanluc.adventure.io; +import thb.jeanluc.adventure.io.text.Hud; +import thb.jeanluc.adventure.io.text.RoomView; +import thb.jeanluc.adventure.io.text.Span; +import thb.jeanluc.adventure.io.text.Style; +import thb.jeanluc.adventure.io.text.StyledText; + import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintStream; import java.nio.charset.StandardCharsets; +import java.util.List; /** - * {@link GameIO} backed by {@code System.in} and {@code System.out}. - * Each {@code write} appends a newline so output blocks are visually - * separated. {@code readLine} prints a {@code > } prompt before - * blocking. + * {@link GameIO} for the terminal: ANSI colours, box-drawing room frames + * (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable + * and degrade safely (no colours when output is piped; ASCII frames on request). */ public class ConsoleIO implements GameIO { - /** Input source. */ + /** Whether ANSI colour is emitted. */ + public enum ColorMode { ON, OFF, AUTO } + + /** Frame/glyph fidelity tier. */ + public enum GlyphMode { ASCII, UNICODE, GLYPH } + + private static final int FRAME_WIDTH = 44; + private final BufferedReader in; - - /** Output sink. */ private final PrintStream out; + private final boolean useColor; + private final GlyphMode glyphs; - /** - * Creates a console IO using standard input and output. - */ public ConsoleIO() { - this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out); + this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), + System.out, ColorMode.AUTO, GlyphMode.UNICODE); } - /** - * Test-friendly constructor allowing custom streams. - * - * @param in reader to consume player input - * @param out stream to write player output - */ + /** Backward-compatible stream constructor (colour off, unicode frames). */ public ConsoleIO(BufferedReader in, PrintStream out) { + this(in, out, ColorMode.OFF, GlyphMode.UNICODE); + } + + public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) { this.in = in; this.out = out; + this.glyphs = glyphs; + this.useColor = switch (colorMode) { + case ON -> true; + case OFF -> false; + case AUTO -> System.console() != null; + }; } @Override @@ -51,7 +67,86 @@ public class ConsoleIO implements GameIO { } @Override - public void write(String s) { - out.println(s); + public void print(StyledText text) { + StringBuilder sb = new StringBuilder(); + for (Span s : text.spans()) { + sb.append(paint(s.style(), s.text())); + } + out.println(sb); + } + + @Override + public void showRoom(RoomView room) { + out.println(paint(Style.HEADING, frameTop(room.name()))); + out.println(room.description().stripTrailing()); + if (!room.items().isEmpty()) { + out.println(section("Items", room.items(), Style.ITEM)); + } + if (!room.npcs().isEmpty()) { + out.println(section("People", room.npcs(), Style.NPC)); + } + if (!room.exits().isEmpty()) { + out.println(section("Exits", room.exits(), Style.EXIT)); + } else { + out.println(" " + paint(Style.DIM, "no obvious exits")); + } + out.println(paint(Style.HEADING, frameBottom())); + } + + @Override + public void setHud(Hud h) { + String line = "[ " + h.location() + " · " + h.gold() + " gold · turn " + h.turn() + + " · light: " + (h.lightOn() ? "on" : "off") + " ]"; + out.println(paint(Style.DIM, line)); + } + + private String section(String label, List xs, Style st) { + StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" "); + for (int i = 0; i < xs.size(); i++) { + if (i > 0) { + sb.append(" · "); + } + sb.append(paint(st, xs.get(i))); + } + return sb.toString(); + } + + private String frameTop(String title) { + String tl = glyphs == GlyphMode.ASCII ? "+" : "┌"; + String tr = glyphs == GlyphMode.ASCII ? "+" : "┐"; + String h = glyphs == GlyphMode.ASCII ? "-" : "─"; + StringBuilder sb = new StringBuilder(tl).append(h).append(' ') + .append(title.toUpperCase()).append(' '); + while (sb.length() < FRAME_WIDTH - 1) { + sb.append(h); + } + return sb.append(tr).toString(); + } + + private String frameBottom() { + String bl = glyphs == GlyphMode.ASCII ? "+" : "└"; + String br = glyphs == GlyphMode.ASCII ? "+" : "┘"; + String h = glyphs == GlyphMode.ASCII ? "-" : "─"; + StringBuilder sb = new StringBuilder(bl); + while (sb.length() < FRAME_WIDTH - 1) { + sb.append(h); + } + return sb.append(br).toString(); + } + + private String paint(Style st, String s) { + if (!useColor) { + return s; + } + String code = switch (st) { + case HEADING -> ""; + case ITEM -> ""; + case NPC -> ""; + case EXIT -> ""; + case DANGER -> ""; + case DIM -> ""; + case PLAIN -> ""; + }; + return code.isEmpty() ? s : code + s + ""; } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java index b4b2ea2..3af5b09 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -1,24 +1,35 @@ package thb.jeanluc.adventure.io; +import thb.jeanluc.adventure.io.text.Hud; +import thb.jeanluc.adventure.io.text.Renderings; +import thb.jeanluc.adventure.io.text.RoomView; +import thb.jeanluc.adventure.io.text.StyledText; + /** - * Bidirectional text channel between the engine and the player. Implemented - * by both {@code ConsoleIO} and {@code SwingIO} so the game loop is agnostic - * to the actual interface. + * Bidirectional channel between the engine and the player. Commands emit + * semantic, role-styled output via {@link #print(StyledText)} (the primitive) + * and the {@link #showRoom}/{@link #setHud} views; each frontend renders them. */ public interface GameIO { - /** - * Blocks until a line of input is available. - * - * @return the next user input line, or an empty string at end of input - */ + /** Blocks until a line of input is available; empty string at end of input. */ String readLine(); - /** - * Writes a message to the player. Each call corresponds to one logical - * output block; the implementation handles line breaks at the boundary. - * - * @param s message text; must not be null - */ - void write(String s); + /** Renders one styled output block. The only method implementors must provide. */ + void print(StyledText text); + + /** Convenience: a plain, unstyled line. */ + default void write(String s) { + print(StyledText.of(s)); + } + + /** Renders a room. Default flattens via {@link Renderings}; renderers may override. */ + default void showRoom(RoomView room) { + print(Renderings.roomToStyledText(room)); + } + + /** Updates the status/HUD region. No-op by default; renderers may override. */ + default void setHud(Hud hud) { + // no-op + } } 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 70ec556..1ef12f5 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -1,73 +1,258 @@ package thb.jeanluc.adventure.io; +import thb.jeanluc.adventure.io.text.Hud; +import thb.jeanluc.adventure.io.text.RoomView; +import thb.jeanluc.adventure.io.text.Span; +import thb.jeanluc.adventure.io.text.Style; +import thb.jeanluc.adventure.io.text.StyledText; + +import javax.swing.AbstractAction; import javax.swing.BorderFactory; +import javax.swing.JComponent; import javax.swing.JFrame; +import javax.swing.JLabel; import javax.swing.JScrollPane; import javax.swing.JTextArea; import javax.swing.JTextField; +import javax.swing.JTextPane; +import javax.swing.KeyStroke; import javax.swing.SwingUtilities; +import javax.swing.text.BadLocationException; +import javax.swing.text.SimpleAttributeSet; +import javax.swing.text.StyleConstants; +import javax.swing.text.StyledDocument; import java.awt.BorderLayout; import java.awt.Color; +import java.awt.Dimension; import java.awt.Font; +import java.awt.GraphicsConfiguration; +import java.awt.GraphicsEnvironment; +import java.awt.Insets; +import java.awt.Toolkit; +import java.awt.event.ActionEvent; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.event.KeyEvent; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.LinkedBlockingQueue; /** - * {@link GameIO} backed by a Swing window. A read-only output area shows - * game text; a bottom text field collects player input. The input from - * the EDT is handed to the (worker-thread) game loop through a - * {@link LinkedBlockingQueue}. + * {@link GameIO} backed by a Swing window with four regions: a HUD bar (top), + * a styled output pane (centre), a side panel for exits / future map (right), + * and an input field (bottom). A bundled TrueType font is loaded if present so + * colours and glyphs work without the player installing anything. + * + *

Font sizes are multiplied by a detected display scale (HiDPI) and by a + * user-adjustable zoom ({@code Ctrl +} / {@code Ctrl -} / {@code Ctrl 0}), so + * text stays readable on high-DPI laptops even when the JVM does not pick up + * the desktop scaling factor.

*/ public class SwingIO implements GameIO { - /** Bridge between the EDT and the game loop. One element = one line. */ + /** Unscaled base size for the main output/input font, in points. */ + private static final float BASE_SIZE = 15f; + + /** Unscaled size for the HUD and side-panel font, in points. */ + private static final float SMALL_SIZE = 12.5f; + + /** Side-panel width as a fraction of the window width, clamped by the bounds below. */ + private static final double SIDE_RATIO = 0.25; + private static final int SIDE_MIN = 160; + private static final int SIDE_MAX = 320; + private final LinkedBlockingQueue inputs = new LinkedBlockingQueue<>(); - - /** Main window. */ private final JFrame frame; - - /** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */ - private final JTextArea output; - - /** Input text field; ENTER pushes the line into {@link #inputs}. */ + private final JTextPane output; + private final StyledDocument doc; private final JTextField input; + private final JLabel hud; + private final JTextArea side; + private final JScrollPane sideScroll; + private final Font baseFont; + + /** Detected display scale (≥ 1.0); HiDPI panels report > 1.0 where supported. */ + private final float uiScale; + + /** User zoom multiplier, adjusted live via keyboard. */ + private float zoom = 1f; - /** - * Creates and shows the window with the given title. Must be called on - * the EDT (typical pattern: from {@code SwingUtilities.invokeLater}). - * - * @param title window title - */ public SwingIO(String title) { - frame = new JFrame(title); - output = new JTextArea(); - input = new JTextField(); + baseFont = loadFont(); + uiScale = detectScale(); + output = new JTextPane(); output.setEditable(false); - output.setLineWrap(true); - output.setWrapStyleWord(true); - output.setBackground(Color.BLACK); - output.setForeground(Color.LIGHT_GRAY); - output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); - output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + output.setBackground(new Color(0x0b, 0x0e, 0x13)); + output.setForeground(new Color(0xcf, 0xd6, 0xe0)); + output.setMargin(new Insets(8, 8, 8, 8)); + doc = output.getStyledDocument(); - input.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14)); + hud = new JLabel(" "); + hud.setOpaque(true); + hud.setBackground(new Color(0x11, 0x15, 0x1c)); + hud.setForeground(new Color(0x8b, 0x94, 0xa3)); + hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12)); + + side = new JTextArea(); + side.setEditable(false); + side.setBackground(new Color(0x0e, 0x12, 0x18)); + side.setForeground(new Color(0x6f, 0xcf, 0x73)); + side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + sideScroll = new JScrollPane(side); + + input = new JTextField(); input.addActionListener(e -> { String line = input.getText(); input.setText(""); - output.append("> " + line + "\n"); + appendSpans(List.of(new Span("> " + line + "\n", Style.DIM))); inputs.offer(line); }); + frame = new JFrame(title); frame.setLayout(new BorderLayout()); + frame.add(hud, BorderLayout.NORTH); frame.add(new JScrollPane(output), BorderLayout.CENTER); + frame.add(sideScroll, BorderLayout.EAST); frame.add(input, BorderLayout.SOUTH); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); - frame.setSize(800, 600); + frame.setSize((int) (900 * uiScale), (int) (600 * uiScale)); frame.setLocationRelativeTo(null); + frame.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + updateSideWidth(); + } + }); + + installZoomKeys(); + applyFonts(); + updateSideWidth(); + frame.setVisible(true); input.requestFocusInWindow(); } + /** Loads the bundled font if present, else falls back to a logical monospaced font. */ + private Font loadFont() { + try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) { + if (in != null) { + Font f = Font.createFont(Font.TRUETYPE_FONT, in); + GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f); + return f; + } + } catch (Exception e) { + // fall through to the logical monospaced fallback + } + return new Font(Font.MONOSPACED, Font.PLAIN, 12); + } + + /** Detects the display scale; HiDPI-aware where the platform reports it, else uses DPI. */ + private float detectScale() { + try { + GraphicsConfiguration gc = GraphicsEnvironment.getLocalGraphicsEnvironment() + .getDefaultScreenDevice().getDefaultConfiguration(); + double transform = gc.getDefaultTransform().getScaleX(); + if (transform > 1.0) { + return (float) transform; + } + } catch (Exception ignored) { + // fall through to DPI heuristic + } + try { + int dpi = Toolkit.getDefaultToolkit().getScreenResolution(); + return Math.max(1f, dpi / 96f); + } catch (Exception ignored) { + return 1f; + } + } + + /** Sizes the side panel to a fraction of the window width, clamped to [min, max] (scaled). */ + private void updateSideWidth() { + int min = (int) (SIDE_MIN * uiScale); + int max = (int) (SIDE_MAX * uiScale); + int target = Math.max(min, Math.min(max, (int) (frame.getWidth() * SIDE_RATIO))); + sideScroll.setPreferredSize(new Dimension(target, 0)); + sideScroll.revalidate(); + frame.validate(); + } + + /** Re-derives and applies all component fonts from the current scale and zoom. */ + private void applyFonts() { + float main = BASE_SIZE * uiScale * zoom; + float small = SMALL_SIZE * uiScale * zoom; + output.setFont(baseFont.deriveFont(main)); + input.setFont(baseFont.deriveFont(main)); + hud.setFont(baseFont.deriveFont(small)); + side.setFont(baseFont.deriveFont(small)); + frame.revalidate(); + frame.repaint(); + } + + /** Binds Ctrl +, Ctrl - and Ctrl 0 to live font zoom. */ + private void installZoomKeys() { + JComponent root = frame.getRootPane(); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, KeyEvent.CTRL_DOWN_MASK), "zoomIn"); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_EQUALS, KeyEvent.CTRL_DOWN_MASK), "zoomIn"); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_ADD, KeyEvent.CTRL_DOWN_MASK), "zoomIn"); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, KeyEvent.CTRL_DOWN_MASK), "zoomOut"); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, KeyEvent.CTRL_DOWN_MASK), "zoomOut"); + bind(root, KeyStroke.getKeyStroke(KeyEvent.VK_0, KeyEvent.CTRL_DOWN_MASK), "zoomReset"); + + root.getActionMap().put("zoomIn", action(e -> setZoom(zoom + 0.1f))); + root.getActionMap().put("zoomOut", action(e -> setZoom(zoom - 0.1f))); + root.getActionMap().put("zoomReset", action(e -> setZoom(1f))); + } + + private void bind(JComponent c, KeyStroke ks, String name) { + c.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(ks, name); + } + + private AbstractAction action(java.util.function.Consumer body) { + return new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + body.accept(e); + } + }; + } + + private void setZoom(float z) { + zoom = Math.max(0.6f, Math.min(3.0f, z)); + applyFonts(); + } + + private Color colorFor(Style s) { + return switch (s) { + case HEADING -> new Color(0xc9, 0x8a, 0xe0); + case ITEM -> new Color(0x46, 0xc8, 0xd8); + case NPC -> new Color(0xe6, 0xc3, 0x4a); + case EXIT -> new Color(0x6f, 0xcf, 0x73); + case DANGER -> new Color(0xe0, 0x6c, 0x6c); + case DIM -> new Color(0x6b, 0x72, 0x80); + case PLAIN -> new Color(0xcf, 0xd6, 0xe0); + }; + } + + private void appendSpans(List spans) { + SwingUtilities.invokeLater(() -> { + try { + for (Span sp : spans) { + SimpleAttributeSet as = new SimpleAttributeSet(); + StyleConstants.setForeground(as, colorFor(sp.style())); + if (sp.style() == Style.HEADING) { + StyleConstants.setBold(as, true); + } + doc.insertString(doc.getLength(), sp.text(), as); + } + output.setCaretPosition(doc.getLength()); + } catch (BadLocationException ignored) { + // append-only; cannot occur with getLength() + } + }); + } + @Override public String readLine() { try { @@ -79,7 +264,45 @@ public class SwingIO implements GameIO { } @Override - public void write(String s) { - SwingUtilities.invokeLater(() -> output.append(s + "\n")); + public void print(StyledText text) { + List spans = new ArrayList<>(text.spans()); + spans.add(new Span("\n", Style.PLAIN)); + appendSpans(spans); + } + + @Override + public void showRoom(RoomView room) { + List spans = new ArrayList<>(); + spans.add(new Span(room.name() + "\n", Style.HEADING)); + spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN)); + if (!room.items().isEmpty()) { + spans.add(new Span("You see ", Style.PLAIN)); + addList(spans, room.items(), Style.ITEM); + spans.add(new Span(".\n", Style.PLAIN)); + } + if (!room.npcs().isEmpty()) { + addList(spans, room.npcs(), Style.NPC); + spans.add(new Span(" is here.\n", Style.PLAIN)); + } + appendSpans(spans); + + String exits = room.exits().isEmpty() ? "(none)" : String.join("\n", room.exits()); + SwingUtilities.invokeLater(() -> side.setText("EXITS\n\n" + exits)); + } + + @Override + public void setHud(Hud h) { + String txt = " " + h.location() + " gold " + h.gold() + " turn " + h.turn() + + " light " + (h.lightOn() ? "on" : "off"); + SwingUtilities.invokeLater(() -> hud.setText(txt)); + } + + private void addList(List spans, List xs, Style st) { + for (int i = 0; i < xs.size(); i++) { + if (i > 0) { + spans.add(new Span(", ", Style.PLAIN)); + } + spans.add(new Span(xs.get(i), st)); + } } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Banner.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Banner.java new file mode 100644 index 0000000..f491446 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Banner.java @@ -0,0 +1,18 @@ +package thb.jeanluc.adventure.io.text; + +/** Builds styled banners for big moments (game start, finale). */ +public final class Banner { + + private Banner() { + } + + /** A framed, heading-styled welcome banner for the given title. */ + public static StyledText welcome(String title) { + String bar = "=".repeat(Math.max(8, title.length() + 8)); + return StyledText.builder() + .heading(bar + "\n") + .heading(" " + title + "\n") + .heading(bar) + .build(); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Hud.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Hud.java new file mode 100644 index 0000000..f4a1e7d --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Hud.java @@ -0,0 +1,4 @@ +package thb.jeanluc.adventure.io.text; + +/** Status snapshot for the HUD region. */ +public record Hud(String location, int gold, int turn, boolean lightOn) {} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Renderings.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Renderings.java new file mode 100644 index 0000000..9baf1aa --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Renderings.java @@ -0,0 +1,49 @@ +package thb.jeanluc.adventure.io.text; + +import java.util.List; + +/** Default, layout-free rendering of high-level views into {@link StyledText}. */ +public final class Renderings { + + private Renderings() { + } + + /** Generic room rendering used by non-overriding {@code GameIO}s (e.g. tests). */ + public static StyledText roomToStyledText(RoomView r) { + StyledText.Builder b = StyledText.builder(); + b.heading(r.name()).plain("\n"); + b.plain(r.description().stripTrailing()); + if (!r.items().isEmpty()) { + b.plain("\nYou see: "); + appendList(b, r.items(), Style.ITEM); + b.plain("."); + } + if (!r.npcs().isEmpty()) { + b.plain("\nHere is: "); + appendList(b, r.npcs(), Style.NPC); + b.plain("."); + } + if (r.exits().isEmpty()) { + b.plain("\nThere are no obvious exits."); + } else { + b.plain("\nExits: "); + appendList(b, r.exits(), Style.EXIT); + b.plain("."); + } + return b.build(); + } + + private static void appendList(StyledText.Builder b, List xs, Style st) { + for (int i = 0; i < xs.size(); i++) { + if (i > 0) { + b.plain(", "); + } + switch (st) { + case ITEM -> b.item(xs.get(i)); + case NPC -> b.npc(xs.get(i)); + case EXIT -> b.exit(xs.get(i)); + default -> b.plain(xs.get(i)); + } + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/RoomView.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/RoomView.java new file mode 100644 index 0000000..9b77854 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/RoomView.java @@ -0,0 +1,13 @@ +package thb.jeanluc.adventure.io.text; + +import java.util.List; + +/** Semantic snapshot of a room for rendering. Display-ready strings, no layout. */ +public record RoomView(String name, String description, + List items, List npcs, List exits) { + public RoomView { + items = List.copyOf(items); + npcs = List.copyOf(npcs); + exits = List.copyOf(exits); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Span.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Span.java new file mode 100644 index 0000000..9f01f06 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Span.java @@ -0,0 +1,13 @@ +package thb.jeanluc.adventure.io.text; + +/** A run of text carrying a single semantic {@link Style}. */ +public record Span(String text, Style style) { + public Span { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + if (style == null) { + throw new IllegalArgumentException("style must not be null"); + } + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Style.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Style.java new file mode 100644 index 0000000..02d67f9 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Style.java @@ -0,0 +1,4 @@ +package thb.jeanluc.adventure.io.text; + +/** Semantic output roles. Renderers map each role to concrete colours/attributes. */ +public enum Style { PLAIN, HEADING, ITEM, NPC, EXIT, DANGER, DIM } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/StyledText.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/StyledText.java new file mode 100644 index 0000000..0065c03 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/StyledText.java @@ -0,0 +1,59 @@ +package thb.jeanluc.adventure.io.text; + +import java.util.ArrayList; +import java.util.List; + +/** An ordered, immutable list of styled {@link Span}s. Built via {@link Builder}. */ +public final class StyledText { + + private final List spans; + + private StyledText(List spans) { + this.spans = List.copyOf(spans); + } + + /** @return the spans in order (unmodifiable). */ + public List spans() { + return spans; + } + + /** @return all span text concatenated, without any styling. */ + public String plainText() { + StringBuilder sb = new StringBuilder(); + for (Span s : spans) { + sb.append(s.text()); + } + return sb.toString(); + } + + /** @return a styled text of a single {@link Style#PLAIN} span. */ + public static StyledText of(String s) { + return new StyledText(List.of(new Span(s, Style.PLAIN))); + } + + public static Builder builder() { + return new Builder(); + } + + /** Fluent builder; one method per {@link Style} role. */ + public static final class Builder { + private final List spans = new ArrayList<>(); + + private Builder add(String text, Style style) { + spans.add(new Span(text, style)); + return this; + } + + public Builder plain(String s) { return add(s, Style.PLAIN); } + public Builder heading(String s) { return add(s, Style.HEADING); } + public Builder item(String s) { return add(s, Style.ITEM); } + public Builder npc(String s) { return add(s, Style.NPC); } + public Builder exit(String s) { return add(s, Style.EXIT); } + public Builder danger(String s) { return add(s, Style.DANGER); } + public Builder dim(String s) { return add(s, Style.DIM); } + + public StyledText build() { + return new StyledText(spans); + } + } +} diff --git a/Semesterprojekt/src/main/resources/fonts/README.md b/Semesterprojekt/src/main/resources/fonts/README.md new file mode 100644 index 0000000..3485750 --- /dev/null +++ b/Semesterprojekt/src/main/resources/fonts/README.md @@ -0,0 +1,12 @@ +# Bundled GUI font + +Drop a TrueType font here named `game.ttf` to use it in the Swing GUI +(`SwingIO`). It is loaded via `Font.createFont` and registered at runtime — +no installation on the player's machine required. + +- A monospaced font is recommended (the UI assumes fixed width for frames). +- A Nerd Font variant additionally provides icon glyphs. +- Check the font's licence before committing the `.ttf`. + +If no `game.ttf` is present, `SwingIO` falls back to the logical +`Font.MONOSPACED`, so the game still runs. diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java new file mode 100644 index 0000000..21735b1 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java @@ -0,0 +1,64 @@ +package thb.jeanluc.adventure.io; + +import org.junit.jupiter.api.Test; +import thb.jeanluc.adventure.io.text.Hud; +import thb.jeanluc.adventure.io.text.RoomView; +import thb.jeanluc.adventure.io.text.StyledText; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ConsoleIORenderTest { + + private ConsoleIO io(ByteArrayOutputStream sink) { + PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8); + return new ConsoleIO(new BufferedReader(new StringReader("")), ps, + ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.UNICODE); + } + + @Test + void showRoom_drawsHeadingSectionsAndExits() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + io(sink).showRoom(new RoomView("Kitchen", "A cold kitchen.", + List.of("Lamp"), List.of("Old Man"), List.of("north", "east"))); + String out = sink.toString(StandardCharsets.UTF_8); + assertThat(out).contains("KITCHEN"); + assertThat(out).contains("A cold kitchen."); + assertThat(out).contains("Lamp"); + assertThat(out).contains("Old Man"); + assertThat(out).contains("north").contains("east"); + } + + @Test + void print_colorOff_emitsNoAnsiEscapes() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + io(sink).print(StyledText.builder().item("brass lamp").build()); + assertThat(sink.toString(StandardCharsets.UTF_8)).doesNotContain("["); + } + + @Test + void setHud_printsLocationGoldTurnAndLight() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + io(sink).setHud(new Hud("Kitchen", 0, 7, false)); + String out = sink.toString(StandardCharsets.UTF_8); + assertThat(out).contains("Kitchen").contains("turn 7").contains("light: off"); + } + + @Test + void asciiGlyphMode_usesPlusDashFrame() { + ByteArrayOutputStream sink = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(sink, true, StandardCharsets.UTF_8); + new ConsoleIO(new BufferedReader(new StringReader("")), ps, + ConsoleIO.ColorMode.OFF, ConsoleIO.GlyphMode.ASCII) + .showRoom(new RoomView("Cell", "x", List.of(), List.of(), List.of("north"))); + String out = sink.toString(StandardCharsets.UTF_8); + assertThat(out).contains("+").contains("-"); + assertThat(out).doesNotContain("─"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TestIO.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TestIO.java index 7a8bacd..872c5b2 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TestIO.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/TestIO.java @@ -36,7 +36,7 @@ public class TestIO implements GameIO { } @Override - public void write(String s) { - outputs.add(s); + public void print(thb.jeanluc.adventure.io.text.StyledText text) { + outputs.add(text.plainText()); } } diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java new file mode 100644 index 0000000..8c89430 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java @@ -0,0 +1,15 @@ +package thb.jeanluc.adventure.io.text; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class BannerTest { + + @Test + void welcome_containsTitleAsHeading() { + StyledText b = Banner.welcome("Haunted Manor"); + assertThat(b.plainText()).contains("Haunted Manor"); + boolean hasHeading = b.spans().stream().anyMatch(s -> s.style() == Style.HEADING); + assertThat(hasHeading).isTrue(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java new file mode 100644 index 0000000..d13fab1 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java @@ -0,0 +1,38 @@ +package thb.jeanluc.adventure.io.text; + +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +class RenderingsTest { + + @Test + void roomToStyledText_includesNameDescriptionItemsNpcsExits() { + RoomView room = new RoomView("Kitchen", "A cold kitchen.", + List.of("Lamp", "Letter"), List.of("Old Man"), List.of("north", "east")); + + String text = Renderings.roomToStyledText(room).plainText(); + + assertThat(text).contains("Kitchen"); + assertThat(text).contains("A cold kitchen."); + assertThat(text).contains("Lamp").contains("Letter"); + assertThat(text).contains("Old Man"); + assertThat(text).contains("north").contains("east"); + } + + @Test + void roomToStyledText_noExits_saysNoObviousExits() { + RoomView room = new RoomView("Void", "Nothing.", List.of(), List.of(), List.of()); + assertThat(Renderings.roomToStyledText(room).plainText()) + .contains("no obvious exits"); + } + + @Test + void roomToStyledText_tagsItemSpansWithItemStyle() { + RoomView room = new RoomView("Kitchen", "desc", + List.of("Lamp"), List.of(), List.of("north")); + boolean hasItemSpan = Renderings.roomToStyledText(room).spans().stream() + .anyMatch(s -> s.style() == Style.ITEM && s.text().equals("Lamp")); + assertThat(hasItemSpan).isTrue(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java new file mode 100644 index 0000000..7b188e4 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java @@ -0,0 +1,38 @@ +package thb.jeanluc.adventure.io.text; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StyledTextTest { + + @Test + void builder_collectsSpansInOrder() { + StyledText t = StyledText.builder() + .plain("You take ").item("the brass lamp").plain(".").build(); + assertThat(t.spans()).hasSize(3); + assertThat(t.spans().get(1).style()).isEqualTo(Style.ITEM); + assertThat(t.spans().get(1).text()).isEqualTo("the brass lamp"); + } + + @Test + void plainText_concatenatesAllSpanText() { + StyledText t = StyledText.builder() + .plain("You take ").item("the brass lamp").plain(".").build(); + assertThat(t.plainText()).isEqualTo("You take the brass lamp."); + } + + @Test + void of_createsSinglePlainSpan() { + StyledText t = StyledText.of("hello"); + assertThat(t.spans()).hasSize(1); + assertThat(t.spans().getFirst().style()).isEqualTo(Style.PLAIN); + assertThat(t.plainText()).isEqualTo("hello"); + } + + @Test + void span_rejectsNulls() { + assertThatThrownBy(() -> new Span(null, Style.PLAIN)) + .isInstanceOf(IllegalArgumentException.class); + } +}