革命学舎

ブログトップ画像
革命学舎
書く、これしか出来ないから。

積み重なる痕跡

投稿日: 2025-10-23

タグ: #tech

#現状
新しいノベルゲームを作成している。今までのゲームのような、導入がノベルゲームっぽいけどゲーム部分はしっかり存在するタイプではなく、完全にストーリーのみのゲームだ。

これにあたり、ノベルゲームのための画面をクリックするとテキストが更新される仕組みをさらにアップデートしたのだが、これにかなりの時間を要した。


#歴史
僕の初めてのゲームからいくつもノベルゲームっぽいものは作成しており、その度に少しずつ、このテキスト更新のJavaScriptファイルはアップデートされてきた。

最初のアーニャ・アサーニャゲームでは、画面をクリックすると数字がひとつ増え、テキストが入れられている配列のn番目を表示するというシンプルなものだった。これはクリックされたときに実行される処理で、最初はlet currentOpeningIndex = 0となっているこの数字で管理している。

document.querySelector('#opening-text p').textContent = openingTextArray[currentOpeningIndex];
if (currentOpeningIndex + 1 < openingTextArray.length) {
  currentOpeningIndex += 1;   }
else {
  // ここにシナリオが最後まで行った時の処理を書く
}

アサーニャ(アーニャの姿)の立ち絵はcurrentOpeningIndexが特定の数字になったときをifで判定して表示する、というものだった。

次の月面開発のゲームでは、テキストと関数を一緒に入れた。こうすることで「このテキストと同時にキャラクターを表示する」といったテキストと連動したアクションを起こせる予定だった。予定、と書いたのは、結局このゲームに立ち絵を用意しなかったからだ。この仕組みは次のゲームから活躍する。

この後の海探索のゲームではテキストとアクションをまとめる仕組みが上手くいった。シナリオデータは以下のようなセットがたくさん入ってる。

{ 
  text: '表示するテキスト',
  speaker: '話者名',   // ないならこれは書かない 
  choiceId: '選択肢ID',  // ないならこれは書かない
  action: () => { /* 実行する処理 */ }   // ないならこれは書かない 
}

これにより「この時に背景を変える」、「この時にBGMを流す、変える」、「この時に立ち絵を変える」といった多くの設定がテキストと紐づいてセットで配置でき、多くのアクションが開発者に分かりやすく設定できるようになった。
さらに、この時はシナリオ分岐のシステムを追加で実装した。選択肢が設定されている場合は表示し、押したらそれぞれのボタンに設定されている選択肢のルートに切り替わるようになった。これによりメインシナリオ -> 選択肢 -> ルートA -> メインシナリオというような選択肢によってシナリオが変わる、ということが可能になった。
そしてこのスクリプトが最新である。

#今回の機能の追加
まず、シナリオ分岐のシステムを改良した。今の仕組みでは、メインから分岐してさらにメインに戻ってくることはできても、ルートAの中でさらに別のルートに分岐する、ということはできない状態だった。これについては分岐を管理するbranchStackという配列を用意した。分岐に入るたびにそのシナリオとインデックスを保存していく。これにより、メインシナリオからルートA、さらにルートAからルートCへと深くネストした分岐も管理できるようになった。

let branchStack = []; // 分岐管理

// 選択肢が選ばれた時
branchStack.push({
  scenario: choice.branch,
  index: 0
});

これは、ルートAの中でさらにルートCに潜る時、以下のように動作する。

branchStack = [
  {
    scenario: [
      { text: 'a-1' },
      { text: 'a-2', choiceId: 'CorD' },
      { text: 'a-3' }
    ],
    index: 2  // a-2まで表示済み。CorDの選択肢を選んでルートCへ
  },
  {
    scenario: [
      { text: 'c-1' },
      { text: 'c-2' }
    ],
    index: 0  // cルートの最初
  }
]

問題は「戻る」の機能だった。今回のゲームから「戻る」や「セーブ」などのボタンをテキストの近くに配置した。様々なゲームの画面を見て、ないゲームも多いことに気が付いたのだが、「ノベルゲームらしさ」というものにこだわって実装することにした。

しかし、これが難しかった。一つ前の状態を一意に特定する機能はないため、メインシナリオ -> 選択肢 -> ルートAとたどったときに、これをルートA -> メインシナリオの選択肢が表示される地点と戻るというのは難しいのである。選択肢は以下のようになっていて、先程のテキストとのセットにchoiceIdが設定されている場合は(この場合は)二つのボタンを表示する。

選択肢ID: [
  {
    buttonText: '選択肢A',
    branch: [
      { text: 'a-1' },
      { text: 'a-2' },
    ],
  },
  {
    buttonText: '選択肢B',
    branch: [
      { text: 'b-1' },
      { 
        text: 'b-2',
        choiceId: 'whereToGo'
      },
      { text: 'b-3' },
    ],
  },
]

そしてこれを見ればわかるが、一つ前の状態を取得することが出来ない。 さらに、「選択肢を押すとヒロインのポイントが増加」みたいな機能があるときに選択肢より前に戻ると、ポイントの無限増加バグが発生する。

そのため、戻るボタンでは選択肢より前には戻らないという挙動が決定した。しかし、更に問題があった。

上のシナリオデータで、メインシナリオ -> 選択肢B -> 選択肢whereToGo -> whereToGoのシナリオ終了 -> 選択肢Bの続きの「b-3」ときて、ここで戻るを押した場合を考える。branchStackは以下のようになっている。

// whereToGoが終了した後
branchStack = [
  {
    scenario: [
      { text: 'b-1' },
      { text: 'b-2', choiceId: 'whereToGo' },
      { text: 'b-3' }
    ],
    index: 3  // b-3を表示中
  }
]

現在選択肢Bのシナリオにいて、「b-3」のテキストが来る3番目を表示中という情報しかない中で一つ前がwhereToGoルートの最後のテキストだと判断するのは難しい1

そのため、現在のbranchStackとは違う方法でこれまでたどってきたすべての履歴を保存しておく必要があった。ただ、選択肢より前に戻らない、という仕組みも必要であるため、以下のようになった。

let displayHistory = [];

// テキスト表示後に状態を記録
displayHistory.push({ 
  scenario: scenario, 
  index: index,
  branchStack: JSON.parse(JSON.stringify(branchStack)),
  openingStoryIndex: openingStoryIndex
});

多少無理やりな方法だが、この仕組みにより、複雑にネストした分岐シナリオでも選択肢の直後まで正確に戻ることができるようになった。

Footnotes

  1. 前に戻る時にchoiceIdが設定されていればwhereToGoの最後にいることはわかるが、whereToGoでどの選択肢を選んだかはわからない