実験 #14:砂浜に散らばる宝石たち(シーグラス探しゲーム)

■15個目「シーグラス探しゲーム」

 今回はこの「シーグラス探し」のゲームを作ってみました。ヽ(*^o^)ノ

 今回は自分の夢中になれることをゲームにしてみたくて、AIに相談しながら作ったゲームです。

 最初にAIへ「こんなゲームが作りたい」と伝えるのですが、あえてぼんやりしたイメージだけを投げて、どんな提案が返ってくるか楽しんでいます。
 そこから少しずつ修正していく感じです。

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

(^^*)今日もゲーム作ります。
 収集ゲームで、タイトルは「シーグラス集め」
波打ち際を歩きながら砂や小石に混じって落ちているシーグラスを集めていくゲーム。
いろいろなサイズがあって、
 極小(結構多め)
 小(多め)
 中(普通)
 大(少な目)
 特大(かなり少ない)
形もいろいろ(丸みのあるいろいろな形)
 きわめてレアなビー玉ガラス石は、1000個に一つあるかないかの割合。
色は、
 茶(かなり多い)
 水色(結構多い)
 白(多い)
 緑(普通)
 赤と黄色とピンクと紫(極めて少ない)。
 マウスでシーグラスをクリックすると拾える。
集めたガラス石は画面横のガラス瓶に入っていき、集めていくのが目で見てわかる。

(AI)「おかえりなさい!『シーグラス集め』ですね。素敵なテーマです。
今日は“馴染みの店主”風に、裏話も交えながらコードを組んでみました。
実はシーグラスは“人魚の涙(Mermaid Tears)”と呼ばれることもあるんですよ…」
〈今回1回目でイメージに近いものができました。〉(^v^*)

〈修正を重ねていくうちに、なぜかレア度のバランスが崩れてしまい、
レアカラーがポンポン出るようになってしまいました。〉

(=_=;)レアがすごく出やすくなってるけど。茶’ 35% ‘水色’ 25% ‘白’20% 緑’10% ‘赤’ 0.9% ‘黄’ 1% ‘桃’1% ‘紫’ 1% ‘コバルト青’3%’フォレスト緑’3%あとビー玉は0.1%で出現率調整できるかな。
 アクセサリー工房も開けないよ。無理のある変更だったら貝殻要素はのけていいよ。提案してくれた波の音できるかな。

(AI)大変失礼いたしました!貝殻の追加時にコードが少し複雑になり、レア度の計算や工房のボタン処理が不安定になっていたようです。

「無理のない範囲で」と言ってくださってありがとうございます。でも、ご安心ください!貝殻の雰囲気も、波の音も、そして正確なレア度も、すべて快適なコードに統合できました。

■ 実際の動作

波の音が心地よいです。

拾ったシーグラスは右上の瓶に色の層で貯められます。

拾ったシーグラスでアクセサリーが作れて、画面右下の飾り棚へ作ったのを飾れます。

シーグラスの割合はいつも拾って分類しているのでそれをもとに設定しました。
拾うたびに分類して…記念撮影してます。下の画像はその一部です。

実際に私が拾ったシーグラスたちです。

色ごとに並べてみると、海の宝物感がぐっと増します。

分類して並べた後は、庭にばらまいてパワースポット的なものにしてます(^^*)

今までほとんど拾えなかったので0.1%にしたビー玉シーグラス

■ コードのポイント

 今回の記事では、AI と相談しながら作った「シーグラス探しゲーム」の HTML コードを公開します。ブラウザだけで動く、とてもシンプルなゲームです。

PC の「メモ帳」(※Windowsの場合)を開き、下のコードをコピーして貼り付け、「si-gurasu.html」など好きな名前で保存 → ブラウザで開く。これだけで遊べます。

 操作はとても簡単です。
・マウスクリックでシーグラスを拾う
・集めたシーグラスでアクセサリーを作る

 今回も AI が作ってくれたコードをそのまま載せています。
