Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>OpenRA-Bench Mission Player</title> | |
| <style> | |
| :root{--bg:#0f1115;--card:#161922;--border:#2a2f3a;--text:#e6e6e6;--muted:#9aa3b2; | |
| --accent:#7497db;--green:#5fae7a;--purple:#9b8cce;--orange:#d2683c;--red:#e05252} | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| body{font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text)} | |
| button{cursor:pointer;font:inherit;border:none;border-radius:6px;padding:8px 16px} | |
| input,select{font:inherit;background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px 12px} | |
| .btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{opacity:.9} | |
| .btn-secondary{background:var(--border);color:var(--text)}.btn-secondary:hover{background:#3a4050} | |
| .btn-sm{padding:4px 10px;font-size:13px} | |
| /* Header */ | |
| header{padding:16px 24px;background:var(--card);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:16px;flex-wrap:wrap} | |
| header h1{font-size:20px;white-space:nowrap} | |
| header .progress{color:var(--muted);font-size:13px;margin-left:auto} | |
| /* Selection screen */ | |
| #selection{padding:24px;max-width:1200px;margin:0 auto} | |
| .filters{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px} | |
| .filters input{flex:1;min-width:200px} | |
| .cap-btn{padding:4px 12px;border-radius:16px;font-size:13px;border:1px solid var(--border);background:transparent;color:var(--text);transition:.2s} | |
| .cap-btn.active{color:#fff} | |
| .cap-btn[data-cap="perception"].active{background:var(--accent)} | |
| .cap-btn[data-cap="reasoning"].active{background:var(--purple)} | |
| .cap-btn[data-cap="action"].active{background:var(--green)} | |
| .cap-btn[data-cap="adversarial"].active{background:var(--orange)} | |
| .scenario-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px} | |
| .scenario-card{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px;cursor:pointer;transition:.15s} | |
| .scenario-card:hover{border-color:var(--accent);transform:translateY(-2px)} | |
| .scenario-card .title{font-weight:600;font-size:16px;margin-bottom:4px} | |
| .scenario-card .meta{font-size:12px;color:var(--muted)} | |
| .scenario-card .summary{font-size:13px;color:#c3cad6;margin-top:8px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} | |
| .badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;color:#fff;margin-right:6px} | |
| .badge-perception{background:var(--accent)}.badge-reasoning{background:var(--purple)} | |
| .badge-action{background:var(--green)}.badge-adversarial{background:var(--orange)} | |
| .status-badge{font-size:11px;padding:2px 8px;border-radius:10px;float:right} | |
| .status-not_started{background:var(--border);color:var(--muted)} | |
| .status-in_progress{background:#4a3800;color:#ffc107} | |
| .status-annotated{background:#1a3a1a;color:var(--green)} | |
| .status-complete{background:#1a2a4a;color:var(--accent)} | |
| .count-bar{text-align:center;color:var(--muted);font-size:13px;margin-bottom:16px} | |
| /* Mission page */ | |
| #mission{display:none;padding:24px;max-width:1100px;margin:0 auto} | |
| .mission-nav{display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap} | |
| .mission-header{margin-bottom:16px} | |
| .mission-header h2{font-size:22px} | |
| .mission-header .sid{color:var(--muted);font-size:12px;font-family:monospace} | |
| .panel{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:16px} | |
| .panel h3{font-size:15px;margin-bottom:8px;color:var(--accent)} | |
| .tabs{display:flex;gap:0;margin-bottom:12px} | |
| .tab{padding:6px 14px;font-size:13px;background:transparent;color:var(--muted);border:1px solid var(--border);cursor:pointer} | |
| .tab:first-child{border-radius:6px 0 0 6px}.tab:last-child{border-radius:0 6px 6px 0} | |
| .tab.active{background:var(--accent);color:#fff;border-color:var(--accent)} | |
| .obj-text{white-space:pre-wrap;font-size:14px;line-height:1.6} | |
| .diff-tabs{margin-bottom:12px} | |
| /* Map area */ | |
| .map-container{position:relative;background:#1a1d24;border:1px solid var(--border);border-radius:8px;overflow:hidden;min-height:300px;cursor:crosshair} | |
| .map-container img{display:block;max-width:100%;height:auto;user-select:none;-webkit-user-drag:none} | |
| .map-placeholder{display:flex;align-items:center;justify-content:center;min-height:300px;color:var(--muted);font-size:14px} | |
| .map-toolbar{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap;align-items:center} | |
| .tool-btn{padding:4px 10px;font-size:12px;border:1px solid var(--border);border-radius:4px;background:transparent;color:var(--text)} | |
| .tool-btn.active{background:var(--accent);color:#fff;border-color:var(--accent)} | |
| /* Annotations list */ | |
| .ann-list{max-height:200px;overflow-y:auto;font-size:13px} | |
| .ann-item{padding:6px 8px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:8px} | |
| .ann-item:last-child{border:none} | |
| .ann-dot{width:10px;height:10px;border-radius:50%;background:var(--accent);flex-shrink:0} | |
| .ann-region .ann-dot{border-radius:2px;background:var(--orange)} | |
| .ann-tags{display:flex;gap:4px;flex-wrap:wrap} | |
| .ann-tag{font-size:11px;background:var(--border);padding:1px 6px;border-radius:8px} | |
| .ann-del{color:var(--red);cursor:pointer;margin-left:auto;font-size:12px} | |
| .ann-edit{color:var(--accent);cursor:pointer;font-size:12px} | |
| /* Annotation overlay */ | |
| .ann-overlay{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none} | |
| .ann-marker{position:absolute;width:12px;height:12px;border-radius:50%;background:rgba(116,151,219,.8);border:2px solid #fff;transform:translate(-50%,-50%);pointer-events:auto;cursor:pointer} | |
| .ann-rect{position:absolute;border:2px solid rgba(210,104,60,.8);background:rgba(210,104,60,.15);pointer-events:auto;cursor:pointer} | |
| .drawing-rect{position:absolute;border:2px dashed var(--orange);background:rgba(210,104,60,.1);pointer-events:none} | |
| /* Tag picker modal */ | |
| .modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);z-index:100;align-items:center;justify-content:center} | |
| .modal-overlay.show{display:flex} | |
| .modal{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:400px;width:90%} | |
| .modal h3{margin-bottom:12px} | |
| .tag-grid{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px} | |
| .tag-opt{padding:3px 10px;border-radius:12px;font-size:12px;border:1px solid var(--border);background:transparent;color:var(--text);cursor:pointer} | |
| .tag-opt.selected{background:var(--accent);color:#fff;border-color:var(--accent)} | |
| .modal textarea{width:100%;min-height:60px;margin:8px 0;resize:vertical} | |
| .modal .actions{display:flex;gap:8px;justify-content:flex-end} | |
| @media(max-width:600px){.scenario-grid{grid-template-columns:1fr}.filters{flex-direction:column}} | |
| /* Game play panel */ | |
| .game-panel{border:2px solid var(--accent);border-radius:10px;overflow:hidden} | |
| .game-panel.inactive{border-color:var(--border)} | |
| .game-header{display:flex;align-items:center;gap:12px;padding:12px 16px;background:rgba(116,151,219,.1);flex-wrap:wrap} | |
| .game-header.inactive{background:var(--card)} | |
| .game-controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap} | |
| .game-body{padding:16px} | |
| .game-status{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px;margin-bottom:12px} | |
| .game-stat{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:8px 12px;text-align:center} | |
| .game-stat .label{font-size:11px;color:var(--muted);text-transform:uppercase} | |
| .game-stat .value{font-size:18px;font-weight:600} | |
| .game-stat .value.win{color:var(--green)}.game-stat .value.loss{color:var(--red)} | |
| .game-minimap{position:relative;background:#0a0c10;border:1px solid var(--border);border-radius:8px;overflow:hidden;margin-bottom:12px;cursor:crosshair} | |
| .game-minimap img{display:block;max-width:100%;height:auto} | |
| .game-minimap canvas{display:block;max-width:100%;height:auto;image-rendering:pixelated} | |
| .game-minimap .placeholder{display:flex;align-items:center;justify-content:center;min-height:200px;color:var(--muted);font-size:14px} | |
| .game-units{font-size:13px;max-height:180px;overflow-y:auto} | |
| .game-units table{width:100%;border-collapse:collapse} | |
| .game-units th{text-align:left;padding:4px 8px;font-size:11px;color:var(--muted);text-transform:uppercase;border-bottom:1px solid var(--border)} | |
| .game-units td{padding:4px 8px;border-bottom:1px solid var(--border)} | |
| .game-units tr.selected{background:rgba(116,151,219,.15)} | |
| .game-units tr:hover{background:rgba(255,255,255,.05);cursor:pointer} | |
| .game-log{font-size:12px;color:var(--muted);max-height:120px;overflow-y:auto;margin-top:8px;font-family:monospace;padding:8px;background:var(--bg);border-radius:6px} | |
| .server-config{display:flex;gap:8px;align-items:center;padding:8px 16px;background:var(--card);border-bottom:1px solid var(--border);font-size:13px} | |
| .server-config input{font-size:13px;padding:4px 8px;width:200px} | |
| .server-config .dot{width:8px;height:8px;border-radius:50%;background:var(--red)} | |
| .server-config .dot.connected{background:var(--green)} | |
| .btn-play{background:var(--green);color:#fff;font-weight:600}.btn-play:hover{opacity:.9} | |
| .btn-play:disabled{opacity:.4;cursor:not-allowed} | |
| .btn-stop{background:var(--red);color:#fff}.btn-stop:hover{opacity:.9} | |
| .btn-endturn{background:var(--accent);color:#fff;font-weight:600;padding:8px 24px}.btn-endturn:hover{opacity:.9} | |
| .btn-endturn:disabled{opacity:.4;cursor:not-allowed} | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>OpenRA-Bench Missions</h1> | |
| <button class="btn-secondary btn-sm" onclick="showSelection()" id="back-btn" style="display:none">All Missions</button> | |
| <span class="progress" id="hdr-progress"></span> | |
| </header> | |
| <main id="selection"> | |
| <div class="filters"> | |
| <input type="text" id="search" placeholder="Search scenarios..." oninput="renderCards()"> | |
| <div id="cap-filters"></div> | |
| </div> | |
| <div class="count-bar" id="count-bar"></div> | |
| <div class="scenario-grid" id="grid"></div> | |
| </main> | |
| <section id="mission"> | |
| <div class="mission-nav"> | |
| <button class="btn-secondary btn-sm" onclick="showSelection()">Back</button> | |
| <button class="btn-secondary btn-sm" onclick="navScenario(-1)">Prev</button> | |
| <button class="btn-primary btn-sm" onclick="navScenario(1)">Next Mission</button> | |
| <button class="btn-primary btn-sm" onclick="navUnannotated()">Next Unannotated</button> | |
| <span style="margin-left:auto;display:flex;gap:6px"> | |
| <button class="btn-secondary btn-sm" onclick="markStatus('annotated')">Mark Annotated</button> | |
| <button class="btn-secondary btn-sm" onclick="markStatus('complete')">Mark Complete</button> | |
| </span> | |
| </div> | |
| <div class="mission-header"> | |
| <h2 id="m-title"></h2> | |
| <div><span class="sid" id="m-id"></span> <span id="m-badges"></span> <span id="m-status-badge"></span></div> | |
| </div> | |
| <!-- Objective panel --> | |
| <div class="panel"> | |
| <h3>Objective</h3> | |
| <div class="tabs" id="lang-tabs"> | |
| <button class="tab active" onclick="setLang('en')">English</button> | |
| <button class="tab" onclick="setLang('zh')">Chinese</button> | |
| </div> | |
| <div class="diff-tabs" id="diff-tabs"></div> | |
| <div class="obj-text" id="m-objective"></div> | |
| </div> | |
| <!-- Annotator hints --> | |
| <div class="panel"> | |
| <h3>Annotator Hints</h3> | |
| <div id="m-hints" style="font-size:14px"></div> | |
| </div> | |
| <!-- Map --> | |
| <div class="panel"> | |
| <h3>Map</h3> | |
| <div class="map-toolbar" id="map-toolbar"> | |
| <button class="tool-btn active" data-tool="select" onclick="setTool('select')">Select</button> | |
| <button class="tool-btn" data-tool="point" onclick="setTool('point')">+ Point</button> | |
| <button class="tool-btn" data-tool="region" onclick="setTool('region')">+ Region</button> | |
| <span style="margin-left:auto;font-size:12px;color:var(--muted)" id="map-coords"></span> | |
| </div> | |
| <div class="map-container" id="map-container"> | |
| <div class="map-placeholder" id="map-ph">Map preview — requires engine to render. Annotations still work on the placeholder area.</div> | |
| <img id="map-img" style="display:none" draggable="false"> | |
| <div class="ann-overlay" id="ann-overlay"></div> | |
| <div class="drawing-rect" id="draw-rect" style="display:none"></div> | |
| </div> | |
| </div> | |
| <!-- Annotations --> | |
| <div class="panel"> | |
| <h3>Annotations <span style="font-size:12px;color:var(--muted)" id="ann-count">(0)</span></h3> | |
| <div class="ann-list" id="ann-list"><span style="color:var(--muted);font-size:13px">No annotations yet. Use the point/region tools above.</span></div> | |
| <div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap"> | |
| <button class="btn-secondary btn-sm" onclick="exportAnnotations()">Export JSON</button> | |
| <button class="btn-secondary btn-sm" onclick="document.getElementById('import-file').click()">Import JSON</button> | |
| <input type="file" id="import-file" accept=".json" style="display:none" onchange="importAnnotations(event)"> | |
| <button class="btn-secondary btn-sm" onclick="saveProgress()">Save Progress</button> | |
| </div> | |
| </div> | |
| <!-- Game play panel --> | |
| <div class="panel game-panel inactive" id="game-panel"> | |
| <h3 style="padding:12px 16px 0;margin:0">Engine Play</h3> | |
| <div class="server-config" id="server-config"> | |
| <span class="dot" id="server-dot"></span> | |
| <label>Server:</label> | |
| <input type="text" id="server-url" value="http://localhost:8765" placeholder="http://localhost:8765"> | |
| <button class="btn-secondary btn-sm" onclick="checkServer()">Connect</button> | |
| <span id="server-status" style="color:var(--muted)">Not connected</span> | |
| </div> | |
| <div class="game-header inactive" id="game-header"> | |
| <h3 style="margin:0">Play</h3> | |
| <div class="game-controls"> | |
| <select id="game-level" style="padding:4px 8px;font-size:13px"> | |
| <option value="easy">Easy</option> | |
| <option value="medium">Medium</option> | |
| <option value="hard">Hard</option> | |
| </select> | |
| <label style="font-size:13px">Seed:</label> | |
| <input type="number" id="game-seed" value="1" min="1" max="99" style="width:50px;padding:4px;font-size:13px"> | |
| <button class="btn-play btn-sm" id="game-start-btn" onclick="gameStart()" disabled>Start</button> | |
| <button class="btn-stop btn-sm" id="game-stop-btn" onclick="gameStop()" style="display:none">Stop</button> | |
| </div> | |
| </div> | |
| <div class="game-body" id="game-body" style="display:none"> | |
| <div class="game-status" id="game-stats"></div> | |
| <div style="display:flex;gap:16px;flex-wrap:wrap"> | |
| <div style="flex:1;min-width:280px"> | |
| <div class="game-minimap" id="game-minimap" onclick="gameMinimapClick(event)"> | |
| <div class="placeholder" id="game-minimap-ph">Start a game to see the minimap</div> | |
| <img id="game-minimap-img" style="display:none" draggable="false"> | |
| </div> | |
| <div style="display:flex;gap:8px;justify-content:center"> | |
| <button class="btn-secondary btn-sm" onclick="gameClearSelection()">Clear Selection</button> | |
| <button class="btn-endturn" id="game-endturn-btn" onclick="gameEndTurn()" disabled>End Turn</button> | |
| </div> | |
| </div> | |
| <div style="flex:1;min-width:280px"> | |
| <div class="game-units" id="game-units"></div> | |
| <div class="game-log" id="game-log"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- Tag picker modal --> | |
| <div class="modal-overlay" id="tag-modal"> | |
| <div class="modal"> | |
| <h3 id="modal-title">Add Annotation</h3> | |
| <div class="tag-grid" id="tag-grid"></div> | |
| <textarea id="ann-note" placeholder="Notes (optional)..."></textarea> | |
| <div class="actions"> | |
| <button class="btn-secondary btn-sm" onclick="cancelModal()">Cancel</button> | |
| <button class="btn-primary btn-sm" onclick="confirmModal()">Save</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const TAGS=["spawn","enemy-spawn","objective","fail-condition","hidden-threat","chokepoint", | |
| "safe-route","danger-zone","resource","defense-position","attack-path","ambush", | |
| "scouting-target","timing-sensitive","micro-intensive","macro-intensive","unclear","bug","interesting","needs-review"]; | |
| const CAPS=["perception","reasoning","action","adversarial"]; | |
| let scenarios=[], filtered=[], currentIdx=-1, currentLang="en", currentDiff="easy"; | |
| let activeCaps=new Set(CAPS), currentTool="select"; | |
| let annotations={}, progress={}; | |
| let drawStart=null, pendingAnn=null, editingAnnId=null; | |
| // ── Data loading ── | |
| async function loadData(){ | |
| try{ | |
| const r=await fetch("public/scenarios.json"); | |
| scenarios=await r.json(); | |
| }catch(e){scenarios=[];} | |
| loadProgress(); | |
| renderCapFilters(); | |
| renderCards(); | |
| updateHeaderProgress(); | |
| // Check URL hash | |
| if(location.hash.startsWith("#mission/")){ | |
| const id=location.hash.slice(9); | |
| const idx=scenarios.findIndex(s=>s.scenarioId===id); | |
| if(idx>=0)openMission(idx); | |
| } | |
| } | |
| // ── Progress persistence (localStorage) ── | |
| function loadProgress(){ | |
| try{ | |
| progress=JSON.parse(localStorage.getItem("openra-progress")||"{}"); | |
| annotations=JSON.parse(localStorage.getItem("openra-annotations")||"{}"); | |
| }catch(e){progress={};annotations={};} | |
| } | |
| function saveProgress(){ | |
| localStorage.setItem("openra-progress",JSON.stringify(progress)); | |
| localStorage.setItem("openra-annotations",JSON.stringify(annotations)); | |
| updateHeaderProgress(); | |
| alert("Progress saved."); | |
| } | |
| function getStatus(id){return progress[id]||"not_started";} | |
| function markStatus(s){ | |
| if(currentIdx<0)return; | |
| const id=scenarios[currentIdx].scenarioId; | |
| progress[id]=s; | |
| saveProgress(); | |
| renderMission(); | |
| renderCards(); | |
| } | |
| // ── Header progress ── | |
| function updateHeaderProgress(){ | |
| const total=scenarios.length; | |
| const done=Object.values(progress).filter(s=>s==="complete"||s==="annotated").length; | |
| document.getElementById("hdr-progress").textContent=`${done}/${total} completed`; | |
| } | |
| // ── Selection screen ── | |
| function renderCapFilters(){ | |
| const el=document.getElementById("cap-filters"); | |
| el.innerHTML=CAPS.map(c=>`<button class="cap-btn ${activeCaps.has(c)?'active':''}" data-cap="${c}" onclick="toggleCap('${c}')">${c}</button>`).join(""); | |
| } | |
| function toggleCap(c){ | |
| if(activeCaps.has(c))activeCaps.delete(c);else activeCaps.add(c); | |
| renderCapFilters();renderCards(); | |
| } | |
| function renderCards(){ | |
| const q=(document.getElementById("search").value||"").toLowerCase(); | |
| filtered=scenarios.filter(s=>{ | |
| if(!activeCaps.has(s.capability))return false; | |
| if(q&&!s.scenarioId.toLowerCase().includes(q)&&!s.title.toLowerCase().includes(q)&& | |
| !(s.humanReadable.summary.en||"").toLowerCase().includes(q))return false; | |
| return true; | |
| }); | |
| document.getElementById("count-bar").textContent=`Showing ${filtered.length} of ${scenarios.length} scenarios`; | |
| const grid=document.getElementById("grid"); | |
| grid.innerHTML=filtered.map((s,i)=>{ | |
| const st=getStatus(s.scenarioId); | |
| const stLabel=st.replace("_"," "); | |
| return `<div class="scenario-card" onclick="openMissionFiltered(${i})"> | |
| <div><span class="badge badge-${s.capability}">${s.capabilityLabel.en}</span> | |
| <span class="status-badge status-${st}">${stLabel}</span></div> | |
| <div class="title">${esc(s.title)}</div> | |
| <div class="meta">${s.scenarioId} · ${s.map.baseMap}</div> | |
| <div class="summary">${esc(s.humanReadable.summary.en)}</div> | |
| </div>`; | |
| }).join(""); | |
| } | |
| function openMissionFiltered(fi){ | |
| const s=filtered[fi]; | |
| const idx=scenarios.indexOf(s); | |
| openMission(idx); | |
| } | |
| // ── Mission page ── | |
| function showSelection(){ | |
| document.getElementById("selection").style.display=""; | |
| document.getElementById("mission").style.display="none"; | |
| document.getElementById("back-btn").style.display="none"; | |
| location.hash=""; | |
| renderCards(); | |
| } | |
| function openMission(idx){ | |
| console.log("openMission called with idx="+idx+" scenarios.length="+scenarios.length); | |
| if(idx<0||idx>=scenarios.length){console.log("idx out of range, returning");return;} | |
| currentIdx=idx; | |
| if(getStatus(scenarios[idx].scenarioId)==="not_started"){ | |
| progress[scenarios[idx].scenarioId]="in_progress"; | |
| localStorage.setItem("openra-progress",JSON.stringify(progress)); | |
| } | |
| document.getElementById("selection").style.display="none"; | |
| document.getElementById("mission").style.display="block"; | |
| document.getElementById("back-btn").style.display=""; | |
| location.hash="mission/"+scenarios[idx].scenarioId; | |
| renderMission(); | |
| updateHeaderProgress(); | |
| } | |
| function renderMission(){ | |
| const s=scenarios[currentIdx]; | |
| if(!s){document.getElementById("mission").innerHTML='<p style="padding:24px;color:var(--red)">Error: scenario not found at index '+currentIdx+'</p>';return;} | |
| try{ _renderMissionInner(s); }catch(e){ document.getElementById("mission").innerHTML='<p style="padding:24px;color:var(--red)">Render error: '+e.message+'</p>'; console.error(e); } | |
| } | |
| function _renderMissionInner(s){ | |
| document.getElementById("m-title").textContent=s.title; | |
| document.getElementById("m-id").textContent=s.scenarioId; | |
| const st=getStatus(s.scenarioId); | |
| document.getElementById("m-badges").innerHTML= | |
| `<span class="badge badge-${s.capability}">${s.capabilityLabel[currentLang]||s.capability}</span>`; | |
| document.getElementById("m-status-badge").innerHTML= | |
| `<span class="status-badge status-${st}">${st.replace("_"," ")}</span>`; | |
| renderDiffTabs(); | |
| renderObjective(); | |
| renderHints(); | |
| renderMap(); | |
| renderAnnotations(); | |
| } | |
| function setLang(l){ | |
| currentLang=l; | |
| document.querySelectorAll("#lang-tabs .tab").forEach(t=>t.classList.toggle("active",t.textContent.toLowerCase().startsWith(l==="en"?"e":"c"))); | |
| renderObjective();renderHints(); | |
| } | |
| function setDiff(d){ | |
| currentDiff=d; | |
| document.querySelectorAll("#diff-tabs .tab").forEach(t=>t.classList.toggle("active",t.dataset.diff===d)); | |
| renderObjective(); | |
| } | |
| function renderDiffTabs(){ | |
| const s=scenarios[currentIdx]; | |
| const diffs=Object.keys(s.levels); | |
| document.getElementById("diff-tabs").innerHTML=diffs.map(d=> | |
| `<button class="tab ${d===currentDiff?'active':''}" data-diff="${d}" onclick="setDiff('${d}')">${d}</button>` | |
| ).join(""); | |
| } | |
| function renderObjective(){ | |
| const s=scenarios[currentIdx]; | |
| const lv=s.levels[currentDiff]; | |
| if(!lv||lv.error){ | |
| document.getElementById("m-objective").textContent=lv?lv.error:"Level not available"; | |
| return; | |
| } | |
| document.getElementById("m-objective").textContent=lv.objective[currentLang]||lv.objective.en||""; | |
| } | |
| function renderHints(){ | |
| const s=scenarios[currentIdx]; | |
| const hints=s.humanReadable.annotatorHints[currentLang]||s.humanReadable.annotatorHints.en||[]; | |
| document.getElementById("m-hints").innerHTML=hints.map(h=>`<div>• ${esc(h)}</div>`).join(""); | |
| } | |
| // ── Map ── | |
| function renderMap(){ | |
| const s=scenarios[currentIdx]; | |
| const img=document.getElementById("map-img"); | |
| const ph=document.getElementById("map-ph"); | |
| const src=s.map.src; | |
| img.onload=()=>{img.style.display="block";ph.style.display="none";}; | |
| img.onerror=()=>{img.style.display="none";ph.style.display="flex";}; | |
| img.src=src; | |
| } | |
| function setTool(t){ | |
| currentTool=t; | |
| document.querySelectorAll(".tool-btn").forEach(b=>b.classList.toggle("active",b.dataset.tool===t)); | |
| const c=document.getElementById("map-container"); | |
| c.style.cursor=t==="select"?"default":"crosshair"; | |
| } | |
| // ── Map interactions ── | |
| (function(){ | |
| const container=document.getElementById("map-container"); | |
| const drawRect=document.getElementById("draw-rect"); | |
| function normCoords(e){ | |
| const r=container.getBoundingClientRect(); | |
| return{x:Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)), | |
| y:Math.max(0,Math.min(1,(e.clientY-r.top)/r.height))}; | |
| } | |
| container.addEventListener("mousedown",function(e){ | |
| if(currentIdx<0)return; | |
| const c=normCoords(e); | |
| document.getElementById("map-coords").textContent=`(${c.x.toFixed(3)}, ${c.y.toFixed(3)})`; | |
| if(currentTool==="point"){ | |
| pendingAnn={type:"point",coordinates:{space:"normalized",points:[{x:c.x,y:c.y}]}}; | |
| openTagModal("Add Point Annotation"); | |
| }else if(currentTool==="region"){ | |
| drawStart=c; | |
| drawRect.style.display="block"; | |
| drawRect.style.left=(c.x*100)+"%";drawRect.style.top=(c.y*100)+"%"; | |
| drawRect.style.width="0";drawRect.style.height="0"; | |
| } | |
| }); | |
| container.addEventListener("mousemove",function(e){ | |
| const c=normCoords(e); | |
| document.getElementById("map-coords").textContent=`(${c.x.toFixed(3)}, ${c.y.toFixed(3)})`; | |
| if(drawStart&¤tTool==="region"){ | |
| const x1=Math.min(drawStart.x,c.x),y1=Math.min(drawStart.y,c.y); | |
| const x2=Math.max(drawStart.x,c.x),y2=Math.max(drawStart.y,c.y); | |
| drawRect.style.left=(x1*100)+"%";drawRect.style.top=(y1*100)+"%"; | |
| drawRect.style.width=((x2-x1)*100)+"%";drawRect.style.height=((y2-y1)*100)+"%"; | |
| } | |
| }); | |
| container.addEventListener("mouseup",function(e){ | |
| if(drawStart&¤tTool==="region"){ | |
| const c=normCoords(e); | |
| const x1=Math.min(drawStart.x,c.x),y1=Math.min(drawStart.y,c.y); | |
| const x2=Math.max(drawStart.x,c.x),y2=Math.max(drawStart.y,c.y); | |
| drawRect.style.display="none"; | |
| drawStart=null; | |
| if(Math.abs(x2-x1)>0.01||Math.abs(y2-y1)>0.01){ | |
| pendingAnn={type:"region",coordinates:{space:"normalized",points:[{x:x1,y:y1},{x:x2,y:y2}]}}; | |
| openTagModal("Add Region Annotation"); | |
| } | |
| } | |
| }); | |
| })(); | |
| // ── Annotations CRUD ── | |
| function getAnns(){ | |
| if(currentIdx<0)return[]; | |
| const id=scenarios[currentIdx].scenarioId; | |
| return annotations[id]||[]; | |
| } | |
| function setAnns(arr){ | |
| if(currentIdx<0)return; | |
| annotations[scenarios[currentIdx].scenarioId]=arr; | |
| localStorage.setItem("openra-annotations",JSON.stringify(annotations)); | |
| } | |
| function addAnnotation(ann){ | |
| const arr=getAnns(); | |
| ann.id=Date.now().toString(36)+Math.random().toString(36).slice(2,6); | |
| ann.createdAt=new Date().toISOString(); | |
| ann.updatedAt=ann.createdAt; | |
| arr.push(ann); | |
| setAnns(arr); | |
| renderAnnotations(); | |
| } | |
| function deleteAnnotation(annId){ | |
| setAnns(getAnns().filter(a=>a.id!==annId)); | |
| renderAnnotations(); | |
| } | |
| function editAnnotation(annId){ | |
| const ann=getAnns().find(a=>a.id===annId); | |
| if(!ann)return; | |
| editingAnnId=annId; | |
| pendingAnn={type:ann.type,coordinates:ann.coordinates}; | |
| openTagModal("Edit Annotation",ann.tags||[],ann.note||""); | |
| } | |
| function renderAnnotations(){ | |
| const anns=getAnns(); | |
| document.getElementById("ann-count").textContent=`(${anns.length})`; | |
| const overlay=document.getElementById("ann-overlay"); | |
| overlay.innerHTML=""; | |
| const list=document.getElementById("ann-list"); | |
| if(!anns.length){list.innerHTML='<span style="color:var(--muted);font-size:13px">No annotations yet.</span>';return;} | |
| list.innerHTML=anns.map(a=>{ | |
| const tags=(a.tags||[]).map(t=>`<span class="ann-tag">${esc(t)}</span>`).join(""); | |
| const note=a.note?` — ${esc(a.note.slice(0,40))}`:""; | |
| return `<div class="ann-item ${a.type==='region'?'ann-region':''}"> | |
| <span class="ann-dot"></span> | |
| <span>${a.type} (${a.coordinates.points.map(p=>`${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" - ")})</span> | |
| <span class="ann-tags">${tags}</span>${note} | |
| <span class="ann-edit" onclick="editAnnotation('${a.id}')">edit</span> | |
| <span class="ann-del" onclick="deleteAnnotation('${a.id}')">del</span> | |
| </div>`; | |
| }).join(""); | |
| // Overlay markers | |
| anns.forEach(a=>{ | |
| if(a.type==="point"){ | |
| const m=document.createElement("div");m.className="ann-marker"; | |
| m.style.left=(a.coordinates.points[0].x*100)+"%"; | |
| m.style.top=(a.coordinates.points[0].y*100)+"%"; | |
| m.title=(a.tags||[]).join(", ")+(a.note?" - "+a.note:""); | |
| m.onclick=()=>editAnnotation(a.id); | |
| overlay.appendChild(m); | |
| }else if(a.type==="region"){ | |
| const p=a.coordinates.points; | |
| const r=document.createElement("div");r.className="ann-rect"; | |
| r.style.left=(p[0].x*100)+"%";r.style.top=(p[0].y*100)+"%"; | |
| r.style.width=((p[1].x-p[0].x)*100)+"%";r.style.height=((p[1].y-p[0].y)*100)+"%"; | |
| r.title=(a.tags||[]).join(", ")+(a.note?" - "+a.note:""); | |
| r.onclick=()=>editAnnotation(a.id); | |
| overlay.appendChild(r); | |
| } | |
| }); | |
| } | |
| // ── Tag picker modal ── | |
| function openTagModal(title,preselected,preNote){ | |
| editingAnnId=editingAnnId||null; | |
| document.getElementById("modal-title").textContent=title; | |
| const grid=document.getElementById("tag-grid"); | |
| const sel=new Set(preselected||[]); | |
| grid.innerHTML=TAGS.map(t=>`<button class="tag-opt ${sel.has(t)?'selected':''}" data-tag="${t}" onclick="this.classList.toggle('selected')">${t}</button>`).join("") | |
| +`<input type="text" id="custom-tag" placeholder="custom tag..." style="width:100px;font-size:12px;padding:3px 8px">`; | |
| document.getElementById("ann-note").value=preNote||""; | |
| document.getElementById("tag-modal").classList.add("show"); | |
| } | |
| function cancelModal(){ | |
| document.getElementById("tag-modal").classList.remove("show"); | |
| pendingAnn=null;editingAnnId=null; | |
| } | |
| function confirmModal(){ | |
| const tags=[...document.querySelectorAll(".tag-opt.selected")].map(b=>b.dataset.tag); | |
| const custom=document.getElementById("custom-tag").value.trim(); | |
| if(custom)tags.push(custom); | |
| const note=document.getElementById("ann-note").value.trim(); | |
| if(editingAnnId){ | |
| const anns=getAnns(); | |
| const ann=anns.find(a=>a.id===editingAnnId); | |
| if(ann){ann.tags=tags;ann.note=note;ann.updatedAt=new Date().toISOString();} | |
| setAnns(anns); | |
| editingAnnId=null; | |
| }else if(pendingAnn){ | |
| addAnnotation({...pendingAnn,tags,note}); | |
| } | |
| pendingAnn=null; | |
| document.getElementById("tag-modal").classList.remove("show"); | |
| renderAnnotations(); | |
| } | |
| // ── Navigation ── | |
| function navScenario(delta){ | |
| const next=currentIdx+delta; | |
| if(next<0){alert("This is the first scenario.");return;} | |
| if(next>=scenarios.length){alert("This is the last scenario. All missions reviewed!");return;} | |
| openMission(next); | |
| } | |
| function navUnannotated(){ | |
| for(let i=currentIdx+1;i<scenarios.length;i++){ | |
| const st=getStatus(scenarios[i].scenarioId); | |
| if(st==="not_started"||st==="in_progress"){openMission(i);return;} | |
| } | |
| for(let i=0;i<currentIdx;i++){ | |
| const st=getStatus(scenarios[i].scenarioId); | |
| if(st==="not_started"||st==="in_progress"){openMission(i);return;} | |
| } | |
| alert("All scenarios are annotated or complete!"); | |
| } | |
| // ── Export / Import ── | |
| function exportAnnotations(){ | |
| const data={progress,annotations,exportedAt:new Date().toISOString()}; | |
| const blob=new Blob([JSON.stringify(data,null,2)],{type:"application/json"}); | |
| const a=document.createElement("a");a.href=URL.createObjectURL(blob); | |
| a.download="openra-annotations-"+new Date().toISOString().slice(0,10)+".json"; | |
| a.click();URL.revokeObjectURL(a.href); | |
| } | |
| function importAnnotations(e){ | |
| const file=e.target.files[0];if(!file)return; | |
| const reader=new FileReader(); | |
| reader.onload=function(ev){ | |
| try{ | |
| const data=JSON.parse(ev.target.result); | |
| if(data.annotations)Object.assign(annotations,data.annotations); | |
| if(data.progress)Object.assign(progress,data.progress); | |
| localStorage.setItem("openra-progress",JSON.stringify(progress)); | |
| localStorage.setItem("openra-annotations",JSON.stringify(annotations)); | |
| renderCards();if(currentIdx>=0)renderAnnotations(); | |
| updateHeaderProgress(); | |
| alert("Imported successfully."); | |
| }catch(err){alert("Invalid JSON file.");} | |
| }; | |
| reader.readAsText(file); | |
| e.target.value=""; | |
| } | |
| function esc(s){const d=document.createElement("div");d.textContent=s||"";return d.innerHTML;} | |
| // ── Game engine integration ── | |
| let gameSession=null, gameServerUrl="", gameServerOk=false; | |
| let gameSelected=new Set(), gameQueue=[], gameState=null; | |
| async function checkServer(){ | |
| const url=document.getElementById("server-url").value.replace(/\/$/,""); | |
| const dot=document.getElementById("server-dot"); | |
| const status=document.getElementById("server-status"); | |
| const btn=document.getElementById("game-start-btn"); | |
| try{ | |
| const r=await fetch(url+"/api/health",{signal:AbortSignal.timeout(3000)}); | |
| if(r.ok){ | |
| gameServerUrl=url; gameServerOk=true; | |
| dot.classList.add("connected"); | |
| status.textContent="Connected";status.style.color="var(--green)"; | |
| btn.disabled=false; | |
| document.getElementById("game-panel").classList.remove("inactive"); | |
| document.getElementById("game-header").classList.remove("inactive"); | |
| }else throw new Error(r.statusText); | |
| }catch(e){ | |
| gameServerOk=false; | |
| dot.classList.remove("connected"); | |
| status.textContent="Failed: "+e.message;status.style.color="var(--red)"; | |
| btn.disabled=true; | |
| } | |
| } | |
| async function gameStart(){ | |
| if(!gameServerOk||currentIdx<0)return; | |
| const s=scenarios[currentIdx]; | |
| const level=document.getElementById("game-level").value; | |
| const seed=parseInt(document.getElementById("game-seed").value)||1; | |
| gameLog("Starting "+s.scenarioId+" / "+level+" / seed "+seed+"..."); | |
| try{ | |
| const r=await fetch(gameServerUrl+"/api/game/start",{ | |
| method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({pack_id:s.scenarioId,level,seed}) | |
| }); | |
| if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText);} | |
| gameState=await r.json(); | |
| gameSession=gameState.session_id; | |
| gameSelected.clear(); gameQueue=[]; | |
| document.getElementById("game-body").style.display=""; | |
| document.getElementById("game-start-btn").style.display="none"; | |
| document.getElementById("game-stop-btn").style.display=""; | |
| document.getElementById("game-endturn-btn").disabled=false; | |
| document.getElementById("game-panel").classList.remove("inactive"); | |
| document.getElementById("game-header").classList.remove("inactive"); | |
| gameRender(); | |
| gameLog("Game started. Turn 0/"+gameState.max_turns+". "+gameState.objective); | |
| }catch(e){ | |
| gameLog("ERROR: "+e.message); | |
| } | |
| } | |
| async function gameStop(){ | |
| if(!gameSession)return; | |
| try{ | |
| await fetch(gameServerUrl+"/api/game/stop/"+gameSession,{method:"POST"}); | |
| }catch(e){} | |
| gameSession=null; gameState=null; | |
| gameSelected.clear(); gameQueue=[]; | |
| document.getElementById("game-body").style.display="none"; | |
| document.getElementById("game-start-btn").style.display=""; | |
| document.getElementById("game-stop-btn").style.display="none"; | |
| document.getElementById("game-start-btn").disabled=!gameServerOk; | |
| gameLog("Game stopped."); | |
| } | |
| async function gameEndTurn(){ | |
| if(!gameSession||!gameState||gameState.done)return; | |
| document.getElementById("game-endturn-btn").disabled=true; | |
| const actions=gameQueue.length?gameQueue:[{mode:"observe"}]; | |
| try{ | |
| const r=await fetch(gameServerUrl+"/api/game/step",{ | |
| method:"POST",headers:{"Content-Type":"application/json"}, | |
| body:JSON.stringify({session_id:gameSession,actions}) | |
| }); | |
| if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText);} | |
| gameState=await r.json(); | |
| gameSelected.clear(); gameQueue=[]; | |
| gameRender(); | |
| if(gameState.done){ | |
| gameLog("GAME OVER — "+gameState.outcome.toUpperCase()+" at turn "+gameState.turn); | |
| document.getElementById("game-endturn-btn").disabled=true; | |
| }else{ | |
| gameLog("Turn "+gameState.turn+"/"+gameState.max_turns+ | |
| " | Explored: "+gameState.explored_percent+"%"+ | |
| " | Kills: "+gameState.units_killed+" | Lost: "+gameState.units_lost); | |
| document.getElementById("game-endturn-btn").disabled=false; | |
| } | |
| }catch(e){ | |
| gameLog("ERROR: "+e.message); | |
| document.getElementById("game-endturn-btn").disabled=false; | |
| } | |
| } | |
| function gameMinimapClick(e){ | |
| if(!gameState||gameState.done)return; | |
| const container=document.getElementById("game-minimap"); | |
| const img=document.getElementById("game-minimap-img"); | |
| if(img.style.display==="none")return; | |
| const rect=img.getBoundingClientRect(); | |
| const nx=(e.clientX-rect.left)/rect.width; | |
| const ny=(e.clientY-rect.top)/rect.height; | |
| const ascii=gameState.minimap_ascii||""; | |
| const rows=ascii.split("\n").filter(r=>r.length>0); | |
| if(!rows.length)return; | |
| const mh=rows.length, mw=Math.max(...rows.map(r=>r.length)); | |
| const cx=Math.round(nx*mw), cy=Math.round(ny*mh); | |
| // Find own unit at this cell | |
| const ownHere=gameState.units.filter(u=>Math.abs(u.cell_x-cx)<=1&&Math.abs(u.cell_y-cy)<=1); | |
| const enemyHere=gameState.enemies.filter(u=>Math.abs(u.cell_x-cx)<=1&&Math.abs(u.cell_y-cy)<=1); | |
| if(ownHere.length>0){ | |
| ownHere.forEach(u=>{ | |
| if(gameSelected.has(u.id))gameSelected.delete(u.id); | |
| else gameSelected.add(u.id); | |
| }); | |
| gameLog("Selected: ["+[...gameSelected].join(",")+"]"); | |
| }else if(gameSelected.size>0&&enemyHere.length>0){ | |
| const eid=enemyHere[0].id; | |
| gameQueue.push({mode:"attack",unit_ids:[...gameSelected],target_x:cx,target_y:cy,target_id:eid}); | |
| gameLog("Queued ATTACK on enemy "+eid+" at ("+cx+","+cy+") with "+gameSelected.size+" unit(s)"); | |
| }else if(gameSelected.size>0){ | |
| gameQueue.push({mode:"move",unit_ids:[...gameSelected],target_x:cx,target_y:cy}); | |
| gameLog("Queued MOVE to ("+cx+","+cy+") with "+gameSelected.size+" unit(s)"); | |
| } | |
| gameRenderUnits(); | |
| } | |
| function gameClearSelection(){ | |
| gameSelected.clear(); gameQueue=[]; | |
| gameLog("Selection and queue cleared."); | |
| gameRenderUnits(); | |
| } | |
| function gameRender(){ | |
| if(!gameState)return; | |
| // Stats bar | |
| const stats=document.getElementById("game-stats"); | |
| const oc=gameState.outcome==="win"?"win":gameState.outcome==="loss"?"loss":""; | |
| stats.innerHTML=` | |
| <div class="game-stat"><div class="label">Turn</div><div class="value">${gameState.turn}/${gameState.max_turns}</div></div> | |
| <div class="game-stat"><div class="label">Outcome</div><div class="value ${oc}">${gameState.done?gameState.outcome.toUpperCase():"Playing"}</div></div> | |
| <div class="game-stat"><div class="label">Explored</div><div class="value">${gameState.explored_percent}%</div></div> | |
| <div class="game-stat"><div class="label">Kills</div><div class="value">${gameState.units_killed}</div></div> | |
| <div class="game-stat"><div class="label">Lost</div><div class="value">${gameState.units_lost}</div></div> | |
| <div class="game-stat"><div class="label">Cash</div><div class="value">${gameState.cash}</div></div> | |
| `; | |
| // Minimap | |
| const img=document.getElementById("game-minimap-img"); | |
| const ph=document.getElementById("game-minimap-ph"); | |
| if(gameState.minimap_b64){ | |
| img.src="data:image/png;base64,"+gameState.minimap_b64; | |
| img.style.display="block"; ph.style.display="none"; | |
| }else if(gameState.minimap_ascii){ | |
| gameRenderAsciiMinimap(gameState.minimap_ascii); | |
| img.style.display="none"; ph.style.display="none"; | |
| }else{ | |
| img.style.display="none"; ph.style.display="flex"; | |
| ph.textContent="No minimap available"; | |
| } | |
| gameRenderUnits(); | |
| } | |
| function gameRenderAsciiMinimap(ascii){ | |
| const rows=ascii.split("\n").filter(r=>r.length>0); | |
| if(!rows.length)return; | |
| const mh=rows.length, mw=Math.max(...rows.map(r=>r.length)); | |
| const scale=4; | |
| let canvas=document.getElementById("game-minimap-canvas"); | |
| if(!canvas){ | |
| canvas=document.createElement("canvas"); | |
| canvas.id="game-minimap-canvas"; | |
| document.getElementById("game-minimap").insertBefore(canvas,document.getElementById("game-minimap-ph")); | |
| } | |
| canvas.width=mw*scale; canvas.height=mh*scale; | |
| canvas.style.display="block"; | |
| const ctx=canvas.getContext("2d"); | |
| const colors={"#":"#333",".":"#1a1d24","~":"#2a4a6a","^":"#2a3a1a","@":"#5fae7a", | |
| "E":"#e05252","B":"#d2683c","b":"#7497db","T":"#9b8cce","*":"#ffcd75"," ":"#0a0c10"}; | |
| for(let y=0;y<mh;y++){ | |
| for(let x=0;x<mw;x++){ | |
| const ch=x<rows[y].length?rows[y][x]:" "; | |
| ctx.fillStyle=colors[ch]||"#1a1d24"; | |
| ctx.fillRect(x*scale,y*scale,scale,scale); | |
| } | |
| } | |
| } | |
| function gameRenderUnits(){ | |
| if(!gameState)return; | |
| const el=document.getElementById("game-units"); | |
| let html='<table><tr><th></th><th>ID</th><th>Type</th><th>Pos</th><th>HP</th><th>Activity</th></tr>'; | |
| for(const u of gameState.units){ | |
| const sel=gameSelected.has(u.id); | |
| html+=`<tr class="${sel?'selected':''}" onclick="gameToggleUnit('${u.id}')"> | |
| <td>${sel?"▶":""}</td><td>${u.id}</td><td>${u.type}</td> | |
| <td>(${u.cell_x},${u.cell_y})</td><td>${u.hp}</td><td>${u.activity||"idle"}</td></tr>`; | |
| } | |
| if(gameState.enemies.length){ | |
| html+='<tr><th colspan="6" style="color:var(--red);padding-top:8px">Visible Enemies</th></tr>'; | |
| for(const e of gameState.enemies){ | |
| html+=`<tr style="color:var(--red)"><td></td><td>${e.id}</td><td>${e.type}</td> | |
| <td>(${e.cell_x},${e.cell_y})</td><td>${e.hp}</td><td></td></tr>`; | |
| } | |
| } | |
| html+='</table>'; | |
| if(gameQueue.length){ | |
| html+='<div style="margin-top:8px;font-size:12px;color:var(--accent)">Queued: '+ | |
| gameQueue.map(a=>a.mode+'('+((a.target_x!=null)?a.target_x+','+a.target_y:'')+')' | |
| ).join(', ')+'</div>'; | |
| } | |
| el.innerHTML=html; | |
| } | |
| function gameToggleUnit(uid){ | |
| if(gameSelected.has(uid))gameSelected.delete(uid); | |
| else gameSelected.add(uid); | |
| gameRenderUnits(); | |
| } | |
| function gameLog(msg){ | |
| const el=document.getElementById("game-log"); | |
| const time=new Date().toLocaleTimeString(); | |
| el.innerHTML+=`<div>[${time}] ${esc(msg)}</div>`; | |
| el.scrollTop=el.scrollHeight; | |
| } | |
| loadData(); | |
| </script> | |
| </body> | |
| </html> | |