Merge presentation/output layer (sub-project 1) into develop

Semantic styled-output model + console (ANSI/box-drawing) and Swing
(4-region, JTextPane, bundled font, HiDPI scaling + zoom, responsive
side panel) renderers, per-turn HUD, welcome banner. 79 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 20:38:19 +02:00
21 changed files with 759 additions and 100 deletions

View File

@@ -36,4 +36,7 @@ build/
.vscode/ .vscode/
### Mac OS ### ### Mac OS ###
.DS_Store.superpowers/ .DS_Store
### Brainstorming visual companion ###
.superpowers/

View File

@@ -17,7 +17,13 @@ alle Phasen 17 grün. Ziel: deutlich mehr Tiefe und Politur.
## Themenblöcke ## 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, - Weg vom reinen `write(String)`: formatierte Ausgabe (Überschriften, Trenner,
Hervorhebungen für Items/NPCs/Richtungen). Hervorhebungen für Items/NPCs/Richtungen).

View File

@@ -19,6 +19,7 @@ import thb.jeanluc.adventure.game.Game;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.io.ConsoleIO; import thb.jeanluc.adventure.io.ConsoleIO;
import thb.jeanluc.adventure.io.GameIO; import thb.jeanluc.adventure.io.GameIO;
import thb.jeanluc.adventure.io.text.Banner;
import thb.jeanluc.adventure.loader.WorldLoader; import thb.jeanluc.adventure.loader.WorldLoader;
/** /**
@@ -77,7 +78,7 @@ public final class App {
Game game = new Game(ctx, registry, new CommandParser()); Game game = new Game(ctx, registry, new CommandParser());
quit.bind(game); quit.bind(game);
io.write(loaded.world().getTitle()); io.print(Banner.welcome(loaded.world().getTitle()));
io.write(loaded.world().getWelcomeMessage()); io.write(loaded.world().getWelcomeMessage());
new LookCommand().execute(ctx, java.util.List.of()); new LookCommand().execute(ctx, java.util.List.of());

View File

@@ -2,49 +2,27 @@ package thb.jeanluc.adventure.command.impl;
import thb.jeanluc.adventure.command.Command; import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.game.GameContext; import thb.jeanluc.adventure.game.GameContext;
import thb.jeanluc.adventure.io.text.RoomView;
import thb.jeanluc.adventure.model.Direction; import thb.jeanluc.adventure.model.Direction;
import thb.jeanluc.adventure.model.Npc; import thb.jeanluc.adventure.model.Npc;
import thb.jeanluc.adventure.model.Room; import thb.jeanluc.adventure.model.Room;
import thb.jeanluc.adventure.model.item.Item; import thb.jeanluc.adventure.model.item.Item;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* Prints a full description of the room the player is currently in: * Describes the current room by emitting a {@link RoomView}; the active
* name, description, visible items, visible NPCs, and available exits. * frontend decides how to render it. Usage: {@code look}.
* Usage: {@code look}.
*/ */
public class LookCommand implements Command { public class LookCommand implements Command {
@Override @Override
public void execute(GameContext ctx, List<String> args) { public void execute(GameContext ctx, List<String> args) {
Room room = ctx.getPlayer().getCurrentRoom(); Room room = ctx.getPlayer().getCurrentRoom();
StringBuilder sb = new StringBuilder(); List<String> items = room.getItems().values().stream().map(Item::getName).toList();
sb.append("== ").append(room.getName()).append(" ==\n"); List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
sb.append(room.getDescription().stripTrailing()); List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits));
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());
} }
@Override @Override

View File

