オートタイルを実装してみる

みんなでつくるダンジョンで使うために簡易的なマップエディタを構成するためのWebComponentを作っています。

github.com

この過程でオートタイルを実装してみたのでまとめてみます。デモはこちら(グリッド上をドラッグアンドドロップするとオートタイルを配置できます)

オートタイルって何ですか

あるフォーマットに則ったマップチップから複数のマップチップパターンを自動生成するとともに、適切なマップチップを自動選択しながら配置するというものです。こんなかんじ。

f:id:piyorinpa:20210105001229g:plain
自動で道を生み出しているようす

(上の動画に登場するマップチップは、ぴぽや様 https://pipoya.net/ からお借りしました)

f:id:piyorinpa:20210105001941p:plain
オートタイル

今回は、上図のフォーマットのオートタイルから、上記を実現します。WOLF RPGエディタで採用されているフォーマットですね。縦に5つのパターンのマップチップが並んています。

f:id:piyorinpa:20210105235014p:plain
オートタイルから生成されたマップチップたち

先ほどのオートタイルのフォーマットから、このようなマップチップを生成することができます。

基本的には、一つのマップチップを4分割して、それぞれの場所にオートタイルから切り出した画像の欠片を当てはめて生成します。

今回の処理の仕様

以下のような仕様が前提条件です。

  • 選択範囲に対してオートタイルから生成したマップチップを配置する(ドラッグアンドドロップで選択範囲を設定するイメージ)
  • 選択範囲に配置済みマップチップが重なったときは接続する

f:id:piyorinpa:20210106000842g:plain

  • 選択範囲に配置済みマップチップが重ならないときは接続しない

f:id:piyorinpa:20210106000854g:plain

隣り合うマップチップを確認する

配置されるマップチップの種類は、「これから配置される、あるいは既に配置されているマップチップ」に影響されます。まずは周囲のマップチップを確認する処理を考えます。

f:id:piyorinpa:20210107002414p:plain
バッファに転写

まず、バッファに既に配置されているマップチップを転写した後、さらにこれから配置する領域(選択領域)をバッファに転写します。このとき、配置済みマップチップとの接続確認のために周囲1マス分多めに転写しておきます。

f:id:piyorinpa:20210107014052p:plain
バッファを走査して領域をマーク

次に、バッファを走査して一つずつ隣り合うマップチップの存在を確認していきます。周囲8チップを一つずつ確認し、選択範囲部分、あるいは配置済みのマップチップの部分をマークしていきます。このとき、上図のように周囲8チップに番号を振っておくと、マークした領域をその番号の和として、一つの整数で表現できます。(2進数に直すと、各ビットの有無で領域のマークの有無が表現されます。たとえば、1 + 2 + 8 = 11 を2進数にすると、 00001011 となり、1番目、3番目、4番目のビットが1ということは、領域番号1, 2, 8 がマークされていることを示しています)

ただし、配置済みマップチップに対しては、注目しているマップチップから見た向きと、配置されているマップチップの種類との組み合わせによってはマークしないようにします。たとえば、上図の選択領域3部分のように、隣り合う配置済みマップチップが「境界」に相当する場合はマップチップ同士を接続しないのでマークしません。

また、このままだと領域のマークの結果は同じなのに、期待する結果が異なる場合があります。

f:id:piyorinpa:20210107004747p:plain

このことを考慮し、斜め方向に隣り合う要素が交差パーツだったときは領域をマークしないようにします。

f:id:piyorinpa:20210107004758p:plain

よって、ここで説明しているような処理を実装するには、マップ上のマップチップに(何らかの方法で)「境界」と「交差」の情報を持たせる必要があります。

疑似ソースコードを書くと以下のような感じになります。(登場するクラスやメソッドは架空のものです)

const x1 = 10   // 選択領域の始点
const y1 = 10
const x2 = 20   // 選択領域の終点
const y2 = 20
const bufferWidth = x2 - x1 + 2  // 終点- 視点 + 周囲1マス分
const bufferHeight = x2 - x1 + 2  // 終点- 視点 + 周囲1マス分

// バッファをつくる
const tiledBuffer = new tiledMapData(bufferWidth, bufferHeight)
// マップ(mapData)から領域を切り出してバッファに転写する(選択領域+周囲1マス分をコピー)
tiledBuffer.copy(mapData, x1 - 1, y1 - 1, bufferWidth, bufferHeight, 0, 0)
// 選択領域をバッファに写す(このとき、配置済みマップチップの転写が上書きされることになる)
const temporaryChip = new MapChip()    // 選択領域を示す仮のマップチップ
for(let x = x1; x < x2; x++) {
  for(let y = y1; y < y2: y++) {
    tiledBuffer.put(temporaryChip, x, y)
  }
}

// 配置結果を格納するバッファをつくる
const width = x2 - x1
const height  = y2 - y1
const result = new tiledMapData(width, height)

for(let y = 1; y < height + 1; y++) {
  for(let x = 1; x < width + 1; x++) {
    const cursor = tiledBuffer.getMapDataFromChipPosition(x, y)

    /**
    * adjacent
    * 周囲のマップチップのマーク結果を格納する 
    *
    *  x      : 注目点(x, y)
    *  周囲の数値: 領域番号
    * *-----*-----*-----*
    * | 16  |  1  | 32  |
    * *-----*-----*-----*
    * |  2  |  x  |  4  |
    * *-----*-----*-----*
    * | 64  |  8  | 128 |
    * *-----*-----*-----*
    * 領域番号の和を2進数に直すと、各領域がマークされているかどうかがわかる
    * ex)
    *    adjacent = 131 = 128 + 2 + 1 = 0b10000011
    *    adjacent = 34  = 32 + 2      = 0b00100010
    */
    let adjacent = 0

    // 周囲のマップチップ情報を取得
    const around = [
      tiledBuffer.getMapData(x, y - 1),
      tiledBuffer.getMapData(x - 1, y),
      tiledBuffer.getMapData(x + 1, y),
      tiledBuffer.getMapData(x, y + 1),
      tiledBuffer.getMapData(x - 1, y - 1),
      tiledBuffer.getMapData(x + 1, y - 1),
      tiledBuffer.getMapData(x - 1, y + 1),
      tiledBuffer.getMapData(x + 1, y + 1)
    ]

    // 周囲のマップチップの状態を確認して領域をマークしていく
    // isAdjacent(chip) : 指定されたマップチップがオートタイル対象あるいは選択範囲内ならTrueを返却 
    // around[].boundary : 配置済みマップチップの境界の情報
    // around[].cross : 配置済みマップチップの交差の情報
    if (!around[0].boundary.bottom) adjacent += isAdjacent(aroundChips[0]) ? 1 : 0
    if (!around[1].boundary.right) adjacent += isAdjacent(aroundChips[1]) ? 2 : 0
    if (!around[2].boundary.left) adjacent += isAdjacent(aroundChips[2]) ? 4 : 0
    if (!around[3].boundary.top) adjacent += isAdjacent(aroundChips[3]) ? 8 : 0
    if (!around[4].boundary.bottom && !around[4].boundary.right && around[4].cross.bottomRight) adjacent += isAdjacent(aroundChips[4]) ? 16 : 0
    if (!around[5].boundary.bottom && !around[5].boundary.left && !around[5].cross.bottomLeft) adjacent += isAdjacent(aroundChips[5]) ? 32 : 0
    if (!around[6].boundary.top && !around[6].boundary.right && !around[6].cross.topRight) adjacent += isAdjacent(aroundChips[6]) ? 64 : 0
    if (!around[7].boundary.top && !around[7].boundary.left && !around[7].cross.topLeft) adjacent += isAdjacent(aroundChips[7]) ? 128 : 0
    
    // マークされた情報に基づきマップチップを決定
    // getAutoTiledChip()の中身については後述のとおり
    const generatedMapChip = getAutoTiledChip(adjacent)
    // 結果バッファに格納する
    result.put(generatedMapChip, x, y)
  }
}

// 結果バッファをマップに転写する
mapData.copy(result, 0, 0, width, height, x1, y1)

マークした領域情報からマップチップを生成する

あとは、マークされた情報に基づきマップチップを決定できればよさそうです。まずは、当てはめるマップチップを4分割して、それぞれにオートタイルの欠片を当てはめます。

f:id:piyorinpa:20210106235027p:plain
マップチップの当てはめ

たとえば上図のように4分割したときの左上側の欠片に着目します。このとき、欠片に隣り合う領域がどのようにマークされているかによって、5つの種類の欠片のうち一つを当てはめることができます。

これは、以下のような疑似コードで表現することができます(あくまで処理の雰囲気を伝えるためのコードなので、コピペしても動きません)。 欠片に接する領域は、以下のようにマークした領域番号の和である adjacent にビットマスクを当てて確認できます。

境界や交差の情報をどのように持たせるかも工夫の余地がありそうですね。

function getAutoTiledChip(adjacent) {
  const  mapChip = new MapChip()

  // 境界の情報(デフォルトではどこにも境界がない)
  const boundary = {
    top: false,
    bottom: false,
    left: false,
    right: false
  }

  // 交差の情報(デフォルトではどこにも交差していない)
  const cross = {
    topLeft: false,
    topRight: false,
    bottomLeft: false,
    bottomRight: false
  }

  // マップチップを4分割したときの左上側の欠片を決定する
  // (19 = 1 + 2 + 16)
  if ((adjacent & 19) === 0) {
    /* コーナー */
    mapChip.renderingArea[0] = clipAutoTile[0]
    boundary.top =  true  // コーナーは上側と左側が境界になる
    boundary.left = true
  } else if ((adjacent & 19) === 1) {
    /* 縦方向の道 */
    mapChip.renderingArea[0] = clipAutoTile[1]
    boundary.left = true  // 縦方向の道は左側が境界になる
  } else if ((adjacent & 19) === 2) {
    /* 横方向の道 */
    mapChip.renderingArea[0] = clipAutoTile[2]
    boundary.top = true  // 横方向の道は上側が境界になる
  } else if ((adjacent & 19) === 3) {
    /* 交差 */
    mapChip.renderingArea[0] = clipAutoTile[3]
    cross.topLeft = true  // 左上側の領域で交差していることを示す
  } else if ((adjacent & 19) === 19) {
    /* 境界なし */
    mapChip.renderingArea[0] = clipAutoTile[4]
  }

  // [TODO] そのほかの部分の欠片も同様のロジックで決定する

  // 境界情報・交差の情報をマップチップに設定する
  mapChip.setBoundary(boundary)
  mapChip.setCross(cross)

  return mapChip
}

残りの3つの欠片も同様に処理すれば、オートタイルからマップチップを生成できます。

実装してみたようす

このようにして実装した結果、こんなかんじに動くようになりました。

garakuta-toolbox.com

動くとけっこうきもちいい~というきもちになりました。ではでは。