目次を開く(全 16 章)
01
完成したゲームを見る
コンピュータと対戦するじゃんけんゲームです。手を選ぶとすぐ勝敗が出るシンプル版と、コンピュータの手がシャッフルされ、成績を保存できる本格版の 2 つがあります。
どちらも 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・効果音
- シンプル版を読む
simple-index.html→simple.css→simple.jsの順に読み、「ボタンを押す → データを変える → 画面を更新する」という基本の流れを掴みます(03〜05 章)。 - 本格版を読む
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 が壊れません。
<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-ui | JavaScript が中身を書き換える部品 | スコア、結果の文 |
data-action | 押すと何かが起きる部品 | リセットボタン |
data-choice | どの手のボタンか | rock・scissors・paper |
JavaScript では、最初にこの目印で部品を探しておきます。
// 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" は、中身が変わったときに読み上げてもらうための属性です。
JavaScript とつなぐ data-* 属性 誰でも使えるようにする DOM 操作 data-* aria-live
04
じゃんけんのルールをデータにまとめる
「グーはチョキに勝つ」というルールを、if 文を何行も並べて書く代わりに、オブジェクトにまとめています。
// 手ごとに「表示する絵文字」「日本語の名前」「勝てる相手」をまとめておく。
// 勝敗のルールをここに集めると、判定の 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 つを選びます。
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 というデータとして持っています。ボタンが押されたら、まずデータを変え、そのあとで画面に映します。
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 が属性セレクタで受け取って決めています。
.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() で作り直します。
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 を見て、動きと色を切り替えます。
/* シャッフル中は手を揺らす */
.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 を使っています。
// 一定の間隔でコンピュータの手を切り替える。
// 経過時間をためて判定するので、画面の更新頻度が違う端末でも速さが揃う。
// (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 回にしています。時間を短くしただけでは、繰り返す動きは高速で回り続けてしまうためです。
setInterval との使い分け アニメーション(Canvas 編) 動きをつける requestAnimationFrame() cancelAnimationFrame()
08
成績をクラスにまとめる
勝ち・負け・あいこの合計と、直近 100 戦の履歴は、JankenRecord クラスにまとめています。データと、それを扱う処理(記録する、勝率を求める、保存用の形にする)を 1 つにしたものです。
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 を返します。
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 に保存しています。ページを閉じても、次に開いたときに続きから遊べます。
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 で包み、保存できなくてもゲームは続けられるようにしています。
ページを開いたときは、保存があれば使い、なければ初期の値から始めます。
// ?. は、左側が 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 ファイルへの書き出しと、ファイルからの読み込みを用意しています。
書き出す
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 章)が返した形が、そのままファイルになります。
{
"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 で画面に表示します。
// 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() で開いています。見た目をほかのボタンとそろえるためです。
ファイルの読み書き 非同期処理 try / catch Blob URL.createObjectURL() text()
11
確認ダイアログの答えを await で待つ
成績のリセットや、いまの成績をファイルの成績で置き換える前には、確認のダイアログを出します。「やめる」「実行する」のどちらが押されたかを、await で待って受け取ります。
<!-- 確認ダイアログ。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 の結果として返します。
// 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() と同じ書き心地で、見た目をサイトに合わせたダイアログを使えます。
非同期処理(Promise) 意味に合ったタグ(dialog) <dialog> returnValue Promise
12
画面の広さに合わせて配置を変える
広い画面では対戦と成績を左右に並べ、スマートフォンでは対戦画面を 1 画面に収めます。成績は「成績」ボタンで、画面の下からせり上がるパネルに表示します。
広い画面では 2 列にする
.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 を付けています。中身の量に関係なく、カードに残った高さを履歴の欄が使い、収まらない分は欄の中でスクロールします。
狭い画面では下から開くパネルにする
/* 狭い画面では、画面の下からせり上がるパネルにする */
@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 段に並べます。成績の欄は、広い画面では右の列、狭い画面ではパネルになり、画面の幅と欄の幅が一致しません。そこで、画面の幅ではなく欄の幅を見るコンテナクエリを使っています。
.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 画面に収まります。
レスポンシブとコンテナクエリ CSS Grid Flexbox 配置と重なり :has() @container inert matchMedia()
13
BGM と効果音を鳴らす
ゲームを始めると BGM が流れ、勝敗が決まると声の効果音が鳴ります。右上のボタンで音の ON/OFF を切り替えられ、その設定も保存されます。
<!-- 音声。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" で先に読み込み、勝敗が出た瞬間に鳴らせるようにしています。
// 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 つのファイルです。
{
"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 を求めるたびに、キャッシュにあればそれを返す
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 フォルダーを丸ごとコピーしておくと、いつでも元に戻せます。
- 勝敗のメッセージを変える
シンプル版の
simple.jsのMESSAGESを書き換えます。メッセージが 1 か所にまとまっているので、ほかは直さずに済みます(04 章)。 - シンプル版に「あいこ」の回数を足す
scoresにdraw: 0を足し、HTML にあいこの欄(data-ui="draw-score")を作って、renderScores()で表示します。あいこのときもscores[winner]++が働くよう、ifの条件も見直します(05 章)。 - シャッフルの速さを変える
本格版の
app.jsのSHUFFLE_MSを250から120にすると、手が速く切り替わります(07 章)。 - 配色を変える
style.cssの--blue・--yellow・--redを変えると、ボタン、結果の色、成績の縁取りがまとめて変わります。 - 更新の流れを試す
ローカルサーバーで本格版を開いたあと、
sw.jsのCACHE_NAMEをv4にして再読み込みします。画面の下に「更新する」ボタンが出ます(14 章)。
16
次の一歩
シンプル版の「ボタンを押す → データを変える → 画面を描く」という流れは、本格版になっても変わっていません。本格版は、その流れのまわりに、保存、ファイル、配置、音、PWA を足したものです。気になった書き方があれば、各章の「ガイド」のリンクから、該当する節を読み直してください。
数当てゲームや、おみくじのような小さなゲームを作ってみましょう。まずシンプル版のように 3 ファイルで動かし、遊べるようになったら、成績の保存やファイルへの書き出しを足していくと、本格版と同じ順番で育てられます。
作ったゲームを公開するなら、公開編へ進むこともできます。