<!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 3 D - Fixed</title>
<style>
body { margin: 0 ; overflow: hidden; background-color: #050510; font-family: 'Segoe UI', sans-serif; }
#ui { position: absolute; top: 20px; left: 20px; color: white; pointer-events: none; z-index: 10; text-shadow: 0 0 5px #000; }
h1 { margin: 0 ; font-size: 24 px; color: #FFD700; }
p { margin: 5 px 0 ; font-size: 14 px; color: #ccc; }
.stat-box { font-size: 20 px; margin-top: 10 px; font-weight: bold; }
#speed-indicator { color: #00FF00; }
#game-over {
position: absolute; top: 50 %; left: 50 %; transform: translate(-50 %, -50 %);
font-size: 80 px; color: #FF0000; font-weight: 900;
text-shadow: 0 0 30 px #FF0000;
display: none; pointer-events: auto; z-index: 100 ;
text-align: center;
}
#restart-btn {
display: block; margin: 20 px auto; padding: 10 px 30 px;
font-size: 24 px; background: #333; color: white; border: 2px solid white; cursor: pointer;
}
#loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: white; }
</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 Engine...</div>
<div id="ui" >
<h1>Gem Physics 3 D</h1>
<p>左クリック: 消す / 右クリック: 混ぜる</p>
<p>[+]キー: 加速 / [-]キー: 減速</p>
<div class ="stat-box" >SCORE: <span id="score" >0 </span></div>
<div class ="stat-box" >SPEED: x<span id="speed-val" >1.0 </span></div>
</div>
<div id="game-over" >
GAME OVER
<button id="restart-btn" onclick="location.reload()" >RETRY</button>
</div>
<script type="module" >
import * as THREE from 'three' ;
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 ;
const BOARD_HEIGHT = 16 ;
const GAME_OVER_HEIGHT = 12 ;
const GEM_SPAWN_Y = 20 ;
const COLORS = [0xFF0055 , 0x00FF99 , 0x00CCFF , 0xFFFF00 , 0xAA00FF ];
const MATCH_DIST = GEM_RADIUS * 3.5 ;
let score = 0 ;
let timeScale = 1.0 ;
let isGameOver = false ;
let gems = [];
let world, scene, camera, renderer, composer;
let raycaster, mouse;
let gameOverLineMesh;
let gameStartTime = 0 ;
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
function playKiranSound ( ) {
if (audioCtx.state === 'suspended' ) audioCtx.resume();
const t = audioCtx.currentTime;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine' ;
osc.frequency.setValueAtTime(2000 , t);
osc.frequency.exponentialRampToValueAtTime(3000 , t + 0.1 );
gain.gain.setValueAtTime(0.05 , t);
gain.gain.exponentialRampToValueAtTime(0.001 , t + 0.15 );
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t);
osc.stop(t + 0.15 );
}
function playKyuanSound (delayIndex ) {
if (audioCtx.state === 'suspended' ) audioCtx.resume();
const t = audioCtx.currentTime + (delayIndex * 0.15 );
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle' ;
osc.frequency.setValueAtTime(400 , t);
osc.frequency.linearRampToValueAtTime(800 , t + 0.3 );
gain.gain.setValueAtTime(0 , t);
gain.gain.linearRampToValueAtTime(0.1 , t + 0.1 );
gain.gain.exponentialRampToValueAtTime(0.001 , t + 0.5 );
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(t);
osc.stop(t + 0.5 );
}
function init ( ) {
document.getElementById('loading' ).style.display = 'none' ;
gameStartTime = Date.now();
world = new CANNON.World();
world.gravity.set (0 , -20 , 0 );
world.broadphase = new CANNON.NaiveBroadphase();
world.solver.iterations = 10 ;
const wallMaterial = new CANNON.Material();
const gemMaterial = new CANNON.Material();
const contactMat = new CANNON.ContactMaterial(wallMaterial, gemMaterial, { friction: 0.05 , restitution: 0.3 });
const gemGemMat = new CANNON.ContactMaterial(gemMaterial, gemMaterial, { friction: 0.1 , restitution: 0.4 });
world.addContactMaterial(contactMat);
world.addContactMaterial(gemGemMat);
scene = new THREE.Scene();
scene.background = new THREE.Color(0x050510 );
scene.fog = new THREE.FogExp2(0x050510 , 0.02 );
camera = new THREE.PerspectiveCamera(45 , window.innerWidth / window.innerHeight, 0.1 , 100 );
camera.position.set (0 , 18 , 28 );
camera.lookAt(0 , 6 , 0 );
scene.add (new THREE.AmbientLight(0x404040 ));
const dirLight = new THREE.DirectionalLight(0xffffff , 1.5 );
dirLight.position.set (10 , 20 , 10 );
dirLight.castShadow = true ;
scene.add (dirLight);
scene.add (new THREE.PointLight(0xffffff , 3 , 50 ).translateY(10 ));
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true ;
document.body.appendChild(renderer.domElement);
const renderScene = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5 , 0.4 , 0.85 );
bloomPass.threshold = 0.1 ; bloomPass.strength = 1.2 ; bloomPass.radius = 0.5 ;
composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
createContainer(wallMaterial);
createGameOverLine();
for (let i = 0 ; i < 60 ; i++) {
setTimeout(() => spawnGem(gemMaterial), i * 80 );
}
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
window.addEventListener('resize' , onWindowResize);
window.addEventListener('mousedown' , onMouseDown);
window.addEventListener('contextmenu' , e => e.preventDefault());
window.addEventListener('keydown' , (e) => {
if (isGameOver) return ;
if (e.key === '+' || e.key === ';' ) {
timeScale = Math.min(timeScale * 2 , 8.0 );
} else if (e.key === '-' || e.key === '_' ) {
timeScale = Math.max(timeScale / 2 , 0.125 );
}
document.getElementById('speed-val' ).innerText = timeScale.toFixed(1 );
});
animate();
}
function createGameOverLine ( ) {
const geometry = new THREE.BoxGeometry(BOARD_WIDTH, 0.1 , BOARD_WIDTH);
const material = new THREE.MeshBasicMaterial({
color: 0xFF0000 , transparent: true , opacity: 0.3 , blending: THREE.AdditiveBlending, side: THREE.DoubleSide
});
gameOverLineMesh = new THREE.Mesh(geometry, material);
gameOverLineMesh.position.set (0 , GAME_OVER_HEIGHT, 0 );
scene.add (gameOverLineMesh);
gameOverLineMesh.userData = { time: 0 };
}
function createContainer (material ) {
const floorBody = new CANNON.Body({ mass: 0 , material: material });
floorBody.addShape(new CANNON.Box(new CANNON.Vec3(BOARD_WIDTH/2 , 1 , BOARD_WIDTH/2 )));
floorBody.position.set (0 , -1 , 0 );
world.addBody(floorBody);
const floorMesh = new THREE.Mesh(new THREE.BoxGeometry(BOARD_WIDTH, 2 , BOARD_WIDTH), new THREE.MeshStandardMaterial({ color: 0x222233 }));
floorMesh.position.copy(floorBody.position);
scene.add (floorMesh);
const h = BOARD_HEIGHT * 2 ;
const walls = [
{ pos: [0 , h/2 , -BOARD_WIDTH/2 -0.5 ], size: [BOARD_WIDTH+2 , h, 1 ] },
{ pos: [0 , h/2 , BOARD_WIDTH/2 +0.5 ], size: [BOARD_WIDTH+2 , h, 1 ] },
{ pos: [-BOARD_WIDTH/2 -0.5 , h/2 , 0 ], size: [1 , h, BOARD_WIDTH] },
{ pos: [BOARD_WIDTH/2 +0.5 , h/2 , 0 ], size: [1 , h, BOARD_WIDTH] }
];
walls.forEach(w => {
const body = new CANNON.Body({ mass: 0 , material: material });
body.addShape(new CANNON.Box(new CANNON.Vec3(w.size[0 ]/2 , w.size[1 ]/2 , w.size[2 ]/2 )));
body.position.set (...w.pos);
world.addBody(body);
});
}
function spawnGem (material ) {
if (isGameOver) return ;
const colorIdx = Math.floor(Math.random() * COLORS.length);
const color = COLORS[colorIdx];
const body = new CANNON.Body({ mass: 10 , material: material });
body.addShape(new CANNON.Sphere(GEM_RADIUS));
body.position.set (
(Math.random() - 0.5 ) * (BOARD_WIDTH - 3 ),
GEM_SPAWN_Y + Math.random() * 5 ,
(Math.random() - 0.5 ) * (BOARD_WIDTH - 3 )
);
body.linearDamping = 0.3 ; body.angularDamping = 0.3 ;
world.addBody(body);
body.addEventListener("collide" , (e) => {
const contactVelocity = e.contact.getImpactVelocityAlongNormal();
if (Math.abs(contactVelocity) > 2.0 ) {
const otherGem = gems.find(g => g.body === e.body);
if (otherGem && otherGem.colorIdx === colorIdx) {
playKiranSound();
}
}
});
const geometry = new THREE.IcosahedronGeometry(GEM_RADIUS, 1 );
const mesh = new THREE.Mesh(geometry, new THREE.MeshPhysicalMaterial({
color: color, metalness: 0.1 , roughness: 0.1 , transmission: 0.4 , thickness: 1.5 , emissive: color, emissiveIntensity: 0.4
}));
mesh.castShadow = true ; mesh.receiveShadow = true ;
scene.add (mesh);
gems.push({ mesh, body, colorIdx, id: body.id, spawnTime: Date.now() });
}
function checkGameOver ( ) {
if (isGameOver) return ;
const now = Date.now();
if (now - gameStartTime < 10000 ) return ;
const dangerousGems = gems.filter(g =>
g.body.position.y > GAME_OVER_HEIGHT &&
g.body.velocity.length() < 0.5 &&
(now - g.spawnTime > 3000 )
);
if (dangerousGems.length > 0 ) {
triggerGameOver();
}
}
function triggerGameOver ( ) {
isGameOver = true ;
document.getElementById('game-over' ).style.display = 'block' ;
document.getElementById('ui' ).style.opacity = 0.3 ;
timeScale = 0.1 ;
}
function onMouseDown (event ) {
if (isGameOver) return ;
event .preventDefault();
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 );
if (event .button === 2 ) {
rotateArea(targetGem.body.position);
} else {
tryMatch(targetGem);
}
}
}
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 ) {
removeGems(matchGroup);
}
}
function removeGems (group ) {
const points = group .length * group .length * 100 ;
score += points;
document.getElementById('score' ).innerText = score;
group .forEach((_, index) => { playKyuanSound(index); });
group .forEach(gem => {
scene.remove (gem.mesh);
world.removeBody(gem.body);
const idx = gems.indexOf(gem);
if (idx > -1 ) gems.splice(idx, 1 );
});
if (!isGameOver) {
setTimeout(() => {
for (let i=0 ; i<group .length; i++) {
setTimeout(() => spawnGem(gems[0 ]?.body.material || new CANNON.Material()), i * 50 );
}
}, 500 );
}
}
function rotateArea (centerPos ) {
const RADIUS = 4.0 ;
gems.forEach(gem => {
const pos = gem.body.position;
const dist = Math.sqrt(Math.pow(pos.x - centerPos.x, 2 ) + Math.pow(pos.z - centerPos.z, 2 ));
if (dist < RADIUS) {
gem.body.velocity.y += 8 ;
const angle = Math.atan2(pos.z - centerPos.z, pos.x - centerPos.x);
const force = 10 ;
gem.body.velocity.x += -Math.sin(angle) * force;
gem.body.velocity.z += Math.cos(angle) * force;
gem.body.angularVelocity.set (Math.random()*10 , Math.random()*10 , Math.random()*10 );
}
});
}
function onWindowResize ( ) {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
}
function animate ( ) {
requestAnimationFrame(animate);
const dt = 1 / 60 * timeScale;
world.step(dt);
gems.forEach(gem => {
gem.mesh.position.copy(gem.body.position);
gem.mesh.quaternion.copy(gem.body.quaternion);
if (gem.body.position.y < -10 ) {
gem.body.position.set (0 , GEM_SPAWN_Y, 0 );
gem.body.velocity.set (0 ,0 ,0 );
}
});
if (gameOverLineMesh) {
gameOverLineMesh.userData.time += 0.05 ;
gameOverLineMesh.material.opacity = 0.2 + Math.sin(gameOverLineMesh.userData.time) * 0.1 ;
}
checkGameOver();
composer.render();
}
init();
</script>
</body>
</html>
copy
コメント