Boat game, built by DeepSeek V4.1 Flash 1017
Hostedvia OpenCodeOpenRouter
127tok/s
First token included
311.4Ktokens
41m 0s
$0.47
Show the code the model wroteHide the code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
<title>NEREID — Black Tide</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,400;0,500;0,600;0,700;1,500;1,600;1,700&family=Inter:wght@300;400;500;600&display=swap');
:root{
--ink:#e9f4f4;
--muted:rgba(220,238,240,.55);
--dim:rgba(210,232,236,.3);
--cyan:#7fe7ff;
--cyan-soft:#b8f3ff;
--amber:#ffb25f;
--red:#ff4d3f;
--glass:rgba(7,16,20,.46);
--line:rgba(193,231,238,.18);
--font-display:'Barlow Condensed',sans-serif;
--font-ui:'Inter',sans-serif;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;overflow:hidden;background:#04080b;color:var(--ink);font-family:var(--font-ui);}
body{user-select:none;-webkit-user-select:none;}
#app{position:fixed;inset:0;overflow:hidden;isolation:isolate;}
#scene{position:absolute;inset:0;display:block;width:100%;height:100%;outline:none;}
#app.playing #scene{cursor:none;}
#app.playing #startScreen{display:none;}
/* ── atmosphere ─────────────────────────────────────────── */
.vignette{position:absolute;inset:0;pointer-events:none;z-index:3;
background:radial-gradient(ellipse at 50% 45%,transparent 34%,rgba(2,7,10,.16) 68%,rgba(1,4,7,.58) 100%);
mix-blend-mode:multiply;}
.grain{position:absolute;inset:-40%;pointer-events:none;z-index:4;opacity:.055;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 220 220' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.86' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation:grainShift 1.1s steps(3) infinite;}
@keyframes grainShift{
0%{transform:translate(0,0)} 33%{transform:translate(-1.4%,.8%)} 66%{transform:translate(1%,-1.2%)} 100%{transform:translate(0,0)}
}
#damageFlash{position:absolute;inset:0;z-index:5;pointer-events:none;opacity:0;
background:radial-gradient(ellipse at 50% 50%,transparent 22%,rgba(150,10,0,.18) 63%,rgba(255,36,18,.68) 100%);
transition:opacity .28s ease-out;}
#waterFlash{position:absolute;inset:0;z-index:5;pointer-events:none;opacity:0;
background:linear-gradient(to top,rgba(185,240,255,.38),rgba(112,200,224,.12) 24%,transparent 58%);
transition:opacity .4s ease-out;}
/* ── HUD ────────────────────────────────────────────────── */
#hud{position:absolute;inset:0;z-index:10;pointer-events:none;opacity:0;transition:opacity .7s ease .2s;}
#app.playing #hud{opacity:1;}
.hud-top{position:absolute;top:0;left:0;right:0;display:flex;justify-content:space-between;align-items:flex-start;
padding:clamp(18px,2.4vw,34px) clamp(18px,3vw,48px);gap:20px;}
.panel{position:relative;background:linear-gradient(135deg,rgba(8,18,23,.62),rgba(5,12,16,.24));
border:1px solid var(--line);backdrop-filter:blur(10px) saturate(1.1);-webkit-backdrop-filter:blur(10px) saturate(1.1);
box-shadow:0 18px 60px rgba(0,0,0,.18);padding:15px 18px 14px;min-width:216px;}
.panel::before,.panel::after{content:'';position:absolute;width:7px;height:7px;border-color:rgba(135,230,248,.65);border-style:solid;pointer-events:none;}
.panel::before{top:-1px;left:-1px;border-width:1px 0 0 1px;}
.panel::after{bottom:-1px;right:-1px;border-width:0 1px 1px 0;}
.eyebrow{font-family:var(--font-display);font-size:10px;letter-spacing:.24em;text-transform:uppercase;color:var(--muted);
display:flex;align-items:center;gap:8px;white-space:nowrap;}
.live-dot{width:5px;height:5px;background:var(--cyan);border-radius:50%;box-shadow:0 0 10px var(--cyan);animation:livePulse 2.4s ease-in-out infinite;}
@keyframes livePulse{0%,100%{opacity:.45}50%{opacity:1}}
.hull-line{display:flex;align-items:baseline;justify-content:space-between;margin-top:13px;gap:18px;}
.hull-label{font-family:var(--font-display);font-size:13px;letter-spacing:.15em;color:rgba(235,248,249,.8);}
#hullVal{font-family:var(--font-display);font-size:25px;line-height:.9;font-weight:600;letter-spacing:.02em;}
.meter{position:relative;height:5px;margin-top:7px;background:rgba(255,255,255,.09);overflow:hidden;}
.meter i{position:absolute;inset:0;width:100%;transform-origin:left center;background:linear-gradient(90deg,#2c8ca0,#9cf0ff);transition:transform .3s ease,background .4s ease;
box-shadow:0 0 12px rgba(120,230,250,.5);}
.meter::after{content:'';position:absolute;inset:0;background:repeating-linear-gradient(90deg,transparent 0 17px,rgba(0,0,0,.4) 17px 18px);}
.data-row{display:flex;align-items:flex-end;gap:12px;margin-top:12px;}
.data-row.right{justify-content:flex-end;gap:14px;}
.big{font-family:var(--font-display);font-size:31px;line-height:.8;font-weight:500;letter-spacing:.01em;}
.big small{font-family:var(--font-ui);font-size:9px;letter-spacing:.16em;color:var(--muted);margin-left:5px;font-weight:500;}
.small{font-family:var(--font-display);font-size:11px;letter-spacing:.14em;color:var(--muted);white-space:nowrap;}
.small b{color:rgba(238,250,251,.92);font-weight:600;font-size:14px;letter-spacing:.06em;}
.thin{width:1px;height:21px;background:var(--line);display:block;}
.hud-center{text-align:center;min-width:150px;padding-top:16px;padding-bottom:12px;}
.wave-num{font-family:var(--font-display);font-size:46px;line-height:.95;font-weight:500;letter-spacing:.03em;margin-top:2px;}
.threat{font-family:var(--font-display);font-size:10px;letter-spacing:.2em;color:var(--amber);margin-top:3px;text-transform:uppercase;}
.hud-right{text-align:right;}
.score{font-family:var(--font-display);font-size:38px;line-height:.9;letter-spacing:.03em;font-weight:500;margin-top:9px;font-variant-numeric:tabular-nums;}
#radarWrap{position:absolute;left:clamp(18px,3vw,48px);bottom:22px;width:148px;height:168px;padding:10px 10px 8px;
border:1px solid var(--line);background:linear-gradient(160deg,rgba(7,18,22,.62),rgba(4,12,16,.25));backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);}
#radarWrap::before{content:'';position:absolute;left:-1px;top:-1px;width:7px;height:7px;border-left:1px solid var(--cyan);border-top:1px solid var(--cyan);}
.radar-top{display:flex;justify-content:space-between;font-family:var(--font-display);font-size:8px;letter-spacing:.16em;color:var(--muted);margin-bottom:3px;}
#radar{display:block;width:126px;height:126px;}
#controlsHint{position:absolute;bottom:25px;left:50%;transform:translateX(-50%);font-family:var(--font-display);
font-size:11px;letter-spacing:.18em;color:rgba(226,245,246,.46);white-space:nowrap;transition:opacity 1.2s ease;
text-shadow:0 2px 12px rgba(0,0,0,.5);}
#controlsHint.fade{opacity:0;}
#audioBtn{position:absolute;right:clamp(18px,3vw,48px);bottom:22px;z-index:30;pointer-events:none;opacity:0;background:rgba(7,16,20,.4);
border:1px solid var(--line);color:var(--muted);font-family:var(--font-display);font-size:10px;letter-spacing:.16em;
padding:8px 12px;cursor:pointer;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);transition:.2s;}
#app.playing #audioBtn{opacity:1;pointer-events:auto;}
#audioBtn:hover{color:var(--ink);border-color:rgba(150,235,250,.46);background:rgba(12,30,38,.58);}
/* ── reticle ────────────────────────────────────────────── */
#reticle{position:absolute;left:50%;top:50%;width:42px;height:42px;transform:translate(-50%,-50%);z-index:20;
pointer-events:none;opacity:0;transition:opacity .35s ease;filter:drop-shadow(0 0 6px rgba(0,8,10,.8));}
#app.playing #reticle{opacity:1;}
.ret-ring{position:absolute;inset:7px;border:1px solid rgba(198,241,250,.6);border-radius:50%;transition:.15s ease;}
.ret-dot{position:absolute;left:50%;top:50%;width:3px;height:3px;margin:-1.5px 0 0 -1.5px;border-radius:50%;background:#e8fdff;box-shadow:0 0 8px #a9f0ff;}
.ret-line{position:absolute;background:rgba(213,247,253,.8);transition:.15s ease;}
.ret-line.n{left:50%;top:-1px;width:1px;height:11px;margin-left:-.5px;}
.ret-line.s{left:50%;bottom:-1px;width:1px;height:11px;margin-left:-.5px;}
.ret-line.w{top:50%;left:-1px;height:1px;width:11px;margin-top:-.5px;}
.ret-line.e{top:50%;right:-1px;height:1px;width:11px;margin-top:-.5px;}
#reticle.target .ret-ring{border-color:rgba(255,89,63,.92);transform:scale(1.18);box-shadow:0 0 14px rgba(255,67,43,.5);}
#reticle.target .ret-line{background:rgba(255,94,66,.95);}
#reticle.target .ret-dot{background:#ff8c70;box-shadow:0 0 8px #ff4f34;}
/* ── warning, banner ────────────────────────────────────── */
#rogueWarning{position:absolute;left:50%;top:25.5%;transform:translateX(-50%);z-index:18;pointer-events:none;opacity:0;
font-family:var(--font-display);font-size:14px;font-weight:600;letter-spacing:.3em;color:#ffc47d;
text-align:center;text-shadow:0 0 20px rgba(255,150,59,.7);transition:opacity .4s ease;white-space:nowrap;
padding:8px 18px 9px;border:1px solid rgba(255,178,95,.24);background:rgba(9,16,18,.34);
backdrop-filter:blur(7px);-webkit-backdrop-filter:blur(7px);}
#rogueWarning small{display:block;font-family:var(--font-ui);font-size:8px;letter-spacing:.22em;color:rgba(255,207,158,.72);margin-top:6px;font-weight:400;}
#rogueWarning.show{opacity:1;animation:roguePulse 1.2s ease-in-out infinite;}
@keyframes roguePulse{0%,100%{opacity:.5}50%{opacity:1}}
#banner{position:absolute;left:50%;top:33%;transform:translate(-50%,8px);z-index:18;pointer-events:none;opacity:0;text-align:center;
transition:opacity .35s ease,transform .45s cubic-bezier(.16,1,.3,1);}
#banner.show{opacity:1;transform:translate(-50%,0);}
#banner .b-kicker{font-family:var(--font-ui);font-size:9px;letter-spacing:.3em;color:var(--cyan);margin-bottom:9px;text-transform:uppercase;}
#banner .b-title{font-family:var(--font-display);font-size:clamp(30px,4.2vw,56px);font-weight:500;letter-spacing:.06em;line-height:.9;text-transform:uppercase;
text-shadow:0 10px 40px rgba(0,0,0,.55);}
#banner .b-sub{font-family:var(--font-ui);font-size:10px;letter-spacing:.2em;color:var(--muted);margin-top:10px;text-transform:uppercase;}
#pauseOverlay{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);z-index:22;text-align:center;pointer-events:none;
opacity:0;transition:opacity .3s ease;padding:22px 38px;border:1px solid var(--line);background:rgba(4,12,16,.52);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);}
#pauseOverlay.show{opacity:1;}
#pauseOverlay b{display:block;font-family:var(--font-display);font-size:40px;font-weight:500;letter-spacing:.3em;color:#eaf8f8;}
#pauseOverlay span{display:block;font-size:8px;letter-spacing:.27em;color:var(--muted);margin-top:8px;}
/* ── start screen ───────────────────────────────────────── */
.screen{position:absolute;inset:0;z-index:25;display:flex;align-items:center;}
#startScreen{background:
linear-gradient(90deg,rgba(2,7,10,.94) 0%,rgba(2,8,11,.82) 31%,rgba(2,8,11,.38) 61%,rgba(2,8,11,.03) 100%),
linear-gradient(0deg,rgba(2,6,9,.8),transparent 38%);}
.start-inner{width:min(1120px,100%);padding:0 clamp(26px,7vw,105px);transform:translateY(-2vh);}
.start-kicker{display:flex;align-items:center;gap:11px;font-family:var(--font-ui);font-size:9px;letter-spacing:.28em;color:rgba(202,234,238,.6);text-transform:uppercase;margin-bottom:clamp(22px,4vh,40px);}
.start-kicker .live-dot{background:var(--amber);box-shadow:0 0 13px var(--amber);}
h1.logo{font-family:var(--font-display);font-weight:600;font-style:italic;font-size:clamp(92px,14.7vw,215px);line-height:.68;letter-spacing:-.025em;margin:0;
color:#edfaf9;text-shadow:0 24px 60px rgba(0,0,0,.4);}
.logo span{display:inline-block;color:transparent;-webkit-text-stroke:1px rgba(171,235,246,.42);font-size:.46em;letter-spacing:.1em;transform:translateY(-.23em);margin-left:.14em;font-style:normal;}
.start-sub{font-family:var(--font-display);font-size:clamp(20px,2.2vw,31px);letter-spacing:.5em;color:var(--amber);margin-top:clamp(20px,3.7vh,34px);font-weight:500;}
.start-desc{max-width:535px;font-size:13px;line-height:1.8;font-weight:300;color:rgba(219,238,240,.62);margin:25px 0 0;letter-spacing:.015em;}
.start-desc b{color:rgba(243,253,253,.92);font-weight:500;}
.primary{position:relative;display:inline-flex;align-items:center;gap:22px;margin-top:clamp(26px,4.7vh,42px);padding:16px 25px 16px 31px;
background:linear-gradient(100deg,rgba(127,231,255,.95),rgba(175,245,255,.82));color:#032329;border:0;cursor:pointer;
font-family:var(--font-display);font-size:15px;font-weight:700;letter-spacing:.24em;text-transform:uppercase;
clip-path:polygon(0 0,100% 0,100% calc(100% - 12px),calc(100% - 12px) 100%,0 100%);transition:.24s ease;
box-shadow:0 16px 46px rgba(60,202,227,.16);}
.primary span{font-family:var(--font-ui);font-size:18px;font-weight:300;letter-spacing:0;}
.primary:hover{background:#e6fcff;transform:translateY(-2px);box-shadow:0 20px 50px rgba(60,202,227,.26);}
.primary:active{transform:translateY(0);}
.controls{display:flex;gap:clamp(22px,3.8vw,52px);margin-top:clamp(34px,6vh,58px);flex-wrap:wrap;}
.controls>div{display:flex;flex-direction:column;gap:8px;}
.keys{display:flex;gap:4px;}
kbd{font-family:var(--font-ui);font-size:10px;font-weight:500;min-width:27px;height:27px;padding:0 7px;display:inline-flex;align-items:center;justify-content:center;
border:1px solid rgba(193,235,241,.23);background:rgba(186,231,238,.06);color:rgba(232,250,251,.82);border-radius:2px;box-shadow:inset 0 -2px rgba(0,0,0,.24);}
.controls label{font-family:var(--font-display);font-size:9px;letter-spacing:.24em;color:rgba(210,236,240,.43);text-transform:uppercase;}
.start-foot{font-family:var(--font-ui);font-size:8px;letter-spacing:.2em;color:rgba(185,222,228,.33);margin-top:clamp(30px,5vh,48px);text-transform:uppercase;line-height:2;}
.start-foot .slash{color:rgba(255,178,95,.55);padding:0 8px;}
.start-credit{position:absolute;right:clamp(20px,3vw,48px);bottom:26px;font-family:var(--font-display);font-size:9px;letter-spacing:.2em;color:rgba(192,226,232,.28);text-align:right;}
/* ── game over ──────────────────────────────────────────── */
#gameOver{opacity:0;pointer-events:none;justify-content:center;background:radial-gradient(ellipse at center,rgba(4,12,15,.35),rgba(2,6,9,.9));transition:opacity .9s ease;}
#gameOver.show{opacity:1;pointer-events:auto;}
.go-inner{text-align:center;max-width:600px;padding:30px;}
.go-kicker{font-family:var(--font-ui);font-size:9px;letter-spacing:.32em;color:var(--red);margin-bottom:18px;}
.go-title{font-family:var(--font-display);font-size:clamp(60px,9vw,128px);font-style:italic;font-weight:600;line-height:.78;letter-spacing:-.01em;margin:0;
text-shadow:0 22px 70px rgba(0,0,0,.5);}
.go-title span{-webkit-text-stroke:1px rgba(255,105,89,.5);color:transparent;}
.go-stats{display:flex;justify-content:center;gap:38px;margin-top:35px;}
.go-stat{display:flex;flex-direction:column;gap:5px;}
.go-stat b{font-family:var(--font-display);font-size:30px;font-weight:500;color:#eaf8f8;}
.go-stat label{font-size:8px;letter-spacing:.22em;color:var(--muted);}
.go-inner .primary{margin-top:38px;background:linear-gradient(100deg,rgba(255,109,77,.95),rgba(255,176,118,.85));color:#280b04;box-shadow:0 16px 46px rgba(255,90,55,.14);}
.go-inner .primary:hover{background:#ffd0bb;}
/* ── loading ────────────────────────────────────────────── */
#loading{position:absolute;inset:0;z-index:60;background:#04080b;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:opacity .8s ease;}
#loading.done{opacity:0;pointer-events:none;}
.load-mark{font-family:var(--font-display);font-style:italic;font-size:44px;font-weight:600;letter-spacing:.02em;color:#eaf8f8;}
.load-mark span{color:var(--amber);}
.load-bar{width:170px;height:1px;background:rgba(255,255,255,.1);margin-top:28px;overflow:hidden;}
.load-bar i{display:block;height:100%;width:40%;background:var(--cyan);box-shadow:0 0 15px var(--cyan);animation:loadSlide 1.4s ease-in-out infinite;}
@keyframes loadSlide{0%{transform:translateX(-110%)}100%{transform:translateX(330%)}}
.load-text{font-family:var(--font-ui);font-size:8px;letter-spacing:.28em;color:rgba(205,235,240,.35);margin-top:17px;text-transform:uppercase;}
@media (max-width:760px){
.hud-top{padding:14px;}
.panel{min-width:0;padding:10px 12px;}
.hud-center{display:none;}
.start-inner{padding:0 25px;transform:none;}
.start-desc{display:none;}
.controls{margin-top:26px;gap:20px;}
.start-foot{display:none;}
.hud-right{display:none;}
}
</style>
</head>
<body>
<div id="app">
<canvas id="scene" tabindex="0"></canvas>
<div class="vignette"></div>
<div class="grain"></div>
<div id="damageFlash"></div>
<div id="waterFlash"></div>
<div id="hud">
<div class="hud-top">
<div class="panel hud-left">
<div class="eyebrow"><span class="live-dot"></span> Patrol Craft / Nereid</div>
<div class="hull-line"><span class="hull-label">HULL INTEGRITY</span><span id="hullVal">100</span></div>
<div class="meter"><i id="hullFill"></i></div>
<div class="data-row">
<span class="big"><b id="speedVal">0.0</b><small>KTS</small></span>
<span class="thin"></span>
<span class="small">HDG <b id="headingVal">000</b>°</span>
</div>
</div>
<div class="panel hud-center">
<div class="eyebrow" style="justify-content:center">Hostile Wave</div>
<div class="wave-num" id="waveVal">01</div>
<div class="threat" id="threatVal">STANDING BY</div>
</div>
<div class="panel hud-right">
<div class="eyebrow" style="justify-content:flex-end">Contact / Score</div>
<div class="score" id="scoreVal">0</div>
<div class="data-row right">
<span class="small">HOSTILES <b id="enemyVal">0</b></span>
<span class="thin"></span>
<span class="small">KILLS <b id="killVal">0</b></span>
</div>
</div>
</div>
<div id="radarWrap">
<div class="radar-top"><span>SURFACE CONTACTS</span><span>250 M</span></div>
<canvas id="radar" width="200" height="200"></canvas>
</div>
<div id="controlsHint">W / S THROTTLE · A / D RUDDER · MOUSE AIM · CLICK FIRE · SHIFT BOOST</div>
</div>
<div id="reticle">
<div class="ret-ring"></div>
<div class="ret-dot"></div>
<div class="ret-line n"></div><div class="ret-line s"></div>
<div class="ret-line w"></div><div class="ret-line e"></div>
</div>
<div id="rogueWarning">ROGUE SWELL INBOUND<small>REDUCE SPEED · BRACE FOR IMPACT</small></div>
<div id="pauseOverlay"><b>PAUSED</b><span>PRESS P TO RESUME</span></div>
<div id="banner">
<div class="b-kicker" id="bannerKicker">TRANSMISSION</div>
<div class="b-title" id="bannerTitle">WAVE 01</div>
<div class="b-sub" id="bannerSub">MARAUDERS INBOUND</div>
</div>
<button id="audioBtn" aria-label="Toggle sound">SOUND ON</button>
<div id="startScreen" class="screen">
<div class="start-inner">
<div class="start-kicker"><span class="live-dot"></span> Open water combat protocol</div>
<h1 class="logo">NEREID<span>07</span></h1>
<div class="start-sub">BLACK TIDE</div>
<p class="start-desc">Command the patrol boat <b>NEREID</b> through a hostile seaway. Marauder skiffs hunt in packs, using the swell for cover. Read the water. Keep your hull intact. Put them under.</p>
<button id="startBtn" class="primary">SET SAIL <span>↗</span></button>
<div class="controls">
<div><span class="keys"><kbd>W</kbd><kbd>S</kbd></span><label>Throttle</label></div>
<div><span class="keys"><kbd>A</kbd><kbd>D</kbd></span><label>Rudder</label></div>
<div><span class="keys"><kbd>MOUSE</kbd></span><label>Aim / Fire</label></div>
<div><span class="keys"><kbd>SHIFT</kbd></span><label>Emergency power</label></div>
</div>
<div class="start-foot">HEADPHONES RECOMMENDED<span class="slash">/</span>SCROLL TO ZOOM<span class="slash">/</span>M TO MUTE</div>
</div>
<div class="start-credit">NORTH SEA / SECTOR 07<br>SEA STATE 4 · VISIBILITY 8 NM</div>
</div>
<div id="gameOver" class="screen">
<div class="go-inner">
<div class="go-kicker">SIGNAL LOST / HULL BREACH</div>
<h2 class="go-title">NEREID<span> DOWN</span></h2>
<div class="go-stats">
<div class="go-stat"><b id="goScore">0</b><label>SCORE</label></div>
<div class="go-stat"><b id="goKills">0</b><label>KILLS</label></div>
<div class="go-stat"><b id="goWave">1</b><label>WAVE</label></div>
</div>
<button id="restartBtn" class="primary">RELAUNCH <span>↗</span></button>
</div>
</div>
<div id="loading">
<div class="load-mark">NEREID<span>.</span></div>
<div class="load-bar"><i></i></div>
<div class="load-text">Charting the black tide</div>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
/* =========================================================================
NEREID — procedural ocean, buoyancy physics, combat.
Everything below is intentionally self-contained.
========================================================================= */
const TAU = Math.PI * 2;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const lerp = (a, b, t) => a + (b - a) * t;
const smoothstep = (a, b, x) => { const t = clamp((x - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
const rand = (a = 0, b = 1) => a + Math.random() * (b - a);
const angleDelta = (a, b) => { let d = (b - a + Math.PI) % TAU; if (d < 0) d += TAU; return d - Math.PI; };
const damp = (a, b, lambda, dt) => lerp(a, b, 1 - Math.exp(-lambda * dt));
const smoothAngle = (a, b, lambda, dt) => a + angleDelta(a, b) * (1 - Math.exp(-lambda * dt));
function hash2(x, y) {
let h = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;
return h - Math.floor(h);
}
function noise2(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
const a = hash2(xi, yi), b = hash2(xi + 1, yi), c = hash2(xi, yi + 1), d = hash2(xi + 1, yi + 1);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function fbm(x, y, octaves = 4) {
let value = 0, amp = 0.5, norm = 0;
for (let i = 0; i < octaves; i++) {
value += amp * noise2(x, y);
norm += amp;
x = x * 2.03 + 17.17;
y = y * 2.03 - 9.31;
amp *= 0.5;
}
return value / norm;
}
function pnoise(x, y, period) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
const wrap = (n, p) => ((n % p) + p) % p;
const a = hash2(wrap(xi, period), wrap(yi, period));
const b = hash2(wrap(xi + 1, period), wrap(yi, period));
const c = hash2(wrap(xi, period), wrap(yi + 1, period));
const d = hash2(wrap(xi + 1, period), wrap(yi + 1, period));
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
function pfbm(x, y, octaves = 5, basePeriod = 4) {
let value = 0, amp = 0.5, norm = 0, f = 1;
for (let i = 0; i < octaves; i++) {
value += amp * pnoise(x * f, y * f, basePeriod * f);
norm += amp;
amp *= 0.5;
f *= 2;
}
return value / norm;
}
/* ── procedural textures ──────────────────────────────────────────────── */
function generateFoamTexture() {
const W = 512, H = 512;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const u = x / W, v = y / H;
const n = pfbm(u * 4, v * 4, 5, 4);
const ridge = 1 - Math.abs(n * 2 - 1);
const fine = pfbm(u * 13 + 5.1, v * 13 - 3.7, 3, 13);
let a = smoothstep(0.57, 0.76, n) * 0.85;
a += smoothstep(0.76, 0.92, ridge) * 0.22;
a += smoothstep(0.69, 0.84, fine) * 0.13;
a = clamp(a, 0, 1);
const idx = (y * W + x) * 4;
img.data[idx] = 255;
img.data[idx + 1] = 255;
img.data[idx + 2] = 255;
img.data[idx + 3] = Math.round(a * 255);
}
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.RepeatWrapping;
tex.flipY = false;
tex.colorSpace = THREE.NoColorSpace;
tex.anisotropy = 8;
return tex;
}
function generateCloudTexture() {
const W = 1024, H = 512;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let y = 0; y < H; y++) {
const v = (y + 0.5) / H;
const lat = (v - 0.5) * Math.PI;
const cl = Math.cos(lat), dy = Math.sin(lat);
for (let x = 0; x < W; x++) {
const lon = ((x + 0.5) / W) * TAU;
const dx = cl * Math.cos(lon), dz = cl * Math.sin(lon);
let a = 0;
if (dy > 0.012) {
const inv = 1 / (dy + 0.18);
const px = dx * inv * 2.45, pz = dz * inv * 2.45;
const warpX = fbm(px * 0.42 + 12.3, pz * 0.42 - 8.1, 3);
const warpZ = fbm(px * 0.42 - 5.7, pz * 0.42 + 3.9, 3);
const broad = fbm(px * 1.55 + warpX * 1.6 + 6.4, pz * 1.55 + warpZ * 1.6 - 2.7, 5);
const detail = fbm(px * 5.0 + warpZ * 0.8 - 9.2, pz * 5.0 + warpX * 0.8 + 3.4, 4);
const val = broad * 0.68 + detail * 0.32;
a = smoothstep(0.49, 0.645, val);
a *= smoothstep(0.018, 0.145, dy);
a *= 0.68 + 0.32 * smoothstep(0.42, 0.7, detail);
}
const idx = (y * W + x) * 4;
img.data[idx] = 255;
img.data[idx + 1] = 249;
img.data[idx + 2] = 243;
img.data[idx + 3] = Math.round(clamp(a, 0, 1) * 255);
}
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = THREE.RepeatWrapping;
tex.wrapT = THREE.ClampToEdgeWrapping;
tex.flipY = false;
tex.colorSpace = THREE.NoColorSpace;
tex.anisotropy = 4;
return tex;
}
function generateWaterNormalTexture() {
const W = 512, H = 512;
const heights = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const u = (x / W) * 8, v = (y / H) * 8;
let h = pfbm(u, v, 5, 8);
h += pfbm(u * 2.7 + 4.1, v * 2.7 - 2.3, 3, 22) * 0.24;
heights[y * W + x] = h;
}
}
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
const at = (x, y) => heights[((y + H) % H) * W + ((x + W) % W)];
const strength = 9.5;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const dx = (at(x + 1, y) - at(x - 1, y)) * strength;
const dy = (at(x, y + 1) - at(x, y - 1)) * strength;
const len = Math.hypot(dx, dy, 1);
const nx = -dx / len, ny = -dy / len, nz = 1 / len;
const i = (y * W + x) * 4;
img.data[i] = Math.round((nx * 0.5 + 0.5) * 255);
img.data[i + 1] = Math.round((ny * 0.5 + 0.5) * 255);
img.data[i + 2] = Math.round((nz * 0.5 + 0.5) * 255);
img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.flipY = false;
tex.colorSpace = THREE.NoColorSpace;
tex.anisotropy = 8;
return tex;
}
function generateTeakTexture() {
const W = 512, H = 512;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#5a3d28';
ctx.fillRect(0, 0, W, H);
const planks = 9;
for (let i = 0; i < planks; i++) {
const x = (i / planks) * W;
const w = W / planks;
const tone = 74 + Math.floor(hash2(i, 3) * 22);
ctx.fillStyle = `rgb(${tone},${Math.floor(tone * 0.68)},${Math.floor(tone * 0.44)})`;
ctx.fillRect(x + 1, 0, w - 2, H);
ctx.strokeStyle = 'rgba(24,14,8,.5)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(x + 0.75, 0);
ctx.lineTo(x + 0.75, H);
ctx.stroke();
for (let g = 0; g < 18; g++) {
const gx = x + 4 + hash2(i, g) * (w - 8);
ctx.strokeStyle = `rgba(${20 + hash2(g, i) * 28},${12 + hash2(g, i + 2) * 18},${6 + hash2(g, i + 4) * 10},${0.1 + hash2(g, i + 7) * 0.15})`;
ctx.lineWidth = 0.6 + hash2(g, i + 9) * 1.2;
ctx.beginPath();
ctx.moveTo(gx, 0);
ctx.bezierCurveTo(gx + 8, H * 0.25, gx - 5, H * 0.68, gx + 3, H);
ctx.stroke();
}
}
const tex = new THREE.CanvasTexture(canvas);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 8;
return tex;
}
function makeSoftSpriteTexture(size = 128, warm = false) {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d');
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
if (warm) {
g.addColorStop(0, 'rgba(255,255,242,1)');
g.addColorStop(0.08, 'rgba(255,244,206,1)');
g.addColorStop(0.24, 'rgba(255,185,104,.8)');
g.addColorStop(0.52, 'rgba(255,121,45,.2)');
g.addColorStop(1, 'rgba(255,80,20,0)');
} else {
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.22, 'rgba(255,255,255,.85)');
g.addColorStop(0.55, 'rgba(225,245,248,.3)');
g.addColorStop(1, 'rgba(210,240,245,0)');
}
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function makeSkullDecal() {
const S = 256;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = S;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, S, S);
ctx.translate(S / 2, S / 2);
ctx.strokeStyle = 'rgba(235,223,198,.82)';
ctx.fillStyle = 'rgba(235,223,198,.82)';
ctx.lineWidth = 8;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, -24, 62, Math.PI * 0.05, Math.PI * 0.95, false);
ctx.arc(0, -24, 62, Math.PI * 1.05, Math.PI * 1.95, false);
ctx.stroke();
ctx.beginPath();
ctx.ellipse(-26, -31, 16, 20, -0.2, 0, TAU);
ctx.ellipse(26, -31, 16, 20, 0.2, 0, TAU);
ctx.fillStyle = 'rgba(10,14,14,.96)';
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, -8);
ctx.lineTo(9, 14);
ctx.lineTo(-9, 14);
ctx.closePath();
ctx.fill();
ctx.fillStyle = 'rgba(235,223,198,.82)';
for (let i = -3; i <= 3; i++) {
ctx.fillRect(i * 13 - 4, 18, 8, 28);
}
ctx.strokeStyle = 'rgba(235,223,198,.82)';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(-54, 53); ctx.lineTo(-90, 93);
ctx.moveTo(54, 53); ctx.lineTo(90, 93);
ctx.stroke();
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
return tex;
}
function makeNameDecal(text, sub) {
const W = 512, H = 160;
const canvas = document.createElement('canvas');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(228,221,198,.92)';
ctx.font = '600 60px "Barlow Condensed", sans-serif';
ctx.letterSpacing = '10px';
ctx.fillText(text, W / 2, 92);
ctx.fillStyle = 'rgba(255,177,95,.75)';
ctx.fillRect(W / 2 - 64, 108, 128, 2);
if (sub) {
ctx.fillStyle = 'rgba(214,231,231,.46)';
ctx.font = '500 18px "Inter", sans-serif';
ctx.letterSpacing = '7px';
ctx.fillText(sub, W / 2, 136);
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
return tex;
}
const TEX = {
foam: null,
waterNormal: null,
clouds: null,
soft: null,
sun: null,
teak: null,
skull: null,
name: null
};
/* ── renderer / scene ─────────────────────────────────────────────────── */
const canvas = document.getElementById('scene');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.02;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setClearColor(0x09141b, 1);
const scene = new THREE.Scene();
const FOG_COLOR = new THREE.Color(0x8a999e);
scene.fog = new THREE.FogExp2(FOG_COLOR, 0.00135);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 9000);
camera.position.set(0, 6, -17);
const sunDir = new THREE.Vector3(-0.46, 0.155, 0.873).normalize();
const sunColor = new THREE.Color(0xffd1a0);
const skyColor = new THREE.Color(0x9ab9cd);
const hemi = new THREE.HemisphereLight(0xa7c6dc, 0x13242a, 1.16);
scene.add(hemi);
const sunLight = new THREE.DirectionalLight(0xffc58e, 3.2);
sunLight.position.copy(sunDir).multiplyScalar(160);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.near = 10;
sunLight.shadow.camera.far = 360;
sunLight.shadow.camera.left = -48;
sunLight.shadow.camera.right = 48;
sunLight.shadow.camera.top = 48;
sunLight.shadow.camera.bottom = -48;
sunLight.shadow.bias = -0.0004;
sunLight.shadow.normalBias = 0.035;
scene.add(sunLight);
scene.add(sunLight.target);
const fillLight = new THREE.DirectionalLight(0x7d9fba, 1.34);
fillLight.position.set(0.45, 0.5, -0.75);
scene.add(fillLight);
const cockpitLight = new THREE.PointLight(0xffc07a, 18.0, 12, 2.0);
cockpitLight.position.set(0, 1.25, -0.55);
scene.add(cockpitLight);
/* ── sky ──────────────────────────────────────────────────────────────── */
TEX.clouds = generateCloudTexture();
const skyMaterial = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
depthTest: false,
uniforms: {
uSunDir: { value: sunDir },
uSunColor: { value: new THREE.Color(0xffb774) },
uClouds: { value: TEX.clouds },
uCloudOffset: { value: 0 },
uTime: { value: 0 },
uExposure: { value: 1.02 },
uOutput: { value: 1 }
},
vertexShader: /* glsl */`
varying vec3 vDir;
void main(){
vec4 wp = modelMatrix * vec4(position, 1.0);
vDir = normalize(wp.xyz - cameraPosition);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */`
precision highp float;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform sampler2D uClouds;
uniform float uCloudOffset;
uniform float uTime;
uniform float uExposure;
uniform float uOutput;
varying vec3 vDir;
vec3 aces(vec3 x){
return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0.0, 1.0);
}
vec3 toSRGB(vec3 c){
return mix(1.055*pow(max(c,vec3(0.0)), vec3(1.0/2.4))-0.055, c*12.92, lessThanEqual(c,vec3(0.0031308)));
}
float hash12(vec2 p){
vec3 p3 = fract(vec3(p.xyx) * .1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void main(){
vec3 dir = normalize(vDir);
float up = max(dir.y, 0.0);
float h = pow(1.0 - up, 3.2);
vec3 zenith = vec3(0.012, 0.045, 0.12);
vec3 mid = vec3(0.09, 0.19, 0.33);
vec3 horizon = vec3(0.72, 0.38, 0.23);
vec3 col = mix(zenith, mid, smoothstep(0.0, 0.62, up));
col = mix(col, horizon, h * 0.96);
float sd = max(dot(dir, uSunDir), 0.0);
float horizonBand = pow(1.0 - up, 5.0);
col += uSunColor * (pow(sd, 5.0) * 0.32 + pow(sd, 28.0) * 0.82 + pow(sd, 140.0) * 1.9);
col += vec3(0.42, 0.17, 0.05) * horizonBand * (0.35 + pow(sd, 2.0) * 0.9);
// sun disk
float disk = smoothstep(0.99945, 0.99972, sd);
col += vec3(1.9, 1.48, 0.96) * disk * 22.0;
col += uSunColor * pow(sd, 1200.0) * 4.0;
// clouds projected on an imaginary high plane
if (dir.y > 0.012 && uCloudOffset >= 0.0) {
float lon = atan(dir.z, dir.x) * 0.15915494 + 0.5;
float lat = asin(clamp(dir.y, -1.0, 1.0)) * 0.31830989 + 0.48;
float c1 = texture2D(uClouds, vec2(lon * 2.0 + uCloudOffset, lat)).a;
float c2 = texture2D(uClouds, vec2(lon * 3.0 - uCloudOffset * 1.7 + 0.37, lat * 1.7 - 0.05)).a;
float cloud = smoothstep(0.15, 0.66, c1 * 0.78 + c2 * 0.39);
cloud *= smoothstep(0.012, 0.1, dir.y);
cloud *= 1.0 - smoothstep(0.82, 1.0, up) * 0.2;
float lit = pow(sd, 2.2);
vec3 shade = vec3(0.21, 0.21, 0.26);
vec3 light = vec3(1.48, 0.82, 0.46);
vec3 cloudCol = mix(shade, light, 0.2 + lit * 0.8);
cloudCol += uSunColor * pow(sd, 14.0) * 1.45 * smoothstep(0.16, 0.7, c1);
cloudCol += vec3(0.2, 0.2, 0.25) * pow(1.0 - up, 3.0);
float cloudStrength = uOutput > 0.5 ? 0.76 : 0.27;
col = mix(col, cloudCol, cloud * cloudStrength);
}
// faint high haze
col += vec3(0.035, 0.05, 0.06) * pow(1.0 - up, 9.0);
col *= 1.0;
if (uOutput > 0.5) {
col = aces(col * uExposure);
col = toSRGB(col);
} else {
col = max(col, vec3(0.0));
}
// subtle dither
col += (hash12(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(col, 1.0);
}
`
});
const skyGeo = new THREE.SphereGeometry(5000, 64, 32);
const skyMesh = new THREE.Mesh(skyGeo, skyMaterial);
skyMesh.frustumCulled = false;
skyMesh.renderOrder = -2000;
scene.add(skyMesh);
TEX.sun = makeSoftSpriteTexture(512, true);
const sunGlow = new THREE.Sprite(new THREE.SpriteMaterial({
map: TEX.sun, color: 0xffcf9a, transparent: true, opacity: 0.48,
blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true, fog: false
}));
sunGlow.scale.set(540, 540, 1);
sunGlow.renderOrder = -1000;
scene.add(sunGlow);
const sunCore = new THREE.Sprite(new THREE.SpriteMaterial({
map: TEX.sun, color: 0xfff0ce, transparent: true, opacity: 0.82,
blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true, fog: false
}));
sunCore.scale.set(160, 160, 1);
sunCore.renderOrder = -999;
scene.add(sunCore);
/* environment for PBR: bake the procedural sky */
{
const pmrem = new THREE.PMREMGenerator(renderer);
const skyScene = new THREE.Scene();
const envSky = new THREE.Mesh(skyGeo, skyMaterial);
envSky.frustumCulled = false;
skyScene.add(envSky);
skyMaterial.uniforms.uOutput.value = 0;
const envRT = pmrem.fromScene(skyScene, 0, 0.1, 12000);
scene.environment = envRT.texture;
scene.environmentIntensity = 0.9;
skyMaterial.uniforms.uOutput.value = 1;
pmrem.dispose();
}
/* ── waves ────────────────────────────────────────────────────────────── */
const WAVES = [
{ dx: 0.951, dz: 0.309, amp: 0.58, len: 62.0, phase: 1.3, harm: 0.12, hphase: 0.4 },
{ dx: -0.276, dz: 0.961, amp: 0.34, len: 37.0, phase: 4.1, harm: 0.11, hphase: 2.0 },
{ dx: 0.623, dz: -0.782, amp: 0.22, len: 22.5, phase: 2.2, harm: 0.10, hphase: 5.0 },
{ dx: -0.882, dz: 0.471, amp: 0.105, len: 13.0, phase: 0.8, harm: 0.08, hphase: 1.1 },
{ dx: 0.147, dz: 0.989, amp: 0.055, len: 7.4, phase: 5.3, harm: 0.06, hphase: 3.3 },
{ dx: -0.655, dz: -0.756, amp: 0.028, len: 4.6, phase: 3.7, harm: 0.05, hphase: 0.9 }
].map(w => {
const k = TAU / w.len;
return { ...w, k, omega: Math.sqrt(9.81 * k) };
});
const WAVE_SCALE = 1.28;
const rogue = {
amp: 0, target: 0, dir: new THREE.Vector2(0.7, 0.7), k: TAU / 72, omega: 0,
offset: 0, active: false, timer: 0, next: 13, warned: false
};
rogue.omega = Math.sqrt(9.81 * rogue.k);
function waveHeight(x, z, t) {
let h = 0;
for (let i = 0; i < WAVES.length; i++) {
const w = WAVES[i];
const ph = (x * w.dx + z * w.dz) * w.k - t * w.omega + w.phase;
h += w.amp * (Math.sin(ph) + w.harm * Math.sin(2 * ph + w.hphase));
}
h += rogue.amp * Math.sin((x * rogue.dir.x + z * rogue.dir.y) * rogue.k - t * rogue.omega + rogue.offset);
return h * WAVE_SCALE;
}
function waveVerticalSpeed(x, z, t) {
let v = 0;
for (let i = 0; i < WAVES.length; i++) {
const w = WAVES[i];
const ph = (x * w.dx + z * w.dz) * w.k - t * w.omega + w.phase;
v -= w.omega * w.amp * (Math.cos(ph) + 2 * w.harm * Math.cos(2 * ph + w.hphase));
}
v -= rogue.amp * rogue.omega * Math.cos((x * rogue.dir.x + z * rogue.dir.y) * rogue.k - t * rogue.omega + rogue.offset);
return v * WAVE_SCALE;
}
function waveNormal(x, z, t, out = new THREE.Vector3()) {
let gx = 0, gz = 0;
for (let i = 0; i < WAVES.length; i++) {
const w = WAVES[i];
const ph = (x * w.dx + z * w.dz) * w.k - t * w.omega + w.phase;
const d = w.amp * w.k * (Math.cos(ph) + 2 * w.harm * Math.cos(2 * ph + w.hphase));
gx += w.dx * d;
gz += w.dz * d;
}
const rp = (x * rogue.dir.x + z * rogue.dir.y) * rogue.k - t * rogue.omega + rogue.offset;
const rd = rogue.amp * rogue.k * Math.cos(rp);
gx += rogue.dir.x * rd;
gz += rogue.dir.y * rd;
return out.set(-gx * WAVE_SCALE, 1, -gz * WAVE_SCALE).normalize();
}
/* Shader wave code generated from the same constants. */
const waveShaderCode = WAVES.map(w => `
ph = dot(xz, vec2(${w.dx.toFixed(6)}, ${w.dz.toFixed(6)})) * ${w.k.toFixed(7)} - t * ${w.omega.toFixed(7)} + ${w.phase.toFixed(6)};
h += ${w.amp.toFixed(6)} * (sin(ph) + ${w.harm.toFixed(6)} * sin(2.0 * ph + ${w.hphase.toFixed(6)}));
grad += vec2(${w.dx.toFixed(6)}, ${w.dz.toFixed(6)}) * ${(w.amp * w.k).toFixed(8)} * (cos(ph) + ${(2 * w.harm).toFixed(6)} * cos(2.0 * ph + ${w.hphase.toFixed(6)}));
`).join('\n');
/* ── ocean ────────────────────────────────────────────────────────────── */
TEX.foam = generateFoamTexture();
TEX.waterNormal = generateWaterNormalTexture();
function createOceanGeometry(half = 1450, segments = 400, power = 1.32) {
const positions = [], uvs = [], indices = [];
for (let j = 0; j <= segments; j++) {
const v = (j / segments) * 2 - 1;
const z = Math.sign(v) * Math.pow(Math.abs(v), power) * half;
for (let i = 0; i <= segments; i++) {
const u = (i / segments) * 2 - 1;
const x = Math.sign(u) * Math.pow(Math.abs(u), power) * half;
positions.push(x, 0, z);
uvs.push(i / segments, j / segments);
}
}
for (let j = 0; j < segments; j++) {
for (let i = 0; i < segments; i++) {
const a = j * (segments + 1) + i;
const b = a + 1;
const c = a + segments + 1;
const d = c + 1;
indices.push(a, c, b, b, c, d);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices);
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), half * 1.5);
return geo;
}
const reflectionRT = new THREE.WebGLRenderTarget(2, 2, {
type: THREE.HalfFloatType,
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
depthBuffer: true,
stencilBuffer: false
});
reflectionRT.texture.generateMipmaps = false;
const reflectionCamera = new THREE.PerspectiveCamera();
const reflectionClip = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0.02);
const waterUniforms = {
uTime: { value: 0 },
uWaveScale: { value: WAVE_SCALE },
uSunDir: { value: sunDir },
uSunColor: { value: new THREE.Color(0xffc58e) },
uReflection: { value: reflectionRT.texture },
uTextureMatrix: { value: new THREE.Matrix4() },
uFoamTex: { value: TEX.foam },
uWaterNormal: { value: TEX.waterNormal },
uFoamStrength: { value: 1.65 },
uReflectionStrength: { value: 1.0 },
uCamPos: { value: new THREE.Vector3() },
uBoatPos: { value: new THREE.Vector3() },
uBoatSpeed: { value: 0 },
uBoatShadow: { value: 1 },
uResolution: { value: new THREE.Vector2(1, 1) },
uFogColor: { value: new THREE.Color(0x8a999e) },
uFogDensity: { value: 0.00135 },
uExposure: { value: 1.02 },
uOutput: { value: 1 },
uRogueDir: { value: rogue.dir },
uRogueAmp: { value: 0 },
uRogueK: { value: rogue.k },
uRogueOmega: { value: rogue.omega },
uRogueOffset: { value: 0 }
};
const waterMaterial = new THREE.ShaderMaterial({
uniforms: waterUniforms,
vertexShader: /* glsl */`
precision highp float;
uniform float uTime;
uniform float uWaveScale;
uniform vec2 uRogueDir;
uniform float uRogueAmp;
uniform float uRogueK;
uniform float uRogueOmega;
uniform float uRogueOffset;
uniform mat4 uTextureMatrix;
varying vec3 vWorldPos;
varying vec3 vNormalW;
varying vec4 vReflectCoord;
varying float vHeight;
void main(){
vec3 p = position;
vec3 wp = (modelMatrix * vec4(p, 1.0)).xyz;
vec2 xz = wp.xz;
float t = uTime;
float h = 0.0;
vec2 grad = vec2(0.0);
float ph;
${waveShaderCode}
float rp = dot(xz, uRogueDir) * uRogueK - t * uRogueOmega + uRogueOffset;
h += uRogueAmp * sin(rp);
grad += uRogueDir * uRogueAmp * uRogueK * cos(rp);
h *= uWaveScale;
grad *= uWaveScale;
wp.y += h;
vHeight = h;
vWorldPos = wp;
vNormalW = normalize(vec3(-grad.x, 1.0, -grad.y));
vReflectCoord = uTextureMatrix * vec4(wp.x, 0.0, wp.z, 1.0);
gl_Position = projectionMatrix * viewMatrix * vec4(wp, 1.0);
}
`,
fragmentShader: /* glsl */`
precision highp float;
uniform float uTime;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform sampler2D uReflection;
uniform sampler2D uFoamTex;
uniform sampler2D uWaterNormal;
uniform float uFoamStrength;
uniform float uReflectionStrength;
uniform vec3 uCamPos;
uniform vec3 uBoatPos;
uniform float uBoatSpeed;
uniform float uBoatShadow;
uniform vec3 uFogColor;
uniform float uFogDensity;
uniform float uExposure;
uniform float uOutput;
varying vec3 vWorldPos;
varying vec3 vNormalW;
varying vec4 vReflectCoord;
varying float vHeight;
vec3 aces(vec3 x){
return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0.0, 1.0);
}
vec3 toSRGB(vec3 c){
return mix(1.055*pow(max(c,vec3(0.0)), vec3(1.0/2.4))-0.055, c*12.92, lessThanEqual(c,vec3(0.0031308)));
}
void main(){
vec2 p = vWorldPos.xz;
vec3 toCam = uCamPos - vWorldPos;
float dist = length(toCam);
vec3 V = toCam / max(dist, 0.001);
// organic capillary ripples from two scrolling normal-map layers
float detailFade = 1.0 / (1.0 + dist * 0.012);
vec3 n1 = texture2D(uWaterNormal, p * 0.052 + vec2(uTime * 0.006, uTime * 0.0035)).rgb * 2.0 - 1.0;
vec3 n2 = texture2D(uWaterNormal, p * 0.138 + vec2(-uTime * 0.009, uTime * 0.0045)).rgb * 2.0 - 1.0;
vec3 n3 = texture2D(uWaterNormal, p * 0.31 + vec2(uTime * 0.012, -uTime * 0.008)).rgb * 2.0 - 1.0;
vec2 ripple = (n1.xy + n2.xy * 0.62 + n3.xy * 0.27) * detailFade * 0.92;
ripple += vec2(cos(dot(p, vec2(0.71, -0.70)) * 6.3 - uTime * 3.1), sin(dot(p, vec2(-0.42, 0.91)) * 4.8 - uTime * 2.7)) * 0.014 * detailFade;
vec3 N = normalize(vNormalW + vec3(ripple.x, 0.0, ripple.y));
float NdV = max(dot(N, V), 0.0);
float fresnel = 0.015 + 0.925 * pow(1.0 - NdV, 5.0);
float distFar = smoothstep(16.0, 320.0, dist);
// mirrored scene (soft taps to keep cloud reflections from turning into ice floes)
vec2 ruv = vReflectCoord.xy / max(vReflectCoord.w, 0.0001);
float j1 = texture2D(uFoamTex, p * 0.095 + vec2(uTime * 0.006, 0.0)).a;
float j2 = texture2D(uFoamTex, p * 0.21 - vec2(0.0, uTime * 0.004)).a;
vec2 jitter = vec2(j1, j2) - 0.5;
vec2 distortion = N.xz * mix(0.065, 0.26, distFar);
ruv += distortion + jitter * 0.02 * distFar;
float blur = 1.0 + distFar * 10.0;
vec2 texel = vec2(1.1, 1.8) / vec2(1024.0, 576.0) * blur;
vec3 refl = texture2D(uReflection, clamp(ruv, 0.001, 0.999)).rgb * 0.26;
refl += texture2D(uReflection, clamp(ruv + vec2(texel.x, 0.0), 0.001, 0.999)).rgb * 0.14;
refl += texture2D(uReflection, clamp(ruv - vec2(texel.x, 0.0), 0.001, 0.999)).rgb * 0.14;
refl += texture2D(uReflection, clamp(ruv + vec2(0.0, texel.y), 0.001, 0.999)).rgb * 0.14;
refl += texture2D(uReflection, clamp(ruv - vec2(0.0, texel.y), 0.001, 0.999)).rgb * 0.14;
refl += texture2D(uReflection, clamp(ruv + texel, 0.001, 0.999)).rgb * 0.045;
refl += texture2D(uReflection, clamp(ruv - texel, 0.001, 0.999)).rgb * 0.045;
refl += texture2D(uReflection, clamp(ruv + vec2(texel.x, -texel.y), 0.001, 0.999)).rgb * 0.045;
refl += texture2D(uReflection, clamp(ruv + vec2(-texel.x, texel.y), 0.001, 0.999)).rgb * 0.045;
refl = mix(refl, vec3(0.09, 0.135, 0.145), 0.07);
refl = refl / (1.0 + refl * 0.78);
// water body
float crest = smoothstep(-0.22, 0.95, vHeight);
vec3 deep = vec3(0.004, 0.019, 0.027);
vec3 shallow = vec3(0.019, 0.086, 0.094);
vec3 body = mix(deep, shallow, 0.42 + crest * 0.58);
body += vec3(0.015, 0.075, 0.067) * crest * pow(max(dot(-uSunDir, V), 0.0), 2.0) * 0.8;
body += vec3(0.015, 0.038, 0.043);
// boat contact shadow
float sd = distance(p, uBoatPos.xz);
float shadow = smoothstep(7.5, 1.0, sd) * uBoatShadow;
body *= 1.0 - shadow * 0.36;
// wind streaks / foam texture
float f1 = texture2D(uFoamTex, p * 0.085 + vec2(uTime * 0.0035, -uTime * 0.0018)).a;
float f2 = texture2D(uFoamTex, p * 0.24 + vec2(-uTime * 0.008, uTime * 0.004)).a;
float foamNoise = f1 * 0.7 + f2 * 0.5;
float foamMask = smoothstep(0.34, 0.7, foamNoise);
float steep = clamp(length(vNormalW.xz) * 4.4, 0.0, 1.0);
float crestFoam = smoothstep(0.56, 1.12, vHeight) * foamMask * (0.18 + steep * 0.78);
crestFoam += smoothstep(0.66, 0.92, foamNoise) * smoothstep(0.78, 1.12, crest) * 0.24;
crestFoam += smoothstep(0.86, 0.995, steep) * foamMask * 0.14;
crestFoam = clamp(crestFoam, 0.0, 1.0) * uFoamStrength;
// churn around the hull
float boatDist = distance(p, uBoatPos.xz);
float hullRing = smoothstep(4.7, 2.2, boatDist) * smoothstep(0.8, 1.9, boatDist);
float hullFoam = hullRing * smoothstep(0.34, 0.74, foamNoise) * (0.38 + clamp(uBoatSpeed * 0.03, 0.0, 0.62));
crestFoam = clamp(crestFoam + hullFoam * 0.58 * uFoamStrength, 0.0, 1.0);
vec3 foamColor = vec3(0.87, 0.94, 0.94);
foamColor *= mix(0.5, 1.0, smoothstep(-0.1, 0.35, vHeight));
vec3 col = mix(body, refl * vec3(0.9, 0.96, 1.0), fresnel * uReflectionStrength);
col = mix(col, foamColor, crestFoam * 0.62);
// sun specular + glitter
vec3 H = normalize(uSunDir + V);
float NdH = max(dot(N, H), 0.0);
float spec = pow(NdH, 1100.0) * 42.0 + pow(NdH, 150.0) * 0.55 + pow(NdH, 24.0) * 0.035;
float glitter = smoothstep(0.58, 0.82, f2) * pow(NdH, 260.0) * 2.2;
col += uSunColor * (spec + glitter) * (0.9 + crestFoam * 0.2);
// rim light on crests
col += vec3(0.05, 0.14, 0.13) * crest * pow(1.0 - NdV, 2.0) * 0.65;
// distance fog
float fog = 1.0 - exp(-pow(dist * uFogDensity, 2.0));
col = mix(col, uFogColor, clamp(fog, 0.0, 1.0));
if (uOutput > 0.5) {
col = aces(col * uExposure);
col = toSRGB(col);
}
gl_FragColor = vec4(col, 1.0);
}
`
});
const oceanGeo = createOceanGeometry();
const ocean = new THREE.Mesh(oceanGeo, waterMaterial);
ocean.frustumCulled = false;
ocean.renderOrder = 1;
scene.add(ocean);
/* ── wake / particle system ───────────────────────────────────────────── */
class ParticlePool {
constructor(max, texture, blending, baseColor = 0xffffff) {
this.max = max;
this.particles = [];
this.positions = new Float32Array(max * 3);
this.colors = new Float32Array(max * 3);
this.sizes = new Float32Array(max);
this.alphas = new Float32Array(max);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('aColor', new THREE.BufferAttribute(this.colors, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('aSize', new THREE.BufferAttribute(this.sizes, 1).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('aAlpha', new THREE.BufferAttribute(this.alphas, 1).setUsage(THREE.DynamicDrawUsage));
this.geometry = geo;
this.material = new THREE.ShaderMaterial({
uniforms: {
uTex: { value: texture },
uScale: { value: 700 },
uExposure: { value: 1.02 }
},
transparent: true,
depthWrite: false,
blending,
vertexShader: /* glsl */`
attribute vec3 aColor;
attribute float aSize;
attribute float aAlpha;
uniform float uScale;
varying vec3 vColor;
varying float vAlpha;
void main(){
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mv;
gl_PointSize = clamp(aSize * uScale / max(1.0, -mv.z), 1.0, 512.0);
vColor = aColor;
vAlpha = aAlpha;
}
`,
fragmentShader: /* glsl */`
precision highp float;
uniform sampler2D uTex;
uniform float uExposure;
varying vec3 vColor;
varying float vAlpha;
vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14),0.0,1.0); }
vec3 toSRGB(vec3 c){ return mix(1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,c*12.92,lessThanEqual(c,vec3(0.0031308))); }
void main(){
vec4 tex = texture2D(uTex, gl_PointCoord);
float a = tex.a * vAlpha;
if (a < 0.008) discard;
vec3 c = aces(vColor * uExposure);
c = toSRGB(c);
gl_FragColor = vec4(c, a);
}
`
});
this.points = new THREE.Points(geo, this.material);
this.points.frustumCulled = false;
this.points.renderOrder = 5;
scene.add(this.points);
this.points.visible = true;
}
spawn(o) {
if (this.particles.length >= this.max) return;
this.particles.push({
x: o.x, y: o.y, z: o.z,
vx: o.vx || 0, vy: o.vy || 0, vz: o.vz || 0,
life: o.life || 1, age: 0,
size: o.size || 1, grow: o.grow || 0,
gravity: o.gravity || 0, drag: o.drag || 0,
snap: !!o.snap, alpha: o.alpha ?? 0.7,
color: o.color ? new THREE.Color(o.color) : new THREE.Color(0xffffff),
fadeIn: o.fadeIn ?? 0.08
});
}
update(dt, t) {
const arr = this.particles;
for (let i = arr.length - 1; i >= 0; i--) {
const p = arr[i];
p.age += dt;
if (p.age >= p.life) { arr.splice(i, 1); continue; }
const drag = Math.exp(-p.drag * dt);
p.vx *= drag; p.vz *= drag;
p.vy = p.vy * drag + p.gravity * dt;
p.x += p.vx * dt;
p.y += p.vy * dt;
p.z += p.vz * dt;
if (p.snap) {
const wh = waveHeight(p.x, p.z, t);
if (p.y < wh + 0.03) { p.y = wh + 0.03; p.vy = 0; }
}
p.size += p.grow * dt;
}
const n = arr.length;
for (let i = 0; i < n; i++) {
const p = arr[i];
const i3 = i * 3;
this.positions[i3] = p.x; this.positions[i3 + 1] = p.y; this.positions[i3 + 2] = p.z;
this.colors[i3] = p.color.r; this.colors[i3 + 1] = p.color.g; this.colors[i3 + 2] = p.color.b;
this.sizes[i] = Math.max(0.01, p.size);
const lifeT = p.age / p.life;
const fadeIn = smoothstep(0, p.fadeIn, p.age);
const fadeOut = 1 - smoothstep(0.55, 1.0, lifeT);
this.alphas[i] = p.alpha * fadeIn * fadeOut;
}
this.geometry.setDrawRange(0, n);
this.geometry.attributes.position.needsUpdate = true;
this.geometry.attributes.aColor.needsUpdate = true;
this.geometry.attributes.aSize.needsUpdate = true;
this.geometry.attributes.aAlpha.needsUpdate = true;
}
clear() {
this.particles.length = 0;
this.geometry.setDrawRange(0, 0);
}
}
TEX.soft = makeSoftSpriteTexture(128, false);
const sprayPool = new ParticlePool(1600, TEX.soft, THREE.NormalBlending);
const firePool = new ParticlePool(900, TEX.soft, THREE.AdditiveBlending);
const smokePool = new ParticlePool(500, TEX.soft, THREE.NormalBlending);
sprayPool.material.depthTest = false;
sprayPool.material.needsUpdate = true;
sprayPool.points.renderOrder = 6;
// A continuous foam ribbon gives the boat a proper turbulent wake.
class WakeTrail {
constructor(max = 110) {
this.max = max;
this.points = [];
this.positions = new Float32Array(max * 6 * 3);
this.colors = new Float32Array(max * 6 * 4);
this.uvs = new Float32Array(max * 6 * 2);
const indices = [];
for (let ribbon = 0; ribbon < 3; ribbon++) {
for (let i = 0; i < max - 1; i++) {
const a = i * 6 + ribbon * 2, b = a + 1, c = a + 6, d = c + 1;
indices.push(a, c, b, b, c, d);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(this.positions, 3).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('color', new THREE.BufferAttribute(this.colors, 4).setUsage(THREE.DynamicDrawUsage));
geo.setAttribute('uv', new THREE.BufferAttribute(this.uvs, 2).setUsage(THREE.DynamicDrawUsage));
geo.setIndex(indices);
geo.setDrawRange(0, 0);
this.geometry = geo;
this.material = new THREE.MeshBasicMaterial({
map: TEX.foam, color: 0xffffff, transparent: true, opacity: 0.9,
depthWrite: false, depthTest: true, side: THREE.DoubleSide,
vertexColors: true, toneMapped: false
});
this.mesh = new THREE.Mesh(geo, this.material);
this.mesh.frustumCulled = false;
this.mesh.renderOrder = 4;
scene.add(this.mesh);
}
add(x, z, rx, rz, speed, right) {
this.points.unshift({ x, z, rx, rz, speed, age: 0 });
if (this.points.length > this.max) this.points.pop();
}
update(dt, t) {
const pts = this.points;
for (let i = pts.length - 1; i >= 0; i--) {
pts[i].age += dt;
if (pts[i].age > 6.5) pts.splice(i, 1);
}
const n = pts.length;
if (n < 2) { this.geometry.setDrawRange(0, 0); return; }
for (let i = 0; i < n; i++) {
const p = pts[i];
const ageN = clamp(p.age / 6.5, 0, 1);
const width = 0.68 + ageN * 2.6 + Math.min(p.speed * 0.02, 0.55);
const y = waveHeight(p.x, p.z, t) + 0.035;
const a = (1 - ageN) * 0.94 + 0.06;
const armOffset = Math.sqrt(ageN) * 8.5;
const armWidth = 0.34 + ageN * 1.65;
const base = i * 18;
// central churn
const setV = (o, ox, oz, alpha, v) => {
this.positions[base + o] = ox;
this.positions[base + o + 1] = y;
this.positions[base + o + 2] = oz;
const ci = i * 24 + (o / 3) * 4;
this.colors[ci] = 0.92; this.colors[ci + 1] = 0.98; this.colors[ci + 2] = 0.98; this.colors[ci + 3] = alpha;
const ui = i * 12 + (o / 3) * 2;
this.uvs[ui] = o % 6 === 0 ? 0 : 1;
this.uvs[ui + 1] = v;
};
setV(0, p.x - p.rx * width, p.z - p.rz * width, a, i * 0.32);
setV(3, p.x + p.rx * width, p.z + p.rz * width, a, i * 0.32);
// port and starboard arms of the Kelvin wake
setV(6, p.x - p.rx * (armOffset + armWidth), p.z - p.rz * (armOffset + armWidth), a * 0.78, i * 0.25);
setV(9, p.x - p.rx * Math.max(0, armOffset - armWidth), p.z - p.rz * Math.max(0, armOffset - armWidth), a * 0.78, i * 0.25);
setV(12, p.x + p.rx * Math.max(0, armOffset - armWidth), p.z + p.rz * Math.max(0, armOffset - armWidth), a * 0.78, i * 0.25);
setV(15, p.x + p.rx * (armOffset + armWidth), p.z + p.rz * (armOffset + armWidth), a * 0.78, i * 0.25);
}
this.geometry.setDrawRange(0, (n - 1) * 18);
this.geometry.attributes.position.needsUpdate = true;
this.geometry.attributes.color.needsUpdate = true;
this.geometry.attributes.uv.needsUpdate = true;
}
clear() { this.points.length = 0; this.geometry.setDrawRange(0, 0); }
}
const wakeTrail = new WakeTrail();
function emitSpray(x, y, z, count, spread, up, size, color = 0xdff4f6) {
for (let i = 0; i < count; i++) {
const a = rand(0, TAU), r = rand(0, spread);
sprayPool.spawn({
x, y: y + rand(-0.1, 0.35), z,
vx: Math.cos(a) * r, vy: rand(up * 0.45, up), vz: Math.sin(a) * r,
life: rand(0.55, 1.35), size: rand(size * 0.55, size * 1.4), grow: rand(0.1, 0.5),
gravity: -9.8, drag: 0.8, alpha: rand(0.22, 0.62), color
});
}
}
function emitFire(x, y, z, count, power, colorA = 0xffdd88, colorB = 0xff4a1a) {
const cA = new THREE.Color(colorA), cB = new THREE.Color(colorB);
for (let i = 0; i < count; i++) {
const a = rand(0, TAU), b = rand(-0.3, 1.0);
const sp = rand(0.2, power);
firePool.spawn({
x, y, z,
vx: Math.cos(a) * Math.cos(b) * sp, vy: Math.sin(b) * sp + rand(0.2, 1.8), vz: Math.sin(a) * Math.cos(b) * sp,
life: rand(0.2, 0.65), size: rand(0.25, 1.0) * (power / 7), grow: rand(0.4, 2.0),
gravity: -2.5, drag: 1.2, alpha: rand(0.5, 1.0),
color: Math.random() < 0.5 ? cA : cB
});
}
}
function emitSmoke(x, y, z, count, power, dark = 0x1a1d1c) {
const col = new THREE.Color(dark);
for (let i = 0; i < count; i++) {
const a = rand(0, TAU);
smokePool.spawn({
x: x + rand(-0.6, 0.6), y: y + rand(-0.4, 0.8), z: z + rand(-0.6, 0.6),
vx: Math.cos(a) * rand(0.2, power * 0.4), vy: rand(0.15, 0.85), vz: Math.sin(a) * rand(0.2, power * 0.4),
life: rand(1.2, 2.8), size: rand(1.0, 2.4), grow: rand(0.7, 1.8),
gravity: 0.35, drag: 0.35, alpha: rand(0.1, 0.25), color: col
});
}
}
function emitWake(x, z, vx, vz, speed) {
const t = clockTime;
const n = clamp(Math.floor(speed * 0.1) + 1, 1, 3);
for (let i = 0; i < n; i++) {
const side = Math.random() < 0.5 ? -1 : 1;
const px = x + side * rand(0.2, 1.1);
const pz = z + rand(-0.25, 0.25);
sprayPool.spawn({
x: px, y: waveHeight(px, pz, t) + 0.12, z: pz,
vx: vx + side * rand(0.8, 2.8), vy: rand(0.08, 0.5), vz: vz + rand(-0.5, 0.5),
life: rand(1.8, 3.6), size: rand(0.55, 1.55), grow: rand(0.18, 0.5),
gravity: 0, drag: 1.15, snap: true, alpha: rand(0.16, 0.42), color: Math.random() < 0.2 ? 0xc1eef2 : 0xe8f6f6
});
}
}
/* ── flare sprites / explosions ───────────────────────────────────────── */
const fxSprites = [];
function spawnFlash(x, y, z, scale, color = 0xffb15c, life = 0.32, opacity = 0.9) {
const mat = new THREE.SpriteMaterial({
map: TEX.sun, color, transparent: true, opacity,
blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true, fog: false
});
const s = new THREE.Sprite(mat);
s.position.set(x, y, z);
s.scale.set(scale, scale, 1);
s.renderOrder = 8;
scene.add(s);
fxSprites.push({ sprite: s, life, age: 0, startScale: scale, opacity, kind: 'flash' });
return s;
}
const ringGeo = new THREE.RingGeometry(0.92, 1.0, 48);
function spawnShockRing(x, z, scale, color = 0xdff7fa, life = 0.8) {
const mat = new THREE.MeshBasicMaterial({
color, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending,
side: THREE.DoubleSide, depthWrite: false
});
const mesh = new THREE.Mesh(ringGeo, mat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(x, waveHeight(x, z, clockTime) + 0.12, z);
mesh.scale.setScalar(scale * 0.2);
scene.add(mesh);
fxSprites.push({ sprite: mesh, life, age: 0, startScale: scale, opacity: 0.5, kind: 'ring' });
}
function updateFX(dt) {
for (let i = fxSprites.length - 1; i >= 0; i--) {
const fx = fxSprites[i];
fx.age += dt;
const t = clamp(fx.age / fx.life, 0, 1);
if (t >= 1) {
scene.remove(fx.sprite);
fx.sprite.material.dispose();
fxSprites.splice(i, 1);
continue;
}
if (fx.kind === 'ring') {
const s = fx.startScale * (0.25 + t * 1.2);
fx.sprite.scale.setScalar(s);
fx.sprite.material.opacity = fx.opacity * (1 - t);
} else {
const s = fx.startScale * (0.55 + t * 1.5);
fx.sprite.scale.set(s, s, 1);
fx.sprite.material.opacity = fx.opacity * Math.pow(1 - t, 1.8);
}
}
}
function bigExplosion(x, y, z, size = 1) {
spawnFlash(x, y + 0.2, z, 9 * size, 0xffb15c, 0.45, 0.95);
spawnFlash(x, y + 0.4, z, 3 * size, 0xfff1c9, 0.2, 0.85);
emitFire(x, y + 0.2, z, Math.round(55 * size), 10 * size);
emitSmoke(x, y + 0.6, z, Math.round(30 * size), 4 * size, 0x171a18);
emitSpray(x, waveHeight(x, z, clockTime) + 0.1, z, Math.round(35 * size), 7 * size, 8 * size, 0.75 * size);
spawnShockRing(x, z, 9 * size, 0xcaedf2, 0.95);
}
/* ── boat construction ────────────────────────────────────────────────── */
TEX.teak = generateTeakTexture();
TEX.skull = makeSkullDecal();
TEX.name = makeNameDecal('NEREID', 'PATROL / 07');
function makeHullGeometry(stations) {
const rings = [];
for (const s of stations) {
const half = [
[0, -s.depth],
[s.w * 0.62, -s.depth * 0.58],
[s.w * 0.98, -0.13],
[s.w * 1.02, 0.025],
[s.w * 1.005, 0.16],
[s.w * 0.92, s.deck]
];
const ring = [];
for (const p of half) ring.push(p);
ring.push([0, s.deck + 0.055]);
for (let i = half.length - 1; i >= 1; i--) ring.push([-half[i][0], half[i][1]]);
rings.push({ z: s.z, pts: ring });
}
const positions = [], uvs = [];
const indicesByMat = [[], [], []];
let base = 0;
const matForBand = [0, 0, 0, 1, 2, 2, 2, 2, 1, 0, 0, 0];
for (let s = 0; s < rings.length; s++) {
const ring = rings[s];
for (let i = 0; i < ring.pts.length; i++) {
positions.push(ring.pts[i][0], ring.pts[i][1], ring.z);
uvs.push(i / ring.pts.length, s / (rings.length - 1));
}
}
for (let s = 0; s < rings.length - 1; s++) {
const r0 = s * 12;
const r1 = (s + 1) * 12;
for (let b = 0; b < 12; b++) {
const b2 = (b + 1) % 12;
const a = r0 + b, bb = r0 + b2, c = r1 + b2, d = r1 + b;
const list = indicesByMat[matForBand[b]];
list.push(a, bb, c, a, c, d);
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
const indices = [];
let start = 0;
indicesByMat.forEach((list, i) => {
indices.push(...list);
geo.addGroup(start, list.length, i);
start += list.length;
});
geo.setIndex(indices);
geo.computeVertexNormals();
base = 0;
return { geo, rings, stations };
}
function makeDeckGeometry(stations) {
const positions = [], uvs = [], indices = [];
const row = (s) => {
const y = s.deck + 0.02;
return [
[-s.w * 0.92, y],
[0, s.deck + 0.075],
[s.w * 0.92, y]
];
};
for (let s = 0; s < stations.length; s++) {
const st = stations[s];
const p = row(st);
for (let i = 0; i < 3; i++) {
positions.push(p[i][0], p[i][1], st.z);
uvs.push(i * 0.5, (st.z + 4) / 8.5);
}
}
for (let s = 0; s < stations.length - 1; s++) {
const r0 = s * 3, r1 = (s + 1) * 3;
indices.push(r0, r1, r0 + 1, r0 + 1, r1, r1 + 1);
indices.push(r0 + 1, r1 + 1, r0 + 2, r0 + 2, r1 + 1, r1 + 2);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.setIndex(indices);
geo.computeVertexNormals();
return geo;
}
function tubeBetween(a, b, radius, mat) {
const dir = new THREE.Vector3().subVectors(b, a);
const len = dir.length();
const geo = new THREE.CylinderGeometry(radius, radius, len, 8, 1);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(a).add(b).multiplyScalar(0.5);
mesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize());
return mesh;
}
const matLib = {};
function initMaterials() {
matLib.white = new THREE.MeshPhysicalMaterial({ color: 0xf1eee4, roughness: 0.29, metalness: 0.08, clearcoat: 0.8, clearcoatRoughness: 0.17, envMapIntensity: 0.95 });
matLib.navy = new THREE.MeshStandardMaterial({ color: 0x071e2a, roughness: 0.32, metalness: 0.3, envMapIntensity: 0.9 });
matLib.red = new THREE.MeshStandardMaterial({ color: 0x921d23, roughness: 0.33, metalness: 0.18, envMapIntensity: 0.85 });
matLib.teak = new THREE.MeshStandardMaterial({ map: TEX.teak, color: 0xd9b58e, roughness: 0.62, metalness: 0.02 });
matLib.chrome = new THREE.MeshStandardMaterial({ color: 0xdfe5e8, roughness: 0.11, metalness: 1.0, envMapIntensity: 1.4 });
matLib.gun = new THREE.MeshStandardMaterial({ color: 0x1b2326, roughness: 0.28, metalness: 0.9, envMapIntensity: 1.25 });
matLib.black = new THREE.MeshStandardMaterial({ color: 0x070c0f, roughness: 0.45, metalness: 0.45, envMapIntensity: 0.8 });
matLib.glass = new THREE.MeshPhysicalMaterial({ color: 0x06151b, roughness: 0.03, metalness: 0.25, transparent: true, opacity: 0.56, envMapIntensity: 1.8, side: THREE.DoubleSide });
matLib.cushion = new THREE.MeshStandardMaterial({ color: 0x493527, roughness: 0.62, metalness: 0.02 });
matLib.enemyHull = new THREE.MeshStandardMaterial({ color: 0x191d1e, roughness: 0.55, metalness: 0.45, envMapIntensity: 0.85 });
matLib.enemyRed = new THREE.MeshStandardMaterial({ color: 0x5c1013, roughness: 0.5, metalness: 0.25 });
matLib.enemyLower = new THREE.MeshStandardMaterial({ color: 0x0a0f12, roughness: 0.62, metalness: 0.3 });
matLib.enemyDeck = new THREE.MeshStandardMaterial({ color: 0x27211b, roughness: 0.8, metalness: 0.07 });
matLib.rust = new THREE.MeshStandardMaterial({ color: 0x3c2118, roughness: 0.86, metalness: 0.25 });
}
const STATIONS = [
{ z: -3.65, w: 1.04, depth: 0.56, deck: 0.35 },
{ z: -2.95, w: 1.22, depth: 0.68, deck: 0.39 },
{ z: -1.65, w: 1.33, depth: 0.75, deck: 0.42 },
{ z: -0.25, w: 1.32, depth: 0.75, deck: 0.42 },
{ z: 1.15, w: 1.23, depth: 0.71, deck: 0.39 },
{ z: 2.35, w: 1.00, depth: 0.63, deck: 0.34 },
{ z: 3.35, w: 0.64, depth: 0.49, deck: 0.26 },
{ z: 4.12, w: 0.10, depth: 0.19, deck: 0.09 }
];
const HULL = makeHullGeometry(STATIONS);
function makeTurret(enemy = false) {
const yaw = new THREE.Group();
yaw.name = 'turretYaw';
const scale = enemy ? 0.8 : 1;
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.52 * scale, 0.6 * scale, 0.18 * scale, 20), matLib.gun);
base.position.y = 0.08 * scale;
base.receiveShadow = true; base.castShadow = true;
yaw.add(base);
const housing = new THREE.Mesh(new RoundedBoxGeometry(0.96 * scale, 0.42 * scale, 1.02 * scale, 3, 0.09 * scale), matLib.gun);
housing.position.set(0, 0.37 * scale, 0);
housing.castShadow = true; housing.receiveShadow = true;
yaw.add(housing);
const pitch = new THREE.Group();
pitch.name = 'barrelPitch';
pitch.position.set(0, 0.4 * scale, 0.36 * scale);
yaw.add(pitch);
const barrelMat = matLib.gun;
const bLen = 1.75 * scale;
for (const side of [-1, 1]) {
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.052 * scale, 0.062 * scale, bLen, 10), barrelMat);
barrel.rotation.x = Math.PI / 2;
barrel.position.set(side * 0.13 * scale, 0.05 * scale, bLen * 0.52);
barrel.castShadow = true;
pitch.add(barrel);
const brake = new THREE.Mesh(new THREE.CylinderGeometry(0.09 * scale, 0.09 * scale, 0.2 * scale, 10), matLib.gun);
brake.rotation.x = Math.PI / 2;
brake.position.set(side * 0.13 * scale, 0.05 * scale, bLen * 1.02);
pitch.add(brake);
}
const sight = new THREE.Mesh(new THREE.BoxGeometry(0.22 * scale, 0.16 * scale, 0.13 * scale), matLib.black);
sight.position.set(0.28 * scale, 0.2 * scale, -0.1 * scale);
yaw.add(sight);
const muzzle = new THREE.Object3D();
muzzle.name = 'muzzleTarget';
muzzle.position.set(0, 0.05 * scale, bLen * 1.1);
pitch.add(muzzle);
yaw.userData.pitch = pitch;
yaw.userData.muzzle = muzzle;
yaw.userData.scale = scale;
return yaw;
}
function makeFlag(color = 0xab1f24) {
const geo = new THREE.PlaneGeometry(1.55, 0.82, 14, 5);
geo.translate(-0.775, 0.41, 0);
const mat = new THREE.MeshStandardMaterial({ color, roughness: 0.7, metalness: 0.0, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true;
mesh.userData.base = geo.attributes.position.array.slice();
mesh.userData.time = Math.random() * 10;
return mesh;
}
function buildBoat(type = 'player') {
const enemy = type === 'enemy';
const root = new THREE.Group();
const hullMat = enemy ? matLib.enemyHull : matLib.white;
const lowerMat = enemy ? matLib.enemyLower : matLib.navy;
const stripeMat = enemy ? matLib.enemyRed : matLib.red;
const hull = new THREE.Mesh(HULL.geo, [lowerMat, stripeMat, hullMat]);
hull.castShadow = true; hull.receiveShadow = true;
root.add(hull);
const deck = new THREE.Mesh(makeDeckGeometry(STATIONS), enemy ? matLib.enemyDeck : matLib.teak);
deck.position.y = 0.008;
deck.receiveShadow = true; deck.castShadow = true;
root.add(deck);
// transom / stern plate
const stern = STATIONS[0];
const shape = new THREE.Shape();
const half = [
[0, -stern.depth],
[stern.w * 0.62, -stern.depth * 0.58],
[stern.w * 0.98, -0.13],
[stern.w * 1.02, 0.025],
[stern.w * 1.005, 0.16],
[stern.w * 0.92, stern.deck],
[0, stern.deck + 0.055]
];
const ring = [...half, ...half.slice(1).reverse().map(p => [-p[0], p[1]])];
shape.moveTo(ring[0][0], ring[0][1]);
for (let i = 1; i < ring.length; i++) shape.lineTo(ring[i][0], ring[i][1]);
shape.closePath();
const transom = new THREE.Mesh(new THREE.ShapeGeometry(shape), hullMat);
transom.rotation.y = Math.PI;
transom.position.z = stern.z - 0.005;
transom.castShadow = true; transom.receiveShadow = true;
root.add(transom);
if (!enemy) {
const stripe = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.105, 0.018), matLib.red);
stripe.position.set(0, 0.13, stern.z - 0.018);
root.add(stripe);
const decal = new THREE.Mesh(new THREE.PlaneGeometry(0.72, 0.225), new THREE.MeshBasicMaterial({ map: TEX.name, transparent: true, depthWrite: false, toneMapped: false }));
decal.position.set(0, 0.53, stern.z - 0.03);
decal.rotation.y = Math.PI;
root.add(decal);
} else {
const stripe = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.12, 0.02), matLib.enemyRed);
stripe.position.set(0, 0.05, stern.z - 0.02);
root.add(stripe);
}
// rub rails
for (const side of [-1, 1]) {
const pts = [];
for (let i = 1; i < STATIONS.length; i++) {
const s = STATIONS[i];
pts.push(new THREE.Vector3(side * s.w * 0.925, s.deck + 0.02, s.z));
}
const curve = new THREE.CatmullRomCurve3(pts);
const rail = new THREE.Mesh(new THREE.TubeGeometry(curve, 24, 0.035, 6, false), enemy ? matLib.rust : matLib.black);
rail.castShadow = true;
root.add(rail);
}
// deck hardware
const consoleZ = enemy ? 0.15 : 0.05;
const consoleBase = new THREE.Mesh(new RoundedBoxGeometry(2.02, 0.72, 1.95, 3, 0.1), hullMat);
consoleBase.position.set(0, 0.76, consoleZ);
consoleBase.castShadow = true; consoleBase.receiveShadow = true;
root.add(consoleBase);
// dash / windshield
const dash = new THREE.Mesh(new RoundedBoxGeometry(1.9, 0.07, 0.64, 2, 0.03), matLib.black);
dash.position.set(0, 1.14, consoleZ + 0.66);
dash.rotation.x = -0.22;
root.add(dash);
const windshield = new THREE.Mesh(new THREE.PlaneGeometry(1.82, 0.72), matLib.glass);
windshield.position.set(0, 1.49, consoleZ + 1.0);
windshield.rotation.x = -0.28;
windshield.castShadow = false;
root.add(windshield);
for (const side of [-1, 1]) {
const sideGlass = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.56), matLib.glass);
sideGlass.position.set(side * 1.015, 1.42, consoleZ + 0.55);
sideGlass.rotation.y = side * Math.PI / 2;
sideGlass.rotation.z = side * 0.07;
root.add(sideGlass);
}
// low windscreen frame
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.94, 0.035, 0.035), matLib.chrome);
frame.position.set(0, 1.85, consoleZ + 0.9);
root.add(frame);
// seats
if (!enemy) {
for (const side of [-1, 1]) {
const seat = new THREE.Group();
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.19, 0.23, 0.48, 10), matLib.gun);
base.position.y = 0.25;
base.castShadow = true;
seat.add(base);
const cushion = new THREE.Mesh(new RoundedBoxGeometry(0.62, 0.16, 0.6, 3, 0.07), matLib.cushion);
cushion.position.y = 0.55;
cushion.castShadow = true;
seat.add(cushion);
const back = new THREE.Mesh(new RoundedBoxGeometry(0.62, 0.68, 0.16, 3, 0.07), matLib.cushion);
back.position.set(0, 0.86, -0.24);
back.rotation.x = -0.16;
back.castShadow = true;
seat.add(back);
seat.position.set(side * 0.52, 0.36, -1.07);
root.add(seat);
}
// stern bench
const bench = new THREE.Group();
const bBase = new THREE.Mesh(new RoundedBoxGeometry(1.9, 0.19, 0.64, 3, 0.07), hullMat);
bBase.position.y = 0.54;
bBase.castShadow = true;
bench.add(bBase);
const bCushion = new THREE.Mesh(new RoundedBoxGeometry(1.68, 0.15, 0.5, 3, 0.055), matLib.cushion);
bCushion.position.y = 0.68;
bench.add(bCushion);
bench.position.set(0, 0, -2.55);
root.add(bench);
// T-top
const top = new THREE.Mesh(new RoundedBoxGeometry(2.6, 0.075, 2.22, 3, 0.055), matLib.white);
top.position.set(0, 2.18, consoleZ - 0.02);
top.castShadow = true; top.receiveShadow = true;
root.add(top);
const lightBar = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.026, 0.055), new THREE.MeshBasicMaterial({ color: 0xffdaa4, toneMapped: false }));
lightBar.position.set(0, 2.105, consoleZ + 0.1);
root.add(lightBar);
const postPts = [
[-1.14, 2.14, consoleZ + 0.87], [1.14, 2.14, consoleZ + 0.87],
[-1.14, 2.14, consoleZ - 0.94], [1.14, 2.14, consoleZ - 0.94]
];
for (const p of postPts) {
const baseP = new THREE.Vector3(p[0] * 0.93, 1.1, p[2]);
const topP = new THREE.Vector3(p[0], p[1], p[2]);
const post = tubeBetween(baseP, topP, 0.028, matLib.chrome);
post.castShadow = true;
root.add(post);
}
const radarBase = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.38, 0.16, 16), matLib.black);
radarBase.position.set(0, 2.28, consoleZ - 0.55);
root.add(radarBase);
const radarDome = new THREE.Mesh(new THREE.SphereGeometry(0.28, 18, 10, 0, TAU, 0, Math.PI / 2), matLib.white);
radarDome.position.set(0, 2.34, consoleZ - 0.55);
radarDome.scale.y = 0.72;
root.add(radarDome);
const radarSpin = new THREE.Group();
radarSpin.name = 'radarSpin';
radarSpin.position.set(0, 2.55, consoleZ - 0.55);
const radarBar = new THREE.Mesh(new THREE.BoxGeometry(1.0, 0.035, 0.075), matLib.black);
radarBar.position.x = 0.32;
radarSpin.add(radarBar);
root.add(radarSpin);
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.02, 1.2, 6), matLib.black);
antenna.position.set(0.72, 2.55, consoleZ - 0.98);
antenna.rotation.z = -0.12;
root.add(antenna);
const antenna2 = antenna.clone();
antenna2.scale.setScalar(0.78);
antenna2.position.set(-0.82, 2.4, consoleZ - 0.9);
antenna2.rotation.z = 0.18;
root.add(antenna2);
// spotlight
const spot = new THREE.Mesh(new THREE.CylinderGeometry(0.13, 0.1, 0.34, 12), matLib.chrome);
spot.rotation.x = Math.PI / 2;
spot.position.set(0, 1.87, consoleZ + 0.92);
root.add(spot);
const lens = new THREE.Mesh(new THREE.CircleGeometry(0.085, 12), new THREE.MeshBasicMaterial({ color: 0xfff0d0, toneMapped: false }));
lens.position.set(0, 1.87, consoleZ + 1.1);
root.add(lens);
// flag
const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.022, 0.028, 1.8, 8), matLib.chrome);
pole.position.set(-0.92, 1.15, -2.98);
root.add(pole);
const flag = makeFlag(0xa61e25);
flag.name = 'flag';
flag.position.set(-0.92, 2.0, -2.98);
flag.rotation.y = Math.PI / 2;
root.add(flag);
// rails
for (const side of [-1, 1]) {
const pts = [];
for (let i = 4; i < STATIONS.length; i++) {
const s = STATIONS[i];
pts.push(new THREE.Vector3(side * s.w * 0.9, s.deck + 0.63, s.z));
}
if (pts.length > 1) {
const curve = new THREE.CatmullRomCurve3(pts);
const rail = new THREE.Mesh(new THREE.TubeGeometry(curve, 20, 0.021, 5, false), matLib.chrome);
rail.castShadow = true;
root.add(rail);
for (let i = 1; i < pts.length - 1; i++) {
const s = STATIONS[i + 4];
const post = new THREE.Mesh(new THREE.CylinderGeometry(0.017, 0.017, 0.62, 6), matLib.chrome);
post.position.set(side * s.w * 0.9, s.deck + 0.34, s.z);
root.add(post);
}
}
}
// nav lights
const navColors = [[-1, 0xff2c1e], [1, 0x27ff79]];
for (const [side, col] of navColors) {
const s = STATIONS[6];
const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.055, 8, 6), new THREE.MeshBasicMaterial({ color: col, toneMapped: false }));
bulb.position.set(side * 0.5, s.deck + 0.18, s.z - 0.05);
root.add(bulb);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: TEX.soft, color: col, transparent: true, opacity: 0.55, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true }));
glow.scale.set(0.5, 0.5, 1);
glow.position.copy(bulb.position);
root.add(glow);
}
const sternLight = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), new THREE.MeshBasicMaterial({ color: 0xfff2cf, toneMapped: false }));
sternLight.position.set(0, 0.62, -3.62);
root.add(sternLight);
// bow decal / number
const number = new THREE.Mesh(new THREE.PlaneGeometry(0.42, 0.15), new THREE.MeshBasicMaterial({ map: makeNameDecal('07', ''), transparent: true, depthWrite: false, toneMapped: false }));
number.position.set(0.73, 0.23, 2.95);
number.rotation.y = Math.PI / 2;
number.rotation.z = Math.PI / 2;
root.add(number);
} else {
// pirate ram + red strips
for (const side of [-1, 1]) {
const spike = new THREE.Mesh(new THREE.ConeGeometry(0.11, 1.05, 4), matLib.rust);
spike.rotation.x = Math.PI / 2;
spike.position.set(side * 0.42, 0.11, 3.95);
spike.castShadow = true;
root.add(spike);
const strip = new THREE.Mesh(new THREE.BoxGeometry(0.02, 0.035, 3.2), new THREE.MeshBasicMaterial({ color: 0xff2617, toneMapped: false }));
strip.position.set(side * 1.13, 0.2, 0.1);
root.add(strip);
}
const ram = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.16, 0.55), matLib.rust);
ram.position.set(0, 0.02, 3.82);
ram.rotation.x = 0.18;
ram.castShadow = true;
root.add(ram);
// skull decals
for (const side of [-1, 1]) {
const skull = new THREE.Mesh(new THREE.PlaneGeometry(0.7, 0.7), new THREE.MeshBasicMaterial({ map: TEX.skull, transparent: true, depthWrite: false, toneMapped: false, opacity: 0.68 }));
skull.position.set(side * 1.13, 0.2, 1.45);
skull.rotation.y = side * Math.PI / 2;
root.add(skull);
}
// rough exhaust stacks / engine glow
for (const side of [-1, 1]) {
const stack = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.11, 0.55, 8), matLib.rust);
stack.position.set(side * 0.76, 0.92, -3.12);
stack.rotation.x = -0.1;
root.add(stack);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: TEX.sun, color: 0xff4b2b, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true }));
glow.scale.set(0.8, 0.8, 1);
glow.position.set(side * 0.55, 0.24, -3.95);
glow.name = 'engineGlow';
root.add(glow);
}
}
// outboards
const engineCount = enemy ? 1 : 2;
for (let i = 0; i < engineCount; i++) {
const side = engineCount === 1 ? 0 : (i === 0 ? -0.56 : 0.56);
const engine = new THREE.Group();
const cowl = new THREE.Mesh(new RoundedBoxGeometry(0.54, 0.72, 0.68, 3, 0.1), enemy ? matLib.enemyRed : matLib.navy);
cowl.position.y = 0.47;
cowl.castShadow = true;
engine.add(cowl);
const top = new THREE.Mesh(new RoundedBoxGeometry(0.46, 0.1, 0.57, 2, 0.045), matLib.black);
top.position.y = 0.86;
engine.add(top);
const handle = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.08, 0.38), matLib.chrome);
handle.position.set(0, 0.52, -0.36);
engine.add(handle);
const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.92, 0.3), matLib.gun);
shaft.position.y = -0.18;
engine.add(shaft);
const fin = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.08, 0.5), matLib.gun);
fin.position.set(0, -0.53, -0.02);
engine.add(fin);
const prop = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 0.46, 3), matLib.chrome);
prop.rotation.z = Math.PI / 2;
prop.position.set(0, -0.62, -0.1);
engine.add(prop);
engine.position.set(side, 0, -3.65);
root.add(engine);
}
// turret
const turret = makeTurret(enemy);
turret.position.set(0, enemy ? 0.44 : 0.43, enemy ? 1.9 : 2.68);
root.add(turret);
root.userData.turret = turret;
if (!enemy) {
root.scale.setScalar(1.0);
} else {
root.scale.setScalar(0.94);
}
root.traverse(o => {
if (o.isMesh) {
o.castShadow = true;
o.receiveShadow = true;
}
});
return root;
}
initMaterials();
const playerBoat = buildBoat('player');
scene.add(playerBoat);
playerBoat.add(cockpitLight);
cockpitLight.position.set(0, 2.0, -0.7);
let enemyTemplate = null;
function getEnemyTemplate() {
if (!enemyTemplate) enemyTemplate = buildBoat('enemy');
return enemyTemplate;
}
/* ── distant islands ──────────────────────────────────────────────────── */
const islands = [];
function addIsland(x, z, radius, height, seed) {
const geo = new THREE.IcosahedronGeometry(1, 4);
const pos = geo.attributes.position;
const v = new THREE.Vector3();
for (let i = 0; i < pos.count; i++) {
v.fromBufferAttribute(pos, i);
const n = fbm(v.x * 2.1 + seed, v.z * 2.1 - seed, 4);
const n2 = noise2(v.x * 7 + seed * 3, v.z * 7 - seed);
v.multiplyScalar(0.78 + n * 0.5 + n2 * 0.12);
v.y = Math.max(v.y * 0.45, -0.2);
pos.setXYZ(i, v.x, v.y * height, v.z);
}
geo.computeVertexNormals();
const mat = new THREE.MeshStandardMaterial({ color: 0x242b2e, roughness: 0.96, metalness: 0.03 });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, -height * 0.18, z);
mesh.scale.set(radius, height, radius);
mesh.castShadow = false;
mesh.receiveShadow = false;
scene.add(mesh);
islands.push({ x, z, radius: radius * 0.72, mesh });
return mesh;
}
addIsland(-640, -860, 92, 34, 3.4);
addIsland(860, -1180, 150, 49, 8.8);
// distant lighthouse on the left island
{
const lh = new THREE.Group();
const tower = new THREE.Mesh(new THREE.CylinderGeometry(0.55, 1.1, 7, 10), new THREE.MeshStandardMaterial({ color: 0xc9c7ba, roughness: 0.8 }));
tower.position.y = 3.5;
lh.add(tower);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.75, 0.75, 0.55, 10), new THREE.MeshStandardMaterial({ color: 0x8f1e1d, roughness: 0.55 }));
cap.position.y = 7.1;
lh.add(cap);
const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 8), new THREE.MeshBasicMaterial({ color: 0xffd596, toneMapped: false }));
lamp.position.y = 7.45;
lh.add(lamp);
lh.position.set(-640, 10, -860);
lh.scale.setScalar(1.3);
scene.add(lh);
}
/* ── aim marker ───────────────────────────────────────────────────────── */
const aimMarker = new THREE.Mesh(
new THREE.RingGeometry(0.34, 0.44, 24),
new THREE.MeshBasicMaterial({ color: 0x8feaff, transparent: true, opacity: 0.55, side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false })
);
aimMarker.rotation.x = -Math.PI / 2;
aimMarker.visible = false;
scene.add(aimMarker);
/* ── watercraft physics ───────────────────────────────────────────────── */
const player = {
group: playerBoat,
turret: playerBoat.userData.turret,
pos: new THREE.Vector3(0, 0, 0),
vel: new THREE.Vector3(0, 0, 0),
heading: 0,
y: 0,
vy: 0,
pitch: 0, roll: 0,
pitchV: 0, rollV: 0,
speed: 0,
throttle: 0,
steer: 0,
health: 100,
maxHealth: 100,
lastHit: -100,
yawAim: 0,
pitchAim: 0,
fireCd: 0,
recoil: 0,
wakeTimer: 0,
sprayTimer: 0,
roosterTimer: 0,
waveHitTimer: 7,
impactCd: 0,
lastBowWater: 0,
direction: new THREE.Vector3(0, 0, 1),
right: new THREE.Vector3(1, 0, 0)
};
const playerCraft = {
length: 7.75,
beam: 2.7,
draftOffset: 0.13,
spring: 19,
damp: 6.2
};
function updatePlayer(dt, t, input) {
const forward = new THREE.Vector3(Math.sin(player.heading), 0, Math.cos(player.heading));
const right = new THREE.Vector3(Math.cos(player.heading), 0, -Math.sin(player.heading));
player.direction.copy(forward);
player.right.copy(right);
const boost = input.boost && input.throttle > 0;
const targetThrottle = input.throttle;
player.throttle = damp(player.throttle, targetThrottle, 3.8, dt);
const power = boost ? 19.5 : 15.5;
let fSpeed = player.vel.dot(forward);
let lSpeed = player.vel.dot(right);
fSpeed += player.throttle * power * dt;
const drag = 0.0145 * fSpeed * Math.abs(fSpeed) + 0.12 * fSpeed;
fSpeed -= drag * dt;
fSpeed = clamp(fSpeed, -7.0, boost ? 35.5 : 30.5);
lSpeed *= Math.exp(-2.8 * dt);
const steerAuthority = 0.22 + Math.min(Math.abs(fSpeed) / 26, 1.0) * 0.64;
const turn = -input.steer * steerAuthority * (fSpeed < -0.5 ? -1 : 1);
player.heading += turn * dt;
player.steer = damp(player.steer, input.steer, 5.5, dt);
player.speed = fSpeed;
player.vel.copy(forward).multiplyScalar(fSpeed).addScaledVector(right, lSpeed);
// wave slope pushes the hull downhill a little
const n = waveNormal(player.pos.x, player.pos.z, t);
player.vel.x += -n.x * 2.8 * dt;
player.vel.z += -n.z * 2.8 * dt;
player.pos.addScaledVector(player.vel, dt);
// sample hull points
const bowX = player.pos.x + forward.x * 3.28;
const bowZ = player.pos.z + forward.z * 3.28;
const sternX = player.pos.x - forward.x * 2.9;
const sternZ = player.pos.z - forward.z * 2.9;
const portX = player.pos.x - right.x * 1.32;
const portZ = player.pos.z - right.z * 1.32;
const stbdX = player.pos.x + right.x * 1.32;
const stbdZ = player.pos.z + right.z * 1.32;
const hBow = waveHeight(bowX, bowZ, t);
const hStern = waveHeight(sternX, sternZ, t);
const hPort = waveHeight(portX, portZ, t);
const hStbd = waveHeight(stbdX, stbdZ, t);
const hCenter = waveHeight(player.pos.x, player.pos.z, t);
const avg = (hBow + hStern + hPort + hStbd + hCenter) * 0.2;
const planing = Math.min(Math.max(fSpeed, 0) / 30, 1);
const targetY = avg + playerCraft.draftOffset + planing * 0.12;
const accel = (player.throttle * power);
player.vy += (targetY - player.y) * playerCraft.spring * dt;
player.vy -= player.vy * playerCraft.damp * dt;
player.vy -= accel * 0.0009 * dt;
player.y += player.vy * dt;
const pitchTarget = -Math.atan2(hBow - hStern, 6.18) * 1.15 - planing * 0.075 + player.throttle * 0.005;
const rollTarget = Math.atan2(hStbd - hPort, 2.64) * 1.05 - input.steer * Math.min(Math.abs(fSpeed) / 24, 1) * 0.14;
player.pitchV += (pitchTarget - player.pitch) * 10.5 * dt - player.pitchV * 4.3 * dt;
player.rollV += (rollTarget - player.roll) * 9.0 * dt - player.rollV * 4.1 * dt;
player.pitch += player.pitchV * dt;
player.roll += player.rollV * dt;
player.group.position.set(player.pos.x, player.y, player.pos.z);
player.group.rotation.order = 'YXZ';
player.group.rotation.set(player.pitch, player.heading, player.roll);
// wake and bow spray
player.wakeTimer -= dt;
if (fSpeed > 2 && player.wakeTimer <= 0) {
player.wakeTimer = 0.045;
const sternX = player.pos.x - forward.x * 3.15;
const sternZ = player.pos.z - forward.z * 3.15;
wakeTrail.add(sternX, sternZ, right.x, right.z, fSpeed, right);
emitWake(sternX, sternZ, -forward.x * fSpeed * 0.19, -forward.z * fSpeed * 0.19, fSpeed);
}
// prop wash / rooster tail
player.roosterTimer -= dt;
if (fSpeed > 8 && player.roosterTimer <= 0) {
player.roosterTimer = 0.045;
for (const side of [-1, 1]) {
const ex = player.pos.x - forward.x * 3.75 + right.x * side * 0.58;
const ez = player.pos.z - forward.z * 3.75 + right.z * side * 0.58;
for (let i = 0; i < 2; i++) {
sprayPool.spawn({
x: ex + rand(-0.28, 0.28), y: waveHeight(ex, ez, t) + rand(0.12, 0.4), z: ez + rand(-0.28, 0.28),
vx: -forward.x * rand(2.0, 6.5) + right.x * side * rand(0.2, 1.8) + rand(-0.5, 0.5),
vy: rand(1.6, 4.8),
vz: -forward.z * rand(2.0, 6.5) + right.z * side * rand(0.2, 1.8) + rand(-0.5, 0.5),
life: rand(0.5, 1.15), size: rand(0.38, 0.95), grow: rand(0.3, 0.8),
gravity: -8.5, drag: 0.5, alpha: rand(0.24, 0.52), color: 0xe7f6f7
});
}
}
}
player.sprayTimer -= dt;
if (fSpeed > 6 && player.sprayTimer <= 0 && Math.random() < 0.55 + planing * 0.4) {
player.sprayTimer = 0.13;
const sx = player.pos.x + forward.x * 2.6 + right.x * rand(-1.2, 1.2);
const sz = player.pos.z + forward.z * 2.6 + right.z * rand(-1.2, 1.2);
const side = Math.random() < 0.5 ? -1 : 1;
for (let i = 0; i < 8; i++) {
sprayPool.spawn({
x: sx, y: waveHeight(sx, sz, t) + 0.2, z: sz,
vx: right.x * side * rand(0.8, 3.4) + forward.x * fSpeed * 0.035 + rand(-0.6, 0.6),
vy: rand(1.6, 5.2), vz: right.z * side * rand(0.8, 3.4) + forward.z * fSpeed * 0.035 + rand(-0.6, 0.6),
life: rand(0.65, 1.45), size: rand(0.38, 1.1), grow: 0.6,
gravity: -9.8, drag: 0.65, alpha: rand(0.28, 0.66), color: 0xe4f7f6
});
}
// low, wide sheets of mist peeling off the hull
for (let i = 0; i < 2; i++) {
sprayPool.spawn({
x: sx + rand(-0.8, 0.8), y: waveHeight(sx, sz, t) + 0.1, z: sz + rand(-0.8, 0.8),
vx: right.x * side * rand(0.8, 2.2), vy: rand(0.3, 1.2), vz: right.z * side * rand(0.8, 2.2),
life: rand(0.8, 1.5), size: rand(1.4, 2.6), grow: 1.1,
gravity: -1.0, drag: 1.0, alpha: rand(0.08, 0.18), color: 0xd9f3f5
});
}
}
// wave impacts
player.impactCd -= dt;
const bowWater = waveHeight(bowX, bowZ, t);
const bowWaterV = waveVerticalSpeed(bowX, bowZ, t);
const relativeImpact = Math.abs(bowWater - player.y) + Math.abs(bowWaterV) * 0.2;
if (player.impactCd <= 0 && fSpeed > 2.5 && relativeImpact > 1.15) {
player.impactCd = rand(0.9, 1.8);
const count = Math.floor(rand(14, 26));
emitSpray(bowX, bowWater + 0.15, bowZ, count, 2.8, 5.5, 0.7);
emitSpray(player.pos.x - forward.x * 2.5, avg, player.pos.z - forward.z * 2.5, 8, 2.0, 3.0, 0.55);
cameraShake = Math.max(cameraShake, 0.28);
splashFlash(0.25);
if (sound.ready) sound.splash(Math.min(1, relativeImpact * 0.4));
}
player.lastBowWater = bowWater;
// occasional wave washing over the bow (more frequent in a rogue swell)
player.waveHitTimer -= dt;
if (player.waveHitTimer <= 0 && state.started && !state.gameOver) {
const rogueHit = rogue.amp > 0.36;
player.waveHitTimer = rogueHit ? rand(3.4, 6.2) : rand(7.5, 13.5);
const strength = rogueHit ? rand(1.15, 1.7) : rand(0.7, 1.05);
emitSpray(bowX, bowWater + 0.3, bowZ, Math.round(34 * strength), 4.6 * strength, 7.5 * strength, 0.88 * strength);
const midX = player.pos.x + forward.x * 1.8;
const midZ = player.pos.z + forward.z * 1.8;
emitSpray(midX, avg + 0.28, midZ, Math.round(18 * strength), 3.2 * strength, 5.0 * strength, 0.6 * strength);
player.vy += 1.1 * strength;
player.pitchV += rand(-0.18, 0.18) * strength;
player.rollV += rand(-0.24, 0.24) * strength;
player.vel.x += n.x * -0.45 * strength;
player.vel.z += n.z * -0.45 * strength;
cameraShake = Math.max(cameraShake, 0.32 * strength);
splashFlash(0.38 * strength);
if (sound.ready) sound.splash(0.65 * strength);
}
// damage smoke and small deck fire
if (state.started && !state.gameOver && player.health < 46 && Math.random() < dt * 8.5) {
const dx = player.pos.x - forward.x * rand(0.2, 2.6) + right.x * rand(-0.8, 0.8);
const dz = player.pos.z - forward.z * rand(0.2, 2.6) + right.z * rand(-0.8, 0.8);
smokePool.spawn({
x: dx, y: player.y + rand(0.8, 1.4), z: dz,
vx: rand(-0.35, 0.35), vy: rand(0.35, 1.1), vz: rand(-0.35, 0.35),
life: rand(1.1, 2.4), size: rand(0.45, 1.1), grow: rand(0.7, 1.5),
gravity: 0.2, drag: 0.4, alpha: rand(0.08, 0.2), color: 0x111616
});
if (player.health < 22 && Math.random() < 0.45) {
firePool.spawn({
x: dx, y: player.y + 0.55, z: dz,
vx: rand(-0.25, 0.25), vy: rand(0.4, 1.4), vz: rand(-0.25, 0.25),
life: rand(0.2, 0.55), size: rand(0.2, 0.55), grow: 0.5,
gravity: 1.0, drag: 0.9, alpha: rand(0.35, 0.75), color: 0xff6a2a
});
}
}
// health regeneration
if (state.started && !state.gameOver && t - player.lastHit > 10) {
player.health = Math.min(player.maxHealth, player.health + 0.95 * dt);
}
// recoil returns
player.recoil = damp(player.recoil, 0, 14, dt);
const pitchGroup = player.turret.userData.pitch;
if (pitchGroup) pitchGroup.position.z = 0.36 + player.recoil * -0.18;
// 3D aim marker
const markerY = waveHeight(aimPoint.x, aimPoint.z, t) + 0.07;
aimMarker.position.set(aimPoint.x, markerY, aimPoint.z);
const d = camera.position.distanceTo(aimMarker.position);
const ms = clamp(0.45 + d * 0.025, 0.55, 4.0);
aimMarker.scale.setScalar(ms);
aimMarker.visible = state.started && !state.gameOver;
}
/* ── enemies ──────────────────────────────────────────────────────────── */
const enemies = [];
const enemyHitboxes = [];
let enemyId = 0;
class Enemy {
constructor(difficulty = 1) {
this.id = ++enemyId;
this.template = getEnemyTemplate();
this.group = this.template.clone(true);
this.group.scale.setScalar(0.94);
this.turret = this.group.getObjectByName('turretYaw');
this.turretPitch = this.group.getObjectByName('barrelPitch');
this.muzzle = this.group.getObjectByName('muzzleTarget');
this.health = 86 + difficulty * 12;
this.maxHealth = this.health;
this.speed = 12 + Math.min(difficulty * 0.65, 7);
this.maxSpeed = 18 + Math.min(difficulty * 0.9, 9);
this.heading = 0;
this.pos = new THREE.Vector3();
this.vel = new THREE.Vector3();
this.y = 0; this.vy = 0;
this.pitch = 0; this.roll = 0; this.pitchV = 0; this.rollV = 0;
this.dead = false;
this.deadTime = 0;
this.fireTimer = rand(1.5, 3.4);
this.strafeDir = Math.random() < 0.5 ? -1 : 1;
this.strafeTimer = rand(4, 8);
this.wakeTimer = 0;
this.hitFlash = 0;
this.turretYaw = 0;
this.aimPitch = 0;
const angle = rand(0, TAU);
const dist = rand(150, 235);
this.pos.set(player.pos.x + Math.sin(angle) * dist, 0, player.pos.z + Math.cos(angle) * dist);
this.heading = Math.atan2(player.pos.x - this.pos.x, player.pos.z - this.pos.z);
this.y = waveHeight(this.pos.x, this.pos.z, clockTime);
const hitbox = new THREE.Mesh(new THREE.SphereGeometry(2.6, 8, 6), new THREE.MeshBasicMaterial({ visible: false, depthWrite: false }));
hitbox.position.y = 0.6;
hitbox.userData.enemy = this;
this.group.add(hitbox);
this.hitbox = hitbox;
enemyHitboxes.push(hitbox);
scene.add(this.group);
enemies.push(this);
}
update(dt, t) {
if (this.dead) {
this.deadTime += dt;
this.y = damp(this.y, waveHeight(this.pos.x, this.pos.z, t) - Math.min(this.deadTime * 0.5, 2.5), 1.4, dt);
this.pitch = damp(this.pitch, 0.35, 0.6, dt);
this.roll = damp(this.roll, Math.sin(this.deadTime * 2.2) * 0.15 + 0.65, 0.55, dt);
this.group.position.set(this.pos.x, this.y, this.pos.z);
this.group.rotation.set(this.pitch, this.heading, this.roll);
if (this.deadTime < 2.6 && Math.random() < dt * 8) {
emitSmoke(this.pos.x, this.y + 0.8, this.pos.z, 1, 1.5, 0x101414);
}
if (this.deadTime > 3.6) this.dispose();
return;
}
const toPlayer = new THREE.Vector3().subVectors(player.pos, this.pos);
const dist = toPlayer.length();
const toAngle = Math.atan2(toPlayer.x, toPlayer.z);
this.strafeTimer -= dt;
if (this.strafeTimer <= 0) {
this.strafeDir *= -1;
this.strafeTimer = rand(3.5, 7.5);
}
let desired;
if (dist > 78) {
desired = toAngle;
} else if (dist < 23) {
desired = toAngle + Math.PI + this.strafeDir * 0.4;
} else {
const orbit = toAngle + this.strafeDir * Math.PI * 0.48;
const blend = clamp((72 - dist) / 42, 0, 1);
desired = toAngle + angleDelta(toAngle, orbit) * blend;
// aim slightly ahead of the player at longer ranges
if (dist > 42) {
const lead = clamp(dist / 50, 0, 2.2);
const pred = Math.atan2(player.pos.x + player.vel.x * lead - this.pos.x, player.pos.z + player.vel.z * lead - this.pos.z);
desired = desired + angleDelta(desired, pred) * 0.4;
}
}
// separation from other enemies
for (const other of enemies) {
if (other === this || other.dead) continue;
const dx = this.pos.x - other.pos.x, dz = this.pos.z - other.pos.z;
const d2 = dx * dx + dz * dz;
if (d2 < 110 && d2 > 0.001) {
const away = Math.atan2(dx, dz);
desired += angleDelta(desired, away) * 0.2;
}
}
const turnRate = 0.72 + Math.min(dist / 200, 0.35);
this.heading = smoothAngle(this.heading, desired, turnRate, dt);
let targetSpeed = this.maxSpeed;
if (dist < 22) targetSpeed = this.maxSpeed * 0.7;
if (dist < 95 && dist > 30) targetSpeed = this.maxSpeed * 0.83;
this.speed = damp(this.speed, targetSpeed, 0.85, dt);
const forward = new THREE.Vector3(Math.sin(this.heading), 0, Math.cos(this.heading));
const right = new THREE.Vector3(Math.cos(this.heading), 0, -Math.sin(this.heading));
this.pos.addScaledVector(forward, this.speed * dt);
// island collision
for (const isl of islands) {
const dx = this.pos.x - isl.x, dz = this.pos.z - isl.z;
const d = Math.hypot(dx, dz);
if (d < isl.radius + 8) {
const push = (isl.radius + 8 - d) / Math.max(d, 0.01);
this.pos.x += dx * push;
this.pos.z += dz * push;
this.heading += angleDelta(this.heading, Math.atan2(dx, dz)) * 0.08;
}
}
const bowX = this.pos.x + forward.x * 2.9;
const bowZ = this.pos.z + forward.z * 2.9;
const sternX = this.pos.x - forward.x * 2.7;
const sternZ = this.pos.z - forward.z * 2.7;
const portX = this.pos.x - right.x * 1.2;
const portZ = this.pos.z - right.z * 1.2;
const stbdX = this.pos.x + right.x * 1.2;
const stbdZ = this.pos.z + right.z * 1.2;
const hBow = waveHeight(bowX, bowZ, t);
const hStern = waveHeight(sternX, sternZ, t);
const hPort = waveHeight(portX, portZ, t);
const hStbd = waveHeight(stbdX, stbdZ, t);
const avg = (hBow + hStern + hPort + hStbd) * 0.25;
const targetY = avg + 0.1;
this.vy += (targetY - this.y) * 18 * dt;
this.vy -= this.vy * 5.8 * dt;
this.y += this.vy * dt;
const pitchTarget = -Math.atan2(hBow - hStern, 5.6) * 1.25;
const rollTarget = Math.atan2(hStbd - hPort, 2.4) * 1.05 - this.strafeDir * 0.035;
this.pitchV += (pitchTarget - this.pitch) * 9.5 * dt - this.pitchV * 4.2 * dt;
this.rollV += (rollTarget - this.roll) * 8.3 * dt - this.rollV * 4.0 * dt;
this.pitch += this.pitchV * dt;
this.roll += this.rollV * dt;
this.group.position.set(this.pos.x, this.y, this.pos.z);
this.group.rotation.order = 'YXZ';
this.group.rotation.set(this.pitch, this.heading, this.roll);
// wake
this.wakeTimer -= dt;
if (this.wakeTimer <= 0 && this.speed > 3) {
this.wakeTimer = 0.06;
emitWake(this.pos.x - forward.x * 2.7, this.pos.z - forward.z * 2.7, -forward.x * this.speed * 0.16, -forward.z * this.speed * 0.16, this.speed * 0.7);
}
// turret aim
if (this.turret) {
const targetLocal = this.group.worldToLocal(player.pos.clone().add(new THREE.Vector3(0, 1.0, 0)));
const yawTarget = Math.atan2(targetLocal.x - this.turret.position.x, targetLocal.z - this.turret.position.z);
this.turretYaw = smoothAngle(this.turretYaw, yawTarget, 4.0, dt);
this.turret.rotation.y = this.turretYaw;
if (this.turretPitch) {
const horiz = Math.hypot(targetLocal.x, targetLocal.z);
const pitch = -Math.atan2(targetLocal.y - 0.85, Math.max(horiz, 0.1));
this.aimPitch = damp(this.aimPitch, clamp(pitch, -0.5, 0.08), 4.0, dt);
this.turretPitch.rotation.x = this.aimPitch;
}
}
// fire
this.fireTimer -= dt;
if (this.fireTimer <= 0 && dist < 118 && dist > 18) {
this.fireTimer = rand(2.4, 4.2) - Math.min(state.wave * 0.08, 1.1);
this.fire(dt);
}
this.hitFlash = Math.max(0, this.hitFlash - dt);
const glow = this.group.getObjectByName('engineGlow');
if (glow) glow.material.opacity = 0.35 + Math.sin(t * 17 + this.id) * 0.08;
}
fire() {
if (!this.muzzle) return;
const origin = new THREE.Vector3();
this.muzzle.getWorldPosition(origin);
const dist = player.pos.distanceTo(this.pos);
const shellSpeed = 54 + Math.min(state.wave * 1.4, 12);
const flight = dist / shellSpeed;
const predicted = player.pos.clone().addScaledVector(player.vel, flight * 0.86);
predicted.y = 0.9;
const dir = predicted.sub(origin).normalize();
const spread = (0.006 + dist / 16000) * 1.3;
dir.x += rand(-spread, spread);
dir.y += rand(-spread * 0.35, spread * 0.45);
dir.z += rand(-spread, spread);
dir.normalize();
const vel = dir.multiplyScalar(shellSpeed);
vel.y += 1.8;
spawnShell(origin, vel, 'enemy');
spawnFlash(origin.x, origin.y, origin.z, 2.2, 0xff6b33, 0.12, 0.8);
if (sound.ready) sound.enemyFire(clamp(1 - dist / 180, 0.15, 0.8));
}
damage(amount, hitPoint) {
if (this.dead) return;
this.health -= amount;
this.hitFlash = 0.14;
spawnFlash(hitPoint.x, hitPoint.y, hitPoint.z, 2.6, 0xffd18a, 0.12, 0.9);
emitFire(hitPoint.x, hitPoint.y, hitPoint.z, 12, 5, 0xffe9a5, 0xff5b24);
if (this.health <= 0) {
this.dead = true;
this.deadTime = 0;
this.health = 0;
this.group.traverse(o => {
if (o.isMesh && o.material) {
o.material = matLib.gun;
}
});
bigExplosion(this.pos.x, this.y + 0.4, this.pos.z, 1.05);
if (sound.ready) sound.explosion(0.7);
state.score += 100 + state.wave * 25;
state.kills++;
addBanner('TARGET DESTROYED', '+' + (100 + state.wave * 25) + ' PTS', 'MARAUDER SKIFF');
cameraShake = Math.max(cameraShake, 0.32);
const idx = enemyHitboxes.indexOf(this.hitbox);
if (idx >= 0) enemyHitboxes.splice(idx, 1);
this.hitbox.removeFromParent();
}
}
dispose() {
const idx = enemies.indexOf(this);
if (idx >= 0) enemies.splice(idx, 1);
scene.remove(this.group);
const hb = enemyHitboxes.indexOf(this.hitbox);
if (hb >= 0) enemyHitboxes.splice(hb, 1);
if (this.hitbox) {
this.hitbox.removeFromParent();
this.hitbox.geometry.dispose();
this.hitbox.material.dispose();
}
}
}
function spawnEnemy(difficulty = state.wave) {
const e = new Enemy(difficulty);
return e;
}
/* ── projectiles ──────────────────────────────────────────────────────── */
const bullets = [];
const bulletGeo = new THREE.SphereGeometry(0.09, 8, 6);
const playerBulletMat = new THREE.MeshBasicMaterial({ color: 0xffedb0, toneMapped: false });
const enemyBulletMat = new THREE.MeshBasicMaterial({ color: 0xff6c36, toneMapped: false });
const tracerGeo = new THREE.CylinderGeometry(0.035, 0.09, 3.6, 6);
function spawnPlayerBullet(origin, dir) {
const g = new THREE.Group();
const core = new THREE.Mesh(bulletGeo, playerBulletMat);
g.add(core);
const tracer = new THREE.Mesh(tracerGeo, new THREE.MeshBasicMaterial({ color: 0xffbc56, transparent: true, opacity: 0.78, blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }));
tracer.position.y = -1.55;
g.add(tracer);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: TEX.sun, color: 0xffc45d, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true }));
glow.scale.set(2.8, 2.8, 1);
g.add(glow);
g.position.copy(origin);
g.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize());
scene.add(g);
const vel = dir.clone().normalize().multiplyScalar(265);
bullets.push({ group: g, pos: origin.clone(), prev: origin.clone(), vel, life: 3.0, type: 'player', radius: 0.9 });
}
function spawnShell(origin, vel, type) {
const g = new THREE.Group();
const core = new THREE.Mesh(bulletGeo, type === 'enemy' ? enemyBulletMat : playerBulletMat);
core.scale.setScalar(1.5);
g.add(core);
const tracer = new THREE.Mesh(tracerGeo, new THREE.MeshBasicMaterial({
color: type === 'enemy' ? 0xff3b1f : 0xffbc56, transparent: true, opacity: 0.8,
blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false
}));
tracer.position.y = -1.55;
tracer.scale.set(1.2, 1.2, 1.2);
g.add(tracer);
const glow = new THREE.Sprite(new THREE.SpriteMaterial({
map: TEX.sun, color: type === 'enemy' ? 0xff4a26 : 0xffc45d, transparent: true,
opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: true
}));
glow.scale.set(3.2, 3.2, 1);
g.add(glow);
g.position.copy(origin);
g.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), vel.clone().normalize());
scene.add(g);
bullets.push({ group: g, pos: origin.clone(), prev: origin.clone(), vel: vel.clone(), life: 5.0, type, radius: 0.8 });
}
function updateBullets(dt, t) {
for (let i = bullets.length - 1; i >= 0; i--) {
const b = bullets[i];
b.prev.copy(b.pos);
if (b.type === 'enemy') {
b.vel.y -= 3.6 * dt;
}
b.pos.addScaledVector(b.vel, dt);
b.life -= dt;
b.group.position.copy(b.pos);
b.group.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), b.vel.clone().normalize());
let hit = false;
if (b.type === 'player') {
for (const e of enemies) {
if (e.dead) continue;
const center = e.pos.clone(); center.y = e.y + 0.55;
if (segmentSphere(b.prev, b.pos, center, 2.55)) {
e.damage(36, b.pos.clone());
if (sound.ready) sound.hit();
hit = true;
break;
}
}
} else {
const center = player.pos.clone(); center.y = player.y + 0.55;
if (segmentSphere(b.prev, b.pos, center, 2.35)) {
damagePlayer(rand(7.5, 12.5), b.pos.clone());
hit = true;
}
}
if (!hit) {
const wh = waveHeight(b.pos.x, b.pos.z, t);
if (b.pos.y < wh + 0.03) {
const count = b.type === 'enemy' ? 18 : 8;
emitSpray(b.pos.x, wh + 0.08, b.pos.z, count, 2.2, 4.2, 0.45);
if (b.type === 'enemy') spawnFlash(b.pos.x, wh + 0.2, b.pos.z, 1.2, 0x9fe6ee, 0.18, 0.4);
if (sound.ready) sound.splash(0.28);
hit = true;
}
}
if (hit || b.life <= 0 || b.pos.distanceTo(camera.position) > 900) {
scene.remove(b.group);
b.group.traverse(o => { if (o.material && o.material !== playerBulletMat && o.material !== enemyBulletMat) o.material.dispose(); });
bullets.splice(i, 1);
}
}
}
function segmentSphere(a, b, center, radius) {
const ab = new THREE.Vector3().subVectors(b, a);
const ac = new THREE.Vector3().subVectors(center, a);
const abLen2 = ab.lengthSq();
if (abLen2 < 1e-8) return ac.lengthSq() <= radius * radius;
const t = clamp(ac.dot(ab) / abLen2, 0, 1);
const p = a.clone().addScaledVector(ab, t);
return p.distanceToSquared(center) <= radius * radius;
}
function damagePlayer(amount, point = null) {
if (state.gameOver) return;
player.health -= amount;
player.lastHit = clockTime;
damageFlashTimer = Math.max(damageFlashTimer, 0.35);
cameraShake = Math.max(cameraShake, 0.5);
if (point) {
emitFire(point.x, point.y, point.z, 10, 4, 0xffd27d, 0xff4820);
emitSmoke(point.x, point.y, point.z, 4, 1.4, 0x181a18);
}
if (sound.ready) sound.impact();
if (player.health <= 0) {
player.health = 0;
triggerGameOver();
}
}
/* ── player firing / aiming ───────────────────────────────────────────── */
const raycaster = new THREE.Raycaster();
const pointerNDC = new THREE.Vector2(0, 0);
const aimPoint = new THREE.Vector3(0, 0, 80);
const aimPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -0.7);
let aimEnemy = null;
let isFiring = false;
function updateAim(dt, t) {
raycaster.setFromCamera(pointerNDC, camera);
let hitPoint = null;
aimEnemy = null;
const hits = raycaster.intersectObjects(enemyHitboxes, false);
if (hits.length) {
hitPoint = hits[0].point.clone();
aimEnemy = hits[0].object.userData.enemy;
if (aimEnemy && aimEnemy.dead) { aimEnemy = null; hitPoint = null; }
}
if (!hitPoint) {
const p = new THREE.Vector3();
if (raycaster.ray.intersectPlane(aimPlane, p)) {
hitPoint = p;
} else {
hitPoint = raycaster.ray.at(160, new THREE.Vector3());
}
}
aimPoint.lerp(hitPoint, 1 - Math.exp(-dt * 18));
const local = player.group.worldToLocal(aimPoint.clone());
const turretPos = player.turret.position;
const dx = local.x - turretPos.x;
const dz = local.z - turretPos.z;
const targetYaw = Math.atan2(dx, dz);
player.yawAim = smoothAngle(player.yawAim, targetYaw, 9.5, dt);
player.turret.rotation.y = player.yawAim;
const horiz = Math.hypot(dx, dz);
const targetPitch = clamp(-Math.atan2(local.y - turretPos.y - 0.4, Math.max(horiz, 0.2)), -0.3, 0.45);
player.pitchAim = damp(player.pitchAim, targetPitch, 8, dt);
const pitchGroup = player.turret.userData.pitch;
if (pitchGroup) pitchGroup.rotation.x = player.pitchAim;
if (isFiring && state.started && !state.gameOver) {
player.fireCd -= dt;
if (player.fireCd <= 0) {
player.fireCd = 0.16;
firePlayer();
}
} else {
player.fireCd = Math.max(0, player.fireCd - dt);
}
}
function firePlayer() {
const muzzle = player.turret.userData.muzzle;
if (!muzzle) return;
const origin = new THREE.Vector3();
muzzle.getWorldPosition(origin);
const dir = aimPoint.clone().sub(origin).normalize();
const spread = 0.0025;
dir.x += rand(-spread, spread);
dir.y += rand(-spread * 0.4, spread * 0.4);
dir.z += rand(-spread, spread);
dir.normalize();
spawnPlayerBullet(origin, dir);
spawnFlash(origin.x, origin.y, origin.z, 3.6, 0xffc45d, 0.1, 0.95);
emitFire(origin.x, origin.y, origin.z, 4, 3, 0xffeab2, 0xff7a31);
player.recoil = 1;
cameraShake = Math.max(cameraShake, 0.075);
if (sound.ready) sound.fire();
}
/* ── game state / waves ───────────────────────────────────────────────── */
const state = {
started: false,
gameOver: false,
paused: false,
wave: 1,
score: 0,
kills: 0,
spawnQueue: 0,
spawnTimer: 0,
nextWaveTimer: -1,
gameTime: 0,
overTimer: 0,
bannerTimer: 0,
rogueTimer: 0
};
let clockTime = 0;
let cameraShake = 0;
let damageFlashTimer = 0;
let waterFlashTimer = 0;
let bannerTimeout = null;
const input = {
throttle: 0,
steer: 0,
boost: false,
keyW: false, keyS: false, keyA: false, keyD: false
};
function startWave(num) {
state.wave = num;
const count = Math.min(2 + Math.floor(num * 0.9), 7);
state.spawnQueue = count;
state.spawnTimer = 0.6;
state.nextWaveTimer = -1;
addBanner('HOSTILE WAVE ' + String(num).padStart(2, '0'), count + ' CONTACTS', num > 1 ? 'MARAUDERS CLOSING' : 'THEY HAVE YOUR SCENT');
}
function updateGame(dt, t) {
if (!state.started) {
updateAttractCamera(dt, t);
return;
}
if (state.gameOver) {
state.overTimer += dt;
if (state.overTimer > 2.1 && !gameOverShown) {
gameOverShown = true;
showGameOver();
}
player.speed = damp(player.speed, 0, 2, dt);
player.throttle = damp(player.throttle, 0, 2, dt);
player.pitch = damp(player.pitch, 0.34, 0.8, dt);
player.roll = damp(player.roll, 0.62, 0.8, dt);
player.y = damp(player.y, player.y - 0.055, 0.5, dt);
player.group.position.set(player.pos.x, player.y, player.pos.z);
player.group.rotation.set(player.pitch, player.heading, player.roll);
return;
}
state.gameTime += dt;
// rogue swell scheduler
state.rogueTimer += dt;
if (!rogue.active && state.rogueTimer > rogue.next) {
rogue.active = true;
rogue.target = rand(0.75, 1.35);
rogue.dir.set(rand(-1, 1), rand(-1, 1)).normalize();
rogue.offset = rand(-0.8, 0.8);
state.rogueTimer = 0;
rogue.next = rand(16, 27);
rogue.warned = false;
}
if (rogue.active) {
rogue.amp = damp(rogue.amp, rogue.target, 0.65, dt);
const warningThreshold = 0.35;
document.getElementById('rogueWarning').classList.toggle('show', rogue.amp > warningThreshold);
if (rogue.amp > warningThreshold) rogue.warned = true;
if (state.rogueTimer > 10.5) {
rogue.active = false;
rogue.target = 0;
document.getElementById('rogueWarning').classList.remove('show');
}
} else {
rogue.amp = damp(rogue.amp, 0, 0.5, dt);
if (rogue.amp < 0.01) rogue.amp = 0;
document.getElementById('rogueWarning').classList.remove('show');
}
waterUniforms.uRogueAmp.value = rogue.amp;
// waves
if (state.spawnQueue > 0) {
state.spawnTimer -= dt;
if (state.spawnTimer <= 0 && enemies.length < 8) {
spawnEnemy(state.wave);
state.spawnQueue--;
state.spawnTimer = rand(0.9, 1.55);
}
} else if (enemies.length === 0) {
if (state.nextWaveTimer < 0) {
state.nextWaveTimer = 4.0;
addBanner('SECTOR CLEAR', 'REGROUP', 'NEW CONTACTS INBOUND');
} else {
state.nextWaveTimer -= dt;
if (state.nextWaveTimer <= 0) startWave(state.wave + 1);
}
}
for (const e of enemies.slice()) e.update(dt, t);
// player vs enemy collision
for (const e of enemies) {
if (e.dead) continue;
const dx = player.pos.x - e.pos.x;
const dz = player.pos.z - e.pos.z;
const d = Math.hypot(dx, dz);
if (d < 4.2) {
const nx = dx / Math.max(d, 0.01), nz = dz / Math.max(d, 0.01);
player.pos.x += nx * (4.2 - d) * 0.55;
player.pos.z += nz * (4.2 - d) * 0.55;
e.pos.x -= nx * (4.2 - d) * 0.55;
e.pos.z -= nz * (4.2 - d) * 0.55;
if (player.impactCd <= 0) {
player.impactCd = 1.1;
damagePlayer(rand(4, 8), new THREE.Vector3((player.pos.x + e.pos.x) / 2, player.y + 0.4, (player.pos.z + e.pos.z) / 2));
if (sound.ready) sound.crunch();
}
}
}
// island collision for player
for (const isl of islands) {
const dx = player.pos.x - isl.x;
const dz = player.pos.z - isl.z;
const d = Math.hypot(dx, dz);
if (d < isl.radius + 5) {
const nx = dx / Math.max(d, 0.01), nz = dz / Math.max(d, 0.01);
player.pos.x = isl.x + nx * (isl.radius + 5);
player.pos.z = isl.z + nz * (isl.radius + 5);
const fSpeed = player.vel.dot(player.direction);
player.vel.multiplyScalar(-0.25);
if (player.impactCd <= 0 && Math.abs(fSpeed) > 4) {
player.impactCd = 1.2;
damagePlayer(3.5, player.pos.clone());
cameraShake = 0.6;
}
}
}
}
function triggerGameOver() {
if (state.gameOver) return;
state.gameOver = true;
state.overTimer = 0;
gameOverShown = false;
bigExplosion(player.pos.x, player.y + 0.5, player.pos.z, 1.7);
emitFire(player.pos.x, player.y + 0.8, player.pos.z, 80, 13, 0xffe1a0, 0xff3f13);
emitSmoke(player.pos.x, player.y + 1.0, player.pos.z, 35, 5, 0x0c0e0d);
if (sound.ready) sound.explosion(1.2);
cameraShake = 1.4;
isFiring = false;
document.getElementById('reticle').style.opacity = '0';
setTimeout(() => { document.getElementById('reticle').style.opacity = ''; }, 2600);
}
let gameOverShown = false;
/* ── camera ───────────────────────────────────────────────────────────── */
let cameraDistance = 13.2;
function updateCamera(dt, t, dead = false) {
const forward = new THREE.Vector3(Math.sin(player.heading), 0, Math.cos(player.heading));
const right = new THREE.Vector3(Math.cos(player.heading), 0, -Math.sin(player.heading));
const speedNorm = clamp(Math.abs(player.speed) / 31, 0, 1);
const back = cameraDistance + speedNorm * 2.4;
const desired = player.pos.clone()
.addScaledVector(forward, -back)
.addScaledVector(right, 1.55 + clamp(-player.roll * 1.8, -1.2, 1.2));
desired.y = player.y + 4.8 + speedNorm * 0.9;
const localWater = waveHeight(desired.x, desired.z, t);
desired.y = Math.max(desired.y, localWater + 2.1);
if (dead) {
desired.y += 1.6;
desired.addScaledVector(forward, -5);
}
const dampSpeed = dead ? 1.2 : 3.7;
camera.position.lerp(desired, 1 - Math.exp(-dampSpeed * dt));
const look = player.pos.clone().addScaledVector(forward, dead ? 0 : 9.5);
look.y = player.y + (dead ? 0.2 : 1.5);
const waterLook = waveHeight(look.x, look.z, t);
look.y = Math.max(look.y, waterLook + 0.6);
if (cameraShake > 0.001) {
const s = cameraShake;
camera.position.x += Math.sin(t * 91.3) * s * 0.35 + Math.sin(t * 34.7) * s * 0.22;
camera.position.y += Math.sin(t * 77.1) * s * 0.28;
camera.position.z += Math.cos(t * 83.7) * s * 0.34;
look.x += Math.sin(t * 63.9) * s * 0.2;
look.y += Math.sin(t * 57.1) * s * 0.15;
cameraShake *= Math.exp(-5.4 * dt);
}
camera.lookAt(look);
const targetFov = 53 + speedNorm * 8 + (dead ? 4 : 0);
camera.fov = damp(camera.fov, targetFov, 2.0, dt);
camera.updateProjectionMatrix();
}
function updateAttractCamera(dt, t) {
const a = 2.9 + Math.sin(t * 0.055) * 0.16;
const rad = 16.0;
const desired = new THREE.Vector3(
player.pos.x + Math.sin(a) * rad,
player.y + 4.6 + Math.sin(t * 0.17) * 0.25,
player.pos.z + Math.cos(a) * rad
);
const localWater = waveHeight(desired.x, desired.z, t);
desired.y = Math.max(desired.y, localWater + 2.4);
camera.position.lerp(desired, 1 - Math.exp(-1.0 * dt));
const look = new THREE.Vector3(player.pos.x - 1.45, player.y + 2.45, player.pos.z - 0.6);
camera.lookAt(look);
camera.fov = damp(camera.fov, 52, 1.2, dt);
camera.updateProjectionMatrix();
}
/* ── reflection ───────────────────────────────────────────────────────── */
const reflectionMatrix = new THREE.Matrix4();
const camWorldPos = new THREE.Vector3();
const reflectorWorldPos = new THREE.Vector3(0, 0, 0);
const normal = new THREE.Vector3(0, 1, 0);
const view = new THREE.Vector3();
const target = new THREE.Vector3();
const lookAtPosition = new THREE.Vector3();
const rotationMatrix = new THREE.Matrix4();
function updateReflectionCamera() {
camWorldPos.setFromMatrixPosition(camera.matrixWorld);
view.subVectors(reflectorWorldPos, camWorldPos);
view.reflect(normal).negate();
view.add(reflectorWorldPos);
rotationMatrix.extractRotation(camera.matrixWorld);
lookAtPosition.set(0, 0, -1).applyMatrix4(rotationMatrix).add(camWorldPos);
target.subVectors(reflectorWorldPos, lookAtPosition);
target.reflect(normal).negate();
target.add(reflectorWorldPos);
reflectionCamera.position.copy(view);
reflectionCamera.up.set(0, 1, 0).applyMatrix4(rotationMatrix).reflect(normal);
reflectionCamera.lookAt(target);
reflectionCamera.far = camera.far;
reflectionCamera.near = camera.near;
reflectionCamera.aspect = camera.aspect;
reflectionCamera.fov = camera.fov;
reflectionCamera.updateProjectionMatrix();
reflectionCamera.updateMatrixWorld();
reflectionMatrix.set(
0.5, 0, 0, 0.5,
0, 0.5, 0, 0.5,
0, 0, 0.5, 0.5,
0, 0, 0, 1
);
reflectionMatrix.multiply(reflectionCamera.projectionMatrix);
reflectionMatrix.multiply(reflectionCamera.matrixWorldInverse);
waterUniforms.uTextureMatrix.value.copy(reflectionMatrix);
}
function renderReflection() {
skyMaterial.uniforms.uOutput.value = 0;
sunGlow.visible = false;
sunCore.visible = false;
ocean.visible = false;
aimMarker.visible = false;
const hider = [sprayPool.points, firePool.points, smokePool.points];
hider.forEach(p => p.visible = false);
wakeTrail.mesh.visible = false;
for (const b of bullets) b.group.visible = false;
renderer.clippingPlanes = [reflectionClip];
renderer.setRenderTarget(reflectionRT);
renderer.render(scene, reflectionCamera);
renderer.setRenderTarget(null);
renderer.clippingPlanes = [];
skyMaterial.uniforms.uOutput.value = 1;
sunGlow.visible = true;
sunCore.visible = true;
ocean.visible = true;
wakeTrail.mesh.visible = true;
hider.forEach(p => p.visible = true);
for (const b of bullets) b.group.visible = true;
}
/* ── audio ────────────────────────────────────────────────────────────── */
class Soundscape {
constructor() {
this.ctx = null;
this.ready = false;
this.muted = false;
this.master = null;
this.engineGain = null;
this.engineOsc = [];
this.engineFilter = null;
this.oceanGain = null;
this.windGain = null;
}
init() {
if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
const ctx = new AC();
this.ctx = ctx;
const master = ctx.createGain();
master.gain.value = this.muted ? 0 : 0.72;
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -18;
comp.knee.value = 18;
comp.ratio.value = 4;
comp.attack.value = 0.006;
comp.release.value = 0.24;
master.connect(comp);
comp.connect(ctx.destination);
this.master = master;
// ocean noise
const length = ctx.sampleRate * 5;
const buf = ctx.createBuffer(2, length, ctx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
let last = 0;
for (let i = 0; i < length; i++) {
const white = Math.random() * 2 - 1;
last = last * 0.985 + white * 0.015;
d[i] = last * 3.2 + white * 0.08;
}
}
const oceanSrc = ctx.createBufferSource();
oceanSrc.buffer = buf; oceanSrc.loop = true;
const oceanFilter = ctx.createBiquadFilter();
oceanFilter.type = 'lowpass'; oceanFilter.frequency.value = 620; oceanFilter.Q.value = 0.5;
const oceanGain = ctx.createGain();
oceanGain.gain.value = 0.15;
oceanSrc.connect(oceanFilter); oceanFilter.connect(oceanGain); oceanGain.connect(master);
oceanSrc.start();
this.oceanGain = oceanGain;
const windSrc = ctx.createBufferSource();
windSrc.buffer = buf; windSrc.loop = true;
const windFilter = ctx.createBiquadFilter();
windFilter.type = 'bandpass'; windFilter.frequency.value = 1300; windFilter.Q.value = 0.45;
const windGain = ctx.createGain();
windGain.gain.value = 0.045;
windSrc.connect(windFilter); windFilter.connect(windGain); windGain.connect(master);
windSrc.start();
this.windGain = windGain;
// engine hum
const engineFilter = ctx.createBiquadFilter();
engineFilter.type = 'lowpass'; engineFilter.frequency.value = 240; engineFilter.Q.value = 3.0;
const engineGain = ctx.createGain();
engineGain.gain.value = 0.0;
engineFilter.connect(engineGain); engineGain.connect(master);
const baseFreqs = [38, 57, 114];
baseFreqs.forEach((f, i) => {
const osc = ctx.createOscillator();
osc.type = i === 2 ? 'triangle' : 'sawtooth';
osc.frequency.value = f;
const g = ctx.createGain();
g.gain.value = i === 2 ? 0.12 : 0.18;
osc.connect(g); g.connect(engineFilter);
osc.start();
this.engineOsc.push(osc);
});
this.engineFilter = engineFilter;
this.engineGain = engineGain;
this.ready = true;
if (ctx.state === 'suspended') ctx.resume();
}
setEngine(speed, throttle, boost) {
if (!this.ready) return;
const t = this.ctx.currentTime;
const n = clamp(Math.abs(speed) / 32, 0, 1);
const target = state.started && !state.gameOver ? 0.04 + n * 0.15 + (boost ? 0.03 : 0) : 0;
this.engineGain.gain.setTargetAtTime(target, t, 0.2);
const f = 38 + n * 64 + (boost ? 12 : 0);
this.engineOsc[0].frequency.setTargetAtTime(f, t, 0.3);
this.engineOsc[1].frequency.setTargetAtTime(f * 1.5, t, 0.3);
this.engineOsc[2].frequency.setTargetAtTime(f * 3.0, t, 0.3);
this.engineFilter.frequency.setTargetAtTime(160 + n * 420 + throttle * 80, t, 0.25);
if (this.oceanGain) this.oceanGain.gain.setTargetAtTime(0.11 + rogue.amp * 0.06 + n * 0.035, t, 1.0);
if (this.windGain) this.windGain.gain.setTargetAtTime(0.04 + n * 0.11, t, 0.4);
}
noiseBurst(duration, volume, freq, q = 1) {
if (!this.ready) return;
const ctx = this.ctx;
const len = Math.max(1, Math.floor(ctx.sampleRate * duration));
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buf;
const filter = ctx.createBiquadFilter();
filter.type = 'bandpass'; filter.frequency.value = freq; filter.Q.value = q;
const g = ctx.createGain();
const t = ctx.currentTime;
g.gain.setValueAtTime(volume, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + duration);
src.connect(filter); filter.connect(g); g.connect(this.master);
src.start(t); src.stop(t + duration + 0.02);
}
tone(from, to, duration, volume, type = 'sine') {
if (!this.ready) return;
const ctx = this.ctx;
const osc = ctx.createOscillator();
const g = ctx.createGain();
const t = ctx.currentTime;
osc.type = type;
osc.frequency.setValueAtTime(from, t);
osc.frequency.exponentialRampToValueAtTime(Math.max(1, to), t + duration);
g.gain.setValueAtTime(volume, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + duration);
osc.connect(g); g.connect(this.master);
osc.start(t); osc.stop(t + duration + 0.02);
}
fire() {
this.noiseBurst(0.16, 0.34, 1500, 0.7);
this.tone(170, 42, 0.2, 0.3, 'sine');
}
enemyFire(vol = 0.4) {
this.noiseBurst(0.12, 0.13 * vol, 900, 0.8);
this.tone(120, 40, 0.16, 0.1 * vol, 'sine');
}
hit() { this.noiseBurst(0.09, 0.16, 3200, 1.2); this.tone(260, 90, 0.08, 0.06, 'square'); }
impact() {
this.noiseBurst(0.35, 0.4, 240, 0.6);
this.tone(92, 28, 0.42, 0.35, 'sine');
this.noiseBurst(0.2, 0.18, 2200, 0.9);
}
explosion(size = 1) {
this.noiseBurst(0.65 * size, 0.44 * size, 130, 0.5);
this.tone(82, 22, 0.8 * size, 0.5 * size, 'sine');
this.noiseBurst(0.3, 0.2 * size, 900, 0.5);
}
splash(vol = 0.4) { this.noiseBurst(0.5, 0.14 * vol, 750, 0.5); }
crunch() {
this.noiseBurst(0.18, 0.3, 350, 1.5);
this.tone(65, 30, 0.2, 0.25, 'square');
}
toggleMute() {
this.muted = !this.muted;
if (this.master) this.master.gain.setTargetAtTime(this.muted ? 0 : 0.72, this.ctx.currentTime, 0.06);
return this.muted;
}
}
const sound = new Soundscape();
/* ── input / UI ───────────────────────────────────────────────────────── */
const reticle = document.getElementById('reticle');
const bannerEl = document.getElementById('banner');
const bannerKicker = document.getElementById('bannerKicker');
const bannerTitle = document.getElementById('bannerTitle');
const bannerSub = document.getElementById('bannerSub');
const audioBtn = document.getElementById('audioBtn');
function addBanner(kicker, title, sub) {
bannerKicker.textContent = kicker;
bannerTitle.textContent = title;
bannerSub.textContent = sub;
bannerEl.classList.remove('show');
void bannerEl.offsetWidth;
bannerEl.classList.add('show');
clearTimeout(bannerTimeout);
bannerTimeout = setTimeout(() => bannerEl.classList.remove('show'), 2400);
}
function splashFlash(amount = 0.3) {
waterFlashTimer = Math.max(waterFlashTimer, amount);
}
function showGameOver() {
document.getElementById('goScore').textContent = state.score;
document.getElementById('goKills').textContent = state.kills;
document.getElementById('goWave').textContent = state.wave;
document.getElementById('gameOver').classList.add('show');
document.getElementById('pauseOverlay').classList.remove('show');
document.getElementById('app').classList.remove('playing');
}
function startGame() {
sound.init();
document.getElementById('loading').classList.add('done');
document.getElementById('startScreen').style.display = 'none';
document.getElementById('gameOver').classList.remove('show');
document.getElementById('pauseOverlay').classList.remove('show');
document.getElementById('app').classList.add('playing');
document.getElementById('reticle').style.opacity = '';
state.started = true;
state.gameOver = false;
state.paused = false;
state.wave = 1;
state.score = 0;
state.kills = 0;
state.gameTime = 0;
state.spawnQueue = 0;
state.nextWaveTimer = -1;
state.rogueTimer = 0;
rogue.active = false;
rogue.amp = 0;
rogue.next = rand(11, 16);
waterUniforms.uRogueAmp.value = 0;
player.health = 100;
player.lastHit = -100;
player.pos.set(0, 0, 0);
player.heading = 0;
player.vel.set(0, 0, 0);
player.speed = 0;
player.throttle = 0;
player.y = waveHeight(0, 0, clockTime) + 0.13;
player.vy = 0;
player.pitch = 0; player.roll = 0; player.pitchV = 0; player.rollV = 0;
player.waveHitTimer = rand(5, 8);
player.roosterTimer = 0;
player.group.position.set(0, player.y, 0);
player.group.rotation.set(0, 0, 0);
camera.position.set(-8, 6, -14);
cameraShake = 0;
isFiring = false;
input.keyW = input.keyS = input.keyA = input.keyD = false;
input.throttle = 0; input.steer = 0; input.boost = false;
// clear previous
for (const e of enemies.slice()) e.dispose();
for (const b of bullets.slice()) {
scene.remove(b.group);
b.group.traverse(o => {
if (o.material && o.material !== playerBulletMat && o.material !== enemyBulletMat) o.material.dispose();
});
}
bullets.length = 0;
enemyHitboxes.length = 0;
sprayPool.clear(); firePool.clear(); smokePool.clear(); wakeTrail.clear();
addBanner('DEPLOYMENT', 'WAVE 01', 'MARAUDERS INBOUND');
startWave(1);
document.getElementById('controlsHint').classList.remove('fade');
setTimeout(() => document.getElementById('controlsHint').classList.add('fade'), 11000);
}
function restartGame() {
document.getElementById('gameOver').classList.remove('show');
startGame();
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', restartGame);
audioBtn.addEventListener('click', () => {
sound.init();
const muted = sound.toggleMute();
audioBtn.textContent = muted ? 'SOUND OFF' : 'SOUND ON';
});
window.addEventListener('pointermove', e => {
pointerNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
pointerNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
reticle.style.left = e.clientX + 'px';
reticle.style.top = e.clientY + 'px';
});
window.addEventListener('pointerdown', e => {
if (e.target.closest('button, .screen')) return;
if (e.button === 0 && state.started && !state.gameOver) {
isFiring = true;
sound.init();
}
});
window.addEventListener('pointerup', e => {
if (e.button === 0) isFiring = false;
});
window.addEventListener('blur', () => {
isFiring = false;
input.keyW = input.keyS = input.keyA = input.keyD = false;
input.boost = false;
});
window.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('wheel', e => {
cameraDistance = clamp(cameraDistance + Math.sign(e.deltaY) * 0.9, 8.5, 19);
}, { passive: true });
window.addEventListener('keydown', e => {
const k = e.key.toLowerCase();
if (k === 'w' || e.key === 'ArrowUp') { input.keyW = true; e.preventDefault(); }
if (k === 's' || e.key === 'ArrowDown') { input.keyS = true; e.preventDefault(); }
if (k === 'a' || e.key === 'ArrowLeft') { input.keyA = true; e.preventDefault(); }
if (k === 'd' || e.key === 'ArrowRight') { input.keyD = true; e.preventDefault(); }
if (e.key === 'Shift') input.boost = true;
if (k === 'm') {
sound.init();
const muted = sound.toggleMute();
audioBtn.textContent = muted ? 'SOUND OFF' : 'SOUND ON';
}
if (k === 'r' && state.gameOver) restartGame();
if (k === 'p' && state.started && !state.gameOver) {
state.paused = !state.paused;
document.getElementById('pauseOverlay').classList.toggle('show', state.paused);
if (!state.paused) addBanner('RESUMED', 'RETURN TO WATCH', 'STAY SHARP');
}
});
window.addEventListener('keyup', e => {
const k = e.key.toLowerCase();
if (k === 'w' || e.key === 'ArrowUp') input.keyW = false;
if (k === 's' || e.key === 'ArrowDown') input.keyS = false;
if (k === 'a' || e.key === 'ArrowLeft') input.keyA = false;
if (k === 'd' || e.key === 'ArrowRight') input.keyD = false;
if (e.key === 'Shift') input.boost = false;
});
/* ── HUD update ───────────────────────────────────────────────────────── */
let hudAccum = 0;
const radarCanvas = document.getElementById('radar');
const radarCtx = radarCanvas.getContext('2d');
const hud = {
hullVal: document.getElementById('hullVal'),
hullFill: document.getElementById('hullFill'),
speedVal: document.getElementById('speedVal'),
headingVal: document.getElementById('headingVal'),
waveVal: document.getElementById('waveVal'),
scoreVal: document.getElementById('scoreVal'),
enemyVal: document.getElementById('enemyVal'),
killVal: document.getElementById('killVal'),
threatVal: document.getElementById('threatVal')
};
function drawRadar() {
const ctx = radarCtx;
const w = radarCanvas.width, h = radarCanvas.height;
const cx = w / 2, cy = h / 2, R = 86;
ctx.clearRect(0, 0, w, h);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, TAU);
ctx.clip();
// grid
ctx.strokeStyle = 'rgba(136,210,222,.13)';
ctx.lineWidth = 1;
for (const r of [R * 0.33, R * 0.66, R]) {
ctx.beginPath(); ctx.arc(cx, cy, r, 0, TAU); ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(cx - R, cy); ctx.lineTo(cx + R, cy);
ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R);
ctx.stroke();
// sweep
const a = -Math.PI / 2 + (clockTime * 1.15) % TAU;
const grad = ctx.createLinearGradient(cx, cy, cx + Math.cos(a) * R, cy + Math.sin(a) * R);
grad.addColorStop(0, 'rgba(130,235,250,.34)');
grad.addColorStop(1, 'rgba(130,235,250,0)');
ctx.strokeStyle = grad;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R); ctx.stroke();
const range = 250;
const scale = R / range;
const cosH = Math.cos(player.heading), sinH = Math.sin(player.heading);
for (const e of enemies) {
if (e.dead) continue;
const dx = e.pos.x - player.pos.x;
const dz = e.pos.z - player.pos.z;
let rx = dx * cosH - dz * sinH;
let rz = dx * sinH + dz * cosH;
const dist = Math.hypot(rx, rz);
if (dist > range) {
rx = rx / dist * range * 0.94;
rz = rz / dist * range * 0.94;
}
const px = cx + rx * scale;
const py = cy - rz * scale;
const near = dist < 55;
ctx.shadowBlur = near ? 10 : 5;
ctx.shadowColor = near ? 'rgba(255,88,62,.9)' : 'rgba(255,110,76,.6)';
ctx.fillStyle = near ? '#ff5b42' : 'rgba(255,116,82,.86)';
ctx.beginPath();
ctx.arc(px, py, near ? 3.4 : 2.4, 0, TAU);
ctx.fill();
ctx.shadowBlur = 0;
if (near) {
ctx.strokeStyle = 'rgba(255,92,66,.34)';
ctx.beginPath(); ctx.arc(px, py, 6.5, 0, TAU); ctx.stroke();
}
}
// player
ctx.shadowBlur = 9;
ctx.shadowColor = 'rgba(150,241,255,.9)';
ctx.fillStyle = '#a9f3ff';
ctx.beginPath();
ctx.moveTo(cx, cy - 7);
ctx.lineTo(cx - 4.4, cy + 4.6);
ctx.lineTo(cx, cy + 2.3);
ctx.lineTo(cx + 4.4, cy + 4.6);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.restore();
// outer ring + cardinal ticks
ctx.strokeStyle = 'rgba(142,229,243,.28)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(cx, cy, R, 0, TAU); ctx.stroke();
ctx.fillStyle = 'rgba(208,241,245,.45)';
ctx.font = '9px "Barlow Condensed", sans-serif';
ctx.textAlign = 'center';
ctx.fillText('N', cx, cy - R + 10);
}
function updateHUD(dt) {
hudAccum += dt;
if (hudAccum < 0.055) return;
hudAccum = 0;
const hp = Math.max(0, player.health);
hud.hullVal.textContent = Math.round(hp);
hud.hullFill.style.transform = `scaleX(${clamp(hp / player.maxHealth, 0, 1)})`;
hud.hullFill.style.background = hp > 55 ? 'linear-gradient(90deg,#2c8ca0,#9cf0ff)' : hp > 28 ? 'linear-gradient(90deg,#b07427,#ffcf7d)' : 'linear-gradient(90deg,#9e2317,#ff6b51)';
const knots = Math.abs(player.speed) * 1.94384;
hud.speedVal.textContent = knots.toFixed(1);
let heading = (player.heading * 180 / Math.PI + 360) % 360;
hud.headingVal.textContent = String(Math.round(heading)).padStart(3, '0');
hud.waveVal.textContent = String(state.wave).padStart(2, '0');
hud.scoreVal.textContent = state.score.toLocaleString('en-US');
hud.enemyVal.textContent = enemies.filter(e => !e.dead).length;
hud.killVal.textContent = state.kills;
let threat = 'CLEAR';
if (state.gameOver) threat = 'SIGNAL LOST';
else if (enemies.some(e => !e.dead && e.pos.distanceTo(player.pos) < 45)) threat = 'CLOSE ACTION';
else if (enemies.some(e => !e.dead)) threat = 'CONTACTS TRACKING';
else if (state.spawnQueue > 0) threat = 'INBOUND';
hud.threatVal.textContent = threat;
drawRadar();
reticle.classList.toggle('target', !!aimEnemy && !aimEnemy.dead);
if (damageFlashTimer > 0) {
damageFlashTimer -= 0.08;
document.getElementById('damageFlash').style.opacity = clamp(damageFlashTimer * 2.4, 0, 0.95);
} else {
document.getElementById('damageFlash').style.opacity = '0';
}
if (waterFlashTimer > 0) {
waterFlashTimer -= 0.08;
document.getElementById('waterFlash').style.opacity = clamp(waterFlashTimer * 1.6, 0, 0.8);
} else {
document.getElementById('waterFlash').style.opacity = '0';
}
}
/* ── animation loop ───────────────────────────────────────────────────── */
let last = performance.now();
let frameCount = 0;
function animate(now) {
requestAnimationFrame(animate);
const rawDt = (now - last) / 1000;
last = now;
const dt = Math.min(rawDt, 0.05);
clockTime += dt;
const t = clockTime;
if (state.paused) {
waterUniforms.uTime.value = t;
skyMaterial.uniforms.uTime.value = t;
renderer.render(scene, camera);
return;
}
input.throttle = (input.keyW ? 1 : 0) + (input.keyS ? -1 : 0);
input.steer = (input.keyD ? 1 : 0) + (input.keyA ? -1 : 0);
if (state.started) {
// correct gameTime double increment from updateGame
updatePlayer(dt, t, input);
updateAim(dt, t);
updateGame(dt, t);
updateBullets(dt, t);
updateCamera(dt, t, state.gameOver);
updateHUD(dt);
sound.setEngine(player.speed, player.throttle, input.boost);
} else {
// attract-mode buoyancy so the boat still breathes with the swell
const hc = waveHeight(player.pos.x, player.pos.z, t);
player.y = lerp(player.y, hc + 0.13, 1 - Math.exp(-2.2 * dt));
const fwd = new THREE.Vector3(Math.sin(player.heading), 0, Math.cos(player.heading));
const rgt = new THREE.Vector3(Math.cos(player.heading), 0, -Math.sin(player.heading));
const hb = waveHeight(player.pos.x + fwd.x * 3.2, player.pos.z + fwd.z * 3.2, t);
const hs = waveHeight(player.pos.x - fwd.x * 3.2, player.pos.z - fwd.z * 3.2, t);
const hp = waveHeight(player.pos.x - rgt.x * 1.3, player.pos.z - rgt.z * 1.3, t);
const hst = waveHeight(player.pos.x + rgt.x * 1.3, player.pos.z + rgt.z * 1.3, t);
player.pitch = damp(player.pitch, -Math.atan2(hb - hs, 6.4) * 1.1, 2.5, dt);
player.roll = damp(player.roll, Math.atan2(hst - hp, 2.6), 2.4, dt);
player.group.position.set(player.pos.x, player.y, player.pos.z);
player.group.rotation.order = 'YXZ';
player.group.rotation.set(player.pitch, player.heading, player.roll);
updateAttractCamera(dt, t);
updateHUD(dt);
}
sprayPool.update(dt, t);
firePool.update(dt, t);
smokePool.update(dt, t);
wakeTrail.update(dt, t);
updateFX(dt);
// sky / clouds / sun
skyMaterial.uniforms.uTime.value = t;
skyMaterial.uniforms.uCloudOffset.value = (t * 0.00075) % 1;
ocean.position.set(player.pos.x, 0, player.pos.z);
skyMesh.position.copy(camera.position);
sunGlow.position.copy(camera.position).addScaledVector(sunDir, 4000);
sunCore.position.copy(camera.position).addScaledVector(sunDir, 3990);
waterUniforms.uTime.value = t;
waterUniforms.uCamPos.value.copy(camera.position);
waterUniforms.uBoatPos.value.set(player.pos.x, player.y, player.pos.z);
waterUniforms.uBoatSpeed.value = Math.abs(player.speed);
waterUniforms.uBoatShadow.value = state.started ? 1 : 1;
skyMaterial.uniforms.uOutput.value = 1;
// light + shadow follow
sunLight.target.position.set(player.pos.x, 0, player.pos.z);
sunLight.position.set(player.pos.x, 0, player.pos.z).addScaledVector(sunDir, 160);
sunLight.target.updateMatrixWorld();
sunLight.updateMatrixWorld();
// animation details
const radar = playerBoat.getObjectByName('radarSpin');
if (radar) radar.rotation.y = t * 2.2;
const flag = playerBoat.getObjectByName('flag');
if (flag) {
const p = flag.geometry.attributes.position;
const base = flag.userData.base;
for (let i = 0; i < p.count; i++) {
const bx = base[i * 3], by = base[i * 3 + 1], bz = base[i * 3 + 2];
const u = clamp(-bx / 1.55, 0, 1);
p.setX(i, bx + Math.sin(t * 5.2 + u * 5.4 + by * 2.0) * 0.11 * u);
p.setY(i, by + Math.sin(t * 4.1 + u * 3.2) * 0.045 * u);
p.setZ(i, bz + Math.sin(t * 6.3 + u * 6.1) * 0.09 * u);
}
p.needsUpdate = true;
}
// particle point scale
const pointScale = (renderer.domElement.height / (2 * Math.tan(camera.fov * Math.PI / 360))) / renderer.getPixelRatio();
[sprayPool, firePool, smokePool].forEach(pool => {
pool.material.uniforms.uScale.value = pointScale * renderer.getPixelRatio();
pool.material.uniforms.uExposure.value = renderer.toneMappingExposure;
});
// reflection uses final camera
camera.updateMatrixWorld();
updateReflectionCamera();
renderReflection();
renderer.render(scene, camera);
if (!state.started) {
// keep reticle hidden on attract
reticle.style.opacity = '0';
} else if (!state.gameOver) {
reticle.style.opacity = '';
}
frameCount++;
if (frameCount === 3) {
document.getElementById('loading').classList.add('done');
window.__appReady = true;
}
}
/* ── resize ───────────────────────────────────────────────────────────── */
function onResize() {
const w = window.innerWidth, h = window.innerHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
const size = new THREE.Vector2();
renderer.getDrawingBufferSize(size);
waterUniforms.uResolution.value.copy(size);
reflectionRT.setSize(Math.max(2, Math.floor(size.x * 0.55)), Math.max(2, Math.floor(size.y * 0.55)));
}
window.addEventListener('resize', onResize);
onResize();
/* ── boot ─────────────────────────────────────────────────────────────── */
player.pos.set(0, 0, 0);
player.y = waveHeight(0, 0, clockTime) + 0.13;
player.group.position.set(0, player.y, 0);
camera.position.set(14.6, 6.2, -13.0);
updateReflectionCamera();
renderer.render(scene, camera);
requestAnimationFrame(animate);
// debug hook used for automated visual checks
window.__NEREID = {
state, player, enemies, rogue,
start: startGame,
restart: restartGame,
gameOver: triggerGameOver,
spawnEnemy: () => spawnEnemy(state.wave),
fire: () => firePlayer(),
setCamera: (x, y, z) => camera.position.set(x, y, z),
camera, scene, renderer, ocean, waterUniforms, skyMaterial, TEX, islands,
pools: { sprayPool, firePool, smokePool }, bullets, sound
};
</script>
</body>
</html>Watch the test
Video tests of this model on coding and agent work.
Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.