実験#123:撒き餌と同調の駆け引き(極・フカセ釣りゲーム)

■123個目 「極・フカセ釣りゲーム」

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

今回は、奥深い磯釣りの世界をテーマにした
フカセ釣りシミュレーションゲーム を作ってみました。

狙うのは、磯の王者……“グレ(メジナ)” です!

青く澄んだ海の中には、たくさんの魚たちが暮らしています。

浅いところや中層には、元気いっぱいにエサをねだる小魚たち。

そして、薄暗い深層には……

どっしりと構える大物のグレが潜んでいます。(=v=*)

そんな海の世界を、今回のゲームでできるだけやさしく再現してみました。

今回こだわったのは、
フカセ釣りならではの 「エサの沈み方」 と 「駆け引き」 です。

パラパラと撒いたオキアミや集魚材が、潮に乗ってゆらゆら沈んでいく感じ。
そこへ一斉に群がってくる、お腹をすかせたエサ取りたち。

「今のうちにあっちへ撒き餌を打って小魚たちを引きつけて……
すばやく本命の仕掛けを海の底へ届ける!」

そんな、本物の磯釣りのような頭脳戦を楽しめるようにしてみました。

潮の流れを読みながら仕掛けをふわりと同調させていくと、
ウキが「シュポッ!」と海中へ消し込む瞬間が訪れます。

その一瞬は、思わず息をのむドキドキ感です。(^v^)

さらに、波の満ち引きの音や、大物が掛かったときの
「ジジジッ!」と響くドラグ音など、磯の臨場感にもこだわりました。

見事釣り上げたグレは、しっかりサイズを検量して記録に残せます。

ぜひ、夢のロクマル(60cmオーバー)目指して挑戦してみてくださいね!

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

● ゲーム仕様の相談からスタート

みー:(^v^*)今日もゲームを作ります。
HTMLで動かせるフカセ釣りゲームです。

撒き餌でエサ取りの小魚を誘導しながら、深いところにいる大物を狙います。

エサの沈む感じや、魚たちが群がる様子にリアリティを出したいです。

AI: いらっしゃいませ。今日もゲーム作りですね。
フカセ釣りは奥深くて、とても面白いテーマです。

撒き餌でエサ取りをコントロールし、
本命のタナ(深さ)まで付け餌を届ける駆け引き。

この「沈んでいく同調のリアリティ」と
「魚が群がる生態の表現」を中心に、
HTML/JavaScriptで仕組みを作ってみました。

● リアリティを出すための工夫

  1. エサの沈み方の違い(同調の再現)
    • 撒き餌:
      クリックした位置から広がりながら、
      サイン波で左右にゆらゆら漂い、ゆっくり沈みます。
    • 仕掛け:
      おもりが先にストンと落ち、
      付け餌(オキアミ)が少し遅れて追従します。
      フカセ釣り特有の「仕掛けが馴染む」動きを再現しています。
  2. 魚たちのAI(索敵と捕食)
    • 魚たちは一定の“視界(検知半径)”を持っています。
    • 撒き餌を撒くと、
      浅い層のエサ取りが一斉に向きを変えて突進します。
    • 付け餌も同じように狙われるため、
      プレイヤーは「分離作戦」が必要になります。

画面の青いグラデーションで深さを表現し、
ウキ〜おもり〜針の長さもピクセル比率で再現しています。

● 撒き餌がすぐ消える問題

みー:(><;) 撒き餌がすぐになくなりすぎです。
小粒のオキアミも混ぜて、中層の魚を引き寄せたいです。

AI: なるほど、確かに勢いが強すぎましたね。
撒き餌に「沈みの早い小粒オキアミ」と「表層に漂う集魚材」をブレンドして、
中層の魚も引きつけられるように調整しました。

さらに、ハリス(おもり〜針の糸)が
水の抵抗でふわっとたわみながら沈むように、
二次ベジェ曲線で自然な動きを追加しました。

● 改良したポイント

  • ハリスの自然な沈み方
    おもりが先に沈み、付け餌がゆっくり追いつくまでの間、
    糸がふんわり横にたわむようにしました。
  • 撒き餌の量と滞留時間を調整
    1クリックあたりの量を40粒に増やし、
    魚に「食べている間のクールダウン」を追加しました。
  • ブレンド撒き餌の表現
    30%で小粒オキアミ、70%で集魚材の粉が出るようにしました。

● 深層まで届かない問題

みー:(つv=) 深いところまで糸が届きません。

AI: 気づいてくれて助かりました。
ウキ下の長さを伸ばし、
深層(水深550〜650付近)に付け餌が届くように調整しました。

