[JavaScript] Three.jsで地形にゾーンを持たせてエンカウントを制御する(tile→ASCII map→ZONE_DEFS)

はじめに

昨日、エンカウントで戦闘になる実装をしましたが、エンカウントするエリアの実装をしてなかったので、その実装メモです。

足下のレイで地形の状態を把握できる実装は終わっている為、地形との衝突判定時にエンカウントする地形かどうかというのを追加する内容です。

マップも少しエリアを拡大し、テクスチャーを2つ使い繋ぎ目も作成しています。

1. 目的

RPG を作っていると、最初にぶつかる違和感がある。

  • 街の中でも敵が出る
  • 安全地帯と危険地帯の区別がない
  • 地形はあるのに「場所の意味」がゲームに反映されていない

今回やりたかったことは、これを解消することだった。

エンカウントを確率や時間だけで管理するのではなく、 「プレイヤーが今どこに立っているか」で制御したい。

具体的には、

  • フィールド(wild)では敵が出る
  • 街(town)や安全地帯(safe)では敵が出ない
  • その判定を、描画や移動処理とは独立したルールとして持たせる

という状態を目標にした。

重要なのは、 「エンカウント処理が地形の詳細を知らなくて済む」構造にすること。

if 文を増やして場当たり的に止めるのではなく、

  • 地形は「どのゾーンか」を持つ
  • プレイヤーは「今いるゾーン」を知る
  • エンカウントは「ゾーンの定義」だけを見る

という流れを作ることを、この実装の最終目的にした。

2. 最初の問題

最初の実装では、地形の種類はほぼ「見た目」だけの存在だった。

  • grass タイルの上では常にエンカウント判定が走る
  • town も field も、ロジック上は同じ扱い
  • 結果として「街の中でも普通に敵が出る」状態になる

これは技術的なバグではなく、設計上の欠落だった。

エンカウント処理は、

  • 時間が経過したか
  • 確率に当たったか

だけを見ていて、 「今いる場所がどんな意味を持つか」を一切知らない。

一方で地形側も、

  • grass / rock / floor といった描画情報はある
  • しかし「ここは安全」「ここは危険」といった意味は持っていない

つまり、

  • 地形はある
  • 見た目の違いもある
  • でもゲームルールには一切反映されていない

という、RPGとしてはかなり不自然な状態だった。

この段階で無理に if 文を足して、

if (isTown) encounter = false;

のような対処をすると、後から必ず破綻する。

  • 地形が増えたら if が増える
  • 条件が分散して把握できなくなる
  • 描画・移動・戦闘が密結合になる

そこで問題をこう言い換えた。

「エンカウントが、地形の“意味”を知らないのが問題」

この認識が、以降の設計をすべて決めることになった。

3. 地形データの見直し

まず手を入れたのは、エンカウント処理ではなく地形データそのものだった。

当初の地形定義は、いわゆるタイル配列形式。

{ "x": 1, "z": 0, "type": "grass" }

この形式は機械的には問題ないが、人間にとっては致命的な欠点があった。

  • マップ全体の形が直感的に分からない
  • どこが街で、どこが外なのか把握しづらい
  • 少し広げるだけで管理が破綻する

「場所に意味を持たせたい」のに、 そもそも場所が頭に入ってこない。

そこで、地形データを次の2層構造に分けることにした。


ASCII map によるレイアウト定義

まず、地形の配置そのものは ASCII map で表現する。

..TTT..
..TST..
..TTT..
.......
.......

この形式にしたことで、

  • マップ全体を一目で把握できる
  • 街の形・境界・広がりが直感的に分かる
  • 後から拡張しても壊れにくい

というメリットが得られた。

ここでは意味は持たせない。 あくまで「どこに何があるか」だけを表す。


legend に意味を集約する

ASCII map の各記号が何を意味するかは、legend 側にまとめる。

"legend": {
  ".": { "type": "grass", "zone": "wild", "texture": "rock" },
  "T": { "type": "grass", "zone": "town", "texture": "grasslan" },
  "S": { "type": "grass", "zone": "safe", "texture": "floorWorld" }
}

ここで重要なのは、見た目と意味を同時に持たせている点。

  • texture … 描画用の情報
  • type … 地形としての分類
  • zone … ゲームルール上の意味

ASCII map は単なる配置図で、 legend が「その場所が何者か」を定義する。

この分離によって、

  • マップ構造を変えてもルールは壊れない
  • ルールを変えてもマップは描き直さなくていい
  • エンカウントの判定材料が自然に用意される

という下地が整った。


「grass = エンカウント」の呪縛を外す

この時点で、ようやく次のことが可能になる。

  • grass でも zone が town ならエンカウントしない
  • 見た目が同じでも、意味は違う

