作例 / ウェブアプリ

じゃんけんゲームを、
育てて読む。

JavaScript が 100 行のシンプル版で「ボタンを押す → データを変える → 画面を描く」の流れを掴み、本格版で、同じ流れに状態の管理・クラス・保存・ファイル入出力・PWA が加わる様子を見ていきます。各章から、ガイドの該当する節へ戻れます。

目次を開く(全 16 章)

作例のコードには、初心者向けのコメントを多めに書いてあります。このページで要点を掴んだら、エディターで joyful-janken フォルダーを開き、コメントと合わせて読んでください。

01

完成したゲームを見る

コンピュータと対戦するじゃんけんゲームです。手を選ぶとすぐ勝敗が出るシンプル版と、コンピュータの手がシャッフルされ、成績を保存できる本格版の 2 つがあります。

シンプル版の画面。スコアが「あなた 1、コンピュータ 0」、グー・チョキ・パーの丸いボタン、「あなたの勝ちです!」の表示、リセットボタンが並ぶ。
シンプル版。手のボタンを押すと、その場で勝敗が決まります。
本格版をパソコンの幅で開いたところ。左に対戦の画面、右に勝ち・負け・あいこ・勝率、最近の対戦の履歴、書き出す・読み込む・リセットのボタンが並ぶ。
本格版をパソコンの幅で開いたところ。左が対戦、右が対戦成績です。

どちらも joyful-janken フォルダーの HTML をダブルクリックすれば遊べます。ただし、ホーム画面への追加とオフラインでの動作(14 章)を試すときだけは、ローカルサーバーで開く必要があります。

02

ファイルの役割と読む順番を知る

シンプル版と本格版は、それぞれ HTML・CSS・JavaScript の 3 ファイルでできています。本格版には、PWA のための 2 ファイルが加わります。

フォルダーの中身
joyful-janken/
├── simple-index.html      … シンプル版
├── index.html             … 本格版
├── manifest.webmanifest   … PWA の設定(アプリ名・アイコン)
├── sw.js                  … Service Worker(オフライン対応)
├── stylesheets/
│   ├── simple.css         … シンプル版
│   └── style.css          … 本格版
├── javascripts/
│   ├── simple.js          … シンプル版
│   └── app.js             … 本格版
├── images/                … 手の画像・アイコン
└── sounds/                … BGM・効果音
  1. シンプル版を読む

    simple-index.html → simple.css → simple.js の順に読み、「ボタンを押す → データを変える → 画面を更新する」という基本の流れを掴みます(03〜05 章)。

  2. 本格版を読む

    index.html → app.js → style.css → sw.js の順に読み、同じ流れに保存・ファイル・PWA が加わる様子を見ます(06〜14 章)。

どちらの JavaScript も、<script src="…" defer> で読み込んでいます。モジュール(type="module")にしなかったのは、HTML をダブルクリックして file:// で開いても動くようにするためです。

シンプル版の simple.js は、そのまま上から書いています。本格版の app.js は全体を即時関数 (() => { … })(); で包んでいます。必要な部品が見つからないときに、return で途中から処理を止められるようにするためです。

シンプル版

simple-index.html・simple.css・simple.js の 3 ファイルで、じゃんけんの基本の流れを作ります。

03

HTML と JavaScript を data-* でつなぐ

JavaScript が HTML の部品を探すときの目印に、data-* 属性を使っています。見た目のための class と役割を分けておくと、デザインを変えても JavaScript が壊れません。

simple-index.html(抜粋)
<dl class="score">
  <div>
    <dt>あなた</dt>
    <dd data-ui="player-score">0</dd>
  </div>
  …
</dl>

<div class="choices" role="group" aria-label="あなたの手">
  <button type="button" class="choice" data-choice="rock" aria-label="グー">✊</button>
  <button type="button" class="choice" data-choice="scissors" aria-label="チョキ">✌️</button>
  <button type="button" class="choice" data-choice="paper" aria-label="パー">✋</button>
</div>

