- 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
311 lines
14 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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);
|