ハイハイスクールアドベンチャー Web版
あらすじ
2019年神奈山県立ハイ高等学校は 地盤が弱く校舎の老朽化も進んだため、 とうとう廃校にする以外方法がなく なってしまった。
ところで大変な情報を手に入れた。 それは、
「ハイ高校にATOMIC BOMBが仕掛けられている。」
と、いうものだ。 どうやらハイ高が廃校になった時、 気が狂った理科の先生がATOMIC BOMBを 学校のどこかに仕掛けてしまったらしい。
お願いだ。我が母校のコナゴナになった 姿を見たくはない。 早くATOMIC BOMBを取り除いてくれ……!!
行動は英語で、“<動詞>” 或いは、“<動詞>”+“<目的語>“のように入れていただきたい。 例えば、”look room”と入れれば部屋の様子を見ることが出来るという訳だ。
それでは Good Luck!!!…………
概要
PalmOS版を作ったときに、勢いで、HTML5バージョンを作ってしまえ、と、当時1)はやり始めていた HTML5に対応したバージョンを作成し始めた。
当時は、JavaScriptによるオブジェクトの取り扱いなどが今2)とは大きく異なっていたため、現在のバージョンとは全くと言って異なるコードになっていた。
しかも当時の Canvas への描画は遅く、ダブルバッファなどの使い方を追求する気もさらさらなかったため、グラフィックスの描画はサーバ側に置いたCGIで行い、JavaScriptからはこのCGIへのパラメータを生成してリクエストを投げるだけというスタイルで動作していた。
その後放置していたら、CGIが使用している Boost::Gil の仕様が変わっているわ、JavaScriptも大きく変わっているわで全く動かなくなってしまった。
そのまま放置でもいいかな、と思ったが、ググってみると旧Twitter3)でこれに言及している人がいたのを見つけてしまったので、なんとなく動かさないといけないような気がして、一気にコードを修正した。
なお、今現在4)、ゲームのセーブとロードに使っていた WebSQLが廃止されていたために、コードの修正中でセーブとロードが行えない。5)
細かなバグが残っていて突然振り出しに差し戻されてしまうこともあるような気がする。 AsIs でシャレの分かる人のみプレイしてみてほしい。
PWA対応なので、インストールして遊んでくれてもいいんだよ!
インストールはアドレスバーの右端にあるボタン、またはブラウザのメニューから。
いうまでもないが、画面真ん中あたりの「どうする?」の隣にあるテキストボックスにコマンドを入れて enter である。
あれこれ
画面の描画
上にも書いた通り、画像の生成には HTML5 の canvas 要素を使用している。 そもそも2012年ごろ公開したバージョンでも、実は、描画用のコード自体は存在していたのだが、テストしたらあまりに遅くて使い物にならなかったので、使用を取りやめて CGI方式にした。
とはいえ、ブラウザに搭載されたJavaScriptエンジンも高速化し続けているので、そろそろ行けるんじゃないかと思ったけれど、そのままでは行けなかった。
書き込みはそこそこいける。
正直ハイハイスクールアドベンチャーくらいのゲームなら十分だ。
だが、読み出しが入るとどうにもならないくらい遅くなる。 このゲームの画像描画の肝はペイントルーチンにあるといっても過言ではない。
そのペイントは境界検出のために画面データをなめまくるのだ。 じゃあ、メモリ上に置いといて、書き込みは処理が終わってまとめてやればいいんじゃないかということになるが、画像を RGBA形式で保持していると、結局読み出しが遅いのだ。
そもそもそんなに色数使わないので、8bitのインデックスを作ってペイントバッファとしてそれを使えばいいんじゃないかということに気づいてそのようにした。
なお、当初は、普通に一枚の canvasでやりくりしてたし、速度的には十分だったが、下に書くように、画面の表示領域をネイティブのサイズより大きくしたかったので、画面の canvas要素へ拡大転送するための canvasを作成して、ネイティブサイズ8)の画像はそこに描画されている。
画像を拡大せよ
このゲームのネイティブな画像サイズは 256×152 ピクセルしかない。 昔のPCなら十分な広さだ。 だが、今時のPCの画面にはさみしすぎる。 せめて倍で表示させたい。9)
ウェブページ上に 512×30410)で canvasを用意して、プログラム中ではネイティブサイズのダミーの canvas に書き込みを行い、倍サイズの表示領域に拡大転送することでこれを実現している。
this.zctx = document.getElementById("HHSAdv").getContext('2d'); this.zctx.imageSmoothingEnabled = true; this.zctx.mozImageSmoothingEnabled = true; this.zctx.webkitImageSmoothingEnabled = true; this.zctx.msImageSmoothingEnabled = true; this.width = 256; this.height = 152; this.canvas = document.createElement("canvas"); this.canvas.width = this.widht; this.canvas.height = this.height; this.bitmap = new Bitmap(this.width * this.height); // 描画用バッファ ... this.context.putImageData(this.bitmap, 0, 0); // バッファの内容をネイティブサイズの canvasに書いてから this.zctx.drawImage(this.canvas, 0, 0, this.width, this.height, 0, 0, this.zctx.canvas.width, this.zctx.canvas.height); // それを拡大して画面に書き込む
クラスとかラムダ式とか
2011年ごろの JavaScript によるオブジェクト指向プログラミングというと、クラスも何もなくて、普通の変数の宣言を利用してやっていたのが実態であった。
var Klass = function() { this.member = "abc"; this.hello = function() { console.log("Hello World!"); }; } var k = new Klass(); k.hello();
こんな感じのコードで、まあコードの管理も大変だった。 しかも、いつの間にかエラー吐いて動かなくなっちゃったし。
今時は普通に class を使うということなので全面的に書き直した。
なお、もうひとつ大きな変更としてはXMLHttpRequestが非推奨になっちまってたこと。 基本的にデータファイルも、CGIで描画させた画像もこの枠組みを使ってやりとりしていたので、これも大きかった。
var req = new XMLHttpRequest(); req.open('GET', 'cgi/loadB64.rb?name=" + name, true); req.onload = function() { ... }; req.send();
みたいなことをしていたのだが、今時はこれも fetch()でやるのが正しいらしいのでそのようにする。 しかも、xxx = function(){} みたいなのも古いらしい。 これはラムダ式を使うのが今風なんだと。
fetch('data/xxx.dat').then((res) => res.blob()).then((blob) => blob.arrayBuffer()).then((abuf) => { var buf = new Uint8Array(abuf); // arrayBuffer型はそのままでは利用できないので適当な形式にする。 ... });
みたいなコードになる。 本当は resを受け取ったら、res.ok かどうかを検証するべきなんだけれど、ファイルの読み取りエラーはなしってことでサボってる。 .then().then() って続いているのは、それぞれ、非同期通信になるので、値がちゃんとやってきてから次段へ進むためにこうなってる。 全部をひとまとまりでやろうとするとうまく動かない。
fetch('data/xxx.dat').then((res) => { if (res.ok) { var blob = res.blob(); var abuf = blob.arrayBuffer(); var buf = new Uint8Array(abuf); ... } });
このコードは同期がとれてないので動かない。 というかbufに中身は入ってこないのだ。
データベースとか
このゲームのデータはサーバ側には保存されない。 最初の版では Web SQLを使って、ブラウザ内のdbに保存していたが、ぼーっとしている間に、HTML5に入れなかった Web SQLは廃止されてしまった。
廃止されてしまったので、代わりを探さなければならない。 代わりになるものは indexedDBで、ハッシュを保存できるようだ。
とりあえず実装してみたが、どうも保存データの上書きがうまくいってないような気がする。
データをDBに格納する際に、objectStore.add(data) とすると、挿入のみ可能で、データがあると失敗するが、objectStore.put(data) とすれば、挿入または置換が可能となる。
最初、add()でやってたが、もちろん put()に直して、上書きもきちんとされるようになった。
Save/Loadはある意味重要な機能11)なので、動くようになって捗った。
トラブルシュート
原因はよくわからないがDBが壊れることがあるようである。 そんなときは、Chromeの系譜なら F12を押して DevTool を呼び出し、“Application”タブにある Storage の中から IndexedDBを選び、objectStore “hhsadvdb” を選んで削除すればいい。
勿論データは失われてしまうが、以後、load/save はできるようになる。
PWA
PWAとは Progressive Web Applicationのことである。
要は、Chromeなどで、ウェブページをアプリっぽく扱うための仕組みである。
基本的には、manifestファイルと呼ばれる JSON形式のファイルと、service workerとよばれる JavaScriptの二本立てでできている。 あと、あまり触れられていないけれど、インストールされたアプリの顔となるアイコンに適切なサイズのものがないとインストールボタンが表示されないので、これも重要な要素である。12)
Manifest
PWAに不可欠なもので、アプリケーションとしてのウェブページの詳細を記述したものになる。 形式はJSON形式で、manifest.json とか xxx.webmanifest とかといった名前であることが多いようである。
マニフェストファイルは、HTMLファイルの linkタグを使って次のようにマッピングする。
<link rel="manifest" href="hhsadv.webmanifest" />
そして、マニフェストファイルそのものの中には、とりあえず以下のような内容を記述する。 もっと詳細な仕様を記述できるが、詳しくはこちらなどを参照してほしい。
{ "background_color": "black", "description": "High High School Adventure WEB", "display": "standalone", "icons": [ { "src": "icon/isako.png", "sizes": "72x72", "type": "image/png" }, { "src": "icon/isako144.png", "sizes": "144x144", "type": "image/png" } ], "name": "ハイハイスクールアドベンチャー", "short_name": "HHSAdv", "start_url": "index.html" }
Service Worker (JavaScript)
PWAを構成するもうひとつの要素が Service Workerになる。 JavaScriptで、Service Workerを登録してやる必要がある。
色々、凝った挙動などをさせることもできるようだが、とりあえず、アプリになればいい、というような向きには、最低限の内容でよさそうである。
なお、キャッシュアイテムについては、相対パスでも絶対パスでもフルのURIでも構わないが、取得できない13)ようなものがあると、インストールに失敗するので必ずすべてのアイテムがアクセス可能なことを確認すること。
Service WorkerもHTMLファイルから登録してやる必要がある。
if ('serviceWorker' in navigator) { navigator.serviceWorker.register('sw.js'); }
Service Worker本体14)も最低限だと以下のようになる。
// cache var CACHE_NAME = 'hhsadv-caches'; var urlsToCache = [ 'ending.html', 'game.html', 'index.html', 'opening.html', 'css/default-style.css', 'data/data.dat', 'data/highd.com', 'data/map.dat', 'data/rule.dat', 'data/thin.dat', 'js/UserData.js', 'js/dictionary.js', 'js/ga.js', 'js/graphics.js', 'js/message.js', 'js/zSystem.js', 'js/database.js', 'js/engine.js', 'js/global.js', 'js/map.js', 'sw.js' ]; // install self.addEventListener('install', (event) => event.waitUntil( caches.open(CACHE_NAME) .then((cache) => cache.addAll(urlsToCache)) ) ); // refresh self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request) .then((res) => { return res ? res : fetch(event.request); }) ); });
要はキャッシュを登録しているだけである。 これでオフラインでも使えるようになるらしい15)が、やってみていないのでわからない。
なお、キャッシュされるオブジェクトの指定は、sw.jsのある場所からの相対パスまたは絶対パス、URIなどで、これを間違うとエラーをはいてキャッシュに失敗する。
ブラウザによってはsw.jsがエラーを出しているとインストールボタンを表示してくれない厳格なやつもいたりするので、よく確認してほしい。
トラブルシュート
Manifest周りに問題がありそうなときは、Chromeの系譜であれば F12で DevToolを呼び出し、Applicationのタブから Manifestを選んで、エラーや Warningがないか、そもそも読み込まれているのかを確認するといい。 上記のアイコンの問題もここに「デスクトップだったら144×144以上のサイズのアイコンがないとだめ」って出てたので問題を認識・解決できた。
音とか
PalmOS、Android、そして Qtでは音がある場面では音が鳴るようになっている。 Web版を再公開したときに、「誰か解けたのか?」というようなことをつぶやくと「校歌は歌った」というような返事があった。 そういえばWeb版では音のことをすっかり失念していた。
HTML5ではaudioコンポーネントがあり、JavaScriptに Audioクラスが実装されている。 そしてほとんどすべてのモダンなブラウザでMP3形式の音源を鳴らすことができる。
音を鳴らせ
HTMLで audio 要素を配置してもいいのだが、別に画面上に何かがいる必要はないので、適宜動的に確保する方向で実装する。 動的に確保された Audio のインスタンスは再生が終わったらガベージコレクションの対象になるって書いてあるので、音を鳴らすときに確保して、鳴らしたら勝手に消えることを期待する。
var m = new Audio(); m.src = this.base_uri + "/data/" + ZAudio.sources[n] + ".mp3"; m.load(); m.volume = 1.0; m.loop = false; m.play();
ボリュームは1.0が100%になる。
音を鳴らすな
多分、音鳴ったら困る環境の人も少なくないだろう。 いきなり、ハイハイスクールの校歌が流れたらびっくりするだろうし、大体が、この校歌結構長尺で鳴り出すと止まらないのだ。
なので、ミュート機能はつけておいた方がいいと判断した。 まあ、PCなり、スマホなりでミュートできるだろうけれど。 どっちかというと、ミュートというよりデフォルトでミュートしておいて、鳴らしたい人だけ解除するイメージだ。
まあ、単純にチェックボックスを一個置いておけばいいのだが、今時のインターフェイスとしてはちょっとカッコ悪い。 そもそもこの昭和レトロなアドベンチャーゲームに何をいっているんだという向きもあるだろうが、せっかくなので、今風のスライドスイッチにしておきたい。
Safariなら、そういう要素がサポートされているらしいが、多くのブラウザでは未サポート16)なので、別の方法でやることにする。
世の中には優れた人が多くいて、既にCSSを駆使して、そういうものをチェックボックスに付加してくれているので、まるっとそれをいただくことにする。
<label for="mute" class="switch_label"> <div class="switch"> <input type="checkbox" id="mute" checked="true" /> <div class="circle"></div> <div class="base"></div> </div> <span class="title">MUTE</span> </label>
チェックボックスがスイッチの本体だが、チェックボックスを消して、代わりに circleと baseでスイッチを構成する。 この部分はすべてCSSに記述されている。
.switch { position: relative; } .switch_label { display: flex; align-items: center; } .switch input[type='checkbox'] { position: absolute; width: 0; height: 0; } .switch .base { width: 28px; border-radius: 8px; height: 16px; background-color: #ddd; } .switch .circle { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 6px; background-color: white; transition: 0.5s; } .title { margin-left: 4px; } .switch input:checked ~ .base { background-color: rgb(219, 234, 254); transition: 0.5s; } .switch input:checked ~ .circle { transform: translateX(100%); background-color: blue; }
なお、実際に使用しているスタイルシートは、レスポンシブデザインっぽく、px使わないでサイズを指定しています。
設定
基本的には、特に設定項目はなかったのだが、音を鳴らすことにしたら、Qt版同様に、音を鳴らすのかどうかを覚えておいた方がいいような気がしてきた。
まあ、毎回 Mute ONにしてしまうでもいいような気もしたが、将来何かの設定を追加したときに、それも覚えておいた方がいいような気がしたので、保存できるようにした。
ゲームデータ保存用objectStoreはIndexedDBに作ってあったので、バージョンを2にして、設定用のobjectStoreも作るようにした。 DBのアップグレードとかしないと思ってたが、いきなりすることになるとは先見の明がなさ過ぎた。
const req = indexedDB.open(Database.DBNAME,Database.VERSION); req.onupgradeneeded = (event) => { const db = event.target.result; const oldver = event.oldVersion; const newver = event.newVersion; const migrations = { "1": () => { db.createObjectStore(Database.STORENAME, {keyPath:'id'}); }, "2": () => { db.createObjectStore(Database.PREFSTORE, {keyPath:'pref'}); } } for (var v = oldver + 1 ; v <= newver ; v++) { if (migrations[v]) migrations[v](); } };
バージョン更新のやり方は、どこかで見かけたのを参考にした。