実験#107:水泳ゲームからの無茶ぶりな一言(ブレストストローク:リズム&ライバル)

■107個目 「ブレストストローク:リズム&ライバル」

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

毎日とろけそうな暑さが続きますね。(´Д`)
今日は、そんな季節にぴったりの涼しげな水泳ゲームをご紹介します。

今回の作品は『ブレストストローク:リズム&ライバル』。

数ある泳ぎ方の中でも、ちょっと渋めの
「平泳ぎ(ブレストストローク)」に特化したレースゲームです。

平泳ぎって、ただ手足をバシャバシャ動かすだけではなくて、
水を掻いたあとに体を「スーッ」と伸ばして滑るように進む
“グライド”の時間がとても大切ですよね。(=v=*)

この作品では、その気持ちよさと、
掻くタイミングの「リズム」を丁寧にゲーム化しています。

「自分のペースで泳いでいるつもりでも、隣のレーンに誰かがいると、
なぜか息継ぎのリズムが狂っちゃう……」

そんなリアルな焦りとドキドキを、
レースの駆け引きとして楽しめるようになっています。(´▽`)

コースは4レーン構成で、あなたと一緒に泳ぐのは次のメンバーです。

  • ① 前回ベストのゴースト(過去の最速の自分)
  • ② 0.5秒前の残像(現在のペース確認用)
  • ③ プレイヤー(あなた自身)
  • ④ CPUライバル(手強い緑のスイマー)

冷たい水しぶきと泡を上げながら、
コンマ何秒の世界で 誰が一番早く50m先の壁にタッチできるのか……!

美しい蒼いプールの中で、
息を呑むようなデッドヒートが味わえます。(≧∇≦)

このあと、
操作方法やちょっとシビアなランク判定についても詳しくご紹介しますね。


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

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

みー:(^v^) 今日もゲームを作ります。HTMLで動く水泳ゲーム。

平泳ぎをテーマにした作品って作れますか?

AI: 平泳ぎの「伸び」と「掻き」のタイミングを再現するために、
収縮するリングが重なる瞬間にキーを押すリズムアクション形式を
提案しました。

タイミングよく連続成功すると加速し、
ピコピコ音のテンポも上がっていく、
中毒性の高い50mタイムアタックゲームになります。

みー:(^_^) たのしい!
プールとプレイヤーのリアリティをもっと高めたいです。

AI: そう言っていただけて嬉しいです。

今回はビジュアルとアニメーションを強化して、
平泳ぎの「質感」をより丁寧に表現していきます。

注力したポイントは次の3つです。

  • プレイヤーのリアリティ向上
    • カメから人間のスイマーに変更
    • 「掻き → 引き付け → 蹴り → 伸び(グライド)」の4フェーズを滑らかなアニメーションで表現
  • プール(水)の質感アップ
    • 水底のタイル模様
    • 水面の光の屈折(コースティクス)
    • 立体的なレーンロープ
    • わずかな奥行き(パース)を追加
  • エフェクトの追加
    • 掻いた瞬間の波紋
    • 蹴りの泡と水しぶき

みー:(*=v=) 視点を斜め後方にできるかな?

AI: 斜め後方の視点はとても良いアイデアです。
奥行きが出て、体感ゲームのような臨場感が生まれます。

プールの底にある黒いラインや
「T字」のマーク(残り2mの目印)も映えるようになりますね。

今回はCanvasの座標変形を使い、
2Dのまま世界全体を斜めに傾ける方法で実現しました。

みー:(=v=) ゴール地点のプールの端がほしいです。

隣のレーンに一瞬前の自分の残像を泳がせて、
2回目以降は前回の自分ゴーストとも競えるようにしたいです。

AI: ゴースト機能は水泳ゲームとの相性が抜群です。
リクエスト通り、ゴールの壁と2種類のゴーストを追加しました。

さらに、ゴール手前の水底に黒い「T字」ラインも描き込み、
ラストスパートの目印として機能するようにしています。

みー:(^o^) 画面を広げて第4レーンにCPUも参加させましょう。
競争感がほしいです。

AI: 画面を横に広げ、4レーン構成にしました。

右端の第4レーンには、
独自の思考を持つCPUライバル(緑)を配置しています。

レーン構成は次の通りです。

  • 第1レーン:前回ベストのゴースト(赤)
  • 第2レーン:0.5秒前の自分の残像(青)
  • 第3レーン:プレイヤー(自分)
  • 第4レーン:CPUライバル(緑)

競争相手がいることで、リズムが乱れたり、
逆に集中してGREATを連発できたり、
ゲームとしての緊張感がぐっと高まりました。

みー:( ̄v ̄*) 安定してGREATを出すと、
リアルなタイムに近い秒数でゴールできるようにしたいです。

AI: 水泳として現実的なタイム(トップ選手で25秒前後)に合わせるため、
物理演算を根本から調整しました。

調整ポイントは次の3つです。

水の抵抗(drag)の適正化 → 推進力とのバランスを取り、
最高速度が現実的な範囲に収まるようにしました。

ランク判定の同期 → 表示と内部判定を完全に一致させ、
S++は「24.0秒未満」に設定。

ミス時の挙動を自然に → 急停止ではなく、
惰性で進む「自然な減速(0.5倍)」に変更し、違和感を解消。


■ 実際の動作

まずは、AIが最初に作ってくれた試作版のゲーム画面です。
カメのようなキャラクターが泳いでいて、
縮む円の大きさが重なるタイミングでクリックすると
前に進む仕組みになっています。

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


