ヒラリラーのブログ

ゲームレビュー・旅行記ブログです。

いもスパ風ダンジョン生成システム

この記事は「いもスパ風ダンジョン生成システム」と題しまして、WWAコンテスト2025でお陰様で3位をいただきました「Immortal Spiral - 不死の螺旋 -」に搭載されたランダムダンジョンの自動生成システムを解説していく記事となります。

plicy.net

ちなみにいもスパ本体のシステム解説は22日の記事で改めてお話しようと思うので、ここではあくまでダンジョンアルゴリズムに特化して話していきたいと思います。

また、この記事はWWA Advent Calendar 2025の15日目の記事となります。 

adventar.org

・・・余談ですが、1日目に公開した「アドベントダンジョン」皆さん進めてますでしょうか?

はじめに

さてさて、昨日のAokashiさんの記事「RAIL LAND DUNGEON 制作裏と振り返り」でも触れられましたように2日連続テクニカルな内容が続いていきます。

aokashi.hatenablog.jp

特に今日に関してはダンジョン生成アルゴリズムの深堀りという形になりますので、かなりコアな内容になるんじゃないかなと思います。
本来はちゃんと図表とかを使ってわかりやすく書きたかったんですが、ほぼ1日で書き上げたこともあって図表付き解説はNotebookLMで自動作成したPDFにまかせて、この記事では文字ベースになります。

また、ダンジョン生成部分だけを切り出したサンプルコードも配布してますので、まずはこれに触れてみて自分のWWAに組み込み、この解説記事を参考にしながら少しずつアレンジを加えていくといったやり方が一番簡単かなと思います。

github.com

また、サンプルゲームについてはこちらから遊ぶことができます。

hirarira.net

システム解説

大まかなロジックの紹介

ダンジョン自動生成ロジックというのは自分がオリジナルで考えたものではなく、既に数多の手法が考案されています。 いもスパにて採用した手法は「区分法」と呼ばれる手法となります。このロジックの概要については以下の記事を参考にしたので合わせて読んでみてください。

rainbowvortex.blog.fc2.com

具体的には1区画を5 x 5で区切ります。その1区画事にこの区画は「壁」「通路」「部屋」と役割をもたせ、部屋を起点としてそれらを結ぶための通路を作っていきます。

  1. ダンジョン全面を壁で埋め尽くし初期化をする
  2. 1区画事に通路候補地を作成していく
  3. ダンジョンの広さに合わせて壁エリアをランダムで配置する。
  4. 同様の手法で空き部屋をダンジョンの広さににあわせてランダムで配置する
  5. 左上の「通路候補地」から通路を掘り進め、右方面と下方面に通路を作れるかを判定して掘り進める(このあたりのロジックは後述)
  6. 出来たダンジョンに対して、全ての地点に到達できるかフルチェックをいれる
    1. この段階では通路まみれで迷路ととは程遠い状態
  7. 完全な閉路が出来ないようにチェックしながら、閉じても閉路が出来ない通路を封鎖していく
  8. 最後に出来たダンジョンが全ての地点に到達できるかチェックを入れる
  9. 到達できない地点が確認されれば1からやり直す。

drive.google.com

・・・とここまで大まかなロジックを解説してきましたが、実はGoogleのNotebookLMにもソースコードを食わせて解説資料を作ってみました。
もしかすると自分の解説よりもわかりやすいかもしれません。 というかこの資料だけで良かったのではないか説がある。

ソースコード解説

ここからは実際のソースコードと比較しながら解説をしていきます。

initGame

ソースの解説を始める前に、定数定義をする必要があります。
いちばん大切なのが v["DUNGEON_SIZE"] で、ここでダンジョンサイズを変更することが出来ます。
v["DUNGEON_SIZE"] = 3 であれば 30 x 30のダンジョンが、 v["DUNGEON_SIZE"] = 6 なら 60 x 60のダンジョンが作れます。

また、 v["target_dwfi"] ではそれぞれ床・壁・階段に対応する物体パーツ番号を指定してください。

