実験#35:水面をたたく・寄ってくる(ピラニア釣り)

■35個目「ピラニア釣り

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

アマゾン川の奥深く。
静かな水面の下では、今日も無数の生き物たちがひっそりと息づいています。

今回の実験では、その川を舞台にした 小さな釣りゲーム を作ってみました。

ただの釣りではなく、
「どこに何が潜んでいるのか分からない」
そんな ちょっとした冒険心 をくすぐる内容になっています。

水面を叩くと広がる波紋、 ジャングルの影、
そして時々、こちらをうかがう“巨大な何か”。
水面を叩くたびに、さまざまな生き物たちが寄ってくる仕組みになっています。

AI と相談しながら、 水音の合成、飛沫のパーティクル、背景の描画など、 細かい部分まで少しずつ育てていった作品です。

短い時間で遊べるミニゲームですが、
アマゾンの空気を、ほんの少しでも感じてもらえたら嬉しいです。

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

(^^*)今日もゲーム作ります。html形式で動かせるゲーム。

今日はアマゾン川での「ピラニア釣り」ゲームです。

ピラニア釣りは、竿の先を水面にバシャバシャと叩きつけて、 その音と衝撃にピラニアが寄ってきます。 針につけた生肉をエサに、寄ってきたピラニアがかじりつきます。

ピラニア以外にも、アマゾン川に生息する変わったナマズの仲間や、 よく分からない魚が釣れることもあります。 濁った水の中で、不安と期待が入り混じる釣りゲームです。

釣りは小舟の上から行います。 マウスでボートを移動し、エンターキーで水面を叩きます。 釣ったピラニアの数がスコアになります。 ほかの魚は食べないのでリリースします。

ダメージの仕組みはこんな感じです:

  • ナマズのとげで 1 ダメージ
  • 巨大な何かに餌をちぎられて 2 ダメージ
  • ピラニアを外すときに噛まれて 1 ダメージ

累積ダメージが 10 を超えると釣り終了。 運も大事ですが、ポイント選びが勝負の鍵になります。

(AI)「いらっしゃい。今日も来てくれたね。いつもの席へどうぞ。

アマゾン川でのピラニア釣りか。 泥水の中に何が潜んでいるか分からない、あの独特の緊張感と期待感……。 すごく面白そうなテーマだね。さっそく、ブラウザで遊べるゲームとして仕立ててみたよ。

そういえば、ピラニアの“歯”について面白い話があるんだけど、知ってるかい? あいつらの歯は、人間みたいに1本ずつ生え変わるわけじゃない。 顎の片側、つまり4分の1の歯が“ブロックごと”まとめて抜け落ちて、 下から新しい歯のセットがせり上がってくる仕組みなんだ。 だから常にカミソリみたいに鋭い状態を保っていられるってわけさ。 釣り上げる時は、くれぐれも噛まれないように気をつけなよ。」

(^_^)水面をバシャバシャはリアルに再現したいな。 エンターキーを押すたびにバシャッとできる感じ、音も合わせて出る感じで。 竿先で水面を叩く物理演算とかもできるのかな。 熱帯の気候を感じる背景とかも欲しいね。

(AI)「アマゾン川の釣り、もっと臨場感が欲しくなったか。 水面を叩く物理演算に、熱帯の空気感、それに音だな。 ブラウザの限界ギリギリを攻めるようなオーダー、嫌いじゃないぜ。

そういえば、アマゾン川の“水”についても面白い話があるんだ。 アマゾン川って一口に言うけど、場所によって水の色が全然違う。 植物が溶け込んだ真っ黒な“ネグロ川”、 アンデスの土砂を運ぶカフェオレ色の“ソリモエンス川”。 この2つが合流する場所では、何十キロも混ざらずに 黒と茶色の境界線を作りながら流れていくんだ。 大自然のスケールのデカさを感じるよな。

今回の釣り場は、そんな泥水とジャングルの息吹を感じる場所にしておいたよ。

コードでは、Web Audio API を使って外部音源なしで “バシャッ!”という水音を合成している。 さらに、水面には100個の頂点を持たせたバネ物理を組み込み、 叩くたびに波紋が広がるようにしたぞ。」

(*^^)飛沫が上がると水面を叩いてる感があるね。 音はちょっと鈍いので、もう少し高めの音がいいかな。 ピラニアの揚げた白身、おいしいよ。

(AI)「ピラニアのフライ、食ったことあるのか! 見た目の凶悪さに反して淡白な白身で美味いんだよな。 小骨が多いのが玉に瑕だけど。

そうそう、ピラニアの“骨”といえば……。 現地のインディオたちは、乾燥させた下顎の骨を “天然のハサミ”や“カッター”として使っているんだ。 吹き矢の先端を削ったり、髪の毛を切ったりな。 死んで骨になっても現役の道具として役立つ。無駄がないよな。

さて、ゲームのオーダーだ。 水面を叩く音は高い周波数を強調して“パシャッ!”と鋭く調整した。 叩いた瞬間に飛沫が散るよう、パーティクル物理も追加しておいたぞ。」

(*^^)背景や世界観は熱いけど、少し明るい感じも欲しいな。 釣った魚の姿も見られるといいかも。 釣り上げると吹き出しが出て魚が見える感じ。 巨大な何かはシルエットで大きく表示したい。

(AI)「アマゾンの熱気はそのままに、 少し晴れやかな昼下がりの釣り場に作り変えておいたぜ。

さらに“釣った魚の姿”が見られる機能も実装した。 ピラニアやナマズを釣ると、釣り人の頭上に吹き出しが出て その魚がポップに表示される。 “巨大な何か”が掛かった時は、ひときわ大きな吹き出しに 不気味なシルエットが映し出されるようにした。

