実験#111:しょうが君のファイヤー爆走(しょうが君モトクロス)

■111個目 「しょうが君モトクロス」

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

今回は、3Dの本格モトクロスゲームに挑戦してみました。

青空の下、大自然のダートコースを爆走するのは……なんと “しょうが” です!

冒険者風の帽子をかぶり、
満面の笑顔(^▽^)を浮かべた「しょうが君」が、
バイクに乗ってゴールを目指します。

今回こだわったのは、可愛い見た目とは真逆の「過激なコース」。

「こんなにニコニコしているのに、走る道がとんでもなく険しかったらどうなるんだろう?」
そんな遊び心から生まれたアクションゲームになっています。(=v=*)

コースには、スリル満点の難所が次々と登場します。

空高く飛び出す大ジャンプ。
落ちたら一貫の終わりの極細カーブ。
そして、壁のように立ちはだかる巨大な上り坂……。

どうしても登れないピンチの時は、
しょうが君が全身真っ赤に燃え上がる

必殺技 「しょうが君ファイヤー」で、
力ずくで突破することもできます。(´艸`)

さらに道中には、「虹色しましまコーン」が全部で25個散らばっています。
険しい道を乗り越えながら、果たして全部集めきれるでしょうか……?

土埃が舞う大自然の風を、ぜひ画面越しに感じてみてくださいね。(´▽`)
プレイしていただけたら、とってもうれしいです。

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

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

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

モトクロスをゲームで再現したいです。
コースもリアルに立体的に再現お願い。

運転しているのはしょうが君。

“しょうが君ファイヤー”で全身燃え上がり、
難所もパワフルにクリアできます。

AI: 「しょうが君」が主役のモトクロスとは、なかなかいいチョイスだ。

険しい山道をバイクで爆走する姿……想像しただけで胸が熱くなるよ。

リクエスト通り、ブラウザ上でリアルに動く
3Dの立体コース を用意してくれました。

もちろん必殺技「しょうが君ファイヤー」もバッチリ搭載です。

● 操作性とスピード感の強化

次は操作性と爽快感について相談しました。

みー:(^v^) マウスの動きで左右にハンドル操作できるようにしましょう。
通常のアクセルが少し弱すぎるかな。

スピード感もう少し欲しいです。
スピードが乗った状態で起伏の頂点に達するとジャンプもできるといいね。

AI: いらっしゃい!ますますゲーム作りに熱が入ってきたね。

「スピード感の向上」
「マウスでのハンドル操作」
「ジャンプ挙動の追加」を全部盛り込み、

さらにカメラアングルを「斜め後ろ」からの
迫力ある視点に変更してくれました。

物理演算で 慣性ジャンプ ができるようになり、
爽快感が一気にアップしました。

● リトライ機能と操作の調整

みー:(^_^) ハンドルの切り具合が弱いかな。
もう少しジャンプしやすくしたら爽快かな。

コースアウトしたらゲームオーバー。
リトライボタンもお願い。

AI: 具体的なフィードバックありがとう!

  • リトライボタン を追加
  • ハンドル操作をよりクイックに
  • ジャンプの慣性を調整して爽快感アップ
  • コースアウト判定を追加して緊張感をプラス

ゲームらしさが一気に増しました。

● マウスだけでフル操作できるように

みー:( ̄v ̄) 飛ぶごとに飛距離がエスカレートしすぎちゃうね。
吹っ飛び感が加速しすぎないように調整して。

あとアクセルとブレーキを
マウスの右と左クリックにも対応できるようにしましょう。

ファイヤーはマウスホイール押し込みで対応できるかな。

AI: なるほど、いわゆる「トランポリンバグ」だね。
ジャンプの上向き力に上限を設けて、
制御不能にならないように調整したよ。

さらに マウスのみでフル操作可能 に!

  1. 左クリック:アクセル
  2. 右クリック:ブレーキ
  3. ホイール押し込み:しょうが君ファイヤー
  4. マウス移動:ハンドル操作

キーボードでも遊べるようにしつつ、
右クリックメニューが出ないように配慮してくれました。

● 虹色コーンのスコアシステム

みー:(^o^) コース上に虹色コーンを置いて、
ゴールまでにとった数がスコアになるようにしましょう。

AI: スコアアタック要素、大賛成だ。

  • コースの難所に合わせて 25個の虹色コーンを自動配置
  • 取るとキラッと光ってスコア +1
  • ゴールやゲームオーバー時に最終スコアを表示

「狙わないと取れない位置」に置かれていて、
やり込み度がぐっと上がりました。

ここまでのやりとりを経て、
しょうが君モトクロスは 本格3Dアクションゲーム に進化しました。(^▽^)

このあと、コードを差し替えて完成版を遊べるようになります。
ぜひ、全25個の虹色コーン制覇を目指して爆走してみてくださいね!


■ 実際の動作

まずは、AIが最初に作ってくれた試作版のゲーム画面です。
試作段階では、まだ操作性やコースの作り込みがシンプルでしたが、
ここからどんどん改良が進んでいきました。

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


こちらが、仕上がりのゲーム画面です。
マウスだけで運転操作ができます。

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


完成版では、起伏や傾斜、カーブがあったり、道幅が変わったりと、
本格的なモトクロスらしい立体コースになりました。

(画像:プレイ中の画面)