v["DUNGEON_SIZE"] = 3;
/** ダンジョンの区画数 */
v["DUNGEON_BLOCK_NUM"] = v["DUNGEON_SIZE"] * 2;
v["tmp"] = {};
// ループ上限1万回だと突破するので緩和
LOOPLIMIT = 1000000;
v["target_dwfi"] = {
    floor_id: 6,
    wall_id: 7,
    stairs_id: 5,
}

createRandomDungeon

まず一番外側の処理である createRandomDungeon の解説をしていきます。
コード自体はいもスパのものから流用してきているので冗長な所もありますが適宜読み飛ばしてください。

/*
 * ランダムダンジョンを作成する
 * 以下の処理を完了してから呼ぶこと
 * v["dungeon_level"]: インクリメント
 * v["target_dwfi"]
 * v["DUNGEON_SIZE"]
 * v["DUNGEON_BLOCK_NUM"]: 確定
 */
function createRandomDungeon() {
  v["able_dungeon"] = false;
  for(LP[2] = 0; LP[2] < 100; LP[2]++) {
    // ダンジョンとして成立するまでマップ作成をやり直す
    v["tmp"]["able_dungeon"] = true;
    initDungeon();
    checkRooms();
    if(v["tmp"]["able_dungeon"]) {
      v["able_dungeon"] = true;
      break;
    }
    else {
      LOG("ダンジョンとして成立しないので処理をやり直します。");
    }
  }
  /** 壁をセットする */
  addCastShadow();
  v["player_start_room_idx"] = 0;
  /** 下り階段の位置 */
  v["down_room_idx"] = RAND(LENGTH(v["dungeon_rooms"]) - 1) + 1;
  /** 下り階段を設置する */
  m[v["dungeon_rooms"][v["down_room_idx"]]["x"] + 3][v["dungeon_rooms"][v["down_room_idx"]]["y"] + 3] = v["target_dwfi"]["stairs_id"];
  // プレイヤー開始地点識別床を設置する
  m[v["dungeon_rooms"][v["player_start_room_idx"]]["x"] + 3][v["dungeon_rooms"][v["player_start_room_idx"]]["y"] + 3] = 6;
  // 仮床を床に置換する
  PARTS(1, v["target_dwfi"]["floor_id"], 1);
  // プレイヤー開始地点識別床を床に置換する
  PARTS(6, v["target_dwfi"]["floor_id"], 1);
  /** プレイヤーをゲーム開始位置に飛ばす */
  JUMPGATE(
    v["dungeon_rooms"][v["player_start_room_idx"]]["x"] + 3,
    v["dungeon_rooms"][v["player_start_room_idx"]]["y"] + 3
  )
}
  • 生成ループ: 最大100回試行し、成立するダンジョンを生成
  • initDungeon()でマップ生成
  • checkRooms()で部屋数検証
    • 不成立なら再試行
  • 影付け: addCastShadow()実行
  • 階段配置: ランダムな部屋に下り階段を設置
  • プレイヤー配置: 開始部屋にプレイヤーを配置
  • 床の置換: 仮床を実際の床IDに変換
  • プレイヤー移動: JUMPGATE()で開始位置へ転送

一応ループは100回回して成立したら breakしてるんですが、ちょっと例外処理をサボってて100回生成して出来なかったら中途半端なまま出してしまっているので、実際に使う時にはここでエラーを吐くようにしたほうが良いですね。

プレイヤー出現位置や階段の設置位置については checkRooms() 関数で説明します。

initDungeon

ダンジョン構造を生成する心臓部です。

