From c03123d5ae5e1002d0058da3f06ada9c8f5978f6 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 31 May 2026 20:59:52 +0200 Subject: [PATCH] feat(map): MapLayout BFS grid layout with fog of war Co-Authored-By: Claude Opus 4.8 (1M context) --- .../thb/jeanluc/adventure/map/MapLayout.java | 137 ++++++++++++++++++ .../jeanluc/adventure/map/MapLayoutTest.java | 86 +++++++++++ 2 files changed, 223 insertions(+) create mode 100644 Semesterprojekt/src/main/java/thb/jeanluc/adventure/map/MapLayout.java create mode 100644 Semesterprojekt/src/test/java/thb/jeanluc/adventure/map/MapLayoutTest.java 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/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(); + } +}