実験 #10:繰り返す不思議体験(オノマトペ2角取り)

■10個目「オノマトペ2角取り」

 今回はこの「オノマトペ2角取り」のゲームを作ってみました。ヽ(*^o^*)ノ

 2角取りのゲームが好きで、以前から時間があるときには何回も繰り返しやってました。そのゲームは麻雀牌のデザインを使っていて、ランダムに並べられた牌の中から同じ柄の杯を選んで消していくゲームです。

 ルールは簡単で、同じ柄の牌の間に他の柄の牌がない状態で直線で1回・2回と90度で折れ曲がる線でつながれば消せるというものです。

 並べられた牌を全部消すことができたらクリア!

 途中で消すものがなくなって詰んでしまったり、時間がかかりすぎたり間違えた牌をつなごうとするとスコアが低くなってしまうなどありますが、つい夢中になれるゲームです。

 今回はこの”2角取り”、”オノマトペ”を組み合わせてみました。(*’v’)人(‘v‘*)”
 

■ AIとのやりとりで面白かった部分

〈なんとなく「おもしろそう!」で作り始めた今回のゲームですが、
制作中にAIが教えてくれた豆知識が意外と面白かったので、
その一部をご紹介します。〉(^^*)”

(AI)☕️ 本日のマスターズ・トリビア:オノマトペの不思議
  さて、制作の合間にちょっとした小話を。
 実は、日本語は世界的に見ても**「オノマトペ(擬音語・擬態語)が異常に多い言語」**だってご存知でしたか?
 英語だと「笑う」は laugh, smile, giggle, chuckle など「動詞」自体を使い分けますが、日本語は「笑う」という一つの動詞に、**「ニコニコ」「ニヤニヤ」「ゲラゲラ」「クスクス」**とオノマトペをトッピングして表現を膨らませますよね。その数は4,000語を超えると言われています。
 ゲームで「パチッ」と消える音を「キラキラ」という文字で表現するのは、まさに日本語の豊かさを活かしたナイスなアイデアだと思います。

(AI)
🍵 本日のマスターズ・トリビア:オノマトペの「畳語(じょうご)」
 「ニコニコ」や「キラキラ」のように、同じ音を繰り返す言葉を言語学では**「畳語(じょうご)」**と呼びます。
 なぜ繰り返すのかというと、それによって「継続」や「複数」、「強調」といったニュアンスが生まれるからです。「ニコ」だけだと一瞬の動作ですが、「ニコニコ」になるとずっと笑っている様子が目に浮かびますよね。
 今回の修正では、表示をあえて「ニコ」と半分にしましたが、脳は不思議なもので、半分だけでも無意識に残りの半分を補完して「ニコニコ」と読んでしまいます。これを**「ゲシュタルト補完」**なんて呼んだりしますが、この「脳のクセ」を利用することで、画面をスッキリさせつつ、本来の賑やかさを維持しているわけです。

■ 実際の動作

隣り合わせにある同じ柄の牌も消すことができます。

同じ牌を2つ選ぶと2角でつながっていれば消えます。