function initDungeon() {
  // 一旦全部のマスを壁で埋める
  for(i=0; i<=(v["DUNGEON_BLOCK_NUM"] * 5); i++) {
    for(j=0; j<=(v["DUNGEON_BLOCK_NUM"] * 5); j++) {
      m[i][j] = 2;
      // 物体も削除しておく
      o[i][j] = 0;
    }
  }
  // 通路候補地を生成する
  for(i=0; i<v["DUNGEON_BLOCK_NUM"]; i++) {
    for(j=0; j<v["DUNGEON_BLOCK_NUM"]; j++) {
      v["tmp"]["x"] = 3 + 5 * i;
      v["tmp"]["y"] = 3 + 5 * j;
      m[v["tmp"]["x"]][v["tmp"]["y"]] = 1;
    }
  }
  // 壁を生成する
  // 壁の数はダンジョンサイズに比例させる
  v["wall_num"] = RAND(v["DUNGEON_SIZE"] * v["DUNGEON_SIZE"]) + v["DUNGEON_SIZE"];
  for(i=0; i<v["wall_num"]; i++) {
    decisionRoomPos();
    for(j=0; j<4; j++) {
      for(k=0; k<4; k++) {
        m[v["tmp"]["x"] + j][v["tmp"]["y"] + k] = 3;
      }
    }
  }
  // 空き部屋を作る数を決める
  v["room_num"] = RAND(v["DUNGEON_SIZE"] * v["DUNGEON_SIZE"]) + v["DUNGEON_SIZE"];
  for(i=0; i<v["room_num"]; i++) {
    decisionRoomPos();
    for(j=0; j<4; j++) {
      for(k=0; k<4; k++) {
        m[v["tmp"]["x"] + j][v["tmp"]["y"] + k] = 1;
      }
    }
  }
  // 通路を掘り進める
  for(i=0; i<(v["DUNGEON_BLOCK_NUM"]); i++) {
    for(j=0; j<v["DUNGEON_BLOCK_NUM"]; j++) {
      v["tmp"]["x"] = i * 5 + 3;
      v["tmp"]["y"] = j * 5 + 3;
      if(m[v["tmp"]["x"]][v["tmp"]["y"]] == 1) {
        // 対象地点タイプと右・下に通路を作れるか判定
        checkTargetTypeAndRightDownType();
        // 右方向へ通路を延ばす
        openAisleRight();
        // 下方向へ通路を延ばす
        v["tmp"]["is_right"] = false;
        openAisleDown();
      }
    }
  }
  /** 1回目の閉路チェックを行う */
  v["is_no_replace_floor"] = true;
  checkFullClosedCircuit();
  // 到達できない地点は壁で埋める
  PARTS(1, 2, 1);
  // 仮床2を仮床1に戻す
  PARTS(4, 1, 1);
  v["is_no_replace_floor"] = false;
  /** 通路を閉じまくる */
  closedAside();
  // 仮壁を壁に置換する
  PARTS(3, 2, 1);
}
  • 全マスを壁で埋める + オブジェクト削除
  • 通路候補地を配置: 5×5ブロックの中心に仮床1を配置
  • 壁ブロック生成: ランダム位置に4×4の壁を配置(数はダンジョンサイズに比例)
  • 空き部屋生成: ランダム位置に4×4の床仮床1を配置(数はダンジョンサイズに比例)
  • 通路掘削: 全ブロック中心から右・下方向へ通路を延ばす
  • 到達不能領域を壁化: フル閉路チェック後、到達不能な床を壁(2)に置換
  • 通路閉鎖: closedAside()で通路をランダムに閉鎖
  • 仮壁を壁に置換: 仮壁(3)を壁(2)に変換

checkTargetTypeAndRightDownType

続いて対象地点を起点として、右側・下側に通路を伸ばせるかを判定します。

/** 
 * 対象地点タイプと右・下のタイプを調査する
 * @params v["tmp"]["x"] 調査地点のX座標
 * @params v["tmp"]["y"] 調査地点のY座標
 **/
function checkTargetTypeAndRightDownType() {
  // 対象地点のタイプについて
  v["tmp"]["target_type"] = m[v["tmp"]["x"]+1][v["tmp"]["y"]+1] == 1? "room": "aisle";
  v["tmp"]["right_x"] = v["tmp"]["x"] + 5;
  v["tmp"]["down_y"] = v["tmp"]["y"] + 5;
  // 右側のタイプを調べる
  if(m[v["tmp"]["right_x"]][v["tmp"]["y"]] == 1) {
    if(m[v["tmp"]["right_x"]+1][v["tmp"]["y"]+1] == 1) {
      v["tmp"]["right_type"] = "room";
    }
    else {
      v["tmp"]["right_type"] = "aisle";
    }
  }
  else {
    v["tmp"]["right_type"] = "wall";
  }
  // 下方向へ穴を掘れるか?
  // 下側のタイプを調べる
  if(m[v["tmp"]["x"]][v["tmp"]["down_y"]] == 1) {
    if(m[v["tmp"]["x"]+1][v["tmp"]["down_y"]+1] == 1) {
      v["tmp"]["down_type"] = "room";
    }
    else {
      v["tmp"]["down_type"] = "aisle";
    }
  }
  else {
    v["tmp"]["down_type"] = "wall";
  }
}
  • 役割: 指定座標と右・下の隣接ブロックのタイプを判定
  • ロジック:
    • 対象地点が部屋(4×4の床)か通路(1マスの床)かを判定
    • 対象地点のタイプ判定は、ブロックの中心座標 (v["tmp"]["x"], v["tmp"]["y"]) から見て右下1マス離れた地点 (v["tmp"]["x"] + 1, v["tmp"]["y"] + 1) をチェックします。このマスが床(1)であれば、そのブロックは 『部屋 (room)』 であり、そうでなければ 『通路 (aisle)』 と判定されます。

