建設系レースゲーム「BuildRace」
【更新履歴】
・2026/5/17 バージョン1.0公開。
・2026/5/17 バージョン1.1公開。
・2026/5/17 バージョン1.2公開。
・2026/5/18 バージョン1.3公開。
・2026/5/18 バージョン1.4公開。
・ダウンロードされる方はこちら。↓
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: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
h1 { position: absolute; top: 10px; left: 15px; margin: 0; font-size: 28px; color: #ffeb3b; text-shadow: 2px 2px 4px #000; }
#topCenter {
position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
text-align: center; text-shadow: 2px 2px 4px #000; color: white;
background: rgba(0,0,0,0.5); padding: 5px 20px; border-radius: 10px;
}
#stageBox { font-size: 28px; font-weight: bold; color: #ffeb3b; }
.hp-container {
position: absolute; bottom: 20px; width: 250px;
background: rgba(0,0,0,0.7); padding: 10px; border-radius: 8px; border: 2px solid #555;
text-shadow: 1px 1px 2px #000; color: white;
}
#bottomLeft { left: 20px; }
#bottomRight { right: 20px; text-align: right; }
.hp-name { font-size: 16px; font-weight: bold; margin-bottom: 5px; }
.hp-bar-bg { width: 100%; height: 16px; background: #222; border-radius: 8px; overflow: hidden; border: 1px solid #000; }
.hp-bar-fill { height: 100%; transition: width 0.2s ease-out; }
#p1-fill { background: linear-gradient(90deg, #0088aa, #00e5ff); width: 100%; }
#bottomRight .hp-bar-bg { display: flex; }
#cpu-fill { background: linear-gradient(270deg, #aa00aa, #ff00ff); width: 100%; margin-left: auto; }
#instructions {
position: absolute; top: 10px; right: 15px; font-size: 13px; line-height: 1.5;
background: rgba(0,0,0,0.6); padding: 10px; border-radius: 8px; color: white;
}
.highlight { color: #00e5ff; font-weight: bold; }
#speedIndicator {
position: absolute; bottom: 80px; left: 20px;
font-size: 20px; font-weight: bold; color: #ff9900;
text-shadow: 0 0 12px #ff6600, 0 0 4px #000;
display: none; pointer-events: none;
}
#jumpIndicator {
position: absolute; bottom: 110px; left: 20px;
font-size: 20px; font-weight: bold; color: #00aaff;
text-shadow: 0 0 12px #0055ff, 0 0 4px #000;
display: none; pointer-events: none;
}
#overlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.8); display: flex; flex-direction: column;
justify-content: center; align-items: center; color: white; z-index: 20;
pointer-events: auto;
}
#overlayMsg { font-size: 48px; font-weight: bold; margin-bottom: 20px; text-shadow: 2px 2px 10px #000; text-align: center; }
#overlayBtn { padding: 15px 30px; font-size: 20px; cursor: pointer; background: #ffeb3b; border: none; border-radius: 10px; font-weight: bold; }
#overlayBtn:hover { background: #fff59d; }
#pauseOverlay {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); display: none; flex-direction: column;
justify-content: center; align-items: center; color: white; z-index: 30;
font-size: 64px; font-weight: bold; letter-spacing: 5px; text-shadow: 4px 4px 10px #000;
}
</style>
</head>
<body>
<div id="ui">
<h1>BuildRace</h1>
<div id="topCenter"><div id="stageBox">STAGE <span id="stageDisplay">1</span> / 8</div></div>
<div id="bottomLeft" class="hp-container">
<div class="hp-name" style="color:#00e5ff">P1 HP: <span id="p1HP">10</span>/10</div>
<div class="hp-bar-bg"><div id="p1-fill" class="hp-bar-fill"></div></div>
</div>
<div id="bottomRight" class="hp-container">
<div class="hp-name" style="color:#ff00ff">CPU HP: <span id="cpuHP">10</span>/10</div>
<div class="hp-bar-bg"><div id="cpu-fill" class="hp-bar-fill"></div></div>
</div>
<div id="speedIndicator">⚡ SPEED BOOST!</div>
<div id="jumpIndicator">↑ JUMP BOOST! x<span id="jumpCharges">0</span></div>
<div id="instructions">
[矢印]移動 / [Esc]PAUSE<br>
<span style="color:#ffeb3b">[A] 道を作る / 引っこ抜く</span> (自動アシスト)<br>
<span style="color:#ff9900">[Z] 相手コートへ投げる</span> (妨害&資源奪取)<br>
※山積みのブロックは一番下から引っこ抜き可能!<br>
<span style="color:#ff5555; font-weight:bold;">■爆弾(赤)</span>: 周囲隆起 / <span style="color:#55ff55; font-weight:bold;">■回復(緑)</span>: HP+1<br>
<span style="color:#ff9900; font-weight:bold;">■速(橙)</span>: SPD+4秒 / <span style="color:#00aaff; font-weight:bold;">■跳(青)</span>: JUMP+2(3回)<br>
<span class="highlight">相手より先にゴールへ!落ちてくる物も資材になる!</span>
</div>
</div>
<div id="overlay">
<div id="overlayMsg">BuildRace</div>
<button id="overlayBtn">GAME START</button>
</div>
<div id="pauseOverlay">PAUSE</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script>
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function playSound(type, pitch = 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 * pitch, now); osc.frequency.linearRampToValueAtTime(600 * pitch, 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 * pitch, 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 === 'bomb') {
osc.type = 'square'; osc.frequency.setValueAtTime(100, now); osc.frequency.exponentialRampToValueAtTime(10, now + 0.5);
gain.gain.setValueAtTime(0.5, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.5);
osc.start(now); osc.stop(now + 0.5);
} else if (type === 'damage') {
osc.type = 'sawtooth'; osc.frequency.setValueAtTime(200, now); osc.frequency.linearRampToValueAtTime(50, now + 0.2);
gain.gain.setValueAtTime(0.5, now); gain.gain.linearRampToValueAtTime(0, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
} else if (type === 'heal') {
osc.type = 'sine'; osc.frequency.setValueAtTime(800, now); osc.frequency.linearRampToValueAtTime(1200, now + 0.2);
gain.gain.setValueAtTime(0.3, now); gain.gain.linearRampToValueAtTime(0, now + 0.2);
osc.start(now); osc.stop(now + 0.2);
} else if (type === 'speed') {
osc.type = 'sine'; osc.frequency.setValueAtTime(600, now); osc.frequency.linearRampToValueAtTime(1400, now + 0.15);
gain.gain.setValueAtTime(0.35, now); gain.gain.linearRampToValueAtTime(0, now + 0.25);
osc.start(now); osc.stop(now + 0.25);
} else if (type === 'hit') {
osc.type = 'square'; osc.frequency.setValueAtTime(150, now); osc.frequency.exponentialRampToValueAtTime(50, now + 0.1);
gain.gain.setValueAtTime(0.5, now); gain.gain.linearRampToValueAtTime(0, now + 0.1);
osc.start(now); osc.stop(now + 0.1);
} else if (type === 'fall') {
osc.type = 'sine'; osc.frequency.setValueAtTime(300, now); osc.frequency.exponentialRampToValueAtTime(50, now + 0.6);
gain.gain.setValueAtTime(0.3, now); gain.gain.linearRampToValueAtTime(0, now + 0.6);
osc.start(now); osc.stop(now + 0.6);
}
}
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000011, 0.02);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enablePan = false;
controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.DOLLY };
controls.maxPolarAngle = Math.PI / 2.5;
controls.minPolarAngle = Math.PI / 6;
controls.maxDistance = 30; controls.minDistance = 5;
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);
let currentStage = 1;
const MAX_STAGE = 8;
let MAP_LENGTH = 50;
let gameActive = false;
let isPaused = false;
const gridMap = new Map();
const blocks = [];
const animatingBlocks = [];
let goalLine = null;
let baseCamOffset = new THREE.Vector3(0, 14, 12);
const passTypes = ['heal', 'speed', 'jump'];
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);
if (typeof cpuNotifyWorldChanged === 'function') cpuNotifyWorldChanged();
}
function getGrid(x, y, z) { return gridMap.get(getGridKey(x, y, z)); }
function removeGrid(x, y, z) {
gridMap.delete(getGridKey(x, y, z));
if (typeof cpuNotifyWorldChanged === 'function') cpuNotifyWorldChanged();
}
function getDropY(x, z, startY) {
for (let y = startY + 5; y >= -15; y--) if (getGrid(x, y, z)) return y + 1;
return -20;
}
function dropColumn(x, emptyY, z) {
let currentY = emptyY;
while (true) {
let above = getGrid(x, currentY + 1, z);
if (above && !above.userData.isStatic && !passTypes.includes(above.userData.type)) {
removeGrid(x, currentY + 1, z);
above.position.y = currentY;
setGrid(x, currentY, z, above);
currentY++;
} else break;
}
}
function dropColumnAnimated(x, emptyY, z, owner) {
let checkY = emptyY + 1;
let landY = emptyY;
while (true) {
let above = getGrid(x, checkY, z);
if (!above || above.userData.isStatic || passTypes.includes(above.userData.type)) break;
removeGrid(x, checkY, z);
animatingBlocks.push({
mesh: above, sx: x, sy: checkY, sz: z, ex: x, ey: landY, ez: z,
fallStartY: checkY, time: 0, duration: Math.max(0.18, (checkY - landY) * 0.12),
arcMax: 0, owner: owner, isFalling: true, isKicked: false
});
landY++; checkY++;
}
}
const blockGeo = new THREE.BoxGeometry(1, 1, 1);
const healGeo = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const weightColors = [0xffffff, 0xaaaaaa, 0x444444];
function createBlock(x, y, z, weight, isStatic, type = 'normal') {
let mat, geo = blockGeo;
if (type === 'bomb') { mat = new THREE.MeshLambertMaterial({ color: 0xff0000, emissive: 0x550000 }); }
else if (type === 'heal') { mat = new THREE.MeshLambertMaterial({ color: 0x55ff55, emissive: 0x225522 }); geo = healGeo; }
else if (type === 'speed') { mat = new THREE.MeshLambertMaterial({ color: 0xff9900, emissive: 0x442200 }); geo = healGeo; }
else if (type === 'jump') { mat = new THREE.MeshLambertMaterial({ color: 0x0088ff, emissive: 0x002288 }); geo = healGeo; }
else { mat = new THREE.MeshLambertMaterial({ color: isStatic ? 0x111133 : weightColors[weight - 1] }); }
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, y, z);
if (passTypes.includes(type)) mesh.position.y -= 0.25;
mesh.userData = { weight, isStatic, type };
scene.add(mesh); blocks.push(mesh); setGrid(x, y, z, mesh);
return mesh;
}
function explodeBomb(bx, by, bz) {
playSound('bomb');
for (let dx = -1; dx <= 1; dx++) {
for (let dz = -1; dz <= 1; dz++) {
let tx = Math.round(bx + dx), tz = Math.round(bz + dz);
if ((tx >= -7 && tx <= -3) || (tx >= 3 && tx <= 7)) {
let topY = getDropY(tx, tz, by + 10) - 1;
let addCount = Math.floor(Math.random() * 2) + 2;
for (let i = 1; i <= addCount; i++) {
createBlock(tx, topY + i, tz, 2, false, 'normal');
}
}
}
}
}
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 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);
const dropDashMat = new THREE.LineDashedMaterial({ color: 0xff3333, dashSize: 0.2, gapSize: 0.2 });
const dropPts = [
new THREE.Vector3(-0.45, 0, -0.45), new THREE.Vector3(0.45, 0, -0.45),
new THREE.Vector3(0.45, 0, 0.45), new THREE.Vector3(-0.45, 0, 0.45),
new THREE.Vector3(-0.45, 0, -0.45)
];
const dropDashGeo = new THREE.BufferGeometry().setFromPoints(dropPts);
function createDropCursor(x, y, z) {
const line = new THREE.Line(dropDashGeo, dropDashMat);
line.computeLineDistances();
line.position.set(x, Math.max(y, -5) + 0.52, z);
scene.add(line); return line;
}
function getSmartLiftTarget(px, py, pz, dirX, dirZ) {
let visited = new Set();
let queue = [];
queue.push({x: Math.round(px), y: Math.round(py), z: Math.round(pz), cost: 0, depth: 0});
visited.add(getGridKey(px, py, pz));
let bestTarget = null;
let bestScore = Infinity;
let iterations = 0;
let pxR = Math.round(px);
let pzR = Math.round(pz);
while (queue.length > 0 && iterations < 100) {
iterations++;
queue.sort((a, b) => a.cost - b.cost);
let curr = queue.shift();
if (curr.cost >= bestScore) continue;
if (curr.depth > 2) continue;
const dirs = [
{ dx: dirX, dy: 0, dz: dirZ, cost: 1 },
{ dx: 0, dy: -1, dz: 0, cost: 2 },
{ dx: 0, dy: 1, dz: 0, cost: 2 },
{ dx: -dirX, dy: 0, dz: -dirZ, cost: 3 },
{ dx: dirZ, dy: 0, dz: -dirX, cost: 3 },
{ dx: -dirZ, dy: 0, dz: dirX, cost: 3 }
];
for (let d of dirs) {
let nx = curr.x + d.dx;
let ny = curr.y + d.dy;
let nz = curr.z + d.dz;
let key = getGridKey(nx, ny, nz);
if (Math.abs(nx - pxR) + Math.abs(nz - pzR) > 1) continue;
if (visited.has(key)) continue;
visited.add(key);
let nextCost = curr.cost + d.cost;
let b = getGrid(nx, ny, nz);
if (b) {
if (passTypes.includes(b.userData.type)) {
queue.push({x: nx, y: ny, z: nz, cost: nextCost, depth: curr.depth + 1});
}
else if (!b.userData.isStatic) {
if (nextCost < bestScore) {
bestScore = nextCost;
bestTarget = { block: b, x: nx, y: ny, z: nz };
}
}
} else {
queue.push({x: nx, y: ny, z: nz, cost: nextCost, depth: curr.depth + 1});
}
}
}
return bestTarget;
}
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}));
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.isPlayer = isPlayer;
this.hp = 10;
this.reset(x, y, z);
}
reset(x, y, z) {
this.gridX = x; this.gridY = y; this.gridZ = z;
this.dirX = 0; this.dirZ = -1;
this.mesh.position.set(x, y, z);
if (this.heldBlock) { scene.remove(this.heldBlock); this.heldBlock = null; }
this.isJumping = false;
this.jumpTime = 0;
this.jumpDuration = 0.25;
this.startPos = new THREE.Vector3();
this.endPos = new THREE.Vector3();
this.speedBoostTimer = 0;
this.jumpBoostCharges = 0;
this.updateHPUI();
}
takeDamage(dmg) {
if (this.hp <= 0) return;
this.hp -= dmg;
playSound('damage');
if (this.hp < 0) this.hp = 0;
this.updateHPUI();
const mat = this.mesh.children[0].material;
const oldColor = mat.emissive.getHex();
mat.emissive.setHex(0xff0000);
setTimeout(() => mat.emissive.setHex(oldColor), 200);
}
heal(amt) {
if (this.hp <= 0) return;
this.hp += amt;
if (this.hp > 10) this.hp = 10;
playSound('heal');
this.updateHPUI();
}
updateHPUI() {
if (this.isPlayer) {
document.getElementById('p1HP').innerText = this.hp;
document.getElementById('p1-fill').style.width = (this.hp * 10) + '%';
const ji = document.getElementById('jumpIndicator');
if (this.jumpBoostCharges > 0) {
ji.style.display = 'block';
document.getElementById('jumpCharges').innerText = this.jumpBoostCharges;
} else {
ji.style.display = 'none';
}
} else {
document.getElementById('cpuHP').innerText = this.hp;
document.getElementById('cpu-fill').style.width = (this.hp * 10) + '%';
}
}
knockback() {
const dirs = [[1,0], [-1,0], [0,1], [0,-1], [1,1], [1,-1], [-1,1], [-1,-1]];
dirs.sort(() => Math.random() - 0.5);
for (let [dx, dz] of dirs) {
let nx = this.gridX + dx, nz = this.gridZ + dz;
if ((nx >= -7 && nx <= -3) || (nx >= 3 && nx <= 7)) {
if (!getGrid(nx, this.gridY + 1, nz) && !getGrid(nx, this.gridY + 2, nz)) {
let targetY = this.gridY;
while(targetY >= this.gridY - 10) {
if (getGrid(nx, targetY - 1, nz)) break;
targetY--;
}
if (targetY >= this.gridY - 10) {
this.doJump(nx, targetY, nz);
return;
}
}
}
}
}
update(dt, timeElapsed) {
if (this.hp <= 0) return;
if (this.speedBoostTimer > 0) this.speedBoostTimer -= 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; this.checkItemPickup(); }
}
if (this.isPlayer && gameActive) {
targetCursor.visible = false;
liftCursor.visible = false;
if (this.heldBlock) {
let tgt = this.getSmartPlaceTarget();
if (tgt) {
targetCursor.position.set(tgt.x, Math.max(tgt.y, -5) + 0.51, tgt.z);
targetCursor.visible = true;
if (tgt.isSmart) {
// スマートに適切な場所(青色で激しく点滅)
targetCursor.material.opacity = 0.3 + 0.7 * Math.abs(Math.sin(timeElapsed * 15));
targetCursor.material.color.setHex(0x00e5ff);
} else {
// 近くにない場合のデフォルト(赤色で点灯)
targetCursor.material.opacity = 0.7;
targetCursor.material.color.setHex(0xff3333);
}
}
} else if (!this.isJumping) {
let tgt = getSmartLiftTarget(this.gridX, this.gridY, this.gridZ, this.dirX, this.dirZ);
if (tgt) {
liftCursor.position.set(tgt.x, Math.max(tgt.y, -5) + 0.51, tgt.z);
liftCursor.visible = true;
}
}
}
}
checkItemPickup() {
let curB = getGrid(this.gridX, this.gridY, this.gridZ);
if (curB) {
if (curB.userData.type === 'heal') {
this.heal(1);
removeGrid(this.gridX, this.gridY, this.gridZ); scene.remove(curB);
} else if (curB.userData.type === 'speed') {
this.speedBoostTimer = 4.0;
playSound('speed');
removeGrid(this.gridX, this.gridY, this.gridZ); scene.remove(curB);
} else if (curB.userData.type === 'jump') {
this.jumpBoostCharges = 3;
playSound('speed', 1.5);
this.updateHPUI();
removeGrid(this.gridX, this.gridY, this.gridZ); scene.remove(curB);
}
}
}
tryMove(dx, dz) {
if (this.isJumping || this.hp <= 0) return false;
this.dirX = dx; this.dirZ = dz;
let nx = this.gridX + dx, nz = this.gridZ + dz, ny = this.gridY;
let maxJump = this.jumpBoostCharges > 0 ? 3 : 1;
for (let dy = maxJump; dy >= -4; dy--) {
let targetY = ny + dy;
let floorB = getGrid(nx, targetY - 1, nz);
let space1 = getGrid(nx, targetY, nz);
let space2 = getGrid(nx, targetY + 1, nz);
if (space1 && passTypes.includes(space1.userData.type)) space1 = null;
if (space2 && passTypes.includes(space2.userData.type)) space2 = null;
if (floorB && passTypes.includes(floorB.userData.type)) floorB = null;
if (floorB && !space1 && !space2) {
if (dy > 1 && this.jumpBoostCharges > 0) {
this.jumpBoostCharges--;
this.updateHPUI();
}
this.doJump(nx, targetY, nz);
return true;
}
}
return false;
}
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;
this.jumpDuration = this.speedBoostTimer > 0 ? 0.12 : 0.25;
playSound('jump');
}
getSmartPlaceTarget() {
let dx = this.dirX;
let dz = this.dirZ;
if (dx === 0 && dz === 0) dz = -1; // デフォルトは前
// 1. まず壁登りアシスト(目の前が登れない壁なら足元を押し上げる)
let frontX = this.gridX + dx;
let frontZ = this.gridZ + dz;
let frontTopY = getDropY(frontX, frontZ, this.gridY + 4) - 1;
let maxJump = this.jumpBoostCharges > 0 ? 3 : 1;
if (frontTopY > this.gridY - 1 + maxJump) {
return {x: this.gridX, y: getDropY(this.gridX, this.gridZ, this.gridY + 4), z: this.gridZ, isSmart: true};
}
// 2. 進行方向に向かって穴埋めや階段を探す (A* 的な前進探索)
let bestTarget = null;
let queue = [{x: this.gridX, z: this.gridZ, y: this.gridY - 1, depth: 0}];
let visited = new Set();
visited.add(`${this.gridX},${this.gridZ}`);
while (queue.length > 0) {
let curr = queue.shift();
if (curr.depth >= 4) continue; // 最大4マス先まで探索
let nx = curr.x + dx;
let nz = curr.z + dz;
if (nx < -7 || (nx > -3 && nx < 3) || nx > 7) continue;
let key = `${nx},${nz}`;
if (visited.has(key)) continue;
visited.add(key);
let targetTopY = getDropY(nx, nz, this.gridY + 4) - 1;
if (targetTopY < curr.y) {
// 穴を見つけた -> 穴を埋める
bestTarget = {x: nx, y: targetTopY + 1, z: nz, isSmart: true};
break;
} else if (targetTopY > curr.y + maxJump) {
// 登れない壁を見つけた -> その手前に積む
bestTarget = {x: curr.x, y: getDropY(curr.x, curr.z, this.gridY + 4), z: curr.z, isSmart: true};
break;
}
// 平坦なら次を探索
queue.push({x: nx, z: nz, y: targetTopY, depth: curr.depth + 1});
}
if (bestTarget) return bestTarget;
// 3. 適した場所が近くになければ、射程距離内(デフォルト2マス先)に置く
let defDist = 2;
let tx = this.gridX + dx * defDist;
let tz = this.gridZ + dz * defDist;
// マップ外にはみ出ないように補正
if (tx < -7 || (tx > -3 && tx < 3) || tx > 7) {
tx = this.gridX + dx;
tz = this.gridZ + dz;
}
return {x: tx, y: getDropY(tx, tz, this.gridY + 4), z: tz, isSmart: false};
}
actionLiftOrPlace() {
if (this.hp <= 0 || this.isJumping) return;
if (this.heldBlock) {
let tgt = this.getSmartPlaceTarget();
if (!tgt) return;
let placeCheck = getGrid(cpu.gridX, cpu.gridY - 1, cpu.gridZ - 1);
if (placeCheck && !this.isPlayer) {
// CPUの重複設置防止用
}
let block = this.heldBlock;
this.heldBlock = null; scene.attach(block);
playSound('throw', 1.5);
let pos = new THREE.Vector3(); block.getWorldPosition(pos);
animatingBlocks.push({
mesh: block, sx: pos.x, sy: pos.y, sz: pos.z,
ex: tgt.x, ey: tgt.y, ez: tgt.z,
time: 0, duration: 0.15, arcMax: 1,
owner: this, isFalling: false, isKicked: false
});
} else {
let target = getSmartLiftTarget(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);
playSound('jump', 0.8);
}
}
}
actionInterfere() {
if (this.hp <= 0) return;
if (!this.isPlayer) {
if (Math.abs(player.gridX - this.gridX) <= 1 && Math.abs(player.gridZ - this.gridZ) <= 1) {
return;
}
}
let targetSide = this.isPlayer ? 5 : -5;
if (this.heldBlock) {
let block = this.heldBlock;
this.heldBlock = null; scene.attach(block);
playSound('throw', 0.5);
let tx = targetSide + (Math.floor(Math.random() * 5) - 2);
let tz = this.gridZ;
let ty = getDropY(tx, tz, 15);
let pos = new THREE.Vector3(); block.getWorldPosition(pos);
animatingBlocks.push({
mesh: block, sx: pos.x, sy: pos.y, sz: pos.z,
ex: tx, ey: ty, ez: tz,
time: 0, duration: 0.5, arcMax: 4,
owner: this, isFalling: false, isKicked: true, finalY: ty
});
} else {
let tgt = getSmartLiftTarget(this.gridX, this.gridY, this.gridZ, this.dirX, this.dirZ);
if (tgt) {
removeGrid(tgt.x, tgt.y, tgt.z);
let block = tgt.block;
playSound('throw', 0.5);
let tx = targetSide + (Math.floor(Math.random() * 5) - 2);
let tz = tgt.z;
let ty = getDropY(tx, tz, 15);
let pos = new THREE.Vector3(); block.getWorldPosition(pos);
animatingBlocks.push({
mesh: block, sx: pos.x, sy: pos.y, sz: pos.z,
ex: tx, ey: ty, ez: tz,
time: 0, duration: 0.5, arcMax: 4,
owner: this, isFalling: false, isKicked: true, finalY: ty
});
dropColumnAnimated(tgt.x, tgt.y, tgt.z, this);
}
}
}
}
const player = new Slime(-5, 1, 0, 0x00e5ff, true);
const cpu = new Slime(5, 1, 0, 0xff00ff, false);
// =========================================================
// CPU AI CORE SYSTEM
// =========================================================
const CPU_AI = {
THINK_INTERVAL: 0.16,
MAX_SEARCH_NODE: 220,
MAX_PATH_DEPTH: 14,
STUCK_TIME: 1.5,
DANGER_FALL_HEIGHT: 4,
FORWARD_SCORE: 120,
SIDE_PENALTY: 10,
HEIGHT_PENALTY: 7,
VISITED_PENALTY: 30,
HOLE_PENALTY: 120,
CEILING_PENALTY: 40,
INTERFERE_RATE: 0.16
};
const cpuBrain = {
stuckTimer: 0,
lastX: 0, lastY: 0, lastZ: 0,
recentPositions: [],
currentPath: [],
repathCooldown: 0,
emergencyEscape: false,
actionLockTimer: 0,
bestForwardZ: 0,
mode: 'normal',
buildTarget: null,
fetchTarget: null
};
let cpuWorldVersion = 0;
let cpuPathCache = null;
let cpuPathVersion = -1;
let cpuLastSafePosition = { x: 5, y: 1, z: 0 };
function cpuNotifyWorldChanged() {
cpuWorldVersion++;
if (cpuWorldVersion > 9999999) cpuWorldVersion = 0;
}
function cpuRememberPosition(x, y, z) {
cpuBrain.recentPositions.push({ x: x, y: y, z: z });
if (cpuBrain.recentPositions.length > 24) cpuBrain.recentPositions.shift();
}
function cpuVisitedCount(x, y, z) {
let count = 0;
for (let p of cpuBrain.recentPositions) {
if (p.x === x && p.y === y && p.z === z) count++;
}
return count;
}
function cpuIsPassable(block) {
if (!block) return true;
return passTypes.includes(block.userData.type);
}
function cpuIsAnimatingBlock(block) {
if (!block) return false;
for (let a of animatingBlocks) {
if (a && a.mesh === block) return true;
}
return false;
}
function cpuCanStand(x, y, z) {
if (isNaN(x) || isNaN(y) || isNaN(z)) return false;
if (y < -20 || y > 60) return false;
if (!((x >= 3 && x <= 7) || (x >= -7 && x <= -3))) return false;
let center = getGrid(x, y, z);
if (center && cpuIsAnimatingBlock(center)) return false;
let floor = getGrid(x, y - 1, z);
let body = getGrid(x, y, z);
let head = getGrid(x, y + 1, z);
if (cpuIsPassable(floor)) floor = null;
if (cpuIsPassable(body)) body = null;
if (cpuIsPassable(head)) head = null;
if (!floor) return false;
if (body || head) return false;
return true;
}
function cpuIsDangerFall(fromY, toY) {
return (fromY - toY) >= CPU_AI.DANGER_FALL_HEIGHT;
}
function cpuEvaluatePosition(x, y, z) {
let score = 0;
score += (-z) * CPU_AI.FORWARD_SCORE;
score -= Math.abs(x - cpu.gridX) * CPU_AI.SIDE_PENALTY;
score -= Math.abs(y - cpu.gridY) * CPU_AI.HEIGHT_PENALTY;
score -= cpuVisitedCount(x, y, z) * CPU_AI.VISITED_PENALTY;
if (cpuIsDangerFall(cpu.gridY, y)) score -= CPU_AI.HOLE_PENALTY;
let ceiling = getGrid(x, y + 2, z);
if (ceiling && !cpuIsPassable(ceiling)) score -= CPU_AI.CEILING_PENALTY;
return score;
}
function cpuGenerateMoves(node, jumpPower) {
if (!node) return [];
const result = [];
const dirs = [{ x: 0, z: -1 }, { x: -1, z: 0 }, { x: 1, z: 0 }, { x: 0, z: 1 }];
for (let d of dirs) {
for (let dy = jumpPower; dy >= -5; dy--) {
let tx = node.x + d.x;
let ty = node.y + dy;
let tz = node.z + d.z;
if (!cpuCanStand(tx, ty, tz)) continue;
result.push({ x: tx, y: ty, z: tz, score: cpuEvaluatePosition(tx, ty, tz) });
}
}
return result;
}
function cpuFindPath() {
if (cpu.hp <= 0) return null;
if (cpu.isJumping) return null;
let jumpPower = cpu.jumpBoostCharges > 0 ? 3 : 1;
const open = [];
const closed = new Set();
open.push({ x: cpu.gridX, y: cpu.gridY, z: cpu.gridZ, path: [], score: 0 });
let bestNode = null;
let bestScore = -999999;
let searchCount = 0;
while (open.length > 0 && searchCount < CPU_AI.MAX_SEARCH_NODE) {
searchCount++;
open.sort(function(a, b) { return b.score - a.score; });
const current = open.shift();
const key = current.x + "," + current.y + "," + current.z;
if (closed.has(key)) continue;
closed.add(key);
let currentScore = cpuEvaluatePosition(current.x, current.y, current.z);
if (currentScore > bestScore) {
bestScore = currentScore;
bestNode = current;
}
if (current.path.length >= CPU_AI.MAX_PATH_DEPTH) continue;
const nextMoves = cpuGenerateMoves(current, jumpPower);
for (let n of nextMoves) {
const nk = n.x + "," + n.y + "," + n.z;
if (closed.has(nk)) continue;
const nextPath = current.path.slice();
nextPath.push({ x: n.x, y: n.y, z: n.z });
if (open.length > CPU_AI.MAX_SEARCH_NODE) break;
open.push({
x: n.x, y: n.y, z: n.z,
path: nextPath,
score: current.score + n.score
});
}
}
return bestNode;
}
function cpuFindPathCached() {
if (cpuPathCache && cpuPathVersion === cpuWorldVersion) {
if (cpuPathCache.path && cpuPathCache.path.length > 0) return cpuPathCache;
}
let result = cpuFindPath();
if (!result || !result.path) {
cpuPathCache = null;
return null;
}
cpuPathCache = result;
cpuPathVersion = cpuWorldVersion;
return result;
}
function cpuIsBoxed() {
let blocked = 0;
const dirs = [[1,0], [-1,0], [0,1], [0,-1]];
for (let d of dirs) {
let b = getGrid(cpu.gridX + d[0], cpu.gridY, cpu.gridZ + d[1]);
if (b && !cpuIsPassable(b)) blocked++;
}
return blocked >= 3;
}
function cpuRememberSafePosition() {
let floor = getGrid(cpu.gridX, cpu.gridY - 1, cpu.gridZ);
if (floor) {
cpuLastSafePosition.x = cpu.gridX;
cpuLastSafePosition.y = cpu.gridY;
cpuLastSafePosition.z = cpu.gridZ;
}
}
// =========================================================
// CPU 建築・探索用アシスト関数
// =========================================================
function getTopY(x, z) {
return getDropY(x, z, 50) - 1;
}
function cpuFindBestBuildTask() {
let maxJump = cpu.jumpBoostCharges > 0 ? 3 : 1;
let maxDrop = 4;
let startX = Math.round(cpu.gridX);
let startZ = Math.round(cpu.gridZ);
let startY = getTopY(startX, startZ);
let costs = new Map();
let queue = [{x: startX, z: startZ, y: startY, cost: 0, path: []}];
costs.set(`${startX},${startZ}`, 0);
let bestGoalNode = null;
let minTargetZ = startZ;
let iterations = 0;
while(queue.length > 0 && iterations < 300) {
iterations++;
queue.sort((a,b) => a.cost - b.cost);
let curr = queue.shift();
if (curr.z < minTargetZ) {
minTargetZ = curr.z;
bestGoalNode = curr;
}
if (curr.z <= startZ - 6) break;
const dirs = [ {dx: 0, dz: -1}, {dx: -1, dz: 0}, {dx: 1, dz: 0}, {dx: 0, dz: 1} ];
for (let d of dirs) {
let nx = curr.x + d.dx;
let nz = curr.z + d.dz;
if (nx < 3 || nx > 7) continue;
let targetTopY = getTopY(nx, nz);
let cost = curr.cost;
let buildTarget = null;
if (targetTopY > curr.y + maxJump) {
let neededBlocks = targetTopY - (curr.y + maxJump);
cost += neededBlocks * 10;
buildTarget = {x: curr.x, z: curr.z};
} else if (targetTopY < curr.y - maxDrop) {
let neededBlocks = (curr.y - maxDrop) - targetTopY;
cost += neededBlocks * 10;
buildTarget = {x: nx, z: nz};
} else {
cost += 1;
}
let key = `${nx},${nz}`;
if (!costs.has(key) || cost < costs.get(key)) {
costs.set(key, cost);
let newPath = curr.path.slice();
if (buildTarget) newPath.push(buildTarget);
queue.push({
x: nx, z: nz,
y: buildTarget ? (targetTopY > curr.y + maxJump ? curr.y + 1 : targetTopY + 1) : targetTopY,
cost: cost,
path: newPath
});
}
}
}
if (bestGoalNode && bestGoalNode.path.length > 0) return bestGoalNode.path[0];
return null;
}
function cpuFindClosestBlockToFetch() {
let bestBlock = null;
let minDist = 999999;
for (let z = cpu.gridZ - 1; z <= cpu.gridZ + 15; z++) {
for (let x = 3; x <= 7; x++) {
let topY = getTopY(x, z);
let b = getGrid(x, topY, z);
if (b && !b.userData.isStatic && !passTypes.includes(b.userData.type)) {
if (x === cpu.gridX && z === cpu.gridZ) continue;
let dist = Math.abs(x - cpu.gridX) + Math.abs(z - cpu.gridZ);
if (dist < minDist) {
minDist = dist;
bestBlock = {x: x, y: topY, z: z};
}
}
}
}
return bestBlock;
}
function cpuFindPathTo(goalX, goalZ) {
let queue = [{x: cpu.gridX, z: cpu.gridZ, path: []}];
let visited = new Set([`${cpu.gridX},${cpu.gridZ}`]);
let iterations = 0;
while(queue.length > 0 && iterations < 200) {
iterations++;
let curr = queue.shift();
if (Math.abs(curr.x - goalX) + Math.abs(curr.z - goalZ) === 1) {
if (curr.path.length > 0) return {dx: curr.path[0].x - cpu.gridX, dz: curr.path[0].z - cpu.gridZ};
return {dx: 0, dz: 0};
}
if (curr.path.length > 8) continue;
const dirs = [{dx: 0, dz: -1}, {dx: -1, dz: 0}, {dx: 1, dz: 0}, {dx: 0, dz: 1}];
for (let d of dirs) {
let nx = curr.x + d.dx;
let nz = curr.z + d.dz;
if (nx < 3 || nx > 7) continue;
let key = `${nx},${nz}`;
if (visited.has(key)) continue;
visited.add(key);
let topY = getTopY(nx, nz);
let currTopY = getTopY(curr.x, curr.z);
if (Math.abs(topY - currTopY) <= (cpu.jumpBoostCharges > 0 ? 3 : 1)) {
let newPath = curr.path.slice();
newPath.push({x: nx, z: nz});
queue.push({x: nx, z: nz, path: newPath});
}
}
}
return null;
}
function rollItem(stage) {
let r = Math.random();
let base = 0.02 + stage * 0.01;
if (r < base) return 'bomb';
if (r < base * 3) return 'heal';
if (r < base * 5) return 'speed';
if (r < base * 7) return 'jump';
return 'normal';
}
const BASE_FLOOR_Y = -4;
const mapChunks = [
(z, h, sideX, stage) => {
for (let dz = 0; dz >= -5; dz--) {
for (let x = sideX - 2; x <= sideX + 2; x++) {
for(let py = h-1; py >= h-4; py--) createBlock(x, py, z + dz, 1, true, 'normal');
if (Math.random() < 0.15) createBlock(x, h, z + dz, 1, false, rollItem(stage));
}
}
return { outZ: z - 6, outH: h };
},
(z, h, sideX, stage) => {
for (let dz = 0; dz >= -5; dz--) {
let isWall = (dz === -2 || dz === -3);
for (let x = sideX - 2; x <= sideX + 2; x++) {
for(let py = h-1; py >= h-4; py--) createBlock(x, py, z + dz, 1, true, 'normal');
if (isWall) {
createBlock(x, h, z + dz, 2, false, 'normal');
} else if (Math.random() < 0.2) {
createBlock(x, h, z + dz, 1, false, rollItem(stage));
}
}
}
return { outZ: z - 6, outH: h };
},
(z, h, sideX, stage) => {
for (let dz = 0; dz >= -5; dz--) {
let stepH = dz <= -3 ? h + 2 : h;
for (let x = sideX - 2; x <= sideX + 2; x++) {
for(let py = stepH-1; py >= stepH-4; py--) createBlock(x, py, z + dz, 1, true, 'normal');
if (Math.random() < 0.2) createBlock(x, stepH, z + dz, 1, false, rollItem(stage));
}
}
return { outZ: z - 6, outH: h + 2 };
},
(z, h, sideX, stage) => {
for (let dz = 0; dz >= -5; dz--) {
let stepH = dz <= -3 ? h - 2 : h;
for (let x = sideX - 2; x <= sideX + 2; x++) {
for(let py = stepH-1; py >= stepH-4; py--) createBlock(x, py, z + dz, 1, true, 'normal');
if (Math.random() < 0.2) createBlock(x, stepH, z + dz, 1, false, rollItem(stage));
}
}
return { outZ: z - 6, outH: h - 2 };
},
(z, h, sideX, stage) => {
for (let dz = 0; dz >= -5; dz--) {
for (let x = sideX - 2; x <= sideX + 2; x++) {
for(let py = h-1; py >= h-4; py--) createBlock(x, py, z + dz, 1, true, 'normal');
if (dz === -3) {
createBlock(x, h, z + dz, 2, false, 'normal');
createBlock(x, h + 1, z + dz, 2, false, 'normal');
} else if (Math.random() < 0.15) {
createBlock(x, h, z + dz, 1, false, rollItem(stage));
}
}
}
return { outZ: z - 6, outH: h };
}
];
function initStage(stage) {
currentStage = stage;
MAP_LENGTH = 30 + stage * 10;
blocks.forEach(b => scene.remove(b));
blocks.length = 0; gridMap.clear();
animatingBlocks.forEach(ab => {
if (ab.dropCursor) scene.remove(ab.dropCursor);
scene.remove(ab.mesh);
});
animatingBlocks.length = 0;
if (goalLine) scene.remove(goalLine);
player.reset(-5, 1, 0); cpu.reset(5, 1, 0);
cpuWorldVersion = 0; cpuPathCache = null; cpuPathVersion = -1;
cpuBrain.mode = 'normal'; cpuBrain.buildTarget = null; cpuBrain.fetchTarget = null;
isPaused = false;
document.getElementById('pauseOverlay').style.display = 'none';
document.getElementById('speedIndicator').style.display = 'none';
document.getElementById('jumpIndicator').style.display = 'none';
document.getElementById('stageDisplay').innerText = stage;
const goalMat = new THREE.MeshBasicMaterial({color: 0x00ff00, transparent: true, opacity: 0.5});
goalLine = new THREE.Mesh(new THREE.BoxGeometry(20, 50, 1), goalMat);
goalLine.position.set(0, 0, -MAP_LENGTH - 2); scene.add(goalLine);
let p1Z = 0, p1H = 1, cpuZ = 0, cpuH = 1;
let p1Res = mapChunks[0](p1Z, p1H, -5, stage); p1Z = p1Res.outZ; p1H = p1Res.outH;
let cpuRes = mapChunks[0](cpuZ, cpuH, 5, stage); cpuZ = cpuRes.outZ; cpuH = cpuRes.outH;
while (p1Z > -MAP_LENGTH) {
let chunkId = Math.floor(Math.random() * mapChunks.length);
p1Res = mapChunks[chunkId](p1Z, p1H, -5, stage);
p1Z = p1Res.outZ; p1H = p1Res.outH;
cpuRes = mapChunks[chunkId](cpuZ, cpuH, 5, stage);
cpuZ = cpuRes.outZ; cpuH = cpuRes.outH;
}
for (let z = p1Z; z >= -MAP_LENGTH - 10; z--) {
for (let x = -7; x <= -3; x++) {
for(let py = p1H-1; py >= p1H-4; py--) createBlock(x, py, z, 1, true, 'normal');
}
}
for (let z = cpuZ; z >= -MAP_LENGTH - 10; z--) {
for (let x = 3; x <= 7; x++) {
for(let py = cpuH-1; py >= cpuH-4; py--) createBlock(x, py, z, 1, true, 'normal');
}
}
camera.position.set(-5, 15, 12);
controls.target.set(-5, 1, 0);
controls.update();
gameActive = true;
document.getElementById('overlay').style.display = 'none';
targetCursor.visible = false; liftCursor.visible = false;
}
document.getElementById('overlayBtn').addEventListener('click', () => {
if (currentStage === 1 && !gameActive) initStage(currentStage);
});
function endGame(msg, color, btnText, nextAction) {
gameActive = false;
document.getElementById('overlayMsg').innerHTML =
`<span style="color:${color}">${msg}</span><br><span style="font-size:24px; color:#fff">Stage ${currentStage}</span>`;
document.getElementById('speedIndicator').style.display = 'none';
document.getElementById('jumpIndicator').style.display = 'none';
const btn = document.getElementById('overlayBtn');
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.innerText = btnText;
newBtn.addEventListener('click', nextAction);
document.getElementById('overlay').style.display = 'flex';
}
function togglePause() {
isPaused = !isPaused;
document.getElementById('pauseOverlay').style.display = isPaused ? 'flex' : 'none';
}
const keys = { ArrowUp: false, ArrowDown: false, ArrowLeft: false, ArrowRight: false };
let aPressed = false, zPressed = false;
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape' && gameActive && player.hp > 0 && cpu.hp > 0) togglePause();
if (!gameActive || isPaused) return;
if (keys.hasOwnProperty(e.code)) keys[e.code] = true;
if (e.code === 'KeyA' && !aPressed) { aPressed = true; player.actionLiftOrPlace(); }
if (e.code === 'KeyZ' && !zPressed) { zPressed = true; player.actionInterfere(); }
});
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);
let dt = clock.getDelta();
if (dt > 0.08) dt = 0.08;
if (dt < 0.001) dt = 0.001;
if (isPaused) {
renderer.render(scene, camera);
return;
}
const timeElapsed = clock.getElapsedTime();
blocks.forEach(b => {
if (b.userData.type === 'bomb') {
b.material.emissive.setHex(Math.sin(timeElapsed * 15) > 0 ? 0xaa0000 : 0x330000);
} else if (b.userData.type === 'speed') {
b.material.emissive.setHex(Math.sin(timeElapsed * 8) > 0 ? 0x884400 : 0x221100);
} else if (b.userData.type === 'jump') {
b.material.emissive.setHex(Math.sin(timeElapsed * 8) > 0 ? 0x0044aa : 0x001144);
}
});
if (gameActive) {
if (player.hp <= 0) {
endGame("YOU DIED...", "#ff5555", "RETRY STAGE", () => initStage(currentStage));
} else if (cpu.hp <= 0 || player.gridZ <= -MAP_LENGTH) {
if (currentStage >= MAX_STAGE) {
endGame("GAME CLEAR!!", "#ffeb3b", "BACK TO TITLE", () => {
currentStage = 1;
document.getElementById('overlayMsg').innerHTML = "BuildRace";
const btn = document.getElementById('overlayBtn');
const newBtn = btn.cloneNode(true);
btn.parentNode.replaceChild(newBtn, btn);
newBtn.innerText = "GAME START";
newBtn.addEventListener('click', () => {
if (currentStage === 1 && !gameActive) initStage(currentStage);
});
});
} else {
endGame("STAGE CLEAR!", "#00e5ff", "NEXT STAGE", () => initStage(currentStage + 1));
}
} else if (cpu.gridZ <= -MAP_LENGTH) {
endGame("CPU WINS...", "#ff00ff", "RETRY STAGE", () => initStage(currentStage));
}
document.getElementById('speedIndicator').style.display =
(player.speedBoostTimer > 0 && player.hp > 0) ? 'block' : 'none';
if (Math.random() < 0.005 + currentStage * 0.001) {
let tChar = Math.random() < 0.5 ? player : cpu;
if (tChar.hp > 0) {
let tx = tChar.gridX + (Math.floor(Math.random() * 5) - 2);
let tz = tChar.gridZ - Math.floor(Math.random() * 10);
if ((tx >= -7 && tx <= -3) || (tx >= 3 && tx <= 7)) {
let type = 'normal';
let b = createBlock(tx, 25, tz, 2, false, type);
removeGrid(tx, 25, tz);
let ey = getDropY(tx, tz, 25);
let cursor = createDropCursor(tx, ey, tz);
animatingBlocks.push({
mesh: b, sx: tx, sy: 25, sz: tz, ex: tx, ey: ey, ez: tz,
time: 0, duration: 4.0, arcMax: 0,
dropCursor: cursor, owner: null, isFalling: true, isKicked: false
});
}
}
}
}
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);
if (cpu.hp > 0) {
cpuTimer += dt;
let cpuDifficultyScale = Math.min(1.8, 1.0 + currentStage * 0.06);
if (player.gridZ < cpu.gridZ - 12) {
cpuDifficultyScale += 0.25;
}
let cpuThinkInterval = Math.max(
0.05,
(CPU_AI.THINK_INTERVAL / cpuDifficultyScale) - currentStage * 0.01 - (cpu.speedBoostTimer > 0 ? 0.04 : 0)
);
if (cpuTimer >= cpuThinkInterval) {
cpuTimer = 0;
cpuRememberPosition(cpu.gridX, cpu.gridY, cpu.gridZ);
if (cpu.gridX === cpuBrain.lastX && cpu.gridY === cpuBrain.lastY && cpu.gridZ === cpuBrain.lastZ) {
cpuBrain.stuckTimer += cpuThinkInterval;
if (cpuBrain.stuckTimer > 5.0) {
cpuPathCache = null;
cpuBrain.actionLockTimer = 0;
cpuBrain.mode = 'normal';
cpu.tryMove(Math.random() < 0.5 ? -1 : 1, 0);
}
} else {
cpuBrain.stuckTimer = 0;
cpuBrain.lastX = cpu.gridX;
cpuBrain.lastY = cpu.gridY;
cpuBrain.lastZ = cpu.gridZ;
}
if (cpuBrain.actionLockTimer > 0) {
cpuBrain.actionLockTimer -= cpuThinkInterval;
if (cpuBrain.actionLockTimer < 0) cpuBrain.actionLockTimer = 0;
}
let needEmergencyEscape = false;
if (cpuBrain.stuckTimer >= CPU_AI.STUCK_TIME) needEmergencyEscape = true;
if (cpuIsBoxed()) needEmergencyEscape = true;
if (needEmergencyEscape) {
cpuBrain.emergencyEscape = true;
cpuBrain.mode = 'normal';
let escapeDir = Math.random() < 0.5 ? -1 : 1;
if (cpu.tryMove(escapeDir, 0)) {
cpuBrain.actionLockTimer = 0.25;
} else {
cpu.tryMove(0, -1);
cpuBrain.actionLockTimer = 0.25;
}
if (cpuBrain.stuckTimer > CPU_AI.STUCK_TIME * 2) {
let rdx = cpuLastSafePosition.x - cpu.gridX;
let rdz = cpuLastSafePosition.z - cpu.gridZ;
if (Math.abs(rdx) > 0) rdx = Math.sign(rdx);
if (Math.abs(rdz) > 0) rdz = Math.sign(rdz);
cpu.tryMove(rdx, rdz);
}
} else {
cpuBrain.emergencyEscape = false;
if (cpuBrain.actionLockTimer <= 0) {
if (cpuBrain.mode === 'build') {
if (!cpu.heldBlock) {
cpuBrain.mode = 'normal';
} else if (!cpuBrain.buildTarget) {
cpuBrain.mode = 'normal';
} else {
let tx = cpuBrain.buildTarget.x;
let tz = cpuBrain.buildTarget.z;
let dist = Math.abs(tx - cpu.gridX) + Math.abs(tz - cpu.gridZ);
if (dist === 1) {
cpu.dirX = tx - cpu.gridX;
cpu.dirZ = tz - cpu.gridZ;
cpu.actionLiftOrPlace();
cpuBrain.actionLockTimer = 0.3;
cpuBrain.mode = 'normal';
cpuNotifyWorldChanged();
} else if (dist === 0) {
cpu.tryMove(Math.random() < 0.5 ? 1 : -1, 0);
cpuBrain.actionLockTimer = 0.2;
} else {
let move = cpuFindPathTo(tx, tz);
if (move) {
cpu.tryMove(move.dx, move.dz);
cpuBrain.actionLockTimer = 0.15;
} else {
cpuBrain.mode = 'normal';
}
}
}
}
else if (cpuBrain.mode === 'fetch') {
if (cpu.heldBlock) {
cpuBrain.mode = 'build';
} else {
let target = cpuFindClosestBlockToFetch();
if (target) {
let dist = Math.abs(target.x - cpu.gridX) + Math.abs(target.z - cpu.gridZ);
if (dist === 1) {
cpu.dirX = target.x - cpu.gridX;
cpu.dirZ = target.z - cpu.gridZ;
let tgt = getSmartLiftTarget(cpu.gridX, cpu.gridY, cpu.gridZ, cpu.dirX, cpu.dirZ);
if (tgt) {
cpu.actionLiftOrPlace();
cpuBrain.actionLockTimer = 0.3;
cpuBrain.mode = 'build';
} else {
cpu.tryMove(Math.random() < 0.5 ? 1 : -1, 0);
cpuBrain.actionLockTimer = 0.2;
}
} else if (dist === 0) {
cpu.tryMove(0, 1);
cpuBrain.actionLockTimer = 0.2;
} else {
let move = cpuFindPathTo(target.x, target.z);
if (move) {
cpu.tryMove(move.dx, move.dz);
cpuBrain.actionLockTimer = 0.15;
} else {
cpuBrain.mode = 'normal';
}
}
} else {
cpuBrain.mode = 'normal';
}
}
}
else {
let pathResult = cpuFindPathCached();
let forwardProgress = false;
if (pathResult && pathResult.path && pathResult.path.length > 0) {
for(let p of pathResult.path) {
if (p.z < cpu.gridZ) {
forwardProgress = true; break;
}
}
}
if (forwardProgress) {
let nextNode = pathResult.path[0];
let dx = nextNode.x - cpu.gridX;
let dz = nextNode.z - cpu.gridZ;
if (dx > 1) dx = 1; if (dx < -1) dx = -1;
if (dz > 1) dz = 1; if (dz < -1) dz = -1;
if (cpu.tryMove(dx, dz)) {
cpuBrain.actionLockTimer = 0.15;
cpuRememberSafePosition();
} else {
if (!cpu.heldBlock) {
let tgt = getSmartLiftTarget(cpu.gridX, cpu.gridY, cpu.gridZ, dx, dz);
if (tgt) {
cpu.dirX = dx; cpu.dirZ = dz;
cpu.actionLiftOrPlace();
cpuBrain.actionLockTimer = 0.25;
} else {
cpu.tryMove(Math.random() < 0.5 ? 1 : -1, 0);
cpuBrain.actionLockTimer = 0.2;
}
} else {
cpu.dirX = 0; cpu.dirZ = -1;
cpu.actionLiftOrPlace();
cpuBrain.actionLockTimer = 0.3;
}
}
} else {
let task = cpuFindBestBuildTask();
if (task) {
cpuBrain.buildTarget = task;
cpuBrain.mode = cpu.heldBlock ? 'build' : 'fetch';
} else {
cpu.tryMove(Math.random() < 0.5 ? 1 : -1, 0);
cpuBrain.actionLockTimer = 0.2;
}
}
}
}
}
}
}
}
player.update(dt, timeElapsed);
cpu.update(dt, timeElapsed);
if (gameActive && player.hp > 0) {
let targetPos = new THREE.Vector3(player.mesh.position.x, player.mesh.position.y, player.mesh.position.z);
controls.target.lerp(targetPos, 0.1);
let swayX = Math.sin(timeElapsed * 0.5) * 1.5;
let swayZ = Math.cos(timeElapsed * 0.4) * 0.5;
camera.position.copy(controls.target).add(baseCamOffset).add(new THREE.Vector3(swayX, 0, swayZ));
controls.update();
}
for (let i = animatingBlocks.length - 1; i >= 0; i--) {
if (!animatingBlocks[i] || !animatingBlocks[i].mesh) continue;
let ab = animatingBlocks[i];
ab.time += dt;
if (!ab.isFalling) {
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);
let hitTarget = null;
for (let char of [player, cpu]) {
if (char.hp > 0 && Math.abs(cx - char.mesh.position.x) < 0.8 &&
Math.abs(cz - char.mesh.position.z) < 0.8 &&
Math.abs(cy - char.mesh.position.y) < 1.5) {
hitTarget = char; break;
}
}
if (hitTarget && hitTarget !== ab.owner && !passTypes.includes(ab.mesh.userData.type)) {
let dmg = ab.mesh.userData.type === 'bomb' ? 2 : 1;
hitTarget.takeDamage(dmg);
if (ab.mesh.userData.type === 'bomb') explodeBomb(Math.round(cx), Math.round(cy), Math.round(cz));
if (ab.dropCursor) scene.remove(ab.dropCursor);
scene.remove(ab.mesh); animatingBlocks.splice(i, 1);
continue;
}
if (!ab.isKicked) {
let gX = Math.round(cx), gY = Math.round(cy), gZ = Math.round(cz);
let hitBlock = getGrid(gX, gY, gZ);
if (t > 0.15 && hitBlock && !passTypes.includes(hitBlock.userData.type)) {
playSound('hit');
ab.isFalling = true; ab.ex = gX; ab.ez = gZ;
ab.fallStartY = cy; ab.ey = getDropY(gX, gZ, gY);
if (ab.ey < -10) playSound('fall');
ab.time = 0; ab.duration = Math.max(0.2, (ab.fallStartY - ab.ey) * 0.05);
if (ab.dropCursor) { scene.remove(ab.dropCursor); ab.dropCursor = null; }
continue;
}
}
if (t >= 1) {
if (ab.isKicked && ab.ey > ab.finalY) {
ab.isKicked = false; ab.isFalling = true;
ab.sx = ab.ex; ab.sy = ab.ey; ab.sz = ab.ez; ab.ey = ab.finalY;
ab.fallStartY = ab.sy; ab.time = 0;
ab.duration = Math.max(0.2, (ab.fallStartY - ab.ey) * 0.05);
if (ab.ey < -10) playSound('fall');
} else {
processLanding(ab, i);
}
}
} else {
let t = Math.min(ab.time / ab.duration, 1);
let cy = ab.fallStartY ? ab.fallStartY + (ab.ey - ab.fallStartY) * t : ab.sy + (ab.ey - ab.sy) * t;
let cx = ab.ex, cz = ab.ez;
ab.mesh.position.set(cx, cy, cz);
let hitTarget = null;
for (let char of [player, cpu]) {
if (char.hp > 0 && Math.abs(cx - char.mesh.position.x) < 0.8 &&
Math.abs(cz - char.mesh.position.z) < 0.8 && Math.abs(cy - char.mesh.position.y) < 1.5) {
hitTarget = char; break;
}
}
if (hitTarget && hitTarget !== ab.owner && !passTypes.includes(ab.mesh.userData.type)) {
let dmg = ab.mesh.userData.type === 'bomb' ? 2 : 1;
if (ab.owner === null) { dmg = 2; hitTarget.knockback(); }
hitTarget.takeDamage(dmg);
if (ab.mesh.userData.type === 'bomb') explodeBomb(Math.round(cx), Math.round(cy), Math.round(cz));
if (ab.dropCursor) scene.remove(ab.dropCursor);
scene.remove(ab.mesh); animatingBlocks.splice(i, 1);
continue;
}
if (t >= 1) processLanding(ab, i);
}
}
if (isNaN(cpu.mesh.position.x)) cpu.mesh.position.x = 5;
if (isNaN(cpu.mesh.position.y)) cpu.mesh.position.y = 5;
if (isNaN(cpu.mesh.position.z)) cpu.mesh.position.z = 0;
if (cpu.mesh.position.y < -50) {
cpu.mesh.position.x = cpuLastSafePosition.x;
cpu.mesh.position.y = cpuLastSafePosition.y + 2;
cpu.mesh.position.z = cpuLastSafePosition.z;
}
renderer.render(scene, camera);
}
function processLanding(ab, index) {
if (ab.ey < -10) {
scene.remove(ab.mesh);
} else {
if (ab.mesh.userData.type === 'bomb') {
explodeBomb(Math.round(ab.ex), Math.round(ab.ey), Math.round(ab.ez));
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);
for (let char of [player, cpu]) {
if (char.hp > 0 && Math.round(char.gridX) === rx && Math.round(char.gridZ) === rz) {
if (char.gridY <= ry) {
char.gridY = ry + 1;
char.mesh.position.y = char.gridY;
}
}
}
}
}
if (ab.dropCursor) scene.remove(ab.dropCursor);
animatingBlocks.splice(index, 1);
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
animate();
</script>
</body>
</html>

コメント