5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ruffleを入れただけでは動かない ― Pleiades Company様製のErinyesを現代ブラウザで蘇らせて分かったこと【原作者様許可済・GitHub公開中】

5
Last updated at Posted at 2026-03-20

「Erinyes」というゲームについて

ゲーム紹介に興味のない方は「Ruffleでできること・できないこと」まで、ゲームを遊びたい方は「おわりに」まで飛ばしてください。

失われたゲーム

Erinyes」(エリニュエス)は、2003年から2004年にかけてPleiades Company 牧尾様が開発したフリーウェアのアドベンチャーゲームです。謎の島に拉致・監禁された主人公達が脱出を試みる本格サスペンスで、全9章・エンディング20種という大作でした。

画面クリックによる探索、アイテムの装備・使用、キャラクター切り替えによるマルチ視点プレイなど、「読むだけのノベルゲーム」とは一線を画す、行動の組み合わせで物語が分岐する純粋なアドベンチャーゲームです。

image.png
プレイ画面例

窓の杜では「フリーソフトとは思えないほど完成度の高い作品」、Vectorでは「現在ではめずらしい本格派の純粋アドベンチャーゲーム」と評されました。2011年には窓の杜ソフトライブラリーに収録(当時ADVはわずか3作のみ)。

しかし、2014年にPleiades Companyの公式サイトが閉鎖され、Vectorからもダウンロードが停止。このゲームは事実上、入手不可能な状態になりました。

なぜ動かなくなったか

Erinyesの動作環境は Internet Explorer 5以降 + Macromedia Flash Player です。

  • 2020年末:Adobe Flash Playerが全ブラウザから削除
  • 2022年6月:Internet ExplorerがWindowsから完全に削除

この2つの依存先が消滅したことで、Windows 10/11環境ではErinyesを起動することすらできなくなりました。

技術的に見ると、Erinyesは単なる「Flash SWFファイル」ではありません。HTMLフレームセット+JavaScript+Flash SWFが三位一体で動くブラウザアプリケーションです。IE独自のDOM API、Flashのローカル保存機能(SharedObject)、MIDI再生、JavaScript↔Flash双方向通信など、2004年当時のIE+Flashエコシステム全体に依存していました。

復元プロジェクト

2026年初頭、原作者の牧尾様に連絡を取り、現代ブラウザ対応化の許諾を得ました。改造の範囲はエンジン部分(JavaScript/CSS/HTML)のみとし、シナリオ・画像・BGM・ムービー等のゲームアセットは一切改変しないことを条件としています。

改造にあたっては、ゲームロジック・シナリオスクリプトの命令体系・セーブデータ形式も全て原作のまま維持し、プレイヤーから見て原作と区別がつかないレベルの復元を目指しました。

約60時間の改修を経て、Chrome上で原作と同等に動作する状態になっています。


Ruffleでできること・できないこと

RuffleはFlash SWFをWebAssemblyで再生するオープンソースのエミュレータで、「Flashの救世主」として広く知られています。単体のSWFアニメーションや簡単なFlashゲームであれば、Ruffleを読み込むだけで確かに動きます。

しかし、Erinyesのような本格的なブラウザゲームを丸ごと復元しようとしたとき、Ruffleだけでは解決できない壁に何度もぶつかりました。以下、具体的に何が動かなかったか、そしてどう対処したかを記録します。


1. JS→SWF変数セット(SetVariable)が効かない

何が起きるか: JavaScriptからswf.SetVariable("SWFNAME", "value")でSWF内の変数を書き換える操作が無視されます。

Erinyesでの実例: スタッフロール(eroll.swf)は内部にGetSubmovie()というJavaScript関数をgetURLで呼び出し、その戻り値をSWFNAME変数にセットしてもらうのをwhileループで待つ構造でした。

SWF内部:
  getURL("javascript:GetSubmovie()")   // JSに「ファイル名を教えて」と要求
  while (SWFNAME == "") { }             // JSがSetVariableで書き込むのを待つ

RuffleはSetVariableに対応していないため、SWF側のwhileループが永久に回り続けて画面が固まります。

