Interactive 3D aquariumOne-line
Gemini 4 Argon
by Googlereasoning defaultClean
Running live in a sandboxed frame. Click inside it before using the keyboard.
How it went
- Total time
- 3 min 24 s
- Cost
- $0.132
- Output tokens
- 13,093
- Spent thinking
- 0 tokens
- Speed
- 65.2 tok/s
- Console errors
- 0
- First token after
- 2.8 s
- First visible text after
- 2.8 s
- Prompt tokens
- 486
- Code size
- 35.0 KB
- Finish reason
- stop
- Served by
- Model id
google/gemini-4-argon- Reasoning sent
model default- Max output
- 943,718 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 · 1034 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Interactive 3D Aquarium</title>
<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>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: radial-gradient(circle at 50% 35%, #162638 0%, #070d14 75%, #020408 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #e2f1ff;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#hud {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
background: rgba(8, 22, 36, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(110, 210, 255, 0.25);
border-radius: 16px;
padding: 18px 22px;
max-width: 340px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5), inset 0 1px 1px rgba(255, 255, 255, 0.15);
pointer-events: auto;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
gap: 10px;
}
h1 {
font-size: 1.15rem;
font-weight: 700;
letter-spacing: 0.4px;
background: linear-gradient(90deg, #7ef0ff, #42b0ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.badge {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 99px;
background: rgba(0, 229, 255, 0.16);
border: 1px solid rgba(0, 229, 255, 0.4);
color: #84f2ff;
letter-spacing: 0.6px;
}
p.desc {
font-size: 0.82rem;
line-height: 1.45;
color: #a8cbe4;
margin-bottom: 14px;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
button {
flex: 1 1 auto;
background: rgba(20, 60, 95, 0.65);
color: #d8f3ff;
border: 1px solid rgba(100, 200, 255, 0.3);
border-radius: 9px;
padding: 8px 11px;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
transition: all 0.18s ease;
}
button:hover {
background: rgba(35, 105, 160, 0.8);
border-color: rgba(130, 230, 255, 0.7);
transform: translateY(-1px);
}
button.active {
background: linear-gradient(135deg, #00b4d8, #0077b6);
border-color: #90e0ef;
color: #ffffff;
box-shadow: 0 0 12px rgba(0, 180, 216, 0.45);
}
.stats {
display: flex;
justify-content: space-between;
font-size: 0.76rem;
color: #82b2d0;
border-top: 1px solid rgba(110, 210, 255, 0.15);
padding-top: 10px;
}
.stats span strong {
color: #dbf6ff;
}
#hint-pill {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: rgba(8, 24, 40, 0.75);
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 229, 255, 0.35);
padding: 9px 20px;
border-radius: 999px;
font-size: 0.85rem;
color: #ccf4ff;
pointer-events: none;
box-shadow: 0 6px 20px rgba(0,0,0,0.45);
white-space: nowrap;
}
@media (max-width: 550px) {
#hud {
top: 12px;
left: 12px;
right: 12px;
max-width: none;
padding: 14px 16px;
}
#hint-pill {
font-size: 0.75rem;
bottom: 14px;
padding: 7px 14px;
}
}
</style>
</head>
<body>
<div id="hud">
<div class="title-row">
<h1>Reef Sanctuary 3D</h1>
<span class="badge" id="mode-badge">Auto Probe</span>
</div>
<p class="desc">Move your cursor into the tank to startle the fish school. Click inside the glass to drop food pellets, or right-drag to orbit.</p>
<div class="controls">
<button id="btn-drop-food">Drop Food</button>
<button id="btn-scare-pulse">Shockwave</button>
<button id="btn-toggle-probe" class="active">Auto-Scare: ON</button>
</div>
<div class="stats">
<span>Fish: <strong id="fish-count">60</strong></span>
<span>Startled: <strong id="scared-count">0</strong></span>
<span>Pellets: <strong id="food-count">0</strong></span>
</div>
</div>
<div id="hint-pill">Move mouse across the tank to scatter the fish!</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- Tank Dimensions ---
const TANK = {
width: 30,
height: 17,
depth: 18,
wallThickness: 0.35,
sandHeight: 1.5
};
const BOUNDS = {
x: TANK.width / 2 - 1.4,
yMin: -TANK.height / 2 + TANK.sandHeight + 0.9,
yMax: TANK.height / 2 - 1.3,
z: TANK.depth / 2 - 1.4
};
// --- Scene, Camera, Renderer ---
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x092438, 0.014);
const camera = new THREE.PerspectiveCamera(44, window.innerWidth / window.innerHeight, 0.1, 150);
camera.position.set(0, 2.5, 34);
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;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.maxDistance = 55;
controls.minDistance = 14;
controls.maxPolarAngle = Math.PI * 0.56;
controls.minPolarAngle = Math.PI * 0.15;
// Allow left-click to interact with fish/food and right-click or two-finger to orbit
controls.mouseButtons = {
LEFT: null,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.ROTATE
};
// --- Procedural Caustic & Sand Textures ---
function createCausticTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#073b54';
ctx.fillRect(0, 0, 256, 256);
for (let i = 0; i < 45; i++) {
const x = Math.random() * 256;
const y = Math.random() * 256;
const r = 18 + Math.random() * 42;
const grad = ctx.createRadialGradient(x, y, 2, x, y, r);
grad.addColorStop(0, 'rgba(130, 245, 255, 0.35)');
grad.addColorStop(0.6, 'rgba(40, 195, 235, 0.12)');
grad.addColorStop(1, 'rgba(0, 80, 120, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(3, 2);
return tex;
}
const causticTex = createCausticTexture();
// --- Lighting ---
const ambientLight = new THREE.AmbientLight(0x4b93b8, 1.3);
scene.add(ambientLight);
const hemiLight = new THREE.HemisphereLight(0x88f3ff, 0x102838, 1.4);
scene.add(hemiLight);
const sunLight = new THREE.DirectionalLight(0xbbf7ff, 2.8);
sunLight.position.set(6, 22, 8);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.left = -18;
sunLight.shadow.camera.right = 18;
sunLight.shadow.camera.top = 12;
sunLight.shadow.camera.bottom = -12;
sunLight.shadow.camera.near = 5;
sunLight.shadow.camera.far = 38;
sunLight.shadow.bias = -0.0008;
scene.add(sunLight);
const reefGlow1 = new THREE.PointLight(0xff4da6, 6, 15);
reefGlow1.position.set(-7, -4, -1);
scene.add(reefGlow1);
const reefGlow2 = new THREE.PointLight(0x00f5d4, 7, 16);
reefGlow2.position.set(7, -3, 2);
scene.add(reefGlow2);
// --- Aquarium Tank Construction ---
const tankGroup = new THREE.Group();
scene.add(tankGroup);
// Inner water volume tint
const waterGeo = new THREE.BoxGeometry(TANK.width - 0.15, TANK.height - 0.6, TANK.depth - 0.15);
const waterMat = new THREE.MeshPhysicalMaterial({
color: 0x0e6b8c,
transparent: true,
opacity: 0.16,
roughness: 0.1,
metalness: 0.0,
side: THREE.BackSide,
depthWrite: false
});
const waterMesh = new THREE.Mesh(waterGeo, waterMat);
waterMesh.position.y = -0.2;
tankGroup.add(waterMesh);
// Animated top water surface
const surfaceGeo = new THREE.PlaneGeometry(TANK.width - 0.2, TANK.depth - 0.2, 48, 32);
surfaceGeo.rotateX(-Math.PI / 2);
const surfaceMat = new THREE.MeshPhysicalMaterial({
color: 0x6ce8ff,
emissive: 0x0d5c75,
emissiveMap: causticTex,
emissiveIntensity: 0.85,
transparent: true,
opacity: 0.55,
roughness: 0.15,
metalness: 0.1,
side: THREE.DoubleSide
});
const surfaceMesh = new THREE.Mesh(surfaceGeo, surfaceMat);
surfaceMesh.position.y = TANK.height / 2 - 0.5;
tankGroup.add(surfaceMesh);
// Outer glass walls
const glassGeo = new THREE.BoxGeometry(TANK.width, TANK.height, TANK.depth);
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0xc8f4ff,
transparent: true,
opacity: 0.13,
roughness: 0.05,
reflectivity: 0.9,
clearcoat: 1.0,
depthWrite: false
});
const glassMesh = new THREE.Mesh(glassGeo, glassMat);
tankGroup.add(glassMesh);
// Crisp metallic frame & corners
const edgesGeo = new THREE.EdgesGeometry(glassGeo);
const edgesMat = new THREE.LineBasicMaterial({ color: 0x62d2ff, transparent: true, opacity: 0.45 });
tankGroup.add(new THREE.LineSegments(edgesGeo, edgesMat));
const baseFrameGeo = new THREE.BoxGeometry(TANK.width + 0.6, 0.9, TANK.depth + 0.6);
const frameMat = new THREE.MeshStandardMaterial({ color: 0x101822, roughness: 0.4, metalness: 0.7 });
const baseFrame = new THREE.Mesh(baseFrameGeo, frameMat);
baseFrame.position.y = -TANK.height / 2 - 0.35;
tankGroup.add(baseFrame);
const topRimGeo = new THREE.BoxGeometry(TANK.width + 0.4, 0.45, TANK.depth + 0.4);
const topRim = new THREE.Mesh(topRimGeo, frameMat);
topRim.position.y = TANK.height / 2 + 0.15;
tankGroup.add(topRim);
// --- Sculpted Sandy Seabed ---
const sandGeo = new THREE.BoxGeometry(TANK.width - 0.25, TANK.sandHeight, TANK.depth - 0.25, 60, 1, 36);
const posAttr = sandGeo.attributes.position;
for (let i = 0; i < posAttr.count; i++) {
const vy = posAttr.getY(i);
if (vy > 0) {
const vx = posAttr.getX(i);
const vz = posAttr.getZ(i);
const wave = Math.sin(vx * 0.35) * Math.cos(vz * 0.35) * 0.38 +
Math.sin(vx * 0.9 + vz * 0.7) * 0.12;
posAttr.setY(i, vy + wave);
}
}
sandGeo.computeVertexNormals();
const sandMat = new THREE.MeshStandardMaterial({
color: 0xd9c28c,
roughness: 0.88,
metalness: 0.05,
emissive: 0x164c64,
emissiveMap: causticTex,
emissiveIntensity: 0.45
});
const sandMesh = new THREE.Mesh(sandGeo, sandMat);
sandMesh.position.y = -TANK.height / 2 + TANK.sandHeight / 2;
sandMesh.receiveShadow = true;
tankGroup.add(sandMesh);
// --- Reef Rocks, Corals & Swaying Seaweed ---
const decorGroup = new THREE.Group();
const sandTopY = -TANK.height / 2 + TANK.sandHeight;
tankGroup.add(decorGroup);
// Rocks
const rockMat = new THREE.MeshStandardMaterial({ color: 0x3b4d5e, roughness: 0.85, flatShading: true });
const rockClusters = [
{ x: -8.5, z: -2.5, s: 2.2 },
{ x: -6.2, z: -3.2, s: 1.5 },
{ x: -9.8, z: -0.5, s: 1.6 },
{ x: 7.2, z: 1.5, s: 2.4 },
{ x: 9.2, z: 0.2, s: 1.7 },
{ x: 5.5, z: 2.8, s: 1.3 },
{ x: 0.2, z: -4.2, s: 2.7 },
{ x: -2.0, z: -4.8, s: 1.8 },
{ x: 2.5, z: -3.6, s: 1.6 }
];
rockClusters.forEach(rc => {
const geo = new THREE.DodecahedronGeometry(rc.s, 1);
const p = geo.attributes.position;
for (let i = 0; i < p.count; i++) {
p.setXYZ(
i,
p.getX(i) * (0.85 + Math.sin(i * 2.1) * 0.2),
p.getY(i) * (0.7 + Math.cos(i * 1.7) * 0.2),
p.getZ(i) * (0.85 + Math.sin(i * 3.3) * 0.2)
);
}
geo.computeVertexNormals();
const mesh = new THREE.Mesh(geo, rockMat);
mesh.position.set(rc.x, sandTopY + rc.s * 0.35, rc.z);
mesh.rotation.set(Math.random(), Math.random() * Math.PI, 0);
mesh.castShadow = true;
mesh.receiveShadow = true;
decorGroup.add(mesh);
});
// Vibrant Corals
const coralColors = [0xff5d8f, 0xff9e00, 0xb5179e, 0x00f5d4, 0xff4d6d];
rockClusters.forEach((rc, idx) => {
const color = coralColors[idx % coralColors.length];
const cMat = new THREE.MeshStandardMaterial({
color,
roughness: 0.4,
emissive: color,
emissiveIntensity: 0.22
});
const branches = 5 + (idx % 4);
for (let b = 0; b < branches; b++) {
const h = 1.1 + Math.random() * 1.4;
const cGeo = new THREE.CapsuleGeometry(0.14, h, 5, 8);
const cMesh = new THREE.Mesh(cGeo, cMat);
cMesh.position.set(
rc.x + (Math.random() - 0.5) * 1.6,
sandTopY + rc.s * 0.55 + h * 0.4,
rc.z + (Math.random() - 0.5) * 1.6
);
cMesh.rotation.z = (Math.random() - 0.5) * 0.65;
cMesh.rotation.x = (Math.random() - 0.5) * 0.65;
cMesh.castShadow = true;
decorGroup.add(cMesh);
}
});
// Swaying Kelp / Seaweed
const kelpUniforms = { uTime: { value: 0 } };
const kelpMat = new THREE.MeshStandardMaterial({
color: 0x1dd38f,
roughness: 0.5,
side: THREE.DoubleSide
});
kelpMat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = kelpUniforms.uTime;
shader.vertexShader = `
uniform float uTime;
attribute float aPhase;
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
`
#include <begin_vertex>
float heightFactor = clamp((position.y + 2.5) / 5.0, 0.0, 1.0);
transformed.x += sin(uTime * 2.2 + position.y * 1.1 + aPhase) * 0.45 * heightFactor;
transformed.z += cos(uTime * 1.7 + position.y * 0.9 + aPhase) * 0.25 * heightFactor;
`
);
};
for (let i = 0; i < 38; i++) {
const h = 3.0 + Math.random() * 4.2;
const kGeo = new THREE.PlaneGeometry(0.38, h, 2, 14);
const phases = new Float32Array(kGeo.attributes.position.count).fill(Math.random() * Math.PI * 2);
kGeo.setAttribute('aPhase', new THREE.BufferAttribute(phases, 1));
const kMesh = new THREE.Mesh(kGeo, kelpMat);
const side = i % 2 === 0 ? -1 : 1;
const kx = side * (5 + Math.random() * 8.5);
const kz = -6.5 + Math.random() * 12;
kMesh.position.set(kx, sandTopY + h / 2 - 0.2, kz);
kMesh.rotation.y = Math.random() * Math.PI;
kMesh.castShadow = true;
decorGroup.add(kMesh);
}
// --- Rising Air Bubbles ---
const BUBBLE_COUNT = 120;
const bubbleGeo = new THREE.SphereGeometry(0.09, 8, 8);
const bubbleMat = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
transmission: 0.9,
opacity: 0.65,
transparent: true,
roughness: 0.05,
ior: 1.33
});
const bubbleInst = new THREE.InstancedMesh(bubbleGeo, bubbleMat, BUBBLE_COUNT);
tankGroup.add(bubbleInst);
const bubbleData = [];
const emitters = [
new THREE.Vector3(-8.2, sandTopY + 0.4, -2.2),
new THREE.Vector3(7.4, sandTopY + 0.4, 1.6),
new THREE.Vector3(0.4, sandTopY + 0.4, -4.0)
];
for (let i = 0; i < BUBBLE_COUNT; i++) {
const em = emitters[i % emitters.length];
bubbleData.push({
emitter: em,
pos: new THREE.Vector3(
em.x + (Math.random() - 0.5) * 0.8,
sandTopY + Math.random() * (TANK.height - 2),
em.z + (Math.random() - 0.5) * 0.8
),
speed: 2.0 + Math.random() * 2.2,
scale: 0.4 + Math.random() * 1.1,
wobbleOffset: Math.random() * Math.PI * 2
});
}
// --- Procedural Fish Geometry & Shader ---
function createFishGeometry() {
const geo = new THREE.SphereGeometry(0.5, 24, 14);
const pos = geo.attributes.position;
for (let i = 0; i < pos.count; i++) {
let x = pos.getX(i);
let y = pos.getY(i);
let z = pos.getZ(i);
// Stretch along Z (head at +Z, tail at -Z)
z *= 2.4;
if (z < -0.25) {
// Pinch body into tail peduncle and fan out tail fin
const tailT = (-z - 0.25) / 0.95;
y *= 0.28 + Math.pow(tailT, 1.5) * 1.65;
x *= Math.max(0.06, 0.55 * (1.0 - tailT * 0.88));
// Forked tail notch
if (z < -0.95 && Math.abs(y) < 0.25) {
z += (0.25 - Math.abs(y)) * 0.55;
}
} else {
// Streamlined oval body
const bodyFactor = 1.0 - Math.pow(z / 1.25, 2) * 0.35;
x *= 0.48 * bodyFactor;
y *= 0.88 * bodyFactor;
// Dorsal fin ridge on top
if (y > 0.32 && z > -0.25 && z < 0.35) {
y += (0.35 - Math.abs(z)) * 0.7;
x *= 0.25;
}
}
pos.setXYZ(i, x, y, z);
}
geo.computeVertexNormals();
return geo;
}
const fishBaseGeo = createFishGeometry();
const eyeGeo = new THREE.SphereGeometry(0.055, 8, 8);
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x05070a });
const fishSpecies = [
{ primary: 0xff6b00, secondary: 0xffffff, scale: 0.95, speed: 3.6 }, // Clownfish Orange
{ primary: 0x00d4ff, secondary: 0xffbe0b, scale: 1.05, speed: 3.9 }, // Blue Tang
{ primary: 0xff2a85, secondary: 0xffd166, scale: 0.85, speed: 4.2 }, // Anthias Pink/Gold
{ primary: 0x19e68c, secondary: 0x00bbf9, scale: 0.90, speed: 3.8 }, // Damselfish Neon Green
{ primary: 0xffbe0b, secondary: 0xfb5607, scale: 1.12, speed: 3.4 } // Golden Butterflyfish
];
const FISH_COUNT = 60;
const fishes = [];
const fishGroup = new THREE.Group();
scene.add(fishGroup);
for (let i = 0; i < FISH_COUNT; i++) {
const spec = fishSpecies[i % fishSpecies.length];
const swimUniforms = {
uTime: { value: Math.random() * 100 },
uSpeed: { value: 1.0 },
uScared: { value: 0.0 },
uColorA: { value: new THREE.Color(spec.primary) },
uColorB: { value: new THREE.Color(spec.secondary) }
};
const mat = new THREE.MeshStandardMaterial({
roughness: 0.22,
metalness: 0.18
});
mat.onBeforeCompile = (shader) => {
shader.uniforms.uTime = swimUniforms.uTime;
shader.uniforms.uSpeed = swimUniforms.uSpeed;
shader.uniforms.uScared = swimUniforms.uScared;
shader.uniforms.uColorA = swimUniforms.uColorA;
shader.uniforms.uColorB = swimUniforms.uColorB;
shader.vertexShader = `
uniform float uTime;
uniform float uSpeed;
varying vec3 vLocalPos;
` + shader.vertexShader;
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
`
#include <begin_vertex>
vLocalPos = position;
float tailFactor = clamp((0.45 - position.z) / 1.65, 0.0, 1.0);
float wiggle = sin(uTime * 11.0 - position.z * 4.2) * 0.24 * tailFactor * tailFactor * uSpeed;
transformed.x += wiggle;
`
);
shader.fragmentShader = `
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform float uScared;
varying vec3 vLocalPos;
` + shader.fragmentShader;
shader.fragmentShader = shader.fragmentShader.replace(
'#include <color_fragment>',
`
#include <color_fragment>
float stripe = smoothstep(-0.15, 0.25, sin(vLocalPos.z * 8.5 + vLocalPos.y * 2.5));
vec3 baseCol = mix(uColorA, uColorB, stripe * 0.75);
// Subtle startled flash
diffuseColor.rgb = mix(baseCol, vec3(0.85, 0.98, 1.0), uScared * 0.35);
`
);
};
const root = new THREE.Group();
const bodyMesh = new THREE.Mesh(fishBaseGeo, mat);
bodyMesh.castShadow = true;
root.add(bodyMesh);
// Eyes
const leftEye = new THREE.Mesh(eyeGeo, eyeMat);
leftEye.position.set(0.16, 0.1, 0.68);
const rightEye = new THREE.Mesh(eyeGeo, eyeMat);
rightEye.position.set(-0.16, 0.1, 0.68);
root.add(leftEye, rightEye);
const scaleVariation = spec.scale * (0.8 + Math.random() * 0.4);
root.scale.setScalar(scaleVariation);
const pos = new THREE.Vector3(
(Math.random() - 0.5) * BOUNDS.x * 1.7,
BOUNDS.yMin + Math.random() * (BOUNDS.yMax - BOUNDS.yMin),
(Math.random() - 0.5) * BOUNDS.z * 1.6
);
root.position.copy(pos);
const angle = Math.random() * Math.PI * 2;
const vel = new THREE.Vector3(Math.sin(angle), (Math.random() - 0.5) * 0.3, Math.cos(angle))
.normalize()
.multiplyScalar(spec.speed);
fishGroup.add(root);
fishes.push({
mesh: root,
pos,
vel,
acc: new THREE.Vector3(),
baseSpeed: spec.speed,
maxForce: 14.0,
scaredTimer: 0,
uniforms: swimUniforms,
wanderSeed: Math.random() * 100
});
}
// --- Interactive Threat / Cursor Repeller Orb ---
const repellerGroup = new THREE.Group();
scene.add(repellerGroup);
const coreOrbGeo = new THREE.SphereGeometry(0.38, 24, 24);
const coreOrbMat = new THREE.MeshBasicMaterial({
color: 0x70f8ff,
transparent: true,
opacity: 0.9
});
const coreOrb = new THREE.Mesh(coreOrbGeo, coreOrbMat);
repellerGroup.add(coreOrb);
const auraGeo = new THREE.SphereGeometry(4.8, 32, 24);
const auraMat = new THREE.MeshBasicMaterial({
color: 0x00e5ff,
transparent: true,
opacity: 0.11,
wireframe: true
});
const auraMesh = new THREE.Mesh(auraGeo, auraMat);
repellerGroup.add(auraMesh);
const cursorLight = new THREE.PointLight(0x56f0ff, 10, 11);
repellerGroup.add(cursorLight);
// State for cursor & automatic demo probe
const repellerPos = new THREE.Vector3(0, 0, 0);
const targetRepellerPos = new THREE.Vector3(0, 0, 0);
const raycaster = new THREE.Raycaster();
const pointerNDC = new THREE.Vector2();
const interactionPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
let autoProbeActive = true;
let lastPointerMoveTime = -999;
let shockwaveBoost = 0;
// --- Food Pellets System ---
const foodPellets = [];
const pelletGeo = new THREE.SphereGeometry(0.14, 10, 10);
const pelletMat = new THREE.MeshStandardMaterial({
color: 0xffaa2b,
emissive: 0xff6600,
emissiveIntensity: 0.4,
roughness: 0.4
});
function spawnFoodCluster(centerX = (Math.random() - 0.5) * 14, centerZ = (Math.random() - 0.5) * 8) {
for (let i = 0; i < 6; i++) {
const mesh = new THREE.Mesh(pelletGeo, pelletMat);
mesh.position.set(
THREE.MathUtils.clamp(centerX + (Math.random() - 0.5) * 3.5, -BOUNDS.x, BOUNDS.x),
TANK.height / 2 - 0.7,
THREE.MathUtils.clamp(centerZ + (Math.random() - 0.5) * 3.5, -BOUNDS.z, BOUNDS.z)
);
scene.add(mesh);
foodPellets.push({
mesh,
velY: -(1.1 + Math.random() * 0.6),
driftPhase: Math.random() * Math.PI * 2
});
}
updateHUDStats();
}
// --- UI & Pointer Event Listeners ---
const badgeEl = document.getElementById('mode-badge');
const toggleBtn = document.getElementById('btn-toggle-probe');
const scaredCountEl = document.getElementById('scared-count');
const foodCountEl = document.getElementById('food-count');
const hintPillEl = document.getElementById('hint-pill');
function setProbeMode(isAuto) {
autoProbeActive = isAuto;
if (isAuto) {
badgeEl.textContent = 'Auto Probe';
toggleBtn.textContent = 'Auto-Scare: ON';
toggleBtn.classList.add('active');
} else {
badgeEl.textContent = 'Mouse Control';
toggleBtn.textContent = 'Auto-Scare: OFF';
toggleBtn.classList.remove('active');
}
}
function updatePointerTarget(clientX, clientY) {
pointerNDC.x = (clientX / window.innerWidth) * 2 - 1;
pointerNDC.y = -(clientY / window.innerHeight) * 2 + 1;
// Align plane normal with camera view direction for natural 3D cursor placement
const camDir = new THREE.Vector3();
camera.getWorldDirection(camDir);
interactionPlane.setFromNormalAndCoplanarPoint(camDir, new THREE.Vector3(0, 0, 0));
raycaster.setFromCamera(pointerNDC, camera);
const hitPoint = new THREE.Vector3();
if (raycaster.ray.intersectPlane(interactionPlane, hitPoint)) {
targetRepellerPos.set(
THREE.MathUtils.clamp(hitPoint.x, -BOUNDS.x, BOUNDS.x),
THREE.MathUtils.clamp(hitPoint.y, BOUNDS.yMin, BOUNDS.yMax),
THREE.MathUtils.clamp(hitPoint.z, -BOUNDS.z, BOUNDS.z)
);
}
}
window.addEventListener('pointermove', (e) => {
if (e.target.closest('#hud')) return;
lastPointerMoveTime = performance.now() * 0.001;
if (autoProbeActive) setProbeMode(false);
hintPillEl.textContent = 'Fish flee from your cursor! Click to drop food pellets.';
updatePointerTarget(e.clientX, e.clientY);
});
renderer.domElement.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
lastPointerMoveTime = performance.now() * 0.001;
if (autoProbeActive) setProbeMode(false);
updatePointerTarget(e.clientX, e.clientY);
// Spawn a few pellets above where the user clicked
spawnFoodCluster(targetRepellerPos.x, targetRepellerPos.z);
});
document.getElementById('btn-drop-food').addEventListener('click', () => {
spawnFoodCluster();
});
document.getElementById('btn-scare-pulse').addEventListener('click', () => {
shockwaveBoost = 1.0;
});
toggleBtn.addEventListener('click', () => {
setProbeMode(!autoProbeActive);
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
function updateHUDStats(scaredCount = 0) {
scaredCountEl.textContent = scaredCount;
foodCountEl.textContent = foodPellets.length;
}
// --- Boids Flocking & Repulsion Simulation ---
const SCARE_RADIUS = 6.2;
const NEIGHBOR_RADIUS = 3.8;
const SEPARATION_RADIUS = 1.45;
const tempVec = new THREE.Vector3();
const sepForce = new THREE.Vector3();
const aliForce = new THREE.Vector3();
const cohForce = new THREE.Vector3();
const dummyMatrix = new THREE.Matrix4();
const dummyPos = new THREE.Vector3();
const dummyScale = new THREE.Vector3();
const dummyQuat = new THREE.Quaternion();
const lookMatrix = new THREE.Matrix4();
const targetQuat = new THREE.Quaternion();
const upVec = new THREE.Vector3(0, 1, 0);
let lastTime = performance.now() * 0.001;
function animate() {
requestAnimationFrame(animate);
const now = performance.now() * 0.001;
const dt = Math.min(now - lastTime, 0.05);
lastTime = now;
// Resume auto-probe if mouse hasn't moved for 9 seconds
if (!autoProbeActive && (now - lastPointerMoveTime > 9.0)) {
setProbeMode(true);
}
// Update Repeller Position (Auto figure-8 patrol vs User Cursor)
if (autoProbeActive) {
targetRepellerPos.set(
Math.sin(now * 0.95) * 9.5,
Math.sin(now * 1.7) * 3.6,
Math.cos(now * 0.75) * 4.5
);
}
repellerPos.lerp(targetRepellerPos, Math.min(1.0, dt * 12.0));
repellerGroup.position.copy(repellerPos);
// Shockwave decay
if (shockwaveBoost > 0) {
shockwaveBoost = Math.max(0, shockwaveBoost - dt * 2.2);
}
const activeScareRadius = SCARE_RADIUS + shockwaveBoost * 7.5;
const pulseScale = (activeScareRadius / 4.8) * (1.0 + Math.sin(now * 6.0) * 0.05);
auraMesh.scale.setScalar(pulseScale);
auraMesh.rotation.y = now * 0.6;
auraMesh.rotation.z = now * 0.4;
// Animate caustics & kelp
causticTex.offset.x = now * 0.03;
causticTex.offset.y = now * 0.02;
kelpUniforms.uTime.value = now;
// Update Falling Food Pellets
for (let i = foodPellets.length - 1; i >= 0; i--) {
const p = foodPellets[i];
p.mesh.position.y += p.velY * dt;
p.mesh.position.x += Math.sin(now * 3.0 + p.driftPhase) * 0.4 * dt;
if (p.mesh.position.y <= sandTopY + 0.15) {
scene.remove(p.mesh);
foodPellets.splice(i, 1);
}
}
// Update Rising Bubbles
for (let i = 0; i < BUBBLE_COUNT; i++) {
const b = bubbleData[i];
b.pos.y += b.speed * dt;
b.pos.x += Math.sin(now * 4.0 + b.wobbleOffset) * 0.35 * dt;
if (b.pos.y > TANK.height / 2 - 0.6) {
b.pos.y = sandTopY + 0.3;
b.pos.x = b.emitter.x + (Math.random() - 0.5) * 0.7;
b.pos.z = b.emitter.z + (Math.random() - 0.5) * 0.7;
}
dummyPos.copy(b.pos);
dummyScale.setScalar(b.scale);
dummyMatrix.compose(dummyPos, dummyQuat, dummyScale);
bubbleInst.setMatrixAt(i, dummyMatrix);
}
bubbleInst.instanceMatrix.needsUpdate = true;
// Update Fish School (Boids + Mouse Evasion + Food Seeking)
let scaredFishCount = 0;
for (let i = 0; i < FISH_COUNT; i++) {
const f = fishes[i];
f.acc.set(0, 0, 0);
sepForce.set(0, 0, 0);
aliForce.set(0, 0, 0);
cohForce.set(0, 0, 0);
let neighborCount = 0;
for (let j = 0; j < FISH_COUNT; j++) {
if (i === j) continue;
const other = fishes[j];
const dSq = f.pos.distanceToSquared(other.pos);
if (dSq < NEIGHBOR_RADIUS * NEIGHBOR_RADIUS && dSq > 0.0001) {
const d = Math.sqrt(dSq);
cohForce.add(other.pos);
aliForce.add(other.vel);
neighborCount++;
if (d < SEPARATION_RADIUS) {
tempVec.subVectors(f.pos, other.pos).divideScalar(d * d);
sepForce.add(tempVec);
}
}
}
if (neighborCount > 0) {
cohForce.divideScalar(neighborCount).sub(f.pos).normalize().multiplyScalar(2.4);
aliForce.divideScalar(neighborCount).normalize().multiplyScalar(3.2);
sepForce.multiplyScalar(5.8);
f.acc.add(cohForce).add(aliForce).add(sepForce);
}
// Subtle organic wander
f.acc.x += Math.sin(now * 1.4 + f.wanderSeed) * 1.1;
f.acc.y += Math.cos(now * 1.9 + f.wanderSeed) * 0.7;
// Seek nearest food pellet when not startled
if (foodPellets.length > 0 && f.scaredTimer < 0.2) {
let bestDistSq = 110;
let targetPelletIdx = -1;
for (let pIdx = 0; pIdx < foodPellets.length; pIdx++) {
const dSq = f.pos.distanceToSquared(foodPellets[pIdx].mesh.position);
if (dSq < bestDistSq) {
bestDistSq = dSq;
targetPelletIdx = pIdx;
}
}
if (targetPelletIdx !== -1) {
const pelletPos = foodPellets[targetPelletIdx].mesh.position;
if (bestDistSq < 0.55) {
// Eat pellet
scene.remove(foodPellets[targetPelletIdx].mesh);
foodPellets.splice(targetPelletIdx, 1);
} else {
tempVec.subVectors(pelletPos, f.pos).normalize().multiplyScalar(11.0);
f.acc.add(tempVec);
}
}
}
// --- Mouse / Probe Evasion Force ---
const distToThreat = f.pos.distanceTo(repellerPos);
if (distToThreat < activeScareRadius) {
const urgency = 1.0 - distToThreat / activeScareRadius;
tempVec.subVectors(f.pos, repellerPos).normalize();
// Add slight vertical scatter so fish dart dynamically in 3D
tempVec.y += (f.pos.y >= repellerPos.y ? 0.25 : -0.25);
tempVec.normalize().multiplyScalar((48.0 + shockwaveBoost * 35.0) * urgency * urgency);
f.acc.add(tempVec);
f.scaredTimer = Math.max(f.scaredTimer, Math.min(1.0, urgency * 1.5));
} else {
f.scaredTimer = Math.max(0.0, f.scaredTimer - dt * 2.2);
}
if (f.scaredTimer > 0.15) scaredFishCount++;
// Soft Tank Boundary Containment
const turnAccel = 22.0;
if (f.pos.x > BOUNDS.x) f.acc.x -= turnAccel * ((f.pos.x - BOUNDS.x) + 0.5);
if (f.pos.x < -BOUNDS.x) f.acc.x += turnAccel * ((-BOUNDS.x - f.pos.x) + 0.5);
if (f.pos.y > BOUNDS.yMax) f.acc.y -= turnAccel * ((f.pos.y - BOUNDS.yMax) + 0.5);
if (f.pos.y < BOUNDS.yMin) f.acc.y += turnAccel * ((BOUNDS.yMin - f.pos.y) + 0.5);
if (f.pos.z > BOUNDS.z) f.acc.z -= turnAccel * ((f.pos.z - BOUNDS.z) + 0.5);
if (f.pos.z < -BOUNDS.z) f.acc.z += turnAccel * ((-BOUNDS.z - f.pos.z) + 0.5);
// Integrate Velocity & Clamp Speed
f.vel.addScaledVector(f.acc, dt);
const maxSpd = f.baseSpeed * (1.0 + f.scaredTimer * 2.1);
const minSpd = f.baseSpeed * 0.55;
const curSpd = f.vel.length();
if (curSpd > maxSpd) {
f.vel.multiplyScalar(maxSpd / curSpd);
} else if (curSpd < minSpd) {
f.vel.multiplyScalar(minSpd / (curSpd || 1));
}
// Keep vertical pitch reasonable unless escaping strongly
f.vel.y = THREE.MathUtils.clamp(f.vel.y, -maxSpd * 0.65, maxSpd * 0.65);
f.pos.addScaledVector(f.vel, dt);
// Hard safety clamp inside glass walls
f.pos.x = THREE.MathUtils.clamp(f.pos.x, -BOUNDS.x - 0.7, BOUNDS.x + 0.7);
f.pos.y = THREE.MathUtils.clamp(f.pos.y, BOUNDS.yMin - 0.4, BOUNDS.yMax + 0.5);
f.pos.z = THREE.MathUtils.clamp(f.pos.z, -BOUNDS.z - 0.7, BOUNDS.z + 0.7);
f.mesh.position.copy(f.pos);
// Smoothly orient fish along velocity vector (+Z is forward)
tempVec.addVectors(f.pos, f.vel);
lookMatrix.lookAt(tempVec, f.pos, upVec);
targetQuat.setFromRotationMatrix(lookMatrix);
f.mesh.quaternion.slerp(targetQuat, Math.min(1.0, dt * 12.0));
// Update tail wiggle shader uniforms
const speedRatio = curSpd / f.baseSpeed;
f.uniforms.uTime.value += dt * (1.0 + speedRatio * 0.85);
f.uniforms.uSpeed.value = THREE.MathUtils.clamp(speedRatio, 0.8, 2.8);
f.uniforms.uScared.value = f.scaredTimer;
}
updateHUDStats(scaredFishCount);
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>