feat(map): MapLayout BFS grid layout with fog of war
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user