対処: SWFをバイナリエディタで開き、待機ループのActionScript(DoAction)を0x00で上書きしてNOP化しました。eroll.swf、ebad.swf、egood.swfの3本に対してそれぞれパッチを適用。映像部分はwhileループを除去すれば単体で再生されるため、BGMだけHTML5 Audioで独立再生する方式に切り替えました。


2. SWF内からの外部SWFロード(loadMovie)が動作しない

何が起きるか: SWF内部のActionScriptからloadMovie("other.swf")で別のSWFを読み込む処理が実行されません。

Erinyesでの実例: egood.swf(トゥルーエンディング用映像)はloadMovie("../swf/JIMAKU系.swf")で字幕用SWFを読み込み、本編映像の上に重ねて表示する構造でした。

対処: egood.swf側のloadMovie処理をNOP化し、代わりに**JavaScriptで2つのRuffleプレイヤーを重ねる「overlay方式」**を実装しました。

// egoodの上にed20.swfを透過で重ねて同時再生
var player = ruffle.createPlayer();
player.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;" +
                        "z-index:9999;pointer-events:none;background:transparent;";
player.volume = 0;  // overlay側はミュート(音声はegood側のみ)
player.load({ url: "../swf/ed20.swf", wmode: "transparent" });

3. getURL("javascript:...")の実行が不安定

何が起きるか: SWF内からgetURL("javascript:someFunction()")でJavaScript関数を呼び出す機能は、Ruffleでは動作する場合としない場合があります。

Erinyesでの実例:

  • getURL("JavaScript:EndofMovie()")動作する(ムービー終了通知)
  • getURL("javascript:gettitle()")動作しない(ebad.swfのエンディングタイトル取得)

同じgetURLでも、SWF内での呼び出しタイミングや大文字小文字の違いで挙動が変わるため、「このSWFは大丈夫だろう」という予測が立てられません。

対処: getURLに依存する処理は全てJavaScript側で代替実装しました。SWFの再生時間はSWFヘッダ(FrameCount÷FrameRate)から算出し、setTimeoutで終了を検知。getURLが先に発火した場合はタイマーをキャンセルする二重発火防止機構を入れています。

// SWFヘッダから再生時間を計算
function MK_parseSWFDuration(url, callback) {
    fetch(url).then(r => r.arrayBuffer()).then(buf => {
        // FWSv5ヘッダからFrameCount, FrameRateを読み取り
        var durationMs = (frameCount / frameRate) * 1000;
        callback(durationMs);
    });
}

// タイマーで終了検知(getURLが先に来たらキャンセル)
MK_parseSWFDuration(url, function(ms) {
    el.__endTimer = setTimeout(function() {
        EndofMovie();
    }, ms + 500);
});

4. Flash SharedObject(ローカル保存)は存在しない

何が起きるか: Flash PlayerのSharedObject(ブラウザのCookieに相当するFlash独自の永続ストレージ)をRuffleは実装していません。

Erinyesでの実例: 元のゲームはlocal.swfというFlash SWFを介して62個の仮想ファイルにセーブデータを読み書きしていました。起動時にFlashのPercentLoadedを監視するループで読み込み完了を待ち、61回のコールバックで1ファイルずつ読み出すという複雑な非同期チェーンです。

対処: Flash依存を完全に除去し、localStorageによる同期的な一括読み込みに置換しました。元のデータ形式("section=escapedData#")はそのまま維持し、セーブデータの互換性を保っています。

// 旧: Flash PercentLoaded待ちループ → 62ファイル逐次読み出し → initkarnel()
// 新: localStorage同期読み込み → initkarnel() 直結
function LF_Init() {
    for (var i = 0; i < 62; i++) {
        LF_FlashReg[i] = localStorage.getItem("erinyes_" + i) || "";
    }
    initkarnel();
}

5. polyfillsモードをONにするとページ全体が壊れる

何が起きるか: Ruffleのpolyfills: true設定は、ページ内の全<embed>/<object>タグを自動的にRuffleプレイヤーに置換します。これは単純なFlashコンテンツの復旧には便利ですが、ゲームエンジンがJavaScriptからFlash APIを呼び出す構造だと、UIが完全に崩壊します。