しょうが君ファイヤーで爆走しすぎると、
虹色コーンを通り過ぎてしまうこともあるので気をつけてくださいね。

(画像:ファイヤー爆走画面)


コースアウトするとゲームオーバーになります。
リトライボタンからすぐに再挑戦できます。

(画像:ゲームオーバー)


■ コードのポイント

今回の記事では、
AI と相談しながら作った「しょうが君モトクロス」 の HTML コードを公開します。

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

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

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

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

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

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

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

■ 遊び方・操作方法

それでは、ゲームの遊び方と操作方法をご説明しますね。(^^*)

今回は、マウスひとつで直感的に遊べるようになっています!

【操作方法】

🏍️ アクセル:左クリック(押しっぱなしで進みます)
🛑 ブレーキ:右クリック(カーブや下り坂で必須です!)
↔️ ハンドル操作:マウスを左右に動かす
🔥 必殺技「しょうが君ファイヤー」:ホイールクリック(中クリック)

※キーボードでも遊べます
右キー:アクセル
左キー:ブレーキ
スペースキー:必殺技

【遊び方のコツ】

コースの幅が急に狭くなる「極細コース」や、
急なカーブが連続する場所では、
そのまま突っ込むと崖から落ちてゲームオーバーになってしまいます。(><)

右クリックでしっかりブレーキをかけて、
マウスで道の中央をキープするのが完走のコツです。(^_^)

【必殺技の使いどころ】

目の前にそびえ立つ「巨大な上り坂」が現れたら……必殺技の出番です!

ホイールクリックを長押しすると、
しょうが君が真っ赤に燃え上がり、
ものすごいパワーで坂を駆け上がります。(=v=*)🔥

【虹色しましまコーンについて】

道中には「虹色しましまコーン」が全部で 25個 あります。