openOrClosedAisle

通路の開通・閉鎖を行う関数です。
呼び出す前に v["tmp"]["is_right"] = true なら右方向、falseなら下方面に掘り進めます。
また v["tmp"]["set_foor_number"] に床パーツ番号・壁パーツ番号を指定することで開通・閉鎖両方の処理に対応できます。

/**
 * 通路開通・閉鎖共通関数
 * @params v["tmp"]["x"] 開始地点のX座標
 * @params v["tmp"]["y"] 開始地点のY座標
 * @params v["tmp"]["is_right"] 右方向へ掘るか?
 */
function openOrClosedAisle() {
  // 右側通路解放
  if(v["tmp"]["is_right"]) {
    if(v["tmp"]["right_type"] != "wall") {
      v["tmp"]["start_idx"] = v["tmp"]["target_type"] == "aisle"? 1: 2;
      v["tmp"]["open_size"] = v["tmp"]["right_type"] == "aisle"? 5: 3;
      for(k=v["tmp"]["start_idx"]; k<v["tmp"]["open_size"]; k++) {
        m[v["tmp"]["x"] + k][v["tmp"]["y"]] = v["tmp"]["set_foor_number"]; 
      }
    }
  }
  // 下側通路解放
  else {
    if(v["tmp"]["down_type"] != "wall") {
      v["tmp"]["start_idx"] = v["tmp"]["target_type"] == "aisle"? 1: 2;
      v["tmp"]["open_size"] = v["tmp"]["down_type"] == "aisle"? 5: 3;
      for(k=v["tmp"]["start_idx"]; k<v["tmp"]["open_size"]; k++) {
        m[v["tmp"]["x"]][v["tmp"]["y"] + k] = v["tmp"]["set_foor_number"]; 
      }
    }
  }
}
  • 役割: 通路の開通・閉鎖の共通処理
  • ロジック:
    • 右方向: 開始インデックスから終了インデックスまでの横マスを変更
    • 下方向: 開始インデックスから終了インデックスまでの縦マスを変更
    • 部屋同士なら2マス目から、通路なら1マス目から
    • 終了位置は接続先のタイプで決定(通路なら5マス、部屋なら3マス)

checkFullClosedCircuit

閉路チェックです。
閉路チェックには簡易チェックとフルチェックがあり、簡易チェックでは5マス単位で飛び飛びで検査するのに対し、フルチェックは1マス単位で厳格にチェックを行います。
後述の closedAside() は1つの壁を閉じる度に閉路チェックをするのですが、毎回フルチェックをすると処理が重くなりすぎてしまうのでこちらでは簡易チェックにて処理を済ませることで処理の高速化をしています。

/**
 * 閉路チェックを行う(フルチェック)
 * フルチェックは到達できない閉路を壁に置換するときにのみ用いること
 * 通常のチェックでは簡易版(checkClosedCircuit)を利用してください
 * 
 * 以下パラメータがtrueの時には閉路チェック後の後処理を行わない
 * v["is_no_replace_floor"]
 * 
 * 結果は以下に格納される
 * v["is_closed_circuit"]
 **/
