帰ってきた焚き火さん!
上へ、上へと火力を上げる、シンプルな積み上げパズルゲームです
気づけばアドベントカレンダーでゲームを作る季節。早いですね。
早すぎてすっかり気づくのに遅れた Web フロントエンジニアです。
尚、今年はソリティアのような整理パズルゲームです。
同じ色をひとつにまとめていく、そんなシンプルなルール。
すこし音が出ます
今年も今年で紆余曲折ありましたが、その辺りは以下の note へ。
こちらはいつものように実装の知見まとめです。
技術概要と今回の知見
とはいえ、去年からの変化もあまりありません。
HTML / CSS / JavaScript / Web Components
ライブラリの利用もビルドも必要無く、
ローカルサーバーさえあればブラウザでそのまま動作するコンセプトも、
イベント駆動なカタチも変わりなく。
おかげで、あまり書けることがないんですよね。
だいぶプロジェクトローカルな備忘録かもしれません。今年。
※なお公開にあたっては諸々ビルドしています
elii
のっけからローカル事情。
HTML をコンポーネント利用するためのオレオレ簡易フレームワークです。
ゼロから作り直して、名前もつきました。
具体的には下のような感じに書きます。簡単なカウンターの例です。
<script type="module">
import { defineComponent, reactive } from 'elii'
export default defineComponent({
tag: 'x-counter',
document: import.meta.document,
setup() {
const state = reactive({ count: 0 })
const increment = () => state.count++
const decrement = () => state.count--
return { state, increment, decrement }
},
})
</script>
<template>
<button type="button" data-on-click="decrement">-</button>
<button type="button" data-on-click="increment">+</button>
<span data-class-alert="state.count > 9" data-text="state.count"></span>
</template>
<style>
.alert {
color: red;
}
</style>
野暮ったさが増したような気もしますか。特にテンプレート内。
data 属性は多めで、リアクティブ変数はオブジェクト限定。
ともあれ、これを HTML Modules の使える世界で読み込めば。
Web Components として動きます。
つまりこの世界では動かないということですね!策定はまだですか。
とりあえず現状、ネイティブにおいては Service Worker で。
ビルドなら esbuild プラグインで、それっぽいものが動きます。
HTML Modules の開発環境
そんな HTML Modules の開発環境について。
去年までは「型が効かない」で諦めていましたが。
今年はせっかくなので、少し開発環境を整えてみました。
インラインスクリプトで型を効かせる
VSCode&TypeScript。お馴染みの開発環境ですが。
HTML ファイルの script タグでは型が効きません。
解決する未来もありそうですが。
ともあれ。
手っ取り早そうなのは JSX として扱わせることでした。
仮に *.m.html がコンポーネントファイルなら、こんな感じ。
{
"files.associations": {
"*.m.html": "javascriptreact"
}
}
これだけでスクリプト内の型チェック、のみならず。
HTML/CSS の構文ハイライトも付いてきます。特にエラーも無し。
Vue/Svelte/Astro などの選択肢もありますが。
拡張機能や追加の設定が必要だったり。
import の型が解決されなかったり。特有の仕様に一部阻まれたり。
import した際の型推論
.m.html ファイルを import すると TypeScript は当然怒ります。
そんなモジュールは知らない? それはそう。
この解決には TypeScript Language Service プラグインを書きました。
思ったより簡単に作れます。
module.exports = (ts) => {
return {
create({ languageService, languageServiceHost: host }) {
host.getScriptSnapshot = (fileName) => {
// .m.htmlファイルをscriptに変換して返す
}
return languageService
}
}
}
html modules でコードジャンプやエラー位置を意識するなら、
スクリプトの部分以外を全てスペースで塗りつぶす変換がお手軽です。
function htmlToScript(html) {
const match = html.match(/([\s\S]*?<script[^>]*>)([\s\S]*?)(<\/script>[\s\S]*)/)
const [, beforeScript, scriptContent, afterScript] = match
// スクリプト以外の部分はスペースでマスク
const maskedBeforeScript = beforeScript.replaceAll(/./g, ' ')
const maskedAfterScript = afterScript.replaceAll(/./g, ' ')
return `${maskedBeforeScript}${scriptContent}${maskedAfterScript}`
}
js 系の拡張子(.js,.tsなどハードコートされている)以外は、
宣言ファイルが必須でパス解決も拒否されるので、その辺りも誤魔化す。
const { resolveModuleNameLiterals } = host
host.resolveModuleNameLiterals = (...args) => {
return resolveModuleNameLiterals.apply(host, args).map((resolvedModule) => {
// .m.htmlファイルをJavaScriptモジュール判定させる
})
}
// .m.html の宣言ファイルがあると誤認してくれるホスト
const adaptedHost = {
...host,
fileExists(path) {
return ts.typescript.sys.fileExists(path.replace('.m.d.html.ts', '.m.html'))
},
readFile(path) {
return ts.typescript.sys.readFile(path.replace('.m.d.html.ts', '.m.html'))
},
}
const result = ts.typescript.resolveModuleName(moduleName, containingFile, options, adaptedHost)
これを tsconfig.json に設定すると import で型推論してくれます。
{
"compilerOptions": {
"plugins": [{ "name": "ts-plugin-html-modules" }]
},
"include": ["src/**/*.m.html", "src/**/*.js"]
}
プラグインのローカルパッケージ化
プラグインを使うための準備その 1。
相対パス指定が出来ないので、パッケージ化が必須です。
package.json を忘れずに。
npm install ./ts-plugin-html-modules
Workspace の TypeScript を有効化
プラグインを使うための準備その 2。
デフォルトではプロジェクト内のプラグインは使われません。
利用するならプロジェクトの TypeScript を使うよう指定しましょう。
{
"typescript.tsdk": "./node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}
これで import した .m.html の型も使えます。ありがたや。
不足があるのか食い合わせなのか、一部に型チェックのおかしい部分もありますが。
ともあれそこそこ安心感のある環境に。
いつか、テンプレート内の型チェックも効かせたいですね。
iOS(iPadOS) 対応
今回、iOS 対応でだいぶ混乱させられたアレやコレや。
解決したハズの Web Audio API が、今ふたたび立ちはだかる。
AudioContext と resume()
音を鳴らすために使う Web Audio API の主に AudioContext は、
初期が機能が停止状態なので resume() で再開させます。
resume() が返すのは promise。
つまりこれが解決したら準備完了で。後は安心して音を鳴らせます。
なら待ちたくなるじゃないですか。終わるまで。
const ctx = new AudioContext()
await ctx.resume() // 準備が終わるのを待つ
しかしこれ iOS 系だと先に進みません。
なぜ。
どうやらこれ 実際に音を再生しないと完了してくれない らしく。
なぜなの。
要はこれ、待たなくていいようです。実際待っている人もほとんど見ません。
そうか。
resume() からすぐに音を鳴らしたらまだ準備が終わっておらず、
再生が遅れたり、音の最初が切れる。そんな事が起こり得ないのであれば。
確かに待つ必要はないですよね。そうなんですよね?
Chrome の一手間
実はここ。
同じ iOS 上でも Safari と Chrome で微妙に挙動が違います。
Safari だと、resume() は直後の音の再生でも完了します。
しかし Chrome だと、resume() の後に少し待たないと音の再生が無視 されます。
とりあえず手元の iPad の場合は 100ms も待てば OK。
requestAnimationFrame() ではダメでした。
UserActivation API の罠
実は resume() の実行にはユーザー操作が必要です。
スクリプトだけで勝手に音を鳴らせないようにする制限らしく、
ページ上で何かしらのユーザー操作があって初めて鳴らす許可が得られます。
例えばこんな感じ。
addEventListener('click', () => {
ctx.resume()
})
そしてそんなユーザー操作があったかどうかを判定する API があります。
UserActivation API。初めて知りました。
今回の場合なら navigator.userActivation.hasBeenActive。
なので、雑に書くならこんな形も。
;(function fn() {
if (navigator.userActivation.hasBeenActive) {
ctx.resume()
} else {
requestAnimationFrame(fn)
}
})()
さて、そんな hasBeenActive ですが。
手元の iOS Chrome だと、ページ読み込み直後から true になってくれます。
もちろん true であっても、鳴らすとまだ気が早いとAudioContext に怒られます。
同じ iOS でも Safari であれば問題なし。
さすがにバグですかね?
再現環境も何か条件があって限定的なのかもしれません。
結局どうしたか
- ロード直後に
hasBeenActiveが false 以外なら、自前でイベント監視。 -
resume()は待たない。 - ただし 100ms はおまじないとして待つ。
いやですね。この泥臭さ。
おのれ iOS。
その他の小ネタ
リザーバーサンプリング
選択肢がいくつかあって、そこからランダムに選ぶ。
素直にやるなら、全部を並べて乱数の気の向くままに。
それぞれが重みを持っているなら尚更。
パズルの問題生成は、完成からの逆手順で作っています。
なので解けない問題は出ないのですが。それはさておき。
上述のランダム選択で作っていました。特にそれで支障もなかったのですが。
違うやり方もあるんですね。
1 個目は 1/1 で勝ち。2 個目は 1/2 で勝ち。3 個目は 1/3 で勝ち。
そんな感じで最終的な勝ち残りを決めるという。
重みがあっても話は同じ。
let totalWeight = 0
let winner = null
function lottery(item, weight) {
totalWeight += weight
if (Math.random() * totalWeight < weight) {
winner = item
}
}
候補を溜め込んでおかなくて良いんですね。
乱数が酷使される以外は大変お優しい。
リザーバーサンプリング(貯水池サンプリング)と言うそうで。
サンプリングしたい訳ではないですけれど。
valueAsNumber
input 要素は type が何であれ value は常に文字列ですが。
それを数値で扱えるのが valueAsNumber。
読んでよし。書いてよし。
今の今まで知りませんでした。データのバインド先として便利ですね。
循環参照とメモリリーク
2 つの変数が互いに参照していたら、自然に解放されることはない。
参照カウントならそうだった。
けど、とっくの昔に変わっていたんですね。
マーク&スイープ。都度ルートから参照を辿れるものだけが生き残る。
全く知りませんでした。
ついでに import の循環参照についても。
同じく論外だと思っていましたが。
これも import したものを非同期に使うなら OK なんですね。
動的 import に置き換える必要もない。
構成的に望ましくない感じは変わらないですが。ともあれ。
パララックススクロール
今回の、やりたかったけど、できなかったこと。
ページのスクロールに対して、立体感を演出する。
ちなみに昔のゲームで言うなら、多重スクロール。
奥のものほど遅く、手前のものほど早く動く。そんな演出です。
それが CSS で実現できるという。
.hoge {
animation: hoge linear;
animation-timeline: scroll();
}
中身はシンプルに使い慣れた CSS アニメーションです。
その進行度に、ページスクロールの進行度を反映させる。
つまり通常のスクロール追従以外は、アニメーションなんですね。
なるほど。
しかし考慮する事が結構増えるので、時間的に諦めました。
おわりに
去年と構成がほとんど変わらないので、サクッと終わる。
そう信じていたんです。
出てきますね色々と。沼のようだ。
ちなみに去年まで音周りが動いていたのは、単に偶然だったらしく。
今回は元々進めていたゲームをカタチに出来ず。
新しく作ったゲームは大幅に見通しを突き抜けて。
次こそは計画的に平穏な年末を迎えたいですね。
そして html modules に幸あれ。
Web フロントはたのしいですね!
Web フロントはたのしいですよ!
補足
※ Fire Rise リポジトリ
※ 2024 年の記事
※ 2023 年の記事
※ 2022 年の記事
※ 2021 年の記事
※ 2020 年の記事