feat(io): AsciiMap console renderer for MapView
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a {@link MapView} as a styled ASCII grid: boxed rooms connected by
|
||||||
|
* corridor lines. Box-drawing uses Unicode or pure ASCII depending on {@code unicode}.
|
||||||
|
*/
|
||||||
|
public final class AsciiMap {
|
||||||
|
|
||||||
|
private static final int INNER = 7; // room name field width
|
||||||
|
private static final int GAP = 3; // horizontal corridor width
|
||||||
|
|
||||||
|
private AsciiMap() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StyledText render(MapView view, boolean unicode) {
|
||||||
|
if (view.cells().isEmpty()) {
|
||||||
|
return StyledText.of("(no map yet)");
|
||||||
|
}
|
||||||
|
|
||||||
|
int maxX = 0;
|
||||||
|
int maxY = 0;
|
||||||
|
Map<Long, RoomCell> at = new HashMap<>();
|
||||||
|
for (RoomCell c : view.cells()) {
|
||||||
|
maxX = Math.max(maxX, c.x());
|
||||||
|
maxY = Math.max(maxY, c.y());
|
||||||
|
at.put(key(c.x(), c.y()), c);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<Long> hEdge = new HashSet<>();
|
||||||
|
Set<Long> vEdge = new HashSet<>();
|
||||||
|
for (Connection cn : view.connections()) {
|
||||||
|
RoomCell a = cn.from();
|
||||||
|
RoomCell b = cn.to();
|
||||||
|
if (a.y() == b.y()) {
|
||||||
|
hEdge.add(key(Math.min(a.x(), b.x()), a.y()));
|
||||||
|
} else if (a.x() == b.x()) {
|
||||||
|
vEdge.add(key(a.x(), Math.min(a.y(), b.y())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String tl = unicode ? "┌" : "+";
|
||||||
|
String tr = unicode ? "┐" : "+";
|
||||||
|
String bl = unicode ? "└" : "+";
|
||||||
|
String br = unicode ? "┘" : "+";
|
||||||
|
String hz = unicode ? "─" : "-";
|
||||||
|
String vt = unicode ? "│" : "|";
|
||||||
|
|
||||||
|
StyledText.Builder b = StyledText.builder();
|
||||||
|
for (int gy = 0; gy <= maxY; gy++) {
|
||||||
|
for (int line = 0; line < 3; line++) {
|
||||||
|
for (int gx = 0; gx <= maxX; gx++) {
|
||||||
|
RoomCell c = at.get(key(gx, gy));
|
||||||
|
if (c == null) {
|
||||||
|
b.plain(" ".repeat(INNER + 2));
|
||||||
|
} else if (line == 0) {
|
||||||
|
styled(b, tl + hz.repeat(INNER) + tr, c.state());
|
||||||
|
} else if (line == 2) {
|
||||||
|
styled(b, bl + hz.repeat(INNER) + br, c.state());
|
||||||
|
} else {
|
||||||
|
styled(b, vt + center(label(c), INNER) + vt, c.state());
|
||||||
|
}
|
||||||
|
if (gx < maxX) {
|
||||||
|
if (line == 1 && hEdge.contains(key(gx, gy))) {
|
||||||
|
b.dim(hz.repeat(GAP));
|
||||||
|
} else {
|
||||||
|
b.plain(" ".repeat(GAP));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.plain("\n");
|
||||||
|
}
|
||||||
|
if (gy < maxY) {
|
||||||
|
for (int gx = 0; gx <= maxX; gx++) {
|
||||||
|
if (vEdge.contains(key(gx, gy))) {
|
||||||
|
b.plain(" ".repeat(4)).dim(vt).plain(" ".repeat(4));
|
||||||
|
} else {
|
||||||
|
b.plain(" ".repeat(INNER + 2));
|
||||||
|
}
|
||||||
|
if (gx < maxX) {
|
||||||
|
b.plain(" ".repeat(GAP));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.plain("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void styled(StyledText.Builder b, String s, CellState state) {
|
||||||
|
switch (state) {
|
||||||
|
case CURRENT -> b.heading(s);
|
||||||
|
case KNOWN -> b.dim(s);
|
||||||
|
default -> b.plain(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String label(RoomCell c) {
|
||||||
|
String n = c.name();
|
||||||
|
if (n.length() > INNER) {
|
||||||
|
n = n.substring(0, INNER);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String center(String s, int width) {
|
||||||
|
int total = width - s.length();
|
||||||
|
int left = total / 2;
|
||||||
|
int right = total - left;
|
||||||
|
return " ".repeat(left) + s + " ".repeat(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long key(int x, int y) {
|
||||||
|
return (((long) x) << 32) ^ (y & 0xffffffffL);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 AsciiMapTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rendersVisitedNameCurrentAndKnownStub() {
|
||||||
|
RoomCell kitchen = new RoomCell("kitchen", "Kitchen", 0, 1, CellState.CURRENT);
|
||||||
|
RoomCell hallway = new RoomCell("hallway", "Hallway", 0, 0, CellState.VISITED);
|
||||||
|
RoomCell cellar = new RoomCell("cellar", "?", 1, 1, CellState.KNOWN);
|
||||||
|
MapView v = new MapView(List.of(kitchen, hallway, cellar),
|
||||||
|
List.of(new Connection(hallway, kitchen), new Connection(kitchen, cellar)));
|
||||||
|
|
||||||
|
String out = AsciiMap.render(v, true).plainText();
|
||||||
|
|
||||||
|
assertThat(out).contains("Kitchen");
|
||||||
|
assertThat(out).contains("Hallway");
|
||||||
|
assertThat(out).contains("?");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyMapRendersPlaceholder() {
|
||||||
|
String out = AsciiMap.render(new MapView(List.of(), List.of()), true).plainText();
|
||||||
|
assertThat(out).containsIgnoringCase("no map");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void asciiModeUsesPlusFrames() {
|
||||||
|
RoomCell only = new RoomCell("kitchen", "Kitchen", 0, 0, CellState.CURRENT);
|
||||||
|
String out = AsciiMap.render(new MapView(List.of(only), List.of()), false).plainText();
|
||||||
|
assertThat(out).contains("+").doesNotContain("┌");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user