ブラウザで遊べるドライブゲームImageBooster
【更新機歴】
・2025/12/22 バージョン3.0公開。
・2025/12/24 バージョン4.0公開。
・2025/12/26 バージョン5.0公開。…トンネルの外側を追加。
・テキストファイルを新規作成して、(index.html)
以下のソースコードをコピペして保存し、ファイルをクリックすると、
webブラウザが起動してゲームが遊べます。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Image Booster 5.0 Custom+</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);
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;
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, stageScore = 0;
let 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,
barrier: null,
shadow: null,
barrierRadius: 3.5
};
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;
const sharpness = Math.sin(t * Math.PI) * 150;
y += sharpness;
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);
const T = new THREE.Vector3().subVectors(forwardPoint, origin).normalize();
const tempUp = new THREE.Vector3(0, 1, 0);
const R = new THREE.Vector3().crossVectors(T, tempUp).normalize();
const U = new THREE.Vector3().crossVectors(R, T).normalize();
return { origin, T, U, R };
}
function getSectionPosition(angle, radius, basis, mode) {
// F2: フラット展開
if (mode === "F2") {
let a = angle;
while (a > Math.PI) a -= Math.PI * 2;
while (a < -Math.PI) a += Math.PI * 2;
const spread = TUBE_R * Math.PI; // 全周の展開幅
const localX = (a / Math.PI) * spread; // 角度を横位置にリニア変換
const localY = -radius; // 半径を縦位置に(地面が下)
// F2モードでは完全にフラットな座標系を使用(basis依存なし)
return new THREE.Vector3(localX, localY, basis.origin.z);
}
// F1, F3, F4, F5: 円筒座標
const localX = Math.sin(angle) * radius;
const localY = -Math.cos(angle) * radius;
return basis.origin.clone()
.addScaledVector(basis.R, localX)
.addScaledVector(basis.U, localY);
}
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 isGap(segmentIndex) {
if (segmentIndex < 10) return false;
return (segmentIndex % 25) >= 23;
}
function init() {
sound.init();
scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
scene.fog = new THREE.Fog(0x000000, 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();
// --- Player Model ---
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);
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;
playerGeo.add(thrust);
player.thrust = thrust;
const barrierGeo = new THREE.IcosahedronGeometry(3.5, 1);
const barrierMat = new THREE.MeshPhongMaterial({ color: 0x00ffff, transparent: true, opacity: 0.3, wireframe: true, emissive: 0x00aaaa, shininess: 100 });
const barrier = new THREE.Mesh(barrierGeo, barrierMat);
barrier.position.z = 3.5;
playerGeo.add(barrier);
player.barrier = barrier;
player.mesh = playerGeo;
scene.add(player.mesh);
// --- 影 (Shadow) ---
// 修正: サイズを自機幅に合わせて縮小 (2.0)
const shadowGeo = new THREE.PlaneGeometry(2.0, 2.0);
const shadowMat = new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.6 });
player.shadow = new THREE.Mesh(shadowGeo, shadowMat);
scene.add(player.shadow);
initTunnel();
window.addEventListener('keydown', (e) => {
const views = ["F1", "F2", "F3", "F4", "F5"];
if(views.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 * 1.02, 0.15, TILE_SEGMENT_LENGTH * 1.02);
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(),
side: THREE.DoubleSide // 両面描画(F4/F5対応)
});
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());
});
}
const gap = isGap(segIdx);
seg.visible = !gap;
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") {
// フラット展開時:完全に平面として扱い、すべてのタイルを同じ角度に
tile.rotation.set(0, 0, 0);
// 同じセグメント内のすべてのタイルを同じ向きに統一
// Z軸(進行方向)を基準に、X-Y平面に平行に配置
const forward = new THREE.Vector3(0, 0, 1); // 常にZ軸方向
const up = new THREE.Vector3(0, 1, 0); // 常にY軸方向
const right = new THREE.Vector3(1, 0, 0); // 常にX軸方向
const m = new THREE.Matrix4();
m.makeBasis(right, up, forward);
tile.rotation.setFromRotationMatrix(m);
} else {
// --- 修正: シリンダー状に整列 ---
// F4, F5(外側走行)の場合は外向き、それ以外は内向き
const isOutside = (viewMode === "F4" || viewMode === "F5");
// 進行方向ベクトル
const forward = basis.T.clone();
// 各タイルの法線ベクトル(中心からの方向)
const tileLocalX = Math.sin(angle);
const tileLocalY = -Math.cos(angle);
const normal = new THREE.Vector3()
.addScaledVector(basis.R, tileLocalX)
.addScaledVector(basis.U, tileLocalY)
.normalize();
// 外側なら外向き、内側なら内向き
const up = isOutside ? normal : normal.clone().multiplyScalar(-1);
// 接線方向(円周方向)
const tangent = new THREE.Vector3().crossVectors(up, forward).normalize();
// 再計算して直交化
const orthoForward = new THREE.Vector3().crossVectors(tangent, up).normalize();
const m = new THREE.Matrix4();
m.makeBasis(tangent, up, orthoForward);
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 (INNER)",
"F2": "FLAT MODE",
"F3": "3RD PERSON (INNER)",
"F4": "1ST PERSON (OUTER)",
"F5": "3RD PERSON (OUTER)"
};
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');
currentStage++;
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 5.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") {
stageScore = 0;
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>STAGE SCORE: "+stageScore+"</p><p>SPACE FOR NEXT STAGE</p>";
}
}
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;
const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
const inGap = isGap(currentSegIdx);
if (inGap) {
player.jumpV -= 0.03;
} else {
if (player.altitude > 0) player.jumpV -= 0.03;
else {
if (player.wasAirborne) { sound.play('land'); player.wasAirborne = false; }
player.altitude = 0; player.jumpV = 0;
}
}
if (player.altitude < -30) {
hp = 0;
changeState("GAMEOVER");
}
let steerFactor = 1.0;
if (player.vz > 10.0) steerFactor = 0.6;
// F2モードは直感的な左右移動なので少し感度を上げる
if (viewMode === "F2") steerFactor *= 1.8; // 1.5倍の速度に調整
let targetBank = 0;
// F4/F5(外側走行)では左右が逆になるので符号を反転
// F2モードでも座標系が違うので反転が必要
const isOutside = (viewMode === "F4" || viewMode === "F5");
const isFlat = (viewMode === "F2");
const steerDirection = (isOutside || isFlat) ? -1 : 1;
if (keys['ArrowLeft']) {
player.vAngle = THREE.MathUtils.lerp(player.vAngle, -0.12 * steerFactor * steerDirection, 0.1);
targetBank = -0.8;
}
else if (keys['ArrowRight']) {
player.vAngle = THREE.MathUtils.lerp(player.vAngle, 0.12 * steerFactor * steerDirection, 0.1);
targetBank = 0.8;
}
else player.vAngle *= 0.9;
player.angle += player.vAngle;
// F2モードの端の制限(フラット表示時の壁)
if (viewMode === "F2") {
const limit = Math.PI; // 180度
if (player.angle > limit) { player.angle = limit; player.vAngle = 0; }
else if (player.angle < -limit) { player.angle = -limit; player.vAngle = 0; }
}
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);
// F4, F5 (外側走行) の場合は半径計算を変える
const isOutside = (viewMode === "F4" || viewMode === "F5");
// 外側なら TUBE_R + altitude, 内側なら TUBE_R - altitude
// ただしタイル厚み等考慮して微調整
const r = isOutside
? TUBE_R + 1.5 + player.altitude
: TUBE_R - 1.5 - player.altitude;
const pos = getSectionPosition(player.angle, r, basis, viewMode);
const center = basis.origin.clone();
let playerUp;
if (viewMode === "F2") {
playerUp = new THREE.Vector3(0, 1, 0); // F2は常にY軸上向き(固定)
} else if (isOutside) {
// 外側走行:中心からプレイヤー方向が「上」
playerUp = new THREE.Vector3().subVectors(pos, center).normalize();
} else {
// 内側走行:プレイヤーから中心方向が「上」(内向き重力)
playerUp = new THREE.Vector3().subVectors(center, pos).normalize();
}
let playerForward;
if (viewMode === "F2") {
playerForward = new THREE.Vector3(0, 0, 1); // F2は常にZ軸方向(固定)
} else {
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" && viewMode !== "F4");
// スラスター
if (player.thrust) {
if (player.vz < 0.1) {
player.thrust.visible = false;
} else {
player.thrust.visible = true;
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;
}
}
if (player.barrier) {
const hpRatio = Math.max(0, hp / MAX_HP);
const targetScale = 0.6 + (hpRatio * 1.55);
player.barrier.scale.setScalar(targetScale);
player.barrier.rotation.y += Math.PI;
player.barrier.rotation.z += Math.PI;
player.barrierRadius = 3.5 * targetScale;
if (player.isBoosting) {
player.barrier.material.color.setHex(0xff0000);
player.barrier.material.emissive.setHex(0x550000);
} else {
player.barrier.material.color.setHex(0x00ffff);
player.barrier.material.emissive.setHex(0x004444);
}
}
// --- 影の処理修正 ---
if (player.shadow) {
const currentSegIdx = Math.floor(player.z / TILE_SEGMENT_LENGTH);
const inGap = isGap(currentSegIdx);
const isFirstPerson = (viewMode === "F1" || viewMode === "F4");
if (!isFirstPerson && !inGap) {
player.shadow.visible = true;
// 影の位置:地面(TUBE_R)の表面
// 外側モードなら TUBE_R + 0.1, 内側なら TUBE_R - 0.2
const groundR = isOutside ? TUBE_R + 0.2 : TUBE_R - 0.2;
const groundPos = getSectionPosition(player.angle, groundR, basis, viewMode);
player.shadow.position.copy(groundPos);
// 影の回転:地面の法線に合わせる
// PlaneGeometryはZ軸+が正面。
if (viewMode === "F2") {
// F2: 平面なのでシンプルに上を向く
player.shadow.rotation.set(-Math.PI/2, 0, 0);
} else {
// シリンダー: 中心を見て調整
player.shadow.lookAt(center);
if(isOutside) {
// 外側:影の面が外を向く必要がある。lookAt(center)だと内向き。
player.shadow.rotateY(Math.PI);
}
}
// ジャンプ時の影サイズ制御: 単純に小さくする (opacityは変えない)
const distFromGround = Math.max(0, player.altitude);
// 最小0.2倍まで縮小
const shadowScale = Math.max(0.2, 1.0 - distFromGround * 0.1);
player.shadow.scale.setScalar(shadowScale);
player.shadow.material.opacity = 0.6; // 固定
} else {
player.shadow.visible = false;
}
}
updateCamera(pos, orthoUp, playerForward, visualZ, isOutside);
}
function updateCamera(playerPos, playerUp, playerForward, visualZ, isOutside) {
const shake = player.isBoosting ? (Math.random()-0.5)*0.3 : 0;
if (viewMode === "F1" || viewMode === "F4") {
// 1ST PERSON
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") {
// FLAT MODE:自機のやや背後上に固定
const camPos = playerPos.clone();
camPos.z -= 30; // 背後
camPos.y += 25; // 上
camera.position.copy(camPos);
camera.up.set(0, 1, 0);
// 自機の少し前方を見る
const target = playerPos.clone();
target.z += 10;
camera.lookAt(target);
}
else {
// 3RD PERSON
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)) * 0.2;
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 {
// オブジェクトが地面に固定されている場合
const isOutside = (viewMode === "F4" || viewMode === "F5");
worldPos = getSectionPosition(obj.angle, obj.r, basis, viewMode);
if (viewMode === "F2") {
// F2モード:タイルと同じフラットな角度
const forward = new THREE.Vector3(0, 0, 1); // 常にZ軸方向
const up = new THREE.Vector3(0, 1, 0); // 常にY軸方向
const right = new THREE.Vector3(1, 0, 0); // 常にX軸方向
const m = new THREE.Matrix4();
m.makeBasis(right, up, forward);
obj.mesh.rotation.setFromRotationMatrix(m);
} else {
// F1, F3, F4, F5モード:地面の接平面に平行
// 地面の法線ベクトル(地面に対して垂直な方向)
const objLocalX = Math.sin(obj.angle);
const objLocalY = -Math.cos(obj.angle);
const groundNormal = new THREE.Vector3()
.addScaledVector(basis.R, objLocalX)
.addScaledVector(basis.U, objLocalY)
.normalize();
// 外側なら外向き、内側なら内向き(地面から見た上方向)
const up = isOutside ? groundNormal : groundNormal.clone().multiplyScalar(-1);
// 進行方向ベクトル(トンネルの接線方向)
const forward = basis.T.clone();
// 地面の接平面に沿った右方向ベクトル
// 地面に平行な方向 = 法線に垂直な方向
const right = new THREE.Vector3().crossVectors(up, forward).normalize();
// 地面に平行な前方向ベクトルを再計算(直交化)
const parallelForward = new THREE.Vector3().crossVectors(right, up).normalize();
// オブジェクトの回転行列を設定
// right: 地面に平行な横方向
// up: 地面の法線方向(地面に垂直)
// parallelForward: 地面に平行な前方向
const m = new THREE.Matrix4();
m.makeBasis(right, up, parallelForward);
obj.mesh.rotation.setFromRotationMatrix(m);
}
}
obj.mesh.position.copy(worldPos);
const dz = Math.abs(player.z - obj.z);
let collisionThreshold = 4.0;
if (obj.type === "block" && player.barrierRadius) collisionThreshold = player.barrierRadius;
if (dz < collisionThreshold && !obj.flying) {
const dist = player.mesh.position.distanceTo(obj.mesh.position);
if (dist < collisionThreshold) {
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));
let type = (Math.random() < 0.15) ? "score" : (Math.random() < 0.08) ? "heal" : "block";
if (Math.random() < hurdleRate) type = "hurdle";
// グリッドスナップ用の角度計算
// 全周16分割とかではなく、ランダムだがブロックサイズで整列させる
// TILES_PER_RING = 16 なので、それに合わせる
const seg = Math.floor(Math.random() * TILES_PER_RING);
let tubeAngle = (seg / TILES_PER_RING) * Math.PI * 2;
let length = (type === 'block') ? 3 : 1;
for(let i = 0; i < length; i++) {
// --- 修正: Z方向の位置を固定間隔で整列 ---
let zOffset = z + (i * 3.0);
let stackHeight = (type === 'block') ? Math.floor(Math.random() * (1 + currentStage)) + 1 : 1;
if(stackHeight > 5) stackHeight = 5;
for (let k = 0; k < stackHeight; k++) {
let geo;
let blockSize = 2.5;
if (type === 'hurdle') { geo = new THREE.BoxGeometry(4,4,4); blockSize=4.0; }
else if (type === 'heal') { geo = new THREE.BoxGeometry(1.5,1.5,1.5); blockSize=1.5; }
else { geo = new THREE.BoxGeometry(2.5,2.5,2.5); blockSize=2.5; }
let color;
if (type === 'block') {
const gVal = 0.3 + Math.random() * 0.5;
color = new THREE.Color(gVal, gVal, gVal);
} else {
const colors = { hurdle: 0xff3300, score: 0xffff00, heal: 0x00ff00 };
color = new THREE.Color(colors[type]);
}
// オブジェクトのマテリアルも両面描画にしておく
const mesh = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({ color: color, side: THREE.DoubleSide }));
let baseR = TUBE_R;
// F4, F5 (外側走行モード) の場合は、オブジェクトも外側に配置
const isOutside = (viewMode === "F4" || viewMode === "F5");
if (isOutside) {
// 外側モード:TUBE_Rより外側に配置
if (type === 'hurdle') baseR += 2;
else if (type === 'block') {
baseR += (1.25 + (k * 2.5));
}
else if (type === 'score') baseR += 1.25;
else baseR += 0.7;
} else {
// 内側モード(従来通り):TUBE_Rより内側に配置
if (type === 'hurdle') baseR -= 2;
else if (type === 'block') {
baseR -= (1.25 + (k * 2.5));
}
else if (type === 'score') baseR -= 1.25;
else baseR -= 0.7;
}
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);
const pts = 50; score += pts; stageScore += pts;
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);
const pts = 10; score += pts; stageScore += pts;
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');
const pts = 100; score += pts; stageScore += pts;
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;
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キー … チューブ内側モードの三人称視点に切り替え。
・F4キー … チューブ外側モードの一人称視点に切り替え。
・F5キー … チューブ外側モードの三人称視点に切り替え。
・障害物を避けて、高得点を目指しましょう。
・灰色ブロック … バリアで破壊可能。+10点。
・赤色ブロック … バリアでは防げず、HPにダメージ。
・黄緑色ブロック … HPを回復。
・黄色ブロック … +100点。
・HPが減るとバリアが小さくなる。
・HPがゼロになるとゲームオーバー。
【おまけ】
・チューブ系のゲームを検索してみたところ、
やはりいくつかあったので紹介しておきます。
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?)
・これも障害物を避けまくるゲーム。
「Redout」(2016)34BigThings
「Redout 2」(2022)34BigThings
・これはレースゲームですが、
ブーストが付いていて1000km/hまで加速可能。
とまあ、似たような雰囲気のゲームは
これまでにもあったみたいなんですが、
ぶっ壊しに主軸をおいたゲームでは無かったみたいなので、
まあまあセーフだったりするのかも知れない。(^^;
3Dのこういう真正面を向いたゲームは、
合わせるのが難しいので、
気軽に遊べるのがいいのではないだろうか。uu



コメント