ちなみに「タナ」という言葉は、商品棚から来ているんですよ。
海の中を棚のように見立てて、
どの層に魚がいるか探るところから生まれた言葉です。

● ファイト演出の追加

みー:(=v=*) 大物が根に潜ろうとする引きが欲しいです。

AI: もちろんです!
ウキが「シュポッ!」と消し込み、根へ突っ込む重量感を再現しました。

その後は自動で巻き上げて釣り上げる流れを追加しています。

● ランダムサイズとランキング機能

みー:( ̄o ̄*) 釣り上げたグレにサイズをつけて、
ランキングも表示したいです。

AI: 任せてください。
30〜50cm台のサイズをランダムで生成し、
LocalStorage対応のランキング機能を追加しました。

● クオリティをさらに高めるブラッシュアップ

みー:ヽ(^o^) もっとクオリティを上げたいです。

AI: 今回は「潮の流れ(ドリフト)」を導入しました。

潮上に撒き餌を打ち、仕掛けを流し込んで同調させる――
フカセ釣りの醍醐味をゲーム内で再現しています。

さらに、水面の波紋やファイト中の水しぶきなど、
視覚エフェクトも強化しました。

● 潮流が強すぎる問題とラインメンディング

みー:(^^:) 潮流が強すぎて仕掛けが流されます。

AI: そこで「ラインメンディング(道糸の管理)」を追加しました。

Spaceキーや新設ボタンを押し続けると、
道糸にブレーキがかかり、流される速度を抑えられます。

糸を張ると付け餌がふわっと浮き上がる挙動も再現しているので、
「張る・緩める」の駆け引きが必要になります。

潮流をいなしながら、
ぜひ大物メジナの鼻先にオキアミを届けてみてくださいね!



■ 実際の動作

まずは、AI が最初に作ってくれた試作版のゲーム画面をご紹介します。
この段階では、付け餌が深いところまで届かず、
大物のグレを釣り上げることができませんでした。

(画像:AIが最初に出してくれた試作画面)


こちらが、調整を重ねて仕上がったゲーム画面です。
底の方にいる大物グレを狙いながら、
撒き餌でエサ取りの小魚をうまくおびき寄せます。
画面右側には、釣り上げたグレのサイズが記録されていき、
潮の流れの情報も表示されます。

(画像:仕上がりのゲーム画面)


撒き餌をまくと、エサ取りの魚たちが一斉に集まってきます。
この「群がる感じ」がとても楽しく、見ているだけでも磯の臨場感があります。

(画像:撒き餌に集まる魚たち)



潮流と魚の動きを予測しながら、タイミングを見て釣り糸を投入します。
撒き餌の帯と仕掛けを同調させるのが、このゲームの大きなポイントです。

(画像:撒き餌の様子)


このゲームでは、
大物のグレが食いついたら必ず釣り上げられる“安心設定”になっています。
ウキが沈み、魚が根に向かって突っ込む引きの臨場感はしっかり味わえるので、
釣り上げる楽しさは十分感じられます。

(画像:釣り上げの様子)



■ コードのポイント

今回の記事では、
AI と相談しながら作った「極・フカセ釣りゲーム」 の HTML コードを公開します。

このコードをそのまま保存するだけで、
あなたの PC でもすぐに遊べます。

ブラウザだけで動く、とてもシンプルなゲームなので、
「コードは初めて…」という方でも気軽に試してみてくださいね。

▼ 遊ぶまでの手順(むずかしさ:やさしい)
① テキストエディタを開く

Windows の場合は「メモ帳」、
Mac の場合は「テキストエディット」を使います。
(※検索バーで「メモ帳」と入力するとすぐ出てきます)

② 下のコードをコピーして貼り付けます。
③ 「fukaseturi.html」など、好きな名前で保存します。

(※拡張子は.html にしてください)

④ 保存したファイルをダブルクリックすると、
ブラウザでゲームが起動します。

■ 遊び方・操作方法

それでは、実際の操作方法をご説明しますね。(^_^)
マウス操作だけでも、キーボードでも手軽に遊べるようになっています。

① 撒き餌(コマセ)を打つ
・操作: 画面の好きな場所を【左クリック】(タップ)

・クリックした場所から、パラパラと撒き餌が海へ広がっていきます。
※一番最初のクリックで、心地いい波の音とBGMが流れ始めますよ♪(^^*)

② 仕掛けを投入する
・操作: 【仕掛け投入ボタン】をクリック、
     または【Spaceキー】をポンと押す

・ウキとオキアミのついた仕掛けを海へ投げ入れます。
おもりが沈み、付け餌がふわ〜っと自然に追従して海中へ馴染んでいきます。

