物理演算3マッチゲーム
【更新履歴】
・2026/1/7 バージョン1.0公開
・2026/1/7 バージョン1.1公開
・2026/1/8 バージョン1.2公開
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Gem Physics 3D - High Speed & Stability</title>
<style>
body { margin: 0; overflow: hidden; background-color: #222233; font-family: 'Segoe UI', sans-serif; user-select: none; }
#ui-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10; }
#header { position: absolute; top: 20px; left: 20px; color: white; text-shadow: 1px 1px 0 #000; pointer-events: none; }
h1 { display: none; }
.status-row { font-size: 28px; font-weight: 800; color: #fff; margin-bottom: 20px; line-height: 1.4; text-shadow: 2px 2px 0 rgba(0,0,0,0.5); }
.stat-label { color: #aaa; font-size: 0.7em; }
.stat-val { color: #FFD700; font-family: "Hiragino Kaku Gothic ProN", "Meiryo", sans-serif; }
.controls-info { margin-top: 20px; font-size: 14px; color: #ddd; font-weight: bold; text-shadow: 1px 1px 0 #000; line-height: 1.6; }
#combo-display {
position: absolute; top: 30px; right: 40px; text-align: right; transform: skew(-15deg);
}
#combo-num {
font-size: 80px; font-weight: 900; color: #FFFFAA; text-shadow: 3px 3px 0 #000; line-height: 1;
}
#combo-unit {
font-size: 40px; font-weight: 700; color: #FFFFFF; text-shadow: 2px 2px 0 #000; margin-left: 10px;
}
#combo-container { opacity: 0; transition: transform 0.1s; }
.combo-anim { animation: comboPop 0.5s ease-out forwards; }
@keyframes comboPop {
0% { opacity: 0; transform: scale(0.5) translateX(50px); }
20% { opacity: 1; transform: scale(1.2) translateX(0); }
100% { opacity: 1; transform: scale(1.0); }
}
#timer-container {
position: absolute; top: 20px; left: 50%; transform: translateX(-50%);
width: 400px; max-width: 80%; text-align: center;
}
#time-text { color: white; font-size: 28px; font-weight: 900; text-shadow: 2px 2px 0 #000; margin-bottom: 5px; }
#time-bar-bg { width: 100%; height: 10px; background: rgba(0,0,0,0.5); border-radius: 5px; overflow: hidden; border: 2px solid rgba(255,255,255,0.5); }
#time-bar-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #88FFAA, #FFFFAA, #FFAA88); transform-origin: left; transition: transform 0.1s linear; }
#fps-counter {
position: absolute; bottom: 10px; right: 10px;
color: #00FF00; font-family: monospace; font-weight: bold; font-size: 16px;
text-shadow: 1px 1px 0 #000;
}
.floating-score {
position: absolute; color: #FFFFCC; font-size: 40px; font-weight: 900;
text-shadow: 2px 2px 0 #000; pointer-events: none;
animation: floatUp 0.6s ease-out forwards; white-space: nowrap; z-index: 20;
}
@keyframes floatUp {
0% { opacity: 1; transform: translate(-50%, -50%) scale(0.5); }
20% { transform: translate(-50%, -150%) scale(1.3); }
100% { opacity: 0; transform: translate(-50%, -300%) scale(1.0); }
}
#danger-warning {
position: absolute; top: 15%; left: 50%; transform: translateX(-50%);
font-size: 40px; color: #FF5555; font-weight: 900; text-shadow: 2px 2px 0 #000;
display: none; animation: blink 0.5s infinite alternate;
}
@keyframes blink { from { opacity: 1; } to { opacity: 0.3; } }
.center-screen {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
text-align: center; display: none; pointer-events: auto;
background: rgba(30,30,45,0.95); padding: 40px; border-radius: 20px;
border: 4px solid #AAEEFF; box-shadow: 0 0 30px rgba(170,238,255,0.3); color: #fff;
}
.msg-title { font-size: 50px; font-weight: 900; margin-bottom: 20px; white-space: nowrap; text-shadow: 2px 2px 0 #000; }
.btn {
padding: 15px 40px; font-size: 24px; background: transparent;
color: white; border: 2px solid #AAEEFF; cursor: pointer; transition: 0.2s;
font-family: inherit; letter-spacing: 2px; text-transform: uppercase; font-weight: bold;
}
.btn:hover { background: #AAEEFF; color: black; box-shadow: 0 0 20px #AAEEFF; }
#loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-weight: bold; text-shadow: 1px 1px 0 #000; }
</style>
<script async src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/",
"cannon-es": "https://unpkg.com/cannon-es@0.20.0/dist/cannon-es.js"
}
}
</script>
</head>
<body>
<div id="loading">Loading Square Box...</div>
<div id="ui-layer">
<div id="header">
<div class="status-row">
<span class="stat-label">STAGE</span> <span id="stage-val" class="stat-val">1</span><br>
<span class="stat-label">SCORE</span> <span id="score-val" class="stat-val">0</span>
</div>
<div class="controls-info">
<p>左ドラッグ: 視点移動<br>
左クリック: 破壊<br>
右ドラッグ: 撹拌 (回転)<br>
Esc: 一時停止 | SPEED: x<span id="speed-val">1.0</span></p>
</div>
</div>
<div id="combo-display">
<div id="combo-container">
<span id="combo-num"></span><span id="combo-unit">break !</span>
</div>
</div>
<div id="timer-container">
<div id="time-text">残り 60 秒</div>
<div id="time-bar-bg"><div id="time-bar-fill"></div></div>
</div>
<div id="fps-counter">60 FPS</div>
<div id="danger-warning">FALL OUT!</div>
<div id="pause-screen" class="center-screen">
<div class="msg-title" style="color: #FFFFFF;">PAUSED</div>
<button class="btn" onclick="togglePause()">RESUME</button>
</div>
<div id="game-over-screen" class="center-screen">
<div class="msg-title" style="color: #FF5555;">GAME OVER</div>
<button class="btn" onclick="location.reload()">RETRY</button>
</div>
<div id="stage-clear-screen" class="center-screen">
<div class="msg-title" style="color: #AAEEFF;">STAGE CLEAR!</div>
<p style="margin-bottom:20px; font-weight:bold;">落下速度UP & 難易度上昇</p>
<button class="btn" onclick="window.nextStage()">NEXT STAGE</button>
</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import * as CANNON from 'cannon-es';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
// --- 定数設定 ---
const GEM_RADIUS = 0.8;
const BOARD_WIDTH = 12;
// 箱の高さ (宝石3個分 = 約4.8程度)
const BOARD_HEIGHT = GEM_RADIUS * 6.0;
const GEM_SPAWN_Y = 18;
const STAGE_TIME = 60;
const BASE_SPAWN_INTERVAL = 400;
const MATCH_DIST = GEM_RADIUS * 3.5;
const ROT_LIGHT_HEIGHT = 30;
const ROT_LIGHT_RADIUS = BOARD_WIDTH * 2.0;
// 彩度高めのパステル
const GEM_TYPES = [
{ color: 0xFF6666 },
{ color: 0x44FF44 },
{ color: 0x44AAFF },
{ color: 0xFFFF44 },
{ color: 0xCC66FF }
];
// --- グローバル変数 ---
let score = 0;
let stage = 1;
let timeScale = 1.0;
let isPlaying = false;
let isPaused = false;
let isGameOver = false;
let timeLeft = STAGE_TIME;
let spawnInterval = BASE_SPAWN_INTERVAL;
let penaltyCount = 0;
let lastSpawnTime = 0;
let gameStartTime = 0;
let dangerTimer = 0;
let baseGravity = -800; // 初期落下速度を8倍相当に強化
let gems = [];
let particles = [];
// FPS計算用
let lastFrameTime = 0;
let frameCount = 0;
let lastFpsTime = 0;
let dragMode = null;
let dragPivotGem = null;
let dragStartMouse = new THREE.Vector2();
let dragPrevMouse = new THREE.Vector2();
let squeezeSoundCooldown = 0;
let world, scene, camera, renderer, composer, controls;
let raycaster, mouse, mouseDownPos;
let wallMaterial, gemPhysMat;
let gemGeometry;
let rotatingPointLight;
// 音響
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let noiseBuffer = 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;
}
noiseBuffer = buffer;
}
function resumeAudio() { if (audioCtx.state === 'suspended') audioCtx.resume(); }
function playSound(type, delay=0) {
resumeAudio();
const t = audioCtx.currentTime + delay;
const gain = audioCtx.createGain();
gain.connect(audioCtx.destination);
if (type === 'hit') {
const osc = audioCtx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(2000, t);
osc.frequency.exponentialRampToValueAtTime(4000, t + 0.05);
gain.gain.setValueAtTime(0.01, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
osc.connect(gain);
osc.start(t); osc.stop(t + 0.05);
} else if (type === 'break') {
const osc = audioCtx.createOscillator();
osc.type = 'sine';
osc.frequency.setValueAtTime(3000, t);
osc.frequency.exponentialRampToValueAtTime(6000, t + 0.1);
const oscGain = audioCtx.createGain();
oscGain.gain.setValueAtTime(0.1, t);
oscGain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
osc.connect(oscGain);
oscGain.connect(gain);
osc.start(t); osc.stop(t + 0.3);
} else if (type === 'error') {
const osc = audioCtx.createOscillator();
osc.type = 'triangle';
osc.frequency.setValueAtTime(150, t);
osc.frequency.linearRampToValueAtTime(100, t + 0.2);
gain.gain.setValueAtTime(0.1, t);
gain.gain.exponentialRampToValueAtTime(0.01, t + 0.2);
osc.connect(gain);
osc.start(t); osc.stop(t + 0.2);
} else if (type === 'squeeze') {
const osc = audioCtx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(100, t);
osc.frequency.linearRampToValueAtTime(50, t + 0.2);
gain.gain.setValueAtTime(0.05, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.2);
const filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 500;
osc.connect(filter);
filter.connect(gain);
osc.start(t); osc.stop(t + 0.2);
}
}
function formatScore(num) {
if (num < 10000) return num.toString();
let str = "";
const oku = Math.floor(num / 100000000);
const man = Math.floor((num % 100000000) / 10000);
const rest = num % 10000;
if (oku > 0) str += oku + "億";
if (man > 0) str += man + "万";
if (rest > 0) str += rest;
return str;
}
function updateScoreDisplay() {
document.getElementById('score-val').innerText = formatScore(score);
}
function init() {
document.getElementById('loading').style.display = 'none';
createNoiseBuffer();
world = new CANNON.World();
world.gravity.set(0, baseGravity, 0);
world.broadphase = new CANNON.NaiveBroadphase();
world.solver.iterations = 10;
world.solver.tolerance = 0.01;
wallMaterial = new CANNON.Material();
gemPhysMat = new CANNON.Material();
const contactMat = new CANNON.ContactMaterial(wallMaterial, gemPhysMat, { friction: 0.1, restitution: 0.1 });
const gemGemMat = new CANNON.ContactMaterial(gemPhysMat, gemPhysMat, { friction: 0.1, restitution: 0.1 });
world.addContactMaterial(contactMat);
world.addContactMaterial(gemGemMat);
scene = new THREE.Scene();
scene.background = new THREE.Color(0x222233);
scene.fog = new THREE.FogExp2(0x222233, 0.01);
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 20, 30);
const ambientLight = new THREE.AmbientLight(0xffffff, 1.2);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 1.8);
sunLight.position.set(20, 40, 20);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 1024;
sunLight.shadow.mapSize.height = 1024;
scene.add(sunLight);
rotatingPointLight = new THREE.PointLight(0xffffff, 3.0, 300);
rotatingPointLight.position.set(ROT_LIGHT_RADIUS, ROT_LIGHT_HEIGHT, 0);
const lightMesh = new THREE.Mesh(
new THREE.SphereGeometry(1.0, 8, 8),
new THREE.MeshBasicMaterial({ color: 0xffffff })
);
rotatingPointLight.add(lightMesh);
scene.add(rotatingPointLight);
const fillLight = new THREE.DirectionalLight(0xaaccff, 0.6);
fillLight.position.set(-20, 10, -10);
scene.add(fillLight);
renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.body.appendChild(renderer.domElement);
const renderScene = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth/2, window.innerHeight/2), 1.5, 0.4, 0.85);
bloomPass.threshold = 0.6;
bloomPass.strength = 0.4;
bloomPass.radius = 0.2;
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 2, 0);
controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: null };
gemGeometry = new THREE.IcosahedronGeometry(GEM_RADIUS, 0);
createContainer();
for(let i=0; i<30; i++) {
setTimeout(() => spawnGem(), i*30);
}
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
mouseDownPos = new THREE.Vector2();
window.addEventListener('resize', onWindowResize);
renderer.domElement.addEventListener('pointerdown', onPointerDown);
renderer.domElement.addEventListener('pointermove', onPointerMove);
renderer.domElement.addEventListener('pointerup', onPointerUp);
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('keydown', onKeyDown);
startGame();
requestAnimationFrame(animate);
}
function startGame() {
isPlaying = true;
isPaused = false;
isGameOver = false;
timeLeft = STAGE_TIME;
gameStartTime = performance.now();
lastFrameTime = performance.now();
lastFpsTime = performance.now();
document.getElementById('danger-warning').style.display = 'none';
updateTimerDisplay();
updateScoreDisplay();
}
window.togglePause = function() {
if (!isPlaying || isGameOver) return;
isPaused = !isPaused;
document.getElementById('pause-screen').style.display = isPaused ? 'block' : 'none';
if (!isPaused) lastFrameTime = performance.now();
}
window.nextStage = function() {
stage++;
score += 2000;
spawnInterval = Math.max(100, spawnInterval * 0.9);
// ステージ進行で重力を1.5倍にする
baseGravity *= 1.5;
world.gravity.set(0, baseGravity, 0);
clearAllGems();
document.getElementById('stage-clear-screen').style.display = 'none';
document.getElementById('stage-val').innerText = stage;
updateScoreDisplay();
for(let i=0; i<10; i++) {
setTimeout(() => spawnGem(), i*50);
}
startGame();
}
function clearAllGems() {
gems.forEach(gem => {
world.removeBody(gem.body);
scene.remove(gem.mesh);
createSparks(gem.body.position, 0xFFFFFF);
});
gems = [];
}
function createContainer() {
const h = BOARD_HEIGHT;
const w = BOARD_WIDTH;
// 床 (物理ボディは非常に分厚くして突き抜け防止)
// Y=-50 を中心に、高さ100のボックスにする
const floorShape = new CANNON.Box(new CANNON.Vec3(w/2, 50, w/2));
const floorBody = new CANNON.Body({ mass: 0, material: wallMaterial });
floorBody.addShape(floorShape);
floorBody.position.set(0, -50, 0); // 表面が Y=0 になる
world.addBody(floorBody);
// 床の描画 (見た目は薄い板)
const floorMesh = new THREE.Mesh(
new THREE.BoxGeometry(w, 0.2, w),
new THREE.MeshStandardMaterial({ color: 0x444455, roughness: 0.6 })
);
floorMesh.position.set(0, -0.1, 0);
floorMesh.receiveShadow = true;
scene.add(floorMesh);
// 壁
const wallPositions = [
{ x: 0, z: -w/2 - 0.5, w: w + 2, h: h, d: 1 },
{ x: 0, z: w/2 + 0.5, w: w + 2, h: h, d: 1 },
{ x: -w/2 - 0.5, z: 0, w: 1, h: h, d: w },
{ x: w/2 + 0.5, z: 0, w: 1, h: h, d: w }
];
wallPositions.forEach(p => {
const body = new CANNON.Body({ mass: 0, material: wallMaterial });
body.addShape(new CANNON.Box(new CANNON.Vec3(p.w/2, p.h/2, p.d/2)));
body.position.set(p.x, p.h/2, p.z);
world.addBody(body);
// 透明度 0.05 (非常に薄い)
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(p.w, p.h, p.d),
new THREE.MeshPhysicalMaterial({
color: 0xffffff,
opacity: 0.05, // かなり透明
transparent: true,
roughness: 0.1,
side: THREE.DoubleSide,
depthWrite: false
})
);
mesh.position.copy(body.position);
scene.add(mesh);
});
}
function spawnGem() {
if ((isGameOver && !isPlaying) || isPaused) return;
let spawnPos = new CANNON.Vec3(0, GEM_SPAWN_Y, 0);
let valid = false;
let attempts = 0;
while (!valid && attempts < 15) {
spawnPos.x = (Math.random() - 0.5) * (BOARD_WIDTH - 3);
spawnPos.z = (Math.random() - 0.5) * (BOARD_WIDTH - 3);
valid = true;
for (let gem of gems) {
if (gem.body.position.y > GEM_SPAWN_Y - 3.0) {
const dx = gem.body.position.x - spawnPos.x;
const dz = gem.body.position.z - spawnPos.z;
if (Math.sqrt(dx*dx + dz*dz) < GEM_RADIUS * 2.2) {
valid = false;
break;
}
}
}
attempts++;
}
const typeIdx = Math.floor(Math.random() * GEM_TYPES.length);
const gemData = GEM_TYPES[typeIdx];
const body = new CANNON.Body({ mass: 30, material: gemPhysMat });
body.addShape(new CANNON.Sphere(GEM_RADIUS));
body.position.copy(spawnPos);
body.linearDamping = 0.0;
body.angularDamping = 0.1;
body.allowSleep = true;
body.sleepSpeedLimit = 0.5;
body.sleepTimeLimit = 0.2;
world.addBody(body);
body.addEventListener("collide", (e) => {
const cv = e.contact.getImpactVelocityAlongNormal();
if (Math.abs(cv) > 20.0) {
const other = gems.find(g => g.body === e.body);
if (other && other.colorIdx === typeIdx) playSound('hit');
}
});
let meshMat = new THREE.MeshStandardMaterial({
color: gemData.color,
emissive: gemData.color,
emissiveIntensity: 0.3,
roughness: 0.3,
metalness: 0.1,
flatShading: true
});
const mesh = new THREE.Mesh(gemGeometry, meshMat);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
gems.push({ mesh, body, colorIdx: typeIdx, id: body.id, spawnTime: Date.now() });
}
function createSparks(pos, color) {
const particleCount = 8;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
const velocities = [];
for(let i=0; i<particleCount; i++) {
positions[i*3] = pos.x; positions[i*3+1] = pos.y; positions[i*3+2] = pos.z;
velocities.push({ x: (Math.random()-0.5)*20, y: (Math.random()-0.5)*20, z: (Math.random()-0.5)*20 });
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({ color: color, size: 0.8, transparent: true, blending: THREE.AdditiveBlending });
const points = new THREE.Points(geo, mat);
scene.add(points);
particles.push({ mesh: points, velocities: velocities, life: 1.0 });
}
function showFloatingScore(points, position) {
const div = document.createElement('div');
div.className = 'floating-score';
div.innerText = '+' + points;
document.body.appendChild(div);
const vec = position.clone();
vec.y += 1.5;
vec.project(camera);
const x = (vec.x * 0.5 + 0.5) * window.innerWidth;
const y = (-(vec.y * 0.5) + 0.5) * window.innerHeight;
div.style.left = `${x}px`;
div.style.top = `${y}px`;
setTimeout(() => { if(div.parentNode) div.parentNode.removeChild(div); }, 600);
}
function showComboDisplay(count) {
const container = document.getElementById('combo-container');
const numEl = document.getElementById('combo-num');
container.classList.remove('combo-anim');
void container.offsetWidth;
numEl.innerText = count;
container.classList.add('combo-anim');
if(window.comboTimer) clearTimeout(window.comboTimer);
window.comboTimer = setTimeout(() => {
container.classList.remove('combo-anim');
numEl.innerText = "";
}, 3000);
}
function onPointerDown(event) {
if (isGameOver || isPaused) return;
mouseDownPos.x = event.clientX;
mouseDownPos.y = event.clientY;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(gems.map(g => g.mesh));
if (event.button === 2) {
if (intersects.length > 0) {
const targetGem = gems.find(g => g.mesh === intersects[0].object);
if (targetGem) {
dragMode = 'rotate';
dragPivotGem = targetGem;
dragStartMouse.set(event.clientX, event.clientY);
dragPrevMouse.set(event.clientX, event.clientY);
controls.enabled = false;
dragPivotGem.body.type = CANNON.Body.KINEMATIC;
dragPivotGem.body.velocity.set(0,0,0);
dragPivotGem.body.angularVelocity.set(0,0,0);
}
}
}
}
function onPointerMove(event) {
if (isGameOver || isPaused) return;
if (dragMode === 'rotate' && dragPivotGem) {
const currentMouse = new THREE.Vector2(event.clientX, event.clientY);
const deltaX = currentMouse.x - dragPrevMouse.x;
const deltaY = currentMouse.y - dragPrevMouse.y;
dragPrevMouse.copy(currentMouse);
const ROTATION_STRENGTH = 20.0;
const EFFECT_RADIUS = GEM_RADIUS * 2.8;
const pivotPos = dragPivotGem.body.position;
// 詰まり判定 (単純に一定距離内のジェム数で判定)
let neighborCount = 0;
gems.forEach(gem => {
if (gem === dragPivotGem) return;
const dist = gem.body.position.distanceTo(pivotPos);
if (dist < EFFECT_RADIUS) {
neighborCount++;
}
});
if (neighborCount >= 6) {
if (Date.now() - squeezeSoundCooldown > 300) {
playSound('squeeze');
squeezeSoundCooldown = Date.now();
}
return;
}
gems.forEach(gem => {
if (gem === dragPivotGem) return;
const dx = gem.body.position.x - pivotPos.x;
const dz = gem.body.position.z - pivotPos.z;
const dist = Math.sqrt(dx*dx + dz*dz);
if (dist < EFFECT_RADIUS) {
const speed = (Math.abs(deltaX) + Math.abs(deltaY)) * 0.5;
if (speed > 0.5) {
let tx = -dz;
let tz = dx;
const len = Math.sqrt(tx*tx + tz*tz);
if (len > 0.001) {
tx /= len;
tz /= len;
const dir = deltaX > 0 ? -1 : 1;
gem.body.wakeUp();
gem.body.velocity.x += tx * ROTATION_STRENGTH * speed * 0.1 * dir;
gem.body.velocity.z += tz * ROTATION_STRENGTH * speed * 0.1 * dir;
// 上方向へ跳ねる力を抑制
if (gem.body.velocity.y > 0) {
gem.body.velocity.y = 0;
}
}
}
}
});
}
}
function onPointerUp(event) {
if (isGameOver || isPaused) return;
if (dragMode === 'rotate') {
if (dragPivotGem) {
dragPivotGem.body.type = CANNON.Body.DYNAMIC;
dragPivotGem.body.wakeUp();
}
dragMode = null;
dragPivotGem = null;
controls.enabled = true;
} else if (event.button === 0) {
const dx = event.clientX - mouseDownPos.x;
const dy = event.clientY - mouseDownPos.y;
if (Math.sqrt(dx*dx + dy*dy) < 5) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(gems.map(g => g.mesh));
if (intersects.length > 0) {
const targetGem = gems.find(g => g.mesh === intersects[0].object);
tryMatch(targetGem);
}
}
}
}
function updateTimerDisplay() {
const seconds = Math.ceil(timeLeft);
document.getElementById('time-text').innerText = `残り ${seconds} 秒`;
const ratio = timeLeft / STAGE_TIME;
document.getElementById('time-bar-fill').style.transform = `scaleX(${ratio})`;
}
function checkGameOver() {
// 箱の外に溢れた玉(Y < -3.0)の処理
const dropped = [];
for (let i = gems.length - 1; i >= 0; i--) {
if (gems[i].body.position.y < -3.0) {
dropped.push(gems[i]);
}
}
dropped.forEach(gem => {
// 箱の内部エリア(x,z < 6)なら底抜け判定 -> 消すだけ
// 箱の外部なら溢れ判定 -> GAMEOVER
const x = Math.abs(gem.body.position.x);
const z = Math.abs(gem.body.position.z);
if (x < 6.0 && z < 6.0) {
// 底抜け (バグ回避のための救済)
world.removeBody(gem.body);
scene.remove(gem.mesh);
const idx = gems.indexOf(gem);
if (idx > -1) gems.splice(idx, 1);
} else {
// 箱の外へ溢れた
document.getElementById('danger-warning').style.display = 'block';
gameOver();
}
});
if (dropped.length === 0) {
document.getElementById('danger-warning').style.display = 'none';
}
}
function animate(time) {
requestAnimationFrame(animate);
frameCount++;
if (time - lastFpsTime >= 1000) {
document.getElementById('fps-counter').innerText = frameCount + " FPS";
frameCount = 0;
lastFpsTime = time;
}
if (isPaused) { composer.render(); lastFrameTime = time; return; }
let dt = (time - lastFrameTime) / 1000;
if (dt > 0.05) dt = 0.05;
lastFrameTime = time;
if (isPlaying && !isGameOver) {
const now = Date.now();
if (now - lastSpawnTime > spawnInterval) {
spawnGem(gemPhysMat);
while(penaltyCount > 0) {
setTimeout(() => spawnGem(gemPhysMat), Math.random()*200);
penaltyCount--;
}
lastSpawnTime = now;
}
timeLeft -= dt * timeScale;
if (timeLeft <= 0) { timeLeft = 0; stageClear(); }
updateTimerDisplay();
checkGameOver();
const t = now * 0.001;
if (rotatingPointLight) {
rotatingPointLight.position.x = Math.sin(t) * ROT_LIGHT_RADIUS;
rotatingPointLight.position.z = Math.cos(t) * ROT_LIGHT_RADIUS;
}
}
world.step(1/60, dt, 3);
controls.update();
gems.forEach(gem => {
gem.mesh.position.copy(gem.body.position);
gem.mesh.quaternion.copy(gem.body.quaternion);
});
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt * 3.0;
if (p.life <= 0) { scene.remove(p.mesh); particles.splice(i, 1); continue; }
const pos = p.mesh.geometry.attributes.position.array;
for(let j=0; j<p.velocities.length; j++) {
pos[j*3] += p.velocities[j].x * dt;
pos[j*3+1] += p.velocities[j].y * dt;
pos[j*3+2] += p.velocities[j].z * dt;
}
p.mesh.geometry.attributes.position.needsUpdate = true;
p.mesh.material.opacity = p.life;
}
composer.render();
}
function gameOver() {
if(isGameOver) return;
isPlaying = false;
isGameOver = true;
timeScale = 0.1;
document.getElementById('game-over-screen').style.display = 'block';
}
function stageClear() {
isPlaying = false;
timeScale = 0.5;
document.getElementById('danger-warning').style.display = 'none';
document.getElementById('stage-clear-screen').style.display = 'block';
}
function tryMatch(startGem) {
const matchGroup = [startGem];
const checkQueue = [startGem];
const checkedIds = new Set([startGem.id]);
while(checkQueue.length > 0) {
const current = checkQueue.shift();
for (let other of gems) {
if (checkedIds.has(other.id)) continue;
if (other.colorIdx !== current.colorIdx) continue;
const dist = current.body.position.distanceTo(other.body.position);
if (dist < MATCH_DIST) {
matchGroup.push(other);
checkQueue.push(other);
checkedIds.add(other.id);
}
}
}
if (matchGroup.length >= 3) {
// スコア計算: 基本30点 + 増えた分(個数-3) * 30点
const baseScore = 30;
const extraCount = Math.max(0, matchGroup.length - 3);
const points = baseScore + (30 * extraCount);
score += points;
updateScoreDisplay();
showFloatingScore(points, startGem.mesh.position);
showComboDisplay(matchGroup.length);
matchGroup.forEach((gem, idx) => {
setTimeout(() => {
if (gems.includes(gem)) {
playSound('break', 0);
createSparks(gem.body.position, GEM_TYPES[gem.colorIdx].color);
scene.remove(gem.mesh);
world.removeBody(gem.body);
const i = gems.indexOf(gem);
if (i > -1) gems.splice(i, 1);
}
}, idx * 60);
});
dangerTimer = Math.max(0, dangerTimer - 1.0);
} else {
playSound('error');
penaltyCount++;
startGem.body.velocity.y += 20;
}
}
function onKeyDown(e) {
if (e.code === 'Escape') { togglePause(); return; }
if (isGameOver || isPaused) return;
if (e.key === '+' || e.key === ';') timeScale = Math.min(timeScale * 2, 8.0);
if (e.key === '-' || e.key === '_') timeScale = Math.max(timeScale / 2, 0.125);
document.getElementById('speed-val').innerText = timeScale.toFixed(1);
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
}
init();
</script>
</body>
</html>・ダウンロードされる方はこちら。↓
【難易度を調整して特訓する方法】
・テンキーの+キーを押すと、玉が落ちてくる速さが上がります。
・-キーを押すと、速度が半分になります。


コメント