Merge map / mini-map (sub-project 2) into develop
Fog-of-war map from the directional exit graph: MapLayout (BFS) builds a frontend-agnostic MapView; SwingIO draws it (Graphics2D MapPanel, replacing the exits text), ConsoleIO mirrors it as ASCII via the `map` command. Visited tracking on Player; per-turn map push from Game. 90 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui)
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen |
|
| `go <richtung>` | `move`, `walk` | In eine Himmelsrichtung gehen |
|
||||||
| `look` | `l` | Aktuellen Raum beschreiben |
|
| `look` | `l` | Aktuellen Raum beschreiben |
|
||||||
|
| `map` | `m` | Karte der erkundeten Räume anzeigen |
|
||||||
| `inventory` | `inv`, `i` | Inventar anzeigen |
|
| `inventory` | `inv`, `i` | Inventar anzeigen |
|
||||||
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
|
| `take <item>` | `pick`, `get` | Gegenstand aufnehmen |
|
||||||
| `drop <item>` | `put` | Gegenstand ablegen |
|
| `drop <item>` | `put` | Gegenstand ablegen |
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr.
|
|||||||
- **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks
|
- **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks
|
||||||
(reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme.
|
(reines ASCII → Unicode-Box-Drawing → Nerd-Glyphen) statt harter Annahme.
|
||||||
|
|
||||||
### 3. Karte / Map
|
### 3. Karte / Map ✅ umgesetzt (Branch `feature/map`)
|
||||||
|
|
||||||
|
> Umgesetzt: `MapLayout` (BFS-Gitter-Layout + Fog of War), `MapView`-Modell,
|
||||||
|
> GUI-`MapPanel` (Graphics2D, ersetzt die Exit-Liste), Konsolen-ASCII via
|
||||||
|
> `AsciiMap` + `map`-Befehl, Besuchte-Räume-Tracking auf `Player`, Map-Push pro Zug.
|
||||||
|
> Item/NPC-Marker und Türen-Styling offen (Quest-Teilprojekt). Spec/Plan unter
|
||||||
|
> `docs/superpowers/`.
|
||||||
|
|
||||||
|
|
||||||
- Übersichtskarte der Räume – in **beiden** Modi.
|
- Übersichtskarte der Räume – in **beiden** Modi.
|
||||||
- Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph).
|
- Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import thb.jeanluc.adventure.command.impl.GoCommand;
|
|||||||
import thb.jeanluc.adventure.command.impl.HelpCommand;
|
import thb.jeanluc.adventure.command.impl.HelpCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
import thb.jeanluc.adventure.command.impl.InventoryCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.LookCommand;
|
import thb.jeanluc.adventure.command.impl.LookCommand;
|
||||||
|
import thb.jeanluc.adventure.command.impl.MapCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
import thb.jeanluc.adventure.command.impl.QuitCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.ReadCommand;
|
import thb.jeanluc.adventure.command.impl.ReadCommand;
|
||||||
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
import thb.jeanluc.adventure.command.impl.TakeCommand;
|
||||||
@@ -68,6 +69,7 @@ public final class App {
|
|||||||
registry.register(new UseCommand(), "use");
|
registry.register(new UseCommand(), "use");
|
||||||
registry.register(new ReadCommand(), "read");
|
registry.register(new ReadCommand(), "read");
|
||||||
registry.register(new ExamineCommand(), "examine", "x", "inspect");
|
registry.register(new ExamineCommand(), "examine", "x", "inspect");
|
||||||
|
registry.register(new MapCommand(), "map", "m");
|
||||||
registry.register(new TalkCommand(), "talk", "speak");
|
registry.register(new TalkCommand(), "talk", "speak");
|
||||||
registry.register(new GiveCommand(), "give");
|
registry.register(new GiveCommand(), "give");
|
||||||
registry.register(new HelpCommand(registry), "help", "?");
|
registry.register(new HelpCommand(registry), "help", "?");
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.command.Command;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
|
import thb.jeanluc.adventure.map.MapLayout;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Shows the fog-of-war map of explored rooms. Usage: {@code map}. */
|
||||||
|
public class MapCommand implements Command {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(GameContext ctx, List<String> args) {
|
||||||
|
MapView view = MapLayout.compute(
|
||||||
|
ctx.getWorld(),
|
||||||
|
ctx.getPlayer().getVisitedRoomIds(),
|
||||||
|
ctx.getPlayer().getCurrentRoom());
|
||||||
|
ctx.getIo().showMap(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String help() {
|
||||||
|
return "map - show the map of explored rooms";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import thb.jeanluc.adventure.command.CommandParser;
|
|||||||
import thb.jeanluc.adventure.command.CommandRegistry;
|
import thb.jeanluc.adventure.command.CommandRegistry;
|
||||||
import thb.jeanluc.adventure.command.ParsedCommand;
|
import thb.jeanluc.adventure.command.ParsedCommand;
|
||||||
import thb.jeanluc.adventure.io.text.Hud;
|
import thb.jeanluc.adventure.io.text.Hud;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
|
import thb.jeanluc.adventure.map.MapLayout;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -63,6 +65,11 @@ public class Game {
|
|||||||
ctx.getPlayer().getGold(),
|
ctx.getPlayer().getGold(),
|
||||||
turn,
|
turn,
|
||||||
false));
|
false));
|
||||||
|
MapView map = MapLayout.compute(
|
||||||
|
ctx.getWorld(),
|
||||||
|
ctx.getPlayer().getVisitedRoomIds(),
|
||||||
|
ctx.getPlayer().getCurrentRoom());
|
||||||
|
ctx.getIo().setMap(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package thb.jeanluc.adventure.io;
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.text.AsciiMap;
|
||||||
import thb.jeanluc.adventure.io.text.Hud;
|
import thb.jeanluc.adventure.io.text.Hud;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
import thb.jeanluc.adventure.io.text.RoomView;
|
import thb.jeanluc.adventure.io.text.RoomView;
|
||||||
import thb.jeanluc.adventure.io.text.Span;
|
import thb.jeanluc.adventure.io.text.Span;
|
||||||
import thb.jeanluc.adventure.io.text.Style;
|
import thb.jeanluc.adventure.io.text.Style;
|
||||||
@@ -100,6 +102,11 @@ public class ConsoleIO implements GameIO {
|
|||||||
out.println(paint(Style.DIM, line));
|
out.println(paint(Style.DIM, line));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showMap(MapView view) {
|
||||||
|
print(AsciiMap.render(view, glyphs != GlyphMode.ASCII));
|
||||||
|
}
|
||||||
|
|
||||||
private String section(String label, List<String> xs, Style st) {
|
private String section(String label, List<String> xs, Style st) {
|
||||||
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
|
StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" ");
|
||||||
for (int i = 0; i < xs.size(); i++) {
|
for (int i = 0; i < xs.size(); i++) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package thb.jeanluc.adventure.io;
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.text.AsciiMap;
|
||||||
import thb.jeanluc.adventure.io.text.Hud;
|
import thb.jeanluc.adventure.io.text.Hud;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
import thb.jeanluc.adventure.io.text.Renderings;
|
import thb.jeanluc.adventure.io.text.Renderings;
|
||||||
import thb.jeanluc.adventure.io.text.RoomView;
|
import thb.jeanluc.adventure.io.text.RoomView;
|
||||||
import thb.jeanluc.adventure.io.text.StyledText;
|
import thb.jeanluc.adventure.io.text.StyledText;
|
||||||
@@ -32,4 +34,14 @@ public interface GameIO {
|
|||||||
default void setHud(Hud hud) {
|
default void setHud(Hud hud) {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-turn push of the current map. GUI repaints its panel; console ignores. */
|
||||||
|
default void setMap(MapView view) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On-demand map (the 'map' command). Default renders ASCII (Unicode frames). */
|
||||||
|
default void showMap(MapView view) {
|
||||||
|
print(AsciiMap.render(view, true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
|
import thb.jeanluc.adventure.io.text.CellState;
|
||||||
|
import thb.jeanluc.adventure.io.text.Connection;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
|
import thb.jeanluc.adventure.io.text.RoomCell;
|
||||||
|
|
||||||
|
import javax.swing.JComponent;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
|
import java.awt.BasicStroke;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Dimension;
|
||||||
|
import java.awt.Font;
|
||||||
|
import java.awt.Graphics;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.Rectangle;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.Stroke;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Draws a {@link MapView} as outlined rooms linked by corridors (Graphics2D). */
|
||||||
|
public class MapPanel extends JComponent {
|
||||||
|
|
||||||
|
private static final int CELL_W = 96;
|
||||||
|
private static final int CELL_H = 46;
|
||||||
|
private static final int GAP_X = 34;
|
||||||
|
private static final int GAP_Y = 30;
|
||||||
|
private static final int PAD = 16;
|
||||||
|
|
||||||
|
private static final Color BG = new Color(0x0e, 0x12, 0x18);
|
||||||
|
private static final Color CORRIDOR = new Color(0x46, 0x70, 0x8a);
|
||||||
|
private static final Color VISITED = new Color(0xcf, 0xd6, 0xe0);
|
||||||
|
private static final Color CURRENT = new Color(0xe6, 0xc3, 0x4a);
|
||||||
|
private static final Color KNOWN = new Color(0x3a, 0x46, 0x55);
|
||||||
|
|
||||||
|
private final Font font;
|
||||||
|
private MapView view = new MapView(List.of(), List.of());
|
||||||
|
|
||||||
|
public MapPanel(Font font) {
|
||||||
|
this.font = font.deriveFont(11f);
|
||||||
|
setBackground(BG);
|
||||||
|
setOpaque(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the rendered map and scrolls the current room into view. */
|
||||||
|
public void show(MapView v) {
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
this.view = v;
|
||||||
|
int maxX = 0;
|
||||||
|
int maxY = 0;
|
||||||
|
for (RoomCell c : v.cells()) {
|
||||||
|
maxX = Math.max(maxX, c.x());
|
||||||
|
maxY = Math.max(maxY, c.y());
|
||||||
|
}
|
||||||
|
setPreferredSize(new Dimension(
|
||||||
|
PAD * 2 + (maxX + 1) * CELL_W + maxX * GAP_X,
|
||||||
|
PAD * 2 + (maxY + 1) * CELL_H + maxY * GAP_Y));
|
||||||
|
revalidate();
|
||||||
|
repaint();
|
||||||
|
v.cells().stream().filter(c -> c.state() == CellState.CURRENT).findFirst()
|
||||||
|
.ifPresent(c -> scrollRectToVisible(boundsExpanded(c)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rectangle boundsExpanded(RoomCell c) {
|
||||||
|
int x = PAD + c.x() * (CELL_W + GAP_X);
|
||||||
|
int y = PAD + c.y() * (CELL_H + GAP_Y);
|
||||||
|
return new Rectangle(x - GAP_X, y - GAP_Y, CELL_W + 2 * GAP_X, CELL_H + 2 * GAP_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int cx(RoomCell c) {
|
||||||
|
return PAD + c.x() * (CELL_W + GAP_X);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int cy(RoomCell c) {
|
||||||
|
return PAD + c.y() * (CELL_H + GAP_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void paintComponent(Graphics g0) {
|
||||||
|
Graphics2D g = (Graphics2D) g0.create();
|
||||||
|
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g.setColor(BG);
|
||||||
|
g.fillRect(0, 0, getWidth(), getHeight());
|
||||||
|
g.setFont(font);
|
||||||
|
|
||||||
|
Stroke solid = new BasicStroke(2f);
|
||||||
|
Stroke dashed = new BasicStroke(1.5f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER,
|
||||||
|
10f, new float[]{4f, 3f}, 0f);
|
||||||
|
|
||||||
|
// corridors first
|
||||||
|
for (Connection cn : view.connections()) {
|
||||||
|
boolean known = cn.from().state() == CellState.KNOWN || cn.to().state() == CellState.KNOWN;
|
||||||
|
g.setStroke(known ? dashed : solid);
|
||||||
|
g.setColor(known ? KNOWN : CORRIDOR);
|
||||||
|
int x1 = cx(cn.from()) + CELL_W / 2;
|
||||||
|
int y1 = cy(cn.from()) + CELL_H / 2;
|
||||||
|
int x2 = cx(cn.to()) + CELL_W / 2;
|
||||||
|
int y2 = cy(cn.to()) + CELL_H / 2;
|
||||||
|
g.drawLine(x1, y1, x2, y2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// rooms on top
|
||||||
|
for (RoomCell c : view.cells()) {
|
||||||
|
int x = cx(c);
|
||||||
|
int y = cy(c);
|
||||||
|
Color stroke = switch (c.state()) {
|
||||||
|
case CURRENT -> CURRENT;
|
||||||
|
case VISITED -> VISITED;
|
||||||
|
case KNOWN -> KNOWN;
|
||||||
|
};
|
||||||
|
g.setColor(c.state() == CellState.CURRENT
|
||||||
|
? new Color(0x27, 0x21, 0x10)
|
||||||
|
: new Color(0x11, 0x16, 0x1f));
|
||||||
|
g.fillRoundRect(x, y, CELL_W, CELL_H, 12, 12);
|
||||||
|
|
||||||
|
g.setStroke(c.state() == CellState.KNOWN
|
||||||
|
? dashed
|
||||||
|
: new BasicStroke(c.state() == CellState.CURRENT ? 2.5f : 1.5f));
|
||||||
|
g.setColor(stroke);
|
||||||
|
g.drawRoundRect(x, y, CELL_W, CELL_H, 12, 12);
|
||||||
|
|
||||||
|
String label = c.state() == CellState.KNOWN ? "?" : c.name();
|
||||||
|
int tw = g.getFontMetrics().stringWidth(label);
|
||||||
|
g.setColor(c.state() == CellState.KNOWN ? KNOWN.brighter() : stroke);
|
||||||
|
g.drawString(label, x + (CELL_W - tw) / 2, y + CELL_H / 2 + 4);
|
||||||
|
}
|
||||||
|
g.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package thb.jeanluc.adventure.io;
|
package thb.jeanluc.adventure.io;
|
||||||
|
|
||||||
import thb.jeanluc.adventure.io.text.Hud;
|
import thb.jeanluc.adventure.io.text.Hud;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
import thb.jeanluc.adventure.io.text.RoomView;
|
import thb.jeanluc.adventure.io.text.RoomView;
|
||||||
import thb.jeanluc.adventure.io.text.Span;
|
import thb.jeanluc.adventure.io.text.Span;
|
||||||
import thb.jeanluc.adventure.io.text.Style;
|
import thb.jeanluc.adventure.io.text.Style;
|
||||||
@@ -12,7 +13,6 @@ import javax.swing.JComponent;
|
|||||||
import javax.swing.JFrame;
|
import javax.swing.JFrame;
|
||||||
import javax.swing.JLabel;
|
import javax.swing.JLabel;
|
||||||
import javax.swing.JScrollPane;
|
import javax.swing.JScrollPane;
|
||||||
import javax.swing.JTextArea;
|
|
||||||
import javax.swing.JTextField;
|
import javax.swing.JTextField;
|
||||||
import javax.swing.JTextPane;
|
import javax.swing.JTextPane;
|
||||||
import javax.swing.KeyStroke;
|
import javax.swing.KeyStroke;
|
||||||
@@ -68,7 +68,7 @@ public class SwingIO implements GameIO {
|
|||||||
private final StyledDocument doc;
|
private final StyledDocument doc;
|
||||||
private final JTextField input;
|
private final JTextField input;
|
||||||
private final JLabel hud;
|
private final JLabel hud;
|
||||||
private final JTextArea side;
|
private final MapPanel map;
|
||||||
private final JScrollPane sideScroll;
|
private final JScrollPane sideScroll;
|
||||||
private final Font baseFont;
|
private final Font baseFont;
|
||||||
|
|
||||||
@@ -95,12 +95,9 @@ public class SwingIO implements GameIO {
|
|||||||
hud.setForeground(new Color(0x8b, 0x94, 0xa3));
|
hud.setForeground(new Color(0x8b, 0x94, 0xa3));
|
||||||
hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));
|
hud.setBorder(BorderFactory.createEmptyBorder(6, 12, 6, 12));
|
||||||
|
|
||||||
side = new JTextArea();
|
map = new MapPanel(baseFont);
|
||||||
side.setEditable(false);
|
sideScroll = new JScrollPane(map);
|
||||||
side.setBackground(new Color(0x0e, 0x12, 0x18));
|
sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18));
|
||||||
side.setForeground(new Color(0x6f, 0xcf, 0x73));
|
|
||||||
side.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
|
|
||||||
sideScroll = new JScrollPane(side);
|
|
||||||
|
|
||||||
input = new JTextField();
|
input = new JTextField();
|
||||||
input.addActionListener(e -> {
|
input.addActionListener(e -> {
|
||||||
@@ -185,7 +182,6 @@ public class SwingIO implements GameIO {
|
|||||||
output.setFont(baseFont.deriveFont(main));
|
output.setFont(baseFont.deriveFont(main));
|
||||||
input.setFont(baseFont.deriveFont(main));
|
input.setFont(baseFont.deriveFont(main));
|
||||||
hud.setFont(baseFont.deriveFont(small));
|
hud.setFont(baseFont.deriveFont(small));
|
||||||
side.setFont(baseFont.deriveFont(small));
|
|
||||||
frame.revalidate();
|
frame.revalidate();
|
||||||
frame.repaint();
|
frame.repaint();
|
||||||
}
|
}
|
||||||
@@ -285,9 +281,7 @@ public class SwingIO implements GameIO {
|
|||||||
spans.add(new Span(" is here.\n", Style.PLAIN));
|
spans.add(new Span(" is here.\n", Style.PLAIN));
|
||||||
}
|
}
|
||||||
appendSpans(spans);
|
appendSpans(spans);
|
||||||
|
// Exits are shown by the map panel, not as text.
|
||||||
String exits = room.exits().isEmpty() ? "(none)" : String.join("\n", room.exits());
|
|
||||||
SwingUtilities.invokeLater(() -> side.setText("EXITS\n\n" + exits));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -297,6 +291,16 @@ public class SwingIO implements GameIO {
|
|||||||
SwingUtilities.invokeLater(() -> hud.setText(txt));
|
SwingUtilities.invokeLater(() -> hud.setText(txt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setMap(MapView view) {
|
||||||
|
map.show(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showMap(MapView view) {
|
||||||
|
// The GUI map panel is always visible; nothing extra to do on the 'map' command.
|
||||||
|
}
|
||||||
|
|
||||||
private void addList(List<Span> spans, List<String> xs, Style st) {
|
private void addList(List<Span> spans, List<String> xs, Style st) {
|
||||||
for (int i = 0; i < xs.size(); i++) {
|
for (int i = 0; i < xs.size(); i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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 MIN_INNER = 7; // minimum room name field width
|
||||||
|
private static final int MAX_INNER = 14; // cap so the map stays compact
|
||||||
|
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;
|
||||||
|
int inner = MIN_INNER;
|
||||||
|
Map<Long, RoomCell> at = new HashMap<>();
|
||||||
|
for (RoomCell c : view.cells()) {
|
||||||
|
maxX = Math.max(maxX, c.x());
|
||||||
|
maxY = Math.max(maxY, c.y());
|
||||||
|
inner = Math.max(inner, Math.min(MAX_INNER, c.name().length()));
|
||||||
|
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 ? "│" : "|";
|
||||||
|
|
||||||
|
int boxW = inner + 2;
|
||||||
|
int leftPad = (boxW - 1) / 2;
|
||||||
|
int rightPad = boxW - 1 - leftPad;
|
||||||
|
|
||||||
|
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(boxW));
|
||||||
|
} 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), 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(leftPad)).dim(vt).plain(" ".repeat(rightPad));
|
||||||
|
} else {
|
||||||
|
b.plain(" ".repeat(boxW));
|
||||||
|
}
|
||||||
|
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, int inner) {
|
||||||
|
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,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** Fog-of-war state of a room on the map. */
|
||||||
|
public enum CellState { CURRENT, VISITED, KNOWN }
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** A corridor between two placed rooms. */
|
||||||
|
public record Connection(RoomCell from, RoomCell to) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Frontend-agnostic snapshot of the visible map: placed cells + connections. */
|
||||||
|
public record MapView(List<RoomCell> cells, List<Connection> connections) {
|
||||||
|
public MapView {
|
||||||
|
cells = List.copyOf(cells);
|
||||||
|
connections = List.copyOf(connections);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package thb.jeanluc.adventure.io.text;
|
||||||
|
|
||||||
|
/** A room placed on the map grid at integer coords, with its fog-of-war state. */
|
||||||
|
public record RoomCell(String id, String name, int x, int y, CellState state) {}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package thb.jeanluc.adventure.map;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import thb.jeanluc.adventure.io.text.CellState;
|
||||||
|
import thb.jeanluc.adventure.io.text.Connection;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
|
import thb.jeanluc.adventure.io.text.RoomCell;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.ArrayDeque;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Deque;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes a {@link MapView} from the room graph: BFS assigns grid coordinates
|
||||||
|
* from exit directions, then fog-of-war keeps only visited rooms and their
|
||||||
|
* directly-reachable (but unentered) neighbours.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class MapLayout {
|
||||||
|
|
||||||
|
private MapLayout() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MapView compute(World world, Set<String> visited, Room current) {
|
||||||
|
if (visited.isEmpty()) {
|
||||||
|
return new MapView(List.of(), List.of());
|
||||||
|
}
|
||||||
|
String anchorId = visited.iterator().next();
|
||||||
|
Room anchor = world.getRooms().get(anchorId);
|
||||||
|
if (anchor == null) {
|
||||||
|
return new MapView(List.of(), List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, int[]> coords = bfs(anchor);
|
||||||
|
|
||||||
|
// Determine which rooms are placed: visited (with coords) + their unentered neighbours.
|
||||||
|
Map<String, CellState> placed = new LinkedHashMap<>();
|
||||||
|
for (String id : visited) {
|
||||||
|
if (coords.containsKey(id)) {
|
||||||
|
placed.put(id, id.equals(current.getId()) ? CellState.CURRENT : CellState.VISITED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (String id : visited) {
|
||||||
|
Room r = world.getRooms().get(id);
|
||||||
|
if (r == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (Room nb : r.getExits().values()) {
|
||||||
|
if (!placed.containsKey(nb.getId()) && coords.containsKey(nb.getId())) {
|
||||||
|
placed.put(nb.getId(), CellState.KNOWN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise coordinates to non-negative.
|
||||||
|
int minX = Integer.MAX_VALUE;
|
||||||
|
int minY = Integer.MAX_VALUE;
|
||||||
|
for (String id : placed.keySet()) {
|
||||||
|
int[] c = coords.get(id);
|
||||||
|
minX = Math.min(minX, c[0]);
|
||||||
|
minY = Math.min(minY, c[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, RoomCell> cells = new LinkedHashMap<>();
|
||||||
|
for (Map.Entry<String, CellState> e : placed.entrySet()) {
|
||||||
|
int[] c = coords.get(e.getKey());
|
||||||
|
String name = e.getValue() == CellState.KNOWN ? "?" : world.getRooms().get(e.getKey()).getName();
|
||||||
|
cells.put(e.getKey(), new RoomCell(e.getKey(), name, c[0] - minX, c[1] - minY, e.getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connections: from each visited room to any placed neighbour, de-duplicated.
|
||||||
|
List<Connection> connections = new ArrayList<>();
|
||||||
|
Set<String> seen = new HashSet<>();
|
||||||
|
for (String id : visited) {
|
||||||
|
Room r = world.getRooms().get(id);
|
||||||
|
RoomCell from = cells.get(id);
|
||||||
|
if (r == null || from == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (Room nb : r.getExits().values()) {
|
||||||
|
RoomCell to = cells.get(nb.getId());
|
||||||
|
if (to == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String key = id.compareTo(nb.getId()) < 0 ? id + "|" + nb.getId() : nb.getId() + "|" + id;
|
||||||
|
if (seen.add(key)) {
|
||||||
|
connections.add(new Connection(from, to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MapView(List.copyOf(cells.values()), connections);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, int[]> bfs(Room anchor) {
|
||||||
|
Map<String, int[]> coords = new HashMap<>();
|
||||||
|
Deque<Room> queue = new ArrayDeque<>();
|
||||||
|
coords.put(anchor.getId(), new int[]{0, 0});
|
||||||
|
queue.add(anchor);
|
||||||
|
while (!queue.isEmpty()) {
|
||||||
|
Room r = queue.poll();
|
||||||
|
int[] c = coords.get(r.getId());
|
||||||
|
for (Map.Entry<Direction, Room> e : r.getExits().entrySet()) {
|
||||||
|
Room nb = e.getValue();
|
||||||
|
int[] d = delta(e.getKey());
|
||||||
|
int[] nc = {c[0] + d[0], c[1] + d[1]};
|
||||||
|
int[] existing = coords.get(nb.getId());
|
||||||
|
if (existing == null) {
|
||||||
|
coords.put(nb.getId(), nc);
|
||||||
|
queue.add(nb);
|
||||||
|
} else if (existing[0] != nc[0] || existing[1] != nc[1]) {
|
||||||
|
log.warn("Map layout collision at room '{}' ({} vs {},{}); keeping first placement",
|
||||||
|
nb.getId(), java.util.Arrays.toString(existing), nc[0], nc[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coords;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int[] delta(Direction d) {
|
||||||
|
return switch (d) {
|
||||||
|
case NORTH -> new int[]{0, -1};
|
||||||
|
case SOUTH -> new int[]{0, 1};
|
||||||
|
case EAST -> new int[]{1, 0};
|
||||||
|
case WEST -> new int[]{-1, 0};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import lombok.Setter;
|
|||||||
import thb.jeanluc.adventure.model.item.Item;
|
import thb.jeanluc.adventure.model.item.Item;
|
||||||
|
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player avatar. Tracks the current room, an ordered inventory, and a
|
* The player avatar. Tracks the current room, an ordered inventory, and a
|
||||||
@@ -15,9 +17,11 @@ import java.util.Optional;
|
|||||||
public class Player {
|
public class Player {
|
||||||
|
|
||||||
/** Room the player is currently standing in. */
|
/** Room the player is currently standing in. */
|
||||||
@Setter
|
|
||||||
private Room currentRoom;
|
private Room currentRoom;
|
||||||
|
|
||||||
|
/** Ids of rooms the player has entered, in first-visit order (for the map). */
|
||||||
|
private final Set<String> visitedRoomIds = new LinkedHashSet<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory keyed by item id. {@link LinkedHashMap} keeps insertion
|
* Inventory keyed by item id. {@link LinkedHashMap} keeps insertion
|
||||||
* order for stable {@code inventory} listings while still providing
|
* order for stable {@code inventory} listings while still providing
|
||||||
@@ -44,6 +48,17 @@ public class Player {
|
|||||||
}
|
}
|
||||||
this.currentRoom = startRoom;
|
this.currentRoom = startRoom;
|
||||||
this.gold = startGold;
|
this.gold = startGold;
|
||||||
|
this.visitedRoomIds.add(startRoom.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the player into a room and records it as visited.
|
||||||
|
*
|
||||||
|
* @param room the room now occupied; must not be null
|
||||||
|
*/
|
||||||
|
public void setCurrentRoom(Room room) {
|
||||||
|
this.currentRoom = room;
|
||||||
|
this.visitedRoomIds.add(room.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package thb.jeanluc.adventure.command.impl;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.game.GameContext;
|
||||||
|
import thb.jeanluc.adventure.io.TestIO;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Player;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MapCommandTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void map_showsVisitedRoomAndKnownNeighbour() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Room hallway = new Room("hallway", "Hallway", "d");
|
||||||
|
kitchen.addExit(Direction.NORTH, hallway);
|
||||||
|
hallway.addExit(Direction.SOUTH, kitchen);
|
||||||
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
rooms.put("kitchen", kitchen);
|
||||||
|
rooms.put("hallway", hallway);
|
||||||
|
World world = new World(rooms, Map.of(), Map.of(), "t", "w");
|
||||||
|
|
||||||
|
Player player = new Player(kitchen, 0); // visited = {kitchen}; hallway is a KNOWN stub
|
||||||
|
TestIO io = new TestIO();
|
||||||
|
GameContext ctx = new GameContext(world, player, io);
|
||||||
|
|
||||||
|
new MapCommand().execute(ctx, List.of());
|
||||||
|
|
||||||
|
String out = io.allOutput();
|
||||||
|
assertThat(out).contains("Kitchen"); // current room name appears on the map
|
||||||
|
assertThat(out).contains("?"); // unexplored neighbour stub
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,10 @@ import thb.jeanluc.adventure.io.TestIO;
|
|||||||
import thb.jeanluc.adventure.model.Direction;
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
import thb.jeanluc.adventure.model.Player;
|
import thb.jeanluc.adventure.model.Player;
|
||||||
import thb.jeanluc.adventure.model.Room;
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
@@ -24,7 +28,10 @@ class GameTest {
|
|||||||
TestIO io = new TestIO()
|
TestIO io = new TestIO()
|
||||||
.enqueue("go north")
|
.enqueue("go north")
|
||||||
.enqueue("quit");
|
.enqueue("quit");
|
||||||
GameContext ctx = new GameContext(null, player, io);
|
Map<String, Room> rooms = new LinkedHashMap<>();
|
||||||
|
rooms.put("kitchen", kitchen);
|
||||||
|
rooms.put("hallway", hallway);
|
||||||
|
GameContext ctx = new GameContext(new World(rooms, Map.of(), Map.of(), "t", "w"), player, io);
|
||||||
|
|
||||||
CommandRegistry registry = new CommandRegistry();
|
CommandRegistry registry = new CommandRegistry();
|
||||||
registry.register(new GoCommand(), "go");
|
registry.register(new GoCommand(), "go");
|
||||||
@@ -42,9 +49,10 @@ class GameTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void run_unknownVerb_writesHint() {
|
void run_unknownVerb_writesHint() {
|
||||||
Player player = new Player(new Room("r", "R", "d"), 0);
|
Room r = new Room("r", "R", "d");
|
||||||
|
Player player = new Player(r, 0);
|
||||||
TestIO io = new TestIO().enqueue("dance").enqueue("quit");
|
TestIO io = new TestIO().enqueue("dance").enqueue("quit");
|
||||||
GameContext ctx = new GameContext(null, player, io);
|
GameContext ctx = new GameContext(new World(Map.of("r", r), Map.of(), Map.of(), "t", "w"), player, io);
|
||||||
|
|
||||||
CommandRegistry registry = new CommandRegistry();
|
CommandRegistry registry = new CommandRegistry();
|
||||||
QuitCommand quit = new QuitCommand();
|
QuitCommand quit = new QuitCommand();
|
||||||
|
|||||||
@@ -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("┌");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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 MapViewTest {
|
||||||
|
@Test
|
||||||
|
void mapViewIsImmutableAndHoldsCellsAndConnections() {
|
||||||
|
RoomCell a = new RoomCell("kitchen", "Kitchen", 0, 0, CellState.CURRENT);
|
||||||
|
RoomCell b = new RoomCell("hallway", "Hallway", 0, -1, CellState.VISITED);
|
||||||
|
MapView v = new MapView(List.of(a, b), List.of(new Connection(a, b)));
|
||||||
|
assertThat(v.cells()).hasSize(2);
|
||||||
|
assertThat(v.connections()).hasSize(1);
|
||||||
|
assertThat(v.connections().getFirst().from().id()).isEqualTo("kitchen");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package thb.jeanluc.adventure.map;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import thb.jeanluc.adventure.io.text.CellState;
|
||||||
|
import thb.jeanluc.adventure.io.text.MapView;
|
||||||
|
import thb.jeanluc.adventure.io.text.RoomCell;
|
||||||
|
import thb.jeanluc.adventure.model.Direction;
|
||||||
|
import thb.jeanluc.adventure.model.Room;
|
||||||
|
import thb.jeanluc.adventure.model.World;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class MapLayoutTest {
|
||||||
|
|
||||||
|
private World worldOf(Room... rooms) {
|
||||||
|
Map<String, Room> byId = new java.util.LinkedHashMap<>();
|
||||||
|
for (Room r : rooms) {
|
||||||
|
byId.put(r.getId(), r);
|
||||||
|
}
|
||||||
|
return new World(byId, Map.of(), Map.of(), "t", "w");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<RoomCell> cell(MapView v, String id) {
|
||||||
|
return v.cells().stream().filter(c -> c.id().equals(id)).findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void placesNeighbourByDirectionAndMarksCurrent() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Room hallway = new Room("hallway", "Hallway", "d");
|
||||||
|
kitchen.addExit(Direction.NORTH, hallway);
|
||||||
|
hallway.addExit(Direction.SOUTH, kitchen);
|
||||||
|
World w = worldOf(kitchen, hallway);
|
||||||
|
|
||||||
|
MapView v = MapLayout.compute(w, new LinkedHashSet<>(java.util.List.of("kitchen", "hallway")), hallway);
|
||||||
|
|
||||||
|
RoomCell k = cell(v, "kitchen").orElseThrow();
|
||||||
|
RoomCell h = cell(v, "hallway").orElseThrow();
|
||||||
|
// hallway is north of kitchen -> smaller y
|
||||||
|
assertThat(h.y()).isLessThan(k.y());
|
||||||
|
assertThat(h.state()).isEqualTo(CellState.CURRENT);
|
||||||
|
assertThat(k.state()).isEqualTo(CellState.VISITED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unexploredNeighbourIsKnownStub() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Room cellar = new Room("cellar", "Cellar", "d");
|
||||||
|
kitchen.addExit(Direction.EAST, cellar);
|
||||||
|
cellar.addExit(Direction.WEST, kitchen);
|
||||||
|
World w = worldOf(kitchen, cellar);
|
||||||
|
|
||||||
|
MapView v = MapLayout.compute(w, new LinkedHashSet<>(java.util.List.of("kitchen")), kitchen);
|
||||||
|
|
||||||
|
RoomCell c = cell(v, "cellar").orElseThrow();
|
||||||
|
assertThat(c.state()).isEqualTo(CellState.KNOWN);
|
||||||
|
assertThat(c.name()).isEqualTo("?");
|
||||||
|
assertThat(v.connections()).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void coordsAreNormalisedToNonNegative() {
|
||||||
|
Room kitchen = new Room("kitchen", "Kitchen", "d");
|
||||||
|
Room west = new Room("west", "West", "d");
|
||||||
|
kitchen.addExit(Direction.WEST, west);
|
||||||
|
west.addExit(Direction.EAST, kitchen);
|
||||||
|
World w = worldOf(kitchen, west);
|
||||||
|
|
||||||
|
MapView v = MapLayout.compute(w, new LinkedHashSet<>(java.util.List.of("kitchen", "west")), kitchen);
|
||||||
|
assertThat(v.cells()).allSatisfy(c -> {
|
||||||
|
assertThat(c.x()).isGreaterThanOrEqualTo(0);
|
||||||
|
assertThat(c.y()).isGreaterThanOrEqualTo(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyVisitedYieldsEmptyMap() {
|
||||||
|
World w = worldOf(new Room("kitchen", "Kitchen", "d"));
|
||||||
|
MapView v = MapLayout.compute(w, new LinkedHashSet<>(), null);
|
||||||
|
assertThat(v.cells()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,4 +54,20 @@ class PlayerTest {
|
|||||||
|
|
||||||
assertThat(p.getCurrentRoom()).isEqualTo(b);
|
assertThat(p.getCurrentRoom()).isEqualTo(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startRoomIsVisitedFromTheStart() {
|
||||||
|
Room start = new Room("kitchen", "Kitchen", "desc");
|
||||||
|
Player p = new Player(start, 0);
|
||||||
|
assertThat(p.getVisitedRoomIds()).containsExactly("kitchen");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void movingMarksTheNewRoomVisited() {
|
||||||
|
Room start = new Room("kitchen", "Kitchen", "desc");
|
||||||
|
Room hall = new Room("hallway", "Hallway", "desc");
|
||||||
|
Player p = new Player(start, 0);
|
||||||
|
p.setCurrentRoom(hall);
|
||||||
|
assertThat(p.getVisitedRoomIds()).containsExactly("kitchen", "hallway");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user