ヒマつぶし向けの 山手線ゲーム プログラム、アプリです。 山手線ゲームのルールを知らないので適当に作りました。
PC(Windows)、Android スマホ、iPhone(たぶん) で動きます。
完全にローカルだけで(スタンドアロンで)動くスマホアプリです。 PCのファイル エクスプローラや Android の Google Files アプリで、ダウンロードした HTML ファイルをダブルクリックすれば、ブラウザ上で そのまま動きます。 (ただし iPhone Safari ではダウンロードした HTML ファイルは JavaScript が動かないので、ローカルで実行はできません。)
- 山手線ゲーム.htm (すぐ試せます)
https://grand-tiramisu-1e79a8.netlify.app
(下にあるソースプログラムと同じものです。)
広告なし。 通信なし。 情報取集なし。 もちろん無料。
山手線の駅名なんて関東以外の人は詳しくないでしょうからヒントを表示しています。
音声認識機能も使えます。
実行画面例
山手線ゲーム ソースプログラム
山手線ゲーム.htm (UTF-8 で保存)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>山手線ゲーム</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
max-width: 600px;
margin: 10px auto;
padding: 12px;
border: 1px solid #ccc;
border-radius: 8px;
background-color: #f9f9f9;
}
h2 {
margin-top: 5px;
margin-bottom: 10px;
font-size: 1.4rem;
}
p {
font-size: 0.95rem;
margin-bottom: 12px;
}
.station-list-box {
background: #eef9f0;
border: 1px solid #81c784;
// border-radius: 6px;
border-radius: 1px;
padding: 10px;
// margin-bottom: 12px;
margin-bottom: 1px;
}
.station-list-box h3 {
// margin: 0 0 8px 0;
margin: 0 0 1px 0;
font-size: 0.95rem;
color: #2e7d32;
}
.station-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
// gap: 6px;
gap: 1px;
font-size: 0.85rem;
}
.station-item {
background: #ffffff;
// padding: 4px 6px;
padding: 1px 1px;
// border-radius: 4px;
border-radius: 1px;
border: 1px solid #c8e6c9;
text-align: center;
}
.station-item .hint {
color: #d32f2f;
font-weight: bold;
}
/* チャットボックスの高さ約2行(60px) */
#chat-log {
height: 60px;
border: 1px solid #ddd;
background: #fff;
padding: 8px;
overflow-y: scroll;
margin-bottom: 10px;
font-size: 0.95rem;
line-height: 1.4;
}
.user { color: blue; margin: 2px 0; }
.cpu { color: green; margin: 2px 0; }
.system { color: red; font-weight: bold; margin: 4px 0; }
.control-panel {
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.control-panel label {
font-size: 0.9rem;
white-space: nowrap;
}
.control-panel select {
flex: 1;
padding: 6px;
font-size: 0.9rem;
}
textarea {
width: 100%;
height: 50px;
margin-bottom: 10px;
font-size: 1rem;
padding: 8px;
}
.button-group {
display: flex;
gap: 10px;
}
button {
flex: 1;
padding: 12px;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
border-radius: 6px;
border: 1px solid #aaa;
color: #ffffff;
background: #2563eb;
}
button.sub {
color: #2563eb;
background-color: #ffffff;
}
#mic-btn.recording {
// color: white;
// background-color: #ff4d4d;
color: #2563eb;
background-color: #ffffff;
animation: pulse 1s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
</style>
</head>
<body>
<h2>🚃 山手線ゲーム 🚃</h2>
<p>山手線の駅名をひらがな(またはカタカナ)で答えてください! 一度出たものはダメ!</p>
<!-- ヒント用:山手線全駅一覧(頭3文字) -->
<div class="station-list-box">
<h3>📍 山手線 全30駅一覧(頭3文字ヒント)</h3>
<div class="station-grid" id="station-grid"></div>
</div>
<div class="control-panel">
<label for="voice-select">CPUの音声:</label>
<select id="voice-select">
<option value="">読み込み中...</option>
</select>
</div>
<!-- チャット表示部(2行程度の高さ) -->
<div id="chat-log"></div>
<textarea id="user-input" placeholder="ひらがなで入力(例: しぶや) [Enter] で送信 / [Shift + Enter] で改行"></textarea>
<div class="button-group">
<button id="send-btn" onclick="playTurn()">送信</button>
<button id="mic-btn" class="sub" onclick="toggleSpeechRecognition()">🎤 音声入力</button>
</div>
<script>
const yamanoteStations = [
{ name: "大崎", reading: "おおさき", head3: "おおさ" },
{ name: "五反田", reading: "ごたんだ", head3: "ごたん" },
{ name: "目黒", reading: "めぐろ", head3: "めぐろ" },
{ name: "恵比寿", reading: "えびす", head3: "えびす" },
{ name: "渋谷", reading: "しぶや", head3: "しぶや" },
{ name: "原宿", reading: "はらじゅく", head3: "はらじ" },
{ name: "代々木", reading: "よよぎ", head3: "よよぎ" },
{ name: "新宿", reading: "しんじゅく", head3: "しんじ" },
{ name: "新大久保", reading: "しんおおくぼ", head3: "しんお" },
{ name: "高田馬場", reading: "たかだのばば", head3: "たかだ" },
{ name: "目白", reading: "めじろ", head3: "めじろ" },
{ name: "池袋", reading: "いけぶくろ", head3: "いけぶ" },
{ name: "大塚", reading: "おおつか", head3: "おおつ" },
{ name: "巣鴨", reading: "すがも", head3: "すがも" },
{ name: "駒込", reading: "こまごめ", head3: "こまご" },
{ name: "田端", reading: "たばた", head3: "たばた" },
{ name: "西日暮里", reading: "にしにっぽり", head3: "にしに" },
{ name: "日暮里", reading: "にっぽり", head3: "にっぽ" },
{ name: "鶯谷", reading: "うぐいすだに", head3: "うぐい" },
{ name: "上野", reading: "うえの", head3: "うえの" },
{ name: "御徒町", reading: "おかちまち", head3: "おかち" },
{ name: "秋葉原", reading: "あきはばら", head3: "あきは" },
{ name: "神田", reading: "かんだ", head3: "かんだ" },
{ name: "東京", reading: "とうきょう", head3: "とうき" },
{ name: "有楽町", reading: "ゆうらくちょう", head3: "ゆうら" },
{ name: "新橋", reading: "しんばし", head3: "しんば" },
{ name: "浜松町", reading: "はままつちょう", head3: "はまま" },
{ name: "田町", reading: "たまち", head3: "たまち" },
{ name: "高輪ゲートウェイ", reading: "たかなわげーとうぇい", head3: "たかな" },
{ name: "品川", reading: "しながわ", head3: "しなが" }
];
let usedStations = [];
let isGameOver = false;
let recognition = null;
let isRecording = false;
let voices = [];
const voiceSelect = document.getElementById("voice-select");
function renderStationList() {
const grid = document.getElementById("station-grid");
grid.innerHTML = "";
yamanoteStations.forEach(st => {
const item = document.createElement("div");
item.className = "station-item";
// item.innerHTML = `${st.name}: <span class="hint">${st.head3}</span>`;
item.innerHTML = `<span class="hint">${st.head3}</span>`;
grid.appendChild(item);
});
}
function loadVoices() {
if (!('speechSynthesis' in window)) {
voiceSelect.innerHTML = '<option value="">音声合成非対応</option>';
return;
}
voices = speechSynthesis.getVoices().filter(voice => voice.lang.includes('ja'));
voiceSelect.innerHTML = '';
if (voices.length === 0) {
const option = document.createElement('option');
option.textContent = '標準音声(デフォルト)';
option.value = '';
voiceSelect.appendChild(option);
} else {
voices.forEach((voice, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${voice.name} (${voice.lang})`;
voiceSelect.appendChild(option);
});
}
}
if ('speechSynthesis' in window) {
loadVoices();
speechSynthesis.onvoiceschanged = loadVoices;
}
function speakCpuWord(text) {
if (!('speechSynthesis' in window)) return;
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'ja-JP';
const selectedIndex = voiceSelect.value;
if (selectedIndex !== "" && voices[selectedIndex]) {
utterance.voice = voices[selectedIndex];
}
speechSynthesis.speak(utterance);
}
const chatLog = document.getElementById("chat-log");
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SpeechRecognition) {
recognition = new SpeechRecognition();
recognition.lang = 'ja-JP';
recognition.interimResults = false;
recognition.onstart = () => {
isRecording = true;
const micBtn = document.getElementById("mic-btn");
micBtn.textContent = "🎙️ 録音中...";
micBtn.classList.add("recording");
};
recognition.onresult = (event) => {
const transcript = event.results[0][0].transcript;
document.getElementById("user-input").value = transcript;
playTurn();
};
recognition.onerror = (event) => {
console.error("音声認識エラー:", event.error);
appendMessage("system", "音声認識に失敗しました。テキスト入力してください。");
};
recognition.onend = () => {
isRecording = false;
const micBtn = document.getElementById("mic-btn");
micBtn.textContent = "🎤 音声入力";
micBtn.classList.remove("recording");
};
}
function toggleSpeechRecognition() {
if (!recognition) {
alert("お使いのブラウザは音声認識に対応していません。(Google Chrome等を推奨)");
return;
}
if (isRecording) {
recognition.stop();
} else {
recognition.start();
}
}
function initGame() {
renderStationList();
const userInput = document.getElementById("user-input");
userInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
playTurn();
}
});
appendMessage("system", "山手線ゲームスタート! ひらがなで入力してください。");
}
function cleanWord(str) {
if (!str) return "";
let cleaned = str.replace(/[。、!?.!]/g, "").trim();
return toHiragana(cleaned);
}
function toHiragana(str) {
return str.replace(/[ァ-ヶ]/g, match => {
return String.fromCharCode(match.charCodeAt(0) - 0x60);
});
}
function playTurn() {
if (isGameOver) return;
const inputArea = document.getElementById("user-input");
let rawWord = inputArea.value.trim();
let word = cleanWord(rawWord);
if (!word) return;
appendMessage("user", `あなた: ${rawWord}`);
inputArea.value = "";
const matchedStation = yamanoteStations.find(st => st.reading === word || st.name === rawWord);
if (!matchedStation) {
endGame(`「${rawWord}」は山手線の駅名ではありません!`);
return;
}
const stationReading = matchedStation.reading;
if (usedStations.includes(stationReading)) {
endGame(`「${matchedStation.name}(${stationReading})」は すでに回答された駅名です!`);
return;
}
usedStations.push(stationReading);
if (usedStations.length === yamanoteStations.length) {
appendMessage("system", "全30駅完走! あなたの完全勝利です!");
speakCpuWord("全30駅達成です! あなたの勝ちです!");
isGameOver = true;
disableInput();
return;
}
const remainingStations = yamanoteStations.filter(st => !usedStations.includes(st.reading));
const cpuStation = remainingStations[Math.floor(Math.random() * remainingStations.length)];
usedStations.push(cpuStation.reading);
setTimeout(() => {
appendMessage("cpu", `CPU: ${cpuStation.name}(${cpuStation.reading})`);
speakCpuWord(`${cpuStation.reading}`);
if (usedStations.length === yamanoteStations.length) {
appendMessage("system", "全30駅が出尽くしました! 引き分けです!");
isGameOver = true;
disableInput();
}
}, 600);
}
function appendMessage(type, text) {
const p = document.createElement("p");
p.className = type;
p.textContent = text;
chatLog.appendChild(p);
chatLog.scrollTop = chatLog.scrollHeight;
}
function endGame(reason) {
appendMessage("system", `【アウト!】${reason}`);
speakCpuWord(`アウト! ${reason}`);
appendMessage("system", "ゲーム終了! CPUの勝ちです!");
isGameOver = true;
disableInput();
}
function disableInput() {
document.getElementById("user-input").disabled = true;
document.getElementById("send-btn").disabled = true;
document.getElementById("mic-btn").disabled = true;
}
window.onload = initGame;
</script>
</body>
</html>
