Showcase

Boat game, built by GPT-6 Luna

Hostedvia Codex CLIOpenRouter
Running live in your browserNew tab
Show the code the model wrote
HTML59,760 characters
<!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> &nbsp;·&nbsp; 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 &nbsp;·&nbsp; 000 M</div>
      <div id="waveAlert">⚠ &nbsp; LARGE SWELL APPROACHING &nbsp; <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&nbsp; ↗</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&nbsp; ↻</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

6 more
Compare side by side

One email when a new model lands on the board or in the showcase. Unsubscribe anytime.