ブラウザで遊べるドライブゲームImageBooster
【更新機歴】
・2025/12/22 バージョン3.0公開。
・2025/12/24 バージョン4.0公開。
・テキストファイルを新規作成して、(index.html)
以下のソースコードをコピペして保存し、ファイルをクリックすると、
webブラウザが起動してゲームが遊べます。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Image Booster 4.0</title>
<style>
body { margin: 0; overflow: hidden; background: #000; font-family: 'Arial Black', sans-serif; }
#ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; color: white; text-shadow: 2px 2px 4px #000; z-index: 10; }
#view-mode { position: absolute; top: 20px; right: 20px; font-size: 20px; color: #0f0; }
#stage-info { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); font-size: 20px; color: #ff0; }
#score-info { position: absolute; top: 20px; left: 20px; font-size: 24px; color: #fff; }
#speed-lines {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(circle, transparent 30%, rgba(255, 255, 255, 0.1) 80%),
repeating-conic-gradient(transparent 0deg, transparent 2deg, rgba(100, 255, 150, 0.3) 2.1deg, transparent 2.5deg);
opacity: 0;
transition: opacity 0.1s ease-out;
pointer-events: none;
z-index: 5;
mix-blend-mode: screen;
}
#hud-bottom { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); width: 85%; display: none; flex-direction: row; justify-content: space-around; align-items: flex-end; }
.gauge-container { width: 30%; text-align: center; }
.gauge-label { font-size: 18px; margin-bottom: 5px; }
.bar-bg { width: 100%; height: 12px; border: 2px solid #fff; border-radius: 6px; overflow: hidden; background: rgba(0,0,0,0.5); }
.bar-fill { height: 100%; width: 100%; transition: width 0.05s ease-out; }
#hp-fill { background: #adff2f; box-shadow: 0 0 10px #adff2f; }
#speed-val { font-size: 48px; line-height: 1; color: #00ffff; text-shadow: 0 0 10px #00ffff; }
#speed-unit { font-size: 20px; color: #aaa; margin-left: 5px; }
#speed-bg { }
#speed-fill { background: #00ffff; }
#time-bg { }
#time-fill { background: #ffcc00; }
#center-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; text-align: center; }
h1 { font-size: 60px; margin: 0; color: #0f0; text-shadow: 0 0 20px #0f0; }
p { font-size: 24px; margin: 10px 0; color: #fff; }
.pop-text { position: absolute; font-weight: bold; font-size: 40px; animation: moveUpFade 0.8s forwards; transform: translate(-50%, -50%); z-index: 999; }
@keyframes moveUpFade { 0% { transform: translate(-50%, 0) scale(1); opacity: 1; } 100% { transform: translate(-50%, -100px) scale(1.5); opacity: 0; } }
</style>
<script type="importmap"> { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } } </script>
</head>
<body>
<div id="speed-lines"></div>
<div id="ui-layer">
<div id="score-info">SCORE: 0</div>
<div id="stage-info">STAGE: 1 / 8</div>
<div id="view-mode">VIEW: 3RD PERSON</div>
<div id="hud-bottom">
<div class="gauge-container">
<div class="gauge-label">HP: <span id="hp-num" style="font-size: 32px;">10</span></div>
<div class="bar-bg" id="hp-bg"><div id="hp-fill" class="bar-fill"></div></div>
</div>
<div class="gauge-container">
<div style="margin-bottom:5px;">
<span id="speed-val">0</span><span id="speed-unit">km/h</span>
</div>
<div class="bar-bg" id="speed-bg"><div id="speed-fill" class="bar-fill"></div></div>
</div>
<div class="gauge-container">
<div class="gauge-label">TIME: <span id="time-num" style="font-size: 32px;">1:00</span></div>
<div class="bar-bg" id="time-bg"><div id="time-fill" class="bar-fill"></div></div>
</div>
</div>
<div id="center-text"></div>
</div>
<script type="module">
import * as THREE from 'three';
// --- Sound Manager ---
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let noiseBuffer = null;
let windSource = null, windGain = null, windFilter = null;
let jetOsc = null, jetGain = null;
function createNoiseBuffer() {
const bufferSize = audioCtx.sampleRate * 2.0;
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
return buffer;
}
const sound = {
init: () => { if (!noiseBuffer) noiseBuffer = createNoiseBuffer(); },
startEngine: () => {
if (windSource) return;
// 風切り音
windSource = audioCtx.createBufferSource();
windSource.buffer = noiseBuffer;
windSource.loop = true;
windFilter = audioCtx.createBiquadFilter();
windFilter.type = 'bandpass';
windFilter.Q.value = 1.0;
windFilter.frequency.value = 400;
windGain = audioCtx.createGain();
windGain.gain.value = 0;
windSource.connect(windFilter); windFilter.connect(windGain); windGain.connect(audioCtx.destination);
windSource.start();
// ジェット音
jetOsc = audioCtx.createOscillator();
jetOsc.type = 'sawtooth';
jetOsc.frequency.value = 800;
jetGain = audioCtx.createGain();
jetGain.gain.value = 0;
jetOsc.connect(jetGain); jetGain.connect(audioCtx.destination);
jetOsc.start();
},
updateEngine: (speedRatio, isBoosting) => {
if (!windSource) return;
const now = audioCtx.currentTime;
const targetFreq = 400 + (speedRatio * 800);
const targetVol = Math.min(0.15, speedRatio * 0.1);
windFilter.frequency.setTargetAtTime(targetFreq, now, 0.1);
windGain.gain.setTargetAtTime(targetVol, now, 0.1);
const jetVol = isBoosting ? 0.05 : 0.0;
const jetFreq = 1000 + (speedRatio * 2000);
jetGain.gain.setTargetAtTime(jetVol, now, 0.2);
jetOsc.frequency.setTargetAtTime(jetFreq, now, 0.1);
},
play: (type) => {
if (audioCtx.state === 'suspended') audioCtx.resume();
const now = audioCtx.currentTime;
if (type === 'crash') {
// ガラガラ音(岩崩れ)
// ノイズをベースに、エンベロープで長めに鳴らし、フィルターを動かす
const src = audioCtx.createBufferSource();
src.buffer = noiseBuffer;
const f = audioCtx.createBiquadFilter();
f.type = 'lowpass';
f.frequency.setValueAtTime(800, now);
f.frequency.exponentialRampToValueAtTime(100, now + 1.2); // ゆっくり下がる
const g = audioCtx.createGain();
g.gain.setValueAtTime(1.0, now);
g.gain.exponentialRampToValueAtTime(0.01, now + 1.2);
// ガラガラ感を出すための変調(AM)
const modOsc = audioCtx.createOscillator();
modOsc.type = 'square';
modOsc.frequency.value = 20; // 20Hzで揺らす
const modGain = audioCtx.createGain();
modGain.gain.value = 0.5;
modOsc.connect(modGain);
modGain.connect(g.gain); // 音量を揺らす
modOsc.start(now); modOsc.stop(now + 1.2);
src.connect(f); f.connect(g); g.connect(audioCtx.destination);
src.start(now); src.stop(now + 1.2);
} else if (type === 'heal') {
const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
osc.connect(g); g.connect(audioCtx.destination); osc.type = 'sine';
osc.frequency.setValueAtTime(600, now); osc.frequency.exponentialRampToValueAtTime(2000, now+0.4);
g.gain.setValueAtTime(0.2, now); g.gain.linearRampToValueAtTime(0, now+0.4);
osc.start(now); osc.stop(now+0.4);
} else if (type === 'boost_dash') {
const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
osc.connect(g); g.connect(audioCtx.destination); osc.type = 'triangle';
osc.frequency.setValueAtTime(150, now); osc.frequency.exponentialRampToValueAtTime(50, now + 0.3);
g.gain.setValueAtTime(0.5, now); g.gain.exponentialRampToValueAtTime(0.01, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
} else if (type === 'jump') {
const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
osc.connect(g); g.connect(audioCtx.destination); osc.type = 'triangle';
osc.frequency.setValueAtTime(150, now); osc.frequency.linearRampToValueAtTime(300, now+0.2);
g.gain.setValueAtTime(0.3, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.3);
osc.start(now); osc.stop(now+0.3);
} else if (type === 'land') {
const src = audioCtx.createBufferSource(); src.buffer = noiseBuffer;
const f = audioCtx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.setValueAtTime(200, now);
const g = audioCtx.createGain(); g.gain.setValueAtTime(0.6, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.15);
src.connect(f); f.connect(g); g.connect(audioCtx.destination); src.start(now); src.stop(now+0.15);
} else if (type === 'coin') {
const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
osc.connect(g); g.connect(audioCtx.destination); osc.type = 'square';
osc.frequency.setValueAtTime(1200, now); osc.frequency.setValueAtTime(1800, now+0.05);
g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1);
osc.start(now); osc.stop(now+0.1);
} else if (type === 'ui') {
const osc = audioCtx.createOscillator(); const g = audioCtx.createGain();
osc.connect(g); g.connect(audioCtx.destination); osc.frequency.setValueAtTime(880, now);
g.gain.setValueAtTime(0.1, now); g.gain.exponentialRampToValueAtTime(0.01, now+0.1);
osc.start(now); osc.stop(now+0.1);
}
}
};
// --- Config ---
const MAX_HP = 10, TUBE_R = 18, MAX_TIME = 60; // 15 -> 18(タイルを外側に)
const NORMAL_MAX_SPEED = 5.0;
const BOOST_MAX_SPEED = 20.0;
const NORMAL_ACCEL = 0.08;
const BOOST_ACCEL = 0.5;
const TOTAL_STAGES = 8;
let scene, camera, renderer;
let gameState = "TITLE", viewMode = "F3";
let score = 0, hp = MAX_HP, timeLeft = MAX_TIME;
let currentStage = 1;
let player = {
mesh: null,
angle: 0,
x: 0,
z: 0,
vz: 0.2, vAngle: 0, vx: 0,
altitude: 0, jumpV: 0,
surge: 0, bank: 0,
isBoosting: false,
dashOffset: 0,
wasAirborne: false
};
let worldObjects = [], debris = [], stars;
let keys = {};
let nextSpawnZ = 50;
// --- Tunnel & Curve Logic ---
const TILE_SEGMENT_LENGTH = 8;
const VISIBLE_SEGMENTS = 40;
const TILES_PER_RING = 16;
let tunnelSegments = [];
// カーブ関数:基本は緩やかな左右カーブ + 急な上下カーブ
function getCurve(z) {
const scale = 0.002;
// 基本的な緩やかな左右カーブ
let x = Math.sin(z * scale) * 120 + Math.sin(z * scale * 2.3) * 60;
// 基本的な緩やかな上下カーブ
let y = Math.cos(z * scale * 0.7) * 90 + Math.sin(z * scale * 1.5) * 50;
// 急カーブ区間を追加(上り坂・下り坂)
const sharpCurveInterval = 4000; // 急カーブの間隔
const sharpCurveWidth = 400; // 急カーブの幅
const segmentInCycle = z % sharpCurveInterval;
if (segmentInCycle < sharpCurveWidth) {
// 急な上下カーブ区間
const t = segmentInCycle / sharpCurveWidth; // 0 -> 1
const sharpness = Math.sin(t * Math.PI) * 150; // 上下の急カーブ
// Y軸(上下)に大きな変化を加える
y += sharpness;
// X軸(左右)は緩やかに
x += Math.sin(z * 0.003) * 30;
}
return new THREE.Vector3(x, y, z);
}
// コースの基準ベクトル(接線、法線、従法線)を取得
function getBasis(z) {
const origin = getCurve(z);
const forwardPoint = getCurve(z + 1.0);
// Tangent (Z軸進行方向)
const T = new THREE.Vector3().subVectors(forwardPoint, origin).normalize();
// 仮の上方向
const tempUp = new THREE.Vector3(0, 1, 0);
// Right (Binormal)
const R = new THREE.Vector3().crossVectors(T, tempUp).normalize();
// Corrected Up (Normal)
const U = new THREE.Vector3().crossVectors(R, T).normalize();
return { origin, T, U, R };
}
// 断面上の座標を計算する関数
function getSectionPosition(angle, radius, basis, mode) {
let localX, localY;
if (mode === "F2") {
// F2モード:下半分のU字型(ハーフパイプ)
// 角度を単純にマッピングするのではなく、全周のタイルを下半分のU字ラインに再配置する
// angleは 0~2PI なので、これを -PI/2 ~ PI/2 (下半分の弧) に圧縮してマッピング
// ただし、angle 0 が下(6時)。
// プレイヤーの操作感と合わせるため、以下のように変換
// 入力 angle: 0(下), PI(上)
// U字展開: 0を中心として左右に広げる
let a = angle;
// 正規化 (-PI ~ PI)
while (a > Math.PI) a -= Math.PI * 2;
while (a < -Math.PI) a += Math.PI * 2;
// F2ではトンネルを開いて、緩やかなU字谷にする
// a (回転角) を横方向の位置(spread)に変換
const spread = radius * 0.9; // 道幅を半分に(1.8 -> 0.9)
localX = a / Math.PI * spread * 2.5; // 横に広く
// Y座標は放物線的(U字)にする
// 中心(0)が低く、端が高い
localY = (a * a * 0.8) - radius;
} else {
// 通常トンネルモード (円形)
localX = Math.sin(angle) * radius;
localY = -Math.cos(angle) * radius;
}
// ワールド座標に変換
const pos = basis.origin.clone()
.addScaledVector(basis.R, localX)
.addScaledVector(basis.U, localY);
return pos;
}
function getGrassColor() {
const hue = 0.2 + (Math.random() * 0.15);
const sat = 0.2 + (Math.random() * 0.3);
const val = 0.3 + (Math.random() * 0.3);
return new THREE.Color().setHSL(hue, sat, val);
}
function init() {
sound.init();
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.fog = new THREE.Fog(0x000000, 50, 600); // 20,300 -> 50,600に変更
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(80, window.innerWidth / window.innerHeight, 0.1, 1000);
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
const sun = new THREE.DirectionalLight(0xffffee, 1.0);
sun.position.set(0, 100, -50);
scene.add(sun);
const rim = new THREE.DirectionalLight(0x00aaff, 0.8);
rim.position.set(0, -50, 50);
scene.add(rim);
createStars();
// プレイヤーモデル(正方形の箱型 + ロケット噴射)
const playerGeo = new THREE.Group();
const body = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshPhongMaterial({ color: 0x00ffff, emissive: 0x004444 }));
playerGeo.add(body);
// ロケット噴射エフェクト(長い円錐形)
const thrustGeo = new THREE.ConeGeometry(0.8, 8, 8); // 長さ3 -> 8
const thrustMat = new THREE.MeshBasicMaterial({
color: 0xff6600,
transparent: true,
opacity: 0.7
});
const thrust = new THREE.Mesh(thrustGeo, thrustMat);
thrust.rotation.x = Math.PI / 2; // 後ろ向きに
thrust.position.z = -5; // 自機の後ろ(-2.5 -> -5)
playerGeo.add(thrust);
player.thrust = thrust; // 後で制御できるように保存
player.mesh = playerGeo;
scene.add(player.mesh);
initTunnel();
window.addEventListener('keydown', (e) => {
if(["F1", "F2", "F3"].includes(e.code)) {
e.preventDefault();
viewMode = e.code;
updateViewLabel();
}
keys[e.code] = true;
if(e.code === 'Space') { e.preventDefault(); handleSpace(); }
if(e.code === 'KeyX') handleJump();
if(e.code === 'KeyZ' && !player.isBoosting && gameState === 'PLAYING') {
player.dashOffset = 0;
sound.play('boost_dash');
}
});
window.addEventListener('keyup', (e) => { keys[e.code] = false; });
updateViewLabel();
changeState("TITLE");
animate();
}
function initTunnel() {
// タイルの幅を計算(円周をタイル数で割る)
const tileWidth = (TUBE_R * 2 * Math.PI) / TILES_PER_RING;
const boxGeo = new THREE.BoxGeometry(tileWidth * 0.95, 0.15, TILE_SEGMENT_LENGTH * 0.95); // 厚さ 0.3 -> 0.15
for (let i = 0; i < VISIBLE_SEGMENTS; i++) {
const segmentGroup = new THREE.Group();
segmentGroup.userData = { zIndex: i };
for (let j = 0; j < TILES_PER_RING; j++) {
const mat = new THREE.MeshPhongMaterial({
color: getGrassColor(),
transparent: true,
opacity: 0.7
});
const tile = new THREE.Mesh(boxGeo, mat);
segmentGroup.add(tile);
}
tunnelSegments.push(segmentGroup);
scene.add(segmentGroup);
}
}
function updateTunnel() {
const playerSegmentIndex = Math.floor(player.z / TILE_SEGMENT_LENGTH);
const startSegment = playerSegmentIndex - 5;
tunnelSegments.forEach((seg) => {
let segIdx = seg.userData.zIndex;
if (segIdx < startSegment) {
segIdx += VISIBLE_SEGMENTS;
seg.userData.zIndex = segIdx;
seg.children.forEach(tile => {
tile.material.color.copy(getGrassColor());
tile.visible = true;
});
// 穴あき処理
const gapNoise = Math.sin(segIdx * 0.3) + Math.sin(segIdx * 0.1);
if (gapNoise < -0.8) seg.children.forEach(t => t.visible = false);
else if (gapNoise < 0) seg.children.forEach(t => { if(Math.random() < 0.3) t.visible = false; });
}
// セグメント配置
const zPos = segIdx * TILE_SEGMENT_LENGTH;
const basis = getBasis(zPos);
seg.children.forEach((tile, j) => {
const angle = (j / TILES_PER_RING) * Math.PI * 2;
// 位置決定(完璧な円形配置)
const pos = getSectionPosition(angle, TUBE_R, basis, viewMode);
tile.position.copy(pos);
if (viewMode === "F2") {
// F2モードの回転
const center = basis.origin.clone();
const virtualCenter = center.clone().add(basis.U.clone().multiplyScalar(40));
let tileUp = new THREE.Vector3().subVectors(pos, virtualCenter).normalize();
const tileForward = basis.T.clone();
const tileRight = new THREE.Vector3().crossVectors(tileForward, tileUp).normalize();
const correctedTileUp = new THREE.Vector3().crossVectors(tileRight, tileForward).normalize();
const m = new THREE.Matrix4();
m.makeBasis(tileRight, correctedTileUp, tileForward);
tile.rotation.setFromRotationMatrix(m);
} else {
// 通常モード:円筒の内壁として完璧な配置
//
// ローカル座標での円形配置から法線を計算
const localX = Math.sin(angle) * TUBE_R;
const localY = -Math.cos(angle) * TUBE_R;
// ローカル座標系での法線(円の中心から外向き)
// 中心は(0,0)なので、法線は単純に(localX, localY)の方向
const localNormal = new THREE.Vector2(localX, localY).normalize();
// ワールド座標系での法線ベクトル
// basis.R が横方向、basis.U が縦方向
const worldNormal = new THREE.Vector3()
.addScaledVector(basis.R, localNormal.x)
.addScaledVector(basis.U, localNormal.y)
.normalize();
// タイルの座標系を構築
const forward = basis.T.clone(); // 進行方向
const up = worldNormal; // 法線方向(外向き)
const right = new THREE.Vector3().crossVectors(forward, up).normalize();
const correctedUp = new THREE.Vector3().crossVectors(right, forward).normalize();
// 回転行列を作成
const m = new THREE.Matrix4();
m.makeBasis(right, correctedUp, forward);
tile.rotation.setFromRotationMatrix(m);
}
});
});
}
function createStars() {
const starGeo = new THREE.BufferGeometry();
const starCount = 3000;
const posArray = new Float32Array(starCount * 3);
for(let i=0; i<starCount * 3; i++) posArray[i] = (Math.random() - 0.5) * 1000;
starGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
const starMat = new THREE.PointsMaterial({color: 0xffffff, size: 1.5, transparent: true, opacity: 0.8});
stars = new THREE.Points(starGeo, starMat);
scene.add(stars);
}
function updateStars() {
if(stars) {
stars.position.set(0, 0, player.z);
stars.rotation.z += 0.0002;
}
}
function updateViewLabel() {
const labels = { "F1": "1ST PERSON", "F2": "U-PIPE MODE", "F3": "3RD PERSON" };
document.getElementById('view-mode').innerText = "VIEW: " + labels[viewMode];
}
function handleSpace() {
if (gameState === "TITLE" || gameState === "GAMEOVER" || gameState === "ALL_CLEAR") {
sound.play('ui');
sound.startEngine();
if(gameState !== "TITLE") { score = 0; currentStage = 1; }
changeState("STAGE_START");
}
else if (gameState === "STAGE_CLEAR") { sound.play('ui'); changeState("STAGE_START"); }
else if (gameState === "STAGE_START") { sound.play('ui'); changeState("PLAYING"); }
}
function handleJump() {
if (gameState === "PLAYING" && player.altitude <= 0.01) {
sound.play('jump');
player.jumpV = 0.8;
player.wasAirborne = true;
}
}
function changeState(s) {
gameState = s;
document.getElementById('hud-bottom').style.display = (s === "PLAYING") ? "flex" : "none";
const menu = document.getElementById('center-text');
document.getElementById('stage-info').innerText = `STAGE: ${currentStage} / ${TOTAL_STAGES}`;
document.getElementById('speed-lines').style.opacity = 0;
if(s === "TITLE") menu.innerHTML = "<h1>Image Booster 4.0</h1><p style='color:#adff2f;'>ROCK & ROLL</p><p>PRESS SPACE TO START</p><p style='font-size:18px; color:#aaa;'>[UP]: ACCEL / [DOWN]: BRAKE / [Z]: BOOST / [X]: JUMP</p>";
else if(s === "STAGE_START") { menu.innerHTML = "<h1>STAGE "+currentStage+"</h1><p>SPACE: GO!</p>"; resetPlayerPos(); }
else if(s === "PLAYING") menu.innerHTML = "";
else if(s === "STAGE_CLEAR") {
if (currentStage >= TOTAL_STAGES) changeState("ALL_CLEAR");
else { menu.innerHTML = "<h1 style='color:#0f0;'>STAGE CLEAR!</h1><p>SCORE: "+score+"</p><p>SPACE FOR NEXT STAGE</p>"; currentStage++; }
}
else if(s === "ALL_CLEAR") menu.innerHTML = "<h1 style='color:#0ff;'>ALL CLEARED!</h1><p>FINAL SCORE: "+score+"</p><p>THANK YOU FOR PLAYING</p><p>SPACE TO TITLE</p>";
else if(s === "GAMEOVER") menu.innerHTML = "<h1 style='color:red;'>GAME OVER</h1><p>SCORE: "+score+"</p><p>SPACE TO RESTART</p>";
}
function resetPlayerPos() {
player.angle = 0; player.x = 0; player.z = 0; player.vz = 0.5; player.altitude = 0; player.jumpV = 0;
player.surge = 0; player.bank = 0; player.dashOffset = 0;
hp = MAX_HP; timeLeft = MAX_TIME;
worldObjects.forEach(obj => scene.remove(obj.mesh));
debris.forEach(d => scene.remove(d));
worldObjects = []; debris = [];
nextSpawnZ = 50;
tunnelSegments.forEach((seg, i) => { seg.userData.zIndex = i; });
}
function animate() {
requestAnimationFrame(animate);
if (gameState === "PLAYING") {
updatePhysics();
updateObjects();
updateDebris();
}
if(gameState === "PLAYING") {
const ratio = player.vz / BOOST_MAX_SPEED;
sound.updateEngine(ratio, player.isBoosting);
} else sound.updateEngine(0, false);
updateTunnel();
updatePlayerVisuals();
updateStars();
renderer.render(scene, camera);
}
function updatePhysics() {
let targetMax = NORMAL_MAX_SPEED;
let currentAccel = 0;
player.isBoosting = false;
if (keys['KeyZ']) {
player.isBoosting = true;
targetMax = BOOST_MAX_SPEED;
currentAccel = BOOST_ACCEL;
player.surge = THREE.MathUtils.lerp(player.surge, 6.0, 0.05);
if(player.dashOffset < 15.0) player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 20.0, 0.2);
}
else if (keys['ArrowUp']) {
targetMax = NORMAL_MAX_SPEED;
currentAccel = NORMAL_ACCEL;
player.surge = THREE.MathUtils.lerp(player.surge, 2.0, 0.05);
player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 0.1);
}
else {
currentAccel = 0;
player.surge = THREE.MathUtils.lerp(player.surge, 0, 0.05);
player.dashOffset = THREE.MathUtils.lerp(player.dashOffset, 0, 0.1);
}
if (keys['ArrowDown']) {
player.vz *= 0.92;
player.surge = THREE.MathUtils.lerp(player.surge, -2.0, 0.1);
}
if (currentAccel > 0) {
if (player.vz < targetMax) player.vz += currentAccel;
else player.vz *= 0.98;
} else player.vz *= 0.98;
player.vz = Math.max(0.1, player.vz);
const lineOpacity = Math.max(0, (player.vz - 8) / 10.0);
document.getElementById('speed-lines').style.opacity = lineOpacity;
player.altitude += player.jumpV;
if (player.altitude > 0) player.jumpV -= 0.03;
else {
if (player.wasAirborne) { sound.play('land'); player.wasAirborne = false; }
player.altitude = 0; player.jumpV = 0;
}
// --- 旋回制御(修正済み)---
let steerFactor = 1.0;
if (player.vz > 10.0) steerFactor = 0.6;
// F2モードの速度調整を削除(操作が効くように)
// if (viewMode === "F2") steerFactor *= 0.5; // この行を削除
let targetBank = 0;
// F1/F3モードでの操作修正
// 左キー: 負の回転(vAngle-) -> 画面上では左へ
// 右キー: 正の回転(vAngle+) -> 画面上では右へ
if (keys['ArrowLeft']) {
player.vAngle = THREE.MathUtils.lerp(player.vAngle, -0.12 * steerFactor, 0.1);
targetBank = -0.8;
}
else if (keys['ArrowRight']) {
player.vAngle = THREE.MathUtils.lerp(player.vAngle, 0.12 * steerFactor, 0.1);
targetBank = 0.8;
}
else player.vAngle *= 0.9;
player.angle += player.vAngle;
player.bank = THREE.MathUtils.lerp(player.bank, targetBank, 0.1);
player.z += player.vz;
timeLeft -= 1/60;
if (timeLeft <= 0) { timeLeft = 0; changeState("STAGE_CLEAR"); }
if (hp <= 0) { hp = 0; changeState("GAMEOVER"); }
updateUI();
}
function updatePlayerVisuals() {
const visualZ = player.z + player.surge + player.dashOffset;
const basis = getBasis(visualZ);
const r = TUBE_R - 1.5 - player.altitude;
const pos = getSectionPosition(player.angle, r, basis, viewMode);
// 姿勢計算
const center = basis.origin.clone();
// 基本Upベクトル
let playerUp = new THREE.Vector3().subVectors(center, pos).normalize();
// F2のU字谷では、プレイヤーのUpベクトルも仮想中心(上空)に向ける
if (viewMode === "F2") {
const virtualCenter = center.clone().add(basis.U.clone().multiplyScalar(40));
playerUp = new THREE.Vector3().subVectors(virtualCenter, pos).normalize();
}
const playerForward = basis.T.clone();
player.mesh.position.copy(pos);
const m = new THREE.Matrix4();
const right = new THREE.Vector3().crossVectors(playerForward, playerUp).normalize();
const orthoUp = new THREE.Vector3().crossVectors(right, playerForward).normalize();
m.makeBasis(right, orthoUp, playerForward);
player.mesh.rotation.setFromRotationMatrix(m);
player.mesh.rotateZ(player.bank);
player.mesh.visible = (viewMode !== "F1");
// ロケット噴射エフェクトの更新
if (player.thrust) {
const speedRatio = player.vz / BOOST_MAX_SPEED;
player.thrust.scale.z = 0.5 + speedRatio * 1.5; // 速度に応じて伸びる
player.thrust.material.opacity = 0.4 + speedRatio * 0.4;
// ブースト時は色を変える
if (player.isBoosting) {
player.thrust.material.color.setHex(0xff00ff); // マゼンタ
} else {
player.thrust.material.color.setHex(0xff6600); // オレンジ
}
// 揺らめき効果
player.thrust.scale.x = 0.8 + Math.random() * 0.4;
player.thrust.scale.y = 0.8 + Math.random() * 0.4;
}
updateCamera(pos, orthoUp, playerForward, visualZ);
}
function updateCamera(playerPos, playerUp, playerForward, visualZ) {
const shake = player.isBoosting ? (Math.random()-0.5)*0.3 : 0;
if (viewMode === "F1") {
const eyePos = playerPos.clone().addScaledVector(playerForward, 2.0).addScaledVector(playerUp, 0.5);
camera.position.copy(eyePos);
camera.position.y += shake;
camera.up.copy(playerUp);
const target = eyePos.clone().add(playerForward);
camera.lookAt(target);
}
else if (viewMode === "F2") {
// U-PIPE Mode: 自機のやや後ろ上から見下ろす
const offset = new THREE.Vector3(0, 30, -40); // 後ろ上の位置
camera.position.copy(playerPos).add(offset);
camera.up.set(0, 1, 0);
camera.lookAt(playerPos); // 自機を注視
}
else {
const camDist = 20 + (player.vz * 1.0);
const camHeight = 6 + (player.vz * 0.2);
const camPos = playerPos.clone()
.addScaledVector(playerForward, -camDist)
.addScaledVector(playerUp, camHeight);
camera.position.copy(camPos);
camera.position.addScaledVector(camera.position.clone().cross(playerForward).normalize(), shake);
camera.up.copy(playerUp);
const target = playerPos.clone().addScaledVector(playerForward, 20);
camera.lookAt(target);
}
}
function updateObjects() {
let spawnDist = Math.max(10, 30 - (currentStage * 2.0)) + (player.vz * 2.0);
if (player.z + 600 > nextSpawnZ) {
spawnObject(nextSpawnZ);
nextSpawnZ += Math.random() * 10 + spawnDist;
}
for (let i = worldObjects.length - 1; i >= 0; i--) {
const obj = worldObjects[i];
if(obj.flying) {
obj.localPos.add(obj.vel);
obj.mesh.rotation.x += 0.1;
obj.mesh.rotation.y += 0.1;
}
const objVisualZ = obj.z;
const basis = getBasis(objVisualZ);
let worldPos;
if (obj.flying) {
worldPos = basis.origin.clone().add(obj.localPos);
} else {
worldPos = getSectionPosition(obj.angle, obj.r, basis, viewMode);
// オブジェクトの姿勢制御(タイルと同様に面に合わせる)
const center = basis.origin.clone();
let up = new THREE.Vector3().subVectors(center, worldPos).normalize();
if(viewMode === "F2") {
const virtualCenter = center.clone().add(basis.U.clone().multiplyScalar(40));
up = new THREE.Vector3().subVectors(virtualCenter, worldPos).normalize();
}
const forward = basis.T.clone();
const m = new THREE.Matrix4();
const right = new THREE.Vector3().crossVectors(forward, up).normalize();
const orthoUp = new THREE.Vector3().crossVectors(right, forward).normalize();
m.makeBasis(right, orthoUp, forward);
obj.mesh.rotation.setFromRotationMatrix(m);
}
obj.mesh.position.copy(worldPos);
const dz = Math.abs(player.z - obj.z);
if (dz < 2.5 && !obj.flying) {
// 衝突判定
// F2モードでの座標変換によるズレを考慮し、角度差判定を甘くするか、距離判定にする
// ここでは簡易的に「位置距離」で判定する
const dist = player.mesh.position.distanceTo(obj.mesh.position);
if (dist < 4.0) { // 判定距離
handleCollision(obj, i);
if(!obj.flying && obj.type !== "block" && obj.type !== "hurdle") {
scene.remove(obj.mesh);
worldObjects.splice(i, 1);
continue;
}
}
}
if (obj.z < player.z - 50) {
scene.remove(obj.mesh);
worldObjects.splice(i, 1);
}
}
}
function spawnObject(z) {
let hurdleRate = Math.min(0.5, 0.15 + (currentStage * 0.05));
// ブロック出現率をさらに上げる(デフォルトをblockに)
let type = (Math.random() < 0.15) ? "score" : (Math.random() < 0.08) ? "heal" : "block";
if (Math.random() < hurdleRate) type = "hurdle";
const colors = { block: 0xaa5533, hurdle: 0xff3300, score: 0xffff00, heal: 0x00ff00 };
let stackCount = (type === 'block') ? Math.floor(Math.random() * 7) + 4 : 1; // 4~10個(1~5 -> 4~10)
let tubeAngle = Math.random()*Math.PI*2;
for (let k = 0; k < stackCount; k++) {
let geo = (type === 'hurdle') ? new THREE.BoxGeometry(4,4,4) : ( (type==='score'||type==='heal') ? new THREE.BoxGeometry(1.5,1.5,1.5) : new THREE.BoxGeometry(2.5,2.5,2.5) );
const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: colors[type] }));
let zOffset = z + (k * 4.0);
let baseR = TUBE_R;
if (type === 'hurdle') baseR -= 2;
else if (type === 'block') baseR -= 1.25;
else baseR -= 0.7;
// localPos初期化用(F2で位置が変わるのであくまで相対保存用)
const lx = Math.sin(tubeAngle) * baseR;
const ly = -Math.cos(tubeAngle) * baseR;
scene.add(mesh);
worldObjects.push({
mesh, type, flying: false,
z: zOffset,
angle: tubeAngle,
r: baseR,
localPos: new THREE.Vector3(lx, ly, 0),
vel: new THREE.Vector3()
});
}
}
function handleCollision(obj, index) {
const isHighSpeed = player.vz >= (NORMAL_MAX_SPEED + 2.0);
if (obj.type === "block") {
if (isHighSpeed) {
sound.play('crash');
createExplosion(obj.mesh.position, obj.mesh.material.color, 1.5);
scene.remove(obj.mesh);
worldObjects.splice(index, 1);
score += 50; showPopText("SMASH! +50", "#ffaa00");
} else {
sound.play('crash');
// 破片を飛び散らせる
createExplosion(obj.mesh.position, obj.mesh.material.color, 0.8);
obj.flying = true;
obj.vel.set((Math.random()-0.5)*2, 2, 5);
score += 10; showPopText("+10", "#ffaa00");
player.vz *= 0.8;
}
} else if (obj.type === "hurdle") {
sound.play('crash');
if (player.vz > 15.0) {
hp -= 1; showPopText("IMPACT! HP -1", "#ff5500");
obj.flying = true;
obj.vel.set((Math.random()-0.5)*5, 5, 10);
player.vz *= 0.7;
} else {
hp -= 2; showPopText("CRASH! HP -2", "#ff0000");
obj.flying = true;
obj.vel.set(0, 2, 2);
player.vz *= 0.5;
}
}
else if (obj.type === "score") {
sound.play('coin');
score += 100; showPopText("+100", "#ffff00");
scene.remove(obj.mesh);
worldObjects.splice(index, 1);
}
else if (obj.type === "heal") {
sound.play('heal');
hp = Math.min(MAX_HP, hp + 1); showPopText("RECOVER!", "#00ff00");
scene.remove(obj.mesh);
worldObjects.splice(index, 1);
}
}
function createExplosion(pos, color, scaleFactor) {
const count = 8 * scaleFactor;
for(let i=0; i<count; i++) {
const size = 0.6 * scaleFactor;
const mesh = new THREE.Mesh(new THREE.BoxGeometry(size, size, size), new THREE.MeshPhongMaterial({ color }));
mesh.position.copy(pos);
scene.add(mesh);
const vel = new THREE.Vector3((Math.random()-0.5)*10, (Math.random()-0.5)*10, (Math.random()-0.5)*10);
debris.push({ mesh, vel, life: 60 });
}
}
function updateDebris() {
for(let i = debris.length - 1; i >= 0; i--) {
const d = debris[i];
d.mesh.position.add(d.vel);
d.mesh.rotation.x += 0.2;
d.life--;
if(d.life <= 0) {
scene.remove(d.mesh);
debris.splice(i, 1);
}
}
}
function showPopText(text, color) {
const div = document.createElement('div');
div.className = 'pop-text';
div.style.color = color;
div.innerText = text;
div.style.left = "50%"; div.style.top = "40%";
document.getElementById('ui-layer').appendChild(div);
setTimeout(() => div.remove(), 800);
}
function updateUI() {
document.getElementById('score-info').innerText = "SCORE: " + score;
// HP表示(数字を大きく)
document.getElementById('hp-num').innerText = hp;
document.getElementById('hp-fill').style.width = Math.max(0, (hp / MAX_HP * 100)) + "%";
const spd = (player.vz * 40).toFixed(0);
document.getElementById('speed-val').innerText = spd;
const speedRatio = player.vz / BOOST_MAX_SPEED;
document.getElementById('speed-fill').style.width = Math.min(100, speedRatio * 100) + "%";
if (player.isBoosting) document.getElementById('speed-fill').style.backgroundColor = "#ff00ff";
else document.getElementById('speed-fill').style.backgroundColor = "#00ff88";
// タイム表示(残り時間を分:秒で表示)
const remainingTime = Math.max(0, timeLeft);
const minutes = Math.floor(remainingTime / 60);
const seconds = Math.floor(remainingTime % 60);
const timeStr = minutes + ":" + (seconds < 10 ? "0" : "") + seconds;
document.getElementById('time-num').innerText = timeStr;
document.getElementById('time-fill').style.width = (timeLeft / MAX_TIME * 100) + "%";
}
init();
</script>
</body>
</html>・ダウンロードされる方はこちら。↓
【操作説明】
・左右キー … 自機を左右に移動。
・上キー … アクセル。
・下キー … ブレーキ。
・Zキー … ブースト。
・Xキー … ジャンプ。
・F1キー … チューブモードの一人称視点に切り替え。
・F2キー … 平面モードに切り替え。
・F3キー … チューブモードの三人称視点に切り替え。
・障害物を避けて、高得点を目指しましょう。
【おまけ】
・チューブ系のゲームを検索してみたところ、
やはりいくつかあったので紹介しておきます。
1981 Atari Tempest Arcade
・これはレースゲームというか、シューティングみたいな感じですね。
1983 ジャイラス コナミ
・これもシューティングゲームで、やや滑らかに回る感じ。
Bullfrog - Tube - 1995
・かなりレースゲームっぽい感じになってきました。
Ballistics (2001) - PC
これは障害物を避けていくパターンで、一応、レースゲームなのかな?
Tube Slider (2003) GameCube
・これは任天堂のレースゲームで、
「F-ZERO」の後継シリーズとして開発されていたとのこと。
Space Giraffe(2007)Llamasoft
・これはまたシューティングゲームで、テンペストっぽい感じ。
「Race The Sun」Flippfly (2013)
・これはチューブ系ではないものの、平面モードはわりと近いかも。
「Thumper」Drool(2016)
・これはリズム系のゲームで、
タイミングを合わせると障害物を回避できる。
「Tunnel Rush」(2024?)
・これも障害物を避けまくるゲーム。
とまあ、似たような雰囲気のゲームは
これまでにもあったみたいなんですが、
ぶっ壊しに主軸をおいたゲームでは無かったみたいなので、
まあまあセーフだったりするのかも知れない。(^^;
3Dのこういう真正面を向いたゲームは、
合わせるのが難しいので、
気軽に遊べるのがいいのではないだろうか。uu



コメント