つまり、

「grass だから危険」ではなく 「zone が wild だから危険」

という発想への切り替え。

これは単なるデータ形式の変更ではなく、 ゲームルールの責務を正しい場所に戻す作業だった。

この地形データの見直しが、 後のエンカウント制御をすべてシンプルにしてくれることになる。

4. zone を「描画」から「意味」に分離

地形データを整理して見えてきたのは、 zone は「見た目の分類」ではないという事実だった。

最初はつい、こう考えてしまう。

  • grass = フィールド
  • stone = 街
  • texture が違う = ルールも違う

しかしこの発想のままだと、必ず詰まる。


見た目にルールを結びつけると壊れる

もしエンカウントを texture や type に紐づけると、

  • 見た目を変えたくなった瞬間にロジックが壊れる
  • 同じ草でも「安全な草」「危険な草」を作れなくなる
  • アート調整がゲームルールに影響する

これは、描画とゲームルールが密結合している状態。

小規模なうちは気にならないが、 地形が増えた途端に管理不能になる。


zone は「その場所の意味」

そこで、zone の役割をはっきり定義した。

  • zone = 「この場所で何が起きていいか」

見た目とは独立した、純粋な意味情報として扱う。

{
  "type": "grass",
  "texture": "grasslan",
  "zone": "town"
}

このデータが示すのは、

  • 見た目は草地
  • でも意味としては街

という状態。

これによって、

  • 見た目が同じでも安全・危険を切り替えられる
  • 街の外観を草地にしても問題ない
  • エンカウント以外のルールにも使い回せる

zone を分離すると何が楽になるか

zone を「意味」として分離すると、次の構造が作れる。

  • 描画コードは texture だけを見る
  • 移動・衝突は type だけを見る
  • エンカウントは zone だけを見る

それぞれがお互いを知らなくていい。

これは単なるきれい事ではなく、 実装が進むほど効いてくる。

  • if 文が増えない
  • 修正箇所が一点で済む
  • 意図しない副作用が起きにくい

「場所に意味がある」状態を作る

RPG で大事なのは、 プレイヤーが「今どこにいるか」を感じられること。

  • ここは戦闘が起きる場所
  • ここは安全な場所
  • ここは特別な場所

zone を描画から切り離すことで、 地形が単なる床から「意味のある場所」に変わる。

この考え方が、 次のステップである「コライダーに zone を持たせる」実装につながっていく。

5. collider に zone を持たせる

zone を「意味」として定義しただけでは、まだ足りない。 その意味を、実際にゲーム内で参照できる場所まで運ぶ必要がある。

そこで次にやったのが、 地形コライダーに zone を持たせることだった。


なぜ collider なのか

プレイヤーが「今どこにいるか」を判定する瞬間は、 描画でもデータ定義でもなく、衝突処理のタイミングにある。

  • プレイヤーが立っている
  • 足元にある地面と衝突している
  • その地面が「どの場所か」を知っている

この関係が作れれば、

「今立っている場所の意味」= zone を自然に取得できる。


地形メッシュを collider として登録する

地形生成時に、見た目用のメッシュとは別に、 コライダー情報をまとめて管理している。

そこに zone を追加した。

config.model.collider.push({
  name: `ground_${position[0]}_${position[2]}`,
  mesh: mesh,
  box: new THREE.Box3().setFromObject(mesh),
  type: 'ground',
  zone, // ← ここ
});

ここでやっていることは単純だが重要。

  • 地形は自分が ground であることを知っている
  • 同時に「自分がどの zone に属するか」も知っている

つまり地形は、

「見た目を描画するだけの存在」から 「意味を持った場所」へ昇格した。


collider が意味を持つメリット

この構造にすると、次のことができるようになる。

  • 衝突判定の結果から zone を取得できる
  • 座標計算やマップ検索を別途やらなくていい
  • 「今いる場所」を地形側に聞くだけで済む

特に重要なのは、 プレイヤー側が地形データを直接触らなくていい点。

プレイヤーは、

  • タイル構造を知らない
  • ASCII map を知らない
  • texture を知らない

ただ、

  • 「この地面は ground」
  • 「この ground の zone は何か」

だけを受け取る。


「場所が自分の意味を知っている」状態

この段階で、世界の構造はこうなる。

  • 地形は「自分が何者か」を知っている
  • collider はその意味を運ぶ
  • プレイヤーは衝突結果から意味を受け取る

この設計によって、

場所に意味があり、 その意味が自然にプレイヤーへ伝わる

という状態が完成する。

次のステップでは、 この zone をプレイヤー自身が記憶し、 エンカウント処理がそれを見るようにしていく。

6. プレイヤーが現在地を取得する

