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) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 00:57:16 +09:00
parent 92741d415d
commit 26784479b7
5 changed files with 490 additions and 54 deletions

View File

@@ -2,41 +2,252 @@ package dungeon
import "math/rand" import "math/rand"
type Floor struct { const (
Number int MapWidth = 60
Rooms []*Room MapHeight = 20
CurrentRoom int 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 { func GenerateFloor(floorNum int) *Floor {
numRooms := 5 + rand.Intn(4) // Create tile map filled with walls
rooms := make([]*Room, numRooms) tiles := make([][]Tile, MapHeight)
for i := 0; i < numRooms; i++ { 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() rt := RandomRoomType()
rooms[i] = &Room{ rooms[i] = &Room{
Type: rt, Type: rt,
X: (i % 3) * 20, X: rx,
Y: (i / 3) * 10, Y: ry,
Width: 12 + rand.Intn(6), W: rw,
Height: 6 + rand.Intn(4), H: rh,
Neighbors: []int{}, 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].Neighbors = append(rooms[i].Neighbors, i+1)
rooms[i+1].Neighbors = append(rooms[i+1].Neighbors, i) 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) extras := 1 + rand.Intn(2)
for e := 0; e < extras; e++ { for e := 0; e < extras; e++ {
a := rand.Intn(numRooms) a := rand.Intn(len(rooms))
b := rand.Intn(numRooms) b := rand.Intn(len(rooms))
if a != b && !hasNeighbor(rooms[a], b) { if a != b && !hasNeighbor(rooms[a], b) {
rooms[a].Neighbors = append(rooms[a].Neighbors, b) rooms[a].Neighbors = append(rooms[a].Neighbors, b)
rooms[b].Neighbors = append(rooms[b].Neighbors, a) 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 { func hasNeighbor(r *Room, idx int) bool {

View File

@@ -40,3 +40,19 @@ func TestRoomTypeProbability(t *testing.T) {
t.Errorf("Combat room probability: got %.1f%%, want ~45%%", combatPct) 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)
}
}

225
dungeon/render.go Normal file
View File

@@ -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
}
}

View File

@@ -17,15 +17,33 @@ func (r RoomType) String() string {
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r] return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss"}[r]
} }
type Tile int
const (
TileWall Tile = iota
TileFloor
TileCorridor
TileDoor
)
type Room struct { type Room struct {
Type RoomType Type RoomType
X, Y int X, Y int // top-left in tile space
Width, Height int W, H int // dimensions in tiles
Visited bool Visited bool
Cleared bool Cleared bool
Neighbors []int Neighbors []int
} }
type Floor struct {
Number int
Rooms []*Room
CurrentRoom int
Tiles [][]Tile
Width int
Height int
}
func RandomRoomType() RoomType { func RandomRoomType() RoomType {
r := rand.Float64() * 100 r := rand.Float64() * 100
switch { switch {

View File

@@ -27,43 +27,9 @@ func renderMap(floor *dungeon.Floor) string {
if floor == nil { if floor == nil {
return "" return ""
} }
var sb strings.Builder
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true)
sb.WriteString(headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number))) header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d ──", floor.Number))
sb.WriteString("\n\n") return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
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()
} }
func renderHUD(state game.GameState, targetCursor int) string { func renderHUD(state game.GameState, targetCursor int) string {