@@ -5,6 +5,7 @@ import thb.jeanluc.adventure.command.Command;
import thb.jeanluc.adventure.command.CommandParser; import thb.jeanluc.adventure.command.CommandParser;
import thb.jeanluc.adventure.command.CommandRegistry; import thb.jeanluc.adventure.command.CommandRegistry;
import thb.jeanluc.adventure.command.ParsedCommand; import thb.jeanluc.adventure.command.ParsedCommand;
import thb.jeanluc.adventure.io.text.Hud;
import java.util.Optional; import java.util.Optional;
@@ -28,10 +29,14 @@ public class Game {
/** Loop flag. Flipped to false by {@link #stop()}. */ /** Loop flag. Flipped to false by {@link #stop()}. */
private boolean running = true; 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. * Runs the loop until {@link #stop()} is called or input is exhausted.
*/ */
public void run() { public void run() {
publishHud();
while (running) { while (running) {
String input = ctx.getIo().readLine(); String input = ctx.getIo().readLine();
if (input == null) { if (input == null) {
@@ -46,8 +51,18 @@ public class Game {
ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'."); ctx.getIo().write("I don't understand '" + parsed.verb() + "'. Type 'help'.");
} else { } else {
cmd.get().execute(ctx, parsed.args()); 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));
} }
/** /**

View File

@@ -1,41 +1,57 @@
package thb.jeanluc.adventure.io; 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.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.io.PrintStream; import java.io.PrintStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List;
/** /**
* {@link GameIO} backed by {@code System.in} and {@code System.out}. * {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
* Each {@code write} appends a newline so output blocks are visually * (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
* separated. {@code readLine} prints a {@code &gt; } prompt before * and degrade safely (no colours when output is piped; ASCII frames on request).
* blocking.
*/ */
public class ConsoleIO implements GameIO { 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; private final BufferedReader in;
/** Output sink. */
private final PrintStream out; private final PrintStream out;
private final boolean useColor;
private final GlyphMode glyphs;
/**
* Creates a console IO using standard input and output.
*/
public ConsoleIO() { 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);
} }
/** /** Backward-compatible stream constructor (colour off, unicode frames). */
* Test-friendly constructor allowing custom streams.
*
* @param in reader to consume player input
* @param out stream to write player output
*/
public ConsoleIO(BufferedReader in, PrintStream out) { 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.in = in;
this.out = out; this.out = out;
this.glyphs = glyphs;
this.useColor = switch (colorMode) {
case ON -> true;
case OFF -> false;
case AUTO -> System.console() != null;
};
} }
@Override @Override
@@ -51,7 +67,86 @@ public class ConsoleIO implements GameIO {
} }
@Override @Override
public void write(String s) { public void print(StyledText text) {
out.println(s); 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<String> 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 + "";
} }
} }

View File

