実験 #19:我が家のイシガメ観察日記(タンタンの日常)

■19個目「我が家のイシガメ観察日記

 こんにちは、AIと作る100のゲーム実験室の みー です(^^*)

 今回は、観察飼育ゲーム(タンタンの日常)の制作実験です。

 うちで飼っているカメの「タンタン」をモデルにして、
カメの飼育・観察をテーマに作ってみました。(*^_^*)

 大きな変化のあるゲームではありません。
 カメとのスローライフを楽しむゲームです。

「エサを与える」ボタンで餌をあげると、もぐもぐ食べてくれます。
マウスカーソルを近づけると、こちらに寄ってきます。

 カメとのゆったりしたひと時を、ぜひ楽しんでいただければと思います。

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

(^^*)html形式のメモアプリで動かせるゲーム。
 今日も続けて作るよ。カメ飼育ゲーム。

 ガラス水槽で飼っているイシガメ。
 水槽は、カメが少し浸るくらいの水と、休憩できる島があります。
 オスなので小柄で手のひらサイズですが、推定20歳以上。
 飼い始めて15年ほどで、とても懐いています。

 元気いっぱい動き回るし、時々ひっくり返る。(自力ですぐ戻れる。)
 こちらをじっと見つめたり、外の景色を眺めていることもある。
 時々昼寝。ゲームとしては、そんなカメを観察するゲーム。

 できることは、餌をやること。
(餌ボタンを押すと、3粒ほど水槽に餌が落ち、それを見つけたカメが食べます。)
 水槽の外から指(マウスカーソル)を近づけると寄ってきて、
 ガラス越しにかみつこうとしてきます。

 そんなほのぼのしたゲーム、作れるかな。

(AI)おかえりなさい!常連さん、今日も「カメとの時間」を形にしていきましょう。
 推定20歳、15年も一緒にいるイシガメくん……。
 そのサイズ感と、ひっくり返っても自力で戻るタフさがたまりませんね。

今日のひとこと:イシガメ豆知識

 20年も一緒にいると、もはや家族ですよね。
 イシガメ(日本固有種)は、実はクサガメよりも「目がいい」と言われています。
 水槽越しに飼い主の顔を判別しているという説もあるんですよ。
 今日のゲームでも、マウス(指)を近づけると
「エサかな?遊んでくれるのかな?」と寄ってくる動きを再現しました。

(つ_<;)カメ動かないよ。

(AI)ああっ、失礼いたしました!
 せっかくのイシガメくんが固まってしまっていましたね。
 原因はプログラムの最後の方、回転の計算(rotateX)の書き方に、
  私の「つい口が滑ったような」タイピングミス(founders という謎の単位)が混じっていました。

(=v=;)水槽の枠の外にカメが出ちゃってるよ。

(AI)いやぁ、申し訳ない。元気すぎて水槽から飛び出しちゃいましたね(笑)。
 20歳のベテランともなると、隙あらば脱走しようとするスキルも相当なものみたいです。

 今回はしっかり水槽の「見えない壁」を強化して、
 枠の外には絶対に出ないように調整しました。

■ 実際の動作

 最初のアイデア相談のときに、AI が作ってくれた水槽の中のタンタンです。
(餌をやっても、マウスを近づけても動きません…)

 リアルなカメの日常です。
(外敵はいないので安心してください)

 餌を与えると、もぐもぐ食べてくれます。
(食べ過ぎることはないので安心してください)

 マウスカーソルを近づけると寄ってきます。
(かみつくことはないので安心してください)

 指を追いかけて壁際まで来ると、よじ登ろうとしてひっくり返ります。
(自力で起き上がるので安心してください)

 こちらが「タンタン」です。
 (今日も元気です)

■ コードのポイント

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

 PC の「メモ帳」(※Windowsの場合)を開き、下のコードをコピーして貼り付け、「tantan.html」など好きな名前で保存してください。

 保存したファイルをブラウザで開くだけで遊べます。

