Boat game, built by GPT-6 Luna
Hostedvia Codex CLIOpenRouter
Show the code the model wroteHide the code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#07151b">
<title>BLACKWATER — Open Sea Engagement</title>
<style>/* <stdin> */
:root {
color-scheme: dark;
--ink: #e5f2ef;
--muted: #8ca4a6;
--line: rgba(200, 230, 224, .18);
--cyan: #73f1df;
--red: #ff6c5c;
--amber: #ffc56d;
--panel: rgba(7, 19, 24, .64);
--mono:
"SFMono-Regular",
Consolas,
"Liberation Mono",
monospace;
--sans:
Inter,
"Helvetica Neue",
Helvetica,
Arial,
sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #102a34;
color: var(--ink);
font-family: var(--sans);
}
body {
-webkit-font-smoothing: antialiased;
}
#game {
position: fixed;
inset: 0;
}
canvas#view {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
outline: none;
}
.screen-vignette {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
radial-gradient(
ellipse at 50% 48%,
transparent 42%,
rgba(3, 13, 18, .12) 78%,
rgba(3, 10, 14, .48) 120%);
}
.grain {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: .12;
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='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.32'/%3E%3C/svg%3E");
mix-blend-mode: soft-light;
}
.scanlines {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: .12;
background:
repeating-linear-gradient(
0deg,
transparent 0 3px,
rgba(0, 0, 0, .19) 4px);
}
#hud {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
opacity: 0;
transition: opacity .7s ease;
}
#hud.active {
opacity: 1;
}
.topbar {
position: absolute;
left: 34px;
right: 34px;
top: 26px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.brand {
display: flex;
align-items: center;
gap: 13px;
}
.brand-mark {
width: 26px;
height: 26px;
border: 1px solid rgba(115, 241, 223, .72);
display: grid;
place-items: center;
transform: rotate(45deg);
box-shadow: 0 0 18px rgba(115, 241, 223, .13);
}
.brand-mark:before {
content: "";
width: 8px;
height: 8px;
background: var(--cyan);
box-shadow: 0 0 10px var(--cyan);
}
.brand-copy {
font: 10px/1.6 var(--mono);
letter-spacing: .19em;
color: #c2d5d1;
}
.brand-copy b {
color: #f1faf6;
font-weight: 600;
}
.top-status {
text-align: right;
font: 9px/1.7 var(--mono);
letter-spacing: .16em;
color: rgba(229, 242, 239, .62);
}
.live {
color: var(--cyan);
}
.live:before {
content: "";
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--cyan);
margin: 0 7px 1px 0;
box-shadow: 0 0 9px var(--cyan);
animation: blink 1.5s infinite;
}
@keyframes blink {
50% {
opacity: .35;
}
}
.readout {
position: absolute;
top: 100px;
left: 34px;
width: 218px;
}
.readout-title,
.small-label {
font: 9px/1.4 var(--mono);
letter-spacing: .19em;
color: rgba(211, 229, 225, .62);
}
.health-block {
padding: 12px 14px 14px;
background:
linear-gradient(
100deg,
rgba(5, 17, 22, .72),
rgba(5, 17, 22, .15));
border-left: 1px solid rgba(115, 241, 223, .7);
}
.health-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0 9px;
font: 11px var(--mono);
color: #ecf5f1;
}
.health-head span:last-child {
color: var(--cyan);
}
.health-track {
height: 3px;
background: rgba(226, 245, 240, .14);
}
#healthFill {
height: 100%;
width: 100%;
background:
linear-gradient(
90deg,
#55dfd0,
#b0ffe8);
box-shadow: 0 0 10px rgba(115, 241, 223, .48);
transition: width .22s, background .22s;
}
.readout-meta {
margin-top: 12px;
display: flex;
gap: 18px;
}
.metric {
min-width: 69px;
}
.metric .small-label {
font-size: 8px;
}
.metric b {
display: block;
margin-top: 5px;
font: 14px var(--mono);
letter-spacing: -.04em;
color: #f3f8f5;
}
.right-stack {
position: absolute;
top: 100px;
right: 34px;
width: 224px;
text-align: right;
}
.enemy-count {
font: 34px/.9 var(--mono);
letter-spacing: -.08em;
color: #fff;
text-shadow: 0 0 25px rgba(255, 108, 92, .3);
}
.enemy-count small {
font-size: 10px;
letter-spacing: .16em;
color: var(--red);
margin-left: 5px;
}
.enemy-copy {
margin-top: 10px;
font: 9px/1.55 var(--mono);
letter-spacing: .15em;
color: rgba(225, 238, 232, .58);
}
.bearing {
margin-top: 25px;
display: inline-flex;
align-items: center;
gap: 12px;
font: 10px var(--mono);
color: #d8e8e3;
}
.bearing-mark {
width: 35px;
height: 35px;
border: 1px solid rgba(224, 243, 237, .3);
border-radius: 50%;
display: grid;
place-items: center;
position: relative;
}
.bearing-mark:before {
content: "";
width: 1px;
height: 11px;
background: var(--cyan);
position: absolute;
top: 3px;
left: 16px;
transform-origin: 50% 14px;
}
#compassNeedle {
width: 1px;
height: 10px;
background: var(--red);
position: absolute;
top: 5px;
left: 16px;
transform-origin: 50% 12px;
}
.bottom-left {
position: absolute;
left: 34px;
bottom: 28px;
display: flex;
align-items: flex-end;
gap: 15px;
}
.radar-wrap {
position: relative;
width: 150px;
height: 150px;
padding: 7px;
border: 1px solid rgba(213, 236, 229, .22);
background: rgba(5, 17, 22, .43);
backdrop-filter: blur(5px);
}
.radar-wrap:before {
content: "TACTICAL PLOT";
position: absolute;
left: 9px;
top: -17px;
font: 8px var(--mono);
letter-spacing: .17em;
color: rgba(216, 233, 226, .63);
}
#radar {
display: block;
width: 100%;
height: 100%;
}
.speedbox {
width: 156px;
padding: 11px 13px 13px;
border-left: 1px solid rgba(115, 241, 223, .45);
background:
linear-gradient(
90deg,
rgba(4, 16, 21, .48),
transparent);
}
.speedrow {
display: flex;
align-items: baseline;
justify-content: space-between;
}
#speedNum {
font: 34px/.95 var(--mono);
letter-spacing: -.08em;
color: #f4fbf6;
}
.unit {
font: 9px var(--mono);
letter-spacing: .12em;
color: var(--muted);
}
.speed-track {
height: 2px;
margin: 13px 0 8px;
background: rgba(225, 243, 237, .15);
}
#speedFill {
height: 100%;
width: 0%;
background: var(--cyan);
box-shadow: 0 0 10px rgba(115, 241, 223, .7);
}
.throttle-label {
display: flex;
justify-content: space-between;
font: 8px var(--mono);
letter-spacing: .1em;
color: rgba(214, 233, 226, .56);
}
.bottom-right {
position: absolute;
right: 34px;
bottom: 31px;
text-align: right;
}
.weapon-label {
font: 8px/1.7 var(--mono);
letter-spacing: .17em;
color: rgba(218, 234, 228, .58);
}
.weapon-name {
margin: 3px 0 10px;
font: 15px var(--mono);
letter-spacing: .04em;
color: #f0f7f3;
}
.ammo-dots {
display: flex;
gap: 5px;
justify-content: flex-end;
}
.ammo-dots i {
display: block;
width: 20px;
height: 3px;
background: var(--amber);
box-shadow: 0 0 8px rgba(255, 197, 109, .3);
}
.crosshair {
position: absolute;
left: 50%;
top: 50%;
width: 54px;
height: 54px;
transform: translate(-50%, -50%);
opacity: .9;
transition: transform .12s;
}
.crosshair:before,
.crosshair:after {
content: "";
position: absolute;
background: rgba(224, 250, 243, .8);
box-shadow: 0 0 6px rgba(115, 241, 223, .45);
}
.crosshair:before {
width: 1px;
height: 16px;
left: 26px;
top: 0;
box-shadow: 0 38px 6px rgba(115, 241, 223, .45);
}
.crosshair:after {
height: 1px;
width: 16px;
top: 26px;
left: 0;
box-shadow: 38px 0 6px rgba(115, 241, 223, .45);
}
.crosshair-core {
position: absolute;
left: 24px;
top: 24px;
width: 6px;
height: 6px;
border: 1px solid var(--cyan);
transform: rotate(45deg);
}
#targetReadout {
position: absolute;
left: calc(50% + 36px);
top: calc(50% + 24px);
font: 8px/1.6 var(--mono);
color: rgba(220, 242, 235, .66);
letter-spacing: .12em;
text-shadow: 0 1px 4px #001;
}
#waveAlert {
position: absolute;
left: 50%;
top: 14%;
transform: translate(-50%, -5px);
padding: 8px 13px;
border-top: 1px solid rgba(255, 197, 109, .75);
border-bottom: 1px solid rgba(255, 197, 109, .35);
color: #ffdaa0;
font: 9px var(--mono);
letter-spacing: .18em;
background: rgba(30, 23, 12, .43);
opacity: 0;
transition: opacity .3s, transform .3s;
}
#waveAlert.visible {
opacity: 1;
transform: translate(-50%, 0);
}
#hitFlash {
position: absolute;
inset: 0;
border: 0 solid rgba(255, 65, 54, 0);
background:
radial-gradient(
ellipse,
transparent 46%,
rgba(255, 56, 44, .47) 100%);
opacity: 0;
transition: opacity .2s;
}
#hitFlash.show {
opacity: 1;
}
#toast {
position: absolute;
left: 50%;
top: 23%;
transform: translate(-50%, 8px);
color: #fff;
font: 11px var(--mono);
letter-spacing: .16em;
text-align: center;
opacity: 0;
transition: all .25s;
text-shadow: 0 2px 12px #000;
}
#toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
#toast span {
display: block;
margin-top: 5px;
color: var(--cyan);
font-size: 8px;
letter-spacing: .2em;
}
#targetTag {
position: absolute;
z-index: 4;
display: none;
font: 8px var(--mono);
color: #ff9a81;
letter-spacing: .14em;
text-shadow: 0 1px 5px #001;
white-space: nowrap;
transform: translate(-50%, -100%);
pointer-events: none;
}
#targetTag:before {
content: "";
display: block;
margin: 0 auto 4px;
width: 19px;
height: 19px;
border: 1px solid rgba(255, 118, 91, .8);
transform: rotate(45deg);
box-shadow: 0 0 12px rgba(255, 80, 70, .24);
}
#intro,
#gameover {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
linear-gradient(
90deg,
rgba(4, 15, 20, .71),
rgba(4, 15, 20, .15) 70%),
linear-gradient(
0deg,
rgba(3, 13, 17, .5),
transparent 62%);
transition: opacity .6s, visibility .6s;
}
#intro.hidden,
#gameover.hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.intro-panel {
width: min(100%, 1080px);
display: grid;
grid-template-columns: 1.15fr .85fr;
align-items: end;
gap: 7%;
padding: 7vh 4vw;
}
.eyebrow {
display: flex;
align-items: center;
gap: 11px;
color: var(--cyan);
font: 9px var(--mono);
letter-spacing: .22em;
}
.eyebrow:before {
content: "";
display: block;
width: 25px;
height: 1px;
background: var(--cyan);
box-shadow: 0 0 9px var(--cyan);
}
h1 {
margin: 19px 0 14px;
font-size: clamp(48px, 8.2vw, 110px);
line-height: .82;
letter-spacing: -.085em;
font-weight: 600;
color: #f2f7f3;
text-shadow: 0 5px 50px rgba(0, 0, 0, .2);
}
h1 span {
display: block;
color: rgba(223, 240, 233, .56);
font-size: .42em;
letter-spacing: .05em;
margin-top: 14px;
font-weight: 400;
}
.intro-copy {
max-width: 480px;
color: rgba(225, 239, 233, .66);
font-size: 13px;
line-height: 1.75;
}
.deploy-row {
display: flex;
align-items: center;
gap: 20px;
margin-top: 31px;
}
button {
font: inherit;
color: inherit;
}
#deploy,
#retry {
cursor: pointer;
border: 1px solid rgba(115, 241, 223, .75);
color: #dcfff6;
background: rgba(40, 130, 119, .12);
padding: 15px 23px;
font: 10px var(--mono);
letter-spacing: .18em;
transition:
background .2s,
box-shadow .2s,
transform .2s;
}
#deploy:hover,
#retry:hover {
background: rgba(60, 178, 159, .22);
box-shadow: 0 0 28px rgba(96, 232, 210, .16);
transform: translateY(-1px);
}
.deploy-hint {
color: rgba(214, 231, 224, .47);
font: 8px/1.6 var(--mono);
letter-spacing: .1em;
}
.mission-card {
padding: 20px 0 0 22px;
border-left: 1px solid rgba(213, 235, 226, .23);
margin-bottom: 2px;
}
.mission-card h2 {
margin: 0 0 16px;
color: #eff7f2;
font: 10px var(--mono);
letter-spacing: .18em;
font-weight: 400;
}
.mission-line {
display: grid;
grid-template-columns: 52px 1fr;
padding: 9px 0;
border-top: 1px solid rgba(215, 237, 229, .11);
color: rgba(222, 237, 230, .65);
font: 9px/1.55 var(--mono);
letter-spacing: .045em;
}
.mission-line b {
color: var(--cyan);
font-weight: 400;
}
.intro-footer {
position: absolute;
left: 34px;
right: 34px;
bottom: 23px;
display: flex;
justify-content: space-between;
color: rgba(218, 233, 226, .35);
font: 8px var(--mono);
letter-spacing: .15em;
}
#gameover {
z-index: 6;
background: rgba(4, 12, 16, .72);
backdrop-filter: blur(7px);
}
.end-card {
width: min(420px, 100%);
padding: 34px;
border: 1px solid rgba(255, 108, 92, .36);
background: rgba(8, 20, 24, .84);
text-align: center;
}
.end-card .eyebrow {
justify-content: center;
color: var(--red);
}
.end-card h2 {
margin: 24px 0 8px;
font-size: clamp(32px, 6vw, 52px);
letter-spacing: -.07em;
font-weight: 500;
}
.end-card p {
color: var(--muted);
font: 10px/1.7 var(--mono);
letter-spacing: .08em;
}
#finalScore {
color: var(--amber);
font-size: 22px;
}
#retry {
margin-top: 16px;
border-color: rgba(255, 108, 92, .62);
}
@media (max-width: 700px) {
.topbar {
left: 17px;
right: 17px;
top: 17px;
}
.readout {
left: 17px;
top: 79px;
transform: scale(.82);
transform-origin: top left;
}
.right-stack {
right: 17px;
top: 83px;
transform: scale(.82);
transform-origin: top right;
}
.bottom-left {
left: 17px;
bottom: 18px;
gap: 9px;
transform: scale(.82);
transform-origin: bottom left;
}
.bottom-right {
right: 17px;
bottom: 18px;
transform: scale(.82);
transform-origin: bottom right;
}
.intro-panel {
grid-template-columns: 1fr;
gap: 34px;
padding: 4vh 4vw;
}
.mission-card {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 14px;
padding-left: 14px;
}
.mission-card h2 {
grid-column: 1/-1;
}
.mission-line {
grid-template-columns: 40px 1fr;
}
.intro-footer {
left: 17px;
right: 17px;
font-size: 7px;
}
}
@media (max-height: 650px) and (min-width: 701px) {
.intro-panel {
padding: 2vh 4vw;
transform: scale(.88);
}
.readout,
.right-stack {
top: 83px;
}
}
</style>
</head>
<body>
<main id="game" aria-label="Blackwater open sea engagement game">
<canvas id="view" tabindex="0" aria-label="Three dimensional ocean combat game"></canvas>
<div class="screen-vignette"></div><div class="grain"></div><div class="scanlines"></div>
<div id="hud" aria-hidden="true">
<div class="topbar">
<div class="brand"><div class="brand-mark"></div><div class="brand-copy"><b>BLACKWATER</b><br>MARITIME RESPONSE / 04</div></div>
<div class="top-status"><span class="live">ENGAGEMENT ACTIVE</span><br><span id="missionClock">00:00</span> · NORTH ATLANTIC / 41° 18′ N</div>
</div>
<div class="readout">
<div class="health-block">
<div class="readout-title">VESSEL CONDITION</div>
<div class="health-head"><span>HULL INTEGRITY</span><span id="healthValue">100%</span></div>
<div class="health-track"><div id="healthFill"></div></div>
<div class="readout-meta">
<div class="metric"><div class="small-label">VESSELS SUNK</div><b id="scoreValue">00</b></div>
<div class="metric"><div class="small-label">SEA STATE</div><b id="seaState">03 / MOD</b></div>
</div>
</div>
</div>
<div class="right-stack">
<div class="readout-title">HOSTILE CONTACTS</div>
<div class="enemy-count"><span id="enemyValue">03</span><small>HOSTILES</small></div>
<div class="enemy-copy">FAST ATTACK CRAFT<br>WEAPONS FREE · RANGE CLOSING</div>
<div class="bearing"><span id="bearingText">CONTACT 000°</span><span class="bearing-mark"><i id="compassNeedle"></i></span></div>
</div>
<div class="crosshair"><i class="crosshair-core"></i></div>
<div id="targetReadout">MK-04 · 000 M</div>
<div id="waveAlert">⚠ LARGE SWELL APPROACHING <span id="waveEta">00:10</span></div>
<div id="hitFlash"></div><div id="toast"></div><div id="targetTag">HOSTILE / FAST CRAFT</div>
<div class="bottom-left">
<div class="radar-wrap"><canvas id="radar" width="240" height="240"></canvas></div>
<div class="speedbox">
<div class="small-label">VESSEL SPEED</div>
<div class="speedrow"><span id="speedNum">00</span><span class="unit">KNOTS</span></div>
<div class="speed-track"><div id="speedFill"></div></div>
<div class="throttle-label"><span>THROTTLE</span><span id="throttleValue">IDLE</span></div>
</div>
</div>
<div class="bottom-right"><div class="weapon-label">DECK MOUNT / STABILIZED</div><div class="weapon-name">M2 .50 CALIBER</div><div class="ammo-dots"><i></i><i></i><i></i><i></i><i></i><i></i></div></div>
</div>
<div id="intro">
<div class="intro-panel">
<section>
<div class="eyebrow">OPEN WATER ENGAGEMENT / 04</div>
<h1>BLACK<span>WATER</span></h1>
<p class="intro-copy">A hostile flotilla has made contact in rough water. Keep your bow into the swell, manage your momentum, and clear the patrol before they overwhelm your hull.</p>
<div class="deploy-row"><button id="deploy">DEPLOY VESSEL ↗</button><div class="deploy-hint">SIMULATION READY<br>NO SAFE HARBOR IN RANGE</div></div>
</section>
<aside class="mission-card">
<h2>VESSEL / INTERCEPTOR 04</h2>
<div class="mission-line"><b>W A S D</b><span>Throttle, reverse, and steer through the swell</span></div>
<div class="mission-line"><b>MOUSE</b><span>Aim the stabilized deck gun</span></div>
<div class="mission-line"><b>CLICK</b><span>Fire · hold to sustain fire</span></div>
<div class="mission-line"><b>SPACE</b><span>Emergency brake and settle the hull</span></div>
</aside>
</div>
<div class="intro-footer"><span>BLACKWATER MARITIME RESPONSE</span><span>REAL-TIME SEA STATE / BALLISTIC INTERCEPT</span><span>CLASSIFIED // 04</span></div>
</div>
<div id="gameover" class="hidden"><div class="end-card"><div class="eyebrow">VESSEL LOST / SIGNAL ENDED</div><h2>MISSION<br>FAILED</h2><p>HOSTILES NEUTRALIZED<br><strong id="finalScore">00</strong></p><button id="retry">REDEPLOY VESSEL ↻</button></div></div>
</main>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { Sky } from 'three/addons/objects/Sky.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const canvas = document.querySelector('#view');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
renderer.setSize(window.innerWidth, window.innerHeight, false);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.12;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x96b8c1);
scene.fog = new THREE.Fog(0x9bb7bb, 330, 2050);
const camera = new THREE.PerspectiveCamera(48, window.innerWidth / window.innerHeight, 0.1, 3200);
const clock = new THREE.Clock();
const tmpV = new THREE.Vector3();
const tmpV2 = new THREE.Vector3();
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2(0, 0);
const aimPlaneY = 0.7;
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
pmrem.dispose();
const sky = new Sky();
sky.scale.setScalar(450000);
scene.add(sky);
const skyU = sky.material.uniforms;
skyU.turbidity.value = 8.3;
skyU.rayleigh.value = 1.9;
skyU.mieCoefficient.value = 0.006;
skyU.mieDirectionalG.value = 0.78;
const sunDir = new THREE.Vector3(-0.42, 0.74, -0.52).normalize();
skyU.sunPosition.value.copy(sunDir).multiplyScalar(450000);
scene.add(new THREE.HemisphereLight(0xc7e5e5, 0x24363a, 1.0));
const sunLight = new THREE.DirectionalLight(0xffe7c8, 2.3);
sunLight.position.copy(sunDir).multiplyScalar(300);
sunLight.castShadow = true;
sunLight.shadow.mapSize.set(2048, 2048);
sunLight.shadow.camera.left = -100; sunLight.shadow.camera.right = 100;
sunLight.shadow.camera.top = 100; sunLight.shadow.camera.bottom = -100;
sunLight.shadow.camera.near = 1; sunLight.shadow.camera.far = 600;
sunLight.shadow.bias = -0.00022;
scene.add(sunLight);
const waveVertex = `
uniform float uTime;
uniform vec2 uImpactCenter;
uniform vec2 uImpactDir;
uniform float uImpactAge;
uniform float uImpactAmp;
varying vec3 vWorld;
varying float vHeight;
float seaHeight(vec2 p, float t) {
float h = 1.05 * sin(dot(p, normalize(vec2(0.81, 0.43))) * 0.046 + t * 0.72);
h += 0.66 * sin(dot(p, normalize(vec2(-0.37, 0.93))) * 0.083 - t * 0.94 + 1.1);
h += 0.36 * sin(dot(p, normalize(vec2(0.98, -0.21))) * 0.133 + t * 1.42 + 2.6);
h += 0.17 * sin(dot(p, normalize(vec2(-0.72, -0.69))) * 0.238 - t * 1.86);
if (uImpactAmp > 0.0) {
vec2 d = p - uImpactCenter - uImpactDir * (uImpactAge * 12.0);
float along = dot(d, uImpactDir);
float across = dot(d, vec2(-uImpactDir.y, uImpactDir.x));
float envelope = exp(-(along * along) / 1500.0 - (across * across) / 4400.0);
h += uImpactAmp * sin(along * 0.078) * envelope;
}
return h;
}
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
float h = seaHeight(wp.xz, uTime);
wp.y += h;
vWorld = wp.xyz;
vHeight = h;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`;
const waveFragment = `
uniform float uTime;
uniform vec3 uCamera;
uniform vec3 uSunDir;
uniform vec2 uImpactCenter;
uniform vec2 uImpactDir;
uniform float uImpactAge;
uniform float uImpactAmp;
varying vec3 vWorld;
varying float vHeight;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1,311.7))) * 43758.5453); }
vec2 waveSlope(vec2 p) {
vec2 d1 = normalize(vec2(0.81,0.43));
vec2 d2 = normalize(vec2(-0.37,0.93));
vec2 d3 = normalize(vec2(0.98,-0.21));
vec2 d4 = normalize(vec2(-0.72,-0.69));
vec2 g = 1.05*0.046*d1*cos(dot(p,d1)*0.046+uTime*0.72);
g += 0.66*0.083*d2*cos(dot(p,d2)*0.083-uTime*0.94+1.1);
g += 0.36*0.133*d3*cos(dot(p,d3)*0.133+uTime*1.42+2.6);
g += 0.17*0.238*d4*cos(dot(p,d4)*0.238-uTime*1.86);
vec2 ripple1=normalize(vec2(0.64,-0.77));
vec2 ripple2=normalize(vec2(-0.92,-0.38));
vec2 ripple3=normalize(vec2(0.15,0.99));
g += 0.22*0.34*ripple1*cos(dot(p,ripple1)*0.34+uTime*2.2);
g += 0.12*0.53*ripple2*cos(dot(p,ripple2)*0.53-uTime*2.85);
g += 0.075*0.78*ripple3*cos(dot(p,ripple3)*0.78+uTime*3.4);
if (uImpactAmp > 0.0) {
vec2 d = p-uImpactCenter-uImpactDir*(uImpactAge*12.0);
float a=dot(d,uImpactDir);
float b=dot(d,vec2(-uImpactDir.y,uImpactDir.x));
float sn=sin(a*0.078);
float env=exp(-(a*a)/1500.0-(b*b)/4400.0);
float da=uImpactAmp*env*(0.078*cos(a*0.078)-sn*(2.0*a/1500.0));
float db=uImpactAmp*sn*env*(-2.0*b/4400.0);
g += da*uImpactDir+db*vec2(-uImpactDir.y,uImpactDir.x);
}
return g;
}
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)),f.x),mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);
}
void main() {
vec2 slope = waveSlope(vWorld.xz);
vec3 N = normalize(vec3(-slope.x,1.0,-slope.y));
vec3 V = normalize(uCamera - vWorld);
vec3 L = normalize(uSunDir);
float ndv = clamp(dot(N,V),0.0,1.0);
float fresnel = 0.025 + 0.975 * pow(1.0 - ndv, 4.3);
float n = noise(vWorld.xz * 0.021 + vec2(uTime * 0.025, -uTime * 0.018));
float n2 = noise(vWorld.xz * 0.083 - uTime * 0.045);
vec3 deep = vec3(0.008, 0.052, 0.073);
vec3 blue = vec3(0.016, 0.116, 0.151);
vec3 water = mix(deep, blue, clamp(0.28 + n * 0.46 + vHeight * 0.07,0.0,1.0));
vec3 R = reflect(-V,N);
float skyH = clamp(R.y * 0.5 + 0.5, 0.0, 1.0);
vec3 reflectedSky = mix(vec3(0.62,0.72,0.72),vec3(0.08,0.22,0.31),pow(1.0-skyH,1.55));
float sunDisc = pow(max(dot(R,L),0.0),480.0);
reflectedSky += vec3(1.0,0.78,0.51) * sunDisc * 1.8;
float spec = pow(max(dot(reflect(-L,N),V),0.0),180.0);
spec += pow(max(dot(reflect(-L,N),V),0.0),38.0) * 0.22;
water += vec3(1.0,0.82,0.61) * spec * (0.28 + 0.72 * (1.0-n));
float foamNoise = noise(vWorld.xz * 0.105 + vec2(uTime * 0.11, uTime * 0.065));
float crest = smoothstep(0.75,1.72,vHeight + (foamNoise - 0.5) * 0.74);
float foamBreakup = smoothstep(0.51,0.78,noise(vWorld.xz*0.071+vec2(uTime*0.13,-uTime*0.09))*0.58+noise(vWorld.xz*0.19-vec2(uTime*0.18,uTime*0.12))*0.42);
float foam = crest*foamBreakup*0.52;
if (uImpactAmp > 0.0) {
vec2 d = vWorld.xz - uImpactCenter - uImpactDir * (uImpactAge * 12.0);
float along = dot(d,uImpactDir);
float across = dot(d,vec2(-uImpactDir.y,uImpactDir.x));
float e = exp(-(along*along)/1500.0-(across*across)/4400.0);
foam = max(foam, smoothstep(0.66,0.92,abs(sin(along*0.078))*e*uImpactAmp) * 0.5);
}
vec3 color = mix(water, reflectedSky, fresnel * 0.73);
color = mix(color, vec3(0.75,0.86,0.82), foam);
float distanceToEye = length(uCamera - vWorld);
float fog = smoothstep(470.0,2100.0,distanceToEye);
color = mix(color,vec3(0.44,0.59,0.62),fog);
color += vec3(0.12,0.18,0.19) * n2 * (1.0-fog);
gl_FragColor = vec4(color,1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`;
const waterUniforms = {
uTime: { value: 0 },
uCamera: { value: new THREE.Vector3() },
uSunDir: { value: sunDir.clone() },
uImpactCenter: { value: new THREE.Vector2(0,0) },
uImpactDir: { value: new THREE.Vector2(0,1) },
uImpactAge: { value: 0 },
uImpactAmp: { value: 0 }
};
const sea = new THREE.Mesh(
new THREE.PlaneGeometry(4800,4800,480,480),
new THREE.ShaderMaterial({ vertexShader: waveVertex, fragmentShader: waveFragment, uniforms: waterUniforms, side: THREE.DoubleSide, depthWrite: true })
);
sea.rotation.x = -Math.PI / 2;
sea.receiveShadow = true;
scene.add(sea);
const mats = {
hull: new THREE.MeshStandardMaterial({ color: 0x23343a, metalness: 0.57, roughness: 0.32 }),
deck: new THREE.MeshStandardMaterial({ color: 0x4c5856, metalness: 0.42, roughness: 0.68 }),
cabin: new THREE.MeshStandardMaterial({ color: 0x243239, metalness: 0.45, roughness: 0.31 }),
glass: new THREE.MeshPhysicalMaterial({ color: 0x67aab1, metalness: 0.35, roughness: 0.15, transparent: true, opacity: 0.62, side: THREE.DoubleSide }),
rubber: new THREE.MeshStandardMaterial({ color: 0x151e21, roughness: 0.85 }),
steel: new THREE.MeshStandardMaterial({ color: 0x75817e, metalness: 0.82, roughness: 0.28 }),
gun: new THREE.MeshStandardMaterial({ color: 0x182327, metalness: 0.8, roughness: 0.24 }),
orange: new THREE.MeshStandardMaterial({ color: 0xb96636, metalness: 0.27, roughness: 0.46 }),
enemyHull: new THREE.MeshStandardMaterial({ color: 0x4d2825, metalness: 0.54, roughness: 0.34 }),
enemyDeck: new THREE.MeshStandardMaterial({ color: 0x6d3930, metalness: 0.4, roughness: 0.62 }),
enemyGlass: new THREE.MeshPhysicalMaterial({ color: 0xc07755, metalness: 0.18, roughness: 0.22, transparent: true, opacity: 0.68 })
};
const boxGeo = new THREE.BoxGeometry(1,1,1);
const sphereGeo = new THREE.SphereGeometry(1,14,10);
const cylGeo = new THREE.CylinderGeometry(0.5,0.5,1,12);
function block(parent, material, pos, size, rotation) {
const m = new THREE.Mesh(boxGeo, material);
m.position.set(pos[0],pos[1],pos[2]); m.scale.set(size[0],size[1],size[2]);
if (rotation) m.rotation.set(rotation[0],rotation[1],rotation[2]);
m.castShadow = true; m.receiveShadow = true; parent.add(m); return m;
}
function cylinder(parent, material, pos, size, rotation) {
const m = new THREE.Mesh(cylGeo,material);
m.position.set(pos[0],pos[1],pos[2]); m.scale.set(size[0],size[1],size[2]);
if (rotation) m.rotation.set(rotation[0],rotation[1],rotation[2]);
m.castShadow = true; parent.add(m); return m;
}
function sphere(parent, material, pos, size) {
const m = new THREE.Mesh(sphereGeo,material);
m.position.set(pos[0],pos[1],pos[2]); m.scale.set(size[0],size[1],size[2]);
m.castShadow = true; parent.add(m); return m;
}
function hullShape() {
const s = new THREE.Shape();
s.moveTo(0,3.95); s.lineTo(-0.25,3.43); s.lineTo(-1.15,2.0); s.lineTo(-1.55,0.7);
s.lineTo(-1.6,-1.75); s.lineTo(-1.42,-2.85); s.lineTo(-0.92,-3.34); s.lineTo(0.92,-3.34);
s.lineTo(1.42,-2.85); s.lineTo(1.6,-1.75); s.lineTo(1.55,0.7); s.lineTo(1.15,2.0); s.lineTo(0.25,3.43); s.closePath();
return s;
}
function createVessel(isEnemy) {
const group = new THREE.Group();
const hullMat = isEnemy ? mats.enemyHull : mats.hull;
const deckMat = isEnemy ? mats.enemyDeck : mats.deck;
const glassMat = isEnemy ? mats.enemyGlass : mats.glass;
const shape = hullShape();
const hullGeo = new THREE.ExtrudeGeometry(shape,{depth:0.72,bevelEnabled:true,bevelSegments:2,steps:1,bevelSize:0.07,bevelThickness:0.08,curveSegments:5});
hullGeo.rotateX(Math.PI/2);
hullGeo.translate(0,0.04,0);
const hull = new THREE.Mesh(hullGeo,hullMat); hull.castShadow=true; hull.receiveShadow=true; group.add(hull);
const topShape = hullShape();
const topGeo = new THREE.ShapeGeometry(topShape,5);
topGeo.rotateX(-Math.PI/2);
topGeo.scale(0.91,0.91,1);
topGeo.translate(0,0.055,0);
const deck = new THREE.Mesh(topGeo,deckMat); deck.castShadow=true; deck.receiveShadow=true; group.add(deck);
block(group,mats.rubber,[0,-0.56,-0.05],[1.0,0.24,5.3]);
if (!isEnemy) {
block(group,mats.orange,[0,-0.01,-2.95],[1.6,0.08,0.1]);
block(group,mats.orange,[-1.56,0.06,-1.05],[0.08,0.09,2.25]);
block(group,mats.orange,[1.56,0.06,-1.05],[0.08,0.09,2.25]);
}
block(group,mats.cabin,[0,0.62,-0.42],[1.75,1.13,1.8]);
block(group,glassMat,[0,1.28,0.4],[1.48,0.53,0.08],[-0.28,0,0]);
block(group,glassMat,[-0.88,0.95,-0.4],[0.07,0.53,1.25],[0,0,-0.15]);
block(group,glassMat,[0.88,0.95,-0.4],[0.07,0.53,1.25],[0,0,0.15]);
block(group,mats.deck,[0,1.22,-0.52],[1.95,0.14,2.0]);
if (!isEnemy) {
block(group,mats.rubber,[0,0.15,1.65],[0.95,0.34,1.0]);
block(group,mats.orange,[-0.8,0.32,-2.62],[0.48,0.5,0.42]);
block(group,mats.orange,[0.8,0.32,-2.62],[0.48,0.5,0.42]);
} else {
block(group,mats.rubber,[0,0.2,1.8],[0.84,0.35,0.8]);
block(group,mats.orange,[0,1.32,-0.55],[1.35,0.1,1.55]);
}
const railMat = isEnemy ? mats.enemyDeck : mats.steel;
for (const side of [-1,1]) {
for (const z of [-2.65,-1.75,0.1,1.7]) {
const w = z > 1 ? 0.95 : z < -2 ? 0.82 : 1.52;
cylinder(group,railMat,[side*w,0.34,z],[0.055,0.62,0.055],[0,0,0]);
}
block(group,railMat,[side*1.33,0.61,-1.15],[0.08,0.075,2.15]);
}
const mount = new THREE.Group();
mount.position.set(0,0.17,-1.55);
group.add(mount);
cylinder(mount,mats.gun,[0,0.15,0],[0.54,0.22,0.54],[0,0,0]);
const gunPivot = new THREE.Group(); gunPivot.position.set(0,0.47,0); mount.add(gunPivot);
cylinder(gunPivot,mats.steel,[0,0.1,0],[0.29,0.17,0.29],[0,0,0]);
block(gunPivot,mats.gun,[0,0.28,0.17],[0.25,0.25,0.9]);
block(gunPivot,mats.gun,[0,0.34,0.65],[0.12,0.12,1.05]);
block(gunPivot,mats.steel,[0,0.34,1.17],[0.14,0.14,0.16]);
block(gunPivot,mats.gun,[-0.22,0.47,-0.12],[0.055,0.42,0.055]);
block(gunPivot,mats.gun,[0.22,0.47,-0.12],[0.055,0.42,0.055]);
for (let i=0;i<3;i++) {
sphere(group,mats.rubber,[-1.45,0.22,-1.95+i*1.45],[0.23,0.23,0.43]);
sphere(group,mats.rubber,[1.45,0.22,-1.95+i*1.45],[0.23,0.23,0.43]);
}
if (!isEnemy) {
const antenna = cylinder(group,mats.steel,[0.66,1.9,-0.89],[0.035,1.08,0.035],[0,0,0.08]);
sphere(group,mats.orange,[0.66,2.44,-0.89],[0.08,0.08,0.08]);
block(group,mats.steel,[0.66,1.6,-0.89],[0.48,0.035,0.035]);
} else {
sphere(group,mats.orange,[0,1.65,-0.55],[0.11,0.11,0.11]);
}
return { group, gunPivot, mount };
}
const playerRig = createVessel(false);
const player = { rig:playerRig, pos:new THREE.Vector3(0,0,0), vel:new THREE.Vector3(), yaw:0, yawVel:0, y:1.1, yVel:0, pitch:0, pitchVel:0, roll:0, rollVel:0, health:100, invulnerable:0 };
scene.add(playerRig.group);
const enemyList = [];
let score = 0;
let started = false;
let gameOver = false;
let elapsed = 0;
let nextSpawn = 0;
let impactEvent = null;
let nextSwell = 22;
let mouseDown = false;
let fireTimer = 0;
let toastTimer = 0;
let cameraShake = 0;
let uiTimer = 0;
const keys = Object.create(null);
const projectiles = [];
const particles = [];
const wakes = [];
const bulletGeo = new THREE.SphereGeometry(0.095,8,6);
const bulletPlayerMat = new THREE.MeshBasicMaterial({ color:0xffd696, toneMapped:false });
const bulletEnemyMat = new THREE.MeshBasicMaterial({ color:0xff5d4a, toneMapped:false });
const particleMat = new THREE.MeshBasicMaterial({ color:0xd9fff5, transparent:true, opacity:0.78, depthWrite:false });
const wakeMat = new THREE.MeshBasicMaterial({ color:0xc9f5e8, transparent:true, opacity:0.2, depthWrite:false, side:THREE.DoubleSide });
const wakeGeo = new THREE.CircleGeometry(1,12);
const radar = document.querySelector('#radar');
const radarCtx = radar.getContext('2d');
const intro = document.querySelector('#intro');
const hud = document.querySelector('#hud');
const gameover = document.querySelector('#gameover');
const targetTag = document.querySelector('#targetTag');
const targetReadout = document.querySelector('#targetReadout');
const waveAlert = document.querySelector('#waveAlert');
const hitFlash = document.querySelector('#hitFlash');
const crosshair = document.querySelector('.crosshair');
function waveHeight(x,z,t) {
let h = 1.05 * Math.sin((x*0.81+z*0.43)/Math.hypot(0.81,0.43)*0.046 + t*0.72);
h += 0.66 * Math.sin((x*-0.37+z*0.93)/Math.hypot(-0.37,0.93)*0.083 - t*0.94 + 1.1);
h += 0.36 * Math.sin((x*0.98-z*0.21)/Math.hypot(0.98,-0.21)*0.133 + t*1.42 + 2.6);
h += 0.17 * Math.sin((x*-0.72-z*0.69)/Math.hypot(-0.72,-0.69)*0.238 - t*1.86);
if (impactEvent) {
const dx=x-impactEvent.x-impactEvent.dir.x*impactEvent.age*12;
const dz=z-impactEvent.z-impactEvent.dir.y*impactEvent.age*12;
const along=dx*impactEvent.dir.x+dz*impactEvent.dir.y;
const across=dx*(-impactEvent.dir.y)+dz*impactEvent.dir.x;
const env=Math.exp(-(along*along)/1500-(across*across)/4400);
h += impactEvent.amp*Math.sin(along*0.078)*env;
}
return h;
}
function forwardOf(yaw) { return new THREE.Vector3(Math.sin(yaw),0,Math.cos(yaw)); }
function rightOf(yaw) { return new THREE.Vector3(Math.cos(yaw),0,-Math.sin(yaw)); }
function shortestAngle(a) { return Math.atan2(Math.sin(a),Math.cos(a)); }
function addParticle(pos, velocity, color, life, scale) {
const material = color ? new THREE.MeshBasicMaterial({color,transparent:true,opacity:0.85,depthWrite:false}) : particleMat;
const mesh = new THREE.Mesh(sphereGeo,material);
mesh.position.copy(pos); mesh.scale.setScalar(scale || 0.12); scene.add(mesh);
particles.push({mesh,vel:velocity.clone(),life,maxLife:life,material,ownedMaterial:!!color});
}
function burst(pos, color, count, force) {
for (let i=0;i<count;i++) {
const v=new THREE.Vector3((Math.random()-.5)*force,Math.random()*force*.75,(Math.random()-.5)*force);
addParticle(pos,v,color,0.35+Math.random()*0.8,0.06+Math.random()*0.11);
}
}
function addWake(pos, size) {
const mesh=new THREE.Mesh(wakeGeo,wakeMat);
mesh.rotation.x=-Math.PI/2; mesh.position.set(pos.x,0.03,pos.z);
const s=size || .45; mesh.scale.set(s,s*1.8,1); scene.add(mesh);
wakes.push({mesh,life:2.4,maxLife:2.4,base:s});
}
function setVesselPose(vessel, isPlayer, dt) {
const group=vessel.rig.group;
group.position.set(vessel.pos.x,vessel.y,vessel.pos.z);
group.rotation.order='YXZ';
group.rotation.y=vessel.yaw;
group.rotation.x=vessel.pitch;
group.rotation.z=vessel.roll;
if (!isPlayer) {
vessel.rig.gunPivot.rotation.y = shortestAngle(Math.atan2(player.pos.x-vessel.pos.x,player.pos.z-vessel.pos.z)-vessel.yaw);
}
}
function spawnEnemy() {
const angle=Math.random()*Math.PI*2;
const radius=105+Math.random()*58;
const rig=createVessel(true);
const pos=new THREE.Vector3(player.pos.x+Math.sin(angle)*radius,0,player.pos.z+Math.cos(angle)*radius);
const dir=new THREE.Vector3(player.pos.x-pos.x,0,player.pos.z-pos.z).normalize();
const enemy={rig,pos,vel:dir.clone().multiplyScalar(3+Math.random()*2),yaw:Math.atan2(dir.x,dir.z),yawVel:0,y:1.0,yVel:0,pitch:0,pitchVel:0,roll:0,rollVel:0,health:3,fire:2.1+Math.random()*2.3,phase:Math.random()*6.28,radius:3.2};
enemyList.push(enemy); scene.add(rig.group);
}
function createShot(start, direction, friendly) {
const mesh=new THREE.Mesh(bulletGeo,friendly?bulletPlayerMat:bulletEnemyMat);
mesh.position.copy(start); scene.add(mesh);
const speed=friendly?112:39;
projectiles.push({mesh,vel:direction.clone().normalize().multiplyScalar(speed),friendly,life:friendly?4.8:4.0});
}
function getAimPoint() {
raycaster.setFromCamera(mouse,camera);
const d=raycaster.ray.direction;
if (Math.abs(d.y)<0.0001) return player.pos.clone().add(forwardOf(player.yaw).multiplyScalar(120));
const dist=(aimPlaneY-camera.position.y)/d.y;
if (dist<0 || dist>1100) return player.pos.clone().add(forwardOf(player.yaw).multiplyScalar(160));
const p=raycaster.ray.origin.clone().addScaledVector(d,dist);
p.y=waveHeight(p.x,p.z,elapsed)+0.55;
return p;
}
function firePlayer() {
if (!started || gameOver || fireTimer>0) return;
fireTimer=0.105;
const aim=getAimPoint();
const gun=player.rig.gunPivot;
const muzzle=gun.localToWorld(new THREE.Vector3(0,0.34,1.28));
const direction=aim.sub(muzzle).normalize();
createShot(muzzle,direction,true);
burst(muzzle,0xffd69a,2,1.2);
cameraShake=Math.max(cameraShake,0.035);
}
function fireEnemy(enemy) {
const start=enemy.rig.gunPivot.localToWorld(new THREE.Vector3(0,0.34,1.28));
const lead=player.pos.clone().addScaledVector(player.vel,1.25);
lead.y=player.y+0.45;
const direction=lead.sub(start).normalize();
createShot(start,direction,false);
burst(start,0xff7964,2,0.7);
}
function showToast(title, detail, duration) {
const toast=document.querySelector('#toast');
toast.innerHTML=title+(detail?'<span>'+detail+'</span>':'');
toast.classList.add('show'); toastTimer=duration||1.7;
}
function hurtPlayer(amount, pos) {
if (player.invulnerable>0 || gameOver) return;
player.health=Math.max(0,player.health-amount);
player.invulnerable=0.72;
cameraShake=0.4;
hitFlash.classList.add('show'); setTimeout(()=>hitFlash.classList.remove('show'),170);
if (pos) burst(pos,0xff755c,12,4.2);
if (player.health<=0) {
gameOver=true;
document.querySelector('#finalScore').textContent=String(score).padStart(2,'0');
gameover.classList.remove('hidden');
}
}
function destroyEnemy(enemy) {
const index=enemyList.indexOf(enemy);
if (index<0) return;
enemyList.splice(index,1);
const p=enemy.pos.clone(); p.y=enemy.y+0.8;
burst(p,0xff986c,22,6.8); burst(p,0xb9d5ce,16,4.2);
scene.remove(enemy.rig.group);
score++; nextSpawn=Math.max(nextSpawn,elapsed+2.4+Math.random()*2.4);
showToast('HOSTILE NEUTRALIZED','CONTACT CLEARED / '+String(score).padStart(2,'0')+' CONFIRMED',1.9);
}
function updatePlayer(dt) {
const f=forwardOf(player.yaw), r=rightOf(player.yaw);
let throttle=(keys.KeyW||keys.ArrowUp?1:0)-(keys.KeyS||keys.ArrowDown?1:0);
let steer=(keys.KeyD||keys.ArrowRight?1:0)-(keys.KeyA||keys.ArrowLeft?1:0);
const longSpeed=player.vel.dot(f);
const lateral=player.vel.dot(r);
const handbrake=!!keys.Space;
const thrust=handbrake?0:throttle*7.4;
const dragLong=0.22+0.014*Math.abs(longSpeed);
const dragLat=handbrake?8.4:3.0;
player.vel.addScaledVector(f,(thrust-dragLong*longSpeed)*dt);
player.vel.addScaledVector(r,-lateral*dragLat*dt);
if (handbrake) player.vel.multiplyScalar(Math.max(0,1-1.8*dt));
const speed=Math.hypot(player.vel.x,player.vel.z);
player.yawVel += steer*(0.36+Math.min(speed,22)*0.042)*Math.max(0.18,Math.min(1,Math.abs(longSpeed)/4+0.2))*dt;
player.yawVel *= Math.exp(-1.45*dt);
player.yaw += player.yawVel*dt;
player.pos.addScaledVector(player.vel,dt);
const front=player.pos.clone().addScaledVector(f,2.45);
const back=player.pos.clone().addScaledVector(f,-2.4);
const left=player.pos.clone().addScaledVector(r,-1.15);
const right=player.pos.clone().addScaledVector(r,1.15);
const centerH=waveHeight(player.pos.x,player.pos.z,elapsed);
const bowH=waveHeight(front.x,front.z,elapsed);
const sternH=waveHeight(back.x,back.z,elapsed);
const portH=waveHeight(left.x,left.z,elapsed);
const starboardH=waveHeight(right.x,right.z,elapsed);
const targetY=(centerH+bowH*.14+sternH*.14+portH*.11+starboardH*.11)*.5+0.88;
const verticalAccel=(targetY-player.y)*15.5-player.yVel*6.4;
player.yVel+=verticalAccel*dt; player.y+=player.yVel*dt;
const targetPitch=-Math.atan2(bowH-sternH,4.9);
const targetRoll=Math.atan2(starboardH-portH,2.3);
player.pitchVel+=((targetPitch-player.pitch)*10.5-player.pitchVel*4.4)*dt;
player.rollVel+=((targetRoll-player.roll)*10.5-player.rollVel*4.6)*dt;
player.pitch+=player.pitchVel*dt; player.roll+=player.rollVel*dt;
player.invulnerable=Math.max(0,player.invulnerable-dt);
if (handbrake) player.vel.multiplyScalar(Math.max(0,1-.35*dt));
setVesselPose(player,true,dt);
const aim=getAimPoint();
const muzzle=player.rig.mount.getWorldPosition(tmpV);
const aimDir=aim.sub(muzzle);
player.rig.gunPivot.rotation.y=shortestAngle(Math.atan2(aimDir.x,aimDir.z)-player.yaw);
player.rig.gunPivot.rotation.x=-Math.atan2(aimDir.y,Math.hypot(aimDir.x,aimDir.z));
const currentForward=forwardOf(player.yaw);
const movingBack=player.vel.dot(currentForward)<-0.4;
const speedKnots=Math.round(Math.hypot(player.vel.x,player.vel.z)*1.94384);
if (Math.abs(speed)>1.6 && Math.random()<dt*17) {
const stern=player.pos.clone().addScaledVector(currentForward,-3.15).addScaledVector(rightOf(player.yaw),(Math.random()-.5)*1.35);
addWake(stern,0.25+Math.min(speed*.018,.58));
}
for (const enemy of enemyList) {
if (enemy.pos.distanceTo(player.pos)<4.3) {
const n=enemy.pos.clone().sub(player.pos); n.y=0;
if (n.lengthSq()>0) n.normalize();
enemy.pos.addScaledVector(n,8*dt);
player.vel.addScaledVector(n,-3.5*dt);
if (Math.random()<dt*2) hurtPlayer(5,player.pos.clone().add(new THREE.Vector3(0,1,0)));
}
}
return {speedKnots,throttle,movingBack};
}
function updateEnemies(dt) {
for (const enemy of [...enemyList]) {
const toward=player.pos.clone().sub(enemy.pos); toward.y=0;
const dist=toward.length();
const desiredYaw=Math.atan2(toward.x,toward.z);
const turn=shortestAngle(desiredYaw-enemy.yaw);
enemy.yawVel+=THREE.MathUtils.clamp(turn,-1,1)*(0.7+Math.min(enemy.vel.length(),12)*0.045)*dt;
enemy.yawVel*=Math.exp(-1.8*dt); enemy.yaw+=enemy.yawVel*dt;
const forward=forwardOf(enemy.yaw), right=rightOf(enemy.yaw);
const speed=enemy.vel.dot(forward), slip=enemy.vel.dot(right);
const targetSpeed=dist>48?11:dist>26?7.2:4.4;
enemy.vel.addScaledVector(forward,THREE.MathUtils.clamp(targetSpeed-speed,-4,4)*1.35*dt);
enemy.vel.addScaledVector(right,-slip*2.4*dt);
enemy.pos.addScaledVector(enemy.vel,dt);
const bob=waveHeight(enemy.pos.x,enemy.pos.z,elapsed);
enemy.yVel+=((bob+0.76-enemy.y)*12-enemy.yVel*5.6)*dt; enemy.y+=enemy.yVel*dt;
const ahead=enemy.pos.clone().addScaledVector(forward,2.3);
const behind=enemy.pos.clone().addScaledVector(forward,-2.1);
const rollTarget=Math.atan2(waveHeight(enemy.pos.x+right.x,enemy.pos.z+right.z,elapsed)-waveHeight(enemy.pos.x-right.x,enemy.pos.z-right.z,elapsed),2);
const pitchTarget=-Math.atan2(waveHeight(ahead.x,ahead.z,elapsed)-waveHeight(behind.x,behind.z,elapsed),4.4);
enemy.rollVel+=((rollTarget-enemy.roll)*8-enemy.rollVel*4)*dt; enemy.roll+=enemy.rollVel*dt;
enemy.pitchVel+=((pitchTarget-enemy.pitch)*8-enemy.pitchVel*4)*dt; enemy.pitch+=enemy.pitchVel*dt;
setVesselPose(enemy,false,dt);
enemy.fire-=dt;
if (enemy.fire<=0 && dist<190) { fireEnemy(enemy); enemy.fire=3.4+Math.random()*1.55; }
}
if (enemyList.length<4 && elapsed>=nextSpawn && started && !gameOver) {
spawnEnemy(); nextSpawn=elapsed+5.5;
}
}
function updateProjectiles(dt) {
for (let i=projectiles.length-1;i>=0;i--) {
const shot=projectiles[i];
shot.life-=dt; shot.vel.y-= (shot.friendly?4.2:2.4)*dt;
shot.mesh.position.addScaledVector(shot.vel,dt);
if (shot.friendly) {
for (const enemy of [...enemyList]) {
const hitPoint=enemy.pos.clone(); hitPoint.y=enemy.y+0.9;
if (shot.mesh.position.distanceTo(hitPoint)<2.55) {
enemy.health--;
const impact=shot.mesh.position.clone(); burst(impact,0xffc27e,7,2.6);
if (enemy.health<=0) destroyEnemy(enemy);
else showToast('TARGET HIT','HOSTILE HULL / '+enemy.health+' ARMOR SEGMENTS',0.8);
shot.life=0; break;
}
}
} else {
const target=player.pos.clone(); target.y=player.y+0.65;
if (shot.mesh.position.distanceTo(target)<2.55) {
hurtPlayer(12,shot.mesh.position.clone()); shot.life=0;
}
}
const p=shot.mesh.position;
if (p.y<waveHeight(p.x,p.z,elapsed)+0.05) {
burst(new THREE.Vector3(p.x,Math.max(0,p.y),p.z),shot.friendly?0xd8f5e8:0xff8b6e,4,1.8);
shot.life=0;
}
if (shot.life<=0) { scene.remove(shot.mesh); projectiles.splice(i,1); }
}
}
function updateEffects(dt) {
for (let i=particles.length-1;i>=0;i--) {
const p=particles[i]; p.life-=dt; p.vel.y-=3.7*dt; p.mesh.position.addScaledVector(p.vel,dt);
p.material.opacity=Math.max(0,p.life/p.maxLife)*0.85;
p.mesh.scale.multiplyScalar(1+dt*.6);
if (p.life<=0) { scene.remove(p.mesh); if(p.ownedMaterial)p.material.dispose(); particles.splice(i,1); }
}
for (let i=wakes.length-1;i>=0;i--) {
const w=wakes[i]; w.life-=dt; w.mesh.material=wakeMat;
w.mesh.material.opacity=0.2*Math.max(0,w.life/w.maxLife);
w.mesh.scale.multiplyScalar(1+dt*.42);
if (w.life<=0) { scene.remove(w.mesh); wakes.splice(i,1); }
}
}
function updateSwell(dt) {
if (impactEvent) {
impactEvent.age+=dt;
if (impactEvent.age>19) { impactEvent=null; waterUniforms.uImpactAmp.value=0; }
else {
waterUniforms.uImpactAge.value=impactEvent.age;
const dx=player.pos.x-impactEvent.x-impactEvent.dir.x*impactEvent.age*12;
const dz=player.pos.z-impactEvent.z-impactEvent.dir.y*impactEvent.age*12;
const along=dx*impactEvent.dir.x+dz*impactEvent.dir.y;
const across=dx*(-impactEvent.dir.y)+dz*impactEvent.dir.x;
if (!impactEvent.hit && Math.abs(along)<7 && Math.abs(across)<46 && impactEvent.age>2) {
impactEvent.hit=true;
const push=new THREE.Vector3(impactEvent.dir.x,0,impactEvent.dir.y);
player.vel.addScaledVector(push,3.8);
player.rollVel+= (Math.random()-.5)*0.8;
cameraShake=Math.max(cameraShake,0.22);
burst(player.pos.clone().add(new THREE.Vector3(0,1.2,0)),0xbce7e1,18,4.2);
showToast('HEAVY SWELL','BRACE / BOW INTO THE WAVE',2.0);
}
}
}
if (elapsed>=nextSwell && !impactEvent) {
const a=Math.random()*Math.PI*2;
const dir=new THREE.Vector2(Math.sin(a),Math.cos(a));
impactEvent={x:player.pos.x-dir.x*135,z:player.pos.z-dir.y*135,dir,age:0,amp:2.5,hit:false};
waterUniforms.uImpactCenter.value.set(impactEvent.x,impactEvent.z);
waterUniforms.uImpactDir.value.copy(dir);
waterUniforms.uImpactAge.value=0; waterUniforms.uImpactAmp.value=impactEvent.amp;
nextSwell=elapsed+28+Math.random()*13;
showToast('SWELL WARNING','LARGE SET / APPROX. 10 SECONDS',2.4);
}
if (impactEvent) {
const dist=135-impactEvent.age*12;
waveAlert.classList.toggle('visible',dist<108 && dist>4);
document.querySelector('#waveEta').textContent='00:'+String(Math.max(0,Math.ceil(dist/12))).padStart(2,'0');
} else waveAlert.classList.remove('visible');
}
function updateCamera(dt) {
const f=forwardOf(player.yaw);
const desired=new THREE.Vector3(player.pos.x-f.x*22,player.y+11.3,player.pos.z-f.z*22);
if (cameraShake>0) {
desired.x+=(Math.random()-.5)*cameraShake; desired.y+=(Math.random()-.5)*cameraShake;
cameraShake=Math.max(0,cameraShake-dt*1.8);
}
camera.position.lerp(desired,1-Math.exp(-3.4*dt));
const look=player.pos.clone().addScaledVector(f,11);
look.y=player.y+0.6;
camera.lookAt(look);
waterUniforms.uCamera.value.copy(camera.position);
sea.position.set(player.pos.x,0,player.pos.z);
const aim=getAimPoint();
targetReadout.textContent='MK-04 · '+String(Math.round(aim.distanceTo(player.pos))).padStart(3,'0')+' M';
let nearest=null,nearestD=Infinity;
for(const e of enemyList) { const d=e.pos.distanceTo(aim); if(d<nearestD){nearestD=d;nearest=e;} }
if(nearest && nearestD<22) {
const projected=nearest.pos.clone().add(new THREE.Vector3(0,3,0)).project(camera);
targetTag.style.display=projected.z<1?'block':'none';
targetTag.style.left=((projected.x*.5+.5)*window.innerWidth)+'px';
targetTag.style.top=((-projected.y*.5+.5)*window.innerHeight)+'px';
} else targetTag.style.display='none';
}
function updateUI(dt, driving) {
uiTimer-=dt;
if (uiTimer>0) return;
uiTimer=.11;
const health=Math.max(0,player.health);
document.querySelector('#healthValue').textContent=String(Math.round(health)).padStart(3,'0')+'%';
const fill=document.querySelector('#healthFill'); fill.style.width=health+'%';
if(health<35) fill.style.background='linear-gradient(90deg,#ff5e4d,#ffbd73)';
document.querySelector('#scoreValue').textContent=String(score).padStart(2,'0');
document.querySelector('#enemyValue').textContent=String(enemyList.length).padStart(2,'0');
document.querySelector('#speedNum').textContent=String(driving.speedKnots).padStart(2,'0');
document.querySelector('#speedFill').style.width=Math.min(100,driving.speedKnots/42*100)+'%';
const throttle=document.querySelector('#throttleValue');
throttle.textContent=driving.movingBack?'REVERSE':driving.throttle>0?'AHEAD':driving.throttle<0?'REVERSE':keys.Space?'BRAKING':'IDLE';
document.querySelector('#missionClock').textContent=String(Math.floor(elapsed/60)).padStart(2,'0')+':'+String(Math.floor(elapsed%60)).padStart(2,'0');
const f=forwardOf(player.yaw);
const toward=enemyList.length?enemyList[0].pos.clone().sub(player.pos):f;
const bearing=(Math.atan2(toward.x,toward.z)*180/Math.PI+360)%360;
document.querySelector('#bearingText').textContent='CONTACT '+String(Math.round(bearing)).padStart(3,'0')+'°';
document.querySelector('#compassNeedle').style.transform='rotate('+bearing+'deg)';
drawRadar();
}
function drawRadar() {
const c=radarCtx, size=radar.width, center=size/2, scale=size/300;
c.clearRect(0,0,size,size);
c.save(); c.translate(center,center);
c.strokeStyle='rgba(179,224,212,.14)'; c.lineWidth=1*scale;
for(const r of [36,70,106]) { c.beginPath(); c.arc(0,0,r*scale,0,Math.PI*2); c.stroke(); }
c.beginPath(); c.moveTo(-center,0); c.lineTo(center,0); c.moveTo(0,-center); c.lineTo(0,center); c.stroke();
const f=forwardOf(player.yaw);
for(const enemy of enemyList) {
const dx=enemy.pos.x-player.pos.x, dz=enemy.pos.z-player.pos.z;
const x=(dx*f.x+dz*f.z)*.66*scale, y=-(dx*f.z-dz*f.x)*.66*scale;
if(Math.hypot(x,y)>center-8) continue;
c.fillStyle='#ff7664'; c.shadowColor='#ff6756'; c.shadowBlur=10*scale;
c.beginPath(); c.arc(x,y,3.4*scale,0,Math.PI*2); c.fill();
}
c.shadowBlur=0; c.fillStyle='#8bffeb'; c.strokeStyle='rgba(139,255,235,.6)';
c.beginPath(); c.moveTo(0,-8*scale); c.lineTo(5*scale,6*scale); c.lineTo(0,3*scale); c.lineTo(-5*scale,6*scale); c.closePath(); c.fill();
c.restore();
}
function resetGame() {
for (const e of enemyList) scene.remove(e.rig.group);
enemyList.length=0;
for (const s of projectiles) scene.remove(s.mesh); projectiles.length=0;
for (const p of particles) { scene.remove(p.mesh); if(p.ownedMaterial)p.material.dispose(); } particles.length=0;
for (const w of wakes) scene.remove(w.mesh); wakes.length=0;
player.pos.set(0,0,0); player.vel.set(0,0,0); player.yaw=0; player.yawVel=0; player.y=1.1; player.yVel=0; player.pitch=0; player.pitchVel=0; player.roll=0; player.rollVel=0; player.health=100; player.invulnerable=0;
elapsed=0; score=0; nextSpawn=7; nextSwell=22; impactEvent=null; waterUniforms.uImpactAmp.value=0; waterUniforms.uImpactAge.value=0; gameOver=false; fireTimer=0;
for(let i=0;i<3;i++) spawnEnemy();
gameover.classList.add('hidden');
intro.classList.add('hidden'); hud.classList.add('active');
started=true; canvas.focus();
showToast('INTERCEPTOR UNDERWAY','HOSTILE VESSELS / WEAPONS FREE',2.2);
}
document.querySelector('#deploy').addEventListener('click',resetGame);
document.querySelector('#retry').addEventListener('click',resetGame);
canvas.addEventListener('pointermove',e=>{
const rect=canvas.getBoundingClientRect();
mouse.x=((e.clientX-rect.left)/rect.width)*2-1;
mouse.y=-((e.clientY-rect.top)/rect.height)*2+1;
crosshair.style.left=e.clientX+'px';
crosshair.style.top=e.clientY+'px';
targetReadout.style.left=(e.clientX+36)+'px';
targetReadout.style.top=(e.clientY+24)+'px';
});
canvas.addEventListener('pointerdown',e=>{
if(e.button===0){mouseDown=true;firePlayer();}
if(e.button===2)e.preventDefault();
});
window.addEventListener('pointerup',()=>mouseDown=false);
canvas.addEventListener('contextmenu',e=>e.preventDefault());
window.addEventListener('keydown',e=>{
if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code))e.preventDefault();
keys[e.code]=true;
if(e.code==='Enter'&&!started)resetGame();
});
window.addEventListener('keyup',e=>keys[e.code]=false);
window.addEventListener('blur',()=>{mouseDown=false;for(const k in keys)keys[k]=false;});
window.addEventListener('resize',()=>{
camera.aspect=window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,1.8)); renderer.setSize(window.innerWidth,window.innerHeight,false);
});
let bootTime=0;
function animate() {
requestAnimationFrame(animate);
const dt=Math.min(clock.getDelta(),0.04);
bootTime+=dt;
if(started&&!gameOver) {
elapsed+=dt;
fireTimer=Math.max(0,fireTimer-dt);
if(mouseDown)firePlayer();
const driving=updatePlayer(dt);
updateEnemies(dt);
updateProjectiles(dt);
updateSwell(dt);
updateEffects(dt);
if(toastTimer>0){toastTimer-=dt;if(toastTimer<=0)document.querySelector('#toast').classList.remove('show');}
updateCamera(dt);
updateUI(dt,driving);
} else {
if(started&&gameOver) elapsed+=dt;
if(!started) {
const f=forwardOf(0);
camera.position.lerp(new THREE.Vector3(-17,8, -19+Math.sin(bootTime*.08)*2),1-Math.exp(-1.4*dt));
camera.lookAt(0,0,9);
sea.position.set(0,0,0); waterUniforms.uCamera.value.copy(camera.position);
waterUniforms.uTime.value=bootTime;
player.y=waveHeight(0,0,bootTime)+0.88+Math.sin(bootTime*.6)*.04;
player.rig.group.position.set(0,player.y,0);
player.rig.group.rotation.set(0,0,Math.sin(bootTime*.32)*.025);
for(let i=0;i<3;i++) {
if(!enemyList[i])spawnEnemy();
enemyList[i].pos.set(Math.sin(bootTime*.11+i*2.1)*(54+i*14),0,44+Math.cos(bootTime*.14+i*2.1)*(34+i*8));
enemyList[i].yaw=Math.atan2(-enemyList[i].pos.x,-enemyList[i].pos.z);
enemyList[i].y=waveHeight(enemyList[i].pos.x,enemyList[i].pos.z,bootTime)+.8;
setVesselPose(enemyList[i],false,dt);
}
} else updateEffects(dt);
}
waterUniforms.uTime.value=started?elapsed:bootTime;
renderer.render(scene,camera);
}
animate();
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.