Player アーキテクチャ

Romergo Player が通常の DOM で動作する理由

ビジュアルノベル エンジンは通常、3D シューティング ゲームやリアルタイム ストラテジー ゲームのエンジンほど複雑ではありません。広い世界の物理学、何百ものオブジェクトの動作、複雑な照明を毎秒計算する必要はありません。しかし、クリックしたときに画像を単に交換するだけで十分というわけではありません。

典型的なビジュアルノベルのシーンは、非常に理解しやすい一連の要素で構成されています。

すべての魔法は、これらのパラメータを時間の経過とともに変更することで生まれます。重要なのは、何を見せるかだけではなく、いつ見せるか、何をそのままにするか、シーン間でどのように音楽を継続するか、どの言語を使用するか、プレイヤーのアクションの後にどこに行くかということです。

長い時間をかけてエンジンを探し、ブラウザを選びました

Godot、PixiJS、Three.jsを使ってシーンを構築してみました。成功の度合いは異なりますが、すべて正常に動作し、エンジン自体には問題はありませんでした。しかし、クエストの作成方法に関しては、別のレイヤーが追加され、個別に交渉する必要がありました。

完成したゲーム画面以上のものが必要でした。 Builder のすべての変更をすぐに確認し、ゲーム全体をビルドせずに単一のシーンを起動し、プレビューで要素を直接選択して移動し、公開された Player で同じ動作を取得したいと考えていました。

最終的に、最も効果的な解決策は最も単純な解決策、つまりブラウザの通常の DOM でした。

背景、文字、ダイアログは、使い慣れたブラウザ レイヤーのままです。ほとんどのコンポジションとエフェクトは CSS で構築されています。 Canvas 2D は個々のビジュアル アーティファクトに使用され、画像間のシェーダー遷移が本当に必要な場合は WebGL キャンバスが接続されます。

サウンドはブラウザの HTMLAudioElement を通じて機能します。ランタイムは、アクティブなクリップのオーディオを作成し、ボリュームとループを制御し、アセットが変更されていないトランジション中に同じ音楽を保持します。

これはすべてのゲームに共通するレシピではありません。しかし、ビジュアルノベルの場合、DOM は妥協ではなく、非常に正確なツールであることがわかりました。

シンプルなシーンとスマートなランタイム

私は頭の中で Player を 2 つの部分に分割します。

シーンでは、特定のキャラクターがなぜ登場したのか、どの条件で答えが得られたのか、次にどの章が来るのかを知る必要はありません。クリップ、現在の時刻、言語、画面モード、メディア参照を受け取ります。次に、結果をレンダリングし、プレイヤーのアクションを返します。

ランタイムはクエストの説明とプレイスルー状態を受け取ります。現在のチャプター、ノード、シーン、変数値、行われた選択、チェックポイント、および完了したチャプターを認識します。プレイヤーがシーンをクリックするか、回答を選択すると、ランタイムはエフェクトを適用し、次のステップを見つけて、シーンを再び準備完了状態にします。

非常に単純化した形式では、ループは次のようになります。

Quest JSON + saved progress
          ↓
runtime determines the current step
          ↓
the scene displays clips and media
          ↓
the player's action returns to the runtime
          ↓
the next step — and the loop repeats until the ending

公開されたゲームでは、Player はクエスト全体の固定ランタイム スナップショットをロードします。 Builder では、プレビューは現在のドラフトから互換性のあるランタイムを作成します。したがって、これらは徐々に動作が異なる 2 つの類似したプレイヤーではなく、1 つのプレイヤーが RuntimePlayerSceneStage、および異なるシェル内のビューポートを共有していました。

シーン間の連続性がどのように保たれるか

遷移中に、ランタイムは新しいシーンの状態を再計算します。繰り返される音楽はアセット ID によって認識され、新たな開始や別のフェードインを行わずに継続されます。ブラウザは画像をロードしてキャッシュするため、背景の再利用はネットワークからファイルを再度ダウンロードすることを意味しません。

したがって、最適化は 1 つの大きな「何も送信しない」状態ではなく、適切な層で行われます。ランタイムは再生の継続性を維持し、リゾルバーは安定したリソースを返し、ブラウザのキャッシュは既知のメディアを再度ダウンロードしません。

1シーンに2画面

私にとって最も厄介だったのは、レンダリング自体ではなく、シーンをモバイル向けに最適化する作業でした。横長のシーンを単純に縮小すると、キャラクターが小さくなりすぎ、ダイアログが窮屈になり、背景の重要な部分が画面外へはみ出しやすくなります。

