diff --git a/Semesterprojekt/README.md b/Semesterprojekt/README.md index 9125ad6..0806541 100644 --- a/Semesterprojekt/README.md +++ b/Semesterprojekt/README.md @@ -46,6 +46,7 @@ mvn exec:java@gui # Swing-GUI (thb.jeanluc.adventure.AppGui) |---|---|---| | `go ` | `move`, `walk` | In eine Himmelsrichtung gehen | | `look` | `l` | Aktuellen Raum beschreiben | +| `map` | `m` | Karte der erkundeten Räume anzeigen | | `inventory` | `inv`, `i` | Inventar anzeigen | | `take ` | `pick`, `get` | Gegenstand aufnehmen | | `drop ` | `put` | Gegenstand ablegen | diff --git a/Semesterprojekt/docs/enhancement-ideas.md b/Semesterprojekt/docs/enhancement-ideas.md index ee69844..900a459 100644 --- a/Semesterprojekt/docs/enhancement-ideas.md +++ b/Semesterprojekt/docs/enhancement-ideas.md @@ -56,7 +56,14 @@ Farb-Rollen: Items / NPCs / Exits / Heading / Gefahr. - **Konsole**: Font nicht kontrollierbar → gestufte Fallbacks (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. - Konsole: ASCII-Map. GUI: gezeichnete Map (Grid/Graph). diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java index 8aa08a3..817b772 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/App.java @@ -10,6 +10,7 @@ import thb.jeanluc.adventure.command.impl.GoCommand; import thb.jeanluc.adventure.command.impl.HelpCommand; import thb.jeanluc.adventure.command.impl.InventoryCommand; 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.ReadCommand; import thb.jeanluc.adventure.command.impl.TakeCommand; @@ -68,6 +69,7 @@ public final class App { registry.register(new UseCommand(), "use"); registry.register(new ReadCommand(), "read"); registry.register(new ExamineCommand(), "examine", "x", "inspect"); + registry.register(new MapCommand(), "map", "m"); registry.register(new TalkCommand(), "talk", "speak"); registry.register(new GiveCommand(), "give"); registry.register(new HelpCommand(registry), "help", "?"); diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MapCommand.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MapCommand.java new file mode 100644 index 0000000..d73be6a --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/command/impl/MapCommand.java @@ -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 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"; + } +} 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 fa5c3bf..24210e3 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/game/Game.java @@ -6,6 +6,8 @@ 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 thb.jeanluc.adventure.io.text.MapView; +import thb.jeanluc.adventure.map.MapLayout; import java.util.Optional; @@ -63,6 +65,11 @@ public class Game { ctx.getPlayer().getGold(), turn, false)); + MapView map = MapLayout.compute( + ctx.getWorld(), + ctx.getPlayer().getVisitedRoomIds(), + ctx.getPlayer().getCurrentRoom()); + ctx.getIo().setMap(map); } /** 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 d88cb7c..8be6545 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/ConsoleIO.java @@ -1,6 +1,8 @@ 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.MapView; import thb.jeanluc.adventure.io.text.RoomView; import thb.jeanluc.adventure.io.text.Span; import thb.jeanluc.adventure.io.text.Style; @@ -100,6 +102,11 @@ public class ConsoleIO implements GameIO { 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 xs, Style st) { StringBuilder sb = new StringBuilder(" ").append(paint(Style.DIM, label)).append(" "); for (int i = 0; i < xs.size(); i++) { 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 3af5b09..060d88d 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/GameIO.java @@ -1,6 +1,8 @@ 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.MapView; import thb.jeanluc.adventure.io.text.Renderings; import thb.jeanluc.adventure.io.text.RoomView; import thb.jeanluc.adventure.io.text.StyledText; @@ -32,4 +34,14 @@ public interface GameIO { default void setHud(Hud hud) { // 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)); + } } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MapPanel.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MapPanel.java new file mode 100644 index 0000000..0b90088 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/MapPanel.java @@ -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(); + } +} 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 1ef12f5..9d4a77e 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/SwingIO.java @@ -1,6 +1,7 @@ package thb.jeanluc.adventure.io; 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.Span; import thb.jeanluc.adventure.io.text.Style; @@ -12,7 +13,6 @@ 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; @@ -68,7 +68,7 @@ public class SwingIO implements GameIO { private final StyledDocument doc; private final JTextField input; private final JLabel hud; - private final JTextArea side; + private final MapPanel map; private final JScrollPane sideScroll; private final Font baseFont; @@ -95,12 +95,9 @@ public class SwingIO implements GameIO { hud.setForeground(new Color(0x8b, 0x94, 0xa3)); 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.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - sideScroll = new JScrollPane(side); + map = new MapPanel(baseFont); + sideScroll = new JScrollPane(map); + sideScroll.getViewport().setBackground(new Color(0x0e, 0x12, 0x18)); input = new JTextField(); input.addActionListener(e -> { @@ -185,7 +182,6 @@ public class SwingIO implements GameIO { output.setFont(baseFont.deriveFont(main)); input.setFont(baseFont.deriveFont(main)); hud.setFont(baseFont.deriveFont(small)); - side.setFont(baseFont.deriveFont(small)); frame.revalidate(); frame.repaint(); } @@ -285,9 +281,7 @@ public class SwingIO implements GameIO { 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)); + // Exits are shown by the map panel, not as text. } @Override @@ -297,6 +291,16 @@ public class SwingIO implements GameIO { 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 spans, List xs, Style st) { for (int i = 0; i < xs.size(); i++) { if (i > 0) { diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/AsciiMap.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/AsciiMap.java new file mode 100644 index 0000000..a6bd119 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/AsciiMap.java @@ -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 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 hEdge = new HashSet<>(); + Set 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); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/CellState.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/CellState.java new file mode 100644 index 0000000..b486940 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/CellState.java @@ -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 } diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Connection.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Connection.java new file mode 100644 index 0000000..decf156 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/Connection.java @@ -0,0 +1,4 @@ +package thb.jeanluc.adventure.io.text; + +/** A corridor between two placed rooms. */ +public record Connection(RoomCell from, RoomCell to) {} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/MapView.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/MapView.java new file mode 100644 index 0000000..82c249e --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/MapView.java @@ -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 cells, List connections) { + public MapView { + cells = List.copyOf(cells); + connections = List.copyOf(connections); + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/RoomCell.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/RoomCell.java new file mode 100644 index 0000000..06ec898 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/io/text/RoomCell.java @@ -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) {} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/map/MapLayout.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/map/MapLayout.java new file mode 100644 index 0000000..454b6b0 --- /dev/null +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/map/MapLayout.java @@ -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 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 coords = bfs(anchor); + + // Determine which rooms are placed: visited (with coords) + their unentered neighbours. + Map 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 cells = new LinkedHashMap<>(); + for (Map.Entry 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 connections = new ArrayList<>(); + Set 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 bfs(Room anchor) { + Map coords = new HashMap<>(); + Deque 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 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}; + }; + } +} diff --git a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Player.java b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Player.java index 9bb0336..923a2f6 100644 --- a/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Player.java +++ b/Semesterprojekt/src/main/java/thb/jeanluc/adventure/model/Player.java @@ -5,7 +5,9 @@ import lombok.Setter; import thb.jeanluc.adventure.model.item.Item; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Optional; +import java.util.Set; /** * The player avatar. Tracks the current room, an ordered inventory, and a @@ -15,9 +17,11 @@ import java.util.Optional; public class Player { /** Room the player is currently standing in. */ - @Setter private Room currentRoom; + /** Ids of rooms the player has entered, in first-visit order (for the map). */ + private final Set visitedRoomIds = new LinkedHashSet<>(); + /** * Inventory keyed by item id. {@link LinkedHashMap} keeps insertion * order for stable {@code inventory} listings while still providing @@ -44,6 +48,17 @@ public class Player { } this.currentRoom = startRoom; 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()); } /** diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/MapCommandTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/MapCommandTest.java new file mode 100644 index 0000000..1382076 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/command/impl/MapCommandTest.java @@ -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 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 + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java index 2cd53a5..4b85ced 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/game/GameTest.java @@ -10,6 +10,10 @@ 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.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -24,7 +28,10 @@ class GameTest { TestIO io = new TestIO() .enqueue("go north") .enqueue("quit"); - GameContext ctx = new GameContext(null, player, io); + Map 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(); registry.register(new GoCommand(), "go"); @@ -42,9 +49,10 @@ class GameTest { @Test 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"); - 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(); QuitCommand quit = new QuitCommand(); diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/AsciiMapTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/AsciiMapTest.java new file mode 100644 index 0000000..eada294 --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/AsciiMapTest.java @@ -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("┌"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/MapViewTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/MapViewTest.java new file mode 100644 index 0000000..d8a6ade --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/io/text/MapViewTest.java @@ -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"); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/map/MapLayoutTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/map/MapLayoutTest.java new file mode 100644 index 0000000..a13489f --- /dev/null +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/map/MapLayoutTest.java @@ -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 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 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(); + } +} diff --git a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/PlayerTest.java b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/PlayerTest.java index 0b4fc6c..8a26e90 100644 --- a/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/PlayerTest.java +++ b/Semesterprojekt/src/test/java/thb/jeanluc/adventure/model/PlayerTest.java @@ -54,4 +54,20 @@ class PlayerTest { 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"); + } }