OpenRA-Bench / site /index.html
yxc20098's picture
Land PR #19 static site infrastructure (rebased onto pr13-revised)
1c57350
Raw
History Blame Contribute Delete
41.2 kB
<!DOCTYPE html>
<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} &middot; ${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>&bull; ${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&&currentTool==="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&&currentTool==="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?` &mdash; ${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>