From 26784479b7c95ae59d1b4e694df1441c4129c03a Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 00:57:16 +0900 Subject: [PATCH] feat: BSP dungeon generation with 2D ASCII tile map Replace list-based room display with proper 2D tile map using Binary Space Partitioning. Rooms are carved into a 60x20 grid, connected by L-shaped corridors, and rendered with ANSI-colored ASCII art including fog of war visibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- dungeon/generator.go | 243 +++++++++++++++++++++++++++++++++++--- dungeon/generator_test.go | 16 +++ dungeon/render.go | 225 +++++++++++++++++++++++++++++++++++ dungeon/room.go | 22 +++- ui/game_view.go | 38 +----- 5 files changed, 490 insertions(+), 54 deletions(-) create mode 100644 dungeon/render.go diff --git a/dungeon/generator.go b/dungeon/generator.go index 591a6fb..f42fda7 100644 --- a/dungeon/generator.go +++ b/dungeon/generator.go @@ -2,41 +2,252 @@ package dungeon import "math/rand" -type Floor struct { - Number int - Rooms []*Room - CurrentRoom int +const ( + MapWidth = 60 + MapHeight = 20 + MinLeafW = 12 + MinLeafH = 8 + MinRoomW = 6 + MinRoomH = 4 + RoomPad = 1 +) + +type bspNode struct { + x, y, w, h int + left, right *bspNode + room *Room + roomIdx int } func GenerateFloor(floorNum int) *Floor { - numRooms := 5 + rand.Intn(4) - rooms := make([]*Room, numRooms) - for i := 0; i < numRooms; i++ { + // Create tile map filled with walls + tiles := make([][]Tile, MapHeight) + for y := 0; y < MapHeight; y++ { + tiles[y] = make([]Tile, MapWidth) + // TileWall is 0, so already initialized + } + + // BSP tree + root := &bspNode{x: 0, y: 0, w: MapWidth, h: MapHeight} + splitBSP(root, 0) + + // Collect leaf nodes + var leaves []*bspNode + collectLeaves(root, &leaves) + + // Shuffle leaves so room assignment is varied + rand.Shuffle(len(leaves), func(i, j int) { + leaves[i], leaves[j] = leaves[j], leaves[i] + }) + + // We want 5-8 rooms. If we have more leaves, merge some; if fewer, accept it. + // Ensure at least 5 leaves by re-generating if needed (BSP should produce enough). + // Cap at 8 rooms. + targetRooms := 5 + rand.Intn(4) // 5..8 + if len(leaves) > targetRooms { + leaves = leaves[:targetRooms] + } + // If we somehow have fewer than 5, that's fine — the BSP with 60x20 and min 12x8 gives ~5-8 naturally. + + // Place rooms inside each leaf + rooms := make([]*Room, len(leaves)) + for i, leaf := range leaves { + // Room with padding inside the leaf + maxW := leaf.w - 2*RoomPad + maxH := leaf.h - 2*RoomPad + if maxW < MinRoomW { + maxW = MinRoomW + } + if maxH < MinRoomH { + maxH = MinRoomH + } + + rw := MinRoomW + if maxW > MinRoomW { + rw = MinRoomW + rand.Intn(maxW-MinRoomW+1) + } + rh := MinRoomH + if maxH > MinRoomH { + rh = MinRoomH + rand.Intn(maxH-MinRoomH+1) + } + + // Position room within the leaf + rx := leaf.x + RoomPad + if leaf.w-2*RoomPad > rw { + rx += rand.Intn(leaf.w - 2*RoomPad - rw + 1) + } + ry := leaf.y + RoomPad + if leaf.h-2*RoomPad > rh { + ry += rand.Intn(leaf.h - 2*RoomPad - rh + 1) + } + + // Clamp to map bounds + if rx+rw > MapWidth-1 { + rw = MapWidth - 1 - rx + } + if ry+rh > MapHeight-1 { + rh = MapHeight - 1 - ry + } + if rx < 1 { + rx = 1 + } + if ry < 1 { + ry = 1 + } + rt := RandomRoomType() rooms[i] = &Room{ Type: rt, - X: (i % 3) * 20, - Y: (i / 3) * 10, - Width: 12 + rand.Intn(6), - Height: 6 + rand.Intn(4), + X: rx, + Y: ry, + W: rw, + H: rh, Neighbors: []int{}, } + leaf.room = rooms[i] + leaf.roomIdx = i } - rooms[numRooms-1].Type = RoomBoss - for i := 0; i < numRooms-1; i++ { + + // Last room is boss + rooms[len(rooms)-1].Type = RoomBoss + + // Carve rooms into tile map + for _, room := range rooms { + for dy := 0; dy < room.H; dy++ { + for dx := 0; dx < room.W; dx++ { + ty := room.Y + dy + tx := room.X + dx + if ty >= 0 && ty < MapHeight && tx >= 0 && tx < MapWidth { + tiles[ty][tx] = TileFloor + } + } + } + } + + // Connect rooms: linear chain for guaranteed connectivity + for i := 0; i < len(rooms)-1; i++ { rooms[i].Neighbors = append(rooms[i].Neighbors, i+1) rooms[i+1].Neighbors = append(rooms[i+1].Neighbors, i) + carveCorridor(tiles, rooms[i], rooms[i+1]) } + + // Add 1-2 extra connections extras := 1 + rand.Intn(2) for e := 0; e < extras; e++ { - a := rand.Intn(numRooms) - b := rand.Intn(numRooms) + a := rand.Intn(len(rooms)) + b := rand.Intn(len(rooms)) if a != b && !hasNeighbor(rooms[a], b) { rooms[a].Neighbors = append(rooms[a].Neighbors, b) rooms[b].Neighbors = append(rooms[b].Neighbors, a) + carveCorridor(tiles, rooms[a], rooms[b]) + } + } + + return &Floor{ + Number: floorNum, + Rooms: rooms, + CurrentRoom: 0, + Tiles: tiles, + Width: MapWidth, + Height: MapHeight, + } +} + +func splitBSP(node *bspNode, depth int) { + // Stop conditions + if depth > 4 { + return + } + if node.w < MinLeafW*2 && node.h < MinLeafH*2 { + return + } + + // Random chance to stop splitting (more likely at deeper levels) + if depth > 2 && rand.Float64() < 0.3 { + return + } + + // Decide split direction + horizontal := rand.Float64() < 0.5 + if node.w < MinLeafW*2 { + horizontal = true + } + if node.h < MinLeafH*2 { + horizontal = false + } + + if horizontal { + if node.h < MinLeafH*2 { + return + } + split := MinLeafH + rand.Intn(node.h-MinLeafH*2+1) + node.left = &bspNode{x: node.x, y: node.y, w: node.w, h: split} + node.right = &bspNode{x: node.x, y: node.y + split, w: node.w, h: node.h - split} + } else { + if node.w < MinLeafW*2 { + return + } + split := MinLeafW + rand.Intn(node.w-MinLeafW*2+1) + node.left = &bspNode{x: node.x, y: node.y, w: split, h: node.h} + node.right = &bspNode{x: node.x + split, y: node.y, w: node.w - split, h: node.h} + } + + splitBSP(node.left, depth+1) + splitBSP(node.right, depth+1) +} + +func collectLeaves(node *bspNode, leaves *[]*bspNode) { + if node == nil { + return + } + if node.left == nil && node.right == nil { + *leaves = append(*leaves, node) + return + } + collectLeaves(node.left, leaves) + collectLeaves(node.right, leaves) +} + +func carveCorridor(tiles [][]Tile, a, b *Room) { + // L-shaped corridor from center of a to center of b + ax := a.X + a.W/2 + ay := a.Y + a.H/2 + bx := b.X + b.W/2 + by := b.Y + b.H/2 + + // Go horizontal first, then vertical + x := ax + for x != bx { + if y := ay; y >= 0 && y < MapHeight && x >= 0 && x < MapWidth { + if tiles[y][x] == TileWall { + tiles[y][x] = TileCorridor + } + } + if x < bx { + x++ + } else { + x-- + } + } + y := ay + for y != by { + if x >= 0 && x < MapWidth && y >= 0 && y < MapHeight { + if tiles[y][x] == TileWall { + tiles[y][x] = TileCorridor + } + } + if y < by { + y++ + } else { + y-- + } + } + // Place final tile + if bx >= 0 && bx < MapWidth && by >= 0 && by < MapHeight { + if tiles[by][bx] == TileWall { + tiles[by][bx] = TileCorridor } } - return &Floor{Number: floorNum, Rooms: rooms, CurrentRoom: 0} } func hasNeighbor(r *Room, idx int) bool { diff --git a/dungeon/generator_test.go b/dungeon/generator_test.go index f1ba9e6..377643c 100644 --- a/dungeon/generator_test.go +++ b/dungeon/generator_test.go @@ -40,3 +40,19 @@ func TestRoomTypeProbability(t *testing.T) { t.Errorf("Combat room probability: got %.1f%%, want ~45%%", combatPct) } } + +func TestFloorHasTileMap(t *testing.T) { + floor := GenerateFloor(1) + if floor.Tiles == nil { + t.Fatal("Floor should have tile map") + } + if floor.Width != 60 || floor.Height != 20 { + t.Errorf("Map size: got %dx%d, want 60x20", floor.Width, floor.Height) + } + // Current room should have floor tiles + room := floor.Rooms[0] + centerTile := floor.Tiles[room.Y+room.H/2][room.X+room.W/2] + if centerTile != TileFloor { + t.Errorf("Room center should be floor tile, got %d", centerTile) + } +} diff --git a/dungeon/render.go b/dungeon/render.go new file mode 100644 index 0000000..f591e7d --- /dev/null +++ b/dungeon/render.go @@ -0,0 +1,225 @@ +package dungeon + +import "fmt" + +// ANSI color codes +const ( + ansiReset = "\033[0m" + ansiBright = "\033[1m" + ansiDim = "\033[2m" + ansiFgWhite = "\033[97m" + ansiFgGray = "\033[90m" + ansiFgGreen = "\033[92m" + ansiFgRed = "\033[91m" + ansiFgBrRed = "\033[1;91m" + ansiFgYellow = "\033[93m" + ansiFgCyan = "\033[96m" + ansiFgMagenta = "\033[95m" +) + +// roomOwnership maps each tile coordinate to the room index that contains it (-1 = none). +func roomOwnership(floor *Floor) [][]int { + owner := make([][]int, floor.Height) + for y := 0; y < floor.Height; y++ { + owner[y] = make([]int, floor.Width) + for x := 0; x < floor.Width; x++ { + owner[y][x] = -1 + } + } + for i, room := range floor.Rooms { + for dy := 0; dy < room.H; dy++ { + for dx := 0; dx < room.W; dx++ { + ty := room.Y + dy + tx := room.X + dx + if ty >= 0 && ty < floor.Height && tx >= 0 && tx < floor.Width { + owner[ty][tx] = i + } + } + } + } + return owner +} + +// corridorVisibility determines if a corridor tile should be visible. +// A corridor is visible if it's adjacent to a visible or visited room. +func corridorVisible(floor *Floor, owner [][]int, x, y int) Visibility { + best := Hidden + // Check neighboring tiles for room ownership + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + ny, nx := y+dy, x+dx + if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width { + ri := owner[ny][nx] + if ri >= 0 { + v := GetRoomVisibility(floor, ri) + if v > best { + best = v + } + } + } + } + } + // Also check along the corridor path: if this corridor connects two rooms, + // it should be visible if either room is visible/visited. + // The adjacency check above handles most cases. + return best +} + +// wallVisibility determines if a wall tile should be shown based on adjacent rooms. +func wallVisible(floor *Floor, owner [][]int, x, y int) Visibility { + best := Hidden + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + ny, nx := y+dy, x+dx + if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width { + if floor.Tiles[ny][nx] == TileFloor { + ri := owner[ny][nx] + if ri >= 0 { + v := GetRoomVisibility(floor, ri) + if v > best { + best = v + } + } + } + if floor.Tiles[ny][nx] == TileCorridor { + cv := corridorVisible(floor, owner, nx, ny) + if cv > best { + best = cv + } + } + } + } + } + return best +} + +// RenderFloor renders the tile map as a colored ASCII string. +func RenderFloor(floor *Floor, currentRoom int, showFog bool) string { + if floor == nil || floor.Tiles == nil { + return "" + } + + owner := roomOwnership(floor) + + // Find room centers for content markers + type marker struct { + symbol string + color string + } + markers := make(map[[2]int]marker) + + for i, room := range floor.Rooms { + cx := room.X + room.W/2 + cy := room.Y + room.H/2 + vis := GetRoomVisibility(floor, i) + if !showFog || vis != Hidden { + sym, col := roomMarker(room, i == currentRoom) + markers[[2]int{cy, cx}] = marker{sym, col} + } + } + + // Player position at center of current room + var playerPos [2]int + if currentRoom >= 0 && currentRoom < len(floor.Rooms) { + r := floor.Rooms[currentRoom] + playerPos = [2]int{r.Y + r.H/2, r.X + r.W/2} + } + + buf := make([]byte, 0, floor.Width*floor.Height*4) + + for y := 0; y < floor.Height; y++ { + for x := 0; x < floor.Width; x++ { + tile := floor.Tiles[y][x] + + // Determine visibility of this tile + var vis Visibility + if showFog { + switch tile { + case TileFloor: + ri := owner[y][x] + if ri >= 0 { + vis = GetRoomVisibility(floor, ri) + } else { + vis = Hidden + } + case TileCorridor: + vis = corridorVisible(floor, owner, x, y) + case TileWall: + vis = wallVisible(floor, owner, x, y) + default: + vis = Hidden + } + } else { + vis = Visible + } + + if vis == Hidden { + buf = append(buf, ' ') + continue + } + + // Check for player marker + if y == playerPos[0] && x == playerPos[1] { + buf = append(buf, []byte(fmt.Sprintf("%s%s@%s", ansiBright, ansiFgGreen, ansiReset))...) + continue + } + + // Check for room content marker + if m, ok := markers[[2]int{y, x}]; ok { + if vis == Visible { + buf = append(buf, []byte(fmt.Sprintf("%s%s%s", m.color, m.symbol, ansiReset))...) + } else { + buf = append(buf, []byte(fmt.Sprintf("%s%s%s", ansiFgGray, m.symbol, ansiReset))...) + } + continue + } + + // Render tile + var ch byte + switch tile { + case TileWall: + ch = '#' + case TileFloor: + ch = '.' + case TileCorridor: + ch = '+' + case TileDoor: + ch = '/' + default: + ch = ' ' + } + + if vis == Visible { + buf = append(buf, []byte(fmt.Sprintf("%s%s%c%s", ansiBright, ansiFgWhite, ch, ansiReset))...) + } else { + // Visited but not current — dim + buf = append(buf, []byte(fmt.Sprintf("%s%c%s", ansiFgGray, ch, ansiReset))...) + } + } + buf = append(buf, '\n') + } + + return string(buf) +} + +func roomMarker(room *Room, isCurrent bool) (string, string) { + if room.Cleared { + return ".", ansiFgGray + } + switch room.Type { + case RoomCombat: + return "D", ansiFgRed + case RoomTreasure: + return "$", ansiFgYellow + case RoomShop: + return "S", ansiFgCyan + case RoomEvent: + return "?", ansiFgMagenta + case RoomBoss: + return "B", ansiFgBrRed + case RoomEmpty: + return ".", ansiFgGray + default: + return " ", ansiReset + } +} diff --git a/dungeon/room.go b/dungeon/room.go index aea616b..b5a496d 100644 --- a/dungeon/room.go +++ b/dungeon/room.go @@ -17,15 +17,33 @@ func (r RoomType) String() string { return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r] } +type Tile int + +const ( + TileWall Tile = iota + TileFloor + TileCorridor + TileDoor +) + type Room struct { Type RoomType - X, Y int - Width, Height int + X, Y int // top-left in tile space + W, H int // dimensions in tiles Visited bool Cleared bool Neighbors []int } +type Floor struct { + Number int + Rooms []*Room + CurrentRoom int + Tiles [][]Tile + Width int + Height int +} + func RandomRoomType() RoomType { r := rand.Float64() * 100 switch { diff --git a/ui/game_view.go b/ui/game_view.go index d0b7538..1324484 100644 --- a/ui/game_view.go +++ b/ui/game_view.go @@ -27,43 +27,9 @@ func renderMap(floor *dungeon.Floor) string { if floor == nil { return "" } - - var sb strings.Builder headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) - sb.WriteString(headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number))) - sb.WriteString("\n\n") - - roomStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()) - dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) - hiddenStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("236")) - - for i, room := range floor.Rooms { - vis := dungeon.GetRoomVisibility(floor, i) - symbol := roomTypeSymbol(room.Type) - label := fmt.Sprintf("[%d] %s %s", i, symbol, room.Type.String()) - - if i == floor.CurrentRoom { - label = ">> " + label + " <<" - } - - switch vis { - case dungeon.Visible: - sb.WriteString(roomStyle.Render(label)) - case dungeon.Visited: - sb.WriteString(dimStyle.Render(label)) - case dungeon.Hidden: - sb.WriteString(hiddenStyle.Render("[?] ???")) - } - - for _, n := range room.Neighbors { - if n > i { - sb.WriteString(" --- ") - } - } - sb.WriteString("\n") - } - - return sb.String() + header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number)) + return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true) } func renderHUD(state game.GameState, targetCursor int) string {