edu-boardgame-generator/codegen.js
Spiel-Generator Workshop 016be6ea90 feat: initial project structure with modular mini-game system
- editor.html: 5-step game editor with live Python code split-screen
- game.html: playable board game engine
- codegen.js: Python live-code generator (extracted from editor)
- logo.png: PH Weingarten logo (extracted from Base64)
- minigames/_api.js: shared Mini-Game API (makeCanvas, onResult, helpers)
- 12 Mini-Games as individual modules (launch + preview API):
  snake, flappy, memory, quiz, reaction,
  basketball, catch, maze, simon, typing, puzzle, spotdiff
- README.md with architecture docs and deployment guide

Refactoring highlights:
  - editor.html: 251 KB → 102 KB (-59%)
  - Mini-games fully decoupled, each ~100-200 lines
  - All 12 games now have working launch() + preview()
  - Maze uses recursive backtracker algorithm
  - spotdiff uses canvas-drawn scene with 5 differences
2026-03-14 22:12:25 +00:00

311 lines
14 KiB
JavaScript

/**
* codegen.js
* Python Live-Code-Generator für den Split-Screen
* Wird von editor.html geladen
*/
// ── Progressive unlock tracking ─────────────────────────────
const unlocked = new Set();
let lastKey = null; // which section was LAST interacted with → gets yellow highlight
function setCodeFocus(key){
unlocked.add(key);
lastKey = key;
scheduleCodeUpdate(50);
}
// Focus listener for text inputs
document.addEventListener('focusin', e=>{
const id = e.target.id || '';
if(id==='s1devname') setCodeFocus('dev');
if(id==='s1name') setCodeFocus('gamename');
if(id==='s1desc') setCodeFocus('gamedesc');
if(id && id.startsWith('qq')) setCodeFocus('quiz');
});
// ─────────────────────────────────────────────
function buildPythonCode(){
const s = ST;
const fig = FIGURES.find(f=>f.id===s.figure);
const bg = BACKGROUNDS.find(b=>b.id===s.background);
// Empty until first interaction
if(unlocked.size===0 && !s.devName && !s.name && !s.figure) return [];
const lines = [];
// hi() returns true only for the last-touched key
const hi = key => lastKey === key;
// Header + imports — always shown once anything unlocked
lines.push({t:'cm', v:'# ═══════════════════════════════════════════'});
lines.push({t:'cm', v:'# boardgame.py — Spiel-Generator PH Weingarten'});
lines.push({t:'cm', v:'# ═══════════════════════════════════════════'});
lines.push({t:'bl'});
lines.push({t:'cm', v:'# Module importieren'});
lines.push({t:'kw', v:'import', rest:' boardgame_engine as engine'});
lines.push({t:'kw', v:'import', rest:' minigames'});
lines.push({t:'kw', v:'from', rest:' config import GameConfig, Field, StoryText'});
lines.push({t:'bl'});
// DEVELOPER
if(unlocked.has('dev') || s.devName){
lines.push({t:'cm', v:'# Wer hat dieses Spiel programmiert?'});
if(s.devName)
lines.push({t:'ass', var:'developer_name', val:`"${s.devName}"`, hi:hi('dev')});
else
lines.push({t:'ph', var:'developer_name', hint:'← tippe deinen Namen'});
lines.push({t:'bl'});
}
// GAME NAME
if(unlocked.has('gamename') || s.name){
lines.push({t:'cm', v:'# Wie heißt das Spiel?'});
if(s.name)
lines.push({t:'ass', var:'game_name', val:`"${s.name}"`, hi:hi('gamename')});
else
lines.push({t:'ph', var:'game_name', hint:'← tippe den Spielnamen'});
lines.push({t:'bl'});
}
// DESCRIPTION
if(unlocked.has('gamedesc') || s.desc){
lines.push({t:'cm', v:'# Kurze Beschreibung des Spiels'});
if(s.desc)
lines.push({t:'ass', var:'game_description', val:`"${s.desc}"`, hi:hi('gamedesc')});
else
lines.push({t:'ph', var:'game_description', hint:'← tippe eine Beschreibung'});
lines.push({t:'bl'});
}
// FIGURE
if(unlocked.has('figure') || s.figure){
lines.push({t:'cm', v:'# Spielfigur auswählen'});
if(s.figure)
lines.push({t:'ass', var:'player_figure',
val:`"${s.figure}" # ${fig?fig.e+' '+fig.n:''}`, hi:hi('figure')});
else
lines.push({t:'ph', var:'player_figure', hint:'← klicke eine Figur an'});
lines.push({t:'bl'});
}
// BACKGROUND
if(unlocked.has('background') || s.background){
lines.push({t:'cm', v:'# Spielwelt / Hintergrund'});
if(s.background)
lines.push({t:'ass', var:'world_setting',
val:`"${s.background}" # ${bg?bg.e+' '+bg.n:''}`, hi:hi('background')});
else
lines.push({t:'ph', var:'world_setting', hint:'← klicke eine Welt an'});
lines.push({t:'bl'});
}
// MOVEMENT
if(unlocked.has('movement')){
lines.push({t:'cm', v:'# Wie viele Felder pro Runde?'});
lines.push({t:'ass', var:'movement_type',
val: s.rules.movement==='step' ? '"schritt" # immer genau 1 Feld'
: '"wuerfeln" # zufällig 1-6 Felder',
hi:hi('movement')});
lines.push({t:'bl'});
}
// FAIL MODE
if(unlocked.has('failmode')){
lines.push({t:'cm', v:'# Was passiert wenn man verliert?'});
if(s.rules.fail==='lives'){
lines.push({t:'ass', var:'verlust_system', val:'"leben"', hi:hi('failmode')});
lines.push({t:'ass', var:'anzahl_leben', val:String(s.rules.lives||3), hi:hi('lives')});
} else {
lines.push({t:'ass', var:'verlust_system', val:'"punkte"', hi:hi('failmode')});
lines.push({t:'ass', var:'punkte_pro_sieg',val:String(s.rules.pts||10), hi:hi('pts')});
}
lines.push({t:'bl'});
}
// LIVES / PTS sub-option
if(unlocked.has('lives') && s.rules.fail==='lives'){
// already rendered above, just make sure hi updates
}
// FIELD COUNT
if(unlocked.has('fieldcount')){
lines.push({t:'cm', v:'# Wie groß ist das Spielfeld?'});
lines.push({t:'ass', var:'anzahl_felder', val:String(s.fieldCount), hi:hi('fieldcount')});
lines.push({t:'bl'});
}
// BOARD SETUP — only once a game was dropped
const MG_PY ={snake:'schlangen_spiel',flappy:'flug_spiel',memory:'memory',quiz:'quiz',reaction:'reaktionstest',basketball:'basketball',catch:'fangen',maze:'labyrinth',simon:'simon_says',puzzle:'raetsel',spotdiff:'unterschiede',typing:'tipp_rennen'};
const MG_CM ={snake:'Schlange steuern',flappy:'Durch Hindernisse fliegen',memory:'Paare finden',quiz:'Frage beantworten',reaction:'Schnell reagieren',basketball:'Ball werfen',catch:'Objekte fangen',maze:'Ausweg finden',simon:'Sequenz merken',puzzle:'Rätsel lösen',spotdiff:'Unterschiede finden',typing:'Text eintippen'};
const hasAnyField = (s.fields||[]).some(Boolean);
if(unlocked.has('fields') || hasAnyField){
lines.push({t:'cm', v:'# Spielfeld aufbauen — jedes Feld wird definiert'});
lines.push({t:'fn-def', name:'setup_spielfeld', args:''});
lines.push({t:'ind', v:'felder = []'});
lines.push({t:'bl'});
lines.push({t:'ind-cm', v:'# Startfeld (Index 0)'});
lines.push({t:'ind', v:'felder.append(Field(index=0, typ="start"))'});
for(let i=1;i<s.fieldCount-1;i++){
const fId=(s.fields||[])[i];
lines.push({t:'bl'});
if(fId){
lines.push({t:'ind-cm', v:`# Feld ${i}: Mini-Game → ${MG_CM[fId]||fId}`});
lines.push({t:'ind', v:`felder.append(Field(index=${i}, typ="minigame", spiel="${MG_PY[fId]||fId}"))`,
hi: hi('fields') && (s.fields||[])[i]===fId});
} else {
lines.push({t:'ind-cm', v:`# Feld ${i}: kein Spiel, einfach durchlaufen`});
lines.push({t:'ind', v:`felder.append(Field(index=${i}, typ="leer"))`});
}
}
lines.push({t:'bl'});
lines.push({t:'ind-cm', v:`# Zielfeld (Index ${s.fieldCount-1}) 🏁`});
lines.push({t:'ind', v:`felder.append(Field(index=${s.fieldCount-1}, typ="ziel"))`});
lines.push({t:'bl'});
lines.push({t:'ind', v:'return felder'});
lines.push({t:'bl'});
}
// MINIGAME FUNCTIONS
const usedGames=[...new Set((s.fields||[]).filter(Boolean))];
if(usedGames.length>0){
const MG_CM2={snake:'Schlange steuern ohne Wand zu treffen',flappy:'Durch alle Hindernisse fliegen',memory:'Alle Kartenpaare finden',quiz:'Die richtige Antwort wählen',reaction:'Schnell auf Signal drücken',basketball:'Ball zum richtigen Moment werfen',catch:'Fallende Objekte auffangen',maze:'Den Ausgang aus dem Labyrinth finden',simon:'Farbreihenfolge merken und wiederholen',puzzle:'Zahlen in die richtige Reihenfolge bringen',spotdiff:'Alle Unterschiede im Bild finden',typing:'Text so schnell wie möglich eintippen'};
lines.push({t:'cm', v:'# Für jedes Mini-Game gibt es eine eigene Funktion'});
usedGames.forEach(id=>{
const pyName=MG_PY[id]||id;
lines.push({t:'bl'});
lines.push({t:'fn-def', name:`spiele_${pyName}`, args:'spieler'});
lines.push({t:'ind-cm', v:`# Aufgabe: ${MG_CM2[id]||id}`});
lines.push({t:'ind', v:`ergebnis = minigames.${pyName}.starten(spieler)`});
lines.push({t:'ind', v:`return ergebnis.gewonnen`});
});
lines.push({t:'bl'});
}
// QUIZ
const quizItems=(s.quizData||[]).filter(q=>q&&q.question);
if(quizItems.length>0 || unlocked.has('quiz')){
lines.push({t:'cm', v:'# Quiz-Fragen für das Spiel'});
lines.push({t:'ass', var:'fragen', val:'[', hi:false});
quizItems.forEach(q=>{
const clean=q.question.replace(/"/g,"'").slice(0,50);
const ans=q.answers.map(a=>`"${(a||'').replace(/"/g,"'")}"`).join(', ');
const cor=q.correct!=null?`"${(q.answers[q.correct]||'').replace(/"/g,"'")}"`:'None';
lines.push({t:'ind', v:`{"frage": "${clean}", "antworten": [${ans}], "richtig": ${cor}},`});
});
if(quizItems.length===0) lines.push({t:'ind-cm', v:'# noch keine Fragen eingetragen...'});
lines.push({t:'ass-end', v:']'});
lines.push({t:'bl'});
}
// MAIN BLOCK
lines.push({t:'cm', v:'# ── Spiel starten ───────────────────────────'});
lines.push({t:'kw', v:'if', rest:" __name__ == '__main__':"});
lines.push({t:'ind-cm', v:'# Konfiguration zusammenstellen und Spiel starten'});
lines.push({t:'ind', v:'config = GameConfig('});
lines.push({t:'ind2', v:`name=${s.name?`"${s.name}"`:'""'},`});
lines.push({t:'ind2', v:`developer=${s.devName?`"${s.devName}"`:'""'},`});
if(s.figure) lines.push({t:'ind2', v:`figure="${s.figure}",`});
if(s.background) lines.push({t:'ind2', v:`setting="${s.background}",`});
if(hasAnyField) lines.push({t:'ind2', v:'board=setup_spielfeld(),'});
if(quizItems.length) lines.push({t:'ind2', v:'questions=fragen,'});
lines.push({t:'ind', v:')'});
lines.push({t:'bl'});
lines.push({t:'ind', v:'engine.run(config)'});
return lines;
}
function renderToken(line){
const esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let r='';
switch(line.t){
case 'cm': r=`<span class="py-cm">${esc(line.v)}</span>`; break;
case 'bl': return '';
case 'ph': r=`<span class="py-var">${esc(line.var)}</span><span class="py-punc"> = </span><span class="py-cm">"???" <span style="color:#e06c75;font-style:italic">${esc(line.hint||'')}</span></span>`; break;
case 'kw': r=`<span class="py-kw">${esc(line.v)}</span><span class="py-val">${esc(line.rest||'')}</span>`; break;
case 'ass': r=`<span class="py-var">${esc(line.var)}</span><span class="py-punc"> = </span><span class="py-str">${esc(String(line.val))}</span>`; break;
case 'ass-end':r=`<span class="py-punc">${esc(line.v)}</span>`; break;
case 'fn-def': r=`<span class="py-kw">def </span><span class="py-fn">${esc(line.name)}</span><span class="py-punc">(${esc(line.args||'')})</span><span class="py-punc">:</span>`; break;
case 'ind': r=` <span class="py-val">${esc(line.v)}</span>`; break;
case 'ind2': r=` <span class="py-val">${esc(line.v)}</span>`; break;
case 'ind-cm': r=` <span class="py-cm">${esc(line.v)}</span>`; break;
default: return esc(line.v||'');
}
return line.hi ? `<span class="py-hi">${r}</span>` : r;
}
let codeTypingTimer = null;
let lastLineCount = 0;
let lastKey_scroll = null; // prevent scroll thrash
function updateCodePane(){
const lines = buildPythonCode();
const codeEl = document.getElementById('codeContent');
const numsEl = document.getElementById('codeLineNums');
const linesEl= document.getElementById('codeLines');
if(!codeEl) return;
// Filename
const dev = ST.devName ? ST.devName.toLowerCase().replace(/[^a-z0-9]/g,'_') : 'dev';
const gn = ST.name ? ST.name.toLowerCase().replace(/[^a-z0-9]/g,'_').slice(0,20) : 'boardgame';
const fnEl = document.getElementById('codeFilename');
if(fnEl) fnEl.textContent = (ST.name||ST.devName) ? `${gn}_von_${dev}.py` : 'boardgame.py';
if(lines.length===0){
codeEl.innerHTML='<span class="py-cm"># Klicke ein Feld an — dein Code erscheint hier ✨</span>';
numsEl.innerHTML='<div>1</div>';
if(linesEl) linesEl.textContent='1 Zeile';
lastLineCount=0; return;
}
let html='', lineNum=1;
const lineNums=[];
lines.forEach(line=>{
if(line.t==='bl'){ html+='\n'; }
else { html+=renderToken(line)+'\n'; }
lineNums.push(lineNum++);
});
const newCount = lines.length;
const grew = newCount > lastLineCount;
const prevCount= lastLineCount;
lastLineCount = newCount;
codeEl.innerHTML = html;
numsEl.innerHTML = lineNums.map(n=>`<div>${n}</div>`).join('');
if(linesEl) linesEl.textContent = lineNum+' Zeilen';
const status = document.getElementById('codeStatus');
if(status){ status.textContent='● Live'; setTimeout(()=>{ status.textContent='● Bereit'; },800); }
// Scroll: only when new lines appeared AND key changed (avoid thrash while typing)
const scroll = document.getElementById('codeScroll');
if(scroll && grew && lastKey !== lastKey_scroll){
lastKey_scroll = lastKey;
// Find the first highlighted line's position
setTimeout(()=>{
const hiEl = codeEl.querySelector('.py-hi');
if(hiEl){
const pre = codeEl;
const preTop = pre.getBoundingClientRect().top;
const hiTop = hiEl.getBoundingClientRect().top;
const offset = hiTop - preTop;
const scrollTarget = scroll.scrollTop + offset - 80;
scroll.scrollTo({top: Math.max(0, scrollTarget), behavior:'smooth'});
}
}, 80);
}
}
function scheduleCodeUpdate(delay=80){
clearTimeout(codeTypingTimer);
codeTypingTimer = setTimeout(updateCodePane, delay);
}
document.addEventListener('input', ()=>scheduleCodeUpdate(50));
document.addEventListener('change', ()=>scheduleCodeUpdate(50));
// Initial render
setTimeout(updateCodePane, 200);