8
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?

html modulesを夢見てパズルゲームを作る

8
Last updated at Posted at 2025-12-22

帰ってきた焚き火さん!
上へ、上へと火力を上げる、シンプルな積み上げパズルゲームです

気づけばアドベントカレンダーでゲームを作る季節。早いですね。
早すぎてすっかり気づくのに遅れた Web フロントエンジニアです。

尚、今年はソリティアのような整理パズルゲームです。
同じ色をひとつにまとめていく、そんなシンプルなルール。

すこし音が出ます

今年も今年で紆余曲折ありましたが、その辺りは以下の note へ。
こちらはいつものように実装の知見まとめです。

技術概要と今回の知見

とはいえ、去年からの変化もあまりありません。
HTML / CSS / JavaScript / Web Components

ライブラリの利用もビルドも必要無く、
ローカルサーバーさえあればブラウザでそのまま動作するコンセプトも、
イベント駆動なカタチも変わりなく。

おかげで、あまり書けることがないんですよね。
だいぶプロジェクトローカルな備忘録かもしれません。今年。

※なお公開にあたっては諸々ビルドしています

elii

のっけからローカル事情。
HTML をコンポーネント利用するためのオレオレ簡易フレームワークです。
ゼロから作り直して、名前もつきました。

具体的には下のような感じに書きます。簡単なカウンターの例です。

x-counter.m.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 がコンポーネントファイルなら、こんな感じ。

.vscode/settings.json
{
  "files.associations": {
    "*.m.html": "javascriptreact"
  }
}

これだけでスクリプト内の型チェック、のみならず。
HTML/CSS の構文ハイライトも付いてきます。特にエラーも無し。

Vue/Svelte/Astro などの選択肢もありますが。
拡張機能や追加の設定が必要だったり。
import の型が解決されなかったり。特有の仕様に一部阻まれたり。

import した際の型推論

.m.html ファイルを import すると TypeScript は当然怒ります。
そんなモジュールは知らない? それはそう。

この解決には TypeScript Language Service プラグインを書きました。
思ったより簡単に作れます。

ts-plugin-html-modules/index.js (概要)
module.exports = (ts) => {
  return {
    create({ languageService, languageServiceHost: host }) {
      host.getScriptSnapshot = (fileName) => {
        // .m.htmlファイルをscriptに変換して返す
      }
      return languageService
    }
  }
}

html modules でコードジャンプやエラー位置を意識するなら、
スクリプトの部分以外を全てスペースで塗りつぶす変換がお手軽です。

ts-plugin/index.js(概要)
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などハードコートされている)以外は、
宣言ファイルが必須でパス解決も拒否されるので、その辺りも誤魔化す。

ts-plugin-html-modules/index.js (概要)
const { resolveModuleNameLiterals } = host

host.resolveModuleNameLiterals = (...args) => {
  return resolveModuleNameLiterals.apply(host, args).map((resolvedModule) => {
    // .m.htmlファイルをJavaScriptモジュール判定させる
  })
}
ts-plugin-html-modules/index.js (概要)
// .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 で型推論してくれます。

tsconfig.json
{
  "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 を使うよう指定しましょう。

.vscode/settings.json
{
  "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 年の記事

8
0
0

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
8
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?