Erinyesでの実例: 元のゲームはSE(効果音)再生用にse.swfを2つ、ムービー再生用にmovie.swfmovief.swfをそれぞれ<embed>で配置し、JavaScriptからSetVariableGotoFramePlayの順でAPI呼び出しする構造でした。polyfillsをONにするとRuffleがこれらを勝手に置換してしまい、ゲームエンジンのAPI呼び出しが全て失敗します。

対処: polyfills: falseを必須設定とし、SWFの再生は全てJavaScript APIで明示的に制御。元のFlash embedはダミーのdiv要素に置き換え、SetVariable/GotoFrame/Play等のメソッドをJavaScriptでスタブ実装しました。

window.RufflePlayer = window.RufflePlayer || {};
window.RufflePlayer.config = {
    polyfills: false,  // ← これがないとUIが崩壊する
    autoplay: "on",
    splashScreen: false
};

6. Ruffleプレイヤーがクリックイベントを吸い込む

何が起きるか: Ruffleのプレイヤー要素はshadow DOMで構築されており、通常のDOM要素とは異なるイベント伝播をします。プレイヤーの上に透明なdivを重ねても、クリックイベントがRuffle内部に吸収されてdivまで届かないことがあります。

Erinyesでの実例: ムービー再生中にクリックでスキップする機能を実装する際、Ruffleプレイヤーの上にz-index:999のoverlay divを配置しましたが、クリックが効きませんでした。

対処: documentレベルのcaptureフェーズでクリックイベントを拾うフォールバックを追加しました。Ruffleのshadow DOMより先にイベントを捕捉します。

// Ruffleがクリックを吸収するため、captureフェーズでmovie中のクリックを拾う
document.addEventListener("click", function(ev) {
    if (nowmode === "movie") {
        SkipMovie(105);
    }
}, true);  // capture: true が重要

7. ブラウザの自動再生制限(Audio Unlock)

何が起きるか: 現代ブラウザは、ユーザーが何も操作していない状態での音声再生をブロックします。2004年当時は<embed autostart=true>でBGMが勝手に鳴り始めるのが当たり前でしたが、今これをやると無音のままです。

Erinyesでの実例: ゲーム起動時にオープニングムービー(movie.swf)が再生された後、タイトル画面でBGMが鳴るはずですが、ブラウザの自動再生ポリシーにより無音のまま。Ruffleが再生するSWFムービーの音声も同様にブロックされることがあります。

対処: ゲーム開始前に「クリックして開始」のUIゲートを挿入しました。このクリックイベントのコンテキスト内でAudioContextを解放し、以降の音声再生を許可します。

// ユーザー操作のコンテキスト内でAudio解放
var doUnlockAndClose = async function(ev) {
    try { await BGM_unlock(); } catch (e) {}
    window.__ERINYES_AUDIO_UNLOCKED = true;
    gate.style.display = "none";
};

// BGM_unlock: 保留中のBGMがあれば再生開始
function BGM_unlock() {
    if (BGM_STATE.unlocked) return;
    BGM_STATE.unlocked = true;
    if (BGM_STATE.pending) {
        BGM_start(BGM_STATE.pending.name, BGM_STATE.pending.repeat);
        BGM_STATE.pending = null;
    }
}

さらに、BGM再生要求がAudio Unlock前に来た場合はpendingに保留し、Unlock後に自動再生する仕組みを入れています。ゲート表示のタイミングもオープニングムービー終了後(タイトル画面遷移時)に限定し、ムービー中に邪魔しないよう配慮しました。


8. file://ではfetchが動かない(ローカルサーバー必須)

何が起きるか: Frameset廃止に伴い、シナリオファイルの読み込みをfetch()に切り替えた途端、file://プロトコルではCORSエラーで全く動かなくなります。

Access to fetch at 'file:///C:/erinyes/3.htm' from origin 'null' 
has been blocked by CORS policy

2004年当時はHTMLファイルをダブルクリックするだけで遊べました。しかし現代ブラウザのセキュリティモデルでは、file://からのfetch()は同一オリジンとみなされず拒否されます。

