Boat game, built by Kimi K3.1 Pro
Hostedvia OpenCodeOpenRouter
68tok/s
First token included
114.7Ktokens
28m 0s
$1.77
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" />
<meta name="theme-color" content="#07171d" />
<title>BREAKWATER — Naval Survival</title>
<style>
:root {
--ink: #07161c;
--paper: #ecf5f2;
--muted: #9cb3b6;
--line: rgba(220, 241, 238, .18);
--accent: #ff9a5c;
--danger: #ff655c;
--glass: rgba(5, 20, 26, .72);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #6e929d; }
body { font-family: Inter, "Helvetica Neue", Arial, sans-serif; color: var(--paper); user-select: none; }
button { font: inherit; }
#stage, #stage canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; }
#stage canvas { cursor: none; }
.vignette, .grain, #damage-flash { position: fixed; inset: 0; pointer-events: none; z-index: 3; }
.vignette { background: radial-gradient(circle at 50% 43%, transparent 42%, rgba(0, 11, 16, .3) 100%); }
.grain { opacity: .055; background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.8'/%3E%3C/svg%3E"); }
#damage-flash { background: radial-gradient(circle, transparent 25%, rgba(255, 38, 25, .75)); opacity: 0; transition: opacity .12s; }
.hidden { display: none !important; }
.screen { position: fixed; inset: 0; z-index: 10; }
#menu { background: linear-gradient(90deg, rgba(3, 16, 22, .88) 0%, rgba(3, 16, 22, .56) 42%, rgba(3, 16, 22, .04) 75%); }
.menu-top { position: absolute; left: 4.5vw; right: 4.5vw; top: 34px; display: flex; align-items: center; justify-content: space-between; }
.brand { display: flex; gap: 13px; align-items: center; font-size: 12px; letter-spacing: .24em; font-weight: 750; }
.brand svg { width: 29px; height: 29px; color: var(--accent); }
.build-tag { color: rgba(235, 246, 243, .62); font: 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .15em; }
.menu-main { position: absolute; left: 7.5vw; top: 50%; transform: translateY(-48%); max-width: 720px; }
.eyebrow { display: flex; align-items: center; gap: 12px; color: var(--accent); font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .22em; text-transform: uppercase; }
.eyebrow::before { content: ""; width: 30px; height: 1px; background: currentColor; }
h1 { margin: 22px 0 17px; font-size: clamp(58px, 7.7vw, 124px); line-height: .82; letter-spacing: -.065em; font-weight: 850; text-shadow: 0 5px 40px rgba(0, 0, 0, .15); }
.subtitle { max-width: 450px; color: rgba(229, 241, 238, .72); font-size: 14px; line-height: 1.75; letter-spacing: .015em; }
.launch-row { display: flex; align-items: center; gap: 24px; margin-top: 34px; }
.primary { position: relative; border: 0; background: var(--accent); color: #152128; padding: 0 25px; height: 54px; min-width: 205px; font-size: 11px; letter-spacing: .16em; font-weight: 800; text-transform: uppercase; cursor: pointer; transition: transform .2s, background .2s; clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); }
.primary:hover { transform: translateY(-2px); background: #ffb07e; }
.primary span { margin-left: 25px; font-size: 18px; }
.best { font: 10px/1.8 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); letter-spacing: .12em; }
.best strong { display: block; color: var(--paper); font-size: 16px; letter-spacing: .05em; }
.feature-row { display: flex; gap: 9px; margin-top: 30px; flex-wrap: wrap; }
.feature { padding: 8px 11px; border: 1px solid var(--line); background: rgba(8, 24, 30, .25); color: rgba(226, 241, 238, .66); font: 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .1em; }
.mission-card { position: absolute; right: 5vw; top: 50%; transform: translateY(-42%); width: 280px; padding: 22px; background: linear-gradient(145deg, rgba(7, 26, 33, .76), rgba(7, 23, 29, .45)); border: 1px solid rgba(225, 242, 239, .19); backdrop-filter: blur(10px); clip-path: polygon(0 0, calc(100% - 15px) 0, 100% 15px, 100% 100%, 0 100%); }
.card-head { display: flex; justify-content: space-between; font: 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--accent); letter-spacing: .14em; }
.mission-card h2 { font-size: 20px; letter-spacing: .02em; margin: 20px 0 7px; }
.mission-card > p { margin: 0 0 22px; font-size: 11px; color: var(--muted); line-height: 1.6; }
.control-row { display: flex; align-items: center; justify-content: space-between; padding: 11px 0; border-top: 1px solid var(--line); color: #b7c8c9; font-size: 10px; letter-spacing: .09em; }
.keys { display: flex; gap: 4px; }
kbd { display: inline-flex; min-width: 25px; height: 23px; padding: 0 6px; align-items: center; justify-content: center; border: 1px solid rgba(231, 246, 243, .3); border-radius: 2px; background: rgba(255, 255, 255, .045); color: #eff8f6; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: 0 2px 0 rgba(0, 0, 0, .18); }
.status-line { display: flex; align-items: center; gap: 8px; margin-top: 16px; color: #8fb7ae; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; }
.status-dot { width: 5px; height: 5px; border-radius: 50%; background: #75d5ac; box-shadow: 0 0 8px #75d5ac; }
.menu-bottom { position: absolute; bottom: 27px; left: 4.5vw; right: 4.5vw; display: flex; justify-content: space-between; align-items: center; color: rgba(223, 238, 235, .42); font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; }
#hud { position: fixed; inset: 0; z-index: 5; pointer-events: none; opacity: 0; transition: opacity .4s; }
#hud.active { opacity: 1; }
.hud-top { position: absolute; left: 30px; right: 30px; top: 24px; display: flex; justify-content: space-between; align-items: flex-start; }
.hud-brand { font-size: 11px; font-weight: 800; letter-spacing: .19em; text-shadow: 0 1px 5px #000; }
.hud-brand small { display: block; margin-top: 6px; color: rgba(231, 244, 241, .55); font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .16em; }
.compass { position: absolute; left: 50%; top: 0; transform: translateX(-50%); width: 280px; text-align: center; overflow: hidden; }
#heading { font: 700 12px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .15em; text-shadow: 0 1px 6px #000; }
.compass-rule { height: 13px; margin-top: 8px; border-top: 1px solid rgba(238, 248, 246, .4); background: repeating-linear-gradient(90deg, rgba(238,248,246,.5) 0 1px, transparent 1px 17.5px); mask-image: linear-gradient(90deg, transparent, black 25%, black 75%, transparent); }
.compass-rule::after { content: ""; display: block; width: 0; height: 0; margin: -2px auto; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid var(--accent); }
.hud-stats { display: flex; gap: 26px; text-align: right; }
.stat label { display: block; color: rgba(229, 243, 240, .56); font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .17em; }
.stat strong { display: block; margin-top: 4px; font: 600 19px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .06em; text-shadow: 0 1px 6px #000; }
.hud-bottom { position: absolute; left: 30px; right: 30px; bottom: 25px; display: flex; justify-content: space-between; align-items: flex-end; }
.panel { width: 235px; padding: 15px 16px; background: linear-gradient(110deg, rgba(4, 19, 25, .77), rgba(4, 19, 25, .36)); border-left: 2px solid rgba(230, 244, 241, .45); backdrop-filter: blur(5px); }
.panel-title { display: flex; justify-content: space-between; color: #d5e5e2; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .15em; }
.health-number { margin: 8px 0 7px; font: 600 25px ui-monospace, SFMono-Regular, Menlo, monospace; }
.health-number small { font-size: 10px; color: var(--muted); }
.bar { height: 4px; background: rgba(255, 255, 255, .13); overflow: hidden; }
.bar > i { display: block; width: 100%; height: 100%; background: #9dd9ca; transform-origin: left; transition: background .2s; }
.hull-meta { display: flex; justify-content: space-between; margin-top: 9px; color: #829c9e; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; }
.speed-block { position: absolute; left: 50%; bottom: 0; transform: translateX(-50%); text-align: center; text-shadow: 0 2px 8px #001016; }
#speed { font: 300 46px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: -.08em; }
.speed-unit { margin-left: 7px; color: #c0d1d0; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .18em; }
.throttle { width: 140px; height: 2px; margin: 12px auto 7px; background: rgba(255,255,255,.18); }
.throttle i { display: block; height: 100%; width: 0; background: var(--accent); }
#throttle-label { color: #a9bdbc; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .14em; }
.weapon { border-left: 0; border-right: 2px solid var(--accent); text-align: right; }
.weapon-name { margin-top: 10px; font-size: 12px; letter-spacing: .09em; font-weight: 650; }
.weapon-state { margin-top: 6px; color: var(--accent); font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; }
.weapon .bar { margin-top: 10px; }
.weapon .bar i { background: var(--accent); }
.controls-hint { position: absolute; bottom: 5px; left: 50%; transform: translateX(-50%); color: rgba(224, 239, 236, .38); white-space: nowrap; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; }
#crosshair { position: fixed; z-index: 7; left: 50%; top: 50%; width: 38px; height: 38px; margin: -19px; pointer-events: none; opacity: 0; transition: opacity .2s; }
#crosshair.active { opacity: 1; }
#crosshair::before, #crosshair::after { content: ""; position: absolute; inset: 0; border: 1px solid rgba(235, 247, 243, .75); border-radius: 50%; clip-path: polygon(0 0, 35% 0, 35% 100%, 0 100%, 0 0, 65% 0, 100% 0, 100% 100%, 65% 100%, 65% 0); }
#crosshair::after { inset: 16px; border: 0; background: var(--accent); clip-path: none; }
#crosshair.locked::before { border-color: var(--danger); transform: rotate(45deg); }
#crosshair.locked::after { background: var(--danger); }
#range-readout { position: absolute; top: 44px; left: 50%; transform: translateX(-50%); white-space: nowrap; color: rgba(238,248,245,.8); font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; }
#announcement { position: fixed; z-index: 8; top: 23%; left: 50%; transform: translate(-50%, -10px); text-align: center; opacity: 0; transition: opacity .35s, transform .35s; pointer-events: none; }
#announcement.show { opacity: 1; transform: translate(-50%, 0); }
#announcement small { display: block; color: var(--accent); font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .25em; }
#announcement strong { display: block; margin-top: 9px; font-size: 27px; letter-spacing: .13em; font-weight: 650; text-shadow: 0 2px 12px #001117; }
#wave-warning { position: fixed; z-index: 8; left: 50%; top: 87px; transform: translateX(-50%); display: none; align-items: center; gap: 12px; padding: 9px 16px; background: rgba(66, 20, 14, .65); border: 1px solid rgba(255, 133, 90, .5); color: #ffd3bb; backdrop-filter: blur(4px); font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .14em; animation: warningPulse 1s infinite alternate; }
#wave-warning.show { display: flex; }
@keyframes warningPulse { to { border-color: rgba(255, 193, 154, .95); } }
#toast { position: fixed; z-index: 8; left: 50%; bottom: 145px; transform: translateX(-50%); padding: 9px 14px; background: rgba(4, 20, 26, .7); border-left: 2px solid var(--accent); font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .1em; opacity: 0; transition: opacity .2s; pointer-events: none; }
#toast.show { opacity: 1; }
#labels { position: fixed; inset: 0; z-index: 4; pointer-events: none; overflow: hidden; }
.enemy-label { position: absolute; width: 100px; margin-left: -50px; text-align: center; color: #ffc0b3; font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-shadow: 0 1px 3px #000; }
.enemy-label .enemy-bar { width: 55px; height: 2px; margin: 5px auto; background: rgba(0, 0, 0, .45); }
.enemy-label .enemy-bar i { display: block; height: 100%; background: #ff7968; }
.modal-screen { display: flex; align-items: center; justify-content: center; background: rgba(2, 12, 17, .57); backdrop-filter: blur(6px); }
.modal { width: min(420px, calc(100vw - 40px)); padding: 35px; text-align: center; background: rgba(5, 22, 28, .92); border: 1px solid var(--line); clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 0 100%); }
.modal .eyebrow { justify-content: center; }
.modal .eyebrow::before { display: none; }
.modal h2 { margin: 18px 0 9px; font-size: 36px; letter-spacing: -.03em; }
.modal p { color: var(--muted); font-size: 12px; line-height: 1.6; }
.result-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin: 27px 0; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.result-grid div { padding: 18px 5px; border-right: 1px solid var(--line); }
.result-grid div:last-child { border: 0; }
.result-grid strong { display: block; font: 21px ui-monospace, SFMono-Regular, Menlo, monospace; }
.result-grid small { color: var(--muted); font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; }
.modal .primary { width: 100%; }
.secondary { margin-top: 15px; border: 0; background: transparent; color: var(--muted); font-size: 9px; letter-spacing: .12em; cursor: pointer; text-transform: uppercase; }
.secondary:hover { color: white; }
#pause .modal { width: 340px; }
#loading { position: fixed; inset: 0; z-index: 30; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: #07171d; transition: opacity .5s; }
#loading span { font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .25em; color: #9eb8b8; }
.loader { width: 90px; height: 2px; background: #17343d; overflow: hidden; }
.loader::after { content: ""; display: block; height: 100%; width: 45%; background: var(--accent); animation: load 1s infinite ease-in-out; }
@keyframes load { from { transform: translateX(-100%); } to { transform: translateX(230%); } }
#fatal { max-width: 520px; padding: 25px; text-align: center; color: #ffc1b4; font-size: 13px; line-height: 1.6; }
@media (max-width: 1050px) { .mission-card { display: none; } .menu-main { left: 8vw; } }
@media (max-width: 650px) {
.menu-top { left: 22px; right: 22px; } .build-tag { display: none; }
.menu-main { left: 25px; right: 25px; } h1 { font-size: 58px; }
.menu-bottom { left: 25px; right: 25px; } .menu-bottom span:last-child { display: none; }
.hud-top { left: 17px; right: 17px; } .compass { display: none; }
.hud-bottom { left: 15px; right: 15px; } .panel { width: 160px; padding: 11px; }
.speed-block { bottom: 95px; } .controls-hint { display: none; }
.hud-stats { gap: 12px; } .hud-brand small { display: none; }
.feature-row { max-width: 360px; }
}
</style>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/" } }
</script>
</head>
<body>
<div id="stage"></div>
<div class="vignette"></div><div class="grain"></div><div id="damage-flash"></div>
<div id="labels"></div>
<section id="menu" class="screen">
<div class="menu-top">
<div class="brand"><svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M16 3v19M10 8h12M6 18c0 7 10 11 10 11s10-4 10-11M3 18h6M23 18h6"/><path d="M10 25l-3 3M22 25l3 3"/></svg>BREAKWATER</div>
<div class="build-tag">OCEAN SIMULATION / BUILD 01</div>
</div>
<div class="menu-main">
<div class="eyebrow">Real-time naval survival</div>
<h1>BREAK<br>WATER</h1>
<p class="subtitle">The open sea is your battlefield. Read the swells, outmaneuver hostile patrols, and hold the line against an unforgiving ocean.</p>
<div class="launch-row">
<button id="launch" class="primary">Launch patrol <span>→</span></button>
<div class="best">PERSONAL BEST<strong id="best-score">000000</strong></div>
</div>
<div class="feature-row"><span class="feature">GERSTNER OCEAN</span><span class="feature">BUOYANCY PHYSICS</span><span class="feature">BALLISTIC COMBAT</span></div>
</div>
<aside class="mission-card">
<div class="card-head"><span>CAPTAIN'S BRIEF</span><span>01 / ∞</span></div>
<h2>Hold the open water.</h2>
<p>Survive escalating attack waves. Brace bow-first into rogue swells to reduce hull damage.</p>
<div class="control-row"><span>THROTTLE / REVERSE</span><span class="keys"><kbd>W</kbd><kbd>S</kbd></span></div>
<div class="control-row"><span>STEER</span><span class="keys"><kbd>A</kbd><kbd>D</kbd></span></div>
<div class="control-row"><span>AIM / FIRE</span><span class="keys"><kbd>MOUSE</kbd><kbd>LMB</kbd></span></div>
<div class="control-row"><span>PAUSE</span><span class="keys"><kbd>P</kbd></span></div>
<div class="status-line"><i class="status-dot"></i>ALL SYSTEMS OPERATIONAL</div>
</aside>
<div class="menu-bottom"><span>HEADPHONES RECOMMENDED · KEYBOARD + MOUSE</span><span>43° 21′ N 09° 32′ E</span></div>
</section>
<section id="hud">
<div class="hud-top">
<div class="hud-brand">BREAKWATER<small>PB–01 / PATROL CRAFT</small></div>
<div class="compass"><div id="heading">000 · N</div><div class="compass-rule"></div></div>
<div class="hud-stats"><div class="stat"><label>WAVE</label><strong id="wave-count">01</strong></div><div class="stat"><label>HOSTILES</label><strong id="enemy-count">03</strong></div><div class="stat"><label>SCORE</label><strong id="score">000000</strong></div></div>
</div>
<div class="hud-bottom">
<div class="panel"><div class="panel-title"><span>HULL INTEGRITY</span><span>PB–01</span></div><div class="health-number"><span id="health">100</span><small> / 100</small></div><div class="bar"><i id="health-bar"></i></div><div class="hull-meta"><span id="hull-status">SEAWORTHY</span><span>ALUMINUM ALLOY</span></div></div>
<div class="speed-block"><span id="speed">00</span><span class="speed-unit">KNOTS</span><div class="throttle"><i id="throttle-bar"></i></div><div id="throttle-label">ENGINE · IDLE</div></div>
<div class="panel weapon"><div class="panel-title"><span>DECK WEAPON</span><span>30 MM</span></div><div class="weapon-name">MK II AUTOCANNON</div><div class="weapon-state" id="weapon-state">READY TO FIRE</div><div class="bar"><i id="reload-bar"></i></div></div>
<div class="controls-hint">W/S THROTTLE · A/D STEER · MOUSE AIM · LMB / SPACE FIRE · P PAUSE · M SOUND</div>
</div>
</section>
<div id="crosshair"><span id="range-readout"></span></div>
<div id="announcement"><small></small><strong></strong></div>
<div id="wave-warning">⚠ <span>ROGUE WAVE APPROACHING</span></div>
<div id="toast"></div>
<section id="gameover" class="screen modal-screen hidden">
<div class="modal"><div class="eyebrow">Patrol terminated</div><h2>Lost to the sea.</h2><p>Your patrol has ended, but the open water remains contested.</p><div class="result-grid"><div><strong id="final-score">0</strong><small>SCORE</small></div><div><strong id="final-kills">0</strong><small>KILLS</small></div><div><strong id="final-wave">1</strong><small>WAVE</small></div></div><button id="restart" class="primary">Return to battle <span>↻</span></button><button id="to-menu" class="secondary">Return to title</button></div>
</section>
<section id="pause" class="screen modal-screen hidden"><div class="modal"><div class="eyebrow">Simulation suspended</div><h2>Paused</h2><p>The ocean can wait. Take a breath, captain.</p><button id="resume" class="primary">Resume patrol</button><button id="pause-menu" class="secondary">Abandon patrol</button></div></section>
<div id="loading"><div class="loader"></div><span>CALIBRATING OCEAN</span><div id="fatal" class="hidden"></div></div>
<script type="module">
import * as THREE from 'three';
import { Water } from 'three/addons/objects/Water.js';
import { Sky } from 'three/addons/objects/Sky.js';
const $ = (id) => document.getElementById(id);
const clamp = THREE.MathUtils.clamp;
const lerp = THREE.MathUtils.lerp;
const TAU = Math.PI * 2;
const Y_AXIS = new THREE.Vector3(0, 1, 0);
const tmpV = new THREE.Vector3();
const tmpV2 = new THREE.Vector3();
const tmpV3 = new THREE.Vector3();
const mouse = new THREE.Vector2(0, 0);
const raycaster = new THREE.Raycaster();
const aimPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const keys = Object.create(null);
const ROGUE_DIR = new THREE.Vector2(0.84, 0.5426).normalize();
const SUN_DIRECTION = new THREE.Vector3();
let renderer, scene, camera, water, sky, sunLight, player, audio;
let mode = 'menu';
let simTime = 0;
let lastTime = performance.now();
let score = 0, kills = 0, wave = 0, shots = 0, hits = 0;
let waveDelay = -1, firing = false, hoverTarget = null;
let announcementTimer = 0, toastTimer = 0, damageFlash = 0, cameraShake = 0;
let bestScore = Number(localStorage.getItem('breakwater-best') || 0);
let muted = localStorage.getItem('breakwater-muted') === '1';
const enemies = [], bullets = [], wrecks = [];
const aimPoint = new THREE.Vector3(0, 0, 30);
const cameraLook = new THREE.Vector3(0, 1, 5);
const rogue = { active: false, timer: 12, front: -500, amp: 0, width: 9, speed: 18, id: 0 };
$('best-score').textContent = String(bestScore).padStart(6, '0');
// The same deep-water dispersion waves drive the GPU surface and CPU buoyancy.
const WAVES = [
{ x: 0.92, z: 0.392, a: 0.44, l: 58, q: 0.48, p: 0.0 },
{ x: -0.34, z: 0.94, a: 0.28, l: 34, q: 0.38, p: 1.7 },
{ x: 0.65, z: -0.76, a: 0.16, l: 21, q: 0.30, p: 3.1 },
{ x: -0.86, z: -0.50, a: 0.09, l: 13, q: 0.22, p: 2.3 }
].map(w => ({ ...w, k: TAU / w.l, omega: Math.sqrt(9.81 * TAU / w.l) }));
function displaced(x, z, t, out) {
let ox = 0, oz = 0, y = 0;
for (const w of WAVES) {
const phase = w.k * (w.x * x + w.z * z) - w.omega * t + w.p;
const c = Math.cos(phase);
ox += w.q * w.a * w.x * c;
oz += w.q * w.a * w.z * c;
y += w.a * Math.sin(phase);
}
if (rogue.amp > 0.001) {
const q = x * ROGUE_DIR.x + z * ROGUE_DIR.y - rogue.front;
const envelope = Math.exp(-(q * q) / (2 * rogue.width * rogue.width));
const phase = q * 0.145;
y += rogue.amp * envelope * Math.cos(phase);
const push = rogue.amp * 0.16 * envelope * Math.sin(phase);
ox += ROGUE_DIR.x * push;
oz += ROGUE_DIR.y * push;
}
out.set(x + ox, y, z + oz);
return out;
}
function waterHeight(x, z, t = simTime) {
let bx = x, bz = z;
for (let i = 0; i < 3; i++) {
displaced(bx, bz, t, tmpV3);
bx = x - (tmpV3.x - bx);
bz = z - (tmpV3.z - bz);
}
displaced(bx, bz, t, tmpV3);
return tmpV3.y;
}
function waterNormal(x, z, out) {
const e = 0.65;
const h = waterHeight(x, z);
return out.set(h - waterHeight(x + e, z), e, h - waterHeight(x, z + e)).normalize();
}
function makeNormalTexture(size = 256) {
const data = new Uint8Array(size * size * 4);
const heights = new Float32Array(size * size);
const hash = (x, y, seed) => {
let h = Math.imul(x, 374761393) + Math.imul(y, 668265263) + seed * 1447;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
};
const noise = (x, y, px, py, seed) => {
const gx = x / size * px, gy = y / size * py;
const ix = Math.floor(gx), iy = Math.floor(gy);
const fx = gx - ix, fy = gy - iy;
const sx = fx * fx * (3 - 2 * fx), sy = fy * fy * (3 - 2 * fy);
const wrap = (n, p) => (n % p + p) % p;
const x0 = wrap(ix, px), x1 = wrap(ix + 1, px);
const y0 = wrap(iy, py), y1 = wrap(iy + 1, py);
const a = lerp(hash(x0, y0, seed), hash(x1, y0, seed), sx);
const b = lerp(hash(x0, y1, seed), hash(x1, y1, seed), sx);
return lerp(a, b, sy);
};
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
heights[y * size + x] =
noise(x, y, 4, 6, 1) * .34 + noise(x, y, 8, 12, 2) * .25 +
noise(x, y, 16, 24, 3) * .18 + noise(x, y, 32, 48, 4) * .12 +
noise(x, y, 64, 96, 5) * .07 + noise(x, y, 128, 192, 6) * .04;
}
const at = (x, y) => heights[((y % size + size) % size) * size + ((x % size + size) % size)];
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
const dx = (at(x - 1, y) - at(x + 1, y)) * 22;
const dy = (at(x, y - 1) - at(x, y + 1)) * 22;
const inv = 1 / Math.hypot(dx, dy, 1);
const i = (y * size + x) * 4;
data[i] = (dx * inv * .5 + .5) * 255;
data[i + 1] = (dy * inv * .5 + .5) * 255;
data[i + 2] = (inv * .5 + .5) * 255;
data[i + 3] = 255;
}
const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
texture.magFilter = THREE.LinearFilter;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.generateMipmaps = true;
texture.needsUpdate = true;
return texture;
}
const oceanGLSL = `
uniform vec4 rogueData;
varying vec3 waveNormal;
varying float foamAmount;
const vec2 RD = vec2(${ROGUE_DIR.x.toFixed(6)}, ${ROGUE_DIR.y.toFixed(6)});
vec3 oceanDisplace(vec2 p) {
vec3 d = vec3(p.x, 0.0, p.y);
${WAVES.map(w => `{
float ph = ${w.k.toFixed(7)} * dot(p, vec2(${w.x}, ${w.z})) - ${w.omega.toFixed(7)} * time + ${w.p.toFixed(1)};
d.y += ${w.a} * sin(ph);
d.xz += ${(w.q * w.a).toFixed(6)} * vec2(${w.x}, ${w.z}) * cos(ph);
}`).join('\n')}
float rq = dot(p, RD) - rogueData.x;
float env = exp(-(rq * rq) / (2.0 * rogueData.z * rogueData.z));
d.y += rogueData.y * env * cos(rq * 0.145);
d.xz += RD * rogueData.y * 0.16 * env * sin(rq * 0.145);
return d;
}
float oceanHeight(vec2 p) { return oceanDisplace(p).y; }
`;
function createOcean() {
const geometry = new THREE.PlaneGeometry(1200, 1200, 220, 220);
water = new Water(geometry, {
textureWidth: 512, textureHeight: 512,
waterNormals: makeNormalTexture(),
sunDirection: new THREE.Vector3(1, 1, 1),
sunColor: 0xffdfae, waterColor: 0x063f49,
distortionScale: 2.7, fog: true
});
water.rotation.x = -Math.PI / 2;
water.material.uniforms.size.value = 3.2;
water.material.uniforms.rogueData = { value: new THREE.Vector4(-500, 0, rogue.width, 1) };
water.material.vertexShader = `
uniform mat4 textureMatrix;
uniform float time;
varying vec4 mirrorCoord;
varying vec4 worldPosition;
#include <common>
#include <fog_pars_vertex>
#include <shadowmap_pars_vertex>
#include <logdepthbuf_pars_vertex>
${oceanGLSL}
void main() {
vec3 baseWorld = (modelMatrix * vec4(position, 1.0)).xyz;
vec3 displacedWorld = oceanDisplace(baseWorld.xz);
float e = 0.7;
float h = displacedWorld.y;
waveNormal = normalize(vec3(h - oceanHeight(baseWorld.xz + vec2(e, 0.0)), e, h - oceanHeight(baseWorld.xz + vec2(0.0, e))));
float rq = dot(baseWorld.xz, RD) - rogueData.x;
float env = exp(-(rq * rq) / (2.0 * rogueData.z * rogueData.z));
foamAmount = env * rogueData.y * 0.22 + max(0.0, h - 0.36) * 0.35 + pow(1.0 - waveNormal.y, 2.0) * 2.0;
worldPosition = vec4(displacedWorld, 1.0);
mirrorCoord = textureMatrix * worldPosition;
vec4 mvPosition = viewMatrix * worldPosition;
gl_Position = projectionMatrix * mvPosition;
#include <beginnormal_vertex>
#include <defaultnormal_vertex>
#include <logdepthbuf_vertex>
#include <fog_vertex>
#include <shadowmap_vertex>
}
`;
water.material.fragmentShader = water.material.fragmentShader
.replace('varying vec4 worldPosition;', `varying vec4 worldPosition; varying vec3 waveNormal; varying float foamAmount;`)
.replace('vec3 surfaceNormal = normalize( noise.xzy * vec3( 1.5, 1.0, 1.5 ) );', `
vec3 detailNormal = noise.xzy * vec3(1.15, 0.85, 1.15);
vec3 surfaceNormal = normalize(waveNormal + detailNormal * 0.58);
`)
.replace('float rf0 = 0.3;', 'float rf0 = 0.18;')
.replace('vec3 scatter = max( 0.0, dot( surfaceNormal, eyeDirection ) ) * waterColor;', 'vec3 scatter = (0.22 + max(0.0, dot(surfaceNormal, eyeDirection)) * 0.78) * waterColor * 1.65;')
.replace('vec3 outgoingLight = albedo;', `
float foamNoise = texture2D(normalSampler, worldPosition.xz * 0.025 + vec2(time * 0.008, 0.0)).r;
float foam = smoothstep(0.36, 0.92, foamAmount + foamNoise * 0.34) * 0.82;
float sss = pow(max(0.0, dot(eyeDirection, -sunDirection)), 3.0) * max(0.0, worldPosition.y) * 0.065;
vec3 outgoingLight = mix(albedo + waterColor * sss, vec3(0.72, 0.84, 0.83), foam);
`);
water.receiveShadow = true;
scene.add(water);
}
function init() {
scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x789aa4, 0.00195);
camera = new THREE.PerspectiveCamera(61, innerWidth / innerHeight, 0.15, 1800);
camera.position.set(14, 8, -17);
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.6));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.84;
renderer.outputColorSpace = THREE.SRGBColorSpace;
$('stage').appendChild(renderer.domElement);
sky = new Sky();
sky.scale.setScalar(1500);
const su = sky.material.uniforms;
su.turbidity.value = 3.4;
su.rayleigh.value = 2.7;
su.mieCoefficient.value = 0.004;
su.mieDirectionalG.value = 0.82;
const phi = THREE.MathUtils.degToRad(90 - 16);
const theta = THREE.MathUtils.degToRad(70);
const sun = SUN_DIRECTION.setFromSphericalCoords(1, phi, theta);
su.sunPosition.value.copy(sun);
scene.add(sky);
const hemi = new THREE.HemisphereLight(0xc9e2e4, 0x18313a, 1.65);
scene.add(hemi);
sunLight = new THREE.DirectionalLight(0xffd6a3, 3.0);
sunLight.position.copy(sun).multiplyScalar(230);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
Object.assign(sunLight.shadow.camera, { left: -75, right: 75, top: 75, bottom: -75, near: 1, far: 550 });
sunLight.shadow.bias = -0.00025;
sunLight.shadow.normalBias = 0.035;
scene.add(sunLight, sunLight.target);
createOcean();
water.material.uniforms.sunDirection.value.copy(sun).normalize();
createClouds();
particles.init();
player = createBoat(false);
player.group.position.set(0, 0.4, 0);
scene.add(player.group);
audio = new Soundscape();
bindEvents();
onResize();
renderer.compile(scene, camera);
renderer.render(scene, camera);
$('loading').style.opacity = '0';
setTimeout(() => $('loading').classList.add('hidden'), 550);
window.__breakwater = {
get state() {
return {
mode, score, kills, wave, enemies: enemies.length, health: player.health,
bullets: bullets.length, rogue: rogue.active, time: simTime, shots, hits,
player: { x: player.pos.x, y: player.pos.y, z: player.pos.z, speed: player.speed, pitch: player.pitch, roll: player.roll },
targets: enemies.map(e => {
const v = e.pos.clone(); v.y += .7; v.project(camera);
return { x: (v.x * .5 + .5) * innerWidth, y: (-v.y * .5 + .5) * innerHeight, visible: v.z < 1 && Math.abs(v.x) < 1 && Math.abs(v.y) < 1 };
})
};
},
start: startGame
};
requestAnimationFrame(animate);
}
function createClouds() {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 256;
const ctx = canvas.getContext('2d');
for (let i = 0; i < 45; i++) {
const x = 40 + Math.random() * 176, y = 80 + Math.random() * 95;
const r = 18 + Math.random() * 40;
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, 'rgba(255,250,238,.18)'); g.addColorStop(1, 'rgba(255,250,238,0)');
ctx.fillStyle = g; ctx.fillRect(x-r, y-r, r*2, r*2);
}
const texture = new THREE.CanvasTexture(canvas);
for (let i = 0; i < 14; i++) {
const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: .55, depthWrite: false, fog: true, color: 0xffe9d3 });
const cloud = new THREE.Sprite(mat);
const a = i / 14 * TAU;
cloud.position.set(Math.sin(a) * (280 + Math.random()*260), 70 + Math.random()*85, Math.cos(a) * (280 + Math.random()*260));
cloud.scale.set(150 + Math.random()*130, 60 + Math.random()*45, 1);
scene.add(cloud);
}
}
const MAT = {
playerHull: new THREE.MeshStandardMaterial({ color: 0x173d4b, roughness: .36, metalness: .48 }),
enemyHull: new THREE.MeshStandardMaterial({ color: 0x3b2526, roughness: .42, metalness: .45 }),
deck: new THREE.MeshStandardMaterial({ color: 0xc6c4ae, roughness: .73, metalness: .12 }),
enemyDeck: new THREE.MeshStandardMaterial({ color: 0x646763, roughness: .7, metalness: .22 }),
cabin: new THREE.MeshStandardMaterial({ color: 0xe0dcc4, roughness: .48, metalness: .25 }),
enemyCabin: new THREE.MeshStandardMaterial({ color: 0x777970, roughness: .55, metalness: .3 }),
dark: new THREE.MeshStandardMaterial({ color: 0x101d22, roughness: .32, metalness: .65 }),
glass: new THREE.MeshStandardMaterial({ color: 0x092a37, roughness: .12, metalness: .75 }),
metal: new THREE.MeshStandardMaterial({ color: 0x87928e, roughness: .28, metalness: .9 }),
orange: new THREE.MeshStandardMaterial({ color: 0xe86f35, roughness: .45, metalness: .15 }),
redLight: new THREE.MeshBasicMaterial({ color: 0xff352b }),
greenLight: new THREE.MeshBasicMaterial({ color: 0x63ffd0 })
};
function mesh(geometry, material, parent, x=0, y=0, z=0) {
const m = new THREE.Mesh(geometry, material);
m.position.set(x, y, z);
m.castShadow = true; m.receiveShadow = true;
parent.add(m);
return m;
}
function cylinderBetween(a, b, radius, material, parent) {
const delta = new THREE.Vector3().subVectors(b, a);
const m = mesh(new THREE.CylinderGeometry(radius, radius, delta.length(), 6), material, parent);
m.position.copy(a).add(b).multiplyScalar(.5);
m.quaternion.setFromUnitVectors(Y_AXIS, delta.normalize());
return m;
}
function createHullGeometry() {
const stations = [
{ z: -3.25, w: 1.02 }, { z: -2.25, w: 1.30 }, { z: 0, w: 1.43 },
{ z: 1.85, w: 1.15 }, { z: 3.25, w: 0.06 }
];
const vertices = [], indices = [];
for (const s of stations) {
const ring = [[-s.w,.42],[-s.w*.98,-.06],[-s.w*.65,-.57],[0,-.78],[s.w*.65,-.57],[s.w*.98,-.06],[s.w,.42]];
for (const [x,y] of ring) vertices.push(x,y,s.z);
}
for (let j=0; j<stations.length-1; j++) for (let i=0; i<6; i++) {
const a=j*7+i,b=a+1,c=a+7,d=c+1;
indices.push(a,c,b,b,c,d);
}
for (const end of [0, stations.length-1]) {
const base=end*7;
for(let i=1;i<6;i++) indices.push(base,base+i,base+i+1);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(vertices,3));
g.setIndex(indices); g.computeVertexNormals();
return { hull: g, stations };
}
const HULL = createHullGeometry();
function createDeck(stations, material, parent) {
const vertices = [], indices = [];
for (const s of stations) vertices.push(-s.w,.43,s.z, s.w,.43,s.z);
for (let i=0; i<stations.length-1; i++) {
const a=i*2; indices.push(a,a+2,a+1,a+1,a+2,a+3);
}
const g = new THREE.BufferGeometry();
g.setAttribute('position',new THREE.Float32BufferAttribute(vertices,3)); g.setIndex(indices); g.computeVertexNormals();
return mesh(g,material,parent);
}
function createBoat(enemy) {
const group = new THREE.Group();
group.rotation.order = 'YXZ';
const hullMat = enemy ? MAT.enemyHull : MAT.playerHull;
mesh(HULL.hull, hullMat, group);
createDeck(HULL.stations, enemy ? MAT.enemyDeck : MAT.deck, group);
// Raised cabin, armored glass, roof, working rails and navigation equipment.
mesh(new THREE.BoxGeometry(1.85,.82,2.05), enemy ? MAT.enemyCabin : MAT.cabin, group, 0,.87,-.68);
const windshield = mesh(new THREE.BoxGeometry(1.65,.43,.055), MAT.glass, group, 0,1.02,.37);
windshield.rotation.x = -.15;
mesh(new THREE.BoxGeometry(.055,.4,1.25),MAT.glass,group,-.94,1.02,-.73);
mesh(new THREE.BoxGeometry(.055,.4,1.25),MAT.glass,group,.94,1.02,-.73);
mesh(new THREE.BoxGeometry(2.06,.12,2.25),enemy ? MAT.enemyDeck : MAT.cabin,group,0,1.35,-.68);
mesh(new THREE.BoxGeometry(.7,.48,.7),MAT.dark,group,0,1.64,-.95);
cylinderBetween(new THREE.Vector3(0,1.86,-.95),new THREE.Vector3(0,3.05,-.95),.025,MAT.metal,group);
cylinderBetween(new THREE.Vector3(-.35,2.64,-.95),new THREE.Vector3(.35,2.64,-.95),.018,MAT.metal,group);
const radar = mesh(new THREE.BoxGeometry(1.05,.09,.13),MAT.cabin,group,0,2.25,-.95);
radar.rotation.y = .25;
for (const side of [-1,1]) {
const railX = side * 1.22;
for (const z of [-2.25,-1.3,.1,1.25]) cylinderBetween(new THREE.Vector3(railX,.45,z),new THREE.Vector3(railX,.9,z),.018,MAT.metal,group);
cylinderBetween(new THREE.Vector3(railX,.9,-2.25),new THREE.Vector3(railX,.9,1.25),.022,MAT.metal,group);
cylinderBetween(new THREE.Vector3(railX,.9,1.25),new THREE.Vector3(side*.45,.9,2.75),.022,MAT.metal,group);
const nav = mesh(new THREE.SphereGeometry(.065,8,6),side < 0 ? MAT.redLight : MAT.greenLight,group,side*1.03,1.48,-.15);
nav.castShadow = false;
}
const ring = mesh(new THREE.TorusGeometry(.27,.075,8,20),MAT.orange,group,-1.01,.9,-1.5);
ring.rotation.y = Math.PI/2;
mesh(new THREE.BoxGeometry(1.7,.1,.08),MAT.orange,group,0,.1,-3.27);
const turret = new THREE.Group();
turret.position.set(0,.49,1.35); group.add(turret);
mesh(new THREE.CylinderGeometry(.48,.57,.24,16),MAT.dark,turret,0,.12,0);
const pivot = new THREE.Group(); pivot.position.y=.4; turret.add(pivot);
mesh(new THREE.BoxGeometry(.66,.42,.72),enemy ? MAT.enemyCabin : MAT.cabin,pivot,0,0,0);
const barrel = mesh(new THREE.CylinderGeometry(.055,.085,1.75,10),MAT.dark,pivot,0,.03,1.05);
barrel.rotation.x = Math.PI/2;
mesh(new THREE.CylinderGeometry(.095,.095,.24,10),MAT.metal,pivot,0,.03,1.78).rotation.x = Math.PI/2;
const muzzle = new THREE.Object3D(); muzzle.position.set(0,.03,1.96); pivot.add(muzzle);
const flash = new THREE.PointLight(0xff9b55,0,14,2); muzzle.add(flash);
const boat = {
group, turret, pivot, muzzle, flash, enemy,
pos: new THREE.Vector3(), yaw: enemy ? Math.PI : 0, pitch: 0, roll: 0,
pitchVel: 0, rollVel: 0, verticalVel: 0, speed: 0, throttle: 0, steer: 0,
health: enemy ? 60 : 100, maxHealth: enemy ? 60 : 100,
cooldown: 0, flashTime: 0, collisionCooldown: 0, lastRogue: -1,
orbit: Math.random()<.5 ? -1 : 1, dead: false, label: null, wakeClock: 0
};
group.userData.boat = boat;
group.traverse(o => { o.userData.boat = boat; });
return boat;
}
const particles = {
cap: 1300, list: [], points: null, positions: null, colors: null, sizes: null, alphas: null,
init() {
this.positions = new Float32Array(this.cap*3); this.colors = new Float32Array(this.cap*3);
this.sizes = new Float32Array(this.cap); this.alphas = new Float32Array(this.cap);
const g = new THREE.BufferGeometry();
g.setAttribute('position',new THREE.BufferAttribute(this.positions,3));
g.setAttribute('color',new THREE.BufferAttribute(this.colors,3));
g.setAttribute('aSize',new THREE.BufferAttribute(this.sizes,1));
g.setAttribute('aAlpha',new THREE.BufferAttribute(this.alphas,1));
const m = new THREE.ShaderMaterial({
transparent:true, depthWrite:false, vertexColors:true,
vertexShader:`attribute float aSize; attribute float aAlpha; varying vec3 vColor; varying float vAlpha; void main(){vColor=color;vAlpha=aAlpha;vec4 mv=modelViewMatrix*vec4(position,1.0);gl_PointSize=clamp(aSize*(250.0/max(1.0,-mv.z)),1.0,70.0);gl_Position=projectionMatrix*mv;}`,
fragmentShader:`varying vec3 vColor;varying float vAlpha;void main(){float d=length(gl_PointCoord-.5)*2.0;if(d>1.0)discard;float a=smoothstep(1.0,.2,d)*vAlpha;gl_FragColor=vec4(vColor,a);}`
});
this.points = new THREE.Points(g,m); this.points.frustumCulled=false; scene.add(this.points);
},
spawn(pos, vel, color, size, life, gravity=0) {
if(this.list.length>=this.cap) this.list.shift();
this.list.push({p:pos.clone(),v:vel.clone(),c:new THREE.Color(color),size,life,max:life,gravity});
},
update(dt) {
let n=0;
for(let i=this.list.length-1;i>=0;i--) {
const p=this.list[i]; p.life-=dt;
if(p.life<=0){this.list.splice(i,1);continue;}
p.v.y-=p.gravity*dt; p.p.addScaledVector(p.v,dt);
const k=n*3, fade=clamp(p.life/p.max,0,1);
this.positions[k]=p.p.x;this.positions[k+1]=p.p.y;this.positions[k+2]=p.p.z;
this.colors[k]=p.c.r;this.colors[k+1]=p.c.g;this.colors[k+2]=p.c.b;
this.sizes[n]=p.size*(1.2-fade*.2);this.alphas[n]=Math.min(1,fade*2)*.85;n++;
}
this.points.geometry.setDrawRange(0,n);
for(const name of ['position','color','aSize','aAlpha']) this.points.geometry.attributes[name].needsUpdate=true;
},
clear(){this.list.length=0;this.points.geometry.setDrawRange(0,0);}
};
function sprayAt(pos, count=18, power=1) {
for(let i=0;i<count;i++) {
const v=new THREE.Vector3((Math.random()-.5)*4*power,(1.5+Math.random()*4)*power,(Math.random()-.5)*4*power);
particles.spawn(pos,v,Math.random()<.3?0xb4d7d8:0xe4efea,.25+Math.random()*.55,.55+Math.random()*.65,8.5);
}
}
function explosionAt(pos, big=false) {
const count=big?55:28;
for(let i=0;i<count;i++) {
const v=new THREE.Vector3((Math.random()-.5)*(big?11:7),Math.random()*(big?9:5),(Math.random()-.5)*(big?11:7));
const color=Math.random()<.55?0xffa044:(Math.random()<.5?0xffe1a0:0x343b3b);
particles.spawn(pos,v,color,.45+Math.random()*(big?1.3:.65),.7+Math.random()*1.15,5.5);
}
sprayAt(pos, big?28:12, big?1.6:1);
}
class Soundscape {
constructor(){this.ctx=null;this.engine=null;this.engineGain=null;this.master=null;}
init(){
if(this.ctx){this.ctx.resume();return;}
const AC=window.AudioContext||window.webkitAudioContext;if(!AC)return;
this.ctx=new AC();this.master=this.ctx.createGain();this.master.gain.value=muted?0:.42;this.master.connect(this.ctx.destination);
this.engine=this.ctx.createOscillator();this.engine.type='sawtooth';this.engine.frequency.value=42;
const filter=this.ctx.createBiquadFilter();filter.type='lowpass';filter.frequency.value=125;
this.engineGain=this.ctx.createGain();this.engineGain.gain.value=.035;
this.engine.connect(filter);filter.connect(this.engineGain);this.engineGain.connect(this.master);this.engine.start();
const length=this.ctx.sampleRate*2,buffer=this.ctx.createBuffer(1,length,this.ctx.sampleRate),data=buffer.getChannelData(0);
let last=0;for(let i=0;i<length;i++){last=last*.96+(Math.random()*2-1)*.04;data[i]=last;}
const sea=this.ctx.createBufferSource();sea.buffer=buffer;sea.loop=true;
const seaFilter=this.ctx.createBiquadFilter();seaFilter.type='lowpass';seaFilter.frequency.value=500;
const seaGain=this.ctx.createGain();seaGain.gain.value=.22;sea.connect(seaFilter);seaFilter.connect(seaGain);seaGain.connect(this.master);sea.start();
}
update(){if(!this.ctx)return;const t=this.ctx.currentTime;this.engine.frequency.setTargetAtTime(42+Math.abs(player.speed)*3.2,t,.08);this.engineGain.gain.setTargetAtTime(mode==='playing'?.035+Math.abs(player.throttle)*.045:.018,t,.1);}
burst(freq,duration,volume,type='sine'){
if(!this.ctx||muted)return;const t=this.ctx.currentTime,o=this.ctx.createOscillator(),g=this.ctx.createGain();o.type=type;o.frequency.setValueAtTime(freq,t);o.frequency.exponentialRampToValueAtTime(Math.max(20,freq*.25),t+duration);g.gain.setValueAtTime(volume,t);g.gain.exponentialRampToValueAtTime(.001,t+duration);o.connect(g);g.connect(this.master);o.start(t);o.stop(t+duration);
}
cannon(enemy=false){this.burst(enemy?105:135,.22,enemy?.16:.28,'square');}
hit(){this.burst(70,.35,.23,'sawtooth');}
explosion(){this.burst(48,.8,.4,'sawtooth');}
toggle(){muted=!muted;localStorage.setItem('breakwater-muted',muted?'1':'0');if(this.master)this.master.gain.setTargetAtTime(muted?0:.42,this.ctx.currentTime,.05);toast(muted?'AUDIO MUTED':'AUDIO ENABLED');}
}
function resetBoat(b, x, z, yaw) {
b.pos.set(x,.45,z);b.yaw=yaw;b.pitch=b.roll=b.pitchVel=b.rollVel=b.verticalVel=0;
b.speed=b.throttle=b.steer=0;b.health=b.maxHealth;b.cooldown=1;b.dead=false;b.lastRogue=-1;
b.group.visible=true;b.group.scale.setScalar(1);b.group.position.copy(b.pos);b.group.rotation.set(0,yaw,0);
}
function startGame() {
audio.init();
clearWorld();
mode='playing';score=0;kills=0;wave=0;shots=0;hits=0;waveDelay=-1;
simTime=0;rogue.active=false;rogue.amp=0;rogue.timer=12;rogue.front=-500;
player.maxHealth=100;resetBoat(player,0,0,0);player.cooldown=0;
$('menu').classList.add('hidden');$('gameover').classList.add('hidden');$('pause').classList.add('hidden');
$('hud').classList.add('active');$('crosshair').classList.add('active');
startNextWave();updateHUD();toast('WASD TO MANEUVER · MOUSE TO AIM');
}
function disposeBoat(b) {
b.group.traverse(o => { if (o.geometry && o.geometry !== HULL.hull) o.geometry.dispose(); });
}
function clearWorld() {
for(const e of enemies){scene.remove(e.group);e.label?.remove();disposeBoat(e);}enemies.length=0;
for(const b of bullets)scene.remove(b.mesh);bullets.length=0;
for(const w of wrecks){scene.remove(w.group);disposeBoat(w.boat);}wrecks.length=0;
particles.clear();firing=false;hoverTarget=null;
}
function startNextWave() {
wave++;waveDelay=-1;
const count=Math.min(3+Math.floor((wave-1)*.8),7);
for(let i=0;i<count;i++) {
const angle=(i/count)*TAU+Math.random()*.45;
const distance=95+Math.random()*45;
const e=createBoat(true);
e.maxHealth=e.health=48+wave*9;
const x=player.pos.x+Math.sin(angle)*distance,z=player.pos.z+Math.cos(angle)*distance;
resetBoat(e,x,z,Math.atan2(player.pos.x-x,player.pos.z-z));
e.cooldown=2.5+Math.random()*2.5;e.speed=3;
scene.add(e.group);enemies.push(e);
const label=document.createElement('div');label.className='enemy-label';label.innerHTML=`<span>HOSTILE · GUNBOAT</span><div class="enemy-bar"><i></i></div><small></small>`;
$('labels').appendChild(label);e.label=label;
}
announce(`WAVE ${String(wave).padStart(2,'0')}`,count===1?'1 HOSTILE INBOUND':`${count} HOSTILES INBOUND`);
}
function announce(small,big) {
const el=$('announcement');el.querySelector('small').textContent=small;el.querySelector('strong').textContent=big;el.classList.add('show');announcementTimer=3.2;
}
function toast(text) {$('toast').textContent=text;$('toast').classList.add('show');toastTimer=2.6;}
function angleDelta(a,b){return Math.atan2(Math.sin(a-b),Math.cos(a-b));}
function forward(b,out=new THREE.Vector3()){return out.set(Math.sin(b.yaw),0,Math.cos(b.yaw));}
function right(b,out=new THREE.Vector3()){return out.set(Math.cos(b.yaw),0,-Math.sin(b.yaw));}
function updatePlayer(dt) {
player.throttle=(keys.KeyW||keys.ArrowUp)?1:(keys.KeyS||keys.ArrowDown)?-.48:0;
player.steer=(keys.KeyD||keys.ArrowRight)?1:(keys.KeyA||keys.ArrowLeft)?-1:0;
updateBoatPhysics(player,dt);
if(player.pos.length()>390){
const inward=Math.atan2(-player.pos.x,-player.pos.z);
player.yaw+=angleDelta(inward,player.yaw)*dt*.22;
if(player.pos.length()>440){player.pos.setLength(440);damageBoat(player,4*dt,false);}
}
}
function updateEnemy(e,dt) {
const dx=player.pos.x-e.pos.x,dz=player.pos.z-e.pos.z,dist=Math.hypot(dx,dz);
const lead=forward(player,tmpV).multiplyScalar(player.speed*.65);
const tx=player.pos.x+lead.x,tz=player.pos.z+lead.z;
let desired=Math.atan2(tx-e.pos.x,tz-e.pos.z);
if(dist<65){
const flank=Math.atan2(dx,dz)+e.orbit*(dist<23?1.45:.82);
desired=flank;
}
// Local separation keeps the attack formation legible.
for(const other of enemies){if(other===e)continue;const ox=e.pos.x-other.pos.x,oz=e.pos.z-other.pos.z,d2=ox*ox+oz*oz;if(d2<100&&d2>.1)desired+=angleDelta(Math.atan2(ox,oz),desired)*(100-d2)*.0008;}
const diff=angleDelta(desired,e.yaw);
e.steer=clamp(diff*1.6,-1,1);
e.throttle=dist>85?1:dist<16?-.3:.62;
updateBoatPhysics(e,dt);
aimTurret(e,new THREE.Vector3(player.pos.x,player.pos.y+.65,player.pos.z),dt);
if(mode==='playing'&&dist<120&&e.cooldown<=0){
const travel=dist/58;
const target=player.pos.clone().addScaledVector(forward(player,tmpV),player.speed*travel);
const inaccuracy=1.1+dist*.022;
target.x+=(Math.random()-.5)*inaccuracy;target.z+=(Math.random()-.5)*inaccuracy;target.y+=.45;
fire(e,target,58,8+wave*.35);e.cooldown=Math.max(.8,2.15-wave*.055)+Math.random()*1.1;
}
}
function updateBoatPhysics(b,dt) {
const maxSpeed=b.enemy?17+wave*.25:22;
const accel=b.enemy?5.2:6.8;
const drag=.013*b.speed*Math.abs(b.speed)+.12*b.speed;
b.speed+=(b.throttle*accel-drag)*dt;
b.speed=clamp(b.speed,-maxSpeed*.32,maxSpeed);
const steerAuthority=.22+Math.min(1,Math.abs(b.speed)/7)*.78;
b.yaw+=b.steer*dt*(b.enemy?.7:.82)*steerAuthority*(b.speed<-.2?-1:1);
const f=forward(b,tmpV);
b.pos.addScaledVector(f,b.speed*dt);
// A small hydrodynamic lateral drift follows steep rogue-wave faces.
if(rogue.amp>0){
const q=b.pos.x*ROGUE_DIR.x+b.pos.z*ROGUE_DIR.y-rogue.front;
const env=Math.exp(-(q*q)/(2*rogue.width*rogue.width));
b.pos.x+=ROGUE_DIR.x*env*rogue.amp*.19*dt;b.pos.z+=ROGUE_DIR.y*env*rogue.amp*.19*dt;
}
const yaw=b.yaw,c=Math.cos(yaw),s=Math.sin(yaw);
const samples=[[-1.05,-.55,-1.85],[1.05,-.55,-1.85],[-.82,-.55,1.75],[.82,-.55,1.75]];
const subs=[];
for(const [lx,ly,lz] of samples){
const wx=b.pos.x+lx*c+lz*s,wz=b.pos.z-lx*s+lz*c;
const py=b.pos.y+ly-lz*Math.sin(b.pitch)+lx*Math.sin(b.roll);
subs.push(clamp(waterHeight(wx,wz)-py,0,1.8));
}
const avg=(subs[0]+subs[1]+subs[2]+subs[3])*.25;
const verticalAcc=avg*55-9.81-b.verticalVel*3.25;
b.verticalVel+=verticalAcc*dt;b.pos.y+=b.verticalVel*dt;
const frontSub=(subs[2]+subs[3])*.5,backSub=(subs[0]+subs[1])*.5;
const rightSub=(subs[1]+subs[3])*.5,leftSub=(subs[0]+subs[2])*.5;
b.pitchVel+=((backSub-frontSub)*13-b.pitch*1.8-b.pitchVel*3.25)*dt;
b.rollVel+=((rightSub-leftSub)*11-b.roll*1.55-b.rollVel*3.0-b.steer*b.speed*.012)*dt;
b.pitch=clamp(b.pitch+b.pitchVel*dt,-.48,.48);
b.roll=clamp(b.roll+b.rollVel*dt,-.58,.58);
if(b.pos.y<-3){b.pos.y=-3;b.verticalVel=Math.max(0,b.verticalVel);}
b.cooldown=Math.max(0,b.cooldown-dt);b.collisionCooldown=Math.max(0,b.collisionCooldown-dt);
b.flashTime=Math.max(0,b.flashTime-dt);b.flash.intensity=b.flashTime>0?22:0;
b.group.position.copy(b.pos);b.group.rotation.set(b.pitch,b.yaw,b.roll);
b.wakeClock-=dt;
if(Math.abs(b.speed)>2&&b.wakeClock<=0){
b.wakeClock=.055;
const back=f.clone().multiplyScalar(-3.0).add(b.pos);back.y=waterHeight(back.x,back.z)+.1;
for(const side of [-1,1]){
const r=right(b,new THREE.Vector3());const p=back.clone().addScaledVector(r,side*.8);
const v=r.multiplyScalar(side*(.4+Math.random()*.6)).addScaledVector(f,-.8);v.y=.3+Math.random()*.4;
particles.spawn(p,v,0xd9e8e3,.45+Math.random()*.65,.65+Math.random()*.55,1.1);
}
}
checkRogueImpact(b);
}
function checkRogueImpact(b){
if(!rogue.active||b.lastRogue===rogue.id)return;
const q=b.pos.x*ROGUE_DIR.x+b.pos.z*ROGUE_DIR.y-rogue.front;
if(Math.abs(q)<2.4){
b.lastRogue=rogue.id;
const f=forward(b,tmpV);const alignment=Math.abs(f.x*ROGUE_DIR.x+f.z*ROGUE_DIR.y);
const broadside=1-alignment;
b.verticalVel+=2.2+rogue.amp*.22;b.rollVel+=(Math.random()<.5?-1:1)*(1+broadside*1.8);b.speed*=.8;
sprayAt(b.pos.clone().add(new THREE.Vector3(0,1,0)),45,1.6);
damageBoat(b,4+broadside*14,false);
if(!b.enemy){cameraShake=1.2;toast(alignment>.65?'BOW THROUGH · IMPACT ABSORBED':'BROADSIDE IMPACT · HULL DAMAGED');audio.hit();}
}
}
function aimTurret(b,target,dt){
const dx=target.x-b.pos.x,dz=target.z-b.pos.z;
const worldYaw=Math.atan2(dx,dz),local=angleDelta(worldYaw,b.yaw);
b.turret.rotation.y+=angleDelta(local,b.turret.rotation.y)*(1-Math.exp(-dt*9));
const distance=Math.hypot(dx,dz);
const elevation=clamp(Math.atan2(target.y-(b.pos.y+1.2),distance)+9.81*distance/(2*80*80),.0,.32);
b.pivot.rotation.x=lerp(b.pivot.rotation.x,-elevation,1-Math.exp(-dt*7));
}
function ballisticDirection(from,to,speed){
const dx=to.x-from.x,dz=to.z-from.z,d=Math.max(1,Math.hypot(dx,dz)),dy=to.y-from.y;
const g=9.81,v2=speed*speed,disc=v2*v2-g*(g*d*d+2*dy*v2);
const angle=disc>0?Math.atan((v2-Math.sqrt(disc))/(g*d)):Math.PI/4;
const horizontal=Math.cos(angle);
return new THREE.Vector3(dx/d*horizontal,Math.sin(angle),dz/d*horizontal);
}
const bulletGeometry=new THREE.SphereGeometry(.105,6,5);
const playerBulletMat=new THREE.MeshBasicMaterial({color:0xffd395});
const enemyBulletMat=new THREE.MeshBasicMaterial({color:0xff6547});
function fire(b,target,speed=82,damage=15){
if(b.cooldown>0||b.dead)return;
b.group.updateMatrixWorld(true);
const from=new THREE.Vector3();b.muzzle.getWorldPosition(from);
const direction=ballisticDirection(from,target,speed);
const m=new THREE.Mesh(bulletGeometry,b.enemy?enemyBulletMat:playerBulletMat);m.position.copy(from);scene.add(m);
bullets.push({mesh:m,pos:from,prev:from.clone(),vel:direction.multiplyScalar(speed),life:4.2,owner:b,damage});
b.cooldown=b.enemy?1.5:.24;b.flashTime=.065;
for(let i=0;i<5;i++)particles.spawn(from,direction.clone().multiplyScalar(2+Math.random()*3).add(new THREE.Vector3((Math.random()-.5)*2,(Math.random()-.5)*2,(Math.random()-.5)*2)),0xffc271,.25+Math.random()*.35,.12+Math.random()*.16,0);
if(!b.enemy){shots++;audio.cannon(false);cameraShake=Math.max(cameraShake,.16);}else if(b.pos.distanceTo(player.pos)<70)audio.cannon(true);
}
function segmentDistance(point,a,b){
tmpV.subVectors(b,a);const len2=tmpV.lengthSq();
const t=len2?clamp(tmpV2.subVectors(point,a).dot(tmpV)/len2,0,1):0;
tmpV2.copy(a).addScaledVector(tmpV,t);return tmpV2.distanceTo(point);
}
function updateBullets(dt){
for(let i=bullets.length-1;i>=0;i--){
const b=bullets[i];b.life-=dt;b.prev.copy(b.pos);b.vel.y-=9.81*dt;b.pos.addScaledVector(b.vel,dt);b.mesh.position.copy(b.pos);
let hit=null;
if(!b.owner.enemy){
for(const e of enemies){tmpV3.copy(e.pos);tmpV3.y+=.55;if(segmentDistance(tmpV3,b.prev,b.pos)<2.05){hit=e;break;}}
}else if(!player.dead){tmpV3.copy(player.pos);tmpV3.y+=.55;if(segmentDistance(tmpV3,b.prev,b.pos)<2.05)hit=player;}
if(hit){
if(!b.owner.enemy)hits++;
explosionAt(b.pos,false);damageBoat(hit,b.damage,true);audio.hit();
scene.remove(b.mesh);bullets.splice(i,1);continue;
}
if(b.pos.y<waterHeight(b.pos.x,b.pos.z)||b.life<=0){
if(b.life>0)sprayAt(b.pos,12,.7);
scene.remove(b.mesh);bullets.splice(i,1);
}
}
}
function damageBoat(b,amount,combat=true){
if(b.dead||mode!=='playing')return;
b.health=Math.max(0,b.health-amount);
if(!b.enemy){damageFlash=Math.min(1,damageFlash+.55);cameraShake=Math.max(cameraShake,.45);if(b.health<=0)endGame();}
else if(b.health<=0)destroyEnemy(b,combat);
}
function destroyEnemy(e,combat){
if(e.dead)return;e.dead=true;
const index=enemies.indexOf(e);if(index>=0)enemies.splice(index,1);
e.label?.remove();e.label=null;explosionAt(e.pos.clone().add(new THREE.Vector3(0,1,0)),true);audio.explosion();
wrecks.push({group:e.group,boat:e,life:4,roll:(Math.random()-.5)*.8});
if(combat){kills++;score+=100*wave;player.health=Math.min(player.maxHealth,player.health+5);toast(`HOSTILE DESTROYED +${100*wave} · HULL +5`);}
if(enemies.length===0&&mode==='playing'){waveDelay=4;score+=250*wave;announce('SECTOR SECURE',`WAVE ${String(wave).padStart(2,'0')} CLEARED`);}
}
function endGame(){
player.dead=true;mode='gameover';firing=false;explosionAt(player.pos.clone().add(new THREE.Vector3(0,1,0)),true);audio.explosion();
bestScore=Math.max(bestScore,score);localStorage.setItem('breakwater-best',String(bestScore));$('best-score').textContent=String(bestScore).padStart(6,'0');
$('final-score').textContent=score;$('final-kills').textContent=kills;$('final-wave').textContent=wave;
$('gameover').classList.remove('hidden');$('crosshair').classList.remove('active');$('wave-warning').classList.remove('show');
}
function updateCollisions(){
for(const e of enemies){
const d=e.pos.distanceTo(player.pos);
if(d<3.35&&e.collisionCooldown<=0&&player.collisionCooldown<=0){
tmpV.subVectors(player.pos,e.pos).setY(0).normalize();player.pos.addScaledVector(tmpV,.65);e.pos.addScaledVector(tmpV,-.65);
e.speed*=-.35;player.speed*=-.35;e.collisionCooldown=player.collisionCooldown=1;
damageBoat(e,12,true);damageBoat(player,10,true);explosionAt(player.pos.clone().add(new THREE.Vector3(0,.5,0)),false);
}
}
}
function updateRogue(dt){
if(!rogue.active){
rogue.timer-=dt;
if(rogue.timer<=0){
rogue.active=true;rogue.id++;rogue.amp=3.8+Math.min(2,wave*.18);rogue.width=8.5;rogue.speed=18;
rogue.front=player.pos.x*ROGUE_DIR.x+player.pos.z*ROGUE_DIR.y-105;
rogue.timer=27+Math.random()*12;
}
}else{
rogue.front+=rogue.speed*dt;
const projection=player.pos.x*ROGUE_DIR.x+player.pos.z*ROGUE_DIR.y;
const eta=(projection-rogue.front)/rogue.speed;
if(mode==='playing'&&eta>-2&&eta<7){
$('wave-warning').classList.add('show');
document.querySelector('#wave-warning span').textContent=eta>1?`ROGUE WAVE · IMPACT IN ${Math.ceil(eta)}S · TURN BOW FIRST`:'ROGUE WAVE · BRACE FOR IMPACT';
}else $('wave-warning').classList.remove('show');
if(eta<-28){rogue.active=false;rogue.amp=0;$('wave-warning').classList.remove('show');}
}
water.material.uniforms.rogueData.value.set(rogue.front,rogue.amp,rogue.width,1);
}
function updateWrecks(dt){
for(let i=wrecks.length-1;i>=0;i--){const w=wrecks[i];w.life-=dt;w.group.position.y-=dt*.65;w.group.rotation.z+=w.roll*dt;w.group.rotation.x+=dt*.08;if(w.life<=0){scene.remove(w.group);disposeBoat(w.boat);wrecks.splice(i,1);}}
}
function updateAim(dt){
raycaster.setFromCamera(mouse,camera);hoverTarget=null;
const objects=[];for(const e of enemies)objects.push(e.group);
const hitsList=raycaster.intersectObjects(objects,true);
if(hitsList.length){
const target=hitsList[0].object.userData.boat;
if(target&&!target.dead){
hoverTarget=target;
const distance=target.pos.distanceTo(player.pos),travel=distance/82;
aimPoint.copy(target.pos).addScaledVector(forward(target,tmpV),target.speed*travel);aimPoint.y+=.55;
}
}
if(!hoverTarget){
if(!raycaster.ray.intersectPlane(aimPlane,aimPoint))raycaster.ray.at(100,aimPoint);
aimPoint.y=waterHeight(aimPoint.x,aimPoint.z)+.25;
}
aimTurret(player,aimPoint,dt);
$('crosshair').classList.toggle('locked',!!hoverTarget);
const distance=Math.round(aimPoint.distanceTo(player.pos));
$('range-readout').textContent=hoverTarget?`HOSTILE · ${distance} M`:`${distance} M`;
if((firing||keys.Space)&&mode==='playing')fire(player,aimPoint,82,16);
}
function updateCamera(dt){
let desired,look;
if(mode==='menu'){
const a=simTime*.075+.65;
desired=new THREE.Vector3(player.pos.x+Math.sin(a)*18,7.2,player.pos.z+Math.cos(a)*18);
look=new THREE.Vector3(player.pos.x,player.pos.y+1,player.pos.z);
}else{
const f=forward(player,new THREE.Vector3()),r=right(player,new THREE.Vector3());
desired=player.pos.clone().addScaledVector(f,-15.5).add(new THREE.Vector3(0,7.3,0)).addScaledVector(r,player.steer*-.7);
look=player.pos.clone().add(new THREE.Vector3(0,1.15,0)).addScaledVector(f,6);
tmpV.subVectors(aimPoint,player.pos).setY(0);if(tmpV.lengthSq()>.1)look.addScaledVector(tmpV.normalize(),2.2);
}
camera.position.lerp(desired,1-Math.exp(-dt*(mode==='menu'?1.3:5.0)));
cameraLook.lerp(look,1-Math.exp(-dt*5.5));
const minY=waterHeight(camera.position.x,camera.position.z)+1.7;if(camera.position.y<minY)camera.position.y=minY;
if(cameraShake>0){camera.position.x+=(Math.random()-.5)*cameraShake;camera.position.y+=(Math.random()-.5)*cameraShake*.6;cameraShake=Math.max(0,cameraShake-dt*2.4);}
camera.lookAt(cameraLook);
const targetFov=mode==='playing'?61+Math.min(7,Math.abs(player.speed)*.28):58;
camera.fov=lerp(camera.fov,targetFov,1-Math.exp(-dt*2));camera.updateProjectionMatrix();
sunLight.position.copy(player.pos).addScaledVector(SUN_DIRECTION,230);sunLight.target.position.copy(player.pos);
water.position.x=player.pos.x;water.position.z=player.pos.z;
}
function updateLabels(){
for(const e of enemies){
if(!e.label)continue;
tmpV.copy(e.pos);tmpV.y+=3.1;tmpV.project(camera);
const visible=tmpV.z<1&&tmpV.z>-1&&Math.abs(tmpV.x)<1.15&&Math.abs(tmpV.y)<1.15;
e.label.style.display=visible?'block':'none';
if(visible){
e.label.style.left=`${(tmpV.x*.5+.5)*innerWidth}px`;e.label.style.top=`${(-tmpV.y*.5+.5)*innerHeight}px`;
e.label.querySelector('i').style.width=`${e.health/e.maxHealth*100}%`;
e.label.querySelector('small').textContent=`${Math.round(e.pos.distanceTo(player.pos))} M`;
}
}
}
function updateHUD(){
const hp=Math.ceil(player.health);$('health').textContent=hp;$('health-bar').style.transform=`scaleX(${player.health/player.maxHealth})`;
$('health-bar').style.background=hp<30?'#ff655c':hp<60?'#ffc06d':'#9dd9ca';$('hull-status').textContent=hp<30?'CRITICAL DAMAGE':hp<60?'HULL COMPROMISED':'SEAWORTHY';
$('speed').textContent=String(Math.round(Math.abs(player.speed)*1.94384)).padStart(2,'0');
$('throttle-bar').style.width=`${Math.abs(player.throttle)*100}%`;$('throttle-label').textContent=player.throttle>0?'ENGINE · AHEAD':player.throttle<0?'ENGINE · REVERSE':Math.abs(player.speed)>.4?'ENGINE · COASTING':'ENGINE · IDLE';
$('wave-count').textContent=String(wave).padStart(2,'0');$('enemy-count').textContent=String(enemies.length).padStart(2,'0');$('score').textContent=String(score).padStart(6,'0');
const heading=(THREE.MathUtils.radToDeg(player.yaw)%360+360)%360;
const cardinal=['N','NE','E','SE','S','SW','W','NW'][Math.round(heading/45)%8];$('heading').textContent=`${String(Math.round(heading)).padStart(3,'0')} · ${cardinal}`;
const reload=1-clamp(player.cooldown/.24,0,1);$('reload-bar').style.transform=`scaleX(${reload})`;$('weapon-state').textContent=reload>=1?'READY TO FIRE':'CYCLING ACTION';
}
function animate(now){
requestAnimationFrame(animate);
const rawDt=Math.min((now-lastTime)/1000,.05);lastTime=now;
const dt=(mode==='paused')?0:rawDt;
if(dt>0){
simTime+=dt;
updateRogue(dt);
if(mode==='menu'){player.throttle=0;player.steer=0;updateBoatPhysics(player,dt);}
else{
if(!player.dead)updatePlayer(dt);else{player.throttle=0;updateBoatPhysics(player,dt);}
if(mode==='playing'){
for(const e of [...enemies])updateEnemy(e,dt);
updateCollisions();updateAim(dt);
if(waveDelay>0){waveDelay-=dt;if(waveDelay<=0)startNextWave();}
}
}
updateBullets(dt);updateWrecks(dt);particles.update(dt);
if(announcementTimer>0){announcementTimer-=dt;if(announcementTimer<=0)$('announcement').classList.remove('show');}
if(toastTimer>0){toastTimer-=dt;if(toastTimer<=0)$('toast').classList.remove('show');}
if(damageFlash>0){damageFlash=Math.max(0,damageFlash-dt*1.8);$('damage-flash').style.opacity=damageFlash;}
}
water.material.uniforms.time.value=simTime;
updateCamera(Math.max(dt,.001));updateLabels();updateHUD();audio.update();
renderer.render(scene,camera);
}
function togglePause(){
if(mode==='playing'){mode='paused';firing=false;$('pause').classList.remove('hidden');$('crosshair').classList.remove('active');}
else if(mode==='paused'){mode='playing';lastTime=performance.now();$('pause').classList.add('hidden');$('crosshair').classList.add('active');audio.init();}
}
function returnMenu(){
clearWorld();mode='menu';player.dead=false;resetBoat(player,0,0,0);rogue.amp=0;rogue.active=false;rogue.timer=12;
$('pause').classList.add('hidden');$('gameover').classList.add('hidden');$('menu').classList.remove('hidden');$('hud').classList.remove('active');$('crosshair').classList.remove('active');$('wave-warning').classList.remove('show');
}
function bindEvents(){
addEventListener('resize',onResize);
addEventListener('keydown',e=>{
keys[e.code]=true;if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code))e.preventDefault();
if(e.repeat)return;
if(e.code==='KeyP'||e.code==='Escape')togglePause();
if(e.code==='KeyM')audio.toggle();
if(e.code==='KeyR'&&mode==='gameover')startGame();
});
addEventListener('keyup',e=>{keys[e.code]=false;});
addEventListener('blur',()=>{for(const k in keys)keys[k]=false;firing=false;if(mode==='playing')togglePause();});
addEventListener('mousemove',e=>{mouse.x=e.clientX/innerWidth*2-1;mouse.y=-(e.clientY/innerHeight)*2+1;$('crosshair').style.left=`${e.clientX}px`;$('crosshair').style.top=`${e.clientY}px`;});
renderer.domElement.addEventListener('mousedown',e=>{if(e.button===0&&mode==='playing'){firing=true;audio.init();}});
addEventListener('mouseup',e=>{if(e.button===0)firing=false;});
renderer.domElement.addEventListener('contextmenu',e=>e.preventDefault());
$('launch').addEventListener('click',startGame);$('restart').addEventListener('click',startGame);
$('resume').addEventListener('click',togglePause);$('pause-menu').addEventListener('click',returnMenu);$('to-menu').addEventListener('click',returnMenu);
document.addEventListener('visibilitychange',()=>{if(document.hidden&&mode==='playing')togglePause();});
}
function onResize(){
camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio,1.6));
}
try { init(); } catch (error) {
console.error(error);
$('loading').querySelector('.loader').classList.add('hidden');$('loading').querySelector('span').textContent='UNABLE TO LAUNCH';
$('fatal').classList.remove('hidden');$('fatal').textContent='WebGL initialization failed. Please use a modern browser with hardware acceleration enabled. '+error.message;
}
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.