「アクアノートキャンパス」というゲームについて
失われたゲーム(2本目)
![]() |
| プレイ画面例 |
「アクアノートキャンパス」(Aquanaut Campus)は、2002年にPleiades Company 牧尾様が公開したフリーウェアのアドベンチャーゲームです。
主人公は、T大学で海洋考古学を教える若き教授・島原涼一。半ば趣味で開いたゼミは実績も学生も乏しく、ついに大学から「期限までに成果を出さなければゼミ廃止」と通告されてしまいます。追い詰められた涼一は、たった一人の学生・青海凪を助手に、ある島へ発掘調査へ向かいます。行き先は「李尊島」——涼一自身が生まれ育った故郷でした。
プレイヤーは涼一となって、1週間という限られた期間で海底遺跡の発見を目指します。助手の凪、そして故郷で再会するもう一人のヒロイン・浜野さやかとどう関わっていくか——その選択が、遺跡発見の成否と物語の結末の双方を左右するといった構成です。
本作はクリックと選択肢で読み進めるノベルタイプのADVで、ラブコメの色合いを帯びた物語が展開しますが、軽いルートはともかく本質的にドシリアスな作品です。愛、自尊心、過去からの逃避、決断、しがらみ等々。
※ちなみに発表当時中学生だった私は凪ルートをぐるぐる回ってました🤔。
Pleiades Companyのブラウザゲーム第5弾。牧尾様の作品群が共有するひとつの世界に連なっています。
より詳細に知りたい方は以下の牧尾様のnoteを確認ください。
https://note.com/so_nosg/n/n173c0b4b2bdc
この作品も発表当時窓の杜・ベクターをはじめ複数のパソコン雑誌でも紹介されましたが、2014年2月12日、配信は終了。入手手段は静かに失われました。
ゲーム紹介の出典について
本節のゲーム紹介のうち、作品の位置づけや経歴に関する情報(Pleiades Companyブラウザゲーム第5弾、世界観の位置づけ、窓の杜・ベクターほか複数誌での紹介歴、2014年2月12日の配信終了など)は、以下を参照しました。
一次資料としては、ベクター 新着ソフトレビュー(2002年2月6日)、窓の杜「週末ゲーム」第153回(2002年11月8日)があります。
なぜ動かなくなったか
アクアノートキャンパスがErinyesと共通するのは「Flash(SWF)に依存している」という一点だけで、ゲームの構造そのものはかなり異なります。
Erinyesがframeset+多数のHTMLファイル+静止画(JPG)を主体とし、Flashを補助的に使う作りだったのに対し、アクアノートキャンパスは aqcan.hta というファイルから起動し、画面演出の大半を game.swf 一本に集約した、よりFlash依存度の高い構造になっています。
そもそもHTAとは
HTA(HTML Application)は、Microsoftが1999年、Internet Explorer 5と同時期に導入した仕組みです。HTMLとJavaScriptで書いたものを「.hta」という拡張子で保存すると、mshta.exe を通じて、Webページではなく「ローカルにインストールされたアプリ」のように実行できます。
通常のブラウザはセキュリティのため、ローカルファイルの読み書きなどを強く制限(サンドボックス化)しています。しかしHTAはその制限の外で動くため、ファイル操作やローカルリソースへ自由にアクセスでき、当時は「インストール不要で配れる手軽なアプリ開発手段」として重宝されました。アクアノートキャンパスが aqcan.hta から起動するのも、この時代の作法です。
裏を返せば、HTAは中身をIEのレンダリングエンジン(Trident)で描画するため、IEと運命を共にする技術でもあります。
消えた依存先
こうした背景から、動かなくなった原因はErinyesと少し異なります。
- 2020年末のFlash Player削除
→ 演出の中核だった game.swf・face.swf・効果音SWF がすべて再生不能に - HTA(mshta.exe = IEエンジン)依存
→ IEコンポーネントの終息、およびmshtaがマルウェアに悪用された経緯から、セキュリティ上ブロック・無効化される流れが進み、起動の土台そのものが不安定化 - 音源も当時のWMA/MIDIで、現代ブラウザでは素直に再生できない
「Flashが消えた」という入口はErinyesと同じでも、演出をFlashに預け、起動の器もHTAという旧世代技術に依存していた分、アクアノートキャンパスのほうが復旧のハードルは高いものでした。
復元プロジェクト2
引き続き原作者の牧尾様から現代化の許可をいただき、さらに今回は最終版(Vista対応版)のアクアノートキャンパスそのものをご提供いただいて、回収作業を開始しました。
Erinyesの経験があるぶん「今回は70時間ほどで終わるだろう」と見込んでいたのですが、見積もりは甘く、最終的に完全な復旧までに約130時間を費やすことになりました。特にRuffleが外部からのフレーム制御APIを公開していないため、game.swfを1,000本近くに分割するという力業が必要になり、その副作用(立ち絵の明滅やキャラの消失)を潰していく作業で、AIに何度も同じ画面を投げ返しては作り直す、という試行錯誤を延々と繰り返しました。「もう直ったはず」が何度ひっくり返ったか分かりません。
前回に引き続き改造の範囲はエンジン部分(JavaScript/CSS/HTML)に限定し、シナリオ・画像・音声等のゲームアセットは一切改変しない方針を貫いています。約130時間を経て、Chrome上で原作と同等に動作する状態になりました(※他環境は未検証)。
![]() |
| デバッグ例 |
現代化作業
Erinyesの移植は「Flash/IE時代の依存(SharedObject、getURL、Frameset、IE独自DOM…)を、現代のAPIへ置き換えて動かす」までが主戦場でした。アクアノートキャンパスの難しさはその先にあります。動かした後、Flash Playerが当たり前のようにやっていた「再生・描画・演出」を、Ruffleの制約の中でJavaScriptが肩代わりして、原作と区別がつかない見た目まで持っていく——ここが本番でした。
なぜそうなるのか。原作の画面制御コードを見ると、驚くほど短いのです。
// 原作 body.htm — 画面を切り替えるコアはこれだけ
function setposs(num){
drawsts=false;
if (!skip) {FLASH.GotoFrame(num);drawsts=true;} // 指定フレームへ一瞬でジャンプ
nowgrp=num;
if (skip) return;
if ((num>=200)&&(num<4400)) FLASH.Play(); // アニメ区間は再生
...
}
function getsts(){ return FLASH.IsPlaying(); } // 再生中か?
function getpos(){ return FLASH.CurrentFrame(); } // 今どのフレーム?
function face(num){ if (!skip) FACEF.GotoFrame(num); } // 顔も同じ
立ち絵も、顔も、効果音も、すべて XXX.GotoFrame(num) の一行。Flash Player(ActiveX版)という万能の再生エンジンに「Nフレーム目を出して」と命令するだけで、フレームのseek・描画・再生・停止・ループ管理を全部やってくれていました。原作のエンジンが2,000行に満たないのに本格的なADVとして成立していたのは、重い仕事をFlash Playerが肩代わりしていたからです。
ところが移植先の Ruffle(WASM版Flashエミュレータ)が公開しているのは play() / pause() / isPlaying どまり。原作が画面制御の軸にしていた GotoFrame(任意フレームへのseek)と CurrentFrame(現在フレームの取得)、そして「このフレームの描画が完了した」というイベントが無いのです(issueは上がっていましたが、開発陣が手を付けていないことから実装した場合セキュリティ上の懸念、つまりFlashplayerの欠点を引き継いでしまうためあえて手を付けていないのではないかと)。
再生・停止は命令でき、再生中かどうか(isPlaying)も取れる。けれど タイムラインのどこにいて・描き終えたのかが分からない——これが移植の最初の一歩で突きつけられた壁でした。以降の章は、その「消えた制御」を埋めるために生まれた知見です。本格的にFlashへ演出を預けた作品ほど、ここから先が長くなります。
A. フレーム制御APIが無い ― SWFを物理的に分割する
一番の土台が FLASH.GotoFrame(num) の不在です。原作は1本の game.swf(約1.8MB・全フレーム入り)の中を、シナリオコマンド #v N のたびに GotoFrame(N) で瞬間ジャンプして立ち絵やシーンを切り替えていました。Ruffleにはこの「任意フレームへのseek」を行うAPIがありません。
そこで取った力業が、game.swf を、参照される全フレームを起点に物理的に分割するというものです。最終的に 1,000本近い game_F####.swf に割り、setposs(N) は対応する分割SWFを丸ごと読み込むようにしました。
// 移植版 — GotoFrame(num) の代わりに「フレーム番号→専用SWFファイル」をロード
function setposs(num){
...
var info = _findSWF(num); // num に対応する分割SWFを探す
_loadGameSWF(info.file, info.frames); // game_F####.swf を load()
...
}
「1フレーム=1ファイル」という割り切りですが、代償は小さくありません。原作の GotoFrame がメモリ内の瞬間ジャンプだったのに対し、移植版は毎回ネットワーク経由でSWFをfetch+パースし直すことになります。この遅延が、次章以降の「明滅」「立ち絵の1テンポ遅れ」といった一連の問題の温床になりました。さらに、立ち絵、顔グラフィックが多数で、主なSWFの総数は原作の4個(game/face/se1/se2)から1,000本超へ膨れ上がります。
教訓:Ruffleでタイムライン制御が前提のレガシー作品を移植するなら、「1フレーム=1SWF」分割は現実解になります。ただしロードコストという代償と、それが連鎖的に生む描画タイミング問題を覚悟しておくべきです。
※Ruffle自体はMITのため、Forkは可能ですが公式が避けた方法をするわけにはいかず、今回は力業で対応します💪。
B. 同じRuffleでも、ブラウザによって7倍速度が違う
分割SWFを毎回ロードする構造になった以上、load() の速度は死活問題です。そして計測して最初に面食らったのが、まったく同じRuffle・同じSWFの load() が、ブラウザによって約7倍違ったことでした。
| ブラウザ |
load()(fetch+パース完了) |
|---|---|
| Edge | 約500ms(SWFサイズによらずほぼ固定) |
| Chrome | 約70ms |
両方ともChromium(Blink/V8)系なのに、です。立ち絵が「クリックの文字送りに1テンポ遅れる」という症状の正体は、このEdge側の固定オーバーヘッドでした。原因の特定は本稿の範囲を超えますが(WASM/WebGL初期化やセキュリティ機構の差が疑わしい)、移植の設計判断としては重い意味を持ちました。最終的に、配布を実用速度の出るChrome専用と割り切ることにしています(Erinyesでもそうでしたが、EdgeとChromeが互換性は少なくともフラッシュゲームに関しては厳しいものがありそうです)。
危なかったのは、Edgeの数字だけを見て「ロードが重いから先読みキャッシュを作ろう」と、画面外にもう1つRuffleプレイヤーを常駐させる二重バッファを実装しかけたことです。結果は立ち絵が消えた——Ruffleは2インスタンスを同時にロード/描画させると競合します。Chromeに替えれば遅延自体が消える問題に、丸ごと無駄な最適化を投げ込むところでした。
教訓:WASM/エミュレータの性能は、同系統ブラウザでも激変しえます。ベンチは必ず複数ブラウザで取る。1ブラウザの数字で設計を決めると、見当違いの最適化に突っ込みます。
C. 「描画が終わったか(settle)」が取れない ― 画面をピクセルで観測する
原作は FLASH.IsPlaying() で再生状態を、FLASH.CurrentFrame() で現在フレームを、いつでも問い合わせできました。Ruffleにも再生中かを返す isPlaying はあります。しかし 「今どのフレームを描いているか(CurrentFrame)」も「このフレームの描画が完了した」というイベントも無い。さらに厄介なのは、後述のstopが欠けた分割SWFは isPlaying が true のままループして明滅するため、「絵が止まった瞬間(settle)」を isPlaying では判定できないことです。再生状態は取れても、肝心の「描き終わり」が取れない。
これは想像以上に効きます。分割SWFの多くは元フレームに stop タグが無く、Ruffleで loop:false を指定しても最終フレームで止まりきらず、2〜3フレームをループして明滅する個体が出ます。明滅を止めるには「絵が安定した瞬間」に静止画を被せたいのに、その瞬間が分からない。
解決策は、状態を問い合わせる代わりに、画面のピクセルを直接観測することでした。canvasを32×24に縮小して全ピクセルをハッシュ化し、requestAnimationFrame で毎フレーム比較します。
// 概念:2フレーム連続で同じハッシュ=「絵が止まった(settle)」
if (hash !== null && hash === lastHash) stableCount++; else stableCount = 0;
lastHash = hash;
if ((hash !== null && stableCount >= 2) || elapsed >= maxWaitMs) {
onComplete(); // ここで現在の合成画面をcanvasに焼いて静止オーバーレイ化(明滅停止)
}
面白いのは、この同じfingerprintを逆向きにも使えることです。SWFを切り替えるとき、Ruffleは load() 中に一瞬canvasをクリア(透明)します。そこに古い静止画を被せておき、「ハッシュが変化した=新フレームを描き始めた」瞬間に被せを剥がす。こうすると、切り替えの黒フレームも、剥がしが早すぎて出る素地の露出も、両方消えます。
- 「止まった(settle)」=ハッシュが変わらなくなったを検知
- 「描き始めた(rendered)」=ハッシュが変わったを検知
固定時間のsleepで誤魔化すと、その分まるごと体感遅延になります。タイマーの数字をいくら詰めてもレースは消えません。描画完了イベントの無い環境(WASM/エミュレータ/iframe)では、状態をピクセルで観測して駆動するのが、結局いちばん確実でした。原作が1回の関数呼び出しで得ていた「再生状態」を、移植版は毎フレームのピクセル比較で推定し続けています。
D. Flashのテキストが、フォントごと描けない
原作のEDスタッフロールやセーブ画面のヘルプ、選択肢画面の操作説明——こうしたテキストは、SWFの中に Flash のテキスト機能(DefineText/DefineEditText)として埋め込まれています。Flash Playerはこれを当然のように描いていました。ところがRuffle上では、文字が fZ[fu…oæ のような文字化けになったり、丸ごと消えたりします。
SWFをバイナリ解析すると、原因が2種類あることが分かりました。
-
静的テキスト(
DefineText) … 字形(グリフ)の参照で描かれる。該当フォントを注入すれば復活します。 -
動的テキスト(
DefineEditText)でuseOutlines=0… 「埋め込みフォントの字形を使わず、OSのデバイスフォントで描け」という指定。Ruffleは日本語デバイスフォントを持たないので化けます。フォントを注入しても、useOutlinesを立て直しても描けません。
後者が厄介で、最小修正に見えた「フラグのビットを 0→1 に書き換える」では直りませんでした。最終的な現実解は、SWFから原文テキストをデコードして抽出し、同じ位置・同じフォントサイズのHTMLオーバーレイで再現することです。
# SWFのDefineEditTextタグをパースして初期テキスト(htmlText)を抽出
# <font face="MS明朝" size="40"> … → CSSの font-size:40px にそのまま変換できる
# ※Flash5の日本語CodeTableはShift-JISコード格納。Unicode扱いだと全部化ける
このとき地味に踏んだ罠が、ページ全体のCSS(div { position:absolute })が後付けの各行divを全部1点に重ねてしまう問題で、id付き <style> を詳細度で勝たせて回避しました。
教訓:Ruffleで文字が出ないときは、まず DefineText(静的)か DefineEditText(動的)かを見分けること。静的は同IDフォント注入で直り、動的(EditText)はオーバーレイ再現が唯一解です。「フォントが無いから」と一括で片付けると、片方は永遠に直りません。
E. 色変換・フェードが、原作通りに描かれない
ここまでは「APIが無い」話でしたが、Ruffleには「あるはずの描画が、原作と違う結果になる」落とし穴もあります。代表が CXFORM(カラートランスフォーム)――Flashが色や透明度をアニメーションさせる仕組みです。
物語終盤の回想に、「船の航行 → 画面が白くフェードイン → 白から黒へフェード」という演出があります。原作ではFlash PlayerがSWF内のCXFORM(Am=-256 による負方向のアルファ加算でのフェードイン、Ra:255→0 での白→黒)を素直に描いていました。ところがRuffleは、この負方向系のCXFORMを原作通りに描画しません。白が完全に乗らず下に隠したはずの船が透けたり、白から黒へ遷移しきらず白のまま固まったりします。
最初は「canvasごと隠せばいい」と乱暴に消したら、フェード演出そのものが死にました(白い塗りつぶしになる)。次に「不透明な白い <div> を被せて船の露出を覆う」方式にしましたが、被せるタイミングを固定ミリ秒で当てずっぽうに調整して迷走。最終的に効いたのは2つの発想でした。
-
タイミングはフレーム数から逆算する。
durationMs = ceil(frameCount / fps × 1000) + 余白で再生の終点を計算し、「終点の200ms手前」で白を被せる。固定msの勘で合わせるのをやめる。 -
白→黒もRuffleのCXFORMに頼らず、白い
<div>のbackground-colorをCSS transitionで黒へ遷移させる。エミュレータの色変換を一切使わずに、同じ見た目をブラウザ標準のCSSで作り直す。
教訓:WASM版Flashエミュレータのフィルタ・色変換は完全ではありません。負方向系のCXFORM(アルファ減算・色減算によるフェード)は原作と乖離しやすいので、重要な演出はRuffleに任せず、JS/CSSのオーバーレイで作り直すのが確実です。そして「隠す(非表示)」より「覆う(overlay)」を選ぶこと――非表示は演出ごと殺します。
F. stopタグが欠落して、勝手にループする
Flashのタイムラインには「ここで止まれ」という stop アクションが置かれ、Flash Playerはそれを解釈して最終フレームで停止していました。例えばセーブ画面に入るときの「Aquanaut Campus」ロゴは、登場アニメののち最終フレームで止まって鎮座します。
ところが A章でSWFを物理分割した際、元フレームにあった stop アクションが分割後のSWFから欠落する個体がありました。Ruffleは停止できず2周目を再生してしまい、「鎮座したはずのロゴが、一瞬また最初から出てくる」「日付の切り替えアニメが繰り返される」といった症状になります。
解決は、SWFのバイナリに stop アクションを注入することでした。最終フレームの直前に DoAction(stop)(バイト列 [0x02,0x03,0x07,0x00])を書き込み、Flash時代の「止まれ」を物理的に復元します。セーブ画面のロゴ、画面を畳むアニメ、日付遷移など、stopが落ちていた箇所を1つずつ特定して注入しました(アセット改変はしない方針なので、演出用SWFの制御タグのみの修正です)。
教訓:タイムラインを物理分割すると、stop のような制御アクションが境界で落ちることがあります。「ループする」「鎮座しない」演出を見つけたら、分割後SWFの最終フレームにstopが残っているかを疑うこと。
G. 高速送り(スキップ)が、原作に無かった種類のバグを生む
最後は、A章の分割が間接的に生んだ問題です。原作のスキップは何も特別ではありませんでした。FLASH.GotoFrame(num) は瞬間ジャンプなので、高速送り中も普通に指定フレームへ飛ぶだけ。コストがゼロに近いからです。
// 原作:スキップ中でも GotoFrame は一瞬。何の工夫も要らない
function setposs(num){ if (!skip) {FLASH.GotoFrame(num); ...} ... }
移植版は事情が違います。setposs のたびに分割SWFを fetch+パースするコスト(B章)があるため、高速送り中は重いSWFロードを省略する最適化を入れています。これが新種のバグを連れてきました。
- 背景が切り替わらない:立ち絵SWFは背景JPEGを内蔵していて、前面のcanvasが古い背景を保持し続ける。高速送り中はSWFを更新しないので、背面の背景レイヤーだけ差し替えても前面に隠れて見えない。
-
エンディング後、タイトルのOPアニメが再生されない:ED中に押したスキップのフラグが、タイトル復帰関数のリセット漏れで持ち越され、
setposs冒頭の「スキップ中は重い処理を省く」早期returnに弾かれて、ロゴSWFの再生処理に到達しない。
どちらも原作には存在し得なかった問題です。Flashが瞬間ジャンプでやっていたことを、コストのある仕組みで肩代わりしたからこそ生まれた副作用と言えます。対処は2つの型に落ち着きました。
- 問題の箇所だけ高速送りを強制解除して通常経路に戻す。原作にも「スキップ不可ムービー」コマンドがあったので、それと同じ意味論に乗せる。
- モードを抜ける関数では、スキップ等のフラグを必ずリセットする。1つの出口関数でリセットを忘れると、別経路でフラグが生き残って遠くで誤動作します。
教訓:「出力が出ない(背景が変わらない/OPが流れない)」バグは、出力命令の不足より先に「処理の入口に到達しているか(早期returnやガードに弾かれていないか)」を疑う。フラグは「いつ立てるか」より「いつ落とすか」を設計すること。
まとめ ― Ruffleは「Flashの完全な代替」ではない
Erinyesでも書いた通り、Ruffleは素晴らしいプロジェクトで、SWFを現代ブラウザで動かす唯一の現実解です。しかし本作の移植を通して見えたのは、Ruffleは「SWFを再生するエンジン」であって、「Flash Playerが提供していた制御環境の代替」ではないということでした。
原作がFlash Playerに預けていた仕事を並べ直すと、移植で何を肩代わりしたかがはっきりします。
| Flash Playerが暗黙にやっていたこと | Ruffleでの扱い | 移植での肩代わり |
|---|---|---|
GotoFrame(N):任意フレームへseek |
API無し | SWFを1,000本近くに物理分割(A) |
| 速い再生 | ブラウザで7倍差 | Chrome専用と割り切り(B) |
CurrentFrame/描画完了・settle |
API・イベント無し(isPlayingでは明滅と区別不可) |
ピクセルfingerprintで観測(C) |
| デバイスフォントのテキスト描画 | 描けない | SWF解析→HTMLオーバーレイ(D) |
| CXFORM(色・透明度アニメ) | 負方向系を誤描画 | JS/CSSオーバーレイで再現(E) |
stop アクションでの停止 |
分割で欠落 | バイナリにstop注入(F) |
| (瞬間ジャンプ前提のスキップ) | 重いロードで再現 | スキップ強制解除・フラグ管理(G) |
Erinyesの移植が「Flash/IE依存を現代APIへ置き換えて動かす」までだったのに対し、アクアノートキャンパスは「動いた後、Flashが裏でやっていた7つの仕事を、1つずつJSで再発明する」作業でした。演出を game.swf 一本に集約した、Flash依存度の高い作品だったぶん、その肩代わりが重くのしかかったわけです。
「Erinyesの経験があるから早く終わるだろう」という見積もりは大きく外れ、約130時間かかりました。1,000本近い分割SWF、手動でのバイナリ書き換え、そして同じ画面を「もう直ったはず」と何度もひっくり返しながらの試行錯誤。その大半が、この7つの章に書いたような「Flashが当たり前にやっていたことの再現」に費やされています。
本格的にFlashへ演出を預けたゲームの移植は、「Ruffleを入れて動かす」ところからが、本当のスタートでした。
おわりに
なんとかアクアノートキャンパスの移植版を出すことができました。
正直途中で無理かなあと思ってましたが何とかなるものですね💪。
引き続きちょっとずつ『時の故郷』の移植にとりくんでいければと。
※ちなみにファイルサイズが地獄のようにでかくなってますが👀
はい、今回は250MBあります<(_ _)>。
主にruffle&game.swfを重複込みで細かく刻んだせいですね。
原作がリリース時点で1.44MBに入るサイズだったことを考えると隔世の感がありますが💦。
推奨動作環境:
AMD Ryzen7 5700x以上を推奨です。
※Ruffleをフルに動かしてるので、旧型CPUだと一部動作が飛ぶ可能性があります。
その場合設定から軽量モードを試してみてください。
いい再現具合でしたら、できればGitHubのstarお願いします(Erinyesのほうもできればお願いします👍)。
移植版のGithubは以下となります。
https://github.com/shinohara-tsukasa/AquanautCampus
DL直リンクは以下となります。
https://github.com/shinohara-tsukasa/AquanautCampus/archive/refs/heads/main.zip
他)以下工事中、方針決まったら活動支援のBooth開くときもありますので、
その場合よろしければご支援いただけると幸いです。
※現在無効にしています<(_ _)>
可能であれば引き続きの作業のためのBoothの活動支援をお願いします。
もし活動ご協力いただける方はBOOTHのほうから支援ください<(_ _)>。
AIツール代金として使用させていただきます。
https://shinoharatsukasa.booth.pm/items/8540770

