Interactive 3D aquariumOne-line
Gemini 3.8 Flash
by GoogleReasoningClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 1 min 21 s
- Cost
- $0.053
- Output tokens
- 14,037
- Spent thinking
- 1,453 tokens
- Speed
- 179.5 tok/s
- Console errors
- 0
- First token after
- 2.3 s
- First visible text after
- 16.0 s
- Prompt tokens
- 448
- Code size
- 35.5 KB
- Finish reason
- stop
- Served by
- Model id
google/gemini-3.8-flash- Reasoning sent
model default- Max output
- 65,536 tokens
- Generated
- Nov 12, 2026
What it was asked
The prompt
“a 3D aquarium with Three.js where the fish swim away when I move the mouse near them (Load Three.js only through this import map: <script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}</script>)”
System prompt, sent with every prompt
You are generating a self-contained web demo. It will be rendered automatically in a sandboxed iframe and compared side by side with the output of other AI models given the exact same task. Output rules: - Reply with exactly ONE fenced code block containing the complete file: ```html (or ```svg when the task asks for an SVG). No text before or after the code block. - Everything must be inline: CSS in <style>, JavaScript in <script>. No external files, images, fonts or network requests, except the CDN URLs explicitly allowed by the task. - Do not use localStorage, sessionStorage, cookies, IndexedDB, alert(), confirm(), prompt() or window.open(): they are blocked in the sandbox and will throw. - The page must look right in a 1280×800 viewport and adapt gracefully down to 375px wide. No horizontal scrollbar. - Something meaningful must be visible on first paint: a screenshot is taken automatically about 3 seconds after load, without any interaction. - Everything that makes the demo interesting must be visible within the first seconds: nothing hidden behind a high score, a later level or a long play session. - Games must start in a self-playing demo mode (a simple AI plays) behind a "Click or press a key to start" message, so the game can be judged in action without playing. - The iframe only receives keyboard events after the user clicks it: if the demo uses the keyboard, display a clear "Click or press a key to start" message. - The code must run with zero errors in the console of the latest Chrome.
Code run-1.html · 1032 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>3D Aquarium with Three.js</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
body, html {
width: 100%;
height: 100%;
overflow: hidden;
background: #02121e;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
#webgl-container {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
#ui-overlay {
position: absolute;
top: 20px;
left: 20px;
color: rgba(255, 255, 255, 0.88);
text-shadow: 0 2px 10px rgba(0, 20, 40, 0.8);
pointer-events: none;
z-index: 10;
}
h1 {
font-size: 1.35rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 4px;
color: #7ce8ff;
}
p {
font-size: 0.85rem;
color: #d1f4ff;
opacity: 0.85;
margin-bottom: 2px;
}
.badge {
display: inline-block;
margin-top: 6px;
padding: 3px 8px;
border-radius: 20px;
background: rgba(8, 70, 110, 0.45);
border: 1px solid rgba(124, 232, 255, 0.35);
font-size: 0.72rem;
color: #9feeff;
}
#crosshair {
position: absolute;
width: 32px;
height: 32px;
border: 2px dashed rgba(124, 232, 255, 0.5);
border-radius: 50%;
pointer-events: none;
transform: translate(-50%, -50%);
transition: width 0.15s, height 0.15s, border-color 0.15s;
z-index: 5;
display: none;
}
#crosshair.scaring {
width: 48px;
height: 48px;
border-color: rgba(255, 100, 120, 0.85);
box-shadow: 0 0 15px rgba(255, 80, 100, 0.4);
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="webgl-container"></div>
<div id="crosshair"></div>
<div id="ui-overlay">
<h1>Living Coral Aquarium</h1>
<p>Move mouse near fish to scatter them away</p>
<p>Click water to drop fish food • Drag to rotate view</p>
<div class="badge" id="fish-count">48 Tropical Fish</div>
</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- Scene Setup ---
const container = document.getElementById('webgl-container');
const crosshairEl = document.getElementById('crosshair');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x021727);
scene.fog = new THREE.FogExp2(0x032238, 0.024);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 1.5, 23);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
container.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.maxDistance = 34;
controls.minDistance = 7;
controls.maxPolarAngle = Math.PI * 0.52;
controls.minPolarAngle = Math.PI * 0.15;
controls.target.set(0, 0, 0);
// --- Tank Bounds ---
const TANK = {
width: 32,
height: 16,
depth: 18,
minY: -7.5,
maxY: 7.5,
minX: -15.5,
maxX: 15.5,
minZ: -8.5,
maxZ: 8.5
};
// --- Lighting ---
const ambientLight = new THREE.AmbientLight(0x0b3b5c, 1.8);
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xbbf0ff, 2.8);
sunLight.position.set(2, 22, 4);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 1024;
sunLight.shadow.mapSize.height = 1024;
sunLight.shadow.camera.near = 5;
sunLight.shadow.camera.far = 35;
sunLight.shadow.camera.left = -18;
sunLight.shadow.camera.right = 18;
sunLight.shadow.camera.top = 12;
sunLight.shadow.camera.bottom = -12;
sunLight.shadow.bias = -0.001;
scene.add(sunLight);
const fillLight = new THREE.DirectionalLight(0x18779c, 1.2);
fillLight.position.set(-8, -4, -6);
scene.add(fillLight);
// Warm accent light for corals
const coralLight = new THREE.PointLight(0xff7088, 2.5, 20);
coralLight.position.set(-6, -4, 2);
scene.add(coralLight);
const cyanPointLight = new THREE.PointLight(0x40e0d0, 2.0, 22);
cyanPointLight.position.set(7, -3, -2);
scene.add(cyanPointLight);
// --- Procedural Caustics Shader for Seabed ---
const causticsVertexShader = `
varying vec2 vUv;
varying vec3 vWorldPos;
void main() {
vUv = uv;
vec4 wp = modelMatrix * vec4(position, 1.0);
vWorldPos = wp.xyz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const causticsFragmentShader = `
uniform float uTime;
uniform vec3 uColorBase;
varying vec2 vUv;
varying vec3 vWorldPos;
// Caustic sine pattern
float caustic(vec2 p) {
vec2 p1 = p * 1.6;
float c1 = sin(p1.x * 1.8 + uTime * 2.2 + cos(p1.y * 1.5 - uTime * 1.2));
float c2 = cos(p1.y * 2.1 - uTime * 1.8 + sin(p1.x * 2.0 + uTime * 1.5));
float c3 = sin((p1.x + p1.y) * 2.4 + uTime * 2.0);
return pow(clamp((c1 + c2 + c3) * 0.33 + 0.5, 0.0, 1.0), 3.5);
}
void main() {
vec2 p = vWorldPos.xz * 0.22;
float c = caustic(p);
c += caustic(p * 1.8 + vec2(1.7, 3.2)) * 0.6;
vec3 sand = uColorBase;
// Subtle depth noise on sand
float speckle = sin(vWorldPos.x * 25.0) * sin(vWorldPos.z * 25.0) * 0.04;
sand += vec3(speckle);
vec3 lightColor = vec3(0.5, 0.95, 1.0) * 1.35;
vec3 finalColor = sand + lightColor * c * 0.85;
gl_FragColor = vec4(finalColor, 1.0);
}
`;
const sandMaterial = new THREE.ShaderMaterial({
vertexShader: causticsVertexShader,
fragmentShader: causticsFragmentShader,
uniforms: {
uTime: { value: 0 },
uColorBase: { value: new THREE.Color(0xd2b57e).multiplyScalar(0.48) }
}
});
// Sand floor with wavy sand dunes
const floorGeo = new THREE.PlaneGeometry(38, 24, 64, 48);
floorGeo.rotateX(-Math.PI / 2);
const posAttr = floorGeo.attributes.position;
for (let i = 0; i < posAttr.count; i++) {
const x = posAttr.getX(i);
const z = posAttr.getZ(i);
const dune = Math.sin(x * 0.35) * Math.cos(z * 0.4) * 0.45 +
Math.sin(x * 0.8 + z * 0.5) * 0.2;
posAttr.setY(i, dune);
}
floorGeo.computeVertexNormals();
const floorMesh = new THREE.Mesh(floorGeo, sandMaterial);
floorMesh.position.y = TANK.minY;
floorMesh.receiveShadow = true;
scene.add(floorMesh);
// Water Surface (ceiling)
const surfaceGeo = new THREE.PlaneGeometry(36, 22, 32, 32);
surfaceGeo.rotateX(Math.PI / 2);
const surfaceMat = new THREE.MeshStandardMaterial({
color: 0x22c2dd,
roughness: 0.1,
metalness: 0.1,
transparent: true,
opacity: 0.35,
side: THREE.DoubleSide
});
const surfaceMesh = new THREE.Mesh(surfaceGeo, surfaceMat);
surfaceMesh.position.y = TANK.maxY + 0.1;
scene.add(surfaceMesh);
// Tank Frame / Glass Boundary Outline
const boxGeo = new THREE.BoxGeometry(TANK.width, TANK.height, TANK.depth);
const edges = new THREE.EdgesGeometry(boxGeo);
const frameLine = new THREE.LineSegments(
edges,
new THREE.LineBasicMaterial({ color: 0x429bbb, transparent: true, opacity: 0.35 })
);
scene.add(frameLine);
// Back glass gradient plane
const backGlassMat = new THREE.MeshBasicMaterial({
color: 0x011b2e,
side: THREE.BackSide
});
const backGlass = new THREE.Mesh(new THREE.PlaneGeometry(TANK.width + 4, TANK.height + 4), backGlassMat);
backGlass.position.set(0, 0, -TANK.depth / 2 - 0.5);
scene.add(backGlass);
// --- Underwater Decorations: Corals & Seaweed ---
const seaweedGroups = [];
const seaweedMaterial = new THREE.MeshStandardMaterial({
color: 0x1f8f48,
roughness: 0.4,
side: THREE.DoubleSide
});
const seaweedMatAlt = new THREE.MeshStandardMaterial({
color: 0x126c36,
roughness: 0.5,
side: THREE.DoubleSide
});
function createSeaweedCluster(cx, cz, count, heightScale) {
const group = new THREE.Group();
group.position.set(cx, TANK.minY, cz);
for (let i = 0; i < count; i++) {
const segCount = 7;
const bladeHeight = (3.5 + Math.random() * 2.5) * heightScale;
const segHeight = bladeHeight / segCount;
const width = 0.25 + Math.random() * 0.15;
const bladeGroup = new THREE.Group();
bladeGroup.position.set((Math.random() - 0.5) * 1.5, 0, (Math.random() - 0.5) * 1.5);
bladeGroup.rotation.y = Math.random() * Math.PI * 2;
let parent = bladeGroup;
const segments = [];
for (let s = 0; s < segCount; s++) {
const segGeo = new THREE.PlaneGeometry(width * (1 - (s / segCount) * 0.6), segHeight);
segGeo.translate(0, segHeight / 2, 0);
const mat = Math.random() > 0.4 ? seaweedMaterial : seaweedMatAlt;
const segMesh = new THREE.Mesh(segGeo, mat);
parent.add(segMesh);
const nextJoint = new THREE.Group();
nextJoint.position.set(0, segHeight, 0);
parent.add(nextJoint);
parent = nextJoint;
segments.push(segMesh);
}
bladeGroup.userData = { segments, seed: Math.random() * 10 };
group.add(bladeGroup);
seaweedGroups.push(bladeGroup);
}
scene.add(group);
}
// Plant seaweed gardens along sides & back
createSeaweedCluster(-12, -4, 9, 1.3);
createSeaweedCluster(-10, 4, 7, 1.1);
createSeaweedCluster(11, -3, 10, 1.4);
createSeaweedCluster(13, 3, 8, 1.2);
createSeaweedCluster(-4, -6, 8, 1.1);
createSeaweedCluster(4, -6, 9, 1.2);
// Rocks & Coral Formations
const rockMat = new THREE.MeshStandardMaterial({ color: 0x4a4d52, roughness: 0.9 });
const coralColors = [0xff4f6e, 0xffaa33, 0xa144eb, 0x22d2c2, 0xef238a];
function createRockFormation(x, z, scale) {
const rockGroup = new THREE.Group();
rockGroup.position.set(x, TANK.minY, z);
const mainRock = new THREE.Mesh(
new THREE.DodecahedronGeometry(2 * scale, 1),
rockMat
);
mainRock.scale.set(1.4, 0.7, 1.1);
mainRock.castShadow = true;
mainRock.receiveShadow = true;
rockGroup.add(mainRock);
// Add coral branches / cups on top of rock
const coralCount = 4 + Math.floor(Math.random() * 4);
for (let i = 0; i < coralCount; i++) {
const color = coralColors[Math.floor(Math.random() * coralColors.length)];
const coralMat = new THREE.MeshStandardMaterial({
color: color,
roughness: 0.35,
metalness: 0.05
});
if (Math.random() > 0.45) {
// Brain / Mushroom Coral
const cGeo = new THREE.SphereGeometry(0.7 * scale, 12, 10);
cGeo.scale(1.2, 0.55, 1.2);
const coral = new THREE.Mesh(cGeo, coralMat);
coral.position.set((Math.random() - 0.5) * 1.8 * scale, 0.9 * scale + Math.random() * 0.4, (Math.random() - 0.5) * 1.8 * scale);
rockGroup.add(coral);
} else {
// Tube / Branch coral
const branchGroup = new THREE.Group();
branchGroup.position.set((Math.random() - 0.5) * 1.5 * scale, 0.7 * scale, (Math.random() - 0.5) * 1.5 * scale);
const tubes = 3 + Math.floor(Math.random() * 3);
for (let t = 0; t < tubes; t++) {
const h = (0.8 + Math.random() * 1.0) * scale;
const tube = new THREE.Mesh(new THREE.CylinderGeometry(0.12 * scale, 0.18 * scale, h, 8), coralMat);
tube.position.set((Math.random() - 0.5) * 0.4 * scale, h / 2, (Math.random() - 0.5) * 0.4 * scale);
tube.rotation.x = (Math.random() - 0.5) * 0.3;
tube.rotation.z = (Math.random() - 0.5) * 0.3;
branchGroup.add(tube);
}
rockGroup.add(branchGroup);
}
}
scene.add(rockGroup);
}
createRockFormation(-6, -2, 1.4);
createRockFormation(7, -1, 1.6);
createRockFormation(1, -4.5, 1.2);
createRockFormation(-11, 2, 1.1);
// --- Bubbles Particle System ---
const bubbleCount = 180;
const bubbleGeo = new THREE.SphereGeometry(0.08, 8, 8);
const bubbleMat = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
transmission: 0.9,
opacity: 0.8,
transparent: true,
roughness: 0.05,
ior: 1.1
});
const bubbleInstanced = new THREE.InstancedMesh(bubbleGeo, bubbleMat, bubbleCount);
const bubbleData = [];
const dummy = new THREE.Object3D();
for (let i = 0; i < bubbleCount; i++) {
const sourceX = (Math.random() - 0.5) * (TANK.width * 0.85);
const sourceZ = (Math.random() - 0.5) * (TANK.depth * 0.85);
const y = TANK.minY + Math.random() * TANK.height;
const scale = 0.5 + Math.random() * 1.6;
const speed = 1.2 + Math.random() * 1.8;
const wobbleSpeed = 1.5 + Math.random() * 2.5;
const wobbleAmp = 0.08 + Math.random() * 0.12;
bubbleData.push({ x: sourceX, y, z: sourceZ, scale, speed, wobbleSpeed, wobbleAmp, seed: Math.random() * 100 });
dummy.position.set(sourceX, y, sourceZ);
dummy.scale.setScalar(scale);
dummy.updateMatrix();
bubbleInstanced.setMatrixAt(i, dummy.matrix);
}
scene.add(bubbleInstanced);
// --- Procedural Fish Creation ---
// Varieties: Clownfish, Blue Tang, Yellow Tang, Coral Angel
const FISH_TYPES = [
{
name: 'Clownfish',
bodyColor: 0xff6611,
stripeColor: 0xffffff,
bellyColor: 0x221100,
aspect: { length: 1.2, height: 0.65, thickness: 0.3 },
speed: 2.2,
schooling: true,
tailLength: 0.45
},
{
name: 'BlueTang',
bodyColor: 0x1160ee,
stripeColor: 0x112244,
bellyColor: 0xffdd22, // bright yellow tail
aspect: { length: 1.4, height: 0.8, thickness: 0.28 },
speed: 2.6,
schooling: true,
tailLength: 0.5
},
{
name: 'YellowTang',
bodyColor: 0xffdd00,
stripeColor: 0xfff066,
bellyColor: 0xffaa00,
aspect: { length: 1.2, height: 0.95, thickness: 0.22 },
speed: 2.0,
schooling: false,
tailLength: 0.4
},
{
name: 'AngelFish',
bodyColor: 0x111111,
stripeColor: 0xeeeeee,
bellyColor: 0x33ddff,
aspect: { length: 1.3, height: 1.2, thickness: 0.2 },
speed: 1.8,
schooling: false,
tailLength: 0.55
}
];
function createFishMesh(type) {
const root = new THREE.Group();
const asp = type.aspect;
// Fish Body Geometry (procedurally scaled & tapered)
const bodyGeo = new THREE.SphereGeometry(1, 16, 12);
bodyGeo.scale(asp.length * 0.5, asp.height * 0.5, asp.thickness * 0.5);
// Vertex color or canvas texture for stripes
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 64;
const ctx = canvas.getContext('2d');
const hex1 = '#' + new THREE.Color(type.bodyColor).getHexString();
const hex2 = '#' + new THREE.Color(type.stripeColor).getHexString();
const hex3 = '#' + new THREE.Color(type.bellyColor).getHexString();
ctx.fillStyle = hex1;
ctx.fillRect(0, 0, 128, 64);
if (type.name === 'Clownfish') {
ctx.fillStyle = hex2;
ctx.fillRect(36, 0, 14, 64);
ctx.fillRect(72, 0, 12, 64);
ctx.fillStyle = '#000000';
ctx.fillRect(34, 0, 2, 64);
ctx.fillRect(50, 0, 2, 64);
ctx.fillRect(70, 0, 2, 64);
ctx.fillRect(84, 0, 2, 64);
} else if (type.name === 'BlueTang') {
ctx.fillStyle = hex2;
ctx.beginPath();
ctx.arc(40, 20, 30, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = hex3;
ctx.fillRect(100, 10, 28, 44);
} else if (type.name === 'AngelFish') {
ctx.fillStyle = hex2;
ctx.fillRect(25, 0, 10, 64);
ctx.fillRect(55, 0, 12, 64);
ctx.fillRect(85, 0, 10, 64);
ctx.fillStyle = hex3;
ctx.fillRect(0, 28, 128, 8);
} else {
// Yellow tang subtle gradient
const grad = ctx.createLinearGradient(0, 0, 128, 0);
grad.addColorStop(0, hex1);
grad.addColorStop(0.8, hex2);
grad.addColorStop(1, hex3);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 128, 64);
}
const bodyTex = new THREE.CanvasTexture(canvas);
bodyTex.wrapS = THREE.ClampToEdgeWrapping;
bodyTex.wrapT = THREE.ClampToEdgeWrapping;
const bodyMat = new THREE.MeshStandardMaterial({
map: bodyTex,
roughness: 0.3,
metalness: 0.1
});
const bodyMesh = new THREE.Mesh(bodyGeo, bodyMat);
bodyMesh.castShadow = true;
root.add(bodyMesh);
// Eyes
const eyeGeo = new THREE.SphereGeometry(0.08, 8, 8);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x050505 });
const eyeWhiteMat = new THREE.MeshBasicMaterial({ color: 0xffffff });
const eyeL = new THREE.Mesh(eyeGeo, eyeMat);
const eyeWhiteL = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), eyeWhiteMat);
eyeWhiteL.position.set(asp.length * 0.3, asp.height * 0.1, asp.thickness * 0.25);
eyeL.position.set(asp.length * 0.32, asp.height * 0.1, asp.thickness * 0.28);
root.add(eyeWhiteL);
root.add(eyeL);
const eyeWhiteR = eyeWhiteL.clone();
eyeWhiteR.position.z = -eyeWhiteL.position.z;
const eyeR = eyeL.clone();
eyeR.position.z = -eyeL.position.z;
root.add(eyeWhiteR);
root.add(eyeR);
// Fins Material
const finMat = new THREE.MeshStandardMaterial({
color: type.bodyColor,
roughness: 0.4,
transparent: true,
opacity: 0.88,
side: THREE.DoubleSide
});
// Tail Joint & Fin (animated)
const tailJoint = new THREE.Group();
tailJoint.position.set(-asp.length * 0.42, 0, 0);
const tailGeo = new THREE.ConeGeometry(asp.height * 0.45, type.tailLength * 1.2, 3);
tailGeo.rotateZ(Math.PI / 2);
tailGeo.scale(1, 0.15, 1);
const tailMesh = new THREE.Mesh(tailGeo, finMat);
tailMesh.position.set(-type.tailLength * 0.5, 0, 0);
tailJoint.add(tailMesh);
root.add(tailJoint);
// Dorsal Fin
const dorsalGeo = new THREE.BufferGeometry();
const dH = asp.height * (type.name === 'AngelFish' ? 0.9 : 0.45);
const dVerts = new Float32Array([
-asp.length * 0.25, asp.height * 0.35, 0,
asp.length * 0.15, asp.height * 0.32, 0,
-asp.length * 0.05, asp.height * 0.35 + dH, 0
]);
dorsalGeo.setAttribute('position', new THREE.BufferAttribute(dVerts, 3));
dorsalGeo.computeVertexNormals();
const dorsalMesh = new THREE.Mesh(dorsalGeo, finMat);
root.add(dorsalMesh);
// Ventral / Pectoral fin accents
const pectoralGeo = new THREE.ConeGeometry(0.18, 0.45, 3);
pectoralGeo.rotateX(Math.PI / 2);
const pecL = new THREE.Mesh(pectoralGeo, finMat);
pecL.position.set(asp.length * 0.1, -asp.height * 0.2, asp.thickness * 0.22);
pecL.rotation.y = 0.4;
pecL.rotation.z = -0.3;
root.add(pecL);
const pecR = pecL.clone();
pecR.position.z = -pecL.position.z;
pecR.rotation.y = -0.4;
root.add(pecR);
return { mesh: root, tailJoint, pecL, pecR, type };
}
// --- Fish Agent Class (Flocking & Mouse Scattering) ---
class FishAgent {
constructor(type, x, y, z) {
this.type = type;
const fishObj = createFishMesh(type);
this.mesh = fishObj.mesh;
this.tailJoint = fishObj.tailJoint;
this.pecL = fishObj.pecL;
this.pecR = fishObj.pecR;
this.position = new THREE.Vector3(x, y, z);
this.mesh.position.copy(this.position);
// Random heading
const angle = Math.random() * Math.PI * 2;
this.velocity = new THREE.Vector3(Math.cos(angle), (Math.random() - 0.5) * 0.4, Math.sin(angle)).normalize().multiplyScalar(type.speed * (0.8 + Math.random() * 0.4));
this.acceleration = new THREE.Vector3();
this.baseSpeed = this.velocity.length();
this.maxSpeed = this.baseSpeed * 2.8; // Burst speed when panicked
this.maxForce = 0.12;
this.fear = 0; // 0 (calm) to 1 (scared)
this.scareTimer = 0;
this.tailPhase = Math.random() * Math.PI * 2;
this.scale = 0.8 + Math.random() * 0.45;
this.mesh.scale.setScalar(this.scale);
scene.add(this.mesh);
}
applyForce(force) {
this.acceleration.add(force);
}
scareAway(repelOrigin, strength = 1.0) {
const diff = new THREE.Vector3().subVectors(this.position, repelOrigin);
let dist = diff.length();
if (dist < 0.001) {
diff.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5);
dist = 1.0;
}
// Strong repulsion impulse
diff.normalize();
const factor = Math.min(1.0, strength / Math.max(dist, 0.4));
diff.multiplyScalar(factor * 2.2);
this.applyForce(diff);
// Instant panic state
this.fear = 1.0;
this.scareTimer = 1.4; // duration of heightened speed
}
update(delta, allFish, foodPellets, mouseRay) {
// --- 1. Mouse Ray Hover Detection (3D Raycast Distance) ---
if (mouseRay) {
// Closest point on mouse ray to fish position
const rayOrigin = mouseRay.origin;
const rayDir = mouseRay.direction;
const toFish = new THREE.Vector3().subVectors(this.position, rayOrigin);
const projection = toFish.dot(rayDir);
if (projection > 0) {
const closestPoint = new THREE.Vector3().copy(rayOrigin).addScaledVector(rayDir, projection);
const distToRay = this.position.distanceTo(closestPoint);
const scareRadius = 4.8;
if (distToRay < scareRadius) {
const repelIntensity = (1.0 - distToRay / scareRadius) * 2.0;
this.scareAway(closestPoint, repelIntensity);
}
}
}
// --- 2. Flocking Behaviors (Separation & Cohesion) ---
let sepSum = new THREE.Vector3();
let sepCount = 0;
let alignSum = new THREE.Vector3();
let alignCount = 0;
let cohSum = new THREE.Vector3();
let cohCount = 0;
const neighborDist = 4.0;
const desiredSeparation = 1.4;
for (let i = 0; i < allFish.length; i++) {
const other = allFish[i];
if (other === this) continue;
const d = this.position.distanceTo(other.position);
if (d < desiredSeparation) {
const diff = new THREE.Vector3().subVectors(this.position, other.position).normalize().divideScalar(Math.max(d, 0.2));
sepSum.add(diff);
sepCount++;
}
if (this.type.schooling && other.type.schooling && d < neighborDist) {
alignSum.add(other.velocity);
alignCount++;
cohSum.add(other.position);
cohCount++;
}
}
if (sepCount > 0) {
sepSum.divideScalar(sepCount).normalize().multiplyScalar(this.maxSpeed).sub(this.velocity).clampLength(0, this.maxForce * 1.5);
this.applyForce(sepSum);
}
if (alignCount > 0 && this.fear < 0.3) {
alignSum.divideScalar(alignCount).normalize().multiplyScalar(this.baseSpeed).sub(this.velocity).clampLength(0, this.maxForce * 0.8);
this.applyForce(alignSum);
}
if (cohCount > 0 && this.fear < 0.3) {
cohSum.divideScalar(cohCount);
const seekVec = new THREE.Vector3().subVectors(cohSum, this.position).normalize().multiplyScalar(this.baseSpeed).sub(this.velocity).clampLength(0, this.maxForce * 0.7);
this.applyForce(seekVec);
}
// --- 3. Attraction to Food Pellets (if not scared) ---
if (this.fear < 0.2 && foodPellets.length > 0) {
let nearestFood = null;
let minFoodDist = 9.0;
for (let f = 0; f < foodPellets.length; f++) {
const fp = foodPellets[f];
const dist = this.position.distanceTo(fp.position);
if (dist < minFoodDist) {
minFoodDist = dist;
nearestFood = fp;
}
}
if (nearestFood) {
const foodForce = new THREE.Vector3().subVectors(nearestFood.position, this.position).normalize().multiplyScalar(this.baseSpeed * 1.2).sub(this.velocity).clampLength(0, this.maxForce * 1.2);
this.applyForce(foodForce);
}
}
// --- 4. Soft Boundary Walls (Keep fish swimming inside tank) ---
const pad = 2.0;
const turnForce = 0.16;
if (this.position.x < TANK.minX + pad) this.velocity.x += turnForce;
if (this.position.x > TANK.maxX - pad) this.velocity.x -= turnForce;
if (this.position.y < TANK.minY + pad) this.velocity.y += turnForce * 1.2;
if (this.position.y > TANK.maxY - pad) this.velocity.y -= turnForce * 1.2;
if (this.position.z < TANK.minZ + pad) this.velocity.z += turnForce;
if (this.position.z > TANK.maxZ - pad) this.velocity.z -= turnForce;
// Hard clamp fallback
this.position.x = THREE.MathUtils.clamp(this.position.x, TANK.minX, TANK.maxX);
this.position.y = THREE.MathUtils.clamp(this.position.y, TANK.minY + 0.3, TANK.maxY - 0.2);
this.position.z = THREE.MathUtils.clamp(this.position.z, TANK.minZ, TANK.maxZ);
// --- 5. Physics Integration ---
this.velocity.add(this.acceleration);
// Speed management: high speed if scared, glide if calm
if (this.scareTimer > 0) {
this.scareTimer -= delta;
this.fear = Math.max(0, this.scareTimer / 1.4);
} else {
this.fear = THREE.MathUtils.damp(this.fear, 0, 2.5, delta);
}
const targetMax = THREE.MathUtils.lerp(this.baseSpeed, this.maxSpeed, this.fear);
if (this.velocity.length() > targetMax) {
this.velocity.setLength(targetMax);
} else if (this.velocity.length() < this.baseSpeed * 0.7) {
this.velocity.setLength(this.baseSpeed * 0.7);
}
this.position.addScaledVector(this.velocity, delta);
this.mesh.position.copy(this.position);
this.acceleration.set(0, 0, 0);
// --- 6. Smooth Mesh Orientation towards Velocity ---
if (this.velocity.lengthSq() > 0.001) {
const forward = this.velocity.clone().normalize();
const targetRotation = new THREE.Quaternion();
// Fish model faces +X initially; calculate rotation to align with velocity
const m = new THREE.Matrix4();
const up = new THREE.Vector3(0, 1, 0);
const right = new THREE.Vector3().crossVectors(up, forward).normalize();
const correctedUp = new THREE.Vector3().crossVectors(forward, right).normalize();
m.makeBasis(forward, correctedUp, right);
targetRotation.setFromRotationMatrix(m);
// Add banking/tilt when turning
const turnAngle = forward.dot(new THREE.Vector3(0, 1, 0));
this.mesh.quaternion.slerp(targetRotation, Math.min(1.0, delta * 9.0));
}
// --- 7. Tail Wiggle & Fin Flutter Animation ---
const speedRatio = this.velocity.length() / this.baseSpeed;
const wagFreq = (4.0 + speedRatio * 8.0) * (1.0 + this.fear * 1.5);
this.tailPhase += wagFreq * delta;
const wagAngle = Math.sin(this.tailPhase) * (0.35 + this.fear * 0.4);
this.tailJoint.rotation.y = wagAngle;
// Pectoral fins flutter
const finFlutter = Math.cos(this.tailPhase * 1.5) * 0.25;
this.pecL.rotation.z = -0.3 + finFlutter;
this.pecR.rotation.z = -0.3 - finFlutter;
}
}
// --- Spawn School of Tropical Fish ---
const fishList = [];
const TOTAL_FISH = 48;
for (let i = 0; i < TOTAL_FISH; i++) {
const type = FISH_TYPES[i % FISH_TYPES.length];
const x = (Math.random() - 0.5) * (TANK.width * 0.75);
const y = TANK.minY + 2.0 + Math.random() * (TANK.height - 4.0);
const z = (Math.random() - 0.5) * (TANK.depth * 0.75);
const fish = new FishAgent(type, x, y, z);
fishList.push(fish);
}
// --- Fish Food Dropping System ---
const foodPellets = [];
const foodGeo = new THREE.DodecahedronGeometry(0.12);
const foodMat = new THREE.MeshStandardMaterial({ color: 0xcc6622, roughness: 0.8 });
function dropFood(worldPos) {
for (let i = 0; i < 4; i++) {
const pellet = new THREE.Mesh(foodGeo, foodMat);
pellet.position.copy(worldPos);
pellet.position.x += (Math.random() - 0.5) * 0.8;
pellet.position.z += (Math.random() - 0.5) * 0.8;
pellet.position.y = Math.min(pellet.position.y, TANK.maxY - 0.5);
pellet.userData = {
vy: - (0.6 + Math.random() * 0.8),
vx: (Math.random() - 0.5) * 0.3,
vz: (Math.random() - 0.5) * 0.3,
rotX: Math.random() * 2,
rotZ: Math.random() * 2,
life: 14.0
};
scene.add(pellet);
foodPellets.push(pellet);
}
}
// --- Mouse & Touch Interaction Raycasting ---
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(-999, -999);
let activeMouseRay = null;
let isMouseInView = false;
let isScaringAny = false;
window.addEventListener('mousemove', (e) => {
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
isMouseInView = true;
crosshairEl.style.display = 'block';
crosshairEl.style.left = `${e.clientX}px`;
crosshairEl.style.top = `${e.clientY}px`;
});
window.addEventListener('mouseleave', () => {
isMouseInView = false;
activeMouseRay = null;
crosshairEl.style.display = 'none';
});
// Click to drop food
window.addEventListener('pointerdown', (e) => {
// If clicking with left button without dragging
if (e.button !== 0) return;
const clickCoords = new THREE.Vector2(
(e.clientX / window.innerWidth) * 2 - 1,
-(e.clientY / window.innerHeight) * 2 + 1
);
const clickRay = new THREE.Raycaster();
clickRay.setFromCamera(clickCoords, camera);
// Plane in center of tank z=0 to drop food
const dropPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const hitPoint = new THREE.Vector3();
if (clickRay.ray.intersectPlane(dropPlane, hitPoint)) {
hitPoint.x = THREE.MathUtils.clamp(hitPoint.x, TANK.minX + 1, TANK.maxX - 1);
hitPoint.y = Math.min(hitPoint.y, TANK.maxY - 1);
hitPoint.z = THREE.MathUtils.clamp(hitPoint.z, TANK.minZ + 1, TANK.maxZ - 1);
dropFood(hitPoint);
}
});
// Touch support for mobile devices
window.addEventListener('touchmove', (e) => {
if (e.touches.length > 0) {
const t = e.touches[0];
mouse.x = (t.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(t.clientY / window.innerHeight) * 2 + 1;
isMouseInView = true;
crosshairEl.style.display = 'block';
crosshairEl.style.left = `${t.clientX}px`;
crosshairEl.style.top = `${t.clientY}px`;
}
}, { passive: true });
// --- Animation Loop ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
const elapsedTime = clock.getElapsedTime();
controls.update();
// Update Caustics Shader Time
sandMaterial.uniforms.uTime.value = elapsedTime;
// Update Mouse 3D Ray for fish repelling
if (isMouseInView) {
raycaster.setFromCamera(mouse, camera);
activeMouseRay = raycaster.ray;
} else {
activeMouseRay = null;
}
// Check if any fish is currently scattering (for UI cursor effect)
isScaringAny = false;
// Update Fish
for (let i = 0; i < fishList.length; i++) {
const fish = fishList[i];
fish.update(delta, fishList, foodPellets, activeMouseRay);
if (fish.fear > 0.5) {
isScaringAny = true;
}
}
if (isScaringAny) {
crosshairEl.classList.add('scaring');
} else {
crosshairEl.classList.remove('scaring');
}
// Update Food Pellets
for (let f = foodPellets.length - 1; f >= 0; f--) {
const pellet = foodPellets[f];
pellet.position.y += pellet.userData.vy * delta;
pellet.position.x += pellet.userData.vx * delta;
pellet.position.z += pellet.userData.vz * delta;
pellet.rotation.x += pellet.userData.rotX * delta;
pellet.rotation.z += pellet.userData.rotZ * delta;
pellet.userData.life -= delta;
// Fish eating food
let eaten = false;
for (let i = 0; i < fishList.length; i++) {
if (fishList[i].position.distanceTo(pellet.position) < 0.65) {
eaten = true;
break;
}
}
// Hits floor or eaten or expired
if (pellet.position.y <= TANK.minY + 0.1 || eaten || pellet.userData.life <= 0) {
scene.remove(pellet);
foodPellets.splice(f, 1);
}
}
// Update Seaweed Swaying
for (let i = 0; i < seaweedGroups.length; i++) {
const blade = seaweedGroups[i];
const segs = blade.userData.segments;
const seed = blade.userData.seed;
const sway = Math.sin(elapsedTime * 1.8 + seed) * 0.08 + Math.cos(elapsedTime * 2.8 + seed) * 0.04;
for (let s = 0; s < segs.length; s++) {
const seg = segs[s];
seg.rotation.z = sway * (s + 1) * 0.45;
}
}
// Update Rising Bubbles
for (let i = 0; i < bubbleCount; i++) {
const b = bubbleData[i];
b.y += b.speed * delta;
const wobbleX = Math.sin(elapsedTime * b.wobbleSpeed + b.seed) * b.wobbleAmp;
const wobbleZ = Math.cos(elapsedTime * b.wobbleSpeed + b.seed) * b.wobbleAmp;
if (b.y > TANK.maxY) {
b.y = TANK.minY;
b.x = (Math.random() - 0.5) * (TANK.width * 0.85);
b.z = (Math.random() - 0.5) * (TANK.depth * 0.85);
}
dummy.position.set(b.x + wobbleX, b.y, b.z + wobbleZ);
dummy.scale.setScalar(b.scale);
dummy.updateMatrix();
bubbleInstanced.setMatrixAt(i, dummy.matrix);
}
bubbleInstanced.instanceMatrix.needsUpdate = true;
// Gentle caustics light movement
sunLight.position.x = 2 + Math.sin(elapsedTime * 0.5) * 1.5;
sunLight.position.z = 4 + Math.cos(elapsedTime * 0.4) * 1.5;
renderer.render(scene, camera);
}
animate();
// --- Window Resize ---
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>Reasoning trace (1,453 tokens)
**Creating Dynamic Aquarium** I'm currently implementing a 3D aquarium using Three.js. My focus is on making the fish react dynamically to user interaction, specifically by swimming away when the mouse cursor approaches. The provided import map is integrated to manage the Three.js library and its addons effectively. **Developing Interactive Fish** I'm refining the fish AI to exhibit more natural wandering behavior and integrating their tail wagging animation. My current focus is on ensuring they scatter convincingly when the cursor gets too close, initiating their panicked retreat. I'm also tweaking the interaction logic to feel more responsive and emergent. **Enhancing Visuals and Interaction** I'm now focusing on adding atmospheric elements like rising bubbles and realistic underwater caustics. My interaction logic is being refined with raycasting from mouse movement to create a dynamic repulsor sphere, offering subtle visual feedback on the cursor itself. Ensuring high performance and zero console errors remains a priority. **Enhancing Fish Behavior** I'm focusing on refining the fish AI with a more robust boids system. This includes implementing separation, alignment, cohesion, and boundary avoidance. The core of my current effort is enhancing the mouse interaction, making fish actively repel from a 3D point with adjustable force and triggering a frantic tail wag animation when scared. I'm also planning to integrate a click-to-feed mechanic with drifting food flakes. **Refining Fish Movement** I'm perfecting the fish's reaction to the mouse. Instead of just hitting a plane, I'm calculating the closest distance from each fish to the mouse's ray in 3D space. If a fish is within a certain range, it will be pushed away along a vector that's perpendicular to the ray and slightly along its direction, creating a more magical and responsive avoidance.