このシリーズについて
- 第1回:タイピングWebアプリ制作|ホームポジション練習編
- 第2回:タイピングWebアプリ制作|画面キーボード・指ガイド編
- 第3回:タイピングWebアプリ制作|段別練習・4段キーボード編
- 第4回:タイピングWebアプリ制作|サウンド・途中終了編
- 第5回:タイピングWebアプリ制作|数字段・テンキー練習編
- 第6回:タイピングWebアプリ制作|複数段選択・入力テンポ改善編
- 第7回:タイピングWebアプリ制作|チャレンジモード編
- 第8回:タイピングWebアプリ制作|段別計測・成績保存編
- 第9回:タイピングWebアプリ制作|成績分析・CSVバックアップ編
- 第10回:タイピングWebアプリ制作|日本語ローマ字入力・Ver.1.0完成編
第4回では、Web Audio APIを使った効果音と、Escキーによる途中終了を追加しました。今回は、これまで表示だけだった数字段を正式な練習対象にし、さらにテンキー練習を追加していきます。
テンキーは、1文字ずつ落ちてくる通常のタイピング練習とは少し性格が違います。Excelなどで数字を続けて入力するときの感覚を意識し、3~5桁の数字を順番に入力し、最後にEnterで確定する練習にしました。
AIと作業を分担して作ります
このシリーズでは、必要な機能や画面の動き、実際に使ったときの違和感や修正点を人間側で考え、HTML・CSS・JavaScriptの作成をAIのノンに担当してもらっています。
今回も、最初に「数字段とテンキーを練習したい」「次の数字を先読みできるようにしたい」などの仕様を相談し、試作したものを実際に触りながら表示方法や効果音を調整しました。
完成形を最初から一発で決めるのではなく、実際に操作して「ここは見にくい」「この音は合わない」と感じた部分を、その都度修正しながら作っています。
今回作るもの
第5回では、通常キーボードの数字段とテンキー練習を追加します。完成版では、開始前に「数字段・上段・ホーム段・下段・テンキー」から練習モードを選べるようになりました。
- 通常キーボードの数字段を練習対象に追加
- テンキー練習モードを追加
- テンキーの5に突起を表示
- テンキー用の右手指ガイドを表示
- 3~5桁の数字をランダム出題
- カンマは表示するが入力はしない
- 数字を1文字ずつ正誤判定
- 最後にEnterを押して1問確定
- 次の問題を先読みできる数字リール表示
- テンキー入力用の効果音を聞き比べて選定

