Skip to content

Instantly share code, notes, and snippets.

@senko
Created September 6, 2026 08:50
Show Gist options
  • Select an option

  • Save senko/0bb3989991e97f855056a0e8a9004c4a to your computer and use it in GitHub Desktop.

Select an option

Save senko/0bb3989991e97f855056a0e8a9004c4a to your computer and use it in GitHub Desktop.
Flying simulator by GPT-6 Astra
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#eff0e6">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 32 32%27%3E%3Crect width=%2732%27 height=%2732%27 rx=%276%27 fill=%27%233e5944%27/%3E%3Cpath d=%27M16 4v24M5 12h22M7 16h18M12 26h8%27 stroke=%27%23f3f3e9%27 stroke-width=%272%27/%3E%3C/svg%3E">
<title>Dawn Patrol — A little sky, a little courage.</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700&family=DM+Sans:wght@400;450;500;550;600;650;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--paper:#f3f3e9;--ink:#303e34;--muted:#7b8475;--line:#d8dccb;--green:#3e5944;--lime:#d6e3a6;--red:#aa5141;--mono:'IBM Plex Mono',monospace;--sans:'DM Sans',sans-serif;--display:'Barlow Condensed',sans-serif}
*{box-sizing:border-box}html,body{margin:0;width:100%;height:100%;overflow:hidden}body{font-family:var(--sans);background:#b7c9c6;color:var(--ink);font-size:13px}button,input{font:inherit}button{cursor:pointer;color:inherit}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #819555;outline-offset:5px}button{touch-action:manipulation}button:disabled{cursor:wait;opacity:.55}svg{display:block;width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon{display:inline-flex;align-items:center;justify-content:center}.sprite{position:absolute;width:0;height:0;overflow:hidden}.topbar{position:absolute;top:0;left:0;width:100%;height:80px;background:var(--paper);display:flex;align-items:center;justify-content:space-between;padding:0 34px;z-index:5;border-bottom:1px solid #c5cdba}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;color:inherit}.brand-mark{height:42px;width:48px;display:grid;place-items:center}.brand-mark svg{width:46px;height:40px;stroke-width:1.25}.wordmark{font:600 25px/.9 var(--display);letter-spacing:1.8px}.wordmark span{display:block}.edition{font:9px var(--mono);letter-spacing:1.5px;color:var(--muted);padding-left:18px;margin-left:9px;border-left:1px solid var(--line);line-height:1.7}.nav{position:absolute;left:50%;transform:translateX(-50%);height:100%;display:flex;gap:34px;align-items:center}.nav button{height:100%;position:relative;font-size:12px;font-weight:600;border:0;background:none;padding:0 2px;color:var(--muted)}.nav button.active{color:var(--ink)}.nav button.active:after{content:'';position:absolute;height:3px;background:var(--green);bottom:0;left:0;right:0}.nav button:hover{color:var(--green)}.top-actions{display:flex;align-items:center;gap:9px}.icon-button{width:35px;height:35px;border:1px solid #d9dece;background:transparent;display:grid;place-items:center;border-radius:5px;transition:.2s}.icon-button:hover{background:#e5e9da;border-color:#bac3ae}.icon-button svg{height:17px;width:17px}.status-pill{display:flex;align-items:center;gap:7px;font:10px var(--mono);letter-spacing:.5px;margin-left:14px}.status-dot{height:6px;width:6px;border-radius:100%;background:#5a7652;box-shadow:0 0 0 3px #5a765215}.world{position:absolute;inset:80px 0 104px;overflow:hidden;background:linear-gradient(#a7c4cc,#e2e5cf)}#scene{width:100%;height:100%;display:block}.world:after{content:'';position:absolute;inset:0;pointer-events:none;box-shadow:inset 0 -60px 90px -70px #37453155}.hud{position:absolute;inset:0;pointer-events:none}.hud button,.hud input{pointer-events:auto}.eyebrow{font:10px var(--mono);letter-spacing:1.5px;text-transform:uppercase}.mission-panel{position:absolute;left:34px;top:30px;width:256px}.mission-number{display:flex;align-items:center;gap:9px;color:#4b604c}.mission-number:before{content:'';height:5px;width:5px;background:#4b604c}.mission-panel h1{font:500 43px/1.02 var(--display);letter-spacing:.2px;margin:13px 0 10px}.mission-panel .description{font-size:12px;line-height:1.7;color:#53624e;margin:0;max-width:205px}.mission-progress{margin-top:23px;border-top:1px solid #6171533b;padding-top:15px;display:flex;align-items:center;gap:10px;font-size:11px}.mission-progress svg{height:17px;width:17px}.mission-progress b{font:500 12px var(--mono);margin-left:auto}.mission-progress small{font-size:10px;color:#7b8672}.objective-dots{display:flex;gap:5px;margin:11px 0 0 27px}.objective-dots i{width:35px;height:3px;background:#62745435}.objective-dots i.done{background:#486441}.compass{position:absolute;top:28px;left:50%;transform:translateX(-50%);width:342px;text-align:center;color:#536852}.compass-track{display:flex;justify-content:space-between;font:10px var(--mono);position:relative;padding:0 2px 13px}.compass-track:after{position:absolute;content:'';bottom:0;left:0;right:0;height:6px;background:repeating-linear-gradient(90deg,#627b5b80 0px,#627b5b80 1px,transparent 1px,transparent 14px)}.compass-track .major{color:#334931;font-weight:600}.compass-heading{position:relative;display:inline-block;margin-top:9px;font:500 12px var(--mono)}.compass-heading:before{content:'';position:absolute;top:-13px;left:50%;transform:translateX(-50%);border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid #3d533b}.weather{position:absolute;right:34px;top:28px;text-align:right;color:#53654f}.weather-top{display:flex;align-items:center;justify-content:flex-end;gap:9px;font-size:11px;font-weight:500;margin-bottom:9px}.weather-top svg{height:18px;width:18px}.weather .eyebrow{font-size:9px;color:#6a7860}.weather-line{margin-top:15px;display:flex;justify-content:flex-end;gap:16px;font:10px var(--mono)}.weather-line span{display:flex;align-items:center;gap:5px}.weather-line svg{width:14px;height:14px}.coordinate{position:absolute;right:34px;top:124px;font:9px var(--mono);letter-spacing:1px;color:#5c7251;writing-mode:vertical-rl}.reticle{position:absolute;left:50%;top:43%;width:39px;height:39px;transform:translate(-50%,-50%);opacity:.6}.reticle:before,.reticle:after{content:'';position:absolute;background:#fafbeb;box-shadow:0 0 1px #263c23}.reticle:before{width:1px;height:7px;left:19px;top:16px}.reticle:after{height:1px;width:7px;left:16px;top:19px}.reticle-ring{position:absolute;inset:0;border:1px solid #f9fbda95;border-radius:50%}.reticle-ring:before,.reticle-ring:after{content:'';position:absolute;top:18px;width:8px;height:1px;background:#f6f8e2}.reticle-ring:before{left:-11px}.reticle-ring:after{right:-11px}.reticle.firing{opacity:1;filter:drop-shadow(0 0 5px #ffdc7b)}.target-marker{position:absolute;display:none;text-align:center;transform:translate(-50%,-50%);color:#8b493c;font:9px var(--mono);pointer-events:none}.target-marker .target-box{width:22px;height:22px;border:1px solid #aa625aaa;transform:rotate(45deg);margin:0 auto 12px;position:relative}.target-marker .target-box:after{content:'';height:3px;width:3px;border-radius:50%;background:#ad685b;position:absolute;left:9px;top:9px}.target-marker .target-label{white-space:nowrap;text-shadow:0 1px 2px #e8e8d3}.target-marker small{display:block;margin-top:4px;font-size:8px;color:#7e6854}.plane-tag{display:none;position:absolute;left:50%;top:73%;transform:translateX(-50%);font:9px var(--mono);color:#fffef2b3;letter-spacing:2px}.ready-card{position:absolute;bottom:30px;left:50%;transform:translateX(-50%);text-align:center;min-width:250px}.ready-card .eyebrow{color:#f3f5df;font-size:9px;text-shadow:0 1px 3px #27362380;margin-bottom:11px}.primary{border:1px solid #475a3a;background:var(--green);color:#f4f4e6;padding:15px 24px;border-radius:4px;font-size:12px;font-weight:500;display:inline-flex;justify-content:center;align-items:center;gap:37px;box-shadow:0 3px 10px #2a3c2825;transition:background .2s,transform .2s}.primary:hover{background:#2f4935;transform:translateY(-1px)}.primary svg{width:16px;height:16px}.primary .key{color:#d9e3c5;border-color:#81906e;font:9px var(--mono);padding:3px 5px}.primary-label{display:flex;align-items:center;gap:9px}.ready-card .hint{margin:9px 0 0;color:#eff1df;font-size:10px;text-shadow:0 1px 2px #21352280}.key{display:inline-flex;align-items:center;justify-content:center;min-width:20px;height:20px;border:1px solid #9ba88c70;border-radius:3px;font:10px var(--mono);padding:0 5px;background:#e9eddb35;box-shadow:0 1px 1px #31432b08}.controls-card{position:absolute;right:34px;bottom:30px;width:207px;background:#f0f2dfda;border:1px solid #fff6;border-radius:5px;padding:15px 16px 13px;backdrop-filter:blur(12px);box-shadow:0 4px 20px #2b3a2410}.controls-title{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.controls-title .eyebrow{font-size:9px}.controls-title svg{height:13px;width:13px;color:#83906f}.controls-row{display:flex;align-items:center;justify-content:space-between;margin-top:7px;font-size:10px;color:#67755a}.controls-keys{display:flex;gap:4px}.controls-row .key{min-width:23px;height:19px;color:#425738;background:#f5f6e9}.controls-footer{border-top:1px solid #acb99c55;margin-top:12px;padding-top:10px;display:flex;justify-content:space-between;font-size:9px;color:#7b876e}.controls-footer button{border:0;padding:0;background:none;font-size:9px;color:#4b6341;display:flex;gap:3px;align-items:center}.controls-footer svg{height:11px;width:11px}.radar-panel{position:absolute;left:34px;bottom:28px;display:flex;gap:17px;align-items:flex-end}.radar{position:relative;width:136px;height:136px;border:1px solid #dce5bc99;border-radius:50%;background:#2b4239b3;box-shadow:0 0 0 5px #ecf0d00a;backdrop-filter:blur(6px)}#radar{width:100%;height:100%;display:block;border-radius:50%}.radar-label{position:absolute;left:50%;top:6px;transform:translateX(-50%);font:8px var(--mono);color:#d9e2bc}.radar-scale{position:absolute;left:50%;bottom:7px;transform:translateX(-50%);font:7px var(--mono);color:#d9e2bc99;white-space:nowrap}.radar-info{color:#f1f0d6;padding-bottom:2px;text-shadow:0 1px 2px #344b28aa}.radar-info .eyebrow{font-size:8px;letter-spacing:1px;opacity:.7}.radar-info p{font:500 14px var(--display);letter-spacing:.8px;margin:4px 0 14px}.radar-info .radar-status{font:8px var(--mono);display:flex;gap:5px;align-items:center}.radar-status:before{content:'';height:4px;width:4px;background:#d1e49a;border-radius:100%}.flight-message{position:absolute;left:50%;bottom:34px;transform:translateX(-50%);text-align:center;display:none;color:#f3f2db;text-shadow:0 1px 3px #263c25}.flight-message strong{font:500 17px var(--display);letter-spacing:1px;display:block}.flight-message span{font:9px var(--mono);display:block;margin-top:7px;opacity:.75}.warning{position:absolute;top:105px;left:50%;transform:translateX(-50%);font:500 11px var(--mono);letter-spacing:1px;background:#ae503ddd;color:#fff3dc;padding:9px 16px;border-radius:3px;display:none}.toast{position:absolute;top:148px;left:50%;transform:translateX(-50%) translateY(-8px);font:500 11px var(--mono);background:#f2f4e4ed;border:1px solid #fff7;padding:11px 18px;border-radius:4px;color:#41583c;opacity:0;transition:.25s}.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}.damage-overlay{position:absolute;inset:0;box-shadow:inset 0 0 150px 30px #ae3e32;opacity:0;pointer-events:none;transition:opacity .15s}.instrument-bar{height:104px;position:absolute;bottom:0;left:0;width:100%;background:var(--paper);display:flex;align-items:center;padding:0 34px;gap:0;z-index:5;border-top:1px solid #d2d7c4}.aircraft-info{width:240px;min-width:195px;display:flex;gap:14px;align-items:center}.aircraft-symbol{height:43px;width:43px;display:grid;place-items:center;border:1px solid #cbd3bd;border-radius:50%;color:#57724a}.aircraft-symbol svg{width:30px;height:30px;transform:rotate(-35deg)}.aircraft-info .eyebrow{font-size:8px;color:var(--muted);letter-spacing:1.3px}.aircraft-name{margin-top:5px;font-size:13px;font-weight:600}.aircraft-type{font:8px var(--mono);color:#909780;margin-top:4px;letter-spacing:.5px}.gauges{flex:1;height:47px;display:flex;align-items:stretch;justify-content:space-evenly}.gauge{padding:0 20px;border-left:1px solid var(--line);min-width:100px;flex:1}.gauge .eyebrow{font-size:8px;color:#7d8873;letter-spacing:1px;display:flex;align-items:center;gap:6px}.gauge .eyebrow svg{width:11px;height:11px}.gauge-value{font:500 27px/1.1 var(--display);margin-top:6px;letter-spacing:.5px;white-space:nowrap}.gauge-value small{font:9px var(--mono);color:#7f8b70;margin-left:5px;letter-spacing:0}.gauge.health{max-width:163px}.health-track{height:4px;display:flex;gap:3px;margin-top:11px}.health-track i{height:100%;background:#617a4e;flex:1;transition:background .3s}.health-track i.empty{background:#d9decb}.health-text{font:9px var(--mono);margin-top:7px;color:#657b53}.bar-actions{border-left:1px solid var(--line);margin-left:10px;padding-left:24px;display:flex;align-items:center;gap:15px}.bar-actions .icon-button{width:32px;height:32px}.pause-label{font:9px var(--mono);color:#7c8771}.throttle-line{height:3px;background:#d9dfcd;margin-top:7px;width:70px}.throttle-line span{display:block;height:100%;width:72%;background:#738556}.modal-backdrop{position:absolute;inset:80px 0 104px;z-index:10;background:#263a3260;backdrop-filter:blur(9px);display:none;align-items:center;justify-content:center;padding:22px}.modal-backdrop.open{display:flex}.modal{background:var(--paper);border:1px solid #fff9;border-radius:8px;padding:31px;max-width:510px;width:100%;box-shadow:0 16px 90px #152e3935;max-height:100%;overflow:auto}.modal-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:13px}.modal-header .eyebrow{font-size:9px;color:#81906f}.modal h2{font:500 36px/1.1 var(--display);margin:8px 0 13px}.modal p{font-size:12px;color:#768168;line-height:1.8}.modal .primary{margin-top:20px;gap:24px}.key-grid{display:grid;grid-template-columns:1fr 1fr;gap:13px 20px;margin-top:22px}.key-item{display:flex;align-items:center;gap:10px;font-size:11px}.key-item>span:first-child{min-width:61px;display:flex;gap:4px}.modal-tip{border-top:1px solid var(--line);padding-top:15px;margin-top:24px!important;font-size:11px!important}.setting{display:flex;align-items:center;justify-content:space-between;padding:17px 0;border-bottom:1px solid var(--line);gap:12px}.setting strong{font-size:12px;font-weight:500;display:block}.setting small{display:block;font-size:10px;color:var(--muted);margin-top:5px}.switch{width:37px;height:21px;appearance:none;border-radius:12px;background:#cbd1bd;position:relative;cursor:pointer;flex-shrink:0;margin:0}.switch:checked{background:#547049}.switch:after{content:'';position:absolute;top:3px;left:3px;height:15px;width:15px;border-radius:50%;background:#f5f5e9;transition:.15s}.switch:checked:after{transform:translateX(16px)}input[type=range]{width:100px;accent-color:#5e7750}.log-empty{padding:19px;border:1px dashed #bfcab1;border-radius:4px;margin-top:20px}.log-empty p{margin:0}.log-entry{display:flex;justify-content:space-between;padding:14px 0;border-bottom:1px solid var(--line);font-size:11px}.log-entry small{display:block;color:var(--muted);margin-top:5px}.log-result{font:10px var(--mono);color:#647e50}.result-stats{display:flex;justify-content:space-between;margin:25px 0 8px;border-top:1px solid var(--line);padding-top:20px}.result-stats div{font:500 28px var(--display)}.result-stats small{display:block;font:8px var(--mono);color:var(--muted);margin-top:7px;text-transform:uppercase;letter-spacing:1px}.mobile-controls{display:none;position:absolute;left:18px;right:18px;bottom:18px;justify-content:space-between;pointer-events:none}.mobile-cluster{display:flex;gap:7px;align-items:center}.mobile-dpad{display:grid;grid-template-columns:repeat(3,37px);grid-template-rows:repeat(2,37px);gap:5px}.touch-button{pointer-events:auto;border:1px solid #edf1d7a0;border-radius:5px;background:#eff3daac;color:#3e5437;width:37px;height:37px;display:grid;place-items:center;font-size:16px;user-select:none;touch-action:none}.touch-button[data-key=Space]{width:55px;height:55px;border-radius:50%;background:#4d6549dd;color:#ecedd6}.touch-button.pressed{background:#cddda3}.loading-note{position:absolute;left:50%;top:42%;transform:translate(-50%,-50%);background:#f1f3e6e6;padding:14px 20px;border-radius:4px;text-align:center;font:11px var(--mono);color:#53684a;max-width:80%}.hidden{display:none!important}
body.cockpit .mission-panel,body.cockpit .mission-number,body.cockpit .mission-panel .description,body.cockpit .compass,body.cockpit .compass-track .major,body.cockpit .weather,body.cockpit .weather .eyebrow,body.cockpit .coordinate{color:#ecefda}body.cockpit .mission-progress{border-color:#e1e8c850}body.cockpit .mission-progress small{color:#c7d3b4}body.cockpit .mission-number:before{background:#d2dfb6}body.cockpit .objective-dots i{background:#e1e8c845}body.cockpit .objective-dots i.done{background:#c2d99e}body.cockpit .compass-track:after{opacity:.8;filter:brightness(2)}
@media(min-width:1600px){.mission-panel{top:40px;left:44px}.mission-panel h1{font-size:49px}.controls-card{right:44px;bottom:38px;width:225px}.radar-panel{left:44px;bottom:36px}.radar{width:155px;height:155px}.ready-card{bottom:36px}.topbar,.instrument-bar{padding-left:44px;padding-right:44px}.aircraft-info{width:285px}.gauge{padding:0 30px}}
@media(max-width:1050px){.topbar,.instrument-bar{padding-left:22px;padding-right:22px}.edition{display:none}.nav{gap:23px}.mission-panel{left:24px;width:210px}.mission-panel h1{font-size:37px}.weather{right:24px}.controls-card{right:24px;width:186px}.radar-panel{left:24px}.radar-info{display:none}.compass{width:265px}.aircraft-info{width:185px;min-width:185px;gap:10px}.gauge{padding:0 15px;min-width:85px}.gauge.ammo{display:none}.bar-actions{padding-left:15px;gap:9px}.status-pill{font-size:9px;margin-left:7px}}
@media(max-width:760px){.topbar{height:64px;padding:0 18px}.brand{gap:6px}.brand-mark{width:33px}.brand-mark svg{width:33px}.wordmark{font-size:21px}.nav{position:static;transform:none;gap:20px;margin-left:auto;margin-right:20px}.nav button{font-size:10px}.nav button:nth-child(2){display:none}.top-actions{gap:5px}.top-actions .icon-button{width:30px;height:30px}.status-pill{display:none}.world{inset:64px 0 85px}.mission-panel{top:22px;left:20px;width:174px}.mission-panel h1{font-size:33px;margin:10px 0}.mission-panel .eyebrow{font-size:8px}.mission-panel .description{font-size:10px;max-width:155px}.mission-progress{margin-top:14px;padding-top:10px;font-size:10px;gap:6px}.mission-progress b{font-size:10px}.objective-dots{margin-top:8px}.objective-dots i{width:26px}.compass{top:24px;left:auto;right:20px;transform:none;width:165px}.compass-track{font-size:8px}.compass-heading{font-size:10px}.weather{top:92px;right:20px}.weather-top{font-size:10px}.weather .eyebrow{font-size:7px}.weather-line{font-size:8px;gap:10px;margin-top:11px}.coordinate{display:none}.radar-panel{left:20px;bottom:22px}.radar{width:97px;height:97px}.radar-scale{font-size:6px}.controls-card{display:none}.ready-card{bottom:26px;left:auto;right:20px;transform:none;min-width:190px}.ready-card .eyebrow{font-size:8px}.primary{padding:13px 17px;gap:25px;font-size:11px}.ready-card .hint{font-size:8px}.instrument-bar{height:85px;padding:0 15px}.aircraft-info{width:46px;min-width:46px}.aircraft-info>div:last-child{display:none}.aircraft-symbol{width:33px;height:33px}.aircraft-symbol svg{height:23px;width:23px}.gauges{height:42px}.gauge{padding:0 12px;min-width:61px}.gauge .eyebrow{font-size:7px;letter-spacing:.3px}.gauge .eyebrow svg{display:none}.gauge-value{font-size:23px}.gauge-value small{font-size:7px;margin-left:2px}.gauge.health{min-width:66px}.health-track{margin-top:10px;gap:2px}.health-text{font-size:7px}.bar-actions{margin-left:0;padding-left:10px}.pause-label{display:none}.throttle-line{width:43px}.modal-backdrop{inset:64px 0 85px}.modal{padding:24px}.modal h2{font-size:31px}.key-grid{gap:12px}.key-item{font-size:10px;gap:6px}.key-item>span:first-child{min-width:50px}.flight-message{bottom:145px;min-width:220px}.flight-message strong{font-size:15px}.flight-message span{font-size:8px}.plane-tag{display:none}.warning{top:210px;font-size:9px;white-space:nowrap}.toast{top:250px;white-space:nowrap;font-size:9px}body.playing .mobile-controls{display:flex}body.playing .radar-panel{bottom:122px}.reticle{top:46%}}
@media(max-width:430px){.nav{gap:10px;margin-right:12px}.nav button{font-size:9px}.top-actions .icon-button{width:27px;height:27px}.gauge{padding:0 9px}.gauge.health{display:none}.gauge.throttle{border-right:1px solid var(--line)}.bar-actions{border:0}.weather-top{font-size:9px}.weather{top:96px}.compass{width:133px}.mission-panel{width:164px}.ready-card{right:17px;min-width:181px}.radar-panel{left:18px}.key-grid{grid-template-columns:1fr 1fr}.modal{padding:20px}.modal-tip{font-size:10px!important}}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<svg class="sprite" aria-hidden="true"><defs>
<symbol id="i-plane" viewBox="0 0 32 32"><path d="M16 2v27M13 6h6l1 9 10 4v3l-11-2-1 6 5 3v1l-7-1-7 1v-1l5-3-1-6-11 2v-3l10-4z"/><path d="M7 12h18M5 9h22M11 9v3m10-3v3"/></symbol>
<symbol id="i-brand" viewBox="0 0 48 42"><path d="m24 5 2 12 15 5v4l-15-2-1 9 6 3v2l-7-2-7 2v-2l6-3-1-9-15 2v-4l15-5zM7 12h34M9 15h30M15 12v3m18-3v3"/><path d="M4 19 2 16m42 3 2-3M24 2v2"/></symbol>
<symbol id="i-volume" viewBox="0 0 24 24"><path d="m11 5-5 4H3v6h3l5 4zM15 8c3 2 3 6 0 8m3-11c5 4 5 10 0 14"/></symbol>
<symbol id="i-muted" viewBox="0 0 24 24"><path d="m11 5-5 4H3v6h3l5 4zM16 9l5 6m0-6-5 6"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="m9 3-.5 3-2 1-3-.5-1 3 2 2v2l-2 2 1 3 3-.5 2 1 .5 3h4l.5-3 2-1 3 .5 1-3-2-2v-2l2-2-1-3-3 .5-2-1-.5-3z"/><circle cx="11" cy="12" r="3"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M5 12h14m-6-6 6 6-6 6"/></symbol>
<symbol id="i-target" viewBox="0 0 24 24"><circle cx="12" cy="12" r="7"/><path d="M12 2v5m0 10v5M2 12h5m10 0h5"/><circle cx="12" cy="12" r="2"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1 1m12 12 1 1M5 19l1-1M18 6l1-1"/></symbol>
<symbol id="i-wind" viewBox="0 0 24 24"><path d="M3 8h13c4 0 4-5 1-5M2 12h18c3 0 3 5 0 5M5 16h7c4 0 4 5 1 5"/></symbol>
<symbol id="i-eye" viewBox="0 0 24 24"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-keyboard" viewBox="0 0 24 24"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M5 9h1m3 0h1m3 0h1m3 0h1M5 12h1m3 0h1m3 0h1m3 0h1M7 16h10"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M8 5v14M16 5v14" stroke-width="3"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="m8 5 11 7-11 7z"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="m6 6 12 12M6 18 18 6"/></symbol>
<symbol id="i-speed" viewBox="0 0 24 24"><path d="M4 18a9 9 0 1 1 16 0M12 13l5-5M5 13h2m5-8v2m6 6h2"/><circle cx="12" cy="13" r="1"/></symbol>
<symbol id="i-alt" viewBox="0 0 24 24"><path d="m3 19 6-10 4 5 3-9 5 14zM6 5l3-3 3 3M9 2v5"/></symbol>
<symbol id="i-engine" viewBox="0 0 24 24"><path d="M3 9h4l3-4h7v4h4v10H7l-4-4zM10 2h5M2 10v6m20-5v6"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="m12 2 8 3v7c0 5-8 10-8 10S4 17 4 12V5zM8 12l3 3 5-6"/></symbol>
<symbol id="i-ammo" viewBox="0 0 24 24"><path d="M5 20V8l3-6 3 6v12zm9 0V8l3-6 3 6v12zM5 16h6m3 0h6"/></symbol>
</defs></svg>
<header class="topbar">
<a class="brand" href="#" aria-label="Dawn Patrol home" id="brand"><span class="brand-mark"><svg><use href="#i-brand"/></svg></span><div class="wordmark">DAWN<span>PATROL</span></div><div class="edition">FLIGHT SIMULATOR<br>EST. 1917</div></a>
<nav class="nav" aria-label="Main navigation"><button class="active" id="mission-nav">The mission</button><button id="log-nav">Flight log</button><button id="help-nav">How to fly <span style="font-size:10px;margin-left:3px">↗</span></button></nav>
<div class="top-actions"><button class="icon-button" id="sound-button" aria-label="Enable sound" title="Toggle sound (M)"><svg><use id="sound-icon" href="#i-muted"/></svg></button><button class="icon-button" id="settings-button" aria-label="Flight settings" title="Flight settings"><svg><use href="#i-settings"/></svg></button><div class="status-pill"><span class="status-dot"></span><span id="flight-status">READY FOR FLIGHT</span></div></div>
</header>
<main class="world" id="world" aria-label="3D biplane flight simulator">
<canvas id="scene" aria-label="A biplane above the Western Front"></canvas>
<div class="hud">
<section class="mission-panel"><div class="mission-number eyebrow">THE WESTERN FRONT · 1917</div><h1>Over the valley.</h1><p class="description">The morning is yours.<br>Keep the skies that way.</p><div class="mission-progress"><svg><use href="#i-target"/></svg><span>Enemy aircraft</span><b><span id="kills">0</span> <small>/ 4</small></b></div><div class="objective-dots" aria-hidden="true"><i></i><i></i><i></i><i></i></div></section>
<div class="compass"><div class="compass-track" id="compass-track"><span>W</span><span>300</span><span>330</span><span class="major">N</span><span>30</span><span>60</span><span>E</span></div><span class="compass-heading" id="heading">000°</span></div>
<div class="weather"><div class="weather-top"><svg><use href="#i-sun"/></svg>06:42 AM <span style="opacity:.45">/</span> Clear skies</div><div class="eyebrow">SOMME, FRANCE</div><div class="weather-line"><span><svg><use href="#i-wind"/></svg>W 6 kn</span><span><svg><use href="#i-eye"/></svg>10 km</span></div></div>
<div class="coordinate">50° 01′ N &nbsp; 02° 48′ E &nbsp; — &nbsp; SECTOR 07</div>
<div class="reticle" id="reticle"><div class="reticle-ring"></div></div>
<div id="target-markers"></div><div class="plane-tag">SOPWITH CAMEL · F.1</div>
<div class="radar-panel"><div class="radar"><canvas id="radar" width="272" height="272" aria-label="Radar showing nearby enemy aircraft"></canvas><span class="radar-label">N</span><span class="radar-scale">2 KM RANGE</span></div><div class="radar-info"><div class="eyebrow">PATROL AREA</div><p>THE SOMME VALLEY</p><div class="radar-status" id="radar-status">4 CONTACTS NEARBY</div></div></div>
<div class="ready-card" id="ready-card"><div class="eyebrow">A LITTLE SKY. A LITTLE COURAGE.</div><button class="primary" id="start-button" disabled><span class="primary-label"><svg><use href="#i-plane"/></svg><span id="start-label">Preparing aircraft</span></span><span class="key">↵</span></button><p class="hint">Headphones on. Eyes on the horizon.</p></div>
<div class="flight-message" id="flight-message"><strong>MAKE THE SKY YOUR OWN.</strong><span>Find a target · Get on its tail · Hold SPACE to fire</span></div>
<aside class="controls-card"><div class="controls-title"><span class="eyebrow">AT THE CONTROLS</span><svg><use href="#i-keyboard"/></svg></div><div class="controls-row"><span>Pitch</span><span class="controls-keys"><kbd class="key">W</kbd><kbd class="key">S</kbd></span></div><div class="controls-row"><span>Roll</span><span class="controls-keys"><kbd class="key">A</kbd><kbd class="key">D</kbd></span></div><div class="controls-row"><span>Throttle</span><span class="controls-keys"><kbd class="key">Shift</kbd><kbd class="key">Ctrl</kbd></span></div><div class="controls-row"><span>Machine guns</span><kbd class="key">Space</kbd></div><div class="controls-footer"><span><kbd style="font:inherit">C</kbd> change view</span><button id="all-controls">All controls <svg><use href="#i-arrow"/></svg></button></div></aside>
<div class="mobile-controls" id="mobile-controls"><div class="mobile-dpad"><span></span><button class="touch-button" data-key="KeyW" aria-label="Pitch down">↓</button><span></span><button class="touch-button" data-key="KeyA" aria-label="Bank left">←</button><button class="touch-button" data-key="KeyS" aria-label="Pitch up">↑</button><button class="touch-button" data-key="KeyD" aria-label="Bank right">→</button></div><div class="mobile-cluster"><div style="display:grid;gap:5px"><button class="touch-button" data-key="ShiftLeft" aria-label="Increase throttle">+</button><button class="touch-button" data-key="ControlLeft" aria-label="Decrease throttle">−</button></div><button class="touch-button" data-key="Space" aria-label="Fire machine guns"><svg><use href="#i-target"/></svg></button></div></div>
<div class="warning" id="warning" role="status"></div><div class="toast" id="toast" role="status"></div><div class="damage-overlay" id="damage-overlay"></div><div class="loading-note" id="loading-note">Checking the skies…</div>
</div>
</main>
<footer class="instrument-bar" aria-label="Flight instruments"><div class="aircraft-info"><div class="aircraft-symbol"><svg><use href="#i-plane"/></svg></div><div><div class="eyebrow">YOUR AIRCRAFT</div><div class="aircraft-name">Sopwith Camel</div><div class="aircraft-type">F.1 · ROYAL FLYING CORPS</div></div></div><div class="gauges"><div class="gauge"><div class="eyebrow"><svg><use href="#i-speed"/></svg>AIRSPEED</div><div class="gauge-value"><span id="speed">142</span><small>km/h</small></div></div><div class="gauge"><div class="eyebrow"><svg><use href="#i-alt"/></svg>ALTITUDE</div><div class="gauge-value"><span id="altitude">380</span><small>m</small></div></div><div class="gauge throttle"><div class="eyebrow"><svg><use href="#i-engine"/></svg>THROTTLE</div><div class="gauge-value" style="font-size:22px"><span id="throttle">72</span><small>%</small></div><div class="throttle-line"><span id="throttle-fill"></span></div></div><div class="gauge ammo"><div class="eyebrow"><svg><use href="#i-ammo"/></svg>AMMUNITION</div><div class="gauge-value"><span id="ammo">500</span><small>/ 500</small></div></div><div class="gauge health"><div class="eyebrow"><svg><use href="#i-shield"/></svg>AIRCRAFT</div><div class="health-track" id="health-track"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div class="health-text" id="health-text">100% · AIRFRAME GOOD</div></div></div><div class="bar-actions"><span class="pause-label" id="pause-label">PAUSE</span><button class="icon-button" id="pause-button" aria-label="Pause flight" title="Pause flight (Esc)"><svg><use id="pause-icon" href="#i-pause"/></svg></button></div></footer>
<div class="modal-backdrop" id="modal-backdrop"><section class="modal" id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title"></section></div>
<script type="module">
let THREE;
try { THREE = await import('https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'); }
catch (error) { document.getElementById('loading-note').textContent='The ground crew couldn’t load the 3D engine. Check your connection and refresh.'; throw error; }
const $ = id => document.getElementById(id);
const clamp = (x,a,b) => Math.max(a,Math.min(b,x));
const lerp = (a,b,t) => a+(b-a)*t;
const TAU = Math.PI*2;
let seed=1917;
function random(){seed=(seed*1664525+1013904223)>>>0;return seed/4294967296;}
const rand=(a,b)=>a+random()*(b-a);
const scene=new THREE.Scene();scene.background=new THREE.Color('#b8cfd0');scene.fog=new THREE.FogExp2('#c6d5c6',.00017);
const camera=new THREE.PerspectiveCamera(55,1,.4,14000);
let renderer;
try {renderer=new THREE.WebGLRenderer({canvas:$('scene'),antialias:true,powerPreference:'high-performance'});}
catch(error){$('loading-note').textContent='WebGL is unavailable. Enable hardware acceleration in your browser to take flight.';throw error;}
renderer.setPixelRatio(Math.min(devicePixelRatio,1.7));renderer.outputColorSpace=THREE.SRGBColorSpace;renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=1.02;
scene.add(new THREE.HemisphereLight('#fff6d9','#789274',1.5));const sunlight=new THREE.DirectionalLight('#fff4db',1.75);sunlight.position.set(-900,1600,700);scene.add(sunlight);
const materialCache=new Map();
const mat=(c,extra={})=>{const key=c+JSON.stringify(extra);if(!materialCache.has(key))materialCache.set(key,new THREE.MeshStandardMaterial({color:c,roughness:.88,flatShading:true,...extra}));return materialCache.get(key);};
const palette={grass:mat('#84956a'),darkGrass:mat('#697e54'),trunk:mat('#635c42'),pine:mat('#526c4b'),pineLight:mat('#6c8255'),roof:mat('#98725d'),wall:mat('#d7ceb0'),wood:mat('#65513b'),metal:mat('#45504a',{metalness:.4,roughness:.45}),canvas:mat('#c4b582'),olive:mat('#73794e'),red:mat('#984c3d'),black:mat('#303c35'),cream:mat('#eee1b9')};
function mesh(geo,material,parent=scene,x=0,y=0,z=0){const m=new THREE.Mesh(geo,material);m.position.set(x,y,z);parent.add(m);return m;}
const box=(x,y,z,m,p,px=0,py=0,pz=0)=>mesh(new THREE.BoxGeometry(x,y,z),m,p,px,py,pz);
function rod(a,b,r,material,parent){const d=new THREE.Vector3().subVectors(b,a);const m=mesh(new THREE.CylinderGeometry(r,r,d.length(),5),material,parent);m.position.copy(a).add(b).multiplyScalar(.5);m.quaternion.setFromUnitVectors(new THREE.Vector3(0,1,0),d.normalize());return m;}
function riverX(z){return 430+Math.sin(z*.00077)*520+Math.sin(z*.0017+1)*120;}
function terrainHeight(x,z){const r=Math.abs(x-riverX(z));const valley=1-Math.exp(-r*r/160000);let hills=35+65*Math.sin(x*.0011+.7)*Math.cos(z*.00085)+38*Math.sin(x*.0022+z*.0015)+22*Math.cos(z*.0027-x*.0013);return 6+Math.max(2,hills)*valley;}
// A continuous, faceted landscape; the same height function is used for collision.
const terrainGeo=new THREE.PlaneGeometry(18000,18000,180,180);terrainGeo.rotateX(-Math.PI/2);const tp=terrainGeo.attributes.position;for(let i=0;i<tp.count;i++)tp.setY(i,terrainHeight(tp.getX(i),tp.getZ(i)));terrainGeo.computeVertexNormals();const terrainFlat=terrainGeo.toNonIndexed();const colors=[];const baseColor=new THREE.Color();for(let i=0;i<terrainFlat.attributes.position.count;i+=3){const x=terrainFlat.attributes.position.getX(i),z=terrainFlat.attributes.position.getZ(i);const n=Math.sin(Math.floor(x/340)*13.45+Math.floor(z/430)*7.87);baseColor.setHSL(.239+n*.019,.24+random()*.08,.30+random()*.07);for(let j=0;j<3;j++)colors.push(baseColor.r,baseColor.g,baseColor.b);}terrainFlat.setAttribute('color',new THREE.Float32BufferAttribute(colors,3));mesh(terrainFlat,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,flatShading:true}));terrainGeo.dispose();
// River, a narrow shore, and flat farm plots conform to the hills.
function riverRibbon(width,material,offset){const v=[],indices=[];for(let i=0;i<=300;i++){const z=-9000+i*60;const x=riverX(z);v.push(x-width,offset,z,x+width,offset,z);if(i<300){const a=i*2;indices.push(a,a+2,a+1,a+1,a+2,a+3);}}const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(v,3));g.setIndex(indices);g.computeVertexNormals();mesh(g,material);}
riverRibbon(86,mat('#c3bc8d'),7.3);riverRibbon(70,mat('#81aead',{metalness:.12,roughness:.42}),8.4);
const fieldColors=['#b7ad70','#a1a16b','#9fa775','#bac18a','#8a9b6d','#b8b088','#909360'];
for(let i=0;i<155;i++){const x=rand(-3800,3800),z=rand(-5500,2500),w=rand(100,340),d=rand(130,390);if(Math.abs(x-riverX(z))<w+100)continue;const g=new THREE.PlaneGeometry(w,d,3,3);g.rotateX(-Math.PI/2);const p=g.attributes.position;for(let j=0;j<p.count;j++)p.setY(j,terrainHeight(x+p.getX(j),z+p.getZ(j))+1.15);g.computeVertexNormals();mesh(g,mat(fieldColors[i%fieldColors.length]),scene,x,0,z);if(i%3===0){for(let j=0;j<5;j++){const zz=z+(j-2)*d/6;const path=[];for(let k=0;k<5;k++){const xx=x-w/2+k*w/4;path.push(new THREE.Vector3(xx,terrainHeight(xx,zz)+1.65,zz));}const lines=new THREE.Line(new THREE.BufferGeometry().setFromPoints(path),new THREE.LineBasicMaterial({color:'#727c53',transparent:true,opacity:.24}));scene.add(lines);}}}
function road(points,width=7,color='#c7b995'){const v=[],ind=[];for(let i=0;i<points.length;i++){const p=points[i],next=points[Math.min(i+1,points.length-1)],prev=points[Math.max(0,i-1)];const dx=next[0]-prev[0],dz=next[1]-prev[1],len=Math.hypot(dx,dz)||1;for(const s of [-1,1]){const x=p[0]-dz/len*width*s,z=p[1]+dx/len*width*s;v.push(x,terrainHeight(x,z)+1.4,z);}if(i<points.length-1){const a=i*2;ind.push(a,a+1,a+2,a+1,a+3,a+2);}}const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.Float32BufferAttribute(v,3));g.setIndex(ind);g.computeVertexNormals();mesh(g,mat(color));}
const mainRoad=[];for(let z=-6000;z<4000;z+=80)mainRoad.push([riverX(z)-260+Math.sin(z*.002)*55,z]);road(mainRoad,7);
for(let i=0;i<6;i++){const z=-i*900+350;const arr=[];for(let x=-2800;x<2900;x+=70)arr.push([x,z+Math.sin(x*.002+i)*110]);road(arr,4);}
// Instanced trees keep the woodland inexpensive to render.
const trees=[];for(let i=0;i<4700;i++){const x=rand(-6500,6500),z=rand(-7600,4700);if(Math.abs(x-riverX(z))<115)continue;const density=Math.sin(x*.002+z*.001)*Math.cos(z*.0017);if(density<-.2&&random()>.14)continue;trees.push({x,z,h:rand(9,24),s:rand(.7,1.35)});}
const trunkInst=new THREE.InstancedMesh(new THREE.CylinderGeometry(.9,1.5,1,4),palette.trunk,trees.length);const leavesInst=new THREE.InstancedMesh(new THREE.ConeGeometry(1,1,5),palette.pine,trees.length*2);const dummy=new THREE.Object3D();trees.forEach((t,i)=>{const y=terrainHeight(t.x,t.z);dummy.position.set(t.x,y+t.h*.28,t.z);dummy.scale.set(t.s,t.h*.55,t.s);dummy.rotation.set(0,0,0);dummy.updateMatrix();trunkInst.setMatrixAt(i,dummy.matrix);for(let j=0;j<2;j++){dummy.position.set(t.x,y+t.h*(.6+j*.22),t.z);dummy.scale.set(t.h*(.32-j*.065),t.h*.79,t.h*(.32-j*.065));dummy.rotation.y=i*2.4;dummy.updateMatrix();leavesInst.setMatrixAt(i*2+j,dummy.matrix);leavesInst.setColorAt(i*2+j,new THREE.Color().setHSL(.245+random()*.025,.20,.25+random()*.10));}});scene.add(trunkInst,leavesInst);
// Small farmsteads, hangars, and a field airstrip.
function house(x,z,s=1){const g=new THREE.Group();g.position.set(x,terrainHeight(x,z),z);g.rotation.y=rand(-.15,.15);scene.add(g);box(15*s,10*s,21*s,palette.wall,g,0,5*s,0);const roof=mesh(new THREE.CylinderGeometry(12*s,12*s,18*s,3),palette.roof,g,0,12*s,0);roof.rotation.set(Math.PI/2,Math.PI/2,0);box(3*s,6*s,.3,palette.wood,g,0,3*s,10.6*s);box(2*s,5*s,2*s,palette.wall,g,4*s,15*s,2*s);for(const dx of [-4,4])box(2.5*s,2.5*s,.3,palette.black,g,dx*s,6*s,10.65*s);}
for(let v=0;v<9;v++){const z=450-v*620,x=riverX(z)-430;for(let i=0;i<7;i++)house(x+(i%3)*41+rand(-8,8),z+Math.floor(i/3)*51,rand(.7,1.2));}
const stripX=-700,stripZ=850;const strip= new THREE.PlaneGeometry(90,640,1,10);strip.rotateX(-Math.PI/2);for(let i=0;i<strip.attributes.position.count;i++){const p=strip.attributes.position;p.setY(i,terrainHeight(stripX+p.getX(i),stripZ+p.getZ(i))+1.3);}strip.computeVertexNormals();mesh(strip,mat('#aaa57b'),scene,stripX,0,stripZ);for(let i=0;i<3;i++)house(stripX-105,stripZ+i*65,2);
// Low-poly ridgelines layer into the morning haze.
for(let i=0;i<42;i++){const x=-8500+i*415,z=-6200-rand(0,2400),h=rand(440,1400),r=rand(700,1450);const peak=mesh(new THREE.ConeGeometry(r,h,rand(4,7)|0,1),mat(i%3===0?'#8caaa1':'#94aea4'),scene,x,h/2-50,z);peak.rotation.y=rand(0,TAU);peak.scale.z=rand(.5,.9);}
const cloudMat=new THREE.MeshBasicMaterial({color:'#f0f0df',transparent:true,opacity:.73,depthWrite:false});for(let i=0;i<25;i++){const g=new THREE.Group();g.position.set(rand(-6500,6500),rand(1200,2100),rand(-7600,2000));for(let j=0;j<4;j++){const c=mesh(new THREE.IcosahedronGeometry(rand(60,130),1),cloudMat,g,j*105,rand(-10,20),rand(-35,35));c.scale.set(1.9,.44,1);}scene.add(g);}
// Merge static geometry by material so each aircraft uses a handful of draw calls.
function batchStatic(root,excluded=null){
root.updateMatrixWorld(true);const inverse=root.matrixWorld.clone().invert(),groups=new Map();
root.traverse(o=>{if(!o.isMesh||o.isInstancedMesh||o.material.transparent||o.material.vertexColors||o.children.length)return;if(excluded&&(o===excluded||o.parent===excluded))return;const list=groups.get(o.material)||[];list.push(o);groups.set(o.material,list);});
for(const [material,objects] of groups){if(objects.length<2)continue;const geos=objects.map(o=>{const g=o.geometry.index?o.geometry.toNonIndexed():o.geometry.clone();g.applyMatrix4(new THREE.Matrix4().multiplyMatrices(inverse,o.matrixWorld));return g;});const count=geos.reduce((n,g)=>n+g.attributes.position.count,0),positions=new Float32Array(count*3),normals=new Float32Array(count*3);let offset=0;for(const g of geos){positions.set(g.attributes.position.array,offset);normals.set(g.attributes.normal.array,offset);offset+=g.attributes.position.array.length;g.dispose();}const combined=new THREE.BufferGeometry();combined.setAttribute('position',new THREE.BufferAttribute(positions,3));combined.setAttribute('normal',new THREE.BufferAttribute(normals,3));mesh(combined,material,root);for(const o of objects){o.removeFromParent();o.geometry.dispose();}}
}
batchStatic(scene);
// A model assembled from canvas wings, bracing wires, a rotary engine and markings.
function makePlane(enemy=false){const g=new THREE.Group(),bodyMat=enemy?palette.red:palette.olive,wingMat=enemy?palette.red:palette.canvas;
const fuselage=mesh(new THREE.CylinderGeometry(.79,.2,6.8,9),bodyMat,g,0,0,.15);fuselage.rotation.x=-Math.PI/2;
const cowl=mesh(new THREE.CylinderGeometry(.85,.84,1.05,12),palette.metal,g,0,0,-3.2);cowl.rotation.x=Math.PI/2;
for(let i=0;i<9;i++){const ang=i*TAU/9;box(.21,.24,.12,palette.black,g,Math.cos(ang)*.6,Math.sin(ang)*.6,-3.74);}
function wing(y,z,w){const geo=new THREE.BoxGeometry(w,.13,1.9,8,1,1);const wing=mesh(geo,wingMat,g,0,y,z);const edges=new THREE.LineSegments(new THREE.EdgesGeometry(geo,30),new THREE.LineBasicMaterial({color:enemy?'#74372f':'#807b57',transparent:true,opacity:.32}));wing.add(edges);for(const side of [-1,1]){for(let i=1;i<7;i++)box(.021,.008,1.84,enemy?palette.wood:palette.olive,g,side*i*.82,y+.071,z);if(!enemy){for(const [r,col] of [[.65,'#394e65'],[.45,'#e4dcc3'],[.23,'#a75142']]){const m=mesh(new THREE.CircleGeometry(r,24),mat(col),g,side*(w/2-1.2),y+.08+(1-r)*.01,z);m.rotation.x=-Math.PI/2;}}else{box(1.2,.014,.36,palette.cream,g,side*(w/2-1.2),y+.08,z);box(.36,.015,1.2,palette.cream,g,side*(w/2-1.2),y+.083,z);}}}
wing(1.55,-.93,12.7);wing(-.6,-.6,11.9);
for(const side of [-1,1]){for(const z of [-1.52,.05]){rod(new THREE.Vector3(side*4.1,-.55,z+.12),new THREE.Vector3(side*4.35,1.52,z),.048,palette.wood,g);rod(new THREE.Vector3(side*.63,.4,z-.15),new THREE.Vector3(side*1.2,1.51,z-.1),.043,palette.metal,g);}for(const z of [-1.48,-.03]){rod(new THREE.Vector3(side*1.1,-.5,z),new THREE.Vector3(side*4.32,1.51,z),.012,palette.metal,g);rod(new THREE.Vector3(side*4.1,-.55,z),new THREE.Vector3(side*1.2,1.51,z),.012,palette.metal,g);}}
const tail=mesh(new THREE.SphereGeometry(1,12,6),wingMat,g,0,.1,3.1);tail.scale.set(2.1,.085,.8);const fin=mesh(new THREE.SphereGeometry(1,10,8),bodyMat,g,0,.61,3.05);fin.scale.set(.085,.95,.8);if(!enemy){box(.19,1.19,.23,palette.cream,g,0,.68,3.48);box(.20,1.04,.18,palette.red,g,0,.68,3.67);}
const cockpit=mesh(new THREE.SphereGeometry(.46,12,8),palette.black,g,0,.56,.5);cockpit.scale.set(.85,.3,1.4);const pilot=mesh(new THREE.SphereGeometry(.23,8,6),palette.wood,g,0,.85,.75);mesh(new THREE.SphereGeometry(.16,8,6),palette.canvas,g,0,.88,.59);
const windshield=mesh(new THREE.SphereGeometry(.35,8,5,0,Math.PI),new THREE.MeshStandardMaterial({color:'#b5d7d1',transparent:true,opacity:.48,side:THREE.DoubleSide,roughness:.1}),g,0,.77,-.05);windshield.rotation.y=Math.PI;
for(const s of [-1,1]){box(.12,.12,1.25,palette.black,g,s*.29,.69,-1.75);rod(new THREE.Vector3(s*.4,-.5,-1.8),new THREE.Vector3(s*1.25,-1.75,-1.2),.065,palette.metal,g);rod(new THREE.Vector3(s*.4,-.4,.3),new THREE.Vector3(s*1.25,-1.75,-1.2),.055,palette.metal,g);const wheel=mesh(new THREE.CylinderGeometry(.46,.46,.21,12),palette.black,g,s*1.26,-1.72,-1.2);wheel.rotation.z=Math.PI/2;const hub=mesh(new THREE.CylinderGeometry(.21,.21,.23,10),palette.canvas,g,s*1.26,-1.72,-1.2);hub.rotation.z=Math.PI/2;}
rod(new THREE.Vector3(-1.3,-1.72,-1.2),new THREE.Vector3(1.3,-1.72,-1.2),.065,palette.metal,g);rod(new THREE.Vector3(0,-.2,2.65),new THREE.Vector3(0,-.9,3.4),.065,palette.wood,g);
const prop=new THREE.Group();prop.position.z=-3.83;g.add(prop);const blade=mesh(new THREE.SphereGeometry(1,8,6),palette.wood,prop);blade.scale.set(.12,1.58,.065);const spinner=mesh(new THREE.SphereGeometry(.16,8,6),palette.metal,g,0,0,-3.92);
const propDisc=mesh(new THREE.CircleGeometry(1.62,32),new THREE.MeshBasicMaterial({color:'#e2d4a7',transparent:true,opacity:.09,side:THREE.DoubleSide,depthWrite:false}),g,0,0,-3.85);
g.userData={prop,propDisc};batchStatic(g,prop);scene.add(g);return g;}
const playerModel=makePlane();
const state={mode:'ready',speed:39.45,throttle:.72,health:100,ammo:500,kills:0,time:0,shots:0,hits:0,pitch:0,yaw:0,roll:0,pitchRate:0,rollRate:0,position:new THREE.Vector3(-480,380,480),velocity:new THREE.Vector3(0,0,-39.45),camera:'chase',sound:false,assist:true,sensitivity:1};
const enemies=[],bullets=[],particles=[],keys=new Set();let viewMode=0,modalKind=null,modalReturn='ready',lastFocus=null,lastFire=0,elapsed=0,hudTick=0,toastTimer=0,damageFlash=0,resultSaved=false;
let flightLog=[];try{flightLog=JSON.parse(localStorage.getItem('dawn-patrol-log')||'[]');if(!Array.isArray(flightLog))flightLog=[];}catch{}
const enemyConfigs=[{x:-476,y:383,z:-90,yaw:0},{x:150,y:420,z:-1060,yaw:.27},{x:-1090,y:500,z:-1700,yaw:-.3},{x:580,y:360,z:-2100,yaw:.6}];
function spawnEnemies(){for(const e of enemies){scene.remove(e.model);e.model.traverse(o=>{if(o.geometry)o.geometry.dispose();});e.marker.remove();}enemies.length=0;enemyConfigs.forEach((c,i)=>{const model=makePlane(true);model.position.set(c.x,c.y,c.z);const marker=document.createElement('div');marker.className='target-marker';marker.innerHTML='<div class="target-box"></div><div class="target-label">ALBATROS D.III</div><small></small>';$('target-markers').append(marker);enemies.push({model,position:model.position,yaw:c.yaw,pitch:0,roll:0,speed:31+i*2,health:100,alive:true,marker,cooldown:3+i,phase:i*1.5,index:i});});}
spawnEnemies();
const forward=new THREE.Vector3(),right=new THREE.Vector3(),up=new THREE.Vector3(),tmp=new THREE.Vector3(),lookAt=new THREE.Vector3(),camDesired=new THREE.Vector3();const orient=new THREE.Euler(0,0,0,'YXZ');
function setPlaneTransform(){playerModel.position.copy(state.position);orient.set(state.pitch,state.yaw,state.roll);playerModel.quaternion.setFromEuler(orient);forward.set(0,0,-1).applyQuaternion(playerModel.quaternion);right.set(1,0,0).applyQuaternion(playerModel.quaternion);up.set(0,1,0).applyQuaternion(playerModel.quaternion);}
setPlaneTransform();
function resize(){const w=$('world').clientWidth,h=$('world').clientHeight;renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();}
window.addEventListener('resize',resize);resize();
function updateCamera(dt,instant=false){const q=playerModel.quaternion;if(viewMode===0){camDesired.set(0,7.2,22.5).applyQuaternion(q).add(state.position);lookAt.copy(state.position).addScaledVector(forward,37).addScaledVector(up,3.5);playerModel.visible=true;}else if(viewMode===1){camDesired.set(0,1.09,.32).applyQuaternion(q).add(state.position);lookAt.copy(camDesired).addScaledVector(forward,80);playerModel.visible=true;}else{camDesired.set(20,8,18).applyQuaternion(q).add(state.position);lookAt.copy(state.position).addScaledVector(forward,9);playerModel.visible=true;}const t=instant?1:1-Math.exp(-dt*5);camera.position.lerp(camDesired,t);camera.up.lerp(tmp.set(0,1,0).lerp(up,viewMode===1?1:.22),t).normalize();camera.lookAt(lookAt);}
updateCamera(1,true);
let audioCtx,engineOsc,engineGain,engineFilter;
function initAudio(){if(audioCtx){if(audioCtx.state==='suspended')audioCtx.resume().catch(()=>{});return;}try{audioCtx=new(window.AudioContext||window.webkitAudioContext)();engineOsc=audioCtx.createOscillator();engineOsc.type='sawtooth';engineGain=audioCtx.createGain();engineGain.gain.value=0;engineFilter=audioCtx.createBiquadFilter();engineFilter.type='lowpass';engineFilter.frequency.value=280;engineOsc.connect(engineFilter);engineFilter.connect(engineGain);engineGain.connect(audioCtx.destination);engineOsc.start();}catch{state.sound=false;}}
function soundShot(explosion=false){if(!state.sound||!audioCtx)return;const duration=explosion?.55:.07,n=Math.floor(audioCtx.sampleRate*duration),buf=audioCtx.createBuffer(1,n,audioCtx.sampleRate),d=buf.getChannelData(0);for(let i=0;i<n;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/n,2);const source=audioCtx.createBufferSource();source.buffer=buf;const gain=audioCtx.createGain();gain.gain.value=explosion?.18:.055;const filter=audioCtx.createBiquadFilter();filter.type='lowpass';filter.frequency.value=explosion?500:1900;source.connect(filter);filter.connect(gain);gain.connect(audioCtx.destination);source.start();}
function updateSound(){if(!engineGain)return;const active=state.mode==='flying'&&state.sound;engineGain.gain.setTargetAtTime(active?.018+state.throttle*.022:0,audioCtx.currentTime,.1);engineOsc.frequency.setTargetAtTime(35+state.throttle*45,audioCtx.currentTime,.1);}
function toggleSound(){state.sound=!state.sound;if(state.sound)initAudio();$('sound-icon').setAttribute('href',state.sound?'#i-volume':'#i-muted');$('sound-button').setAttribute('aria-label',state.sound?'Mute sound':'Enable sound');updateSound();if($('sound-setting'))$('sound-setting').checked=state.sound;}
function toast(message){$('toast').textContent=message;$('toast').classList.add('show');toastTimer=3.4;}
function startFlight(){if(state.mode==='loading')return;if(state.mode==='ended')reset();state.mode='flying';document.body.classList.add('playing');$('ready-card').classList.add('hidden');$('flight-message').style.display='block';$('flight-status').textContent='PATROL IN PROGRESS';$('pause-icon').setAttribute('href','#i-pause');initAudio();updateSound();toast('Patrol underway. Bring her home in one piece.');}
function reset(){Object.assign(state,{mode:'ready',speed:39.45,throttle:.72,health:100,ammo:500,kills:0,time:0,shots:0,hits:0,pitch:0,yaw:0,roll:0,pitchRate:0,rollRate:0});state.position.set(-480,380,480);state.velocity.set(0,0,-39.45);viewMode=0;state.camera='chase';document.body.classList.remove('cockpit');resultSaved=false;lastFire=0;keys.clear();for(const b of bullets)scene.remove(b.mesh);bullets.length=0;for(const p of particles)scene.remove(p.mesh);particles.length=0;spawnEnemies();setPlaneTransform();updateCamera(1,true);$('warning').style.display='none';$('damage-overlay').style.opacity=0;$('ready-card').classList.remove('hidden');$('flight-message').style.display='none';document.body.classList.remove('playing');$('flight-status').textContent='READY FOR FLIGHT';$('pause-label').textContent='PAUSE';$('pause-button').setAttribute('aria-label','Pause flight');$('pause-icon').setAttribute('href','#i-pause');updateHUD();}
const bulletGeo=new THREE.SphereGeometry(.34,4,3),bulletMat=new THREE.MeshBasicMaterial({color:'#ffdda0'}),enemyBulletMat=new THREE.MeshBasicMaterial({color:'#ed9665'}),particleGeo=new THREE.IcosahedronGeometry(1,0),particleMats=[mat('#efb469'),mat('#da7849'),mat('#6d7161'),mat('#858575')];
function fireBullet(position,direction,owner,velocity){const m=mesh(bulletGeo,owner==='player'?bulletMat:enemyBulletMat);m.position.copy(position);m.scale.set(.45,.45,6);m.quaternion.setFromUnitVectors(new THREE.Vector3(0,0,1),direction);bullets.push({mesh:m,velocity:direction.clone().multiplyScalar(390).add(velocity),prev:position.clone(),life:2.6,owner});}
function fire(){if(state.ammo<=0){if(state.time-lastFire>.6){toast('Guns empty. Press R to begin a new patrol.');lastFire=state.time;}return;}if(state.time-lastFire<.105)return;lastFire=state.time;state.ammo--;state.shots++;const origin=state.position.clone().addScaledVector(forward,4).addScaledVector(up,.72).addScaledVector(right,state.shots%2?.29:-.29);const dir=forward.clone();dir.addScaledVector(right,(Math.random()-.5)*.005);dir.addScaledVector(up,(Math.random()-.5)*.005).normalize();fireBullet(origin,dir,'player',state.velocity);soundShot();}
function explode(pos,big=true){soundShot(true);for(let i=0;i<(big?28:6);i++){const m=mesh(particleGeo,particleMats[i%particleMats.length]);m.position.copy(pos);const scale=big?rand(.7,2.5):rand(.2,.7);m.scale.setScalar(scale);particles.push({mesh:m,velocity:new THREE.Vector3(rand(-15,15),rand(-5,18),rand(-15,15)),life:rand(.6,2),maxLife:2,scale});}}
function finish(won,reason){if(state.mode!=='flying')return;state.mode='ended';keys.clear();document.body.classList.remove('playing');$('flight-status').textContent=won?'MISSION COMPLETE':'PATROL ENDED';updateSound();if(!resultSaved){const item={won,kills:state.kills,time:Math.floor(state.time),accuracy:state.shots?Math.round(state.hits/state.shots*100):0,date:new Date().toISOString()};flightLog.unshift(item);flightLog=flightLog.slice(0,12);try{localStorage.setItem('dawn-patrol-log',JSON.stringify(flightLog));}catch{}resultSaved=true;}showModal('result',{won,reason});}
function updateFlight(dt){state.time+=dt;
const pitchInput=(keys.has('KeyS')||keys.has('ArrowDown')?1:0)-(keys.has('KeyW')||keys.has('ArrowUp')?1:0);
const rollInput=(keys.has('KeyA')||keys.has('ArrowLeft')?1:0)-(keys.has('KeyD')||keys.has('ArrowRight')?1:0);
const yawInput=(keys.has('KeyQ')?1:0)-(keys.has('KeyE')?1:0);
if(keys.has('ShiftLeft')||keys.has('ShiftRight'))state.throttle=clamp(state.throttle+dt*.25,0,1);
if(keys.has('ControlLeft')||keys.has('ControlRight'))state.throttle=clamp(state.throttle-dt*.25,0,1);
const authority=clamp(state.speed/32,.2,1.4)*state.sensitivity;
state.pitchRate=lerp(state.pitchRate,pitchInput*.49*authority,1-Math.exp(-dt*4));state.rollRate=lerp(state.rollRate,rollInput*1.1*authority,1-Math.exp(-dt*4));
state.pitch=clamp(state.pitch+state.pitchRate*dt,-1.4,1.4);state.roll+=state.rollRate*dt;
if(state.assist&&!rollInput)state.roll*=Math.exp(-dt*.75);
state.roll=Math.atan2(Math.sin(state.roll),Math.cos(state.roll));
state.yaw+= (Math.sin(state.roll)*9.81/Math.max(state.speed,17)*1.7+yawInput*.33*authority)*dt;
setPlaneTransform();
// Thrust, quadratic drag and gravity exchange airspeed and altitude. Low airspeed reduces lift.
const thrust=state.throttle*10.8,drag=.005*state.speed*state.speed+.75+Math.abs(state.roll)*.48;
state.speed=clamp(state.speed+(thrust-drag-9.81*forward.y)*dt,8,88);
const liftRatio=clamp((state.speed*state.speed)/(25*25),0,1);
const sink=(1-liftRatio)*16+(1-Math.cos(state.roll))*4.2;
const wanted=tmp.copy(forward).multiplyScalar(state.speed);wanted.y-=sink;wanted.x+=1.3;
state.velocity.lerp(wanted,1-Math.exp(-dt*2.5));state.position.addScaledVector(state.velocity,dt);
if(liftRatio<.82)state.pitch-=dt*(1-liftRatio)*.16;
if(keys.has('Space'))fire();
if(state.position.y<terrainHeight(state.position.x,state.position.z)+2.5){explode(state.position);state.health=0;finish(false,'The ground came up a little too quickly. Keep an eye on your altitude.');return;}
if(Math.abs(state.position.x)>7000||Math.abs(state.position.z)>7000){const home=Math.atan2(state.position.x,state.position.z);let delta=home-state.yaw;delta=Math.atan2(Math.sin(delta),Math.cos(delta));state.yaw+=clamp(delta,-.5,.5)*dt;state.roll=lerp(state.roll,Math.sign(delta)*.3,dt);toastTimer=Math.max(toastTimer,.2);if(!$('toast').classList.contains('show'))toast('Leaving the patrol area. Turning toward the valley.');}
if(state.position.y>2500)state.pitch=Math.min(state.pitch,-.08);
setPlaneTransform();
playerModel.userData.prop.rotation.z+=dt*(40+state.throttle*100);
}
function updateEnemies(dt){for(const e of enemies){if(!e.alive)continue;const toPlayer=tmp.copy(state.position).sub(e.position);const distance=toPlayer.length();const avoidGround=e.position.y<terrainHeight(e.position.x,e.position.z)+65;
// Aircraft patrol, pursue, and break away when they pass too close.
let desiredYaw=e.yaw,desiredPitch=0;
if(state.time<13&&e.index===0){desiredYaw=.05*Math.sin(state.time*.2);desiredPitch=0;}
else if(distance<220){desiredYaw=e.yaw+(.8+e.index*.2);desiredPitch=.12;}
else if(distance<1800){desiredYaw=Math.atan2(-toPlayer.x,-toPlayer.z);desiredPitch=clamp(Math.atan2(toPlayer.y,Math.hypot(toPlayer.x,toPlayer.z)),-.27,.27);}
else{desiredYaw=Math.atan2(-e.position.x,-e.position.z)+Math.sin(state.time*.06+e.phase)*.7;desiredPitch=clamp((420-e.position.y)*.001,-.18,.18);}
if(avoidGround)desiredPitch=.36;
let yawDiff=Math.atan2(Math.sin(desiredYaw-e.yaw),Math.cos(desiredYaw-e.yaw));const turn=clamp(yawDiff,-.24,.24);e.yaw+=turn*dt;e.pitch=lerp(e.pitch,desiredPitch,dt*.8);e.roll=lerp(e.roll,turn*2.4,dt*2);e.model.rotation.set(e.pitch,e.yaw,e.roll,'YXZ');const dir=new THREE.Vector3(0,0,-1).applyQuaternion(e.model.quaternion);e.position.addScaledVector(dir,e.speed*dt);e.model.userData.prop.rotation.z+=dt*105;
if(e.position.distanceTo(state.position)<7){state.health=Math.max(0,state.health-65);e.health=0;damageFlash=1;destroyEnemy(e);if(state.health<=0){finish(false,'A mid-air collision ended your patrol. Give the other aircraft a little room.');return;}}
e.cooldown-=dt;
if(e.cooldown<0&&distance<650&&distance>55){const aim=state.position.clone().addScaledVector(state.velocity,distance/410).sub(e.position).normalize();if(aim.dot(dir)>.955){const origin=e.position.clone().addScaledVector(dir,5);const scatter=.018+distance*.000018;aim.x+=(Math.random()-.5)*scatter;aim.y+=(Math.random()-.5)*scatter;fireBullet(origin,aim.normalize(),'enemy',dir.clone().multiplyScalar(e.speed));e.cooldown=.48+Math.random()*.6;}}
}}
function destroyEnemy(e){if(!e.alive)return;e.alive=false;e.model.visible=false;e.marker.style.display='none';state.kills++;explode(e.position);toast('Enemy down. '+(4-state.kills)+' aircraft remaining.');if(state.kills===4)finish(true,'The valley is quiet again. All four enemy aircraft have been cleared from the patrol area.');}
function segmentDistanceSquared(p,a,b){const ab=new THREE.Vector3().subVectors(b,a);const t=clamp(new THREE.Vector3().subVectors(p,a).dot(ab)/(ab.lengthSq()||1),0,1);return tmp.copy(a).addScaledVector(ab,t).distanceToSquared(p);}
function updateProjectiles(dt){for(let i=bullets.length-1;i>=0;i--){const b=bullets[i];b.prev.copy(b.mesh.position);b.mesh.position.addScaledVector(b.velocity,dt);b.velocity.y-=1.5*dt;b.life-=dt;if(b.owner==='player'){for(const e of enemies){if(e.alive&&segmentDistanceSquared(e.position,b.prev,b.mesh.position)<36){e.health-=34;state.hits++;b.life=0;explode(b.mesh.position,false);if(e.health<=0)destroyEnemy(e);break;}}}else if(segmentDistanceSquared(state.position,b.prev,b.mesh.position)<15){state.health=Math.max(0,state.health-8);b.life=0;damageFlash=.8;if(state.health<=0)finish(false,'Your aircraft took too much damage. Keep moving and stay out of the enemy’s sights.');}if(b.life<=0||b.mesh.position.y<terrainHeight(b.mesh.position.x,b.mesh.position.z)){scene.remove(b.mesh);bullets.splice(i,1);}}
for(let i=particles.length-1;i>=0;i--){const p=particles[i];p.life-=dt;p.velocity.y-=dt*4;p.mesh.position.addScaledVector(p.velocity,dt);p.mesh.rotation.x+=dt*2;p.mesh.scale.setScalar(p.scale*Math.max(0,p.life/p.maxLife));if(p.life<=0){scene.remove(p.mesh);particles.splice(i,1);}}}
function updateMarkers(){const w=$('world').clientWidth,h=$('world').clientHeight;const aimPoint=state.position.clone().addScaledVector(forward,800).addScaledVector(up,.72).project(camera);$('reticle').style.left=(aimPoint.x*.5+.5)*w+'px';$('reticle').style.top=(-aimPoint.y*.5+.5)*h+'px';const cameraDir=camera.getWorldDirection(new THREE.Vector3()),occupied=[];for(const e of [...enemies].sort((a,b)=>a.position.distanceToSquared(state.position)-b.position.distanceToSquared(state.position))){if(!e.alive){e.marker.style.display='none';continue;}const distance=e.position.distanceTo(state.position),p=e.position.clone().project(camera),front=tmp.copy(e.position).sub(camera.position).dot(cameraDir)>0;const px=(p.x*.5+.5)*w,py=(-p.y*.5+.5)*h;const visible=front&&p.z<1&&Math.abs(p.x)<.88&&p.y>-.35&&p.y<.67&&distance<3600&&!occupied.some(o=>Math.abs(o[0]-px)<100&&Math.abs(o[1]-py)<75);if(visible)occupied.push([px,py]);e.marker.style.display=visible?'block':'none';if(visible){e.marker.style.left=(p.x*.5+.5)*w+'px';e.marker.style.top=(-p.y*.5+.5)*h+'px';e.marker.querySelector('small').textContent=distance>1000?(distance/1000).toFixed(1)+' KM':Math.round(distance)+' M';e.marker.style.opacity=distance>2000?.55:.85;}}}
const radarCtx=$('radar').getContext('2d');
function drawRadar(){const c=radarCtx,s=272,mid=s/2;c.clearRect(0,0,s,s);c.strokeStyle='#d9e4b72b';c.lineWidth=1;for(const r of [42,84,123]){c.beginPath();c.arc(mid,mid,r,0,TAU);c.stroke();}c.beginPath();c.moveTo(13,mid);c.lineTo(s-13,mid);c.moveTo(mid,13);c.lineTo(mid,s-13);c.stroke();c.save();c.beginPath();c.arc(mid,mid,122,0,TAU);c.clip();c.strokeStyle='#bacc9545';c.lineWidth=3;c.beginPath();for(let i=0;i<80;i++){const z=state.position.z-2000+i*50,x=riverX(z);const dx=(x-state.position.x)/2000*120,dy=(z-state.position.z)/2000*120;if(i===0)c.moveTo(mid+dx,mid+dy);else c.lineTo(mid+dx,mid+dy);}c.stroke();const angle=elapsed*.45;c.fillStyle='#dce8b408';c.beginPath();c.moveTo(mid,mid);c.arc(mid,mid,122,angle-.6,angle);c.closePath();c.fill();for(const e of enemies){if(!e.alive)continue;const dx=(e.position.x-state.position.x)/2000*120,dy=(e.position.z-state.position.z)/2000*120;c.fillStyle='#e4a18a';c.save();c.translate(mid+dx,mid+dy);c.rotate(Math.PI/4);c.fillRect(-3,-3,6,6);c.restore();}c.restore();c.save();c.translate(mid,mid);c.rotate(-state.yaw);c.fillStyle='#edf0cc';c.beginPath();c.moveTo(0,-8);c.lineTo(5,5);c.lineTo(0,2);c.lineTo(-5,5);c.closePath();c.fill();c.strokeStyle='#e8edc333';c.beginPath();c.moveTo(0,0);c.lineTo(-43,-75);c.moveTo(0,0);c.lineTo(43,-75);c.stroke();c.restore();}
function updateHUD(){ $('speed').textContent=Math.round(state.speed*3.6);$('altitude').textContent=Math.round(state.position.y).toLocaleString('en');$('throttle').textContent=Math.round(state.throttle*100);$('throttle-fill').style.width=state.throttle*100+'%';$('ammo').textContent=state.ammo;$('kills').textContent=state.kills;document.querySelectorAll('.objective-dots i').forEach((el,i)=>el.classList.toggle('done',i<state.kills));document.querySelectorAll('#health-track i').forEach((el,i)=>el.classList.toggle('empty',i>=Math.ceil(state.health/10)));$('health-text').textContent=Math.round(state.health)+'% · '+(state.health>65?'AIRFRAME GOOD':state.health>30?'AIRFRAME DAMAGED':'CRITICAL DAMAGE');$('health-text').style.color=state.health<35?'#b05240':'';$('radar-status').textContent=(4-state.kills)+' CONTACT'+(state.kills===3?'':'S')+' NEARBY';const heading=((Math.round(-state.yaw*180/Math.PI)%360)+360)%360;$('heading').textContent=String(heading).padStart(3,'0')+'°';const dirs={0:'N',90:'E',180:'S',270:'W'};const center=Math.round(heading/30)*30;Array.from($('compass-track').children).forEach((el,i)=>{const n=((center+(i-3)*30)%360+360)%360;el.textContent=dirs[n]??n;el.classList.toggle('major',i===3);});let warning='';if(state.mode==='flying'){if(state.speed<24)warning='STALL · LOWER NOSE / INCREASE THROTTLE';else if(state.position.y-terrainHeight(state.position.x,state.position.z)<45)warning='LOW ALTITUDE · PULL UP';else if(state.health<30)warning='AIRCRAFT DAMAGE · EVADE ENEMY FIRE';else if(state.ammo===0)warning='GUNS EMPTY · PRESS R TO RESTART';} $('warning').textContent=warning;$('warning').style.display=warning?'block':'none';drawRadar();}
function formatTime(seconds){return Math.floor(seconds/60)+':'+String(Math.floor(seconds%60)).padStart(2,'0');}
const closeButton='<button class="icon-button" id="close-modal" aria-label="Close dialog"><svg><use href="#i-close"/></svg></button>';
function showModal(kind,data={}){lastFocus=document.activeElement;if(!modalKind){modalReturn=state.mode;if(state.mode==='flying')state.mode='paused';}modalKind=kind;keys.clear();updateSound();$('modal-backdrop').classList.add('open');let body='';
if(kind==='help'){body='<div class="modal-header"><span class="eyebrow">FLIGHT SCHOOL · 01</span>'+closeButton+'</div><h2 id="modal-title">Find your wings.</h2><p>You’re already airborne. Bank into a turn, line up an enemy in the center sight, and fire short bursts. Three hits will bring one down.</p><div class="key-grid">'+[['W / S','Pitch down / up'],['A / D','Bank left / right'],['Q / E','Rudder left / right'],['↑ / ↓','Pitch down / up'],['Shift','More throttle'],['Ctrl','Less throttle'],['Space','Machine guns'],['C','Change camera'],['Esc / P','Pause flight'],['R','Restart patrol'],['M','Toggle sound'],['Enter','Take flight']].map(([key,label])=>'<div class="key-item"><span><kbd class="key">'+key+'</kbd></span><span>'+label+'</span></div>').join('')+'</div><p class="modal-tip">Pilot’s note: climbing costs airspeed. Below 86 km/h, the wings lose lift. Lower the nose and add throttle to recover. On a touch screen, use the on-screen flight controls.</p><button class="primary" id="modal-done">Ready for the sky <svg><use href="#i-arrow"/></svg></button>';}
if(kind==='settings'){body='<div class="modal-header"><span class="eyebrow">MAKE YOURSELF AT HOME</span>'+closeButton+'</div><h2 id="modal-title">Flight settings.</h2><div class="setting"><div><strong>Flight assistance</strong><small>Gently level the wings when you release the controls.</small></div><input type="checkbox" class="switch" id="assist-setting" aria-label="Flight assistance" '+(state.assist?'checked':'')+'></div><div class="setting"><div><strong>Engine & combat audio</strong><small>A little atmosphere from the cockpit.</small></div><input type="checkbox" class="switch" id="sound-setting" aria-label="Engine and combat audio" '+(state.sound?'checked':'')+'></div><div class="setting"><div><strong>Control sensitivity</strong><small id="sensitivity-value">'+state.sensitivity.toFixed(1)+'× response</small></div><input type="range" id="sensitivity-setting" aria-label="Control sensitivity" min="0.5" max="1.5" step="0.1" value="'+state.sensitivity+'"></div><p class="modal-tip">Camera: '+['chase view','cockpit view','wing view'][viewMode]+'. Press C during flight to switch between all three.</p><button class="primary" id="modal-done">Back to the skies <svg><use href="#i-arrow"/></svg></button>';}
if(kind==='log'){body='<div class="modal-header"><span class="eyebrow">ROYAL FLYING CORPS</span>'+closeButton+'</div><h2 id="modal-title">Your flight log.</h2><p>Every flight has a story. These are yours.</p>'+(flightLog.length?flightLog.map((f,i)=>'<div class="log-entry"><div>Somme Valley patrol <small>'+new Date(f.date).toLocaleDateString('en-GB',{day:'numeric',month:'short'})+' · '+formatTime(Number(f.time)||0)+' airborne</small></div><span class="log-result">'+(f.won?'VICTORY':Number(f.kills)+' / 4 DOWN')+'</span></div>').join(''):'<div class="log-empty"><p>No patrols logged just yet.<br>Your first story is waiting on the horizon.</p></div>')+'<button class="primary" id="modal-done">Back to the mission <svg><use href="#i-arrow"/></svg></button>';}
if(kind==='pause'){body='<div class="modal-header"><span class="eyebrow">TAKE A BREATHER</span>'+closeButton+'</div><h2 id="modal-title">A moment in the clouds.</h2><p>Your patrol is paused. The skies will be right here when you’re ready.</p><div class="result-stats"><div>'+formatTime(state.time)+'<small>Time airborne</small></div><div>'+state.kills+' / 4<small>Aircraft down</small></div><div>'+Math.round(state.health)+'%<small>Airframe</small></div></div><button class="primary" id="modal-done">Resume flight <svg><use href="#i-play"/></svg></button>';}
if(kind==='restart'){body='<div class="modal-header"><span class="eyebrow">A FRESH START</span>'+closeButton+'</div><h2 id="modal-title">Another run at the sky?</h2><p>Begin this patrol again with a fresh aircraft and full ammunition. Your current flight will be reset.</p><button class="primary" id="confirm-restart">Restart patrol <svg><use href="#i-arrow"/></svg></button><button style="margin-left:16px;border:0;background:none;font-size:11px" id="modal-done">Keep flying</button>';}
if(kind==='result'){body='<div class="modal-header"><span class="eyebrow">'+(data.won?'PATROL COMPLETE':'PATROL DEBRIEF')+'</span>'+closeButton+'</div><h2 id="modal-title">'+(data.won?'The sky is yours.':'Live to fly another day.')+'</h2><p>'+data.reason+'</p><div class="result-stats"><div>'+state.kills+' / 4<small>Aircraft down</small></div><div>'+formatTime(state.time)+'<small>Time airborne</small></div><div>'+(state.shots?Math.round(state.hits/state.shots*100):0)+'%<small>Accuracy</small></div></div><button class="primary" id="fly-again">Fly another patrol <svg><use href="#i-arrow"/></svg></button>';}
$('modal').innerHTML=body;$('close-modal').onclick=closeModal;if($('modal-done'))$('modal-done').onclick=closeModal;if($('assist-setting'))$('assist-setting').onchange=e=>state.assist=e.target.checked;if($('sound-setting'))$('sound-setting').onchange=toggleSound;if($('sensitivity-setting'))$('sensitivity-setting').oninput=e=>{state.sensitivity=Number(e.target.value);$('sensitivity-value').textContent=state.sensitivity.toFixed(1)+'× response';};if($('confirm-restart'))$('confirm-restart').onclick=()=>{closeModal(false);reset();startFlight();};if($('fly-again'))$('fly-again').onclick=()=>{closeModal(false);reset();startFlight();};$('close-modal').focus();}
function closeModal(resume=true){const was=modalKind;modalKind=null;$('modal-backdrop').classList.remove('open');if(resume&&state.mode==='paused'&&modalReturn==='flying')state.mode='flying';if(was==='result'){ $('ready-card').classList.remove('hidden');$('start-label').textContent='Fly again';$('flight-message').style.display='none';}updateSound();if(lastFocus&&lastFocus.isConnected)lastFocus.focus();}
function pause(){if(modalKind){closeModal();return;}if(state.mode==='flying')showModal('pause');else if(state.mode==='ready')toast('Your aircraft is ready. Press Enter to take flight.');}
function changeView(){viewMode=(viewMode+1)%3;state.camera=['chase','cockpit','wing'][viewMode];document.body.classList.toggle('cockpit',viewMode===1);updateCamera(1,true);toast(['Chase view','Cockpit view','Wing view'][viewMode]);}
$('start-button').onclick=startFlight;$('help-nav').onclick=()=>showModal('help');$('all-controls').onclick=()=>showModal('help');$('log-nav').onclick=()=>showModal('log');$('settings-button').onclick=()=>showModal('settings');$('sound-button').onclick=toggleSound;$('pause-button').onclick=pause;$('mission-nav').onclick=()=>{if(modalKind)closeModal();else toast('Your mission: clear all four enemy aircraft from the valley.');};$('brand').onclick=e=>{e.preventDefault();if(modalKind)closeModal();else if(state.mode==='flying')showModal('restart');};$('modal-backdrop').onclick=e=>{if(e.target===$('modal-backdrop'))closeModal();};
const flightKeys=['KeyW','KeyS','KeyA','KeyD','KeyQ','KeyE','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space','ShiftLeft','ShiftRight','ControlLeft','ControlRight'];
window.addEventListener('keydown',e=>{if(modalKind){if(e.code==='Escape'){e.preventDefault();closeModal();}if(e.code==='Tab'){const focusable=Array.from($('modal').querySelectorAll('button,input'));const first=focusable[0],last=focusable[focusable.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}}return;}if(e.target instanceof HTMLInputElement)return;if(flightKeys.includes(e.code)&&state.mode==='flying'){e.preventDefault();keys.add(e.code);}if(e.repeat)return;if(e.code==='Enter'&&['ready','ended'].includes(state.mode)){e.preventDefault();if(!$('start-button').disabled)startFlight();}if(e.code==='Escape'||e.code==='KeyP'){e.preventDefault();pause();}if(e.code==='KeyC'){e.preventDefault();changeView();}if(e.code==='KeyM')toggleSound();if(e.code==='KeyR'&&state.mode==='flying')showModal('restart');if(e.code==='KeyH')showModal('help');});
window.addEventListener('keyup',e=>keys.delete(e.code));window.addEventListener('blur',()=>{keys.clear();if(state.mode==='flying')showModal('pause');});document.addEventListener('visibilitychange',()=>{if(document.hidden&&state.mode==='flying')showModal('pause');});
for(const b of document.querySelectorAll('.touch-button')){b.addEventListener('pointerdown',e=>{e.preventDefault();b.setPointerCapture(e.pointerId);keys.add(b.dataset.key);b.classList.add('pressed');});for(const ev of ['pointerup','pointercancel','lostpointercapture'])b.addEventListener(ev,()=>{keys.delete(b.dataset.key);b.classList.remove('pressed');});}
let last=performance.now(),frameAverage=16,perfSamples=0,renderScale=Math.min(devicePixelRatio,1.7);
function frame(now){requestAnimationFrame(frame);const rawDt=now-last;const dt=Math.min(rawDt/1000,.12);last=now;elapsed+=dt;frameAverage=lerp(frameAverage,Math.min(rawDt,200),.05);if(++perfSamples%45===0&&frameAverage>40&&renderScale>.65){renderScale=Math.max(.65,renderScale*.8);renderer.setPixelRatio(renderScale);resize();}if(state.mode==='flying'){// Substeps keep projectiles and flight stable during slow frames.
const steps=Math.max(1,Math.ceil(dt/.0167)),step=dt/steps;for(let i=0;i<steps&&state.mode==='flying';i++){updateFlight(step);if(state.mode==='flying')updateEnemies(step);if(state.mode==='flying')updateProjectiles(step);}updateCamera(dt);updateSound();}else if(state.mode==='ready'){playerModel.userData.prop.rotation.z+=dt*60;playerModel.position.y=state.position.y+Math.sin(elapsed*.8)*.14;enemies.forEach(e=>e.model.userData.prop.rotation.z+=dt*75);}
if(toastTimer>0){toastTimer-=dt;if(toastTimer<=0)$('toast').classList.remove('show');}damageFlash=Math.max(0,damageFlash-dt*1.5);$('damage-overlay').style.opacity=damageFlash*.6;$('reticle').classList.toggle('firing',keys.has('Space')&&state.mode==='flying'&&state.ammo>0);hudTick+=dt;if(hudTick>.1){updateHUD();hudTick=0;}updateMarkers();renderer.render(scene,camera);}
$('loading-note').classList.add('hidden');$('start-button').disabled=false;$('start-label').textContent='Take flight';updateHUD();requestAnimationFrame(frame);
// Read-only telemetry is useful for checking simulation behavior in browser devtools.
window.flightSim={getState:()=>({mode:state.mode,speed:state.speed,altitude:state.position.y,throttle:state.throttle,health:state.health,ammo:state.ammo,kills:state.kills,pitch:state.pitch,roll:state.roll,yaw:state.yaw,time:state.time,camera:state.camera,position:state.position.toArray(),enemies:enemies.map(e=>({alive:e.alive,health:e.health,position:e.position.toArray()})),bullets:bullets.length,assist:state.assist,sound:state.sound}),getRenderInfo:()=>({calls:renderer.info.render.calls,triangles:renderer.info.render.triangles,geometries:renderer.info.memory.geometries})};
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment