From d57a1ab9a45be3472d40d7bdf5003cc9ffd8d275 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:14:22 +0200 Subject: [PATCH 01/12] feat(io): semantic styled-text model (Style, Span, StyledText) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/text/Span.java | 13 ++++ .../thb/jeanluc/adventure/io/text/Style.java | 4 ++ .../jeanluc/adventure/io/text/StyledText.java | 59 +++++++++++++++++++ .../adventure/io/text/StyledTextTest.java | 38 ++++++++++++ 4 files changed, 114 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Span.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Style.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/StyledText.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java 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/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); + } +} From 02e448d61aca172f649c1e1c3a1e650760396b4a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:15:06 +0200 Subject: [PATCH 02/12] feat(io): RoomView/Hud views and default Renderings Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/text/Hud.java | 4 ++ .../jeanluc/adventure/io/text/Renderings.java | 49 +++++++++++++++++++ .../jeanluc/adventure/io/text/RoomView.java | 13 +++++ .../adventure/io/text/RenderingsTest.java | 38 ++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Hud.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Renderings.java create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/RoomView.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java 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/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(); + } +} From 44fde78c64b5ea6b6a836400f3e8f959c9d6f326 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:16:55 +0200 Subject: [PATCH 03/12] feat(io): make print(StyledText) the GameIO primitive; keep suite green Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/ConsoleIO.java | 6 ++- .../java/thb/jeanluc/adventure/io/GameIO.java | 41 ++++++++++++------- .../thb/jeanluc/adventure/io/SwingIO.java | 6 ++- .../java/thb/jeanluc/adventure/io/TestIO.java | 4 +- 4 files changed, 36 insertions(+), 21 deletions(-) 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..dcd7a5a 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java @@ -6,6 +6,8 @@ import java.io.InputStreamReader; import java.io.PrintStream; import java.nio.charset.StandardCharsets; +import thb.jeanluc.adventure.io.text.StyledText; + /** * {@link GameIO} backed by {@code System.in} and {@code System.out}. * Each {@code write} appends a newline so output blocks are visually @@ -51,7 +53,7 @@ public class ConsoleIO implements GameIO { } @Override - public void write(String s) { - out.println(s); + public void print(StyledText text) { + out.println(text.plainText()); } } 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..791e257 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -11,6 +11,8 @@ import java.awt.Color; import java.awt.Font; import java.util.concurrent.LinkedBlockingQueue; +import thb.jeanluc.adventure.io.text.StyledText; + /** * {@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 @@ -79,7 +81,7 @@ public class SwingIO implements GameIO { } @Override - public void write(String s) { - SwingUtilities.invokeLater(() -> output.append(s + "\n")); + public void print(StyledText text) { + SwingUtilities.invokeLater(() -> output.append(text.plainText() + "\n")); } } 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()); } } From 64cc65480316d715c28e5f3bcec2ef8c5440d0e9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:18:18 +0200 Subject: [PATCH 04/12] feat(io): ConsoleIO ANSI + box-drawing renderer with colour/glyph modes Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/ConsoleIO.java | 133 +++++++++++++++--- .../adventure/io/ConsoleIORenderTest.java | 64 +++++++++ 2 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java 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 dcd7a5a..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,43 +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 thb.jeanluc.adventure.io.text.StyledText; +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 @@ -54,6 +68,85 @@ public class ConsoleIO implements GameIO { @Override public void print(StyledText text) { - out.println(text.plainText()); + 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/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("─"); + } +} From 9274eef4c08e99deaadd3689a7d5bdfb1862e8b6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:18:50 +0200 Subject: [PATCH 05/12] refactor(command): LookCommand emits semantic RoomView Co-Authored-By: Claude Opus 4.8 (1M context) --- .../adventure/command/impl/LookCommand.java | 36 ++++--------------- 1 file changed, 7 insertions(+), 29 deletions(-) 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 From 5f9d52ca415c8ec8ebf5ffa53ff88f881386c3ec Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:19:40 +0200 Subject: [PATCH 06/12] feat(game): publish HUD (location, gold, turn) each loop iteration Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/thb/jeanluc/adventure/game/Game.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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. */ From 963ddfb89e0bba3233886bb2496f109e3d7c5f1c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:21:23 +0200 Subject: [PATCH 07/12] feat(io): SwingIO 4-region layout, JTextPane styling, bundled font loader Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/SwingIO.java | 172 ++++++++++++++---- .../src/main/resources/fonts/README.md | 12 ++ 2 files changed, 151 insertions(+), 33 deletions(-) create mode 100644 Semesterprojekt/src/main/resources/fonts/README.md 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 791e257..b5b69e2 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -1,75 +1,143 @@ 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.BorderFactory; 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.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.GraphicsEnvironment; +import java.awt.Insets; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.LinkedBlockingQueue; -import thb.jeanluc.adventure.io.text.StyledText; - /** - * {@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. */ public class SwingIO implements GameIO { - /** Bridge between the EDT and the game loop. One element = one line. */ 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 Font baseFont; - /** - * 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(); + 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.setFont(baseFont); + 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.setFont(baseFont.deriveFont(12f)); + 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.setFont(baseFont.deriveFont(12f)); + side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + JScrollPane sideScroll = new JScrollPane(side); + sideScroll.setPreferredSize(new Dimension(210, 0)); + + input = new JTextField(); + input.setFont(baseFont); 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(900, 600); frame.setLocationRelativeTo(null); frame.setVisible(true); input.requestFocusInWindow(); } + private Font loadFont() { + try (InputStream in = getClass().getResourceAsStream("/fonts/game.ttf")) { + if (in != null) { + Font f = Font.createFont(Font.TRUETYPE_FONT, in).deriveFont(14f); + GraphicsEnvironment.getLocalGraphicsEnvironment().registerFont(f); + return f; + } + } catch (Exception e) { + // fall through to the logical monospaced fallback + } + return new Font(Font.MONOSPACED, Font.PLAIN, 14); + } + + 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 { @@ -82,6 +150,44 @@ public class SwingIO implements GameIO { @Override public void print(StyledText text) { - SwingUtilities.invokeLater(() -> output.append(text.plainText() + "\n")); + 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/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. From 650bbbe58c32995333d8a611bbfad1d077082acd Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:22:59 +0200 Subject: [PATCH 08/12] feat(io): welcome banner for game start Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/thb/jeanluc/adventure/App.java | 3 ++- .../thb/jeanluc/adventure/io/text/Banner.java | 18 ++++++++++++++++++ .../jeanluc/adventure/io/text/BannerTest.java | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Banner.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java 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/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/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(); + } +} From d3a9bd06ccafda12966f7d3c05a5cef18084282e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:25:04 +0200 Subject: [PATCH 09/12] fix(io): scale GUI fonts for HiDPI + live zoom (Ctrl +/-/0) Java Swing does not reliably pick up desktop display scaling on every Linux session (tiny text on HiDPI laptops). Detect the display scale from the default GraphicsConfiguration transform (falling back to screen DPI) and multiply all font sizes by it, plus the window size. Add live zoom via Ctrl +/-/0 as a guaranteed manual override. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/SwingIO.java | 107 ++++++++++++++++-- 1 file changed, 99 insertions(+), 8 deletions(-) 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 b5b69e2..6109bd0 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -6,13 +6,16 @@ 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; @@ -22,8 +25,12 @@ 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.KeyEvent; import java.io.InputStream; import java.util.ArrayList; import java.util.List; @@ -34,9 +41,20 @@ import java.util.concurrent.LinkedBlockingQueue; * 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 { + /** 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; + private final LinkedBlockingQueue inputs = new LinkedBlockingQueue<>(); private final JFrame frame; private final JTextPane output; @@ -46,14 +64,20 @@ public class SwingIO implements GameIO { private final JTextArea side; 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; + public SwingIO(String title) { baseFont = loadFont(); + uiScale = detectScale(); output = new JTextPane(); output.setEditable(false); output.setBackground(new Color(0x0b, 0x0e, 0x13)); output.setForeground(new Color(0xcf, 0xd6, 0xe0)); - output.setFont(baseFont); output.setMargin(new Insets(8, 8, 8, 8)); doc = output.getStyledDocument(); @@ -61,20 +85,17 @@ public class SwingIO implements GameIO { hud.setOpaque(true); hud.setBackground(new Color(0x11, 0x15, 0x1c)); hud.setForeground(new Color(0x8b, 0x94, 0xa3)); - hud.setFont(baseFont.deriveFont(12f)); 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.setFont(baseFont.deriveFont(12f)); side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); JScrollPane sideScroll = new JScrollPane(side); - sideScroll.setPreferredSize(new Dimension(210, 0)); + sideScroll.setPreferredSize(new Dimension((int) (210 * uiScale), 0)); input = new JTextField(); - input.setFont(baseFont); input.addActionListener(e -> { String line = input.getText(); input.setText(""); @@ -89,23 +110,93 @@ public class SwingIO implements GameIO { frame.add(sideScroll, BorderLayout.EAST); frame.add(input, BorderLayout.SOUTH); frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); - frame.setSize(900, 600); + frame.setSize((int) (900 * uiScale), (int) (600 * uiScale)); frame.setLocationRelativeTo(null); + + installZoomKeys(); + applyFonts(); + 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).deriveFont(14f); + 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, 14); + 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; + } + } + + /** 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) { From 9316f67c31e59705fdc097c7830333933d9f02c4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:25:42 +0200 Subject: [PATCH 10/12] docs: mark presentation layer (sub-project 1) as implemented Co-Authored-By: Claude Opus 4.8 (1M context) --- Semesterprojekt/docs/enhancement-ideas.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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). From b042d7e0ef3a91d3293a6e4387648373410b7046 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:29:51 +0200 Subject: [PATCH 11/12] fix(io): responsive side-panel width (ratio of window, clamped min/max) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Side panel (map slot) now sizes to ~25% of window width, clamped to [160, 320] scaled px, recomputed on resize — instead of a fixed width. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/io/SwingIO.java | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) 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 6109bd0..1ef12f5 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -30,6 +30,8 @@ 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; @@ -55,6 +57,11 @@ public class SwingIO implements GameIO { /** 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<>(); private final JFrame frame; private final JTextPane output; @@ -62,6 +69,7 @@ public class SwingIO implements GameIO { 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. */ @@ -92,8 +100,7 @@ public class SwingIO implements GameIO { side.setBackground(new Color(0x0e, 0x12, 0x18)); side.setForeground(new Color(0x6f, 0xcf, 0x73)); side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - JScrollPane sideScroll = new JScrollPane(side); - sideScroll.setPreferredSize(new Dimension((int) (210 * uiScale), 0)); + sideScroll = new JScrollPane(side); input = new JTextField(); input.addActionListener(e -> { @@ -112,9 +119,16 @@ public class SwingIO implements GameIO { frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); 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(); @@ -154,6 +168,16 @@ public class SwingIO implements GameIO { } } + /** 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; From 13f34e16943ce85ceab842525728ac3095f5dfb6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:38:10 +0200 Subject: [PATCH 12/12] fix: correct .gitignore for .superpowers/ (was glued to .DS_Store line) Co-Authored-By: Claude Opus 4.8 (1M context) --- Semesterprojekt/.gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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/