こちらが、最終的に仕上がったゲーム画面です。 見た目は4人でのレースですが、実際に泳いでいるのは次の4つの「自分」です。

  • 前回ベストの自分(ゴースト)
  • 0.5秒前の自分(残像)
  • 現在の自分(プレイヤー)
  • CPUライバルと競うために配置した自分のデータ

つまり、リアルタイムの自分と、過去の自分たちが並んで泳ぐ「自分だらけのレース」になっています。(=v=*)

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


ゴールすると、今回の記録とランクが表示されます。
画像では調整前の記録が残っていますが、
実際のプレイでは最新の成績が反映されます。

(画像:ゲーム終了画面)



■ コードのポイント

今回の記事では、
AI と相談しながら作った「ブレストストローク:リズム&ライバル」 の HTML コードを公開します。

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

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

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

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

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

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

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

■ 遊び方・操作方法

操作はとってもシンプルです!(^^)
マウスの左クリックか、キーボードのスペースキー*だけで遊べます。

あなたのスイマーの周りには、
目標となる 黄色い円 と、
外側からゆっくり縮んでくる 白いリング が表示されます。

この白いリングが黄色い円と
ぴったり重なる瞬間 にクリックしてください!🖱️✨

▼ 判定について

タイミングによって、次の4つの判定が出ます。(・ω・)

  • 🌊 GREAT / PERFECT 力強く水を掻いて、大きく前へ進みます。
    そのあとの「スーッ」と滑るような伸び(グライド)が長くなります。
  • 💦 GOOD 少しだけ前進しますが、推進力は控えめです。
  • 🛑 MISS タイミングがずれるとミス。
    大きく失速してしまいます。(>_<)

▼ RHYTHM COMBO(リズムコンボ)

このゲームの一番熱いポイントが リズムコンボ です!

🔥 GREAT や PERFECT を連続で出すとコンボになり、
泳ぐテンポが上がっていきます。

