このページでは、ひなテックGamesで実際に動いている「百人一首タイピング」のソースコードを丸ごと公開しています。ここに載っているのは説明用に書き直したものではなく、いまあなたが遊んでいるゲームそのものを動かしている本物のファイルです。
上の句を見て下の句をローマ字で打つタイピングゲームで、コードの見どころは3つ。100首を現代仮名遣いのかなだけで持つデータ設計、かるた経験者が暗記する決まり字を「最短の一意な接頭辞」としてプログラムで自動計算するアルゴリズム、そして「し=si/shi」「っ=子音重ね」など複数の打ち方を全部受け付けるローマ字判定エンジンです。
コードはコピーして自分のパソコンで自由に動かせます。出題データを都道府県名やことわざに差し替えれば、まったく別のタイピングゲームにも改造できます。
百人一首タイピングは、次のファイルが役割を分担して動いています。それぞれの中身は下で1つずつ解説します。
| ファイル | 役割 |
|---|---|
index.html | ゲーム画面の骨組み。盤面・スコア表示・ボタンなどの部品をHTMLで配置する |
style.css | 見た目のデザイン。色・大きさ・レイアウト・アニメーション |
game.js | ゲームのルールを動かす頭脳。操作の受け付け・判定・スコア計算など |
この「HTMLで構造、CSSで見た目、JavaScriptで動き」という分担は、ほとんどのWebページ・Webゲームに共通する考え方です。
index.htmlは、ブラウザが最初に読み込むファイルです。ゲームの「置き場所」をHTMLで用意しているだけで、動きの処理は書かれていません。
大きく分けると、(1)かるた10首・決まり字・通し100首を選ぶmode-area、(2)タイム・ミス・ペナルティを出すscore-area、(3)読み札(上の句)と下の句の入力表示が並ぶkaruta-stage、(4)タッチ端末用の3択ボタンが入るchoice-area、(5)結果のオーバーレイ、でできています。下の句のかなやローマ字はJavaScriptがspanとして毎回作り直します。
※ 下のコードでは、ゲームの動きに関係のないタグ(広告・アクセス解析・検索エンジン向けの記述)を省いています。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>百人一首タイピング|無料ブラウザゲーム|ひなテックGames</title>
<meta name="description" content="上の句を見て下の句をローマ字で打つ百人一首タイピング!かるた10首のタイムアタック・決まり字の練習モード・通し100首を収録。決まり字は全100首から自動計算。無料ブラウザゲーム。">
<link rel="stylesheet" href="../../css/style.css">
<link rel="stylesheet" href="style.css?v=20260815">
<link rel="stylesheet" href="../../css/leaderboard.css">
</head>
<body>
<div id="header"></div>
<div class="game-page">
<h1 class="game-page-title">百人一首タイピング</h1>
<div class="game-page-tags">
<span class="tag">タイピング</span>
<span class="tag">学習</span>
</div>
<div class="game-container">
<!-- モード選択 -->
<div class="mode-area" id="mode-area">
<span class="mode-label">モード:</span>
<div class="mode-buttons">
<button class="mode-btn active" data-mode="karuta10">かるた10首<small>ランキング対象</small></button>
<button class="mode-btn" data-mode="kimariji">決まり字<small>1文字ずつ読み上げ</small></button>
<button class="mode-btn" data-mode="all100">通し100首<small>進捗セーブあり</small></button>
</div>
</div>
<!-- タッチ端末向けの案内 -->
<p class="touch-note" id="touch-note" hidden>
タッチ端末のため<strong>「下の句3択かるた」</strong>で遊べます。
全国ランキングへの登録は<strong>PCキーボード入力(かるた10首)のみ</strong>です。
</p>
<!-- スコアエリア -->
<div class="score-area">
<div class="score-box">
<span class="score-label">タイム(秒)</span>
<span class="score-value" id="time">0.0</span>
</div>
<div class="score-box">
<span class="score-label">首</span>
<span class="score-value" id="poem-count">-</span>
</div>
<div class="score-box">
<span class="score-label">ミス</span>
<span class="score-value" id="miss">0</span>
</div>
<div class="score-box">
<span class="score-label">ペナルティ</span>
<span class="score-value" id="penalty">+0.0</span>
</div>
<div class="score-box">
<span class="score-label">ベスト</span>
<span class="score-value" id="best-score">--</span>
</div>
</div>
<!-- かるたステージ -->
<div class="karuta-stage" id="karuta-stage">
<div class="stage-title">〜 ひなテック歌会 〜</div>
<div class="kami-card">
<div class="kami-text" id="kami-text"></div>
<div class="poet-name" id="poet-name"></div>
</div>
<p class="stage-message" id="stage-message">「スタート」で歌会がはじまります</p>
<div class="typing-area" id="typing-area">
<div class="shimo-kana" id="shimo-kana"></div>
<div class="shimo-romaji" id="shimo-romaji"></div>
</div>
<div class="choice-area" id="choice-area"></div>
<div class="pop-layer" id="pop-layer"></div>
</div>
<!-- アクション -->
<div class="game-actions">
<button class="btn-primary" id="btn-start">スタート</button>
<button class="btn-secondary btn-reset-progress" id="btn-reset-progress" style="display:none;">最初から</button>
<button class="btn-mute" id="btn-mute" type="button" aria-label="サウンド切り替え">🔊</button>
</div>
<!-- 結果オーバーレイ -->
<div class="result-overlay" id="result-overlay">
<div class="result-content">
<h2 id="result-title">競技終了!</h2>
<div class="result-mode-badge" id="result-mode">かるた10首</div>
<div class="result-stats">
<div class="result-stat">
<span class="result-stat-label">合計タイム</span>
<span class="result-stat-value" id="result-time">0.0秒</span>
</div>
<div class="result-stat">
<span class="result-stat-label">ミス</span>
<span class="result-stat-value" id="result-miss">0回</span>
</div>
<div class="result-stat">
<span class="result-stat-label">ペナルティ</span>
<span class="result-stat-value" id="result-penalty">+0.0秒</span>
</div>
<div class="result-stat" id="result-kimariji-row" style="display:none;">
<span class="result-stat-label">決まり字で取った</span>
<span class="result-stat-value" id="result-kimariji">0/10首</span>
</div>
</div>
<div class="result-best" id="result-best" style="display:none;">
ベストタイム更新!
</div>
<p class="result-note" id="result-note"></p>
<button class="btn-primary" id="btn-restart">もう一度遊ぶ</button>
</div>
</div>
</div>
<!-- ゲーム枠直下の広告(hinata-game-below) -->
<div class="game-instructions">
<h3>遊び方</h3>
<ul>
<li>上の句(と歌人名)が読み札として表示されます。<strong>下の句をローマ字でタイプ</strong>しよう</li>
<li><strong>かるた10首</strong>: ランダム10首の合計タイム勝負。ミスタイプ1打ごとに<strong>+0.3秒のペナルティ</strong>。全国ランキング対象!</li>
<li><strong>決まり字モード</strong>: 上の句が0.5秒に1文字ずつ現れます。<strong>決まり字</strong>(下線の文字)が出た瞬間に打ちはじめられたら「⚡決まり字で取った!」</li>
<li><strong>通し100首</strong>: 1番の天智天皇から100番の順徳院まで読み切りに挑戦。進捗は自動保存され、続きから再開できます</li>
<li>「し」は si / shi、「ちょう」は tyou / chou など<strong>複数の打ち方に全対応</strong>。おすすめの打ち方が薄く表示されます</li>
<li>読みはすべて現代仮名遣い(「けふ」→「きょう」など)。上の句の下線部分がその歌の<strong>決まり字</strong>です</li>
<li>スマホ・タブレットでは<strong>下の句3択かるた</strong>に自動で切り替わります(ランキング登録はPCキーボード入力のみ)</li>
<li>読み手の<strong>朗詠つき</strong>。本物のかるたと同じく、<strong>下の句は上の句を読み終えると表示</strong>されます(暗記していれば表示前から打ってOK)。決まり字モードの文字送りは朗詠に同期。音声: Synthesizer V AI 重音テト(🔊で消せます)</li>
<li>「ころもほすてふ(tefu)」のような<strong>歴史的仮名遣いの打ち方</strong>や、聞こえたまま「わ」(わがころもで<strong>わ</strong>)と打っても正解になります</li>
</ul>
</div>
<div class="game-promo">
<h3>このゲーム、自分でも作れるよ!</h3>
<p>
百人一首タイピングは、100首のかなデータと<br>
ローマ字判定エンジンで作られています。<br><br>
決まり字も「最短の一意な接頭辞」として<br>
プログラムで自動計算しているよ。<br><br>
ひなテックでは、こうしたゲームの<br>
作り方を楽しく学べます!
</p>
<div class="promo-links">
<a href="tutorial/" class="btn-primary">このゲームの作り方を見る →</a>
<a href="https://hinata-ya.tech/contact/" class="btn-primary">体験レッスンに申し込む →</a>
<a href="source/" class="btn-secondary">ソースコードを見る →</a>
</div>
</div>
<div class="other-games">
<a href="../../">← 他のゲームも遊ぶ</a>
</div>
</div>
<div id="footer"></div>
<script src="../../js/common.js?v=20260613c" data-base="../../"></script>
<script src="../../js/leaderboard.js"></script>
<script src="../../assets/voice/hyakunin/yomi.js?v=20260815b"></script>
<script src="game.js?v=20260815g"></script>
</body>
</html>
style.cssは見た目を作っているファイルです。画像は1枚も使わず、畳はrepeating-linear-gradientの細いしま模様、読み札はクリーム色のグラデーションと枠線で描いています。
タイピングの進み具合は色で見せます。下の句のかなは「打ち終えた字=金色」「いま打つ字=白+下線」「残り=半透明」、ローマ字は「入力済み=緑」「残り=薄い白」。上の句の決まり字部分には赤い下線(.kimariji-mark)を引いて、どこまで聞けば札が確定するかが一目で分かるようにしています。ミスしたときは@keyframesでステージ全体を小さく揺らします。
/* ============================================
百人一首タイピング - 固有スタイル
============================================ */
/* モード選択 */
.mode-area {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.mode-label {
font-size: 0.9rem;
font-weight: 600;
color: #776E65;
}
.mode-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.mode-btn {
background: #CDC1B4;
color: #776E65;
border: 2px solid transparent;
border-radius: 100px;
padding: 0.4rem 1rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
font-family: inherit;
line-height: 1.3;
}
.mode-btn small {
display: block;
font-size: 0.65rem;
font-weight: 500;
opacity: 0.85;
}
.mode-btn:hover {
background: #BBADA0;
color: #fff;
}
.mode-btn.active {
background: #8E3B46;
color: #fff;
border-color: #6E2934;
}
.mode-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* タッチ端末向けの案内 */
.touch-note {
background: #FDF6E3;
border: 1px solid #E6D9B8;
border-radius: 8px;
padding: 0.6rem 0.8rem;
font-size: 0.8rem;
color: #776E65;
margin-bottom: 1rem;
line-height: 1.6;
}
/* スコアエリア */
.score-area {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.score-box {
background: #BBADA0;
border-radius: 8px;
padding: 0.4rem 0.75rem;
text-align: center;
min-width: 60px;
flex: 1;
}
.score-label {
display: block;
font-size: 0.65rem;
color: #EEE4DA;
letter-spacing: 0.05em;
}
.score-value {
display: block;
font-size: 1.1rem;
font-weight: 700;
color: #fff;
}
/* ============================================
かるたステージ(畳の上の読み札・取り札)
============================================ */
.karuta-stage {
position: relative;
background:
repeating-linear-gradient(90deg,
rgba(255, 255, 255, 0.035) 0px, rgba(255, 255, 255, 0.035) 2px,
transparent 2px, transparent 8px),
linear-gradient(180deg, #2D5A3D 0%, #24492F 100%);
border: 4px solid #1B3A24;
border-radius: 12px;
padding: 1.1rem 1rem 1.4rem;
margin-bottom: 1rem;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
.karuta-stage.shake {
animation: stage-shake 0.18s ease;
}
@keyframes stage-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.stage-title {
text-align: center;
font-size: 0.8rem;
color: #E8DFC8;
letter-spacing: 0.35em;
margin-bottom: 0.7rem;
opacity: 0.7;
user-select: none;
}
/* 読み札(上の句) */
.kami-card {
background: linear-gradient(180deg, #F7EDD5 0%, #F0E2C0 100%);
border: 2px solid #C9B78C;
border-radius: 10px;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.3);
max-width: 560px;
margin: 0 auto 0.9rem;
padding: 0.9rem 1rem 0.7rem;
text-align: center;
min-height: 5.4rem;
}
.kami-text {
font-size: 1.5rem;
font-weight: 700;
color: #3B2A1A;
line-height: 1.5;
min-height: 2.2rem;
}
/* 句のかたまり。行の折返しは5・7・5の句の切れ目でだけ起きる */
.kphrase { display: inline-block; }
.kami-text .kimariji-mark {
color: #8E3B46;
border-bottom: 3px solid #C0392B;
}
.kami-text .kami-waiting {
color: #B7A57C;
letter-spacing: 0.2em;
}
.poet-name {
margin-top: 0.35rem;
font-size: 0.85rem;
color: #7A6845;
min-height: 1.2rem;
}
.stage-message {
text-align: center;
color: #E8DFC8;
font-size: 0.95rem;
margin: 0.5rem 0 0.75rem;
}
/* 取り札(下の句の入力表示) */
.typing-area {
text-align: center;
min-height: 4.6rem;
}
.shimo-kana {
font-size: 1.35rem;
font-weight: 700;
min-height: 2rem;
line-height: 1.5;
}
.shimo-kana .k-done { color: #F1C40F; }
.shimo-kana .k-cur {
color: #fff;
border-bottom: 2px solid #F1C40F;
}
.shimo-kana .k-rest { color: rgba(255, 255, 255, 0.45); }
.shimo-romaji {
font-family: 'Courier New', 'SF Mono', 'Consolas', monospace;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.08em;
min-height: 1.8rem;
margin-top: 0.3rem;
word-break: break-all;
}
.shimo-romaji .r-done { color: #2ECC71; }
.shimo-romaji .r-rest { color: rgba(255, 255, 255, 0.55); }
/* タッチ端末(3択モード)ではタイピング表示を使わない */
.touch-mode .typing-area {
display: none;
}
/* 3択かるた(タッチ端末) */
.choice-area {
display: flex;
flex-direction: column;
gap: 0.55rem;
max-width: 480px;
margin: 0 auto;
}
.choice-btn {
background: linear-gradient(180deg, #F7EDD5 0%, #EEDFBB 100%);
border: 2px solid #C9B78C;
border-radius: 10px;
padding: 0.7rem 0.9rem;
font-size: 1.05rem;
font-weight: 700;
color: #3B2A1A;
cursor: pointer;
font-family: inherit;
line-height: 1.4;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25);
transition: transform 0.1s;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.choice-btn:active {
transform: scale(0.98);
}
.choice-btn.choice-correct {
background: #2ECC71;
border-color: #27AE60;
color: #fff;
}
.choice-btn.choice-wrong {
background: #B03A2E;
border-color: #922B21;
color: #F5B7B1;
opacity: 0.75;
}
/* ミス・ボーナスのポップ表示 */
.pop-layer {
position: absolute;
top: 16%;
left: 0;
right: 0;
display: flex;
justify-content: center;
pointer-events: none;
z-index: 4;
}
.score-pop {
position: absolute;
font-size: 1.2rem;
font-weight: 800;
color: #F1C40F;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
animation: pop-rise 0.9s ease forwards;
}
.score-pop.pop-miss {
color: #FF8A80;
}
.score-pop.pop-kimariji {
color: #FFD54F;
font-size: 1.45rem;
}
@keyframes pop-rise {
0% { transform: translateY(0); opacity: 0; }
15% { opacity: 1; }
100% { transform: translateY(-44px); opacity: 0; }
}
/* アクションボタン */
.game-actions {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.game-actions .btn-primary {
min-width: 160px;
font-size: 1.1rem;
padding: 0.8rem 2.5rem;
}
.btn-reset-progress {
font-size: 0.9rem;
}
.btn-mute {
background: #fff;
border: 2px solid #EEE4DA;
border-radius: 50%;
width: 46px;
height: 46px;
font-size: 1.2rem;
cursor: pointer;
transition: border-color 0.2s;
}
.btn-mute:hover {
border-color: #8E3B46;
}
/* ============================================
結果オーバーレイ
============================================ */
.result-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 200;
padding: 1rem;
animation: overlay-appear 0.3s ease;
}
.result-overlay.active {
display: flex;
}
@keyframes overlay-appear {
0% { opacity: 0; }
100% { opacity: 1; }
}
.result-content {
background: #fff;
border-radius: 16px;
padding: 2rem 1.5rem;
max-width: 420px;
width: 100%;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
animation: result-pop 0.4s ease;
}
@keyframes result-pop {
0% { transform: scale(0.8); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.result-content h2 {
font-size: 1.5rem;
color: #776E65;
margin-bottom: 0.5rem;
}
.result-mode-badge {
display: inline-block;
background: linear-gradient(135deg, #8E3B46, #C0392B);
color: #fff;
font-size: 1rem;
font-weight: 800;
border-radius: 100px;
padding: 0.4rem 1.4rem;
margin-bottom: 1rem;
letter-spacing: 0.1em;
box-shadow: 0 3px 10px rgba(142, 59, 70, 0.35);
}
.result-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.result-stat {
background: #FAF8F0;
border-radius: 8px;
padding: 0.75rem 0.5rem;
}
.result-stat:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
.result-stat-label {
display: block;
font-size: 0.75rem;
color: #999;
margin-bottom: 0.25rem;
}
.result-stat-value {
display: block;
font-size: 1.3rem;
font-weight: 700;
color: #776E65;
}
.result-best {
color: #E67E22;
font-weight: 700;
font-size: 1rem;
margin-bottom: 1rem;
animation: best-glow 1s ease-in-out infinite alternate;
}
@keyframes best-glow {
0% { opacity: 0.7; }
100% { opacity: 1; text-shadow: 0 0 10px rgba(230, 126, 34, 0.3); }
}
.result-note {
font-size: 0.78rem;
color: #999;
margin-bottom: 0.75rem;
min-height: 1em;
}
.result-content .btn-primary {
margin-top: 0.5rem;
}
/* レスポンシブ */
@media (min-width: 600px) {
.kami-text { font-size: 1.75rem; }
.shimo-kana { font-size: 1.55rem; }
.shimo-romaji { font-size: 1.4rem; }
}
@media (max-width: 420px) {
.score-area { gap: 0.35rem; }
.score-box { min-width: 48px; padding: 0.35rem 0.4rem; }
.score-label { font-size: 0.58rem; }
.score-value { font-size: 0.95rem; }
.kami-text { font-size: 1.15rem; }
.shimo-kana { font-size: 1.05rem; }
.shimo-romaji { font-size: 1rem; }
.mode-btn { padding: 0.35rem 0.7rem; font-size: 0.8rem; }
.choice-btn { font-size: 0.95rem; }
.result-stats { gap: 0.5rem; }
.result-stat-value { font-size: 1.1rem; }
}
game.jsがこのゲームの心臓部です。全体が(function () { ... })();という「即時実行関数」で囲まれ、変数が他のコードとぶつからないようになっています。
先頭のPOEMSが百人一首100首のデータです。1首は{ no, kami, shimo, poet }のオブジェクトで、読みは「けふ」→「きょう」のように現代仮名遣いに正規化したひらがなだけ。漢字を持たないのは、タイピングに必要なのが「打つ音」だけだからです。
computeKimariji()が見どころのアルゴリズム。各歌の上の句について「先頭1文字で他の歌と被らないか?被るなら2文字では?」と伸ばしていき、他のどの歌とも被らない最短の接頭辞=決まり字を求めます。決まり字をデータに書き込まず計算で出すので、データを直せば決まり字も自動で正しくなります。「む・す・め・ふ・さ・ほ・せ」の7首だけが1字決まりになるという百人一首の有名な性質は、自動テストでデータ誤りを見つける検出器として使っています。
ローマ字判定はKANAテーブル(かな→打ち方候補の配列)とacceptKey()のペアです。1キー打たれるたびに「その入力で始まる候補が残っているか」を調べ、残っていなければミス。「し」は si/shi/ci、「っ」は子音重ねと xtu、「ん」は次の文字によって n 1打を許すかを判定します。かるた10首モードはミス1打を+0.3秒に換算し、10首の合計タイムを1/10秒の整数にしてLeaderboard.show()でランキングに登録します(短いほど上位なのでorder: 'asc')。タッチ端末はmatchMedia('(pointer: coarse)')で判定して下の句3択かるたに自動で切り替え、こちらはランキング登録なしです。
// ============================================
// 百人一首タイピング ロジック
// ============================================
//
// 上の句を見て下の句をローマ字で打つタイピングゲーム。
// - かるた10首: ランダム10首の合計タイム勝負(ランキング対象・ミス1打=+0.3秒)
// - 決まり字モード: 上の句が1文字ずつ現れる。決まり字で取れたら演出(練習用)
// - 通し100首: 1番から100番まで。進捗はlocalStorageに保存
// - タッチ端末では「下の句3択かるた」に自動切替(ランキング登録なし)
//
// ローマ字判定は games/sushi-typing/game.js のエンジンを流用。
// 「si / shi」「ja / zya / jya」「っ=子音重ね」「ん=n / nn」など
// 複数の打ち方を全て受け付ける。
//
// 百人一首の読みは現代仮名遣いに正規化してある(けふ→きょう 等)。
// 決まり字(何文字目で1首に確定するか)はデータに持たせず、
// 全100首の上の句から「最短一意接頭辞」として起動時に計算する。
(function () {
'use strict';
// ============================================
// 百人一首データ(現代仮名遣い・すべてひらがな)
// no: 歌番号 / kami: 上の句 / shimo: 下の句 / poet: 歌人
// ============================================
var POEMS = [
{ no: 1, kami: 'あきのたのかりほのいおのとまをあらみ', shimo: 'わがころもではつゆにぬれつつ', poet: '天智天皇' },
{ no: 2, kami: 'はるすぎてなつきにけらししろたえの', shimo: 'ころもほすちょうあまのかぐやま', poet: '持統天皇' },
{ no: 3, kami: 'あしびきのやまどりのおのしだりおの', shimo: 'ながながしよをひとりかもねむ', poet: '柿本人麻呂' },
{ no: 4, kami: 'たごのうらにうちいでてみればしろたえの', shimo: 'ふじのたかねにゆきはふりつつ', poet: '山部赤人' },
{ no: 5, kami: 'おくやまにもみじふみわけなくしかの', shimo: 'こえきくときぞあきはかなしき', poet: '猿丸大夫' },
{ no: 6, kami: 'かささぎのわたせるはしにおくしもの', shimo: 'しろきをみればよぞふけにける', poet: '中納言家持' },
{ no: 7, kami: 'あまのはらふりさけみればかすがなる', shimo: 'みかさのやまにいでしつきかも', poet: '阿倍仲麻呂' },
{ no: 8, kami: 'わがいおはみやこのたつみしかぞすむ', shimo: 'よをうじやまとひとはいうなり', poet: '喜撰法師' },
{ no: 9, kami: 'はなのいろはうつりにけりないたずらに', shimo: 'わがみよにふるながめせしまに', poet: '小野小町' },
{ no: 10, kami: 'これやこのゆくもかえるもわかれては', shimo: 'しるもしらぬもおうさかのせき', poet: '蝉丸' },
{ no: 11, kami: 'わたのはらやそしまかけてこぎいでぬと', shimo: 'ひとにはつげよあまのつりぶね', poet: '参議篁' },
{ no: 12, kami: 'あまつかぜくものかよいじふきとじよ', shimo: 'おとめのすがたしばしとどめむ', poet: '僧正遍昭' },
{ no: 13, kami: 'つくばねのみねよりおつるみなのがわ', shimo: 'こいぞつもりてふちとなりぬる', poet: '陽成院' },
{ no: 14, kami: 'みちのくのしのぶもじずりたれゆえに', shimo: 'みだれそめにしわれならなくに', poet: '河原左大臣' },
{ no: 15, kami: 'きみがためはるののにいでてわかなつむ', shimo: 'わがころもでにゆきはふりつつ', poet: '光孝天皇' },
{ no: 16, kami: 'たちわかれいなばのやまのみねにおうる', shimo: 'まつとしきかばいまかえりこむ', poet: '中納言行平' },
{ no: 17, kami: 'ちはやぶるかみよもきかずたつたがわ', shimo: 'からくれないにみずくくるとは', poet: '在原業平朝臣' },
{ no: 18, kami: 'すみのえのきしによるなみよるさえや', shimo: 'ゆめのかよいじひとめよくらむ', poet: '藤原敏行朝臣' },
{ no: 19, kami: 'なにわがたみじかきあしのふしのまも', shimo: 'あわでこのよをすぐしてよとや', poet: '伊勢' },
{ no: 20, kami: 'わびぬればいまはたおなじなにわなる', shimo: 'みをつくしてもあわむとぞおもう', poet: '元良親王' },
{ no: 21, kami: 'いまこむといいしばかりにながつきの', shimo: 'ありあけのつきをまちいでつるかな', poet: '素性法師' },
{ no: 22, kami: 'ふくからにあきのくさきのしおるれば', shimo: 'むべやまかぜをあらしというらむ', poet: '文屋康秀' },
{ no: 23, kami: 'つきみればちぢにものこそかなしけれ', shimo: 'わがみひとつのあきにはあらねど', poet: '大江千里' },
{ no: 24, kami: 'このたびはぬさもとりあえずたむけやま', shimo: 'もみじのにしきかみのまにまに', poet: '菅家' },
{ no: 25, kami: 'なにしおわばおうさかやまのさねかずら', shimo: 'ひとにしられでくるよしもがな', poet: '三条右大臣' },
{ no: 26, kami: 'おぐらやまみねのもみじばこころあらば', shimo: 'いまひとたびのみゆきまたなむ', poet: '貞信公' },
{ no: 27, kami: 'みかのはらわきてながるるいずみがわ', shimo: 'いつみきとてかこいしかるらむ', poet: '中納言兼輔' },
{ no: 28, kami: 'やまざとはふゆぞさびしさまさりける', shimo: 'ひとめもくさもかれぬとおもえば', poet: '源宗于朝臣' },
{ no: 29, kami: 'こころあてにおらばやおらむはつしもの', shimo: 'おきまどわせるしらぎくのはな', poet: '凡河内躬恒' },
{ no: 30, kami: 'ありあけのつれなくみえしわかれより', shimo: 'あかつきばかりうきものはなし', poet: '壬生忠岑' },
{ no: 31, kami: 'あさぼらけありあけのつきとみるまでに', shimo: 'よしののさとにふれるしらゆき', poet: '坂上是則' },
{ no: 32, kami: 'やまがわにかぜのかけたるしがらみは', shimo: 'ながれもあえぬもみじなりけり', poet: '春道列樹' },
{ no: 33, kami: 'ひさかたのひかりのどけきはるのひに', shimo: 'しずごころなくはなのちるらむ', poet: '紀友則' },
{ no: 34, kami: 'たれをかもしるひとにせむたかさごの', shimo: 'まつもむかしのともならなくに', poet: '藤原興風' },
{ no: 35, kami: 'ひとはいさこころもしらずふるさとは', shimo: 'はなぞむかしのかににおいける', poet: '紀貫之' },
{ no: 36, kami: 'なつのよはまだよいながらあけぬるを', shimo: 'くものいずこにつきやどるらむ', poet: '清原深養父' },
{ no: 37, kami: 'しらつゆにかぜのふきしくあきののは', shimo: 'つらぬきとめぬたまぞちりける', poet: '文屋朝康' },
{ no: 38, kami: 'わすらるるみをばおもわずちかいてし', shimo: 'ひとのいのちのおしくもあるかな', poet: '右近' },
{ no: 39, kami: 'あさじうのおののしのはらしのぶれど', shimo: 'あまりてなどかひとのこいしき', poet: '参議等' },
{ no: 40, kami: 'しのぶれどいろにいでにけりわがこいは', shimo: 'ものやおもうとひとのとうまで', poet: '平兼盛' },
{ no: 41, kami: 'こいすちょうわがなはまだきたちにけり', shimo: 'ひとしれずこそおもいそめしか', poet: '壬生忠見' },
{ no: 42, kami: 'ちぎりきなかたみにそでをしぼりつつ', shimo: 'すえのまつやまなみこさじとは', poet: '清原元輔' },
{ no: 43, kami: 'あいみてののちのこころにくらぶれば', shimo: 'むかしはものをおもわざりけり', poet: '権中納言敦忠' },
{ no: 44, kami: 'あうことのたえてしなくはなかなかに', shimo: 'ひとをもみをもうらみざらまし', poet: '中納言朝忠' },
{ no: 45, kami: 'あわれともいうべきひとはおもおえで', shimo: 'みのいたずらになりぬべきかな', poet: '謙徳公' },
{ no: 46, kami: 'ゆらのとをわたるふなびとかじをたえ', shimo: 'ゆくえもしらぬこいのみちかな', poet: '曽禰好忠' },
{ no: 47, kami: 'やえむぐらしげれるやどのさびしきに', shimo: 'ひとこそみえねあきはきにけり', poet: '恵慶法師' },
{ no: 48, kami: 'かぜをいたみいわうつなみのおのれのみ', shimo: 'くだけてものをおもうころかな', poet: '源重之' },
{ no: 49, kami: 'みかきもりえじのたくひのよるはもえ', shimo: 'ひるはきえつつものをこそおもえ', poet: '大中臣能宣朝臣' },
{ no: 50, kami: 'きみがためおしからざりしいのちさえ', shimo: 'ながくもがなとおもいけるかな', poet: '藤原義孝' },
{ no: 51, kami: 'かくとだにえやはいぶきのさしもぐさ', shimo: 'さしもしらじなもゆるおもいを', poet: '藤原実方朝臣' },
{ no: 52, kami: 'あけぬればくるるものとはしりながら', shimo: 'なおうらめしきあさぼらけかな', poet: '藤原道信朝臣' },
{ no: 53, kami: 'なげきつつひとりぬるよのあくるまは', shimo: 'いかにひさしきものとかはしる', poet: '右大将道綱母' },
{ no: 54, kami: 'わすれじのゆくすえまではかたければ', shimo: 'きょうをかぎりのいのちともがな', poet: '儀同三司母' },
{ no: 55, kami: 'たきのおとはたえてひさしくなりぬれど', shimo: 'なこそながれてなおきこえけれ', poet: '大納言公任' },
{ no: 56, kami: 'あらざらむこのよのほかのおもいでに', shimo: 'いまひとたびのあうこともがな', poet: '和泉式部' },
{ no: 57, kami: 'めぐりあいてみしやそれともわかぬまに', shimo: 'くもがくれにしよわのつきかな', poet: '紫式部' },
{ no: 58, kami: 'ありまやまいなのささはらかぜふけば', shimo: 'いでそよひとをわすれやはする', poet: '大弐三位' },
{ no: 59, kami: 'やすらわでねなましものをさよふけて', shimo: 'かたぶくまでのつきをみしかな', poet: '赤染衛門' },
{ no: 60, kami: 'おおえやまいくののみちのとおければ', shimo: 'まだふみもみずあまのはしだて', poet: '小式部内侍' },
{ no: 61, kami: 'いにしえのならのみやこのやえざくら', shimo: 'きょうここのえににおいぬるかな', poet: '伊勢大輔' },
{ no: 62, kami: 'よをこめてとりのそらねははかるとも', shimo: 'よにおうさかのせきはゆるさじ', poet: '清少納言' },
{ no: 63, kami: 'いまはただおもいたえなむとばかりを', shimo: 'ひとづてならでいうよしもがな', poet: '左京大夫道雅' },
{ no: 64, kami: 'あさぼらけうじのかわぎりたえだえに', shimo: 'あらわれわたるせぜのあじろぎ', poet: '権中納言定頼' },
{ no: 65, kami: 'うらみわびほさぬそでだにあるものを', shimo: 'こいにくちなむなこそおしけれ', poet: '相模' },
{ no: 66, kami: 'もろともにあわれとおもえやまざくら', shimo: 'はなよりほかにしるひともなし', poet: '前大僧正行尊' },
{ no: 67, kami: 'はるのよのゆめばかりなるたまくらに', shimo: 'かいなくたたむなこそおしけれ', poet: '周防内侍' },
{ no: 68, kami: 'こころにもあらでうきよにながらえば', shimo: 'こいしかるべきよわのつきかな', poet: '三条院' },
{ no: 69, kami: 'あらしふくみむろのやまのもみじばは', shimo: 'たつたのかわのにしきなりけり', poet: '能因法師' },
{ no: 70, kami: 'さびしさにやどをたちいでてながむれば', shimo: 'いずこもおなじあきのゆうぐれ', poet: '良暹法師' },
{ no: 71, kami: 'ゆうさればかどたのいなばおとずれて', shimo: 'あしのまろやにあきかぜぞふく', poet: '大納言経信' },
{ no: 72, kami: 'おとにきくたかしのはまのあだなみは', shimo: 'かけじやそでのぬれもこそすれ', poet: '祐子内親王家紀伊' },
{ no: 73, kami: 'たかさごのおのえのさくらさきにけり', shimo: 'とやまのかすみたたずもあらなむ', poet: '前権中納言匡房' },
{ no: 74, kami: 'うかりけるひとをはつせのやまおろしよ', shimo: 'はげしかれとはいのらぬものを', poet: '源俊頼朝臣' },
{ no: 75, kami: 'ちぎりおきしさせもがつゆをいのちにて', shimo: 'あわれことしのあきもいぬめり', poet: '藤原基俊' },
{ no: 76, kami: 'わたのはらこぎいでてみればひさかたの', shimo: 'くもいにまがうおきつしらなみ', poet: '法性寺入道前関白太政大臣' },
{ no: 77, kami: 'せをはやみいわにせかるるたきがわの', shimo: 'われてもすえにあわむとぞおもう', poet: '崇徳院' },
{ no: 78, kami: 'あわじしまかようちどりのなくこえに', shimo: 'いくよねざめぬすまのせきもり', poet: '源兼昌' },
{ no: 79, kami: 'あきかぜにたなびくくものたえまより', shimo: 'もれいずるつきのかげのさやけさ', poet: '左京大夫顕輔' },
{ no: 80, kami: 'ながからむこころもしらずくろかみの', shimo: 'みだれてけさはものをこそおもえ', poet: '待賢門院堀河' },
{ no: 81, kami: 'ほととぎすなきつるかたをながむれば', shimo: 'ただありあけのつきぞのこれる', poet: '後徳大寺左大臣' },
{ no: 82, kami: 'おもいわびさてもいのちはあるものを', shimo: 'うきにたえぬはなみだなりけり', poet: '道因法師' },
{ no: 83, kami: 'よのなかよみちこそなけれおもいいる', shimo: 'やまのおくにもしかぞなくなる', poet: '皇太后宮大夫俊成' },
{ no: 84, kami: 'ながらえばまたこのごろやしのばれむ', shimo: 'うしとみしよぞいまはこいしき', poet: '藤原清輔朝臣' },
{ no: 85, kami: 'よもすがらものおもうころはあけやらで', shimo: 'ねやのひまさえつれなかりけり', poet: '俊恵法師' },
{ no: 86, kami: 'なげけとてつきやはものをおもわする', shimo: 'かこちがおなるわがなみだかな', poet: '西行法師' },
{ no: 87, kami: 'むらさめのつゆもまだひぬまきのはに', shimo: 'きりたちのぼるあきのゆうぐれ', poet: '寂蓮法師' },
{ no: 88, kami: 'なにわえのあしのかりねのひとよゆえ', shimo: 'みをつくしてやこいわたるべき', poet: '皇嘉門院別当' },
{ no: 89, kami: 'たまのおよたえなばたえねながらえば', shimo: 'しのぶることのよわりもぞする', poet: '式子内親王' },
{ no: 90, kami: 'みせばやなおじまのあまのそでだにも', shimo: 'ぬれにぞぬれしいろはかわらず', poet: '殷富門院大輔' },
{ no: 91, kami: 'きりぎりすなくやしもよのさむしろに', shimo: 'ころもかたしきひとりかもねむ', poet: '後京極摂政前太政大臣' },
{ no: 92, kami: 'わがそではしおひにみえぬおきのいしの', shimo: 'ひとこそしらねかわくまもなし', poet: '二条院讃岐' },
{ no: 93, kami: 'よのなかはつねにもがもななぎさこぐ', shimo: 'あまのおぶねのつなでかなしも', poet: '鎌倉右大臣' },
{ no: 94, kami: 'みよしののやまのあきかぜさよふけて', shimo: 'ふるさとさむくころもうつなり', poet: '参議雅経' },
{ no: 95, kami: 'おおけなくうきよのたみにおおうかな', shimo: 'わがたつそまにすみぞめのそで', poet: '前大僧正慈円' },
{ no: 96, kami: 'はなさそうあらしのにわのゆきならで', shimo: 'ふりゆくものはわがみなりけり', poet: '入道前太政大臣' },
{ no: 97, kami: 'こぬひとをまつほのうらのゆうなぎに', shimo: 'やくやもしおのみもこがれつつ', poet: '権中納言定家' },
{ no: 98, kami: 'かぜそよぐならのおがわのゆうぐれは', shimo: 'みそぎぞなつのしるしなりける', poet: '従二位家隆' },
{ no: 99, kami: 'ひともおしひともうらめしあじきなく', shimo: 'よをおもうゆえにものおもうみは', poet: '後鳥羽院' },
{ no: 100, kami: 'ももしきやふるきのきばのしのぶにも', shimo: 'なおあまりあるむかしなりけり', poet: '順徳院' }
];
// ============================================
// ローマ字変換テーブル(かな → 打ち方の候補)
// games/sushi-typing/game.js からコピーして流用。
// 候補の先頭が画面に表示される「おすすめの打ち方」
// ============================================
var KANA = {
'あ': ['a'], 'い': ['i', 'yi'], 'う': ['u', 'wu', 'whu'], 'え': ['e'], 'お': ['o'],
'か': ['ka', 'ca'], 'き': ['ki'], 'く': ['ku', 'cu', 'qu'], 'け': ['ke'], 'こ': ['ko', 'co'],
'さ': ['sa'], 'し': ['si', 'shi', 'ci'], 'す': ['su'], 'せ': ['se', 'ce'], 'そ': ['so'],
'た': ['ta'], 'ち': ['ti', 'chi'], 'つ': ['tu', 'tsu'], 'て': ['te'], 'と': ['to'],
'な': ['na'], 'に': ['ni'], 'ぬ': ['nu'], 'ね': ['ne'], 'の': ['no'],
'は': ['ha'], 'ひ': ['hi'], 'ふ': ['fu', 'hu'], 'へ': ['he'], 'ほ': ['ho'],
'ま': ['ma'], 'み': ['mi'], 'む': ['mu'], 'め': ['me'], 'も': ['mo'],
'や': ['ya'], 'ゆ': ['yu'], 'よ': ['yo'],
'ら': ['ra'], 'り': ['ri'], 'る': ['ru'], 'れ': ['re'], 'ろ': ['ro'],
'わ': ['wa'], 'を': ['wo'], 'ん': ['nn', 'xn'],
'が': ['ga'], 'ぎ': ['gi'], 'ぐ': ['gu'], 'げ': ['ge'], 'ご': ['go'],
'ざ': ['za'], 'じ': ['zi', 'ji'], 'ず': ['zu'], 'ぜ': ['ze'], 'ぞ': ['zo'],
'だ': ['da'], 'ぢ': ['di'], 'づ': ['du'], 'で': ['de'], 'ど': ['do'],
'ば': ['ba'], 'び': ['bi'], 'ぶ': ['bu'], 'べ': ['be'], 'ぼ': ['bo'],
'ぱ': ['pa'], 'ぴ': ['pi'], 'ぷ': ['pu'], 'ぺ': ['pe'], 'ぽ': ['po'],
'きゃ': ['kya'], 'きゅ': ['kyu'], 'きょ': ['kyo'],
'しゃ': ['sya', 'sha'], 'しゅ': ['syu', 'shu'], 'しょ': ['syo', 'sho'],
'ちゃ': ['tya', 'cha', 'cya'], 'ちゅ': ['tyu', 'chu', 'cyu'], 'ちょ': ['tyo', 'cho', 'cyo'],
'にゃ': ['nya'], 'にゅ': ['nyu'], 'にょ': ['nyo'],
'ひゃ': ['hya'], 'ひゅ': ['hyu'], 'ひょ': ['hyo'],
'みゃ': ['mya'], 'みゅ': ['myu'], 'みょ': ['myo'],
'りゃ': ['rya'], 'りゅ': ['ryu'], 'りょ': ['ryo'],
'ぎゃ': ['gya'], 'ぎゅ': ['gyu'], 'ぎょ': ['gyo'],
'じゃ': ['zya', 'ja', 'jya'], 'じゅ': ['zyu', 'ju', 'jyu'], 'じょ': ['zyo', 'jo', 'jyo'],
'びゃ': ['bya'], 'びゅ': ['byu'], 'びょ': ['byo'],
'ぴゃ': ['pya'], 'ぴゅ': ['pyu'], 'ぴょ': ['pyo'],
'ふぁ': ['fa'], 'ふぃ': ['fi'], 'ふぇ': ['fe'], 'ふぉ': ['fo'],
'てぃ': ['thi'], 'でぃ': ['dhi'], 'でゅ': ['dhu'],
'うぃ': ['wi'], 'うぇ': ['we'],
'ぁ': ['xa', 'la'], 'ぃ': ['xi', 'li'], 'ぅ': ['xu', 'lu'], 'ぇ': ['xe', 'le'], 'ぉ': ['xo', 'lo'],
'ゃ': ['xya', 'lya'], 'ゅ': ['xyu', 'lyu'], 'ょ': ['xyo', 'lyo'],
'っ': ['xtu', 'ltu', 'ltsu'],
'ゐ': ['wi'], 'ゑ': ['we'],
'ー': ['-']
};
// 「ん」を1打の n で打てない(nn が必要な)次の文字の頭
var N_NEEDS_DOUBLE = 'aiueony';
// ============================================
// ローマ字エンジン(sushi-typing と同じ判定方式)
// ============================================
function dedupe(arr) {
var seen = {};
return arr.filter(function (s) {
if (seen[s]) return false;
seen[s] = true;
return true;
});
}
// かな文字列をトークン列に分解する。
// 「っ」「ん」は次のかなと結合した1トークンにして、
// 「子音重ね」「1打のn」を候補として正しく扱う。
function tokenize(kana) {
// まず拗音(2文字)優先でかなを切り出す
var units = [];
var i = 0;
while (i < kana.length) {
var two = kana.substr(i, 2);
if (two.length === 2 && KANA[two]) {
units.push(two);
i += 2;
} else {
units.push(kana.charAt(i));
i += 1;
}
}
var tokens = [];
for (var j = 0; j < units.length; j++) {
var u = units[j];
var next = units[j + 1];
var cands;
if (u === 'っ' && next && KANA[next]) {
// っ: 次の候補の頭の子音を重ねる(ppo など)か xtu/ltu/ltsu
cands = [];
KANA[next].forEach(function (s) {
var c = s.charAt(0);
if ('aiueon'.indexOf(c) === -1) cands.push(c + s);
});
KANA['っ'].forEach(function (p) {
KANA[next].forEach(function (s) { cands.push(p + s); });
});
tokens.push({ kana: 'っ' + next, candidates: dedupe(cands) });
j++;
} else if (u === 'ん' && next && KANA[next]) {
// ん: 次が母音・な行・や行でなければ1打の n も許可
cands = [];
KANA[next].forEach(function (s) {
if (N_NEEDS_DOUBLE.indexOf(s.charAt(0)) === -1) cands.push('n' + s);
});
KANA[next].forEach(function (s) {
cands.push('nn' + s);
cands.push('xn' + s);
});
tokens.push({ kana: 'ん' + next, candidates: dedupe(cands) });
j++;
} else {
tokens.push({ kana: u, candidates: (KANA[u] || [u]).slice() });
}
}
return tokens;
}
// 現在の入力に合う候補のうち先頭のもの(表示用)
function bestCandidate(token, typed) {
for (var i = 0; i < token.candidates.length; i++) {
if (token.candidates[i].indexOf(typed) === 0) return token.candidates[i];
}
return token.candidates[0];
}
// かな1句ぶんの入力状態
function createWordState(kana) {
return {
kana: kana,
tokens: tokenize(kana),
ti: 0, // 何トークン目か
typed: '' // 現在トークンへの入力
};
}
// 1キー入力を処理。true=正解 / false=ミス
// ============================================
// 下の句の別表記(打ち方の拡張)
// 百人一首は歴史的仮名遣い(てふ⇔ちょう・おもふ⇔おもう 等)や、
// 朗詠の発音(助詞は⇔わ・あうこと⇔おうこと)でも打てるようにする。
// データは scratchpad/koten.mjs で機械検証済み(モーラ数・トークン数・仮名対応ペア)
// ============================================
var ALT_SHIMO = {
1: ['わがころもでわつゆにぬれつつ'],
2: ['ころもほすてふあまのかぐやま'],
4: ['ふじのたかねにゆきわふりつつ'],
5: ['こゑきくときぞあきはかなしき', 'こえきくときぞあきわかなしき'],
8: ['よをうぢやまとひとはいふなり', 'よをうじやまとひとわいうなり'],
10: ['しるもしらぬもあふさかのせき'],
11: ['ひとにわつげよあまのつりぶね'],
12: ['をとめのすがたしばしとどめむ'],
13: ['こひぞつもりてふちとなりぬる'],
15: ['わがころもでにゆきわふりつつ'],
16: ['まつとしきかばいまかへりこむ'],
17: ['からくれなゐにみづくくるとは', 'からくれないにみずくくるとわ'],
18: ['ゆめのかよひぢひとめよくらむ'],
19: ['あはでこのよをすぐしてよとや'],
20: ['みをつくしてもあはむとぞおもふ'],
22: ['むべやまかぜをあらしといふらむ'],
23: ['わがみひとつのあきにわあらねど'],
24: ['もみぢのにしきかみのまにまに'],
27: ['いつみきとてかこひしかるらむ'],
28: ['ひとめもくさもかれぬとおもへば'],
29: ['おきまどはせるしらぎくのはな'],
30: ['あかつきばかりうきものわなし'],
32: ['ながれもあへぬもみぢなりけり'],
33: ['しづごころなくはなのちるらむ'],
35: ['はなぞむかしのかににほひける'],
36: ['くものいづこにつきやどるらむ'],
38: ['ひとのいのちのをしくもあるかな'],
39: ['あまりてなどかひとのこひしき'],
40: ['ものやおもふとひとのとふまで'],
41: ['ひとしれずこそおもひそめしか'],
42: ['すゑのまつやまなみこさじとは', 'すえのまつやまなみこさじとわ'],
43: ['むかしはものをおもはざりけり', 'むかしわものをおもわざりけり'],
45: ['みのいたづらになりぬべきかな'],
46: ['ゆくへもしらぬこひのみちかな'],
47: ['ひとこそみえねあきわきにけり'],
48: ['くだけてものをおもふころかな'],
49: ['ひるはきえつつものをこそおもへ', 'ひるわきえつつものをこそおもえ'],
50: ['ながくもがなとおもひけるかな'],
51: ['さしもしらじなもゆるおもひを'],
52: ['なほうらめしきあさぼらけかな'],
53: ['いかにひさしきものとかわしる'],
54: ['けふをかぎりのいのちともがな'],
55: ['なこそながれてなほきこえけれ'],
56: ['いまひとたびのあふこともがな', 'いまひとたびのおうこともがな'],
57: ['くもがくれにしよはのつきかな'],
58: ['いでそよひとをわすれやわする'],
61: ['けふここのへににほひぬるかな'],
62: ['よにあふさかのせきはゆるさじ', 'よにおうさかのせきわゆるさじ'],
63: ['ひとづてならでいふよしもがな'],
64: ['あらはれわたるせぜのあじろぎ'],
65: ['こひにくちなむなこそをしけれ'],
67: ['かひなくたたむなこそをしけれ'],
68: ['こひしかるべきよはのつきかな'],
69: ['たつたのかはのにしきなりけり'],
70: ['いづこもおなじあきのゆふぐれ'],
74: ['はげしかれとわいのらぬものを'],
75: ['あはれことしのあきもいぬめり'],
76: ['くもゐにまがふおきつしらなみ'],
77: ['われてもすゑにあはむとぞおもふ'],
79: ['もれいづるつきのかげのさやけさ'],
80: ['みだれてけさはものをこそおもへ', 'みだれてけさわものをこそおもえ'],
82: ['うきにたへぬはなみだなりけり', 'うきにたえぬわなみだなりけり'],
84: ['うしとみしよぞいまはこひしき', 'うしとみしよぞいまわこいしき'],
85: ['ねやのひまさへつれなかりけり'],
86: ['かこちがほなるわがなみだかな'],
87: ['きりたちのぼるあきのゆふぐれ'],
88: ['みをつくしてやこひわたるべき'],
90: ['ぬれにぞぬれしいろはかはらず', 'ぬれにぞぬれしいろわかわらず'],
92: ['ひとこそしらねかはくまもなし'],
93: ['あまのをぶねのつなでかなしも'],
96: ['ふりゆくものわわがみなりけり'],
97: ['やくやもしほのみもこがれつつ'],
99: ['よをおもふゆゑにものおもふみは', 'よをおもうゆえにものおもうみわ'],
100: ['なほあまりあるむかしなりけり']
};
// 別表記のローマ字候補を取り込む。トークン数が食い違う場合は何もしない(安全側)
function mergeAltShimo(state, no) {
var alts = ALT_SHIMO[no];
if (!alts) return state;
for (var a = 0; a < alts.length; a++) {
var altTokens = tokenize(alts[a]);
if (altTokens.length !== state.tokens.length) continue;
for (var i = 0; i < state.tokens.length; i++) {
if (altTokens[i].kana !== state.tokens[i].kana) {
state.tokens[i].candidates = dedupe(state.tokens[i].candidates.concat(altTokens[i].candidates));
}
}
}
return state;
}
function acceptKey(state, ch) {
var token = state.tokens[state.ti];
if (!token) return false;
var pref = state.typed + ch;
var alive = token.candidates.some(function (c) { return c.indexOf(pref) === 0; });
if (!alive) return false;
state.typed = pref;
if (token.candidates.indexOf(pref) !== -1) {
// トークン完成 → 次へ
token.done = pref;
state.ti++;
state.typed = '';
}
return true;
}
function isWordComplete(state) {
return state.ti >= state.tokens.length;
}
// 表示用ローマ字: [打ち終えた部分, これから打つ部分]
function romajiParts(state) {
var done = '';
var rest = '';
for (var i = 0; i < state.tokens.length; i++) {
var t = state.tokens[i];
if (i < state.ti) {
done += t.done;
} else if (i === state.ti) {
var cand = bestCandidate(t, state.typed);
done += state.typed;
rest += cand.slice(state.typed.length);
} else {
rest += t.candidates[0];
}
}
return [done, rest];
}
// ============================================
// 決まり字の計算
// 各歌の上の句について「他のどの歌とも被らない最短の接頭辞」を求める。
// 戻り値: { 歌番号: '決まり字文字列' }
// ============================================
function computeKimariji(poems) {
var map = {};
poems.forEach(function (p) {
var len = 1;
for (; len < p.kami.length; len++) {
var pre = p.kami.slice(0, len);
var dup = poems.some(function (q) {
return q.no !== p.no && q.kami.slice(0, len) === pre;
});
if (!dup) break;
}
map[p.no] = p.kami.slice(0, len);
});
return map;
}
// Node.js でのテスト用エクスポート(ブラウザでは無視される)
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
POEMS: POEMS,
KANA: KANA,
tokenize: tokenize,
bestCandidate: bestCandidate,
createWordState: createWordState,
acceptKey: acceptKey,
isWordComplete: isWordComplete,
romajiParts: romajiParts,
computeKimariji: computeKimariji,
mergeAltShimo: mergeAltShimo
};
return;
}
// ============================================
// ここからブラウザ用のゲーム本体
// ============================================
var KIMARIJI = computeKimariji(POEMS);
var PENALTY_MS = 300; // タイピングのミス1打 = +0.3秒
var CHOICE_PENALTY_MS = 1000; // 3択のお手つき1回 = +1秒
var REVEAL_INTERVAL = 500; // 決まり字モード: 朗詠が使えない時の予備(0.5秒に1文字)
var KARUTA_COUNT = 10; // かるた10首・決まり字・3択で読む首数
// ---- 朗詠音声(Synthesizer V 重音テトで生成したmp3)----
// 実測タイミングは assets/voice/hyakunin/yomi.js。決まり字モードの文字送りは
// 朗詠の発声時刻に同期する。読み込めない環境では従来の一定速表示で続行
var VOICE_BASE = '../../assets/voice/hyakunin/';
var YOMI = window.HYAKU_YOMI || null;
var voiceEl = null;
var serveAt = 0; // 出題時刻(音声が使えない時の文字送りの基準)
function playVoice(no) {
stopVoice();
if (!YOMI || !YOMI[no]) return;
voiceEl = new Audio(VOICE_BASE + ('00' + no).slice(-3) + '.mp3');
voiceEl.muted = muted;
var p = voiceEl.play();
if (p && p.catch) p.catch(function () { /* 自動再生拒否などは無音で続行 */ });
}
function stopVoice() {
if (voiceEl) { try { voiceEl.pause(); } catch (e) { /* 無視 */ } voiceEl = null; }
}
// 次の歌の音声を先読みしておく(歌の切り替わりで頭が欠けないように)
function preloadVoice(no) {
if (!no || !YOMI || !YOMI[no]) return;
var a = new Audio(VOICE_BASE + ('00' + no).slice(-3) + '.mp3');
a.preload = 'auto';
}
// 本物のかるたと同じく、上の句を読んでいる間は下の句(入力欄・3択)を伏せる
var shimoTimer = null;
function setShimoHidden(hidden) {
var v = hidden ? 'hidden' : '';
els.typingArea.style.visibility = v;
els.choiceArea.style.visibility = v;
}
function scheduleShimoReveal(no) {
if (shimoTimer) { clearTimeout(shimoTimer); shimoTimer = null; }
var y = YOMI && YOMI[no];
if (y) {
setShimoHidden(true);
shimoTimer = setTimeout(function () { setShimoHidden(false); }, y.ke); // 上の句を読み終えたら公開
} else {
setShimoHidden(false); // 朗詠データが無い環境では従来どおりすぐ見せる
}
}
// タッチデバイスは「下の句3択かるた」に自動切替(設計で確定)
var isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
// DOM要素
var els = {
time: document.getElementById('time'),
poemCount: document.getElementById('poem-count'),
miss: document.getElementById('miss'),
penalty: document.getElementById('penalty'),
best: document.getElementById('best-score'),
stage: document.getElementById('karuta-stage'),
kamiText: document.getElementById('kami-text'),
poetName: document.getElementById('poet-name'),
typingArea: document.getElementById('typing-area'),
shimoKana: document.getElementById('shimo-kana'),
shimoRomaji: document.getElementById('shimo-romaji'),
choiceArea: document.getElementById('choice-area'),
popLayer: document.getElementById('pop-layer'),
stageMsg: document.getElementById('stage-message'),
touchNote: document.getElementById('touch-note'),
modeArea: document.getElementById('mode-area'),
btnStart: document.getElementById('btn-start'),
btnResetProgress: document.getElementById('btn-reset-progress'),
btnMute: document.getElementById('btn-mute'),
btnRestart: document.getElementById('btn-restart'),
overlay: document.getElementById('result-overlay'),
rTitle: document.getElementById('result-title'),
rMode: document.getElementById('result-mode'),
rTime: document.getElementById('result-time'),
rMiss: document.getElementById('result-miss'),
rPenalty: document.getElementById('result-penalty'),
rKimarijiRow: document.getElementById('result-kimariji-row'),
rKimariji: document.getElementById('result-kimariji'),
rBest: document.getElementById('result-best'),
rNote: document.getElementById('result-note')
};
var modeBtns = document.querySelectorAll('.mode-btn');
var MODE_LABELS = {
karuta10: 'かるた10首',
kimariji: '決まり字モード',
all100: '通し100首',
choice: '下の句3択かるた'
};
// ゲーム状態
var mode = isTouch ? 'choice' : 'karuta10';
var phase = 'idle'; // idle / playing / over
var queue = []; // 出題する歌の配列
var idx = 0; // 何首目か
var wordState = null;
var rafId = null;
var playStart = 0; // 今セッションの開始時刻
var baseElapsedMs = 0; // 通し100首の再開時に引き継ぐ経過時間
var penaltyMs = 0;
var missCount = 0;
var correctKeys = 0;
// 決まり字モード用
var revealCount = 0;
var revealTimer = null;
var firstKeyReveal = null; // 最初の打鍵時に何文字表示されていたか
var poemMiss = false; // この歌でお手つきしたか
var kimarijiCatches = 0; // 決まり字で取れた数
var PROGRESS_KEY = 'hyakuninTyping100';
// ============================================
// サウンド(WebAudio・音声ファイルは使わない)
// ============================================
var audioCtx = null;
var muted = localStorage.getItem('hyakuninTypingMuted') === '1';
// 札取り効果音の読み込み(初回のensureAudioで開始)
var LOADING = 'loading';
var takeBuf = null;
function loadTakeSound() {
if (takeBuf !== null || !audioCtx) return;
takeBuf = LOADING;
fetch('../../assets/se/karuta-take.mp3')
.then(function (r) { return r.arrayBuffer(); })
.then(function (ab) { return audioCtx.decodeAudioData(ab); })
.then(function (b) { takeBuf = b; })
.catch(function () { takeBuf = null; });
}
function ensureAudio() {
if (!audioCtx) {
var AC = window.AudioContext || window.webkitAudioContext;
if (AC) audioCtx = new AC();
}
if (audioCtx && audioCtx.state === 'suspended') audioCtx.resume();
loadTakeSound();
}
function blip(freq, dur, type, gain, delay) {
if (muted || !audioCtx) return;
var t = audioCtx.currentTime + (delay || 0);
var osc = audioCtx.createOscillator();
var g = audioCtx.createGain();
osc.type = type || 'square';
osc.frequency.value = freq;
g.gain.setValueAtTime(gain || 0.04, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
osc.connect(g).connect(audioCtx.destination);
osc.start(t);
osc.stop(t + dur);
}
var sounds = {
type: function () { blip(1800, 0.045, 'square', 0.09); }, // 朗詠中でも聞こえる音量・高さ
miss: function () { blip(130, 0.2, 'sawtooth', 0.18); }, // ミスと即分かる強さ
// 札を取った「すぱーん」: 効果音ラボの打撃2+ハリセン(-3半音)を重ねたmp3。
// WebAudio合成では質感が出なかったため、この音だけファイル再生(assets/se/karuta-take.mp3)
take: function () {
if (muted || !audioCtx) return;
if (takeBuf && takeBuf !== LOADING) {
var s = audioCtx.createBufferSource();
s.buffer = takeBuf;
var g = audioCtx.createGain();
g.gain.value = 0.9;
s.connect(g);
g.connect(audioCtx.destination);
s.start();
} else {
// 読み込み前・失敗時の予備音
blip(660, 0.08, 'triangle', 0.06);
blip(990, 0.12, 'triangle', 0.06, 0.07);
}
},
kimariji: function () { blip(880, 0.1, 'sine', 0.06); blip(1320, 0.18, 'sine', 0.06, 0.09); },
finish: function () {
blip(660, 0.12, 'triangle', 0.06);
blip(880, 0.12, 'triangle', 0.06, 0.12);
blip(1100, 0.25, 'triangle', 0.06, 0.24);
}
};
function updateMuteButton() {
els.btnMute.textContent = muted ? '🔇' : '🔊';
els.btnMute.setAttribute('aria-label', muted ? 'サウンドをオンにする' : 'サウンドをオフにする');
}
// ============================================
// ユーティリティ
// ============================================
function shuffle(arr) {
var a = arr.slice();
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
function escapeHtml(str) {
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
// 1/10秒の整数 → 「X分Y.Y秒」表示
function formatDeci(deci) {
var min = Math.floor(deci / 600);
var sec = (deci % 600) / 10;
var secStr = sec.toFixed(1) + '秒';
return min > 0 ? min + '分' + secStr : secStr;
}
function totalMs() {
var live = phase === 'playing' ? performance.now() - playStart : 0;
return baseElapsedMs + live + penaltyMs;
}
// ベストタイム(1/10秒整数)をモード別に保存
function bestKey() { return 'hyakuninTypingBest_' + mode; }
function getBest() { return parseInt(localStorage.getItem(bestKey()) || '0', 10); }
function setBest(v) { localStorage.setItem(bestKey(), String(v)); }
// 通し100首の進捗
function loadProgress() {
try {
var raw = localStorage.getItem(PROGRESS_KEY);
if (!raw) return null;
var p = JSON.parse(raw);
if (typeof p.idx !== 'number' || p.idx < 1 || p.idx >= POEMS.length) return null;
return p;
} catch (e) {
return null;
}
}
function saveProgress() {
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
idx: idx,
elapsedMs: baseElapsedMs + (performance.now() - playStart),
penaltyMs: penaltyMs,
miss: missCount
}));
}
function clearProgress() {
localStorage.removeItem(PROGRESS_KEY);
}
// ============================================
// 表示
// ============================================
function updateHud() {
var deci = Math.round(totalMs() / 100);
els.time.textContent = (deci / 10).toFixed(1);
els.poemCount.textContent = phase === 'idle' ? '-' : (idx + 1) + '/' + queue.length;
els.miss.textContent = missCount;
els.penalty.textContent = '+' + (penaltyMs / 1000).toFixed(1);
var best = getBest();
els.best.textContent = best > 0 ? formatDeci(best) : '--';
}
function loop() {
if (phase !== 'playing') return;
updateHud();
rafId = requestAnimationFrame(loop);
}
// 上の句を表示。決まり字部分に下線マーク。
// 決まり字モードでは revealCount 文字だけ見せる。
// 上の句の句切れ(1句目・2句目のモーラ数)。字余りで5・7からずれる歌だけ列挙
var KAMI_KUGIRE = {
4: [6, 8], 9: [6, 7], 25: [6, 7], 29: [6, 7], 48: [6, 7], 55: [6, 7], 57: [6, 7], 75: [6, 7],
15: [5, 8], 24: [5, 8], 31: [5, 8], 40: [5, 8], 70: [5, 8], 76: [5, 8], 85: [5, 8]
};
// 下の句の句切れ(1句目のモーラ数)。字余りで7からずれる歌だけ列挙
var SHIMO_KUGIRE = { 21: 8, 79: 8, 99: 8 };
function moraUnits(kana) {
var units = [];
for (var i = 0; i < kana.length; i++) {
var c = kana.charAt(i);
if ('ゃゅょぁぃぅぇぉ'.indexOf(c) !== -1 && units.length) units[units.length - 1] += c;
else units.push(c);
}
return units;
}
function kamiPhrases(poem) {
var units = moraUnits(poem.kami);
var counts = KAMI_KUGIRE[poem.no] || [5, 7];
return [
units.slice(0, counts[0]).join(''),
units.slice(counts[0], counts[0] + counts[1]).join(''),
units.slice(counts[0] + counts[1]).join('')
];
}
// 上の句は句ごとのかたまり(.kphrase)で組む。句の途中で行が折れないように
function renderKami(poem) {
var kimariji = KIMARIJI[poem.no];
var shownLen = mode === 'kimariji' ? revealCount : poem.kami.length;
var phrases = kamiPhrases(poem);
var html = '';
var off = 0;
for (var p = 0; p < phrases.length && off < shownLen; p++) {
var ph = phrases[p];
var span = '';
for (var i = 0; i < ph.length && off + i < shownLen; i++) {
var cls = (off + i) < kimariji.length ? 'kimariji-mark' : '';
span += cls ? '<span class="' + cls + '">' + escapeHtml(ph[i]) + '</span>' : escapeHtml(ph[i]);
}
if (span) html += '<span class="kphrase">' + span + '</span> ';
off += ph.length;
}
els.kamiText.innerHTML = html || '<span class="kami-waiting">・・・</span>';
els.poetName.textContent = mode === 'kimariji' && revealCount < poem.kami.length ? '' : poem.poet;
}
// 下の句のかな+ローマ字(未入力=グレー / 入力済=色付き)。7・7の句のかたまりで組む
function renderShimo() {
if (!wordState) return;
var breakAt = SHIMO_KUGIRE[queue[idx].no] || 7;
var html = '<span class="kphrase">';
var acc = 0;
for (var i = 0; i < wordState.tokens.length; i++) {
var t = wordState.tokens[i];
var cls = i < wordState.ti ? 'k-done' : (i === wordState.ti ? 'k-cur' : 'k-rest');
html += '<span class="' + cls + '">' + escapeHtml(t.kana) + '</span>';
acc += moraUnits(t.kana).length;
if (acc === breakAt && i < wordState.tokens.length - 1) html += '</span> <span class="kphrase">';
}
html += '</span>';
els.shimoKana.innerHTML = html;
var parts = romajiParts(wordState);
els.shimoRomaji.innerHTML =
'<span class="r-done">' + escapeHtml(parts[0]) + '</span>' +
'<span class="r-rest">' + escapeHtml(parts[1]) + '</span>';
}
function popText(text, cls) {
var el = document.createElement('div');
el.className = 'score-pop ' + (cls || '');
el.textContent = text;
els.popLayer.appendChild(el);
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, 900);
}
function shakeStage() {
els.stage.classList.remove('shake');
void els.stage.offsetWidth; // アニメを毎回最初から再生する
els.stage.classList.add('shake');
}
// ============================================
// 出題
// ============================================
function servePoem() {
var poem = queue[idx];
poemMiss = false;
firstKeyReveal = null;
playVoice(poem.no);
preloadVoice(queue[idx + 1] && queue[idx + 1].no);
serveAt = performance.now();
scheduleShimoReveal(poem.no);
if (mode === 'choice') {
wordState = null;
renderKami(poem);
renderChoices(poem);
} else {
wordState = mergeAltShimo(createWordState(poem.shimo), poem.no);
if (mode === 'kimariji') {
revealCount = 1; // 1文字目はすぐ見せる
if (revealTimer) clearInterval(revealTimer);
var yk = YOMI && YOMI[poem.no] && YOMI[poem.no].k;
revealTimer = setInterval(function () {
if (phase !== 'playing') return;
var target;
if (yk) {
// 朗詠の発声時刻に同期して文字を出す(音声が止まっていたら経過時間で代用)
var t = (voiceEl && !voiceEl.paused && voiceEl.currentTime > 0)
? voiceEl.currentTime * 1000
: performance.now() - serveAt;
target = 1;
while (target < poem.kami.length && t >= yk[target]) target++;
} else {
target = revealCount + 1; // 従来: 0.5秒に1文字
}
if (target > revealCount) {
revealCount = Math.min(target, poem.kami.length);
renderKami(poem);
}
if (revealCount >= poem.kami.length) {
clearInterval(revealTimer);
revealTimer = null;
}
}, yk ? 100 : REVEAL_INTERVAL);
}
renderKami(poem);
renderShimo();
}
updateHud();
}
// 3択の選択肢(正解の下の句+他の歌からランダム2つ)
function renderChoices(poem) {
var others = shuffle(POEMS.filter(function (p) { return p.no !== poem.no; })).slice(0, 2);
var options = shuffle([poem].concat(others));
els.choiceArea.innerHTML = '';
options.forEach(function (p) {
var btn = document.createElement('button');
btn.className = 'choice-btn';
btn.type = 'button';
btn.textContent = p.shimo;
// click は離した瞬間で数十ms遅いので pointerdown で計測
btn.addEventListener('pointerdown', function (e) {
e.preventDefault();
handleChoice(btn, p, poem);
});
els.choiceArea.appendChild(btn);
});
}
function handleChoice(btn, picked, poem) {
if (phase !== 'playing') return;
if (picked.no === poem.no) {
btn.classList.add('choice-correct');
sounds.take();
poemDone();
} else {
if (btn.classList.contains('choice-wrong')) return; // 同じ札の連打は1回だけ
btn.classList.add('choice-wrong');
missCount++;
penaltyMs += CHOICE_PENALTY_MS;
sounds.miss();
popText('お手つき +1.0秒', 'pop-miss');
shakeStage();
updateHud();
}
}
// 1首打ち切った
function poemDone() {
// 決まり字モード: 決まり字が出た瞬間(またはそれ以前)に入力開始して
// お手つきなしで取れたらボーナス演出
if (mode === 'kimariji' && !poemMiss &&
firstKeyReveal !== null && firstKeyReveal <= KIMARIJI[queue[idx].no].length) {
kimarijiCatches++;
sounds.kimariji();
popText('⚡ 決まり字で取った!', 'pop-kimariji');
} else if (mode !== 'choice') {
sounds.take();
}
if (revealTimer) { clearInterval(revealTimer); revealTimer = null; }
idx++;
if (mode === 'all100') {
if (idx < queue.length) saveProgress();
else clearProgress();
}
if (idx >= queue.length) {
endGame();
} else {
servePoem();
}
}
// ============================================
// キー入力(PCタイピングモード)
// ============================================
function handleChar(ch) {
if (phase !== 'playing' || !wordState) return;
ch = ch.toLowerCase();
if (!/^[a-z\-]$/.test(ch)) return;
if (firstKeyReveal === null) firstKeyReveal = revealCount;
if (acceptKey(wordState, ch)) {
correctKeys++;
sounds.type();
renderShimo();
if (isWordComplete(wordState)) poemDone();
} else {
missCount++;
poemMiss = true;
penaltyMs += PENALTY_MS;
sounds.miss();
popText('ミス +0.3秒', 'pop-miss');
shakeStage();
}
updateHud();
}
document.addEventListener('keydown', function (e) {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (phase === 'playing' && mode !== 'choice') {
if (e.key.length === 1) {
e.preventDefault();
handleChar(e.key);
}
return;
}
// Enterでスタート(結果画面が出ていないとき)
if (e.key === 'Enter' && phase !== 'playing' && !els.overlay.classList.contains('active')) {
e.preventDefault();
startGame();
}
if (e.key === 'Escape' && els.overlay.classList.contains('active')) {
els.overlay.classList.remove('active');
}
});
// ============================================
// ゲーム進行
// ============================================
function startGame() {
ensureAudio();
idx = 0;
baseElapsedMs = 0;
penaltyMs = 0;
missCount = 0;
correctKeys = 0;
kimarijiCatches = 0;
if (mode === 'all100') {
queue = POEMS.slice(); // 1番から100番まで順に
var progress = loadProgress();
if (progress) {
idx = progress.idx;
baseElapsedMs = progress.elapsedMs || 0;
penaltyMs = progress.penaltyMs || 0;
missCount = progress.miss || 0;
}
} else {
queue = shuffle(POEMS).slice(0, KARUTA_COUNT);
}
phase = 'playing';
playStart = performance.now();
els.overlay.classList.remove('active');
els.btnStart.disabled = true;
els.btnStart.textContent = 'プレイ中...';
modeBtns.forEach(function (b) { b.disabled = true; });
els.stageMsg.style.display = 'none';
els.stage.classList.add('playing');
servePoem();
loop();
}
function endGame() {
// 経過時間を確定させてから phase を変える(totalMs は playing 中しか実時間を足さないため)
baseElapsedMs += performance.now() - playStart;
phase = 'over';
if (rafId) cancelAnimationFrame(rafId);
if (revealTimer) { clearInterval(revealTimer); revealTimer = null; }
if (shimoTimer) { clearTimeout(shimoTimer); shimoTimer = null; }
setShimoHidden(false);
stopVoice();
sounds.finish();
var deci = Math.round(totalMs() / 100);
els.btnStart.disabled = false;
modeBtns.forEach(function (b) { b.disabled = false; });
els.stage.classList.remove('playing');
els.kamiText.textContent = 'お見事!';
els.poetName.textContent = '';
els.shimoKana.textContent = '';
els.shimoRomaji.textContent = '';
els.choiceArea.innerHTML = '';
var isNewBest = getBest() === 0 || deci < getBest();
if (isNewBest) setBest(deci);
els.rTitle.textContent = mode === 'all100' ? '百首読み切り!' : '競技終了!';
els.rMode.textContent = MODE_LABELS[mode];
els.rTime.textContent = formatDeci(deci);
els.rMiss.textContent = missCount + '回';
els.rPenalty.textContent = '+' + (penaltyMs / 1000).toFixed(1) + '秒';
els.rKimarijiRow.style.display = mode === 'kimariji' ? '' : 'none';
els.rKimariji.textContent = kimarijiCatches + '/' + queue.length + '首';
els.rBest.style.display = isNewBest ? 'block' : 'none';
if (mode === 'karuta10') {
els.rNote.textContent = '';
// かるた10首だけがランキング対象(1/10秒の整数・小さいほど上位)
if (window.Leaderboard) {
Leaderboard.show('hyakunin-typing', deci, { order: 'asc', format: 'time-deci' });
}
} else if (mode === 'choice') {
els.rNote.textContent = '3択かるたはランキング登録対象外です(ランキングはPCキーボード入力のみ)';
} else {
els.rNote.textContent = 'このモードは練習用のためランキング登録はありません';
}
els.overlay.classList.add('active');
updateStartButton();
updateHud();
phase = 'idle';
}
// ============================================
// UIイベント
// ============================================
function updateStartButton() {
if (mode === 'all100') {
var progress = loadProgress();
if (progress) {
els.btnStart.textContent = 'つづきから(' + (progress.idx + 1) + '首目〜)';
els.btnResetProgress.style.display = '';
updateHud();
return;
}
}
els.btnStart.textContent = 'スタート';
els.btnResetProgress.style.display = 'none';
updateHud();
}
modeBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
if (phase === 'playing') return;
modeBtns.forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
mode = btn.getAttribute('data-mode');
els.stageMsg.style.display = '';
els.kamiText.textContent = '';
els.poetName.textContent = '';
els.shimoKana.textContent = '';
els.shimoRomaji.textContent = '';
updateStartButton();
});
});
els.btnStart.addEventListener('click', function () {
if (phase !== 'playing') startGame();
});
els.btnResetProgress.addEventListener('click', function () {
if (phase === 'playing') return;
clearProgress();
updateStartButton();
});
els.btnRestart.addEventListener('click', function () {
els.overlay.classList.remove('active');
startGame();
});
els.overlay.addEventListener('click', function (e) {
if (e.target === els.overlay) els.overlay.classList.remove('active');
});
els.btnMute.addEventListener('click', function () {
muted = !muted;
localStorage.setItem('hyakuninTypingMuted', muted ? '1' : '0');
if (voiceEl) voiceEl.muted = muted;
updateMuteButton();
});
// ============================================
// 初期化
// ============================================
if (isTouch) {
// タッチ端末: 3択かるた固定。モード選択を隠して案内を出す
mode = 'choice';
els.modeArea.style.display = 'none';
els.touchNote.hidden = false;
document.body.classList.add('touch-mode');
}
updateMuteButton();
updateStartButton();
updateHud();
})();
上のコードをそれぞれのファイル名で同じフォルダに保存し、index.htmlをブラウザで開けばゲームが動きます。各コードブロックのファイル名バーにある「コピー」ボタンを使うと便利です。
このゲームはサイト全体で共通して使うファイル(共通CSSやヘッダー)も少し利用しているため、ソースのファイルだけで開くとヘッダーなどサイト共通の部分は表示されません。でもゲーム本体はちゃんと動くので、仕組みを学んだり改造してためすには十分です。
慣れてきたら、色を変えたりルールを少しいじったり、自由に改造してみてください。コードを「読む」次は「いじってみる」のがいちばんの上達法です。
このソースコードは、プログラミングの勉強のために自由に読んだり、コピーして動かしたり、改造したりして大丈夫です。学校の授業や自由研究で参考にするのも歓迎します。ぜひ「自分だったらどう作るか」を考えるきっかけにしてください。
ただし、コードをそのまままるごとコピーして自分の作品やサービスとして公開・配布するのはご遠慮ください。あくまで学習用としてお使いください。
コードを読んで「自分でも作ってみたい!」と思ったら、百人一首タイピングの作り方ガイドがおすすめです。このページが「完成したコード」を見せるのに対して、作り方ガイドは何もない状態から1ステップずつ組み立てていく流れを解説しています。
ひなテックでは、こうしたゲームづくりを先生といっしょに楽しく学べる教室を開いています。「コードを読むだけじゃなく、ちゃんと作れるようになりたい」という人は、体験レッスンにぜひ来てみてください。