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:
5
Semesterprojekt/.gitignore
vendored
5
Semesterprojekt/.gitignore
vendored
@@ -36,4 +36,7 @@ build/
|
||||
.vscode/
|
||||
|
||||
### Mac OS ###
|
||||
.DS_Store.superpowers/
|
||||
.DS_Store
|
||||
|
||||
### Brainstorming visual companion ###
|
||||
.superpowers/
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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<String> 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<String> items = room.getItems().values().stream().map(Item::getName).toList();
|
||||
List<String> npcs = room.getNpcs().values().stream().map(Npc::getName).toList();
|
||||
List<String> exits = room.getExits().keySet().stream().map(Direction::getLabel).toList();
|
||||
ctx.getIo().showRoom(new RoomView(room.getName(), room.getDescription(), items, npcs, exits));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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<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 -> "[1;35m";
|
||||
case ITEM -> "[36m";
|
||||
case NPC -> "[33m";
|
||||
case EXIT -> "[32m";
|
||||
case DANGER -> "[31m";
|
||||
case DIM -> "[90m";
|
||||
case PLAIN -> "";
|
||||
};
|
||||
return code.isEmpty() ? s : code + s + "[0m";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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 {
|
||||
|
||||
/** 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<>();
|
||||
|
||||
/** 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<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
|
||||
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<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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
Semesterprojekt/src/main/resources/fonts/README.md
Normal file
12
Semesterprojekt/src/main/resources/fonts/README.md
Normal 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.
|
||||
@@ -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("─");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user