リモート開発メインのソフトウェア開発企業のエンジニアブログです

Go言語で作るローグライクダンジョン生成プログラム

最近2Dゲーム制作エンジンである Ebitengine を使用して、ローグライクゲームの作成を行っています。

ローグライクゲームの代名詞であるランダムダンジョン生成。今回は、Ebitengine で使用されている Go 言語でRogueスタイルのダンジョンを生成するプログラムを詳しく解説します。

このプログラムは Ebitengine に依存しない、Go 製のコマンドラインアプリです。ダンジョン生成のコードは、そのまま Ebitengine のプロジェクトにも流用できます。

初心者にも分かりやすく、プログラム全体の仕組みから各関数の役割まで、順を追って説明していきます。

プログラムの全体は以下のリポジトリで公開しています。

https://github.com/Kenshu-Miura/RandomDungeon

以下のコマンドでコードを実行することで、ランダムなダンジョンが生成されます。

go run dungeon.go

プログラム概要

このプログラムは、3×3のグリッドシステムを基盤として、次の手順でダンジョンを生成します:

  1. 設計図作成: マップを9つの区画(セクション)に分割
  2. 部屋配置: 各セクション内にランダムに部屋を生成
  3. 通路接続: 部屋同士を通路で繋いで歩行可能にする
  4. マップ描画: 文字によるASCIIアートでダンジョンを表示

