Flight Simulator
GPT 6 Astra · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 107,958 bytes · SHA-256 400f7bf64495
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Apex Aero — Flight simulator</title>
<style>
:root{--ink:#f1f5f2;--muted:#a6b7b7;--line:rgba(208,230,225,.17);--panel:rgba(15,29,33,.83);--accent:#c4ed9f}*{box-sizing:border-box}body{margin:0;background:#192c32;color:var(--ink);font-family:Arial,Helvetica,sans-serif;overflow:hidden}button{font:inherit;color:inherit;cursor:pointer}button:focus-visible{outline:2px solid var(--accent);outline-offset:4px}#world{position:absolute;inset:0;width:100%;height:100%;display:block}#threeMount{position:absolute;inset:0;pointer-events:none}#threeMount canvas{width:100%;height:100%}.vignette{position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,rgba(9,24,29,.4),transparent 22%,transparent 67%,rgba(7,21,26,.6));box-shadow:inset 0 0 170px rgba(8,20,26,.22)}header{position:absolute;left:32px;right:32px;top:25px;display:flex;align-items:center;justify-content:space-between;gap:20px}.brand{display:flex;align-items:center;gap:12px;width:250px}.brand svg{width:35px;height:35px}.brand b{font-size:21px;letter-spacing:4px;font-weight:750}.brand small{display:block;font-size:8px;letter-spacing:3px;margin-top:5px;color:#c5d1d0}.mode-tabs{padding:5px;background:rgba(17,32,37,.7);border:1px solid var(--line);border-radius:9px;display:flex;gap:4px}.mode-tabs button{border:0;background:none;padding:12px 20px;font-size:11px;color:#b3c4c4;border-radius:5px;letter-spacing:.3px}.mode-tabs button.active{background:#c5ecab;color:#243527}.mode-tabs span{margin-right:7px}.top-actions{width:250px;display:flex;justify-content:flex-end;gap:8px}.icon-btn{border:1px solid var(--line);background:rgba(17,32,37,.7);border-radius:7px;height:40px;padding:0 13px;font-size:12px}.icon-btn:hover{background:#354b4e}.live{font-size:9px;letter-spacing:1.5px;display:flex;align-items:center;gap:7px;margin-right:12px}.dot{width:5px;height:5px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px #b8e39a}.rail{position:absolute;left:32px;top:122px;width:236px}.eyebrow{font-size:9px;letter-spacing:2px;color:var(--muted);font-weight:600}.section-label{display:flex;justify-content:space-between;margin-bottom:13px}.section-label span:last-child{letter-spacing:0;color:#d5e1df}.fleet{background:var(--panel);border:1px solid var(--line);border-radius:9px;overflow:hidden;backdrop-filter:blur(20px)}.aircraft{position:relative;display:flex;align-items:center;width:100%;height:82px;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--line);padding:12px 15px;gap:13px}.aircraft:last-child{border-bottom:0}.aircraft.active{background:rgba(194,231,159,.09)}.aircraft.active:before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--accent)}.aircraft svg{width:58px;height:44px;color:#85989b}.aircraft.active svg{color:#d2e7d2}.aircraft b{font-size:12px;display:block;font-weight:500}.aircraft small{display:block;font-size:9px;color:var(--muted);margin-top:6px;letter-spacing:.4px}.aircraft .check{position:absolute;right:12px;top:14px;color:var(--accent);font-size:10px}.aircraft:not(.active) .check{display:none}.specs{margin-top:13px;display:flex;justify-content:space-between;font-size:9px;color:#cfddda}.specs span{color:#80999d;font-size:8px;letter-spacing:1px;display:block;margin-bottom:5px}.location{position:absolute;right:32px;top:127px;width:203px}.location .name{font-size:17px;letter-spacing:.2px;margin:9px 0}.location .coords{font:9px monospace;color:#bbc9c8;letter-spacing:1px}.weather{display:flex;align-items:center;gap:15px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-top:18px;padding:15px 0}.weather .sun{font-size:26px;color:#e0dfb5}.weather b{font-size:11px;display:block;font-weight:400}.weather small{font-size:9px;display:block;color:#aababc;margin-top:6px}.checklist{margin-top:20px}.checklist .eyebrow{margin-bottom:15px}.task{font-size:10px;color:#b5c6c6;display:flex;align-items:center;gap:9px;margin:13px 0}.task i{width:13px;height:13px;border:1px solid #6c8384;border-radius:50%;font-size:8px;font-style:normal;display:grid;place-items:center}.task.done{color:var(--accent)}.task.done i{background:var(--accent);color:#233631;border:0}.scene-tag{position:absolute;top:106px;left:50%;transform:translateX(-50%);text-align:center;font-size:8px;color:#d5e0de;letter-spacing:2px}.scene-tag .line{height:18px;width:1px;background:var(--line);margin:8px auto}.compass{position:absolute;left:50%;top:149px;transform:translateX(-50%);width:300px;text-align:center;color:rgba(232,243,231,.75);font:9px monospace;letter-spacing:10px;border-bottom:1px solid rgba(236,245,222,.25);padding-bottom:12px}.compass:after{content:'▾';position:absolute;bottom:-13px;left:48%;color:var(--accent);font-size:17px}.heading{position:absolute;left:50%;top:202px;transform:translateX(-50%);font:11px monospace;color:var(--accent);letter-spacing:1px}.center-reticle{position:absolute;left:50%;top:45%;transform:translate(-50%,-50%);width:100px;height:25px;opacity:.5;pointer-events:none}.center-reticle:before,.center-reticle:after{content:'';position:absolute;top:10px;width:34px;height:7px;border-bottom:1px solid #eff8e4}.center-reticle:before{left:0;border-right:1px solid #eff8e4}.center-reticle:after{right:0;border-left:1px solid #eff8e4}.center-reticle span{position:absolute;left:48px;top:14px;width:3px;height:3px;border-radius:50%;background:#eff8e4}.mission{position:absolute;left:32px;bottom:164px;width:285px}.mission .eyebrow{color:var(--accent);display:flex;gap:8px;align-items:center}.mission h1{font-size:30px;font-weight:400;line-height:1.13;letter-spacing:-1px;margin:12px 0}.mission p{font-size:11px;line-height:1.7;color:#b6c6c5;max-width:250px;margin:0 0 18px}.launch{background:var(--accent);color:#263629;border:0;border-radius:6px;padding:13px 17px;width:235px;display:flex;justify-content:space-between;font-size:11px;font-weight:bold}.launch:hover{background:#dcf9c6}.launch small{font-size:9px;font-weight:400;opacity:.7}.camera-tools{position:absolute;right:32px;bottom:161px;display:flex;flex-direction:column;align-items:flex-end;gap:8px}.camera-tools button{background:var(--panel);border:1px solid var(--line);border-radius:5px;font-size:9px;padding:10px 12px;min-width:122px;text-align:left}.camera-tools kbd{margin-right:12px;border:1px solid #657b7d;border-radius:2px;padding:2px 4px;font:9px monospace}.camera-tools .engine-tag{color:#c3d1d0;font-size:8px;letter-spacing:1.2px;margin-bottom:8px}.telemetry{position:absolute;bottom:39px;left:32px;right:32px;height:104px;border:1px solid var(--line);border-radius:9px;background:rgba(12,28,33,.86);backdrop-filter:blur(16px);display:flex;align-items:center;padding:17px 22px;gap:0}.instrument{padding:0 23px;border-right:1px solid var(--line);flex:1;min-width:0}.instrument:first-child{padding-left:0}.instrument .label{font-size:8px;letter-spacing:1.6px;color:#94aaad;white-space:nowrap}.reading{font-family:Consolas,'Courier New',monospace;font-size:29px;font-weight:400;margin-top:9px;white-space:nowrap;letter-spacing:-1px}.reading small{font:9px Arial;letter-spacing:.5px;color:#829ca0;margin-left:7px}.reading sup{font-size:12px;color:#9bafb0;letter-spacing:0}.instrument .sub{font-size:8px;margin-top:5px;color:#809b9e}.attitude{flex:0 0 101px;padding:0 20px;display:flex;justify-content:center}.attitude canvas{width:68px;height:68px}.throttle{flex:1.2;border-right:0;padding-right:0}.throttle .reading{font-size:25px;color:var(--accent);margin-top:6px}.bar{height:3px;background:#34494b;margin-top:8px;max-width:135px}.bar div{height:100%;background:var(--accent);width:0}.status{flex:.9;border-right:0;padding:0 0 0 22px}.status .label{color:var(--accent);font-size:9px}.status .sub{line-height:1.8}.footer{position:absolute;bottom:14px;left:33px;right:33px;display:flex;justify-content:space-between;font-size:8px;color:#9bb0b1;letter-spacing:1px}.footer span:first-child{display:flex;gap:15px}.footer b{font-weight:400;color:#d2dcda}.toast{position:absolute;left:50%;top:260px;transform:translateX(-50%);background:rgba(13,30,32,.88);border:1px solid var(--line);border-radius:6px;padding:12px 21px;font-size:11px;text-align:center;display:none;max-width:440px}.toast.show{display:block}.toast.warning{color:#f4c18b;border-color:#bd9160}.modal{position:absolute;inset:0;background:rgba(6,18,23,.65);backdrop-filter:blur(12px);display:none;align-items:center;justify-content:center;z-index:10}.modal.open{display:flex}.dialog{width:500px;max-width:90vw;border:1px solid var(--line);background:#172c32;border-radius:13px;padding:30px;box-shadow:0 25px 90px #07141a80}.dialog h2{font-size:24px;font-weight:400;margin:12px 0 8px}.dialog p{color:var(--muted);font-size:12px;line-height:1.8}.control-row{display:flex;justify-content:space-between;font-size:11px;padding:12px 0;border-bottom:1px solid var(--line)}.control-row kbd{font:10px monospace;color:var(--accent)}.dialog .launch{width:100%;margin-top:22px}.switch-row{display:flex;align-items:center;justify-content:space-between;margin-top:19px;font-size:12px}.switch-row input{accent-color:var(--accent)}body.flying .rail{opacity:.5}body.flying .rail:hover{opacity:1}body.flying .mission p{display:none}body.flying .mission h1{font-size:23px}body.flying .mission .launch{width:180px;padding:10px 13px}body.flying .mission{bottom:160px}body.flying .mission .eyebrow{font-size:8px}#targetHud{position:absolute;width:40px;height:40px;border:1px solid #efb68d;display:none;pointer-events:none}#targetHud span{position:absolute;top:46px;left:-20px;width:100px;text-align:center;font:10px monospace;color:#f2cbb0}.touch-controls{display:none;position:absolute;bottom:160px;left:50%;transform:translateX(-50%);gap:5px}.touch-controls button{padding:13px;background:var(--panel);border:1px solid var(--line);border-radius:5px}@media(max-width:1000px){.brand,.top-actions{width:auto}.live{display:none}.instrument{padding:0 12px}.telemetry{padding:15px}.status{display:none}.rail{width:200px}.location{width:170px}.mission h1{font-size:26px}.aircraft{padding-left:10px;gap:8px}}@media(max-width:700px){header{left:16px;right:16px;top:16px;flex-wrap:wrap;gap:14px}.brand b{font-size:16px}.brand small{font-size:6px}.brand svg{width:26px}.mode-tabs{order:3;margin:auto}.mode-tabs button{padding:9px 18px}.top-actions .live{display:none}.icon-btn{height:34px;padding:0 10px}.rail{left:16px;top:131px;width:160px}.aircraft{height:62px;padding:8px}.aircraft svg{width:38px}.aircraft b{font-size:10px}.aircraft small{font-size:7px}.specs{font-size:8px}.location{right:16px;top:137px;width:125px}.location .name{font-size:13px}.location .coords{font-size:7px}.weather{padding:10px 0;gap:8px}.checklist{display:none}.scene-tag,.compass,.heading{display:none}.mission{left:16px;bottom:148px;width:200px}.mission h1{font-size:25px}.mission p{font-size:10px}.launch{width:190px}.telemetry{left:12px;right:12px;bottom:35px;height:93px;padding:10px;flex-wrap:nowrap}.instrument{padding:0 9px}.reading{font-size:20px}.instrument .label{font-size:6px;letter-spacing:.8px}.reading small{font-size:6px;margin-left:2px}.instrument .sub{font-size:6px}.instrument.vertical,.instrument.angles,.attitude{display:none}.throttle .reading{font-size:20px}.camera-tools{right:16px;bottom:146px}.camera-tools button{font-size:8px;min-width:96px;padding:8px}.camera-tools .engine-tag{display:none}.footer{left:16px;right:16px;font-size:6px}.footer span:first-child{gap:8px}.footer .build{display:none}body.flying .rail{display:none}body.flying .mission{bottom:auto;top:135px}body.flying .mission h1{font-size:18px}.toast{top:45%;width:80%}.touch-controls{display:flex}.mission p{max-width:180px}}@media(max-height:680px) and (min-width:701px){.rail{top:105px}.aircraft{height:66px}.mission{bottom:153px}.mission h1{font-size:25px;margin:9px 0}.mission p{font-size:10px;margin-bottom:12px}.location{top:107px}.checklist{margin-top:12px}.task{margin:10px 0}}
.compass{white-space:nowrap;letter-spacing:8px}.live{white-space:nowrap}.vignette{background:linear-gradient(90deg,rgba(7,22,28,.4),transparent 30%,transparent 68%,rgba(7,22,28,.34)),linear-gradient(180deg,rgba(9,24,29,.25),transparent 28%,transparent 60%,rgba(7,21,26,.55))}.mission p{color:#ced8d4}.specs span{color:#bbccca}@media(min-width:701px) and (max-height:800px){.rail{top:114px}.aircraft{height:69px}.specs{margin-top:10px}.section-label{margin-bottom:10px}.mission h1{font-size:28px}.mission p{margin-bottom:16px}}@media(max-width:700px){.touch-controls{display:none}body.flying .touch-controls{display:flex}}
/* Expedition flight deck */
:root{--panel:rgba(12,28,33,.86)}.brand small{letter-spacing:3.2px}.rail{transition:opacity .3s,transform .3s}.location{width:213px}.nav-panel{margin-top:20px;background:rgba(12,28,33,.73);border:1px solid var(--line);border-radius:8px;overflow:hidden;backdrop-filter:blur(15px)}.nav-title{display:flex;justify-content:space-between;padding:12px 12px 9px;font:8px monospace;letter-spacing:1.4px;color:#c4ed9f}.nav-title span{color:#90a9a8}#navMap{display:block;width:100%;height:143px}.nav-bottom{display:flex;justify-content:space-between;border-top:1px solid var(--line);padding:10px 12px;font:8px monospace;color:#acc1bb}.nav-bottom b{color:#e7eddb;font-weight:400}.flight-chip{display:none;position:absolute;left:32px;top:124px;width:207px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px}.flight-chip b{display:block;font-size:14px;margin:9px 0}.flight-chip small{font:9px monospace;color:#a6b7b7}.flight-chip .meter{height:2px;background:#34494b;margin:12px 0 8px}.flight-chip .meter span{display:block;background:var(--accent);width:100%;height:100%}.flight-chip button{background:none;border:0;color:var(--accent);font-size:9px;padding:8px 0 0}.tape{position:absolute;top:37%;font:10px monospace;color:#d8e7d5;opacity:.75;pointer-events:none;width:54px;text-align:center}.tape.left{left:calc(50% - 210px)}.tape.right{right:calc(50% - 210px)}.tape small{font-size:7px;letter-spacing:2px;color:#a4bcb2}.tape .ticks{height:112px;margin:10px 0;background:repeating-linear-gradient(0deg,transparent,transparent 21px,#c2d8c84d 22px,transparent 23px);mask-image:linear-gradient(transparent,#000 25%,#000 75%,transparent);position:relative}.tape strong{font-size:14px;position:absolute;top:62px;left:0;right:0;background:#122b30a6;border:1px solid #c4ed9f70;padding:7px 2px;font-weight:400}.tape .tape-unit{font-size:8px;letter-spacing:1px}#sceneOverlay{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}#cinemaBadge{display:none;position:absolute;bottom:26px;left:32px;font:10px monospace;letter-spacing:3px;color:#deeadb}body.clean header,body.clean aside,body.clean .mission,body.clean .telemetry,body.clean .footer,body.clean .camera-tools,body.clean .flight-chip,body.clean .tape,body.clean .center-reticle,body.clean .compass,body.clean .heading,body.clean .scene-tag,body.clean .touch-controls{display:none!important}body.clean #cinemaBadge{display:block}.camera-tools button.active{border-color:#c4ed9f70;color:#c4ed9f}.camera-tools{bottom:162px}.camera-tools button{min-width:133px}.camera-tools .engine-tag{font:8px monospace}.mission .flight-log{font:9px monospace;line-height:1.7;color:#a7bab5;margin-bottom:12px;display:none}body.flying .mission .flight-log{display:block}body.flying .rail{display:none}body.flying .flight-chip{display:block}body.flying .mission h1{font-size:22px;line-height:1.25}.mission .eyebrow{font-size:8px}.center-reticle{top:50%}.vignette{box-shadow:inset 0 0 140px rgba(8,20,26,.2)}#debriefModal .score-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin:25px 0}#debriefModal .score-grid b{font:25px monospace;display:block;margin-top:7px;color:var(--accent)}#debriefModal .score-grid span{font:9px monospace;letter-spacing:1px;color:var(--muted)}.dialog .secondary{background:transparent;color:#c4ed9f;border:1px solid var(--line)}.approach-state{font-size:8px;letter-spacing:1px;color:#c4ed9f;margin-top:12px}.checklist{margin-top:17px}.task{margin:11px 0}.settings-note{font:9px monospace!important;color:#a9c2b1!important}.toast{z-index:5}.audio-disabled{opacity:.55}
@media(min-width:701px) and (max-height:800px){.location{top:114px}.nav-panel{margin-top:12px}.nav-title{padding:9px 11px}#navMap{height:99px}.nav-bottom{padding:8px 10px}.weather{margin-top:12px;padding:11px 0}.checklist{margin-top:13px}.checklist .eyebrow{margin-bottom:10px}.task{margin:10px 0}.camera-tools .engine-tag{display:none}.camera-tools{gap:5px}.camera-tools button{padding:8px 10px}.mission h1{font-size:25px}.tape{top:34%}.flight-chip{top:114px}}@media(max-width:1100px){.tape.left{left:calc(50% - 150px)}.tape.right{right:calc(50% - 150px)}.location{width:180px}}@media(max-width:700px){.location{width:125px}.nav-panel{margin-top:12px}#navMap{height:94px}.nav-title{font-size:6px;padding:8px}.nav-bottom{font-size:6px;padding:8px}.tape{display:none}.flight-chip{display:none!important}.camera-tools{bottom:145px;gap:5px}.camera-tools button{min-width:98px;padding:7px 9px;font-size:8px}.camera-tools button:nth-last-child(1){display:none}.camera-tools kbd{margin-right:6px}.mission h1{font-size:23px}.mission .flight-log{max-width:190px;font-size:8px}.approach-state{display:none}.mission .eyebrow{letter-spacing:1px}.touch-controls{bottom:137px;gap:3px}.touch-controls button{padding:10px}body.flying .mission{top:135px;max-width:185px}body.flying .mission .flight-log{margin-bottom:8px;font-size:7px}body.flying .touch-controls{left:16px;transform:none}.nav-bottom span:last-child{display:none}.nav-title span{display:none}}@media(max-height:640px) and (min-width:701px){.nav-panel{display:none}.camera-tools button{padding:6px 10px}.mission p{display:none}.mission h1{font-size:23px}.aircraft{height:57px}}
.camera-tools{display:grid;grid-template-columns:133px 133px;gap:7px;width:273px}.camera-tools .engine-tag{grid-column:1/-1;text-align:right}.camera-tools button{width:100%;min-width:0}.camera-tools #controlsBtn{grid-column:2;grid-row:3}.camera-tools #viewBtn{grid-column:1/-1;text-align:center;padding:8px}.nav-panel{background:rgba(12,28,33,.84)}.tape{opacity:.86}.tape strong{background:#142c31c4}.tape .ticks{margin-top:13px}.tape small{color:#dae5d3}.compass,.heading,.scene-tag{text-shadow:0 1px 8px #1b353b}.nav-title{font-size:7px}.location{text-shadow:0 1px 7px #16333460}
@media(min-width:701px) and (max-height:800px){.checklist{display:none}.camera-tools .engine-tag{display:none}.camera-tools #controlsBtn{grid-row:2}.nav-panel{margin-top:16px}.camera-tools #viewBtn{padding:7px}.tape{top:33%}}@media(max-width:1000px) and (min-width:701px){.camera-tools{width:225px;grid-template-columns:109px 109px}.camera-tools button{font-size:8px}.camera-tools kbd{margin-right:6px}}@media(max-width:700px){.camera-tools{display:flex;width:100px}.camera-tools button{width:100px}.camera-tools #controlsBtn{display:none}.camera-tools #viewBtn{display:none}.nav-title{font-size:6px}.camera-tools #guideBtn{font-size:7px}.camera-tools kbd{margin-right:6px}}@media(min-height:801px){.nav-panel{margin-top:16px}#navMap{height:125px}.nav-bottom{padding:9px 12px}}
@media(min-width:701px) and (max-height:800px){.aircraft{height:62px}.mission h1{font-size:24px;margin:10px 0}.mission p{font-size:10px;margin-bottom:12px}.mission .eyebrow{font-size:7px}.mission{bottom:163px}.specs{margin-top:9px}.specs b{font-weight:400}.scene-tag{top:97px}.heading{top:187px}.compass{top:141px}.tape{top:35%}.telemetry .instrument{padding-left:17px;padding-right:17px}.telemetry .instrument:first-child{padding-left:0}.telemetry .reading{font-size:25px}.telemetry .instrument .label{font-size:7px}}@media(min-width:701px) and (max-height:690px){.aircraft{height:53px}.mission p{display:none}.mission{bottom:157px}.specs{display:none}.rail{top:105px}.mission h1{font-size:23px}.heading{top:175px}}
@media(max-width:700px){#navMap{height:94px}.touch-controls{gap:3px;flex-wrap:wrap;max-width:205px}.touch-controls button{width:34px;padding:9px 0}.mission .eyebrow{max-width:175px}.scene-tag{display:none}.telemetry .reading{font-size:20px}.telemetry .throttle .reading{font-size:20px}.telemetry .instrument{padding-left:10px;padding-right:10px}}
</style>
</head>
<body>
<canvas id="world"></canvas><div id="threeMount"></div><div class="vignette"></div>
<header><div class="brand"><svg viewBox="0 0 40 40" fill="none"><path d="M4 32 19 5h5l12 27H25l-5-12-6 12Z" fill="#d2eeaf"/><path d="m17 29 4-7 4 10H15Z" fill="#162b31"/></svg><div><b>APEX AERO</b><small>THE ART OF FLIGHT</small></div></div><nav class="mode-tabs" aria-label="Mission"><button class="active" data-mode="precision"><span>↗</span> Precision flight</button><button data-mode="dogfight"><span>⌖</span> Dogfight</button></nav><div class="top-actions"><div class="live"><i class="dot"></i> LIVE SIMULATION</div><button class="icon-btn" id="audioBtn" title="Toggle sound" aria-label="Toggle sound">♪</button><button class="icon-btn" id="helpBtn" title="Flight controls">?</button><button class="icon-btn" id="settingsBtn" title="Settings">⚙</button></div></header>
<div class="scene-tag">FREE FLIGHT · NORTH ATLANTIC<div class="line"></div></div><div class="compass">330 · 345 · N · 015 · 030</div><div class="heading" id="heading">000°</div>
<aside class="rail"><div class="section-label eyebrow"><span>SELECT AIRCRAFT</span><span>01 / 03</span></div><div class="fleet">
<button class="aircraft active" data-plane="0"><svg viewBox="0 0 80 60"><path fill="currentColor" d="m40 3 3 8 2 15 31 10v5L45 36l-1 15 12 5v3l-16-3-16 3v-3l12-5-1-15-31 5v-5l31-10 2-15Z"/><path d="M40 15v34" stroke="#314b50"/></svg><div><b>Cessna 172</b><small>LIGHT · SINGLE ENGINE</small></div><span class="check">✓</span></button>
<button class="aircraft" data-plane="1"><svg viewBox="0 0 80 60"><path fill="currentColor" d="m40 1 4 12 2 17 23 15v5l-24-8-1 9 10 7-14-3-14 3 10-7-1-9-24 8v-5l23-15 2-17Z"/></svg><div><b>F-16 Falcon</b><small>FIGHTER · TURBOFAN</small></div><span class="check">✓</span></button>
<button class="aircraft" data-plane="2"><svg viewBox="0 0 80 60"><path fill="currentColor" d="m40 2 4 7 1 16 31 18v4L45 36l-1 14 13 7v3l-17-5-17 5v-3l13-7-1-14L4 47v-4l31-18 1-16Z"/><path fill="currentColor" d="M27 34h5v11h-5zm21 0h5v11h-5z"/></svg><div><b>Aero L-39</b><small>JET TRAINER · TURBOFAN</small></div><span class="check">✓</span></button></div><div class="specs"><div><span>MAX SPEED</span><b id="specSpeed">126</b> KTS</div><div><span>MTOW</span><b id="specMass">1,110</b> KG</div><div><span>ENVELOPE</span><b id="specG">+3.8 G</b></div></div></aside>
<aside class="location"><div class="eyebrow">YOUR AIRSPACE</div><div class="name">Nordfjord, Norway</div><div class="coords">61°51′ N 06°07′ E</div><div class="weather"><span class="sun">☀</span><div><b>Clear skies · 15°C</b><small>WIND 270° / 4 KTS ↗</small></div></div><div class="checklist"><div class="eyebrow" id="objectiveTitle">DEPARTURE CHECKLIST</div><div class="task done" id="task0"><i>✓</i><span>Aircraft ready</span></div><div class="task" id="task1"><i>1</i><span>Apply full throttle</span></div><div class="task" id="task2"><i>2</i><span>Rotate at 55 knots</span></div><div class="task" id="task3"><i>3</i><span>Climb to 1,000 feet</span></div></div></aside>
<div class="center-reticle"><span></span></div><div id="targetHud"><span id="targetRange"></span></div><div class="toast" id="toast"></div>
<section class="mission"><div class="eyebrow"><i class="dot"></i><span id="phase">PRECISION FLIGHT / 01</span></div><h1 id="missionTitle">Every great flight<br>starts here.</h1><p id="missionDesc">A quiet runway. A world of possibility.<br>Master the fundamentals of flight.</p><button class="launch" id="launchBtn"><span>Begin flight ↗</span><small>ENTER</small></button></section>
<div class="camera-tools"><div class="engine-tag">NEWTONIAN PHYSICS · 120 HZ</div><button id="cameraBtn"><kbd>C</kbd><span>Chase camera</span></button><button id="resetBtn"><kbd>R</kbd>Reset flight</button><button id="controlsBtn"><kbd>?</kbd>Flight controls</button></div>
<div class="touch-controls"><button data-key="ArrowLeft">◀</button><button data-key="ArrowDown">↑</button><button data-key="ArrowUp">↓</button><button data-key="ArrowRight">▶</button><button data-key="KeyW">+</button><button data-key="Space">◎</button></div>
<section class="telemetry"><div class="instrument"><div class="label">AIRSPEED</div><div class="reading"><span id="speed">000</span><small>KTS</small></div><div class="sub" id="speedSub">INDICATED AIRSPEED</div></div><div class="instrument"><div class="label">ALTITUDE</div><div class="reading"><span id="altitude">0066</span><small>FT</small></div><div class="sub" id="agl">6 FT AGL</div></div><div class="instrument vertical"><div class="label">VERTICAL SPEED</div><div class="reading"><span id="vs">+0</span><small>FPM</small></div><div class="sub">RATE OF CLIMB</div></div><div class="instrument attitude"><canvas id="attitude" width="136" height="136"></canvas></div><div class="instrument angles"><div class="label">ATTITUDE</div><div class="reading"><span id="pitch">0</span><sup>°</sup><small>PITCH</small></div><div class="sub"><span id="roll">0</span>° ROLL / <span id="gforce">1.0</span> G</div></div><div class="instrument throttle"><div class="label">ENGINE / THROTTLE</div><div class="reading"><span id="throttle">0</span><small>%</small></div><div class="bar"><div id="throttleBar"></div></div><div class="sub"><span id="rpm">600</span> RPM · <span id="engineStatus">IDLE</span></div></div><div class="instrument status"><div class="label" id="flightStatus">● ON THE GROUND</div><div class="sub" id="flightDetail">PARKING BRAKE SET<br>FLAPS 10° · GEAR DOWN</div></div></section>
<footer class="footer"><span><b>W / S</b> THROTTLE <b>↑ ↓ ← →</b> PITCH / ROLL <b>A / D</b> RUDDER</span><span class="build">APEX FLIGHT SYSTEMS / <span id="renderLabel">PROCEDURAL WORLD</span></span><span id="clock">FLIGHT TIME 00:00</span></footer>
<div class="modal" id="helpModal"><div class="dialog"><div class="eyebrow">YOUR FIRST FLIGHT</div><h2>A little input. A lot of sky.</h2><p>Build airspeed before pulling back. Bank gently and add back pressure to turn. Energy, momentum, and every control input matter.</p><div class="control-row"><span>Pitch down / up · Roll left / right</span><kbd>↑ / ↓ ← / →</kbd></div><div class="control-row"><span>Throttle up / down · Rudder</span><kbd>W / S A / D</kbd></div><div class="control-row"><span>Wheel brakes · Flaps · Landing gear</span><kbd>B F G</kbd></div><div class="control-row"><span>Camera · Mouse flight toggle</span><kbd>C M</kbd></div><div class="control-row"><span>Fire cannon · Pause · Restart</span><kbd>SPACE P R</kbd></div><p id="pilotTip">Cessna 172: rotate at 55 KTS, climb at 75 KTS. Complete your circuit, then land on runway 36 below 70 KTS with a gentle sink rate.</p><button class="launch" data-close="helpModal"><span>Ready, captain</span><span>↗</span></button></div></div>
<div class="modal" id="settingsModal"><div class="dialog"><div class="eyebrow">FLIGHT SYSTEMS</div><h2>Make it your airspace.</h2><div class="switch-row"><label for="volume">Audio volume</label><input id="volume" type="range" min="0" max="100" value="45"></div><div class="switch-row"><label for="turbulence">Atmospheric turbulence</label><input id="turbulence" type="checkbox" checked></div><div class="switch-row"><label for="mouseMode">Mouse flight control</label><input id="mouseMode" type="checkbox"></div><div class="switch-row"><label for="quality">Terrain detail</label><select id="quality" style="background:#263f44;color:#d7e4dc;border:1px solid #577176;padding:7px"><option value="high">High</option><option value="low">Performance</option></select></div><p>Full aerodynamic model · ISA atmosphere · Rigid-body inertia · Ground contact. No arcade steering or altitude locks.</p><button class="launch" data-close="settingsModal"><span>Return to the flight deck</span><span>↗</span></button></div></div>
<script>
'use strict';
const $=id=>document.getElementById(id), clamp=(x,a,b)=>Math.max(a,Math.min(b,x)), lerp=(a,b,t)=>a+(b-a)*t, rad=Math.PI/180;
class V{constructor(x=0,y=0,z=0){this.x=x;this.y=y;this.z=z}add(v){return new V(this.x+v.x,this.y+v.y,this.z+v.z)}sub(v){return new V(this.x-v.x,this.y-v.y,this.z-v.z)}mul(s){return new V(this.x*s,this.y*s,this.z*s)}dot(v){return this.x*v.x+this.y*v.y+this.z*v.z}cross(v){return new V(this.y*v.z-this.z*v.y,this.z*v.x-this.x*v.z,this.x*v.y-this.y*v.x)}len(){return Math.hypot(this.x,this.y,this.z)}unit(){return this.mul(1/(this.len()||1))}}
class Q{constructor(x=0,y=0,z=0,w=1){Object.assign(this,{x,y,z,w})}mul(q){return new Q(this.w*q.x+this.x*q.w+this.y*q.z-this.z*q.y,this.w*q.y-this.x*q.z+this.y*q.w+this.z*q.x,this.w*q.z+this.x*q.y-this.y*q.x+this.z*q.w,this.w*q.w-this.x*q.x-this.y*q.y-this.z*q.z)}norm(){let n=Math.hypot(this.x,this.y,this.z,this.w);return new Q(this.x/n,this.y/n,this.z/n,this.w/n)}inv(){return new Q(-this.x,-this.y,-this.z,this.w)}rot(v){let qv=new V(this.x,this.y,this.z),t=qv.cross(v).mul(2);return v.add(t.mul(this.w)).add(qv.cross(t))}static axis(v,a){let s=Math.sin(a/2);return new Q(v.x*s,v.y*s,v.z*s,Math.cos(a/2))}}
const fleet=[{name:'Cessna 172',mass:1110,area:16.2,span:11,cl0:.27,cla:4.9,cd:.029,k:.052,thrust:2600,power:134000,stall:16,max:126,rotate:55,g:3.8,inertia:new V(1300,1900,2100),response:new V(.38,.35,1.25),length:8.4,color:'#e5e7d8',type:'prop'}, {name:'F-16 Falcon',mass:12000,area:27.9,span:9.96,cl0:.14,cla:3.8,cd:.022,k:.095,thrust:129000,power:0,stall:25,max:1145,rotate:150,g:9,inertia:new V(76000,96000,19000),response:new V(2.1,.8,4.7),length:15,color:'#89969a',type:'fighter'}, {name:'Aero L-39',mass:4700,area:18.8,span:9.5,cl0:.2,cla:4.5,cd:.026,k:.07,thrust:16900,power:0,stall:19,max:405,rotate:105,g:8,inertia:new V(21000,29000,8000),response:new V(1.7,.7,3.2),length:12.1,color:'#e6e5d8',type:'trainer'}];
let selected=0,mode='precision',running=false,paused=false,crashed=false,started=false,elapsed=0,throttle=0,spool=0,flaps=10,gear=true,brake=false,mouseControl=false,cameraMode=0,audioOn=true,turbulence=true,volume=.45,phase=0,shots=0,kills=0,fireClock=0,toastTimer=0,warning='',lastAlpha=0,lastG=1,ias=0;
let pos=new V(0,19.5,190),vel=new V(),orientation=new Q(),omega=new V(),controls=new V(),cameraPos=new V(-19,29,229),cameraTarget=new V(0,21,145),keys={},mouse={x:0,y:0},bullets=[],enemy={pos:new V(100,1100,-1500),vel:new V(),q:new Q(),hp:100},wind=new V(2.06,0,0);
let W=innerWidth,H=innerHeight,ctx=$('world').getContext('2d'),cameraBasis,terrain=[],worldMeshes=[],planeMesh=[],enemyMesh=[],trees=[],T=null,renderer,scene,camera,airGroup,enemyGroup,terrainGroup,threeBullets;
function hash(x,z){let a=Math.sin(x*127.1+z*311.7)*43758.5453;return a-Math.floor(a)}
function noise(x,z){let i=Math.floor(x),j=Math.floor(z),a=x-i,b=z-j;a=a*a*(3-2*a);b=b*b*(3-2*b);return lerp(lerp(hash(i,j),hash(i+1,j),a),lerp(hash(i,j+1),hash(i+1,j+1),a),b)}
function fbm(x,z){return noise(x,z)*.57+noise(x*2,z*2)*.27+noise(x*4,z*4)*.11+noise(x*8,z*8)*.05}
function heightAt(x,z){let n=fbm(x/1050+3,z/1050-7);let left=1550*Math.exp(-(((x+2450)/1500)**2)),right=2050*Math.exp(-(((x-3550)/1500)**2));let range=(left+right)*(.25+n*1.03);let channel=125*Math.exp(-(((x-1050-180*Math.sin(z/1600))/650)**2));let h=range-95-channel;let alpine=clamp((h-370)/650,0,1);h+=alpine*(1-Math.abs(noise(x/440+17,z/440)*2-1))*(noise(x/110,z/110)*90+110);let flatten=1-clamp((Math.abs(x+40)-155)/370,0,1);let runwayZone=1-clamp((Math.abs(z+620)-1090)/650,0,1);return lerp(h,18,flatten*runwayZone)}
function colorRGB(hex){return [parseInt(hex.slice(1,3),16),parseInt(hex.slice(3,5),16),parseInt(hex.slice(5,7),16)]}
function mesh(vertices,faces,color,extra={}){return {v:vertices,f:faces,c:colorRGB(color),...extra}}
function box(x,y,z,sx,sy,sz,c){let v=[];for(let a of [-1,1])for(let b of [-1,1])for(let d of [-1,1])v.push(new V(x+a*sx/2,y+b*sy/2,z+d*sz/2));return mesh(v,[[0,1,3,2],[4,6,7,5],[0,4,5,1],[2,3,7,6],[0,2,6,4],[1,5,7,3]],c)}
function ellipsoid(x,y,z,sx,sy,sz,c,n=14,r=9){let v=[],f=[];for(let j=0;j<=r;j++){let p=Math.PI*j/r;for(let i=0;i<n;i++){let t=i/n*Math.PI*2;v.push(new V(x+Math.sin(p)*Math.cos(t)*sx,y+Math.sin(p)*Math.sin(t)*sy,z+Math.cos(p)*sz))}}for(let j=0;j<r;j++)for(let i=0;i<n;i++)f.push([j*n+i,j*n+(i+1)%n,(j+1)*n+(i+1)%n,(j+1)*n+i]);return mesh(v,f,c)}
function wing(points,thickness,c){let v=points.map(p=>new V(...p));v.push(...points.map(p=>new V(p[0],p[1]-thickness,p[2])));let n=points.length,f=[Array.from({length:n},(_,i)=>i),Array.from({length:n},(_,i)=>n+i).reverse()];for(let i=0;i<n;i++)f.push([i,(i+1)%n,(i+1)%n+n,i+n]);return mesh(v,f,c)}
function makePlane(index){let a=fleet[index],m=[],L=a.length,b=a.span/2,prop=index===0,col=a.color; m.push(ellipsoid(0,.05,0,prop?.66:.74,prop?.73:.72,L/2,col));m.push(ellipsoid(0,.69,-L*.13,prop?.55:.49,prop?.46:.51,L*(prop?.14:.17),'#274950'));
for(let side of [-1,1]){m.push(wing([[side*.4,prop?.8:.05,-1],[side*b,prop?.88:.23,prop?-1.2:1.2],[side*b,prop?.88:.23,prop?.15:2.25],[side*.45,prop?.8:.05,prop?.55:2.5]],.13,col));m.push(wing([[side*.22,.2,L*.3],[side*(prop?1.9:2.1),.26,L*.36],[side*(prop?1.9:2.1),.26,L*.45],[side*.2,.18,L*.46]],.09,col));m.push(box(side*(b-.16),prop?.84:.24,prop?-.5:1.73,.24,.16,prop?1.25:1.0,side<0?'#9d4b35':'#467460'));if(prop){m.push(wing([[side*.48,-.5,.1],[side*3.5,.72,-.4],[side*3.56,.72,-.3],[side*.51,-.5,.24]],.045,'#aabbb7'));m.push(box(side*1.05,-.95,.2,.08,1,.12,'#abb5af'));m.push(ellipsoid(side*1.05,-1.37,.2,.16,.3,.3,'#252d2c',8,6))}}
m.push(wing([[0,.3,L*.25],[0,prop?2.1:2.45,L*.35],[0,prop?2:2.1,L*.47],[0,.2,L*.48]],.1,col));m.push(wing([[.055,.7,L*.31],[.055,prop?1.65:1.85,L*.375],[.055,prop?1.6:1.65,L*.46],[.055,.6,L*.46]],.012,index===1?'#495c5d':'#bf603d'));
if(prop){m.push(ellipsoid(0,0,-L/2,.4,.4,.35,'#c66b45'));m.push(box(0,0,-L/2-.2,.1,2,.065,'#343e39'));m.push(box(0,-1,-2,.08,.8,.1,'#a7b3ab'));m.push(ellipsoid(0,-1.3,-2,.13,.25,.25,'#293330',8,6));m.push(box(0,-.25,-1.3,1.25,.12,3,'#bd653e'))}else{m.push(ellipsoid(0,-.35,L*.44,.47,.45,.7,'#334344'));m.push(ellipsoid(0,-.53,-L*.11,.53,.4,L*.2,'#566b6f'));for(let s of [-1,1]){m.push(box(s*1,-.86,.65,.1,.85,.12,'#b6c5bd'));m.push(ellipsoid(s*1,-1.28,.65,.17,.3,.3,'#202d2d',8,6))}m.push(ellipsoid(0,-1.25,-L*.26,.15,.25,.25,'#202d2d',8,6));if(index===1)for(let s of [-1,1])m.push(ellipsoid(s*(b-.3),.05,1.1,.1,.1,1.5,'#d0d3c9'))}return m}
function buildWorld(){terrain=[];worldMeshes=[];let N=$('quality').value==='low'?64:100,size=14000,step=size/N;let v=[],f=[],colors=[];for(let j=0;j<=N;j++)for(let i=0;i<=N;i++){let x=-size/2+i*step,z=-10000+j*step;v.push(new V(x,heightAt(x,z),z))}for(let j=0;j<N;j++)for(let i=0;i<N;i++){let k=j*(N+1)+i;for(let tri of [[k,k+1,k+N+1],[k+1,k+N+2,k+N+1]]){let h=tri.reduce((a,b)=>a+v[b].y,0)/3,n=hash(i,j);f.push(tri);colors.push(h>1120?[156+n*25,166+n*22,161+n*22]:h>620?[94+n*19,115+n*19,108+n*15]:[76+n*19,108+n*18,91+n*15])}}terrain.push({v,f,c:[100,130,104],colors,terrain:true});worldMeshes.push(box(0,17.6,-640,90,1,1910,'#8c998c'));worldMeshes.push(box(0,18.2,-640,35,.3,1830,'#525e5e'));for(let z=150;z>-1530;z-=90)worldMeshes.push(box(0,18.4,z,.65,.04,34,'#e3e4cd'));for(let s of [-1,1]){worldMeshes.push(box(s*16,18.42,-640,.3,.04,1810,'#c6cdbc'));for(let i=0;i<7;i++)worldMeshes.push(box(s*(3+i*1.7),18.43,210,1,.03,28,'#eeedda'));for(let z=210;z>-1550;z-=75)worldMeshes.push(box(s*19,18.7,z,.6,.6,.6,'#d5d9ac'))}
worldMeshes.push(box(-85,18.18,80,75,.12,90,'#7b8b82'));worldMeshes.push(box(-108,27,60,37,14,27,'#9faaa2'));worldMeshes.push(wing([[-130,34,43],[-108,42,43],[-87,34,43],[-87,34,77],[-108,42,77],[-130,34,77]],.4,'#6c817e'));worldMeshes.push(box(-68,28,0,8,20,8,'#a7b6aa'));worldMeshes.push(box(-68,39,0,13,5,12,'#3d6061'));worldMeshes.push(box(-68,42,0,15,1,14,'#a5b4a8'));
for(let i=0;i<200;i++){let x=(hash(i,88)-.5)*6500,z=hash(i,45)*7300-5800,h=heightAt(x,z);if(h<24||h>740||Math.abs(x)<90)continue;let sz=7+hash(i,27)*10;worldMeshes.push(mesh([new V(x,h,z),new V(x-sz/2,h+2,z-sz/3),new V(x+sz/2,h+2,z-sz/3),new V(x,h+sz*2,z),new V(x,h+2,z+sz/2)],[[1,2,3],[2,4,3],[4,1,3]],'#3e6557'))}planeMesh=makePlane(selected);enemyMesh=makePlane(1)}
function updateCamera(dt){const forward=orientation.rot(new V(0,0,-1)),vertical=new V(0,1,0);let camUp=vertical;const length=fleet[selected].length;if(cameraMode===1){cameraPos=pos.add(orientation.rot(new V(0,1.12,-length*.15)));cameraTarget=cameraPos.add(forward.mul(200));camUp=orientation.rot(vertical)}else{const distance=cameraMode===2?length*1.9:length*3.7,above=cameraMode===2?length*.35:length*.95;cameraPos=pos.sub(forward.mul(distance)).add(vertical.mul(above));cameraPos.y=Math.max(cameraPos.y,Math.max(0,heightAt(cameraPos.x,cameraPos.z))+2.5);cameraTarget=pos.add(forward.mul(cameraMode===2?32:48))}let f=cameraTarget.sub(cameraPos).unit(),r=f.cross(camUp).unit(),u=r.cross(f).unit();cameraBasis={f,r,u,focal:H*.88};if(T&&camera){camera.position.set(cameraPos.x,cameraPos.y,cameraPos.z);camera.up.set(camUp.x,camUp.y,camUp.z);camera.lookAt(cameraTarget.x,cameraTarget.y,cameraTarget.z)}}
function project(p){let d=p.sub(cameraPos),z=d.dot(cameraBasis.f);if(z<1)return null;return {x:W/2+d.dot(cameraBasis.r)*cameraBasis.focal/z,y:H/2-d.dot(cameraBasis.u)*cameraBasis.focal/z,z}}
function drawSoftware(){ctx.clearRect(0,0,W,H);let sky=ctx.createLinearGradient(0,0,0,H);sky.addColorStop(0,'#7d9fad');sky.addColorStop(.43,'#c3ccc3');sky.addColorStop(.64,'#e7dbc3');sky.addColorStop(1,'#769ba0');ctx.fillStyle=sky;ctx.fillRect(0,0,W,H);let sun=project(new V(-4200,2800,-14000));if(sun){let glow=ctx.createRadialGradient(sun.x,sun.y,2,sun.x,sun.y,200);glow.addColorStop(0,'#fff3d489');glow.addColorStop(1,'#ffe3b700');ctx.fillStyle=glow;ctx.fillRect(0,0,W,H);ctx.beginPath();ctx.arc(sun.x,sun.y,20,0,7);ctx.fillStyle='#ffedca';ctx.fill()}
let water=project(new V(0,0,-50000)),horizon=water?water.y:H*.45;ctx.fillStyle='#628e96';ctx.fillRect(0,Math.max(0,horizon),W,H);let polygons=[];let sunDir=new V(-.5,.8,.2).unit();
function addMesh(m,p,q){let verts=p?m.v.map(v=>q.rot(v).add(p)):m.v;let screen=verts.map(project);for(let i=0;i<m.f.length;i++){let face=m.f[i],sp=face.map(k=>screen[k]);if(sp.some(v=>!v))continue;if(sp.every(v=>v.x<-100)||sp.every(v=>v.x>W+100)||sp.every(v=>v.y<-100)||sp.every(v=>v.y>H+100))continue;let depth=sp.reduce((s,v)=>s+v.z,0)/sp.length;let a=verts[face[0]],b=verts[face[1]],c=verts[face[2]],n=b.sub(a).cross(c.sub(a)).unit();if(n.y<0&&m.terrain)n=n.mul(-1);let light=.61+Math.max(0,n.dot(sunDir))*.42,co=m.colors?m.colors[i]:m.c,fog=clamp(1-Math.exp(-depth/10500),0,.86);let col=co.map((v,j)=>Math.round(lerp(v*light,[176,194,186][j],fog)));polygons.push({sp,depth,col})}}
terrain.forEach(m=>addMesh(m));worldMeshes.forEach(m=>addMesh(m));if(cameraMode!==1)planeMesh.forEach(m=>addMesh(m,pos,orientation));if(mode==='dogfight'&&enemy.hp>0)enemyMesh.forEach(m=>addMesh(m,enemy.pos,enemy.q));polygons.sort((a,b)=>b.depth-a.depth);for(let p of polygons){ctx.beginPath();p.sp.forEach((v,i)=>i?ctx.lineTo(v.x,v.y):ctx.moveTo(v.x,v.y));ctx.closePath();ctx.fillStyle=`rgb(${p.col.join(',')})`;ctx.fill();ctx.strokeStyle=ctx.fillStyle;ctx.lineWidth=.4;ctx.stroke()}
let sh=project(new V(pos.x,Math.max(18.5,heightAt(pos.x,pos.z)+.3),pos.z));if(sh&&pos.y-heightAt(pos.x,pos.z)<12){ctx.save();ctx.translate(sh.x,sh.y);ctx.scale(1,.3);ctx.beginPath();ctx.ellipse(0,0,clamp(200/sh.z,1,12),clamp(230/sh.z,1,14),0,0,7);ctx.fillStyle='#142b2833';ctx.fill();ctx.restore()}for(let b of bullets){let a=project(b.p),c=project(b.p.sub(b.v.unit().mul(25)));if(a&&c){ctx.strokeStyle='#ffedb0';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(c.x,c.y);ctx.stroke()}}}
function loadThree(){let s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/three@0.160.1/build/three.min.js';s.onload=()=>{try{initThree()}catch(e){console.warn('WebGL unavailable; using local preview renderer',e)}};s.onerror=()=>{$('renderLabel').textContent='LOCAL RENDERER · OFFLINE'};document.head.appendChild(s)}
function initThree(){T=window.THREE;renderer=new T.WebGLRenderer({antialias:true,alpha:false});renderer.setSize(W,H);renderer.setPixelRatio(Math.min(devicePixelRatio,1.7));renderer.shadowMap.enabled=true;renderer.shadowMap.type=T.PCFSoftShadowMap;renderer.toneMapping=T.ACESFilmicToneMapping;renderer.toneMappingExposure=.98;$('threeMount').appendChild(renderer.domElement);scene=new T.Scene();scene.background=new T.Color('#a6bbc0');scene.fog=new T.FogExp2('#bdccc8',.000075);camera=new T.PerspectiveCamera(59.2,W/H,0.3,40000);scene.add(new T.HemisphereLight('#e4f4ff','#52684b',2.4));let sun=new T.DirectionalLight('#ffe0ad',3.6);sun.position.set(-1600,2600,1000);sun.castShadow=true;sun.shadow.mapSize.set(2048,2048);sun.shadow.camera.left=-300;sun.shadow.camera.right=300;sun.shadow.camera.top=300;sun.shadow.camera.bottom=-300;sun.shadow.camera.far=6000;sun.shadow.bias=-.0003;scene.add(sun);scene.add(sun.target);let water=new T.Mesh(new T.PlaneGeometry(60000,60000),new T.MeshStandardMaterial({color:'#497d88',roughness:.29,metalness:.48}));water.rotation.x=-Math.PI/2;water.position.y=0;scene.add(water);let sunDisc=new T.Mesh(new T.SphereGeometry(180,20,12),new T.MeshBasicMaterial({color:'#ffebc8'}));sunDisc.position.set(-6000,4700,-21000);scene.add(sunDisc);rebuildThree();$('world').style.visibility='hidden';$('renderLabel').textContent='THREE.JS · WEBGL'}
let groundTexture=null;
function terrainTexture(){if(groundTexture)return groundTexture;let c=document.createElement('canvas');c.width=c.height=256;let x=c.getContext('2d'),im=x.createImageData(256,256);for(let i=0;i<256*256;i++){let n=160+hash(i%256,Math.floor(i/256))*65;im.data[i*4]=n;im.data[i*4+1]=n;im.data[i*4+2]=n;im.data[i*4+3]=255}x.putImageData(im,0,0);groundTexture=new T.CanvasTexture(c);groundTexture.wrapS=groundTexture.wrapT=T.RepeatWrapping;groundTexture.colorSpace=T.SRGBColorSpace;groundTexture.anisotropy=8;return groundTexture}
function threeMesh(m){if(m.terrain){let g=new T.BufferGeometry(),v=[],c=[],uv=[];m.v.forEach(p=>{v.push(p.x,p.y,p.z);uv.push(p.x/70,p.z/70);let n=fbm(p.x/320,p.z/320),co=p.y>1120?[148,157,153]:p.y>620?[80,99,96]:[52+n*28,77+n*24, 59+n*19];c.push(...co.map(z=>Math.pow(z/255,2.2)))});g.setAttribute('position',new T.Float32BufferAttribute(v,3));g.setAttribute('color',new T.Float32BufferAttribute(c,3));g.setAttribute('uv',new T.Float32BufferAttribute(uv,2));g.setIndex(m.f.flat());g.computeVertexNormals();let o=new T.Mesh(g,new T.MeshStandardMaterial({vertexColors:true,map:terrainTexture(),roughness:.96,side:T.DoubleSide}));o.receiveShadow=true;return o}let vertices=[],colors=[];for(let i=0;i<m.f.length;i++){let f=m.f[i],c=m.colors?m.colors[i]:m.c;for(let j=1;j<f.length-1;j++)for(let k of [f[0],f[j],f[j+1]]){let v=m.v[k];vertices.push(v.x,v.y,v.z);colors.push(...c.map(v=>Math.pow(v/255,2.2)))}}let g=new T.BufferGeometry();g.setAttribute('position',new T.Float32BufferAttribute(vertices,3));g.setAttribute('color',new T.Float32BufferAttribute(colors,3));g.computeVertexNormals();let material=new T.MeshStandardMaterial({vertexColors:true,roughness:m.terrain?.92:.55,metalness:m.terrain?0:.15,side:T.DoubleSide});let o=new T.Mesh(g,material);o.castShadow=!m.terrain;o.receiveShadow=true;return o}
function disposeGroup(g){if(!g)return;scene.remove(g);g.traverse(o=>{if(o.geometry)o.geometry.dispose();if(o.material)o.material.dispose()})}
function rebuildThree(){if(!scene)return;disposeGroup(terrainGroup);disposeGroup(airGroup);disposeGroup(enemyGroup);terrainGroup=new T.Group();terrain.forEach(m=>terrainGroup.add(threeMesh(m)));let merged={v:[],f:[],colors:[],c:[100,120,100]};for(let m of worldMeshes){let offset=merged.v.length;merged.v.push(...m.v);m.f.forEach((f,i)=>{merged.f.push(f.map(k=>k+offset));merged.colors.push(m.colors?m.colors[i]:m.c)})}terrainGroup.add(threeMesh(merged));scene.add(terrainGroup);airGroup=new T.Group();planeMesh.forEach(m=>airGroup.add(threeMesh(m)));scene.add(airGroup);enemyGroup=new T.Group();enemyMesh.forEach(m=>enemyGroup.add(threeMesh(m)));scene.add(enemyGroup);if(!threeBullets){let g=new T.BufferGeometry();g.setAttribute('position',new T.BufferAttribute(new Float32Array(600),3));threeBullets=new T.LineSegments(g,new T.LineBasicMaterial({color:'#fff0b2'}));threeBullets.frustumCulled=false;scene.add(threeBullets)}}
function drawThree(){airGroup.position.set(pos.x,pos.y,pos.z);airGroup.quaternion.set(orientation.x,orientation.y,orientation.z,orientation.w);airGroup.visible=cameraMode!==1;if(selected!==0)airGroup.children.forEach((o,i)=>{o.visible=gear||!planeMesh[i].v.every(v=>v.y<-.35)});enemyGroup.position.set(enemy.pos.x,enemy.pos.y,enemy.pos.z);enemyGroup.quaternion.set(enemy.q.x,enemy.q.y,enemy.q.z,enemy.q.w);enemyGroup.visible=mode==='dogfight'&&enemy.hp>0;let a=threeBullets.geometry.attributes.position;bullets.slice(0,100).forEach((b,i)=>{a.setXYZ(i*2,b.p.x,b.p.y,b.p.z);let t=b.p.sub(b.v.unit().mul(25));a.setXYZ(i*2+1,t.x,t.y,t.z)});a.needsUpdate=true;threeBullets.geometry.setDrawRange(0,Math.min(100,bullets.length)*2);threeBullets.visible=bullets.length>0;renderer.render(scene,camera)}
function notify(text,time=4,warn=false){$('toast').textContent=text;$('toast').className='toast show'+(warn?' warning':'');toastTimer=time}
function setTask(i,done){let e=$('task'+i);e.classList.toggle('done',done);e.querySelector('i').textContent=done?'✓':i}
function resetFlight(){stress=0;airframe=100;enemyFireTimer=0;running=false;paused=false;crashed=false;started=false;elapsed=0;throttle=0;spool=0;phase=0;flaps=mode==='precision'?10:0;gear=mode==='precision'||selected===0;brake=false;omega=new V();controls=new V();orientation=new Q();bullets=[];shots=0;kills=0;fireClock=0;lastG=1;lastAlpha=0;ias=0;keys={};if(mode==='precision'){pos=new V(0,19.5,190);vel=new V()}else{pos=new V(0,1050,100);vel=new V(0,0,-(selected===0?57:selected===1?210:140));throttle=.78;spool=.78;enemy={pos:new V(30,1080,-750),vel:new V(0,0,-vel.len()*.88),q:new Q(),hp:100}}cameraPos=pos.add(new V(-19,10,39));cameraTarget=pos.add(new V(0,0,-50));$('launchBtn').innerHTML='<span>Begin flight ↗</span><small>ENTER</small>';document.body.classList.remove('flying');$('missionTitle').innerHTML=mode==='precision'?'Every great flight<br>starts here.':'Own your energy.<br>Own the sky.';$('missionDesc').innerHTML=mode==='precision'?'A quiet runway. A world of possibility.<br>Master the fundamentals of flight.':'One adversary. No second chances.<br>Fly the aircraft, not just the crosshair.';$('phase').textContent=mode==='precision'?'PRECISION FLIGHT / 01':'DOGFIGHT / 02';$('objectiveTitle').textContent=mode==='precision'?'DEPARTURE CHECKLIST':'ENGAGEMENT OBJECTIVES';$('task0').querySelector('span').textContent=mode==='precision'?'Aircraft ready':'Weapons armed';$('task1').querySelector('span').textContent=mode==='precision'?'Apply full throttle':'Close to 800 meters';$('task2').querySelector('span').textContent=mode==='precision'?`Rotate at ${fleet[selected].rotate} knots`:'Align and fire cannon';$('task3').querySelector('span').textContent=mode==='precision'?'Climb to 1,000 feet':'Neutralize the bandit';for(let i=1;i<=3;i++)setTask(i,false);$('toast').className='toast';toastTimer=0;updateHUD();updateCamera(1)}
function selectPlane(i){selected=i;document.querySelectorAll('.aircraft').forEach((b,j)=>b.classList.toggle('active',i===j));document.querySelector('.section-label span:last-child').textContent=`0${i+1} / 03`;$('specSpeed').textContent=fleet[i].max.toLocaleString();$('specMass').textContent=fleet[i].mass.toLocaleString();$('specG').textContent='+'+fleet[i].g+' G';$('pilotTip').textContent=`${fleet[i].name}: rotate at ${fleet[i].rotate} KTS; climb at ${Math.round(fleet[i].rotate*1.35)} KTS. Fly a circuit, lower the gear, and land below ${Math.round(fleet[i].rotate*1.3)} KTS with a gentle sink rate.`;planeMesh=makePlane(i);if(T&&scene)rebuildThree();resetFlight()}
function beginFlight(){if(crashed)resetFlight();if(!started){started=true;running=true;paused=false;document.body.classList.add('flying');$('missionTitle').textContent=mode==='precision'?'Runway 36. Cleared.':'Bandit in your airspace.';$('phase').textContent=mode==='precision'?'DEPARTURE / RUNWAY 36':'ENGAGEMENT / LIVE';notify(mode==='precision'?`Hold W for throttle. At ${fleet[selected].rotate} KTS, press ↓ to rotate.`:'Close on the orange marker. Hold SPACE to fire.',6);initAudio()}else{paused=!paused;running=!paused;if(paused)notify('Flight paused. Press P or resume to continue.',100);else{$('toast').className='toast';toastTimer=0}}$('launchBtn').innerHTML=paused?'<span>Resume flight ▷</span><small>P</small>':'<span>Pause flight Ⅱ</span><small>P</small>'}
function crash(reason){crashed=true;running=false;notify(reason+' · Press R to try again.',100,true);$('missionTitle').textContent='Flight ended.';$('phase').textContent='DEBRIEF / AIRCRAFT LOST';$('flightStatus').textContent='● AIRCRAFT LOST';$('launchBtn').innerHTML='<span>Try again ↗</span><small>ENTER</small>';if(audio)audio.impact()}
function angles(){let f=orientation.rot(new V(0,0,-1)),right=orientation.rot(new V(1,0,0)),up=orientation.rot(new V(0,1,0));return {pitch:Math.asin(clamp(f.y,-1,1))/rad,roll:Math.atan2(right.y,up.y)/rad,heading:(Math.atan2(f.x,-f.z)/rad+360)%360}}
function physics(dt){if(!running||crashed)return;elapsed+=dt;let a=fleet[selected];throttle=clamp(throttle+((keys.KeyW?1:0)-(keys.KeyS?1:0))*dt*.25,0,1);spool+=(throttle-spool)*(1-Math.exp(-dt/(a.type==='prop'?.85:2.8)));brake=!!keys.KeyB;let input=new V((keys.ArrowDown?1:0)-(keys.ArrowUp?1:0), (keys.KeyA?1:0)-(keys.KeyD?1:0), (keys.ArrowLeft?1:0)-(keys.ArrowRight?1:0));input.x+=trim;if(mouseControl){input.x-=mouse.y;input.z-=mouse.x}controls=controls.add(input.sub(controls).mul(1-Math.exp(-dt*6)));
let gust=turbulence?new V(Math.sin(elapsed*.61)*.75,Math.sin(elapsed*1.7+pos.x*.003)*.38,Math.sin(elapsed*.37)*.5):new V();let air=vel.sub(wind.add(gust)),v=Math.max(.1,air.len()),local=orientation.inv().rot(air),forward=orientation.rot(new V(0,0,-1)),up=orientation.rot(new V(0,1,0)),right=orientation.rot(new V(1,0,0)),rho=1.225*Math.pow(Math.max(.15,1-.0000225577*Math.max(0,pos.y)),4.25588),q=.5*rho*v*v;ias=v*Math.sqrt(rho/1.225)*1.94384;let alpha=Math.atan2(-local.y,Math.max(1,-local.z)),beta=Math.asin(clamp(local.x/v,-1,1)),critical=(a.stall+(flaps?2:0))*rad,cl=a.cl0+a.cla*alpha+(flaps/30)*.6;
if(Math.abs(alpha)>critical){let peak=a.cl0+a.cla*critical;cl=Math.sign(alpha)*(peak*Math.exp(-(Math.abs(alpha)-critical)*5)+.35*Math.sin(2*Math.abs(alpha)))}cl=clamp(cl,-2.0,2.3);let agl=pos.y-Math.max(0,heightAt(pos.x,pos.z)),ground=agl<1.6,groundEffect=1-.45*Math.exp(-Math.max(0,agl)/a.span);let cd=a.cd+a.k*cl*cl*groundEffect+(flaps/30)*.055+(gear?.018:0)+.3*beta*beta;if(v>285)cd+=.09*clamp((v-285)/70,0,1);let lift=q*a.area*cl,drag=q*a.area*cd;let liftDir=right.cross(air.unit()).unit();if(liftDir.dot(up)<0)liftDir=liftDir.mul(-1);let thrust=a.power?Math.min(a.thrust,a.power*.78/Math.max(24,v))*spool: a.thrust*spool*(.3+.7*rho/1.225)*clamp(1-v/900,.3,1);let force=liftDir.mul(lift).sub(air.unit().mul(drag)).add(forward.mul(thrust)).add(new V(0,-a.mass*9.80665,0));force=force.add(right.mul(-q*a.area*beta*.7));lastAlpha=alpha/rad;lastG=lift/(a.mass*9.80665);
let authority=clamp(q/(a.type==='prop'?700:4000),0,2),slip=a.type==='prop'?spool*.12:0,ratePitch=controls.x*a.response.x,rateYaw=controls.y*a.response.y,rateRoll=controls.z*a.response.z;let stallBuffet=Math.abs(alpha)>critical?Math.sin(elapsed*24)*.09:0;let target=new V(ratePitch,rateYaw,rateRoll),damping=new V(.9,.75,1.8);let torque=new V((target.x-omega.x)*a.inertia.x*(authority+slip)*damping.x,(target.y-omega.y-(ground?0:beta*.5))*a.inertia.y*authority*damping.y,(target.z-omega.z+stallBuffet)*a.inertia.z*authority*damping.z);torque.x-=alpha*a.inertia.x*authority*.19;let Iomega=new V(a.inertia.x*omega.x,a.inertia.y*omega.y,a.inertia.z*omega.z),gyroscopic=omega.cross(Iomega),angularAcc=torque.sub(gyroscopic);omega=omega.add(new V(angularAcc.x/a.inertia.x,angularAcc.y/a.inertia.y,angularAcc.z/a.inertia.z).mul(dt));omega=new V(clamp(omega.x,-2.7,2.7),clamp(omega.y,-1.5,1.5),clamp(omega.z,-5,5));
if(ground){let normal=Math.max(0,a.mass*9.80665-lift*liftDir.y);let horizontal=new V(vel.x,0,vel.z),friction=Math.min(horizontal.len()*a.mass/dt,normal*(brake?.65:.023));force=force.sub(horizontal.unit().mul(friction));force=force.sub(right.mul(vel.dot(right)*a.mass*5));omega.y+=(controls.y*clamp(v/25,0,1)*.22-omega.y)*dt*2;let an=angles();if(an.pitch<0)omega.x+=(-an.pitch*rad*5-omega.x)*dt*12;if(an.pitch>13)omega.x-=dt*2;if(v<12)omega.x*=Math.exp(-dt*5);omega.z+=(-an.roll*rad*5-omega.z)*dt*8}
let speedOmega=omega.len();if(speedOmega>0)orientation=orientation.mul(Q.axis(omega.unit(),speedOmega*dt)).norm();vel=vel.add(force.mul(dt/a.mass));pos=pos.add(vel.mul(dt));let terrainH=Math.max(0,heightAt(pos.x,pos.z)),contact=terrainH+1.5;if(pos.y<contact){let an=angles(),onRunway=Math.abs(pos.x)<17.5&&pos.z<265&&pos.z>-1560;if((phase>=2||mode==='dogfight')&&(vel.y<-4.5||Math.abs(an.roll)>13||!gear||terrainH<1||( !onRunway&&vel.len()>25))){crash(terrainH<1?'Water impact':'Hard landing — airframe limits exceeded');return}if(phase>=2&&onRunway&&vel.y> -3.5&&ias<fleet[selected].rotate*1.5){phase=4;notify('Beautiful touchdown. Hold B to brake to a stop.',6);$('missionTitle').textContent='Welcome back, captain.';$('phase').textContent='ARRIVAL / RUNWAY 36'}pos.y=contact;if(vel.y<0)vel.y=0}
if(mode==='precision'){if(throttle>.9)setTask(1,true);if(agl>5&&phase===0){phase=1;setTask(2,true);notify('Positive climb. Keep your airspeed above the stall limit.',4);$('phase').textContent='CLIMB / 1,000 FT'}if(pos.y*3.28084>=1000&&phase===1){phase=2;setTask(3,true);$('missionTitle').textContent='Make the circuit.';$('phase').textContent='APPROACH / RUNWAY 36';notify('Climb complete. Fly a circuit and return to runway 36.',6)}if(phase===4&&vel.len()<2){phase=5;notify('Mission complete — a safe departure and landing.',100);$('missionTitle').textContent='Precision. Achieved.';$('phase').textContent='MISSION COMPLETE';running=false;paused=true;$('launchBtn').innerHTML='<span>Fly again ↗</span><small>R</small>'}}
if(Math.abs(lastG)>a.g*1.45&&v>60){stress+=dt;if(stress>2.5){crash('Structural failure — sustained overload');return}}else stress=Math.max(0,stress-dt);if(mode==='dogfight')combat(dt);if(pos.len()>29000){notify('Leaving the operating area. Turn back toward Nordfjord.',4,true)}
}
let stress=0,airframe=100,enemyFireTimer=0;
function hostileFire(dt){enemyFireTimer-=dt;if(enemy.hp<=0)return;let delta=pos.sub(enemy.pos),dist=delta.len(),forward=enemy.q.rot(new V(0,0,-1));if(dist<1600&&dist>50&&forward.dot(delta.unit())>.94&&enemyFireTimer<=0){enemyFireTimer=.25;let p=enemy.pos.add(forward.mul(9));bullets.push({p,old:p,v:enemy.vel.add(delta.unit().mul(750)),age:0,hostile:true})}}
function combat(dt){hostileFire(dt);fireClock-=dt;if(enemy.hp>0){let speed=(selected===0?48:selected===1?190:115),yaw=Math.sin(elapsed*.055)*.38,climb=Math.sin(elapsed*.13)*.035;enemy.q=Q.axis(new V(0,1,0),yaw).mul(Q.axis(new V(1,0,0),climb)).mul(Q.axis(new V(0,0,1),Math.cos(elapsed*.055)*.16));enemy.vel=enemy.q.rot(new V(0,0,-speed));enemy.pos=enemy.pos.add(enemy.vel.mul(dt));if(enemy.pos.sub(pos).len()<800)setTask(1,true);if(enemy.pos.y<heightAt(enemy.pos.x,enemy.pos.z)+250)enemy.pos.y+=30*dt;if(enemy.pos.z< -8000)enemy.pos.z=pos.z-1300}
if(keys.Space&&fireClock<=0){fireClock=.085;shots++;let f=orientation.rot(new V(0,0,-1)),p=pos.add(f.mul(fleet[selected].length*.6));bullets.push({p,old:p,v:vel.add(f.mul(900)),age:0});if(audio)audio.fire();setTask(2,true)}
for(let b of bullets){b.old=b.p;b.p=b.p.add(b.v.mul(dt));b.v.y-=9.8*dt;b.age+=dt;if(b.hostile){let seg=b.p.sub(b.old),t=clamp(pos.sub(b.old).dot(seg)/(seg.dot(seg)||1),0,1);if(pos.sub(b.old.add(seg.mul(t))).len()<7){b.age=4;airframe-=15;notify('Taking fire — airframe '+Math.max(0,airframe)+'%. Break away!',2,true);if(airframe<=0)crash('Shot down')}continue}if(enemy.hp>0){let seg=b.p.sub(b.old),t=clamp(enemy.pos.sub(b.old).dot(seg)/(seg.dot(seg)||1),0,1),d=enemy.pos.sub(b.old.add(seg.mul(t))).len();if(d<12){enemy.hp-=25;b.age=4;notify('Confirmed hit · Bandit integrity '+Math.max(0,enemy.hp)+'%',1);if(enemy.hp<=0){kills++;setTask(3,true);notify('Bandit neutralized. Airspace secure. Press R for another sortie.',100);$('missionTitle').textContent='Airspace secured.';$('phase').textContent='MISSION COMPLETE / 1 KILL';if(audio)audio.impact()}}}}bullets=bullets.filter(b=>b.age<3)
}
function updateHUD(){if(!started)ias=mode==='dogfight'?vel.sub(wind).len()*Math.sqrt(Math.pow(Math.max(.15,1-.0000225577*pos.y),4.25588))*1.94384:0;let a=angles(),ground=pos.y-Math.max(0,heightAt(pos.x,pos.z))<2,agl=pos.y-Math.max(0,heightAt(pos.x,pos.z));$('speed').textContent=Math.round(ias).toString().padStart(3,'0');$('altitude').textContent=Math.round(pos.y*3.28084).toString().padStart(4,'0');$('agl').textContent=Math.round(agl*3.28084)+' FT AGL';$('vs').textContent=(vel.y>=0?'+':'')+Math.round(vel.y*196.85/10)*10;$('pitch').textContent=Math.round(a.pitch);$('roll').textContent=Math.round(a.roll);$('gforce').textContent=clamp(lastG,-99,99).toFixed(1);$('heading').textContent=Math.round(a.heading).toString().padStart(3,'0')+'°';$('throttle').textContent=Math.round(throttle*100);$('throttleBar').style.width=throttle*100+'%';$('rpm').textContent=Math.round(selected===0?600+spool*2100:1500+spool*13000).toLocaleString();$('engineStatus').textContent=spool>.9?'FULL POWER':spool>.1?'RUNNING':'IDLE';$('flightStatus').textContent=crashed?'● AIRCRAFT LOST':paused?'Ⅱ FLIGHT PAUSED':ground?'● ON THE GROUND':'● IN FLIGHT';$('flightDetail').innerHTML=(ground?(!started?'PARKING BRAKE SET':brake?'WHEEL BRAKES ON':'BRAKES RELEASED'):'AOA '+lastAlpha.toFixed(1)+'° · '+(1.225*Math.exp(-pos.y/8500)).toFixed(2)+' KG/M³')+'<br>FLAPS '+flaps+'° · GEAR '+(gear?'DOWN':'UP');$('clock').textContent='FLIGHT TIME '+Math.floor(elapsed/60).toString().padStart(2,'0')+':'+Math.floor(elapsed%60).toString().padStart(2,'0');$('speedSub').textContent=Math.abs(lastAlpha)>fleet[selected].stall&&!ground?'⚠ STALL · LOWER NOSE':'INDICATED AIRSPEED';$('speedSub').style.color=Math.abs(lastAlpha)>fleet[selected].stall&&!ground?'#f5b486':'';if(running&&!ground){let warn=Math.abs(lastAlpha)>fleet[selected].stall?'STALL — lower the nose and apply power':lastG>fleet[selected].g?'OVER G — ease back pressure':agl<25&&vel.y< -3?'TERRAIN — pull up':'';if(warn&&toastTimer<=0)notify(warn,1,true)}
let ac=$('attitude').getContext('2d');ac.clearRect(0,0,136,136);ac.save();ac.beginPath();ac.arc(68,68,61,0,7);ac.clip();ac.translate(68,68);ac.rotate(-a.roll*rad);let off=clamp(a.pitch*1.5,-85,85);ac.fillStyle='#638b97';ac.fillRect(-150,-150+off,300,150);ac.fillStyle='#665e48';ac.fillRect(-150,off,300,200);ac.strokeStyle='#ccd8c9';ac.lineWidth=2;ac.beginPath();ac.moveTo(-100,off);ac.lineTo(100,off);ac.stroke();for(let y=-60;y<=60;y+=20){if(y===0)continue;ac.strokeStyle='#b0c3bb88';ac.lineWidth=1;ac.beginPath();ac.moveTo(-15,y+off);ac.lineTo(15,y+off);ac.stroke()}ac.restore();ac.strokeStyle='#93aaa380';ac.lineWidth=3;ac.beginPath();ac.arc(68,68,62,0,7);ac.stroke();ac.strokeStyle='#d5eeae';ac.lineWidth=3;ac.beginPath();ac.moveTo(28,68);ac.lineTo(53,68);ac.lineTo(60,76);ac.lineTo(68,68);ac.lineTo(76,76);ac.lineTo(83,68);ac.lineTo(108,68);ac.stroke();ac.fillStyle='#d5eeae';ac.beginPath();ac.moveTo(68,9);ac.lineTo(62,19);ac.lineTo(74,19);ac.fill();
let target=$('targetHud');if(mode==='dogfight'&&enemy.hp>0){let p=project(enemy.pos);if(p&&p.x>0&&p.x<W&&p.y>0&&p.y<H){target.style.display='block';target.style.left=p.x-20+'px';target.style.top=p.y-20+'px';$('targetRange').textContent=Math.round(enemy.pos.sub(pos).len())+' M · '+enemy.hp+'%'}else{target.style.display='none'}}else target.style.display='none'}
let audio=null;
function initAudio(){if(!audioOn)return;if(audio){audio.ac.resume();return}try{let ac=new(window.AudioContext||window.webkitAudioContext)(),master=ac.createGain();master.gain.value=volume*.3;master.connect(ac.destination);let engine=ac.createOscillator(),harmonic=ac.createOscillator(),engGain=ac.createGain(),filter=ac.createBiquadFilter();engine.type='sawtooth';harmonic.type='triangle';filter.type='lowpass';filter.frequency.value=550;engGain.gain.value=.13;engine.connect(filter);harmonic.connect(filter);filter.connect(engGain);engGain.connect(master);engine.start();harmonic.start();let buffer=ac.createBuffer(1,ac.sampleRate*3,ac.sampleRate),data=buffer.getChannelData(0);let last=0;for(let i=0;i<data.length;i++){let white=Math.random()*2-1;last=(last+.02*white)/1.02;data[i]=last*4}let noise=ac.createBufferSource();noise.buffer=buffer;noise.loop=true;let windFilter=ac.createBiquadFilter(),windGain=ac.createGain();windFilter.type='bandpass';windFilter.frequency.value=800;windFilter.Q.value=.7;noise.connect(windFilter);windFilter.connect(windGain);windGain.connect(master);noise.start();let ambient=ac.createBiquadFilter(),ambGain=ac.createGain();ambient.frequency.value=110;ambGain.gain.value=.15;noise.connect(ambient);ambient.connect(ambGain);ambGain.connect(master);
function burst(dur,gain,freq){let source=ac.createBufferSource();source.buffer=buffer;let f=ac.createBiquadFilter(),g=ac.createGain();f.type='lowpass';f.frequency.value=freq;g.gain.setValueAtTime(gain,ac.currentTime);g.gain.exponentialRampToValueAtTime(.001,ac.currentTime+dur);source.connect(f);f.connect(g);g.connect(master);source.start();source.stop(ac.currentTime+dur);source.onended=()=>{source.disconnect();f.disconnect();g.disconnect()}}
audio={ac,master,engine,harmonic,engGain,filter,windFilter,windGain,ambGain,fire:()=>burst(.1,1.8,2400),impact:()=>burst(1.2,3,500)};ac.resume()}catch(e){notify('Audio is not available in this browser.',3)}}
function updateAudio(){if(!audio)return;let t=audio.ac.currentTime,s=running?1:.18;audio.master.gain.setTargetAtTime(audioOn?volume*.36:0,t,.12);audio.engine.frequency.setTargetAtTime((selected===0?35:58)+spool*(selected===0?85:160),t,.1);audio.harmonic.frequency.setTargetAtTime(55+spool*(selected===0?150:280),t,.1);audio.engGain.gain.setTargetAtTime((.06+spool*.2)*s*Math.exp(-pos.y/26000),t,.2);audio.filter.frequency.setTargetAtTime(270+spool*1600,t,.15);audio.windFilter.frequency.setTargetAtTime(400+Math.min(ias,600)*3+Math.abs(lastAlpha)*13,t,.1);audio.windGain.gain.setTargetAtTime(clamp(ias/300,0,.7)*s+clamp(Math.abs(lastAlpha)-12,0,15)*.02*s,t,.12);audio.ambGain.gain.setTargetAtTime((.04+clamp(pos.y/5000,0,.2))*s,t,.4)}
function cycleCamera(){cameraMode=(cameraMode+1)%3;$('cameraBtn').querySelector('span').textContent=['Chase camera','Cockpit camera','Wing-follow cam'][cameraMode];notify(['Chase · centered behind the aircraft','Cockpit · airframe-relative horizon','Wing-follow · close, centered rear view'][cameraMode],2)}
function toggleMouse(){mouseControl=!mouseControl;$('mouseMode').checked=mouseControl;notify('Mouse flight '+(mouseControl?'enabled — move around screen center':'disabled'),3)}
function modal(id){$(id).classList.add('open');if(running){paused=true;running=false;$('launchBtn').innerHTML='<span>Resume flight ▷</span><small>P</small>'}keys={}}
document.querySelectorAll('[data-plane]').forEach(b=>b.onclick=()=>selectPlane(+b.dataset.plane));document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>{mode=b.dataset.mode;document.querySelectorAll('[data-mode]').forEach(x=>x.classList.toggle('active',x===b));resetFlight()});$('launchBtn').onclick=()=>{if(phase===5)resetFlight();beginFlight()};$('resetBtn').onclick=()=>{resetFlight();notify('Aircraft reset. Ready for departure.',3)};$('cameraBtn').onclick=cycleCamera;$('helpBtn').onclick=$('controlsBtn').onclick=()=>modal('helpModal');$('settingsBtn').onclick=()=>modal('settingsModal');document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).classList.remove('open'));document.querySelectorAll('.modal').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('open')}));$('audioBtn').onclick=()=>{audioOn=!audioOn;$('audioBtn').textContent=audioOn?'♪':'♪̸';$('audioBtn').style.color=audioOn?'#c4ed9f':'#80999d';if(audioOn)initAudio();notify(audioOn?'Spatial engine & airflow audio enabled':'Audio muted',2)};$('volume').oninput=e=>{volume=e.target.value/100;initAudio()};$('turbulence').onchange=e=>turbulence=e.target.checked;$('mouseMode').onchange=toggleMouse;$('quality').onchange=buildWorld;
window.addEventListener('keydown',e=>{if(e.target.matches('input,select'))return;if(['ArrowDown','ArrowUp','ArrowLeft','ArrowRight','Space'].includes(e.code))e.preventDefault();if(e.code==='Escape'){document.querySelectorAll('.modal').forEach(m=>m.classList.remove('open'));return}if(document.querySelector('.modal.open'))return;keys[e.code]=true;if(e.repeat)return;switch(e.code){case'Enter':case'KeyP':beginFlight();break;case'KeyR':resetFlight();notify('Flight reset',2);break;case'KeyC':cycleCamera();break;case'KeyM':toggleMouse();break;case'KeyF':flaps=(flaps+10)%40;notify('Flaps '+flaps+'°',2);break;case'KeyG':if(selected===0)notify('Cessna 172 has fixed landing gear.',2);else{gear=!gear;notify('Landing gear '+(gear?'down':'up'),2)}break;case'Slash':modal('helpModal');break}});window.addEventListener('keyup',e=>keys[e.code]=false);window.addEventListener('blur',()=>{keys={};if(running){running=false;paused=true;$('launchBtn').innerHTML='<span>Resume flight ▷</span><small>P</small>';notify('Flight paused while window is inactive.',100)}});window.addEventListener('mousemove',e=>{mouse.x=clamp((e.clientX-W/2)/(W*.35),-1,1);mouse.y=clamp((e.clientY-H/2)/(H*.35),-1,1)});document.querySelectorAll('[data-key]').forEach(b=>{b.onpointerdown=e=>{e.preventDefault();keys[b.dataset.key]=true;b.setPointerCapture(e.pointerId)};b.onpointerup=b.onpointercancel=()=>keys[b.dataset.key]=false});
function resize(){W=innerWidth;H=innerHeight;$('world').width=W*Math.min(devicePixelRatio,1.5);$('world').height=H*Math.min(devicePixelRatio,1.5);ctx.setTransform(Math.min(devicePixelRatio,1.5),0,0,Math.min(devicePixelRatio,1.5),0,0);if(renderer){renderer.setSize(W,H);camera.aspect=W/H;camera.updateProjectionMatrix()}}window.addEventListener('resize',resize);
let prev=performance.now(),acc=0,hudTime=0,renderTime=0;function frame(now){requestAnimationFrame(frame);let dt=Math.min((now-prev)/1000,.08);prev=now;acc+=dt;while(acc>=1/120){physics(1/120);acc-=1/120}updateCamera(dt);hudTime+=dt;renderTime+=dt;if(hudTime>.075){updateHUD();updateAudio();hudTime=0}if(toastTimer>0){toastTimer-=dt;if(toastTimer<=0)$('toast').className='toast'}if(renderer&&scene)drawThree();else if(renderTime>1/28){drawSoftware();renderTime=0}}
/* Expanded world, avionics and flight effects */
const expansionUI=document.createElement('div');expansionUI.innerHTML=`<canvas id="sceneOverlay"></canvas><div class="flight-chip"><div class="eyebrow">AIRCRAFT / LIVE TELEMETRY</div><b id="chipPlane">Cessna 172</b><small id="chipStatus">SYSTEMS NOMINAL</small><div class="meter"><span id="integrityBar"></span></div><small id="chipFuel">AIRFRAME 100% · TRIM 0%</small><br><button id="hangarBtn">↺ Return to aircraft selection</button></div><div class="tape left"><small>AIRSPEED</small><div class="ticks"></div><strong id="tapeSpeed">000</strong><div class="tape-unit">KNOTS</div></div><div class="tape right"><small>ALTITUDE</small><div class="ticks"></div><strong id="tapeAlt">64</strong><div class="tape-unit">FEET MSL</div></div><div id="cinemaBadge">APEX AERO / NORDFJORD · H TO RESTORE HUD</div><div class="modal" id="debriefModal"><div class="dialog"><div class="eyebrow" id="reportEyebrow">SORTIE REPORT</div><h2 id="reportTitle">The sky remembers.</h2><p id="reportDesc"></p><div class="score-grid"><div><span>FLIGHT TIME</span><b id="reportTime">00:00</b></div><div><span>MAX ALTITUDE</span><b id="reportAlt">0 FT</b></div><div><span>PEAK AIRSPEED</span><b id="reportSpeed">0 KTS</b></div><div><span id="reportExtraLabel">LANDING RATE</span><b id="reportExtra">—</b></div></div><button class="launch" id="reportRetry"><span>New sortie</span><span>↗</span></button><button class="launch secondary" data-close="debriefModal"><span>Return to flight deck</span><span>×</span></button></div></div>`;document.body.appendChild(expansionUI);
const nav=document.createElement('div');nav.className='nav-panel';nav.innerHTML='<div class="nav-title">NAVIGATION <span>8 NM / NORTH UP</span></div><canvas id="navMap" width="420" height="286"></canvas><div class="nav-bottom"><span>RWY 36 <b id="navDistance">0.5 NM</b></span><span id="navBearing">000°</span></div>';document.querySelector('.location').appendChild(nav);
const log=document.createElement('div');log.className='flight-log';log.id='flightLog';document.querySelector('.mission').insertBefore(log,$('launchBtn'));
const viewBtn=document.createElement('button');viewBtn.id='viewBtn';viewBtn.innerHTML='<kbd>H</kbd>Cinematic view';document.querySelector('.camera-tools').appendChild(viewBtn);viewBtn.onclick=()=>document.body.classList.toggle('clean');
const guideBtn=document.createElement('button');guideBtn.id='guideBtn';guideBtn.className='active';guideBtn.innerHTML='<kbd>V</kbd>Flight path · ON';document.querySelector('.camera-tools').insertBefore(guideBtn,$('resetBtn'));
$('hangarBtn').onclick=()=>{resetFlight();notify('Back at Nordfjord. Select your next aircraft.',3)};
const flightRows=document.createElement('div');flightRows.innerHTML='<div class="control-row"><span>Elevator trim down / up</span><kbd>[ / ]</kbd></div><div class="control-row"><span>Flight path aid · Cinematic HUD</span><kbd>V H</kbd></div>';$('helpModal').querySelector('.dialog').insertBefore(flightRows,$('pilotTip'));
const settingsExtra=document.createElement('div');settingsExtra.innerHTML='<div class="switch-row"><label for="daylight">Light & atmosphere</label><select id="daylight" style="background:#263f44;color:#d7e4dc;border:1px solid #577176;padding:7px"><option value="golden">Golden hour</option><option value="day">Nordic daylight</option><option value="dusk">Blue hour</option></select></div><div class="switch-row"><label for="pathAid">Departure / approach guidance</label><input id="pathAid" type="checkbox" checked></div><p class="settings-note">CHASE + WING CAMS · CENTERLINE LOCKED<br>Camera position never drifts sideways during a turn.</p>';$('settingsModal').querySelector('.dialog').insertBefore(settingsExtra,$('settingsModal').querySelector('p'));
let trim=0,pathAid=true,lightMode='golden',maxAltitude=0,maxAirspeed=0,landingRate=null,reportShown=false,fx=[],trail=[],lastEnemyHP=100,wasCrashed=false,routeStep=0,routeScore=0,worldClock=0,navCache=null,cloudGroup=null,detailGroup=null,gateGroup=null,fxGroup=null,sunLight=null,hemisphere=null,skyDome=null,sea=null;
const route=[new V(0,112,-1650),new V(0,305,-2950),new V(-750,430,-3450),new V(-1300,470,-2000),new V(-1050,400,300),new V(-400,240,1550),new V(0,108,1550),new V(0,49,690)];
function toggleGuidance(){pathAid=!pathAid;$('pathAid').checked=pathAid;$('guideBtn').classList.toggle('active',pathAid);$('guideBtn').innerHTML='<kbd>V</kbd>Flight path · '+(pathAid?'ON':'OFF');notify('Visual flight path '+(pathAid?'enabled — optional, fly at your own pace':'hidden'),3)}guideBtn.onclick=toggleGuidance;$('pathAid').onchange=toggleGuidance;
window.addEventListener('keydown',e=>{if(e.repeat||e.target.matches('input,select')||document.querySelector('.modal.open'))return;if(e.code==='KeyH')document.body.classList.toggle('clean');if(e.code==='KeyV')toggleGuidance();if(e.code==='BracketLeft'||e.code==='BracketRight'){trim=clamp(trim+(e.code==='BracketRight'?.025:-.025),-.35,.35);notify('Elevator trim '+Math.round(trim*100)+'%',2)}});
$('reportRetry').onclick=()=>{$('debriefModal').classList.remove('open');resetFlight();beginFlight()};$('debriefModal').querySelector('[data-close]').onclick=()=>$('debriefModal').classList.remove('open');$('debriefModal').onclick=e=>{if(e.target===$('debriefModal'))$('debriefModal').classList.remove('open')};
function rod(a,b,r,c){let d=b.sub(a).unit(),u=d.cross(Math.abs(d.y)>.9?new V(1,0,0):new V(0,1,0)).unit(),v=d.cross(u),points=[],faces=[];for(let p of [a,b])for(let i=0;i<5;i++){let t=i/5*Math.PI*2;points.push(p.add(u.mul(Math.cos(t)*r)).add(v.mul(Math.sin(t)*r)))}for(let i=0;i<5;i++)faces.push([i,(i+1)%5,(i+1)%5+5,i+5]);faces.push([0,1,2,3,4],[9,8,7,6,5]);return mesh(points,faces,c)}
function road(points,width,color){let vs=[],fs=[];for(let i=0;i<points.length;i++){let p=points[i],last=points[Math.max(0,i-1)],next=points[Math.min(points.length-1,i+1)],dx=next[0]-last[0],dz=next[1]-last[1],l=Math.hypot(dx,dz)||1;for(let s of [-1,1]){let x=p[0]+s*dz/l*width/2,z=p[1]-s*dx/l*width/2;vs.push(new V(x,Math.max(1,heightAt(x,z))+.18,z))}if(i)fs.push([2*i-2,2*i-1,2*i+1,2*i])}return mesh(vs,fs,color)}
const baseMakePlane=makePlane;makePlane=function(index){let m=baseMakePlane(index),a=fleet[index],L=a.length,b=a.span/2;
for(let part of m){if(part.v.every(v=>v.y<-.68))part.landing=true;if(index===0&&part.v.every(v=>v.z< -L/2-.12&&v.z> -L/2-.3))part.propeller=true}
// Cowling seams, cabin framing, registration stripe and articulated trailing edges.
if(index===0){for(let s of [-1,1]){m.push(wing([[s*.663,-.02,-2.1],[s*.59,-.01,2.65],[s*.59,-.15,2.65],[s*.663,-.23,-2.1]],.018,'#b55732'));for(let z of [-1.86,-.55])m.push(rod(new V(s*.44,.91,z),new V(s*.51,.39,z),.038,'#d8dfd4'));m.push(rod(new V(s*.44,.96,-1.82),new V(s*.5,.96,-.55),.035,'#d8dfd4'));m.push(rod(new V(s*.59,.2,-.7),new V(s*.6,-.24,.4),.016,'#647977'));m.push(box(s*.61,.03,.1,.035,.04,.23,'#313e3c'));m.push(wing([[s*1.1,.8,.43],[s*3.25,.84,.11],[s*3.25,.84,.3],[s*1.1,.8,.68]],.045,'#cdd5cb'));m.push(box(s*2.8,.97,-.45,.7,.016,.09,'#a3b0a7'));}m.push(rod(new V(0,1.01,.2),new V(0,1.75,.5),.02,'#374c4c'));m.push(box(0,.07,-4.13,.5,.08,.018,'#263633'));m.push(ellipsoid(0,.28,-4.08,.16,.13,.07,'#fff0c8',8,5))}
else {for(let s of [-1,1]){m.push(wing([[s*.64,.32,-L*.17],[s*.38,.29,L*.4],[s*.44,.12,L*.4],[s*.72,.15,-L*.17]],.025,index===1?'#526c70':'#c46039'));m.push(rod(new V(s*.37,.87,-L*.23),new V(s*.48,.76,-L*.08),.038,'#a4b9b6'));m.push(ellipsoid(s*2.3,-.3,1,.18,.19,1.9,'#a3b6b1',10,6));m.push(wing([[s*2.1,.18,1.73],[s*4.3,.22,1.86],[s*4.3,.22,2.27],[s*2.1,.18,2.4]],.065,'#677f81'))}m.push(rod(new V(0,0,-L*.47),new V(0,0,-L*.58),.022,'#b7c8bf'));m.push(ellipsoid(0,-.34,L*.48,.34,.31,.07,'#171e23',14,6));}
return m};
function pine(x,z,size){let y=heightAt(x,z),vs=[],fs=[];for(let j=0;j<2;j++){let radius=size*(.42-j*.12),bottom=y+size*(.15+j*.55),tip=y+size*(1.65+j*.27),off=vs.length;for(let i=0;i<5;i++){let t=i/5*Math.PI*2;vs.push(new V(x+Math.sin(t)*radius,bottom,z+Math.cos(t)*radius))}vs.push(new V(x,tip,z));for(let i=0;i<5;i++)fs.push([off+i,off+(i+1)%5,off+5])}let shades=['#315644','#3d5e49','#426951','#2f5144','#526b50'];return mesh(vs,fs,shades[Math.floor(hash(x,z)*5)])}
function house(x,z,w,d,h,c){let y=Math.max(2,heightAt(x,z));worldMeshes.push(box(x,y+h/2,z,w,h,d,c));worldMeshes.push(wing([[x-w*.57,y+h,z-d*.56],[x,y+h+w*.3,z-d*.56],[x+w*.57,y+h,z-d*.56],[x+w*.57,y+h,z+d*.56],[x,y+h+w*.3,z+d*.56],[x-w*.57,y+h,z+d*.56]],.25,'#4b5959'));for(let s of [-1,1])for(let k=-1;k<=1;k++){worldMeshes.push(box(x+k*w*.27,y+h*.62,z+s*(d*.5+.04),w*.12,h*.21,.08,'#c9d9cc'));worldMeshes.push(box(x+k*w*.27,y+h*.62,z+s*(d*.5+.1),w*.09,h*.16,.04,'#35515a'))}worldMeshes.push(box(x+w*.2,y+h+w*.28,z-d*.15,1.1,2.5,1.3,'#8e8980'))}
const baseBuildWorld=buildWorld;buildWorld=function(){baseBuildWorld();const high=$('quality').value==='high';
// A denser continuous heightfield, with exposed rock and a treeline.
let n=high?168:94,size=18000,vs=[],fs=[],cs=[];for(let j=0;j<=n;j++)for(let i=0;i<=n;i++){let x=-9000+i*size/n,z=-11500+j*size/n;vs.push(new V(x,heightAt(x,z),z))}for(let j=0;j<n;j++)for(let i=0;i<n;i++){let k=j*(n+1)+i;for(let f of [[k,k+1,k+n+1],[k+1,k+n+2,k+n+1]]){let a=vs[f[0]],b=vs[f[1]],c=vs[f[2]],h=(a.y+b.y+c.y)/3,slope=Math.abs(b.sub(a).cross(c.sub(a)).unit().y),t=hash(i,j);fs.push(f);cs.push(h>1100?[169+t*20,182+t*16,179+t*16]:slope<.72||h>700?[101+t*16,113+t*14,106+t*12]:[73+t*22,107+t*15,76+t*12])}}terrain=[{v:vs,f:fs,colors:cs,c:[90,120,92],terrain:true}];
// Asphalt shoulders, connecting taxiway and a working-scale apron.
worldMeshes.push(box(-89,18.24,-80,122,.16,268,'#586362'));worldMeshes.push(box(-51,18.26,-644,12,.18,1640,'#606966'));worldMeshes.push(box(-28,18.28,148,55,.1,12,'#606966'));worldMeshes.push(box(-28,18.28,-1420,55,.1,12,'#606966'));worldMeshes.push(box(-51,18.39,-644,.18,.02,1620,'#d4b776'));worldMeshes.push(box(-27,18.39,148,53,.02,.19,'#d4b776'));
for(let z=-220;z<55;z+=38){worldMeshes.push(box(-96,18.36,z,63,.02,.17,'#c2b07e'));worldMeshes.push(box(-66,18.36,z+10,.15,.02,20,'#c2b07e'));}for(let z=-1600;z<280;z+=60)for(let s of [-1,1])worldMeshes.push(box(s*21,18.58,z,.32,.55,.32,'#ece5b1'));
for(let z of [-1280,-1140])for(let s of [-1,1])worldMeshes.push(box(s*9,18.48,z,4,.02,34,'#dce0cb'));
for(let z of [255,-1540])for(let x=-14;x<=14;x+=4)worldMeshes.push(box(x,18.6,z,.5,.22,.5,z===255?'#8fdca0':'#ef8f70'));
// Hangars, maintenance sheds, terminal and perimeter lighting.
for(let i=0;i<4;i++){let x=-139,z=-110-i*62;worldMeshes.push(box(x,25,z,41,14,38,'#abb3a5'));worldMeshes.push(wing([[x-22,32,z-21],[x,39,z-21],[x+22,32,z-21],[x+22,32,z+21],[x,39,z+21],[x-22,32,z+21]],.45,'#556c6d'));worldMeshes.push(box(x+20.55,24.6,z, .08,11,31,'#425457'));for(let t=-12;t<=12;t+=4)worldMeshes.push(box(x+20.7,24.6,z+t,.06,10,.07,'#a6b4a9'));worldMeshes.push(box(x+20.72,30.5,z,.09,.9,26,'#94bec4'));}
house(-155,94,36,19,9,'#bfbaa4');worldMeshes.push(box(-151,18.25,136,51,.1,33,'#626b67'));for(let i=0;i<7;i++){let x=-169+i*5;worldMeshes.push(box(x,18.4,137,.1,.02,16,'#c6c8af'));worldMeshes.push(box(x+2,19.3,138,2.1,1.5,4,['#bdbba9','#923f32','#516e75'][i%3]));worldMeshes.push(box(x+2,20.2,138,1.7,.6,2.2,'#344d54'))}
for(let z=-400;z<=200;z+=75){worldMeshes.push(rod(new V(-175,18,z),new V(-175,29,z),.14,'#7d918a'));worldMeshes.push(box(-173.5,29,z,3.5,.3,1,'#dbdebf'))}
// Airport access road follows the actual topography.
let roadPoints=[];for(let j=0;j<80;j++){let z=450-j*65,x=-250-65*Math.sin(z/800);roadPoints.push([x,z])}worldMeshes.push(road(roadPoints,9,'#737975'));worldMeshes.push(road(roadPoints, .17,'#d2c8a2'));let coastRoad=[];for(let j=0;j<110;j++){let z=1200-j*75,x=210+95*Math.sin(z/1200);coastRoad.push([x,z])}worldMeshes.push(road(coastRoad,7,'#7b8074'));
// Nordfjord village: painted timber houses, gardens and a church.
for(let i=0;i<74;i++){let z=-450-hash(i,331)*3200,x=-355-hash(i,341)*510;if(heightAt(x,z)<3)continue;let w=10+hash(i,18)*9,d=11+hash(i,19)*8,h=6+hash(i,21)*5;house(x,z,w,d,h,['#b8b4a0','#ad5a42','#d4d2ba','#6f8880','#b78b63'][i%5]);let path=[];for(let j=0;j<8;j++){let t=j/7;path.push([lerp(x,-250-65*Math.sin(z/800),t),z])}worldMeshes.push(road(path,2.4,'#a8aa8b'));}
house(-392,-1030,17,35,13,'#d3d2bd');let churchY=heightAt(-392,-1012);worldMeshes.push(box(-392,churchY+16,-1012,8,32,8,'#cbcbb8'));worldMeshes.push(mesh([new V(-398,churchY+32,-1018),new V(-386,churchY+32,-1018),new V(-386,churchY+32,-1006),new V(-398,churchY+32,-1006),new V(-392,churchY+48,-1012)],[[0,1,4],[1,2,4],[2,3,4],[3,0,4]],'#40585b'));
// Seeded clusters, not a uniform scatter. Keep the protected runway corridor clear.
for(let i=0;i<(high?3200:1000);i++){let x=hash(i,612)*9500-4500,z=hash(i,725)*10300-8000,h=heightAt(x,z);if(h<5||h>880||Math.abs(x+50)<240&&z> -2400&&z<650||fbm(x/350,z/350)<.4||x< -300&&x> -930&&z< -300&&z> -3800)continue;worldMeshes.push(pine(x,z,7+hash(i,72)*11))}
for(let i=0;i<100;i++){let z=hash(i,771)*7500-5000,x=185+hash(i,772)*220;if(heightAt(x,z)>3&&Math.abs(z+600)>1150)worldMeshes.push(pine(x,z,6+hash(i,73)*9))}
// Waterside jetties, boathouses and anchored boats.
for(let i=0;i<8;i++){let z=650-i*600,x=530;while(heightAt(x,z)>2&&x<1800)x+=15;if(x>=1800)continue;worldMeshes.push(box(x+20,2.5,z,52,1,4,'#928c74'));for(let j=0;j<4;j++)worldMeshes.push(box(x+j*14,0,z, .5,5,.5,'#5a6760'));house(x-18,z,12,15,7,'#ad5941');let bx=x+100,bz=z+25;worldMeshes.push(ellipsoid(bx,1.7,bz,3,1.4,10,'#d9dacc',8,5));worldMeshes.push(box(bx,3.6,bz+1,4,3,5,'#a0b6b5'));worldMeshes.push(rod(new V(bx,4,bz),new V(bx,17,bz),.09,'#d6d9c7'));worldMeshes.push(wing([[bx,16,bz],[bx,5,bz],[bx,5,bz-8]],.025,'#e7e1cc'))}
// A few parked aircraft make the apron feel inhabited.
for(let i=0;i<3;i++){let parking=new V(-95,19.8,-75-i*56),q=Q.axis(new V(0,1,0),Math.PI/2);for(let part of makePlane(i===2?2:0))worldMeshes.push({...part,v:part.v.map(v=>q.rot(v).add(parking))})}
planeMesh=makePlane(selected);enemyMesh=makePlane(1);navCache=null;if(T&&scene)rebuildThree()};
const baseThreeMesh=threeMesh;threeMesh=function(m){let o=baseThreeMesh(m);if(m.terrain){let attr=o.geometry.attributes.color;for(let i=0;i<m.v.length;i++){let p=m.v[i],eps=25,sx=(heightAt(p.x+eps,p.z)-heightAt(p.x-eps,p.z))/(2*eps),sz=(heightAt(p.x,p.z+eps)-heightAt(p.x,p.z-eps))/(2*eps),slope=Math.hypot(sx,sz),n=fbm(p.x/180,p.z/180),rock=clamp((slope-.3)*1.5+(p.y-670)/900,0,1),snow=clamp((p.y-1140+n*160)/250,0,1),low=[76+n*21,102+n*21,66+n*16],stone=[113+n*25,121+n*22,114+n*22];let co=low.map((v,j)=>lerp(lerp(v,stone[j],rock),[212,220,213][j],snow));if(p.y<6)co=[124,127,100];attr.setXYZ(i,...co.map(v=>Math.pow(v/255,2.2)))}attr.needsUpdate=true;o.material.roughness=.93}else{let co=m.c;if(co[2]>co[0]*1.2&&co[0]<70){o.material.roughness=.18;o.material.metalness=.5}}return o};
function labelTexture(text,fg,bg,size=256){const c=document.createElement('canvas');c.width=size;c.height=size;let x=c.getContext('2d');if(bg){x.fillStyle=bg;x.fillRect(0,0,size,size)}x.fillStyle=fg;x.textAlign='center';x.textBaseline='middle';x.font='bold '+Math.floor(size*.66)+'px Arial';x.fillText(text,size/2,size/2);let texture=new T.CanvasTexture(c);texture.colorSpace=T.SRGBColorSpace;return texture}
function groundLabel(text,x,z,width,depth,rotation=0){let tex=labelTexture(text,'#f0edcf'),o=new T.Mesh(new T.PlaneGeometry(width,depth),new T.MeshBasicMaterial({map:tex,transparent:true,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-3}));o.rotation.x=-Math.PI/2;o.rotation.z=rotation;o.position.set(x,18.52,z);detailGroup.add(o)}
function skyShader(){return new T.ShaderMaterial({side:T.BackSide,depthWrite:false,uniforms:{sunDir:{value:new T.Vector3(-.7,.27,-.6).normalize()},zenith:{value:new T.Color('#618eaa')},horizon:{value:new T.Color('#efdcbb')}},vertexShader:'varying vec3 vDir; void main(){vDir=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}',fragmentShader:`varying vec3 vDir;uniform vec3 sunDir;uniform vec3 zenith;uniform vec3 horizon;void main(){vec3 d=normalize(vDir);float h=pow(max(d.y,0.),.46);vec3 col=mix(horizon,zenith,h);float sun=max(dot(d,sunDir),0.);col+=vec3(1.,.68,.29)*pow(sun,16.)*.22;col+=vec3(1.,.83,.51)*pow(sun,480.)*.75;col+=vec3(2.,1.6,1.1)*smoothstep(.99972,.9999,sun);gl_FragColor=vec4(col,1.);}`})}
function addScenery(){if(!T||!scene)return;disposeGroup(detailGroup);disposeGroup(gateGroup);detailGroup=new T.Group();scene.add(detailGroup);groundLabel('36',0,163,14,26);groundLabel('18',0,-1480,14,26,Math.PI);groundLabel('H',-87,-5,13,13);groundLabel('03',-77,-177,7,7);
// Taxi signage, fence, approach beacons and PAPI lights.
for(let z of [-1250,-750,-220,115]){let sign=new T.Mesh(new T.PlaneGeometry(5,1.8),new T.MeshBasicMaterial({map:labelTexture('A →','#1d2929','#ddbe58'),side:T.DoubleSide}));sign.position.set(-28,19.4,z);detailGroup.add(sign)}
const lampPositions=[];for(let z=240;z> -1550;z-=45)for(let s of [-1,1])lampPositions.push(s*18,18.85,z);for(let z=310;z<800;z+=45)for(let x of [-3,0,3])lampPositions.push(x,Math.max(18.5,heightAt(x,z)+.5),z);
let glowCanvas=document.createElement('canvas');glowCanvas.width=glowCanvas.height=64;let g=glowCanvas.getContext('2d'),grad=g.createRadialGradient(32,32,0,32,32,32);grad.addColorStop(0,'#fffceaff');grad.addColorStop(.14,'#fff1bcdd');grad.addColorStop(.4,'#ffce7955');grad.addColorStop(1,'#ffcc7700');g.fillStyle=grad;g.fillRect(0,0,64,64);let glowMap=new T.CanvasTexture(glowCanvas),geo=new T.BufferGeometry();geo.setAttribute('position',new T.Float32BufferAttribute(lampPositions,3));let lamps=new T.Points(geo,new T.PointsMaterial({map:glowMap,color:'#ffe5a1',size:2.4,transparent:true,depthWrite:false,blending:T.AdditiveBlending}));detailGroup.add(lamps);
for(let i=0;i<4;i++){let o=new T.Mesh(new T.BoxGeometry(1.3,.7,.5),new T.MeshStandardMaterial({color:i<2?'#dfffdb':'#ec5339',emissive:i<2?'#dfffdb':'#ff3928',emissiveIntensity:.5}));o.position.set(-27-i*3,18.9,-60);detailGroup.add(o)}
// Windsock responds to the wind vector, not aircraft heading.
let pole=new T.Mesh(new T.CylinderGeometry(.08,.11,9,7),new T.MeshStandardMaterial({color:'#c5cbbc'}));pole.position.set(-34,22.5,20);detailGroup.add(pole);let sock=new T.Group();sock.position.set(-34,27,20);for(let i=0;i<5;i++){let s=new T.Mesh(new T.CylinderGeometry(.6-i*.08,.53-i*.08,.62,12,1,true),new T.MeshStandardMaterial({color:i%2?'#e2dfc5':'#d6783f',side:T.DoubleSide}));s.rotation.z=Math.PI/2;s.position.x=i*.6+.3;s.position.y=-i*.14;sock.add(s)}sock.userData.windsock=true;detailGroup.add(sock);
const fence=[];for(let z=-450;z<250;z+=12){fence.push(...rod(new V(-188,18,z),new V(-188,20.4,z),.055,'#778b80').v)}let fg=new T.BufferGeometry();fg.setAttribute('position',new T.Float32BufferAttribute([-188,19,250,-188,19,-450,-188,20,250,-188,20,-450],3));detailGroup.add(new T.LineSegments(fg,new T.LineBasicMaterial({color:'#8e9c8a'})));
// Departure circuit gates. These are optional navigation aids, never arcade physics.
gateGroup=new T.Group();scene.add(gateGroup);route.forEach((p,i)=>{let group=new T.Group(),r=i>5?49:85,ring=new T.Mesh(new T.TorusGeometry(r,.55,5,72),new T.MeshBasicMaterial({color:'#c4ed9f',transparent:true,opacity:.4,depthWrite:false}));group.add(ring);let next=route[(i+1)%route.length],prev=route[Math.max(0,i-1)],direction=next.sub(prev).unit();group.quaternion.setFromUnitVectors(new T.Vector3(0,0,1),new T.Vector3(direction.x,0,direction.z).normalize());group.position.set(p.x,p.y,p.z);let tex=labelTexture(String(i+1).padStart(2,'0'),'#d8f7b6');let sprite=new T.Sprite(new T.SpriteMaterial({map:tex,transparent:true,opacity:.6,depthTest:true}));sprite.position.y=r+17;sprite.scale.set(20,20,1);group.add(sprite);gateGroup.add(group)});
// Navigation lamps mounted to each aircraft.
let navMat=new T.SpriteMaterial({map:glowMap,color:'#fa4739',transparent:true,depthWrite:false,blending:T.AdditiveBlending});for(let s of [-1,1]){let o=new T.Sprite(navMat.clone());o.material.color.set(s<0?'#ff553b':'#85ffb0');o.position.set(s*fleet[selected].span/2,selected===0?.85:.23,selected===0?-.4:1.6);o.scale.set(.48,.48,.48);o.userData.navLight=true;airGroup.add(o)}let beacon=new T.Sprite(navMat.clone());beacon.material.color.set('#fff4d3');beacon.position.set(0,2.2,fleet[selected].length*.37);beacon.scale.set(.65,.65,.65);beacon.userData.beacon=true;airGroup.add(beacon);
// A restrained cockpit coaming with proper forward visibility.
let cockpit=new T.Group();cockpit.name='cockpit';let frameMat=new T.MeshStandardMaterial({color:'#283a3c',roughness:.8});for(let s of [-1,1]){let pillar=threeMesh(rod(new V(s*.58,.45,-2.2),new V(s*.62,1.88,-2.8),.035,'#6d807b'));cockpit.add(pillar)}let dash=new T.Mesh(new T.BoxGeometry(1.24,.23,.65),frameMat);dash.position.set(0,.52,-2.1);cockpit.add(dash);airGroup.add(cockpit);cockpit.visible=false;
}
const baseRebuildThree=rebuildThree;rebuildThree=function(){baseRebuildThree();addScenery()};
function buildAtmosphere(){sunLight=scene.children.find(o=>o.isDirectionalLight);hemisphere=scene.children.find(o=>o.isHemisphereLight);sunLight.position.set(-2200,3200,-1600);sunLight.intensity=3.1;sunLight.shadow.camera.left=-400;sunLight.shadow.camera.right=400;sunLight.shadow.camera.top=400;sunLight.shadow.camera.bottom=-400;sunLight.shadow.camera.updateProjectionMatrix();hemisphere.intensity=2.15;renderer.toneMappingExposure=1.12;scene.fog=new T.FogExp2('#c5d3c8',.000059);
sea=scene.children.find(o=>o.isMesh&&o.geometry.type==='PlaneGeometry');if(sea){sea.material.dispose();sea.material=new T.MeshStandardMaterial({color:'#366f78',roughness:.3,metalness:.4});sea.material.onBeforeCompile=shader=>{shader.uniforms.uTime={value:0};sea.userData.shader=shader;shader.vertexShader=shader.vertexShader.replace('#include <common>','#include <common>\nvarying vec3 vWaterWorld;').replace('#include <worldpos_vertex>','#include <worldpos_vertex>\nvWaterWorld=(modelMatrix*vec4(transformed,1.)).xyz;');shader.fragmentShader=shader.fragmentShader.replace('#include <common>','#include <common>\nuniform float uTime; varying vec3 vWaterWorld;').replace('#include <normal_fragment_maps>',`#include <normal_fragment_maps>
float ripple=sin(vWaterWorld.x*.18+vWaterWorld.z*.22+uTime*.8)*.14+sin(vWaterWorld.x*.59-vWaterWorld.z*.42-uTime*1.2)*.08;
normal=normalize(normal+vec3(ripple,sin(vWaterWorld.z*.29+uTime)*.1,0.));`).replace('#include <color_fragment>',`#include <color_fragment>
float bands=sin(vWaterWorld.x*.03+sin(vWaterWorld.z*.028)*3.)*.5+.5;
diffuseColor.rgb*=.87+bands*.24;`)};sea.material.needsUpdate=true}
for(let o of [...scene.children])if(o.isMesh&&o.geometry.type==='SphereGeometry')scene.remove(o);skyDome=new T.Mesh(new T.SphereGeometry(28000,32,20),skyShader());skyDome.frustumCulled=false;scene.add(skyDome);
cloudGroup=new T.Group();scene.add(cloudGroup);let cloudGeo=new T.SphereGeometry(1,9,7),cloudMat=new T.MeshStandardMaterial({color:'#f7efdc',roughness:1,transparent:true,opacity:.8,depthWrite:false,flatShading:false});for(let i=0;i<36;i++){let cx=(hash(i,91)-.5)*22000,cz=hash(i,92)*16000-12000,cy=1800+hash(i,93)*1000;let group=new T.Group();for(let j=0;j<5;j++){let c=new T.Mesh(cloudGeo,cloudMat);c.position.set((j-2)*170,hash(i,j+500)*70,hash(i,j+600)*100);c.scale.set(180+hash(i,j+800)*220,65+hash(i,j+700)*80,170+hash(i,j+900)*100);group.add(c)}group.position.set(cx,cy,cz);cloudGroup.add(group)}
fxGroup=new T.Group();let fireGeo=new T.BufferGeometry();fireGeo.setAttribute('position',new T.BufferAttribute(new Float32Array(4500),3));fireGeo.setAttribute('color',new T.BufferAttribute(new Float32Array(4500),3));let fireMat=new T.PointsMaterial({size:7,vertexColors:true,transparent:true,opacity:.8,depthWrite:false,blending:T.AdditiveBlending});let fire=new T.Points(fireGeo,fireMat);fire.frustumCulled=false;fxGroup.add(fire);scene.add(fxGroup);applyLighting()}
const baseInitThree=initThree;initThree=function(){baseInitThree();buildAtmosphere()};
function applyLighting(){lightMode=$('daylight').value;if(!scene||!skyDome)return;let dusk=lightMode==='dusk',day=lightMode==='day';skyDome.material.uniforms.zenith.value.set(dusk?'#14213d':day?'#275779':'#315e7b');skyDome.material.uniforms.horizon.value.set(dusk?'#a5746c':day?'#a7c5cd':'#c6c9b5');skyDome.material.uniforms.sunDir.value.set(-.7,day?.7:dusk?.03:.27,-.6).normalize();sunLight.color.set(day?'#fff4da':dusk?'#eb9e7c':'#ffdaa1');sunLight.intensity=dusk?.8:day?3.1:3.2;hemisphere.intensity=dusk?1.2:2.3;hemisphere.color.set(dusk?'#829bcc':'#e4f4ff');scene.fog.color.set(dusk?'#8d9dba':day?'#c5d9d8':'#cbd4c4');renderer.toneMappingExposure=dusk?.96:1.1;document.querySelector('.weather b').textContent=dusk?'Blue hour · 11°C':day?'Clear skies · 15°C':'Golden hour · 15°C'}$('daylight').onchange=applyLighting;
const baseDrawThree=drawThree;drawThree=function(){airGroup.position.set(pos.x,pos.y,pos.z);airGroup.quaternion.set(orientation.x,orientation.y,orientation.z,orientation.w);airGroup.visible=true;
for(let i=0;i<airGroup.children.length;i++){let o=airGroup.children[i],m=planeMesh[i];if(m){o.visible=cameraMode!==1&&(!m.landing||gear||selected===0);if(m.propeller)o.rotation.z=worldClock*(6+spool*100)}else if(o.name==='cockpit'){o.visible=cameraMode===1}else{o.visible=cameraMode!==1;if(o.userData.beacon)o.visible=o.visible&&(worldClock%1.5<.12)}}
enemyGroup.position.set(enemy.pos.x,enemy.pos.y,enemy.pos.z);enemyGroup.quaternion.set(enemy.q.x,enemy.q.y,enemy.q.z,enemy.q.w);enemyGroup.visible=mode==='dogfight'&&enemy.hp>0;enemyGroup.children.forEach((o,i)=>{let m=enemyMesh[i];o.visible=!m||!m.landing});let a=threeBullets.geometry.attributes.position;bullets.slice(0,100).forEach((b,i)=>{a.setXYZ(i*2,b.p.x,b.p.y,b.p.z);let end=b.p.sub(b.v.unit().mul(26));a.setXYZ(i*2+1,end.x,end.y,end.z)});a.needsUpdate=true;threeBullets.geometry.setDrawRange(0,Math.min(bullets.length,100)*2);threeBullets.visible=bullets.length>0;
if(skyDome)skyDome.position.copy(camera.position);if(sea&&sea.userData.shader)sea.userData.shader.uniforms.uTime.value=worldClock;if(cloudGroup)cloudGroup.position.x=Math.sin(worldClock*.002)*120;if(gateGroup){gateGroup.visible=pathAid&&mode==='precision'&&phase<4;gateGroup.children.forEach((g,i)=>{g.visible=i>=routeStep&&i<routeStep+3;g.children[0].material.opacity=i===routeStep?.45:.15})}if(sunLight){sunLight.position.set(pos.x-2200,pos.y+3200,pos.z-1600);sunLight.target.position.set(pos.x,pos.y,pos.z)}if(detailGroup){let sock=detailGroup.children.find(o=>o.userData.windsock);if(sock)sock.rotation.y=Math.sin(worldClock*1.1)*.09}
if(fxGroup){fxGroup.visible=fx.length>0;let g=fxGroup.children[0].geometry,p=g.attributes.position,c=g.attributes.color;fx.slice(0,1500).forEach((f,i)=>{p.setXYZ(i,f.p.x,f.p.y,f.p.z);let t=f.life/f.total;c.setXYZ(i,t,f.hot?t*t*.6:t*.5,f.hot?t*t*.17:t*.45)});p.needsUpdate=true;c.needsUpdate=true;g.setDrawRange(0,Math.min(fx.length,1500))}renderer.render(scene,camera)};
const navCtx=$('navMap').getContext('2d'),overlay=$('sceneOverlay').getContext('2d');let lastTrack=0,fxPrev=performance.now();
function drawNav(){let c=navCtx,w=420,h=286,scale=.032,cx=0,cz=-1500;c.clearRect(0,0,w,h);if(!navCache){navCache=document.createElement('canvas');navCache.width=w;navCache.height=h;let n=navCache.getContext('2d');for(let y=0;y<h;y+=3)for(let x=0;x<w;x+=3){let wx=(x-w/2)/scale+cx,wz=(y-h/2)/scale+cz,ht=heightAt(wx,wz),t=clamp(ht/1300,0,1),contour=Math.abs(ht%100)<10;n.fillStyle=ht<1?'#244b54':contour?'#5b756450':`rgb(${Math.round(34+t*43)},${Math.round(55+t*36)},${Math.round(48+t*27)})`;n.fillRect(x,y,3,3)}}c.drawImage(navCache,0,0);c.strokeStyle='#b5cdc014';c.lineWidth=1;for(let i=0;i<420;i+=42){c.beginPath();c.moveTo(i,0);c.lineTo(i,h);c.stroke()}for(let i=0;i<286;i+=42){c.beginPath();c.moveTo(0,i);c.lineTo(w,i);c.stroke()}function mp(p){return{x:w/2+(p.x-cx)*scale,y:h/2+(p.z-cz)*scale}}let runway=mp(new V(0,0,-640));c.lineWidth=5;c.strokeStyle='#d5decd';c.beginPath();c.moveTo(runway.x,runway.y-915*scale);c.lineTo(runway.x,runway.y+915*scale);c.stroke();c.font='12px monospace';c.fillStyle='#c4ed9f';c.fillText('ENNF',runway.x+9,runway.y+18);c.font='10px monospace';c.fillStyle='#90aaa7';c.fillText('NORDFJORD',297,230);c.fillText('N ↑',18,23);
if(mode==='precision'&&pathAid){c.strokeStyle='#c4ed9f65';c.lineWidth=1;c.setLineDash([4,5]);c.beginPath();route.forEach((p,i)=>{let q=mp(p);i?c.lineTo(q.x,q.y):c.moveTo(q.x,q.y)});c.stroke();c.setLineDash([]);let active=mp(route[Math.min(routeStep,route.length-1)]);c.strokeStyle='#c4ed9f';c.beginPath();c.arc(active.x,active.y,6,0,7);c.stroke()}
c.strokeStyle='#e4eac187';c.lineWidth=1.5;c.beginPath();trail.forEach((p,i)=>{let q=mp(p);i?c.lineTo(q.x,q.y):c.moveTo(q.x,q.y)});c.stroke();let p=mp(pos),off=p.x<10||p.x>w-10||p.y<10||p.y>h-10;c.save();c.translate(clamp(p.x,10,w-10),clamp(p.y,10,h-10));c.rotate(angles().heading*rad);c.fillStyle=off?'#e9b784':'#e3f7c6';c.beginPath();c.moveTo(0,-10);c.lineTo(6,7);c.lineTo(0,3);c.lineTo(-6,7);c.closePath();c.fill();c.restore();if(mode==='dogfight'&&enemy.hp>0){let e=mp(enemy.pos);c.strokeStyle='#f2ac82';c.strokeRect(clamp(e.x,5,w-10)-4,clamp(e.y,5,h-10)-4,8,8)}let delta=new V(0,18,200).sub(pos);$('navDistance').textContent=(delta.len()/1852).toFixed(1)+' NM';$('navBearing').textContent=Math.round((Math.atan2(delta.x,-delta.z)/rad+360)%360).toString().padStart(3,'0')+'°'}
function drawOverlay(){if($('sceneOverlay').width!==W||$('sceneOverlay').height!==H){$('sceneOverlay').width=W;$('sceneOverlay').height=H}let c=overlay;c.clearRect(0,0,W,H);if(document.body.classList.contains('clean'))return;
// Small, unobtrusive velocity vector: nose attitude and momentum are not the same thing.
if(started&&vel.len()>15){let v=project(pos.add(vel.unit().mul(900)));if(v&&v.x>W*.25&&v.x<W*.75&&v.y>200&&v.y<H-160){c.strokeStyle='#d3edb4b0';c.lineWidth=1;c.beginPath();c.arc(v.x,v.y,6,0,7);c.moveTo(v.x-17,v.y);c.lineTo(v.x-6,v.y);c.moveTo(v.x+6,v.y);c.lineTo(v.x+17,v.y);c.moveTo(v.x,v.y-6);c.lineTo(v.x,v.y-12);c.stroke()}}
if(mode==='precision'&&pathAid&&routeStep<route.length){let gate=route[routeStep],p=project(gate);if(p&&p.x>W*.23&&p.x<W*.77&&p.y>220&&p.y<H-160){c.textAlign='center';c.font='9px monospace';c.fillStyle='#d5eac3b0';let top=Math.max(242,p.y-(H*.88*(routeStep>5?49:85)/p.z)-28);c.fillText((routeStep<2?'DEPARTURE':routeStep<6?'CIRCUIT':'FINAL')+' / '+String(routeStep+1).padStart(2,'0'),p.x,top);c.fillStyle='#bfd0bf';c.font='8px monospace';c.fillText((gate.sub(pos).len()/1852).toFixed(1)+' NM · '+Math.round(gate.y*3.28084)+' FT',p.x,top+14);if(!renderer){c.strokeStyle='#c4ed9f70';c.lineWidth=1.5;c.beginPath();c.arc(p.x,p.y,clamp(H*.88*85/p.z,3,200),0,7);c.stroke()}}}
if(mode==='dogfight'&&enemy.hp>0){let delta=enemy.pos.sub(pos),range=delta.len(),lead=enemy.pos.add(enemy.vel.sub(vel).mul(range/900)),p=project(lead);if(p&&p.x>270&&p.x<W-250&&p.y>220&&p.y<H-150){c.strokeStyle='#edc69b';c.setLineDash([3,4]);c.beginPath();c.arc(p.x,p.y,12,0,7);c.stroke();c.setLineDash([]);c.fillStyle='#edc69b';c.fillRect(p.x-1,p.y-1,2,2);c.font='8px monospace';c.fillText('LEAD',p.x+17,p.y+3)}let target=project(enemy.pos);if(!target||target.x<50||target.x>W-50||target.y<80||target.y>H-140){let r=delta.dot(cameraBasis.r),u=delta.dot(cameraBasis.u),ang=Math.atan2(-u,r),x=W/2+Math.cos(ang)*Math.min(W*.3,300),y=H/2+Math.sin(ang)*Math.min(H*.22,150);c.save();c.translate(x,y);c.rotate(ang);c.fillStyle='#f1b083';c.beginPath();c.moveTo(10,0);c.lineTo(-5,-5);c.lineTo(-5,5);c.fill();c.restore();c.font='9px monospace';c.fillStyle='#f1b083';c.textAlign='center';c.fillText('BANDIT '+(range/1000).toFixed(1)+' KM',x,y+20)}}
// High-G tunnel vision is linked to measured lift load, not key presses.
if(running&&lastG>4){let g=c.createRadialGradient(W/2,H/2,H*.16,W/2,H/2,H*.7);g.addColorStop(0,'#00000000');g.addColorStop(1,'rgba(5,10,13,'+clamp((lastG-4)/7,0,.85)+')');c.fillStyle=g;c.fillRect(0,0,W,H)}if(crashed){c.fillStyle='#7b261414';c.fillRect(0,0,W,H)}}
function explosion(p,count,hot=true){for(let i=0;i<count;i++){let dir=new V(Math.random()-.5,Math.random()-.25,Math.random()-.5).unit(),life=.5+Math.random()*2.5;fx.push({p:p.add(dir.mul(Math.random()*3)),v:dir.mul(8+Math.random()*45),life,total:life,hot})}}
function debrief(){if(reportShown)return;reportShown=true;$('reportEyebrow').textContent=crashed?'FLIGHT RECORDER / SORTIE ENDED':'FLIGHT RECORDER / MISSION COMPLETE';$('reportTitle').textContent=crashed?'Learn. Reset. Fly again.':mode==='dogfight'?'Airspace secured.':'A fine return to earth.';$('reportDesc').textContent=crashed?'Respect the flight envelope. Keep enough airspeed to fly, watch your angle of attack, and leave room to recover.':mode==='dogfight'?'One bandit neutralized. Your energy management and cannon solution made the difference.':'Departure, circuit, arrival. The best landing is the one you can fly again from.';$('reportTime').textContent=Math.floor(elapsed/60).toString().padStart(2,'0')+':'+Math.floor(elapsed%60).toString().padStart(2,'0');$('reportAlt').textContent=Math.round(maxAltitude)+' FT';$('reportSpeed').textContent=Math.round(maxAirspeed)+' KTS';$('reportExtraLabel').textContent=mode==='dogfight'?'CANNON ACCURACY':'LANDING RATE';$('reportExtra').textContent=mode==='dogfight'?(shots?Math.min(100,Math.round(4/shots*100)):0)+'%':landingRate===null?'—':Math.round(landingRate*196.85)+' FPM';modal('debriefModal')}
const baseResetFlight=resetFlight;resetFlight=function(){trim=0;maxAltitude=0;maxAirspeed=0;landingRate=null;reportShown=false;routeStep=0;routeScore=0;trail=[];fx=[];lastTrack=0;lastEnemyHP=100;wasCrashed=false;$('debriefModal').classList.remove('open');baseResetFlight()};
const basePhysics=physics;physics=function(dt){let wasRunning=running,oldPhase=phase,sink=vel.y,enemyBefore=enemy.hp;basePhysics(dt);if(!wasRunning)return;maxAltitude=Math.max(maxAltitude,pos.y*3.28084);maxAirspeed=Math.max(maxAirspeed,ias);if(elapsed-lastTrack>.7){lastTrack=elapsed;trail.push(new V(pos.x,pos.y,pos.z));if(trail.length>600)trail.shift()}if(phase===4&&oldPhase!==4){landingRate=sink;explosion(pos,15,false)}if(mode==='precision'&&routeStep<route.length&&pos.sub(route[routeStep]).len()<(routeStep>5?70:120)){routeStep++;routeScore++;notify(routeStep===route.length?'On final. Gear down, manage your descent, aim beyond the threshold.':'Waypoint '+routeStep+' / 8 · '+(routeStep<2?'Keep climbing.':routeStep<6?'Fly the circuit at your own pace.':'Set up for runway 36.'),4)}if(enemy.hp<enemyBefore){explosion(enemy.pos,enemy.hp<=0?180:25);if(enemy.hp<=0)setTimeout(()=>{if(enemy.hp<=0&&mode==='dogfight')debrief()},2200)}if(crashed&&!wasCrashed){wasCrashed=true;explosion(pos,160);setTimeout(()=>{if(crashed)debrief()},2100)}if(phase===5&&oldPhase!==5)setTimeout(()=>{if(phase===5)debrief()},2000)};
// Make the opponent a continuous, energy-limited maneuvering target instead of teleporting it.
const baseCombat=combat;combat=function(dt){let old=enemy.pos,oldQ=enemy.q,oldHP=enemy.hp;baseCombat(dt);if(oldHP<=0||enemy.hp<=0)return;let speed=selected===0?49:selected===1?185:118;let yaw=(Math.atan2(-oldQ.rot(new V(0,0,-1)).x,-oldQ.rot(new V(0,0,-1)).z));let delta=pos.sub(old),range=delta.len(),turn=range>2100||elapsed%65>36;let desiredYaw=turn?Math.atan2(-delta.x,-delta.z):Math.sin(elapsed*.04)*.35;let diff=Math.atan2(Math.sin(desiredYaw-yaw),Math.cos(desiredYaw-yaw)),rate=clamp(diff*.45,-.105,.105),newYaw=yaw+rate*dt;let safe=heightAt(old.x,old.z)+300,desiredAlt=Math.max(safe,pos.y+Math.sin(elapsed*.11)*100),pitch=clamp((desiredAlt-old.y)/850,-.16,.19),bank=clamp(-Math.atan(speed*rate/9.81),-.9,.9);enemy.q=Q.axis(new V(0,1,0),newYaw).mul(Q.axis(new V(1,0,0),pitch)).mul(Q.axis(new V(0,0,1),bank));enemy.vel=enemy.q.rot(new V(0,0,-speed));enemy.pos=old.add(enemy.vel.mul(dt))};
const baseUpdateHUD=updateHUD;updateHUD=function(){baseUpdateHUD();let a=angles();$('tapeSpeed').textContent=Math.round(ias).toString().padStart(3,'0');$('tapeAlt').textContent=Math.round(pos.y*3.28084);$('chipPlane').textContent=fleet[selected].name;$('chipStatus').textContent=crashed?'AIRFRAME LOST':paused?'SIMULATION PAUSED':Math.abs(lastAlpha)>fleet[selected].stall?'STALL WARNING':'SYSTEMS NOMINAL';$('integrityBar').style.width=Math.max(0,airframe)+'%';$('chipFuel').textContent='AIRFRAME '+Math.max(0,airframe)+'% · TRIM '+Math.round(trim*100)+'%';let bearing=Math.round(a.heading/15)*15;document.querySelector('.compass').textContent=[-30,-15,0,15,30].map(d=>{let h=(bearing+d+360)%360;return h===0?'N':h===90?'E':h===180?'S':h===270?'W':String(h).padStart(3,'0')}).join(' · ');$('flightLog').innerHTML=mode==='precision'?(phase<1?'ROTATE '+fleet[selected].rotate+' KTS · RWY 36 / 1,830 M':phase<4?'CIRCUIT '+routeScore+' / 8 · '+(pos.sub(new V(0,18,200)).len()/1852).toFixed(1)+' NM TO FIELD':'LANDING '+(landingRate===null?'—':Math.round(landingRate*196.85)+' FPM'))+'<br>TRIM '+Math.round(trim*100)+'% · FLAPS '+flaps+'° · GEAR '+(gear?'DOWN':'UP'):'AIRFRAME '+Math.max(0,airframe)+'% · CANNON '+shots+' RDS<br>'+(enemy.hp>0?'BANDIT '+(enemy.pos.sub(pos).len()/1000).toFixed(2)+' KM · '+enemy.hp+'%':'1 CONFIRMED KILL · AIRSPACE SECURE');drawNav()};
const baseUpdateCamera=updateCamera;updateCamera=function(dt){baseUpdateCamera(dt);worldClock+=running?dt:!started?dt*.3:0;fx=fx.filter(f=>{if(!paused){f.life-=dt;f.v.y-=dt*4;f.p=f.p.add(f.v.mul(dt));f.v=f.v.mul(Math.exp(-dt*.8))}return f.life>0});drawOverlay()};
// Offline preview remains self-contained; Three.js is the primary scene renderer.
const baseSoftware=drawSoftware;drawSoftware=function(){baseSoftware();if(fx.length){for(let f of fx){let p=project(f.p);if(!p)continue;ctx.fillStyle=f.hot?'rgba(255,178,84,'+f.life/f.total+')':'#b7bcad70';ctx.beginPath();ctx.arc(p.x,p.y,clamp(100/p.z,1,8),0,7);ctx.fill()}}};
$('cameraBtn').title='C: chase → cockpit → wing-follow. Both exterior cameras stay centered behind the aircraft.';
$('audioBtn').setAttribute('aria-pressed','true');const oldAudioClick=$('audioBtn').onclick;$('audioBtn').onclick=()=>{oldAudioClick();$('audioBtn').setAttribute('aria-pressed',String(audioOn));$('audioBtn').classList.toggle('audio-disabled',!audioOn)};
// Soft volumetric-looking cloud cards, procedurally painted without image assets.
const originalAtmosphere=buildAtmosphere;buildAtmosphere=function(){originalAtmosphere();disposeGroup(cloudGroup);cloudGroup=new T.Group();scene.add(cloudGroup);let cv=document.createElement('canvas');cv.width=512;cv.height=256;let cc=cv.getContext('2d');for(let i=0;i<55;i++){let x=75+hash(i,21)*360,y=110+hash(i,26)*58,r=30+hash(i,31)*53;let gradient=cc.createRadialGradient(x,y,r*.12,x,y,r);gradient.addColorStop(0,'rgba(245,248,235,.15)');gradient.addColorStop(.45,'rgba(233,238,227,.1)');gradient.addColorStop(1,'rgba(226,237,233,0)');cc.fillStyle=gradient;cc.fillRect(x-r,y-r,r*2,r*2)}let tex=new T.CanvasTexture(cv);tex.colorSpace=T.SRGBColorSpace;for(let i=0;i<34;i++){let mat=new T.SpriteMaterial({map:tex,transparent:true,opacity:.54+hash(i,718)*.3,depthWrite:false,fog:true,color:'#fffaea'}),o=new T.Sprite(mat);o.position.set((hash(i,511)-.5)*23000,2100+hash(i,512)*1400,hash(i,513)*19000-15000);o.scale.set(1600+hash(i,514)*2000,600+hash(i,515)*700,1);cloudGroup.add(o)}skyDome.material.fragmentShader=skyDome.material.fragmentShader.replace('gl_FragColor=vec4(col,1.);','gl_FragColor=vec4(col,1.);\n#include <tonemapping_fragment>\n#include <colorspace_fragment>');skyDome.material.needsUpdate=true;};
const baseAudioUpdate=updateAudio;updateAudio=function(){baseAudioUpdate();if(audio){let t=audio.ac.currentTime;audio.filter.frequency.setTargetAtTime((270+spool*1600)*(cameraMode===1?.6:1),t,.15);audio.master.gain.setTargetAtTime(audioOn?volume*(cameraMode===1?.28:.36):0,t,.12)}};
for(let [code,label,title] of [['KeyS','−','Reduce throttle'],['KeyB','B','Wheel brakes']]){let b=document.createElement('button');b.dataset.key=code;b.textContent=label;b.title=title;b.onpointerdown=e=>{e.preventDefault();keys[code]=true;b.setPointerCapture(e.pointerId)};b.onpointerup=b.onpointercancel=()=>keys[code]=false;document.querySelector('.touch-controls').appendChild(b)}
document.querySelectorAll('button').forEach(b=>{if(!b.hasAttribute('aria-label')&&b.title)b.setAttribute('aria-label',b.title)});
/* EXPANSION END */
resize();buildWorld();updateCamera(1);updateHUD();drawSoftware();loadThree();requestAnimationFrame(frame);
window.apex={get state(){return{running,paused,crashed,selected,mode,position:{...pos},velocity:{...vel},throttle,spool,ias,alpha:lastAlpha,g:lastG,phase,elapsed,enemyHP:enemy.hp,shots,renderer:renderer?'Three.js':'offline preview'}},reset:resetFlight,start:beginFlight,select:selectPlane,step:physics};
</script>
</body>
</html>
<!-- agent-meta {"model":"openai/gpt-6-astra","provider":"openrouter","persona":"full","sessionId":"fea8c69a-1607-410a-ac1e-553bc32f3935","tokensIn":1180911,"tokensOut":25194,"tokensTotal":1206105,"cost":3.1180815000000006,"turns":33,"toolCalls":32,"failedToolCalls":0,"timestamp":"2026-09-05T15:20:26.276Z"} -->