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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
225
dungeon/render.go
Normal file
225
dungeon/render.go
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user