edu-boardgame-generator/js/board.js

307 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* board.js — Mini-Game-Palette, Spielfeld-Aufbau, Story Drag&Drop */
function palTab(t){
document.querySelectorAll('.pal-tab').forEach((x,i)=>x.classList.toggle('active',(i===0&&t==='games')||(i===1&&t==='story')));
document.getElementById('panGames').classList.toggle('active',t==='games');
document.getElementById('panStory').classList.toggle('active',t==='story');
}
function getUC(id){return ST.fields.filter(f=>f===id).length;}
function mgDis(mg){
if(mg.requires==='2p' && ST.playerCount!==2) return true; // nur in 2P verfügbar
const c=getUC(mg.id);
return mg.multi===1?c>=1:mg.multi===3?c>=MULTI_MAX:false;
}
function mgLockReason(mg){
if(mg.requires==='2p' && ST.playerCount!==2) return '2-Spieler-Modus nötig';
return '';
}
function sCnt(){return ST.storyItems.length;}
function sMaxed(){return ST.storyItems.length>=STORY_MAX;}
function buildPalette(){
const pan=document.getElementById('panGames');pan.innerHTML='';
MINIGAMES.forEach(mg=>{
const tagCls=mg.requires==='2p'?'requires2p':mg.multi==='∞'?'unlim':mg.multi===3?'multi':'once';
const tagTxt=mg.requires==='2p'?'2P':mg.multi==='∞'?'∞':mg.multi===3?'max.3×':'1×';
const c=document.createElement('div');c.className='mg-card';c.id='mgc_'+mg.id;c.draggable=true;
const lockHint=mg.requires==='2p'?`<div class="mg-lock" id="mglk_${mg.id}">🔒 2-Spieler-Modus</div>`:'';
c.innerHTML=`<div class="use-count" id="uc_${mg.id}">0</div><div class="mg-emoji">${mg.e}</div><div class="mg-lbl">${mg.n}</div><div class="mg-tag ${tagCls}">${tagTxt}</div>${lockHint}<button class="mg-test-btn" onclick="openMGTest('${mg.id}',event)">▶ Test</button>`;
c.addEventListener('click',()=>{if(c.classList.contains('disabled'))return;if(selGame===mg.id){selGame=null;c.classList.remove('click-sel');}else{clearSel();selGame=mg.id;c.classList.add('click-sel');}});
c.addEventListener('dragstart',e=>{
if(c.classList.contains('disabled')){e.preventDefault();return;}
dragging='game'; dragId=mg.id; clearSel();
e.dataTransfer.effectAllowed='copy';
e.dataTransfer.setData('text/plain','game:'+mg.id);
// Use the card itself as drag image — offset so cursor is in center
e.dataTransfer.setDragImage(c, c.offsetWidth/2, c.offsetHeight/2);
ghost(mg.e+' '+mg.n);
});
c.addEventListener('dragend',()=>{dragging=null;dragId=null;unghost();cleanDropTargets();});
pan.appendChild(c);
});
const sc=document.getElementById('storyCard');
sc.ondragstart=e=>{
if(sMaxed()){e.preventDefault();return;}
dragging='story'; dragId='story'; clearSel(); selGame='story';
e.dataTransfer.effectAllowed='copy';
e.dataTransfer.setData('text/plain','story');
e.dataTransfer.setDragImage(sc, sc.offsetWidth/2, sc.offsetHeight/2);
ghost('📖 Erzähl-Text');
};
sc.ondragend=()=>{dragging=null;dragId=null;unghost();cleanDropTargets();};
updPal();
}
function clearSel(){selGame=null;document.querySelectorAll('.mg-card').forEach(c=>c.classList.remove('click-sel'));}
function cleanDropTargets(){
document.querySelectorAll('.drop-zone').forEach(z=>{z.classList.remove('story-hoverable','drag-over');});
document.querySelectorAll('.field-row').forEach(r=>r.classList.remove('drag-target'));
}
function updPal(){
MINIGAMES.forEach(mg=>{
const c=document.getElementById('mgc_'+mg.id);const uc=document.getElementById('uc_'+mg.id);if(!c)return;
const cnt=getUC(mg.id);uc.textContent=cnt;
c.classList.toggle('used',cnt>0);c.classList.toggle('disabled',mgDis(mg));
const lockNeeded = mg.requires==='2p' && ST.playerCount!==2;
const lockEl=document.getElementById('mglk_'+mg.id);
if(lockEl) lockEl.style.display = lockNeeded ? '' : 'none';
if(mgDis(mg)&&selGame===mg.id)clearSel();
});
const sc=document.getElementById('storyCard');if(sc){sc.classList.toggle('disabled',sMaxed());const suc=document.getElementById('storyUC');if(suc)suc.textContent=sCnt();sc.classList.toggle('used',sCnt()>0);}
const badge=document.getElementById('storyCtr');badge.textContent='📖 '+sCnt()+' / '+STORY_MAX;badge.classList.toggle('maxed',sMaxed());
const filled=ST.fields.slice(1,ST.fieldCount-1).filter(Boolean).length;
document.getElementById('s3next').disabled=filled===0;
const s3hint=document.getElementById('s3hint');if(s3hint)s3hint.style.display=filled===0?'block':'none';
}
let dragId=null;
function ghost(txt){const g=document.getElementById('dragGhost');g.textContent=txt;g.style.opacity='1';}
function unghost(){document.getElementById('dragGhost').style.opacity='0';}
// Track mouse position for ghost element
document.addEventListener('dragover',e=>{
e.preventDefault();
const g=document.getElementById('dragGhost');
if(dragging){g.style.left=e.clientX+'px';g.style.top=e.clientY+'px';}
if(dragging==='story'){
document.querySelectorAll('.drop-zone').forEach(z=>z.classList.add('story-hoverable'));
}
});
document.addEventListener('dragend',()=>cleanDropTargets());
/* ── Welt-Editor: Canvas mit rAF-Loop + Drag&Drop direkt auf Pads ── */
let worldEdit=null, worldRaf=null, worldHover=-1, worldCanvasInit=false;
function ensureWorld(){
if(!ST.worldSeed) ST.worldSeed=World.randomSeed();
worldEdit=World.generate(ST.background||'space', ST.worldSeed, ST.fieldCount);
}
function worldReroll(){
const doReroll=()=>{
ST.worldSeed=World.randomSeed();
ensureWorld(); setCodeFocus('fields'); save(); updateWorldUI();
};
if(ST.worldLocked){
appConfirm('Spielbrett ist fixiert. Willst du wirklich eine neue Welt generieren?\n\nFelder und Storys bleiben erhalten.', doReroll, {icon:'🔒', okText:'Neu generieren'});
} else {
doReroll();
}
}
function worldToggleLock(){
ST.worldLocked=!ST.worldLocked;
save();
updateWorldUI();
}
function updateWorldUI(){
const lb=document.getElementById('wbLock'); const st=document.getElementById('wbStatus'); const rb=document.getElementById('wbReroll');
if(lb){lb.textContent=ST.worldLocked?'🔓 Wieder bearbeiten':'✓ Spielbrett verwenden'; lb.classList.toggle('locked',ST.worldLocked);}
if(st){st.textContent=ST.worldLocked?'🔒 Spielbrett ist fixiert':'🎲 Welt frei generierbar';}
if(rb)rb.classList.toggle('dim',ST.worldLocked);
}
function resizeWorldCanvas(canvas){
const r=canvas.getBoundingClientRect();
const dpr=window.devicePixelRatio||1;
const w=Math.round(r.width), h=Math.round(r.height);
if(w<=0||h<=0) return false;
if(canvas.width!==Math.round(w*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr);}
canvas._w=w; canvas._h=h; canvas._dpr=dpr;
return true;
}
function worldLoop(){
const canvas=document.getElementById('worldCanvas');
if(!canvas){worldRaf=null;return;}
// Pause wenn Schritt 3 nicht aktiv (cur!==2)
if(typeof cur==='number' && cur!==2){worldRaf=requestAnimationFrame(worldLoop);return;}
if(!resizeWorldCanvas(canvas)){worldRaf=requestAnimationFrame(worldLoop);return;}
if(!worldEdit || worldEdit.theme!==World.resolveTheme(ST.background||'space') || worldEdit.fieldCount!==ST.fieldCount || worldEdit.seed!==(ST.worldSeed>>>0)){
ensureWorld();
}
const ctx=canvas.getContext('2d');
ctx.setTransform(canvas._dpr,0,0,canvas._dpr,0,0);
ctx.clearRect(0,0,canvas._w,canvas._h);
const fig1=(FIGURES.find(f=>f.id===ST.figure)||{}).e||'🎮';
const fig2=ST.playerCount===2 ? ((FIGURES.find(f=>f.id===ST.figure2)||{}).e||'🎮') : null;
const c0=World.padCenter(worldEdit,0,canvas._w,canvas._h);
const figures = [{ emoji:fig1, x:c0.x, y:c0.y, active:true, dimmed:false }];
if(fig2) figures.push({ emoji:fig2, x:c0.x, y:c0.y, active:false, dimmed:false });
World.render(ctx, worldEdit, {
W:canvas._w, H:canvas._h,
pos:0, visited:new Set([0]),
fields:ST.fields, storyItems:ST.storyItems,
figures,
figEmoji:fig1, figX:c0.x, figY:c0.y,
gameName:ST.name, devName:ST.devName,
hover:worldHover,
}, performance.now());
worldRaf=requestAnimationFrame(worldLoop);
}
function worldEventToPad(e){
const canvas=document.getElementById('worldCanvas');
if(!canvas||!worldEdit) return -1;
const r=canvas.getBoundingClientRect();
return World.hitTestPad(worldEdit, e.clientX-r.left, e.clientY-r.top, canvas._w||r.width, canvas._h||r.height);
}
function initWorldCanvas(){
if(worldCanvasInit) return;
const canvas=document.getElementById('worldCanvas'); if(!canvas) return;
worldCanvasInit=true;
canvas.addEventListener('dragover',e=>{if(!dragging)return; e.preventDefault(); e.dataTransfer.dropEffect='copy'; worldHover=worldEventToPad(e);});
canvas.addEventListener('dragleave',()=>{worldHover=-1;});
canvas.addEventListener('drop',e=>{
if(!dragging) return;
e.preventDefault();
const idx=worldEventToPad(e);
const d=e.dataTransfer.getData('text/plain');
worldHover=-1;
if(idx<0) return;
if(d==='story'){
const r=canvas.getBoundingClientRect();
const pc=World.padCenter(worldEdit,idx,canvas._w,canvas._h);
const above=(e.clientY-r.top)<pc.y;
// before idx>0; after idx<last
const pos = above ? (idx>0?'before':'after') : (idx<ST.fieldCount-1?'after':'before');
const fi = above ? (idx>0?idx:idx) : (idx<ST.fieldCount-1?idx:idx);
dropStory(pos,fi);
} else if(d.startsWith('game:')){
if(idx===0||idx===ST.fieldCount-1){showToast('Start- und Zielfeld sind reserviert.');return;}
dropGame(idx, d.replace('game:',''));
}
});
canvas.addEventListener('click',e=>{
const idx=worldEventToPad(e);
if(idx<0) return;
if(selGame==='story'){
const r=canvas.getBoundingClientRect();
const pc=World.padCenter(worldEdit,idx,canvas._w,canvas._h);
const above=(e.clientY-r.top)<pc.y;
const pos = above ? (idx>0?'before':'after') : (idx<ST.fieldCount-1?'after':'before');
dropStory(pos,idx);
} else if(selGame){
if(idx===0||idx===ST.fieldCount-1) return;
handleFClick(idx);
} else if(ST.fields[idx] && idx>0 && idx<ST.fieldCount-1){
// Klick auf belegtes Pad ohne Auswahl → entfernen?
appConfirm('Mini-Game von diesem Feld entfernen?', ()=>{
ST.fields[idx]=null; buildBoard(); save(); setCodeFocus('fields');
}, {danger:true, okText:'Entfernen', icon:'🗑️'});
}
});
canvas.addEventListener('mousemove',e=>{worldHover=worldEventToPad(e);});
canvas.addEventListener('mouseleave',()=>{worldHover=-1;});
}
function buildStoryList(){
const list=document.getElementById('storyList'); if(!list) return;
list.innerHTML='';
// sortiert: nach fieldIndex, before vor after
const sorted=[...ST.storyItems].sort((a,b)=>(a.fieldIndex-b.fieldIndex)||(a.position==='before'?-1:1));
sorted.forEach(s=>list.appendChild(mkStory(s)));
}
function buildBoard(){
ensureWorld();
initWorldCanvas();
updateWorldUI();
if(!worldRaf) worldRaf=requestAnimationFrame(worldLoop);
buildStoryList();
updPal();
const filled=ST.fields.slice(1,ST.fieldCount-1).filter(Boolean).length;
const stEl=document.getElementById('boardStats');
if(stEl) stEl.innerHTML=`<div class="stat-pill">🎯 Belegt: <span>${filled}/${ST.fieldCount-2}</span></div><div class="stat-pill">🕹️ Games: <span>${new Set(ST.fields.filter(Boolean)).size}</span></div><div class="stat-pill">📖 Texte: <span>${sCnt()}</span></div>`;
}
function mkDrop(pos,fi){
const z=document.createElement('div');z.className='drop-zone';
z.textContent = pos==='before' ? `↗ Vor Feld ${fi}` : `↘ Nach Feld ${fi}`;
z.addEventListener('dragenter',e=>{if(dragging==='story'){e.preventDefault();z.classList.add('drag-over');}});
z.addEventListener('dragover',e=>{if(dragging==='story'){e.preventDefault();e.dataTransfer.dropEffect='copy';z.classList.add('drag-over');}});
z.addEventListener('dragleave',e=>{if(!z.contains(e.relatedTarget))z.classList.remove('drag-over');});
z.addEventListener('drop',e=>{
e.preventDefault();z.classList.remove('drag-over');
if(e.dataTransfer.getData('text/plain')==='story')dropStory(pos,fi);
});
z.addEventListener('click',()=>{if(selGame==='story')dropStory(pos,fi);});
return z;
}
function mkStory(s){
// XSS-sicher: DOM-API + textContent/value statt innerHTML — s.id wird via JS-Closure übergeben,
// also kein HTML-Inject-Vektor mehr.
const row=document.createElement('div'); row.className='story-row'; row.dataset.sid=s.id;
const posLbl = s.position==='before' ? `Vor Feld ${s.fieldIndex|0}` : `Nach Feld ${s.fieldIndex|0}`;
const icon = document.createElement('div'); icon.className='story-icon';
icon.textContent = String(s.emoji||'📖').slice(0,8);
icon.addEventListener('click', () => openEP(s.id, icon));
const content = document.createElement('div'); content.className='story-content';
const lbl = document.createElement('div'); lbl.className='story-lbl'; lbl.textContent='📖 Erzähl-Text';
const ta = document.createElement('textarea'); ta.className='story-ta'; ta.rows=2; ta.maxLength=300;
ta.placeholder='Schreib deine Geschichte...';
ta.value = String(s.text||'');
ta.addEventListener('input', () => updStory(s.id, ta.value));
const meta = document.createElement('div'); meta.className='story-meta';
const posEl = document.createElement('div'); posEl.className='story-pos-lbl'; posEl.textContent=posLbl;
const chars = document.createElement('div'); chars.className='story-chars'; chars.textContent=(s.text||'').length+'/300';
meta.appendChild(posEl); meta.appendChild(chars);
content.appendChild(lbl); content.appendChild(ta); content.appendChild(meta);
const clear = document.createElement('button'); clear.className='story-clear'; clear.textContent='✕';
clear.addEventListener('click', () => rmStory(s.id));
row.appendChild(icon); row.appendChild(content); row.appendChild(clear);
return row;
}
function clickStory(){if(sMaxed())return;if(selGame==='story'){selGame=null;document.getElementById('storyCard').classList.remove('click-sel');}else{clearSel();selGame='story';document.getElementById('storyCard').classList.add('click-sel');}}
function dropStory(pos,fi){
if(sMaxed()){showToast('⚠️ Maximal 5 Erzähltexte!');return;}
const id='st_'+Date.now();
ST.storyItems.push({id,emoji:'📖',text:'',position:pos,fieldIndex:fi});
clearSel();buildBoard();
setTimeout(()=>{const r=document.querySelector(`[data-sid="${id}"] textarea`);if(r)r.focus();},80);
}
function updStory(id,val){const s=ST.storyItems.find(x=>x.id===id);if(!s)return;s.text=val;const row=document.querySelector(`[data-sid="${id}"]`);if(row){const cc=row.querySelector('.story-chars');if(cc)cc.textContent=val.length+'/300';}}
function rmStory(id){appConfirm('Erzähltext wirklich löschen?', ()=>{ST.storyItems=ST.storyItems.filter(x=>x.id!==id);buildBoard();}, {danger:true, okText:'Löschen', icon:'🗑️'});}
function openEP(sid,iconEl){
const picker=document.getElementById('emojiPicker');
if(epFor===sid){picker.classList.remove('open');epFor=null;return;}
epFor=sid;picker.innerHTML='';
STORY_EMOJIS.forEach(em=>{const btn=document.createElement('div');btn.className='ep-it';btn.textContent=em;btn.addEventListener('click',()=>{const s=ST.storyItems.find(x=>x.id===sid);if(s){s.emoji=em;buildBoard();}picker.classList.remove('open');epFor=null;});picker.appendChild(btn);});
const r=iconEl.getBoundingClientRect();
picker.style.left=Math.min(r.left,window.innerWidth-220)+'px';
picker.style.top=(r.bottom+6)+'px';
picker.classList.add('open');
}
document.addEventListener('click',e=>{if(epFor&&!e.target.classList.contains('story-icon')&&!document.getElementById('emojiPicker').contains(e.target)){document.getElementById('emojiPicker').classList.remove('open');epFor=null;}});
function handleFClick(idx){if(!selGame||selGame==='story')return;const mg=MINIGAMES.find(m=>m.id===selGame);if(!mg||mgDis(mg))return;ST.fields[idx]=selGame;setCodeFocus('fields');buildBoard();}
function dropGame(idx,id){
const mg=MINIGAMES.find(m=>m.id===id);if(!mg)return;
const src=dragFromIdx;dragFromIdx=null;
if(src===idx){buildBoard();return;}
const tmp=[...ST.fields];
if(src!==null)tmp[src]=null;
tmp[idx]=null;
const cnt=tmp.filter(f=>f===id).length;
if((mg.multi===1&&cnt>=1)||(mg.multi===3&&cnt>=MULTI_MAX)){showToast('⚠️ Limit erreicht für '+mg.n+'!');return;}
if(src!==null)ST.fields[src]=null;
ST.fields[idx]=id;
setCodeFocus('fields');
buildBoard();
}
function clearF(idx,e){e&&e.stopPropagation();appConfirm('Mini-Game von Feld '+idx+' entfernen?', ()=>{ST.fields[idx]=null;buildBoard();}, {danger:true, okText:'Entfernen', icon:'🗑️'});}