aether-framework/minigames/index.html
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark,
  Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI)
- aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource,
  an aether-core gekoppelt (Rechte über aether_users.admin_group), mit
  WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU)
- Prototypen admin/ vervollständigt
- SQL-Schema aether_admin.sql
- Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

405 lines
24 KiB
HTML

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Aether — Minispiele</title>
<style>
:root{
--bg:#08070d;--fg:#f0eef7;--muted:#918bad; /* Grundfarben */
--surface:rgba(22,19,36,0.72);--surface2:rgba(34,29,54,0.9); /* Panel-Flächen */
--slot:#100e1a;--border:rgba(255,255,255,0.09); /* Eingaben/Rahmen */
--accent:#7c5cf6;--accent2:#5b8cff;--ink:#160c2e;/* Aether-Akzent */
--accent-soft:rgba(124,92,246,0.16);
--green:#34d39a;--red:#f0506e;--amber:#f5a742; /* Zustände */
}
*{box-sizing:border-box;} html,body{margin:0;height:100%;}
body{min-height:100%;background:radial-gradient(1000px 640px at 50% -8%,rgba(124,92,246,0.12),transparent 55%),var(--bg);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg);
display:flex;align-items:center;justify-content:center;padding:22px 14px;}
.app{position:relative;width:min(1000px,98vw);aspect-ratio:16/10;border-radius:20px;overflow:hidden;
background:linear-gradient(160deg,rgba(20,17,32,0.95),rgba(8,7,12,0.98));border:1px solid rgba(124,92,246,0.22);
box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:210px 1fr;grid-template-rows:auto 1fr;
grid-template-areas:"brand top" "nav main";}
.brand{grid-area:brand;padding:14px 16px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
.brand .bl{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;font-size:19px;background:linear-gradient(150deg,var(--accent),var(--accent2));color:#fff;}
.brand .bn{font-size:16px;font-weight:800;letter-spacing:-0.02em;} .brand .bn span{color:var(--accent);} .brand .bn small{display:block;font-size:10px;color:var(--muted);font-weight:500;}
.top{grid-area:top;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;padding:0 20px;font-size:12.5px;color:var(--muted);}
.top b{color:var(--fg);}
.nav{grid-area:nav;border-right:1px solid var(--border);padding:12px 9px;display:flex;flex-direction:column;gap:2px;}
.nav button{display:flex;align-items:center;gap:10px;padding:10px 11px;border-radius:9px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:13.5px;font-weight:700;cursor:pointer;text-align:left;transition:.12s;}
.nav button .ni{font-size:15px;width:18px;text-align:center;} .nav button:hover{background:var(--surface);color:var(--fg);} .nav button.active{background:var(--accent-soft);color:var(--accent);}
.main{grid-area:main;overflow-y:auto;padding:22px 26px;display:flex;flex-direction:column;}
.phead{margin-bottom:16px;} .phead h2{font-size:22px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:3px 0 0;color:var(--muted);font-size:13px;}
.stage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;text-align:center;}
.btn{border:none;border-radius:11px;padding:12px 20px;font-size:14px;font-weight:800;cursor:pointer;font-family:inherit;transition:.12s;}
.btn.primary{background:var(--accent);color:#fff;} .btn.primary:hover{filter:brightness(1.1);}
.btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);}
.btn.green{background:var(--green);color:#052018;} .btn:active{transform:scale(0.98);}
.kbd{display:inline-block;background:rgba(255,255,255,0.1);border:1px solid var(--border);border-bottom-width:2px;border-radius:6px;padding:1px 8px;font-weight:800;font-size:12px;}
/* Übersicht */
.lobbygrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px;width:100%;}
.gamecard{border-radius:16px;padding:18px;cursor:pointer;border:1px solid var(--border);background:var(--surface);transition:.12s;text-align:left;}
.gamecard:hover{transform:translateY(-3px);border-color:var(--accent);}
.gamecard .gi{font-size:32px;} .gamecard .gn{font-size:16px;font-weight:800;margin-top:8px;} .gamecard .gd{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.4;}
.roundinfo{font-size:13px;color:var(--muted);font-weight:700;} .roundinfo b{color:var(--accent);}
/* Skill-Check: rotierender Zeiger */
.scwrap{position:relative;width:230px;height:230px;}
.scwrap svg{width:100%;height:100%;}
.sc-track{stroke:rgba(255,255,255,0.1);}
.sc-zone{stroke:var(--green);transition:stroke .2s;}
.sc-pointer{stroke:var(--accent);stroke-width:5;stroke-linecap:round;}
.sc-hub{position:absolute;inset:0;display:grid;place-items:center;font-size:15px;font-weight:800;pointer-events:none;}
/* Lockpick: Timing-Bar + Pins */
.pins{display:flex;gap:8px;} .pins i{width:26px;height:8px;border-radius:99px;background:var(--slot);border:1px solid var(--border);}
.pins i.set{background:var(--green);border-color:transparent;} .pins i.fail{background:var(--red);}
.lives{font-size:13px;color:var(--muted);font-weight:700;} .lives b{color:var(--red);}
.lpbar{position:relative;width:min(420px,80vw);height:52px;border-radius:12px;background:var(--slot);border:1px solid var(--border);overflow:hidden;}
.lp-zone{position:absolute;top:0;bottom:0;background:linear-gradient(180deg,rgba(52,211,153,0.4),rgba(52,211,153,0.18));border-left:2px solid var(--green);border-right:2px solid var(--green);}
.lp-marker{position:absolute;top:-3px;bottom:-3px;width:5px;background:var(--accent);border-radius:3px;box-shadow:0 0 12px var(--accent);}
/* Memory: 4 Pads */
.pads{display:grid;grid-template-columns:1fr 1fr;gap:12px;width:270px;height:270px;}
.pad{border-radius:16px;border:2px solid rgba(255,255,255,0.08);cursor:pointer;transition:.1s;opacity:0.5;}
.pad.lit{opacity:1;transform:scale(0.96);box-shadow:0 0 26px currentColor;}
.pad[data-p="0"]{background:#7c5cf6;color:#7c5cf6;} .pad[data-p="1"]{background:#5b8cff;color:#5b8cff;}
.pad[data-p="2"]{background:#34d39a;color:#34d39a;} .pad[data-p="3"]{background:#f5a742;color:#f5a742;}
.pads.locked .pad{cursor:not-allowed;}
/* Keypad-Crack */
.kpwrap{display:flex;gap:26px;align-items:flex-start;flex-wrap:wrap;justify-content:center;}
.kpdisplay{display:flex;gap:8px;} .kpdisplay .d{width:46px;height:58px;border-radius:10px;background:var(--slot);border:1px solid var(--border);
display:grid;place-items:center;font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;}
.kpdisplay .d.fill{border-color:var(--accent);}
.keypad{display:grid;grid-template-columns:repeat(3,58px);gap:8px;}
.keypad button{height:52px;border-radius:10px;border:1px solid var(--border);background:var(--surface2);color:var(--fg);font-family:inherit;font-size:19px;font-weight:800;cursor:pointer;transition:.1s;}
.keypad button:hover{border-color:var(--accent);} .keypad button.wide{grid-column:span 1;font-size:15px;}
.kphist{min-width:180px;max-height:260px;overflow-y:auto;display:flex;flex-direction:column;gap:6px;}
.kphist .row{background:var(--surface);border:1px solid var(--border);border-radius:9px;padding:7px 10px;}
/* Jede Ziffer bekommt ihren Marker direkt darunter (positionsbezogen) */
.kphist .zeile{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;text-align:center;}
.kphist .zeile .z{font-size:17px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.1;}
.kphist .zeile .m{font-size:12px;margin-top:3px;line-height:1;}
.kpmeta{display:flex;gap:16px;font-size:13px;color:var(--muted);font-weight:700;} .kpmeta b{color:var(--fg);}
.kpmeta .t.low b{color:var(--red);}
/* Ergebnis-Banner */
.result{display:flex;flex-direction:column;align-items:center;gap:14px;animation:auf .3s ease;}
@keyframes auf{from{opacity:0;transform:translateY(12px);}to{opacity:1;transform:none;}}
.result .ri{font-size:60px;} .result h3{margin:0;font-size:26px;font-weight:900;}
.result.win h3{color:var(--green);} .result.lose h3{color:var(--red);}
.result p{margin:0;color:var(--muted);font-size:13.5px;} .result .row{display:flex;gap:10px;}
</style>
</head>
<body>
<div class="app">
<div class="brand"><span class="bl">🕹️</span><div class="bn">Aether <span>Minigames</span><small>Skill-Checks</small></div></div>
<div class="top" id="top">Sammlung von Minispielen · in FiveM per NUI ausgelöst, geben <b>Erfolg / Fehlschlag</b> zurück.</div>
<div class="nav" id="nav"></div>
<div class="main" id="main"></div>
</div>
<script>
"use strict";
/* Kurzer Zugriffshelfer auf Elemente per ID */
const $=id=>document.getElementById(id);
/* Ganzzahl-Zufall a..b inklusive */
const rnd=(a,b)=>a+Math.floor(Math.random()*(b-a+1));
/* =====================================================================
TIMER-VERWALTUNG (damit beim Wechsel nichts weiterläuft)
===================================================================== */
let _intervals=[], _timeouts=[];
let letzteAktion=0; // Zeitstempel der letzten Aktion (verhindert Doppelauslösung)
/* Intervall registrieren */
function I(fn,ms){const id=setInterval(fn,ms);_intervals.push(id);return id;}
/* Timeout registrieren */
function T(fn,ms){const id=setTimeout(fn,ms);_timeouts.push(id);return id;}
/* Alle laufenden Timer stoppen */
function stopAlle(){_intervals.forEach(clearInterval);_timeouts.forEach(clearTimeout);_intervals=[];_timeouts=[];}
/* =====================================================================
SHELL / NAVIGATION
===================================================================== */
const spiele=[
{id:"skillcheck", icon:"🎯", label:"Skill-Check", desc:"Stoppe den Zeiger in der grünen Zone — mehrere Runden, wird schneller."},
{id:"lockpick", icon:"🔓", label:"Lockpick", desc:"Setze jeden Pin, indem du den Marker in der Zone triffst. 3 Versuche."},
{id:"memory", icon:"🧠", label:"Memory", desc:"Merke dir die Sequenz der Felder und wiederhole sie fehlerfrei."},
{id:"keypad", icon:"🔢", label:"Code knacken", desc:"Errate den 4-stelligen Code aus den Hinweisen — gegen die Zeit."},
];
const nav=[{id:"lobby",icon:"🏠",label:"Übersicht"},...spiele.map(s=>({id:s.id,icon:s.icon,label:s.label}))];
let sektion="lobby";
/* Navigationsleiste rendern */
function renderNav(){
$("nav").innerHTML=nav.map(n=>`<button data-s="${n.id}" class="${n.id===sektion?'active':''}"><span class="ni">${n.icon}</span>${n.label}</button>`).join('');
$("nav").querySelectorAll("[data-s]").forEach(b=>b.addEventListener("click",()=>wechsle(b.dataset.s)));
}
/* Zu einer Sektion wechseln (stoppt vorher alle Timer) */
function wechsle(id){stopAlle();sektion=id;renderNav();(sektionen[id]||sektionen.lobby)();}
const sektionen={};
/* Gemeinsames Ergebnis-Banner (Erfolg/Fehlschlag) mit Aktionen */
function spielEnde(gewonnen,text){
stopAlle();
const spiel=spiele.find(s=>s.id===sektion);
$("main").innerHTML=`<div class="phead"><h2>${spiel.label}</h2></div>
<div class="stage"><div class="result ${gewonnen?'win':'lose'}">
<div class="ri">${gewonnen?'✅':'❌'}</div>
<h3>${gewonnen?'Erfolg!':'Fehlgeschlagen'}</h3>
<p>${text}</p>
<div class="row"><button class="btn primary" id="reNochmal">↻ Nochmal</button>
<button class="btn ghost" id="reBack">Zur Übersicht</button></div>
</div></div>`;
$("reNochmal").addEventListener("click",()=>wechsle(sektion));
$("reBack").addEventListener("click",()=>wechsle("lobby"));
}
/* =====================================================================
ÜBERSICHT
===================================================================== */
sektionen.lobby=()=>{
$("main").innerHTML=`<div class="phead"><h2>Minispiele</h2><p>Wähle einen Skill-Check zum Ausprobieren.</p></div>
<div class="lobbygrid">${spiele.map(s=>
`<div class="gamecard" data-g="${s.id}"><div class="gi">${s.icon}</div><div class="gn">${s.label}</div><div class="gd">${s.desc}</div></div>`).join('')}</div>`;
$("main").querySelectorAll("[data-g]").forEach(c=>c.addEventListener("click",()=>wechsle(c.dataset.g)));
};
/* =====================================================================
1) SKILL-CHECK (rotierender Zeiger in die grüne Zone stoppen)
===================================================================== */
const SC_RUNDEN=3; // Anzahl zu bestehender Runden
const SC_UMFANG=2*Math.PI*92; // Umfang des Rings (Radius 92)
const sc={runde:1, winkel:0, spd:3, zoneStart:0, zoneWinkel:60, aktiv:false};
sektionen.skillcheck=()=>{
Object.assign(sc,{runde:1,winkel:0,spd:3,zoneWinkel:60,aktiv:true});
$("main").innerHTML=`<div class="phead"><h2>Skill-Check</h2><p>Drücke <span class="kbd">Leertaste</span> oder klicke, wenn der Zeiger in der grünen Zone steht.</p></div>
<div class="stage">
<div class="roundinfo">Runde <b id="sc-runde">1</b> / ${SC_RUNDEN}</div>
<div class="scwrap" id="sc-click">
<svg viewBox="0 0 230 230">
<circle class="sc-track" cx="115" cy="115" r="92" fill="none" stroke-width="14"></circle>
<circle class="sc-zone" id="sc-zone" cx="115" cy="115" r="92" fill="none" stroke-width="14" stroke-linecap="butt"></circle>
<line class="sc-pointer" id="sc-pointer" x1="115" y1="115" x2="115" y2="18"></line>
</svg>
<div class="sc-hub">STOP</div>
</div>
<button class="btn primary" id="sc-btn">Stoppen</button>
</div>`;
scNeueRunde();
I(()=>{ // Zeiger drehen
sc.winkel=(sc.winkel+sc.spd)%360;
$("sc-pointer").setAttribute("transform",`rotate(${sc.winkel} 115 115)`);
},16);
$("sc-btn").addEventListener("click",scAktion);
$("sc-click").addEventListener("click",scAktion);
};
/* Neue Runde vorbereiten: Zone zufällig setzen, Tempo/Größe je Runde */
function scNeueRunde(){
sc.zoneWinkel=[60,46,36][sc.runde-1]||34; // Zone schrumpft je Runde
sc.spd=[3,3.9,4.8][sc.runde-1]||5; // Tempo steigt je Runde
sc.zoneStart=rnd(0,359);
const arc=sc.zoneWinkel/360*SC_UMFANG;
const z=$("sc-zone");
z.setAttribute("stroke-dasharray",`${arc} ${SC_UMFANG}`);
z.setAttribute("transform",`rotate(${sc.zoneStart-90} 115 115)`); // -90: Start oben
$("sc-runde").textContent=sc.runde;
}
/* Aktion: prüfen, ob der Zeiger in der Zone steht */
function scAktion(){
if(!sc.aktiv)return;
// Doppelauslösung (Leertaste + fokussierter Button) verhindern
const jetzt=performance.now(); if(jetzt-letzteAktion<140)return; letzteAktion=jetzt;
const rel=((sc.winkel-sc.zoneStart)%360+360)%360; // Abstand zum Zonenstart
if(rel<=sc.zoneWinkel){ // Treffer
if(sc.runde>=SC_RUNDEN){sc.aktiv=false;spielEnde(true,"Alle "+SC_RUNDEN+" Runden getroffen — sauber!");}
else {sc.runde++;scNeueRunde();}
} else { // Verfehlt
sc.aktiv=false;spielEnde(false,"Zeiger verfehlt die Zone. Timing üben!");
}
}
/* =====================================================================
2) LOCKPICK (Marker in der Zone stoppen, mehrere Pins)
===================================================================== */
const LP_PINS=4;
const lp={pin:0, leben:3, pos:0, dir:1, spd:1.6, zoneStart:0, zoneBreite:26, aktiv:false};
sektionen.lockpick=()=>{
Object.assign(lp,{pin:0,leben:3,pos:5,dir:1,aktiv:true});
$("main").innerHTML=`<div class="phead"><h2>Lockpick</h2><p>Drücke <span class="kbd">Leertaste</span> oder klicke, wenn der Marker in der grünen Zone ist.</p></div>
<div class="stage">
<div class="roundinfo">Pins <span class="pins" id="lp-pins" style="display:inline-flex;vertical-align:middle;margin:0 6px"></span> · <span class="lives" id="lp-leben"></span></div>
<div class="lpbar" id="lp-bar"><div class="lp-zone" id="lp-zone"></div><div class="lp-marker" id="lp-marker"></div></div>
<button class="btn primary" id="lp-btn">Pin setzen</button>
</div>`;
lpRenderPins(); lpRenderLeben(); lpNeuerPin();
I(()=>{ // Marker hin und her bewegen
lp.pos+=lp.dir*lp.spd;
if(lp.pos>=100){lp.pos=100;lp.dir=-1;} if(lp.pos<=0){lp.pos=0;lp.dir=1;}
$("lp-marker").style.left="calc("+lp.pos+"% - 2.5px)";
},16);
$("lp-btn").addEventListener("click",lpAktion);
$("lp-bar").addEventListener("click",lpAktion);
};
/* Pin-Anzeige (gesetzt/offen) rendern */
function lpRenderPins(){$("lp-pins").innerHTML=Array.from({length:LP_PINS},(_,i)=>`<i class="${i<lp.pin?'set':''}" id="lp-pin-${i}"></i>`).join('');}
/* Verbleibende Versuche rendern */
function lpRenderLeben(){$("lp-leben").innerHTML="Versuche <b>"+lp.leben+"</b>";}
/* Neuen Pin vorbereiten: Zone zufällig, wird enger/schneller */
function lpNeuerPin(){
lp.zoneBreite=Math.max(12,26-lp.pin*3);
lp.spd=1.6+lp.pin*0.35;
lp.zoneStart=rnd(6,94-lp.zoneBreite);
$("lp-zone").style.left=lp.zoneStart+"%";
$("lp-zone").style.width=lp.zoneBreite+"%";
}
/* Aktion: liegt der Marker in der Zone? */
function lpAktion(){
if(!lp.aktiv)return;
// Doppelauslösung (Leertaste + fokussierter Button) verhindern
const jetzt=performance.now(); if(jetzt-letzteAktion<140)return; letzteAktion=jetzt;
if(lp.pos>=lp.zoneStart&&lp.pos<=lp.zoneStart+lp.zoneBreite){ // Pin gesetzt
lp.pin++;
if(lp.pin>=LP_PINS){lp.aktiv=false;lpRenderPins();spielEnde(true,"Alle Pins gesetzt — das Schloss ist offen!");return;}
lpRenderPins(); lpNeuerPin();
} else { // Fehlversuch
lp.leben--; lpRenderLeben();
if(lp.leben<=0){lp.aktiv=false;spielEnde(false,"Dietrich abgebrochen — kein Versuch mehr übrig.");return;}
lpNeuerPin(); // neuer Zonenort als „Ruck"
}
}
/* =====================================================================
3) MEMORY (wachsende Sequenz merken und wiederholen)
===================================================================== */
const MEM_ZIEL=5; // benötigte Sequenzlänge zum Gewinnen
const mem={seq:[], eingabe:0, sperre:true};
sektionen.memory=()=>{
Object.assign(mem,{seq:[],eingabe:0,sperre:true});
$("main").innerHTML=`<div class="phead"><h2>Memory</h2><p>Merke dir die aufleuchtende Reihenfolge und tippe sie nach.</p></div>
<div class="stage">
<div class="roundinfo" id="mem-info">Achtung …</div>
<div class="pads locked" id="mem-pads">${[0,1,2,3].map(p=>`<div class="pad" data-p="${p}"></div>`).join('')}</div>
</div>`;
$("mem-pads").querySelectorAll(".pad").forEach(p=>p.addEventListener("click",()=>memEingabe(+p.dataset.p)));
T(memNaechsteRunde,700);
};
/* Ein Feld kurz aufleuchten lassen */
function memBlink(p,dauer){const el=$("mem-pads").querySelector(`[data-p="${p}"]`);if(!el)return;el.classList.add("lit");T(()=>el.classList.remove("lit"),dauer);}
/* Sequenz verlängern und dem Spieler vorspielen */
function memNaechsteRunde(){
mem.seq.push(rnd(0,3)); mem.eingabe=0; mem.sperre=true;
$("mem-pads").classList.add("locked");
$("mem-info").textContent="Merken … ("+mem.seq.length+"/"+MEM_ZIEL+")";
mem.seq.forEach((p,i)=>{T(()=>memBlink(p,380),700+i*620);});
// Nach dem Vorspielen Eingabe freigeben
T(()=>{mem.sperre=false;$("mem-pads").classList.remove("locked");$("mem-info").textContent="Jetzt wiederholen …";},700+mem.seq.length*620+150);
}
/* Spieler-Eingabe prüfen */
function memEingabe(p){
if(mem.sperre)return;
memBlink(p,220);
if(p!==mem.seq[mem.eingabe]){mem.sperre=true;spielEnde(false,"Falsche Reihenfolge bei Schritt "+(mem.eingabe+1)+".");return;}
mem.eingabe++;
if(mem.eingabe===mem.seq.length){ // Sequenz korrekt wiederholt
if(mem.seq.length>=MEM_ZIEL){mem.sperre=true;spielEnde(true,"Sequenz aus "+MEM_ZIEL+" Feldern gemeistert!");return;}
mem.sperre=true; $("mem-pads").classList.add("locked"); $("mem-info").textContent="Richtig! Weiter …";
T(memNaechsteRunde,700);
}
}
/* =====================================================================
4) KEYPAD / CODE KNACKEN (Deduktion gegen die Zeit)
===================================================================== */
const KP_STELLEN=4, KP_VERSUCHE=6, KP_ZEIT=60;
const kp={code:[], eingabe:"", versuche:KP_VERSUCHE, zeit:KP_ZEIT, aktiv:false};
sektionen.keypad=()=>{
// Geheimen Code aus vier verschiedenen Ziffern erzeugen
const ziffern=[0,1,2,3,4,5,6,7,8,9]; kp.code=[];
for(let i=0;i<KP_STELLEN;i++)kp.code.push(ziffern.splice(rnd(0,ziffern.length-1),1)[0]);
Object.assign(kp,{eingabe:"",versuche:KP_VERSUCHE,zeit:KP_ZEIT,aktiv:true});
$("main").innerHTML=`<div class="phead"><h2>Code knacken</h2><p>Vier verschiedene Ziffern. Der Marker steht unter seiner Ziffer: 🟩 hier richtig · 🟨 kommt vor, aber woanders · ⬛ kommt nicht vor.</p></div>
<div class="stage"><div class="kpmeta"><div>Versuche <b id="kp-v">${KP_VERSUCHE}</b></div><div class="t" id="kp-tw">Zeit <b id="kp-t">${KP_ZEIT}s</b></div></div>
<div class="kpwrap">
<div>
<div class="kpdisplay" id="kp-disp"></div>
<div class="keypad" id="kp-pad" style="margin-top:14px">
${[1,2,3,4,5,6,7,8,9].map(n=>`<button data-k="${n}">${n}</button>`).join('')}
<button class="wide" data-k="del">⌫</button><button data-k="0">0</button><button class="wide" data-k="ok">✓</button>
</div>
</div>
<div class="kphist" id="kp-hist"></div>
</div>
</div>`;
kpRenderDisplay();
$("kp-pad").querySelectorAll("[data-k]").forEach(b=>b.addEventListener("click",()=>kpTaste(b.dataset.k)));
// Countdown starten
I(()=>{ if(!kp.aktiv)return; kp.zeit--; $("kp-t").textContent=kp.zeit+"s";
$("kp-tw").classList.toggle("low",kp.zeit<=10);
if(kp.zeit<=0){kp.aktiv=false;spielEnde(false,"Zeit abgelaufen — Code nicht geknackt. (Er war "+kp.code.join("")+")");}
},1000);
};
/* Aktuelle Eingabe in den vier Feldern anzeigen */
function kpRenderDisplay(){
$("kp-disp").innerHTML=Array.from({length:KP_STELLEN},(_,i)=>
`<div class="d ${i<kp.eingabe.length?'fill':''}">${kp.eingabe[i]??''}</div>`).join('');
}
/* Tastendruck auf dem Keypad verarbeiten */
function kpTaste(k){
if(!kp.aktiv)return;
if(k==="del")kp.eingabe=kp.eingabe.slice(0,-1);
else if(k==="ok"){ if(kp.eingabe.length===KP_STELLEN)kpPruefe(); return; }
else if(kp.eingabe.length<KP_STELLEN)kp.eingabe+=k;
kpRenderDisplay();
}
/* Eingabe mit dem Code vergleichen und Rückmeldung geben */
function kpPruefe(){
const g=kp.eingabe.split("").map(Number);
// Rückmeldung je Stelle: 🟩 richtige Ziffer hier · 🟨 Ziffer kommt vor, aber woanders · ⬛ kommt nicht vor
const marker=g.map((z,i)=> z===kp.code[i] ? "🟩" : (kp.code.includes(z) ? "🟨" : "⬛"));
const exakt=marker.filter(m=>m==="🟩").length;
// Ergebniszeile in die Historie schreiben (Marker steht unter seiner Ziffer)
const zeile=document.createElement("div"); zeile.className="row";
zeile.innerHTML=`<div class="zeile">${g.map((z,i)=>
`<div><div class="z">${z}</div><div class="m">${marker[i]}</div></div>`).join('')}</div>`;
$("kp-hist").prepend(zeile);
if(exakt===KP_STELLEN){kp.aktiv=false;spielEnde(true,"Code "+kp.code.join("")+" geknackt!");return;}
kp.versuche--; $("kp-v").textContent=kp.versuche;
if(kp.versuche<=0){kp.aktiv=false;spielEnde(false,"Keine Versuche mehr — der Code war "+kp.code.join("")+".");return;}
kp.eingabe=""; kpRenderDisplay();
}
/* =====================================================================
GLOBALE TASTATUR (Leertaste für Skill-Check/Lockpick, Ziffern fürs Keypad)
===================================================================== */
addEventListener("keydown",e=>{
if(e.repeat)return;
if(e.code==="Space"){ // Leertaste als Aktions-Taste
// Fokus vom Button nehmen, damit die Leertaste ihn nicht zusätzlich auslöst
if(document.activeElement&&document.activeElement.blur)document.activeElement.blur();
if(sektion==="skillcheck"){e.preventDefault();scAktion();}
else if(sektion==="lockpick"){e.preventDefault();lpAktion();}
} else if(sektion==="keypad"){
if(/^[0-9]$/.test(e.key))kpTaste(e.key);
else if(e.key==="Enter")kpTaste("ok");
else if(e.key==="Backspace")kpTaste("del");
}
});
/* =====================================================================
START
===================================================================== */
renderNav(); sektionen.lobby();
</script>
</body>
</html>