// 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();
})();