<div class="result" aria-live="polite">
  <p class="result-message" data-ui="result">手を選んでください!</p>
  <p class="result-computer" data-ui="computer-choice"></p>
</div>

<button type="button" class="reset" data-action="reset">リセット</button>
目印意味例
data-uiJavaScript が中身を書き換える部品スコア、結果の文
data-action押すと何かが起きる部品リセットボタン
data-choiceどの手のボタンかrock・scissors・paper

JavaScript では、最初にこの目印で部品を探しておきます。

simple.js(抜粋)
// data-ui は表示する部品、data-action は操作する部品の目印。
const choiceButtons         = document.querySelectorAll('[data-choice]');
const playerScoreElement    = document.querySelector('[data-ui="player-score"]');
…
const resetButton           = document.querySelector('[data-action="reset"]');
読み上げへの配慮

絵文字のボタンは、読み上げソフトでは「握りこぶし」のように読まれることがあります。aria-label="グー" で、ボタンの名前をはっきり伝えています。結果の欄の aria-live="polite" は、中身が変わったときに読み上げてもらうための属性です。

04

じゃんけんのルールをデータにまとめる

「グーはチョキに勝つ」というルールを、if 文を何行も並べて書く代わりに、オブジェクトにまとめています。

simple.js(抜粋)
// 手ごとに「表示する絵文字」「日本語の名前」「勝てる相手」をまとめておく。
// 勝敗のルールをここに集めると、判定の if 文が短くなる。
const HANDS = {
  rock:     { emoji: '✊', label: 'グー',   beats: 'scissors' },
  scissors: { emoji: '✌️', label: 'チョキ', beats: 'paper' },
  paper:    { emoji: '✋', label: 'パー',   beats: 'rock' },
};

// 勝敗を判定する('player'・'computer'・'draw' のどれかを返す)
function getWinner(player, computer) {
  if (player === computer) {
    return 'draw';
  }
  // 自分の手が勝てる相手と、コンピュータの手が同じなら勝ち
  return HANDS[player].beats === computer ? 'player' : 'computer';
}

勝ち負けの組み合わせは 3 × 3 = 9 通りありますが、判定は 2 つだけです。同じ手ならあいこ、そうでなければ「自分の手の beats が相手の手と同じか」を見れば、勝ちか負けかが決まります。

コンピュータの手は、HANDS のキーの一覧から 1 つを選びます。

simple.js(抜粋)
function getComputerChoice() {
  const choices = Object.keys(HANDS);  // ['rock', 'scissors', 'paper']
  const randomIndex = Math.floor(Math.random() * choices.length);
  return choices[randomIndex];
}

Math.random() は 0 以上 1 未満の数を返します。3 を掛けて小数点以下を切り捨てる(Math.floor)と、0・1・2 のどれかになります。勝敗ごとのメッセージも、MESSAGES[winner] のように対応表から選んでいます。

05

データを変えてから画面を描く

スコアは画面の数字を直接足すのではなく、scores というデータとして持っています。ボタンが押されたら、まずデータを変え、そのあとで画面に映します。

simple.js(抜粋)
const scores = { player: 0, computer: 0 };
…
// スコアのデータを画面に映す
function renderScores() {
  playerScoreElement.textContent   = scores.player;
  computerScoreElement.textContent = scores.computer;
}

// ボタンが押されたら 1 回勝負する
function playGame(event) {
  // currentTarget は「イベントを登録したボタン」を指す
  const playerChoice   = event.currentTarget.dataset.choice;
  const computerChoice = getComputerChoice();
  const winner         = getWinner(playerChoice, computerChoice);

  // 先にデータを変えてから、画面を更新する
  if (winner !== 'draw') {
    scores[winner]++;
  }
  renderScores();

  resultElement.textContent = MESSAGES[winner];
  // CSS で勝ち・負け・引き分けの色を変えられるよう、状態を data 属性で渡す
  resultElement.dataset.winner = winner;
  …
}