collider に zone を持たせただけでは、 まだ「エンカウントが地形で変わる」状態にはならない。

次に必要なのは、 プレイヤー自身が「今どこにいるか」を把握することだった。


プレイヤーは「床に立つ」ことで場所を確定する

このゲームでは、プレイヤーの移動は常に次の流れで決まる。

  1. 入力から移動方向を計算する
  2. 仮の移動先を作る
  3. 衝突判定を行う
  4. 足元に立てる床があるかを確認する

ここで使っているのが、

  • collision … 何かに当たったか
  • snapToGround … その場所に立てるか

という2つの処理。

重要なのは、 「どの床に立てたか」が分かる瞬間が必ず存在すること。


snapToGround は「現在地確定ポイント」

プレイヤーが実際に移動を確定するのは、このタイミング。

if (canStand) {
  config.player.box.position.copy(testPos);

  // ★ ここで現在地を更新
  config.player.currentZone = hit.zone;
}

この瞬間、

  • プレイヤーは移動した
  • 足元にあるのは ground
  • その ground は zone を持っている

つまり、

「今立っている地面の zone」=「プレイヤーの現在地」

という定義が自然に成立する。


なぜここで取得するのが正しいのか

もし現在地を、

  • 座標計算で判定する
  • マップ配列から逆引きする

といった方法で求めると、

  • 処理が増える
  • 責務が混ざる
  • 判定がズレやすい

という問題が出てくる。

それに対してこの方法は、

  • すでにある衝突判定を使う
  • 「立てた床」だけを見る
  • 移動と現在地が常に一致する

という利点がある。

移動が確定した瞬間に、現在地も確定する。 ズレる余地がない。


プレイヤーが持つのは「意味」だけ

ここでプレイヤーに持たせているのは、たったこれだけ。

config.player.currentZone
  • タイル座標は知らない
  • ASCII map も知らない
  • 地形データも直接触らない

知っているのは、

  • 「今いる場所の意味(zone)」だけ

この状態を作ることで、 次のステップが一気にシンプルになる。


次につながる準備が整った

この時点で、構造はこうなっている。

  • 地形は zone を持っている
  • collider がその zone を運ぶ
  • プレイヤーが currentZone として記憶する

あとは、

エンカウント処理が currentZone を見るだけ。

次の章では、 エンカウントを ZONE_DEFS に集約し、 地形に依存しないルールとして完成させる。

7. ZONE_DEFS でルールを集中管理

ここまでで、

  • 地形は zone を持つ
  • プレイヤーは currentZone を知る

という状態はできている。

最後にやるべきことは、 エンカウントのルールを「場所」から完全に切り離すことだった。


エンカウント処理が地形を知っていると壊れる

もしエンカウント処理の中で、

if (zone === 'town') { ... }
if (zone === 'wild') { ... }

のような分岐を書き始めると、

  • zone が増えるたびに if が増える
  • 条件が散らばって把握しづらくなる
  • 別のルール(BGM や UI)と整合が取れなくなる

ルールが実装に埋もれてしまう。


ZONE_DEFS に「ルール」を集約する

そこで、zone ごとの振る舞いを 1か所にまとめて定義することにした。

// ワールド設定
export const ZONE_DEFS = {
  safe: {
    area: 'field',
    encounter: false,
  },
  wild: {
    area: 'field',
    encounter: true,
  },
  town: {
    area: 'town',
    encounter: false,
  },
};

ここには、

  • この zone では何が起きていいか
  • どんな扱いの場所か

だけを書く。

地形も、プレイヤーも、エンカウントも、 この定義の存在だけを前提にする。


エンカウント処理は zone 定義を見るだけ

エンカウント更新処理は、驚くほど単純になる。

function updateEncounter(delta) {
  const zone = config.player.currentZone;
  const def = ZONE_DEFS[zone];

  if (!def?.encounter) return;

  RPG.encounterTime += delta;

  if (RPG.encounterTime >= RPG.nextEncounterTime) {
    enterBattle(delta);
  }
}

ここでエンカウント処理が見ているのは、

  • 地形でも
  • タイルでも
  • 座標でもなく

ZONE_DEFS だけ。


「エンカウントは地形を知らない」状態

この構造が意味しているのは、

  • 地形は意味を持つ
  • プレイヤーは現在地を知る
  • ルールは意味だけを見る

という一方向の流れ。

エンカウント処理は、

  • grass かどうか
  • town かどうか

を一切気にしない。

「この zone で戦闘が起きていいか」 という問いにだけ答える。


拡張が自然にできる設計

この方式にすると、後から簡単に拡張できる。

dungeon: {
  area: 'dungeon',
  encounter: true,
}

エンカウント側のコードは一切変更しなくていい。

