マガジン

ノベルゲームの作り方:分岐管理とテキスト表示

遊びをつくる

執筆にClaudeを使用しています。

活版のような抽象的な字形とローラーを並べ、太さや間隔を検討する写真風イメージ。
記事のテーマをもとに制作したAI生成イラスト。

ノベルゲームを作るとき、最初につまずくのはたいていテキスト表示の仕組みと、選択肢による分岐の管理です。「文章を一文字ずつ表示したい」「選んだルートによってエンディングを変えたい」という要件は単純に見えて、実装が進むにつれ構造が崩れやすい箇所でもあります。 この記事では、HTMLとJavaScriptだけでノベルゲームを作る前提で、テキストのタイプライター表示と分岐データの設計を中心に解説します。ゲームエンジンを使わず自前で書く人も、既存のライブラリを選ぶ人も、「どこに何を書けばよいか」の判断基準として役立ててください。

この記事の目次
  1. テキスト表示の基本:タイプライター効果の作り方
  2. 分岐データの設計:スクリプト配列とノード管理
  3. 分岐の状態管理:フラグとルートの追跡
  4. テキスト演出の応用:話者名・立ち絵・背景切り替え
  5. セーブとロード:ブラウザゲームでの実装方針
  6. 次の行動
  7. 参考資料

テキスト表示の基本:タイプライター効果の作り方

ノベルゲームの文章は、一度に全文を表示するよりも一文字ずつ順番に現れる「タイプライター効果」が定番です。演出の意味もありますが、読み手のペースと画面への没入感を保つ実用的な役割もあります。

最小限の実装は setInterval または setTimeout の再帰呼び出しで実現できます。文字列を配列に分解し、インデックスを1ずつ進めながら表示要素に追記する方法です。

const text = "こんにちは、旅人よ。";
let i = 0;
const el = document.getElementById("textbox");

function typeNext() {
  if (i < text.length) {
    el.textContent += text[i];
    i++;
    setTimeout(typeNext, 50); // 50msごとに1文字
  }
}
typeNext();

実用上で注意したい点が二つあります。一つは表示中にクリックしたとき全文を即座に表示する「スキップ」の処理です。setTimeout のIDを変数に保持しておき、クリック時に clearTimeout で止め、残りのテキストを一括で出します。この処理がないと、読み直し操作で混乱が起きます。

もう一つはテキストの改行・ページ送りです。ひとつの「ページ」に収まる分量を定め、テキストがそれを超えたらクリック待ち状態にして次の文に進む、という区切りを設計段階で決めておく必要があります。後からページ区切りを追加しようとすると、データ構造の改修が必要になるため、最初に決めておくのが得策です。

分岐データの設計:スクリプト配列とノード管理

テキストと選択肢をJavaScriptのコード中に直書きすると、シナリオが長くなったときに保守が困難になります。本文コードとシナリオデータを分離することが、規模を問わず最初に取り組むべき設計です。

シンプルな方法は、各「場面」をオブジェクトとして定義し、配列またはIDでつなぐ方法です。

const script = {
  start: {
    text: "暗い森の入り口に立っている。",
    choices: [
      { label: "森に入る", next: "forest" },
      { label: "引き返す", next: "road" }
    ]
  },
  forest: {
    text: "木々の間から光が差し込んでいる。",
    choices: []  // 選択肢なし=次のノードへ自動進行
  },
  road: {
    text: "来た道を戻ることにした。",
    choices: []
  }
};

各ノードは text(表示テキスト)と choices(選択肢と遷移先)を持ち、choices が空なら「クリックで次へ」というルールにすれば、会話が続くシーンも分岐シーンも同じ構造で扱えます。

このデータをJSONファイルに分離すると、シナリオの編集がさらにやりやすくなります。fetch でJSONを読み込んでゲームエンジン部分(表示・入力処理)に渡す構成にすることで、シナリオを書く人とコードを書く人が別々に作業できます。

分岐の状態管理:フラグとルートの追跡

