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