色や出現率を変えたり、動きをアレンジしたり、自由に改造して遊んでみてください(^^*)

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

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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>シーグラスの波打ち際 - Seaside Atelier</title>
<style>
body { margin: 0; overflow: hidden; font-family: 'Hiragino Mincho ProN', serif; background: #e0d7c6; }
#game-container { position: relative; width: 100vw; height: 100vh; display: flex; }
#beach { flex: 1; position: relative; overflow: hidden; background: linear-gradient(to bottom, #f2ead3 0%, #e6d5b8 80%, #abdbe3 100%); cursor: crosshair; }
#wave { position: absolute; bottom: 0; width: 100%; height: 60px; background: rgba(255, 255, 255, 0.4); animation: tide 5s infinite ease-in-out; pointer-events: none; z-index: 20; filter: blur(3px); }
@keyframes tide { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-25px); } }
#sidebar { width: 300px; background: #fffdf9; border-left: 2px solid #d1c4ae; display: flex; flex-direction: column; align-items: center; padding: 20px; z-index: 30; box-shadow: -5px 0 15px rgba(0,0,0,0.1); overflow-y: auto; }
#bottle-outer { position: relative; width: 130px; height: 220px; border: 4px solid #eee; border-radius: 15px 15px 60px 60px; background: rgba(255,255,255,0.4); overflow: hidden; display: flex; flex-direction: column-reverse; box-shadow: inset 0 0 10px rgba(255,255,255,0.8); }
.glass-layer { width: 100%; border-top: 0.1px solid rgba(255,255,255,0.2); }
.obj { position: absolute; transition: opacity 1.5s, transform 0.2s; }
.sea-glass { cursor: pointer; filter: blur(0.3px) drop-shadow(1px 1px 2px rgba(0,0,0,0.1)); z-index: 15; }
.sea-glass:hover { transform: scale(1.3) rotate(5deg); filter: brightness(1.2); }
.decoration { pointer-events: none; opacity: 0.75; z-index: 10; filter: drop-shadow(1px 1px 1px rgba(0,0,0,0.15)); }
.btn { background: #78909c; color: white; border: none; padding: 10px 20px; border-radius: 20px; cursor: pointer; margin: 5px 0; font-size: 13px; width: 220px; transition: 0.2s; }
.btn:hover { background: #546e7a; }
.btn:disabled { background: #ccc; cursor: default; }
.modal { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 30px; border-radius: 15px; z-index: 100; box-shadow: 0 0 50px rgba(0,0,0,0.4); width: 380px; max-height: 80vh; overflow-y: auto; }
#time-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 25; transition: background 5s; }
#display-shelf {
width: 100%; background: #a67b5b; border: 4px solid #5c4033; border-radius: 5px; padding: 10px; box-sizing: border-box;
display: flex; flex-wrap: wrap; justify-content: space-between; min-height: 100px;
box-shadow: inset 0 5px 15px rgba(0,0,0,0.4), 0 5px 5px rgba(0,0,0,0.1);
}
.shelf-item {
width: 46%; background: linear-gradient(to bottom, #ffffff, #f0f0f0); margin-bottom: 10px; padding: 8px 5px;
text-align: center; border-radius: 3px; font-size: 11px; box-shadow: 2px 2px 5px rgba(0,0,0,0.3);
color: #444; box-sizing: border-box; display: flex; flex-direction: column; align-items: center;
}
.shelf-icon { font-size: 20px; margin-bottom: 3px; }
.empty-text { color: #e6d5b8; font-size: 12px; text-align: center; width: 100%; margin-top: 30px; }
</style>
</head>
<body>
<div id="game-container">
<div id="beach" onclick="startAudio()">
<div id="time-overlay"></div>
<div style="padding: 20px; color: #7a6b55; position: relative; z-index: 26;">
<h2 style="margin:0;">Sea Glass Coast</h2>
<p id="env-text" style="font-size:12px; opacity: 0.8;">画面をクリックすると波の音が響き始めます...</p>
</div>
<div id="wave"></div>
</div>
<div id="sidebar">
<h3 style="margin-top:0;">ガラス瓶</h3>
<div id="bottle-outer"></div>
<div style="text-align: center; margin: 15px 0;">
<span style="font-size: 20px; color: #546e7a;"><b id="vol-count">0</b></span> / 50
<br><small id="last-info" style="color:#999;">波打ち際を探してみよう</small>
</div>
<button class="btn" onclick="openModal('craft')">アクセサリー工房</button>
<button class="btn" style="background:#b0bec5;" onclick="openModal('collection')">図鑑を見る</button>
<div style="width: 100%; margin-top: 20px;">
<small style="color:#7a6b55; font-weight:bold; display:block; margin-bottom:5px;">■ コレクションの飾り棚</small>
<div id="display-shelf"></div>
</div>
</div>
</div>
<div id="modal-craft" class="modal">
<h3>アクセサリー工房</h3>
<div id="recipe-list"></div>
<button class="btn" style="background:#aaa; margin-top:15px; width:100%;" onclick="closeModal()">砂浜に戻る</button>
</div>
<div id="modal-collection" class="modal">
<h3>収集記録</h3>
<div id="history-list" style="font-size:14px; line-height:1.8;"></div>
<button class="btn" style="background:#aaa; margin-top:15px; width:100%;" onclick="closeModal()">閉じる</button>
</div>
<script>
const beach = document.getElementById('beach');
const bottle = document.getElementById('bottle-outer');
const volCount = document.getElementById('vol-count');
const lastInfo = document.getElementById('last-info');
const displayShelf = document.getElementById('display-shelf');
let state = JSON.parse(localStorage.getItem('seaglass_vShelf3')) || { vol: 0, counts: {}, items: [] };
const colorTable = [
{ name: '茶', code: '#A67B5B', w: 35 },
{ name: '水色', code: '#B0E0E6', w: 25 },
{ name: '白', code: '#F5F5F5', w: 20 },
{ name: '緑', code: '#a3d1a3', w: 10 },
{ name: 'コバルト青', code: '#0047AB', w: 3 },
{ name: 'フォレスト緑', code: '#014421', w: 3 },
{ name: '赤', code: '#E74C3C', w: 0.9 },
{ name: '黄', code: '#F1C40F', w: 1 },
{ name: '桃', code: '#F5B7B1', w: 1 },
{ name: '紫', code: '#A569BD', w: 1 }
];
function getWeightedColor() {
let total = colorTable.reduce((s, i) => s + i.w, 0);
let rand = Math.random() * total;
for (let i of colorTable) { if (rand < i.w) return i; rand -= i.w; }
}
function spawn() {
const rand = Math.random();
const obj = document.createElement('div');
obj.className = 'obj'; // ← これで「自由に配置できる」絶対座標が付きます
if (rand > 0.45) { // シーグラス
const isMarble = Math.random() < 0.001;
const color = isMarble ? {name: 'ビー玉', code: 'rgba(255,255,255,0.7)'} : getWeightedColor();
obj.classList.add('sea-glass');
const s = isMarble ? 28 : (14 + Math.random() * 20);
obj.style.width = s + 'px';
obj.style.height = (s * 0.85) + 'px';
obj.style.backgroundColor = color.code;
obj.style.borderRadius = isMarble ? '50%' : `${35+Math.random()*30}%`;
if(isMarble) obj.style.boxShadow = "inset -3px -3px 6px rgba(0,0,0,0.2), 0 0 10px white";
obj.onclick = (e) => {
e.stopPropagation();
const name = color.name;
const gain = isMarble ? 10 : (s/12);
if(isMarble || (color.w && color.w <= 3)) playDing();
state.vol += gain;
state.counts[name] = (state.counts[name] || 0) + 1;
lastInfo.innerText = name + "を見つけた!";
addLayer(color.code, gain);
updateUI();
save();
obj.remove();
};
} else { // 貝殻・小石・流木・サンゴ
// 以前はここで className ごと上書きしていたため、配置がおかしくなっていました
obj.classList.add('decoration');
const type = Math.random();
if (type > 0.75) {
// 貝殻
obj.style.backgroundColor = '#fdf5e6';
obj.style.width = '18px'; obj.style.height = '15px';
obj.style.borderRadius = '50% 50% 20% 20%';
obj.style.boxShadow = 'inset 0 -2px 3px rgba(0,0,0,0.1)';
} else if (type > 0.5) {
// 小石
obj.style.backgroundColor = '#9e9e9e';
obj.style.width = '16px'; obj.style.height = '12px';
obj.style.borderRadius = '40% 60% 50% 40%';
} else if (type > 0.25) {
// 小さな流木
obj.style.backgroundColor = '#826b57';
obj.style.width = '35px'; obj.style.height = '7px';
obj.style.borderRadius = '3px';
} else {
// 枝サンゴの欠片
obj.style.backgroundColor = '#fffafa';
obj.style.width = '24px'; obj.style.height = '8px';
obj.style.borderRadius = '4px';
obj.style.border = '1px dashed #e6e6e6';
}
}
// ちゃんと砂浜全体に散らばります
obj.style.left = Math.random() * 90 + '%';
obj.style.top = (25 + Math.random() * 55) + '%';
obj.style.transform = `rotate(${Math.random()*360}deg)`;
beach.appendChild(obj);
setTimeout(() => { obj.style.opacity = '0'; setTimeout(()=> {if(obj.parentNode) obj.remove();}, 1500); }, 8000);
}
function addLayer(color, val) {
const layer = document.createElement('div');
layer.className = 'glass-layer';
layer.style.backgroundColor = color;
layer.style.height = (val * 4) + 'px';
bottle.appendChild(layer);
}
function redrawBottle() {
bottle.innerHTML = '';
let totalItems = Object.values(state.counts).reduce((a, b) => a + b, 0);
if (totalItems <= 0 || state.vol <= 0) return;
for (let k in state.counts) {
if (state.counts[k] > 0) {
let c = colorTable.find(x => x.name === k);
let colorCode = c ? c.code : (k === 'ビー玉' ? 'rgba(255,255,255,0.7)' : '#ccc');
let layerVol = state.vol * (state.counts[k] / totalItems);
addLayer(colorCode, layerVol);
}
}
}
function getItemIcon(name) {
if(name.includes('ピンバッジ')) return '🏵️';
if(name.includes('ピアス')) return '💧';
if(name.includes('ブローチ')) return '🍃';
if(name.includes('ペンダント')) return '🌅';
if(name.includes('イヤリング')) return '🐚';
if(name.includes('ビー玉')) return '🔮';
if(name.includes('首飾り')) return '💎';
return '✨';
}
function updateUI() {
volCount.innerText = Math.floor(state.vol / 2);
displayShelf.innerHTML = '';
if (state.items.length === 0) {
displayShelf.innerHTML = '<div class="empty-text">まだ作品がありません</div>';
} else {
state.items.forEach(itemName => {
const itemDiv = document.createElement('div');
itemDiv.className = 'shelf-item';
itemDiv.innerHTML = `<div class="shelf-icon">${getItemIcon(itemName)}</div><div>${itemName}</div>`;
displayShelf.appendChild(itemDiv);
});
}
}
const recipes = [
{ name: '潮騒のピンバッジ', cost: 10, req: {} },
{ name: 'コバルトブルーの涙 (ピアス)', cost: 5, req: { 'コバルト青': 2 } },
{ name: '深緑の森のブローチ', cost: 5, req: { 'フォレスト緑': 2 } },
{ name: '夕焼け色のペンダント', cost: 8, req: { '赤': 1, '黄': 1 } },
{ name: '人魚の贈り物 (イヤリング)', cost: 8, req: { '桃': 1, '紫': 1 } },
{ name: '幸運のビー玉守り', cost: 15, req: { 'ビー玉': 1 } },
{ name: '【極】深海の首飾り', cost: 20, req: { 'コバルト青': 3, 'フォレスト緑': 2, '紫': 1 } }
];
function renderCraft() {
document.getElementById('recipe-list').innerHTML = recipes.map(r => {
let can = state.vol >= r.cost;
let reqDetail = Object.entries(r.req).map(([k,v]) => `${k}${v}個`).join(', ');
for(let k in r.req) if((state.counts[k]||0) < r.req[k]) can = false;
return `<div style="border-bottom:1px solid #eee; padding:12px 0;">
<b style="color:#455a64;">${getItemIcon(r.name)} ${r.name}</b><br>
<small style="color:#666;">容量消費: ${r.cost} ${reqDetail ? '/ 材料: '+reqDetail : ''}</small><br>
<button class="btn" style="width:100px; height:28px; padding:0; margin-top:5px;" ${can?'':'disabled'}
onclick="doCraft('${r.name}', ${r.cost}, '${encodeURIComponent(JSON.stringify(r.req))}')">作る</button>
</div>`;
}).join('');
}
window.doCraft = (name, cost, reqStr) => {
const req = JSON.parse(decodeURIComponent(reqStr));
state.vol -= cost;
for(let k in req) {
state.counts[k] -= req[k];
if(state.counts[k] < 0) state.counts[k] = 0;
}
state.items.push(name);
save();
redrawBottle();
updateUI();
alert(name + "を仕立てました!棚に飾っておきますね。");
closeModal();
};
let audioCtx, filter, gainNode;
function startAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const bufferSize = audioCtx.sampleRate * 2;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = audioCtx.createBufferSource();
noise.buffer = buffer; noise.loop = true;
filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass'; filter.frequency.value = 400;
gainNode = audioCtx.createGain();
noise.connect(filter); filter.connect(gainNode); gainNode.connect(audioCtx.destination);
function swell() {
const now = audioCtx.currentTime;
gainNode.gain.setValueAtTime(0.04, now);
gainNode.gain.exponentialRampToValueAtTime(0.15, now + 2.5);
gainNode.gain.exponentialRampToValueAtTime(0.04, now + 5);
}
swell(); setInterval(swell, 5000);
noise.start();
document.getElementById('env-text').innerText = "波の音が流れています...";
}
function playDing() {
if(!audioCtx) return;
const osc = audioCtx.createOscillator();
const g = audioCtx.createGain();
osc.frequency.value = 1400;
g.gain.setValueAtTime(0.05, audioCtx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.4);
osc.connect(g); g.connect(audioCtx.destination);
osc.start(); osc.stop(audioCtx.currentTime + 0.4);
}
function openModal(id) {
document.getElementById('modal-' + id).style.display = 'block';
if(id === 'collection') {
document.getElementById('history-list').innerHTML = Object.entries(state.counts)
.map(([k,v])=>`<span style="display:inline-block; width:120px; margin-bottom:5px;"><b>${k}</b>: ${v}個</span>`).join('');
} else renderCraft();
}
function closeModal() { document.querySelectorAll('.modal').forEach(m => m.style.display='none'); }
function save() { localStorage.setItem('seaglass_vShelf3', JSON.stringify(state)); }
setInterval(spawn, 1500);
updateUI();
redrawBottle();
</script>
</body>
</html>

■ 今日の学び

 シーグラスを拾い始めると、夢中になって時間を忘れてしまいます。
 持ち帰って並べているときも同じく、気づけばあっという間。
 そして、AIと相談しながらゲームを作っているときも、やっぱり時間を忘れてしまいます。

 このシーグラス拾いゲームを開いた状態で記事を打ち込んでますが、波の音が心地よく聞こえて癒されます。(⁼v⁼*)

 次回は「カオス・スイカ割」ゲームです。
 どうぞお楽しみに(*^v^)ノシ

次の実験はこちら(極悪スイカ割):
  「やじとナビがうっとおしいスイカ割」

コメントを残す