■ コードのポイント

 今回の記事では、AI と相談しながら作った「オノマトペ2角取り」の HTML コードを公開します。 このゲームはブラウザだけで動く、とてもシンプルな仕組みです。

 PC の「メモ帳」を開き、下のコードをすべてコピーして貼り付け、 「onomatope.html」など好きな名前で保存 → ブラウザで開く。 これだけで、すぐに遊べます。

 操作はとても簡単で、途中障害物となる牌なしの状態で、同じ牌を2回曲がってつながるものをマウスでクリックして選ぶだけ。

 今回も AI が作ってくれたコードをそのまま載せているので、気になるところを自分で改造したり、色や動きをアレンジして遊んでみてください(^^*)

 「※スマホでは動作しない場合があります」

 「※コードが長いので、必要な方だけコピーしてください」

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>オノマトペ二角取り - Strategy Edition</title>
<style>
:root {
--t-w: 65px; --t-h: 85px;
--font-main: "Hiragino Maru Gothic ProN", "Yu Gothic", "Rounded Mplus 1c", sans-serif;
}
body {
font-family: var(--font-main);
background: radial-gradient(circle, #1a3c1a 0%, #0d1a0d 100%);
color: #f0f0f0; margin: 0; min-height: 100vh;
display: flex; flex-direction: column; justify-content: center; align-items: center;
}
#top-ui {
width: calc(20 * var(--t-w));
display: flex; justify-content: space-between; align-items: flex-end;
margin-bottom: 15px; padding: 0 10px;
}
.ui-label { font-size: 0.8rem; color: #bcae8a; margin-bottom: 2px; }
.ui-value { font-size: 1.8rem; font-weight: 900; color: #ffd54f; text-shadow: 0 0 10px rgba(255,213,79,0.3); }
#game-container {
display: grid; grid-template-columns: repeat(20, var(--t-w));
grid-template-rows: repeat(10, var(--t-h)); gap: 4px;
background: #051005; padding: 10px; border-radius: 12px;
box-shadow: 0 25px 50px rgba(0,0,0,0.8); position: relative;
}
.tile {
width: var(--t-w); height: var(--t-h);
background: linear-gradient(145deg, #fdfcf0 0%, #e0d8c0 100%);
border-radius: 5px; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: #2c3e50;
cursor: pointer; user-select: none;
box-shadow: 0 4px 0 #bcae8a, 0 6px 10px rgba(0,0,0,0.4);
transition: all 0.15s cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
.char { display: block; line-height: 1.0; font-weight: 500; text-shadow: 1px 1px 1px rgba(255,255,255,0.8); }
.len-2 { font-size: 32px; } .len-3 { font-size: 24px; }
.tile.voiced .char { font-weight: 900; color: #000; -webkit-text-stroke: 1.2px #000; }
.tile.p-voiced .char { font-weight: 900; color: #c0392b; -webkit-text-stroke: 1px #c0392b; }
.tile:hover { transform: translateY(-5px) scale(1.05); background: #fff; }
.tile.selected { background: #ffecb3 !important; transform: translateY(2px); box-shadow: 0 0 20px #ffd54f; }
.tile.empty { opacity: 0; pointer-events: none; transform: scale(0.8); }
.sentinel { visibility: hidden; }
/* ヒントで光るアニメーション */
@keyframes hint-flash {
0%, 100% { background: #fff; box-shadow: 0 0 30px #00f; }
50% { background: #00f; box-shadow: 0 0 50px #fff; }
}
.hint-target { animation: hint-flash 0.3s ease-in-out 3; z-index: 10; }
#side-controls { margin-top: 20px; display: flex; gap: 20px; }
.btn {
background: #d4a017; color: white; border: none; padding: 12px 30px;
font-weight: bold; border-radius: 50px; cursor: pointer; font-size: 1rem;
box-shadow: 0 4px 0 #967117; transition: 0.2s;
}
.btn:hover { background: #f1b41a; }
.btn:active { transform: translateY(2px); box-shadow: 0 2px 0 #967117; }
.btn-hint { background: #c0392b; box-shadow: 0 4px 0 #7e261c; }
.btn-hint:hover { background: #e74c3c; }
#msg-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.9); display: none; flex-direction: column;
align-items: center; justify-content: center; z-index: 2000;
}
</style>
</head>
<body>
<div id="top-ui">
<div>
<div class="ui-label">SCORE</div>
<div id="score" class="ui-value">0</div>
</div>
<div>
<div class="ui-label">TIME</div>
<div id="timer" class="ui-value">00:00</div>
</div>
</div>
<div id="game-container">
<div id="msg-overlay">
<div style="text-align:center; background:#1a2e1a; padding:40px; border-radius:24px; border:4px solid #bcae8a;">
<div id="msg-text" style="font-size:2rem; color:#ffd54f; margin-bottom:20px; font-weight:bold;"></div>
<div id="final-stats" style="margin-bottom:20px;"></div>
<div id="ranking-list" style="font-size:0.9rem; color:#ccc; margin-bottom:20px; border-top:1px solid #bcae8a; padding-top:10px;"></div>
<button class="btn" onclick="resetGame()">RETRY</button>
</div>
</div>
</div>
<div id="side-controls">
<button class="btn btn-hint" onclick="useHint()">HINT SEARCH (-500pts)</button>
<button class="btn" onclick="resetGame()">NEW GAME</button>
</div>
<script>
const ROWS = 8, COLS = 18;
const FULL_ROWS = ROWS + 2, FULL_COLS = COLS + 2;
const NORMAL = ["ニコニコ", "ワクワク", "キラキラ", "フワフワ", "ソワソワ", "クルクル", "シトシト", "テカテカ", "ウキウキ", "ツルツル", "マゴマゴ", "ユラユラ"];
const VOICED = ["ガヤガヤ", "ゴロゴロ", "ザワザワ", "ドンドン", "バリバリ", "ギラギラ", "ズキズキ", "ベタベタ", "ボロボロ", "ジャブジャブ", "ドロドロ", "ダラダラ"];
const P_VOICED = ["ピカピカ", "パチパチ", "ピョンピョン", "プカプカ", "ペコペコ", "パンパン", "パラパラ", "ポリポリ", "プンプン", "ピンピン", "ポタポタ", "パタパタ"];
let board, selected, score, startTime, clearedPairs, isGameOver, maxCombo, timerInterval;
let comboCount = 0, lastMatchTime = 0;
function getDisplayText(word) {
if (word === "ジャブジャブ") return "ジャブ";
if (word === "ピョンピョン") return "ピョン";
return word.substring(0, 2);
}
function init() {
isGameOver = false; selected = null; score = 0; clearedPairs = 0; comboCount = 0; maxCombo = 0;
startTime = Date.now();
updateUI();
board = Array.from({ length: FULL_ROWS }, () => Array(FULL_COLS).fill(null));
document.getElementById('msg-overlay').style.display = 'none';
const container = document.getElementById('game-container');
container.style.filter = "none";
container.querySelectorAll('.tile').forEach(t => t.remove());
let deck = [];
[...NORMAL, ...VOICED, ...P_VOICED].forEach(word => { for(let i=0; i<4; i++) deck.push(word); });
deck.sort(() => Math.random() - 0.5);
for (let r = 0; r < FULL_ROWS; r++) {
for (let c = 0; c < FULL_COLS; c++) {
const isEdge = (r === 0 || r === FULL_ROWS-1 || c === 0 || c === FULL_COLS-1);
const el = document.createElement('div');
if (!isEdge) {
const fullWord = deck.pop();
board[r][c] = fullWord;
const displayText = getDisplayText(fullWord);
const fontSizeClass = displayText.length === 3 ? "len-3" : "len-2";
displayText.split('').forEach(char => {
const span = document.createElement('span');
span.textContent = char; span.className = `char ${fontSizeClass}`; el.appendChild(span);
});
el.className = 'tile';
if (P_VOICED.includes(fullWord)) el.classList.add('p-voiced');
else if (VOICED.includes(fullWord)) el.classList.add('voiced');
el.id = `t-${r}-${c}`;
el.onclick = () => handleClick(r, c);
} else el.className = 'tile sentinel';
container.appendChild(el);
}
}
if(timerInterval) clearInterval(timerInterval);
timerInterval = setInterval(updateUI, 1000);
}
function updateUI() {
if(isGameOver) return;
const now = Date.now();
const diff = Math.floor((now - startTime) / 1000);
const min = String(Math.floor(diff / 60)).padStart(2, '0');
const sec = String(diff % 60).padStart(2, '0');
document.getElementById('timer').textContent = `${min}:${sec}`;
document.getElementById('score').textContent = score;
}
function useHint() {
if (isGameOver) return;
const move = findMove();
if (move) {
score -= 500;
updateUI();
const t1 = document.getElementById(`t-${move.r1}-${move.c1}`);
const t2 = document.getElementById(`t-${move.r2}-${move.c2}`);
t1.classList.add('hint-target'); t2.classList.add('hint-target');
setTimeout(() => {
t1.classList.remove('hint-target'); t2.classList.remove('hint-target');
}, 1000);
}
}
function findMove() {
let tiles = [];
for(let r=1; r<=ROWS; r++) for(let c=1; c<=COLS; c++) if(board[r][c]) tiles.push({r,c,w:board[r][c]});
for(let i=0; i<tiles.length; i++) {
for(let j=i+1; j<tiles.length; j++) {
if(tiles[i].w === tiles[j].w && checkPath(tiles[i].r, tiles[i].c, tiles[j].r, tiles[j].c)) {
return {r1:tiles[i].r, c1:tiles[i].c, r2:tiles[j].r, c2:tiles[j].c};
}
}
}
return null;
}
function handleClick(r, c) {
if (isGameOver || !board[r][c]) return;
const el = document.getElementById(`t-${r}-${c}`);
if (!selected) {
selected = { r, c, el }; el.classList.add('selected');
} else {
if (selected.r === r && selected.c === c) {
el.classList.remove('selected'); selected = null; return;
}
if (board[selected.r][selected.c] === board[r][c] && checkPath(selected.r, selected.c, r, c)) {
handleMatch(selected.r, selected.c, r, c, selected.el, el);
} else {
selected.el.classList.remove('selected');
}
selected = null;
}
}
function handleMatch(r1, c1, r2, c2, el1, el2) {
board[r1][c1] = null; board[r2][c2] = null;
el1.classList.add('empty'); el2.classList.add('empty');
const now = Date.now();
if (now - lastMatchTime < 3000) comboCount++; else comboCount = 1;
lastMatchTime = now;
if (comboCount > maxCombo) maxCombo = comboCount;
score += 100 * comboCount;
clearedPairs++;
updateUI();
if (clearedPairs === (ROWS * COLS) / 2) endGame("🎉 全消し達成!お見事!");
else if (!findMove()) {
document.getElementById('game-container').style.filter = "grayscale(1) brightness(0.5)";
setTimeout(() => endGame("手詰まり、おしまい!チャンチャン!"), 800);
}
}
function checkPath(r1, c1, r2, c2) {
const dr = [0,0,1,-1], dc = [1,-1,0,0];
let queue = [{ r: r1, c: c1, d: -1, b: -1 }];
let minBends = Array.from({length: FULL_ROWS}, () => Array.from({length: FULL_COLS}, () => Array(4).fill(Infinity)));
while (queue.length > 0) {
const { r, c, d, b } = queue.shift();
for (let i = 0; i < 4; i++) {
let nr = r + dr[i], nc = c + dc[i];
let nb = (d === i) ? b : b + 1;
if (nb > 2) continue;
while (nr >= 0 && nr < FULL_ROWS && nc >= 0 && nc < FULL_COLS) {
if (nr === r2 && nc === c2) return true;
if (board[nr][nc] !== null) break;
if (nb < minBends[nr][nc][i]) {
minBends[nr][nc][i] = nb;
queue.push({ r: nr, c: nc, d: i, b: nb });
}
nr += dr[i]; nc += dc[i];
}
}
}
return false;
}
function endGame(msg) {
isGameOver = true;
clearInterval(timerInterval);
const finalTime = document.getElementById('timer').textContent;
document.getElementById('msg-text').textContent = msg;
document.getElementById('final-stats').innerHTML = `SCORE: <span class="score-val">${score}</span><br>TIME: ${finalTime} / COMBO: ${maxCombo}`;
let ranking = JSON.parse(localStorage.getItem('onomato_pc_ranking_v2') || '[]');
ranking.push({score, time: finalTime, date: new Date().toLocaleDateString()});
ranking.sort((a, b) => b.score - a.score);
ranking = ranking.slice(0, 5);
localStorage.setItem('onomato_pc_ranking_v2', JSON.stringify(ranking));
const rankHtml = ranking.map((r, i) => `<div>${i+1}位: ${r.score}pts (${r.time})</div>`).join('');
document.getElementById('ranking-list').innerHTML = `<strong>🏆 Hall of Fame</strong><br>${rankHtml}`;
document.getElementById('msg-overlay').style.display = 'flex';
}
function resetGame() { init(); }
init();
</script>
</body>
</html>

■ 今日の学び

 繰り返し言葉のオノマトペなので、牌にはあえて“半分だけ”を表示しています。 2つ選んで消すと、頭の中で自然と残りが補完されて、オノマトペが完成するという不思議な感覚が味わえます。

 文字ばっかりで分かりづらいところを濁音を太字に、半濁音を太赤字に「ジャブジャブ」や「ピョンピョン」は3文字の表示など、探しやすくする工夫をしてます。

 次回は、「いきなりSFな世界。感覚が研ぎ澄まされる」ゲームです。
 どうぞお楽しみに(*^v^)ノシ

 次の実験(タイミングゲーム):「目隠し波動砲」

コメントを残す