リングが縮むスピードもどんどん速くなるので、
焦らず、でもテンポ良く…… 無心でリズムに乗れた時のスピード感は、
とても気持ちいいですよ。(´▽`)

▼ ランク判定

ゴールした時のタイムによって、最後にランクが決まります。
今回はアスリートの厳しさをイメージして、
少しシビアめの設定にしています。(=v=*)

  • S++(24秒未満) → 男子世界記録を上回る“超人タイム”。
    ほぼ完璧なリズムを保たないと届かない、特別なランクです。

過去の自分(ゴースト)や、
隣のレーンでプレッシャーをかけてくるライバルと競いながら、
ぜひ何度もチャレンジしてみてくださいね。

リズムがぴたりとハマった瞬間、驚くほど気持ちよく泳げます。(^_^)


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>蒼きグライド:水上の競演『ブレストストローク:リズム&ライバル』</title>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700;900&display=swap" rel="stylesheet">
<style>
body {
background-color: #011627; color: #f0f0f0; text-align: center; font-family: 'Montserrat', sans-serif; margin: 0; padding: 20px; user-select: none; overflow: hidden;
}
h1 {
font-size: 32px; font-weight: 900; margin-bottom: 10px; letter-spacing: -1px; text-shadow: 0 2px 10px rgba(0, 255, 255, 0.5); color: #b9faf8;
}
h2 {
font-size: 14px; font-weight: 700; color: #aaaaaa; margin-top: -10px; margin-bottom: 15px;
}
#game-container {
display: inline-block; position: relative;
}
canvas {
background: #0077b6; border: 10px solid #caf0f8; border-radius: 20px; box-shadow: 0 0 50px rgba(0, 180, 216, 0.8), inset 0 0 30px rgba(0, 0, 0, 0.3); cursor: pointer;
}
#instructions {
max-width: 740px; margin: 20px auto; font-size: 13px; line-height: 1.7; text-align: left; background: rgba(27, 38, 59, 0.8); padding: 15px; border-radius: 12px; border: 1px solid #415a77;
}
.highlight { color: #48cae4; font-weight: bold; }
.great-perfect { color: #ff0054; font-weight: bold; text-shadow: 0 0 5px #ff0054; }
.perfect { color: #fca311; font-weight: bold; }
.ghost-blue { color: #8ecae6; font-weight: bold; }
.ghost-red { color: #ef476f; font-weight: bold; }
.rival-green { color: #cfffb3; font-weight: bold; text-shadow: 0 0 5px #cfffb3; }
.rhythm-combo { color: #ff6e7f; font-weight: bold; text-shadow: 0 0 5px #ff6e7f; }
#best-time-display {
position: absolute; top: 15px; left: 15px; font-size: 14px; color: #b9faf8; font-weight: bold; z-index: 10;
}
#reset-best-btn {
font-size: 11px; padding: 3px 8px; cursor: pointer; background: #ef476f; border: none; border-radius: 4px; color: #fff; margin-left: 10px; font-weight: bold;
}
#reset-best-btn:hover { background: #d90429; }
.rank-box {
position: absolute; top: 80px; left: 15px; border: 2px solid #ee9b00; padding: 10px; background-color: rgba(1, 22, 39, 0.9); border-radius: 8px; width: 180px; font-weight: bold; color: #ffffff; text-align: left; font-size: 13px; z-index: 10; box-shadow: 0 4px 15px rgba(0,0,0,0.5);
}
</style>
</head>
<body>
<div style="position:relative; display:inline-block;">
<h1>BREASTSTROKE: RHYTHM & RIVAL</h1>
<h2>蒼きグライド:水上の競演</h2>
<div id="best-time-display">BEST: --:--</div>
<!-- ランク判定インセットボックス (UI同期済み) -->
<div class="rank-box">
<span style="color: #ee9b00; font-size: 11px;">シビアなランク判定</span><br>
<span style="font-size: 10px; color:#aaa;">(Severe Rank Judgement)</span><br><br>
<span style="color: #ff0054; font-size: 18px;">S++: &lt; 24.0s</span><br>
<span style="color: #fca311; font-size: 15px;">S+: &lt; 28.0s</span><br>
<span style="color: #ff6e7f; font-size: 14px;">S: &lt; 34.0s</span><br>
<span style="color: #48cae4;">A: &lt; 42.0s</span><br>
<span style="color: #aaaaaa;">B: &lt; 50.0s</span>
</div>
</div>
<div id="game-container">
<canvas id="gameCanvas" width="640" height="640"></canvas>
</div>
<div id="instructions">
<strong>【遊び方】 - 完璧なリズムで限界を突破しろ -</strong><br>
・画面クリック(またはスペースキー)でスタート。<br>
・<span class="highlight">黄色い円</span>に<span class="highlight">白いリング</span>が重なる瞬間に入力!<br>
・タイミング判定は <span class="great-perfect">GREAT PERFECT</span>, <span class="perfect">PERFECT</span>, <span class="highlight">GOOD</span>。<br>
・GREAT以上を連続すると、<span class="rhythm-combo">RHYTHM COMBO</span>が発生し、リングが劇的に速くなる!<br>
・第1レーン:<span class="ghost-red">前回ベスト</span>、第2レーン:<span class="ghost-blue">0.5秒前の残像</span>、第3レーン:<span class="highlight">自分</span>、第4レーン:<span class="rival-green">CPUライバル</span>。
</div>
<script>
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioContext();
// --- オーディオ・BGMエンジン ---
let nextNoteTime = 0; let beatCount = 0;
function playSound(type, pitchOffset = 1) {
if (audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
const now = audioCtx.currentTime; osc.frequency.setValueAtTime(440, now);
if (type === 'greatPerfect') {
osc.type = 'triangle'; osc.frequency.setValueAtTime(1320 * pitchOffset, now);
osc.frequency.exponentialRampToValueAtTime(1760 * pitchOffset, now + 0.04);
gain.gain.setValueAtTime(0.2, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
} else if (type === 'perfect') {
osc.type = 'sine'; osc.frequency.setValueAtTime(880 * pitchOffset, now);
osc.frequency.exponentialRampToValueAtTime(1320 * pitchOffset, now + 0.05);
gain.gain.setValueAtTime(0.15, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
} else if (type === 'good') {
osc.type = 'sine'; osc.frequency.setValueAtTime(660 * pitchOffset, now);
gain.gain.setValueAtTime(0.15, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.15);
osc.start(now); osc.stop(now + 0.15);
} else if (type === 'miss') {
osc.type = 'triangle'; osc.frequency.setValueAtTime(220, now);
osc.frequency.linearRampToValueAtTime(110, now + 0.3);
gain.gain.setValueAtTime(0.2, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
}
}
function playBgmBeat(time, tempoFactor) {
if (audioCtx.state === 'suspended') audioCtx.resume();
if (beatCount % 4 === 0) {
const osc1 = audioCtx.createOscillator(); const gain1 = audioCtx.createGain();
osc1.connect(gain1); gain1.connect(audioCtx.destination);
osc1.type = 'triangle'; osc1.frequency.setValueAtTime(100, time);
osc1.frequency.exponentialRampToValueAtTime(50, time + 0.1);
gain1.gain.setValueAtTime(0.2, time); gain1.gain.exponentialRampToValueAtTime(0.01, time + 0.15);
osc1.start(time); osc1.stop(time + 0.15);
}
if (beatCount % 2 === 1) {
const osc2 = audioCtx.createOscillator(); const gain2 = audioCtx.createGain();
osc2.connect(gain2); gain2.connect(audioCtx.destination);
osc2.type = 'square'; osc2.frequency.setValueAtTime(5000, time);
gain2.gain.setValueAtTime(0.05, time); gain2.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
osc2.start(time); osc2.stop(time + 0.05);
}
beatCount++;
}
// --- エフェクト・パーティクルクラス ---
class Particle {
constructor(x, y, type, speedFactor = 1) {
this.x = x; this.y = y; this.type = type; this.life = 1.0;
this.size = Math.random() * 5 + 3; if (type === 'splash') this.size = Math.random() * 8 + 5;
this.vx = (Math.random() - 0.5) * 4 * speedFactor; this.vy = type === 'bubble' ? Math.random() * 3 + 2 : Math.random() * 2 + 1;
if (type === 'splash') { this.vx = (Math.random() - 0.5) * 15; this.vy = -Math.random() * 10 - 5; }
this.color = type === 'bubble' ? `rgba(255, 255, 255, ${0.5 + Math.random()*0.3})` : `rgba(202, 240, 248, ${0.4})`;
if (type === 'splash') this.color = `rgba(224, 251, 252, ${0.7})`;
}
update(dt) {
this.x += this.vx; this.y += this.vy; if (this.type === 'bubble' || this.type === 'splash') this.vy *= 0.95;
if (this.type === 'bubble') this.size += 0.1; else if (this.type === 'splash') this.size *= 0.98; else this.size += (dt / 1000) * 100;
this.life -= (dt / 1000) * (this.type === 'splash' ? 3.0 : 2.0);
}
draw(ctx) {
ctx.fillStyle = this.color;
if (this.type === 'bubble' || this.type === 'splash') { ctx.beginPath(); ctx.arc(this.x, this.y, Math.max(0, this.size), 0, Math.PI * 2); ctx.fill(); }
else if (this.type === 'ripple') { ctx.strokeStyle = `rgba(202, 240, 248, ${this.life * 0.4})`; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(this.x, this.y, Math.max(0, this.size), 0, Math.PI * 2); ctx.stroke(); }
}
}
// --- ゲーム状態・定数 ---
let state = 'TITLE';
let distance = 0, speed = 0, timeMs = 0, lastTime = 0, combo = 0, screenShake = 0;
// ゴースト達の状態
let ghostDistance = 0, immDistance = 0;
let cpuDistance = 0, cpuSpeed = 0, cpuStrokeTimer = 0, cpuCombo = 0, cpuTimingRadius = 200, cpuFinished = false, cpuTimeMs = 0;
// 難易度調整用:GREAT連続判定
let rhythmCombo = 0;
const targetRadius = 45;
let timingRadius = 200;
let gameTime = 0, strokeTimer = 0, popups = [], particles = [];
// 自己ベストと記録表示
let bestTime = localStorage.getItem('breaststroke_best') || null;
function updateBestTimeDisplay() {
const display = document.getElementById('best-time-display');
if (bestTime) {
display.innerHTML = `BEST: ${(bestTime/1000).toFixed(2)}s <button id="reset-best-btn">リセット</button>`;
document.getElementById('reset-best-btn').addEventListener('click', resetBestRecord);
} else {
display.innerHTML = `BEST: --:--`;
}
}
updateBestTimeDisplay();
// 記録リセット関数
function resetBestRecord() {
localStorage.removeItem('breaststroke_best');
bestTime = null;
updateBestTimeDisplay();
addPopup("RECORD RESET", "255, 255, 255", canvas.width/2, 280, 1);
}
// ゴースト用データ
let historyBuffer = [], currentRaceData = [], lastRaceData = [];
let raceResults = [];
// プレイヤー位置と定数
const playerY = 400;
const PPM = 150;
const laneWidth = 160;
const lane1X = laneWidth * 0.5; // 前回ベスト
const lane2X = laneWidth * 1.5; // 残像
const lane3X = laneWidth * 2.5; // 自分
const lane4X = laneWidth * 3.5; // CPU
const playerX = lane3X;
// --- 描画関数群 ---
function drawSwimmer(cx, cy, animTimer, type = null) {
function drawPart(x, y, w, h, color, rotation = 0) {
ctx.fillStyle = color; ctx.save(); ctx.translate(x, y); ctx.rotate(rotation * Math.PI / 180); ctx.beginPath(); ctx.ellipse(0, 0, w/2, h/2, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore();
}
let skin = '#f7d1b6', cap = '#caf0f8', goggle = '#03045e', swimSuits = '#0077b6';
ctx.save();
if (type === 'previous') { ctx.globalAlpha = 0.4; skin = '#ffd166'; cap = '#ef476f'; swimSuits = '#118ab2'; }
else if (type === 'immediate') { ctx.globalAlpha = 0.3; skin = '#8ecae6'; cap = '#ffffff'; swimSuits = '#219ebc'; }
else if (type === 'cpu') { ctx.globalAlpha = 0.9; skin = '#f7d1b6'; cap = '#cfffb3'; swimSuits = '#38b000'; }
let torsoH = 100, torsoW = 40; if (animTimer > 0.3 && animTimer <= 0.6) torsoH = 95; drawPart(cx, cy, torsoW, torsoH, swimSuits);
const headSize = 25; headY = cy - torsoH/2 - headSize/2 + 5; if (animTimer > 0.4 && animTimer <= 0.7) headY += 5;
drawPart(cx, headY, headSize, headSize + 5, skin); drawPart(cx, headY - headSize/4, headSize + 2, headSize/2 + 2, cap); drawPart(cx, headY + headSize/6, headSize, headSize/3, goggle);
function drawArms(phase) {
let armW = 45, armH = 12, shoulderX = torsoW / 2 - 5, shoulderY = cy - torsoH / 2 + 10, armAngle = -90;
if (phase <= 0.3) { const t = phase / 0.3; armAngle = -90 + t * 45; armAngle = Math.min(-45, armAngle); }
else if (phase <= 0.5) { const t = (phase - 0.3) / 0.2; armAngle = -45 + t * 135; armH = 14; armW = 40; }
else if (phase <= 0.7) { const t = (phase - 0.5) / 0.2; armAngle = 90 + t * (-180); shoulderY -= t * 5; }
drawPart(cx - shoulderX - armW/2, shoulderY, armW, armH, skin, armAngle); drawPart(cx + shoulderX + armW/2, shoulderY, armW, armH, skin, -armAngle);
}
drawArms(animTimer);
function drawLegs(phase) {
let legW = 50, legH = 14, hipX = torsoW / 2 - 10, hipY = cy + torsoH / 2 - 15, legAngle = 90;
if (phase <= 0.3) { legAngle = 90; } else if (phase <= 0.5) { const t = (phase - 0.3) / 0.2; legAngle = 90 - t * 45; hipY -= t * 5; } else if (phase <= 0.7) { const t = (phase - 0.5) / 0.2; legAngle = 45 + t * 45; hipX -= t * 5; } else if (phase <= 0.9) { const t = (phase - 0.7) / 0.2; legAngle = 90; hipX -= (1-t) * 5; }
drawPart(cx - hipX - legW/2, hipY, legW, legH, skin, legAngle); drawPart(cx + hipX + legW/2, hipY, legW, legH, skin, -legAngle);
}
drawLegs(animTimer); ctx.restore();
}
function drawFlatPool(time, dist) {
const wallY = playerY - (50 - dist) * PPM;
const tileSize = 60; let yOffset = (dist * PPM) % (tileSize * 2);
for (let y = -yOffset - 1000; y < 1000; y += tileSize) {
if (y < wallY - tileSize) continue;
for (let x = -800; x < 1600; x += tileSize) {
if (((Math.floor(x / tileSize) + Math.floor((y+yOffset) / tileSize))) % 2 === 0) ctx.fillStyle = '#0077b6'; else ctx.fillStyle = '#0073a5';
let warpX = Math.sin(y / 100 + time) * 3, warpY = Math.cos(x / 100 + time) * 3; ctx.fillRect(x + warpX, y + warpY, tileSize + 1, tileSize + 1);
}
}
let tMarkY = wallY + 2 * PPM;
if (tMarkY > -1000 && tMarkY < 1000) {
ctx.fillStyle = 'rgba(2, 48, 71, 0.6)';
const drawTMark = (laneCenter) => { ctx.fillRect(laneCenter - 5, tMarkY, 10, PPM); ctx.fillRect(laneCenter - 30, tMarkY, 60, 10); };
drawTMark(lane1X); drawTMark(lane2X); drawTMark(lane3X); drawTMark(lane4X);
}
ctx.globalCompositeOperation = 'lighter'; ctx.fillStyle = `rgba(202, 240, 248, ${0.15})`;
for (let i = 0; i < 15; i++) {
let x = (Math.sin(time + i * 3) * 800), y = (time * 100 + i * 200) % 1500 - 500;
if (y > wallY) { ctx.beginPath(); ctx.arc(x, y, Math.random()*40 + 40, 0, Math.PI*2); ctx.fill(); }
}
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = '#d62828'; ctx.fillRect(-800, -2000, 2400, 2000 + wallY);
ctx.fillStyle = '#f8edeb'; ctx.fillRect(-800, wallY - 10, 2400, 10);
ctx.fillStyle = '#fcbf49';
ctx.fillRect(lane1X - 40, wallY, 80, 20); ctx.fillRect(lane2X - 40, wallY, 80, 20); ctx.fillRect(lane3X - 40, wallY, 80, 20); ctx.fillRect(lane4X - 40, wallY, 80, 20);
function drawFlatLaneRope(x) {
let floatH = 30, floatY = (dist * PPM) % (floatH * 2);
for (let y = -floatY - 1000; y < 1000; y += floatH) {
if (y < wallY) continue;
ctx.fillStyle = Math.floor((y+floatY) / floatH) % 2 === 0 ? '#fca311' : '#fefae0';
ctx.fillRect(x - 8, y, 16, floatH - 2);
}
}
drawFlatLaneRope(laneWidth * 0); drawFlatLaneRope(laneWidth * 1); drawFlatLaneRope(laneWidth * 2); drawFlatLaneRope(laneWidth * 3); drawFlatLaneRope(laneWidth * 4);
}
// ランク判定(UI表示と完全同期)
function getRank(time) {
const s = time / 1000;
if (s < 24) return { r: 'S++', c: '#ff0054', desc: 'WORLD RECORD CLASS' }; // 男子世界記録超え
if (s < 28) return { r: 'S+', c: '#fca311', desc: 'WORLD CLASS' }; // 女子世界記録並み
if (s < 34) return { r: 'S', c: '#ff6e7f', desc: 'PRO' }; // プロ級
if (s < 42) return { r: 'A', c: '#48cae4', desc: 'ADVANCED' }; // 上級者
if (s < 50) return { r: 'B', c: '#aaaaaa', desc: 'INTERMEDIATE' }; // 中級者
return { r: 'C', c: '#aaaaaa', desc: 'BEGINNER' }; // それ以上
}
// レース結果の集計
function processResults() {
raceResults = [
{ name: '自分', type: 'player', time: timeMs, dist: distance, finished: true },
{ name: 'ライバル', type: 'rival', time: cpuFinished ? cpuTimeMs : timeMs, dist: cpuDistance, finished: cpuFinished }
];
if (bestTime) { raceResults.push({ name: '前回ベスト', type: 'best', time: bestTime, dist: 50, finished: true }); }
raceResults.sort((a, b) => {
if (a.finished && b.finished) { return a.time - b.time; }
if (a.finished) { return -1; } if (b.finished) { return 1; }
return b.dist - a.dist;
});
}
// --- メインループ ---
function update(timestamp) {
if (!lastTime) lastTime = timestamp;
const dt = timestamp - lastTime;
lastTime = timestamp; gameTime += dt / 1000;
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height);
let tempoFactor = 1 + Math.min(combo * 0.05, 1.0);
// --- BGM ---
if (state === 'PLAY') {
const secondsPerBeat = 0.4 / tempoFactor;
while (nextNoteTime < audioCtx.currentTime + 0.1) { playBgmBeat(nextNoteTime, tempoFactor); nextNoteTime += secondsPerBeat; }
}
// --- 画面揺れ ---
if (screenShake > 0) {
const sx = (Math.random() - 0.5) * screenShake, sy = (Math.random() - 0.5) * screenShake;
ctx.translate(sx, sy); screenShake *= 0.9;
if (screenShake < 0.5) screenShake = 0;
}
let grad = ctx.createLinearGradient(0, 0, 0, canvas.height); grad.addColorStop(0, '#000814'); grad.addColorStop(1, '#003566'); ctx.fillStyle = grad; ctx.fillRect(0, 0, canvas.width, canvas.height);
if (state === 'PLAY') {
timeMs += dt; distance += (speed * dt) / 1000;
// --- 物理演算の本気ブラッシュアップ(リアリティ追求) ---
// drag値が1.0に近いほど失速しない。現実的なタイム(25〜30秒)にするため抵抗を強めた。
let drag = 0.6; // グライド中の抵抗(毎秒40%減速するリアルな水の抵抗)
if (strokeTimer > 0.0 && strokeTimer <= 0.8) drag = 0.2; // 掻き・キック中はさらに抵抗大
speed *= Math.pow(drag, dt / 1000);
// リング収縮(難易度調整:RHYTHM COMBOで加速。上限を250に調整して人間が反応できる限界に)
let shrinkRate = 80 + Math.min(rhythmCombo * 15, 250);
timingRadius -= shrinkRate * (dt / 1000);
if (timingRadius < targetRadius - 15) { // ミス
addPopup("MISS...", "255, 80, 80", playerX, 280, 1);
speed *= 0.5; // 自然な失速(急停止0.1ではなく、惰性を残す0.5に変更)
combo = 0; playSound('miss'); timingRadius = 200; strokeTimer = 0.01; screenShake = 0;
rhythmCombo = 0;
}
if (strokeTimer > 0.0) {
strokeTimer += (dt / 1000) * (2.0 + combo * 0.08);
if (strokeTimer >= 1.0) strokeTimer = 0.0;
}
currentRaceData.push({ time: timeMs, dist: distance, anim: strokeTimer });
historyBuffer.push({ time: timeMs, dist: distance, anim: strokeTimer });
while (historyBuffer.length > 0 && timeMs - historyBuffer[0].time > 500) historyBuffer.shift();
// --- ゴースト達 ---
if (lastRaceData.length > 0) {
let ghostFrame = lastRaceData[lastRaceData.length - 1]; ghostFinished = true;
for(let i=0; i<lastRaceData.length; i++) { if (lastRaceData[i].time >= timeMs) { ghostFrame = lastRaceData[i]; ghostFinished = false; break; } }
ghostDistance = ghostFrame.dist; ghostAnim = ghostFrame.anim;
}
immDist = distance; immAnim = strokeTimer;
if (historyBuffer.length > 0) { immDist = historyBuffer[0].dist; immAnim = historyBuffer[0].anim; }
// --- CPUライバル ---
if (!cpuFinished) {
cpuTimeMs += dt; cpuDistance += (cpuSpeed * dt) / 1000;
// CPUもプレイヤーと同じ適正化された物理
let cpuDrag = 0.6; if (cpuStrokeTimer > 0.0 && cpuStrokeTimer <= 0.8) cpuDrag = 0.2;
cpuSpeed *= Math.pow(cpuDrag, dt / 1000);
let cpuShrinkRate = 80 + Math.min(cpuCombo * 15, 250);
cpuTimingRadius -= cpuShrinkRate * (dt / 1000);
if (cpuTimingRadius <= targetRadius) { cpuPerformInput(); cpuTimingRadius = 200; }
if (cpuTimingRadius < targetRadius - 15) {
cpuSpeed *= 0.5; cpuCombo = 0; cpuTimingRadius = 200; cpuStrokeTimer = 0.01;
addPopup("MISS", hexToRgb('#ffcccc'), lane4X, 350, 0.8, false);
}
if (cpuStrokeTimer > 0.0) {
cpuStrokeTimer += (dt / 1000) * (2.0 + cpuCombo * 0.08);
if (cpuStrokeTimer >= 1.0) cpuStrokeTimer = 0.0;
}
if (cpuDistance >= 50) { cpuDistance = 50; cpuFinished = true; cpuStrokeTimer = 0.0; cpuTimingRadius = 200; addPopup("GOAL", "cfffb3", lane4X, 100, 1.2, false); }
}
// ゴール判定
if (distance >= 50) {
distance = 50; state = 'GOAL'; strokeTimer = 0.0; speed = 0; timingRadius = 200;
lastRaceData = [...currentRaceData];
if (!bestTime || timeMs < bestTime) {
bestTime = timeMs; localStorage.setItem('breaststroke_best', bestTime);
updateBestTimeDisplay();
addPopup("NEW BEST!", "255, 255, 255", canvas.width/2, 180, 2);
}
processResults();
rhythmCombo = 0;
}
}
// 疑似3Dトランスフォーム
ctx.save(); ctx.translate(canvas.width / 2, canvas.height * 0.55); ctx.scale(1, 0.5); ctx.rotate(-25 * Math.PI / 180); ctx.translate(-canvas.width / 2, -canvas.height * 0.55);
drawFlatPool(gameTime, distance);
if (state === 'PLAY' || state === 'GOAL') { particles.forEach(p => { p.update(dt); p.draw(ctx); }); particles = particles.filter(p => p.life > 0); }
if (state === 'PLAY' || state === 'GOAL') {
if (lastRaceData.length > 0 && ghostDistance < 50) {
let prevY = playerY + (distance - ghostDistance) * PPM;
if (prevY > -1000 && prevY < 2000) drawSwimmer(lane1X, prevY, ghostAnim, 'previous');
}
if (immDist < 50) {
let immY = playerY + (distance - immDist) * PPM;
drawSwimmer(lane2X, immY, immAnim, 'immediate');
}
if (cpuDistance < 50) {
let cpuY = playerY + (distance - cpuDistance) * PPM;
if (cpuY > -1000 && cpuY < 2000) drawSwimmer(lane4X, cpuY, cpuStrokeTimer, 'cpu');
}
}
drawSwimmer(playerX, playerY, strokeTimer === 0.0 ? 0.0 : strokeTimer);
if (state === 'PLAY') {
ctx.beginPath(); ctx.arc(playerX, playerY, targetRadius, 0, Math.PI * 2);
ctx.strokeStyle = combo >= 10 ? 'rgba(255, 0, 84, 0.9)' : 'rgba(255, 230, 0, 0.9)';
ctx.lineWidth = 6; ctx.stroke();
if (timingRadius > 0) { ctx.beginPath(); ctx.arc(playerX, playerY, Math.max(0, timingRadius), 0, Math.PI * 2); ctx.strokeStyle = '#fff'; ctx.lineWidth = 4; ctx.stroke(); }
}
ctx.restore();
// --- UI(平面) ---
ctx.fillStyle = '#fff'; ctx.font = 'bold 24px Montserrat'; ctx.textAlign = 'left'; ctx.fillText(`${distance.toFixed(1)}m`, 15, 35);
ctx.textAlign = 'right'; ctx.fillText(`${(timeMs / 1000).toFixed(2)}s`, canvas.width - 15, 35);
if (combo >= 2) {
ctx.textAlign = 'center'; ctx.fillStyle = combo >= 10 ? '#ff0054' : '#fca311'; ctx.font = 'bold 40px Montserrat';
ctx.fillText(`${combo} COMBO!`, canvas.width / 2, 85);
}
if (rhythmCombo >= 2) {
ctx.textAlign = 'center'; ctx.fillStyle = '#ff6e7f'; ctx.font = 'bold 24px Montserrat';
ctx.fillText(`RHYTHM x${rhythmCombo}`, canvas.width / 2, 120);
}
ctx.textAlign = 'center'; popups.forEach(p => {
p.y -= (dt / 1000) * (p.mainPlayer ? 40 : 20); p.life -= (dt / 1000) * (p.mainPlayer ? 1.5 : 2.5);
ctx.fillStyle = `rgba(${p.color}, ${p.life})`;
ctx.font = p.mainPlayer ? (p.scale === 2 ? 'bold 48px Montserrat' : 'bold 32px Montserrat') : 'bold 24px Montserrat';
ctx.fillText(p.text, p.x, p.y);
}); popups = popups.filter(p => p.life > 0);
if (state === 'TITLE') {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#b9faf8'; ctx.font = 'bold 32px Montserrat'; ctx.fillText("CLICK TO START", canvas.width/2, canvas.height/2);
} else if (state === 'GOAL') {
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px Montserrat'; ctx.fillText("RANK", 130, 160);
const rank = getRank(timeMs); ctx.fillStyle = rank.c; ctx.font = 'bold 100px Montserrat'; ctx.fillText(rank.r, 130, 260);
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px Montserrat'; ctx.fillText(rank.desc, 130, 310);
ctx.font = 'bold 32px Montserrat'; ctx.fillText(`TIME: ${(timeMs / 1000).toFixed(2)}s`, 130, 360);
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px Montserrat'; ctx.fillText("RACE RESULTS", 430, 160);
ctx.textAlign = 'left'; ctx.font = 'bold 24px Montserrat';
raceResults.forEach((res, i) => {
let color = '#fff'; if (res.type === 'player') color = '#fff'; else if (res.type === 'cpu') color = '#cfffb3'; else if (res.type === 'best') color = '#ffd166';
ctx.fillStyle = color;
ctx.fillText(`${i + 1}`, 300, 200 + i * 40);
ctx.fillText(`${res.name}`, 340, 200 + i * 40);
ctx.textAlign = 'right'; ctx.fillText(res.finished ? `${(res.time / 1000).toFixed(2)}s` : `${res.dist.toFixed(1)}m`, 560, 200 + i * 40);
ctx.textAlign = 'left';
});
ctx.textAlign = 'center'; ctx.fillStyle = '#fff'; ctx.font = 'bold 20px Montserrat'; ctx.fillText("CLICK TO RETRY", canvas.width/2, 440);
}
requestAnimationFrame(update);
}
function addPopup(text, color, x, y, scale = 1, mainPlayer = true) {
if (!color.includes(',')) color = hexToRgb(color); popups.push({ text, color, x, y, life: mainPlayer ? 1.2 : 1.0, scale, mainPlayer });
}
function hexToRgb(hex) {
hex = hex.replace('#',''); var r = parseInt(hex.substring(0,2), 16); var g = parseInt(hex.substring(2,4), 16); var b = parseInt(hex.substring(4,6), 16);
return r + ',' + g + ',' + b;
}
function triggerEffects(type, x, y, intensity = 1) {
if (type === 'pull') {
for (let i = 0; i < 5 * intensity; i++) particles.push(new Particle(x - 30 + i * 15, y - 20, 'ripple', intensity));
if (intensity > 1.5) { for (let i = 0; i < 20; i++) particles.push(new Particle(x, y - 20, 'splash')); }
} else if (type === 'kick') { for (let i = 0; i < 15 * intensity; i++) particles.push(new Particle(x, y + 30, 'bubble', intensity)); }
}
// --- CPU入力 ---
function cpuPerformInput() {
const r = Math.random();
let great_chance = 0.20 + Math.min(cpuCombo * 0.02, 0.40);
let perfect_chance = great_chance + 0.35 - Math.min(cpuCombo * 0.01, 0.20);
if (r <= great_chance) {
cpuSpeed += 2.2; cpuCombo++;
addPopup("GREAT", hexToRgb('#cfffb3'), lane4X, 350, 0.8, false);
cpuStrokeTimer = 0.01; triggerEffects('pull', lane4X, playerY, 2);
} else if (r <= perfect_chance) {
cpuSpeed += 1.6; cpuCombo++;
addPopup("PERFECT", hexToRgb('#fcbf49'), lane4X, 350, 0.8, false);
cpuStrokeTimer = 0.01; triggerEffects('pull', lane4X, playerY, 1);
} else {
const r2 = Math.random();
if (r2 < 0.05) {
cpuSpeed *= 0.5; cpuCombo = 0; cpuStrokeTimer = 0.01;
addPopup("MISS", hexToRgb('#ffcccc'), lane4X, 350, 0.8, false);
} else {
cpuSpeed += 1.0; cpuStrokeTimer = 0.01;
addPopup("GOOD", hexToRgb('#48cae4'), lane4X, 350, 0.8, false);
triggerEffects('pull', lane4X, playerY, 0.8);
}
}
}
// --- 入力処理 ---
function handleInput(e) {
if (e.type === 'keydown' && e.code !== 'Space') return; if (e.type === 'keydown') e.preventDefault();
if (state === 'TITLE') { audioCtx.resume(); state = 'PLAY'; nextNoteTime = audioCtx.currentTime; beatCount = 0; lastTime = performance.now(); return; }
if (state === 'GOAL') {
distance = 0; speed = 0; timeMs = 0; combo = 0; timingRadius = 200; popups = []; particles = []; strokeTimer = 0.0; historyBuffer = []; currentRaceData = []; screenShake = 0;
cpuDistance = 0; cpuSpeed = 0; cpuStrokeTimer = 0; cpuCombo = 0; cpuTimingRadius = 200; cpuFinished = false; cpuTimeMs = 0;
ghostDistance = 0; immDistance = 0; raceResults = []; rhythmCombo = 0;
state = 'PLAY'; nextNoteTime = audioCtx.currentTime; beatCount = 0; lastTime = performance.now(); return;
}
if (state === 'PLAY') {
const diff = Math.abs(timingRadius - targetRadius); timingRadius = 200;
triggerEffects('pull', playerX, playerY);
const pitchOffset = 1 + Math.min(combo * 0.02, 0.5);
if (diff <= 8) {
// GREAT PERFECT
speed += 2.2; combo++; rhythmCombo++;
addPopup("GREAT!!", "ff0054", canvas.width/2, 300, 2); playSound('greatPerfect', pitchOffset);
strokeTimer = 0.01; triggerEffects('pull', playerX, playerY, 2); screenShake = 15;
} else if (diff <= 20) {
// PERFECT
speed += 1.6; combo++; rhythmCombo++;
addPopup("PERFECT!", "fca311", canvas.width/2, 300, 1); playSound('perfect', pitchOffset);
strokeTimer = 0.01; triggerEffects('pull', playerX, playerY, 1); screenShake = 3;
} else if (diff <= 45) {
// GOOD
speed += 1.0; addPopup("GOOD", "48cae4", canvas.width/2, 300, 1); playSound('good', pitchOffset);
strokeTimer = 0.01; triggerEffects('pull', playerX, playerY, 0.8);
rhythmCombo = 0;
} else {
// MISS: 推進力を完全に失うのではなく、自然に失速する(0.5倍)
speed *= 0.5; combo = 0; addPopup("MISS...", "255, 80, 80", canvas.width/2, 300, 1); playSound('miss'); strokeTimer = 0.01; screenShake = 0;
rhythmCombo = 0;
}
}
}
window.addEventListener('keydown', handleInput); canvas.addEventListener('mousedown', handleInput);
requestAnimationFrame(update);
</script>
</body>
</html>


■ 今日の学び

今回のゲーム開発でこだわったポイントは、
「一人プレイなのに、たくさんの相手と競っている熱気を出したい!」
というところでした。

過去の自分(ベスト記録)や、少し前の自分の残像、
そして手強い CPU ライバルを同じ画面で泳がせることで、
一人でも孤独にならず、
白熱したデッドヒートが楽しめる仕上がりになりました。

見た目にも涼しげなプールの水面が表現できて、
個人的にもとても気に入っている作品です。(˘︶˘)

さて……実は今回、このゲームが完成したあとに、
ひとつ「実験」をしてみました。

順調に泳ぐスイマーたちを眺めているうちに、
ふと魔が差してしまい、AI にこんなお願いをしたのです。

「人間の水泳選手を、アメンボに変えてみてください。」

すると……

( ꒪⌓꒪)

さすがの AI も、
この突然すぎる無茶ぶりにはかなり困ってしまったようです。

平泳ぎの複雑なアニメーションから、
急に昆虫の水上滑走へ変更するという大転換。

思考がショートしてしまったのか、
その後のコメントもコードもバグだらけになり、
まったく収拾がつかない状態になってしまいました。

( ᐛ )パァ

普段はどんな難題でもさらりと答えてくれる AI ですが、
話の流れやタイミングが急に噛み合わなくなると、

こんなふうにパニックを起こすこともあるんだなぁ……と、
少し人間らしさを感じて、面白い学びになりました。

その時の、ちょっとカオスになってしまった様子の画像も
下に紹介しておきますね。
AI が必死にアメンボを表現しようとして迷走した跡を、
ぜひご覧ください(笑)。

(画像:困惑するAI)

■ 次回は、大好きな”桃まん”をゲームにしてみました。

次回は、私が大好きな 桃まん をテーマにした、
ちょっとユニークなゲームをご紹介します。

中華鍋を熱して、制限時間内に
たくさんの桃まんをはじけさせるアクションゲームです。

ポイントは、中華鍋を上手に振ること。

タイミングよく鍋をあおると、桃まんがポンッと飛び跳ねて、
とても楽しい仕上がりになっています。

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

▶ 次の実験はこちら
(はじける・飛び出す・中華鍋)
  「桃まんポップコーン」



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