泥水の中に何が潜んでいるか、視覚的にもドキドキできるはずだ。 さあ、新しい道具で一勝負していってくれ!」

■ 実際の動作

まずは、AIと相談しながら作ってもらった
ゲームの初期状態です。
(画像:AIが最初に作った基本のベース画面)

今回のゲームでは、 「水面をたたく表現」に力を入れています。
叩くたびに波紋が広がり、飛沫が上がります。

(画像:水面たたきの画像)

次は、十字バーのミニゲームです。
中心の円に合わせて止めると成功です。

(画像:十字バーの画像)

ピラニアを釣り上げた時、中心をとらえるとケガを回避できます。

(画像:ピラニア釣りあげ成功)

中心をとらえることができれば、 ナマズや大物が掛かってもダメージを受けません。

(画像:ナマズ釣り上げ成功)

こちらは はずれポイント。 何も釣れない場所もあります。

(画像:はずれポイント)

そして、今回の目玉のひとつ。
“巨大な何か”は数種類存在します。
シルエットだけでも迫力があります。

(画像:巨大な何か)


■ コードのポイント

今回の記事では、AI と相談しながら作った
「ピラニア釣り」の HTML コードを公開します。

ブラウザだけで動く、とてもシンプルなゲームです。
コードといっても難しい部分はありません。
貼り付けて保存するだけで動くので、気軽に試してみてください。

【遊ぶまでの手順】

1. PC の「メモ帳」(※Windows の場合)を開きます。
2. 下のコードをコピーして貼り付けます。
3. 「pirania.html」など、好きな名前で保存します。
4. 保存したファイルをブラウザで開くだけで遊べます。

■ 基本操作

 移動: マウスを左右に動かして小舟を移動させ、狙ったポイント(漁場)を探りましょう。

釣りを始める: 好きな場所で Enterキーを押して、竿先で水面を激しく叩きます。

アクション: アタリが来た後の「十字メーター」も、 Enterキーでタイミングよく止めます。

● ゲームの流れ
  1. 水面を叩いて音を立てる(アピール)
    • Enterキーを連続で叩くと、バシャバシャと激しい飛沫と音が上がります。
    • この音と衝撃で、濁った水中の奥深くから「魚影」が寄ってきます。たくさん叩いて、しっかりアピールしましょう!
  2. じっとアタリを待つ(静寂)
    • 叩くのをやめて数秒待つと、アタリ待ちの状態になります。
    • 竿先が小刻みに「ツンツン」と揺れる予兆を見逃さないでください。※この時に叩いてしまうと、魚が逃げてしまいます!
  3. 十字メーターの死闘(フッキング)
    • 魚が掛かると、画面中央に「十字メーター」が出現します。
    • 上下に動くバー、次に左右に動くバーを、それぞれ Enterキーで止めます。
    • 狙うのは**「中心の黄色い円」**! ここにピタリと合わせられるかが釣果の分かれ目です。
● 釣果とダメージの駆け引き
  • ピラニア: メーターの中心円で正確に止めれば釣り上げ成功(+1pt)。外れると指を噛まれてダメージを受けます。
  • ナマズ: 食べられないのでリリースしますが、メーターを外すと暴れられて毒の棘が刺さります(1ダメージ)。
  • 巨大な何か(怪魚): アマゾンの主は釣り上げることができません。しかし、メーターの中心で完璧にいなすことができれば、強烈な引きによるダメージを回避し、その神業の証として**「怪魚経験 +5pt」**を獲得できます!

