Boat game, built by Gemini 4 Argon
Hostedvia DaBenchOpenRouter
$0.23
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.0">
<title>TEMPEST VANGUARD — Realistic Naval Wave Physics & Combat</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #040b14;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color: #e2f1ff;
cursor: crosshair;
}
#glCanvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
#fxCanvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2;
}
/* HUD OVERLAY */
#hud {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 3;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 18px 24px;
}
.hud-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
}
.panel {
background: rgba(6, 18, 32, 0.72);
border: 1px solid rgba(90, 190, 255, 0.28);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), inset 0 0 18px rgba(40, 150, 255, 0.08);
backdrop-filter: blur(8px);
border-radius: 8px;
padding: 12px 16px;
pointer-events: auto;
}
.status-block {
min-width: 280px;
}
.title-tag {
font-size: 11px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
color: #6bc5ff;
margin-bottom: 6px;
display: flex;
justify-content: space-between;
align-items: center;
}
.bar-wrap {
width: 100%;
height: 12px;
background: rgba(0, 0, 0, 0.6);
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
margin-bottom: 8px;
}
.bar-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #00e676, #69f0ae);
transition: width 0.15s ease-out, background 0.3s;
box-shadow: 0 0 10px rgba(0, 230, 118, 0.6);
}
.stats-row {
display: flex;
gap: 18px;
font-size: 13px;
font-weight: 600;
color: #b8dcff;
}
.stats-row span b {
color: #fff;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.controls-bar {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.btn {
background: rgba(18, 48, 82, 0.78);
border: 1px solid rgba(100, 195, 255, 0.35);
color: #d6eeff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
padding: 7px 11px;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover {
background: rgba(36, 94, 156, 0.9);
border-color: #7cd4ff;
color: #fff;
transform: translateY(-1px);
}
.btn.active {
background: linear-gradient(135deg, #0088ff, #00c6ff);
color: #02101f;
border-color: #a6e9ff;
box-shadow: 0 0 12px rgba(0, 174, 255, 0.5);
}
.btn.warn {
background: rgba(140, 35, 25, 0.8);
border-color: rgba(255, 110, 90, 0.55);
color: #ffd4cc;
}
.btn.warn:hover {
background: rgba(200, 45, 30, 0.95);
box-shadow: 0 0 14px rgba(255, 70, 50, 0.6);
}
/* CENTER ALERTS */
#alertBanner {
align-self: center;
background: rgba(160, 20, 20, 0.82);
border: 1px solid #ff6b6b;
box-shadow: 0 0 30px rgba(255, 40, 40, 0.6);
padding: 10px 26px;
border-radius: 6px;
font-size: 14px;
font-weight: 800;
letter-spacing: 2.5px;
text-transform: uppercase;
color: #fff;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
text-align: center;
}
#alertBanner.visible {
opacity: 1;
transform: translateY(0);
animation: pulseAlert 1s infinite alternate;
}
@keyframes pulseAlert {
from { box-shadow: 0 0 15px rgba(255, 50, 50, 0.45); }
to { box-shadow: 0 0 35px rgba(255, 80, 50, 0.95); }
}
/* BOTTOM HUD */
.hud-bottom {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
}
.radar-box {
display: flex;
align-items: center;
gap: 14px;
}
#radarCanvas {
width: 136px;
height: 136px;
border-radius: 50%;
border: 2px solid rgba(100, 205, 255, 0.45);
background: radial-gradient(circle, rgba(6, 38, 60, 0.9) 0%, rgba(2, 14, 26, 0.95) 100%);
box-shadow: 0 0 20px rgba(0, 150, 255, 0.25), inset 0 0 15px rgba(0, 200, 255, 0.2);
}
.key-hints {
font-size: 12px;
line-height: 1.65;
color: #a6cde8;
}
.key {
display: inline-block;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 4px;
padding: 1px 6px;
font-size: 11px;
font-weight: 700;
color: #fff;
margin-right: 4px;
}
.telemetry-panel {
min-width: 270px;
display: flex;
flex-direction: column;
gap: 8px;
}
.weapons-row {
display: flex;
gap: 10px;
}
.weapon-slot {
flex: 1;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(100, 190, 255, 0.22);
border-radius: 6px;
padding: 6px 8px;
}
.weapon-name {
font-size: 10px;
font-weight: 700;
color: #7ccbff;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 4px;
display: flex;
justify-content: space-between;
}
.cooldown-bar {
height: 5px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
overflow: hidden;
}
.cooldown-fill {
height: 100%;
width: 100%;
background: #00d2ff;
transition: width 0.05s linear;
}
.gauges-row {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #9ec8e8;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 6px;
font-variant-numeric: tabular-nums;
}
.gauges-row b {
color: #fff;
}
/* GAME OVER MODAL */
#modalOverlay {
position: absolute;
inset: 0;
background: rgba(2, 8, 18, 0.82);
backdrop-filter: blur(10px);
z-index: 10;
display: none;
align-items: center;
justify-content: center;
}
#modalOverlay.show {
display: flex;
}
.modal-card {
background: rgba(8, 24, 44, 0.95);
border: 1px solid rgba(100, 200, 255, 0.45);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8);
border-radius: 12px;
padding: 32px 42px;
text-align: center;
max-width: 440px;
}
.modal-card h1 {
font-size: 28px;
letter-spacing: 3px;
color: #ff5e5e;
margin-bottom: 10px;
text-transform: uppercase;
}
.modal-card p {
font-size: 15px;
color: #b8dcff;
margin-bottom: 24px;
line-height: 1.5;
}
.modal-card .btn {
font-size: 14px;
padding: 12px 28px;
}
</style>
</head>
<body>
<canvas id="glCanvas"></canvas>
<canvas id="fxCanvas"></canvas>
<div id="hud">
<div class="hud-top">
<div class="panel status-block">
<div class="title-tag">
<span>HULL INTEGRITY</span>
<span id="hullPctText">100%</span>
</div>
<div class="bar-wrap">
<div class="bar-fill" id="hullBar"></div>
</div>
<div class="stats-row">
<span>SCORE: <b id="scoreVal">0</b></span>
<span>FLEET WAVE: <b id="waveVal">1</b></span>
<span>HOSTILES: <b id="enemiesVal">0</b></span>
</div>
</div>
<div class="panel controls-bar">
<span style="font-size:11px;font-weight:700;color:#7ccbff;letter-spacing:1px;margin-right:4px;">SEA STATE:</span>
<button class="btn active" data-preset="sunset">Golden Swell</button>
<button class="btn" data-preset="noon">Tropical Noon</button>
<button class="btn" data-preset="storm">North Sea Storm</button>
<button class="btn" data-preset="night">Moonlit Raid</button>
<button class="btn warn" id="rogueBtn" title="Trigger an immediate towering Rogue Wave">⚡ Summon Rogue Wave</button>
<button class="btn" id="audioBtn">🔊 Audio: ON</button>
</div>
</div>
<div id="alertBanner">⚠️ MASSIVE ROGUE WAVE APPROACHING — TURN BOW INTO WAVE! ⚠️</div>
<div class="hud-bottom">
<div class="panel radar-box">
<canvas id="radarCanvas" width="160" height="160"></canvas>
<div class="key-hints">
<div><span class="key">W</span><span class="key">S</span> Throttle Forward / Reverse</div>
<div><span class="key">A</span><span class="key">D</span> Rudder Port / Starboard</div>
<div><span class="key">SHIFT</span> Turbo Hydro-Boost</div>
<div><span class="key">MOUSE</span> Aim Deck Turret (360°)</div>
<div><span class="key">L-CLICK</span> Twin 120mm Cannons</div>
<div><span class="key">R-CLICK</span> Broadside Depth Salvo</div>
<div><span class="key">SCROLL</span> Zoom Camera</div>
</div>
</div>
<div class="panel telemetry-panel">
<div class="weapons-row">
<div class="weapon-slot">
<div class="weapon-name"><span>120mm Turret</span><span id="w1Status">READY</span></div>
<div class="cooldown-bar"><div class="cooldown-fill" id="w1Bar"></div></div>
</div>
<div class="weapon-slot">
<div class="weapon-name"><span>Salvo Mortar</span><span id="w2Status">READY</span></div>
<div class="cooldown-bar"><div class="cooldown-fill" id="w2Bar"></div></div>
</div>
<div class="weapon-slot">
<div class="weapon-name"><span>Turbo Boost</span><span id="boostStatus">100%</span></div>
<div class="cooldown-bar"><div class="cooldown-fill" id="boostBar"></div></div>
</div>
</div>
<div class="gauges-row">
<span>SPEED: <b id="speedVal">0.0</b> kts</span>
<span>PITCH: <b id="pitchVal">0°</b></span>
<span>ROLL: <b id="rollVal">0°</b></span>
<span>WAVE HT: <b id="waveHtVal">0.0m</b></span>
</div>
</div>
</div>
</div>
<div id="modalOverlay">
<div class="modal-card">
<h1>VESSEL SUNK</h1>
<p id="finalStatsText">Your warship was claimed by the sea after destroying 0 hostile vessels.</p>
<button class="btn active" id="restartBtn">DEPLOY NEW DESTROYER</button>
</div>
</div>
<script>
// ============================================================================
// SELF-CONTAINED THREE.JS-INSPIRED WEBGL2 PBR & GERSTNER OCEAN ENGINE
// ============================================================================
// Provides a clean Three.js-style scene graph & math architecture with bespoke
// custom GLSL shaders for Gerstner + Rogue wave hydrodynamics, subsurface
// scattering, Kelvin wakes, multi-point buoyancy physics, and naval combat.
const THREE = (() => {
class Vector3 {
constructor(x = 0, y = 0, z = 0) { this.x = x; this.y = y; this.z = z; }
set(x, y, z) { this.x = x; this.y = y; this.z = z; return this; }
copy(v) { this.x = v.x; this.y = v.y; this.z = v.z; return this; }
clone() { return new Vector3(this.x, this.y, this.z); }
add(v) { this.x += v.x; this.y += v.y; this.z += v.z; return this; }
sub(v) { this.x -= v.x; this.y -= v.y; this.z -= v.z; return this; }
addScaledVector(v, s) { this.x += v.x * s; this.y += v.y * s; this.z += v.z * s; return this; }
multiplyScalar(s) { this.x *= s; this.y *= s; this.z *= s; return this; }
dot(v) { return this.x * v.x + this.y * v.y + this.z * v.z; }
cross(v) {
const ax = this.x, ay = this.y, az = this.z;
this.x = ay * v.z - az * v.y;
this.y = az * v.x - ax * v.z;
this.z = ax * v.y - ay * v.x;
return this;
}
lengthSq() { return this.x * this.x + this.y * this.y + this.z * this.z; }
length() { return Math.hypot(this.x, this.y, this.z); }
normalize() {
const l = this.length() || 1;
return this.multiplyScalar(1 / l);
}
distanceTo(v) { return Math.hypot(this.x - v.x, this.y - v.y, this.z - v.z); }
lerp(v, a) {
this.x += (v.x - this.x) * a;
this.y += (v.y - this.y) * a;
this.z += (v.z - this.z) * a;
return this;
}
}
class Matrix4 {
constructor() {
this.elements = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
]);
}
identity() {
const e = this.elements;
e.fill(0); e[0] = e[5] = e[10] = e[15] = 1;
return this;
}
copy(m) { this.elements.set(m.elements); return this; }
multiplyMatrices(a, b) {
const ae = a.elements, be = b.elements, te = this.elements;
const a11 = ae[0], a12 = ae[4], a13 = ae[8], a14 = ae[12];
const a21 = ae[1], a22 = ae[5], a23 = ae[9], a24 = ae[13];
const a31 = ae[2], a32 = ae[6], a33 = ae[10], a34 = ae[14];
const a41 = ae[3], a42 = ae[7], a43 = ae[11], a44 = ae[15];
const b11 = be[0], b12 = be[4], b13 = be[8], b14 = be[12];
const b21 = be[1], b22 = be[5], b23 = be[9], b24 = be[13];
const b31 = be[2], b32 = be[6], b33 = be[10], b34 = be[14];
const b41 = be[3], b42 = be[7], b43 = be[11], b44 = be[15];
te[0] = a11 * b11 + a12 * b21 + a13 * b31 + a14 * b41;
te[4] = a11 * b12 + a12 * b22 + a13 * b32 + a14 * b42;
te[8] = a11 * b13 + a12 * b23 + a13 * b33 + a14 * b43;
te[12] = a11 * b14 + a12 * b24 + a13 * b34 + a14 * b44;
te[1] = a21 * b11 + a22 * b21 + a23 * b31 + a24 * b41;
te[5] = a21 * b12 + a22 * b22 + a23 * b32 + a24 * b42;
te[9] = a21 * b13 + a22 * b23 + a23 * b33 + a24 * b43;
te[13] = a21 * b14 + a22 * b24 + a23 * b34 + a24 * b44;
te[2] = a31 * b11 + a32 * b21 + a33 * b31 + a34 * b41;
te[6] = a31 * b12 + a32 * b22 + a33 * b32 + a34 * b42;
te[10] = a31 * b13 + a32 * b23 + a33 * b33 + a34 * b43;
te[14] = a31 * b14 + a32 * b24 + a33 * b34 + a34 * b44;
te[3] = a41 * b11 + a42 * b21 + a43 * b31 + a44 * b41;
te[7] = a41 * b12 + a42 * b22 + a43 * b32 + a44 * b42;
te[11] = a41 * b13 + a42 * b23 + a43 * b33 + a44 * b43;
te[15] = a41 * b14 + a42 * b24 + a43 * b34 + a44 * b44;
return this;
}
makePerspective(fovDeg, aspect, near, far) {
const f = 1.0 / Math.tan((fovDeg * Math.PI / 180) * 0.5);
const nf = 1 / (near - far);
const e = this.elements;
e.fill(0);
e[0] = f / aspect;
e[5] = f;
e[10] = (far + near) * nf;
e[11] = -1;
e[14] = (2 * far * near) * nf;
return this;
}
makeLookAt(eye, target, up) {
let zx = eye.x - target.x, zy = eye.y - target.y, zz = eye.z - target.z;
let len = Math.hypot(zx, zy, zz) || 1;
zx /= len; zy /= len; zz /= len;
let xx = up.y * zz - up.z * zy;
let xy = up.z * zx - up.x * zz;
let xz = up.x * zy - up.y * zx;
len = Math.hypot(xx, xy, xz) || 1;
xx /= len; xy /= len; xz /= len;
const yx = zy * xz - zz * xy;
const yy = zz * xx - zx * xz;
const yz = zx * xy - zy * xx;
const e = this.elements;
e[0] = xx; e[4] = xy; e[8] = xz; e[12] = -(xx * eye.x + xy * eye.y + xz * eye.z);
e[1] = yx; e[5] = yy; e[9] = yz; e[13] = -(yx * eye.x + yy * eye.y + yz * eye.z);
e[2] = zx; e[6] = zy; e[10] = zz; e[14] = -(zx * eye.x + zy * eye.y + zz * eye.z);
e[3] = 0; e[7] = 0; e[11] = 0; e[15] = 1;
return this;
}
composeYPR(pos, yaw, pitch, roll, scale) {
// Intrinsic YXZ (Yaw around Y, Pitch around X, Roll around Z)
const cy = Math.cos(yaw), sy = Math.sin(yaw);
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const cr = Math.cos(roll), sr = Math.sin(roll);
const sx = scale.x, syS = scale.y, sz = scale.z;
const e = this.elements;
e[0] = (cy * cr + sy * sp * sr) * sx;
e[1] = (cp * sr) * sx;
e[2] = (-sy * cr + cy * sp * sr) * sx;
e[3] = 0;
e[4] = (cy * (-sr) + sy * sp * cr) * syS;
e[5] = (cp * cr) * syS;
e[6] = (-sy * (-sr) + cy * sp * cr) * syS;
e[7] = 0;
e[8] = (sy * cp) * sz;
e[9] = (-sp) * sz;
e[10] = (cy * cp) * sz;
e[11] = 0;
e[12] = pos.x;
e[13] = pos.y;
e[14] = pos.z;
e[15] = 1;
return this;
}
transformPoint(v) {
const e = this.elements;
return new Vector3(
e[0] * v.x + e[4] * v.y + e[8] * v.z + e[12],
e[1] * v.x + e[5] * v.y + e[9] * v.z + e[13],
e[2] * v.x + e[6] * v.y + e[10] * v.z + e[14]
);
}
transformDirection(v) {
const e = this.elements;
return new Vector3(
e[0] * v.x + e[4] * v.y + e[8] * v.z,
e[1] * v.x + e[5] * v.y + e[9] * v.z,
e[2] * v.x + e[6] * v.y + e[10] * v.z
).normalize();
}
}
return { Vector3, Matrix4 };
})();
// ============================================================================
// SYNTHESIZED WEB AUDIO SOUND ENGINE (Zero External Assets)
// ============================================================================
class NavalAudio {
constructor() {
this.ctx = null;
this.enabled = true;
this.initialized = false;
this.engineGain = null;
this.engineOsc = null;
this.oceanGain = null;
}
init() {
if (this.initialized) return;
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
this.initialized = true;
// Continuous Ocean Surf & Wind Noise
const bufferSize = this.ctx.sampleRate * 3;
const noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const output = noiseBuffer.getChannelData(0);
let lastOut = 0.0;
for (let i = 0; i < bufferSize; i++) {
const white = Math.random() * 2 - 1;
output[i] = (lastOut + (0.03 * white)) / 1.03;
lastOut = output[i];
output[i] *= 2.5;
}
const whiteNoise = this.ctx.createBufferSource();
whiteNoise.buffer = noiseBuffer;
whiteNoise.loop = true;
this.oceanFilter = this.ctx.createBiquadFilter();
this.oceanFilter.type = 'lowpass';
this.oceanFilter.frequency.value = 340;
this.oceanGain = this.ctx.createGain();
this.oceanGain.gain.value = 0.16;
whiteNoise.connect(this.oceanFilter);
this.oceanFilter.connect(this.oceanGain);
this.oceanGain.connect(this.ctx.destination);
whiteNoise.start(0);
// Boat Diesel-Turbine Engine Hum
this.engineOsc = this.ctx.createOscillator();
this.engineOsc.type = 'sawtooth';
this.engineOsc.frequency.value = 42;
this.engineFilter = this.ctx.createBiquadFilter();
this.engineFilter.type = 'lowpass';
this.engineFilter.frequency.value = 110;
this.engineGain = this.ctx.createGain();
this.engineGain.gain.value = 0.04;
this.engineOsc.connect(this.engineFilter);
this.engineFilter.connect(this.engineGain);
this.engineGain.connect(this.ctx.destination);
this.engineOsc.start(0);
} catch (e) {
this.enabled = false;
}
}
updateContinuous(speedRatio, waveRoughness, submergedAmount) {
if (!this.initialized || !this.enabled) return;
if (this.ctx.state === 'suspended') this.ctx.resume();
const now = this.ctx.currentTime;
this.engineOsc.frequency.setTargetAtTime(38 + speedRatio * 55, now, 0.1);
this.engineFilter.frequency.setTargetAtTime(95 + speedRatio * 180, now, 0.1);
this.engineGain.gain.setTargetAtTime(0.035 + speedRatio * 0.065, now, 0.1);
this.oceanFilter.frequency.setTargetAtTime(280 + waveRoughness * 320 + submergedAmount * 400, now, 0.15);
this.oceanGain.gain.setTargetAtTime(Math.min(0.38, 0.12 + waveRoughness * 0.12 + submergedAmount * 0.15), now, 0.15);
}
playCannon(isHeavy = true) {
if (!this.initialized || !this.enabled) return;
const now = this.ctx.currentTime;
// Punchy low frequency boom + noise burst
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(isHeavy ? 125 : 180, now);
osc.frequency.exponentialRampToValueAtTime(24, now + (isHeavy ? 0.42 : 0.25));
gain.gain.setValueAtTime(isHeavy ? 0.42 : 0.25, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + (isHeavy ? 0.45 : 0.28));
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.46);
// Crack noise
this.playNoiseBurst(isHeavy ? 0.35 : 0.2, 900, 0.22);
}
playSplash(intensity = 1.0) {
if (!this.initialized || !this.enabled) return;
this.playNoiseBurst(0.35 + intensity * 0.3, 650 + intensity * 350, Math.min(0.35, 0.12 * intensity));
}
playExplosion() {
if (!this.initialized || !this.enabled) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(90, now);
osc.frequency.exponentialRampToValueAtTime(16, now + 0.85);
gain.gain.setValueAtTime(0.5, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.9);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 0.92);
this.playNoiseBurst(0.8, 500, 0.38);
}
playRogueHorn() {
if (!this.initialized || !this.enabled) return;
const now = this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(110, now);
osc.frequency.linearRampToValueAtTime(82, now + 1.4);
gain.gain.setValueAtTime(0.01, now);
gain.gain.linearRampToValueAtTime(0.22, now + 0.2);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.5);
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 320;
osc.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
osc.start(now);
osc.stop(now + 1.55);
}
playNoiseBurst(duration, cutoff, volume) {
const bufferSize = Math.floor(this.ctx.sampleRate * duration);
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufferSize * 0.35));
}
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = cutoff;
filter.Q.value = 0.7;
const gain = this.ctx.createGain();
gain.gain.value = volume;
noise.connect(filter);
filter.connect(gain);
gain.connect(this.ctx.destination);
noise.start();
}
toggle() {
this.init();
this.enabled = !this.enabled;
if (this.oceanGain && this.engineGain) {
if (!this.enabled) {
this.oceanGain.gain.setValueAtTime(0, this.ctx.currentTime);
this.engineGain.gain.setValueAtTime(0, this.ctx.currentTime);
}
}
return this.enabled;
}
}
const audio = new NavalAudio();
// ============================================================================
// WEBGL2 CONTEXT & SHADER SOURCES
// ============================================================================
const canvas = document.getElementById('glCanvas');
const gl = canvas.getContext('webgl2', { antialias: true, alpha: false, powerPreference: 'high-performance' });
if (!gl) {
alert('WebGL 2 is required to run this simulation.');
}
const fxCanvas = document.getElementById('fxCanvas');
const fxCtx = fxCanvas.getContext('2d');
const radarCanvas = document.getElementById('radarCanvas');
const radarCtx = radarCanvas.getContext('2d');
function compileShader(type, source) {
const s = gl.createShader(type);
gl.shaderSource(s, source);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(s), source);
}
return s;
}
function createProgram(vsSource, fsSource) {
const p = gl.createProgram();
gl.attachShader(p, compileShader(gl.VERTEX_SHADER, vsSource));
gl.attachShader(p, compileShader(gl.FRAGMENT_SHADER, fsSource));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(p));
}
return p;
}
// ----------------------------------------------------------------------------
// 1. SKY DOME SHADER (Rayleigh + Mie Scattering, Sun Disk, Horizon Mist)
// ----------------------------------------------------------------------------
const SKY_VS = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aPos;
out vec3 vRayDir;
uniform mat4 uInvProj;
uniform mat4 uInvView;
void main() {
vec4 clip = vec4(aPos, 0.9999, 1.0);
vec4 eye = uInvProj * clip;
eye = vec4(eye.xy, -1.0, 0.0);
vRayDir = (uInvView * eye).xyz;
gl_Position = clip;
}
`;
const SKY_FS = `#version 300 es
precision highp float;
in vec3 vRayDir;
out vec4 fragColor;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform vec3 uSkyZenith;
uniform vec3 uSkyHorizon;
uniform float uTime;
uniform float uStormFactor;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1,0)), f.x),
mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), f.x), f.y);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5;
mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
for (int i = 0; i < 4; i++) {
v += a * noise(p);
p = rot * p * 2.03;
a *= 0.5;
}
return v;
}
void main() {
vec3 dir = normalize(vRayDir);
float y = max(dir.y, 0.001);
// Atmospheric gradient
float horizonBlend = pow(1.0 - y, 2.8);
vec3 col = mix(uSkyZenith, uSkyHorizon, horizonBlend);
// Mie scattering glow around the sun
float sunAmt = max(dot(dir, uSunDir), 0.0);
col += uSunColor * pow(sunAmt, 8.0) * 0.45 * (1.0 - uStormFactor * 0.5);
col += uSunColor * pow(sunAmt, 64.0) * 0.65;
// Crisp solar disk
col += uSunColor * smoothstep(0.9992, 0.9998, sunAmt) * 3.5 * (1.0 - uStormFactor * 0.7);
// Procedural volumetric-style clouds in upper hemisphere
if (dir.y > 0.01) {
vec2 cloudUV = dir.xz / (dir.y + 0.18) * 1.4 + vec2(uTime * 0.012, uTime * 0.006);
float c = fbm(cloudUV);
float cloudMask = smoothstep(0.45 - uStormFactor * 0.18, 0.82, c) * smoothstep(0.01, 0.16, dir.y);
vec3 cloudLit = mix(uSkyHorizon * 0.85, uSunColor * 1.15, pow(sunAmt, 3.0));
vec3 cloudDark = uSkyZenith * 0.45;
vec3 cloudCol = mix(cloudLit, cloudDark, c * (0.45 + uStormFactor * 0.4));
col = mix(col, cloudCol, cloudMask * (0.55 + uStormFactor * 0.35));
}
// Horizon atmospheric haze
float haze = exp(-abs(dir.y) * 9.5);
col = mix(col, uSkyHorizon, haze * 0.65);
// Filmic tonemapping
col = col / (1.0 + col * 0.35);
col = pow(col, vec3(1.0 / 2.2));
fragColor = vec4(col, 1.0);
}
`;
// ----------------------------------------------------------------------------
// 2. ULTRA-REALISTIC GERSTNER + ROGUE WAVE WATER SHADER
// ----------------------------------------------------------------------------
const WATER_VS = `#version 300 es
precision highp float;
layout(location = 0) in vec2 aGridPos;
uniform mat4 uProj;
uniform mat4 uView;
uniform vec3 uCameraPos;
uniform float uTime;
uniform float uWaveScale;
// 8 Gerstner waves: vec4(dirX, dirZ, steepnessQ, amplitude), vec4(wavelengthK, speedOmega, 0, 0)
uniform vec4 uWavesA[8];
uniform vec4 uWavesB[8];
// Occasional Rogue Wave: vec4(dirX, dirZ, currentCenterDist, amplitude), vec4(width, activeFlag, steepness, 0)
uniform vec4 uRogueA;
uniform vec4 uRogueB;
out vec3 vWorldPos;
out vec3 vMacroNormal;
out float vJacobian;
out float vRogueCrest;
void main() {
// Radial + adaptive grid centered on camera XZ
vec2 worldBase = aGridPos + uCameraPos.xz;
vec3 pos = vec3(worldBase.x, 0.0, worldBase.y);
vec3 tangent = vec3(1.0, 0.0, 0.0);
vec3 binormal = vec3(0.0, 0.0, 1.0);
float jacobian = 1.0;
for (int i = 0; i < 8; i++) {
vec2 d = uWavesA[i].xy;
float q = uWavesA[i].z;
float a = uWavesA[i].w * uWaveScale;
float k = uWavesB[i].x;
float w = uWavesB[i].y;
float phase = k * dot(d, worldBase) - w * uTime;
float s = sin(phase);
float c = cos(phase);
pos.x -= q * a * d.x * c;
pos.z -= q * a * d.y * c;
pos.y += a * s;
float ka = k * a;
tangent.x -= q * d.x * d.x * ka * s;
tangent.y += d.x * ka * c;
tangent.z -= q * d.x * d.y * ka * s;
binormal.x -= q * d.x * d.y * ka * s;
binormal.y += d.y * ka * c;
binormal.z -= q * d.y * d.y * ka * s;
jacobian -= q * ka * s;
}
// Evaluate towering Rogue Wave if active
float rogueCrest = 0.0;
if (uRogueB.y > 0.001) {
vec2 rDir = uRogueA.xy;
float rCenter = uRogueA.z;
float rAmp = uRogueA.w * uRogueB.y;
float rWidth = uRogueB.x;
float proj = dot(worldBase, rDir);
float dist = proj - rCenter;
float normDist = dist / rWidth;
float env = exp(-normDist * normDist);
// Asymmetric curling wave profile
float curl = -sin(normDist * 1.9) * env * rWidth * 0.28 * uRogueB.y;
pos.x += rDir.x * curl;
pos.z += rDir.y * curl;
pos.y += rAmp * env;
// Derivative of Gaussian envelope
float dEnv = (-2.0 * normDist / rWidth) * rAmp * env;
tangent.y += rDir.x * dEnv;
binormal.y += rDir.y * dEnv;
rogueCrest = env * smoothstep(-0.4, 0.35, normDist);
jacobian -= env * 0.65 * uRogueB.y;
}
vWorldPos = pos;
vMacroNormal = normalize(cross(binormal, tangent));
vJacobian = jacobian;
vRogueCrest = rogueCrest;
gl_Position = uProj * uView * vec4(pos, 1.0);
}
`;
const WATER_FS = `#version 300 es
precision highp float;
in vec3 vWorldPos;
in vec3 vMacroNormal;
in float vJacobian;
in float vRogueCrest;
out vec4 fragColor;
uniform vec3 uCameraPos;
uniform float uTime;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform vec3 uSkyZenith;
uniform vec3 uSkyHorizon;
uniform vec3 uWaterDeep;
uniform vec3 uWaterShallow;
uniform float uStormFactor;
// Wake trails: vec4(x, z, radius, age0to1)
uniform vec4 uWake[24];
// Splash & explosion rings: vec4(x, z, radius, age0to1)
uniform vec4 uSplashes[12];
// Active Boats for hull contact foam: vec4(x, z, yaw, halfLength)
uniform vec4 uBoats[10];
uniform int uNumBoats;
// Ballistic Aiming Reticle on water: vec4(x, z, radius, active)
uniform vec4 uAimReticle;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
// Multi-octave capillary wave heightfield for micro-normals & foam texture
float microWaves(vec2 p, float t) {
float h = 0.0;
float amp = 0.5;
mat2 rot = mat2(0.8, -0.6, 0.6, 0.8);
vec2 shift = vec2(t * 0.75, -t * 0.55);
for (int i = 0; i < 5; i++) {
h += amp * noise(p + shift);
p = rot * p * 2.18;
shift = rot * shift * 1.3;
amp *= 0.48;
}
return h;
}
vec3 sampleSkyReflection(vec3 reflDir) {
reflDir.y = max(reflDir.y, 0.005);
float horizonBlend = pow(1.0 - reflDir.y, 2.6);
vec3 sky = mix(uSkyZenith, uSkyHorizon, horizonBlend);
float sunAmt = max(dot(reflDir, uSunDir), 0.0);
sky += uSunColor * pow(sunAmt, 10.0) * 0.45;
return sky;
}
void main() {
vec3 viewVec = uCameraPos - vWorldPos;
float dist = length(viewVec);
vec3 V = viewVec / max(dist, 0.001);
// Distance LOD attenuation for micro-ripples to prevent aliasing at horizon
float detailFade = exp(-dist * 0.0038);
// Compute micro-normal via central differences on animated FBM
vec2 uv = vWorldPos.xz * 0.42;
float eps = 0.08;
float h0 = microWaves(uv, uTime);
float hx = microWaves(uv + vec2(eps, 0.0), uTime);
float hz = microWaves(uv + vec2(0.0, eps), uTime);
vec3 microN = normalize(vec3((h0 - hx) / eps, 1.35, (h0 - hz) / eps));
vec3 N = normalize(mix(vMacroNormal, normalize(vMacroNormal + microN * vec3(0.42, 0.0, 0.42)), detailFade));
// --- WAKE, SPLASHES & HULL CONTACT FOAM ---
float foamMask = 0.0;
float foamNoise = noise(vWorldPos.xz * 1.6 + vec2(uTime * 0.4, -uTime * 0.3));
float fineFoamNoise = noise(vWorldPos.xz * 4.5 - vec2(uTime * 0.8));
float cellularFoam = smoothstep(0.22, 0.75, foamNoise * 0.65 + fineFoamNoise * 0.35);
// 1. Hull contact foam & bow wave around every boat
for (int i = 0; i < 10; i++) {
if (i >= uNumBoats) break;
vec4 b = uBoats[i];
vec2 dp = vWorldPos.xz - b.xy;
// Quick bounding check
if (dot(dp, dp) < 260.0) {
float cy = cos(b.z), sy = sin(b.z);
// Transform into boat local XZ (Z forward, X right)
float lx = dp.x * cy - dp.y * sy;
float lz = dp.x * sy + dp.y * cy;
float halfL = b.w;
float halfW = halfL * 0.34;
// Tapered hull SDF
float bowTaper = clamp(1.0 - max(lz, 0.0) / (halfL * 1.15), 0.25, 1.0);
vec2 q = vec2(lx / (halfW * bowTaper), lz / halfL);
float hullDist = length(q);
if (hullDist > 0.85 && hullDist < 1.85) {
float rim = smoothstep(1.85, 1.02, hullDist) * smoothstep(0.75, 0.98, hullDist);
foamMask += rim * (0.85 + 0.35 * cellularFoam);
// Perturb water normal away from boat hull
vec2 pushDir = normalize(dp + vec2(0.001));
N = normalize(N + vec3(pushDir.x, 0.0, pushDir.y) * rim * 0.35);
}
}
}
// 2. Kelvin Wake trail points behind moving boats
for (int i = 0; i < 24; i++) {
vec4 w = uWake[i];
if (w.w <= 0.0 || w.w >= 1.0) continue;
vec2 dp = vWorldPos.xz - w.xy;
float r2 = dot(dp, dp);
float maxR = w.z * (1.0 + w.w * 2.2);
if (r2 < maxR * maxR) {
float d = sqrt(r2) / maxR;
// Double V-wake edge + center propeller wash
float edgeRing = exp(-pow((d - 0.72) * 4.5, 2.0));
float centerWash = (1.0 - d) * 0.75;
float fade = (1.0 - w.w) * (1.0 - w.w);
foamMask += (edgeRing * 0.9 + centerWash * cellularFoam) * fade * 0.75;
}
}
// 3. Expanding Splash & Cannon Impact Rings
for (int i = 0; i < 12; i++) {
vec4 sp = uSplashes[i];
if (sp.w <= 0.0 || sp.w >= 1.0) continue;
vec2 dp = vWorldPos.xz - sp.xy;
float d = length(dp);
float ringRadius = sp.z * (0.25 + sp.w * 1.6);
float ringDiff = d - ringRadius;
if (abs(ringDiff) < 4.5) {
float waveRing = sin(ringDiff * 3.14159) * exp(-abs(ringDiff) * 0.8) * (1.0 - sp.w);
vec2 dir = dp / max(d, 0.001);
N = normalize(N + vec3(dir.x * waveRing * 0.6, 0.0, dir.y * waveRing * 0.6));
foamMask += exp(-abs(ringDiff) * 0.95) * (1.0 - sp.w) * 1.1;
}
}
// 4. Wave Crest Whitecaps (Jacobian compression + Rogue Wave Crest)
float crestFactor = smoothstep(0.68, 0.15, vJacobian) + vRogueCrest * 1.35;
foamMask += crestFactor * (0.45 + 0.75 * cellularFoam);
foamMask = clamp(foamMask * detailFade, 0.0, 1.0);
// --- WATER OPTICS: FRESNEL, SUBSURFACE SCATTERING, GGX SPECULAR ---
float NdotV = max(dot(N, V), 0.001);
// Schlick Fresnel (F0 = 0.02 for water)
float fresnel = 0.02 + 0.98 * pow(1.0 - NdotV, 5.0);
// Sky reflection
vec3 R = reflect(-V, N);
vec3 skyRefl = sampleSkyReflection(R);
// Deep vs Shallow water base color + wave elevation tint
float heightFactor = clamp((vWorldPos.y + 2.5) * 0.16, 0.0, 1.0);
vec3 waterBody = mix(uWaterDeep, uWaterShallow, heightFactor * 0.45 + (1.0 - NdotV) * 0.25);
// Subsurface Scattering (SSS): emerald/turquoise glow through thin wave crests toward the sun
float backLight = max(dot(V, -normalize(uSunDir + N * 0.45)), 0.0);
float crestThickness = clamp((vWorldPos.y * 0.35 + crestFactor * 0.8), 0.0, 1.4);
float upwardSun = max(uSunDir.y + 0.2, 0.1);
vec3 sssColor = uWaterShallow * vec3(0.15, 1.25, 1.05) * (0.4 + 0.6 * uSunColor);
vec3 sss = sssColor * (pow(backLight, 3.0) * 1.4 + (1.0 - N.y) * 0.6) * crestThickness * upwardSun;
vec3 refracted = waterBody + sss;
// GGX Sun Specular Highlight (glittering sun path across waves)
vec3 H = normalize(V + uSunDir);
float NdotH = max(dot(N, H), 0.0);
float NdotL = max(dot(N, uSunDir), 0.0);
float roughness = mix(0.055, 0.16, foamMask);
float a2 = roughness * roughness * roughness * roughness;
float denom = (NdotH * NdotH * (a2 - 1.0) + 1.0);
float D = a2 / (3.14159 * denom * denom + 0.0001);
vec3 sunSpec = uSunColor * D * fresnel * NdotL * 1.45 * (1.0 - foamMask * 0.75);
// Combine Refraction, Reflection, Specular, and aerated Whitewater Foam
vec3 color = mix(refracted, skyRefl, fresnel) + sunSpec;
vec3 foamColor = mix(uSkyHorizon, uSunColor * 0.9 + vec3(0.45), max(uSunDir.y, 0.25)) * 0.95;
color = mix(color, foamColor, foamMask * 0.92);
// Aiming Reticle projected onto the 3D water surface
if (uAimReticle.w > 0.5) {
float rDist = length(vWorldPos.xz - uAimReticle.xy);
float rad = uAimReticle.z;
float outerRing = smoothstep(0.35, 0.0, abs(rDist - rad));
float innerDot = smoothstep(0.65, 0.0, rDist);
float crossHair = (abs(vWorldPos.x - uAimReticle.x) < 0.18 || abs(vWorldPos.z - uAimReticle.y) < 0.18)
&& rDist < rad * 1.35 && rDist > rad * 0.45 ? 0.65 : 0.0;
float reticleMask = clamp(outerRing + innerDot + crossHair, 0.0, 1.0);
color = mix(color, vec3(0.0, 1.0, 0.75) * 1.8, reticleMask * 0.75);
}
// Distance atmospheric fog blending seamlessly into the sky horizon
float fogFactor = 1.0 - exp(-dist * 0.00145);
color = mix(color, uSkyHorizon, clamp(fogFactor, 0.0, 1.0));
// Tonemapping & Gamma
color = color / (1.0 + color * 0.35);
color = pow(color, vec3(1.0 / 2.2));
fragColor = vec4(color, 1.0);
}
`;
// ----------------------------------------------------------------------------
// 3. 3D MESH SHADER (Boats, Turrets, Projectiles, Crates, Wet Waterline)
// ----------------------------------------------------------------------------
const MESH_VS = `#version 300 es
precision highp float;
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec3 aColor;
uniform mat4 uProj;
uniform mat4 uView;
uniform mat4 uModel;
out vec3 vWorldPos;
out vec3 vNormal;
out vec3 vColor;
out float vLocalY;
void main() {
vec4 wp = uModel * vec4(aPos, 1.0);
vWorldPos = wp.xyz;
vNormal = normalize(mat3(uModel) * aNormal);
vColor = aColor;
vLocalY = aPos.y;
gl_Position = uProj * uView * wp;
}
`;
const MESH_FS = `#version 300 es
precision highp float;
in vec3 vWorldPos;
in vec3 vNormal;
in vec3 vColor;
in float vLocalY;
out vec4 fragColor;
uniform vec3 uCameraPos;
uniform vec3 uSunDir;
uniform vec3 uSunColor;
uniform vec3 uSkyZenith;
uniform vec3 uSkyHorizon;
uniform float uWaterHeightAtBoat;
uniform float uEmissive;
uniform float uDamageFlash;
void main() {
vec3 N = normalize(vNormal);
vec3 V = normalize(uCameraPos - vWorldPos);
vec3 L = normalize(uSunDir);
vec3 H = normalize(V + L);
// Wet hull darkening near and below the wave line
float wetLine = smoothstep(uWaterHeightAtBoat + 0.9, uWaterHeightAtBoat - 0.2, vWorldPos.y);
vec3 baseCol = vColor * mix(1.0, 0.52, wetLine);
// Hemispherical ambient + Sun Lambert + Blinn-Phong Specular
float NdotL = max(dot(N, L), 0.0);
vec3 ambient = mix(uSkyHorizon * 0.35, uSkyZenith * 0.55, N.y * 0.5 + 0.5);
vec3 diffuse = uSunColor * NdotL * 0.95;
float specPower = mix(32.0, 128.0, wetLine);
float specStrength = mix(0.28, 0.85, wetLine);
float spec = pow(max(dot(N, H), 0.0), specPower) * specStrength * NdotL;
// Rim light from water bounce
float rim = pow(1.0 - max(dot(N, V), 0.0), 3.0) * 0.25;
vec3 col = baseCol * (ambient + diffuse) + uSunColor * spec + uSkyHorizon * rim;
col += vColor * uEmissive;
col = mix(col, vec3(2.2, 0.4, 0.2), uDamageFlash);
float dist = length(uCameraPos - vWorldPos);
float fogFactor = 1.0 - exp(-dist * 0.00145);
col = mix(col, uSkyHorizon, clamp(fogFactor, 0.0, 1.0));
col = col / (1.0 + col * 0.35);
col = pow(col, vec3(1.0 / 2.2));
fragColor = vec4(col, 1.0);
}
`;
// ----------------------------------------------------------------------------
// 4. 3D PARTICLE SHADER (Spray, Cannon Smoke, Fireballs, Plumes)
// ----------------------------------------------------------------------------
const PARTICLE_VS = `#version 300 es
precision highp float;
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec4 aColor;
layout(location = 2) in float aSize;
uniform mat4 uProj;
uniform mat4 uView;
uniform float uViewportHeight;
out vec4 vColor;
void main() {
vec4 eyePos = uView * vec4(aPos, 1.0);
gl_Position = uProj * eyePos;
vColor = aColor;
float dist = max(-eyePos.z, 1.0);
gl_PointSize = clamp((aSize * uViewportHeight * 0.55) / dist, 1.0, 120.0);
}
`;
const PARTICLE_FS = `#version 300 es
precision highp float;
in vec4 vColor;
out vec4 fragColor;
void main() {
vec2 uv = gl_PointCoord * 2.0 - 1.0;
float r2 = dot(uv, uv);
if (r2 > 1.0) discard;
float alpha = (1.0 - r2) * (1.0 - r2) * vColor.a;
fragColor = vec4(vColor.rgb, alpha);
}
`;
// Compile all programs
const skyProg = createProgram(SKY_VS, SKY_FS);
const waterProg = createProgram(WATER_VS, WATER_FS);
const meshProg = createProgram(MESH_VS, MESH_FS);
const particleProg = createProgram(PARTICLE_VS, PARTICLE_FS);
// ============================================================================
// PROCEDURAL GEOMETRY BUILDERS (Water Grid, Warship Hull, Turret, Enemy, Crate)
// ============================================================================
function createSkyQuad() {
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
return { vao, count: 4 };
}
function createWaterMesh(gridN = 210) {
// Nonlinear radial-style grid: ultra-dense near the boat, stretching to 950m horizon
const verts = [];
const indices = [];
const half = gridN / 2;
for (let z = 0; z <= gridN; z++) {
for (let x = 0; x <= gridN; x++) {
const nx = (x - half) / half; // -1..1
const nz = (z - half) / half;
// Nonlinear cubic+linear mapping gives ~0.4m resolution near center and 900m at edges
const wx = (nx * 0.16 + nx * nx * nx * 0.84) * 850.0;
const wz = (nz * 0.16 + nz * nz * nz * 0.84) * 850.0;
verts.push(wx, wz);
}
}
const row = gridN + 1;
for (let z = 0; z < gridN; z++) {
for (let x = 0; x < gridN; x++) {
const i0 = z * row + x;
const i1 = i0 + 1;
const i2 = i0 + row;
const i3 = i2 + 1;
indices.push(i0, i2, i1, i1, i2, i3);
}
}
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
const ibo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);
return { vao, count: indices.length };
}
// Helper to construct 3D triangle meshes with flat/smooth normals and vertex colors
class MeshBuilder {
constructor() {
this.data = []; // x,y,z, nx,ny,nz, r,g,b
}
addTri(p1, p2, p3, color) {
const ux = p2[0] - p1[0], uy = p2[1] - p1[1], uz = p2[2] - p1[2];
const vx = p3[0] - p1[0], vy = p3[1] - p1[1], vz = p3[2] - p1[2];
let nx = uy * vz - uz * vy;
let ny = uz * vx - ux * vz;
let nz = ux * vy - uy * vx;
const len = Math.hypot(nx, ny, nz) || 1;
nx /= len; ny /= len; nz /= len;
for (const p of [p1, p2, p3]) {
this.data.push(p[0], p[1], p[2], nx, ny, nz, color[0], color[1], color[2]);
}
}
addQuad(p1, p2, p3, p4, color) {
this.addTri(p1, p2, p3, color);
this.addTri(p1, p3, p4, color);
}
addBox(cx, cy, cz, sx, sy, sz, color, topColor = color) {
const x0 = cx - sx * 0.5, x1 = cx + sx * 0.5;
const y0 = cy - sy * 0.5, y1 = cy + sy * 0.5;
const z0 = cz - sz * 0.5, z1 = cz + sz * 0.5;
// Top
this.addQuad([x0,y1,z0], [x0,y1,z1], [x1,y1,z1], [x1,y1,z0], topColor);
// Bottom
this.addQuad([x0,y0,z1], [x0,y0,z0], [x1,y0,z0], [x1,y0,z1], color);
// Front (+Z)
this.addQuad([x0,y0,z1], [x1,y0,z1], [x1,y1,z1], [x0,y1,z1], color);
// Back (-Z)
this.addQuad([x1,y0,z0], [x0,y0,z0], [x0,y1,z0], [x1,y1,z0], color);
// Right (+X)
this.addQuad([x1,y0,z1], [x1,y0,z0], [x1,y1,z0], [x1,y1,z1], color);
// Left (-X)
this.addQuad([x0,y0,z0], [x0,y0,z1], [x0,y1,z1], [x0,y1,z0], color);
}
addCylinderZ(cx, cy, z0, z1, r0, r1, segs, color) {
for (let i = 0; i < segs; i++) {
const a0 = (i / segs) * Math.PI * 2;
const a1 = ((i + 1) / segs) * Math.PI * 2;
const c0 = Math.cos(a0), s0 = Math.sin(a0);
const c1 = Math.cos(a1), s1 = Math.sin(a1);
const p1 = [cx + c0 * r0, cy + s0 * r0, z0];
const p2 = [cx + c1 * r0, cy + s1 * r0, z0];
const p3 = [cx + c1 * r1, cy + s1 * r1, z1];
const p4 = [cx + c0 * r1, cy + s0 * r1, z1];
this.addQuad(p1, p4, p3, p2, color);
}
}
build() {
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
const arr = new Float32Array(this.data);
gl.bufferData(gl.ARRAY_BUFFER, arr, gl.STATIC_DRAW);
const stride = 9 * 4;
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * 4);
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 3, gl.FLOAT, false, stride, 6 * 4);
return { vao, count: arr.length / 9 };
}
}
function buildWarshipHullMesh(hullCol, deckCol, cabinCol, trimCol) {
const mb = new MeshBuilder();
// Articulated V-hull stations from Stern (z = -6.0) to Bow (z = +6.8)
const stations = [
{ z: -6.0, wDeck: 2.0, wChine: 1.75, yKeel: -1.1, yChine: -0.45, yDeck: 0.75 },
{ z: -3.2, wDeck: 2.35, wChine: 2.05, yKeel: -1.35, yChine: -0.55, yDeck: 0.72 },
{ z: 0.0, wDeck: 2.45, wChine: 2.10, yKeel: -1.40, yChine: -0.55, yDeck: 0.78 },
{ z: 3.2, wDeck: 1.85, wChine: 1.35, yKeel: -1.30, yChine: -0.45, yDeck: 0.95 },
{ z: 5.4, wDeck: 0.75, wChine: 0.38, yKeel: -1.05, yChine: -0.25, yDeck: 1.18 },
{ z: 6.8, wDeck: 0.04, wChine: 0.02, yKeel: -0.40, yChine: 0.30, yDeck: 1.38 }
];
const keelCol = [0.48, 0.11, 0.11]; // Anti-fouling red oxide lower hull
for (let i = 0; i < stations.length - 1; i++) {
const s0 = stations[i], s1 = stations[i + 1];
// Starboard (+X) Upper Hull (Chine to Deck)
mb.addQuad([s0.wChine, s0.yChine, s0.z], [s1.wChine, s1.yChine, s1.z], [s1.wDeck, s1.yDeck, s1.z], [s0.wDeck, s0.yDeck, s0.z], hullCol);
// Port (-X) Upper Hull
mb.addQuad([-s1.wChine, s1.yChine, s1.z], [-s0.wChine, s0.yChine, s0.z], [-s0.wDeck, s0.yDeck, s0.z], [-s1.wDeck, s1.yDeck, s1.z], hullCol);
// Starboard Lower Hull (Keel to Chine)
mb.addQuad([0, s0.yKeel, s0.z], [0, s1.yKeel, s1.z], [s1.wChine, s1.yChine, s1.z], [s0.wChine, s0.yChine, s0.z], keelCol);
// Port Lower Hull
mb.addQuad([0, s1.yKeel, s1.z], [0, s0.yKeel, s0.z], [-s0.wChine, s0.yChine, s0.z], [-s1.wChine, s1.yChine, s1.z], keelCol);
// Main Deck Surface
mb.addQuad([-s0.wDeck, s0.yDeck, s0.z], [s0.wDeck, s0.yDeck, s0.z], [s1.wDeck, s1.yDeck, s1.z], [-s1.wDeck, s1.yDeck, s1.z], deckCol);
// Gunwale armored coaming strips
mb.addBox( s0.wDeck * 0.94, s0.yDeck + 0.12, (s0.z + s1.z) * 0.5, 0.12, 0.22, s1.z - s0.z, trimCol);
mb.addBox(-s0.wDeck * 0.94, s0.yDeck + 0.12, (s0.z + s1.z) * 0.5, 0.12, 0.22, s1.z - s0.z, trimCol);
}
// Stern Transom Cap
const st = stations[0];
mb.addQuad([st.wChine, st.yChine, st.z], [-st.wChine, st.yChine, st.z], [-st.wDeck, st.yDeck, st.z], [st.wDeck, st.yDeck, st.z], hullCol);
mb.addTri([0, st.yKeel, st.z], [st.wChine, st.yChine, st.z], [-st.wChine, st.yChine, st.z], keelCol);
// Armored Superstructure & Bridge Cabin
mb.addBox(0, 1.35, -1.1, 2.5, 1.1, 3.8, cabinCol, deckCol);
// Slanted Bridge Command House
mb.addBox(0, 2.15, -0.5, 2.0, 0.75, 2.1, cabinCol, deckCol);
// Glowing Cyan/Amber Bridge Windows
mb.addBox(0, 2.22, 0.58, 1.75, 0.32, 0.1, trimCol);
mb.addBox(0, 2.22, -0.3, 2.06, 0.30, 1.2, trimCol);
// Smokestack / Exhaust Funnel (tilted aft)
mb.addBox(0, 2.35, -2.2, 0.95, 1.3, 1.1, cabinCol, [0.1, 0.1, 0.12]);
// Radar Mast Tower
mb.addBox(0, 3.1, -1.1, 0.28, 1.6, 0.28, cabinCol);
mb.addBox(0, 3.75, -1.1, 1.6, 0.15, 0.22, trimCol);
// Forward Turret Barbet Base
mb.addCylinderZ(0, 0.85, 2.1, 2.2, 0.9, 0.9, 12, cabinCol);
// Rear Depth-Salvo Racks
mb.addBox(-1.1, 1.05, -4.6, 0.65, 0.55, 1.4, cabinCol, trimCol);
mb.addBox( 1.1, 1.05, -4.6, 0.65, 0.55, 1.4, cabinCol, trimCol);
return mb.build();
}
function buildTurretMesh(turretCol, barrelCol, accentCol) {
const mb = new MeshBuilder();
// Armored angled turret housing
mb.addBox(0, 0.35, -0.1, 1.45, 0.65, 1.65, turretCol, accentCol);
mb.addBox(0, 0.32, 0.65, 1.10, 0.48, 0.6, turretCol);
// Twin 120mm Rifled Barrels extending along +Z
mb.addCylinderZ(-0.34, 0.36, 0.6, 3.15, 0.13, 0.10, 10, barrelCol);
mb.addCylinderZ( 0.34, 0.36, 0.6, 3.15, 0.13, 0.10, 10, barrelCol);
// Muzzle brakes
mb.addCylinderZ(-0.34, 0.36, 3.05, 3.35, 0.16, 0.15, 10, accentCol);
mb.addCylinderZ( 0.34, 0.36, 3.05, 3.35, 0.16, 0.15, 10, accentCol);
return mb.build();
}
function buildShellMesh() {
const mb = new MeshBuilder();
mb.addBox(0, 0, 0, 0.36, 0.36, 0.95, [1.0, 0.82, 0.3]);
return mb.build();
}
function buildCrateMesh() {
const mb = new MeshBuilder();
mb.addBox(0, 0, 0, 1.8, 1.4, 1.8, [0.15, 0.75, 0.42], [0.25, 0.95, 0.55]);
mb.addBox(0, 0, 0, 1.9, 0.3, 1.9, [0.95, 0.85, 0.2]);
return mb.build();
}
const skyGeo = createSkyQuad();
const waterGeo = createWaterMesh(210);
const playerHullGeo = buildWarshipHullMesh([0.24, 0.33, 0.42], [0.18, 0.23, 0.29], [0.34, 0.43, 0.52], [0.15, 0.82, 1.0]);
const playerTurretGeo = buildTurretMesh([0.32, 0.42, 0.52], [0.16, 0.20, 0.24], [0.15, 0.85, 1.0]);
const enemyHullGeo = buildWarshipHullMesh([0.28, 0.22, 0.22], [0.18, 0.15, 0.16], [0.36, 0.26, 0.26], [1.0, 0.32, 0.15]);
const enemyTurretGeo = buildTurretMesh([0.36, 0.25, 0.25], [0.15, 0.15, 0.15], [1.0, 0.32, 0.15]);
const shellGeo = buildShellMesh();
const crateGeo = buildCrateMesh();
// ============================================================================
// OCEAN HYDRODYNAMICS: GERSTNER WAVES + ROGUE WAVE + EXACT BUOYANCY SAMPLER
// ============================================================================
const WAVES = [
{ dx: 0.88, dz: 0.47, q: 0.34, a: 0.78, l: 48.0 },
{ dx: 0.96, dz: -0.28, q: 0.32, a: 0.56, l: 31.0 },
{ dx: 0.52, dz: 0.85, q: 0.30, a: 0.42, l: 21.0 },
{ dx: -0.35, dz: 0.93, q: 0.26, a: 0.28, l: 14.5 },
{ dx: 0.76, dz: -0.65, q: 0.24, a: 0.19, l: 9.8 },
{ dx: -0.82, dz: -0.57, q: 0.22, a: 0.14, l: 6.7 },
{ dx: 0.22, dz: -0.97, q: 0.20, a: 0.09, l: 4.3 },
{ dx: -0.60, dz: 0.80, q: 0.18, a: 0.06, l: 2.8 }
];
// Normalize directions and precompute wave numbers k & dispersion speeds omega
const uWavesAData = new Float32Array(8 * 4);
const uWavesBData = new Float32Array(8 * 4);
WAVES.forEach((w, i) => {
const len = Math.hypot(w.dx, w.dz) || 1;
w.dx /= len;
w.dz /= len;
w.k = (Math.PI * 2) / w.l;
w.omega = Math.sqrt(9.81 * w.k); // Deep-water dispersion relation
uWavesAData.set([w.dx, w.dz, w.q, w.a], i * 4);
uWavesBData.set([w.k, w.omega, 0, 0], i * 4);
});
// Occasional Rogue Wave state
const rogueWave = {
active: false,
intensity: 0.0,
dirX: 0.85,
dirZ: 0.52,
centerDist: -320,
speed: 24.0,
amplitude: 7.4,
width: 28.0,
cooldown: 16.0
};
function triggerRogueWave(playerPos) {
const angle = Math.random() * Math.PI * 2;
rogueWave.dirX = Math.cos(angle);
rogueWave.dirZ = Math.sin(angle);
const playerProj = playerPos.x * rogueWave.dirX + playerPos.z * rogueWave.dirZ;
rogueWave.centerDist = playerProj - 250.0;
rogueWave.active = true;
rogueWave.intensity = 0.01;
rogueWave.cooldown = 32.0;
audio.playRogueHorn();
document.getElementById('alertBanner').classList.add('visible');
}
// Sea State & Lighting Presets
const SEA_PRESETS = {
sunset: {
sunDir: new THREE.Vector3(0.75, 0.20, -0.62).normalize(),
sunColor: [1.55, 0.78, 0.36],
skyZenith: [0.07, 0.16, 0.34],
skyHorizon: [0.85, 0.44, 0.25],
waterDeep: [0.01, 0.05, 0.10],
waterShallow: [0.02, 0.32, 0.34],
waveScale: 1.0,
stormFactor: 0.15
},
noon: {
sunDir: new THREE.Vector3(0.35, 0.84, -0.42).normalize(),
sunColor: [1.35, 1.30, 1.18],
skyZenith: [0.06, 0.30, 0.68],
skyHorizon: [0.52, 0.78, 0.95],
waterDeep: [0.005, 0.08, 0.18],
waterShallow: [0.0, 0.58, 0.56],
waveScale: 0.88,
stormFactor: 0.0
},
storm: {
sunDir: new THREE.Vector3(-0.55, 0.30, -0.78).normalize(),
sunColor: [0.72, 0.80, 0.88],
skyZenith: [0.05, 0.08, 0.12],
skyHorizon: [0.24, 0.30, 0.35],
waterDeep: [0.008, 0.03, 0.05],
waterShallow: [0.03, 0.20, 0.22],
waveScale: 1.52,
stormFactor: 0.9
},
night: {
sunDir: new THREE.Vector3(-0.62, 0.38, 0.68).normalize(),
sunColor: [0.45, 0.65, 0.98],
skyZenith: [0.01, 0.02, 0.06],
skyHorizon: [0.06, 0.12, 0.22],
waterDeep: [0.003, 0.015, 0.04],
waterShallow: [0.01, 0.16, 0.24],
waveScale: 1.12,
stormFactor: 0.35
}
};
let currentPreset = SEA_PRESETS.sunset;
// Sample exact Gerstner + Rogue wave height, surface normal, and horizontal surge velocity at (worldX, worldZ)
function sampleWaterAt(worldX, worldZ, time) {
const scale = currentPreset.waveScale;
// Two-step fixed-point inversion for Gerstner horizontal displacement so physics matches visual mesh 1:1
let bx = worldX, bz = worldZ;
for (let iter = 0; iter < 2; iter++) {
let ox = 0, oz = 0;
for (let i = 0; i < 8; i++) {
const w = WAVES[i];
const a = w.a * scale;
const phase = w.k * (w.dx * bx + w.dz * bz) - w.omega * time;
const c = Math.cos(phase);
ox -= w.q * a * w.dx * c;
oz -= w.q * a * w.dz * c;
}
bx = worldX - ox;
bz = worldZ - oz;
}
let y = 0;
let tx = 1, ty = 0, tz = 0;
let bnx = 0, bny = 0, bnz = 1;
let vx = 0, vz = 0;
for (let i = 0; i < 8; i++) {
const w = WAVES[i];
const a = w.a * scale;
const phase = w.k * (w.dx * bx + w.dz * bz) - w.omega * time;
const s = Math.sin(phase);
const c = Math.cos(phase);
y += a * s;
const ka = w.k * a;
tx -= w.q * w.dx * w.dx * ka * s;
ty += w.dx * ka * c;
tz -= w.q * w.dx * w.dz * ka * s;
bnx -= w.q * w.dx * w.dz * ka * s;
bny += w.dz * ka * c;
bnz -= w.q * w.dz * w.dz * ka * s;
// Orbital wave surge velocity
const orb = w.omega * a * s;
vx += w.dx * orb;
vz += w.dz * orb;
}
// Add Rogue Wave contribution if active
let isRogueHit = 0;
if (rogueWave.intensity > 0.001) {
const rDirX = rogueWave.dirX, rDirZ = rogueWave.dirZ;
const proj = bx * rDirX + bz * rDirZ;
const dist = proj - rogueWave.centerDist;
const normDist = dist / rogueWave.width;
const env = Math.exp(-normDist * normDist);
const rAmp = rogueWave.amplitude * rogueWave.intensity;
y += rAmp * env;
const dEnv = (-2.0 * normDist / rogueWave.width) * rAmp * env;
ty += rDirX * dEnv;
bny += rDirZ * dEnv;
// Strong horizontal surge force from the rogue wave wall
const surge = env * rogueWave.speed * 0.68 * rogueWave.intensity;
vx += rDirX * surge;
vz += rDirZ * surge;
isRogueHit = env * rogueWave.intensity;
}
// Normal = cross(binormal, tangent)
let nx = bny * tz - bnz * ty;
let ny = bnz * tx - bnx * tz;
let nz = bnx * ty - bny * tx;
const nLen = Math.hypot(nx, ny, nz) || 1;
return {
height: y,
nx: nx / nLen,
ny: ny / nLen,
nz: nz / nLen,
vx,
vz,
isRogueHit
};
}
// ============================================================================
// MULTI-POINT RIGID-BODY BUOYANCY BOAT CLASS
// ============================================================================
class Warship {
constructor(x, z, isPlayer = false) {
this.isPlayer = isPlayer;
this.pos = new THREE.Vector3(x, 0, z);
this.vel = new THREE.Vector3(0, 0, 0);
this.yaw = isPlayer ? 0 : Math.random() * Math.PI * 2;
this.pitch = 0;
this.roll = 0;
this.yawVel = 0;
this.pitchVel = 0;
this.rollVel = 0;
this.throttle = 0;
this.rudder = 0;
this.boostFuel = 1.0;
this.isBoosting = false;
this.turretYaw = this.yaw;
this.turretPitch = 0.05;
this.recoil = 0;
this.maxHp = isPlayer ? 100 : 45;
this.hp = this.maxHp;
this.sinking = false;
this.sinkTimer = 0;
this.damageFlash = 0;
this.primaryCooldown = 0;
this.secondaryCooldown = 0;
this.wakeTimer = 0;
this.waterHeight = 0;
// 5 Hull Pontoon sample points in local coordinates: Center, Bow, Stern, Starboard, Port
this.pontoons = [
new THREE.Vector3( 0.0, -0.5, 0.0), // Center
new THREE.Vector3( 0.0, -0.4, 5.2), // Bow
new THREE.Vector3( 0.0, -0.5, -5.0), // Stern
new THREE.Vector3( 2.1, -0.4, 0.0), // Starboard
new THREE.Vector3(-2.1, -0.4, 0.0) // Port
];
this.modelMatrix = new THREE.Matrix4();
this.turretMatrix = new THREE.Matrix4();
}
updatePhysics(dt, time) {
if (this.damageFlash > 0) this.damageFlash = Math.max(0, this.damageFlash - dt * 4.0);
if (this.recoil > 0) this.recoil = Math.max(0, this.recoil - dt * 3.5);
this.modelMatrix.composeYPR(this.pos, this.yaw, this.pitch, this.roll, new THREE.Vector3(1, 1, 1));
if (this.sinking) {
this.sinkTimer += dt;
this.pos.y -= dt * 2.2;
this.pitch += dt * 0.35;
this.roll += dt * 0.45;
this.modelMatrix.composeYPR(this.pos, this.yaw, this.pitch, this.roll, new THREE.Vector3(1, 1, 1));
return;
}
// Evaluate water at each of the 5 pontoons
const wSamples = [];
let avgWaterY = 0;
let avgWaveVx = 0, avgWaveVz = 0;
let totalSubmerged = 0;
let maxRogueHit = 0;
for (let i = 0; i < 5; i++) {
const wp = this.modelMatrix.transformPoint(this.pontoons[i]);
const ws = sampleWaterAt(wp.x, wp.z, time);
const depth = ws.height - wp.y;
wSamples.push({ wp, ws, depth });
avgWaterY += ws.height;
avgWaveVx += ws.vx;
avgWaveVz += ws.vz;
if (depth > 0) totalSubmerged += Math.min(depth, 2.5);
if (ws.isRogueHit > maxRogueHit) maxRogueHit = ws.isRogueHit;
}
avgWaterY /= 5;
avgWaveVx /= 5;
avgWaveVz /= 5;
this.waterHeight = avgWaterY;
// Vertical Buoyancy & Gravity
const targetFloatY = avgWaterY + 0.18;
const yError = targetFloatY - this.pos.y;
// Spring-damper vertical hydrostatics + wave vertical acceleration
const buoyAccel = yError * 26.0 - this.vel.y * 5.2;
this.vel.y += buoyAccel * dt;
this.pos.y += this.vel.y * dt;
// Pitch torque from Bow vs Stern wave height difference
const bowWaterY = wSamples[1].ws.height;
const sternWaterY = wSamples[2].ws.height;
const wavePitchTarget = -Math.atan2(bowWaterY - sternWaterY, 10.2);
// Add bow-up planing trim under forward throttle + recoil kick
const planingPitch = -Math.max(0, this.throttle) * (this.isBoosting ? 0.12 : 0.065);
const pitchDiff = (wavePitchTarget + planingPitch) - this.pitch;
this.pitchVel += (pitchDiff * 24.0 - this.pitchVel * 4.8) * dt;
this.pitch = Math.max(-0.65, Math.min(0.65, this.pitch + this.pitchVel * dt));
// Roll torque from Port vs Starboard wave height difference + centrifugal turn lean
const stbdWaterY = wSamples[3].ws.height;
const portWaterY = wSamples[4].ws.height;
const waveRollTarget = Math.atan2(portWaterY - stbdWaterY, 4.2);
const turnLean = -this.rudder * (this.vel.length() * 0.012);
const rollDiff = (waveRollTarget + turnLean) - this.roll;
this.rollVel += (rollDiff * 28.0 - this.rollVel * 4.4) * dt;
this.roll = Math.max(-0.75, Math.min(0.75, this.roll + this.rollVel * dt));
// Forward & Lateral Hydrodynamics
const fwdX = Math.sin(this.yaw);
const fwdZ = Math.cos(this.yaw);
const rightX = Math.cos(this.yaw);
const rightZ = -Math.sin(this.yaw);
const maxThrust = (this.isPlayer ? 46.0 : 32.0) * (this.isBoosting ? 1.65 : 1.0);
const contactFactor = Math.min(1.0, Math.max(0.25, totalSubmerged * 0.35));
// Apply engine thrust + wave slope gravity slide + wave orbital surge
const centerNorm = wSamples[0].ws;
const wavePushX = centerNorm.nx * 14.0 + avgWaveVx * 0.45;
const wavePushZ = centerNorm.nz * 14.0 + avgWaveVz * 0.45;
this.vel.x += (fwdX * this.throttle * maxThrust * contactFactor + wavePushX) * dt;
this.vel.z += (fwdZ * this.throttle * maxThrust * contactFactor + wavePushZ) * dt;
// Anisotropic hull drag: low drag along keel (forward), very high drag sideways (keel resistance)
const fwdSpeed = this.vel.x * fwdX + this.vel.z * fwdZ;
const sideSpeed = this.vel.x * rightX + this.vel.z * rightZ;
const dampedFwd = fwdSpeed * Math.exp(-1.55 * dt);
const dampedSide = sideSpeed * Math.exp(-6.5 * dt);
this.vel.x = fwdX * dampedFwd + rightX * dampedSide;
this.vel.z = fwdZ * dampedFwd + rightZ * dampedSide;
this.pos.x += this.vel.x * dt;
this.pos.z += this.vel.z * dt;
// Rudder Yaw Torque (responsive at both low and high speeds)
const steerEffectiveness = (0.45 + Math.min(1.0, Math.abs(fwdSpeed) / 10.0) * 0.85) * (fwdSpeed >= -1.0 ? 1 : -1);
const targetYawVel = this.rudder * (this.isPlayer ? 1.55 : 1.15) * steerEffectiveness;
this.yawVel += (targetYawVel - this.yawVel) * 6.5 * dt;
this.yaw += this.yawVel * dt;
// Update matrices
this.modelMatrix.composeYPR(this.pos, this.yaw, this.pitch, this.roll, new THREE.Vector3(1, 1, 1));
const turretWorldPos = this.modelMatrix.transformPoint(new THREE.Vector3(0, 0.88, 2.15));
// Apply recoil offset along turret barrel
const recX = Math.sin(this.turretYaw) * this.recoil * 0.35;
const recZ = Math.cos(this.turretYaw) * this.recoil * 0.35;
turretWorldPos.x -= recX;
turretWorldPos.z -= recZ;
this.turretMatrix.composeYPR(turretWorldPos, this.turretYaw, this.pitch * 0.5 - this.turretPitch, this.roll * 0.5, new THREE.Vector3(1, 1, 1));
// Spawn bow spray & wake trail when moving or hit by rogue wave
const speed = Math.hypot(this.vel.x, this.vel.z);
this.wakeTimer += dt * (1.0 + speed * 0.3);
if (this.wakeTimer > 0.14 && speed > 1.8) {
this.wakeTimer = 0;
const sternPos = this.modelMatrix.transformPoint(new THREE.Vector3(0, 0, -5.6));
addWakePoint(sternPos.x, sternPos.z, 2.2 + Math.min(2.2, speed * 0.08));
// Bow whitewater spray particles
const bowPos = this.modelMatrix.transformPoint(new THREE.Vector3(0, 0.2, 5.8));
if (bowPos.y < wSamples[1].ws.height + 0.6) {
for (let s = -1; s <= 1; s += 2) {
spawnParticle(
bowPos.x + rightX * s * 0.7,
wSamples[1].ws.height + 0.2,
bowPos.z + rightZ * s * 0.7,
this.vel.x * 0.35 + rightX * s * (3.5 + Math.random() * 2.5),
2.5 + speed * 0.16 + Math.random() * 2.0,
this.vel.z * 0.35 + rightZ * s * (3.5 + Math.random() * 2.5),
[0.88, 0.96, 1.0, 0.75],
1.4 + Math.random() * 1.2,
0.65,
true
);
}
}
}
// Rogue wave crashing over deck effect
if (maxRogueHit > 0.45 && Math.random() < 0.45) {
const deckPos = this.modelMatrix.transformPoint(new THREE.Vector3((Math.random() - 0.5) * 3, 1.0, (Math.random() - 0.5) * 8));
spawnParticle(
deckPos.x, deckPos.y, deckPos.z,
avgWaveVx * 0.5 + (Math.random() - 0.5) * 8,
5.0 + Math.random() * 5.0,
avgWaveVz * 0.5 + (Math.random() - 0.5) * 8,
[0.85, 0.95, 1.0, 0.85],
2.5,
0.9,
true
);
if (this.isPlayer) {
addLensDroplets(3);
audio.playSplash(1.4);
}
}
}
}
// ============================================================================
// WAKE BUFFER, SPLASH BUFFER, PROJECTILES, CRATES & 3D PARTICLES
// ============================================================================
const MAX_WAKE = 24;
const wakeList = [];
function addWakePoint(x, z, radius) {
wakeList.unshift({ x, z, radius, age: 0.01 });
if (wakeList.length > MAX_WAKE) wakeList.pop();
}
const MAX_SPLASH = 12;
const splashList = [];
function addSplashRing(x, z, radius) {
splashList.unshift({ x, z, radius, age: 0.01 });
if (splashList.length > MAX_SPLASH) splashList.pop();
}
const MAX_PARTICLES = 900;
const particles = [];
const particlePos = new Float32Array(MAX_PARTICLES * 3);
const particleCol = new Float32Array(MAX_PARTICLES * 4);
const particleSize = new Float32Array(MAX_PARTICLES);
const particleVAO = gl.createVertexArray();
gl.bindVertexArray(particleVAO);
const pPosBuf = gl.createBuffer();
const pColBuf = gl.createBuffer();
const pSizeBuf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pPosBuf);
gl.bufferData(gl.ARRAY_BUFFER, particlePos.byteLength, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, pColBuf);
gl.bufferData(gl.ARRAY_BUFFER, particleCol.byteLength, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(1);
gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0);
gl.bindBuffer(gl.ARRAY_BUFFER, pSizeBuf);
gl.bufferData(gl.ARRAY_BUFFER, particleSize.byteLength, gl.DYNAMIC_DRAW);
gl.enableVertexAttribArray(2);
gl.vertexAttribPointer(2, 1, gl.FLOAT, false, 0, 0);
function spawnParticle(x, y, z, vx, vy, vz, color, size, life, hasGravity = true) {
if (particles.length >= MAX_PARTICLES) particles.shift();
particles.push({
x, y, z, vx, vy, vz,
r: color[0], g: color[1], b: color[2], a: color[3],
size, maxLife: life, life, hasGravity
});
}
function spawnWaterPlume(x, y, z, scale = 1.0) {
addSplashRing(x, z, 3.8 * scale);
const count = Math.floor(24 * scale);
for (let i = 0; i < count; i++) {
const ang = Math.random() * Math.PI * 2;
const spd = (1.5 + Math.random() * 5.5) * scale;
spawnParticle(
x + (Math.random() - 0.5) * 1.2,
y + 0.2,
z + (Math.random() - 0.5) * 1.2,
Math.cos(ang) * spd,
(7.0 + Math.random() * 11.0) * scale,
Math.sin(ang) * spd,
[0.88, 0.96, 1.0, 0.9],
(1.8 + Math.random() * 2.2) * scale,
0.75 + Math.random() * 0.55,
true
);
}
}
function spawnExplosionFX(x, y, z, scale = 1.0) {
addSplashRing(x, z, 5.5 * scale);
for (let i = 0; i < Math.floor(32 * scale); i++) {
const ang = Math.random() * Math.PI * 2;
const elev = (Math.random() - 0.2) * Math.PI * 0.5;
const spd = (3.0 + Math.random() * 14.0) * scale;
const isFire = i < 18 * scale;
spawnParticle(
x, y + 0.5, z,
Math.cos(ang) * Math.cos(elev) * spd,
Math.sin(elev) * spd + 4.5,
Math.sin(ang) * Math.cos(elev) * spd,
isFire ? [1.0, 0.45 + Math.random() * 0.45, 0.1, 0.95] : [0.18, 0.18, 0.2, 0.85],
(2.2 + Math.random() * 3.0) * scale,
0.6 + Math.random() * 0.9,
!isFire
);
}
}
// Screen-space lens water droplets when waves crash over the player boat
const lensDroplets = [];
function addLensDroplets(count) {
for (let i = 0; i < count; i++) {
lensDroplets.push({
x: Math.random() * window.innerWidth,
y: Math.random() * window.innerHeight,
r: 8 + Math.random() * 22,
alpha: 0.65 + Math.random() * 0.25,
vy: 15 + Math.random() * 35
});
}
}
// ============================================================================
// GAME STATE, PLAYER CONTROLS, ENEMY AI & COMBAT LOOP
// ============================================================================
let player = new Warship(0, 0, true);
let enemies = [];
let projectiles = [];
let crates = [];
let score = 0;
let waveNumber = 1;
let gameOver = false;
// Input state
const keys = {};
let mouseNDC = { x: 0, y: 0 };
let mouseDownLeft = false;
let mouseDownRight = false;
let cameraDist = 28.0;
let cameraPos = new THREE.Vector3(0, 11, -28);
let cameraLookAt = new THREE.Vector3(0, 1.5, 8);
let aimWorldPos = new THREE.Vector3(0, 0, 45);
window.addEventListener('keydown', (e) => {
keys[e.code] = true;
audio.init();
});
window.addEventListener('keyup', (e) => {
keys[e.code] = false;
});
window.addEventListener('mousemove', (e) => {
mouseNDC.x = (e.clientX / window.innerWidth) * 2 - 1;
mouseNDC.y = -(e.clientY / window.innerHeight) * 2 + 1;
});
window.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
audio.init();
if (e.button === 0) mouseDownLeft = true;
if (e.button === 2) mouseDownRight = true;
});
window.addEventListener('mouseup', (e) => {
if (e.button === 0) mouseDownLeft = false;
if (e.button === 2) mouseDownRight = false;
});
window.addEventListener('contextmenu', (e) => e.preventDefault());
window.addEventListener('wheel', (e) => {
cameraDist = Math.max(15.0, Math.min(55.0, cameraDist + e.deltaY * 0.02));
}, { passive: true });
// UI Buttons
document.querySelectorAll('[data-preset]').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-preset]').forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
currentPreset = SEA_PRESETS[btn.dataset.preset];
});
});
document.getElementById('rogueBtn').addEventListener('click', () => {
audio.init();
triggerRogueWave(player.pos);
});
document.getElementById('audioBtn').addEventListener('click', (e) => {
const on = audio.toggle();
e.target.textContent = on ? '🔊 Audio: ON' : '🔇 Audio: OFF';
});
document.getElementById('restartBtn').addEventListener('click', () => {
resetGame();
});
function spawnEnemyWave(waveNum) {
const count = 2 + waveNum;
for (let i = 0; i < count; i++) {
const angle = (i / count) * Math.PI * 2 + Math.random() * 0.5;
const dist = 125 + Math.random() * 65;
const ex = player.pos.x + Math.sin(angle) * dist;
const ez = player.pos.z + Math.cos(angle) * dist;
const enemy = new Warship(ex, ez, false);
enemy.maxHp = 40 + waveNum * 10;
enemy.hp = enemy.maxHp;
enemy.primaryCooldown = 1.5 + Math.random() * 2.0;
enemies.push(enemy);
}
}
function resetGame() {
player = new Warship(0, 0, true);
enemies = [];
projectiles = [];
crates = [];
score = 0;
waveNumber = 1;
gameOver = false;
document.getElementById('modalOverlay').classList.remove('show');
spawnEnemyWave(waveNumber);
}
function fireMainTurret(boat, targetPos, isPlayer) {
const muzzle1 = boat.turretMatrix.transformPoint(new THREE.Vector3(-0.34, 0.36, 3.35));
const muzzle2 = boat.turretMatrix.transformPoint(new THREE.Vector3( 0.34, 0.36, 3.35));
const dx = targetPos.x - muzzle1.x;
const dz = targetPos.z - muzzle1.z;
const horizDist = Math.hypot(dx, dz) || 1;
const shellSpeed = isPlayer ? 72.0 : 52.0;
const gravity = 22.0;
// Compute ballistic flight time & required vertical launch velocity so shells land right on the target!
const flightTime = Math.min(3.5, horizDist / shellSpeed);
const dy = (targetPos.y + 0.4) - muzzle1.y;
const vy = (dy + 0.5 * gravity * flightTime * flightTime) / Math.max(0.15, flightTime);
const dirX = dx / horizDist;
const dirZ = dz / horizDist;
for (const m of [muzzle1, muzzle2]) {
projectiles.push({
pos: m.clone(),
vel: new THREE.Vector3(
dirX * shellSpeed + (Math.random() - 0.5) * (isPlayer ? 0.6 : 2.2),
vy + (Math.random() - 0.5) * 0.6,
dirZ * shellSpeed + (Math.random() - 0.5) * (isPlayer ? 0.6 : 2.2)
),
isPlayer,
damage: isPlayer ? 16 : 9,
life: 4.0
});
// Muzzle flash & smoke particles
for (let k = 0; k < 7; k++) {
spawnParticle(
m.x, m.y, m.z,
dirX * (8 + Math.random() * 10) + (Math.random() - 0.5) * 3,
1.5 + Math.random() * 2.5,
dirZ * (8 + Math.random() * 10) + (Math.random() - 0.5) * 3,
k < 3 ? [1.0, 0.78, 0.25, 0.95] : [0.65, 0.68, 0.72, 0.65],
1.6 + Math.random() * 1.4,
0.35 + Math.random() * 0.25,
false
);
}
}
// Physical recoil pitches/rolls the boat slightly
boat.recoil = 1.0;
boat.pitchVel -= 0.16;
audio.playCannon(true);
}
function fireSalvoMortar(boat, targetPos) {
const shellSpeed = 48.0;
const gravity = 22.0;
for (let i = -2; i <= 2; i++) {
const launchPos = boat.modelMatrix.transformPoint(new THREE.Vector3(i * 0.6, 1.3, -4.5));
const spreadX = targetPos.x + (Math.random() - 0.5) * 9.0;
const spreadZ = targetPos.z + (Math.random() - 0.5) * 9.0;
const dx = spreadX - launchPos.x;
const dz = spreadZ - launchPos.z;
const dist = Math.hypot(dx, dz) || 1;
const t = Math.min(3.8, dist / shellSpeed);
const vy = ((targetPos.y - launchPos.y) + 0.5 * gravity * t * t) / Math.max(0.2, t);
projectiles.push({
pos: launchPos,
vel: new THREE.Vector3((dx / dist) * shellSpeed, vy, (dz / dist) * shellSpeed),
isPlayer: true,
damage: 14,
life: 4.5
});
spawnParticle(launchPos.x, launchPos.y, launchPos.z, 0, 5, 0, [1.0, 0.6, 0.2, 0.9], 2.2, 0.45, false);
}
boat.pitchVel += 0.22;
audio.playCannon(false);
}
// Compute 3D world intersection of mouse ray with the ocean surface
function updateMouseAimRay(invProj, invView, time) {
// Near and far points in clip space
const pElements = invProj.elements;
const x = mouseNDC.x, y = mouseNDC.y;
// View space ray
const vx = x / (1.0 / pElements[0]);
const vy = y / (1.0 / pElements[5]);
const vDir = new THREE.Vector3(vx, vy, -1.0).normalize();
const worldDir = invView.transformDirection(vDir);
if (worldDir.y < -0.015) {
const avgWater = player.waterHeight;
const t = Math.min(280.0, Math.max(8.0, (avgWater - cameraPos.y) / worldDir.y));
aimWorldPos.set(
cameraPos.x + worldDir.x * t,
avgWater,
cameraPos.z + worldDir.z * t
);
// Refine once with actual wave height at that XZ
const ws = sampleWaterAt(aimWorldPos.x, aimWorldPos.z, time);
aimWorldPos.y = ws.height;
} else {
// Horizon aim fallback
aimWorldPos.set(
cameraPos.x + worldDir.x * 220.0,
player.waterHeight,
cameraPos.z + worldDir.z * 220.0
);
}
}
// ============================================================================
// MAIN SIMULATION & RENDER LOOP
// ============================================================================
const projMat = new THREE.Matrix4();
const viewMat = new THREE.Matrix4();
const invProjMat = new THREE.Matrix4();
const invViewMat = new THREE.Matrix4();
const tempMat = new THREE.Matrix4();
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(window.innerWidth * dpr);
canvas.height = Math.floor(window.innerHeight * dpr);
fxCanvas.width = window.innerWidth;
fxCanvas.height = window.innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
}
window.addEventListener('resize', resize);
resize();
// Spawn initial wave of enemy warships
spawnEnemyWave(waveNumber);
let lastTime = performance.now() * 0.001;
function animate(nowMs) {
requestAnimationFrame(animate);
const time = nowMs * 0.001;
const dt = Math.min(0.04, Math.max(0.001, time - lastTime));
lastTime = time;
// --- 1. UPDATE ROGUE WAVE SYSTEM ---
if (!rogueWave.active) {
rogueWave.cooldown -= dt;
if (rogueWave.cooldown <= 0) {
triggerRogueWave(player.pos);
}
} else {
rogueWave.centerDist += rogueWave.speed * dt;
const playerProj = player.pos.x * rogueWave.dirX + player.pos.z * rogueWave.dirZ;
const relDist = rogueWave.centerDist - playerProj;
if (relDist < 90) {
rogueWave.intensity = Math.min(1.0, rogueWave.intensity + dt * 0.6);
} else {
rogueWave.intensity = Math.max(0.0, rogueWave.intensity - dt * 0.45);
if (rogueWave.intensity <= 0.001) {
rogueWave.active = false;
document.getElementById('alertBanner').classList.remove('visible');
}
}
if (relDist > 25) {
document.getElementById('alertBanner').classList.remove('visible');
}
}
// --- 2. PLAYER INPUT & CONTROLS ---
if (!gameOver) {
let targetThrottle = 0;
if (keys['KeyW'] || keys['ArrowUp']) targetThrottle += 1.0;
if (keys['KeyS'] || keys['ArrowDown']) targetThrottle -= 0.65;
player.throttle += (targetThrottle - player.throttle) * 5.0 * dt;
let targetRudder = 0;
if (keys['KeyA'] || keys['ArrowLeft']) targetRudder += 1.0;
if (keys['KeyD'] || keys['ArrowRight']) targetRudder -= 1.0;
player.rudder += (targetRudder - player.rudder) * 7.0 * dt;
// Hydro-Boost
if ((keys['ShiftLeft'] || keys['ShiftRight'] || keys['Space']) && player.boostFuel > 0.05 && Math.abs(targetThrottle) > 0.1) {
player.isBoosting = true;
player.boostFuel = Math.max(0, player.boostFuel - dt * 0.38);
} else {
player.isBoosting = false;
player.boostFuel = Math.min(1.0, player.boostFuel + dt * 0.18);
}
// Smoothly rotate player turret toward 3D mouse aim point
const turretOrigin = player.modelMatrix.transformPoint(new THREE.Vector3(0, 0.88, 2.15));
const desiredYaw = Math.atan2(aimWorldPos.x - turretOrigin.x, aimWorldPos.z - turretOrigin.z);
let yawDiff = desiredYaw - player.turretYaw;
while (yawDiff > Math.PI) yawDiff -= Math.PI * 2;
while (yawDiff < -Math.PI) yawDiff += Math.PI * 2;
player.turretYaw += yawDiff * Math.min(1.0, dt * 12.0);
// Weapon cooldowns & firing
player.primaryCooldown = Math.max(0, player.primaryCooldown - dt);
player.secondaryCooldown = Math.max(0, player.secondaryCooldown - dt);
if (mouseDownLeft && player.primaryCooldown <= 0) {
fireMainTurret(player, aimWorldPos, true);
player.primaryCooldown = 0.42;
}
if (mouseDownRight && player.secondaryCooldown <= 0) {
fireSalvoMortar(player, aimWorldPos);
player.secondaryCooldown = 2.4;
}
}
player.updatePhysics(dt, time);
// Update continuous engine & wave audio
const playerSpeed = Math.hypot(player.vel.x, player.vel.z);
audio.updateContinuous(
Math.min(1.0, playerSpeed / 22.0),
currentPreset.waveScale * 0.6 + rogueWave.intensity * 0.8,
Math.max(0, (player.waterHeight - player.pos.y) * 0.5)
);
// --- 3. UPDATE ENEMY WARSHIPS & AI ---
for (let i = enemies.length - 1; i >= 0; i--) {
const e = enemies[i];
if (!e.sinking) {
const toPlayerX = player.pos.x - e.pos.x;
const toPlayerZ = player.pos.z - e.pos.z;
const dist = Math.hypot(toPlayerX, toPlayerZ) || 1;
// Desired navigation angle: circle around player at ~55m combat distance
const directAngle = Math.atan2(toPlayerX, toPlayerZ);
const orbitOffset = dist < 68 ? Math.PI * 0.38 : (dist < 105 ? Math.PI * 0.18 : 0.0);
const desiredNavYaw = directAngle + orbitOffset;
let navDiff = desiredNavYaw - e.yaw;
while (navDiff > Math.PI) navDiff -= Math.PI * 2;
while (navDiff < -Math.PI) navDiff += Math.PI * 2;
e.rudder = Math.max(-1.0, Math.min(1.0, navDiff * 1.8));
e.throttle = dist > 35 ? 0.85 : 0.45;
// Avoid colliding with other enemy boats
for (let j = 0; j < enemies.length; j++) {
if (i === j) continue;
const other = enemies[j];
const sepX = e.pos.x - other.pos.x;
const sepZ = e.pos.z - other.pos.z;
const sepDist = Math.hypot(sepX, sepZ);
if (sepDist < 22.0 && sepDist > 0.1) {
e.vel.x += (sepX / sepDist) * 14.0 * dt;
e.vel.z += (sepZ / sepDist) * 14.0 * dt;
}
}
// Aim turret with predictive target leading on the player boat
const leadTime = Math.min(2.2, dist / 52.0);
const leadPos = new THREE.Vector3(
player.pos.x + player.vel.x * leadTime,
player.pos.y,
player.pos.z + player.vel.z * leadTime
);
const desiredTurretYaw = Math.atan2(leadPos.x - e.pos.x, leadPos.z - e.pos.z);
let tDiff = desiredTurretYaw - e.turretYaw;
while (tDiff > Math.PI) tDiff -= Math.PI * 2;
while (tDiff < -Math.PI) tDiff += Math.PI * 2;
e.turretYaw += tDiff * Math.min(1.0, dt * 5.0);
// Fire at player when within range
e.primaryCooldown -= dt;
if (e.primaryCooldown <= 0 && dist < 175.0 && !gameOver) {
fireMainTurret(e, leadPos, false);
e.primaryCooldown = 2.2 + Math.random() * 1.4;
}
// Smoke plume if heavily damaged
if (e.hp < e.maxHp * 0.5 && Math.random() < 0.35) {
spawnParticle(
e.pos.x + (Math.random() - 0.5) * 2,
e.pos.y + 2.0,
e.pos.z + (Math.random() - 0.5) * 2,
(Math.random() - 0.5) * 2, 3.5 + Math.random() * 2, (Math.random() - 0.5) * 2,
[0.18, 0.18, 0.2, 0.75], 2.4, 1.1, false
);
}
}
e.updatePhysics(dt, time);
if (e.sinking && e.sinkTimer > 3.5) {
enemies.splice(i, 1);
}
}
// Spawn next fleet wave if all enemies defeated
if (enemies.length === 0 && !gameOver) {
waveNumber++;
score += 250;
spawnEnemyWave(waveNumber);
}
// --- 4. UPDATE PROJECTILES & COLLISIONS ---
for (let i = projectiles.length - 1; i >= 0; i--) {
const p = projectiles[i];
p.life -= dt;
p.vel.y -= 22.0 * dt; // Gravity
p.pos.addScaledVector(p.vel, dt);
// Tracer smoke trail
if (Math.random() < 0.65) {
spawnParticle(
p.pos.x, p.pos.y, p.pos.z,
0, 0.4, 0,
p.isPlayer ? [0.7, 0.9, 1.0, 0.45] : [1.0, 0.6, 0.4, 0.45],
0.9, 0.28, false
);
}
let hitSomething = false;
// Check boat hits
if (p.isPlayer) {
for (const e of enemies) {
if (e.sinking) continue;
const dx = p.pos.x - e.pos.x;
const dz = p.pos.z - e.pos.z;
const dy = p.pos.y - (e.pos.y + 1.0);
if (dx * dx + dz * dz < 28.0 && Math.abs(dy) < 3.2) {
e.hp -= p.damage;
e.damageFlash = 1.0;
e.rollVel += (Math.random() - 0.5) * 0.9;
spawnExplosionFX(p.pos.x, p.pos.y, p.pos.z, 0.75);
audio.playExplosion();
hitSomething = true;
if (e.hp <= 0 && !e.sinking) {
e.sinking = true;
score += 100;
spawnExplosionFX(e.pos.x, e.pos.y + 1.0, e.pos.z, 1.6);
// Drop floating repair crate
if (Math.random() < 0.65) {
crates.push({ pos: new THREE.Vector3(e.pos.x, e.pos.y, e.pos.z), yaw: 0 });
}
}
break;
}
}
} else if (!gameOver) {
const dx = p.pos.x - player.pos.x;
const dz = p.pos.z - player.pos.z;
const dy = p.pos.y - (player.pos.y + 1.0);
if (dx * dx + dz * dz < 26.0 && Math.abs(dy) < 3.2) {
player.hp = Math.max(0, player.hp - p.damage);
player.damageFlash = 1.0;
player.rollVel += (Math.random() - 0.5) * 0.85;
spawnExplosionFX(p.pos.x, p.pos.y, p.pos.z, 0.75);
addLensDroplets(2);
audio.playExplosion();
hitSomething = true;
if (player.hp <= 0 && !gameOver) {
gameOver = true;
player.sinking = true;
spawnExplosionFX(player.pos.x, player.pos.y + 1.0, player.pos.z, 2.0);
document.getElementById('finalStatsText').textContent =
`Your warship was claimed by the sea on Wave ${waveNumber} with a combat score of ${score}.`;
document.getElementById('modalOverlay').classList.add('show');
}
}
}
// Check water impact
if (!hitSomething) {
const ws = sampleWaterAt(p.pos.x, p.pos.z, time);
if (p.pos.y <= ws.height) {
spawnWaterPlume(p.pos.x, ws.height, p.pos.z, 0.9);
if (p.pos.distanceTo(player.pos) < 85) audio.playSplash(0.8);
hitSomething = true;
}
}
if (hitSomething || p.life <= 0) {
projectiles.splice(i, 1);
}
}
// --- 5. UPDATE REPAIR CRATES ---
for (let i = crates.length - 1; i >= 0; i--) {
const c = crates[i];
const ws = sampleWaterAt(c.pos.x, c.pos.z, time);
c.pos.y = ws.height + 0.25;
c.yaw += dt * 1.4;
if (c.pos.distanceTo(player.pos) < 8.5 && !gameOver) {
player.hp = Math.min(player.maxHp, player.hp + 30);
score += 50;
crates.splice(i, 1);
}
}
// --- 6. UPDATE WAKE, SPLASHES & PARTICLES ---
for (let i = wakeList.length - 1; i >= 0; i--) {
wakeList[i].age += dt * 0.24;
if (wakeList[i].age >= 1.0) wakeList.splice(i, 1);
}
for (let i = splashList.length - 1; i >= 0; i--) {
splashList[i].age += dt * 0.55;
if (splashList[i].age >= 1.0) splashList.splice(i, 1);
}
for (let i = particles.length - 1; i >= 0; i--) {
const pt = particles[i];
pt.life -= dt;
if (pt.life <= 0) {
particles.splice(i, 1);
continue;
}
if (pt.hasGravity) pt.vy -= 18.0 * dt;
pt.x += pt.vx * dt;
pt.y += pt.vy * dt;
pt.z += pt.vz * dt;
}
// --- 7. UPDATE 3RD-PERSON FOLLOW CAMERA ---
const camYaw = player.yaw - mouseNDC.x * 0.55;
const targetCamPos = new THREE.Vector3(
player.pos.x - Math.sin(camYaw) * cameraDist,
Math.max(player.waterHeight + 3.8, player.pos.y + 7.5 + cameraDist * 0.24 - mouseNDC.y * 3.5),
player.pos.z - Math.cos(camYaw) * cameraDist
);
// Prevent camera from dipping below waves at camera's own XZ
const camWave = sampleWaterAt(targetCamPos.x, targetCamPos.z, time);
targetCamPos.y = Math.max(targetCamPos.y, camWave.height + 2.4);
cameraPos.lerp(targetCamPos, Math.min(1.0, dt * 6.5));
const targetLook = new THREE.Vector3(
player.pos.x + Math.sin(player.yaw) * 7.5,
player.pos.y + 1.8,
player.pos.z + Math.cos(player.yaw) * 7.5
);
cameraLookAt.lerp(targetLook, Math.min(1.0, dt * 8.5));
const aspect = canvas.width / canvas.height;
projMat.makePerspective(55.0, aspect, 0.5, 1800.0);
viewMat.makeLookAt(cameraPos, cameraLookAt, new THREE.Vector3(Math.sin(player.roll) * 0.14, 1.0, 0.0).normalize());
// Compute inverse matrices for sky ray-casting & mouse aiming
// Inverse Perspective
const pe = projMat.elements;
invProjMat.identity();
invProjMat.elements[0] = 1.0 / pe[0];
invProjMat.elements[5] = 1.0 / pe[5];
invProjMat.elements[11] = 1.0 / pe[14];
invProjMat.elements[14] = -1.0;
invProjMat.elements[15] = pe[10] / pe[14];
// Inverse View (transpose of 3x3 rotation + camera position)
const ve = viewMat.elements;
invViewMat.identity();
invViewMat.elements[0] = ve[0]; invViewMat.elements[4] = ve[1]; invViewMat.elements[8] = ve[2];
invViewMat.elements[1] = ve[4]; invViewMat.elements[5] = ve[5]; invViewMat.elements[9] = ve[6];
invViewMat.elements[2] = ve[8]; invViewMat.elements[6] = ve[9]; invViewMat.elements[10] = ve[10];
invViewMat.elements[12] = cameraPos.x;
invViewMat.elements[13] = cameraPos.y;
invViewMat.elements[14] = cameraPos.z;
updateMouseAimRay(invProjMat, invViewMat, time);
// ==========================================================================
// RENDER PASS 1: ATMOSPHERIC SKY DOME
// ==========================================================================
gl.disable(gl.DEPTH_TEST);
gl.depthMask(false);
gl.useProgram(skyProg);
gl.uniformMatrix4fv(gl.getUniformLocation(skyProg, 'uInvProj'), false, invProjMat.elements);
gl.uniformMatrix4fv(gl.getUniformLocation(skyProg, 'uInvView'), false, invViewMat.elements);
gl.uniform3f(gl.getUniformLocation(skyProg, 'uSunDir'), currentPreset.sunDir.x, currentPreset.sunDir.y, currentPreset.sunDir.z);
gl.uniform3fv(gl.getUniformLocation(skyProg, 'uSunColor'), currentPreset.sunColor);
gl.uniform3fv(gl.getUniformLocation(skyProg, 'uSkyZenith'), currentPreset.skyZenith);
gl.uniform3fv(gl.getUniformLocation(skyProg, 'uSkyHorizon'), currentPreset.skyHorizon);
gl.uniform1f(gl.getUniformLocation(skyProg, 'uTime'), time);
gl.uniform1f(gl.getUniformLocation(skyProg, 'uStormFactor'), currentPreset.stormFactor);
gl.bindVertexArray(skyGeo.vao);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, skyGeo.count);
// ==========================================================================
// RENDER PASS 2: ULTRA-REALISTIC GERSTNER & ROGUE WAVE OCEAN
// ==========================================================================
gl.enable(gl.DEPTH_TEST);
gl.depthMask(true);
gl.clear(gl.DEPTH_BUFFER_BIT);
gl.useProgram(waterProg);
gl.uniformMatrix4fv(gl.getUniformLocation(waterProg, 'uProj'), false, projMat.elements);
gl.uniformMatrix4fv(gl.getUniformLocation(waterProg, 'uView'), false, viewMat.elements);
gl.uniform3f(gl.getUniformLocation(waterProg, 'uCameraPos'), cameraPos.x, cameraPos.y, cameraPos.z);
gl.uniform1f(gl.getUniformLocation(waterProg, 'uTime'), time);
gl.uniform1f(gl.getUniformLocation(waterProg, 'uWaveScale'), currentPreset.waveScale);
gl.uniform4fv(gl.getUniformLocation(waterProg, 'uWavesA'), uWavesAData);
gl.uniform4fv(gl.getUniformLocation(waterProg, 'uWavesB'), uWavesBData);
gl.uniform4f(gl.getUniformLocation(waterProg, 'uRogueA'), rogueWave.dirX, rogueWave.dirZ, rogueWave.centerDist, rogueWave.amplitude);
gl.uniform4f(gl.getUniformLocation(waterProg, 'uRogueB'), rogueWave.width, rogueWave.intensity, 0, 0);
gl.uniform3f(gl.getUniformLocation(waterProg, 'uSunDir'), currentPreset.sunDir.x, currentPreset.sunDir.y, currentPreset.sunDir.z);
gl.uniform3fv(gl.getUniformLocation(waterProg, 'uSunColor'), currentPreset.sunColor);
gl.uniform3fv(gl.getUniformLocation(waterProg, 'uSkyZenith'), currentPreset.skyZenith);
gl.uniform3fv(gl.getUniformLocation(waterProg, 'uSkyHorizon'), currentPreset.skyHorizon);
gl.uniform3fv(gl.getUniformLocation(waterProg, 'uWaterDeep'), currentPreset.waterDeep);
gl.uniform3fv(gl.getUniformLocation(waterProg, 'uWaterShallow'), currentPreset.waterShallow);
gl.uniform1f(gl.getUniformLocation(waterProg, 'uStormFactor'), currentPreset.stormFactor);
// Pack Wake uniform array
const wakeUniform = new Float32Array(MAX_WAKE * 4);
for (let i = 0; i < wakeList.length; i++) {
const w = wakeList[i];
wakeUniform.set([w.x, w.z, w.radius, w.age], i * 4);
}
gl.uniform4fv(gl.getUniformLocation(waterProg, 'uWake'), wakeUniform);
// Pack Splash uniform array
const splashUniform = new Float32Array(MAX_SPLASH * 4);
for (let i = 0; i < splashList.length; i++) {
const sp = splashList[i];
splashUniform.set([sp.x, sp.z, sp.radius, sp.age], i * 4);
}
gl.uniform4fv(gl.getUniformLocation(waterProg, 'uSplashes'), splashUniform);
// Pack Boats uniform array for hull contact foam
const boatsUniform = new Float32Array(10 * 4);
let boatCount = 0;
if (!player.sinking) {
boatsUniform.set([player.pos.x, player.pos.z, player.yaw, 6.4], 0);
boatCount = 1;
}
for (let i = 0; i < enemies.length && boatCount < 10; i++) {
const e = enemies[i];
if (!e.sinking) {
boatsUniform.set([e.pos.x, e.pos.z, e.yaw, 6.4], boatCount * 4);
boatCount++;
}
}
gl.uniform4fv(gl.getUniformLocation(waterProg, 'uBoats'), boatsUniform);
gl.uniform1i(gl.getUniformLocation(waterProg, 'uNumBoats'), boatCount);
gl.uniform4f(gl.getUniformLocation(waterProg, 'uAimReticle'), aimWorldPos.x, aimWorldPos.z, 3.2, gameOver ? 0.0 : 1.0);
gl.bindVertexArray(waterGeo.vao);
gl.drawElements(gl.TRIANGLES, waterGeo.count, gl.UNSIGNED_INT, 0);
// ==========================================================================
// RENDER PASS 3: 3D WARSHIPS, TURRETS, SHELLS & SUPPLY CRATES
// ==========================================================================
gl.useProgram(meshProg);
gl.uniformMatrix4fv(gl.getUniformLocation(meshProg, 'uProj'), false, projMat.elements);
gl.uniformMatrix4fv(gl.getUniformLocation(meshProg, 'uView'), false, viewMat.elements);
gl.uniform3f(gl.getUniformLocation(meshProg, 'uCameraPos'), cameraPos.x, cameraPos.y, cameraPos.z);
gl.uniform3f(gl.getUniformLocation(meshProg, 'uSunDir'), currentPreset.sunDir.x, currentPreset.sunDir.y, currentPreset.sunDir.z);
gl.uniform3fv(gl.getUniformLocation(meshProg, 'uSunColor'), currentPreset.sunColor);
gl.uniform3fv(gl.getUniformLocation(meshProg, 'uSkyZenith'), currentPreset.skyZenith);
gl.uniform3fv(gl.getUniformLocation(meshProg, 'uSkyHorizon'), currentPreset.skyHorizon);
const uModelLoc = gl.getUniformLocation(meshProg, 'uModel');
const uWaterHtLoc = gl.getUniformLocation(meshProg, 'uWaterHeightAtBoat');
const uEmissiveLoc = gl.getUniformLocation(meshProg, 'uEmissive');
const uDamageLoc = gl.getUniformLocation(meshProg, 'uDamageFlash');
// Render Player Boat & Turret
gl.uniform1f(uWaterHtLoc, player.waterHeight);
gl.uniform1f(uEmissiveLoc, 0.08);
gl.uniform1f(uDamageLoc, player.damageFlash);
gl.uniformMatrix4fv(uModelLoc, false, player.modelMatrix.elements);
gl.bindVertexArray(playerHullGeo.vao);
gl.drawArrays(gl.TRIANGLES, 0, playerHullGeo.count);
gl.uniformMatrix4fv(uModelLoc, false, player.turretMatrix.elements);
gl.bindVertexArray(playerTurretGeo.vao);
gl.drawArrays(gl.TRIANGLES, 0, playerTurretGeo.count);
// Render Enemy Boats & Turrets
for (const e of enemies) {
gl.uniform1f(uWaterHtLoc, e.waterHeight);
gl.uniform1f(uDamageLoc, e.damageFlash);
gl.uniformMatrix4fv(uModelLoc, false, e.modelMatrix.elements);
gl.bindVertexArray(enemyHullGeo.vao);
gl.drawArrays(gl.TRIANGLES, 0, enemyHullGeo.count);
gl.uniformMatrix4fv(uModelLoc, false, e.turretMatrix.elements);
gl.bindVertexArray(enemyTurretGeo.vao);
gl.drawArrays(gl.TRIANGLES, 0, enemyTurretGeo.count);
}
// Render Repair Crates
gl.uniform1f(uDamageLoc, 0.0);
gl.uniform1f(uEmissiveLoc, 0.35 + Math.sin(time * 5.0) * 0.2);
gl.bindVertexArray(crateGeo.vao);
for (const c of crates) {
gl.uniform1f(uWaterHtLoc, c.pos.y - 0.2);
tempMat.composeYPR(c.pos, c.yaw, Math.sin(time * 3.0) * 0.15, Math.cos(time * 2.5) * 0.15, new THREE.Vector3(1, 1, 1));
gl.uniformMatrix4fv(uModelLoc, false, tempMat.elements);
gl.drawArrays(gl.TRIANGLES, 0, crateGeo.count);
}
// Render Artillery Shells
gl.uniform1f(uEmissiveLoc, 1.4);
gl.bindVertexArray(shellGeo.vao);
for (const p of projectiles) {
const yaw = Math.atan2(p.vel.x, p.vel.z);
const pitch = -Math.atan2(p.vel.y, Math.hypot(p.vel.x, p.vel.z));
tempMat.composeYPR(p.pos, yaw, pitch, 0, new THREE.Vector3(1, 1, 1));
gl.uniformMatrix4fv(uModelLoc, false, tempMat.elements);
gl.drawArrays(gl.TRIANGLES, 0, shellGeo.count);
}
// ==========================================================================
// RENDER PASS 4: 3D SOFT PARTICLES (SPRAY, SMOKE, EXPLOSIONS)
// ==========================================================================
if (particles.length > 0) {
for (let i = 0; i < particles.length; i++) {
const pt = particles[i];
const lifeRatio = pt.life / pt.maxLife;
particlePos[i * 3] = pt.x;
particlePos[i * 3 + 1] = pt.y;
particlePos[i * 3 + 2] = pt.z;
particleCol[i * 4] = pt.r;
particleCol[i * 4 + 1] = pt.g;
particleCol[i * 4 + 2] = pt.b;
particleCol[i * 4 + 3] = pt.a * lifeRatio;
particleSize[i] = pt.size * (1.25 - lifeRatio * 0.45);
}
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);
gl.useProgram(particleProg);
gl.uniformMatrix4fv(gl.getUniformLocation(particleProg, 'uProj'), false, projMat.elements);
gl.uniformMatrix4fv(gl.getUniformLocation(particleProg, 'uView'), false, viewMat.elements);
gl.uniform1f(gl.getUniformLocation(particleProg, 'uViewportHeight'), canvas.height);
gl.bindVertexArray(particleVAO);
gl.bindBuffer(gl.ARRAY_BUFFER, pPosBuf);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particlePos.subarray(0, particles.length * 3));
gl.bindBuffer(gl.ARRAY_BUFFER, pColBuf);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particleCol.subarray(0, particles.length * 4));
gl.bindBuffer(gl.ARRAY_BUFFER, pSizeBuf);
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particleSize.subarray(0, particles.length));
gl.drawArrays(gl.POINTS, 0, particles.length);
gl.depthMask(true);
gl.disable(gl.BLEND);
}
// ==========================================================================
// RENDER PASS 5: 2D OVERLAYS (ENEMY HEALTH BARS, LENS DROPLETS, RADAR & HUD)
// ==========================================================================
draw2DOverlays(dt);
updateHUDTelemetry();
}
function draw2DOverlays(dt) {
fxCtx.clearRect(0, 0, fxCanvas.width, fxCanvas.height);
// Project 3D enemy health bars onto 2D screen
const vpMat = new THREE.Matrix4().multiplyMatrices(projMat, viewMat);
const vpe = vpMat.elements;
for (const e of enemies) {
if (e.sinking) continue;
const wx = e.pos.x, wy = e.pos.y + 4.8, wz = e.pos.z;
const cx = vpe[0] * wx + vpe[4] * wy + vpe[8] * wz + vpe[12];
const cy = vpe[1] * wx + vpe[5] * wy + vpe[9] * wz + vpe[13];
const cw = vpe[3] * wx + vpe[7] * wy + vpe[11] * wz + vpe[15];
if (cw > 0.1) {
const sx = (cx / cw * 0.5 + 0.5) * fxCanvas.width;
const sy = (-cy / cw * 0.5 + 0.5) * fxCanvas.height;
const barW = 54, barH = 6;
fxCtx.fillStyle = 'rgba(0, 0, 0, 0.65)';
fxCtx.fillRect(sx - barW * 0.5 - 1, sy - 1, barW + 2, barH + 2);
fxCtx.fillStyle = '#ff4757';
fxCtx.fillRect(sx - barW * 0.5, sy, barW * Math.max(0, e.hp / e.maxHp), barH);
}
}
// Draw water droplets on camera lens when hit by big waves
for (let i = lensDroplets.length - 1; i >= 0; i--) {
const d = lensDroplets[i];
d.y += d.vy * dt;
d.alpha -= dt * 0.25;
if (d.alpha <= 0) {
lensDroplets.splice(i, 1);
continue;
}
const grad = fxCtx.createRadialGradient(d.x, d.y, 1, d.x, d.y, d.r);
grad.addColorStop(0, `rgba(200, 240, 255, ${d.alpha * 0.55})`);
grad.addColorStop(0.7, `rgba(120, 200, 255, ${d.alpha * 0.25})`);
grad.addColorStop(1, 'rgba(120, 200, 255, 0)');
fxCtx.fillStyle = grad;
fxCtx.beginPath();
fxCtx.arc(d.x, d.y, d.r, 0, Math.PI * 2);
fxCtx.fill();
}
// Draw Tactical Naval Radar
const rw = radarCanvas.width, rh = radarCanvas.height;
const cx = rw * 0.5, cy = rh * 0.5;
const radarRange = 230.0;
radarCtx.clearRect(0, 0, rw, rh);
// Range rings
radarCtx.strokeStyle = 'rgba(80, 190, 255, 0.22)';
radarCtx.lineWidth = 1;
for (const r of [25, 50, 72]) {
radarCtx.beginPath();
radarCtx.arc(cx, cy, r, 0, Math.PI * 2);
radarCtx.stroke();
}
// Rotate radar relative to player heading (Up = Boat Bow)
radarCtx.save();
radarCtx.translate(cx, cy);
radarCtx.rotate(player.yaw);
// Draw Rogue Wave front line on radar if active
if (rogueWave.active && rogueWave.intensity > 0.05) {
const playerProj = player.pos.x * rogueWave.dirX + player.pos.z * rogueWave.dirZ;
const distToWave = rogueWave.centerDist - playerProj;
if (Math.abs(distToWave) < radarRange) {
const wx = (rogueWave.dirX * distToWave / radarRange) * 75;
const wz = -(rogueWave.dirZ * distToWave / radarRange) * 75;
const px = -rogueWave.dirZ * 80;
const pz = -rogueWave.dirX * 80;
radarCtx.strokeStyle = 'rgba(255, 80, 80, 0.85)';
radarCtx.lineWidth = 3;
radarCtx.beginPath();
radarCtx.moveTo(wx - px, wz - pz);
radarCtx.lineTo(wx + px, wz + pz);
radarCtx.stroke();
}
}
// Draw Repair Crates
radarCtx.fillStyle = '#00e676';
for (const c of crates) {
const dx = ((c.pos.x - player.pos.x) / radarRange) * 75;
const dz = -((c.pos.z - player.pos.z) / radarRange) * 75;
if (dx * dx + dz * dz < 72 * 72) {
radarCtx.fillRect(dx - 2.5, dz - 2.5, 5, 5);
}
}
// Draw Enemy Boats
radarCtx.fillStyle = '#ff4757';
for (const e of enemies) {
if (e.sinking) continue;
const dx = ((e.pos.x - player.pos.x) / radarRange) * 75;
const dz = -((e.pos.z - player.pos.z) / radarRange) * 75;
const d = Math.hypot(dx, dz);
const clampedD = Math.min(72, d);
const rx = d > 0 ? (dx / d) * clampedD : 0;
const rz = d > 0 ? (dz / d) * clampedD : 0;
radarCtx.beginPath();
radarCtx.arc(rx, rz, 3.5, 0, Math.PI * 2);
radarCtx.fill();
}
radarCtx.restore();
// Player Boat Icon at Radar Center
radarCtx.fillStyle = '#00e5ff';
radarCtx.beginPath();
radarCtx.moveTo(cx, cy - 6);
radarCtx.lineTo(cx + 4.5, cy + 5);
radarCtx.lineTo(cx - 4.5, cy + 5);
radarCtx.closePath();
radarCtx.fill();
}
function updateHUDTelemetry() {
const hpPct = Math.max(0, Math.round((player.hp / player.maxHp) * 100));
document.getElementById('hullPctText').textContent = `${hpPct}%`;
const hullBar = document.getElementById('hullBar');
hullBar.style.width = `${hpPct}%`;
hullBar.style.background = hpPct > 50
? 'linear-gradient(90deg, #00e676, #69f0ae)'
: (hpPct > 25 ? 'linear-gradient(90deg, #ffab00, #ffd740)' : 'linear-gradient(90deg, #ff1744, #ff5252)');
document.getElementById('scoreVal').textContent = score;
document.getElementById('waveVal').textContent = waveNumber;
document.getElementById('enemiesVal').textContent = enemies.filter((e) => !e.sinking).length;
const knots = (Math.hypot(player.vel.x, player.vel.z) * 1.65).toFixed(1);
document.getElementById('speedVal').textContent = knots;
document.getElementById('pitchVal').textContent = `${Math.round(player.pitch * 57.3)}°`;
document.getElementById('rollVal').textContent = `${Math.round(player.roll * 57.3)}°`;
document.getElementById('waveHtVal').textContent = `${player.waterHeight.toFixed(1)}m`;
const w1Pct = Math.round((1.0 - player.primaryCooldown / 0.42) * 100);
document.getElementById('w1Bar').style.width = `${w1Pct}%`;
document.getElementById('w1Status').textContent = player.primaryCooldown <= 0 ? 'READY' : 'RELOAD';
const w2Pct = Math.round((1.0 - player.secondaryCooldown / 2.4) * 100);
document.getElementById('w2Bar').style.width = `${w2Pct}%`;
document.getElementById('w2Status').textContent = player.secondaryCooldown <= 0 ? 'READY' : 'RELOAD';
const boostPct = Math.round(player.boostFuel * 100);
document.getElementById('boostBar').style.width = `${boostPct}%`;
document.getElementById('boostStatus').textContent = `${boostPct}%`;
}
requestAnimationFrame(animate);
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.