対処: Caddy(Go製の単体実行ファイル)によるローカルWebサーバーを同梱し、バッチファイル一発で起動する方式にしました。

起動フロー:
  start_erinyes_local.bat
    → Caddy起動(localhost:ポート番号)
    → ブラウザで http://localhost:xxxx/sys/index.html を開く
    → ゲーム内からping.txtを1秒ごとにfetch
    → タブを閉じるとpingが止まり、Caddyが2.5秒後に自動終了

「ダブルクリックで遊べる」という手軽さは失われましたが、バッチファイルのダブルクリックに変わっただけで、ユーザー体験はほぼ同等です。Caddyは設定不要・インストール不要の単一バイナリなので、追加の依存は最小限に抑えられました。

ブラウザを閉じた後にサーバーが残り続ける問題は、ping.txt監視による自動停止で解決しています。ゲーム側が1秒ごとにpingを送り、Caddy側はpingが途絶えたら自動終了します。


9. IE独自DOMが全滅している

2004年当時のブラウザシェアはIE6が圧倒的で、Web標準など誰も気にしていませんでした。当然、ゲームのコードはIE独自のDOM APIで書かれています。現代ブラウザではこれらが一つも動きません。

document.all

IE独自のグローバル要素アクセスです。id="BG"を振ったdivにBG.style.left = "100px"のように変数名で直接アクセスできました。

// 旧(IE): idをグローバル変数として直接参照
BG.style.left = "100px";
OVERRAY.filters.blendTrans.Apply();

// 新(標準DOM): getElementById必須
document.getElementById("BG").style.left = "100px";

Erinyesのコードにはこのパターンが約30箇所あり、全てdocument.getElementById()に置換しました。

filters.blendTrans(IE独自の視覚効果フィルター)

IEにはelement.filters.blendTransというCSS非標準のトランジション機構がありました。Apply()で現在の見た目を保存し、DOMを書き換えてからPlay()するとフェードインする、という仕組みです。

// 旧(IE): blendTransフィルター
element.filters.blendTrans.Apply();  // 現在の見た目を保存
element.innerHTML = newContent;       // DOM書き換え(まだ表示に反映されない)
element.filters.blendTrans.Play();    // フェードイン開始

// 新(CSS3): opacity + transition で再実装
element.style.transition = "none";
element.style.opacity = "0";
element.innerHTML = newContent;
element.offsetHeight;  // リフロー強制
element.style.transition = "opacity 180ms ease";
element.style.opacity = "1";

これをポリフィルとしてMK_blendApply/MK_blendPlay/MK_blendStopの3関数にまとめ、元コードの呼び出しパターンをそのまま維持しました。

その他のIE独自仕様

IE独自 標準への置換
cursor: hand cursor: pointer
font-color: white(CSS) color: white
document.all.elementId document.getElementById()
window.event イベントハンドラの引数ev
event.keyCode(単独) ev.keyCode || ev.which
element.innerText(一部) element.textContent
attachEvent addEventListener
インラインのonkeydown="return isEscape()" addEventListener("keydown", ...)

元コードのNetscapeというフラグ変数が象徴的です。原作はNetscape=false(=IE)を前提にしていたため、現代化でNetscape=trueに固定したところ、Enter既読スキップが効かなくなるなどの副作用が発生。「IEかNetscapeか」の分岐を全て削除して統一する必要がありました。


10. Framesetが使えない

HTML5でFramesetは廃止されました。しかし2004年当時、Framesetは複数の独立したHTMLページ間で通信する事実上唯一の手段でした。

Erinyesのフレーム構成(元)

frame.htm(Frameset)
├── MAIN  (main.htm)   ← ゲームエンジン本体
├── MUSIC (music.htm)  ← BGM再生(MIDI embed)
└── MSG   (*.htm)      ← シナリオデータ(章ごとに差し替え)

3つのフレームがJavaScriptで相互に参照し合う構造です:

  • ゲームエンジン → BGM: top.MUSIC.WriteLayer(musicname, repeat) で再生指示
  • ゲームエンジン → シナリオ: top.MSG.location.replace("3.htm") でシナリオファイルをロード
  • シナリオ → ゲームエンジン: top.MAIN.init_script() / top.MAIN.fsys[] / top.MAIN.freg[] でゲーム状態を参照

