// gzweb 3D 畫面右下角浮動面板 —— 震亞物聯網 無人機模擬教學平台 // 對戰狀態+快控、遙測、風快調、截圖/重載。API 走同源 /api(由 server.js 代理到 adapter)。 (function () { function api(path, method) { return fetch(path, { method: method || 'GET' }).catch(function () {}); } function apiJson(path, body) { return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).catch(function () {}); } function build() { var css = document.createElement('style'); css.textContent = '#zy-panel{position:fixed;right:14px;bottom:14px;width:270px;z-index:99999;' + 'font-family:system-ui,"Noto Sans TC",sans-serif;color:#e6e9ef;' + 'background:rgba(16,22,34,.92);border:1px solid #2a3550;border-radius:10px;' + 'box-shadow:0 6px 24px rgba(0,0,0,.45);font-size:12.5px;overflow:hidden}' + '#zy-hd{background:#1c2740;padding:8px 10px;font-weight:700;cursor:pointer;' + 'display:flex;justify-content:space-between;align-items:center;font-size:12.5px}' + '#zy-hd small{color:#8fa1c7;font-weight:400}' + '#zy-body{padding:9px 10px;display:block}' + '#zy-panel.min #zy-body{display:none}' + '#zy-panel.min #zy-tabs{display:none}' + '#zy-tabs{display:flex;background:#131c30;border-bottom:1px solid #24304a}' + '#zy-panel #zy-tabs button{flex:1;background:transparent;color:#8fa1c7;border:0;' + 'border-radius:0;padding:6px 0;font-size:12px;cursor:pointer;' + 'border-bottom:2px solid transparent}' + '#zy-panel #zy-tabs button.act{color:#fff;border-bottom-color:#2d6cdf;' + 'background:#1a2440;font-weight:700}' + '.zy-page{display:none}' + '.zy-page.act{display:block}' + '.zy-sec{margin:6px 0;padding-bottom:6px;border-bottom:1px solid #24304a}' + '.zy-sec:last-child{border-bottom:0}' + '.zy-info{margin-top:4px;color:#b9c6e6;line-height:1.55;font-variant-numeric:tabular-nums}' + '.zy-btns{display:flex;gap:5px;flex-wrap:wrap;margin-top:5px}' + '#zy-panel button{background:#2d6cdf;color:#fff;border:0;border-radius:6px;' + 'padding:5px 8px;font-size:12px;cursor:pointer}' + '#zy-panel button:hover{background:#3b7bee}' + '#zy-panel button.on{background:#18a058}' + // 預設隱藏 gzweb 內建左選單(含危險的「重設世界」;⚙選單按鈕可叫回) 'html.zy-gzmenu-off #leftPanel,html.zy-gzmenu-off #mainMenu,' + 'html.zy-gzmenu-off #mainMenuTab,html.zy-gzmenu-off #treeMenu,' + 'html.zy-gzmenu-off #treeMenuTab,html.zy-gzmenu-off #insertMenu,' + 'html.zy-gzmenu-off #insertMenuTab{display:none !important}' + // 右上原生工具列(模式/插入形狀/燈光)一併隱藏;保留暫停鍵與時鐘 'html.zy-gzmenu-off #manipulatorModeFieldset,' + 'html.zy-gzmenu-off #mode-header-fieldset,' + 'html.zy-gzmenu-off #box-header-fieldset,' + 'html.zy-gzmenu-off #sphere-header-fieldset,' + 'html.zy-gzmenu-off #cylinder-header-fieldset,' + 'html.zy-gzmenu-off #pointlight-header-fieldset,' + 'html.zy-gzmenu-off #spotlight-header-fieldset,' + 'html.zy-gzmenu-off #directionallight-header-fieldset,' + // GLB 自訂物件上傳框:預設隱藏,原位置改放機載鏡頭面板;⚙選單可切回(進階功能) 'html.zy-gzmenu-off #zy-glb{display:none !important}' + '#zy-panel input[type=range]{width:100%;margin-top:4px}' + '#zy-panel input[type=text]{width:100%;box-sizing:border-box;background:#141c2f;' + 'border:1px solid #2a3550;color:#e6e9ef;border-radius:6px;padding:4px 6px;' + 'font-size:12px;margin-top:4px}' + '#zy-panel select{width:100%;box-sizing:border-box;background:#141c2f;' + 'border:1px solid #2a3550;color:#e6e9ef;border-radius:6px;padding:4px 6px;' + 'font-size:12px;margin-top:4px}' + // 左上角品牌識別牌 '#zy-brand{position:fixed;top:14px;left:14px;z-index:99998;' + 'font-family:system-ui,"Noto Sans TC",sans-serif;color:#e6e9ef;' + 'background:rgba(16,22,34,.88);border:1px solid #2a3550;border-radius:10px;' + 'box-shadow:0 6px 24px rgba(0,0,0,.4);padding:10px 16px;pointer-events:none}' + '#zy-brand-t{font-size:17px;font-weight:800;letter-spacing:2px;color:#8ec5ff;' + 'text-shadow:0 1px 6px rgba(60,130,255,.35)}' + '#zy-brand-s{font-size:11.5px;color:#8fa1c7;letter-spacing:1.5px;margin-top:2px}' + // 觸控虛擬雙搖桿(平板/展示免手把):左=油門/轉向,右=前後/左右 '.zy-stick{position:fixed;bottom:26px;width:124px;height:124px;border-radius:50%;' + 'background:rgba(20,28,47,.45);border:2px solid rgba(80,110,170,.55);z-index:100001;' + 'display:none;touch-action:none}' + 'body.zy-touchjoy .zy-stick{display:block}' + '#zy-stick-l{left:26px}' + '#zy-stick-r{right:26px}' + 'body.zy-touchjoy #zy-stick-r{bottom:74px}' + '.zy-knob{position:absolute;left:50%;top:50%;width:52px;height:52px;border-radius:50%;' + 'background:rgba(60,110,220,.8);border:2px solid #9cc0ff;transform:translate(-50%,-50%);' + 'box-shadow:0 2px 10px rgba(0,0,0,.4)}' + '.zy-stick small{position:absolute;top:-18px;left:50%;transform:translateX(-50%);' + 'color:#9db3dd;font-size:10px;white-space:nowrap;text-shadow:0 1px 3px #000}' + 'body.zy-touchjoy #zy-hud,body.zy-touchjoy #zy-glb{display:none !important}' + // RWD:平板/小螢幕 '#zy-body{max-height:calc(100vh - 150px);overflow-y:auto}' + '@media (max-width:900px){' + ' #zy-panel{width:232px;font-size:12px}' + ' #zy-hud{transform:scale(.82);transform-origin:bottom left}' + ' #zy-brand{padding:6px 12px}' + ' #zy-brand-t{font-size:14px;letter-spacing:1px}' + ' #zy-brand-s{font-size:10px;letter-spacing:.5px}' + '}' + '@media (max-width:700px){' + ' #zy-hud,#zy-glb{display:none !important}' + ' #zy-panel{width:44vw;min-width:198px;bottom:8px;right:8px}' + ' #zy-brand{top:8px;left:8px}' + '}'; document.head.appendChild(css); // 左上角品牌識別牌 var brand = document.createElement('div'); brand.id = 'zy-brand'; brand.innerHTML = '
震亞物聯網
' + '
AI無人機模擬飛行訓練
'; document.body.appendChild(brand); var el = document.createElement('div'); el.id = 'zy-panel'; el.innerHTML = '
震亞物聯網 · 無人機模擬教學平台
' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
' + '
' + '
⚔️ 對戰' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
' + ' ' + '
' + '
🎮 手飛(Xbox,單機模式)' + '
' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + '
' + '
插搖桿→按一下搖桿鍵喚醒→🛫起飛→搖桿接管
' + '
' + '
🎯 任務' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
飛到發光圓環內停留即過關
' + '
' + '
💣 投彈訓練' + '
' + ' ' + ' ' + ' ' + '
' + '
依當下速度算真實拋物線落點,依離靶距離計分
' + '
' + '
🛡️ 障礙迴避' + '
' + '
前向超音波模擬:8m內警告,3.5m內自動後退迴避(見 HUD 測距條)
' + '
' + '
🚀 地面防空(規避訓練)' + '
' + '
雷達站鎖定後發射追蹤飛彈;躲進建築物斷視線可靠脫鎖
' + '
' + '
📡 遙測
' + '
🎦 視角' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
觀察者=站定目視(考照視角)·FPV=第一人稱
' + '
' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
' + '
' + '
🌬️ 風 0 m/s' + ' ' + '
實際施力於機體;含陣風擾動與風向漂移
' + '
' + '
🌦️ 天氣' + '
' + ' ' + ' ' + ' ' + '
' + '
雨/雪會增加機身負載與亂流;霧降能見度
' + '
' + '
🌓 光照' + '
' + ' ' + ' ' + '
' + '
循環=10分鐘白天/5分鐘夜間輪替
' + '
' + '
' + '
' + '
🧑‍🏫 AI 教練' + '
' + '
' + '
即時盯遙測,超限時給提醒(可語音播報)
' + '
' + '
🪄 AI 出題' + ' ' + '
' + '
' + '
' + '
🎓 AI 示範飛行' + '
' + ' ' + ' ' + ' ' + '
' + '
AI 自動飛標準科目給學員觀摩(建議搭配🎥跟隨)
' + '
' + '
🎤 AI 口試' + '
' + '
考完術科後按「出題」,依你的飛行表現出口試題
' + ' ' + '
' + '
' + '
🔍 視覺任務判定' + '
' + ' ' + ' ' + ' ' + '
' + '
以目前畫面截圖交給視覺 AI 判定(建議 FPV 視角)
' + '
' + '
' + '
' + '
📼 航跡回放' + ' ' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + '
每次飛行自動錄影,落地即存檔
' + '
' + '
⛔ 電子圍籬' + '
' + ' ' + ' ' + '
' + '
半徑400m·限高120m;超出警告,遠超強制返航
' + '
' + '
' + '
'; document.body.appendChild(el); document.getElementById('zy-hd').addEventListener('click', function () { el.classList.toggle('min'); document.getElementById('zy-min').textContent = el.classList.contains('min') ? '▸' : '▾'; }); // 頁籤切換(🕹 操控 / 🤖 AI / 📼 回放) document.getElementById('zy-tabs').addEventListener('click', function (e) { var t = e.target && e.target.getAttribute && e.target.getAttribute('data-t'); if (!t) return; el.querySelectorAll('#zy-tabs button').forEach(function (b) { b.classList.toggle('act', b.getAttribute('data-t') === t); }); el.querySelectorAll('.zy-page').forEach(function (p2) { p2.classList.toggle('act', p2.id === 'zy-page-' + t); }); if (t === 'replay') trkRefresh(); }); // 📼 航跡回放 async function trkRefresh() { try { var list = await (await fetch('/api/track/list')).json(); var sel = document.getElementById('zy-trk-sel'); sel.innerHTML = list.length ? '' : ''; list.forEach(function (t) { var o = document.createElement('option'); o.value = t.id; o.textContent = t.recording ? (t.id + ' ⏺錄影中(' + t.points + '點)') : (t.id + ' · ' + t.duration_s + 's · 高' + t.max_alt + 'm' + (t.events ? ' · ⚠' + t.events : '')); sel.appendChild(o); }); } catch (e) {} } document.getElementById('zy-trk-load').addEventListener('click', async function () { var id = document.getElementById('zy-trk-sel').value; var msg = document.getElementById('zy-trk-msg'); if (!id) return; try { var d = await (await fetch('/api/track/' + id)).json(); if (d.error) { msg.textContent = '載入失敗'; return; } var ok = window.zyTrack && window.zyTrack.show(d); msg.textContent = ok ? ('已載入:' + d.points.length + ' 點·' + (d.events || []).length + ' 事件,按▶播放') : '場景尚未就緒'; } catch (e) { msg.textContent = '載入失敗'; } }); document.getElementById('zy-trk-play').addEventListener('click', function () { if (!window.zyTrack) return; if (window.zyTrack.isPlaying()) { window.zyTrack.pause(); this.textContent = '▶播放'; } else { window.zyTrack.play(); this.textContent = '⏸暫停'; } }); setInterval(function () { if (!window.zyTrack) return; var p = window.zyTrack.progress(); var btn = document.getElementById('zy-trk-play'); if (p && window.zyTrack.isPlaying()) { document.getElementById('zy-trk-msg').textContent = '重播中 ' + p.t + 's / ' + p.dur + 's'; } else if (btn.textContent === '⏸暫停' && p && !window.zyTrack.isPlaying()) { btn.textContent = '▶播放'; } }, 1000); var spd = [1, 2, 4, 8], spdIdx = 0; document.getElementById('zy-trk-speed').addEventListener('click', function () { spdIdx = (spdIdx + 1) % spd.length; if (window.zyTrack) window.zyTrack.setSpeed(spd[spdIdx]); this.textContent = 'x' + spd[spdIdx]; }); document.getElementById('zy-trk-clear').addEventListener('click', function () { if (window.zyTrack) window.zyTrack.clear(); document.getElementById('zy-trk-msg').textContent = '已清除'; document.getElementById('zy-trk-play').textContent = '▶播放'; }); // ⛔ 電子圍籬 document.getElementById('zy-fence').addEventListener('click', async function () { var on = !this.classList.contains('on'); var j = await (await fetch('/api/geofence/' + (on ? 'enable' : 'disable'), { method: 'POST' })).json(); this.classList.toggle('on', !!j.enabled); this.textContent = j.enabled ? '圍籬:開' : '圍籬:關'; if (window.zyTrack) window.zyTrack.refreshFence(); }); document.getElementById('zy-fence-show').addEventListener('click', function () { var show = !this.classList.contains('on'); this.classList.toggle('on', show); if (window.zyTrack) window.zyTrack.fenceVisible(show); }); // 天氣切換:先 GET 現值再合併 POST,不把風/其他欄位蓋回預設 var wLevel = { rain: 0.7, snow: 0.7, fog: 0.6 }; async function toggleWeather(kind, btn) { try { var j = await (await fetch('/api/state')).json(); j.weather = j.weather || { rain: 0, fog: 0, snow: 0 }; var on = !(j.weather[kind] > 0); j.weather[kind] = on ? wLevel[kind] : 0; await fetch('/api/state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(j) }); btn.classList.toggle('on', on); } catch (e2) {} } el.addEventListener('click', async function (e) { var dm = e.target && e.target.getAttribute && e.target.getAttribute('data-demo'); if (dm) { var dmsg = document.getElementById('zy-demo-msg'); if (dm === 'stop') { api('/api/demo/stop', 'POST'); dmsg.textContent = '示範已停止(懸停)'; } else { dmsg.textContent = '🎓 起飛並開始示範「' + (dm === 'square' ? '方形' : '8字') + '」…'; apiJson('/api/demo/start', { pattern: dm, altitude: 10 }); } return; } var vt = e.target && e.target.getAttribute && e.target.getAttribute('data-vt'); if (vt) { var vmsg = document.getElementById('zy-vt-msg'); var img = captureFrame(); if (!img) { vmsg.textContent = '截圖失敗'; return; } vmsg.textContent = '🧠 視覺 AI 判定中…'; try { var vj = await (await fetch('/api/vision/judge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ task: vt, image: img }) })).json(); vmsg.textContent = vj.ok ? ((vj.passed ? '✅ 達成' : '❌ 未達成') + ' — ' + vj.reason) : ('判定失敗:' + (vj.error || '')); } catch (e2) { vmsg.textContent = '判定失敗(網路)'; } return; } var w = e.target && e.target.getAttribute && e.target.getAttribute('data-w'); if (w) { toggleWeather(w, e.target); return; } if (e.target && e.target.id === 'zy-night') { e.target.classList.toggle('on', window.zyNight && window.zyNight()); return; } if (e.target && e.target.id === 'zy-cycle') { e.target.classList.toggle('on', window.zyDayCycle && window.zyDayCycle()); return; } if (e.target && e.target.id === 'zy-bomb-start') { var bmsg1 = document.getElementById('zy-bomb-msg'); bmsg1.textContent = '🎯 靶場設置中…'; fetch('/api/bomb/start', { method: 'POST' }).then(function (r) { return r.json(); }) .then(function (j) { bmsg1.textContent = '靶場已設置,飛過去按「投彈」試試'; }); return; } if (e.target && e.target.id === 'zy-bomb-stop') { api('/api/bomb/stop', 'POST'); document.getElementById('zy-bomb-msg').textContent = '靶場已收起'; return; } if (e.target && e.target.id === 'zy-bomb-drop') { doBombDrop(); return; } if (e.target && e.target.id === 'zy-avoid-toggle') { var onNow = !e.target.classList.contains('on'); fetch('/api/sensors/ultrasonic/' + (onNow ? 'enable' : 'disable'), { method: 'POST' }) .then(function (r) { return r.json(); }) .then(function (j) { e.target.classList.toggle('on', !!j.enabled); e.target.textContent = j.enabled ? '開啟中' : '已關閉'; }); return; } if (e.target && e.target.id === 'zy-sam-toggle') { var samOn = !e.target.classList.contains('on'); fetch('/api/missiles/' + (samOn ? 'enable' : 'disable'), { method: 'POST' }) .then(function (r) { return r.json(); }) .then(function (j) { e.target.classList.toggle('on', !!j.enabled); e.target.textContent = j.enabled ? '開啟中' : '已關閉'; }); return; } var mi = e.target && e.target.getAttribute && e.target.getAttribute('data-mi'); if (mi) { var mim = document.getElementById('zy-mi-msg'); if (mi === 'stop') { api('/api/mission/stop', 'POST'); mim.textContent = '任務已停止'; } else { api('/api/mission/' + mi + '/start', 'POST'); mim.textContent = '🎯 任務生成中…看場景裡的發光圓環'; } return; } var v = e.target && e.target.getAttribute && e.target.getAttribute('data-v'); if (v && window.zySetView) { var cur = window.zySetView(v); el.querySelectorAll('[data-v]').forEach(function (b) { b.classList.toggle('on', b.getAttribute('data-v') === cur); }); var vm = document.getElementById('zy-viewmsg'); vm.textContent = { free: '滑鼠拖曳旋轉/滾輪縮放', los: '站定目視:飛遠會變小,練距離感(考照視角)', fpv: '第一人稱:跟著機身俯仰滾轉', chase: '機尾追隨:無人機轉向,視角跟著轉' }[cur]; return; } var a = e.target && e.target.getAttribute && e.target.getAttribute('data-a'); if (!a) return; if (a === 'takeoff') api('/api/fleet/takeoff?altitude=10', 'POST'); else if (a === 'combat') api('/api/fleet/combat/start', 'POST'); else if (a === 'stop' || a === 'resetc') api('/api/fleet/combat/stop', 'POST'); else if (a === 'land') api('/api/fleet/land', 'POST'); else if (a === 's-takeoff') apiJson('/api/action', { action: 'takeoff', altitude: 5 }); else if (a === 's-land') apiJson('/api/action', { action: 'land' }); else if (a === 's-rtl') apiJson('/api/action', { action: 'return' }); else if (a === 's-respawn') { api('/api/respawn', 'POST'); document.getElementById('zy-joymsg').textContent = '復位中:移回起飛坪並扶正…'; } else if (a === 'reload') location.reload(); else if (a === 'shot') screenshot(); else if (a === 'gzmenu') document.documentElement.classList.toggle('zy-gzmenu-off'); }); document.documentElement.classList.add('zy-gzmenu-off'); // 預設隱藏左選單 // 💣 投彈(面板按鈕與搖桿 A 鍵共用) function doBombDrop() { var bmsg = document.getElementById('zy-bomb-msg'); fetch('/api/bomb/drop', { method: 'POST' }).then(function (r) { return r.json(); }) .then(function (j) { if (bmsg) bmsg.textContent = j.ok ? ('💥 落點距靶心 ' + j.drop.distance_m + 'm,得分 ' + j.drop.score + '/100') : ('無法投彈:' + (j.error || '')); }); } // 🎮 Xbox 搖桿手飛(單機走 /api/manual/*;多機接管時 fleetJoyIdx 指向該機通道) var joyOn = false, joyTimer = null, fleetJoyIdx = null; var bombBtnWasDown = false; // A 鍵邊緣偵測,避免按住連續投彈 function readGamepad() { var pads = navigator.getGamepads ? navigator.getGamepads() : []; var gp = null; for (var i = 0; i < pads.length; i++) if (pads[i]) { gp = pads[i]; break; } var m = document.getElementById('zy-joymsg'); if (!gp) { m.textContent = '未偵測到手把(按一下手把鍵喚醒)'; return; } var dz = function (v) { return Math.abs(v) < 0.08 ? 0 : v; }; var r = dz(gp.axes[0]); var z = 0.5 - dz(gp.axes[1]) * 0.5; // 左搖桿上=油門大 var y = dz(gp.axes[2]); // 右搖桿左右=橫移 var x = -dz(gp.axes[3]); // 右搖桿上=前進 m.textContent = (fleetJoyIdx != null ? ('機#' + fleetJoyIdx + ' ') : '') + '前後 ' + x.toFixed(2) + ' 左右 ' + y.toFixed(2) + ' 油門 ' + z.toFixed(2) + ' 轉向 ' + r.toFixed(2) + ' · A鍵投彈'; var url = fleetJoyIdx != null ? ('/api/fleet/manual/' + fleetJoyIdx + '/input') : '/api/manual/input'; apiJson(url, { x: x, y: y, z: z, r: r }); // A 鍵(標準 Xbox 映射 buttons[0])投彈:偵測按下瞬間(邊緣),放開重置 var bombDown = !!(gp.buttons[0] && gp.buttons[0].pressed); if (bombDown && !bombBtnWasDown) doBombDrop(); bombBtnWasDown = bombDown; } // 📱 觸控虛擬雙搖桿(pointer events 同時支援觸控與滑鼠;多點觸控各桿獨立) function makeStick(id, labelText) { var outer = document.createElement('div'); outer.className = 'zy-stick'; outer.id = id; outer.innerHTML = '' + labelText + '
'; document.body.appendChild(outer); var knob = outer.querySelector('.zy-knob'); var st = { nx: 0, ny: 0, pid: null }; function setKnob(dx, dy) { knob.style.transform = 'translate(-50%,-50%) translate(' + dx + 'px,' + dy + 'px)'; } function handle(e) { var rect = outer.getBoundingClientRect(); var cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2; var dx = e.clientX - cx, dy = e.clientY - cy; var max = rect.width / 2 - 26; var len = Math.hypot(dx, dy); if (len > max) { dx *= max / len; dy *= max / len; } st.nx = +(dx / max).toFixed(2); st.ny = +(dy / max).toFixed(2); setKnob(dx, dy); } outer.addEventListener('pointerdown', function (e) { st.pid = e.pointerId; outer.setPointerCapture(e.pointerId); handle(e); e.preventDefault(); }); outer.addEventListener('pointermove', function (e) { if (e.pointerId === st.pid) { handle(e); e.preventDefault(); } }); function release(e) { if (e.pointerId !== st.pid) return; st.pid = null; st.nx = 0; st.ny = 0; setKnob(0, 0); } outer.addEventListener('pointerup', release); outer.addEventListener('pointercancel', release); return st; } var stickL = makeStick('zy-stick-l', '油門↑↓ · 轉向←→'); var stickR = makeStick('zy-stick-r', '前後↑↓ · 左右←→'); var touchOn = false, touchTimer = null; document.getElementById('zy-touch').addEventListener('click', async function () { var btn = this, m = document.getElementById('zy-joymsg'); if (!touchOn) { var url = fleetJoyIdx != null ? ('/api/fleet/manual/' + fleetJoyIdx + '/enable') : '/api/manual/enable'; var j = await (await fetch(url, { method: 'POST' })).json(); if (j.status !== 'ok') { m.textContent = '無法接管:' + (j.note || j.status) + '(需先🛫起飛)'; return; } touchOn = true; document.body.classList.add('zy-touchjoy'); el.classList.add('min'); // 收合面板讓出右下角給搖桿 document.getElementById('zy-min').textContent = '▸'; btn.textContent = '📱關閉虛擬搖桿'; btn.classList.add('on'); m.textContent = '虛擬搖桿操控中(左=油門/轉向,右=前後/左右)'; touchTimer = setInterval(function () { var payload = { x: -stickR.ny, // 右桿上推=前進 y: stickR.nx, // 右桿右推=右移 z: 0.5 - stickL.ny * 0.5, // 左桿上推=油門大 r: stickL.nx, // 左桿左右=轉向 }; var u = fleetJoyIdx != null ? ('/api/fleet/manual/' + fleetJoyIdx + '/input') : '/api/manual/input'; apiJson(u, payload); }, 60); } else { touchOn = false; if (touchTimer) { clearInterval(touchTimer); touchTimer = null; } document.body.classList.remove('zy-touchjoy'); el.classList.remove('min'); document.getElementById('zy-min').textContent = '▾'; var du = fleetJoyIdx != null ? ('/api/fleet/manual/' + fleetJoyIdx + '/disable') : '/api/manual/disable'; await fetch(du, { method: 'POST' }); btn.textContent = '📱虛擬搖桿'; btn.classList.remove('on'); m.textContent = '已釋放,回懸停'; } }); // 多機:選機接管(每架獨立通道,可多人各飛各機) document.getElementById('zy-fleet-joy').addEventListener('click', async function () { var btn = this, m = document.getElementById('zy-joymsg'); if (fleetJoyIdx == null) { var idx = parseInt(document.getElementById('zy-fleet-sel').value, 10); if (isNaN(idx)) return; var j = await (await fetch('/api/fleet/manual/' + idx + '/enable', { method: 'POST' })).json(); if (j.status === 'ok') { fleetJoyIdx = idx; btn.textContent = '🎮釋放機#' + idx; btn.classList.add('on'); if (!joyTimer) joyTimer = setInterval(readGamepad, 50); m.textContent = '已接管機#' + idx + ',搖桿操控中'; } else { m.textContent = '接管失敗:' + (j.note || j.status) + '(需先全體起飛)'; } } else { await fetch('/api/fleet/manual/' + fleetJoyIdx + '/disable', { method: 'POST' }); if (joyTimer && !joyOn) { clearInterval(joyTimer); joyTimer = null; } m.textContent = '已釋放機#' + fleetJoyIdx; fleetJoyIdx = null; btn.textContent = '🎮接管此機'; btn.classList.remove('on'); } }); document.getElementById('zy-joy').addEventListener('click', async function () { var btn = document.getElementById('zy-joy'), m = document.getElementById('zy-joymsg'); if (!joyOn) { var j = await (await fetch('/api/manual/enable', { method: 'POST' })).json(); if (j.status === 'ok') { joyOn = true; btn.textContent = '🎮釋放'; joyTimer = setInterval(readGamepad, 50); } else m.textContent = '無法接管:' + j.status + '(需先🛫起飛、且為單機模式)'; } else { if (joyTimer) clearInterval(joyTimer); joyTimer = null; await fetch('/api/manual/disable', { method: 'POST' }); joyOn = false; btn.textContent = '🎮搖桿接管'; m.textContent = '已釋放,回懸停'; } }); // 🧑‍🏫 AI 教練:啟停 + 輪詢最新提醒 + 語音播報(瀏覽器 TTS) var coachOn = false, voiceOn = true, lastSpokenT = 0; function speak(text) { try { var u = new SpeechSynthesisUtterance(text.replace(/^💥\s*/, '')); u.lang = 'zh-TW'; u.rate = 1.05; window.speechSynthesis.speak(u); } catch (e) {} } document.getElementById('zy-coach-voice').addEventListener('click', function () { voiceOn = !voiceOn; this.classList.toggle('on', voiceOn); if (!voiceOn) { try { window.speechSynthesis.cancel(); } catch (e) {} } }); document.getElementById('zy-coach').addEventListener('click', async function () { var btn = this; var j = await (await fetch('/api/coach/' + (coachOn ? 'stop' : 'start'), { method: 'POST' })).json(); coachOn = !!j.running; btn.textContent = coachOn ? '■停止教練' : '▶啟動教練'; btn.classList.toggle('on', coachOn); if (coachOn) { document.getElementById('zy-coach-msg').textContent = '教練待命中…'; if (voiceOn) speak('AI 教練已啟動'); } }); setInterval(async function () { if (!coachOn) return; try { var s = await (await fetch('/api/coach/status')).json(); var ms = s.messages || []; if (ms.length) { var last = ms.slice(-2).map(function (m) { return '💬 ' + m.text; }); document.getElementById('zy-coach-msg').innerHTML = last.join('
'); var newest = ms[ms.length - 1]; if (voiceOn && newest.t > lastSpokenT) { lastSpokenT = newest.t; speak(newest.text); } } } catch (e) {} }, 3000); // 🎓 AI 示範飛行 // 🎤 AI 口試(逐題作答) var oralIdx = -1, oralQs = []; function oralShowNext() { var qEl = document.getElementById('zy-oral-q'), aEl = document.getElementById('zy-oral-a'); oralIdx++; if (oralIdx >= oralQs.length) { qEl.textContent = '口試完成!'; aEl.style.display = 'none'; return; } qEl.textContent = '第' + (oralIdx + 1) + '題:' + oralQs[oralIdx]; aEl.style.display = 'block'; aEl.value = ''; aEl.focus(); } document.getElementById('zy-oral-start').addEventListener('click', async function () { document.getElementById('zy-oral-q').textContent = '🧠 出題中…'; document.getElementById('zy-oral-fb').textContent = ''; try { var j = await (await fetch('/api/oral/start', { method: 'POST' })).json(); oralQs = j.questions || []; oralIdx = -1; oralShowNext(); } catch (e) { document.getElementById('zy-oral-q').textContent = '出題失敗'; } }); document.getElementById('zy-oral-a').addEventListener('keydown', async function (e) { if (e.key !== 'Enter' || !this.value.trim()) return; var ans = this.value.trim(), fbEl = document.getElementById('zy-oral-fb'); fbEl.textContent = '🧠 評分中…'; try { var j = await (await fetch('/api/oral/answer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ index: oralIdx, answer: ans }) })).json(); fbEl.textContent = (j.score != null ? ('得分 ' + j.score + '/10 · ') : '') + (j.feedback || ''); oralShowNext(); } catch (e2) { fbEl.textContent = '評分失敗'; } }); // 🔍 視覺任務:截圖前先手動 render 一次(WebGL 無 preserveDrawingBuffer, // 直接 toDataURL 會是空白),再縮到 640 寬省 token。 function captureFrame() { var sc = window.scene, cv = document.querySelector('canvas'); if (!cv) return null; try { if (sc && sc.renderer && sc.scene && sc.camera) sc.renderer.render(sc.scene, sc.camera); } catch (e) {} var w = Math.min(640, cv.width), h = Math.round(cv.height * w / cv.width); var off = document.createElement('canvas'); off.width = w; off.height = h; off.getContext('2d').drawImage(cv, 0, 0, w, h); return off.toDataURL('image/jpeg', 0.75); } // 🪄 AI 出題:一句話 → 生成腳本 → 立即開跑 async function aiGen() { var q = document.getElementById('zy-ai-q').value.trim(); var msg = document.getElementById('zy-ai-msg'); if (!q) { msg.textContent = '請先描述考題'; return; } msg.textContent = '🧠 生成中…'; try { var j = await (await fetch('/api/scenario/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: q }) })).json(); if (!j.ok) { msg.textContent = '生成失敗:' + (j.error || ''); return; } await fetch('/api/scenario/ai_custom/start', { method: 'POST' }); msg.innerHTML = '▶「' + j.label + '」開始(' + j.source + ')
' + j.steps.map(function (s) { return s.at + 's ' + s.label; }).join(' → '); } catch (e) { msg.textContent = '生成失敗(網路)'; } } document.getElementById('zy-ai-gen').addEventListener('click', aiGen); document.getElementById('zy-ai-q').addEventListener('keydown', function (e) { if (e.key === 'Enter') aiGen(); }); document.getElementById('zy-ai-stop').addEventListener('click', async function () { await fetch('/api/scenario/stop', { method: 'POST' }); document.getElementById('zy-ai-msg').textContent = '腳本已停止'; }); var wind = document.getElementById('zy-wind'); wind.addEventListener('input', function () { document.getElementById('zy-wv').textContent = wind.value; }); wind.addEventListener('change', function () { apiJson('/api/state', { wind: { speed_mps: parseFloat(wind.value), direction_deg: 270, gust_mps: 0 } }); }); function screenshot() { var c = document.querySelector('canvas'); if (!c) { alert('找不到 3D 畫面'); return; } try { var sc = window.scene; // 先 render 一次,否則無 preserveDrawingBuffer 會空白 if (sc && sc.renderer && sc.scene && sc.camera) sc.renderer.render(sc.scene, sc.camera); var url = c.toDataURL('image/png'); var a = document.createElement('a'); a.href = url; a.download = 'px4-sim-' + Date.now() + '.png'; a.click(); } catch (e) { alert('截圖失敗(可能需渲染器 preserveDrawingBuffer):' + e); } } var flag = function (t) { return t === 'A' ? '🔴A' : '🔵B'; }; async function poll() { try { var s = await (await fetch('/api/fleet/status')).json(); if (s && s.n > 1) { var win = s.winner ? (' ' + (s.winner === 'draw' ? '平手' : flag(s.winner) + '隊勝🏆')) : ''; document.getElementById('zy-combat').textContent = 'referee:' + (s.referee_on ? '開' : '關') + win; document.getElementById('zy-tele').innerHTML = (s.drones || []).map(function (d) { var hp = d.alive ? '❤'.repeat(Math.max(0, d.hp || 0)) : '💥'; return flag(d.team) + ' 機#' + d.idx + ' ' + hp + (d.manual ? ' 🎮' : '') + (d.pos ? ' alt ' + d.pos.alt + 'm' : ' (未連)'); }).join('
'); // 選機下拉(多人各飛各機) var my = document.getElementById('zy-fleet-my'); my.style.display = 'block'; var sel = document.getElementById('zy-fleet-sel'); if (sel.options.length !== s.drones.length) { sel.innerHTML = ''; s.drones.forEach(function (d) { var o = document.createElement('option'); o.value = d.idx; o.textContent = flag(d.team) + ' 機#' + d.idx; sel.appendChild(o); }); } } else { var t = await (await fetch('/api/telemetry')).json(); document.getElementById('zy-combat').textContent = '(單機模式)'; document.getElementById('zy-tele').textContent = t.connected ? ('模式 ' + t.flight_mode + ' · alt ' + (t.position ? t.position.rel_alt_m : '-') + 'm') : '未連線'; } } catch (e) {} } setInterval(poll, 1200); poll(); // 🎯 任務進度(training.js 輪詢的共享狀態) setInterval(function () { var st = window.__zyMissionStatus; var mim = document.getElementById('zy-mi-msg'); if (!st || !st.active) return; if (st.done) mim.textContent = '✅ ' + st.label + ' 完成!得分 ' + st.score + '/100'; else { var wp = st.wps[st.current]; mim.textContent = st.label + ':前往「' + (wp ? wp.label : '') + '」(' + (st.current + 1) + '/' + st.wps.length + ')·' + Math.round(st.elapsed) + 's'; } }, 2000); // 💥 碰撞警示:監看 adapter 碰撞計數,增加即滿版紅色警示 3 秒 var collSeen = -1; setInterval(async function () { try { var cs = await (await fetch('/api/collision/status')).json(); if (collSeen >= 0 && cs.count > collSeen && cs.last) { var t = document.createElement('div'); t.style.cssText = 'position:fixed;top:18%;left:50%;transform:translateX(-50%);' + 'z-index:100000;background:rgba(160,20,20,.92);color:#fff;font-weight:800;' + 'font-size:26px;padding:14px 30px;border-radius:12px;pointer-events:none;' + 'font-family:system-ui,"Noto Sans TC",sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.5)'; t.textContent = '💥 ' + cs.last.desc + '!馬達失效'; document.body.appendChild(t); setTimeout(function () { t.remove(); }, 3000); } collSeen = cs.count; } catch (e) {} }, 1500); // 🚨 超音波自動迴避警示:觸發時滿版橘色警示 2 秒(HUD 常駐測距條見 hud.js) var avoidSeen = -1; setInterval(async function () { try { var us = await (await fetch('/api/sensors/ultrasonic')).json(); if (avoidSeen >= 0 && us.avoid_count > avoidSeen) { var t = document.createElement('div'); t.style.cssText = 'position:fixed;top:18%;left:50%;transform:translateX(-50%);' + 'z-index:100000;background:rgba(180,90,10,.92);color:#fff;font-weight:800;' + 'font-size:24px;padding:12px 28px;border-radius:12px;pointer-events:none;' + 'font-family:system-ui,"Noto Sans TC",sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.5)'; t.textContent = '🚨 偵測到前方障礙,自動迴避中!'; document.body.appendChild(t); setTimeout(function () { t.remove(); }, 2000); } avoidSeen = us.avoid_count; } catch (e) {} }, 1000); // 🚀 飛彈發射/命中警示 var launchSeen = -1, hitSeen = -1; function flashBanner(text, bg) { var t = document.createElement('div'); t.style.cssText = 'position:fixed;top:18%;left:50%;transform:translateX(-50%);' + 'z-index:100000;background:' + bg + ';color:#fff;font-weight:800;' + 'font-size:24px;padding:12px 28px;border-radius:12px;pointer-events:none;' + 'font-family:system-ui,"Noto Sans TC",sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.5)'; t.textContent = text; document.body.appendChild(t); setTimeout(function () { t.remove(); }, 2200); } setInterval(async function () { try { var ms = await (await fetch('/api/missiles/status')).json(); if (launchSeen >= 0 && ms.launch_count > launchSeen) flashBanner('🚀 飛彈鎖定發射!立即規避', 'rgba(200,60,10,.92)'); if (hitSeen >= 0 && ms.hit_count > hitSeen) flashBanner('💥 被飛彈擊中!', 'rgba(160,20,20,.92)'); launchSeen = ms.launch_count; hitSeen = ms.hit_count; } catch (e) {} }, 900); // 多機時確保 fleet 已連線(冪等);單機不呼叫以免和 Px4Driver 搶 14540 fetch('/api/fleet/status').then(function (r) { return r.json(); }) .then(function (s) { if (s && s.n > 1) fetch('/api/fleet/start', { method: 'POST' }); }) .catch(function () {}); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', build); else build(); })();