リセットでは scores を 0 に戻して renderScores() を呼ぶだけです。画面を直す処理が 1 か所にまとまっているので、スコアの表示を変えたくなっても renderScores() だけを直せば済みます。

結果の色は、JavaScript が dataset.winner で渡した値を、CSS が属性セレクタで受け取って決めています。

simple.css(抜粋)
.result-message {
  …
  /* JavaScript が付けた data-winner の値で色を変える */
  &[data-winner="player"]   { background-color: color-mix(in srgb, var(--yellow) 35%, var(--card)); }
  &[data-winner="computer"] { background-color: color-mix(in srgb, var(--red) 25%, var(--card)); }
  &[data-winner="draw"]     { background-color: color-mix(in srgb, var(--blue) 25%, var(--card)); }
}

配色は、手の画像の青(グー)・黄(チョキ)・赤(パー)から採っています。color-mix() でカードの地色と混ぜ、背景に使える淡い色を作っています。

本格版

シンプル版と同じ流れに、状態の管理・保存・ファイルの読み書き・画面に合わせた配置・PWA を加えます。

06

状態を 1 か所に持ち、描画関数で画面に映す

本格版の対戦には、「開始前」「シャッフル中」「結果表示中」の 3 つの状態があります。今どの状態かを game.state に持ち、画面はいつも renderGame() で作り直します。

シャッフル中の対戦画面。コンピュータの手の画像の下に「じゃんけん…… 手を選んでね!」と表示され、グー・チョキ・パーのボタンが押せる状態になっている。
シャッフル中。手のボタンが押せるようになり、「もう一度あそぶ」は押せなくなります。
app.js(抜粋)
const game = {
  state: 'idle',         // 'idle'(開始前)・'playing'(シャッフル中)・'result'(結果表示中)
  computerHand: 'guu',
  lastResult: null,      // 直前の対戦記録
  …
};
…
// 状態を data-state・data-result として HTML に渡し、色や動きの切り替えは CSS に任せる
function renderGame() {
  const { state, computerHand, lastResult } = game;
  ui.game.dataset.state = state;
  ui.game.dataset.result = state === 'result' ? lastResult.result : '';
  …
  for (const button of ui.handButtons) {
    button.disabled = state !== 'playing';
    …
  }

  ui.startButton.disabled = state === 'playing';
  ui.startLabel.textContent = state === 'idle' ? 'じゃんけんを はじめる' : 'もう一度あそぶ';

  ui.message.textContent = messageFor(game);
}

ボタンを押せるかどうか、ボタンの文字、メッセージが、どれも state から決まります。状態を変える関数(開始・手を選ぶ)は、game を書き換えたあと renderGame() を呼ぶだけです。画面の部品を個別に書き換える処理が散らばらないので、「押せないはずのボタンが押せる」といった食い違いが起きにくくなります。

CSS は、data-state と data-result を見て、動きと色を切り替えます。

style.css(抜粋)
/* シャッフル中は手を揺らす */
.game[data-state="playing"] .opponent-hand {
  animation: shake .5s ease-in-out infinite;
}
…
.message {
  …
  /* & を後ろに書くと「.game が win のときの .message」という意味になる */
  .game[data-result="win"] &  { background-color: var(--win-bg); }
  .game[data-result="lose"] & { background-color: var(--lose-bg); }
  .game[data-result="draw"] & { background-color: var(--draw-bg); }
}

07

コンピュータの手をシャッフルする

シャッフル中は、コンピュータの手の画像が 0.25 秒ごとに切り替わります。この繰り返しには requestAnimationFrame を使っています。

app.js(抜粋)
// 一定の間隔でコンピュータの手を切り替える。
// 経過時間をためて判定するので、画面の更新頻度が違う端末でも速さが揃う。
// (1 秒に 60 回描く画面でも 120 回描く画面でも、250 ミリ秒ごとに切り替わる)
// time には、ページを開いてからの時刻(ミリ秒)が入る
function shuffle(time) {
  if (game.state !== 'playing') {
    return;
  }

  game.elapsed += time - game.lastTime;
  game.lastTime = time;

  if (game.elapsed >= SHUFFLE_MS) {
    // 250 で割った余りを残し、次に切り替えるまでの時間がずれないようにする
    game.elapsed %= SHUFFLE_MS;
    …
  }

  // 次のフレームでも自分を呼ぶよう予約する
  game.frameId = requestAnimationFrame(shuffle);
}

