Spaces:
Running
Running
Download game.js from CyberMax-tools/fellwing: direct link, hf CLI and curl.
- Browser
- Download file 70.5 kB
-
https://huggingface.co/spaces/CyberMax-tools/fellwing/resolve/main/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/fellwing/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/fellwing/resolve/main/game.js
70.5 kB
| // Fellwing: game flow, input (keyboard, gamepad, touch yoke + throttle), flight assist, cameras, route gates, | |
| // photos, clips, challenge links and the Fun Pass extras (aircraft, time of day, weather, saved spots, past routes). | |
| // (c) 2026 CyberMax. All rights reserved. | |
| import * as THREE from 'three'; | |
| import { SPOTS, AIRPORTS, RUNS, LANDINGS, spotById, isAirport, startHeading, dayNumber, dailySpot, dailyLanding, route, RINGS, encodeChallenge, decodeChallenge, fmtTime, bearing, approachStart, landingScore } from './places.js'; | |
| import { AIRCRAFT, createPlane, step, readout, fromEuler, KT, vec } from './flight.js'; | |
| import { Frame, Terrain, levelHeight } from './terrain.js'; | |
| import { Imagery, creditsAt } from './imagery.js'; | |
| import { Buildings } from './osm.js'; | |
| import { makeSky, makeSea, makeClouds, makeRunway, makeGate, makeShadow, makeAircraft, animateAircraft } from './world.js'; | |
| import { TIMES, WEATHER, sunPosition, solarTime, sunsetHour, SOLAR, lightFor } from './atmos.js'; | |
| import { Hud } from './hud.js'; | |
| import { Sound } from './audio.js'; | |
| import * as pass from './funpass.js'; | |
| import { count, store, toast, share, intents, download, pwa } from './cmx.js'; | |
| const GAME = 'fellwing', PATH = '/games/fellwing/'; | |
| const $ = (id) => document.getElementById(id); | |
| const cv = $('c'), stage = $('stage'); | |
| const touchDev = matchMedia('(pointer:coarse)').matches || 'ontouchstart' in window; | |
| // phones and small tablets get the light profile; laptops (1366x768 and up, no touch) keep full detail and lean on the | |
| // adaptive resolution below if they are slow | |
| const small = (touchDev && Math.min(screen.width, screen.height) < 820) || Math.min(screen.width, screen.height) < 480 || /[?&]lowq=1/.test(location.search); | |
| const TEST = /[?&]test=1/.test(location.search) || navigator.webdriver; | |
| const CAP = /[?&]cap=1/.test(location.search); | |
| // ---------------------------------------------------------------- renderer | |
| let renderer; | |
| try { | |
| renderer = new THREE.WebGLRenderer({ canvas: cv, antialias: !small, logarithmicDepthBuffer: true, powerPreference: 'high-performance', preserveDrawingBuffer: false }); | |
| if (!renderer.capabilities.isWebGL2) throw new Error('webgl2'); | |
| } catch (e) { $('loading').hidden = true; $('nogl').hidden = false; throw e; } | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| let pr = Math.min(devicePixelRatio || 1, small ? 1.5 : 2); | |
| const Q = small ? { seg: 40, maxZ: 13, split: 1.1, parallel: 8, limit: 260, low: true } : { seg: 64, maxZ: 14, split: 1.6, parallel: 12, limit: 480 }; | |
| const imagery = /[?&]noimg=1/.test(location.search) ? null : new Imagery(renderer, { k2: !small, parallel: small ? 6 : 10, aniso: small ? 4 : 8 }); | |
| const scene = new THREE.Scene(); | |
| const camera = new THREE.PerspectiveCamera(62, 1, 0.4, 900000); | |
| const U = { | |
| uSun: { value: new THREE.Vector3(0, 1, 0) }, uHaze: { value: 0.15 }, uOvercast: { value: 0 }, uNight: { value: 0 }, uSunCol: { value: new THREE.Vector3(3, 3, 3) }, | |
| uCam: { value: new THREE.Vector3() }, uEarth: { value: null }, uEarthOn: { value: 0 }, uAmbSky: { value: new THREE.Vector3(0.3, 0.4, 0.55) }, uAmbGnd: { value: new THREE.Vector3(0.08, 0.07, 0.05) }, | |
| uLat: { value: 46 }, uSnow: { value: 2800 }, uTime: { value: 0 }, uCloudAlt: { value: 2600 }, uCover: { value: 0.3 }, uWind: { value: new THREE.Vector2(3, 0) }, | |
| }; | |
| const sky = makeSky(U); scene.add(sky); | |
| const sea = makeSea(U); scene.add(sea); | |
| const clouds = makeClouds(U); scene.add(clouds); | |
| const sunLight = new THREE.DirectionalLight(0xffffff, 3); scene.add(sunLight); scene.add(sunLight.target); | |
| const hemi = new THREE.HemisphereLight(0x9cc0ff, 0x3a3226, 0.8); scene.add(hemi); | |
| const shadow = makeShadow(); scene.add(shadow); | |
| const pmrem = new THREE.PMREMGenerator(renderer); | |
| let envRT = null, envKey = ''; | |
| new THREE.TextureLoader().load('earth.webp', (t) => { t.colorSpace = THREE.NoColorSpace; t.anisotropy = 4; t.wrapS = THREE.RepeatWrapping; U.uEarth.value = t; U.uEarthOn.value = 1; }); | |
| // ---------------------------------------------------------------- settings + state | |
| const SET = Object.assign({ ac: 'kestrel', time: 'day', wx: 'fair', assist: true, invert: false, hud: true, sound: true, cam: 0 }, store('fw-set') || {}); | |
| const saveSet = () => store('fw-set', SET); | |
| const STATS = Object.assign({ routes: 0, best: {}, flown: 0 }, store('fw-stats') || {}); | |
| const saveStats = () => store('fw-stats', STATS); | |
| let buildings = null, landing = null, roll = null, td = null, lastTouch = null; | |
| const tut = { step: -1, hdg0: null }; | |
| const BEST = Object.assign({}, store('fw-land') || {}); | |
| let frame = null, terrain = null, P = null, model = null, runways = [], gates = [], route0 = null, mode = 'boot', paused = false, placeInfo = null; | |
| let challenge = null, flightT = 0, routeT = 0, routeIdx = 0, done = null, startedAt = 0, holdUntilGround = false, holdT = 0, crashT = 0; | |
| const sound = new Sound(); sound.on = SET.sound; | |
| const hud = new Hud($('hud')); | |
| const today = dayNumber(); | |
| let dayN = today; | |
| const q = new URLSearchParams(location.search); | |
| challenge = decodeChallenge(q.get('c')); | |
| if (q.get('d') && +q.get('d') >= 1 && +q.get('d') <= today) dayN = +q.get('d'); | |
| const atParam = (q.get('at') || '').split(',').map(Number); | |
| const own = (kind, id) => CAP || (kind === 'ac' ? AIRCRAFT[id]?.free : kind === 'time' ? TIMES[id]?.free : WEATHER[id]?.free) || pass.isPro(); | |
| // ---------------------------------------------------------------- world setup for a start | |
| function snowLine(lat) { const a = Math.abs(lat), t = Math.max(0, Math.min(1, (a - 10) / 60)); return 5500 - 3700 * t * t * (3 - 2 * t); } | |
| function applyLook() { | |
| const tid = own('time', SET.time) ? SET.time : 'day', W = WEATHER[own('wx', SET.wx) ? SET.wx : 'fair']; | |
| const T = realSun(tid); | |
| const el = (T.el * Math.PI) / 180, az = (T.az * Math.PI) / 180; | |
| const sun = new THREE.Vector3(Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)).normalize(); | |
| U.uSun.value.copy(sun); | |
| const oc = W.overcast; | |
| const i = T.i * (1 - oc * 0.72); | |
| U.uSunCol.value.set(T.sun[0] * i, T.sun[1] * i, T.sun[2] * i); | |
| U.uHaze.value = W.haze; U.uOvercast.value = oc; U.uNight.value = T.night; | |
| const amb = T.amb * (0.85 + oc * 0.3); | |
| U.uAmbSky.value.set(0.26 * amb, 0.34 * amb, 0.48 * amb); | |
| U.uAmbGnd.value.set(0.07 * amb, 0.06 * amb, 0.045 * amb); | |
| U.uCover.value = W.clouds; | |
| sunLight.color.setRGB(T.sun[0], T.sun[1], T.sun[2]); sunLight.intensity = i; | |
| hemi.intensity = 0.9 * amb; | |
| renderer.toneMappingExposure = T.night > 0.5 ? 1.6 : 1.0; | |
| // reflections on the aircraft: a PMREM of the sky for this light | |
| const key = `${SET.time}|${SET.wx}|${pass.isPro()}`; | |
| if (envKey !== key) { | |
| envKey = key; | |
| const envScene = new THREE.Scene(); const s2 = makeSky(U); envScene.add(s2); | |
| if (envRT) envRT.dispose(); | |
| envRT = pmrem.fromScene(envScene, 0, 0.1, 2000); | |
| } | |
| scene.environment = null; | |
| if (model) model.traverse((o) => { if (o.material && o.material.envMap !== undefined && o.material.isMeshStandardMaterial) { o.material.envMap = envRT.texture; o.material.needsUpdate = true; } }); | |
| return W; | |
| } | |
| // the real sun over the place being flown: each preset is a local solar time today (noon, mid-morning, before and after | |
| // sunset, late night), 'live' is right now. Sun colour and sky light follow the real elevation. | |
| function realSun(tid) { | |
| const T0 = TIMES[tid]; | |
| const ll = placeInfo ? placeLatLon(placeInfo) : [46, 8]; | |
| const [lat, lon] = ll; | |
| let when; | |
| if (tid === 'live' || CAP_TIME) when = CAP_TIME ? new Date(CAP_TIME) : new Date(); | |
| else { | |
| const sp = SOLAR[tid], set = sunsetHour(lat); | |
| const h = typeof sp === 'number' ? sp : set + parseFloat(sp.slice(3)); | |
| when = solarTime(lon, h); | |
| } | |
| const s = sunPosition(lat, lon, when); | |
| let el = s.el; | |
| if (tid === 'day') el = Math.max(el, 16); // polar winter noon: keep the free daytime sky a daytime sky | |
| if (tid === 'golden' && (el < 2 || el > 14)) el = 7; | |
| if (tid === 'dusk' && (el > 0 || el < -6)) el = -2.5; | |
| if (tid === 'night' && el > -12) el = -18; | |
| return { ...lightFor(el), el, az: s.az, name: T0.name }; | |
| } | |
| const CAP_TIME = +(new URLSearchParams(location.search).get('t') || 0) || 0; | |
| function placeLatLon(p) { return isAirport(p) ? [p.a[0], p.a[1]] : [p.lat, p.lon]; } | |
| function start(p, opts = {}) { | |
| placeInfo = p; | |
| const [lat, lon] = placeLatLon(p); | |
| const W = applyLook(); | |
| const far = !frame || Math.abs(frame.lat0 - lat) > 0.6 || Math.abs(frame.lon0 - lon) > 0.6 / Math.max(0.2, Math.cos((lat * Math.PI) / 180)); | |
| if (!terrain) { frame = new Frame(lat, lon); terrain = new Terrain(scene, frame, { ...Q, uniforms: U, imagery }); if (!/[?&]nobld=1/.test(location.search)) buildings = new Buildings(scene, terrain, U, small ? { radius: 3000, max: 14, minH: 7, parallel: 3, budget: 3.5e5 } : { radius: 7000, max: 40, parallel: 4, budget: 1.2e6 }); } | |
| else if (far) { frame = new Frame(lat, lon); terrain.reframe(frame); if (buildings) buildings.reset(); } | |
| U.uLat.value = lat; U.uSnow.value = snowLine(lat); | |
| // runways near this start | |
| if (far || !runways.built) { | |
| for (const r of runways) { scene.remove(r.mesh); r.mesh.geometry.dispose(); } | |
| runways = terrain.masks.filter((r) => r.far < 120000).map((r) => { const mesh = makeRunway(r, U); scene.add(mesh); return { r, mesh }; }); | |
| runways.built = true; | |
| } | |
| // aircraft | |
| const ac = own('ac', SET.ac) ? SET.ac : 'kestrel'; | |
| const A = AIRCRAFT[ac]; | |
| const hdg = opts.hdg ?? startHeading(p); | |
| let pos, speed, onGround = false; | |
| landing = opts.landing ? { a: p, mask: terrain.masks.find((m) => m.a.id === p.id) } : null; | |
| if (isAirport(p) && opts.landing) { // final approach: 7 km out on a 3-degree path, gear down, first flaps, slowed | |
| const s0 = approachStart(p); | |
| const [x, z] = frame.toLocal(s0.lat, s0.lon); | |
| pos = { x, y: s0.alt, z }; speed = (ac === 'vesper' ? 135 : Math.max(A.vr * 1.35, 70)) * KT; | |
| } else if (isAirport(p)) { | |
| const rm = terrain.masks.find((m) => m.a.id === p.id); | |
| const d = 40; | |
| pos = { x: rm.ax + rm.dx * d, y: rm.ea + (rm.eb - rm.ea) * (d / rm.L) + A.spawnAgl, z: rm.az + rm.dz * d }; | |
| speed = 0; onGround = true; | |
| } else { | |
| const [x, z] = frame.toLocal(p.lat, p.lon); | |
| pos = { x, y: opts.alt ?? p.alt, z }; speed = (opts.kt ?? Math.max(p.kt || 110, A.vr * 1.45)) * KT; | |
| if (ac === 'vesper') speed = Math.max(speed, 210 * KT); | |
| } | |
| P = createPlane(ac, { pos, hdg, speed, onGround }); manTrim = 0; | |
| P.ctl.flaps = 0; | |
| if (!onGround) { P.ctl.throttle = 0.7; P.surf.thr = 0.7; } | |
| if (A.retract && !onGround) { P.ctl.gear = false; P.surf.gear = 0; } | |
| if (landing) { const f1 = A.flaps[1] ?? 0; P.ctl.flaps = f1; P.surf.f = f1; P.ctl.throttle = P.surf.thr = 0.38; P.ctl.gear = true; P.surf.gear = 1; P.vel = vec.rot(P.q, { x: 0, y: -speed * 0.052, z: -speed }); } | |
| roll = onGround ? { x: pos.x, z: pos.z, max: 0, mask: terrain.masks.find((m) => m.a.id === p.id), done: false } : null; | |
| td = null; lastTouch = null; tut.step = opts.tutorial ? 0 : -1; tut.hdg0 = null; showTut(); | |
| if (!model || model.userData.id !== ac) { if (model) scene.remove(model); model = makeAircraft(ac, envRT && envRT.texture); model.userData.id = ac; scene.add(model); } | |
| holdUntilGround = true; holdT = 0; | |
| // clouds base: above the start's terrain | |
| U.uCloudAlt.value = W.overcast > 0.5 ? Math.max(W.cloudAlt, pos.y + (onGround ? 1100 : 600)) : Math.max(W.cloudAlt, pos.y + (onGround ? 1500 : 1300)); | |
| const wr = (W.wind[0] * Math.PI) / 180, wk = W.wind[1] * KT; | |
| wind.set(-Math.sin(wr) * wk, 0, Math.cos(wr) * wk); // wind blows FROM the given direction | |
| U.uWind.value.set(wind.x, wind.z); | |
| turb = W.turb; | |
| // route | |
| for (const g of gates) scene.remove(g.obj); | |
| gates = []; route0 = null; routeIdx = 0; routeT = 0; done = null; | |
| if (opts.route && p.gates) { // a hand-traced run: fixed gates, facing along the run | |
| route0 = opts.route; | |
| gates = p.gates.map(([la, lo, alt], i) => { const [gx, gz] = frame.toLocal(la, lo); const obj = makeGate(); scene.add(obj); return { x: gx, z: gz, agl: 0, y: alt, obj, i }; }); | |
| gates.forEach((g, i) => { const pr = i === 0 ? frame.toLocal(p.lat, p.lon) : [gates[i - 1].x, gates[i - 1].z]; g.nx = g.x - pr[0]; g.nz = g.z - pr[1]; const l = Math.hypot(g.nx, g.nz); g.nx /= l; g.nz /= l; g.obj.position.set(g.x, g.y, g.z); g.obj.rotation.y = Math.atan2(g.nx, g.nz); g.obj.visible = true; }); | |
| } else if (opts.route) { | |
| route0 = opts.route; | |
| gates = route(p, route0.seed).map(([la, lo, agl], i) => { const [gx, gz] = frame.toLocal(la, lo); const obj = makeGate(); obj.visible = false; scene.add(obj); return { x: gx, z: gz, agl, y: null, obj, i }; }); | |
| } | |
| flightT = 0; camInit = true; crashT = 0; | |
| $('rt').hidden = !route0; $('rt-vs').hidden = !(route0 && route0.vs); | |
| if (route0 && route0.vs) $('rt-vs').textContent = `${route0.by || 'FRIEND'} ${fmtTime(route0.vs)}`; | |
| showPlace(p); | |
| attribution(lat, lon); | |
| $('t-gear').hidden = !A.retract; | |
| sound.kind = A.engine; | |
| } | |
| function attribution(lat, lon) { | |
| const im = imagery && !imagery.off ? `Imagery: ${creditsAt(lat, lon).join(' · ')}` : 'Imagery offline: shaded from elevation'; | |
| $('attr').textContent = `${im} · Elevation: AWS Terrain Tiles${buildings ? ' · Buildings: OpenFreeMap © OpenMapTiles, © OpenStreetMap contributors' : ''}`; | |
| } | |
| function showPlace(p) { | |
| $('place-n').textContent = isAirport(p) ? `${p.name} · RWY ${p.rwy}${landing ? ' · LANDING CHALLENGE' : ''}` : p.name; | |
| $('place-r').textContent = isAirport(p) ? `${p.region} · ${p.id}` : p.region; | |
| const el = $('place'); el.hidden = false; el.style.opacity = 1; | |
| clearTimeout(showPlace.t); showPlace.t = setTimeout(() => { el.style.opacity = 0; }, 6000); | |
| } | |
| // ---------------------------------------------------------------- input | |
| const keys = new Set(); | |
| const inp = { pitch: 0, roll: 0, yaw: 0 }; | |
| const touchIn = { active: false, x: 0, y: 0, thr: null }; | |
| let gp = null, wind = new THREE.Vector3(), turb = 0; | |
| addEventListener('keydown', (e) => { | |
| if (e.target.tagName === 'INPUT') return; | |
| sound.start(); | |
| const k = e.key.toLowerCase(); | |
| if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' ', 'pageup', 'pagedown'].includes(k) && mode === 'fly') e.preventDefault(); | |
| if (keys.has(k)) return; | |
| keys.add(k); | |
| if (mode === 'fly' || mode === 'crash') { | |
| if (k === 'escape' || k === 'p') togglePause(); | |
| if (paused) return; | |
| if (k === 'f') { const fl = P.A.flaps, i = fl.findIndex((f) => f >= P.ctl.flaps - 1e-3); P.ctl.flaps = e.shiftKey ? fl[Math.max(0, i - 1)] : fl[Math.min(fl.length - 1, i + (fl[i] > P.ctl.flaps + 1e-3 ? 0 : 1))]; } | |
| if (k === 'g' && P.A.retract) P.ctl.gear = !P.ctl.gear; | |
| if (k === '[' || k === ']') { manTrim = Math.max(-0.5, Math.min(0.5, manTrim + (k === ']' ? 0.025 : -0.025))); toast(`Trim ${manTrim > 0 ? 'nose up' : manTrim < 0 ? 'nose down' : 'neutral'} ${Math.round(Math.abs(manTrim) * 200)}%${SET.assist ? ' (flight assist trims for you)' : ''}`, 900); } | |
| if (k === 'c') nextCam(); | |
| if (k === 'k') photo(); | |
| if (k === 'h') { SET.hud = !SET.hud; saveSet(); } | |
| if (k === 'n') { SET.map = SET.map === false; saveSet(); } | |
| if (k === 'm') toggleSound(); | |
| if (k === 'r' && (mode === 'crash' || e.shiftKey)) restart(); | |
| if (/^[0-9]$/.test(k)) P.ctl.throttle = k === '0' ? 1 : (+k) / 10; | |
| } else if (k === 'r' && !$('over').hidden) restart(); | |
| }); | |
| addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase())); | |
| addEventListener('blur', () => keys.clear()); | |
| const kd = (...ks) => ks.some((k) => keys.has(k)); | |
| function readInputs(dt) { | |
| // keyboard: ramps so a tap gives a small input and a hold gives a full one | |
| const tgt = (neg, pos) => (kd(...pos) ? 1 : 0) - (kd(...neg) ? 1 : 0); | |
| const tp = tgt(['arrowup', 'w'], ['arrowdown', 's']) * (SET.invert ? -1 : 1), tr = tgt(['arrowleft', 'a'], ['arrowright', 'd']), ty = tgt(['q', 'z'], ['e', 'x']); | |
| const ramp = (cur, t, up = 2.2, dn = 4) => cur + Math.max(-dt * (t ? up : dn), Math.min(dt * (t ? up : dn), t - cur)); | |
| inp.pitch = ramp(inp.pitch, tp * 0.75); inp.roll = ramp(inp.roll, tr, 3); inp.yaw = ramp(inp.yaw, ty, 3); | |
| let pitch = inp.pitch, roll = inp.roll, yaw = inp.yaw; | |
| if (kd('shift', 'pageup', '+', '=')) P.ctl.throttle = Math.min(1, P.ctl.throttle + dt * 0.6); | |
| if (kd('control', 'pagedown', '-', '_')) P.ctl.throttle = Math.max(0, P.ctl.throttle - dt * 0.6); | |
| P.ctl.brake = kd(' ', 'b') ? 1 : touchIn.brake ? 1 : 0; | |
| // gamepad | |
| gp = (navigator.getGamepads ? [...navigator.getGamepads()] : []).find((g) => g && g.connected); | |
| if (gp) { | |
| const dz = (v) => (Math.abs(v) < 0.08 ? 0 : v); | |
| const ax = gp.axes; | |
| if (Math.abs(dz(ax[0])) || Math.abs(dz(ax[1]))) { roll = dz(ax[0]); pitch = dz(ax[1]) * (SET.invert ? -1 : 1); } | |
| const b = (i) => gp.buttons[i] && (gp.buttons[i].value > 0.3 || gp.buttons[i].pressed); | |
| if (b(7)) P.ctl.throttle = Math.min(1, P.ctl.throttle + dt * 0.6); | |
| if (b(6)) P.ctl.throttle = Math.max(0, P.ctl.throttle - dt * 0.6); | |
| if (b(4)) yaw = -1; if (b(5)) yaw = 1; | |
| if (b(0)) P.ctl.brake = 1; | |
| gp.prev = gp.prev || []; | |
| const edge = (i) => b(i) && !readInputs.prev?.[i]; | |
| if (edge(1) && P.A.retract) P.ctl.gear = !P.ctl.gear; | |
| if (edge(2)) { const fl = P.A.flaps, i = fl.findIndex((f) => f >= P.ctl.flaps - 1e-3); P.ctl.flaps = fl[(i + 1) % fl.length]; } | |
| if (edge(3)) nextCam(); | |
| if (edge(9)) togglePause(); | |
| if (edge(12) || edge(13)) manTrim = Math.max(-0.5, Math.min(0.5, manTrim + (edge(13) ? -0.025 : 0.025))); | |
| readInputs.prev = gp.buttons.map((x) => x.pressed || x.value > 0.3); | |
| if (Math.abs(dz(ax[2] || 0)) || Math.abs(dz(ax[3] || 0))) { look.yaw -= dz(ax[2]) * dt * 2; look.pitch = Math.max(-1.2, Math.min(1.2, look.pitch - dz(ax[3]) * dt * 1.5)); look.t = 3; } | |
| } | |
| // mouse yoke (pause menu: Mouse flies): the pointer's offset from the centre of the view, with a small dead zone | |
| if (SET.mouse && mouseYoke.in && !touchDev) { const dz = (v) => Math.sign(v) * Math.max(0, Math.abs(v) - 0.06) / 0.94; roll = Math.max(-1, Math.min(1, dz(mouseYoke.x) * 1.6)); pitch = Math.max(-1, Math.min(1, dz(mouseYoke.y) * 1.6)) * (SET.invert ? -1 : 1); } | |
| // phone tilt | |
| if (tilt.on && !touchIn.active) { roll = tilt.roll; pitch = tilt.pitch * (SET.invert ? -1 : 1); } | |
| // touch yoke | |
| if (touchIn.active) { roll = touchIn.x; pitch = touchIn.y * (SET.invert ? -1 : 1); } | |
| return { pitch, roll, yaw }; | |
| } | |
| // touch: right side = yoke (appears under the thumb), left = throttle slider, buttons | |
| function setupTouch() { | |
| const yoke = $('yoke'), knob = $('yoke-k'); let id = null, ox = 0, oy = 0; | |
| const R = 70; | |
| yoke.addEventListener('pointerdown', (e) => { | |
| sound.start(); id = e.pointerId; yoke.setPointerCapture(id); const r = yoke.getBoundingClientRect(); ox = e.clientX; oy = e.clientY; | |
| knob.style.left = `${ox - r.left}px`; knob.style.top = `${oy - r.top}px`; yoke.classList.add('on'); touchIn.active = true; touchIn.x = 0; touchIn.y = 0; | |
| }); | |
| yoke.addEventListener('pointermove', (e) => { | |
| if (e.pointerId !== id) return; | |
| let dx = (e.clientX - ox) / R, dy = (e.clientY - oy) / R; const l = Math.hypot(dx, dy); if (l > 1) { dx /= l; dy /= l; } | |
| touchIn.x = Math.sign(dx) * Math.abs(dx) ** 1.4; touchIn.y = Math.sign(dy) * Math.abs(dy) ** 1.4 * 0.8; | |
| knob.style.setProperty('--kx', `${dx * 38}px`); knob.style.setProperty('--ky', `${dy * 38}px`); | |
| }); | |
| const up = (e) => { if (e.pointerId !== id) return; id = null; touchIn.active = false; yoke.classList.remove('on'); knob.style.setProperty('--kx', '0px'); knob.style.setProperty('--ky', '0px'); }; | |
| yoke.addEventListener('pointerup', up); yoke.addEventListener('pointercancel', up); | |
| const thr = $('thr'), tk = $('thr-k'); | |
| const setThr = (e) => { const r = thr.getBoundingClientRect(); const v = 1 - Math.max(0, Math.min(1, (e.clientY - r.top - 15) / (r.height - 30))); P.ctl.throttle = Math.round(v * 20) / 20; }; | |
| thr.addEventListener('pointerdown', (e) => { sound.start(); thr.setPointerCapture(e.pointerId); setThr(e); thr.onpointermove = setThr; }); | |
| thr.addEventListener('pointerup', () => { thr.onpointermove = null; }); | |
| setInterval(() => { if (P) { const r = thr.getBoundingClientRect(); tk.style.bottom = `${P.surf.thr * (r.height - 30)}px`; } }, 100); | |
| $('t-flaps').onclick = () => { const fl = P.A.flaps, i = fl.findIndex((f) => f >= P.ctl.flaps - 1e-3); P.ctl.flaps = fl[(i + 1) % fl.length]; $('t-flaps').setAttribute('aria-pressed', P.ctl.flaps > 0); }; | |
| $('t-gear').onclick = () => { P.ctl.gear = !P.ctl.gear; $('t-gear').setAttribute('aria-pressed', !P.ctl.gear); }; | |
| const br = $('t-brake'); | |
| br.addEventListener('pointerdown', () => { touchIn.brake = true; br.setAttribute('aria-pressed', 'true'); }); | |
| for (const ev of ['pointerup', 'pointercancel', 'pointerleave']) br.addEventListener(ev, () => { touchIn.brake = false; br.setAttribute('aria-pressed', 'false'); }); | |
| } | |
| // flight assist: coordinated rudder, and with the stick centred it holds the climb or descent angle you left it at | |
| const assist = { gT: null, trim: 0 }; | |
| let manTrim = 0; // pitch trim with flight assist off: [ nose down, ] nose up (gamepad: d-pad up/down) | |
| function assistControls(c, r) { | |
| if (!SET.assist || P.onGround || P.crashed) { assist.gT = null; P.ctl.pitch = c.pitch; P.ctl.roll = c.roll; P.ctl.yaw = c.yaw; P.ctl.trim = manTrim; return; } | |
| const V = Math.max(1, P.tas), gam = Math.asin(Math.max(-1, Math.min(1, P.vel.y / V))); | |
| const qrate = P.w.x; | |
| let pitch = c.pitch; | |
| if (Math.abs(c.pitch) < 0.04) { | |
| if (assist.gT === null) assist.gT = Math.max(-0.35, Math.min(0.35, gam)); | |
| const err = assist.gT - gam; | |
| assist.trim = Math.max(-0.6, Math.min(0.6, assist.trim + err * 0.9 * (1 / 120) * 4)); | |
| pitch = assist.trim + err * 2.4 - qrate * 0.9; | |
| } else { assist.gT = null; assist.trim = Math.max(-0.6, Math.min(0.6, assist.trim * 0.995)); pitch = c.pitch + assist.trim * 0.6; } | |
| // bank protection only past 67 degrees with the stick centred (keeps phones from spiralling) | |
| let roll = c.roll; | |
| if (Math.abs(c.roll) < 0.04) roll = Math.max(-0.4, Math.min(0.4, (-r.bank * (Math.abs(r.bank) > 30 ? 0.02 : 0.004)) - P.w.z * -0.3 * 0)); | |
| const yaw = Math.max(-1, Math.min(1, c.yaw + P.beta * 4 + c.roll * 0.12)); | |
| P.ctl.pitch = Math.max(-1, Math.min(1, pitch)); P.ctl.roll = roll; P.ctl.yaw = yaw; P.ctl.trim = 0; | |
| } | |
| // ---------------------------------------------------------------- cameras | |
| const CAMS = ['CHASE', 'COCKPIT', 'ORBIT', 'FLYBY']; | |
| const look = { yaw: 0, pitch: 0, t: 0, drag: null }; | |
| let camInit = true; | |
| const camPos = new THREE.Vector3(), camLook = new THREE.Vector3(), flyby = new THREE.Vector3(); | |
| function nextCam() { SET.cam = (SET.cam + 1) % CAMS.length; saveSet(); camInit = true; toast(`Camera: ${CAMS[SET.cam].toLowerCase()}`, 1200); } | |
| cv.addEventListener('pointerdown', (e) => { if (e.pointerType === 'mouse' && (!SET.mouse || e.button === 2)) { look.drag = { x: e.clientX, y: e.clientY }; cv.setPointerCapture(e.pointerId); } }); | |
| cv.addEventListener('pointermove', (e) => { if (look.drag) { look.yaw -= (e.clientX - look.drag.x) * 0.006; look.pitch = Math.max(-1.2, Math.min(1.2, look.pitch - (e.clientY - look.drag.y) * 0.005)); look.drag = { x: e.clientX, y: e.clientY }; look.t = 4; } }); | |
| cv.addEventListener('pointerup', () => { look.drag = null; }); | |
| cv.addEventListener('dblclick', () => { look.yaw = 0; look.pitch = 0; }); | |
| function updateCamera(dt) { | |
| const q = new THREE.Quaternion(P.q.x, P.q.y, P.q.z, P.q.w); | |
| const p = new THREE.Vector3(P.pos.x, P.pos.y, P.pos.z); | |
| const size = P.A.b; | |
| if (look.t > 0 && !look.drag) { look.t -= dt; if (look.t <= 0) { /* ease back */ } } else if (!look.drag && SET.cam !== 2) { look.yaw *= Math.pow(0.1, dt); look.pitch *= Math.pow(0.1, dt); } | |
| const mode = mode0(); | |
| if (mode === 'COCKPIT') { | |
| const e = P.A.eye; | |
| const eye = new THREE.Vector3(e[0], e[1], e[2]).applyQuaternion(q).add(p); | |
| camera.position.copy(eye); | |
| const lq = q.clone().multiply(new THREE.Quaternion().setFromEuler(new THREE.Euler(look.pitch, look.yaw, 0, 'YXZ'))); | |
| camera.quaternion.copy(lq); camera.fov = 68; | |
| model.visible = true; | |
| } else if (mode === 'FLYBY') { | |
| const fwd = new THREE.Vector3(P.vel.x, P.vel.y, P.vel.z); const sp = fwd.length(); fwd.normalize(); | |
| if (camInit || flyby.distanceTo(p) > Math.max(400, sp * 9)) { flyby.copy(p).addScaledVector(fwd, Math.max(160, sp * 5)).add(new THREE.Vector3(fwd.z * 30, 6 + size, -fwd.x * 30)); const g = ground(flyby.x, flyby.z).h; flyby.y = Math.max(flyby.y, g + 3); } | |
| camera.position.copy(flyby); camera.lookAt(p); camera.fov = Math.max(14, Math.min(55, 2800 / Math.max(30, flyby.distanceTo(p)))); | |
| } else { // chase + orbit | |
| const hd = Math.atan2(P.vel.x, -P.vel.z); | |
| const speed = Math.hypot(P.vel.x, P.vel.z); | |
| const yawBase = P.onGround || speed < 8 ? (() => { const f = new THREE.Vector3(0, 0, -1).applyQuaternion(q); return Math.atan2(f.x, -f.z); })() : hd; | |
| const dist = (mode === 'ORBIT' ? 2.6 : 1.55) * size + 6; | |
| const yaw = (mode === 'ORBIT' ? 0 : yawBase) + look.yaw + (mode === 'ORBIT' ? (camInit ? yawBase : 0) : 0); | |
| if (mode === 'ORBIT' && camInit) look.yaw = yawBase + Math.PI * 0.75; | |
| const pit = (P.onGround ? 0.24 : 0.17) + look.pitch; | |
| const want = new THREE.Vector3(Math.sin(yaw) * -dist * Math.cos(pit), dist * Math.sin(pit) + size * 0.12, Math.cos(yaw) * dist * Math.cos(pit)).add(p); | |
| if (mode === 'ORBIT') want.set(p.x + Math.sin(look.yaw) * dist * Math.cos(pit), p.y + dist * Math.sin(pit), p.z + Math.cos(look.yaw) * dist * Math.cos(pit)); | |
| const gh = ground(want.x, want.z).h; | |
| want.y = Math.max(want.y, Math.max(gh, 0) + 2.2); | |
| const k = camInit ? 1 : 1 - Math.pow(0.0008, dt); | |
| camPos.lerp(want, k); | |
| camPos.sub(p).setLength(dist).add(p); // keep the distance exact (no lag-stretch) | |
| camPos.y = Math.max(camPos.y, Math.max(gh, 0) + 2.2); | |
| camera.position.copy(camPos); | |
| camLook.copy(p).add(new THREE.Vector3(0, size * 0.08, 0)); | |
| camera.up.set(0, 1, 0); | |
| camera.lookAt(camLook); | |
| camera.fov = 60; | |
| } | |
| camInit = false; | |
| camera.updateProjectionMatrix(); | |
| if (mode0() === 'COCKPIT') { model.visible = true; } | |
| } | |
| const mode0 = () => (mode === 'menu' ? 'ORBIT' : CAMS[SET.cam] || 'CHASE'); | |
| // ---------------------------------------------------------------- ground + physics | |
| function ground(x, z) { return terrain ? terrain.height(x, z) : { h: -1e4, water: false, z: -1 }; } | |
| const env = { ground: (x, z) => { const g = ground(x, z); return g.z < 0 ? { h: -1e4, water: false } : g; }, wind: { x: 0, y: 0, z: 0 }, turb: 0, rand: Math.random }; | |
| let acc = 0, simRate = 1; // simRate > 1 only from the test hook (a CPU renderer would otherwise fly in slow motion) | |
| function physics(dt) { | |
| // the test hook's faster clock runs the controls as often as real time would (a laggy loop would fly worse) | |
| if (simRate > 1 && !holdUntilGround) { const r0 = simRate; simRate = 1; for (let i = 0; i < r0 && !P.crashed; i++) physics(dt); simRate = r0; return; } | |
| let ctl = readInputs(dt); | |
| const r = readout(P); | |
| if (AP.on) ctl = autopilot(r); | |
| if (holdUntilGround) { // wait for the ground under the start before physics runs (instant on air starts) | |
| if (!holdT) holdT = performance.now(); | |
| const g = ground(P.pos.x, P.pos.z), waited = performance.now() - holdT; | |
| const need = P.onGround ? 12 : 8; // air starts fly on the first (coarse) ground at once; detail streams in | |
| if (g.z >= need || (!P.onGround && waited > 2500 && g.z >= 0) || (P.onGround && waited > 9000 && g.z >= 10)) { | |
| holdUntilGround = false; | |
| if (!P.onGround && !landing && P.pos.y < g.h + 350) P.pos.y = g.h + 350; | |
| if (P.onGround) P.pos.y = Math.max(P.pos.y, g.h + P.A.spawnAgl); | |
| startedAt = performance.now(); | |
| } else return; | |
| } | |
| assistControls(ctl, r); | |
| env.agl = P.pos.y - Math.max(0, ground(P.pos.x, P.pos.z).h) - P.A.spawnAgl * 0.6; | |
| env.wind = { x: wind.x, y: 0, z: wind.z }; env.turb = turb * Math.max(0.2, 1 - Math.max(0, P.pos.y - Math.max(0, ground(P.pos.x, P.pos.z).h)) / 3000); | |
| acc += Math.min(dt, 0.1); | |
| const h = 1 / 120; | |
| while (acc >= h) { | |
| acc -= h; step(P, h, env); flightT += h; if (route0 && !done && routeIdx < gates.length) routeT += h; | |
| if (P.crashed) break; | |
| } | |
| if (P.crashed && mode === 'fly') crashed(); | |
| } | |
| function crashed() { | |
| mode = 'crash'; crashT = 0; | |
| sound.crash(); | |
| if (navigator.vibrate) try { navigator.vibrate([60, 40, 120]); } catch { /* */ } | |
| const m = $('msg'); m.hidden = false; m.innerHTML = `${P.crashed}<small>${touchDev ? 'Tap' : 'Press R or click'} to try again from the start</small>`; | |
| STATS.flown += Math.round(flightT); saveStats(); | |
| } | |
| // ---------------------------------------------------------------- take-off and landing scoring | |
| /** Where a world point sits relative to a runway: metres along from threshold a, metres off the centreline. */ | |
| function onRunway(m, x, z) { const px = x - m.ax, pz = z - m.az; return { along: px * m.dx + pz * m.dz, off: px * -m.dz + pz * m.dx }; } | |
| function runwayAt(x, z) { for (const m of terrain.masks) { if (Math.abs(m.ax - x) > m.L + 3000 || Math.abs(m.az - z) > m.L + 3000) continue; const o = onRunway(m, x, z); if (o.along > -60 && o.along < m.L + 60 && Math.abs(o.off) < m.a.w / 2 + 6) return { m, ...o }; } return null; } | |
| function scoring() { | |
| if (!P || P.crashed) return; | |
| const r = readout(P); | |
| // take-off: from brakes off on a runway to wheels up | |
| if (roll && !roll.done && roll.mask) { | |
| const o = onRunway(roll.mask, P.pos.x, P.pos.z); | |
| if (P.onGround) roll.max = Math.max(roll.max, Math.abs(o.off)); | |
| else if (P.pos.y - Math.max(0, ground(P.pos.x, P.pos.z).h) > 8) { | |
| roll.done = true; | |
| const d = Math.hypot(P.pos.x - roll.x, P.pos.z - roll.z); | |
| if (d > 30) toast(`Airborne after ${Math.round(d * 3.28084).toLocaleString('en-US')} ft of runway · centreline ±${roll.max.toFixed(1)} m${roll.max < 3 ? ' · nicely flown' : ''}`, 3200); | |
| } | |
| } | |
| // touchdown: score it on a runway once the aircraft stops, or as a touch-and-go when it lifts off again | |
| if (P.touch && P.touch !== lastTouch) { | |
| lastTouch = P.touch; | |
| const rw = runwayAt(P.pos.x, P.pos.z); | |
| if (rw && (!roll || roll.done)) td = { m: rw.m, vz: P.touch.vz, off: rw.off, along: rw.along, bank: r.bank, t: flightT }; | |
| else if (!rw && (!roll || roll.done) && !landing) toast(`Off-field landing at ${Math.round(Math.abs(P.touch.vz) * 196.85)} ft/min`, 2600); | |
| } | |
| if (td) { | |
| const gs = Math.hypot(P.vel.x, P.vel.z); | |
| const air = !P.onGround && P.pos.y - Math.max(0, ground(P.pos.x, P.pos.z).h) > 12; | |
| if ((P.onGround && gs < 2.5) || air || flightT - td.t > 60) { | |
| const o = onRunway(td.m, P.pos.x, P.pos.z); | |
| const stopped = !air && o.along > 0 && o.along < td.m.L && Math.abs(o.off) < td.m.a.w / 2; | |
| const sc = landingScore({ vz: td.vz, off: td.off, along: td.along, len: td.m.L, hw: td.m.a.w / 2, stopped, bank: td.bank }); | |
| const line = `${sc.fpm} ft/min · ${Math.abs(td.off).toFixed(1)} m ${td.off > 0 ? 'right' : 'left'} of centreline · ${Math.round(td.along * 3.28084).toLocaleString('en-US')} ft past the threshold${stopped ? ' · stopped on the runway' : air ? ' · touch and go' : ''}`; | |
| const id = td.m.a.id; td = null; | |
| if (landing && landing.a.id === id && !air) landingResult(sc, line); | |
| else toast(`Landing ${sc.score}/100 · ${sc.grade} · ${line}`, 5200); | |
| if (tut.step === 5) { tut.step = 6; showTut(); } | |
| } | |
| } | |
| // tutorial progress | |
| if (tut.step >= 0 && tut.step < 6) { | |
| const agl = P.pos.y - Math.max(0, ground(P.pos.x, P.pos.z).h); | |
| const adv = [() => P.ctl.throttle > 0.9, () => !P.onGround && agl > 15, () => agl > 300, () => { if (tut.hdg0 === null) tut.hdg0 = r.hdg; return Math.abs(((r.hdg - tut.hdg0 + 540) % 360) - 180) > 75; }, () => P.ctl.throttle < 0.66 && Math.abs(r.vs) < 2.5][tut.step]; | |
| if (adv && adv()) { tut.step++; showTut(); sound.chime(false); } | |
| } | |
| } | |
| function landingResult(sc, line) { | |
| const a = landing.a, key = `${a.id}`; | |
| const prev = BEST[key]; | |
| if (!prev || sc.score > prev) { BEST[key] = sc.score; store('fw-land', BEST); } | |
| const o = $('over'); o.hidden = false; mode = 'done'; | |
| const daily = landing.daily; | |
| $('o-kick').textContent = daily ? `DAILY LANDING #${dayN} · ${a.id} RWY ${a.rwy}` : `LANDING CHALLENGE · ${a.id} RWY ${a.rwy}`; | |
| $('o-res').textContent = `${sc.score}/100`; | |
| $('o-sub').textContent = `${sc.grade}. ${line}. ${prev ? (sc.score > prev ? 'New personal best.' : `Your best: ${prev}/100.`) : ''}`; | |
| $('o-vs').hidden = true; $('o-clip').hidden = !lastClip; | |
| landing.result = sc; | |
| const text = `Fellwing ${daily ? `Daily Landing #${dayN}` : 'landing challenge'}: ${a.name} runway ${a.rwy}, ${sc.score}/100 (${sc.grade}, ${sc.fpm} ft/min). Can you land it softer?`; | |
| const it = intents(GAME, text, PATH + `?land=${a.id}`); | |
| $('sx').href = it.x; $('sw').href = it.whatsapp; | |
| count(GAME, 'win', daily ? 'daily-landing' : 'landing'); | |
| pass.afterWin($('over-fp'), 'landing'); | |
| } | |
| const TUT = [ | |
| ['Full power', 'Hold Shift (or press 0) until the throttle reads 100%.', 'Slide the THR bar on the left all the way up.'], | |
| ['Take off', 'Let the speed build. At 55 kt pull back gently (Down arrow or S) and the Kestrel lifts off.', 'Let the speed build. At 55 kt drag your right thumb down a little to lift off.'], | |
| ['Climb', 'Keep a gentle climb to 1,000 ft above the ground. The tape on the right is your altitude.', 'Keep a gentle climb to 1,000 ft above the ground. The tape on the right is your altitude.'], | |
| ['Turn', 'Bank with the Left or Right arrow and turn about 90 degrees. Flight assist coordinates the rudder.', 'Drag your thumb left or right to bank, and turn about 90 degrees.'], | |
| ['Cruise', 'Throttle back to 60% (Ctrl or press 6) and level off.', 'Slide the throttle down to about 60% and level off.'], | |
| ['Land', 'Fly back and line up with the runway. Flaps (F), power back, a 3-degree descent, then raise the nose just before touchdown. You get a landing score.', 'Fly back and line up with the runway. FLAPS, power back, gentle descent, raise the nose just before touchdown. You get a landing score.'], | |
| ['Done', 'You can fly. Try a landing challenge or the Daily Route from the menu.', 'You can fly. Try a landing challenge or the Daily Route from the menu.'], | |
| ]; | |
| function showTut() { | |
| const el = $('tut'); if (!el) return; | |
| if (tut.step < 0 || mode === 'menu') { el.hidden = true; return; } | |
| const t = TUT[tut.step]; | |
| el.hidden = false; el.innerHTML = `<b>${tut.step + 1}/6 · ${t[0]}</b><span>${touchDev ? t[2] : t[1]}</span>`; | |
| if (tut.step === 6) setTimeout(() => { if (tut.step === 6) { tut.step = -1; el.hidden = true; } }, 7000); | |
| } | |
| // ---------------------------------------------------------------- minimap (heading-up, from the loaded elevation) | |
| const mini = { cv: null, x: null, t: 0, img: null }; | |
| function drawMini(dt) { | |
| if (!mini.cv) { mini.cv = $('mini'); if (!mini.cv) return; mini.x = mini.cv.getContext('2d'); } | |
| const on = SET.map !== false && mode !== 'menu' && SET.hud && P; | |
| mini.cv.hidden = !on; if (!on) return; | |
| mini.t -= dt; if (mini.t > 0) return; mini.t = 0.25; | |
| const c = mini.cv, x = mini.x, S = c.width, R = S / 2; | |
| const r = readout(P), agl = P.pos.y - Math.max(0, ground(P.pos.x, P.pos.z).h); | |
| const span = agl > 2500 ? 16000 : agl > 800 ? 9000 : 5000; // metres from centre to edge | |
| const hd = (r.hdg * Math.PI) / 180, ch = Math.cos(hd), sh = Math.sin(hd); | |
| const toMap = (wx, wz) => { const dx = wx - P.pos.x, dz = wz - P.pos.z; const rx = dx * ch + dz * sh, ry = -dx * sh + dz * ch; return [R + (rx / span) * R, R + (ry / span) * R]; }; | |
| // ground: a coarse grid of real heights, coloured by elevation and sea | |
| const N = 28; if (!mini.img) mini.img = x.createImageData(N, N); | |
| const d = mini.img.data; | |
| for (let j = 0; j < N; j++) for (let i = 0; i < N; i++) { | |
| const mx = ((i + 0.5) / N) * 2 - 1, my = ((j + 0.5) / N) * 2 - 1; | |
| const rx = mx * span, ry = my * span; | |
| const wx = P.pos.x + rx * ch - ry * sh, wz = P.pos.z + rx * sh + ry * ch; | |
| const g = ground(wx, wz); const k = (j * N + i) * 4; let col; | |
| if (g.z < 0) col = [30, 36, 46]; else if (g.h < 0.5) col = [28, 70, 112]; | |
| else { const t = Math.min(1, g.h / 3500); col = t < 0.3 ? [70 + t * 200, 110 + t * 120, 60 + t * 40] : t < 0.7 ? [140 + (t - 0.3) * 150, 130 + (t - 0.3) * 60, 90 + (t - 0.3) * 80] : [200 + (t - 0.7) * 160, 200 + (t - 0.7) * 160, 205 + (t - 0.7) * 160]; } | |
| d[k] = col[0]; d[k + 1] = col[1]; d[k + 2] = col[2]; d[k + 3] = 255; | |
| } | |
| x.save(); x.clearRect(0, 0, S, S); x.beginPath(); x.arc(R, R, R - 1, 0, Math.PI * 2); x.clip(); | |
| const tmp = mini.tmp || (mini.tmp = Object.assign(document.createElement('canvas'), { width: N, height: N })); | |
| tmp.getContext('2d').putImageData(mini.img, 0, 0); | |
| x.imageSmoothingEnabled = true; x.globalAlpha = 0.9; x.drawImage(tmp, 0, 0, S, S); x.globalAlpha = 1; | |
| // runways | |
| x.strokeStyle = '#f4f4ee'; x.lineCap = 'round'; | |
| for (const { r: m } of runways) { const [ax, ay] = toMap(m.ax, m.az), [bx, by] = toMap(m.bx, m.bz); x.lineWidth = Math.max(2, (m.a.w / span) * R * 1.5); x.beginPath(); x.moveTo(ax, ay); x.lineTo(bx, by); x.stroke(); } | |
| // gates | |
| gates.forEach((g, i) => { if (g.y === null) return; const [gx, gy] = toMap(g.x, g.z); x.fillStyle = i < routeIdx ? '#3fd47f' : i === routeIdx ? '#ff8a3d' : '#ffc067'; x.beginPath(); x.arc(gx, gy, i === routeIdx ? 5 : 3.5, 0, Math.PI * 2); x.fill(); }); | |
| // north marker | |
| const [nx, ny] = [R - sh * (R - 9), R - ch * (R - 9)]; | |
| x.fillStyle = '#fff'; x.font = '700 11px ui-monospace, Menlo, monospace'; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText('N', nx, ny); | |
| x.restore(); | |
| // own aircraft | |
| x.fillStyle = '#ff9b4a'; x.beginPath(); x.moveTo(R, R - 8); x.lineTo(R + 6, R + 6); x.lineTo(R, R + 3); x.lineTo(R - 6, R + 6); x.closePath(); x.fill(); | |
| x.strokeStyle = 'rgba(255,255,255,0.5)'; x.lineWidth = 1.5; x.beginPath(); x.arc(R, R, R - 1, 0, Math.PI * 2); x.stroke(); | |
| x.fillStyle = 'rgba(255,255,255,0.75)'; x.font = '600 10px ui-monospace, Menlo, monospace'; x.textAlign = 'center'; x.fillText(`${(span / 1852).toFixed(1)} NM`, R, S - 10); | |
| } | |
| // ---------------------------------------------------------------- phone tilt + mouse yoke | |
| const tilt = { on: false, b0: null, g0: null, roll: 0, pitch: 0 }; | |
| function onTilt(e) { | |
| if (e.beta === null) return; | |
| const ang = (screen.orientation && screen.orientation.angle) || window.orientation || 0; | |
| let rl, pt; | |
| if (ang === 90) { rl = e.beta; pt = e.gamma; } else if (ang === -90 || ang === 270) { rl = -e.beta; pt = -e.gamma; } else { rl = e.gamma; pt = e.beta; } | |
| if (tilt.b0 === null) { tilt.b0 = rl; tilt.g0 = pt; } | |
| tilt.roll = Math.max(-1, Math.min(1, (rl - tilt.b0) / 28)); | |
| tilt.pitch = Math.max(-1, Math.min(1, -(pt - tilt.g0) / 22)); | |
| } | |
| async function setTilt(on) { | |
| if (on && typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) { | |
| try { if ((await DeviceOrientationEvent.requestPermission()) !== 'granted') { toast('Motion access was not allowed.'); on = false; } } catch { on = false; } | |
| } | |
| tilt.on = on; tilt.b0 = null; | |
| if (on) { addEventListener('deviceorientation', onTilt); toast('Tilt to fly: hold the phone how you like it now; that is level.', 2600); } else removeEventListener('deviceorientation', onTilt); | |
| $('p-tilt').setAttribute('aria-pressed', String(on)); | |
| } | |
| const mouseYoke = { x: 0, y: 0, in: false }; | |
| cv.addEventListener('mousemove', (e) => { const r = cv.getBoundingClientRect(); mouseYoke.x = ((e.clientX - r.left) / r.width) * 2 - 1; mouseYoke.y = ((e.clientY - r.top) / r.height) * 2 - 1; mouseYoke.in = true; }); | |
| cv.addEventListener('mouseleave', () => { mouseYoke.in = false; }); | |
| cv.addEventListener('contextmenu', (e) => { if (SET.mouse) e.preventDefault(); }); | |
| // ---------------------------------------------------------------- route gates | |
| const prevPos = new THREE.Vector3(); | |
| function updateGates() { | |
| if (!route0) return; | |
| for (const g of gates) { | |
| if (g.y === null) { | |
| const gr = ground(g.x, g.z); | |
| if (gr.z >= 11 || (gr.z >= 9 && Math.hypot(g.x - P.pos.x, g.z - P.pos.z) > 6000)) { | |
| g.y = Math.max(Math.max(gr.h, 0) + g.agl, placeInfo.alt - 250); | |
| // face along the route | |
| const prev = g.i === 0 ? frame.toLocal(placeInfo.lat, placeInfo.lon) : [gates[g.i - 1].x, gates[g.i - 1].z]; | |
| g.nx = g.x - prev[0]; g.nz = g.z - prev[1]; const l = Math.hypot(g.nx, g.nz); g.nx /= l; g.nz /= l; | |
| g.obj.position.set(g.x, g.y, g.z); g.obj.rotation.y = Math.atan2(g.nx, g.nz); | |
| g.obj.visible = true; | |
| } | |
| } | |
| if (g.obj.visible) { | |
| const nxt = g.i === routeIdx; | |
| const col = g.i < routeIdx ? 0x3fd47f : nxt ? 0xff8a3d : 0xffc067; | |
| for (const m of [g.obj.userData.ring, g.obj.userData.glow, g.obj.userData.pole]) m.material.color.setHex(col); | |
| g.obj.userData.ring.material.opacity = g.i < routeIdx ? 0.35 : nxt ? 1 : 0.55; | |
| const s = 1 + (nxt ? Math.sin(performance.now() / 300) * 0.04 : 0); g.obj.scale.setScalar(s); | |
| // curvature: lower distant gates like the terrain shader does | |
| const d2 = (g.x - camera.position.x) ** 2 + (g.z - camera.position.z) ** 2; g.obj.position.y = g.y - d2 / (2 * 6378137); | |
| } | |
| } | |
| const g = gates[routeIdx]; | |
| if (g && g.y !== null && !P.crashed) { | |
| const rel0 = [prevPos.x - g.x, prevPos.y - g.y, prevPos.z - g.z], rel1 = [P.pos.x - g.x, P.pos.y - g.y, P.pos.z - g.z]; | |
| const s0 = rel0[0] * g.nx + rel0[2] * g.nz, s1 = rel1[0] * g.nx + rel1[2] * g.nz; | |
| if (s0 < 0 && s1 >= 0) { | |
| const t = s0 / (s0 - s1); const px = rel0[0] + (rel1[0] - rel0[0]) * t, py = rel0[1] + (rel1[1] - rel0[1]) * t, pz = rel0[2] + (rel1[2] - rel0[2]) * t; | |
| const lat = Math.hypot(px - g.nx * (px * g.nx + pz * g.nz), pz - g.nz * (px * g.nx + pz * g.nz)); | |
| if (Math.hypot(lat, py) < 46) { | |
| routeIdx++; sound.chime(routeIdx === gates.length); | |
| if (navigator.vibrate) try { navigator.vibrate(25); } catch { /* */ } | |
| if (routeIdx === gates.length) finishRoute(); | |
| } | |
| } | |
| } | |
| prevPos.set(P.pos.x, P.pos.y, P.pos.z); | |
| $('rt-g').textContent = `GATE ${Math.min(routeIdx + 1, gates.length)}/${gates.length}`; | |
| $('rt-t').textContent = fmtTime(routeT); | |
| } | |
| function finishRoute() { | |
| done = { t: routeT }; | |
| const daily = route0.daily; | |
| STATS.routes++; STATS.flown += Math.round(flightT); | |
| const key = `${placeInfo.id}.${route0.seed}`; | |
| const prevBest = STATS.best[key]; | |
| if (!prevBest || routeT < prevBest) STATS.best[key] = +routeT.toFixed(1); | |
| saveStats(); stats(); | |
| count(GAME, 'win', daily ? 'daily' : 'route'); | |
| setTimeout(() => showResult(prevBest), 900); | |
| } | |
| function showResult(prevBest) { | |
| const o = $('over'); o.hidden = false; mode = 'done'; | |
| $('o-kick').textContent = route0.daily ? `DAILY ROUTE #${route0.seed}` : route0.run ? placeInfo.name.toUpperCase() : 'ROUTE'; | |
| $('o-res').textContent = fmtTime(done.t); | |
| const r = readout(P); | |
| $('o-sub').textContent = `${placeInfo.name} · ${RINGS} gates · ${P.A.name}${prevBest && done.t >= prevBest ? ` · your best ${fmtTime(prevBest)}` : prevBest ? ' · new personal best' : ''}`; | |
| const vs = $('o-vs'); | |
| if (route0.vs) { vs.hidden = false; vs.textContent = done.t < route0.vs ? `You beat ${route0.by || 'your friend'} by ${(route0.vs - done.t).toFixed(1)} s` : `${route0.by || 'Your friend'} is still ${(done.t - route0.vs).toFixed(1)} s faster. Again?`; } else vs.hidden = true; | |
| $('callsign').value = store('fw-callsign') || ''; | |
| const text = shareText(); | |
| const it = intents(GAME, text, PATH + `?c=${chalCode()}`); | |
| $('sx').href = it.x; $('sw').href = it.whatsapp; | |
| pass.afterWin($('over-fp'), 'route'); | |
| $('o-clip').hidden = !lastClip; | |
| void r; | |
| } | |
| const chalCode = () => encodeChallenge({ spot: placeInfo.id, seed: route0.seed, t: done ? done.t : 0, by: ($('callsign').value || store('fw-callsign') || '').trim() }); | |
| const shareText = () => `Fellwing ${route0.daily ? `Daily Route #${route0.seed}` : route0.run ? 'challenge' : 'route'}: ${placeInfo.name}, ${RINGS}/${RINGS} gates in ${fmtTime(done.t)}. Can you fly it faster?`; | |
| // ---------------------------------------------------------------- photo + clip | |
| async function photoBlob() { | |
| renderer.render(scene, camera); | |
| const W = 1200, H = 630, c = document.createElement('canvas'); c.width = W; c.height = H; const x = c.getContext('2d'); | |
| const src = renderer.domElement, ar = src.width / src.height; | |
| const sw = ar > W / H ? src.height * (W / H) : src.width, sh = ar > W / H ? src.height : src.width / (W / H); | |
| x.drawImage(src, (src.width - sw) / 2, (src.height - sh) / 2, sw, sh, 0, 0, W, H); | |
| const g = x.createLinearGradient(0, H - 180, 0, H); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.72)'); x.fillStyle = g; x.fillRect(0, H - 180, W, 180); | |
| const r = readout(P); | |
| x.fillStyle = '#fff'; x.font = '800 54px FW, "Arial Narrow", sans-serif'; x.textBaseline = 'alphabetic'; | |
| x.fillText(placeInfo.name.toUpperCase(), 40, H - 70); | |
| x.font = '600 24px ui-monospace, Menlo, monospace'; x.fillStyle = '#E8ECF2'; | |
| x.fillText(`${P.A.name.toUpperCase()} · ${Math.round(r.alt * 3.28084).toLocaleString('en-US')} FT · ${Math.round(r.ias)} KT · HDG ${String(Math.round(r.hdg) % 360).padStart(3, '0')}`, 40, H - 32); | |
| x.font = '800 italic 34px FW, "Arial Narrow", sans-serif'; x.textAlign = 'right'; x.fillStyle = '#fff'; x.fillText('FELL', W - 112, H - 34); x.fillStyle = '#FF9B4A'; x.fillText('WING', W - 40, H - 34); | |
| return new Promise((res) => c.toBlob(res, 'image/jpeg', 0.9)); | |
| } | |
| function spotLink() { | |
| const [lat, lon] = frame.toLatLon(P.pos.x, P.pos.z), r = readout(P); | |
| return `${PATH}?at=${lat.toFixed(4)},${lon.toFixed(4)},${Math.round(Math.max(r.alt, 300))},${Math.round(r.hdg)}`; | |
| } | |
| async function photo() { | |
| if (!P) return; | |
| const blob = await photoBlob(); | |
| const r = readout(P); | |
| const text = `Flying the ${P.A.name} over ${placeInfo.name} at ${Math.round(r.alt * 3.28084).toLocaleString('en-US')} ft in Fellwing. Fly the same spot:`; | |
| count(GAME, 'share', 'photo'); | |
| if (navigator.share && navigator.canShare && touchDev) await share(GAME, text, spotLink(), blob, 'fellwing.jpg'); | |
| else { download(blob, `fellwing-${placeInfo.id}.jpg`); toast('Photo saved. Share it with the link to fly the same spot.'); } | |
| } | |
| let rec = null, lastClip = null; | |
| function clip() { | |
| if (rec) { rec.stop(); return; } | |
| if (!cv.captureStream || !window.MediaRecorder) { toast('This browser cannot record clips. Try Chrome or Edge.'); return; } | |
| const type = ['video/mp4;codecs=avc1', 'video/webm;codecs=vp9', 'video/webm'].find((t) => MediaRecorder.isTypeSupported(t)) || ''; | |
| const chunks = []; | |
| rec = new MediaRecorder(cv.captureStream(30), type ? { mimeType: type, videoBitsPerSecond: 6e6 } : undefined); | |
| rec.ondataavailable = (e) => e.data.size && chunks.push(e.data); | |
| rec.onstop = () => { | |
| const b = new Blob(chunks, { type: rec.mimeType || 'video/webm' }); rec = null; $('rec').hidden = true; | |
| lastClip = b; count(GAME, 'share', 'clip'); | |
| download(b, `fellwing-${placeInfo.id}.${b.type.includes('mp4') ? 'mp4' : 'webm'}`); toast('Clip saved (10 seconds).'); | |
| $('o-clip').hidden = false; | |
| }; | |
| rec.start(); $('rec').hidden = false; toast('Recording 10 seconds…', 1500); | |
| setTimeout(() => rec && rec.state === 'recording' && rec.stop(), 10000); | |
| } | |
| // ---------------------------------------------------------------- menu + flow | |
| function chip(host, items, cur, kind, on) { | |
| host.innerHTML = ''; | |
| for (const [id, it] of Object.entries(items)) { | |
| const b = document.createElement('button'); b.className = 'chip'; b.type = 'button'; b.dataset.id = id; | |
| b.setAttribute('aria-pressed', String(id === cur)); | |
| b.innerHTML = `${it.name}${it.free ? '' : '<span class="lk">PASS</span>'}`; | |
| if (it.kind) b.title = it.kind; | |
| b.onclick = () => { if (!own(kind, id)) { pass.open(`${kind}-${id}`); return; } on(id); host.querySelectorAll('.chip').forEach((x) => x.setAttribute('aria-pressed', String(x.dataset.id === id))); }; | |
| host.appendChild(b); | |
| } | |
| } | |
| function buildMenu() { | |
| chip($('m-ac'), AIRCRAFT, SET.ac, 'ac', (id) => { SET.ac = id; saveSet(); attract(); }); | |
| chip($('m-time'), TIMES, SET.time, 'time', (id) => { SET.time = id; saveSet(); applyLook(); }); | |
| chip($('m-wx'), WEATHER, SET.wx, 'wx', (id) => { SET.wx = id; saveSet(); applyLook(); const W = WEATHER[id]; U.uCover.value = W.clouds; }); | |
| const ds = dailySpot(dayN); | |
| const best = STATS.best[`${ds.id}.${dayN}`]; | |
| $('m-daily').innerHTML = `<b>DAILY ROUTE #${dayN} · ${ds.name.toUpperCase()}</b><span>${ds.region}. ${RINGS} gates, same route for everyone today. ${best ? `Your best: ${fmtTime(best)}.` : 'Fly it, then send your time to a friend.'}</span>`; | |
| if (dayN !== today) $('m-daily').innerHTML += `<span> Past route (archive).</span>`; | |
| if (challenge) { | |
| const s = spotById(challenge.spot); | |
| const el = $('m-chal'); el.hidden = false; | |
| el.innerHTML = `<b>${(challenge.by || 'A FRIEND').toUpperCase()} FLEW ${s.name.toUpperCase()} IN ${fmtTime(challenge.t)}</b><span>Same ${RINGS} gates, same start. Beat it.</span>`; | |
| $('m-fly').textContent = 'TAKE THE CHALLENGE'; | |
| } | |
| $('m-foot').textContent = touchDev ? 'Right thumb flies (or tilt the phone: pause menu), left slider is the throttle. Turn your phone sideways for the best view.' : 'Arrows or WASD fly, Shift and Ctrl set the throttle, F flaps, C camera, N map. Mouse flying, gamepads and phone tilt work too.'; | |
| } | |
| function flyRoute() { | |
| sound.start(); | |
| if (challenge) { const s = spotById(challenge.spot); go(s, { route: { seed: challenge.seed, vs: challenge.t, by: challenge.by, run: !!s.gates, daily: !s.gates && challenge.seed === dayN && dailySpot(dayN).id === s.id } }); return; } | |
| if (dayN !== today && !pass.isPro()) { pass.open('archive'); return; } | |
| go(dailySpot(dayN), { route: { seed: dayN, daily: true } }); | |
| } | |
| function go(p, opts = {}) { | |
| if (opts.landing) opts = { ...opts, landing: true }; | |
| $('menu').hidden = true; $('picker').hidden = true; $('over').hidden = true; $('msg').hidden = true; | |
| start(p, opts); | |
| if (landing) landing.daily = !!opts.daily; | |
| mode = 'fly'; paused = false; | |
| document.body.classList.add('playing'); | |
| $('tbar').hidden = false; $('touch').hidden = !touchDev; | |
| lastGo = { p, opts }; | |
| if (!store('fw-day') || store('fw-day') !== today) { store('fw-day', today); count(GAME, 'play', opts.route ? 'route' : 'free'); } | |
| cv.focus(); | |
| } | |
| let lastGo = null; | |
| function restart() { if (lastGo) go(lastGo.p, lastGo.opts); } | |
| function togglePause() { | |
| if (mode !== 'fly' && mode !== 'crash') return; | |
| paused = !paused; $('pause').hidden = !paused; | |
| $('p-assist').setAttribute('aria-pressed', SET.assist); $('p-invert').setAttribute('aria-pressed', SET.invert); $('p-hud').setAttribute('aria-pressed', SET.hud); | |
| $('p-tilt').hidden = !touchDev; $('p-mouse').hidden = touchDev; $('p-tilt').setAttribute('aria-pressed', String(tilt.on)); $('p-mouse').setAttribute('aria-pressed', String(!!SET.mouse)); $('p-map').setAttribute('aria-pressed', String(SET.map !== false)); | |
| } | |
| function toMenu() { | |
| mode = 'menu'; paused = false; | |
| for (const id of ['pause', 'over', 'picker', 'msg', 'tbar', 'touch', 'rt', 'tut']) $(id).hidden = true; | |
| tut.step = -1; landing = null; | |
| document.body.classList.remove('playing'); | |
| $('menu').hidden = false; buildMenu(); attract(); | |
| } | |
| function attract() { // the menu background: the daily spot, flown hands-off | |
| const s = challenge ? spotById(challenge.spot) : dailySpot(dayN); | |
| start(s, {}); mode = 'menu'; $('place').hidden = true; $('rt').hidden = true; | |
| } | |
| // picker | |
| function picker(tab = 'scenic') { | |
| $('picker').hidden = false; $('menu').hidden = true; | |
| $('pk-tabs').querySelectorAll('.chip').forEach((c) => c.setAttribute('aria-pressed', String(c.dataset.tab === tab))); | |
| const L = $('pk-list'); L.innerHTML = ''; | |
| const item = (title, sub, fn, lock = false) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'pk-i'; b.innerHTML = `<b>${title}${lock ? '<span class="lk">PASS</span>' : ''}</b><span>${sub}</span>`; b.onclick = fn; L.appendChild(b); }; | |
| if (tab === 'scenic') for (const s of SPOTS) item(s.name, `${s.region} · air start ${Math.round(s.alt * 3.28084).toLocaleString('en-US')} ft`, () => go(s)); | |
| if (tab === 'airports') for (const a of AIRPORTS) item(`${a.name}`, `${a.region} · ${a.id} runway ${a.rwy} · ${Math.round(distM(a) * 3.28084).toLocaleString('en-US')} ft`, () => go(a)); | |
| if (tab === 'challenges') { | |
| const dl = dailyLanding(dayN); | |
| const sub = (id) => (BEST[id] ? ` · your best ${BEST[id]}/100` : ''); | |
| item(`Daily Landing #${dayN}: ${dl.name} ${dl.rwy}`, `Same approach for everyone today. 7 km final, 3-degree path, scored out of 100${sub(dl.id)}`, () => go(dl, { landing: true, daily: true })); | |
| for (const r of RUNS) { const b = STATS.best[`${r.id}.0`]; item(r.name, `${r.region} · 7 gates against the clock${b ? ` · your best ${fmtTime(b)}` : ''}`, () => go(r, { route: { seed: 0, run: true } })); } | |
| for (const id of LANDINGS) { const a = spotById(id); item(`Land at ${a.name}`, `${a.id} runway ${a.rwy} · ${a.region} · scored landing${sub(a.id)}`, () => go(a, { landing: true })); } | |
| item('Learn to fly', 'Six short steps from brakes off to your first landing, at San Francisco.', () => go(spotById('KSFO'), { tutorial: true })); | |
| } | |
| if (tab === 'anywhere') { | |
| const d = document.createElement('div'); d.className = 'pk-any'; | |
| d.innerHTML = `<p class="hint">Fly over any place on Earth: type latitude, longitude (and optional altitude in feet), e.g. <b>36.10, -112.11, 9000</b>.</p><input id="pk-ll" inputmode="decimal" placeholder="lat, lon, altitude ft" autocomplete="off"><div class="mrow"><button class="btn fly sm" id="pk-go" type="button">FLY THERE</button><button class="mbtn sm" id="pk-me" type="button">NEAR ME</button></div>`; | |
| L.appendChild(d); | |
| $('pk-go').onclick = () => { const v = $('pk-ll').value.split(/[ ,;]+/).map(Number).filter((x) => !Number.isNaN(x)); if (v.length < 2 || Math.abs(v[0]) > 84 || Math.abs(v[1]) > 180) { toast('Type a latitude and longitude, e.g. 46.0, 7.7'); return; } anywhere(v[0], v[1], v[2] ? v[2] / 3.28084 : null); }; | |
| $('pk-me').onclick = () => { if (!navigator.geolocation) return toast('Location is not available here.'); navigator.geolocation.getCurrentPosition((p) => anywhere(p.coords.latitude, p.coords.longitude, null), () => toast('Location was not shared. Type coordinates instead.'), { timeout: 8000, maximumAge: 6e5 }); }; | |
| } | |
| if (tab === 'saved') { | |
| if (!pass.isPro()) { item('Saved spots', 'Save any spot from the pause menu and fly it again later. Included with the Fun Pass.', () => pass.open('saved'), true); return; } | |
| const sv = store('fw-saved') || []; | |
| if (!sv.length) item('No saved spots yet', 'Pause in flight and press SAVE THIS SPOT.', () => {}); | |
| for (const s of sv) item(s.name, `${s.lat.toFixed(3)}, ${s.lon.toFixed(3)} · ${Math.round(s.alt * 3.28084).toLocaleString('en-US')} ft`, () => anywhere(s.lat, s.lon, s.alt, s.hdg, s.name)); | |
| } | |
| } | |
| const distM = (a) => Math.round(haversine(a.a[0], a.a[1], a.b[0], a.b[1])); | |
| function haversine(a, b, c, d) { const R = 6371000, r = Math.PI / 180; const x = Math.sin(((c - a) * r) / 2) ** 2 + Math.cos(a * r) * Math.cos(c * r) * Math.sin(((d - b) * r) / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(x)); } | |
| function anywhere(lat, lon, alt, hdg = 0, name = null) { | |
| const s = { id: 'custom', name: name || `${lat.toFixed(3)}, ${lon.toFixed(3)}`, region: 'Custom start', lat, lon, alt: alt || 1500, to: [lat + 0.1, lon], kt: 110, custom: true, autoAlt: !alt }; | |
| go(s, { hdg }); | |
| } | |
| $('pk-tabs').addEventListener('click', (e) => { const t = e.target.closest('[data-tab]'); if (t) picker(t.dataset.tab); }); | |
| $('pk-x').onclick = () => { $('picker').hidden = true; $('menu').hidden = false; }; | |
| // buttons | |
| $('m-fly').onclick = flyRoute; | |
| $('m-free').onclick = () => picker('scenic'); | |
| $('p-resume').onclick = togglePause; | |
| $('p-restart').onclick = () => { togglePause(); restart(); }; | |
| $('p-menu').onclick = toMenu; | |
| $('p-save').onclick = () => { | |
| if (!pass.isPro()) { pass.open('save-spot'); return; } | |
| const [lat, lon] = frame.toLatLon(P.pos.x, P.pos.z), r = readout(P); | |
| const sv = store('fw-saved') || []; sv.unshift({ name: `${placeInfo.name} ${new Date().toLocaleDateString()}`, lat, lon, alt: Math.max(r.alt, 300), hdg: r.hdg }); | |
| store('fw-saved', sv.slice(0, 24)); toast('Spot saved. Find it under Free flight › Saved.'); | |
| }; | |
| $('p-share').onclick = async () => { const r = readout(P); await share(GAME, `Fly with me over ${placeInfo.name} in Fellwing (${Math.round(r.alt * 3.28084).toLocaleString('en-US')} ft):`, spotLink()); }; | |
| $('p-assist').onclick = () => { SET.assist = !SET.assist; saveSet(); $('p-assist').setAttribute('aria-pressed', SET.assist); }; | |
| $('p-invert').onclick = () => { SET.invert = !SET.invert; saveSet(); $('p-invert').setAttribute('aria-pressed', SET.invert); }; | |
| $('p-tilt').onclick = () => setTilt(!tilt.on); | |
| $('p-mouse').onclick = () => { SET.mouse = !SET.mouse; saveSet(); $('p-mouse').setAttribute('aria-pressed', String(!!SET.mouse)); toast(SET.mouse ? 'Mouse flies: move the pointer from the centre to bank and pitch. Right-drag looks around.' : 'Mouse looks around again.', 2600); }; | |
| $('p-map').onclick = () => { SET.map = SET.map === false; saveSet(); $('p-map').setAttribute('aria-pressed', String(SET.map !== false)); }; | |
| $('m-learn').onclick = () => { sound.start(); go(spotById('KSFO'), { tutorial: true }); }; | |
| $('m-chal2').onclick = () => picker('challenges'); | |
| $('p-hud').onclick = () => { SET.hud = !SET.hud; saveSet(); $('p-hud').setAttribute('aria-pressed', SET.hud); }; | |
| $('b-pause').onclick = togglePause; $('b-cam').onclick = nextCam; $('b-photo').onclick = photo; $('b-clip').onclick = clip; | |
| $('retry').onclick = restart; | |
| $('o-free').onclick = () => { $('over').hidden = true; mode = 'fly'; route0 = null; landing = null; for (const g of gates) g.obj.visible = false; gates = []; $('rt').hidden = true; }; | |
| $('o-menu').onclick = toMenu; | |
| $('o-photo').onclick = async () => download(await photoBlob(), `fellwing-${placeInfo.id}.jpg`); | |
| $('o-clip').onclick = () => lastClip && download(lastClip, `fellwing-${placeInfo.id}.${lastClip.type.includes('mp4') ? 'mp4' : 'webm'}`); | |
| $('callsign').oninput = () => { if (!route0) return; store('fw-callsign', $('callsign').value.trim()); const it = intents(GAME, shareText(), PATH + `?c=${chalCode()}`); $('sx').href = it.x; $('sw').href = it.whatsapp; }; | |
| $('share').onclick = () => (landing && landing.result ? share(GAME, `Fellwing landing challenge: ${landing.a.name} runway ${landing.a.rwy}, ${landing.result.score}/100. Can you land it softer?`, PATH + `?land=${landing.a.id}`) : share(GAME, shareText(), PATH + `?c=${chalCode()}`)); | |
| $('beat').onclick = async () => { | |
| if (landing && landing.result) { const link = `https://cybermaxtools.com${PATH}?land=${landing.a.id}`; try { await navigator.clipboard.writeText(`I landed ${landing.a.name} runway ${landing.a.rwy} at ${landing.result.score}/100 in Fellwing. Beat it: ${link}`); toast('Challenge link copied. Send it to a friend.'); } catch { prompt('Copy your challenge link:', link); } count(GAME, 'share', 'challenge'); return; } const link = `https://cybermaxtools.com${PATH}?c=${chalCode()}`; try { await navigator.clipboard.writeText(`Beat my ${fmtTime(done.t)} on the ${placeInfo.name} route in Fellwing: ${link}`); toast('Challenge link copied. Send it to a friend.'); } catch { prompt('Copy your challenge link:', link); } count(GAME, 'share', 'challenge'); }; | |
| function toggleSound() { SET.sound = !SET.sound; saveSet(); sound.setOn(SET.sound); $('mute').textContent = SET.sound ? 'SOUND' : 'MUTED'; } | |
| $('mute').onclick = () => { sound.start(); toggleSound(); }; | |
| $('mute').textContent = SET.sound ? 'SOUND' : 'MUTED'; | |
| stage.addEventListener('pointerdown', (e) => { sound.start(); if (mode === 'crash' && !e.target.closest('button')) restart(); }); | |
| function stats() { $('st-r').textContent = STATS.routes; const b = STATS.best[`${dailySpot(today).id}.${today}`]; $('st-b').textContent = b ? fmtTime(b) : '-'; $('st-t').textContent = `${Math.round(STATS.flown / 60)} min`; } | |
| // ---------------------------------------------------------------- resize + loop | |
| function resize() { | |
| const w = cv.clientWidth, h = cv.clientHeight; | |
| renderer.setPixelRatio(pr); renderer.setSize(w, h, false); | |
| camera.aspect = w / h; camera.updateProjectionMatrix(); | |
| hud.size(w, h, devicePixelRatio || 1); | |
| } | |
| addEventListener('resize', resize); | |
| let last = performance.now(), fpsT = 0, fpsN = 0, slow = 0; | |
| const sp = new THREE.Vector3(); | |
| function loop(now) { | |
| requestAnimationFrame(loop); | |
| if (CAP) return; // frame-stepped capture: __fwCap.advance() drives tick() | |
| const dt = Math.min(0.1, (now - last) / 1000); last = now; | |
| tick(dt, now); | |
| } | |
| function tick(dt, now) { | |
| U.uTime.value = now / 1000; | |
| if (!P) return; | |
| if (!paused) { | |
| if (mode === 'fly') physics(dt); | |
| else if (mode === 'menu') { // attract: hands-off flight with a gentle turn | |
| P.ctl.throttle = 0.68; | |
| const r = readout(P); | |
| assistControls({ pitch: 0, roll: Math.max(-0.3, Math.min(0.3, (12 - r.bank) * 0.02)), yaw: 0 }, r); | |
| if (!holdUntilGround) { acc += dt; while (acc >= 1 / 120) { acc -= 1 / 120; step(P, 1 / 120, env); } } else physics(0); | |
| if (P.crashed || (P.pos.y - ground(P.pos.x, P.pos.z).h) < 120) attract(); | |
| look.yaw += dt * 0.12; | |
| } else if (mode === 'crash') { crashT += dt; } | |
| } | |
| if (mode !== 'menu' && SET.cam === 2 && !look.drag && look.t <= 0) look.yaw += dt * 0.08; | |
| // model | |
| model.position.set(P.pos.x, P.pos.y, P.pos.z); | |
| model.quaternion.set(P.q.x, P.q.y, P.q.z, P.q.w); | |
| animateAircraft(model, P, now / 1000); | |
| updateCamera(dt); | |
| if (model.userData.prop) model.userData.prop.hub.visible = mode0() !== 'COCKPIT'; | |
| // sky + sea follow the camera | |
| sky.position.copy(camera.position); sky.scale.setScalar(Math.max(1000, camera.far * 0.8) / 1000); | |
| sea.position.set(camera.position.x, 0, camera.position.z); | |
| clouds.position.set(camera.position.x, 0, camera.position.z); | |
| U.uCam.value.copy(camera.position); | |
| sunLight.position.copy(P.pos).addScaledVector(U.uSun.value, 1000); sunLight.target.position.set(P.pos.x, P.pos.y, P.pos.z); | |
| // shadow blob under the aircraft | |
| const g = ground(P.pos.x, P.pos.z), gh = Math.max(g.h, 0), agl = P.pos.y - gh; | |
| shadow.visible = agl < 400 && g.z >= 0; | |
| if (shadow.visible) { const s = P.A.b * (1 + agl / 200); shadow.scale.set(s, 1, s); shadow.position.set(P.pos.x + U.uSun.value.x * -agl * 0.3, gh + 0.25, P.pos.z + U.uSun.value.z * -agl * 0.3); shadow.material.opacity = Math.max(0, 1 - agl / 400) * (1 - U.uOvercast.value * 0.6); } | |
| terrain.update(camera.position.x, camera.position.y, camera.position.z); | |
| if (buildings) buildings.update(camera.position.x, camera.position.y, camera.position.z); | |
| if (terrain.offline && !tick.off) { tick.off = true; toast('Offline: the map tiles cannot be reached, so you are flying over open sea. Runways still work.', 5000); $('attr').textContent = 'Offline: no elevation or imagery tiles · runways from OurAirports'; } | |
| if (mode === 'fly' && !paused) { updateGates(); scoring(); } | |
| drawMini(dt); | |
| sound.update(P, paused || mode === 'menu' || mode === 'done'); | |
| renderer.render(scene, camera); | |
| // HUD | |
| const r = readout(P); | |
| let gate = null; | |
| if (route0 && gates[routeIdx] && gates[routeIdx].y !== null && mode === 'fly') { | |
| const gg = gates[routeIdx]; sp.set(gg.x, gg.obj.position.y, gg.z); | |
| const dist = sp.distanceTo(camera.position); sp.project(camera); | |
| gate = { x: (sp.x * 0.5 + 0.5) * cv.clientWidth, y: (-sp.y * 0.5 + 0.5) * cv.clientHeight, on: sp.z < 1, dist, label: `GATE ${routeIdx + 1}` }; | |
| } | |
| hud.draw(r, P, { show: SET.hud && mode !== 'menu', cockpit: mode0() === 'COCKPIT', agl: g.z >= 0 ? agl : null, gate, touch: touchDev }); | |
| if (holdUntilGround && mode === 'fly') { const m = $('msg'); m.hidden = false; m.innerHTML = '<small>Loading terrain…</small>'; } else if (mode === 'fly' && $('msg').innerHTML.includes('Loading')) $('msg').hidden = true; | |
| // adaptive resolution | |
| fpsT += dt; fpsN++; | |
| if (fpsT > 2) { const fps = fpsN / fpsT; fpsT = 0; fpsN = 0; if (fps < 32 && pr > 0.75) { slow++; if (slow >= 2) { pr = Math.max(0.75, pr * 0.85); resize(); slow = 0; } } else if (fps < 26 && !CAP && !TEST) { slow++; if (slow >= 3) { slow = 0; terrain.split = Math.max(1.0, terrain.split * 0.88); if (buildings) { buildings.radius = Math.max(1800, buildings.radius * 0.8); buildings.minH = Math.min(12, buildings.minH + 3); } } } else slow = 0; } | |
| window.__fw = { mode, r, stats: terrain.stats(), bld: buildings && buildings.stats(), crashed: P.crashed, routeIdx, gates: gates.length, hold: holdUntilGround, started: startedAt, thr: P.surf.thr, brake: P.ctl.brake, onGround: P.onGround, contact: P.contact, pos: P.pos, flaps: P.surf.f, pr }; | |
| } | |
| // ---------------------------------------------------------------- autopilot (attract mode, trailer capture, tests) | |
| const AP = { on: false, hdg: null, alt: null, bank: 0, gate: false }; | |
| function autopilot(r) { | |
| if (P.onGround) { // take-off roll: full power, rotate at Vr | |
| P.ctl.throttle = 1; P.ctl.flaps = P.A.flaps[1] || 0; | |
| return { pitch: r.ias > P.A.vr ? Math.min(0.55, (9 - r.pitch) * 0.06) : 0, roll: -r.bank * 0.02, yaw: 0 }; | |
| } | |
| let hdgT = AP.hdg, altT = AP.alt; | |
| if (altT === null && AP.climb) altT = Math.max(0, ground(P.pos.x, P.pos.z).h) + 600; // climb out to 2,000 ft above the ground | |
| if (AP.gate && gates[routeIdx] && gates[routeIdx].y !== null) { const g = gates[routeIdx]; hdgT = (Math.atan2(g.x - P.pos.x, -(g.z - P.pos.z)) * 180) / Math.PI; altT = g.y; } | |
| let bankT = AP.bank; | |
| if (hdgT !== null) { const e = ((hdgT - r.hdg + 540) % 360) - 180; bankT = Math.max(-35, Math.min(35, e * 1.4)); } | |
| if (P.pos.y - P.A.spawnAgl > 250 + Math.max(0, ground(P.pos.x, P.pos.z).h) && AP.climb) P.ctl.flaps = 0; // flaps up once safely clear | |
| let gam = altT === null ? 0 : Math.max(-0.12, Math.min(AP.climb ? 0.075 : 0.15, (altT - r.alt) * 0.0012)); | |
| if (r.ias < P.A.vr * 1.25) gam = Math.min(gam, 0.02); // speed first: never climb away slow | |
| assist.gT = gam; | |
| return { pitch: 0, roll: Math.max(-0.5, Math.min(0.5, (bankT - r.bank) * 0.03)), yaw: 0 }; | |
| } | |
| if (CAP || TEST) window.__fwCap = { | |
| ready: () => !!P && terrain && terrain.stats().queue === 0 && terrain.stats().active === 0 && !holdUntilGround && (!buildings || buildings.pending() === 0), | |
| go: (id, o = {}) => { const s = id === 'daily' ? dailySpot(dayN) : spotById(id); go(s, id === 'daily' ? { route: { seed: dayN, daily: true } } : o); }, | |
| set: (o) => Object.assign(SET, o) && applyLook(), | |
| cam: (i, yaw = 0, pitch = 0) => { SET.cam = i; look.yaw = yaw; look.pitch = pitch; look.t = i === 2 ? 0 : 99; camInit = true; }, | |
| auto: (o) => Object.assign(AP, { on: true, hdg: null, alt: null, bank: 0, gate: false, climb: false }, o), | |
| manual: () => { AP.on = false; }, | |
| rate: (n) => { simRate = Math.max(1, Math.min(8, n)); }, | |
| throttle: (v) => { P.ctl.throttle = v; P.surf.thr = v; }, | |
| advance: (n = 1, speed = 1) => { for (let i = 0; i < n; i++) { capT += 1 / 30; tick((1 / 30) * speed, capT * 1000); } }, | |
| hud: (on) => { SET.hud = on; $('hud').style.visibility = on ? '' : 'hidden'; hud.clear(); $('tbar').hidden = !on; $('rt').hidden = !on || !route0; $('touch').hidden = !on || !touchDev; }, | |
| caption: (h) => { const c = $('cap'); c.innerHTML = h || ''; c.hidden = !h; }, | |
| ui: (on) => { for (const id of ['menu', 'place', 'msg', 'loading']) $(id).hidden = true; document.body.classList.toggle('capmode', on); resize(); }, | |
| gateNear: () => { const g = gates[routeIdx]; if (!g || g.y === null) return false; P.pos = { x: g.x - g.nx * 250, y: g.y, z: g.z - g.nz * 250 }; P.q = fromEuler((Math.atan2(g.nx, -g.nz) * 180) / Math.PI, 0, 0); const sp = Math.max(55, P.tas); P.vel = vec.rot(P.q, { x: 0, y: 0, z: -sp }); P.w = { x: 0, y: 0, z: 0 }; assist.gT = 0; assist.trim = 0; prevPos.set(P.pos.x, P.pos.y, P.pos.z); return true; }, | |
| state: () => ({ mode, routeIdx, gates: gates.length, done: !!done, over: !$('over').hidden, crashed: P && P.crashed, hold: holdUntilGround, startedAt, r: P && readout(P) }), | |
| photo: () => photoBlob().then((b) => b.size), | |
| }; | |
| let capT = 0; | |
| // ---------------------------------------------------------------- boot | |
| pass.init({ game: GAME, name: 'Fellwing', title: 'Fellwing Pilot Pack', short: 'Pilot Pack', exact: true, free: 'The Daily Route, free flight anywhere and the Kestrel T-3 stay free for everyone.', perks: ['Three more aircraft: Rook 9 STOL, Strix A2 aerobatic, Vesper SJ jet', 'Morning, golden hour, dusk, night and live local-time skies', 'Haze, overcast and strong-wind weather', 'Saved spots and every past daily route'], extra: 'The Fellwing Pilot Pack ($2.99 once) adds the STOL bush plane, the aerobatic Strix and the Vesper jet, golden hour and night skies, wind and overcast weather, saved spots and every past daily route.' }); | |
| pass.onChange(() => { if (mode === 'menu') buildMenu(); }); | |
| setupTouch(); | |
| resize(); stats(); | |
| pwa(GAME, $('install')); | |
| $('pass-btn').onclick = () => pass.open('header'); | |
| (function boot() { | |
| $('lbar').style.width = '60%'; | |
| buildMenu(); | |
| const spotQ = spotById(q.get('spot') || ''), landQ = spotById(q.get('land') || ''), runQ = spotById(q.get('run') || ''); | |
| if (landQ && isAirport(landQ)) { $('loading').hidden = true; go(landQ, { landing: true, daily: landQ.id === dailyLanding(today).id && dayN === today }); } | |
| else if (runQ && runQ.gates) { $('loading').hidden = true; go(runQ, { route: { seed: 0, run: true } }); } | |
| else if (spotQ && spotQ.gates) { $('loading').hidden = true; go(spotQ, { route: { seed: 0, run: true } }); } | |
| else if (spotQ) { $('loading').hidden = true; go(spotQ, q.get('ac') && own('ac', q.get('ac')) ? {} : {}); } | |
| else if (atParam.length >= 2 && atParam.every((x) => !Number.isNaN(x)) && Math.abs(atParam[0]) < 85) { | |
| $('loading').hidden = true; anywhere(atParam[0], atParam[1], atParam[2] || null, atParam[3] || 0, 'Shared spot'); | |
| } else { attract(); $('menu').hidden = false; } | |
| requestAnimationFrame((t) => { last = t; loop(t); }); | |
| window.__fwBoot = performance.now(); | |
| // hide the loader once the first ground has arrived (or after 2.5 s) | |
| const t0 = performance.now(); | |
| const iv = setInterval(() => { const s = terrain.stats(); $('lbar').style.width = `${Math.min(100, 60 + s.loaded * 4)}%`; if (s.loaded >= 6 || performance.now() - t0 > 2500) { $('loading').hidden = true; clearInterval(iv); } }, 100); | |
| })(); | |
| void [bearing, fromEuler, vec]; | |