↔ bougez ou inclinez l'écran
/* ============================================================
SWE FLUID CORE
State: h[i] = water height (column height from bottom)
u[i] = horizontal velocity (depth-averaged)
Equations:
∂h/∂t + ∂(hu)/∂x = 0 (mass conservation)
∂u/∂t + u·∂u/∂x = -g·∂h/∂x (momentum, gravity-driven)
+ viscosity damping + wall BC + tilt gravity
============================================================ */
const N = 180; // number of fluid columns (high res)
const G = 980; // gravity px/s² (tuned for pixel space)
const VISC = 0.018; // kinematic viscosity (damping)
const FILL = 0.58; // rest fill fraction of tank height
let h, u, hPrev, uPrev; // SWE state arrays
let tilt = 0; // lateral gravity −1..1
let gLateral = 0; // smoothed tilt acceleration
function initFluid() {
h = new Float64Array(N);
u = new Float64Array(N);
hPrev = new Float64Array(N);
uPrev = new Float64Array(N);
const restH = H * FILL;
for(let i = 0; i < N; i++) { h[i] = restH; u[i] = 0; }
}
/* --- Godunov-style upwind SWE step (stable, wave-capturing) --- */
function stepFluid(dt) {
// Clamp dt for stability
dt = Math.min(dt, 0.012);
const dx = W / N;
const gLat = tilt * G * 0.22; // lateral body force
// Lax-Friedrichs flux at each interface i+½
const Fh = new Float64Array(N + 1);
const Fu = new Float64Array(N + 1);
for(let i = 0; i < N - 1; i++) {
const hL = h[i], hR = h[i+1];
const uL = u[i], uR = u[i+1];
// wave speed (Roe average)
const c = Math.sqrt(G * 0.5 * (hL + hR + 1e-6));
// Rusanov (local Lax-Friedrichs) flux — robust & fast
const sMax = Math.max(Math.abs(uL) + c, Math.abs(uR) + c);
// Flux of h: F_h = h*u
Fh[i+1] = 0.5 * (hL*uL + hR*uR) - 0.5 * sMax * (hR - hL);
// Flux of hu: F_hu = h*u² + ½g*h²
const phiL = hL*uL*uL + 0.5*G*hL*hL;
const phiR = hR*uR*uR + 0.5*G*hR*hR;
Fu[i+1] = 0.5 * (phiL + phiR) - 0.5 * sMax * (hR*uR - hL*uL);
}
// Wall BCs: reflecting (zero normal flux)
Fh[0] = 0; Fu[0] = 0;
Fh[N] = 0; Fu[N] = 0;
// Update conserved variables: h, hu → then derive u
for(let i = 0; i < N; i++) {
const dh = -(dt / dx) * (Fh[i+1] - Fh[i]);
const dhu = -(dt / dx) * (Fu[i+1] - Fu[i]);
const hNew = Math.max(h[i] + dh, 1.0); // h > 0 always
const huNew = h[i]*u[i] + dhu + dt * h[i] * gLat; // body force
// Viscosity: diffuse u
let uDiff = 0;
if(i > 0) uDiff += u[i-1] - u[i];
if(i < N-1) uDiff += u[i+1] - u[i];
const uNew = huNew / hNew + VISC * uDiff;
hPrev[i] = hNew;
uPrev[i] = uNew * (1 - VISC * 0.5); // global damping
}
// Swap buffers
const tmp = h; h = hPrev; hPrev = tmp;
const tmu = u; u = uPrev; uPrev = tmu;
// Volume correction — keep total water volume constant
let totalH = 0;
const targetH = H * FILL * N;
for(let i = 0; i < N; i++) totalH += h[i];
const correction = (targetH - totalH) / N;
for(let i = 0; i < N; i++) h[i] = Math.max(h[i] + correction, 0.5);
}
/* --- Surface Y position for column i (from top) --- */
function surfaceY(i) {
return H - h[i];
}
/* --- Surface normal for caustics / specular (finite difference) --- */
function surfaceNormal(i) {
const dx = W / N;
let dydx;
if(i === 0) dydx = (surfaceY(1) - surfaceY(0)) / dx;
else if(i === N-1) dydx = (surfaceY(N-1) - surfaceY(N-2)) / dx;
else dydx = (surfaceY(i+1) - surfaceY(i-1)) / (2*dx);
// Normal vector (normalised): perpendicular to tangent (1, dydx)
const len = Math.sqrt(1 + dydx*dydx);
return { nx: -dydx/len, ny: 1/len, slope: dydx };
}
/* --- Perturb fluid at pixel x with velocity impulse --- */
function splash(px, strength = 8) {
const ci = Math.floor((px / W) * N);
const r = 4; // radius in cells
for(let i = Math.max(0, ci-r); i <= Math.min(N-1, ci+r); i++) {
const dist = Math.abs(i - ci) / r;
const w = Math.exp(-dist * dist * 3);
u[i] += strength * w * 60; // velocity impulse
}
}
/* ============================================================
DRAW WATER — volumetric multi-layer rendering
============================================================ */
function drawWater() {
wCtx.clearRect(0, 0, W, H);
const dx = W / N;
/* --- Layer 1: Deep water body --- */
const bodyGrad = wCtx.createLinearGradient(0, 0, 0, H);
bodyGrad.addColorStop(0, 'rgba(8, 80, 160, 0.0)');
bodyGrad.addColorStop(0.05,'rgba(12, 95, 175, 0.65)');
bodyGrad.addColorStop(0.4, 'rgba(8, 65, 140, 0.82)');
bodyGrad.addColorStop(0.75,'rgba(4, 40, 100, 0.92)');
bodyGrad.addColorStop(1, 'rgba(2, 20, 60, 0.97)');
buildSurfacePath(wCtx, 0);
wCtx.fillStyle = bodyGrad;
wCtx.fill();
/* --- Layer 2: Mid-water scattering glow (Mie-like) --- */
const midY = H * 0.65;
const scatter = wCtx.createLinearGradient(0, surfaceY(N>>1), 0, H);
scatter.addColorStop(0, 'rgba(30,140,220, 0.18)');
scatter.addColorStop(0.35,'rgba(15,100,190, 0.12)');
scatter.addColorStop(1, 'rgba(5, 50, 130, 0.0)');
buildSurfacePath(wCtx, 0);
wCtx.fillStyle = scatter;
wCtx.fill();
/* --- Layer 3: Caustic shimmer stripes under surface --- */
drawCausticStripes();
/* --- Layer 4: Surface specular highlights --- */
drawSpecular();
/* --- Layer 5: Surface line with foam --- */
drawSurfaceLine();
}
/* Build the water polygon path (offset = vertical shift for layering) */
function buildSurfacePath(ctx, yOffset) {
const dx = W / N;
ctx.beginPath();
ctx.moveTo(0, surfaceY(0) + yOffset);
for(let i = 1; i < N; i++) {
// Catmull-Rom style smooth curve via midpoints
const x0 = (i-1) * dx, x1 = i * dx;
const mx = (x0 + x1) * 0.5;
const my = (surfaceY(i-1) + surfaceY(i)) * 0.5 + yOffset;
wCtx.quadraticCurveTo(x0, surfaceY(i-1) + yOffset, mx, my);
}
wCtx.lineTo(W, surfaceY(N-1) + yOffset);
wCtx.lineTo(W, H);
wCtx.lineTo(0, H);
wCtx.closePath();
}
/* Caustic light stripes: driven by surface curvature focusing light */
function drawCausticStripes() {
const dx = W / N;
for(let i = 1; i < N - 1; i++) {
const n = surfaceNormal(i);
// Curvature proxy: second derivative of surface height
const curv = (surfaceY(i+1) + surfaceY(i-1) - 2*surfaceY(i));
if(curv < -0.4) { // concave → focusing → bright caustic
const intensity = Math.min(1, -curv * 0.08);
const x = i * dx;
const sy = surfaceY(i);
// Draw a refracted light column downward
const cgrad = wCtx.createLinearGradient(x, sy, x, H);
cgrad.addColorStop(0, `rgba(140,210,255,${intensity * 0.22})`);
cgrad.addColorStop(0.3, `rgba(100,180,240,${intensity * 0.10})`);
cgrad.addColorStop(1, 'rgba(60,140,200,0)');
wCtx.fillStyle = cgrad;
wCtx.fillRect(x - dx*1.5, sy, dx*3, H - sy);
}
}
}
/* Specular highlights: mirror-like reflections on surface peaks */
function drawSpecular() {
const dx = W / N;
wCtx.save();
// Clip to water body
buildSurfacePath(wCtx, 0);
wCtx.clip();
for(let i = 1; i < N-1; i++) {
const n = surfaceNormal(i);
// Specular intensity: how close normal is to vertical (Blinn-Phong)
const spec = Math.pow(Math.max(0, n.ny), 12);
if(spec < 0.01) continue;
const x = i * dx;
const sy = surfaceY(i);
const sg = wCtx.createRadialGradient(x, sy, 0, x, sy, dx*3);
sg.addColorStop(0, `rgba(220,240,255,${spec * 0.7})`);
sg.addColorStop(0.4, `rgba(180,220,255,${spec * 0.25})`);
sg.addColorStop(1, 'rgba(160,210,255,0)');
wCtx.fillStyle = sg;
wCtx.fillRect(x - dx*3, sy - 4, dx*6, 18);
}
wCtx.restore();
}
/* Surface line: smooth foam edge + secondary wave shimmer */
function drawSurfaceLine() {
const dx = W / N;
// Primary surface — bright edge
wCtx.beginPath();
wCtx.moveTo(0, surfaceY(0));
for(let i = 1; i < N; i++) {
const x0 = (i-1)*dx, x1 = i*dx;
const mx = (x0+x1)*0.5, my = (surfaceY(i-1)+surfaceY(i))*0.5;
wCtx.quadraticCurveTo(x0, surfaceY(i-1), mx, my);
}
wCtx.lineTo(W, surfaceY(N-1));
wCtx.strokeStyle = 'rgba(200,235,255, 0.70)';
wCtx.lineWidth = 2.5;
wCtx.stroke();
// Foam: secondary softer line slightly below
wCtx.beginPath();
wCtx.moveTo(0, surfaceY(0)+3);
for(let i = 1; i < N; i++) {
const x0=(i-1)*dx, x1=i*dx;
const mx=(x0+x1)*0.5, my=(surfaceY(i-1)+surfaceY(i))*0.5+3;
wCtx.quadraticCurveTo(x0, surfaceY(i-1)+3, mx, my);
}
wCtx.lineTo(W, surfaceY(N-1)+3);
wCtx.strokeStyle = 'rgba(255,255,255, 0.18)';
wCtx.lineWidth = 5;
wCtx.stroke();
// Sub-surface diffuse band
wCtx.beginPath();
wCtx.moveTo(0, surfaceY(0)+1);
for(let i = 1; i < N; i++) {
const x0=(i-1)*dx, x1=i*dx;
const mx=(x0+x1)*0.5, my=(surfaceY(i-1)+surfaceY(i))*0.5+1;
wCtx.quadraticCurveTo(x0, surfaceY(i-1)+1, mx, my);
}
wCtx.lineTo(W, surfaceY(N-1)+1);
wCtx.lineTo(W, surfaceY(N-1)+22);
// back along offset
for(let i=N-1;i>=0;i--){
const x0=i*dx;
wCtx.lineTo(x0, surfaceY(i)+22);
}
wCtx.closePath();
const subGrad = wCtx.createLinearGradient(0,0,0,30);
subGrad.addColorStop(0,'rgba(120,200,255,0.18)');
subGrad.addColorStop(1,'rgba(80,160,230,0)');
wCtx.fillStyle = subGrad;
wCtx.fill();
}
/* ============================================================
FISH
============================================================ */
const FISH_COLORS = [
['#ff7a2f','#ff4400'],
['#ffcc00','#ff8800'],
['#ff4488','#cc1166'],
['#44ddff','#0077cc'],
['#88ff88','#22aa44'],
];
class Fish {
constructor(){
this.reset(true);
}
reset(init=false){
this.size = 18 + Math.random()*22;
const [c1,c2] = FISH_COLORS[Math.floor(Math.random()*FISH_COLORS.length)];
this.col1 = c1; this.col2 = c2;
this.speed = (0.4 + Math.random()*0.6) * (Math.random()<0.5?1:-1);
this.y = H*0.55 + Math.random()*(H*0.38);
this.x = this.speed>0 ? -60 : W+60;
this.vy = 0;
this.ay = 0;
this.targetY = this.y;
this.tailPhase = Math.random()*Math.PI*2;
this.finPhase = Math.random()*Math.PI*2;
this.wobble = 0;
if(init) this.x = Math.random()*W;
}
getWaterY(x){
const i = Math.max(0, Math.min(N-1, Math.floor((x/W)*N)));
return surfaceY(i);
}
update(dt){
this.tailPhase += dt*7;
this.finPhase += dt*4;
// drift vertically toward target
if(Math.random()<0.005) this.targetY = H*0.55 + Math.random()*(H*0.36);
this.vy += (this.targetY - this.y)*0.004;
this.vy *= 0.94;
this.y += this.vy;
// stay below water surface
const wy = this.getWaterY(this.x);
if(this.y < wy + this.size*0.6) {
this.y = wy + this.size*0.6;
this.vy = Math.abs(this.vy)*0.5;
}
if(this.y > H - this.size*0.5) this.y = H - this.size*0.5;
this.x += this.speed * dt * 60;
// wrap
if(this.speed>0 && this.x > W+80) this.reset();
if(this.speed<0 && this.x < -80) this.reset();
}
draw(ctx){
const dir = this.speed>0 ? 1 : -1;
ctx.save();
ctx.translate(this.x, this.y);
ctx.scale(dir, 1);
const tw = this.size*1.8, th = this.size*0.7;
const tail = Math.sin(this.tailPhase)*this.size*0.45;
const s = this.size;
// tail
ctx.beginPath();
ctx.moveTo(-tw*0.55, 0);
ctx.lineTo(-tw*0.95, -s*0.55 + tail*0.6);
ctx.lineTo(-tw, tail);
ctx.lineTo(-tw*0.95, s*0.55 + tail*0.6);
ctx.closePath();
const tg = ctx.createLinearGradient(-tw, 0, -tw*0.5, 0);
tg.addColorStop(0, this.col2+'88');
tg.addColorStop(1, this.col1);
ctx.fillStyle = tg;
ctx.fill();
// body
ctx.beginPath();
ctx.ellipse(0, 0, tw*0.55, th, 0, 0, Math.PI*2);
const bg = ctx.createRadialGradient(-s*0.2, -s*0.1, 0, 0, 0, tw*0.6);
bg.addColorStop(0, '#fff8');
bg.addColorStop(0.3, this.col1);
bg.addColorStop(1, this.col2);
ctx.fillStyle = bg;
ctx.fill();
// dorsal fin
ctx.beginPath();
const fp = Math.sin(this.finPhase)*s*0.1;
ctx.moveTo(-s*0.1, -th+fp);
ctx.quadraticCurveTo(s*0.1, -th-s*0.5+fp, s*0.3, -th+fp*0.5);
ctx.lineTo(s*0.05, -th*0.7);
ctx.closePath();
ctx.fillStyle = this.col2+'aa';
ctx.fill();
// eye
ctx.beginPath();
ctx.arc(s*0.35, -s*0.05, s*0.12, 0, Math.PI*2);
ctx.fillStyle = '#111';
ctx.fill();
ctx.beginPath();
ctx.arc(s*0.37, -s*0.07, s*0.045, 0, Math.PI*2);
ctx.fillStyle = '#fff';
ctx.fill();
// scale shimmer
ctx.globalAlpha = 0.12;
for(let i=0;i<4;i++){
ctx.beginPath();
const sx = -s*0.3 + i*s*0.22;
ctx.arc(sx, 0, s*0.25, Math.PI*0.7, Math.PI*2.3);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1;
ctx.stroke();
}
ctx.globalAlpha = 1;
ctx.restore();
}
}
/* ============================================================
SEABED & DECORATIONS
============================================================ */
function drawSeabed(ctx){
// sandy bottom
const sg = ctx.createLinearGradient(0, H*0.85, 0, H);
sg.addColorStop(0, '#c8a86888');
sg.addColorStop(1, '#a07840cc');
ctx.fillStyle = sg;
ctx.beginPath();
ctx.moveTo(0, H*0.86);
ctx.quadraticCurveTo(W*0.25, H*0.83, W*0.5, H*0.86);
ctx.quadraticCurveTo(W*0.75, H*0.89, W, H*0.85);
ctx.lineTo(W, H);
ctx.lineTo(0, H);
ctx.closePath();
ctx.fill();
// pebbles
const pebbles = [{x:0.1,y:0.88,r:5},{x:0.22,y:0.9,r:7},{x:0.35,y:0.87,r:4},
{x:0.5,y:0.89,r:6},{x:0.63,y:0.88,r:5},{x:0.78,y:0.91,r:8},{x:0.9,y:0.87,r:5}];
pebbles.forEach(p=>{
ctx.beginPath();
ctx.ellipse(p.x*W, p.y*H, p.r*1.6, p.r*0.9, 0, 0, Math.PI*2);
ctx.fillStyle = '#8a6a40cc';
ctx.fill();
ctx.beginPath();
ctx.ellipse(p.x*W-p.r*0.3, p.y*H-p.r*0.3, p.r*0.4, p.r*0.25, 0.5, 0, Math.PI*2);
ctx.fillStyle = '#fff5';
ctx.fill();
});
}
function drawSeaweed(ctx, t){
const weeds = [
{x:0.08, h:0.22, col:'#1a9944', col2:'#0d6630'},
{x:0.18, h:0.16, col:'#22bb55', col2:'#158833'},
{x:0.72, h:0.25, col:'#1a9944', col2:'#0d6630'},
{x:0.82, h:0.18, col:'#22bb55', col2:'#158833'},
{x:0.55, h:0.14, col:'#33cc66', col2:'#229944'},
];
weeds.forEach(w=>{
const bx = w.x*W, by = H*0.87;
const segs = 7;
const sh = w.h*H/segs;
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(bx, by);
let cx=bx, cy=by;
for(let i=0;i