@@ -1,24 +1,35 @@
package thb.jeanluc.adventure.io; 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 * Bidirectional channel between the engine and the player. Commands emit
* by both {@code ConsoleIO} and {@code SwingIO} so the game loop is agnostic * semantic, role-styled output via {@link #print(StyledText)} (the primitive)
* to the actual interface. * and the {@link #showRoom}/{@link #setHud} views; each frontend renders them.
*/ */
public interface GameIO { public interface GameIO {
/** /** Blocks until a line of input is available; empty string at end of input. */
* Blocks until a line of input is available.
*
* @return the next user input line, or an empty string at end of input
*/
String readLine(); String readLine();
/** /** Renders one styled output block. The only method implementors must provide. */
* Writes a message to the player. Each call corresponds to one logical void print(StyledText text);
* output block; the implementation handles line breaks at the boundary.
* /** Convenience: a plain, unstyled line. */
* @param s message text; must not be null default void write(String s) {
*/ print(StyledText.of(s));
void write(String 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
}
} }

View File

@@ -1,73 +1,258 @@
package thb.jeanluc.adventure.io; 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.BorderFactory;
import javax.swing.JComponent;
import javax.swing.JFrame; import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JScrollPane; import javax.swing.JScrollPane;
import javax.swing.JTextArea; import javax.swing.JTextArea;
import javax.swing.JTextField; import javax.swing.JTextField;
import javax.swing.JTextPane;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities; 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.BorderLayout;
import java.awt.Color; import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font; 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; import java.util.concurrent.LinkedBlockingQueue;
/** /**
* {@link GameIO} backed by a Swing window. A read-only output area shows * {@link GameIO} backed by a Swing window with four regions: a HUD bar (top),
* game text; a bottom text field collects player input. The input from * a styled output pane (centre), a side panel for exits / future map (right),
* the EDT is handed to the (worker-thread) game loop through a * and an input field (bottom). A bundled TrueType font is loaded if present so
* {@link LinkedBlockingQueue}. * colours and glyphs work without the player installing anything.
*
* <p>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.</p>
*/ */
public class SwingIO implements GameIO { 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<String> inputs = new LinkedBlockingQueue<>(); private final LinkedBlockingQueue<String> inputs = new LinkedBlockingQueue<>();
/** Main window. */
private final JFrame frame; private final JFrame frame;
private final JTextPane output;
/** Output text area; appended to from any thread via {@link SwingUtilities#invokeLater}. */ private final StyledDocument doc;
private final JTextArea output;
/** Input text field; ENTER pushes the line into {@link #inputs}. */
private final JTextField input; 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) { public SwingIO(String title) {
frame = new JFrame(title); baseFont = loadFont();
output = new JTextArea(); uiScale = detectScale();
input = new JTextField();
output = new JTextPane();
output.setEditable(false); output.setEditable(false);
output.setLineWrap(true); output.setBackground(new Color(0x0b, 0x0e, 0x13));
output.setWrapStyleWord(true); output.setForeground(new Color(0xcf, 0xd6, 0xe0));
output.setBackground(Color.BLACK); output.setMargin(new Insets(8, 8, 8, 8));
output.setForeground(Color.LIGHT_GRAY); doc = output.getStyledDocument();
output.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 14));
output.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8));
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 -> { input.addActionListener(e -> {
String line = input.getText(); String line = input.getText();
input.setText(""); input.setText("");
output.append("> " + line + "\n"); appendSpans(List.of(new Span("> " + line + "\n", Style.DIM)));
inputs.offer(line); inputs.offer(line);
}); });
frame = new JFrame(title);
frame.setLayout(new BorderLayout()); frame.setLayout(new BorderLayout());
frame.add(hud, BorderLayout.NORTH);
frame.add(new JScrollPane(output), BorderLayout.CENTER); frame.add(new JScrollPane(output), BorderLayout.CENTER);
frame.add(sideScroll, BorderLayout.EAST);
frame.add(input, BorderLayout.SOUTH); frame.add(input, BorderLayout.SOUTH);
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setSize(800, 600); frame.setSize((int) (900 * uiScale), (int) (600 * uiScale));
frame.setLocationRelativeTo(null); frame.setLocationRelativeTo(null);
frame.addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
updateSideWidth();
}
});
installZoomKeys();
applyFonts();
updateSideWidth();
frame.setVisible(true); frame.setVisible(true);
input.requestFocusInWindow(); 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<ActionEvent> 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<Span> 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 @Override
public String readLine() { public String readLine() {
try { try {
@@ -79,7 +264,45 @@ public class SwingIO implements GameIO {
} }
@Override @Override
public void write(String s) { public void print(StyledText text) {
SwingUtilities.invokeLater(() -> output.append(s + "\n")); List<Span> spans = new ArrayList<>(text.spans());
spans.add(new Span("\n", Style.PLAIN));
appendSpans(spans);
}
@Override
public void showRoom(RoomView room) {
List<Span> 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<Span> spans, List<String> 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));
}
} }
} }

View File

@@ -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();
}
}

View File

@@ -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) {}

View File

@@ -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<String> 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));
}
}
}
}

View File

@@ -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<String> items, List<String> npcs, List<String> exits) {
public RoomView {
items = List.copyOf(items);
npcs = List.copyOf(npcs);
exits = List.copyOf(exits);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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 }

View File

@@ -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<Span> spans;
private StyledText(List<Span> spans) {
this.spans = List.copyOf(spans);
}
/** @return the spans in order (unmodifiable). */
public List<Span> 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<Span> 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);
}
}
}

View File

@@ -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.

View File

@@ -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("");
}
}

View File

@@ -36,7 +36,7 @@ public class TestIO implements GameIO {
} }
@Override @Override
public void write(String s) { public void print(thb.jeanluc.adventure.io.text.StyledText text) {
outputs.add(s); outputs.add(text.plainText());
} }
} }

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}