単一HTML統合後の互換レイヤー

全てをindex.html一本に統合し、window.MAIN = window でフレーム参照を透過的に解決:

// フレーム間参照を同一windowに集約
window.MAIN = window;  // top.MAIN.xxx → window.xxx

// BGMフレーム互換: HTML5 Audio で再実装
window.MUSIC = {
    WriteLayer: MusicPlay,      // BGM再生
    ChangeVol:  MusicChangeVol, // ボリューム変更
    BeginFade:  MusicBeginFade  // フェードアウト
};

// シナリオフレーム互換: fetch + eval で再実装
window.MSG = {
    location: {
        replace: function(url) { MSG_loadScenario(url); }
    },
    // scn0, scn1, scn2, weq, ExtFunction はロード時に動的セット
};

シナリオ動的ロードの罠

一番厄介だったのはMSGフレームの廃止です。元のゲームはlocation.replace("3.htm")でシナリオHTMをフレームにロードしていました。フレームにロードすると:

  1. <body onload="...">が発火する
  2. <script>内のグローバル変数(scn0, scn1, scn2等)がフレームのスコープに展開される
  3. ExtFunction等の関数定義もフレームスコープに置かれる

これをfetch+eval方式に置き換えると:

  1. body onloadが発火しない → HTMLからonloadを正規表現で抽出して手動実行
  2. 変数がグローバルに出ないnew Function()内でevalし、明示的にMSG.scn0 = scn0等でエクスポート
  3. 関数定義がスコープ外new Function()内のfunction定義を正規表現で拾い、window['load92'] = load92のようにグローバル化
  4. Shift-JISエンコードTextDecoder("shift_jis")でデコード(元のHTMはShift-JIS)

特にbody onloadの問題は、複数のシナリオファイルのうち92.htmと93.htmだけが特殊なonloadを持っていたため、通常テストでは発見できず、おまけコンテンツの検証で初めて発覚しました。


まとめ:Ruffleは「部品」であって「復元ツール」ではない

Ruffleは素晴らしいプロジェクトですが、それはSWFファイル単体を再生する部品です。2000年代のブラウザゲームが依存していたのはFlash Playerだけではなく、

  • IE独自DOMdocument.allfilters.blendTranscursor:hand
  • Framesetによる複数フレーム間通信(top.MAIN / top.MUSIC / top.MSG
  • body onloadによるフレームロード完了フック
  • Shift-JISエンコードのHTMLファイル群
  • Flash ↔ JavaScript双方向通信(SetVariable / getURL)
  • Flash SharedObjectによるローカルストレージ
  • IE固有のイベントモデルattachEventwindow.eventNetscape分岐)
  • MIDI再生(BGM)
  • 自動再生が当たり前の時代の音声設計
  • file://でローカルファイルが自由に読めた時代のファイル構成

といった、当時のIE+Flashエコシステム全体です。これらを一つずつ現代の標準技術(localStorage、CSS3、Web Audio API、fetch、addEventListener)に載せ替えていく作業は、考古学的な復元作業に近いものでした。

元のゲームロジック・シナリオ・画像・音楽に一切手を加えず、エンジン部分だけを透過的に入れ替える。作者以外は気づかないレベルの復元を目指して、約60時間の改修を行いました。


おわりに

現代化したErinyesはwindows環境のChromeで動作します(ほか環境は現時点で未検証)。BGMは原作者の牧尾様から提供いただいた高品質のMP3ファイルを使用しており、当時のXG音源に遜色ない形で再現されています。
※そのためMP3が180MB超えてるのはご勘弁ください💦。

一ファンとして、当時の名作をなんとか公開まで持ち込めて、正直ほっとしました。

2014年の配信終了から10年以上、遊びたくても遊べない状態が続いていたこの作品を、再び誰でも手に取れる形にできたこと。それが、この60時間の一番の成果です。

GitHub: https://github.com/shinohara-tsukasa/erinyes
DL直リンク:https://github.com/shinohara-tsukasa/erinyes/archive/refs/heads/main.zip

5
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?