③ ラインを張る(ブレーキをかける)
・操作: 【ラインを張るボタン】を押したままにする、
     または【Spaceキー長押し】

・潮の流れが速くて仕掛けが画面の外へ流されそうな時は、
糸を張ってブレーキ!
仕掛けが流されるのをグッと抑えることができます。
ずっと張りすぎるとエサがふわっと浮き上がってしまうので、
適度に緩めるのがコツです(*=v=*)

④ 大物がヒットしたら……!
・ウキが水中にズバッと引き込まれたら、大物グレが掛かった合図です!
根に潜ろうとする強烈な引きに耐えたあと、
自動でリールを巻き上げてくれます。
釣り上がったグレのサイズが検量され、
右側のランキングに記録されますよ。

■「ちょこっと攻略ヒント」 (o^-‘)b

浅いところにいるエサ取りの小魚たちは、とっても食いしん坊です。
仕掛けのすぐ近くに撒き餌を打ってしまうと、
あっという間に針のエサを取られてしまいます。(><;)

「まずは画面の端っこに撒き餌を打って、エサ取りたちを遠くへ集める!」
そして小魚たちが夢中で食べている隙に、
本命の仕掛けをスーッと深い底へ送り届けてみてくださいね。

潮の流れをよく見て、撒き餌と付け餌がふわっと重なり合う
「同調」が決まった瞬間は最高の気分です。(^v^)ノシ