⚠️ 注意 累積ダメージが「10」に達すると、ボートが限界を迎えゲームオーバー(釣り終了)です。危険なポイントは避け、ピラニアの群れを探し当てましょう。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Amazon Piranha Fishing - 完璧な神業</title>
<style>
body {
background-color: #0d1a0d;
color: #eee;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
text-align: center;
margin: 0;
padding: 20px;
overflow: hidden;
}
canvas {
background-color: #1a2414;
box-shadow: 0 0 25px rgba(0, 0, 0, 0.9);
cursor: none;
border: 4px solid #333;
border-radius: 10px;
transition: transform 0.1s;
}
#instructions {
margin-top: 15px;
font-size: 14px;
color: #88a;
line-height: 1.6;
}
kbd {
background-color: #333;
padding: 3px 6px;
border-radius: 4px;
border: 1px solid #666;
font-family: monospace;
}
</style>
</head>
<body>
<h2>Amazon Piranha Fishing</h2>
<canvas id="gameCanvas" width="800" height="500"></canvas>
<div id="instructions">
マウスで小舟を移動し、<kbd>Enter</kbd>キーで水面を叩きます。<br>
アタリが来たら、十字メーターの<b>黄色い中心円の中</b>を狙って2回止めてください。<br>
外れると魚に逃げられます。巨大な何かを中心でいなせば、無傷で5ポイント獲得です。
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
let audioCtx = null;
let state = 'TITLE';
let boatX = canvas.width / 2;
let score = 0;
let monsterScore = 0;
let damage = 0;
const MAX_DAMAGE = 10;
let message = "Enterキーでゲームスタート";
let subMessage = "※音が出ます";
let waitTimer = 0;
let agitation = 0;
let lastCatchType = 'nothing';
let lastMonsterType = 'none';
let caughtFish = 'none'; // 見事中心で捉えた魚の種類
// 十字メーター用変数
let meterVPos = 0;
let meterVDir = 1;
let meterHPos = 0;
let meterHDir = 1;
let meterVHit = 0;
let meterHHit = 0;
const METER_SPEED = 0.065; // メーター速度(少し速くして難易度アップ)
// 釣り竿の物理モデル
let rodAngle = 0;
let rodVelocity = 0;
const ROD_TENSION = 0.15;
const ROD_DAMPING = 0.85;
// 水面の物理モデル
const NUM_SURFACE_POINTS = 100;
let surfaceHeights = Array(NUM_SURFACE_POINTS).fill(0);
let surfaceVelocities = Array(NUM_SURFACE_POINTS).fill(0);
const WATER_TENSION = 0.05;
const WATER_DAMPING = 0.95;
const WATER_SPREAD = 0.15;
let particles = [];
// 水中の魚影
const NUM_WATER_SHADOWS = 18;
let waterShadows = [];
for(let i=0; i<NUM_WATER_SHADOWS; i++){
let x = Math.random() * canvas.width;
let y = 250 + Math.random() * 200;
waterShadows.push({
originX: x, originY: y,
x: x, y: y,
vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.2,
targetX: null, targetY: null, life: 0
});
}
const ZONE_COUNT = 10;
let zones = [];
// --- 魚の描画関数 ---
function drawPiranha(ctx, x, y, scale, isSilhouette = false) {
ctx.save(); ctx.translate(x, y); ctx.scale(scale, scale); ctx.beginPath(); ctx.moveTo(-15, 0); ctx.bezierCurveTo(-15, -10, 5, -12, 15, -5); ctx.bezierCurveTo(20, 0, 15, 8, 10, 10); ctx.bezierCurveTo(5, 12, -10, 10, -15, 5); ctx.closePath(); if (isSilhouette) { ctx.fillStyle = '#111'; } else { let fishGrad = ctx.createLinearGradient(0, -10, 0, 10); fishGrad.addColorStop(0, '#aaa'); fishGrad.addColorStop(0.6, '#cc4444'); ctx.fillStyle = fishGrad; } ctx.fill(); ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.moveTo(-15, 0); ctx.lineTo(-25, -8); ctx.lineTo(-22, 0); ctx.lineTo(-25, 8); ctx.closePath(); ctx.fillStyle = isSilhouette ? '#111' : '#cc4444'; ctx.fill(); ctx.stroke(); if (!isSilhouette) { ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(8, -3, 3, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(9, -3, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#eee'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(8, 3); ctx.lineTo(11, 6); ctx.lineTo(13, 3); ctx.lineTo(15, 6); ctx.stroke(); } ctx.restore();
}
function drawCatfish(ctx, x, y, scale, isSilhouette = false) {
ctx.save(); ctx.translate(x, y); ctx.scale(scale, scale); ctx.beginPath(); ctx.moveTo(-20, 0); ctx.bezierCurveTo(-15, -8, 5, -10, 20, -5); ctx.bezierCurveTo(25, 0, 25, 8, 20, 10); ctx.bezierCurveTo(15, 12, -10, 10, -20, 5); ctx.closePath(); ctx.fillStyle = isSilhouette ? '#111' : '#665544'; ctx.fill(); ctx.strokeStyle = '#333'; ctx.lineWidth = 1; ctx.stroke(); ctx.beginPath(); ctx.moveTo(-20, 0); ctx.lineTo(-30, -5); ctx.lineTo(-30, 5); ctx.closePath(); ctx.fill(); ctx.stroke(); if (!isSilhouette) { ctx.fillStyle = 'rgba(0,0,0,0.2)'; for(let i=0; i<5; i++){ ctx.beginPath(); ctx.arc(-10 + i*6, i%2?3:-3, 2, 0, Math.PI*2); ctx.fill(); } ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(15, -3, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(16, -3, 1, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#887766'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(18, 0); ctx.quadraticCurveTo(25, -10, 30, -5); ctx.moveTo(18, 2); ctx.quadraticCurveTo(25, 10, 30, 5); ctx.stroke(); } ctx.restore();
}
function drawGiantCatfishSilhouette(ctx, x, y, scale) {
ctx.save(); ctx.translate(x, y); ctx.scale(scale, scale); ctx.fillStyle = '#050505'; ctx.beginPath(); ctx.moveTo(-50, 0); ctx.bezierCurveTo(-45, -20, 10, -25, 40, -10); ctx.bezierCurveTo(55, -5, 55, 10, 40, 15); ctx.bezierCurveTo(30, 20, -10, 20, -30, 25); ctx.bezierCurveTo(-45, 30, -55, 15, -50, 0); ctx.moveTo(5, -22); ctx.lineTo(15, -45); ctx.lineTo(25, -20); ctx.moveTo(-50, 0); ctx.lineTo(-70, -15); ctx.lineTo(-65, 0); ctx.lineTo(-70, 15); ctx.closePath(); ctx.fill(); ctx.restore();
}
function drawPirarucuSilhouette(ctx, x, y, scale) {
ctx.save(); ctx.translate(x, y); ctx.scale(scale, scale); ctx.fillStyle = '#080808'; ctx.beginPath(); ctx.moveTo(-60, 0); ctx.bezierCurveTo(-55, -15, 10, -18, 50, -10); ctx.bezierCurveTo(65, -5, 65, 10, 50, 15); ctx.bezierCurveTo(30, 18, -20, 15, -50, 18); ctx.bezierCurveTo(-58, 20, -62, 10, -60, 0); ctx.moveTo(30, -13); ctx.lineTo(40, -30); ctx.lineTo(48, -12); ctx.moveTo(30, 13); ctx.lineTo(40, 30); ctx.lineTo(48, 12); ctx.moveTo(-50, 0); ctx.bezierCurveTo(-65, -15, -75, -5, -75, 0); ctx.bezierCurveTo(-75, 5, -65, 15, -50, 0); ctx.closePath(); ctx.fill(); ctx.restore();
}
function drawPinkDolphinSilhouette(ctx, x, y, scale) {
ctx.save(); ctx.translate(x, y); ctx.scale(scale, scale); ctx.fillStyle = 'rgba(255, 180, 200, 0.4)'; ctx.beginPath(); ctx.moveTo(30, 0); ctx.bezierCurveTo(35, -5, 45, -5, 50, -2); ctx.lineTo(60, -1); ctx.lineTo(50, 2); ctx.bezierCurveTo(45, 5, 35, 5, 30, 2); ctx.moveTo(30, 0); ctx.bezierCurveTo(20, -10, 0, -12, -20, -8); ctx.lineTo(-15, -25); ctx.lineTo(-25, -5); ctx.bezierCurveTo(-40, 0, -45, 10, -40, 15); ctx.bezierCurveTo(-30, 20, 0, 15, 20, 8); ctx.bezierCurveTo(30, 5, 30, 0, 30, 0); ctx.moveTo(-40, 15); ctx.lineTo(-55, 25); ctx.lineTo(-50, 15); ctx.lineTo(-55, 5); ctx.closePath(); ctx.fill(); ctx.restore();
}
function initZones() {
zones = [];
for(let i = 0; i < ZONE_COUNT; i++) {
let r = Math.random();
if(r < 0.4) zones.push('piranha');
else if(r < 0.7) zones.push('catfish');
else if(r < 0.85) zones.push('monster');
else zones.push('empty');
}
}
function initAudio() {
if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); }
if (audioCtx.state === 'suspended') { audioCtx.resume(); }
}
function playSplashSound() {
if(!audioCtx) return;
const bufferSize = audioCtx.sampleRate * 0.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; const filter = audioCtx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 1800 + Math.random() * 600; filter.Q.value = 0.8; const gain = audioCtx.createGain(); gain.gain.setValueAtTime(2.5, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15); noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination); noise.start();
}
function playHookedSound() {
if(!audioCtx) return;
const osc = audioCtx.createOscillator(); osc.type = 'sine'; osc.frequency.value = 1500; const gain = audioCtx.createGain(); gain.gain.setValueAtTime(0.3, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.1); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.1);
}
function playMonsterSound() {
if(!audioCtx) return;
const bufferSize = audioCtx.sampleRate * 0.5; 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; const filter = audioCtx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = 200 + Math.random() * 100; const gain = audioCtx.createGain(); gain.gain.setValueAtTime(3.5, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.4); noise.connect(filter); filter.connect(gain); gain.connect(audioCtx.destination); noise.start();
canvas.style.transform = `translate(${Math.random()*8-4}px, ${Math.random()*8-4}px)`; setTimeout(()=> canvas.style.transform = 'translate(0,0)', 150);
}
function playDolphinSound() {
if(!audioCtx) return;
const osc = audioCtx.createOscillator(); osc.type = 'sine'; osc.frequency.value = 1200 + Math.random() * 300; const gain = audioCtx.createGain(); gain.gain.setValueAtTime(0.5, audioCtx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.8); osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime + 0.8);
}
canvas.addEventListener('mousemove', (e) => {
if(state === 'IDLE' || state === 'SPLASHING' || state === 'WAITING') {
const rect = canvas.getBoundingClientRect();
boatX = e.clientX - rect.left;
boatX = Math.max(40, Math.min(boatX, canvas.width - 60));
}
});
window.addEventListener('keydown', (e) => {
if(e.key === 'Enter') {
initAudio();
if(state === 'TITLE') {
score = 0; monsterScore = 0; damage = 0; initZones(); particles = []; state = 'IDLE'; lastCatchType = 'nothing'; lastMonsterType = 'none'; caughtFish = 'none';
message = "マウスで移動し、Enterキーで水面を叩け"; subMessage = "";
} else if(state === 'IDLE' || state === 'SPLASHING') {
state = 'SPLASHING'; waitTimer = 120; agitation += 1;
message = "パシャッ! パシャッ! (もっと飛沫を上げろ)";
rodVelocity += 1.8;
gatherWaterFishShadows(boatX + 50, 200);
} else if(state === 'WAITING') {
message = "じっと待て! (叩くと予兆が逃げる)";
gatherWaterFishShadows(boatX + 50, 200);
} else if(state === 'METER_V') {
meterVHit = meterVPos;
meterHPos = 0; meterHDir = 1;
state = 'METER_H';
message = "次は左右! 黄色い円を狙ってEnter!";
} else if(state === 'METER_H') {
meterHHit = meterHPos;
processCatch(meterVHit, meterHHit);
} else if(state === 'RESULT') {
if(damage >= MAX_DAMAGE) {
state = 'GAMEOVER'; message = "ボートが限界だ。陸へ戻ろう。"; subMessage = "Enterキーでタイトルへ";
} else {
state = 'IDLE'; agitation = 0; lastCatchType = 'nothing'; lastMonsterType = 'none'; caughtFish = 'none';
message = "次のポイントで釣りを再開する"; subMessage = "";
}
} else if(state === 'GAMEOVER') {
state = 'TITLE'; message = "Enterキーでゲームスタート"; subMessage = "";
}
}
});
function gatherWaterFishShadows(targetX, targetY) {
waterShadows.forEach(s => {
if(Math.abs(s.x - targetX) < 300 && Math.abs(s.y - targetY) < 300) {
s.targetX = targetX + (Math.random() - 0.5) * 50; s.targetY = targetY + (Math.random() - 0.5) * 50; s.life = 100;
}
});
}
function disperseWaterFishShadows() {
waterShadows.forEach(s => { s.targetX = s.originX; s.targetY = s.originY; s.life = 120; });
}
function drawCrossMeter() {
let meterCenterX = canvas.width / 2; let meterCenterY = canvas.height / 2; let meterSize = 150;
ctx.strokeStyle = '#fff'; ctx.lineWidth = 3; ctx.beginPath();
ctx.moveTo(meterCenterX, meterCenterY - meterSize); ctx.lineTo(meterCenterX, meterCenterY + meterSize);
ctx.moveTo(meterCenterX - meterSize, meterCenterY); ctx.lineTo(meterCenterX + meterSize, meterCenterY);
ctx.stroke();
// ターゲットの中心円 (半径30pxは、meterSize150pxに対して0.2の割合)
ctx.fillStyle = 'rgba(255, 255, 0, 0.3)'; ctx.beginPath();
ctx.arc(meterCenterX, meterCenterY, 30, 0, Math.PI*2); ctx.fill();
ctx.strokeStyle = '#ff0'; ctx.lineWidth = 2; ctx.stroke();
let barSize = 20;
if (state === 'METER_V') {
meterVPos += meterVDir * METER_SPEED;
if (meterVPos > 1.0) { meterVPos = 1.0; meterVDir = -1; }
if (meterVPos < -1.0) { meterVPos = -1.0; meterVDir = 1; }
let barY = meterCenterY + meterVPos * meterSize;
ctx.fillStyle = '#ff4444'; ctx.fillRect(meterCenterX - barSize/2, barY - 3, barSize, 6);
} else if (state === 'METER_H') {
let barY = meterCenterY + meterVHit * meterSize;
ctx.fillStyle = 'rgba(255, 68, 68, 0.3)'; ctx.fillRect(meterCenterX - barSize/2, barY - 3, barSize, 6);
meterHPos += meterHDir * METER_SPEED;
if (meterHPos > 1.0) { meterHPos = 1.0; meterHDir = -1; }
if (meterHPos < -1.0) { meterHPos = -1.0; meterHDir = 1; }
let barX = meterCenterX + meterHPos * meterSize;
ctx.fillStyle = '#ff4444'; ctx.fillRect(barX - 3, meterCenterY - barSize/2, 6, barSize);
}
}
// --- 釣果判定 (中心円の判定を追加) ---
function processCatch(vHit, hHit) {
let zoneIndex = Math.floor((boatX / canvas.width) * ZONE_COUNT);
let zoneType = zones[zoneIndex];
let roll = Math.random() - Math.min(agitation * 0.02, 0.25);
lastCatchType = 'nothing'; lastMonsterType = 'none'; caughtFish = 'none';
// 止めた位置が中心の黄色い円(0.2)の中に入っているか
let meterDistance = Math.sqrt(vHit * vHit + hHit * hHit);
let isCenterHit = meterDistance <= 0.2;
if(zoneType === 'piranha') {
if(roll < 0.7) lastCatchType = 'piranha'; else if(roll < 0.8) lastCatchType = 'catfish'; else lastCatchType = 'nothing';
} else if(zoneType === 'catfish') {
if(roll < 0.1) lastCatchType = 'piranha'; else if(roll < 0.7) lastCatchType = 'catfish'; else if(roll < 0.8) lastCatchType = 'monster'; else lastCatchType = 'nothing';
} else if(zoneType === 'monster') {
if(roll < 0.1) lastCatchType = 'piranha'; else if(roll < 0.3) lastCatchType = 'catfish'; else if(roll < 0.8) lastCatchType = 'monster'; else lastCatchType = 'nothing';
} else {
if(roll < 0.2) lastCatchType = 'piranha'; else lastCatchType = 'nothing';
}
switch(lastCatchType) {
case 'piranha':
if(isCenterHit) {
caughtFish = 'piranha';
score += 1;
message = "見事ピラニアを釣り上げた! (+1 pt)";
subMessage = "中心円を正確に捉えたな。プロの腕前だ。";
} else {
damage += 1;
message = "ピラニアに逃げられた! (1ダメージ)";
subMessage = "中心から外れた! 暴れられて指を噛まれた。";
}
break;
case 'catfish':
if(isCenterHit) {
caughtFish = 'catfish';
message = "ナマズだ。安全にリリースした。";
subMessage = "中心を捉え、見事に棘を避けたな。";
} else {
damage += 1;
message = "ナマズに逃げられた! (1ダメージ)";
subMessage = "中心から外れ、暴れた拍子に棘が刺さった!";
}
break;
case 'monster':
let monsterRoll = Math.random();
if (monsterRoll < 0.4) lastMonsterType = 'giantcatfish';
else if (monsterRoll < 0.8) lastMonsterType = 'pirarucu';
else lastMonsterType = 'pinkdolphin';
if(isCenterHit) {
caughtFish = 'monster'; // 吹き出しに描画させる
monsterScore += 5; // ダメージなしで5ポイント!
if(lastMonsterType === 'giantcatfish') { playMonsterSound(); message = "川底の主、大ナマズをいなした! (+5pt)"; subMessage = "完璧なタイミングだ! 凄まじい力を無傷でやり過ごした。"; }
else if(lastMonsterType === 'pirarucu') { playMonsterSound(); message = "古代魚ピラルクを回避! (+5pt)"; subMessage = "完璧だ! 硬い鎧の突進をいなし、無傷で切り抜けた。"; }
else { playMonsterSound(); playDolphinSound(); message = "ピンクイルカと心を通わせた! (+5pt)"; subMessage = "完璧な間合いだ。怪我もなく神聖な体験をしたな。"; }
} else {
damage += 2; // 中心から外れると大ダメージ
if(lastMonsterType === 'giantcatfish') { playMonsterSound(); message = "大ナマズに力負けした!! (2ダメージ)"; subMessage = "中心から外れた! 竿ごと持っていかれそうになった。"; }
else if(lastMonsterType === 'pirarucu') { playMonsterSound(); message = "ピラルクの硬い鎧に弾かれた! (2ダメージ)"; subMessage = "タイミングが甘い! 糸が軋み、強烈な衝撃を受けた。"; }
else { playMonsterSound(); playDolphinSound(); message = "ピンクイルカに翻弄された! (2ダメージ)"; subMessage = "圧倒的な存在感に呆然とし、怪我を負った。"; }
}
break;
case 'nothing':
message = "静まり返ってしまった…"; subMessage = "ここはハズレのポイントだったようだな。";
disperseWaterFishShadows();
break;
}
rodVelocity += (lastCatchType === 'monster') ? 5.0 : (lastCatchType === 'nothing' ? -0.5 : 2.0);
state = 'RESULT';
}
function drawBackground() {
let bgGrad = ctx.createLinearGradient(0, 0, 0, 200); bgGrad.addColorStop(0, '#ffdb99'); bgGrad.addColorStop(1, '#ffedcc'); ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, canvas.width, 200); ctx.fillStyle = '#1a261a'; ctx.beginPath(); ctx.moveTo(0, 200); for(let i = 0; i <= canvas.width; i+=20) { ctx.lineTo(i, 150 - Math.sin(i * 0.05) * 20 - Math.cos(i * 0.02) * 25); } ctx.lineTo(canvas.width, 200); ctx.fill(); ctx.strokeStyle = '#332211'; ctx.lineWidth = 3; for(let i = 0; i < 6; i++) { ctx.beginPath(); let startX = 50 + i * 140; ctx.moveTo(startX, 0); ctx.quadraticCurveTo(startX - 20, 80, startX + 15, 170 + Math.sin(Date.now()*0.001 + i)*15); ctx.stroke(); }
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawBackground();
rodVelocity += (0 - rodAngle) * ROD_TENSION; rodVelocity *= ROD_DAMPING; rodAngle += rodVelocity;
let rodTipY = 160 + Math.sin(rodAngle) * 60; let rodTipX = boatX + 50 + Math.cos(rodAngle) * 60;
ctx.save();
waterShadows.forEach(s => {
if(s.targetX !== null) { s.vx += (s.targetX - s.x) * (state === 'SPLASHING' ? 0.001 : 0.0005); s.vy += (s.targetY - s.y) * 0.001; s.vx *= 0.95; s.vy *= 0.95; if(s.targetX !== s.originX) { s.life--; if(s.life <= 0) { s.targetX = null; s.targetY = null; } } else { if(Math.abs(s.x - s.originX) < 5 && Math.abs(s.y - s.originY) < 5) { s.targetX = null; s.targetY = null; s.life = 0; } } } else { s.vx += (Math.random() - 0.5) * 0.02; s.vy += (Math.random() - 0.5) * 0.01; s.vx = Math.max(-0.3, Math.min(0.3, s.vx)); s.vy = Math.max(-0.1, Math.min(0.1, s.vy)); }
s.x += s.vx; s.y += s.vy; if(s.x < 0) s.x = canvas.width; if(s.x > canvas.width) s.x = 0; if(s.y < 230) s.y = 230; if(s.y > canvas.height - 30) s.y = canvas.height - 30;
let alpha = (s.targetX !== null && s.targetX !== s.originX) ? 0.3 + (s.life / 100) * 0.3 : 0.3; ctx.fillStyle = `rgba(10, 20, 5, ${alpha})`; ctx.beginPath(); ctx.ellipse(s.x, s.y, 20, 6, 0, 0, Math.PI*2); ctx.fill();
});
ctx.restore();
if (rodTipY > 200 && state === 'SPLASHING') {
if (rodVelocity > 0.2) {
playSplashSound(); let index = Math.floor((rodTipX / canvas.width) * NUM_SURFACE_POINTS); if(index >= 0 && index < NUM_SURFACE_POINTS) surfaceVelocities[index] += rodVelocity * 18; gatherWaterFishShadows(rodTipX, rodTipY); let particleCount = 10 + Math.random() * 10; for(let p = 0; p < particleCount; p++) { particles.push({ x: rodTipX + (Math.random() * 10 - 5), y: 200, vx: (Math.random() - 0.5) * 8, vy: -2 - Math.random() * 5, life: 1.0 }); } rodVelocity *= 0.4;
}
}
for(let i = 0; i < NUM_SURFACE_POINTS; i++) { surfaceVelocities[i] += (0 - surfaceHeights[i]) * WATER_TENSION; surfaceVelocities[i] *= WATER_DAMPING; surfaceHeights[i] += surfaceVelocities[i]; } let leftDeltas = Array(NUM_SURFACE_POINTS).fill(0); let rightDeltas = Array(NUM_SURFACE_POINTS).fill(0); for(let i = 0; i < NUM_SURFACE_POINTS; i++) { if(i > 0) { leftDeltas[i] = WATER_SPREAD * (surfaceHeights[i] - surfaceHeights[i-1]); surfaceVelocities[i-1] += leftDeltas[i]; } if(i < NUM_SURFACE_POINTS - 1) { rightDeltas[i] = WATER_SPREAD * (surfaceHeights[i] - surfaceHeights[i+1]); surfaceVelocities[i+1] += rightDeltas[i]; } } for(let i = 0; i < NUM_SURFACE_POINTS; i++) { if(i > 0) surfaceHeights[i-1] += leftDeltas[i]; if(i < NUM_SURFACE_POINTS - 1) surfaceHeights[i+1] += rightDeltas[i]; }
let waterGrad = ctx.createLinearGradient(0, 200, 0, canvas.height); waterGrad.addColorStop(0, '#73914a'); waterGrad.addColorStop(1, '#2e3d1a'); ctx.fillStyle = waterGrad; ctx.beginPath(); ctx.moveTo(0, canvas.height); ctx.lineTo(0, 200 + surfaceHeights[0]); for(let i = 1; i < NUM_SURFACE_POINTS; i++) { let x = (i / (NUM_SURFACE_POINTS - 1)) * canvas.width; let y = 200 + surfaceHeights[i]; ctx.lineTo(x, y); } ctx.lineTo(canvas.width, canvas.height); ctx.fill(); ctx.strokeStyle = '#b8cc99'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(0, 200 + surfaceHeights[0]); for(let i = 1; i < NUM_SURFACE_POINTS; i++) { let x = (i / (NUM_SURFACE_POINTS - 1)) * canvas.width; let y = 200 + surfaceHeights[i]; ctx.lineTo(x, y); } ctx.stroke();
ctx.fillStyle = 'rgba(200, 230, 255, 0.8)'; for(let i = particles.length - 1; i >= 0; i--) { let p = particles[i]; p.x += p.vx; p.y += p.vy; p.vy += 0.4; p.life -= 0.03; if(p.life <= 0 || p.y > canvas.height) particles.splice(i, 1); else { ctx.beginPath(); ctx.arc(p.x, p.y, 2.5 * p.life, 0, Math.PI*2); ctx.fill(); } }
if(state !== 'TITLE' && state !== 'GAMEOVER') {
let boatCenterIndex = Math.floor((boatX / canvas.width) * NUM_SURFACE_POINTS); let boatYOffset = surfaceHeights[boatCenterIndex] ? surfaceHeights[boatCenterIndex] * 0.5 : 0; let baseBoatY = 190 + boatYOffset;
ctx.fillStyle = '#664433'; ctx.beginPath(); ctx.moveTo(boatX - 35, baseBoatY - 10); ctx.lineTo(boatX + 35, baseBoatY - 10); ctx.lineTo(boatX + 25, baseBoatY + 10); ctx.lineTo(boatX - 25, baseBoatY + 10); ctx.fill();
ctx.fillStyle = 'rgba(10, 20, 10, 0.7)'; ctx.beginPath(); ctx.arc(boatX - 5, baseBoatY - 25, 9, 0, Math.PI * 2); ctx.fill(); ctx.fillRect(boatX - 12, baseBoatY - 18, 14, 18);
ctx.strokeStyle = '#a67b5b'; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(boatX + 2, baseBoatY - 15); ctx.lineTo(rodTipX, rodTipY); ctx.stroke();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(rodTipX, rodTipY);
if(state === 'WAITING') {
ctx.lineTo(rodTipX, canvas.height - 100);
} else if (state === 'HOOKED' || state === 'METER_V' || state === 'METER_H') {
ctx.save(); ctx.strokeStyle = 'rgba(255, 10, 10, 0.5)'; ctx.lineWidth = 2; ctx.lineTo(rodTipX, rodTipY + 40); if (Math.random() < 0.3) rodVelocity += (agitation > 5 ? 0.3 : 0.15); ctx.stroke(); ctx.restore();
} else if(state === 'RESULT') {
ctx.lineTo(rodTipX, rodTipY + 40);
ctx.save(); ctx.translate(rodTipX, rodTipY + 40); ctx.rotate(Math.PI / 4 + Math.sin(Date.now() * 0.005) * 0.1);
// caughtFishがnone以外なら、中心で成功した証
if (caughtFish === 'piranha') drawPiranha(ctx, 0, 0, 0.5);
else if (caughtFish === 'catfish') drawCatfish(ctx, 0, 0, 0.5);
else if (caughtFish === 'monster') { ctx.fillStyle = '#ff1111'; ctx.beginPath(); ctx.arc(0, 0, 6, 0, Math.PI*2); ctx.fill(); }
else { ctx.fillStyle = '#ff4444'; ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI*2); ctx.fill(); } // 逃げられたかエサのみ
ctx.restore();
// 成功時のみ吹き出しを描画
if (caughtFish !== 'none') {
let bubbleX = boatX + 40; let bubbleY = baseBoatY - 50; let bubbleWidth = (caughtFish === 'monster') ? 180 : 120; let bubbleHeight = (caughtFish === 'monster') ? 120 : 80; ctx.fillStyle = (caughtFish === 'monster') ? 'rgba(255, 255, 255, 0.8)' : 'rgba(255, 255, 255, 0.9)'; ctx.strokeStyle = '#333'; ctx.lineWidth = 2; ctx.beginPath(); let r = 10; ctx.moveTo(bubbleX + r, bubbleY); ctx.lineTo(bubbleX + bubbleWidth - r, bubbleY); ctx.quadraticCurveTo(bubbleX + bubbleWidth, bubbleY, bubbleX + bubbleWidth, bubbleY + r); ctx.lineTo(bubbleX + bubbleWidth, bubbleY + bubbleHeight - r); ctx.quadraticCurveTo(bubbleX + bubbleWidth, bubbleY + bubbleHeight, bubbleX + bubbleWidth - r, bubbleY + bubbleHeight); ctx.lineTo(bubbleX + 20, bubbleY + bubbleHeight); ctx.lineTo(bubbleX, bubbleY + bubbleHeight + 15); ctx.lineTo(bubbleX + 10, bubbleY + bubbleHeight); ctx.lineTo(bubbleX + r, bubbleY + bubbleHeight); ctx.quadraticCurveTo(bubbleX, bubbleY + bubbleHeight, bubbleX, bubbleY + bubbleHeight - r); ctx.lineTo(bubbleX, bubbleY + r); ctx.quadraticCurveTo(bubbleX, bubbleY, bubbleX + r, bubbleY); ctx.closePath(); ctx.fill(); ctx.stroke();
let fishCenterX = bubbleX + bubbleWidth / 2; let fishCenterY = bubbleY + bubbleHeight / 2;
if (caughtFish === 'piranha') drawPiranha(ctx, fishCenterX, fishCenterY, 2.0);
else if (caughtFish === 'catfish') drawCatfish(ctx, fishCenterX, fishCenterY, 1.8);
else if (caughtFish === 'monster') {
if (lastMonsterType === 'giantcatfish') drawGiantCatfishSilhouette(ctx, fishCenterX, fishCenterY, 1.4); else if (lastMonsterType === 'pirarucu') drawPirarucuSilhouette(ctx, fishCenterX, fishCenterY, 1.3); else drawPinkDolphinSilhouette(ctx, fishCenterX, fishCenterY, 1.2);
}
}
}
ctx.stroke();
if (state === 'SPLASHING') {
waitTimer--; if(waitTimer <= 0) { state = 'WAITING'; waitTimer = Math.floor(Math.random() * 60) + 60; message = "……じっとアタリを待っている……"; disperseWaterFishShadows(); }
} else if (state === 'WAITING') {
waitTimer--; if (waitTimer < 40 && Math.random() < 0.2) rodVelocity += 0.15;
if(waitTimer <= 0) { state = 'HOOKED'; waitTimer = 30; message = "予兆だ! エサがツンツンされている…。"; playHookedSound(); }
} else if (state === 'HOOKED') {
waitTimer--;
if(waitTimer <= 0) { meterVPos = 0; meterVDir = 1; state = 'METER_V'; message = "魚が掛かった! 十字の黄色い中心を狙え! Enter!"; }
} else if (state === 'METER_V' || state === 'METER_H') {
drawCrossMeter();
}
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, canvas.height - 110, canvas.width, 110); ctx.fillStyle = '#fff'; ctx.textAlign = 'center';
if(state === 'TITLE') { ctx.fillStyle = '#ffedcc'; ctx.font = '40px sans-serif'; ctx.fillText("アマゾン川 ピラニア釣り", canvas.width / 2, canvas.height / 2 - 40); ctx.font = '20px sans-serif'; ctx.fillStyle = '#8d6e3c'; ctx.fillText(message, canvas.width / 2, canvas.height / 2 + 20); ctx.fillStyle = '#d8c09a'; ctx.font = '16px sans-serif'; ctx.fillText(subMessage, canvas.width / 2, canvas.height / 2 + 50); } else if (state === 'GAMEOVER') { ctx.fillStyle = '#ff6666'; ctx.font = '40px sans-serif'; ctx.fillText("釣り終了", canvas.width / 2, canvas.height / 2 - 40); ctx.font = '24px sans-serif'; ctx.fillStyle = '#fff'; ctx.fillText(`最終釣果: ピラニア ${score} 匹 / 怪魚経験 ${monsterScore} pt`, canvas.width / 2, canvas.height / 2 + 20); ctx.font = '18px sans-serif'; ctx.fillStyle = '#aaa'; ctx.fillText(subMessage, canvas.width / 2, canvas.height / 2 + 70); } else { ctx.textAlign = 'left'; ctx.font = '20px sans-serif';
ctx.fillText(`ピラニア: ${score} 匹`, 20, 30); ctx.fillStyle = '#ffccff'; ctx.fillText(`怪魚経験: ${monsterScore} pt`, 20, 60); ctx.fillStyle = '#fff';
ctx.fillText(`ダメージ:`, canvas.width - 200, 30); ctx.fillStyle = '#333'; ctx.fillRect(canvas.width - 110, 15, 100, 15); ctx.fillStyle = damage >= 7 ? '#ff8888' : '#ffcc00'; ctx.fillRect(canvas.width - 110, 15, Math.min(damage, MAX_DAMAGE) * 10, 15); ctx.strokeStyle = '#fff'; ctx.strokeRect(canvas.width - 110, 15, 100, 15); ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.fillText(message, canvas.width / 2, canvas.height - 70); ctx.fillStyle = '#ffcccc'; ctx.font = '18px sans-serif'; ctx.fillText(subMessage, canvas.width / 2, canvas.height - 35); }
requestAnimationFrame(draw);
}
draw();
</script>
</body>
</html>