Romergo では、シーンに水平 1280 × 720 と垂直 390 × 693 という 2 つの固定仮想ビューポートがあります。 Player は、デバイスと方向に基づいて適切なモードを選択し、完成したシーンを全体としてスケーリングします。

デスクトップ レイアウトがメインのままです。電話機に対して個別の文字位置とスケールを設定できます。オーバーライドがない場合は、デスクトップ バージョンがさらに削減されて使用されます。したがって、作成者は 2 つの独立したシーンではなく、ターゲットを絞ったモバイル調整を行った 1 つのシーンを編集します。

背景については、bgXbgY、スケールが両方のモードで共有されますが、垂直ビューポートは同じ画像を独自の方法でトリミングします。したがって、リリース前に、両方のフォーマットでフレーミングをチェックし、共有の焦点を選択する必要があります。個別のモバイル バックグラウンド設定はランタイム契約の一部ではありません。

反復的なキャラクター調整は MCP を通じて AI エージェントに任せ、両方のビューポートの実際のプレビューで確認できます。だからこそ、この最適化は完全自動ではなく半自動なのです。

プレイヤーに黒い画面を表示しないようにする方法

必要な画像がネットワーク経由でまだ到着していない場合、単純なシーンは役に立ちません。したがって、読み込みも実行時契約の一部になりました。

最初のレンダリングの前に、Player は開始シーンのアクティブなメディアを収集し、それらがロードされるのを待ちます。この間、プレイヤーには空の背景ではなく、適切な進行状況インジケーターが表示されます。起動後、ランタイムは少し待機し、現在のシーンと次の 2 つのグラフ ステップ内で到達可能なシーンのリソースをウォームアップします。デフォルトでは、深さは 2 つの遷移先であり、両方のステップで考えられるすべての分岐が含まれます。

プリロードは遷移グラフに従い、その深さは個別に設定できます。ここでは固定数のシーンは使用されていません。形式的には同じ数のステップが進んでいる場合でも、線形シーケンスと分岐では異なる負荷が作成されます。

オフラインプレイ用の別のモードもあります。ユーザーは事前にクエスト全体をダウンロードできます。実行時スナップショット、メディア、および PWA シェルはブラウザのキャッシュに残り、ネットワーク接続なしで開きます。次のシーンをプリロードするとスムーズなオンライン プレイが提供され、完全にダウンロードすると真のオフライン プレイが提供されます。

シェルとしての Telegram、別のシステムとしての Discord

私のブラウザへの愛は、Player を Telegram および Discord に埋め込むときに特にうまくいきました。どちらの場合も、同じ Web ランタイムがプラットフォーム内で開くため、シーンとプレイスルー ルールを新しいゲームエンジン用に書き直す必要はありません。

Telegram では主に、プラットフォーム セッション、クエストの起動、ローカルの進行状況、ミニアプリと通常の Player 間のナビゲーションが必要でした。ゲーム自体は同じままでした。

Discord は、複数の人がストーリーの同じポイントを見る必要があるため、より複雑です。すべての参加者はローカル ランタイムを実行しますが、ホストが信頼できる情報源です。ホストの Player は、変更が発生したとき、および約 750 ミリ秒ごとに状態を送信します。リアルタイム ルームは WebSocket 経由でコマンドを受信し、スナップショットを保存して、参加者にブロードキャストします。スペクテーター ランタイムはリモート状態を適用し、シーンを復元し、同期間のローカル タイムキーピングを継続します。

この分離により、言語と画面サイズはローカルのままでありながら、ストーリーの状態が共有されるという有益な結果が得られました。 1 人の参加者は携帯電話でロシア語でシーンを表示し、もう 1 人の参加者は大画面で英語でシーンを表示でき、どちらもホストと同期したままになります。観客は、2 番目のホストにならずに、回答の選択肢に投票することもできます。

すべてのモードに 1 つの基盤

Player は、「背景、キャラクター、セリフ」という基本セットをはるかに超えています。これには、分岐、変数、チェックポイント、巻き戻し、インタラクティブ インターフェイス シーン、翻訳、オフライン プレイ、同期プレイスルーが含まれるようになりました。

しかし、基本的な決定はその成長を生き延びました。シーンは引き続き画像とサウンドを処理します。ランタイムは引き続き状態と遷移を処理します。ブラウザ API はレンダリング、メディア、キャッシュ、埋め込みをカバーし、特殊なレイヤーは本当に必要な場合にのみ追加されます。

このため、Builder プレビュー、テスト、通常のブラウザ プレイ、Telegram、および Discord 全体で同じ Player を維持できます。私にとって、これは、責任の境界線が正しく引かれていれば、最も単純でやや率直な解決策が、どのようにして最も柔軟な解決策になり得るかを示す良い例です。

開発ブログに戻る