同じ定義を使って、

  • BGM
  • 足音
  • 天候
  • エフェクト

を切り替えることもできる。


ルールが「世界設定」になる

ZONE_DEFS は if 文の代替ではない。

世界のルールそのもの。

地形・移動・戦闘が疎結合になり、 「場所に意味がある世界」がここで完成する。

8. やらなかったこと

地形と zone を結びつけていく過程で、 いくつか「技術的にはできるが、今回はやらなかったこと」がある。

その代表が シェーダブレンド だった。


シェーダブレンドとは何か

いわゆる地形表現の王道手法で、

  • 複数のテクスチャを
  • ノイズや高さで
  • 滑らかに混ぜる

というもの。

理論的には、

  • grass と rock を自然につなげられる
  • タイル境界を完全に消せる
  • 見た目の完成度は上がる

しかし今回は、あえて採用しなかった。


今回は「見た目」が主目的ではない

この実装の目的は、最初から一貫している。

エンカウントを「場所」で制御すること。

  • 見た目を完璧にすることではない
  • 地形をアート作品にすることでもない
  • ルールと構造をきれいに分離すること

ここに対して、シェーダブレンドは明らかにオーバースペックだった。


実装コストと得られるものが釣り合わない

実際に少し試してみて分かったことがある。

  • Three.js のシェーダは前提が多い
  • onBeforeCompile は地雷が多い
  • デバッグコストが高い
  • 見た目調整に終わりがない

「できる」ことと 「今やるべき」ことは別だった。


データ駆動で十分な解決ができた

結果として、

  • ASCII map
  • legend
  • zone
  • ZONE_DEFS

というデータ構造だけで、

  • エンカウント制御
  • 場所の意味付け
  • 拡張可能な設計

がすべて成立した。

ツートンに見える部分や、 多少の境界感は残る。

しかしそれは、

  • ルールの破綻ではない
  • 設計の欠陥でもない

むしろ 意図して残した簡素さ。


「やらなかった」判断も設計の一部

技術記事では、 つい「できたこと」だけを書きがちだ。

だが実際の開発では、

  • やらない
  • 後回しにする
  • 切り捨てる

という判断の方が重要になる場面が多い。

今回シェーダブレンドを採用しなかったのも、 設計として正しい判断だった。


必要になったら、いつでも足せる

大事なのは、 今の設計がシェーダブレンドを「拒否していない」こと。

  • zone は意味として独立している
  • 描画は texture に閉じている

だから、必要になれば後から、

  • 見た目だけを差し替える
  • ルールに一切影響を与えない

形で導入できる。


今回はやらなかった。 それで十分だった。

それが、この実装の結論。

9. 得られた設計

今回の実装で一番大きかった収穫は、 エンカウント制御そのものではなく、設計の形だった。

最終的にできあがったのは、 「地形に意味があり、その意味をルールが参照する」構造。


完成した流れ

整理すると、世界の情報はこう流れている。

  1. 地形データが zone を持つ
  2. collider が zone を運ぶ
  3. プレイヤーが currentZone として記憶する
  4. ルールは ZONE_DEFS を参照する

どの段階でも、

  • 座標に依存しない
  • 描画に依存しない
  • 実装同士が直接つながらない

一方向の関係になっている。


拡張は「ルールを足す」だけ

この構造の強みは、 エンカウント以外にもそのまま使えること。

たとえば BGM。

if (ZONE_DEFS[zone].area === 'town') {
  playBGM('town');
} else {
  playBGM('field');
}

足音も同じ。

const footstep = ZONE_DEFS[zone].area === 'town'
  ? 'stone'
  : 'grass';

天候や演出も同様に分岐できる。

  • 霧が出る場所
  • 雨が降る場所
  • 砂嵐が起きる場所

すべて zone の意味として管理できる。


zone は「世界の共通言語」

zone を導入したことで、

  • 地形
  • プレイヤー
  • エンカウント
  • サウンド
  • 演出

が、同じ言葉で会話できるようになった。

それぞれが勝手に判断せず、 「この場所は何者か?」という問いに ZONE_DEFS が答える。


設計として一番大事だったこと

今回一貫して意識したのは、これだけ。

  • その処理は「何を知る必要があるか」
  • それ以上を知っていないか

地形が戦闘ルールを知る必要はない。 エンカウントが描画を知る必要もない。

知るべき情報を、正しい場所に置く。

その結果、

  • コードが短くなる
  • 意図が読みやすくなる
  • 後から壊れにくくなる

という設計にたどり着いた。


このやり方は、Three.js に限らず、 RPG やゲーム制作全般でそのまま使える考え方だ。

「エンカウントを場所で制御したい」 その単純な目的から始めて、 結果的に 世界の設計を整理する実装になった。