第4回から追加する機能
第4回までは、上段・ホームポジション段・下段から1段を選び、1文字ずつ落ちてくる文字を入力していました。第5回では数字段を正式に追加し、テンキーだけは別の練習方法にしています。
テンキーでは、現在の数字を入力しながら次の数字を先読みできることを重視しました。そのため、通常の落下文字ではなく、数字が縦方向へ送られる「数字リール」のような表示に変更しています。
通常タイピングは「表示された1文字を押す練習」、テンキーは「複数桁の数字を連続入力する練習」として、同じアプリ内でも少し違う見せ方にしました。
ファイル構成
第5回では、完成版の3ファイルに加えて、効果音を聞き比べるために作った試作ページも残します。
05/
├─ index.html
├─ style.css
├─ script.js
└─ サウンド/
├─ index.html
├─ style.css
└─ script.js「05」フォルダ直下の3ファイルが第5回の完成版です。「サウンド」フォルダには、今回の効果音を選ぶために作った試聴用ページを入れています。
完成したソースコード
まずは、第5回で完成したソースコードをまとめて掲載します。下の「詳細」を開くと、それぞれのコードを確認できます。
第5回 完成版
index.html のソースコードを表示
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>段別・テンキー タイピング練習</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="app">
<header class="app__header">
<div><p class="app__eyebrow">のんびり研究所</p><h1>段別・テンキー タイピング練習</h1></div>
<dl class="status" aria-label="練習状況">
<div class="status__item"><dt>残り時間</dt><dd><span id="time">60</span><small>秒</small></dd></div>
<div class="status__item"><dt>スコア</dt><dd id="score">0</dd></div>
<div class="status__item"><dt>ミス</dt><dd id="misses">0</dd></div>
</dl>
</header>
<section id="play-area" class="play-area" aria-label="タイピング練習エリア">
<div id="falling-key" class="falling-key" aria-live="off" hidden>A</div>
<div id="effect-layer" class="effect-layer" aria-hidden="true"></div>
<div id="numpad-question" class="numpad-question" hidden>
<div class="number-reel" aria-label="テンキー入力問題">
<div class="number-reel__frame" aria-hidden="true"></div>
<div id="number-track" class="number-track" aria-live="polite"></div>
</div>
<p class="comma-note">カンマは打ちません</p>
</div>
<div id="start-panel" class="panel start-panel">
<p class="panel__label">練習モードを選びましょう</p>
<fieldset class="mode-selector">
<legend class="visually-hidden">練習モード</legend>
<label class="mode-option"><input type="radio" name="practice-mode" value="number"><span>数字段<small>1 2 3 4 5 6 7 8 9 0</small></span></label>
<label class="mode-option"><input type="radio" name="practice-mode" value="top"><span>上段<small>Q W E R T Y U I O P</small></span></label>
<label class="mode-option"><input type="radio" name="practice-mode" value="home" checked><span>ホーム段<small>A S D F G H J K L ;</small></span></label>
<label class="mode-option"><input type="radio" name="practice-mode" value="bottom"><span>下段<small>Z X C V B N M , . /</small></span></label>
<label class="mode-option mode-option--numpad"><input type="radio" name="practice-mode" value="numpad"><span>テンキー<small>3~5桁+Enter</small></span></label>
</fieldset>
<p class="panel__main"><kbd>Space</kbd> キーでスタート</p>
<div class="sound-setting"><span>SE</span><button id="sound-toggle" class="sound-toggle" type="button" aria-pressed="true">ON</button><small>※音が出ます</small></div>
<p id="start-description" class="panel__sub">60秒間、表示された文字を入力しましょう</p>
</div>
<div id="countdown" class="countdown" aria-live="assertive" hidden></div>
<div id="exit-panel" class="panel exit-panel" role="dialog" aria-modal="true" aria-labelledby="exit-title" hidden>
<p class="panel__label">練習を一時停止しています</p><h2 id="exit-title">練習を途中で終了しますか?</h2>
<div class="exit-panel__buttons"><button id="exit-practice" class="dialog-button dialog-button--danger" type="button">終了する</button><button id="continue-practice" class="dialog-button" type="button">続ける</button></div>
</div>
<div id="result-panel" class="panel result" hidden>
<p class="panel__label">練習終了!</p><h2>今回の結果</h2>
<div class="result__scores"><p>スコア<strong id="final-score">0</strong></p><p>ミス<strong id="final-misses">0</strong></p></div>
<p class="panel__sub">もう一度練習するときは <kbd>Space</kbd> キー</p><button id="change-mode" class="change-mode" type="button">練習モードを変更</button>
</div>
</section>
<section class="guide" aria-label="キーと担当指ガイド">
<div class="guide__header"><p id="guide-title" class="guide__title">光っているキーを、光っている指で押しましょう</p><p id="exit-hint" class="exit-hint" hidden><kbd>Esc</kbd>:途中終了</p></div>
<div id="standard-guide">
<div class="keyboard-viewport"><div id="screen-keyboard" class="screen-keyboard" aria-label="4段の画面キーボード"></div></div>
<div id="hands" class="hands" aria-label="左右の担当指"></div>
</div>
<div id="numpad-guide" class="numpad-guide" hidden>
<div id="screen-numpad" class="screen-numpad" aria-label="テンキー"></div>
<div id="numpad-hand" class="numpad-hand" aria-label="テンキーの右手担当指"></div>
</div>
</section>
<p id="message" class="message" aria-live="polite">練習モードを選び、Spaceキーを押してください</p>
</main>
<script src="script.js"></script>
</body>
</html>
style.css のソースコードを表示
:root{color-scheme:light;--ink:#253044;--muted:#68758b;--line:#dbe5ed;--accent:#1c92a9;--accent-dark:#126b7d;--glow:#40e6ff;--back:#eef7f8;font-family:"Yu Gothic UI","Hiragino Kaku Gothic ProN",Meiryo,sans-serif}*{box-sizing:border-box}body{min-width:320px;min-height:100vh;margin:0;color:var(--ink);background:radial-gradient(circle at 15% 10%,#dff4f6 0,transparent 34rem),linear-gradient(145deg,#f8fbfc,var(--back))}button,input{font:inherit}.app{width:min(1100px,calc(100% - 32px));margin:auto;padding:26px 0 20px}.app__header{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:14px}.app__eyebrow{margin:0 0 4px;color:var(--accent-dark);font-size:.82rem;font-weight:700;letter-spacing:.12em}h1{margin:0;font-size:clamp(1.35rem,3vw,2rem)}.status{display:flex;gap:8px;margin:0}.status__item{min-width:92px;padding:9px 14px;border:1px solid var(--line);border-radius:12px;background:#ffffffe0;text-align:center;box-shadow:0 5px 18px #29535e0f}.status dt{color:var(--muted);font-size:.75rem;font-weight:700}.status dd{margin:2px 0 0;color:var(--accent-dark);font-size:1.55rem;font-weight:800;line-height:1}.status small{font-size:.72rem}.play-area{position:relative;height:clamp(300px,43vh,420px);overflow:hidden;border:3px solid #b9dbe0;border-radius:22px;background:linear-gradient(#ffffffb8,#ffffffe8),repeating-linear-gradient(90deg,transparent 0 79px,#d9eef0 80px);box-shadow:0 18px 45px #254c581f}.falling-key{position:absolute;z-index:4;top:0;left:0;width:1.25em;color:var(--accent-dark);font:900 clamp(4rem,10vw,6.5rem)/1 Arial,sans-serif;text-align:center;text-shadow:0 5px 0 #d4edf0;will-change:transform,opacity}.falling-key.is-hit{animation:target-hit .18s ease-out forwards}.effect-layer{position:absolute;z-index:3;inset:0;pointer-events:none;overflow:hidden}.panel{position:absolute;z-index:8;top:50%;left:50%;width:min(760px,calc(100% - 32px));padding:clamp(18px,3vw,28px);border:1px solid var(--line);border-radius:20px;background:#fffffff5;text-align:center;box-shadow:0 20px 50px #254c5826;transform:translate(-50%,-50%)}.panel__label,.panel__sub{margin:0;color:var(--muted)}.panel__main{margin:14px 0 9px;font-size:clamp(1.1rem,3vw,1.45rem);font-weight:800}kbd{display:inline-block;padding:.28em .65em;border:1px solid #9eb8bf;border-bottom-width:4px;border-radius:8px;color:var(--accent-dark);background:#f8ffff;font:inherit}.mode-selector{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:14px 0 0;padding:0;border:0}.mode-option{cursor:pointer}.mode-option--numpad{grid-column:2/4}.mode-option input{position:absolute;opacity:0;pointer-events:none}.mode-option span{display:grid;place-items:center;min-height:62px;padding:8px 5px;border:2px solid #c7dade;border-radius:12px;color:#40576a;background:#f7fbfc;font-weight:800;transition:.15s}.mode-option small{display:block;margin-top:4px;color:var(--muted);font:600 .62rem Arial,sans-serif}.mode-option input:checked+span{color:#07404c;border-color:#29c5db;background:#d8fbff;box-shadow:0 0 0 3px #40e6ff2e,0 7px 18px #1c92a91f}.sound-setting{display:flex;align-items:center;justify-content:center;gap:8px;margin:0 0 9px;color:var(--muted);font-weight:700}.sound-setting small{font-weight:500}.sound-toggle{min-width:54px;padding:4px 10px;border:1px solid #75bdc9;border-radius:99px;color:#07404c;background:#d8fbff;font-weight:800;cursor:pointer}.sound-toggle[aria-pressed=false]{color:#68758b;border-color:#becbd0;background:#edf1f2}.countdown{position:absolute;z-index:9;inset:0;display:grid;place-items:center;color:var(--accent-dark);background:#ffffffd6;font:900 clamp(6rem,22vw,13rem)/1 Arial,sans-serif;text-shadow:0 8px 0 #d4edf0}.exit-panel h2,.result h2{margin:8px 0 20px}.exit-panel__buttons,.result__scores{display:flex;justify-content:center;gap:10px}.dialog-button,.change-mode{padding:10px 18px;border:1px solid #9eb8bf;border-radius:99px;color:var(--accent-dark);background:#f6fcfd;font-weight:800;cursor:pointer}.dialog-button--danger{color:#9b2833;border-color:#dfaab0;background:#fff1f2}.result__scores{margin-bottom:18px}.result__scores p{min-width:130px;margin:0;padding:14px;border-radius:12px;color:var(--muted);background:#edf8f9;font-size:.82rem;font-weight:700}.result__scores strong{display:block;margin-top:4px;color:var(--accent-dark);font-size:2.2rem}.change-mode{margin-top:15px}.guide{margin-top:12px;padding:12px 14px 9px;border:1px solid #cfe2e6;border-radius:18px;background:#ffffffe0;box-shadow:0 10px 30px #254c5814}.guide__header{position:relative;display:flex;align-items:center;justify-content:center;min-height:24px;margin-bottom:9px}.guide__title{margin:0;color:var(--muted);font-size:.78rem;font-weight:700}.exit-hint{position:absolute;right:0;margin:0;color:var(--muted);font-size:.78rem}.exit-hint kbd{padding:.12em .45em;border-bottom-width:2px}.keyboard-viewport{overflow-x:auto;padding:4px 3px 7px}.screen-keyboard{--key-width:clamp(43px,6.3vw,68px);width:calc(var(--key-width)*10 + 63px);min-width:493px;margin:auto}.keyboard-row{display:grid;grid-template-columns:repeat(10,var(--key-width));gap:7px;width:max-content;margin-bottom:6px;transition:.18s}.keyboard-row[data-row=top]{margin-left:15px}.keyboard-row[data-row=home]{margin-left:29px}.keyboard-row[data-row=bottom]{margin-left:43px}.keyboard-row.is-inactive-row{opacity:.48;filter:saturate(.65)}.keycap,.numpad-key{position:relative;min-width:0;min-height:48px;padding:0;border:1px solid #a9c0c6;border-bottom:5px solid #819ba2;border-radius:9px;color:#33475b;background:linear-gradient(#fff,#eaf1f3);font:800 clamp(.95rem,2vw,1.28rem)/1 Arial,sans-serif;box-shadow:0 3px 6px #254c581f;transition:.08s}.keycap:before,.numpad-key:before{content:"";position:absolute;inset:3px;z-index:-1;border-radius:7px;opacity:.28}.keycap[data-zone*=little]:before,.numpad-key[data-finger=little]:before{background:#f6acc1}.keycap[data-zone*=ring]:before,.numpad-key[data-finger=ring]:before{background:#efc57f}.keycap[data-zone*=middle]:before,.numpad-key[data-finger=middle]:before{background:#d9dc75}.keycap[data-zone*=index]:before,.numpad-key[data-finger=index]:before{background:#8edcc4}.numpad-key[data-finger=thumb]:before{background:#b6c8ef}.keycap[data-bump=true]:after,.numpad-key[data-bump=true]:after{content:"";position:absolute;left:33%;right:33%;bottom:7px;height:3px;border-radius:2px;background:#718c94}.is-zone-target{opacity:1!important;border-color:#68cbd8!important;background:#e5fbfd!important;box-shadow:0 0 10px #40e6ff4d!important}.is-target{z-index:2;color:#073e4b!important;background:#bdf8ff!important;box-shadow:0 0 0 2px #5edbec,0 0 18px #40e6ffd9!important;transform:translateY(-1px)}.is-correct{color:#fff!important;background:#23cde6!important;transform:translateY(2px) scale(.98);box-shadow:0 0 0 3px #fff,0 0 30px var(--glow)!important}.is-wrong{z-index:3;color:#fff!important;background:#ef5360!important;border-color:#b32635!important;box-shadow:0 0 18px #ef3243d9!important;animation:wrong-shake .24s linear}.hands{display:flex;justify-content:center;gap:clamp(34px,11vw,130px);margin:7px auto 0}.hand{display:flex;align-items:flex-end;gap:4px;padding:5px 13px 0;border-bottom:7px solid #b8c9ce;border-radius:0 0 24px 24px}.finger,.numpad-finger{display:grid;place-items:start center;width:clamp(22px,4vw,34px);height:var(--finger-height);padding-top:6px;border:2px solid #a9bcc2;border-radius:15px 15px 8px 8px;color:#60727a;background:linear-gradient(#edf5f6,#dce9eb);font-size:clamp(.56rem,1.4vw,.68rem);font-weight:800;transition:.1s}.finger.is-target,.numpad-finger.is-target{transform:translateY(-4px)}.numpad-question{position:absolute;z-index:5;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding-bottom:30px}.current-question{position:relative;z-index:4;display:flex;align-items:center;min-height:1.2em;color:#33475b;font:900 clamp(3.1rem,9vw,6rem)/1 Arial,sans-serif;letter-spacing:.04em;text-shadow:0 4px 0 #e3eff1}.current-question span{transition:.08s}.current-question span.is-entered{color:#1cb9cf;text-shadow:0 0 14px #40e6ff99}.current-question span.is-current{text-decoration:underline;text-decoration-color:#ef9f24;text-underline-offset:8px}.current-question .comma{margin-right:.05em;color:#738595;font-style:normal}.question-enter{margin-left:.35em;font-size:.27em;letter-spacing:0;text-shadow:none}.question-enter.is-ready{color:#073e4b;border-color:#29c5db;background:#bdf8ff;box-shadow:0 0 18px #40e6ffd9}.next-question{position:relative;z-index:4;display:flex;align-items:center;gap:12px;margin-top:24px;padding:10px 20px;border:1px solid #b9dbe0;border-radius:13px;background:#edf8f9d9;color:var(--muted)}.next-question span{font-size:.8rem;font-weight:800}.next-question strong{color:#40576a;font:800 clamp(1.3rem,4vw,2rem)/1 Arial,sans-serif;letter-spacing:.05em}.comma-note{z-index:4;margin:8px 0 0;color:var(--muted);font-size:.76rem}.current-question.is-promoting{animation:promote .3s ease-out}.question-shatter{position:absolute;z-index:6;top:39%;animation:shatter .28s ease-out forwards;pointer-events:none}.numpad-guide{display:flex;align-items:center;justify-content:center;gap:clamp(34px,8vw,100px);padding:4px 0 10px}.screen-numpad{display:grid;grid-template-columns:repeat(3,62px);grid-template-rows:repeat(4,52px);gap:7px}.numpad-key{font-size:1.1rem}.numpad-key[data-numpad-key="0"]{grid-column:1/3}.numpad-key[data-numpad-key="."]{grid-column:3;opacity:.32;filter:grayscale(1)}.numpad-key[data-numpad-key=Enter]{grid-column:1/4}.numpad-hand{display:flex;align-items:flex-end;gap:4px;padding:6px 10px 0;border-bottom:7px solid #b8c9ce;border-radius:0 0 24px 24px}.numpad-finger{width:34px}.numpad-finger[data-finger=thumb]{height:42px}.numpad-finger[data-finger=index]{height:57px}.numpad-finger[data-finger=middle]{height:63px}.numpad-finger[data-finger=ring]{height:57px}.numpad-finger[data-finger=little]{height:46px}.laser{position:fixed;z-index:2;height:9px;border-radius:99px;background:linear-gradient(#0877bd 0 20%,#69d8ff 20% 40%,#fff 40% 60%,#69d8ff 60% 80%,#0877bd 80%);box-shadow:0 0 5px #0877bd,0 0 12px #39c8ff,0 0 24px #1e9de2cc;transform-origin:0 50%;pointer-events:none;animation:laser-shot .2s ease-out forwards}.wrong-laser{position:fixed;z-index:20;height:5px;border-radius:99px;background:linear-gradient(#665978,#93869e,#554b61);box-shadow:0 0 5px #5c4c6dbf;transform-origin:0 50%;pointer-events:none;animation:wrong-laser-shot .11s ease-out forwards}.spark{position:absolute;width:8px;height:8px;border-radius:50%;background:#fff;box-shadow:0 0 12px var(--glow);animation:spark-burst .28s ease-out forwards}.message{min-height:1.5em;margin:10px 0 0;color:var(--muted);text-align:center;font-size:.92rem}.visually-hidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}[hidden]{display:none!important}@keyframes wrong-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-5px)}40%{transform:translateX(5px)}60%{transform:translateX(-4px)}80%{transform:translateX(3px)}}@keyframes laser-shot{from{opacity:1;clip-path:inset(0 100% 0 0)}45%{clip-path:inset(0)}to{opacity:0;clip-path:inset(0)}}@keyframes wrong-laser-shot{from{opacity:.9;clip-path:inset(0 100% 0 0)}70%{opacity:.8;clip-path:inset(0)}to{opacity:0}}@keyframes target-hit{to{opacity:0;filter:blur(5px);transform:translate(var(--key-x),var(--key-y)) scale(1.65) rotate(12deg)}}@keyframes spark-burst{from{opacity:1;transform:translate(-50%,-50%) scale(.5)}to{opacity:0;transform:translate(calc(-50% + var(--spark-x)),calc(-50% + var(--spark-y))) scale(.1)}}@keyframes promote{from{opacity:.45;transform:translateY(58px) scale(.65)}to{opacity:1;transform:none}}@keyframes shatter{to{opacity:0;filter:blur(5px);transform:scale(1.55) rotate(6deg)}}
@media(max-width:720px){.app{width:min(100% - 20px,1100px);padding-top:16px}.app__header{align-items:stretch;flex-direction:column;gap:10px}.status{width:100%}.status__item{flex:1;min-width:0;padding:8px 4px}.play-area{height:410px;border-radius:16px}.panel{padding:16px 12px}.mode-selector{grid-template-columns:repeat(2,1fr)}.mode-option--numpad{grid-column:1/3}.mode-option span{min-height:49px}.screen-keyboard{--key-width:43px;margin-left:0}.keycap{min-height:43px}.hands{gap:18px}.hand{gap:2px;padding-inline:4px}.guide__header{justify-content:space-between;gap:8px}.exit-hint{position:static}.numpad-guide{flex-direction:column;gap:16px}.screen-numpad{grid-template-columns:repeat(3,56px);grid-template-rows:repeat(4,48px)}.numpad-question{padding-bottom:0}.question-enter{display:block}.next-question{margin-top:20px}}
@keyframes promote{from{transform:translate(var(--promote-x),var(--promote-y)) scale(var(--promote-scale));transform-origin:center}to{transform:none;transform-origin:center}}
/* テンキー:視線を固定したまま一段ずつ送る数字リール */
.numpad-question{padding-bottom:0}.number-reel{--row-step:88px;position:relative;width:min(620px,calc(100% - 32px));height:286px;overflow:hidden}.number-reel__frame{position:absolute;z-index:2;top:50%;left:50%;width:min(520px,calc(100% - 24px));height:76px;border:3px solid #55d8e8;border-radius:16px;background:#dffbff47;box-shadow:0 0 0 5px #fff9,0 8px 28px #1c92a92e,0 0 24px #40e6ff45;transform:translate(-50%,-50%);pointer-events:none}.number-track{position:absolute;inset:0}.number-row{position:absolute;z-index:3;top:50%;left:50%;display:flex;align-items:center;justify-content:center;width:min(500px,calc(100% - 44px));height:70px;color:#526477;font:850 clamp(2rem,6vw,3.4rem)/1 Arial,sans-serif;letter-spacing:.04em;opacity:.58;transform:translate(-50%,calc(-50% + var(--row-offset)*var(--row-step))) scale(.82);transition:transform .28s cubic-bezier(.22,.75,.25,1),opacity .28s,color .28s;will-change:transform,opacity}.number-row[data-offset="-1"]{--row-offset:-1;opacity:.18}.number-row[data-offset="0"]{--row-offset:0;z-index:4;color:#253044;opacity:1;transform:translate(-50%,calc(-50% + var(--row-offset)*var(--row-step))) scale(1)}.number-row[data-offset="1"]{--row-offset:1}.number-row[data-offset="2"]{--row-offset:2;opacity:.3}.number-row.is-new{opacity:0}.number-row span{transition:color .08s,text-shadow .08s}.number-row span.is-entered{color:#1cb9cf;text-shadow:0 0 14px #40e6ff99}.number-row span.is-current{text-decoration:underline;text-decoration-color:#ef9f24;text-underline-offset:8px}.number-row .comma{margin-right:.05em;color:#738595;font-style:normal}.number-row .question-enter{margin-left:.55em;font-size:.3em;letter-spacing:0;text-shadow:none}.comma-note{position:absolute;z-index:5;bottom:12px;margin:0}
@media(max-width:720px){.number-reel{--row-step:82px;width:100%;height:280px}.number-reel__frame{width:calc(100% - 20px);height:72px}.number-row{width:calc(100% - 30px);font-size:clamp(1.8rem,9vw,3rem)}.number-row .question-enter{margin-left:.35em}}
/* 実物のテンキー配置:Enterは3とドットの右側で縦2段 */
.screen-numpad{grid-template-columns:repeat(4,62px)}
.numpad-key[data-numpad-key="7"]{grid-area:1/1}
.numpad-key[data-numpad-key="8"]{grid-area:1/2}
.numpad-key[data-numpad-key="9"]{grid-area:1/3}
.numpad-key[data-numpad-key="4"]{grid-area:2/1}
.numpad-key[data-numpad-key="5"]{grid-area:2/2}
.numpad-key[data-numpad-key="6"]{grid-area:2/3}
.numpad-key[data-numpad-key="1"]{grid-area:3/1}
.numpad-key[data-numpad-key="2"]{grid-area:3/2}
.numpad-key[data-numpad-key="3"]{grid-area:3/3}
.numpad-key[data-numpad-key="0"]{grid-area:4/1/5/3}
.numpad-key[data-numpad-key="."]{grid-area:4/3}
.numpad-key[data-numpad-key="Enter"]{grid-area:3/4/5/5}
@media(max-width:720px){.screen-numpad{grid-template-columns:repeat(4,56px)}}
@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important}}
script.js のソースコードを表示
"use strict";
const KEYBOARD_ROWS = [
{ id: "number", label: "数字段", keys: [["1","1"],["2","2"],["3","3"],["4","4"],["5","5"],["6","6"],["7","7"],["8","8"],["9","9"],["0","0"]] },
{ id: "top", label: "上段", keys: [["q","Q"],["w","W"],["e","E"],["r","R"],["t","T"],["y","Y"],["u","U"],["i","I"],["o","O"],["p","P"]] },
{ id: "home", label: "ホームポジション段", keys: [["a","A"],["s","S"],["d","D"],["f","F"],["g","G"],["h","H"],["j","J"],["k","K"],["l","L"],[";",";"]] },
{ id: "bottom", label: "下段", keys: [["z","Z"],["x","X"],["c","C"],["v","V"],["b","B"],["n","N"],["m","M"],[",",","],[".","."],["/","/"]] }
];
const FINGERS = [
{hand:"left",finger:"little",label:"小指"},{hand:"left",finger:"ring",label:"薬指"},{hand:"left",finger:"middle",label:"中指"},{hand:"left",finger:"index",label:"人差"},{hand:"left",finger:"index",label:"人差"},
{hand:"right",finger:"index",label:"人差"},{hand:"right",finger:"index",label:"人差"},{hand:"right",finger:"middle",label:"中指"},{hand:"right",finger:"ring",label:"薬指"},{hand:"right",finger:"little",label:"小指"}
];
const KEY_GUIDE = KEYBOARD_ROWS.flatMap(row => row.keys.map(([key,label],column) => ({key,label,row:row.id,...FINGERS[column]})));
const MODE_LABELS = {number:"数字段",top:"上段",home:"ホームポジション段",bottom:"下段",numpad:"テンキー"};
const NUMPAD_FINGERS = {"1":"index","4":"index","7":"index","2":"middle","5":"middle","8":"middle","3":"ring","6":"ring","9":"ring","0":"thumb","Enter":"little"};
const PRACTICE_TIME = 60, FALL_SPEED = 16, HIT_DELAY = 180;
const $ = id => document.getElementById(id);
const playArea=$("play-area"), fallingKey=$("falling-key"), effectLayer=$("effect-layer"), startPanel=$("start-panel"), countdown=$("countdown"), resultPanel=$("result-panel");
const timeDisplay=$("time"), scoreDisplay=$("score"), missesDisplay=$("misses"), finalScore=$("final-score"), finalMisses=$("final-misses"), message=$("message");
const screenKeyboard=$("screen-keyboard"), hands=$("hands"), standardGuide=$("standard-guide"), numpadGuide=$("numpad-guide"), screenNumpad=$("screen-numpad"), numpadHand=$("numpad-hand");
const numpadQuestion=$("numpad-question"), numberTrack=$("number-track"), soundToggle=$("sound-toggle"), exitPanel=$("exit-panel"), exitHint=$("exit-hint");
const modeInputs=[...document.querySelectorAll('input[name="practice-mode"]')];
let state="waiting", selectedMode="home", practiceKeys=[], currentKey="", score=0, misses=0, keyX=0, keyY=0, previousFrameTime=0, finishTime=0;
let animationId=0, countdownTimerId=0, nextKeyTimerId=0, soundEnabled=true, audioContext=null, pausedAt=0, pausedState="playing";
let currentNumber="", nextNumber="", numberQueue=[], inputIndex=0, transitionSerial=0;
buildKeyboard(); buildHands(); buildNumpad(); buildNumpadHand(); selectMode(selectedMode);
document.addEventListener("keydown", handleKeydown);
modeInputs.forEach(input => input.addEventListener("change", () => selectMode(input.value)));
$("change-mode").addEventListener("click", returnToSelection); soundToggle.addEventListener("click", toggleSound);
$("exit-practice").addEventListener("click", () => finishPractice(true)); $("continue-practice").addEventListener("click", resumePractice);
function buildKeyboard(){
KEYBOARD_ROWS.forEach(rowData=>{ const row=document.createElement("div"); row.className="keyboard-row"; row.dataset.row=rowData.id; row.setAttribute("aria-label",rowData.label);
rowData.keys.forEach(([keyName,label],column)=>{ const guide=KEY_GUIDE.find(item=>item.key===keyName), key=document.createElement("button"); key.type="button"; key.className="keycap"; key.dataset.key=keyName; key.dataset.zone=`${guide.hand}-${guide.finger}`; key.dataset.bump=String(keyName==="f"||keyName==="j"); key.textContent=label; key.tabIndex=-1; row.appendChild(key); }); screenKeyboard.appendChild(row); });
}
function buildHands(){
const order={left:["little","ring","middle","index"],right:["index","middle","ring","little"]}, labels={little:"小指",ring:"薬指",middle:"中指",index:"人差"}, heights={little:"43px",ring:"55px",middle:"62px",index:"57px"};
["left","right"].forEach(side=>{const hand=document.createElement("div");hand.className="hand";order[side].forEach(name=>{const finger=document.createElement("span");finger.className="finger";finger.dataset.hand=side;finger.dataset.finger=name;finger.style.setProperty("--finger-height",heights[name]);finger.textContent=labels[name];hand.appendChild(finger);});hands.appendChild(hand);});
}
function buildNumpad(){
[["7","7"],["8","8"],["9","9"],["4","4"],["5","5"],["6","6"],["1","1"],["2","2"],["3","3"],["0","0"],[".","."],["Enter","Enter"]].forEach(([name,label])=>{const key=document.createElement("button");key.type="button";key.tabIndex=-1;key.className="numpad-key";key.dataset.numpadKey=name;key.dataset.finger=NUMPAD_FINGERS[name]||"none";key.dataset.bump=String(name==="5");key.textContent=label;if(name===".")key.classList.add("is-disabled");screenNumpad.appendChild(key);});
}
function buildNumpadHand(){
[["thumb","親指"],["index","人差指"],["middle","中指"],["ring","薬指"],["little","小指"]].forEach(([name,label])=>{const finger=document.createElement("span");finger.className="numpad-finger";finger.dataset.finger=name;finger.textContent=label;numpadHand.appendChild(finger);});
}
function selectMode(mode){
selectedMode=mode; const isNumpad=mode==="numpad"; practiceKeys=isNumpad?[]:KEY_GUIDE.filter(item=>item.row===mode).map(item=>item.key);
standardGuide.hidden=isNumpad; numpadGuide.hidden=!isNumpad; $("guide-title").textContent=isNumpad?"光っているテンキーを、光っている指で押しましょう":"光っているキーを、光っている指で押しましょう";
screenKeyboard.dataset.selectedRow=mode; document.querySelectorAll(".keyboard-row").forEach(row=>{row.classList.toggle("is-active-row",row.dataset.row===mode);row.classList.toggle("is-inactive-row",row.dataset.row!==mode);});
$("start-description").textContent=isNumpad?"NumLockをONにして、数字の後にEnterを押しましょう":"60秒間、表示された文字を入力しましょう";
message.textContent=`${MODE_LABELS[mode]}を練習します。Spaceキーを押してください`;
}
function handleKeydown(event){
if(event.code==="Escape"&&(state==="playing"||state==="hit")){event.preventDefault();pausePractice();return;}
if(event.code==="Escape"&&state==="paused"){event.preventDefault();resumePractice();return;}
if(event.code==="Space"&&(state==="waiting"||state==="finished")){event.preventDefault();startCountdown();return;}
if(state!=="playing"||event.repeat||event.ctrlKey||event.altKey||event.metaKey)return;
if(selectedMode==="numpad") handleNumpadInput(event); else handleStandardInput(event);
}
function handleStandardInput(event){
const typed=event.key.toLowerCase(); if(typed===currentKey){event.preventDefault();handleCorrectStandard();} else if(event.key.length===1){registerMiss(typed,getStandardKey(typed)||getStandardKey(currentKey));}
}
function handleNumpadInput(event){
const typed=event.code.startsWith("Numpad") ? (event.code==="NumpadEnter"?"Enter":event.code.replace("Numpad","")) : "";
const expected=inputIndex<currentNumber.length?currentNumber[inputIndex]:"Enter";
if(typed===expected){event.preventDefault(); if(expected==="Enter") completeNumpadQuestion(); else acceptNumpadDigit(typed);}
else if(typed || event.key.length===1 || event.key==="Enter") registerMiss(typed||event.key,getNumpadKey(typed)||getNumpadKey(expected));
}
function startCountdown(){
if(soundEnabled)getAudioContext();clearTimeout(countdownTimerId);clearTimeout(nextKeyTimerId);cancelAnimationFrame(animationId);transitionSerial++;
state="countdown";score=misses=0;timeDisplay.textContent=PRACTICE_TIME;scoreDisplay.textContent=missesDisplay.textContent="0";startPanel.hidden=true;resultPanel.hidden=true;fallingKey.hidden=true;numpadQuestion.hidden=true;effectLayer.replaceChildren();clearGuide();exitPanel.hidden=true;exitHint.hidden=true;countdown.hidden=false;message.textContent=`${MODE_LABELS[selectedMode]}のカウントダウン中です`;modeInputs.forEach(i=>i.disabled=true);
const labels=["3","2","1","START"];let index=0;const show=()=>{countdown.textContent=labels[index++];countdownTimerId=setTimeout(index<labels.length?show:beginPractice,index<labels.length?1000:700);};show();
}
function beginPractice(){
state="playing";countdown.hidden=true;exitHint.hidden=false;finishTime=performance.now()+PRACTICE_TIME*1000;previousFrameTime=performance.now();
if(selectedMode==="numpad"){numberQueue=Array.from({length:4},randomNumber);currentNumber=numberQueue[0];nextNumber=numberQueue[1];inputIndex=0;numpadQuestion.hidden=false;renderNumberReel();message.textContent="数字を順番に入力し、最後にテンキーのEnterを押してください";}else{message.textContent="表示された文字を、光っている指で押してください";showNextStandardKey();}
animationId=requestAnimationFrame(update);
}
function showNextStandardKey(){if(state!=="playing")return;fallingKey.classList.remove("is-hit");currentKey=practiceKeys[Math.floor(Math.random()*practiceKeys.length)];fallingKey.textContent=currentKey.toUpperCase();fallingKey.hidden=false;const margin=24,max=Math.max(margin,playArea.clientWidth-fallingKey.offsetWidth-margin);keyX=margin+Math.random()*(max-margin);keyY=54;drawKey();highlightStandard(currentKey);}
function handleCorrectStandard(){state="hit";score++;scoreDisplay.textContent=score;const key=getStandardKey(currentKey);key?.classList.add("is-correct");shootLaser(key,fallingKey);fallingKey.style.setProperty("--key-x",`${keyX}px`);fallingKey.style.setProperty("--key-y",`${keyY}px`);fallingKey.classList.add("is-hit");createSparks(fallingKey);setTimeout(()=>playSound("break"),75);nextKeyTimerId=setTimeout(()=>{key?.classList.remove("is-correct");if(performance.now()>=finishTime)return finishPractice();state="playing";previousFrameTime=performance.now();showNextStandardKey();},HIT_DELAY);}
function randomNumber(){const length=3+Math.floor(Math.random()*3);let value=String(1+Math.floor(Math.random()*9));while(value.length<length)value+=Math.floor(Math.random()*10);return value;}
function formatNumber(value){return Number(value).toLocaleString("ja-JP");}
function createNumberRow(value,offset){const row=document.createElement("div");row.className="number-row";row.dataset.offset=String(offset);row.dataset.value=value;row.setAttribute("aria-label",offset===0?`現在の問題 ${formatNumber(value)}`:`次の問題 ${formatNumber(value)}`);fillNumberRow(row,value,offset===0);return row;}
function fillNumberRow(row,value,isCurrent){row.replaceChildren();[...value].forEach((digit,index)=>{const span=document.createElement("span");span.textContent=digit;if(isCurrent)span.className=index<inputIndex?"is-entered":index===inputIndex?"is-current":"";row.appendChild(span);const remaining=value.length-index-1;if(remaining>0&&remaining%3===0){const comma=document.createElement("i");comma.textContent=",";comma.className="comma";row.appendChild(comma);}});const enter=document.createElement("kbd");enter.textContent="Enter";enter.className="question-enter";enter.classList.toggle("is-ready",isCurrent&&inputIndex===value.length);row.appendChild(enter);}
function renderNumberReel(){numberTrack.replaceChildren(...numberQueue.slice(0,3).map((value,index)=>createNumberRow(value,index)));highlightNumpad(inputIndex<currentNumber.length?currentNumber[inputIndex]:"Enter");}
function refreshCurrentRow(){const row=numberTrack.querySelector('.number-row[data-offset="0"]');if(row)fillNumberRow(row,currentNumber,true);highlightNumpad(inputIndex<currentNumber.length?currentNumber[inputIndex]:"Enter");}
function acceptNumpadDigit(digit){
score++;scoreDisplay.textContent=score;const row=numberTrack.querySelector('.number-row[data-offset="0"]'),source=getNumpadKey(digit),target=[...row.querySelectorAll("span")][inputIndex];source?.classList.add("is-correct");shootLaser(source,target);playSound("key10");setTimeout(()=>source?.classList.remove("is-correct"),100);inputIndex++;refreshCurrentRow();
}
function completeNumpadQuestion(){
score++;scoreDisplay.textContent=score;playSound("shift7");const serial=++transitionSerial,rows=[...numberTrack.querySelectorAll(".number-row")];numberQueue.shift();numberQueue.push(randomNumber());currentNumber=numberQueue[0];nextNumber=numberQueue[1];inputIndex=0;rows.forEach(row=>{const offset=Number(row.dataset.offset)-1;if(offset<-1){row.remove();return;}row.dataset.offset=String(offset);row.setAttribute("aria-label",offset===0?`現在の問題 ${formatNumber(row.dataset.value)}`:`次の問題 ${formatNumber(row.dataset.value)}`);});const incoming=createNumberRow(numberQueue[2],2);incoming.classList.add("is-new");numberTrack.appendChild(incoming);requestAnimationFrame(()=>incoming.classList.remove("is-new"));refreshCurrentRow();setTimeout(()=>{if(serial!==transitionSerial)return;numberTrack.querySelector('.number-row[data-offset="-1"]')?.remove();},310);
}
function update(now){if(["finished","waiting","countdown","paused"].includes(state))return;const remaining=finishTime-now;if(remaining<=0)return finishPractice();timeDisplay.textContent=Math.ceil(remaining/1000);if(selectedMode!=="numpad"&&state==="playing"){keyY=Math.min(keyY+FALL_SPEED*Math.min((now-previousFrameTime)/1000,.1),playArea.clientHeight-fallingKey.offsetHeight-20);previousFrameTime=now;drawKey();}animationId=requestAnimationFrame(update);}
function drawKey(){fallingKey.style.transform=`translate(${keyX}px,${keyY}px)`;}
function highlightStandard(name){clearGuide();const guide=KEY_GUIDE.find(item=>item.key===name);getStandardKey(name)?.classList.add("is-target");if(!guide)return;document.querySelector(`.finger[data-hand="${guide.hand}"][data-finger="${guide.finger}"]`)?.classList.add("is-target");document.querySelectorAll(`.keycap[data-zone="${guide.hand}-${guide.finger}"]`).forEach(key=>key.classList.add("is-zone-target"));}
function highlightNumpad(name){clearGuide();const key=getNumpadKey(name),finger=NUMPAD_FINGERS[name];key?.classList.add("is-target");if(finger){document.querySelector(`.numpad-finger[data-finger="${finger}"]`)?.classList.add("is-target");document.querySelectorAll(`.numpad-key[data-finger="${finger}"]`).forEach(k=>k.classList.add("is-zone-target"));}}
function clearGuide(){document.querySelectorAll(".keycap,.finger,.numpad-key,.numpad-finger").forEach(el=>el.classList.remove("is-target","is-zone-target","is-correct","is-wrong"));}
function registerMiss(name,source){misses++;missesDisplay.textContent=misses;if(source){source.classList.remove("is-wrong");void source.offsetWidth;source.classList.add("is-wrong");setTimeout(()=>source.classList.remove("is-wrong"),260);}playSound("wrong");shootWrongLaser(source);}
function getStandardKey(name){return document.querySelector(`.keycap[data-key="${CSS.escape(name)}"]`);} function getNumpadKey(name){return document.querySelector(`.numpad-key[data-numpad-key="${CSS.escape(name)}"]`);}
function shootLaser(source,target){if(!source||!target)return;const a=source.getBoundingClientRect(),b=target.getBoundingClientRect(),sx=a.left+a.width/2,sy=a.top+3,ex=b.left+b.width/2,ey=b.top+b.height/2,d=Math.hypot(ex-sx,ey-sy),angle=Math.atan2(ey-sy,ex-sx)*180/Math.PI,laser=document.createElement("span");laser.className="laser";laser.style.cssText=`left:${sx}px;top:${sy}px;width:${d}px;transform:rotate(${angle}deg)`;document.body.appendChild(laser);setTimeout(()=>laser.remove(),220);}
function shootWrongLaser(source){if(!source)return;const r=source.getBoundingClientRect(),sx=r.left+r.width/2,sy=r.top+3,angle=-155+Math.random()*130,distance=75+Math.random()*85,laser=document.createElement("span");laser.className="wrong-laser";laser.style.cssText=`left:${sx}px;top:${sy}px;width:${distance}px;transform:rotate(${angle}deg)`;document.body.appendChild(laser);setTimeout(()=>{laser.remove();playSound("fizzle");},105);}
function createSparks(target){if(!target)return;const area=playArea.getBoundingClientRect(),rect=target.getBoundingClientRect(),x=rect.left+rect.width/2-area.left,y=rect.top+rect.height/2-area.top;for(let i=0;i<8;i++){const angle=Math.PI*2*i/8,s=document.createElement("span");s.className="spark";s.style.left=`${x}px`;s.style.top=`${y}px`;s.style.setProperty("--spark-x",`${Math.cos(angle)*45}px`);s.style.setProperty("--spark-y",`${Math.sin(angle)*45}px`);effectLayer.appendChild(s);setTimeout(()=>s.remove(),330);}}
function pausePractice(){pausedAt=performance.now();pausedState=state;state="paused";cancelAnimationFrame(animationId);clearTimeout(nextKeyTimerId);exitPanel.hidden=false;$("continue-practice").focus();message.textContent="練習を一時停止しています";}
function resumePractice(){if(state!=="paused")return;finishTime+=performance.now()-pausedAt;exitPanel.hidden=true;state=pausedState;previousFrameTime=performance.now();message.textContent=selectedMode==="numpad"?"数字を順番に入力し、最後にテンキーのEnterを押してください":"表示された文字を、光っている指で押してください";if(state==="hit"){state="playing";showNextStandardKey();}animationId=requestAnimationFrame(update);}
function finishPractice(early=false){state="finished";transitionSerial++;clearTimeout(nextKeyTimerId);cancelAnimationFrame(animationId);timeDisplay.textContent=early?Math.max(0,Math.ceil((finishTime-pausedAt)/1000)):"0";fallingKey.hidden=true;numpadQuestion.hidden=true;effectLayer.replaceChildren();clearGuide();exitPanel.hidden=true;exitHint.hidden=true;finalScore.textContent=score;finalMisses.textContent=misses;resultPanel.hidden=false;message.textContent=`${early?"途中終了しました。":""}${MODE_LABELS[selectedMode]}をもう一度練習するときはSpaceキーを押してください`;}
function returnToSelection(){state="waiting";resultPanel.hidden=true;startPanel.hidden=false;exitHint.hidden=true;modeInputs.forEach(i=>i.disabled=false);timeDisplay.textContent=PRACTICE_TIME;scoreDisplay.textContent=missesDisplay.textContent="0";selectMode(selectedMode);}
let noiseBuffer=null;
function getAudioContext(){if(!audioContext){audioContext=new(window.AudioContext||window.webkitAudioContext)();noiseBuffer=audioContext.createBuffer(1,audioContext.sampleRate*.25,audioContext.sampleRate);const data=noiseBuffer.getChannelData(0);for(let i=0;i<data.length;i++)data[i]=Math.random()*2-1;}if(audioContext.state==="suspended")audioContext.resume();return audioContext;}
function tone({frequency,endFrequency=frequency,duration=.05,volume=.035,type="square",delay=0}){if(!soundEnabled)return;const c=getAudioContext(),start=c.currentTime+delay,o=c.createOscillator(),g=c.createGain();o.type=type;o.frequency.setValueAtTime(frequency,start);o.frequency.exponentialRampToValueAtTime(Math.max(20,endFrequency),start+duration);g.gain.setValueAtTime(.0001,start);g.gain.exponentialRampToValueAtTime(volume,start+.004);g.gain.exponentialRampToValueAtTime(.0001,start+duration);o.connect(g).connect(c.destination);o.start(start);o.stop(start+duration+.01);}
function noise({duration=.025,volume=.035,delay=0,highpass=680,lowpass=2310}){if(!soundEnabled)return;const c=getAudioContext(),start=c.currentTime+delay,s=c.createBufferSource(),hp=c.createBiquadFilter(),lp=c.createBiquadFilter(),g=c.createGain();s.buffer=noiseBuffer;hp.type="highpass";hp.frequency.value=highpass;lp.type="lowpass";lp.frequency.value=lowpass;g.gain.setValueAtTime(.0001,start);g.gain.exponentialRampToValueAtTime(volume,start+.002);g.gain.exponentialRampToValueAtTime(.0001,start+duration);s.connect(hp).connect(lp).connect(g).connect(c.destination);s.start(start);s.stop(start+duration+.01);}
function playSound(name){if(name==="wrong"){tone({frequency:150,endFrequency:75,duration:.105,volume:.055,type:"triangle"});tone({frequency:95,endFrequency:55,duration:.09,volume:.025,delay:.012});}if(name==="break")[1500,2150,2850].forEach((f,i)=>tone({frequency:f,endFrequency:f*.65,duration:.055+i*.012,volume:.018,type:"triangle",delay:i*.009}));if(name==="key10"){noise({duration:.025,volume:.035,highpass:680,lowpass:2310});tone({frequency:1050,endFrequency:756,duration:.035,volume:.0245,type:"triangle"});}if(name==="shift7"){noise({duration:.14,volume:.075,highpass:700,lowpass:12000});[1500,1150,850,620,430].forEach((f,i)=>tone({frequency:f,endFrequency:f*.72,duration:.045,volume:.04,type:"square",delay:i*.025}));}if(name==="fizzle")tone({frequency:340,endFrequency:85,duration:.075,volume:.03,type:"sawtooth"});}
function toggleSound(){soundEnabled=!soundEnabled;soundToggle.textContent=soundEnabled?"ON":"OFF";soundToggle.setAttribute("aria-pressed",String(soundEnabled));}
サウンド確認用ページ
サウンド/index.html のソースコードを表示
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>タイピングWebアプリ 音くらべ</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="app-shell">
<header class="hero">
<p class="eyebrow">NONBIRI LAB / SOUND TEST</p>
<h1>タイピングWebアプリ 音くらべ</h1>
<p>ボタンを押すと、Web Audio APIで作った音をその場で試聴できます。</p>
<div class="controls">
<label for="volume">音量 <output id="volume-value">70%</output></label>
<input id="volume" type="range" min="0" max="100" value="70">
<button id="stop-all" class="stop-button" type="button">すべて停止</button>
</div>
</header>
<section class="sound-section" aria-labelledby="key-title">
<div class="section-heading">
<span class="section-icon">⌨</span>
<div><h2 id="key-title">キーボードを押した音</h2><p>短く、入力の邪魔をしにくい10種類</p></div>
</div>
<div id="key-sounds" class="sound-grid"></div>
</section>
<section class="sound-section" aria-labelledby="laser-title">
<div class="section-heading">
<span class="section-icon laser-icon">↗</span>
<div><h2 id="laser-title">レーザーが出る音</h2><p>発射感やスピード感が異なる10種類</p></div>
</div>
<div id="laser-sounds" class="sound-grid"></div>
</section>
<section class="sound-section" aria-labelledby="break-title">
<div class="section-heading">
<span class="section-icon break-icon">✦</span>
<div><h2 id="break-title">文字が壊れる音</h2><p>Enter時の「パリンッ」候補10種類</p></div>
</div>
<div id="break-sounds" class="sound-grid"></div>
</section>
<p class="note">※ 最初のクリック時にブラウザの音声機能が有効になります。音量を上げすぎないようご注意ください。</p>
</main>
<script src="script.js"></script>
</body>
</html>
サウンド/style.css のソースコードを表示
:root{color-scheme:dark;--bg:#070b16;--panel:#10182a;--line:#263653;--text:#f5f7ff;--muted:#9cabca;--cyan:#42dbff;--violet:#ac7cff;--gold:#ffd36a}
*{box-sizing:border-box}
body{margin:0;min-width:320px;background:radial-gradient(circle at 50% 0,#172343 0,#090e1c 34rem,#050812 100%);color:var(--text);font-family:"Segoe UI","Yu Gothic UI",sans-serif}
button,input{font:inherit}
.app-shell{width:min(1160px,calc(100% - 32px));margin:auto;padding:42px 0 60px}
.hero{padding:32px;border:1px solid #30466c;border-radius:24px;background:linear-gradient(135deg,rgba(28,43,75,.92),rgba(12,18,35,.92));box-shadow:0 18px 60px #0008}
.eyebrow{margin:0 0 8px;color:var(--cyan);font-size:.78rem;font-weight:800;letter-spacing:.18em}
h1{margin:0;font-size:clamp(1.7rem,4vw,3rem);letter-spacing:.03em}
.hero>p:not(.eyebrow){margin:12px 0 0;color:#b9c7df}
.controls{display:grid;grid-template-columns:auto minmax(140px,340px) auto;align-items:center;gap:14px;margin-top:26px}
.controls label{font-weight:700}.controls output{display:inline-block;min-width:3em;color:var(--cyan)}
input[type="range"]{width:100%;accent-color:var(--cyan)}
.stop-button{border:1px solid #ec6c84;background:#331521;color:#ffd9e0;border-radius:10px;padding:9px 15px;cursor:pointer}
.stop-button:hover{background:#501b2b}
.sound-section{margin-top:26px;padding:24px;border:1px solid var(--line);border-radius:20px;background:rgba(12,19,34,.86)}
.section-heading{display:flex;align-items:center;gap:14px;margin-bottom:18px}
.section-icon{display:grid;place-items:center;flex:0 0 48px;height:48px;border:1px solid #2f7990;border-radius:14px;background:#123447;color:var(--cyan);font-size:1.35rem;font-weight:900}
.laser-icon{border-color:#6148a1;background:#291c4d;color:#bd98ff}.break-icon{border-color:#8c702b;background:#3c3011;color:var(--gold)}
h2{margin:0;font-size:1.25rem}.section-heading p{margin:3px 0 0;color:var(--muted);font-size:.9rem}
.sound-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:11px}
.sound-button{position:relative;min-height:82px;padding:12px;border:1px solid #2b3b59;border-radius:13px;background:linear-gradient(180deg,#18243a,#111a2c);color:var(--text);text-align:left;cursor:pointer;transition:transform .12s,border-color .12s,box-shadow .12s}
.sound-button:hover{transform:translateY(-2px);border-color:var(--cyan);box-shadow:0 8px 24px #0007}.sound-button:active,.sound-button.is-playing{transform:translateY(1px);background:#1c3450;box-shadow:0 0 22px #42dbff33 inset}
.sound-number{display:block;color:var(--cyan);font-size:.72rem;font-weight:800;letter-spacing:.1em}.sound-name{display:block;margin-top:5px;font-weight:750}.sound-desc{display:block;margin-top:3px;color:var(--muted);font-size:.74rem;line-height:1.35}
.sound-button[data-category="laser"] .sound-number{color:#bd98ff}.sound-button[data-category="break"] .sound-number{color:var(--gold)}
.current-badge{position:absolute;top:8px;right:8px;padding:3px 6px;border-radius:999px;background:#f2bd3c;color:#231900;font-size:.62rem;font-weight:900}
.note{margin:22px 4px 0;color:#8291ad;font-size:.82rem;text-align:center}
@media(max-width:900px){.sound-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:560px){.app-shell{width:min(100% - 20px,1160px);padding-top:18px}.hero,.sound-section{padding:18px;border-radius:16px}.controls{grid-template-columns:1fr auto}.controls label{grid-column:1/-1}.sound-grid{grid-template-columns:1fr}.sound-button{min-height:70px}}
サウンド/script.js のソースコードを表示
"use strict";
const catalog={
key:[
["ライトクリック","軽いカチッ"],["メカニカル","硬めのカチャッ"],["ディープキー","低いコトッ"],["タイプライター","金属的なパチッ"],["ソフトタップ","丸いポッ"],
["デジタル","短い電子音"],["クリスタルキー","小さな高音"],["アーケード","ボタンらしい音"],["木製キー","乾いたコンッ"],["サイレント","ごく控えめ"]
],
laser:[
["クイックビーム","一瞬で走る光線"],["SFレーザー","高音から鋭く下降"],["パワーショット","低音を含む発射音"],["チャージビーム","溜めてから発射"],["プラズマ","太く揺れる光線"],
["パルス","二連射の短い音"],["スナイパー","細く鋭い一撃"],["レトロゲーム","昔風の電子光線"],["スイープ","音程が大きく移動"],["ライトレイ","明るく軽い光線"]
],
break:[
["現在のパリンッ","第5回で使用中"],["ガラス","細かく澄んだ破砕"],["アイス","冷たく高いひび割れ"],["ハードクラッシュ","硬質で強い破壊"],["ライトシャード","軽い破片が散る"],
["クリスタル","きらめく余韻"],["デジタル崩壊","電子的に分解"],["セラミック","乾いた割れ方"],["ビッグシャター","低音入りの大破砕"],["スター破壊","明るく派手な破壊"]
]
};
let context=null,master=null,noiseBuffer=null,activeNodes=[];
const volume=document.querySelector("#volume"),volumeValue=document.querySelector("#volume-value");
Object.entries(catalog).forEach(([category,sounds])=>{
const grid=document.querySelector(`#${category}-sounds`);
sounds.forEach(([name,desc],index)=>{
const button=document.createElement("button");
button.type="button";button.className="sound-button";button.dataset.category=category;button.dataset.index=index;
button.innerHTML=`<span class="sound-number">SOUND ${String(index+1).padStart(2,"0")}</span><span class="sound-name">${name}</span><span class="sound-desc">${desc}</span>${category==="break"&&index===0?'<span class="current-badge">現在</span>':""}`;
button.addEventListener("click",()=>play(category,index,button));grid.appendChild(button);
});
});
volume.addEventListener("input",()=>{volumeValue.value=`${volume.value}%`;if(master)master.gain.setTargetAtTime((volume.value/100)*.75,context.currentTime,.015)});
document.querySelector("#stop-all").addEventListener("click",stopAll);
function audio(){
if(!context){
context=new(window.AudioContext||window.webkitAudioContext)();master=context.createGain();master.gain.value=(volume.value/100)*.75;master.connect(context.destination);
noiseBuffer=context.createBuffer(1,context.sampleRate*.55,context.sampleRate);const data=noiseBuffer.getChannelData(0);for(let i=0;i<data.length;i++)data[i]=Math.random()*2-1;
}
if(context.state==="suspended")context.resume();return context;
}
function remember(node){activeNodes.push(node);node.addEventListener?.("ended",()=>activeNodes=activeNodes.filter(item=>item!==node));return node}
function env(g,start,peak,attack,duration){g.gain.setValueAtTime(.0001,start);g.gain.exponentialRampToValueAtTime(Math.max(.0002,peak),start+attack);g.gain.exponentialRampToValueAtTime(.0001,start+duration)}
function osc(freq,end,duration,{type="sine",gain=.12,delay=0,attack=.003}={}){const c=audio(),start=c.currentTime+delay,o=remember(c.createOscillator()),g=c.createGain();o.type=type;o.frequency.setValueAtTime(freq,start);o.frequency.exponentialRampToValueAtTime(Math.max(20,end),start+duration);env(g,start,gain,attack,duration);o.connect(g).connect(master);o.start(start);o.stop(start+duration+.02);return o}
function noise(duration,{gain=.12,delay=0,highpass=500,lowpass=12000}={}){const c=audio(),start=c.currentTime+delay,s=remember(c.createBufferSource()),hp=c.createBiquadFilter(),lp=c.createBiquadFilter(),g=c.createGain();s.buffer=noiseBuffer;hp.type="highpass";hp.frequency.value=highpass;lp.type="lowpass";lp.frequency.value=lowpass;env(g,start,gain,.002,duration);s.connect(hp).connect(lp).connect(g).connect(master);s.start(start);s.stop(start+duration+.01);return s}
function click(freq,gain=.12,delay=0){noise(.025,{gain,delay,highpass:freq*.65,lowpass:freq*2.2});osc(freq,freq*.72,.035,{type:"triangle",gain:gain*.7,delay})}
function play(category,index,button){audio();stopAll();button.classList.add("is-playing");setTimeout(()=>button.classList.remove("is-playing"),420);({key:playKey,laser:playLaser,break:playBreak}[category])(index)}
function stopAll(){activeNodes.forEach(node=>{try{node.stop()}catch{}});activeNodes=[];document.querySelectorAll(".is-playing").forEach(el=>el.classList.remove("is-playing"))}
function playKey(i){
const f=[1150,1650,420,2100,620,880,2600,520,760,1050][i],g=[.09,.12,.16,.1,.1,.1,.07,.15,.12,.035][i];
if(i===0)click(f,g);if(i===1){click(f,g);click(720,.06,.018)}if(i===2){click(f,g);osc(120,70,.07,{type:"triangle",gain:.08})}if(i===3){click(f,g);osc(3300,1900,.045,{gain:.045})}if(i===4)osc(f,390,.055,{type:"sine",gain:g});
if(i===5){osc(f,680,.04,{type:"square",gain:g});osc(1320,900,.03,{type:"square",gain:.03,delay:.018})}if(i===6){osc(f,1800,.045,{gain:g});osc(3900,2800,.025,{gain:.025,delay:.006})}if(i===7){noise(.035,{gain:.08,highpass:180});osc(f,260,.065,{type:"square",gain:g})}if(i===8){noise(.035,{gain:.08,highpass:360,lowpass:1800});osc(f,470,.045,{type:"triangle",gain:g})}if(i===9)click(f,g);
}
function playLaser(i){
if(i===0){osc(1900,430,.11,{type:"sawtooth",gain:.07});noise(.08,{gain:.04,highpass:1700})}
if(i===1){osc(3600,620,.18,{type:"sawtooth",gain:.09});osc(1800,350,.15,{gain:.05,delay:.015})}
if(i===2){osc(680,90,.2,{type:"square",gain:.12});noise(.12,{gain:.09,highpass:100})}
if(i===3){osc(180,950,.13,{type:"sine",gain:.07});osc(2600,470,.16,{type:"sawtooth",gain:.1,delay:.12});noise(.1,{gain:.06,delay:.12,highpass:1200})}
if(i===4){osc(1100,170,.24,{type:"sawtooth",gain:.12});osc(980,210,.2,{type:"square",gain:.045,delay:.015});noise(.17,{gain:.07,highpass:250})}
if(i===5){osc(1900,600,.065,{type:"square",gain:.075});osc(2100,650,.065,{type:"square",gain:.075,delay:.085})}
if(i===6){osc(4200,720,.105,{type:"sawtooth",gain:.085});noise(.045,{gain:.07,highpass:3000})}
if(i===7){osc(1250,280,.17,{type:"square",gain:.085});osc(1650,390,.12,{type:"square",gain:.05,delay:.025})}
if(i===8){osc(220,3200,.2,{type:"sawtooth",gain:.08});osc(3300,480,.16,{type:"triangle",gain:.07,delay:.18})}
if(i===9){osc(2600,820,.14,{type:"sine",gain:.075});osc(3900,1450,.1,{type:"sine",gain:.04,delay:.015})}
}
function shards(freqs,{baseGain=.045,spread=.012,duration=.08,type="triangle"}={}){freqs.forEach((f,i)=>osc(f,f*.58,duration+i*.006,{type,gain:baseGain*(1-i*.045),delay:i*spread}))}
function playBreak(i){
if(i===0)shards([1500,2150,2850],{baseGain:.055,spread:.009,duration:.055});
if(i===1){noise(.16,{gain:.09,highpass:1700});shards([1900,2750,3900,5200,3300],{baseGain:.045,spread:.012,duration:.1})}
if(i===2){noise(.13,{gain:.07,highpass:2300});shards([2400,3600,5100,6200],{baseGain:.04,spread:.015,duration:.085})}
if(i===3){noise(.2,{gain:.15,highpass:180});osc(240,55,.19,{type:"triangle",gain:.13});shards([780,1200,1850],{baseGain:.06,duration:.11})}
if(i===4){noise(.1,{gain:.055,highpass:1400});shards([1800,2350,3100],{baseGain:.032,spread:.018,duration:.07})}
if(i===5){shards([2200,3300,4400,5600,6800],{baseGain:.036,spread:.018,duration:.15});osc(1100,750,.2,{gain:.035})}
if(i===6){noise(.14,{gain:.075,highpass:700});[1500,1150,850,620,430].forEach((f,n)=>osc(f,f*.72,.045,{type:"square",gain:.04,delay:n*.025}))}
if(i===7){noise(.16,{gain:.11,highpass:380,lowpass:4800});shards([900,1350,1750,2400],{baseGain:.05,spread:.014,duration:.075,type:"square"})}
if(i===8){noise(.28,{gain:.15,highpass:100});osc(170,45,.25,{type:"sawtooth",gain:.13});shards([620,1050,1650,2500,3600],{baseGain:.055,spread:.016,duration:.14})}
if(i===9){noise(.14,{gain:.08,highpass:1400});shards([1600,2400,3200,4100,5300,6500],{baseGain:.042,spread:.014,duration:.12});osc(900,1800,.16,{gain:.045})}
}
まず効果音を聞き比べるページを作る
今回は、いきなり本体へ効果音を追加せず、先に「音くらべ」専用ページを作りました。Web Audio APIで作った複数の音をボタンで鳴らし、実際に耳で聞いてから本体へ使う音を決めるためです。
試聴ページには、「キーボードを押した音」「レーザーが出る音」「文字が壊れる音」をそれぞれ10種類ずつ用意しました。音量調整と「すべて停止」ボタンも付けています。

音は文章だけで「軽い音」「硬い音」と説明しても、実際にアプリへ合うか判断しにくい部分です。そこでAIに複数案を作ってもらい、人間が実際に聞いて選ぶ方法にしました。
聞き比べた音を本体へ使う
試聴した結果、テンキーで数字を1文字入力したときには「キーボードを押した音」のSOUND 10を使い、1問入力し終えてEnterを押したときには「文字が壊れる音」のSOUND 07を使う形にしました。
完成版のJavaScriptでは、数字入力時に playSound("key10")、問題確定時に playSound("shift7") を呼び出しています。
function acceptNumpadDigit(digit){
// 中略
playSound("key10");
inputIndex++;
refreshCurrentRow();
}
function completeNumpadQuestion(){
score++;
scoreDisplay.textContent=score;
playSound("shift7");
// 中略
}音くらべページを別に作っておくと、あとから別の音へ変更したくなった場合も比較しやすくなります。
実際に選んだ効果音を入れて、何回かテンキー練習をしてみました。

サウンドを選んだけど、たぶん、これ……ちょっとうるさいと思う(笑)

そのためのSE ON/OFFだからね(笑)
実際に連続で遊んでみると、思っていたより賑やかでした(笑)。音が気になる場合は、スタート前のSE設定をOFFにすれば無音で練習できます。
数字段を正式な練習対象にする
第3回で4段キーボードを作ったとき、数字段は表示だけ先に用意していました。今回は開始画面へ「数字段」を追加し、1~0を正式に練習できるようにします。
数字段を選んだ場合は、これまでの上段・ホーム段・下段と同じ方式です。対象の数字が落ちてきて、対応するキーと担当指が光ります。
{ id: "number", label: "数字段",
keys: [["1","1"],["2","2"],["3","3"],["4","4"],["5","5"],
["6","6"],["7","7"],["8","8"],["9","9"],["0","0"]] }テンキーと右手の指ガイドを追加する
テンキーモードを選ぶと、通常の4段キーボードと左右の手を非表示にし、テンキーと右手専用の指ガイドへ切り替えます。
テンキーの5には、実物のキーボードと同じように位置を確認するための突起を付けました。画面を見ながら練習しても、「5の突起を基準に指を置く」という感覚が分かるようにしています。
今回の担当指は、1・4・7を人差し指、2・5・8を中指、3・6・9を薬指、0を親指、Enterを小指として表示します。
const NUMPAD_FINGERS = {
"1":"index","4":"index","7":"index",
"2":"middle","5":"middle","8":"middle",
"3":"ring","6":"ring","9":"ring",
"0":"thumb","Enter":"little"
};【画像を挿入:テンキーと右手指ガイド。5キーの突起と担当指の発光が分かる状態】
3~5桁の数字をランダムに出題する
テンキー練習では、1問につき3~5桁の整数をランダムに作ります。3桁なら比較的入力しやすく、4~5桁になると少し「数字を入力している」感が強くなるため、この範囲にしました。
function randomNumber(){
const length=3+Math.floor(Math.random()*3);
let value=String(1+Math.floor(Math.random()*9));
while(value.length<length)value+=Math.floor(Math.random()*10);
return value;
}先頭が0にならないよう、最初の1桁だけ1~9から作り、その後ろへ0~9を追加しています。
カンマは表示するだけにする
4桁以上の数字は、画面上では 12,345 のように3桁区切りのカンマを付けます。ただし、テンキー入力ではカンマを打ちません。
たとえば 12,345 と表示された場合、実際に押すキーは 1 → 2 → 3 → 4 → 5 → Enter です。
画面にも「カンマは打ちません」と表示しています。
表示用の数字と、入力判定に使う数字を分けて考えるのがポイントです。見た目は読みやすくしながら、入力処理は数字だけで進めます。
NEXT表示から数字リール方式へ変更した
打ち合わせ段階では、現在の数字の下に「NEXT > 6,789」のような表示を置く案を考えていました。ところが実際に作ってみると、現在問題と次問題を別々の場所で見るより、視線を固定したまま次の数字へ移れる方がテンキー練習に合っていました。
そこで完成版では、中央に固定枠を置き、その中へ現在問題を表示する「数字リール」方式へ変更しました。次の問題は下に普通に読める濃さで表示され、Enterを押すと一段上へ送られます。
さらに、直前の問題は上へ抜け、次の次の問題も下側に控えめに表示されます。

let currentNumber="", nextNumber="", numberQueue=[];
function beginPractice(){
// 中略
numberQueue=Array.from({length:4},randomNumber);
currentNumber=numberQueue[0];
nextNumber=numberQueue[1];
inputIndex=0;
renderNumberReel();
}現在何文字目かを管理して1文字ずつ判定する
通常の段別練習では、現在の正解キーは1文字だけでした。テンキーでは複数桁の数字を扱うため、「今、何文字目を入力しているのか」を覚えておく必要があります。
そのために inputIndex を使います。現在問題が 12345 なら、最初は0番目の「1」、次は1番目の「2」というように、正解するたびに位置を1つ進めます。
const expected =
inputIndex < currentNumber.length
? currentNumber[inputIndex]
: "Enter";
if(typed===expected){
if(expected==="Enter"){
completeNumpadQuestion();
}else{
acceptNumpadDigit(typed);
}
}正しく入力した数字は消さず、色を変えて発光させます。現在入力する位置には下線を表示し、どこまで進んだか分かるようにしました。
全部入力したらEnterを光らせる
数字を最後まで入力すると、次に押すキーはEnterへ切り替わります。画面内のEnter表示と、テンキー側のEnterキー、担当する小指が光ります。
1文字入力するたびに文字を壊すのではなく、数字入力中は色だけを変え、最後のEnterで1問をまとめて確定する形です。

Enterで数字を一段送りにする
Enterを押すと、現在問題が完了し、下に見えていた次の数字が中央の枠へ移動します。CSSのtransitionは約0.28~0.3秒にして、数字が一段ずつ送られるように見せています。
.number-row{
transition:
transform .28s cubic-bezier(.22,.75,.25,1),
opacity .28s,
color .28s;
}今回の完成版では、最初に考えていた「NEXTという文字を表示する方法」ではなく、次の数字そのものをリール内へ見せる形になりました。
演出中でも次の入力を止めない
テンキー入力が速い人は、現在の数字を入力しながら次の数字を先読みします。そのため、数字が中央へ移動する0.3秒の演出が終わるまで入力を禁止すると、速く入力できる人ほど引っ掛かってしまいます。
そこで、Enterを押した瞬間にJavaScript側では次の問題へ切り替え、画面の移動アニメーションだけを後から見せるようにしました。
numberQueue.shift();
numberQueue.push(randomNumber());
currentNumber=numberQueue[0];
nextNumber=numberQueue[1];
inputIndex=0;
// ここで次の問題として入力受付を開始
refreshCurrentRow();
// 古い行を消すのはアニメーション後
setTimeout(()=>{
numberTrack
.querySelector('.number-row[data-offset="-1"]')
?.remove();
},310);今回の大事な考え方は「演出は見せる。でも、演出のために入力を待たせない」です。見た目のアニメーションと、内部の入力判定を別々に進めています。
小数点は表示だけ残しておく
テンキーには小数点キーも表示していますが、第5回では練習対象にしていません。レイアウト上は実物に近い形を残しつつ、使わないキーとして薄く表示しています。
将来、「小数点以下あり/なし」を選べる機能を追加する案もありますが、現時点では候補です。今回の段階では整数入力を完成させることを優先しました。
演算記号の +、-、*、/ は今回のテンキー練習には入れていません。
今回の完成形
第5回では、数字段をこれまでの段別練習へ追加し、テンキーだけは連続入力向けの別モードとして作りました。
- 数字段・上段・ホーム段・下段・テンキーの5モード
- テンキーは3~5桁のランダム整数
- 3桁区切りのカンマは表示のみ
- 5キーに突起
- 右手の担当指ガイド
- 入力済み数字を色変更
- 入力中の位置を下線表示
- 最後にEnterで1問確定
- 次問題を先読みできる数字リール
- 約0.3秒の送り演出中も次問題を入力可能
- Web Audio APIで作った候補音を聞き比べて採用

完成したタイピング練習を使ってみる
下の埋め込み画面から、第5回の完成版を確認できます。テンキー練習をしっかり行う場合は、別画面で開いて物理キーボードを使うのがおすすめです。
記事内のiframeでは、表示領域やブラウザ環境によってレーザーなどの演出位置がずれる場合があります。実際に練習するときは別画面で開いてください。
まとめ
今回は、これまで表示だけだった数字段を練習対象へ追加し、さらにテンキー練習を作りました。
テンキーでは、3~5桁の数字を1文字ずつ判定し、最後にEnterで確定します。次の数字を先読みできるように数字リール方式へ変更し、表示が動いている途中でも入力を止めないようにしました。
また、効果音はAIにいくつか候補を作ってもらい、専用の音くらべページで実際に聞いてから採用する方法を試しました。見た目だけでなく、音も実際に確認しながら決められるようになりました。
次回は、ここまで作ったキー入力の仕組みを土台に、さらにタイピング練習を発展させていきます。