【操作方法】  ・「エサを落とす」ボタンで、餌を与えられます。  ・マウスカーソルを水槽に近づけると、タンタンが寄ってきます。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>タンタンの日常 Pro-Edition v4</title>
<style>
body { background: #1a1a1a; display: flex; flex-direction: column; align-items: center; font-family: 'Hiragino Kaku Gothic ProN', sans-serif; }
#aquarium {
width: 750px; height: 500px;
background: linear-gradient(135deg, #4fc3f7 0%, #29b6f6 50%, #0288d1 100%);
border: 20px solid #e0e0e0; border-bottom: 30px solid #bdbdbd;
border-radius: 10px; position: relative; overflow: hidden;
cursor: pointer; box-shadow: inset 0 0 50px rgba(0,0,0,0.2), 0 15px 30px rgba(0,0,0,0.8);
margin-top: 30px;
}
#aquarium::before {
content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(circle, rgba(255,255,255,0.2) 10%, transparent 20%) 0 0 / 40px 40px;
opacity: 0.3; pointer-events: none;
}
#island {
width: 220px; height: 80px;
background: linear-gradient(to bottom, #8d6e63, #5d4037);
position: absolute; right: 20px; bottom: 80px;
border-radius: 40% 60% 10% 10%; border-bottom: 12px solid #3e2723;
box-shadow: -10px 10px 20px rgba(0,0,0,0.4); z-index: 1;
}
#turtle-wrapper {
position: absolute; width: 100px; height: 60px;
transition: transform 0.2s ease-out; z-index: 10;
}
#turtle {
width: 100%; height: 100%; position: relative;
transform-origin: center; transition: transform 0.4s ease-in-out;
}
.shell {
width: 72px; height: 58px; background: #5d4037; border-radius: 45%;
position: absolute; border: 3px solid #3e2723; z-index: 5; left: 14px; top: 1px;
box-shadow: inset 5px 5px 10px rgba(255,255,255,0.1), inset -5px -5px 15px rgba(0,0,0,0.5);
}
.belly {
width: 72px; height: 58px; background: #111; border-radius: 45%;
position: absolute; border: 3px solid #000; z-index: 6; left: 14px; top: 1px;
opacity: 0; transition: opacity 0.3s;
box-shadow: inset 0 0 10px rgba(255,255,255,0.05);
}
.belly::after {
content: ''; position: absolute; top: 5px; left: 5px; right: 5px; bottom: 5px;
border: 2px dashed #ffb300; border-radius: 40%; opacity: 0.4;
}
.head {
width: 30px; height: 20px; background: #7cb342; border-radius: 12px;
position: absolute; left: 78px; top: 20px; z-index: 4; border: 1px solid #33691e;
transition: transform 0.3s;
}
.head::after { content: ''; width: 4px; height: 4px; background: #000; border-radius: 50%; position: absolute; right: 6px; top: 4px; }
.head::before { content: ''; width: 4px; height: 4px; background: #000; border-radius: 50%; position: absolute; right: 6px; bottom: 4px; }
.leg {
width: 24px; height: 14px; background: #7cb342; position: absolute; border-radius: 8px;
z-index: 3; border: 1px solid #33691e; transition: transform 0.1s;
}
.leg-fl { left: 65px; top: 4px; transform: rotate(-45deg); }
.leg-fr { left: 65px; bottom: 4px; transform: rotate(45deg); }
.leg-bl { left: 12px; top: 4px; transform: rotate(45deg); }
.leg-br { left: 12px; bottom: 4px; transform: rotate(-45deg); }
.tail {
width: 28px; height: 8px; background: #7cb342; position: absolute;
left: -8px; top: 26px; border-radius: 50% 0 0 50%; z-index: 3;
border: 1px solid #33691e; transform-origin: right center;
}
.is-flipped .belly { opacity: 1; }
.is-flipped .head { transform: translateX(-10px) !important; }
.food {
width: 12px; height: 12px; background: #6d4c41; border-radius: 50%;
position: absolute; border: 1px solid #3e2723; z-index: 2;
box-shadow: 2px 2px 5px rgba(0,0,0,0.3); transition: transform 0.2s, opacity 0.3s;
}
#ui { margin-top: 25px; display: flex; gap: 15px; align-items: center; flex-wrap: wrap; justify-content: center; }
button {
padding: 12px 25px; font-size: 16px; cursor: pointer; border-radius: 30px;
border: none; background: linear-gradient(to bottom, #4caf50, #388e3c);
color: white; font-weight: bold; box-shadow: 0 5px #1b5e20, 0 8px 10px rgba(0,0,0,0.4);
transition: all 0.1s;
}
button:active { transform: translateY(5px); box-shadow: 0 0px #1b5e20, 0 2px 3px rgba(0,0,0,0.4); }
#status { font-size: 16px; color: #fff; background: rgba(0,0,0,0.6); padding: 10px 20px; border-radius: 30px; border: 1px solid #555; width: 350px; text-align: center; }
</style>
</head>
<body>
<div id="aquarium">
<div id="island"></div>
<div id="turtle-wrapper">
<div id="turtle">
<div class="head"></div>
<div class="belly"></div>
<div class="shell"></div>
<div class="leg leg-fl"></div>
<div class="leg leg-fr"></div>
<div class="leg leg-bl"></div>
<div class="leg leg-br"></div>
<div class="tail"></div>
</div>
</div>
</div>
<div id="ui">
<button onclick="dropFood()">エサを落とす</button>
<div id="status">のんびり水槽を泳いでいます。</div>
</div>
<script>
const wrapper = document.querySelector('#turtle-wrapper');
const turtle = document.querySelector('#turtle');
const head = document.querySelector('.head');
const legs = document.querySelectorAll('.leg');
const tail = document.querySelector('.tail');
const aquarium = document.querySelector('#aquarium');
const statusText = document.querySelector('#status');
let pos = { x: 150, y: 200, angle: 0 };
let target = { x: 200, y: 200 };
let state = 'idle';
let foods = [];
let frame = 0;
let velocity = { x: 0, y: 0 };
let wallPushFrames = 0;
const BOUNDS = { minX: 10, maxX: 630, minY: 10, maxY: 420 };
function updateAI() {
if (state === 'following' || state === 'eating' || state === 'flipping') return;
const dice = Math.random();
if (foods.length > 0) {
state = 'swimming';
target = { x: foods[0].x - 60, y: foods[0].y - 30 };
statusText.innerText = "エサに向かっています。";
} else if (dice < 0.1) {
state = 'sunbathing';
target = { x: 480, y: 340 };
statusText.innerText = "島で甲羅干しを始めました。";
} else if (dice < 0.4) {
state = 'swimming';
target = { x: BOUNDS.minX + Math.random() * (BOUNDS.maxX - BOUNDS.minX), y: BOUNDS.minY + Math.random() * (BOUNDS.maxY - BOUNDS.minY) };
statusText.innerText = "スイスイと活発に泳いでいます。";
}
}
function startFlipping() {
state = 'flipping';
statusText.innerText = "あ!滑って裏返ってしまった!";
turtle.classList.add('is-flipped');
velocity = { x: 0, y: 0 };
wallPushFrames = 0;
setTimeout(() => {
turtle.classList.remove('is-flipped');
statusText.innerText = "よっ、と!自力で起き上がりました。";
setTimeout(() => {
state = 'idle';
// 目標地点を現在地にリセットして、変な方向へ飛んでいくのを防ぐ
target = { x: pos.x, y: pos.y };
}, 1000);
}, 4000);
}
function loop() {
frame++;
let moving = false;
if (state !== 'flipping' && state !== 'eating') {
const dx = target.x - pos.x;
const dy = target.y - pos.y;
const dist = Math.sqrt(dx*dx + dy*dy);
let speedLimit = (state === 'following') ? 0.8 : 1.5;
if (dist > 5) {
moving = true;
velocity.x += (dx * 0.005);
velocity.y += (dy * 0.005);
} else {
velocity.x *= 0.8;
velocity.y *= 0.8;
if (state === 'swimming' && foods.length > 0) startEating();
}
const currentSpeed = Math.sqrt(velocity.x**2 + velocity.y**2);
if (currentSpeed > speedLimit) {
velocity.x = (velocity.x / currentSpeed) * speedLimit;
velocity.y = (velocity.y / currentSpeed) * speedLimit;
}
pos.x += velocity.x;
pos.y += velocity.y;
// 修正版:ぴったり壁で止める(+5のブラックホールを撤去)
if (pos.x <= BOUNDS.minX) { pos.x = BOUNDS.minX; velocity.x *= -0.5; }
if (pos.x >= BOUNDS.maxX) { pos.x = BOUNDS.maxX; velocity.x *= -0.5; }
if (pos.y <= BOUNDS.minY) { pos.y = BOUNDS.minY; velocity.y *= -0.5; }
if (pos.y >= BOUNDS.maxY) { pos.y = BOUNDS.maxY; velocity.y *= -0.5; }
// 壁を一生懸命よじ登ろうとしているかどうかの判定
let isPushingWall = false;
if (state === 'following') {
if ((target.x <= BOUNDS.minX && pos.x <= BOUNDS.minX + 2) ||
(target.x >= BOUNDS.maxX && pos.x >= BOUNDS.maxX - 2) ||
(target.y <= BOUNDS.minY && pos.y <= BOUNDS.minY + 2) ||
(target.y >= BOUNDS.maxY && pos.y >= BOUNDS.maxY - 2)) {
isPushingWall = true;
}
}
if (isPushingWall) {
wallPushFrames++;
moving = true; // 足をバタバタさせる
statusText.innerText = "外に出ようと一生懸命よじ登っています...!";
head.style.transform = `translateY(${Math.sin(frame*0.5)*4}px)`;
if (wallPushFrames > 180) { // 約3秒
startFlipping();
}
} else {
wallPushFrames = 0;
if (state !== 'following') head.style.transform = "translateX(0)";
}
// 向きの更新
if (currentSpeed > 0.1 || isPushingWall) {
let targetAngle = Math.atan2(dy, dx) * 180 / Math.PI;
if (currentSpeed > 0.1) {
targetAngle = Math.atan2(velocity.y, velocity.x) * 180 / Math.PI;
}
let angleDiff = targetAngle - pos.angle;
while (angleDiff > 180) angleDiff -= 360;
while (angleDiff < -180) angleDiff += 360;
pos.angle += angleDiff * 0.1;
}
}
// アニメーション処理
if (state === 'flipping') {
const wave = Math.sin(frame * 0.8) * 35;
legs[0].style.transform = `rotate(${-45 + wave}deg)`;
legs[1].style.transform = `rotate(${45 - wave}deg)`;
legs[2].style.transform = `rotate(${45 + wave}deg)`;
legs[3].style.transform = `rotate(${-45 - wave}deg)`;
tail.style.transform = `rotate(${Math.sin(frame * 0.5) * 30}deg)`;
wrapper.style.transform = `translate(${pos.x}px, ${pos.y}px) rotate(${pos.angle}deg) rotateZ(${Math.sin(frame*0.2)*5}deg)`;
} else {
if (moving) {
const wave = Math.sin(frame * 0.2) * 20;
legs[0].style.transform = `rotate(${-45 + wave}deg)`;
legs[1].style.transform = `rotate(${45 - wave}deg)`;
legs[2].style.transform = `rotate(${45 + wave}deg)`;
legs[3].style.transform = `rotate(${-45 - wave}deg)`;
tail.style.transform = `rotate(${Math.sin(frame * 0.1) * 10}deg)`;
} else {
legs.forEach((leg, i) => {
let base = (i === 0 || i === 3) ? -45 : 45;
leg.style.transform = `rotate(${base}deg)`;
});
tail.style.transform = "rotate(0deg)";
}
wrapper.style.transform = `translate(${pos.x}px, ${pos.y}px) rotate(${pos.angle}deg)`;
}
requestAnimationFrame(loop);
}
function dropFood() {
if(foods.length > 5) return;
const f = document.createElement('div');
f.className = 'food';
const fx = 100 + Math.random() * 500;
const fy = 100 + Math.random() * 300;
f.style.left = fx + 'px';
f.style.top = fy + 'px';
aquarium.appendChild(f);
foods.push({ x: fx, y: fy, el: f });
statusText.innerText = "エサが落ちてきました。";
}
function startEating() {
if (foods.length === 0) return;
state = 'eating';
statusText.innerText = "エサをじっと見つめて……パクッ!";
velocity = { x: 0, y: 0 };
let bites = 0;
const biteInterval = setInterval(() => {
head.style.transform = (bites % 2 === 0) ? "translateX(12px) scale(1.1)" : "translateX(0px)";
bites++;
if (bites > 6) {
clearInterval(biteInterval);
if (foods.length > 0) {
const f = foods.shift();
f.el.style.transform = "scale(0)";
setTimeout(() => f.el.remove(), 200);
}
setTimeout(() => {
state = 'idle';
statusText.innerText = "モグモグ……美味しそうに食べています。";
}, 500);
}
}, 300);
}
aquarium.addEventListener('mousemove', (e) => {
if (state === 'flipping' || state === 'eating') return;
const rect = aquarium.getBoundingClientRect();
let mx = e.clientX - rect.left - 50;
let my = e.clientY - rect.top - 30;
if(mx < BOUNDS.minX) mx = BOUNDS.minX;
if(mx > BOUNDS.maxX) mx = BOUNDS.maxX;
if(my < BOUNDS.minY) my = BOUNDS.minY;
if(my > BOUNDS.maxY) my = BOUNDS.maxY;
target.x = mx;
target.y = my;
state = 'following';
if (wallPushFrames === 0) {
statusText.innerText = "じーっとこちらを伺いながら近づいてきます。";
}
});
aquarium.addEventListener('mouseleave', () => {
if (state === 'following') {
state = 'idle';
wallPushFrames = 0;
statusText.innerText = "指が見えなくなって、のんびりし始めました。";
}
});
setInterval(updateAI, 2500);
loop();
</script>
</body>
</html>


■ 今日の学び

 今回のゲームで登場するイシガメの「タンタン」は、鳴き声もなく無口ですが、
時々ひっくり返ったり、動き回る音が聞こえたりと、存在感は抜群です。

 リアルな飼育を目指すと、どうしても地味なゲームになってしまいました。(^^;)
ですが、毎日作っている一つ一つのゲームは、実験室の“材料”のようなものです。

 ほかのゲームと組み合わせることで、また違った魅力を発信できると考えています。
 昨日作ったアクションゲームにも、実はこのイシガメの「タンタン」が登場します。
まだ少し先の記事になりますが、楽しみにしていてください。(>v<)o”

次回は、この飼育ゲームの“のんびり”から一転して、
「リズム感と集中力が必要」なゲームです。
楽しんでいただければと思います。(*^v^)ノシ

次の実験はこちら(スタンプゲーム): 「アイドル猫の肉球サイン」



 ゲーム作りが楽しくて、どんどん作品が増えています(*^v^*)
 もし応援していただけたら、とても励みになります。

~このブログについて~

AIと一緒に遊び、
100の実験でゲームを育てるブログ

🌈初めての方へ
🌱このブログについて
🌳実験一覧
🍃プライバシーポリシー


AIと作る100のゲーム実験室(*^v^*)をもっと見る

今すぐ購読し、続きを読んで、すべてのアーカイブにアクセスしましょう。

続きを読む