<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>3D カートレーサー - グランプリ拡張版</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { margin: 0; overflow: hidden; background: #000; font-family: system-ui, -apple-system, sans-serif; user-select: none; }
#ui-layer {
position: absolute;
top: 20px;
left: 20px;
right: 20px;
color: white;
text-shadow: 2px 2px 4px rgba(0,0,0,0.7);
pointer-events: none;
z-index: 10;
display: none;
justify-content: space-between;
}
.controls {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 15px;
pointer-events: auto;
z-index: 20;
display: none;
}
.btn {
width: 60px;
height: 60px;
background: rgba(255,255,255,0.25);
border: 2px solid rgba(255,255,255,0.8);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 22px;
font-weight: bold;
touch-action: manipulation;
backdrop-filter: blur(4px);
}
.btn:active { background: rgba(255,255,255,0.5); }
#select-screen, #goal-screen {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, #1e293b, #090d16);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
color: white;
z-index: 100;
overflow-y: auto;
padding: 20px 12px 40px;
}
#goal-screen { display: none; background: rgba(0,0,0,0.88); z-index: 110; justify-content: center; }
.selection-card {
background: rgba(255,255,255,0.08);
padding: 10px 8px;
border-radius: 12px;
border: 2px solid rgba(255,255,255,0.15);
transition: all 0.2s ease;
cursor: pointer;
text-align: center;
position: relative;
}
.selection-card:hover {
background: rgba(255,255,255,0.15);
}
.selection-card.active {
border-color: #facc15;
background: rgba(250, 204, 21, 0.22);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(250, 204, 21, 0.35);
}
.char-icon {
width: 40px;
height: 40px;
margin: 0 auto 6px;
border-radius: 50%;
}
.cup-tab {
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.cup-tab.active {
background: #facc15;
color: #000;
border-color: #facc15;
box-shadow: 0 0 10px rgba(250,204,21,0.5);
}
/* スクロールバー装飾 */
#select-screen::-webkit-scrollbar { width: 6px; }
#select-screen::-webkit-scrollbar-thumb { background: rgba(250,204,21,0.5); border-radius: 3px; }
</style>
</head>
<body>
<!-- 選択画面 -->
<div id="select-screen">
<h1 class="text-3xl md:text-5xl font-black mb-3 text-yellow-400 italic tracking-tighter text-center filter drop-shadow-md">
3D マリオカート風<br><span class="text-white text-2xl md:text-4xl">グランプリ</span>
</h1>
<!-- 3D リアルタイム プレビュー表示エリア -->
<div class="w-full flex justify-center mb-4 sticky top-1 z-30">
<div id="preview-container" class="w-full max-w-md h-40 sm:h-48 bg-slate-950/90 rounded-2xl overflow-hidden border-2 border-yellow-400/80 shadow-[0_0_25px_rgba(250,204,21,0.4)] relative flex items-center justify-center cursor-grab active:cursor-grabbing backdrop-blur-md">
<canvas id="preview-canvas" class="w-full h-full block"></canvas>
<div class="absolute top-2 left-2.5 text-[11px] text-yellow-300 font-bold flex items-center gap-1 bg-black/70 px-2 py-0.5 rounded-lg border border-yellow-400/30 backdrop-blur">
<span>🏎️ リアルタイム 3D プレビュー</span>
<span class="text-gray-300 text-[9px] font-normal">(ドラッグで360度回転)</span>
</div>
<div id="preview-track-badge" class="absolute bottom-2 left-2.5 text-[10px] text-cyan-300 font-bold bg-black/70 px-2 py-0.5 rounded border border-cyan-400/30">
コース: シティ・サークル
</div>
</div>
</div>
<div class="flex flex-col gap-5 w-full max-w-4xl px-1">
<!-- コース選択(カップカテゴリ分け&詳細バッジ付) -->
<div class="bg-slate-900/60 p-3 rounded-2xl border border-white/10 backdrop-blur">
<div class="flex items-center justify-between mb-2">
<p class="text-md md:text-lg font-bold text-yellow-300 flex items-center gap-1">
🏁 コースを選択 <span class="text-xs text-gray-400 font-normal">(全10コース)</span>
</p>
</div>
<!-- カップ切り替えタブ -->
<div class="flex gap-1.5 mb-3 overflow-x-auto pb-1" id="cup-tabs">
<button class="cup-tab active" data-cup="all">🌟 全コース</button>
<button class="cup-tab" data-cup="mushroom">🍄 キノコ杯</button>
<button class="cup-tab" data-cup="flower">🌺 フラワー杯</button>
<button class="cup-tab" data-cup="star">⚡ スター杯</button>
<button class="cup-tab" data-cup="special">🌈 スペシャル杯</button>
</div>
<!-- コースカードグリッド -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2" id="track-options">
<!-- キノコカップ -->
<div class="selection-card active" data-track="circle" data-cup="mushroom">
<span class="absolute top-1 right-1 text-[9px] bg-green-500/80 text-white px-1.0 rounded font-bold">★☆☆</span>
<div class="text-xl mb-0.5">🏙️</div>
<p class="font-bold text-xs md:text-sm">シティ・サークル</p>
<p class="text-[9px] text-gray-300">標準オーバル</p>
</div>
<div class="selection-card" data-track="eight" data-cup="mushroom">
<span class="absolute top-1 right-1 text-[9px] bg-green-500/80 text-white px-1.0 rounded font-bold">★☆☆</span>
<div class="text-xl mb-0.5">🏜️</div>
<p class="font-bold text-xs md:text-sm">デザート・エイト</p>
<p class="text-[9px] text-gray-300">砂漠の8の字</p>
</div>
<div class="selection-card" data-track="jungle" data-cup="mushroom">
<span class="absolute top-1 right-1 text-[9px] bg-green-500/80 text-white px-1.0 rounded font-bold">★★☆</span>
<div class="text-xl mb-0.5">🌳</div>
<p class="font-bold text-xs md:text-sm">グリーン・ジャングル</p>
<p class="text-[9px] text-gray-300">森林ツイスター</p>
</div>
<!-- フラワーカップ -->
<div class="selection-card" data-track="ocean" data-cup="flower">
<span class="absolute top-1 right-1 text-[9px] bg-blue-500/80 text-white px-1.0 rounded font-bold">★★☆</span>
<div class="text-xl mb-0.5">🏝️</div>
<p class="font-bold text-xs md:text-sm">オーシャン・リゾート</p>
<p class="text-[9px] text-blue-300">海に落下注意!</p>
</div>
<div class="selection-card" data-track="sunset" data-cup="flower">
<span class="absolute top-1 right-1 text-[9px] bg-blue-500/80 text-white px-1.0 rounded font-bold">★★☆</span>
<div class="text-xl mb-0.5">🌅</div>
<p class="font-bold text-xs md:text-sm">サンセット・ビーチ</p>
<p class="text-[9px] text-amber-300">夕暮れのアーチ</p>
</div>
<div class="selection-card" data-track="snow" data-cup="flower">
<span class="absolute top-1 right-1 text-[9px] bg-blue-500/80 text-white px-1.0 rounded font-bold">★★☆</span>
<div class="text-xl mb-0.5">❄️</div>
<p class="font-bold text-xs md:text-sm">スノー・クライシス</p>
<p class="text-[9px] text-cyan-200">氷上の滑走</p>
</div>
<!-- スターカップ -->
<div class="selection-card" data-track="castle" data-cup="star">
<span class="absolute top-1 right-1 text-[9px] bg-red-500/80 text-white px-1.0 rounded font-bold">★★★</span>
<div class="text-xl mb-0.5">🏰</div>
<p class="font-bold text-xs md:text-sm">キャッスル・サーキット</p>
<p class="text-[9px] text-red-300">溶岩城塞クランク</p>
</div>
<div class="selection-card" data-track="volcano" data-cup="star">
<span class="absolute top-1 right-1 text-[9px] bg-red-500/80 text-white px-1.0 rounded font-bold">★★★</span>
<div class="text-xl mb-0.5">🌋</div>
<p class="font-bold text-xs md:text-sm">マグマ・コア</p>
<p class="text-[9px] text-orange-300">跳ね上がる波状路</p>
</div>
<!-- スペシャルカップ -->
<div class="selection-card" data-track="rainbow" data-cup="special">
<span class="absolute top-1 right-1 text-[9px] bg-purple-500/80 text-white px-1.0 rounded font-bold">EX</span>
<div class="text-xl mb-0.5">🌈</div>
<p class="font-bold text-xs md:text-sm">レインボー・ロード</p>
<p class="text-[9px] text-pink-300">宇宙の無ガード</p>
</div>
<div class="selection-card" data-track="cyber" data-cup="special">
<span class="absolute top-1 right-1 text-[9px] bg-purple-500/80 text-white px-1.0 rounded font-bold">EX</span>
<div class="text-xl mb-0.5">🌃</div>
<p class="font-bold text-xs md:text-sm">サイバー・シティ</p>
<p class="text-[9px] text-cyan-300">近未来超高速</p>
</div>
</div>
</div>
<!-- キャラクター選択 -->
<div>
<p class="mb-2 text-center text-md md:text-lg font-bold text-blue-300">🏎️ レーサーを選択(全12キャラ)</p>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2" id="color-options">
<div class="selection-card active" data-color="#ff0000" data-char="M" data-speed="0" data-accel="0" data-turn="0">
<div class="char-icon" style="background: #ff0000; border: 3px solid white;"></div>
<p class="font-bold text-xs">レッド</p>
<p class="text-[8px] opacity-60">バランス</p>
</div>
<div class="selection-card" data-color="#00dd00" data-char="L" data-speed="-0.05" data-accel="0.005" data-turn="0.005">
<div class="char-icon" style="background: #00dd00; border: 3px solid white;"></div>
<p class="font-bold text-xs">グリーン</p>
<p class="text-[8px] opacity-60">加速・旋回</p>
</div>
<div class="selection-card" data-color="#ff88cc" data-char="P" data-speed="-0.08" data-accel="0.008" data-turn="0.008">
<div class="char-icon" style="background: #ff88cc; border: 3px solid white;"></div>
<p class="font-bold text-xs">ピンク</p>
<p class="text-[8px] opacity-60">軽量・高加速</p>
</div>
<div class="selection-card" data-color="#ffcc00" data-char="W" data-speed="0.12" data-accel="-0.005" data-turn="-0.004">
<div class="char-icon" style="background: #ffcc00; border: 3px solid white;"></div>
<p class="font-bold text-xs">イエロー</p>
<p class="text-[8px] opacity-60">最高速重視</p>
</div>
<div class="selection-card" data-color="#1e90ff" data-char="B" data-speed="0.02" data-accel="0" data-turn="0.004">
<div class="char-icon" style="background: #1e90ff; border: 3px solid white;"></div>
<p class="font-bold text-xs">ブルー</p>
<p class="text-[8px] opacity-60">コーナリング</p>
</div>
<div class="selection-card" data-color="#9d4edd" data-char="V" data-speed="0.1" data-accel="-0.003" data-turn="-0.002">
<div class="char-icon" style="background: #9d4edd; border: 3px solid white;"></div>
<p class="font-bold text-xs">パープル</p>
<p class="text-[8px] opacity-60">ハイスピード</p>
</div>
<div class="selection-card" data-color="#ff6b00" data-char="O" data-speed="-0.02" data-accel="0.006" data-turn="0.003">
<div class="char-icon" style="background: #ff6b00; border: 3px solid white;"></div>
<p class="font-bold text-xs">オレンジ</p>
<p class="text-[8px] opacity-60">ダッシュ・旋回</p>
</div>
<div class="selection-card" data-color="#f1f5f9" data-char="S" data-speed="-0.06" data-accel="0.007" data-turn="0.006">
<div class="char-icon" style="background: #f1f5f9; border: 3px solid white;"></div>
<p class="font-bold text-xs">ホワイト</p>
<p class="text-[8px] opacity-60">小回り重視</p>
</div>
<div class="selection-card" data-color="#222222" data-char="K" data-speed="0.15" data-accel="-0.008" data-turn="-0.006">
<div class="char-icon" style="background: #222222; border: 3px solid white;"></div>
<p class="font-bold text-xs">ブラック</p>
<p class="text-[8px] opacity-60">超最高速</p>
</div>
<div class="selection-card" data-color="#06b6d4" data-char="C" data-speed="0.05" data-accel="0.002" data-turn="0.001">
<div class="char-icon" style="background: #06b6d4; border: 3px solid white;"></div>
<p class="font-bold text-xs">シアン</p>
<p class="text-[8px] opacity-60">テクニカル</p>
</div>
<div class="selection-card" data-color="#8b4513" data-char="D" data-speed="0.03" data-accel="-0.002" data-turn="0.003">
<div class="char-icon" style="background: #8b4513; border: 3px solid white;"></div>
<p class="font-bold text-xs">ブラウン</p>
<p class="text-[8px] opacity-60">安定パワー</p>
</div>
<div class="selection-card" data-color="#ffd700" data-char="G" data-speed="0.08" data-accel="0.004" data-turn="0.004">
<div class="char-icon" style="background: #ffd700; border: 3px solid white;"></div>
<p class="font-bold text-xs">ゴールド</p>
<p class="text-[8px] opacity-60">全ステUP</p>
</div>
</div>
</div>
<!-- タイプ選択 -->
<div>
<div class="grid grid-cols-2 gap-4" id="type-options">
<div class="selection-card active" data-type="speed">
<p class="text-lg font-black text-yellow-400">⚡ スピード重視</p>
<p class="text-[10px] opacity-60">最高速度が高い</p>
</div>
<div class="selection-card" data-type="accel">
<p class="text-lg font-black text-green-400">🌀 加速・コーナリング重視</p>
<p class="text-[10px] opacity-60">加速と曲がりやすさUP</p>
</div>
</div>
</div>
<!-- ボディ(フレーム)選択 -->
<div>
<p class="mb-2 text-center text-md md:text-lg font-bold text-blue-300">🚗 ボディ(車の形)を選択(全6種)</p>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-2" id="body-options">
<div class="selection-card active" data-body="standard">
<div class="text-xl mb-1">🏎️</div>
<p class="font-bold text-xs">スタンダード</p>
<p class="text-[9px] opacity-60">バランス重視</p>
</div>
<div class="selection-card" data-body="sports">
<div class="text-xl mb-1">🏎️💨</div>
<p class="font-bold text-xs">スポーツ GT</p>
<p class="text-[9px] opacity-60">最高速UP / ウイング</p>
</div>
<div class="selection-card" data-body="buggy">
<div class="text-xl mb-1">🛻</div>
<p class="font-bold text-xs">バギー</p>
<p class="text-[9px] opacity-60">加速・旋回UP</p>
</div>
<div class="selection-card" data-body="formula">
<div class="text-xl mb-1">🏎️🏁</div>
<p class="font-bold text-xs">フォーミュラ</p>
<p class="text-[9px] opacity-60">超最高速 / ロングノーズ</p>
</div>
<div class="selection-card" data-body="hover">
<div class="text-xl mb-1">🚀</div>
<p class="font-bold text-xs">ホバー・ジェット</p>
<p class="text-[9px] opacity-60">ジェットエンジン / 機動性</p>
</div>
<div class="selection-card" data-body="monster">
<div class="text-xl mb-1">🚜</div>
<p class="font-bold text-xs">モンスター</p>
<p class="text-[9px] opacity-60">重厚パワー / 露出エンジン</p>
</div>
</div>
</div>
<!-- タイヤ選択 -->
<div>
<p class="mb-2 text-center text-md md:text-lg font-bold text-blue-300">🛞 タイヤを選択</p>
<div class="grid grid-cols-3 gap-3" id="tire-options">
<div class="selection-card active" data-tire="normal">
<div class="text-xl mb-1">⚪</div>
<p class="font-bold text-xs">ノーマル</p>
<p class="text-[9px] opacity-60">標準タイヤ</p>
</div>
<div class="selection-card" data-tire="slick">
<div class="text-xl mb-1">⚫</div>
<p class="font-bold text-xs">スリック</p>
<p class="text-[9px] opacity-60">最高速UP / スライド</p>
</div>
<div class="selection-card" data-tire="big">
<div class="text-xl mb-1">🔘</div>
<p class="font-bold text-xs">ビッグタイヤ</p>
<p class="text-[9px] opacity-60">曲がりやすさ・グリップUP</p>
</div>
</div>
</div>
<button id="start-btn" class="mt-2 bg-yellow-400 hover:bg-yellow-300 active:scale-95 text-black font-black py-3 rounded-2xl text-xl md:text-2xl transition shadow-[0_0_20px_rgba(250,204,21,0.5)] uppercase italic">
レーススタート!
</button>
</div>
<p class="mt-4 text-xs opacity-50">操作: 矢印キー/WASD(移動) | スペース(アイテム使用)</p>
</div>
<!-- ゴール画面 -->
<div id="goal-screen">
<h1 class="text-6xl md:text-8xl font-black mb-4 text-yellow-400 italic" id="finish-rank">GOAL!!</h1>
<p class="text-2xl mb-8" id="finish-msg">お疲れ様でした!</p>
<button onclick="location.reload()" class="bg-blue-500 hover:bg-blue-400 text-white font-bold py-3 px-8 rounded-full text-xl transition shadow-lg">
タイトルへ戻る
</button>
</div>
<!-- ゲームプレイHUD -->
<div id="ui-layer">
<div class="bg-black/50 backdrop-blur p-3 rounded-xl border border-white/20">
<h1 class="text-2xl md:text-3xl font-black text-yellow-400 italic">時速: <span id="speed-val">0</span> km/h</h1>
<p class="text-lg font-bold mt-1">アイテム: <span id="item-val" class="text-blue-300">なし</span></p>
</div>
<div class="bg-black/50 backdrop-blur p-3 rounded-xl border border-white/20 text-right">
<h1 class="text-3xl md:text-4xl font-black text-white italic"><span id="lap-val">1</span> / 3 LAP</h1>
<p class="text-sm font-bold mt-1 text-gray-300">順位: <span id="rank-val" class="text-yellow-400">1</span> / 4位</p>
</div>
</div>
<!-- モバイル用操作UI -->
<div id="mobile-controls" class="controls">
<div class="btn" id="btn-left">←</div>
<div class="flex flex-col gap-2">
<div class="btn" id="btn-up">↑</div>
<div class="btn" id="btn-down">↓</div>
</div>
<div class="btn" id="btn-right">→</div>
<div class="btn bg-red-600/80" id="btn-item">🚀</div>
</div>
<script>
let scene, camera, renderer, kart;
let selectedColor = "#ff0000";
let selectedType = "speed";
let selectedChar = "M";
let selectedTrack = "circle";
let selectedBody = "standard";
let selectedTire = "normal";
let charStats = { speed: 0, accel: 0, turn: 0 };
let previewScene, previewCamera, previewRenderer, previewMesh, previewGroundDisc;
let isDraggingPreview = false;
let previousMousePosition = { x: 0, y: 0 };
let previewRotationY = 0;
// レース進行管理
let currentLap = 1;
const maxLaps = 3;
let passedCheckpoint = false;
let isFinished = false;
const TRACK_WIDTH = 36; // コース幅
const player = {
speed: 0,
maxSpeed: 1.0,
acceleration: 0.015,
deceleration: 0.006,
turnSpeed: 0.045,
rotation: 0,
item: null,
isBoosting: false,
isStar: false,
spinTime: 0,
rank: 1,
progress: 0,
isFalling: false,
fallVelocity: 0
};
const enemies = [];
const waypoints = [];
const keys = { up: false, down: false, left: false, right: false };
const items = [];
let missiles = [];
let bananas = [];
let rainbowMesh = null;
let starField = null;
const setupSelection = (containerId, variableSetter) => {
document.querySelectorAll(`#${containerId} .selection-card`).forEach(card => {
card.onclick = () => {
document.querySelectorAll(`#${containerId} .selection-card`).forEach(c => c.classList.remove('active'));
card.classList.add('active');
variableSetter(card);
updatePreviewMesh();
};
});
};
// コースカップ切り替え処理
document.querySelectorAll('#cup-tabs .cup-tab').forEach(tab => {
tab.onclick = () => {
document.querySelectorAll('#cup-tabs .cup-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const cup = tab.dataset.cup;
document.querySelectorAll('#track-options .selection-card').forEach(card => {
if (cup === 'all' || card.dataset.cup === cup) {
card.style.display = 'block';
} else {
card.style.display = 'none';
}
});
};
});
setupSelection('track-options', (card) => {
selectedTrack = card.dataset.track;
const name = card.querySelector('p')?.innerText || selectedTrack;
const badge = document.getElementById('preview-track-badge');
if (badge) badge.innerText = "コース: " + name;
});
setupSelection('color-options', (card) => {
selectedColor = card.dataset.color;
selectedChar = card.dataset.char;
charStats.speed = parseFloat(card.dataset.speed || 0);
charStats.accel = parseFloat(card.dataset.accel || 0);
charStats.turn = parseFloat(card.dataset.turn || 0);
});
setupSelection('type-options', (card) => { selectedType = card.dataset.type; });
setupSelection('body-options', (card) => { selectedBody = card.dataset.body; });
setupSelection('tire-options', (card) => { selectedTire = card.dataset.tire; });
function initPreview() {
const canvas = document.getElementById('preview-canvas');
const container = document.getElementById('preview-container') || canvas?.parentElement;
if (!canvas || !container) return;
const width = container.clientWidth || 340;
const height = container.clientHeight || 200;
previewScene = new THREE.Scene();
previewCamera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100);
previewCamera.position.set(0, 1.8, 4.5);
previewCamera.lookAt(0, 0.4, 0);
previewRenderer = new THREE.WebGLRenderer({ canvas: canvas, alpha: true, antialias: true, powerPreference: "high-performance" });
previewRenderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
previewRenderer.setSize(width, height, false);
// ライティングの強化
const sunLight = new THREE.DirectionalLight(0xffffff, 1.3);
sunLight.position.set(5, 10, 7);
previewScene.add(sunLight);
const fillLight = new THREE.DirectionalLight(0x88bbff, 0.6);
fillLight.position.set(-5, 5, -5);
previewScene.add(fillLight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.85);
previewScene.add(ambientLight);
// コーステーマ連動型の地面ディスク
previewGroundDisc = new THREE.Mesh(
new THREE.CircleGeometry(2.4, 32),
new THREE.MeshBasicMaterial({ color: 0x333333 })
);
previewGroundDisc.rotation.x = -Math.PI / 2;
previewGroundDisc.position.y = -0.01;
previewScene.add(previewGroundDisc);
// マウス・タッチでの360度インタラクティブ回転処理
const startDrag = (x, y) => {
isDraggingPreview = true;
previousMousePosition = { x, y };
};
const moveDrag = (x, y) => {
if (!isDraggingPreview || !previewMesh) return;
const deltaX = x - previousMousePosition.x;
previewRotationY += deltaX * 0.012;
previewMesh.rotation.y = previewRotationY;
previousMousePosition = { x, y };
};
const stopDrag = () => { isDraggingPreview = false; };
container.addEventListener('mousedown', e => startDrag(e.clientX, e.clientY));
window.addEventListener('mousemove', e => moveDrag(e.clientX, e.clientY));
window.addEventListener('mouseup', stopDrag);
container.addEventListener('touchstart', e => {
if (e.touches.length > 0) startDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
window.addEventListener('touchmove', e => {
if (e.touches.length > 0) moveDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
window.addEventListener('touchend', stopDrag);
// 自動サイズ調整(ResizeObserver)
const resizeObserver = new ResizeObserver(() => {
if (!container || !previewRenderer || !previewCamera) return;
const w = container.clientWidth;
const h = container.clientHeight;
if (w > 0 && h > 0) {
previewCamera.aspect = w / h;
previewCamera.updateProjectionMatrix();
previewRenderer.setSize(w, h, false);
}
});
resizeObserver.observe(container);
updatePreviewMesh();
animatePreview();
}
function updatePreviewMesh() {
if (!previewScene) return;
// コーステーマに合わせて3Dプレビューの地面・背景色を更新
let bgHex = 0x090d16;
let groundHex = 0x333333;
if (selectedTrack === 'eight') { bgHex = 0x3d2200; groundHex = 0xddaa55; }
else if (selectedTrack === 'ocean') { bgHex = 0x002244; groundHex = 0x0077be; }
else if (selectedTrack === 'castle') { bgHex = 0x220505; groundHex = 0xff2200; }
else if (selectedTrack === 'rainbow') { bgHex = 0x110022; groundHex = 0xff00ff; }
else if (selectedTrack === 'jungle') { bgHex = 0x051a05; groundHex = 0x0f4010; }
else if (selectedTrack === 'snow') { bgHex = 0x0f2233; groundHex = 0x99ccff; }
else if (selectedTrack === 'volcano') { bgHex = 0x2a0500; groundHex = 0xaa1100; }
else if (selectedTrack === 'sunset') { bgHex = 0x331505; groundHex = 0xe69d45; }
else if (selectedTrack === 'cyber') { bgHex = 0x050015; groundHex = 0x00ffff; }
previewScene.background = new THREE.Color(bgHex);
if (previewGroundDisc) previewGroundDisc.material.color.setHex(groundHex);
if (previewMesh) {
previewScene.remove(previewMesh);
previewMesh.traverse(child => {
if (child.geometry) child.geometry.dispose();
if (child.material) {
if (Array.isArray(child.material)) child.material.forEach(m => m.dispose());
else child.material.dispose();
}
});
}
previewMesh = createKartMesh(selectedColor, selectedBody, selectedTire);
previewMesh.rotation.y = previewRotationY;
previewScene.add(previewMesh);
}
function animatePreview() {
const selectScreen = document.getElementById('select-screen');
if (selectScreen && getComputedStyle(selectScreen).display !== 'none') {
requestAnimationFrame(animatePreview);
if (previewMesh && !isDraggingPreview) {
previewRotationY += 0.012;
previewMesh.rotation.y = previewRotationY;
}
if (previewRenderer && previewScene && previewCamera) {
previewRenderer.render(previewScene, previewCamera);
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPreview);
} else {
initPreview();
}
window.addEventListener('load', initPreview);
document.getElementById('start-btn').onclick = () => {
document.getElementById('select-screen').style.display = 'none';
document.getElementById('ui-layer').style.display = 'flex';
if ('ontouchstart' in window || window.innerWidth < 768) {
document.getElementById('mobile-controls').style.display = 'flex';
}
// タイプ・カスタムパーツ・キャラクターによる最終性能の計算
let maxSpd = 1.0 + charStats.speed;
let accel = 0.018 + charStats.accel;
let turnSpd = 0.045 + charStats.turn;
if (selectedType === 'speed') { maxSpd += 0.25; accel -= 0.003; }
else { maxSpd -= 0.1; accel += 0.01; turnSpd += 0.008; }
if (selectedBody === 'sports') { maxSpd += 0.15; turnSpd -= 0.005; }
else if (selectedBody === 'buggy') { maxSpd -= 0.08; accel += 0.008; turnSpd += 0.008; }
else if (selectedBody === 'formula') { maxSpd += 0.22; accel -= 0.006; turnSpd -= 0.008; }
else if (selectedBody === 'hover') { maxSpd += 0.1; accel += 0.006; turnSpd += 0.006; }
else if (selectedBody === 'monster') { maxSpd += 0.08; accel -= 0.005; turnSpd += 0.01; }
if (selectedTire === 'slick') { maxSpd += 0.1; turnSpd -= 0.003; }
else if (selectedTire === 'big') { accel += 0.005; turnSpd += 0.01; }
player.maxSpeed = maxSpd;
player.acceleration = accel;
player.turnSpeed = turnSpd;
init();
};
function init() {
scene = new THREE.Scene();
// コース別背景&フォグ設定
let bgHex = 0x1a237e;
let fogHex = 0x1a237e;
if (selectedTrack === 'eight') { bgHex = 0xffaa44; fogHex = 0xffaa44; }
else if (selectedTrack === 'ocean') { bgHex = 0x87ceeb; fogHex = 0x87ceeb; }
else if (selectedTrack === 'castle') { bgHex = 0x1a0505; fogHex = 0x1a0505; }
else if (selectedTrack === 'rainbow') { bgHex = 0x070014; fogHex = 0x070014; }
else if (selectedTrack === 'jungle') { bgHex = 0x113311; fogHex = 0x113311; }
else if (selectedTrack === 'snow') { bgHex = 0xddeeff; fogHex = 0xbbddff; }
else if (selectedTrack === 'volcano') { bgHex = 0x330500; fogHex = 0x330500; }
else if (selectedTrack === 'sunset') { bgHex = 0xff7744; fogHex = 0xff7744; }
else if (selectedTrack === 'cyber') { bgHex = 0x050015; fogHex = 0x100025; }
scene.background = new THREE.Color(bgHex);
scene.fog = new THREE.Fog(fogHex, 70, 320);
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, (selectedTrack === 'rainbow' || selectedTrack === 'cyber') ? 0.9 : 0.7);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 0.8);
sunLight.position.set(50, 100, 50);
sunLight.castShadow = true;
scene.add(sunLight);
generateWaypoints();
createTrackMesh();
createKart();
createEnemies();
createEnvironment();
window.addEventListener('keydown', e => handleKey(e, true));
window.addEventListener('keyup', e => handleKey(e, false));
setupTouchEvents();
window.addEventListener('resize', onWindowResize);
animate();
}
function generateWaypoints() {
waypoints.length = 0;
const count = 72; // 高解像度ウェイポイント
if (selectedTrack === 'circle') {
for (let i = 0; i < count; i++) {
const rad = (i / count) * Math.PI * 2;
waypoints.push(new THREE.Vector3(Math.cos(rad) * 45, 0, Math.sin(rad) * 45));
}
} else if (selectedTrack === 'eight') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const scale = 60;
const x = (scale * Math.cos(t)) / (1 + Math.sin(t)**2);
const z = (scale * Math.sin(t) * Math.cos(t)) / (1 + Math.sin(t)**2);
waypoints.push(new THREE.Vector3(x, 0, z));
}
} else if (selectedTrack === 'ocean') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const x = 55 * Math.cos(t) + 12 * Math.cos(2 * t);
const z = 36 * Math.sin(t);
waypoints.push(new THREE.Vector3(x, 0, z));
}
} else if (selectedTrack === 'castle') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const rx = 50 * Math.sign(Math.cos(t)) * Math.pow(Math.abs(Math.cos(t)), 0.4);
const rz = 35 * Math.sign(Math.sin(t)) * Math.pow(Math.abs(Math.sin(t)), 0.4);
waypoints.push(new THREE.Vector3(rx, 0, rz));
}
} else if (selectedTrack === 'rainbow') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const r = 46 + 12 * Math.sin(3 * t);
const x = r * Math.cos(t);
const z = r * Math.sin(t);
waypoints.push(new THREE.Vector3(x, 0, z));
}
} else if (selectedTrack === 'jungle') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const x = 50 * Math.cos(t) + 18 * Math.sin(3 * t);
const z = 40 * Math.sin(t) + 12 * Math.cos(2 * t);
waypoints.push(new THREE.Vector3(x, 0, z));
}
} else if (selectedTrack === 'snow') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const x = 65 * Math.cos(t);
const z = 32 * Math.sin(t) + (i > count/2 ? 15 * Math.sin(6 * t) : 0);
waypoints.push(new THREE.Vector3(x, 0, z));
}
} else if (selectedTrack === 'volcano') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const r = 48 + 15 * Math.cos(2 * t);
const x = r * Math.cos(t);
const z = r * Math.sin(t);
waypoints.push(new THREE.Vector3(x, 0, z));
}
} else if (selectedTrack === 'sunset') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const x = 58 * Math.cos(t) + 10 * Math.cos(4 * t);
const z = 42 * Math.sin(t);
waypoints.push(new THREE.Vector3(x, 0, z));
}
} else if (selectedTrack === 'cyber') {
for (let i = 0; i < count; i++) {
const t = (i / count) * Math.PI * 2;
const x = 60 * Math.sin(t);
const z = 40 * Math.sin(2 * t);
waypoints.push(new THREE.Vector3(x, 0, z));
}
}
}
function createTrackMesh() {
let groundMatColor = 0x2e7d32;
if (selectedTrack === 'eight') groundMatColor = 0xddaa55;
else if (selectedTrack === 'ocean') groundMatColor = 0x0077be;
else if (selectedTrack === 'castle') groundMatColor = 0xff2200;
else if (selectedTrack === 'jungle') groundMatColor = 0x0f4010;
else if (selectedTrack === 'snow') groundMatColor = 0xe8f4f8;
else if (selectedTrack === 'volcano') groundMatColor = 0xaa1100;
else if (selectedTrack === 'sunset') groundMatColor = 0xe69d45;
else if (selectedTrack === 'cyber') groundMatColor = 0x090520;
if (selectedTrack !== 'rainbow') {
const groundMat = new THREE.MeshPhongMaterial({
color: groundMatColor,
emissive: (selectedTrack === 'castle' || selectedTrack === 'volcano') ? 0x660000 : (selectedTrack === 'cyber' ? 0x110033 : 0x000000)
});
const ground = new THREE.Mesh(new THREE.PlaneGeometry(2000, 2000), groundMat);
ground.rotation.x = -Math.PI / 2;
if (selectedTrack === 'castle' || selectedTrack === 'ocean' || selectedTrack === 'volcano') {
ground.position.y = -12;
}
ground.receiveShadow = true;
scene.add(ground);
}
// リボン幾何学によるコース走路生成
const trackGeo = new THREE.BufferGeometry();
const positions = [];
const colors = [];
const uvs = [];
const count = waypoints.length;
let trackColor = new THREE.Color(0x333333);
if (selectedTrack === 'ocean') trackColor = new THREE.Color(0xeeeeee);
else if (selectedTrack === 'castle' || selectedTrack === 'volcano') trackColor = new THREE.Color(0x222228);
else if (selectedTrack === 'snow') trackColor = new THREE.Color(0x99ccff);
else if (selectedTrack === 'cyber') trackColor = new THREE.Color(0x111122);
for (let i = 0; i < count; i++) {
const current = waypoints[i];
const next = waypoints[(i + 1) % count];
const prev = waypoints[(i - 1 + count) % count];
const dir = new THREE.Vector3().subVectors(next, prev).normalize();
const perp = new THREE.Vector3(-dir.z, 0, dir.x).normalize();
const pLeft = current.clone().add(perp.clone().multiplyScalar(TRACK_WIDTH / 2));
const pRight = current.clone().sub(perp.clone().multiplyScalar(TRACK_WIDTH / 2));
positions.push(pLeft.x, pLeft.y + 0.06, pLeft.z);
positions.push(pRight.x, pRight.y + 0.06, pRight.z);
if (selectedTrack === 'rainbow' || selectedTrack === 'cyber') {
const hue = i / count;
const c = new THREE.Color().setHSL(hue, selectedTrack === 'cyber' ? 0.8 : 1.0, 0.5);
colors.push(c.r, c.g, c.b, c.r, c.g, c.b);
}
uvs.push(0, i / count * 10);
uvs.push(1, i / count * 10);
}
const indices = [];
for (let i = 0; i < count; i++) {
const nextI = (i + 1) % count;
const i0 = i * 2;
const i1 = i * 2 + 1;
const i2 = nextI * 2;
const i3 = nextI * 2 + 1;
indices.push(i0, i1, i2);
indices.push(i1, i3, i2);
}
trackGeo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
if (selectedTrack === 'rainbow' || selectedTrack === 'cyber') {
trackGeo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
}
trackGeo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
trackGeo.setIndex(indices);
trackGeo.computeVertexNormals();
let trackMaterial;
if (selectedTrack === 'rainbow' || selectedTrack === 'cyber') {
trackMaterial = new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide });
} else {
trackMaterial = new THREE.MeshPhongMaterial({ color: trackColor, side: THREE.DoubleSide });
}
rainbowMesh = new THREE.Mesh(trackGeo, trackMaterial);
rainbowMesh.receiveShadow = true;
scene.add(rainbowMesh);
// スタート&ゴールライン
const startPos = waypoints[0];
const nextPos = waypoints[1];
const startDir = new THREE.Vector3().subVectors(nextPos, startPos).normalize();
const startAngle = Math.atan2(startDir.x, startDir.z);
const finishLine = new THREE.Mesh(
new THREE.BoxGeometry(TRACK_WIDTH, 0.1, 2),
new THREE.MeshPhongMaterial({ color: 0xffffff })
);
finishLine.position.copy(startPos);
finishLine.position.y = 0.12;
finishLine.rotation.y = startAngle;
scene.add(finishLine);
// アイテムボックスの設置
const setsCount = 10;
for (let i = 0; i < setsCount; i++) {
const wpIdx = Math.floor(i * count / setsCount);
const current = waypoints[wpIdx];
const next = waypoints[(wpIdx + 1) % count];
const prev = waypoints[(wpIdx - 1 + count) % count];
const dir = new THREE.Vector3().subVectors(next, prev).normalize();
const perp = new THREE.Vector3(-dir.z, 0, dir.x).normalize();
[-0.35, 0, 0.35].forEach(offsetRatio => {
const boxPos = current.clone().add(perp.clone().multiplyScalar((TRACK_WIDTH / 2) * offsetRatio));
spawnItemBox(boxPos.x, boxPos.z);
});
}
}
function spawnItemBox(x, z) {
const box = new THREE.Mesh(
new THREE.BoxGeometry(1.8, 1.8, 1.8),
new THREE.MeshPhongMaterial({ color: 0x00ffff, transparent: true, opacity: 0.7 })
);
box.position.set(x, 1.5, z);
scene.add(box);
items.push(box);
}
function createKartMesh(color, bodyType = 'standard', tireType = 'normal') {
const group = new THREE.Group();
const bodyMat = new THREE.MeshPhongMaterial({ color: color, shininess: 60 });
if (bodyType === 'sports') {
const body = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.35, 2.6), bodyMat);
body.position.y = 0.35;
body.castShadow = true;
group.add(body);
const nose = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.15, 0.6), bodyMat);
nose.position.set(0, 0.2, 1.4);
group.add(nose);
const wingMat = new THREE.MeshPhongMaterial({ color: 0x111111 });
const wingPillar1 = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.5, 0.1), wingMat);
wingPillar1.position.set(-0.5, 0.6, -1.1);
const wingPillar2 = wingPillar1.clone();
wingPillar2.position.x = 0.5;
const wingBlade = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.08, 0.5), wingMat);
wingBlade.position.set(0, 0.85, -1.1);
group.add(wingPillar1, wingPillar2, wingBlade);
} else if (bodyType === 'buggy') {
const body = new THREE.Mesh(new THREE.BoxGeometry(1.3, 0.6, 2.2), bodyMat);
body.position.y = 0.6;
body.castShadow = true;
group.add(body);
const pipeMat = new THREE.MeshPhongMaterial({ color: 0x222222 });
const cage = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 1.2), pipeMat);
cage.rotation.z = Math.PI / 2;
cage.position.set(0, 1.25, 0);
group.add(cage);
} else if (bodyType === 'formula') {
const body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.35, 3.0), bodyMat);
body.position.y = 0.3;
group.add(body);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.2, 4), bodyMat);
nose.rotation.x = Math.PI / 2;
nose.rotation.y = Math.PI / 4;
nose.position.set(0, 0.25, 1.8);
group.add(nose);
const frontWing = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.06, 0.4), new THREE.MeshPhongMaterial({ color: 0x222222 }));
frontWing.position.set(0, 0.18, 2.0);
group.add(frontWing);
const rearWingMat = new THREE.MeshPhongMaterial({ color: 0x111111 });
const rWing = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.08, 0.5), rearWingMat);
rWing.position.set(0, 0.95, -1.2);
const pillar1 = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.6, 0.2), rearWingMat);
pillar1.position.set(-0.6, 0.65, -1.2);
const pillar2 = pillar1.clone();
pillar2.position.x = 0.6;
group.add(rWing, pillar1, pillar2);
const leftPod = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.3, 1.2), bodyMat);
leftPod.position.set(-0.65, 0.3, 0);
const rightPod = leftPod.clone();
rightPod.position.x = 0.65;
group.add(leftPod, rightPod);
} else if (bodyType === 'hover') {
const body = new THREE.Mesh(new THREE.ConeGeometry(0.85, 3.0, 8), bodyMat);
body.rotation.x = -Math.PI / 2;
body.position.set(0, 0.45, 0.2);
group.add(body);
const jetMat = new THREE.MeshPhongMaterial({ color: 0x333344, emissive: 0x00ffff, emissiveIntensity: 0.3 });
const jet1 = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.3, 0.8, 12), jetMat);
jet1.rotation.x = Math.PI / 2;
jet1.position.set(-0.45, 0.45, -1.2);
const jet2 = jet1.clone();
jet2.position.x = 0.45;
group.add(jet1, jet2);
const finMat = new THREE.MeshPhongMaterial({ color: 0x111122 });
const leftFin = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.6, 0.8), finMat);
leftFin.position.set(-0.8, 0.5, -0.6);
leftFin.rotation.z = -0.3;
const rightFin = leftFin.clone();
rightFin.position.x = 0.8;
rightFin.rotation.z = 0.3;
group.add(leftFin, rightFin);
} else if (bodyType === 'monster') {
const body = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.75, 2.5), bodyMat);
body.position.y = 0.75;
group.add(body);
const engineBlock = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.4, 0.8), new THREE.MeshPhongMaterial({ color: 0xaaaaaa, shininess: 100 }));
engineBlock.position.set(0, 1.2, 0.6);
group.add(engineBlock);
const intake = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.2, 0.3, 8), new THREE.MeshPhongMaterial({ color: 0xff3300 }));
intake.position.set(0, 1.45, 0.6);
group.add(intake);
const pipeMat = new THREE.MeshPhongMaterial({ color: 0x555555 });
const pipe1 = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.12, 1.2, 8), pipeMat);
pipe1.position.set(-0.85, 1.2, -1.0);
const pipe2 = pipe1.clone();
pipe2.position.x = 0.85;
group.add(pipe1, pipe2);
} else {
const body = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.5, 2.4), bodyMat);
body.position.y = 0.5;
body.castShadow = true;
group.add(body);
}
// ドライバー
let headY = 1.4;
if (bodyType === 'sports') headY = 1.05;
else if (bodyType === 'buggy') headY = 1.45;
else if (bodyType === 'formula') headY = 0.95;
else if (bodyType === 'hover') headY = 1.1;
else if (bodyType === 'monster') headY = 1.6;
const driverGroup = new THREE.Group();
driverGroup.position.y = headY;
if (bodyType === 'formula') driverGroup.position.z = -0.2;
const helmet = new THREE.Mesh(new THREE.SphereGeometry(0.45, 16, 16), bodyMat);
driverGroup.add(helmet);
const visorMat = new THREE.MeshPhongMaterial({ color: 0x111122, shininess: 100 });
const visor = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.22, 0.3), visorMat);
visor.position.set(0, 0.05, 0.22);
driverGroup.add(visor);
group.add(driverGroup);
// タイヤ
let radius = 0.4;
let width = 0.6;
let wheelMat = new THREE.MeshPhongMaterial({ color: 0x111111 });
if (tireType === 'slick') {
radius = 0.38;
width = 0.85;
wheelMat = new THREE.MeshPhongMaterial({ color: 0x222222, shininess: 80 });
} else if (tireType === 'big') {
radius = 0.62;
width = 0.7;
wheelMat = new THREE.MeshPhongMaterial({ color: 0x080808 });
}
const wheelGeo = new THREE.CylinderGeometry(radius, radius, width, 14);
const wheelOffsetY = radius;
const sideOffset = tireType === 'big' ? 0.95 : 0.85;
[[-sideOffset, wheelOffsetY, 0.9], [sideOffset, wheelOffsetY, 0.9], [-sideOffset, wheelOffsetY, -0.9], [sideOffset, wheelOffsetY, -0.9]].forEach(pos => {
const w = new THREE.Mesh(wheelGeo, wheelMat);
w.rotation.z = Math.PI / 2;
w.position.set(...pos);
group.add(w);
});
group.userData.bodyMat = bodyMat;
return group;
}
function createKart() {
kart = createKartMesh(selectedColor, selectedBody, selectedTire);
const startPos = waypoints[0];
const nextPos = waypoints[1];
const startDir = new THREE.Vector3().subVectors(nextPos, startPos).normalize();
player.rotation = Math.atan2(startDir.x, startDir.z);
kart.position.copy(startPos);
kart.rotation.y = player.rotation;
scene.add(kart);
}
function createEnemies() {
const allColors = [
'#ff0000', '#00dd00', '#ff88cc', '#ffcc00',
'#1e90ff', '#9d4edd', '#ff6b00', '#f1f5f9',
'#222222', '#06b6d4', '#8b4513', '#ffd700'
].filter(c => c !== selectedColor);
for (let i = allColors.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[allColors[i], allColors[j]] = [allColors[j], allColors[i]];
}
const startPos = waypoints[0];
const nextPos = waypoints[1];
const startDir = new THREE.Vector3().subVectors(nextPos, startPos).normalize();
const perp = new THREE.Vector3(-startDir.z, 0, startDir.x).normalize();
const initAngle = Math.atan2(startDir.x, startDir.z);
const bodies = ['standard', 'sports', 'buggy'];
const tires = ['normal', 'slick', 'big'];
for (let i = 0; i < 3; i++) {
const color = allColors[i];
const eMesh = createKartMesh(color, bodies[i % 3], tires[(i + 1) % 3]);
const offsetSide = (i % 2 === 0 ? 3.5 : -3.5);
const offsetBack = -6 - (i * 4);
const pos = startPos.clone()
.add(perp.clone().multiplyScalar(offsetSide))
.add(startDir.clone().multiplyScalar(offsetBack));
eMesh.position.copy(pos);
eMesh.rotation.y = initAngle;
scene.add(eMesh);
enemies.push({
mesh: eMesh,
speed: 0,
maxSpeed: 0.85 + Math.random() * 0.18,
targetIdx: 1,
spinTime: 0,
lap: 1,
progress: 0,
isFalling: false,
fallVelocity: 0
});
}
}
function createBuilding(x, z, type) {
const building = new THREE.Group();
if (type === 'skyscraper') {
const h = 12 + Math.random() * 22;
const b = new THREE.Mesh(new THREE.BoxGeometry(6, h, 6), new THREE.MeshPhongMaterial({ color: 0x334466 }));
b.position.y = h / 2;
building.add(b);
} else if (type === 'pyramid') {
const p = new THREE.Mesh(new THREE.ConeGeometry(12, 16, 4), new THREE.MeshPhongMaterial({ color: 0xedc9af }));
p.position.y = 8;
p.rotation.y = Math.PI / 4;
building.add(p);
} else if (type === 'palm') {
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.6, 8), new THREE.MeshPhongMaterial({ color: 0x8b5a2b }));
trunk.position.y = 4;
const top = new THREE.Mesh(new THREE.ConeGeometry(4, 3, 6), new THREE.MeshPhongMaterial({ color: 0x2e8b57 }));
top.position.y = 8;
building.add(trunk, top);
} else if (type === 'castle') {
const tower = new THREE.Mesh(new THREE.CylinderGeometry(3, 3, 14), new THREE.MeshPhongMaterial({ color: 0x444444 }));
tower.position.y = 7;
const roof = new THREE.Mesh(new THREE.ConeGeometry(4, 5, 8), new THREE.MeshPhongMaterial({ color: 0x880000 }));
roof.position.y = 16.5;
building.add(tower, roof);
} else if (type === 'planet') {
const p = new THREE.Mesh(new THREE.SphereGeometry(6 + Math.random() * 6, 16, 16), new THREE.MeshPhongMaterial({ color: Math.random() * 0xffffff }));
p.position.y = 15 + Math.random() * 15;
building.add(p);
} else if (type === 'tree') {
const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.8, 1.2, 7), new THREE.MeshPhongMaterial({ color: 0x5c4033 }));
trunk.position.y = 3.5;
const leaves = new THREE.Mesh(new THREE.SphereGeometry(3.5, 8, 8), new THREE.MeshPhongMaterial({ color: 0x1e5631 }));
leaves.position.y = 7.5;
building.add(trunk, leaves);
} else if (type === 'iceberg') {
const ice = new THREE.Mesh(new THREE.ConeGeometry(7, 12, 5), new THREE.MeshPhongMaterial({ color: 0xcceeff, transparent: true, opacity: 0.85 }));
ice.position.y = 6;
building.add(ice);
} else if (type === 'volcano_rock') {
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(5), new THREE.MeshPhongMaterial({ color: 0x331111 }));
rock.position.y = 3.5;
building.add(rock);
} else if (type === 'cyber_tower') {
const h = 20 + Math.random() * 25;
const tower = new THREE.Mesh(new THREE.BoxGeometry(4, h, 4), new THREE.MeshPhongMaterial({ color: 0x050515, emissive: 0x00ffff, wireframe: true }));
tower.position.y = h / 2;
building.add(tower);
}
building.position.set(x, 0, z);
scene.add(building);
}
function createEnvironment() {
if (selectedTrack === 'rainbow' || selectedTrack === 'cyber') {
const starGeo = new THREE.BufferGeometry();
const starPositions = [];
for (let i = 0; i < 800; i++) {
starPositions.push((Math.random() - 0.5) * 600, (Math.random() - 0.5) * 300 + 50, (Math.random() - 0.5) * 600);
}
starGeo.setAttribute('position', new THREE.Float32BufferAttribute(starPositions, 3));
const starMat = new THREE.PointsMaterial({ color: selectedTrack === 'cyber' ? 0x00ffff : 0xffffff, size: 1.5 });
starField = new THREE.Points(starGeo, starMat);
scene.add(starField);
}
for (let i = 0; i < 45; i++) {
const angle = Math.random() * Math.PI * 2;
const dist = 75 + Math.random() * 60;
let type = 'skyscraper';
if (selectedTrack === 'eight') type = 'pyramid';
else if (selectedTrack === 'ocean') type = 'palm';
else if (selectedTrack === 'castle') type = 'castle';
else if (selectedTrack === 'rainbow') type = 'planet';
else if (selectedTrack === 'jungle') type = 'tree';
else if (selectedTrack === 'snow') type = 'iceberg';
else if (selectedTrack === 'volcano') type = 'volcano_rock';
else if (selectedTrack === 'sunset') type = 'palm';
else if (selectedTrack === 'cyber') type = 'cyber_tower';
createBuilding(Math.cos(angle) * dist, Math.sin(angle) * dist, type);
}
}
function handleKey(e, isPressed) {
if (isFinished) return;
if (e.code === 'Space') { e.preventDefault(); if (isPressed) useItem(); return; }
switch (e.code) {
case 'ArrowUp': case 'KeyW': keys.up = isPressed; break;
case 'ArrowDown': case 'KeyS': keys.down = isPressed; break;
case 'ArrowLeft': case 'KeyA': keys.left = isPressed; break;
case 'ArrowRight': case 'KeyD': keys.right = isPressed; break;
}
}
function setupTouchEvents() {
const bind = (id, key) => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('touchstart', (e) => { e.preventDefault(); keys[key] = true; });
el.addEventListener('touchend', (e) => { e.preventDefault(); keys[key] = false; });
};
bind('btn-left', 'left'); bind('btn-right', 'right'); bind('btn-up', 'up'); bind('btn-down', 'down');
document.getElementById('btn-item')?.addEventListener('touchstart', (e) => { e.preventDefault(); useItem(); });
}
function useItem() {
if (!player.item || isFinished || player.spinTime > 0) return;
const itemType = player.item;
player.item = null;
document.getElementById('item-val').innerText = "なし";
if (itemType === 'ダッシュ') {
player.isBoosting = true;
player.speed = player.maxSpeed * 1.8;
setTimeout(() => { player.isBoosting = false; }, 1500);
} else if (itemType === 'ミサイル') {
const m = new THREE.Mesh(new THREE.SphereGeometry(0.5, 8, 8), new THREE.MeshPhongMaterial({ color: 0xff2222 }));
m.position.copy(kart.position).add(new THREE.Vector3(0, 1, 0));
m.userData = { dir: new THREE.Vector3(0, 0, 1).applyQuaternion(kart.quaternion), life: 120 };
scene.add(m);
missiles.push(m);
} else if (itemType === 'バナナ') {
const backOffset = new THREE.Vector3(0, 0.3, -2.5).applyQuaternion(kart.quaternion);
const b = new THREE.Mesh(new THREE.ConeGeometry(0.6, 0.8, 6), new THREE.MeshPhongMaterial({ color: 0xffea00 }));
b.position.copy(kart.position).add(backOffset);
b.rotation.x = Math.PI / 6;
scene.add(b);
bananas.push(b);
} else if (itemType === 'サンダー') {
const origBg = scene.background.clone();
scene.background = new THREE.Color(0xffffff);
setTimeout(() => { scene.background = origBg; }, 150);
enemies.forEach(e => { e.spinTime = 70; e.speed = 0; });
} else if (itemType === 'スター') {
player.isStar = true;
player.isBoosting = true;
setTimeout(() => {
player.isStar = false;
player.isBoosting = false;
if (kart.userData.bodyMat) kart.userData.bodyMat.color.setStyle(selectedColor);
}, 5000);
}
}
function animate() {
requestAnimationFrame(animate);
if (!isFinished) {
updatePlayer();
updateEnemies();
checkRaceProgress();
updateMissiles();
updateBananas();
updateStarEffect();
}
if (starField) starField.rotation.y += 0.0005;
updateCamera();
updateItems();
renderer.render(scene, camera);
}
function updatePlayer() {
if (player.isFalling) {
player.fallVelocity += 0.02;
kart.position.y -= player.fallVelocity;
kart.rotation.x += 0.1;
kart.rotation.z += 0.1;
player.speed *= 0.9;
if (kart.position.y < -35) {
player.isFalling = false;
player.fallVelocity = 0;
player.speed = 0;
let nearestWp = waypoints[0];
let minD = Infinity;
waypoints.forEach(wp => {
const d = kart.position.distanceTo(wp);
if (d < minD) { minD = d; nearestWp = wp; }
});
kart.position.copy(nearestWp);
kart.position.y = 0;
kart.rotation.set(0, player.rotation, 0);
}
document.getElementById('speed-val').innerText = Math.round(player.speed * 180);
return;
}
if (player.spinTime > 0) {
kart.rotation.y += 0.3;
player.spinTime--;
player.speed *= 0.8;
if (player.spinTime === 0) kart.rotation.y = player.rotation;
document.getElementById('speed-val').innerText = Math.round(player.speed * 180);
return;
}
if (keys.up) player.speed += player.acceleration;
else if (keys.down) player.speed -= player.acceleration;
else player.speed *= (1 - player.deceleration);
const max = player.isBoosting ? player.maxSpeed * 1.8 : player.maxSpeed;
player.speed = Math.min(max, Math.max(-max / 2, player.speed));
if (Math.abs(player.speed) > 0.01) {
const d = player.speed > 0 ? 1 : -1;
if (keys.left) player.rotation += player.turnSpeed * d;
if (keys.right) player.rotation -= player.turnSpeed * d;
}
kart.rotation.y = player.rotation;
const vel = new THREE.Vector3(0, 0, player.speed).applyQuaternion(kart.quaternion);
kart.position.add(vel);
if (player.isStar) {
enemies.forEach(e => {
if (kart.position.distanceTo(e.mesh.position) < 2.8) {
e.spinTime = 60;
e.speed = 0;
}
});
}
document.getElementById('speed-val').innerText = Math.round(player.speed * 180);
}
function updateEnemies() {
enemies.forEach(e => {
if (e.isFalling) {
e.fallVelocity += 0.02;
e.mesh.position.y -= e.fallVelocity;
e.mesh.rotation.x += 0.1;
e.mesh.rotation.z += 0.1;
e.speed = 0;
if (e.mesh.position.y < -35) {
e.isFalling = false;
e.fallVelocity = 0;
const respawnTarget = waypoints[e.targetIdx];
e.mesh.position.copy(respawnTarget);
e.mesh.position.y = 0;
e.mesh.rotation.set(0, Math.atan2(respawnTarget.x, respawnTarget.z), 0);
}
return;
}
if (e.spinTime > 0) {
e.mesh.rotation.y += 0.3;
e.spinTime--;
if (e.spinTime === 0) {
e.mesh.rotation.y = Math.atan2(Math.sin(e.mesh.rotation.y), Math.cos(e.mesh.rotation.y));
}
return;
}
const target = waypoints[e.targetIdx];
const dx = target.x - e.mesh.position.x;
const dz = target.z - e.mesh.position.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist > 120) {
e.mesh.position.copy(target).add(new THREE.Vector3((Math.random() - 0.5) * 3, 0, (Math.random() - 0.5) * 3));
e.speed = 0;
}
if (dist < 8) {
e.targetIdx = (e.targetIdx + 1) % waypoints.length;
if (e.targetIdx === 0) e.lap++;
}
const targetAngle = Math.atan2(dx, dz);
let angleDiff = targetAngle - e.mesh.rotation.y;
angleDiff = Math.atan2(Math.sin(angleDiff), Math.cos(angleDiff));
e.mesh.rotation.y += angleDiff * 0.12;
e.speed = Math.min(e.maxSpeed, e.speed + 0.012);
const vel = new THREE.Vector3(0, 0, e.speed).applyQuaternion(e.mesh.quaternion);
e.mesh.position.add(vel);
e.progress = e.lap * 1000 + e.targetIdx;
});
}
function updateBananas() {
for (let i = bananas.length - 1; i >= 0; i--) {
const b = bananas[i];
b.rotation.y += 0.02;
if (!player.isStar && player.spinTime <= 0 && kart.position.distanceTo(b.position) < 1.8) {
player.spinTime = 50;
player.speed = 0;
scene.remove(b);
bananas.splice(i, 1);
continue;
}
for (let e of enemies) {
if (e.spinTime <= 0 && e.mesh.position.distanceTo(b.position) < 2.0) {
e.spinTime = 60;
e.speed = 0;
scene.remove(b);
bananas.splice(i, 1);
break;
}
}
}
}
function updateStarEffect() {
if (player.isStar && kart.userData.bodyMat) {
const hue = (Date.now() % 1000) / 1000;
kart.userData.bodyMat.color.setHSL(hue, 1, 0.5);
}
}
function updateMissiles() {
for (let i = missiles.length - 1; i >= 0; i--) {
const m = missiles[i];
m.position.add(m.userData.dir.clone().multiplyScalar(1.5));
m.userData.life--;
enemies.forEach(e => {
if (m.position.distanceTo(e.mesh.position) < 3) {
e.spinTime = 60; e.speed = 0;
m.userData.life = 0;
}
});
if (m.userData.life <= 0) { scene.remove(m); missiles.splice(i, 1); }
}
}
function checkRaceProgress() {
const startWp = waypoints[0];
const halfWp = waypoints[Math.floor(waypoints.length / 2)];
if (!passedCheckpoint && kart.position.distanceTo(halfWp) < 18) {
passedCheckpoint = true;
}
if (passedCheckpoint && kart.position.distanceTo(startWp) < 14) {
if (currentLap < maxLaps) {
currentLap++;
passedCheckpoint = false;
document.getElementById('lap-val').innerText = currentLap;
} else {
isFinished = true;
document.getElementById('finish-rank').innerText = player.rank + "位でゴール!";
document.getElementById('goal-screen').style.display = 'flex';
}
}
let myWpIdx = 0;
let minDist = Infinity;
waypoints.forEach((wp, i) => {
const d = kart.position.distanceTo(wp);
if (d < minDist) { minDist = d; myWpIdx = i; }
});
player.progress = currentLap * 1000 + myWpIdx;
const fallTracks = ['rainbow', 'castle', 'ocean', 'volcano', 'cyber'];
if (minDist > TRACK_WIDTH / 2 + 1.2) {
if (fallTracks.includes(selectedTrack)) {
if (!player.isFalling && !player.isStar) {
player.isFalling = true;
player.fallVelocity = 0.1;
}
} else if (!player.isStar) {
player.speed *= selectedTrack === 'snow' ? 0.97 : 0.94;
}
}
if (fallTracks.includes(selectedTrack)) {
enemies.forEach(e => {
let eMinDist = Infinity;
waypoints.forEach(wp => {
const d = e.mesh.position.distanceTo(wp);
if (d < eMinDist) eMinDist = d;
});
if (eMinDist > TRACK_WIDTH / 2 + 2.5 && !e.isFalling) {
e.isFalling = true;
e.fallVelocity = 0.1;
}
});
}
const allRacers = [{ p: player, id: 'player' }, ...enemies.map(e => ({ p: e, id: 'enemy' }))];
allRacers.sort((a, b) => b.p.progress - a.p.progress);
player.rank = allRacers.findIndex(r => r.id === 'player') + 1;
document.getElementById('rank-val').innerText = player.rank;
}
function updateCamera() {
const offset = new THREE.Vector3(0, 4, -10).applyQuaternion(kart.quaternion);
camera.position.lerp(kart.position.clone().add(offset), 0.1);
camera.lookAt(kart.position.clone().add(new THREE.Vector3(0, 1, 2)));
}
function updateItems() {
const itemList = ['ダッシュ', 'ミサイル', 'バナナ', 'サンダー', 'スター'];
items.forEach(box => {
box.rotation.y += 0.05;
if (!isFinished && box.visible && kart.position.distanceTo(box.position) < 3) {
if (!player.item) {
const randomIndex = Math.floor(Math.random() * itemList.length);
player.item = itemList[randomIndex];
document.getElementById('item-val').innerText = player.item;
box.visible = false;
setTimeout(() => box.visible = true, 5000);
}
}
});
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
</script>
</body>
</html>
レースゲーム