ARTALE
龍王計時器
本機運算 ‧ 不上傳畫面

Artale 龍王戰專用:自動偵測畫面右上角 buff/技能圖示,火消等一消失立即響警報聲+語音播報,可發 Discord 通知;還能開多人警報房間讓隊伍共享、龍王倒數同步播報。免安裝、免費、純瀏覽器本機運算。

尚未開始 請擷取畫面並勾選要監控的圖示
1擷取畫面
2開始監控
3開始警報

遊戲畫面

按上方 「擷取畫面」,選擇你的遊戲視窗
擷取後,右上角橘色框是偵測範圍(buff 列),可拖曳調整。
偵測範圍:拖框的邊或角可改大小(要加左邊寬度就拖左邊那條)、拖框內可整塊移動、在框外拖則重畫。只在框內尋找圖示。

🛰 警報房間 未加入

取個暱稱就能用內建免費伺服器——建立房間把房號給隊友,他們加入房間即可,免任何設定。

素材庫 0 已選 ‧

把技能圖示 PNG 拖進來,或點此選檔加入自己的圖示
載入中…

事件紀錄

尚無紀錄

設定

警報與靈敏度
每個圖示可在素材庫各自調整靈敏度,這裡是新圖示的預設值。越高越靈敏。看每列的符合度走勢圖,把該圖示的靈敏度調到讓警報線落在「存在」與「消失」之間最準。
偵測(進階)
開啟後跟著遊戲畫面每一幀即時偵測,反應最快(會忽略上面的間隔)。提示:把工具分頁放在看得到的地方(如第二螢幕),背景分頁會被瀏覽器降速
避免圖示快到期閃爍/跳秒數造成誤判(最低 0.4 秒)。
通知
Discord 頻道 → 編輯頻道 → 整合 → Webhook → 建立並複製網址。圖示消失時自動發訊。網址等同密碼,請勿外流(只存在你的瀏覽器)。
暗黑龍王計時器'); w.document.close(); document.querySelectorAll("style").forEach(s=> w.document.head.appendChild(s.cloneNode(true))); w.document.body.classList.add("pip-body"); w.document.body.appendChild(overlay); w.document.addEventListener("keydown", onTimerKey); w.addEventListener("beforeunload", restoreOverlay); pipWin=w; } if(btnPip) btnPip.textContent="🔝 浮窗開啟中(點此聚焦)"; }catch(e){ alert("無法開啟置頂浮窗:"+(e&&e.message||e)); pipWin=null; } } function restoreOverlay(){ try{ if(overlay && overlayDock && overlay.parentElement!==overlayDock) overlayDock.appendChild(overlay); }catch(_){} pipWin=null; if(btnPip) btnPip.textContent="🔝 置頂浮窗(計時器+聊天)"; } function closePip(){ if(pipWin){ try{ pipWin.close(); }catch(_){} } restoreOverlay(); } btnPip && btnPip.addEventListener("click", openPip); ovInput && ovInput.addEventListener("keydown", e=>{ if(e.key==="Enter"){ const t=ovInput.value.trim(); if(t && room){ try{ room.channel.send({type:"broadcast",event:"msg",payload:{nick:room.nick,text:t,kind:"chat"}}); }catch(_){} } ovInput.value=""; } }); buildBossGrid(); updateBossWho(); // 浮窗各區塊顯示/隱藏(房間資訊 / 計時器 / 聊天室)— 記住偏好 const ovToggles=document.querySelectorAll(".ovtg"); function applyOvToggles(){ ovToggles.forEach(cb=>{ const el=overlay.querySelector('#'+cb.getAttribute("data-sec")); if(el) el.style.display = cb.checked ? "" : "none"; }); } // 在 overlay 子樹內找:浮窗(PiP)也有效 function saveOvToggles(){ try{ const o={}; ovToggles.forEach(cb=>o[cb.getAttribute("data-sec")]=cb.checked); localStorage.setItem("overlay-secs", JSON.stringify(o)); }catch(e){} } function loadOvToggles(){ try{ const o=JSON.parse(localStorage.getItem("overlay-secs")||"{}"); ovToggles.forEach(cb=>{ const k=cb.getAttribute("data-sec"); if(k in o) cb.checked=!!o[k]; }); }catch(e){} } ovToggles.forEach(cb=>cb.addEventListener("change", ()=>{ applyOvToggles(); saveOvToggles(); })); loadOvToggles(); applyOvToggles(); $("btnCreateRoom").addEventListener("click", ()=>{ ensureAudio(); enterRoom(roomCode6(), true); }); $("btnJoinRoom").addEventListener("click", ()=>{ const c=(roomCodeInput.value||"").trim().toUpperCase(); if(c.length<4){ alert("請輸入房號"); return; } ensureAudio(); enterRoom(c, false); }); $("btnLeaveRoom").addEventListener("click", leaveRoom); $("btnCopyRoom").addEventListener("click", ()=>{ if(room&&navigator.clipboard){ navigator.clipboard.writeText(room.code); roomStatus.textContent="已複製房號"; } }); // 聊天輸入已移至浮窗內 #ovInput;舊的獨立聊天監聽已移除 function saveRoomCfg(){ try{ localStorage.setItem("warnroom-cfg", JSON.stringify({ url:supaUrl.value, key:supaKey.value, nick:nickInput.value })); }catch(e){} } function loadRoomCfg(){ try{ const s=JSON.parse(localStorage.getItem("warnroom-cfg")||"{}"); if(s.url)supaUrl.value=s.url; if(s.key)supaKey.value=s.key; if(s.nick)nickInput.value=s.nick; }catch(e){} } [supaUrl,supaKey,nickInput].forEach(el=>el.addEventListener("change", saveRoomCfg)); window.addEventListener("beforeunload", ()=>{ if(room){ try{ room.client.removeChannel(room.channel); }catch(_){} } }); loadRoomCfg(); // ================= 初始化 ================= async function init(){ loadCfg(); syncLabels(); mapSensitivity(); await loadUserLib(); const ok = await loadPack(); if(!ok && !library.length){ libEl.innerHTML='
無法自動載入內建圖示(請改用 http://localhost 開啟,或把 PNG 拖進上面的框)。
'; } else renderLib(); refreshMonitorBtn(); } init(); // ================= 除錯掛勾 ================= window.__dbg = { buildTemplate, znccAt, locate, track, relocateInRoi, freshTrk, grabRoiGray, buildIntegral, ensureScratch, mapSensitivity, SCALE_LADDER, forceLockBest, zoomView, setRoi:(r)=>{roi=r;}, getRoi:()=>roi, setFrame:(el,w,h)=>{frameEl=el;srcW=w;srcH=h;full.width=w;full.height=h;}, setLocked:(v)=>{lockedRung=v;}, getCur:()=>cur, timer:{ BOSS, bt, buildBossGrid, bossLocalStart, bossLocalStop, onTimer6, renderBoss, ensureBossLoop, openPip, closePip, setCanControl:(v)=>{canControl=!!v;}, getCanControl:()=>canControl, getPip:()=>!!pipWin } }; // ================= 合成測試(無遊戲也能驗證比對引擎) ================= window.__selfTest = async function(){ const out=[]; const assert=(name,cond,val)=>{ out.push({name,pass:!!cond,val}); console.log((cond?"PASS ":"FAIL ")+name, val!==undefined?val:""); }; // 造一個合成圖示(彩色圖形 + 透明邊) const isz=32; const ic=document.createElement("canvas"); ic.width=isz; ic.height=isz; const ig=ic.getContext("2d"); // 中頻紋理(4px 方塊)+ 圓形 alpha 遮罩,模擬細緻的技能圖示 for(let by=0;by<8;by++) for(let bx=0;bx<8;bx++){ const v=bx*37+by*91; ig.fillStyle=`rgb(${(v*3)%256},${(v*7+30)%256},${(v*13+60)%256})`; ig.fillRect(bx*4,by*4,4,4); } { const d=ig.getImageData(0,0,isz,isz); for(let y=0;y196) d.data[(y*isz+x)*4+3]=0; } ig.putImageData(d,0,0); } const tpl=buildTemplate(ic,isz,isz); const a={ name:"TEST", tpl, trk:freshTrk(), monitor:true, present:true, absentSince:null, _dom:null }; // 造一張合成畫面 const FW=640, FH=360; const fc=document.createElement("canvas"); fc.width=FW; fc.height=FH; const fg=fc.getContext("2d"); // 接近遊戲場景:低相關背景(一個特定圖示不會與無關場景高度相符) function bg(seed){ fg.fillStyle="#101820"; fg.fillRect(0,0,FW,FH); for(let i=0;i<7;i++){ fg.fillStyle=`rgba(${(i*40+seed)%255},${(i*70)%255},${(i*110)%255},.22)`; fg.beginPath(); fg.arc((i*123+seed)%FW,(i*81+seed)%FH,44,0,7); fg.fill(); } } function frame({withIcon=true, x=480, y=40, scale=30, dim=1, flash=0, digit=false, seed=11}={}){ bg(seed); if(withIcon){ const t=document.createElement("canvas"); t.width=scale; t.height=scale; const tg=t.getContext("2d"); tg.drawImage(ic,0,0,scale,scale); if(dim!==1||flash){ const d=tg.getImageData(0,0,scale,scale); for(let i=0;i0.7&&z2>0.7&&Math.abs(z1-z2)<0.2, `${z1.toFixed(2)}/${z2.toFixed(2)}`); // 4) 消失 → 報警一次 → 恢復 let fired=0, recov=0; lockedRung=null; a.trk=freshTrk(); frame({x:480,y:40,scale:30}); locate(a); const t0=performance.now(); // 模擬:圖示不見,連續數幀超過 holdMs const dbg=[]; for(let k=0;k<8;k++){ frame({withIcon:false}); const b=track(a); dbg.push(`z${b.z.toFixed(2)}m${b.mad.toFixed(2)}`); decideStateTest(a,b,t0+k*200); } assert("disappear fires", a.present===false && a.trk.state==="ABSENT", `fired=${fired} [${dbg.join(' ')}]`); // 恢復 frame({x:480,y:40,scale:30}); { const b=track(a); decideStateTest(a,b,performance.now()); if(a.trk.state!=="PRESENT"){ locate(a); } } assert("reappear", a.present===true, a.trk.zncc.toFixed(2)); // 5) 變暗不誤報 lockedRung=null; a.trk=freshTrk(); frame({x:480,y:40,scale:30}); locate(a); frame({x:480,y:40,scale:30,dim:0.5}); { const b=track(a); decideStateTest(a,b,performance.now()); } assert("dim no false-positive", a.present===true, a.trk.zncc.toFixed(2)+"/mad"+a.trk.mad.toFixed(2)); // 6) 倒數數字不誤報(殘差修剪) a.trk=freshTrk(); a.trk.state="SEARCHING"; lockedRung=null; frame({x:480,y:40,scale:30}); locate(a); frame({x:480,y:40,scale:30,digit:true}); { const b=track(a); decideStateTest(a,b,performance.now()); } assert("countdown digit no FP", a.present===true, a.trk.zncc.toFixed(2)); // 7) 左移追蹤 lockedRung=null; a.trk=freshTrk(); frame({x:480,y:40,scale:30}); locate(a); let lost=false; for(let k=1;k<=6;k++){ frame({x:480-k*6,y:40,scale:30}); const b=track(a); decideStateTest(a,b,performance.now()); if(a.present===false) lost=true; } assert("left-shift tracks", !lost && a.present===true, `cx=${a.trk.cx|0}`); // 8) 平坦畫面不誤鎖 lockedRung=null; a.trk=freshTrk(); fg.fillStyle="#222"; fg.fillRect(0,0,FW,FH); frameEl=fc; srcW=FW;srcH=FH; full.width=FW; full.height=FH; roi={x:400,y:0,w:240,h:120}; ensureScratch(roi.w,roi.h); grabRoiGray(); buildIntegral(); ok=locate(a); assert("flat no false-lock", !ok && a.trk.state==="SEARCHING", a.trk.zncc.toFixed(2)); // 8b) 🎯 自動鎖定最佳位置(forceLockBest)+ 放大視窗固定右上角 lockedRung=null; a.trk=freshTrk(); let ff=frame({x:480,y:40,scale:30}); const fr=forceLockBest(a); assert("forceLockBest locks best", !!fr && a.trk.state==="PRESENT" && Math.abs((roi.x+a.trk.cx)-(ff.x+ff.scale/2))<=4, fr?((fr.z*100|0)+"% @"+fr.s):"null"); const zv=zoomView(); assert("zoomView 右上角貼齊右邊界25%x20%", zv.x===Math.round(srcW*0.75) && zv.y===0 && (zv.x+zv.w)===srcW && zv.h===Math.round(srcH*0.20), JSON.stringify(zv)); // 9) 暗黑龍王計時器(6 timers,純邏輯) assert("boss has 6", BOSS.length===6 && bt.length===6, BOSS.map(b=>b.k+b.sec).join(',')); assert("boss grid 6 cells", !!ovGrid && ovGrid.querySelectorAll(".tcell").length===6, ovGrid?ovGrid.querySelectorAll(".tcell").length:'no-grid'); assert("boss labels copied", bt[0].nm==="噴火" && bt[1].nm==="腳踩" && bt[2].nm==="左消" && bt[3].nm==="黑鎖" && bt[4].nm==="紅閃" && bt[5].nm==="下煙" && bt[3].sec===30, bt.map(t=>t.k+t.nm+t.sec).join(' ')); const _room=room, _cc=canControl; room=null; canControl=true; bossLocalStop(0); assert("boss stop shows sec", bt[0].numEl.textContent===String(bt[0].sec), bt[0].numEl.textContent); bossLocalStart(0); assert("boss start runs", bt[0].end>Date.now() && bt[0].cell.classList.contains("run")); onTimer6({action:"stop", idx:0}); assert("boss timer6 stop", bt[0].end===0); onTimer6({action:"start", idx:1}); assert("boss timer6 start", bt[1].end>Date.now()); onTimer6({action:"resetAll"}); assert("boss resetAll", bt.every(t=>t.end===0)); onTimer6({action:"state", list:[{idx:2,remain:4}]}); renderBoss(); assert("boss state sync urgent", bt[2].end>Date.now() && bt[2].cell.classList.contains("urgent")); onTimer6({action:"resetAll"}); // 時間到 → 顯示「預備」並停止(不自動重啟),等再次按鍵 bossLocalStart(0); bt[0].end=Date.now()-5; await new Promise(rs=>setTimeout(rs,350)); assert("boss 時間到顯示預備且停止", bt[0].numEl.textContent==="預備" && bt[0].ready===true && bt[0].end===0 && bt[0].cell.classList.contains("ready"), bt[0].numEl.textContent+" end="+bt[0].end); assert("boss 名稱有各自顏色", !!bt[0].cell.querySelector('.nm').style.color && !!bt[3].cell.querySelector('.nm').style.color && bt[0].cell.querySelector('.nm').style.color!==bt[3].cell.querySelector('.nm').style.color, bt[0].cell.querySelector('.nm').style.color+"/"+bt[3].cell.querySelector('.nm').style.color); assert("預備訊息含對應emoji", document.getElementById('ovMsgs').textContent.includes(bt[0].em), bt[0].em); const _pm=ovMsgs.lastElementChild, _ns=_pm&&_pm.querySelector('span[style*="color"]'); assert("預備系統訊息名稱套技能色", !!_ns && _ns.style.color!=="" && _ns.textContent===bt[0].nm, _ns?(_ns.style.color+":"+_ns.textContent):"none"); onTimer6({action:"resetAll"}); if(btTimer){ clearInterval(btTimer); btTimer=0; } room=_room; canControl=_cc; updateBossWho(); // 10) 浮窗區塊隱藏 / 暱稱6字 / Supabase隱藏 / 已擷取素材刪除鈕 const tgC=document.querySelector('.ovtg[data-sec="ovSecChat"]'); tgC.checked=false; tgC.dispatchEvent(new Event('change')); assert("浮窗可隱藏聊天室", document.getElementById('ovSecChat').style.display==='none'); tgC.checked=true; tgC.dispatchEvent(new Event('change')); assert("浮窗可顯示聊天室", document.getElementById('ovSecChat').style.display!=='none'); nickInput.value="ABCDEFGH"; assert("暱稱上限6字", nick3()==="ABCDEF", nick3()); nickInput.value=""; assert("Supabase設定已隱藏", getComputedStyle(document.querySelector('#roomOut details.supa')).display==='none'); const _fake={name:'TST', sens:50, fav:false, monitor:false, pack:false, calibrated:true, cal:{thumb:'',scale:30}, trk:freshTrk(), thumb:ic.toDataURL("image/png"), src:'', hist:[], _dom:null}; library.push(_fake); renderLib(); assert("已擷取素材有刪除/還原鈕", libEl.querySelectorAll('.lib-item .decal').length>=1); const _oc=window.confirm; window.confirm=()=>true; await decalibrate(_fake); window.confirm=_oc; assert("還原後回到原素材(cal清除+重建範本)", _fake.calibrated===false && _fake.cal===null && !!_fake.tpl); library=library.filter(x=>x!==_fake); renderLib(); // 11) PiP 相容:overlay 搬到另一個 document 後,聊天與區塊切換仍正常 const _dock=overlay.parentElement; const _doc2=document.implementation.createHTMLDocument('pip'); _doc2.body.appendChild(_doc2.adoptNode(overlay)); addRow("系統","PIPTEST","sys"); assert("PiP聊天可寫入(他document)", overlay.querySelector('#ovMsgs').textContent.includes("PIPTEST")); const _tgR=[...ovToggles].find(c=>c.getAttribute('data-sec')==='ovSecRoom'); _tgR.checked=false; applyOvToggles(); assert("PiP可隱藏區塊(他document)", overlay.querySelector('#ovSecRoom').style.display==='none'); _tgR.checked=true; applyOvToggles(); document.adoptNode(overlay); _dock.appendChild(overlay); // 還原監控環境 frameEl=null; lockedRung=null; const passed=out.filter(o=>o.pass).length; console.log(`\n===== selfTest: ${passed}/${out.length} passed =====`); return { passed, total:out.length, details:out }; // 測試用 decideState(避免動到真正 alarm 的計時器) function decideStateTest(a,b,t){ mapSensitivity(); if(b.z>=cur.Zpresent){ a.absentSince=null; if(!a.present){a.present=true;a.trk.state="PRESENT";recov++;} else a.trk.state="PRESENT"; return;} if(b.z=cur.MADgone){ if(a.absentSince==null)a.absentSince=t; if(a.present&&t-a.absentSince>=cur.holdMs){a.present=false;a.trk.state="ABSENT";fired++;} } } }; })();