画面外のスプライトを更新しない処理軽減(高速化)
ワールドマップなどの大きなマップを扱う場合、画面の表示されていない場所にイベントが設置される状況が起こりうる。このとき、RPGツクールVXAceでは離れたイベントは移動などの動作を更新しないという処理負荷軽減がデフォルトで行われている。
Game_Eventクラスの near_the_screen? メソッドがそれにあたる。
#--------------------------------------------------------------------------
# ● 画面の可視領域付近にいるか判定
# dx : 画面中央から左右何マス以内を判定するか
# dy : 画面中央から上下何マス以内を判定するか
#--------------------------------------------------------------------------
def near_the_screen?(dx = 12, dy = 8)
ax = $game_map.adjust_x(@real_x) - Graphics.width / 2 / 32
ay = $game_map.adjust_y(@real_y) - Graphics.height / 2 / 32
ax >= -dx && ax <= dx && ay >= -dy && ay <= dy
end
デフォルトのスクリーンサイズは544pix * 416pixで、横17チップ*縦13チップになる。画面中央から横幅12チップ、縦幅8チップの範囲を可視領域付近と判定しており、大体横は画面外3チップ、縦は画面外1チップ程度を超える場所にイベントがある場合は、そのイベントは移動しないようになっている(dx,dyの引数を変更すれば挙動を確認できる)。
しかし、実際に大きなマップを作ってみると、イベントから離れてもあまり処理負荷が落ちていないと感じる。要因はいろいろあると思うが、とりあえずマップの更新に関して何が処理負荷になっているか検証した。
【条件】
マップサイズ:290×200(かなり大き目を想定)
イベント数:106
イベント内容:場所移動のみ
この状態で、主人公がマップ上を歩行するときに、1回のscene.updateにかかる各処理の処理時間を、高精度CPUカウンタ(WindowsのAPI)を用いてマイクロ秒単位で計測した。Scene_Mapのupdateメソッドを引用する。
#--------------------------------------------------------------------------
# ● フレーム更新
#--------------------------------------------------------------------------
def update
super
$game_map.update(true)
$game_player.update
$game_timer.update
@spriteset.update
update_scene if scene_change_ok?
end
①super
Scene_Baseのupdate_basicにあたり、Graphics.update,Input.update,ウィンドウ更新を含むが、Graphics.updateがフレームレートまでウェイトをかけており処理負荷が見え辛いため今回は負荷の対象に含めない。
②$game_map.update(true)
マップの更新。乗り物、イベント、スクロールなどの更新を含む。
③$game_player.update
プレイヤーの移動更新。
④$game_timer.update
タイマー(ゲーム内で表示するもの)があれば更新。
⑤spriteset.update
イベントやピクチャなどのスプライトの位置などを更新する。
⑥update_scene
シーン遷移があった場合のみの処理
60fpsの場合、1フレームは16.66ms以内で処理を終わらせなければ、フレームスキップ(がくつき)が発生する。②③④⑤が和が16ms以内であればおそらく通常の歩行時にフレームスキップが起ころうことはない。とはいえ、処理は早めに終わる(軽い)ほうがCPUへの負荷が少なく、軽いにこしたことはない。
自分のPC(Core i5 670 3.4GHz)で計測した結果②③④⑤は、マップのどこを歩いていても大体以下のようになった。()内は1フレーム16.66msに対する処理時間の割合
②0.510ms(3.1%)
③0.058ms(0.3%)
④0.014ms(0.1%)
⑤1.620ms(9.7%)
合計 2.202ms (13.2%)
②はマップサイズとイベント数に依存するため改善の余地はあるが、とにかく⑤が重い。しかも、画面にイベントが表示されていないときでも処理時間が変わらないため、画面外のスプライトに対しても更新を行っているのではないかと予想できる。実際RGSSの処理を見るとそうなっている。(Sprite_Characterクラスのupdateメソッド)
ここで、Sprite_Characterクラスについても near_the_screen? メソッドをつくり、画面外のスプライトは更新しないようにしてみた。
#--------------------------------------------------------------------------
# ● 画面の可視領域付近にいるか判定(Game_Eventを参考に)
# dx : 画面中央から左右何マス以内を判定するか
# dy : 画面中央から上下何マス以内を判定するか
#--------------------------------------------------------------------------
def near_the_screen?(dx = 12, dy = 8)
ax = @character.screen_x/32 - Graphics.width / 2 / 32
ay = @character.screen_y/32 - Graphics.height / 2 / 32
ax >= -dx && ax <= dx && ay >= -dy && ay <= dy
end
引数のdx,dyは必要に応じて調整してもいい。@characterのスクリーン位置情報を参照するのがポイント。あとはSprite_Characterクラスのupdateメソッドに、
#画面外のスプライトは更新しない
return if !near_the_screen?
を追加すればいい。
処理を追加した結果、イベントから離れた場所では
②0.507ms(3.0%)
③0.050ms(0.3%)
④0.013ms(0.1%)
⑤0.684ms(4.1%)
合計 1.254ms (7.5%)
になり、全体で1msくらい改善された。CPU処理負荷もタスクマネージャで確認しても下がっているのが確認できた。
この方式では、横100pixなどの巨大なキャラグラフィックを持つイベントが、スクリーンに近づくまで表示されないといった不都合が考えられるが、32*32に収まるイベントしかないことがわかっている場合は、イベントが少ない場所での処理負荷軽減につながるだろう。
巷ではほかにもたくさんの負荷軽減処理が考え出されているが、とりあえず少ない変更で効果が大きい部分から手を加えてみるのが良さそうに思う。システムの根幹の部分は、下手に多くの箇所を変えると流用性が低下するし、思いもよらなかったバグを生み出しやすい。
柔軟なアイテムカテゴリ
ツクールVXAceのデフォルトメニュー画面のアイテムウィンドウで、標準のカテゴリは以下の4つである。
- アイテム
- 武器
- 防具
- 大事なもの
ここに、新たにオリジナルのカテゴリを追加したい場合もあるだろう。例えば、「アイテム」を、「薬」「食べ物」「本」の3種類にカテゴリわけして表示したい、とする。
このとき、VXAceのデータベースエディタのアイテム項目には、これら薬、食べ物、本を区別する項目が無いので、自分でこれらのアイテムを識別するための何らかのパラメータを追加せねばならない。設定値としては「アイテムタイプ」がそれにあたるが、通常と大事なものの2種類しか選択できず、独自に追加できない。
最も単純な方法としては、アイテムIDを区切ってしまうというものがある。例えば、ID1~100を薬、101~200を食べ物、201以降を本としてしまうもの。これを使えば、特に難しいことを考えなくても追加できそうである。しかし、もし開発の途中で薬が100種類を超えてしまったときにRGSSを修正せねばならないのは必至で、逆に食べ物を100個分用意したものの実際は20個しか使わず、残り80個分はデータベースの肥大化のみ影響する、という創作者にとってもプレイヤーにとってもあまり嬉しくない事態が起こりうる。
RPGツクールXPの網にも以前記載したが、今回のような独自パラメータには「メモ」を使うのが賢い。エディタ右下のメモ欄のことで、この内容は
Item.note
で参照できる。また、アイテムだけでなく RPG::BaseItem から派生しているもの(アクター、職業、スキル、アイテム、武器、防具、敵キャラ、状態)全てで使用することができる。
今回の例を実現しようとすると、まずアイテム蘭のメモに 薬 と記入する。一つのアイテムに複数のパラメータを使用したければ、改行で区切ったメモにしておくこともできる。このとき、RGSS側の item.note は \r\n をデリミタとする文字列を返してくるので、これをspritで区切って使う。Window_ItemListクラスのinclude?(item)メソッドの内容を、薬かどうか?の判定として以下のように修正する。
item.is_a?(RPG::Item) && item.note.split("\r\n")[0] == "薬"
この方法の利点は、開発途中で個数が増減してもRGSSの修正を必要とせず、データベースのアイテムのメモ編集だけで完結できるというところ。データベースのサイズにも無駄が無い。ほかにも、スキルのクリティカル発生率だとかオリジナルパラメータを追加したいときに使える。
ただし、上のアイテムの例で1点注意しないといけないのは、include?(item) メソッドの引数にはnilが入ってくることだ。装備選択で、装備しているアイテムを外す(素手にする)ときに「空欄」を選択しないといけないためで、make_item_listメソッドの @data.push(nil) if include?(nil)で記述されている(コメントが無いため何のための処理なのかわかり辛い)。nilに対して.noteは参照するとエラーになるので、ここは別に分岐しておく必要がある。もしくは、一度武器をつけたら素手にしなくてもよいわ、という人は上のdata.push(nil)をコメントアウトしてしまう、という方法もある。
RPGでギャンブル
RPGでゲームの中にカジノを作ろう。
と思って作ったこともある人もいるだろう。カジノに限らず、運や確率に左右されるものは失敗と成功が絡まり人間の興味をそそる。しかし、RPGならではの問題がつきまとう。
単純な例を挙げる。サイコロを2個振って両方とも1(ピンゾロ)が出たら掛け金20倍、それ以外だと掛け金没収という少しえげつないギャンブルを、RPGのゲーム内通貨を利用して行うシステムを作るとする。賭ける金額は自由で、一攫千金のチャンス。
このゲームを作ってしばらく遊んだとき、多くの人がこう考えるだろう。
「セーブしたあとに所持金全部賭けよう。掛け金が没収されたらリセットしよう。」
ほとんどのRPGは、セーブというシステムが存在し、ゲームの状態を保存できる。いわばこれは、あらゆる失敗をなかった事にするシステムと言える(プレイヤーがプレイにかけた手間と時間はなかったことにならないが)。
これにより、特にハイリスクハイリターンな要素がある場合、セーブとリセットを繰り返して最高の報酬を偶然に(必然に)出すことが出来、下手をするとその結果がゲームバランスに大きく影響してしまうこともある。
しかし、トライアンドリセットの繰り返しはもはや作業であって、ドキドキ感はなく、ギャンブルがギャンブルでなくなってしまっている。ここでRPG内でのギャンブルのドキドキ感を維持する方法をいくつか考える。
(1)オートセーブ
まず思いつくのがリセットをできなくしてしまう方法。賭けの結果がわかった瞬間に強制的にセーブしてしまえば、後戻りできなくなる。ギャンブル自体の緊張感は成立するが、いくつか問題点がある。まず、セーブは結果がわかった本当に直後でなければ意味がない。結果が表示されたあと、プレイヤーにセーブファイルを選択させているようでは、その間に安易にリセットされてセーブの強制力は無い。また、プレイヤーのためを考えるなら、ゲーム前に「強制セーブされるが良いか?」というアピールが必要。興味本位で所持金を全部かけて、結果表示後に後戻りできなくなると、どうしようもなくなる。ギャンブル性は維持されるが、少し使い勝手が難しい。
(2)リセットすると何かペナルティがある
カジノとセーブポイントとの間には険しい山脈があり、行くのに苦労する。この場合、カジノで負けてリセットすると失うものも大きいため、リセットは躊躇される。また、ポーカーのダブルアップのように引き際を選択できるのも手だ。次にはずすと今までの分はなくなるが、より利益も高く、この勝負の間はセーブができなければ良い。ただ、やはり元の掛け金が大きいと時間がかかってでもリセットしよう、という考えになることもある。
(3)常にオートセーブ
オンラインゲームやソーシャルゲームで主流の、とにかく何かあるごとにセーブされるシステム。後戻りが効かないため、逆にギャンブル性の高い仕掛けを多く出来る。ただ、RPGでは過去のあるシーンに立ち戻ってリプレイしたり、セーブポイントまで後どれくらいか見積もってアイテムの消費を抑えたりとセーブポイントありきの楽しみ方もある。また、宝箱を取り損ねて2度と取れなくなったりという心配もある。
(4)ローリスクローリターン
ドラゴンクエストVのカジノには1回ロールするのに1,10,100コインが必要な3種類のスロットマシンがあるが、10000コインのスロットマシンはない。10000コインで数倍のアタリが出るなら、10000コインたまった時点でトライアンドロードで何回も試せば一攫千金だから、それを設定していないのだろう。少ない掛け金で数多く試す方式の場合、時間もかかるためリセットの意味は薄れてくる。これも一つの手法だが、おそらく一般にハイリスクハイリターンのほうがギャンブル性が高く、多くの人を虜にする。
(5)乱数テーブル固定
この記事は、この乱数テーブル固定について書き留めておくために書いている。RGSSのランダムさを生み出す乱数生成:rand関数は、特に何も指示しなければおそらくPCが起動してからのミリ秒などからシードを作り、新たな乱数を返す。この乱数は、メルセンヌツイスタという方式で作られ、ほぼ現実でサイコロを振ったときと同じように振舞う。
上述のシードを指定することもできる。srand(シード値)。シード値(仮にXとする)が同じであれば、srand(X)命令後にrandで生成した値が、順にA,B,C・・・であったとするならば、また別のシーンで同じXを指定したあとのrandで生成する値は常にA,B,C・・・になる。これを使うと、ゲーム中の好きなタイミングで乱数テーブルを固定することができる。ただし、上述のA,B,Cはrand関数を呼ぶたびに常に次の値に更新されるため、たとえばスロットマシン開始時にsrand(X)したとしても、スロットマシン中にカジノ内でキャラがランダム方向に1歩動くと乱数生成値AやBは消費されてしまう。
しかしここで、ゲームの乱数とはまったく無関係に別の乱数テーブルを作り出す方法がある。
乱数テーブル = Random.new(seed値)
ここで返された乱数テーブルは、乱数テーブル.rand するたびに新たな乱数を返し、この組み合わせはゲーム内の他の乱数生成にまったく影響されない。しかも、セーブファイルに乱数テーブルを保存することができる。上の例で使ったシード値Xを使ってテーブルを生成し、乱数テーブル.randを呼ぶとAが返される。このあと、セーブファイルに乱数テーブルを保存し、コンティニューしてまた乱数テーブル.randを呼ぶとBが返される。その後、C、D、E・・・と決まったテーブルを呼び続ける。セーブできるのにいつか終端が繰るのではないか?と思うが、この乱数テーブルが返す乱数列はおそらく無限の長さがある。かといって無限のメモリを使っているわけではなくて、1からNまでの和がN(N+1)/2で求められるように、あるシード値から生まれた乱数列も呼ぶたびに一意の乱数を返すようになっているイメージになる。
これで何が出来るかというと、たとえばNEWGAME時にNEWGAMEをはじめたミリ秒数などから乱数テーブルを一つ作っておく。この乱数テーブルはセーブ時に保存されるようにする。そしてゲーム中盤で、とあるギャンブルがはじまったとき、確率計算にはこの乱数テーブルを使う。1回目ははずれ、2回目もはずれ、3回目に大当たりが出たとすると、何度リセットしても、やはり1回目ははずれ、2回目もはずれ、3回目に大当たりが出る。このゲームでは、NEWGAMEの瞬間にプレイヤーのくじ運を確定している。このギャンブルに対してもはやトライアンドロードは無意味で、大当たりを出すためには必ず3回やらなければならない。(新しくNEWGAMEを始めれば、そのゲームではもしかすると2回目で大当たりかもしれない)。これでロードすることなく、現実に近い形でギャンブルが楽しめる。
ただ、注意しなければならないのはサイコロで掛け金を自由に決められるようなもの。このとき掛け金全てに対して同じシードを使ってしまうと、1回目1円かけてはずれ、2回目1円かけてはずれ、3回目1円かけて大当たり10倍の10円。ときたときにリセットして、1回目と2回目に1円、3回目に所持金全額50万円かけて一躍大金持ちになれば良い。これはギャンブルでもなんでもなくただの予知で、乱数テーブルが固定されていれどもそのテーブルを予想できてはゲームにならない。
こういうときは、たとえば掛け金を10円、1000円、10万円の3種類に絞らせ、3つの乱数テーブルを用意しておのおのを割り当てると良いだろう。10円の賭けは10万円の賭けとテーブルが無関係なので、10万円であたりを出すにはセーブロードに関わらずNEWGAMEで定まった運による、ある一定の回数をトライしなければならない。
これらの方式は、ギャンブルだけではなく、ある一定の確率でランダムに効果が付与される何らかのイベントなどにも使える。製作者サイドから見れば、ファミコンなどではおそらく取りづらい手法で、PCならではだろうと思う。
メッセージウィンドウとFiber
RPGツクールVXAceから実装されているFiberで少しはまったので挙動をメモする。
VXAceのメッセージウィンドウはデフォルトで文章を1文字ずつ表示する(1文字表示ごとに画面が更新される)。これを実現するために、Window_Messageクラス内で以下のようなメソッドが構成されている。
①update:画面(ウィンドウ)描画更新のために定期的に呼ばれる。
②update_fiber:update内で更新パーツの一部として定期的に呼ばれる。
もしインスタンス変数@fiberがnilならFiberを新しく生成され、resumeによってfiber_mainで定義される子のコンテキストに制御が切り替わる。
③fiber_main:子のコンテキスト。ウィンドウを開いてから文章を表示し、閉じるまでの一連の処理が全て記述されている。
④process_all_text:全てのメッセージを描画する。
⑤process_character(WIndow_Baseで定義):制御文字を考慮した1文字を描画する。
⑥process_normal_character:通常の1文字描画を行い、メッセージウィンドウの場合は⑦を呼ぶ。
⑦wait_for_one_character:1文字描画後の処理が記述されている。Enterで瞬間表示判定がかからなかった場合は、Fiber.yieldで親に処理を返す。
Fiber.yieldの制御戻り先は@fiber.resumeの真下。描画に関してはここで処理が終了し、次の周期のupdateを待つ。fiber.resumeが次の周期で呼ばれたとき、③fiber_mainは、はじめからではなく前回のFiber.yieldの直後から再開される。(図中青矢印の着地点は、初回と2回目以降で異なる。)このときslice後の次の1文字が描画され、また処理が親に戻る。全ての文字を書き終わるまでこの制御の受け渡しを繰り返す。
構成の狙いとしては、fiber_mainはそれ単体でfiber.resumeのくだりを除いて「全文を描画処理する」という機能のみを記述し、途中で切りたいところにfiber.resumeを差し込んでいるというもの。文章の全表示という機能の中に、グラフィック更新タイミングのいざこざを巻き込まなくて良い、というシンプルな記述を得ることが出来る。ただし、yieldの戻り先をきちんとわかっておかないと、goto的な制御系が難解な構成になる。ためしにfiber.resumeをコメントアウトすると、瞬間表示と等しい処理になる。
さて、本来自分がチャレンジしていたのはメッセージの表示スピードを速くしたり遅くしたりすることだった。たとえば3文字を1度に描画したい場合は、yieldで制御を戻す頻度を3回に1回にすれば良い。下手にfiberの外側をいじると、メッセージウィンドウ表示動作全体の挙動がおかしくなる可能性がある。
RPGツクールVXAceで長い文字列をdraw_textできない
VXAceで画面をはみ出るレベルの長い文字列を描画しようとするとおかしな挙動になった。フローティングヘルプを作る際の障壁になっている。
VXとVXAceで同一の描画スクリプトを実行し、挙動を比較する。
RGSS:
@sprite1 = Sprite.new
@sprite2 = Sprite.new
@sprite1.bitmap = Bitmap.new(1000,40)
@sprite2.bitmap = Bitmap.new(1000,40)
@sprite1.bitmap.fill_rect(@sprite1.bitmap.rect, Color.new(0,100,100,255))
@sprite2.bitmap.fill_rect(@sprite1.bitmap.rect, Color.new(100,0,100,255))
text = "これはRPGツクールVXとRPGツクールVXAceで長い文字列の"
text += "表示動作検証を行うためのテスト文字列です。"
@sprite1.bitmap.draw_text(0,0,1000,40,text)
@sprite2.bitmap.draw_text(0,0,1000,40,text)
@sprite1.x = 20
@sprite1.y = 20
@sprite2.x = -400
@sprite2.y = 80
実行結果
【ツクールVX】
【ツクールVXAce】
VXAceは途中で勝手に折り返している。(このBitmapに再度draw_textするとGame.exeが例外で落ちる)。
現時点でこれを回避するためには、長い文字ではなく文字列を1字ずつ描画していくしかないようだ(文字表示 draw_text_ex の内部処理では、制御文字処理目的でprocess_characterという関数で1字ずつ処理されている)。
仕様なのだろうか。
暗号化されないAudio
ツクールVXの仕様で、Audioディレクトリのファイル(効果音および曲)とFontは暗号化されない。
Fontはおそらく配布で版権が絡むところもあるのだろう。Audioは、オリジナル要素を含む素材だけに画像が暗号化されるのに音声はそのままか、という意見もある。
音声が暗号化されない理由は大きく二つあると推測する。
1.復号化に時間がかかる
効果音についてはそうでもないが、曲のフォーマットOGGおよびWAVEは再生時にストリーム読み込みを行う。極端な例でいうと再生時間が60分の曲、標準OGGで60MB、WAVEで700MB程度の曲を読み込むにしても、ストリーム読み込みは再生分のデータを順次バッファするためすぐに再生が開始される。
ところが、何らかの方法でオーディオを暗号化してしまった場合、部分復号に対応したものでなければ、数MB以上あるであろうそれらのファイルを一旦丸ごと処理してから再生にうつることになる。この場合、曲再生直前に復号するにせよ、ゲームの開始・終了時にまとめて復号するにせよ、タイムロス(プレイヤーへの負荷)は大きい。
2.素材として加工しにくい
画像は切り貼りおよび編集が比較的簡単だが、MIDIは参考にするにしてもなかなかそれを素材として発展させることは難しく、OGGやWAVEに至ってはミキシングの技術がなければ編集はさらに困難になる。このことは、安易に他のゲームに流用しにくいことを示している。さらに、画像の場合はサムネイルなどで一瞬にして大画面に展開される(意図しないで見てしまう)可能性があるが、音声は意思を持って聞こうとしない限り再生されることは稀であり、全て聞くにしても時間がかかる。よって、画像のように事故で見てしまってゲームの楽しさ(新鮮さ)を損なうような、ネタバレ的なことは少ない。
これらのことから、自作RPGでは音声は自力のアルゴリズムで暗号化せず、そのまま配布しようと考えている。ただし、「音声ファイルの再生によってゲームの新鮮さが損なわれる可能性があるため、ゲームクリア後の視聴を推奨する」という注意書きをつける。
このあたりは、プレイヤーの判断に任せても良いところだとおもっている。あえて、クリアした人がファイルを携帯音楽プレイヤーに入れて聞きたいかもしれない。そこにも配慮すると、やはり無理な暗号化はプレイヤーフレンドリーでないと考える。
マップ数枯渇問題
RPGツクールVX、VXAceのマップ数上限は1000。(多分XPも)
マップ数が1000のときに新規作成しようとした場合「これ以上マップを作成できません」というダイアログが表示される。
この上限を破るには、ゲーム自体を二つのプロジェクトに分割するか、二つのプロジェクトでデータを作成し、RGSSで明示的にデータをスイッチすることで1000を越えるマップを使用する、という方法が考えられる。しかし、いずれも編集、管理、バグ要因の面からみてリスクが大きく、やはりここは1000以内になんとか抑えるのが適切な対策だと感じる。
そもそも、大抵のRPGではマップ数は1000以内に収まる場合が多い。下の表は自作のRPG GUST完全版のマップ数の詳細。
注)エリア数は町やダンジョンなど1つの地域をまとめて1と数える。マップ数はそれらに含まれる 塔1F などの各マップを全て数える
GUSTのマップ総数は404。GEがこの2.5倍以上の規模になるかというと、きわどいところ。町のマップが1エリアあたり平均7が少し多い気がする。武器屋、宿屋、~の家などそれぞれ別のマップを割り当てていることが原因だと推定される。もし町や非戦闘エリアのマップを内装と外装の2つに区分できれば、エリア数23に対しマップ数は46で済む計算になる。この場合115が未使用となり、総数は289。これなら規模3倍以上まで耐える。
しかし、仮に1マップに複数の部屋を置く構造にしたとしても、「宿屋」や「武器屋」の表示は個別に出たほうがプレイヤーにとって親切なつくりになる。これにはリージョンの割り当てで対応できると考えている。本来、リージョンは戦闘エリアの割り当てに使用するものだが、シンボルエンカウントのGEでは地域名の区分に使うことができる。
--------------------------
少し考えてみたところ移動イベントの直前に変数として宿屋 などを設定しそれを表示させたほうがスマートかもしれない。