生成されるマップは、壁(-, |)、床(.)、通路(#)、扉(+)で構成される、古典的なローグライクスタイルのダンジョンです。

設計図作成

セクションシステム

ダンジョン全体は3×3の9つのセクションに分割されます:

type SectionDefinition struct {
    id       int    // セクションID
    area     Rect   // セクションの範囲
    weight   int    // 部屋生成の重み(確率)
    required bool   // 必須部屋かどうか
}

type Section struct {
    def      SectionDefinition // セクション定義
    room     *Room            // 生成された部屋(nil可能)
    relay    Point            // リレー点(部屋がない場合の接続点)
    hasRelay bool             // リレー点を持つかどうか
}

weightが高いセクションほど部屋が生成されやすく、requiredがtrueのセクションには必ず部屋が作られます。部屋が生成できなかった場合は、relay(リレー点)が接続のために使用されます。

初期化処理(NewDungeon関数)

func NewDungeon() *Dungeon {
    // 乱数シードを設定し空のマップを初期化
    seed := time.Now().UnixNano()
    if s := os.Getenv("DUNGEON_SEED"); s != "" {
        if parsed, err := strconv.ParseInt(s, 10, 64); err == nil {
            seed = parsed
        }
    }
    rand.Seed(seed)

    d := &Dungeon{}

    // マップを空白で初期化
    for y := 0; y < MAP_HEIGHT; y++ {
        for x := 0; x < MAP_WIDTH; x++ {
            d.level[y][x] = SPACE
        }
    }

    // 設計図に従ってセクションを登録
    for _, def := range createBlueprint() {
        section := &Section{def: def}
        d.sections = append(d.sections, section)
    }

    return d
}

初期化では以下の処理を行います:

  1. 乱数シード設定: 現在時刻をシードとして使用(環境変数DUNGEON_SEEDで固定値も指定可能)
  2. マップ初期化: 全体を空白(SPACE)で埋める
  3. セクション作成: 3×3グリッドの各セクションに設定を適用

設計図の作成(createBlueprint関数)

func createBlueprint() []SectionDefinition {
    weights := [GRID_HEIGHT][GRID_WIDTH]int{
        {3, 2, 3}, // 上段:角が重要度高、中央やや低
        {2, 5, 2}, // 中段:中央が最重要
        {3, 2, 3}, // 下段:上段と同じ
    }

    required := map[[2]int]bool{
        {1, 1}: true, // 中央は必須
        {0, 2}: true, // 右上も必須
    }

    var defs []SectionDefinition
    id := 0
    for gy := 0; gy < GRID_HEIGHT; gy++ {
        for gx := 0; gx < GRID_WIDTH; gx++ {
            // グリッド座標から実際の座標範囲を計算
            rect := Rect{
                x:      gx * CELL_WIDTH,  // 25マス間隔
                y:      gy * CELL_HEIGHT, // 7マス間隔
                width:  CELL_WIDTH,
                height: CELL_HEIGHT,
            }

            defs = append(defs, SectionDefinition{
                id:       id,
                area:     rect,
                weight:   weights[gy][gx],
                required: required[[2]int{gx, gy}],
            })
            id++
        }
    }
    return defs
}

この関数では、3×3の各セクションに以下を設定します:

  • 重み配分: 中央セクション(重み5)が最も部屋が作られやすい
  • 必須部屋: 中央と右上の2箇所は必ず部屋を配置
  • 座標計算: グリッド位置を実際のマップ座標に変換
Moba Pro

部屋配置

基本データ構造

座標とサイズを扱う構造体

プログラムの根幹となる基本的な構造体を見ていきましょう。

// 座標を表す構造体
type Point struct {
    x int // X座標
    y int // Y座標
}

// 矩形を表す構造体
type Rect struct {
    x      int // 左上角のX座標
    y      int // 左上角のY座標
    width  int // 幅
    height int // 高さ
}

Pointは単純にX、Y座標を持つ構造体です。Rectは矩形(長方形)を表現し、部屋やセクションの範囲を定義するのに使います。

Rectには便利なメソッドがいくつか用意されています:

func (r Rect) center() Point {
    // 長方形の中央の座標を計算して返す
    return Point{r.x + r.width/2, r.y + r.height/2}
}

func (r Rect) right() int {
    // 右端の座標を算出する(左端 + 幅)
    return r.x + r.width
}

func (r Rect) bottom() int {
    // 下端の座標を算出する(上端 + 高さ)
    return r.y + r.height
}

これらのメソッドにより、矩形の中心点や端の座標を簡単に取得できます。

部屋の情報を管理する構造体

type Room struct {
    area        Rect                 // 部屋の範囲
    doorsByDir  map[string][]Point   // 方向別の扉候補一覧
}

Room構造体は、部屋の位置とサイズ(area)に加えて、扉の候補位置を方向別(”N”、”S”、”E”、”W”)に管理しています。これにより、隣接する部屋との接続時に適切な位置に扉を設置できます。

部屋生成(generateRooms関数)

func (d *Dungeon) generateRooms() {
    roomCount := 0

    // 必須セクションに部屋を優先配置
    for _, section := range d.sections {
        if section.def.required {
            if d.createRoomInSection(section) {
                roomCount++
            }
        }
    }

    // 未配置セクションから候補リストを作成
    var candidates []*Section
    for _, section := range d.sections {
        if section.room == nil {
            candidates = append(candidates, section)
        }
    }

    // 重み付きランダムで追加部屋を生成(最大6部屋)
    maxRooms := 6
    for roomCount < maxRooms && len(candidates) > 0 {
        idx := pickWeightedIndex(candidates) // 重み付き選択
        section := candidates[idx]

        if d.createRoomInSection(section) {
            roomCount++
        }

        // 選択されたセクションを候補から除去
        candidates = append(candidates[:idx], candidates[idx+1:]...)
    }

    // 部屋が作れなかったセクションにはリレー点を設定
    for _, section := range d.sections {
        if section.room == nil {
            section.relay = chooseRelayPoint(section.def.area)
            section.hasRelay = true
        }
    }
}

部屋生成は段階的に進みます:

  1. 必須部屋: 最初に確実に配置
  2. 追加部屋: 重み付き確率で最大6部屋まで生成
  3. リレー点: 部屋が作れなかったセクションの接続点を設定

個別部屋の作成(createRoomInSection関数)

func (d *Dungeon) createRoomInSection(section *Section) bool {
    area := section.def.area
    maxWidth := area.width - 2   // 外壁分を除く
    maxHeight := area.height - 2

    if maxWidth < 4 || maxHeight < 4 {
        return false // 最小サイズに満たない
    }

    // ランダムなサイズを決定(最小4マス)
    roomWidth := randInRange(4, maxWidth)
    roomHeight := randInRange(4, maxHeight)

    // セクション内での配置位置を計算
    xMin, xMax := area.x+1, area.right()-roomWidth-1
    yMin, yMax := area.y+1, area.bottom()-roomHeight-1

    if xMax < xMin || yMax < yMin {
        return false // 配置不可能
    }

    roomX := randInRange(xMin, xMax)
    roomY := randInRange(yMin, yMax)

    // 部屋情報を作成
    roomArea := Rect{x: roomX, y: roomY, width: roomWidth, height: roomHeight}
    room := &Room{
        area:       roomArea,
        doorsByDir: generateDoorCandidates(roomArea),
    }

    section.room = room
    section.hasRelay = false

    return true
}

各部屋は以下の制約で作成されます:

  • 最小サイズ: 4×4マス
  • 配置位置: セクション内で1マスの余裕を持つ
  • 扉候補: 生成時に各方向の扉候補も計算

通路接続

隣接関係の構築(buildAdjacency関数)

func (d *Dungeon) buildAdjacency() map[int][]int {
    adj := make(map[int][]int)

    for i := 0; i < len(d.sections); i++ {
        for j := i + 1; j < len(d.sections); j++ {
            if sectionsAdjacent(d.sections[i].def.area, d.sections[j].def.area) {
                adj[i] = append(adj[i], j)
                adj[j] = append(adj[j], i)
            }
        }
    }
    return adj
}

func sectionsAdjacent(a, b Rect) bool {
    // 左右で接触
    if a.right() == b.x || b.right() == a.x {
        _, _, ok := overlapRange(a.y, a.bottom(), b.y, b.bottom())
        return ok
    }
    // 上下で接触
    if a.bottom() == b.y || b.bottom() == a.y {
        _, _, ok := overlapRange(a.x, a.right(), b.x, b.right())
        return ok
    }
    return false
}

この関数では、各セクションが上下左右で隣接しているかを調べ、隣接リストを作成します。隣接判定は矩形同士の境界が一致し、なおかつY軸(または X軸)で重複があることを確認します。

接続関係の決定(generateConnections関数)

func (d *Dungeon) generateConnections(adjacency map[int][]int) [][2]int {
    sectionCount := len(d.sections)
    if sectionCount == 0 {
        return nil
    }

    connections := make([][2]int, 0)
    visited := make(map[int]bool)
    start := rand.Intn(sectionCount) // ランダムな開始点
    visited[start] = true
    pending := []int{start}

    // 全セクションが接続されるまで繰り返し
    for len(visited) < sectionCount {
        var current int
        if len(pending) > 0 {
            current = pending[rand.Intn(len(pending))]
        } else {
            // フォールバック処理
            for idx := range visited {
                current = idx
                break
            }
        }

        // 未訪問の隣接セクションを探す
        neighbors := adjacency[current]
        var nextCandidates []int
        for _, nb := range neighbors {
            if !visited[nb] {
                nextCandidates = append(nextCandidates, nb)
            }
        }

        if len(nextCandidates) > 0 {
            // 未訪問セクションと接続
            next := nextCandidates[rand.Intn(len(nextCandidates))]
            connections = append(connections, [2]int{current, next})
            visited[next] = true
            pending = append(pending, next)
        } else {
            // 接続先がない場合は保留リストから削除
            for i, v := range pending {
                if v == current {
                    pending = append(pending[:i], pending[i+1:]...)
                    break
                }
            }
        }
    }

    // 追加のランダム接続でループを作成
    extraEdges := rand.Intn(3)
    for e := 0; e < extraEdges; e++ {
        a := rand.Intn(sectionCount)
        neighbors := adjacency[a]
        if len(neighbors) > 0 {
            b := neighbors[rand.Intn(len(neighbors))]
            if !connectionExists(connections, a, b) {
                connections = append(connections, [2]int{a, b})
            }
        }
    }

    return connections
}

この関数は最小スパニングツリーの考え方で、全セクションが確実に繋がるように接続を作ります:

  1. ランダムな開始点から開始
  2. 未接続の隣接セクションを選んで接続
  3. 全セクションが接続されるまで継続
  4. 追加でランダムな接続を作成してバリエーション増加

実際の通路掘削(connectPair関数)

func (d *Dungeon) connectPair(aIdx, bIdx int) {
    sectionA := d.sections[aIdx]
    sectionB := d.sections[bIdx]

    // 共有境界情報を取得
    boundary, hasBoundary := sharedBoundaryInfo(sectionA.def.area, sectionB.def.area)

    // 各セクションのアクセス点を決定
    startDoor, hasStartDoor, startPos := d.accessPoint(sectionA, sectionB, boundary, hasBoundary)
    endDoor, hasEndDoor, endPos := d.accessPoint(sectionB, sectionA, boundary, hasBoundary)

    // 扉を設置
    if hasStartDoor {
        d.placeDoorAtPosition(startDoor.x, startDoor.y)
    }
    if hasEndDoor {
        d.placeDoorAtPosition(endDoor.x, endDoor.y)
    }

    // 境界に沿った整列接続または直接接続
    if hasBoundary {
        // 境界情報を使って効率的な接続
        // (詳細なアライメント処理)
    } else {
        // 直接的な通路掘削
        d.carveCorridorPath(startPos.x, startPos.y, endPos.x, endPos.y)
    }
}

2つのセクションを繋ぐ際は:

  1. 境界分析: セクション同士が共有する境界を分析
  2. アクセス点決定: 各セクションの適切な接続点を選択
  3. 扉設置: 部屋の壁にドアを配置
  4. 通路掘削: 最適な経路で通路を作成

通路生成アルゴリズム

パス探索(findTilePath関数)

func (d *Dungeon) findTilePath(startX, startY, endX, endY int) [][2]int {
    type node struct {
        x, y int
    }

    startKey := [2]int{startX, startY}
    endKey := [2]int{endX, endY}

    front := []node{{startX, startY}} // 優先キュー(コスト0)
    back := make([]node, 0)           // 通常キュー(コスト1)
    dist := map[[2]int]int{startKey: 0}
    parent := make(map[[2]int][2]int)

    directions := [][2]int{{0, -1}, {1, 0}, {0, 1}, {-1, 0}} // 上右下左

    for len(front) > 0 || len(back) > 0 {
        var current node
        if len(front) > 0 {
            // コスト0のマスを優先処理(スタック)
            current = front[len(front)-1]
            front = front[:len(front)-1]
        } else {
            // 通常コストのマス(キュー)
            current = back[0]
            back = back[1:]
        }

        if current.x == endX && current.y == endY {
            break // 目標到達
        }

        currentKey := [2]int{current.x, current.y}
        currentDist := dist[currentKey]

        // 4方向を探索
        for _, dir := range directions {
            nx, ny := current.x+dir[0], current.y+dir[1]
            if !d.isInBounds(nx, ny) {
                continue
            }

            cost, ok := d.corridorStepCost(nx, ny)
            if !ok {
                continue // 通行不可
            }

            neighborKey := [2]int{nx, ny}
            newDist := currentDist + cost

            if existing, exists := dist[neighborKey]; exists && existing <= newDist {
                continue // より良いパスが既存
            }

            dist[neighborKey] = newDist
            parent[neighborKey] = currentKey
            nextNode := node{nx, ny}

            if cost == 0 {
                front = append(front, nextNode) // 優先処理
            } else {
                back = append(back, nextNode) // 通常処理
            }
        }
    }

    // パスの復元
    if _, found := dist[endKey]; !found {
        return nil // パスなし
    }

    var reversed [][2]int
    current := endKey
    for {
        reversed = append(reversed, current)
        if current == startKey {
            break
        }
        current = parent[current]
    }

    // 逆順を修正
    path := make([][2]int, 0, len(reversed))
    for i := len(reversed) - 1; i >= 0; i-- {
        path = append(path, reversed[i])
    }

    return path
}

この関数は0-1 BFS(幅優先探索)という高効率なアルゴリズムを使用します:

  • コスト0: 既存の通路や扉は通行コスト0
  • コスト1: 空白地帯は掘削コスト1
  • デュアルキュー: コスト0のマスを優先的に処理

これにより、既存の通路を活用しつつ最短経路で新しい通路を生成します。

L字型通路の試行(carveSingleLCorridor関数)

func (d *Dungeon) carveSingleLCorridor(start, end Point) bool {
    // 直線で繋がる場合
    if d.canCarveStraightSegment(start, end) {
        d.carveStraightSegment(start, end)
        return true
    }

    // L字の2つのコーナーを試す
    corners := []Point{
        {x: end.x, y: start.y}, // 水平→垂直
        {x: start.x, y: end.y}, // 垂直→水平
    }

    for _, corner := range corners {
        if !d.isInBounds(corner.x, corner.y) {
            continue
        }
        if !d.canCarveStraightSegment(start, corner) {
            continue
        }
        if !d.canCarveStraightSegment(corner, end) {
            continue
        }

        // L字型通路を作成
        d.carveStraightSegment(start, corner)
        d.carveStraightSegment(corner, end)
        return true
    }

    return false
}

複雑な経路探索の前に、シンプルなL字型接続を試します。これは処理が軽く、自然な見た目の通路になります。

マップ描画

実行とデバッグ

メイン実行部分

func main() {
    dungeon := NewDungeon() // ダンジョン初期化
    dungeon.Generate()      // 生成実行
    dungeon.Display()       // 表示
}

func (d *Dungeon) Generate() {
    d.generateRooms() // 部屋生成

    // 部屋をマップに描画
    for _, section := range d.sections {
        if section.room != nil {
            d.drawRoom(section.room)
        }
    }

    // リレー点を通路として設定
    for _, section := range d.sections {
        if section.hasRelay {
            d.setCorridorTile(section.relay.x, section.relay.y)
        }
    }

    d.connectSections() // セクション間接続
}

実行は以下の順序で進みます:

  1. 初期化: 空のダンジョンを作成
  2. 部屋生成: セクション内に部屋を配置
  3. 部屋描画: マップ上に壁と床を描く
  4. リレー設定: 部屋のないセクションに接続点を設置
  5. 接続処理: 全セクションを通路で繋ぐ
  6. 表示: 完成したダンジョンを出力

デバッグ機能

const debugLogging = false

func debugf(format string, args ...interface{}) {
    if !debugLogging {
        return
    }
    fmt.Printf("[DEBUG] "+format+"\n", args...)
}

debugLoggingをtrueに変更すると、詳細な処理ログが出力されます。通路生成やセクション接続の内部処理を追跡できるため、アルゴリズムの理解や問題解決に役立ちます。

環境変数での制御

seed := time.Now().UnixNano()
if s := os.Getenv("DUNGEON_SEED"); s != "" {
    if parsed, err := strconv.ParseInt(s, 10, 64); err == nil {
        seed = parsed
    }
}
rand.Seed(seed)

環境変数DUNGEON_SEEDを設定すると、同じレイアウトのダンジョンを再現できます:

DUNGEON_SEED=12345 go run dungeon.go

重要なユーティリティ関数

重み付きランダム選択(pickWeightedIndex関数)

func pickWeightedIndex(sections []*Section) int {
    // 全体の重み合計を計算
    weight := 0
    for _, section := range sections {
        weight += max(1, section.def.weight)
    }

    // 重み範囲内でランダム選択
    choice := rand.Intn(weight)
    for idx, section := range sections {
        choice -= max(1, section.def.weight)
        if choice < 0 {
            return idx
        }
    }
    return len(sections) - 1
}

この関数により、重みの高いセクションほど選ばれやすくなります。例えば重み5のセクションは重み1のセクションより5倍選ばれやすくなります。

範囲制限関数(clamp関数)

func clamp(v, lo, hi int) int {
    if v < lo {
        return lo
    }
    if v > hi {
        return hi
    }
    return v
}

値を指定範囲内に収める関数で、座標計算でマップ境界を超えないようにするのに使用されます。

Bresenhamライン描画

func bresenhamLine(x0, y0, x1, y1 int) []Point {
    var points []Point
    dx := abs(x1 - x0)
    dy := -abs(y1 - y0)
    sx := 1
    if x0 > x1 {
        sx = -1
    }
    sy := 1
    if y0 > y1 {
        sy = -1
    }
    err := dx + dy

    for {
        points = append(points, Point{x: x0, y: y0})
        if x0 == x1 && y0 == y1 {
            break
        }
        e2 := 2 * err
        if e2 >= dy {
            if x0 != x1 {
                err += dy
                x0 += sx
            }
        }
        if e2 <= dx {
            if y0 != y1 {
                err += dx
                y0 += sy
            }
        }
    }
    return points
}

Bresenhamアルゴリズムは、コンピュータグラフィックスで直線を描画する古典的な手法です。2点間の格子点上の最短パスを効率的に計算し、通路の直線部分を生成するのに使用されます。

まとめ

このプログラムは、Rogueライクゲームの核心である「ランダムでありながら必ず歩行可能なダンジョン」を生成する洗練されたアルゴリズムの実装です。

ポイント

  1. 構造化設計: データ構造を明確に分離して管理
  2. 段階的処理: 複雑な問題を小さな手順に分割
  3. グラフ理論: 接続性を保証するスパニングツリーの活用
  4. 効率的探索: 0-1 BFSによる最適化された経路探索
  5. 確率的生成: 重み付きランダムによる自然なバリエーション

ローグライクゲーム開発やプロシージャル生成に興味がある方は、ぜひこのコードを基盤として、より複雑なダンジョン生成や、モンスター配置、アイテム生成などの機能拡張にチャレンジしてみてください。

Tags

← 前の投稿

次の投稿 →

AWS プライベートサブネットにある OpenSearch ドメインに AWS SigV4 で認証する

コメントを残す