■ 今日の学び

アマゾン川の水面は、叩くたびにちがう表情を見せてくれました。 どんな魚が寄ってくるのか、どんな音が返ってくるのか……。

水面の表現には 音・波紋・光 の三つがそろうだけで、 画面の雰囲気がふっと変わる瞬間があることに気づきました。 今回も AI と相談を重ねながら、水を叩いたときの表現にこだわって仕上げました。

そして、AI との制作はやっぱり楽しいです。 「もっと明るく」「飛沫を増やしたい」など、思いつきをそのまま試せるおかげで、 背景のジャングルや魚の吹き出し、水音の高さまで、少しずつ世界が整っていきました。

次の実験でも、また新しい“世界のかけら”を探しにいきます。
また遊びに来てくださいね(^o^)ノシ

■ 次回は、素敵なコーヒータイム
 
コーヒー体験のできる、ちいさな実験ゲームです。
美味しい一杯を楽しみましょう(*^~^)☕

次回もお楽しみに。(^v^)ノシ

次の実験はこちら(癒しの時間とゲーム): 「コーヒーハンドドリップシミュレーター」



 今日も最後まで読んでくださって、ありがとうございます。
みーの実験室は、みなさんの応援で元気に動いています。(*^v^*)

~このブログについて~

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

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


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

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

続きを読む