“コードは長いので折りたたんでいます。必要な方は開いてご覧ください。

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>極・フカセ釣りシミュレーター - ラインメンディング編</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
background-color: #1a252f;
color: #ecf0f1;
font-family: 'Helvetica Neue', Arial, sans-serif;
margin: 0;
padding: 20px;
user-select: none;
}
h2 {
margin-bottom: 5px;
color: #3498db;
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
}
.game-wrapper {
display: flex;
gap: 20px;
align-items: flex-start;
max-width: 950px;
}
#game-container {
position: relative;
}
canvas {
background: linear-gradient(to bottom, #2980b9 0%, #1f618d 30%, #17456b 70%, #0b253a 100%);
border: 4px solid #34495e;
border-radius: 8px;
box-shadow: 0 10px 20px rgba(0,0,0,0.3);
cursor: crosshair;
}
.sidebar {
background-color: #2c3e50;
border: 4px solid #34495e;
border-radius: 8px;
padding: 20px;
min-width: 240px;
box-sizing: border-box;
box-shadow: 0 10px 20px rgba(0,0,0,0.3);
}
.sidebar h3 {
margin-top: 0;
color: #f1c40f;
border-bottom: 2px solid #34495e;
padding-bottom: 8px;
text-align: center;
font-size: 20px;
}
.sidebar ol {
padding-left: 20px;
margin-bottom: 20px;
}
.sidebar li {
font-size: 18px;
font-weight: bold;
margin-bottom: 12px;
color: #bdc3c7;
}
.sidebar li span {
color: #2ecc71;
font-size: 22px;
}
.env-info {
background-color: #1a252f;
padding: 10px;
border-radius: 5px;
font-size: 14px;
border-left: 4px solid #f1c40f;
}
.controls {
margin-top: 15px;
display: flex;
gap: 12px;
align-items: center;
}
button {
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
color: white;
border: none;
border-radius: 25px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0,0,0,0.2);
transition: all 0.2s;
}
#castBtn { background-color: #e74c3c; }
#castBtn:hover { background-color: #c0392b; }
#mendBtn { background-color: #f39c12; }
#mendBtn:hover { background-color: #d35400; }
button:hover { transform: translateY(-2px); }
button:active { transform: translateY(0); }
#message {
font-size: 18px;
font-weight: bold;
color: #f1c40f;
min-width: 320px;
text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
}
</style>
</head>
<body>
<h2>極・磯のフカセ釣り(潮流コントロール版)</h2>
<div class="game-wrapper">
<div id="game-container">
<canvas id="gameCanvas" width="600" height="700"></canvas>
<div class="controls">
<button id="castBtn">仕掛け投入 (Space)</button>
<button id="mendBtn">ラインを張る (Space長押し)</button>
<div id="message">画面クリックでサウンド開始&撒き餌!</div>
</div>
</div>
<div class="sidebar">
<h3>大物グレ記録</h3>
<ol id="ranking-list">
<li>まだ記録がありません</li>
</ol>
<div class="env-info">
<strong>【本日の状況】</strong><br>
<span id="tide-text">潮の流れ: 流動中...</span><br>
<small style="color:#aaa;">※潮が速い時は、ラインを張って(ブレーキ)仕掛けの流れる速度を調整しましょう。張りすぎるとエサが浮き上がります。</small>
</div>
</div>
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const msgEl = document.getElementById('message');
const castBtn = document.getElementById('castBtn');
const mendBtn = document.getElementById('mendBtn');
const rankingListEl = document.getElementById('ranking-list');
const tideTextEl = document.getElementById('tide-text');
let gameState = 'ready';
let frames = 0;
let fightTimer = 0;
let currentSize = 0;
let tideSpeed = 0.6;
let isMending = false; // ラインを張っているかどうかのフラグ
let keys = {}; // キーボード状態保持
const SURFACE_Y = 50;
const FLOAT_TO_SINKER = 400;
const SINKER_TO_BAIT = 150;
let chums = [];
let fishes = [];
let particles = [];
let rig = {
active: false,
x: 300,
floatY: SURFACE_Y,
sinkerY: SURFACE_Y,
baitY: SURFACE_Y,
hasBait: true
};
// ==========================================
// 音響システム
// ==========================================
class AudioManager {
constructor() { this.audioCtx = null; }
init() {
if (this.audioCtx) return;
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
this.createWaveSound();
this.startBgmLoop();
}
createWaveSound() {
const bufferSize = this.audioCtx.sampleRate * 2;
const buffer = this.audioCtx.createBuffer(1, bufferSize, this.audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = this.audioCtx.createBufferSource();
noise.buffer = buffer; noise.loop = true;
const filter = this.audioCtx.createBiquadFilter();
filter.type = 'lowpass'; filter.frequency.value = 300;
const waveGain = this.audioCtx.createGain();
waveGain.gain.value = 0.03;
noise.connect(filter); filter.connect(waveGain); waveGain.connect(this.audioCtx.destination);
noise.start();
setInterval(() => {
if (!this.audioCtx) return;
const now = this.audioCtx.currentTime;
waveGain.gain.setValueAtTime(0.015, now);
waveGain.gain.linearRampToValueAtTime(0.05, now + 2.5);
waveGain.gain.linearRampToValueAtTime(0.015, now + 5.0);
}, 5000);
}
startBgmLoop() {
const scale = [293.66, 329.63, 392.00, 440.00, 523.25];
setInterval(() => {
if (gameState !== 'fishing' && gameState !== 'ready') return;
if (Math.random() > 0.4) this.playTone(scale[Math.floor(Math.random() * scale.length)], 'triangle', 0.015, 1.5);
}, 900);
}
playTone(freq, type, vol, duration) {
if (!this.audioCtx) return;
const osc = this.audioCtx.createOscillator(); const gain = this.audioCtx.createGain();
osc.type = type; osc.frequency.setValueAtTime(freq, this.audioCtx.currentTime);
gain.gain.setValueAtTime(vol, this.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.00001, this.audioCtx.currentTime + duration);
osc.connect(gain); gain.connect(this.audioCtx.destination);
osc.start(); osc.stop(this.audioCtx.currentTime + duration);
}
playChumSE() { this.playTone(400, 'sine', 0.05, 0.1); }
playCastSE() { this.playTone(300, 'triangle', 0.05, 0.2); }
playHookedSE() {
this.playTone(800, 'sine', 0.15, 0.1);
this.playTone(70, 'sawtooth', 0.3, 0.5);
}
playDragSE() {
if (!this.audioCtx) return;
const osc = this.audioCtx.createOscillator(); const gain = this.audioCtx.createGain();
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(500 + Math.random() * 200, this.audioCtx.currentTime);
gain.gain.setValueAtTime(0.03, this.audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + 0.03);
osc.connect(gain); gain.connect(this.audioCtx.destination);
osc.start(); osc.stop(this.audioCtx.currentTime + 0.03);
}
playClearSE() {
const now = this.audioCtx.currentTime;
[523.25, 659.25, 783.99, 1046.50].forEach((f, i) => {
setTimeout(() => this.playTone(f, 'sine', 0.08, 0.4), i * 100);
});
}
playOverSE() { this.playTone(180, 'sine', 0.1, 0.4); }
}
const audio = new AudioManager();
function addRipple(x, y, maxRadius, color) {
particles.push({ type: 'ripple', x, y, r: 2, maxR: maxRadius, alpha: 1, color });
}
function addSplash(x, y) {
for(let i=0; i<5; i++) {
particles.push({
type: 'splash', x, y,
vx: (Math.random() - 0.5) * 4, vy: -Math.random() * 5 - 2,
size: Math.random() * 3 + 2, alpha: 1
});
}
}
// ==========================================
// ランキング処理
// ==========================================
function loadAndShowRanking() {
let ranking = JSON.parse(localStorage.getItem('fukase_gre_ranking')) || [];
rankingListEl.innerHTML = '';
if (ranking.length === 0) {
rankingListEl.innerHTML = '<li>なし</li>';
return;
}
ranking.forEach(score => {
const li = document.createElement('li');
li.innerHTML = `<span>${score.toFixed(1)}</span> cm`;
rankingListEl.appendChild(li);
});
}
function saveRecord(newSize) {
let ranking = JSON.parse(localStorage.getItem('fukase_gre_ranking')) || [];
ranking.push(parseFloat(newSize));
ranking.sort((a, b) => b - a).splice(5);
localStorage.setItem('fukase_gre_ranking', JSON.stringify(ranking));
loadAndShowRanking();
}
// ==========================================
// 魚クラス
// ==========================================
class Fish {
constructor(type) {
this.type = type;
this.x = Math.random() * canvas.width;
this.eatCooldown = 0;
this.state = 'normal';
if (type === 'small') {
this.y = SURFACE_Y + 40 + Math.random() * 320;
this.size = 8;
this.speed = 2.2;
this.color = '#7f8c8d';
this.detectRadius = 160;
} else {
this.y = 560 + Math.random() * 90;
this.size = 24;
this.speed = 1.0;
this.color = '#2c3e50';
this.detectRadius = 150;
}
this.vx = (Math.random() > 0.5 ? 1 : -1) * this.speed;
this.vy = 0;
}
update() {
if (this.state === 'hooked') {
if (gameState === 'fight') {
this.vx += (Math.random() - 0.5) * 4;
this.vy += 0.9;
this.vx = Math.max(-7, Math.min(7, this.vx));
if (this.y > canvas.height - 25) this.y = canvas.height - 25;
if (this.x < 15) this.x = 15;
if (this.x > canvas.width - 15) this.x = canvas.width - 15;
this.x += this.vx;
this.y += this.vy;
rig.x = this.x;
rig.baitY = this.y;
rig.sinkerY = rig.baitY - SINKER_TO_BAIT;
rig.floatY = rig.sinkerY - FLOAT_TO_SINKER;
if (frames % 4 === 0) audio.playDragSE();
if (frames % 8 === 0) addSplash(this.x, rig.floatY);
msgEl.innerText = "強烈な突っ込み!のされそう、耐えろ!";
msgEl.style.color = '#e67e22';
} else if (gameState === 'reeling') {
this.y -= 4.0;
this.x += (canvas.width / 2 - this.x) * 0.04;
rig.x = this.x;
rig.baitY = this.y;
rig.sinkerY = rig.baitY - SINKER_TO_BAIT;
rig.floatY = Math.min(SURFACE_Y, rig.sinkerY - FLOAT_TO_SINKER);
if (frames % 10 === 0) audio.playDragSE();
msgEl.innerText = "浮いてきたぞ!タモ入れの準備!";
msgEl.style.color = '#3498db';
if (this.y <= SURFACE_Y + 20) {
gameState = 'clear';
audio.playClearSE();
currentSize = (35.0 + Math.random() * 25.0 + (Math.random() > 0.9 ? 5 : 0)).toFixed(1);
saveRecord(currentSize);
msgEl.innerText = `メジナ御用!検量:【 ${currentSize} cm 】!`;
msgEl.style.color = '#2ecc71';
}
}
return;
}
if (this.eatCooldown > 0) this.eatCooldown--;
this.x += tideSpeed * 0.4;
let closestDist = this.detectRadius;
let closestFood = null;
let foodType = null;
if (this.type === 'small') {
chums.forEach(chum => {
let d = Math.hypot(this.x - chum.x, this.y - chum.y);
if (d < closestDist) { closestDist = d; closestFood = chum; foodType = 'chum'; }
});
}
let isSynchronized = chums.some(c => Math.hypot(this.x - c.x, this.y - c.y) < 120);
if (rig.active && rig.hasBait) {
if (this.type === 'small' || isSynchronized) {
let d = Math.hypot(this.x - rig.x, this.y - rig.baitY);
if (d < closestDist) { closestDist = d; closestFood = { x: rig.x, y: rig.baitY }; foodType = 'bait'; }
}
}
if (closestFood) {
let angle = Math.atan2(closestFood.y - this.y, closestFood.x - this.x);
let currentSpeed = this.eatCooldown > 0 ? this.speed * 0.3 : this.speed * 1.6;
this.vx = Math.cos(angle) * currentSpeed;
this.vy = Math.sin(angle) * currentSpeed;
if (closestDist < this.size + 5) {
if (foodType === 'chum' && this.eatCooldown <= 0) {
closestFood.eaten = true;
this.eatCooldown = 12;
} else if (foodType === 'bait') {
rig.hasBait = false;
if (this.type === 'small') {
gameState = 'over';
audio.playOverSE();
msgEl.style.color = '#e74c3c';
msgEl.innerText = "あちゃー!エサ取りの猛攻で素針だ…";
} else {
gameState = 'fight';
audio.playHookedSE();
addRipple(rig.x, SURFACE_Y, 40, 'rgba(231, 76, 60, 0.8)');
fightTimer = 160;
this.state = 'hooked';
}
}
}
} else {
if (Math.random() < 0.01) this.vx = (Math.random() > 0.5 ? 1 : -1) * this.speed;
if (Math.random() < 0.03) this.vy = (Math.random() - 0.5) * 0.5;
if (this.x < -20) this.x = canvas.width + 20;
if (this.x > canvas.width + 20) this.x = -20;
let minY = this.type === 'small' ? SURFACE_Y + 20 : 500;
let maxY = this.type === 'small' ? 460 : canvas.height - 20;
if (this.y < minY) this.vy = Math.abs(this.vy);
if (this.y > maxY) this.vy = -Math.abs(this.vy);
}
this.x += this.vx;
this.y += this.vy;
}
draw() {
ctx.save();
ctx.fillStyle = this.color;
let isLeft = this.vx < 0;
ctx.beginPath();
ctx.ellipse(this.x, this.y, this.size * 1.4, this.size, 0, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
let tailX = isLeft ? this.x + this.size * 1.4 : this.x - this.size * 1.4;
ctx.moveTo(this.x, this.y);
ctx.lineTo(tailX, this.y - this.size * 0.8);
ctx.lineTo(tailX, this.y + this.size * 0.8);
ctx.fill();
if(this.type === 'big') {
ctx.fillStyle = '#3498db';
let eyeX = isLeft ? this.x - this.size * 0.8 : this.x + this.size * 0.8;
ctx.beginPath();
ctx.arc(eyeX, this.y - this.size * 0.2, 3, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
}
}
function init() {
chums = []; fishes = []; particles = [];
gameState = 'ready';
fightTimer = 0;
isMending = false;
// 激流になりすぎないよう最大潮流を少しマイルドに調整 (-1.0 〜 +1.0)
tideSpeed = (Math.random() > 0.5 ? 1 : -1) * (0.4 + Math.random() * 0.6);
let directionText = tideSpeed > 0 ? "右流れ" : "左流れ";
if (Math.abs(tideSpeed) > 0.8) directionText += "(本流・速い)";
else directionText += "(緩潮)";
tideTextEl.innerText = `潮速: ${directionText} (${Math.abs(tideSpeed * 5).toFixed(1)} ノット相当)`;
msgEl.innerText = "潮上にコマセを打ち、仕掛けを同調させよ!";
msgEl.style.color = '#f1c40f';
rig.active = false;
for (let i = 0; i < 18; i++) fishes.push(new Fish('small'));
fishes.push(new Fish('big'));
loadAndShowRanking();
}
// 撒き餌
canvas.addEventListener('mousedown', (e) => {
audio.init();
if (gameState === 'over' || gameState === 'clear') { init(); return; }
if (gameState === 'fight' || gameState === 'reeling') return;
audio.playChumSE();
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
addRipple(mouseX, SURFACE_Y, 25, 'rgba(255,255,255,0.4)');
for (let i = 0; i < 35; i++) {
let isKrill = Math.random() < 0.35;
chums.push({
x: mouseX + (Math.random() - 0.5) * 40,
y: SURFACE_Y + Math.random() * 5,
vx: (Math.random() - 0.5) * 0.5,
vy: isKrill ? 0.9 + Math.random() * 0.4 : 0.3 + Math.random() * 0.3,
driftBase: Math.random() * Math.PI * 2,
type: isKrill ? 'krill' : 'powder',
eaten: false
});
}
});
function castRig() {
audio.init();
if (gameState === 'ready' || !rig.active) {
audio.playCastSE();
rig.active = true;
rig.x = tideSpeed > 0 ? 150 : 450; // 潮の流れに合わせて初期位置を調整(流される余裕を作る)
rig.floatY = SURFACE_Y;
rig.sinkerY = SURFACE_Y;
rig.baitY = SURFACE_Y;
rig.hasBait = true;
gameState = 'fishing';
msgEl.innerText = "仕掛けが馴染むのを待つ...";
addRipple(rig.x, SURFACE_Y, 30, 'rgba(52, 152, 219, 0.6)');
}
}
castBtn.addEventListener('click', castRig);
// ラインメンディング用のイベント設定(ボタン長押し対応)
function startMending() { if (gameState === 'fishing') isMending = true; }
function stopMending() { isMending = false; }
mendBtn.addEventListener('mousedown', startMending);
mendBtn.addEventListener('mouseup', stopMending);
mendBtn.addEventListener('mouseleave', stopMending);
mendBtn.addEventListener('touchstart', (e) => { e.preventDefault(); startMending(); });
mendBtn.addEventListener('touchend', stopMending);
// キーボード入力の監視
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
if (e.code === 'Space') {
if (gameState === 'ready') castRig();
else if (gameState === 'fishing') isMending = true;
}
});
window.addEventListener('keyup', (e) => {
keys[e.code] = false;
if (e.code === 'Space') isMending = false;
});
// ==========================================
// メインループ
// ==========================================
function loop() {
frames++;
if (gameState === 'fight') {
fightTimer--;
if (fightTimer <= 0) gameState = 'reeling';
}
// ラインメンディング(ブレーキ)による潮流影響の軽減
if (rig.active && gameState === 'fishing') {
// メンディング中は潮流の影響を15%にカット!
let currentTideSpeed = isMending ? tideSpeed * 0.15 : tideSpeed;
rig.x += currentTideSpeed;
// 状態メッセージの更新
if (isMending) {
msgEl.innerText = "糸を張って仕掛けをキープ中…";
msgEl.style.color = '#f39c12';
} else {
msgEl.innerText = "仕掛けが馴染むのを待つ...";
msgEl.style.color = '#f1c40f';
}
// 画面外ロスト処理
if (rig.x < -10 || rig.x > canvas.width + 10) {
rig.active = false;
gameState = 'ready';
msgEl.innerText = "仕掛けが流されすぎた。手返ししよう。";
msgEl.style.color = '#bdc3c7';
}
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (rig.active && rig.floatY === SURFACE_Y && frames % 60 === 0) {
addRipple(rig.x, SURFACE_Y, 12, 'rgba(255,255,255,0.2)');
}
// エフェクトの更新・描画
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
if (p.type === 'ripple') {
p.r += 0.5; p.alpha = 1 - (p.r / p.maxR);
if (p.r >= p.maxR) { particles.splice(i, 1); continue; }
ctx.strokeStyle = p.color; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.ellipse(p.x, p.y, p.r * 1.5, p.r * 0.4, 0, 0, Math.PI * 2); ctx.stroke();
} else if (p.type === 'splash') {
p.x += p.vx; p.y += p.vy; p.vy += 0.2; p.alpha -= 0.02;
if (p.alpha <= 0) { particles.splice(i, 1); continue; }
ctx.fillStyle = `rgba(255,255,255, ${p.alpha})`;
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI*2); ctx.fill();
}
}
ctx.fillStyle = 'rgba(255, 255, 255, 0.15)';
ctx.fillRect(0, 0, canvas.width, SURFACE_Y);
// 撒き餌の更新と描画
for (let i = chums.length - 1; i >= 0; i--) {
let c = chums[i];
c.x += c.vx + Math.sin(frames * 0.04 + c.driftBase) * 0.2 + tideSpeed;
c.y += c.vy;
if (c.y > canvas.height || c.x < 0 || c.x > canvas.width || c.eaten) {
chums.splice(i, 1);
} else {
if (c.type === 'krill') {
ctx.fillStyle = 'rgba(255, 118, 117, 0.8)';
ctx.beginPath(); ctx.arc(c.x, c.y, 2.5, 0, Math.PI * 2); ctx.fill();
} else {
ctx.fillStyle = 'rgba(255, 215, 215, 0.4)';
ctx.beginPath(); ctx.arc(c.x, c.y, 1.5, 0, Math.PI * 2); ctx.fill();
}
}
}
// 仕掛けの描画
if (rig.active) {
if (gameState === 'fishing' || gameState === 'ready') {
if (rig.sinkerY < rig.floatY + FLOAT_TO_SINKER) rig.sinkerY += 1.6;
// メンディング(ラインを張る)時はエサが潮流で上に持ち上がる物理挙動
if (isMending) {
if (rig.baitY > rig.sinkerY + 60) rig.baitY -= 0.8; // ジワジワ浮き上がる
} else {
if (rig.baitY < rig.sinkerY + SINKER_TO_BAIT) {
let sinkSpeed = (rig.sinkerY < rig.floatY + FLOAT_TO_SINKER) ? 1.3 : 0.6;
rig.baitY += sinkSpeed;
}
}
rig.sinkerY += (Math.sin(frames * 0.02) * 0.1);
}
// ラインの描画(メンディング中はオレンジ色に輝く)
if (gameState === 'fight') {
ctx.strokeStyle = 'rgba(231, 76, 60, 0.9)'; ctx.lineWidth = 2;
} else if (isMending) {
ctx.strokeStyle = 'rgba(243, 156, 18, 0.8)'; ctx.lineWidth = 1.5;
} else {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.35)'; ctx.lineWidth = 1;
}
ctx.beginPath(); ctx.moveTo(rig.x, rig.floatY); ctx.lineTo(rig.x, rig.sinkerY); ctx.stroke();
// ハリス(なじみ曲線)
let lineSlack = SINKER_TO_BAIT - (rig.baitY - rig.sinkerY);
if (lineSlack > 0 && gameState !== 'fight' && gameState !== 'reeling') {
ctx.beginPath();
ctx.moveTo(rig.x, rig.sinkerY);
let controlX = rig.x + (lineSlack * 0.3) + (tideSpeed * 20);
let controlY = rig.sinkerY + (rig.baitY - rig.sinkerY) * 0.5;
if (isMending) {
controlY -= lineSlack * 0.4; // 引っ張られて上方向にたわむ
}
ctx.quadraticCurveTo(controlX, controlY, rig.x, rig.baitY);
ctx.stroke();
} else {
ctx.beginPath(); ctx.moveTo(rig.x, rig.sinkerY); ctx.lineTo(rig.x, rig.baitY); ctx.stroke();
}
// ウキ
ctx.fillStyle = (gameState === 'fight') ? '#d35400' : '#e74c3c';
ctx.beginPath(); ctx.ellipse(rig.x, rig.floatY, 4, 8, 0, 0, Math.PI * 2); ctx.fill();
// おもり
ctx.fillStyle = '#95a5a6';
ctx.beginPath(); ctx.arc(rig.x, rig.sinkerY, 3, 0, Math.PI * 2); ctx.fill();
// 付け餌
if (rig.hasBait && gameState !== 'fight' && gameState !== 'reeling' && gameState !== 'clear') {
ctx.fillStyle = '#ff7675';
ctx.beginPath(); ctx.arc(rig.x, rig.baitY, 4, 0, Math.PI * 2); ctx.fill();
}
}
fishes.forEach(fish => {
if (gameState !== 'over' && gameState !== 'clear') fish.update();
fish.draw();
});
requestAnimationFrame(loop);
}
init();
loop();
</script>
</body>
</html>