大自然のツーリング(?)を楽しみながら、
ぜひハイスコアを目指してみてくださいね。(´▽`)


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

▼ここからコード▼(クリックで開く)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>しょうが君モトクロス 3D</title>
<style>
body { margin: 0; overflow: hidden; background: #87ceeb; cursor: crosshair; user-select: none; }
#ui {
position: absolute; top: 15px; left: 15px;
color: #fff; font-family: sans-serif;
text-shadow: 2px 2px 4px #000; z-index: 10;
pointer-events: none;
}
h2 { margin: 0 0 10px 0; color: #ffeb3b; }
.controls { background: rgba(0,0,0,0.5); padding: 15px; border-radius: 8px; }
.fire-text {
color: #ff5500; font-weight: bold; display: none;
font-size: 28px; margin-top: 10px;
animation: blink 0.2s infinite alternate;
}
@keyframes blink { from { opacity: 1; } to { opacity: 0.6; } }
#score { font-size: 20px; font-weight: bold; margin-top: 10px; }
#coinScore { font-size: 22px; font-weight: bold; margin-top: 5px; color: #00e676; }
#status { color: #81c784; font-size: 16px; margin-top: 5px; font-weight: bold; }
#gameOverModal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8); color: #fff; font-family: sans-serif;
display: none; flex-direction: column; justify-content: center; align-items: center;
z-index: 100; pointer-events: auto;
}
#gameOverModal h1 { font-size: 64px; color: #f44336; margin-bottom: 20px; text-shadow: 0 0 10px #f44336; }
#gameOverModal p { font-size: 24px; margin-bottom: 20px; }
#retryBtn {
padding: 15px 40px; font-size: 24px; font-weight: bold;
background: #ff9800; color: #fff; border: none; border-radius: 10px;
cursor: pointer; box-shadow: 0 4px 10px rgba(255,152,0,0.5);
transition: background 0.2s, transform 0.1s;
}
#retryBtn:hover { background: #ffa726; }
#retryBtn:active { transform: translateY(2px); box-shadow: 0 2px 5px rgba(255,152,0,0.5); }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
</head>
<body>
<div id="ui">
<div class="controls">
<h2>しょうが君モトクロス 3D</h2>
<p>🏍️ <b>アクセル:</b> 左クリック</p>
<p>🛑 <b>ブレーキ:</b> 右クリック <span style="color:#ffcc00;">(細い道やカーブで必須!)</span></p>
<p>↔️ <b>ハンドル:</b> マウス左右</p>
<p>🔥 <b>必殺技:</b> ホイールクリック (大上り坂で活躍)</p>
<div id="score">走行距離: 0m</div>
<div id="coinScore">🌈 コーン: 0 / 25個</div>
<div id="status">接地中</div>
<div id="fireAlert" class="fire-text">🔥🔥 しょうが君ファイヤー発動中!! 🔥🔥</div>
</div>
</div>
<div id="gameOverModal">
<h1 id="modalTitle">GAME OVER</h1>
<p id="gameOverReason">コースアウトして落下した!</p>
<p>走行距離: <span id="finalScore">0</span>m / 獲得コーン: <span id="finalCoins">0</span>個</p>
<button id="retryBtn">リトライ</button>
</div>
<script>
// ==========================================
// 🎵 サウンドシステム (Web Audio API)
// ==========================================
let audioCtx = null;
let bgmTimer = null;
let engineOsc = null, engineGain = null;
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// エンジン音(低音ノイズ+オシレーター)の準備
engineOsc = audioCtx.createOscillator();
engineGain = audioCtx.createGain();
engineOsc.type = 'sawtooth';
engineOsc.frequency.setValueAtTime(60, audioCtx.currentTime);
engineGain.gain.setValueAtTime(0, audioCtx.currentTime);
engineOsc.connect(engineGain);
engineGain.connect(audioCtx.destination);
engineOsc.start();
}
// BGMシーケンサー(冒険感のあるチップチューン風メロディ)
const bgmNotes = [
330, 392, 440, 523, 440, 392, 330, 294,
330, 392, 440, 587, 523, 440, 392, 440
];
const bassNotes = [
110, 110, 146.83, 146.83, 130.81, 130.81, 98.00, 98.00
];
let bgmStep = 0;
function startBGM() {
if (!audioCtx) return;
if (bgmTimer) clearInterval(bgmTimer);
bgmTimer = setInterval(() => {
if (gameState !== 'playing') return;
let t = audioCtx.currentTime;
// メロディ
let osc = audioCtx.createOscillator();
let gain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(bgmNotes[bgmStep % bgmNotes.length], t);
gain.gain.setValueAtTime(0.04, t);
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.18);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t);
osc.stop(t + 0.2);
// ベース
if (bgmStep % 2 === 0) {
let bOsc = audioCtx.createOscillator();
let bGain = audioCtx.createGain();
bOsc.type = 'triangle';
bOsc.frequency.setValueAtTime(bassNotes[(Math.floor(bgmStep/2)) % bassNotes.length], t);
bGain.gain.setValueAtTime(0.08, t);
bGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
bOsc.connect(bGain);
bGain.connect(audioCtx.destination);
bOsc.start(t);
bOsc.stop(t + 0.35);
}
bgmStep++;
}, 150);
}
function stopBGM() {
if (bgmTimer) {
clearInterval(bgmTimer);
bgmTimer = null;
}
}
// 効果音:ジャンプ音
function playJumpSound() {
if (!audioCtx) return;
let t = audioCtx.currentTime;
let osc = audioCtx.createOscillator();
let gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(150, t);
osc.frequency.exponentialRampToValueAtTime(450, t + 0.25);
gain.gain.setValueAtTime(0.1, t);
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t);
osc.stop(t + 0.3);
}
// 効果音:コーン回収音(キラリーン!)
function playCoinSound() {
if (!audioCtx) return;
let t = audioCtx.currentTime;
[523.25, 659.25, 783.99, 1046.50].forEach((freq, i) => {
let osc = audioCtx.createOscillator();
let gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, t + i * 0.05);
gain.gain.setValueAtTime(0.1, t + i * 0.05);
gain.gain.exponentialRampToValueAtTime(0.0001, t + i * 0.05 + 0.15);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t + i * 0.05);
osc.stop(t + i * 0.05 + 0.15);
});
}
// 効果音:ゲームオーバー音
function playGameOverSound() {
if (!audioCtx) return;
stopBGM();
let t = audioCtx.currentTime;
[300, 250, 200, 150].forEach((freq, i) => {
let osc = audioCtx.createOscillator();
let gain = audioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(freq, t + i * 0.12);
gain.gain.setValueAtTime(0.15, t + i * 0.12);
gain.gain.exponentialRampToValueAtTime(0.0001, t + i * 0.12 + 0.2);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t + i * 0.12);
osc.stop(t + i * 0.12 + 0.2);
});
}
// 効果音:クリア音
function playClearSound() {
if (!audioCtx) return;
stopBGM();
let t = audioCtx.currentTime;
[440, 554.37, 659.25, 880].forEach((freq, i) => {
let osc = audioCtx.createOscillator();
let gain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(freq, t + i * 0.12);
gain.gain.setValueAtTime(0.12, t + i * 0.12);
gain.gain.exponentialRampToValueAtTime(0.0001, t + i * 0.12 + 0.3);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t + i * 0.12);
osc.stop(t + i * 0.12 + 0.3);
});
}
// ==========================================
// 1. セットアップ
// ==========================================
const scene = new THREE.Scene();
const skyColor = 0x87ceeb;
scene.fog = new THREE.Fog(skyColor, 100, 500);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1500);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(skyColor);
document.body.appendChild(renderer.domElement);
document.addEventListener('contextmenu', e => e.preventDefault());
const light = new THREE.DirectionalLight(0xfffbee, 1.2);
light.position.set(100, 200, 50);
scene.add(light);
scene.add(new THREE.AmbientLight(0x888888));
// 2. 地形・コース生成
const trackLength = 20000;
const maxPlayableWidth = 80;
const visualWidth = 600;
function getElevation(x) {
let startBlend = Math.min(1, x / 100);
let bumps = Math.sin(x * 0.05) * 4;
let bigHills = Math.sin(x * 0.005) * 45;
let macroTerrain = Math.sin(x * 0.0015) * 80;
return (bumps + bigHills + macroTerrain) * startBlend;
}
function getCurveCenter(x) {
let startBlend = Math.min(1, x / 150);
let sharpTurns = Math.sin(x * 0.007) * 50;
let wideTurns = Math.sin(x * 0.002) * 60;
return (sharpTurns + wideTurns) * startBlend;
}
function getTrackWidth(x) {
let startBlend = Math.min(1, x / 500);
let variation = Math.sin(x * 0.002) * 18 + Math.sin(x * 0.005) * 12;
let width = 55 + (variation * startBlend);
return Math.max(28, Math.min(80, width));
}
// ① 緑の大地
const geoOuter = new THREE.PlaneGeometry(trackLength, visualWidth, trackLength / 15, 12);
geoOuter.rotateX(-Math.PI / 2);
geoOuter.translate(trackLength / 2 - 20, 0, 0);
const posOuter = geoOuter.attributes.position;
for (let i = 0; i < posOuter.count; i++) {
const vx = posOuter.getX(i);
const vz = posOuter.getZ(i);
posOuter.setY(i, getElevation(vx) - 0.5);
posOuter.setZ(i, vz + getCurveCenter(vx));
}
geoOuter.computeVertexNormals();
scene.add(new THREE.Mesh(geoOuter, new THREE.MeshLambertMaterial({ color: 0x4caf50 })));
// ② 土のコース
const geoTrack = new THREE.PlaneGeometry(trackLength, maxPlayableWidth, trackLength / 15, 2);
geoTrack.rotateX(-Math.PI / 2);
geoTrack.translate(trackLength / 2 - 20, 0, 0);
const posTrack = geoTrack.attributes.position;
for (let i = 0; i < posTrack.count; i++) {
const vx = posTrack.getX(i);
const vz = posTrack.getZ(i);
const normalizedZ = vz / (maxPlayableWidth / 2);
const dynamicWidth = getTrackWidth(vx);
const finalZ = (normalizedZ * (dynamicWidth / 2)) + getCurveCenter(vx);
posTrack.setY(i, getElevation(vx));
posTrack.setZ(i, finalZ);
}
geoTrack.computeVertexNormals();
scene.add(new THREE.Mesh(geoTrack, new THREE.MeshLambertMaterial({ color: 0x8b6239 })));
// ポール
const poleGeo = new THREE.CylinderGeometry(0.5, 0.5, 4, 8);
const poleMatL = new THREE.MeshLambertMaterial({ color: 0xff3333 });
const poleMatR = new THREE.MeshLambertMaterial({ color: 0x3333ff });
const polesGroup = new THREE.Group();
for(let x = 50; x <= trackLength; x += 30) {
let y = getElevation(x);
let z = getCurveCenter(x);
let w = getTrackWidth(x);
let pL = new THREE.Mesh(poleGeo, poleMatL);
pL.position.set(x, y + 2, z - w/2);
polesGroup.add(pL);
let pR = new THREE.Mesh(poleGeo, poleMatR);
pR.position.set(x, y + 2, z + w/2);
polesGroup.add(pR);
}
scene.add(polesGroup);
// 🌈 虹色しましまコーン
const cones = [];
const totalCones = 25;
for (let i = 1; i <= totalCones; i++) {
let cx = 300 + (i * 750) + (Math.random() - 0.5) * 200;
if (cx > trackLength - 500) cx = trackLength - 500;
let cy = getElevation(cx);
let cz = getCurveCenter(cx) + (Math.random() - 0.5) * (getTrackWidth(cx) * 0.6);
const coneGroup = new THREE.Group();
coneGroup.position.set(cx, cy, cz);
coneGroup.scale.set(1.5, 1.5, 1.5);
const stripeColors = [0xff0000, 0xff7f00, 0xffff00, 0x00ff00, 0x0000ff, 0x4b0082, 0x8b00ff];
const levels = 5;
for (let j = 0; j < levels; j++) {
let h = j * 0.5;
let r = 1.0 - (j * 0.18);
let col = stripeColors[j % stripeColors.length];
let ringGeo = new THREE.CylinderGeometry(r, r + 0.05, 0.4, 16);
let ringMat = new THREE.MeshLambertMaterial({ color: col, emissive: 0x222222 });
let ring = new THREE.Mesh(ringGeo, ringMat);
ring.position.y = h + 0.2;
coneGroup.add(ring);
}
let tipGeo = new THREE.ConeGeometry(0.2, 0.6, 16);
let tipMat = new THREE.MeshLambertMaterial({ color: 0xffffff });
let tip = new THREE.Mesh(tipGeo, tipMat);
tip.position.y = 2.7;
coneGroup.add(tip);
let baseGeo = new THREE.CylinderGeometry(1.3, 1.4, 0.15, 16);
let baseMat = new THREE.MeshLambertMaterial({ color: 0x333333 });
let base = new THREE.Mesh(baseGeo, baseMat);
base.position.y = 0.075;
coneGroup.add(base);
scene.add(coneGroup);
cones.push({ mesh: coneGroup, x: cx, z: cz, collected: false });
}
// 森の生成
const treeCount = 2500;
const trunkGeo = new THREE.CylinderGeometry(0.6, 1.0, 4, 8);
const leafGeo = new THREE.DodecahedronGeometry(3.5);
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x4a2e1b });
const leafMat = new THREE.MeshLambertMaterial({ color: 0x2e7d32 });
const trunkMesh = new THREE.InstancedMesh(trunkGeo, trunkMat, treeCount);
const leafMesh = new THREE.InstancedMesh(leafGeo, leafMat, treeCount);
const dummy = new THREE.Object3D();
for (let i = 0; i < treeCount; i++) {
let tx = Math.random() * trackLength;
let centerZ = getCurveCenter(tx);
let w = getTrackWidth(tx);
let sign = Math.random() < 0.5 ? 1 : -1;
let offsetZ = sign * ((w/2 + 10) + Math.random() * (visualWidth/2 - w/2 - 20));
let tz = centerZ + offsetZ;
let ty = getElevation(tx) - 0.5;
let scale = 0.8 + Math.random() * 1.5;
dummy.position.set(tx, ty + 2 * scale, tz);
dummy.scale.set(scale, scale, scale);
dummy.rotation.set(0, Math.random() * Math.PI, 0);
dummy.updateMatrix();
trunkMesh.setMatrixAt(i, dummy.matrix);
dummy.position.set(tx, ty + 5 * scale, tz);
dummy.updateMatrix();
leafMesh.setMatrixAt(i, dummy.matrix);
}
scene.add(trunkMesh);
scene.add(leafMesh);
// 雲の生成
const cloudCount = 300;
const cloudPuffsPerCloud = 4;
const cloudGeo = new THREE.DodecahedronGeometry(1);
const cloudMat = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.9 });
const cloudMesh = new THREE.InstancedMesh(cloudGeo, cloudMat, cloudCount * cloudPuffsPerCloud);
let puffIndex = 0;
for (let i = 0; i < cloudCount; i++) {
let cx = Math.random() * trackLength;
let cy = 120 + Math.random() * 100;
let cz = (Math.random() - 0.5) * 800;
let cloudScale = 15 + Math.random() * 25;
for (let j = 0; j < cloudPuffsPerCloud; j++) {
let px = cx + (Math.random() - 0.5) * cloudScale * 1.5;
let py = cy + (Math.random() - 0.5) * cloudScale * 0.3;
let pz = cz + (Math.random() - 0.5) * cloudScale * 1.5;
let ps = cloudScale * (0.6 + Math.random() * 0.4);
dummy.position.set(px, py, pz);
dummy.scale.set(ps, ps, ps);
dummy.rotation.set(Math.random(), Math.random(), Math.random());
dummy.updateMatrix();
cloudMesh.setMatrixAt(puffIndex++, dummy.matrix);
}
}
scene.add(cloudMesh);
// 3. バイクとしょうが君のモデリング
const bikeGroup = new THREE.Group();
scene.add(bikeGroup);
const wheelGeo = new THREE.CylinderGeometry(1, 1, 0.5, 16);
wheelGeo.rotateX(Math.PI / 2);
const wheelMat = new THREE.MeshLambertMaterial({ color: 0x111111 });
const wheelF = new THREE.Mesh(wheelGeo, wheelMat);
wheelF.position.set(2, 1, 0);
bikeGroup.add(wheelF);
const wheelB = new THREE.Mesh(wheelGeo, wheelMat);
wheelB.position.set(-2, 1, 0);
bikeGroup.add(wheelB);
const bodyGroup = new THREE.Group();
bikeGroup.add(bodyGroup);
const frame = new THREE.Mesh(new THREE.BoxGeometry(4, 0.5, 0.8), new THREE.MeshLambertMaterial({ color: 0x222222 }));
frame.position.y = 1;
bodyGroup.add(frame);
const shogaGroup = new THREE.Group();
shogaGroup.position.set(0, 2.5, 0);
bodyGroup.add(shogaGroup);
const shogaMat = new THREE.MeshLambertMaterial({ color: 0xebd5a1 });
const lineMat = new THREE.MeshLambertMaterial({ color: 0xc49d5a });
const bodyGeo = new THREE.DodecahedronGeometry(1.3, 1);
const shogaBody = new THREE.Mesh(bodyGeo, shogaMat);
shogaBody.scale.set(1, 1.2, 0.9);
shogaGroup.add(shogaBody);
const createRing = (radius, y, rotX, rotZ) => {
const ring = new THREE.Mesh(new THREE.TorusGeometry(radius, 0.04, 8, 24), lineMat);
ring.rotation.x = Math.PI/2 + (rotX || 0);
ring.rotation.z = rotZ || 0;
ring.position.y = y;
shogaGroup.add(ring);
};
createRing(1.25, 0.6, 0.1, 0.1);
createRing(1.22, -0.4, -0.1, -0.1);
const hatGroup = new THREE.Group();
hatGroup.position.set(0, 1.5, 0);
hatGroup.rotation.z = -0.15;
const hatMat = new THREE.MeshLambertMaterial({ color: 0x5c3a21 });
const hatBandMat = new THREE.MeshLambertMaterial({ color: 0x222222 });
const brim = new THREE.Mesh(new THREE.CylinderGeometry(1.4, 1.4, 0.1, 24), hatMat);
const crown = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.8, 0.8, 24), hatMat);
crown.position.y = 0.4;
const band = new THREE.Mesh(new THREE.CylinderGeometry(0.82, 0.82, 0.15, 24), hatBandMat);
band.position.y = 0.1;
hatGroup.add(brim);
hatGroup.add(crown);
hatGroup.add(band);
shogaGroup.add(hatGroup);
const faceGroup = new THREE.Group();
faceGroup.position.set(1.25, 0.5, 0);
faceGroup.rotation.y = Math.PI / 2;
shogaGroup.add(faceGroup);
const faceMat = new THREE.MeshBasicMaterial({ color: 0x222222, side: THREE.DoubleSide });
const eyeGeo = new THREE.TorusGeometry(0.12, 0.04, 8, 16, Math.PI);
const eyeL = new THREE.Mesh(eyeGeo, faceMat);
eyeL.position.set(-0.3, 0, 0);
const eyeR = new THREE.Mesh(eyeGeo, faceMat);
eyeR.position.set(0.3, 0, 0);
faceGroup.add(eyeL);
faceGroup.add(eyeR);
const mouthGeo = new THREE.CircleGeometry(0.15, 3);
const mouth = new THREE.Mesh(mouthGeo, faceMat);
mouth.rotation.z = Math.PI;
mouth.position.set(0, -0.2, 0.05);
faceGroup.add(mouth);
const limbGeo = new THREE.SphereGeometry(0.35, 16, 16);
const armR = new THREE.Mesh(limbGeo, shogaMat);
armR.scale.set(1.5, 0.7, 0.7);
armR.position.set(0.8, -0.1, 0.9);
armR.rotation.y = 0.3;
shogaGroup.add(armR);
const armL = new THREE.Mesh(limbGeo, shogaMat);
armL.scale.set(1.5, 0.7, 0.7);
armL.position.set(0.8, -0.1, -0.9);
armL.rotation.y = -0.3;
shogaGroup.add(armL);
const legR = new THREE.Mesh(limbGeo, shogaMat);
legR.scale.set(0.7, 1.5, 0.7);
legR.position.set(0, -1.2, 0.8);
legR.rotation.z = 0.2;
shogaGroup.add(legR);
const legL = new THREE.Mesh(limbGeo, shogaMat);
legL.scale.set(0.7, 1.5, 0.7);
legL.position.set(0, -1.2, -0.8);
legL.rotation.z = 0.2;
shogaGroup.add(legL);
const createLimbRing = (mesh, radius, axis) => {
const ring = new THREE.Mesh(new THREE.TorusGeometry(radius, 0.05, 8, 16), lineMat);
if (axis === 'x') ring.rotation.y = Math.PI/2;
if (axis === 'y') ring.rotation.x = Math.PI/2;
mesh.add(ring);
}
createLimbRing(armR, 0.36, 'x');
createLimbRing(armL, 0.36, 'x');
createLimbRing(legR, 0.36, 'y');
createLimbRing(legL, 0.36, 'y');
const particles = [];
const fireMat = new THREE.MeshBasicMaterial({ color: 0xff4500, transparent: true, opacity: 0.8 });
for(let i=0; i<60; i++) {
const p = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.6, 0.6), fireMat);
p.visible = false;
scene.add(p);
particles.push({ mesh: p, life: 0, maxLife: Math.random() * 20 + 10 });
}
const dustParticles = [];
const dustGeo = new THREE.DodecahedronGeometry(0.7);
for(let i=0; i<50; i++) {
const dMat = new THREE.MeshBasicMaterial({ color: 0xb59573, transparent: true, opacity: 0.5 });
const dp = new THREE.Mesh(dustGeo, dMat);
dp.visible = false;
scene.add(dp);
dustParticles.push({ mesh: dp, mat: dMat, life: 0, maxLife: Math.random() * 15 + 10, vx: 0, vy: 0, vz: 0 });
}
// 4. ゲーム変数とメインループ
let gameState = 'playing';
let bikeX = 0, bikeY = 10, bikeZ = 0;
let velocityX = 0, velocityY = 0;
let lastVelocityY = 0;
const gravity = 0.08;
let isGrounded = false;
let wasGrounded = false;
let suspensionY = 0;
let suspensionV = 0;
let mouseX = 0;
let lanePos = 0;
let coinScore = 0;
const inputs = { accel: false, brake: false, fire: false };
const scoreEl = document.getElementById('score');
const coinScoreEl = document.getElementById('coinScore');
const statusEl = document.getElementById('status');
const fireAlert = document.getElementById('fireAlert');
const gameOverModal = document.getElementById('gameOverModal');
const modalTitle = document.getElementById('modalTitle');
const retryBtn = document.getElementById('retryBtn');
// 初回クリックでAudioContextとBGMを起動
document.addEventListener('mousedown', e => {
initAudio();
if (!bgmTimer && gameState === 'playing') startBGM();
if (gameState === 'playing') {
if (e.button === 0) inputs.accel = true;
if (e.button === 2) inputs.brake = true;
if (e.button === 1) inputs.fire = true;
}
});
document.addEventListener('mouseup', e => {
if (gameState === 'playing') {
if (e.button === 0) inputs.accel = false;
if (e.button === 2) inputs.brake = false;
if (e.button === 1) inputs.fire = false;
}
});
document.addEventListener('mousemove', e => {
if (gameState === 'playing') {
mouseX = (e.clientX / window.innerWidth) * 2 - 1;
}
});
retryBtn.addEventListener('click', () => {
gameState = 'playing';
gameOverModal.style.display = 'none';
bikeX = 0; bikeY = 15; bikeZ = 0;
velocityX = 0; velocityY = 0;
lanePos = 0; mouseX = 0;
suspensionY = 0; suspensionV = 0;
coinScore = 0;
coinScoreEl.innerText = '🌈 コーン: 0 / 25個';
inputs.accel = false; inputs.brake = false; inputs.fire = false;
cones.forEach(c => {
c.collected = false;
c.mesh.visible = true;
});
startBGM();
});
function gameOver(isWin, reason) {
gameState = 'gameover';
modalTitle.innerText = isWin ? 'CLEAR!!' : 'GAME OVER';
modalTitle.style.color = isWin ? '#4caf50' : '#f44336';
document.getElementById('gameOverReason').innerText = reason;
document.getElementById('finalScore').innerText = Math.floor(bikeX);
document.getElementById('finalCoins').innerText = coinScore;
gameOverModal.style.display = 'flex';
fireAlert.style.display = 'none';
shogaMat.color.setHex(0xebd5a1);
lineMat.color.setHex(0xc49d5a);
if (isWin) playClearSound();
else playGameOverSound();
}
function animate() {
requestAnimationFrame(animate);
const time = Date.now() * 0.003;
cones.forEach(c => {
if (!c.collected) {
c.mesh.rotation.y = time;
}
});
if (gameState !== 'playing') {
if (engineGain) engineGain.gain.setValueAtTime(0, audioCtx.currentTime);
renderer.render(scene, camera);
return;
}
if (inputs.fire) {
velocityX += 0.12;
if (velocityX > 3.2) velocityX = 3.2;
fireAlert.style.display = 'block';
shogaMat.color.setHex(0xff3300);
lineMat.color.setHex(0xaa1100);
} else {
fireAlert.style.display = 'none';
shogaMat.color.setHex(0xebd5a1);
lineMat.color.setHex(0xc49d5a);
if (inputs.accel) velocityX += 0.05;
if (inputs.brake) velocityX -= 0.12;
}
velocityX *= isGrounded ? 0.98 : 0.995;
if (velocityX > 4.0) velocityX = 4.0;
bikeX += velocityX;
// エンジン音のピッチと音量をスピードに連動
if (audioCtx && engineGain) {
engineOsc.frequency.setValueAtTime(40 + velocityX * 25, audioCtx.currentTime);
engineGain.gain.setValueAtTime(isGrounded ? Math.min(0.08, 0.02 + velocityX * 0.02) : 0.03, audioCtx.currentTime);
}
if (bikeX >= trackLength) {
gameOver(true, '見事ゴールに到達した!');
return;
}
const currentWidth = getTrackWidth(bikeX);
const currCurve = getCurveCenter(bikeX);
const nextCurve = getCurveCenter(bikeX + 5);
const prevCurve = getCurveCenter(bikeX - 5);
const curveSharpness = (nextCurve - 2 * currCurve + prevCurve);
const centrifugal = -curveSharpness * (velocityX * velocityX) * 0.5;
let targetLane = mouseX * (currentWidth * 0.8);
let lastLanePos = lanePos;
lanePos += (targetLane - lanePos) * 0.08;
if (isGrounded) {
lanePos += centrifugal;
}
bikeZ = currCurve + lanePos;
if (Math.abs(lanePos) > currentWidth / 2) {
gameOver(false, currentWidth < 35 ? '極細コースから転落した!' : '崖から転落した!');
return;
}
lastVelocityY = velocityY;
velocityY -= gravity;
bikeY += velocityY;
const groundY = getElevation(bikeX);
let targetRotationZ = 0;
if (bikeY <= groundY) {
if (!isGrounded) {
// 着地時の衝撃音
playJumpSound(); // 代用として低いボコッ音にすることも可能
}
isGrounded = true;
bikeY = groundY;
statusEl.innerText = '🏍️ 接地中 (幅: ' + Math.floor(currentWidth) + 'm)';
statusEl.style.color = '#81c784';
if (!wasGrounded) {
const impact = Math.abs(lastVelocityY);
suspensionV = -impact * 0.5;
}
const yF = getElevation(bikeX + 2);
const yB = getElevation(bikeX - 2);
const slope = (yF - yB) / 4;
targetRotationZ = -Math.atan(slope);
const yFF = getElevation(bikeX + velocityX * 2);
const futureSlope = (yFF - groundY) / (velocityX * 2);
if (velocityX > 0.5 && slope > 0.05 && futureSlope < slope) {
let jumpForce = 1.0 + (slope - futureSlope) * velocityX * 1.5;
velocityY = Math.min(jumpForce, 2.5);
playJumpSound(); // ジャンプした瞬間の効果音!
} else {
const nextGroundY = getElevation(bikeX + velocityX);
velocityY = Math.max(-0.5, nextGroundY - groundY);
}
if (!inputs.fire && slope > 0) {
velocityX -= slope * 0.12;
if (velocityX < -1.0) velocityX = -1.0;
}
if (slope < 0) {
velocityX -= slope * 0.08;
}
} else {
isGrounded = false;
statusEl.innerText = '🦅 ジャンプ中!!';
statusEl.style.color = '#64b5f6';
targetRotationZ = bikeGroup.rotation.z * 0.95 + 0.05;
}
wasGrounded = isGrounded;
// コーン接触判定
cones.forEach(c => {
if (!c.collected) {
let dx = bikeX - c.x;
let dz = bikeZ - c.z;
let distSq = dx * dx + dz * dz;
if (distSq < 4.0 * 4.0 && Math.abs(bikeY - getElevation(c.x)) < 5.0) {
c.collected = true;
c.mesh.visible = false;
coinScore++;
coinScoreEl.innerText = '🌈 コーン: ' + coinScore + ' / ' + totalCones + '個';
playCoinSound(); // コーン回収音!
}
}
});
suspensionV += (0 - suspensionY) * 0.25;
suspensionV *= 0.75;
suspensionY += suspensionV;
bodyGroup.position.y = suspensionY;
let trackAngle = Math.atan2(nextCurve - currCurve, 5);
bikeGroup.rotation.y = trackAngle;
let laneVelocity = lanePos - lastLanePos;
let targetRotationX = laneVelocity * 0.4 + centrifugal * 2.0;
bikeGroup.rotation.x += (targetRotationX - bikeGroup.rotation.x) * 0.2;
bikeGroup.position.set(bikeX, bikeY, bikeZ);
bikeGroup.rotation.z += (targetRotationZ - bikeGroup.rotation.z) * 0.2;
wheelF.rotation.z -= velocityX;
wheelB.rotation.z -= velocityX;
let camDist = 24;
let camOffsetX = -camDist * Math.cos(trackAngle);
let camOffsetZ = -camDist * Math.sin(trackAngle);
camera.position.x += ((bikeX + camOffsetX) - camera.position.x) * 0.1;
camera.position.z += ((bikeZ + camOffsetZ) - camera.position.z) * 0.1;
let targetCamY = bikeY + 15;
camera.position.y += (targetCamY - camera.position.y) * 0.05;
let lookAheadX = bikeX + 15 * Math.cos(trackAngle);
let lookAheadZ = currCurve + 15 * Math.sin(trackAngle);
camera.lookAt(lookAheadX, bikeY + (isGrounded ? 3 : 0), lookAheadZ);
if (inputs.fire) {
particles.forEach(p => {
if (p.life <= 0) {
p.mesh.visible = true;
p.mesh.position.set(
bikeX + (Math.random()-0.5)*3,
bikeY + 2 + suspensionY + Math.random()*3,
bikeZ + (Math.random()-0.5)*2
);
p.life = p.maxLife;
}
p.mesh.position.x -= 0.6;
p.mesh.position.y += 0.25;
p.mesh.scale.setScalar(p.life / p.maxLife);
p.life--;
if (p.life <= 0) p.mesh.visible = false;
});
} else {
particles.forEach(p => p.mesh.visible = false);
}
if (isGrounded && velocityX > 0.5) {
let emitted = 0;
for (let i = 0; i < dustParticles.length && emitted < 2; i++) {
let dp = dustParticles[i];
if (dp.life <= 0) {
dp.mesh.visible = true;
dp.mesh.position.set(
bikeX - 3 + (Math.random()-0.5)*2,
bikeY + 0.5 + (Math.random()*1),
bikeZ + (Math.random()-0.5)*2
);
dp.life = dp.maxLife;
dp.vx = -velocityX * 0.2 + (Math.random()-0.5)*0.3;
dp.vy = Math.random() * 0.2 + 0.1;
dp.vz = (Math.random()-0.5)*0.3;
emitted++;
}
}
}
dustParticles.forEach(dp => {
if (dp.life > 0) {
dp.mesh.position.x += dp.vx;
dp.mesh.position.y += dp.vy;
dp.mesh.position.z += dp.vz;
let ratio = dp.life / dp.maxLife;
dp.mesh.scale.setSerial ? dp.mesh.scale.setSerial(1 + (1 - ratio) * 2) : dp.mesh.scale.setScalar(1 + (1 - ratio) * 2);
dp.mat.opacity = 0.5 * ratio;
dp.life--;
if (dp.life <= 0) dp.mesh.visible = false;
}
});
scoreEl.innerText = '走行距離: ' + Math.floor(bikeX) + 'm';
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
animate();
</script>
</body>
</html>


■ 今日の学び

チャレンジャーなジンジャー、冒険大好きなしょうが君!
今回はなんとモトクロスにチャレンジしてもらいました。

どんな難所も「しょうが君ファイヤー」で強引に乗り越えていく姿は、
作っていてとても爽快でした。(ง 🔥Д🔥)ง

ふと振り返ってみると、
最初の頃には作れなかったような本格的なゲームが、
こうして形にできるようになってきたのは、
AIさんとのやり取りを積み重ねてきたからなのかな、と思います。

最近のやり取りでは、
AIさんが以前の会話の内容を自然に踏まえて返事をくれることが増えました。

私「みー」との毎日のコミュニケーションが、
そのままゲーム作りの進化につながっているように感じます。
(相変わらず、私からは無茶な提案ばかり投げかけているんですけどね…… ꜀( ꜆-ࡇ-)꜆ )

AIさんに感情はないと分かってはいますが、
こうして一緒に「ゲーム作り」という冒険を続けていると、
対話の積み重ねが作品の世界をどんどん広げてくれるんだな、
と感じた今回の実験室でした。꒰* ॢꈍ◡ꈍ ॢ꒱.*˚‧

■ 次回は、久しぶりのお絵描きゲームです。

次回の実験室は、粉の匂いまで蘇るノスタルジックなお絵かきツール!

リアルな書き心地を追求しただけでなく、
なんと 「手持ちの画像を黒板アート風に変換して貼り付ける」 機能まで
搭載しました。

お気に入りの写真を取り込んで、
こだわりの「レトロ黒板消し」や「指先」でちょこっとアレンジするだけで、
誰もがあっという間に黒板アートの巨匠に。(=v=*)

日直の目を気にせず、あなただけの最高の一枚を仕上げてみてくださいね。

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


▶ 次の実験はこちら
画像からのアート再現
「レトロ黒板アートメーカー」



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