requestAnimationFrame(shuffle) は、次に画面を描く直前に shuffle を 1 回呼びます。shuffle の最後でもう一度予約するので、止めるまで呼ばれ続けます。画面を描く回数は端末によって違う(1 秒に 60 回、120 回など)ため、呼ばれた回数ではなく経過した時間で切り替えを決めています。

手が選ばれたら、cancelAnimationFrame(game.frameId) で予約を取り消して止めます。なお、シャッフル中に見えている手は演出で、勝負に使う手は、ボタンを押した瞬間にあらためてランダムに決めています。

動きを減らす設定

手を揺らす shake は、止めるまで繰り返す(infinite)アニメーションです。本格版では、動きを減らす設定のときに、動きの時間をほぼ 0 にするだけでなく animation-iteration-count: 1 で繰り返しの回数を 1 回にしています。時間を短くしただけでは、繰り返す動きは高速で回り続けてしまうためです。

08

成績をクラスにまとめる

勝ち・負け・あいこの合計と、直近 100 戦の履歴は、JankenRecord クラスにまとめています。データと、それを扱う処理(記録する、勝率を求める、保存用の形にする)を 1 つにしたものです。

app.js(抜粋)
class JankenRecord {
  // # で始まる名前は、クラスの外から読み書きできない(プライベートフィールド)
  #totals  = { win: 0, lose: 0, draw: 0 };
  #history = [];
  …
  // 1 回分の対戦を記録し、その記録を返す
  add(player, computer) {
    const entry = { at: new Date().toISOString(), player, computer, result: judge(player, computer) };
    this.#totals[entry.result]++;
    this.#history.push(entry);
    // 古い履歴から捨てて、保存量を一定に保つ
    if (this.#history.length > HISTORY_LIMIT) {
      this.#history.shift();
    }
    return entry;
  }
  …
  // 勝率はあいこを除いて計算する。まだ勝敗がついていなければ null
  get winRate() {
    const decided = this.#totals.win + this.#totals.lose;
    return decided === 0 ? null : this.#totals.win / decided;
  }
  …
}
  • #totals・#history … # を付けた項目は、クラスの外から書き換えられません。成績を変える方法は add() だけになるので、合計と履歴が食い違いません。
  • get winRate() … ゲッターです。record.winRate のように、() を付けずに読めます。
  • toJSON() … JSON.stringify(record) と書くと自動で呼ばれ、保存用の形を返します。ブラウザへの保存(09 章)とファイルへの書き出し(10 章)が、同じ形で済みます。

確かめてから作る入口 — static from()

保存してあった成績や、読み込んだファイルから成績を作るときは、JankenRecord.from(…) を通します。static を付けたメソッドは、1 つずつの成績(インスタンス)からではなく、クラスから呼びます。中身を確かめ、正しくなければ null を返します。

app.js(抜粋)
static from(value) {
  if (value === null || typeof value !== 'object' || Array.isArray(value)) {
    return null;
  }

  const { totals, history } = value;
  const isCount = (n) => Number.isInteger(n) && n >= 0;
  if (!totals || !RESULT_IDS.every((id) => isCount(totals[id]))) {
    return null;
  }
  …
}

履歴の 1 件ずつについても、勝敗が手の組み合わせと合っているかまで確かめています。ファイルを書き換えて「グー対グーで勝ち」とした成績は、読み込めません。

09

成績と設定をブラウザに保存する

成績と音の設定は、ブラウザの localStorage に保存しています。ページを閉じても、次に開いたときに続きから遊べます。

