From 9e7961e9038e85fd39ed448547be29a80c27091f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:13:30 +0200 Subject: [PATCH 01/13] docs: implementation plan for presentation/output layer Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-05-31-presentation-layer.md | 1193 +++++++++++++++++ 1 file changed, 1193 insertions(+) create mode 100644 Semesterprojekt/docs/superpowers/plans/2026-05-31-presentation-layer.md diff --git a/Semesterprojekt/docs/superpowers/plans/2026-05-31-presentation-layer.md b/Semesterprojekt/docs/superpowers/plans/2026-05-31-presentation-layer.md new file mode 100644 index 0000000..3dc3951 --- /dev/null +++ b/Semesterprojekt/docs/superpowers/plans/2026-05-31-presentation-layer.md @@ -0,0 +1,1193 @@ +# Presentation/Output Layer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the plain `GameIO.write(String)` channel with a semantic, role-styled output layer so both the console and the Swing GUI can render rich output from the same frontend-agnostic game loop. + +**Architecture:** Commands emit *meaning* (`StyledText`, `RoomView`, `Hud`) — not formatting. `GameIO` exposes `print(StyledText)` as the abstract primitive plus `showRoom`/`setHud`/`write` defaults. `ConsoleIO` renders to ANSI + box-drawing; `SwingIO` renders into a 4-region window with a bundled font. Existing 67 tests stay green because `TestIO` flattens styled output to plain text. + +**Tech Stack:** Java 25, Maven, JUnit 5 + AssertJ, Lombok (existing), Swing (`JTextPane`/`StyledDocument`). + +Spec: [docs/superpowers/specs/2026-05-31-presentation-layer-design.md](../specs/2026-05-31-presentation-layer-design.md) + +--- + +## File Structure + +**Create:** +- `src/main/java/thb/jeanluc/adventure/io/text/Style.java` — role enum +- `src/main/java/thb/jeanluc/adventure/io/text/Span.java` — text+style record +- `src/main/java/thb/jeanluc/adventure/io/text/StyledText.java` — span list + fluent builder +- `src/main/java/thb/jeanluc/adventure/io/text/RoomView.java` — semantic room view +- `src/main/java/thb/jeanluc/adventure/io/text/Hud.java` — status snapshot +- `src/main/java/thb/jeanluc/adventure/io/text/Renderings.java` — RoomView → generic StyledText (default rendering) +- `src/main/java/thb/jeanluc/adventure/io/text/Banner.java` — welcome banner StyledText +- `src/main/resources/fonts/README.md` — drop-in instructions for a bundled `.ttf` +- `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java` +- `src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java` +- `src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java` + +**Modify:** +- `src/main/java/thb/jeanluc/adventure/io/GameIO.java` — add `print`/`showRoom`/`setHud`, `write` default +- `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java` — render ANSI + box-drawing + HUD; color/glyph modes +- `src/main/java/thb/jeanluc/adventure/io/SwingIO.java` — 4-region layout, `JTextPane`, bundled font +- `src/test/java/thb/jeanluc/adventure/io/TestIO.java` — flatten styled output to plain text +- `src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java` — emit `RoomView` +- `src/main/java/thb/jeanluc/adventure/game/Game.java` — per-turn HUD update +- `src/main/java/thb/jeanluc/adventure/App.java` — welcome banner + +--- + +## Task 1: Semantic text model (`Style`, `Span`, `StyledText`) + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/io/text/Style.java` +- Create: `src/main/java/thb/jeanluc/adventure/io/text/Span.java` +- Create: `src/main/java/thb/jeanluc/adventure/io/text/StyledText.java` +- Test: `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=StyledTextTest` +Expected: FAIL — compilation error, `Style`/`Span`/`StyledText` do not exist. + +- [ ] **Step 3: Write the implementation** + +`Style.java`: +```java +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 } +``` + +`Span.java`: +```java +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"); + } + } +} +``` + +`StyledText.java`: +```java +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); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -q test -Dtest=StyledTextTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/text/Style.java \ + src/main/java/thb/jeanluc/adventure/io/text/Span.java \ + src/main/java/thb/jeanluc/adventure/io/text/StyledText.java \ + src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java +git commit -m "feat(io): semantic styled-text model (Style, Span, StyledText)" +``` + +--- + +## Task 2: Views + default rendering (`RoomView`, `Hud`, `Renderings`) + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/io/text/RoomView.java` +- Create: `src/main/java/thb/jeanluc/adventure/io/text/Hud.java` +- Create: `src/main/java/thb/jeanluc/adventure/io/text/Renderings.java` +- Test: `src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java` + +- [ ] **Step 1: Write the failing test** + +```java +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(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=RenderingsTest` +Expected: FAIL — `RoomView`/`Renderings` do not exist. + +- [ ] **Step 3: Write the implementation** + +`RoomView.java`: +```java +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); + } +} +``` + +`Hud.java`: +```java +package thb.jeanluc.adventure.io.text; + +/** Status snapshot for the HUD region. */ +public record Hud(String location, int gold, int turn, boolean lightOn) {} +``` + +`Renderings.java`: +```java +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)); + } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -q test -Dtest=RenderingsTest` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/text/RoomView.java \ + src/main/java/thb/jeanluc/adventure/io/text/Hud.java \ + src/main/java/thb/jeanluc/adventure/io/text/Renderings.java \ + src/test/java/thb/jeanluc/adventure/io/text/RenderingsTest.java +git commit -m "feat(io): RoomView/Hud views and default Renderings" +``` + +--- + +## Task 3: Extend `GameIO`, keep all implementors compiling and tests green + +This makes `print(StyledText)` the abstract primitive and `write`/`showRoom`/`setHud` defaults, then gives each implementor a minimal `print` so the build stays green. Rich rendering comes in later tasks. + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/io/GameIO.java` +- Modify: `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java` +- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java` +- Modify: `src/test/java/thb/jeanluc/adventure/io/TestIO.java` + +- [ ] **Step 1: Rewrite `GameIO.java`** + +```java +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 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; empty string at end of input. */ + String readLine(); + + /** 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 + } +} +``` + +- [ ] **Step 2: Give `ConsoleIO` a minimal `print` (full rendering in Task 4)** + +Replace the `write` method in `ConsoleIO.java` with: +```java + @Override + public void print(StyledText text) { + out.println(text.plainText()); + } +``` +(Remove the old `@Override public void write(String s)` method; the `GameIO` default now covers it.) + +- [ ] **Step 3: Give `SwingIO` a minimal `print` (full layout in Task 7)** + +In `SwingIO.java`, replace the `write` method with: +```java + @Override + public void print(StyledText text) { + SwingUtilities.invokeLater(() -> output.append(text.plainText() + "\n")); + } +``` +(Remove the old `@Override public void write(String s)` method.) + +- [ ] **Step 4: Flatten styled output in `TestIO.java`** + +Replace the `write` method in `TestIO.java` with the new primitive (and keep the `import`): +```java + @Override + public void print(thb.jeanluc.adventure.io.text.StyledText text) { + outputs.add(text.plainText()); + } +``` +(Remove the old `@Override public void write(String s)` method. `showRoom`/`setHud` use the `GameIO` defaults: `showRoom` flows through `print`, so room text is still captured in `outputs`.) + +- [ ] **Step 5: Run the full suite** + +Run: `mvn -q test` +Expected: PASS — all 67 existing tests still green (output now routed through `print`, plain-text content unchanged). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/GameIO.java \ + src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java \ + src/main/java/thb/jeanluc/adventure/io/SwingIO.java \ + src/test/java/thb/jeanluc/adventure/io/TestIO.java +git commit -m "feat(io): make print(StyledText) the GameIO primitive; keep suite green" +``` + +--- + +## Task 4: `ConsoleIO` rich renderer (ANSI + box-drawing + HUD + modes) + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java` +- Test: `src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java` + +- [ ] **Step 1: Write the failing test** (colour OFF so assertions are on structure, not escape codes) + +```java +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("─"); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=ConsoleIORenderTest` +Expected: FAIL — the 4-arg constructor and `ColorMode`/`GlyphMode` do not exist yet. + +- [ ] **Step 3: Rewrite `ConsoleIO.java`** + +```java +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} 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 { + + /** Whether ANSI colour is emitted. */ + public enum ColorMode { ON, OFF, AUTO } + + /** Frame/glyph fidelity tier. */ + public enum GlyphMode { ASCII, UNICODE, GLYPH } + + private static final int FRAME_WIDTH = 44; + + private final BufferedReader in; + private final PrintStream out; + private final boolean useColor; + private final GlyphMode glyphs; + + public ConsoleIO() { + this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), + System.out, ColorMode.AUTO, GlyphMode.UNICODE); + } + + /** 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 + public String readLine() { + out.print("> "); + out.flush(); + try { + String line = in.readLine(); + return line == null ? "" : line; + } catch (IOException e) { + return ""; + } + } + + @Override + public void print(StyledText text) { + StringBuilder sb = new StringBuilder(); + for (Span s : text.spans()) { + sb.append(paint(s.style(), s.text())); + } + out.println(sb); + } + + @Override + public void showRoom(RoomView room) { + out.println(paint(Style.HEADING, frameTop(room.name()))); + out.println(room.description().stripTrailing()); + if (!room.items().isEmpty()) { + out.println(section("Items", room.items(), Style.ITEM)); + } + if (!room.npcs().isEmpty()) { + out.println(section("People", room.npcs(), Style.NPC)); + } + if (!room.exits().isEmpty()) { + out.println(section("Exits", room.exits(), Style.EXIT)); + } else { + out.println(" " + paint(Style.DIM, "no obvious exits")); + } + out.println(paint(Style.HEADING, frameBottom())); + } + + @Override + public void setHud(Hud h) { + String line = "[ " + h.location() + " · " + h.gold() + " gold · turn " + h.turn() + + " · light: " + (h.lightOn() ? "on" : "off") + " ]"; + out.println(paint(Style.DIM, line)); + } + + private String section(String label, List xs, Style st) { + StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" "); + for (int i = 0; i < xs.size(); i++) { + if (i > 0) { + sb.append(" · "); + } + sb.append(paint(st, xs.get(i))); + } + return sb.toString(); + } + + private String frameTop(String title) { + String tl = glyphs == GlyphMode.ASCII ? "+" : "┌"; + String tr = glyphs == GlyphMode.ASCII ? "+" : "┐"; + String h = glyphs == GlyphMode.ASCII ? "-" : "─"; + StringBuilder sb = new StringBuilder(tl).append(h).append(' ') + .append(title.toUpperCase()).append(' '); + while (sb.length() < FRAME_WIDTH - 1) { + sb.append(h); + } + return sb.append(tr).toString(); + } + + private String frameBottom() { + String bl = glyphs == GlyphMode.ASCII ? "+" : "└"; + String br = glyphs == GlyphMode.ASCII ? "+" : "┘"; + String h = glyphs == GlyphMode.ASCII ? "-" : "─"; + StringBuilder sb = new StringBuilder(bl); + while (sb.length() < FRAME_WIDTH - 1) { + sb.append(h); + } + return sb.append(br).toString(); + } + + private String paint(Style st, String s) { + if (!useColor) { + return s; + } + String code = switch (st) { + case HEADING -> ""; + case ITEM -> ""; + case NPC -> ""; + case EXIT -> ""; + case DANGER -> ""; + case DIM -> ""; + case PLAIN -> ""; + }; + return code.isEmpty() ? s : code + s + ""; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -q test -Dtest=ConsoleIORenderTest` +Expected: PASS (4 tests). + +- [ ] **Step 5: Run the full suite** + +Run: `mvn -q test` +Expected: PASS (existing tests + new ones). + +- [ ] **Step 6: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java \ + src/test/java/thb/jeanluc/adventure/io/ConsoleIORenderTest.java +git commit -m "feat(io): ConsoleIO ANSI + box-drawing renderer with colour/glyph modes" +``` + +--- + +## Task 5: Migrate `LookCommand` to emit a `RoomView` + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java` +- Test (existing, should stay green): `src/test/java/thb/jeanluc/adventure/command/impl/LookCommandTest.java` + +- [ ] **Step 1: Rewrite `LookCommand.execute`** + +```java +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; + +/** + * 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(); + 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 + public String help() { + return "look - describe the current room"; + } +} +``` + +- [ ] **Step 2: Run the relevant tests** + +Run: `mvn -q test -Dtest=LookCommandTest` +Expected: PASS — `TestIO.showRoom` uses the `GameIO` default → `Renderings` → `print`, so `lastOutput()` still contains "Kitchen", "kitchen desc", "Lamp", "Letter", "Old Man", "north". + +- [ ] **Step 3: Run the full suite** + +Run: `mvn -q test` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/command/impl/LookCommand.java +git commit -m "refactor(command): LookCommand emits semantic RoomView" +``` + +--- + +## Task 6: Per-turn HUD in `Game` + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/game/Game.java` + +- [ ] **Step 1: Add a turn counter and HUD update to the loop** + +In `Game.java`, add the import and a field, and update the HUD after each handled command. Full updated `run()` plus the new field: + +Add import: +```java +import thb.jeanluc.adventure.io.text.Hud; +``` + +Add field (next to `running`): +```java + /** Number of commands processed this session; shown in the HUD. */ + private int turn = 0; +``` + +Replace `run()` with: +```java + public void run() { + publishHud(); + while (running) { + String input = ctx.getIo().readLine(); + if (input == null) { + break; + } + ParsedCommand parsed = parser.parse(input); + if (parsed.verb().isEmpty()) { + continue; + } + Optional cmd = registry.find(parsed.verb()); + if (cmd.isEmpty()) { + 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)); + } +``` + +- [ ] **Step 2: Run the full suite** + +Run: `mvn -q test` +Expected: PASS — `GameTest` unaffected (`TestIO.setHud` is the no-op default; HUD output is not asserted). + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/game/Game.java +git commit -m "feat(game): publish HUD (location, gold, turn) each loop iteration" +``` + +--- + +## Task 7: `SwingIO` 4-region layout, `JTextPane`, bundled font + +No unit test (Swing/EDT). Verified manually via `mvn exec:java@gui`. + +**Files:** +- Modify: `src/main/java/thb/jeanluc/adventure/io/SwingIO.java` +- Create: `src/main/resources/fonts/README.md` + +- [ ] **Step 1: Rewrite `SwingIO.java`** + +```java +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; + +/** + * {@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 { + + private final LinkedBlockingQueue inputs = new LinkedBlockingQueue<>(); + private final JFrame frame; + private final JTextPane output; + private final StyledDocument doc; + private final JTextField input; + private final JLabel hud; + private final JTextArea side; + private final Font baseFont; + + public SwingIO(String title) { + baseFont = loadFont(); + + 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(); + + 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(""); + 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(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 { + return inputs.take(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ""; + } + } + + @Override + public void print(StyledText text) { + List spans = new ArrayList<>(text.spans()); + spans.add(new Span("\n", Style.PLAIN)); + appendSpans(spans); + } + + @Override + public void showRoom(RoomView room) { + List spans = new ArrayList<>(); + spans.add(new Span(room.name() + "\n", Style.HEADING)); + spans.add(new Span(room.description().stripTrailing() + "\n", Style.PLAIN)); + if (!room.items().isEmpty()) { + spans.add(new Span("You see ", Style.PLAIN)); + addList(spans, room.items(), Style.ITEM); + spans.add(new Span(".\n", Style.PLAIN)); + } + if (!room.npcs().isEmpty()) { + addList(spans, room.npcs(), Style.NPC); + spans.add(new Span(" is here.\n", Style.PLAIN)); + } + appendSpans(spans); + + String exits = room.exits().isEmpty() ? "(none)" : String.join("\n", room.exits()); + SwingUtilities.invokeLater(() -> side.setText("EXITS\n\n" + exits)); + } + + @Override + public void setHud(Hud h) { + String txt = " " + h.location() + " gold " + h.gold() + " turn " + h.turn() + + " light " + (h.lightOn() ? "on" : "off"); + SwingUtilities.invokeLater(() -> hud.setText(txt)); + } + + private void addList(List spans, List xs, Style st) { + for (int i = 0; i < xs.size(); i++) { + if (i > 0) { + spans.add(new Span(", ", Style.PLAIN)); + } + spans.add(new Span(xs.get(i), st)); + } + } +} +``` + +- [ ] **Step 2: Create the font drop-in note** + +`src/main/resources/fonts/README.md`: +```markdown +# 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. +``` + +- [ ] **Step 3: Verify it compiles and the suite is green** + +Run: `mvn -q test` +Expected: PASS. + +- [ ] **Step 4: Manual smoke test of the GUI** + +Run: `mvn -q -DskipTests exec:java@gui` +Expected: a window opens with a HUD bar on top, styled room text in the centre, an EXITS list on the right, and an input field at the bottom. Typing `look`, `go north`, `take lamp` updates the regions. Close the window to exit. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/SwingIO.java \ + src/main/resources/fonts/README.md +git commit -m "feat(io): SwingIO 4-region layout, JTextPane styling, bundled font loader" +``` + +--- + +## Task 8: Welcome banner + wire-in + +**Files:** +- Create: `src/main/java/thb/jeanluc/adventure/io/text/Banner.java` +- Test: add to `src/test/java/thb/jeanluc/adventure/io/text/StyledTextTest.java` (or a new `BannerTest`) +- Modify: `src/main/java/thb/jeanluc/adventure/App.java` + +- [ ] **Step 1: Write the failing test** + +`src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java`: +```java +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(); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `mvn -q test -Dtest=BannerTest` +Expected: FAIL — `Banner` does not exist. + +- [ ] **Step 3: Write `Banner.java`** + +```java +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(); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `mvn -q test -Dtest=BannerTest` +Expected: PASS. + +- [ ] **Step 5: Use the banner in `App.run`** + +In `App.java`, add the import: +```java +import thb.jeanluc.adventure.io.text.Banner; +``` +Replace these two lines in `run(GameIO io)`: +```java + io.write(loaded.world().getTitle()); + io.write(loaded.world().getWelcomeMessage()); +``` +with: +```java + io.print(Banner.welcome(loaded.world().getTitle())); + io.write(loaded.world().getWelcomeMessage()); +``` + +- [ ] **Step 6: Run the full suite** + +Run: `mvn -q test` +Expected: PASS. + +- [ ] **Step 7: Final verification (both frontends)** + +Run: `mvn -q test` +Expected: PASS — full suite green. + +Run: `printf 'look\ngo north\nquit\n' | mvn -q -DskipTests exec:java@run` +Expected: framed welcome banner, a boxed room view with coloured/ANSI sections (or plain when piped), HUD line, no exceptions. + +- [ ] **Step 8: Commit** + +```bash +git add src/main/java/thb/jeanluc/adventure/io/text/Banner.java \ + src/test/java/thb/jeanluc/adventure/io/text/BannerTest.java \ + src/main/java/thb/jeanluc/adventure/App.java +git commit -m "feat(io): welcome banner for game start" +``` + +--- + +## Self-Review notes + +- **Spec coverage:** model (T1), views + default rendering (T2), GameIO extension + migration (T3), ConsoleIO B-baseline + modes + glyph fallback (T4), command migration (T5), HUD (T6), SwingIO region layout + bundled font + nerd-font solution (T7), ASCII-art banner mechanism (T8). Map/menu/save/music explicitly deferred — side panel slot delivered in T7. +- **Type consistency:** `print(StyledText)`, `showRoom(RoomView)`, `setHud(Hud)`, `StyledText.builder()/of()/spans()/plainText()`, `Renderings.roomToStyledText`, `ConsoleIO.ColorMode/GlyphMode`, `Banner.welcome` are used consistently across tasks. +- **Open spec detail:** `Hud.lightOn` is wired as constant `false` (T6) until the light mechanic arrives with content — matches spec §11. From 32ece84a85008ede3ea0db764c70bbcdf6dc0412 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:14:22 +0200 Subject: [PATCH 02/13] 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 f2b243afb5ef268aaa10463137f55184ea69ea39 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:15:06 +0200 Subject: [PATCH 03/13] 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 95ed54eb2506a1d3486fc6b96526680e89dd6a28 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:16:55 +0200 Subject: [PATCH 04/13] 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 d31b583871ea7fe82703fe870781efae8c98fa54 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:18:18 +0200 Subject: [PATCH 05/13] 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 d033a878db45ae848461251fdcdeb05ccb092302 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:18:50 +0200 Subject: [PATCH 06/13] 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 c27d7194523f5603ddea65a0fbea93e6a37f908b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:19:40 +0200 Subject: [PATCH 07/13] 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 d25f9f45045a14cbbe959430e9e61d849fc0899a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:21:23 +0200 Subject: [PATCH 08/13] 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 42330d937657f2c4a2c2a787443adf8a976db2e2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:22:59 +0200 Subject: [PATCH 09/13] 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 94ce5ab2f74ff57017e9f11872a951dc6d3d41dc Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:25:04 +0200 Subject: [PATCH 10/13] 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 5181622f7dffca3158b40414e3e2e0debe038047 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:25:42 +0200 Subject: [PATCH 11/13] 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 f91d525fd0b32890ae18ee4447ac8467bc36d9b4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:29:51 +0200 Subject: [PATCH 12/13] 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 2616c9bea538189a86392e065041d97f79ae7b01 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:38:10 +0200 Subject: [PATCH 13/13] 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/