function checkFullClosedCircuit() {
  // 閉路が産まれているかをチェック
  v["is_closed_circuit"] = true;
  // 閉路チェックを開始地点を決定する
  v["tmp"]["ck_st_x"] = -1;
  v["tmp"]["ck_st_y"] = -1;
  v["tmp"]["check_end"] = false;
  for(i=0; i<v["DUNGEON_BLOCK_NUM"]*5; i++) {
    for(j=0; j<v["DUNGEON_BLOCK_NUM"]*5; j++) {
      if(m[i][j] == 1 && v["tmp"]["check_end"] == false) {
        v["tmp"]["check_end"] = true;
        v["tmp"]["ck_st_x"] = i;
        v["tmp"]["ck_st_y"] = j;
        // LOG(`CK START POS: X:${v["tmp"]["ck_st_x"]} Y:${v["tmp"]["ck_st_y"]}`)
      }
    }
  }
  // フルスキャンループを開始する
  v["tmp"]["current_ck_x"] = v["tmp"]["ck_st_x"];
  v["tmp"]["current_ck_y"] = v["tmp"]["ck_st_y"];
  checkFullClosedCircuitLoop();
  // ループ終了後に仮床が残ってるかを判定
  for(i=0; i<v["DUNGEON_BLOCK_NUM"]*5; i++) {
    for(j=0; j<v["DUNGEON_BLOCK_NUM"]*5; j++) {
      // 仮床が残ってればアウトにする
      if(m[i][j] == 1) {
        v["is_closed_circuit"] = false;
      }
    }
  }
  // 確認に使用したフロアをもとに戻す
  if(!v["is_no_replace_floor"]) {
    PARTS(4, 1, 1);
  }
}
  • 役割: 全マスをスキャンして閉路を完全チェック
  • 作業内容:
    • 全マスから開始地点を探索(床=1)
    • checkFullClosedCircuitLoop()で到達可能な全マスをマーク(1→4)
    • チェック後に床(1)が残っていれば閉路ありと判定
    • v["is_no_replace_floor"]がfalseなら仮床2(4)を床(1)に戻す

checkFullClosedCircuitLoop

閉路フルチェックの再帰処理用ループ関数です。

/** 再帰をすることで進める箇所はチェックを付ける(フルチェック) */
function checkFullClosedCircuitLoop() {
  if(m[v["tmp"]["current_ck_x"]][v["tmp"]["current_ck_y"]] == 1) {
    m[v["tmp"]["current_ck_x"]][v["tmp"]["current_ck_y"]] = 4;
    // X軸方向のチェック
    v["tmp"]["current_ck_x"] += 1;
    checkFullClosedCircuitLoop();
    v["tmp"]["current_ck_x"] -= 2;
    if(v["tmp"]["current_ck_x"] > 0) {
      checkFullClosedCircuitLoop();
    }
    v["tmp"]["current_ck_x"] += 1;
    // Y軸方向のチェック
    v["tmp"]["current_ck_y"] += 1;
    checkFullClosedCircuitLoop();
    v["tmp"]["current_ck_y"] -= 2;
    if(v["tmp"]["current_ck_y"] > 0) {    
      checkFullClosedCircuitLoop();
    }
    v["tmp"]["current_ck_y"] += 1;
  }
}
  • 役割: フルチェックの再帰処理
  • ロジック:
    • 現在位置が床(1)なら仮床2(4)に置換
    • 上下左右1マスずつ移動して再帰的に探索
    • 簡易版と違い、全ての床マスを1マス単位でチェック

closedAside

ダンジョンを通路の集合体ではなくて迷路らしくするための処理です。