■ 今日の学び

前回の「#122 スイカサバイバルゲーム」では、
割ったスイカに魚が群がって自分を守ってくれるというシステムでしたが、
不思議なことに今回も「エサで魚を集める」という意味で
繋がりを感じるテーマになりました。

実は私自身、以前職場の先輩方に磯釣りを教わった経験があります。
その時、私がエサ取りの小魚ばかり釣ってしまうのを見かねた先輩が、
仕掛けを投げ入れる直前に、
少し離れたところへサッと撒き餌を放り込んでくれたんです。Ꙭ ‼︎

すると、見事にエサ取りたちがそちらへ誘導され、
無事にいいサイズのグレを釣り上げることができました。

「見えない水中の様子を想像しながら釣るというのは、
こういうことなんだなぁ」と感動したのを覚えています。
今回のゲームは、まさにそんな私の実体験が原点になっています。

私自身は釣りに関する専門知識がまだまだ少なく、
うまく形にできるか不安な部分もあったのですが、
そこはAIがしっかりサポートしてくれました。

おかげで、当時の釣り場では実際に目で見ることができなかった
「海中の駆け引き」をゲームとして可視化でき、
あの時の先輩の凄さを改めて実感できたのが、
今回の最大の学びです。( ˘⊖˘) 。o ( 海の中って奥が深い…… )

目に見えないものを想像してプログラムで表現していくのって、
やっぱりゲーム作りの醍醐味ですね。

■ 次回は、3Dの緑のカーテンで楽しむ
  ゴーヤ収穫&チャンプルーゲームです。

次回は、夏の風物詩「ゴーヤの緑のカーテン」をテーマにした、
3D空間で楽しむちょっとユニークな実験ゲームをご紹介します。(^v^)

青々と茂る葉っぱのすき間から、 ちょうどいい大きさのゴーヤを探して収穫!

採れたてのゴーヤをフライパンへジュージューっと放り込んで、
特大ゴーヤチャンプルーを完成させる爽快感を味わえます。

うっかり熟れすぎて黄色く弾けてしまう前に、
美味しいタイミングを見極めて収穫してみてくださいね。

次回もぜひ楽しみにしていてくださいね。(*^v^)ノシ

▶ 次の実験はこちら
 育てて収穫!おいしく炒めよう
「ゴーヤの緑のカーテン シミュレーション」




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