From 64cc65480316d715c28e5f3bcec2ef8c5440d0e9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:18:18 +0200 Subject: [PATCH] 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("─"); + } +}