/** 通路を閉じまくる */
function closedAside() {
  for(LP[0]=0; LP[0] <= v["DUNGEON_BLOCK_NUM"]; LP[0]++) {
    for(LP[1]=0; LP[1] <= v["DUNGEON_BLOCK_NUM"]; LP[1]++) {
      v["tmp"]["x"] = LP[0] * 5 + 3;
      v["tmp"]["y"] = LP[1] * 5 + 3;
      // 壁に対しては処理をしない
      if(m[v["tmp"]["x"]][v["tmp"]["y"]] == 1) {
        // 対象地点タイプと右・下に通路を作れるか判定
        checkTargetTypeAndRightDownType();
        // LOG(`X: ${v["tmp"]["x"]} Y: ${v["tmp"]["y"]} TYPE: ${v["tmp"]["target_type"]} RIGHT: ${v["tmp"]["right_type"]} DOWN: ${v["tmp"]["down_type"]}`);
        // 右の床を閉鎖する
        if(v["tmp"]["right_type"] != "wall") {
          /** 部屋―部屋が連続しているときには必ず通路を閉じる。それ以外なら1/2の確率で閉じる */
          v["tmp"]["is_close"] = true;
          if(v["tmp"]["target_type"] != "room" || v["tmp"]["right_type"] != "room") {
            v["tmp"]["is_close"] = RAND(2) == 0;
          }
          if(v["tmp"]["is_close"]) {
            closedAisleRight();
            // 閉路チェック
            checkClosedCircuit();
            // 閉鎖により閉路が産まれてしまったら解放
            if(v["is_closed_circuit"] == false) {
              openAisleRight();
            }
          }
        }
        // 下の床を閉鎖する
        if(v["tmp"]["down_type"] != "wall") {
          /** 部屋―部屋が連続しているときには必ず通路を閉じる。それ以外なら2/3の確率で閉じる */
          v["tmp"]["is_close"] = true;
          if(v["tmp"]["target_type"] != "room" || v["tmp"]["down_type"] != "room") {
            v["tmp"]["is_close"] = RAND(3) != 0;
          }
          if(v["tmp"]["is_close"]) {
            closedAisleDown();
            // 閉路チェック
            checkClosedCircuit();
            // 閉鎖により閉路が産まれてしまったら解放
            if(v["is_closed_circuit"] == false) {
              openAisleDown();
            }
          }
        }
      }
    }
  }
}
  • 役割: ダンジョン全体の通路をランダムに閉鎖して複雑化
  • 作業内容:
    • 全ブロックの中心座標をループ
    • 各地点で右・下方向の通路閉鎖を試行
    • 閉鎖条件:
      • 部屋同士の連結: 必ず閉鎖
      • 右方向: 1/2の確率で閉鎖
      • 下方向: 2/3の確率で閉鎖
    • 閉鎖後に閉路チェックを実行
    • 閉路が発生したら即座に再開通
  • 効果: 単調な格子状ダンジョンを複雑な構造に変化させる

ここでポイントなのは部屋同士の連結は必ず閉鎖する点
これにより部屋同士は隣接するが迂回しないとたどり着けないという構造を作りやすくしています。
また右側は1/2・下側は2/3とランダムで閉じるようにしているのは閉じれる箇所を全部閉じるようにするとダンジョン全体を見ると右側だけ開通 or 下側だけ開通するような偏った構造のダンジョンが出来やすくなってしまうからです。
このあたりの確率を手元で変更して、どんなダンジョンが出来るか確かめてみると面白いと思います。

とにかく、この通路を閉じる処理こそランダムダンジョンをユニークな構造にする心臓部と言えます。

ちなみに、簡易閉路チェック・簡易閉路チェックループにか関しては処理内容が詳細版とあまり変わらないのでここで紹介するのはやめておきます。

checkRooms

部屋の数が規定数以上かをチェックします

/** 部屋の数をチェックします */
function checkRooms() {
  v["dungeon_rooms"] = [];
  for(i=0; i<v["DUNGEON_BLOCK_NUM"]; i++) {
    for(j=0; j<v["DUNGEON_BLOCK_NUM"]; j++) {
      v["tmp"]["x"] = i * 5 + 4;
      v["tmp"]["y"] = j * 5 + 4;
      if(m[v["tmp"]["x"]][v["tmp"]["y"]] == 1) {
        v["rooms_len"] = LENGTH(v["dungeon_rooms"]);
        v["dungeon_rooms"][v["rooms_len"]] = {
          x: (i * 5),
          y: (j * 5)
        }
      }
    }
  }
  /** 部屋の数が2個以下の場合にはダンジョンとして成立させない */
  if(LENGTH(v["dungeon_rooms"]) < 3) {
    // LOG("dungeon_rooms: "+LENGTH(v["dungeon_rooms"]))
    v["tmp"]["able_dungeon"] = false;
  }
}
  • 役割: 生成されたダンジョンの部屋数を検証
  • ロジック:
    • 各ブロックの中心+1座標をチェックし、床(1)なら部屋として記録
    • 部屋座標をv["dungeon_rooms"]配列に格納
    • 部屋が3個未満ならダンジョン不成立と判定

