<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>清流の魚とカニの3D水族館</title>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #f0f4f8;
font-family: 'Helvetica Neue', Arial, sans-serif;
touch-action: none; /* ブラウザのデフォルトのタッチ操作(スクロール等)を抑制 */
}
#ui {
position: absolute;
top: env(safe-area-inset-top, 20px);
left: env(safe-area-inset-left, 20px);
color: #333333;
text-shadow: 1px 1px 2px rgba(255,255,255,0.8);
pointer-events: none;
z-index: 10;
}
#ui h1 {
margin: 0;
font-size: clamp(1rem, 5vw, 1.5rem); /* 画面サイズに合わせて自動調整 */
}
#ui p {
margin: 5px 0 0 0;
font-size: clamp(0.7rem, 3vw, 0.9rem);
}
#instructions {
position: absolute;
bottom: env(safe-area-inset-bottom, 20px);
width: 100%;
text-align: center;
color: #555555;
font-size: clamp(0.6rem, 2.5vw, 0.8rem);
text-shadow: 1px 1px 1px rgba(255,255,255,0.8);
pointer-events: none;
z-index: 10;
padding: 0 10px;
box-sizing: border-box;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="ui">
<h1>Mountain Stream Aquarium</h1>
<p>魚100匹とカニ35匹の清流アクアリウム</p>
</div>
<div id="instructions">
ドラッグ・スワイプで回転 / スクロール・ピンチでズーム
</div>
<!-- Three.js Library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
let scene, camera, renderer, fishes = [], bubbles = [], rocks = [], crabs = [], plants = [];
let targetRotationX = 0.2, targetRotationY = 0;
let cameraDistance = 600;
let isMouseDown = false;
let lastMouseX = 0, lastMouseY = 0;
// タッチ操作用変数
let lastTouchX = 0, lastTouchY = 0;
let lastPinchDistance = 0;
const FISH_COUNT = 100;
const NIGOI_COUNT = 10;
const CRAB_COUNT = 35;
const BUBBLE_COUNT = 60;
const PLANT_COUNT = 40;
const TANK_SIZE = { w: 160, h: 90, d: 90 };
window.onload = function() {
init();
animate();
};
// 各種テクスチャ生成
function createYamameTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 128;
const ctx = canvas.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 128);
grad.addColorStop(0, '#5d5d4a');
grad.addColorStop(0.3, '#a3a380');
grad.addColorStop(0.5, '#d1d1c1');
grad.addColorStop(1, '#ffffff');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 256, 128);
ctx.fillStyle = 'rgba(60, 65, 50, 0.7)';
for(let i = 0; i < 8; i++) {
ctx.beginPath();
ctx.ellipse(40 + i * 25, 64, 8, 15, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
for(let i = 0; i < 150; i++) {
ctx.beginPath();
ctx.arc(Math.random() * 256, Math.random() * 60, 0.5 + Math.random(), 0, Math.PI * 2);
ctx.fill();
}
return new THREE.CanvasTexture(canvas);
}
function createRainbowTroutTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 128;
const ctx = canvas.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 128);
grad.addColorStop(0, '#4a5d4a');
grad.addColorStop(0.4, '#d1d1c1');
grad.addColorStop(1, '#ffffff');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 256, 128);
const redStripeGrad = ctx.createLinearGradient(0, 60, 0, 75);
redStripeGrad.addColorStop(0, 'rgba(180, 50, 50, 0)');
redStripeGrad.addColorStop(0.5, 'rgba(200, 60, 60, 0.6)');
redStripeGrad.addColorStop(1, 'rgba(180, 50, 50, 0)');
ctx.fillStyle = redStripeGrad;
ctx.fillRect(0, 60, 256, 15);
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
for(let i = 0; i < 300; i++) {
const rx = Math.random() * 256, ry = Math.random() * 128;
if (ry > 100 && Math.random() > 0.3) continue;
ctx.beginPath();
ctx.arc(rx, ry, 0.5 + Math.random() * 0.8, 0, Math.PI * 2);
ctx.fill();
}
return new THREE.CanvasTexture(canvas);
}
function createNigoiTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 128;
const ctx = canvas.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 128);
grad.addColorStop(0, '#222222');
grad.addColorStop(0.3, '#666666');
grad.addColorStop(0.6, '#bbbbbb');
grad.addColorStop(1, '#dddddd');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 256, 128);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
ctx.lineWidth = 1;
for(let x = 0; x < 256; x += 8) {
for(let y = 0; y < 128; y += 6) {
ctx.beginPath();
ctx.arc(x + (y%12===0?0:4), y, 4, 0, Math.PI);
ctx.stroke();
}
}
return new THREE.CanvasTexture(canvas);
}
function createCrabTexture() {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#e09f7d';
ctx.fillRect(0, 0, 128, 128);
ctx.fillStyle = 'rgba(165, 42, 42, 0.4)';
for(let i=0; i<200; i++) {
ctx.beginPath();
ctx.arc(Math.random()*128, Math.random()*128, 1+Math.random()*2, 0, Math.PI*2);
ctx.fill();
}
return new THREE.CanvasTexture(canvas);
}
function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0xf0f4f8);
scene.fog = new THREE.Fog(0xf0f4f8, 300, 1800);
camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 4000);
camera.position.set(0, 100, cameraDistance);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // モバイルの負荷軽減
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
dirLight.position.set(50, 200, 100);
dirLight.castShadow = true;
scene.add(dirLight);
const tankLight = new THREE.PointLight(0x4fc3f7, 1.2, 1000);
tankLight.position.set(0, 100, 0);
scene.add(tankLight);
const sandGeo = new THREE.PlaneGeometry(3000, 3000);
const sandMat = new THREE.MeshPhongMaterial({ color: 0xdfd4b0 });
const sand = new THREE.Mesh(sandGeo, sandMat);
sand.rotation.x = -Math.PI / 2;
sand.position.y = -TANK_SIZE.h / 2;
sand.receiveShadow = true;
scene.add(sand);
createRocks();
createGlassTank();
createPlants();
createRockTunnel({x: -120, z: 0}, Math.PI / 4);
createRockTunnel({x: 120, z: -50}, -Math.PI / 3);
const yamameTex = createYamameTexture();
const rainbowTex = createRainbowTroutTexture();
const nigoiTex = createNigoiTexture();
for(let i=0; i < FISH_COUNT; i++) {
createFish((i % 2 === 0) ? yamameTex : rainbowTex, 1.0);
}
for(let i=0; i < NIGOI_COUNT; i++) {
createFish(nigoiTex, 2.5);
}
const crabTex = createCrabTexture();
for(let i=0; i < CRAB_COUNT; i++) {
createCrab(crabTex);
}
for(let i=0; i < BUBBLE_COUNT; i++) {
createBubble();
}
// イベントリスナー
window.addEventListener('resize', onWindowResize, false);
// マウス
document.addEventListener('mousedown', () => isMouseDown = true);
document.addEventListener('mouseup', () => isMouseDown = false);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('wheel', onMouseWheel, { passive: false });
// タッチ (スマホ・タブレット対応)
document.addEventListener('touchstart', onTouchStart, { passive: false });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}
function createRockTunnel(pos, rotation) {
const tunnelGroup = new THREE.Group();
const rockMat = new THREE.MeshPhongMaterial({ color: 0x607d8b, flatShading: true });
const segments = 8;
const radius = 25;
for (let i = 0; i < segments; i++) {
const angle = (i / (segments - 1)) * Math.PI;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
for (let zOffset = -20; zOffset <= 20; zOffset += 15) {
const size = 12 + Math.random() * 8;
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(size, 0), rockMat);
rock.position.set(x, y - 5, zOffset);
rock.rotation.set(Math.random(), Math.random(), Math.random());
rock.receiveShadow = true; rock.castShadow = true;
tunnelGroup.add(rock);
}
}
tunnelGroup.position.set(pos.x, -TANK_SIZE.h / 2 + 5, pos.z);
tunnelGroup.rotation.y = rotation;
scene.add(tunnelGroup);
}
function createPlants() {
const plantMat = new THREE.MeshPhongMaterial({
color: 0x2e7d32, side: THREE.DoubleSide, flatShading: true
});
for (let i = 0; i < PLANT_COUNT; i++) {
const plantGroup = new THREE.Group();
const bladeCount = 3 + Math.floor(Math.random() * 4);
for (let j = 0; j < bladeCount; j++) {
const height = 12 + Math.random() * 25;
const width = 1 + Math.random() * 2;
const geo = new THREE.PlaneGeometry(width, height, 1, 4);
const posAttr = geo.attributes.position;
for (let k = 0; k < posAttr.count; k++) {
const y = posAttr.getY(k);
const normalizedY = (y + height/2) / height;
posAttr.setX(k, posAttr.getX(k) + Math.pow(normalizedY, 2) * 2);
}
const blade = new THREE.Mesh(geo, plantMat);
blade.position.y = height / 2;
blade.rotation.y = Math.random() * Math.PI;
blade.position.set((Math.random()-0.5)*5, height/2, (Math.random()-0.5)*5);
plantGroup.add(blade);
}
const px = (Math.random() - 0.5) * TANK_SIZE.w * 3.8;
const pz = (Math.random() - 0.5) * TANK_SIZE.d * 3.8;
plantGroup.position.set(px, -TANK_SIZE.h / 2, pz);
scene.add(plantGroup);
plants.push({ mesh: plantGroup, phase: Math.random() * Math.PI * 2 });
}
}
function createGlassTank() {
const width = TANK_SIZE.w * 4.6;
const height = TANK_SIZE.h * 3.2;
const depth = TANK_SIZE.d * 4.6;
const glassGeo = new THREE.BoxGeometry(width, height, depth);
const glassMat = new THREE.MeshStandardMaterial({
color: 0xccf2ff, transparent: true, opacity: 0.18, metalness: 0.1, roughness: 0.1, side: THREE.DoubleSide
});
const glass = new THREE.Mesh(glassGeo, glassMat);
glass.position.y = height / 2 - TANK_SIZE.h / 2 - 5;
scene.add(glass);
const wireframe = new THREE.LineSegments(new THREE.EdgesGeometry(glassGeo), new THREE.LineBasicMaterial({ color: 0xbbbbbb }));
wireframe.position.copy(glass.position);
scene.add(wireframe);
const columnGeo = new THREE.BoxGeometry(5, height, 5);
const columnMat = new THREE.MeshStandardMaterial({ color: 0x666666 });
const corners = [{x: 0.5, z: 0.5}, {x: -0.5, z: 0.5}, {x: 0.5, z: -0.5}, {x: -0.5, z: -0.5}];
corners.forEach(c => {
const column = new THREE.Mesh(columnGeo, columnMat);
column.position.set(c.x * width, glass.position.y, c.z * depth);
scene.add(column);
});
}
function createRocks() {
for(let i=0; i<35; i++) {
const size = 4 + Math.random() * 20;
const rock = new THREE.Mesh(new THREE.DodecahedronGeometry(size, 0), new THREE.MeshPhongMaterial({ color: 0x78909c, flatShading: true }));
rock.position.set((Math.random()-0.5)*TANK_SIZE.w*4.5, -TANK_SIZE.h/2 + size*0.5, (Math.random()-0.5)*TANK_SIZE.d*4.2);
rock.rotation.set(Math.random(), Math.random(), Math.random());
rock.receiveShadow = true; rock.castShadow = true;
scene.add(rock);
}
}
function createFish(texture, baseScale) {
const group = new THREE.Group();
const body = new THREE.Mesh(
new THREE.SphereGeometry(1, 32, 16),
new THREE.MeshPhongMaterial({ map: texture, shininess: 80 })
);
const isNigoi = baseScale > 2.0;
body.scale.set(4 * baseScale, isNigoi ? 1.1 * baseScale : 1.4 * baseScale, 0.9 * baseScale);
body.rotation.y = Math.PI / 2;
group.add(body);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x111111 });
const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.15 * baseScale, 8, 8), eyeMat);
eyeL.position.set(0.5 * baseScale, 0.1 * baseScale, -3.4 * baseScale); group.add(eyeL);
const eyeR = eyeL.clone(); eyeR.position.x = -0.5 * baseScale; group.add(eyeR);
const finMat = new THREE.MeshPhongMaterial({ color: 0x666666, side: THREE.DoubleSide, transparent: true, opacity: 0.7 });
const tail = new THREE.Mesh(new THREE.PlaneGeometry(2.5 * baseScale, 2.8 * baseScale), finMat);
tail.position.z = 4.2 * baseScale; tail.rotation.y = Math.PI / 2; group.add(tail);
const dorsalFin = new THREE.Mesh(new THREE.PlaneGeometry(1.5 * baseScale, 2.2 * baseScale), finMat);
dorsalFin.position.set(0, 1.3 * baseScale, 0.3 * baseScale); dorsalFin.rotation.y = Math.PI / 2; group.add(dorsalFin);
const pectoralL = new THREE.Mesh(new THREE.PlaneGeometry(1.2 * baseScale, 0.8 * baseScale), finMat);
pectoralL.position.set(0.8 * baseScale, -0.4 * baseScale, -2.0 * baseScale); pectoralL.rotation.set(0.3, 0.8, -0.5); group.add(pectoralL);
const pectoralR = pectoralL.clone(); pectoralR.position.x = -0.8 * baseScale; pectoralR.rotation.y = -0.8; group.add(pectoralR);
const pelvicL = new THREE.Mesh(new THREE.PlaneGeometry(0.8 * baseScale, 0.6 * baseScale), finMat);
pelvicL.position.set(0.4 * baseScale, -1.2 * baseScale, 0); pelvicL.rotation.set(0.5, 0.2, 0); group.add(pelvicL);
const pelvicR = pelvicL.clone(); pelvicR.position.x = -0.4 * baseScale; group.add(pelvicR);
const analFin = new THREE.Mesh(new THREE.PlaneGeometry(1.2 * baseScale, 1.0 * baseScale), finMat);
analFin.position.set(0, -1.2 * baseScale, 2.2 * baseScale); analFin.rotation.y = Math.PI / 2; group.add(analFin);
const posY = isNigoi ? (-TANK_SIZE.h/2 + 15 + Math.random()*30) : (Math.random()-0.5)*TANK_SIZE.h*1.3;
group.position.set((Math.random()-0.5)*TANK_SIZE.w*2.5, posY, (Math.random()-0.5)*TANK_SIZE.d*2.5);
const speed = (0.08 + Math.random() * 0.25) * (1.5 / baseScale);
fishes.push({
mesh: group, tail: tail, pectoralL: pectoralL, pectoralR: pectoralR,
speed: speed, angle: Math.random()*Math.PI*2, phase: Math.random()*Math.PI*2, isNigoi
});
scene.add(group);
}
function createCrab(texture) {
const group = new THREE.Group();
const body = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 12), new THREE.MeshPhongMaterial({ map: texture }));
body.scale.set(1.5, 0.8, 1.2); group.add(body);
const stalkMat = new THREE.MeshPhongMaterial({ color: 0xdba28c });
for(let i=0; i<2; i++) {
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 1), stalkMat);
stalk.position.set(i===0?0.3:-0.3, 0.6, -0.6); stalk.rotation.x = -0.4; group.add(stalk);
const eyeball = new THREE.Mesh(new THREE.SphereGeometry(0.15, 8, 8), new THREE.MeshBasicMaterial({ color: 0x000000 }));
eyeball.position.set(i===0?0.3:-0.3, 1.1, -0.8); group.add(eyeball);
}
const legMat = new THREE.MeshPhongMaterial({ color: 0xdba28c });
for(let i=0; i<4; i++) {
for(let side=0; side<2; side++) {
const leg = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.15, 0.15), legMat);
leg.position.set(side===0?1.2:-1.2, -0.3, -0.6 + i*0.4);
leg.rotation.z = side===0? -0.5 : 0.5; group.add(leg);
}
}
const clawMat = new THREE.MeshPhongMaterial({ color: 0xff6347 });
const leftClaw = new THREE.Mesh(new THREE.SphereGeometry(0.5, 8, 8), clawMat);
leftClaw.scale.set(1.5, 1, 1); leftClaw.position.set(1.2, 0, -1.2); group.add(leftClaw);
const rightClaw = leftClaw.clone(); rightClaw.scale.set(0.8, 0.6, 0.6); rightClaw.position.set(-1.0, 0, -1.2); group.add(rightClaw);
group.position.set((Math.random()-0.5)*TANK_SIZE.w*2.5, -TANK_SIZE.h/2 + 0.5, (Math.random()-0.5)*TANK_SIZE.d*2.5);
group.scale.set(1.8, 1.8, 1.8);
crabs.push({ mesh: group, speed: 0.05 + Math.random() * 0.15, direction: Math.random() > 0.5 ? 1 : -1, wait: 0 });
scene.add(group);
}
function createBubble() {
const bubble = new THREE.Mesh(new THREE.SphereGeometry(0.1+Math.random()*0.6, 8, 8), new THREE.MeshPhongMaterial({ color: 0xffffff, transparent: true, opacity: 0.3 }));
resetBubble(bubble); scene.add(bubble); bubbles.push(bubble);
}
function resetBubble(bubble) {
bubble.position.set((Math.random()-0.5)*400, -TANK_SIZE.h/2, (Math.random()-0.5)*350);
bubble.userData.speed = 0.06 + Math.random() * 0.3;
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// マウスイベント
function onMouseMove(event) {
if (isMouseDown) {
targetRotationY += (event.clientX - lastMouseX) * 0.01;
targetRotationX += (event.clientY - lastMouseY) * 0.01;
targetRotationX = Math.max(-0.5, Math.min(1.2, targetRotationX));
}
lastMouseX = event.clientX; lastMouseY = event.clientY;
}
function onMouseWheel(event) {
event.preventDefault();
cameraDistance += event.deltaY * 0.5;
cameraDistance = Math.max(150, Math.min(1200, cameraDistance));
}
// タッチイベント (スマホ・タブレット対応)
function onTouchStart(event) {
if (event.touches.length === 1) {
lastTouchX = event.touches[0].clientX;
lastTouchY = event.touches[0].clientY;
} else if (event.touches.length === 2) {
lastPinchDistance = getTouchDistance(event.touches[0], event.touches[1]);
}
}
function onTouchMove(event) {
event.preventDefault(); // デフォルトのスクロール等を防止
if (event.touches.length === 1) {
// 1本指: 視点回転
const touch = event.touches[0];
const dx = touch.clientX - lastTouchX;
const dy = touch.clientY - lastTouchY;
targetRotationY += dx * 0.01;
targetRotationX += dy * 0.01;
targetRotationX = Math.max(-0.5, Math.min(1.2, targetRotationX));
lastTouchX = touch.clientX;
lastTouchY = touch.clientY;
} else if (event.touches.length === 2) {
// 2本指: ピンチズーム
const distance = getTouchDistance(event.touches[0], event.touches[1]);
const delta = lastPinchDistance - distance;
cameraDistance += delta * 2.0;
cameraDistance = Math.max(150, Math.min(1200, cameraDistance));
lastPinchDistance = distance;
}
}
function onTouchEnd() {
// 特にリセットの必要なし
}
function getTouchDistance(t1, t2) {
const dx = t1.clientX - t2.clientX;
const dy = t1.clientY - t2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function animate() {
requestAnimationFrame(animate);
const time = Date.now() * 0.001;
const targetX = Math.sin(targetRotationY) * cameraDistance;
const targetZ = Math.cos(targetRotationY) * cameraDistance;
const targetY = targetRotationX * (cameraDistance * 0.3) + 50;
camera.position.x += (targetX - camera.position.x) * 0.05;
camera.position.y += (targetY - camera.position.y) * 0.05;
camera.position.z += (targetZ - camera.position.z) * 0.05;
camera.lookAt(0, 0, 0);
fishes.forEach(f => {
f.mesh.position.x += Math.cos(f.angle) * f.speed;
f.mesh.position.z += Math.sin(f.angle) * f.speed;
f.mesh.position.y += Math.sin(time + f.phase) * (f.isNigoi ? 0.02 : 0.05);
f.mesh.rotation.y = -f.angle + Math.PI;
f.tail.rotation.y = Math.PI / 2 + Math.sin(time * (f.isNigoi ? 10 : 15) + f.phase) * 0.7;
f.pectoralL.rotation.z = -0.5 + Math.sin(time * 5 + f.phase) * 0.2;
f.pectoralR.rotation.z = 0.5 - Math.sin(time * 5 + f.phase) * 0.2;
if (Math.abs(f.mesh.position.x) > TANK_SIZE.w * 2.3) f.angle = Math.PI - f.angle;
if (Math.abs(f.mesh.position.z) > TANK_SIZE.d * 2.3) f.angle = -f.angle;
});
plants.forEach(p => {
p.mesh.children.forEach((blade, index) => {
blade.rotation.z = Math.sin(time * 1.5 + p.phase + index) * 0.1;
});
});
crabs.forEach(c => {
if (c.wait > 0) { c.wait--; } else {
c.mesh.position.x += c.speed * c.direction;
c.mesh.position.y = -TANK_SIZE.h/2 + 0.5 + Math.abs(Math.sin(time * 12)) * 0.2;
if (Math.abs(c.mesh.position.x) > TANK_SIZE.w * 2.2 || Math.random() < 0.005) {
c.wait = 40 + Math.random() * 120; if (Math.abs(c.mesh.position.x) > TANK_SIZE.w * 2.2) c.direction *= -1;
}
}
});
bubbles.forEach(b => {
b.position.y += b.userData.speed; if(b.position.y > TANK_SIZE.h * 1.5) resetBubble(b);
});
renderer.render(scene, camera);
}
</script>
</body>
</html>
癒やされます。眺めて下さい。