feat(io): ConsoleIO ANSI + box-drawing renderer with colour/glyph modes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,43 +1,57 @@
|
|||||||
package thb.jeanluc.adventure.io;
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.text.Hud;
|
||||||
|
import thb.jeanluc.adventure.io.text.RoomView;
|
||||||
|
import thb.jeanluc.adventure.io.text.Span;
|
||||||
|
import thb.jeanluc.adventure.io.text.Style;
|
||||||
|
import thb.jeanluc.adventure.io.text.StyledText;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.PrintStream;
|
import java.io.PrintStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
import thb.jeanluc.adventure.io.text.StyledText;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link GameIO} backed by {@code System.in} and {@code System.out}.
|
* {@link GameIO} for the terminal: ANSI colours, box-drawing room frames
|
||||||
* Each {@code write} appends a newline so output blocks are visually
|
* (level "B"), and a dim HUD line. Colour and glyph fidelity are configurable
|
||||||
* separated. {@code readLine} prints a {@code > } prompt before
|
* and degrade safely (no colours when output is piped; ASCII frames on request).
|
||||||
* blocking.
|
|
||||||
*/
|
*/
|
||||||
public class ConsoleIO implements GameIO {
|
public class ConsoleIO implements GameIO {
|
||||||
|
|
||||||
/** Input source. */
|
/** Whether ANSI colour is emitted. */
|
||||||
|
public enum ColorMode { ON, OFF, AUTO }
|
||||||
|
|
||||||
|
/** Frame/glyph fidelity tier. */
|
||||||
|
public enum GlyphMode { ASCII, UNICODE, GLYPH }
|
||||||
|
|
||||||
|
private static final int FRAME_WIDTH = 44;
|
||||||
|
|
||||||
private final BufferedReader in;
|
private final BufferedReader in;
|
||||||
|
|
||||||
/** Output sink. */
|
|
||||||
private final PrintStream out;
|
private final PrintStream out;
|
||||||
|
private final boolean useColor;
|
||||||
|
private final GlyphMode glyphs;
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a console IO using standard input and output.
|
|
||||||
*/
|
|
||||||
public ConsoleIO() {
|
public ConsoleIO() {
|
||||||
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)), System.out);
|
this(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)),
|
||||||
|
System.out, ColorMode.AUTO, GlyphMode.UNICODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Backward-compatible stream constructor (colour off, unicode frames). */
|
||||||
* Test-friendly constructor allowing custom streams.
|
|
||||||
*
|
|
||||||
* @param in reader to consume player input
|
|
||||||
* @param out stream to write player output
|
|
||||||
*/
|
|
||||||
public ConsoleIO(BufferedReader in, PrintStream out) {
|
public ConsoleIO(BufferedReader in, PrintStream out) {
|
||||||
|
this(in, out, ColorMode.OFF, GlyphMode.UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConsoleIO(BufferedReader in, PrintStream out, ColorMode colorMode, GlyphMode glyphs) {
|
||||||
this.in = in;
|
this.in = in;
|
||||||
this.out = out;
|
this.out = out;
|
||||||
|
this.glyphs = glyphs;
|
||||||
|
this.useColor = switch (colorMode) {
|
||||||
|
case ON -> true;
|
||||||
|
case OFF -> false;
|
||||||
|
case AUTO -> System.console() != null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -54,6 +68,85 @@ public class ConsoleIO implements GameIO {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void print(StyledText text) {
|
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<String> xs, Style st) {
|
||||||
|
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
|
||||||
|
for (int i = 0; i < xs.size(); i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
sb.append(" · ");
|
||||||
|
}
|
||||||
|
sb.append(paint(st, xs.get(i)));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String frameTop(String title) {
|
||||||
|
String tl = glyphs == GlyphMode.ASCII ? "+" : "┌";
|
||||||
|
String tr = glyphs == GlyphMode.ASCII ? "+" : "┐";
|
||||||
|
String h = glyphs == GlyphMode.ASCII ? "-" : "─";
|
||||||
|
StringBuilder sb = new StringBuilder(tl).append(h).append(' ')
|
||||||
|
.append(title.toUpperCase()).append(' ');
|
||||||
|
while (sb.length() < FRAME_WIDTH - 1) {
|
||||||
|
sb.append(h);
|
||||||
|
}
|
||||||
|
return sb.append(tr).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String frameBottom() {
|
||||||
|
String bl = glyphs == GlyphMode.ASCII ? "+" : "└";
|
||||||
|
String br = glyphs == GlyphMode.ASCII ? "+" : "┘";
|
||||||
|
String h = glyphs == GlyphMode.ASCII ? "-" : "─";
|
||||||
|
StringBuilder sb = new StringBuilder(bl);
|
||||||
|
while (sb.length() < FRAME_WIDTH - 1) {
|
||||||
|
sb.append(h);
|
||||||
|
}
|
||||||
|
return sb.append(br).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String paint(Style st, String s) {
|
||||||
|
if (!useColor) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
String code = switch (st) {
|
||||||
|
case HEADING -> "[1;35m";
|
||||||
|
case ITEM -> "[36m";
|
||||||
|
case NPC -> "[33m";
|
||||||
|
case EXIT -> "[32m";
|
||||||
|
case DANGER -> "[31m";
|
||||||
|
case DIM -> "[90m";
|
||||||
|
case PLAIN -> "";
|
||||||
|
};
|
||||||
|
return code.isEmpty() ? s : code + s + "[0m";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("─");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user