プレイヤー開始位置・下り階段出現位置についても解説します。
下り階段についてはこのように求めています。

/** 下り階段の位置 */
v["down_room_idx"] = RAND(
  LENGTH(v["dungeon_rooms"]) - 1
) + 1;
/** 下り階段を設置する */
m[
  v["dungeon_rooms"][v["down_room_idx"]]["x"] + 3
]
[
  v["dungeon_rooms"][v["down_room_idx"]]["y"] + 3
] = v["target_dwfi"]["stairs_id"];

v["dungeon_rooms"] には部屋一覧が {x: number, y: number}[] で格納されており、まずはIndexを乱数で求めます。
これにより存在する数ある部屋の中からランダムで下り階段が決められます。
あとはランダムで求めた部屋IDから対応座標として v["dungeon_rooms"][v["down_room_idx"]]["x"] で求めます。
で、階段は起点からX+3/Y+3の地点にあるので補正して設置です。

一方でプレイヤーに関しては v["player_start_room_idx"] = 0; なので部屋ID0番で固定となります。
これを逆手に取って、下り階段は部屋ID0番には設置しないようにすることで、プレイヤー初期位置に階段が出現するようなことを防いでいるわけですね。

ちなみに v["target_dwfi"] については壁パーツ番号・床パーツ番号・階段パーツ番号が入っていまして、このセットを変更することでダンジョンの見た目を変えることが出来ます。
いもスパにおいて深く潜るとダンジョンを構成するパーツがどんどん変わっていくのは深度に応じてこのセットを切り替えているからですね。

おわりに

・・・というわけでダンジョンの生成アルゴリズムの解説でした。
多分ほとんどの人は「なるほど、わからん」と思われるかもしれませんが、これに関しては考えるより触ってみたほうが良いと思うので冒頭でもお伝えした通りサンプルプログラムを手元で動かして、 closedAside() 部分の処理を少しずつ変えていって動作を理解していくのが良いかなと思います。

もちろん処理自体を理解しなくてもランダムダンジョンは作れるのでブラックボックスとして使ってしまっても良いと思います。

また、今回紹介した処理では敵もアイテムも何も無いスカスカダンジョンになってしまいますが、実際のいもスパでは敵やアイテムの設置処理なんかも入れてあります。
どこに敵やアイテムを設置するかなどもランダムダンジョンを作るには必要になってきますが、ダンジョンの基本構造を学習すれば二重forループで基点のパーツが床・壁の判定で該当区画が通路・壁・部屋かの判定は容易にできますので、それぞれの場合に合わせて敵やアイテムを配置していけば良いんじゃないかと思います。

直接の記事内容とは関係ありませんが、記事の執筆にあたり Claudeでのソースコード解析+NotebookLMでの分析 の有用性がかなり感じられたんじゃないかと思います。
特にNotebookLMではまとめスライドの作成もそうなのですが、公開前の下書きもソースとして食わせることも出来るので生のソースコードと比較して誤った箇所・分かりにくい箇所や表現の指摘をしてくれるので記事のブラッシュアップに役に立ったような気がします。

今回の記事は全体を書き上げてから添削をお願いしたので全体の構成としては分かりにくいものになってしまったのですが、今後似たような記事を書く時にはNotebookLMに全ての資料を与えたうえで全体構成の考案→各内容をリストで与える→記事を書いてもらうというフローを辿ればかなり手間暇を削れて人間がひいひい頑張るよりクオリティの高い記事が出来るんじゃないかって思いますね。

もちろんAIですから間違った内容を出力することもあるので、人間の役目としてはアウトプットの整合性確認の方に移っていくんじゃないかなって思います。
・・・以上全然関係ない余談でした。

さて、明日の記事は池田哲次さんの「数独反省会 あれの内部どうなってるの?今から作るならどう作り直す?」となります。 今日に続いてロジック紹介記事になるかと思いますので、こちらもご期待ください!

plicy.net

それではまた明日の記事をお楽しみに!
ちなみにいもスパ本体の解説記事も来週22日に公開予定なので、こちらもご期待ください~