app.js(抜粋)
const storage = {
  load(key) {
    try {
      const text = localStorage.getItem(key);
      return text === null ? null : JSON.parse(text);
    } catch (error) {
      console.warn(`${key} を読み込めませんでした`, error);
      return null;
    }
  },

  save(key, value) {
    try {
      localStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch (error) {
      console.warn(`${key} を保存できませんでした`, error);
      return false;
    }
  },
};

localStorage は文字列しか保存できないので、JSON.stringify() で文字列にして保存し、JSON.parse() で元に戻します。プライベートブラウズなど、保存を拒否される環境もあります。そこで try / catch で包み、保存できなくてもゲームは続けられるようにしています。

ページを開いたときは、保存があれば使い、なければ初期の値から始めます。

app.js(抜粋)
// ?. は、左側が null や undefined のときにエラーにせず undefined を返す。
// 保存がない初回は undefined !== false が true になり、音は ON で始まる
const settings = { sound: storage.load(SETTINGS_KEY)?.sound !== false };
// ?? は、左側が null や undefined のときだけ右側を使う。
// 保存がない・壊れているときは、空の成績から始める
let record = JankenRecord.from(storage.load(RECORD_KEY)) ?? new JankenRecord();

保存のキーは joyful-janken-record-v1 のように版を含めています。あとで保存の形を変えたとき、古い形のデータと区別できるようにするためです。また、同じゲームを 2 つのタブで開いていると、片方で遊んだ成績が storage イベントで、もう片方にも反映されます。

10

成績をファイルに書き出し、読み込む

localStorage の中身は、そのブラウザの中にしかありません。別の端末に成績を移せるよう、JSON ファイルへの書き出しと、ファイルからの読み込みを用意しています。

書き出す

app.js(抜粋)
function exportRecord() {
  // 3 つ目の 2 は字下げの幅。人が開いても読みやすい形で書き出す
  const json = JSON.stringify(record, null, 2);
  // Blob はファイルの中身になるデータのかたまり。createObjectURL() でそれを指す一時的な URL を作る
  const file = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(file);

  // download 属性の付いたリンクを押すと、ページを移動せずにこの名前で保存される。
  // 画面には置かず、click() で押したことにする
  const link = document.createElement('a');
  link.href = url;
  link.download = `janken-record-${dateStamp(new Date())}.json`;
  link.click();

  // ダウンロードが始まったあとで、一時 URL のメモリを解放する
  setTimeout(() => URL.revokeObjectURL(url), 1000);
  …
}

書き出したファイルの中身は、次のような形です。toJSON()(08 章)が返した形が、そのままファイルになります。

janken-record-2026-09-27.json(例)
{
  "app": "joyful-janken",
  "version": 1,
  "savedAt": "2026-09-27T01:00:00.000Z",
  "totals": { "win": 11, "lose": 7, "draw": 4 },
  "history": [
    { "at": "2026-09-27T01:00:00.000Z", "player": "guu", "computer": "choki", "result": "win" }
  ]
}

読み込む

読み込むファイルは、利用者がどんなファイルでも選べます。そのため、大きさ、JSON として読めるか、このゲームのファイルか、形式の版、成績の中身を、順に確かめます。問題があれば throw で理由を投げ、catch で画面に表示します。

app.js(抜粋)
// async を付けた関数の中では、await で時間のかかる処理の完了を待てる
async function importRecord() {
  …
  // 問題を見つけたら throw で下の catch へ飛び、理由を画面に出す
  try {
    // accept は選択画面の目安にすぎないので、大きさと中身を必ず確かめる
    if (file.size > FILE_SIZE_LIMIT) {
      throw new Error('ファイルが大きすぎます');
    }

    let data;
    try {
      // file.text() はファイルの中身を文字列として読む。時間がかかるので await で待つ
      data = JSON.parse(await file.text());
    } catch {
      throw new Error('JSON ファイルではありません');
    }
    if (data?.app !== FILE_APP_NAME) {
      throw new Error('じゃんけんゲームの成績ファイルではありません');
    }
    …
    const imported = JankenRecord.from(data);
    if (!imported) {
      throw new Error('成績の内容に誤りがあります');
    }
    …
  } catch (error) {
    showDataMessage(`読み込めませんでした:${error.message}`, 'error');
  }
}

ファイルを選ぶ <input type="file"> は hidden で隠し、「読み込む」ボタンから click() で開いています。見た目をほかのボタンとそろえるためです。

11

確認ダイアログの答えを await で待つ

成績のリセットや、いまの成績をファイルの成績で置き換える前には、確認のダイアログを出します。「やめる」「実行する」のどちらが押されたかを、await で待って受け取ります。

「成績をリセットしますか?」という確認ダイアログ。全 24 戦の成績と履歴を消すという説明と、やめる・リセットするの 2 つのボタンがある。後ろの画面は暗くなっている。
リセットの前に出る確認ダイアログ。後ろの画面は操作できなくなります。
index.html(抜粋)
<!-- 確認ダイアログ。method="dialog" のフォームは、押したボタンの value を returnValue に入れて閉じる -->
<dialog class="confirm" data-ui="confirm" aria-labelledby="confirm-h">
  <form method="dialog">
    <h2 id="confirm-h" class="confirm-title" data-ui="confirm-title">確認</h2>
    <p data-ui="confirm-text"></p>
    <div class="confirm-actions">
      <button type="submit" class="button" value="cancel" autofocus>やめる</button>
      <button type="submit" class="button button-danger" value="ok" data-ui="confirm-ok">実行する</button>
    </div>
  </form>
</dialog>

method="dialog" のフォームでは、ボタンを押すとダイアログが閉じ、押したボタンの value(ok か cancel)が returnValue に入ります。JavaScript はダイアログを開き、閉じたときの returnValue を Promise の結果として返します。

app.js(抜粋)
// showModal() は、後ろの画面を操作できない形で開く。Esc キーでも閉じられる
ui.confirm.showModal();

// method="dialog" のフォームでは、押したボタンの value(ok / cancel)が returnValue に入る。
// { once: true } を付けると、1 回呼ばれたあと自動で登録が外れる
return new Promise((resolve) => {
  ui.confirm.addEventListener('close', () => resolve(ui.confirm.returnValue === 'ok'), { once: true });
});

呼び出す側は、const ok = await confirmAction({ … }); と書くだけで、ボタンが押されるまで待ってから次の行へ進みます。ブラウザ標準の confirm() と同じ書き心地で、見た目をサイトに合わせたダイアログを使えます。

12

画面の広さに合わせて配置を変える

広い画面では対戦と成績を左右に並べ、スマートフォンでは対戦画面を 1 画面に収めます。成績は「成績」ボタンで、画面の下からせり上がるパネルに表示します。

スマートフォンの幅の対戦画面。上に勝ち・負け・あいこの小さな集計、コンピュータの手、結果、手のボタン、もう一度あそぶボタンが 1 画面に収まっている。
スマートフォンでは、上に小さな集計を出します。
スマートフォンの幅で成績パネルを開いたところ。画面の下から白いパネルがせり上がり、後ろの画面は暗くなっている。パネルには勝ち・負け・あいこ・勝率、書き出すなどのボタン、最近の対戦が並ぶ。
「成績」を押すと、下からパネルが開きます。

広い画面では 2 列にする

style.css(抜粋)
.layout {
  display: grid;
  gap: var(--gutter);
  padding-block-end: clamp(1rem, 3dvh, 2rem);

  /* minmax(0, …) にすると、中身が長くても列がはみ出さず、1.15 : 1 の比率を保つ */
  @media (width >= 60rem) {
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  }
}

成績のカードは縦に並べた Flexbox で、履歴の欄に flex: 1 1 0 を付けています。中身の量に関係なく、カードに残った高さを履歴の欄が使い、収まらない分は欄の中でスクロールします。

狭い画面では下から開くパネルにする

style.css(抜粋)
/* 狭い画面では、画面の下からせり上がるパネルにする */
@media (width < 60rem) {
  position: fixed;
  inset-inline: 0;
  inset-block-end: 0;
  …
  /* 閉じているときは画面の外に置き、読み上げやキーボード操作の対象からも外す */
  translate: 0 100%;
  visibility: hidden;
  …
  &.is-open {
    translate: 0 0;
    visibility: visible;
    /* 大きく広げた影で、後ろの画面を暗くする。
       vmax は画面の縦横の長いほうの 1% なので、100vmax 広げればどの画面でも全体を覆える */
    box-shadow: 0 0 0 100vmax rgb(31 28 28 / .5);
    …
  }
}

パネルを開いている間は、JavaScript が後ろの部分に inert を付けます。inert の付いた要素には、クリックもキーボードのフォーカスも届きません。後ろのスクロールは、CSS の body:has(.record.is-open) { overflow: hidden; } で止めています。

切り替えの境目は、CSS と JavaScript で同じ 60rem にそろえています。JavaScript では matchMedia() に CSS と同じ条件 (width >= 60rem) を渡して調べ、画面を広げたときに、開いていたパネルを片付けます。

置かれた場所の幅で並べ方を変える

成績の 4 つの数字(勝ち・負け・あいこ・勝率)は、成績の欄の幅が 19rem 以上なら 1 列に、狭ければ 2 列 × 2 段に並べます。成績の欄は、広い画面では右の列、狭い画面ではパネルになり、画面の幅と欄の幅が一致しません。そこで、画面の幅ではなく欄の幅を見るコンテナクエリを使っています。

style.css(抜粋)
.stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .6rem;

  /* 成績パネル(record)の幅が 19rem 以上あれば、4 つを 1 列に並べる */
  @container record (width >= 19rem) {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

コンピュータの手の円は clamp(6.5rem, min(42vw, 24dvh), 20rem) の大きさです。画面の幅(vw)と高さ(dvh)の窮屈なほうに合わせるので、背の低いスマートフォンでも 1 画面に収まります。

13

BGM と効果音を鳴らす

ゲームを始めると BGM が流れ、勝敗が決まると声の効果音が鳴ります。右上のボタンで音の ON/OFF を切り替えられ、その設定も保存されます。

index.html(抜粋)
<!-- 音声。BGM は再生するまで読み込まない -->
<audio data-sound="bgm" src="sounds/bgm.mp3" preload="none" loop></audio>
<audio data-sound="win" src="sounds/win.mp3" preload="auto"></audio>

BGM のファイルは 1.4MB ほどあるので、preload="none" で、再生するまで読み込まないようにしています。短い効果音は preload="auto" で先に読み込み、勝敗が出た瞬間に鳴らせるようにしています。

app.js(抜粋)
// set を付けると、sound.enabled = false のような代入で呼ばれる(セッター)。
// OFF にしたら、その場で BGM も止める
set enabled(value) {
  this.#enabled = Boolean(value);
  if (!this.#enabled) {
    this.#bgm?.pause();
  }
}

// ブラウザは利用者の操作がないと音を鳴らさないので、ボタンを押したときに呼ぶ
startBgm() {
  if (!this.#enabled || !this.#bgm || !this.#bgm.paused) {
    return;
  }
  SoundPlayer.#play(this.#bgm);
}

ブラウザは、ページを開いただけで音が鳴り出さないよう、利用者がボタンを押すなどの操作をするまで再生を許しません。そのため BGM は、ページを開いたときではなく、「じゃんけんを はじめる」を押したときに流し始めます。

音の ON/OFF のボタンには aria-pressed を付け、押された状態かどうかを読み上げに伝えています。CSS は [aria-pressed="false"] を見て、スピーカーのアイコンを消音のアイコンに切り替えます。

素材の利用条件

BGM と効果音は、配布サイトの素材を使っています。作例のコードは MIT ライセンスで公開していますが、音の素材はそれぞれの配布元の規約に従います。自分の作品に使うときは、配布元の規約を確かめてください。

14

PWA にして、オフラインでも遊べるようにする

本格版は、スマートフォンのホーム画面に追加して、アプリのように開けます。一度開いたあとは、インターネットにつながっていなくても遊べます。これを実現しているのが、manifest と Service Worker の 2 つのファイルです。

manifest.webmanifest(抜粋)
{
  "name": "じゃんけんゲーム",
  "short_name": "じゃんけん",
  …
  "start_url": "./",
  "display": "standalone",
  …
  "icons": [
    { "src": "images/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "images/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
  ]
}

manifest には、ホーム画面に出す名前とアイコン、開いたときの表示の仕方(standalone はブラウザの枠を出さない)を書きます。HTML からは <link rel="manifest" href="manifest.webmanifest"> で読み込みます。

Service Worker でファイルを手元に置く

Service Worker は、ページとネットワークの間で働く別のスクリプトです。sw.js は、次の 3 つの段階で働きます。

  • install … 初めて登録されたときに、遊ぶのに必要なファイルをキャッシュへ保存する
  • activate … 新しい版が働き始めるときに、古い版のキャッシュを消す
  • fetch … ページが画像や CSS を求めるたびに、キャッシュにあればそれを返す
sw.js(抜粋)
const CACHE_PREFIX = 'joyful-janken-';
const CACHE_NAME = `${CACHE_PREFIX}v3`;
…
self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_FILES)));
});
ファイルを直したら、版を上げる

