建設系レースゲーム「BuildRace」
・ダウンロードされる方はこちら。↓
https://drive.google.com/drive/folders/1ebG3fyxw6jS63gLVqAc603ti-sJryFip?ths=true
※このゲームで遊ぶには、いつものテスト用ブラウザが必要です。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>BuildRace</title>
<style>
body { margin: 0; overflow: hidden; background-color: #000; font-family: sans-serif; user-select: none; }
#ui {
position: absolute; top: 10px; left: 10px; color: white;
text-shadow: 2px 2px 4px #000; pointer-events: none;
z-index: 10;
}
h1 { margin: 0 0 5px 0; font-size: 28px; color: #ffeb3b; }
#timerBox { font-size: 24px; font-weight: bold; color: #ff5252; margin-bottom: 10px; }
#instructions { font-size: 13px; line-height: 1.5; background: rgba(0,0,0,0.6); padding: 10px; border-radius: 8px; }
.highlight { color: #00e5ff; font-weight: bold; }
.cpu-highlight { color: #ff00ff; font-weight: bold; }
</style>
</head>
<body>
<div id="ui">
<h1>BuildRace</h1>
<div id="timerBox">Time: <span id="timeDisplay">90</span></div>
<div id="instructions">
※画面を一度クリックして操作を開始してください<br>
[矢印キー] 移動 / [Aキー] 持つ・投げる / [Zキー] 弾く<br>
<br>
<span class="highlight">★勝利条件:CPUより先に最奥(マップ終端)へ到達しろ!</span><br>
<span class="cpu-highlight">【警告】CPUが賢くなりました。壁を掘り進み、<br>こちらにブロックを投げて妨害してきます!</span><br>
<br>
<span style="color:#aaa">■白(軽): 8セル</span> | <span style="color:#888">■灰(中): 4セル</span> | <span style="color:#444">■黒(重): 2セル</span>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
// --- Web Audio API ---
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function playSound(type, pitchModifier = 1) {
if(audioCtx.state === 'suspended') audioCtx.resume();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain); gain.connect(audioCtx.destination);
let now = audioCtx.currentTime;
if (type === 'jump') {
osc.type = 'sine'; osc.frequency.setValueAtTime(400, now); osc.frequency.linearRampToValueAtTime(600, now + 0.15);
gain.gain.setValueAtTime(0.2, now); gain.gain.linearRampToValueAtTime(0, now + 0.15);
osc.start(now); osc.stop(now + 0.15);
} else if (type === 'throw') {
osc.type = 'triangle'; osc.frequency.setValueAtTime(800 * pitchModifier, now); osc.frequency.exponentialRampToValueAtTime(200, now + 0.3);
gain.gain.setValueAtTime(0.3, now); gain.gain.linearRampToValueAtTime(0, now + 0.3);
osc.start(now); osc.stop(now + 0.3);
} else if (type === 'win') {
osc.type = 'square'; osc.frequency.setValueAtTime(440, now); osc.frequency.linearRampToValueAtTime(880, now + 0.5);
gain.gain.setValueAtTime(0.5, now); gain.gain.linearRampToValueAtTime(0, now + 1);
osc.start(now); osc.stop(now + 1);
} else if (type === 'lose') {
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(300, now); osc.frequency.linearRampToValueAtTime(100, now + 0.8);
gain.gain.setValueAtTime(0.5, now); gain.gain.linearRampToValueAtTime(0, now + 1);
osc.start(now); osc.stop(now + 1);
}
}
// --- Three.js 基本セットアップ ---
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000011, 0.03);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 15, 15);
camera.lookAt(-5, 1, -3);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const light = new THREE.DirectionalLight(0xffffff, 1.2); light.position.set(10, 20, 10); scene.add(light);
scene.add(new THREE.AmbientLight(0x555555));
const starsGeo = new THREE.BufferGeometry();
const starsMat = new THREE.PointsMaterial({color: 0xffffff, size: 0.15});
const starVerts = [];
for(let i=0; i<1500; i++) starVerts.push((Math.random()-0.5)*100, Math.random()*50-10, (Math.random()-0.5)*100);
starsGeo.setAttribute('position', new THREE.Float32BufferAttribute(starVerts, 3));
const stars = new THREE.Points(starsGeo, starsMat); scene.add(stars);
// --- ゲーム管理・グリッドシステム ---
const MAP_LENGTH = 50; // ちょっと短くして白熱しやすく
const gridMap = new Map();
const blocks = [];
const animatingBlocks = [];
let gameActive = true;
let remainingTime = 90;
function getGridKey(x, y, z) { return `${Math.round(x)},${Math.round(y)},${Math.round(z)}`; }
function setGrid(x, y, z, block) { gridMap.set(getGridKey(x, y, z), block); }
function getGrid(x, y, z) { return gridMap.get(getGridKey(x, y, z)); }
function removeGrid(x, y, z) { gridMap.delete(getGridKey(x, y, z)); }
function dropColumn(x, emptyY, z) {
let currentY = emptyY;
while (true) {
let above = getGrid(x, currentY + 1, z);
if (above && !above.userData.isStatic) {
removeGrid(x, currentY + 1, z);
above.position.y = currentY;
setGrid(x, currentY, z, above);
currentY++;
} else break;
}
}
// --- カーソルシステム ---
const cursorGeo = new THREE.PlaneGeometry(0.95, 0.95);
const cursorMat = new THREE.MeshBasicMaterial({color: 0xff3333, transparent: true, opacity: 0.7, side: THREE.DoubleSide});
const targetCursor = new THREE.Mesh(cursorGeo, cursorMat);
targetCursor.rotation.x = -Math.PI / 2; targetCursor.visible = false; scene.add(targetCursor);
const trajMat = new THREE.LineDashedMaterial({ color: 0xff5555, dashSize: 0.2, gapSize: 0.2 });
const trajGeo = new THREE.BufferGeometry();
trajGeo.setFromPoints([new THREE.Vector3(0,0,0), new THREE.Vector3(1,0,0)]);
const trajLine = new THREE.Line(trajGeo, trajMat);
trajLine.computeLineDistances(); trajLine.visible = false; scene.add(trajLine);
const liftCursorMat = new THREE.MeshBasicMaterial({color: 0xffff00, transparent: true, opacity: 0.8, side: THREE.DoubleSide});
const liftCursor = new THREE.Mesh(cursorGeo, liftCursorMat);
liftCursor.rotation.x = -Math.PI / 2; liftCursor.visible = false; scene.add(liftCursor);
function getDropY(x, z, startY) {
for (let y = startY + 5; y >= -15; y--) if (getGrid(x, y, z)) return y + 1;
return -20;
}
function getLiftTarget(px, py, pz, dx, dz) {
let tx = px + dx, tz = pz + dz;
let bWall = getGrid(tx, py, tz);
if (bWall && !bWall.userData.isStatic) return { block: bWall, x: tx, y: py, z: tz };
let bFloor = getGrid(tx, py - 1, tz);
if (bFloor && !bFloor.userData.isStatic) return { block: bFloor, x: tx, y: py - 1, z: tz };
return null;
}
// --- コース生成 ---
const blockGeo = new THREE.BoxGeometry(1, 1, 1);
const weightColors = [0xffffff, 0xaaaaaa, 0x444444];
const weightsDropDist = [0, 8, 4, 2];
function createBlock(x, y, z, weight, isStatic) {
const mat = new THREE.MeshLambertMaterial({ color: isStatic ? 0x111133 : weightColors[weight-1] });
const mesh = new THREE.Mesh(blockGeo, mat);
mesh.position.set(x, y, z);
mesh.userData = { weight: weight, isStatic: isStatic };
scene.add(mesh); blocks.push(mesh); setGrid(x, y, z, mesh);
return mesh;
}
// ゴールラインの作成
const goalMat = new THREE.MeshBasicMaterial({color: 0x00ff00, transparent: true, opacity: 0.5});
const goalLine = new THREE.Mesh(new THREE.BoxGeometry(20, 50, 1), goalMat);
goalLine.position.set(0, 0, -MAP_LENGTH - 2);
scene.add(goalLine);
let h1 = 0, h2 = 0;
for (let z = 0; z >= -MAP_LENGTH; z--) {
if (z < -3 && z % 5 === 0) {
if (Math.random() < 0.7) h1 += Math.floor(Math.random() * 4) + 1;
if (Math.random() < 0.7) h2 += Math.floor(Math.random() * 4) + 1;
}
[[-7, -3, h1], [3, 7, h2]].forEach(([startX, endX, h]) => {
for (let x = startX; x <= endX; x++) {
createBlock(x, h - 5, z, 1, true);
for (let y = h - 4; y <= h; y++) {
let w = 2;
if (y === h && Math.random() < 0.4) w = 1;
else if (y < h - 2) w = 3;
createBlock(x, y, z, w, false);
}
}
});
}
// --- キャラクタークラス ---
class Slime {
constructor(x, y, z, color, isPlayer) {
this.mesh = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 16), new THREE.MeshLambertMaterial({color: color}));
body.scale.y = 0.8; body.position.y = 0.4; this.mesh.add(body);
this.mesh.position.set(x, y, z); scene.add(this.mesh);
this.gridX = x; this.gridY = y; this.gridZ = z;
this.dirX = 0; this.dirZ = -1;
this.isPlayer = isPlayer;
this.heldBlock = null;
this.isJumping = false; this.jumpTime = 0; this.jumpDuration = 0.25;
this.startPos = new THREE.Vector3(); this.endPos = new THREE.Vector3();
}
update(dt) {
if (this.isJumping) {
this.jumpTime += dt;
let t = Math.min(this.jumpTime / this.jumpDuration, 1);
this.mesh.position.lerpVectors(this.startPos, this.endPos, t);
this.mesh.position.y += Math.sin(t * Math.PI) * 1.2;
if (t >= 1) this.isJumping = false;
}
if (this.isPlayer && gameActive) {
if (this.heldBlock && !this.isJumping) {
liftCursor.visible = false;
let dist = weightsDropDist[this.heldBlock.userData.weight];
let tx = this.gridX + this.dirX * dist;
let tz = this.gridZ + this.dirZ * dist;
let ty = getDropY(tx, tz, this.gridY + 1);
targetCursor.position.set(tx, Math.max(ty, -5) + 0.51, tz);
targetCursor.visible = true;
let pts = [];
let sx = this.mesh.position.x, sy = this.mesh.position.y + 1, sz = this.mesh.position.z;
for(let i=0; i<=20; i++){
let t = i/20;
let px = sx + (tx - sx) * t;
let pz = sz + (tz - sz) * t;
let py = sy + (ty - sy) * t + Math.sin(t * Math.PI) * (dist * 0.4);
pts.push(new THREE.Vector3(px, py, pz));
}
trajGeo.setFromPoints(pts);
trajLine.computeLineDistances();
trajLine.visible = true;
} else if (!this.heldBlock && !this.isJumping) {
targetCursor.visible = false; trajLine.visible = false;
let target = getLiftTarget(this.gridX, this.gridY, this.gridZ, this.dirX, this.dirZ);
if (target) {
liftCursor.position.set(target.x, target.y + 0.51, target.z);
liftCursor.visible = true;
} else liftCursor.visible = false;
}
}
}
tryMove(dx, dz) {
if (this.isJumping) return;
this.dirX = dx; this.dirZ = dz;
let nx = this.gridX + dx, nz = this.gridZ + dz, ny = this.gridY;
let front = getGrid(nx, ny, nz);
let below = getGrid(nx, ny - 1, nz);
let above = getGrid(nx, ny + 1, nz);
if (!front && below) this.doJump(nx, ny, nz);
else if (front && !above) this.doJump(nx, ny + 1, nz);
else if (!front && !below) {
let targetY = ny - 1;
while (targetY >= ny - 4) {
if (getGrid(nx, targetY - 1, nz)) break;
targetY--;
}
if(targetY >= ny - 4) this.doJump(nx, targetY, nz);
}
}
doJump(x, y, z) {
this.startPos.copy(this.mesh.position); this.endPos.set(x, y, z);
this.gridX = x; this.gridY = y; this.gridZ = z;
this.isJumping = true; this.jumpTime = 0;
playSound('jump');
}
actionLiftOrThrow() {
if (this.isJumping) return;
if (this.heldBlock) {
let dist = weightsDropDist[this.heldBlock.userData.weight];
let tx = this.gridX + this.dirX * dist, tz = this.gridZ + this.dirZ * dist;
let ty = getDropY(tx, tz, this.gridY + 1);
let block = this.heldBlock;
this.heldBlock = null; scene.attach(block);
playSound('throw', 1 / block.userData.weight);
animatingBlocks.push({
mesh: block, sx: block.position.x, sy: block.position.y, sz: block.position.z,
ex: tx, ey: ty, ez: tz, time: 0, duration: 0.4 + (dist * 0.05), arcMax: dist * 0.4
});
} else {
let target = getLiftTarget(this.gridX, this.gridY, this.gridZ, this.dirX, this.dirZ);
if (target) {
removeGrid(target.x, target.y, target.z);
this.heldBlock = target.block;
this.heldBlock.position.set(0, 1.2, 0); this.mesh.add(this.heldBlock);
dropColumn(target.x, target.y, target.z);
}
}
}
actionKick() {
let target = getLiftTarget(this.gridX, this.gridY, this.gridZ, this.dirX, this.dirZ);
if (target && !this.heldBlock) {
removeGrid(target.x, target.y, target.z);
let b = target.block;
let dist = weightsDropDist[b.userData.weight] / 2;
let endX = target.x, endZ = target.z, endY = target.y;
for (let i = 0; i < dist; i++) {
let nx = endX + this.dirX, nz = endZ + this.dirZ;
if (getGrid(nx, endY, nz)) break;
if (!getGrid(nx, endY - 1, nz)) { endX = nx; endZ = nz; endY--; break; }
endX = nx; endZ = nz;
}
b.position.set(endX, endY, endZ); setGrid(endX, endY, endZ, b);
dropColumn(target.x, target.y, target.z);
}
}
}
const player = new Slime(-5, 1, 0, 0x00e5ff, true);
const cpu = new Slime(5, 1, 0, 0xff00ff, false);
// --- キー入力 ---
const keys = { ArrowUp: false, ArrowDown: false, ArrowLeft: false, ArrowRight: false };
let aPressed = false, zPressed = false;
window.addEventListener('keydown', (e) => {
if (!gameActive) return;
if (keys.hasOwnProperty(e.code)) keys[e.code] = true;
if (e.code === 'KeyA' && !aPressed) { player.actionLiftOrThrow(); aPressed = true; }
if (e.code === 'KeyZ' && !zPressed) { player.actionKick(); zPressed = true; }
});
window.addEventListener('keyup', (e) => {
if (keys.hasOwnProperty(e.code)) keys[e.code] = false;
if (e.code === 'KeyA') aPressed = false;
if (e.code === 'KeyZ') zPressed = false;
});
// --- メインループ ---
const clock = new THREE.Clock();
let cpuTimer = 0;
function animate() {
requestAnimationFrame(animate);
const dt = clock.getDelta();
// 勝敗判定
if (gameActive) {
if (player.gridZ <= -MAP_LENGTH) {
gameActive = false; playSound('win');
document.getElementById('timerBox').innerHTML = "<span style='color:#00e5ff; font-size:36px'>YOU WIN!!</span>";
targetCursor.visible = false; liftCursor.visible = false; trajLine.visible = false;
} else if (cpu.gridZ <= -MAP_LENGTH) {
gameActive = false; playSound('lose');
document.getElementById('timerBox').innerHTML = "<span style='color:#ff00ff; font-size:36px'>CPU WINS...</span>";
targetCursor.visible = false; liftCursor.visible = false; trajLine.visible = false;
} else {
remainingTime -= dt;
if (remainingTime <= 0) {
remainingTime = 0; gameActive = false; playSound('lose');
document.getElementById('timerBox').innerHTML = "TIME UP!";
targetCursor.visible = false; liftCursor.visible = false; trajLine.visible = false;
}
document.getElementById('timeDisplay').innerText = Math.ceil(remainingTime);
}
}
const pos = stars.geometry.attributes.position.array;
for(let i=1; i<pos.length; i+=3) {
pos[i] -= 15 * dt; if(pos[i] < -10) pos[i] = 40;
}
stars.geometry.attributes.position.needsUpdate = true;
if (gameActive) {
if (keys.ArrowUp) player.tryMove(0, -1);
else if (keys.ArrowDown) player.tryMove(0, 1);
else if (keys.ArrowLeft) player.tryMove(-1, 0);
else if (keys.ArrowRight) player.tryMove(1, 0);
// --- 強化版 CPU AI (ブルドーザー型) ---
cpuTimer += dt;
if (cpuTimer > 0.3) { // 0.3秒間隔で高速に判断
cpuTimer = 0;
if (cpu.heldBlock) {
// ブロックを持っていたら、プレイヤー側(-X方向)を向いて投げ捨てる!
cpu.dirX = -1; cpu.dirZ = 0;
cpu.actionLiftOrThrow();
cpu.dirX = 0; cpu.dirZ = -1; // 向きを前に戻す
} else {
// 基本は前(ゴール)を向く
cpu.dirX = 0; cpu.dirZ = -1;
let f = getGrid(cpu.gridX, cpu.gridY, cpu.gridZ - 1);
let f2 = getGrid(cpu.gridX, cpu.gridY + 1, cpu.gridZ - 1);
let floor = getGrid(cpu.gridX, cpu.gridY - 1, cpu.gridZ - 1);
if (f && f2) {
// 2段以上の壁にぶつかったら「掘る(持つ)」
cpu.actionLiftOrThrow();
} else if (f && !f2) {
// 1段の壁ならジャンプで登れるので前進
cpu.tryMove(0, -1);
} else if (!f && floor) {
// 道が開けていたら前進
cpu.tryMove(0, -1);
} else if (!f && !floor) {
// 崖がある場合、安全に降りられる深さ(4段以内)か確認
let dropY = cpu.gridY - 1;
while(dropY >= cpu.gridY - 4) {
if(getGrid(cpu.gridX, dropY - 1, cpu.gridZ - 1)) break;
dropY--;
}
if (dropY >= cpu.gridY - 4) {
cpu.tryMove(0, -1); // 安全なので飛び降りる
} else {
// 深すぎる、あるいは奈落の場合は横に避ける
let canGoLeft = cpu.gridX > 3 && !getGrid(cpu.gridX - 1, cpu.gridY, cpu.gridZ);
let canGoRight = cpu.gridX < 7 && !getGrid(cpu.gridX + 1, cpu.gridY, cpu.gridZ);
if (canGoLeft && Math.random() < 0.5) cpu.tryMove(-1, 0);
else if (canGoRight) cpu.tryMove(1, 0);
else if (canGoLeft) cpu.tryMove(-1, 0);
else {
// 完全に手詰まりなら足元の床を抜いて下の階層へ活路を見出す
cpu.dirX = 0; cpu.dirZ = 0;
cpu.actionLiftOrThrow();
cpu.dirX = 0; cpu.dirZ = -1;
}
}
}
}
}
}
player.update(dt);
cpu.update(dt);
if (gameActive) {
const targetCamPos = new THREE.Vector3(player.mesh.position.x + 5, player.mesh.position.y + 12, player.mesh.position.z + 12);
camera.position.lerp(targetCamPos, 0.1);
camera.lookAt(player.mesh.position.x, player.mesh.position.y, player.mesh.position.z - 3);
}
for (let i = animatingBlocks.length - 1; i >= 0; i--) {
let ab = animatingBlocks[i];
ab.time += dt;
let t = Math.min(ab.time / ab.duration, 1);
let cx = ab.sx + (ab.ex - ab.sx) * t;
let cz = ab.sz + (ab.ez - ab.sz) * t;
let cy = ab.sy + (ab.ey - ab.sy) * t + Math.sin(t * Math.PI) * ab.arcMax;
ab.mesh.position.set(cx, cy, cz);
if (t >= 1) {
if (ab.ey < -10) scene.remove(ab.mesh);
else {
let rx = Math.round(ab.ex), ry = Math.round(ab.ey), rz = Math.round(ab.ez);
ab.mesh.position.set(rx, ry, rz); setGrid(rx, ry, rz, ab.mesh);
}
animatingBlocks.splice(i, 1);
}
}
renderer.render(scene, camera);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
animate();
</script>
</body>
</html>

コメント