選択肢が増えると「過去の選択の組み合わせによって結末が変わる」という設計になります。このとき必要なのがフラグ管理です。フラグとは、ゲーム内で起きた出来事や選択を記録する変数のことです。

const flags = {
  tookSword: false,
  metGuard: false
};

ノードの定義に condition(表示条件)と effect(フラグ操作)を加えることで、「剣を取っていれば選択肢Aが出る」「この場面を通過したらフラグをオンにする」という制御が実現します。

forest_gate: {
  text: "門番が立っている。",
  effect: () => { flags.metGuard = true; },
  choices: [
    {
      label: "剣を見せる",
      condition: () => flags.tookSword,
      next: "gate_open"
    },
    { label: "話しかける", next: "gate_talk" }
  ]
}

condition が false の選択肢は表示しない、あるいはグレーアウトするかは仕様次第ですが、存在すら表示しない方が没入感を保ちやすいことが多いです。

注意点として、フラグが増えるほどテストの組み合わせが爆発します。主要なルートを「エンディング一覧」として先に書き出し、そこから逆算して必要なフラグだけを定義する進め方が現実的です。思いつきでフラグを増やすと、どの組み合わせが未テストか把握しきれなくなります。

テキスト演出の応用:話者名・立ち絵・背景切り替え

基本が動いたら、次に手を入れるのは演出まわりです。ノベルゲームらしさを支える要素として、以下の三つが代表的です。

話者名の表示はノードに speaker プロパティを追加するだけです。空文字列や null のときは名前欄を非表示にするCSSを当てると、地の文と台詞を切り替えられます。

立ち絵(キャラクター画像)はノードに character と expression を持たせ、対応する画像ファイルに切り替えます。画像の読み込み遅延が気になる場合は、ゲーム起動時に主要画像を new Image() で先読みしておく方法が有効です。

背景切り替えも同様に、ノードに bg プロパティを持たせてCSSの background-image を更新します。フェードイン・アウトはCSSトランジションで対応できます。

これらの演出情報もすべてスクリプトデータ側に記述し、表示ロジック(エンジン側)はデータを読むだけに留めると、演出の修正がシナリオファイルだけで完結します。エンジンとデータの分離は、プロジェクトが大きくなるほど効いてきます。

セーブとロード:ブラウザゲームでの実装方針

ブラウザゲームでのセーブは localStorage を使うのが基本です。現在のノードID・フラグの状態・既読フラグをまとめてJSON化して保存します。

function saveGame(slotId) {
  const saveData = {
    currentNode: state.currentNode,
    flags: { ...flags },
    readNodes: [...state.readNodes]
  };
  localStorage.setItem("save_" + slotId, JSON.stringify(saveData));
}

既読フラグは「一度読んだ場面はテキストスキップできる」機能に使います。長編になるほど既読スキップの有無がプレイ体験に直結するため、設計初期から readNodes の集合を持っておくことを勧めます。

localStorage の容量は一般的に5MB前後と言われており、テキストとフラグだけなら大半のケースで問題ありません。ただし画像データなどをBase64で詰め込むと限界に近づくため、セーブデータには状態情報のみを保存し、素材ファイルは別管理にするルールを守ってください。

アソバースのSDKを使った作品では、クラウドセーブ機能を利用することでブラウザをまたいだデータ保持が可能です。ただしSDK対応は作者側の実装が必要で、localStorage と共存させるか置き換えるかは設計段階で決めておく必要があります。

次の行動

スクリプトデータとエンジン部分を分離した最小構成を一度手元で動かし、ノード3〜5個の小さなデモで分岐とフラグの動作を確認してください。構造が固まったら、シナリオをJSONファイルに移して別管理にすると、量が増えても見通しが保てます。完成したゲームはHTMLファイルまたはZIPファイルとしてアソバース(https://aso-verse.com)に投稿・公開できます。

参考資料

  • MDN Web Docs「WindowOrWorkerGlobalScope.setTimeout」
  • MDN Web Docs「Window.localStorage」
  • MDN Web Docs「Fetch API」