キャッシュにあるファイルを優先して返すので、HTML や CSS を直しても、古いものが表示され続けます。公開するファイルを変えたら、CACHE_NAME の v3 を v4 のように上げてください。sw.js の中身が変わると、ブラウザが新しい版を見つけてインストールします。本格版では、新しい版が見つかると画面の下に「更新する」ボタンを出します。

app.js は、http: か https: で開いたときだけ Service Worker を登録します。file:// では登録できないためです。手元で試すときは、フォルダーで ruby -run -e httpd . -p 8000 を実行し、http://localhost:8000/ を開きます。

15

自分で改造してみる

少しずつ書き換えて、動きの変化を確かめてみましょう。始める前に joyful-janken フォルダーを丸ごとコピーしておくと、いつでも元に戻せます。

  1. 勝敗のメッセージを変える

    シンプル版の simple.js の MESSAGES を書き換えます。メッセージが 1 か所にまとまっているので、ほかは直さずに済みます(04 章)。

  2. シンプル版に「あいこ」の回数を足す

    scores に draw: 0 を足し、HTML にあいこの欄(data-ui="draw-score")を作って、renderScores() で表示します。あいこのときも scores[winner]++ が働くよう、if の条件も見直します(05 章)。

  3. シャッフルの速さを変える

    本格版の app.js の SHUFFLE_MS を 250 から 120 にすると、手が速く切り替わります(07 章)。

  4. 配色を変える

    style.css の --blue・--yellow・--red を変えると、ボタン、結果の色、成績の縁取りがまとめて変わります。

  5. 更新の流れを試す

    ローカルサーバーで本格版を開いたあと、sw.js の CACHE_NAME を v4 にして再読み込みします。画面の下に「更新する」ボタンが出ます(14 章)。

16

次の一歩

シンプル版の「ボタンを押す → データを変える → 画面を描く」という流れは、本格版になっても変わっていません。本格版は、その流れのまわりに、保存、ファイル、配置、音、PWA を足したものです。気になった書き方があれば、各章の「ガイド」のリンクから、該当する節を読み直してください。

作ってみよう

数当てゲームや、おみくじのような小さなゲームを作ってみましょう。まずシンプル版のように 3 ファイルで動かし、遊べるようになったら、成績の保存やファイルへの書き出しを足していくと、本格版と同じ順番で育てられます。

Canvas / 描画ブラウザの中に絵を描く座標、図形、アニメーション、操作を学びます。ゲームの画面を自由に描けるようになります。

作ったゲームを公開するなら、公開編へ進むこともできます。